blob: 7c5ad5abe2ac16a035042cb621f5afa50bcad602 [file]
// ===========================================================================
// Overview (plain language)
// ===========================================================================
// Problem: when gbmcweb hits an uncaught C++ exception it crashes, but by the
// time the crash handler runs the stack has already unwound, so the usual
// backtrace points at framework code (asio / sdbusplus re-throwing a stashed
// exception) instead of the line that actually threw. That makes fleet crashes
// very hard to debug.
//
// Trick: every C++ `throw` funnels through one libstdc++ function, __cxa_throw.
// We define our own __cxa_throw in the main binary, so the linker makes the
// whole process (libstdc++ included) call ours. On each throw we snapshot the
// call stack *before* it unwinds, tag it with the exception object's address,
// and stash it in a small fixed ring. If the program later dies from an
// uncaught exception, our std::terminate handler finds that exception's
// snapshot and logs it -- the real throw site.
//
// How the pieces fit:
// * Capture: backtrace() returns a list of raw code addresses ("here, called
// from here, ..."), with no names.
// * Store: a 64-slot circular buffer (the newest throw overwrites the oldest)
// with no memory allocation. Each slot is tagged with the exception's
// address. A lock-free seqlock lets the crash handler read a slot while
// other threads keep throwing, without locks and without ever trusting a
// half-written entry.
// * Report at crash: look up the dying exception's snapshot and print each
// address as "module + hex offset".
// * Decode later: a developer runs the standard `addr2line` tool on the
// matching debug binary to turn "module + offset" into file:line. We do not
// resolve names at crash time -- that needs locks/allocation and the
// process may be corrupt -- so at startup we snapshot once which address
// ranges belong to which loaded binary.
//
// Why so defensive (no allocation, no locks, no re-entry): the handler runs
// when the process may already be corrupt; anything that could block, allocate,
// or crash again would lose the log entirely.
//
// Scope: built only in the gcc/yocto firmware image, since it relies on GNU
// libstdc++ exception internals; other builds compile it to "unavailable". It
// works on x86-64, ARM64 and ARM32 (it uses the standard stack unwinder, not a
// CPU-specific hack, so -fomit-frame-pointer and optimization levels do not
// affect it).
//
// The links below point to the specs and mechanisms this file relies on; the
// rationale now lives as comments next to the code each point describes.
// ===========================================================================
// References (formats and mechanisms this file depends on):
// - Itanium C++ ABI, Exception Handling — __cxa_throw, the __cxa_exception
// header layout, __cxa_current_exception_type, and the exception_ptr /
// primary-object model this code keys on:
// https://itanium-cxx-abi.github.io/cxx-abi/abi-eh.html
// - folly exception_tracer — the __cxa_throw interposer this file reduces to a
// single self-contained TU:
// https://github.com/facebook/folly/blob/main/folly/debugging/exception_tracer/ExceptionTracerLib.cpp
// - glibc backtrace(3) — the throw-site stack capture primitive:
// https://man7.org/linux/man-pages/man3/backtrace.3.html
// - dl_iterate_phdr(3) — source of the ELF program-header module table
// snapshotted at install time:
// https://man7.org/linux/man-pages/man3/dl_iterate_phdr.3.html
// - Seqlock — the lock-free single-writer scheme each ring slot uses:
// https://www.kernel.org/doc/html/latest/locking/seqlock.html
// - Unwind tables consumed by backtrace():
// DWARF .eh_frame (ARM64/x86-64) — LSB Core, Exception Frames:
// https://refspecs.linuxfoundation.org/LSB_5.0.0/LSB-Core-generic/LSB-Core-generic/ehframechpt.html
// ARM32 EHABI .ARM.exidx — Exception Handling ABI for the Arm Arch:
// https://github.com/ARM-software/abi-aa/blob/main/ehabi32/ehabi32.rst
// ARM64 frame-record chain (x29/x30), AAPCS64:
// https://github.com/ARM-software/abi-aa/blob/main/aapcs64/aapcs64.rst
#include "exception_stack_tracer.hpp"
#include "exception_stack_tracer_internal.hpp"
#include <cxxabi.h>
#include <dlfcn.h>
#include <execinfo.h>
#include <link.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <exception>
#include <typeinfo>
#include "absl/base/internal/raw_logging.h"
namespace {
// Frame 0 is the interposer itself, so ~9 caller frames survive; enough to
// clear the __throw_* helper and land in application code.
constexpr int kMaxFrames = 10;
constexpr uint32_t kRingSize = 64;
constexpr size_t kMaxModules = 64;
constexpr size_t kModuleNameSize = 40;
// A captured call stack as written by backtrace(): an array of up to
// kMaxFrames return addresses (code pointers / program counters), innermost
// frame first. Each entry is an opaque instruction address, later resolved to
// module + offset for offline addr2line; it is never dereferenced.
using FrameArray = std::array<void*, kMaxFrames>;
// A throw-site stack copied out of the ring for the terminate handler to print.
struct ThrowRecordSnapshot {
const std::type_info* type = nullptr;
int depth = 0;
FrameArray frames{};
};
// ---------------------------------------------------------------------------
// ThrowSiteRing: a lock-free ring of throw-site stacks.
//
// Writers are the throwing threads (via __cxa_throw); the single reader is the
// terminate handler. Each slot is published as a seqlock: `object` is the
// sequence word, stored last with release order and cleared first by a writer,
// so a reader trusts a copy only if `object` matched before and after.
// ---------------------------------------------------------------------------
class ThrowSiteRing {
public:
void setEnabled(bool enable) {
enabled_.store(enable, std::memory_order_relaxed);
}
// Writer, called from __cxa_throw before the unwinder runs: at catch time the
// throw-site frames no longer exist.
void record(void* object, const std::type_info* type) {
if (!enabled_.load(std::memory_order_relaxed)) {
return;
}
// backtrace() writes up to kMaxFrames return addresses into `frames` and
// returns how many it wrote (`depth`), innermost frame first.
FrameArray frames{};
int depth = backtrace(frames.data(), kMaxFrames);
nextSlot().publish(object, type, frames, depth);
}
// Reader: scan newest-first so that after wraparound a reused object address
// resolves to the live exception's record, not an older evicted throw.
bool lookup(void* object, ThrowRecordSnapshot& out) const {
if (object == nullptr) {
return false;
}
uint32_t end = next_.load(std::memory_order_acquire);
for (uint32_t i = 0; i < kRingSize; ++i) {
const Slot& slot = slots_[(end - 1 - i) % kRingSize];
switch (slot.tryRead(object, out)) {
case Slot::ReadResult::kNoMatch:
continue;
case Slot::ReadResult::kCopied:
return true;
case Slot::ReadResult::kEvicted:
// The address matched but the slot was rewritten during the copy.
// The caller's exception_ptr pins `object` alive, so no concurrent
// throw can record this address; a rewrite therefore means this slot
// was reused by an unrelated throw and the record is gone. Report it
// evicted rather than matching a stale older same-address slot.
return false;
}
}
return false;
}
// Test-only: re-enable recording and drop all records.
void reset() {
enabled_.store(true, std::memory_order_relaxed);
next_.store(0, std::memory_order_relaxed);
for (Slot& slot : slots_) {
slot.invalidate();
}
}
private:
class Slot {
public:
enum class ReadResult { kNoMatch, kCopied, kEvicted };
// Writer half of the seqlock: invalidate, fill the payload, then publish
// `object` last so a concurrent reader sees either the old value, a
// no-match sentinel, or the fully written record — never a torn mix.
void publish(void* object, const std::type_info* type,
const FrameArray& frames, int depth) {
object_.store(nullptr, std::memory_order_relaxed);
// A release *store* only keeps preceding writes above it; it does not stop
// the relaxed payload writes below from being hoisted above the
// invalidation. This release fence is the store-store barrier that keeps
// the object_=nullptr invalidation ordered before the payload writes, so a
// reader still seeing the old object during an eviction can never observe
// a torn payload and revalidate it as kCopied.
std::atomic_thread_fence(std::memory_order_release);
type_.store(type, std::memory_order_relaxed);
depth_.store(depth, std::memory_order_relaxed);
for (size_t i = 0; i < kMaxFrames; ++i) {
frames_[i].store(frames[i], std::memory_order_relaxed);
}
object_.store(object, std::memory_order_release);
}
// Reader half: copy the payload iff the slot holds `object`, then report
// whether the sequence word still matches (kCopied) or was rewritten
// mid-copy (kEvicted). Payload fields are atomic so the mid-copy read that
// kEvicted discards is well defined rather than a data race.
ReadResult tryRead(void* object, ThrowRecordSnapshot& out) const {
if (object_.load(std::memory_order_acquire) != object) {
return ReadResult::kNoMatch;
}
out.type = type_.load(std::memory_order_relaxed);
out.depth = sanitizedDepth(depth_.load(std::memory_order_relaxed));
for (size_t i = 0; i < kMaxFrames; ++i) {
out.frames[i] = frames_[i].load(std::memory_order_relaxed);
}
// Symmetric to publish()'s release fence. An acquire *load* on object_
// only orders operations that follow it; it would not stop the relaxed
// payload loads above from being reordered *after* the revalidation, so a
// slot rewritten mid-copy could be revalidated as kCopied over a torn
// payload. This acquire fence is the load-load barrier that keeps the
// payload reads ordered before the revalidation load, so a mid-copy
// rewrite is reliably detected as kEvicted. With the fence in place the
// revalidation load itself only needs to be relaxed.
std::atomic_thread_fence(std::memory_order_acquire);
return object_.load(std::memory_order_relaxed) == object
? ReadResult::kCopied
: ReadResult::kEvicted;
}
void invalidate() { object_.store(nullptr, std::memory_order_relaxed); }
private:
// A valid frame count is 0..kMaxFrames; anything else can only come from a
// torn read (later rejected by revalidation), so map it to 0 -- show no
// frames rather than a bogus count.
static int sanitizedDepth(int depth) {
return (depth < 0 || depth > kMaxFrames) ? 0 : depth;
}
std::atomic<void*> object_{nullptr};
std::atomic<const std::type_info*> type_{nullptr};
std::atomic<int> depth_{0};
std::array<std::atomic<void*>, kMaxFrames> frames_{};
};
Slot& nextSlot() {
return slots_[next_.fetch_add(1, std::memory_order_relaxed) % kRingSize];
}
// Kill switch (--exception_stack_tracer=false): recording is a no-op and the
// default terminate handler stays installed.
std::atomic<bool> enabled_{true};
std::atomic<uint32_t> next_{0};
std::array<Slot, kRingSize> slots_{};
};
// ---------------------------------------------------------------------------
// ModuleTable: maps a runtime code address back to (module, in-file offset).
//
// backtrace() gives us raw runtime addresses. To symbolize them offline we need
// two things per address: which binary it came from, and its offset within that
// binary's file (that is what `addr2line -e <file> <offset>` consumes).
//
// Why we keep our own table instead of resolving at crash time:
// The normal way to map an address to its module is dladdr(), but dladdr
// takes the dynamic loader lock and may allocate -- both forbidden in the
// terminate handler, which runs under suspected corruption and must never
// block or allocate. So we walk the loaded modules once at install time,
// while the process is healthy, and cache exactly what we need in a
// fixed-size table the handler can scan with plain array reads: no locks, no
// allocation, no loader calls. This stays correct because bmcweb does not
// dlopen anything after startup, so the module map never changes.
//
// What the table holds:
// One ModuleInfo row per loaded module (the executable and each .so). Each
// row records that module's runtime address range (to test which module a
// frame falls in), its load bias (to convert the frame to an in-file offset),
// and its short name (for the log line) -- everything needed to turn a raw
// address into "module + offset".
//
// ELF background (how a program is laid out in memory, i.e. where those numbers
// come from):
// * A "module" is one ELF file mapped into the process: the main executable
// plus every shared library (.so) it loaded.
// * Each ELF file carries a "program header" table describing how the loader
// should map it. Each entry is a segment; the ones with type PT_LOAD are
// the chunks of code/data actually copied into memory (other types hold
// dynamic-linking metadata, notes, etc., and take up no code space).
// * Addresses inside the file are recorded relative to a link-time base of 0
// (for position-independent code -- PIEs and all .so files). At load time
// ASLR places the module at a random base address; the loader then shifts
// every address by a fixed amount called the "load bias".
// runtime address = load bias + in-file address
// * So a PT_LOAD segment lives at runtime in
// [ bias + p_vaddr , bias + p_vaddr + p_memsz )
// where p_vaddr is the segment's in-file address and p_memsz its size in
// memory. Unioning all PT_LOAD segments gives the module's whole runtime
// address range (ModuleInfo::begin/end).
// * To symbolize a frame we reverse the shift: in-file offset = addr - bias.
//
// The snapshot is taken with dl_iterate_phdr (see loadedRange below).
// ---------------------------------------------------------------------------
// One row of the table: a single loaded module (the executable or one .so), and
// everything the terminate handler needs to place and symbolize an address that
// falls inside it.
struct ModuleInfo {
// Load bias (dl_phdr_info::dlpi_addr): the address a position-independent
// object was mapped at. `frame - bias` is the in-file offset addr2line wants.
uintptr_t bias = 0;
// Half-open runtime address range [begin, end) the module occupies, used to
// match a captured frame to its module.
uintptr_t begin = 0;
uintptr_t end = 0;
// Module basename ("exe" for the main program), NUL-terminated.
std::array<char, kModuleNameSize> name{};
// Copy `n` into the fixed buffer, always leaving it NUL-terminated.
void setName(const char* n) {
std::strncpy(name.data(), n, kModuleNameSize - 1);
name[kModuleNameSize - 1] = '\0';
}
};
class ModuleTable {
public:
// Snapshot the currently loaded modules. Call once, at install time.
void snapshot() { dl_iterate_phdr(&phdrCallback, this); }
// Find the module containing `addr`, or nullptr if none does.
const ModuleInfo* resolve(uintptr_t addr) const {
size_t modules = count_.load(std::memory_order_acquire);
for (size_t m = 0; m < modules; ++m) {
if (addr >= modules_[m].begin && addr < modules_[m].end) {
return &modules_[m];
}
}
return nullptr;
}
void reset() { count_.store(0, std::memory_order_relaxed); }
private:
static int phdrCallback(dl_phdr_info* info, size_t /*size*/, void* self) {
return static_cast<ModuleTable*>(self)->addModule(*info);
}
// A half-open [begin, end) address range, accumulated across PT_LOAD
// segments. Starts empty (begin > end) so min/max fold correctly.
struct AddressRange {
uintptr_t begin = UINTPTR_MAX;
uintptr_t end = 0;
bool empty() const { return end == 0; }
};
// dl_iterate_phdr hands us one dl_phdr_info per loaded ELF object (the main
// program and each shared library). The fields we use:
// dlpi_addr - load bias: the base address the object was mapped at.
// dlpi_name - the object's file path ("" for the main program).
// dlpi_phdr - array of the object's ELF program headers (ElfW(Phdr),
// dlpi_phnum which is Elf32/Elf64 chosen for the target).
// Each PT_LOAD program header describes a segment actually mapped into
// memory; its runtime span is [dlpi_addr + p_vaddr, + p_memsz). Unioning the
// PT_LOAD segments yields the whole object's occupied address range.
static AddressRange loadedRange(const dl_phdr_info& info) {
AddressRange range;
for (int i = 0; i < info.dlpi_phnum; ++i) {
// NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic)
const ElfW(Phdr)& phdr = info.dlpi_phdr[i];
if (phdr.p_type != PT_LOAD) {
continue; // only PT_LOAD segments are mapped into memory
}
uintptr_t segmentBegin = info.dlpi_addr + phdr.p_vaddr;
uintptr_t segmentEnd = segmentBegin + phdr.p_memsz;
range.begin = std::min(range.begin, segmentBegin);
range.end = std::max(range.end, segmentEnd);
}
return range;
}
// Reduce a module path to its basename for compact logging; the main
// program's dlpi_name is empty, so report it as "exe".
static const char* moduleBasename(const char* path) {
if (path == nullptr || path[0] == '\0') {
return "exe";
}
const char* slash = std::strrchr(path, '/');
// NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic)
return slash != nullptr ? slash + 1 : path;
}
// dl_iterate_phdr callback. Returns 1 to stop iteration once the table is
// full, 0 to continue.
int addModule(const dl_phdr_info& info) {
size_t count = count_.load(std::memory_order_relaxed);
if (count >= kMaxModules) {
return 1;
}
AddressRange range = loadedRange(info);
if (range.empty()) {
return 0; // no PT_LOAD segments (e.g. a linker placeholder); skip
}
ModuleInfo& m = modules_[count];
m.bias = info.dlpi_addr;
m.begin = range.begin;
m.end = range.end;
m.setName(moduleBasename(info.dlpi_name));
count_.store(count + 1, std::memory_order_release);
return 0;
}
// The snapshot: modules_[0, count_) are the loaded modules captured at
// install time. Fixed capacity so no allocation is ever needed; count_ is
// published with release so resolve() sees fully-written rows.
std::array<ModuleInfo, kMaxModules> modules_{};
std::atomic<size_t> count_{0};
};
// The two process-wide tables. constinit guarantees constant initialization,
// so they are ready before any static constructor (or early throw) runs.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
constinit ThrowSiteRing gThrowRing{};
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
constinit ModuleTable gModuleTable{};
// Returns the address that identifies the in-flight exception -- the same
// pointer __cxa_throw recorded -- which the handler uses to look up its stack.
// On libstdc++ (GNU), a std::exception_ptr is exactly one pointer to that
// object, so we read it out directly (layout checked by static_assert). On any
// other C++ runtime (e.g. clang/libc++) we don't know the layout, so we return
// nullptr; the handler then just reports "stack unavailable" and everything
// else keeps working.
#if defined(__GLIBCXX__)
void* primaryExceptionObject(const std::exception_ptr& p) {
static_assert(sizeof(std::exception_ptr) == sizeof(void*),
"libstdc++ exception_ptr layout assumption violated");
void* object = nullptr;
std::memcpy(&object, &p, sizeof(object));
return object;
}
#else
void* primaryExceptionObject(const std::exception_ptr& /*p*/) {
return nullptr; // unknown exception_ptr layout: lookups report unavailable
}
#endif
void logResolvedFrames(const FrameArray& frames, int depth) {
for (int i = 0; i < depth; ++i) {
// NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast)
const auto addr = reinterpret_cast<uintptr_t>(frames[static_cast<size_t>(i)]);
const ModuleInfo* module = gModuleTable.resolve(addr);
if (module != nullptr) {
// Module-relative offset: symbolizable offline against the matching
// .debug file (addr2line -e) even for a stripped PIE.
ABSL_RAW_LOG(ERROR, " #%02d %s+0x%lx", i, module->name.data(),
static_cast<unsigned long>(addr - module->bias));
} else {
ABSL_RAW_LOG(ERROR, " #%02d [0x%lx]", i,
static_cast<unsigned long>(addr));
}
}
}
[[noreturn]] void terminateWithThrowStack() {
// If anything below terminates again (a throwing what(), a second uncaught
// exception on another thread), die immediately instead of recursing.
// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables)
static std::atomic<bool> entered{false};
if (entered.exchange(true, std::memory_order_relaxed)) {
std::abort();
}
exception_stack_tracer_internal::logThrowSiteStackOfCurrentException();
std::abort();
}
} // namespace
namespace exception_stack_tracer_internal {
void logThrowSiteStackOfCurrentException() {
const std::type_info* type = abi::__cxa_current_exception_type();
if (type == nullptr) {
ABSL_RAW_LOG(ERROR, "terminate: called without an active exception");
return;
}
ABSL_RAW_LOG(ERROR, "terminate: uncaught exception of type %s", type->name());
// __cxa_call_terminate (or a test's active catch) began the catch, so the
// in-flight exception is rethrowable in place. Unlike std::rethrow_exception
// this allocates nothing (same technique as libstdc++'s
// __verbose_terminate_handler).
try {
throw;
} catch (const std::exception& e) {
// A user-defined what() override may return nullptr; passing that to %s is
// undefined behaviour, so fall back to a literal (mirrors the type-name
// guard below).
const char* what = e.what();
ABSL_RAW_LOG(ERROR, " what(): %s", what != nullptr ? what : "(null)");
} catch (...) {
}
// For a native exception this only bumps a refcount on the existing object
// — no allocation. It also pins the object for the ring's revalidation.
if (std::exception_ptr eptr = std::current_exception()) {
ThrowRecordSnapshot snapshot;
if (gThrowRing.lookup(primaryExceptionObject(eptr), snapshot)) {
ABSL_RAW_LOG(ERROR, "throw-site stack (recorded as %s):",
snapshot.type != nullptr ? snapshot.type->name() : "?");
logResolvedFrames(snapshot.frames, snapshot.depth);
} else {
ABSL_RAW_LOG(ERROR,
"throw-site stack unavailable (ring evicted, foreign "
"exception, or non-libstdc++ runtime)");
}
}
}
void resetForTest() {
gThrowRing.reset();
gModuleTable.reset();
}
} // namespace exception_stack_tracer_internal
// Called once from run() at startup. Besides the one-time setup below, this
// explicit call is also what keeps this file in the binary: with no reference to
// it, the linker's --gc-sections would discard the whole translation unit -- and
// our __cxa_throw override along with it. enable=false (the
// --exception_stack_tracer kill switch) leaves the default terminate handler in
// place and turns recording into a no-op.
void installExceptionStackTracer(bool enable) {
if (!enable) {
gThrowRing.setEnabled(false);
return;
}
// Snapshot the module table and force glibc's lazy libgcc unwinder load now
// so the throw path and the terminate path never allocate, dlopen, or take
// the loader lock.
gModuleTable.snapshot();
std::array<void*, 2> warm{};
backtrace(warm.data(), 2);
std::set_terminate(terminateWithThrowStack);
}
// Our replacement for the C++ runtime's throw entry point. Every `throw` in the
// process -- including libstdc++'s own __throw_* helpers and throws originating
// in other shared objects -- calls this function, because our definition is
// exported from the main executable (default visibility + a linker
// --dynamic-list entry for __cxa_throw; see meson.build) and the executable is
// searched first in the global symbol scope, so it interposes the library's.
// We record the throw site, then forward to the real __cxa_throw (found via
// dlsym(RTLD_NEXT, ...)) so throwing behaves exactly as normal.
//
// NOTE: exporting this symbol is what makes cross-module capture work. Without
// it (e.g. built LOCAL under -fvisibility=hidden), only throws whose __cxa_throw
// call site is compiled into the executable are recorded; throws executing
// inside libstdc++ or any other .so bind to the library's own __cxa_throw and
// are missed.
extern "C" [[gnu::visibility("default")]] void __cxa_throw(
void* thrown_object, std::type_info* tinfo, void (*dest)(void*)) {
gThrowRing.record(thrown_object, tinfo);
using ThrowFn = void (*)(void*, std::type_info*, void (*)(void*));
// NOLINTBEGIN(cppcoreguidelines-pro-type-reinterpret-cast)
static const ThrowFn realCxaThrow =
reinterpret_cast<ThrowFn>(dlsym(RTLD_NEXT, "__cxa_throw"));
// NOLINTEND(cppcoreguidelines-pro-type-reinterpret-cast)
realCxaThrow(thrown_object, tinfo, dest);
__builtin_unreachable();
}