Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions monitor/filter_nable_exception_decoder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
# Copyright (c) 2014-present PlatformIO <contact@platformio.org>
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Nable backtrace decoder monitor filter.

This filter automatically decodes hard fault backtraces using addr2line,
similar to the ESP32 exception decoder.

Usage in platformio.ini:
[env:seeed_xiao_nrf52840_sense]
monitor_filters = nable_exception_decoder
"""

import os
import re
import subprocess
import sys

from platformio.exception import PlatformioException
from platformio.public import (
DeviceMonitorFilterBase,
load_build_metadata,
)

# By design, __call__ is called inside miniterm and we can't pass context to it.
# pylint: disable=attribute-defined-outside-init

IS_WINDOWS = sys.platform.startswith("win")

class NableExceptionDecoder(DeviceMonitorFilterBase):
"""Nable backtrace decoder filter."""
Comment on lines +41 to +44
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is only a single blank line between the module-level constant IS_WINDOWS and the class NableExceptionDecoder definition. This repo’s Python files appear to follow PEP8’s two-blank-lines rule for top-level class definitions (e.g., platform.py). Consider adding an extra blank line here for consistency.

Copilot uses AI. Check for mistakes.

NAME = "nable_exception_decoder"

# Pattern to match hex addresses in backtrace format like " #0: 0x12345678"
ADDR_PATTERN = re.compile(r"(\s+#\d+:\s+)(0x[0-9a-fA-F]{8})")

def __call__(self):
"""Initialize the filter."""
self.buffer = ""
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.buffer is initialized but never used anywhere in this filter. Removing it would reduce confusion about whether partial-line buffering is intended.

Suggested change
self.buffer = ""

Copilot uses AI. Check for mistakes.
self.backtrace_buffer = ""
self.in_backtrace = False
self.firmware_path = None
self.addr2line_path = None
self.enabled = self.setup_paths()

if not self.enabled:
sys.stderr.write(
"%s: failed to find addr2line or firmware. Backtrace decoding disabled.\n"
% self.__class__.__name__
)

if self.config.get("env:" + self.environment, "build_type") != "debug":
sys.stderr.write(
"""
Please build project in debug configuration to get more details about an exception.
See https://docs.platformio.org/page/projectconf/build_configurations.html
"""
)

return self

def setup_paths(self):
"""Setup paths to firmware and addr2line tool."""
self.project_dir = os.path.abspath(self.project_dir)
try:
data = load_build_metadata(self.project_dir, self.environment, cache=True)

self.firmware_path = data["prog_path"]
if not os.path.isfile(self.firmware_path):
sys.stderr.write(
"%s: firmware at %s does not exist, rebuild the project?\n"
% (self.__class__.__name__, self.firmware_path)
)
return False

cc_path = data.get("cc_path", "")
if "eabi-gcc" in cc_path:
path = cc_path.replace("eabi-gcc", "eabi-addr2line")
if os.path.isfile(path):
self.addr2line_path = path
return True

except PlatformioException as e:
sys.stderr.write(
"%s: disabling, exception while looking for addr2line: %s\n"
% (self.__class__.__name__, e)
)
return False

sys.stderr.write(
"%s: disabling, failed to find addr2line.\n" % self.__class__.__name__
)
return False

def rx(self, text):
"""Process received text and decode backtraces."""
if not self.enabled:
return text

output = ""
lines = text.splitlines(True)

for line in lines:
ended = line.endswith("\n") or line.endswith("\r")
raw = line.rstrip("\r\n") if ended else line
ending = line[len(raw):] if ended else ""

# Detect start of backtrace
if "Call Stack Backtrace:" in raw:
self.in_backtrace = True
self.backtrace_buffer = ""
output += raw + (ending or "\n")
continue

# Detect end of backtrace
if self.in_backtrace and "======" in raw:
self.in_backtrace = False
# Process complete backtrace
decoded = self.process_backtrace(self.backtrace_buffer)
if decoded:
output += decoded
output += raw + (ending or "\n")
self.backtrace_buffer = ""
continue

# Buffer backtrace lines
if self.in_backtrace:
if ended:
self.backtrace_buffer += raw + "\n"
else:
self.backtrace_buffer += raw
else:
output += raw + ending

return output
Comment on lines +114 to +149
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rx() builds output via repeated string concatenation inside a loop. For high-throughput serial output this can become unnecessarily expensive (quadratic behavior). Consider accumulating pieces in a list and ''.join(...) at the end.

Copilot uses AI. Check for mistakes.

def process_backtrace(self, backtrace_text):
"""Process complete backtrace and decode all addresses."""
result = ""
lines = backtrace_text.split("\n")

for line in lines:
m = self.ADDR_PATTERN.search(line)
if m is not None:
decoded = self.build_backtrace(m.group(1), m.group(2))
if decoded:
result += decoded
elif line.strip():
# Preserve non-address informational lines inside the backtrace
result += line + "\n"

return result

Comment on lines +156 to +167
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process_backtrace()/build_backtrace() run addr2line once per frame. This can be noticeably slow for long backtraces and will delay monitor output. Consider batching addresses into a single addr2line invocation (it accepts multiple addresses) and then mapping the results back to frames.

Suggested change
for line in lines:
m = self.ADDR_PATTERN.search(line)
if m is not None:
decoded = self.build_backtrace(m.group(1), m.group(2))
if decoded:
result += decoded
elif line.strip():
# Preserve non-address informational lines inside the backtrace
result += line + "\n"
return result
# First pass: collect all addresses to decode in one addr2line invocation
prefixes = []
addrs = []
for line in lines:
m = self.ADDR_PATTERN.search(line)
if m is not None:
prefixes.append(m.group(1))
addrs.append(m.group(2))
decoded_traces = None
if addrs:
decoded_traces = self._decode_addresses_batched(prefixes, addrs)
# If batching failed for any reason, fall back to per-address decoding
if decoded_traces is None:
decoded_traces = []
for prefix, addr in zip(prefixes, addrs):
decoded = self.build_backtrace(prefix, addr)
decoded_traces.append(decoded or "")
# Second pass: rebuild the result, inserting decoded traces
trace_index = 0
for line in lines:
m = self.ADDR_PATTERN.search(line)
if m is not None:
# For each backtrace frame line, append the corresponding decoded trace
if trace_index < len(decoded_traces):
result += decoded_traces[trace_index]
trace_index += 1
elif line.strip():
# Preserve non-address informational lines inside the backtrace
result += line + "\n"
return result
def _decode_addresses_batched(self, prefixes, addrs):
"""Decode multiple addresses in a single addr2line call.
Returns a list of formatted backtrace strings (one per address),
or None if batching fails and the caller should fall back.
"""
if not addrs:
return []
enc = "mbcs" if IS_WINDOWS else "utf-8"
# Use -fpC here so that each address produces exactly two lines
# (function and location), which makes batching predictable.
args = [self.addr2line_path, "-fpC", "-e", self.firmware_path]
try:
output = (
subprocess.check_output(args + addrs)
.decode(enc)
.strip()
)
except subprocess.CalledProcessError as e:
sys.stderr.write(
"%s: failed to call %s (batched): %s\n"
% (self.__class__.__name__, self.addr2line_path, e)
)
return None
lines = output.split("\n") if output else []
# Expect two lines per address (function and file:line)
if len(lines) != 2 * len(addrs):
# Unexpected format; fall back to per-address decoding
return None
decoded_traces = []
for idx, (prefix, addr) in enumerate(zip(prefixes, addrs)):
func_line = lines[2 * idx]
loc_line = lines[2 * idx + 1]
frame_output = func_line + "\n" + loc_line
# newlines happen with inlined methods (if any), indent them
frame_output = frame_output.replace("\n", "\n ")
frame_output = self.strip_project_dir(frame_output)
trace = "%s%s in %s\n" % (prefix, addr, frame_output)
decoded_traces.append(trace)
return decoded_traces

Copilot uses AI. Check for mistakes.
def tx(self, text):
"""Process transmitted text (pass-through)."""
return text

def build_backtrace(self, prefix, addr):
"""Build a formatted backtrace from a single address."""
enc = "mbcs" if IS_WINDOWS else "utf-8"
args = [self.addr2line_path, "-fipC", "-e", self.firmware_path]

try:
output = (
subprocess.check_output(args + [addr])
.decode(enc)
.strip()
)
Comment on lines +175 to +182
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

subprocess.check_output() is invoked without a timeout; if addr2line hangs (or the binary is very slow), it will block the monitor thread and stall the serial output. Consider adding a reasonable timeout and handling subprocess.TimeoutExpired with a clear fallback output.

Copilot uses AI. Check for mistakes.

# newlines happen with inlined methods
output = output.replace("\n", "\n ")

output = self.strip_project_dir(output)
trace = "%s%s in %s\n" % (prefix, addr, output)
return trace

except subprocess.CalledProcessError as e:
sys.stderr.write(
"%s: failed to call %s: %s\n"
% (self.__class__.__name__, self.addr2line_path, e)
)
return "%s%s in ??:?\n" % (prefix, addr)

Comment on lines +177 to +197
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

build_backtrace() only handles subprocess.CalledProcessError, but subprocess.check_output() can also raise FileNotFoundError/OSError (e.g., non-executable/bad path) which would crash the monitor filter. Consider catching these exceptions and returning a fallback decoded line (similar to the CalledProcessError path) so the serial monitor keeps running.

Copilot uses AI. Check for mistakes.
def strip_project_dir(self, trace):
"""Remove project directory path from trace for cleaner output."""
while True:
idx = trace.find(self.project_dir)
if idx == -1:
break
trace = trace[:idx] + trace[idx + len(self.project_dir) + 1:]

return trace