先把轮子转稳,才不会烧心:编码器速度环 PID 怎么接回 PWM

经典三问

Q: 速度环是什么?

A: 速度环用编码器测得的实际转速与目标转速比较,再调整 PWM 输出。

Q: 为什么先做速度环?

A: 速度稳定后,位置控制和循迹控制才有可预测的执行基础,左右轮差异也更容易补偿。

Q: 速度环常见使用场景?

A: 双轮小车直行、定速行驶、左右轮同步和上层位置/循迹控制的内层执行。


给左右两个电机写同样的 30% PWM,看起来很公平。车一落地,左轮先咬住地面,右轮慢半拍,车头开始往一边蹭。再过几分钟,电池电压掉了一点,刚才勉强能动的占空比又变成了嗡嗡响。

固定 PWM 在说"我给了多少力"。小车真正关心的是"轮子这 20ms 里到底走了多少"。编码器把这个结果送回来,PID 再决定下一轮 PWM 加一点还是减一点。终于,控制量回到了真实轮子上。

先留出两条完整的反馈路

这里用的每个霍尔编码器电机,都有电机两线和编码器的 VCC、GND、A、B 四线。商品页会给出 VCC 电压、每圈脉冲数和 A/B 的线序。别凭线的颜色猜,遇到同名电机换一家店,颜色就可能换一套。

参考引脚分配如下。它描述的是 STM32 这边的接口;TB6612、L298N 或其他双路驱动板的电机电源、使能脚和方向真值表,仍然按手上那块板的资料接。

用途 STM32 引脚 程序里的外设
左电机 PWM PA0 TIM2_CH1
右电机 PWM PA1 TIM2_CH2
左电机方向 PB12、PB13 GPIO 输出
右电机方向 PB14、PB15 GPIO 输出
TB6612 待机控制 PB10 GPIO 输出;L298N 不使用该脚
左编码器 A、B PA6、PA7 TIM3 编码器模式
右编码器 A、B PB6、PB7 TIM4 编码器模式
1ms 节拍 无外接引脚 TIM1 更新中断
OLED SCL、SDA PB8、PB9 软件 I2C

电机电源走电池和降压模块,STM32、驱动板和编码器的 GND 连在一起。TB6612 的 STBY 必须处于资料规定的使能状态,常见接法是拉到逻辑高电平;L298N 的 ENA、ENB 是否要拔跳帽、能不能直接接 PWM,也看手上板子。驱动板的 VM、电机额定电压、编码器 VCC 并不由这张表替你决定。

编码器信号进入 STM32 前,确认高电平没有超过芯片允许范围。有些编码器供电 5V、输出却是 3.3V 兼容;有些会把 A/B 拉到 5V。后一种情况先做电平转换或分压,别把"能读到一下"当成接法正确。

按键 A、B 已固定在 PB11、PB1,不与速度环争资源。右编码器必须使用 PB6/PB7:TIM4 在 STM32F103C8T6 的默认编码器映射就在这两根脚上,完整重映射需要 C8 没有引出的 PD12/PD13。OLED 接好后,这两根不适合反复插拔;整车阶段把它们用长跳线引到面包板边缘,一次接好即可。

20ms 里发生的事

速度不必一上来就换算成 RPM。每 20ms 读一次定时器计数器,把这段时间新增的脉冲数叫作 delta。目标也用同一把尺子:希望轮子 20ms 走 20 个计数,目标就是 20。

c 复制代码
#define SPEED_PID_PERIOD_MS     20U
#define SPEED_PID_TARGET_PULSE 20.0f
text 复制代码
第 k 个 20ms:

目标脉冲 ----> [ 增量式 PID ] ----> 左右 PWM
                    ^                 |
                    |                 v
实际脉冲 <---- 编码器计数 <------ 轮子转动

电机不动,实际脉冲接近 0,误差为正,PID 会慢慢抬高 PWM。轮子转得超过目标,误差反过来,下一轮 PWM 收回来。每次只看最近 20ms,轮胎打滑、载重变化、电池掉电都能在后面的循环里留下痕迹。

20ms 不是神奇数字。它足够快,能看见电机的变化;又不会快到每次只读到零星几个脉冲、曲线全是跳格。低速时可以拉长一些,高速或高线数编码器要考虑溢出和 CPU 时间。先固定一个周期,调参才有共同的地面。周期改成 10ms 后,原来那套 Kp、Ki、Kd 已经不再是同一套参数。

想换算 RPM 也只差一行:

c 复制代码
rpm = delta * 60000.0f /
      (counts_per_revolution * period_ms);

counts_per_revolution 要把编码器线数、正交倍频和减速比都算进去。第一次调速,我更推荐先盯 delta。它离 TIM3/TIM4 读出来的数只有一步,出了问题也少一层换算可以怀疑。

这套整车资源里,TIM1 专门提供 1ms 节拍,TIM2 专门输出两路电机 PWM,TIM3、TIM4 分别数左右编码器。Timer_Expired() 的用法不变,TIM1 每 1ms 进入一次更新中断:

c 复制代码
void TIM1_UP_IRQHandler(void)
{
    if (TIM_GetITStatus(TIM1, TIM_IT_Update) != RESET)
    {
        TIM_ClearITPendingBit(TIM1, TIM_IT_Update);
        Timer_Tick1ms();
    }
}

if (Timer_Expired(&control_time, 20))
{
    /* 每 20ms 进一次速度环 */
}

这三个函数各归各的:每进一次 1ms 中断就把计数加一;主循环想知道过去了多久就问计数,想知道某个周期到没到就把上次的时间戳交给比较。它们和具体外设无关,只认一个一直在走的毫秒数:

c 复制代码
volatile uint32_t system_ms = 0U;

void Timer_Tick1ms(void)
{
    system_ms++;
}

uint32_t Timer_Millis(void)
{
    return system_ms;
}

uint8_t Timer_Expired(uint32_t *last_ms, uint32_t period_ms)
{
    if ((uint32_t)(system_ms - *last_ms) >= period_ms)
    {
        *last_ms = system_ms;
        return 1U;
    }
    return 0U;
}

比较用减法而不是直接比大小,是因为 system_ms 会绕回来------减出来的差值在绕回那一次照样是对的。

定时器的功能没有消失,只是这次在整车开始前就把它们分开了:TIM1 敲节拍,TIM2 出 PWM,TIM3/TIM4 数 A/B 脉冲。这正是资源表存在的意义。

让两个定时器变成两只脉冲计数器

EncoderDual_Init() 做的事很少:给 PA6、PA7、PB6、PB7 配输入,分别让 TIM3 和 TIM4 进入编码器模式,再把计数器清零。定时器会看 A/B 两相的先后关系,自己判断正反方向,CPU 不用每个边沿都进中断。

c 复制代码
void EncoderDual_Init(void)
{
    GPIO_InitTypeDef gpio;

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA |
                           RCC_APB2Periph_GPIOB, ENABLE);

    /* PA6/PA7 -> TIM3,PB6/PB7 -> TIM4,两相都上拉输入 */
    gpio.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
    gpio.GPIO_Mode = GPIO_Mode_IPU;
    GPIO_Init(GPIOA, &gpio);
    GPIO_Init(GPIOB, &gpio);

    EncoderDual_InitTimer(TIM3);
    EncoderDual_InitTimer(TIM4);
}

static void EncoderDual_InitTimer(TIM_TypeDef *TIMx)
{
    TIM_TimeBaseInitTypeDef timer;
    TIM_ICInitTypeDef input;

    RCC_APB1PeriphClockCmd(
        (TIMx == TIM3) ? RCC_APB1Periph_TIM3 : RCC_APB1Periph_TIM4,
        ENABLE);

    timer.TIM_Prescaler = 0;
    timer.TIM_CounterMode = TIM_CounterMode_Up;
    timer.TIM_Period = 0xFFFF;
    timer.TIM_ClockDivision = TIM_CKD_DIV1;
    timer.TIM_RepetitionCounter = 0;
    TIM_TimeBaseInit(TIMx, &timer);

    TIM_EncoderInterfaceConfig(TIMx, TIM_EncoderMode_TI12,
                               TIM_ICPolarity_Rising,
                               TIM_ICPolarity_Rising);

    TIM_ICStructInit(&input);
    input.TIM_Channel = TIM_Channel_1;
    input.TIM_ICFilter = 6;
    TIM_ICInit(TIMx, &input);
    input.TIM_Channel = TIM_Channel_2;
    TIM_ICInit(TIMx, &input);

    TIM_SetCounter(TIMx, 0);
    TIM_Cmd(TIMx, ENABLE);
}

int16_t EncoderDual_ReadDelta(TIM_TypeDef *TIMx)
{
    int16_t delta = (int16_t)TIM_GetCounter(TIMx);

    TIM_SetCounter(TIMx, 0);
    return delta;
}

int16_t EncoderDual_ReadLeftDelta(void)
{
    return EncoderDual_ReadDelta(TIM3);
}

int16_t EncoderDual_ReadRightDelta(void)
{
    return EncoderDual_ReadDelta(TIM4);
}

EncoderDual_ReadDelta() 读取当前计数值后立即清零。下一次读到的自然就是下一个 20ms 内新增加的量。把 16 位无符号计数器强转成 int16_t,反转时计数器绕回来的值会以负数形式出现;20ms 内的计数通常远小于 32767,不会碰到这条边界。

四个宏留给硬件方向差异:

c 复制代码
#define ENCODER_LEFT_REVERSED   0U
#define ENCODER_RIGHT_REVERSED  0U

#define MOTOR_LEFT_REVERSED     0U
#define MOTOR_RIGHT_REVERSED    0U

电机"正转"和编码器"正数"必须指向同一个车头方向。机械装配会让右电机的正方向和左边相反,编码器 A/B 的线序也可能相反。把这种差异收进这里,主循环里的 target - actual 才能一直按同一套约定理解。反着接时,先改一个方向宏,别急着拿 Kp 去堵。

四个数字只看四件事

OLED 固定显示四行数字:

text 复制代码
左目标,左实际,右目标,右实际

OLED 按这个顺序放四行:左目标、左实际、右目标、右实际。数字刷新得没有电脑曲线那么快,但四个值谁在追、谁没追上,一眼能对上。

控制循环就在 main.c:

c 复制代码
if (Timer_Expired(&control_time, SPEED_PID_PERIOD_MS))
{
    left_actual = EncoderDual_ReadLeftDelta();
    right_actual = EncoderDual_ReadRightDelta();

    left_output = IncrementalPID_Update(&left_pid,
                                        left_target,
                                        (float)left_actual);
    right_output = IncrementalPID_Update(&right_pid,
                                         right_target,
                                         (float)right_actual);

    Motor_SetTargetPercent(FloatToPercent(left_output),
                           FloatToPercent(right_output));
    Motor_Update();
}

PID 算出来的是 float,而 Motor_SetTargetPercent() 要的是整数百分比。转换只有一步,但单独写成一个函数,免得以后两处各用一种取整方式:

c 复制代码
int16_t FloatToPercent(float value)
{
    return (int16_t)value;
}

控制循环最后那一下 Motor_Update(),就是把这两个目标值写到硬件上:先把这一路的比较值清零,再定方向,最后写新的比较值------顺序和 PWM 那篇一样,为的是不让转子还朝着一个方向转、方向脚却先被翻过来。两路各写一遍,所以抽成一个小函数。目标值、限幅和写入都在同一个模块里:

c 复制代码
static int16_t motor_left_target = 0;
static int16_t motor_right_target = 0;

static int16_t Motor_LimitPercent(int16_t percent)
{
    if (percent > 100)
    {
        return 100;
    }
    if (percent < -100)
    {
        return -100;
    }
    return percent;
}

static void Motor_WriteChannel(uint8_t channel, int16_t percent)
{
    uint16_t compare;

    if (channel == 0U)
    {
        TIM_SetCompare1(TIM2, 0);
    }
    else
    {
        TIM_SetCompare2(TIM2, 0);
    }

    if (percent >= 0)
    {
        if (channel == 0U)
        {
            GPIO_SetBits(GPIOB, GPIO_Pin_12);
            GPIO_ResetBits(GPIOB, GPIO_Pin_13);
        }
        else
        {
            GPIO_SetBits(GPIOB, GPIO_Pin_14);
            GPIO_ResetBits(GPIOB, GPIO_Pin_15);
        }
        compare = (uint16_t)percent;
    }
    else
    {
        if (channel == 0U)
        {
            GPIO_ResetBits(GPIOB, GPIO_Pin_12);
            GPIO_SetBits(GPIOB, GPIO_Pin_13);
        }
        else
        {
            GPIO_ResetBits(GPIOB, GPIO_Pin_14);
            GPIO_SetBits(GPIOB, GPIO_Pin_15);
        }
        compare = (uint16_t)(-percent);
    }

    compare = (uint16_t)((compare * MOTOR_PWM_PERIOD) / 100U);

    if (channel == 0U)
    {
        TIM_SetCompare1(TIM2, compare);
    }
    else
    {
        TIM_SetCompare2(TIM2, compare);
    }
}

void Motor_Update(void)
{
    Motor_WriteChannel(0U, motor_left_target);
    Motor_WriteChannel(1U, motor_right_target);
}

两个目标值在这里叫 motor_left_target/motor_right_target,不叫 left_target:这里是速度环,left_target 已经被拿去表示目标脉冲数了,一个名字不能同时表示两种量。Motor_SetTargetPercent() 存的就是这两个百分比,Motor_LimitPercent() 负责把越界的数夹回 -100 到 100。

Motor_Stop() 是同一个写法的"全零版":两路比较值清零,四个方向脚一起回到低电平。TB6612 和 L298N 在 IN1=0、IN2=0、PWM=0 时到底是滑行、关闭还是制动,仍然按各自真值表看:

c 复制代码
void Motor_Stop(void)
{
    TIM_SetCompare1(TIM2, 0);
    TIM_SetCompare2(TIM2, 0);

    GPIO_ResetBits(GPIOB, GPIO_Pin_12 | GPIO_Pin_13 |
                          GPIO_Pin_14 | GPIO_Pin_15);
}

左、右各有一个增量式 PID。它们的目标一样,实际值和输出各管各的。两边同样写成 20,不会强行让它们吃同一个 PWM;左轮实际少一点,左 PID 会继续往上补,右边早到目标就收一点。

首次下载时,下面这个宏是 0:

c 复制代码
#define SPEED_PID_ENABLE_MOTOR 0U

所以 OLED 上会出现目标 20、实际接近 0 的画面,轮子仍然停着。它是在确认 OLED、编码器初始化和停机路径都还活着。车架空、方向验证完成后,才改成 1U 重新下载。

先用 20% PWM 问清楚方向

别把第一次 PID 输出交给一辆还没有确认方向的小车。把车架空,保证轮子没有碰到桌面、线材不会卷进轮轴。保留 SPEED_PID_ENABLE_MOTOR = 1U,只把下面一项改为 1U:

c 复制代码
#define SPEED_PID_DIRECTION_TEST 1U

这个模式绕开 PID,固定给左右电机 20% PWM。OLED 的目标行会停在 0,实际行显示原始脉冲;此刻数字不是跟踪结果,只用来检查方向。

正常起点是:两个轮子朝你定义的"前进"方向转,左实际和右实际的符号也一致。记录四列数据和轮子转向。

只改 ENCODER_LEFT_REVERSED 为 1U,再次下载,左轮的实际列应该翻转符号,右列保持原样。把它改回 0U。这轮操作证明了"符号"在软件边界里被处理,没有把方向补丁散落进 PID 公式。

如果轮子朝后转,改对应的 MOTOR_*_REVERSED;轮子朝前、实际脉冲却是负数,改对应的 ENCODER_*_REVERSED。每次只动一个宏,重新观察 OLED 的实际值。两个方向都对上,再把 SPEED_PID_DIRECTION_TEST 恢复 0U。

这一步漏掉,后面很容易看到"越慢就越加 PWM、越加却越慢"的怪画面。PID 接到反号反馈后会尽职尽责地把输出顶满,电机快速向错误方向冲。那不是 Kp 太大,是公式里的减号拿到了错误的方向。

第一轮闭环,先别让 Kd 出场

恢复方向测试宏,先用这组保守起点:

c 复制代码
IncrementalPID_Init(&left_pid, 1.0f, 0.15f, 0.0f,
                    -100.0f, 100.0f);
IncrementalPID_Init(&right_pid, 1.0f, 0.15f, 0.0f,
                    -100.0f, 100.0f);

上面那个函数只做两件事:把系数和边界填进结构体,再把状态清零。增量式用不到积分累积和 i_max,这里给一个安全值,免得同一个结构体将来拿去做位置式时读到没有初始化的数:

c 复制代码
void IncrementalPID_Init(PID *pid, float kp, float ki, float kd,
                         float out_min, float out_max)
{
    pid->Kp = kp;
    pid->Ki = ki;
    pid->Kd = kd;
    pid->out_min = out_min;
    pid->out_max = out_max;
    pid->i_max = 100.0f;

    pid->output = 0.0f;
    pid->prev_error = 0.0f;
    pid->prev_prev_error = 0.0f;
    pid->out_p = 0.0f;
    pid->out_i = 0.0f;
    pid->out_d = 0.0f;
}

参数没有通用答案。编码器的分辨率、减速比、电池、轮径、负载都会换掉它们。这里故意把 Kd 设成 0,先把电机速度环里最常用的 Ki、Kp 看清。

先把 Kp 临时改成 0.0f,保留一个很小的 Ki。架空运行 4 秒,再放到低速地面上运行,分别记录四条曲线。实际脉冲低于目标时,Ki 会一轮一轮累加输出,直到轮子能克服静摩擦;若目标附近拖得很久、目标已下降实际还迟迟不肯下来,Ki 已经记了太多旧误差。

架空和落地必须分开记参数。架空时轮胎没有压住地面,电机只要克服轴承、齿轮和自身的阻力就能转;落地后,轮胎和地面的静摩擦先把 PWM 吃掉一截,车身重量又把负载压到齿轮上。我在桌上见过 8% PWM 已经有脉冲,放到地上却只听到嗡声。这个"起转门槛"不是编码器坏了,轮子确实还没有动。

我会这样做:先架空确认方向和反馈符号,记录一组能稳定跟踪的 Kp、Ki;再把车放到平整地面,目标从很小的脉冲开始,每次只提高一点,单独记录左右轮开始连续转动的 PWM。落地后的起转值通常比架空高,左右还可能不同。速度环最终要在地面参数上收敛,架空数据只负责帮你排除接线、方向和计数器问题。

恢复 Kp,从小数值一点点增加。曲线靠近目标明显变快,继续加到实际值开始在目标两侧大幅摆动时,记下那一组数,不要顺手再塞一个 Kd 进去。先退回上一次稳定的 Kp。电机振动、齿隙和脉冲跳格本来就会让实际曲线有细小锯齿,靠软件把每一格磨平不现实。

调 PID 时很容易走进一条岔路:Kp 大了摆,就加 Kd;Kd 把噪声放大,又加 Kp 补回来。参数在"面多了加水,水多了加面"里越转越远。速度环的这组增量式 PID,很多底盘只用 Ki 和 Kp 就够了。Kd 留在 0 不是少学了一个字母,是一次有根据的取舍。

三个可以亲手做的坏实验

下面的实验从能正常跑的速度环开始。每次先截图或记下参数,只动标出的一个点,跑完立刻恢复。具体波形受底盘影响很大,文章里不替任何一辆车宣布"必然长成某个形状"。

把一个编码器符号翻过去

车保持架空,SPEED_PID_ENABLE_MOTOR = 1U,SPEED_PID_DIRECTION_TEST = 1U。正常画面里,两个实际列的符号与前进约定一致。

只把 ENCODER_LEFT_REVERSED 改成 1U,重新下载。左轮机械方向没有变,左实际列应当变为相反符号;右实际列仍保留原符号。此时 PID 仍被方向测试绕开,能安全地把"错符号"看见。

把 ENCODER_LEFT_REVERSED 恢复 0U,确认左实际回到原符号,再退出方向测试。这个实验把正反馈的根源钉在编码器数值上:PID 只相信传进来的 actual,它看不到轮子到底向前还是向后。

Kp 加过头

闭环能稳定跟踪后,保留 Ki 和控制周期,只把左右 IncrementalPID_Init() 的 Kp 增大一小档。记录目标附近实际脉冲是更快贴住目标,还是跨过目标后左右摆动。重复一两档,出现明显摆动就停,不需要把车调到剧烈甩动才算看见问题。

OLED 上四行数字会来回跳,跳的幅度和时间长度就是摆动的现场。恢复到上一次稳定的 Kp,重新跑一轮,让数字回到可接受范围。这个来回说明 Kp 不是"越大越灵敏"的开关,过大的补偿会抢在轮子真正反应之前下下一道命令。

观察 OLED 里的目标速度和实际速度。实际速度反复越过目标时,PWM 占空比也会跟着一上一下地修正;这比只听电机声音更容易分清是控制在抖,还是机械结构在响。

让积分面对一次目标下降

c 复制代码
#define SPEED_PID_STEP_TEST 0U

正常闭环下保持 SPEED_PID_STEP_TEST = 0U,先保存一段曲线。然后只把它改成 1U,复位后运行。前 4 秒目标仍是 20;第 4 秒以后,SpeedPID_GetTarget() 把目标降到 5:

c 复制代码
float SpeedPID_GetTarget(void)
{
#if SPEED_PID_STEP_TEST
    if (Timer_Millis() >= 4000U)
    {
        return 5.0f;
    }
#endif
    return SPEED_PID_TARGET_PULSE;
}

OLED 的左、右目标行会一起落下。盯着下面的实际行,记录它们多久回到新目标附近;再把 Ki 临时加大一档,重复同样的 4 秒阶跃。若下降后实际值拖得更长,积分留下的历史推动仍在参与输出。恢复原来的 Ki 和 SPEED_PID_STEP_TEST = 0U。

这里不靠"把 Ki 设为零"逃开问题。积分限幅、条件积分、目标切换时复位积分,都是处理这个问题的工具;先让曲线把旧误差留下来的尾巴露出来。

最后橘猫说

SPEED_PID_ENABLE_MOTOR 是第一道停机开关。改为 0U、重新下载,代码每个 20ms 都会调用 Motor_Stop(),两路 PWM 比较值归零。它适合首次上电、接线改变、参数跑偏后的硬停。

临时停止实验时,把车架空,先让闭环稳定运行,再把 SPEED_PID_ENABLE_MOTOR 改回 0U 下载。观察 OLED 的实际脉冲逐渐回到 0、目标仍停在设定值,轮子停止。恢复到 1U 前,再检查方向宏和目标脉冲。

完整小车还会有更高一层的停止条件:遥控失联、全黑赛道、障碍物、低电压和软件故障。它们应该直接把目标置零、复位 PID 状态并停止电机,而不是寄希望于"当前 Kp 小一点就不会出事"。控制器负责让轮子跟目标,安全逻辑负责决定什么时候根本不该有目标。

轮子能稳住之后,编码器不只会报出"这一小段走了多快"。把每次的 delta 累加起来,它还会变成位置。再把八路循迹读到的偏移写成位置误差,原来那组写死的左右差速也终于可以退场。

相关推荐
smartpi_ai2 小时前
51 单片机写好了,大模型语音怎么接?17T 全驱动的现成方案、双板串口分工的边界与画板前的三项硬件确认
单片机·嵌入式硬件·学习
我先去打把游戏先2 小时前
NimBLE是什么-ESP32蓝牙协议栈详解
单片机·嵌入式硬件·ubuntu
Gongsion3 小时前
30V输入耐压|1.5μA超低静态电流|200mA输出,CSM52系列:线性稳压器LDO,12V母线转3.3V/5V传感器、MCU弱电支路可靠稳压方案
单片机·嵌入式硬件·车载系统·无人机·智能家居
Escalating_xu5 小时前
【C 语言】编译和链接:从源文件、目标文件到可执行程序完整解析
c语言·开发语言
C++ 老炮儿的技术栈5 小时前
std::atomic 是什么
linux·c语言·网络·c++·visual studio
Logic1016 小时前
C语言/数据结构位运算题解:异或XOR找出数轴移动中只出现一次的指令——只出现一次的数字
c语言·数据结构·数组·位运算·时间复杂度·算法题·异或性质
weixin_462901976 小时前
CubeIDE串口重定向 printf
c语言
芯岭技术6 小时前
XL2400T收发芯片,2.4G无线荧光棒控光解决方案,+PA距离可达700M
单片机·嵌入式硬件·物联网
人邮异步社区7 小时前
C语言好学,还是Python好学?
c语言·开发语言·python