
1. mbed OS 的可移植性促使我决定读源码有一个场景我印象很深一台 STM32F4 开发板上的 mbed OS 工程在只改目标平台名称和少量引脚宏的情况下直接跑到了另一颗完全不同厂商的 ARM Cortex-M 芯片上。串口、按键、传感器读取、OLED 显示这些模块几乎没有改动。这种跨平台体验在传统裸机工程里很难想象也正是那一刻我意识到自己一直把 mbed OS 当成一个“黑盒”在用。于是我做了一个决定把它的源码从头到尾梳理一遍搞清楚 HAL、RTOS、驱动框架和测试体系这几层到底是怎么配合的。很多嵌入式开发者第一次接触 mbed OS 时会被它的目录体积吓到。源码动辄几千个文件里面有底层 CMSIS 头文件、各种厂商 SDK、网络协议栈、文件系统还有一整套测试工具链。如果一头扎进去确实容易迷路。但换个角度想mbed OS 本质上就是一个结构化非常清晰的物联网操作系统它的所有源码都在回答一个问题如何让上层的应用代码和具体的芯片平台解耦。这篇文章是我读 mbed OS 源码架构时的笔记和踩坑记录重点围绕四条主线展开第一HAL 层如何屏蔽芯片差异第二RTOS 层如何基于 RTX5 提供线程、事件等能力第三驱动层如何把 GPIO、I2C、SPI、UART 这些东西封装成统一的 C 接口第四Greentea 和 utest 组成的测试体系如何保证这套代码在各种开发板上真正可用。如果你正准备基于 mbed OS 做产品开发或者想要理解一个工业级嵌入式 RTOS 的分层思路又或者在学习 ARM Cortex-M 平台时对“架构抽象”这件事感到好奇这篇内容应该能给你提供一条完整的地图。2. 从根目录开始先建立一张源码地图2.1 顶层目录谁在真正参与你的程序读 mbed OS 源码我不建议直接从某个具体文件开始而是先在根目录待一会儿把顶层目录之间的职责边界理清楚。以 mbed OS 6 为例开源仓库常见的顶层目录包括这些mbed-os/ ├── targets/ # 各厂商芯片平台包含芯片 SDK、链接脚本、目标 JSON 配置 ├── hal/ # HAL 层 C API 头文件与公共实现 ├── platform/ # 与平台无关的基础组件错误处理、回调、池、工具类 ├── drivers/ # C 驱动类DigitalOut、SPI、I2C、Serial 等 ├── rtos/ # 基于 CMSIS-RTOS v2 的 C 封装Thread、Mutex、Semaphore ├── events/ # EventQueue 事件队列 ├── connectivity/ # 网络协议栈、蓝牙、LoRa 等连接能力 ├── storage/ # BlockDevice、文件系统等存储抽象 ├── components/ # 一些无线模组、传感器、显示组件等 ├── tools/ # 构建脚本、测试工具、打包工具 └── UNITTESTS/ # 宿主机器上的单元测试刚开始看这个目录结构时我容易把hal和drivers混淆。后来总结出一个很实用的判断方法hal里几乎全是 C 接口并且每个函数的第一印象都特别“底层”比如直接操作寄存器、引脚、波特率drivers里则全是 C 类是给应用工程师用的比如mbed::DigitalOut led(PA_5)。两者之间是严格的调用关系驱动层最终会落到 HAL 层。2.2 targets 和宏开关编译时怎么知道该编哪份代码mbed OS 源码中大量文件是通过编译器宏来选择和裁剪的。在targets/targets.json里每个目标平台都有一段配置里面声明了这个芯片支持哪些外设例如是否支持低功耗 ticker、是否有真随机数发生器、SPI 控制器数量是多少。构建系统读取这些信息后会决定把哪些 HAL 实现文件和驱动代码拉进编译同时把对应的DEVICE_xxx宏喂给编译器。这一点对读源码非常重要。很多人在源码里搜索一个函数却搜到好几份实现然后不知道编译器选了哪一个。其实答案基本都在 targets 配置和构建脚本里。比如你看到DEVICE_USBDEVICE、DEVICE_ANALOGIN这类宏它们本质上就是“当前这颗芯片有没有这个外设、要不要编译这一类驱动”的开关。读代码时建议先打开目标平台的 JSON 配置看看device_has里列了哪些外设能力再去找对应的 HAL 实现。这种“从配置开始追代码”的方式比随机点开文件要高效得多。3. HAL 层拆解换芯片不换应用的关键边界3.1 HAL 层到底在解决什么矛盾HAL 全称 Hardware Abstraction Layer中文一般叫硬件抽象层。mbed OS 之所以能支持几十个系列、几百块开发板核心依赖就是这一层。可以打个比方你家里的电器插头有标准不同国家供电电压不同但插座接口统一电器就能通用。HAL 做的事就是定义出一套“嵌入式外设接口标准”让上层驱动不再关心芯片的寄存器细节。STM32 的串口寄存器布局和 NXP 的芯片完全不同但只要它们都实现了 mbed HAL 规定的函数上层mbed::UnbufferedSerial的代码就完全不用改。这套标准里包含大量 C 函数头文件集中在hal/目录下。常见的几组 API 包括gpio_api.h初始化引脚、设置方向、读写电平serial_api.h串口初始化、波特率设置、收发单个字节spi_api.hSPI 主机和从机收发i2c_api.hI2C 起始停止、读写字节pwmout_api.hPWM 输出频率和占空比analogin_api.hADC 单通道采样在 mbed OS 里这些 API 名字基本都是统一的外设_动作格式。比如serial_init、gpio_write、i2c_write。你可以在hal/目录下看到它们的函数声明但真正的 C 实现文件是按厂商和芯片分目录放的路径一般长这样targets/TARGET_STM/TARGET_STM32F4/TARGET_STM32F407xG/ hal/serial_api.c hal/gpio_api.c hal/spi_api.c也就是说同一个serial_init函数在不同芯片目录下有不同实现。编译器在构建时会根据 targets 配置只选其中一份参与链接。3.2 从一个串口函数看 HAL 的调用链讲一个具体例子。应用层如果用mbed::UnbufferedSerial最终初始化串口时会走到 HAL 的serial_init。函数签名长这样void serial_init(serial_t *obj, PinName tx, PinName rx);这个函数要干的事很纯粹根据传入的tx、rx引脚名找到对应的 UART 外设编号配置引脚复用功能把引脚连接到串口控制器初始化串口寄存器设置默认参数把外设句柄和对象指针关联起来。在芯片厂商的 SDK 里可能只需要几句寄存器操作。但 mbed OS 要求所有平台都按照统一签名来实现这就是抽象的价值。读这个函数时你会看到里面对pinmap的使用。mbed OS 里引脚名是一个枚举值比如PA_2并不只是一根线的编号它背后有一张映射表把“引脚名”映射到“芯片的端口、引脚位、以及复用功能编号”。这套机制非常值得多看几眼因为它解决了嵌入式开发里的经典问题引脚冲突。3.3 PinMap 引脚复用表一个经常被忽略的核心机制mbed OS 里有一个结构叫作PinMap通常定义在目标平台的PeripheralPins.c或类似文件里它长这样const PinMap PinMap_UART_TX[] { {PA_2, UART_2_TX, AF7}, {PA_9, UART_1_TX, AF7}, {PB_6, UART_1_TX, AF7}, {NC, NC, 0} };这个表的意思是UART 的 TX 功能可能被引出到多个引脚每个引脚对应不同的外设实例和复用编号。当你在应用层指定PA_9作为串口 TX 时HAL 会自动在表中查找确认 PA_9 可以映射到 UART1 的 TX然后配置对应的寄存器。这套机制让“换板子”变得容易但也带来了一个坑如果你的自定义板卡上引脚映射不在表中HAL 可能找不到匹配项初始化会静默失败或者直接断言。所以移植新板子时我第一件事就是检查PeripheralPins.c里有没有覆盖我要用的引脚功能。3.4 移植新芯片时先看哪几个文件实际做芯片移植时我不会把所有 HAL 文件都写完而是先做最小集。以“能跑 Hello World 串口输出”为目标最少需要mbed.h可以编译通过serial_api.c实现串口初始化、单字节收发、波特率设置gpio_api.c实现引脚方向与电平读写us_ticker_api.c或lp_ticker_api.c提供 ticker 定时器targets.json中最基本的平台字段和时钟配置。这个最小集跑通后再逐步补齐 SPI、I2C、PWM 等。每个模块的测试用例在 mbed OS 仓库中基本都是现成的补一个模块就回归一遍基本不需要自己从头写验证代码。这个流程完美体现了 HAL 接口设计的价值一次约定多方复用。4. RTOS 层RTX5 之上那层 C 封装并不是摆设4.1 Thread 与 CMSIS-RTOS v2 的关系mbed OS 的底层内核是 RTX5这是一个真正意义上的抢占式实时操作系统内核。但应用开发者很少直接调用 RTX5 的原生接口而是通过 CMSIS-RTOS v2 API 和 mbed 提供的 C 封装来使用。两层关系大致是这样RTX5负责线程调度、时基、中断管理是最底层的内核实现CMSIS-RTOS v2Arm 制定的统一 RTOS API 标准定义osThreadNew、osMutexNew、osMessageQueueNew这类接口mbed C 封装把这些 API 包装成mbed::Thread、mbed::Mutex、mbed::Queue、mbed::EventFlags等类。为什么要多包一层因为裸的 C API 用起来容易出错尤其是创建线程时的参数组合。封装成 C 类之后基础的默认参数、错误检查、析构处理都由框架接管应用层只需要关心业务逻辑。比如创建一个线程一个比较典型的 mbed 写法是这样#include mbed.h void task1(void) { while (true) { printf(task1\n); ThisThread::sleep_for(1000ms); } } int main() { Thread t1; t1.start(task1); while (true) { ThisThread::sleep_for(2000ms); } }Thread对象在构造时并不启动必须调用start才真正创建线程。如果你在 main 函数之前构造了全局Thread对象并且在构造函数里调用了start就会踩到“RTOS 还没启动”的坑。这个规则值得记住mbed OS 的 main 本身就是一个线程但全局对象构造阶段发生在 RTOS 调度器接管之前不能依赖核心 RTOS 服务。4.2 内存模型线程栈和调度器对象从哪里来嵌入式 RTOS 和 Linux 这种系统最大的区别是内存预算极其有限。mbed OS 没有运行时的全局内存分配池默认情况下线程栈由使用者提供或者由 mbed 分配器去“借用”堆空间。在底层RTX5 的线程控制块和栈可以是静态的也可以是动态的。mbed 封装层倾向于动态分配但通过平台的 linker 脚本把堆空间限制在一个明确区域。你在mbed_app.json或平台配置文件里看到的main_stack_size、thread_stack_size这类参数最终会影响 RTOS 能为线程分配多大栈空间。这带来一个非常实际的调试经验当某个线程里出现莫名其妙的栈溢出、死机、打印乱码时先别怀疑编译器去查线程栈是否够用。mbed OS 在调试模式下会输出线程栈使用峰值和错误信息格式大概长这样CURRENT THREAD: main STACK POINTER: 0x2001FF90 STACK MEMORY: 0x2001F000 - 0x20020000 STACK LIMIT: 0x2001F000看到STACK LIMIT与STACK POINTER很接近就说明栈快爆了。我遇到过不止一次因为中断回调里调用复杂打印函数导致栈直接穿透的情况。4.3 RTX5 调度模型里最容易误解的点RTOS 面试里经常问“优先级翻转”在 mbed OS 下用mbed::Mutex就能直接复现和规避。RTX5 默认是优先级抢占调度高优先级线程只要就绪就会立刻抢占低优先级线程。mbed 里的优先级数值越小越优先这里的“小”和很多初学者从 FreeRTOS 带过来的习惯正好相反用错时你会发现个别线程永远跑不起来。还有一个容易被忽略的点mbed OS 里很多 API 并不支持在中断上下文调用。比如Mutex::lock、Semaphore::release的部分实现不一定允许在 ISR 中使用或者需要专门的中断安全版本。读源码时会发现很多函数内部有osKernelGetState()一类的判断目的就是把“当前是否在中断上下文”检测出来然后报错。新人在中断回调里执行了耗时操作或者调用了阻塞式 API经常会导致整个系统挂起。5. 驱动层与事件机制从引脚到网络协议栈的层级调用链5.1 驱动类与 HAL 函数一层有意保持“薄”的封装drivers/目录里是驱动 C 类但这些类普遍很薄因为它们的设计目标不是实现复杂逻辑而是把 HAL 的 C 接口封装得让应用层用起来舒服。以DigitalOut的源码为例它的核心代码逻辑大概只有几行构造函数调用gpio_initwrite方法调用gpio_writeread方法调用gpio_read。底层真正的寄存器操作完全藏在 HAL 里。这种“薄封装”是有意的既保证了 C 层使用方便又避免了在驱动层重复实现硬件逻辑。同样的模式贯穿整个驱动体系mbed::I2C封装i2c_init、i2c_write、i2c_read等mbed::SPI封装spi_init、spi_master_write等mbed::PwmOut封装pwmout_init、pwmout_write等。很多刚开始接触 HAL 的开发者都是从 DHT11 这类单总线传感器或者 OLED 显示模块开始练手的。DHT11 本质上就是 GPIO 输入输出方向切换和时序控制OLED 基本就是 I2C 写字节它们最终都会落到这套 HAL 接口上。理解了这一层调用链你再看任何外设驱动思路都会清晰很多。5.2 Callback 与中断为什么 mbed OS 不鼓励在中断里干活嵌入式驱动离不开中断。mbed OS 提供了一套非常常用的Callback模板类用来包装函数指针、函数对象甚至成员函数。比如按键中断可以这样写InterruptIn btn(BUTTON1); void on_button_fall() { // 只做标志位或事件通知 } int main() { btn.fall(callback(on_button_fall)); while (true) { ... } }btn.fall()注册了一个回调底层通过 GPIO 中断触发。这个回调是在中断上下文执行的所以千万不能在里面调用printf、加锁、延时等操作。你以为只是几行打印代码实际可能引发死锁、系统挂死甚至因为打印驱动使用了同一串口产生不可预期行为。解决方案是尽快结束中断回调把真正的处理逻辑放到线程上下文。这就是 EventQueue 的用武之地。5.3 EventQueue物联网场景下最实用的“软中断”机制读 mbed OS 源码时events/目录是我个人最喜欢的一部分。EventQueue本质上是一个线程安全的事件队列某个上下文包括中断可以把任务投递到队列里然后由绑定的线程在普通线程上下文逐个执行。下面是一个典型的使用方式#include mbed.h EventQueue queue; Thread eventThread; InterruptIn btn(BUTTON1); void print_message() { // 这里是线程上下文可以安全打印 printf(button pressed\n); } void button_fall_callback() { // 中断上下文只投递事件不做耗时操作 queue.call(print_message); } int main() { eventThread.start(callback(queue, EventQueue::dispatch_forever)); btn.fall(callback(button_fall_callback)); while (true) { ThisThread::sleep_for(100ms); } }这个模式几乎可以套用到所有中断场景外部中断、串口接收、定时器回调。用上 EventQueue 之后很多“偶尔崩一下但查不到原因”的疑难杂症会大幅减少。读源码时你可以关注一下EventQueue::call的实现它内部使用了队列和内存池来管理回调对象。这样设计带来了一个约束回调对象不能无限大队列深度也有限。如果你把过大的成员变量绑进回调或者一瞬间投递几百个事件可能丢掉任务。所以在实际项目中我通常把事件队列设得稍微大一点并且把回调里传递的数据用指针或者简单结构体而不是一个大对象。从驱动层继续往上走connectivity/和storage/是另外两片大区域。网络接口如NetworkInterface、Socket存储接口如BlockDevice、FileSystem本质上延续了同样的分层理念。应用层操作一个文件系统根本不需要知道底下是 SPI NOR Flash 还是 SD 卡只要通过统一接口访问就行。这种“驱动之上再抽象”的模式是整个 mbed OS 架构的精髓。6. 测试体系Greentea 和 utest 是怎么保证跨平台质量的6.1 三层测试组件各管一段读 mbed OS 源码时不把测试体系看一遍说实话很难真正理解这套架构的可靠性。测试体系主要分成三层utest跑在设备上的 C 单元测试框架用来组织和断言测试用例。Unity提供TEST_ASSERT_*系列断言宏是 mbed 测试里最常用的断言库。Greentea和htrun运行在 PC 上的 Python 测试调度工具负责把测试镜像烧录到板子、通过串口和板子上的测试程序通信、收集结果。三者配合后一套测试用例可以被编译成不同目标平台的镜像跑在不同厂商的芯片上最后统一汇总测试结果。6.2 一个用例从编译到上板验证的生命线先看设备上的测试代码。utest 的用例组织和 mbed OS 的框架结合后一般是这样的结构#include mbed.h #include utest/utest.h #include unity/unity.h using namespace utest; void test_gpio_output() { DigitalOut led(LED1); led 1; wait_us(100); led 0; TEST_ASSERT_TRUE(true); } void test_serial_echo() { // 假设通过串口发送一个字节并读取回来 TEST_ASSERT_EQUAL_UINT8(0x55, received); } Case cases[] { Case(GPIO output test, test_gpio_output), Case(Serial echo test, test_serial_echo), }; Specification specification(cases); int main() { Harness::run(specification); }编译成测试镜像后板子上电PC 端的 Greentea 通过串口检测到设备发送的启动同步序列然后开始逐个执行用例。测试程序内部会向串口输出带格式的状态字符串比如用例开始、通过、失败htrun 负责解析这些字符串。全部跑完后PC 端汇总报告。这套机制看起来简单但它解决了嵌入式开发里一个非常实际的问题多平台回归测试。没有这样一套基础设施想验证“改动某一个 HAL 文件会不会影响其他几十块板卡”几乎是不可能的。6.3 我跑官方测试时踩过的两个典型问题第一次跑 mbed 官方测试时我卡在一个很奇怪的现象上测试镜像烧进去后PC 端一直等不到设备上线。后来发现是因为板卡上用于测试的串口和用于打印日志的串口是同一个而测试镜像默认会向这个串口输出同步信息但我的串口工具也占用了同一个端口导致通信冲突。第二个问题是串口复位信号线。很多开发板通过 DTR/RTS 信号控制目标芯片的复位Greentea 依赖这套信号来复位设备并进入测试流程。如果板卡上的 USB 转串口模块没有接复位控制线Greentea 会一直卡在“waiting for target reset”。这种情况有两种解决办法更换带复位控制的开发板或者在测试配置里关闭自动复位手动按下复位键。这两类问题让我明白测试框架本身没问题但硬件环境的不规范会让结果变得很不可控。跑官方测试之前建议先确认三件事测试串口号、波特率、复位控制方式。mbed OS 在这一点上确实有一套很完整的文档但只有真正踩过坑才能理解它为什么设计得那么啰嗦。7. 源码阅读与项目实战中的几条硬经验7.1 我建议按这个顺序读源码如果你也想系统地读一遍 mbed OS 源码而不是随便找几个文件看看我建议按照“驱动 - HAL - RTOS - 事件 - 测试”的顺序走。先从一个最简单的驱动类入手比如DigitalOut然后追它调用的 HAL C 函数再跳到目标平台的底层实现。这个过程跑通后你会对“抽象层级”有真实的体感再去看 RTOS 的 Thread、Mutex 和 EventQueue 会轻松很多。读 HAL 实现时最好同时打开芯片厂商的参考手册。因为 mbed OS 的 HAL 文件很薄很多寄存器细节就是一行直接操作如果不知道寄存器含义很快就会卡住。但不要试图从第一行读到最后一个文件那样效率太低了。7.2 编译器版本差异比想象中大mbed OS 从 5 到 6 的演进除了 API 调整编译器兼容性也是一个容易踩坑的地方。ARM Compiler 5 在 2022 年后已经进入维护衰退期新项目我通常建议用 ARM Compiler 6 或者 GCC_ARM。你会发现代码风格、优化行为、警告处理方式差异都很大。读源码时你会看到很多条件编译分支专门处理不同编译器之间的差异。比如内联汇编的写法、原子操作、__attribute__的处理都可能有分支。这提醒我们在使用 mbed OS 时不要轻易把一个平台的编译优化选项套到另一个编译器上否则可能会出现“性能下降一截但找不到原因”的情况。7.3 不要把 mbed OS 当裸机程序来写最后一条经验也是最重要的mbed OS 不是一个“带库函数的裸机环境”它是一个完整的 RTOS。很多人初次上手时习惯于在 main 里写一个大循环把所有外设操作都塞进去结果发现实时性能很差。这不是系统慢而是你没有利用好它提供的多线程和事件机制。正确的思路是把每个功能模块放在自己的线程里线程之间用消息队列、事件标志、信号量来同步把硬件中断通过 EventQueue 转成普通线程回调。这个过程初看会增加代码量但换来的是更高程度的解耦和可测试性。我后面几个项目的稳定性基本都得益于这种从裸机思维到 RTOS 思维的转变。针对自己的板子调试时我还习惯在启动阶段把系统信息打印出来包括芯片型号、时钟频率、堆大小、主线程栈大小。这样即使后面出了问题也能快速判断是不是平台配置不对。源码读得越深你会越理解 mbed OS 的每一个设计细节都是有原因的。等到某天你能不依赖第三方教程直接从源码里找到答案时这套架构带给你的价值才真正开始显现。