Table of Contents
Introduction
I've been using Zig a lot over the past few months writing wide variety of projects. From kewpie, a simple query string parser, to dirtstache, a Mustache implementation -- even a libcurl wrapper with zig-curl -- I have experience with Zig across a wide range of domains.
However, I've always wanted to use Zig on an embedded target. On paper, Zig is an excellent choice for embedded programming: no implicit allocations, no hidden control flow, seamless integration with the C ABI, a fantastic build system1, etc. However, that's on paper. I wanted to see for myself if the advertised strengths of Zig held up when put to the test, especially in a resource-constrained embedded environment.
Well, I did just that. In this post I'll retrace my steps in how I went from an empty main.zig
to printing Hello, World!
to my terminal.
Goals
Before writing any Zig code, I set some goals for this project in order to maximize my learning potential:
- No dependencies
- 100% Zig code
- A 3-day time limit
What we're working with
Hardware
I have a few Cortex-M4 development boards laying around: an MSP432P4 (RIP), a NUCLEO-F4, and a NUCLEO-L4. For this experiment, I decided to go with the NUCLEO-F4 for no reason other than it was sitting closest to me at the time.
The process is quite similar for other Cortex-M4-based MCUs, so feel free to follow along with your own hardware. The peripherals on your device may vary, so consult your device's reference manual.
Software
Flash programming
You'll need a way to flash the code to your MCU. I'm using OpenOCD to flash mine. However, there are several other utilities out there you may choose from.
Compiler and binary utilities
I'm using Zig master version 0.14.0-dev.1632+d83a3f174
, installed via zvm. Note that this version is in development and has some planned breaking changes, so this may not work in the future.
In addition to the Zig compiler (and its suite of binary utilities), the GNU binutils will help us out immensely if things go wrong. Notably, Zig doesn't ship with nm
or objdump
utilities. I strongly recommend installing the GCC toolchain for ARM before continuing.
Literature
You'll absolutely need the documentation on your MCU, unless you're some superhuman that's memorized everything. At the very least, you'll need:
- MCU reference manual
- Cortex-M4 reference manual
- Device-specific datasheet
- Development board user manual
Getting started
I was excited. I finally sat down and was ready to make some LEDs blink. I created my project and an empty build.zig
and main.zig
with an empty while loop:
pub fn build(b: *std.Build) void {}
const std = @import("std");
main.zig
:
pub fn main() void {
asm volatile ("nop"); // Prevents optimizing the loop away
}
Build my thing
The first logical step here was to get something to build. Since we know we're only going to run this code on a Cortex-M4 MCU, we can hard-code the target field using b.resolveTargetQuery
instead of using the usual b.standardTargetOptions
.
pub fn build(b: *std.Build) void {
const exe = b.addExecutable(.{
.name = "stm32-test",
.root_source_file = b.path("src/main.zig"),
.optimize = b.standardOptimizeOption(.{}),
.target = b.resolveTargetQuery(.{
.cpu_arch = .thumb,
.cpu_model = .{ .explicit = &std.Target.arm.cpu.cortex_m4 },
.os_tag = .freestanding,
.abi = .eabihf,
}),
});
const install_exe = b.addInstallArtifact(exe, .{});
b.getInstallStep().dependOn(&install_exe.step);
}
const std = @import("std");
After running zig build
, stm32-test
was put in zig-out/bin
:
❯ file zig-out/bin/stm32-test
zig-out/bin/stm32-test: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, with debug_info, not stripped
Theoretically, we could flash this to the MCU right now, but it wouldn't work.
Convert ELF to binary
I said that flashing the ELF
file we created in the previous step won't work. Let's dive into why it won't work.
The Cortex-M4 doesn't know about the ELF
file format. It loads programs in a very specific way, and just executes instructions sequentially. In my case, the STM32F4 has a few different boot modes but we'll stick with the default -- booting from main flash memory. The reference manual says this about booting:
[T]he CPU fetches the top-of-stack value from address 0x0000 0000, then starts code execution from the boot memory starting from 0x0000 0004.
We won't get too deep into the startup process here. It's covered in Cortex M4 startup.
The first 4 bytes of the ELF
file header are 7f 45 4c 46
. If we flashed this to the MCU, it would fetch this value and set it to the stack pointer, then start executing spurious instructions. Who knows what would happen in this case. At best, an invalid instruction execution causes an interrupt that resets the MCU. Additionally, the ELF
file contains other non-binary data, such as debug sympols, section tables, etc. We don't want the MCU interpreting these data as instructions, so we'll need to convert to a different format.
This is where objcopy
comes into play. We'll convert our ELF
file into a BIN
file. objcopy
's man page says this:
objcopy can be used to generate a raw binary file by using an output target of ‘binary’ (e.g., use -O binary). When objcopy generates a raw binary file, it will essentially produce a memory dump of the contents of the input object file. All symbols and relocation information will be discarded. The memory dump will start at the load address of the lowest section copied into the output file.
This is what we want -- raw instructions and data to flash onto the MCU. Zig ships with an implementation of objcopy
. Let's update our build.zig
file to convert the ELF
file into a binary file:
pub fn build(b: *std.Build) void {
const exe = b.addExecutable({
// -- snip
});
const bin = exe.addObjCopy(.{ .format = .bin });
const install_exe = b.addInstallArtifact(exe, .{});
const install_bin = b.addInstallBinFile(bin.getOutput(), bin.basename);
b.getInstallStep().dependOn(&install_bin.step);
install_bin.step.dependOn(&install_exe.step);
}
After re-running zig build
, we now get stm32-test.bin
alongside stm32-test
in zig-out/bin
. Sweet!
Memory map and linking
We can avoid this topic no longer. We must now talk about linking and the memory map.
Embedded devices typically have small amounts of flash and SRAM. My STM32F401RE has 512K of flash and 92K of SRAM. In addition to flash and data memory, the Cortex-M4 has memory-mapped peripherals. Even though the amount flash and data memory is small, the address space is quite large with 4G of addressable memory.
First, lets take a look at how the linker put our ELF
together:
❯ arm-none-eabi-objdump -h zig-out/bin/stm32-test
zig-out/bin/stm32-test: file format elf32-littlearm
Sections:
Idx Name Size VMA LMA File off Algn
0 .ARM.exidx 00000a50 000100f4 000100f4 000000f4 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
1 .rodata 00001db8 00010b48 00010b48 00000b48 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .ARM.extab 00000b7c 00012900 00012900 00002900 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .text 0001f604 0002347c 0002347c 0000347c 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
4 .data 00000004 00052a80 00052a80 00022a80 2**2
// -- snip
Note two key headings in the output of objdump
: VMA and LMA. GNU's ld has a great description of VMA and LMA, so I'll let them do the talking:
Every loadable or allocatable output section has two addresses. The first is the VMA, or virtual memory address. This is the address the section will have when the output file is run. The second is the LMA, or load memory address. This is the address at which the section will be loaded. In most cases the two addresses will be the same. An example of when they might be different is when a data section is loaded into ROM, and then copied into RAM when the program starts up (this technique is often used to initialize global variables in a ROM based system). In this case the ROM address would be the LMA, and the RAM address would be the VMA.
In other words, LMA is the physical address in the MCU's memory, whereas VMA is the address within the MCU's memory map.
We need to tell the linker how to lay out each section of our ELF
file so that we can flash it to the target and ensure it executes the instructions we want it to. In order to do this, we'll need a custom linker script.
Linker script
There are so many good resources out there regarding linker scripts. I will only briefly explain the parts of the linker script as it relates to the Cortext-M4. If you want more in-depth information on linker scripts, refer to the references below.
Before we get started, let's tell Zig to use this linker script:
pub fn build(b: *std.Build) void {
const exe = b.addExecutable({
// -- snip
});
exe.setLinkerScript(b.path("stm32f401re.ld"));
// -- snip
}
Memory Regions
We need to tell our linker about the memory layout of our target. We do so using the MEMORY
keyword:
FLASH_SIZE = 0x80000; /* 512k flash */
SRAM_SIZE = 0x17000; /* 92k sram */
STACK_SIZE = 0x800; /* 2k stack */
MEMORY
{
flash (rx) : ORIGIN = 0x08000000, LENGTH = FLASH_SIZE
sram (rwx) : ORIGIN = 0x20000000, LENGTH = SRAM_SIZE
}
Here, we define our flash as a 512K region of read-only memory beginning at address 0x0800_0000
and our sram as a 92K region of read-write memory beginning at address 0x2000_0000
. We'll use the STACK_SIZE
variable later.
Entry point
The linker needs an entry point. Let's define our entry point as the first vector in our vector table, resetHandler
(see vector table below):
ENTRY(resetHandler)
Sections
Next we define the layout of our output file with the SECTIONS
keyword:
SECTIONS
{
}
.text
section
SECTIONS
{
/* snip */
.text :
{
. = ALIGN(4);
LONG(__initial_stack_pointer)
KEEP(*(.vectors))
*(.text*)
*(.rodata*)
. = ALIGN(4);
} > flash
/* snip */
}
Quite a lot is happening here. Let's break it down line-by-line:
- align the
.text
section on a 4-byte boundary - reserve 4-bytes at the start of the
.text
section for the initial stack pointer - place the vector table immediatly after our initial stack pointer
- place the code following the reset vectors
- place read-only data after the code
- align the end of the
.text
section on a 4-byte boundary
.stack
section
Next comes the stack section. We'll reserve spack for our stack using the STACK_SIZE
variable we created earlier. Note that the ARM Procedure Call Standard (AAPCS) says that the stack must be double word (8-byte) aligned, not single word (4-byte).
SECTIONS
{
/* snip */
.stack (NOLOAD) :
{
. = ALIGN(8);
. = . + STACK_SIZE;
. = ALIGN(8);
__initial_stack_pointer = .;
} > sram
/* snip */
}
- align the bottom of the stack on an 8-byte boundary
- reserve space for stack defined by
STACK_SIZE
- align the top of the stack on an 8-byte boundary
- set the
__initial_stack_pointer
to the current position
.data
section
SECTIONS
{
/* snip */
.data :
{
_sdata = .;
. = ALIGN(4);
*(.data*)
. = ALIGN(4);
_edata = .;
} > sram AT> flash
_ldata = LOADADDR(.data);
/* snip */
}
- align the
.data
section on a 4-byte boundary - define a symbol
_sdata
that marks the start of the.data
section - place initialized data in the output file
- define a symbol
_edata
that marks the end of the.data
section - align the
.data
section on a 4-byte boundary - define a symbol
_ldata
that holds the LMA of our.data
section
Note the last part > sram AT> flash
. This tells the linker that we want to put the data in the sram
region of memory, but we're going to load it from the flash
region. This is important for understanding what happens before main()
.
.bss
section
SECTIONS
{
/* snip */
.bss (NOLOAD) :
{
_szero = .;
. = ALIGN(4);
*(.bss*)
. = ALIGN(4);
_ezero = .;
} > sram
/* snip */
}
- align the
.bss
section on a 4-byte boundary - define a symbol
_szero
that marks the start of the.bss
section - place initialized data in the output file
- define a symbol
_ezero
that marks the end of the.bss
section - the
.bss
section on a 4-byte boundary
The vector table
In the previous section, we put the vector table (.vectors
) at the start of the .text
section preceded only by the 4-byte initial stack pointer. Let's define our vector table in Zig:
pub fn main() void {
}
export fn resetHandler() callconv(.C) void {}
export fn nmiHandler() callconv(.C) void {}
export fn hardFaultHandler() callconv(.C) void {}
export fn memManageHandler() callconv(.C) void {}
export fn busFaultHandler() callconv(.C) void {}
export fn usageFaultHandler() callconv(.C) void {}
export fn svCallHandler() callconv(.C) void {}
export fn debugMonitorHandler() callconv(.C) void {}
export fn pendSvHandler() callconv(.C) void {}
export fn sysTickHandler() callconv(.C) void {}
export const vectors linksection(".vectors") = [_]?*const fn () callconv(.C) void{
resetHandler,
nmiHandler,
hardFaultHandler,
memManageHandler,
busFaultHandler,
usageFaultHandler,
null, // reserved
null, // reserved
null, // reserved
null, // reserved
svCallHandler,
debugMonitorHandler,
null, // reserved
pendSvHandler,
sysTickHandler,
// -- snip: continued for MCU-specific interrupts; consult datasheet
};
Here, we create our vector table, which is an array of function pointers having the signature:
const fn () callconv(.C) void
Each index in the array corresponds to an interrupt vector. The elements containing null
values are reserved. The first 15 bytes of this table will be identical across all Cortex-M4 MCUs. After that, each MCU will have its own order for device-specific interrupts.
The linksection(".vectors")
tells the linker that we want to put this declaration in the .vectors
section of the file. This is analogous to __attribute__((section(".vectors")))
in GCC.
Cortex-M4 startup
Now that we told the linker where to put our code, let's talk about why we put it there. Recall the quote from the datasheet in the Convert ELF to binary section:
[T]he CPU fetches the top-of-stack value from address 0x0000 0000, then starts code execution from the boot memory starting from 0x0000 0004.
When the MCU boots up, the first thing it does is fetch the value at address 0x0000_0000
and uses it as its stack pointer. There are a couple of interesting things to note here. First, the address 0x0000_0000
is aliased to 0x0800_0000
(see footnote 4 for more info). This means that our __initial_stack_pointer
we put at 0x0800_0000
is accessible also from 0x0000_0000
.
After our stack pointer is loaded, the MCU proceeds to 0x0000_0004
and starts executing instructions.
Let's take a closer look at our output file. First, let's check out the section table:
❯ arm-none-eabi-objdump -h zig-out/bin/stm32-test
zig-out/bin/stm32-test: file format elf32-littlearm
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000000c 08000000 08000000 00010000 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .ARM.exidx 00000010 0800000c 0800000c 0001000c 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .stack 00000800 20000000 20000000 00020000 2**0
ALLOC, READONLY
3 .data 00000000 20000800 20000000 00020000 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .bss 00000000 20000800 20000800 00020000 2**0
ALLOC
5 .ARM.attributes 0000003f 00000000 00000000 00020000 2**0
CONTENTS, READONLY
6 .comment 00000067 00000000 00000000 0002003f 2**0
CONTENTS, READONLY
Here, we see:
.text
section starting at0x0800_0000
corresponding with the start of theflash
region.stack
section starting at0x2000_0000
corresponding with the start of thesram
region and having a length of0x800
.data
section starting immediately after our.stack
section.bss
section starting immediately after our.data
section
❯ arm-none-eabi-objdump -d -j .text zig-out/bin/stm32-test | head -n 20
zig-out/bin/stm32-test: file format elf32-littlearm
Disassembly of section .text:
08000000 <vectors-0x4>:
8000000: 20000800 andcs r0, r0, r0, lsl #16
08000004 <vectors>:
8000004: 08000059 0800005d 08000061 08000065 Y...]...a...e...
8000014: 08000069 0800006d 00000000 00000000 i...m...........
...
800002c: 08000071 08000075 00000000 08000079 q...u.......y...
800003c: 0800007d }...
Here, we see the value 20000800
, which corresponds to the top of our stack. This is the __initial_stack_pointer
we reserved in the .text
section and defined in the .stack
section of our linker script. The following few addresses are our interrupt vectors that were placed in the .text
section. How cool is that!
What happens before main()
?
When we compile an application on our PCs, we might assume that the entry point for the application is the main()
function. If you assumed that, you're half right. The main
function is the entry point your application, but that's not what the operating system calls. The operating system calls the _start()
function, which is usually provided by your libc.
When you build a C/C++ application for Linux, your system's libc implementation is implicitly linked to your application, which includes a piece of code called the "startup code". The startup code does things like initilize argc
and argv
, call constructors, and so on. I won't get into too much detail here. Instead, check out the references below for some deep-dives.
Even though we set our entry point above, we'll still need to define our _start()
symbol, otherwise the linker complains at us. Let's add the following to our main.zig
:
export fn _start() void {
main();
while (true) {
asm volatile ("nop");
}
}
We implement an exported _start()
function that just calls main()
. If main returns (which it shouldn't), we'll just hang in an infinite loop.
Here's what code got generated:
08000040 <_start>:
8000040: b580 push {r7, lr}
8000042: 466f mov r7, sp
8000044: f000 f804 bl 8000050 <main.main>
8000048: e7ff b.n 800004a <_start+0xa>
800004a: bf00 nop
800004c: e7fd b.n 800004a <_start+0xa>
800004e: bf00 nop
08000050 <main.main>:
8000050: e7ff b.n 8000052 <main.main+0x2>
8000052: bf00 nop
8000054: e7fd b.n 8000052 <main.main+0x2>
8000056: bf00 nop
08000058 <resetHandler>:
8000058: 4770 bx lr
800005a: bf00 nop
Awesome, we made it to main()
! You'd expect this to work once flashed to the MCU, right? Well, you're in for disappointment. Review the Cortex-M4 startup process: we fetch the stack pointer and begin executing instructions at 0x0000_0004
, which is our reset handler. However, our reset handler doesn't do anything, so main will never be called.
In order to fix this, resetHandler()
needs to call _start()
:
export fn resetHandler() callconv(.C) void {
_start();
}
That gives us the following:
08000040 <_start>:
8000040: b580 push {r7, lr}
8000042: 466f mov r7, sp
8000044: f000 f804 bl 8000050 <main.main>
8000048: e7ff b.n 800004a <_start+0xa>
800004a: bf00 nop
800004c: e7fd b.n 800004a <_start+0xa>
800004e: bf00 nop
08000050 <main.main>:
8000050: e7ff b.n 8000052 <main.main+0x2>
8000052: bf00 nop
8000054: e7fd b.n 8000052 <main.main+0x2>
8000056: bf00 nop
08000058 <resetHandler>:
8000058: b580 push {r7, lr}
800005a: 466f mov r7, sp
800005c: f7ff fff0 bl 8000040 <_start>
8000060: bd80 pop {r7, pc}
8000062: bf00 nop
Now we can see that resetHandler()
calls _start()
, which calls main()
. Are we there yet? Technically, yes - but we still have work to do. If you're working entirely on the stack and don't rely on preinitialized memory, you can get started, and even make an LED blink.
However, in most cases, you'll want to load up your memory from the .data
and .bss
sections -- this is not done for you automatically like it is when you run a program on your computer. Embedded startup code must explicitly perform this step.
To do this, we'll need to refer back to the symbols we created in the linker script:
_sdata
: the start of the.data
section_edata
: the end of the.data
section_ldata
: the LMA of the.data
section_szero
: the start of the.bss
section_ezero
: the end of the.bss
section
For our .bss
section, we need to zero out all memory between _szero
and _ezero
. For the .data
section, we need to copy the memory from _ldata
into the memory from _sdata
to _edata
. Let's do this in our _start()
function:
extern var _szero: u32;
extern var _ezero: u32;
extern var _sdata: u32;
extern var _edata: u32;
extern var _ldata: u32;
export fn _start() void {
const szero: [*]u32 = @ptrCast(&_szero);
const sdata: [*]u32 = @ptrCast(&_sdata);
const ldata: [*]u32 = @ptrCast(&_ldata);
const bss_len = _ezero - _szero;
if (bss_len > 0) {
@memset(szero[0 .. _ezero - _szero], 0);
}
const data_len = _edata - _sdata;
if (data_len > 0) {
@memcpy(sdata[0..data_len], ldata[0..data_len]);
}
main();
while (true) {
asm volatile ("nop");
}
}
And there you have it! We've reached main! That's a lot of stuff happening under the hood... Note that your startup code isn't limited to initilizing system memory. The possibilities are endless. One thing you might consider putting in your startup code is system clock setup. But that's outside the scope of this project, for now at least.
Flashing
As I mentioned earlier, I'm using OpenOCD to flash the MCU. I created a step in build.zig
that would do this for me:
pub const build(b: *std.Build) void {
// -- snip
const flash_run = b.addSystemCommand(&.{
"openocd",
"-f",
"/usr/share/openocd/scripts/board/st_nucleo_f4.cfg",
"-c",
"init",
"-c",
"reset halt; flash write_image erase zig-out/bin/stm32-test.bin 0x08000000 bin",
"-c",
"flash verify_image zig-out/bin/stm32-test.bin 0x08000000 bin",
"-c",
"reset run; shutdown",
});
const flash_step = b.step("flash", "Flash the code to the target");
flash_run.step.dependOn(&install_bin.step);
flash_step.dependOn(&flash_run.step);
// -- snip
}
const std = @import("std");
To flash the MCU, all I need to do is zig build flash
. The artifacts are rebuilt only when needed, and flashing is handled via the system command to openocd
.
Blink a damn LED
The NUCLEO-F401RE development board has an LED wired up to GPIO PA5
. Before anything else, the GPIO A clocks need to be enabled on the AHB1
bus. We do this by setting the GPIOA EN
bit in the RCC_AHB1ENR
register. After that' we're free to configure the GPIO pin as an output and toggle the ODR
register to turn the LED on and off.
const rcc_ahb1enr: *volatile u32 = @ptrFromInt(0x4002_3830);
const gpio_a_mode: *volatile u32 = @ptrFromInt(0x4002_0000);
const gpio_a_odr: *volatile u32 = @ptrFromInt(0x4002_0014);
pub fn main() void {
rcc_ahb1enr.* |= 1;
gpio_a_mode.* |= (1 << 10);
gpio_a_odr.* = 0;
while (true) {
for (0..1000000) |_| {
asm volatile ("nop");
}
gpio_a_odr.* ^= 1 << 5;
}
}
Hello, world!
Next, we need to enable the UART. On my board, USART2 (GPIO PA2
& PA3
) is exposed over to the ST-Link. In rough steps, we need to do the following:
- enable
APB1
bus clocks for USART2 - set GPIO mode to alternate function
- configure alternate function to USART2
- enable USART2
- configure the baud rate
- enable the transmitter
- send data
Here's what I came up with:
const rcc_ahb1enr: *volatile u32 = @ptrFromInt(0x4002_3830);
const rcc_apb1enr: *volatile u32 = @ptrFromInt(0x4002_3840);
const gpio_a_moder: *volatile u32 = @ptrFromInt(0x4002_0000);
const gpio_a_odr: *volatile u32 = @ptrFromInt(0x4002_0014);
const gpio_a_aflr: *volatile u32 = @ptrFromInt(0x4002_0020);
const usart_2_sr: *volatile u32 = @ptrFromInt(0x4000_4400);
const usart_2_dr: *volatile u32 = @ptrFromInt(0x4000_4404);
const usart_2_brr: *volatile u32 = @ptrFromInt(0x4000_4408);
const usart_2_cr1: *volatile u32 = @ptrFromInt(0x4000_440c);
pub fn main() void {
rcc_ahb1enr.* |= 1;
rcc_apb1enr.* |= (1 << 17);
gpio_a_moder.* |= (1 << 10);
gpio_a_odr.* = 0;
gpio_a_moder.* |= (2 << 6) | (2 << 4);
gpio_a_aflr.* |= (7 << 8) | (7 << 12);
usart_2_cr1.* |= (1 << 13);
usart_2_brr.* |= 0xffff & ((8 << 4) | (11));
usart_2_cr1.* |= (1 << 3);
const xmit_str = "Hello, world!\r\n";
var xmit = false;
while (true) {
if (!xmit) {
xmit = true;
for (xmit_str) |c| {
while (usart_2_sr.* & (1 << 7) == 0) {}
usart_2_dr.* |= c;
}
}
for (0..1000000) |_| {
asm volatile ("nop");
}
gpio_a_odr.* ^= 1 << 5;
}
}
Attaching to the serial port and flashing the code gives me what I want:
Hello, world!
Conclusion
If you couldn't tell, I'm getting burned out writing this. This post has taken me the better part of a day. Even though it's somewhat laborious, I accomplished my goal of getting "Hello, World!" printed to my terminal from an embedded device without any dependencies within the time limit I imposed on myself.
The code is not particularly readable, though. The register access is esoteric, and there's no documentation about what each value means. However, there are some pretty elegant ways of solving that problem that I hope to share in a follow-up post. However, that's all for today.
Zig has quickly become my favorite language since I picked it up back in February when it was in 0.11.0
. We're now in 0.13.0
. So much has changed, and the 0.14.0-dev
branch has seemingly more changes than ever. Since 0.11.0
, the language, its standard library, and its build system have seem massive improvements. If you're looking to try out Zig, there's no better time than now!.
I have a lot to say about the Zig build system. Stay tuned for that post!
The STM32F401RE aliases address 0x0000_0000
to flash memory at 0x0800_0000
up to 256KB. In other words, when we reference 0x0000_0000
, the address is translated to 0x0800_0000
; when 0x0000_0004
is accessed, the address is translated to 0x0800_0004
; and so on. This means that our LMA for flash may be either of these values. This is relevant to understand the Cortex-M4 startup proccess.