拿到 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 那层不是裸的 RTX5C 封装已经把坑填了一遍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 与跑在板子上的 TESTSmbed 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_MACRO1], 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 的架构其实非常经得起推敲。