29、SMP安全机制:内核加固、地址空间隔离、侧信道防护

多核SMP系统跑起来之后,很多人第一反应是「性能真香」。但我要泼一盆冷水——性能上去了,安全问题也跟着翻倍了。你想想看,原来单核系统里,一个任务搞破坏顶多把自己玩死。现在多核并行,一个核上的恶意任务,完全可能通过共享缓存、共享内存,把其他核上的敏感数据给偷走。

我早年做一款工业控制器时,就吃过这个亏。两个核共享一个内存池,一个核上的通信任务因为指针越界,直接把另一个核上的控制任务栈给踩了。机器当场死机,客户差点要退货。从那以后,我对SMP安全机制就格外上心。

这一章,咱们就聊聊RT-Thread在多核环境下的三道安全防线:内核加固、地址空间隔离、侧信道防护。

29.1 内核加固:别让一个核把整个系统带崩

内核加固,说白了就是让内核自己变得「皮实」。多核环境下,最怕的就是一个核在内核态里搞破坏,把全局数据结构弄乱,其他核跟着遭殃。

第一道防线:临界区保护

RT-Thread SMP内核里,所有全局数据结构的访问,都必须经过锁保护。我习惯把锁分为三类:

  • 自旋锁:用于保护极短临界区,比如链表操作、位图修改。持有时间不能超过几十个指令周期。
  • 互斥量:用于保护可能引起阻塞的临界区,比如内存分配、设备驱动。
  • 读写锁:用于读多写少的场景,比如系统信息查询。

这里有个坑——自旋锁持有期间绝对不能睡眠。我曾经见过一个新手,在自旋锁保护的区域里调用了rt_thread_delay(),结果其他核全部死等,系统直接锁死。嗯,这种bug极难排查,因为触发时机完全随机。

核心原则:自旋锁内只做「原子级」操作,任何可能引起调度的函数都不能碰。

第二道防线:内核对象校验

RT-Thread为每个内核对象都分配了类型ID和魔数。每次操作前,内核都会校验这个对象是不是「自己人」。比如:

// 内核对象校验示例
rt_err_t rt_sem_take(rt_sem_t sem, rt_int32_t time)
{
    /* 校验对象类型 */
    RT_ASSERT(rt_object_get_type(&sem->parent.parent) == RT_Object_Class_Semaphore);
    RT_ASSERT(rt_object_is_systemobject(&sem->parent.parent));
    
    /* 校验魔数 */
    if (sem->magic != RT_SEM_MAGIC)
        return -RT_ERROR;
    
    /* 正常操作... */
}

这种校验在单核系统里可能觉得多余,但在多核环境下,一个核上的野指针完全可能把另一个核的对象头给覆盖掉。有了校验,至少能第一时间触发断言,而不是让系统在诡异状态下继续运行。

注意:生产环境中,建议把RT_ASSERT替换为有实际恢复逻辑的错误处理,而不是直接死机。我一般会在断言失败时,尝试重启出问题的核,而不是整个系统。

29.2 地址空间隔离:每个核都有自己的「地盘」

地址空间隔离,说白了就是让每个核的敏感数据「眼不见为净」。多核系统里,所有核共享同一个物理内存,但我们可以通过MMU或MPU,给每个核划定不同的「势力范围」。

方案一:基于MMU的页表隔离

如果芯片支持MMU(比如Cortex-A系列),可以为每个核创建独立的页表。每个核只能看到自己的内核栈、私有数据区。共享区域(比如全局变量、设备寄存器)则映射为共享页。

我做过一个项目,把4个核的页表完全独立。每个核的内核栈放在不同的物理页上,页表项里把其他核的栈区域标记为「不可访问」。这样一来,就算一个核的内核栈被踩,也不会影响到其他核。

/* 每个核的页表配置示例 */
struct core_page_table {
    uint32_t *table_base;      /* 页表基址 */
    uint32_t kernel_stack_pa;  /* 内核栈物理地址 */
    uint32_t kernel_stack_va;  /* 内核栈虚拟地址 */
    uint32_t stack_size;       /* 栈大小 */
};

void core_mmu_init(int core_id)
{
    /* 为每个核创建独立的页表 */
    /* 私有区域:只映射当前核的内核栈 */
    /* 共享区域:映射全局变量、设备寄存器 */ 
}

方案二:基于MPU的区域保护

对于Cortex-R或Cortex-M系列,没有MMU但有MPU。我们可以用MPU设置内存区域的访问权限。比如:

  • 把每个核的私有数据区设为「只允许当前核访问」
  • 把共享内存区设为「所有核可读写」
  • 把代码区设为「只读可执行」

我的经验:MPU的区域数量有限(通常8-16个),要精打细算。我一般会预留2个区域给动态配置,比如临时映射DMA缓冲区。

方案三:栈保护区

这是最轻量级的隔离方案。在每个核的内核栈底部,放一个「哨兵页」或「哨兵值」。RT-Thread的栈溢出检测就是基于这个原理:

/* 栈保护区初始化 */
void stack_guard_init(struct rt_thread *thread)
{
    uint32_t *stack_bottom = (uint32_t *)thread->stack_addr;
    /* 在栈底写入固定模式 */
    for (int i = 0; i < STACK_GUARD_SIZE; i++) {
        stack_bottom[i] = STACK_GUARD_MAGIC;
    }
}

每个核在上下文切换时,都会检查自己的栈底魔数是否被破坏。一旦发现异常,立即触发安全处理流程。

29.3 侧信道防护:看不见的「窃听者」

侧信道攻击,说白了就是利用系统的「物理特性」来偷信息。多核环境下,共享资源成了天然的侧信道。我见过最典型的例子:一个核通过测量访问共享缓存的时间,推断出另一个核正在处理什么数据。

攻击路径一:缓存时间侧信道

两个核共享L2缓存。攻击核不断访问某个缓存行,通过测量访问延迟,判断受害核是否也在访问同一缓存行。如果延迟变高,说明缓存行被受害核占用了——这就泄露了信息。

防护手段:缓存分区

RT-Thread SMP可以配合硬件,为每个核划分专用的缓存区域。比如Cortex-A系列支持「缓存锁定」和「缓存分区」:

  • 缓存锁定:把关键数据(比如内核代码)锁定在缓存中,防止被其他核的访问挤出去。
  • 缓存染色:给每个缓存行打上「核ID」标签,只有对应核才能访问。

关键点:缓存分区会降低缓存利用率,一般只用于保护最敏感的数据。我通常只把「密码学密钥」和「安全配置」锁定在缓存中。

攻击路径二:共享总线侧信道

多个核共享一条内存总线。攻击核可以通过监测总线占用情况,推断其他核的内存访问模式。这种攻击更难防护,因为总线是物理共享的。

防护手段:总线带宽限制

RT-Thread SMP可以配合硬件QoS,为每个核设置总线带宽上限。比如:

/* 总线带宽限制配置示例 */
struct bus_qos_config {
    uint32_t core_id;
    uint32_t max_bandwidth;   /* 最大带宽,单位MB/s */
    uint32_t priority;        /* 总线优先级 */
};

void bus_qos_init(void)
{
    /* 为安全核设置高优先级和带宽保障 */
    bus_qos_set(0, 1000, 3);  /* 核0:1000MB/s,优先级3 */
    bus_qos_set(1, 500, 1);   /* 核1:500MB/s,优先级1 */
}

攻击路径三:共享内存侧信道

两个核通过共享内存通信时,攻击核可以通过监测共享变量的变化频率,推断受害核的处理节奏。这在实时控制系统中尤其危险——攻击者可能推断出控制周期的精确时序。

防护手段:无感填充

在共享内存中插入「假数据」或「随机延迟」,让攻击者无法准确判断真实数据的更新频率。我习惯的做法是:

  • 在真实数据更新之间,随机插入假更新
  • 使用固定时间间隔更新共享变量,而不是事件驱动
  • 对敏感数据做「时间混淆」,比如加入随机延迟

注意:侧信道防护是有代价的。缓存分区降低性能,随机延迟影响实时性。你需要根据实际威胁模型做权衡。我一般只在「安全关键」模块中启用完整防护,普通模块只做基础隔离。

29.4 实战建议:三步加固你的SMP系统

说了这么多理论,最后给点实操建议。如果你现在就要加固一个RT-Thread SMP系统,我建议按这个顺序来:

  1. 第一步:内核加固——检查所有临界区是否加了正确的锁,内核对象校验是否开启。这是基础,不做的话后面都是白搭。
  2. 第二步:地址空间隔离——至少做到栈保护。如果硬件支持MMU/MPU,把每个核的私有数据区隔离开。这一步能防住90%的「意外破坏」。
  3. 第三步:侧信道防护——评估你的系统是否面临侧信道威胁。如果是安全产品(比如金融终端、加密网关),必须做缓存分区和总线QoS。如果是普通工控设备,做好前两步就够了。

我记得有一次帮客户排查一个诡异问题:两个核跑同样的代码,但一个核总是莫名其妙地挂掉。最后发现是共享内存区没有做缓存一致性处理,一个核写的数据,另一个核读到的还是旧值。嗯,这种问题在SMP系统里太常见了。所以,加固之前,先确保你的缓存一致性协议是正常工作的。

好了,这一章的内容就到这里。安全机制不是一蹴而就的,需要根据实际硬件和应用场景不断调整。下一章,咱们聊聊SMP系统的调试和性能分析,那又是另一片天地了。