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 内存布局的验证方法

写完链接脚本,怎么验证对不对?我推荐三个方法:

  1. 查看 Map 文件:编译时加上 -Wl,-Map=output.map,然后检查每个段的起始地址和结束地址是否在预期范围内。
  2. 使用 objdump 反汇编:arm-none-eabi-objdump -h firmware.elf 可以列出所有段的地址信息。
  3. 运行时检查:在安全固件启动代码中,读取 SAU 寄存器,确认安全区域的配置与链接脚本一致。

警告:千万不要在生产固件中依赖运行时动态分配安全内存。所有安全内存的布局必须在编译时确定,并在链接脚本中静态分配。动态分配容易导致安全漏洞,我在审计过的固件中见过不止一次因为 malloc 返回了非安全地址而导致的崩溃。

3.6 知识体系总览

下面这张图总结了本章的核心逻辑。我把它画成了流程图,方便你理解编译链接阶段的内存布局决策过程:

安全固件编译与链接:内存布局决策流程 芯片内存资源分析 SAU 支持 多少个区域? 8区域:精细划分 4区域:安全/非安全各2块 编写安全/非安全 .ld 文件 编译生成 .elf 文件 验证:Map/objdump/SAU 安全区域必须连续且对齐 非安全区域不能包含安全数据

这张图的核心逻辑是:先分析芯片的物理内存和 SAU 能力,然后决定分区粒度,再编写对应的链接脚本,最后通过多种手段验证。每一步都不能跳过。

3.7 常见错误与排查思路

最后,我总结几个我在实际项目中踩过的坑:

  • 段重叠:安全 Flash 和非安全 Flash 的地址范围有交集。排查方法:用 objdump -h 看每个段的 VMA 和 LMA,确认没有重叠。
  • 栈溢出:安全栈太小,递归调用时栈指针进入非安全区域。我建议在链接脚本中给安全栈至少分配 8KB,复杂应用 16KB 起步。
  • 中断向量表错位:安全中断向量表没有放在安全 Flash 的起始地址。Cortex-M 强制要求向量表在 0x10000000(安全)或 0x00000000(非安全),不能偏移。
  • 未初始化全局变量:BSS 段没有清零。安全固件启动代码中必须手动清零 __bss_start 到 __bss_end,否则变量初始值随机。