嵌入式高手都在偷偷用的“第30条”:链接脚本里加一行 ASSERT ,让地址越界在链接期就暴露
该文章同步至OneChan
改了个结构体,多加了几个中断向量,或者换了片 Flash 更大的芯片,结果系统出现各种随机死机、数据错乱,查了三天三夜才发现是某个段悄悄溢出了它的领地。
这是资深工程师压箱底的编程技巧系列第三十篇。前面我们学会了用 KEEP 锁死中断向量表,用 .noinit 保留复位不丢失的变量,用 section 把热函数搬到 RAM。今天这一招,作用在构建流程的最末端——链接阶段,它能在固件生成的最后一刻,对地址、长度、对齐做强制校验,任何不满足都直接报错,连 .hex 文件都不给你生成。
它就是链接脚本中一个被严重低估的指令:
ASSERT(condition, "error message");。
在嵌入式开发中,内存布局的正确性是系统稳定的前提。一个段悄悄溢出、一个地址没对齐、堆栈撞到一起——这些问题一旦发生,运行时表现就是“随机 HardFault”、“偶尔丢数据”、“有时死机”。ASSERT 让你在链接时就把这些“定时炸弹”统统拆掉,一个不留。
一、这东西到底是干什么用的?
简单说:ASSERT 是 GNU 链接器(ld)脚本中的指令,它在链接阶段对常量表达式求值。如果表达式为假,链接器立即报错并停止生成目标文件。
它的语法非常简单:
ASSERT(表达式, "错误信息");
在嵌入式链接脚本中,你可以使用:
- 地址符号:
.isr_vector的起始地址、__bss_start__、__stack_top__等。 - 算术运算:
+、-、*、/、%。 - 逻辑运算:
&&、||、!、==、!=、<、>、<=、>=。 - 内置函数:
SIZEOF(段名)返回段的大小,LENGTH(内存区域)返回内存区域的总长度,ORIGIN(内存区域)返回起始地址,ALIGN(值)计算对齐。
ASSERT 最大的价值在于:它将内存布局约束从“人工检查”变成了“机器强制”。 你可以写下:
- “
.bss段的末尾不能超过 RAM 的末尾减最小栈大小” - “向量表的起始地址必须是 512 字节对齐”
- “
.ramfunc段的大小不能超过 4KB” - “堆的起始地址和栈顶之间至少要留 1KB 空隙”
这些约束一旦被写进链接脚本,任何违反都会在链接时以清晰的错误信息曝光,绝不可能流到运行时。
二、上硬菜,直接看怎么用
Step 1:检查段大小——防止 .ramfunc 超出预期
假设你的芯片 RAM 有限,你把关键函数放入 .ramfunc 段并加载到 RAM 执行。但函数体可能随着版本迭代变大,直到某天悄悄超过了链接脚本中分配的 RAM 区域。可以在脚本末尾加上:
.ramfunc : {
KEEP(*(.ramfunc))
. = ALIGN(4);
} >RAM AT>FLASH
ASSERT(SIZEOF(.ramfunc) <= 4K, "ERROR: .ramfunc section exceeds 4KB! Trim functions or expand RAM allocation.");
一旦 .ramfunc 超过 4096 字节,链接器立刻报错:
ld: error: .ramfunc section exceeds 4KB! Trim functions or expand RAM allocation.
你连调试的机会都不需要,直接在编译日志里看到。
Step 2:检查区域不重叠——堆栈别打架
堆和栈通常是放在 RAM 末尾,栈向下增长,堆向上增长。如果它们撞到一起,就是经典的“堆栈溢出”。链接时可以用符号检查它们的间距:
/* 假设 RAM 区域定义在 MEMORY 中 */
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rw) : ORIGIN = 0x20000000, LENGTH = 128K
}
/* 栈顶设在 RAM 末尾 */
__stack_top__ = ORIGIN(RAM) + LENGTH(RAM);
/* 堆起始在 BSS 和 Noinit 之后 */
__heap_start__ = ALIGN(__noinit_end__, 8);
__heap_end__ = __stack_top__ - 1024; /* 预留 1KB 栈 */
ASSERT(__heap_start__ < __heap_end__,
"ERROR: Heap and stack overlap! Increase RAM or reduce heap/stack usage.");
Step 3:检查绝对地址——启动代码不能跑偏
系统启动代码(如 .isr_vector)通常必须放在 Flash 的最开头。如果有人不小心调整了链接脚本,导致向量表不在地址 0x08000000,后果是灾难性的。锁定它:
ASSERT(ADDR(.isr_vector) == 0x08000000,
"ERROR: Vector table must be at start of Flash (0x08000000)!");
Step 4:检查对齐——DMA 缓冲区必须对齐
如果你的 .dma_buffers 段用于存放 DMA 描述符,需要 256 字节对齐(某些 DMA 控制器的要求):
.dma_buf : {
. = ALIGN(256);
KEEP(*(.dma_buf))
. = ALIGN(256);
} >RAM
ASSERT((ADDR(.dma_buf) % 256) == 0,
"ERROR: DMA buffer section is not 256-byte aligned!");
三、举一反三,ASSERT 的这些组合让你高枕无忧
1. 检查 Flash 使用率,防止超出芯片容量
整个 .text、.rodata、.data 的加载段总和不能超过 Flash 大小:
ASSERT( (SIZEOF(.isr_vector) + SIZEOF(.text) + SIZEOF(.rodata) +
SIZEOF(.data) + SIZEOF(.ramfunc_load)) < LENGTH(FLASH),
"ERROR: Firmware does not fit in Flash! Reduce code/data size.");
2. 检查 .data 段和 .bss 段总和不超过 RAM
ASSERT( (SIZEOF(.data) + SIZEOF(.bss) + SIZEOF(.noinit) + SIZEOF(.ramfunc)
+ 2048) < LENGTH(RAM), /* 2048 为预留堆栈 */
"ERROR: RAM overflow! Check data/bss/noinit sizes.");
3. 用 ASSERT 强化多固件协同
如果 Bootloader 和 App 之间通过固定地址通信,可以在各自的链接脚本中相互验证地址约定:
/* App 的链接脚本 */
ASSERT(ABSOLUTE(APP_START) == 0x08020000,
"ERROR: App start address must be 0x08020000 to match Bootloader!");
4. 配合 DEFINED 检查必要符号是否存在
你可以结合 DEFINED 函数检查某个符号是否在链接时已被定义:
ASSERT(DEFINED(__stack_top__),
"ERROR: Stack top symbol __stack_top__ not defined!");
ASSERT(DEFINED(__noinit_end__),
"ERROR: __noinit_end__ not defined! Check .noinit section.");
四、留两个问题给你思考
现在请你停下来,思考这两个链接器层面的问题:
ASSERT中的表达式是在什么时候求值的?如果我在表达式中使用了 C 语言变量的地址(比如&g_crash_info),能通过吗?为什么?- 如果我在链接脚本中用
ASSERT检查了一个条件,而这个条件依赖于另一个尚未分配地址的段的大小,会不会因为链接器内部顺序导致误报或漏报?如何避免?
想清楚这两个问题,你就能在链接脚本中写出牢不可破的断言,而不是被工具链的求值顺序绊倒。
五、总结与思考题回答
核心总结:
ASSERT(expr, msg)是链接器脚本中的强制检查指令,在链接时验证地址、大小、对齐等约束。- 主要用途:检查段不溢出、堆栈不重叠、关键地址正确、DMA 缓冲区对齐、Flash/RAM 总量不超限。
- 核心优势:将内存布局错误从“运行时随机崩溃”转化为“链接时明确报错”,零遗漏、零误判。
- 最佳实践:对所有关键段做尺寸断言、对固定地址段做地址断言、对内存区域做总量断言。
思考题回答
问题1:ASSERT 能使用 C 变量的地址吗?
不能。 ASSERT 中的表达式是链接时表达式,它只能操作链接器脚本中定义的符号(如 .、段名、__xxx__ 符号、ORIGIN、LENGTH 等)。C 语言中的变量地址(如 &g_crash_info)在链接时确实存在,但你无法在链接脚本中直接用 C 语法引用。你只能通过链接脚本定义的符号来间接引用。例如,如果你把 g_crash_info 放入了 .noinit 段,你可以用 ADDR(.noinit) 或 __noinit_start__ 来获取它的大致位置,但不能精确到某个 C 变量。更精确的检查通常用 static_assert 在 C 代码中完成,而链接脚本主要检查段级别、区域级别的约束。
问题2:链接器的求值顺序会不会导致断言误判?
会的,这是一定要小心的地方。 链接器是顺序处理链接脚本的。如果 ASSERT 放在某个段定义之前,而断言中引用了该段的 SIZEOF 或 ADDR,可能因为段尚未被分配而得到不准确的值。最佳实践是:
- 将所有
ASSERT放在链接脚本的最末尾,确保所有段都已被分配后再做检查。 - 如果必须放在中间,确保它所依赖的段和符号已经在前文中定义完成。
- 使用
DEFINED函数先检查符号是否存在,再断言其属性。
例如,下面是一个安全的位置:
SECTIONS {
.text : { ... }
.data : { ... }
.bss : { ... }
.noinit : { ... }
/* 其他所有段 */
/* 所有 ASSERT 放在最后 */
ASSERT(SIZEOF(.data) + SIZEOF(.bss) + SIZEOF(.noinit) < LENGTH(RAM), "...");
ASSERT(ADDR(.isr_vector) == 0x08000000, "...");
}
好了,第 30 招我们就彻底吃透了。从今天起,在你项目的链接脚本末尾加上几行 ASSERT,让地址越界、堆栈相撞、对齐错误这些隐形杀手,在固件生成前就被绳之以法。
如果今天的内容让你对构建流程的最后一道关有了新的认识,欢迎转发和点赞。下一篇我们继续挖:通过 PROVIDE 在链接脚本中为栈顶、堆边界提供默认弱符号。 咱们不见不散!
更多推荐
所有评论(0)