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系统,我建议按这个顺序来:
- 第一步:内核加固——检查所有临界区是否加了正确的锁,内核对象校验是否开启。这是基础,不做的话后面都是白搭。
- 第二步:地址空间隔离——至少做到栈保护。如果硬件支持MMU/MPU,把每个核的私有数据区隔离开。这一步能防住90%的「意外破坏」。
- 第三步:侧信道防护——评估你的系统是否面临侧信道威胁。如果是安全产品(比如金融终端、加密网关),必须做缓存分区和总线QoS。如果是普通工控设备,做好前两步就够了。
我记得有一次帮客户排查一个诡异问题:两个核跑同样的代码,但一个核总是莫名其妙地挂掉。最后发现是共享内存区没有做缓存一致性处理,一个核写的数据,另一个核读到的还是旧值。嗯,这种问题在SMP系统里太常见了。所以,加固之前,先确保你的缓存一致性协议是正常工作的。
好了,这一章的内容就到这里。安全机制不是一蹴而就的,需要根据实际硬件和应用场景不断调整。下一章,咱们聊聊SMP系统的调试和性能分析,那又是另一片天地了。