ulib and the Command Set¶
This page is the reference for ulib, the tiny I/O façade the five Module 1
command exercises (10c_echo through 14c_head) are written against, and for
oslings ship, the step at the end of Module 2 (53k_ship_your_commands) that
takes those same source files and runs them on the kernel you built. Open it
when you are mid-exercise and need the
exact function signature, when the compiler is refusing something you would
normally reach for (Vec, format!, println!), or when oslings ship
rejects your image. Everything below is read out of ulib/src/,
commands/, oslings-cli/src/ship.rs, and the reference kernel
(exercises/52k_userland/solution/, in your tree once 53k is released).
The one-sentence version¶
ulib exists so that the echo.rs you write in week 4 on your laptop is the
same file, unedited, that runs on your own operating system in December.
It does that by putting one seam — five system calls and a couple of helpers —
between your program and whatever is underneath it, and by having two private
backends behind that seam.
| Backend | Target | Underneath | Source |
|---|---|---|---|
| host | aarch64-apple-darwin, x86_64-unknown-linux-gnu, … |
std, real Unix fds |
ulib/src/sys/host.rs |
| rv6 | riscv64gc-unknown-none-elf |
ecall into your kernel |
ulib/src/sys/rv6.rs |
Your command file contains no #[cfg] attributes at all. The entire
two-target ceremony is two lines, taken verbatim from
commands/src/bin/echo.rs:18 and :22:
Backend selection: the target, not a feature¶
Selection happens in ulib/src/sys/mod.rs:4-12 and is a single condition:
target_os is derived from the target triple you pass to cargo. It cannot
disagree with what you are actually building. A cargo feature can: nothing
stops you from cargo build --features rv6 on macOS, and the failure mode is
not a clear error but a wall of link errors about missing std symbols and a
duplicate #[panic_handler]. The same reasoning drives
ulib/src/lib.rs:19 — #![cfg_attr(target_os = "none", no_std)] — and
commands/.cargo/config.toml, which deliberately sets no default
[build] target, so plain cargo test builds for your laptop and needs no
cross-toolchain. The RISC-V target is opt-in and is passed explicitly by
oslings ship.
The complete API surface¶
That is the whole of it. If it is not in these tables, ulib does not have it.
| Item | Signature (ulib/src/lib.rs) |
Notes |
|---|---|---|
Fd |
type Fd = i32; |
lib.rs:38 |
STDIN / STDOUT / STDERR |
0 / 1 / 2 |
lib.rs:40-42 |
Error |
struct Error(pub i32) |
lib.rs:55; rv6 answers every failure with -1, so there is nothing richer to report |
read |
fn read(fd, &mut [u8]) -> Result<usize, Error> |
lib.rs:104; Ok(0) is EOF, a short read is normal |
write |
fn write(fd, &[u8]) -> Result<usize, Error> |
lib.rs:114; may write fewer bytes than asked |
write_all |
fn write_all(fd, &[u8]) -> Result<(), Error> |
lib.rs:154; loops over short writes — use this one |
open |
fn open(&[u8], u32) -> Result<Fd, Error> |
lib.rs:125; adds the NUL terminator for you |
close |
fn close(fd) -> Result<(), Error> |
lib.rs:134 |
exit |
fn exit(i32) -> ! |
lib.rs:144 |
print / eprint |
fn print(&str) |
lib.rs:167, :172; ignore errors |
write_usize |
fn write_usize(fd, n, width) -> Result<(), Error> |
lib.rs:181; decimal, right-aligned — this is your printf("%8d") |
Open flags are the same bits xv6 and rv6's file.rs use (lib.rs:45-49):
| Flag | Value |
|---|---|
O_RDONLY |
0x000 |
O_WRONLY |
0x001 |
O_RDWR |
0x002 |
O_CREATE |
0x200 |
O_TRUNC |
0x400 |
Args (lib.rs:63) is the command line, built for you by main!.
Arguments are &[u8], not &str, because that is literally what exec
pushes onto the new program's stack — forcing &str would put a UTF-8
validation table in every image. len() is argc (including argv[0]),
get(i) -> Option<&[u8]>, str(i) -> Option<&str> for the cases where you
want text, and prog() for argv[0].
Lines (ulib/src/lines.rs) is a line iterator that never allocates:
Lines::new(fd, &mut buf) borrows your buffer and is the only storage it
has. next_line() -> Option<&[u8]> returns each line without its \n. If a
line is longer than the buffer it hands back what it has and sets
truncated(). It is given, not implemented by you — without it, grep turns
into an exercise about ring buffers instead of an exercise about matching.
Note what is absent: no fork, exec, wait, getpid, dup, mkdir,
unlink, no seeking, no directory reading. Your kernel grows some of those
(syscall.rs:21-29), but a Module 1 command never calls them.
ulib::main! — what it expands to¶
ulib/src/entry.rs:13 is a two-armed macro:
| Target | Expansion | Detail |
|---|---|---|
| host | fn main() |
collects the real args_os() as bytes, calls run, process::exits the returned code (entry.rs:15-21) |
| rv6 | #[no_mangle] extern "C" fn _start(argc, argv) -> ! |
rebuilds Args from the raw stack, then ulib::exit(run(args)) (entry.rs:29-39) |
The rv6 arm also carries #[link_section = ".text.start"]. That is not
decoration. rv6's current loader is a flat loader: it copies the image to
USER_CODE and jumps to the first byte, with no ELF entry point to read
(exec.rs header). commands/user.ld places *(.text.start) first for the
same reason. Remove either one and the linker is free to order some other
function first, and your program jumps into the middle of itself.
argv_slices (ulib/src/sys/rv6.rs:80) walks argc pointers to
NUL-terminated strings and measures each with a byte loop, capped at
MAX_ARGS = 8 (rv6.rs:98) to match the kernel's own MAXARG
(exec.rs:612).
The rv6 backend: one ecall per call¶
Every ulib function on rv6 is a single ecall. The convention is not
invented here; it is what dispatch in your kernel reads out of the trapframe
(usermode.rs:399-408):
| Register | Meaning |
|---|---|
a7 |
system call number |
a0, a1, a2 |
arguments |
a0 |
return value, written back by the kernel |
That is ecall3 at ulib/src/sys/rv6.rs:21. It deliberately uses the
conservative default asm! options — no nomem, no nostack — because the
kernel's trap path can touch memory on your behalf.
| Call | a7 |
a0 |
a1 |
a2 |
|---|---|---|---|---|
exit(status) |
2 | status | — | — |
read(fd, buf, len) |
5 | fd | buf ptr | len |
open(path, flags) |
15 | path ptr | flags | — |
write(fd, buf, len) |
16 | fd | buf ptr | len |
close(fd) |
21 | fd | — | — |
The numbers match the reference kernel's syscall.rs:21-29, which
match xv6's. Two details worth knowing: sys_open (rv6.rs:41) copies your
path into a fixed [u8; MAX_PATH + 1] scratch buffer to NUL-terminate it,
which is why MAX_PATH is a hard 63 (lib.rs:150) — and why the kernel's own
exec caps a program name at 32 (syscall.rs:179). And rv6.rs:66 holds the
one #[panic_handler] in the entire linked image: it writes panic\n to fd 2
and exits -1. No file you write ever has to contain one.
The host backend and the test harness¶
On the host, fds really are Unix fds — host.rs:33-79 wraps them in a
ManuallyDrop<File> so a write does not close the descriptor. But when a
capture is active, writes are diverted into a buffer instead
(host.rs:16-31, a thread_local! so tests still run in parallel).
That is the whole trick behind ulib::testing: the harness calls your run
function directly, in-process. No subprocess, no dyn Write parameter
threaded through your command, no test-only code path. The source under test
is byte-identical to the source that runs on rv6.
Function (ulib/src/testing.rs) |
Use |
|---|---|
run(&["echo", "a"], run) |
argv only (testing.rs:28) |
run_with_stdin(argv, b"...", run) |
feeds fd 0 (testing.rs:33) |
run_with_files(argv, &[("f.txt", b"...")], run) |
in-memory files your program may open (testing.rs:38) |
You get back Output { code, stdout, stderr }, with .out() and .err()
returning &str for readable assertions. Fds 0/1/2 are reserved in the
capture table so your first open returns 3, exactly as on rv6
(testing.rs:60). Be aware of one honest limitation: under capture, write
to anything other than fd 1 or 2 returns -1 (host.rs:38). The harness can
give a command files to read, not files to write.
Portability rules a command must follow¶
These are not stylistic preferences. On riscv64gc-unknown-none-elf there is
no user-side allocator at all — rv6 has no sbrk, no mmap, no brk,
and ulib declares no #[global_allocator]. A program's entire memory is its
image plus one 4 KiB stack page.
| Do not use | Why | Use instead |
|---|---|---|
std::* |
the target has no std |
core only |
Vec, String, Box, HashMap |
no allocator exists | [u8; N], [T; N] |
format!, to_string() |
allocation | write_usize, byte slices |
println!, write!, core::fmt |
core::fmt drags in 12–18 KiB of machinery |
write_all, print, write_usize |
| growable buffers | nothing to grow into | fixed buffers you declare and own |
Look at what the real commands do: cat.rs:23 and wc.rs:34 declare
let mut buf = [0u8; 512];, head.rs:40 and grep.rs:45 use
[0u8; 1024]. The whole program's memory footprint is visible in one line —
which is the same discipline the kernel itself follows.
commands/Cargo.toml backs this up with a release profile of
opt-level = "s", lto = true, codegen-units = 1, panic = "abort",
strip = "symbols".
oslings ship¶
oslings ship # every command in commands/src/bin/
oslings ship echo grep # just these
oslings ship --list # what is embedded now
oslings ship --clean # remove all embedded programs
flowchart LR
A["commands/src/bin/echo.rs"] --> B["cargo build --release\n--target riscv64gc-unknown-none-elf"]
B --> C["ELF, linked at 0 by user.ld"]
C --> D["flatten_elf: PT_LOAD segments,\n.bss zero-filled"]
D --> E["rv6/src/userbin/echo.bin"]
E --> F["include_bytes! in\nrv6/src/userbin.rs"]
F --> G["your kernel image"]
The kernel cannot read ELF, so ship.rs:42 flatten_elf does the loading work
ahead of time: it walks the program headers, copies every PT_LOAD segment to
the address the linker chose, and leaves the memsz-beyond-filesz tail as
zeros — that tail is .bss. It is about eighty lines of hand-rolled ELF
parsing precisely so that no student needs binutils installed. Then it writes
rv6/src/userbin/<name>.bin and regenerates rv6/src/userbin.rs, a table of
(name, &'static [u8]) built with include_bytes! (ship.rs:169). That
generated module is how your own programs enter the name lookup your exec
does (exec.rs:594). Do not hand-edit it; the next ship overwrites it.
Three checks will stop you, all in flatten_elf:
| Check | Message | Usual cause |
|---|---|---|
lowest vaddr must be 0 |
image starts at …, but rv6 loads at 0 | user.ld edited or not passed |
e_entry must be 0 |
entry point is …, not 0 | #[link_section = ".text.start"] lost, or .text.start no longer first in user.ld |
| image ≤ 65536 bytes | rv6 maps only 65536 (16 pages) | you pulled in core::fmt |
The budget, and what a command actually costs¶
0x0001_1000 initial sp; push_argv lays argv strings just below
0x0001_0000 the stack page -- exactly ONE page, 4 KiB
unmapped gap: a program that overruns its image faults here
0x0000_0000 the flat image, 1..16 pages -> 64 KiB maximum
Those constants are USER_STACK_TOP, USER_STACK, MAX_PROG_PAGES and
USER_CODE at memlayout.rs:61-75; the 64 KiB cap is mirrored as
MAX_IMAGE in ship.rs:27. The gap between image and stack is a feature: a
runaway program takes a page fault instead of quietly corrupting its own
stack.
Measured flat-image sizes for the reference solutions, built exactly as
oslings ship builds them:
| Command | Flat image | Fraction of the 64 KiB budget |
|---|---|---|
echo |
384 B | 0.6 % |
cat |
1256 B | 1.9 % |
wc |
1821 B | 2.8 % |
head |
2713 B | 4.1 % |
grep |
2854 B | 4.4 % |
All five together are under 9 KiB. That is what the no-allocator, no-fmt
discipline buys: one println! would roughly quintuple the largest of them.
The tighter constraint in practice is the stack, not the image — 4 KiB
total, shared between your locals, your Lines buffer, and the argv strings
push_argv copies in (exec.rs:781, capped at 8 arguments of 32 bytes). A
[0u8; 1024] buffer is already a quarter of your stack; a [0u8; 8192] one
is a fault.
Once shipped, rebuild and boot: cd rv6 && cargo run, then at the prompt
run echo hello world. See Using OSlings for the
surrounding workflow, rv6 Architecture for how exec
and the shell fit together, The Memory Map for the address
space, and Unsafe Rust and no_std for what no_std
takes away in general.