Skip to content

API 控制

中文 | English

CAN配置

硬件配置

终端电阻必须使用 Split termination。禁止使用星形连接接法。必须保证同根CAN线上有且仅有两组终端电阻,并且都在线的两个末端上。

软件配置

电机使用 CAN-FD。仲裁段固定为 1Mbps,采样点为0.8。 数据段波特率固定为 5Mbps,采样点为 0.75。

推荐的 CAN 配置命令:

ip link set can0 type can bitrate 1000000 sample-point 0.8 dbitrate 5000000 sample-point 0.75 sjw 5 dsjw 3 fd on

注意

必须正确设置采样点,否则发送长帧时必定导致通信失败。有些USB转CAN设备不一定正确支持设置这些参数。如果遇到这种情况,请转而使用 RK3576 等硬件 CAN-FD 支持的 SOC。

配置电机ID 与 解析CAN消息

我们的电机使用 CANOpen(CiA301) 协议。CANOpen是工业自动化领域广泛使用的协议,网上拥有诸多教程,在此不做赘述。其中针对PDO做了一些小修改,允许PDO最大长度为 64 字节,以此充分利用CAN-FD的带宽。

CAN-ID的设置是通过对象字典 2001h, 1h进行设置的。

例如使用 canopend 工具进行配置,将原本的1号电机改为3号:

关于如何使用 canopend 工具,请参考其 Github 介绍,此处不做解释说明

新ID断电生效

新ID设置后,断电才会生效。请您务必小心,不要设置了错误的ID。

默认的TPDO映射如下:

// TPDO1: 位置(0x6064, 0, i32(长度0x20),时间戳(0x1013 0, u32(长度0x20)),力矩(0x6077, 0, i16(长度0x10)),错误码(0x603F, 0, u16(长度0x10))。12字节,1000Hz。
// TPDO2: 状态字(2字节),驱动器温度(2字节),电机温度(2字节),控制字(2字节),错误码(2字节)。10字节(实际发送12字节),50Hz。

总之,一个典型的完整电机初始化与控制流程是这样的:

  1. 上位机程序读取 一次 1018h 03h 对象获取电机固件版本,以确保上位机程序与电机固件版本兼容。
  2. 上位机程序读取 一次 6076h 00h 对象获取电机的峰值力矩,方便后续计算转换为国际单位力矩。
  3. 上位机程序读取 一次 2003h 07h 对象获取 MIT KP,KD的尺度因子,方便转换为国际单位量纲的KP,KD。(仅限程序版本07,且仅限需要使用MIT模式)
  4. 上位机程序配置心跳包检测 1016h 00h 对象,确保上位机程序结束时电机会自动退出使能。(可选,从第一次接收到目标节点心跳包开始计时)
  5. 上位机配置TPDO与RPDO映射(如果已经通过 1010h 对象完成过参数保存,则不需要进行这一步)
  6. 上位机程序生成CAN接收处理进程,针对电机的TPDO与HEARTBEAT以及可选的EMCY进行解析并处理
  7. 上位机发送NMT指令将电机设置为 Operational 状态。
  8. 上位机正确配置 CiA402 状态机,选择进入的模式并使能电机。
  9. 上位机开始发送自己的心跳包(可选,如果需要心跳包超时保护。自己的心跳包建议低频发送。),并开始通过PDO发送控制指令。

这里提供解析部份(也就是第6步)的部分代码示例

有几个小细节

  • 速度使用dequeue通过硬件时间戳做差在上位机进行计算,消除潜在的时间误差,并且方便用户自行选择滤波算法。
  • 由于 6077h 没有量纲,是0-1000,以千分比形式提供。因此需要乘以 6076h 得到国际单位后的力矩值。您应该在电机开始时读取 一次 6076h(对同一电机为常量)。后续用它乘即可。
  • 关于 Q21 数据格式。Q21 是一种定点数格式,用于表示小数。与浮点格式相比,这种表示方法没有累计误差。其精度为 1/2^21。使用32位数表示时,可表示范围为 -1024~1023.99 (0x80000000~0x7FFFFFFF) * (1 / 2^21)。转换方法为 Q21格式数据 = 2^21 * 浮点格式原始数据。例如,电机目前的实际位置为 1.52 Rev,则从 6064h中读到的值为:3187671(1.52 × 2097152)
for frame in frames {
    let id = frame.id();
    if let socketcan::Id::Standard(id) = id {
        let id = id.as_raw();
        let canopen_id = (id as u8) & 0x7F;
        if canopen_id != self.canopen_id {
            continue;
        }
        if let Ok(communication_object_code) =
            canopen::canopen_types::CommunicationObject::try_from(id)
        {
            let constants = self.runtime_constants.get();
            match communication_object_code {
                canopen::canopen_types::CommunicationObject::TPDO1 => {
                    if frame.data().len() != 12 {
                        continue;
                    }
                    // TPDO1: 位置(4字节),时间戳(4字节),力矩(2字节),错误码(2字节)。12字节,1000Hz。
                    let position = i32::from_le_bytes([
                        frame.data()[0],
                        frame.data()[1],
                        frame.data()[2],
                        frame.data()[3],
                    ]);
                    // Reduce pi, to map start point from 0-2pi to -pi-pi
                    let position = position - Self::OFFSET;
                    let timestamp = u32::from_le_bytes([
                        frame.data()[4],
                        frame.data()[5],
                        frame.data()[6],
                        frame.data()[7],
                    ]);
                    let speed = {
                        let dq = &mut self.runtime_data.lock().unwrap().raw_position;
                        // Push the new data
                        dq.push_back((position, timestamp));
                        if dq.len() > self.speed_average_window {
                            // Pop the first incoming data
                            let last_data = dq.pop_front().unwrap();
                            // In us
                            let time_diff = timestamp.overflowing_sub(last_data.1).0;
                            // In seconds
                            let time_diff = time_diff as f64 / 1_000_000.0f64;
                            // Q21 Rev
                            let position_diff = position.overflowing_sub(last_data.0).0;
                            // In rad/s
                            position_diff as f64 * 2.0f64 * std::f64::consts::PI
                                / time_diff
                                / 2_u32.pow(21) as f64
                        } else {
                            0.0f64
                        }
                    };

                    let torque = if let Some(peak_torque) = self.runtime_constants.get() {
                        let peak_torque = peak_torque.peak_torque;
                        let torque = i16::from_le_bytes([frame.data()[8], frame.data()[9]]);
                        // Convert mapped torque to Nm
                        torque as f64 * peak_torque as f64 / 1000.0f64
                    } else {
                        0.0f64
                    };
                    let error_code =
                        u16::from_le_bytes([frame.data()[10], frame.data()[11]]);
                    {
                        let runtime_data = &mut self.runtime_data.lock().unwrap();
                        // Raw position is already updated, so no need to update it here
                        runtime_data.last_contact_time = std::time::Instant::now();
                        runtime_data.smoothed_speed = speed;
                        runtime_data.torque = torque;
                        runtime_data.raw_error_code = error_code;
                    }
                }
                canopen::canopen_types::CommunicationObject::TPDO2 => {
                    if frame.data().len() != 12 {
                        continue;
                    }
                    // TPDO2: 状态字(2字节),驱动器温度(2),电机温度(2),控制字(2)。8字节,50Hz。
                    let status_word =
                        u16::from_le_bytes([frame.data()[0], frame.data()[1]]);
                    let driver_temperature =
                        i16::from_le_bytes([frame.data()[2], frame.data()[3]]) as f32
                            * 0.1f32;
                    let motor_temperature =
                        i16::from_le_bytes([frame.data()[4], frame.data()[5]]) as f32
                            * 0.1f32;
                    let control_word =
                        u16::from_le_bytes([frame.data()[6], frame.data()[7]]);
                    {
                        let runtime_data = &mut self.runtime_data.lock().unwrap();
                        runtime_data.status_word = status_word;
                        runtime_data.driver_temperature = Some(driver_temperature);
                        runtime_data.motor_temperature = Some(motor_temperature);
                        runtime_data.read_control_word = control_word;
                    }
                }
                canopen::canopen_types::CommunicationObject::Heartbeat => {
                    if frame.data().len() != 1 {
                        continue;
                    }
                    let data = frame.data()[0];
                    if let Ok(nmt_state) = NMTState::try_from(data) {
                        if nmt_state != NMTState::Operational {
                            {
                                let mut runtime_data = self.runtime_data.lock().unwrap();
                                if runtime_data.last_nmt_send_time.elapsed().as_millis()
                                    > 250
                                {
                                    runtime_data.last_nmt_send_time =
                                        std::time::Instant::now();
                                }
                            }

                            ret.push(canopen::set_nmt_state(
                                NMTState::Operational,
                                self.canopen_id,
                            ));
                        }
                        self.runtime_data.lock().unwrap().nmt_state = Some(nmt_state);
                    }
                }
                _ => {}
            }
        }
    }
}

电机控制

控制指令超时

CANOpen自带超时机制。默认是关闭的,您需要手动启用。具体请查看 1016h 对象的介绍。

电机失能刹车

通过调整 2040h 00h 对象,可以设置电机失能或发生故障后是否短接绕组以维持制动状态。

一拖多控制

在CANOpen中,PDO并非只能使用指定的COB-ID,而是可以自由设置。这意味着您可以通过设置多个机器的RPDO的COB-ID为同一个来实现一拖多的功能。

听起来有点抽象?我们以四个电机,使用力位混合控制为例。

两个电机的CANOpenID分别为0x01,0x02。每个电机都控制他们的 6072h(最大力矩/Max Torque) 与 60FFh(目标速度/Target Velocity)。将这两个电机的RPDO1的COB-ID均设置为0x10节点的TPDO1 COB-ID上。

那么每个电机有两个有效对象。其他字节使用填充即可。我们假定将6072h排在前面,那么对于一号电机,映射应该是这样的:60FFh(4bytes)|6072h(2bytes)|fill(4bytes)|fill(4bytes)|fill(4bytes)|fill(4bytes)|fill(2bytes)。因为一共有4个电机,所以需要 4 * ( 4 + 2 ) = 24 字节来一帧控制所有电机,因此需要在后方放18字节长的填充对象。

那么二号电机同理,就应该是:fill(4bytes)|fill(2bytes)|60FFh(4bytes)|6072h(2bytes)|fill(4bytes)|fill(4bytes)|fill(4bytes)。以此类推。

保存PDO Mapping

PDO Mapping 映射是可以保存的,避免每次开机都重新设置。具体请查看对象 1010h。保存后每次开机仅需发送NMT指令即可直接开始控制。

MIT控制与速度控制

对于 动力角轮(Powered Castor Wheel) 来讲,可被人推动基本是必须的。很多情况下都会希望在底盘能在特定情况能被自由推动。此时仅需要将电机的最大输出力矩限制为0即可。到这里都只需要使用速度控制。但如果您希望增加一些力控,例如 动/静摩擦力补偿 等,那么只能使用 MIT 控制模式。

另外还值得一提的是,没有所有电机必须要使用同种模式的限制,因此您可以自由组合速度控制与MIT控制,只要确保您的PDO映射正常即可。

速度模式:

  • 数据简短,有效降低can总线占用率。
  • 结合 6072h 使用,可以实现力位混合控制,令底盘可以被推动。力位混控情况下单电机需要6字节,64字节可以轻松带动 4个舵轮模组 (8个电机)
  • 无法实现力控

MIT控制模式:

  • 数据较长,会显著增加can总线占用率。如果can总线信号质量太差,会导致通信失败。
  • 无需配合6072h使用,直接Kd给0即可。
  • 可以实现力控,例如 动/静摩擦力补偿 等。
  • 视情况长度可为 16 字节 或 10 字节。无论何种情况,都需要2个COB-ID带动全部4个舵轮模组。尽管如此,使用10字节长度可以有效降低can总线占用率,因此仍然推荐。

关于MIT控制

单个MIT对象有16字节长,其中4字节的目标位置与2字节的Kp,如果没有使用,建议您直接从映射中去除。这样可以进一步减少CAN总线占用率。

堵转错误

当电机持续以峰值力矩输出时,会触发堵转错误。如果您想彻底避开堵转错误,您需要将电机的最大输出力矩限制为 80% 峰值力矩。即向6072h写入 800。

控制测试

请谨慎操作

开始电机控制前,请务必确保周围环境安全。由于在终端中反复发送某个节点的心跳包不是特别方便,这里提供的两个案例都没有使用心跳超时检测。

SDO控制测试

关于SDO控制测试

事实上你应该使用PDO写例如 60FFh 等对象,但我们这里先使用SDO进行CAN通信演示。

请谨慎操作

开始电机控制前,请务必确保周围环境安全

开始之前,我们重新对电机上电,确保电机处于初始状态。假定需要控制的电机为0x01 与 0x02。我们将使用速度模式进行演示。其中1号电机的力矩将被限制到峰值力矩的5%,2号电机将被限制到 80% 峰值力矩。

1 w 0x6060 0 i8 3
2 w 0x6060 0 i8 3
1 w 0x60FF 0 i32 0
2 w 0x60FF 0 i32 0
1 w 0x6040 0 u16 6 
2 w 0x6040 0 u16 6 
1 w 0x6072 0 u16 50
2 w 0x6072 0 u16 800

等待上述全部写入后,再将控制字写7

1 w 0x6040 0 u16 7
2 w 0x6040 0 u16 7

最后再写0x0f

1 w 0x6040 0 u16 0x0f
2 w 0x6040 0 u16 0x0f

此时电机启动使能,目标为 0Rev/s。写入Q21单位的转速即可。这里准备以0.1Rev/s的速度进行测试。

0.1 * 2^21 = 209715

1 w 0x60FF 0 i32 209715
2 w 0x60FF 0 i32 209715

此时两个电机开始以0.1Rev/s的速度旋转,并且两电机的最大力矩不同,其中一个施加较小阻力就会停止,另一个则需要更大的阻力才会停止。

控制测试

PDO控制测试

显然,使用SDO通信带宽浪费明显。应该尽量使用PDO进行控制,但正如前文所提到的,PDO映射需要根据实际操作的电机数量进行调整,因此本demo仍然只演示控制4个电机的情况,其余情况请根据实际情况调整。

调整PDO Mapping

为了实现一帧拖多电机进行控制,原有的RPDO COB-ID是无法满足需求的。因此不仅需要调整所有被控电机的 Mapping,还需要调整 RPDO COB-ID。

以带力矩上限的速度控制为例,每个电机都需要使用 6072h(最大力矩/Max Torque) 与 60FFh(目标速度/Target Velocity) 这两个对象。因此每个电机都需要六个字节。一共有4个电机,因此需要24字节。

对于发送的程序来讲,全部24字节的顺序是这样的:

4字节填充对象为 3000h 03h, 2字节填充对象为 3000h 02h

电机1最大力矩(2 Bytes) | 电机1目标速度(4 Bytes) | 电机2最大力矩(2 Bytes) | 电机2目标速度(4 Bytes) | 电机3最大力矩(2 Bytes) | 电机3目标速度(4 Bytes) | 电机4最大力矩(2 Bytes) | 电机4目标速度(4 Bytes)

那么对1号电机来讲,他的RPDO Mapping应该是这样的:

最大力矩 6072h | 目标速度 60FFh | 4字节填充 | 4字节填充 | 4字节填充 | 4字节填充 | 2字节填充

那么对2号电机来讲,他的RPDO Mapping应该是这样的:

4字节填充 | 2字节填充 | 最大力矩 6072h | 目标速度 60FFh | 4字节填充 | 4字节填充 | 4字节填充

那么对3号电机来讲,他的RPDO Mapping应该是这样的:

4字节填充 | 4字节填充 | 4字节填充 | 最大力矩 6072h | 目标速度 60FFh | 4字节填充 | 2字节填充

那么对4号电机来讲,他的RPDO Mapping应该是这样的:

4字节填充 | 4字节填充 | 4字节填充 | 4字节填充 | 2字节填充 | 最大力矩 6072h | 目标速度 60FFh

关于填充对象

无需拘泥于此形式,得益于PDO的灵活特性,您可以自由安排。假设实际上目标速度需要更高的变动频率,而最大力矩不需要,那么完全可以将所有的最大力矩放到RPDO2,以高频发送目标速度,低频发送最大力矩。

也无需强制所有的电机都处于同一控制模式,完全可以一部分电机处于速度控制模式,一部分电机使用MIT控制模式。只要 Mapping 正确即可。

关于配置自动化与配置持久化

请记住,本质上 canopend 就是发送了一些 can 消息,您完全可以将配置PDO的过程自动化。关于 SDO 协议的具体格式请直接查看丰富的互联网教程。

另外,这些配置除非通过 1010h 对象进行保存,否则每次开机后都会恢复为默认配置。如果您确认不会改变这些配置,那么只要通过 1010h 对象进行保存,即可实现配置持久化。后续开机无需重新配置,仅需发送NMT指令与选择模式,操作控制字。

这里我们需要选一个 COB-ID 做所有电机的 RPDO1 COB-ID。这里我们选择 0x10 节点的 TPDO1 COB-ID(即0x190)。您可以任意选择一个CAN-ID,只要确保不会与其他节点冲突即可。这里推荐选主站的TPDO1,2,3或4。

  • 首先给 1400h 01h 写 0x8000_0000 | 0x190 失能 RPDO1
  • 设置 1400h 02h 传输类型设置为 255
  • 设置 1600 00h RPDO 有效映射数为 0
  • 设置 1600 01h 应用对象1为 4字节填充 3000h 03h
  • 设置 1600 02h 应用对象2为 2字节填充 3000h 02h
  • 设置 1600 03h 应用对象3为 最大力矩 6072h 00h
  • 设置 1600 04h 应用对象4为 目标速度 60FFh 00h
  • 设置 1600 05h 应用对象5为 4字节填充 3000h 03h
  • 设置 1600 06h 应用对象6为 4字节填充 3000h 03h
  • 设置 1600 07h 应用对象7为 4字节填充 3000h 03h
  • 设置 1600 00h 有效映射数为 7
  • 最后给 1400h 01h 写 0x0000_0000 | 0x190 使能 RPDO1

此时已经完成RPDO配置,接下来输入 2 start 发送 NMT指令,使电机进入 CiA301 的 Operational 状态。

但此时还不能通过 PDO 进行控制,因为还没有配置 CiA402 状态机。因为只需要上电配置一次,也使用 SDO 进行演示。

  • 给 6040h 00h 写 0x80 清除错误
  • 给 6060h 00h 写 0x03 设置电机为速度控制模式
  • 给 6040h 00h 写 0x06 Shutdown
  • 给 6040h 00h 写 0x07 Switch On
  • 给 6040h 00h 写 0x0f Operation Enable

sdo-test

此时电机可以开始通过 PDO 进行控制。下面以将所有电机最大力矩限制为 80%,目标转速为 1Rev/s 为例。

cansend can0 190##1.200300002000200300002000200300002000200300002000200300002000200300002000

如果正确配置了所有的电机,此时所有电机都会开始运行。

对象字典

仅提供与 CiA301 和 CiA402 不同的部分。由于分发问题,我们无法提供 CiA301.pdf 与 CiA402.pdf,请您使用搜索引擎搜索相关内容。

软件版本

通过读取 1018h 03h 我们可以获取电机的固件版本。不同固件版本的对象字典可能有所不同,请注意。

版本变更历史

  • 0x07
    • 首个对外公开版本

0x07

软件版本07的对象字典