VESC 电调 CAN 通信解析:从 bldc 源码读懂如何通过 CAN 驱动 BLDC

1785 字
9 分钟
VESC 电调 CAN 通信解析:从 bldc 源码读懂如何通过 CAN 驱动 BLDC

最近做一个小项目,想用板载 MCU 通过 CAN 直接控 VESC 电机,于是把 vedderb/bldc 仓库里 CAN 相关的代码翻了一遍。这篇把协议要点和组帧细节整理出来,给自己留个笔记,也希望对想用 CAN 直控 VESC 的人有帮助。主要看这几个文件:comm/comm_can.cutil/buffer.cdatatypes.hutil/crc.c,以及仓库里的 documentation/comm_can.md

一、VESC 的 CAN 模式#

VESC 固件定义了 4+1 种 CAN 模式(datatypes.h 里的 CAN_MODE),默认是 CAN_MODE_VESC = 0,下面讲的都是这个模式:

typedef enum {
CAN_MODE_VESC = 0,
CAN_MODE_UAVCAN,
CAN_MODE_COMM_BRIDGE,
CAN_MODE_UNUSED,
CAN_MODE_VESC_UAVCAN
} CAN_MODE;

「VESC 模式」和「UAVCAN 模式」是两套完全不同的协议,如果是用外部 MCU 直控电机,默认的 VESC 模式就够了。适用范围大概是<外部> MCU 直控(机器人、云台这类)、总线上挂多块 VESC 按 ID 分别控制或广播、需要实时回传状态(ERPM/电流/占空比)。

二、总线参数与帧 ID 布局#

波特率#

默认是 500 kbit/s。固件默认配置 cancfgset_timing() 里的公式能对上:

comm/comm_can.c
static CANConfig cancfg = {
CAN_MCR_ABOM | CAN_MCR_AWUM | CAN_MCR_TXFP,
CAN_BTR_SJW(3) | CAN_BTR_TS2(2) | CAN_BTR_TS1(9) | CAN_BTR_BRP(5)
};
/* set_timing() 注释里的公式(42 MHz APB1):
* 42000000 / ((brp+1) * (ts1+ts2+3)) ≈ 500000 */

主控端的 CAN 外设也要配成 500k。改波特率有专用命令 CAN_PACKET_UPDATE_BAUD(0x3F),或者用 VESC Tool 在 App Settings 里改。

29 位扩展帧 ID 是怎么排的#

comm_can.md 给出的位域:

B28–B16B15–B8B7–B0
未使用Command IDVESC ID

代码里发命令时(comm_can_set_duty 等)就是这么拼的:

comm_can_transmit_eid_replace(
controller_id | ((uint32_t)CAN_PACKET_SET_DUTY << 8),
buffer, send_index, true, 0);

也就是:

EID = (command_id << 8) | target_vesc_id

注意低 8 位是接收方(VESC)的 ID,不是发送方自己的 ID。主控发命令时把目标 VESC 的 controller_id 填进低 8 位即可,id == 255 表示广播。接收端 decode_msg(eid, data8, len) 的过滤逻辑:

uint8_t id = eid & 0xFF;
CAN_PACKET_ID cmd = eid >> 8;
...
if (id == 255 || id == id1 || id == id2) {
switch (cmd) {
case CAN_PACKET_SET_DUTY: ...
}
}

id1/id2 是固件运行时本机的 controller_id(默认 APPCONF_CONTROLLER_ID = -1,由 MCU UUID 计算;也可以用 VESC Tool 显式指定)。ID 匹配不上会被静默丢弃,很多”发了没反应”就是栽在这里。

另外,标准帧(11 位 SID)不会进 decode_msg,命令协议只认扩展帧,别用标准帧发命令。

三、帧内容是怎么编码的#

数值<大端定点缩放>,不是 float#

这是最容易出错的地方。util/buffer.c 里几个底层函数:

void buffer_append_int16(uint8_t* b, int16_t n, int32_t *index); // 大端 2 字节
void buffer_append_int32(uint8_t* b, int32_t n, int32_t *index); // 大端 4 字节
void buffer_append_float32(uint8_t* b, float n, float scale, int32_t *index);
// → buffer_append_int32(b, (int32_t)(n * scale), index)
int32_t buffer_get_int32(const uint8_t* b, int32_t* index);
float buffer_get_float32(const uint8_t* b, float scale, int32_t* index);
// → buffer_get_int32(...) / scale

函数名叫 buffer_append_float32,但实现其实就是「定点缩放后的大端 int32」,并不是 IEEE-754 浮点。文档里写得很直白:

All simple CAN-commands have 4 data bytes … as a 32-bit big endian signed number with scaling.

如果直接把 float 的二进制 memcpy 进帧,或者反过来按 IEEE754 去解析收到的帧,结果都是错的。

常用命令表#

驱动电机最常用的几条(枚举来自 datatypes.hCAN_PACKET_ID,缩放/单位来自 comm_can.md):

命令Hex缩放单位/范围
CAN_PACKET_SET_DUTY0x00100000占空比 ×100,−1.0 ~ 1.0
CAN_PACKET_SET_CURRENT0x011000电流(A)
CAN_PACKET_SET_CURRENT_BRAKE0x021000制动电流(A)
CAN_PACKET_SET_RPM0x031电转速 ERPM
CAN_PACKET_SET_POS0x041000000位置(°)0~360
CAN_PACKET_STATUS0x09状态回传
CAN_PACKET_SET_CURRENT_REL0x0A100000相对电流 −1~1
CAN_PACKET_PING / PONG0x11 / 0x12总线探测
CAN_PACKET_STATUS_50x1B状态回传 5
CAN_PACKET_STATUS_60x3A状态回传 6

完整枚举有 68 项(0~68),涵盖 IO 板、BMS、GNSS、配置下发这些,平时驱动电机用上面的就够。

一个完整例子<设电流> 51A,目标 VESC ID=23#

照文档里的例子:

  • EID = (0x01 << 8) | 0x17 = 0x0117
  • 51 * 1000 = 51000 = 0xC738
  • 大端写入 4 字节 → data = 00 00 C7 38

所以这一帧是<扩展帧>,IDE=1, DLC=4, ID=0x0117, DATA=00 00 C7 38

要不要 CRC?#

简单单帧命令不用 CRC。CRC16(CCITT,多项式 0x1021,种子 0,见 util/crc.ccrc16())只在长命令的分片协议里用到:

  • CAN_PACKET_FILL_RX_BUFFER(0x05)/FILL_RX_BUFFER_LONG(0x06)<分片灌入接收缓冲>
  • CAN_PACKET_PROCESS_RX_BUFFER(0x07)<最后一条帧尾> 2 字节带 crc16(data, len),接收侧校验

驱动电机用不到,知道有这么回事就行。

四、驱动 BLDC 的最小流程#

一次性前置(用 VESC Tool 配好)#

  1. CAN Mode = VESC(默认就是)
  2. 记下这块 VESC 的 VESC ID(controller_id),多块时各自唯一
  3. 波特率两侧一致(默认 500k)
  4. 已经做过电机参数检测/设好限流(l_current_max/min,否则电流命令会被限流闷住)

关键<命令必须持续重发>#

decode_msg 每收到一条命令都会调 timeout_reset(),timeout.c 里的超时保护:

if (kill_sw || (timeout_msec != 0 &&
chVTTimeElapsedSinceX(last_update_time) > MS2ST(timeout_msec))) {
mc_interface_set_brake_current(timeout_brake_current); /* 默认 0 = 释放 */
}
  • 默认 APPCONF_TIMEOUT_MSEC = 1000(1 秒;comm_can.md 里旧文写 0.5s,和代码不一致,以代码为准)
  • timeout_brake_current 默认 0,超时后就是滑行/释放

也就是说只发一次命令,大约 1 秒后电机就停了。文档建议按固定频率持续发,比如 50Hz(每 20ms 一帧)

// 目标 VESC ID=1,主控 500k。下面这段放在 20ms 定时中断/循环里执行。
uint32_t tx_cmd(uint8_t cmd, uint8_t id, const uint8_t *data, uint8_t len) {
return can_transmit_extended(((uint32_t)cmd << 8) | (id & 0xFF), data, len);
}
/* 大端写 int32 */
static void be32(uint8_t *b, int32_t v) {
b[0] = (v >> 24) & 0xFF; b[1] = (v >> 16) & 0xFF;
b[2] = (v >> 8) & 0xFF; b[3] = v & 0xFF;
}
/* 方式 A:直接占空比(最省事,轻载能转) */
uint8_t b[4];
be32(b, (int32_t)(0.2f * 100000.0f)); // 20% 占空比
tx_cmd(0x00 /*SET_DUTY*/, 1, b, 4);
/* 方式 B:电流闭环(推荐,数值要在限流以内) */
be32(b, (int32_t)(2.0f * 1000.0f)); // 2A
tx_cmd(0x01 /*SET_CURRENT*/, 1, b, 4);
/* 方式 C:转速闭环 */
be32(b, (int32_t)(6000.0f)); // 6000 ERPM,scale=1
tx_cmd(0x03 /*SET_RPM*/, 1, b, 4);

单块 VESC 不用在代码里”enable app”:decode_msg 拿到命令直接交给 mc_interface_set_duty/set_current/set_pid_speed,和有没有启用 PPM/ADC/UART app 没关系。这对外部 MCU 直控很关键。

状态回传#

启用后 VESC 会按设置频率回发 CAN_PACKET_STATUS* 系列帧(ERPM/电流/占空比/温度等),逐字节含义见 comm_can.md,可以用来做闭环或者看门狗。

五、硬件接线#

  • CAN_H ↔ CAN_H、CAN_L ↔ CAN_L,不要交叉
  • 主控和 VESC 共地(GND 连一起,避免共模电压损坏收发器)
  • 总线两端各 120Ω 终端电阻(两个节点时两端各一个;VESC 板载能不能焊终端电阻要查对应板卡手册)
  • 波特率两侧一致(500k)

六、常见坑#

  1. 只发一次命令电机就停 → 默认 1s 超时释放,要么 ≥50Hz 持续重发,要么调大 Timeout(不建议设 0,容易失控)
  2. ID 对不上收不到 → 目标 ID 要在 EID 低 8 位,且等于该 VESC 的 controller_id;255 是广播
  3. 编码用错字节序/格式 → 大端定点缩放,不是小端也不是 IEEE754
  4. 拿标准帧发命令 → 命令协议只在扩展帧里处理,标准帧会被静默丢弃
  5. 占空比模式下发 0 就是停转,低占空比效率也差 → 零速起步用电流模式
  6. CAN Mode 选成 UAVCAN → 简单命令的解析路径不一样,保持默认 VESC 模式
  7. 限流没配 → 电流命令被截断到 l_current_max,电机没劲

七、进阶#

  • 多块 VESC 同步<每块唯一> ID,分别或广播下发
  • id=255 广播<给所有从机同时下同一命令>
  • 长命令桥<分片> + CRC16,用于下发配置、固件这类大数据

参考#

具体板卡的 CAN 引脚和终端电阻配置,还是要以手上的 VESC 硬件手册为准。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

VESC 电调 CAN 通信解析:从 bldc 源码读懂如何通过 CAN 驱动 BLDC
https://blog.yungong.online/posts/vesc-can-communication/
作者
云宫
发布于
2026-09-03
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
云宫
Hi, I'm 云宫
公告
欢迎来到 CODE云宫,一座磁带里的知识库。于过去寻找未来,于复古寻找现代。
分类
标签
站点统计
文章
3
分类
2
标签
12
总字数
3,688
运行时长
0
最后活动
0 天前