The C backend¶
The C backend (name = "c") lowers a typed POST IR module to a single C99 translation unit and hands it to the system C compiler. It is the oldest, most complete backend and doubles as the project's reference implementation — the correctness oracle every other backend is measured against.
It does no optimization of its own. The emitted C is straight-line, SSA-shaped code; register allocation, inlining, vectorization and the rest are delegated to cc -O2 -fPIC. That keeps the backend small and lets it ride decades of GCC/Clang investment.
At a glance¶
| Field | Value |
|---|---|
| Registry key | c |
| Source extension | .c |
| Capabilities | complex=True, ext_module=True |
| Emits | one C99 translation unit per module |
| Optimizer | the system C compiler (cc -O2 -fPIC) |
| Availability | any of cc / clang / gcc on PATH |
The class is a plain implementation of the Backend protocol:
class CBackend:
name = "c"
source_ext = ".c"
capabilities = Capabilities(complex=True, ext_module=True)
def emit(self, module: Module) -> str: ...
def compile_object(self, source: Path, obj: Path, tc: Toolchain) -> None: ...
def runtime_sources(self, modules: list[Module]) -> list[RuntimeUnit]: ...
runtime_sources returns []: every runtime helper the C backend needs is inlined into each translation unit's preamble, so there is no separate runtime object to link.
The reference backend¶
Every backend consumes the same IR, so a program compiles identically through any of them. The C backend is the one the differential test suite treats as ground truth: when the QBE backend — or any future backend — disagrees with C on a value, the C result is presumed correct. Because it is the reference, the C backend is also the most complete: it is the only one that currently advertises both complex and ext_module.
Type vocabulary¶
The backend maps the entire POST dtype set to C types:
| POST dtype | C type |
|---|---|
Bool |
bool |
Int8 … Int64 |
int8_t … int64_t |
UInt8 … UInt64 |
uint8_t … uint64_t |
Float32 / Float64 |
float / double |
Complex64 / Complex128 |
float _Complex / double _Complex |
Complex arithmetic is native: the preamble includes <complex.h>, complex constants are emitted as (re + im * _Complex_I), and abs/** dispatch to cabs/cabsf and cpow/cpowf. This is why the C backend sets complex=True.
Float16, Str and Bytes are rejected
They are valid POST Python that this compiler does not lower, so the frontend rejects them with
PP900 on every backend — including C. The C backend used to map Float16 to uint16_t and claim that
was "storage, not arithmetic". It was not storage: Float16(a) emitted (uint16_t)_a, a C
float-to-integer truncation, so Float16(1.5) + Float16(2.5) gave 3.0 and Float16(0.1) gave
0.0. c_type() now raises CUnsupportedError rather than inventing a type. Write Float32.
Python semantics in C¶
C's integer / and % truncate toward zero; Python's // and % floor toward negative infinity. Rather than special-case every site, the backend emits two macros into the preamble and calls them for signed-integer floor-division (__pp_floordiv_si) and modulo (__pp_mod_si):
/* floor division: rounds toward -inf, unlike C's `/` */
#define __pp_floordiv_si(a, b) \
(((a) / (b)) - ((((a) % (b)) != 0) && (((a) < 0) != ((b) < 0)) ? 1 : 0))
/* modulo: remainder takes the sign of the divisor, unlike C's `%` */
#define __pp_mod_si(a, b) \
(((a) % (b)) + ((((a) % (b)) != 0) && (((a) < 0) != ((b) < 0)) ? (b) : 0))
Both dispatch per dtype: floats route to floor(a/b) and fmod; unsigned and bool fall through to plain C / and %, which already match Python for non-negative operands. Exponentiation is handled the same way — ** on integers calls the inlined __pp_ipow_i64 / __pp_ipow_u64 (exponentiation by squaring; negative int exponents yield 0), while float and complex ** route to pow/powf and cpow/cpowf.
Reserved-symbol mangling¶
A POST function may legitimately be named sin, floor, malloc, j0 — names that already exist in libm/libc. Emitting them verbatim would shadow the library symbol at link time. c_symbol (in backend/common.py) rewrites any name in the reserved set to a __pp_ prefix:
So a POST j0 is emitted and linked as __pp_j0. The rule is shared by every backend and by the ABI export layer, so a given POST name maps to exactly one object-file symbol regardless of which backend compiled it. The mangling is an internal detail: the stable export contract (below) is always pp_j0.
Private POST functions (underscore-prefixed, per spec §9.1) are additionally emitted with static storage so same-named helpers in different translation units cannot collide.
Arrays and NumPy ufuncs¶
Arrays are passed as a NumPy-compatible view struct, __pp_array (spec §9.2), with byte strides:
typedef struct __pp_array {
void *data;
int64_t ndim;
int64_t const *shape;
int64_t const *strides; /* byte strides */
int64_t offset_bytes;
} __pp_array;
Loads, stores, dims and strides go through inlined macros (__pp_array_at, __pp_array_dim, __pp_array_stride, __pp_array_len). For vectorized functions the backend emits a <name>_ufunc_loop wrapper conforming to the NumPy (g)ufunc calling convention. Building with -DNUMPY_UFUNC pulls in the real NumPy headers; without it the preamble supplies ABI-compatible fallbacks (npy_intp → ssize_t) so the loop still compiles and can be registered later via numpy.ctypeslib.
Stable ABI and CPython extension modules¶
backend/abi.py builds the Package ABI v1 layer on top of the C backend. For every export it emits a thin wrapper named pp_<name> with the export's exact signature, delegating to the (possibly mangled) kernel symbol:
Alongside the wrappers, the ABI layer emits a self-contained C99 header (emit_header) and a machine-readable JSON manifest (export_manifest, "post_abi": 1) mapping Python names to C symbols, dtypes, aliases and ufunc layouts.
Because this glue — the pp_* wrappers, the header, and the PyInit shim that registers the module with CPython — is itself C, the C backend is the one that can back a CPython extension module directly. That is what the ext_module=True capability advertises.
Compilation¶
compile_object simply forwards to the toolchain:
which runs the C compiler with its default flags:
-O2 is where all optimization happens; -fPIC makes the object suitable for the shared library the driver links. To target a different compiler or flag set, construct a Toolchain with a different cc or cflags — see the toolchain reference.
Writing your own backend?
The C backend is the worked example. Read it alongside Writing a backend: it shows the minimum a backend must lower, and its output is the reference your backend's results are diffed against.