STM32F103C8T6+L298N+MG513P30 PID闭环调速实战

发布时间:2026/10/13 15:53:09
STM32F103C8T6+L298N+MG513P30 PID闭环调速实战 简介这份资源面向嵌入式开发初学者与电机控制方向的进阶学习者提供一套基于STM32F103C8T6、L298N双H桥驱动与MG513P30直流电机的PID闭环调速程序可用于课程设计、毕业设计或智能小车等实时控制场景。压缩包共47.69MB内含工程源码、头文件与说明文档等覆盖GPIO、定时器、PWM输出及串口等外设配置便于直接编译与二次修改。程序完整实现了PID控制流程初始化Kp、Ki、Kd及积分限幅与死区时间设定采样周期计算设定速度与实测速度的误差完成比例、积分、微分项组合经限幅后输出PWM驱动L298N并持续反馈电机转速进入下一周期。读者可据此理解参数整定方法如手动试凑、Ziegler-Nichols法则或模糊逻辑优化掌握从驱动电路到控制算法的完整链路。目前已有5756人学习下载适合希望打通嵌入式实时控制与电机调速实践的开发者参考。1. 从一堆散落的模块到能跑的车这套 PID 控制程序到底解决了什么如果你手头正好有一块 STM32F103C8T6 最小系统板、一个 L298N 驱动模块、两个带编码器的 MG513P30 直流减速电机再加上一个车架和电池那么你大概率正在面对一个很具体的问题怎么让这几个东西配合起来让轮子按照你想要的速度转而不是一通电就疯跑或者干脆不动。这套基于 STM32F103C8T6 加 L298N 加 MG513P30 的 PID 控制程序解决的就是从硬件接线到闭环调速这一整条链路。它适合正在做智能小车、平衡车、循迹车或者任何需要精确控制直流电机转速的嵌入式开发者尤其是那些已经能把电机转起来、但一加载就发现速度飘忽不定的朋友。STM32 的定时器编码器接口、PWM 输出、PID 算法整定这几个关键词会贯穿整个调试过程而这份程序把最容易被忽略的细节——比如编码器计数方向、PWM 频率和 PID 采样周期的匹配——都摆在了明面上。2. 硬件链路与软件框架为什么选 STM32F103C8T6 配 L298N 和 MG513P302.1 从编码器脉冲到 PWM 占空比信号是怎么走通的MG513P30 这个型号里13 代表减速比P30 通常指 30 线编码器也就是电机转一圈输出 30 个脉冲经过 13 比 1 的减速箱后轮子转一圈对应 390 个脉冲。这个数字很关键后面算实际转速全靠它。编码器的 A、B 两相输出接到 STM32F103C8T6 的定时器编码器接口上比如 TIM2 的 CH1 和 CH2或者 TIM3、TIM4 的对应引脚。STM32 的编码器模式会自动处理正交解码你只需要读计数器就能知道转了多少、往哪个方向转。L298N 这边它负责把 STM32 输出的 PWM 信号放大成能驱动电机的电流同时接收方向控制信号。典型接法是 STM32 的两个 GPIO 接 L298N 的 IN1、IN2 控制正反转一个定时器通道输出 PWM 接 ENA 使能脚。这里有个容易翻车的地方L298N 的 ENA 跳线帽如果没拔使能脚会被直接拉高PWM 就进不去了电机只会全速转。我一般会先把跳线帽拔掉用杜邦线把 PWM 引过去。软件框架上这套程序通常包含几个模块编码器初始化与读数、PWM 初始化与占空比设置、定时器中断用于固定周期采样、PID 计算、以及串口输出用于调参观察。主循环里一般不做重活PID 计算放在定时器中断里保证采样周期严格固定。常见做法是用一个 5ms 或 10ms 的定时中断每次中断读一次编码器计数、算一次实际速度、跑一次 PID、更新 PWM。为什么不用主循环延时因为主循环里如果有串口打印或者别的任务采样周期就会抖PID 效果直接变玄学。2.2 关键参数怎么定PWM 频率、采样周期和编码器倍频PWM 频率不能太低否则电机会听到啸叫也不能太高否则 L298N 可能响应不过来。L298N 的数据手册里一般建议 PWM 频率在 1kHz 到 20kHz 之间我通常取 10kHz 左右这样既避开音频范围又给驱动留足开关时间。STM32F103C8T6 主频 72MHz定时器预分频和自动重装载值要算好。比如用 TIM1 的 CH1 输出 PWM预分频设为 0自动重装载值设为 7199那 PWM 频率就是 72MHz / (71991) 10kHz。占空比通过修改比较寄存器 CCR1 来实现范围 0 到 7199。编码器这边STM32 的编码器模式可以设置成在 A、B 两相的上升沿和下降沿都计数也就是四倍频。对于 30 线编码器加 13 减速比四倍频后轮子转一圈的计数是 30 × 13 × 4 1560。这个数字要写进速度计算公式里。采样周期我一般用 10ms也就是 100Hz 的采样率。为什么不是 1ms因为 1ms 内编码器脉冲太少低速时算出来的速度分辨率不够噪声大。10ms 是个折中既跟得上电机动态又有足够的脉冲数。PID 的积分和微分项都跟采样周期强相关换采样周期必须重新整定参数这一点后面避坑章节会细说。2.3 代码骨架初始化、中断和 PID 计算怎么串起来下面这段代码是核心骨架展示了从初始化到 PID 计算的完整流程。用的是 HAL 库标准库的逻辑一样只是函数名不同。// encoder.c - 编码器初始化与读数 void Encoder_Init(void) { // TIM2 编码器模式四倍频 TIM_Encoder_InitTypeDef encoder_config {0}; TIM_MasterConfigTypeDef master_config {0}; encoder_config.EncoderMode TIM_ENCODERMODE_TI12; // A、B两相都计数 encoder_config.IC1Polarity TIM_ICPOLARITY_RISING; encoder_config.IC1Selection TIM_ICSELECTION_DIRECTTI; encoder_config.IC1Prescaler TIM_ICPSC_DIV1; encoder_config.IC1Filter 0; // 先不滤波后面看情况加 encoder_config.IC2Polarity TIM_ICPOLARITY_RISING; encoder_config.IC2Selection TIM_ICSELECTION_DIRECTTI; encoder_config.IC2Prescaler TIM_ICPSC_DIV1; encoder_config.IC2Filter 0; HAL_TIM_Encoder_Init(htim2, encoder_config); HAL_TIM_Encoder_Start(htim2, TIM_CHANNEL_ALL); } int16_t Encoder_Read(void) { int16_t count (int16_t)__HAL_TIM_GET_COUNTER(htim2); __HAL_TIM_SET_COUNTER(htim2, 0); // 读完清零下次中断再读 return count; }// pid.c - PID 结构体与计算函数 typedef struct { float Kp, Ki, Kd; float target; float actual; float error; float last_error; float integral; float output; float output_max; float integral_max; } PID_TypeDef; float PID_Calc(PID_TypeDef *pid, float actual, float dt) { pid-actual actual; pid-error pid-target - pid-actual; // 积分项带限幅防止饱和 pid-integral pid-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 derivative (pid-error - pid-last_error) / dt; pid-output pid-Kp * pid-error pid-Ki * pid-integral pid-Kd * derivative; // 输出限幅 if (pid-output pid-output_max) pid-output pid-output_max; if (pid-output -pid-output_max) pid-output -pid-output_max; pid-last_error pid-error; return pid-output; }// main.c - 定时器中断里的控制逻辑 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM6) { // 10ms 定时中断 int16_t encoder_count Encoder_Read(); // 实际转速脉冲数 / 每圈脉冲数 / 采样周期 // 1560 是四倍频后轮子转一圈的计数 float actual_speed (float)encoder_count / 1560.0f / 0.01f; // 单位转/秒 float output PID_Calc(pid_left, actual_speed, 0.01f); // 把 PID 输出映射到 PWM 占空比 // output 范围假设是 -7200 到 7200对应正反转满占空比 int16_t pwm (int16_t)output; if (pwm 0) { __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, pwm); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET); // 方向1 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); // 方向2 } else { __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, -pwm); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); } } }这段代码里Encoder_Read读完计数器立刻清零这样每次中断拿到的就是这 10ms 内的增量。PID_Calc里的dt是采样周期必须和实际中断周期一致否则积分和微分项都会算错。integral_max是积分限幅防止电机堵转时积分项无限累加松手后猛冲。output_max对应 PWM 的最大比较值我设的是 7200和自动重装载值一致这样 PID 输出可以直接当占空比用。方向控制用两个 GPIO 配合正转时 IN1 高 IN2 低反转时反过来。注意 L298N 的逻辑表有些模块 IN1 和 IN2 同时高是刹车同时低是滑行别搞混。3. 从零搭建接线、配置和 PID 整定的完整步骤3.1 硬件接线与电源隔离别让电机把单片机拖复位接线顺序我一般是这样先接电源再接控制信号最后接电机。电源部分L298N 需要两路供电一路是电机的驱动电源通常 7V 到 12V另一路是逻辑电源 5V。很多 L298N 模块自带 5V 稳压输出可以把单片机的 5V 脚接上去但要注意这个 5V 的输出电流有限如果单片机外设多最好单独供电。血泪经验是电机启动瞬间电流很大如果单片机和电机共用一路电源电压会被拉低单片机直接复位表现就是小车一启动就重启。解决方法是电机电源和单片机电源分开或者至少在电源入口加一个大电容缓冲。编码器接线MG513P30 一般有六根线电机两根、编码器电源两根通常 3.3V 或 5V、编码器 A/B 相两根。编码器电源电压要确认有些是 3.3V 有些是 5V接错可能烧编码器。A/B 相接 STM32 的定时器引脚比如 TIM2_CH1 是 PA0TIM2_CH2 是 PA1。注意 STM32F103C8T6 的 TIM2 是 32 位定时器做编码器接口很合适。PWM 输出用 TIM1_CH1 是 PA8方向控制用两个普通 GPIO比如 PA2 和 PA3。L298N 的 ENA 接 PA8IN1 接 PA2IN2 接 PA3。如果用的是两路电机另一路同理用 TIM1_CH2 或者 TIM1_CH3。3.2 STM32CubeMX 配置要点时钟树、定时器和中断优先级用 CubeMX 配置的话几个关键点别漏。时钟树里HSE 选外部晶振PLL 倍频到 72MHzAPB1 分频到 36MHzAPB2 保持 72MHz。TIM1 挂在 APB2 上所以 PWM 频率按 72MHz 算。TIM2 也挂在 APB1 上但编码器模式对时钟频率不敏感只要计数能跟上就行。TIM6 做 10ms 定时中断预分频设为 7199自动重装载值设为 99这样 72MHz / 7200 / 100 100Hz正好 10ms。中断优先级要排好。TIM6 的中断优先级设高一点保证采样周期不被其他中断打断。串口中断设低一点因为串口打印可以慢但 PID 计算不能等。如果用了 FreeRTOS 或者别的调度器PID 计算最好放在最高优先级的任务里或者直接用硬件定时器中断别放在低优先级任务里被别的任务挤掉。常见错误是串口打印放在中断里打印一堆数据导致中断执行时间过长下一个中断来了还没处理完采样周期就乱了。我一般只在主循环里打印中断里只做计算和更新 PWM。3.3 PID 参数整定从 Kp 开始先 P 后 I 再 D整定顺序不能乱。先把 Ki 和 Kd 设为零只调 Kp。Kp 从 0 开始慢慢加每加一次给电机一个阶跃目标速度观察实际速度的响应。Kp 太小响应慢到不了目标速度Kp 太大速度会震荡甚至啸叫。找到那个刚好不震荡的 Kp然后往回退一点留点余量。比如 Kp 加到 50 开始震荡那就取 35 到 40。然后加 Ki。Ki 的作用是消除稳态误差。只有 P 的时候实际速度总会比目标低一点因为 PWM 占空比需要一定值才能克服摩擦力。Ki 从很小的值开始加比如 Kp 的百分之一。加 Ki 后速度会慢慢爬到目标值。但 Ki 太大会导致超调速度冲过头再掉回来来回振荡。如果发现积分项一直在累加但速度上不去检查integral_max是不是设太小或者电机是不是堵转了。最后加 Kd。Kd 抑制超调但会放大噪声。编码器读数本身有量化误差微分项一放大PWM 输出就会抖。如果电机声音变得刺耳或者速度曲线毛刺很多说明 Kd 太大了。我一般 Kd 取 Kp 的十分之一到五分之一再根据实际波形微调。整定过程中串口打印目标速度和实际速度的曲线非常有用用 Excel 或者串口示波器画出来一眼就能看出超调、震荡和稳态误差。// 串口打印格式方便导入 Excel 画图 printf(%.2f,%.2f,%.2f\r\n, target_speed, actual_speed, pid_output);这段打印放在主循环里每 50ms 打一次不要放在 10ms 中断里。打印的内容包括目标速度、实际速度和 PID 输出用逗号分隔存成 CSV 文件直接能画图。注意串口波特率别太低115200 比较合适太低会拖慢主循环。4. 避坑与排查编码器方向、积分饱和和 PWM 频率的那些坑4.1 现象电机一启动就全速转PID 完全不起作用原因通常是 L298N 的 ENA 跳线帽没拔使能脚被直接拉高PWM 信号根本没进到驱动芯片。或者 PWM 定时器没启动HAL_TIM_PWM_Start没调用输出一直是低电平但方向控制又给了高电机就全速了。还有一种可能是 PWM 比较值设成了自动重装载值占空比 100%。解决方法是先拔跳线帽用万用表量 ENA 脚电压确认 PWM 信号在变化。然后检查代码里HAL_TIM_PWM_Start是否在初始化后调用了比较值是否在 0 到自动重装载值之间。4.2 现象速度显示是负数或者电机反转时编码器计数不减少原因是编码器 A、B 相接反了或者编码器模式配置成了只计一相。STM32 的编码器模式如果设成TIM_ENCODERMODE_TI1就只计 A 相方向靠 B 相电平判断但计数不会反向。要设成TIM_ENCODERMODE_TI12两相都计数才能正反转都正确。另外如果 A、B 相接反了正转时计数会减少反转时增加。解决方法很简单把 A、B 相的两根线对调或者在代码里把计数取反。我一般先在代码里取反验证确认是接线问题后再改线。4.3 现象电机堵转后松手速度猛冲一下才稳定这是积分饱和的典型表现。电机堵转时实际速度为零误差一直存在积分项不断累加直到integral_max限幅。松手后积分项已经很大PID 输出直接饱和电机全速冲出去然后积分项慢慢降下来才恢复。解决方法就是加积分限幅integral_max不要设太大一般取output_max的百分之二三十。另外可以用积分分离当误差大于某个阈值时不累加积分项等误差小了再累加。这样既能消除稳态误差又不会在动态过程中积分饱和。4.4 现象改变采样周期后原来调好的 PID 参数完全失效PID 的积分和微分项都跟采样周期dt强相关。积分项是error * dt累加dt变了同样的 Ki 产生的积分作用就变了。微分项是(error - last_error) / dtdt变了微分作用也变了。所以换采样周期必须重新整定参数。有个经验公式如果采样周期从T1变成T2新的 Ki 大约是旧的 Ki 乘以T1/T2新的 Kd 大约是旧的 Kd 乘以T2/T1。但这只是近似最好还是重新调。我一般把采样周期固定死不轻易改改了就重新走一遍整定流程。4.5 现象串口打印的数据跳变很大速度曲线像锯齿原因可能是编码器计数没有清零或者清零时机不对。如果每次读计数器后不清零读到的就是累计值算出来的速度是平均速度不是瞬时速度。如果清零放在中断外面主循环和中断可能同时访问计数器导致数据竞争。解决方法是在中断里读计数器并立刻清零保证原子性。另外编码器信号线如果太长或者没有屏蔽可能引入噪声导致多计数。可以在编码器 A、B 相和地之间加一个小电容滤波比如 100nF或者在 STM32 的编码器配置里打开输入滤波IC1Filter设成 0x0F 试试。5. 进阶技巧用上位机看波形把 PID 整定从玄学变成工程调 PID 最痛苦的就是盲调改一个参数烧一次程序看电机转得怎么样全凭感觉。我后来养成了一个习惯不管什么电机项目先把串口波形调通。具体做法是在 10ms 中断里把目标速度、实际速度、PID 输出三个值打包成一帧通过串口发出去主循环里不做任何打印。帧格式可以很简单比如0xAA开头然后三个 float最后0x55结尾。上位机用 Python 写个脚本读串口、解析帧、实时画曲线。这样改一个参数立刻就能看到响应曲线的变化超调多少、调节时间多长、稳态误差多大一目了然。# serial_plot.py - 实时读取串口数据并画图 import serial import struct import matplotlib.pyplot as plt import matplotlib.animation as animation from collections import deque ser serial.Serial(COM3, 115200, timeout0.01) target_data deque(maxlen200) actual_data deque(maxlen200) output_data deque(maxlen200) def parse_frame(data): # 找帧头 0xAA然后解三个 float再找帧尾 0x55 idx data.find(b\xAA) if idx -1 or len(data) idx 14: return None frame data[idx:idx14] if frame[13] ! 0x55: return None target, actual, output struct.unpack(fff, frame[1:13]) return target, actual, output def update(frame): while ser.in_waiting: data ser.read(ser.in_waiting) result parse_frame(data) if result: target_data.append(result[0]) actual_data.append(result[1]) output_data.append(result[2]) plt.cla() plt.plot(target_data, labelTarget) plt.plot(actual_data, labelActual) plt.plot(output_data, labelPID Output) plt.legend() plt.ylim(-2, 2) ani animation.FuncAnimation(plt.gcf(), update, interval50) plt.show()这段 Python 脚本用struct.unpack解析三个小端 float帧头帧尾做校验防止数据错位。deque限制数据长度避免内存无限增长。matplotlib的FuncAnimation每 50ms 刷新一次和 STM32 的 10ms 中断不冲突因为串口缓冲会攒数据。实际用的时候把目标速度设成阶跃信号比如从 0 跳到 1 转/秒看实际速度多久跟上、有没有超调。Kp 太大曲线会震荡Ki 太大曲线会缓慢爬升然后冲过头Kd 太大曲线会毛刺。调到你满意为止把参数记下来下次换电机只需要微调。还有一个技巧是给 PID 输出加一个前馈项。电机从静止到转动需要克服静摩擦力纯 PID 在低速时响应很慢。可以在 PID 输出上叠加一个小的固定值或者根据目标速度算一个前馈 PWM这样启动更快PID 只需要修正误差。前馈值怎么定给电机一个从小到大的 PWM看它刚好能转起来的值是多少那就是静摩擦补偿。把这个值加到 PID 输出上低速性能会好很多。从那以后我每次调电机 PID都先把串口波形跑通再动手改参数再也不靠耳朵听声音判断震荡了。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询