Skip to main content

Trace playground

See ethdebug/format in motion. Each example below compiles a small BUG program and lets you step through its execution trace — watching the source line, in-scope variables, call stack, and instruction context light up at every step.

Click "Try it" on an example to open the trace drawer, then use the step controls to walk through execution. The examples build on each other: start at the top and work down, picking up one idea at a time.

A storage write

Counter increments a single storage slot. Step through it to get a feel for the drawer — the highlighted source line, the value in storage, and the context attached to each instruction.

Counter increment
Increments count from 0 to 1, storing the result
name Counter;

storage {
[0] count: uint256;
}

create {
count = 0;
}

code {
count = count + 1;
}

A function call

Real programs call functions, and the debugger follows them. SimpleFunctions calls an add helper directly, then calls addThree — which itself calls add twice — so the call stack nests two frames deep. Each JUMP into a function pushes a frame (an invoke context); each JUMP back pops it (a return context).

Nested function calls
Calls add directly, then addThree — which calls add twice — storing 60
name SimpleFunctions;

define {
// Simple arithmetic function
function add(a: uint256, b: uint256) -> uint256 {
return a + b;
};

// Function that calls another function
function addThree(x: uint256, y: uint256, z: uint256) -> uint256 {
let sum1 = add(x, y);
let sum2 = add(sum1, z);
return sum2;
};
}

storage {
[0] result: uint256;
}

code {
// Test function calls
let a = 10;
let b = 20;
let c = 30;

// Call add function
let sum = add(a, b);

// Call addThree function
let total = addThree(a, b, c);

// Store result
result = total;
}

For the exact shape of invoke, return, and revert contexts, see the function call spec and the tracing reference.

Watching the optimizer

Compilers rewrite code as they optimize, and transform contexts record what they did. Set the Opt selector to O2 and step through the examples below: each instruction the optimizer rewrote carries a transform array naming the passes responsible.

Tail-call optimization

In TailFactorial the recursive call folds into a loop. The back-edge JUMP carries transform: ["tailcall"] next to its invoke and return, and the call stack stays flat instead of growing one frame per iteration.

Tail-recursive factorial
Computes 5! with an accumulator; TCO folds it into a loop
name TailFactorial;

define {
function fact(n: uint256, acc: uint256) -> uint256 {
if (n == 0) { return acc; }
else { return fact(n - 1, acc * n); }
};
}

storage {
[0] result: uint256;
}

create {
result = 0;
}

code {
result = fact(5, 1);
}

Inlining

In InlineDemo the leaf helper square is small enough that at O2 the compiler splices its body straight into each call site. sumOfSquares = square(a) + square(b) calls it twice, and after inlining neither call JUMPs into square — each multiply runs inline, and those instructions carry transform: ["inline"] pointing back to the square body they came from. The call stack marks the inlined square as a virtual activation, not a real pushed frame.

Inlined leaf calls
Inlines the square helper into both call sites at O2
name InlineDemo;

define {
function square(x: uint256) -> uint256 {
return x * x;
};
}

storage {
[0] a: uint256;
[1] b: uint256;
[2] sumOfSquares: uint256;
}

create {
a = 3;
b = 4;
sumOfSquares = 0;
}

code {
sumOfSquares = square(a) + square(b);
}

Flip the Opt selector between O0 and O2 to see the difference: at O0 each program calls its helper the ordinary way; at O2 the transforms above take over. For how these transforms compose with invoke/return, see the transform context spec.