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

资讯详情

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

mbed OS 源码架构详解:从 HAL 到 RTOS 与驱动测试的嵌入式设计范式

mbed OS 源码架构详解:从 HAL 到 RTOS 与驱动测试的嵌入式设计范式 mbed OS 是我这几年接触过的物联网操作系统里源码结构最“教科书”的一个。它不像 FreeRTOS 那样只给你一个内核也不像 Linux 那样庞大到没法通读而是把 HAL、RTOS、驱动、测试框架整整齐齐地分层摆好。这篇文章我想从源码架构的角度把我对 mbed OS 的理解完整梳理一遍重点讲清楚 HAL 层怎么设计、RTOS 封装了什么、驱动和测试体系如何衔接以及实际移植和开发中容易踩的坑。如果你正准备上手 mbed OS或者想找一个源码清晰、可扩展性强的嵌入式系统作为参考这篇应该对你有用。1. 项目背景与源码架构总览1.1 为什么值得花时间读 mbed OS 源码很多开发者对 mbed OS 的认知停留在“用 Arduino 的方式写 STM32 程序”也就是调用DigitalOut、I2C这些现成类库。但 mbed OS 的价值远不止一堆积木式驱动它的核心设计思路是用一套统一接口屏蔽不同厂商芯片的差异同时保留实时操作系统能力再叠加上一套完整测试体系。这种设计在物联网碎片化场景里有非常强的现实意义。我最初接触 mbed OS 是因为一个多传感器采集项目需要同时跑几个周期任务、处理串口和 I2C 中断还要保证低功耗。用裸机和定时器轮询的方式写了一版逻辑一复杂就乱。后来切到 mbed OS把采集、处理、上报拆成几个线程用事件队列和信号量协调代码结构瞬间清晰了。但真正让我决定深入源码的原因是当时遇到一个诡异的中断嵌套问题——查遍应用层代码都没找到原因最后追到 HAL 层的 GPIO 中断实现才明白问题所在。从那时起我就意识到用 mbed OS 做产品光会调 API 远远不够。你必须理解它的 HAL 层如何抽象硬件、RTOS 封装了什么、驱动的事件机制怎么工作、测试框架怎么组织才能在出问题时快速定位甚至针对自己的硬件做裁剪和优化。读 mbed OS 源码本质上是读一套工业级嵌入式软件的工程范式。1.2 mbed OS 的分层结构与核心模块边界mbed OS 的源码树从顶层看可以分为这么几块platform/平台相关的基础设施包括错误处理、内存池、电源管理接口、软件定时器抽象。drivers/面向应用的外设驱动类也就是DigitalOut、SPI、I2C、UART这些 C 封装。hal/硬件抽象层定义了gpio_t、serial_t、spi_t等底层结构体和接口函数。rtos/基于 CMSIS-RTOS 封装的线程、信号量、互斥量、事件标志、队列等原语。events/事件队列EventQueue用于在中断上下文与线程上下文之间传递事件。connectivity/网络协议栈、蓝牙、WiFi、LoRa 等通信方案。storage/文件系统和块设备抽象。targets/各芯片厂商的具体实现包括 startup 文件、链接脚本、HAL 底层实现。features/一些可选功能模块比如安全、OTA、加密等。tools/构建和测试工具包括 mbed-cli、Greentea 测试脚本等。这张分层图的核心逻辑是上层依赖接口而非具体实现。应用代码只跟drivers/和rtos/的 API 打交道drivers/调用hal/的接口hal/的具体实现分散在targets/里。哪一层的逻辑变了影响范围都限制在本层不会牵连其他部分。对应到实际项目中这意味着如果你要换芯片平台只需要替换targets/下的 BSP 和 HAL 实现上层的驱动类、RTOS 接口、应用代码基本不需要动。这个思路和我们做模块化设计的理念完全一致唯一不同的地方在于它做得更彻底、更有体系感。2. HAL 层设计思路从接口约定到具体实现2.1 HAL 的目录结构与接口约定HAL 层在源码里的位置是hal/和targets/两部分的组合。hal/里放的是接口定义和通用逻辑targets/里放的是不同芯片的翻译层实现。打开hal/目录你可以看到一组头文件gpio_api.h、serial_api.h、spi_api.h、i2c_api.h、pwmout_api.h、analogin_api.h、analogout_api.h、flash_api.h、trng_api.h、rtc_api.h等等。每个xxx_api.h都遵循一套固定风格先定义硬件对象的结构体再声明初始化、读写、控制、去初始化这几个维度的函数。以gpio_api.h为例typedef struct gpio_s gpio_t; 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); void gpio_irq_init(gpio_t *obj, PinName pin, gpio_irq_handler handler, uint32_t id); void gpio_irq_set(gpio_t *obj, gpio_irq_event event, uint32_t enable);注意这里没有用寄存器地址也没有用GPIO_TypeDef *这种跟某某芯片强相关的类型而是用一个不透明结构体gpio_t。这个结构体的真正定义藏在targets/下的某个头文件里比如TARGET_STM32L4/PeripheralNames.h和gpio_object.h里它会被解析成具体的端口、引脚号甚至寄存器基地址。这种接口设计有一个好处上层驱动不需要知道引脚背后是哪个芯片的哪个寄存器只管用PinName描述引脚功能即可。这也解释了为什么 mbed OS 的应用代码跨平台能力特别强——PC14 在 STM32 上是一个引脚在 NXP 芯片上也是通过同样的PinName类型传递。2.2 几个核心 HAL 模块的实现要点GPIO HAL 是相对简单但最能看出设计思路的模块。它的初始化流程是把引脚号解析成端口和引脚号使能对应时钟配置模式输入、输出、开漏、复用等再设置初始状态。比如在 STM32 的 HAL 实现里pinmap表格会做一次“mbed PinName → STM32 GPIO 端口/引脚”的映射然后调用底层寄存器操作函数。这里有一个很关键的工程细节mbed 把芯片厂商原有的固件库比如 ST 的标准外设库或 HAL 库看作“底层驱动”mbed 的gpio_t只是薄薄一层封装而不是重写寄存器操作。串口UARTHAL 的难点在于中断和缓冲区管理。接口上有serial_init、serial_putc、serial_getc、serial_irq_handler但是具体到芯片实现接收中断触发后怎么办、环形缓冲区用多大、是否是 DMA 搬运这些都藏在目标实现里。drivers/层的UnbufferedSerial和BufferedSerial分别对应“直接操作底层”和“自带环形缓冲”两种模式。如果你在项目里直接使用printf重定向到串口实际是经过了BufferedSerial的缓冲区满时的丢数据行为需要自己心中有数。I2C HAL 在 mbed OS 里表现非常稳定。原因在于 HAL 层把 I2C 的总线状态机抽象成几个简单接口i2c_start、i2c_stop、i2c_read、i2c_write、i2c_byte_read、i2c_byte_write。驱动层按协议组合这些原语就可以应对各种外设的时序要求。比如你给一个 I2C 传感器写寄存器就是先 start再写设备地址加写标记再写寄存器地址再发起读最后 stop。这套流程在任何芯片上跑逻辑都是一样的只要底层 HAL 的时序实现正确。2.3 HAL 与厂商 SDK 的分工关系这里有一个很多人误解的地方mbed OS不是抛弃了厂商 SDK而是站在 SDK 之上再抽象了一层。在targets/里你会看到每个芯片目标都有自己的PeripheralPins.c、system_clock.c、xxx_hal.c里面大量调用厂商原始库函数。以 STM32 为例mbed 的 SPI HAL 会调用 ST 的HAL_SPI_Transmit、HAL_SPI_ReceiveGPIO HAL 会调用HAL_GPIO_Init和寄存器改写函数。这种设计的最大好处是厂商 SDK 有 bug 修复或性能优化时mbed 可以直接受益。坏处是不同芯片的 HAL 实现风格差异大驱动的行为一致性需要靠 mbed 的测试套件来保证。这也是 mbed 测试体系如此重要的原因之一。所以如果你要做芯片移植正确路径不是从零写寄存器操作而是找到厂商 SDK 的数据手册和示例代码把底层函数换成 mbed 的接口。3. RTOS 核心封装从 CMSIS-RTOS 到 mbed 线程模型3.1 基于 CMSIS-RTOS 的线程与调度机制mbed OS 的 RTOS 层并不自己发明调度器而是建立在 CMSIS-RTOS 基础之上。CMSIS-RTOS 是 Arm 定义的 RTOS 标准接口它规定了一套跟具体 RTOS 实现无关的 API比如osThreadNew、osMutexNew、osMessageQueueNew。mbed 在rtos/目录下用 C 把这些 C API 再包装一层就成了Thread、Mutex、Semaphore、EventFlags、Queue、Mail这些类。为什么中间要隔一层因为 mbed 项目早期使用过多个 RTOS 内核比如 Keil RTX后来又切换到其他内核如果应用层直接跟 CMSIS-RTOS 的 C API 打交道换内核就意味着改应用代码。现在加了 C 封装接口稳定底层怎么换、调度器换成谁对开发者透明。看一个简单的线程示例#include mbed.h #include rtos.h Thread thread1; void task1() { while (true) { printf(task1 running, priority%d\r\n, osThreadGetPriority(osThreadGetId())); ThisThread::sleep_for(1000); } } int main() { thread1.start(task1); thread1.set_priority(osPriorityAboveNormal); while (true) { printf(main running\r\n); ThisThread::sleep_for(500); } }Thread对象是一个 C 封装内部持有线程 ID、栈空间、优先级等属性。start方法把普通void (*)()函数或类成员函数转化为线程入口。ThisThread是当前线程的命名空间用于让出 CPU、睡眠、获取自身信息。3.2 线程间通信与同步原语的实际应用mbed OS 提供了完整的线程间通信工具使用时要区分场景避免滥用。Mutex保护共享资源典型的场景是多个线程同时往一个外设写数据。嵌入式里要特别注意优先级翻转问题mbed 的 Mutex 支持优先级继承机制从 RTOS 内核层面尽量缓解这个问题。Semaphore信号量适合做“资源计数”例如一个环形缓冲区有 N 个空位生产者每放入一条数据释放一个信号量消费者每取走一条数据获取一个信号量。不过在 mbed 里我更推荐直接用EventFlags或Queue来处理生产消费模型。EventFlags这是 mbed 用得非常多的一种同步机制多个事件位可以分别置起等待方可以等待任意标志或全部标志。一个典型场景是按键线程等待 GPIO 中断标志收到后读取事件位一次只处理一个事件。Queue/MailQueue适合等长消息的传递Mail允许动态分配含类型信息的块。从内存效率和代码可读性上Mail往往更直观。这里分享一个我踩过的坑一开始我用Semaphore做线程间消息通知后来发现高负载下消费者被多次唤醒却拿不到数据排查很久才意识到是信号量计数累加和队列长度不一致导致。后来干脆换成EventFlags 消息结构体共用同一个队列逻辑立刻清晰了。同步原语选型要跟数据流模型匹配不要为了炫技而乱用。3.3 内存管理与线程栈的默认配置mbed OS 里每个线程都有独立栈空间默认大小跟目标平台有关一般是 4KB 或 8KB。因为嵌入式环境内存有限栈溢出往往是“偶发死机”的罪魁祸首。查看当前线程栈使用情况可以用osThreadId_t id osThreadGetId(); uint32_t free osThreadGetStackSpace(id); printf(thread stack free: %u bytes\r\n, free);如果你的线程里有较大的局部数组比如一个 512 字节的协议解析缓冲区建议结合以下方式判断栈空间够不够规定线程函数的局部数据结构大小上限。在开发调试时把日志打印栈余量。不要在线程里使用递归也不要调用深度未知的第三方库函数。mbed OS 6 对 RTOS 内存管理做了不少调整把内核对象、线程栈等内存从全局静态数组改成动态分配简化了配置流程但也意味着要更加关注堆的大小和碎片情况。mbed_config.h里MBED_CONF_RTOS_*系列配置项可以调整线程默认栈大小、定时器线程栈大小等在资源紧张的 MCU 上是必须了解的。4. 驱动层设备驱动框架与事件处理机制4.1 从 HAL 到驱动类C 封装如何简化开发驱动层位于drivers/下这一层把 HAL 的 C 接口封装成 C 类让应用开发者直接实例化即可使用。比如DigitalOut led(LED1); DigitalIn button(BUTTON1, PullUp); I2C i2c(PB_7, PB_8); SPI spi(SPI_MOSI, SPI_MISO, SPI_SCK); BufferedSerial pc(USBTX, USBRX, 115200);这些类的内部实现其实就是调用 HAL 接口但增加了 C 的资源管理能力构造函数负责初始化析构函数负责去初始化拷贝构造被禁用、移动构造被支持避免重复释放和对象悬空。以I2C类为例它的核心方法包括int read(int address, char *data, int length, bool repeated false); int write(int address, const char *data, int length, bool repeated false);这两个方法内部会调用 HAL 层的i2c_start、i2c_stop、i2c_byte_read/write再根据地址和重复起始条件组合时序。也就是说上层驱动不需要关心硬件时序HAL 解决“怎么把硬件跑起来”驱动解决“怎么把协议用起来”。4.2 中断、回调与事件队列的协作模式mbed OS 驱动层最值得学习的一点是它的中断处理模型。硬件的 GPIO 中断、串口接收中断、定时器中断通常在中断上下文触发但在中断里执行耗时逻辑是大忌。mbed 的解决方案是在中断回调里只做“记录事件 触发主线处理”更重的工作放到线程上下文。具体有三种常见模式直接回调模式gpio_irq的中断回调里执行简短逻辑比如置一个全局标志然后主线线程轮询这个标志。实现简单但有轮询延迟。EventQueue 模式中断回调里调用event_queue.call(callback)把任务投递到事件队列事件队列的循环跑在一个专用线程上EventQueue queue(32 * EVENTS_EVENT_SIZE); Thread eventThread; void on_button_fall() { queue.call([] { printf(button pressed\r\n); }); } int main() { eventThread.start(callback(queue, EventQueue::dispatch_forever)); InterruptIn btn(BUTTON1); btn.fall(callback(on_button_fall)); }这个模式是 mbed 官方推荐的因为EventQueue能在线程上下文执行回调不用担心中断里调用系统 API 出现死锁或不安全行为。Queue Semaphore 模式在中断回调里向Queue放入指针同时释放一个信号量专用线程等待信号量后从队列取数据。这种做法适合驱动层高频数据流比如 DMA 半满/全满中断。4.3 自定义设备驱动的实现思路如果你要接入一个 mbed OS 中没有预置驱动的传感器建议优先查官方驱动仓库和社区贡献的驱动库。以 I2C 温湿度传感器为例设计流程大致是用I2C类建立总线句柄配置地址和超时。实现设备初始化序列比如读取设备 ID、设置分辨率、开启自动测量。实现数据读取函数解析传感器返回的多个字节转成物理量。封装成独立类构造函数传入 I2C 对象引用避免重复初始化总线。对外提供init()、read_temperature()、read_humidity()等公共接口。这里有个容易踩的坑多个 I2C 设备共用一条总线时每个驱动类都应该持有同一个I2C对象的引用而不是各 new 一个对象。因为 mbed 的I2C对象构造时可能重新初始化总线多个实例会导致总线冲突。5. 测试体系Greentea、unity 与自动化验证5.1 mbed OS 测试框架的组成mbed OS 的测试体系是源码中经常被忽略但价值极高的部分它由几个组件组成GreenteaPython 编写的自动化测试工具负责编译测试固件、烧录到目标板、控制测试流程、汇总结果。unity成熟的 C 单元测试框架提供断言宏和测试用例组织能力。utestmbed 自己封装的测试框架在 unity 基础上增加了测试夹具、多测试套件支持、用例间资源清理等功能。htrun主机端测试运行器与设备上的测试固件通过串口通信接收测试结果和日志。测试代码通常放在每个模块的tests/目录下或TEST_APPS目录里编译时通过 mbed-cli 的--profiletest和--test参数编译出测试固件。5.2 一个测试用例的编写结构以驱动层测试为例用 utest 编写一个测试#include mbed.h #include utest/utest.h #include unity/unity.h #include greentea-client/test_env.h using namespace utest; static void test_i2c_write_read() { I2C i2c(PB_7, PB_8); char tx_data[] {0x01, 0x02, 0x03}; char rx_data[3] {0}; TEST_ASSERT_EQUAL_INT(0, i2c.write(0x50, tx_data, 3)); TEST_ASSERT_EQUAL_INT(0, i2c.read(0x50, rx_data, 3)); } static utest::v1::case_t const test_cases[] { case_t(I2C volatile write/read, test_i2c_write_read), }; static utest::v1::status_t greentea_setup(const size_t number_of_cases) { GREENTEA_SETUP(20, default_auto); return verbose_test_setup_handler(number_of_cases); } int main() { Harness::run(test_cases); }编译运行后Greentea 会与板子上的测试固件交互识别测试用例名和结果。TEST_ASSERT_*宏的失败会停止当前用例记录失败信息并上报主机。这套体系让你可以在本地或 CI 环境里对驱动做回归测试很大程度上减少了硬件平台差异带来的隐性 bug。5.3 测试环境搭建与 CI 集成的一些经验要给 mbed OS 项目搭测试环境常用命令大概是mbed compile --profilembed-os/tools/profiles/debug.json --test -t GCC_ARM -m YOUR_TARGET mbed test --compile-list mbed test -t GCC_ARM -m YOUR_TARGET --greentea在 CI比如 GitLab CI 或 Jenkins里通常会设置定时任务或提交触发编译测试把结果以 trxml 文件输出。实践中有几点值得注意硬件资源池化测试用例要覆盖 GPIO、I2C、SPI、串口、定时器、电源管理等多个维度一块板子往往不够建议搭一个小型硬件测试矩阵自动按用例分配到不同板卡。测试固件和运行环境分离物理板卡通过调试器或 USB 转串口连接工控机Greentea 通过串口交互。串口波特率要固定日志尽量精简否则会干扰结果解析。稳定性优先于覆盖率对嵌入式驱动来说跑 100 个只用一次的用例不如跑 20 个连续重复 1000 次的用例更有说服力因为很多问题都是频率触发、边界条件触发。6. 常见问题与调试经验6.1 工具链选择与编译配置mbed OS 6 支持 GCC、Arm Compiler 6AC6、IAR 等工具链。历史项目里你还会看到 Arm Compiler 5AC5但如今新开发建议直接用 AC6 或 GCC。工具链切换时要注意标准库差异GCC 用 Newlib-nanoAC6 用 Arm 的 C 库浮点打印支持、堆栈初始化行为有差别。优化等级差异Debug 配置默认-O0Release 配置默认-Os代码在-O2下可能暴露未定义行为问题。启动文件兼容性不同工具链需要各自版本的 startup 文件和链接脚本直接混用大概率链接失败。在项目的mbed_app.json里可以配置工具链相关的额外编译参数和宏定义比如{ target_overrides: { *: { target.printf_lib: std, platform.stdio-buffered-serial: true } } }我自己的习惯是主力用 GCC 做日常编译和单元测试发布前用 AC6 的-Osize编一版对比 flash 和 RAM 占用顺便验证代码可移植性。6.2 低功耗与 tickless 模式踩坑mbed OS 支持 tickless 模式即系统在无任务可运行时关闭周期性的 SysTick 中断用低功耗定时器来唤醒。这个特性明显降低待机功耗但坑也不少。最典型的问题是某个外设或驱动在进入/退出低功耗模式的回调里没有正确处理。比如你把 UART 引脚配置为低功耗模式下的唤醒源但 uart HAL 的sleep_manager回调没有恢复引脚功能就会导致唤不醒或数据错乱。排查这类问题建议先用sleep_manager_can_deep_sleep()打印各驱动是否允许深度睡眠。逐个驱动模块注册的回调函数看哪个在pre_sleep或post_sleep阶段处理异常。在调试阶段强制关闭 tickless确认问题是否与低功耗相关。6.3 中断上下文里的雷区mbed 驱动大量使用中断回调但在中断上下文里碰系统资源要格外小心。以下操作尽量不要在中断回调里出现调用printf除非经过专门设计可能导致阻塞或重入问题动态分配内存malloc、new直接调用Mutex的 lock可能造成死锁因为中断不属于线程上下文长时间的循环等待这是我在一个项目里真实遇到过的场景GPIO 中断回调里放了printf做调试结果中断频率一高输出缓冲区和串口中断互相干扰板子直接卡死。后来把printf换成只置一个volatile标志然后通过事件队列在线程里打印问题就消失了。中断回调的代码越短越好最好只是“把事件告诉别人”。6.4 RAM 占用过高与堆碎片处理mbed OS 的 C 驱动、事件队列、RTOS 对象都会占用 RAM。STM32F103 这种只有 20KB RAM 的芯片用 mbed OS 稍不注意内存就不够用。优化思路一般是这样精简不需要的驱动和网络功能通过mbed_app.json的宏裁剪。调整MBED_CONF_RTOS_THREAD_STACK_SIZE和事件队列大小。大缓冲区尽量放全局静态区避免在线程栈里申请大数组。如果内存碎片严重考虑定期重启或使用内存池管理固定大小消息块。mbed OS 6 引入了更灵活的内存管理方式但底层依然是 C 库的malloc/free在有长期运行需求的产品里建议对堆使用情况做周期监控比如记录最大分配块、空闲块数量等。7. 如果你要上手 mbed OS几条务实建议先别急着把整个工程全部迁移到 mbed OS。我见过不少人抱着“全面替换”的心态结果被工具链、驱动、内存等各种问题折磨。更稳妥的路子是先在评估板上跑通基础外设比如点灯、串口打印、按键中断、RTOS 多线程再逐步加 I2C 传感器、网络模块、文件系统每加一块就跑一遍 Greentea 测试。学习源码的顺序上我个人推荐“从下往上”先读hal/里的 gpio 和 serial 接口理解硬件抽象的思路再读drivers/里的DigitalOut和BufferedSerial看看 C 封装怎么调用 HAL然后看rtos/的 Thread 和 EventFlags 源码理解内核封装层最后再看events/的 EventQueue 和测试框架摸清整个事件驱动模型。等你把这几个模块串起来mbed OS 在你眼里就不再有“黑盒”了。这个系统能走多远取决于你对 HAL 层和驱动框架的理解深度。遇到问题多读源码、多用测试框架去验证自己的假设比到处搜答案更有效。
返回列表