ARTICLE DETAIL

资讯详情

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

基于STM32与FreeRTOS的多任务数据采集控制器设计实战

基于STM32与FreeRTOS的多任务数据采集控制器设计实战 1. 项目缘起与整体设计思路1.1 为什么会有这个V1项目这个V1项目最早是从一块STM32F407的板子开始的。当时的需求很朴素做一个多路数据采集与执行控制的小型控制器要求能同时处理几路模拟量输入、几路CAN通信、还要把关键数据存到Flash里掉电不丢。听起来不复杂但真正动手之后才发现裸机跑前后台架构在任务一多的时候就开始互相打架——采样被通信阻塞、Flash写入把主循环卡死几百毫秒、串口打印一多整个系统响应就变得迟钝。于是很自然地引入了FreeRTOS。选它的理由其实很直接资料多、移植成熟、社区里踩过的坑基本都能搜到答案对于STM32这种Cortex-M内核的芯片来说FreeRTOS的移植成本几乎可以忽略。V1项目的核心目标就变成了用FreeRTOS把采集、通信、存储、人机交互这几件事拆成独立任务让它们各跑各的节奏互不拖累。这个项目适合谁参考我觉得有三类人一是刚学完FreeRTOS理论、想找个完整项目练手的嵌入式新手二是手里有STM32板子、想做CAN通信和Flash存储但不知道怎么组织代码的工程师三是准备做毕业设计、需要一套能跑通的多任务框架的同学。V1不追求功能大而全追求的是结构清晰、每个模块都能单独拎出来复现。1.2 整体架构是怎么拆的V1的软件架构我按“驱动层—中间件层—应用任务层”三层来划分。驱动层就是STM32的HAL库加上自己写的CAN、Flash、ADC封装中间件层是FreeRTOS内核加上自己封装的队列、信号量、软件定时器应用任务层就是具体干活的几个任务。任务划分上我最终定了五个核心任务任务名称优先级栈大小(字)职责Task_Acquire4256ADC多通道采集、滤波、打包Task_CanComm3512CAN报文收发、协议解析Task_Storage2384Flash读写、参数保存Task_Display1256状态刷新、串口输出Task_Monitor5128系统心跳、栈溢出检测优先级这样排是有讲究的。采集任务对实时性要求最高给它4CAN通信涉及外部交互给3存储任务可以慢一点但不能丢数据给2显示任务最不着急给1监控任务给最高5是因为它要定期检查其他任务的栈使用情况必须能抢占。提示栈大小这里给的是“字”不是“字节”Cortex-M4一个栈字是4字节所以Task_CanComm实际占用2KB栈空间。新手最容易犯的错就是按字节算结果栈开小了直接HardFault。1.3 为什么不用裸机前后台有人会问这么点功能裸机跑不行吗我实测过裸机版本在CAN连续收报文的时候如果主循环里正好在写Flash那一帧CAN报文就丢了。Flash写入STM32F4内部Flash一个扇区擦除动辄几百毫秒到一秒这期间CPU如果死等整个系统就是瘫痪的。用FreeRTOS之后Flash擦写放到低优先级任务里擦除期间高优先级任务照样跑数据先存到队列里排队等Flash空闲了再落盘。这就是多任务带来的最直接收益——把长耗时操作和实时响应解耦。2. 核心模块的细节拆解与实操要点2.1 FreeRTOS任务间通信的选型逻辑任务拆开之后它们之间怎么传数据就成了关键。FreeRTOS给了队列、信号量、事件组、任务通知好几种手段V1里我主要用了队列和信号量。采集任务和存储任务之间用队列。采集任务每采完一轮把数据打包成一个结构体丢进队列存储任务从队列里取。队列深度我设的是8为什么是8因为Flash一个扇区4KB我的数据结构是32字节一个扇区能存128条但队列不需要那么深8条足够缓冲突发再深就浪费RAM了。typedef struct { uint16_t adc_value[4]; uint32_t timestamp; uint8_t channel_id; } SampleData_t; QueueHandle_t xSampleQueue xQueueCreate(8, sizeof(SampleData_t));CAN任务和存储任务之间用二值信号量。CAN收到需要保存的报文时先释放一个信号量存储任务在信号量上等待等到了就去处理。这里不用队列是因为CAN报文可能连续来但存储只需要知道“有活干了”具体数据放在一个全局缓冲区里加互斥锁保护就行。注意队列传递的是值拷贝结构体大了会吃RAM也会增加拷贝开销。如果数据结构超过64字节建议改成传指针但传指针必须配内存池管理否则动态分配容易产生碎片。2.2 CAN通信的配置与报文解析CAN这部分是V1里调试花时间最多的。STM32F407的CAN外设配置有几个关键参数波特率、工作模式、滤波器。波特率计算是新手最容易翻车的地方。CAN波特率 APB1时钟 / (Prescaler × (BS1 BS2 1))。我的APB1是42MHz目标波特率500kbps取Prescaler6BS111BS22算下来42M / (6 × (1121)) 42M / 84 500k。这个参数必须和总线上其他节点完全一致差一点就通信失败。hcan1.Instance CAN1; hcan1.Init.Prescaler 6; hcan1.Init.Mode CAN_MODE_NORMAL; hcan1.Init.SyncJumpWidth CAN_SJW_1TQ; hcan1.Init.TimeSeg1 CAN_BS1_11TQ; hcan1.Init.TimeSeg2 CAN_BS2_2TQ; hcan1.Init.AutoRetransmission ENABLE; hcan1.Init.ReceiveFifoLocked DISABLE;滤波器配置我用了标识符列表模式只接收我关心的几个ID。这里有个坑STM32的CAN滤波器组是有限的F407有28组如果随便配成掩码模式接收所有报文中断里处理不过来会丢帧。V1里我只放行了0x100到0x103这四个ID其他一律过滤掉。报文解析我定义了一个简单的应用层协议第一个字节是命令字第二个字节是长度后面是数据最后两个字节是CRC16校验。解析函数放在CAN接收中断的回调里但只做最轻量的搬运——把报文拷进队列真正的解析放到Task_CanComm里做。中断里绝对不能做耗时操作这是铁律。2.3 Flash存储的扇区管理与磨损考量STM32F407的内部Flash是1MB分成12个扇区前几个扇区被程序占用我用了最后一个扇区扇区11地址0x080E0000存参数。为什么选最后一个因为擦除是按扇区来的放最后不会影响程序区升级固件的时候也好处理。Flash操作有三个必须记住的特性写之前必须先擦、擦除单位是扇区、擦写次数有限约1万次。V1里参数不是频繁变的所以磨损不是大问题但如果你要做数据记录仪那种高频写入就必须做磨损均衡或者外挂一片SPI Flash。我的存储策略是参数区用一个结构体开头放一个魔数0xA5A5A5A5标识数据有效后面跟实际参数最后放CRC。上电时先读魔数不对就加载默认参数。写入时先把整个扇区读到RAM改完再擦除写回。#define PARAM_ADDR 0x080E0000 #define PARAM_MAGIC 0xA5A5A5A5 typedef struct { uint32_t magic; uint16_t sample_period; uint32_t can_baudrate; uint8_t reserved[64]; uint16_t crc; } Param_t;提示STM32F4的Flash编程必须按半字16位或字32位对齐写之前要解锁写完要上锁。擦除一个扇区在F407上大约需要1秒这期间CPU取指会停顿所以擦除代码最好放到RAM里执行或者确保擦除期间不响应中断。2.4 PI控制在项目中的实际应用热词里出现了PIV1里确实用到了PI控制。项目有一路输出是控制一个加热片的温度用PWM驱动温度传感器反馈。最开始我用的是纯比例控制结果温度永远稳定不到目标值总差那么一两度——这就是稳态误差。加上积分项之后误差被累积补偿掉了最终能稳定在目标值±0.3度以内。PI参数整定我用的经验法先把积分项关掉比例系数从0慢慢加大加到系统开始等幅振荡记下此时的Kp为Ku振荡周期为Tu。然后按Ziegler-Nichols公式PI控制取Kp0.45KuTiTu/1.2。实际调的时候在这个基础上微调积分时间太短会超调振荡太长又响应慢。typedef struct { float kp; float ki; float integral; float integral_max; float output_max; } PI_Controller_t; float PI_Update(PI_Controller_t *pi, float error, float dt) { pi-integral error * dt; if (pi-integral pi-integral_max) pi-integral pi-integral_max; if (pi-integral -pi-integral_max) pi-integral -pi-integral_max; float output pi-kp * error pi-ki * pi-integral; if (output pi-output_max) output pi-output_max; if (output 0) output 0; return output; }积分限幅是必须的否则一旦误差长时间存在积分项会累积到非常大等误差反向时输出要很久才能拉回来这就是积分饱和。输出限幅也是同理PWM占空比不可能超过100%。3. 完整实操流程与关键环节实现3.1 开发环境搭建与工程配置V1的开发环境是Keil MDK 5加上STM32CubeMX。CubeMX负责生成初始化代码和FreeRTOS的配置Keil负责写业务逻辑和调试。这里有个细节CubeMX生成的FreeRTOS配置里堆管理方案默认是heap_4这个方案支持碎片合并适合长期运行的系统我建议保持默认。时钟配置上F407主频跑到168MHzAPB1是42MHzAPB2是84MHz。CAN挂在APB1上所以前面波特率计算用的是42MHz。如果你用的是F103APB1是36MHz参数要重新算。FreeRTOS的config文件里有几个参数必须检查#define configUSE_PREEMPTION 1 #define configUSE_TIME_SLICING 1 #define configTICK_RATE_HZ 1000 #define configMAX_PRIORITIES 7 #define configMINIMAL_STACK_SIZE 128 #define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_MALLOC_FAILED_HOOK 1configCHECK_FOR_STACK_OVERFLOW设成2是最严格的检测方式会在任务切换时检查栈末尾的标记字节有没有被改写。configUSE_MALLOC_FAILED_HOOK打开后如果动态创建任务时堆不够会调用钩子函数方便定位问题。3.2 任务创建与启动的完整代码任务创建我放在一个统一的初始化函数里所有队列、信号量、任务都在这里建好然后调用vTaskStartScheduler()启动调度器。void App_Init(void) { xSampleQueue xQueueCreate(8, sizeof(SampleData_t)); xCanSemaphore xSemaphoreCreateBinary(); xParamMutex xSemaphoreCreateMutex(); xTaskCreate(Task_Acquire, Acquire, 256, NULL, 4, NULL); xTaskCreate(Task_CanComm, CanComm, 512, NULL, 3, NULL); xTaskCreate(Task_Storage, Storage, 384, NULL, 2, NULL); xTaskCreate(Task_Display, Display, 256, NULL, 1, NULL); xTaskCreate(Task_Monitor, Monitor, 128, NULL, 5, NULL); vTaskStartScheduler(); }创建顺序不影响优先级但习惯上按优先级从高到低或者从低到高写都行。注意每个任务的栈大小要留余量我一般会在Monitor任务里定期打印各任务的栈高水位线看看实际用了多少。void Task_Monitor(void *pvParameters) { while (1) { UBaseType_t hw uxTaskGetStackHighWaterMark(NULL); printf(Monitor stack free: %u\r\n, hw); vTaskDelay(pdMS_TO_TICKS(5000)); } }uxTaskGetStackHighWaterMark返回的是栈剩余的最小值单位是字。如果这个值小于20说明栈快满了得赶紧加大。3.3 CAN中断与任务的数据交接CAN接收中断里我只做一件事把报文从硬件FIFO搬到软件队列。这里用的是FreeRTOS的xQueueSendFromISR注意必须用带FromISR后缀的版本并且要传一个pxHigherPriorityTaskWoken参数。void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan) { CAN_RxHeaderTypeDef rxHeader; uint8_t rxData[8]; BaseType_t xHigherPriorityTaskWoken pdFALSE; HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, rxHeader, rxData); CanFrame_t frame; frame.id rxHeader.StdId; frame.dlc rxHeader.DLC; memcpy(frame.data, rxData, 8); xQueueSendFromISR(xCanRxQueue, frame, xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }portYIELD_FROM_ISR这行很关键。如果中断里释放的信号量唤醒了比当前被中断任务更高优先级的任务就需要在中断退出时触发一次任务切换否则高优先级任务要等到下一个tick才能跑实时性就打折扣了。3.4 Flash参数保存的完整流程参数保存我封装了一个函数流程是加互斥锁 → 读整个扇区到RAM → 修改对应字段 → 擦除扇区 → 写回 → 解锁。HAL_StatusTypeDef Param_Save(Param_t *param) { HAL_FLASH_Unlock(); __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_EOP | FLASH_FLAG_OPERR | FLASH_FLAG_WRPERR | FLASH_FLAG_PGAERR); FLASH_EraseInitTypeDef erase; erase.TypeErase FLASH_TYPEERASE_SECTORS; erase.Sector FLASH_SECTOR_11; erase.NbSectors 1; erase.VoltageRange FLASH_VOLTAGE_RANGE_3; uint32_t sectorError 0; HAL_FLASHEx_Erase(erase, sectorError); uint32_t *p (uint32_t *)param; for (uint32_t i 0; i sizeof(Param_t) / 4; i) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, PARAM_ADDR i * 4, p[i]); } HAL_FLASH_Lock(); return HAL_OK; }擦除前必须清标志位否则上一次操作的错误标志会导致这次擦除直接返回错误。这个坑我踩过现象是第一次保存成功第二次就失败查了半天才发现是标志位没清。4. 常见问题与排查技巧实录4.1 FreeRTOS栈溢出与HardFault排查栈溢出是FreeRTOS项目里最常见的崩溃原因。现象通常是程序跑一段时间后突然进HardFault或者某个任务行为异常。排查步骤我总结成一张表现象可能原因排查方法进HardFault栈溢出、空指针、数组越界看LR和PC寄存器用Keil的Call Stack任务不执行优先级被抢占、栈不够打印各任务栈高水位线队列满丢数据消费者太慢、队列太浅加大队列深度或提高消费者优先级系统卡死死锁、中断里调用了阻塞API检查互斥锁获取顺序栈溢出检测钩子函数一定要实现它能在溢出发生的瞬间告诉你哪个任务出了问题void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { printf(Stack overflow in task: %s\r\n, pcTaskName); taskDISABLE_INTERRUPTS(); for (;;); }4.2 CAN通信失败的典型原因CAN调不通九成是这几个原因波特率不匹配、终端电阻没接、滤波器配置错误、收发器供电不对。波特率不匹配最隐蔽因为两边都能正常初始化就是收不到。用示波器看CAN_H和CAN_L的差分波形如果波形正常但通信失败基本就是波特率问题。终端电阻必须是120欧姆接在总线两端中间节点不用接。我遇到过一块板子忘了接终端电阻短距离能通线一长就丢帧。滤波器配置错误的表现是总线上明明有报文中断就是不进。这时候先确认滤波器模式列表模式只放行指定ID掩码模式按掩码匹配。如果拿不准先配成接收所有报文通了再收窄。4.3 Flash写入失败与数据丢失Flash写入失败常见原因没解锁、没擦除、地址不对齐、写到了程序区。FLASH_EraseInitTypeDef里的VoltageRange参数要根据供电电压选3.3V供电选FLASH_VOLTAGE_RANGE_3。选错了擦除会失败但不报错读出来全是0xFF。数据丢失的另一个原因是掉电时机。如果在擦除过程中掉电整个扇区数据就没了。V1里我的做法是参数区放两份交替写入一份坏了还能从另一份恢复。这个思路在关键参数存储上很值得借鉴。4.4 任务优先级反转与互斥锁使用优先级反转是RTOS里的经典问题低优先级任务持有锁高优先级任务等锁中优先级任务抢占CPU导致高优先级任务被无限期阻塞。FreeRTOS的互斥量支持优先级继承能缓解这个问题但根本解法还是尽量缩短持锁时间。V1里参数保存的互斥锁只在Flash操作期间持有而且Flash操作放在低优先级任务里高优先级任务不会去抢这把锁所以实际影响不大。但如果你的高优先级任务也要访问共享资源就必须用互斥量而不是二值信号量。提示二值信号量用于同步中断通知任务互斥量用于保护共享资源。两者API长得像但用途完全不同用错了会出现优先级反转。5. 项目封装与后续扩展思路5.1 代码分层与接口封装V1收尾的时候我做了一次代码整理把驱动层和应用层彻底分开。驱动层对外只暴露Drv_Can_Send()、Drv_Flash_Write()、Drv_Adc_Read()这样的接口应用层不直接碰HAL库。这样做的好处是换芯片的时候只需要重写驱动层应用逻辑一行不用改。接口封装还有一个好处是方便单元测试。我可以在PC上写一个假的驱动层把应用逻辑跑起来验证不用每次都烧到板子上。5.2 从V1到V2的演进方向V1跑通之后我梳理了几个V2要改进的点。一是加入看门狗和任务级的心跳检测任何一个任务卡死都能被Monitor发现并复位二是把参数存储改成双备份加版本号支持参数回滚三是CAN协议加上分帧和重传机制支持大于8字节的数据块传输四是把调试信息通过USB虚拟串口输出比UART方便插上就能看。这些方向不是拍脑袋想的都是V1实际运行中暴露出来的短板。比如CAN大数据块传输V1里超过8字节就得应用层自己分包很容易出错V2里打算在协议层解决掉。5.3 给后来者的几点实操建议第一先跑通最小系统再往上加功能。我见过太多人一上来就把所有任务都建好结果一出问题根本不知道是哪儿的锅。正确做法是先让一个LED闪起来再加串口再加FreeRTOS再加CAN每加一个都验证通过再继续。第二善用printf调试但要节制。串口打印在调试期很有用但正式运行时打印太多会拖慢系统甚至因为串口阻塞导致任务超时。V1里我定义了一个DEBUG_LOG宏发布版本直接编译掉。第三参数计算要写下来。波特率、定时器周期、PI参数这些算一遍容易过两个月再回头看就忘了怎么来的。我在代码注释里把计算过程写清楚后来调参数的时候省了很多事。第四版本管理从第一天就开始。V1我用了Git每次改完能跑通就提交一次。有一次改CAN滤波器把通信改挂了直接回退到上一个提交五分钟解决问题。没有版本管理的话这种问题可能要查一整天。这个V1项目从动手到稳定运行大概花了三周其中一半时间在调CAN和Flash。现在回头看最难的不是写代码而是想清楚任务之间怎么协作、资源怎么共享、异常怎么处理。把这些理清楚了代码只是翻译的过程。
返回列表