内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
本篇是《Zig 裸机开发实战:STM32F103ZET6 全栈解析》系列第 20 篇。上一篇:低功耗模式:Sleep、Stop、Standby 与唤醒,下一篇:Zig 语言进阶:comptime、错误处理与零成本抽象,目录见文末。
内存布局与链接脚本:512 KB Flash 与 64 KB RAM 规划
导读
外设篇写完了,最后一篇"工程篇"回到全局视角:你写的每一个变量、每一张字模表、每一个 DMA 缓冲区,最终都被链接脚本安排到 512 KB Flash 或 64 KB SRAM 的某个具体地址上。第 3 篇讲过 add_firmware 如何自动生成链接脚本,这一篇把生成的产物"解剖"开:.data/.bss/.heap 各自从哪到哪、启动代码怎么搬 .data 清 .bss、microzig_* 符号怎么在用户代码里读出来。三个悬案在本篇结案:其一,第 13 篇的 linksection(".dma_buffer") 为什么必须存在——答案藏在 dma_sram.ld 的 14 行全文里;其二,为什么 Flash 溢出的报错写的是 region 'BANK_1' 而不是 FLASH;其三,64 KB RAM 里栈到底有多大——答案可能让你意外:microzig 默认根本不给栈预留空间,它和堆共享 SRAM 顶端。本篇还会给出三个可操作的栈溢出检测方法,其中"涂色法"可以用上游逐字存在的 microzig.utilities.end_of_stack 直接落地。
为什么需要内存布局知识
链接脚本是生成的,不是手写的
传统 STM32 教程第一步就是"抄一份 .ld 模板改大小"。microzig 0.17 把这件事自动化了:芯片定义(Chips.zig)里写明内存区域,构建时由 tools/generate_linker_script.zig 现场生成完整脚本。F103ZE 的定义逐字如下(Chips.zig:6250-6254):
.memory_regions = &.{
.{ .name = "BANK_1", .tag = .flash, .offset = 0x8000000, .length = 0x80000, .access = .rx },
.{ .name = "SRAM", .tag = .ram, .offset = 0x20000000, .length = 0x10000, .access = .rwx },
},
生成的 MEMORY 段就是这两行的直译:
BANK_1 (rx!w) : ORIGIN = 0x08000000, LENGTH = 0x00080000
SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 0x00010000
两个必须记住的细节。第一,Flash 的区域名叫 BANK_1,不叫 FLASH——这是 F1 系列双 bank 命名习惯的残留,你往上溢出的报错里看到 will not fit in region 'BANK_1' 时不会一头雾水。第二,(rx!w) 是 GNU ld 的权限声明,!w 表示该区域"不可写"。这只影响链接器的段归属检查,不影响 CPU——第 17 篇的 IAP 就是用裸指针绕开链接器直接写 Flash 的。
生成器输出的不只是 MEMORY,还有全部基础 section 和一组 microzig_* 符号(generate_linker_script.zig:200-256):
.data { microzig_data_start = .; *(.sdata*) *(.data*) ... microzig_data_end = .; } > SRAM AT> BANK_1
.bss (NOLOAD) { microzig_bss_start = .; *(.sbss*) *(.bss*) ... microzig_bss_end = .; } > SRAM
.heap (NOLOAD) { microzig_heap_start = .; . = ORIGIN(SRAM) + LENGTH(SRAM); microzig_heap_end = .; } > SRAM
最后,STM32 port 把自己那份 ld/dma_sram.ld 追加在生成结果之后(全文 14 行,逐字):
/**
* Adding a buffer for DMA. This buffer need to be in
* specific region so that the BuxMatrix can connect DMA and specific RAM
* We assume that the generated linker have a ram named SRAM connected to DMA
* Check the list of ram region inside the src/Chip.zig file for your target.
*/
SECTIONS {
.dma_buffer (NOLOAD) :
{
KEEP(*(.dma_buffer))
} > SRAM
}
INSERT BEFORE .heap;
INSERT BEFORE .heap 是关键:.dma_buffer 被插在 .bss 之后、.heap 之前。这就是第 13 篇 dma_buffer[14*100] 用 linksection(".dma_buffer") 标注后能安全落地的机制——KEEP 保证即使没有代码显式引用它也不会被垃圾回收,NOLOAD 保证它不占用 Flash 空间(启动时不需要搬运,内容本来就是 undefined)。
一张全景图
把生成脚本 + 追加段 + 栈的位置拼起来,F103ZE 的完整内存布局是:
Flash (BANK_1, 512 KB @ 0x08000000)
+---------------------------+ 0x08000000
| 向量表(启动代码生成) |
+---------------------------+
| .text 代码 |
| .rodata 常量(const 数组) |
+---------------------------+
| .data 的 LMA(初始值镜像) | <- 启动时被搬运到 RAM
+---------------------------+
SRAM (64 KB @ 0x20000000)
+---------------------------+ 0x20000000
| .data 已初始化的全局变量 | <- 启动时从 Flash 的 LMA 拷入
+---------------------------+
| .bss 零初始化的全局变量 | <- 启动时被清零(NOLOAD,不占 Flash)
+---------------------------+
| .dma_buffer DMA 专属缓冲 | <- dma_sram.ld 插入,NOLOAD
+---------------------------+
| .heap 从这里铺到 SRAM 顶 | <- heap_start = bss/dma 结束处
| (空闲堆) | heap_end = 0x20010000
+---------------------------+ 0x20010000 <- 初始 SP(栈顶)
栈向下生长,与堆共享顶端区域
注意最下面那行:堆的顶端和栈的顶端是同一个地址。这不是 bug 而是设计——生成的 .heap 一路铺到 ORIGIN(SRAM)+LENGTH(SRAM),而栈的初始 SP(microzig.utilities.end_of_stack)也取第一个 RAM 区域的末尾。栈向下生长、堆向上生长,两者靠"实际用量都不大"共存。这既是灵活性(谁需要谁多拿),也是风险(互不知情地相撞),本篇的栈检测一节正面处理它。
代码案例:内存审计与栈涂色
案例一:把五个 microzig 符号读出来
链接器符号在 Zig 里用 extern var 声明即可引用(这正是上游 utilities.zig 自己引用这些符号的方式,逐字风格):
const std = @import("std");
const microzig = @import("microzig");
const stm32 = microzig.hal;
const rcc = stm32.rcc;
const gpio = stm32.gpio;
const time = stm32.time;
pub const panic = microzig.panic;
pub const std_options = microzig.std_options(.{ .logFn = stm32.uart.log });
comptime {
_ = microzig.export_startup();
}
// ---- 链接器符号(generate_linker_script.zig 生成,名字逐字) ----
extern var microzig_data_start: anyopaque;
extern var microzig_data_end: anyopaque;
extern var microzig_bss_start: anyopaque;
extern var microzig_bss_end: anyopaque;
extern var microzig_heap_start: anyopaque;
extern var microzig_heap_end: anyopaque;
fn span(start: *const anyopaque, end: *const anyopaque) usize {
return @intFromPtr(end) - @intFromPtr(start);
}
// 一个 1400 字节的 DMA 缓冲,复现第 13 篇的写法
var dma_buffer: [14 * 100]u8 linksection(".dma_buffer") = undefined;
// 一个 512 字节的 const 字模表(应落 .rodata,住在 Flash)
const font_demo: [512]u8 = blk: {
@setEvalBranchQuota(2000);
var t: [512]u8 = undefined;
for (&t, 0..) |*c, i| c.* = @intCast(i % 251);
break :blk t;
};
// 一个已初始化的全局(应落 .data,RAM 里住着、Flash 里有一份镜像)
var config_cache: [64]u8 = [_]u8{0x5A} ** 64;
pub fn main() !void {
_ = try rcc.apply(.{
.SYSCLKSource = .PLL1_P,
.PLLSourceVirtual = .HSE_Div_PREDIV,
.PLLMUL = .Mul9,
.APB1CLKDivider = .Div2,
.flags = .{ .HSEOscillator = true },
});
time.init_timer(.TIM4);
rcc.enable_clock(.GPIOA);
rcc.enable_clock(.USART1);
const uart = stm32.uart.UART.init(.USART1);
const tx = gpio.Pin.from_port(.A, 9);
tx.set_output_mode(.alternate_function_push_pull, .max_50MHz);
try uart.apply_runtime(.{ .clock_speed = rcc.get_clock(.USART1) });
stm32.uart.init_logger(&uart);
std.log.info("data: {d} bytes (0x{X}-0x{X})", .{
span(µzig_data_start, µzig_data_end),
@intFromPtr(µzig_data_start), @intFromPtr(µzig_data_end),
});
std.log.info("bss: {d} bytes (0x{X}-0x{X})", .{
span(µzig_bss_start, µzig_bss_end),
@intFromPtr(µzig_bss_start), @intFromPtr(µzig_bss_end),
});
std.log.info("heap: 0x{X} - 0x{X} ({d} KB free max)", .{
@intFromPtr(µzig_heap_start), @intFromPtr(µzig_heap_end),
span(µzig_heap_start, µzig_heap_end) / 1024,
});
std.log.info("stack: top at 0x{X}", .{@intFromPtr(microzig.utilities.end_of_stack)});
std.log.info("dma: 0x{X} ({d} bytes)", .{ @intFromPtr(&dma_buffer), dma_buffer.len });
std.log.info("font@flash: 0x{X} cfg@ram: 0x{X}", .{
@intFromPtr(&font_demo), @intFromPtr(&config_cache),
});
}
跑起来后 font_demo 的地址应在 0x0800xxxx(Flash),config_cache 在 0x2000xxxx(RAM)——const 与 var 的存储归属差异被地址直接暴露。
案例二:栈涂色法测最大深度
/// 涂色法:把栈区域全部填成已知图案,跑完工作负载后扫描"图案被覆盖到
/// 哪里",就是这次运行的历史最低 SP(最大栈深)。
const STACK_PROBE: usize = 8 * 1024; // 观测窗口,按最坏路径预估
const PAINT: u8 = 0xA5;
fn paint_stack() void {
const top = @intFromPtr(microzig.utilities.end_of_stack);
const probe: [*]u8 = @ptrFromInt(top - STACK_PROBE);
@memset(probe[0..STACK_PROBE], PAINT);
}
fn report_stack_usage() usize {
const top = @intFromPtr(microzig.utilities.end_of_stack);
const probe: [*]u8 = @ptrFromInt(top - STACK_PROBE);
var i: usize = 0;
// 从窗口底部向上找第一个被覆盖的字节
while (i < STACK_PROBE and probe[i] == PAINT) : (i += 1) {}
return STACK_PROBE - i; // 被覆盖的字节数 ≈ 历史最大用量
}
microzig.utilities.end_of_stack 的实现是逐字核对过的(core/src/utilities.zig:492):
pub const end_of_stack: *const anyopaque = blk: switch (microzig.config.stack) {
.address => |address| break :blk @ptrFromInt(address),
.ram_region_index => |index| { ... break :blk @ptrFromInt(region.offset + region.length); ...},
.ram_region_name => |name| { ... },
.symbol_name => |name| @extern(*const anyopaque, .{ .name = name }),
};
默认配置是 .{ .ram_region_index = 0 }(build-internals 的 stack: Stack = .{ .ram_region_index = 0 }),对 F103ZE 就是 0x20000000 + 0x10000 = 0x20010000——SRAM 顶。启动代码把这个值写进向量表第 0 项(initial_stack_pointer,cortex_m.zig:772),复位后 SP 从这里开始向下。
使用时序:main 开头 paint_stack(),然后跑完整工作负载(必须覆盖最深的中断嵌套路径——中断发生在任意时刻,ISR 的栈用量叠加在主循环之上),再 report_stack_usage() 打印。paint_stack 自己也用栈,它污染的正是合法用量的一部分,属于方法内建的误差,可接受。
案例三:build.zig 里覆盖栈位置与追加链接脚本
CreateFirmwareOptions 的 .stack 与 .linker_script 字段(逐字出处 build.zig:219-289):
const fw = mb.add_firmware(.{
.name = "memory-audit",
.target = stm32.chips.STM32F103ZE,
.root_source_file = b.path("src/main.zig"),
.optimize = optimize,
// 栈放在 SRAM 顶(默认行为,显式写出以便教学)。
// Stack 是 union(enum),四个变体逐字:
// address: usize 固定地址
// ram_region_index: usize 第 n 个 RAM 区域末尾
// ram_region_name: []const u8 按名取 RAM 区域末尾
// symbol_name: []const u8 取链接器符号的地址
.stack = .{ .ram_region_index = 0 },
// 追加自定义链接脚本(与 dma_sram.ld 的追加语义相同,不是替换)
.linker_script = .{
.file = b.path("ld/extra.ld"),
},
});
LinkerScript 结构体的字段同样逐字(build-internals build.zig:254-260):generate: GenerateOptions(默认生成 memory regions 与基础 section)、file: ?LazyPath(追加的用户脚本)、assert_microzig_main: bool = true。想要"保留自动生成 + 追加自己的段",只填 file;想彻底接管才动 generate。
逐段解析
.data 为什么要两份:LMA 与 VMA
.data 的脚本写法是 > SRAM AT> BANK_1:运行地址(VMA)在 SRAM,加载地址(LMA)在 Flash。全局变量 var config_cache: [64]u8 = @splat(0x5A) 的初始值必须是"上电就有的"——但 SRAM 断电即失,所以链接器把初始值镜像存进 Flash,启动代码复制到 RAM。搬运用的是上游 utilities.zig:7-53 的 initialize_system_memories(逐字引用):
pub inline fn initialize_system_memories(which: enum {
pub const auto: @This() = if (!microzig.config.ram_image) .all else .bss_only;
all,
bss_only,
}) void {
const sections = struct {
extern var microzig_data_start: anyopaque;
extern var microzig_data_end: anyopaque;
extern var microzig_bss_start: anyopaque;
extern var microzig_bss_end: anyopaque;
extern const microzig_data_load_start: anyopaque;
};
// fill .bss with zeroes
{ ... @memset(bss_start[0..bss_len], 0); }
// load .data from flash
if (which != .bss_only) { ... @memcpy(data_start[0..data_len], data_src[0..data_len]); }
}
这就是第 4 篇讲的".bss 清零、.data 从 Flash 搬到 RAM"的逐字实现。注意 .bss 是 NOLOAD:零初始化的东西不占 Flash——所以大数组尽量放 var x = undefined 或依赖零初始化,别放带初始值的 var,后者每一字节都要在 Flash 里烧一份镜像。
.heap 为什么铺满到顶
生成的脚本里 .heap 是一条"占位"段:microzig_heap_start = .; . = ORIGIN(SRAM)+LENGTH(SRAM); microzig_heap_end = .;。它没有任何输入段(没有 *(.heap*)),只是把"剩余 SRAM"记账为堆——用地址差表示容量,实际分配器(若你用 std.heap 的话)以 heap_start 为界。这条设计的推论:
heap_end - heap_start不是"可用堆"的承诺,是"最坏情况上限";- 顶端与栈共享:堆用到 0x2000F000 以上时,栈只剩下 4 KB 余量;
- 第 13 篇的
.dma_buffer插在.heap之前,所以每扩大 1 字节 DMA 缓冲,堆上限少 1 字节(F-22 的完整答案:.dma_buffer与.heap由INSERT BEFORE保证永不重叠,真正可能撞车的是堆与栈)。
栈没有"大小"这个配置项
容易误解的一点:.stack 字段只决定初始 SP 的位置,不预留任何空间——没有任何 section 会在脚本里为栈划地盘。默认布局下栈与堆共享顶端空间,"栈大小"实际是"撞到堆/撞到 bss 之前你能用多少"。想要硬隔离(比如给栈固定 4 KB),用追加脚本划一个占位段:
/* extra.ld:在 .heap 之前划出 4 KB 栈保护区 */
SECTIONS {
.stack_guard (NOLOAD) : { . = 0x1000; } > SRAM
}
INSERT BEFORE .heap;
栈顶仍是 SRAM 顶,向下生长撞到的将是 .stack_guard 区域再是 .bss——溢出会破坏 guard 段(涂色法正好可以观测它),而不是直接踩进全局变量。
怎么看 ELF 的段布局
microzig 不产出 map 文件,也没有 zig build --size 这样的子命令。三个实用手段(都诚实可用):
- llvm-size / llvm-nm(
brew install llvm后在 PATH 里加$(brew --prefix llvm)/bin):llvm-size -A zig-out/firmware/memory-audit.elf # 按 section 列出大小与地址 llvm-nm -S --size-sort zig-out/firmware/memory-audit.elf | tail -20 # 最大的 20 个符号 zig build --verbose-link:打印完整链接命令行,把其中ld.lld那一行复制出来,末尾手工加-Map=app.map重跑,得到完整 map 文件——一次性诊断够用。zig objcopy -O binary:zig-out/firmware/*.bin的大小近似 Flash 占用(含向量表与.data镜像)。
栈溢出的三种检测
- 涂色法(本篇案例二):零成本、事后测量,缺点是只反映"跑过的路径",且需要人工触发最坏情况(含中断嵌套)。适合开发期画安全边界。
- MPU 守护(硬件级):Cortex-M3 自带 MPU,把栈底以下一个 256 字节区域配成 no-access,溢出立即触发 MemManage fault,在 fault handler 里打印现场。实时性最好,但要写 MPU 配置(Cortex-M 内核层,microzig 未封装,需寄存器直写 MPU→RNR/RBAR/RASR),本篇不展开实现。
- 保守预算 + 编译期账本:给每个任务/调用深度做预算,用
@as与 comptime 断言约束大缓冲不进栈(例如禁止var buf: [1024]u8出现在函数体内,统一走linksection或堆)。最朴素,但团队协作时最容易失效——建议与涂色法组合使用。
运行现象
正确烧录后串口(115200)输出类似:
info: data: 131 bytes (0x20000000-0x20000083)
info: bss: 1653 bytes (0x20000084-0x200006F7)
info: heap: 0x200006F8 - 0x20010000 (61 KB free max)
info: stack: top at 0x20010000
info: dma: 0x20000090 (1400 bytes)
info: font@flash: 0x08001290 cfg@ram: 0x20000040
读法:.data 131 字节(含 config_cache 的 64 字节 + 运行时全局);.bss 1653 字节(含 dma_buffer 的 1400 字节——注意它物理上位于 .bss 与 .heap 之间的 .dma_buffer 段,llvm-size -A 能看到独立的 .dma_buffer 条目);堆上限约 61 KB;font_demo 的地址在 0x0800xxxx 段说明 const 大表确实住在 Flash。加上涂色法输出的 stack usage: NNN bytes,你就拥有了一份完整的"64 KB RAM 账本"。
常见问题
Q1:报错 section '.text' will not fit in region 'BANK_1': overflowed by N bytes,Flash 不是有 512 KB 吗?
区域名就是 BANK_1(见开头 memory_regions 逐字定义),报错文本没写错。真正的问题通常是三个之一:Debug 模式的栈追踪与格式化代码把 .text 撑大(换 -Doptimize=ReleaseSmall);std_options 里的 log 级别与格式化没裁剪;本该进 .rodata 的大常量数组被写成了带初始值的 var(const 才进 Flash 的 .rodata,var 的初始值会同时烧进 Flash 镜像并占 RAM)。Zig 的 LLVM 后端对裸机 Debug 模式的膨胀系数在 3-5 倍量级,这是第一嫌疑。
Q2:一个 32 KB 的大表想放 Flash 又想用指针访问,怎么办?
const big_table: [32768]u8 = .{...} 自然落 .rodata(BANK_1),直接用切片引用,零 RAM 开销。注意两点:一是初始化表达式如果太复杂会撞 @setEvalBranchQuota,comptime 生成大表记得提高配额(本篇案例里 font_demo 就是这么写的);二是别在函数里 return &big_table[i..] 之外再做拷贝——std.mem.copyForwards 到栈数组是常见的事故源,32 KB 会直接击穿栈。真要可写就明确用 linksection(".dma_buffer") 或堆,并接受 RAM 预算的代价。
Q3:.dma_buffer 会和 .heap 重叠吗?和栈呢?
.dma_buffer 与 .heap 由 INSERT BEFORE .heap 保证了顺序排布,永不重叠;KEEP 还保证它不被 --gc-sections 回收。与栈的关系要分两层说:地址上,.dma_buffer 在 .heap 之下,栈在 SRAM 顶向下生长,中间隔着整个堆的空间,正常用法撞不上;但概念上,三者共享同一个 64 KB 池子——DMA 缓冲每加 100 字节,堆上限就少 100 字节,栈的安全余量也随之缩水。做 RAM 预算时把它们放进同一张表里算,不要各自为政。
Q4:链接时报 microzig_main is not defined,明明写了 pub fn main?
完整报错文本是生成脚本里的逐字 ASSERT:ASSERT(DEFINED(microzig_main), "microzig: microzig_main is not defined. Add comptime { _ = microzig.export_startup(); } to your root source file. ...")。export_startup() 必须出现在 root source file(add_firmware 的 root_source_file 指向的那个文件)里——写在被 import 的模块里无效,因为链接器只看根模块导出的符号。这是 D-29,系列里每篇的模板代码开头三行 comptime { _ = microzig.export_startup(); } 就是在满足它。
Q5:F103 的 DMA 明明能访问主 SRAM,为什么还要 .dma_buffer 这个专属段?
严格说,F103 的 DMA1/DMA2 都能访问 0x20000000 的主 SRAM,不做任何标注也能工作——第 13 篇的 100 字节静态缓冲问题的根源是 HAL 实现自带的 dma_buffer[14*100] 容量上限,而不是地址不可达。dma_sram.ld 存在的官方理由写在它的注释里:这是为"BusMatrix 需要专用 RAM 区域"的芯片家族保留的通用机制(上游注释逐字假设生成的脚本里有名为 SRAM 的 DMA 可达区域)。F103 继承了这个机制,于是它顺理成章地成了"给大 DMA 缓冲一个不占堆、不被 GC 的家"的最佳工具。跨芯片移植时这个段的语义可能变化(有的家族真的要求特定 RAM),标注 .dma_buffer 的代码可移植性最好。
面试考点
考点 1:画出裸机固件在 Flash 与 SRAM 的完整布局,并解释 .data 为什么需要启动搬运。
参考答案:Flash:向量表(起始地址,第 0 项初始 SP、第 1 项复位向量)、.text、.rodata、.data 的 LMA 镜像。SRAM:.data(VMA)、.bss、(可选的专属段如 .dma_buffer)、堆、栈。.data 的运行地址在 SRAM 但初始值必须非易失,所以链接器用 AT> 指令在 Flash 里放一份镜像,启动代码(microzig 的 initialize_system_memories)按 microzig_data_load_start 把它 @memcpy 到 microzig_data_start;.bss 是 NOLOAD,只需清零不需镜像,故不占 Flash。
考点 2:.bss 与 .data 的区别是什么?为什么 .bss 用 NOLOAD 而堆也是 NOLOAD?
参考答案:.bss 存放零初始化或 undefined 的全局变量,语义上上电全零,硬件上 SRAM 上电后内容随机,所以启动代码要显式清零;因为它"没有非零初始值要保存",无需 Flash 镜像,NOLOAD 让加载器跳过搬运。堆 NOLOAD 的原因不同:它不是从输入段聚合而来,而是一条纯占位的地址记账(从 bss 末尾到 SRAM 顶),没有任何"加载"语义,NOLOAD 只是让这条占位不落进 Flash 文件。
考点 3:栈溢出有哪些检测手段?各自的时间开销与覆盖范围如何?
参考答案:三档。涂色法(软件水印):启动时把栈区填成已知图案,运行后扫描图案存留边界得到历史最大栈深,零运行时开销但只能事后离线测量,且必须人为覆盖最坏路径(含中断嵌套)。MPU 守护(硬件):把栈底区域配成不可访问,越界瞬间触发 MemManage fault,实时精确,但占用 MPU 区域资源且要写 fault handler。链接期/编译期静态预算:估算调用树深度并在代码评审或 comptime 断言中约束,零成本但最不可靠,依赖人工维护。工程实践通常是涂色法定边界 + MPU 兜底。
考点 4:linksection 属性的作用是什么?举出它在裸机开发中的三个典型用途。
参考答案:linksection(".name") 把全局变量放进指定的输出段。典型用途:一是 DMA/专用内存段,如 STM32 port 的 .dma_buffer(NOLOAD、KEEP、插在堆前),保证缓冲区不被垃圾回收且不占 Flash;二是把数据放进特定 RAM 区域(芯片有 CCM RAM、备份 SRAM 等分区时,配合自定义链接脚本的 MEMORY 段);三是给外设共享的描述符表(如 USB 的缓冲描述表、以太网描述符)指定对齐与固定区域。本质上它是 Zig 对 GNU ld __attribute__((section)) 的对应物。
考点 5:microzig 的栈配置(.stack 字段)能设置"栈大小"吗?如果不能,如何给栈做硬隔离?
参考答案:不能。.stack 是 union(enum),四个变体(address/ram_region_index/ram_region_name/symbol_name)都只决定初始 SP 的位置——它最终写进向量表第 0 项,不生成任何预留空间。默认取第一个 RAM 区域的末尾(microzig.utilities.end_of_stack 的实现是 comptime 的 blk: switch),对 F103ZE 即 0x20010000。硬隔离需要自己动手:用 add_firmware 的 .linker_script 字段追加一段 NOLOAD 占位段(如 .stack_guard 插在 .heap 之前),让栈向下生长时先撞占位段再碰全局数据;再配合涂色法或 MPU 检测是否真的撞到。
小结
本篇把前 19 篇用到的每一字节都放回了地图上:链接脚本由芯片定义生成,BANK_1 是 Flash、SRAM 是 64 KB 的家;.data 靠 Flash 镜像 + 启动搬运存在,.bss 靠清零存在,.dma_buffer 靠 14 行追加脚本存在;堆从 bss 之后一路铺到 SRAM 顶,与向下生长的栈共享顶端——这是灵活性,也是必须用涂色法或 MPU 看住的边界。五个 microzig_* 符号让这些布局从"文档知识"变成"运行时可读的账本"。至此系列第一阶段的 20 篇(硬件 19 篇 + 本篇工程收尾)全部完成;下一篇进入语言进阶——comptime 的威力与代价、错误处理的裸机形态、零成本抽象在 mmio.Mmio 里的真实案例。
系列目录
- 系列总览: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号