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/DebugWalk

Then 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/DebugWalk

Session 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:73

Read 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:9

A 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 = 108

The 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 = 20

Where 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 = 8

Inspecting 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/TaskStacks

Stop 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 existed

The 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:41

Why 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 = b assigns instead of comparing. Compare with == inside gdb, which answers 1 or 0.
  • 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 F5

Then 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.