从0到1带你打电赛·小车电控篇(十一):把一切串起来——状态机、控制环时序与整车软件架构

电赛小车系列前面十篇,我们把电控的零件一个个磨出来了:电机能听话转、传感器看得见路、PID 能把速度和方向调稳、视觉和主控也能对上话。但这些都还只是”零件”。这一篇,我们要做的是把所有零件焊成一台真正能在赛道上跑的车。

你会发现一个有意思的现象:很多队伍每个模块单测都好好的——电机转得欢、灰度读得准、PID 波形漂亮——可一旦合到一起跑整车,就开始抽风:要么卡死不动,要么时灵时不灵,要么过个十字就冲出去了。问题几乎都不在某个模块本身,而在”怎么把它们组织起来、谁先跑谁后跑、出了岔子怎么兜底”。这就是整车软件架构要解决的事。

这一篇我们讲清四件事:分层(代码怎么摆放)、调度(谁在什么时候跑)、控制环时序(一拍里按什么顺序干活)、状态机(整车行为怎么管),最后再钉一遍贯穿全系列的保护逻辑和那条”机械定上限、软件定下限”的硬道理。吃透这几件事,你的车就从”一堆能动的零件”变成”一台能比赛的车”。

一、分层架构:让你的算法”换芯片不换脑子”

先讲个大白话比喻。写整车软件像点外卖:

🧩 分层就是各司其职

你(应用层 App)只管对着 App 下单,从不冲进后厨。App(中间件层)把你的订单翻译成”做一份番茄炒蛋、骑手送到 3 号楼”。真正干脏活累活的是商家灶台和骑手(驱动层 / 硬件抽象层)。 妙处在于:你换个城市(换芯片),点外卖的方式一点不变,变的只是当地的骑手和店家(驱动层)。

这正是逐飞、恩智浦那套智能车开源库多年沉淀下来的标准做法。把代码分成四层,上层依赖下层、下层不知道上层的存在:

电赛小车电控 · 示意图

以逐飞 SeekFree 库为例,它的真实分层是这样的:

逐飞里叫什么 放什么 芯片相关吗
公共层 zf_common 时钟、中断、调试配置 弱相关
驱动层 zf_driver PWM、PIT 定时中断、编码器、ADC、GPIO、UART、SPI 强相关
设备层 zf_device 摄像头、陀螺、编码器、屏的初始化和应用函数 弱相关
应用层 你自己写的 code 状态机、循迹决策、串级 PID 调用 无关

中间件层(PID、滤波、协议、元素识别)这些纯算法,本质上和芯片没有任何关系——它们只是数学,搬到任何平台上都一样跑。

一条必须刻进脑子的铁律

🔥 上层只调下层,下层绝不反调;设备层禁止直接碰芯片 API

设备层(zf_device)只能调驱动层(zf_driver)的接口,绝不能直接去调 HAL_GPIO_WritePin 这种最底层的芯片 API。逐飞为了彻底解耦,连设备层都全部通过驱动层拿接口,连 extern 都不用。

这条铁律还是用打比方理解最快:

💡 像公司里的汇报关系

下属向上级汇报(对外暴露接口),老板不会替下属干活,下属也别越级跑去机房乱动设备(直接调 HAL)。一旦有人绕过流程,整个公司就乱套了——你换个机房(换芯片),就得满地找哪儿被人偷偷动过。

为什么值得花这个力气分层? 因为它直接决定了你前面那些功夫能不能”白嫖”过来。整车搭建那一篇讲过”STM32 练手、MSPM0 比赛”的策略,分层就是这套策略能成立的根。看一组实打实的证据:同一套逐飞库,社区已经移植到了 RT1064、英飞凌 TC264、STC32G144K,甚至 2025 年 TI 板电赛用的 MSPM0G3507。这些版本的 App 层和算法层几乎一字不改,差异全部集中在底层驱动。

✅ 这意味着什么

你在 STM32 上调通的串级 PID、写好的状态机、磨好的丢线找回逻辑,换到 TI MSPM0 比赛时,只动驱动层就能整段平移过去。不分层,换芯片就是重写;分层做好,换芯片只是换驱动。

想找范本直接抄结构的,推荐 Sakuramdd/SeekFree_RT1064_Opensource_Library,标准三层加上 code 目录(image / control / init),是研究分层与解耦最干净的样本。STM32 与 MSPM0 之间具体怎么对应 API、平移时要注意什么,整车搭建那一篇已经给过对照心法,这里不展开。

二、调度:电赛首选”前后台裸机”,别动不动就上 RTOS

代码摆好了,下一个问题是:这么多任务——读传感器、跑 PID、刷屏、收视觉数据——谁在什么时候跑?这叫调度。常见的有三档,我们从简单到复杂排一下。

第一档:前后台裸机(电赛首选)

这是最常见、最可预测的方案,也是绝大多数电赛车的选择。结构极简:

🧩 值班大爷和门铃

主循环是”后台”,像值班大爷慢悠悠扫地、刷屏、收通信,干那些不急的慢活。定时器中断是”前台”,像门铃——一响立刻放下扫帚去开门(跑控制环),开完门接着扫地。 关键纪律:门铃要快进快出,开个门别在门口跟人唠嗑(中断服务函数别太长),不然下一个客人(下一个控制周期)就堵在门口了。

把这套结构画出来就是:

电赛小车电控 · 示意图

第二档:时间片轮询

如果你有好几个不同周期的任务(控制环 5ms、读视觉 20ms、刷屏 100ms),可以用一个简单的时间片调度器:用一个 1ms 的定时器统一递减计时,到点就给任务打个”该跑了”的标记,主循环看到标记再去执行。

// 时间片轮询调度器:一个 1ms 的 tick 管多周期任务
typedef struct { uint16_t period, timer; uint8_t run; void(*task)(void); } Task_t;

Task_t tasks[] = {
    {5,   5,   0, Control_Loop},  // 5ms:  控制环
    {20,  20,  0, Read_Vision},   // 20ms: 收视觉
    {100, 100, 0, Update_OLED},   // 100ms:刷屏
};
#define N (sizeof(tasks) / sizeof(tasks[0]))

void SysTick_Handler(void) {          // 1ms tick
    for (int i = 0; i < N; i++) {
        if (tasks[i].timer == 0) { tasks[i].timer = tasks[i].period; tasks[i].run = 1; }
        else tasks[i].timer--;
    }
}

int main(void) {
    Init();
    while (1)
        for (int i = 0; i < N; i++)
            if (tasks[i].run) { tasks[i].run = 0; tasks[i].task(); }
}

这其实是前后台的”加强版”,介于裸机和 RTOS 之间——能管多个不同周期的任务,但仍然透明可控、没有调度器的黑盒。

💡 一个小提醒

上面这种把任务”标记+主循环执行”的写法,慢任务(比如刷屏)拖久了会顺延后面的任务。真正死磕时序的控制环,最稳的还是直接放进定时器中断里跑(也就是第一档的”前台”),别和慢任务挤在主循环里抢时间。

第三档:RTOS(FreeRTOS / RT-Thread)

只有当任务真的多、真的需要并发的时候才上 RTOS。它的代价不小:

RTOS 内核占用 特点
FreeRTOS 4~9 KB 抢占/协作/时间片可选,支持 30+ 架构,tickless 低功耗
RT-Thread 最小 3K ROM / 1K RAM 基于优先级全抢占、256 优先级 O(1) 调度、自带 TCP/IP
µC/OS-III 6~24K 代码 + 1K 数据 任务数无限、文档全

电赛如果真要上 RTOS,主流就是 FreeRTOS 和 RT-Thread 这两个。

那到底该不该上 RTOS?

💡 出门用不用导航

去楼下小卖部(三五个任务)凭记忆走最快;跨城多点配送(多任务真并发)才值得开导航(RTOS)。但导航本身也耗油(几 KB 开销)。任务就三五个,硬上 RTOS 纯属给自己添堵。

更具体地说,宁可用周期性裸机也不上 RTOS 的理由有三条,都很实在:

  • 确定性:定时器中断直接管任务,没有 RTOS 那套调度算法和优先级反转,固定周期任务给你严格的时序保证,最坏执行时间也好分析。
  • 低开销:省掉内核那几 KB 的 ROM/RAM,省掉每个任务的控制块(TCB)。
  • 易验证:任务少(三五个)时,状态机比多线程透明得多,不会有死锁、没有同步 bug。

⚠️ 一个很常见的坑

任务才三五个还非要上 FreeRTOS,徒增几 KB 开销,引入优先级反转和同步 bug,时序还更难验证。用最小的复杂度完成需求,这是工程上的硬道理。比赛时一个莫名其妙的死锁,能让你整夜睡不着还找不到原因。

记住一句话:RTOS 是给”任务多到管不过来”准备的解药,不是给整车软件撑场面的装饰品。

三、控制环时序:5ms 一拍,按死顺序干活

调度定好了,现在聚焦最核心的那个”前台”——控制环。它跑在定时器中断里,是整车的心跳。

🧩 5ms 控制周期 = 机器人的心跳

每跳一次,完整做一遍:看路(采集)→ 判断(融合)→ 打方向、踩油门(串级 PID + 输出)→ 检查安全(保护)。心跳必须稳,每一拍的活必须在一拍之内干完,绝不能拖到下一拍。

为什么是 5ms(也就是 200Hz)?这是智能车社区多年试出来的甜点:够快,能保证控制平滑;又不至于太快,给中断里的计算留足时间。背后有个定量依据——采样定理(Nyquist):采样频率要大于被控信号变化频率的 2 倍,才不会”漏看”信号的变化。小车的动态没那么快,5ms 一拍绰绰有余。

📝 哈工大紫丁香三队的实测节拍

电磁/舵机处理 2.2ms(约 454Hz)、舵机输出 5ms、电机控制 5ms、陀螺采样 5ms,部分队伍把编码器读取放到 10ms。你看,核心控制基本都压在 5ms 这个量级。

STM32 上配一个 5ms 中断很简单:在 72MHz 主频下,预分频 PSC = 72(把计数时钟降到 1MHz,即 1µs 一格)、重装载 ARR = 5000(数 5000 格就是 5ms),就是精确的 5ms。

一拍里的标准流程

中断一进来,按这个顺序走,一步都不能乱:

电赛小车电控 · 示意图

落成代码骨架就是这样(前后台架构里的”前台”):

// PIT / TIM 5ms 中断 —— 固定周期控制环(前台)
void TIM3_IRQHandler(void) {
    if (TIM_GetITStatus(TIM3, TIM_IT_Update)) {
        TIM_ClearITPendingBit(TIM3, TIM_IT_Update);

        // 1) 采集:读编码器测速,读完立刻清零
        int16_t el = Read_Encoder(L); Clear_Encoder(L);
        int16_t er = Read_Encoder(R); Clear_Encoder(R);
        IMU_Read(&gyro, &acc);

        // 2) 融合:互补滤波出姿态角
        float ang = Complementary_Filter(acc, gyro, 0.005f);

        // 3) 串级 PID:外环速度 → 内环转向
        int16_t ps = Speed_PI(target, (el + er) / 2);   // 外环:速度
        int16_t pt = Turn_PD(line_err, gyro.z);         // 内环:转向

        // 4) 输出:差速 + 限幅后写 PWM
        Set_Motor(LIMIT(ps - pt, -7200, 7200),
                  LIMIT(ps + pt, -7200, 7200));

        // 5) 保护:视觉超时则降级,绝不盲冲
        if (++vision_timeout > VTO) Set_Motor_Safe();
    }
}

内环必须比外环快

讲串级 PID 时埋过一个伏笔,这里再钉一遍:

🧩 串级 PID 像开车

外环(大脑)决定”我要开 60 码”,内环(脚)精确控制油门把速度顶上去。大脑别管太细,脚的反应必须更快。摄像头循迹车里,方向环用位置式 PD、速度环用增量式 PI,直道加速、弯道减速。

所以控制环里的铁律是:内环周期 ≤ 外环周期,且内环先调稳。外环的输出是内环的目标,内环都晃晃悠悠,外环再准也没用。具体的调参顺序(先内后外、先 P 后 I 再 D、看波形治百病)在 PID 入门、PID 进阶和 PID 调参实战那几篇已经讲透了,这里只强调时序关系。

最容易踩的两个时序坑

🔥 坑一:把耗时操作塞进控制中断

在 5ms 控制中断里塞显示刷新、串口打印、图像处理这种耗时操作,会直接挤爆控制周期、破坏固定时序,严重时触发看门狗复位。控制环里只留:采集 + PID + 输出 + 保护。 显示、打印、通信、图像处理一律甩到主循环或单独的低频中断。

有个队伍踩过的真实坑:边处理图像边在屏幕上画点,结果屏幕一直闪烁、还拖慢了节拍。正确做法是所有显示函数集中到图像处理完成后一次性执行——显示永远别插进控制热路径。

🔥 坑二:把控制周期和 PWM 频率混为一谈

这俩根本不是一回事: – PWM 频率是 20kHz 级——为了超过人耳听觉、避免电机啸叫(电机驱动那一篇讲过)。 – 控制环频率是 200Hz 级(5ms 一拍)——这是你算 PID 的节奏。

一个是”电机驱动信号有多细腻”,一个是”你多久重新决策一次”,别搞混。

顺手补一个:传感器进 PID 前先滤波

采集到的编码器、陀螺数据有高频噪声,直接喂给 PID 的 D 项会被 Kd 放大成持续抖动。一个又便宜又好用的办法是一阶低通:

// 一阶低通:新值占 0.3,旧值占 0.7,平滑又便宜
Encoder = Encoder * 0.7f + new_value * 0.3f;

融合姿态角同理。互补滤波是工程上最常用的:

// dt = 0.005s;0.98 越大 => 越信陀螺(高通)
float Complementary_Filter(float acc_ang, float gyro_rate, float dt) {
    static float angle = 0;
    angle = 0.98f * (angle + gyro_rate * dt) + 0.02f * acc_ang;
    return angle;
}

💡 陀螺和加速度计是天生互补的一对

陀螺像短跑选手——短期很准,但越跑越偏(零漂会随时间累积);加速度计像指南针——长期方向对,但一抖一抖的(受振动干扰)。互补滤波把两人的优点加权拼起来:短期信陀螺、长期信加计,得到又稳又准的姿态。精度要求更高时上卡尔曼,原理一样是融合两者,进阶控制那一篇有更细的取舍判断。

⚠️ 滤波也有代价

滤波会引入相位滞后,系数下得太狠会拖慢响应、削弱 D 的效果,需要折中。

四、状态机:把整车行为拆成”一次只演一个角色”

控制环管的是”这一拍怎么打方向”,但整车在赛道上还有更宏观的行为切换:在起跑线待命、起步加速、跑直道、过弯道、到了十字路口要决策、到终点要精确停靠、丢线了要找回、出意外要急停……这些行为怎么管?答案是有限状态机(FSM)

🧩 状态机像剧本杀

车在任何时刻只演一个角色(待命 / 直道 / 弯道 / 十字 / 丢线 / 急停),剧情(条件)触发了才换角色。每段戏只管自己的台词,出了 bug 一眼就能定位是哪个角色演砸了,不会一锅乱炖。

这就是状态机最大的价值:降耦合、易调试。把复杂行为拆成独立状态,每个状态只管单一功能。智能车国赛代码多年都用状态机管理直道、弯道、十字、丢线、急停;自动驾驶的行为规划也用分层有限状态机(HFSM),状态名都很像,比如 FORWARD_DRIVESTOP_SIGN_WAITCROSS_INTERSECTION

整车主状态机骨架

电赛小车电控 · 示意图

注意急停(ESTOP)能从任何状态进入——这是最高优先级,下面代码里会体现(图里只画了几条代表线,实际是所有状态都能转):

typedef enum {
    ST_IDLE, ST_START, ST_STRAIGHT, ST_CURVE,
    ST_CROSS, ST_PARK, ST_LOST, ST_ESTOP
} CarState;

CarState st = ST_IDLE;

void State_Machine(void) {
    if (estop_flag) { st = ST_ESTOP; }   // 急停最高优先级,先判
    switch (st) {
        case ST_IDLE:     if (start_btn) st = ST_START;       break;
        case ST_START:    if (moving)    st = ST_STRAIGHT;    break;
        case ST_STRAIGHT: if (is_cross())      st = ST_CROSS;
                          else if (is_curve()) st = ST_CURVE;
                          else if (line_lost)  st = ST_LOST;  break;
        case ST_CURVE:    if (!is_curve())     st = ST_STRAIGHT;
                          else if (line_lost)  st = ST_LOST;  break;
        case ST_CROSS:    Cross_FillLine();
                          if (passed) st = ST_STRAIGHT;       break;
        case ST_LOST:     Recover_ByLastError();
                          if (line_found) st = ST_STRAIGHT;   break;
        case ST_PARK:     Precise_Stop();                     break;
        case ST_ESTOP:    Set_Motor(0, 0);                    break;
    }
}

📝 状态机放在哪跑?

状态机本身不是高频热路径,它做的是”宏观决策”,可以放在主循环里、或者用比控制环慢一档的节奏跑(比如配合视觉数据的更新频率)。真正每 5ms 死磕的是控制环;状态机只负责告诉控制环”现在该用什么目标速度、什么循迹策略”。

状态机最容易写错的地方

🔥 迁移条件写得耦合或有遗漏

两个高发 bug: 1. 急停不是最高优先级——藏在某个 case 里,结果某些状态下急停根本触发不了。一定要像上面那样,在 switch 之前先判 estop_flag。 2. 丢线找回没有超时退出——车进了 ST_LOST 状态,找半天找不回来又没有退出条件,就卡死在那儿原地打转。每个”等待型”状态(丢线找回、十字通过、精确停靠)都该配一个超时兜底,超时了就降级或急停。

五、保护逻辑:能拿分的车,先得是”摔不死”的车

开篇那一篇讲过电赛的拿分逻辑——有一堆”失败即 0 分”的红线(冲出赛道、超时等等)。所以整车软件里,保护逻辑不是锦上添花,而是保命。前面控制环代码里那行 if (++vision_timeout > VTO) Set_Motor_Safe(); 就是保护段的入口。这里展开两个最关键的。

视觉超时降级:信号断了别闭眼猛踩

这是新手最容易忽略、又最容易翻车的地方。注意一个事实:摄像头帧率(60~100Hz)远低于控制环(200Hz)。也就是说,控制环跑两三拍,视觉才更新一帧。如果某一拍没有新帧,或者连续丢线超过阈值了,你绝不能拿过期的、无效的图像去盲控。

🧩 进隧道信号没了

开车进隧道,导航(摄像头)突然黑屏。聪明的做法是:松油门、按记忆方向慢慢直行,等信号恢复;而不是闭着眼按原速猛踩。

具体做法:设一个看门狗 / 超时计数器,每来一帧新数据就清零,控制环每拍 +1。超过阈值就降级——保持上一个有效打角、减速或限速直行;严重时直接急停。阈值各队按自己车的特性定。视觉那一篇也讲过同款思路:通信超时(比如超过 100ms 没收到包)就降级到灰度或 IMU 兜底,两处其实是一回事。

🔥 真实教训

摄像头帧率低于控制环却不做帧同步、不做超时处理,用过期或无效图像盲控,丢线那一刻直接冲出去——这是社区里反复出现的”冲出赛道”原因之一。

丢线找回:记住”最后看见路的方向”

丢线之后怎么回到赛道?核心思路是记忆最后一次有效偏差

  • 设一个 last_error,没丢线时持续更新它;
  • 一旦判定丢线,就冻结这个值;
  • 用冻结的偏差判断车是往哪边出去的,据此朝相反方向打角,把车拽回赛道。

判丢线常用”全行扫描”:从图像底部往上扫,一直扫到最上面一行还找不到赛道边界,就判为丢线。

⚠️ 丢线时千万别让偏差归零

如果丢线瞬间偏差归零或乱跳,车就彻底失去方向感,原地乱转或直接飞出去。一定要冻结 last_error,靠它把车找回来。条件允许的话,视觉 + 电磁双传感器互补,丢线时切到电磁兜底也是好招。

六、一条贯穿始终的硬道理:机械定上限,软件定下限

最后,讲一个比任何代码都重要的认知,来自哈工大紫丁香队的血泪经验:

❗ 机械影响上限,软件影响下限

机械没做好,车速会被死死限制——高速甩尾侧滑对机械要求极高,主销后倾/内倾、前束、底盘高度(降重心)直接影响回正稳定性。软件再强,也救不了垃圾机械。

他们有两个特别值得记的故事:

🧩 故事一:别迷信单一高级算法

他们的电磁越野组一开始用纯神经网络控制,低速效果好,但高速抖动过大、甚至直接冲出赛道。事后复盘出两个根因:一是偏差范围太大,量化成整型时损失了精度;二是用来做时序预测的网络(TCN)预测值偏小,还出现了”提前预测”的现象,导致打角时机不对。最终方案是混合控制——用神经网络提取与时间无关的信息,叠加传统 PID 处理时间相关的部分,按预测角度动态切换两者权重,才稳下来。

教训:高速控制对反馈精度和动态响应极其敏感;经典 PID 常常是高速稳定的”压舱石”。进阶算法该不该上、怎么上,进阶控制那一篇有更细的判断。

🧩 故事二:调参撞天花板,先回看机械

同一支队伍发现车速一高就明显甩尾侧滑,软件怎么调都压不住,最后被迫回去改机械——调主销后倾内倾、前束、降低底盘重心,问题才解决。

教训:调参遇到天花板,别在软件里死磕,先回头看看是不是机械到极限了。

还有一条赛场铁律,宁可多说一遍:

🔥 传感器上场前必须标定、自检

线上比赛真有队伍电磁没标定就莫名其妙冲出赛道——而国赛赛道边缘是黑海绵条,是否冲出由人工判罚,赛场上还无从复现。起跑前一定留一个标定 / 自检环节,别带着一台没标定的车上场。

想找完整工程当范本?

光看片段不过瘾,下面几个开源仓库可以拿来研究整车是怎么组织的:

更全的开源清单和现场调试速查表,留到下一篇集中给。

小结:把这一篇钉进脑子

到这里,整车软件的骨架就立起来了。回顾一下这几条心法:

  • 分层:App / 中间件 / Driver / HAL 四层,上层只调下层、下层不反调,设备层禁碰芯片 API——换芯片只改驱动层。
  • 调度:电赛首选前后台裸机(主循环跑慢任务 + 定时器中断跑控制环),任务真多了再考虑 RTOS。
  • 控制环:5ms 一拍(200Hz),按”采集→融合→串级 PID→输出→保护”死顺序走,内环快于外环,ISR 只做核心、耗时操作甩主循环。
  • 状态机:把整车行为拆成独立状态,急停最高优先级、等待型状态都要超时退出。
  • 保护:视觉超时降级、丢线记忆找回、出界急停——保命优先。
  • 认知:机械定上限、软件定下限;传感器上场前必标定。

软件框架搭好了,但真正决定你能不能拿奖的,是四天三夜里在赛场上把它跑稳、调好、不翻车。下一篇现场作战手册,我们就聊聊四天三夜怎么安排、调试怎么避坑、以及哪些开源仓库值得你现在就 star。



本文由 云间辞 原创,发布于 HBZGC 的博客

转载请保留链接: https://cloudlay.cn/nuedc-car-11-architecture-fsm/

暂无评论

发送评论 编辑评论


|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇