diff --git a/monitor/README_STANDALONE.md b/monitor/README_STANDALONE.md new file mode 100644 index 000000000..7611ba9b0 --- /dev/null +++ b/monitor/README_STANDALONE.md @@ -0,0 +1,156 @@ +# ESP32 Exception Decoder - Standalone Mode + +This tool decodes ESP32 crash logs (exception backtraces) using the firmware ELF file to provide human-readable function names and source code locations. + +## Features + +- **Offline Operation**: Works without an active project +- **Auto-Detection**: Automatically detects architecture (RISC-V or Xtensa) +- **Auto-Discovery**: Finds toolchain binaries (addr2line, GDB) automatically +- **Multiple Architectures**: Supports ESP32, ESP32-S2, ESP32-S3, ESP32-C3, ESP32-C6, ESP32-H2, etc. +- **Detailed Decoding**: Decodes register dumps, stack memory, and backtraces + +## Requirements + +- Python 3.10 or higher +- ESP-IDF toolchain installed (default installed from pioarduino) +- Optional: `pyelftools` for improved address filtering (default installed from pioarduino) + +## Installation + +Done by using `pioarduino - platform espressif32`! Just use the script directly: + +```bash +python3 filter_exception_decoder.py +``` + +## Usage + +### Basic Usage + +Decode a crash log and print to stdout: + +```bash +python3 filter_exception_decoder.py firmware.elf crash.txt +``` + +### Save to File + +Decode and save the output to a file: + +```bash +python3 filter_exception_decoder.py firmware.elf crash.txt -o decoded.txt +``` + +### With Full Paths + +```bash +python3 filter_exception_decoder.py /path/to/firmware.elf /path/to/crash.log --output result.txt +``` + +### Help + +```bash +python3 filter_exception_decoder.py --help +``` + +## How It Works + +1. **Architecture Detection**: Reads the ELF header to determine if the firmware is RISC-V or Xtensa +2. **Toolchain Discovery**: Searches for `addr2line` and `gdb` in `~/.platformio/packages/toolchain-*/bin/` +3. **Address Decoding**: Uses `addr2line` to convert memory addresses to function names and source locations +4. **Stack Unwinding**: For RISC-V, optionally uses GDB for complete stack unwinding + +## Output Format + +The tool adds decoded information inline with the original crash log: + +```text +Core 0 register dump: +MEPC : 0x4080c1aa RA : 0x4080c16e SP : 0x40823630 GP : 0x40813ae4 + MEPC: 0x4080c1aa: panic_abort at panic.c:491 + RA: 0x4080c16e: esp_vApplicationTickHook at freertos_hooks.c:31 + +Stack memory: +40823630: 0x4082376c 0x00000067 0x42090f54 0x4081107c ... + 0x4081107c: esp_libc_include_assert_impl at assert.c:96 +``` + +## Toolchain Installation + +### Via pioarduino + +No manual install needed, pioarduino does install everything automatically. + +## Troubleshooting + +### "addr2line tool not found" + +The toolchain is not installed or not in the search path. Install via: +- (re)install pioarduino / platform espressif32 + +### "ELF file not found" + +Check that the path to the firmware ELF file is correct. The ELF file is typically: +- pioarduino: `.pio/build//firmware.elf` + +### "Crash log file not found" + +Ensure the crash log file path is correct. You can capture crash logs via: +- Serial monitor output +- pioarduino monitor +- ESP-IDF monitor + +## Examples + +### Example 1: Quick Decode + +```bash +python3 filter_exception_decoder.py firmware.elf crash.txt +``` + +### Example 2: Decode with Output File + +```bash +python3 filter_exception_decoder.py \ + .pio/build/esp32c6/firmware.elf \ + crash_log.txt \ + -o decoded_crash.txt +``` + +### Example 3: Pipe from Serial Monitor + +```bash +# Capture crash log from serial port +cat /dev/ttyUSB0 > crash.txt + +# Decode it +python3 filter_exception_decoder.py firmware.elf crash.txt +``` + +## Advanced Features + +### ELF Section Filtering + +When `pyelftools` is installed, the tool filters addresses by checking if they fall into executable ELF sections, avoiding unnecessary decoding of data addresses. + +### GDB Stack Unwinding (RISC-V only) + +For RISC-V targets, if GDB is available, the tool can perform complete stack unwinding to show the full call chain. + +## Supported Chips + +- ESP32 (Xtensa) +- ESP32-S2 (Xtensa) +- ESP32-S3 (Xtensa) +- ESP32-C2 (RISC-V) +- ESP32-C3 (RISC-V) +- ESP32-C5 (RISC-V) +- ESP32-C6 (RISC-V) +- ESP32-H2 (RISC-V) +- ESP32-H4 (RISC-V) +- ESP32-P4 (RISC-V) + +## License + +Apache License 2.0 diff --git a/monitor/filter_exception_decoder.py b/monitor/filter_exception_decoder.py index 43a8f7fd3..7e659921f 100644 --- a/monitor/filter_exception_decoder.py +++ b/monitor/filter_exception_decoder.py @@ -27,18 +27,29 @@ from collections import deque from pathlib import Path -# This file serves two roles: +# This file serves three roles: # # 1. As a PlatformIO monitor filter (loaded by PlatformIO, class # Esp32ExceptionDecoder is instantiated, rx() processes serial data). # # 2. As a standalone GDB RSP server (launched by GDB via # "target remote | python -u --rsp-server "). -# In this mode PlatformIO packages are not on sys.path, so the -# imports below are skipped to avoid ImportError. -_RSP_SERVER_MODE = len(sys.argv) >= 2 and sys.argv[1] == "--rsp-server" +# +# 3. As a standalone CLI tool for offline crash log decoding. +# +# In modes 2 and 3, PlatformIO packages are not required, so imports +# are guarded to avoid ImportError. -if not _RSP_SERVER_MODE: +_RSP_SERVER_MODE = len(sys.argv) >= 2 and sys.argv[1] == "--rsp-server" +# CLI mode: has arguments and first arg looks like a file path or option (no filesystem check) +_CLI_MODE = (len(sys.argv) >= 2 and + sys.argv[1] not in ("--rsp-server",) and + (sys.argv[1].startswith("-") or "/" in sys.argv[1] or "\\" in sys.argv[1])) +# Standalone mode: running as main script, RSP server, or CLI mode +_STANDALONE_MODE = (__name__ == "__main__") or _RSP_SERVER_MODE or _CLI_MODE + +if not _STANDALONE_MODE: + # PlatformIO monitor filter mode - import dependencies from platformio.package.manager.tool import ToolPackageManager from platformio.compat import IS_WINDOWS from platformio.exception import PlatformioException @@ -47,9 +58,19 @@ load_build_metadata, ) else: - # Minimal shims so the module can be parsed without PlatformIO. + # Standalone mode - create minimal shims IS_WINDOWS = sys.platform == "win32" DeviceMonitorFilterBase = object + + class PlatformioException(Exception): + pass + + class ToolPackageManager: + def get_package(self, name): + return None + + def load_build_metadata(project_dir, environment, cache=True): + raise PlatformioException("Not available in standalone mode") try: from elftools.elf.elffile import ELFFile @@ -341,6 +362,7 @@ def get_chip_name(self, data): """ sorted_chips = sorted(self.CHIP_NAME_MAP.keys(), key=len, reverse=True) env_section = "env:" + self.environment + board_mcu = None try: board_name = self.config.get(env_section, "board") if board_name: @@ -554,6 +576,59 @@ def _find_riscv_gdb(self): % self.__class__.__name__ ) + def _find_toolchain_in_path(self, tool_names): + """Search for a toolchain binary in common locations. + + Args: + tool_names: List of possible binary names to search for + + Returns: + Path to the tool if found, None otherwise + """ + # Try using ToolPackageManager first (PlatformIO mode) + try: + pm = ToolPackageManager() + for tool_name in tool_names: + # Derive package name from tool name + # e.g., "riscv32-esp-elf-addr2line" -> "toolchain-riscv32-esp-elf" + # e.g., "xtensa-esp32-elf-addr2line" -> "toolchain-xtensa-esp32-elf" + base_name = tool_name.rsplit("-", 1)[0] # Remove last part (addr2line, gdb, etc.) + pkg_name = "toolchain-" + base_name + + pkg = pm.get_package(pkg_name) + if pkg and pkg.path: + tool_bin = str(Path(pkg.path) / "bin" / tool_name) + if IS_WINDOWS: + tool_bin += ".exe" + if os.path.isfile(tool_bin): + return tool_bin + except (PlatformioException, OSError, AttributeError): + # Fall back to manual search if ToolPackageManager is not available + pass + + # Fallback: Search in PlatformIO packages directory manually + # Use the same logic as ToolPackageManager mode to find the correct toolchain + home = os.path.expanduser("~") + pio_packages = os.path.join(home, ".platformio/packages") + + if os.path.isdir(pio_packages): + for tool_name in tool_names: + # Derive package name from tool name (same logic as above) + base_name = tool_name.rsplit("-", 1)[0] + pkg_name = "toolchain-" + base_name + pkg_dir = os.path.join(pio_packages, pkg_name) + + if os.path.isdir(pkg_dir): + bin_dir = os.path.join(pkg_dir, "bin") + if os.path.isdir(bin_dir): + candidate = os.path.join(bin_dir, tool_name) + if IS_WINDOWS: + candidate += ".exe" + if os.path.isfile(candidate): + return candidate + + return None + # ------------------------------------------------------------------------- # Line filtering # ------------------------------------------------------------------------- @@ -1392,11 +1467,233 @@ def get_mem(addr, size): sys.exit(0) -if __name__ == "__main__": - if len(sys.argv) >= 3 and sys.argv[1] == "--rsp-server": - _run_rsp_server(sys.argv[2]) +# --------------------------------------------------------------------------- +# Standalone CLI Mode +# --------------------------------------------------------------------------- + +def _find_toolchain_binaries(elf_path): + """Auto-detect toolchain binaries (addr2line, GDB) based on ELF architecture. + + Returns: + tuple: (addr2line_path, gdb_path, is_riscv) + """ + addr2line_path = None + gdb_path = None + is_riscv = False + + # Detect architecture from ELF file + try: + with open(elf_path, "rb") as f: + # Read ELF header to detect architecture + elf_header = f.read(20) + if len(elf_header) >= 18: + e_machine = struct.unpack("\n" % sys.argv[0] + "Error: addr2line tool not found.\n" + "Please install pioarduino with espressif32 platform.\n" ) sys.exit(1) + + sys.stderr.write("Found addr2line: %s\n" % addr2line_path) + if gdb_path: + sys.stderr.write("Found GDB: %s\n" % gdb_path) + sys.stderr.write("Architecture: %s\n" % ("RISC-V" if is_riscv else "Xtensa")) + sys.stderr.write("\n") + + # Create a minimal mock environment for the decoder + class StandaloneConfig: + def __init__(self): + pass + + def get(self, section, key): + if key == "build_type": + return "debug" + return "" + + # Create decoder instance + decoder = Esp32ExceptionDecoder() + decoder.project_dir = os.path.dirname(os.path.abspath(elf_path)) + decoder.environment = "standalone" + decoder.config = StandaloneConfig() + + # Manually set paths (bypass PlatformIO dependency) + decoder.firmware_path = os.path.abspath(elf_path) + decoder.addr2line_path = addr2line_path + decoder.rom_elf_path = None # ROM ELF not needed for basic decoding + decoder._addr_cache = {} + decoder._is_riscv = is_riscv + decoder._gdb_path = gdb_path + + # Initialize matchers + if HAS_PYELFTOOLS: + decoder._firmware_matcher = PcAddressMatcher(decoder.firmware_path) + decoder._has_working_matcher = bool(decoder._firmware_matcher.intervals) + decoder._rom_matcher = None # ROM ELF not used in standalone mode + else: + decoder._firmware_matcher = None + decoder._rom_matcher = None + decoder._has_working_matcher = False + + # Initialize state + decoder.buffer = "" + decoder._rx_lock = threading.Lock() + decoder._buf_lock = threading.Lock() + decoder._rx_buf = deque() + decoder._rx_buf_bytes = 0 + decoder._RX_BUF_MAX = 65536 + decoder._riscv_state = decoder._RISCV_IDLE + decoder._riscv_regs = {} + decoder._riscv_stack_lines = [] + decoder._fallback_context = False + decoder._fallback_lines = 0 + decoder.enabled = True + + sys.stderr.write("Decoding crash log...\n\n") + + # Process the crash log incrementally (not single-shot) + # Read file in chunks to respect _RX_BUF_MAX and allow proper + # RISC-V backtrace accumulation and EOF detection + output_buffer = [] + chunk_size = min(8192, decoder._RX_BUF_MAX // 2) # Use reasonable chunk size + + with open(crash_log_path, 'r', encoding='utf-8', errors='replace') as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + + # Feed chunk to decoder incrementally + decoded_chunk = decoder.rx(chunk) + output_buffer.append(decoded_chunk) + + # Flush any remaining buffered data and trigger EOF processing + # This is critical for RISC-V backtrace generation + if decoder.buffer: + # Process any remaining incomplete line + final_line = decoder.buffer + "\n" + decoder.buffer = "" + + # Feed final line and check for RISC-V backtrace + if decoder._is_riscv and decoder._feed_riscv_line(final_line.rstrip('\n')): + trace = decoder._invoke_gdb_backtrace() + if trace: + output_buffer.append(trace) + # Line was consumed as part of RISC-V dump + else: + output_buffer.append(final_line) + + # Check if we have accumulated RISC-V state that needs final processing + if decoder._is_riscv and decoder._riscv_state != decoder._RISCV_IDLE: + if decoder._riscv_regs and decoder._riscv_stack_lines: + trace = decoder._invoke_gdb_backtrace() + if trace: + output_buffer.append(trace) + + decoded_output = "".join(output_buffer) + + # Write output + if output_path: + with open(output_path, 'w', encoding='utf-8') as f: + f.write(decoded_output) + sys.stderr.write("\nDecoded output written to: %s\n" % output_path) + else: + sys.stdout.write(decoded_output) + + +if __name__ == "__main__": + import argparse + + # Check for RSP server mode first + if len(sys.argv) >= 3 and sys.argv[1] == "--rsp-server": + _run_rsp_server(sys.argv[2]) + sys.exit(0) + + # CLI mode + parser = argparse.ArgumentParser( + description="ESP32 Exception Decoder - Decode crash logs from ESP32 devices", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Decode crash log and print to stdout + %(prog)s firmware.elf crash.txt + + # Decode crash log and save to file + %(prog)s firmware.elf crash.txt -o decoded.txt + + # Decode with specific output file + %(prog)s /path/to/firmware.elf /path/to/crash.log --output result.txt + +The tool will automatically detect the architecture (RISC-V or Xtensa) and +find the required toolchain binaries (addr2line, GDB) in: + - ~/.platformio/packages/ +""" + ) + + parser.add_argument( + "elf_file", + help="Path to firmware ELF file" + ) + + parser.add_argument( + "crash_log", + help="Path to crash log text file" + ) + + parser.add_argument( + "-o", "--output", + dest="output_file", + help="Output file path (default: stdout)", + default=None + ) + + parser.add_argument( + "--version", + action="version", + version="ESP32 Exception Decoder 1.0 (Standalone Mode)" + ) + + args = parser.parse_args() + + _run_standalone_decoder(args.elf_file, args.crash_log, args.output_file)