8. 有刷电机多环控制实现

通过前面章节的学习,我们已经大致了解速度环、电流环、位置环等使用方法。通过简单的介绍,对各种反馈环节如何去选择也有了一定的了解。 在实际的生产生活中,单闭环控制系统并不是最常使用的,因为实际环节里,往往存在着许多的干扰等各种因素。

简单举例,汽车在爬坡过程中,想要汽车有足够的力量爬坡,又想速度又快又稳。 在类似的场景中,如果想要达到良好的调节效果,往往会使用多环控制。 以前面的举例来说,在整个控制闭环内,速度环的后级,再串上一个电流环,以前级的输出(这里指速度环的输出), 作为后级的输入(这里指作为电流环的输入),最终后级(电流环)的输出最终实现在执行机构上,以形成双反馈控制的效果。 下面框图可能可以帮助理解串级PID的组织结构。

../../_images/速度电流环.jpg

野火提供的电机开发板配套例程中,有两环和三环的串级PID控制例程,串级PID的实现原理大同小异。 在本章中,我们通过前面学习的位置式PID和单环PID调整知识,来实现速度环、电流环、位置环三环串级PID控制, 如果还不知道什么是位置式PID和各种反馈环的实现D,请务必先学习前面的章节。 下面我们通过讲解如何使用位置环、速度环、电流环三环控制,来领略多环控制的魅力。

8.1. 硬件设计

本章实验需要连接开发板和驱动板,这里给出接线表。

8.1.1. MOS管搭建驱动板

电机与MOS管搭建驱动板连接见下表所示。

电机与MOS管搭建驱动板连接

电机

MOS管搭建驱动板

M+

M+

5V

编码器电源:+

GND

编码器电源:-

A

A

B

B

M-

M-

MOS管搭建驱动板与主控板连接见下表所示。

MOS管搭建驱动板与主控板连接

MOS管搭建驱动板

主控板

PWM1

PE11

PWM2

PE11

编码器A端

PB06

编码器B端

PB07

SD

PB15

信号检测-电流

PA03

信号检测-电压

PA01

电源输入:5V

5V

电源输入:GND

GND

推荐使用配套的牛角排线直接连接驱动板和主控板。连接开发板的那端,请连接在“无刷电机驱动接口1”上。

8.2. 直流电机速度环、电流环、位置环三环串级PID控制-位置式PID实现

8.2.1. 编程要点

在串级PID控制中,最外环一般选择期望控制的参数的环节,例如对应速度快慢的速度环、位置的位置环、电流大小的电流环大小, 本代码的选择位置环作为最外环,位置作为控制量,期望控制电机实际位置。在主函数中,可以看出这点。

  1. 配置基本定时器可以产生定时中断来执行PID运算

  2. 编写位置式PID算法

  3. 编写位置环、速度环、电流环控制函数

  4. 增加上位机曲线观察相关代码

  5. 编写按键控制代码

8.2.2. 软件设计

8.2.2.1. 新建工程

我们直接在提高部分的直流有刷章节的 “208_Motor_BDC_PID_Location_Positional” 例程的基础上修改程序。

对于 e2 studio 开发环境:

拷贝一份我们之前的 e2s 工程 “208_Motor_BDC_PID_Location_Positional”, 然后将工程文件夹重命名为 210_Motor_BDC_PID_Three_Ring,最后再将它导入到我们的 e2 studio 工作空间中。

对于 Keil 开发环境:

拷贝一份我们之前的 Keil 工程 “208_Motor_BDC_PID_Location_Positional”, 然后将工程文件夹重命名为 210_Motor_BDC_PID_Three_Ring,并进入该文件夹里面双击 Keil 工程文件,打开该工程。

工程文件结构如下。

文件结构
210_Motor_BDC_PID_Three_Ring
├─ ......
└─ src
   ├─ adc
   │  ├─ motor_v_c_acquisition.c
   │  └─ motor_v_c_acquisition.h
   ├─ beep
   │  ├─ bsp_beep.c
   │  └─ bsp_beep.h
   ├─ debug_uart
   │  ├─ bsp_debug_uart.c
   │  └─ bsp_debug_uart.h
   ├─ encoder
   │  ├─ bsp_encoder.c
   │  └─ bsp_encoder.h
   ├─ gpt
   │  ├─ bsp_gpt_pwm_output.c
   │  └─ bsp_gpt_pwm_output.h
   │  ├─ bsp_gpt_timing.c
   │  └─ bsp_gpt_timing.h
   ├─ key
   │  ├─ bsp_key_irq.c
   │  └─ bsp_key_irq.h
   ├─ led
   │  ├─ bsp_led.c
   │  └─ bsp_led.h
   ├─ motor_controls
   │  ├─ bsp_motor_control.c
   │  └─ bsp_motor_control.h
   ├─ pid
   │  ├─ bsp_pid.c
   │  └─ bsp_pid.h
   ├─ protocol
   │  ├─ protocol.c
   │  └─ protocol.h
   └─ hal_entry.c

8.2.2.2. FSP配置

FSP配置只是将之前的内容添加进来,也就是ADC、DMAC,新添加的文件及FSP配置都在之前章节有所讲解,在这里不再赘述,详细配置可以查看配套例程。

8.2.2.3. 定时器中断函数

定时器如何配置前面章节多次提到,这边就略过不讲了,只展示部分不同的代码。如有疑问,之前的章节有详细讲解,代码也非常简单易懂。

该代码通过获取当前的脉冲计数周期 pulse_period 和上一次周期 last_pulse_period 的差值, 计算转轴速度 shaft_speed,其中乘以 40 是因为时间单位为 25ms(1秒=40个25ms)。 随后,根据实时采样的电流值 current,调用 motor_pid_control 函数, 将转轴方向计数 pulse_direction、转轴速度和电流值一并传入,用于电机位置环控制的调整。 最后,更新 last_pulse_period 以便为下一次周期计算准备数据。

bsp_motor_control.c-定时器中断函数
/* GPT定时器中断回调函数 */
void gpt0_timing_callback(timer_callback_args_t *p_args)
{
    static uint32_t last_pulse_period = 0; // 上一次的脉冲周期
    static uint32_t new_period = 0; // 当前脉冲周期
    static float shaft_speed = 0.00f; // 转轴速度

    if (TIMER_EVENT_CYCLE_END == p_args->event)  // 检查定时器周期结束事件
    {
        // 计算实际电流值
        float current = GET_ADC_CURR_VAL(GET_ADC_VDC_VAL((float)(filtered_current)));

        // 更新最近的脉冲周期
        new_period = (pulse_period - last_pulse_period);

        // 计算转轴速度(单位:转/秒)
        shaft_speed = SHAFT_SPEED(new_period) * 40.0f;

        motor_pid_control((float)pulse_direction,shaft_speed,current);
    }
        // 更新脉冲周期
        last_pulse_period = pulse_period;
}

8.2.2.4. PID算法初始化

与前面章节不同的是,由于引入了三环控制(位置环、速度环、电流环),所以在PID初始化时,对应的有三套PID参数,同理两环有两套。具体看代码。

从代码中,可以看到三套PID参数配置结构体,它们分别是位置环、速度环、电流环的PID参数配置, 每套 PID 参数均包括目标值、实际值、误差等变量的初始化,以及 KpKiKd 系数的设置。 这三套配置,需要我们从内环到外环依次的调参。以本章工程为例,本章工程是位置环作为最外环, 电流环作为最内环,所以进行PID调参时,从电流环开始调参。

bsp_pid.c-位置式PID算法初始化
/**
  * @brief  PID参数初始化
    *   @note   无
  * @retval 无
  */
void PID_param_init(void)
{
        /* 位置相关初始化参数 */
        pid_location.target_val=0.0;
        pid_location.actual_val=0.0;
        pid_location.err=0.0;
        pid_location.err_last=0.0;
        pid_location.integral=0.0;

        pid_location.Kp = 0.015f;//0.0068
        pid_location.Ki = 0.001f    ;//0.01
        pid_location.Kd = 0.0;

        /* 速度相关初始化参数 */
        pid_speed.target_val=50.0;
        pid_speed.actual_val=0.0;
        pid_speed.err=0.0;
        pid_speed.err_last=0.0;
        pid_speed.integral=0.0;

        pid_speed.Kp = 5.0;
        pid_speed.Ki = 0.03f;
        pid_speed.Kd = 5.00;

        /* 电流相关初始化参数 */
        pid_curr.target_val=50.0f;
        pid_curr.actual_val=0.0;
        pid_curr.err=0.0;
        pid_curr.err_last=0.0;
        pid_curr.integral=0.0;

        pid_curr.Kp = 0.0;
        pid_curr.Ki = 3.5f;//5.5
        pid_curr.Kd = 0.00;

        float pid_temp[3] = {pid_location.Kp, pid_location.Ki, pid_location.Kd};
        set_computer_value(SEND_P_I_D_CMD, CURVES_CH1, pid_temp, 3);     // 给通道 1 发送 P I D 值

        pid_temp[0] = pid_speed.Kp;
        pid_temp[1] = pid_speed.Ki;
        pid_temp[2] = pid_speed.Kd;
        set_computer_value(SEND_P_I_D_CMD, CURVES_CH2, pid_temp, 3);     // 给通道 2 发送 P I D 值

        int temp = (int)pid_speed.target_val;
        pid_temp[0] = pid_curr.Kp;
        pid_temp[1] = pid_curr.Ki;
        pid_temp[2] = pid_curr.Kd;
        set_computer_value(SEND_P_I_D_CMD, CURVES_CH3, pid_temp, 3);     // 给通道 3 发送 P I D 值
}

8.2.2.5. 位置环PID算法实现

代码的实现与前面章节基本相似,只是添加了一些特殊的计算。

因为使用PID控制,实际的控制过程中常常会遇到一些问题,例如积分饱和、死区处理。 这就要求我们要在PID运算中,对计算过程加以干预,防止控制效果达不到预期。 进行常见的数据处理方式有许多。这里只讲本工程中遇到的两种问题处理的方法。

闭环死区,是指执行机构的最小控制量,无法满足控制需求产生的。 举个例子,假设有个水池,你期望控制水龙头让水从水池以1.5L每秒的流速流出, 但是你买的水龙头流量太大了,水龙头按最小刻度拧一下都会让流速增加1L每秒。 最终流速只能控制在1L每秒或2L每秒,始终无法达到预设值。这1.5L小数点后的范围内,就是闭环死区,系统是无法控制的。 如果不限定闭环,因为始终无法达到目标值,误差会一直存在,容易发生震荡现象。一般情况下要是系统要求的精确度不高, 就可以设定闭环死区来解决。还是以上面为例,如果说水1L或2L每秒的流速流出也是能接受的,就可以认为只要实际值和目标值的误差在2分之一升以内, 就没有误差,将目标值与实际值之差赋值为0,这就限定了闭环死区。

积分饱和的处理。积分饱和,就是执行机构达到极限输出能力了,仍无法到达目标值, 在很长一段时间内无法消除静差造成的。简单举例,就是电机满功率运行,仍达不到期望转速,在一段时间内没有到达目标值, 这时候PID的积分项累计了很大的数值,如果这时候到达了目标值或者重新设定了目标值,由于积分由于累计的误差很大, 系统并不能马上稳定到目标值,并会造成严重的超调或失调的现象。 解决办法有很多,代码中使用了积分分离的方法,在累计误差大于一定值后去掉积分项的作用。

bsp_pid.c-PID算法实现
/**
  * @brief  位置PID算法实现
  * @param  actual_val:实际值
    *   @note   无
  * @retval 通过PID计算后的输出
  */
float location_pid_realize(_pid *pid, float actual_val)
{
    /*计算目标值与实际值的误差*/
    pid->err = pid->target_val - actual_val;

    /* 限定闭环死区 */
    if((pid->err >= -20) && (pid->err <= 20))
    {
        pid->err = 0;
        pid->integral = 0;
    }

    /* 积分分离,偏差较大时去掉积分作用 */
    if (pid->err > -1500 && pid->err < 1500)
    {
        pid->integral += pid->err;    // 误差累积

        /* 限定积分范围,防止积分饱和 */
        if (pid->integral > 4000)
            pid->integral = 4000;
        else if (pid->integral < -4000)
            pid->integral = -4000;
    }

        /*PID算法实现*/
    pid->actual_val = pid->Kp * pid->err +
                      pid->Ki * pid->integral +
                      pid->Kd * (pid->err - pid->err_last);

        /*误差传递*/
    pid->err_last = pid->err;

        /*返回当前实际值*/
    return pid->actual_val;
}

8.2.2.6. 速度环PID算法实现

由于编码器精度原因,当实际值和目标值的偏差小于编码区能测量得到的最小精度时,就认为目标值与实际值没有偏差,pid->err为0。 其他部分与之前的计算方法没有什么不同。

bsp_pid.c-PID算法实现
  /**
  * @brief  速度PID算法实现
  * @param  actual_val:实际值
  * @note     无
  * @retval 通过PID计算后的输出
  */
  float speed_pid_realize(_pid *pid, float actual_val)
  {
          /*计算目标值与实际值的误差*/
      pid->err = pid->target_val - actual_val;

      if((pid->err<0.2f ) && (pid->err>-0.2f))
          pid->err = 0.0f;

      pid->integral += pid->err;    // 误差累积

          /*PID算法实现*/
      pid->actual_val = pid->Kp * pid->err +
                      pid->Ki * pid->integral +
                      pid->Kd * (pid->err - pid->err_last);

          /*误差传递*/
      pid->err_last = pid->err;

          /*返回当前实际值*/
      return pid->actual_val;
  }

8.2.2.7. 电流环PID算法实现

由于ADC采集电流精度原因,当实际值和目标值的偏差小于ADC能测量得到的最小精度时,就认为目标值与实际值没有偏差,pid->err为0。

bsp_pid.c-PID算法实现
  /**
  * @brief  电流环PID算法实现
  * @param  actual_val:实际值
  * @note     无
  * @retval 通过PID计算后的输出
  */
  float curr_pid_realize(_pid *pid, float actual_val)
  {
          /*计算目标值与实际值的误差*/
      pid->err=pid->target_val-actual_val;

      pid->integral += pid->err;    // 误差累积

      if (pid->err > -5 && pid->err < 5)
          pid->err = 0;

      /* 限定积分范围,防止积分饱和 */
      if (pid->integral > 2000)
          pid->integral = 2000;
      else if (pid->integral < -2000)
          pid->integral = -2000;

          /*PID算法实现*/
      pid->actual_val = pid->Kp * pid->err +
                      pid->Ki * pid->integral +
                      pid->Kd * (pid->err - pid->err_last);

          /*误差传递*/
      pid->err_last=pid->err;

          /*返回当前实际值*/
      return pid->actual_val;
  }

8.2.2.8. 多环控制函数

代码的整体实现逻辑,是位置环作为三环控制的外环,它的PID输出作为中间环(即速度环)的输入,而速度环的PID输出,作为电流环(内环)的输入,以达到三环控制的目的。 增加了速度环、电流环,即增加了对速度、电流控制的期望,将速度、电流也纳入到了控制的环节中,这样一方面降低了干扰,另一方可以对速度、电流进行跟随,防止超调。

从代码中可以看到,在定时器调用的不同周期中(由louter_ring_timer控制)计算了各环的PID参数。 内环控制的周期要比外环周期短,因为内环控制着最终的输出,这个输出对应的就是实际场景中的控制量(本代码中最终的控制量是位置),位置是无法突变,是需要时间积累的,所以内环输出尽可能快些。

在实际应用中,还是根据实际的场景设定内外环控制周期。控制的最终效果,还是要让内环跟随外环的变化趋势最终达到稳定效果。

bsp_motor_control.c-三环pid控制
/**
* @brief  电机 PID 控制实现(定时调用)
*
* 通过三环控制(位置环、速度环、电流环),根据电机的实际位置、速度和电流,
* 动态调整电机方向与 PWM 占空比,实现精准控制。
*
* @param[in] actual_location 当前电机实际位置
* @param[in] actual_speed    当前电机实际速度
* @param[in] actual_current  当前电机实际电流
* @retval 无
*/
void motor_pid_control(float actual_location, float actual_speed, float actual_current)
{
    int32_t location = (int32_t)actual_location;   // 将实际位置转换为整数类型
    uint32_t speed = (uint32_t)actual_speed;       // 将实际速度转换为整数类型
    uint32_t current = (uint32_t)actual_current;   // 将实际电流转换为整数类型

    static uint32_t louter_ring_timer = 0;         // 外环周期计数器(控制不同环的执行周期)
    float cont_val = 0;                            // 当前控制值

    // 限制实际电流最大值,避免过载
    if (actual_current > TARGET_CURRENT_MAX)
    {
        actual_current = TARGET_CURRENT_MAX;
    }

    // 当电机处于启用状态时执行控制
    if (motor_state == true)
    {
        /* 位置环计算(每 3 个周期执行一次) */
        if (louter_ring_timer++ % 3 == 0)
        {
            // 通过位置 PID 计算目标速度
            cont_val = location_pid_realize(&pid_location, actual_location);

            // 根据计算结果调整电机方向
            if (cont_val > 0)
            {
                motor_dir = true;  // 正方向
            }
            else if (cont_val < 0)
            {
                cont_val = -cont_val;
                motor_dir = false; // 反方向
            }

            // 限制目标速度的最大值
            if (cont_val > TARGET_SPEED_MAX)
            {
                cont_val = TARGET_SPEED_MAX;
            }

            // 将目标速度传递给速度环
            set_pid_target(&pid_speed, cont_val);

            // 向上位机发送目标速度值(通道 2)
            uint32_t temp = (uint32_t)cont_val;
            set_computer_value(SEND_TARGET_CMD, CURVES_CH2, &temp, 1);
        }

        /* 速度环计算(每 2 个周期执行一次) */
        if (louter_ring_timer % 2 == 0)
        {
            // 通过速度 PID 计算目标电流
            cont_val = speed_pid_realize(&pid_speed, actual_speed);

            // 限制目标电流的最大值
            if (cont_val > TARGET_CURRENT_MAX)
            {
                cont_val = TARGET_CURRENT_MAX;
            }

            // 将目标电流传递给电流环
            set_pid_target(&pid_curr, cont_val);

            // 向上位机发送目标电流值(通道 3)
            uint32_t temp = (uint32_t)cont_val;
            set_computer_value(SEND_TARGET_CMD, CURVES_CH3, &temp, 1);
        }

        /* 电流环计算(每个周期执行一次) */
        // 通过电流 PID 计算最终的 PWM 占空比
        cont_val = curr_pid_realize(&pid_curr, actual_current);

        // 限制 PWM 占空比的上下限
        if (cont_val < 0)
        {
            cont_val = 0;  // 下限处理
        }
        else if (cont_val > PWM_MAX_PERIOD_COUNT)
        {
            cont_val = PWM_MAX_PERIOD_COUNT;  // 上限处理
        }

        // 设置电机方向和 PWM 占空比
        Motor_Control_SetDirAndCount(motor_dir, cont_val);

        // 向上位机发送当前实际值(通道 1、2、3 分别为位置、速度、电流)
        set_computer_value(SEND_FACT_CMD, CURVES_CH1, &location, 1); // 位置
        set_computer_value(SEND_FACT_CMD, CURVES_CH2, &speed, 1);    // 速度
        set_computer_value(SEND_FACT_CMD, CURVES_CH3, &current, 1);  // 电流
    }
}

8.2.2.9. 上位机相关代码

最后修改上位机曲线观察相关代码,该函数用于处理上位机发下的数据,在主函数中循环调用,可以使用上位机调整PID参数,使用上位机可以非常方便的调整PID参数, 主要对三环所接收的目标值和P、I、D值部分进行修改。

protocol.c-上位机相关代码
/**
* @brief   接收的数据处理
* @param   void
* @return  -1:没有找到一个正确的命令.
*/
int8_t receiving_process(void)
{
  uint8_t frame_data[128];         // 要能放下最长的帧
  uint16_t frame_len = 0;          // 帧长度
  uint8_t cmd_type = CMD_NONE;     // 命令类型
  packet_head_t packet;

  while(1)
  {
    cmd_type = protocol_frame_parse(frame_data, &frame_len);
    switch (cmd_type)
    {
      case CMD_NONE:
      {
        return -1;
      }

      case SET_P_I_D_CMD:
      {
        type_cast_t temp_p, temp_i, temp_d;

        packet.ch = frame_data[CHX_INDEX_VAL];

        temp_p.i = COMPOUND_32BIT(&frame_data[13]);
        temp_i.i = COMPOUND_32BIT(&frame_data[17]);
        temp_d.i = COMPOUND_32BIT(&frame_data[21]);

        if (packet.ch == CURVES_CH1)
        {
          set_p_i_d(&pid_location, temp_p.f, temp_i.f, temp_d.f);    // 设置 P I D
        }
        else if (packet.ch == CURVES_CH2)
        {
          set_p_i_d(&pid_speed, temp_p.f, temp_i.f, temp_d.f);    // 设置 P I D
        }
        else if (packet.ch == CURVES_CH3)
        {
          set_p_i_d(&pid_curr, temp_p.f, temp_i.f, temp_d.f);    // 设置 P I D
        }
      }
      break;

      case SET_TARGET_CMD:
      {

          int target_temp = COMPOUND_32BIT(&frame_data[13]);    // 得到数据
          packet.ch = frame_data[CHX_INDEX_VAL];

          /* 只设置位置的目标值,电流的目标置是由速度pid的输出决定的 */
          if (packet.ch == CURVES_CH1)
          {
            set_pid_target(&pid_location, target_temp);    // 设置目标值
          }
          else if (packet.ch == CURVES_CH2)
          {
            set_pid_target(&pid_speed, target_temp);    // 设置目标值
          }
          else if (packet.ch == CURVES_CH3)
          {
            set_pid_target(&pid_curr, target_temp);    // 设置目标值
          }
      }
      break;

      case START_CMD:
      {
          Motor_Control_Start();    //启动电机
      }
      break;

      case STOP_CMD:
      {

          Motor_Control_Stop();    //关闭电机
      }
      break;

      case RESET_CMD:
      {
          NVIC_SystemReset();    //复位系统
      }
      break;

      case SET_PERIOD_CMD:
      {

        uint32_t temp = COMPOUND_32BIT(&frame_data[13]);     // 周期数,单位毫秒
        packet.ch = frame_data[CHX_INDEX_VAL];

        SET_BASIC_TIM_PERIOD(temp);                        // 设置定时器周期1~1000ms

      }
      break;

      default:
        return -1;
    }
  }
}

8.2.3. 下载验证

下载程序到电机开发板,我们按下Key2和Key3键增加或减少电机位置,也可以通过上位机给PID算法输入目标值启动,开发板就能实时的通过PID运算并控制输出,见下图。

三环控制实验图

可以看到,电机按照设定的位置进行了转动。要是放大来看,可以看到实际值和目标值还是有一个小的偏差,由于精度的限制,代码中设置了死区,所以PID就不会对着点小偏差再进行PID调节了。