资讯动态

MSPM0G3507 上跑通 PID 闭环:定时器、ADC、PWM 与串口调参完整工程拆解

发布时间:2026/10/14 11:15:13 来源:尧图企业网站定制
简介这份资源面向学习TI MSPM0G3507微控制器的嵌入式初学者与进阶开发者提供一套可直接编译运行的简易PID项目完整代码帮助读者理解PID算法在电机控制等场景中的落地方式并熟悉TI官方开发环境的工程组织。压缩包共56个文件约158KB以15个C源文件与13个头文件为核心涵盖电机驱动、编码器、按键、定时器、LCD与OLED显示、串口通信及系统模式管理等模块同时附带IAR、Keil、ticlang、gcc多套工程配置与链接脚本以及SysConfig生成的初始化代码方便在不同工具链下切换调试。目前已有158人学习下载。读者可从中获得完整的工程目录结构、外设驱动分层写法、PID控制逻辑实现与多平台构建配置参考适合作为课程设计、竞赛准备或自学MSPM0系列时的实践素材。1. 从一块 MSPM0G3507 说起为什么这个 PID 工程值得拆如果你手头正躺着一块 TI MSPM0G3507 LaunchPad想让它跑起一个能用的闭环控制又不想从寄存器手册第一页啃到最后一页那这份「简易 PID 项目完整代码」大概率能省掉你两三个晚上的试错。MSPM0G3507 是 TI 这两年主推的 Arm Cortex-M0 通用 MCU80MHz 主频、带硬件乘法器、片上 128KB Flash / 32KB SRAM做电机速度环、温控环、位置环这类单回路 PID 完全够用。但真正上手的人会发现TI 的 SDK 把外设抽象得很干净干净到很多人卡在「定时器怎么触发 ADC、ADC 结果怎么喂给 PID、PWM 占空比怎么反算」这条链路上。这份代码的价值不在于 PID 算法本身——那玩意儿二十行就能写完——而在于它把 MSPM0G3507 的定时器、ADC、PWM、串口这几块拼成了一个能直接烧录、能在线调参的完整闭环。适合谁适合已经点过 LED、跑过 GPIO 中断但还没把「采样—计算—输出」这条实时链路真正打通的人。2. 拆开工程看骨架MSPM0G3507 上 PID 的四个硬件支点拿到一份别人写好的 MCU 工程最忌讳的就是直接编译烧录然后盯着现象发呆。我一般会先把工程按「硬件支点」拆开看清楚每个外设到底在 PID 回路里扮演什么角色。MSPM0G3507 的 PID 实现本质上就是四个外设的接力定时器负责节拍ADC 负责感知PWM 负责执行串口负责让你能看见中间量。缺一个闭环就跑不起来或者跑起来也是个黑匣子。2.1 定时器PID 的节拍器别用 SysTick 硬扛很多人第一反应是用 SysTick 做 PID 周期中断简单直接。但在 MSPM0G3507 上SysTick 是 Cortex-M0 内核自带的用它做控制周期会有一个隐蔽问题一旦你后面要加串口 DMA 或者 ADC 连续采样SysTick 的优先级和中断嵌套容易和别的外设打架。更常见的做法是用 TIMG 通用定时器配置成周期中断专门给 PID 提供固定节拍。这份代码里用的是 TIMG0配置成 1kHz 中断也就是每 1ms 执行一次 PID 计算。为什么是 1kHz因为大多数电机速度环、温控环的机械时间常数在几十到几百毫秒量级1ms 的控制周期足够覆盖再快就是浪费 CPU。配置的关键参数有三个时钟源选 BUSCLK默认 80MHz 分频后预分频值决定计数频率装载值决定中断周期。常见做法是预分频设成 80得到 1MHz 计数频率装载值设成 999这样每 1000 个计数溢出一次正好 1ms。// TIMG0 初始化1kHz 周期中断作为 PID 控制节拍 // 假设 BUSCLK 80MHz预分频 80 - 1MHz装载值 999 - 1ms void PID_Timer_Init(void) { // 使能 TIMG0 外设时钟 DL_TimerG_setClockConfig(TIMG0, DL_TimerG_getClockConfig(DL_TIMER_G_CLOCK_BUSCLK)); // 配置定时器周期模式预分频 80装载值 999 DL_TimerG_initPeriodMode(TIMG0, 80, 999); // 使能定时器中断 DL_TimerG_enableInterrupt(TIMG0, DL_TIMERG_INTERRUPT_ZERO_EVENT); // 在 NVIC 中使能 TIMG0 中断 NVIC_EnableIRQ(TIMG0_INT_IRQn); // 启动定时器 DL_TimerG_startCounter(TIMG0); }这段代码的逻辑很直白先给定时器选时钟源再设分频和装载值然后开中断、开 NVIC、启动计数。参数里最容易翻车的是预分频和装载值的组合——如果你把预分频设成 0装载值设成 79999理论上也是 1ms但计数范围大了之后中断响应延迟的抖动会更明显。我一般倾向于「小装载值 适中预分频」让计数器快速溢出中断触发更均匀。2.2 ADC 采样别让采样时间和 PID 周期打架PID 的输入是 ADC 采回来的反馈量比如电机转速对应的电压、热敏电阻的分压。MSPM0G3507 的 ADC 是 12 位、最高 4Msps 的逐次逼近型配置上要注意采样窗口和转换时间的匹配。这份代码里 ADC 用的是单通道单次转换模式在 TIMG0 中断里手动触发一次转换转换完成后读结果。为什么不直接用连续转换模式因为连续模式下 ADC 自己跑自己的和 PID 周期不同步读到的值可能是上一次的残留也可能是刚采到一半的中间态对闭环控制来说就是引入噪声。// ADC 单次采样在 PID 中断里调用采完即读 uint16_t ADC_Read_Feedback(void) { // 软件触发一次转换 DL_ADC12_startConversion(ADC12_0_INST); // 等待转换完成轮询方式转换时间约 1us 量级 while (!DL_ADC12_isConversionComplete(ADC12_0_INST)); // 读取 12 位结果 uint16_t result DL_ADC12_getMemResult(ADC12_0_INST, DL_ADC12_MEM_IDX_0); // 清除完成标志为下次转换做准备 DL_ADC12_clearInterruptStatus(ADC12_0_INST, DL_ADC12_INTERRUPT_MEM0_RESULT_LOADED); return result; }这里有个参数细节ADC 的采样时间Sample Time要设得比信号源内阻对应的建立时间略长。比如你用一个 10k 的热敏电阻分压源阻抗大概几 kΩ采样时间设成 1000 个 ADC 时钟周期比较稳妥。设太短采到的值偏低且跳动大设太长1ms 的 PID 周期里光等 ADC 就花掉一大半留给计算和 PWM 更新的时间就紧张了。常见做法是采样时间控制在 200~500 个 ADC 时钟周期转换总时间压在 10us 以内。2.3 PWM 输出占空比更新的时机比数值更重要PID 算出来的控制量最终要变成 PWM 占空比去驱动执行器。MSPM0G3507 的 TIMG 或者 TIMA 都能输出 PWM这份代码用的是 TIMG1 的两路互补输出频率设成 20kHz刚好在 audible noise 之上电机驱动听不到啸叫。关键点在于PWM 占空比的更新必须和定时器周期同步否则会出现「半周期更新」导致的脉冲畸形。// 更新 PWM 占空比在 PID 计算完成后调用 // duty 范围 0~1000对应 0%~100% void PWM_SetDuty(uint16_t duty) { // 限幅防止越界 if (duty 1000) duty 1000; // 写入比较寄存器下一个周期生效 DL_TimerG_setCaptureCompareValue(TIMG1, duty, DL_TIMER_CC_0_INDEX); }参数上PWM 频率和 PID 周期的关系要算清楚20kHz 的 PWM 周期是 50us1ms 的 PID 周期里包含 20 个 PWM 周期。也就是说每次 PID 更新占空比后执行器要经过最多 20 个 PWM 周期才完全响应。对于大多数电机和加热丝这个延迟可以忽略但如果你控的是高速开关阀或者压电陶瓷就得把 PWM 频率再拉高或者改用「周期同步更新」模式让占空比在计数器归零时统一生效。2.4 串口调参没有在线可视化的 PID 就是盲调这份代码里还带了一个串口输出把设定值、反馈值、PID 输出、P/I/D 三项分量按固定格式打到上位机。别小看这个功能PID 调参最怕的就是「改了参数、烧了程序、看现象、再改」这个循环一轮下来五分钟一天调不出所以然。有了串口波形你能直接看到超调、振荡、积分饱和调参效率差好几倍。// 串口上报每 10 个 PID 周期发一次避免刷屏 void PID_Report(void) { static uint8_t decim 0; if (decim 10) return; decim 0; // 格式setpoint,feedback,output,p_term,i_term,d_term printf(%d,%d,%d,%d,%d,%d\r\n, g_pid.setpoint, g_pid.feedback, g_pid.output, g_pid.p_term, g_pid.i_term, g_pid.d_term); }串口波特率一般设 115200够用且兼容性好。上报周期用「分频」而不是「每周期都发」是因为 1kHz 全发的话串口带宽吃紧而且上位机绘图也来不及渲染。10 分频后 100Hz 的上报率看波形足够顺滑。3. PID 算法本体位置式、增量式怎么选参数怎么落地硬件链路打通之后PID 算法本身反而是最简单的一块。但「简单」不等于「随便写」位置式和增量式的选择、积分限幅的处理、微分项的滤波这几个点决定了你的闭环是稳如老狗还是全程玄学振荡。3.1 位置式 PID 的代码结构和三个限幅点这份代码用的是位置式 PID输出直接是控制量的绝对值。位置式的优点是直观输出和占空比一一对应缺点是积分项容易饱和一旦设定值突变或者执行器到极限积分项会一路累积到天上等误差反向时半天拉不回来。所以位置式 PID 必须做限幅而且要做三处。typedef struct { float kp, ki, kd; // PID 三个增益 float setpoint; // 设定值 float feedback; // 反馈值 float error, last_error; // 当前误差、上次误差 float integral; // 积分累积 float output; // 最终输出 float out_min, out_max; // 输出限幅 float integral_min, integral_max; // 积分限幅 float p_term, i_term, d_term; // 分项用于上报 } PID_Handle; float PID_Compute(PID_Handle *pid, float fb) { pid-feedback fb; pid-error pid-setpoint - fb; // 比例项 pid-p_term pid-kp * pid-error; // 积分项先累加再限幅 pid-integral pid-ki * pid-error; if (pid-integral pid-integral_max) pid-integral pid-integral_max; if (pid-integral pid-integral_min) pid-integral pid-integral_min; pid-i_term pid-integral; // 微分项用差分近似注意首次调用时 last_error 要初始化 pid-d_term pid-kd * (pid-error - pid-last_error); pid-last_error pid-error; // 求和 pid-output pid-p_term pid-i_term pid-d_term; // 输出限幅 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; }三个限幅点分别是积分累积限幅、输出限幅、以及隐含的「积分分离」——如果误差很大可以先不累积分等误差进入某个阈值再开启积分。这份代码里积分限幅用的是固定上下限实际调的时候可以设成输出限幅的 1.5 倍左右给积分留一点余量但不至于失控。3.2 参数整定的实操顺序先 P 后 I 再 D别跳步PID 参数整定没有捷径但有一个不容易翻车的顺序。我一般按这个流程走第一步把 ki 和 kd 置零只留 kp。从小到大慢慢加直到反馈值开始跟随设定值但还没出现明显振荡。这时候系统会有稳态误差正常。第二步加 ki。从小往大加观察稳态误差是否被消除。加到误差基本归零就停再加就会出现「积分过冲」——反馈值冲过设定值再慢慢回落。第三步加 kd。kd 的作用是抑制超调和振荡但会放大噪声。如果反馈信号本身跳动大kd 要谨慎或者先对反馈做一阶低通滤波再加微分。具体数值上以 1kHz 控制周期、12 位 ADC、PWM 满量程 1000 为例kp 的起始值可以从 0.5 开始试ki 从 0.01 开始kd 从 0 开始。这几个数不是标准答案但能让你第一轮就有个能看的波形不至于一上来就满屏振荡。3.3 微分项的血泪经验不滤波的 D 就是噪声放大器微分项对噪声的放大是平方级的。ADC 采回来的值哪怕只有 ±2 个 LSB 的跳动经过差分之后如果 kd 稍微大一点输出就会剧烈抖动。我踩过的坑是电机空载时调好的参数一加上负载就啸叫查了半天发现是微分项把电流采样噪声放大了。解决办法有两个一是对反馈值做一阶低通滤波滤波系数根据噪声频率来定二是用「不完全微分」也就是在微分项上串一个一阶惯性环节。这份代码里用的是第一种简单直接。// 一阶低通滤波alpha 越小滤波越强但相位滞后越大 float LowPass_Filter(float new_val, float old_val, float alpha) { return alpha * new_val (1.0f - alpha) * old_val; } // 在 PID 计算前对反馈值滤波 g_pid.feedback LowPass_Filter(adc_val, g_pid.feedback, 0.3f);alpha 取 0.3 意味着新值占 30%旧值占 70%截止频率大概在几十 Hz 量级。这个值要根据你的信号带宽来调滤波太狠系统响应变慢滤波太弱微分项照样抖。4. 避坑与排查MSPM0G3507 上跑 PID 最容易翻车的五个点这一章是我自己在这块板子上反复烧录、反复示波器抓波形攒下来的。每一条都是「现象 → 原因 → 解决」的完整记录你照着排查能省不少时间。4.1 现象PID 输出一动不动PWM 占空比恒定原因最常见的是 ADC 根本没采到值或者采到的值恒定不变。MSPM0G3507 的 ADC 引脚有复用功能如果你忘了在 SysConfig 或者初始化代码里把对应引脚配成模拟输入ADC 读回来永远是 0 或者满量程。解决先用万用表量一下 ADC 输入引脚的电压确认信号确实到了引脚上然后在调试器里看 ADC 结果寄存器的值如果恒定不变检查引脚复用配置。TI 的 SysConfig 工具里ADC 通道和引脚是绑定的别选错通道号。4.2 现象系统一上电就满速冲反馈完全压不住原因PID 输出的正负号和执行器的方向反了。比如你用的是反相驱动的 MOS 管PID 输出增大反而让电机减速闭环就变成了正反馈直接饱和。解决先把 PID 断开手动给一个固定占空比看电机转向和反馈值的变化方向。如果反馈值随占空比增大而减小说明方向反了把 PID 输出的符号取反或者把执行器的驱动逻辑反过来。4.3 现象稳态时反馈值在设定值附近小幅高频抖动原因微分项噪声放大或者 PWM 分辨率不够导致极限环振荡。12 位 ADC 配 1000 级 PWM理论分辨率是够的但如果 ADC 参考电压不稳或者 PWM 频率和 PID 周期有拍频就会出现这种抖动。解决先加大反馈滤波的强度看抖动是否减弱如果减弱了但还有把 PWM 频率和 PID 周期设成整数倍关系避免拍频。另外检查 ADC 参考电压是不是用了 VDDA 而 VDDA 上有没有足够去耦电容。4.4 现象设定值突变时输出冲过头很久才回来原因积分饱和。设定值从 0 跳到满量程误差瞬间很大积分项一路累加到限幅值等反馈值追上来了积分项还顶在天花板上输出下不来。解决加积分分离误差大于某个阈值时暂停积分累加或者用「反计算抗饱和」——当输出被限幅时把超出的部分从积分项里扣掉。这份代码里用的是积分限幅简单但有效限幅值设成输出限幅的 1.2 倍左右。4.5 现象串口有输出但上位机波形乱跳或者干脆收不到原因串口波特率不匹配或者 printf 重定向没配好。MSPM0G3507 的 UART 时钟源如果选错实际波特率会和设定值差很多。另外 TI 的 SDK 里 printf 默认走 CIO需要重定向到 UART 才能从物理串口输出。解决先用示波器量 UART TX 引脚发一个固定字符比如 0x55量波形周期反推实际波特率。如果偏差超过 2%检查 UART 时钟源配置。printf 重定向要在工程里实现 fputc 或者用 DL_UART_transmitData 手动发。5. 进阶技巧把 PID 参数放到 Flash 里免烧录在线改调 PID 最烦的就是改一个参数就要重新编译烧录一轮几分钟一天调不了几组。这份代码的基础版是把参数写成宏定义改完要重烧。我后来加了一个小改动把 PID 参数存到 MSPM0G3507 的 Flash 末尾一页上电时读出来串口收到特定命令就更新参数并写回 Flash。这样调参就不用重新烧录了改完参数发个串口命令掉电也不丢。实现上分三步。第一步在链接脚本或者代码里指定一个 Flash 页作为参数区MSPM0G3507 的 Flash 擦写粒度是 1KB 一页找最后一页用就行。第二步上电初始化时从该地址读参数如果读出来是全 0xFF擦除态就加载默认值。第三步串口收到参数更新命令后先擦除该页再写入新参数。// Flash 参数区地址假设用最后一页具体地址查芯片手册 #define PID_PARAM_ADDR 0x0001FC00 typedef struct { float kp, ki, kd; float out_max, out_min; uint32_t magic; // 校验字防止读到空数据 } PID_Param; // 上电加载参数 void PID_Param_Load(PID_Handle *pid) { PID_Param *p (PID_Param *)PID_PARAM_ADDR; if (p-magic ! 0x50494431) { // PID1 // Flash 里没有有效参数用默认值 pid-kp 0.5f; pid-ki 0.01f; pid-kd 0.0f; pid-out_max 1000.0f; pid-out_min 0.0f; return; } pid-kp p-kp; pid-ki p-ki; pid-kd p-kd; pid-out_max p-out_max; pid-out_min p-out_min; } // 保存参数到 Flash调用前需确保该页已擦除 void PID_Param_Save(PID_Handle *pid) { PID_Param param { .kp pid-kp, .ki pid-ki, .kd pid-kd, .out_max pid-out_max, .out_min pid-out_min, .magic 0x50494431 }; DL_FlashCTRL_eraseSector(FLASHCTRL, PID_PARAM_ADDR); DL_FlashCTRL_programData(FLASHCTRL, (uint32_t *)param, PID_PARAM_ADDR, sizeof(param) / 4); }这里有几个参数要注意magic 校验字是必须的因为 Flash 擦除后是全 0xFF如果不校验读出来的 kp 会是一个巨大的浮点数PID 直接疯掉。擦除和写入的顺序不能反必须先擦后写。另外 Flash 写入期间 CPU 会暂停所以别在 PID 中断里调用保存函数放到主循环里处理串口命令时再写。验证方法很简单上电后串口打印当前参数改一组值发保存命令断电再上电看参数是否还是改后的值。如果每次上电都回到默认值检查 magic 校验字是不是写进去了或者 Flash 地址是不是被程序本身占用了。从那以后我每次做 MCU 上的闭环控制都强制先把「参数在线可改」这条链路走通再开始调 PID不然光烧录就能把耐心耗光。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑