From 986ff1d692f856d6f91134ee8b5189cc00bf20c9 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Mon, 13 Jul 2026 20:11:38 -0700 Subject: [PATCH 1/4] tools: add core1 flash-call checker for rp2 USB host builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On raspberrypi boards with USB host, core1 runs the PIO-USB frame loop behind an MPU region that makes flash inaccessible, so everything core1 reaches must be RAM-resident. The linker script places the entry points in RAM, but nothing verified their callees — which is how #10243 happened: an upstream TinyUSB change added a flash-resident call inside RAM-placed tuh_task_event_ready() and core1 died at first device attach. check_core1_flash_calls.py disassembles the linked ELF, walks the static call graph from core1_main (following linker veneers through their literal-pool targets), and fails if any reachable function lives in flash, printing the offending call chain. Run against current builds it catches the #10243 regression, the Pico-PIO-USB calc_usb_crc16 flash placement (hard-locks the board on any multi-packet OUT transfer, e.g. writing to a USB drive), and a latent flash call chain in the TinyUSB queue mutex contention path. Co-Authored-By: Claude Fable 5 --- tools/check_core1_flash_calls.py | 189 +++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 tools/check_core1_flash_calls.py diff --git a/tools/check_core1_flash_calls.py b/tools/check_core1_flash_calls.py new file mode 100644 index 00000000000..c1392c18538 --- /dev/null +++ b/tools/check_core1_flash_calls.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +# This file is part of the CircuitPython project: https://circuitpython.org +# +# SPDX-FileCopyrightText: Copyright (c) 2026 Mikey Sklar +# +# SPDX-License-Identifier: MIT +"""Post-link check: core1-executed code must not reach into flash. + +On raspberrypi-port boards with USB host, core1 runs the PIO-USB frame loop +and may only execute (or read) RAM-resident code: core1_main enables an MPU +region that makes flash inaccessible (see common-hal/usb_host/Port.c), and the +linker script deliberately RAM-places tuh_task_event_ready() and the PIO-USB +code. Nothing verified their *callees* stay in RAM, which is how issue #10243 +happened: an upstream TinyUSB change added a flash-resident call inside +RAM-placed tuh_task_event_ready(), core1 faulted at the first device attach, +and USB host went silent. + +This tool disassembles the linked ELF, walks the static call graph from the +core1 entry points, and fails if any reachable function lives in flash. Linker +veneers (long-call trampolines) are followed through their literal-pool +targets, so a flash callee hidden behind a RAM veneer is still detected. + +Usage: + check_core1_flash_calls.py firmware.elf [--root SYMBOL]... [--allow SYMBOL]... + +Default root: core1_main. --allow skips a symbol (and everything only +reachable through it); use it for calls that provably run before the MPU is +enabled. + +Limitation: indirect calls (blx rN / function pointers) cannot be traced +statically. They are counted and reported per function so the gaps are +visible. +""" + +import argparse +import re +import subprocess +import sys +from collections import deque + +OBJDUMP = "arm-none-eabi-objdump" + +FLASH_LO, FLASH_HI = 0x10000000, 0x20000000 + +# Symbols that are reachable from a core1 root but are known to execute only +# before the MPU cuts off flash access. Keep this list short and commented. +DEFAULT_ALLOW = [ + # core1_main calls this while configuring SysTick, before enabling the MPU. + "common_hal_mcu_processor_get_frequency", +] + + +def in_flash(addr): + return FLASH_LO <= addr < FLASH_HI + + +def main(): + parser = argparse.ArgumentParser( + description="Check that core1-reachable code is RAM-resident." + ) + parser.add_argument("elf", help="linked firmware ELF") + parser.add_argument( + "--root", + action="append", + default=[], + help="core1 entry point symbol (default: core1_main)", + ) + parser.add_argument( + "--allow", + action="append", + default=[], + help="symbol to skip (e.g. runs before the MPU is enabled)", + ) + args = parser.parse_args() + roots = args.root or ["core1_main"] + allow = set(DEFAULT_ALLOW) | set(args.allow) + + dis = subprocess.run( + [OBJDUMP, "-d", args.elf], capture_output=True, text=True, check=True + ).stdout + + func_re = re.compile(r"^([0-9a-f]+) <([^>]+)>:$") + # e.g. "10001234: f7ff fffe bl 10005678 " + branch_re = re.compile( + r"^\s*[0-9a-f]+:\s+[0-9a-f ]+\t(bl|blx|b|b\.n|b\.w|" + r"b(?:eq|ne|cs|cc|mi|pl|vs|vc|hi|ls|ge|lt|gt|le)(?:\.n|\.w)?)" + r"\s+([0-9a-f]+)\s<([^>+]+)(\+0x[0-9a-f]+)?>" + ) + indirect_re = re.compile(r"^\s*[0-9a-f]+:\s+[0-9a-f ]+\tblx\s+(r\d+|ip|lr)\b") + # Veneer bodies jump through a literal pool word rather than a branch. + word_re = re.compile(r"^\s*[0-9a-f]+:\s+([0-9a-f]{8})\s+\.word\s") + + funcs = {} # name -> addr + edges = {} # name -> {target name} + veneer_words = {} # veneer name -> {literal addresses} + indirects = {} # name -> count of untraceable indirect calls + cur = None + for line in dis.splitlines(): + m = func_re.match(line) + if m: + cur = m.group(2) + funcs[cur] = int(m.group(1), 16) + edges.setdefault(cur, set()) + indirects.setdefault(cur, 0) + continue + if cur is None: + continue + if cur.endswith("_veneer"): + m = word_re.match(line) + if m: + veneer_words.setdefault(cur, set()).add(int(m.group(1), 16) & ~1) + continue + if indirect_re.match(line): + indirects[cur] += 1 + continue + m = branch_re.match(line) + if m: + target = m.group(3) + if target != cur: # ignore intra-function branches + edges[cur].add(target) + + # Resolve veneer literal addresses to function names. + addr_to_name = {} + for name, addr in funcs.items(): + addr_to_name.setdefault(addr, name) + for veneer, words in veneer_words.items(): + for w in words: + tgt = addr_to_name.get(w) + if tgt is not None: + edges.setdefault(veneer, set()).add(tgt) + + missing = [r for r in roots if r not in funcs] + if missing: + # A board without usb_host has no core1_main; nothing to check. + print(f"{args.elf}: root symbol(s) not present, skipping: {', '.join(missing)}") + return 0 + + # BFS from roots, remembering one call chain per function for reporting. + parent = {r: None for r in roots} + queue = deque(roots) + seen = set(roots) + violations = [] + indirect_notes = [] + while queue: + fn = queue.popleft() + if fn in allow: + continue + addr = funcs.get(fn) + if addr is not None and in_flash(addr): + violations.append(fn) + continue # don't walk further into flash + if indirects.get(fn): + indirect_notes.append((fn, indirects[fn])) + for tgt in sorted(edges.get(fn, ())): + if tgt not in seen and tgt in funcs: + seen.add(tgt) + parent[tgt] = fn + queue.append(tgt) + + def chain(fn): + parts = [] + while fn is not None: + parts.append(fn) + fn = parent[fn] + return " <- ".join(parts) + + print(f"{args.elf}: walked {len(seen)} functions from roots: {', '.join(roots)}") + if indirect_notes: + print( + f"note: {len(indirect_notes)} reachable function(s) make indirect " + f"calls that cannot be traced statically:" + ) + for fn, n in sorted(indirect_notes): + print(f" {fn} ({n} indirect call site(s))") + if violations: + print( + f"\nFAIL: {len(violations)} flash-resident function(s) reachable " + f"from core1:" + ) + for fn in sorted(violations): + print(f" {fn} @ {funcs[fn]:#010x}") + print(f" via: {chain(fn)}") + return 1 + print("PASS: no flash-resident code reachable from core1") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From b5bafa940ce5930ca834b1822eda0146420102b2 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Mon, 13 Jul 2026 23:03:59 -0700 Subject: [PATCH 2/4] ruff format Co-Authored-By: Claude Fable 5 --- tools/check_core1_flash_calls.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tools/check_core1_flash_calls.py b/tools/check_core1_flash_calls.py index c1392c18538..ef0b8b06031 100644 --- a/tools/check_core1_flash_calls.py +++ b/tools/check_core1_flash_calls.py @@ -173,10 +173,7 @@ def chain(fn): for fn, n in sorted(indirect_notes): print(f" {fn} ({n} indirect call site(s))") if violations: - print( - f"\nFAIL: {len(violations)} flash-resident function(s) reachable " - f"from core1:" - ) + print(f"\nFAIL: {len(violations)} flash-resident function(s) reachable from core1:") for fn in sorted(violations): print(f" {fn} @ {funcs[fn]:#010x}") print(f" via: {chain(fn)}") From b03cc853c9fc994fb4d6b458a68b135b99cf8f97 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 14 Jul 2026 13:19:11 -0700 Subject: [PATCH 3/4] raspberrypi: run core1 flash-call checker after linking USB host builds Fails the build with the offending call chain if any function reachable from core1 lives in flash. Adds about 2 seconds per board build. Co-Authored-By: Claude Fable 5 --- ports/raspberrypi/Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ports/raspberrypi/Makefile b/ports/raspberrypi/Makefile index 6d9b557e0ba..b5c0fa3e50c 100644 --- a/ports/raspberrypi/Makefile +++ b/ports/raspberrypi/Makefile @@ -776,6 +776,10 @@ $(BUILD)/firmware.elf: $(OBJ) $(BOARD_LD) link-$(CHIP_VARIANT_LOWER).ld $(Q)echo $(OBJ) > $(BUILD)/firmware.objs $(Q)echo $(PICO_LDFLAGS) > $(BUILD)/firmware.ldflags $(Q)$(CC) -o $@ $(CFLAGS) @$(BUILD)/firmware.ldflags $(LINKER_SCRIPTS) -Wl,--print-memory-usage -Wl,-Map=$@.map -Wl,-cref -Wl,--gc-sections @$(BUILD)/firmware.objs -Wl,-lc +ifeq ($(CIRCUITPY_USB_HOST), 1) + $(STEPECHO) "CHECK core1 flash calls" + $(Q)$(PYTHON) $(TOP)/tools/check_core1_flash_calls.py $@ +endif endif $(BUILD)/firmware.bin: $(BUILD)/firmware.elf From ecddc2170588fe088c0d479944c511fc42272635 Mon Sep 17 00:00:00 2001 From: Mikey Sklar Date: Tue, 14 Jul 2026 17:16:02 -0700 Subject: [PATCH 4/4] checker: allow picodvi core1 pre-MPU startup calls The picodvi Framebuffer_RP2040 core1_main calls dvi_register_irqs_this_core and dvi_start before it enables the MPU that blocks flash access, like the usb_host core1_main does with common_hal_mcu_processor_get_frequency. Fixes the 10 RP2040 DVI board build failures. Co-Authored-By: Claude Fable 5 --- tools/check_core1_flash_calls.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tools/check_core1_flash_calls.py b/tools/check_core1_flash_calls.py index ef0b8b06031..add040bae15 100644 --- a/tools/check_core1_flash_calls.py +++ b/tools/check_core1_flash_calls.py @@ -45,8 +45,13 @@ # Symbols that are reachable from a core1 root but are known to execute only # before the MPU cuts off flash access. Keep this list short and commented. DEFAULT_ALLOW = [ - # core1_main calls this while configuring SysTick, before enabling the MPU. + # usb_host core1_main calls this while configuring SysTick, before + # enabling the MPU. "common_hal_mcu_processor_get_frequency", + # picodvi Framebuffer_RP2040 core1_main calls these during startup, + # before enabling the MPU. + "dvi_register_irqs_this_core", + "dvi_start", ]