Skip to content

Commit 8714233

Browse files
authored
bugc: mark a function's return on its exit jump (#349)
Put the return context on the callee's JUMP back to the caller, with the return value at stack slot 0, and leave the continuation JUMPDEST with only the call site's code range. buildCallStack now pops a frame on the return's postcondition step instead of one step later.
1 parent b7ae5fe commit 8714233

11 files changed

Lines changed: 371 additions & 168 deletions

File tree

‎packages/bugc/CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ support. Changes to the specification itself are tracked in the root
6262
- An array or bytes index and a slice bound must now be an unsigned
6363
integer, as in Solidity; cast a signed one, as in `a[i as uint256]`.
6464
Before, a signed index compiled ([#344]).
65+
- A function's `return` context is now on its exit: the `JUMP` back to
66+
the caller, as the format's documentation describes. Its `data`
67+
pointer (stack slot 0) is there when the function returns a value. The
68+
caller's continuation `JUMPDEST` keeps only the call site's `code`
69+
range. Before, the `return` was on that `JUMPDEST`, one instruction
70+
later, so a debugger showed the function's frame for two steps after
71+
control was back in the caller ([#349]).
6572

6673
### Fixed
6774

@@ -269,3 +276,4 @@ First publication.
269276
[#344]: https://github.com/ethdebug/format/pull/344
270277
[#345]: https://github.com/ethdebug/format/pull/345
271278
[#347]: https://github.com/ethdebug/format/pull/347
279+
[#349]: https://github.com/ethdebug/format/pull/349

‎packages/bugc/src/evmgen/call-contexts.test.ts‎

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -106,18 +106,18 @@ code {
106106
},
107107
);
108108

109-
it("should emit return context on continuation JUMPDEST", async () => {
109+
it("should emit return context on the callee's exit JUMP", async () => {
110110
const program = await compileProgram(source);
111111

112-
const returnJumpdests = findInstructionsWithContext(
112+
const returnJumps = findInstructionsWithContext(
113113
program,
114-
"JUMPDEST",
114+
"JUMP",
115115
Context.isReturn,
116116
);
117117

118-
expect(returnJumpdests.length).toBeGreaterThanOrEqual(1);
118+
expect(returnJumps.length).toBeGreaterThanOrEqual(1);
119119

120-
const { return: ret } = returnJumpdests[0].context;
120+
const { return: ret } = returnJumps[0].context;
121121

122122
expect(ret.identifier).toBe("add");
123123

@@ -134,6 +134,14 @@ code {
134134
});
135135
});
136136

137+
it("should emit no return context on the continuation JUMPDEST", async () => {
138+
const program = await compileProgram(source);
139+
140+
expect(
141+
findInstructionsWithContext(program, "JUMPDEST", Context.isReturn),
142+
).toHaveLength(0);
143+
});
144+
137145
it(
138146
"should emit invoke context on callee entry " +
139147
"JUMPDEST with args and code target",
@@ -185,28 +193,26 @@ code {
185193
const program = await compileProgram(source);
186194

187195
// The caller JUMP should come before the
188-
// continuation JUMPDEST
196+
// callee's exit JUMP
189197
const invokeJump = findInstructionsWithContext(
190198
program,
191199
"JUMP",
192200
Context.isInvoke,
193201
)[0];
194202

195-
const returnJumpdest = findInstructionsWithContext(
203+
const returnJump = findInstructionsWithContext(
196204
program,
197-
"JUMPDEST",
205+
"JUMP",
198206
Context.isReturn,
199207
)[0];
200208

201209
expect(invokeJump).toBeDefined();
202-
expect(returnJumpdest).toBeDefined();
210+
expect(returnJump).toBeDefined();
203211

204212
// Invoke JUMP offset should be less than
205-
// return JUMPDEST offset (caller comes first
213+
// return JUMP offset (caller comes first
206214
// in bytecode)
207-
expect(Number(invokeJump.offset)).toBeLessThan(
208-
Number(returnJumpdest.offset),
209-
);
215+
expect(Number(invokeJump.offset)).toBeLessThan(Number(returnJump.offset));
210216
});
211217

212218
describe("void function calls", () => {
@@ -238,15 +244,15 @@ code {
238244
async () => {
239245
const program = await compileProgram(voidSource);
240246

241-
const returnJumpdests = findInstructionsWithContext(
247+
const returnJumps = findInstructionsWithContext(
242248
program,
243-
"JUMPDEST",
249+
"JUMP",
244250
Context.isReturn,
245251
);
246252

247-
expect(returnJumpdests.length).toBeGreaterThanOrEqual(1);
253+
expect(returnJumps.length).toBeGreaterThanOrEqual(1);
248254

249-
const { return: ret } = returnJumpdests[0].context;
255+
const { return: ret } = returnJumps[0].context;
250256
expect(ret.identifier).toBe("setVal");
251257
// Since setVal returns a value, data should
252258
// be present
@@ -310,15 +316,19 @@ code {
310316
expect(invokeIds).toContain("addThree");
311317
expect(invokeIds).toContain("add");
312318

313-
// Should have return contexts for all
314-
// continuation points
315-
const returnJumpdests = findInstructionsWithContext(
319+
// Should have return contexts on both
320+
// functions' exit JUMPs
321+
const returnJumps = findInstructionsWithContext(
316322
program,
317-
"JUMPDEST",
323+
"JUMP",
318324
Context.isReturn,
319325
);
320326

321-
expect(returnJumpdests.length).toBeGreaterThanOrEqual(3);
327+
const returnIds = returnJumps.map(
328+
(instr) => instr.context.return.identifier,
329+
);
330+
expect(returnIds).toContain("addThree");
331+
expect(returnIds).toContain("add");
322332
});
323333
});
324334

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* Call frame timing, checked against real traces. Instruction
3+
* contexts are postconditions: the context of the instruction run
4+
* at step i-1 holds at step i. Replaying the invoke and return
5+
* contexts that way, a called function's frame must be on from the
6+
* step after the caller's JUMP (the callee's entry JUMPDEST) through
7+
* the step of the callee's exit JUMP, and off at the step after it
8+
* (the caller's continuation JUMPDEST). At that step, the exit's
9+
* `return` data pointer must read the returned value.
10+
*/
11+
import { describe, it, expect } from "vitest";
12+
import { createMachineState } from "@ethdebug/evm";
13+
import { dereference } from "@ethdebug/pointers";
14+
import { Program } from "@ethdebug/format";
15+
import type * as Format from "@ethdebug/format";
16+
import { traceLocals, type Level } from "#test/evm/locals";
17+
18+
const { Context } = Program;
19+
20+
const programs = [
21+
{
22+
name: "nested calls and a function with two exits",
23+
source: `name Exits;
24+
define {
25+
function add(a: uint256, b: uint256) -> uint256 { return a + b; };
26+
function pick(a: uint256) -> uint256 {
27+
if (a > 5) { return a; }
28+
return 99;
29+
};
30+
function addThree(a: uint256) -> uint256 { return add(add(a, 1), 2); };
31+
}
32+
storage { [0] r: uint256; [1] s: uint256; [2] t: uint256; [3] u: uint256; }
33+
create { r = 0; }
34+
code {
35+
r = add(10, 20);
36+
s = pick(7);
37+
t = pick(2);
38+
u = addThree(5);
39+
}`,
40+
// The values each function returns, in order, at O0 (higher
41+
// levels inline some calls away)
42+
returns: [30n, 7n, 99n, 6n, 8n, 8n],
43+
},
44+
{
45+
name: "recursion",
46+
source: `name Recursion;
47+
define {
48+
function fact(n: uint256) -> uint256 {
49+
if (n < 2) { return 1; }
50+
return n * fact(n - 1);
51+
};
52+
}
53+
storage { [0] r: uint256; }
54+
create { r = 0; }
55+
code { r = fact(4); }`,
56+
returns: [1n, 2n, 6n, 24n],
57+
},
58+
];
59+
60+
/** A context's invoke and return leaves, outside inlined bodies */
61+
function events(context: Format.Program.Context | undefined): {
62+
invoke?: string;
63+
return?: string;
64+
} {
65+
if (!context) return {};
66+
if (Context.isTransform(context) && context.transform.includes("inline")) {
67+
return {};
68+
}
69+
return {
70+
...(Context.isInvoke(context) ? { invoke: context.invoke.identifier } : {}),
71+
...(Context.isReturn(context) ? { return: context.return.identifier } : {}),
72+
};
73+
}
74+
75+
describe("call frame timing", () => {
76+
for (const { name, source, returns } of programs) {
77+
for (const level of [0, 1, 2, 3] as Level[]) {
78+
it(`${name} at O${level}`, async () => {
79+
const { program, executor, steps, instructionAt } = await traceLocals(
80+
source,
81+
level,
82+
);
83+
84+
// Each function's body: from its entry JUMPDEST to the next
85+
// function's entry (bugc lays functions out one after another,
86+
// after the main code)
87+
const entries = program.instructions
88+
.filter(
89+
(instruction) =>
90+
instruction.operation?.mnemonic === "JUMPDEST" &&
91+
events(instruction.context).invoke !== undefined,
92+
)
93+
.map((instruction) => ({
94+
name: events(instruction.context).invoke!,
95+
offset: Number(instruction.offset),
96+
}))
97+
.sort((a, b) => a.offset - b.offset);
98+
const functionAt = (pc: number) =>
99+
entries.filter(({ offset }) => offset <= pc).at(-1)?.name;
100+
101+
// Replay the frames by the postcondition rule. The caller's
102+
// JUMP and the callee's JUMPDEST both carry the invoke, on
103+
// consecutive steps: count it once.
104+
const frames: { name: string; step: number }[] = [];
105+
const read: bigint[] = [];
106+
let calls = 0;
107+
for (let i = 0; i < steps.length; i++) {
108+
if (i > 0) {
109+
const previous = instructionAt(steps[i - 1]);
110+
const { invoke, return: returned } = events(previous?.context);
111+
if (returned !== undefined && invoke !== undefined) {
112+
// A tail call's back-edge: the frame is reused
113+
frames[frames.length - 1] = { name: invoke, step: i };
114+
} else if (returned !== undefined) {
115+
expect(frames.pop()?.name).toBe(returned);
116+
const ret = (previous!.context as Format.Program.Context.Return)
117+
.return;
118+
if (ret.data) {
119+
const state = createMachineState(executor, {
120+
traceStep: steps[i],
121+
});
122+
const cursor = await dereference(ret.data.pointer, { state });
123+
const view = await cursor.view(state);
124+
read.push((await view.read(view.regions[0])).asUint());
125+
}
126+
} else if (invoke !== undefined) {
127+
const top = frames[frames.length - 1];
128+
if (top?.name === invoke && top.step === i - 1) {
129+
top.step = i;
130+
} else {
131+
frames.push({ name: invoke, step: i });
132+
calls++;
133+
}
134+
}
135+
}
136+
137+
// The innermost frame is the function whose code this
138+
// step runs: on at the callee's entry JUMPDEST, through
139+
// its exit JUMP, and off at the caller's continuation
140+
expect(
141+
frames[frames.length - 1]?.name,
142+
`step ${i} (pc ${steps[i].pc})`,
143+
).toBe(functionAt(steps[i].pc));
144+
}
145+
146+
expect(frames).toEqual([]);
147+
expect(read).toHaveLength(calls);
148+
if (level === 0) {
149+
expect(read).toEqual(returns);
150+
} else {
151+
for (const value of read) expect(returns).toContain(value);
152+
}
153+
});
154+
}
155+
}
156+
});

‎packages/bugc/src/evmgen/generation/block.ts‎

Lines changed: 6 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -65,21 +65,16 @@ export function generate<S extends Stack>(
6565

6666
// Set JUMPDEST for non-first blocks
6767
if (!isFirstBlock) {
68-
// Check if this is a call continuation
69-
let isContinuation = false;
70-
let calledFunction = "";
68+
// A call's continuation resumes at the call expression, so its
69+
// JUMPDEST carries the call site's source range. The callee's
70+
// exit JUMP already carries the `return`.
7171
let callSiteCode: Format.Program.Context.Code["code"] | undefined;
7272
if (func && predecessor) {
7373
const predBlock = func.blocks.get(predecessor);
7474
if (
7575
predBlock?.terminator.kind === "call" &&
7676
predBlock.terminator.continuation === block.id
7777
) {
78-
isContinuation = true;
79-
calledFunction = predBlock.terminator.function;
80-
// The continuation resumes at the call expression, so
81-
// carry the call site's source range onto the return
82-
// context (disjoint keys — flat composition).
8378
const ctx = predBlock.terminator.operationDebug?.context as
8479
| Record<string, unknown>
8580
| undefined;
@@ -89,39 +84,13 @@ export function generate<S extends Stack>(
8984
}
9085
}
9186

92-
// Add JUMPDEST with continuation annotation if applicable
93-
if (isContinuation) {
94-
// Return context describes state after JUMPDEST
95-
// executes: TOS is the return value (if any).
96-
// data pointer is required by the schema; for
97-
// void returns, slot 0 is still valid (empty).
98-
const calledFunc = functions?.get(calledFunction);
99-
const declaration =
100-
calledFunc?.loc && calledFunc?.sourceId
101-
? {
102-
source: { id: calledFunc.sourceId },
103-
range: calledFunc.loc,
104-
}
105-
: undefined;
106-
const returnCtx: Format.Program.Context.Return = {
107-
return: {
108-
identifier: calledFunction,
109-
...(declaration ? { declaration } : {}),
110-
data: {
111-
pointer: {
112-
location: "stack" as const,
113-
slot: 0,
114-
},
115-
},
116-
},
117-
};
87+
if (callSiteCode) {
11888
const entry = block.entryDebug;
11989
const continuationDebug = {
12090
...entry,
12191
context: {
12292
...entry?.context,
123-
...returnCtx,
124-
...(callSiteCode ? { code: callSiteCode } : {}),
93+
code: callSiteCode,
12594
} as Format.Program.Context,
12695
};
12796
result = result.then(JUMPDEST({ debug: continuationDebug }));
@@ -260,7 +229,7 @@ export function generate<S extends Stack>(
260229
const terminate =
261230
term.kind === "call"
262231
? generateCallTerminator(term, functions)
263-
: generateTerminator(term, isLastBlock, isUserFunction);
232+
: generateTerminator(term, isLastBlock, isUserFunction, func);
264233
// A terminator carries an inlined function's invoke or return
265234
// only when its block has no instruction to carry it (an
266235
// inlined function that starts with a call, or returns a call's

0 commit comments

Comments
 (0)