PID控制算法:原理仿真、嵌入式实现与调参实战指南 📅 发布时间:2026/9/1 2:14:46 👁 浏览次数: 做电机调速、温控或者小车平衡这类项目时很多人第一次接触 PID 就被“调参”劝退了代码写好后一上电输出不是超调就是持续振荡Kp 调小了跟不上目标Kp 调大了又抖得厉害。网上讲 PID 的文章虽然多但大部分要么只讲公式要么只贴代码很少把“原理、离散化公式、仿真、嵌入式实现、调参排错”这条完整链路串起来。这篇文章从闭环控制的基本思想出发拆解 PID 中比例、积分、微分三项的物理含义然后给出位置式和增量式两种离散化实现并用 Python 和 Simulink 对同一组被控对象做阶跃响应仿真最后结合 STM32 和 Vofa 上位机讲清楚嵌入式里 PID 的落地方法和常见排查思路。无论你是自动化、电气、机械还是计算机方向的学生或者已经在做电机控制、机器人项目的开发者都能从里面找到可以直接用的代码和调参经验。1. PID 控制算法是什么1.1 从开环控制到闭环控制先看一个最简单的场景直流电机调速。开环控制的思路是“我给多少 PWM 占空比电机就跑多少转”。这种做法在空载、电源电压稳定时还能应付可一旦电机负载变大或者电池电压下降实际转速就会明显偏离期望值。因为系统没有“测量实际转速并纠正偏差”的环节外界扰动会直接体现在输出上这就是开环控制最大的缺陷。闭环控制则是在执行器前面加一个反馈回路传感器实时测量当前转速控制器把“目标转速”和“当前转速”的差当作误差再根据误差计算控制量。只要转速没到目标误差就不为 0控制器就会持续调整输出直到误差为 0 为止。PID 就是这种“看误差、算输出、再测量、再算”的通用控制器。1.2 PID 的控制回路组成PID 控制回路可以画成下面这样的结构目标值 r(t) --[误差计算 e(t)]--[PID 控制器]--[执行机构/被控对象]-- 实际输出 y(t) ^ | ----------- 传感器反馈 --------这里几个重要概念目标值 r(t)我们希望系统达到的值比如转速 1000 RPM。测量值 y(t)传感器采集回来的实际值。误差 e(t)目标值减去测量值即e(t) r(t) - y(t)。控制量 u(t)PID 输出的控制信号比如 PWM 占空比或者电压。PID 的英文全称是 Proportional-Integral-Differential翻译过来就是“比例、积分、微分”。它之所以在工业控制中占据统治地位最重要的原因之一是不需要知道被控对象的精确数学模型。你不需要列写电机的微分方程也不需要辨识传递函数只要给定合适的 Kp、Ki、Kd 参数就能让大多数系统稳定跟随目标值。1.3 适合哪些应用场景PID 控制的典型应用场景包括电机转速闭环控制、舵机角度控制。恒温箱、加热棒温度控制。压力、流量、液位等过程控制。小车循迹、自平衡、无人机姿态控制。电源输出电压稳定控制。可以说只要是“有传感器、有执行器、输出需要稳定到某个目标值”的系统PID 都能作为第一版控制算法快速跑起来。理解 PID 原理也是后续学习串级 PID、模糊 PID、预测控制、ADRC 等更复杂算法的基础。2. P、I、D 三项的作用拆解在写代码之前必须先理解 PID 每一项到底在做什么。很多调参翻车就是因为只知道“三个系数”不知道每个系数改变后系统响应会发生什么变化。2.1 比例项 P响应当前误差比例控制的计算很简单u_P(t) Kp * e(t)当误差越大输出就越大误差趋近 0输出也趋近 0。Kp 太小时系统响应很迟钝输出需要很久才能接近目标。Kp 增大后系统对误差更敏感达到目标的速度提高但 Kp 过大时系统容易产生超调严重的会变成等幅振荡甚至发散。以电机调速为例如果 Kp 很小转速差值带来的控制量不足以克服阻力电机可能停在某个较低转速Kp 增大后转速上升更快但如果 Kp 超过某个阈值电流和转速就可能来回震荡。2.2 积分项 I消除稳态误差如果系统只有比例控制很多对象最终会存在“稳态误差”。比如一阶惯性对象在纯比例控制下稳态输出满足y_ss (Kp / (1 Kp)) * r也就是说Kp 越大稳态误差越小但永远不等于 0。积分项的作用是把历史误差累积起来再叠加到控制量上u_I(t) Kp / Ti * ∫ e(t) dt只要系统还存在误差积分就会一直累积推动控制量继续增大直到误差为 0。积分项的存在让系统真正实现“无差调节”。但积分项也有副作用误差累积过大、执行器已经饱和后积分项仍然继续累积会导致超调甚至控制系统长期振荡这就是后面会说的“积分饱和”问题。积分时间 Ti 越大积分作用越弱Ti 越小积分作用越强。2.3 微分项 D预测误差趋势微分项反映的是误差变化的速度u_D(t) Kp * Td * de(t)/dt如果误差在快速下降说明系统正在逼近目标此时微分项输出一个负向的“刹车”作用减小超调如果误差在快速增大说明系统正在偏离目标微分项会提前输出更大的控制量来“拦截”误差。微分项相当于给控制器增加了一些“预见性”所以它能改善系统的动态响应速度减小超调。但它的缺点也很明显如果测量噪声比较大噪声会在差分运算中被放大导致控制量剧烈抖动甚至引发执行机构发热或损坏。2.4 三个参数的作用对照参数主要作用调大后的影响调小后的影响Kp响应当前误差响应变快超调变大过大会振荡响应变慢可能出现稳态误差Ki消除稳态误差稳态误差消失更快但容易超调和积分饱和误差消除变慢可能长期存在偏差Kd抑制误差变化趋势抑制超调但会放大高频噪声超调可能变大动态响应变差连续域中完整 PID 控制量常用下面这个公式表示u(t) Kp * ( e(t) 1/Ti * ∫e(t)dt Td * de(t)/dt )这里的 Ki 和 Kd其实是根据 Kp、Ti、Td 推导出来的实际编程时也会直接把三个独立系数写成 Kp、Ki、Kd。理解这两套参数之间的换算关系后面写离散化代码时才不会混乱。3. PID 的离散化实现计算机和单片机都是数字系统不能直接算连续积分和微分必须把连续 PID 公式离散化。离散化时最重要的工作是确定采样周期 Ts也就是每隔多久执行一次 PID 计算。采样周期越短系统越接近连续控制但会占用更多 CPU 资源采样周期太长则会丢失对象动态信息控制效果会明显下降。通常建议采样周期至少比对象时间常数小一个数量级比如时间常数为 1 秒的对象采样周期选 10ms 到 50ms 比较合适。3.1 位置式 PID位置式 PID 是最直观的离散化形式。把连续积分改成累加把微分改成后向差分得到u(k) Kp * e(k) Ki * Σ e(j) * Ts Kd * (e(k) - e(k-1)) / Ts如果进一步合并常数因子也可以写成u(k) Kp * e(k) Ki_sum * Σ e(j) Kd_diff * (e(k) - e(k-1))其中Ki_sum Kp * Ts / Ti Kd_diff Kp * Td / Ts第二种写法在代码中更常见因为不需要每一步都乘 Ts但要注意这里的 Ki_sum、Kd_diff 和前面的 Ki、Kd 不是一个量纲。写代码时最好在注释里标清楚。位置式 PID 的核心代码片段如下核心数据结构只包含三个状态量误差累加、上一次误差、上一次输出class PositionalPID: def __init__(self, kp, ki, kd, ts): self.kp kp self.ki ki self.kd kd self.ts ts self.integral 0.0 self.prev_error 0.0 def update(self, setpoint, measurement): error setpoint - measurement self.integral error * self.ts derivative (error - self.prev_error) / self.ts output self.kp * error self.ki * self.integral self.kd * derivative self.prev_error error return output位置式 PID 输出的就是执行机构需要到达的绝对位置比如 PWM 占空比直接给 60%。由于需要累积历史误差一旦积分项不做限制就可能出现积分饱和。3.2 增量式 PID增量式 PID 不直接计算控制量的绝对值而是计算控制量的增量Δu(k) Kp * (e(k) - e(k-1)) Ki * e(k) Kd * (e(k) - 2*e(k-1) e(k-2))然后当前输出等于上一次输出加上增量u(k) u(k-1) Δu(k)增量式的好处是不需要累计历史误差不容易出现积分饱和。执行机构本身带记忆比如步进电机、带锁存的 PID 输出直接加增量即可。手动/自动切换时冲击小因为输出在原来工作点上平滑叠加。缺点是需要保存更多历史状态而且在输出端仍然要做限幅否则误差突变时增量太大会让输出飞出去。增量式 PID 的核心逻辑如下class IncrementalPID: def __init__(self, kp, ki, kd, ts): self.kp kp self.ki ki self.kd kd self.ts ts self.prev_error 0.0 self.prev2_error 0.0 self.output 0.0 def update(self, setpoint, measurement): error setpoint - measurement delta_error error - self.prev_error delta2_error error - 2 * self.prev_error self.prev2_error delta_output self.kp * delta_error self.ki * self.ts * error self.kd * delta2_error / self.ts self.output delta_output self.prev2_error self.prev_error self.prev_error error return self.output这里delta_output中积分项写了self.ki * self.ts * error是为了和位置式保持同一个积分定义。如果你更习惯把 Ki 定义成离散累积系数那可以直接写self.ki * error但前后公式要统一。3.3 位置式与增量式的选型对照对比项位置式 PID增量式 PID输出含义控制量绝对值控制量变化量历史数据需要累积误差只需最近若干个误差积分饱和需要额外抗饱和不容易饱和手自动切换冲击较大冲击较小适用执行器输出直接对应执行机构位置的系统带记忆的执行机构如步进、积分对象实际项目中电机速度环很多会使用增量式 PID温度控制和需要直接输出 PWM 占空比的系统则常使用位置式 PID 加积分限幅。选择哪种没有绝对标准关键看执行机构能否直接接受增量式输出。4. Python 仿真闭环控制效果跳过仿真直接上实物是调 PID 最容易翻车的原因。用 Python 先做一遍闭环仿真可以观察到不同参数下系统的响应差异也能帮你在接入硬件前形成比较强的直觉。4.1 环境与对象模型下面的仿真使用 Python 3.8 及以上版本需要安装 numpy 和 matplotlibpip install numpy matplotlib仿真对象选择一阶惯性环节这是电机、温度、压力等很多实际对象的一阶近似模型传递函数为G(s) 1 / (τs 1)这里取时间常数τ 1s目标值setpoint 1.0采样周期Ts 0.01s。离散化递推公式为y[k] y[k-1] Ts * (u[k-1] - y[k-1]) / τ4.2 完整仿真代码新建文件pid_sim.py复制下面代码# 文件路径pid_sim.py import numpy as np import matplotlib.pyplot as plt class PID: 离散 PID 控制器支持输出限幅和积分限幅 def __init__(self, kp, ki, kd, dt, max_outNone, max_iNone): self.kp kp self.ki ki self.kd kd self.dt dt self.max_out max_out self.max_i max_i self.prev_error 0.0 self.integral 0.0 def reset(self): self.prev_error 0.0 self.integral 0.0 def update(self, setpoint, measurement): error setpoint - measurement self.integral error * self.dt if self.max_i is not None: self.integral np.clip(self.integral, -self.max_i, self.max_i) derivative (error - self.prev_error) / self.dt self.prev_error error output self.kp * error self.ki * self.integral self.kd * derivative if self.max_out is not None: output np.clip(output, -self.max_out, self.max_out) return output def plant_step(y, u, dt, tau): 一阶惯性对象递推dy/dt (u - y) / tau return y dt * (u - y) / tau def simulate(kp, ki, kd, t_total10.0, dt0.01, target1.0, tau1.0): n int(t_total / dt) times np.linspace(0, t_total, n) pid PID(kp, ki, kd, dt, max_out2.0, max_i1.0) y 0.0 u 0.0 ys [] us [] for t in times: u pid.update(target, y) y plant_step(y, u, dt, tau) ys.append(y) us.append(u) return times, ys, us if __name__ __main__: param_list [ {kp: 1.0, ki: 0.0, kd: 0.0}, {kp: 2.5, ki: 0.0, kd: 0.0}, {kp: 2.5, ki: 0.8, kd: 0.0}, {kp: 2.5, ki: 0.8, kd: 0.5}, ] plt.figure(figsize(10, 6)) for params in param_list: times, ys, us simulate(**params) label fKp{params[kp]}, Ki{params[ki]}, Kd{params[kd]} plt.plot(times, ys, labellabel, linewidth1.8) plt.axhline(1.0, colorgray, linestyle--, linewidth1) plt.xlabel(时间 (s)) plt.ylabel(输出 y(t)) plt.title(不同 PID 参数下的阶跃响应) plt.legend() plt.grid(alpha0.4) plt.show()4.3 运行结果与解读在命令行运行python pid_sim.py你会看到 4 条响应曲线大致表现如下参数组合响应特点Kp1.0, Ki0, Kd0响应较慢稳态输出约 0.5存在稳态误差Kp2.5, Ki0, Kd0响应变快稳态输出约 0.71误差更小但仍有残余Kp2.5, Ki0.8, Kd0输出最终到达 1.0稳态误差被积分消除Kp2.5, Ki0.8, Kd0.5上升更快超调更小曲线整体更平滑这个结果直观体现了三个系数的作用纯比例控制无论如何都无法消除稳态误差加入积分后误差累计会推动输出继续增加微分项则让系统更早“刹车”减少过冲。你可以试着修改param_list里的参数把 Kp 调到 5 以上再运行会发现曲线出现明显振荡把 Kd 调到 2 以上曲线虽然变得平缓但如果给测量值添加随机噪声控制量会剧烈抖动。这些现象和实际系统完全一致。4.4 扩展二阶对象与纯滞后一阶惯性对象是最理想的情况实际系统往往包含二阶惯性、纯滞后或非线性。你可以在仿真对象中加入纯滞后环节比如让y(t)使用delay_steps之前计算出的历史输出def plant_step_with_delay(y_hist, u_hist, dt, tau, delay_steps): u u_hist[-delay_steps] if len(u_hist) delay_steps else 0.0 y y_hist[-1] return y dt * (u - y) / tau加入纯滞后之后同一组 PID 参数往往会出现振荡你需要减小 Kp、增大积分时间来重新稳定系统。这也是为什么实际调参时一上来就用大 Kp 往往会导致系统反复震荡。5. Simulink 快速搭建仿真模型如果你习惯用 MATLAB/Simulink也可以快速搭出同样的闭环模型。Simulink 的优势是图形化、可以方便地替换成更复杂的被控对象和控制算法。5.1 模型搭建步骤打开 MATLAB在命令行输入simulink新建空白模型然后依次拖入以下模块Step阶跃信号源用来模拟目标值突变。Sum求和模块计算误差。双击后将一个输入符号改为正号另一个改为负号。PID ControllerPID 控制器模块默认使用连续时间 PID也可以改成离散时间。Transfer Fcn被控对象传递函数例如输入1/(s1)对应时间常数为 1 的一阶惯性对象。Scope示波器观察输出曲线。Mux可选把目标值和输出接到同一个 Scope便于对比。连接方式如下Step -- Sum -- PID Controller -- Transfer Fcn -- Scope ^ | ----------- Sum 反相输入端 ----------也就是把被控对象输出接回 Sum 的负输入端形成单位负反馈闭环。5.2 手动试凑与自动整定PID Controller 模块可以直接修改 Kp、Ki、Kd。你可以从纯 P 开始逐渐增加 Ki 和 Kd观察 Scope 里的曲线变化。较新版本的 Simulink 还提供了自动整定功能在 PID Controller 块上点击Tune...软件会基于当前被控对象线性化模型自动计算一组参数。自动整定的结果并不总是最佳但对快速获取初始参数非常有帮助。不同 MATLAB 版本界面差异较大如果你的版本里没有自动整定按钮手动试凑或者用 Python 仿真先确定参数也是完全可行的。5.3 什么时候用 Simulink什么时候用 Python如果对象模型复杂、需要搭多输入多输出系统或者后续要通过 Simulink 生成 C 代码Simulink 更合适。如果你只是想快速验证 PID 算法、批量测试参数、跑离线数据分析Python 更轻量也更容易和数据分析流程集成。两种方式不冲突很多项目是先用 Python 做算法验证再用 Simulink 做软件在环测试最后迁移到嵌入式代码。6. 嵌入式中的 PID 实现与调试仿真跑通后就可以把 PID 算法移植到单片机上。下面以 STM32 裸机环境为例给出位置式和增量式 PID 的 C 代码实现并介绍如何使用 Vofa 上位机实时观察 PID 曲线。6.1 STM32 上的位置式 PID先看头文件pid.h// 文件路径pid.h #ifndef PID_H #define PID_H typedef struct { float kp; float ki; float kd; float dt; float integral; float prev_error; float prev2_error; float out; float out_max; float integral_max; } PID_Handle; void PID_Init(PID_Handle *pid, float kp, float ki, float kd, float dt); float PID_Update(PID_Handle *pid, float setpoint, float measurement); float PID_Incremental(PID_Handle *pid, float setpoint, float measurement); #endif实现文件pid.c// 文件路径pid.c #include pid.h void PID_Init(PID_Handle *pid, float kp, float ki, float kd, float dt) { pid-kp kp; pid-ki ki; pid-kd kd; pid-dt dt; pid-integral 0.0f; pid-prev_error 0.0f; pid-prev2_error 0.0f; pid-out 0.0f; pid-out_max 0.0f; pid-integral_max 0.0f; } float PID_Update(PID_Handle *pid, float setpoint, float measurement) { float error setpoint - measurement; float derivative; pid-integral error * pid-dt; if (pid-integral_max 0.0f) { if (pid-integral pid-integral_max) { pid-integral pid-integral_max; } else if (pid-integral -pid-integral_max) { pid-integral -pid-integral_max; } } derivative (error - pid-prev_error) / pid-dt; pid-prev_error error; pid-out pid-kp * error pid-ki * pid-integral pid-kd * derivative; if (pid-out_max 0.0f) { if (pid-out pid-out_max) { pid-out pid-out_max; } else if (pid-out -pid-out_max) { pid-out -pid-out_max; } } return pid-out; }这里需要注意几点dt必须和实际调用周期一致。推荐在定时器中断里调用 PID 计算把定时器周期直接传给 PID 结构体。out_max和integral_max非常重要。如果不限制积分项电机启动时误差很大积分会持续累积等误差反转后控制量依然很大产生明显超调。PID 输出为负值时要根据执行机构特性处理。比如电机正反转控制负值可能对应反转如果只是单向调速需要把负值钳位到 0。6.2 增量式 PID 实现增量式 PID 实现也多占一个历史误差状态prev2_errorfloat PID_Incremental(PID_Handle *pid, float setpoint, float measurement) { float error setpoint - measurement; float delta_error error - pid-prev_error; float delta2_error error - 2.0f * pid-prev_error pid-prev2_error; float delta_out; delta_out pid-kp * delta_error pid-ki * error * pid-dt pid-kd * delta2_error / pid-dt; pid-prev2_error pid-prev_error; pid-prev_error error; pid-out delta_out; if (pid-out_max 0.0f) { if (pid-out pid-out_max) { pid-out pid-out_max; } else if (pid-out -pid-out_max) { pid-out -pid-out_max; } } return pid-out; }如果你的执行机构是步进电机这类“给增量就走一步”的执行器可以直接用delta_out作为输出如果执行机构需要绝对控制量就用pid-out。6.3 用 Vofa 上位机看曲线嵌入式调试 PID 最怕“只看到现象看不到过程”。强烈建议把目标值、测量值、PID 输出通过串口发送到 Vofa 这类上位机实时画出波形。Vofa 的 FireWater 协议支持非常简单的浮点文本格式。在 STM32 中可以用串口发送printf(%.3f,%.3f,%.3f,\r\n, setpoint, measurement, pid.out);打开 Vofa选择串口模式波特率与单片机一致协议选择 FireWater然后点击连接。这样就能在软件上看到三条实时曲线调参时效果一目了然。需要注意使用printf重定向时要确保串口波特率足够高否则刷新频率上不去。曲线震荡明显时要看测量值是否有周期性波动如果测量噪声很大优先滤波而不是盲目调 Kd。发送频率不需要太高50Hz 到 200Hz 足够观察大多数控制过程。6.4 控制频率和 PWM 输出注意嵌入式 PID 的控制频率不是越高越好。控制频率太高传感器分辨率不够会导致计算出的误差不变化微分项几乎失效控制频率太低对象状态变化又太快PID 跟不上。选择控制频率的经验做法是电机速度环200Hz 到 2kHz。温度环1Hz 到 10Hz。自平衡小车角度环500Hz 到 1kHz。此外用 PWM 控制电机时必须检查方向逻辑和占空比限幅。比如只在 0% 到 100% 范围内输出并预留死区避免在 0 附近频繁切换方向导致 MOS 管发热或电机抖动。7. 常见问题与排查思路7.1 常见问题速查表问题现象常见原因解决思路输出始终无法到达目标积分项太小或为零执行器能力不足增大 Ki检查 PWM 是否饱和系统产生持续振荡Kp 过大或纯滞后对象未减小增益减小 Kp必要时增加微分项或减小采样周期曲线高频抖动测量噪声大微分项放大噪声先做低通滤波降低 Kd或采用微分先行启动时超调很大积分饱和限制积分项最大值或者使用积分分离系统响应特别缓慢Kp 太小增大 Kp再重新观察超调控制方向反了传感器方向或执行器方向配置错误检查编码器相序和 PWM 输出方向换成反作用 PID手动切换自动瞬间跳变位置式 PID 输出没有跟踪当前执行器值初始化时把 PID 输出设为当前占空比或改用增量式7.2 如何判断系统是否接近最优调参的一个简单标准是观察阶跃响应曲线如果输出没有明显超调只是上升缓慢可以适当增大 Kp。如果输出快速上升但回不去出现等幅振荡说明 Kp 偏大积分也偏强。如果输出到达目标后波动很密、很尖优先怀疑测量噪声和 Kd。如果输出到达目标后有小幅低频波动且持续时间长可能是积分作用过强。每次只修改一个参数改完后记录曲线这是最稳妥的做法。同时修改多个参数出了问题你很难定位是哪一步导致的。7.3 被控对象响应慢怎么办对象响应慢往往不是 PID 能完全解决的。例如大惯量电机、大热容温度对象本身时间常数很大PID 参数再激进也会受物理限制。这时候可以考虑减小采样周期让控制器更频繁地修正输出。使用串级 PID比如“电流环 速度环 位置环”内环提高响应速度。对被控对象进行前馈补偿把已知的干扰提前消除减轻 PID 负担。如果系统纯滞后很大PID 控制效果会明显下降可以进一步研究 Smith 预估器、预测控制等方案。8. 最佳实践与工程建议8.1 调参顺序与方法新手调参最简单的方法就是“先 P、再 I、最后 D”把 Ki、Kd 都设为 0只保留 Kp。从小到大增大 Kp观察系统什么时候开始明显超调或振荡记下这个临界值然后退回 50% 左右作为工作 Kp。逐步增大 Ki直到稳态误差消除。注意 Ki 不要太大否则超调会增加。最后增大 Kd观察超调是否被抑制。如果曲线抖动加剧说明 Kd 过大或测量噪声太大需要降低 Kd 并对测量值做滤波。如果系统已经振荡可以用临界比例度法整定先只保留 P让系统产生等幅振荡记录临界增益 Kcr 和临界振荡周期 Tcr然后按以下经验公式配置参数控制器类型KpTiTdP0.5 * Kcr--PI0.45 * KcrTcr / 1.2-