Skip to content

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 is libpebble_runtime.a. It is built from pebble_runtime.c (output, float formatting, traps) and pebble_main.c (the C main).
  • pebblec --emit=exe links 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. -lm is added on Linux because float % becomes a call to fmod.
  • lit's %runtime substitution 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 ptr to a copy made by the caller. The callee may modify it, and the caller's value is unaffected. Pebble's &T/&mut T parameters are passed as plain pointers to the caller's place, with no copy.
  • Aggregate results use a hidden first parameter ptr sret(%T) noalias that points to caller-provided storage. The function then returns void.
  • unit results are void.
  • Defined functions other than pebble_main have internal linkage, 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:

  1. nan, inf and -inf for the special values;
  2. 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;
  3. 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);
  4. with .0 appended 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):

  1. calls fflush(stdout),
  2. writes pebble: trap: <message> at <file>:<line>:<column>\n to stderr, or pebble: trap: <message>\n if file is NULL or line is 0,
  3. 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

int main(void) { return (int)pebble_main(); }

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:

  1. define i64 @pebble_main(), or define a C main itself (then the runtime's is not linked);
  2. call only the runtime functions of §2 and C library functions, with the declarations shown in §2;
  3. report traps by calling @pebble_trap with the codes of §6 (never llvm.trap, whose status and message differ), so that end-to-end tests see identical behavior;
  4. 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.