电赛智能小车软件架构:基于时间片与事件驱动的混合调度实战

电赛智能小车软件架构:基于时间片与事件驱动的混合调度实战

1. 项目概述与核心挑战

2022年电赛C题,那个关于智能小车的题目,现在回想起来依然觉得“酸爽”。题目要求小车在特定赛道上完成循迹、避障、识别与抓取搬运等一系列任务,对软件系统的实时性、稳定性和模块化设计提出了极高的要求。很多队伍硬件做得不错,但最终折在了软件上——要么是各模块打架,逻辑混乱;要么是响应不及时,错过关键动作点。我当时带的队伍,核心策略就是把软件流程当作一个系统工程来设计,而不是一堆代码的简单堆砌。这篇文章,我就来详细拆解一下我们当时构建这套软件系统的完整思路、核心架构以及那些在实验室通宵调试出来的“血泪经验”。无论你是正在备赛的电赛新人,还是对嵌入式实时系统设计感兴趣的开发者,相信这些从实战中总结出的流程与方法,都能给你带来直接的参考价值。

简单来说,这个题目的软件核心就一句话:如何让一个资源有限的单片机(通常是STM32系列),像一位训练有素的交响乐指挥,精准、协调地指挥传感器(眼睛)、电机(手脚)和执行机构(手)完成一套复杂的组合动作。这背后涉及任务调度、传感器融合、运动控制、状态机设计等多个层面的问题。我们将围绕“流程”这个关键词,深入探讨从系统初始化到任务闭环的每一个环节。

2. 软件系统整体架构设计

一套清晰的顶层设计是成功的一半。面对多任务并发的需求,我们放弃了简单粗暴的超级循环(Super Loop)加大量延时delay的写法,而是采用了基于时间片与事件驱动的混合调度架构。这听起来有点高级,但其实理解起来很简单。

2.1 架构选型:为什么不是RTOS也不是纯轮询?

很多同学第一个想到的是上实时操作系统(RTOS),比如FreeRTOS。对于电赛小车,RTOS当然是优秀的解决方案,它提供了任务、队列、信号量等成熟机制。但我们当时基于几点考虑没有首选RTOS:

  1. 学习成本与稳定性:在有限的备赛时间内,让所有队员熟练掌握RTOS的编程范式、内存管理和调试技巧,风险较高。一个使用不当的信号量死锁就可能导致整车“僵死”,调试起来更为复杂。
  2. 资源开销:虽然RTOS本身开销不大,但对于任务数量相对固定(5-8个)、逻辑关系明确的小车项目,一个精心设计的裸机调度器完全够用,且整体可控性更强。
  3. 对硬件的直接控制感:裸机编程能让我们更贴近硬件计时器、中断,对于需要微秒级精度的电机PWM控制、传感器脉冲捕获,感觉更“直接”。

而传统的纯轮询(在一个大循环里依次查询各个模块)的弊端很明显:低优先级任务会阻塞高优先级任务。比如你在循环里读一个响应慢的传感器,电机的控制就可能不及时,导致小车冲出赛道。

因此,我们最终的架构是一个混合模型

  • 心脏:系统时钟节拍。利用一个硬件定时器(如SysTick或通用定时器)产生固定的时间中断(例如1ms或5ms),这个中断作为整个系统的“心跳”。
  • 骨架:分层任务调度。将任务分为不同层级:
    • 临界任务:必须在每个“心跳”中执行,或由硬件中断直接触发。例如:电机PID计算与输出、编码器速度测量、紧急停车检测。
    • 周期任务:以固定的较长周期执行。例如:摄像头图像采集与处理(50ms)、超声波测距(20ms)、路径规划决策(100ms)。
    • 事件任务:由特定条件触发执行。例如:抓取机构动作(当识别到目标且到达指定位置时)、状态指示灯更新。
  • 神经:事件与标志位通信。各任务间通过共享变量(加简易保护,如关中断访问)或标志位来传递信息。例如,图像处理任务完成后设置一个“目标已识别”标志,主控任务检测到该标志后,触发运动控制任务向目标移动。

2.2 模块划分与通信接口

我们将整个小车软件划分为以下几个核心模块,并明确定义了它们的输入输出接口:

模块名称主要职责输入输出执行周期/触发方式
传感器采集模块周期性读取所有传感器原始数据GPIO、ADC、定时器输入捕获编码器脉冲数、陀螺仪角速度、摄像头原始数组、超声波距离值定时中断(1ms/5ms)
数据融合与滤波模块处理原始数据,得到可靠状态估计原始传感器数据小车速度、角度、位置、赛道偏差、障碍物坐标定时任务(5ms)
图像识别模块分析摄像头数据,识别赛道线、目标物摄像头原始数组赛道中心偏差、目标物类型与坐标事件触发(当摄像头数据就绪)
决策与路径规划模块根据当前状态和目标,生成期望轨迹小车状态、赛道信息、目标物信息期望速度、期望转向角、动作指令(如抓取)定时任务(10-20ms)
运动控制模块计算电机控制量,实现循迹、避障期望速度/转向角、实际速度/角度左、右电机PWM占空比定时中断(严格1ms)
执行机构控制模块控制舵机、机械臂完成动作动作指令(枚举值)舵机PWM信号、步进电机脉冲事件触发
状态管理与调试模块系统状态维护、参数在线调试、数据发送至上位机内部状态标志、调试命令串口发送数据包、LED显示状态定时任务(50ms)

注意:这个表格不仅仅是分工,更是“契约”。在编码前,小组必须就每个模块的输入输出数据类型、单位、范围达成一致。例如,“赛道中心偏差”是用像素表示还是用厘米表示?正负方向如何定义?提前约定好能避免后续联调时大量的“扯皮”和BUG。

3. 核心流程的详细拆解与实现

有了架构,接下来就是填充血肉。我们以小车从启动到完成一次抓取任务的典型流程为例,进行逐环节的解析。

3.1 系统初始化流程:顺序就是稳定性

初始化顺序混乱是很多新手小车的第一个“坑”。一个推荐顺序如下:

  1. 关闭所有中断:防止在初始化中途被意外中断打断。
  2. 初始化系统时钟:配置HCLK、PCLK等,为后续外设提供正确时钟源。
  3. 初始化调试接口:如串口(USART)。这一步非常关键!尽早打通调试信息输出通道,后续任何模块初始化失败或运行异常,你才能通过打印信息快速定位。我们通常会在这里输出“System Start...”这样的启动信息。
  4. 初始化GPIO:将电机驱动、传感器电源控制、LED指示灯等GPIO配置为推挽输出或浮空输入等模式。注意,电机驱动芯片的使能引脚(ENABLE)此时应置为禁用状态,防止电机误动作。
  5. 初始化定时器
    • PWM定时器:用于电机驱动。配置为互补输出带死区控制(如果使用有刷直流电机+H桥,或直流无刷电机),但先不启动。
    • 编码器接口定时器:配置为编码器模式,用于测量电机转速。
    • 系统节拍定时器:如SysTick,配置为1ms中断,作为系统时间基准。
    • 通用定时器:用于超声波测距的输入捕获、舵机PWM生成等。
  6. 初始化ADC:用于采集电池电压、巡线传感器模拟量等。
  7. 初始化传感器:通过I2C/SPI初始化陀螺仪(MPU6050/9250)、摄像头(OV7725等)模块。这里要注意传感器的启动时序和寄存器配置,最好参考官方驱动或成熟例程。
  8. 初始化中断与DMA:配置串口接收中断、定时器中断等,并设置DMA(如果用到,如摄像头数据搬运)。
  9. 初始化控制算法数据结构:初始化PID控制器的参数结构体、滤波器结构体等,赋予合理的初始值。
  10. 最后才使能电机:在所有软硬件准备就绪后,再将电机驱动芯片的使能引脚置高。
  11. 开启全局中断:系统正式“活”过来,开始响应定时器中断。

这个顺序的核心逻辑是:先准备“观察”和“思考”的工具(调试、传感器),再准备“行动”的工具(电机),最后才允许系统开始运行

3.2 主循环与中断服务例程协作流程

我们的主函数main()非常简洁,核心就是一个无限循环,但循环内不是具体任务,而是调度器。

int main(void) { System_Init(); // 执行上述初始化流程 SysTick_Init(); // 启动系统心跳定时器 while (1) { // 1. 调度非实时性任务(低优先级) if (task_10ms_flag) { // 10ms任务标志,由SysTick中断置位 task_10ms_flag = 0; Task_ImageProcess(); // 图像处理 Task_DecisionMaking(); // 决策规划 } if (task_50ms_flag) { // 50ms任务标志 task_50ms_flag = 0; Task_StatusReport(); // 状态上报与调试 } // 2. 处理事件触发型任务 if (event_grab_flag) { event_grab_flag = 0; Task_GrabObject(); } // 3. 空闲时可执行低优先级后台任务,如内存碎片整理(如果有) // ... } }

而真正的实时性任务,放在中断服务函数(ISR)中。以1ms的SysTick中断为例:

void SysTick_Handler(void) { static uint16_t tick_10ms = 0, tick_50ms = 0; // --- 临界任务,每次中断都必须执行 --- Task_SensorDataCapture(); // 快速读取编码器、陀螺仪 Task_MotorSpeedControl(); // 电机PID计算与PWM更新 // --- 周期任务标志更新 --- if (++tick_10ms >= 10) { // 10ms到 tick_10ms = 0; task_10ms_flag = 1; // 通知主循环执行10ms任务 } if (++tick_50ms >= 50) { // 50ms到 tick_50ms = 0; task_50ms_flag = 1; // 也可以在这里直接调用非常简单的50ms任务 } }

这里的关键技巧:中断服务函数里只做必要、快速的事情。像图像处理这种耗时可能长达几十毫秒的任务,绝对不能在中断中做,否则会阻塞其他所有中断,导致系统崩溃。我们的做法是,在中断里只设置一个“数据就绪”标志,或者将原始数据拷贝到缓冲区,具体的处理算法放到主循环中对应的周期任务里。

3.3 从循迹到抓取的完整任务流

现在,我们看一个具体场景:小车需要循迹到某个位置,识别并抓取目标物。

  1. 状态启动:系统上电,初始化后进入STATE_IDLE(空闲)状态。等待一个启动信号(比如按键按下),切换到STATE_LINE_FOLLOWING(循迹)状态。

  2. 循迹过程

    • 数据流:摄像头模块(Task_ImageProcess)每50ms处理一次图像,计算出赛道中线与图像中心的横向偏差line_error
    • 决策流:决策模块(Task_DecisionMaking)每20ms运行一次,它读取line_error,并结合当前小车速度,使用一个查表法或简单PID,计算出期望的转向角steering_angle
    • 控制流:运动控制模块(Task_MotorSpeedControl,在1ms中断中执行)是核心。它采用双闭环控制
      • 内环速度环:通过编码器反馈的实际转速与决策模块给出的期望速度target_speed做差,经PID计算,输出电机的基础PWM值。
      • 外环方向环:将期望转向角steering_angle转换为左右轮的速度差,叠加到内环输出的基础PWM值上。最终生成左轮PWM和右轮PWM。
    • 这个过程中,数据融合模块(5ms任务)持续融合编码器和陀螺仪数据,提供更准确的车体朝向角(Yaw),用于提高循迹的平滑性和抗干扰能力。
  3. 目标识别与接近

    • 决策模块在循迹的同时,会判断图像识别模块是否输出了有效的目标物信息(比如一个红色方块的坐标)。
    • 一旦识别到目标,决策模块会计算目标相对于小车的位置。如果目标在抓取范围内,则触发event_grab_flag。如果还需要调整位置,决策模块可能会临时修改期望速度和转向角,让小车微调到一个精确的抓取位姿。
  4. 抓取动作执行

    • event_grab_flag被置位后,主循环调用Task_GrabObject()
    • 这是一个典型的顺序状态机任务。它内部会细分为多个子状态:
      • GRAB_SUBSTATE_EXTEND:控制机械臂伸出。
      • GRAB_SUBSTATE_CLAMP:控制夹爪闭合。
      • GRAB_SUBSTATE_RETRACT:控制机械臂缩回。
      • 每个子状态完成后,根据传感器反馈(如限位开关)切换到下一个子状态,直到整个抓取动作完成。
    • 抓取期间,决策模块可能会将小车状态置为STATE_PAUSE(暂停),暂时冻结运动控制,确保机械臂动作时车身稳定。
  5. 任务完成与返回:抓取完成后,决策模块将状态切回STATE_LINE_FOLLOWING,并可能将期望速度设置为负值,让小车沿原路返回起点。

整个流程中,状态机是贯穿始终的灵魂。我们为小车定义了一个顶层的状态枚举,如IDLE,LINE_FOLLOWING,AVOIDING,GRABBING,PAUSE,ERROR。每个状态决定了哪些模块该工作,以及如何工作。状态之间的转换条件必须清晰、无二义性。

4. 关键算法模块的软件实现细节

流程搭好了,每个模块的“算法内核”决定了小车的性能上限。

4.1 运动控制:PID的实战调参心得

PID是运动控制的核心,但直接套用公式往往效果不好。我们的经验是:

  • 速度环(内环)

    • 只使用PI控制器。微分(D)对速度噪声非常敏感,容易引入振荡。
    • 参数整定顺序:先将I和D设为0。大幅增加P,直到电机出现明显的“嗡嗡”声或周期性振荡,此时系统处于临界稳定状态。然后将P减小到此时值的50%-60%。接着,慢慢增加I,用于消除静差(即给定速度与实际速度的稳态误差)。观察响应,直到系统能较快且无静差地跟随速度指令。
    • 积分抗饱和:必须实现!当电机输出PWM已达限幅值(如最大占空比)时,应停止积分项的累加,防止“积分饱和”,导致系统退出饱和区时产生大的超调。
  • 方向环(外环,用于循迹)

    • 输入是图像处理得到的横向偏差line_error(单位:像素或厘米),输出是转向角或速度差。
    • 这里可以使用位置式PD控制器。P项提供快速响应,D项抑制过冲和振荡(因为偏差的变化率可以预测趋势)。
    • D项的低通滤波:偏差的微分直接计算噪声很大,会对转向造成高频抖动。必须对微分项进行低通滤波。一个简单有效的方法是使用“不完全微分”:dout = Kd * (error - last_error) * alpha + dout * (1 - alpha),其中alpha是一个远小于1的滤波系数。
  • 前馈补偿:对于已知的扰动,如前方的上坡,可以在PID输出上直接加上一个预估的补偿值,能显著提升抗干扰性能。

4.2 图像处理:轻量化与实时性的平衡

电赛常用的OV系列摄像头,输出一幅图像(比如80*60分辨率)的数据量对于单片机来说也不小。我们的策略是在满足功能的前提下,极度轻量化

  1. 区域兴趣(ROI):不需要处理整幅图像。例如,只分析图像最下方几行来做近处的紧急纠偏,分析中间区域来做主要循迹,分析上方区域来预判弯道。这能节省大量处理时间。

  2. 二值化与边缘提取

    • 固定阈值:在比赛现场光线稳定的情况下,赛前标定一个固定的灰度阈值是最快最稳的。
    • 动态阈值(大津法):如果现场光线有变化,可以每帧或每隔数帧计算一次阈值,但计算量稍大。
    • 我们常用的是基于行的扫描。逐行扫描像素,找到灰度跳变点(从黑到白,从白到黑),这些点就是赛道边缘。记录下每行左右边缘的坐标,取中点即可得到该行的赛道中心。
  3. 偏差计算:得到多行的中心点后,可以采用加权平均,近处的行权重大,远处的行权重小。最终计算出一个代表当前赛道横向偏差的数值。

  4. 特殊元素识别:对于十字路口、环岛、车库等,需要通过边缘点的分布模式(比如连续多行丢失边缘、边缘点呈现特定形状)来进行判断,并触发相应的决策状态。

实操心得:图像处理算法一定要在PC上先用Python(OpenCV)仿真验证逻辑,生成一套测试用例。然后再将验证好的算法逻辑“翻译”成C代码,并针对单片机进行优化(比如用查表代替浮点运算,用移位代替乘除)。这能极大提高开发效率和代码可靠性。

4.3 数据融合与滤波:让数据更“靠谱”

传感器数据都有噪声,直接使用会使得小车“发抖”。

  • 编码器速度测量:不要直接用本次脉冲数/间隔时间。我们采用移动平均滤波:维护一个包含最近N次速度测量值的队列,输出其平均值。N取4或8,能有效平滑转速波动。
  • 陀螺仪角度积分:陀螺仪输出的角速度积分得到角度,但存在零漂,积分会累积误差。在电赛短时间、小范围运行中,可以只用陀螺仪积分,但赛前必须进行零偏校准:上电后静止2秒,计算这段时间角速度的平均值作为零偏,后续每个读数都减去这个零偏。
  • 互补滤波:如果还有加速度计(可以测倾角),可以用互补滤波将加速度计(长期稳定但瞬时噪声大)和陀螺仪(短期精确但长期漂移)的数据融合,得到更稳定的姿态角。但对于只在平地运行的循迹小车,一个校准好的陀螺仪通常就够了。

5. 调试、测试与常见问题排查

软件流程的威力,一半在编写,一半在调试。

5.1 分层调试法

  1. 模块单元测试:在集成前,单独测试每个模块。

    • 电机:写个测试程序,让电机正转、反转、调速,看是否正常。
    • 传感器:读取原始数据,通过串口打印出来,观察是否合理。用手转动轮子看编码器值变化,晃动车身看陀螺仪数据变化。
    • 摄像头:将二值化后的图像通过串口发送到上位机(如匿名上位机、山外上位机)显示出来,确认赛道识别是否正确。
  2. 控制环测试

    • 开环测试:让小车悬空,给定一个固定PWM,看轮子转不转。
    • 速度闭环测试:给定一个目标速度,通过串口打印实际速度曲线,调整PID参数直到能快速、平稳地跟随。
    • 方向闭环测试:用手持着小车,在赛道上方移动,通过串口打印计算出的偏差和控制输出的转向量,观察逻辑是否正确。
  3. 系统集成联调:所有模块组合在一起,在低速、简单赛道上进行测试。务必做好急停开关!

5.2 常见问题与排查清单

现象可能原因排查思路
小车启动后原地抖动或狂奔1. 电机PWM输出引脚配置错误(如同相通道接反)。
2. 编码器AB相接反,导致速度反馈极性错误,形成正反馈。
3. PID参数极不合理(P过大)。
1. 单独测试电机正反转。
2. 手动转动一个轮子,观察单片机读到的编码器值是增加还是减少,方向是否正确。
3. 将PID参数全部设为0,先给一个很小的固定PWM输出,看电机是否平稳低速转动。
循迹时左右摇摆(振荡)1. 方向环PID的P太大或D太小。
2. 图像处理延迟太大,导致控制滞后。
3. 机械结构松动,存在间隙。
1. 降低方向环P,适当增加D(并做好滤波)。
2. 优化图像处理算法,减少处理时间;或者降低控制频率,让控制周期大于图像处理周期。
3. 紧固所有螺丝,检查轮子与电机的连接。
过弯时冲出赛道1. 前瞻距离太短,入弯太晚。
2. 弯道处图像识别丢线。
3. 速度太快,离心力过大。
1. 使用图像更上方的行来计算偏差,实现“预瞄”。
2. 优化弯道处的图像处理算法,如增加搜索范围,或使用上一帧的有效边缘进行预测。
3. 在决策模块中,根据赛道曲率(偏差的变化率)动态降低期望速度。
抓取机构动作不到位1. 舵机控制脉冲宽度不准确。
2. 机械结构卡死或摩擦力过大。
3. 动作时序错误,未等待前一个动作完成。
1. 用示波器测量舵机控制信号脉宽,校准对应角度的脉冲值。
2. 手动测试机构运动是否顺畅,润滑关键部位。
3. 在状态机中,增加对动作完成传感器(如限位开关)的等待判断,或增加固定的动作完成延时。
系统运行一段时间后死机1. 中断服务函数执行时间过长,导致其他中断被阻塞。
2. 栈溢出或堆内存碎片化(如果用了动态内存)。
3. 数组越界、野指针等内存错误。
1. 检查中断函数,确保其中没有耗时操作(如浮点运算、循环处理图像)。
2. 监控栈使用情况,避免在中断和任务中定义大数组;尽量使用静态内存。
3. 使用调试器设置内存访问断点,或进行代码审查。

5.3 不可或缺的调试工具

  1. 串口上位机:这是你的“眼睛”。除了打印文本日志,一定要学会用上位机的波形显示功能。将关键变量(如期望速度、实际速度、赛道偏差、PID输出)实时发送并绘图,参数调整的效果一目了然。
  2. 逻辑分析仪:用于精确测量PWM波形、编码器脉冲、串口数据等时序信号,排查硬件层面的通信问题。
  3. 离线数据分析:在关键节点(如识别到目标时),将一批相关数据(如图像行、传感器读数、控制量)打包存储到单片机的Flash或通过串口发送保存。事后在PC上分析,可以复现问题场景。

最后,我想分享一个最深刻的体会:电赛小车的软件,本质上是一个对“确定性”和“实时性”要求极高的系统。你的每一行代码,每一个延时,都可能被赛道上的每一个细节无限放大。成功的流程设计,就是为这种不确定性建立起确定的响应规则。从架构上隔离变化,在模块内封装细节,用状态机描述逻辑,用调试数据驱动优化——这套方法论,远不止于应对一场比赛。