1. 安全概述:为什么要在 MCU 上谈安全
过去做单片机开发,大家很少主动去想“安全”这回事。裸机时代,整个程序就是一个大循环加中断,所有内存都是平铺的,代码能跑、功能正常就算完工。但自从上了 RTOS,情况开始不一样了——系统里同时跑着多个任务,任务之间共享堆栈、队列、信号量,一旦某个任务越界或者崩溃,可能把整个系统拖下水。
FreeRTOS 本身是一个轻量级内核,它的设计目标是在资源受限的 MCU 上提供实时调度能力。也正因为“轻量”,它的安全模型和 Linux 这种大型操作系统完全不是一个路子。Linux 有 MMU,有用户态和内核态的隔离,进程之间天然是隔开的;而 FreeRTOS 跑在 Cortex-M 这类 MCU 上,通常没有 MMU,所有任务都运行在特权模式,共享同一片内存空间。
这就带来一个非常现实的问题:一旦某个任务因为野指针、数组越界或者堆栈溢出而写坏了内存,可能直接破坏其他任务的数据,甚至把内核的调度器数据搞崩。我在实际项目中遇到过好几次,现象极其诡异——系统运行几个小时才死一次,查了半天最后定位到是某个任务的局部数组越界,把相邻任务的队列句柄覆盖了。
所以 FreeRTOS 语境下的“安全”有两个层面:
- 可靠性安全:通过内核提供的机制(堆栈检测、内存保护、任务看门狗等)来防止任务之间的相互干扰,或者说让问题尽早暴露。
- 信息安全:在通信链路上做加密和认证,防止数据被窃听或篡改。这在物联网设备中越来越重要。
这篇文章会从这两个层面展开,重点讲清楚 FreeRTOS 提供了哪些安全能力、怎么配置,以及在真实项目里怎么用才能既保证安全又不牺牲太多实时性。
2. 任务隔离与内存保护:FreeRTOS 的 TrustZone 与 MPU 支持
2.1 没有 MMU 的 MCU 怎么“隔离”
Cortex-M 处理器虽然普遍没有 MMU,但很多型号带有 MPU(Memory Protection Unit,内存保护单元)。MPU 的作用是把物理内存划分成若干个区域,给每个区域设置访问权限——比如某块内存只允许特定任务读写,其他任务一碰就触发异常。
FreeRTOS 从 9.0 版本开始正式支持 MPU 版本的移植,叫 FreeRTOS-MPU。它的核心思路是:把内核和任务分为特权模式和用户模式两种运行等级。内核运行在特权模式,可以访问所有内存;普通任务运行在用户模式,只能访问自己被明确允许的内存区域。
听起来不错,但用起来有不少限制。我最初把 FreeRTOS-MPU 移植到某款 Cortex-M4 芯片上时,发现几个很实际的问题:
- MPU 区域数量有限,Cortex-M3/M4 通常只有 8 个区域。任务一多,区域就不够分配。
- 用户模式下的任务不能直接访问外设寄存器,所有外设操作都得通过内核提供的系统调用接口,这需要为每个外设封装一层 API,工作量不小。
- 上下文切换时要切换 MPU 配置,这本身会带来额外的时间开销。
所以我的建议是:如果你的产品对安全等级有硬性要求(比如要通过某种安全认证),可以考虑基于 MPU 的方案;如果只是普通的消费级或工业级产品,用纯 FreeRTOS 加堆栈检测基本就够用了,性价比更高。
2.2 ARM TrustZone 与 Cortex-M23/M33
近几年新出的 Cortex-M23、Cortex-M33 内核开始支持 TrustZone 技术。TrustZone 把系统划分为安全世界(Secure World)和非安全世界(Non-Secure World),硬件层面强制隔离。
FreeRTOS 官方把这种支持称为 FreeRTOS TrustZone 支持,配合 ARM 的 TrustZone 技术,可以在安全世界跑安全相关代码(比如密钥管理、安全启动),在非安全世界跑普通应用代码(比如网络协议栈、业务逻辑)。即使非安全世界的代码被攻击者完全控制,也拿不到安全世界里的密钥。
这个方案的优点是隔离强度远高于 MPU 方案,而且不影响非安全世界的实时性。代价是你需要一颗支持 TrustZone 的 MCU(比如 STM32L5、STM32U5),并且要花精力设计安全世界和非安全世界之间的通信接口。
不过说实话,对于大多数中小团队来说,TrustZone 的开发门槛偏高,工具链配置复杂,调试也不方便。我建议先把基础的安全机制用扎实,再考虑 TrustZone。
3. 堆栈溢出检测:最容易被忽视的致命问题
3.1 堆栈溢出为什么这么隐蔽
堆栈溢出是 FreeRTOS 项目中最常见、也最难排查的问题之一。它的隐蔽性在于:并不是程序一跑就崩,而是可能运行几个小时甚至几天后才出现异常,而且异常的现象五花八门——有时是某个变量莫名其妙被改掉,有时是任务突然卡死,有时是系统直接进 HardFault。
原因其实很好理解:任务的堆栈在内存中是连续的一段区域。当一个任务的函数嵌套调用过深、局部变量过大或者递归层数太多时,堆栈指针就会越过堆栈边界,写到相邻的内存区域。如果相邻区域是另一个任务的堆栈,就会把别人的局部变量覆盖掉;如果是内核的数据结构,那问题就更大了。
我在一个 LTE 模组的项目中就遇到过这种问题。一个网络解析任务原本挺正常,后来加了一个 JSON 解析库,栈上临时缓冲区开得比较大,直接把这个任务自己的堆栈干爆了,连带把相邻任务的队列句柄写坏,导致系统随机死机。当时排查了整整一周。
3.2 FreeRTOS 的三种堆栈检测机制
FreeRTOS 提供了两种堆栈溢出检测方法,在FreeRTOSConfig.h中通过configCHECK_FOR_STACK_OVERFLOW宏来配置:
第一种是方法 1,把configCHECK_FOR_STACK_OVERFLOW设为 1。它的原理是在任务切换时检查任务的栈指针是否越界。具体做法是:任务被切换出去时,内核会比较当前任务的栈指针和堆栈的边界。但这个方法有个盲区——如果任务在运行过程中栈指针越界后又退回来了(比如临时用了大量栈空间,函数返回后又恢复了),切换时的检查就发现不了。
第二种是方法 2,把configCHECK_FOR_STACK_OVERFLOW设为 2。这个方法更彻底一些:在创建任务时,内核会把任务堆栈的全部内存填充为一个特殊值(0xA5)。在任务切换时,内核检查堆栈末尾的一段区域内这个特殊值是否被覆盖。如果被覆盖了,说明任务确实用到了非常深的位置,基本可以认定快要溢出了。
第三种是运行时检查,任务通过uxTaskGetStackHighWaterMark()查询自己的剩余堆栈空间。这个 API 可以告诉我们任务历史上最低剩余的堆栈字节数,用于在开发阶段判断该给每个任务分配多大的堆栈。
// 在任务中周期性检查堆栈余量 void vMonitorTask(void *pvParameters) { UBaseType_t uxHighWaterMark; for (;;) { uxHighWaterMark = uxTaskGetStackHighWaterMark(NULL); // 如果剩余空间低于警戒值,记录日志或采取处理措施 if (uxHighWaterMark < 100) { // 堆栈空间不足,记录错误信息 } vTaskDelay(pdMS_TO_TICKS(5000)); } }实际项目里我通常的做法是:
- 开发阶段开启方法 2 的堆栈检测。
- 同时周期性调用
uxTaskGetStackHighWaterMark()并打印每个任务的堆栈余量。 - 通过一段时间的运行数据,统计出每个任务的最大堆栈深度,然后留出 30% 到 50% 的余量来设置正式的堆栈大小。
- 发布版本时保留堆栈检测,但把检测频率降低,避免影响性能。
注意:方法 2 的检测依赖已定义
configCHECK_FOR_STACK_OVERFLOW宏且需要移植层提供vApplicationStackOverflowHook()钩子函数。如果不实现这个钩子函数,检测到了溢出也无法处理,系统会直接进入断言失败状态。
3.3 堆栈大小估算的实践经验
堆栈大小估算是 FreeRTOS 开发中的一门“玄学”,但有一些可以遵循的规律。操作系统任务堆栈上存放的内容主要有四类:
- 任务函数的局部变量(包括数组、结构体)
- 函数调用时的返回地址(每层调用约 4 字节,Cortex-M 架构)
- 函数调用时保存的寄存器(异常发生时压栈,约 32 字节)
- 中断嵌套时的上下文(如果中断中调用了 FreeRTOS API,要额外预留空间)
我给一个参考做法:先用一个比较“宽裕”的堆栈大小(比如 512 字节或 1024 字节),跑完典型业务场景后,用uxTaskGetStackHighWaterMark()读出实际使用量,再反推合适的大小。以 1024 字节的初始堆栈跑出 700 字节的使用深度为例,那么正式设置时可以取 700 乘以 1.5,约 1050 字节,取整为 1024 字节的整数倍来设置。
这样“先宽后紧”的做法,能避免一开始就遇到堆栈溢出的问题,又能最终做到内存的合理利用。
4. 通信安全:在资源受限环境下做加密与认证
4.1 物联网设备的信息安全挑战
FreeRTOS 常用于物联网终端设备,这类设备通常通过 WiFi、LoRa、NB-IoT 等方式连接到服务器。如果你的设备只是发个温湿度数据,而且数据本身不敏感,可能觉得加密无所谓。但实际上,信息安全问题远不止“数据被偷看”这么简单。
攻击者可以做的事情有很多:窃听通信拿到设备上报的数据;篡改下行命令,比如把“关闭阀门”改成“打开阀门”;伪造设备身份接入服务器,冒充你的设备;重放之前截获的合法指令,让设备执行重复操作。这些攻击里,数据篡改和设备伪造往往比数据窃听更危险。
4.2 FreeRTOS 与 mbedTLS 的集成
FreeRTOS 官方推荐的安全通信方案是 mbedTLS(现改名为 Mbed TLS,不过大家还是习惯叫 mbedTLS)。mbedTLS 是一个轻量级的 TLS/SSL 协议栈,专门为嵌入式设备设计,支持 AES、RSA、ECC 等常用加密算法,而且有针对小内存设备的裁剪选项。
在 FreeRTOS 里集成 mbedTLS 有几个关键点:
首先,mbedTLS 需要配置随机数发生器。TLS 握手过程中需要生成随机数、密钥等,随机数的质量直接决定加密强度。很多 MCU 内部有硬件随机数发生器(比如 STM32 的 RNG 外设),建议优先用硬件 RNG。
// mbedTLS 随机数回调函数示例(基于硬件 RNG) static int mbedtls_hardware_poll(void *data, unsigned char *output, size_t len, size_t *olen) { for (size_t i = 0; i < len; i++) { output[i] = (unsigned char)hw_rng_get_byte(); } *olen = len; return 0; }其次,mbedTLS 在内存不大的 MCU 上跑 TLS 握手会比较吃力。握手过程要交换证书、协商密钥、验证身份,内存开销通常在 10KB 到 40KB 不等。我在 STM32F407(192KB RAM)上同时跑 FreeRTOS、lwIP、mbedTLS 时,RAM 压力确实不小,需要精细地调整 mbedTLS 的配置宏来裁剪功能。
实用的裁剪手段包括:
- 去掉不需要的密钥交换算法,只保留
MBEDTLS_KEY_EXCHANGE_ECDHE_RSA_ENABLED或者MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED。 - 使用 ECC 椭圆曲线算法而不是 RSA。ECC 在同等安全强度下密钥更短,计算量更小。
- 关闭不需要的 TLS 版本(比如只保留 TLS 1.2)。
- 缩小握手缓冲区的最大值,但要注意不能小于实际需要。
4.3 轻量级替代:预共享密钥模式
如果设备的计算能力和内存实在有限,跑完整 TLS 握手很吃力,还有一个折中方案——使用 TLS-PSK(预共享密钥)模式。
PSK 模式不需要证书,不需要公钥算法,只需要在设备和服务器之间预置一个共享密钥。握手时双方用这个密钥来协商会话密钥。PSK 的计算开销比证书模式小一个数量级,在 Cortex-M0 这种低端 MCU 上也能跑。
不过 PSK 模式的安全性依赖密钥的保密性。如果攻击者能从设备固件中提取出预共享密钥,就能伪装成设备接入服务器。所以需要配合安全存储(比如 MCU 内置的 OTP 区或者外部安全芯片)来保存密钥。
顺便提醒一句:不要在程序源码里明文写密钥。我见过有项目把 AES 密钥和服务器地址直接写死在源码里,发布到了 GitHub,这等于把大门钥匙挂在了门口。密钥至少要做混淆存储,最好用独立的加密芯片。
5. FreeRTOS 安全配置实操:上手路线与常见坑
5.1 FreeRTOSConfig.h 中与安全相关的关键宏
在开始安全配置之前,先熟悉几个与安全直接相关的 FreeRTOS 配置宏。这些宏都在FreeRTOSConfig.h中定义,直接影响系统的安全表现。
| 配置宏 | 作用 | 建议值 |
|---|---|---|
configCHECK_FOR_STACK_OVERFLOW | 使能堆栈溢出检测 | 2(开发阶段),发布阶段保留 |
configUSE_TRACE_FACILITY | 使能运行时统计信息 | 1 |
configUSE_STATS_FORMATTING_FUNCTIONS | 使能任务统计格式化函数 | 1(调试阶段) |
configUSE_MPU_WRAPPERS | 使能 MPU 封装层 | 根据硬件决定 |
configENABLE_TRUSTZONE | 使能 TrustZone 支持 | 根据硬件决定 |
configUSE_DAEMON_TASK_STARTUP_HOOK | 守护任务启动钩子 | 按需 |
// FreeRTOSConfig.h 安全相关配置示例 #define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_TRACE_FACILITY 1 #define configUSE_STATS_FORMATTING_FUNCTIONS 1 #define configUSE_MPU_WRAPPERS 0 #define configENABLE_TRUSTZONE 0提示:
configUSE_MPU_WRAPPERS和configENABLE_TRUSTZONE这两个宏一旦开启,FreeRTOS 的 API 调用方式会有所变化,用户任务必须通过syscall方式调用某些 API。如果不需要硬隔离,保持关闭即可,不要盲目开启。
5.2 创建任务时的安全注意事项
任务创建时的几个参数里,最容易出问题的是堆栈大小。很多入门者在xTaskCreate里随手填一个数字,比如 128,以为是 128 字节,结果代码里用了uint8_t buffer[256],直接栈溢出。
这里要特别说清楚:FreeRTOS 的堆栈大小单位是“字”(Word),不是字节。Cortex-M 是 32 位架构,一个字是 4 字节。所以xTaskCreate(..., 128, ...)实际分配的是 128 × 4 = 512 字节。不同的移植版本和不同的编译器,这个单位可能会变,最好在移植说明里确认一下。
另外,任务句柄和任务参数如果定义为局部变量,要注意作用域。任务句柄如果在创建函数里定义为局部变量,创建后函数返回,这个变量就失效了,之后再用这个句柄操作任务就会出问题。正确的做法是把句柄定义为全局变量或静态变量。
// 正确的任务创建方式 static TaskHandle_t xSensorTaskHandle; void vCreateTasks(void) { // 堆栈大小单位:字(4字节) // 任务参数:传结构体指针,注意指针生命周期 xTaskCreate(vSensorTask, "Sensor", 256, &xSensorData, 2, &xSensorTaskHandle); }5.3 安全启动与固件完整性校验
除了运行时的安全机制,产品级的 FreeRTOS 设备还需要考虑启动过程的安全。攻击者可以替换设备的固件,植入恶意代码,然后让设备正常运行——这种攻击很难被发现。
安全的启动流程通常分两步:
- 引导程序校验:设备上电后,第一步执行的引导程序先对应用程序固件计算哈希值,与预先存储的哈希值做比较。不一致就拒绝启动。
- 签名验证:更严格的做法是用非对称算法验证固件签名。只有持有私钥的开发者才能生成合法固件,设备端用公钥验证。
在 STM32 系列上,可以利用硬件 CRC 或者内置的 AES 加速器来加速固件校验过程。如果 MCU 支持 TrustZone(比如 STM32L5),还可以把公钥和校验逻辑放在安全世界里,进一步提升安全性。
5.4 常见安全问题速查表
下面这个表格是我在实际项目调试中积累的常见问题,遇到类似现象可以直接对照:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 系统运行不定时死机 | 任务堆栈溢出写坏内核数据 | 开启堆栈检测,用uxTaskGetStackHighWaterMark()查看余量 |
| HardFault 但无法定位 | 野指针操作或数组越界,概率性触发 | 检查所有指针操作,尤其注意结构体指针强转 |
| 使用队列/信号量时任务卡死 | 队列创建失败或句柄无效 | 检查xQueueCreate返回值,确认configSUPPORT_DYNAMIC_ALLOCATION已开启 |
| 加密通信握手失败 | 随机数质量差或证书格式错误 | 确认硬件 RNG 已初始化,检查证书是否是 DER 格式 |
| 设备上报的数据被篡改 | 通信链路未加密或未校验 | 启用 TLS,或至少在应用层加消息认证码(MAC) |
| 任务优先级反转 | 低优先级任务持有信号量 | 使用互斥量(Mutex)而不是二值信号量 |
5.5 调试安全相关问题的实操建议
调试安全相关问题时,有几个工具和手段非常实用:
一是 FreeRTOS 的vApplicationStackOverflowHook()钩子函数。我通常在这个函数里写一个死循环,同时点亮一个专门的错误 LED,这样产品在客户现场出问题时,不需要接调试器就能判断是不是堆栈溢出。
二是利用configASSERT()宏。在开发和测试阶段,把configASSERT()打开,内核会在检测到参数错误、状态异常时直接停下来,把问题扼杀在萌芽阶段。虽然会牺牲一些性能,但能帮你节省大量排查时间。
// 堆栈溢出钩子函数示例 void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { // 点亮错误 LED,并记录出错的任务名称 LED_Error_On(); strncpy(g_cErrorTaskName, pcTaskName, configMAX_TASK_NAME_LEN); // 进入死循环,方便外部观察 for (;;); }三是在 RTOS 调度器启动前后都加上监控逻辑。调度器启动前,通常做外设初始化和自检;调度器启动后,系统才真正进入多任务运行状态。如果系统在启动早期就崩溃,问题几乎一定出在某个外设初始化函数中,而不是任务逻辑中。
6. FreeRTOS 安全的常见误区与选型思考
6.1 误区一:开启了 MPU 就安全了
MPU 只能阻止内存访问越界,但它解决不了逻辑层面的安全问题。比如一个任务逻辑上有漏洞,导致缓冲区内容被错误地发送出去;或者一个任务通过 FreeRTOS 提供的队列接口,把敏感数据传给了本不该接收数据的人。这些都属于“合法的非法操作”,MPU 管不了。
所以,不要把 MPU 和 TrustZone 当作万能药。真正的安全需要分层设计:硬件层尽量提供隔离能力,内核层做好堆栈检测和资源保护,应用层做好权限管理和数据校验,通信层做好加密和认证。
6.2 误区二:安全功能全开就是好
有些开发者喜欢把 FreeRTOS 的所有安全选项全部打开,觉得这样“最安全”。但代价是性能和资源的双重损失。MPU 开启后,每次任务切换都要重新配置 MPU 寄存器,这个时间虽然只有几微秒,但在高频率切换的场景下也会积少成多。TrustZone 更是需要专门设计安全分区。
我见过一个项目,开发者把configCHECK_FOR_STACK_OVERFLOW设为 2,同时又开启了所有能开的统计功能,导致系统整体的帧率比预期低了 20% 左右。这其实是过度配置。
合理的做法是:先明确产品的安全需求等级,再决定开哪些功能。消费类产品,做好堆栈检测和通信加密基本就够了;工控、医疗、汽车类产品,再考虑 MPU 和 TrustZone。
6.3 如何选择合适的安全方案
| 方案 | 隔离强度 | 性能开销 | 开发难度 | 适用场景 |
|---|---|---|---|---|
| 纯 FreeRTOS + 堆栈检测 | 弱 | 极低 | 低 | 大部分消费级、工业级产品 |
| FreeRTOS-MPU(MPU) | 中 | 中 | 中 | 对内存隔离有硬性要求的产品 |
| FreeRTOS + TrustZone | 高 | 中 | 高 | 安全认证类设备,如支付、医疗、车规 |
| FreeRTOS + mbedTLS | 通信层加密 | 中 | 中 | 物联网设备、远程控制设备 |
拿我最近做的一个智能家居网关项目举例:网关采用 STM32F407 + FreeRTOS + lwIP + mbedTLS,运行 7 个任务,分别是 Wi-Fi 管理、TCP/IP 协议栈、MQTT 客户端、设备配网、LED 指示、固件升级、系统监控。这个方案没有用 MPU,因为产品定位是消费级,对内存隔离没有硬性要求;但通信层用了 TLS-PSK 模式,既能保证通信安全,又比完整证书模式节省资源。系统稳定性通过堆栈检测加任务看门狗来保障。
7. 安全性测试清单与验收标准
无论你选择了哪种安全方案,最终都要通过测试来验证。我总结了以下一套实际验证清单,供大家参考:
一是堆栈压力测试。让每个任务在最极端的输入条件下运行(比如最大报文、最长字符串、最深嵌套调用),长时间运行(至少 24 小时),观察是否有堆栈溢出。这个测试可以在开发阶段开启方法 2 的堆栈检测来辅助判断。
二是看门狗有效性测试。故意让某个任务挂起(比如在调试器中暂停),确认看门狗能复位系统,并记录复位原因。注意,FreeRTOS 中任务级的看门狗可以通过xTaskCreate加一个监控任务来实现,它定时检查各任务的“心跳”计数器,如果某个任务超过设定的时间没有更新心跳,就执行相应处理。
三是通信安全测试。使用抓包工具(比如 Wireshark)抓取设备与服务器之间的通信数据,尝试窃听、重放、篡改三种攻击。正常的 TLS 加密连接,抓到的数据应该是密文,重放和篡改也会被协议层拒绝。
四是异常恢复测试。模拟内存分配失败(把heap空间故意改小)、队列满、任务创建失败等异常场景,确认系统有完善的错误处理路径,不会死锁或崩溃。
五是固件安全测试。尝试把非法的固件文件烧录到设备中,确认引导程序能拒绝启动。如果固件校验失败后能退回到上一个固件版本继续工作,那体验会更好。
8. 最后再分享一点个人体会
做了这么多年 FreeRTOS 项目,我对“安全”两个字最大的感受是:安全不是一个开关,而是一种设计习惯。不是说在配置文件里打开几个宏,系统就安全了;而是从任务划分、内存分配、通信协议设计、代码规范等每个环节都把“安全性”当作一个默认约束来考虑。
比如一开始设计任务时,就把需要访问敏感资源的任务单独划分出来,不要把所有功能都塞进一个任务里。分配堆栈时,先按最坏情况估算再留出余量,而不是写一个看起来差不多的数字。写通信代码时,默认所有数据都是不可信的,先校验再处理。
这些习惯带来的收益可能不会立刻显现,但当你的设备部署到现场、卖出几千台之后,稳定性就是最好的回报。还是那句话:对于嵌入式系统来说,安全的目的不是为了对抗什么强大的对手,而是让设备在漫长复杂的使用环境中,能始终按设计意图可靠运行。