1. 项目缘起与整体设计思路
1.1 为什么会有这个V1项目
这个V1项目说白了就是一个基于STM32的综合性嵌入式控制平台,核心任务是把几个平时分散在不同demo里的功能模块——FreeRTOS任务调度、CAN总线通信、Flash参数存储、PI闭环控制——整合到一个能跑、能维护、能扩展的工程框架里。做这个项目的直接动机很简单:之前每个功能都是单独验证的,单独跑都稳,一旦合到一起就各种打架,任务优先级冲突、CAN中断抢占导致PI计算周期抖动、Flash写入时任务阻塞……这些问题不解决,后面根本没法往上叠功能。
所以V1的目标不是做多炫的功能,而是把地基打牢。它适合谁参考?如果你正在做基于STM32的毕业设计、工控板卡、电机控制或者车载节点,需要一套能直接抄作业的工程骨架,那这个总结对你有用。如果你只是点个灯跑个串口,那可能用不上,但里面关于FreeRTOS和CAN配合的坑,看看也不亏。
1.2 整体架构是怎么定的
架构定下来之前我纠结了很久,核心矛盾在于:裸机跑还是上RTOS。裸机的好处是时序确定、没有任务切换开销,但坏处是随着功能增加,主循环会变成一锅粥,PI控制、CAN收发、Flash存储全挤在一起,任何一个环节耗时长了都会拖累其他环节。上FreeRTOS的好处是解耦,每个功能一个任务,优先级明确,坏处是引入了调度开销和资源竞争问题。
最终选FreeRTOS,理由有三个。第一,PI控制需要严格周期,用vTaskDelayUntil能保证周期稳定,裸机靠定时器中断虽然也行,但一旦主循环里有Flash写入这种耗时操作,周期就崩了。第二,CAN接收是异步事件,用任务+队列的方式处理比中断里直接干活安全得多。第三,Flash存储需要在不影响控制的前提下后台进行,任务化之后可以放到低优先级慢慢写。
整体架构分四层:硬件抽象层(HAL库封装)、驱动层(CAN、Flash、定时器)、服务层(PI控制器、参数管理)、应用层(任务调度与业务逻辑)。这个分层不是摆设,后面调试的时候你就知道好处了——换芯片只需要改HAL层,PI算法调参不用碰驱动。
1.3 关键选型背后的考量
为什么用CAN而不是串口:CAN的差分信号抗干扰能力强,多节点组网方便,而且报文自带ID和CRC,比串口自己定协议省事。工控和车载场景基本是标配。
为什么用PI而不是PID:这个项目控制的是温度/速度这类一阶惯性系统,D项对噪声极其敏感,实际调下来PI就够用,加了D反而抖。这不是说PID不好,而是要看被控对象。
为什么Flash用内部而不是外挂:STM32内部的Flash虽然擦写次数有限(一般1万次左右),但存参数这种低频写入场景完全够用。外挂SPI Flash虽然容量大,但增加了硬件成本和驱动复杂度,V1阶段没必要。
为什么PI周期定在10ms:这个是根据被控对象的时间常数来的。被控对象响应时间在百毫秒级别,按照采样周期取时间常数的1/10到1/20的经验法则,10ms是合理值。太快了CPU浪费,太慢了控制品质下降。
2. 核心模块的细节拆解与实操要点
2.1 FreeRTOS任务划分与优先级设计
任务划分是这个项目的骨架,划不好后面全是坑。我最终分了五个任务:
| 任务名称 | 优先级 | 周期/触发方式 | 栈大小 | 职责 |
|---|---|---|---|---|
| ControlTask | 4(最高) | 10ms周期 | 512字 | PI计算、PWM输出 |
| CanRxTask | 3 | 队列阻塞 | 512字 | 解析CAN报文、更新设定值 |
| CanTxTask | 2 | 100ms周期 | 384字 | 上报状态、心跳 |
| ParamTask | 1 | 事件触发 | 512字 | Flash读写参数 |
| MonitorTask | 0(最低) | 500ms周期 | 384字 | 状态指示、看门狗喂狗 |
优先级设计的原则是:周期越短、实时性要求越高的任务优先级越高。ControlTask必须最高,因为PI计算延迟直接影响控制品质。CanRxTask次之,因为接收到的设定值要尽快生效。ParamTask放低优先级,Flash写入慢就慢点,不能阻塞控制。
这里有个容易踩的坑:中断优先级和任务优先级是两码事。CAN接收中断的优先级(NVIC里配的)必须高于configMAX_SYSCALL_INTERRUPT_PRIORITY,否则在中断里调用xQueueSendFromISR会触发断言。我一开始没注意这个,CAN一收数据就进HardFault,查了半天。
// CAN中断优先级配置,数值越小优先级越高 HAL_NVIC_SetPriority(CAN1_RX0_IRQn, 5, 0); // FreeRTOSConfig.h中 #define configMAX_SYSCALL_INTERRUPT_PRIORITY 5 // 中断优先级必须 >= 5(数值上),否则不能在中断里调FreeRTOS API注意:STM32的NVIC优先级数值越小优先级越高,而FreeRTOS的
configMAX_SYSCALL_INTERRUPT_PRIORITY是个阈值,中断优先级数值必须大于等于这个阈值才能安全调用FromISR接口。这个逻辑和直觉相反,特别容易搞错。
2.2 CAN通信的报文设计与过滤
CAN通信这块,报文ID的设计直接决定了后期扩展性。我用的是扩展帧,29位ID拆成几段:高8位表示节点地址,中间8位表示功能码,低13位表示数据索引。这样过滤的时候可以用掩码只匹配功能码,不用关心具体节点。
// 报文ID定义 #define CAN_ID_SETPOINT 0x01000000 // 设定值下发 #define CAN_ID_STATUS 0x02000000 // 状态上报 #define CAN_ID_PARAM_WR 0x03000000 // 参数写入 #define CAN_ID_PARAM_RD 0x04000000 // 参数读取过滤器配置是另一个坑。STM32的CAN外设有14个过滤器组,可以配成掩码模式或列表模式。我只需要接收特定几个ID,用掩码模式最省资源:
CAN_FilterTypeDef filter; filter.FilterBank = 0; filter.FilterMode = CAN_FILTERMODE_IDMASK; filter.FilterScale = CAN_FILTERSCALE_32BIT; filter.FilterIdHigh = 0x0100; // 期望ID的高16位 filter.FilterIdLow = 0x0000; filter.FilterMaskIdHigh = 0xFF00; // 只匹配高8位功能码 filter.FilterMaskIdLow = 0x0000; filter.FilterFIFOAssignment = CAN_RX_FIFO0; filter.FilterActivation = ENABLE;这样配置后,只要功能码是0x01的报文都能进来,节点地址不关心。后期增加节点不用改过滤器,扩展性拉满。
CAN接收中断里只做一件事:把报文丢进队列,然后立刻退出。解析工作放到CanRxTask里做。为什么?因为中断里干活时间越长,系统响应越差,而且解析过程中如果用到浮点运算,中断上下文里做浮点操作在某些编译器配置下会出问题。
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); // 只做入队,不做解析 xQueueSendFromISR(canRxQueue, &rxHeader, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }2.3 Flash参数存储的磨损均衡与掉电保护
Flash存储这块,STM32内部Flash的擦写寿命是个硬约束。F4系列一般标称1万次擦写,F1系列也是1万次左右。如果每次参数变化都擦写一次,按每天改100次算,100天就报废了。所以必须做磨损均衡。
我的做法是把Flash的一个扇区(比如16KB)分成多个页,轮流写入。每次写新参数写到下一个空闲页,写满整个扇区后再统一擦除。这样擦除次数降低到原来的1/N(N是页数)。具体实现:
#define FLASH_SECTOR_ADDR 0x08010000 // 扇区起始地址 #define FLASH_PAGE_SIZE 0x800 // 2KB一页 #define FLASH_PAGE_COUNT 8 // 8页一个扇区 typedef struct { uint32_t magic; // 0x5A5A5A5A表示有效 uint32_t version; ParamStruct params; uint32_t crc; } FlashPage_t; // 写入时找第一个magic无效的页 uint32_t FindFreePage(void) { for (int i = 0; i < FLASH_PAGE_COUNT; i++) { uint32_t addr = FLASH_SECTOR_ADDR + i * FLASH_PAGE_SIZE; if (*(uint32_t*)addr != 0x5A5A5A5A) { return addr; } } return 0; // 需要擦除 }掉电保护是另一个重点。参数写入过程中如果掉电,可能写到一半,下次上电读到的是坏数据。解决办法是双备份+CRC校验:每次写入前先算好CRC,写入后回读校验,只有校验通过才更新有效标志。上电时从后往前找第一个CRC正确的页,找不到就用默认参数。
实操心得:Flash写入前必须先擦除,而擦除是按扇区的,不能按页擦。所以磨损均衡的粒度是页,但擦除的粒度是扇区。设计的时候要算好一个扇区能放多少页,写满后统一擦。另外擦除期间CPU会阻塞,F4系列擦一个16KB扇区大概几百毫秒到一秒,这段时间控制任务会停摆。我的做法是把Flash操作放到ParamTask里,优先级最低,而且擦除前先暂停ControlTask的输出,擦完再恢复。
2.4 PI控制器的离散化实现与抗积分饱和
PI控制器理论公式很简单:u(t) = Kpe(t) + Ki∫e(t)dt。但离散化实现的时候有几个细节决定成败。
首先是积分项的离散化方式。我用的是后向差分:I(k) = I(k-1) + KiTe(k),其中T是采样周期。这种方式比前向差分稳定,不容易振荡。
typedef struct { float Kp; float Ki; float T; // 采样周期,单位秒 float integral; // 积分累积 float out_max; // 输出上限 float out_min; // 输出下限 float last_error; } PIController; float PI_Update(PIController *pi, float setpoint, float feedback) { float error = setpoint - feedback; // 比例项 float p_term = pi->Kp * error; // 积分项,先累加 pi->integral += pi->Ki * pi->T * error; // 抗积分饱和:如果输出超限,停止积分累积 float output = p_term + pi->integral; if (output > pi->out_max) { output = pi->out_max; pi->integral -= pi->Ki * pi->T * error; // 回退积分 } else if (output < pi->out_min) { output = pi->out_min; pi->integral -= pi->Ki * pi->T * error; } pi->last_error = error; return output; }抗积分饱和是必须做的。没有这个,当设定值突变或者反馈丢失时,积分项会一直累积到无穷大,等误差反向时输出要很久才能拉回来,表现为系统"反应迟钝"。上面的代码用的是回退法,检测到输出饱和就把这次积分增量减掉。
参数整定我用的临界比例度法:先把Ki设为0,Kp从小往大加,直到系统出现等幅振荡,记下此时的Kp为Ku,振荡周期为Tu。然后按经验公式Kp=0.45Ku,Ki=0.54Ku/Tu。这个方法比试凑法快,但要注意振荡不能太剧烈,否则可能损坏执行机构。
注意:PI的采样周期必须和任务周期严格一致。我用
vTaskDelayUntil保证10ms周期,实测抖动在几十微秒以内。如果用vTaskDelay,周期会随任务执行时间漂移,PI参数就白整了。
3. 实操过程与核心环节实现
3.1 工程搭建与FreeRTOS移植
工程搭建我用的是STM32CubeMX生成基础框架,然后手动加FreeRTOS源码。CubeMX虽然能直接生成FreeRTOS工程,但版本往往偏旧,而且配置项藏得深,不如自己移植可控。
移植步骤:
- 从FreeRTOS官网下载源码,把
Source目录下的tasks.c、queue.c、list.c、timers.c、event_groups.c、stream_buffer.c加到工程。 - 把
portable/RVDS/ARM_CM4F(F4系列)或对应内核的端口文件加进来。 - 把
FreeRTOSConfig.h放到include路径下,逐项配置。
FreeRTOSConfig.h的关键配置:
#define configUSE_PREEMPTION 1 // 抢占式调度 #define configUSE_TIME_SLICING 1 // 同优先级时间片轮转 #define configCPU_CLOCK_HZ 168000000 // F4主频168MHz #define configTICK_RATE_HZ 1000 // 1ms一个tick #define configMAX_PRIORITIES 7 #define configMINIMAL_STACK_SIZE 128 #define configTOTAL_HEAP_SIZE (20 * 1024) // 20KB堆 #define configUSE_MUTEXES 1 #define configUSE_COUNTING_SEMAPHORES 1 #define configCHECK_FOR_STACK_OVERFLOW 2 // 栈溢出检测 #define configUSE_MALLOC_FAILED_HOOK 1 #define configMAX_SYSCALL_INTERRUPT_PRIORITY 5configTICK_RATE_HZ设1000意味着1ms一个tick,10ms周期就是10个tick。这个值不能太高,否则中断太频繁影响性能;也不能太低,否则周期精度不够。
栈溢出检测我开了级别2,会在任务切换时检查栈指针是否越界。配合vApplicationStackOverflowHook回调,一旦溢出就打印任务名并停机,方便定位。
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { printf("Stack overflow in task: %s\r\n", pcTaskName); taskDISABLE_INTERRUPTS(); for(;;); }3.2 CAN通信的完整收发流程
CAN的初始化分几步:GPIO配置、CAN外设配置、过滤器配置、启动。F4系列的CAN挂在APB1上,时钟是42MHz。
void CAN_Init(void) { hcan1.Instance = CAN1; hcan1.Init.Prescaler = 6; // 42MHz / 6 = 7MHz hcan1.Init.Mode = CAN_MODE_NORMAL; hcan1.Init.SyncJumpWidth = CAN_SJW_1TQ; hcan1.Init.TimeSeg1 = CAN_BS1_12TQ; // 12个时间单元 hcan1.Init.TimeSeg2 = CAN_BS2_2TQ; // 2个时间单元 hcan1.Init.TimeTriggeredMode = DISABLE; hcan1.Init.AutoBusOff = ENABLE; hcan1.Init.AutoWakeUp = DISABLE; hcan1.Init.AutoRetransmission = ENABLE; hcan1.Init.ReceiveFifoLocked = DISABLE; hcan1.Init.TransmitFifoPriority = DISABLE; HAL_CAN_Init(&hcan1); // 过滤器配置 CAN_FilterTypeDef filter = {0}; filter.FilterBank = 0; filter.FilterMode = CAN_FILTERMODE_IDMASK; filter.FilterScale = CAN_FILTERSCALE_32BIT; filter.FilterIdHigh = 0x0100; filter.FilterIdLow = 0x0000; filter.FilterMaskIdHigh = 0xFF00; filter.FilterMaskIdLow = 0x0000; filter.FilterFIFOAssignment = CAN_RX_FIFO0; filter.FilterActivation = ENABLE; HAL_CAN_ConfigFilter(&hcan1, &filter); HAL_CAN_Start(&hcan1); HAL_CAN_ActivateNotification(&hcan1, CAN_IT_RX_FIFO0_MSG_PENDING); }波特率计算:42MHz / 6 = 7MHz,一个位时间 = 1 + 12 + 2 = 15个时间单元,波特率 = 7MHz / 15 ≈ 466.7kbps。实际想要500kbps的话,Prescaler设5,42/5=8.4MHz,8.4/15=560kbps,不对。重新算:500kbps需要位时间 = 42MHz / 500k = 84个时钟周期。Prescaler=6,时间单元=7MHz,位时间=15个单元,7M/15=466.7k。要精确500k,Prescaler=5,时间单元=8.4MHz,位时间=16.8个单元,取17个单元(1+14+2),8.4M/17=494kbps,接近。或者Prescaler=7,6MHz,位时间12个单元(1+9+2),6M/12=500kbps,正好。
发送流程:
void CAN_SendStatus(uint8_t node_id, uint16_t status) { CAN_TxHeaderTypeDef txHeader; uint8_t txData[8]; uint32_t txMailbox; txHeader.ExtId = CAN_ID_STATUS | (node_id << 16); txHeader.IDE = CAN_ID_EXT; txHeader.RTR = CAN_RTR_DATA; txHeader.DLC = 2; txHeader.TransmitGlobalTime = DISABLE; txData[0] = status >> 8; txData[1] = status & 0xFF; HAL_CAN_AddTxMessage(&hcan1, &txHeader, txData, &txMailbox); }3.3 PI闭环的调试过程记录
PI调试我分了三个阶段:开环验证、纯比例、加积分。
开环验证:先不接反馈,手动给固定输出,观察被控对象的响应。这一步是为了确认硬件没问题,传感器读数正确,执行机构能动。我当时的被控对象是个加热片,给50%占空比,温度从25度升到60度用了大概3分钟,说明加热功率和散热基本平衡。
纯比例:Ki=0,Kp从0.1开始加。Kp=0.1时温度稳定在55度(设定60度),有稳态误差。Kp=0.5时稳定在58度,误差缩小。Kp=1.0时开始有轻微振荡,周期大概20秒。Kp=1.5时振荡明显,周期15秒。所以临界Kp在1.2左右,Ku=1.2,Tu=18秒。
加积分:按公式Kp=0.451.2=0.54,Ki=0.541.2/18=0.036。实际调的时候发现响应偏慢,把Kp加到0.8,Ki加到0.05,响应快了,超调大概5%,能接受。最终参数Kp=0.8,Ki=0.05,T=0.01秒。
调试过程中用串口把设定值、反馈值、输出值实时打出来,用曲线工具看波形。没有曲线工具的话,至少要把数据打到串口助手里,肉眼观察趋势。
实操心得:PI调试最忌讳一上来就同时调Kp和Ki,两个参数互相影响,根本不知道谁在起作用。一定要先把Ki关掉调Kp,Kp差不多了再加Ki。另外积分时间常数Ti=Kp/Ki,我最终Ti=0.8/0.05=16秒,和振荡周期18秒接近,符合经验。
3.4 Flash参数管理的完整实现
参数管理模块对外提供三个接口:Param_Init、Param_Read、Param_Write。内部维护一个RAM缓存,读的时候直接读缓存,写的时候先写缓存再异步写Flash。
typedef struct { float Kp; float Ki; float setpoint; uint32_t can_node_id; uint32_t can_baudrate; uint8_t reserved[32]; } ParamStruct; static ParamStruct g_params; static uint32_t g_current_page_addr; void Param_Init(void) { // 从Flash加载 for (int i = FLASH_PAGE_COUNT - 1; i >= 0; i--) { uint32_t addr = FLASH_SECTOR_ADDR + i * FLASH_PAGE_SIZE; FlashPage_t *page = (FlashPage_t*)addr; if (page->magic == 0x5A5A5A5A) { uint32_t crc = CRC32((uint8_t*)&page->params, sizeof(ParamStruct)); if (crc == page->crc) { memcpy(&g_params, &page->params, sizeof(ParamStruct)); g_current_page_addr = addr; return; } } } // 没有有效页,用默认值 Param_LoadDefault(); g_current_page_addr = 0; } void Param_Write(ParamStruct *new_params) { memcpy(&g_params, new_params, sizeof(ParamStruct)); // 发消息给ParamTask,异步写Flash xQueueSend(paramWriteQueue, &g_params, 0); }ParamTask收到写请求后:
void ParamTask(void *argument) { ParamStruct params; for (;;) { if (xQueueReceive(paramWriteQueue, ¶ms, portMAX_DELAY) == pdTRUE) { uint32_t addr = FindFreePage(); if (addr == 0) { // 扇区满了,擦除 HAL_FLASH_Unlock(); FLASH_Erase_Sector(FLASH_SECTOR_4, VOLTAGE_RANGE_3); HAL_FLASH_Lock(); addr = FLASH_SECTOR_ADDR; } FlashPage_t page; page.magic = 0x5A5A5A5A; page.version = 1; memcpy(&page.params, ¶ms, sizeof(ParamStruct)); page.crc = CRC32((uint8_t*)¶ms, sizeof(ParamStruct)); HAL_FLASH_Unlock(); // 按字写入 uint32_t *src = (uint32_t*)&page; for (int i = 0; i < sizeof(FlashPage_t)/4; i++) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i*4, src[i]); } HAL_FLASH_Lock(); g_current_page_addr = addr; } } }注意:STM32的Flash写入必须按半字(16位)或字(32位)对齐,不能按字节写。而且写入前目标地址必须是擦除状态(0xFF),否则写入会失败。我一开始没注意对齐,写进去的数据全是乱的。
4. 常见问题与排查技巧实录
4.1 FreeRTOS相关的问题
问题一:任务创建后不运行
现象是xTaskCreate返回pdPASS,但任务函数里的打印一句都不出。排查思路:先确认调度器启动了vTaskStartScheduler(),再确认任务优先级没有超过configMAX_PRIORITIES,最后检查栈大小是不是太小导致任务一启动就溢出。我遇到过一次是栈给了128字(512字节),但任务里有个大数组,一进去就溢出,触发了溢出钩子函数。
问题二:中断里调用FreeRTOS API导致HardFault
这个前面提过,根因是中断优先级配置不对。STM32的NVIC优先级数值越小越高,而FreeRTOS要求调用FromISR接口的中断优先级数值必须大于等于configMAX_SYSCALL_INTERRUPT_PRIORITY。我一开始把CAN中断优先级设成1(很高),结果一收数据就挂。改成5就好了。
问题三:队列发送失败
xQueueSend返回errQUEUE_FULL,说明队列满了。要么是消费者任务优先级太低抢不到CPU,要么是队列长度设太小。我的CanRxTask优先级是3,队列长度设了16,正常情况不会满。如果满了,说明CAN报文来得太猛,需要加大队列或者提高任务优先级。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 任务不运行 | 调度器未启动 | 检查vTaskStartScheduler | 调用启动函数 |
| 任务不运行 | 栈溢出 | 开栈溢出检测 | 加大栈或优化局部变量 |
| 中断进HardFault | 中断优先级错误 | 检查NVIC配置 | 优先级数值≥5 |
| 队列满 | 消费者太慢 | 看任务优先级 | 提高优先级或加长队列 |
| 周期抖动大 | 用了vTaskDelay | 检查延时函数 | 改用vTaskDelayUntil |
4.2 CAN通信相关的问题
问题一:CAN发不出去,mailbox一直满
CAN发送需要等待总线空闲,如果总线上没有其他节点应答,发送会一直重试。检查AutoRetransmission是不是开了,开了的话发不出去会一直重发。另外确认波特率对不对,波特率不匹配的话总线上全是错误帧。
问题二:收不到特定ID的报文
过滤器配置问题。STM32的过滤器有14个组,但FIFO只有两个。如果多个过滤器组指向同一个FIFO,只有第一个生效。我一开始配了两个过滤器组都指向FIFO0,结果第二个死活不生效。后来改成FIFO0和FIFO1分开就好了。
问题三:CAN总线错误计数增长
用HAL_CAN_GetError读错误码。如果REC(接收错误计数)增长,说明接收有问题,可能是波特率偏差或者终端电阻没接。CAN总线两端必须各接一个120欧姆终端电阻,中间节点不接。我调试的时候忘了接终端电阻,短距离通信勉强能通,一长就全是错误。
4.3 Flash与PI相关的问题
Flash写入失败:最常见的原因是目标地址没擦除。STM32的Flash只能把1写成0,不能把0写成1。写入前必须确保地址是0xFF。另外写入过程中不能被打断,如果开了中断,中断里又操作Flash,会冲突。我的做法是Flash操作期间关中断。
PI输出振荡:先检查采样周期是否稳定,用示波器看PWM输出周期。如果周期抖动大,先解决周期问题。周期没问题的话,降低Kp或者增大Ti。还有一种可能是反馈信号噪声大,需要加滤波。我在反馈通道加了个一阶低通滤波,截止频率10Hz,振荡明显改善。
参数保存后重启丢失:检查CRC计算是否正确,以及写入后有没有回读校验。我遇到过一次是CRC算的时候用了错误的长度,导致每次校验都失败,上电加载时直接跳过有效页用了默认值。
避坑技巧:Flash操作前一定要先解锁
HAL_FLASH_Unlock(),操作完立刻上锁HAL_FLASH_Lock()。忘记上锁的话,后续任何Flash操作都会失败。另外擦除和写入之间最好加个延时,等Flash内部电荷泵稳定。
5. 项目封装与后续扩展方向
5.1 代码封装的组织方式
V1的代码组织我按功能分目录,每个模块一个文件夹,对外只暴露一个头文件。比如CAN模块,can_driver.c和can_driver.h,头文件里只放函数声明和必要的宏,内部实现细节全部藏起来。这样做的目的是降低耦合,后面换CAN芯片或者改协议,只需要改驱动层,上层不用动。
Project/ ├── Core/ │ ├── Inc/ │ └── Src/ ├── Drivers/ │ ├── STM32F4xx_HAL_Driver/ │ └── CMSIS/ ├── Middlewares/ │ └── FreeRTOS/ ├── App/ │ ├── can_app.c/h │ ├── flash_app.c/h │ ├── pi_app.c/h │ └── task_config.c/h └── Bsp/ ├── bsp_can.c/h ├── bsp_flash.c/h └── bsp_timer.c/hApp层放业务逻辑,Bsp层放硬件相关。PI控制器放在App层,因为它不依赖具体硬件。CAN和Flash的底层操作放在Bsp层,换芯片只改这层。
5.2 后续可以扩展的方向
V1跑通之后,后面可以往上叠的东西不少。第一个方向是加LVGL做本地显示,把状态和参数显示在屏幕上,用FreeRTOS的任务跑LVGL刷新,注意LVGL不是线程安全的,要么在同一个任务里刷,要么加互斥锁。第二个方向是加网络通信,用LWIP或者别的协议栈把数据传到上位机,这个对RAM要求高,F4系列要外扩SRAM才够。第三个方向是多轴控制,把PI控制器实例化多份,每个轴一个任务,CAN报文里区分轴号。
不过这些都是后话,V1先把基础打牢。我个人的体会是,嵌入式项目最怕的就是地基没打好就往上堆功能,堆到后面全是补丁,改一个地方崩三个地方。V1花时间把任务划分、优先级、通信协议、参数管理这些基础做扎实,后面加功能就是搭积木。
最后分享一个小技巧:调试的时候在MonitorTask里加一个命令解析,通过串口可以实时查看每个任务的栈使用情况、CPU占用率、队列水位。这些数据平时不看,一出问题就是救命稻草。
void MonitorTask(void *argument) { for (;;) { // 打印各任务栈剩余 printf("ControlTask stack free: %u\r\n", uxTaskGetStackHighWaterMark(controlTaskHandle)); printf("CanRxTask stack free: %u\r\n", uxTaskGetStackHighWaterMark(canRxTaskHandle)); // 打印CPU占用率需要开configGENERATE_RUN_TIME_STATS vTaskDelay(pdMS_TO_TICKS(5000)); } }这个uxTaskGetStackHighWaterMark返回的是任务运行过程中栈剩余的最小值,单位是字。如果这个值小于50,说明栈快满了,得赶紧加。我一开始ControlTask栈给了256字,跑起来发现水位只剩20,赶紧加到512才稳。