简介:这是一份基于51单片机与Proteus仿真实现的PID电机控制系统设计资源,面向单片机初学者、嵌入式课程设计及电子竞赛备赛者。系统以51单片机为核心,包含电机驱动电路,支持按键设定PID参数、启停与方向控制,并通过LED指示运行状态、LCD1602屏实时显示相关信息,可直观理解PID闭环调速流程。压缩包内含34个文件,以C语言源码、Proteus仿真工程、Keil工程文件及Hex烧录文件为主,另有仿真截图和说明文档,整体仅1.09MB,便于快速下载与二次修改。目前已有149人学习,资源附带了仿真图与程序源码,可帮助读者对照设计框图进行实物验证或毕设/课设拓展。
1. 基于单片机Proteus仿真的PID电机控制系统,先解决“闭不上环”的问题
把“基于单片机proteus仿真的PID电机控制系统设计(仿真图、源代码)”这个题目拆开看,真正难的不是画图,而是让仿真里的电机转速闭环。PWM输出再漂亮,没有测速反馈,PID就是空转。我一般会选 AT89C52 这类51单片机打底:Proteus 里元件齐全,仿真跑得稳,源代码调试也直观,应付课程设计和入门级工程验证都够用。下面按仿真图、测速通道、增量式PID代码、示波器验证这条线,讲一套能落地的最小系统。需要说明一下,项目标题里的 protues 是 Proteus 的常见误拼,检索时两个拼写都能用,下文统一按正式名称写。
2. 从开环PWM到闭环PID:被控量、采样周期和控制量的关系
2.1 为什么开环PWM不能当“电机控制”
直流电机的开环调速,最直接的做法就是改变 PWM 占空比:占空比高,平均电压高,转速快;占空比低,平均电压低,转速慢。但 PWM 只解决了“给定电压”的问题,没有解决“实际转速到底是多少”的问题。电机的端电压、负载转矩、绕组电阻和工作温度一旦变化,同样的占空比得到的转速会明显漂移。
闭环控制要盯住的是“被控量”。在被控对象是电机转速时,被控量就是每分钟转过的圈数,也就是转速。控制器先测量实时转速,再与目标转速求差,根据误差计算下一步的控制量。误差为正说明转速偏低,需要把占空比加大;误差为负说明转速偏高,需要把占空比收小。这个循环必须在一个固定周期内完成,周期太短单片机算不过来,周期太长转速会出现明显振荡。
2.2 增量式PID算法是把占空比“叠”上去,位置式PID是把占空比“设”出来
PID 的输出拿过来怎么变成 PWM 占空比,常见有两种写法。位置式PID直接计算当前应该输出的占空比:
// 位置式PID伪代码 output = kp * err + ki * err_sum + kd * (err - err_last);位置式的err_sum是所有历史误差的累加,只要前期一直有稳态误差,这个累加项就会一直增大,程序里一旦忘记做积分限幅,占空比很容易被顶到上限,这种现象叫积分饱和。
增量式PID输出的不是“当前占空比”,而是“当前占空比应该在上一次基础上增加多少”:
// 增量式PID伪代码,控制周期内执行一次 int inc = kp * (err - err_1) + ki * err + kd * (err - 2 * err_1 + err_2); pid_out += inc; if (pid_out > 999) pid_out = 999; if (pid_out < 0) pid_out = 0;每次只算增量,不需要维护一个无限大的历史误差累加项。即使某次计算出的增量特别大,在输出累加后也能被限幅拦下来。这个特性对电机这类惯性负载很重要,所以我更倾向在51上用增量式。式中的err_2是上上次误差,err_1是上次误差,注意在每次计算结束后要把err_1赋给err_2,把本次err赋给err_1。若后续做位置环和速度环组成的双闭环,可以把两个PID按顺序串起来,也就是级联PID控制,外环输出作为内环目标值;先把这里的单级跑通再往上扩展。
如果你希望把工业上常见的PB、Ti、Td三参数转成上面的kp、ki、kd,可以用kp = 100 / PB、ki = kp * T / Ti、kd = kp * Td / T换算,其中T是控制周期,单位要和Ti、Td保持一致。Proteus调参时直接用kp / ki / kd更直观。
2.3 Proteus仿真里PID算法需要盯住三个量:测速值、占空比、目标值
仿真运行起来之后,不能只盯着“转速显示是否稳定”这一个结果。我一般会在代码里保留两个实时变量:current_speed和pid_out,分别对应当前测到的转速和PID计算后的占空比。再用 Proteus 的虚拟示波器看两路信号:一路是单片机输出到L298N使能端的PWM波形,另一路是编码器反馈脉冲。把这三个量放在一起看,才能判断到底是PID收敛了,还是被限幅强行压住了。
| 观察对象 | 从哪里看 | 正常表现 | 异常迹象 |
|---|---|---|---|
| 测速值 | 代码变量或串口打印 | 稳定在目标值附近 | 数值跳变快,需要检查计数器溢出 |
| 占空比 | 示波器PWM引脚波形 | 占空比随转速偏差平缓变化 | 频繁撞到0或999,说明限幅生效过猛 |
| 目标值 | 程序内给定 | 与测速值偏差越来越小 | 与测速值差距长期不变,可能积分太弱 |
如果示波器上PWM波形一直在满占空比和0占空比之间来回跳,先不要继续调参数,去检查测速通道的计数是否稳定。测速值本身抖动太大,PID会把这些抖动当成真实转速误差来响应,最终表现就是仿真发散。这个顺序在Proteus里尤其重要,因为仿真不存在传感器噪声,但接线和元件配置错误造成的信号毛刺反而更容易误导调试。
3. 搭Proteus仿真图:元件清单与测速通道接线
3.1 元件选取:单片机、驱动、测速电机和观察设备
Proteus 仿真图不需要把所有电源引脚都画出来,但负载回路一定要标清楚。下面这张表是我搭这套系统时会放到画布上的最小元件集:
| 元件 | Proteus 搜索关键词 | 数量 | 在本系统中的作用 |
|---|---|---|---|
| AT89C52 | AT89C52 | 1 | 运行PID算法并产生PWM |
| L298N | L298 或 L298D | 1 | H桥电机驱动,隔离单片机与电机 |
| 直流电机 | MOTOR-ENCODER 或 DC MOTOR | 1 | 被控对象,带测速输出更好 |
| 12MHz晶振 | CRYSTAL | 1 | 单片机时钟源 |
| 30pF电容 | CAP | 2 | 晶振负载电容 |
| 10k电阻 | RES | 1 | 复位上拉 |
| 虚拟示波器 | OSCILLOSCOPE | 1 | 观察PWM占空比和测速脉冲 |
| 虚拟终端 | VIRTUAL TERMINAL | 1 | 打印转速、目标值和PID输出 |
元件放好后,双击 AT89C52,在 Program File 一栏加载 Keil 编译生成的.hex文件,Crystal Frequency 填 12MHz。这里最容易出的问题是晶振频率配置和代码里定时器初值不一致,导致PWM实际频率和预期差很多。
3.2 接线顺序:PWM、方向引脚和编码器反馈
单片机引脚到 L298N 的接线,我习惯固定为:先接方向,再接使能,最后接测速反馈。P2.0接 L298N 的IN1,P2.1接IN2,P2.3接ENA。正转时把IN1置1、IN2置0,反转则反过来。ENA接收单片机送来的PWM信号,PWM占空比控制L298N输出到电机的平均电压。
L298N 输出端OUT1、OUT2接直流电机两端。电机如果自带编码器输出,编码器的A相信号接单片机P3.2/INT0。编码器输出的信号通常是5V方波,51的P3.2可直接识别。若使用的是不带测速输出的电机模型,可以在Proteus里另外找一个信号发生器,把它输出频率设成与转速成比例的信号去模拟编码器脉冲,这样PID算法本身仍然可以验证,只是转速换算不再来自真实电机模型。
3.3 编码器测速通道:INT0计数与转速换算
测速的基本思路是:在固定时间窗口内统计外部脉冲个数,再根据编码器每转输出的脉冲数换算成转速。代码里把P3.2配置成下降沿触发的外部中断,每来一个脉冲pulse_cnt加1,控制周期结束时读取并清零:
#define PULSE_PER_REV 20 // 编码器每转输出20个脉冲 volatile unsigned int pulse_cnt = 0; void Ext0_ISR(void) interrupt 0 { pulse_cnt++; // 外部中断计数,只做累加 } // 每100ms控制周期结束前,在主循环中做一次换算 unsigned int get_rpm(void) { unsigned int rpm; rpm = (unsigned long)pulse_cnt * 600UL / PULSE_PER_REV; pulse_cnt = 0; // 计数清零,进入下一个测量窗口 return rpm; }换算公式里出现常数600,是因为测量窗口是0.1秒:脉冲频率除以每转脉冲数得到转每秒,再乘60得到转每分钟,即(pulse_cnt / 0.1) / 20 * 60 = pulse_cnt * 600 / 20。如果改了测量窗口,这个换算系数要同步调整。这里的中断服务函数只做累加,不在中断里做乘除运算,是因为外部中断可能随时到来,耗时的运算会延长关中断时间,造成脉冲丢失。
4. 编写PID电机控制系统源代码:51的定时器PWM与增量式PID
4.1 动态定时器PWM:用定时器0做出可调占空比
51单片机标准内核没有硬件PWM模块,网上常见的“延时翻转IO”办法在主循环里会阻塞测速和PID计算。我一般用动态定时器方案:一个定时器中断交替设置引脚高低电平,中断服务函数根据当前电平状态和占空比动态重装定时器初值。
#include <REG52.H> sbit PWM_PIN = P2^3; sbit MOTOR_IN1 = P2^0; sbit MOTOR_IN2 = P2^1; #define PWM_PERIOD_CNT 1000 // PWM周期对应的定时器计数个数,1kHz volatile unsigned int duty_cycle = 500; // 初始占空比 50% static bit pwm_state = 0; static unsigned int tcnt = 0; void Timer0_ISR(void) interrupt 1 { if (!pwm_state) { PWM_PIN = 1; // 低电平结束,翻转为高 pwm_state = 1; tcnt = duty_cycle; // 高电平持续时间 } else { PWM_PIN = 0; // 高电平结束,翻转为低 pwm_state = 0; tcnt = PWM_PERIOD_CNT - duty_cycle; // 低电平持续时间 } if (tcnt == 0) tcnt = 1; // 避免初值异常导致中断卡死 TH0 = (unsigned int)(65536 - tcnt) >> 8; TL0 = (unsigned int)(65536 - tcnt) & 0xFF; } void pwm_init(void) { TMOD = 0x11; // 定时器0和定时器1都工作在16位定时器模式 TH0 = 0xFC; // 初始计数 1000,约1ms TL0 = 0x18; ET0 = 1; TR0 = 1; EA = 1; }这套逻辑的核心在于每次中断都在翻转引脚后,重新计算到下一次翻转的时间。因为12MHz下51的机器周期是1MHz,定时器每计数1次对应1微秒,PWM_PERIOD_CNT定为1000时,PWM频率就是1kHz,占空比分辨率是1/1000。实际运行中duty_cycle被PID输出更新,占空比平滑变化,不占用主循环时间。
4.2 主循环里的增量式PID与输出限幅
控制周期我用定时器1做:50ms中断一次,主循环里每累计2次触发一次PID计算,也就是100ms控制周期。这样测速窗口100ms,PID周期100ms,两个时间基准天然对齐。
volatile unsigned int current_speed = 0; volatile unsigned char time_flag = 0; volatile unsigned char ctrl_counter = 0; int set_speed = 600; // 目标转速,单位rpm int pid_out = 0; // PID输出,范围 0~999 int err_1 = 0; int err_2 = 0; float kp = 0.80f; float ki = 0.05f; float kd = 0.02f; void Timer1_ISR(void) interrupt 3 { TH1 = (65536 - 50000) / 256; // 50ms TL1 = (65536 - 50000) % 256; time_flag = 1; } void pid_regulate(void) { int err, inc; err = set_speed - (int)current_speed; inc = (int)(kp * (err - err_1) + ki * err + kd * (err - 2 * err_1 + err_2)); pid_out += inc; if (pid_out > 999) pid_out = 999; // 占空比上限 if (pid_out < 0) pid_out = 0; // 占空比下限 duty_cycle = (volatile unsigned int)pid_out; err_2 = err_1; err_1 = err; } void main(void) { unsigned int rpm = 0; MOTOR_IN1 = 1; // 正转方向 MOTOR_IN2 = 0; IT0 = 1; // INT0下降沿触发 EX0 = 1; pwm_init(); TH1 = (65536 - 50000) / 256; TL1 = (65536 - 50000) % 256; ET1 = 1; TR1 = 1; while (1) { if (time_flag) { time_flag = 0; if (++ctrl_counter >= 2) // 每两个50ms执行一次 { ctrl_counter = 0; rpm = get_rpm(); current_speed = rpm; pid_regulate(); } } } }这段代码里,set_speed和current_speed的单位都是rpm,pid_out的单位是千分比占空比。kp取0.8意味着100rpm的转速误差会产生80个单位的占空比增量;ki负责消化长期稳态误差,kd负责在转速接近目标时抑制超调。如果3个参数都过小,系统会长时间达不到目标转速;如果ki过大,转速会在目标值附近持续振荡。
4.3 PID参数初值怎么给:Kp、Ki、Kd三组起步值
Proteus仿真中不需要上来就做临界比例度法,我一般直接按经验区间起步,再根据现象微调。
| 控制现象 | 参数调整 | 检查其他环节 |
|---|---|---|
| 启动太慢,稳定时间过长 | 增大Kp或Ki | 确认转速换算没有偏小 |
| 超调大,转速冲过目标值很多 | 减小Kp或增大Kd | 确认PWM频率没有过低 |
| 目标附近等幅振荡 | 减小Ki,略增Kd | 检查测速脉冲是否抖动 |
| 仿真发散,波形越来越远 | Kp、Ki同时减小一半 | 查输出是否频繁到999限幅 |
当目标转速从600rpm启动时,kp从0.5到1.0之间起步比较安全,ki从0.02到0.05起步,kd从0.01到0.05起步。如果PID输出的占空比单位改成0到100的百分比,三个参数要按比例缩小到原来的1/10,很多人在Proteus里直接把0到100的代码套进0到999的限幅,结果系统怎么调都不稳。
5. 用虚拟示波器和串口验证Proteus PID电机控制系统
5.1 先开环测PWM,再闭环测转速
闭环验证前,我强烈建议先把PID断开,让duty_cycle固定在一个值,比如500。运行仿真后示波器接P2.3,看到的应当是1kHz、占空比50%的方波。如果频率不是1kHz,优先检查晶振频率和定时器初值。开环能稳住PWM之后,再把duty_cycle更新交给PID,并把目标转速设到一个较小值,比如300rpm。从虚拟终端或代码调试变量里观察,转速应当先上升,经过少量超调后稳定在300附近。
5.2 三个容易误判的现象:超调、稳态误差、仿真发散
Proteus里观察闭环曲线比实物更容易看到整条动态过程。超调表现为转速越过目标值再回落,稳态误差表现为转速停在目标值附近但永远有偏差,仿真发散则是一路增大直到撞上限幅。遇到仿真发散,第一反应不是继续降Kp,而是检查PID的输出限幅和测速换算是否匹配。若限幅上限999对应的是100%占空比,而PID输出在900附近长期打顶,说明积分项在积累无效误差,需要在控制周期结束后判断是否处于限幅状态,再决定是否更新积分项。这套仿真图和源代码移植到实物时,只需根据真实编码器的每转脉冲数和采样周期重写换算系数,PID结构本身不需要改动。
本文还有配套的精品资源,点击获取