Skip to content

Muzig

Muzig is a library which supports applications for STM32 microcontrollers in Zig (hence the name: "µ" + "zig"). It has a built-in code generator to provide all hardware register definitions for any of the STM32 µC chip families and cores. Hardware registers can be accessed using named structs and bitfields, thanks to an amazing JSON file collection from the Embassy-RS project.

Muzig has optional real-time support, based on a "stacking event-driven task model". All tasks share a single stack, processing incoming events in a nested fashion. Each task has a priority - only higher tasks can pre-empt lower ones. Tasks can send request events to higher tasks, and reply with events to lower tasks. The library uses a single lock-free event queue: interrupt requests are never blocked (except on M0/M0+, which lacks LDREX/STREX support).

Drivers are set up as tasks with optional interrupt handlers. Each handler can then generate events to alert their task as needed. Since events are only consumed when a task is inactive, event processing is atomic by design.

The basic model is that a task's process() is called whenever there is an event, and that a task will only be suspended when a higher task is activated, either by an event sent from this one, or via some interrupt. Apart from these two cases, all calls to process() "run to completion" without having to guard against their own interrupts, for example. If a task does blocking I/O or some amount of CPU processing, it won't return and lower tasks can't proceed.

This design was chosen so that it works with a single stack and so that - with proper task layering - no atomic guards are ever needed at the task / application level. There is no memory allocation in Muzig, the maximum number of tasks and pending events are set at compile time.

The Muzig library aims to be extremely "lean and mean" and can be used on very low-end microcontrollers.

Muzig is work-in-progress and based on a previous design in C++, called "JeeH". See the git repository for further details.

Getting started

Zig

  • The Zig compiler, build system, and standard library are installed as one package with no further dependencies. There are pre-compiled builds for all major platforms, see the https://ziglang.org homepage.

    Muzig currently requires a "master" release, i.e. 0.17.<something>, because it relies on some features which are newer than the 0.16.0 "tagged" release. See https://ziglang.org/learn/getting-started/.

  • There is also a Zig Language Server (zls) which helps code editors do a much better job of pointing out syntax errors and locating code definitions and references in the app and in the standard library. See https://zigtools.org/zls/install/.

  • One way to manage both of the above is to install the Zig Version Manager (zvm) and let it do all the installation and upgrading. See https://www.zvm.app/guides/install-zvm/ and then run zvm i master --zls to install both zig and zls.

Muzig

There are two ways to get a copy of the Muzig source code: 1) as a package, using zig fetch, or 2) as a git clone. The former creates a snapshot in zig-pkg/, whereas the latter will track updates after a git pull and is probably more practical for development at the moment.

  1. Zig fetch: create a new zig project and add the Muzig package dependency:

    mkdir muapp
    cd muapp
    zig init
    zig fetch --save git+https://codeberg.org/jcw/muzig.git
    
  2. Git clone: make a repository clone and create a new zig project next to it:

    git clone https://codeberg.org/jcw/muzig.git
    mkdir muapp
    cd muapp
    zig init
    

    In this case, build.zig.zon also needs to be told where to find Muzig:

    .dependencies = .{
        .muzig = .{ .path = "../muzig" },
    },
    

The zig-generated files include a lot of comments ... feel free remove them.

Next, add the following snippet to build.zig to automatically build and run the "chipGen" tool, to generate a chip.zig hardware register definition file and a linker.ld µC-specific linker map file:

const muzig = b.dependency("muzig", .{});

const chipGen = b.addExecutable(.{
    .name = "chipGen",
    .root_module = b.createModule(.{
        .root_source_file = muzig.path("tools/chipGen.zig"),
        .target = b.graph.host,
    }),
});

const chipGenTool = b.addRunArtifact(chipGen);
chipGenTool.addArg("STM32F723IE");
const chipMod = chipGenTool.addOutputFileArg("chip.zig");
const linkerLd = chipGenTool.addOutputFileArg("linker.ld");

exe.root_module.addAnonymousImport(
    "chip",
    .{ .root_source_file = chipMod },
);

exe.setLinkerScript(linkerLd);

This assumes that there is an "exe" build step and ties it all together.

Embassy

Note that the chipGen tool takes the exact version of STM32 microcontroller as first argument ("STM32F723IE" in the example above). It determines what gets generated in chip.zig and the memory layout defined in linker.ld.

This relies on the Embassy-RS project's JSON datafiles from embassy-rs/stm32-data-generated on GitHub.

The JSON datafiles need to be installed manually for now ...

A convenient place to put these files is next to the Muzig project area:

cd ..
git clone https://github.com/embassy-rs/stm32-data-generated.git
cd ../muapp
ln -s ../stm32-data-generated/data .

In other words: Muzig expects a data/ folder or symlink in the project area, which chipGen then uses to locate the proper JSON files.

Blackmagic

There are many ways to upload firmware to a microcontroller. One is "Blackmagic Debug" - see https://black-magic.org. It has the benefit of automatically detecting the type of debug probe, i.e. either an ST-Link, a JLink, or a Black Magic Probe.

Firware uploads often need a "bin" file, which can be created as build step:

const bin = exe.addObjCopy(.{ .format = .binary });
const install_bin = b.addInstallBinFile( bin.getOutput(), "muapp.bin");
install_bin.step.dependOn(&install_exe.step);

The upload can then be (yet another) build step:

const flash_run = b.addSystemCommand(&.{ "blackmagic", "-w" });
flash_run.addFileArg("zig-out/bin/muapp.bin");
flash_run.step.dependOn(&install_bin.step);

const run_step = b.step(ex.name, ex.desc);
run_step.dependOn(&flash_run.step);

If all is well, a build + upload should now be a matter of simply typing zig build. With an extra --watch option, this process will even automatically repeat whenever a source file is changed.

Examples

The next two examples are NOT the way to do things in Muzig, but an illustration of low-level hardware register access.

This is a minimal src/main.zig example to blink an LED in the most basic possible way:

pub const Chip = @import("chip");
const RCC = Chip.RCC.regs;
const GPIOB = Chip.GPIOB.regs;

const mu = @import("muzig");

pub fn main() !void {
    RCC.AHB1ENR.GPIOBEN = 1;
    GPIOB.MODER.MODER1 = 1; // PB1 push-pull output mode

    while (true) {
        GPIOB.ODR.ODR1 ^= 1; // toggle PB1
        for (0..10_000_000) |_| asm volatile ("");
    }
}

comptime {
    _ = mu; // needed to bring in the startup code
}

It only uses direct register access. With minor adjustments, this code will work with any GPIO pin and any STM32 µC.

Or let's talk

Another minimal example illustrates how to communicate over a UART by addressing all the hardware registers directly:

pub const Chip = @import("chip");
const RCC = Chip.RCC.regs;
const GPIOC = Chip.GPIOC.regs;
const UART = Chip.USART6.regs;

const mu = @import("muzig");

pub fn main() !void {
    const hz = 16_000_000;
    const baud = 115_200;

    // set the PC6 & PC7 UART pins to alt mode 8
    RCC.AHB1ENR.GPIOCEN = 1;
    GPIOC.MODER.MODER6 = 2;
    GPIOC.AFR[0].AFR6 = 8;
    GPIOC.MODER.MODER7 = 2;
    GPIOC.AFR[0].AFR7 = 8;

    // set the baud rate and enable the UART
    RCC.APB2ENR.USART6EN = 1;
    UART.BRR.BRR = @intCast(hz / baud);
    UART.CR1 = .{ .TE = 1, .RE = 1, .UE = 1 };

    // poll for incoming data and send it back out
    while (true) {
        while (UART.ISR.RXNE == 0) {}
        UART.TDR.DR = UART.RDR.DR;
    }
}

comptime {
    _ = mu; // needed to bring in the startup code
}

This code uses USART6 on PC6 and PC7, sets it to 115200 baud with default "8N1" framing, and enters a loop to echo all incoming bytes back out.

Hello from Muzig

The previous examples used low-level register access. This example combines both and simplifies the code a bit:

const std = @import("std");
pub const Chip = @import("chip");

const mu = @import("muzig");
const Pin = mu.gpio.Pin;
const Uart = mu.uart;
const SYSTICK = mu.cortex.SYSTICK;

const led: Pin = .B1;

pub fn main() !void {
    led.mode("P"); // push-pull output
    SYSTICK.setRate(250, mu.stm32.hz); // 4 toggles/sec: 2 Hz blink rate

    var console = Uart.Def(.{
        .dev = Chip.USART6,
        .baud = 115_200,
        .tx = .C6,
        .rx = .C7,
    }).init();

    for ("\nHello? ... ") |c|
        console.putc(c);

    while (true)
        if (console.getc()) |c|
            console.putc(c);
}

pub fn sysTick_Handler() callconv(.c) void {
    led.toggle();
}

comptime {
    _ = mu; // needed to bring in the startup code
}
  • Pin is a type which simplifies the use of names and setup of GPIO pins
  • Uart.Def is a way to defined a UART type and .init() then configures it
  • SYSTICK provides access to the ARM Cortex SysTick hardware registers
  • there's no need to specify "ALT" modes, Muzig extracts this from the Chip file
  • the bus clock divider for USART6 is also extracted from the Chip file
  • the startup code sees the systick_Handler and ties it into the IRQ vector

This is now a startup greeting, an echo loop, and a blinking LED driven by SysTick interrupts triggering in the background.

Formatted output

With just a minor addition, the UART can be tied into Zig's formatted I/O functions in the standard library:

pub const Chip = @import("chip");

const mu = @import("muzig");
const Uart = mu.uart;
const SYSTICK = mu.cortex.SYSTICK;

pub fn main() !void {
    const ms = 250; // 4 Hz
    SYSTICK.setRate(ms, mu.stm32.hz);

    var console = mu.uart.Def(.{ .dev = Chip.USART6, .tx = .C6 }).init();
    const stdout = &console.interface; // set up a std.Io.Writer

    var ticks: u32 = 0;
    while (true) {
        try stdout.print("{} ms\n", .{ticks});
        asm volatile ("wfi"); // go to sleep
        ticks += ms;
    }
}

comptime {
    _ = mu; // needed to bring in the startup code
}
  • all the std.Io magic is based on const stdout = &console.interface;
  • the SysTick interrupt still fires, but now it merely wakes up from "WFI"
  • on each wakeup, the elapsed time is updated and printed on the console
  • the blinking LED code has been omitted for brevcity

Run fast and shrink

This code switches to the STM32F7's maximum clock speed, and then reports that speed plus some application statistics:

pub const std = @import("std");
pub const Chip = @import("chip");
const mu = @import("muzig");

const Uart = mu.uart;
var stdout: *std.Io.Writer = undefined;

pub fn main() !void {
    mu.stm32.fastClock(25, 216); // switch to 216 MHz, using 25 MHz XTAL + PLL

    var console = Uart.Def(.{ .dev = Chip.USART6, .tx = .C6 }).init();
    stdout = &console.interface;

    prAppInfo();
}

fn pr(comptime fmt: []const u8, args: anytype) void {
    stdout.print(fmt ++ "\n", args) catch {}; // ignore errors
}

fn prAppInfo() void {
    pr("\n{s} {s} @ {} MHz (reset: {}) - {}b text, {}b data, {}b bss", .{
        @tagName(Chip.name),
        @tagName(Chip.core),
        mu.stm32.hz / 1_000_000,
        mu.rcc.ResetCause.getAndClear(),
        @intFromPtr(&_etext) - @intFromPtr(&_stext),
        @intFromPtr(&_edata) - @intFromPtr(&_sdata),
        @intFromPtr(&_ebss) - @intFromPtr(&_sbss),
    });
}

// these locations are defined in the "linker.ld" file
extern const _stext: u32;
extern const _etext: u32;
extern const _sdata: u32;
extern const _edata: u32;
extern const _sbss: u32;
extern const _ebss: u32;

comptime {
    _ = mu; // needed to bring in the startup code
}

Sample output for a "safe" build, which includes numerous safety checks:

STM32F723IE cm7 @ 216 MHz (reset: .rst) - 6360b text, 0b data, 4b bss

Sample output for a "small" build, with all Zig runtime checks dropped:

STM32F723IE cm7 @ 216 MHz (reset: .rst) - 1632b text, 0b data, 4b bss

Note that the startup cause is also mentioned (a hard reset in this case).

Don't panic

Safe builds include checks which will trigger a Zig "panic" when they fail. These can be sent to the console port:

const std = @import("std");
pub const Chip = @import("chip");
const mu = @import("muzig");

const Uart = mu.uart;
var stdout: *std.Io.Writer = undefined;

var oops: u8 = 255;

pub fn main() !void {
    var console = Uart.Def(.{ .dev = Chip.USART6, .tx = .C6 }).init();
    stdout = &console.interface; // set up a std.Io.Writer

    oops += 1;
}

fn pr(comptime fmt: []const u8, args: anytype) void {
    stdout.print(fmt ++ "\n", args) catch {}; // ignore errors
}

pub const panic = std.debug.FullPanic(myPanic);

fn myPanic(msg: []const u8, fta: ?usize) noreturn {
    pr("\n### {s} @ {X:08} ###", .{ msg, fta orelse 0 });
    while (true) {}
}

comptime {
    _ = mu; // needed to bring in the startup code
}

Sample output:

### integer overflow @ 080002E9 ###

The address is where the panic happened. It can be looked up in the executable.

Documentation

T.B.D.