Skip to content

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
Int8Int64 int8_tint64_t
UInt8UInt64 uint8_tuint64_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:

def c_symbol(name: str) -> str:
    return f"__pp_{name}" if name in _RESERVED_C_SYMBOLS else name

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_intpssize_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:

double pp_j0(double x)
{
    return __pp_j0(x);
}

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:

def compile_object(self, source: Path, obj: Path, tc: Toolchain) -> None:
    tc.compile_c(source, obj)

which runs the C compiler with its default flags:

cc -O2 -fPIC -c module.c -o module.o

-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.