
1. 项目要解决什么这套硬件选型好在哪1.1 这套组合能做什么这段时间一直在收拾手头的一台小车底盘电机是淘宝常见的那种带霍尔编码器 MG513X 直流减速电机驱动器选了 TB6612主控用 TI 的 MSPM0G3507。到这里硬件就定型了剩下的核心工作就是三件事把 TB6612 的 PWM 时序搞对把编码器脉冲数准然后用 PID 调速器把速度闭环起来。很多刚开始做机器人底盘的朋友卡住的位置其实不在“电机转不转”而在“电机能不能稳定按目标转速转”。直流减速电机空载和带载转速差很多电池电压一降转速跟着掉轮子压到地毯也会慢半拍。单纯给一个固定占空比转向、上坡、载重一变车速根本稳不住。所以这个项目的本质不是“点亮一个电机”而是把调速回路走通。你跟着把代码写完就能得到一套可复用的“编码器电机 驱动 速度闭环”模板后面换更小的机械臂、麦克纳姆轮底盘或者做循迹、避障小车控制代码几乎不用大改。这套方案适合谁主要有三类做智能车竞赛、机器人毕设的在校学生正在做舵轮或差速底盘的 DIY 爱好者以及刚切换到 MSPM0 系列想快速上手定时器、中断、PID 的嵌入式开发者。1.2 为什么是 MSPM0G3507 TB6612 MG513X先说主控。MSPM0G3507 是 TI 的 Arm Cortex-M0 内核 MCU主频最高能跑到 80MHz片上带 128KB Flash 和 32KB SRAM。放在电机控制这个场景里它比同价位的 Cortex-M3 更省电、更便宜但定时器资源一点都不寒酸多个 16 位高级定时器可以输出互补 PWM还支持死区、捕获、编码器接口这类高级功能。再加上 TI 的 MSPM0 SDK 和 SysConfig 图形化配置工具初始化代码几乎不用手写开发效率比对着寄存器手册写快得多。然后是驱动器 TB6612。它本质是一个双 H 桥芯片额定每通道 1.2A峰值 3.2A逻辑电压 2.7V 到 5.5V电机驱动电压从 2.2V 一直到 13.5V。对比老牌的 L298N 模块TB6612 的内阻低、体积小、发热少12V 以内的小型直流减速电机用起来很合适。MG513X 电机也不挑驱动正常工作电流在几百毫安到一两安之间TB6612 完全扛得住。实际上除非你用的是 35GA、540 这类大功率电机不然真的不用上 BTN7971 这种大电流驱动成本高还大材小用。再说电机 MG513X。它属于带霍尔编码器的直流减速电机常见减速比 1:30 左右空心杯或者铁芯电机的本体加一级齿轮减速箱输出编码器安装在电机尾部用来测电机轴的转速。选它主要是“性价比高、信息全”编码器 AB 两相正好可以做方向判别和倍频计数如果你后面想搞里程计、定位也可以拿它当轮式编码器用。整套系统的学习价值就在于有 PWM 输出、有 GPIO 方向控制、有编码器脉冲采集、有定时器中断采样、有 PID 闭环几乎把一个嵌入式控制系统的所有基础模块都覆盖了一遍。2. 硬件原理与接线细节2.1 TB6612 模块引脚和真值表TB6612 模块通常是 16 脚芯片加一圈排针常见的功能引脚可以分三组看。电源和地VM 接电机电源正极VCC 接逻辑电源3.3V 或 5VGND 一定要共地。VM 和 VCC 不能接反接错轻则芯片烫手重则直接烧掉。电机输出AO1、AO2 接第一个电机BO1、BO2 接第二个电机。电机两根线没有严格正负极你只要在代码里规定好哪个逻辑电平组合是正转、哪个是反转就行。控制输入AIN1、AIN2、PWMA 控制 A 路电机BIN1、BIN2、PWMB 控制 B 路电机。STBY 是待机控制脚拉低进入待机模式电机不工作正常使用时必须拉高。TB6612 控制逻辑其实非常直观我做了个速查表AIN1AIN2PWMA输出状态10PWM正转速度由占空比决定01PWM反转速度由占空比决定11任意刹车输出短路00任意滑行输出高阻任意任意0停止类似滑行这个表非常关键很多不转的现象其实不是 PWM 没给而是 AIN1、AIN2 的电平组合不对。要是你发现电机嗡嗡响但轴不转多半就是两个方向脚给成了同一个电平驱动输出被刹死了。2.2 与 MSPM0G3507 的实际接线下面是我手上板子的接法芯片封装和引脚编号可能跟你的板子不一样但是思路完全一样。建议你在 SysConfig 里根据实际板子重新分配。主控侧需要做三件事两个 GPIO 输出方向电平一个定时器通道输出 PWM两个 GPIO 接编码器 AB 相。以 A 路电机为例我的映射是功能信号名MSPM0G3507 引脚说明A 路方向AIN1PA3GPIO 输出A 路方向AIN2PA4GPIO 输出A 路 PWMPWMAPA10TIMG0_C0 定时器输出通道A 路编码器 A 相ENC_APB2GPIO 上升沿中断A 路编码器 B 相ENC_BPB3GPIO 读取方向B 路电机同样接 TB6612 的 BIN1、BIN2、PWMB另外两个定时器通道输出 PWM。如果你只想驱动单电机把 B 路空着也没问题。注意 PWM 引脚不是普通 GPIO它必须对应定时器的输出比较通道。比如你把 PWMA 接到 PA10那在 SysConfig 里就要选 TIMG0并把对应通道映射到这个引脚上。这个映射关系查数据手册里的 PinMux 表就可以。2.3 供电方案是最容易翻车的环节供电是这个项目里最容易出问题的地方我要多说几句。我当时的供电方案是2S 锂电池7.4V供 VMMSPM0G3507 核心板通过自带 LDO 输出 3.3V 给 MCU 和 TB6612 的 VCC编码器需要 5V我从核心板 5V 输出脚取电。如果你用 3S 电池11.1V建议再外接一个降压模块降出一路 5V 给编码器不要让线性稳压器直接吃太高压差。接线注意三件事第一GND 必须全部共地。编码器的地、TB6612 的地、MCU 的地全部连到电源负极。这里出了问题编码器读数会乱跳PWM 也会受影响。第二VM 供电线尽量粗电池到 TB6612 的 VM 和 GND 用至少 24AWG 的线不要用杜邦线硬撑。电机的启动电流往往比额定电流大很多细线压降大会导致芯片内部欠压。第三编码器输出信号要仔细看。MG513X 电机常见批次里编码器 VCC 是红色GND 是黑色A 相白色B 相绿色但这不是绝对的不同批次颜色可能不一样。拿到电机第一件事不是查颜色而是用万用表找电源和地再用示波器或者逻辑分析仪看两个信号脚在电机转动时的波形。如果编码器输出是集电极开路那必须在信号线上加上拉电阻到 5V 或 3.3V。主控是 3.3V 逻辑编码器输出可能是 5V 电平直接接到 MSPM0G3507 引脚有一定风险。稳妥的做法是在 AB 相信号线上串一个 1kΩ 电阻或者用两三个电阻做分压把 5V 拉到 3.3V。如果板子引脚支持 5V 容忍datasheet 里标注 FT串个电阻再进 MCU 也够用了。3. MSPM0G3507 软件初始化与 PWM 生成3.1 开发环境与工程配置软件部分我用的是 TI 的 CCSTUDIOCCS搭配最新版 MSPM0 SDK。你用 Keil 也可以现在 Keil 也支持 MSPM0G3507Pack 在 MDK 的 pack installer 里直接搜能搜到配置方法差不多。新建工程建议直接用 SysConfig 图形化配置能省很多事。在 SysConfig 里你需要做四件事第一配置系统主频。MSPM0G3507 最高 80MHz直接把 BUSCLK 拉到 80MHz这样定时器时序计算更充裕。第二配置 PWM。新建两个 PWM 实例选择 TIMG0 的两个输出通道频率我习惯设 20kHz。为什么是 20kHz因为人耳可听范围是 20Hz 到 20kHzPWM 频率低于 10kHz 时电机和导线会发出明显的啸叫20kHz 以上基本听不到而且这个频率对 TB6612 来说完全没问题电源纹波也小。第三配置 GPIO。方向引脚设为输出模式编码器 AB 相引脚设为输入模式并打开内部上拉。如果要用中断把 A 相引脚配置为上升沿或双边沿触发中断。第四配置一个定时器中断。我习惯用 TIMG1让它每 10ms 进入一次中断在中断里做速度计算和 PID 更新。SysConfig 会自动生成ti_msp_dl_config.c和ti_msp_dl_config.h初始化代码都藏在里面你不需要自己写太多底层函数。但是你要明白它生成的每个函数是干嘛的不然后面调试起来两眼一抹黑。3.2 关键初始化代码解读PWM 初始化这部分SysConfig 生成后大概长这样#include ti_msp_dl_config.h int main(void) { SYSCFG_DL_init(); // 使能 PWM 输出 DL_TimerG_startCounter(PWM_LEFT_INST); while(1) { // 主循环等中断 } }SYSCFG_DL_init()会把时钟、GPIO、定时器全部初始化好。DL_TimerG_startCounter()是启动计数器这一步忘写 PWM 是没有输出的这是新手最容易漏的地方。设置占空比是通过写比较寄存器实现的。比如 PWM 周期是 50 个计数单位要让占空比达到 50%就把比较值设为 25DL_TimerG_setCaptureCompareValue(PWM_LEFT_INST, 25, DL_TIMERG_CAPTURE_COMPARE_0_INDEX);这个函数的第三个参数是通道索引TIMG0 的 C0 通道对应 PWM_LEFT_INST 里的 0 号比较器。你如果用了 C1 通道索引就填DL_TIMERG_CAPTURE_COMPARE_1_INDEX。这里还有个容易踩的坑寄存器里填的比较值范围必须小于等于 Auto Reload 寄存器里的周期值。我遇到过朋友把比较值写成 500但周期只有 200最后输出一直是满占空比电机疯狂转排查半天才定位到是溢出回绕了。3.3 我在初始化阶段踩过的坑第一条是 GPIO 方向引脚没有设置推挽输出。如果默认是开漏模式AIN1 高电平根本拉不上去会出现“有时候能转有时候不转”的诡异现象。SysConfig 里方向引脚务必备注成推挽输出。第二条是定时器时钟分频没算对。我看很多新手以为 PWM 频率是 80MHz 直接除以周期结果实际频率高得离谱GPIO 根本翻转不过来。合理做法是先用预分频把计数时钟降到 1MHz这样周期值直接就是频率倒数的微秒数20kHz 对应 5010kHz 对应 100非常直观。第三条是中断优先级。MSPM0 的 NVIC 里如果编码器中断和定时器中断抢优先级编码器丢脉冲会导致速度计算偏小PID 会傻乎乎地往上加输出。我习惯把编码器中断优先级设得比定时器中断高一些因为编码器中断丢了计数损失是不可逆的PID 中断晚个几微秒处理影响反而不大。4. TB6612 方向控制与电机输出逻辑4.1 方向控制怎么和 PID 输出关联PID 算出来是一个带正负号的输出量比如40表示正转占空比 40%-25表示反转占空比 25%。但是 TB6612 没法直接接收“负 PWM”你需要把负号翻译成方向脚电平void set_motor_speed(int16_t speed, uint8_t dir1_pin, uint8_t dir2_pin) { if (speed 0) { DL_GPIO_setPins(GPIOA, dir1_pin); DL_GPIO_clearPins(GPIOA, dir2_pin); } else { speed -speed; DL_GPIO_clearPins(GPIOA, dir1_pin); DL_GPIO_setPins(GPIOA, dir2_pin); } if (speed PWM_MAX) speed PWM_MAX; DL_TimerG_setCaptureCompareValue(PWM_LEFT_INST, speed, DL_TIMERG_CAPTURE_COMPARE_0_INDEX); }这段代码的本质就是把“数学上的符号”翻译成“硬件上的电平”。你把速度值直接传进去方向脚自动跟着变。这样 PID 输出的正负号就不用额外做映射控制逻辑非常干净。我建议你所有地方都用这个接口不要在中断里又改方向又改 PWM散得到处都是不好排查。所有电机控制入口统一到一个函数后面加刹车、限幅、故障保护都方便。4.2 死区、刹车和最低占空比直流电机的 PWM 有个很实际的物理现象占空比太低时电机克服不了静摩擦和齿轮箱阻力根本不会转。这个临界值我这边实测在 15% 左右也就是 50 周期计数里要大于等于 8 个计数单位。PID 在低速启动时如果输出只有 3%那电机就是纹丝不动编码器计数为 0PID 发现误差还在又继续加积分。这就是低速发抖的根源。解决办法有两个一是设置最小占空比PID 输出绝对值小于某个阈值时直接把输出拉到最小启动占空比二是做前馈开环测出让电机刚能稳定转动的占空比作为 PID 输出底衬。还有一个容易忽略的点PID 输出接近 0 时方向脚会频繁切换。比如上一个周期输出 3这一个周期输出 -2电机就会在正反转之间疯狂抖动电流还特别大。我的处理方式是在 0 附近加一个死区if (abs(speed) DEAD_ZONE) { speed 0; }死区范围我设的是 3太小了没用太大了低速控制会滞后需要自己实测平衡。另外 TB6612 的刹车模式偶尔能用上。如果你想让电机快速停住不需要 PID 慢慢减到 0直接把 AIN1、AIN2 都拉高让电机两端短路电磁制动会让轴立刻锁死。做机器人急停时这个很有用但刹车电流也大不建议长时间导通。5. 编码器脉冲采集与转速计算5.1 MG513X 编码器原理简析MG513X 的霍尔编码器本质是给电机尾部加了一个小磁环和两个霍尔传感器。电机轴转动时磁场交替变化两个霍尔元件分别输出 A、B 两路方波信号。A、B 相位相差 90°所以只要看 B 相电平就能知道当前转向。为什么需要编码器因为 PID 闭环必须要知道当前速度。电机轴上每转一圈编码器产生若干个脉冲。你数脉冲数就知道电机轴转了多少圈再除以采样时间就是转速。MG513X 常见的参数是减速比 1:30编码器线数 13 线/圈这个数字不同批次可能有差异。我做测速时采用了最简单的“A 相双边沿计数”方式A 相上升沿和下降沿都触发中断每个边沿读一次 B 相电平判断方向这样在 13 个物理脉冲基础上翻倍到了每电机轴转 26 个有效计数。再乘以减速比 30输出轴转一圈得到 780 个计数。有的朋友想更精确会在 A/B 两相都用双边沿中断那就是 4 倍频输出轴一圈 1560 个计数。计数分辨率越高低速测速越准但中断负载也越大。实测下来对于 MG513 这种减速电机780 计数/圈已经够用代码也更简单。5.2 用 GPIO 中断做脉冲计数先看最简单可靠的 A 相双边沿计数代码extern volatile int32_t g_motor_pulse; void GROUP1_IRQHandler(void) { if (DL_GPIO_getEnabledInterruptStatus(GPIOA, ENC_A_PIN) ! 0) { DL_GPIO_clearInterruptStatus(GPIOA, ENC_A_PIN); // 注意这里用最快的方式读取 B 相电平 if (DL_GPIO_readPins(GPIOB, ENC_B_PIN) ! 0) g_motor_pulse; else g_motor_pulse--; } }这个中断函数的写法有个前提代码执行要快不要在中断里做浮点运算、不要打印日志最好就是几行 GPIO 读写。每次进入中断脉冲计数加一或减一。方向判断的原理是A 相先到B 相电平为高则说明正转反之为反转。这个规则和你实际接线有关如果你发现方向正好反了把加改成减就行。定时器里每 10ms 读一次计数值int32_t pulse g_motor_pulse; g_motor_pulse 0; // 每圈 780 个计数采样周期 0.01s float speed_rpm (float)pulse * 60.0f / 780.0f / 0.01f;换算成转速公式是转速rpm 秒内脉冲数 × 60 / 每转脉冲数。把 10ms 内的脉冲数代进去就是上面那行。我这里再强调一点倍频数和每转计数一定要先标定。不同批次 MG513X 减速比可能标 1:30 实际却是 1:29.7你直接拿理论值算长时间累计里程会偏。正确做法是把轮子转一圈看编码器计数增加多少把这个实测值写进代码。5.3 定时器中断做速度采样速度采样周期我用 10ms这个时间要跟 PID 控制周期一致。太短了编码器一个周期没几个脉冲测速抖动大太长了PID 响应慢调速会有肉眼可见的超调。定时器中断里做三件事读脉冲计数、计算转速、调用 PID 更新。void TIMG1_IRQHandler(void) { if (DL_TimerG_getPendingInterrupt(TIMG1) DL_TIMERG_INTERRUPT_CC_0_DOWN) { int32_t pulse g_motor_pulse; g_motor_pulse 0; float speed_rpm (float)pulse * 60.0f / PULSE_PER_REV / SAMPLING_TIME; g_current_speed_rpm speed_rpm; g_pid_output pid_update(speed_pid, target_speed_rpm, speed_rpm); set_motor_speed((int16_t)g_pid_output); DL_TimerG_clearInterruptStatus(TIMG1, DL_TIMERG_INTERRUPT_CC_0_DOWN); } }有件事必须说清楚你在中断里读 g_motor_pulse 并清零是一个经典的生产者-消费者模型。如果主循环也去碰这个变量就可能出现“先清零后计数”的交错问题。所以最好只有编码器中断会修改计数只有速度定时器中断会清零其他代码一律只读不要写。6. PID 调速实现与参数整定6.1 为什么这里用增量式 PID位置式 PID 好理解但它有两个麻烦第一输出是绝对量计算过程中一旦饱和积分项会一直涨造成深度饱和第二输出是绝对值你从自动控制切到手动给值会有突变。增量式 PID 算的是“这个周期比上个周期要增加多少输出”公式是u(k) u(k-1) Kp × (e(k) - e(k-1)) Ki × e(k) Kd × (e(k) - 2e(k-1) e(k-2))实际写代码时我们不用真的保存过去的完整误差数组只要保存上一次和上上次的误差就行。增量式的好处是它天然对执行器友好因为每次只是微调占比不会突然从 50% 跳到 -50%而且积分饱和也好处理只要把累加结果限幅在 -255 到 255 之间饱和之后自然停止累加。当然增量式也不是万能的。如果你做位置控制比如机械臂关节角度位置式 PID 反而更直观。但咱们这里是速度控制增量式是主流选择。6.2 一个能直接复用的 PID 结构体我把 PID 封装成一个非常简单的结构体typedef struct { float kp; float ki; float kd; float target; float max_output; float integral; float last_error; float prev_error; float output; } PID_TypeDef; float PID_Update(PID_TypeDef *pid, float target, float current) { float error target - current; pid-integral error; float delta_error error - pid-last_error; float p_term pid-kp * error; float i_term pid-ki * pid-integral; float d_term pid-kd * (error - 2.0f * pid-last_error pid-prev_error); float output pid-output p_term i_term d_term; if (output pid-max_output) output pid-max_output; if (output -pid-max_output) output -pid-max_output; pid-prev_error pid-last_error; pid-last_error error; pid-output output; return output; }注意我把pid-integral error放在限幅之前还是之后这里其实算的是标准的积分累加。如果输出已经限幅饱和而误差又很大你就会一直累积分饱和。一个简单的抗饱和办法是输出饱和时往 0 方向修正积分项。下面的写法更实用一点点float output pid-kp * error pid-ki * pid-integral pid-kd * (error - pid-last_error); if (output pid-max_output) { output pid-max_output; pid-integral - error; // 取消本次累加起到抗饱和效果 } else if (output -pid-max_output) { output -pid-max_output; pid-integral - error; } else { pid-integral error; // 正常范围才累加 }这种做法的好处是积分永远不会超过输出限幅对应的值学术上叫“条件积分法”这个小技巧在很多工业控制器里都能见到。6.3 我的 PID 参数整定流程整定 PID 参数最忌讳一上来就三个参数一起调。我十分钟速调的办法是这样第一步先把 Ki 和 Kd 都置 0只留 Kp。然后给一个目标转速比如 100rpm。慢慢加大 Kp观察电机响应。如果你给 0.3电机缓慢爬升那就翻倍到 0.6如果电机开始周期性振荡说明 Kp 偏大了把它退回到临界振荡值的 0.6 倍。第二步加 Ki。Ki 的作用是消除静态误差让最终速度准确地落到目标值。我给一个很小的 Ki比如 Kp 的两百分之一然后观察稳态误差。如果你发现速度在目标值附近缓慢上下晃那是 Ki 过大减小一些。第三步最后加 Kd。速度环的微分项对噪声很敏感编码器测速本身就带一点抖动Kd 大了反而会放大抖动。除非你对响应速度要求特别高否则速度环的 Kd 往往可以取 0 或者一个很小的值。以我手里这套 MG513X 为例最终调出来的 Kp0.35Ki0.008Kd0.02。但注意这个参数只对同一款电机、同一个供电电压、同一种机械负载有意义。你换了电池电压响应就会变参数得重新调。如果你懒得手工调也可以做个简单的自动整定程序里通过串口调试助手远程改 Kp、Ki、Kd 和目标转速不用每改一次参数就重新烧录一次。我在代码里留了一个非常简单的串口协议收到P0.5\r\n就设置 Kp 为 0.5实测调参效率高了五倍不止。7. 完整代码框架和主流程7.1 主循环结构整个代码的结构其实就是“前后台系统”后台是 main 主循环前台是中断。主循环尽量少干活只做舵机控制、串口通信、数据显示这些不需要精确时间的任务所有跟实时性相关的控制逻辑全部放到定时器中断里。#include ti_msp_dl_config.h #include stdint.h #include math.h #define PULSE_PER_REV 780 #define SAMPLING_TIME 0.01f #define PWM_PERIOD 50 volatile int32_t g_motor_pulse 0; volatile float g_current_speed 0.0f; volatile float g_target_speed 80.0f; volatile float g_pid_output 0.0f; PID_TypeDef g_speed_pid; void set_motor_speed(int16_t speed); float PID_Update(PID_TypeDef *pid, float target, float current); int main(void) { SYSCFG_DL_init(); g_speed_pid.kp 0.35f; g_speed_pid.ki 0.008f; g_speed_pid.kd 0.02f; g_speed_pid.max_output 45.0f; g_speed_pid.target 0.0f; g_speed_pid.integral 0.0f; g_speed_pid.last_error 0.0f; g_speed_pid.prev_error 0.0f; g_speed_pid.output 0.0f; DL_TimerG_startCounter(PWM_LEFT_INST); while(1) { // 主循环负责打印状态、响应串口指令 // 不要把 PID 放这里 uint8_t ch; if (uart_receive_char(ch)) { process_uart_command(ch); } delay_ms(50); } }你可能注意到我把max_output设成了 45而不是 50。因为 50 对应 100% 占空比但电机直接满占空比启动电流冲击非常大也容易让 PID 严重饱和。留 5% 的余量对电池和驱动模块都友好一些。7.2 完整双电机速度闭环双电机小车底盘你需要两路 PID 和两路编码器计数。把代码从单路扩展成双路最直接的办法是写两个 PID 实例和两套计数变量但更好的方式是做一个数组#define MOTOR_LEFT 0 #define MOTOR_RIGHT 1 typedef struct { int32_t pulse; float target_speed; float current_speed; PID_TypeDef pid; } Motor_TypeDef; Motor_TypeDef g_motor[2];两个电机的编码器中断各写各的PID 定时器中断里则对数组循环处理。这样以后扩展三四轮车只要增加数组元素就行。void TIMG1_IRQHandler(void) { if (DL_TimerG_getPendingInterrupt(TIMG1) DL_TIMERG_INTERRUPT_CC_0_DOWN) { for (uint8_t i 0; i 2; i) { int32_t pulse g_motor[i].pulse; g_motor[i].pulse 0; float speed (float)pulse * 60.0f / PULSE_PER_REV / SAMPLING_TIME; g_motor[i].current_speed speed; float out PID_Update(g_motor[i].pid, g_motor[i].target_speed, speed); g_pid_output[i] out; if (i MOTOR_LEFT) set_motor_speed(out, LEFT_MOTOR); else set_motor_speed(out, RIGHT_MOTOR); } DL_TimerG_clearInterruptStatus(TIMG1, DL_TIMERG_INTERRUPT_CC_0_DOWN); } }注意我在这里做了一个非常实际的小优化每次定时器中断里把当前速度计算完后先存进current_speed再调 PID。这样主循环想显示转速、想传给里程计直接用这个变量就行不会出现读到一半被改掉的撕裂问题。7.3 为什么不在 while(1) 里做 PID很多从 Arduino 转过来的朋友习惯写loop()里面有 delay 的循环控制。但在 MSPM0 这种裸机工程里最好别在 while(1) 里写 PID。原因很简单主循环里一旦跑串口打印、OLED 刷新、超声波测距这些耗时操作循环周期就不是固定的 10ms 了。PID 的积分项假设采样周期恒定周期一旦抖动控制质量明显下降。更严重的是如果你在主循环里用delay(100)之后再读取编码器计数值去算速度那算出来的速度是一个“平均速度”实时性很差。所以我的原则是所有采样、计算、输出的周期都由硬件定时器保证主循环只做对实时性不敏感的事情。你后面加蓝牙、加 WiFi、加 IMU都扔主循环PID 不受任何影响。8. 常见问题与调试技巧实录8.1 电机不转先按顺序排查哪几个点我带的两个学弟做这个小车一共出现过五次“电机不转”的问题每次原因都不一样所以我把排查路径整理成一张表遇事直接按这个顺序查序号排查项怎么查1STBY 是否拉高量 TB6612 的 STBY 引脚电压不是 3.3V 就是被拉低了2AIN1/AIN2 电平是否正确代码里直接拉高 AIN1 拉低 AIN2看电机是否转动3PWM 引脚有没有波形用示波器或逻辑分析仪看 PA10 引脚没有波形说明定时器没启动4VM 和 VCC 电压是否正常VM 要等于电池电压VCC 要在 3.3V 左右5PWM 比较值是不是 0检查 PID 输出是否为 0是不是死在死区里6电机线是否接牢换根线直接短接电池测试电机本身好坏这里面最有迷惑性的是第一项很多成品的 TB6612 模块把 STBY 引脚留出来了但你没接或者接了 GND模块直接进待机模式所有输出都关了。我当时就犯过这个错查了半小时引脚电平最后发现是杜邦线插错到 GND 了。8.2 编码器读数为 0 或者疯狂乱跳编码器读数异常是最影响 PID 工作的故障它比电机不转更磨人。如果是 0八成是编码器 VCC 没接对或 GND 没共地。还有一个可能是 A 相没接上拉电阻输出一直是低电平中断永远不会触发。你把 A 相直接接到 3.3V看中断计数是否变化就能定位。如果是乱跳先看 B 相电平稳定不稳定。B 相悬空时读取结果会受电磁干扰随机变化方向判断自然就乱了。给 B 相也加上上拉再不行就加一个 100nF 电容到地做滤波。还有就是在电机电源线上并联一个 470uF 电解电容能大幅减小电刷产生的尖峰干扰。如果是方向反了这不是故障是接线相位反了。A、B 两相对调代码计数方向就反过来。你可以选择换线也可以把代码里的方向判断反过来效果一样。8.3 PID 调速的过冲和振荡PID 调好后最理想的响应是速度快速爬升到目标值不超调最后稳定。实际调试时经常见到的现象有三种第一种是速度像波浪一样周期性起伏。这种通常是积分项太大。你把 Ki 往下调比如从 0.01 降到 0.003波浪幅度会明显减小。第二种是启动瞬间猛冲越过目标再掉回来再冲过去。这是 Kp 太大或者微分没有起作用。检查 Kd 是否设置成了 0如果 Kd 是 0适当加一点 Kd会抑制这个“冲过头”的行为。当然 Kp 降下来也可以只是响应会慢。第三种是低速时来回振荡、吱吱响。这是死区和最小占空比没处理好。低速时即使 PID 输出 5%电机可能还没克服静摩擦下一周期误差又变大PID 又加大输出等真转起来了又超调。解决策略就是我在前面提到的最小占空比低于阈值直接给阈值越过临界区后回到 PID 正常输出。最后给一个很多教程不会提的细节PID 参数受机械结构影响很大同一个电机空载和装上轮子调出来的参数完全不同。所以调参前一定要先把小车架起来让轮子悬空调一个相对稳定的参数再放到地面微调 Ki 和死区。直接在地面上调你很难分清到底是 PID 参数问题还是地面摩擦变化问题。8.4 调试工具是效率倍增器我强烈建议你在项目一开始就把串口调试通道做好。用一块 USB 转串口模块把 MSPM0G3507 的 UART0 接到电脑每 50ms 打印一行目标:80.0 当前:79.3 输出:32.1 Kp:0.35 Ki:0.008 Kd:0.02然后配合串口绘图工具把当前速度和目标速度画成实时曲线。你会发现 PID 调参从“盲人摸象”变成“看着曲线调”效率高太多了。串口打印代码也不复杂就用 SDK 的 UART 阻塞发送void debug_print_speed(void) { char buffer[64]; int len snprintf(buffer, sizeof(buffer), target:%.1f current:%.1f out:%.1f\r\n, g_target_speed, g_current_speed, g_pid_output); DL_UART_Main_transmitData(UART0, (uint8_t *)buffer, len); }但注意别把串口打印放进 10ms 的中断里阻塞发送占时间。我都是把打印放主循环用 50ms 轮询一次。整个项目跑通之后剩下的扩展方向就多了在双电机速度闭环基础上做差速转向就是麦克纳姆轮底盘的控制核心把左右轮转速差和轮距换算成角速度就能解算小车当前位置在调速环外面再套一个位置环就是循迹车、仓储机器人的基本骨架。我自己做完这一步之后再回头看那些复杂的机器人底盘教程发现核心逻辑都是同一套定时器采编码器PID 调速度PWM 出占空比。先把这套基础打扎实后面什么底盘都不慌。