Skip to content

Commit afb1334

Browse files
committed
Add monitor filter for decoding stack backtraces
1 parent aa100b8 commit afb1334

1 file changed

Lines changed: 206 additions & 0 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2014-present PlatformIO <contact@platformio.org>
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
"""
17+
Nable backtrace decoder monitor filter.
18+
19+
This filter automatically decodes hard fault backtraces using addr2line,
20+
similar to the ESP32 exception decoder.
21+
22+
Usage in platformio.ini:
23+
[env:seeed_xiao_nrf52840_sense]
24+
monitor_filters = nable_exception_decoder
25+
"""
26+
27+
import os
28+
import re
29+
import subprocess
30+
import sys
31+
32+
from platformio.exception import PlatformioException
33+
from platformio.public import (
34+
DeviceMonitorFilterBase,
35+
load_build_metadata,
36+
)
37+
38+
# By design, __call__ is called inside miniterm and we can't pass context to it.
39+
# pylint: disable=attribute-defined-outside-init
40+
41+
IS_WINDOWS = sys.platform.startswith("win")
42+
43+
class NableExceptionDecoder(DeviceMonitorFilterBase):
44+
"""Nable backtrace decoder filter."""
45+
46+
NAME = "nable_exception_decoder"
47+
48+
# Pattern to match hex addresses in backtrace format like " #0: 0x12345678"
49+
ADDR_PATTERN = re.compile(r"(\s+#\d+:\s+)(0x[0-9a-fA-F]{8})")
50+
51+
def __call__(self):
52+
"""Initialize the filter."""
53+
self.buffer = ""
54+
self.backtrace_buffer = ""
55+
self.in_backtrace = False
56+
self.firmware_path = None
57+
self.addr2line_path = None
58+
self.enabled = self.setup_paths()
59+
60+
if not self.enabled:
61+
sys.stderr.write(
62+
"%s: failed to find addr2line or firmware. Backtrace decoding disabled.\n"
63+
% self.__class__.__name__
64+
)
65+
66+
if self.config.get("env:" + self.environment, "build_type") != "debug":
67+
sys.stderr.write(
68+
"""
69+
Please build project in debug configuration to get more details about an exception.
70+
See https://docs.platformio.org/page/projectconf/build_configurations.html
71+
"""
72+
)
73+
74+
return self
75+
76+
def setup_paths(self):
77+
"""Setup paths to firmware and addr2line tool."""
78+
self.project_dir = os.path.abspath(self.project_dir)
79+
try:
80+
data = load_build_metadata(self.project_dir, self.environment, cache=True)
81+
82+
self.firmware_path = data["prog_path"]
83+
if not os.path.isfile(self.firmware_path):
84+
sys.stderr.write(
85+
"%s: firmware at %s does not exist, rebuild the project?\n"
86+
% (self.__class__.__name__, self.firmware_path)
87+
)
88+
return False
89+
90+
cc_path = data.get("cc_path", "")
91+
if "eabi-gcc" in cc_path:
92+
path = cc_path.replace("eabi-gcc", "eabi-addr2line")
93+
if os.path.isfile(path):
94+
self.addr2line_path = path
95+
return True
96+
97+
except PlatformioException as e:
98+
sys.stderr.write(
99+
"%s: disabling, exception while looking for addr2line: %s\n"
100+
% (self.__class__.__name__, e)
101+
)
102+
return False
103+
104+
sys.stderr.write(
105+
"%s: disabling, failed to find addr2line.\n" % self.__class__.__name__
106+
)
107+
return False
108+
109+
def rx(self, text):
110+
"""Process received text and decode backtraces."""
111+
if not self.enabled:
112+
return text
113+
114+
output = ""
115+
lines = text.splitlines(True)
116+
117+
for line in lines:
118+
ended = line.endswith("\n") or line.endswith("\r")
119+
raw = line.rstrip("\r\n") if ended else line
120+
ending = line[len(raw):] if ended else ""
121+
122+
# Detect start of backtrace
123+
if "Call Stack Backtrace:" in raw:
124+
self.in_backtrace = True
125+
self.backtrace_buffer = ""
126+
output += raw + (ending or "\n")
127+
continue
128+
129+
# Detect end of backtrace
130+
if self.in_backtrace and "======" in raw:
131+
self.in_backtrace = False
132+
# Process complete backtrace
133+
decoded = self.process_backtrace(self.backtrace_buffer)
134+
if decoded:
135+
output += decoded
136+
output += raw + (ending or "\n")
137+
self.backtrace_buffer = ""
138+
continue
139+
140+
# Buffer backtrace lines
141+
if self.in_backtrace:
142+
if ended:
143+
self.backtrace_buffer += raw + "\n"
144+
else:
145+
self.backtrace_buffer += raw
146+
else:
147+
output += raw + ending
148+
149+
return output
150+
151+
def process_backtrace(self, backtrace_text):
152+
"""Process complete backtrace and decode all addresses."""
153+
result = ""
154+
lines = backtrace_text.split("\n")
155+
156+
for line in lines:
157+
m = self.ADDR_PATTERN.search(line)
158+
if m is not None:
159+
decoded = self.build_backtrace(m.group(1), m.group(2))
160+
if decoded:
161+
result += decoded
162+
elif line.strip():
163+
# Preserve non-address informational lines inside the backtrace
164+
result += line + "\n"
165+
166+
return result
167+
168+
def tx(self, text):
169+
"""Process transmitted text (pass-through)."""
170+
return text
171+
172+
def build_backtrace(self, prefix, addr):
173+
"""Build a formatted backtrace from a single address."""
174+
enc = "mbcs" if IS_WINDOWS else "utf-8"
175+
args = [self.addr2line_path, "-fipC", "-e", self.firmware_path]
176+
177+
try:
178+
output = (
179+
subprocess.check_output(args + [addr])
180+
.decode(enc)
181+
.strip()
182+
)
183+
184+
# newlines happen with inlined methods
185+
output = output.replace("\n", "\n ")
186+
187+
output = self.strip_project_dir(output)
188+
trace = "%s%s in %s\n" % (prefix, addr, output)
189+
return trace
190+
191+
except subprocess.CalledProcessError as e:
192+
sys.stderr.write(
193+
"%s: failed to call %s: %s\n"
194+
% (self.__class__.__name__, self.addr2line_path, e)
195+
)
196+
return "%s%s in ??:?\n" % (prefix, addr)
197+
198+
def strip_project_dir(self, trace):
199+
"""Remove project directory path from trace for cleaner output."""
200+
while True:
201+
idx = trace.find(self.project_dir)
202+
if idx == -1:
203+
break
204+
trace = trace[:idx] + trace[idx + len(self.project_dir) + 1:]
205+
206+
return trace

0 commit comments

Comments
 (0)