Baremetal Zig (Blackpill, RTOS)
Jan 24, 2025 - ⧖ 7 minInvestigating Embedded Zig in my Dissertation
During my university dissertation, I investigated using Zig for building and writing baremetal embedded software for the STM32 Blackpill development board.
At the time, I was trying to use build.zig as a build system for the
baremetal C project I had already written, as well as integrate some Zig code
into the project.
Thanks to some friendly people on Ziggit, and some helpful example repos, I was able to get a working build.zig file that could compile both zig and C for the STM32, link them together correctly with my hand written linkerscript, and generate a binary file for flashing to the target hardware (and running on my emulator, see my dissertation project.
However, I quickly dropped this as I had to focus on completing my project in the time frame.
Coming back to Zig
Now I have finished my dissertation, I'm interested in learning some more about zig, and additionally wanted to finish Miro Samek's video series on RTOS, as I am trying to learn more about RTOSes (and FreeRTOS) for a project at work.
I had made a lot of changes to my stm32 blackpill project through my dissertation since playing with zig, so I spent a bit of time merging the zig blackpill and dissertation project together.
I intend to keep extending this project in the future in this repo, implementing the RTOS in the video course, and playing around with zig.
Things I learned about Zig and C in embedded so far
As I mentioned, I came up against a bunch of tricky issues to try and get my
project to compile with build.zig. I could have given up to use a pure zig
project like microzig. However,
the main reason I am interested in zig is it's ability to be used as a "drop
in" 👀 C compiler and C interop. This could make it waaaay more useful for
embedded than any other fancy new languages, as so many vendor toolchains and
libraries are written in C, and being able to make use of those without having
to rewrite anything could make it actually viable to use professionally (in my
opinion).
Something I think projects like embedded rust and microzig miss is that while chips like rp2040 and STM32's are great (and used alot), and having a good experience for writing software for them is great, there are so many different microcontrollers around. For a language to be adopted in embedded it really needs to be available on almost all of them, for the effort and cost of committing to the language to be worthwhile. Not all of these chips are Arm Cortex-M, for example I am currently working on a project that uses an Atmel ARM Cortex-A5, and making heavy use of the vendor supplied board support package and FreeRTOS port.
Anyway TLDR; C integration is very important, and thats why I wanted to see how zig worked.
Linker Issues
One of the hardest problems I had with using build.zig rather than a
makefile and arm-none-eabi-gcc, was that I couldn't figure out how to give
commands/options to the linker through zig. It took a lot of searching, reading
documentation and forums, and I'm still not entirely sure how I ended up with a
working build.zig... 😅
One of the issues was that the LLVM linker LLD seemed to behave slightly (but
not that much) differently to the GCC LD linker. Notably, GCC seemed to be
able to read my linker script, and figure out that the memory sections in RAM
should be NOLOAD. However, LLD was not marking my .stack and .bss
sections as NOLOAD, and so objcopy was pulling in these RAM sections into
the binary file for programming the STM32, along with the .text and other
sections in flash. This was resulting in a huge binary file which was mostly
zeros, which I couldn't use to program the board!
Figuring out the Zig Target
When you are compiling embedded software, you have to be quite specific to the compiler to tell it what target you are compiling for. The STM32 on my blackpill dev board is an Arm Cortex-M4, with an FPU (floating point unit).
I can tell arm-none-eabi-gcc what features are available (and therefore how to compile my code) with the following set of flags:
-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16.
These flags say "compile for cortex-m4", "use thumb mode instructions", "use
hardware floating point registers for float arguments" and "use floating point
instructions as defined by fpv4-sp-d16". See [this article] for more
explanation.
Using build.zig, these flags are supplied using a struct called std.Target.Query, where you can specify the architecture, cpu model etc.:
const target_default: std.Target.Query = .{
.cpu_arch = .thumb,
.os_tag = .freestanding,
.abi = .eabi,
.cpu_model = std.Target.Query.CpuModel{ .explicit = &std.Target.arm.cpu.cortex_m4 },
.cpu_features_add = std.Target.arm.featureSet(
&[_]std.Target.arm.Feature{
std.Target.arm.Feature.vfp4d16sp,
},
),
};
You can see (with hindsight) that these match up. However, if you look closer,
you can see that the flag -mfpu=fpv4-sp-d16 matches, but isn't identical to
std.Target.arm.Feature.vfp4d16sp. I found trying to translate these flags was
a bit of a nightmare.
Luckily, since I worked through this last year, someone has written what looks like a great tool/library called gatz which should be able to translate the zig target structure from your GCC compiler flags. I'm excited to try it out!
NewLib
Another annoying thing about using build.zig over arm-none-eabi-gcc for
compiling C code at least, was trying to integrate Newlib. arm-none-eabi-gcc
includes a small libc implementation called
Newlib
You can specify GCC to include Newlib in an embedded project by passing the
args --specs nano.specs.
This is a nice article about including Newlib in an embedded
project
However, while zig includes cross compilers for arm, with the ability to
configure the target, it doesn't seem to include an implementation of Newlib.
It seems to include other libc implementations that can be linked in, such as
musl. Hopefully someone can get Newlib included too!
Newlib allows you to use lots of libc standard library functions by providing
implementations for syscalls that the library functions use. In my project, the
only standard library functions I used was memcpy, which to be honest
wouldn't require Newlib (I could implement it myself), but I wanted to see how
to get Newlib linking anyway.
arm-none-eabi-gcc comes with the Newlib library, and luckily
some other (smarter) people
had already come across this problem, and I was able to use their
solution of writing a zig function in build.zig that would search for the
arm-none-eabi-gcc toolchain and find Newlib to link against.
Map Files
Something I still haven't figured out is how to get build.zig to output map
files. Using the gcc toolchain, you can pass -Wl,-Map=$@.map as an argument
to the linker, which will create a map file, which gives you information about
all the symbols in your firmware, and where they are located. This can be super
useful for debugging any crashes on target where you have a Link Register and
Program Counter value, as they can help you determine where the crash might
have occurred. See
this blog post
for more on map files.
It seems like zig might never support generating map
files,
but hopefully they come up with an equivalent diagnostic tool. You can get a
similar output by running arm-none-eabi-objdump -dh target.elf, so its not
too big of an issue.
Evaluation?
If the level of C interop doesn't change, and the build system gets some additional features for more fine grained control of compiling and linking, then I can absolutely imagine a future where Zig is used in tandem with C for embedded software professionally.
At this point I haven't even really written any zig, just spent all my time fiddling with the build system. Hopefully moving forward I can focus more on the language itself.