拿到 mbed OS 源码,最迷人也最劝退的地方是它的分层:HAL、RTOS、驱动、测试体系各占一片目录,却又互相咬合。如果你只是把它当成 SDK 来用,点编译跑通一个演示程序当然很简单;但一旦要往产品里加一颗不常见的外设、换一颗主控,或者想复用自己的驱动代码,那些藏在hal/、rtos/、targets/里的东西就绕不过去了。这篇文章是我啃完 mbed OS 源码树之后的一份架构笔记,不是 API 字典,重点讲清楚三件事:各层代码放在哪、层与层之间靠什么协议通信、实际开发中该从哪里下手改。
1. 源码树分层:别只盯着 hal、rtos、drivers 三个目录
1.1 宏观划分:platform、hal、rtos、drivers、events 各管一段
mbed OS 仓库打开之后,第一眼会看到一堆目录,常见的第一反应是"我到底该看哪个"。实际上它的分工非常明确,顶层目录基本就是架构图本身:
platform/:C++ 基础设施,包括Callback、CircularBuffer、ScopedLock、FileHandle、错误处理宏MBED_ERROR/MBED_WARN,以及wait_us这类基础延时。它是所有上层代码的底座,但不直接操作外设寄存器。hal/:硬件抽象层接口,全部是 C 函数声明,比如gpio_api.h、serial_api.h、spi_api.h、i2c_api.h、pwmout_api.h、flash_api.h、us_ticker_api.h、lp_ticker_api.h。这层只定规则,不关心你用的是 STM32 还是 NXP。rtos/:RTOS 的 C++ 封装层,Thread、Mutex、Semaphore、EventFlags、Queue、Mail、ThisThread都在这。封装对象是 CMSIS-RTOS2,默认内核是 RTX5。drivers/:给大家用的 C++ 驱动类,DigitalOut、AnalogIn、I2C、SPI、UnbufferedSerial、CAN、FlashIAP等。这一层完全面向开发者,通常也是你会 include 的头文件所在。events/:事件循环,核心是EventQueue和equeue实现,用来做"延迟执行""定时轮询""ISR 里不做重活"这类任务。cmsis/:CMSIS 标准文件,CMSIS-RTOS2 API 和 RTX5 源码都在这。connectivity/、storage/、components/:网络协议栈、文件系统和可选组件的扩展目录,不深入理解核心分层也能用。
这套结构最有意思的地方在于:驱动类不直接面对寄存器,而是全部通过 HAL 的 C 函数接口下钻到targets/里的具体实现。所以你读源码时,真正要记住的是两个方向:上层往底层走,调的是drivers/ → hal/ → targets/;底层往上层走,回调的是中断、事件和回调函数。
1.2 从一颗 LED 出发理解分层
我习惯用 LED 点灯来解释这套层级。DigitalOut是你在 application 里写的:
DigitalOut led(PIN_NAME); led = 1;这一行代码往下走,会经过DigitalOut::write()→ HAL 的gpio_write()→ 目标芯片的 GPIO 寄存器操作。如果换了一块板子,PIN_NAME的定义会变,HAL 实现也会变,但你的上层代码一行都不用动。这就是 mbed OS 分层的核心收益:板级差异被hal/和targets/挡住了,驱动类只跟抽象接口打交道。
理解这套分层还有一个实际好处:排查问题的时候,你知道 bug 大概在哪个目录。比如灯不亮,先查DigitalOut构造是否成功,再查 HAL 的gpio_init是否拿到了正确的 PinName,最后才去怀疑寄存器配置。如果一开始就在targets/的底层配置里翻,效率会低很多。
2. HAL 层接口设计:为什么是一堆 C 函数而不是 C++ 类
2.1 HAL 的命名约定与设备对象
打开hal/gpio_api.h,你会看到类似这样的声明:
void gpio_init(gpio_t *obj, PinName pin); void gpio_mode(gpio_t *obj, PinMode mode); void gpio_dir(gpio_t *obj, PinDirection direction); void gpio_write(gpio_t *obj, int value); int gpio_read(gpio_t *obj);这里有几个值得注意的设计点。第一,接口是 C 函数,不是 C++ 类。原因是 C ABI 足够稳定,各芯片厂商的 HAL 实现可以用 C 写,也可以用 C++ 写再包一层 extern "C",但最终对外的符号是统一的。如果你用 C++ 类做抽象,类名、命名空间、模板参数一变,厂商移植成本立刻上升。
第二,每个设备都有个xxx_t结构体,名字是gpio_t、serial_t、spi_t、i2c_t这样。这个结构体是在哪个文件里定义的?不在hal/里,而是在targets/里。HAL 头文件只负责声明函数,设备对象的内部布局由各目标芯片自行决定。比如 STM32 平台的gpio_t可能就包含端口、引脚号和 GPIO 句柄,而 NXP 平台的可能是另一套内容。这个设计让同一套 HAL API 能适配完全不同的底层寄存器结构。
第三,所有 HAL 函数的第一个参数几乎都是"设备对象指针",第二个参数是引脚或频率这类配置。这种约定非常统一,读一个xxx_api.h就能猜出其他几个的用法。
2.2 PinMap 查表:外设与引脚是怎么绑定的
HAL 层里最容易被忽视的机制是 PinMap 查表。mbed OS 不像传统 STM32 工程那样在初始化函数里手工配置 GPIO 的复用功能,而是用一张静态表来记录"哪个引脚可以用在哪个外设实例上"。
在targets/的对应目录下,你能找到类似PeripheralPins.c的文件,里面是:
const PinMap PinMap_SPI_SCLK[] = { {PA_5, SPI_1, STM_PIN_DATA(STM_MODE_AF_PP, GPIO_PULLUP, GPIO_AF5_SPI1)}, {PB_13, SPI_1, STM_PIN_DATA(STM_MODE_AF_PP, GPIO_PULLUP, GPIO_AF5_SPI1)}, {NC, NC, 0} };这张表的作用是:当你调用SPI驱动时,底层会通过pinmap_find_peripheral(pin, PinMap_SPI_SCLK)查找该引脚能挂到哪个 SPI 外设,如果找不到,就报出经典错误Pinmap not found for SPI SCLK。这个报错不是随便写的,它意味着当前引脚根本没有这个外设的复用功能,不是配置问题,而是硬件连接问题。
这种查表机制带来的好处是,你不需要关心具体芯片的 AF 编号,只需要告诉 mbed OS"我要在 PA_5 上把 SPI 时钟引出来",剩下的事情由 PinMap 完成。坏处是,如果你自己画板子时用了非标引脚,mbed 就无能为力,必须在PeripheralPins.c里手动加一行。这也是移植和定制板卡时最常见的开发动作之一。
3. RTOS 那层不是裸的 RTX5:C++ 封装已经把坑填了一遍
3.1 Thread、Semaphore 与 osThreadNew 之间的映射
mbed OS 的 RTOS 层,底层是 CMSIS-RTOS2 标准接口,默认实现是 RTX5。但你写应用时几乎不会直接调osThreadNew和osSemaphoreNew,而是用rtos::Thread、rtos::Semaphore这些 C++ 类。
我一开始以为这层封装就是给函数起个别名,实际看了源码才发现它做了几件很关键的事:RAII 管理、参数默认值、以及对象内存的分配策略。
以Thread为例,构造函数里会准备osThreadAttr_t,指定栈大小、优先级、是否使用静态内存等。如果你没有提供静态栈,它会从堆上分配;如果你提供的是MBED_STACK_ALLOC宏创建的静态数组,它就指向那个数组。这个看似简单的选择,直接影响嵌入式系统的稳定性:动态分配方便但会产生堆碎片,静态分配稳定但浪费 RAM。
我踩过的坑是:在中断里直接启动一个Thread。RTX5 的osThreadNew在中断上下文里是可以调的,但 mbed 的 C++ 封装并不保证start()在 ISR 里安全,因为它可能涉及内存分配和调度器状态切换。所以我的铁律是:线程创建和启动只放在主循环或任务初始化阶段,不在中断里碰。
Semaphore、Mutex、EventFlags这三个类也值得注意。EventFlags是 mbed 里我最常用的同步原语,因为它可以从 ISR 里调用set(),非常契合"中断里置标志位、线程里等标志位"的经典模型。相比之下,Mutex不能从 ISR 里上锁,这是 RTOS 的通用规则,不是 mbed 特有。
3.2 ISR 里不要做的事情,EventQueue 怎么接
在 mbed OS 里写中断回调,第一原则是回调函数要短。InterruptIn的fall(callback)注册的函数会在中断上下文里执行,里面最好不要做printf、动态内存分配、加锁、延时这些操作。
那这些活谁干?EventQueue。
EventQueue queue(32 * EVENTS_EVENT_SIZE); Thread queueThread; // 中断回调:只做一件事,把真正的处理函数丢给事件队列 void isr_handler() { queue.call(print_uart_message); } int main() { queueThread.start(callback(&queue, &EventQueue::dispatch_forever)); button.fall(isr_handler); }queue.call()在中断里是安全的,它把print_uart_message投递到事件队列,由queueThread在普通线程上下文里执行。这样既避免了中断里做重活导致系统延迟,也避免了中断里调用非线程安全函数。
这套机制的本质是"中断产生事件,线程消费事件",mbed 用events/目录下的EventQueue帮我们把异步处理做成了很自然的代码流。我建议所有新项目都从EventQueue起步,哪怕暂时只有一个中断源,也比直接在 ISR 里堆逻辑好维护得多。
4. 从 DigitalOut 写 1 到 GPIO 输出高电平:一次完整调用链
4.1 驱动类对象里到底存了什么
理清分层之后,最好亲手走一遍调用链,看看一个驱动对象在内存里长什么样。以DigitalOut为例,它的成员变量实际上就是一个gpio_t gpio,构造函数里做的第一件事就是gpio_init(&gpio, pin)。
DigitalOut::DigitalOut(PinName pin, int value) { gpio_init(&gpio, pin); gpio_dir(&gpio, PIN_OUTPUT); gpio_write(&gpio, value); }这里的gpio_t不是一个大结构体,它通常只存几个必要字段:端口、引脚号、当前状态。也就是说,一个DigitalOut对象不会占据很大的 RAM,跟裸机编程里"GPIO_TypeDef *port + uint16_t pin"的模型很像。
这给我们两个启发:
- mbed OS 驱动类不是"重型框架",它的对象通常可以安全地放到线程栈或全局区。
- 驱动类的构造和析构成本很低,你可以频繁创建
DigitalOut控制不同引脚,而不用像某些 SDK 那样必须实例化一个巨大的句柄。
4.2 完整调用栈长这样
从led = 1开始,一路往下追踪,大致是这样:
用户代码: led = 1; -> mbed::DigitalOut::write(1) -> hal/gpio_api.h 声明的 gpio_write(&obj, 1) -> targets/TARGET_VENDOR/... 的 gpio_write 实现 -> vendor_hal_gpio_write_pin(...) -> 写寄存器: GPIOA->ODR |= (1 << pin)在这个链条里,hal/的 C 函数是一个分水岭。在hal/之上,代码是通用的、可移植的;在hal/之下,每一行都是芯片相关的。这也是为什么 mbed OS 能够"一次编写,到处编译"——你的应用代码只依赖分水岭以上的部分。
这套调用链还有一个容易被忽略的好处:它让单元测试变得简单。测试代码可以把gpio_writemock 掉,直接验证DigitalOut::write逻辑是否正确,而不需要真实硬件。mbed OS 的UNITTESTS目录里大量测试就是这么写的。
4.3 像 SPI、I2C 这样的复杂外设,链条更长但思路一样
GPIO 只有一根线,调用链自然短。SPI、I2C、UART 这类协议外设的调用链会多两步:频率设置、格式配置、以及中断或 DMA 的注册。
以 I2C 为例:
I2C i2c(PB_7, PB_6); i2c.write(addr, data, length);构造I2C对象时,底层会解析 PinMap,找到I2C_SDA和I2C_SCL分别对应的实例,然后调i2c_init。写入数据时,i2c_write会根据目标地址发起传输,期间可能使用中断或轮询。整个链条依然是drivers/I2C.cpp → hal/i2c_api.h → targets/.../i2c_api.c。
理解这个链条后,遇到"I2C 读不到设备"这类问题,你就有了一条清晰的排查路径:先看 PinMap 是否匹配,再看 I2C 实例是否被正确初始化,最后才查设备地址和上拉电阻。大多数人一上来就怀疑上拉电阻,其实经常是引脚复用配置错了。
5. 测试体系与构建体系是怎么串起来的
5.1 跑在 PC 上的 UNITTESTS 与跑在板子上的 TESTS
mbed OS 源码仓库里有两个目录,很多人会忽略:TESTS/和UNITTESTS/。这是两套完全不同的测试体系,作用也不一样。
UNITTESTS/是跑在 PC 主机上的单元测试,用 CMake + GoogleTest 这类的测试框架编译,不需要真实开发板。它测试的是那些不依赖硬件的逻辑,比如Callback、CircularBuffer、EventQueue的行为。这类测试的价值在于回归速度快,改完代码几分钟内就能知道有没有破坏基础功能。
TESTS/是跑在真实硬件上的集成测试,比如 GPIO、SPI、RTC、RTOS 特性的验证。它需要配合 Greentea 工具链,把测试固件烧进板子,然后通过串口跟 PC 端的测试脚本通信。
这两套测试的定位差异非常重要。单元测试帮你守住逻辑,集成测试帮你守住硬件适配。如果你在移植一个新板子,TESTS/mbed_hal/下面的测试几乎是必跑的,它能比任何 Demo 程序都更全面地验证你的 HAL 实现是否完整。
5.2 Greentea 的串口握手与 utest 用例结构
跑集成测试时,Greentea 的工作流程大致是这样的:
- PC 端把编译好的测试固件下载到开发板。
- 开发板复位后通过串口发送特定同步字符串。
- Greentea 收到同步信息,开始逐条下发测试用例。
- 每条用例执行完,板上代码通过串口上报
{{success}}或{{failure}}。 - 所有用例跑完后,Greentea 生成测试报告。
所以板上测试代码不是随便printf就行,它必须实现 Greentea 约定的通信协议。mbed OS 内部已经把这一切封装进了utest框架。你写的用例长这样:
utest::v1::status_t test_gpio_output() { // 测试代码 return utest::v1::PASS; } utest::v1::Case cases[] = { Case("GPIO output test", test_gpio_output), Case("GPIO input test", test_gpio_input) }; utest::v1::Specification specification(cases, test_setup); int main() { return !Harness::run(specification); }utest的价值不只是组织用例,它还把超时、异常、分组、故障处理这些嵌入式测试常见的烦心事都处理好了。写测试的时候,你只需要关心 case 函数本身。
5.3 targets.json 和 mbed_app.json 如何影响最终构建
构建 mbed OS 项目时,有两份 JSON 文件决定了一大半行为:仓库根目录的targets.json和用户项目的mbed_app.json。
targets.json定义了每个目标板卡的信息:CPU 架构、继承链、支持的外设列表、宏定义等。比如一个板卡条目大概长这样:
{ "MY_BOARD": { "inherits": ["STM32F4"], "core": "Cortex-M4", "device_has": ["SPI", "I2C", "SERIAL", "ANALOGIN"], "macros_add": ["MY_BOARD_MACRO=1"], "components_add": ["FLASHIAP"] } }构建系统读这份文件后,会生成mbed_config.h,里面定义了TARGET_MY_BOARD、DEVICE_I2C之类的宏,整个源码树在编译时才能决定哪些模块被编进来、哪些被排除。比如device_has里没有CAN,那 CAN 驱动代码就不会参与编译。
mbed_app.json则是用户级配置,用来覆盖默认配置。它最经典的用途是调整rtos的线程栈大小、切换网络协议栈、或者往macros里加自定义宏。我习惯把板级差异都写进mbed_app.json的target_overrides里,这样换板子时只需要改配置,不用动业务代码。
理解这两份文件和mbed_config.h的关系,基本就理解了 mbed OS 的"配置魔法"。很多编译报错根源都在这里:你device_has没写某个外设,代码里却用了对应驱动类,编译期直接报"找不到头文件"或宏未定义。
6. 移植新板卡时,源码树里真正要动的文件
6.1 同 MCU 换板卡:只改 Board 层
如果你只是换了一块板子,但主控 MCU 跟现有某块板一致,那移植成本很低。以 STM32F4 系为例,A 板换成 B 板,MCU 相同,主要改的是引脚定义、外设数量和外接设备的 PinMap。
这种移植通常只碰两个地方:
targets.json里新增一个MY_BOARD条目,继承自已有的 MCU 系列。- 复制一份相近板卡的
PinNames.h和时钟配置,按新板原理图改。
这里最容易犯的错误是只改原理图引脚,忘了改PeripheralPins.c。比如新板把 I2C 从 PB7/PB6 挪到了 PC9/PC8,如果你只改应用层代码里的引脚枚举,底层 PinMap 查表可能依然引用旧的默认实例,导致驱动找不到映射,轻则警告,重则初始化失败。
我的经验是,同 MCU 换板卡,编译通过只是第一步,把TESTS/mbed_hal和TESTS/mbed_drivers里的 GPIO、UART、SPI、I2C 测试全部跑一遍,确认外设工作正常,才算真正移植完成。这个环节省不得。
6.2 全新 MCU 移植:从 targets 目录复制一份照猫画虎
换全新 MCU 的移植量就大了。建议不要从零写,而是从targets/里选一个架构最接近的芯片目录复制过来,然后逐项替换。
必改项至少包括:
targets.json新条目:继承链、core、device_has、macros_add。PinNames.h、PeripheralPins.c:芯片引脚定义和 PinMap 表。- HAL 实现文件:
gpio_api.c、serial_api.c、spi_api.c、i2c_api.c、timer_api.c、us_ticker_api.c、lp_ticker_api.c等。 - 启动文件、系统时钟初始化、中断向量表。
- 如果是带操作系统调度的芯片,还要确认 RTX5 在目标架构上的 Cortex 移植部分,比如
cmsis/下的启动和上下文切换代码。
这里最难的往往不是某个外设驱动本身,而是us_ticker和lp_ticker,因为 RTOS 的时间基准、事件队列的定时器都依赖它们。如果 ticker 实现有偏差,整体 RTOS 可能能编译通过,但跑起来就会出现"任务偶尔卡死""延时不准"这种玄学问题。我的排查顺序永远是先测 ticker,再测 GPIO,再拿串口打印,最后才去碰复杂外设。
还有一个容易遗漏的点:targets.json里的inherits继承关系。mbed OS 很多配置是通过继承传递的,比如芯片系列层面的device_has、macros_add。如果新芯片不属于任何已知系列,你要把公共外设能力都补全,否则可能编译过,但某些驱动在运行时被宏剪掉,问题非常隐蔽。
写在最后的个人体会
mbed OS 这套源码架构,表面上是一堆文件夹和宏定义,内里其实是"约定优先于配置"的嵌入式设计哲学:HAL 用 C 接口定规矩,PinMap 用表驱动代替手工配置,RTOS 用 C++ 封装抹平底层差异,测试体系又把"能不能跑"变成可度量的标准。我自己在移植板卡过程中最大的感悟是,不要一上来就扎进targets/看寄存器代码,先花一个下午把hal/头文件和mbed_config.h生成逻辑搞清楚,后面所有问题都会好找很多。
另外一个小技巧:每次编译时打开mbed_config.h看一眼生成的宏,很多"莫名其妙"的编译错误其实一眼就能发现。比如DEVICE_SPI没有被定义,而你代码里偏偏用了 SPI 类,那问题多半不是语法错误,而是targets.json配置缺项。把配置和源码当做一个整体来看,mbed OS 的架构其实非常经得起推敲。