Zig 裸机编程之 STM32F103 —— 启动过程
| 2026-04-16
原文:https://maldus512.medium.com/zig-bare-metal-programming-on-stm32f103-booting-up-b0ecdcf0de35
在谈论嵌入式软件开发时,我们无法照搬主流平台上通用的编程语言。这是因为大多数语言在一定程度上依赖于支撑其运行的运行时(Runtime)环境——这通常由操作系统提供。然而,微控制器通常无法提供功能完备的操作系统。
注:虽然我曾亲眼见过开发人员(包括我自己)在没有操作系统的嵌入式目标上使用 Java、Python、Lua 或 JavaScript 等高级语言,但这些场景都需要额外的工作来构建运行时环境。即使找到了特定的运行时实现(或者值得花费精力自己开发一个),使用它们通常也会在资源效率方面带来一些弊端。
剩下的选择只有那些能够编译成独立可执行文件的编程语言,它们可能包含一个体积和复杂度均可忽略不计的运行时。
在很大程度上,这意味着行业内占统治地位的唯一选择:C 语言。得益于其底层的简洁性,C 语言能够轻松移植到任何所需的目标平台上,这使它成为一种极其成功的语言。
然而,在过去十年中,出现了一些其他竞争对手,它们可以被视为嵌入式开发的严肃且实用的工具。其中之一,也就是本教程的基础,正是 Zig。
目标
正如本系列文章的一贯做法,我将使用 Blue Pill 开发板,这是一款基于 STM32F103 微控制器的廉价且小巧的开发套件。 ARM 架构在此至关重要:其开放特性使其成为任何依赖 LLVM 进行编译的编程语言(如 Zig)的理想目标。
语言
Zig 是一门优美的语言,旨在以现代化的方式占据低级、效率优先的开发领域。你可以在此了解关于它的一切。 即使没有 Zig 使用经验,任何程序员都能毫无障碍地理解接下来的代码。
作为一门语言,它非常适合嵌入式软件开发:首先,它是一门完全编译的语言;它不(一定)依赖标准库,具有显式的内存分配,且没有任何运行时需求。同时,它带来许多现代编程语言的典型特性,甚至更多。 最重要的是,我觉得它的语法极其简洁且令人耳目一新。虽然我还没有写过太多的 Zig 代码,但目前为止,这是一种享受。
工具
为了完成我们的任务,请准备:
- 你选择的任何编辑器。我喜欢 VSCode 和 NeoVim 的组合,但任何能读写 Zig 源文件的工具都可以。
- Zig 编译器。
arm-none-eabi-binutils或llvm。它们都可以从所有主流发行版的官方仓库中获得。- 注:除了 Zig,你还需要一些工具将 Zig 输出的 ELF 文件转换为可以加载到 Blue Pill 上的二进制格式。遗憾的是,Zig 尚未原生支持此功能,因此需要一个支持 objcopy 的工具(这就是此项要求的原因)。
- 用于将固件加载到 Blue Pill 的外部工具及相关软件;请参阅本文以获取关于烧录的深入指南。
Zig 是一个年轻的项目,仍处于早期开发阶段,但我们不会使用它的大多数高级特性:我希望无论语言版本如何,本文所写的内容在很大程度上都适用。不过,在撰写本文时,我使用的版本是: $ zig version 0.9.1
项目
在开始之前,请务必下载 STM32F103(Blue Pill 上安装的处理器)的官方数据手册(Datasheet)和参考手册(Reference Manual)并将其放在手边。 不要被它们庞大的篇幅吓到!如果你知道去哪里查找,大多数时候你只需要阅读几页,我会引导你完成所有必要的信息。 对于 ST 设备,数据手册包含特定于该设备的概念,而参考手册包含对同一系列所有部件通用的核心信息。
启动过程
我已经在这篇文章中解释了 STM32F103 初始化过程的基本细节。 我将再次涵盖部分内容并略过其他部分;总的来说,所有内容对 Zig 和 C 同样适用,如果你需要深入解释,请参考该部分。 入门只需知道内存 (RAM) 中有两个特定位置应加载栈的起始点和要执行的代码(即函数)。 这两个位置分别是 0x08000000 和 0x08000004。它们位于闪存(Flash)部分,必须在任何代码执行前就绪:提前准备它们的工具就是链接器脚本(Linker Script)。
链接器脚本
当我们把任何程序编译成机器码时,编译器会经历一系列步骤。其中之一是链接步骤,它将每个独立源文件编译出的二进制文件融合在一起,形成最终的可执行二进制文件。 链接器脚本是一个精确描述最终结果如何组成的文件。我们可以用它来显式地将栈指针(Stack Pointer)和复位处理程序(Reset Handler)放置在闪存的起始处。
创建文件 memory.ld 并填入以下内容,让我们一步步分析它:
MEMORY {
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K
}
ENTRY(main);
__reset_stack_pointer = ORIGIN(RAM) + LENGTH(RAM);
SECTIONS {
.text : {
LONG(__reset_stack_pointer);
LONG(main | 1);
/* 整个中断向量表长 332 字节。前进到该位置。 */
. += 332;
/* 这里是代码的其余部分,即所有以 .text 开头的符号 */
*(.text)
*(.rodata*)
. = ALIGN(4);
} > FLASH /* 将此放入闪存区域 */
.ARM.exidx : {
*(.ARM.exidx*)
. = ALIGN(4);
} > FLASH
}
链接器脚本的语法可能很陌生,但你无需掌握每个细节就能理解发生了什么。如果感兴趣,可以查阅此手册作为参考。 首先,MEMORY 命令定义了程序加载的整体内存布局。它由一个起始于 0x08000000、长 128 KB 且可读可执行(rx 位)的 FLASH 部分,以及一个起始于 0x20000000、长 20 KB 且可读写可执行(rwx 位)的 RAM 部分组成。 大小和地址必须与设备内存映射表中的信息匹配。
接下来,我们指定程序的入口点:按照惯例是 main 函数。如果省略,Zig 编译器会报错称找不到 start 符号。 然后,我们声明一个变量,包含 RAM 的地址,用作栈的起始点(栈从 RAM 的末端开始,随着程序嵌套调用函数向下增长)。
这只是热身。我们想要创建的二进制文件的布局由 SECTIONS 块指定。 程序由几个不同的部分组成;其中一些是可选的,而另一些则必须由开发者显式设置。 注:有趣的是,有一些在 C 语言裸机示例中不需要的部分,在这里必须指定,比如 rodata 和 ARM.exidx。
第一部分是 .text,其中包含待执行的编译后的 Zig 代码。通过首先添加它并将其附加到 FLASH 内存,我们确保它被加载到 0x08000000,即栈指针和复位处理程序应该所在的位置。 栈指针由 LONG(__reset_stack_pointer) 指令提供,它将 __reset_stack_pointer 的 4 字节值放在程序的开头。 紧接着,我们放置 main 函数,并将它的最低位设为 1。这是因为虽然函数地址是对齐到机器字(4 字节)的,但手册规定所有异常处理程序必须指向 Thumb 指令,因此有“第一位设为 1”的要求。
设置好这两个值后,通过增加当前地址的值来跳过异常向量的总大小(332):在链接器脚本中,点(.)在任何时刻都代表当前的内存地址。 以上就是大部分内容了。剩下的两个部分 .rodata.str1.1 和 .ARM.exidx 是 Zig 编译器存储调试信息(即栈回溯)所必需的;如果找不到它们,编译时会报错。
我必须说,我们在这篇编程教程中已经深入探讨了足够多,却还没有编写任何代码。让我们写一个极简的 Zig 程序来配合这个链接器脚本,命名为 main.zig:
export fn main() void {
while (true) {
}
}
这个 main 函数非常普通;它只是在空转,什么也没做,但暂时足够了。
编译
现在我们准备编译第一个二进制文件。Zig 编译器允许用优雅的一行命令完成此操作:
$ zig build-exe -target thumb-freestanding -mcpu cortex_m3 --name application.elf --script memory.ld main.zig
如果你已经使用过 Zig,你应该至少熟悉 build-exe 命令。其余标志指定了我们要为 Blue Pill 进行交叉编译:
-target thumb-freestanding是我们硬件的 ARM32 目标架构。Freestanding 意味着除了我们提供的(孤零零的main.zig)之外,没有默认库。-mcpu cortex_m3指定目标微控制器属于 Cortex-M3 系列。--name application.elf仅仅是输出文件的名称。--script memory.ld我们指定了一个显式的链接器脚本。- 最后,
main.zig是编译输入。
如果你正确遵循了每一步,你应该会得到 application.elf。但它还没准备好被加载:ELF 格式包含许多执行不需要的元数据信息。 它必须使用 llvm 或 gcc 工具链转换为二进制文件:
# 任选其一
$ arm-none-eabi-objcopy -O binary application.elf application.bin
$ llvm-objcopy -O binary application.elf application.bin
现在 application.bin 真正准备好了。我们只需要将此二进制文件推送到 Blue Pill 的闪存中即可。
闪烁
如果你正确遵循了烧录教程中的所有步骤,固件现在应该已经加载到 Blue Pill 上了。 恭喜,你正在运行一个嵌入式程序!是不是很激动? 你说什么,什么也没发生? 好吧,确实没发生什么你能察觉到的事情,但我保证 STM32F103 正在你的程序下嗡嗡作响,匆忙地执行着我们设置的空循环。 如果你觉得“这太无聊了”,你是对的。现在让我们寻找生命的迹象。
内存映射外设
Blue Pill 是一个经济实惠且设计优雅的板子,但开箱即用并没有包含太多有趣的周边设备。如果没有额外的硬件,我们证明工作的唯一方法就是闪烁位于红色电源指示灯下方的绿色 LED。 不过,考虑到闪烁 LED 被公认为嵌入式系统的“Hello World”,所以我们走在正确的轨道上。
现在该谈谈 STM32 微控制器上的外设是如何工作的了。也许你期待找到某种提供 turn_on_led 函数的 API,但请记住,我们是孤立无援的。 没有库:只有我们和内存空间……而这正是我们应该查找的地方。 大多数微控制器通过将特定的内存地址映射到控制其操作的寄存器来与外设交互。想象一下,就像“向此地址写入 1 以打开灯”,只是稍微复杂一点。
板上的绿色 LED 在内部连接到 PC13 GPIO(通用输入/输出)。你可以在参考手册的第 9 节中找到关于它们如何工作的详细信息,但我会给你一个总结。 GPIO 被分为不同的端口,用字母(A、B、C 等)命名。每个端口最多可以处理 16 个引脚,我们感兴趣的恰好是 C 端口的第 13 个。 每个端口都有几个管理 IO 操作的寄存器;我们感兴趣的是其中三个:
- APB2 外设时钟使能寄存器 (RCC_APB2ENR),在参考手册 8.3.7 节描述,地址为
0x40021018。芯片上的每个外设都必须输入一个时钟脉冲,在使用前必须启用。对于 GPIOC 端口,这意味着设置该寄存器的第五位。 - 端口配置寄存器高 (GPIOC_CRH),在参考手册 9.2.2 节描述,地址为
0x40011004。这是决定 GPIO 配置的一对寄存器中的高位部分。每个 IO 有 4 位,对于 GPIOC13,相关位是从 20 到 23(包含)。 - 端口输出数据寄存器 (GPIOC_ODR),在参考手册 9.2.4 节描述,地址为
0x4001100C。通过设置或清除该寄存器中的某一位,对应的引脚将保持高电平或低电平(这意味着 LED 将被点亮或熄灭)。
每个寄存器的地址可以通过跟踪参考手册 3.3 节描述的内存映射找到。每个寄存器都是 32 位的,所以它的地址可以被视为 Zig 中 u32 的指针,这正是我们将要实现的方式。
将这三个定义添加到 main.zig 中:
pub const RCC_APB2ENR = @intToPtr(*volatile u32, 0x40021018);
pub const GPIOC_CRH = @intToPtr(*volatile u32, 0x40011004);
pub const GPIOC_ODR = @intToPtr(*volatile u32, 0x4001100C);
现在这三个常量变量包含了每个寄存器的地址,使访问变得更容易。 当我们使用 @intToPtr 进行强制转换时,我们指定 *volatile u32 作为目标类型:“指向 u32 的指针”部分应该很清楚了;volatile 是一个关键字,让编译器知道我们使用的变量可能会毫无征兆地发生变化,并且对其的读写操作绝不能被优化掉。 例如,向内存映射寄存器连续写入同一个值可能对硬件有特殊含义,但如果编译器认为第二次写入无效(对于普通内存位置应如此),它可能会决定删除它。有了 volatile,我们确保这种情况不会发生。
让我们使用这些寄存器来设置并周期性地闪烁绿色 LED:
pub const RCC_APB2ENR = @intToPtr(*volatile u32, 0x40021018);
pub const GPIOC_CRH = @intToPtr(*volatile u32, 0x40011004);
pub const GPIOC_ODR = @intToPtr(*volatile u32, 0x4001100C);
export fn main() void {
RCC_APB2ENR.* |= @as(u32, 0x10); // 使能 GPIOC 时钟
GPIOC_CRH.* &= ~@as(u32, 0b1111 << 20); // 清除与 PC13 相关的所有位
GPIOC_CRH.* |= @as(u32, 0b0010 << 20); // 设置所需配置:输出推挽,2MHz
while (true) {
var i: u32 = 0;
GPIOC_ODR.* ^= @as(u32, 0x2000); // 切换对应 GPIOC13 的位
while (i < 100_000) { // 等待一会
i += 1;
}
}
}
注意所有的寄存器访问都是对现有内容的修改(|=, &=, ^=)而不是直接写入。这是因为寄存器管理许多不同的外设(C 端口的所有 IO),我们只关心修改其中几位的值。 虽然这里保留其他位原样不是必须的(因为我们只使用 IO13),但这是一种好的编程习惯。
我们使用十六进制和二进制字面量来表达对寄存器的更改:例如,与 0x10 进行逻辑或意味着设置第五位——这反过来意味着在应用于 GPIOC_RCC 指向的内存时启用 PORTC 时钟。 显式转换为 u32 是多余的,但使步骤对读者来说没有歧义。 真正的动作发生在 GPIOC_ODR.* ^= @as(u32, 0x2000),它切换第 13 位,从而控制 LED。之后我们计数到 100,000 以在再次翻转之前等待一段时间。 注:这不是一种很好的等待方式,因为我们实际上不知道 CPU 完成该循环需要多少时间,但目前这样足够了。
如果你一切操作正确,现在可以再次编译并加载代码:
$ zig build-exe -target thumb-freestanding -mcpu cortex_m3 --name application.elf --script memory.ld main.zig
$ arm-none-eabi-objcopy -O binary application.elf application.bin
# 使用你最喜欢的方法将二进制文件加载到目标上
绿色 LED 应该周期性地点亮和熄灭!实际频率可以通过减少或增加每个循环周期之间的计数值来进行调节。
结论
这真是一次旅程。我们从头开始构建了一个嵌入式应用程序,没有依赖像制造商提供的 IDE 那样的第三方工具协助。 这很有教育意义,但我相信我们都会同意,为每个新的嵌入式项目重复这个过程会非常笨重。 好消息是,未来我们可以更轻松。将来会有围绕 Zig 的工具来简化这个过程,你只需要指定你的目标设备;然后,链接器脚本、启动过程和外设访问都将自动生成。 事实上,Zig 作为一种语言提供了像 comptime 指令这样的特性,这应该会使这个过程变得相当简单。
一旦基础稳固,就可以在其上构建更复杂的结构。下一个自然步骤将是:
- 将项目拆分为更多文件(一个用于启动,一个用于 main 函数,库……),并使用 Zig 构建库而不是命令行。
- 更详细地配置设备,例如设置与默认值不同的时钟速度。
- 使外设访问更方便。
- 精确跟踪经过的时间。
- 使用更复杂的外设。