Symbols¶
A symbol table gives names to the addresses in your firmware: /sym main
tells you where main lives, /sym 0x2010 tells you that address is
main+0x10. It is the groundwork for named memory access (/mem.*, a
later step); on its own it replaces the "open the .map file and search"
ritual.
termapy reads exactly one symbol format, its own JSON. Converters turn a toolchain's linker map into that file; the runtime never parses a vendor map.
Where it lives¶
In the config's sym/ folder. It auto-loads whenever the config
loads (startup and /cfg.load) in the TUI, the CLI and the MCP server
alike, and a config switch drops the previous table. The demo config ships
one (sym/demo.symbols.json), so termapy --demo has names for the addresses
its mem command answers for.
Importing a linker map¶
writes sym/<name>.symbols.json under the config and loads it. The format is
sniffed from the file; format=xc32 picks one explicitly. Re-import after
every build; the sidecar is a generated file and is overwritten without
asking. Bare /sym.load re-reads it after a hand edit.
After the first import, bare /sym.import re-runs the same map through
the same converter — the rebuild step, without retyping a build-tree
path:
It reads the source and recipe the sidecar recorded, so it also works on a
table imported before recipes existed (that re-import is how such a table
gains one). An explicit <file> or format= always wins.
| Format | Toolchain |
|---|---|
xc32 |
Microchip XC32 (GNU ld) linker map |
More converters land in the same registry; /help sym.import lists what
the running build knows. To teach termapy your toolchain, write a
converter -- see below.
Your own converter, as a plugin¶
A plugin file may export a converter, so the map-to-table step becomes a
pipeline you own -- no PR, no second symbol store. Drop this in
plugin/ (global, or beside one config for one board):
from termapy.symbols.converters import xc32
from termapy.symbols.table import Symbol
FORMAT = "myboard"
DESCRIPTION = "xc32 map, no function addresses, plus typed SFRs"
DETECT = () # explicit-only: /sym.import <map> format=myboard
_SFRS = [Symbol("U1MODE", 0xBF806000, 4, "sfr", type="u32", rmw=False)]
def convert(text):
rows = [s for s in xc32.convert(text) if s.section != "text"]
return rows + _SFRS
Same four names a built-in converter exports -- only discovery differs.
Because your convert is the single producer of the table, a re-import
after a rebuild reproduces all of it: the rows you filtered stay gone and
the typed rows stay put. That is the point of doing it here rather than
hand-editing the sidecar, where the next /sym.import would erase your
work.
A converter may set every Symbol field -- type, rmw, space,
file -- so the typed registers and bit fields a linker map cannot
express become script output instead of hand-maintained data.
The contract¶
convert(text) is pure: text in, list out. No I/O, no config, no
globals. It must never raise on odd input -- a wrong-format file, a
truncated build, an empty string all return [], and /sym.import
renders No symbols found. Return unsorted; SymbolTable sorts by
(addr, size, name). Emit each symbol once: a map often lists the same
symbol in a summary and a detail section, so prefer the detail section
(full names) and use the summary only for addresses it did not cover.
Emit the run address (VMA), never the load address (LMA). Initialized
.data lives in flash at its LMA and is copied to RAM at its VMA, and GNU
ld maps print both. /mem.* talks to the running device, so an LMA
gives you a table that looks correct and reads the wrong memory.
Skip what a C program cannot name: merged literal pools
(.rodata.str1.1), compiler temporaries (.L123), Arm mapping symbols
($t, $d, $a).
Sections¶
Use these words where they fit, else your toolchain's own short lowercase one (an unmapped word is harmless, just unlabeled):
section |
Label | Put here |
|---|---|---|
text |
code | Functions, vectors, any executable |
bss |
bss | Zero-initialized RAM |
data |
data | Initialized RAM |
rodata |
const | Constants in flash |
global |
global | Sizeless linker-provided symbols (__bss_start__, _estack) |
sfr |
sfr | Registers; a linker map never emits these |
Map your toolchain's region names on (ER_IROM1, CODE -> text;
ZI -> bss; RW -> data; RO-data, CONST -> rodata).
Naming and detection¶
Name the format after the linker, not the CPU -- "ARM" has at least
three toolchains with three map layouts (armlink, iar, gnu-ld).
Lowercase, hyphens only, no underscores.
DETECT = ()keeps you out of sniffing. Your converter is one board's pipeline, not a toolchain, and its input is often a map a built-in would also claim. Select it withformat=.- If you do give it detect markers, make them unique to that
toolchain. Never a phrase every GNU
ldmap prints (Linker script and memory map,Memory Configuration) -- that would claim maps belonging to every other GNU-ld toolchain. Two or three independent markers beat one; the sniff scans the whole file, so a marker deep in it is fine. - Converters follow the config. A converter in a config's
plugin/folder exists only while that config is loaded, exactly like a command.
If your toolchain is one others use, the same four names in a module under
symbols/converters/ make it a built-in -- one module, one registry line,
one fixture under tests/fixtures/maps/. xc32.py is the reference.
A relative path given to /sym.import or /sym.load resolves against the
config folder, not the shell's working directory. Under the MCP server,
importing a map that lives outside the config folder needs
TERMAPY_MCP_FS_UNCONFINED=1; bare /sym.load and the auto-load are always
inside the sandbox.
Parts: device files¶
The registers the silicon fixes -- the half a linker map never has -- come
from device files, which have their own page: Devices.
They merge over the build's symbols at load, survive /sym.import, and a
build symbol with the same name wins, with a warning.
Commands¶
| Command | Example | Does |
|---|---|---|
/sym <addr\|name> |
/sym 0x2010 |
Name to address, or address to name+offset |
/sym.import {file} {format=<value>} |
/sym.import build/mem.map |
Convert a linker map to the sidecar and load it (bare: re-import the same map) |
/sym.load {path} |
/sym.load |
Reload the sidecar, or load an explicit file |
/sym.unload |
/sym.unload |
Clear the loaded table (file untouched) |
/sym.search <pattern> |
/sym.search Mon* |
Search names: exact, glob, regex, or substring |
/sym.info |
/sym.info |
File, source, counts by section, address range |
/sym returns the other representation as its value: $(A) <- /sym gTemp
captures 0x00001000, $(N) <- /sym 0x2010 captures main+0x10.
Address grammar¶
| Form | Meaning |
|---|---|
0x1000 |
hex |
1000h |
hex, assembly suffix |
1000 |
decimal 1000; bare hex is never guessed |
main |
symbol name (exact, case-sensitive) |
main+0x10 |
symbol plus offset (main-4 too) |
tick@adc.c |
a duplicate static, qualified by file (tick@adc, tick@src/adc.c) |
name.<bit> |
reserved for bit, slice and field access in a later step |
An exact symbol name wins, so count.12 (a name GCC emits) is the symbol,
not bit 12 of count. Duplicate names without a qualifier are refused with
the addresses and files listed, so you can pick one or type the address.
The file¶
{
"symbols_version": 1,
"source": "build/mem.map",
"imported": "2026-08-29T10:12:00",
"recipe": {"converter": "xc32"},
"witness": {"mtime": 1756461120.0, "size": 482113},
"address_bits": 32,
"endian": "le",
"regions": [],
"symbols": [
{"name": "gTemp", "addr": "0x00001000", "size": 2, "section": "bss", "file": "sensor.c", "type": "u16"},
{"name": "main", "addr": "0x00002000", "size": 442, "section": "text", "file": "main.c"},
{"name": "U1STA", "addr": "0xBF806010", "size": 4, "section": "sfr", "type": "u32", "rmw": false}
]
}
| Field | Required | Meaning |
|---|---|---|
name |
yes | Symbol name; dots kept verbatim |
addr |
yes | 0x-hex string or integer |
size |
no | Bytes; 0 = unknown (linker globals) |
section |
no | text / bss / data / rodata / global / sfr (shown as code, const, ...) |
file |
no | Defining object or source; what name@file matches |
type |
no | A scalar (u8..u64, i8..i64, f32, f64) or a format spec |
rmw |
no | false forbids bit writes (W1C / self-clearing registers); honored later |
space |
no | Reserved for Harvard parts |
recipe and witness are written by /sym.import and drive the staleness
check below; both are optional, and a table without them is fine. endian
and regions are stored now and honored by /mem.* later.
Unknown keys are ignored. type reuses the format-spec language from
protocol testing, so a register
can carry named bits: "type": "ON:B.15 UEN:B.8-9 BRGH:B.3" -- bits of the
register word, its width from size and its byte order from the device
(see Memory).
Is the table still current?¶
A symbol table is derived data, and the map is rebuilt on every compile.
A stale table is worse than none: /mem.dump gTemp reads a real address
that no longer belongs to gTemp.
/sym.import records two things so termapy can tell you. The witness
(mtime + size) answers is this stale, for one stat and no file
read. The recipe answers can termapy fix it — and its absence is
meaningful, not missing data: a hand-written table has no recipe and
never will.
That gives three honest answers:
| Status | Meaning | What you see |
|---|---|---|
in_sync |
The map is as it was at import | Nothing — silence is the good case |
stale |
The map was rebuilt since import | A warning at load and a status row in /sym.info, naming the exact /sym.import line that fixes it |
unknown |
The question can't be asked — no witness, a hand-written source, or a map that's gone | Nothing; this is the normal state of a hand-written table, not a defect |
Symbols may be out of date: mem.map was rebuilt (size changed) since import
rebuild: /sym.import build/mem.map format=xc32
Two deliberate limits:
- termapy never regenerates the table for you. A rebuild is the common case, and a terminal that rewrote your symbol table mid-session would be worse than a yellow line. It reports; you run the command.
- The witness detects a rebuild, not a change. A no-op recompile reports stale even when every address is identical — which is why the message says "rebuilt". Hashing a multi-megabyte map at every config load to remove a harmless false positive is the wrong trade, and re-importing is cheap.
The verdict is also in /sym.info --json as status, fixable and
rebuild_command, so an agent learns the table is out of date at the same
moment it learns the table exists.
JSON output¶
/sym 0x2010 --json (or any JSON-mode session, MCP included) returns a
record with the same keys on a hit and a miss:
{"query": "0x2010", "addr": 8208, "addr_hex": "0x00002010", "symbolic": "main+0x10",
"symbol": {"name": "main", "addr": 8192, "addr_hex": "0x00002000", "end": 8634, "size": 442,
"section": "text", "file": "main.c", "type": "", "space": "", "rmw": true},
"offset": 0, "suffix": ""}
See also: Demo mode for the bundled table, Protocol testing for the format-spec syntax.