diff --git a/monitor/filter_exception_decoder.py b/monitor/filter_exception_decoder.py index 8a4f1cb60..95c2beffc 100644 --- a/monitor/filter_exception_decoder.py +++ b/monitor/filter_exception_decoder.py @@ -17,6 +17,7 @@ import subprocess import sys import glob +import types from platformio.compat import IS_WINDOWS from platformio.exception import PlatformioException @@ -45,10 +46,21 @@ class Esp32ExceptionDecoder(DeviceMonitorFilterBase): ADDR_PATTERN = re.compile(r"((?:0x[0-9a-fA-F]{8}:0x[0-9a-fA-F]{8}(?: |$))+)") ADDR_SPLIT = re.compile(r"[ :]") PREFIX_RE = re.compile(r"^ *") + + # Pattern for stack memory dump lines: "3fca0000: 0x3fce0000 0x3fce0000 ..." + STACK_MEM_LINE = re.compile( + r"^\s*[0-9a-fA-F]{8}:\s+((?:0x[0-9a-fA-F]{8}\s*)+)" + ) + + # Pattern for RISC-V register dump entries: "MEPC : 0x00000000" + REGISTER_ENTRY = re.compile( + r"([A-Z][A-Z0-9/]+)\s*:\s*(0x[0-9a-fA-F]{8})" + ) # Patterns that indicate we're in an exception/backtrace context BACKTRACE_KEYWORDS = re.compile( r"(Backtrace:|" + r"Stack memory:|" r"\bPC:\s*0x[0-9a-fA-F]{8}\b|" r"abort\(\) was called at PC|" r"Guru Meditation Error:|" @@ -58,8 +70,10 @@ class Esp32ExceptionDecoder(DeviceMonitorFilterBase): r"CORRUPT HEAP:|" r"assertion .* failed:|" r"Debug exception reason:|" - r"Undefined behavior of type)", - re.IGNORECASE + r"Undefined behavior of type|" + r"^Exception\s+\(\d+\):|" + r"ELF file SHA256:)", + re.IGNORECASE | re.MULTILINE ) # Chip name mapping for ROM ELF files @@ -72,9 +86,76 @@ class Esp32ExceptionDecoder(DeviceMonitorFilterBase): "esp32c5": "esp32c5", "esp32c6": "esp32c6", "esp32h2": "esp32h2", + "esp32h4": "esp32h4", "esp32p4": "esp32p4", } + # Xtensa exception causes (EXCCAUSE register values) + # From Xtensa ISA Reference Manual / ESP-IDF EspExceptionDecoder + XTENSA_EXCEPTIONS = ( + "IllegalInstruction", # 0 + "Syscall", # 1 + "InstructionFetchError", # 2 + "LoadStoreError", # 3 + "Level1Interrupt", # 4 + "Alloca", # 5 + "IntegerDivideByZero", # 6 + "reserved", # 7 + "Privileged", # 8 + "LoadStoreAlignment", # 9 + "reserved", # 10 + "reserved", # 11 + "InstrPIFDataError", # 12 + "LoadStorePIFDataError", # 13 + "InstrPIFAddrError", # 14 + "LoadStorePIFAddrError", # 15 + "InstTLBMiss", # 16 + "InstTLBMultiHit", # 17 + "InstFetchPrivilege", # 18 + "reserved", # 19 + "InstFetchProhibited", # 20 + "reserved", # 21 + "reserved", # 22 + "reserved", # 23 + "LoadStoreTLBMiss", # 24 + "LoadStoreTLBMultiHit", # 25 + "LoadStorePrivilege", # 26 + "reserved", # 27 + "LoadProhibited", # 28 + "StoreProhibited", # 29 + ) + + # RISC-V exception causes (MCAUSE register values) + RISCV_EXCEPTIONS = types.MappingProxyType({ + 0x0: "Instruction address misaligned", + 0x1: "Instruction access fault", + 0x2: "Illegal instruction", + 0x3: "Breakpoint", + 0x4: "Load address misaligned", + 0x5: "Load access fault", + 0x6: "Store/AMO address misaligned", + 0x7: "Store/AMO access fault", + 0x8: "Environment call from U-mode", + 0x9: "Environment call from S-mode", + 0xb: "Environment call from M-mode", + 0xc: "Instruction page fault", + 0xd: "Load page fault", + 0xf: "Store/AMO page fault", + }) + + # Registers containing exception metadata, not code addresses + NON_CODE_REGISTERS = frozenset({ + "EXCVADDR", # Xtensa fault address + "MTVAL", # RISC-V fault address + "MSTATUS", "MHARTID", # RISC-V status registers + "PS", # Xtensa processor status + "SAR", # Xtensa shift amount register + "LBEG", "LEND", "LCOUNT", # Xtensa loop registers + }) + + # Pattern to detect device reboot (terminates exception context) + REBOOT_RE = re.compile(r"^\s*Rebooting\.\.\.", re.IGNORECASE) + def __call__(self): """ Initialize the filter instance. @@ -93,6 +174,7 @@ def __call__(self): self.firmware_path = None self.addr2line_path = None self.rom_elf_path = None + self._addr_cache = {} self.enabled = self.setup_paths() if self.config.get("env:" + self.environment, "build_type") != "debug": @@ -296,6 +378,34 @@ def is_backtrace_context(self, line): """ return self.BACKTRACE_KEYWORDS.search(line) is not None + def get_xtensa_exception(self, code): + """ + Look up Xtensa exception description by EXCCAUSE code. + + Args: + code: Integer EXCCAUSE value + + Returns: + str: Exception description, or None if code is unknown + """ + if 0 <= code < len(self.XTENSA_EXCEPTIONS): + desc = self.XTENSA_EXCEPTIONS[code] + if desc != "reserved": + return desc + return None + + def get_riscv_exception(self, code): + """ + Look up RISC-V exception description by MCAUSE code. + + Args: + code: Integer MCAUSE value + + Returns: + str: Exception description, or None if code is unknown + """ + return self.RISCV_EXCEPTIONS.get(code) + def should_process_line(self, line): """ Determine if a line should be processed for address decoding. @@ -309,6 +419,11 @@ def should_process_line(self, line): Returns: bool: True if line should be processed for address decoding """ + # Rebooting... terminates the exception context + if self.REBOOT_RE.match(line): + self.in_backtrace_context = False + return False + # Check if this line starts a backtrace context if self.is_backtrace_context(line): self.in_backtrace_context = True @@ -362,14 +477,31 @@ def rx(self, text): if not self.should_process_line(line): continue + # Check for PC:SP pair backtrace format m = self.ADDR_PATTERN.search(line) - if m is None: + if m is not None: + trace = self.build_backtrace(line, m.group(1)) + if trace: + text = text[: idx + 1] + trace + text[idx + 1 :] + last += len(trace) continue - trace = self.build_backtrace(line, m.group(1)) - if trace: - text = text[: idx + 1] + trace + text[idx + 1 :] - last += len(trace) + # Check for stack memory dump format + m = self.STACK_MEM_LINE.search(line) + if m is not None: + trace = self.build_stack_trace(line, m.group(1)) + if trace: + text = text[: idx + 1] + trace + text[idx + 1 :] + last += len(trace) + continue + + # Check for RISC-V register dump lines + reg_matches = self.REGISTER_ENTRY.findall(line) + if len(reg_matches) >= 2: + trace = self.build_register_trace(line, reg_matches) + if trace: + text = text[: idx + 1] + trace + text[idx + 1 :] + last += len(trace) return text def is_address_ignored(self, address): @@ -413,6 +545,10 @@ def decode_address(self, addr, elf_path): Returns: str: Decoded function and location, or None if decoding failed """ + cache_key = (addr, elf_path) + if cache_key in self._addr_cache: + return self._addr_cache[cache_key] + enc = "mbcs" if IS_WINDOWS else "utf-8" args = [self.addr2line_path, u"-fipC", u"-e", elf_path, addr] @@ -428,13 +564,132 @@ def decode_address(self, addr, elf_path): # Check if address was found in ELF (handle common variants) if output in ("?? ??:0", "??:0") or output.strip().startswith("?? ") or output.strip() == "??": + self._addr_cache[cache_key] = None return None + self._addr_cache[cache_key] = output return output except subprocess.CalledProcessError: + self._addr_cache[cache_key] = None return None + def _resolve_address(self, addr, is_return_addr=False): + """ + Resolve a single address through firmware and ROM ELFs. + + Args: + addr: Address string (e.g., "0x420022e4") + is_return_addr: If True, subtract 1 before lookup so addr2line + reports the call site rather than the instruction after it + + Returns: + tuple: (decoded_output, is_rom) or (None, False) if unresolved + """ + if self.is_address_ignored(addr): + return None, False + + lookup = addr + if is_return_addr: + lookup = "0x%08x" % (int(addr, 16) - 1) + + output = self.decode_address(lookup, self.firmware_path) + is_rom = False + + if output is None and self.rom_elf_path: + output = self.decode_address(lookup, self.rom_elf_path) + if output is not None: + is_rom = True + + if output is None: + return None, False + + output = self.strip_project_dir(output) + + if is_rom: + parts = output.split(" at ", 1) + if len(parts) == 2: + output = f"{parts[0]} in ROM" + else: + output = f"{output} in ROM" + + return output, is_rom + + def build_register_trace(self, line, reg_matches): + """ + Build a decoded trace from a register dump line. + + Annotates exception cause registers (EXCCAUSE/MCAUSE) with + human-readable descriptions. Tries to decode code-address registers + (PC, MEPC, RA, etc.) via addr2line. Skips non-code registers. + + Args: + line: Original register dump line + reg_matches: List of (register_name, address) tuples + + Returns: + str: Formatted decoded trace, or empty string if nothing decoded + """ + prefix_match = self.PREFIX_RE.match(line) + prefix = prefix_match.group(0) if prefix_match is not None else "" + + trace = "" + for reg_name, addr in reg_matches: + # Annotate Xtensa exception cause with description + if reg_name == "EXCCAUSE": + code = int(addr, 16) + desc = self.get_xtensa_exception(code) + if desc: + trace += "%s %s: %s (%s)\n" % (prefix, reg_name, addr, desc) + continue + + # Annotate RISC-V exception cause with description + if reg_name == "MCAUSE": + code = int(addr, 16) + desc = self.get_riscv_exception(code) + if desc: + trace += "%s %s: %s (%s)\n" % (prefix, reg_name, addr, desc) + continue + + # Skip registers that don't contain code addresses + if reg_name in self.NON_CODE_REGISTERS: + continue + + output, _ = self._resolve_address(addr, is_return_addr=(reg_name == "RA")) + if output is not None: + trace += "%s %s: %s: %s\n" % (prefix, reg_name, addr, output) + + return trace + + def build_stack_trace(self, line, addresses_str): + """ + Build a decoded trace from a stack memory dump line. + + Extracts individual addresses from the line and attempts to decode + each one. Only addresses that resolve to known symbols are shown. + + Args: + line: Original stack memory line + addresses_str: Matched portion containing space-separated addresses + + Returns: + str: Formatted decoded trace, or empty string if nothing decoded + """ + addresses = re.findall(r"0x[0-9a-fA-F]{8}", addresses_str) + if not addresses: + return "" + + prefix_match = self.PREFIX_RE.match(line) + prefix = prefix_match.group(0) if prefix_match is not None else "" + + trace = "" + for addr in addresses: + output, _ = self._resolve_address(addr, is_return_addr=True) + if output is not None: + trace += "%s %s: %s\n" % (prefix, addr, output) + + return trace + def build_backtrace(self, line, address_match): """ Build a decoded backtrace from a line containing addresses. @@ -458,42 +713,13 @@ def build_backtrace(self, line, address_match): prefix = prefix_match.group(0) if prefix_match is not None else "" trace = "" - try: - i = 0 - for addr in addresses: - # First try to decode with application ELF - output = self.decode_address(addr, self.firmware_path) - is_rom = False - - # If not found in app ELF, try ROM ELF - if output is None and self.rom_elf_path: - output = self.decode_address(addr, self.rom_elf_path) - if output is not None: - is_rom = True - - # Skip if address couldn't be decoded - if output is None: - continue - - output = self.strip_project_dir(output) - - # Add "in ROM" suffix for ROM addresses - if is_rom: - # Extract function name (first part before "at") - parts = output.split(" at ", 1) - if len(parts) == 2: - output = f"{parts[0]} in ROM" - else: - output = f"{output} in ROM" - - trace += "%s #%-2d %s in %s\n" % (prefix, i, addr, output) + i = 0 + for j, addr in enumerate(addresses): + output, is_rom = self._resolve_address(addr, is_return_addr=(j > 0)) + if output is not None: + fmt = "%s #%-2d %s %s\n" if is_rom else "%s #%-2d %s in %s\n" + trace += fmt % (prefix, i, addr, output) i += 1 - - except subprocess.CalledProcessError as e: - sys.stderr.write( - "%s: failed to call %s: %s\n" - % (self.__class__.__name__, self.addr2line_path, e) - ) return trace + "\n" if trace else ""