3、安全固件编译与链接:安全分区与非安全分区的内存布局、链接脚本编写
好,咱们进入第三章。这一章讲的是编译和链接,说白了就是告诉编译器:你的安全代码该放哪儿,非安全代码该放哪儿。很多人觉得链接脚本就是个工具链的附属品,随便改改就行。我刚开始做 TrustZone-M 项目时也这么想,结果有一次安全分区和非安全分区的地址重叠了,调试了整整两天才找到原因……从那以后,我对链接脚本再也不敢马虎了。
3.1 为什么内存布局这么重要?
TrustZone-M 的核心思想,就是把物理内存分成两个世界:安全世界(Secure World)和非安全世界(Non-Secure World)。CPU 通过 SAU(Security Attribution Unit)或 IDAU 来判断当前访问属于哪个世界。
你想想看,如果安全固件的代码段不小心映射到了非安全区域,那攻击者就能直接读你的加密密钥。反过来,如果非安全代码的栈不小心落在了安全区域,一访问就触发异常。所以,内存布局是安全固件的基石。
核心原则:安全分区和非安全分区在物理地址上必须严格隔离,不能有任何重叠。安全代码只能访问安全内存,非安全代码只能访问非安全内存。交叉访问必须通过指定的安全调用(Secure Gateway)机制。
3.2 典型的内存分区模型
我习惯把 TrustZone-M 的固件内存分成以下几个区域。以 ARM Cortex-M33 为例,假设芯片有 2MB Flash 和 512KB SRAM:
| 区域 | 起始地址 | 大小 | 属性 | 用途 |
|---|---|---|---|---|
| 安全 Flash(代码) | 0x1000_0000 | 512 KB | Secure, 只读 | 安全固件、安全启动代码、安全服务 |
| 非安全 Flash(代码) | 0x1008_0000 | 1.5 MB | Non-Secure, 只读 | 用户应用程序、RTOS、驱动 |
| 安全 SRAM(数据) | 0x3000_0000 | 128 KB | Secure, 读写 | 安全堆栈、安全全局变量、密钥存储 |
| 非安全 SRAM(数据) | 0x3002_0000 | 384 KB | Non-Secure, 读写 | 用户堆栈、非安全全局变量、DMA 缓冲区 |
嗯,这里要注意:地址分配不是随便定的。安全区域必须放在低地址还是高地址?这取决于芯片的 SAU 配置。我个人习惯把安全区域放在地址空间的起始部分,这样 SAU 的配置更直观,不容易出错。
3.3 链接脚本的编写实战
链接脚本(Linker Script)是决定内存布局的最终文件。ARM GCC 工具链使用 .ld 文件。咱们来看一个实际项目中我常用的安全分区链接脚本片段:
/* secure.ld - 安全分区链接脚本 */
MEMORY
{
FLASH_SECURE (rx) : ORIGIN = 0x10000000, LENGTH = 512K
RAM_SECURE (rw) : ORIGIN = 0x30000000, LENGTH = 128K
}
SECTIONS
{
/* 安全中断向量表 - 必须放在安全 Flash 起始位置 */
.vectors :
{
. = ALIGN(4);
KEEP(*(.vectors))
__Vectors_End = .;
} > FLASH_SECURE
/* 安全代码段 */
.text :
{
. = ALIGN(4);
*(.text*)
*(.rodata*)
. = ALIGN(4);
} > FLASH_SECURE
/* 安全数据段 - 初始化的全局变量 */
.data :
{
. = ALIGN(4);
__data_start = .;
*(.data*)
. = ALIGN(4);
__data_end = .;
} > RAM_SECURE AT > FLASH_SECURE
/* 安全 BSS 段 - 未初始化的全局变量 */
.bss :
{
. = ALIGN(4);
__bss_start = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
__bss_end = .;
} > RAM_SECURE
/* 安全栈 - 放在 RAM 末尾,防止溢出 */
.stack (NOLOAD) :
{
. = ALIGN(8);
. = . + 0x4000; /* 16KB 安全栈 */
__StackTop = .;
} > RAM_SECURE
}
对应的非安全分区链接脚本类似,只是地址范围不同:
/* nonsecure.ld - 非安全分区链接脚本 */
MEMORY
{
FLASH_NONSECURE (rx) : ORIGIN = 0x10080000, LENGTH = 1536K
RAM_NONSECURE (rw) : ORIGIN = 0x30020000, LENGTH = 384K
}
SECTIONS
{
.vectors : { ... } > FLASH_NONSECURE
.text : { ... } > FLASH_NONSECURE
.data : { ... } > RAM_NONSECURE AT > FLASH_NONSECURE
.bss : { ... } > RAM_NONSECURE
.stack : { ... } > RAM_NONSECURE
}
避坑指南:我曾经犯过一个错误——安全分区和非安全分区使用了相同的栈地址。结果安全代码调用非安全函数时,栈指针直接指向了非安全区域,导致安全数据泄露。正确的做法是:安全栈和非安全栈必须物理隔离,不能共享同一块内存。
3.4 安全网关与函数调用约定
安全分区和非安全分区之间不能直接调用函数。必须通过安全网关(Secure Gateway)机制。在链接脚本中,我们需要为安全网关预留专门的入口表。
我一般会在安全 Flash 中预留一个 .sg_entries 段:
/* 安全网关入口表 - 必须放在固定地址 */
.sg_entries :
{
. = ALIGN(32);
KEEP(*(.sg_entries))
. = ALIGN(32);
} > FLASH_SECURE
每个安全网关入口对应一个 SG 指令。非安全代码通过 SG 指令跳转到安全世界。链接脚本要保证这些入口地址是固定的,不能因为代码变动而偏移。我习惯在链接脚本中硬编码这些地址:
PROVIDE(SG_ENTRY_1 = 0x10000100);
PROVIDE(SG_ENTRY_2 = 0x10000120);
PROVIDE(SG_ENTRY_3 = 0x10000140);
3.5 内存布局的验证方法
写完链接脚本,怎么验证对不对?我推荐三个方法:
- 查看 Map 文件:编译时加上
-Wl,-Map=output.map,然后检查每个段的起始地址和结束地址是否在预期范围内。 - 使用 objdump 反汇编:
arm-none-eabi-objdump -h firmware.elf可以列出所有段的地址信息。 - 运行时检查:在安全固件启动代码中,读取 SAU 寄存器,确认安全区域的配置与链接脚本一致。
警告:千万不要在生产固件中依赖运行时动态分配安全内存。所有安全内存的布局必须在编译时确定,并在链接脚本中静态分配。动态分配容易导致安全漏洞,我在审计过的固件中见过不止一次因为 malloc 返回了非安全地址而导致的崩溃。
3.6 知识体系总览
下面这张图总结了本章的核心逻辑。我把它画成了流程图,方便你理解编译链接阶段的内存布局决策过程:
这张图的核心逻辑是:先分析芯片的物理内存和 SAU 能力,然后决定分区粒度,再编写对应的链接脚本,最后通过多种手段验证。每一步都不能跳过。
3.7 常见错误与排查思路
最后,我总结几个我在实际项目中踩过的坑:
- 段重叠:安全 Flash 和非安全 Flash 的地址范围有交集。排查方法:用
objdump -h看每个段的 VMA 和 LMA,确认没有重叠。 - 栈溢出:安全栈太小,递归调用时栈指针进入非安全区域。我建议在链接脚本中给安全栈至少分配 8KB,复杂应用 16KB 起步。
- 中断向量表错位:安全中断向量表没有放在安全 Flash 的起始地址。Cortex-M 强制要求向量表在 0x10000000(安全)或 0x00000000(非安全),不能偏移。
- 未初始化全局变量:BSS 段没有清零。安全固件启动代码中必须手动清零
__bss_start到__bss_end,否则变量初始值随机。