先跟你交代一个我常看到的现象:很多新手拿到智能车套件,第一件事就是调PID,结果不是原地打转,就是直道跑着跑着冲到赛道外。问题往往不在PID本身,而在你根本没搞明白这辆车的运动方式——它靠什么转弯,转弯时每个轮子的速度该怎么分配,PID又在中间扮演什么角色。这篇文章就是冲着这两个核心问题来的:差速转弯的底层逻辑,以及PID算法怎么和差速配合,让车又能走直线又敢过弯。内容会覆盖位置式PID、增量式PID、串级控制的基本思路,附上可以直接改着用的C代码,最后把我这几年调试智能车踩过的坑一条一条列给你。
我假设你用的是常见的主控平台(STM32、TC264这类),带光电编码器或者磁编码器测速,电机是直流减速电机或者无刷电机。如果你的车是麦克纳姆轮或者舵机转向,本篇文章的差速部分不适用,但PID部分的思路完全可以平移过去。
1. 为什么你的车直道跑不直、弯道冲出去:差速转向的运动学本质
1.1 差速运动的几何关系,先把这个搞清楚
两轮差速小车,本质上是一个平面运动模型。左右轮分别以速度 vL 和 vR 转动,车轮间距(两轮中心线的距离)记为 d。当 vL = vR 时,车走直线;当 vL 不等于 vR 时,车画弧线;当 vL = -vR 时,车原地旋转。这看起来是常识,但实际控制时有一个容易被忽略的量——角速度 ω:
[ \omega = \frac{v_R - v_L}{d} ]
也就是说,速度差越大,车的旋转角速度越大;轮距 d 越小,同样的速度差会造成更明显的转向趋势。赛车一轮距做得窄,一部分原因就是为了在同样速度差下获得更高横摆角速度,但代价是稳定性下降,跑起来更"贼"。
转弯半径 R 和速度差的关系是:
[ R = \frac{d \cdot (v_L + v_R)}{2 \cdot (v_R - v_L)} ]
看到没有:分母是速度差,分子是速度总和。这意味着同样一个速度差,车速越高,实际转弯半径越大。很多新手犯的错误是只用固定的差速比去转弯,低速赛道管用,高速区段就冲出去了。因为车速一快,相同的速度差对应的转弯半径已经成倍放大,必须动态增大差速才能维持同一个过弯曲率。
1.2 直道跑不直的根源,一半在机械一半在控制
直道偏移是一个复合问题。机械层面的典型元凶包括:
- 左右电机减速箱减速比不一致,同样的PWM占空比下两轮实际转速不同;
- 轮胎磨损差异、胎压差异导致滚动半径不同;
- 底盘两侧重心分布不对称,一侧负载大、摩擦力大;
- 轮轴虚位、联轴器松动,导致动力传递不均匀。
控制层面的问题往往是:你把左右轮PWM占空比写成了固定值,比如左右都是50%,然后默认车就会跑直线。这是把"占空比"当成了"速度",而实际上占空比只是输入电压的on/off比例,电机输出转速还要受负载、电池电压、摩擦差异影响。正确的做法是把左右轮的实际转速采集回来,用PID闭环分别锁住左右轮目标转速,让系统自己补偿机械不对称性。
1.3 转向失控:当轮胎先于控制失稳
弯道冲出去还有一个物理极限问题:轮胎与地面的摩擦力是有限的。差速转弯本质上需要两个轮子产生不同的纵向速度,这个速度差最终靠胎面摩擦转化为横摆力矩。如果过弯速度太快,需要的横摆角速度超出了轮胎侧偏和纵向滑转能提供的极限,车就会推头冲出去,或者甩尾失控。
这时候PID填不回来——PID能纠正的是"系统有响应可纠正"的偏差,而轮胎已经突破了附着极限,属于非线性饱和区。所以真正让车过弯更稳的重点有两个方向:一是正确设计过弯速度包络,入弯前减速,弯中稳定速度,出弯再加速;二是让转向修正量不要超出轮速差的实际执行范围,一旦转向PID输出被限幅,就说明物理上已经到极限,继续盲目加大P只会让车更不稳定。
2. PID到底在解决什么问题:从"打方向盘"到"自己修正"的闭环逻辑
2.1 开环为什么救不了智能车
如果你只是给电机一个固定占空比,车是"开环"跑的,控制量不反馈结果。路面坡度变化、电池电压波动、轮胎附着变化,任何扰动都会造成实际速度和期望值的偏差,而开环系统对此毫无感知。
换到赛道场景更明显。摄像头采集到一条1米外的赛道参考线,你想要车沿着参考线走。开环的做法是:偏差大就往左打多一点,偏差小就打少一点。这种"查表式"思路也不是不行,但它本质上是一个极其粗糙的比例控制器,表项不够细腻,且完全没有考虑偏差的变化速率和累计趋势。于是车表现为:轻微偏差时反应迟钝,偏差一大又过冲,然后左右震荡。
PID解决的是这个问题:它把偏差控制量拆成三项——当前偏差(比例)、历史累计(积分)、变化趋势(微分),三项加权叠加后作为修正输出。你可以粗浅地把PID理解成一个会不断自我修正的驾驶员:P负责看"现在偏了多少",D负责看"偏离的速度有多快",I负责抹平"长期存在的微小偏差"。
2.2 P、I、D每一项在智能车场景中的直观意义
比例项 Kp·e(t):偏差直接映射成修正量。比如巡线时赛道横向偏差30像素,Kp=2,修正输出60,车就往弯心调整。Kp太小,车反应迟钝,弯道内侧偏出去;Kp太大,车像喝醉了酒一样左右甩头。
微分项 Kd·de/dt:抑制偏差的变化趋势。当偏差在快速增大(车正在冲向外侧),微分项输出一个反向阻力,相当于提前刹车;当偏差快速收敛(车正在回正),微分项防止它冲过头。这就是"阻尼"的角色。智能车转向环里,D项往往比P项还重要,因为赛道图像的刷新率有限,你需要在两次采集之间靠微分预测趋势。
积分项 Ki·∫e(t)dt:消除稳态误差。当你发现车在长直道上总是偏向赛道一侧几厘米却不再继续扩大的时候,说明P和D都作用了,但存在一个残余偏差——可能是视觉中心标定偏了,也可能是某个轮胎慢速打滑。积分项会慢慢累加这个偏差,输出一个额外的修正量把它顶回去。但积分项在智能车里是双刃剑:过弯时偏差持续单侧存在,积分会迅速饱和,出弯后给你一个很大的反向过冲,反而把车搞乱。很多竞赛车队干脆巡线环不用I,只用PD。
2.3 位置式PID和增量式PID,到底选哪个
位置式PID输出的是绝对控制量,直接作为执行器的目标值(比如目标转速、目标PWM)。它的缺点是积分项需要特殊处理,否则一旦误差持续存在,积分项可能把输出推到饱和区,出现"积分饱和"问题。
增量式PID输出的是控制量的增量 Δu,每次只加到上一次的输出上。它天然带有积分效果但不显式存储一个巨大的积分累加项,适合输出目标速度和增量修正的场景,更重要的是它可以做平稳切换:切换目标不会导致输出跳变。
智能车常见用法是:电机速度环用位置式PID,输出目标PWM;转向环(把赛道偏差映射成差速修正量)用增量式PID,输出角度修正增量,或者用位置式PD输出差速修正目标速度。各种方案都能跑,关键是你要知道你用的公式对应的对消关系,不要混着写还搞不清自己算的是什么。
2.4 串级PID在智能车里的位置:为什么需要两个环
串级PID的核心思想是:外层环路的输出,作为内层环路的目标值。在速度控制里,典型结构是:
- 外环:位置环/速度环。输入是目标车速,反馈是当前车速,输出是期望加速度或期望pwm补量。
- 内环:电流环/转矩环。输入是期望转矩,反馈是实际电流,输出是PWM占空比。
在更简单的智能车主板方案里,如果你用带电流闭环的驱动芯片(比如DRV8301配无刷电机),内环常是电流环;如果你的电机是带霍尔传感器的无刷电机或者有刷电机,内环则常是速度环。但并不是说必须上串级才能跑,很多做得好的车队用单级PID照样拿国一。串级的优势在于它把"响应速度差异很大的两个对象"拆开控制:电流环快,速度环慢。如果你拿一个大Kp去直接压PWM,电流会瞬间过大,电机剧烈抖动;串级之后,外环给了个"期望加速度",内环在几毫秒内微妙地调整PWM,整体柔顺得多。
3. 一份能直接上车的PID与差速转向代码实现
3.1 速度环代码:位置式PID,输出占空比
先来一个最常见的电机速度环,这个代码我建议你直接吃掉,之后所有调参都建立在它之上。注意我做了几个基础保护:
- 积分限幅,防止积分饱和;
- 输出限幅,防止PWM打满导致冲击电流过大;
- 微分项对测量值求导,不对误差求导,避免目标跳变时微分项冲向无穷。
避免"微分冲击",这个细节是很多教程不写的。当你把目标速度直接从0调到1000的时候,目标值跳变会让误差瞬间变大,如果D直接作用于误差,微分项会输出一个巨大的负向冲击,车会猛地抖一下。改成对测量值微分,目标跳变不再影响微分项,过渡就平稳很多。
typedef struct { float kp; // 比例系数 float ki; // 积分系数 float kd; // 微分系数 float integral; // 积分累计值 float last_meas; // 上一次的测量值(用于微分) float out_max; // 输出限幅 float integral_max; // 积分限幅 } SpeedPID_t; float SpeedPID_Update(SpeedPID_t *pid, float target, float measure, float dt) { float error = target - measure; // 积分项,带限幅和抗积分饱和(误差较大时停止积分) if (fabs(error) > 50.0f) { pid->integral = 0.0f; // 大偏差时清零积分,防止过冲 } else { pid->integral += error * dt; if (pid->integral > pid->integral_max) pid->integral = pid->integral_max; if (pid->integral < -pid->integral_max) pid->integral = -pid->integral_max; } // 微分对测量值求导,避免目标跳变的微分冲击 float d_meas = (measure - pid->last_meas) / dt; pid->last_meas = measure; float out = pid->kp * error + pid->ki * pid->integral - pid->kd * d_meas; if (out > pid->out_max) out = pid->out_max; if (out < -pid->out_max) out = -pid->out_max; return out; }调用方式:每个速度控制周期(比如5ms或者10ms)执行一次,读取编码器计算实际轮速,然后把输出当PWM目标值写入电机驱动。out_max不要直接设成PWM上限9999,先给个70%左右的余量,等参数稳了再放开。
3.2 转向环代码:增量式PID,输出差速修正
巡线场景里,转向环的输入是"横向偏差",输出是"差速修正量"。增量式PID在这里非常合适,因为它的输出是"在上次修正量基础上再调整多少",天然平滑,不容易出现修正量跳变。
typedef struct { float kp; float ki; float kd; float last_error; float pre_error; float out; } TurnPID_t; float TurnPID_Update(TurnPID_t *pid, float target, float measure, float dt) { float error = target - measure; // 增量式PID: delta_out = Kp(e_n - e_{n-1}) + Ki*e_n + Kd(e_n - 2e_{n-1} + e_{n-2}) float delta_out = pid->kp * (error - pid->last_error) + pid->ki * error * dt + pid->kd * (error - 2.0f*pid->last_error + pid->pre_error) / dt; pid->pre_error = pid->last_error; pid->last_error = error; pid->out += delta_out; float out_max = 25.0f; // 差速修正量上限,按速度环目标的比例设 if (pid->out > out_max) pid->out = out_max; if (pid->out < -out_max) pid->out = -out_max; return pid->out; }为什么out_max设成25?这是一个经验值,取决于你的基础速度。假设你直道目标速度是100(编码器采样值),差速修正量不能超过25%,否则内侧轮可能反转或者完全失去抓地。上限要根据实际跑车情况放宽或者收紧,原则是:转向修正量永远大于0,让内侧轮正向滚动而不是刹死。
3.3 差速转弯的最终速度分配
有了速度环和转向环,差速转弯的操作就只剩下一个映射公式:
float base_speed = 100.0f; // 当前目标车速 float delta_v = TurnPID_Update(&turn_pid, 0.0f, line_offset, dt); // 转向修正量 float left_target = base_speed - delta_v; float right_target = base_speed + delta_v; // 限幅处理 if (left_target < 10.0f) left_target = 10.0f; // 防止内侧轮完全停止 if (right_target < 10.0f) right_target = 10.0f; float left_pwm = SpeedPID_Update(&left_speed_pid, left_target, left_measure, dt); float right_pwm = SpeedPID_Update(&right_speed_pid, right_target, right_measure, dt); // 写入驱动 motor_set_duty(LEFT_MOTOR, left_pwm); motor_set_duty(RIGHT_MOTOR, right_pwm);这里要注意的是,两个速度环分别闭环,而不是直接把PWM占空比做差速。如果只在PWM层面做差速,等于回到了开环控制:你根本不知道左右轮实际转速是多少。正确的架构是:差速修正的目标速度,然后每个轮子自己闭环跟上各自的目标速度。这样即使左轮摩擦力大一点、减速比差一点,PID也会自动补偿。
3.4 运动学前馈:让差速转向更平滑的一个加分项
如果你的车要高速过弯,纯PID负反馈已经不够了。PID是被动的——它等偏差出现才修正。高速过弯你应该主动预判:根据当前车速和期望曲率,提前计算一个理论差速量,作为前馈叠加在PID输出上。
float expected_v = base_speed; // 当前车速 float steer_angle = line_offset; // 你从图像/电磁线里解算出的横向偏差 float feed_forward = k_ff * expected_v * steer_angle; // 车速越快, 同样的偏差需要更大的差速量 float delta_v = TurnPID_Update(&turn_pid, 0.0f, line_offset, dt) + feed_forward;之所以把前馈加在PID输出外面,是因为它不需要等偏差累积,根据车速和路径曲率直接就能算出来。这相当于你的车"预习"了一下弯道。很多竞赛车队说"我的车入弯不推头",秘诀就在这个前馈项里。
4. 调参没有捷径但有章法:从曲线到参数整定的实操流程
4.1 调参前必须做的三件事
调PID之前,先确认以下三项,否则你拿着再好的PID也白搭:
- 测速可靠。编码器的读数必须稳定。转速环调试前,把车架起来,给一个开环的固定PWM,观察左右轮的编码器读数是否平滑。如果读数剧烈跳动,先处理编码器接线、滤波、电气噪声,再谈PID。
- 方向一致。确认电机正转时编码器计数方向、PWM方向、速度符号三者一致。方向弄反,PID会表现为正反馈——偏差越大修正越大,车直接疯掉。
- 极限标定。测出在设定电池电压下PWM和转速的近似关系,给输出限幅定一个合理范围。
4.2 vofa上位机的接线与配置
智能车圈现在调PID几乎人手一个vofa+(或者VOFA+、Vofa JustFloat)。这个工具读float数据的原理其实很简单:主控按IEEE 754的4字节浮点格式,把数据打包成一帧,帧尾加特定校验字节(JustFloat协议是0x00 0x00 0x80 0x7F),通过串口发到上位机,上位机把这些字节流解析成float并画成曲线。
代码实现:使用串口打印几个数据到上位机可视化,核心就一行发送逻辑,如下:
#include <string.h> // JustFloat 协议帧尾 const uint8_t tail[4] = {0x00, 0x00, 0x80, 0x7F}; void SendFloat(float a, float b, float c, float d) { uint8_t buf[16]; memcpy(buf, &a, 4); memcpy(buf + 4, &b, 4); memcpy(buf + 8, &c, 4); memcpy(buf + 12, &d, 4); // 发送 buf, 直接字节流发送, 上位机选 JustFloat 协议 uart_send_bytes(buf, 16); uart_send_bytes(tail, 4); }使用方式:在速度环更新函数里,把左右轮的目标速度和实际速度传进来:
SendFloat(left_target, left_measure, right_target, right_measure);打开vofa+,端口选你主控对应的串口,波特率和你代码里配置的一致,协议选JustFloat,画面上就应该能看到四条曲线。你会直观看到左右轮是否跟得上目标、超调有多少、稳定时间多长。
调速度环时不需要每次烧录,直接在串口助手/上位机上观察即可。这也是为什么我建议你用串口方式调参,而不是每次改个Kp就重新下载固件——耗时又容易让你失去手感。
4.3 速度环调参的顺序和判断标准
速度环的调参我一般走这个流程:
- 先只调P。Ki和Kd设为0,Kp从非常小(比如0.5)开始,每隔几次更新加一点,直到转速出现小幅震荡。判断标准是编码器曲线出现高频抖动或者嗡嗡声。记下这个"震荡临界Kp",然后回调30~40%。
- 再加D。速度环的D主要是抑制超调和震荡,把Kd从0.05开始慢慢加,观察阶跃响应(突然给一个目标速度)的超调量是否下降。加太多会出现高频抖动,甚至电机啸叫。
- 最后加I。一般只有在"稳态转速和目标始终存在几转误差"的时候才需要加I。Ki从0.01开始慢慢加,注意观察过冲,积分限幅要设小一点,比如输出范围的10%~20%。
记住一个原则:Kp是最硬的响应,决定系统反应快慢;Kd是最柔的阻尼,决定系统是否安静地稳定下来;Ki是最慢的修为,用来抹平小误差。调参顺序永远是先P后D再I,不要上来就改三个参数,否则调死都理不清是哪个造成的问题。
4.4 转向环调参和速度环的关系
转向环的调参必须在速度环稳定之后进行。速度环都没调好,左右轮目标转速根本追不上,转向环看到的"偏差"里混着大量速度环的性能损失,所有调参结论都是错的。
转向环本身遵循类似流程:
- 固定车速(由速度环锁住目标);
- 把转向PID的I设为0,D为0,P从小开始加;
- 观察赛道曲线:横向偏差曲线在弯道处应该平滑爬升然后回落,而不是陡冲出去;
- 当弯道偏差曲线出现高频振荡时,P到极限了,回调20%;
- 加D,你会看到偏差曲线变得圆润,车的过弯姿态也稳定很多。
调试时我强烈建议你在代码里用按键或者蓝牙模块实现"在线调参",不用每次重烧固件。哪怕只是把Kp、Ki、Kd存储到Flash里,开机通过串口助手修改,都能大幅缩短调参周期。
4.5 常见调参怪现象对应的根因
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| 直道上车左右摆头 | 转向环P过大,D不足 | 降低Kp,增加Kd |
| 弯道入弯晚,兜大圈 | 转向环响应过慢,P太小或速度环跟不上 | 加大Kp,检查速度环稳定性 |
| 稳定后始终偏一侧 | 视觉中心偏置/轮胎差异 | 先做机械标定,再加小Ki |
| 高速过弯推头冲出 | 物理极限,PID无法救 | 入弯减速策略,或加前馈差速 |
| 电机嗡嗡响,温度升高 | 速度环震荡,或PWM频率不合适 | 回调Kp/Kd,确认PWM频率 |
| 原地打转 | 左右电机方向反了 | 检查接线和符号方向 |
5. 路试踩坑清单:新手最容易翻车的六个隐藏细节
5.1 PWM死区比你想象的更大
直流减速电机的启动电压并不为零。在PWM占空比很低时,电机可能根本不转;占空比达到某个阈值后才开始缓慢转动。这就是死区。如果速度环PID输出落在死区范围,你会看到"目标转速80,实际转速0,然后PID疯狂输出",直到输出跨过死区,电机猛地一冲。解决方法是死区补偿:在PID输出上叠加一个基础占空比。这个补偿值要开机自检时测定,不同电压下不一样。
5.2 编码器安装位置决定你的控制对象
编码器装在电机轴上测的是电机转速,装在轮轴上测的是轮子转速。电机到轮子之间如果有减速箱,电机轴转速和轮端转速的比例是固定的,但如果你只有一个编码器,你测的其实是"电机有没有转",而不是"轮子真正转不转"。轮胎打滑时,电机转得飞快,但车没动,测速读数确实很高——这时速度环以为速度够了,实际车身速度很低。如果你要求过弯稳定性,最好能加一个IMU感知真实车速,或者在运算里融入加速度信息。否则,你至少要清楚编码器的测量位置带来的局限性。
5.3 积分限幅设多大,直接决定出弯质量
转向环的积分限幅尤其要小心。前面我给的代码里,转向环输出上限是25,对应的是差速修正量。如果你把积分限幅设成和输出限幅一样大,过弯时积分项很快跑到25,出弯后你要靠P和D花很长时间把它拉回来——车的偏移感会持续一整秒。正确做法是把积分限幅设在输出限幅的10%~20%,让积分充当"微调者",而不是"主力"。
5.4 PWM频率和电机驱动芯片的配合
PWM频率选低了,电机会明显发出可听见的噪声,扭矩纹波大;选太高了,驱动芯片和电机感抗的搭配可能导致电流跟不上,实际驱动力下降。我常用15kHz到20kHz的PWM频率做直流有刷电机,基本听不到电机噪声,扭矩输出也稳定。如果你用的是双路H桥,还要注意PWM和方向引脚的模式:一些驱动器是"PWM-IN1, IN2"模式,另一些是"IN1, IN2-PWM"模式,方向搞反会导致一路正转一路反转。
5.5 转向限幅不能只是"封顶"
转向环的out_max不只是防过冲的手段,它还直接决定了车的极限过弯能力。限幅太小,车过不了的弯就过不了;限幅太大,内侧轮反转、动力损耗、车子横向打滑。推荐的调整思路是:先给一个保守值,比如基础速度的20%,然后通过实测弯道最小半径算出理论所需最大速度差,按这个值扩到1.2倍即可。实测过程中,如果转向环输出频繁撞到限幅值,说明你的目标速度已经超出了这辆车的物理弯道能力,不是调参数能解决的。
5.6 电池电压下降:为什么下午跑的参数早上不能用了
锂电池在整个放电过程中电压从高到低持续变化,同样的PWM占空比在满电时能跑1000转速,低电量时可能只有800。这意味着你早上调的PID参数,下午电量低的时候拿过来用,输出就不够了。很多车队最终的解决方案是:在代码里实时检测电池电压,用它做前馈补偿,比如:
float batt_comp = batt_nominal / batt_voltage; // 电池电压越低, 补偿系数越大 float pwm_out = SpeedPID_Update(...) * batt_comp;严格来说这是一个电压前馈,不是PID的一部分。但它对智能车是刚需——否则你八成会遇到"上午调好的弯,下午死活过不去"的郁闷场景。
6. 再分享几个能让车跑得更顺的操作细节
最后一节不讲算法了,讲几个我实际比赛中反复用到的"软技巧"。
第一,转向环的执行频率不要和速度环一样快。速度环是底层执行器,动作越快越好,典型1~5ms一个周期;转向环吃的是图像或传感器的偏差,数据本身更新没那么快,如果也一样跑5ms,同一帧偏差会被重复计算很多次,微分项会被虚假放大。我通常把转向环放在图像数据到达后的下一次控制周期里执行,频率和图像帧率匹配(20ms左右),效果反而比高频执行更稳。
第二,过弯前预减速度,弯中稳速,出弯再加速。这个听起来像废话,但真正实现的代码逻辑需要你做一个简单状态机:检测到赛道曲率超过阈值就进入减速状态,把目标车速拉低一个区间;曲率回落再恢复目标车速。不要让转向环在高速状态下独自扛下所有,它扛不住物理极限。
第三,PID参数不是一次性调完就固定了。我自己的习惯是:每个速度区间跑一圈,记录每组参数下车的表现,最后用枚举表格在运行中查表切换。低速弯一个参数,高速弯换一套成本参数,这样既能保证低速灵敏,又能保证高速稳定。车跑到赛道不同区域时,根据当前速度和曲率查表调用不同PID参数,比一套参数打天下效果好得多。
第四,调参时间要留足给"手感"。我见过很多新手花几个晚上调参数,越调越烦躁,最后把车调成一碰就炸。我的建议是,一次调参session控制在2小时以内,每次只动一个参数,改完必须记录下来(写在笔记本或者Excel上),下一次改之前先看上一次是什么状态。PID调参本质上是在噪声和响应之间找平衡,手感也是重要输入。
这些细节看着小,最后都反映在赛道上那零点几秒的差距里。先把差速和PID配合的逻辑理顺,把每一条代码跑通,把参数一个个调稳定,你的车自然就"稳"了。