十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

mbed OS源码深度拆解:HAL、RTOS与驱动分层架构实战解析

mbed OS源码深度拆解:HAL、RTOS与驱动分层架构实战解析 做嵌入式做了快十年我一直有个感受芯片原厂给的外设库、各家 RTOS、五花八门的驱动代码每个单拎出来都能用但凑到一起就变成了“移植炼狱”。真正让我把 HAL、RTOS、驱动和测试这几条线一次性理清的是 Arm 的 mbed OS 源码。它不是简单的“又一个 RTOS”而是一整套从硬件抽象到工程测试的嵌入式软件体系值得任何一个和 Cortex-M、物联网终端打交道的人认真读一遍。这篇文章我会完全从源码阅读的角度把 mbed OS 里最核心的 HAL 层结构、RTX5 内核封装、驱动分层思路以及测试框架拆开讲最后附上我在实际移植和裁剪时踩过的坑。内容会比较长但每一条基本都能对应到具体源码文件和 API适合想深入理解 mbed OS或者想借鉴其架构思路去重构自己项目的开发者。1. 先搞懂 mbed OS 在嵌入式里到底站哪个位置1.1 它和裸机、FreeRTOS、Linux 的本质区别在哪先上一个总体认知mbed OS 不是一个庞大的 Linux 类系统也不是一个像 FreeRTOS 那样“只管任务调度”的迷你内核。它是面向物联网终端设备的完整软件栈跑在 Cortex-M 级别的 MCU 上内部既有 RTOS 内核RTX5又有统一的硬件抽象层HAL还包括网络协议栈、蓝牙、LoRaWAN、传感器驱动框架和一套自动化测试工具链。用大白话理解裸机程序是你在前台轮询一切FreeRTOS 给你提供了任务调度器但外设驱动还得自己适配Linux 则是完整操作系统但至少需要 MMU 和数百 MB 内存。mbed OS 处在中间——它给资源受限的 MCU 提供“类操作系统”的开发体验你写应用时不用关心底层是 STM32 还是 NXP也不用每次换芯片就把 GPIO、UART、SPI 驱动重写一遍。它在 Arm 官方的定位里有一个关键词叫“productivity”核心目的就是让 IoT 开发者把精力放在业务层而不是陷在芯片手册里。这一点我从源码布局上感触特别深所有与具体芯片相关的代码都被严格隔离在 targets 目录应用层能接触到的 API 几乎都是架构无关的。1.2 源码目录结构那些你迟早要翻的文件夹拿到 mbed-os 源码后顶层目录一眼看上去很多但真正核心的就几个。我建议按这个顺序去读hal这就是硬件抽象层对外提供 gpio_init、spi_init、i2c_init、serial_init 这类 C 接口与上层驱动解耦。rtosRTX5 内核的封装层Thread、Mutex、Queue、EventFlags、EventQueue 都在这里底层走 CMSIS-RTOS2 标准。drivers基于 hal 层 API 封装出的 C 类比如 DigitalOut、SPI、I2C、CAN、Ethernet这是应用层最常 include 的头文件。platform一些通用的基础组件CriticalSectionLock、CircularBuffer、NonCopyable 等属于“支撑设施”。targets所有官方支持的芯片平台实现比如 TARGET_STM32、TARGET_NXP、TARGET_MAX326xx 等里面是 packed 的芯片底层代码和板级管脚映射。features网络、蓝牙、LoRaWAN、Cellular 等组件属于可裁剪模块。tests 和 tools官方测试用例和构建测试工具链很多人根本不看这个目录但我强烈建议读一读这是最容易被忽略的宝藏。一个很反直觉的点应用代码中 include 的 mbed.h 入口位于 platform/mbed.h它会把 drivers、hal、rtos 的公开接口统一暴露出来。所以第一眼一定要分清“头文件暴露的接口”和“底层实现文件”是两套东西。2. HAL 层源码拆解一切驱动的底气都在这2.1 从 PinName 到 GPIO管脚映射的严谨设计HAL 层最底层的概念就是 PinName。它不是一个整数编号而是一个枚举定义了 MCU 所有可用引脚。在 STM32 平台上你打开 targets/TARGET_STM32/TARGET_STM32F1/PinNames.h会看到 PA_0、PB_1 这些定义实际数值被编码成端口号加引脚号的组合源码里通过 PORT_SHIFT 这类宏做位拆分。这种设计的好处是什么我举个例子。如果你要在 NUCLEO_F103RB 上点灯应用层只需写 DigitalOut led(LED1)LED1 这个 PinName 在板级头文件里被定义为具体某个引脚。换一块开发板时同样的应用代码不用改因为 LED1 在另一块板的 PinNames.h 里有不同映射。这就是“板级抽象”和“芯片抽象”的分层逻辑。GPIO 配置的方法也很典型。hal/gpio_api.h 里声明了 gpio_init、gpio_mode、gpio_dir、gpio_write、gpio_read。往下翻到相应 targets 里的 gpio_api.c你会发现实现的关键函数是 gpio_init它先通过 pinmap 找到引脚复用配置然后使能对应 GPIO 端口时钟设置模式、方向。这也解释了为什么 mbed OS 的 GPIO 操作比裸机寄存器写法慢一点——它每次操作都要经过结构体指针二次寻址。但换来的是跨平台一致性到底值不值看项目场景。做低延时控制强相关的还是用寄存器直写做 IoT 设备业务逻辑这个开销完全可以忽略。2.2 外设句柄与初始化流程拿 SPI 举个例子HAL 层第二个核心概念是外设句柄。看 hal/spi_api.h会看到 typedef struct spi_s spi_t这个 spi_t 的具体内容在 targets 下的 spi_device.h 中定义。每个外设的函数基本都遵循一个规律void spi_init(spi_t *obj, PinName mosi, PinName miso, PinName sclk, PinName ssel); void spi_frequency(spi_t *obj, int hz); void spi_format(spi_t *obj, int bits, int mode, int slave); int spi_master_write(spi_t *obj, int value); int spi_slave_read(spi_t *obj);注意 sp_init 的参数里直接接收的是 PinName不是寄存器地址。这意味着 HAL 层必须在初始化阶段做管脚复用查找。我读 STM32 的实现时发现它调用了 pinmap_find_peripheral 和 pinmap_merge 工具函数本质是从一个大的 PinMap 数组里搜索匹配的外设实例。这个数组在 PeripheralPins.c 里长这样const PinMap PinMap_SPI_SCLK[] { {PA_5, SPI_1, STM_PIN_DATA(STM_MODE_AF_PP, GPIO_NOPULL, GPIO_AF5_SPI1)}, {PB_3, SPI_1, STM_PIN_DATA(STM_MODE_AF_PP, GPIO_NOPULL, GPIO_AF5_SPI1)}, {NC, NC, 0} };这种“管脚-外设-功能配置”的三元组数组是整个 HAL 层的关键。搜索时如果找不到匹配项就返回 NC上层会报错或者进入断言。这也提醒我在 mbed OS 里不是任意引脚都能复用成 SPI必须看芯片手册和这份映射表。我用蓝 pillsSTM32F103C8T6跑 SPI 驱动 OLED 时曾顺手想用 PB1 做 SCLK结果初始化后总线完全无波形。后来查 PinMap_SPI_SCLK 才发现这个引脚根本不在映射表里。这个教训很直接mbed OS 的 HAL 层帮你挡掉了很多不合法配置但前提是你要先看映射表而不是看芯片引脚图随意接。2.3 HAL 层两个容易踩坑的细节第一个坑PinName 不代表“功能”只代表“物理引脚”。你把一个引脚定义为 SPI_SCLK不代表 HAL 会自动把它接到 SPI 外设的 SCLK 上。初始化时会查找 PinMap查到匹配项才会配置复用功能。第二个坑spi_frequency 不是精确值。在 STM32 平台里它会先把目标频率除以 APB 时钟得到一个分频系数然后选一个最接近的分频比。所以你想要 1MHz实际得到的可能是 1.05MHz 或者 0.96MHz。在时序敏感的器件通信上不要假设 mbed OS 给的一定是精确频率。3. RTOS 层RTX5 内核与 mbed 的上层封装3.1 内核选型为什么是 RTX5 而不是 FreeRTOSmbed OS 的 RTOS 内核是 Arm 自家的 RTX5而不是 FreeRTOS。从源码目录来看rtos/source/TARGET_CORTEX/rtx5 就是 RTX5 的完整实现对外则通过 CMSIS-RTOS2 标准接口暴露功能。选 RTX5 的理由我理解有几个层面。首先RTX5 和 Cortex-M 内核的配合最“原生”它充分利用了 SVCall、PendSV、SysTick 这些架构特性上下文切换路径短、效率高。其次RTX5 与 CMSIS-RTOS2 API 深度绑定而 CMSIS 本身就是 Arm 主导的 Cortex-M 软件标准天然适合 mbed OS 这种官方体系。第三RTX5 支持动态创建线程和内核对象内存管理灵活这对上层 C 封装很友好。很多人在 mbed OS 工程里直接用了 Thread 类但压根不知道底层是 RTX5。这其实是个好事证明了封装的透明性。但如果要做底层优化你必须了解 cmis_os2.h 提供的接口比如 osThreadNew、osMessageQueuePut、osEventFlagsSet 这些函数。RTX5 的调度机制属于典型的优先级抢占加时间片轮转。同优先级线程按时间片轮询调度高优先级线程随时可以抢占低优先级线程。这个模型和 FreeRTOS 非常像但是 RTX5 的实现更贴近 CMSIS 标准任务控制块是 osThread_t线程入口参数直接就是函数指针加 void 指针。3.2 线程、队列、信号量CMSIS-RTOS2 接口长什么样在 rtos/Thread.h 里mbed OS 对 osThreadNew 做了 C 封装。它的构造函数允许你指定栈大小、优先级比如Thread thread(osPriorityNormal, 1024, nullptr, my_thread); thread.start(callback(my_function));这套封装有个非常实用的小细节它内部会把 C 成员函数指针转成 void 指针加静态 trampoline所以你可以安全地 start 一个类的成员函数而不像裸 C 那样需要包装一层。线程间通信方面mbed OS 提供了 Queue、Mail、EventFlags、Mutex、Semaphore。我读 rtos/Queue.h 时注意到它是个“类型安全”的队列封装push 和 try_pop 都带模板类型底层实际是 osMessageQueuePut/osMessageQueueGet。这里有一个和 FreeRTOS 队列明显的差别mbed OS 的 Queue 在构造时只指定容量和元素大小不需要预留字节缓冲因为 RTX5 内部默许使用动态内存池。好处是使用方便坏处是如果堆内存碎片化严重可能创建失败。在长期运行的物联网设备上我建议保守设置堆大小或者对该类对象做持久化而不是频繁 create/delete。信号量的处理也值得注意。Semaphore 类的 acquire/release 接口做到了 RAII 友好有一个 ScopedSemaphoreLock 辅助类可以自动解锁。这种细节在裸机驱动工程师看来可能有点“杀鸡用牛刀”但在复杂业务逻辑里真的能减少很多忘记释放信号量导致的死锁。3.3 事件队列 EventQueuembed OS 最独特的机制如果说 mbed OS 的 RTOS 层里哪个机制最值得借鉴我首推 EventQueue。它本质上是一个“用户态事件循环”把中断里的耗时操作延迟到线程上下文中处理。源码在 rtos/EventQueue.h使用方式非常直白EventQueue queue(4 * EVENTS_EVENT_SIZE); queue.call_in(2000ms, some_function); queue.call_every(1000ms, periodic_function); queue.dispatch_forever();我特别推荐在带传感器采集和网络上报的 IoT 设备中使用 EventQueue 来替代裸中断或裸轮询。中断服务程序里只做置位标志或向队列投递事件真正的读取、滤波、编码、发送全部放进事件线程。这样做能大幅降低中断处理时间也天然避开了锁竞争问题。EventQueue 还有一个用于低功耗场景的特性dispatch_forever 在没有任何事件时会自动进入 idle配合 tickless 模式可以让 MCU 深度睡眠。这个机制对电池供电的设备意义非常大。但要注意如果某个事件回调里阻塞了较长操作会直接拖垮后续所有事件所以千万不要在事件回调里做长延时。3.4 RTOS 配置节拍、栈大小与内存池分配RTOS 的配置在很多工程里是“能跑就行”但真正读到 mbed OS 的配置文件后就知道这里面的每一项优化空间都很大。配置入口通常是 mbed_config.h 或 targets.json 里的 MBED_CONF_RTOS_* 选项。以节拍为例默认是 1ms 一个 tick。如果你大部分任务都是周期 10ms、50ms 级别的1ms tick 会频繁唤醒系统增加功耗和调度开销。把 OS_TICK_FREQ 调整到 500Hz 甚至 100Hz 能明显降低 idle 次数。但反过来如果业务里有 1ms 精度的延时需求就不建议动这个参数因为所有 osDelay、wait_us 的精度都依赖 tick。栈大小也是个大坑。Thread 构造函数的第二个参数是栈大小单位字节。很多人写 1024 或 2048看着不小但如果线程里有 printf、浮点运算、多层函数调用栈很容易爆。RTX5 提供了 osThreadGetStackSpace 这样的调试接口可以查剩余栈空间。我建议调试阶段给每个线程加一个周期打印栈水位线的任务实测跑一个晚上确认余量再批量收紧。内存池方面RTX5 支持静态和动态两种内核对象内存。mbed OS 很多场景默认动态内存好处是对象创建方便但要求堆空间充足。在资源紧张的 MCU 上可以把某些常驻对象改为静态定义避免运行期分配失败。4. 驱动体系从 HAL 到设备驱动的完整链路4.1 驱动类的封装思路DigitalOut 背后发生了什么drivers 目录下的类本质上是对 hal 层 C API 的 C 化封装。拿最简单的 DigitalOut 来说它的构造函数直接调用 gpio_init 和 gpio_dirwrite 方法则调用 gpio_write。但 DigitalOut 有一个 C API 没有的额外能力就是你可以在一个对象生命周期内多次改变引脚映射底层只是重复初始化。这在某些多用途引脚复用的场景很有用。不过我要提醒频繁切换引脚复用会引入时序波动如果驱动的是通信总线尽量在启动时固定好映射不要运行时反复切换。DigitalIn 与 DigitalOut 类似但重点在 input 和 mode。read 方法内部使用 gpio_read。由于 mbed OS 要求所有 GPIO 操作都要过结构体和 PinMap 查找DigitalIn 的读取延时其实比直接操作 GPIO_IDR 寄存器高不少。在做高速脉冲计数时我通常会绕过 mbed 驱动直接用寄存器操作。但这不代表封装无用。DigitalIn 配合 InterruptIn 做按键、编码器、沿检测体验很好尤其是有 debounce 需求的场景可以直接用 Timeout 或 EventQueue 做消抖代码结构比裸机轮询清晰得多。4.2 复杂外设驱动I2C、SPI、Ethernet 的分层实现再复杂一点的外设比如 I2C 和 SPI驱动类的实现逻辑会更加完整。SPI 类构造时接收 mosi、miso、sclk、ssel 四个引脚内部调用 spi_init 做初始化然后 spi_format 设置位宽和模式spi_frequency 设置频率。write 方法就是 spi_master_write。读 mbed-os/drivers/SPI.h 时你会发现它还有一个高阶特性支持 DMA 传输接口。这就是 HAL 层 spi_api.h 里 spi_master_transfer 这类异步接口的 C 封装能让 SPI 收发不阻塞 CPU。实际项目中如果外设传输速率要求高比如驱动音频编解码器或显示屏建议用 DMA 方式。而 Ethernet 相关的驱动属于 features/netsocket 或 drivers/Ethernet 接口。它依赖 HAL 层的 ethernet_api.h实现通常比较“重”需要完整的 MAC 收发描述符管理。在 mbed OS 6 以后官方把更多网络重心放到了 Wi-Fi、蜂窝和 LoRaWAN 组件上裸 Ethernet 驱动的 API 反而变动不大。4.3 新板级驱动的移植路径如果你不是用官方开发板而是自研 PCBmbed OS 驱动体系的移植路径其实很清晰。核心三件事提供 PinNames.h、PeripheralPins.c 和 targets.json 片段。首先要定义你板子上所有功能引脚的 PinName 别名比如把板载 LED 映射到某个 GPIO把 I2C 引脚连到对应 SCL/SDA 引脚。其次是 PeripheralPins.c 里补充外设和引脚的映射关系确保 HAL 层初始化器能找到对应外设。最后在 targets.json 里声明 device_has、labels 和 features告诉 mbed 构建系统这个平台支持哪些外设和组件。很多工程师移植失败的原因只有一个只改了 PinNames.h 却没动 PeripheralPins.c导致 SPI/I2C 无法初始化。这俩是配套的缺一个都不行。5. 测试体系源码里那套被很多人忽略的工程质量5.1 单元测试与 utest 框架mbed OS 源码里包含了一套相当完善的测试体系位置在 tests 目录配合 tools 下的构建脚本使用。其中最核心的是 utest 框架它是在 mbed OS 内部实现的轻量级 C 单元测试套件设计上和 JUnit 非常类似。写一个测试用例并不复杂你只需要构造一个 utest::v1::Case 数组和 Specification 对象#include utest/utest.h #include mbed.h using namespace utest::v1; static void test_digitalout_basic() { DigitalOut led(LED1); led 1; TEST_ASSERT_EQUAL(1, led.read()); led 0; TEST_ASSERT_EQUAL(0, led.read()); } Case cases[] { Case(DigitalOut basic write/read, test_digitalout_basic), }; Specification specification(cases); HARNESS_START这段测试会在开发板上真实执行不仅仅是 PC 端的模拟。这意味着你可以在没有逻辑分析仪的情况下快速验证一个外设驱动是否基本工作——测试框架会通过串口把断言结果返回给主机。实际跑过之后你会发现utest 的断言能力很齐全TEST_ASSERT_TRUE、TEST_ASSERT_EQUAL、TEST_ASSERT_FLOAT_WITHIN 等。最有用的是 TEST_ASSERT_FLOAT_WITHIN可以指定误差范围比较浮点数值特别适合验证校准算法、编码器滤波数据的精度。5.2 Greentea 硬件在环测试Greentea 是 mbed OS 的自动化测试 runner本质上是一个 Python 工具位于 tools/greentea.py。它通过串口和开发板通信自动烧录固件、执行测试、收集结果。整套流程是这样的先在开发板上跑一个支持 Greentea 协议的测试固件代码里调 HARNESS_START、HARNESS_COMPLETE 来通知主机测试开始与结束主机端用 mbedgt、mbedhtrun 等工具协调串口交互最后生成与测试用例一一对应的 PASS/FAIL 报告。我第一次用 Greentea 跑官方 tests-mbed_hal 和 tests-mbed_drivers 时最大的收获不是“驱动没问题”这个结论而是看到了官方测试设计方法。每个外设测试都从最简单的 loopback 做起然后逐步增加边界条件。比如 UART 测试会先后测单字节回环、大块数据回环、奇偶校验错误等非常成体系。后来我给自己写的驱动补测试时完全沿用了这套思路。5.3 测试目录怎么读才有收获想从测试源码里学到东西我建议读这几个目录tests/mbed_halHAL 层的验证用例能帮你搞懂每个 API 的边界行为比如哪些参数会导致断言。tests/mbed_drivers驱动类的高层测试注意看它如何造 test setup、如何处理超时、如何恢复现场。tests/network网络组件的集成测试能学到如何处理依赖外部环境的稳定性测试。读测试代码并不是为了跑一遍而是观察官方如何设计测试 fixture 和用例边界。很多嵌入式工程师对“测试”的理解只停留在用示波器看波形但 mbed OS 这种“可自动化的硬件测试”理念值得借鉴到任何嵌入式项目中。6. 移植与裁剪经验从源码到能跑通的最小系统6.1 最小系统到底需要哪些目录很多刚接触 mbed OS 的人以为只能用官方 IDE 或 mbed CLI 才能编译其实它的源码完全可以放进 CMake、Keil、IAR 中构建。我在一个 STM32F103C8T6 的自研板子上做过最小系统发现真正需要的目录其实不复杂targets必须有因为芯片和板级定义在这里。hal、rtos、drivers、platform核心必须完整。features如果不用网络和蓝牙可以裁剪掉能省很大空间。components官方第三方组件可选。裁剪的核心手段是配置宏。构建系统会根据 targets.json 里的 labels 自动启用或禁用某些功能。比如你要禁用网络可以在自定义 targets.json 片段里不引入 netsocket 相关 feature也可以在 mbed_app.json 中显式配置关闭。注意裁剪不彻底最常见的现象是链接时出现 undefined reference to network stack 相关符号这时回溯配置你就能发现问题。6.2 典型报错与排查思路我在移植时遇到过几种非常典型的坑值得记下来第一类链接错误“undefined reference to spi_master_write”之类。这通常是 target 选择不对或者 hal 层文件没被包含进编译。排查思路是先从构建日志确认当前 target 是否是你想要的板卡再确认 hal 目录里是否存在对应外设的源文件。第二类“PinNames.h: No such file or directory”。这是 target 宏没传到编译器导致的检查构建系统是否把 TARGET_STM32F103C8 这类宏加入了 -D 参数。第三类SysTick_Handler 冲突。mbed OS 的 RTOS 需要占用 SysTick如果你在自己的启动文件或驱动里又定义了一个 SysTick_Handler编译链接就会出现多重定义或直接卡死。排查方法是搜工程里所有 SysTick_Handler 的定义删掉用户侧或改用其它定时器。第四类线程栈溢出。现象很隐蔽可能是运行几小时后随机崩溃。建议调试期在 main 里开一个低位优先级 watchdog 线程周期打印各线程剩余栈空间用数据定位。我在实际移植中还有一个强烈的体会mbed OS 的构建系统对路径大小写敏感在 Windows/Linux 之间跨平台传输代码要格外小心。因为 MCU 源码里的 include 路径经常混合大小写比如 TARGET_STM32 和 target_stm32 在某些文件系统下就不是同一个路径。这类错误很浪费时间但排查起来也最简单——先查找文件名和引用名的大小写是否一致。6.3 关于工具链的一点建议mbed OS 官方长期支持 Arm Compiler 5 和 6以及 GCC Arm。我在老工程里见过不少人死守 ARM Compiler 5.06因为老外设库和启动文件是基于 AC5 写的。但从源码角度看mbed OS 新版本对 AC5 的兼容性正在弱化如果你要跟上游同步建议尽早切到 AC6 或 GCC ARM。切工具链后最常见的坑是编译参数差异比如 AC5 的 --split_sections 对应 AC6 的 -ffunction-sections优化等级和警告选项也有差异。我自己默认使用 GCC ARM CMake 管理 mbed OS 工程因为灵活性和开源生态更好。遇到问题时可以直接翻阅源码也用 gdb/OpenOCD 调试比 IDE 黑盒更可控。最后再分享一个小技巧。读 mbed OS 源码时不要从 rtx5 内核开始啃而是先跑一个最小点灯程序然后从 main 调用的 DigitalOut、Thread 一步步往底层追看到哪个函数就想“这一步到底在干什么它的输入输出是什么”几轮下来整条链路就通了。我自己也是按这个顺序才把 HAL、RTOS、驱动、测试这几块真正串成了一个整体。源码是最好的老师但前提是你知道从哪里翻起而不是一头扎进最复杂的底层中断代码里。
返回列表