Going Freestanding

matheusmoreira1 pts0 comments

Going freestanding

Anton Zhiyanov<br>projects<br>books<br>blog<br>about

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.<br>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.<br>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.<br>Freestanding mode •<br>Headers •<br>Builtins •<br>Memory •<br>Atomics •<br>Pure C •<br>Allocation •<br>Values •<br>Hooks •<br>Hosted-only •<br>Testing •<br>Final thoughts<br>Freestanding mode<br>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.<br>The compiler tells you which one you're in:<br>#if __STDC_HOSTED__<br>// libc is available<br>#else<br>// you're on your own<br>#endif

Pass -ffreestanding and link with -nostdlib, and that's it: you no longer have printf, or malloc, or even memcpy. There is no entropy source, no file system operations, and no clock. If libc itself is "hard mode", this is "impossible".<br>Despite its limitations, freestanding mode can be really useful for microcontrollers, WebAssembly sandboxes, kernels, and anything else without an operating system to rely on.<br>Freestanding headers<br>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:<br>float.h stdalign.h stdbool.h stdint.h<br>limits.h stdarg.h stddef.h ...

Everything that requires actual function implementations is gone:<br>assert.h math.h stdlib.h time.h<br>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:<br>#if __STDC_HOSTED__

#include<br>#include<br>#include<br>#include<br>#include<br>#include<br>#include<br>#include

#define so_build_hosted

#else

#include<br>#include<br>#include<br>#include

#endif // __STDC_HOSTED__

Individual packages follow the same approach: branch on so_build_hosted to distinguish between the hosted and freestanding implementations.<br>Compiler builtins<br>GCC and Clang implement some C standard functions without relying on libc. These are known as compiler builtins.<br>__builtin_trap causes the program to terminate abnormally. You can use it to implement poor man's assertion and panic:<br>#ifdef so_build_hosted

#define so_panic(msg) \<br>do { \<br>fprintf(stderr, "panic: %s\n %s:%d (func %s)\n", \<br>msg, __FILE__, __LINE__, __func__); \<br>exit(1); \<br>} while (0)

#else

#define assert(cond) \<br>do { \<br>if (!(cond)) __builtin_trap(); \<br>} while (0)

#define so_panic(msg) \<br>do { \<br>(void)msg; \<br>__builtin_trap(); \<br>} 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:<br>#define alloca __builtin_alloca

// The maximum size that can be allocated<br>// with alloca (64 KB by default).<br>#ifndef SO_MAX_ALLOCA_SIZE<br>#define SO_MAX_ALLOCA_SIZE (64 // in bytes<br>#endif

#define so_alloca(size) ({ \<br>size_t _size = (size_t)(size); \<br>if (_size > SO_MAX_ALLOCA_SIZE) \<br>so_panic("alloca: size exceeds maximum allowed"); \<br>_size ? alloca(_size) : NULL; \<br>})

Memory operations<br>The memxxx functions from string.h have matching builtins too, so you might expect a freestanding build to provide them for you:<br>// int memcmp(const void* lhs, const void* rhs, size_t n);<br>#define memcmp __builtin_memcmp

// void* memcpy(void* dst, const void* src, size_t n);<br>#define memcpy __builtin_memcpy

// void* memmove(void* dst, const void* src, size_t n);<br>#define memmove __builtin_memmove

// void* memset(void* dst, int ch, size_t n);<br>#define memset __builtin_memset

Unfortunately, there's no free lunch here.<br>__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.<br>Even worse, you don't need to mention memcpy explicitly to use it. Suppose you copy a large struct like this:<br>typedef struct { char buf[4096]; } Big;

void copy(Big* a, const Big* b) {<br>*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...

freestanding define include void standard memcpy

Related Articles