传统嵌入式开发的第一道坎是搭工具链:ARM 的
arm-none-eabi-gcc、RISC-V 的riscv64-unknown-elf-gcc,版本、头文件、链接脚本各配一套。Zig 则把这个过程压缩成一行-target thumb-freestanding-eabihf——无需安装任何第三方交叉工具链,标准库自带全部目标支持。
本文完整走一遍 Zig 的嵌入式流程:交叉目标模型、build.zig 多目标构建、MCU 裸机寄存器编程(STM32 GPIO/UART/SysTick),以及主流硬件平台的对比与烧录调试。
1. Zig 的交叉编译模型
1.1 目标三元组
Zig 的每个编译目标由三元组 arch-os-abi 描述,嵌入式场景最常见的目标:
| 目标三元组 | 典型硬件 | 说明 |
|---|---|---|
thumb-freestanding-eabihf | STM32、nRF52、RP2040(Cortex-M) | ARM Thumb 指令集,硬件浮点 |
arm-freestanding-eabihf | Cortex-A 单板机 | ARM 32 位 |
aarch64-freestanding-eabihf | Raspberry Pi 3/4 | ARM64 |
riscv32-freestanding-eabihf | ESP32-C3、GD32VF103 | RISC-V 32 位 |
riscv64-freestanding-eabihf | 高端 RISC-V SoC | RISC-V 64 位 |
freestanding 表示无操作系统、无 libc;eabihf 表示嵌入式 ABI 且启用硬件浮点。查看完整列表:
zig targets
# 输出 JSON,其中 available_libs / abi / cpu_arch 等均可查
1.2 CPU 模型:精确定位内核
三元组只到"架构"粒度。同一架构内的不同 MCU 差异很大(如 Cortex-M0+ 无硬件除法,Cortex-M4F 有 FPU),需要用 CPU 模型精确指定:
const target = b.resolveTargetQuery(.{
.cpu_arch = .thumb,
.cpu_model = .{ .explicit = &std.Target.arm.cpu.cortex_m4 },
.os_tag = .freestanding,
.abi = .eabihf,
});
常用 CPU 模型:cortex_m0、cortex_m3、cortex_m4、cortex_m4f(在 std.Target.arm.cpu 下)、cortex_a53、RISC-V 的 rv32imac 等。精确的 CPU 模型决定 Zig 能生成哪些指令(m3 才能用硬件乘法,m4f 才启用 FPU)。
1.3 为什么不需要交叉工具链
Zig 把"编译器 + 目标支持"绑定在同一次发布里:zig cc/zig c++ 可作为交叉编译器,zig build-lib 直接面向任意目标生成 ELF。没有独立的 binutils——objcopy、strip 都由 Zig 内建步骤完成。这使得一台机器、一个 Zig 版本,即可编译任意架构,CI 配置也随之大幅简化。
2. 编译流程与构建配置
2.1 build.zig 多目标构建
一个 build.zig 参数化地构建多个 MCU 固件:
// build.zig
const std = @import("std");
pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});
addMcu(b, optimize, "stm32f411", .{
.cpu_arch = .thumb,
.cpu_model = .{ .explicit = &std.Target.arm.cpu.cortex_m4 },
.os_tag = .freestanding,
.abi = .eabihf,
}, "src/stm32/main.zig", "src/stm32/linker.ld");
addMcu(b, optimize, "esp32c3", .{
.cpu_arch = .riscv32,
.os_tag = .freestanding,
.abi = .eabihf,
}, "src/esp32c3/main.zig", "src/esp32c3/linker.ld");
}
fn addMcu(
b: *std.Build,
optimize: std.builtin.OptimizeMode,
name: []const u8,
query: std.Target.Query,
root: []const u8,
linker: []const u8,
) void {
const target = b.resolveTargetQuery(query);
const exe = b.addExecutable(.{
.name = name,
.root_source_file = b.path(root),
.target = target,
.optimize = optimize,
});
exe.setLinkerScript(b.path(linker));
exe.entry = .{ .symbol = "reset_handler" };
b.installArtifact(exe);
// 同时生成 .bin(烧录用)与 .hex(IDE/量产用)
const bin = exe.addObjCopy(.{ .format = .bin });
b.getInstallStep().dependOn(&b.addInstallFileWithDir(
bin.getOutput(), .bin, b.fmt("{s}.bin", .{name}),
).step);
const hex = exe.addObjCopy(.{ .format = .hex });
b.getInstallStep().dependOn(&b.addInstallFileWithDir(
hex.getOutput(), .hex, b.fmt("{s}.hex", .{name}),
).step);
}
zig build
# zig-out/bin/ 下生成:
# stm32f411.elf / stm32f411.bin / stm32f411.hex
# esp32c3.elf / esp32c3.bin / esp32c3.hex
addObjCopy(.{ .format = .bin }) 等价于传统工具链里的 arm-none-eabi-objcopy -O binary——把 ELF 里的代码和数据按链接地址切出来。
2.2 链接脚本与内存布局
MCU 的 Flash 和 RAM 是两块分离的物理地址空间,链接脚本必须明确每一段落在哪、是否要搬运(.data 初值在 Flash,运行期拷到 RAM):
/* src/stm32/linker.ld —— STM32F411CEU6 */
ENTRY(reset_handler)
MEMORY {
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx): ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS {
/* 向量表必须位于 Flash 起始 */
.isr_vector : {
KEEP(*(.isr_vector))
. = ALIGN(4);
} > FLASH
.text : {
*(.text*)
*(.rodata*)
. = ALIGN(4);
_etext = .;
} > FLASH
/* 初值在 Flash,运行期复制到 RAM(AT> FLASH) */
.data : {
_sdata = .;
*(.data*)
. = ALIGN(4);
_edata = .;
} > RAM AT> FLASH
.bss : {
_sbss = .;
*(COMMON)
*(.bss*)
. = ALIGN(4);
_ebss = .;
} > RAM
/* 堆/栈 */
. = ALIGN(8);
_heap_start = .;
. = . + 4K;
_stack_top = .;
}
AT> FLASH 的加载地址语法是 .data 搬运的关键:链接器把初值放在 Flash,同时给 .data 一个 RAM 的运行时地址。
2.3 启动:向量表与复位
Cortex-M 的上电流程比 x86 简单得多:CPU 从 Flash 起始读取两个值——初始栈指针和复位处理函数地址,然后直接跳到复位处理函数。整个启动就在一个数组里:
// src/stm32/main.zig
const std = @import("std"); // freestanding 下仅用 comptime/断言
extern const _stack_top: u8; // 链接脚本符号
extern const _sdata: u8;
extern const _edata: u8;
extern const _sbss: u8;
extern const _ebss: u8;
extern const _etext: u8;
fn exception_loop() callconv(.c) noreturn {
while (true) {}
}
export fn reset_handler() callconv(.c) noreturn {
// 1. 复制 .data 初值:Flash(_etext..) -> RAM(_sdata.._edata)
const data_len = @intFromPtr(&_edata) - @intFromPtr(&_sdata);
const src: [*]const u8 = @ptrCast(&_etext);
const dst: [*]u8 = @ptrCast(&_sdata);
@memcpy(dst[0..data_len], src[0..data_len]);
// 2. 清零 .bss
const bss_len = @intFromPtr(&_ebss) - @intFromPtr(&_sbss);
const bss: [*]u8 = @ptrCast(&_sbss);
@memset(bss[0..bss_len], 0);
// 3. 跳转主逻辑
main();
}
// 向量表:必须位于 .isr_vector 段(链接脚本保证其在 Flash 首)
export var vectors linksection(".isr_vector") = [_]usize{
@intFromPtr(&_stack_top), // 初始 MSP
@intFromPtr(&reset_handler), // Reset
@intFromPtr(&exception_loop), // NMI
@intFromPtr(&exception_loop), // HardFault
// ... 其余异常/中断向量按需填写
};
@memcpy/@memset 在 freestanding 下可用,因为它们只是内建展开的机器指令,不依赖任何 OS。
3. MCU 裸机编程
3.1 寄存器访问的规范姿势
嵌入式编程的本质是"读-改-写"特定地址的 32 位寄存器。用 volatile 指针封装成统一访问函数:
// 外设基址
const RCC_BASE = 0x40023800; // 复位与时钟控制
const GPIOA_BASE = 0x40020000; // GPIO 端口 A
const USART2_BASE = 0x40004400;
inline fn reg(comptime base: usize, comptime offset: usize) *volatile u32 {
return @ptrFromInt(base + offset);
}
comptime {
// 编译期锁定寄存器地址对齐
std.debug.assert(@intFromPtr(reg(RCC_BASE, 0x30)) % 4 == 0);
}
comptime 参数让 reg 的所有调用都内联为一条地址计算 + 一次加载/存储,零抽象开销。volatile 必不可少:寄存器读写有副作用,编译器不能优化掉、不能重排。
3.2 实战:STM32F411 点灯
GPIOA 的时钟门在 RCC->AHB1ENR 的 bit0,PA5 输出模式在 GPIOA->MODER 的 bit10:11:
fn delay(ms: u32) void {
var i: u32 = 0;
while (i < ms * 4000) : (i += 1) {
asm volatile ("nop");
}
}
fn main() noreturn {
// 1. 打开 GPIOA 时钟
reg(RCC_BASE, 0x30).* |= (1 << 0); // AHB1ENR |= RCC_AHB1ENR_GPIOAEN
// 2. PA5 设为推挽输出(MODER[11:10] = 01)
reg(GPIOA_BASE, 0x00).* |= (1 << 10);
reg(GPIOA_BASE, 0x00).* &= ~(1 << 11);
// 3. 翻转 ODR bit5 实现闪烁
while (true) {
reg(GPIOA_BASE, 0x14).* ^= (1 << 5); // ODR
delay(200);
}
}
对照芯片手册的寄存器偏移表(AHB1ENR=0x30、MODER=0x00、ODR=0x14),每个数字都可追溯。
3.3 UART 驱动
调试输出的黄金通道是 UART。STM32F4 的 USART2 挂在 APB1 上,配置流程:使能时钟 → 设置波特率分频 → 使能发送 → 写 DR:
fn uart_init() void {
reg(RCC_BASE, 0x40).* |= (1 << 17); // APB1ENR |= USART2EN
// 波特率分频:PCLK1=16MHz,115200 → BRR ≈ 8.68 → 取 0x0D6
reg(USART2_BASE, 0x08).* = 0x0D6; // BRR
reg(USART2_BASE, 0x0C).* |= (1 << 13) | (1 << 3) | (1 << 2) | (1 << 0);
// CR1: UE(13)=1, TE(3)=1, RE(2)=1
}
fn uart_putc(c: u8) void {
while ((reg(USART2_BASE, 0x00).* & (1 << 7)) == 0) {} // 等 TXE
reg(USART2_BASE, 0x04).* = c; // DR
}
fn uart_print(msg: []const u8) void {
for (msg) |c| uart_putc(c);
}
把 uart_print 挂进 reset_handler,配合串口终端即可看到"printf 之外"的调试输出。
3.4 SysTick 定时器与中断
SysTick 是每个 Cortex-M 都有的 24 位向下计数定时器。配置它产生周期中断,需要把处理函数地址填进向量表:
const SYSTICK_BASE = 0xE000E010;
var ticks: volatile u32 = 0; // 中断里写、主循环读,volatile 保证可见
fn systick_init(reload: u32) void {
reg(SYSTICK_BASE, 0x00).* = 0; // CTRL = 0,先关
reg(SYSTICK_BASE, 0x04).* = reload - 1; // LOAD
reg(SYSTICK_BASE, 0x08).* = 0; // VAL = 0
reg(SYSTICK_BASE, 0x00).* =
(1 << 2) | // CLKSOURCE:处理器时钟
(1 << 1) | // TICKINT:使能中断
(1 << 0); // ENABLE
}
fn systick_handler() callconv(.c) void {
ticks += 1;
}
// 向量表第 15 项(IRQ 0)挂 SysTick
export var vectors linksection(".isr_vector") = blk: {
var v: [32]usize = undefined;
v[0] = @intFromPtr(&_stack_top);
v[1] = @intFromPtr(&reset_handler);
v[14] = @intFromPtr(&systick_handler); // 15 - 1
// ... 其余默认 exception_loop
break :blk v;
};
与 x86 的关键区别:Cortex-M 的所有异常/中断向量都是普通函数指针(不是 x86 那种"门描述符"),CPU 自动完成现场压栈与出栈,因此处理函数用普通 callconv(.c) 即可——不需要 x86 的 callconv(.interrupt)(见 https://plumephp.com/zig-baremetal-osdev/)。
4. 常见硬件平台
4.1 平台对比
| 平台 | CPU 核心 | Zig 目标 | 片上资源 | 特色 |
|---|---|---|---|---|
| STM32F4 系列 | Cortex-M4F | thumb-freestanding-eabihf | 512KB Flash / 128KB RAM | 生态最全、文档最规范 |
| Raspberry Pi Pico (RP2040) | 双核 Cortex-M0+ | thumb-freestanding-eabihf | 264KB SRAM | 无 Flash,上电即跑 RAM |
| nRF52840 | Cortex-M4F | thumb-freestanding-eabihf | 1MB Flash / 256KB RAM | 低功耗 BLE Mesh |
| ESP32-C3 | RISC-V (RV32IMC) | riscv32-freestanding-eabihf | 4MB Flash | 内置 Wi-Fi/BLE |
| GD32VF103 | RISC-V (RV32IMAC) | riscv32-freestanding-eabihf | 128KB Flash / 32KB RAM | STM32 引脚兼容 |
| ESP32 (经典) | Xtensa LX6 | 不受 Zig 上游支持 | 4MB Flash | 需第三方 Xtensa 分支 |
重要提示:Zig 官方不支持 Xtensa 架构,经典 ESP32/ESP32-S2 需要社区维护的 Zig fork;ESP32-C3 这类 RISC-V 型号则是开箱即用。
4.2 RP2040 的特殊性
RP2040 从 Flash 启动会把整个固件复制到 SRAM(XIP 缓存),链接脚本更简单,且双核启动需要第二个核心的入口地址约定:
// RP2040 第二核心入口:核 1 启动时跳到该地址
export fn second_core_entry() callconv(.c) void {
// 跑在核 1 上的代码
}
4.3 选择平台的标准
- 入门与踩坑:STM32F411 “黑板” 性价比高、文档多;
- 想体验 RISC-V:ESP32-C3 自带无线、下载器即插即用;
- 双核与极致简单:RP2040 有官方 C SDK 可对照,Zig 移植思路清晰;
- 量产低功耗:nRF52 系列的 BLE 协议栈成熟。
5. 烧录与调试
5.1 probe-rs(推荐)
probe-rs 是 Rust 生态的跨平台烧录/调试工具,直接与 ST-Link、CMSIS-DAP、J-Link 等探针交互:
# 构建(Debug,带符号)
zig build -Doptimize=Debug
# 烧录并暂停在入口
probe-rs run --chip STM32F411CEUx zig-out/bin/stm32f411.elf
# 只烧录不运行(Release)
zig build -Doptimize=ReleaseSmall
probe-rs download --chip STM32F411CEUx zig-out/bin/stm32f411.bin \
--base-address 0x08000000
probe-rs run 还能在 -Doptimize=Debug 下提供实时错误信息——Zig 的越界检查、未定义值检查会直接以 panic 形式暴露。
5.2 OpenOCD(通用方案)
传统且普适:
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg \
-c "program zig-out/bin/stm32f411.elf verify reset exit"
OpenOCD 的 verify 会比对芯片内与文件内容,防止烧录失败被忽略。
5.3 调试技巧
- ReleaseSmall 优化:嵌入式首选,体积优先、无安全检查,适合量产固件;
- Debug 优化:开发期必须,安全检查能抓住未定义行为;
-Dsemantic-check/ 链接器 map 文件:检查段布局是否符合链接脚本预期;- 串口日志是嵌入式最快的反馈环,比任何 JTAG 断点都省事。
6. 最佳实践与总结
6.1 嵌入式 Zig 十条铁律
- 所有寄存器访问必须
volatile,且用comptime参数封成内联函数。 - 用编译期断言锁定关键布局:向量表大小、寄存器偏移对齐、
@sizeOf。 .data搬运与.bss清零在 reset_handler 里最先做,否则全局变量全是垃圾值。- 链接脚本的
AT>语法是实现 Flash→RAM 搬运的标准姿势,别用运行期 malloc。 - 中断处理函数保持极短:置标志位、喂数据,重活留给主循环。
ReleaseSmall做固件,Debug做开发,并让两种模式都保留串口日志。- CPU 模型要精确:
cortex_m0用不了硬件乘法,cortex_m4用不了 FPU,选错会生成非法指令。 - 固件烧录必须 verify(
probe-rs download --verify或 OpenOCDverify)。 - 用
zig targets查询能力边界:Xtensa 不受支持,尽早评估替代方案。 - 优先把逻辑写成与硬件无关的纯函数,用 https://plumephp.com/zig-comptime-programming/ 的编译期表驱动生成外设配置,便于测试与复用。
6.2 总结
Zig 把嵌入式工具链的复杂度压缩到最低:zig build 一次产出 ELF/bin/hex,probe-rs 一键烧录,@ptrFromInt + volatile 直击寄存器,comptime 编译期固化配置。与 https://plumephp.com/zig-baremetal-osdev/ 的 x86 内核共享同一套 freestanding 技术栈,与 https://plumephp.com/zig-build-system/ 的多目标构建能力无缝衔接——从 MCU 裸机到操作系统内核,一套语言、一个工具链、全程无隐藏依赖。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。