ARTICLE DETAIL

资讯详情

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

两周吃透FreeRTOS队列:STM32CubeMX配置与源码实战

两周吃透FreeRTOS队列:STM32CubeMX配置与源码实战 1. 为什么我建议用两周时间死磕 FreeRTOS 队列FreeRTOS 这套内核我第一次真正把它用进产品里是在一块多路温度采集的板子上。当时的直观感受是任务创建、优先级、时间片这些概念翻两页手册就能明白个大概可一旦涉及到任务与任务之间怎么安全地把数据递过去坑就全冒出来了。全局变量配标志位能跑通但跑不稳外面套一层临界区能稳住实时性又被拖垮。真正把这个矛盾解决掉的就是队列Queue。这次拿 STM32CubeMX 来配 FreeRTOS 队列是我认为最省事的入门路径。图形界面把任务优先级、栈大小、队列长度、单项字节数全摆在面板上点几下生成代码就能编译运行不用一开始就在main.c里跟一堆宏定义搏斗。而队列这一层恰好又是 FreeRTOS 内核里承上启下的关键部位往上它支撑着信号量、互斥量、队列集、任务通知这些通信机制往下它紧紧贴着内存管理和任务调度。把它啃透回头再看源码其他部分会顺畅很多。这篇内容面向两类人。一类是刚接触 STM32 和 RTOS想知道队列到底怎么用、CubeMX 里那些参数该怎么填的入门者另一类是用过队列 API、但从来没打开过queue.c一遇到队列满、数据错乱就无从下手的进阶者。两周不算长但切分得当足够从建工程一路走到看懂源码主干。1.1 队列为什么值得作为第一个深入的内核模块很多人学 FreeRTOS 的顺序是先学任务再学信号量最后学队列我不太赞同。原因是信号量和互斥量在源码层面根本就是队列的特殊形态。你去看xSemaphoreCreateBinary的实现它内部调用的就是xQueueGenericCreate( 1, 0, queueQUEUE_TYPE_BINARY_SEMAPHORE )——长度为 1、单项大小为 0 的一个队列。二值信号量本质上是不搬运数据、只传递事件的队列。计数信号量同理只是长度变成了 N。互斥量稍微特殊一点它用的是queueQUEUE_TYPE_MUTEX类型并且复用了Queue_t里那个联合体成员u.uxRecursiveCallCount来记录递归持锁次数。也就是说你只要把Queue_t这个结构体吃透后面再看xSemaphoreTake、xSemaphoreGive甚至xQueueSet的实现都是同一套代码的变体。从工程价值上讲队列解决的是三个具体问题。第一是任务解耦采集任务只管往队列里丢数据处理任务只管从队列里取数据两边不需要知道对方的优先级、周期、栈大小。第二是数据缓冲处理任务偶尔被高优先级任务抢占、来不及消费的时候队列天然的环形缓冲区能顶一下不至于丢数据。第三是带超时的同步xQueueReceive可以设一个等待时间等不到就返回pdFALSE这比用全局标志位轮询优雅得多也不会白白占着 CPU。提醒一句队列不是万能的。它内部有一次内存拷贝单项数据越大拷贝开销越明显。后面第 3.4 节会专门算这笔账。1.2 两周时间怎么切分才不白费我最怕看到的学习方式是前两天猛看 API中间一周不碰最后三天突击。RTOS 这东西手感很重要断三天再捡起来配置宏全忘了。下面这个节奏是我自己带过两轮之后总结出来的每天投入 1.5 到 2 小时周末各留半天做动手实验。天数主题当天必须完成的可验证产出第 1-2 天环境搭建与工程骨架用 CubeMX 生成一个能跑的双任务工程串口能打印日志第 3-4 天任务状态与调度亲手制造一次优先级反转观察现象并记录第 5-7 天队列 API 与源码打开 queue.c跟完 xQueueSend 的完整调用链第 8-9 天信号量与互斥量验证二值信号量就是长度为 1 的队列这个结论第 10-11 天事件组与任务通知对比队列和任务通知的效率差异做一次实测第 12-13 天内存管理 heap_1~heap_5切换堆方案观察内存碎片和分配失败的表现第 14 天综合小项目一个采集-处理-上报的三任务系统全部用队列串联这里有个坑要提前说第 5 到 7 天安排了三整天在队列上不是浪费时间。因为队列的源码是 FreeRTOS 里注释最密、分支最全的一个文件queue.c加上queue.h差不多有两千多行其中光xQueueGenericSend一个函数就有四五种返回路径。三天时间摊下来刚刚好再压缩就只能看个热闹。注意事项第 5 天开始看源码之前先把FreeRTOSConfig.h里的configUSE_PREEMPTION、configUSE_TIME_SLICING、configUSE_MUTEXES三个宏改成你最终要用的值。中途改这些宏队列的行为会变前面看过的代码得重看一遍。2. STM32CubeMX 建工程把队列的骨架搭出来用过 CubeMX 的人都知道这个工具最大的价值不是帮你写代码而是帮你把时钟树、引脚复用、外设初始化这些琐事一次性摆平。FreeRTOS 作为中间件被集成进来之后任务和队列都能在图形界面里配置生成的代码虽然啰嗦但结构清晰非常适合对照着看源码。2.1 环境版本选择与几个必须改的默认项我这套流程用的组合是 STM32CubeMX 6.x、STM32CubeIDE或者 Keil MDK 5.3x 配合 ARM Compiler 6、芯片选 STM32F103C8T6 或者 F407。CubeMX 里下载的固件包版本决定了 FreeRTOS 内核的版本F1 系列固件包里带的一般是 FreeRTOS V10.3 之后的版本接口已经比较稳定。新建工程之后有几处默认配置必须动否则后面会莫名其妙卡住SYS 里的 Debug 要选 Serial Wire。不选的话下载完程序芯片可能被锁得用 BOOT0 拉高才能救回来这个坑踩一次就够了。SYS 里的 Timebase Source 要从 SysTick 改成一个普通定时器比如 TIM1 或 TIM6。原因很直接FreeRTOS 自己要拿 SysTick 当系统节拍源HAL 库的HAL_Delay也需要一个时基两者抢同一个中断必然打架。CubeMX 在你勾选 FreeRTOS 之后会给出这个提示照做就行。RCC 里的 HSE 选 Crystal/Ceramic Resonator然后在 Clock Configuration 页面把主频拉到 72MHzF103或 168MHzF407。主频影响configCPU_CLOCK_HZ进而影响节拍计算别偷懒用默认的 8MHz 内部时钟。改完这几项切到 Middleware 分类下的 FREERTOSInterface 有两种选择CMSIS-RTOS v1 和 CMSIS-RTOS v2。我建议新手先用CMSIS-RTOS v2注释清楚函数名以os开头等你熟悉了再切回原生 API也就是xQueueSend那一套因为源码分析必须基于原生 APICMSIS 那层封装只是薄薄一层皮。2.2 Tasks and Queues 页面里的参数到底怎么填这个页面是重点很多人在这里填错参数跑起来不是栈溢出就是队列满。先看任务那一栏。Stack Size的单位是 word不是字节。填 128 意味着 512 字节32 位机上一个 word 是 4 字节。默认任务一般给 128 到 256 就够了如果任务里用到printf、sprintf这类函数栈要往上加因为格式化库内部会吃掉不少栈空间。我的经验值是普通逻辑任务 256 word带浮点运算或者字符串格式化的任务 512 word。Priority这一栏在 CMSIS-RTOS v2 下用的是osPriorityXXX枚举它和 FreeRTOS 内部的 0 到configMAX_PRIORITIES-1之间有个映射关系。比如osPriorityNormal对应的是 24 左右具体值取决于configMAX_PRIORITIES的设置。如果你发现两个任务的优先级表现和想象中不一样先回来看这个映射表别急着怀疑调度器。队列那一栏要填三个东西我用一个具体案例说明。假设我要把 ADC 采到的 16 位数据和采样序号一起传给处理任务那么参数名填写值含义与依据Queue NameAdcDataQueue生成代码里的变量名建议带业务前缀Queue Size16队列能存多少个元素。采集周期 1ms、处理耗时 8ms至少留 8 个缓冲取 16 更稳Item Size4单个元素的字节数。结构体含一个 uint16_t 加一个 uint16_t对齐后是 4 字节AllocationDynamic用 heap 分配配合 heap_4 使用内存紧张时改 Static这里有个细节很容易被忽略Item Size填的是字节数而Queue Size填的是元素个数。两者相乘就是队列存储区的大小再加上队列结构体本身的开销全部从 FreeRTOS 的堆里出。所以你在 FreeRTOS 配置页把configTOTAL_HEAP_SIZE设成 10240 的时候心里要大概有数这些空间被谁吃掉了。实操心得CubeMX 生成的队列创建代码会塞在MX_FREERTOS_Init里CMSIS-RTOS v2 下是osMessageQueueNew。如果你想在源码层面看清楚它到底干了什么把这行替换成xQueueCreate( 16, 4 )效果完全一样而且调试时更方便打断点。2.3 生成代码之后先看哪三个文件代码生成完之后别急着写业务先把下面几个文件翻一遍十分钟就能建立起整体印象。第一个是Core/Src/main.c。主要看main函数里osKernelInitialize()、MX_FREERTOS_Init()、osKernelStart()这三行的顺序。注意osKernelStart()之后的代码永远不会被执行因为调度器已经接管了 CPU。第二个是Core/Src/freertos.c。所有任务函数体和队列、信号量的创建都在这儿。CubeMX 会自动生成一个StartDefaultTask里面是个空的for(;;)循环加一句osDelay(1)。队列创建语句也在这儿找到osMessageQueueNew或者xQueueCreate那一行。第三个是Core/Inc/FreeRTOSConfig.h。这个文件里的宏决定了整个内核的行为比如configTOTAL_HEAP_SIZE、configMAX_PRIORITIES、configCHECK_FOR_STACK_OVERFLOW。我一般会在里面手动补两行#define configASSERT( x ) if( ( x ) 0 ) { taskDISABLE_INTERRUPTS(); for( ;; ); } #define configUSE_QUEUE_SETS 1第一行让参数检查失败时直接停在原地方便定位第二行把队列集功能打开第 4.3 节会用到。3. 队列源码逐层拆解从创建到收发前面都是铺垫这一章才是正餐。我建议你打开Middlewares/Third_Party/FreeRTOS/Source/queue.c跟着往下看。3.1 Queue_t 结构体与环形缓冲区布局queue.c开头的Queue_t定义是整个队列机制的地基我把它简化之后写在这儿typedef struct QueueDefinition { int8_t *pcHead; /* 存储区起始地址 */ int8_t *pcTail; /* 存储区结束地址1 */ int8_t *pcWriteTo; /* 下一个写入位置 */ union { int8_t *pcReadFrom; /* 下一个读取位置 */ UBaseType_t uxRecursiveCallCount; /* 互斥量递归计数复用此成员 */ } u; List_t xTasksWaitingToSend; /* 因队列满而阻塞的任务链表 */ List_t xTasksWaitingToReceive; /* 因队列空而阻塞的任务链表 */ volatile UBaseType_t uxMessagesWaiting; /* 当前元素个数 */ UBaseType_t uxLength; /* 队列容量元素个数 */ UBaseType_t uxItemSize; /* 单项字节数 */ volatile int8_t cRxLock; volatile int8_t cTxLock; } xQUEUE;关键的几个指针摆在一起看就清楚了。pcHead和pcTail一前一后框出了整块存储区长度是uxLength * uxItemSize字节。pcWriteTo从pcHead开始往后走每写一个元素加一个uxItemSize走到pcTail就绕回pcHead。u.pcReadFrom的初始化很有意思它不是指向pcHead而是指向最后一个元素的位置也就是pcHead (uxLength - 1) * uxItemSize。代码里这么做是为了让读指针在第一次读取时自动绕回到pcHead省掉一次额外的边界判断。这是很典型的嵌入式写法用初始状态的巧思换取运行时的一次比较开销。uxMessagesWaiting这个字段是队列状态的核心指标。它等于 0 表示队列空等于uxLength表示队列满。xQueueSend和xQueueReceive的第一步判断都基于它。你如果在调试时怀疑队列有问题直接把这个字段加到 Watch 窗口里看一眼比什么都直观。最后两个cRxLock和cTxLock平时是queueUNLOCKED只有在队列集场景下才被置位。作用是防止在队列集操作期间别的任务偷偷改动xTasksWaitingToSend这类链表。第 4.3 节会具体讲。3.2 入队全链路xQueueSend 究竟做了什么xQueueSend本身是个宏展开之后调用xQueueGenericSend( pxQueue, pvItemToQueue, xTicksToWait, queueSEND_TO_BACK )。核心逻辑在一个for(;;)死循环里我按分支给你捋一遍。进入循环先做一次taskENTER_CRITICAL()也就是关中断加进入内核临界区。然后在临界区里面判断uxMessagesWaiting uxLength。如果条件成立说明还有空位直接调prvCopyDataToQueue把数据拷进去uxMessagesWaiting加一。拷完之后紧接着检查xTasksWaitingToReceive链表是不是空的——如果不是空的说明有任务正等着从这个队列取数据于是调xTaskRemoveFromEventList把等待时间最长或者优先级最高的那个任务从阻塞链表里摘出来挂到就绪链表。摘完之后如果是抢占式调度会调queueYIELD_IF_USING_PREEMPTION实质上是触发一次PendSV等退出临界区之后立刻切换到那个被唤醒的任务。如果队列已经满了代码会分两种情况。xTicksToWait等于 0说明调用者不想等退出临界区直接返回errQUEUE_FULL。xTicksToWait大于 0就要挂起了调vTaskPlaceOnEventList把当前任务挂到xTasksWaitingToSend链表上同时把它的超时时间记进内核的节拍链表然后调prvUnlockQueue处理可能的队列集锁定状态退出临界区之后执行一次portYIELD_WITHIN_API()主动让出 CPU让别的任务跑。等这个任务被唤醒的时候——要么是别人从队列里取走了数据腾出了空位要么是等待超时——代码会重新回到for(;;)开头再走一遍刚才的判断流程。这就是为什么外面必须套一个无限循环唤醒不等于成功只是获得了一次重试的机会。注意事项xQueueSend和xQueueSendToBack是等价的都是往队尾塞xQueueSendToFront是插到队头。插队头这个操作在中断里比较常用适合处理优先级高的紧急消息。但要注意插队头会让队列的先进先出顺序被打乱如果你依赖顺序就别用。3.3 出队与阻塞唤醒xQueueReceive 的另一半出队的入口是xQueueGenericReceive( pxQueue, pvBuffer, xTicksToWait, xJustPeeking )xQueueReceive传的xJustPeeking是pdFALSE表示读完就删xQueuePeek传pdTRUE读完保留。同样先关中断判断uxMessagesWaiting 0。有数据的话调prvCopyDataFromQueue这个函数把u.pcReadFrom位置的数据拷到用户缓冲区然后把读指针往前推一个uxItemSize如果推到了pcTail就绕回pcHead。接着判断xJustPeeking不是窥探的话uxMessagesWaiting减一。然后是对称的另一半检查xTasksWaitingToSend如果有任务因为队列满而阻塞着现在腾出空位了把它唤醒。同样的xTaskRemoveFromEventList同样的抢占式让出。如果队列是空的xTicksToWait为 0 就返回errQUEUE_EMPTY不为 0 就调vTaskPlaceOnEventList( ( pxQueue-xTasksWaitingToReceive ), xTicksToWait )挂起当前任务让出 CPU。这里有个特别容易被忽略的点挂起和唤醒用的链表都是按优先级排序的。FreeRTOS 的List_t在插入时通过vListInsert保持xItemValue升序而任务链表的xItemValue存的是优先级。所以在多个任务同时阻塞在同一个队列上时优先级最高的那个会排在最前面队列一有空位它就先被唤醒。互斥量的优先级继承也是从这里延伸出来的。当任务 A 持有互斥量、任务 B 高优先级想拿拿不到时B 会被挂到互斥量这个特殊队列的xTasksWaitingToReceive上同时内核会把 A 的优先级临时提升到和 B 一样高防止 A 被中间优先级的任务抢占导致 B 无限等待。这部分逻辑在xQueueGenericSend里的prvCopyDataToQueue之后有条件编译宏configUSE_MUTEXES包着。你可以在源码里搜xTaskPriorityDisinherit看它是怎么把优先级还回去的。3.4 拷贝语义传值还是传指针这是一道必答题队列最容易被误解的地方就是xQueueSend传进去的是数据的地址但队列内部存的是数据的副本。这一点从prvCopyDataToQueue里的memcpy就能看出来。所以往队列里丢一个 4 字节的结构体实际动作是把这 4 个字节从你的变量拷贝到队列存储区读出来的时候再从队列存储区拷贝到你的接收缓冲区。两次拷贝全程不涉及指针传递。这个设计有个明显的好处发送方的局部变量在函数返回后销毁了也没关系队列里存的是副本。这一点在中断里发数据时特别重要——中断服务函数退出后局部变量就不存在了如果是传指针就会出大问题。代价也很明显。假设你的消息结构体有 64 字节队列长度 32那光存储区就要 2KB 的堆空间而且每次收发各来一次 64 字节的memcpy。在 72MHz 的 F103 上这个拷贝大概要占几百个时钟周期。如果队列操作很频繁比如 10kHz 的中断往里发数据这个开销就不能忽略了。这时候通常的解法是传指针队列的Item Size设成 432 位指针发的时候把结构体指针塞进去。但这样做的同时必须自己管理内存生命周期常见方案是配一个静态的内存池#define MSG_POOL_SIZE 32 typedef struct { uint16_t raw_value; uint16_t channel; uint32_t timestamp; } AdcMsg_t; static AdcMsg_t msg_pool[MSG_POOL_SIZE]; static uint8_t msg_used[MSG_POOL_SIZE]; /* 从池里申请一个槽位 */ AdcMsg_t * MsgAlloc( void ) { for( int i 0; i MSG_POOL_SIZE; i ) { if( msg_used[i] 0 ) { msg_used[i] 1; return msg_pool[i]; } } return NULL; /* 池满调用方需要处理 */ }这里每一步都有讲究。用静态数组而不是malloc是因为 FreeRTOS 的堆本身也是从一片固定内存里切的再加上标准库malloc和pvPortMalloc混用容易出乱子。用一个uint8_t标志数组做位图是为了让申请和释放都变成 O(n) 的简单扫描32 个槽位也就是几十次比较比链表管理还快。回收的时机放在消费任务处理完数据之后调用MsgFree( ptr )把标志位清掉。实操心得如果用的是 heap_4 并且开了configUSE_MALLOC_FAILED_HOOK队列创建失败会直接进 hook 函数。但在实际项目里我更倾向于把队列存储区做成静态的用xQueueCreateStatic一次分配好。好处是链接阶段就能看到内存占用不会出现运行一天之后内存不够这种事。4. 三个可复现的实操场景理论讲完下面三个场景都是我实际跑过的代码可以直接抄。硬件还是 STM32F103C8T6主频 72MHz串口 115200。4.1 场景一采集任务向处理任务传定长数据需求很简单采集任务每 100ms 造一个数据处理任务把数据打出来。队列长度 8单项 8 字节。/* 消息结构体注意对齐后是 8 字节 */ typedef struct { uint32_t seq; uint16_t value; uint16_t pad; } Sample_t; QueueHandle_t xSampleQueue; void App_Init( void ) { xSampleQueue xQueueCreate( 8, sizeof( Sample_t ) ); configASSERT( xSampleQueue ! NULL ); } void vAcquireTask( void *pvParameters ) { Sample_t s; uint32_t seq 0; for( ;; ) { s.seq seq; s.value ( uint16_t )( 1000 ( seq % 100 ) ); s.pad 0; /* 等 20 个节拍20ms还塞不进去就丢弃并计数 */ if( xQueueSend( xSampleQueue, s, pdMS_TO_TICKS( 20 ) ) ! pdPASS ) { g_drop_count; } vTaskDelay( pdMS_TO_TICKS( 100 ) ); } } void vProcessTask( void *pvParameters ) { Sample_t rx; for( ;; ) { if( xQueueReceive( xSampleQueue, rx, portMAX_DELAY ) pdPASS ) { printf( seq%lu value%u\r\n, ( unsigned long )rx.seq, rx.value ); /* 故意睡 150ms制造消费者比生产者慢的局面 */ vTaskDelay( pdMS_TO_TICKS( 150 ) ); } } }这段代码我特意做了两件事。第一是configASSERT检查队列句柄队列创建失败通常是因为configTOTAL_HEAP_SIZE不够早发现早改。第二是让消费者比生产者慢这样跑一会儿队列就会满xQueueSend会开始超时返回g_drop_count就能看到增长。这是验证队列边界行为最直接的办法比干看文档管用。跑起来之后你会发现串口打印的顺序是完全有序的seq从 0 递增不间断在丢弃计数增长之前。如果顺序乱了或者打印出乱码八成是结构体没对齐或者Item Size填错了。4.2 场景二中断里发数据必须用 FromISR 版本这个场景是新手最容易翻车的地方。假设用 TIM2 定时中断每 1ms 采一次数据中断服务函数里要往队列发。void TIM2_IRQHandler( void ) { BaseType_t xHigherPriorityTaskWoken pdFALSE; Sample_t s; if( __HAL_TIM_GET_FLAG( htim2, TIM_FLAG_UPDATE ) ! RESET ) { __HAL_TIM_CLEAR_FLAG( htim2, TIM_FLAG_UPDATE ); s.seq g_isr_seq; s.value ( uint16_t )HAL_ADC_GetValue( hadc1 ); s.pad 0; /* 中断里绝对不能调 xQueueSend */ xQueueSendFromISR( xSampleQueue, s, xHigherPriorityTaskWoken ); /* 如果唤醒了更高优先级的任务退出前触发一次上下文切换 */ portYIELD_FROM_ISR( xHigherPriorityTaskWoken ); } }三个要点必须同时满足缺一个都会出问题。第一必须用FromISR后缀的版本。普通版本内部会调vTaskPlaceOnEventList甚至portYIELD这些操作在中断上下文里执行会直接破坏内核数据结构表现是随机死机或者任务调度错乱而且这种 bug 很难复现可能跑一整天才崩一次。第二xHigherPriorityTaskWoken这个变量必须初始化为pdFALSE传给FromISR函数然后交给portYIELD_FROM_ISR。这个变量的作用是告诉内核刚才这个操作唤醒了一个比我优先级更高的任务内核据此决定是否在退出中断时立刻切过去。如果不传或者忘了用被唤醒的高优先级任务要等到下一个节拍才能跑实时性就丢了。第三中断的优先级数值必须大于等于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY。在 STM32 上当 NVIC 分组设成NVIC_PRIORITYGROUP_4时数值越大优先级越低。假设configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY是 5那 TIM2 中断的抢占优先级至少要设成 5。如果设成 3这个中断就高于内核可管理范围里面调任何 FreeRTOS API 都会破坏内核状态。注意CubeMX 里配置 NVIC 的时候中断优先级那一栏填的就是抢占优先级。默认值经常是 0这是最高优先级直接和 FreeRTOS 冲突。每次新建工程都要检查一遍。4.3 场景三队列集统一等待多个数据源当你有三个队列三个任务分别从里面取数据代码会写得很啰嗦。队列集Queue Set就是为这个场景准备的把多个队列挂到一个集合上用一个任务统一等待谁有数据先处理谁。使用前必须在FreeRTOSConfig.h里把configUSE_QUEUE_SETS打开。代码大概长这样QueueSetHandle_t xQueueSet; QueueHandle_t xQueueA, xQueueB, xQueueC; void vCollectorTask( void *pvParameters ) { QueueSetMemberHandle_t xActivated; Sample_t rx; for( ;; ) { /* 阻塞等待集合里任意一个队列有数据 */ xActivated xQueueSelectFromSet( xQueueSet, portMAX_DELAY ); if( xActivated xQueueA ) { xQueueReceive( xQueueA, rx, 0 ); /* 处理 A 的数据 */ } else if( xActivated xQueueB ) { xQueueReceive( xQueueB, rx, 0 ); /* 处理 B 的数据 */ } else if( xActivated xQueueC ) { xQueueReceive( xQueueC, rx, 0 ); /* 处理 C 的数据 */ } } }这里有个必须记住的规则xQueueSelectFromSet返回的是有数据的队列句柄但它不把数据取走。你必须紧接着用xQueueReceive把数据读出来而且这里的超时时间必须填 0。原因也很直接如果填了非 0 值在读取的过程中可能又阻塞了而集合内部的计数已经减过了状态就对不上了。填 0 表示我知道里面一定有数据直接拿。队列集还有一个限制集合的容量是所有成员队列长度之和。创建集合时的参数就是这个总和。如果三个队列长度分别是 8、8、4集合长度要填 20。填小了会在xQueueSend的时候失败。实操心得队列集适合多个数据源、一个消费者的场景。反过来一个数据源、多个消费者就不适合了那种情况通常是每个消费者建一个自己的队列由分发任务往各个队列里投递。5. 常见问题与排查技巧实录这一章是我这些年踩过的坑里挑出来的几乎每个都能省下你半天时间。5.1 典型故障速查表现象大概率原因排查手段队列创建返回 NULLconfigTOTAL_HEAP_SIZE太小或 heap 方案不支持释放打印xPortGetFreeHeapSize()换算队列实际需要的字节数中断里发数据后随机死机用了xQueueSend而非FromISR版本全局搜xQueueSend确认是否在中断上下文中被调用数据偶尔错乱、值跳变Item Size和实际结构体大小不一致加configASSERT( sizeof(MyStruct) ITEM_SIZE )任务一直不执行队列被高优先级任务长期占满低优先级任务拿不到 CPU用uxQueueMessagesWaiting观察队列水位队列满但消费者没在跑消费者任务栈溢出被挂起或阻塞在别的地方打开configCHECK_FOR_STACK_OVERFLOW设为 2系统跑一段时间后卡死内存碎片pvPortMalloc失败换 heap_4或改静态创建中断优先级相关的诡异行为NVIC 抢占优先级数值小于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY逐个检查中断配置全部改成 5 以上这张表里第一行和第二行是我遇到频率最高的。关于堆空间计算给一个参考值xQueueCreate( 16, 8 )在 32 位机上大约占用16 * 8 sizeof(xQUEUE) 若干对齐xQUEUE结构体本身大概 80 字节左右加上内存块头部的 8 字节总共不到 250 字节。听起来不多但如果你同时开了五六个队列再加上信号量堆空间消耗就上去了。默认的 10240 字节在很多情况下够用但只要加了字符串处理就紧张。5.2 调试观测的三个实用手段第一个是栈水位观测。每个任务创建之后栈里会被填上0xa5a5a5a5这个魔数任务跑一段时间后调uxTaskGetStackHighWaterMark( xTaskHandle )返回的是栈里还剩多少个 word 从没被用到。返回 5 表示这个任务离溢出只差 20 字节非常危险返回 200 说明栈给多了可以砍一半省内存。我的习惯是项目初版把栈开大一点跑完压力测试之后按水位调到 1.5 倍余量。第二个是任务状态列表。在FreeRTOSConfig.h里打开configUSE_TRACE_FACILITY和configUSE_STATS_FORMATTING_FUNCTIONS然后写一个低优先级任务定时调vTaskList( buf )把结果通过串口打出来。输出里会包含每个任务的名字、状态R 运行、B 阻塞、S 挂起、D 删除、优先级、剩余栈和任务号。当你怀疑某个任务卡住时看它状态是 B 还是 R再看阻塞在哪个对象上方向立刻就清楚了。第三个是 GPIO 翻转配逻辑分析仪。在任务的进出位置各翻转一个引脚用逻辑分析仪或者示波器抓波形能直观算出任务的执行时间和周期。这个方法看起来土但在排查实时性问题上比任何软件工具都准因为它没有引入任何额外开销。提醒vTaskList内部会关中断遍历所有任务执行时间不短。只在调试阶段用别留在正式固件里。另外它需要一块足够大的缓冲区一般给 512 字节起。还有个更省事的办法是在队列操作前后加计数器全局变量记录发送成功次数、发送超时次数、接收次数。三个数一对比就知道是生产慢了、消费慢了还是两边周期根本对不上。这个方法几乎不占资源可以长期留在代码里。6. 源码阅读习惯和这两周之后该往哪走两周时间能把队列用熟但要说把queue.c全看懂我觉得还差口气因为里面还有条件编译、临界区嵌套、队列集锁定这些边角。不过没关系真正重要的是建立起一套读源码的方法。我的习惯是先从queue.h看起把对外暴露的 API 按功能分类发送类、接收类、查询类、删除类、中断安全类。然后回到.c文件从xQueueGenericCreate开始跟一条完整路径跟到xQueueGenericSend和xQueueGenericReceive其余的函数基本都是这三个函数的变化形式。跟的时候把 IDE 的宏展开功能打开xQueueSend这种宏展开一次就能看到真身。另外一个小技巧是善用configASSERT。FreeRTOS 源码里到处都有configASSERT( pxQueue )这类检查你在调试阶段把configASSERT定义成死循环参数传错的时候程序会立刻停住比跑飞了再回头找强太多。等到发布版本再把configASSERT定义成空。队列这一关过了之后我建议的下一步是回头看任务调度和 PendSV 切换。因为你会发现队列的每一次阻塞和唤醒最终都会落到vTaskSwitchContext和PendSV_Handler上那条路径才是 FreeRTOS 真正的心脏。等到你能把中断往队列发数据 - 唤醒高优先级任务 - 退出中断时切换上下文这条链路完整讲出来FreeRTOS 的骨架就算立住了。最后分享一个小习惯每学完一个模块我会写一个不超过 50 行的最小验证程序。队列就写生产消费信号量就写按键触发事件组就写多条件等待。这些小程序留在本地半年之后忘了细节翻出来跑一遍五分钟就能重新上手。比翻笔记管用得多。
返回列表