A language that links with C but cannot be debugged with C has sold you half a boundary. The claim this tutorial demonstrates — and every transcript below is a real session, captured against the shipped compiler — is the whole one: Mica emits standard DWARF v5, so one ordinary gdb session walks Mica frames, C frames, and back, with breakpoints, stepping, watchpoints and variable inspection working identically on both sides of every crossing.
The example is
DebugWalk,
built to be stopped in: the Mica program body calls SumScaled, a
generic Mica function; each round calls DwScale, a C function
lifted through a contract; and the C side calls
back into the exported Mica function Clamp through
its linkage name. Mica → generic → C → Mica, in
one binary.
Setup, step by step
Everything below works in the tutorial container, and on your own machine, with four things:
1. gdb. sudo apt-get install gdb (the container has it).
2. A C compiler. DebugWalk’s C half is C’s own build. The devkit installs
the one it pins, in the container and on your own machine alike:
mica-container/devkit/mica-provision gcc, once.
3. The C/C++ extension. Mica needs no debugger of its own — gdb reads
its DWARF like any native program’s, so the debugger driver is Microsoft’s
stock C/C++ extension (ms-vscode.cpptools), the same one C developers
already run. Install it from the Extensions view; the Mica Developer
container installs it for you.
4. The Mica extension. Press F5 in a Mica file and it builds that
file, or the mica.project it belongs to, at the debug tier and starts gdb
on it through the C/C++ extension. No launch or task file is involved.
DebugWalk’s C half is C’s own build: its Makefile compiles dw_lib.c with
-g into libdw.a, the archive its contract
names. Build it once:
mica-container/devkit/mica-provision gcc # once: the C compiler the C half needs
make -C mica-container/examples/DebugWalkThen open mica-container/examples/DebugWalk/DebugWalk.mica, set a
breakpoint, and press F5: you are stopped at it with both languages'
sources clickable. Everything below is shown as terminal gdb for exactness —
the VS Code experience is the same sessions with a UI. In a terminal, gdb
starts on the program the build wrote:
gdb -q mica-container/examples/build/DebugWalk/DebugWalkSession 1: the sandwich backtrace
Set one breakpoint in the Mica function the C library calls, and run:
(gdb) break helpers.mica:14
(gdb) run
Breakpoint 1, DebugWalk.Clamp (sample=20, low=-100, high=100) at helpers.mica:14
14 if sample < low then
(gdb) bt
#0 DebugWalk.Clamp (sample=20, low=-100, high=100) at helpers.mica:14
#1 0x0000aaaaaaaa2f0c in dw_scale (sample=10, factor=2) at dw_lib.c:18
#2 0x0000aaaaaaaa1cb8 in DebugWalk.SumScaled__int32 (v=..., factor=2) at DebugWalk.mica:55
#3 0x0000aaaaaaaa1a10 in main () at DebugWalk.mica:73Read it from the bottom: the Mica program body (#3), a monomorphized
Mica generic (#2), a C frame with its own file, line and typed arguments
(#1), and a Mica frame the C code called (#0) — four frames, three
language crossings, one unbroken backtrace with arguments readable in
every frame. Nothing was configured to get this; it is what sharing the
platform’s ABI and DWARF buys.
The separately built library direction works the same way — here is
the C-calls-Mica example, a C main linking the
Utilities archive, stopped inside the library by its C-side linkage
name:
make -C mica-container/examples/CCallsMica
gdb -q mica-container/examples/build/CCallsMica/ccallsmica(gdb) break Utilities__Fibonacci
(gdb) run
...
Breakpoint 1, Utilities.Fibonacci (n=10) at Fibonacci.mica:9
(gdb) bt
#0 Utilities.Fibonacci (n=10) at Fibonacci.mica:9
#1 0x0000aaaaaaaa142c in main ()
(gdb) continue
Breakpoint 1, Utilities.Fibonacci (n=9) at Fibonacci.mica:9A breakpoint spelled with the C name lands in the Mica source, and the recursion counts down in the args display — object files, archives, and both directions, one debugger.
Session 2: the generic, really
Generics are where debugging usually goes dark — erased types, mangled soup, synthetic frames. Mica monomorphizes, and the debug information says so plainly. Start a new session on DebugWalk:
gdb -q mica-container/examples/build/DebugWalk/DebugWalk(gdb) info functions SumScaled
File DebugWalk.mica:
43: static float64 DebugWalk.SumScaled__float64(Vector__float64, float64);
43: static float64 DebugWalk.SumScaled__int32(Vector__int32, float64);One source line, two real functions, each named by its concrete type
argument. Break on each specialization separately and inspect v in
both:
(gdb) break DebugWalk.SumScaled__int32
(gdb) break DebugWalk.SumScaled__float64
(gdb) run
Breakpoint 1, DebugWalk.SumScaled__int32 (v=..., factor=2) at DebugWalk.mica:51
(gdb) print v
$1 = {10, 20, 30}
(gdb) ptype v
type = int32 [3]
(gdb) continue
Breakpoint 2, DebugWalk.SumScaled__float64 (v=..., factor=2) at DebugWalk.mica:51
(gdb) print v
$2 = {1.5, 2.5, 90}
(gdb) ptype v
type = float64 [3]
(gdb) finish
Value returned is $3 = 108The same parameter, typed int32 [3] in one stop and float64 [3] in the
next — the type parameter is gone at run time, replaced by the concrete
truth of whichever instantiation is executing, and finish reports the
Mica function’s return value like any native call. This is the question
“do generics really debug?” answered with a transcript rather than a
promise.
(The DebugWalk. prefix is the function’s flat name — the program and
every enclosing procedure joined with dots, the same name a profiler’s
listing and a crash’s backtrace show; info functions prints it, tab
completion finds it, and breakpoints by file:line never need it.)
Session 3: stepping across, and watching through
step at the DwScale call site walks into the C source, and two more
steps walk through C into the called-back Mica. In a new session, stop at
the call site:
gdb -q mica-container/examples/build/DebugWalk/DebugWalk(gdb) break DebugWalk.mica:55
(gdb) run
...
Breakpoint 1.1, DebugWalk.SumScaled__int32 (v=..., factor=2) at DebugWalk.mica:55
55 acc := acc + DwScale(sample, factor);
(gdb) step
dw_scale (sample=10, factor=2) at dw_lib.c:17
17 double scaled = sample * factor;
(gdb) step
18 return DebugWalk__Clamp(scaled, -100.0, 100.0);
(gdb) step
DebugWalk.Clamp (sample=20, low=-100, high=100) at helpers.mica:13
13 Clamp := sample;
(gdb) finish
Run till exit from #0 DebugWalk.Clamp (sample=20, low=-100, high=100) at helpers.mica:13
…dw_scale (sample=10, factor=2) at dw_lib.c:…
Value returned is $1 = 20
(gdb) finish
Run till exit from #0 …dw_scale (sample=10, factor=2) at dw_lib.c:…
DebugWalk.SumScaled__int32 (v=..., factor=2) at DebugWalk.mica:55
55 acc := acc + DwScale(sample, factor);
Value returned is $2 = 20Where the first finish lands in dw_scale is the C compiler’s choice: on
arm64 inside line 18, the call’s own line, which gdb shows with the address,
and on x86-64 at line 19, the closing brace, where gcc puts the code after
the call.
Stepping into a Mica function lands after the prologue with every argument
readable, whether the step comes from Mica or, as the third step above
does, from a C frame — the compiler pins each function’s entry line-table row
to its declaration line, exactly as GCC does, so gdb’s prologue skip works
unaided.
A watchpoint on a generic’s local survives the round-trips through C. In
the float64 specialization, acc goes from 0 to 3, which is 1.5 scaled by
2 and back from C and Clamp, and then to 8, with 2.5 scaled:
(gdb) delete
(gdb) break DebugWalk.SumScaled__float64
(gdb) continue
...
(gdb) watch acc
Hardware watchpoint 3: acc
(gdb) continue
...
Old value = 0
New value = 3
(gdb) continue
...
Old value = 3
New value = 8Inspecting Mica data
Bit records render field by field — gdb reads the packed layout from DWARF bitfields, widths and all. Build the BitRecords example and stop it after its five assignments:
make -C mica-container/examples/BitRecords
gdb -q mica-container/examples/build/BitRecords/BitRecords(gdb) break BitRecords.mica:75
(gdb) run
...
(gdb) print status
$1 = {ready = true, error = false, priority = 5, channel = 129, rest = 0}
(gdb) ptype status
type = struct Status {
bool ready : 1;
bool error : 1;
<range type> priority : 3;
<range type> channel : 8;
<range type> rest : 3;
}Strings print as their text. Every program built at the debug tier
carries gdb printers for strings, string buffers and string parts, and gdb
runs them only from a path you trust, a trust that has to stand before gdb
reads the program. The VS Code extension trusts the program it launches for
you; in a terminal, -iex gives the trust at the start. Save this as
Greeting.mica:
program Greeting;
imp
WriteLn, Append, ToString : std;
var
line : string;
buffer : stringbuffer;
begin
line := "Grüße aus Mica";
Append(address buffer, line);
WriteLn("%ls", ToString(buffer));
end.build it at the debug tier, and start gdb on it with the trust:
mica --compile --link --optimize debug --source Greeting.mica --build build/Greeting
gdb -q -iex "add-auto-load-safe-path ./build/Greeting/Greeting" build/Greeting/Greeting(gdb) break Greeting.mica:13
(gdb) run
...
(gdb) print line
$1 = "Grüße aus Mica"
(gdb) print buffer
$2 = "Grüße aus Mica"
(gdb) print -raw-values -- line
$3 = {data = 0xaaaaaaaa8a6c "G\000\000\000r\000\000\000\374\337e\000\000\000 \000\000\000a\000\000\000u\000\000\000s\000\000\000 \000\000\000M\000\000\000i\000\000\000c\000\000\000a\000\000\000", length = 14}print -raw-values shows the descriptor underneath: the pointer to the
code units and the rune count.
Tasks that are not running have no thread of their own, so bt cannot
show them: a task waiting in a park, or ready to run, lies behind the
context the scheduler saved on its own stack. Every program built at the
debug tier carries a second script beside the string printers, and its
mica-task-backtrace walks such a task from that saved context. The task is
named by its identity, the id the runtime’s narrator and the
runtime protocol show. Here two waiters
park on a pipe nobody writes, a spinner runs, and the program body waits for
all three — TaskStacks.mica:
program TaskStacks;
imp
WriteLn, ScopeDeadline : std;
Pipe, Read, Close : posix;
type
Bytes = array[0..7] of uint8;
FdPair = array[0..1] of int32;
var
ends : FdPair;
rc : int32;
procedure Serve(readEnd : int32);
task Waiter(readEnd : int32);
var
got : int64;
local : Bytes;
begin
got := Read(readEnd, address local[0], 8) on fail use -1;
WriteLn("waiter resumed");
end;
task Spinner();
var
i : int64;
begin
i := 0;
while i < 3 do
begin
i := i + 1;
end;
end;
begin
concurrent
ScopeDeadline(200);
schedule Waiter(readEnd);
schedule Waiter(readEnd);
schedule Spinner();
end;
end;
begin
rc := Pipe(address ends[0]) on fail use -1;
Serve(ends[0]);
WriteLn("serve returned");
end.mica --compile --link --optimize debug --source TaskStacks.mica --build build/TaskStacks
gdb -q -iex "add-auto-load-safe-path ./build/TaskStacks/TaskStacks" build/TaskStacks/TaskStacksStop it in the spinner’s loop, while both waiters are parked and the program body waits for the block’s join:
(gdb) break TaskStacks.mica:33
(gdb) run
...
(gdb) mica-task-backtrace 1
#0 0x55555555cf59 in __mica_task_switch
#1 0x55555555e0c2 in __mica_task_switch_to_next
#2 0x55555555e231 in __mica_task_try_park
#3 0x555555560115 in __mica_task_read
#4 0x555555559107 in TaskStacks.Serve.Waiter at TaskStacks.mica:22
#5 0x55555555cf9f in __mica_task_invoke
#6 0x55555555cf86 in __mica_task_trampoline
(gdb) mica-task-backtrace 0
#0 0x55555555cf59 in __mica_task_switch
#1 0x55555555e0c2 in __mica_task_switch_to_next
#2 0x55555555e6c6 in __mica_task_scope_drain
#3 0x555555559003 in TaskStacks.Serve at TaskStacks.mica:38
#4 0x555555558b90 in main at TaskStacks.mica:48
(gdb) mica-task-backtrace 3
task 3: the task is not suspended: it runs on a carrier, whose own backtrace shows it, or it has finished or never existedThe walk borrows the stopped thread’s stack pointer, program counter and
frame pointer and writes them back afterwards, so bt and continue behave
exactly as before. $mica_task_frames(1) answers the same frames as one line
of JSON, for a tool that asks through an expression.
Any script’s identifiers work — a breakpoint on UtfSources’ Korean-named procedure, quoted like any symbol with a dot in it:
make -C mica-container/examples/UtfSources
gdb -q mica-container/examples/build/UtfSources/UtfSources(gdb) break 'UtfSources.절차의긴이름'
(gdb) run
...
Breakpoint 1, UtfSources.절차의긴이름 () at UtfSources.mica:41Why this works
The debug tier compiles with full optimization off and full fidelity
on: standard DWARF v5, real function names, every local addressable,
line tables that match the source you wrote. There is no “debug build of
the runtime”, no special debugger protocol, and no difference between
debugging a pure Mica program and this two-language sandwich — the C
toolchain’s whole ecosystem (gdb, VS Code’s cpptools, core dumps,
addr2line) applies to Mica because Mica speaks the platform’s formats
natively instead of bringing its own.
What this does not do
- Dynamic arrays read raw — strings print as text, but a dynamic array still shows its descriptor.
- gdb reads Mica with its minimal language — gdb knows no Mica
language, so an expression is parsed its own way:
print a = bassigns instead of comparing. Compare with==inside gdb, which answers1or0. - Release-tier debugging is real but reordered — the optimizer moves work, so stepping release code jumps; debug what you develop at the debug tier, profile what you ship at release, same as C.
Try it
mkdir -p ~/source/repos
cd ~/source/repos
git clone https://gitlab.com/mica-dev/mica-container.git
code mica-container # then: Reopen in Container, and pick Mica Developer
# then open mica-container/examples/DebugWalk/DebugWalk.mica and press F5Then reproduce Session 2 by hand: break SumScaled + tab completion,
print v, continue, print v again — and watch the type change under
your cursor. That is monomorphization, visible.
Next
The learn series’ boundary is told: contracts in, archives out, the kernel, and the debugger across all of it. The remaining ground is the tooling picker: every example in the container debugs through the same three files you just read.