电机 CAN 通信驱动¶
机器人上比较重要的通讯代码就是电机通讯,其他传感器通讯比如相机、IMU都有官方给的程序,这里着重解析电机的通讯设计。本节解析 MOS9 使用的电机 CAN 通信驱动库 Github: encos-driver,给出设计过程以及库的实现细节。
这个库是在 Linux 上封装的 SocketCAN 高层驱动,通过4路CAN总线驱动20个电机,有以下功能:
- 自动拉起 CAN 网口
- 扫描总线上的 ENCOS 电机
- 将位置、速度、力矩、kp、kd 等高层控制量编码成电机协议帧
- 从电机反馈帧中恢复位置、速度、电流等状态
- 向上提供统一的 C API
- 通过 pybind11 向 Python 暴露同一套高层驱动接口
这个库不是通用 CAN 工具库,而是一个针对 ENCOS 电机协议定制的“电机控制驱动层”。我们包装好了 CAN 帧,并让上层控制程序直接面对“关节状态”和“关节控制量”。这个库是给机器人开发者一个电机通讯程序的应用示例。
1. 总体定位¶
这个库的核心价值,在于把三类复杂性都藏到了驱动内部:
- Linux SocketCAN 设备操作复杂性
- 电机私有二进制协议复杂性
- 多电机、多通道同步控制复杂性
因此,上层调用者不需要直接管理:
PF_CAN原始套接字bind、ioctl、fcntl- 控制量到 bitfield 的打包
- 电机扫描与 ID 对应关系
- 状态帧解析与量纲恢复
上层只需要使用以下接口,方便运动控制部分调用:
driver_initialize()driver_ctrl()driver_sync()driver_get_pos()/driver_get_vel()/driver_get_cur()
2. 总体架构¶
从源码结构看,这个库可以分成四层。
flowchart TB
subgraph App[应用层]
CApp[C / C++ 控制程序]
PyApp[Python 控制程序]
end
subgraph API[高层驱动 API]
Driver[driver.c / driver.h\n统一电机控制接口]
PyBind[driver_pybind.cpp\nPython 绑定]
end
subgraph Protocol[协议抽象层]
Encos[encos_command.c\n协议打包、扫描、状态解析]
Buffer[send_buffer.c\n每通道发送队列]
Defs[encos_command_defs.h\n位字段与命令定义]
end
subgraph HAL[Linux CAN 硬件抽象层]
SocketCAN[socket_can_driver.c\nSocketCAN 非阻塞收发]
end
subgraph Linux[Linux 内核与设备]
CanIf[can2 / can3 / can4 / can5]
Bus[物理 CAN 总线]
Motor[Encos Motors]
end
CApp --> Driver
PyApp --> PyBind
PyBind --> Driver
Driver --> Encos
Encos --> Buffer
Encos --> Defs
Buffer --> SocketCAN
Encos --> SocketCAN
SocketCAN --> CanIf
CanIf --> Bus
Bus --> Motor
这张图反映了两个非常关键的设计点:
- Python 端并不是直接操作 CAN,而是通过 pybind11 调同一个
driver.c高层接口。 - 电机协议实现与 Linux SocketCAN 实现是分开的,前者负责“电机语义”,后者负责“总线收发”。
3. 代码结构与职责划分¶
3.1 高层驱动接口层¶
高层入口在 include/driver.h 与 src/driver.c。
它暴露的主要接口有:
driver_initialize()driver_uninitialize()driver_ctrl()driver_sync()driver_set_zero()driver_set_id()driver_send_query()driver_get_pos()driver_get_vel()driver_get_cur()
这一层的作用,是把所有电机控制统一抽象成“20 个关节的一组数组”。例如 driver_ctrl() 一次接收五组长度为 MOTOR_COUNT 的数组:
kp[]kd[]target_pos[]target_vel[]target_tor[]
这意味着它的接口设计不是“按电机逐个发命令”,而是“按整机一帧控制周期发命令”。这非常符合机器人控制循环的使用方式。
3.2 协议抽象层¶
协议层集中在:
src/encos/encos_command.csrc/encos/encos_command_defs.h
这一层负责:
- 把高层控制变量映射为电机协议位字段
- 管理每个电机属于哪个 CAN 通道
- 扫描总线并发现电机
- 解析不同类型的电机响应帧
- 维护当前的电机状态缓存
这是整个库最核心的一层,因为它把“CAN 收发”提升成了“电机控制协议”。
3.3 发送缓冲层¶
发送缓冲在 src/send_buffer.c 中实现。
它为每个通道维护一个环形队列:
CHANNEL_COUNT = 4SEND_BUFFER_SIZE = 1024
作用是把协议层生成的待发送 CAN 帧按通道暂存起来,然后由 push_msg() 统一刷到总线。
这个设计有两个好处:
- 协议层可以先组织完整的一轮多电机发送,再集中下发
- 每个通道的发送顺序得到保持,不会因为上层调用顺序混乱而直接打散
3.4 SocketCAN 硬件抽象层¶
硬件层在:
src/socket_can_driver.csrc/socket_can_driver.h
这层是最贴近 Linux 的部分,主要负责:
socket(PF_CAN, SOCK_RAW, CAN_RAW)创建原始 CAN 套接字ioctl(..., SIOCGIFINDEX, ...)根据接口名解析索引bind()把套接字绑定到指定canXfcntl(..., O_NONBLOCK)设置为非阻塞模式read()/write()完成 CAN 帧收发
另外它还会通过:
sudo ip link set canX up type can bitrate 1000000 loopback off
在初始化时尝试自动拉起 CAN 网口。因此运行程序的用户需要具备相应权限。
4. 系统常量与运行规模¶
从 src/constant.h 与 src/constant.c 可以看到,该驱动当前假定:
MOTOR_COUNT = 20CHANNEL_COUNT = 4SEND_BUFFER_SIZE = 1024- 默认 CAN 口为
can2,can3,can4,can5
这说明它不是面向任意规模设备的完全通用库,而是已经针对目标机器人做了工程配置。
另外,MUST_ONLINE_MOTOR 当前 20 个电机都设为 true,意味着初始化阶段默认认为所有电机都必须在线。
5. 初始化流程¶
初始化入口是 driver_initialize(),它本身很薄,真正核心逻辑在 encos_initialize() 中。
整体初始化流程如下。
flowchart TB
A[driver_initialize] --> B[设置日志级别]
B --> C[encos_initialize]
subgraph Init[初始化 CAN]
direction LR
D[初始化发送缓冲区]
E[逐个初始化 can2-can5]
F[把 SocketCAN 设为非阻塞]
D --> E --> F
end
subgraph Scan[发现电机]
direction LR
G[扫描总线上的电机 ID]
H[建立 motor_id 到 channel 的映射]
I[等待所有必须在线电机返回状态]
G --> H --> I
end
subgraph Config[配置电机]
direction LR
J[写入位置 速度 力矩 kp kd 电流范围]
K[根据配置启用 kt]
J --> K
end
C --> D
F --> G
I --> J
K --> L[驱动初始化完成]
5.1 初始化 CAN 通道¶
encos_initialize() 会依次调用 initialize_can(i)。
每个通道初始化会做这些事情:
- 检查通道编号是否合法。
- 如果该通道已经初始化,则直接返回。
- 执行
ip link set canX up ...拉起设备。 - 创建
PF_CAN原始套接字。 - 设为
O_NONBLOCK。 - 通过
ioctl获取接口索引。 bind到对应canX设备。
这说明驱动选择了“非阻塞轮询”模型,而不是阻塞式等待模型。
5.2 扫描电机¶
扫描逻辑在 scan_motors() 中。
它会在各个可用通道上广播 query_id 命令,然后读取各通道返回,解析出:
- 哪些电机存在
- 某个电机 ID 位于哪个 CAN 通道
内部维护的核心状态有:
channel_available[]motor_to_channel[]motor_found[]motor_error[]
其中 motor_to_channel[id] 很重要,它是后续所有单电机命令的路由依据。
5.3 等待电机上线¶
仅扫描到 ID 还不够,驱动还会进入 wait_motors_online(),不断查询位置,直到:
- 所有必须在线的电机都返回了有效位置
- 或者超时
这一步相当于把“总线发现”提升为“电机真正可通信”。
5.4 下发量程配置¶
初始化成功后,driver_initialize() 会调用:
encos_set_pos_range()encos_set_vel_range()encos_set_tor_range()encos_set_cur_range()encos_set_kp_range()encos_set_kd_range()encos_enable_kt()
这些范围来自 src/constant.c 中的编译期常量。
这说明该驱动并不是简单透传 SDK,而是维护了一套“物理量范围配置”,用于控制量和反馈量的双向映射。
6. 控制命令是怎样编码成 CAN 帧的¶
这一点是整个驱动最值得注意的地方。
在 encos_command_defs.h 中,驱动把一帧 64 bit CAN 数据定义成多个位字段结构。对控制命令来说,核心定义是:
struct {
uint qtor: 12;
uint qvel: 12;
uint qpos: 16;
uint kd: 9;
uint kp: 12;
uint mode: 3;
} ctrl;
这意味着一条 8 字节控制帧内部实际打包了:
kp:12 bitkd:9 bitqpos:16 bitqvel:12 bitqtor:12 bitmode:3 bit
高层控制量在发送前,会先做线性映射:
- 实际位置区间
pos_range[id]映射到0 ~ 65535 - 实际速度区间
vel_range[id]映射到0 ~ 4095 - 实际力矩区间
tor_range[id]映射到0 ~ 4095 kp映射到0 ~ 4095kd映射到0 ~ 511
也就是说,库暴露给用户的是连续物理量,网络上传输的是受位宽限制的离散整数编码。
6.1 控制下发流程¶
driver_ctrl() 会调用 encos_ctrl(),后者按电机循环执行:
- 检查电机是否有最近心跳。
- 检查电机是否上报错误码。
- 将
kp/kd/pos/vel/tor映射为整数。 - 使用
COMMAND_CTRL(...)宏拼成 64 bit CAN 数据。 - 将该帧压入该电机所在通道的发送缓冲。
- 由
push_msg()统一把多通道缓冲刷出。
这说明一次 driver_ctrl() 调用,本质上是在完成“整机所有电机的一轮控制帧组包和发送”。
7. 状态反馈是怎样解析的¶
该库接收帧后并不是直接返回给用户,而是先由协议层做多种响应解析。
核心解析函数有:
parse_query_id()parse_set_id()parse_set_zero()parse_set_range()parse_enable_kt()parse_response_1()parse_response_4()parse_response_5()
其中最重要的是状态反馈:
7.1 response_1¶
response_1 是常规状态帧,里面包含:
positionvelocitycurrentmotor_tempmos_temperror_no
驱动收到这类帧后,会把原始整数值通过 unmap() 恢复成浮点物理量,并写入:
current_qpos[]current_qvel[]current_qcur[]
这三个数组就是上层 driver_get_pos()、driver_get_vel()、driver_get_cur() 返回的底层状态缓存。
7.2 response_5¶
response_5 看起来是某些查询命令的扩展返回,驱动当前支持的 code 主要有:
- code = 1:位置
- code = 2:速度
并且会把角度值从度转换到弧度,把转速值转换到弧度每秒。
7.3 错误与超时¶
每当收到来自某个电机的帧,驱动都会更新 motor_last_seen_time[id]。
在 encos_ctrl() 中,如果发现某个电机超过 1 秒没有被看到,就会认为它超时失联:
- 从映射表里移除该电机
- 如果它属于必须在线电机,则返回
MOTOR_TIMEOUT
此外,如果反馈帧里 error_no != 0,驱动会记录错误码;后续控制时若该电机仍有错误,会返回 MOTOR_ERROR。
8. 发送缓冲与通道调度¶
send_buffer.c 实现的是一个很朴素但很实用的设计:每个通道一个环形发送队列。
8.1 为什么需要发送缓冲¶
如果协议层一边遍历电机、一边直接 write() 到 CAN 套接字,会有几个问题:
- 多通道控制的发送顺序难以统一组织
- 某个通道短时拥塞时,逻辑层不方便继续组织其他电机命令
- 配置命令和控制命令混发时不容易保证批量操作的完整性
所以这里先 send_buffer_push(),最后由 push_msg() 批量刷出。
8.2 push_msg() 如何工作¶
push_msg() 会循环检查所有通道:
- 如果某个通道缓冲不空,就弹出一帧并发送
- 如果所有通道都空了,就结束
- 每轮循环之后
usleep(100),避免紧密忙等
这是一种简单的轮询调度器。它没有复杂优先级,但在当前 20 电机、4 通道的规模下已经足够工程使用。
9. driver_sync() 的含义¶
driver_sync() 的实现非常直接:
encos_query(1)encos_query(2)- 等待约 1 ms
pull_msg()拉取并解析返回
也就是说,它不是做同步屏障,而是主动发起一轮状态查询,再把反馈刷新到本地缓存。
因此在系统语义上,driver_sync() 更接近:
- “主动刷新状态缓存”
而不是:
- “严格时钟同步”
10. C 和 Python 接口关系¶
这个库的 Python 接口并不是独立实现的,它本质上是 driver.h 的 pybind11 包装。
也就是说:
- C 端调用
driver_initialize() - Python 端调用
encos_python.initialize()
它们最终都走到同一个 driver.c 实现。
对应关系大致如下:
| C API | Python API | 说明 |
|---|---|---|
driver_initialize() |
initialize() |
初始化驱动 |
driver_uninitialize() |
uninitialize() |
关闭驱动 |
driver_ctrl() |
ctrl() |
一次下发全部电机控制量 |
driver_sync() |
sync() |
主动拉取状态 |
driver_get_pos() |
get_pos() |
获取位置数组 |
driver_get_vel() |
get_vel() |
获取速度数组 |
driver_get_cur() |
get_cur() |
获取电流数组 |
driver_set_zero() |
set_zero() |
设零 |
driver_set_id() |
set_id() |
改 ID |
driver_send_query() |
send_query() |
发送查询命令 |
因此,这个库的语言边界设计是很整齐的:
- C / C++ / Python 共用同一套高层电机语义
- 协议实现只有一份
- 硬件驱动实现也只有一份
Python 绑定里 get_pos()、get_vel()、get_cur() 还会返回只读 numpy 数组,直接引用底层状态内存,减少了一次多余复制。
11. 控制与反馈数据流¶
下面这张图把一轮控制周期中的命令流和反馈流放在一起。
flowchart TB
subgraph Upper[上层控制程序]
direction TB
CtrlInput[控制指令]
ReadState[状态读取]
end
subgraph Driver[encos-driver]
API[driver_ctrl / driver_sync]
Encode[量纲映射 + 协议编码]
Queue[每通道发送缓冲]
TX[SocketCAN 发送]
RX[SocketCAN 接收]
Parse[协议解析 + 状态恢复]
Cache[qpos/qvel/qcur]
end
subgraph Device[电机与 CAN 总线]
Bus[CAN 总线]
Motors[20 个电机]
end
CtrlInput --> API
API --> Encode
Encode --> Queue
Queue --> TX
TX --> Bus
Bus --> Motors
Motors --> Bus
Bus --> RX
RX --> Parse
Parse --> Cache
Cache --> ReadState
这张图揭示了一个很重要的事实:
- 上层程序看到的是“数组控制”和“数组状态”
- 中间驱动承担了所有“协议编码、通道调度、反馈解析”工作
因此该库天然适合作为机器人整机控制器和执行器之间的中间层。
12. 库的优点¶
从工程角度看,这个库有几个明显优点。
12.1 高层接口简洁¶
上层直接用浮点物理量控制,不需要自己关心 CAN bitfield。
12.2 C 与 Python 共用同一实现¶
避免出现两套控制逻辑分叉,降低维护成本。
12.3 协议层和硬件层分离¶
如果以后要支持别的电机协议,理论上可以重写协议层;如果以后要支持不同总线收发实现,也可以替换硬件层。
12.4 适合整机控制循环¶
driver_ctrl() 的输入是整机 20 电机数组,这和机器人控制器的典型输出完全匹配。
13. 当前实现的局限与注意事项¶
这个库虽然实用,但也有一些需要明确指出的局限。
13.1 配置是编译期固定的¶
例如:
- 电机数量
- 通道数量
- 默认 CAN 口名
- 量程配置
- 必须在线电机集合
这些目前都在 constant.c 中固定,不是运行时配置。
13.2 初始化依赖系统权限¶
驱动内部会执行 sudo ip link set ...,这意味着:
- 运行用户需要有权限
- 在某些受限部署环境中可能不适合这样做
更严格的工程实践通常会把网口配置放在 systemd、启动脚本或运维层,而不是驱动库内部。
13.3 非阻塞轮询模型比较简单¶
当前实现依赖:
- 非阻塞 socket
- 周期性
pull_msg() - 简单的缓冲轮询
这种方案足够直接,但不属于强实时或事件驱动的最优实现。
13.4 错误恢复机制较基础¶
例如电机丢失后,当前策略主要是:
- 标记失联
- 返回错误
- 必要时直接退出
但没有更完整的自动重连、分级降级或局部隔离机制。
14. 小结¶
encos-driver 的本质,可以概括成一句话:
它是一个面向 Encos 电机的 Linux SocketCAN 高层驱动,把原始 CAN 总线通信提升成了“整机多电机关节控制接口”。
它内部的关键工作包括:
- 初始化和拉起 CAN 设备
- 扫描并发现电机
- 维护电机 ID 到 CAN 通道的映射
- 把物理控制量编码为电机协议帧
- 把电机反馈帧还原为位置、速度、电流状态
- 通过统一 C API 和 Python API 向上层控制器提供服务
对于机器人系统来说,这样的驱动层非常重要,因为它把“电机通信”从控制器中剥离了出来,使上层控制程序可以专注于:
- 步态规划
- 状态估计
- 运动控制
- 强化学习策略部署
而不必直接处理底层 CAN 帧细节。