ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

mbed OS源码解析:从HAL到RTOS的嵌入式代码组织路径

mbed OS源码解析:从HAL到RTOS的嵌入式代码组织路径 我拿到 mbed OS 源码的第一反应其实不是“好用”而是“这仓库怎么这么大”。和平时那种只有几个 .c 文件的裸机工程完全两个物种:hal、drivers、rtos、platform、connectivity、storage、targets、tools一层套一层。很多人文档看了半天最后还是不知道一个printf和一个DigitalOut led(PA_5)到底经过了哪些代码。这篇文章我想把自己理 mbed OS 源码的路径完整讲一遍重点放在 HAL、RTOS、驱动、测试这条主线上适合已经有 STM32 基础、或者玩过其它 RTOS想真正看懂一块嵌入式系统代码是怎么组织的朋友。你会看到为什么接口要分层、引脚映射是怎么查表的、RTOS 封装层为什么要套一层 C以及官方测试体系到底能帮你省多少事。1. 从 mbed-os 仓库结构看整体分层先搞清楚谁在管什么1.1 顶层目录的“身份标签”drivers、platform、hal、rtos 各自管什么先把源码解压出来顶层目录里最核心的其实就四块hal、drivers、rtos、platform剩下的connectivity、storage、features、components属于外挂能力可以后看。这四块的分工非常像一家公司的前中后台hal是硬件抽象层全部是 C 接口。它只负责告诉你“有这么个函数可以用”比如gpio_init()、spi_init()、i2c_init()但完全不关心芯片是 ST 还是 NXP。drivers是给应用开发者用的 C 封装。你平时写的DigitalOut、SPI、I2C、UnbufferedSerial都在这里。它做的事很薄基本就是把 HAL 的 C 函数包一层再加点类型安全。rtos是实时操作系统的 C 封装。底层用的是 CMSIS-RTOS2 和 RTX5 内核rtos目录里是Thread、Mutex、Semaphore、Queue、EventFlags这些面向用户的类。platform是跨芯片的公共基础像 critical section、sleep manager、非易失性存储抽象、错误处理都在这里。这里必须先澄清一件事mbed OS 说的 HAL和 ST 的 HAL 库不是同一个东西。ST 的 HAL 库是专门给 STM32 写的固件库而 mbed OS 的 HAL 是一套接口契约同一套函数名每个芯片厂商各自实现一套。这个区别理不清后面看源码很容易被绕晕。1.2 targets 目录才是真正的硬件真相很多人在 mbed-os 根目录翻遍了hal目录却找不到gpio_init的实现原因很简单HAL 接口统一放hal具体实现全部下沉到targets目录。拿 STM32 举例路径大概是targets/TARGET_STM/TARGET_STM32F4/TARGET_STM32F401xE/在这个芯片目录下你会看到PinNames.h、PeripheralPins.c、device.h、system_clock.c以及一串spi_api.c、i2c_api.c、gpio_api.c之类的文件这些才是真正操作寄存器的地方。为什么 mbed OS 不把实现集中在hal目录里因为每家芯片的寄存器完全不一样。如果集中放一个gpio_init里就要堆满#if defined(TARGET_STM32F4) ... #elif defined(TARGET_NXP) ...维护成本会爆炸。分散到各 target 之后同一个函数在不同编译任务里只会编译其中一份从源码层面就做到了隔离。这块布局理解透了看任何驱动代码都不慌先看接口头文件再去targets下找实现。1.3 targets.json 与裁剪配置源码树如何与具体芯片绑定targets目录下还有一堆.json文件比如targets.json。target 之间是有继承关系的比如TARGET_NUCLEO_F401RE会继承TARGET_STM32F401RE后者再继承TARGET_STM32F4再继承TARGET_STM。每次构建时mbed 工具链都会根据目标板型号把这条继承链上的源文件、宏定义、链接脚本全部收集起来。这里想提醒一个常见误区你以为改了某个.c文件重新编译就完事但如果你改的是targets.json里宏定义比如新加了DEVICE_SPI构建系统不一定能立刻感知。很多时候诡异的编译错误和链接错误都是因为配置缓存和源码文件不同步。遇到这种情况先 clean 再 build 往往比研究半天报错信息更有效。2. HAL 层源码拆解引脚映射、外设实例与“接口/实现分离”的协作方式2.1 hal 目录中的接口契约hal目录下能看到很多_api.h头文件它们是 HAL 层的“合同”。把常见接口头文件整理一下大概是这样头文件对应外设常见函数gpio_api.hGPIOgpio_init、gpio_write、gpio_read、gpio_irqserial_api.h / uart_api.h串口serial_init、serial_putc、serial_getcspi_api.hSPIspi_init、spi_master_write、spi_slave_readi2c_api.hI2Ci2c_init、i2c_write、i2c_readpwmout_api.hPWMpwmout_init、pwmout_period、pwmout_writeanalogin_api.hADCanalogin_init、analogin_read_u16us_ticker_api.h / lp_ticker_api.h时间基准us_ticker_init、us_ticker_read这些接口函数的参数和返回值基本都是枚举或者结构体指针比如PinName、gpio_t *。底层具体实现什么结构、寄存器长什么样上层一概不管。这就是接口/实现分离最舒服的地方应用代码只认PinName而PinName可以理解为“逻辑引脚编号”它到底对应芯片哪个 GPIO 端口和引脚由每个 target 的PinNames.h定义。2.2 PinNames.h 和 PeripheralPins.c为什么改名和映射要一起来读 HAL 层源码时我最建议先看PinNames.h和PeripheralPins.c一个是引脚枚举一个是外设复用映射表。PinNames.h里定义的东西类似typedef enum { PA_0 0x00, PA_1 0x01, ... PB_7 0x07 0x10, ... NC (int)0xFFFFFFFF } PinName;它把芯片每个引脚编成一个逻辑编号。为什么不用GPIOA、GPIOB这种寄存器视角因为当你写一个通用驱动时你不希望关心PA_5在 STM32 上是 GPIOA 的第 5 脚还是别家的什么端口。枚举值统一之后上层代码就可以写DigitalOut led(PA_5)而不用管平台。PeripheralPins.c才是真正决定“这个引脚能不能干活”的地方。以 I2C 的 SDA 为例典型表项长这样const PinMap PinMap_I2C_SDA[] { {PB_9, I2C_1, STM_PIN_DATA(STM_MODE_AF_OD, GPIO_NOPULL, GPIO_AF4_I2C1)}, {PB_11, I2C_1, STM_PIN_DATA(STM_MODE_AF_OD, GPIO_NOPULL, GPIO_AF4_I2C1)}, ... {NC, NC, 0} };每一项包含三个信息第一个是逻辑引脚PinName第二个是外设实例编号I2C_1第三个是外设模式配置开漏、上下拉、复用功能编号。i2c_init()拿到你传入的 SDA 和 SCL 引脚后会去查这张表找到对应外设实例和引脚配置再去初始化寄存器。所以如果你要换引脚绝对不能只改应用层代码比如从I2C构造函数里换一个引脚名。你得先确认这个引脚在PeripheralPins.c里有没有对应的表项没有的话驱动初始化时查不到映射表现为外设完全不起作用不是短路就是乱码。修改PinNames.h和修改PeripheralPins.c必须当成一次整体操作来做。2.3 我在移植中踩过的 HAL 坑从 GPIO 回调到 UART 引脚冲突看源码和自己移植完全是两种体验。先说一个我踩过的特别典型的坑有次把板子从现有平台移植到另一个 MCU 上跑串口打印时总是一堆乱码。我第一反应是波特率配置不对调了半天后来发现是PeripheralPins.c里 UART_TX 和板上 LED 复用了同一个引脚。GPIO 初始化时把这个引脚配置成了推挽输出等于把串口 TX 信号硬生生拉成电平输出波特率再对也是乱的。从那以后我移植的第一步就是逐个对照板子原理图把引脚冲突排除干净。另一个坑是关于 GPIO 中断回调。gpio_irq的回调触发时程序跑在中断上下文不是普通线程。很多人以为回调里可以随便做耗时操作结果在回调里读传感器、加延时整个系统就卡死了。真要用中断去驱动复杂逻辑正确的做法是回调里只放一个标志位或者触发EventFlags把耗时工作丢给 RTOS 线程。这个模式后面讲 RTOS 部分会继续展开。HAL 层还有一个需要心理准备的点它不是万能的权限检查器。多个驱动打开同一个外设实例、同一个引脚它不会在编译期报错很多时候运行期行为也很诡异。所以看 HAL 源码时你真正要学会的不是背函数而是建立“引脚-外设映射-寄存器配置”这条完整的排查链路。3. RTOS 层源码拆解CMSIS-RTOS2 封装与 Thread、Queue 的底层逻辑3.1 为什么 mbed 不直接暴露 RTX5 而是套一层 C 封装mbed OS 的实时内核底层是 RTX5代码在cmsis/RTOS2/RTX/Source里但它面向用户的是rtos目录这套 C 封装。为什么要多此一举核心原因是资源管理。直接用 C API 写 RTOS 代码你得记得每次创建线程、信号量、互斥量之后去释放一旦有路径提前 return资源就泄漏了。C 封装通过构造函数和析构函数把这些事情包住对象出了作用域自动释放异常路径上也不会漏。比如Mutex析构时自动释放底层内核对象这种 RAII 理念对嵌入式开发其实帮助很大。但封装层也有代价它会遮住一些底层细节比如线程栈到底是用户传入还是内核动态分配、优先级映射规则是什么。所以我的建议是用的时候先按面向对象的路子写遇到性能问题或者调度异常再往底层 RTX5 源码里钻。3.2 调度器、时隙轮转与优先级看实现更容易理解行为rtos既然是封装那调度逻辑一定在底层。RTX5 是典型的优先级抢占式调度器高优先级线程就绪时会立刻抢占低优先级线程同优先级线程之间靠时间片轮转也就是每个线程最多跑一个时间片然后被切走。mbed OS 里Thread的默认优先级是osPriorityNormal。如果你在应用里不设置优先级两个线程都是 Normal那它们会按时间片轮转。源码里osRtxThreadDispatch这类函数就是找“当前最高优先级的就绪线程”。理解它之后很多莫名奇妙的运行现象都能解释比如一个高优先级线程里写了死循环低优先级线程永远跑不到再比如两个线程同优先级互相不放弃 CPU系统看起来“卡住”其实是被时间片轮转切出了假象。时间基准这一块也要注意。mbed OS 的时间基准最终要到 ticker也就是 HAL 层us_ticker_api.h/lp_ticker_api.h心跳。RTOS 的 tick 驱动和这些 ticker 是强相关的。如果你的板子us_ticker初始化有问题RTOS 调度会立刻翻车现象通常是任务不切换或者系统时间不走。这个故障点新手基本不太会往硬件抽象层去想。3.3 从 Queue、EventFlags 源码看同步原语怎么用先说线程间常用的QueueT, N。它底层是对osMessageQueue的封装。基本用法是这样Queueint, 8 queue; // 生产者线程 int value read_sensor(); queue.put(value, 0); // 消费者线程 int received; queue.get(received, osWaitForever);这里的重点在中断上下文。ISR 里不能调用阻塞型get()但可以调用put()的非阻塞版本通过队列把数据交给线程处理这样中断入口只做拷贝入队耗时极短。EventFlags更适合做“状态通知”。比如一个传感器在数据准备好的时候拉高中断引脚你在中断回调里执行flags.set(0x01)业务线程阻塞在flags.wait_all(0x01)上事件一到就继续跑。看 mbed 源码你会发现wait_all和wait_any的区别其实是底层osEventFlagsWait的参数差异。多线程等待同一个事件标志时只会唤醒其中一个线程这个细节不读源码很容易忽略。我自己在驱动设计里最常用的一套组合就是InterruptIn触发中断回调回调里EventFlags::set业务线程event.wait_all后去做 I2C 或 SPI 读取。中断上下文永远只做最轻量的事既安全又不会把系统拖慢。4. 驱动层源码拆解从 DigitalOut 到 SPIC 类如何一步步落到寄存器4.1 drivers 目录里的 C 封装与 HAL C 函数的分工drivers目录下有很多头文件DigitalOut.h、DigitalIn.h、SPI.h、I2C.h、PwmOut.h、UnbufferedSerial.h。这些类做的事非常薄:本质上就是构造时调用 HAL 初始化函数析构时恢复默认状态操作时调用 HAL 的 C 函数把值写进寄存器。以DigitalOut为例它的构造函数大致会调gpio_init(_gpio, pin, PIN_OUTPUT)。HAL 层拿到PinName后再去targets下找到那个芯片的实现最终操作GPIOx-MODER、GPIOx-ODR这些寄存器。所以一条led 1的语句看起来简单背后是“用户类 - HAL 接口 - target 实现 - 寄存器”这条完整链路。这里顺便解释一下为什么 mbed OS 不直接让你写寄存器。直接写寄存器当然更快但你换一块板子、换一颗芯片代码就废了。封装层牺牲了一点直接性换来的是跨平台的可移植性。在工程上这个取舍绝对是划算的。4.2 一个驱动的完整调用链DigitalOut、SPI、I2C 的实例我给三个最常见的驱动画出调用链读者以后看源码可以照着这个思路去追。DigitalOutDigitalOut led(PA_5); led 1;调用链是DigitalOut::operator-gpio_write-targets/.../gpio_api.c里写GPIO_WriteBit- 寄存器。SPISPI spi(SPI_MOSI, SPI_MISO, SPI_SCK); spi.write(0x55);构造函数里SPI::SPI会调spi_init在 target 实现里会查PinMap_SPI_MOSI、PinMap_SPI_SCK得到外设实例编号和引脚配置然后设置波特率、位宽等。write(0x55)最终调spi_master_write再往下就是具体外设寄存器的发送过程。I2CI2C i2c(I2C_SDA, I2C_SCL); i2c.write(addr 1, data, 1);I2C构造函数里调i2c_inittarget 实现会查PinMap_I2C_SDA和PinMap_I2C_SCL两张表。这里的表查不到I2C 就不工作。这三条链路的核心模式完全一致构造时查表运行时写寄存器。读源码的时候不用每个寄存器都记住只要知道它是从哪张映射表引出来的、外设实例是哪个就已经够了。真到调寄存器细节的时候再打开芯片参考手册对照着看。4.3 自己写一个传感器驱动扩展模块组合优于继承mbed OS 的drivers类并不太适合做继承扩展。很多类的成员变量是protected或private继承会把耦合度拉高。如果我要给一块板子添加一个新传感器驱动我更推荐组合方式驱动类内部持有 I2C 或 SPI 对象业务逻辑全都放在自己类里。一个典型的传感器驱动骨架长这样class MySensor { public: MySensor(I2C i2c, PinName intr_pin) : _i2c(i2c), _intr(intr_pin) {} bool init() { // 写传感器配置寄存器 _intr.rise(callback(this, MySensor::_on_interrupt)); return true; } int read() { // I2C 读数据 } private: void _on_interrupt() { _event.set(EVENT_DATA_READY); } private: I2C _i2c; InterruptIn _intr; EventFlags _event; };构造时传入 I2C 引用而不是自己创建一个 I2C 对象这样多个传感器驱动可以共享同一条 I2C 总线。中断引脚触发_on_interrupt后只置事件标志真正的读取工作由业务线程在_event.wait_all(EVENT_DATA_READY)之后再做。这个结构把“HAL 层引脚操作”、“驱动层协议封装”、“RTOS 层线程同步”三件事全部串起来了也是我在实际项目中用到最多的一种驱动组织方式。5. 测试体系源码拆解Greentea、utest、UNITTESTS 的三层测试策略5.1 TESTS 目录与 Greentea 的集成测试mbed OS 源码里能看到TESTS目录按组件分门别类放着大量集成测试用例比如TESTS/drivers、TESTS/rtos、TESTS/events。这些测试不能跑在电脑上必须烧录到目标板通过串口与主机通信由 Greentea 测试工具来驱动执行。集成测试用例基于utest框架编写。一个基本用例结构如下#include utest/utest.h #include unity/unity.h static void test_led_on() { DigitalOut led(PA_5); led 1; TEST_ASSERT_TRUE(led 1); } utest::v1::status_t greentea_setup(const size_t number_of_cases) { return greentea_test_setup_handler(number_of_cases); } Case cases[] { Case(LED on, test_led_on), }; utest::v1::Specification specification(greentea_setup, cases); int main() { return !Harness::run(specification); }Case注册测试函数Harness::run启动测试测试结果通过串口发回给 Greentea。Greentea 的职责是编译、烧录、复位目标板然后监听串口输出判断 PASS/FAIL。这套体系最大的价值是回归你改了 HAL 层一个映射跑一遍官方测试就能发现有没有把别的功能干坏比自己写测试省太多事。5.2 UNITTESTS 与 CMake/GoogleTest 的宿主机单测和集成测试相对mbed OS 还提供了UNITTESTS目录这套测试用 CMake 构建配合 GoogleTest 在宿主机上直接跑不需要任何硬件。为什么纯逻辑代码能在 PC 上跑因为被测对象是平台无关的那部分比如队列封装、事件标志、存储抽象、字符串处理。凡是涉及到 HAL 寄存器的部分UNITTESTS 里准备了很多 stub链接时把这些依赖替换掉。一个典型的运行流程是cd UNITTESTS cmake -S . -B build -DCMAKE_BUILD_TYPEDebug cmake --build build ctest --test-dir build我在项目里跑过一次之后发现单测的价值主要在预防低级回归。比如有人在platform层改了内存池分配逻辑如果没有单测这个问题要等到上板跑才能暴露有单测的话几秒钟就能发现。但必须清醒一点宿主单测通过不代表硬件功能正常。寄存器时序这种东西单测是永远覆盖不到的还是得靠集成测试和真机验证。5.3 用官方测试来定位驱动问题一次实际排查说一个我记忆比较深的排查过程。当时一块板子的 I2C 传感器读数时好时坏我先怀疑是代码问题毕竟那个传感器驱动是同事写的看了半天也没看出毛病。后来我把官方TESTS/drivers里的 I2C 测试用例编译烧进去跑完之后发现有两个 case 失败而且失败项都集中在这块板子新改过的引脚上。顺着测试失败信息再回PeripheralPins.c查发现这两个引脚被两张映射表同时占用SPI_SCK 和 I2C_SCL 撞了复用配置互相覆盖。这个经历给我的启发是调试驱动问题时不要一头扎进自己的应用代码先用官方测试缩小范围。官方测试跑过说明 HAL 层没问题问题大概率在应用层官方测试挂掉那就去查 target 配置和引脚冲突。这套逻辑比盲打printf高效得多。运行 Greentea 的时候也有个细节它会和串口做握手同步如果你的调试串口还兼着打印输出可能会干扰测试协议。我的经验是先用串口助手手动把测试固件跑一遍看有没有完整的 PASS/FAIL 输出再用 Greentea 自动化执行这样能省很多莫名其妙的对齐问题。6. 构建系统与源码二次开发从 mbed-cli 到 CMake工具链选择的实际经验6.1 Mbed CLI 与 detached 构建源码树如何被“摊平”mbed OS 的工程分为 mbed-os 库和用户工程两块。用mbed-cli或 Mbed Studio 创建项目后mbed-os 通常以子模块或符号链接方式出现在项目根目录。编译时构建系统会根据目标板配置读取targets.json和mbed_app.json生成mbed_config.h然后把选中的源文件编进最终镜像。这里最容易踩的坑是分布式构建缓存。你改了hal目录下的某个.c文件理论上增量编译就能搞定但改了targets.json里的宏定义后构建系统有时不会重新生成配置导致你看着源码改了、新功能却没编进去。所以我现在养成了一个习惯凡是改了 target 配置直接mbed compile --clean重新整一遍虽然多花几分钟但至少不会出现“我明明改了为什么不行”的自我怀疑。6.2 编译器和调试工具选型ARM Compiler 5/6 与 GCC ARM源码里大量宏都是针对编译器差异写的比如__GNUC__、__ARMCC_VERSION、__clang__。很多老工程还在用 ARM Compiler 5尤其是那些依赖 microlib 瘦身内存的场景。在网络上也经常看到有人在找 ARM Compiler 5.06u7 的安装包多半就是为了维护老项目。ARM Compiler 5 和 6 在 C 标准支持、内联行为、对齐规则上有不少差异导致同一份源码在这两个编译器下的行为可能不一样。编译器适合场景注意事项ARM Compiler 5 (armcc)老项目、依赖 microlib已停止更新新芯片支持有限ARM Compiler 6 (armclang)ARM 官方推荐的新项目标准支持更好但老代码可能需要微调GCC ARM开源工具链调试灵活免费、社区资源多部分厂商示例工程支持不佳我的建议很简单新项目默认 GCC ARM 或者 ARMClang 6除非有硬性依赖否则不要为了“熟悉”去选 ARMCC5。ARMCC5 的优势是历史包袱小但不代表它适合新项目。工具链选择的本质是看芯片 SDK 和现有工程依赖而不是单纯追新。6.3 二次开发必踩的坑与我的最后建议看了这么多源码真正动起手来有几个坑是绕不开的第一个是外设宏没开。你明明调了SPI但targets.json里没有DEVICE_SPI结果就是链接报错或者编译出来的固件根本不包含 SPI 实现。这个问题排查起来很快但第一次遇到往往会困惑半天。第二个是线程栈不够。RTOS 里线程默认栈可能只有几 KB你在线程里定义一个大的局部数组或者调用一个递归函数运行起来就会随机 hardfault。这个极其折磨人因为故障复现不稳定。后来我学乖了线程里大缓冲一律放到全局或者堆栈只留控制流变量。第三个是 ISR 里做printf。中断上下文里调用串口输出底层可能会阻塞导致系统调度卡死。这个我在给驱动加调试打印时踩过打印一多系统直接假死。第四个是修改 HAL 接口影响面太大。HAL 头文件是跨 target 的契约你改了一个函数签名所有芯片的实现都要跟着改否则其它 target 编译直接失败。所以正常的做法是尽量不要改 HAL 接口本身而是新增接口或者在驱动层做适配。最后我分享一点个人经验带人读这套源码时我的路线从来都是“先跑测试再读映射然后追调度”。先在板子上把官方的驱动测试跑通建立信心再去看PeripheralPins.c和PinNames.h理解引脚和外设怎么绑定最后才去追rtos里调度和同步原语。这套顺序下来不会在细节里迷失也最容易把源码和实际行为对上。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进