Creating a subset of Go that translates to C (which I named Solod) was never my end goal. I liked writing C code with Go, but without the standard library it felt pretty limited. So the next logical step was to port Go's stdlib.
At some point I decided to make as many packages as possible freestanding — independent of any libc implementation or specific OS runtime. That went pretty well. Solod now has 37 standard library packages, and 31 of them work in freestanding mode.
This post describes the techniques I used to get there. There's nothing genuinely novel, and if you're experienced with C, you probably already know all of them. Still, I think it's useful to document the approach — both for me and for anyone interested.
Freestanding mode • Headers • Builtins • Memory • Atomics • Pure C • Allocation • Values • Hooks • Hosted-only • Testing • Final thoughts
Freestanding mode
C has two types of environments. In a hosted environment, you get the full standard library — either the one required by the C standard or, even better, POSIX. In a freestanding environment, you get almost nothing.
The compiler tells you which one you're in:
```
if STDC_HOSTED
// libc is available
else
// you're on your own
endif
``
Pass-ffreestandingand link with-nostdlib, and that's it: you no longer haveprintf, ormalloc, or evenmemcpy`. There is no entropy source, no file system operations, and no clock. If libc itself is "hard mode", this is "impossible".
Despite its limitations, freestanding mode can be really useful for microcontrollers, WebAssembly sandboxes, kernels, and anything else without an operating system to rely on.
Freestanding headers
Freestanding does not mean "just the C language". The C standard guarantees some headers even without libc, because they define types and macros rather than functions:
float.h stdalign.h stdbool.h stdint.h
limits.h stdarg.h stddef.h ...
Everything that requires actual function implementations is gone:
assert.h math.h stdlib.h time.h
errno.h stdio.h string.h ...
To reflect the hosted/freestanding split, let's introduce builtin.h, a common header included in every standard library package:
```
if STDC_HOSTED
include
include
include
include
include
include
include
include
define so_build_hosted
else
include
include
include
include
endif // STDC_HOSTED
``
Individual packages follow the same approach: branch onso_build_hosted` to distinguish between the hosted and freestanding implementations.
Compiler builtins
GCC and Clang implement some C standard functions without relying on libc. These are known as compiler builtins.
__builtin_trap causes the program to terminate abnormally. You can use it to implement poor man's assertion and panic:
```
ifdef so_build_hosted
#define so_panic(msg) \
do { \
fprintf(stderr, "panic: %s\n %s:%d (func %s)\n", \
msg, FILE, LINE, func); \
exit(1); \
} while (0)
else
#define assert(cond) \
do { \
if (!(cond)) __builtin_trap(); \
} while (0)
#define so_panic(msg) \
do { \
(void)msg; \
__builtin_trap(); \
} while (0)
endif // so_build_hosted
```
From now on, I'll mainly show the freestanding versions and omit the hosted versions to keep things simple.
The __builtin_alloca function allocates memory on the stack. Its bounded wrapper limits the size of each allocation:
```
define alloca __builtin_alloca
// The maximum size that can be allocated
// with alloca (64 KB by default).
ifndef SO_MAX_ALLOCA_SIZE
define SO_MAX_ALLOCA_SIZE (64 << 10) // in bytes
endif
#define so_alloca(size) ({ \
size_t _size = (size_t)(size); \
if (_size > SO_MAX_ALLOCA_SIZE) \
so_panic("alloca: size exceeds maximum allowed"); \
_size ? alloca(_size) : NULL; \
})
```
Memory operations
The memxxx functions from string.h have matching builtins too, so you might expect a freestanding build to provide them for you:
```
// int memcmp(const void lhs, const void rhs, size_t n);
define memcmp __builtin_memcmp
// void memcpy(void dst, const void* src, size_t n);
define memcpy __builtin_memcpy
// void memmove(void dst, const void* src, size_t n);
define memmove __builtin_memmove
// void memset(void dst, int ch, size_t n);
define memset __builtin_memset
```
Unfortunately, there's no free lunch here.
__builtin_memcpy is not a separate memcpy implementation. If n is small and known at compile time, the compiler expands it into a few loads and stores. But if n is large or only known at runtime, it emits a call to the real memcpy — the same symbol that libc would provide.
Even worse, you don't need to mention memcpy explicitly to use it. Suppose you copy a large struct like this:
typedef struct { char buf[4096]; } Big;
void copy(Big* a, const Big* b) {
*a = *b;
}
When you compile the code for aarch64-freestanding, the object file contains an undefined reference to memcpy. Zero-initializing a local array produces the same issue with memset. Neither name appears in the source; both are introduced by the compiler.
So the freestanding environment must still provide memcpy, memmove, memset, and memcmp for memory operations to work in the general case.
WebAssembly covers three of the four: memcpy, memmove, and memset lower to the memory.copy and memory.fill instructions. There is no instruction for comparison, so memcmp stays a real function call even there. On other targets, the toolchain often provides all four, as zig cc does (even with -nostdlib). If it doesn't, provide a plain C implementation:
```
undef memcpy
void memcpy(void dst, const void src, size_t n) {
unsigned char d = dst;
const unsigned char s = src;
while (n--) d++ = *s++;
return dst;
}
undef memset
void memset(void dst, int ch, size_t n) {
unsigned char d = dst;
while (n--) d++ = (unsigned char)ch;
return dst;
}
undef memmove
void memmove(void dst, const void* src, size_t n) {
// omitted for brevity
}
undef memcmp
int memcmp(const void lhs, const void rhs, size_t n) {
const unsigned char l = lhs;
const unsigned char r = rhs;
for (; n--; l++, r++) {
if (l != r) return l - r;
}
return 0;
}
``
The defines (#define memcpy __builtin_memcpy` and others above) are still worth keeping, even if you implement the functions yourself. This way, the compiler can still use its own implementation when applicable.
Fun fact: at
-O2and above, GCC can fold your custommemcpyimplementation back into a call tomemcpy, which is infinite recursion.-ffreestandingprevents this because it implies-fno-builtin, but the guarantee is weak. You can use-fno-tree-loop-distribute-patternsto disable this behavior for good.
The rest of string.h is not covered. No compiler provides memchr or strlen, so those you always have to write yourself — more on that below.
Atomic operations
Another useful group of compiler builtins is __atomic_xxx, which provide atomic, thread-safe memory access. They operate on regular objects rather than _Atomic objects:
// so_atomic_load atomically loads the value at p.
#define so_atomic_load(p) \
(__atomic_load_n((p), __ATOMIC_SEQ_CST))
// so_atomic_store atomically stores v at p.
#define so_atomic_store(p, v) \
(__atomic_store_n((p), (v), __ATOMIC_SEQ_CST))
This makes porting Go's sync/atomic types straightforward. All types — atomic integers, unsigned integers, booleans, and pointers — use the same two load/store macros:
// Bool is an atomic boolean value. The zero value is false.
typedef struct atomic_Bool {
bool v;
} atomic_Bool;
// Load atomically loads and returns the value stored in x.
bool atomic_Bool_Load(atomic_Bool* x) {
return so_atomic_load(&x->v);
}
// Store atomically stores val into x.
void atomic_Bool_Store(atomic_Bool* x, bool val) {
so_atomic_store(&x->v, val);
}
A separate
atomic_Booltype isn't strictly required —atomic_Bool_Loadandatomic_Bool_Storewould work with a plainbool*. Still, it can be useful. With a plain pointer,*x = truecreates a silent data race that looks like ordinary code, while the wrapper makes you explicitly writex->v = true.
No stdatomic.h include is needed. However, the CPU must natively support the integer width you use. For example, a 64-bit atomic on a 32-bit target becomes a call to libatomic instead of a single instruction. But that's a different story.
Pure C implementations
If the compiler doesn't provide an implementation, you have to write one yourself. Preferably, use the libc name so all call sites remain unchanged.
A good example is memchr, which is required by bytes.IndexByte. There is a __builtin_memchr, but it is not an implementation, so you need to provide your own:
```
ifndef so_build_hosted
// memchr implementation for freestanding environments.
static inline void memchr(const void s, int c, size_t n) {
const unsigned char p = s;
unsigned char target = (unsigned char)c;
while (n--) {
if (p == target) return (void*)p;
p++;
}
return NULL;
}
endif
``
Some of these DIY implementations aren't trivial, of course. Fortunately, Go's standard library includes many standalone algorithms, such as the string-to-number conversion functions instrconvor integer math inmath/bits`. Porting them to C is almost mechanical:
// Go version.
const m3 = 0x00ff00ff00ff00ff
// ReverseBytes32 returns the value of x
// with its bytes in reversed order.
func ReverseBytes32(x uint32) uint32 {
const m = 1<<32 - 1
x = x>>8&(m3&m) | x&(m3&m)<<8
return x>>16 | x<<16
}
// C version.
static const int64_t m3 = 0x00ff00ff00ff00ff;
uint32_t bits_ReverseBytes32(uint32_t x) {
const int64_t m = ((int64_t)1 << 32) - 1;
x = ((x >> 8) & (m3 & m)) | ((x & (m3 & m)) << 8);
return (x >> 16) | (x << 16);
}
Memory allocation
Memory allocation calls for a different technique. A naive approach would be to implement a freestanding malloc that uses a static buffer:
extern char so_heap[SO_HEAP_SIZE];
extern size_t so_heap_offset;
static inline void* malloc(size_t size) {
// Simplified version without alignment.
if (size > SO_HEAP_SIZE - so_heap_offset) {
return NULL;
}
void* ptr = &so_heap[so_heap_offset];
so_heap_offset += size;
return ptr;
}
It might be sufficient for testing, but I'd avoid using it in production.
Instead of reimplementing malloc, let's remove the need for it, and make the caller bring the memory. Start with an allocator interface, so callers don't depend on a specific implementation:
// Allocator defines the interface for memory allocators.
// Simplified version without Realloc and alignment.
typedef struct {
void* self;
so_R_ptr_err (*Alloc)(void* self, so_int size);
void (*Free)(void* self, void* ptr, so_int size);
} mem_Allocator;
What's with the so-types?
so_int is an integer of the target width:
```
if SIZE_MAX == 0xFFFFFFFFu
typedef int32_t so_int;
else
typedef int64_t so_int;
endif
``so_String` is a pointer to the underlying string bytes and their count:
typedef struct {
const char* ptr;
so_int len;
} so_String;
so_Error is an interface value that wraps the error data:
typedef struct {
void* self;
so_String (*Error)(void* self);
} so_Error;
so_R_ptr_err is a result-type implementation for a (pointer + error) pair:
typedef struct {
void* val;
so_Error err;
} so_R_ptr_err;
There are other similar types like so_R_int_err (int + error) or so_R_f32_bool (float32 + bool).
Then provide an arena allocator, which is freestanding by design:
// Arena is a memory allocator that bump-allocates
// linearly within a fixed buffer.
typedef struct {
so_Slice buf;
so_int offset;
} mem_Arena;
mem_Arena mem_NewArena(so_Slice buf) {
return (mem_Arena){.buf = buf};
}
so_R_ptr_err mem_Arena_Alloc(void* self, so_int size) {
// Simplified version without alignment.
mem_Arena* a = self;
assert(size > 0 && "mem: invalid allocation size");
if (size > so_len(a->buf) - a->offset) {
return (so_R_ptr_err){.val = NULL, .err = mem_ErrOutOfMemory};
}
void* ptr = &so_at(so_byte, a->buf, a->offset);
a->offset += size;
return (so_R_ptr_err){.val = ptr, .err = (so_Error){}};
}
void mem_Arena_Free(void* self, void* ptr, so_int size) {
// Free in arena is a no-op.
(void)self; (void)ptr; (void)size;
}
void mem_Arena_Reset(void* self) {
mem_Arena* a = self;
a->offset = 0;
}
Usage example:
typedef struct Point {
so_int x;
so_int y;
} Point;
// Prepare the arena.
so_byte data[1024];
so_Slice buf = {.ptr = data, .len = sizeof(data)};
mem_Arena arena = mem_NewArena(buf);
mem_Allocator alloc = {
.self = &arena,
.Alloc = mem_Arena_Alloc,
.Free = mem_Arena_Free};
// Allocate a Point. mem_Alloc is a macro that calls
// the Alloc "method" and panics on failure.
Point* p = mem_Alloc(Point, alloc);
p->x = 11;
p->y = 22;
On a freestanding target, an arena is a better choice than a buffer-backed malloc, because the caller decides how much memory is available and when it's released.
Values, not pointers
Constructor functions in Go typically return a pointer:
// A string reader.
type Reader struct {
s string
i int64 // current reading index
prevRune int // index of previous rune; or < 0
}
// NewReader returns a new Reader reading from s.
func NewReader(s string) *Reader {
return &Reader{s, 0, -1}
}
This roughly translates to the following code, using the memory allocator from the previous section:
// A string reader.
typedef struct {
so_String s;
int64_t i;
so_int prevRune;
} strings_Reader;
// NewReader returns a new Reader reading from s.
// The returned reader is allocated; the caller owns it.
strings_Reader* strings_NewReader(mem_Allocator alloc, so_String s) {
strings_Reader* r = mem_Alloc(strings_Reader, alloc);
r->s = s;
r->i = 0;
r->prevRune = -1;
return r;
}
Rather than blindly following Go idioms, it's better to get rid of allocations altogether and return a value:
// NewReader returns a new Reader reading from s.
strings_Reader strings_NewReader(so_String s) {
return (strings_Reader){.s = s, .prevRune = -1};
}
This isn't a technique specific to writing freestanding code, but rather a useful practice for pretty much any C library.
Target hooks
Some things you can't write in a target-agnostic way at all. Only the target knows how to print a byte, read the clock, or generate a random number; these all depend on the hardware.
What you can do is declare functions (hooks) and let the user's code define them:
| Hook | Description |
|---|---|
| so_write_out | send some bytes to the output |
| so_crand_read | read some random bytes |
| so_time_wall | get the current wall clock time |
| so_time_mono | get the current monotonic time |
| so_time_sleep | pause for a given duration |
Then the user can call specific APIs available on their hardware:
so_int so_write_out(const uint8_t* buf, so_int size) {
return board_uart_write(buf, size);
}
int64_t so_time_mono(void) {
return (int64_t)board_uptime_ms() * 1000000;
}
What happens if the user doesn't provide an implementation? You still want the standard library to compile and work unless someone calls the missing functions. To achieve that, use weak definitions:
// so_write_out drops the bytes and reports a full write,
// so panic and fmt print nothing and report no error.
__attribute__((weak)) so_int so_write_out(const uint8_t* buf, so_int size) {
(void)buf;
return size;
}
// so_crand_read reads no bytes. The interpretation is left to the caller.
__attribute__((weak)) so_int so_crand_read(uint8_t* buf, so_int size) {
(void)buf;
(void)size;
return 0;
}
// so_time_wall panics, because no default date is correct.
__attribute__((weak)) so_R_i64_i32 so_time_wall(void) {
so_panic("time: define so_time_wall for this target");
}
Now every hook gets a default, and a definition in the user code silently wins over the default one.
Note that the defaults above behave differently on purpose. Dropping output is fine because a board with no UART (serial interface) has nowhere to print. Inventing a date is not fine, because no date would be correct.
The same reasoning makes crypto/rand panic instead of falling back to a software generator. A "random" source that quietly returns predictable bytes would be a terrible idea:
// crand_read fills buf with size cryptographically secure random bytes.
// Panics if the target does not define so_crand_read.
static inline void crand_read(uint8_t* buf, so_int size) {
if (size <= 0) return;
if (so_crand_read(buf, size) != size) {
so_panic("crypto/rand: no entropy source");
}
}
You can still use a random fallback when cryptographic security isn't needed, such as for hashing map keys or math/rand:
// runtime_Seed returns a random 64-bit seed.
static inline uint64_t runtime_Seed(void) {
uint64_t seed = 0;
// Use cryptographically secure random if available.
if (so_crand_read((uint8_t*)&seed, 8) == 8 && seed != 0) {
return seed;
}
// Fallback to deterministic xorshift64 sequence.
// ...
}
Hosted-only
Some things aren't worth solving with hooks, such as the os and net packages, which require a lot of target-specific code. In these cases, it's better to use a header-level guard that fails in freestanding mode:
```
// so/os/os.h
include "so/builtin/builtin.h"
ifndef so_build_hosted
error "os: hosted environment required"
endif
``
If user code importsos` in a freestanding environment, the compiler reports an error at compile time instead of at link time or runtime.
Testing
"Compiles without libc" is easy to believe and easy to get wrong. The only way to be sure is to test the freestanding implementation.
My approach in Solod is to run the freestanding packages' test suites with a WASI runtime and a small harness. The harness defines all five hooks from the Target hooks section as WASI imports:
// ciovec is the buffer descriptor that fd_write reads.
// The WASI ABI is 32-bit, so both fields are 32-bit.
typedef struct {
const uint8_t* buf;
uint32_t len;
} ciovec;
// wasi_fd_write writes the buffers to the file descriptor
// and stores the number of bytes written in nwritten.
__attribute__((import_module("wasi_snapshot_preview1"), import_name("fd_write")))
extern uint32_t wasi_fd_write(uint32_t fd, const ciovec* iovs,
uint32_t iovs_len, uint32_t* nwritten);
// so_write_out writes size bytes to the standard output of the WASI host.
so_int so_write_out(const uint8_t* buf, so_int size) {
ciovec iov = {.buf = buf, .len = (uint32_t)size};
uint32_t written = 0;
if (wasi_fd_write(1, &iov, 1, &written) != 0) {
return 0;
}
return (so_int)written;
}
The freestanding make task builds tests from stdlib packages into a single wasm32-freestanding module and runs it with wasmtime. This covers the freestanding logic with the same tests that run in hosted mode, so no separate tests are needed.
Final thoughts
Here's a summary of the approach I used write a freestanding stdlib in C:
- Choose between hosted and freestanding at compile time.
- Use the compiler builtins when possible.
- Implement the missing parts and port the standalone code.
- Use explicit allocators; prefer values to pointers.
- Declare hooks for the hardware, with weak defaults.
- Fail fast for packages that can't work in freestanding.
- Test in a freestanding build, not just hosted.
I hope you find it useful too.
If you're interested in trying this in practice, take a look at Solod's README — it has everything you need to get started. Or try it online without installing anything.
★ Subscribe to keep up with new posts.