ARTICLE DETAIL

资讯详情

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

FreeRTOS从跑通到可维护:STM32嵌入式软件架构实战

FreeRTOS从跑通到可维护:STM32嵌入式软件架构实战 1. 先把问题摆正跑通FreeRTOS和用它搭软件架构是两件事很多人第一次接触FreeRTOS路径都差不多找个STM32F103C8T6的最小系统板把Source目录拖进Keil工程改改FreeRTOSConfig.h建两个闪灯任务编译下载灯闪了收工。这个过程通常一个下午就能搞定也确实值得高兴。但我带过几个刚毕业的同事之后发现一个共性现象他们能在一小时内跑通demo却在一个真实项目里撑不过两周——代码越加越乱任务之间靠全局变量互相偷看某个任务一卡整个系统跟着僵出问题只能靠串口printf到处埋点。问题不在FreeRTOS本身在于从一开始就没人告诉他们RTOS只是提供了并发和同步的零件真正决定项目能不能活下去的是架在这些零件之上的软件架构。这篇内容聊的就是这件事以STM32F103C8T6这类Cortex-M3平台为背景把FreeRTOS从能跑推进到能维护、能扩展、能交付。我会把移植阶段的配置项逐个拆开讲清楚为什么这么选再把任务划分、分层设计、层间通信、堆栈与内存规划这套软件架构的骨架搭出来最后配上我在实际项目里踩过的坑和排查方法。如果你现在正处于demo已经跑起来了但项目不知道从哪下手的阶段或者你的项目已经写了两三千行、开始感觉动一处就崩一处那这篇内容应该能帮到你。对于完全没接触过FreeRTOS的读者建议先把官方那本内核实现与应用开发的资料翻一遍前半部分至少搞清楚任务、队列、信号量这三个概念再往下看否则后面会觉得跳跃。还有一点得提前说明我这里讲的架构不是那种画满框框箭头的大厂标准而是一个三四个人、几万行代码量级的嵌入式项目真正用得上、撑得住的东西。过度设计在MCU上是要付出代价的每多一层抽象就多一次函数调用、多一份RAM占用F103C8T6只有20KB SRAM和64KB Flash这个账必须算清楚。2. 移植这一步配置项里藏着的全是后面架构的地基2.1 文件目录怎么摆决定了后面半年找代码的速度我见过太多工程把FreeRTOS的所有文件一股脑堆在工程根目录半年后自己都找不到vApplicationStackOverflowHook写在哪。建议在下手改配置之前先把目录结构定下来。我的习惯是分四层bsp/放和具体芯片引脚、时钟、外设寄存器强相关的东西比如GPIO初始化、串口底层收发hal/或drivers/放相对通用的外设驱动比如一个抽象过的串口读写接口、Flash读写接口service/放和业务无关但被多个模块共享的服务比如参数存储、日志、通信协议解析app/放真正实现业务逻辑的任务和状态机。FreeRTOS本身单独放third_party/freertos/绝不和业务代码混在一起。这个分法的好处是哪天芯片从F103换到F407或者H743你只需要重写bsp那一层上面的东西基本原样搬过去。有读者可能会说我就一个项目不会换芯片。但更现实的情况是同一个产品会有低配高配两个型号用的就是不同型号的MCU这时候分层能省下你几周的重复劳动。FreeRTOS自身需要加进工程的文件其实不多tasks.c、queue.c、list.c这三个是必须的timers.c看你用不用软件定时器event_groups.c看你用不用事件组stream_buffer.c在新版本里被消息缓冲和流缓冲依赖用了就得加。portable目录下Keil用RVDS/ARM_CM3IAR用IAR/ARM_CM3堆方案选一个MemMang/heap_x.c。这里有个坑ARM_CM3这个port是给不带浮点单元的M3用的如果你用的是M4F或者M7要换成对应的CM4F、CM7目录否则浮点上下文不会自动保存任务切换时浮点寄存器会被破坏现象是任务里算出来的浮点结果偶尔莫名其妙变成乱码。2.2 FreeRTOSConfig.h逐项过一遍每一项都有它的脾气这个文件是整个移植里最需要动脑的地方但网上大部分教程只告诉你照抄不告诉你为什么。我挑几个真正影响架构的讲。#define configUSE_PREEMPTION 1 #define configUSE_TIME_SLICING 1 #define configCPU_CLOCK_HZ ( 72000000UL ) #define configTICK_RATE_HZ ( 1000 ) #define configMAX_PRIORITIES ( 8 ) #define configMINIMAL_STACK_SIZE ( 128 ) #define configTOTAL_HEAP_SIZE ( ( size_t ) ( 10 * 1024 ) ) #define configMAX_TASK_NAME_LEN ( 12 ) #define configUSE_16_BIT_TICKS 0 #define configUSE_MUTEXES 1 #define configUSE_RECURSIVE_MUTEXES 1 #define configUSE_COUNTING_SEMAPHORES 1 #define configQUEUE_REGISTRY_SIZE 8 #define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_MALLOC_FAILED_HOOK 1 #define configUSE_TRACE_FACILITY 1 #define configUSE_TIMERS 1configTICK_RATE_HZ设成1000意味着一个tick是1毫秒。有人图省电或者图省CPU设成100我劝你别。tick太小的时候vTaskDelay(1)这种调用会引入很大的量化误差某些需要10ms级精度的逻辑会明显跑偏而且tick中断处理本身的开销比你想的小得多72MHz的M3上跑1000Hz节拍占用率百分之零点几完全没必要省。设成1000的代价是configUSE_16_BIT_TICKS必须为0因为16位tick在1000Hz下65秒就溢出了。configMAX_PRIORITIES设成8是因为优先级数字本身不消耗RAM消耗RAM的是就绪链表8个级别对绝大多数项目够用。真实项目里我一般只用0到5这六个级别0给空闲任务1给日志、参数存储这类后台任务2给普通业务任务3给通信协议解析4给需要快速响应的控制任务5留给极少数必须抢占一切的场合。留出余量是为了以后加东西的时候不用重新排优先级。configCHECK_FOR_STACK_OVERFLOW设成2而不是1这个后面排查章节会详细讲。configUSE_TRACE_FACILITY打开会增加一点点代码体积但换来的是可以在运行时遍历所有任务、看每个任务的栈使用峰值这个调试能力值这个价。configUSE_TIMERS打开后会额外创建一个定时器服务任务栈深度和队列长度记得配很多人打开这个宏之后忘了配configTIMER_TASK_STACK_DEPTH结果定时器任务栈溢出查半天。2.3 内存方案选型heap_1到heap_5没有最好只有最合适FreeRTOS的官方移植包里给了五种堆管理方案应该选哪个是面试和实际项目里都高频的问题。我用一张表把它们的差别摆清楚方案分配方式能否释放碎片问题适用场景heap_1只分配不释放否无任务和队列在启动时全部创建完之后不再动态申请heap_2首次适配可释放是有且不合并相邻块已不推荐官方建议用heap_4heap_3包装标准malloc/free是取决于C库需要线程安全的malloc且编译器堆空间充足heap_4首次适配相邻空闲块合并是显著缓解最通用的选择推荐heap_5heap_4基础上支持多块不连续内存是同heap_4内存分散在内部SRAM和外部SDRAM等不同区域我的默认答案是heap_4。理由很实际项目里总会有运行期动态创建的需求比如根据协议动态分配一个接收缓冲区或者OTA时临时开辟一块空间heap_4的相邻块合并能把长期运行后的碎片控制住。heap_1虽然绝对安全无碎片但要求所有对象在调度器启动前静态创建完毕实际项目中很难做到一旦你后期想动态建个队列就得回头重构。heap_5的使用场景是内存不连续。比如H743那类片子DTCM、AXI SRAM、SRAM1-4地址是分开的你想把FreeRTOS的堆铺在多块区域上就得用heap_5并在启动时调用vPortDefineHeapRegions()把各块起始地址和大小登记进去。注意这个函数必须在创建任何内核对象之前调用忘了顺序就会踩空。configTOTAL_HEAP_SIZE给多少是门手艺。在F103C8T6上我一般给8KB到10KB20KB总SRAM里剩下的留给全局变量、栈和编译器堆。这个值不是拍脑袋来的可以这么估每个任务控制块加栈栈按configMINIMAL_STACK_SIZE加上你实际逻辑需要算队列按元素大小×长度加固定开销算。更稳妥的做法是先给一个偏大的值跑起来之后调用xPortGetFreeHeapSize()和xPortGetMinimumEverFreeHeapSize()看历史最低水位再压到合理值。我个人的经验是留至少30%余量因为后期一定会加功能。2.4 Cortex-M3的中断优先级配错了就是随机死机这一节是移植阶段最容易出人命的地方。Cortex-M3的NVIC支持4位优先级也就是16个级别数值越小优先级越高。FreeRTOS要求把中断分成两类一类是不调用任何FreeRTOS API的可以给比configMAX_SYSCALL_INTERRUPT_PRIORITY更高的优先级享受极低延迟另一类是要调用FromISR系列API的优先级数值必须大于等于configMAX_SYSCALL_INTERRUPT_PRIORITY也就是优先级要更低。#define configPRIO_BITS 4 #define configLIBRARY_LOWEST_INTERRUPT_PRIORITY 15 #define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5 #define configKERNEL_INTERRUPT_PRIORITY \ ( configLIBRARY_LOWEST_INTERRUPT_PRIORITY (8 - configPRIO_BITS) ) #define configMAX_SYSCALL_INTERRUPT_PRIORITY \ ( configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY (8 - configPRIO_BITS) )这里有个历史上真实存在的坑早期的STM32库函数在NVIC_Init里会自动做优先级移位而FreeRTOS的宏configMAX_SYSCALL_INTERRUPT_PRIORITY已经是移位之后的值于是有人直接把这个宏塞进库函数结果优先级被移了两次变成错误的值。现在HAL库和LL库已经统一不做自动移位了但老项目里还是一片混乱。判断方法很简单你如果用的是标准外设库的NVIC_Init参数里传的应该是移位后的值也就是直接用那两个宏如果用HAL库的HAL_NVIC_SetPriority它内部会自己移位所以传进去的是未移位的数值0到15。这个区分不搞清楚症状就是串口中断里调用xQueueSendFromISR偶尔进HardFault。还有一个必须提的点优先级分组的设置。默认复位后是分组0即4位全部用作抢占优先级没有子优先级位。FreeRTOS官方示例里也有写分组4的那是3位抢占1位子优先级。这个分组必须和你的中断优先级规划配合改分组会影响你所有已配置的中断行为所以定下来就别再动。2.5 内核切换流程SVC、PendSV、SysTick各管什么理解这三个异常的分工很多任务为什么不切换的疑问就自动解开了。SVC是请求启动调度器时用的vTaskStartScheduler()内部会触发一次SVC异常在SVC处理程序里完成第一个任务的上下文恢复从MSP切到PSP然后正式开跑。SysTick按configTICK_RATE_HZ周期性触发做两件事一是把系统节拍计数加一检查有没有延时到期的任务需要唤醒二是判断当前是否有同优先级任务需要轮转。PendSV才是真正干切换这个重活的它被设计成最低优先级只在没有其他中断活跃时才执行这样可以保证任务上下文切换绝不会插到某个中断处理中间去做。为什么不直接在SysTick里切换因为SysTick是个周期中断随时可能打断别的高优先级中断如果在里面做上下文保存和恢复嵌套中断的栈管理会极其复杂。把切换动作推给最低优先级的PendSV等于让所有中断先处理完再统一决定谁该运行这是Cortex-M架构上非常经典的设计。你在调试时如果看到某任务被挂起后半天不切走先看PendSV有没有正常触发再看configUSE_PREEMPTION是否为1、configUSE_TIME_SLICING对同优先级任务的影响。3. 软件架构落地任务怎么切、层怎么分、消息怎么走3.1 任务划分的两条线节拍一致性和职责独立性任务不是越多越好。我见过一个项目切了14个任务结果光任务栈就把8KB堆吃完了剩下什么东西都放不下。切任务我一般用两条线来卡。第一条线是节拍一致性。所有需要周期性执行、且周期相同的逻辑尽量放进同一个任务。比如每10ms采一次传感器、每10ms跑一次PID这两件事合在一个控制任务里用一个vTaskDelayUntil驱动比起开两个任务各自延时省下一个任务栈至少512字节也省了两次上下文切换。如果两件事周期差一个数量级比如一个是10ms一个是1s那就必须拆开否则慢的那件事会把快的节拍拖死。第二条线是阻塞特性独立性。凡是会长时间阻塞等待外部事件的东西一定要单独成任务。最典型的是通信接收——它要阻塞在队列或流缓冲上等数据如果和周期控制逻辑放在一个任务里一旦数据不来控制逻辑跟着停摆。所以通信任务、GUI刷新任务、参数存储任务这类天然带阻塞的单独切。按这两条线一个中等规模项目通常是这样的任务清单任务名优先级周期/触发方式栈估算主要职责AppCtrl410ms周期256字状态机驱动、控制量计算CommRx3阻塞等待队列256字串口/总线收包、解帧、投递事件CommTx2阻塞等待队列256字发送队列调度、重传Storage1事件触发256字参数读写、Flash磨损管理Log1事件触发192字环形缓冲日志落盘/输出IDLE0系统128字空闲任务低功耗钩子这里的栈单位是StackType_t在32位M3上等于4字节所以256字是1KB。这个数字是估算值最终要以uxTaskGetStackHighWaterMark()测出来的水位为准。3.2 分层怎么落五个层次各自的边界架构分层最难的不是画出层次而是划清楚哪些代码允许调用哪些代码。我给自己定的规矩是这样的bsp层只能被hal层调用它里面出现的是寄存器操作和具体引脚号绝对不允许出现业务词汇。你会看到bsp_uart1_init、bsp_uart1_write_bytes这样的函数名不会看到bsp_send_sensor_data。hal层对外提供设备无关的接口比如hal_uart_write(port, buf, len)、hal_flash_read(addr, buf, len)。它内部去调用对应的bsp函数。这样上层代码只依赖hal_uart_write换芯片时改bsp实现即可。service层放被多个业务模块共享的服务比如协议编解码、CRC校验、参数管理、事件总线。这一层不允许直接操作寄存器只能通过hal层。它也不应该知道具体的业务流程只提供给我一个帧我解出命令和载荷这类能力。app层是业务逻辑任务函数、状态机、业务规则都在这。它可以调用service和hal但绝不允许直接调bsp。framework或rtos_wrapper层很薄但很关键。它把FreeRTOS的API做一层浅浅的封装比如os_queue_create、os_queue_send、os_delay_ms。为什么要多这一层两个理由一是哪天你因为某些原因要换成别的内核改动集中在这一层二是封装的函数名更符合业务语境读代码的人不需要反复猜xQueueSend的超时参数单位是tick还是毫秒。注意封装层要薄。我见过有人把队列封装成一个完整的消息中间件带路由、带订阅表结果开销比直接用队列大十倍。封装的目的是收敛依赖不是炫技。3.3 事件总线用一个队列串起所有任务间的通信任务间通信最容易走偏的地方是每两个需要通信的任务之间建一条专用队列。三个任务互相通信要建三条队列五个任务要建十条。队列对象本身要占RAM而且发送方要知道接收方是谁耦合度极高。我的做法是搞一条全局事件队列所有任务往同一个队列发事件由一个分发任务或各任务的统一入口去处理。事件结构体设计得很小typedef enum { EVT_SENSOR_READY 0x0100, EVT_COMM_FRAME_OK, EVT_COMM_TIMEOUT, EVT_PARAM_CHANGED, EVT_KEY_PRESSED, EVT_LOW_POWER_REQ, } evt_id_t; typedef struct { uint16_t id; uint16_t len; uint8_t payload[8]; } evt_t;payload给8字节可以塞一个指针、一个结构体或者几个标量参数。整个结构体16字节队列长度开16就是256字节加固定开销非常省。这个设计的关键在于事件ID用一个统一枚举集中管理谁发了什么事件一目了然出问题时在分发函数入口打一行日志就能看到事件流水。代价也要说清楚所有事件挤一条队列高优先级事件可能排在一个大事件后面等。解决办法是给队列配足够长度并且在分发逻辑里保证每个事件处理足够快不阻塞。如果确实有对延迟极其敏感的通信那可以例外地给它开一条专用队列但这类例外我一般控制在两条以内。3.4 任务模板状态机加事件驱动别写成流水账反面教材是这样的任务体void AppTask(void *pv) { Init(); while (1) { ReadSensor(); Calc(); if (flag 1) { DoA(); } if (flag 2) { DoB(); } SendResult(); vTaskDelay(10); } }这段代码在功能简单时能跑一旦状态多了、分支复杂了就变成一张蛛网加一个功能要改三处删一个功能要担心漏删。我的模板长这样static void AppTask(void *pv) { evt_t evt; TickType_t last_wake xTaskGetTickCount(); App_Init(); for (;;) { if (xQueueReceive(g_evt_q, evt, pdMS_TO_TICKS(APP_TICK_MS)) pdPASS) { App_OnEvent(evt); } App_OnTick(); vTaskDelayUntil(last_wake, pdMS_TO_TICKS(APP_TICK_MS)); } }App_OnEvent处理异步事件用switch按事件ID分发App_OnTick处理周期性逻辑里面用状态机推进。两个入口分开之后异步和同步逻辑不会互相污染状态机的每个状态只需要关心进入时做什么、tick时做什么、退出时做什么。这里有个细节值得单独讲xQueueReceive的超时时间设成了和任务周期一样的值配合vTaskDelayUntil任务的实际周期不会因为队列阻塞而漂移。如果你用vTaskDelay那么每次阻塞等待的时间会叠加在延时上慢任务会越来越慢这是很多人测出来实际周期比设定值大的原因。vTaskDelayUntil基于任务的唤醒时间点计算能把漂移压住。3.5 通信接口层帧协议设计和分包处理和上位机打交道协议帧的设计直接决定后期联调要熬多少夜。我固定用这个格式| 帧头(2B) | 命令(1B) | 长度(2B) | 载荷(NB) | CRC16(2B) |帧头用两个不太容易出现在载荷里的字节比如0xAA 0x55。长度字段解决载荷里本身就含帧头字节的情形收到帧头之后必须等够长度加CRC才能确认成帧不能一看到帧头就当新帧开始。串口接收这一端我会开一个环形缓冲或者用FreeRTOS的流缓冲Stream BufferADC中断或者DMA完成中断往里塞数据CommRx任务从里面取。任务里做的是状态机式的组帧typedef enum { S_HEAD1, S_HEAD2, S_CMD, S_LEN_H, S_LEN_L, S_PAYLOAD, S_CRC_H, S_CRC_L } rx_state_t; static void Rx_Feed(uint8_t byte) { switch (rx_state) { case S_HEAD1: if (byte 0xAA) rx_state S_HEAD2; break; case S_HEAD2: rx_state (byte 0x55) ? S_CMD : S_HEAD1; break; /* 后续状态类似逐字节推进 */ } }逐字节状态机的最大好处是内存占用恒定不受帧长度变化影响而且能在拿到完整帧之后才做CRC校验避免半截帧被误处理。用DMA加空闲中断的接收方式效率更高但组帧逻辑不变只是数据来源从字节中断变成了DMA缓冲。上位机这端如果用的是Qt串口readyRead信号触发时不要立刻解析先readAll追加到一个成员缓冲再调用同一个组帧状态机。很多人在这里踩的坑是把一次readyRead当成一帧实际上一帧可能分两次到达也可能两帧一次到齐这两种情况在调试时都会出现必须靠缓冲拼帧解决。4. 调试和排查堆栈、HardFault、那些半夜爬起来查的问题4.1 堆栈溢出检测的两种模式区别比你想的大configCHECK_FOR_STACK_OVERFLOW设成1和2检测原理完全不同。设成1时内核在每次任务切换时检查当前任务的栈指针是否已经越过了任务栈的边界。这个检查非常快几乎零成本但它有个致命弱点只在切换的那一瞬间检查。如果一个任务在两次切换之间把栈冲掉了然后又恢复了栈指针这个检查就抓不到。设成2时任务创建时会往栈的末尾填一段固定模式通常是0xA5A5A5A5然后每次切换时检查这最后16个字节是否还是原样。如果被改写了说明栈曾经涨到过这里立刻触发溢出钩子。这个方式能抓到曾经溢出代价是任务创建时多一点点初始化和每次切换时多读16字节几乎可以忽略。我默认设成2并且钩子里一定要留证据void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { /* 这里不要用printf栈已经不可靠了 */ g_overflow_task_name pcTaskName; taskDISABLE_INTERRUPTS(); for (;;) { /* 停在这里等调试器 */ } }注意钩子函数里不能调用任何可能再用栈的东西printf这种重函数绝对不能调否则你会看到进了钩子又飞出去死得更惨的现象。正确做法是保存任务名停住让调试器接手。判断一个任务是否栈紧张最直接的办法是uxTaskGetStackHighWaterMark()它返回任务运行以来栈的最小剩余量单位是字。如果这个值小于32我就会给它加栈。经验值是剩余量保持在总栈深的25%以上比较安心因为不同的编译优化等级、不同的函数调用路径会让栈用量波动。4.2 HardFault定位从寄存器里读出真相HardFault是嵌入式开发里最让人头疼的一类问题因为它往往现场已经破坏复现困难。但Cortex-M的硬件设计其实给了足够的线索。进HardFault时先看压栈的8个寄存器找到触发异常的那条指令地址再去反汇编里定位。关键是SCB-CFSR这个寄存器它分三部分MMFSR指示总线访问错误BFSR指示总线错误UFSR指示用法错误。比如UFSR的DIVBYZERO位被置起那就是除了零UNALIGNED位被置起那就是未对齐访问。我习惯在HardFault处理程序里把这些寄存器打成一个结构体存到保持域内存里然后复位重启复位后从保持域读出来打印。这样即使现场复位了线索还在。代码大致是__attribute__((section(.noinit))) static fault_info_t g_fault;.noinit段不会被启动代码清零适合放这类跨复位的数据。注意不同工具链的段名写法不一样IAR用的是 .noinit需要按编译器调整。实际项目里HardFault的成因排序大致是空指针解引用、越界访问数组、栈溢出破坏了返回地址、中断优先级配错导致内核状态损坏、以及浮点上下文没保存。前四个占了绝大多数。4.3 常见问题速查表下面这张表是我这几年攒下来的按现象-可能原因-排查动作组织现象常见原因排查动作任务只跑一次就停栈不够任务体内死循环未让出CPU看高水位检查有无长循环串口中断一进就死中断里调了非FromISR版本API检查ISR内所有API调用队列/信号量永远拿不到中断优先级高于内核可管理范围核对NVIC优先级数值系统跑几小时后死机堆碎片或内存泄漏定期打印最小剩余堆任务周期逐渐变慢用了vTaskDelay而非vTaskDelayUntil检查周期驱动方式二值信号量第一次就拿到新旧版本创建语义不同确认版本并显式置空/释放优先级反转导致响应变慢用二值信号量做互斥改用互斥量打开优先级继承定时器任务崩溃忘了配定时器栈深度和队列长度检查configTIMER相关宏关于二值信号量那一条值得展开。在较早的FreeRTOS版本里xSemaphoreCreateBinary()创建出来的信号量初始状态是已释放也就是第一次xSemaphoreTake会立刻成功而较新的版本改成了初始为不可用必须先xSemaphoreGive一次才能拿到。如果你的代码是照着老教程写的升级内核之后行为就反了症状是某个等待逻辑一开始就冲过去了。这不是bug是设计变更写代码时如果依赖初始状态就显式地写一行Give别依赖默认行为。4.4 运行时统计让系统自己告诉你哪里卡打开configGENERATE_RUN_TIME_STATS和configUSE_STATSRUNTIME再提供一个高精度计数源可以用另一个定时器比如TIM2频率跑在tick的10倍以上就能调vTaskGetRunTimeStats()拿到每个任务的CPU占用百分比。这个功能在排查CPU被谁吃满了时效率极高。我遇到过一次典型情况设备待机时功耗比预期高不少。用运行时统计一看某个本该休眠的任务占了百分之三十多的CPU原因是它内部有个循环在等一个标志位写法是while (flag 0);纯忙等。改成阻塞在信号量上之后CPU占用掉到百分之二功耗也跟着下来了。没有这个统计工具这个问题靠肉眼看代码很难发现因为它能正常工作只是效率差。调试阶段还会用vTaskList()打印所有任务的状态和栈水位配合一个周期性的日志任务形成一个简易的运行时看板。发布版本把这些宏关掉省下代码空间。5. 让架构撑得住后续扩展中间件接入和几个改过好几版的经验5.1 LVGL这类中间件接入时架构上要预留什么给带屏产品加图形界面时LVGL是常见选择它和FreeRTOS的配合有几个约定必须遵守。第一是tick来源LVGL需要一个毫秒级递增的时基最省事的做法是给它挂一个钩子直接返回xTaskGetTickCount()这样不用额外开定时器。第二是lv_timer_handler()的调用周期一般放在一个独立的任务里周期设成5到10毫秒周期太长界面卡顿太短则白白占用CPU。第三点最关键LVGL本身不是线程安全的所有对界面对象的操作必须集中在同一个任务里。如果你的业务任务直接去改界面上的一个label文本同时GUI任务正在刷新轻则花屏重则进HardFault。我的处理方式是在架构里加一层界面请求接口业务任务不直接碰LVGL对象而是往一个队列里发界面更新请求GUI任务收到之后统一执行。多写一层转发换来的是界面不会因为你改业务逻辑而莫名其妙崩掉。第三方的中间件都遵循这个原则谁拥有的资源谁负责操作别人只能通过消息请求。5.2 命名和头文件纪律是架构能不能维持住的隐形因素架构衰退往往不是一下子崩的是从命名开始慢慢烂的。我给团队定的几条硬规矩全局变量一律加模块前缀比如g_comm_rx_buf静态函数加模块前缀比如Comm_ParseFrame每个模块只暴露一个对外头文件内部的私有函数声明放在模块自己的私有头里不往外放跨模块调用只允许通过对外头文件里的接口不允许extern别人的内部变量。这些规矩听起来琐碎但它们的效果在项目做到一万行之后非常明显。你翻任何一个模块的目录一眼就能看出它的边界在哪改了会不会影响别人。我见过一个没有这些纪律的项目两个模块通过一个全局变量传递状态后来有人改了那个变量的含义另一个模块的作者不知道出了个很难查的问题最后靠对比两个版本的提交记录才找到。5.3 关于任务优先级和中断延迟我改过好几版的结论早期我做项目喜欢给关键任务很高的优先级觉得这样响应快。后来发现高优先级任务如果写入不当会饿死低优先级任务尤其是日志和存储这种看起来不重要但必须完成的任务。我现在的做法是优先级数量控制在四到五档把能容忍多长延迟作为排优先级的唯一标准而不是业务重要程度。具体一点如果一个任务的响应延迟要求是10毫秒以内那它排在控制任务那一档如果延迟能容忍100毫秒那它排在业务任务那一档日志和存储这种能容忍几百毫秒的放在次低档。唯一能排到最高档的是那种错过就丢数据的场景比如高速采样而这种场景我通常直接放进中断里处理不走任务。中断延迟的计算也要有个概念。从外设触发中断到中断服务程序第一条指令执行中间的时间受两个因素影响当前是否有更高优先级中断在跑以及当前是否在临界区里。FreeRTOS的临界区会关中断关的是configMAX_SYSCALL_INTERRUPT_PRIORITY及以下的中断也就是优先级数值大于等于5的那些。如果某个中断的实时性要求极高把它的优先级设到5以上它就不受临界区影响但它也就无法调用任何内核API了这个权衡在做设计时就得想明白。5.4 面试里那些问题其实问的都是架构理解FreeRTOS相关的面试问题翻来覆去就那么几类但每一类背后的意图其实是看你有没有真正在项目里用过。问信号量和互斥量的区别真正想听的是你有没有处理过优先级反转问任务切换怎么实现的想看的是你知不知道PendSV和PSP/MSP的分工问heap方案怎么选想确认的是你有没有算过内存账问栈溢出怎么排查考的是你会不会用高水位工具和钩子函数。我个人的建议是准备这类问题的时候不要背答案而是把你自己项目里的决策过程讲出来为什么给这个任务分了256字的栈为什么堆给了10KB为什么这条队列长度是16。具体的数字加上具体的理由比任何标准答案都有说服力。面试官能听出来你是真的做过还是临时抱佛脚。6. 最后再分享几个我在实际项目里养成的习惯我现在的习惯是任何新项目在跑通点灯之后第一件事不是忙着写业务而是先把架构骨架立起来建好目录写好事件总线的队列和分发函数定好任务清单和优先级表把configASSERT、栈溢出钩子、内存分配失败钩子这三个钩子先填上然后才开始写第一个业务任务。这么做会多花半天时间但后面能省下无数个小时。我见过太多反过来的例子先堆功能等到代码两万行了才想起来要分层那时候改造成本已经大到没人愿意动了。还有一个习惯是每加一个新任务就顺手在运行时统计里确认它的栈水位和CPU占用别等到系统跑不稳了再回头排查。栈这种东西问题积累到一定程度才会暴露暴露出来的时候往往是在客户手上那时候就晚了。另外二值信号量代替互斥量这种图省事的写法在早期只有两个任务的时候可能看不出问题任务一多就会变成随机延迟这种问题最难查因为它不报错只是偶尔慢一下。该用互斥量的地方一开始就用对成本最低。顺着这个思路如果你现在的项目正卡在能跑但不敢改的阶段可以先从最痛的那一块下手——通常是任务划分和层间通信——把这部分重构清楚其余的可以慢慢来。架构不是一次成型的是一轮一轮演进出来的。
返回列表