Bare-Metal Programming
SafeC supports bare-metal and embedded development through freestanding mode, function attributes for interrupt handling, inline assembly, and volatile/atomic operations for hardware register access.
Freestanding Mode
The --freestanding flag disables the standard library and produces code that does not depend on an OS:
./build/safec firmware.sc --freestanding --emit-llvm -o firmware.llIn freestanding mode:
- No implicit dependency on libc
- No startup code (
_start/mainconvention is programmer-defined) - No standard C header imports
- The programmer provides all runtime support
Function Attributes
Naked Functions
Naked functions have no compiler-generated prologue or epilogue. The function body must consist entirely of inline assembly. Use these for bootloader entry points, ISR trampolines, and context switches.
naked void _start() {
asm volatile (
"mov $stack_top, %rsp\n"
"call main\n"
"hlt"
);
}Interrupt Functions
Interrupt functions use the ISR calling convention. They must be void(void) and the compiler generates appropriate entry/exit sequences (e.g., iret on x86):
interrupt void timer_handler() {
volatile int *timer_reg = (volatile int*)0x40000C00;
unsafe { *timer_reg = 1; } // acknowledge interrupt
}Noreturn Functions
Functions that never return can be annotated with noreturn, allowing the compiler to optimize call sites:
noreturn void panic(const char *msg) {
// ... write to UART ...
while (1) {}
}Section Attribute
Place functions or variables in specific linker sections:
section(".isr_vector")
void* vector_table[256] = {
_start,
nmi_handler,
hardfault_handler,
// ...
};
section(".text.fast")
void hot_path() {
// placed in fast memory section
}A bare function name, &functionName, and (void*)functionName are all equivalent — a function's code address is neither freed nor moved for the life of the program, so none of the three needs unsafe {}, even at file scope where an unsafe {} block couldn't appear anyway:
void* p1 = _start; // bare name
void* p2 = &_start; // '&' is a no-op on a function designator
void* p3 = (void*)_start; // explicit cast also needs no unsafe{}Inline Assembly
SafeC supports GCC-style extended inline assembly:
asm [volatile] ( "template" [: outputs [: inputs [: clobbers]]] );Inline asm needs an unsafe {} block, same as any other memory-unsafe construct — except inside a naked function, where it's exempt (the whole body is already required to be assembly, so there's nothing extra to opt into).
Basic Assembly
unsafe {
asm volatile ("cli"); // disable interrupts
asm volatile ("sti"); // enable interrupts
asm volatile ("nop"); // no operation
asm volatile ("hlt"); // halt processor
}Extended Assembly with Operands
int result = 0;
unsafe {
asm volatile (
"mov %1, %0\n"
"add $1, %0"
: "=r"(result) // output: result register
: "r"(input) // input: input register
: "cc" // clobbers: condition codes
);
}A write-only output operand ("=r"(result)) counts as initializing its target for the definite-initialization checker, so int result; (no initializer) followed by asm volatile (... : "=r"(result) ...) is fine — the int result = 0; above works too, just isn't required. A read-write operand ("+r"(var)) is a genuine read as well as a write, so it still requires var to already have a value before the asm statement.
Reading Special Registers
long long read_tsc() {
int lo = 0;
int hi = 0;
unsafe {
asm volatile (
"rdtsc"
: "=a"(lo), "=d"(hi)
);
}
return ((long long)hi << 32) | lo;
}Memory-Mapped I/O with Assembly
void outb(uint16_t port, uint8_t value) {
unsafe {
asm volatile (
"outb %0, %1"
:
: "a"(value), "Nd"(port)
);
}
}
uint8_t inb(uint16_t port) {
uint8_t result = 0;
unsafe {
asm volatile (
"inb %1, %0"
: "=a"(result)
: "Nd"(port)
);
}
return result;
}Volatile Access
The volatile qualifier ensures that reads and writes are not optimized away or reordered by the compiler. This is essential for memory-mapped hardware registers.
Volatile Variables
void uart_registers() {
volatile int *UART_DATA = (volatile int*)0x40001000;
volatile int *UART_STATUS = (volatile int*)0x40001004;
}An integer-to-pointer cast of a compile-time-constant address (a literal, or arithmetic on literals) can initialize a global directly, same as a local:
volatile int *UART_DATA = (volatile int*)0x40001000;
volatile int *UART_STATUS = (volatile int*)(0x40001000 + 4);Volatile Load and Store
Built-in functions provide explicit volatile access:
int val = volatile_load(ptr); // guaranteed to read from memory
volatile_store(ptr, value); // guaranteed to write to memoryThese compile to LLVM load volatile and store volatile instructions.
Example: Polling a Hardware Register
void uart_send(char c) {
volatile int *status = (volatile int*)0x40001004;
volatile int *data = (volatile int*)0x40001000;
// Wait until transmit buffer is empty
while ((volatile_load(status) & 0x20) == 0) {}
volatile_store(data, (int)c);
}Atomic Operations
Atomic operations provide lock-free synchronization and are essential for multi-core bare-metal systems and interrupt handlers.
Atomic Variables
atomic int counter = 0;Atomic Built-ins
| Operation | Signature | Description |
|---|---|---|
atomic_load(ptr) | T atomic_load(T *ptr) | Atomically load value |
atomic_store(ptr, val) | void atomic_store(T *ptr, T val) | Atomically store value |
atomic_fetch_add(ptr, val) | T atomic_fetch_add(T *ptr, T val) | Add and return previous value |
atomic_fetch_sub(ptr, val) | T atomic_fetch_sub(T *ptr, T val) | Subtract and return previous value |
atomic_fetch_and(ptr, val) | T atomic_fetch_and(T *ptr, T val) | Bitwise AND and return previous |
atomic_fetch_or(ptr, val) | T atomic_fetch_or(T *ptr, T val) | Bitwise OR and return previous |
atomic_fetch_xor(ptr, val) | T atomic_fetch_xor(T *ptr, T val) | Bitwise XOR and return previous |
atomic_exchange(ptr, val) | T atomic_exchange(T *ptr, T val) | Swap and return previous value |
atomic_cas(ptr, expected, desired) | bool atomic_cas(T *ptr, T exp, T des) | Compare-and-swap |
atomic_fence() | void atomic_fence() | Full memory barrier |
All atomic operations use sequentially consistent ordering by default.
Example: Lock-Free Counter
atomic int shared_counter = 0;
interrupt void timer_isr() {
atomic_fetch_add(&shared_counter, 1);
}
int read_counter() {
return atomic_load(&shared_counter);
}Example: Spinlock
atomic int lock = 0;
void spin_lock() {
while (atomic_exchange(&lock, 1) != 0) {
// spin
}
atomic_fence();
}
void spin_unlock() {
atomic_fence();
atomic_store(&lock, 0);
}ARM Cortex-M
Cortex-M gets first-class treatment beyond the generic bare-metal features above: cross-compiles cleanly with --target thumbv7em-none-eabi --freestanding (M4/M7), --target thumbv6m-none-eabi --freestanding (M0/M0+, no FPU/DSP — the most constrained variant), or --target thumbv8.1m.main-none-eabi with +mve (M55/M85), plus a HAL and DSP-extension intrinsics described below. See Multi-Target Codegen for the full cross-compilation matrix.
HAL: NVIC, SysTick, SCB
std/hal/cortex_m.h wraps the three most commonly needed Cortex-M peripherals as methods on global instances at their standard memory-mapped addresses — see std::hal for the full API:
#include <std/hal/cortex_m.h>
void setup_timer_interrupt() {
std::nvic_init();
std::systick_init();
std::systick.start(1000000, 1); // 1,000,000 core-clock ticks per interrupt
std::nvic.enable(15); // SysTick IRQ
std::nvic.set_priority(15, (unsigned char)0);
}DSP Extension (Cortex-M4/M7)
The DSP extension's packed-SIMD/saturating instructions — SADD16, SMLAD, USAD8, SSAT, and friends — pack 2×16-bit or 4×8-bit lanes into a single 32-bit scalar register, a different model from a real vector register file. LLVM does not auto-vectorize vec<T,N> IR into these (see Native SIMD), so they're exposed directly as std::dsp_* functions in std/simd/cortex_m.h, each verified to emit the single named instruction (not a libcall) via real llc -mcpu=cortex-m4 output:
#include <std/simd/cortex_m.h>
int clamp_i8(int x) {
return __arm_dsp_ssat(x, 8); // saturate to signed 8-bit range
}
int checksum4(int a, int b, int acc) {
return std::dsp_smlad(a, b, acc); // dual 16x16 multiply-accumulate
}dsp_ssat/dsp_usat/dsp_ssat16/dsp_usat16 aren't wrapped as functions — the underlying instruction's bit-width operand is a literal immediate encoded directly into the instruction, enforced by the compiler at the __arm_dsp_ssat(val, bits) call site (bits must literally be an integer-literal expression there, which a forwarding function parameter can never satisfy) — call the builtin directly with a literal width, as above.
Requires an ARM target — using any __arm_dsp_*/std::dsp_* function while compiling for a non-ARM target (or the default host target on a non-ARM machine) is a compile error at the call site, not silently wrong codegen.
MVE (Cortex-M55/M85)
Unlike the DSP extension, MVE ("Helium") is a real vector register file, and vec<T,N> reaches it directly — vec<int,4> arithmetic compiled with --target thumbv8.1m.main-none-eabi and +mve lowers to real MVE instructions (vadd.i32 q2, q0, q1, vldrw.u32/vstrw.32 for loads/ stores), the same mechanism used for NEON, SSE, RVV, and SIMD128 on the other targets — see Native SIMD.
Bare-Metal Example: Minimal Kernel
// kernel.sc -- compiled with --freestanding
section(".text.boot")
naked void _start() {
asm volatile (
"mov $0x80000, %rsp\n"
"call kernel_main\n"
"hlt"
);
}
void vga_putchar(int x, int y, char c, uint8_t color) {
volatile uint8_t *VGA_BUFFER = (volatile uint8_t*)0xB8000;
int offset = (y * 80 + x) * 2;
unsafe {
volatile_store(VGA_BUFFER + offset, (uint8_t)c);
volatile_store(VGA_BUFFER + offset + 1, color);
}
}
void kernel_main() {
const char *msg = "Hello from SafeC!";
unsafe {
for (int i = 0; msg[i] != 0; i = i + 1) {
vga_putchar(i, 0, msg[i], 0x0F);
}
}
while (1) {
unsafe { asm volatile ("hlt"); }
}
}WARNING
VGA_BUFFER could just as well be hoisted to a file-scope global now (see "Volatile Variables" above) — kept local here only because vga_putchar's the only function that needs it. msg[i] needs unsafe either way: msg is a raw const char*, and subscripting a raw pointer always requires it, string literal or not.