Debugging generated C
Tycho transpiles to C and hands the
.cto your systemcc, so DWARF debug info flows through for free — the only missing piece is a map from the generated C back to your.tysource. The-gflag adds that map.
tychoc -g
-g does two things:
- Emits
#line N "src.ty"directives into the generated C, one before each statement, so the C compiler's line table attributes the code to your Tycho source instead of the.cfile. - Builds with
-O0 -g(unoptimized + DWARF) instead of the default-O3, so stepping is faithful and locals aren't optimized away.
tychoc program.ty -g -o program
Then debug the binary as if it were hand-written C, but stepping lands on .ty lines:
Linux (gdb) — DWARF is embedded in the executable directly:
gdb ./program
(gdb) break program.ty:7 # break on a Tycho source line
(gdb) run
(gdb) next # stays on program.ty lines
(gdb) step # descends into the generated C and the runtime
macOS (lldb) — the toolchain leaves DWARF in the intermediate objects, and a one-step compile+link discards them, so generate a .dSYM first:
tychoc program.ty -g --emit-c -o program # keep the .c
cc -O0 -g -fwrapv -pthread -c program.c -o program.o # keep the .o
cc -O0 -g -pthread program.o -o program -lm
dsymutil program # writes program.dSYM
lldb ./program
(lldb) breakpoint set -f program.ty -l 7
-c and a separate link step are the point: dsymutil reads the DWARF out of the object files the executable names, so a one-step cc program.c -o program leaves it nothing to find and the .dSYM comes out empty (dsymutil says no debug symbols in executable, and the breakpoint above resolves to no locations).
(-g alone still produces a runnable binary on macOS; the .dSYM step is only needed for source-level stepping.)
Each block is checked on the host that can run it.
make docs-fencesexecutes every command block in the docs and requires exit 0.gdbis not in the macOS toolchain anddsymutilis Xcode-only, so the gdb block runs on Linux and the lldb block on macOS; on each platform the other block is an enumerated skip inscripts/docs_fences.pyrather than an unchecked category.
tycho debug — the gdb adapter
tycho debug (the tycho-debug tool in tools/tycho-debug/) wraps the raw gdb workflow: it compiles the program with -g itself, starts gdb, and drives an interactive session that breaks by source line and shows locals under their Tycho names (the h_ prefix is stripped; arena machinery is hidden):
tycho debug program.ty [args...]
tycho debug -b 7 -b 12 program.ty # entry-file lines
tycho debug -b lib/parse.ty:42 program.ty # a package file
tycho-debug: debugging program.ty (q quits, h for help)
tycho-debug> b 7
breakpoint 1 at program.ty:7
tycho-debug> r
[breakpoint 1 hit]
at program.ty:7 (main)
tycho-debug> loc
x = 41
y = 42
tycho-debug> p h_x # raw C names for expressions
41
tycho-debug> n
[stepped]
at program.ty:8 (main)
Commands: b <line> break, r run, n next, s step, c continue, p <expr> print a C expression, loc locals, bt a backtrace with demangled pkg.name symbols and .ty file:line, l source around the current line, $ <gdb cmd> raw gdb passthrough, q quit. Ctrl-C interrupts the running program (the tool forwards SIGINT to the inferior); Ctrl-D quits.
tycho-debug> bt
#0 program.ty:7 (main)
#1 runtime (main) [runtime]
Frames whose source is not a .ty file are tagged [runtime] for Tycho glue or [native] for the C library underneath, so they cannot be mistaken for user code.
It is a gdb adapter, not a re-implementation: expressions are the emitted C names (h_x, *h_x for an inout), and $ hands anything gdb understands straight through. It needs gdb on PATH; TYCHOC selects the compiler and TYCHODEBUG the debugger binary, same as TYCHOFMT does for the formatter. The debugger is itself a Tycho program (make tycho-debug), dogfooding core:strings over a small spawn/pipe/signal shim (debug_shim.c); it is gated by make debug-check. Its shell-outs go through the shim rather than core:os because os.system reaches cmd.exe verbatim on Windows, which cannot execute a command name written with forward slashes.
Runtime traps
A -g build records the current statement before it runs, so a bounds, division, empty-pop or similar runtime trap prints the owning source line:
tycho: index 5 out of bounds (len 3)
at examples/parse.ty:42
Set TYCHO_BACKTRACE=1 to add the C frames. They are addresses and symbols; resolve them against the binary with addr2line -e ./program <address>. Normal builds print neither, and pay nothing on the non-trapping path.
What the frames look like
The emitted C is faithful but not idiomatic, so a few things read differently in a debugger:
- Every function takes a hidden first argument
Arena *_parent— the arena that owns its return value. You'll see it on every frame; ignore it unless you're debugging allocation. inoutparameters are passed by pointer. Ainout int xis along *h_xin C; print*h_x, noth_x. A heapinoutalso carries anArena *_ina_xjust before it.- Locals are prefixed
h_(h_xfor Tychox) to avoid clashing with C keywords and runtime symbols. - Statement temporaries live in per-statement
_tarenas freed at the end of each statement; block/loop bodies get their own_b/_scopearenas. These are the arena-management lines you'll step over between your statements.
Where the memory lives: TYCHO_ARENA_STATS
Set the env var on any Tycho binary (built by either compiler — no rebuild, no flag) and a residency summary prints to stderr at exit:
[tycho arena stats] peak live: 75.9 MiB (working-set high-water) bump-alloc: 315.6 MiB over 11177285 allocations OS reserved: 79.1 MiB over 902 blocks block reuse: 777462 of 778364 requests from pool (99.9%) arenas: 8790511 created, 8790511 freed
by function (20 of 139 shown, by peak): main 75.6 MiB peak 75.8 MiB bump 1646059 allocs parse_program 7.3 MiB peak 10.9 MiB bump 455484 allocs gfix_program 4.0 MiB peak 12.2 MiB bump 518127 allocs ... ... 119 more (TYCHO_ARENA_STATS=full lists every function)
Reading it:
- **peak live** is the working-set high-water — the number that decides whether
the program fits in memory. **bump-alloc** is everything ever handed out;
a large gap between the two is arenas doing their job.
- **A function's row counts the memory its arena OWNS**, which is the correct
attribution: a value returned up into the caller's arena is the *caller's*
residency, not the callee's. So a `main` that holds the whole program value
dominates, and per-pass rows show each pass's transient working set.
- `(unlabeled)` collects allocations from arenas with no owning proc — task
roots, channel cells, and per-statement `_t` temporaries (~1% of bump on the
self-compile).
- Counters are only touched when the variable is set; a normal run pays one
never-taken branch per allocation.
## Limits
- **Package builds.** `-g` emits a `#line` before each statement naming the
file that owns it. `tycho debug` accepts package builds; break with
`b lib/parse.ty:42`, and `l` follows whichever file the stop is in.
- **Synthesized glue is unmapped.** The program entry point, arena bootstrap,
and other compiler-generated scaffolding have no `.ty` line; a `#line`
directive only anchors real statements, so stepping through the glue shows the
nearest preceding source line. This is cosmetic.
- **Relative paths resolve against the compile directory.** If you compiled with
a relative path (`tychoc src/program.ty -g`), debug from the same working
directory so the debugger finds the source.