简介:面向电磁循迹与恩智浦智能车竞赛的PID调节工程包,适合具备STM32基础、正在攻克电感采集与舵机/电机闭环调试的参赛者与自动化学习者。压缩包共192个文件、约5.76MB,以47个C头文件、43个C源文件为核心,辅以Keil工程配置、编译输出与少量说明文档,覆盖定时器、Flash、LCD、MPU6050姿态解算及DMP驱动等模块,可用于对照理解工程组织与底层驱动写法。已有4694人学习。读者可从中获取电磁循迹小车的完整代码框架、传感器数据处理思路和PID参数整定参考,结合赛道条件反复试验比例、积分、微分项,观察舵机角度与后轮转速的响应,进而排查振荡、迟滞与静态误差,以及从硬件连接到软件实现的综合调试路径。工程保留中间产物与批处理脚本,也便于复现编译环境和定位常见构建问题。
1. 电磁循迹小车 PID 调节:为什么同一套代码换条赛道就跑歪
车在直道上跑得笔直,一进弯就贴着内道切出去,把 Kp 调大一点又变成左右画龙——这大概是电磁循迹小车调试里最常见的一幕。这份资源给的就是一套能直接编译的电磁循迹小车 PID 调节工程:电感采集、归一化、差比和算偏差、舵机 PD 打角、电机增量式 PID 差速,参数集中在一张表里,改完烧进去就能试。适合正在做循迹小车、卡在“参数到底怎么调”这一步的开发者,也适合想把控制环拆开单独验证的人。下面按信号链、控制层、工程结构、排查、二次开发的顺序拆,照着能先把车跑起来,再动手改。
2. 信号链拆解:从电感谐振到一路可信的偏差量
整车的控制效果上限,其实在信号链这一层就定死了。后面 PID 调得再花,只要喂进去的偏差量本身带跳变、带零点漂移,舵机就会跟着抖。所以先把电感怎么选、怎么采、怎么变成 -1 到 +1 的偏差讲清楚,再谈控制。
2.1 电感布局与谐振采集电路
赛道导线里通的是交变电流,周围产生同频交变磁场。车头的工字电感与并联电容组成 LC 并联谐振回路,谐振频率对准赛道电流频率时回路阻抗最大、感应电压最高,相当于把微弱磁场信号从环境噪声里“挑”出来。常见做法是电感取 10mH 量级,电容按 f = 1/(2π√(LC)) 反推,几百 nF 到 1nF 之间,对准 20kHz 左右。
检波有两条路:一条是二极管检波加 RC 滤波,直接得到直流量,电路简单、响应快,但二极管压降会吃掉小信号,温度一变零点就飘;另一条是让 ADC 在谐振周期内多次采样取峰值或有效值,或者用运放做精密整流,动态范围更好,代价是要多花一点 CPU 和采样时序设计。工程里用的是后者,采样窗口由定时器触发,和 PWM 开关沿错开。
电感布局决定了偏差量的“形状”。常见的是四电感方案:左右两个横置电感对横向偏移敏感,左右两个竖直电感对弯道方向敏感;也有加中间电感的五电感方案,用来判断是否正压在导线上。
| 通道 | 安装位置 | 主要作用 | 典型权重 |
|---|---|---|---|
| IND_L | 车头左侧横置 | 反映左偏量 | 1.0 |
| IND_R | 车头右侧横置 | 反映右偏量 | 1.0 |
| IND_VL | 车头左侧竖直 | 判弯道方向、曲率 | 0.3 左右 |
| IND_VR | 车头右侧竖直 | 判弯道方向、曲率 | 0.3 左右 |
注意:竖直电感的权重不要一上来就给大。它的信号在直道上差值很小,一旦权重过高,直道会被噪声推着走,表现出来就是“直道画龙”。
2.2 归一化与差比和:把电压变成可信的偏差
原始 ADC 值不能直接用。电感离地高度变一点、电池电压掉一点、赛道电流源换一个,绝对值全变,昨天调好的 Kp 今天就不对了。所以第一步是归一化,把每个通道压到 0.0 到 1.0 之间,靠的是实测标定出来的底噪和峰值两个基准。
#define IND_NUM 4 #define IND_MIN 30 /* 电感远离导线时的底噪,实测标定 */ #define IND_MAX 3800 /* 电感正对导线时的峰值,实测标定 */ static uint16_t adc_raw[IND_NUM]; static float ind_norm[IND_NUM]; /* 把原始 ADC 值压到 0.0 ~ 1.0,消掉高度、供电、赛道电流带来的整体缩放 */ void Ind_Normalize(void) { for (int i = 0; i < IND_NUM; i++) { int32_t v = (int32_t)adc_raw[i] - IND_MIN; if (v < 0) v = 0; if (v > (IND_MAX - IND_MIN)) v = IND_MAX - IND_MIN; ind_norm[i] = (float)v / (float)(IND_MAX - IND_MIN); } } /* 差比和:左右横电感之差 ÷ 左右之和,输出 -1.0 ~ +1.0 的偏差 */ float Ind_GetError(void) { static float last_err = 0.0f; float l = ind_norm[IND_L]; float r = ind_norm[IND_R]; float sum = l + r; if (sum < 0.05f) { /* 两边都没信号,基本等于脱线 */ return last_err; /* 保持上一次偏差,交给状态机处理 */ } last_err = (l - r) / sum; return last_err; }IND_MIN和IND_MAX是拿着车在赛道上实测出来的:把车放在导线正上方读一次峰值,把车横移到完全没信号的位置读一次底噪,多测几个点取平均。sum < 0.05f这个阈值是脱线判据,太小会误判,太大会漏判,一般取满量程的 3% 到 5%。
差比和的好处在于分母是 l+r,天然带归一化:车正好压在中间时 l≈r,输出接近 0;信号整体变弱时分子分母同步缩小,输出基本不变。这比直接用 (l-r) 稳得多。
竖直电感要单独融合进来,用来提前感知弯道:
/* 横竖融合:横电感管偏移,竖电感管弯道方向 */ float Ind_GetErrorFused(void) { float err_h = Ind_GetError(); float err_v = (ind_norm[IND_VL] - ind_norm[IND_VR]) / (ind_norm[IND_VL] + ind_norm[IND_VR] + 0.05f); return 0.8f * err_h + 0.2f * err_v; /* 权重按赛道曲率调 */ }分母加 0.05f 是防止两边都接近零时除出个巨大的数,这是血泪经验——不加这一项,直道上偶尔会蹦出一个 ±5 的偏差,舵机当场打满。
进控制环之前还要过一道滤波。电感信号里混着 PWM 开关噪声和电源纹波,不滤直接送进微分项,Kd 会被放大成抖动:
/* 一阶低通:alpha 越小越平滑,但相位滞后越大 */ static float Lpf(float in, float *state, float alpha) { *state += alpha * (in - *state); return *state; }采样率 100Hz 时 alpha 取 0.2 到 0.4 比较合适。alpha 再小,弯道入口的相位滞后会让车“慢半拍”,表现出来就是进弯晚、出弯冲。
3. 控制层拆解:舵机 PD 和电机 PID 到底各管什么
很多人调车调不明白,是把舵机环和速度环当成一个东西在调。实际上舵机是位置伺服,给什么角度转什么角度,属于快环;电机是速度伺服,有加减速惯性,属于慢环。两个环的周期、参数意义、限幅方式都不一样,混在一起调必然翻车。
3.1 舵机 PD:输出的是打角,不是速度
舵机接收的是 PWM 占空比对应的目标角度,所以用位置式 PD,不需要积分项——加了积分反而会在长直道上累积,出弯时猛地一甩。微分项用偏差的差分近似,因为控制周期固定,差分本身就等价于变化率,不用再除 dt。
typedef struct { float kp; float kd; float kff; /* 陀螺仪前馈系数 */ float out_limit; /* 打角限幅,单位是 PWM 计数值 */ float last_err; float out; } SteerPD; float Steer_Calc(SteerPD *s, float err, float gyro_z) { float diff = err - s->last_err; /* 周期固定,差分即变化率 */ s->last_err = err; float out = s->kp * err + s->kd * diff + s->kff * gyro_z; /* 前馈:车头已经开始转,提前打角 */ if (out > s->out_limit) out = s->out_limit; if (out < -s->out_limit) out = -s->out_limit; s->out = out; return out; }参数怎么起手:kp先给 60 到 120(对应舵机 PWM 计数值的量级,具体看你的舵机行程标定),把kd和kff都置零,推车走直道看回正力度;回正够了但进弯过冲,再往上加kd,一次加 1 到 2;等kd加到开始出现高频抖动,就往回退两档,这是kd的实际上限。kff最后加,配合陀螺仪 Z 轴角速度,能让车在弯道入口提前打角,观感上就是“车知道要转弯了”。
还有两个容易被忽略的细节:舵机中值必须单独标定,机械安装误差会让中值偏离 1500,写死会一直往一边跑;打角限幅要留余量,打满 100% 的舵机在弯道里没有调整空间,遇到突发偏差只能硬顶。
3.2 电机增量式 PID:抗饱和、切换无扰
电机这边用的是增量式 PID,输出的是 PWM 增量而不是绝对占空比:
typedef struct { float kp, ki, kd; float e1, e2; /* e(k-1), e(k-2) */ float out; float out_max; } IncPID; float IncPID_Calc(IncPID *p, float target, float feedback) { float e0 = target - feedback; /* 增量式标准形式:Δu = Kp·(e0-e1) + Ki·e0 + Kd·(e0-2e1+e2) */ float du = p->kp * (e0 - p->e1) + p->ki * e0 + p->kd * (e0 - 2.0f * p->e1 + p->e2); p->e2 = p->e1; p->e1 = e0; p->out += du; if (p->out > p->out_max) p->out = p->out_max; if (p->out < -p->out_max) p->out = -p->out_max; return p->out; }选增量式而不是位置式,主要图两点:一是切换目标速度时没有积分累积要清理,从 0 直接跳到 2m/s 也不会猛冲;二是限幅后out被钳住,下一拍du是在钳位值上继续累加,天然带一点抗饱和的意味。但要注意,out被限幅后必须回写进结构体,不写回去,积分项照样在内部涨,这就是典型的积分饱和。
速度环周期一般取 20ms,比舵机环慢四倍。太快的速度环会因为编码器分辨率不够而抖,太慢又跟不上弯道的速度规划。
差速分配放在速度环外面:
/* 差速:基础速度 ± 转向修正,转向修正由打角或偏差给出 */ void Motor_Output(void) { float base = g_param.base_speed; float steer = steer.out / steer.out_limit; /* 归一化到 -1 ~ 1 */ float diff = g_param.diff_gain * steer; /* 弯道降速:偏差越大,基础速度压得越低 */ base *= (1.0f - g_param.curve_slow * fabsf(steer)); float target_l = base - diff; float target_r = base + diff; int16_t pwm_l = (int16_t)IncPID_Calc(&pid_l, target_l, Encoder_GetL()); int16_t pwm_r = (int16_t)IncPID_Calc(&pid_r, target_r, Encoder_GetR()); Motor_SetPwm(pwm_l, pwm_r); }diff_gain决定转向的“力度”,curve_slow决定弯道降速比例,一般 0.2 到 0.4。这两个参数是替代“调 Kp 调不出来”的关键——很多车进弯冲出去,不是舵机 Kp 不够,而是弯道速度没降下来。
3.3 两个环的时序关系
舵机环每 5ms 跑一次,速度环每 20ms 跑一次,都在同一个定时器节拍里按计数分频执行。舵机环的快,是为了让打角能跟上偏差的瞬时变化;速度环的慢,是因为电机本身有惯性,跑太快只会让 PWM 抖成噪声。
提示:控制周期一旦定下来就不要随手改。Kd 项的物理意义依赖固定周期,改了周期不改参数,等于把调好的微分作用整体缩放了一遍,之前的整定全部作废。
4. 工程结构与调度:定时器节拍、状态机和参数表
代码能跑和代码能改,是两件事。这套工程在结构上把“采样—计算—输出”和“参数—状态—存储”分开了,好处是调参不用翻控制代码,改控制不用动参数表。
4.1 目录结构与模块职责
EMC_TrackCar/ ├── Core/ 芯片启动、时钟配置、中断向量表 ├── Drivers/ 外设驱动:ADC、PWM、编码器、UART、陀螺仪 ├── App/ │ ├── sensor/ 电感采样、归一化、滤波、偏差融合 │ ├── control/ 舵机 PD、电机增量式 PID │ ├── chassis/ 差速分配、速度规划 │ └── app_task.c 定时节拍调度、状态机 ├── Param/ │ └── param.c 参数结构体、默认值、Flash 读写 └── Tools/ 串口波形脚本、参数下发工具| 模块 | 关键文件 | 职责 | 调用周期 |
|---|---|---|---|
| sensor | ind.c/filter.c | ADC 取值、归一化、差比和 | 5ms |
| control | steer_pd.c/speed_pid.c | 打角计算、速度闭环 | 5ms / 20ms |
| chassis | diff.c | 差速分配、弯道降速 | 20ms |
| app_task | app_task.c | 节拍调度、状态机 | 5ms |
| param | param.c | 参数集中管理、掉电保存 | 按需 |
分层的边界要守住:control层不允许直接读 ADC 寄存器,也不允许直接写 PWM 寄存器,全部走sensor和Drivers的接口。这样换一块板子,只需要改Drivers,控制逻辑一行不动。
4.2 定时节拍与状态机
节拍由定时器更新中断驱动,中断里只做计数和置标志,实际计算放在主循环或者低优先级任务里,避免中断里跑浮点运算拖长响应:
/* 5ms 节拍,定时器更新中断 */ void TIM3_IRQHandler(void) { if (TIM3->SR & TIM_SR_UIF) { TIM3->SR = ~TIM_SR_UIF; g_tick_5ms = 1; /* 只置标志,不算浮点 */ } } /* 主循环里消费节拍 */ void App_Loop(void) { static uint8_t cnt = 0; if (!g_tick_5ms) return; g_tick_5ms = 0; Ind_Sample(); /* 采样 + 归一化 + 滤波 */ float err = Ind_GetErrorFused(); Steer_Calc(&steer, err, Gyro_GetZ()); /* 舵机环,每拍都跑 */ App_StateMachine(); if (++cnt >= 4) { /* 20ms 一次速度环 */ cnt = 0; Speed_Loop(); } Motor_Output(); }状态机负责处理脱线、发车、停车这些边界情况,别小看它——脱线保护写不好,车冲出赛道后会满速撞墙:
typedef enum { ST_IDLE, ST_RUN, ST_LOST, ST_STOP } CarState; void App_StateMachine(void) { static uint16_t lost_ms = 0; switch (state) { case ST_RUN: if (Ind_AllDark()) { state = ST_LOST; lost_ms = 0; } break; case ST_LOST: /* 用最后一次有效偏差方向低速找线,超时则停车 */ lost_ms += 5; if (Ind_AnyValid()) { state = ST_RUN; lost_ms = 0; } else if (lost_ms > 300) { state = ST_STOP; } break; case ST_STOP: Motor_SetPwm(0, 0); break; default: break; } }lost_ms > 300就是 1.5 秒的找线窗口,太短会在颠簸处误停车,太长会让车带着错误方向跑很远。
5. 常见问题排查:实车上那几种“看着像玄学”的抖动
调车调到怀疑人生的时候,问题多半不在 PID 参数上,而在信号或者时序。下面这几条是反复踩过的,按“现象 → 原因 → 解决”记下来,比盲调参数省时间。
5.1 信号层的问题
现象一:静止时电感值纹丝不动,电机一转就乱跳。原因是电机 PWM 的开关噪声通过电源和地串进了 ADC,采样时刻正好落在 MOS 开关沿上。解决方式是把 ADC 触发源改成定时器触发,采样窗口对齐到 PWM 中心,避开开关沿;电感地和功率地单点连接,别在板上随便铺;输入端加一级 RC,截止频率压在几 kHz。
现象二:换一块场地,直道偏差就不为零了。原因是归一化基准IND_MIN/IND_MAX是写死的,而赛道电流、环境磁场、电感离地高度都变了。解决办法是上电自标定:发车前让车停在赛道中央静止 200ms,采一段峰值,再把车横移到无信号位置采一段底噪,把两个值写进 RAM 的基准变量,归一化函数读这两个变量而不是宏。
现象三:直道好好的,远处弯道提前“看到”,车提前切内道。原因是电感前瞻太远,或者竖直电感权重给大了。竖直电感在直道上的差值本来就小,权重一大,噪声被放大成假弯道。做法是把err_v的权重从 0.2 往下压到 0.1,或者干脆只在横电感差值超过阈值时才启用竖直分量。
5.2 控制层的问题
现象四:直道上小幅高频抖,肉眼看着像车在“颤”。原因通常是kd太大,或者微分项之前没滤波。偏差本身带 10 到 30 个 ADC 码的噪声,差分之后放大十倍,舵机就在中值附近来回打。解法是先对diff单独做一阶低通,再乘kd;或者把kd直接砍半,用前馈补回响应。
现象五:进弯冲出去,出弯甩尾。这个最容易被误判成舵机 Kp 不够。实际原因多半是两个:一是弯道没有降速,速度环目标速度恒定,舵机打到限幅也拉不回来;二是没有前馈,纯反馈永远慢半拍。做法是加曲率前馈或者陀螺仪前馈,同时用curve_slow把弯道基础速度压下来 20% 到 40%。
注意:排查顺序永远是“先看信号,再看时序,最后看参数”。偏差量本身在跳,参数怎么调都是白搭,这也是我一般建议在工程里留一路串口波形的原因——把
err、steer.out、pwm三条曲线打出来,问题基本一眼就能看出来。
6. 二次开发:把整定流程做成可复现的验证方法
参数调通一次不难,难的是换车、换赛道、换电池之后还能快速复现。我的做法是把参数从代码里彻底拔出来,再把整定过程变成一套固定的验证动作,而不是靠手感。
先做参数表外置。所有可调量收进一个结构体,带 magic 校验,上电从 Flash 读,读失败就落回默认值,串口可以单条下发:
typedef struct { uint32_t magic; /* 0x5A5A1234,用来判断是否已初始化 */ float steer_kp, steer_kd, steer_kff; float speed_kp, speed_ki, speed_kd; float base_speed, diff_gain, curve_slow; } CarParam; CarParam g_param; void Param_Load(void) { /* 从 Flash 最后一页读结构体,magic 不匹配就用编译期默认值 */ if (*(uint32_t *)PARAM_ADDR != PARAM_MAGIC) { Param_SetDefault(); Param_Save(); } else { memcpy(&g_param, (void *)PARAM_ADDR, sizeof(g_param)); } } /* 串口命令:SET steer_kp 85,解析后直接改 g_param */ void Param_OnCommand(const char *name, float val) { if (!strcmp(name, "steer_kp")) g_param.steer_kp = val; if (!strcmp(name, "steer_kd")) g_param.steer_kd = val; if (!strcmp(name, "base_speed"))g_param.base_speed = val; /* 其余字段同理,注意加范围钳位 */ }验证方法我固定成三组动作,每次改完参数都跑一遍:静态阶跃——把车架空,手动把偏差从 0 跳到 0.5,看打角响应有没有超调、稳定时间多少;方波跟踪——用串口以 1Hz 下发 ±0.3 的偏差方波,看打角能不能跟上、有没有滞后;实车直线——直道全速跑,看偏差曲线是不是贴着零线走。三组都过了再上弯道,能省掉大量“改一个参数跑一圈”的时间。
一个具体技巧:kd的整定不要看车,看波形。把err和steer.out同时打出来,理想的曲线是打角略超前于偏差变化;如果打角曲线和偏差曲线完全同相,说明kd还没起作用;如果打角在偏差反向之后还在往原方向走,就是kd过头了。
从那以后我每次改控制参数,都强制先跑一遍静态阶跃再上车,哪怕只改了一个数。省下来的不是调试时间,是舵机和齿轮的钱。希望帮到你。
本文还有配套的精品资源,点击获取