Zig 嵌入式开发:交叉编译与 MCU 裸机实践

本文讲解 Zig 在嵌入式领域的交叉编译能力:ARM/RISC-V 目标配置、build.zig 多目标构建流程、MCU 裸机寄存器编程(STM32 GPIO/UART/SysTick),并对比主流硬件平台与烧录调试工具链。

传统嵌入式开发的第一道坎是搭工具链: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-eabihfSTM32、nRF52、RP2040(Cortex-M)ARM Thumb 指令集,硬件浮点
arm-freestanding-eabihfCortex-A 单板机ARM 32 位
aarch64-freestanding-eabihfRaspberry Pi 3/4ARM64
riscv32-freestanding-eabihfESP32-C3、GD32VF103RISC-V 32 位
riscv64-freestanding-eabihf高端 RISC-V SoCRISC-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-M4Fthumb-freestanding-eabihf512KB Flash / 128KB RAM生态最全、文档最规范
Raspberry Pi Pico (RP2040)双核 Cortex-M0+thumb-freestanding-eabihf264KB SRAM无 Flash,上电即跑 RAM
nRF52840Cortex-M4Fthumb-freestanding-eabihf1MB Flash / 256KB RAM低功耗 BLE Mesh
ESP32-C3RISC-V (RV32IMC)riscv32-freestanding-eabihf4MB Flash内置 Wi-Fi/BLE
GD32VF103RISC-V (RV32IMAC)riscv32-freestanding-eabihf128KB Flash / 32KB RAMSTM32 引脚兼容
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 十条铁律

  1. 所有寄存器访问必须 volatile,且用 comptime 参数封成内联函数。
  2. 用编译期断言锁定关键布局:向量表大小、寄存器偏移对齐、@sizeOf。
  3. .data 搬运与 .bss 清零在 reset_handler 里最先做,否则全局变量全是垃圾值。
  4. 链接脚本的 AT> 语法是实现 Flash→RAM 搬运的标准姿势,别用运行期 malloc。
  5. 中断处理函数保持极短:置标志位、喂数据,重活留给主循环。
  6. ReleaseSmall 做固件,Debug 做开发,并让两种模式都保留串口日志。
  7. CPU 模型要精确:cortex_m0 用不了硬件乘法,cortex_m4 用不了 FPU,选错会生成非法指令。
  8. 固件烧录必须 verify(probe-rs download --verify 或 OpenOCD verify)。
  9. 用 zig targets 查询能力边界:Xtensa 不受支持,尽早评估替代方案。
  10. 优先把逻辑写成与硬件无关的纯函数,用 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 裸机到操作系统内核,一套语言、一个工具链、全程无隐藏依赖。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「系统编程」更多文章

  1. Zig 裸机开发:从零编写最小内核
  2. Zig 高级 FFI:动态库、回调、内存布局与 C++ ABI
  3. Zig 并发与原子操作:线程、同步与消息传递