简介:这是针对2021年电赛F题研发的智能小车控制工程,以freeRTOS协调巡线、十字路口识别、黑白块识别与自动返回等任务,基于Keil和CubeMX开发,采用HAL库驱动,适合电赛参赛者及嵌入式开发者,可帮助解决多任务并行与稳定循迹问题。压缩包共1402个文件,主体为C源文件和头文件,含Keil工程配置、CubeMX配置及编译生成文件,整体约51.49MB,目录清晰便于查阅。已有5725人学习下载,属于真实赛题方案。工程代码展示了freeRTOS任务调度、信号量与中断处理的实际运用,可学习巡线数据采集、路口与黑白块识别、自动返回及电机控制等核心模块,也能借助CubeMX快速调整外设参数。工程各模块划分明确,可直接编译烧录或二次开发,对电赛备赛和STM32嵌入式开发有较高参考价值。
1. 2021电赛F题控制部分:这套freeRTOS巡线自动返回工程,能让你现场少熬夜
2021电赛F题的控制部分,真正到了调车现场你会发现,大家拼的不是谁的巡线算法更高明,而是谁的代码经得起临时改。灰度传感器要重新标定、返回路径要加一段减速、按键得临时多一个功能——如果你的控制逻辑全部挤在一个while(1)里,任何一次改动都可能牵起一串全局变量和隐藏状态。把巡线、自动返回这些模块交给freeRTOS之后,每个功能都有自己的任务和入口,现场改参数只需要打开对应那个任务,这是这套Keil工程最值钱的地方。这篇笔记会按 CubeMX 建工程、freeRTOS 任务拆解、巡线算法、自动返回状态机的顺序讲完,最后把我调车时踩过的坑列出来。工程基于STM32F411 + HAL库,Keil MDK直接打开编译即可,思路换到F103或GD32也成立。
2. 用CubeMX生成STM32F411的HAL工程:时钟和外设一次配到位
2.1 CubeMX 里的四个关键配置,先把时基和外设安排明白
做这套控制部分之前,我先说为什么不用标准外设库。HAL库确实啰嗦,但CubeMX能一次性把引脚、时钟、DMA和FreeRTOS都生成出来,对时间紧的比赛场景是最省事的。F411的Cortex-M4F带FPU,跑浮点PID毫不费力,选它主要看中主频高、ADC通道够用。下面的配置是我调过的一套方案,不是唯一解,但大多数8路灰度+双电机的巡线小车可以直接照抄。
时钟树这块,我习惯把SYSCLK放到96MHz而不是100MHz。原因是CubeMX里选96MHz时PLL参数容易凑成整倍数,生成代码后不容易出警告;而且96MHz对48MHz的APB1、96MHz的APB2都能整除,定时器分频更好算。外接25MHz晶振时直接填25,CubeMX会自动算PLLM/PLLN/PLLP。如果板子上没有外部晶振,用HSI也能跑,但温度变化时内部RC会漂,PWM频率跟着变,比赛现场不建议赌这个。
ADC和TIM1的配置取决于外设挂哪条总线。F411的ADC1输入引脚PA0~PA7正好连续,适合做8路扫描;TIM1挂在APB2上,输出PWM频率不会因为APB1分频受影响。打开CubeMX后,把这些外设全部使能,然后进Clock Configuration把SYSCLK调到96MHz,IDE选MDK-ARM,生成工程。
还有一个很容易忽略的选项:SYS里的Timebase Source。默认是SysTick,但FreeRTOS也要用SysTick。如果不改,后面HAL_Delay和osDelay会打架,我建议直接从源头改成TIM6,这一下就能避开后面避坑章节里那个最经典的卡死问题。
| 配置项 | 设置值 | 需注意 |
|---|---|---|
| HSE | 25MHz Crystal | 根据板子实际晶振改 |
| SYSCLK | 96MHz | PLL参数由CubeMX自动算 |
| SYS Timebase Source | TIM6 | 避开FreeRTOS的SysTick |
| ADC1 | 8通道扫描+DMA循环 | 采样时间先拉长到84周期以上 |
| TIM1 | PWM输出CH1/CH2,10kHz | 用APB2定时器时钟 |
2.2 Keil 工程里怎么拆任务:巡线、状态、UI 各干各的
CubeMX生成工程后,打开freertos.c,所有任务都在MX_FREERTOS_Init里创建。我习惯把任务分成三个:line_task负责读灰度、算PID、写PWM;state_task负责状态机切换;ui_task负责按键扫描和显示。这么分的依据是调度周期不同:巡线要100Hz,状态切换100Hz,人机交互20Hz就够。
// MX_FREERTOS_Init:创建巡线、状态、人机交互三个任务 void MX_FREERTOS_Init(void) { osThreadAttr_t line_task_attr = { .name = "line_task", .stack_size = 512 * 4, // 栈空间 2KB,放ADC缓冲和PID浮点变量 .priority = osPriorityAboveNormal }; line_task_handle = osThreadNew(Line_Task, NULL, &line_task_attr); osThreadAttr_t state_task_attr = { .name = "state_task", .stack_size = 256 * 4, // 1KB,状态机里没有大数组 .priority = osPriorityNormal }; state_task_handle = osThreadNew(State_Task, NULL, &state_task_attr); osThreadAttr_t ui_task_attr = { .name = "ui_task", .stack_size = 128 * 4, // 512B,只处理按键和OLED显示 .priority = osPriorityLow }; ui_task_handle = osThreadNew(UI_Task, NULL, &ui_task_attr); }任务优先级按什么标准定?line_task最高,因为它直接决定小车对弯道的响应,被抢占一个周期车就可能偏出去;ui_task最低,晚50ms刷新屏幕没人看得出。优先级的代价是line_task占用CPU多了,state_task可能被饿着,所以我给line_task周期10ms,状态任务10ms,UI任务50ms。最高优先级任务里不要做任何阻塞调用,比如HAL_UART_Transmit和osDelay。
栈大小的估算是调试期最容易翻车的。Keil的默认栈检查不可靠,我一般是先给大一点,比如line_task给2048字节,跑一晚上不出问题再往小压。现场判断栈够不够,最简单是在任务函数里定义一个局部数组凑够栈深,运行后用调试器看这个数组地址附近的数据有没有被踩掉,踩掉就说明栈溢出。
| 任务 | 优先级 | 栈大小 | 周期 | 职责 |
|---|---|---|---|---|
| Line_Task | osPriorityAboveNormal | 2048B | 100Hz | ADC灰度读取、PID计算、PWM输出 |
| State_Task | osPriorityNormal | 1024B | 100Hz | 状态机流转、丢线处理、启停控制 |
| UI_Task | osPriorityLow | 512B | 20Hz | 按键扫描、OLED显示 |
任务之间传数据,我建议先别急着上队列。巡线任务独占灰度数据,状态任务只读丢线标志,用volatile标志位就够了,语义清楚,也省得调一个优先级反转。只有当UI任务要改巡线参数时,才考虑用osMessageQueuePut,因为参数是低频小数据,队列反而安全。
工程编译这块,记得把浮点单元选成FPU单精度。Options for Target → Target → Floating Point Hardware选Single Precision。不选的话,浮点计算会进软件模拟,PID跑起来很慢,现场手感完全不一样。
3. 巡线算法与灰度传感器:从 ADC 采样到 PID 输出
3.1 灰度传感器排布与 ADC 多通道 DMA 采样
灰度传感器我用的8路,输出是模拟电压,黑线反光弱、白底反光强。8个传感器并排装在车头,间距大概2cm,正好覆盖赛道黑线加两侧余量。CubeMX里把PA0~PA7配成ADC1的8个通道,扫描模式打开,DMA循环搬运。
// HAL_ADC_ConvCpltCallback 只做一件事:置位通知 volatile uint8_t line_data_ready = 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc == &hadc1) { line_data_ready = 1; // Line_Task 看到标志就取数据 } }不要在这个回调里做PID计算。中断里跑浮点会把任务调度时钟打乱,还要处理临界区,得不偿失。设一个标志就好,Line_Task周期性检查,主循环天然和DMA采集频率对齐。
#define SENSOR_NUM 8 uint32_t gray_raw[SENSOR_NUM]; uint16_t gray_threshold[SENSOR_NUM]; // 返回偏差,单位 0.1 个传感器间距;全丢线返回 999 int16_t calc_offset_from_gray(void) { int32_t weighted_sum = 0; int32_t active_count = 0; for (int i = 0; i < SENSOR_NUM; i++) { uint8_t on_black = (gray_raw[i] < gray_threshold[i]) ? 1 : 0; weighted_sum += on_black * i * 10; active_count += on_black; } if (active_count == 0) { return 999; // 一路线都没看到 } int16_t offset = (int16_t)(weighted_sum / active_count - (SENSOR_NUM - 1) * 5); return offset; }这组代码的逻辑是位置加权平均:每个传感器如果压到黑线,权值就是编号乘10,中心位置在(N-1)*5=35,所以减去35后偏差范围大约-35到+35。这个偏差直接送给PID作为输入,不做归一化,因为KC量级对PID来说好调。
阈值标定最常见做法是上电后做一次自动校准:车先在白底放2秒,读一遍每个通道的亮值;再把车推到黑线上,读一遍暗值;阈值取二者中值。注意要在两个场地都留够时间,等传感器稳定后再采样,上电瞬间的红外LED电流还没稳。
ADC采样时间我直接设84个ADC时钟周期,不追求速度。8个通道扫描一轮不到8微秒,完全够用,采样周期长一点反而能滤掉高频毛刺。如果比赛场地灯光频闪严重,可以在DMA回调里加一层中值滤波,取最近3轮同通道原始值的中间值。
3.2 PID 巡线参数:先调比例,再加微分,积分只做修边
巡线PID我用浮点实现,F411的FPU跑这些运算就是几条指令的事。下面的PID结构体不绑死某个库,可以直接丢进你的工程。
typedef struct { float kp; float ki; float kd; float integral; float last_error; float limit; // 积分限幅 } PID_TypeDef; void PID_Init(PID_TypeDef *pid, float kp, float ki, float kd, float limit) { pid->kp = kp; pid->ki = ki; pid->kd = kd; pid->integral = 0; pid->last_error = 0; pid->limit = limit; } float PID_Update(PID_TypeDef *pid, float error, float dt) { pid->integral += error * dt; // 积分限幅,防止长时间丢线导致积分饱和 if (pid->integral > pid->limit) pid->integral = pid->limit; if (pid->integral < -pid->limit) pid->integral = -pid->limit; float derivative = (error - pid->last_error) / dt; pid->last_error = error; return pid->kp * error + pid->ki * pid->integral + pid->kd * derivative; }dt直接取Line_Task的调度周期0.01s,不要用HAL_GetTick去现算,那个值受任务调度抖动影响。积分限幅设成转向输出限幅的30%左右,防止过弯时积分项堆积太多,出弯半天回不来。
PID输出到左右轮差速,我这样处理:
// 差速转向:左轮减、右轮加,转向量由PID输出决定 int base_duty = 260; // 基础PWM占空比 int pid_out = (int)PID_Update(&follow_pid, line_offset, 0.01f); int left_duty = base_duty - pid_out; int right_duty = base_duty + pid_out; left_duty = CLAMP(left_duty, 0, 420); right_duty = CLAMP(right_duty, 0, 420); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, left_duty); __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, right_duty);参数初调我建议按这个顺序:先只给Kp,把车放直道上,开到能勉强跟线;然后加Kd,高速冲弯道看甩尾是否被压住;最后加一点点Ki,解决长时间贴外弯的问题。别上来就三参数一起调,翻车了都不知道是哪个参数的问题。
| 参数 | 初调值 | 现象 | 调整方向 |
|---|---|---|---|
| Kp | 8.0 | 直线走不直,弯道反应慢 | 加大Kp |
| Kd | 30.0 | 高速过弯甩尾 | 加大Kd |
| Ki | 0.2 | 长时间贴边 | 微增Ki |
| 积分限幅 | 转向限幅的30% | 出弯回不来 | 减小限幅 |
提示:调PID先关掉自动返回,只保留巡线状态。每次只改一个参数,改完原地复位,不要分不清是哪个参数引起的。
4. 自动返回与丢线处理:用状态机把三段路径理顺
4.1 从巡线到自动返回的状态机设计
自动返回最怕的就是把逻辑堆在巡线函数里,丢线时既要减速又要掉头又要数编码器,一个标志位套一个标志位,最后谁都说不清现在跑到哪一步。所以我用状态机把整个流程拆成待机、巡线、丢线、返回、停止五个状态,每个状态只关心自己的输入和输出。
typedef enum { STATE_IDLE, STATE_FOLLOW, STATE_LOST, STATE_RETURN, STATE_STOP } RobotState; volatile RobotState robot_state = STATE_IDLE;状态转移关系是固定的:启动后进入巡线;连续丢线超过阈值进入丢线处理;掉头完成后进入返回;回到终点触发停止。这样每个阶段都有一份独立的处理代码,不会出现“我在巡线但又在往出发点跑”的混乱。
void State_Task(void *argument) { TickType_t last_wake = xTaskGetTickCount(); for (;;) { switch (robot_state) { case STATE_IDLE: if (start_button_pressed) { PID_Reset(&follow_pid); robot_state = STATE_FOLLOW; } break; case STATE_FOLLOW: if (line_lost_ms >= 50) { SaveReturnParams(); // 记录当前编码器位置和航向 robot_state = STATE_LOST; } break; case STATE_LOST: if (TurnAroundDone()) { robot_state = STATE_RETURN; } break; case STATE_RETURN: if (ReturnDone()) { MotorStop(); robot_state = STATE_STOP; } break; default: break; } vTaskDelayUntil(&last_wake, 10); } }line_lost_ms不是用HAL_GetTick累加,而是在Line_Task里置一个丢线标志,状态任务每次循环对这个标志计数。原因很实际:状态任务周期固定10ms,用自己周期累加出来的时间比查系统Tick更准,不会被调度抖动带偏。
4.2 返回路径:编码器、航向修正、终点停车
自动返回的实现,我按可靠性排过三种方案:纯编码器倒计时、陀螺仪加编码器、终点传感器精停。纯编码器实现最简单,丢线点记录左右轮编码器值,返回时按同样脉冲反向走,但误差会累积,适合短距离。陀螺仪加编码器最稳,用IMU的Z轴角速度积分得到朝向,返回时先转到出发航向,再直线走回丢线点距离。终点处加一个起点传感器做精停。
typedef struct { int32_t left_count; int32_t right_count; int16_t heading; // 记录丢线时朝向,单位 0.1° } ReturnContext; ReturnContext return_ctx; void SaveReturnParams(void) { return_ctx.left_count = encoder_left_count; return_ctx.right_count = encoder_right_count; return_ctx.heading = (int16_t)(imu_yaw * 10); } void DoReturnByEncoder(void) { // 目标:左右轮各自回到丢线前的位置 int32_t left_target = encoder_left_count - return_ctx.left_count; int32_t right_target = encoder_right_count - return_ctx.right_count; if (left_target < 0) SetMotorLeftDirection(DIR_BACKWARD); else SetMotorLeftDirection(DIR_FORWARD); if (right_target < 0) SetMotorRightDirection(DIR_BACKWARD); else SetMotorRightDirection(DIR_FORWARD); }编码器用定时器编码器模式,CubeMX里把TIM2/TIM3设成Encoder Mode,计数频率一般按4倍频理解。返程距离就是当前计数值减丢线点保存值,方向取反,左右轮分别闭环。返回速度要低于巡线速度,我一般取巡线base_duty的60%左右,接近终点前降到30%,否则冲过终点再倒回来就很狼狈。
返回过程中不要用巡线PID。返程时车头很可能离线很远,灰度传感器全丢,PID积分直接饱和,越修越偏。返回阶段更适合用角度闭环:陀螺仪读出当前航向,目标航向是丢线时记录的heading,用一个独立的航向PID控制左右轮差速。
终点停车不能只看编码器,因为打滑和机械误差会让实际距离偏出不少。我在起点放了一组独立的起点传感器,车回到起点后先减速,再检测到触发信号就停车。现场调车时,这个终点传感器的触发阈值要单独测,别用灰度阈值一套通吃。
5. 巡线小车避坑与排查:freeRTOS 环境下最经典的四个问题
5.1 PWM占空比周期性抖动,低速时电机一顿一顿
现象:巡线任务单独跑时PWM输出很干净,加了freeRTOS之后电机在低速时明显一顿一顿,示波器看PWM波形占空比跳动。
原因:最典型的是在中断或高优先级任务里做了耗时的浮点运算,比如printf或OLED刷新,把PWM的更新时刻往后拖了。另一个原因是两个任务同时修改占空比变量,没有加保护,导致调度器看到的值和任务实际写入的值不一致。
解决:把PWM输出放到Line_Task最后,用局部变量算完再一次性写入定时器比较寄存器。printf改成DMA发送或只用低优先级UI任务。如果多个任务确实都要改电机,加一个临界区,让PWM变量访问不可重入。
5.2 加了freeRTOS之后HAL_Delay卡死,任务全部停摆
现象:代码里某个地方调用了HAL_Delay(10),程序就再也跑不动,连LED都不闪。
原因:CubeMX默认把HAL时基源设置为SysTick,而FreeRTOS的时基源也是SysTick。FreeRTOS启动后接管了SysTick的中断优先级,HAL_Delay依赖的uwTick没法更新;或者SysTick优先级高于PendSV,HAL_Delay在临界区里反复等待,把调度器卡住。
解决:CubeMX里把SYS的Timebase Source从SysTick改成TIM6,重新生成代码。然后把代码里所有HAL_Delay替换成osDelay或vTaskDelay。注意TIM6的初始化代码MX_TIM6_Init要在MX_FREERTOS_Init之前调用,顺序别颠倒。
5.3 8路灰度ADC的值忽高忽低,阈值标定总失败
现象:巡线车慢速走过黑线,偏差摆动很大,一个传感器在黑白之间反复跳。
原因:ADC采样时间太短,传感器输出阻抗高,采样电容没充满;GPIO没配成模拟模式;或者DMA搬运宽度设置错误,8位宽度把4字节数据传输拆乱。还有一种玄学是现场灯光频闪,红外传感器在50Hz工频干扰下每帧结果都不一样。
解决:先在CubeMX里确认PA0~PA7是Analog模式,采样时间设84周期以上,DMA外设和内存都设Word宽度。如果场地灯光干扰明显,在中值滤波里处理,取最近3轮同一通道的中间值。
// 对最近3轮原始值做中值滤波,取中间值上送 uint32_t gray_filtered[SENSOR_NUM]; for (int i = 0; i < SENSOR_NUM; i++) { uint32_t a = gray_buf_prev2[i]; uint32_t b = gray_buf_prev1[i]; uint32_t c = gray_buf_curr[i]; gray_filtered[i] = (a > b) ? ((b > c) ? b : (a > c ? c : a)) : ((a > c) ? a : (b > c ? c : b)); }阈值标定不要在通电瞬间做,等上电3秒后再读,红外LED和模拟电路需要稳定时间。标定完把阈值通过串口打印出来,确认每个通道黑白差别明显,如果某一路阈值特别接近,多半是传感器位置偏了。
5.4 自动返回时车走斜,方向偏移越拉越大
现象:巡线出去时很直,掉头后回程越来越偏,到终点偏出去二三十厘米。
原因:左右电机死区不一致。PWM小信号时一个轮子已经转了,另一个还卡着,两个轮子实际走的距离不相等。返程如果只按总脉冲数控制,误差会一直累积到终点。
解决:把左右轮各自标定最小启动占空比,返回时给死区大的轮子加一个补偿量,或者给死区小的减一个补偿量。更靠谱的是编码器闭环,让左右轮分别追目标脉冲数,而不是只给固定PWM。返程速度降到巡线的60%,用慢速换精度,现场微调时最好用一块空地反复测,别在正式赛道上边跑边改。
6. 现场最实用的两个技巧:串口DMA日志和任务心跳看门狗
6.1 让 printf 走 DMA,不干扰巡线
现场调车最需要的就是看清PID输出和偏差值,但直接用HAL_UART_Transmit的printf会阻塞任务,日志一多,巡线直接变形。我一般写一个很轻量的日志函数,把HAL_UART_Transmit换成DMA版本,调用后立即返回。
// 简单地把整条日志打包,用DMA发送,调用后立即返回 void Debug_Print(const char *str, uint16_t len) { HAL_UART_Transmit_DMA(&huart2, (uint8_t *)str, len); }日志输出频率控制在20Hz以内,只在后期调参时临时打开。这样串口打印不会抢走巡线任务的CPU时间,现场看曲线也够用。
6.2 给每个任务加心跳,死循环直接复位
比赛现场最怕的不是编译报错,而是小车跑着跑着突然不动,面板没有任何提示。我给每个任务加一个心跳时间戳,放在最低优先级的UI任务里检查,超过1秒没更新就系统复位。
volatile uint32_t task_heartbeat[3]; // 0: line 1: state 2: ui // 每个任务循环末尾更新 task_heartbeat[0] = uwTick; // UI任务里调用 void HeartBeat_Check(void) { for (int i = 0; i < 3; i++) { if (uwTick - task_heartbeat[i] > 1000) { __disable_irq(); NVIC_SystemReset(); // 直接复位,比硬看门狗更省硬件 } } }这个方案不需要外部看门狗,也不用开FreeRTOS的运行时统计。现场如果车停了,看一眼心跳复位时间大概能猜到是哪个任务卡住,比对着调试器干瞪眼舒服得多。我后来每套控制工程的末尾都会留这个检查,车跑丢了自动重启,至少不会在展示时尴尬。
这就是我这套2021电赛F题控制部分最想保留的两个习惯:日志走DMA,任务有心跳。前者保证巡线性能不被调试拖垮,后者保证现场故障不至于找不到原因。希望帮到你。
本文还有配套的精品资源,点击获取