资讯动态

STM32 PID算法程序实战:位置式与增量式选型、调参与避坑指南

发布时间:2026/10/17 15:49:46 来源:尧图企业网站定制
简介面向STM32嵌入式开发者的PID控制实现资源聚焦光照强度调节场景涵盖STM32F1库函数版本下的完整工程与配套资料。压缩包共727个文件约51.75MB包含大量.c/.h源文件、Keil工程配置uvprojx/uvoptx、编译生成的hex/axf文件以及两个PDF文档和一张SchDoc原理图兼顾源码学习、程序烧录与硬件连接参考。目前已有4356人学习下载。资料从PID比例-积分-微分原理出发结合ADC采样、定时器中断和PWM输出演示如何构建闭环光照控制通过阅读开发指南和工程代码可掌握参数整定流程及常见调试方法。适合正在学习STM32或需要快速落地PID算法的电子爱好者与工程师。1. STM32上的PID算法程序抄来的代码为什么一跑就乱某开发者从例程包里翻出一份STM32的PID算法程序接上直流电机后发现转速在目标值附近来回冲声音刺耳电流明显偏大。排查到最后问题不在公式而在他把PID算出来的浮点结果直接强转成PWM比较值量纲没统一占空比跳变了十几倍。STM32上的PID算法程序难点从来不是背公式而是把公式离散化后放进中断里稳定执行再和外设正确对接。这篇文章围绕“023_STM32PID算法程序”这类工程里最常见的东西来展开位置式和增量式怎么选、采样周期和PWM怎么配、参数怎么从零调出来、常见坑在哪。适合用STM32做电机调速、温控、智能车、云台稳定这类闭环控制的开发者阅读。2. 程序骨架怎么选位置式与增量式PID的差异和最小代码2.1 位置式PID离散公式与第一版C代码位置式PID的输出是“绝对量”公式是 u(k)Kp*e(k)Ki*Σe(n)Kd*(e(k)-e(k-1))。离散化以后积分变成误差累加微分变成误差差分采样周期Δt被隐含在系数里。这个公式写起来容易真正的问题在积分累加不加限制时系统会陷入饱和所以第一版代码就要把积分限幅放进去。/* pid.h */ #ifndef __PID_H #define __PID_H typedef struct { float kp; /* 比例系数 */ float ki; /* 积分系数 */ float kd; /* 微分系数 */ float target; /* 目标值 */ float feedback; /* 当前反馈值 */ float error; /* 当前误差 */ float last_error; /* 上一次误差 */ float integral; /* 积分累加值 */ float output; /* 本次输出 */ float out_min; /* 输出下限 */ float out_max; /* 输出上限 */ float integral_max; /* 积分限幅 */ } pid_handle_t; void PID_Init(pid_handle_t *pid, float kp, float ki, float kd, float out_min, float out_max); float PID_Calc(pid_handle_t *pid, float target, float feedback); #endif/* pid.c */ #include pid.h void PID_Init(pid_handle_t *pid, float kp, float ki, float kd, float out_min, float out_max) { pid-kp kp; pid-ki ki; pid-kd kd; pid-target 0.0f; pid-feedback 0.0f; pid-error 0.0f; pid-last_error 0.0f; pid-integral 0.0f; pid-output 0.0f; pid-out_min out_min; pid-out_max out_max; /* 积分限幅先取输出范围的20%后面按实际效果调 */ pid-integral_max (out_max - out_min) * 0.2f; } float PID_Calc(pid_handle_t *pid, float target, float feedback) { float p_out, i_out, d_out; pid-target target; pid-feedback feedback; pid-error target - feedback; /* 积分累加前先判断避免积分饱和后退出很慢 */ if ((pid-integral pid-error) pid-integral_max) { pid-integral pid-integral_max; } else if ((pid-integral pid-error) -pid-integral_max) { pid-integral -pid-integral_max; } else { pid-integral pid-error; } p_out pid-kp * pid-error; i_out pid-ki * pid-integral; d_out pid-kd * (pid-error - pid-last_error); pid-last_error pid-error; pid-output p_out i_out d_out; /* 输出限幅必须与执行机构允许的范围一致 */ if (pid-output pid-out_max) { pid-output pid-out_max; } if (pid-output pid-out_min) { pid-output pid-out_min; } return pid-output; }这段代码有几个地方值得说清楚。积分限幅用的是输出范围的比例因为积分项的累积值乘以ki之后会直接出现在输出里如果积分累加值无限增长输出会一直顶在限幅上等误差反向后还要花很长时间把积分“卸”掉。误差方向定义为target减去feedback这意味着输出增大对应反馈偏低适用于正向控制场景。这段代码里看不到Δt不是漏了而是假设每次调用PID_Calc的间隔是固定的参数Ki和Kd已经隐式包含了采样周期的影响所以采样周期一旦改变参数必须重新整定。注意这里没有处理微分滤波反馈信号毛刺大时d_out会很难看这个问题放在后面避坑章节详细说。2.2 增量式PID输出变化量的优势与第二份代码增量式PID的输出不再是绝对量而是本次相对上次输出的变化量Δu。公式是 Δu(k)Kp*(e(k)-e(k-1))Ki*e(k)Kd*(e(k)-2e(k-1)e(k-2))。它不累加误差绝对值天然没有积分饱和问题适合执行机构本身有保持能力的场合比如步进电机的位置环、伺服的速度环、阀门开度控制。代价是需要额外维护上上次误差e(k-2)。/* 增量式PID在位置式结构体基础上增加prev_error字段 */ typedef struct { float kp; float ki; float kd; float target; float feedback; float error; float last_error; float prev_error; /* 上上次误差增量式专用 */ float output; /* 累积输出 */ float out_min; float out_max; } pid_inc_handle_t; float PID_IncCalc(pid_inc_handle_t *pid, float target, float feedback) { float delta_u, p_delta, i_delta, d_delta; pid-target target; pid-feedback feedback; pid-error target - feedback; /* 三个增量分别计算再合成Δu */ p_delta pid-kp * (pid-error - pid-last_error); i_delta pid-ki * pid-error; d_delta pid-kd * (pid-error - 2.0f * pid-last_error pid-prev_error); delta_u p_delta i_delta d_delta; /* 时间序列整体往后挪一格 */ pid-prev_error pid-last_error; pid-last_error pid-error; /* 输出增量累积变成绝对输出 */ pid-output delta_u; if (pid-output pid-out_max) { pid-output pid-out_max; } if (pid-output pid-out_min) { pid-output pid-out_min; } return pid-output; }增量式代码里的时间序列更新顺序要特别注意prev_error必须在last_error更新前赋值否则会把当前误差覆盖到两个历史位置。输出限幅仍然需要因为累积后的output可能越界但限幅会带来另一个隐患被截掉的增量丢了下次从受限的output继续累积这段误差会在系统反向时以迟滞的形式表现出来。处理办法是把限幅后的实际输出再存回output并在下一次计算Δu时用实际输出做基准而不是用理论累积值。对比项位置式PID增量式PID输出含义绝对控制量控制量变化值积分饱和风险有必须限幅无但输出累积需限幅历史状态需要1个历史误差需要2个历史误差适用执行机构占空比、加热功率等直接控制步进、伺服、阀门等带保持机构故障影响输出直接跳变冲击大输出渐进冲击小2.3 两种写法的边界什么时候必须换一种选择位置式还是增量式核心看执行机构。常见的直流电机PWM调速用位置式很直接PID算出来的值就是比较寄存器里的占空比一个变量搞定。但如果控制对象是步进电机的位置环输出是“还能走多少步”你不可能把1000步一次性塞给电机必须给增量让执行机构自己保持当前位置这种场合增量式是唯一合理的选择。反过来纯增量式用在加热器上会有问题加热器只接受0到100%的功率没有“保持当前功率”的能力PID的增量输出解释不了“功率变化量”这种概念。所以我的习惯是先问执行机构能不能保持输出能保持就选增量式不能就选位置式。3. 让PID跑在STM32外设上定时器采样、PWM输出和一套最小实现3.1 控制周期怎么定为什么PID调用必须放进定时器中断控制周期决定采样频率也和执行机构的惯性有关。直流电机速度环通常用1ms左右温控系统用100ms到500ms电流环这类快速环需要更短周期。确定了周期之后最可靠的做法是把“采样→计算→输出”整套动作放进定时器中断回调里保证每次调用的间隔严格固定。如果放在主循环里调用按键扫描、串口解析、显示刷新都会让迭代周期忽长忽短参数整定就失去基准这是新手最容易犯的错误。/* 用TIM2做1ms周期中断在其中完成一次完整的PID控制循环 */ static pid_handle_t g_motor_pid; static uint16_t g_adc_raw; static uint16_t g_pwm_duty; static float g_target_rpm; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { /* 1. 读取反馈ADC原始值先转成物理量 */ g_adc_raw Read_ADC_Value(); float current_rpm ADC_To_RPM(g_adc_raw); /* 2. 调用PID计算得到本次输出 */ float pid_out PID_Calc(g_motor_pid, g_target_rpm, current_rpm); /* 3. 把PID输出换算成PWM比较值并更新寄存器 */ g_pwm_duty (uint16_t)RPM_To_PWM(pid_out); TIM_SetCompare1(htim3, g_pwm_duty); } }这里有三件事需要说明。反馈读取必须在PID计算之前因为这一拍用的反馈是上一控制周期之后的最新状态。ADC原始值不建议直接当反馈量送进PID值得先转成物理量否则Kp的数值没有直观意义整定时全靠盲试。最后一步PWM更新用的是各自的换算函数如果PID的输出范围是0到1000而PWM比较值是0到999中间差一个单位对系统影响不大但量纲必须固定不要今天用百分比明天用原始值换了量纲之前整定的参数全部作废。3.2 从ADC反馈到PWM输出量纲统一是整定的前提量纲统一是PID程序里最容易被忽视的环节。ADC原始值、物理量、PWM占空比是三套不同单位换算函数要写清楚并且写完之后固定下来。下面这段代码展示了我常用的映射写法整套控制在同一个量纲体系内。/* ADC 12位参考电压3.3V量程0~4095对应转速0~3000rpm */ float ADC_To_RPM(uint16_t raw) { return (float)raw * 3000.0f / 4095.0f; } /* PID输出范围是0~1000对应PWM比较值0~999 */ uint16_t RPM_To_PWM(float rpm) { if (rpm 0.0f) rpm 0.0f; if (rpm 3000.0f) rpm 3000.0f; return (uint16_t)(rpm * 999.0f / 3000.0f); }映射函数看起来简单但决定了PID调节的灵敏度。如果反馈值用ADC原始值转速3000rpm对应4095Kp1时一个单位的误差变化产生的输出变化是1个PWM档位几乎看不出反应。如果改用物理量同样误差是几百的数量级Kp1时输出直接顶到上限两种量纲下Kp的整定值差几十倍。所以我在工程里总是把反馈统一到物理量输出统一到执行机构量程中间换算函数单独放一个文件参数整定之前先确认两个映射函数的线性关系是符合预期的。3.3 正反作用与双极性输出PID结果如何变成电机的转动方向PID算出来的输出是带符号的而PWM只能输出正占空比。单方向设备如加热器、风扇负值直接钳到0就行。双向设备如直流电机需要根据符号决定方向再取绝对值作为占空比。这里是正反作用最容易出错的地方误差方向定义成target减feedback之后输出增大意味着反馈要追上去这只适用于“正向执行机构”。/* 双极性PID输出转成方向占空比假设100对应最大占空比 */ void Motor_SetOutput(float pid_out) { if (pid_out 0.0f) { if (pid_out 100.0f) pid_out 100.0f; Motor_SetDirection(FORWARD); } else { if (pid_out -100.0f) pid_out -100.0f; pid_out -pid_out; /* 取绝对值 */ Motor_SetDirection(REVERSE); } TIM_SetCompare1(htim3, (uint16_t)(pid_out * 999.0f / 100.0f)); }方向切换的瞬间要留意电机从正转直接切反转时电流冲击很大速度环里最好加一个零速死区输出绝对值小于某个阈值时先停PWM再切换方向。PID输出为0附近会有频繁的方向抖动尤其微分项存在时PWM会发出嘶嘶声这种情况的死区值一般在最大输出的1%到3%之间按实际测试调整不能拍脑袋定一个很大的值导致低速段失控。4. 参数整定从零到能用的实操路径4.1 整定前先把三个东西固定控制周期、输出范围、反馈量纲参数整定第一步不是调Kp而是检查三个前提。控制周期必须固定位置式代码隐含了周期影响周期一变参数全变。输出范围要和执行机构严格一致电机PWM是0到999输出限幅就不能设成0到1000。反馈量要统一到物理量不能拿ADC原始值直接算。这三个前提用一张纸写清楚贴在调试电脑旁边比记住任何整定公式都管用。项目推荐取值注意事项控制周期电机速度环1ms温控100~500ms周期一旦确定整定期间不要改动PID输出范围与执行机构量程一致加热器0~100双向电机-100~100反馈量纲物理量如rpm、℃避免ADC原始值直接进入PID积分限幅输出范围的20%左右太小响应慢太大容易超调4.2 临界比例法找到Ku和Tu查表得到初始参数临界比例法是Ziegler-Nichols整定的标准做法。操作步骤固定先设ki和kd为0只保留Kp从0开始逐步加大观察系统在目标值附近的响应。Kp较小时系统稳定到达稳态前没有震荡继续加大到某个值输出出现等幅震荡不再衰减记录此时的Kp为Ku震荡周期为Tu。注意这个震荡周期不是目标值的来回穿越次数而是一次完整来回的时间单位是秒。/* 临界比例法整定时的参数设置 */ void PID_Tuning_Start(pid_handle_t *pid) { pid-kp 0.0f; /* 从0开始加 */ pid-ki 0.0f; /* 关掉积分 */ pid-kd 0.0f; /* 关掉微分 */ }得到Ku和Tu后按表计算初始参数。这里有一个离散化换算的关键点表中Ki的公式对应的是连续时间积分时间Ti落到代码里的Ki要乘以采样周期Δt。很多开发者拿着表直接填数字结果发现Ki大得离谱系统疯狂震荡原因就在这个换算上。控制形式KpKiKdP0.5*Ku00PI0.45*Ku1.2*Kp/Tu 再乘Δt0PID0.6*Ku2.0*Kp/Tu 再乘ΔtKp*Tu/8换算关系要理解到位代码里的Ki作用于“每隔Δt累加一次的误差”表里的Ki作用于“每秒积分累积量”单位不同。比如Tu0.1秒控制周期Δt0.001秒表算出的Ki直接乘0.01才是代码里要填的数值。没有这一步查表公式再准也白搭。4.3 手动微调的固定顺序先比例、再积分、最后微分临界比例法给出的只是能跑的初始值离好用还有距离。我的微调顺序固定三步先只开P看超调量和响应速度再加I消除静差最后加D压超调。Kp从初始值开始每次增加20%左右让系统在阶跃响应下出现一点点超调再退回一点找到“响应够快且不震荡”的点。此时如果没有静差不需要加I如果有静差Kp已经接近上限再盲目加大Kp会导致震荡正确做法是保持Kp不动逐步加Ki每次让静差缩小一半直到稳态误差进入可接受范围。最后加Kd用来压超调Kd从0开始增加每加一档观察一次超调变化加到超调消失就停再往上加就会变成噪声放大。这里有一个容易忽略的细节I加多了会带来超调和震荡很多人以为超调只能靠D解决其实是I太大。我的判断方法是如果超调出现在目标值附近且伴随周期较长的“喘息”先降Ki而不是加Kd。加Kd只能压掉高频振荡长周期起伏往往是积分项的问题。4.4 在线调参的一种做法把参数暴露到串口边跑边改反复烧录调试浪费时间我一贯的做法是把PID参数做成全局结构体通过串口命令在线修改。这样可以在系统运行状态下观察每一步调整带来的变化节省大量时间。下面是一个最小实现。/* 全局PID实例 */ pid_handle_t g_pid; /* 简单解析形如 kp250 的行命令在主循环中调用 */ void PID_Cmd_Parse(char *line) { float val; if (sscanf(line, kp%f, val) 1) { g_pid.kp val; } else if (sscanf(line, ki%f, val) 1) { g_pid.ki val; } else if (sscanf(line, kd%f, val) 1) { g_pid.kd val; } else if (sscanf(line, target%f, val) 1) { g_pid.target val; } }命令解析放到主循环不要放串口中断里。串口中断只负责把字节积累成一整行攒满回车后再置标志位主循环看到标志位再执行解析。原因很简单解析函数里的sscanf是软件浮点库在中断里跑会拉长中断响应时间影响控制周期。另外串口打印PID的中间变量时打印频率不要超过10Hz打印本身很耗时高频打印对控制周期是致命干扰。5. STM32上跑PID的避坑清单积分饱和、微分噪声与正反作用的排查记录5.1 积分饱和输出顶到限幅后系统为什么迟迟不回调现象电机启动瞬间误差很大PID输出立刻顶到占空比上限转速冲到目标值后还在继续冲过了好一会才慢慢回落超调严重。原因是误差大时积分项持续累加哪怕输出已经饱和积分还在往深处走等到误差反向积分要先把累计的部分“卸”掉系统才有反应。解决积分限幅以及输出饱和时冻结积分。第二章代码里的integral_max就是一种限幅更彻底的做法是判断输出到达上下限时积分项只减不加。/* 输出饱和时冻结积分累加 */ float pid_out p_out i_out d_out; if (pid_out pid-out_max || pid_out pid-out_min) { /* 输出饱和时积分只允许往“帮助退出饱和”的方向走 */ if ((pid-error 0 pid_out pid-out_max) || (pid-error 0 pid_out pid-out_min)) { pid-integral pid-error; } }5.2 微分项放大噪声反馈毛刺让输出抖个不停现象电机平稳运行时PWM占空比肉眼可见地闪烁偶尔听到高频噪音目标值附近转速波动比预期大。原因是微分项对误差差分敏感ADC反馈里任何一个毛刺都会在差分后被放大几十倍。解决先对反馈做软件滤波再计算微分。最简单有效的是在微分项上做一阶低通。/* 在PID结构体中增加微分滤波状态 */ float filtered_error; /* 滤波后的误差 */ float last_filtered_error; /* 上一次滤波后的误差 */ /* 微分计算时使用滤波后的误差 */ #define DERIVATIVE_ALPHA 0.2f /* 滤波系数越小越平滑 */ void PID_UpdateFilteredError(pid_handle_t *pid) { pid-filtered_error DERIVATIVE_ALPHA * pid-error (1.0f - DERIVATIVE_ALPHA) * pid-last_filtered_error; pid-last_filtered_error pid-filtered_error; }滤波器系数0.2的含义是20%的新误差加上80%的历史误差平滑效果明显缺点是对真实快速变化也有延迟。系数不是越小越好太小会让微分项失去提前抑制超调的作用一般从0.2起步超调压不住就调大噪声压不住就调小。切忌在反馈源头上盲目加多重滤波滤波器相位延迟会让控制环变“钝”反而更难整定。5.3 正反作用设反温度越控越偏的根本原因现象目标温度设定后系统输出一路加功率温度却越来越低或者电机启动就往目标的反方向冲。原因是误差方向定义和执行机构作用方向不匹配。代码里errortarget-feedback输出增大时反馈应该增大但如果执行机构是制冷器输出增大反而让温度下降误差越拉越大。解决把误差方向换过来或者把输出取反二选一。两个都改等于没改。/* 制冷场景的正反作用处理 */ float error feedback - target; /* 输出增大时反馈应下降 */ /* 或者保持原误差定义输出取反 */ pid_out -pid_out;排查这个问题的顺序是先画一条控制回路图标注“输出增大→执行机构→物理量→反馈”的符号关系再从反馈端逐级验证。用固定占空比直接驱动执行机构看反馈是增大还是减小这是最直接的确认方法比对着代码猜要快得多。5.4 采样周期被高优先级任务打乱控制节拍忽快忽慢现象系统其他任务一忙PWM输出就出现规律性抖动Kp怎么调都振。原因是定时器中断被更高优先级的任务抢占PID计算不是每隔固定时间执行一次位置式代码隐含的Δt假设被破坏参数在“假周期”下全部失真。解决把PID所在的定时器中断优先级设到最高档并且保证中断服务函数里不做耗时的浮点打印、长循环、内存拷贝。如果中断里确实要做浮点运算检查芯片是否带FPU不带FPU的型号浮点运算可能耗时几十微秒对1ms控制周期的压力不小。5.5 增量式限幅后的迟滞被截掉的增量去哪了现象增量式PID用在步进电机上每次反向都有明显的迟滞位置总是追过头。原因是outputdelta_u后做了限幅被截掉的部分没有从累积量里扣除下次计算在受限的output上继续累加等于把丢失的部分悄悄补了回来。解决限幅后重新同步理论输出。/* 正确的限幅同步写法 */ pid-output delta_u; if (pid-output pid-out_max) { pid-output pid-out_max; } else if (pid-output pid-out_min) { pid-output pid-out_min; } /* 注意这里不需要再做额外补偿。 限幅后delta_u的“丢失”其实是执行机构本身达不到 真正要改的是把限幅范围调小到执行机构物理极限内。 */实际上限幅范围只要和执行机构物理极限一致迟滞问题是不会出现的。真正会出问题的是限幅范围比执行机构范围宽比如电机PWM只能到999限幅却设了1000那么输出到达999之后系统还想再涨每次累加都超出物理极限表现就是反向时有不可控的迟滞。解决方式是限幅值和PWM定时器的自动重载值严格对应不要留余量。6. 把PID程序从能用到好用三个进阶技巧6.1 分段参数大误差快追小误差稳走单一参数组在误差很大和误差很小两种工况下很难兼顾。误差大时希望Kp大一点快速逼近目标误差小时又希望参数保守一点防止超调。分段参数的做法是按误差绝对值切换参数组切换点加滞回防止参数在分界点来回抖。/* 分段PID参数切换滞回区间避免抖动 */ #define ERR_HIGH 500.0f /* 大于此值用快参数 */ #define ERR_LOW 300.0f /* 小于此值用稳参数 */ float PID_Calc_Segment(pid_handle_t *pid, float target, float feedback) { float err target - feedback; if (fabsf(err) ERR_HIGH) { pid-kp 30.0f; pid-ki 0.01f; pid-kd 0.5f; } else if (fabsf(err) ERR_LOW) { pid-kp 12.0f; pid-ki 0.05f; pid-kd 1.2f; } /* 在ERR_LOW和ERR_HIGH之间保持上一组参数这就是滞回 */ return PID_Calc(pid, target, feedback); }6.2 三种抗积分饱和做法选一个就够积分分离用误差阈值控制积分是否参与误差大时积分清零误差小到阈值内再恢复。积分限幅是直接限制累积值的范围。输出饱和冻结是当输出到达限幅时积分项只允许往退出饱和的方向变化。三种做法取一即可叠加使用反而让参数整定更复杂。我的选择习惯是位置式用积分限幅启动频繁的用积分分离执行机构惯性大的用输出饱和冻结。6.3 从阶跃响应判断参数是否合格最后验证参数是否合格不要看曲线好不好看直接看四个指标上升时间、超调量、稳态误差、调节时间。上升时间太长说明Kp不够超调超过10%说明Kp或Ki偏大稳态误差超过2%说明Ki不足来回震荡超过3个周期才收敛说明整体参数都需调整。合格的口径我一般卡“超调10%稳态误差2%调节时间3倍上升时间”。调好一组参数后把整定表连同日期、负载情况、控制周期一起存到工程注释头里。我自己的习惯是每次改完参数就在代码顶部写一条记录半年后回看还能知道当时为什么这么调。有了这种记录参数才真正可复现这套PID程序才算真正落地。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑