Using Zig and STM32 CubeMX to drive soviet VFD tubes to display the time

Posted on Jul 10, 2021


I really like Zig. And I think it’s an awesome language to use for embedded stuff, from the way allocation is handled, to its awesome comptime feature, the build system, the “just-work"iness of it, etc. It’s also backed by LLVM and therefore is able to target the useful architectures that one should care about.

So I decided to rewrite the code for my IV-8 VFD clock, a libopencm3 STM32G0 C project in Zig to see how much nicer it would be.

Previous efforts

After doing some research, I found some resources on previous efforts to run Zig on STM32 hardware. I found someone who rewrote the CubeMX HAL by hand to drive motors on an STM32H7 chip. I also found someone else who made a very small example for the blue pill and of course the now-Zig-famous person who rewrote some Rust keyboard firmware to Zig.

Analyzing all of these solutions, they all present some major challenges and impracticalities. One of the solutions is to rewrite the HAL by hand. Given that the code is absolutely atrocious and hard to read this sounds like an absolute nightmare, so this is a hard pass for me.

The other two approaches are basically mapping structs to memory regions, writing some initialization code, a linker script and that’s it. This is a fine approach, but it has one major problem: it would take me ages because I would essentially need to write my own drivers, and I’m just too damn lazy for that.

Fundamentally, these approaches are are missing the point of Zig.

The point of Zig

The point of Zig is not to “rewrite it all in RustZig”. The point of Zig is that nobody will reuse your software if it doesn’t talk the C ABI.

So I decided to talk the C ABI and use some existing software for my project. As I said, I want to write software for a clock that shows the time on tiny soviet VFD tubes. I do not want to be writing STM32 drivers (or at least not right now), because that already exists!

Of the two realistic options I have –libopencm3 or ST’s official CubeMX– I have decided to use the latter. I’ll leave why I switched to the official SDK for another post.

Using CubeMX with Zig

It’s pretty much the same thing I explain in this blog post I wrote about how to use it with C.

In a nutshell:

  • Put the entire CubeMX project into a single subdirectory, like $(PROJECTNAME)_mxproject
  • Make sure you do not let it generate the main() function
  • Make sure you set the option to generate pairs of .c/.h files instead of just one big file (this is sort of optional though)
  • There’s also an option to only copy necessary files that you should check
  • And finally, make sure the project kind is Makefile

With that, you will have a project structure somewhat like this:

% tree -L 2
├── build
│   ├── main.elf
│   └── main.elf.o
├── build.zig
├── init.gdb
├── iv8_clock_mxproject
│   ├── Core
│   ├── Drivers
│   ├── iv8_clock_mxproject.ioc
│   ├── Makefile
│   ├── startup_stm32g030xx.s
│   └── STM32G030C8Tx_FLASH.ld
├── Makefile
├── src
│   ├── c.zig
│   ├── iv.zig
│   └── main.zig
└── zig-cache

Let’s look inside the files!

Zig’s fantastic build system

The only reason we’re going to be using a Makefile is because it’s easier to type in make in the GDB prompt than shell zig build:

default: build/main.bin

	rm -rf build

.PHONY: build/main.elf
	zig build -Drelease-small

build/main.bin: build/main.elf
	arm-none-eabi-objcopy -O binary build/main.elf build/main.bin

And now we just need to build the CubeMX using Zig’s awesome build system:

// build.zig
const builtin = @import("builtin");
const std = @import("std");
const fs = std.fs;

const log = std.log.scoped(.Build);

const CrossTarget = std.zig.CrossTarget;
const Target =;
const print = std.debug.print;

const mx_project_path = "iv8_clock_mxproject";
const mxcargs = &[_][]const u8{

pub fn build(b: * void {
    const mode = b.standardReleaseOptions();

    const target = CrossTarget.parse(.{
        .arch_os_abi = "thumb-freestanding-eabi",
        .cpu_features = "cortex_m0plus",
    }) catch unreachable;

    var main = b.addExecutable("main.elf", "src/main.zig");

    // Add all the MX stuff.
        var walker = fs.walkPath(std.heap.page_allocator, mx_project_path) catch unreachable;
        defer walker.deinit();
        while ( catch unreachable) |entry| {
            const path = entry.path;
            if (std.mem.eql(u8, path[path.len - 3 .. path.len], "Inc") or
                std.mem.eql(u8, path[path.len - 7 .. path.len], "Include"))
      "adding include path {s}", .{path});
    addCDirectory(b, main, mx_project_path ++ "/Core/Src");
    addCDirectory(b, main, mx_project_path ++ "/Drivers/STM32G0xx_HAL_Driver/Src");
    main.addAssemblyFile(mx_project_path ++ "/startup_stm32g030xx.s");
    main.setLinkerScriptPath(mx_project_path ++ "/STM32G030C8Tx_FLASH.ld");

    const main_step = b.step("main", "Generate bin file to flash.");


fn addCDirectory(b: *, exe: *, p: []const u8) void {
    var walker = fs.walkPath(std.heap.page_allocator, p) catch unreachable;
    defer walker.deinit();
    while ( catch unreachable) |entry| {
        const path = entry.path;
        if (std.mem.eql(u8, path[path.len - 2 .. path.len], ".c") and
            !std.mem.eql(u8, path[path.len - 5 .. path.len], "_it.c"))
            // Exclude the implementation of the interrupt handlers.
            exe.addCSourceFile(b.dupe(path), mxcargs);

This looks like a pretty standard build.zig file for C-interop projects. I added a function to recursively find C files and include dirs because I’m too lazy to add each one by hand, but you could totally do that.


  • Set up the architecture for our chip, in this case an STM32G030
  • Do the usual build.zig stuff with the build mode, output, etc.
  • Find all the relevant C include paths from our MX project directory and add them to the build
  • Find all the relevant C source files and add them as objects, also add the startup code
  • Finally just set the linker script

Let’s have a look at the other few files. Lets dissect c.zig first:

pub usingnamespace @cImport({
    @cDefine("STM32G030xx", "");
    @cDefine("USE_FULL_LL_DRIVER", "");
    @cDefine("USE_HAL_DRIVER", "");

export fn __libc_init_array() callconv(.C) void {}

extern fn SystemClock_Config() callconv(.C) void;

pub fn mxSetup() void {
    _ = HAL_Init();

First off we do a few C imports. The defines that the MX libraries are compiled with are defined there too. Then the relevant MX headers are included so that we can use what they declare.

Then, there’s an empty exported function __libc_init_array. This is referenced by the start-up code and there will be linking problems if it’s not defined somewhere, so it’s defined here. This symbol would usually be provided by libc, but it is not not being linked. Indeed, we could just change the start-up code to not reference it.

Then, there’s a declaration for SystemClock_Config. CubeMX does not declare it in a header, so it is declared here so we can call it on main.

Finally, just a convenience function that calls a few initialisation procedures from MX.


Finally, let’s check out main.zig:

usingnamespace @import("c.zig");

export fn main() callconv(.C) void {

    while (true) {

fn defaultHandler() void {
    while (true) {}

export fn NMI_Handler() callconv(.C) void {
export fn HardFault_Handler() callconv(.C) void {
export fn SVC_Handler() callconv(.C) void {
export fn PendSV_Handler() callconv(.C) void {
export fn SysTick_Handler() callconv(.C) void {

And there’s that!

Dissecting this one is simpler. All those handlers are declared in the _it.h file. The _it.c is excluded from the build so those functions are declared here in the main.

Blinky with interrupts


Set up the timer on CubeMX, generate the code and then:

pub fn mxSetup() void {
    _ = HAL_Init();
export fn main() callconv(.C) void {
	while (true) {}

// [...]

export fn TIM3_IRQHandler() callconv(.C) void {

And you can take my word for it, it does work.

VFD Tubes

All that’s left to do at this point is to translate the rest of the C code to Zig. I won’t walk you through this since it’d be a long and not particularly interesting blog post, but you can read both implementations of this code on my Sourcehut.

Zig version:

C version:

Wrapping up

I love it! The seamless C interop is what sets Zig apart from so many other languages.

🔎 Browse comments 💬 Post a new comment