第一个固件:启动流程、向量表与 GPIO 点灯
本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 4 篇。上一篇:工程结构剖析:build.zig、MicroBuild 与固件产物,下一篇:寄存器级编程:PAC、MMIO 与 volatile 语义,目录见文末。
第一个固件:启动流程、向量表与 GPIO 点灯
导读
第 03 篇解决了"怎么编出来",这一篇解决"上电之后发生了什么"。我们会拆开 microzig 固件的三件套(panic、std_options、export_startup),画出从复位到 main 的完整启动序列,解释 pub fn main() !void 在裸机上的真实语义,然后给出本系列的基准固件:PB5 点灯,100 ms 周期闪烁。这一篇之后,"固件为什么能跑"对你不再是黑盒。
为什么需要启动代码
裸机的 CPU 上电时什么都不懂:它只做三件事——从地址 0x00000000(BOOT0=0 时映射到 Flash 0x08000000)读一个 32 位值装进 MSP(主栈指针),从偏移 4 读一个 32 位值装进 PC(这就是复位处理函数的地址,最低位必须是 1,表示 Thumb 状态),清掉 IPSR 和 PRIMASK。之后一切都要靠软件:
- 向量表必须摆在 Flash 起始:第 0 项是初始栈顶,第 1 项是 Reset 处理函数,第 2–15 项是 NMI/HardFault 等异常,第 16 项起是外设中断。
.data段初始化:有初值的全局变量(var counter: u32 = 42;)初值存在 Flash 里,启动时必须拷到 SRAM——不拷的话读到的是垃圾。.bss段清零:var buf: [64]u8 = undefined;之外的零初始化全局变量依赖启动代码 memset——不清的话"var x: u32 = 0;居然不是 0"。- 调用
main。
C 世界里这些由 startup_stm32f10x_hd.s(汇编)+ SystemInit 完成;microzig 里由 export_startup() 在编译期生成,源码在 core/src/cpus/cortex_m.zig——向量表是一个 regz 生成的 extern struct,_start 用汇编与 Zig 混合实现,.data/.bss 初始化在 core/src/utilities.zig 的 initialize_system_memories 里,靠链接脚本提供的 microzig_data_start/_end、microzig_bss_start/_end、microzig_data_load_start 符号定位各段边界。
这就是 comptime { _ = microzig.export_startup(); } 做的四件事的完整清单:导出 _start 与向量表、拷 .data、清 .bss、跳 main。少写这一行,链接器会报 undefined symbol: microzig_main(链接脚本里有 ASSERT(DEFINED(microzig_main), ...) 的显式检查,报错信息会直接告诉你该加哪三行)。
代码案例:30 行点灯固件
const microzig = @import("microzig");
const stm32 = microzig.hal;
const rcc = stm32.rcc;
const gpio = stm32.gpio;
const time = stm32.time;
// 三件套之一:panic。必须写,否则 Zig 默认 panic 拉进格式化与栈回溯,Flash 暴涨。
pub const panic = microzig.panic;
// 三件套之二:std_options。默认把 std.log 的 logFn 设为空函数,
// 让 freestanding 下能编译;需要日志时再换 uart.log(第 09 篇)。
pub const std_options = microzig.std_options(.{});
// 三件套之三:启动代码。生成 _start、向量表、.data/.bss 初始化。
comptime {
_ = microzig.export_startup();
}
pub fn main() !void {
// 时钟:HSE 8 MHz x PLL9 = 72 MHz,APB1 分频 2(36 MHz 上限)。
// 第 07 篇逐字段讲解。
_ = try rcc.apply(.{
.SYSCLKSource = .PLL1_P,
.PLLSourceVirtual = .HSE_Div_PREDIV,
.PLLMUL = .Mul9,
.APB1CLKDivider = .Div2,
.RTCClockSelection = .LSI,
.flags = .{
.RTCUsed_ForRCC = true,
.HSEOscillator = true,
},
});
// 让 time 模块有节拍来源(RTC 或 TIM2-TIM5 均可)。
time.init_rtc();
rcc.enable_clock(.GPIOB);
const led = gpio.Pin.from_port(.B, 5);
led.set_output_mode(.general_purpose_push_pull, .max_50MHz);
while (true) {
time.sleep_ms(100);
led.toggle();
}
}
构建烧录:
zig build -Doptimize=ReleaseSmall
probe-rs run --chip STM32F103ZE zig-out/firmware/f103ze.elf
逐段解析
启动顺序全景
上电/复位
│ 硬件:从 0x08000000 读 MSP,从 +4 读 PC(=Reset 处理函数)
▼
_start(microzig 生成)
│ 初始化 .data(从 Flash 拷到 SRAM)
│ 清零 .bss
▼
microzig_main(microzig 生成)
│ 若 root 文件定义了 pub fn init() 则调用它,
│ 否则调用 hal.init()(F103 的 hal.init 只做一件事:读复位原因存入 hal.Reset_Reason)
│ 调用 main();若返回错误则 panic(消息形如 "main() returned error.XXX")
│ main 返回后调用 hang():关中断 + 空循环,不会"跑飞"
▼
main()
│ rcc.apply(...):时钟仍是 HSI 8 MHz,现在切到 PLL 72 MHz
│ time.init_rtc() / init_timer(...):给 sleep_ms 提供节拍
▼
while(true) { sleep_ms(100); led.toggle(); }
三个关键事实值得记住。第一,复位后时钟是 HSI 8 MHz——rcc.apply 之前的所有代码都跑在 8 MHz 上,包括 rcc.apply 自己。第二,F103 的 HAL 定义了 init()(读复位原因),microzig 的启动逻辑会在 main 之前自动调用它,所以 microzig.hal.Reset_Reason 在 main 里直接可读(上电复位是 .POR_or_PDR,看门狗复位是 .independent_watchdog 等,第 18 篇会利用它做复位诊断)。第三,main 返回不是"结束"而是 hang()——死循环但中断已关,调试器还能连上。
pub fn main() !void 的语义
microzig 的 microzig_main 同时支持 main() void 与 main() !void:返回类型是错误联合时,它 catch 住错误并 panic,消息是 main() returned error. 加错误名。两个约束:main 必须有显式返回类型(不写返回类型或写 noreturn 会触发编译期错误 microzig: main must have a return type);错误名依赖 @errorName 生成的字符串,量产固件想省 Flash 可以在 microzig_options 里开 .simple_panic_if_main_errors = true,让 main 出错时只 panic 一条静态消息。
panic 与 std_options:为什么必须写
pub const panic = microzig.panic; 的作用是把 Zig 的 panic 处理器换成 microzig 的版本:打日志(std.log.err("panic: {s}"))、可选触发断点(microzig_options.breakpoint_in_panic)、然后 hang()(关中断死循环)。不写的话编译器用标准库默认 panic——它依赖格式化 machinery 与栈回溯展开,在 freestanding 下要么链接失败,要么把 Flash 吃掉几十 KB。
pub const std_options = microzig.std_options(.{}); 的作用是给 std.log 配置 logFn。microzig 的默认实现是一个空函数(什么都不打印)——这看起来奇怪,其实是让 std.log.info 在没有日志输出的固件里也能编译通过的关键。如果你想用日志,把空 options 换成 .{ .logFn = stm32.uart.log } 并在 main 里 init_logger(第 09 篇专题)。不要写 std.debug.print:它在 freestanding 下会拉进 std.Io.Threaded 与 std.posix,直接编译失败,报错指向 std 内部,与你的代码无关。
顺带一个体积数据:panic 回溯信息默认只在 Debug 模式开启(panic_stack_trace 的默认值是 builtin.optimize == .debug),所以 ReleaseSmall 下 panic 已经很瘦——三件套写全是"保底不炸",ReleaseSmall 是"进一步瘦身"。
点灯三行代码
const led = gpio.Pin.from_port(.B, 5);
led.set_output_mode(.general_purpose_push_pull, .max_50MHz);
led.toggle();
Pin.from_port(.B, 5) 构造一个 enum(usize) 值:pin + 16 * 端口序号 = 5 + 16 = 21。F1 的 GPIO 没有独立"方向"寄存器,模式编码在 CRL/CRH 的每引脚 4 位里:set_output_mode(mode, speed) 写入 speed + (mode << 2),写完即是输出——不需要再调一个"set_direction"(那是老教程 API,0.17 的 F103 HAL 没有)。.max_50MHz 是 Speed 枚举的一个反直觉成员(枚举值顺序是 1/2/3 对应 10/2/50 MHz),第 06 篇专门展开。
toggle() 的实现是 port.ODR.raw ^= gpio.mask()——读-改-写,非原子。本固件里只有主循环碰这个引脚,没问题;一旦中断里也要操作同一引脚,就要考虑换成 put(1)/put(0)(写 BSRR,单次写、原子)。put(1) 与 toggle() 的差异在本篇只提一句:BSRR 是"写 1 置位、写 0 无效"的专用寄存器,一次 store 完成动作,天然免疫读改写竞态;ODR 翻转需要 read-modify-write 三步,中间可被打断。第 06 篇用两个 LED 交替闪烁的例子量化这个差异。
time.sleep_ms 的前提
time.sleep_ms(100) 依赖节拍来源:time.init_rtc()(用 RTC,需要时钟配置里带 .RTCClockSelection + .flags.RTCUsed_ForRCC = true,正如示例所写)或 time.init_timer(.TIM2)(用通用定时器,注意它只支持 TIM2–TIM5,且会自动打开全局中断)。忘了初始化节拍就调 sleep_ms,程序会卡死在等待里——现象是"灯常亮或常灭,不闪"。RTC 与定时器两种节拍的取舍(精度、占用、与 PWM 的资源冲突)在第 10 篇展开。
运行现象
PB5 的 LED 以 100 ms 亮、100 ms 灭的节奏闪烁(5 Hz 方波,人眼看起来是均匀快闪)。用逻辑分析仪或示波器接 PB5(探头地夹接 GND)验证:周期 200.0 ms ± 晶振误差,占空比 50%。如果手头有信号源级精度需求,注意周期误差由 HSE 晶振决定(普通 8 MHz 晶振 ±20 ppm 到 ±50 ppm,即 200 ms 周期偏差 ±10 µs 量级),对呼吸灯类应用完全无感。
体积对比(同一份代码,file 与 ls -l 实测量级):
| 构建等级 | ELF 大小量级 |
|---|---|
| Debug(默认) | 约 300–400 KB(DWARF + 回溯 + 安全检查) |
| ReleaseSmall | 约 1–2 KB(.text + .data + 向量表,无调试段) |
差别来自三块:Debug 把 panic 栈回溯、DWARF 调试信息、安全检查代码全部链入。烧 Flash 用 ReleaseSmall,调试时用 Debug(断点、变量查看需要 DWARF)。
probe-rs run 的输出在烧录完成后保持前台(等待 RTT),本固件没有 RTT 输出,终端安静即正常;Ctrl-C 退出后 LED 继续闪(芯片复位后已在独立运行)。
常见问题
Q1:链接报 undefined symbol: microzig_main 或 ASSERT 文本提到 export_startup。
成因:root source file(add_firmware 的 .root_source_file 指向的那个文件)里没有 comptime { _ = microzig.export_startup(); }。microzig 的链接脚本里有显式断言,报错会提示加这三行。解决方案:加到 root 源文件的顶层(不能加在别的被 import 的文件里——export_startup 导出的 microzig_main 必须能引用到 root 的 main)。另一个易错点:把三件套写进了 src/main.zig 但 build.zig 的 root_source_file 指向了别的文件(比如 src/root.zig),同样会报此错。
Q2:编译通过、烧录成功,但灯不闪,量引脚发现根本没有波形。
成因按概率:忘了 rcc.enable_clock(.GPIOB)(F103 未开时钟的外设寄存器写不进去,"写成功但无效"是新手 90% 的莫名问题);sleep_ms 前忘了 init_rtc/init_timer(卡死在第一次 sleep,灯停在初始电平);main 里的 try rcc.apply(...) 因 HSE 起振失败返回错误触发 panic(灯停在 panic 时的状态,hang() 死循环)——用 Debug 构建加断点或看 panic 消息确认。解决方案逐项排查:先注释掉时钟配置(用默认 HSI 8 MHz 跑)把问题切成"时钟问题"或"GPIO 问题"两半。
Q3:灯在闪但周期明显不对(比如快 9 倍)。
成因:时钟配置与硬件不符。sleep_ms 的精度依赖节拍来源的时钟假设;如果 rcc.apply 没生效(比如 HSE 没起振但代码继续跑),或 time 用了定时器模式而定时器时钟假设错误,周期就会按错误的频率缩放。9 倍恰好是 72/8 的比值——rcc.apply 失败但被 catch 吞掉、固件实际跑在 8 MHz HSI 上,而节拍初始化却按 72 MHz 算。解决方案:_ = try rcc.apply(...) 不要改成 catch {} 吞错误;用第 07 篇的 MCO 输出验证真实 SYSCLK。
Q4:ReleaseSmall 下程序行为诡异,Debug 下正常。
成因:三类。安全检查被关——越界、溢出、unreachable 不再 panic 而是未定义行为;std.debug.assert 被编译掉,依赖它做的运行时检查静默消失;未初始化变量在 Debug 下填 0xAA(容易发现),Release 下是栈上残留值。解决方案:把 std.debug.assert 换成 microzig.assert(expr, .{}) 并在 add_firmware 加 .asserts = true;所有缓冲区显式初始化(@splat(0) 或 memset);用 ReleaseSafe 构建过一遍(保留安全检查的优化版)捕捉只在优化后出现的 bug。
Q5:pub fn main() 不写返回类型或写 noreturn 报 main must have a return type。
成因:microzig 的 microzig_main 用 @typeInfo(@TypeOf(main)).@"fn".return_type 检查返回类型,noreturn 或省略会让这个 optional 为 null,触发编译期错误。解决方案:写 main() void(出错场景自己 panic)或 main() !void(错误自动变成 panic 消息)。这不是 Zig 的要求,是 microzig 的约定——裸机上"main 返回"没有意义,框架用返回类型区分"正常结束进 hang"和"出错进 panic"。
Q6:改了 PB5 想换 PE5,编译通过但灯不亮。
成因:这是 microzig F103 GPIO HAL 的上游 bug——Pin.get_port() 对端口 E/F/G 返回 GPIOA,Pin.from_port(.E, 5).put(1) 实际写的是 PA5。PE5 的操作要等第 05 篇寄存器层方案(直接写 microzig.chip.peripherals.GPIOE.BSRR)。本系列约定:HAL 示例只用 PA/PB/PC,PE/PF/PG 一律走寄存器层。
面试考点
Q1:描述 Cortex-M3 从复位到 main 的完整启动序列,microzig 在哪些环节做了什么。
参考答案:硬件阶段:复位后从向量表第 0 项取 MSP、第 1 项取 PC(Reset 处理函数,地址最低位为 1 表示 Thumb),清 IPSR/PRIMASK,进入 Thread 模式特权级。软件阶段(microzig 的 _start):初始化 .data(用链接脚本符号 microzig_data_load_start 从 Flash 拷贝到 SRAM 的 microzig_data_start.._end)、清零 .bss(microzig_bss_start.._end memset)、然后进入 microzig_main:先调 root 或 HAL 的 init()(F103 上读复位原因),再调用户的 main,出错则 panic,正常返回则 hang()(关中断死循环)。与 C 工具链的差异:没有 __libc_init_array(无 C++ 构造函数需求),时钟初始化不放在 Reset 里而是留给 main。
Q2:为什么裸机固件必须自定义 panic 与 std_options?不写会发生什么?
参考答案:Zig 默认 panic 依赖标准库的格式化与栈回溯机制,而这些在 0.17 的实现里最终依赖 std.Io.Threaded 与 std.posix(线程、系统调用),freestanding 目标下链接失败;即使能链接,回溯与格式化代码也会让 Flash 占用暴涨几十 KB。pub const panic = microzig.panic; 换成 microzig 的轻量实现(打日志 + 可选断点 + 关中断死循环)。std_options 同理:std.log 的默认 logFn 在裸机下编不过,microzig 的 std_options(.{}) 注入一个空 logFn 让它可编译;需要输出时换成 uart.log。这是"std 面向 OS"这一 Zig 代价在裸机上的具体表现。
Q3:gpio.Pin.put(1) 和 toggle() 在并发语义上有什么差别?各是什么指令序列?
参考答案:put(1) 写 BSRR 寄存器(值 = 引脚掩码),单条 store,原子;BSRR 是 ST 专门为"无读改写置位/复位"设计的寄存器,写 0 的位无动作,所以多上下文同时写 BSRR 操作不同引脚互不干扰。toggle() 是 ODR.raw ^= mask,三步:读 ODR、异或、写回 ODR,非原子;若中断在读写之间插入并修改了同端口 ODR,写回会覆盖中断的修改(丢更新)。所以多中断共享端口时要么都用 put,要么用临界区包住 toggle。F103 没有"单寄存器翻转"的位带原子写(位带别名区只覆盖部分地址且本质仍是写 0/1 而非翻转)。
Q4:固件的 .data 段初始化失败会表现为什么?为什么"var x: u32 = 0 不是 0"这类 bug 在裸机常见?
参考答案:.data(有初值全局变量)的初值由链接器放在 Flash 的加载地址(LMA),启动代码拷到 SRAM 的虚拟地址(VMA)。拷贝逻辑依赖链接脚本导出的 microzig_data_load_start(LOADADDR(.data))等符号;若链接脚本被错误覆盖(linker_script.generate 设为 none 又没自己写这几行),启动代码拿到的是垃圾地址,表现是全局变量初值随机、const 字符串读出乱码。var x: u32 = 0; 属于 .bss(零初始化段),依赖启动代码 memset——C 的 startup 文件和 microzig 的 initialize_system_memories 都做这件事,裸手写固件忘了就是"零初始化变量不是零"的经典 bug。
Q5:Debug 构建和 ReleaseSmall 构建的固件差多少?差在哪些部分?何时选哪个?
参考答案:同一个点灯固件,Debug ELF 约 300–400 KB(DWARF 调试信息、panic 栈回溯、运行时安全检查——越界/溢出/unreachable panic——全部链入),ReleaseSmall 约 1–2 KB 量级,差两个数量级以上。ReleaseSmall 关闭安全检查且 std.debug.assert 消失,风险用 .asserts = true 的 microzig.assert 对冲。选择:烧板验证与发布用 ReleaseSmall(体积、时序);打断点调试用 Debug(需要 DWARF);排查"只在优化后出现的 bug"用 ReleaseSafe(优化 + 安全检查并存)。三个模式都构建一遍是发布前的标准流程。
小结
export_startup四件事:导出_start与向量表、拷.data、清.bss、跳microzig_main;缺它链接报undefined symbol: microzig_main。- 启动顺序:复位取 MSP/PC →
.data/.bss初始化 →init()(F103 上读复位原因)→main→ 返回后hang();main之前时钟仍是 HSI 8 MHz。 - 三件套缺一不可:
panic = microzig.panic(防 Flash 暴涨/链接失败)、std_options = microzig.std_options(.{})(freestanding 可编译)、comptime { _ = microzig.export_startup(); }。 main() !void出错会 panic;必须有显式返回类型;hang()保证不跑飞。- 点灯三行:
from_port构造引脚、set_output_mode(F1 无独立方向寄存器)、toggle(ODR 读改写非原子,put写 BSRR 原子)。 sleep_ms需要init_rtc或init_timer(.TIM2..TIM5)提供节拍,忘了就卡死。- 下一篇下潜一层:不用 HAL,直接用 PAC 与 MMIO 点亮 PE5。
系列目录
- 系列总览:Zig 裸机开发的定位、生态与平台全景
- 开发环境搭建:Zig 0.17、microzig 与 probe-rs
- 工程结构剖析:build.zig、MicroBuild 与固件产物
- 第一个固件:启动流程、向量表与 GPIO 点灯
- 寄存器级编程:PAC、MMIO 与 volatile 语义
- GPIO 子系统:输入输出模式、上下拉与端口映射陷阱
- 时钟树配置:HSE、PLL 与总线分频
- 中断系统:NVIC、优先级分组与向量注册
- USART 串口通信:轮询收发与日志重定向
- 定时器基础:时基、PWM 与呼吸灯
- 输入捕获:频率与占空比测量
- 中断驱动串口:环形缓冲区与临界区设计
- DMA 传输:从外设搬运到内存
- I2C 总线:SSD1306 OLED 显示实战
- SPI 总线:W25Q64 Flash 读写与 ID 识别
- ADC 采样:单次、扫描与内部温度传感器
- 内部 Flash 编程:参数持久化与页擦除
- 独立看门狗:复位诊断与可靠性设计
- 低功耗模式:Sleep、Stop、Standby 与唤醒
- 内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
- Zig 语言进阶:comptime、错误处理与零成本抽象
- 并发模型:中断与主循环的协作与临界区
- 协作式调度器:状态机与时间片轮转
- 实时性分析:中断延迟、抖动与 WCET
- RTOS 内核实现(上):任务控制块与上下文切换
- RTOS 内核实现(下):信号量、消息队列与优先级继承
- USB 设备开发:CDC 虚拟串口
- CAN 总线:bxCAN 过滤器与环回自测
- 调试与测试:probe-rs、RTT 与硬件在环
- 收官:分层架构、可移植性与 Zig 生态展望

浙公网安备 33010602011771号