Add documentation

README introduces the project and build workflow; CONTRIBUTING covers the stub convention and syscall wrapper process; docs/ explains the architecture and the kernel ABI surface.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
This commit is contained in:
2026-08-30 04:09:49 -04:00
co-authored by Sisyphus
parent 370e64c3a6
commit 5487bbc808
4 changed files with 350 additions and 1 deletions
+119
View File
@@ -0,0 +1,119 @@
# Architecture
nulsl-libc is a from-scratch, C17, static-only libc for Linux, built for
Null Linux's 32 MB target. This document explains how the pieces fit
together. The short version: **the kernel is the API, everything else is a
wrapper, and nothing is allowed to make the process bigger than it needs
to be.**
## Why static-only
A dynamically linked process carries the dynamic linker (`ld.so`) and its
relocation machinery in memory for its entire lifetime. On a 32 MB budget
that is pure overhead. nulsl-libc therefore builds only `libc.a`, links
every program with `-nostdlib -static`, and ships its own `crt0.o` as the
process entry point. There is no `PT_INTERP` in anything we build, and
there is nothing to load.
This is also the project guideline: *"If it's small, if it's lean, you
have ZERO reason to link with libC at ALL. Linux specific syscalls can do
you well."* — so the libc itself goes straight to the kernel.
## The syscall layer
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ stdio.c │ │ stdlib.c │ │ unistd.c │
│ puts, putchar│ │ exit, atoi │ │ read, write │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└──────────────────┼──────────────────┘
▼
┌─────────────────┐
│ syscall.c │ <-- the ONLY file that executes
│ raw syscall() │ the `syscall` instruction
└────────┬────────┘
▼
┌────────────┐
│ Linux │
│ kernel │
└────────────┘
```
- `src/syscall.c` — variadic `syscall(long number, ...)`, the only
architecture-specific C code. On error the kernel returns `-errno`;
syscall() translates that to the libc convention (`-1` + `errno`).
- `src/unistd.c`, `src/stdio.c`, `src/stdlib.c` — wrappers and the
hand-rolled pieces (string core in `src/string.c`).
- `src/crt/crt0.S` — `_start`, the process entry point. Sets up a valid
frame, calls `main(argc, argv)`, hands the return value to `exit()`.
Kept out of `libc.a` on purpose: archive members are only extracted when
referenced, and nothing references `_start`.
## Modules
| Module | Real today | Stubbed (roadmap) |
|-------------------|-----------------------------------------------|----------------------------|
| `src/string.c` | strlen, strcmp, strncmp, strcpy, strncpy, | — |
| | memcpy, memmove, memset, memcmp | |
| `src/unistd.c` | read, write, close, getpid, _exit | unlink |
| `src/stdio.c` | puts, putchar, fflush (trivially), FILE stubs | printf, fopen/fread/... |
| `src/stdlib.c` | exit, abort, atoi | malloc/calloc/realloc, |
| | | free, strtol |
| `src/syscall.c` | raw syscall() (x86_64) | other architectures |
| `src/crt/crt0.S` | _start (x86_64) | other architectures |
## Stub convention
A function that is not implemented yet must:
1. be declared in the public header with its standard signature;
2. return its documented error value (`-1`, `NULL`, `EOF`, `0`...);
3. set `errno = ENOSYS`;
4. carry a `/* TODO: ... */` comment naming what it needs.
This keeps every stub link-clean and its failure mode explicit — programs
fail loudly with a clear errno instead of silently misbehaving.
## Conventions
- **C17 only.** `-std=c17` is forced everywhere; configure refuses
non-C17 compilers.
- **Freestanding.** Everything is compiled with `-ffreestanding
-fno-builtin`, so the compiler never injects its own `memcpy`/`strlen`
and the code you read is the code that runs.
- **errno** is a plain global for now (single-threaded). If threads ever
land, it becomes a TLS slot behind the same header.
- **FILE** is a struct with one `int fd` until a buffering layer exists
(`src/internal.h` owns the definition; the public header only forward-
declares it).
- **No dependencies.** No libtool, no glibc, no kernel UAPI headers —
the few syscall numbers we need live in `include/sys/syscall.h`.
## Build layout
```
Makefile driver (committed): make release / make debug / ...
configure.ac autotools source (C17 enforced, static-only)
autogen.sh autoreconf -i
bin/release/ out-of-tree build, CFLAGS='-O2'
bin/debug/ out-of-tree build, CFLAGS='-O0 -g'
include/ public headers (nothing but declarations)
src/ implementations (headers live elsewhere)
src/crt/crt0.S process entry point (separate object)
benchmarks/ make bench — must stay lean
tests/ make check — smoke test links -nostdlib -static
```
The repository root is never configured in-tree: `configure.ac` refuses it
so the driver `Makefile` cannot be clobbered. Release is always `-O2`.
## Roadmap
- brk()-based allocator (malloc/calloc/realloc/free)
- printf engine
- open()/close()/read()/write() file I/O and a small buffering layer
- environ, getenv
- strtol with full base/errno semantics
- more architectures under `src/arch/`
- signals (only then: a real `abort()`)
+74
View File
@@ -0,0 +1,74 @@
# Syscalls
This project's first principle (project guideline #6): the kernel is the
API. This document is the map of every syscall nulsl-libc touches, how
errors travel, and how to add the next one.
## The one and only ABI surface
`src/syscall.c` — the only file that executes the `syscall` instruction.
```
long syscall(long number, ...);
```
- Arguments 1-6 map to the platform's syscall argument registers.
- On success the kernel return value is returned as-is.
- On error the Linux kernel returns `-errno` (range `-1..-4095`).
`syscall()` translates it: `errno = -ret; return -1;` — the standard
libc convention.
- Numbers live in `include/sys/syscall.h` as `SYS_*` macros,
`#ifndef`-guarded so kernel UAPI headers can coexist.
## The syscalls we use
x86_64 Linux ABI:
| Number | Name | Used by | Status |
|--------|-----------------|----------------------------------|----------|
| 0 | read | `read()` | wrapped |
| 1 | write | `write()`, `puts()`, `putchar()` | wrapped |
| 2 | open | — | constant |
| 3 | close | `close()` | wrapped |
| 39 | getpid | `getpid()`, bench_syscall | wrapped |
| 60 | exit | `_exit()`, `exit()`, `abort()` | wrapped |
| 87 | unlink | — (stub uses errno = ENOSYS) | constant |
| 228 | clock_gettime | benchmarks only | constant |
"Wrapped" means there is a public function in `src/` that calls it.
"Constant" means the number is defined in the header but nothing wraps it
yet (benchmarks call it directly through `syscall()`).
## Adding a new syscall wrapper
1. Add the number to `include/sys/syscall.h` (guarded, with the ABI it
belongs to in a comment).
2. Add the public declaration to the right header (`include/unistd.h` for
POSIX stuff, `include/stdio.h` for stdio, ...).
3. Implement it in the matching `src/` file as a thin wrapper:
```c
ssize_t write(int fd, const void *buf, size_t count)
{
return (ssize_t)syscall(SYS_write, fd, buf, count);
}
```
If the syscall is not implemented yet, follow the stub convention
instead (return the documented error value, `errno = ENOSYS`,
`/* TODO */` comment naming what it needs).
4. Update this table.
5. Add or extend a smoke test in `tests/` — if nothing exercises it, it
does not exist.
## Porting to a new architecture
- `src/syscall.c` — add the register mapping for the new ABI
(`#error` otherwise).
- `src/crt/crt0.S` — add `_start` for the new ABI.
- `include/sys/syscall.h` — the numbers are per-architecture; split the
table or move it to `src/arch/` once a second target exists.
- `configure.ac` — extend the `host_cpu` case.
The project targets Linux on x86_64 today; everything above is written so
a second port is a contained, reviewable change.