The Pebble Runtime and ABI¶
This is the machine-level contract between compiled Pebble programs and the C runtime library (pebble/runtime/). It also binds any front end that skips PIR and hands pebblec --from-llvm its own LLVM IR. It covers:
- which symbols exist and what they do,
- how Pebble and PIR types are laid out in memory,
- how functions are called,
- what happens at program start, exit and on a trap.
The runtime is deliberately tiny, about 200 lines of C11. The C header pebble/runtime/include/pebble_runtime.h is authoritative for the declarations.
1. Building and linking¶
- CMake target:
pebble_runtime, a static library whose archive name islibpebble_runtime.a. It is built frompebble_runtime.c(output, float formatting, traps) andpebble_main.c(the Cmain). pebblec --emit=exelinks as<cc> -o <out> <object> <libpebble_runtime.a> [-lm], where<cc>is the C compiler the build was configured with. Override it with--cc=or$PEBBLE_CC, and the archive with--runtime=or$PEBBLE_RUNTIME.-lmis added on Linux because float%becomes a call tofmod.- lit's
%runtimesubstitution expands to the archive's path.
2. Symbols¶
All functions use the platform's C calling convention. Names beginning with pebble_ are reserved: programs must not define them (PIR verifier rule V5).
| Symbol | C signature | LLVM IR declaration | Behavior |
|---|---|---|---|
pebble_print_int |
void (int64_t) |
declare void @pebble_print_int(i64) |
prints decimal, e.g. -42 |
pebble_print_float |
void (double) |
declare void @pebble_print_float(double) |
prints the canonical float text (§5) |
pebble_print_bool |
void (bool) |
declare void @pebble_print_bool(i1 zeroext) |
prints true / false |
pebble_print_str |
void (const char *) |
declare void @pebble_print_str(ptr) |
prints bytes up to the NUL |
pebble_print_newline |
void (void) |
declare void @pebble_print_newline() |
prints \n |
pebble_trap |
_Noreturn void (int32_t kind, const char *file, int32_t line, int32_t column) |
declare void @pebble_trap(i32, ptr, i32, i32) noreturn nounwind cold |
see §6 |
pebble_format_float |
size_t (double, char *, size_t) |
– | the formatter behind pebble_print_float, also used by pir-run and the PIR printer |
pebble_trap_message |
const char *(int32_t kind) |
– | the message for a trap kind |
pebble_main |
int64_t (void) |
define i64 @pebble_main() |
provided by the program: its main (§7) |
main |
int (void) |
– | provided by the runtime; calls pebble_main |
- None of the print functions writes a newline except
pebble_print_newline. - All output goes through C stdio to
stdout. It is flushed at normal exit and before a trap message.
3. Type layout¶
The layout follows the C ABI of the target (natural alignment, fields in declaration order, padding as C would insert it), so Pebble data can be shared with C.
| Pebble | PIR | In memory (LLVM type) | In a register (LLVM type) | C equivalent |
|---|---|---|---|---|
int |
i64 |
i64 |
i64 |
int64_t |
| – | i8 i16 i32 |
i8 i16 i32 |
same | int8_t … |
float |
f64 |
double |
double |
double |
bool |
bool |
i8, holding 0 or 1 |
i1 |
bool |
str |
str |
ptr |
ptr |
const char * (NUL-terminated, static) |
() |
unit |
{} (size 0) |
– (void result) |
void |
[T; N] |
[T; N] |
[N x T] |
– (always in memory) | T[N] |
struct S |
%S |
named struct %S = type { ... } |
– | struct S |
&T, &mut T |
&T, &mut T |
ptr |
ptr |
const T *, T * |
bool follows Clang: i1 in registers, one byte in memory. Every store writes 0 or 1, and loads truncate to i1.
4. Calling convention¶
For functions defined in a Pebble or PIR program:
- Scalars (integers,
float,bool,str, references) are passed and returned by value, as LLVM register types. - Aggregate parameters (arrays, structs) are passed as a
ptrto a copy made by the caller. The callee may modify it, and the caller's value is unaffected. Pebble's&T/&mut Tparameters are passed as plain pointers to the caller's place, with no copy. - Aggregate results use a hidden first parameter
ptr sret(%T) noaliasthat points to caller-provided storage. The function then returnsvoid. unitresults arevoid.- Defined functions other than
pebble_mainhaveinternallinkage, which lets interprocedural optimizations (Chapter 20) change their signatures freely.
extern fn functions use the C ABI directly. Their parameters and results are restricted to integers, f64, bool (zeroext i1) and str, whose C ABI is unambiguous on every supported target. Aggregates never cross the extern boundary, so the front end never has to implement a platform's struct-passing rules.
5. Float text format¶
pebble_print_float and pebble_format_float print:
nan,infand-inffor the special values;- otherwise the shortest decimal digit string that reads back (with
strtod) as exactly the same double, found by trying 1 to 17 significant digits with correctly rounded%.*e; - laid out positionally if the decimal exponent E of the first digit satisfies −7 ≤ E < 21, and in scientific notation otherwise (
d[.ddd]e±X, with no leading zeros in the exponent); - with
.0appended to positional numbers that have no fractional digits.
| value | text |
|---|---|
| 0.0 / −0.0 | 0.0 / -0.0 |
| 3.0 | 3.0 |
| 0.1 + 0.2 | 0.30000000000000004 |
| 1e-7 | 0.0000001 |
| 1.5e-8 | 1.5e-8 |
| 1e20 | 100000000000000000000.0 |
| 1e21 | 1e+21 |
| 2⁶⁴ | 18446744073709552000.0 |
These are JavaScript's Number.prototype.toString rules, plus the .0 suffix, which keeps floats visibly distinct from integers in test output.
6. Traps¶
pebble_trap(kind, file, line, column):
- calls
fflush(stdout), - writes
pebble: trap: <message> at <file>:<line>:<column>\ntostderr, orpebble: trap: <message>\niffileis NULL orlineis 0, - calls
exit(101).
| kind | message | PIR trap kind |
|---|---|---|
| 1 | arithmetic overflow | overflow |
| 2 | division by zero | div_by_zero |
| 3 | index out of bounds | bounds |
| 4 | shift amount out of range | shift |
| 5 | assertion failed | assert |
| 6 | entered unreachable code | unreachable |
| other | unknown trap | – |
Compiled code passes the module's source file name as a private constant string, plus the line and column from the PIR statement's location, or 0 when it has none. pir-run calls the same function with the same arguments, so trap output is identical.
Exit statuses of a Pebble program:
| Status | Meaning |
|---|---|
main's result, modulo 256 |
normal termination |
| 101 | a trap |
| anything else | a crash (e.g. a stack overflow): not specified by the language |
The tools report their own problems with other statuses: pir-run exits with 70 on undefined behavior and 1 on invalid input, and PEBBLE_TODO exits with 99.
7. Program entry¶
The program defines i64 @pebble_main(). PIR @main and Pebble fn main() -> int are emitted under that name. The runtime's pebble_main.c defines
in its own archive member. Static linkers extract an archive member only to resolve an undefined symbol, so a program or tool that defines its own main, like pir-run does, never pulls this one in.
8. The --from-llvm contract¶
A front end may skip PIR and give pebblec an LLVM module (.ll text or .bc bitcode, LLVM 23, opaque pointers). A Rust front end built on inkwell would do this. The module must:
- define
i64 @pebble_main(), or define a Cmainitself (then the runtime's is not linked); - call only the runtime functions of §2 and C library functions, with the declarations shown in §2;
- report traps by calling
@pebble_trapwith the codes of §6 (neverllvm.trap, whose status and message differ), so that end-to-end tests see identical behavior; - follow the layout of §3 for anything it passes to the runtime.
pebblec --from-llvm sets the host target triple and data layout if the module has none, runs llvm::verifyModule, the selected optimization pipeline (-O0/-O1/-O2, --passes, --pass-plugin), and then emits --emit=llvm|obj|exe. The course's own passes therefore apply to such a front end's output too. What such a module gives up is everything PIR provides: the verifier's type checks, pir-run as an oracle, and the PIR golden tests.