Embedded OS in Zig


An embedded OS in Zig

Zuric is an embedded OS for stm32 hardware written in pure Zig.


Why would one consider using this over a C/C++ or Rust embedded SDK?

Useful errors

Unlike C++, Zig's errors do not require memory allocation making them usable on embedded devices. Unlike C, errors are a part of the language, meaning no more generic "error" status return values that give no indication of what the actual error was.

self.controlLoop() catch |err| switch (err) {
    error.ControllerNotReady => {
        self.display_mode = .Reset;
        defer self.display_mode = .Position;
        while (self.estop_btn.read()) {
            // Wait for operator ...
    // other errors..

In the example code shown at the below if an I2C connection error to the LCD display occurs it (eg I2CArbitrationLost) is automatically propagated up and printed in the debug console by the panic handler... without any extra code you can see exactly what went wrong.

Cleaner and safer code

Zig's release safe mode significantly speeds up development time by catching programming errors like integer overflows, casting errors, or indexing errors.

Optional types prevent a whole class of errors.

Slices and first class function pointers make code that's easier to read, write, and debug.

Zig's defer statement makes it easy to properly clean up resources.

// Attach to uart read events
try uart.attach(Controller.onUartData, self, .rx);
defer uart.detach(.rx);
Standard library

Many of Zig's standard library structures work out of the box. There's no need to roll your own fifo's or string formatting.

// and read from outside an ISR
pub const ReadBuffer = struct {
    const Fifo = std.fifo.LinearFifo(u8, .{.Static=4096*2});
    unprotected_fifo: Fifo,

    pub fn init() ReadBuffer{
        return ReadBuffer{
            .unprotected_fifo = Fifo.init(),

    // Read from the fifo by ensuring that interrupts are disabled
    pub fn readBuffered(self: *ReadBuffer) []const u8 {
        defer mcu.arm.core.critical_section.exit();
        return self.unprotected_fifo.readableSlice(0);
// ...
Async tasks

A simple stack based event loop is included in the OS. Async or "generator" functions can be used to simplify multi-tasking.

_ = async self.processTask();
_ = async self.optimizerTask();
_ = async self.uiTask();
Builtin tests

Hardware independent code can be unit tested on the spot leading to better code quality.

test "parse-code-simple" {
    var r = try Code(f32).parse("G0");
    try testing.expectEqual(r.name, 'G');
    try testing.expect(r.value == 0);
    try testing.expectEqual(r.index, 2);
    try testing.expectEqualSlices(u8, r.data, "");
Single codebase

Zig's comptime features make it trivial to support multiple devices with a single codebase and tells you exactly what needs implemented to add support for a new device.

 pub inline fn enableCrc(self: *SPI) void {
        if (self.config.crc) |_| {
            switch (mcu.family) {
                .stm32h7 => {
                    reg.setMask(u32, &self.instance.CFG1, hw.SPI_CFG1_CRCEN);
                .stm32g4 => {
                    reg.setMask(u32, &self.instance.CR1, hw.SPI_CR1_CRCEN);
                else => @compileError("Not implemented"),
Simple generics

Zig takes a simple approach to generics that lets you do the same thing as "C++ templates" without the complexity.

Zig is as fast and as small as C

The stepper motion controller library can achive over 400khz stepping rates with 3 motors in release fast mode.

The stm32g0 example on the left fits in 11k bytes when compiled in release-small mode.

Program received signal SIGINT, Interrupt.
0x08005f54 in .mcu.stm32.time.sleep (delay=100) at zuric/lib/mcu/stm32/time.zig:21
21              if (system.time(units) >= expires) break;
(gdb) load
`zuric/targets/stm32g0/firmware.elf' has changed; re-reading symbols.
Loading section .text, size 0x299b lma 0x8000000
Loading section .ARM.exidx, size 0x250 lma 0x800299c
Loading section .data, size 0x8 lma 0x8002bec
Start address 0x0800025c, load size 11251

Example script

What does it look like? Here's short example printing the system time and the user button on a NUCLEO-G071RB dev board to an I2C 2 x 16 LCD.

// -------------------------------------------------------------------------- //
// Copyright (c) 2022, CodeLV.                                         //
// Distributed under the terms of the Zuric License.                          //
// The full license is in the file LICENSE, distributed with this software.   //
// -------------------------------------------------------------------------- //
const std = @import("std");
const mcu = @import("mcu");
const gfx = @import("gfx");
const gpio = mcu.gpio;
const time = mcu.time;
const system = mcu.system;
const UART = mcu.uart.UART;
const I2C = mcu.i2c.I2C;
const LCD = gfx.drivers.hd44780u.CharacterDisplay(2, 16);

var serial: UART = undefined;

pub fn main() !void {
    // Setup debug serial port
    serial = UART.init("USART2", .{.tx = .PA2, .rx = .PA3});
    try serial.configure(.{.baudrate = 115200});
    mcu.debug.stream = serial.writer();
    mcu.debug.info("Started", .{});

    var led1 = gpio.Pin.initOutput(.PA5);
    var btn1 = gpio.Pin.initInput(.PC13);

    var lcd = LCD.init(
        I2C.init("I2C1", .{.sda = .PB9, .scl = .PB8 }),
        0x27 << 1
    try lcd.configure(.{});
    try lcd.displayOn();

    // Start an echo loop
    while (true) {
        const v = btn1.read();
        try lcd.printLine(0, "T: {d}ms", .{system.time(.ms)});
        try lcd.printLine(1, "Btn: {s}", .{v});
        try lcd.update();
        time.sleep(100, .ms);

Zig stm32 I2C

Uses translated code

Zuric does not start from scratch but leverages Zig's c-translation to reuse device headers provided from the vendor. This means the registers and API's will be familiar to those with experiance in a given platform.

A function from the arm core NVIC API.

// Enables a device specific interrupt in the NVIC interrupt controller.
pub inline fn enableIrq(irqn: IRQn_Type) void {
    if (@enumToInt(irqn) < 0) return;
    const index = getIrqnIndex(irqn);
    const value = @as(u32, 1) << @intCast(u5, @enumToInt(irqn) & 0x1F);
    reg.write(u32, &NVIC.ISER[index], value);

Hardware Drivers

The current hardware support grid is shown below. Not all device drivers support non-blocking io (ie interrupts).

Nucleo h743zi2 X X X X X X X
Nucleo l552ze-q X X X X
Nucleo g474re X X X X X X
Nucleo g071rb X X X X X X

Device Support

Various optional device drivers are available:

  • HD44780U LCD controller
  • DRV8711 motor driver
  • Generic rotary encoders
  • Hardware quardrature encoders (eg the US Digital E5)
  • Generic stepper motor control


Where can I get it?

The project is not yet being released to the public. It will likely be dual licensed with copyleft and commercial. If you are interested in getting access to a pre-release version please contact me. At a minimum you must have access to a scope and have a long standing github account to even be considered.

Who is using it?

Zuric is currently internally used for several motion control research projects (a CNC lathe and engraver).

Can more boards be supported?

Yes, the code has provisions for supporting other vendors than stm32.

What's up with the name?

Zuric is an acronym for "Zig on your IC".