← board

crtl: implement the libc assumptions real-world C leans on

Why a standing ticket

Each real-world bring-up ([[feature-c-corpus-zlib]], tcc next) surfaces a fresh batch of "libc assumed X" gaps. Rather than a ticket per tiny gap, collect them here; split a dedicated ticket out only when a gap is large or blocks a whole project. Distinguish: a compiler/parser gap is Track C (own ticket); a library surface gap (missing header symbol, wrong macro, absent function) is this ticket / Track B.

Known / expected assumption classes (fill in as found)

How to work it

Bring up a real project → when it fails on a library symbol/assumption (not a parser bug), add the concrete gap here with the project + call site, implement the smallest crtl piece that satisfies it, land green (make lib-test), tick it off. Keep gcc's libc as the oracle for behaviour.

Gate

Per-item: the crtl addition compiles + the consuming project advances; make lib-test stays green. Ongoing ticket — never "done", pruned as the corpus grows.

Collected gap: <inttypes.h> (2026-07-20, Track B — CLOSED)

Was: 15 PRI macros and zero SCN macros, so any scanf("%" SCNd64, &v) failed — and failed confusingly, because a missing PRI/SCN macro is not an error at its definition, it is an undefined identifier inside string concatenation, which surfaces as a syntax error some distance from the cause. Also declared strtoimax/strtoumax with no implementation anywhere in lib/crtl/src — exactly the "declared-but-unimplemented" category this ticket lists first.

Now: the full C99 set — PRI and SCN, for d/i/u/o/x/X, across 8/16/32/64/LEAST/FAST/PTR/MAX — plus imaxdiv_t, imaxabs, imaxdiv, and real bodies for strtoimax/strtoumax in lib/crtl/src/stdlib.c.

The part worth remembering: the first draft was written from glibc's table and was WRONG in two groups, because our <stdint.h> is not glibc's:

type glibc LP64 ours consequence
intmax_t long long long MAX group is "ll*", not "l*"
int_fast16_t / int_fast32_t long long FAST16/32 are "l*", not plain
int64_t long long long 64-bit group is "ll*"

None of these warn at the call site — they are varargs, so a wrong modifier reads the wrong number of bytes off the stack and prints garbage. The header now says this at the top so the next editor re-derives rather than assumes.

Gated by test/crtl_inttypes.c in make lib-test (exit 42 on success, like the other crtl_*.c tests). It is deliberately printf-free: a wrong length modifier IS a varargs bug, so a printf-based check would be testing the bug with the bug — it compares the macro strings instead. Note gcc returns 1, not 42, on this file and must not be "fixed" to agree: it asserts our ABI, and only the 8/16/32-bit and LEAST groups are common ground with glibc.

Separate finding, NOT fixed here

Any crtl C program that calls printf dies at runtime under the current pin:

/tmp/p: symbol lookup error: /tmp/p: undefined symbol: __pxx_fegetround

Reproduces with a bare printf("hi %d\n", 42), so it is nothing to do with inttypes. __pxx_fegetround is registered by compiler/cparser.inc (~line 7148), so this is pin lag — the pinned v222 predates it — not a live defect in HEAD. Worth confirming after the next make pin; it is also why the existing crtl_*.c tests are all exit-code based rather than printing anything.

Declared-but-unimplemented sweep (2026-07-20, Track B)

Ran the ticket's own first category as an actual sweep rather than waiting for the next project to trip over it: every extern function declared in lib/crtl/include/** checked for a definition in lib/crtl/src/**, then each survivor probed by taking its address, linking, and running.

107 declared, 23 with no visible definition, exactly 1 real gap. The other 22 resolve and were noise in the static check, worth recording so nobody re-chases them:

The one real gap: exp2. Declared in <math.h>, defined nowhere. A C program calling it compiled, linked, and then died at run time with undefined symbol: exp2 — the worst-behaved shape in this category, because nothing catches it until the program is already running.

Implemented as exp(x * ln2) with ln2 carried as a double-double, so the product keeps its low bits rather than losing them to a rounding before the exponential (which is where a naive exp(x * M_LN2) drifts for large |x|). Exact powers of two return ldexp directly — 2^k must be exact for integral k, and routing those through the series would round. Judged against 120-digit references: 16 cases, all correctly rounded, 0 ulp. Gated as test/crtl_exp2.c in make lib-test.

Standing-collector note

This ticket is an ongoing collector by design — its own status line says so — so it does not have a "done" state and should not sit in the ready queue as if it did. The currently-collected batch (inttypes completeness + this sweep) is closed. File the next batch against it when a project trips over something; the sweep above is cheap to re-run and worth repeating after any header change.

Collected sweep: declared-but-unimplemented census + a gcc DIFFERENTIAL (2026-07-31, Track B)

The first assumption class this ticket lists is "header symbols declared-but-unimplemented (functions real code calls)". Swept it.

A textual census of lib/crtl/include/**.h against lib/crtl/src suggested 58 missing symbols. Empirically, 36 of the plausible ones all compile and link. The census was mostly false positives — bodies live in places a regex over src/ does not see. Worth recording because the next person will run the same grep and reach the same wrong conclusion: link-probe the candidates, do not grep for definitions.

What replaced the census: an oracle test

Linking is not agreement, and this ticket already says gcc's libc is the oracle for behaviour — but nothing gated that. test/crtl_libc_oracle.c now does, and it is in lib-test: the same file is built by gcc and by pxx and the entire output is diffed, so there are no recorded expectations to drift. A recorded expectation for a libc surface is just our own behaviour written down.

The batch, chosen as the places a wrong answer would be silent:

Result: 25 lines, byte-identical to gcc. No gap to close in this batch — the value landed is the gate, not a fix.

One real bug found on the way

The first draft used a GNU nested function as the bsearch comparator. pxx warned "undeclared identifier 'cmp' used as value (treated as 0)", built the program anyway, and it segfaulted calling through null. Filed as [[bug-c-undeclared-identifier-as-function-pointer-becomes-null]] (Track C). Nested functions are an extension and not supporting them is defensible; turning an undeclared identifier into a null callback and building it is not. Same "treated as 0" recovery that silently made M_SQRT2 zero in [[bug-crtl-headers-lost-when-cwd-is-not-the-repo-root]].

Collected gap: <errno.h> was missing 39 of 71 names (2026-07-31, Track B — CLOSED)

Second oracle batch, over the assumption classes this ticket lists: widths, <limits.h>/<stdint.h> completeness, struct layouts, and errno.

Was: 36 errno names. A census against the set real code uses found 39 of 71 missing, including the ENTIRE socket family — ECONNREFUSED, ECONNRESET, EINPROGRESS, EADDRINUSE, EHOSTUNREACH, ENOTCONN … — plus EDOM, EILSEQ, ENAMETOOLONG, ELOOP, ENOSYS, EWOULDBLOCK, ENOTSUP.

Why missing is worse than wrong here. An undeclared identifier in C is "treated as 0" with only a warning, so

if (errno == ECONNREFUSED) { ... }   /* compiled to  errno == 0  */

— and 0 is the SUCCESS value. The branch fired exactly when it should not have, on every net-facing path, with a warning that scrolls past in a build log. That is the same "treated as 0" recovery that silently made M_SQRT2 zero ([[bug-crtl-headers-lost-when-cwd-is-not-the-repo-root]]) and that builds a null callback ([[bug-c-undeclared-identifier-as-function-pointer-becomes-null]]). Three separate victims of one recovery rule; it is worth revisiting on its own.

Now: all 71, every value printed by a gcc-built program on this target rather than copied from documentation — these numbers are an ABI. Both glibc aliases are kept (EWOULDBLOCK == EAGAIN, ENOTSUP == EOPNOTSUPP) and the header says so at the declaration.

Gated: test/crtl_libc_oracle.c grew the full errno set plus the widths and limits (sizeof for every integer type, CHAR_BIT, plain-char signedness, the INT/LONG/LLONG/INT64/SIZE_MAX bounds). 40 lines, byte-identical to gcc.

Recorded, NOT a bug: struct stat has a different layout

sizeof(struct stat) is 96 here and 144 under glibc, with st_mode at offset 16 rather than 24. Checked before concluding: our stat() fills OUR layout correctly — size, S_ISREG, S_ISDIR and the permission bits all match gcc on the same files, and a guard buffer around the struct is untouched, so nothing is being handed to the kernel short. It is a self-consistent divergence, not a smash. It matters only to C that hard-codes offsets or moves the struct across an ABI boundary; the layout numbers are deliberately NOT in the oracle test, since they would fail for a benign reason.

Collected gap: strnlen absent (2026-07-31, Track B — CLOSED)

Third oracle batch, over <ctype.h> and <string.h> — the two headers where a wrong answer is silent because the caller is a loop, not a check.

Was: strnlen declared nowhere and implemented nowhere. That one is a HARD error at the call site ("call to undeclared function"), so it is the benign kind — unlike the errno case above, it cannot silently do the wrong thing.

Now: in <string.h> and lib/crtl/src/string.c, stopping at maxlen and returning maxlen when no NUL is found, which is the whole reason the function exists: reading a fixed-width field that may not be terminated.

Everything else in the batch already agreed with gcc, including the places where agreement is not obvious and where drifting would be invisible:

Gated: all of the above is in test/crtl_libc_oracle.c — now 65 lines, byte-identical to gcc's build of the same file.

Collected gaps: three printf/scanf bugs (2026-07-31, Track B — CLOSED)

Fourth oracle batch, over <stdio.h> — formatting and scanning, which every C project leans on and where a wrong answer is silent because the caller is building a string, not checking a result.

1. sscanf("%15s") abandoned the whole scan — the serious one

vsscanf never parsed a field WIDTH. %15s therefore reached the unsupported-conversion break, so the call returned a SHORT count and left the destination untouched:

gcc:  scan-n=3 a=17 b=42 s=word
pxx:  scan-n=2 a=17 b=42 s=          <- destination never written

%Ns is the safe spelling every C programmer is told to use instead of bare %s, so this failed precisely on the code that was being careful. A caller who checks the return sees a mysterious short count; one who does not reads a stale buffer. Assignment suppression (%*d) and a width on any other conversion were broken the same way, for the same reason.

Now parsed: * suppression, a decimal width applied to %s (max characters), %c (exact count, no terminator, incomplete field fails), and the numeric conversions (parsed out of a bounded copy so the width really limits what is looked at, then s advanced by what was actually consumed). %hd/%hhd now write short/signed char rather than int, which was a live stack-overwrite of the adjacent variable.

2. %#o ignored the # flag

printf("%#o", 8) gave 10, gcc gives 010. Expressed as C99's rule — # raises the precision so the first digit is a zero — rather than as a literal "0" prefix, because the value 0 already satisfies the rule and must stay 0 rather than becoming 00. Both cases are pinned.

3. %.0d of zero printed 0

C99 7.19.6.1: a precision of 0 with a value of 0 produces NO characters. We printed 0. Niche, but it is the kind of thing a column-formatting loop depends on.

Everything else in the batch already agreed

Flag/width/precision combinations across d/u/x/X/o/f/s/c including %*d and %.*f, the length modifiers (h, hh, l, ll, z), snprintf's return contract (what WOULD have been written, and truncation without overflow), snprintf(NULL, 0, ...) for sizing, and a file round trip — fgets, ftell, fseek(SEEK_END), ungetc, feof.

One non-finding worth recording: the first draft appeared to show a feof divergence. It did not — printf("...", feof(f), fgetc(f), feof(f)) has UNSPECIFIED argument evaluation order, so the two builds were legitimately reading different sequences. Rewritten as statements, both agree. The oracle test says so at that line, because the next person will be tempted to write it the short way.

Gated: test/crtl_libc_oracle.c is now 97 lines, byte-identical to gcc.

Collected gap: div/ldiv/lldiv/llabs absent (2026-07-31, Track B — CLOSED)

Fifth oracle batch, over <stdlib.h> and <time.h>.

Was: abs and labs only. div, ldiv, lldiv and llabs were declared nowhere, along with the div_t / ldiv_t / lldiv_t structs — all C89 except lldiv/llabs, which are C99. The benign kind of missing (a hard "call to undeclared function"), not the silent kind.

Now: declared in <stdlib.h> and implemented in lib/crtl/src/stdlib.c. The bodies just compute the pair with / and %, because those already satisfy C99 7.20.6.2 on this target — quotient truncates toward zero, remainder takes the numerator's sign — and re-deriving the rule by hand would be a second place for it to be wrong. That equivalence is now asserted rather than assumed: the oracle prints 7/2, -7/2, 7/-2, -7/-2 and the matching % row beside the div_t results.

Everything else in the batch already agreed with gcc, including the parts that are easy to get subtly wrong and would be silent:

Fixed epochs and gmtime rather than localtime on purpose: the test must not depend on the machine's timezone.

Gated: test/crtl_libc_oracle.c is now 113 lines, byte-identical to gcc.

Swept: feature-test macros (2026-07-31, Track B — NOT a library gap)

The last assumption class this ticket names — "feature-test macros & config that gate which code path a project compiles" — is swept, and it turned out to be entirely on the COMPILER side, so nothing lands here.

__STDC__, __STDC_VERSION__, __linux__ and __x86_64__ are correct. Missing: __unix__, __SIZEOF_POINTER__, __SIZEOF_LONG__, __CHAR_BIT__, and __BYTE_ORDER__ / __ORDER_LITTLE_ENDIAN__. The last is the one that matters — endianness is decided at compile time by hash libraries, compression and every wire-format parser, and with the macro absent a project silently takes its fallback branch, which is sometimes slower and sometimes wrong.

Worse, found in the same sweep: __LINE__ expands to 0, __FILE__ to an empty string, and __func__ to an empty string — and when passed as ARGUMENTS all three arrive as NULL/0. Those three are the entire content of every assert and logging macro in real C, so a corpus that builds and runs still reports :0 from an unnamed file, or hands a logger a null pointer.

Filed as [[bug-c-line-file-func-and-predefined-macros-missing]] (Track C). Deliberately not worked around in lib/crtl: a header cannot supply __LINE__, and faking the rest would hide the real gap.

Sweep status of the classes this ticket lists

class state
header symbols declared-but-unimplemented swept — census misleading, link-probe instead; strnlen, div/ldiv/lldiv/llabs were the real gaps
<limits.h> / <stdint.h> / <inttypes.h> completeness swept — correct; inttypes closed earlier
errno values + names swept — 39 of 71 missing, fixed
<ctype.h> assumptions swept — correct
struct layouts (stat, off_t width) swept — struct stat diverges but is self-consistent and correct; recorded, not a bug
feature-test macros & config swept — a COMPILER gap, filed for Track C
math edge functions swept — correct
stdio / printf / scanf swept — 3 bugs, fixed

All of it is now gated by test/crtl_libc_oracle.c against gcc's build of the same file (113 lines), so the next divergence is caught rather than discovered.

Swept clean: <setjmp.h> and <fenv.h> (2026-07-31, Track B — no gap)

Sixth oracle batch. Nothing was broken — every case matched gcc first time: longjmp unwinding across frames, the C99 rule that longjmp(buf, 0) arrives as 1, a volatile local surviving the jump, fegetround/fesetround round trips, and round/trunc/nearbyint on the halfway cases.

Gated anyway, as test/crtl_setjmp_oracle.c, for a reason the rest of the C library does not have: setjmp is codegen-sensitive. It saves and restores the frame, so a register-allocation or prologue change can break it while every other test stays green, and the failure mode is a wild jump rather than a wrong value. test/crtl_header_smoke.c only proved the header COMPILES; nothing exercised the behaviour, and no Makefile target referenced it.

Kept as a separate file from crtl_libc_oracle.c on purpose: longjmp unwinds out of the middle of the enclosing function, so folding it into a large main() with many live locals would make the test about that main() rather than about longjmp.

Batch filed 2026-08-05 — 10 declared-but-unimplemented, all needing PAL work

Found by probing all 361 crtl declarations for an implementation (take the function's ADDRESS in a program including only its header; an unimplemented one shows up as a dynamic import). 361 declared, 343 implemented, 18 not — 8 of those were fixed in [[bug-b-crtl-basic-posix-io-not-implemented]] because the bridges already existed. These ten remain:

function what it needs
poll PalPoll exists; needs a __pxx_poll bridge in pxxcio.pas
ioctl PalIoctl exists; same, a bridge
clock_gettime no PAL entry; the syscall is already used internally for time
chmod, umask no PAL entry
msync, mremap no PAL entry (sys/mman.h has the rest)
pread, pwrite want a real positional syscall. A seek/read/seek emulation is silently non-atomic and would differ from every other libc under concurrency — worth doing properly or not at all
atexit runtime hook registration, not a syscall; lib/rtl/atexit.pas exists

Reproduce the probe any time: it is a dozen lines of shell over lib/crtl/include/**, and it is how a declared-but-unreachable function is found before a user finds it. The failure mode is always the same and always quiet — the program links against glibc, works on the dev box, and cannot run anywhere else.