拿到 mbed OS 源码的那一刻,很多人的第一反应是先找到一个板卡 demo,编译、烧录、点灯,跑通一个 mbed OS 应用。但真正让我觉得这个项目值得静下心来拆开看的,是它把 HAL、RTOS、驱动、测试这四条线整整齐齐地摆在了同一个代码库里。无论是在 STM32 上用 HAL 库调 MPU6050,还是折腾 MT6701 磁编码器的 IIC 滤波,又或者是在做 RTOS 面试题时被问到“任务栈到底怎么分配”,当你见过一套成熟的 RTOS 源码后,再看这些问题都会轻松很多。
这篇文章我打算从源码目录结构入手,一步步拆解 mbed OS 的 HAL 层、RTOS 内核、驱动模型和测试体系,把每一层在干什么、层与层之间的接口长什么样、实际移植或使用的时候会在哪里踩坑都讲清楚。内容偏源码解析,但对平时做嵌入式开发有直接帮助,尤其是你想自己写一套可复用的驱动框架,或者正在评估某个 RTOS 适不适合你的产品时,这篇文章应该能给你一些参考。
1. 整体架构拆解:mbed OS 源码的骨架
1.1 源码根目录到底藏着什么
mbed OS 的源码采用模块化组织方式,根目录下能看到platform、drivers、hal、rtos、targets、features、connectivity等一级目录。刚接触的人容易犯一个错误:上来就扎进targets目录找某个芯片的启动文件,结果被大量 vendor 目录绕晕。其实正确的打开方式是先看platform和hal,这两个目录决定了整个系统的抽象形态。
hal目录是硬件抽象层的核心位置,里面按外设分类放着analogin_api.h、gpio_api.h、i2c_api.h、spi_api.h、serial_api.h、pwmout_api.h、flash_api.h等接口头文件。这些头文件给上层驱动提供了统一的调用入口,具体实现则分散在targets目录下每个芯片厂商的子目录里。这样的好处非常明显:如果你要在新板子上跑 mbed OS,只需要针对目标芯片实现这些 API 函数,所有上层驱动和 RTOS 组件都可以直接复用。
rtos目录存放的是基于 CMSIS-RTOS 封装的线程、信号量、互斥锁、消息队列等组件。mbed OS 的内核线程模型以osThread为基础,封装出Thread类,以osMessageQueue为基础封装出Queue类。在阅读这部分源码时,你会发现 mbed OS 并没有完全重写一个 RTOS 内核,而是站在 CMSIS-RTOS 规范肩膀上做了一层 C++ 封装。这种设计的好处是内核可以替换——如果你不想用默认的 RTX 内核,可以换用其他兼容 CMSIS-RTOS 的内核,上层代码完全不需要改动。
1.2 分层设计的核心理由
mbed OS 的分层逻辑非常像 Web 开发里的 controller-service-dao 三层架构:应用层不直接操作寄存器,驱动层不直接面向业务,HAL 层不关心具体芯片。以点一盏 LED 为例,应用层调用DigitalOut led(PA_5);和led = 1;,这行代码最终会通过drivers目录里的DigitalOut类,调用hal目录里的gpio_write函数,再由gpio_write的实现去操作芯片寄存器。
把这一套链路想清楚之后,你会理解为什么说 mbed OS 的移植工作其实主要是 HAL 层的活,而不是应用层的活。只要 HAL API 的实现符合规范,上层几乎不需要改动。编写驱动时的一个常见心法是“剥洋葱”,从应用一路剥到寄存器,每一层只做自己该做的事,这也解释了为什么 mbed OS 的驱动代码看起来特别干净——因为它把脏活累活全隔离在 HAL 层了。
1.3 平台适配层:Target 宏与芯片选择
在 mbed OS 中,targets目录存放着所有受支持的芯片平台,每个平台都有自己的device.h、PeripheralPins.c和启动文件。实际编译时,编译器会根据你选择的TARGET_XXX宏来决定包含哪个平台的头文件。我在第一次看到PeripheralPins.c时还没意识到它的重要性,直到我自己做板卡移植时才发现,这个文件其实决定了引脚复用功能。
PeripheralPins.c里维护的是一张引脚功能映射表,比如某个引脚可以复用为 UART_TX,也可以复用为 SPI_SCK,到底用哪个功能由芯片的硬件设计决定,代码在初始化外设时会从这张表里查找可用引脚。如果你在使用 mbed OS 时发现某个外设无法初始化,十有八九是这张表里的引脚注释没有打开,或者是某一个引脚被两个外设同时占用了。这个问题在后面我会展开细说。
2. HAL 层:硬件抽象是怎么做到“一次编写,到处编译”的
2.1 HAL API 的设计语言
mbed OS 的 HAL API 有几个比较统一的约定。首先是命名规则,外设加下划线加动作,比如gpio_init、i2c_read、serial_putc。其次是错误处理方式,大部分 HAL 函数直接返回int32_t或指针,上层驱动根据返回值判断操作是否成功。最后是配置结构体,比如i2c_t、spi_t、serial_t,这些结构体通常由两个字段组成:一个是外设寄存器基地址指针,另一个是实例索引号,部分平台还会加一个锁字段。
以gpio_t为例,在大多数平台中它包含PinName pin和PinMode mode等信息。PinName是枚举类型,定义了所有引脚名称,从PA_0到PH_1这样的命名方式对用户非常友好。如果你是做 STM32 开发的,你会觉得这套命名比直接操作 GPIOA、GPIOB 要直观得多,因为它把端口和引脚号合并成了一个枚举值。
HAL 层的实现集中于各芯片平台的source目录,例如 STM32 平台的 HAL 实现位于targets/TARGET_STM32下,里面按系列又分成TARGET_STM32F0、TARGET_STM32F1、TARGET_STM32F4等子目录。所有系列共用一套 HAL 框架代码,但每个系列通过宏定义区分差异,表现出来就是极大幅度的代码复用。我自己在移植 mbed OS 到一块国产 MCU 时参考过这套结构,得益很多。
2.2 一个串口驱动的 HAL 实现全流程
串口是嵌入式开发中最基础也最常用的外设,拿串口来走一遍 HAL 实现流程最清晰。在 mbed OS 里,serial_api.h定义了串口的 HAL 接口,包括serial_init、serial_baud、serial_format、serial_putc、serial_getc、serial_irq_handler等。应用层使用的RawSerial或BufferedSerial类就是在这些 API 之上做的封装。
serial_init函数做的事情不少,第一步是把传入的PinName转换成具体的端口、引脚号,第二步是查找该引脚是否具备 UART 复用功能,第三步是配置 GPIO 复用寄存器,第四步是配置 UART 外设参数,第五步是使能 UART 外设。整个过程听起来复杂,但在厂商提供的 SDK 基础上实现并不算难。比较关键的是中断处理函数serial_irq_handler,你需要把用户传入的回调函数保存好,然后在 UART 中断服务程序里调用它。
这里有一个实际的经验教训:serial_irq_handler接收的参数是一个uint32_t id,这个 id 并不是简单的数字,而是一个指向serial_t结构体的指针被封包后的值。在中断里使用这个 id 时,一定要把它正确地转回结构体指针,否则回调内访问外设结构体时内存就越界了。这类问题在裸机调试中很难发现,因为只有在系统运行一段时间、中断频繁触发时才会出问题。
2.3 为什么 HAL 层是移植的关键
mbed OS 的移植文档虽然一直在更新,但真正决定移植工作量的就是 HAL 层的完成度。芯片厂商的 SDK 通常提供了完整的寄存器操作函数,但未必符合 mbed OS 的 HAL API 规范,所以移植时需要做的是“接口适配”,而不是从零开始写驱动。
我之前评估过在 RISC-V 内核 MCU 上移植 mbed OS 的可行性,发现大部分工作集中在targets目录新增一个平台文件夹,然后逐步实现 HAL API。如果只跑 CPU 和串口、GPIO,大概一周左右能跑通;如果要把以太网、USB、SD 卡这些复杂外设全部适配,时间会明显拉长,每类外设对中断优先级、DMA 通道的要求都不一样,需要逐个调。
在移植时有一个“起步三步”的方法比较实用:第一步先把serial_api.c调通,因为它能输出日志;第二步调通定时器,wait函数和 RTOS tick 都依赖它;第三步调通 GPIO 中断和外部中断,因为大部分传感器驱动需要中断配合。只要这三步走完,板子就算能启动并能输出信息了,后续再慢慢补其他外设。
3. RTOS 内核:mbed OS 的多线程心脏
3.1 站在 CMSIS-RTOS 肩膀上的封装
mbed OS 的 RTOS 组件没有走“从零造轮子”的路线,而是构建在 CMSIS-RTOS API 之上,底层内核默认是 Keil RTX5。CMSIS-RTOS 定义了一套标准接口,包括osThreadNew、osMutexNew、osMessageQueueNew等,RTX5 实现了这套接口,mbed OS 再把 C 接口封装成 C++ 类,这样应用层就能用Thread、Mutex、Queue这种非常人类友好的写法。
在翻阅rtos目录源码时,你会看到Thread类内部维护了一个osThreadId_t,构造时调用osThreadNew创建线程,并且通过osThreadAttr_t设置线程名、栈大小、优先级等属性。Thread类的关键点在于栈空间的管理方式,你可以传入自定义栈缓冲区,也可以让内核自动分配。自动分配会走 RTX 的内存管理模块,如果内存池碎片化严重,长时间运行后会出现创建线程失败的情况;自定义栈缓冲区则要求你确保缓冲区生命周期足够长,不能在函数返回后还把栈地址传给线程。
实际项目里我更推荐显式定义线程栈数组的方式,类似uint8_t stack_led[1024];,然后把stack_led传入Thread构造函数。这样做有两个好处:第一是编译时就能看到内存占用,第二是避免动态内存分配带来的不确定性。尤其是在做产品级代码时,静态分配出来的线程栈让压力测试阶段的 panic 排查方便很多。
3.2 调度、中断与优先级反转问题
CMSIS-RTOS 默认的调度策略是时间片轮转加抢占式优先级。每个线程有优先级属性,高优先级线程就绪时会立即抢占低优先级线程。mbed OS 在rtos目录创建线程时,默认优先级对应的就是osPriorityNormal,应用层如果对实时性有要求,可以根据场景调整。
理解调度之后,你就会遇到嵌入式开发里最经典的问题:优先级反转。比如有一个低优先级线程持有互斥锁,高优先级线程等待同一把锁,此时一个中等优先级线程开始运行,高优先级线程就迟迟拿不到锁。CMSIS-RTOS 引入了优先级继承机制来缓解这个问题,当高优先级线程被互斥锁阻塞时,临时把持有锁的低优先级线程优先级提升到与高优先级相同,从而避免中等优先级线程插队。
在 mbed OS 中使用Mutex类的实际经验是尽量缩小临界区。很多人刚接触 RTOS 时会下意识地给整个函数加锁,这会导致其他线程等待时间变长。我的建议是在读改写外设寄存器这种非原子操作上加锁,而不是把整段数据处理流程都包进去。另外要特别注意中断服务程序里不能调用Mutex::lock,因为互斥锁的获取可能引起线程阻塞,而中断上下文不允许阻塞,一旦调用就等着系统卡死。
3.3 内存管理:栈、堆与分散加载
mbed OS 默认的 RTOS 栈空间分配方式在 GCC 环境下会用到堆,在 ARMCC/IAR 环境下则可能通过分散加载文件来定义。不同编译器在实现上有细微差异,但总体思路一致:内核创建线程时,如果osThreadAttr_t里的stack_addr为 NULL,就从系统堆中分配一块连续内存作为栈,分配失败则返回 NULL。
判断一个 RTOS 配置是否合理,不能只看静态分配了多少,还要看运行时峰值。比较实用的方法是周期性调用osThreadGetCount和osThreadGetStackSpace检查线程栈剩余空间,或者在调试器里查看每个线程栈空间的最高水位标记。RTX5 在调试模式下会填充栈区域的特定字节,溢出时能看到明显的边界破坏,这一点在找栈溢出问题时特别好用。
内存不足的另一个常见原因是对Queue和MemoryPool的使用不加节制。每条Queue消息可能占几十字节,但消息数量一多,内存占用就会线性增长。我见过一个项目在消息里塞了一个 2KB 的缓冲区数组,结果队列深度设为 8,一下子就占了 16KB,这在一个小内存 MCU 上是灾难性的。所以设计消息结构时,能传指针就不要传实体,能传索引就不要传结构体。
4. 驱动体系:从 HAL 到设备驱动的完整链路
4.1 驱动类的封装套路
mbed OS 的drivers目录下存放着各种设备驱动类,如DigitalIn、DigitalOut、AnalogIn、PwmOut、I2C、SPI、Serial、CAN、FlashIAP等。这些类把所有 HAL 层的 C 函数包成了符合面向对象习惯的 C++ 接口,调用者只需要关心构造函数、读函数、写函数、中断回调函数。
以I2C类为例,它的构造函数接收sda和scl两个引脚名,初始化时在构造函数里调用i2c_init,然后提供write、read、start、stop等方法。如果你在某个传感器驱动里看到I2C对象,说明底层拿的是一根真实 I2C 总线。这里有一个硬件工程师经常犯的低级错误:把芯片手册上的 I2C 设备地址当成调用write时传入的地址,其实设备地址通常需要左移一位,变成 8 位总线地址形式。
实际使用中,I2C类与其他 RTOS 线程配合时需要注意总线竞争问题。如果多个线程同时访问同一个 I2C 总线,就必须用互斥锁保证同一时刻只有一个线程在传输。mbed OS 框架本身没有为I2C类自动加锁,加不加由你自己决定,这个细节在开始多线程访问外设之前一定要想好。
4.2 异步模型与事件回调机制
mbed OS 的驱动体系里,除了同步的阻塞读写外,还有异步机制,最典型的是SerialBase支持的中断收发和Ticker、Timeout这类软定时器。Ticker类可以在后台周期性触发回调,本质上是用硬件定时器中断加软件链表实现的一组定时任务,这些任务在中断上下文中执行,所以回调函数必须短小精悍,不能在里面做耗时操作。
高级一点的异步模型会配合事件队列EventQueue使用。EventQueue允许你把一个函数和它的参数包装成一个事件,投递到队列中,然后由指定的线程取出并执行。这样做的好处是把中断上下文中的耗时操作搬到线程上下文中执行,既保证了中断响应速度,又避免丢失事件。mbed OS 官方的很多网络示例就是这样做的:网络中断里只发事件,实际数据处理在事件线程里做。
在写传感器驱动时,我习惯把数据就绪中断与EventQueue配合起来:传感器数据准备好后触发一个 GPIO 中断,中断服务程序里eventQueue.call(callback)把处理函数推入队列,事件线程执行读取和滤波。这样做避免了在中断里做大量运算,也不会因为长时间占用中断而导致其他紧急中断响应不过来了。
4.3 电源管理:低功耗设计中的驱动配合
mbed OS 的电源管理框架通过sleep_manager实现,HAL 层提供sleep函数和deep_sleep函数,系统会在空闲时自动进入低功耗状态。但如果你在驱动代码里申请了不允许多次休眠的资源,或者定时器频繁触发导致空闲时间太短,那么低功耗效果会大打折扣。
驱动层影响低功耗的因素主要有三个:第一是外设时钟没有关闭,第二是引脚悬空导致电平不稳定,第三是中断源过多导致频繁唤醒。我在实际测试中发现,一块使用 mbed OS 的板子,仅靠默认配置,睡眠电流比手册标称值高了好几毫安,排查下来是一个 GPIO 引脚悬空导致的,把它配置为带上拉或下拉后功耗就正常了。这个经验很值得分享:用 mbed OS 的第一时间就检查所有未使用引脚的初始状态,要么设成输入上拉,要么输出低电平,别让它们悬空。
5. 测试体系:mbed OS 怎么保证自己可靠
5.1 测试框架 How It Works
mbed OS 的测试代码分布在features、platform、drivers、rtos等各模块的tests子目录中,另外tools目录下有一套用 Python 写的测试运行工具。整体流程是:在主机上编译出测试固件,烧录到目标板卡上,然后通过串口或 USB 与上位机通信,上位机根据串口输出的结果判断测试是否通过。
mbed OS 的单元测试和板级测试是分开的。单元测试在主机上直接编译成原生程序运行,主要验证纯逻辑部分,比如字符串处理、环形缓冲区;板级测试则跑在真实硬件上,通过断言和日志输出验证外设功能是否正常。对于移植 mbed OS 到新板卡的开发者来说,板级测试是验证 HAL 层实现是否正确的关键工具。
5.2 测试用例是怎么组织和运行的
在源码树的每个功能模块下,通常能找到针对该模块的测试用例文件,比如rtos/tests/TESTS目录下按功能分成mutex、queue、semaphore、thread等子目录,每个子目录里有一或多个main.cpp测试入口,通过宏定义和命令解析来选择要跑哪个测试。
板级测试的固件通常使用utest测试框架,它提供TEST_CASE、CASE_WAIT等宏定义。驱动开发人员可以在自己的驱动目录下建立类似结构的测试用例,粗略地验证功能的正确性。但要注意,测试失败时的输出格式必须遵循约定,比如失败时要打印出具体出错的文件名和行号,否则上位机日志解析会跳过这段输出,你会在串口里看到静默失败,排查起来很痛苦。
真正跑测试时,我建议按顺序执行“GPIO -> 串口 -> 定时器 -> I2C -> SPI -> Flash -> RTOS 基础功能”的路径,不要一次性跑全量。因为全量测试里如果早期断言失败,后续测试的结果会互相影响,给定位问题增加很多噪音。而且板级测试的编译时间不短,先跑最小集合快速反馈,往往是效率最高的做法。
5.3 持续集成与自动化测试的接入
mbed OS 官方通过 Jenkins 持续集成体系在大量板卡上跑自动化测试,每有一个提交合入前都会经过多板卡、多编译器的验证。如果你在公司内网搭自动化测试,可以不用全套照搬,只挑选几个关键板卡组成测试矩阵即可。但测试工具链中必须包含编译、烧录、串口通信、日志比对这四步,而且尽量做成 Docker 容器化的,这样才能让每个开发者的本地环境和 CI 环境保持一致。
在搭建 CI 时有一个容易被忽视的细节:不同板卡的烧录方式差别很大,有的用 DAPLink 模拟 U 盘拖拽固件,有的用 J-Link,有的用 ST-Link,烧录命令完全不同。如果你把整个测试流程做成脚本,建议把烧录步骤独立成一个脚本文件,按板卡型号分发到不同 agent 上执行。我第一次搭的时候把所有板卡混在一个 agent 上,结果烧录工具互相冲突,浪费了不少排查时间。
6. 常见问题与排查技巧实录
6.1 外设初始化失败,代码却“看起来没问题”
遇到过最多次的坑是PeripheralPins.c中没有启用目标引脚的复用功能。比如你在 STM32F103 上想用 PB6 和 PB7 作为 I2C1 的 SCL/SDA,如果PeripheralPins.c里 PB6/PB7 那一行的 I2C 功能被注释掉了,HAL 层初始化时会返回错误,或者 I2C 通信卡死在忙状态。
排查思路很简单:先在源码里搜索I2C_SDA和I2C_SCL对应的引脚表,把目标引脚的这一行取消注释或补上。另外要确认该引脚没有被其他外设占用,有时同一引脚既能接 SPI 又能接 I2C,但两个外设同时初始化时会冲突,后初始化的外设会覆盖前一个的配置,导致前一个外设失效。
6.2 线程栈溢出导致系统随机死机
RTOS 系统最让人头疼的就是随机死机。很多时候表现是运行几小时、几天后突然挂掉,复现概率还不稳定。如果踩到这个坑,优先怀疑线程栈溢出。RTX5 调试模式会在每个线程栈的边界处放置特殊值,一旦被改写,调试器或断言可以检测到。
如果没有调试器,可以在每个线程回调里周期性地检查剩余栈空间。Thread类提供了stack_high_mark()方法,可以返回历史最高栈水位。我通常会在压力测试阶段把所有线程的剩余栈空间打进日志,跑一整夜后回看日志,哪个线程的剩余空间低于 10%,就给它加大栈容量。这个方法在多个项目里都有效,比靠猜省时省力。
6.3 I2C 通信不稳定,时好时坏
I2C 问题在 mbed OS 中非常常见。最常见的原因是总线没有上拉电阻,或者上拉电阻阻值取得不合适。系统运行时如果 I2C 波形上升沿太缓,数据传输就会出现偶发错误。优先用示波器看波形,如果上升沿过缓就换更小的上拉电阻,或者降低 I2C 速率。
另一类原因是在竞争场景下,两个线程同时对同一 I2C 总线发起传输。调试这种问题比较困难,因为偶发性和随机性很强。最直接的方法是在所有操作 I2C 的地方都加互斥锁,然后观察问题是否消失。如果确认是竞争导致的,光加锁还不够,要考虑是否需要把 I2C 操作全部收敛到一个专用线程里,通过消息队列串行化访问,这是解决外设并发问题的终极方案。
6.4 编译报错:找不到平台头文件或宏定义
切换板卡后重新编译时经常出现这类问题。mbed OS 的构建系统会根据目标板卡设置TARGET_XXX宏,并把对应平台的目录加入头文件搜索路径。如果你手动删除了mbed-os目录下的某个文件夹,或者修改了platform头文件的#if DEVICE_XXX宏定义,很容易出现编译错误或外设 API 缺失。
排查这类问题的方法:先确认你选择的目标板卡型号与targets目录下的目录名一致,再检查工具链是否在mbed_config.h中正确列出了所有宏定义。如果 API 缺失,查看hal头文件里的DEVICE_XXX宏在目标平台中是否为 1,若为 0,说明该平台没有实现这个外设的 HAL,换一个引脚或外设类型即可解决,而不是硬改平台支持。
个人经验总结
mbed OS 给我的最大收获不是某一个具体功能,而是它把硬件抽象、RTOS、驱动、测试这几个环节非常清晰地串联了起来,跟随源码过一遍后,再去看其他嵌入式项目时你会更有底气。
如果你打算在自己的项目中引入 mbed OS 或者参考它的架构,我给的小建议是:不要一开始就铺开所有外设,先从点灯和串口开始,跑通最小系统,然后按“串口 -> 定时器 -> GPIO 中断 -> I2C/SPI -> RTOS 多线程 -> 驱动分层”这条路径逐步搭。过程中的每一步都以源码为参考,遇到问题不要急着改代码,先去 HAL 层查实现,去PeripheralPins.c查复用,去 RTOS 文档里查调度规则,你会发现大部分坑在源码里早就有答案了。
最后再分享一个实用的小技巧:给 mbed OS 工程接一个调试串口日志模块,所有驱动初始化和 RTOS 线程创建的关键位置都打一条带时间戳的日志。别小看这条日志,当系统上线后运行不稳定时,它就是帮你快速定位问题的第一盏灯。