diff --git a/test-tools/addr2line/README.md b/test-tools/addr2line/README.md new file mode 100644 index 0000000000..f283f7752e --- /dev/null +++ b/test-tools/addr2line/README.md @@ -0,0 +1,132 @@ +# `addr2line.py` — symbolicating WAMR call stacks + +`addr2line.py` turns a WAMR call-stack dump (`#00: 0x0040 - $f0` lines) back +into source-level information (function names, files, line numbers, inline +chains). It is a thin orchestrator over four LLVM/wabt tools, with workarounds +for a real LLVM symbolization bug on wasm targets. + +The two `samples/debug-tools*/` samples are end-to-end demos of the workflow. +This README focuses on the tool itself: how it works, why each subprocess is +necessary, and what edge cases force a "legacy" code path. + +## Quick reference + +```bash +python3 addr2line.py \ + --wasi-sdk /opt/wasi-sdk \ + --wabt /opt/wabt \ + --wasm-file /path/to/app.debug.wasm \ + call_stack.txt +``` + +| Flag | Default | Purpose | +|------|---------|---------| +| `--mode={interp,aot,fast-interp}` | `interp` | How to interpret runtime offsets — see *Three offset spaces* below. | +| `--no-addr` | off | Resolve by function name only (for very old iwasm dumps that lack offsets, or for fast-interp). | +| `-v` / `--verbose` | off | Print the resolver-dispatch decision (modern vs legacy) to stderr. | + +## What the script does, step by step + +1. **Parse each captured frame** (`#NN: 0xADDR - SYMBOL`). +2. **Convert the runtime offset to a DWARF address.** WAMR reports + file-absolute byte offsets; DWARF addresses are relative to the start of + the WASM Code section, so the script subtracts `code_section_start` (read + from `wasm-objdump -h`). +3. **Apply a mode-dependent adjustment.** See *Three offset spaces* below. +4. **Resolve the address.** Either the modern resolver (one subprocess) or + the legacy resolver (two subprocesses with a name overlay) — the script + detects the wasi-sdk's clang version and picks one automatically. +5. **Demangle and render.** C++ symbols pass through `llvm-cxxfilt`; inline + frames are annotated `(inlined into )` so the chain reads top-down. + +## Three offset spaces (`--mode`) + +WAMR captures `frame_ip` differently in each execution mode, so the offset in +a captured frame means different things: + +| Mode | Offset space | Adjustment | What addr2line does | +|------|--------------|-----------|----------------------| +| `interp` (default) | File-absolute, *post*-advance — interpreter has already incremented `frame_ip` past the trapping opcode (and past LEB reads inside the handler) before the trap fires. | `offset - code_start - 1` | Full symbolication: file, line, column, inline chain. | +| `aot` | File-absolute, *instruction-start* — `wamrc` emits a `commit_ip` before every WASM operation. | `offset - code_start` | Full symbolication. | +| `fast-interp` | Function-relative, into the *transformed* in-memory bytecode (opcodes replaced with handler indices, LEBs expanded to fixed widths). The mapping back to source is destroyed by the transform. | (none) | Function-name lookup only — equivalent to `--no-addr`. One frame per input; no inline expansion (the `DW_TAG_inlined_subroutine` overlay is address-keyed and unavailable here). | + +Picking the wrong `--mode` lands the resolver inside an adjacent function and +silently produces wrong file/line. + +## The two resolvers, and why both exist + +`llvm-symbolizer -e -f -i ` is the natural way to map an address +to a function + source location, with `-i` expanding inline frames. + +**This subprocess is buggy on wasm targets emitted by clang < 22.** For some +addresses it returns the wrong outermost function name — frequently a +declaration-only DW_TAG_subprogram from wasi-libc that overlaps the real +function's PC range. Concretely, you can ask for the address of `recurse` and +get back `free`; ask for `a()` in a small-trap demo and get back `_start`. +The line table is correct in both cases — only the function-name lookup is +wrong. The bug lives in the symbolizer/addr2line shared backend; switching +between the two binaries doesn't help. + +The bug is fixed in clang 22 (wasi-sdk 33). For older toolchains we +build a `(low_pc, high_pc, name)` interval table from a single +`llvm-dwarfdump --debug-info` pass at startup, and overlay the +**outermost** frame's name from that table — `[low_pc, high_pc)` ranges +parsed straight from DWARF are canonical, regardless of any symbolizer +backend bug. Inner inline frames stay as-reported by the symbolizer: +they come from `DW_TAG_inlined_subroutine` entries that the symbolizer +renders correctly even on the buggy backend. When several intervals +cover the same address (wasi-libc declarations often have +`low_pc=0` and span much of the binary), the **innermost** (smallest +range) wins. + +`addr2line.py` runs `/bin/clang --version` once at startup and: +- clang ≥ 22 → **modern resolver** (one `llvm-symbolizer` subprocess per address). +- clang < 22 → **legacy resolver** (`llvm-symbolizer` + interval-table overlay). +- clang version undetectable → **legacy resolver** (safest fallback). + +Pass `-v` to see which path is taken. The legacy resolver — and the +interval-table helpers `build_subprogram_intervals` / +`lookup_subprogram_name` — can be removed once the project's minimum +supported wasi-sdk is 33+ everywhere. + +## Tools used by addr2line.py + +| Tool | From | Why this tool, and not another | +|------|------|--------------------------------| +| `llvm-symbolizer` | wasi-sdk | The actual address → `(function, file:line:column)` resolver, with inline expansion via `-i`. Ships in wasi-sdk 29+ (the project's minimum supported version). | +| `llvm-dwarfdump` | wasi-sdk | Legacy path only: a single `--debug-info` pass at startup builds a `(low_pc, high_pc, name)` interval table for every `DW_TAG_subprogram`, used to overlay the outermost frame's name (the buggy lookup on clang < 22). No equivalent CLI exposes this as structured output, so we parse the text. | +| `wasm-objdump` | wabt | Reports the Code-section start offset (needed to convert file-absolute runtime offsets to DWARF addresses) and the function-index → name table (a last-resort fallback for declaration-only DW_TAG_subprograms like `__main_void`). | +| `llvm-cxxfilt` | wasi-sdk | C++ symbol demangling, applied as a final pass on resolved names. | + +The script also detects an emscripten-style sourceMappingURL section and +falls back to `emsymbolizer` for that case. + +## Behavior matrix + +| Input | Resolver path | Output | +|-------|---------------|--------| +| `--mode=interp` or `--mode=aot`, clang ≥ 22, address in DWARF range | Modern | Full file:line:column with inline chain | +| `--mode=interp` or `--mode=aot`, clang < 22, address in DWARF range | Legacy | Same, but the outermost name is overlaid from a startup-built `DW_TAG_subprogram` interval table (innermost match wins) | +| `--mode=fast-interp` | Function-name | Single frame per input, no inline expansion, no source mapping | +| `--no-addr` | Function-name | Resolves the *function* — file:line refers to its declaration, not the trap site | +| Offset = 0 | Function-name fallback (with `(offset=0 — function entry)` note) | Used when WAMR couldn't capture `frame_ip` (e.g., trap at function entry, or top frame of an `iwasm -f` invocation) | +| Address outside any DW_TAG_subprogram | wasm-objdump function-index table | Single name; useful for declaration-only entries | +| Source has emscripten `sourceMappingURL` custom section | `emsymbolizer` source-map path | Single frame per address; no inline expansion (source maps don't carry it) | + +## Tests + +Run the pytest suite under `test-tools/addr2line/tests/`. It builds tiny +purpose-built C/C++ apps, captures their trap sites, and asserts the resolver +behaves correctly under a number of scenarios (single trap, always_inline, +deep inline chain, cross-TU LTO inlining, mid-function trap, multi-frame +call stack, C++ demangling, AOT mode, fast-interp mode, `--no-addr`, +`offset=0`, empty input). Multi-SDK runs (`pytest --multi-sdk`) cover the +modern-vs-legacy equivalence and require both an old and a new wasi-sdk to be +installed. + +## See also + +- `test-tools/addr2line/addr2line.py` — the script +- `test-tools/addr2line/tests/` — pytest harness +- `samples/debug-tools/` — minimal end-to-end sample +- `samples/debug-tools-optimized/` — production-realistic workflow (LTO, wasm-opt, strip, AOT, debug companion) diff --git a/test-tools/addr2line/addr2line.py b/test-tools/addr2line/addr2line.py index 800ba37504..52cbf11f24 100644 --- a/test-tools/addr2line/addr2line.py +++ b/test-tools/addr2line/addr2line.py @@ -14,7 +14,7 @@ """ This is a tool to convert addresses, which are from a call-stack dump generated by iwasm, into line info for a wasm file. -When a wasm file is compiled with debug info, it is possible to transfer the address to line info. +When a wasm file is compiled with debug info (-g), it is possible to transfer the address to line info. For example, there is a call-stack dump: @@ -26,27 +26,35 @@ ``` - store the call-stack dump into a file, e.g. call_stack.txt -- run the following command to convert the address into line info: +- run the following command: ``` - $ cd test-tools/addr2line - $ python3 addr2line.py --wasi-sdk --wabt --wasm-file call_stack.txt + $ python3 addr2line.py --wasi-sdk --wabt --wasm-file call_stack.txt ``` - The script will use *wasm-objdump* in wabt to transform address, then use *llvm-dwarfdump* to lookup the line info for each address - in the call-stack dump. -- if addresses are not available in the stack trace (i.e. iwasm <= 1.3.2) or iwasm is used in fast interpreter mode, - run the following command to convert the function index into line info (passing the `--no-addr` option): - ``` - $ python3 addr2line.py --wasi-sdk --wabt --wasm-file call_stack.txt --no-addr - ``` - The script will use *wasm-objdump* in wabt to get the function names corresponding to function indexes, then use *llvm-dwarfdump* to lookup the line info for each - function index in the call-stack dump. + +The script handles two workflows: +- Same-binary: pass the wasm file that ran (must have DWARF debug info) +- Companion: pass a sibling debug companion (.debug.wasm produced by `wasm-opt -Oz -g`). + The production binary that ran can be stripped — the companion has identical code + layout but retains DWARF, so the runtime offsets map correctly. + +Inline expansion (DW_TAG_inlined_subroutine) is automatic — no flag needed. + +By default, offsets are interpreted as coming from classic interpreter mode +(frame_ip has advanced past the trapping opcode). For AOT call stacks, pass +`--mode=aot` so offsets are used verbatim — wamrc commits ip at the start of +each WASM operation, not after. + +For fast-interp call stacks, pass `--mode=fast-interp`. Fast-interp transforms +the bytecode in-memory at load time, so its offsets are meaningless for source +mapping; the script falls back to function-name lookup (same as `--no-addr`). + +If addresses are not available (iwasm <= 1.3.2), pass `--no-addr` to look up +by function index/name only. """ def locate_sourceMappingURL_section(wasm_objdump: Path, wasm_file: Path) -> bool: - """ - Figure out if the wasm file has a sourceMappingURL section. - """ + """Figure out if the wasm file has a sourceMappingURL section.""" cmd = f"{wasm_objdump} -h {wasm_file}" p = subprocess.run( shlex.split(cmd), @@ -55,24 +63,18 @@ def locate_sourceMappingURL_section(wasm_objdump: Path, wasm_file: Path) -> bool text=True, universal_newlines=True, ) - outputs = p.stdout.split(os.linesep) - - for line in outputs: - line = line.strip() - if "sourceMappingURL" in line: + for line in p.stdout.splitlines(): + if "sourceMappingURL" in line.strip(): return True - return False def get_code_section_start(wasm_objdump: Path, wasm_file: Path) -> int: """ - Find the start offset of Code section in a wasm file. + Find the start file offset of the Code section in a wasm file. - if the code section header likes: + Code section header looks like: Code start=0x0000017c end=0x00004382 (size=0x00004206) count: 47 - - the start offset is 0x0000017c """ cmd = f"{wasm_objdump} -h {wasm_file}" p = subprocess.run( @@ -82,155 +84,219 @@ def get_code_section_start(wasm_objdump: Path, wasm_file: Path) -> int: text=True, universal_newlines=True, ) - outputs = p.stdout.split(os.linesep) - - for line in outputs: + for line in p.stdout.splitlines(): line = line.strip() - if "Code" in line: - return int(line.split()[1].split("=")[1], 16) - + if "Code" in line and "start=" in line: + m = re.search(r"start=(0x[0-9a-fA-F]+)", line) + if m: + return int(m.group(1), 16) return -1 -def get_line_info_from_function_addr_dwarf( - dwarf_dump: Path, wasm_file: Path, offset: int -) -> tuple[str, str, str, str]: - """ - Find the location info of a given offset in a wasm file. +def detect_clang_major_version(wasi_sdk: Path): """ - cmd = f"{dwarf_dump} --lookup={offset} {wasm_file}" - p = subprocess.run( - shlex.split(cmd), - check=False, - capture_output=True, - text=True, - universal_newlines=True, - ) - outputs = p.stdout.split(os.linesep) - - function_name, function_file = "", "unknown" - function_line, function_column = "?", "?" - - for line in outputs: - line = line.strip() - - if "DW_AT_name" in line: - function_name = get_dwarf_tag_value("DW_AT_name", line) - - if "DW_AT_decl_file" in line: - function_file = get_dwarf_tag_value("DW_AT_decl_file", line) - - if "Line info" in line: - _, function_line, function_column = parse_line_info(line) - - return (function_name, function_file, function_line, function_column) + Run /bin/clang --version and parse the major version. + Returns the major version as an int (e.g. 22), or None if the binary + can't be run or the version line can't be parsed. Caller should treat + None as "use legacy path" (safest fallback). -def get_dwarf_tag_value(tag: str, line: str) -> str: - # Try extracting value as string - STR_PATTERN = rf"{tag}\s+\(\"(.*)\"\)" - m = re.match(STR_PATTERN, line) - if m: - return m.groups()[0] - - # Try extracting value as integer - INT_PATTERN = rf"{tag}\s+\((\d+)\)" - m = re.match(INT_PATTERN, line) - return m.groups()[0] + Used to decide whether `addr2line.py` runs the modern resolver + (clang >= 22, clean DWARF) or the legacy resolver (clang < 22, + needs llvm-dwarfdump --lookup overlay to work around an LLVM + symbolization bug on wasm targets). + """ + clang = wasi_sdk.joinpath("bin/clang") + if not clang.exists(): + return None + try: + p = subprocess.run( + [str(clang), "--version"], + check=False, + capture_output=True, + text=True, + universal_newlines=True, + ) + except (OSError, subprocess.SubprocessError): + return None + + m = re.search(r"clang version (\d+)\.\d+", p.stdout) + if not m: + return None + return int(m.group(1)) + + +def build_subprogram_intervals(dwarf_dump: Path, wasm_file: Path) -> list: + """ + Return a list of (low_pc, high_pc, name) for every DW_TAG_subprogram + in the wasm's DWARF that has all three attributes. + Used by the legacy resolver to overlay the outermost frame's name — + `llvm-symbolizer` returns wrong names on wasm under clang < 22, but + [low_pc, high_pc) ranges parsed straight from `--debug-info` are + canonical. Computed once per run; lookups against the result are O(N) + in the subprogram count, which is small. -def get_line_info_from_function_name_dwarf( - dwarf_dump: Path, wasm_file: Path, function_name: str -) -> tuple[str, str, str]: - """ - Find the location info of a given function in a wasm file. + See test-tools/addr2line/README.md for why this exists. """ - cmd = f"{dwarf_dump} --name={function_name} {wasm_file}" - p = subprocess.run( + cmd = f"{dwarf_dump} --debug-info {wasm_file}" + raw = subprocess.run( shlex.split(cmd), check=False, capture_output=True, text=True, universal_newlines=True, - ) - outputs = p.stdout.split(os.linesep) + ).stdout + + intervals = [] + sp_indent = -1 + low = high = name = None + + def _flush(): + if low is not None and high is not None and name is not None: + intervals.append((low, high, name)) + + for raw_line in raw.splitlines(): + # llvm-dwarfdump prefixes DIE-tag lines with their offset + # ("0xNNN:") whose width varies; replace with a fixed sentinel so + # the tag-line indent matches attribute-line indent. + body = re.sub(r"^0x[0-9a-fA-F]+:", " " * 11, raw_line) + stripped = body.lstrip() + if not stripped: + continue + indent = len(body) - len(stripped) - function_name, function_file = "", "unknown" - function_line = "?" + if stripped.startswith("DW_TAG_subprogram"): + _flush() + sp_indent = indent + low = high = name = None + continue + if sp_indent < 0: + continue + # A DIE at sp_indent or shallower closes the current subprogram. + if stripped.startswith("DW_TAG_") and indent <= sp_indent: + _flush() + sp_indent = -1 + low = high = name = None + continue + # Only attributes one level deeper than the tag belong to the + # subprogram itself; deeper indents are nested DIEs. + if indent != sp_indent + 2: + continue - for line in outputs: - line = line.strip() + m = re.match(r"DW_AT_low_pc\s+\(0x([0-9a-fA-F]+)\)", stripped) + if m: + low = int(m.group(1), 16) + continue + m = re.match(r"DW_AT_high_pc\s+\(0x([0-9a-fA-F]+)\)", stripped) + if m: + v = int(m.group(1), 16) + # high_pc is rendered as either an absolute address or — for + # DW_FORM_data* — as size-from-low_pc; pick the larger of the + # two interpretations. + high = v if low is None or v >= low else low + v + continue + m = re.match(r'DW_AT_name\s+\("(.+)"\)', stripped) + if m and name is None: + name = m.group(1) - if "DW_AT_name" in line: - function_name = get_dwarf_tag_value("DW_AT_name", line) + _flush() + return intervals - if "DW_AT_decl_file" in line: - function_file = get_dwarf_tag_value("DW_AT_decl_file", line) - if "DW_AT_decl_line" in line: - function_line = get_dwarf_tag_value("DW_AT_decl_line", line) +def lookup_subprogram_name(intervals: list, dwarf_addr: int) -> str: + """ + Return the name of the innermost (smallest-range) DW_TAG_subprogram + whose [low_pc, high_pc) covers `dwarf_addr`, or None. - return (function_name, function_file, function_line) + "Innermost" matters because wasi-libc declarations (low_pc=0, + high_pc spanning much of the binary) overlap real functions; we want + the tightly-bounded one. + """ + best = None + for low, high, name in intervals: + if low <= dwarf_addr < high: + if best is None or (high - low) < (best[1] - best[0]): + best = (low, high, name) + return best[2] if best else None -def get_line_info_from_function_addr_sourcemapping( - emsymbolizer: Path, wasm_file: Path, offset: int -) -> tuple[str, str, str, str]: +def resolve_address_modern( + symbolizer: Path, wasm_file: Path, dwarf_addr: int +) -> list: """ - Find the location info of a given offset in a wasm file which is compiled with emcc. + Modern address resolver for clang >= 22 (wasi-sdk 33+). + + Runs: -e -f -i 0x - {emsymbolizer} {wasm_file} {offset of file} + The symbolizer's address-to-function resolver is correct on wasm + targets emitted by clang 22+, so we don't need the llvm-dwarfdump + --lookup overlay that resolve_address_legacy uses. Single subprocess + per address. - there usually are two lines: - ?? - relative path to source file:line:column + Returns a list of (function_name, file, line, column) tuples. + Each field is a string; line and column may be "?" if unknown. """ - debug_info_source = wasm_file.with_name(f"{wasm_file.name}.map") - cmd = f"{emsymbolizer} -t code -f {debug_info_source} {wasm_file} {offset}" + addr_str = f"0x{dwarf_addr:x}" + cmd = f"{symbolizer} -e {wasm_file} -f -i {addr_str}" p = subprocess.run( shlex.split(cmd), check=False, capture_output=True, text=True, universal_newlines=True, - cwd=Path.cwd(), ) - outputs = p.stdout.split(os.linesep) - - function_name, function_file = "", "unknown" - function_line, function_column = "?", "?" - - for line in outputs: - line = line.strip() + output_lines = [line for line in p.stdout.splitlines() if line.strip()] - if not line: - continue + frames = [] + for i in range(0, len(output_lines) - 1, 2): + func_name = output_lines[i].strip() + location = output_lines[i + 1].strip() - m = re.match(r"(.*):(\d+):(\d+)", line) + # Parse "file:line:column" or "file:line" or "??:0" + m = re.match(r"^(.*):(\d+):(\d+)$", location) if m: - function_file, function_line, function_column = m.groups() - continue + file, line, column = m.group(1), m.group(2), m.group(3) else: - # it's always ??, not sure about that - if "??" != line: - function_name = line + m = re.match(r"^(.*):(\d+)$", location) + if m: + file, line, column = m.group(1), m.group(2), "?" + else: + file, line, column = location, "?", "?" - return (function_name, function_file, function_line, function_column) + frames.append((func_name, file, line, column)) + + return frames -def parse_line_info(line_info: str) -> tuple[str, str, str]: +def resolve_address_legacy( + symbolizer: Path, wasm_file: Path, dwarf_addr: int, + subprogram_intervals: list, +) -> list: """ - line_info -> [file, line, column] + Legacy address resolver for clang < 22 (wasi-sdk < 33). + + Same llvm-symbolizer call as the modern resolver, but unconditionally + overlays the OUTERMOST frame's name from `subprogram_intervals` (built + by `build_subprogram_intervals`). The symbolizer's outermost name is + unreliable on wasm targets under clang < 22; the interval table is + canonical [low_pc, high_pc) data straight from DWARF. Inline frames + keep the symbolizer-reported name — they come from + DW_TAG_inlined_subroutine entries that the symbolizer renders + correctly. + + See test-tools/addr2line/README.md for the underlying LLVM bug. """ - PATTERN = r"Line info: file \'(.+)\', line ([0-9]+), column ([0-9]+)" - m = re.search(PATTERN, line_info) - assert m is not None - - file, line, column = m.groups() - return (file, int(line), int(column)) + frames = resolve_address_modern(symbolizer, wasm_file, dwarf_addr) + if frames: + name = lookup_subprogram_name(subprogram_intervals, dwarf_addr) + if name: + _, file, line, column = frames[-1] + frames[-1] = (name, file, line, column) + return frames -def parse_call_stack_line(line: str) -> tuple[str, str, str]: +def parse_call_stack_line(line: str) -> tuple: """ New format (WAMR > 1.3.2): #00: 0x0a04 - $f18 => (00, 0x0a04, $f18) @@ -241,7 +307,6 @@ def parse_call_stack_line(line: str) -> tuple[str, str, str]: _start (always): #05: 0x011f - _start => (05, 0x011f, _start) """ - # New format and Text format and _start PATTERN = r"#([0-9]+): 0x([0-9a-f]+) - (\S+)" m = re.match(PATTERN, line) @@ -257,7 +322,8 @@ def parse_call_stack_line(line: str) -> tuple[str, str, str]: return None -def parse_module_functions(wasm_objdump: Path, wasm_file: Path) -> dict[str, str]: +def parse_module_functions(wasm_objdump: Path, wasm_file: Path) -> dict: + """Map function index to name from wasm-objdump output.""" function_index_to_name = {} cmd = f"{wasm_objdump} -x {wasm_file} --section=function" @@ -268,16 +334,13 @@ def parse_module_functions(wasm_objdump: Path, wasm_file: Path) -> dict[str, str text=True, universal_newlines=True, ) - outputs = p.stdout.split(os.linesep) - - for line in outputs: - if not f"func[" in line: + for line in p.stdout.splitlines(): + if "func[" not in line: continue - PATTERN = r".*func\[([0-9]+)\].*<(.*)>" m = re.match(PATTERN, line) - assert m is not None - + if m is None: + continue index = m.groups()[0] name = m.groups()[1] function_index_to_name[index] = name @@ -285,6 +348,121 @@ def parse_module_functions(wasm_objdump: Path, wasm_file: Path) -> dict[str, str return function_index_to_name +def get_line_info_from_function_name_dwarf( + dwarf_dump: Path, wasm_file: Path, function_name: str +) -> tuple: + """ + Used by --no-addr mode (fast-interp call stacks, or WAMR <= 1.3.2 + output where addresses are absent). + + Resolves a function name to its declaration's (file, decl_line) by + parsing `llvm-dwarfdump --name=` output: collect every + matching DW_TAG_subprogram block, then prefer a non-sysroot match + over a wasi-libc declaration of the same name (short names like + `b`, `c` frequently collide with wasi-libc helpers). + + TODO: limitation — `llvm-symbolizer` is address-driven and has no + name-to-source mode, so this helper manually parses llvm-dwarfdump + text output. The resolved location is the function's declaration + line, not the call site. A robust replacement would need a + programmatic DWARF reader (e.g. pyelftools) or a future LLVM CLI + exposing name-keyed lookup. + + Returns (function_name, file, line). + """ + cmd = f"{dwarf_dump} --name={function_name} {wasm_file}" + p = subprocess.run( + shlex.split(cmd), + check=False, + capture_output=True, + text=True, + universal_newlines=True, + ) + + # Collect every (name, file, line) triple from the output. Each + # DW_TAG_subprogram block is separated by a blank line. + candidates = [] + cur = {} + for line in p.stdout.splitlines() + [""]: + line = line.strip() + if not line: + if cur: + candidates.append(( + cur.get("name", ""), + cur.get("file", "unknown"), + cur.get("line", "?"), + )) + cur = {} + continue + m = re.match(r'DW_AT_name\s+\("(.+)"\)', line) + if m: + cur["name"] = m.group(1) + continue + m = re.match(r'DW_AT_decl_file\s+\("(.+)"\)', line) + if m: + cur["file"] = m.group(1) + continue + m = re.match(r"DW_AT_decl_line\s+\((\d+)\)", line) + if m: + cur["line"] = m.group(1) + + # Prefer a candidate whose source file isn't from wasi-sysroot / + # wasi-libc / wasi-sdk — short names like `b`, `c` collide with + # internal libc helpers and llvm-dwarfdump's name lookup returns + # every match across the binary. + def _is_sysroot(p): + return "wasi-sysroot" in p or "wasi-libc" in p or p.startswith("wasisdk://") + + for name, file, line in candidates: + if name == function_name and not _is_sysroot(file): + return (name, file, line) + # Fall back to the last candidate (preserves prior behavior when + # all matches are sysroot-ish or the name doesn't match exactly). + if candidates: + return candidates[-1] + return ("", "unknown", "?") + + +def get_line_info_from_function_addr_sourcemapping( + emsymbolizer: Path, wasm_file: Path, offset: int +) -> tuple: + """ + Find the location info of a given offset in a wasm file compiled with emcc. + + {emsymbolizer} {wasm_file} {offset} + + Output is two lines: + ?? + relative path to source:line:column + """ + debug_info_source = wasm_file.with_name(f"{wasm_file.name}.map") + cmd = f"{emsymbolizer} -t code -f {debug_info_source} {wasm_file} {offset}" + p = subprocess.run( + shlex.split(cmd), + check=False, + capture_output=True, + text=True, + universal_newlines=True, + cwd=Path.cwd(), + ) + + function_name, function_file = "", "unknown" + function_line, function_column = "?", "?" + + for line in p.stdout.splitlines(): + line = line.strip() + if not line: + continue + m = re.match(r"(.*):(\d+):(\d+)", line) + if m: + function_file, function_line, function_column = m.groups() + continue + if "??" != line: + function_name = line + + return (function_name, function_file, function_line, function_column) + + def demangle(cxxfilt: Path, function_name: str) -> str: cmd = f"{cxxfilt} -n {function_name}" p = subprocess.run( @@ -297,45 +475,163 @@ def demangle(cxxfilt: Path, function_name: str) -> str: return p.stdout.strip() +def print_frames(index_str: str, frames: list, cxxfilt: Path) -> None: + """ + Print resolved frames with consistent indentation. Inlined functions + (frames before the outermost) get an "(inlined into )" suffix + so the chain relationship is unambiguous. + + Single frame (no inlines): + 0: c + at trap.c:4:5 + + Multiple frames (inline chain — innermost first): + 0: do_bad_access (inlined into trigger_oob) + at oob_access.c:11:5 + trigger_oob (inlined into app_main) + at oob_main.c:17:5 + app_main + at oob_main.c:23:5 + + The first frame uses ":" prefix; subsequent frames use the same + width of leading spaces for vertical alignment. + """ + prefix = f"{index_str}:" + indent = " " * len(prefix) + + demangled_names = [ + demangle(cxxfilt, fn) if fn != "??" else "" + for fn, _, _, _ in frames + ] + + for i, (func_name, file, line, column) in enumerate(frames): + leading = prefix if i == 0 else indent + # All frames except the outermost are inlined into the next one in the chain. + if i < len(frames) - 1: + suffix = f" (inlined into {demangled_names[i + 1]})" + else: + suffix = "" + print(f"{leading} {demangled_names[i]}{suffix}") + # Don't print "??:0:0" — collapse to "??:0". + if file == "??" and line in ("0", "?"): + print("\tat ??:0") + elif column != "?": + print(f"\tat {file}:{line}:{column}") + else: + print(f"\tat {file}:{line}") + + def main(): parser = argparse.ArgumentParser(description="addr2line for wasm") - parser.add_argument("--wasi-sdk", type=Path, help="path to wasi-sdk") - parser.add_argument("--wabt", type=Path, help="path to wabt") - parser.add_argument("--wasm-file", type=Path, help="path to wasm file") + parser.add_argument("--wasi-sdk", type=Path, required=True, help="path to wasi-sdk") + parser.add_argument("--wabt", type=Path, required=True, help="path to wabt") + parser.add_argument("--wasm-file", type=Path, required=True, help="path to wasm file (must have DWARF)") parser.add_argument("call_stack_file", type=Path, help="path to a call stack file") parser.add_argument( "--no-addr", action="store_true", help="use call stack without addresses or from fast interpreter mode", ) + parser.add_argument( + "--mode", + choices=["interp", "aot", "fast-interp"], + default="interp", + help=( + "WAMR execution mode that produced the call stack. " + "interp: classic interpreter, frame_ip is post-advance, subtract 1 " + "from offsets to land on the trapping opcode. " + "aot: wamrc commits ip at the start of each WASM operation, use " + "offsets verbatim. " + "fast-interp: offsets are relative to a transformed in-memory " + "bytecode and cannot be mapped to source line — falls back to " + "function-name lookup (same as --no-addr). " + "Default: interp." + ), + ) parser.add_argument("--emsdk", type=Path, help="path to emsdk") + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="print the dispatch decision (modern vs legacy resolver) to stderr", + ) args = parser.parse_args() + # fast-interp offsets aren't mappable; treat as --no-addr + if args.mode == "fast-interp": + args.no_addr = True + wasm_objdump = args.wabt.joinpath("bin/wasm-objdump") - assert wasm_objdump.exists() + assert wasm_objdump.exists(), f"wasm-objdump not found at {wasm_objdump}" + + # llvm-symbolizer handles address->source resolution and inline-frame + # expansion via `-f -i`. Ships in wasi-sdk 29+, which is the project's + # minimum supported wasi-sdk for the debug-tools samples. + llvm_symbolizer = args.wasi_sdk.joinpath("bin/llvm-symbolizer") + assert llvm_symbolizer.exists(), ( + f"llvm-symbolizer not found at {llvm_symbolizer}; " + f"need wasi-sdk 29 or newer" + ) llvm_dwarf_dump = args.wasi_sdk.joinpath("bin/llvm-dwarfdump") - assert llvm_dwarf_dump.exists() + assert llvm_dwarf_dump.exists(), f"llvm-dwarfdump not found at {llvm_dwarf_dump}" llvm_cxxfilt = args.wasi_sdk.joinpath("bin/llvm-cxxfilt") - assert llvm_cxxfilt.exists() + assert llvm_cxxfilt.exists(), f"llvm-cxxfilt not found at {llvm_cxxfilt}" emcc_production = locate_sourceMappingURL_section(wasm_objdump, args.wasm_file) if emcc_production: if args.emsdk is None: print("Please provide the path to emsdk via --emsdk") return -1 - emsymbolizer = args.emsdk.joinpath("upstream/emscripten/emsymbolizer") assert emsymbolizer.exists() + # Decide which resolver to use based on the detected clang version. + # clang >= 22 (wasi-sdk 33+) has clean DWARF and llvm-symbolizer alone + # is correct. Older clang has a known wasm DWARF symbolization bug + # that the legacy resolver works around via llvm-dwarfdump --lookup. + clang_major = detect_clang_major_version(args.wasi_sdk) + if clang_major is None: + # Always warn on the unknown-version case — the user should know + # the safest fallback was used and may want to investigate. + print( + f"warning: could not detect clang version under {args.wasi_sdk}; " + f"using legacy resolver (with llvm-dwarfdump subprogram-name overlay)", + file=sys.stderr, + ) + use_legacy = True + elif clang_major < 22: + if args.verbose: + print( + f"detected clang {clang_major}.x; using legacy resolver " + f"(llvm-symbolizer has known wasm DWARF bugs in this version)", + file=sys.stderr, + ) + use_legacy = True + else: + if args.verbose: + print( + f"detected clang {clang_major}.x; using modern resolver", + file=sys.stderr, + ) + use_legacy = False + code_section_start = get_code_section_start(wasm_objdump, args.wasm_file) if code_section_start == -1: + print("Could not find Code section in wasm file", file=sys.stderr) return -1 function_index_to_name = parse_module_functions(wasm_objdump, args.wasm_file) - assert args.call_stack_file.exists() + # Build the DW_TAG_subprogram interval table once if the legacy + # resolver will need it; the parse is O(MB of dwarfdump output) and + # we don't want to repeat it per frame. + subprogram_intervals = ( + build_subprogram_intervals(llvm_dwarf_dump, args.wasm_file) + if use_legacy and not emcc_production else [] + ) + + assert args.call_stack_file.exists(), f"call stack file not found: {args.call_stack_file}" with open(args.call_stack_file, "rt", encoding="ascii") as f: for i, line in enumerate(f): line = line.strip() @@ -348,63 +644,126 @@ def main(): continue _, offset, index = splitted + index_str = str(i) + if args.no_addr: # FIXME: w/ emcc production if not index.startswith("$f"): # E.g. _start or Text format - print(f"{i}: {index}") + print(f"{index_str}: {index}") continue - index = index[2:] + func_idx = index[2:] - if index not in function_index_to_name: - print(f"{i}: {line}") + if func_idx not in function_index_to_name: + print(f"{index_str}: {line}") continue if not emcc_production: _, function_file, function_line = ( get_line_info_from_function_name_dwarf( - llvm_dwarf_dump, - args.wasm_file, - function_index_to_name[index], + llvm_dwarf_dump, args.wasm_file, + function_index_to_name[func_idx], ) ) else: - _, function_file, function_line = _, "unknown", "?" + function_file, function_line = "unknown", "?" - function_name = demangle(llvm_cxxfilt, function_index_to_name[index]) - print(f"{i}: {function_name}") + function_name = demangle(llvm_cxxfilt, function_index_to_name[func_idx]) + print(f"{index_str}: {function_name}") print(f"\tat {function_file}:{function_line}") - else: - offset = int(offset, 16) - # match the algorithm in wasm_interp_create_call_stack() - # either a *offset* to *code* section start - # or a *offset* in a file - assert offset > code_section_start - offset = offset - code_section_start - - if emcc_production: - function_name, function_file, function_line, function_column = ( - get_line_info_from_function_addr_sourcemapping( - emsymbolizer, args.wasm_file, offset - ) - ) - else: - function_name, function_file, function_line, function_column = ( - get_line_info_from_function_addr_dwarf( - llvm_dwarf_dump, args.wasm_file, offset - ) - ) + continue + + # Address-based lookup + offset_int = int(offset, 16) - # if can't parse function_name, use name section or - if function_name == "": + # WAMR reports offset 0 when frame_ip wasn't captured (e.g., trap + # at function entry, or top frame of an iwasm -f invocation where + # ip wasn't sync'd before the trap). Fall back to function-name + # lookup for the function header instead of asserting. + if offset_int == 0 or offset_int <= code_section_start: + if not emcc_production: + # Resolve function name from index ($fN) or use the name directly if index.startswith("$f"): - function_name = function_index_to_name.get(index[2:], index) + func_idx = index[2:] + func_name = function_index_to_name.get(func_idx, index) else: - function_name = index + func_name = index + _, function_file, function_line = ( + get_line_info_from_function_name_dwarf( + llvm_dwarf_dump, args.wasm_file, func_name + ) + ) + print(f"{index_str}: {demangle(llvm_cxxfilt, func_name)}") + print(f"\tat {function_file}:{function_line} " + f"(offset=0 — function entry, no inline info)") + continue + # emcc path: just print raw + print(f"{index_str}: {index}") + print(f"\tat unknown:?:? (offset=0 — frame_ip not captured)") + continue + + # Mode-dependent offset adjustment: + # - interp: subtract 1 because frame_ip has advanced past the trapping + # opcode before the trap handler runs (FETCH_OPCODE_AND_DISPATCH + # increments ip before dispatching, plus LEB reads inside handlers). + # - aot: use offset verbatim. wamrc commits ip at the start of each + # WASM operation, before any LEB reads — the offset is already at + # the opcode boundary. + # (fast-interp is handled above by forcing --no-addr; we never reach + # this branch for it.) + adjustment = 1 if args.mode == "interp" else 0 + dwarf_addr = offset_int - code_section_start - adjustment + + if emcc_production: + # Source-map path doesn't support inline expansion; emit single frame. + func_name, file, line, column = ( + get_line_info_from_function_addr_sourcemapping( + emsymbolizer, args.wasm_file, dwarf_addr + ) + ) + # Fall back to function index name if unresolved + if func_name == "" and index.startswith("$f"): + func_name = function_index_to_name.get(index[2:], index) + func_name = demangle(llvm_cxxfilt, func_name) + print(f"{index_str}: {func_name}") + print(f"\tat {file}:{line}:{column}") + continue - function_name = demangle(llvm_cxxfilt, function_name) + if use_legacy: + frames = resolve_address_legacy( + llvm_symbolizer, args.wasm_file, dwarf_addr, + subprogram_intervals, + ) + else: + frames = resolve_address_modern( + llvm_symbolizer, args.wasm_file, dwarf_addr + ) + + if not frames: + # Fall back to function index name + if index.startswith("$f"): + func_name = function_index_to_name.get(index[2:], index) + else: + func_name = index + print(f"{index_str}: {demangle(llvm_cxxfilt, func_name)}") + print(f"\tat unknown:?:?") + continue - print(f"{i}: {function_name}") - print(f"\tat {function_file}:{function_line}:{function_column}") + # Last-resort fallback for the outermost frame: when neither + # llvm-symbolizer nor the dwarfdump subprogram-name overlay + # produced a clean name (e.g. the address is in a function + # whose DWARF has no PC range — declaration-only + # DW_TAG_subprogram), substitute the function index -> name + # from wasm-objdump. + if index.startswith("$f"): + expected = function_index_to_name.get(index[2:]) + actual = frames[-1][0] + if expected and ( + actual == "??" + or (actual != expected and frames[-1][1] in ("??", "unknown")) + ): + frames[-1] = (expected,) + frames[-1][1:] + + print_frames(index_str, frames, llvm_cxxfilt) return 0