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

资讯详情

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

mbed OS源码架构解析:从HAL到RTOS的嵌入式系统设计

mbed OS源码架构解析:从HAL到RTOS的嵌入式系统设计 写mbed OS源码解析其实是个挺有挑战性的活儿。这玩意儿不像普通MCU工程那样把外设寄存器一调、中断一开就完事它是一个带完整RTOS内核、HAL抽象层、事件驱动机制和云连接框架的操作系统。当年我刚开始接触mbed OS的时候光是把一个点灯程序跑起来就折腾了好几天不是编译报错就是链接失败后来好不容易跑通了点开源码一看——好家伙层与层之间绕来绕去回调套回调事件队列里塞了各种乱七八糟的任务这才意识到想真正用好mbed OS光会调API远远不够必须得从源码架构层面去理解它设计上的“世界观”。这篇文章我就把自己读mbed OS源码时梳理出来的架构逻辑连同移植、驱动编写、测试这些实操环节一起做个复盘。1. 整体设计思路拆解mbed OS到底在解决什么问题1.1 从应用诉求反推架构目标先抛开代码不谈思考一个问题为什么Arm要搞一个mbed OS出来市面上嵌入式操作系统一抓一大把FreeRTOS、RT-Thread、Zephyr哪个不能跑mbed OS真正的差异化诉求在我看来是这三点第一让Cortex-M平台上的开发标准化。不同厂商的MCU外设寄存器布局完全不同即使都是ARM内核GPIO的配置方式也可能差着十万八千里。mbed OS想做的是定义一套统一的API让应用层写出来的代码在NXP、ST、Nordic这些不同芯片上都能编译运行。第二把物联网应用的公共部分沉淀下来。联网要TCP/IP协议栈设备管理要固件升级数据要加密传输这些是每个IoT产品都躲不掉的。mbed OS把它做成了系统级的服务应用开发者不需要关心底层是Wifi还是以太网调用统一的网络接口就行。第三让多线程编程在MCU上变得有序。裸机开发最大的痛点就是中断里不能做耗时操作主循环里又不能阻塞太久逻辑一复杂代码就成了一团乱麻。mbed OS内置了RTOS内核配合事件队列机制把异步、并发这个难题用一套相对优雅的方式解掉了。1.2 源码目录结构背后的分层哲学理解了目标再来看mbed OS的源码组织方式就会清晰很多。我习惯用mbed-os-6.x版本的仓库做分析它的核心目录结构大概是这样的mbed-os/ ├── cmsis/ # CMSIS-Core和CMSIS-RTOS API定义 ├── connectivity/ # 网络协议栈、蓝牙、射频相关 ├── drivers/ # 面向应用的驱动API ├── events/ # 事件队列实现 ├── hal/ # 硬件抽象层定义HAL API ├── platform/ # 平台相关的核心代码如CriticalSection、Timeout等 ├── rtos/ # RTX5内核封装、线程、信号量、消息队列等 ├── storage/ # 文件系统和块设备 ├── target/ # 各家芯片的移植代码 └── testing/ # Greentea、utest等测试框架如果你从“层”的角度去看这些目录它们的关系大致是应用层调用drivers目录下的接口或者直接调用rtos、platform的API。驱动/平台层drivers里的类底层通过HAL API和target里的芯片移植代码打交道。OS核心层rtos目录是CMSIS-RTOS v2 API的实现封装真正干活的是cmsis下的RTX5内核。硬件层target目录下按厂商、按芯片型号、按开发板三层组织最终和寄存器打交道。这种分层设计最大的好处是每一层只依赖下一层暴露的接口上层不需要知道下层的具体实现。实际项目中如果你要换一颗芯片理论上只需要替换target目录下的内容应用层代码一行都不用改——当然这只是理想情况现实里引脚分配、外设资源冲突还是得调的。1.3 为什么选择事件驱动模型mbed OS源码里有一个贯穿始终的设计范式回调 事件队列。这个一定要重点理解因为它直接决定了你写驱动、写应用的方式。传统裸机程序是“顺序执行 中断跳转”中断来了就跳去执行中断服务函数执行完再回来接着干。问题在于中断上下文里能干的事非常有限如果你在ISR里调用一个会阻塞的函数整个系统就直接卡死。mbed OS的解决方案是中断里只做最紧急的事比如读取硬件寄存器、置一个标志位然后把耗时的处理逻辑打包成一个回调函数扔到事件队列里去等系统空闲了在线程上下文中执行。这就是deferred interrupt模式。我举个实际例子你写一个按键驱动程序按下时GPIO产生中断ISR里不能做消抖因为要等10-20ms在中断里sleep是灾难正确做法是void button_isr() { // 关闭中断防止连续触发 button_int.disable_irq(); // 把消抖和状态处理丢给事件队列 ev_queue.call_in(20ms, do_debounce_and_notify); }这样ISR在几微秒内就退出了主线程的压力也不大系统整体响应非常平滑。读mbed OS源码的时候你会发现事件队列EventQueue几乎是无处不在的从网络事件回调到驱动状态机都在用它。2. HAL层源码详解硬件抽象是怎样炼成的2.1 HAL API的通用设计mbed OS的HAL层定义了一套精简但覆盖全面的接口。随便打开一个文件比如hal/spi_api.h你就能看到这组函数void spi_init(spi_t *obj, PinName mosi, PinName miso, PinName sclk, PinName ssel); void spi_free(spi_t *obj); void spi_format(spi_t *obj, int bits, int mode, int slave); void spi_frequency(spi_t *obj, int hz); int spi_master_write(spi_t *obj, int value);注意这里的两个细节一是句柄结构体spi_t。HAL层使用了一个不透明指针opaque pointer的变体spi_t在HAL头文件里只是声明具体内容是在target目录下定义的。不同芯片的SPI外设结构体完全不同但只要实现了相同的一套API函数上层就不需要关心内部到底怎么操作寄存器。二是引脚用PinName枚举来表示。PinName是整个mbed生态的地基它把芯片物理引脚抽象成一个统一的枚举值。例如STM32F103C8T6的PA5引脚在不同板子上可能就是LED1、SPI_SCK或者直接就是PA_5。HAL层的函数接收PinName再通过target层的函数把它解析为具体的GPIO端口和引脚号。这个设计实现起来并不复杂但效果非常好。我在做一个用STM32F103C8T6也就是常见的蓝丸板的项目时从NUCLEO-F103RB移植代码过来只需要改了PinName映射驱动代码完全没动。2.2 引脚映射与PinNames的实现机制打开targets/TARGET_STM/TARGET_STM32F1/TARGET_NUCLEO_F103RB/目录你会看到PinNames.h和PeripheralPins.c这两个关键文件。PinNames.h里定义了板级引脚别名比如typedef enum { PA_0 0x00, PA_1 0x01, // ... LED1 PA_5, LED2 PA_5, // ... USBTX PA_2, USBRX PA_3, // ... } PinName;看到没有板级引脚本质上是芯片引脚的typedef。这也是为什么mbed OS里能用LED1直接初始化DigitalOut因为它最终会被解析成GPIOA的第5位。PeripheralPins.c则是另一个关键文件它定义了“引脚到外设功能”的映射表。例如const PinMap PinMap_SPI_SCLK[] { {PA_5, SPI_1, STM_PIN_DATA(STM_MODE_AF_PP, GPIO_PULLUP, GPIO_AF0_SPI1)}, {PB_3, SPI_1, STM_PIN_DATA(STM_MODE_AF_PP, GPIO_PULLUP, GPIO_AF0_SPI1)}, {NC, NC, 0} };当你调用spi_init时HAL层的实现会遍历这个表找到你传入引脚对应的SPI外设和复用功能配置然后自动初始化GPIO时钟、复用模式、SPI外设时钟等。这些表是芯片移植工程师一针一线缝出来的工作量巨大但用起来真的是“屏蔽硬件差异于无形”。2.3 中断系统的HAL实现与回调绑定HAL层的中断管理也是mbed OS架构中非常有特色的地方。以GPIO中断为例hal/gpio_irq_api.h里的接口包括void gpio_irq_init(gpio_irq_t *obj, PinName pin, gpio_irq_handler handler, uint32_t id); void gpio_irq_set(gpio_irq_t *obj, gpio_irq_event event, uint32_t enable);注意gpio_irq_handler handler和uint32_t id这两个参数。因为一个MCU的GPIO中断线数量有限STM32F1的EXTI0-15但只有7个EXTI中断向量mbed OS的HAL层在初始化时会注册一个统一的内部ISR然后把id通常是一个指向具体GPIOIRQ对象的指针存起来。当外部中断触发时内部ISR根据引脚号找到对应的id再调用handler(id)把事件分发给上层。这个模式的意义在于HAL层不关心上层是谁只负责分发事件。所以你在应用层可以放心地new一个InterruptIn对象然后把它绑定到任意引脚上底层的分配和路由都自动处理好了。总结一下HAL层源码的特点接口极简实现重通过PinName和PinMap统一了芯片差异通过回调id机制实现了灵活的中断分发。读HAL层源码建议重点看hal/目录下的*_api.h头文件理解接口语义然后找你所用的target目录下的同名文件看具体实现。3. RTOS层RTX5内核的封装与线程模型3.1 CMSIS-RTOS v2 API之上mbed OS默认的RTOS内核是RTX5它的原生API遵循CMSIS-RTOS v2标准。但应用层一般不太直接调RTX5的原生API而是通过rtos目录下的C封装类来操作。打开rtos/Thread.h看一下类定义大概长这样class Thread : public rtos::ThreadInterface { public: Thread(osPriority priority osPriorityNormal, uint32_t stack_size OS_STACK_SIZE, unsigned char *stack_mem nullptr, const char *name nullptr); osStatus start(mbed::Callbackvoid() task); osStatus join(); // ... };关键点在于start方法接收的是一个mbed::Callbackvoid()而不是传统的函数指针。这意味着你可以传入lambda表达式、函数对象、成员函数任意一种可调用对象。我在项目里经常这么写Thread thread; thread.start(callback(this, MyClass::worker));或者更C一点的写法thread.start([this]() { while (true) { ThisThread::sleep_for(1s); read_sensor_and_report(); } });这套封装的底层逻辑其实很直接Thread::start把传入的Callback保存下来然后创建一个内部线程入口函数在线程入口处调用这个Callback。这样C的成员函数、lambda就被“翻译”成了RTX5能接受的普通C函数指针。3.2 事件标志、信号量与消息队列的源码阅读路径RTOS层除了Thread还有EventFlags、Semaphore、MessageQueue、Mail等同步通信IPC原语。源码阅读的最佳路径我觉得是照着这样一个顺序来EventFlags最简单就是位标志的等待与置位适合“等待多个事件中的任意一个”的场景。Semaphore计数信号量解决“资源有N个多个线程抢着用”的问题。MessageQueue线程间传递数据块底层是一个环形缓冲区加阻塞等待机制。MailMessageQueue的变体消息自带内存管理。以MessageQueue为例它的核心接口是osStatus put(T *data, uint32_t timeout 0); osStatus get(T *data, uint32_t timeout 0);底层实现会调用osMessageQueueNew、osMessageQueuePut、osMessageQueueGet等CMSIS-RTOS v2的C API。如果你继续追下去会看到RTX5内核里对这些API的实现核心是一个双向链表管理等待队列加上一个环形缓冲区存数据。有一点要特别提醒消息队列里存的是T类型的拷贝不是指针。如果T是一个很大的结构体put和get的开销会比较大。一开始我图省事直接传大结构体后来发现高频率通信时CPU占用率居高不下改成传指针才好。但传指针又引出了生命周期管理的问题——发送方new出来的对象接收方用完必须delete。这种场景建议用Mail它内置了内存池能自动管理。3.3 RTOS内核的调度陷阱与注意事项RTX5是一个优先级抢占式调度器加上时间片轮转调度。源码读多了对几个坑会比较敏感优先级反转。低优先级任务持锁高优先级任务等待中等优先级任务抢占了CPU导致高优先级任务迟迟拿不到锁。经典的解决方法是优先级继承RTX5的互斥量Mutex默认启用了这个机制。但前提是你用的是Mutex而不是Semaphore做互斥——如果你用二进制信号量代替互斥锁是不会有优先级继承的。中断里不能调用阻塞API。这是RTOS使用者的常识但mbed OS的某些API在中断上下文里其实是能调用的比如EventQueue::call而另一些则不行比如Semaphore::acquire。怎么区分看CMSIS-RTOS v2的文档那些fromISR后缀的函数才允许在中断里调用。栈空间估算。mbed OS为每个线程分配的默认栈大小是OS_STACK_SIZE在Cortex-M上通常是4KB。如果你的线程里有较大的局部变量、或者调用了多层嵌套的函数4KB很容易爆栈。我调试过一个诡异的现象系统运行几分钟后随机死机查了好久才发现是一个线程的栈空间不够用导致返回地址被覆盖。用ThisThread::get_stack_usage()打印一下栈使用量或者直接加大stack_size参数问题立刻解决。Idle线程和低功耗。RTX5有一个空闲线程当所有任务都阻塞时运行。mbed OS默认在空闲线程里执行WFI指令让CPU进入休眠。如果你的外设中断没有正确配置唤醒源系统就会睡死过去。之前用NRF52832做低功耗项目时就踩过这个坑后来在空闲钩子里加了调试打印排查出是某个外设的中断没有使能导致无法从WFI唤醒。4. 驱动体系从HAL到应用驱动的中间层4.1 驱动API的抽象路径mbed OS的drivers目录下是面向应用的上层驱动类。这些类本身不直接操作寄存器而是调用HAL层的函数。以SPI类为例它的构造函数SPI(PinName mosi, PinName miso, PinName sclk, PinName ssel NC);构造函数内部会创建一个spi_t对象并调用spi_init。后续的format、frequency、write等方法也都是一层薄薄的封装转发给对应的HAL函数。那为什么不直接在应用层调HAL API呢因为HAL API是C接口用起来别扭也没有生命周期管理。drivers层用C封装了资源获取和释放比如SPI对象的析构函数里会自动调用spi_free释放外设这样即使应用层代码抛异常或者忘记清理资源也不会泄漏。4.2 中断驱动的异步读写DMA与事件回调除了简单的同步接口drivers层还提供异步接口最典型的就是UnbufferedSerial和SPISlave。异步接口的底层往往涉及DMA和中断。举个例子UnbufferedSerial的write有一个tx_enable_irq的内部机制。当应用层调用write时如果底层硬件支持FIFO或DMA驱动程序会把数据交给硬件然后使能发送完成中断。在中断服务函数里驱动检查是否还有数据要发如果还有就继续填充硬件FIFO如果发完了就调用用户注册的tx_irq回调。这个流程在drivers/serial_api.c和targets/TARGET_STM/TARGET_STM32F1/serial_api.c里可以完整地看到。其中比较关键的几个实现细节// 伪代码示意 static void uart_irq_handler(uint32_t id, SerialIrq event) { serial_t *obj (serial_t*)id; if (event TxIrq) { // 从环形缓冲区取一个字节写入数据寄存器 if (!ring_buffer_empty(obj-tx_buff)) { obj-uart-DR ring_buffer_get(obj-tx_buff); } else { // 没有数据了关闭发送中断 uart_disable_irq(obj, TxIrq); if (obj-handler) { obj-handler(obj-id, TxEvent); } } } }这种“中断驱动 环形缓冲”的架构在UART、SPI、I2C驱动中随处可见。它的优点是CPU占用率低、不阻塞调用方但对缓冲区的管理要求非常高特别是缓冲区溢出和并发访问中断和主循环同时访问环形缓冲区的index这些细节一旦出错就是千奇百怪的bug。4.3 事件队列在驱动框架中的角色驱动层的事件回调虽然方便但有一个问题中断回调是在ISR上下文里执行的里面不能做太多事。所以mbed OS把事件队列作为驱动和应用层之间的“搬运工”。看这个例子网络驱动的接收流程void network_recv_callback() { // 这是在中断上下文被调用的 // 不能直接在这里处理数据包太耗时 // 正确做法把实际处理放到EventQueue里 event_queue.call(process_received_packet); }EventQueue内部维护了一个任务队列和一个独立的线程或者使用你指定的线程。当call被调用时它把一个可调用对象挂到队列尾部然后唤醒内部线程去执行。通过这种方式耗时处理被移到了线程上下文中断被打断的时间被压缩到最短。EventQueue的源码在events/EventQueue.h底层是基于events/equeue这个用C实现的轻量级事件循环。我看过它的实现核心数据结构是一个数组模拟的最小堆按时间排序支持call_in、call_every这类定时调用。性能非常优秀插入和删除都是O(log n)级别。4.4 从零写一个mbed OS驱动是什么样的体验讲完架构实际操作一下。假如我要给一个自定义的DHT11温湿度传感器写个mbed OS驱动大概需要做这几件事使用DigitalInOut来模拟单总线时序因为DHT11是单总线协议时序要求比较严格电平保持时间单位是微秒级。mbed OS的DigitalInOut提供了output()、input()、write()、read()这些方法可以把引脚在输入和输出模式间切换。整个驱动流程是主机拉低总线至少18ms然后释放变为输入模式等待DHT11响应。DHT11会拉低总线80us表示响应然后拉高80us准备发送数据。数据是40位每位由50us低电平高低电平的持续时间来区分0和126-28us为070us为1。从架构角度讲DHT11驱动有几种设计选择最简单的直接用wait_us阻塞延时在普通线程上下文里调用。这样无所谓因为50us的延时不会导致调度器乱套最多影响一下其他线程的响应。进阶的把读取过程放到一个独立线程里用信号量做同步避免阻塞主业务线程。推荐的做法也是我在实际项目中用的把DHT11读取封装成一个类提供read_temperature_humidity()方法底层用线程事件标志来管理读取时序和状态。我在实际项目里给一个DHT11写的驱动骨架是这样class DHT11 { public: DHT11(PinName pin) : _pin(pin) {} bool read(float temp, float humidity) { _pin.output(); _pin.write(0); wait_us(20000); _pin.write(1); _pin.input(); // 等待响应此处用忙等但确保在超时时间内 uint32_t timeout 100; while(_pin.read() 1 timeout--); // 读取40位数据... return true; } };有人说mbed OS的GPIO操作速度慢因为要经过PinName解析、GPIO对象封装等多层不太适合DHT11这种微秒级时序的单总线协议。实测下来确实有点悬如果你要追求更高的时序精度建议直接用HAL层的gpio_t或者用寄存器级别的操作。但如果你用mbed OS的DigitalInOut加一些优化比如把引脚操作封装成内联函数、避免在时序关键路径上调用wait_us之外的系统函数还是能跑通的只是稳定性需要多测。顺便提一下读驱动源码时drivers/DigitalInOut.h这个类值得仔细看看它的output和input方法底层实际上调用的是gpio_dir这个HAL函数会修改GPIO的方向寄存器。两条语句之间的切换是有几个时钟周期的开销的。5. 测试体系Greentea、utest与可重复验证5.1 为什么一个MCU OS要死磕测试很多搞嵌入式的工程师对测试体系的印象还停留在“板子上点个灯看亮不亮”。但mbed OS里面测试的权重非常高——因为它要支持各种不同的芯片和开发板没有一套自动化测试框架根本没法保证“在A板上跑得好好的代码换到B板上也能用”。mbed OS把测试分成了几个层次单元测试用utest框架跑在开发板上或者主机上针对纯逻辑代码。集成测试用Greentea框架通过串口与开发板通信验证板级功能。云测试对于涉及网络连接的用例可以集成到mbed的远程测试环境中自动跑在真实的开发板上。5.2 utest框架的使用与源码简析utest是mbed OS内置的微型单元测试框架它的使用方式很简洁比如我们写一个测试用例#include utest/utest.h #include mbed.h using namespace utest; static void test_gpio_output() { DigitalOut led(LED1); led.write(1); wait_us(100); TEST_ASSERT_EQUAL(1, led.read()); } static Case cases[] { Case(GPIO output basic test, test_gpio_output), }; static Specification specification(cases); int main() { Harness::run(specification); }注意这里的Case(测试名, 测试函数)每个测试用例是一个独立的caseHarness::run会依次执行它们并汇总结果。utest的设计核心是“用例 规格 运行器”任何嵌入式开发板上的功能都能用这个框架来验证包括外设、协议栈、内核调度等。我之前做过一个测试验证自定义I2C驱动在长时间高负载下是否稳定就是用utest工具写了个循环case每次读取传感器1000次统计CRC错误率。跑了一晚上第二天看UART输出直接打印了结果非常方便。5.3 Greentea与测试基础设施Greentea是mbed OS的板级测试框架它和主机的交互通过串行通信完成。测试流程大概是主机上执行mbedgt -n tests-mbed_hal-uart之类的命令。Greentea自动构建测试固件烧录到开发板。开发板上的测试程序运行通过串口发送格式化输出。主机侧解析串口输出与期望值比对给出PASS/FAIL结论。看platform/source/ATCmdParser.cpp和platform/source/ATCmdParser.h你会发现这个框架的核心是一个“AT命令解析器”——开发板和主机之间的通信协议本质上就是AT指令。开发板发送TEST_START、TEST_FINISH、TEST_ASSERT等指令主机端做对应的解析和判定。这种自动化测试体系在项目开发中非常有用。我在做一个需要同时支持多款开发板的固件时就用Greentea配置了一组测试矩阵每块板子刷入同一个测试镜像然后从CI服务器批量收取测试结果哪个板子的驱动挂了一眼就能看出来省去了大量人工测试的时间。5.4 测试替身Test Doubles与可配置性测试驱动开发在嵌入式领域比较难落地一个很大的原因是硬件依赖。mbed OS里有个很好的实践很多组件提供了可配置的“测试替身”。比如网络接口它有一个NetworkInterface抽象基类应用层代码只依赖这个基类。在单元测试里你可以写一个FakeNetworkInterface去模拟真实的以太网或Wifi接口验证上层逻辑是否正确而不需要真的连上网络。这种模式同样适用于存储组件。BlockDevice是所有存储设备的抽象测试时可以用HeapBlockDevice在内存里模拟一个块设备这样文件系统层面的逻辑就可以在没有真实Flash的情况下做完整的测试。这一层设计给我的启发是写驱动或者应用逻辑时尽量面向接口编程把底层依赖抽象出来。即使你没有mbed OS这么完善的测试框架在项目里自己定义几个抽象接口把硬件相关的实现做成可替换的能够显著降低测试成本。6. 实操过程与关键环节记录移植、构建与踩坑实录6.1 使用mbed-cli或CMake构建mbed OS工程的两种方式构建mbed OS工程现在主流有两种方式经典的mbed-cli和相对新一些的CMake。我个人的建议是如果只是快速原型验证用mbed-cli一条命令全搞定。如果项目要接入已有的CMake构建体系比如配合Jenkins、GitLab CI用CMake方式更合适。mbed-cli的使用示例mbed new my_project cd my_project mbed target NUCLEO_F103RB mbed toolchain GCC_ARM mbed compile它会自动拉取mbed-os仓库、配置目标平台、编译并生成固件。编译产物在BUILD/NUCLEO_F103RB/GCC_ARM/目录下通常是my_project.bin或my_project.hex。CMake方式稍微复杂一点但灵活性强。首先克隆mbed-os仓库然后在自己的工程里引用include(mbed-os/mbed-os.cmake) project(my_project C CXX ASM) add_executable(my_project main.cpp) target_link_libraries(my_project mbed-os)配置目标平台是用一个特殊的方式的——mbed-os.cmake会读取MBED_TARGET这个宏或者通过mbed_config.cmake文件来设置。这个文件通常由mbed tools生成或手动配置。在CMakeLists里可以这样指定add_definitions(-DMBED_CONF_TARGET_BOOTLOADER_ENABLE0) set(MBED_TARGET NUCLEO_F103RB)6.2 交叉编译工具链的选择GCC_ARM与Arm Compiler 5/6工具链的选择直接关系到编译通过率我在这上面栽过跟头。mbed OS从6.x开始官方主推Arm Compiler 6基于Clang和GCC_ARMarm-none-eabi-gcc。我们常见的arm compiler 5.06u7是老牌ARM编译器基于ARMCC很多老项目还在用。mbed OS 6对AC5的官方支持其实已经逐步弱化了个别新版本特性在AC5下编不过去。如果你手头的项目必须用AC5比如公司的CI环境绑定了建议用mbed OS 5.x分支而不是6.x。GCC_ARM的安装相对省事Ubuntu下一条命令就能装sudo apt install gcc-arm-none-eabi装完验证一下版本arm-none-eabi-gcc --version需要注意版本差异建议用9.x或更新的版本太老的GCC版本对C14/17特性的支持不完整mbed OS源码里可能编译不过。用mbed-cli时在工程里切换到GCC_ARM工具链的命令mbed toolchain GCC_ARM如果是CMake工程一般是在CMake里指定set(CMAKE_C_COMPILER arm-none-eabi-gcc) set(CMAKE_CXX_COMPILER arm-none-eabi-g)6.3 常见编译错误一瞥宏定义与mbed_configmbed OS项目编译之前需要先配置。这个配置实际上会生成一个mbed_config.h文件里面定义了海量的宏启停各种功能模块。举个例子你如果不想用网络功能可以在mbed_app.json里配置{ target_overrides: { *: { target.features_add: [], target.network-type: null } } }这样编译时网络协议栈就不会被编进去了固件体积能小很多。这种按需裁剪的机制是mbed OS能适应各种资源紧张MCU的重要原因。但这也带来一个问题你改了一个配置项编译出来行为可能完全不一样。排查问题时第一步永远是查看mbed_config.h确认当前裁剪配置是否符合预期。有时候你改了mbed_app.json但忘记重新生成配置编译用的还是旧配置导致链路调不通。这类问题排查起来非常浪费时间。6.4 实际移植一个自定义开发板的记录拿一个我最近帮朋友调试的案例来说他们有一块自主设计的STM32F401板子想把mbed OS跑起来。步骤大致是在targets/targets.json里添加新的目标条目指定芯片型号、时钟源、串口引脚等。创建targets/TARGET_STM/TARGET_STM32F4/TARGET_MyBoard/目录从相近的NUCLEO_F401RE拷贝PinNames.h、PeripheralPins.c、system_clock.c等文件。修改PinNames.h里的板级引脚别名比如板载LED是PC13就定义LED1 PC_13板载按键是PA0就定义BUTTON1 PA_0。检查PeripheralPins.c里的外设映射表确认板子上引出的SPI、I2C、UART引脚都在映射表里。修改时钟配置文件如果是外部晶振需要设置HSE_VALUE如果是内部RC可能需要改PLL配置。用mbed-cli编译烧录跑一个最简单的DigitalOut点灯测试验证基础功能。看起来简单但实际过程中我遇到最多的一个问题就是时钟配置不对导致printf乱码或者系统跑飞。STM32F401的时钟树比较复杂PLL配置稍有差错系统主频就不是你想要的84MHz。排查方法很简单在main里初始化串口之前先悄悄读一下SystemCoreClock的值打出来对比是否等于预期值。另外如果板子上的LED接的是高电平点亮而mbed驱动默认认为写1点亮你会发现程序“逻辑正确但灯不亮”。遇到这种情况不要怀疑mbed去查硬件原理图把逻辑改成写0点亮就行。在DigitalOut里加个取反不是大事但千万不要修改HAL层的行为否则会影响所有使用该引脚的功能。6.5 中断上下文与线程上下文之间如何高效通信说一个我在实际项目中反复用到的模式把HAL层的回调“翻译”成线程层面的信号。比如ADC连续采样DMA传输完成时会产生中断。在HAL层的中断处理函数里你可以这样写static void adc_done_irq(uint32_t id) { adc_t *obj (adc_t*)id; obj-completion_signal.set(1); }然后在你自己的线程里等待这个信号while (true) { adc_completion_signal.wait(); process_data(adc_buffer); }这样数据转换完成时工作线程会被唤醒然后去处理数据。这里用的是一个Semaphore或者EventFlags它们都可以在中断上下文中调用set/release非常安全。但如果你的中断处理比较频繁每次都要唤醒线程线程切换开销会比较大。这时候可以适当调整策略中断里只做最低限度的记录比如置一个volatile标志、累加一个计数由主循环定期去检查。这在处理高速数据流比如几百kHz的采样率时会更有优势能有效避免频繁上下文切换带来的“抖动”。7. 常见问题与排查技巧实录7.1 编译期问题速查问题现象可能原因排查手段编译报错找不到cmsis_os2.h目标平台没有配置RTOS检查mbed_app.json里的rtos配置确认target.rtos开启链接报错多个main定义同时启用了多个模块检查TESTS宏设置确认测试模块和应用main不冲突大量undefined reference to功能裁剪导致依赖缺失检查mbed_config.h中的宏按需打开对应的MBED_CONF_*配置编译通过但烧录后无现象时钟配置不对或启动文件不对确认system_clock配置用调试器查看PC指针是否进入HardFault7.2 运行期“黑屏”类问题HardFault与看门狗复位的排查这类问题在嵌入式调试中最耗时我分享一个自己的排查套路第一步排除电源和时钟问题。用调试器连接查看内核寄存器如果PC指针停在HardFault_Handler则基本确认是程序跑飞。第二步打开栈回溯。在GDB会话中执行bt查看函数调用链定位到出错的函数。如果栈已经被破坏就需要在HardFault_Handler里写一段代码把堆栈指针(PSP/MSP)里的返回地址保存下来备用。第三步打印调试信息。在关键函数入口加printf比如printf(enter spi_write\r\n)确定程序执行到哪里才出问题。注意如果printf本身会触发HardFault比如串口没初始化好这个方法会掩盖问题。所以调试串口要在main最开始就初始化并且用一个独立引脚来做。之前遇到一个HardFault最终定位是在DMA回调里访问了已经被释放的内存对象。因为DMA是异步的数据还在搬运时主线程已经把缓冲区释放了。解决方案是在DMA回调里加一个标志等DMA真正完成后才释放缓冲区或者用Mail的线程安全机制来管理缓冲区生命周期。7.3 中断风暴与低功耗带来的“幽灵行为”mbed OS在空闲时默认会进入WFI指令休眠。如果你的外设配置有误导致中断触发非常频繁比如引脚悬空GPIO中断在高低电平之间疯狂跳变CPU会被反复唤醒打乱调度节奏系统表现为“运行一会儿就卡死”或“功耗异常高”。排查手段用示波器抓GPIO中断引脚的波形看是否高频抖动。在ISR入口加计数器周期性打印中断触发次数。检查外部中断是否配置了上拉/下拉电阻确保静态电平确定。当时我做一个BSP时几个按键没接上拉电阻中断引脚悬空。系统表现为“按一下按键同时触发几十次中断”EventQueue被塞满主线程被饿死。解决办法很简单——在PinMode里配置PullUp并用EventQueue的call_in做5ms的消抖把重复触发合并成一次有效事件。7.4 关于printf和串口重定向的坑mbed OS的串口输出默认是通过STDIO_UART_TX和STDIO_UART_RX这两个板级引脚配置的。如果你用的是NUCLEO板这两个引脚被映射到了板载ST-Link的虚拟串口上。但如果你自己画板子可能串口引脚并不是这两个就需要在mbed_app.json里覆盖{ target_overrides: { NUCLEO_F103RB: { platform.stdio-baud-rate: 115200, platform.stdio-convert-newlines: true }, MyBoard: { platform.stdio-baud-rate: 9600 } } }platform.stdio-convert-newlines这个选项特别实用它会把\n自动转成\r\n否则很多串口工具会把输出变成阶梯状。7.5 网络功能相关的问题DNS、Socket与内存mbed OS带有完整的网络栈最常见的问题是内存不够。LwIP协议栈会吃几十KB的RAM如果你用的芯片RAM不大比如STM32F103C8T6的20KB默认配置很容易就爆了。配置方式是mbed_app.json里的lwip相关参数比如TCP窗口大小、最大socket数量、收发缓冲区大小都可以按需调小{ lwip.tcp-wnd-size: 4096, lwip.tcp-snd-buf-size: 4096, lwip.tcp-rcv-buf-size: 4096, lwip.mem-size: 32768 }调完要重新编译烧录后确认系统还能正常联网。我遇到过TCP吞吐量极低的情况最后发现是收发缓冲区太小加上窗口缩放没适配造成的。如果你做的是纯离线应用强烈建议在配置里禁用网络模块能省一大半RAM和Flash{ target.features_add: [], target.network-type: null }8. 对整个源码架构的思考与心得认真读完mbed OS的源码架构我最大的感受是它不只是一个RTOS而是一整套完整的嵌入式软件工程方法论。HAL层屏蔽了芯片差异是物理世界到逻辑世界的翻译官RTOS层通过CMSIS-RTOS v2标准统一了线程、同步、通信的抽象驱动层在HAL之上建立了面向对象的外设访问模型事件队列把中断与线程串联起来让异步编程变得清晰可控测试体系则保证了这套抽象在不同平台上的可维护性和可靠性。从一个从业者的角度我建议读源码的时候不要贪多求快而是带着问题去读。比如“为什么我调用DigitalOut会经过这么多层每一层做了什么”“EventQueue的call_in为什么能做到微秒级精度”顺着这样的问题一点点往下挖比漫无目的地刷源码有用得多。最后再分享一点别看mbed OS的项目现在热度不像几年前那么高但它的设计思路——分层抽象、事件驱动、可测试性、配置裁剪放到今天任何一个嵌入式项目的架构设计里依然是很好的参考模板。哪怕你最终不打算在产品里用mbed OS把它的架构读透了自己做技术选型和框架设计时也会多了很多底气。
返回列表