0
mirror of https://github.com/torvalds/GuitarPedal.git synced 2026-08-19 13:34:01 +00:00
Files
torvalds-GuitarPedal/scripts/check-audio.py
Linus Torvalds 0c1b9c3db3 Split Software/ into the four things it actually was
'Software' was the directory everything that was not KiCad ended up in,
which stopped describing anything a while ago - Validation and the web
app are software too.  Worse, it put the shared parts inside the
firmware, where they read as the firmware's own.

They are not.  Effects/ has three consumers built from it: the firmware,
Validation's bench, and the web app's controls, all generated from the
same POT: comments by gen_effects.py.  Audio/ has two - the bench
compiles the same biquads, the same envelope followers and the same
single_sample(), which is the whole reason a measurement on a
workstation says anything about the pedal.  Neither belongs under
Firmware/, so neither is under it any more:

  Effects/    one file per effect
  Audio/      the DSP they are built from, and the audio loop
  Firmware/   the rest of what runs on the pedal, and the submodules
  WebMIDI/    the web app
  scripts/    what the build runs
  Validation/ unchanged
  Hardware/, Documentation/, Images/

CMakeLists.txt and the wrapper Makefile move to the top with them,
because the build now consumes four of those directories and generates
into a fifth.  board.local and build/ come along; MIDI_CC_MAP.md is
generated into Documentation/ rather than into the old Software/ root.

scripts/ goes with the build rather than staying under the firmware,
because six of the ten had nothing to do with the firmware: gen_effects.py
reads Effects/ and writes to three different places, pow2/log2/quarter_sine
generate Audio/'s tables, check-readme.py compares Effects/ against the
README, and server.py serves the web app.  Four of them are invoked from
Validation, which was reaching into Firmware/ for tooling - the same
burying this commit is undoing.  The four that really are about the
firmware are ELF checks the top-level build drives anyway, and a second
scripts directory would only be a second place to look.

C includes say "Audio/foo.h" and the generated map says
"Effects/bar.h", with the repository root on the include path for both
the firmware and the bench.  Spelling the directory out rather than
relying on a bare name is what keeps Audio/cycles.h shimmable: a quoted
include searches the including file's own directory first.

The submodules are renamed as well as moved.  git mv updates their paths
but leaves the section names, and 'Software/pico-sdk' surviving in
.gitmodules would be the word this commit removes, still load-bearing.
That meant the nested modules under pico-sdk too - six .git files
pointing into .git/modules/Software - which is why 'git submodule update
--init --recursive' is worth running once after pulling this.

Verified rather than assumed: a clean configure and build, make check
(failing only on the missing-eeprom case it already failed on),
check-effects, all four analysis pages reproducing every series and
drawing every chart, and a flash to the board that still measures a
routed reverb where it did before.

One latent bug fell out of it.  bench/coeff declared only quarter_sine.h
of the three generated math tables, and Audio/util.h includes pow2.h and
log2.h as well - so building that target with an empty gen/ could never
have worked.  'make bench' builds bench/bench first, which generates all
three, so it stayed hidden until this rebuilt everything from nothing.

Signed-off-by: Linus Torvalds <torvalds@linux-foundation.org>
2026-08-11 13:48:26 -07:00

191 lines
7.0 KiB
Python
Executable File

#!/usr/bin/env python3
#
# Check that nothing running on the audio core leaves it - neither by
# calling out of it, nor by reading out of it.
#
# Everything marked with __audio_func() is compiled into its own
# '.time_critical.audio_<name>' section. Anything such a function calls
# has to be marked too - if it isn't, either it wants marking, or we have
# accidentally pulled a newlib or soft-float routine into the
# hard-realtime path.
#
# The linker merges all of those input sections into .data, so the
# section names only survive in the map file. That is fine: the macro
# builds the section name out of the function name, so the map is enough
# to recover the list.
#
# The second check is about *data*, and it exists because of flash
# writes. Erasing or programming a flash sector means switching XIP off,
# and while it is off the whole 0x10000000 window reads back nonsense.
# Core 0 is stopped for the duration and does not care. Core 1 keeps
# playing, so every byte it touches has to be in RAM - and marking a
# function __audio_func() moves its *code*, not the tables it reads.
#
# That distinction is invisible in the source and silent at runtime: a
# 'static const float table[]' added to an effect lands in .rodata, in
# flash, and everything works perfectly until the first save, when the
# audio core reads garbage for as long as the erase takes. So find them
# here, where it is a build failure with a symbol name on it.
#
# Called as: check-audio.py <map> <elf> [objdump]
#
import re
import subprocess
import sys
# Calls allowed out of the audio sections. Keep this short, and say why.
ALLOWED = {
# (nothing yet - the audio path is entirely self-contained)
}
# Flash reads allowed from the audio sections. Keep this shorter, and be
# certain: an entry here is a promise that no flash write can ever be in
# flight while the audio core reads it.
ALLOWED_FLASH = {
# (nothing - and adding one should feel like a big decision)
}
# The XIP window. Anything the audio core reads from in here is the bug.
XIP_BASE = 0x10000000
XIP_END = 0x20000000
def flash_symbols(elf, objdump):
"""Every symbol in the XIP window, as (start, end, name).
Sizes come from 'nm -S', but not every symbol has one - so a symbol
without a size runs until the next one starts. Without that, a
literal pointing at a sizeless table matches nothing and the check
quietly passes: 'tunings' is exactly such a symbol.
"""
nm = objdump.replace("objdump", "nm")
res = subprocess.run([nm, "-S", "--numeric-sort", elf],
capture_output=True, text=True)
if res.returncode:
sys.exit(f"check-audio: {nm} failed:\n{res.stderr}")
syms = []
for line in res.stdout.splitlines():
parts = line.split()
if len(parts) == 4:
addr, size, name = int(parts[0], 16), int(parts[1], 16), parts[3]
elif len(parts) == 3:
addr, size, name = int(parts[0], 16), 0, parts[2]
else:
continue
if XIP_BASE <= addr < XIP_END:
syms.append([addr, size, name])
out = []
for i, (addr, size, name) in enumerate(syms):
end = addr + size
if not size:
end = syms[i + 1][0] if i + 1 < len(syms) else addr + 1
out.append((addr, end, name))
return out
def main():
mapfile, elf = sys.argv[1], sys.argv[2]
objdump = (sys.argv[3] if len(sys.argv) > 3 and sys.argv[3]
else "arm-none-eabi-objdump")
with open(mapfile) as f:
audio = set(re.findall(r"^ \.time_critical\.audio_(\S+)$",
f.read(), re.M))
if not audio:
sys.exit("check-audio: no __audio_func() symbols in the map - "
"stale build?")
res = subprocess.run([objdump, "-d", "--no-show-raw-insn", elf],
capture_output=True, text=True)
if res.returncode:
sys.exit(f"check-audio: {objdump} failed:\n{res.stderr}")
flash = flash_symbols(elf, objdump)
def flash_owner(addr):
for start, end, name in flash:
if start <= addr < end:
return name
return None
func = re.compile(r"^[0-9a-f]+ <([^>]+)>:")
call = re.compile(r"\s(?:bl|blx)\s+[0-9a-f]+ <([^>+]+)")
#
# Two ways an address gets into a register. Usually a literal pool
# entry, which objdump prints both as the '.word' itself and as a
# '; 0x...' comment on the load - either spelling will do, they are
# the same address. Otherwise a movw/movt pair builds it inline,
# which never shows up as a literal at all.
#
word = re.compile(r"0x([0-9a-f]{8})\b")
movw = re.compile(r"\bmovw\s+(\w+),\s*#(\d+)")
movt = re.compile(r"\bmovt\s+(\w+),\s*#(\d+)")
cur, bad, seen = None, set(), set()
reads, halves = set(), {}
for line in res.stdout.splitlines():
m = func.match(line)
if m:
cur = m.group(1)
halves = {}
if cur in audio:
seen.add(cur)
continue
if cur not in audio:
continue
m = call.search(line)
if m:
callee = m.group(1)
if callee not in audio and callee not in ALLOWED:
bad.add((cur, callee))
m = movw.search(line)
if m:
halves[m.group(1)] = int(m.group(2))
m = movt.search(line)
if m and m.group(1) in halves:
addr = halves[m.group(1)] | (int(m.group(2)) << 16)
owner = flash_owner(addr)
if owner and owner not in ALLOWED_FLASH:
reads.add((cur, owner))
for hexval in word.findall(line):
owner = flash_owner(int(hexval, 16))
if owner and owner not in ALLOWED_FLASH:
reads.add((cur, owner))
# A marked function that vanished was fully inlined into its callers,
# which is fine - they are marked too.
if bad:
print(f"check-audio: {len(bad)} call(s) leaving the audio core:",
file=sys.stderr)
for caller, callee in sorted(bad):
print(f" {caller} -> {callee}", file=sys.stderr)
print("\nMark the callee with __audio_func(), or if it really has to "
"live\nout of line, add it to ALLOWED in this script with a "
"reason.", file=sys.stderr)
return 1
if reads:
print(f"check-audio: {len(reads)} read(s) of flash from the audio "
f"core:", file=sys.stderr)
for reader, sym in sorted(reads):
print(f" {reader} -> {sym}", file=sys.stderr)
print("\nThese are in .rodata, which is in flash, and the audio core "
"keeps\nrunning while core 0 erases a flash sector with XIP "
"switched off -\nso it would read nonsense for the duration. "
"Mark the data\n__not_in_flash(\"audio\") to move it into RAM.",
file=sys.stderr)
return 1
print(f"check-audio: {len(seen)} functions on the audio core, "
f"no calls out, no flash reads")
return 0
if __name__ == "__main__":
sys.exit(main())