深入mbed OS源码:分层架构、HAL接口与驱动移植实战
2026/9/6 10:46:18 网站建设 项目流程

拿到 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++ 基础设施,包括CallbackCircularBufferScopedLockFileHandle、错误处理宏MBED_ERROR/MBED_WARN,以及wait_us这类基础延时。它是所有上层代码的底座,但不直接操作外设寄存器。
  • hal/:硬件抽象层接口,全部是 C 函数声明,比如gpio_api.hserial_api.hspi_api.hi2c_api.hpwmout_api.hflash_api.hus_ticker_api.hlp_ticker_api.h。这层只定规则,不关心你用的是 STM32 还是 NXP。
  • rtos/:RTOS 的 C++ 封装层,ThreadMutexSemaphoreEventFlagsQueueMailThisThread都在这。封装对象是 CMSIS-RTOS2,默认内核是 RTX5。
  • drivers/:给大家用的 C++ 驱动类,DigitalOutAnalogInI2CSPIUnbufferedSerialCANFlashIAP等。这一层完全面向开发者,通常也是你会 include 的头文件所在。
  • events/:事件循环,核心是EventQueueequeue实现,用来做"延迟执行""定时轮询""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_tserial_tspi_ti2c_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。但你写应用时几乎不会直接调osThreadNewosSemaphoreNew,而是用rtos::Threadrtos::Semaphore这些 C++ 类。

我一开始以为这层封装就是给函数起个别名,实际看了源码才发现它做了几件很关键的事:RAII 管理、参数默认值、以及对象内存的分配策略。

Thread为例,构造函数里会准备osThreadAttr_t,指定栈大小、优先级、是否使用静态内存等。如果你没有提供静态栈,它会从堆上分配;如果你提供的是MBED_STACK_ALLOC宏创建的静态数组,它就指向那个数组。这个看似简单的选择,直接影响嵌入式系统的稳定性:动态分配方便但会产生堆碎片,静态分配稳定但浪费 RAM。

我踩过的坑是:在中断里直接启动一个Thread。RTX5 的osThreadNew在中断上下文里是可以调的,但 mbed 的 C++ 封装并不保证start()在 ISR 里安全,因为它可能涉及内存分配和调度器状态切换。所以我的铁律是:线程创建和启动只放在主循环或任务初始化阶段,不在中断里碰。

SemaphoreMutexEventFlags这三个类也值得注意。EventFlags是 mbed 里我最常用的同步原语,因为它可以从 ISR 里调用set(),非常契合"中断里置标志位、线程里等标志位"的经典模型。相比之下,Mutex不能从 ISR 里上锁,这是 RTOS 的通用规则,不是 mbed 特有。

3.2 ISR 里不要做的事情,EventQueue 怎么接

在 mbed OS 里写中断回调,第一原则是回调函数要短。InterruptInfall(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"的模型很像。

这给我们两个启发:

  1. mbed OS 驱动类不是"重型框架",它的对象通常可以安全地放到线程栈或全局区。
  2. 驱动类的构造和析构成本很低,你可以频繁创建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_SDAI2C_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 这类的测试框架编译,不需要真实开发板。它测试的是那些不依赖硬件的逻辑,比如CallbackCircularBufferEventQueue的行为。这类测试的价值在于回归速度快,改完代码几分钟内就能知道有没有破坏基础功能。

TESTS/是跑在真实硬件上的集成测试,比如 GPIO、SPI、RTC、RTOS 特性的验证。它需要配合 Greentea 工具链,把测试固件烧进板子,然后通过串口跟 PC 端的测试脚本通信。

这两套测试的定位差异非常重要。单元测试帮你守住逻辑,集成测试帮你守住硬件适配。如果你在移植一个新板子,TESTS/mbed_hal/下面的测试几乎是必跑的,它能比任何 Demo 程序都更全面地验证你的 HAL 实现是否完整。

5.2 Greentea 的串口握手与 utest 用例结构

跑集成测试时,Greentea 的工作流程大致是这样的:

  1. PC 端把编译好的测试固件下载到开发板。
  2. 开发板复位后通过串口发送特定同步字符串。
  3. Greentea 收到同步信息,开始逐条下发测试用例。
  4. 每条用例执行完,板上代码通过串口上报{{success}}{{failure}}
  5. 所有用例跑完后,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_BOARDDEVICE_I2C之类的宏,整个源码树在编译时才能决定哪些模块被编进来、哪些被排除。比如device_has里没有CAN,那 CAN 驱动代码就不会参与编译。

mbed_app.json则是用户级配置,用来覆盖默认配置。它最经典的用途是调整rtos的线程栈大小、切换网络协议栈、或者往macros里加自定义宏。我习惯把板级差异都写进mbed_app.jsontarget_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_halTESTS/mbed_drivers里的 GPIO、UART、SPI、I2C 测试全部跑一遍,确认外设工作正常,才算真正移植完成。这个环节省不得。

6.2 全新 MCU 移植:从 targets 目录复制一份照猫画虎

换全新 MCU 的移植量就大了。建议不要从零写,而是从targets/里选一个架构最接近的芯片目录复制过来,然后逐项替换。

必改项至少包括:

  • targets.json新条目:继承链、core、device_has、macros_add。
  • PinNames.hPeripheralPins.c:芯片引脚定义和 PinMap 表。
  • HAL 实现文件:gpio_api.cserial_api.cspi_api.ci2c_api.ctimer_api.cus_ticker_api.clp_ticker_api.c等。
  • 启动文件、系统时钟初始化、中断向量表。
  • 如果是带操作系统调度的芯片,还要确认 RTX5 在目标架构上的 Cortex 移植部分,比如cmsis/下的启动和上下文切换代码。

这里最难的往往不是某个外设驱动本身,而是us_tickerlp_ticker,因为 RTOS 的时间基准、事件队列的定时器都依赖它们。如果 ticker 实现有偏差,整体 RTOS 可能能编译通过,但跑起来就会出现"任务偶尔卡死""延时不准"这种玄学问题。我的排查顺序永远是先测 ticker,再测 GPIO,再拿串口打印,最后才去碰复杂外设。

还有一个容易遗漏的点:targets.json里的inherits继承关系。mbed OS 很多配置是通过继承传递的,比如芯片系列层面的device_hasmacros_add。如果新芯片不属于任何已知系列,你要把公共外设能力都补全,否则可能编译过,但某些驱动在运行时被宏剪掉,问题非常隐蔽。

写在最后的个人体会

mbed OS 这套源码架构,表面上是一堆文件夹和宏定义,内里其实是"约定优先于配置"的嵌入式设计哲学:HAL 用 C 接口定规矩,PinMap 用表驱动代替手工配置,RTOS 用 C++ 封装抹平底层差异,测试体系又把"能不能跑"变成可度量的标准。我自己在移植板卡过程中最大的感悟是,不要一上来就扎进targets/看寄存器代码,先花一个下午把hal/头文件和mbed_config.h生成逻辑搞清楚,后面所有问题都会好找很多。

另外一个小技巧:每次编译时打开mbed_config.h看一眼生成的宏,很多"莫名其妙"的编译错误其实一眼就能发现。比如DEVICE_SPI没有被定义,而你代码里偏偏用了 SPI 类,那问题多半不是语法错误,而是targets.json配置缺项。把配置和源码当做一个整体来看,mbed OS 的架构其实非常经得起推敲。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询