VESC 电调 CAN 通信解析:从 bldc 源码读懂如何通过 CAN 驱动 BLDC
最近做一个小项目,想用板载 MCU 通过 CAN 直接控 VESC 电机,于是把 vedderb/bldc 仓库里 CAN 相关的代码翻了一遍。这篇把协议要点和组帧细节整理出来,给自己留个笔记,也希望对想用 CAN 直控 VESC 的人有帮助。主要看这几个文件:comm/comm_can.c、util/buffer.c、datatypes.h、util/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。固件默认配置 cancfg 和 set_timing() 里的公式能对上:
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–B16 | B15–B8 | B7–B0 |
|---|---|---|
| 未使用 | Command ID | VESC 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.h 的 CAN_PACKET_ID,缩放/单位来自 comm_can.md):
| 命令 | Hex | 缩放 | 单位/范围 |
|---|---|---|---|
CAN_PACKET_SET_DUTY | 0x00 | 100000 | 占空比 ×100,−1.0 ~ 1.0 |
CAN_PACKET_SET_CURRENT | 0x01 | 1000 | 电流(A) |
CAN_PACKET_SET_CURRENT_BRAKE | 0x02 | 1000 | 制动电流(A) |
CAN_PACKET_SET_RPM | 0x03 | 1 | 电转速 ERPM |
CAN_PACKET_SET_POS | 0x04 | 1000000 | 位置(°)0~360 |
CAN_PACKET_STATUS | 0x09 | — | 状态回传 |
CAN_PACKET_SET_CURRENT_REL | 0x0A | 100000 | 相对电流 −1~1 |
CAN_PACKET_PING / PONG | 0x11 / 0x12 | — | 总线探测 |
CAN_PACKET_STATUS_5 | 0x1B | — | 状态回传 5 |
CAN_PACKET_STATUS_6 | 0x3A | — | 状态回传 6 |
完整枚举有 68 项(0~68),涵盖 IO 板、BMS、GNSS、配置下发这些,平时驱动电机用上面的就够。
一个完整例子<设电流>设电流> 51A,目标 VESC ID=23
照文档里的例子:
EID = (0x01 << 8) | 0x17 = 0x011751 * 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.c 的 crc16())只在长命令的分片协议里用到:
CAN_PACKET_FILL_RX_BUFFER(0x05)/FILL_RX_BUFFER_LONG(0x06)<分片灌入接收缓冲>分片灌入接收缓冲>CAN_PACKET_PROCESS_RX_BUFFER(0x07)<最后一条帧尾>最后一条帧尾> 2 字节带crc16(data, len),接收侧校验
驱动电机用不到,知道有这么回事就行。
四、驱动 BLDC 的最小流程
一次性前置(用 VESC Tool 配好)
- CAN Mode = VESC(默认就是)
- 记下这块 VESC 的 VESC ID(controller_id),多块时各自唯一
- 波特率两侧一致(默认 500k)
- 已经做过电机参数检测/设好限流(
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)); // 2Atx_cmd(0x01 /*SET_CURRENT*/, 1, b, 4);
/* 方式 C:转速闭环 */be32(b, (int32_t)(6000.0f)); // 6000 ERPM,scale=1tx_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)
六、常见坑
- 只发一次命令电机就停 → 默认 1s 超时释放,要么 ≥50Hz 持续重发,要么调大 Timeout(不建议设 0,容易失控)
- ID 对不上收不到 → 目标 ID 要在 EID 低 8 位,且等于该 VESC 的
controller_id;255是广播 - 编码用错字节序/格式 → 大端定点缩放,不是小端也不是 IEEE754
- 拿标准帧发命令 → 命令协议只在扩展帧里处理,标准帧会被静默丢弃
- 占空比模式下发 0 就是停转,低占空比效率也差 → 零速起步用电流模式
- CAN Mode 选成 UAVCAN → 简单命令的解析路径不一样,保持默认 VESC 模式
- 限流没配 → 电流命令被截断到
l_current_max,电机没劲
七、进阶
- 多块 VESC 同步<每块唯一>每块唯一> ID,分别或广播下发
id=255广播<给所有从机同时下同一命令>给所有从机同时下同一命令>- 长命令桥<分片>分片> + CRC16,用于下发配置、固件这类大数据
参考
- vedderb/bldc:
comm/comm_can.c、util/buffer.c、datatypes.h、util/crc.c、documentation/comm_can.md - VESC Project
具体板卡的 CAN 引脚和终端电阻配置,还是要以手上的 VESC 硬件手册为准。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!


