简介这是针对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晶振时直接填25CubeMX会自动算PLLM/PLLN/PLLP。如果板子上没有外部晶振用HSI也能跑但温度变化时内部RC会漂PWM频率跟着变比赛现场不建议赌这个。ADC和TIM1的配置取决于外设挂哪条总线。F411的ADC1输入引脚PA0~PA7正好连续适合做8路扫描TIM1挂在APB2上输出PWM频率不会因为APB1分频受影响。打开CubeMX后把这些外设全部使能然后进Clock Configuration把SYSCLK调到96MHzIDE选MDK-ARM生成工程。还有一个很容易忽略的选项SYS里的Timebase Source。默认是SysTick但FreeRTOS也要用SysTick。如果不改后面HAL_Delay和osDelay会打架我建议直接从源头改成TIM6这一下就能避开后面避坑章节里那个最经典的卡死问题。配置项设置值需注意HSE25MHz Crystal根据板子实际晶振改SYSCLK96MHzPLL参数由CubeMX自动算SYS Timebase SourceTIM6避开FreeRTOS的SysTickADC18通道扫描DMA循环采样时间先拉长到84周期以上TIM1PWM输出CH1/CH210kHz用APB2定时器时钟2.2 Keil 工程里怎么拆任务巡线、状态、UI 各干各的CubeMX生成工程后打开freertos.c所有任务都在MX_FREERTOS_Init里创建。我习惯把任务分成三个line_task负责读灰度、算PID、写PWMstate_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状态任务10msUI任务50ms。最高优先级任务里不要做任何阻塞调用比如HAL_UART_Transmit和osDelay。栈大小的估算是调试期最容易翻车的。Keil的默认栈检查不可靠我一般是先给大一点比如line_task给2048字节跑一晚上不出问题再往小压。现场判断栈够不够最简单是在任务函数里定义一个局部数组凑够栈深运行后用调试器看这个数组地址附近的数据有没有被踩掉踩掉就说明栈溢出。任务优先级栈大小周期职责Line_TaskosPriorityAboveNormal2048B100HzADC灰度读取、PID计算、PWM输出State_TaskosPriorityNormal1024B100Hz状态机流转、丢线处理、启停控制UI_TaskosPriorityLow512B20Hz按键扫描、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)*535所以减去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解决长时间贴外弯的问题。别上来就三参数一起调翻车了都不知道是哪个参数的问题。参数初调值现象调整方向Kp8.0直线走不直弯道反应慢加大KpKd30.0高速过弯甩尾加大KdKi0.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优先级高于PendSVHAL_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任务有心跳。前者保证巡线性能不被调试拖垮后者保证现场故障不至于找不到原因。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?