Solod can target freestanding (bare-metal) environments where no C standard library is available.
Compiling • Stdlib packages • Target hooks • Limitations
Name a freestanding target with -target. For example, using zig cc to target bare wasm32:
export CC="zig cc"
export CFLAGS="-Oz -nostdlib -Wl,--no-entry -Wl,--export=main"
so build -target=wasm32-freestanding -o main.wasm .A target whose OS is freestanding (zig) or none (LLVM) changes the behavior:
- Passes
-ffreestandingto the C compiler. The compiler then sets__STDC_HOSTED__to 0, and the stdlib takes its freestanding branch. - Drops the libc dependencies declared with
so:linkin the standard library. It does not affect the libraries declared withso:linkin user code. - Drops the panic trace flags, which a freestanding build has no use for.
so test, so bench and so run take the same flag.
Solod doesn't infer linker flags, so the three above stay in CFLAGS:
-nostdlib, because some freestanding targets link a small C library of their own.-Wl,--no-entry, because it is awasm-ldoption that GNU ld rejects.-Wl,--export=main, because it describes how the caller reaches the output, not the target.
Or transpile to C first and compile separately:
so translate -o generated .
zig cc -Oz \
--target=wasm32-freestanding \
-nostdlib \
-Wl,--no-entry \
-Wl,--export=main \
-o main.wasm \
generated/**/*.cThese packages work in freestanding mode with no restrictions:
bufio bytealg bytes c cmp encoding encoding/binary
encoding/hex encoding/json errors io maps math/bits math/rand
mem path runtime slices strconv strings unicode
unicode/utf8 unsafe
crypto/crand
A freestanding environment has no CSPRNG, so the package draws from the so_crand_read hook. See Target hooks.
fmt
Scanf, Sscanf, and Fscanf read through the stdio of the host, so a freestanding call panics.
Print, Println, and Printf write through the so_write_out hook (see Target hooks). A target with no hook drops the bytes. Sprintf and Fprintf are unaffected.
math
Only the part that needs no libm works. Abs, Copysign, Dim, Inf, IsInf, IsNaN, Max, Min, NaN, Pow10, RoundToEven, Signbit, the Float64bits family and every constant work. Every other function panics.
net/netip
A zone given as an interface name (fe80::1%eth0) resolves to no zone, because a freestanding environment has no network interfaces. A numeric zone (fe80::1%2) works.
sync/atomic
An atomic operation needs a lock-free instruction of its own width. A target with no such instruction calls libatomic instead. A freestanding build doesn't link it, so linking fails with an undefined __atomic_* symbol.
The available widths depend on the target:
| Target | Types that work | Types that fail |
|---|---|---|
| wasm32, x86-64, ARM64, RV64 with the A extension | all | none |
| ARMv7-M, ARMv8-M, RV32 with the A extension | Bool, Int32, Uint32, Pointer |
Int64, Uint64 |
| ARMv6-M, RISC-V without the A extension | none | all |
On ARM, the M profile is the restricted one. ARMv6, ARMv7-A and ARMv7-R have every width. On RISC-V, the A extension decides the available widths, not the word size. RV64I without the A extension has no lock-free width. RV32 with the A extension has the 32-bit widths.
This means sync/atomic won't work on ARMv6-M or on a RISC-V target without the A extension. A build for such a target fails with a compile-time error that names the cause.
It can still work on ARMv7-M, ARMv8-M and RV32 with the A extension if both of these conditions are met:
- You don't use 64-bit atomic types.
- You build with
-ffunction-sections -fdata-sections -Wl,--gc-sectionsto remove unused symbols.
testing
The test report goes to the so_write_out hook. A target with no hook reports nothing.
A failed run traps instead of exiting with a non-zero status, because a freestanding environment has no exit status.
A benchmark reads the clock, so so bench needs the so_time_wall and so_time_mono hooks.
The runner returns the heap to the position it holds before the first test, after every test.
time
Now, Since, Until, and Sleep read the clock through the so_time_wall, so_time_mono, and so_time_sleep hooks. See Target hooks.
Time.Format and Time.Parse only support named layouts (such as RFC3339 or DateOnly), not custom layouts.
uuid
New and NewV4 hold random data only, so they need the so_crand_read hook. NewV7 also reads the clock, so it needs so_time_wall as well. See Target hooks.
These packages require a hosted environment and will produce a compile-time error if imported:
conc flag log/slog net os sync
A freestanding environment has no standard output, no entropy source and no clock, and only the target knows how to reach its own hardware. Solod declares a C function for each of these, and the target defines the ones its program needs:
| Hook | Description | With no definition |
|---|---|---|
so_crand_read |
read some random bytes | crypto/crand panics, runtime.Seed repeats |
so_time_wall |
get the current wall clock time | time.Now panics |
so_time_mono |
get the current monotonic time | no monotonic clock |
so_time_sleep |
pause for a given duration | time.Sleep panics |
so_write_out |
send some bytes to the output | panic and fmt print nothing |
Every hook has a weak default definition in builtin.c, so a program that never calls the package still links, and a program that calls it gets the behavior of the last column. A definition in the target replaces the default. Define the hooks in a C file and add it to the build, or embed it with so:embed:
so_int so_crand_read(uint8_t* buf, so_int size) {
return board_rng_fill(buf, size);
}
so_R_i64_i32 so_time_wall(void) {
return (so_R_i64_i32){.val = board_rtc_unix_seconds(), .val2 = 0};
}
int64_t so_time_mono(void) {
return board_uptime_ms() * 1000000;
}
void so_time_sleep(int64_t ns) {
board_delay_ms(ns / 1000000);
}
so_int so_write_out(const uint8_t* buf, so_int size) {
return board_uart_write(buf, size);
}Notes on each hook:
so_crand_read fills buf with size bytes and returns the number of bytes written. crypto/crand has no deterministic fallback on purpose. A generator that quietly returns predictable bytes gives every device the same keys and identifiers, and nothing reports the failure.
runtime.Seed reads the same hook, so one definition also seeds math/rand and the hash of maps. Seed does fall back, as Deterministic random describes, because these packages promise nothing about unpredictability and must work on a board with no entropy source.
so_time_wall returns seconds and nanoseconds since the Unix epoch. A board that counts elapsed time but does not know the date returns 0 seconds. Every Time then dates at the epoch, and Since and Until stay exact, because they measure with the monotonic clock.
so_time_mono returns nanoseconds from an arbitrary origin. The count must never decrease and must never be 0, because time.Now reads 0 as the absence of a monotonic clock. Convert the tick of the board to nanoseconds here, and widen a counter that wraps: a 32-bit counter at 1 kHz wraps after 49 days.
A target with no so_time_mono still works. time.Now returns a wall clock reading alone, and Since and Until measure with the wall clock.
so_write_out writes size bytes from buf and returns the number of bytes written. panic and the fmt package share the hook, so one definition gives the program both a panic message and printed output.
In freestanding mode, mem.System is implemented as a simple bump allocator backed by a static buffer. It's off by default, but you can enable it by setting the heap size with -DSO_HEAP_SIZE=<bytes> at compile time.
In this implementation free is a no-op; memory is never reclaimed. realloc allocates a new bump region and copies data from the old one; the old region is not freed.
The entire program shares a single heap of SO_HEAP_SIZE bytes.
malloc takes the next range of the heap with an atomic compare and exchange, so more than one thread can allocate. A target with no lock-free atomics of pointer width gets a plain read and write. On such a target, malloc (and hence mem.System) is not thread-safe.
It's best not to use mem.System in freestanding mode. Instead, use mem.Arena to control the heap size and reset it when needed.
mem.Tracker counts allocated bytes and objects in 64-bit counters. A target with a lock-free 64-bit atomic adds to a counter atomically, so the tracker stats are thread-safe. A target with no such atomic gets a plain add. On such a target, tracker's stats could be inaccurate if it's shared across multiple threads.
If the so_crand_read hook is not defined, runtime.Seed mixes a counter into a 64-bit value. Each call returns a different value, but the sequence is the same on every run.
The entire program uses one shared counter, so math/rand and maps draw from the same sequence. The counter uses an atomic add, so more than one thread can call runtime.Seed. A target with no lock-free 32-bit atomics gets a plain add. On such a target, the counter is not thread-safe.
A target that defines so_crand_read never reads the counter. runtime.Seed is then thread-safe only if the hook is thread-safe.
Packages that depend on runtime.Seed (like math/rand and maps) work but produce repeatable output.
A freestanding environment has no standard output, so text goes to the so_write_out hook of the target. Define it and point it at a UART or a host import.
panic prints its message and location through the hook, then traps. fmt.Print, fmt.Println, and fmt.Printf format the text and write it through the hook. The testing package writes its report through the hook too. A target with no hook drops every one of these. A panic then traps with no message.
print and println are no-ops in a freestanding build, whatever the hook does. Both format with the stdio of the host, which the environment does not have.