RM系列电机,电调介绍

Robomaster官方提供了一系列性能各异,可以用于不同场景,且易于驱动的直流无刷减速电机及配套电调,这里主要介绍三款常用的电机&电调——M3508电机&C620电调,GM6020电机(内部集成电调),M2006&C610电调。

这些电调的手册,驱动demo等同样可以到官网上去下载

https://www.robomaster.com/zh-CN/products/components/general

直流无刷电机不使用传统有刷电机的电刷机械结构,而是通过电子换向器实现换向,相比传统电机有着许多的性能优势,一般使用直流无刷电机时需要有配套的电调,通过改变电调输出的电流大小和方向,可以改变电机的转速和转向。

Robomaster系列的电机内部都有霍尔传感器,可以反馈电机的转速,位置等信息,以供用户实现闭环控制。

一般使用电机时都是先将电机与配套电调连接,电调与电源以及主控板连接,这里以M3508电机为例,其他电机使用时也是同理,首先是将电机和电调互联,C620电调上有一个xt30电源输入口和一个2pin的CAN接口。

官方提供了中心板,电调的电源和信号口可以连接至中心板,再由中心板连接电池和主控。一个中心板上有4个xt30电源输出,4个2pin的CAN接口,1个xt60的电源输入和1个8pin的电源&CAN组合输出,刚刚好可以组成一个四轮底盘。

https://www.robomaster.com/zh-CN/products/components/detail/143

RM的A型主控板上一共有两路CAN,一路是CAN1,采用2pin接口,一路是CAN2,采用4pin接口,可以直接使用双头2pin线连接主控板CAN1接口&中心板或主控板CAN1接口&电调,也可以通过2pin转4pin线连接CAN2接口。

电机是整个机器人上最重要的执行器之一,基本上一个机器人的控制流中,所有输入的最终目的都是为了体现在电机的输出上。一个典型的robomaster步兵机器人身上一共会用到哪些电机呢?以大疆开源的ICRA机器人为例

https://www.robomaster.com/zh-CN/products/components/robot

其使用的电机如下表

电机位置 电机型号
底盘电机*4 M3508
云台yaw轴电机,pitch轴电机 GM6020
拨弹电机*1 M2006
摩擦轮电机*2 Snail 2305

其中Snail电机是前文中没有提到的,这是一个PWM控制的直流无刷电机,由于没有霍尔传感器,该电机不能实现闭环控制,有一些队伍会使用去掉减速箱的M3508电机作为替代方案。

不同的电机有着不同的性能,因此被用于不同的机构中,具体使用哪一款电机需要通过分析转速,扭矩等需求进行选型,获取这些参数的直接手段就是查阅官方的手册,这里依然是以M3508电机为例。

Robomaster系列的电机及配套电调几乎全部是通过CAN总线连接到主控的,即主控通过CAN总线发送数据给电调,实现电机的调速,电调通过CAN总线将电机数据反馈给主控。

RM系列的电机&电调是专门针对比赛进行过设计的,在实际的赛场环境中也确实有着很好的发挥,下面是官方论坛上发布的测评贴,内容很有趣,值得一读。

https://bbs.robomaster.com/thread-5009-1-1.html

CAN通讯

如上一小节所说,RM系列电机&电调大都是使用CAN进行通讯的,因此掌握了CAN通讯就搞定了一大半的电机驱动,其重要性不言而喻,但CAN是一个相对而言比较复杂的通讯协议,相比于UART,SPI,IIC这些常用的通讯协议,CAN有着更多的特性需要去记忆,本节将对CAN的一些比较重要的特性进行梳理,但是不会涉及到CAN的全貌,因为如果要介绍全的话可能要写很长很长了.......

  • 硬件层面

    • 差分信号

      与其他通信方式重要差别之一是CAN采用的是“差分信号”,即通过组成总线的2根线(CAN-H和CAN-L)的电位差来确定总线的电平,信号是以两线之间的“差分”电压形式出现,总线电平分为显性电平和隐性电平。

      CAN总线采用两种互补的逻辑数值"显性"和"隐性"。"显性"数值表示逻辑"0",而"隐性"表示逻辑"1"。当总线上同时出现“显性”位和“隐性”位时,最终呈现在总线上的是“显性”位。

      与串口这种除了TX和RX,还需要用GND连接两个设备串行通讯方式不同,CAN总线只需要CAN_H和CAN_L两根线,就能够通过差分信号的方式表征逻辑"0"和逻辑"1"

    • 帧仲裁

      任何总线都不得不需要面临处理冲突的问题,因为多个设备都挂载在总线上,难免会出现若干个设备同时想要发送信号的情况,这种情况下就需要进行仲裁,判断哪个设备可以占用总线,而其他设备要转变为接收或者等待。

      CAN的仲裁机制正好利用了差分信号的特性,即显性电平覆盖隐形电平的特性,如果出现多个设备同时发送的情况,则先输出隐形电平的设备会失去对总线的占有权。下图中D为显性电平,R为隐形电平,通过该图可以很容易地理解CAN的仲裁机制。

    • 波特率

      CAN有着很高的通讯速率,通过查阅手册可知,一般RM系列电调的通讯速率为1Mbps,只有波特率一致的情况下,主控才能成功与电调进行通讯,CAN的通讯速率的决定因素包括

      • 同步段(SYNC_SEG):位变化应该在此时间段内发生。只有一个时间片的固定长度(1 x tq)
      • 位段1(BS1):定义采样点的位置。其持续长度可以在 1 到 16 个时间片之间调整
      • 位段2(BS2):定义发送点的位置。其持续长度可以在 1 到 8 个时间片之间调整
      • 同步跳转宽度(SJW):定义位段加长或缩短的上限。它可以在 1 到 4 个时间片之间调整

      在ST官方的手册中可以找到波特率的计算公式,通过用户对时钟树,分频值,以及上面4个值的设置,就可以得到想要的波特率。

      当然,这种计算一般是有套路的,一个特定的波特率一般会有对应的一组值,比如针对RM系列电调,一套典型的设置值如下(来自官方开源代码),APB1外设时钟42MHz,分频值被设置为7,SJW被设置为1tq,BS1被设置为2tq,BS2被设置为3tq,可以计算出波特率恰好是1Mbps。当然,具体的设置还是要根据时钟树来进行。

        
      hcan1.Instance = CAN1;
        hcan1.Init.Prescaler = 7;
        hcan1.Init.Mode = CAN_MODE_NORMAL;
        hcan1.Init.SyncJumpWidth = CAN_SJW_1TQ;
        hcan1.Init.TimeSeg1 = CAN_BS1_2TQ;
        hcan1.Init.TimeSeg2 = CAN_BS2_3TQ;
  • 软件层面

    • 过滤器

      CAN的过滤器的目的是很容易理解的,由于总线上的信号是以广播的形式发送的,如果设备都在对于每一个被广播的信号都进行接收+判断,那么势必会浪费大量的时间在这项其实没有什么意义的工作上,解决的方法就是通过设置过滤器,屏蔽掉一些和自己无关的设备发来的信息。

      我们都知道所有CAN设备都是有ID的,具体的ID我们可以从手册中获取。以C620电调为例,可以在C620电调的手册中看到,电调反馈信号时其ID为0x201-0x204。

      https://www.robomaster.com/zh-CN/products/components/general/M3508

      在知道了设备ID之后,为了实现过滤的功能,我们需要对CAN过滤器进行配置。

      CAN的过滤器模式分为掩码模式和列表模式,列表模式简单来说就是制作一张ID表,如果来的数据的ID在这张表中则接收,否则不收。

      重点介绍一下掩码模式的原理:掩码模式的思路很容易理解,举个例子,某所学校的学号构成方式为[4位10进制 入学年份]+[4位10进制 学生序号],比如一个2016年入学的学生,其学号可以是20161234,那么假如要开一个2016年毕业生的庆祝会,会场门口要检查每一个人的学号,只有2016级的才可以进入,这里应该使用什么样的判断方法呢?

      首先,我们需要设置屏蔽码,屏蔽掉后四位的学生序号,因为他们和本次检测无关,反而增大了计算量。

      然后设置检验法2016,如果屏蔽后的结果等于2016,则可以放行。

      如下表所示,第一行为原码,第二行为掩码,将第一行表格中的数与掩码相乘,即得到第三行的屏蔽码,最后一行是验证码,屏蔽码和验证码比较确定一致后,就接收该学号。

      2 0 1 6 1 2 3 4
      1 1 1 1 0 0 0 0
      2 0 1 6 0 0 0 0
      2 0 1 6 0 0 0 0

      这里依然是以官方开源代码为例,每一行添加注释说明其功能

        can_filter_st.FilterActivation = ENABLE;				//satori:激活滤波器
        can_filter_st.FilterMode = CAN_FILTERMODE_IDMASK;		//satori:采用掩码模式
        can_filter_st.FilterScale = CAN_FILTERSCALE_32BIT;	//satori:设置32位宽
        can_filter_st.FilterIdHigh = 0x0000;					//satori:设置验证码高低各4字节
        can_filter_st.FilterIdLow = 0x0000;
        can_filter_st.FilterMaskIdHigh = 0x0000;				//satori:设置屏蔽码高低各4字节
        can_filter_st.FilterMaskIdLow = 0x0000;
        can_filter_st.FilterBank = 0;							//satori:使用0号过滤器
        can_filter_st.FilterFIFOAssignment = CAN_RX_FIFO0;	//satori:通过CAN的信息放入0号FIFO
        HAL_CAN_ConfigFilter(&hcan1, &can_filter_st); 

      那么我们来看一下开源代码中验证码和屏蔽码这两项的配置,屏蔽码设为0x00000000,无论任何标识符通过之后都变成0x00000000,验证码为0x00000000,所以无论任何屏蔽码都能通过。可见其实并没有起到任何过滤作用,这是因为CAN总线上挂载的四个电调,我们的主控都需要接收其数据,所以无论来的标识符是哪个,都要照单全收,而CAN不配置完过滤器是无法开启的,所以才有这套验证码+屏蔽码都是0x00000000的操作。

      最后贴一个CSDN上写的比较好的博客,推荐大家也读一下

      https://blog.csdn.net/flydream0/article/details/52317532

    • 标准数据帧

      CAN的一个标准数据帧包括以下几个部分——

      仲裁场中包含12位的标识符

      仲裁场后跟随的是控制场,存放数据长度DLC,数据场中要填写CAN发送的数据

      最后的CRC,应答这些就与校验,总线控制等有关了,和用户没有太大关系。

      具体该怎么配置,还是需要根据手册走,这里以C620电调为例,在手册中我们可以找到如下内容——

      电调接收报文格式:

      电调反馈报文格式:

电调信号收发示例

依然是以官方开源代码为例,我们先看CAN接收的过程,即如何从CAN接收中断回调函数开始,一步一步送到解码函数中,调用过程如下

HAL_CAN_RxFifo0MsgPendingCallback->can1_motor_msg_rec->motor_device_data_update->get_encoder_data

编码器解码函数,主要完成的工作依然是数据拼接

static void get_encoder_data(motor_device_t motor, uint8_t can_rx_data[])
{
  motor_data_t ptr = &(motor->data);
  ptr->msg_cnt++;

  if (ptr->msg_cnt > 50)
  {
    motor->init_offset_f = 0;
  }

  if (motor->init_offset_f == 1)
  {
    get_motor_offset(ptr, can_rx_data);
    return;
  }

  ptr->last_ecd = ptr->ecd;
  //satori:data[0]和data[1]拼接成转子机械角度
  ptr->ecd = (uint16_t)(can_rx_data[0] << 8 | can_rx_data[1]);

  if (ptr->ecd - ptr->last_ecd > 4096)
  {
    ptr->round_cnt--;
    ptr->ecd_raw_rate = ptr->ecd - ptr->last_ecd - 8192;
  }
  else if (ptr->ecd - ptr->last_ecd < -4096)
  {
    ptr->round_cnt++;
    ptr->ecd_raw_rate = ptr->ecd - ptr->last_ecd + 8192;
  }
  else
  {
    ptr->ecd_raw_rate = ptr->ecd - ptr->last_ecd;
  }

  ptr->total_ecd = ptr->round_cnt * 8192 + ptr->ecd - ptr->offset_ecd;
  /* total angle, unit is degree */
  ptr->total_angle = ptr->total_ecd / ENCODER_ANGLE_RATIO;
  //satori:data[2]和data[3]拼接成转子转速
  ptr->speed_rpm = (int16_t)(can_rx_data[2] << 8 | can_rx_data[3]);
  //satori:data[4]和data[5]拼接成实际转矩电流
  ptr->given_current = (int16_t)(can_rx_data[4] << 8 | can_rx_data[5]);
}

CAN发送过程调用顺序如下:

can_msg_bytes_send->motor_can_send->motor_device_can_output->motor_can1_output_1ms

通过软件定时器设置CAN发送周期为1ms

同样分析一下发送函数,在can_msg_bytes_send函数中完成对帧格式的设置

uint32_t can_msg_bytes_send(CAN_HandleTypeDef *hcan,
                            uint8_t *data, uint16_t len, uint16_t std_id)
{
  uint8_t *send_ptr;
  uint16_t send_num;
  can_manage_obj_t m_obj;
  struct can_std_msg msg;

  send_ptr = data;
  msg.std_id = std_id;
  send_num = 0;

  if (hcan == &hcan1)
  {
    m_obj = &can1_manage;
  }
  else if (hcan == &hcan2)
  {
    m_obj = &can2_manage;
  }
  else
  {
    return 0;
  }

  while (send_num < len)
  {
    if (fifo_is_full(&(m_obj->tx_fifo)))
    {
      //can is error
      m_obj->is_sending = 0;
      break;
    }

    if (len - send_num >= 8)
    {
      msg.dlc = 8;
    }
    else
    {
      msg.dlc = len - send_num;
    }

    //memcpy(msg.data, data, msg.dlc);
    *((uint32_t *)(msg.data)) = *((uint32_t *)(send_ptr));
    *((uint32_t *)(msg.data + 4)) = *((uint32_t *)(send_ptr + 4));

    send_ptr += msg.dlc;
    send_num += msg.dlc;

    fifo_put(&(m_obj->tx_fifo), &msg);
  }

  if ((m_obj->is_sending) == 0 && (!(fifo_is_empty(&(m_obj->tx_fifo)))))
  {
    CAN_TxHeaderTypeDef header;
    uint32_t send_mail_box;

    header.StdId = std_id;
    //satori:设置帧格式为标准帧
    header.IDE = CAN_ID_STD;
    header.RTR = CAN_RTR_DATA;

    while (HAL_CAN_GetTxMailboxesFreeLevel(m_obj->hcan) && (!(fifo_is_empty(&(m_obj->tx_fifo)))))
    {
      fifo_get(&(m_obj->tx_fifo), &msg);
      header.DLC = msg.dlc;
      //satori:调用HAL库函数进行发送
      HAL_CAN_AddTxMessage(m_obj->hcan, &header, msg.data, &send_mail_box);

      m_obj->is_sending = 1;
    }
  }

  return send_num;
}

在motor_device_can_output中进行数据,DLC,ID的设置

int32_t motor_device_can_output(enum device_can m_can)
{
  struct object *object;
  list_t *node = NULL;
  struct object_information *information;
  motor_device_t motor_dev;

  memset(motor_msg, 0, sizeof(motor_msg));

  var_cpu_sr();
   
  /* enter critical */
  enter_critical();

  /* try to find device object */
  information = object_get_information(Object_Class_Device);

  for (node = information->object_list.next;
       node != &(information->object_list);
       node = node->next)
  {
    object = list_entry(node, struct object, list);
    motor_dev = (motor_device_t)object;
    if(motor_dev->parent.type == Device_Class_Motor)
    {
      if (((motor_device_t)object)->can_id < 0x205)
      {
        //装填ID,装填数据
        motor_msg[motor_dev->can_periph][0].id = 0x200;
        motor_msg[motor_dev->can_periph][0].data[(motor_dev->can_id - 0x201) * 2] = motor_dev->current >> 8;
        motor_msg[motor_dev->can_periph][0].data[(motor_dev->can_id - 0x201) * 2 + 1] = motor_dev->current;
        motor_send_flag[motor_dev->can_periph][0] = 1;
      }
      else
      {
        motor_msg[motor_dev->can_periph][1].id = 0x1FF;
        motor_msg[motor_dev->can_periph][1].data[(motor_dev->can_id - 0x205) * 2] = motor_dev->current >> 8;
        motor_msg[motor_dev->can_periph][1].data[(motor_dev->can_id - 0x205) * 2 + 1] = motor_dev->current;
        motor_send_flag[motor_dev->can_periph][1] = 1;
      }
    }
  }

  /* leave critical */
  exit_critical();
  
  for (int j = 0; j < 2; j++)
  {
    if (motor_send_flag[m_can][j] == 1)
    {
      if (motor_can_send != NULL)
        motor_can_send(m_can, motor_msg[m_can][j]);
      motor_send_flag[m_can][j] = 0;
    }
  }

  /* not found */
  return RM_OK;
}

实际上对帧格式,DLC,ID,数据的装填可以全部在一个函数中完成,官方代码写的相对而言比较复杂,多封装了好几层,读者可以去论坛上找一些相对而言简单一些的开源代码看看。

结语

这一讲应该是最硬核,也是我自己写的最累的一讲,CAN是每一个参加RM的电控绕不过去的坎,有很多很多的坑需要自己实践时踩过了才会懂,毕竟实践出真知吗。