A BusyBox-style multi-call binary in pure x86_64 NASM assembly — 41 Unix utilities, one ~108 KB static ELF, direct syscalls, no libc, no runtime. The assembly cousin to jib (Rust), topsail (Go), and mainsail (Python).
From a release — no toolchain required (Linux x86_64):
curl -LO https://github.com/Real-Fruit-Snacks/rill/releases/latest/download/rill-linux-x64
chmod +x rill-linux-x64
mv rill-linux-x64 rill # the dispatcher matches argv[0] == "rill"
./rill date
ln -s rill ls && ./ls -la # multi-call via symlinkFrom source — Linux with NASM, GNU ld, GNU make, and bats (or WSL on Windows):
git clone https://github.com/Real-Fruit-Snacks/rill.git
cd rill
make # builds build/rill
make symlinks # creates build/<applet> symlinks for multi-call dispatch
make test # runs the bats integration suite (329 cases)
make size # prints the linked binary's sizeVerify:
file build/rill
# ELF 64-bit LSB executable, x86-64, statically linked
./build/rill true && echo ok # subcommand dispatch
./build/ls -la # multi-call: argv[0] basename
echo hi | ./build/grep -i 'H.' # full pipelineEvery common POSIX tool you'd reach for in a shell pipeline — file ops, text processing, system info, process inspection, and a recursive tree walker. Dispatch via rill <applet> or call any symlink directly.
rill ls -la /etc # auto-sized columns, localtime mtime
rill find . -name '*.asm' -type f # tree walk + glob + type filter
rill cat src/start.asm | rill wc -l # full pipeline through the dispatcher
rill sort -k 2 -n -t , data.csv # field-aware numeric sort
rill grep -i '^[a-z]+pple' fruits.txt # BRE regex with case-foldEach applet implements the common POSIX flags and edge cases.
ls—-a,-lwith auto-sized columns, localtime mtime via/etc/localtime(TZif v2/v3),total <N>header, owner/group resolved against/etc/passwdand/etc/groupfind— recursive walk with-name PATTERN(full glob:*,?,[...],[!...],\X),-type fdlcbps,-maxdepth N,-mindepth N,-empty,-print/-print0grep— basic regex (. * ^ $ [...] [^...] \X),-Ffor fixed strings,-i,-v,-n,-c, multi-fileFILE:prefixessort—-r,-n,-u,-f;-k F[,G]field selection;-t SEPcustom delimiter; in-memory quicksort (16 MB / 256 K-line cap)chown— numeric or namedUSER[:GROUP]/:GROUP,-Rrecursive (lchown — no symlink follow),-hfor top-level lchown vs chowncp—-r/-Rrecursive (preserves symlinks), preserves source mode bitschmod— octal and symbolic modes (u/g/o/a×+/-/=×r/w/x)tail—-n N,-nN; 4 MB sliding window so streams larger than memory still work
No libc, no startup runtime, no global allocator. Every operation is a syscall to the kernel. Buffers live in .bss (lazy-paged) or on the function's stack frame. The whole binary is a static ELF with two PT_LOAD segments — text r-x, data rw-.
$ ldd build/rill
not a dynamic executable
$ readelf -l build/rill | grep PT_LOAD
LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x010600 0x010600 R E 0x1000
LOAD 0x011000 0x0000000000411000 0x0000000000411000 0x000260 0x4082b8 RW 0x1000Every applet has integration tests that diff our output against /usr/bin/<applet> byte-for-byte where the comparison is deterministic, and check exit codes everywhere else. make test runs the lot in WSL or any Linux box with NASM, ld, and bats installed. CI runs the same harness on Ubuntu against every push.
| Category | Applets |
|---|---|
| File ops | ls cp mv rm mkdir rmdir touch ln readlink chmod chown stat find |
| Text | cat wc head tail cut tr sort uniq grep tee |
| Paths & info | pwd basename dirname which |
| System | whoami id uname hostname date kill ps env printenv |
| Control & misc | true false echo yes sleep |
| Applet | Notes |
|---|---|
true |
exit 0 |
false |
exit 1 |
echo |
supports -n; no -e escape interpretation yet |
yes |
default y line; joins multi-arg with spaces |
cat |
concatenates files; - and no-args read stdin; per-file errno reporting |
pwd |
getcwd (physical path; no -L) |
basename |
one-arg and PATH SUFFIX forms |
dirname |
accepts multiple paths |
sleep |
integer seconds only (no fractional / unit suffix) |
printenv |
all-env or per-name; missing names → exit 1 |
env |
print-only; no -i / VAR=VAL / exec yet (returns 125) |
mkdir |
supports -p (create parents, ignore EEXIST) |
rmdir |
empty-directory removal; no -p chain yet |
rm |
-r/-R recursive (in-place path-buffer walk); -f ignores missing |
touch |
creates missing, bumps mtime via utimensat; no -a/-m/-t/-d |
ln |
hard and -s symlink; -f overwrites; two-operand form only |
readlink |
basic; no -f/-e/-m canonicalization yet |
chmod |
octal and symbolic (u/g/o/a × +/-/= × r/w/x); no s/t perms or -R yet |
ls |
sorted names; -a, -l (auto-sized columns, localtime mtime, total <N> header, uid/gid name resolution); no -R/-F/-h |
cp |
-r/-R recursive (preserves symlinks); preserves source mode bits; no -p/-i |
mv |
same-filesystem rename only; cross-device move surfaces a clear error |
stat |
key:value summary; resolves uid/gid names; mtime as Mon DD HH:MM (localtime) |
chown |
numeric or named USER[:GROUP] / :GROUP; -R recursive; -h no-dereference |
tee |
-a appends; named-file failures don't abort other outputs; stdout failure aborts |
wc |
-l / -w / -c columns; multi-file total row; bytes-not-chars (no -m yet) |
head |
-n N / -nN / -c N / -cN; ==> NAME <== headers between files |
tail |
-n N / -nN; 4 MB sliding window for streams larger than memory |
cut |
-c LIST / -b LIST / -d DELIM -f LIST; ranges (N-M, N-, -M); inline forms (-cLIST, -d,) |
tr |
translate / -d delete / -s squeeze; literal + ranges + escapes (\n \t \r \f \v \a \b \\ \NNN) |
uniq |
-c count, -d dups only, -u uniques only; lines truncated at 8 KB |
sort |
-r/-n/-u/-f; -k F[,G]; -t SEP; in-memory quicksort (16 MB / 256 K-line cap) |
grep |
BRE regex (. * ^ $ [...] [^...] \X); -F for fixed-string; -i/-v/-n/-c; multi-file FILE: prefixes |
find |
recursive walk; -name PAT (glob), -type fdlcbps, -maxdepth N, -mindepth N, -empty; actions -print (default), -print0; no -exec/operators yet |
whoami |
uid → name via /etc/passwd (numeric fallback) |
id |
full form, -u/-g/-un/-gn |
uname |
-a/-s/-n/-r/-v/-m; combined short flags |
hostname |
utsname.nodename; setter form deferred |
date |
Sun Apr 26 15:37:54 UTC 2026; UTC only, no +FORMAT yet |
kill |
-NUM/-NAME/-SIGNAME; -l lists; default TERM |
which |
walks $PATH; falls back to a hardcoded default if $PATH unset |
ps |
reads /proc; PID + comm-name; no flags yet |
rill/
├── Makefile # NASM probe, build, symlinks, size, test
├── linker.ld # two PT_LOAD segments at 0x400000
├── include/ # NASM include files
│ ├── syscalls.inc # SYS_* numbers
│ ├── macros.inc # STDIN/OUT/ERR_FILENO, EXIT_USAGE
│ ├── fcntl.inc # O_RDONLY, O_DIRECTORY, AT_FDCWD, …
│ └── stat.inc # struct stat offsets, S_IF* bits
├── src/
│ ├── start.asm # _start, applet dispatcher, applet table
│ ├── core/ # shared runtime
│ │ ├── string.asm # strlen, streq, isort_strs, basename
│ │ ├── io.asm # write_all (EINTR + short-write loop)
│ │ ├── fmt.asm # parse_uint, format_uint, format_uint_pad
│ │ ├── err.asm # errno → message + perror_path
│ │ ├── path.asm # stat_path, is_directory, path_join
│ │ ├── mode.asm # format_mode (10-byte "drwxr-xr-x")
│ │ ├── time.asm # Hinnant unix → calendar, format_date
│ │ ├── tz.asm # /etc/localtime (TZif v2/v3) parser
│ │ ├── passwd.asm # uid/gid ↔ name via /etc/passwd, /etc/group
│ │ └── regex.asm # minimal BRE matcher for grep
│ └── applets/ # one .asm per applet (41 files)
└── tests/integration/ # bats: per-applet diffs vs coreutils
Three-layer flow:
- Entry —
_startreadsargc/argvfrom the kernel-prepared stack and tail-callsdispatch. The exit code goes toSYS_exit_group. - Dispatch —
dispatchpicks an applet bybasename(argv[0])against the applet table. Ifargv[0]isrillitself, it shiftsargvby one and retries — sorill lsand alssymlink dispatch identically. - Applets — each receives
argcinediandargvinrsi(System V AMD64 ABI), returns its exit code inrax. Reads viaSYS_read, writes viaSYS_writethroughcore/io.asm'swrite_all(handlesEINTRand short writes).
Adding an applet is mechanical: drop src/applets/<name>.asm exposing applet_<name>_main, wire it into src/start.asm's extern list and dispatch table, add a bats file under tests/integration/. The Makefile auto-globs src/applets/*.asm.
- Calling convention: System V AMD64. Args in
rdi,rsi,rdx,rcx,r8,r9; return inrax. Callee-saved:rbx,rbp,r12–r15. Caller-saved scratch:rax,rcx,rdx,r8–r11. - Stack alignment: at function entry
rsp = 8 mod 16. Innercallsites needrsp = 0 mod 16. Common shape: 6 callee-saved pushes +sub rsp, 8brings inner-call alignment back to 0 mod 16. - Syscall ABI:
rax = SYS_*, args inrdi,rsi,rdx,r10,r8,r9; return inrax(negative values are-errno). - Buffers: large transient buffers live in
.bss(lazy-paged by the kernel — no startup cost); per-frame scratch (small enough to fit) lives on the stack with explicitsub rsp, N.
A rill is a small, fast-running stream — narrow, sharp, cuts through stone over time. Felt fitting for the assembly cousin in the multi-call family: a slimmer, lower-level companion to the Rust jib, Go topsail, and Python mainsail — same shape, different substrate.
MIT. See LICENSE.