Bare Metal Programming on STM32 - with Zig!

GitHub Repo

As I was finishing my previous blog about bare metal programming, I wanted to explore more. I had always wanted to use Zig, a language created by Andrew Kelly, to program my ESP32s. I had found some projects aiming to port the ESP-IDF to Zig, but I never got the motivation to try. But after finishing this bare metal programming quest, it clicked. Given I was making all the abstractions myself, I asked myself, how hard would it be to rewrite the entire project in Zig? Read more to find out!

Why Zig?

I have always heard about Zig on Twitter and on YouTube, and never really got into it until recently. It offers to be a companion to C, since it can intermingle with the C-ABI and offer modern features that C doesn’t have. This includes things like defer statements, which allow you to make statements early on in your code, but will still execute at the end. You have better error handling, which in C is rare, since C error handling consists of if return statements and integer values. You have more strict compile checks, things like unused variables and mutated variables that are declared const, and a much more rich type system. No longer do you have datatype int, you have to specify the exact bit width. It has a lot going for it, and for being such a new language, it still has much to go.

There are already amazing projects being developed in Zig. You might recall that I used Bun[1], a Javascript runtime, for my Chipotle Endeavours; Bun is written in Zig.

Another project that had become popular is Tigerbeetle[2], a financial transaction database, that does well in using Zig’s feature set.

But one other feature about Zig is the Zig build system.

Zig Build System

So this means you essentially don’t need any other toolchain for compiling and building. No more makefiles. Just your source code and link script if necessary. You can easily build for multiple platforms, not just the host machine; all of this creates a cohesive environment and framework for developing any systems project.

I believe that Zig can be a big player in the embedded project. I believe that Zig can be a big player in the embedded world, since it can make it easier for people to read and write firmware and even build firmware. Or even automate the building process. Did I mention the build script is written in Zig?

comptime
Zig Example Build Script[3]

Step-by-step translating

So you can tackle this task of translating and porting our C code to Zig various ways. Zig itself has a function that lets you translate C to Zig. However, I felt like this wasn’t really the use case for it. Instead, I decided to work the same way that the guide from GitHub works, which is creating the very low-level entry points and the vector table. Looking at the C code, I tried to one-for-one translate each variable and function.

Vector Table

When it came to the vector table, we couldn’t simply use 0 in our list and array of void function pointers. Instead, we had to create an arbitrary 0 function that was with the call convention of C. Again, Zig intermingles very nicely with C.

fn _zero() callconv(.C) void {}
// Vector Table
export const vector_table linksection(".vectors") = [_]*const fn () callconv(.C) void{
    _estack, _start, _zero, // wheres "_reset"?? 
    _zero, _zero, _zero,
    _zero, _zero, _zero, _zero, 
    _zero, _zero, _zero, _zero, 
    _zero, hal.SysTick_Handler
};
Vector Table

One thing to note here is that I tweaked the code a bit. Compared to the C code, instead of having a function called _reset, the function is called _start, since the Zig compiler was complaining about there being no _start entry symbol. This also allowed me to omit the reset entry clause in the linker script. Again, the linker script is the exact same, apart from this small change.

Rewiring my C brain

One thing that you will notice if you are tasked with a translation from C to Zig, is that there are no macros in Zig. And macros can come pretty handy when you want a quick and easy mathematical expression in your C code. The Zig alternative to this is inline functions with comptime.

What is comptime?

Comptime can be used in various ways in Zig. When you consider macros in C, you already are using compile time computing. I still have much to learn about it; but as the name suggests, comptime refers to the computation that occurs during the compilation process of your code. Its a big factor in Zig for omptimizng code to perform better at run-time. So that’s what I tried to do, implementing all these macros, such as the BIT macro and the PIN macro. And when it came time to compile, Zig would complain that some statements weren’t comptime and were instead run-time. Once again, I still need to learn more about comptime.[4]

pub inline fn BIT(x: u32) u32 {
    const bit_value: u32 = @as(u32, 1) << @intCast(x);
    return bit_value;
}

pub inline fn PIN(comptime bank: u32, comptime num: u32) u16 {
    const pin_value = ((bank - 'A') << 8) | num;
    return pin_value;
}
Using "comptime" for our new inline functions, replacing Macros

C structs do not equal Zig structs

This concept is what arguably took the longest for me to fully understand. Zig has three types of structs, normal, packed, and extern.

Structs are groups of variables that can be made easier for referencing. And each language can interpret how these variables are laid out in memory. And for data conscious programming, I believe Zig has a great way of implementing various options for a struct. Normal structs are the standard way to define a struct in Zig. They align their fields according to the alignment rules set by the compiler, which can lead to padding being added between fields to ensure proper alignment.

Packed structs are the next type of structs. And these structs do not add any padding between fields. Which can be useful when you need to ensure that the struct occupies a specific number of bytes. Or for example, the amount of bytes set by the total of your variables in that struct.

Lastly, we have extern structs, these are used to define a struct that corresponds to that of a C struct. These are defined with the extern keyword and are useful when you need to interact with the C libraries. The layout of extern structs are that similar of the C struct.

Learn about Zig structs here[5].

Understanding the differences between these three types of structs can be hard at first. But with time, you will see each one of them has their special use case. This made me realize I still have a lot to learn. Which leads to the next part: RCC (Reset and Control Clock) Struct and pointer assignment. Take a look at this below.

struct rcc {
  volatile uint32_t CR, PLLCFGR, CFGR, CIR, AHB1RSTR, AHB2RSTR, AHB3RSTR,
      RESERVED0, APB1RSTR, APB2RSTR, RESERVED1[2], AHB1ENR, AHB2ENR, AHB3ENR,
      RESERVED2, APB1ENR, APB2ENR, RESERVED3[2], AHB1LPENR, AHB2LPENR,
      AHB3LPENR, RESERVED4, APB1LPENR, APB2LPENR, RESERVED5[2], BDCR, CSR,
      RESERVED6[2], SSCGR, PLLI2SCFGR;
};
#define RCC ((struct rcc *)0x40023800)

RCC Struct and Pointer Assignment in C

Here we have the C code for laying out our RCC for our STM32. We see that we have a struct of all of our registers that are 32-bit integers, unsigned. And we tack the volatile keyword to it. Note on that volatile keyword: in Zig, we cannot simply use the keyword on variables. They must be on pointers.

We see that in the C code, we define the RCC global: a pointer to the RCC struct. We manually assign the address of where the RCC is in the STM32’s memory.

With Zig, instead of having a pointer to a struct of volatile variables, We have, a volatile pointer to a struct of variables. Different conventions, but they achieve the same goal. And again, since we don’t have the define statement, we need to know how to manually assign pointers to these globals.

pub const rcc = extern struct {
    CR: u32, PLLCFGR: u32, CFGR: u32,CIR: u32,
    AHB1RSTR: u32, AHB2RSTR: u32, AHB3RSTR: u32,
    RESERVED0: u32,
    APB1RSTR: u32, APB2RSTR: u32,
    RESERVED1: [2]u32,
    AHB1ENR: u32, AHB2ENR: u32, AHB3ENR: u32,
    RESERVED2: u32,
    APB1ENR: u32, APB2ENR: u32,
    RESERVED3: [2]u32,
    AHB1LPENR: u32, AHB2LPENR: u32, AHB3LPENR: u32,
    RESERVED4: u32,
    APB1LPENR: u32, APB2LPENR: u32,
    RESERVED5: [2]u32,
    BDCR: u32,
    CSR: u32,
    RESERVED6H: u32,
    RESERVEDK: u32,
    SSCGR: u32,
    PLLI2SCFGR: u32,
};
pub const RCC: *volatile rcc = @ptrFromInt(0x40023800); 
// the builtins for things like this are nice to have, 
// and make it easy to read whats going on without taking much space
RCC Struct and Pointer Assignment in Zig

After hours of debugging, I learned that the extern struct needed to be used for this case. Each register is in memory in a contiguous fashion. Regular Zig structs don’t work that way.

Static variables

Zig doesn’t have static variables, at least that are manually assignable. To keep the state of variables between function calls, you can either create a struct for it or just state it as a global variable. Below is C code for our blinky logic.

int main(void) {
  systick_init(16000000 / 1000);
  uart_init(UART3, 115200);             // Initialise UART
  uint16_t led = PIN('B', 7);           // Blue LED
  gpio_set_mode(led, GPIO_MODE_OUTPUT); // Set blue LED to output mode
  uint32_t timer, period = 500;         // Declare timer and 500ms period
  for (;;) {
    if (timer_expired(&timer, period, s_ticks)) {
      static bool on;      // This block is executed
      gpio_write(led, on); // Every `period` milliseconds
      on = !on;            // Toggle LED state
      char *message = "HELLO FROM USART3!\r\n";
      uart_write_buf(UART3, message, strlen(message));
    }
  }
  return 0;
}
C Code for Blinky Logic

We see that we have a boolean value on that is static within this infinite for loop. Meaning that if we were to change the state of this variable, by the time the loop re-executes, it will maintain that state, this isn’t the same with Zig.

pub fn main() void {
    hal.systick_init(hal.FREQ / 1000);
    hal.uart_init(hal.UART3, 115200);
    const led = hal.PIN('B', 7);
    hal.gpio_set_mode(led, hal.GPIO_MODE.OUTPUT);
    var on: bool = true;
    while (true) {
        if (hal.timer_expired(&timer, period, hal.get_counter())) {
            hal.gpio_write(led, on);
            on = !on;
            hal.uart_write_buf(hal.UART3, "HELLO FROM ZIG OMG!\r\n");
        }
    }
}
Zig Blinky

Having the boolean variable on in the while loop in the Zig code would cause the LED to always stay on. So it must be moved outside of the loop to have the same effect as a inside static variable. On that note, with the Zig code, notice how I use a function in the “ticks” parameter of the timer_expired function. This is because Zig only allows the volatile keyword to be used in pointers, once again. May seem unintuitive , but this is how I got around that.

pub var counter: u32 = 0;

pub fn get_counter() u32 {
    return @as(*volatile u32, @ptrCast(&counter)).*;
}
Zig Code for Volatile Access to Counter variable

In the get_counter function, we return a dereference of a cast of a 32-bit variable as a volatile pointer of the counter global. This effectively gives us volatile access to the counter variable.

HELLO FROM ZIG!

Working Program
Working Program

Here you can see the working program with our hello from Zig message. Things work great.

Once we built with the Zig build, we were able to flash it with OpenOCD no problem. On that note, here is the build script for this project, nothing crazy.

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.resolveTargetQuery(.{
        .cpu_arch = .thumb, // ARM instruction set
        .cpu_model = .{ .explicit = &std.Target.arm.cpu.cortex_m4 }, // cross compile is so easy!
        .os_tag = .freestanding,
    });

    const elf = b.addExecutable(.{
        .name = "firmware-zigg.elf",
        .target = target,
        .root_source_file = b.path("main.zig"),
    });

    elf.setLinkerScript(b.path("link.ld"));
    b.installArtifact(elf);
}
Project Build Script

Would-be Nice to Haves

In Zig, you can put functions inside of structs. Think object-oriented programming, but not really OOP. I had the idea of embedding each function for a peripheral, for example, GPIO, so that each GPIO region would be an instance of a general GPIO struct. Making it this way would make would seem simple for controlling the various functions for peripheral, but I just couldn’t get a working product. Will keep at it.

const gpio = struct {
  const reg = extern struct {
      MODER: u32, OTYPER: u32, OSPEEDR: u32,
      PUPDR: u32, IDR: u32, ODR: u32,
      BSRR: u32, LCKR: u32, AFR: [2]u32,
  };
  pub inline fn set_mode(self: *gpio, pin: u16, comptime mode: GPIO_MODE) void {
      ...
  }
  pub inline fn write(self: *gpio, pin: u16, val: bool) void {
      ...
  }
};

// Ideal way of controlling peripherals, in a more organized manner.

GPIOB.set_mode(thePin, GPIO_MODE.OUTPUT);
GPIOB.write(thePin, true);

Ideal calling syntax

This would be the ideal calling syntax of how to control each peripheral. Pretty simple.

Conclusion

Wow, we did it. At first, it might seem like all I did was translate from one language to another, and you can make that argument, but I was also able to learn more about Zig in the scope of embedded development. I had previously done the ZigLings[6] exercises, but never found a project I was willing to grind out.

I hope I’m able to play with Zig in the future, as I believe it can make a name for itself. It doesn’t have to replace C, but C devs might like it more than other languages. I’m looking at you, Rust.


References

1 - BunJS ↩

2 - Tigerbeetle ↩

3 - Zig Build System ↩

4 - Comptime: Zig Documentation ↩

5 - Structs: Zig Documentation ↩

6 - Ziglings Exercises ↩