ODrive 通信协议(Fibre Native Protocol)深度解析:端点操作、数据包格式与 CRC 校验
ODrive 通信协议(Fibre Native Protocol)深度解析:端点操作、数据包格式与 CRC 校验
导读
本文以 ODrive 官方文档 docs/protocol.rst 为核心,系统讲解 ODrive 与上位机(PC)之间 Native Protocol 的完整技术细节:端点(endpoint)操作模型、请求/响应数据包格式、流式封装格式,以及 CRC8/CRC16 校验算法。ODrive 的官方调试工具 odrivetool 正是基于该协议与固件通信(见 docs/native-protocol.rst),理解本协议是开发自定义上位机、协议抓包分析、以及移植驱动库的必备基础。读完本文,你将能够按字节级规范自行构造合法的请求包、解析响应包,并准确实现协议规定的 CRC 校验。
协议总览:一切通信都是端点操作
与 ODrive 通信本质上是一系列端点操作(endpoint operation)。一个端点(endpoint)理论上可以是任意类型、任意方式序列化的数据:
- 对于 POD 类型(
int8_t、uint16_t、float等基本类型),协议提供默认的序列化实现; - 对于自定义类型,需要使用者自行完成(反)序列化。
每个端点都有一个唯一的 Endpoint ID。所有可用的端点可以通过读取 端点 0 返回的 JSON 定义来枚举。理论上每个通信接口的端点集合可以不同,但在 ODrive 的实际实现中它们是相同的。
每次端点操作可以向一个端点发送字节,同时从同一端点接收字节。payload 的语义由端点类型决定,端点类型在 JSON 定义中标注。例如,一个 int32 端点的输入与输出都是 4 字节小端序表示;对于组合读写请求,约定采用 exchange 语义,即返回的值是旧值(自定义端点处理器可以不遵循此约定)。
这种"端点 + JSON 自描述"的设计,正是 tools/odrive/pyfibre/fibre/protocol.py 中 Channel.remote_endpoint_operation() 等客户端 API 与 Firmware/fibre-cpp/legacy_protocol.cpp 中服务端 endpoint_handler() 分发逻辑的共同基础。
协议的两种变体
协议存在基于数据包(packet based)和基于流(stream based)两种变体,按通信介质选用:
| 变体 | 典型介质 | 固件端实例 |
|---|---|---|
| 数据包变体(Packet Based) | USB 自定义接口 | fibre_over_usb,见 Firmware/communication/interface_usb.cpp |
| 流变体(Stream Based) | UART | fibre_over_uart,见 Firmware/communication/interface_uart.cpp |
在 interface_usb.cpp 中可以看到:
LegacyProtocolStreamBased fibre_over_cdc(&usb_cdc_rx_stream, &usb_cdc_tx_stream);
LegacyProtocolPacketBased fibre_over_usb(&usb_native_rx_stream, &usb_native_tx_stream, USB_TX_DATA_SIZE - 1);
即 USB 的厂商自定义接口(端点对 0x03, 0x83)运行数据包变体(Native Protocol),而 CDC 虚拟串口接口(端点对 0x01, 0x81)默认运行 ASCII 协议、也可配置为 Fibre 流协议(见 docs/usb.rst)。UART 上则通过 interface_uart.cpp 中的 odrv.config_.uart0_protocol 配置选择 Fibre 流协议或 ASCII 协议。
数据包格式(Packet Format)详解
通信双方中,ODrive 称为 server(服务端),PC 称为 client(客户端)。从 PC 发往 ODrive 的消息称为请求(request),从 ODrive 发往 PC 的消息称为响应(response)。每一次请求-响应事务对应一次单一的端点操作。
请求(Request)
请求包结构如下(N 为总包长):
| 字节偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0-1 | 2 | 序列号(Sequence number) | 高位 MSB = 0;当前服务端不关心顺序,也不过滤重发消息 |
| 2-3 | 2 | 端点 ID(Endpoint ID) | 所有端点 ID 可从端点 0 的 JSON 定义获得;若 MSB 置 1,客户端期望该请求有响应 |
| 4-5 | 2 | 期望响应大小(Expected response size) | 期望服务端返回的字节数;若不需要响应数据可设为 0(只要 EndpointID 的 MSB 置 1,操作仍会被确认) |
| 6 至 N-3 | 变长 | Payload | 长度由总包长决定;格式取决于端点类型(可从 JSON 定义获取) |
| N-2, N-1 | 2 | Trailer | 端点 0:协议版本(当前为 1);其他端点:按下文算法对 JSON 定义计算的 CRC16(初始值为协议版本 1) |
服务端对 trailer 字段错误的包会直接忽略,对版本不符的端点 0 包也会忽略。
响应(Response)
| 字节偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0-1 | 2 | 序列号 | MSB = 1,值为所响应请求的序列号 |
| 2 至 N-1 | 变长 | Payload | 长度通常等于请求中声明的期望响应大小;服务端不会期望客户端接受超过其请求的字节数 |
源码级对照:请求包的组装与解析
在 Firmware/fibre-cpp/legacy_protocol.cpp 中,客户端侧组包逻辑与上述规范完全一致:
write_le<uint16_t>(op.seqno, tx_buf_); // 字节 0-1:序列号
write_le<uint16_t>(op.endpoint_id | 0x8000, tx_buf_ + 2); // 字节 2-3:端点 ID,MSB 置 1 期望响应
write_le<uint16_t>(op.rx_buf.size(), tx_buf_ + 4); // 字节 4-5:期望响应大小
memcpy(tx_buf_ + 6, op.tx_buf.begin(), n_payload); // 字节 6+:payload
uint16_t trailer = (op.endpoint_id & 0x7fff) == 0 ?
PROTOCOL_VERSION : client_.json_crc_; // trailer:端点 0 用版本号,其余用 JSON CRC
write_le<uint16_t>(trailer, tx_buf_ + 6 + n_payload);
服务端侧(legacy_protocol.cpp)的解析流程为:
- 读取 16 位序列号,通过
seq_no & 0x8000区分响应(ACK)与请求; - 读取端点 ID 并解析
expect_response = endpoint_id & 0x8000,然后屏蔽 MSB; - 校验 trailer:
expected_trailer = endpoint_id ? fibre::json_crc_ : PROTOCOL_VERSION,不一致则丢弃该包; - 读取期望响应长度(超出本地 MTU 时截断),调用
endpoint_handler(endpoint_id, &input_buffer, &output_buffer)执行端点操作; - 若需要响应,以
seq_no | 0x8000作为响应序列号回发数据。
其中 JSON CRC(json_crc_)与协议版本(PROTOCOL_VERSION = 1)的定义位于 Firmware/fibre-cpp/legacy_protocol.hpp。
流格式(Stream Format)
基于流的变体只是对数据包格式的一层包装(wrapper),用于 UART 这类无帧边界的字节流介质。一个流帧结构如下:
| 字节偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0 | 1 | 同步字节(Sync byte) | 固定 0xAA |
| 1 | 1 | 包长度(Packet length) | 目前双方只应发送和接受 0 ~ 127 的值(长度字节 MSB 不可置 1) |
| 2 | 1 | CRC8 | 对字节 0、1 计算(算法见下文) |
| 3 至 N-3 | 变长 | Packet | 内层完整数据包(含其自身的序列号、端点 ID 等) |
| N-2, N-1 | 2 | CRC16 | 对字节 3 至 N-3(即内层包)计算(算法见下文) |
固件端:PacketWrapper / PacketUnwrapper
固件中的收发两端分别由 Firmware/fibre-cpp/legacy_protocol.hpp 中的 PacketWrapper(发送)与 PacketUnwrapper(接收)实现:
- 发送(legacy_protocol.cpp):写入
header_buf_[0] = CANONICAL_PREFIX(即0xAA)、header_buf_[1] = buffer.size()、header_buf_[2] = calc_crc8<...>(CANONICAL_CRC8_INIT, header_buf_, 2),再发送 payload 与 2 字节 CRC16(高字节在前)。 - 接收(legacy_protocol.cpp):依次校验同步字节(不符则丢弃 1 字节)、长度字节 MSB(置 1 表示包长 ≥ 128,当前不支持)、头部 CRC8,然后接收 payload 与 trailer,最终校验 CRC16;出错时同步丢弃,重新同步。
Python 客户端端:StreamToPacketSegmenter
Python 侧 odrivetool 使用的解析器位于 tools/odrive/pyfibre/fibre/protocol.py:
SYNC_BYTE = 0xAA
CRC8_INIT = 0x42
CRC16_INIT = 0x1337
MAX_PACKET_SIZE = 128
StreamToPacketSegmenter 逐字节处理:先累积 3 字节头部(检查 0xAA 同步字节、header<a href="https://link.gitcode.com/i/b48adef64a2abe1135fcfadb4fabe26c" target="_blank">1] & 0x80 长度标志、calc_crc8(CRC8_INIT, header) 校验),再累积 payload,最后当 calc_crc16(CRC16_INIT, self._packet) == 0 时判定整个包合法——这是一个巧妙的校验技巧:将 CRC16 初始值作为余数参与运算,最终余数为 0 即校验通过,固件端 [legacy_protocol.cpp 也采用同样做法。
CRC 算法详解
协议使用两种 CRC,均采用逐位模 2 除法实现(参考 Firmware/fibre-cpp/crc.hpp 的模板化实现,以及 protocol.py 的 Python 实现)。两者均不做输入反射、不做结果反射、不做最终异或。
CRC8
| 参数 | 值 |
|---|---|
| 多项式(Polynomial) | 0x37(即 x^8 + x^5 + x^4 + x^2 + x + 1) |
| 初始值(Initial value) | 0x42 |
| 输入反射 / 结果反射 / 最终异或 | 无 / 无 / 无 |
验证示例:
0x01, 0x02, 0x03, 0x04⇒0x610x05, 0x04, 0x03, 0x02, 0x01⇒0x64
CRC16
| 参数 | 值 |
|---|---|
| 多项式(Polynomial) | 0x3d65(即 x^16 + x^13 + x^12 + x^11 + x^10 + x^8 + x^6 + x^5 + x^2 + 1,即 CRC-16-DNP,见 legacy_protocol.hpp 注释) |
| 初始值(Initial value) | 0x1337(JSON 定义 CRC 使用 0x0001,实际以协议版本 1 作为初始值参与运算) |
| 输入反射 / 结果反射 / 最终异或 | 无 / 无 / 无 |
验证示例:
0x01, 0x02, 0x03, 0x04⇒0x672E0x05, 0x04, 0x03, 0x02, 0x01⇒0xE251
注:示例中 CRC16 结果按高字节在前书写。实现时可借助在线 CRC 计算器交叉验证,但务必按上述多项式、初始值与"三无"参数配置,否则结果不一致。
固件中的常量定义
所有算法常量集中在 Firmware/fibre-cpp/legacy_protocol.hpp:
constexpr uint8_t CANONICAL_CRC8_POLYNOMIAL = 0x37;
constexpr uint8_t CANONICAL_CRC8_INIT = 0x42;
constexpr uint16_t CANONICAL_CRC16_POLYNOMIAL = 0x3d65;
constexpr uint16_t CANONICAL_CRC16_INIT = 0x1337;
constexpr uint8_t CANONICAL_PREFIX = 0xAA;
constexpr uint16_t PROTOCOL_VERSION = 1;
Python 客户端端常量则在 protocol.py 中保持完全一致(CRC8_DEFAULT = 0x37、CRC16_DEFAULT = 0x3d65,注释明确要求与 C++ 实现一致)。
校验保护能力(来自源码注释)
按 legacy_protocol.hpp 引用的 Koopman CRC 目录数据:
- CRC8(
0x37/0x42)可保护 4 字节 payload 抵御最多 5 bit 翻转; - CRC16(
0x3d65/0x1337)可保护 135 字节 payload 抵御最多 5 bit 翻转。
这解释了协议中 CRC8 用于保护固定 2 字节头部、CRC16 用于保护整个内层包的工程设计考量。
端点 0 与接口自描述:JSON 定义如何生成与校验
端点 0 是协议自描述的入口:读取它即可获得整个接口的 JSON 定义,其中包含全部端点的 ID 与类型。固件端处理逻辑见 legacy_protocol.cpp:
- 请求必须携带一个 32 位整数
offset; offset == 0xFFFFFFFF时返回 JSON 版本 ID(json_version_id_);offset超出 JSON 长度时返回空响应(表示读取完毕);- 否则返回 JSON 中从
offset开始的一段数据。
bool fibre::endpoint0_handler(fibre::cbufptr_t* input_buffer, fibre::bufptr_t* output_buffer) {
std::optional<uint32_t> offset = read_le<uint32_t>(input_buffer);
if (!offset.has_value()) return false; // 未收到 offset
else if (*offset == 0xffffffff) // 特殊值:返回 JSON 版本 ID
return write_le<uint32_t>(json_version_id_, output_buffer);
else if (*offset >= embedded_json_length) // 越界:返回空响应
return true;
else { // 返回 JSON 片段
size_t n_copy = std::min(output_buffer->size(), embedded_json_length - (size_t)*offset);
memcpy(output_buffer->begin(), embedded_json + *offset, n_copy);
*output_buffer = output_buffer->skip(n_copy);
return true;
}
}
JSON 定义从哪来
JSON 定义并非手写,而是由接口定义文件 Firmware/odrive-interface.yaml 通过 tools/fibre-tools/interface_generator.py 生成。例如 vbus_voltage 在 YAML 中声明为 readonly float32(见 odrive-interface.yaml),最终会体现为 JSON 中的一个只读 float32 端点。
生成的 endpoints.hpp 模板见 Firmware/fibre-cpp/endpoints_template.j2,其中定义了关键校验常量:
const unsigned char embedded_json[] = ...; // 嵌入固件的 JSON 定义
const uint16_t json_crc_ = calc_crc16<CANONICAL_CRC16_POLYNOMIAL>(
PROTOCOL_VERSION, embedded_json, embedded_json_length); // 以协议版本 1 为初值算 JSON CRC
const uint32_t json_version_id_ = (json_crc_ << 16) | calc_crc16<...>(json_crc_, embedded_json, ...);
JSON CRC 的作用
如请求包格式所述,除端点 0 外,所有请求的 trailer 都是 JSON 定义的 CRC16(初始值为协议版本 1)。它的作用是一致性校验:若固件与客户端持有的接口定义不一致(例如固件升级后端点布局变化),客户端算出的 CRC 与服务端不符,包会被服务端直接忽略,从而避免因端点 ID 错位导致的误操作。
客户端侧在收到 JSON 后同样计算并保存该 CRC,见 legacy_object_client.cpp:
json_crc_ = calc_crc16<CANONICAL_CRC16_POLYNOMIAL>(PROTOCOL_VERSION, json_.data(), json_.size());
从协议到实现:完整调用链与典型交互
服务端(ODrive 固件)处理流程
- 底层流(UART DMA / USB CDC)收到字节;
PacketUnwrapper完成同步、拆帧与 CRC 校验(流变体);LegacyProtocolPacketBased::on_read_finished()校验序列号、端点 ID、trailer,并按需截断期望响应长度;endpoint_handler(endpoint_id, ...)通过 switch 分发到具体端点(模板生成逻辑见 endpoints_template.j2),内部再落到Property<T>的read()/exchange()(见 protocol.hpp,其中exchange先读旧值、再写入新值并返回旧值);- 需要响应时以
seq_no | 0x8000回发。
客户端(odrivetool / Python)处理流程
以官方 Python 库为例(安装方式见 docs/getting-started.rst,基础用法见 docs/native-protocol.rst):
import odrive
odrv0 = odrive.find_any()
print(str(odrv0.vbus_voltage)) # 触发一次对 vbus_voltage 端点的读操作
底层调用链(tools/odrive/pyfibre/fibre/protocol.py):
Channel.remote_endpoint_operation(endpoint_id, input, expect_ack, output_length):- 递增
_outbound_seq_no = (seq + 1) & 0x7fff; expect_ack时对端点 ID 置0x8000,序列号置0x80(源码注释说明:硬编码序列号一位为 1,是为了避免与 ODrive ASCII 协议冲突,见 protocol.py 与固件端 legacy_protocol.cpp);- 按
struct.pack('<HHH', seq_no, endpoint_id, output_length)组包,追加 payload 与 trailer(端点 0 用PROTOCOL_VERSION,否则用_interface_definition_crc); - 期望 ACK 时登记
_expected_acks[seq_no],并具备重发机制:最多_send_attempts = 5次、超时_resend_timeout = 5.0s;
- 递增
- 数据经
StreamBasedPacketSink封装为流帧(0xAA+ 长度 + CRC8 + 包 + CRC16),由传输层发出; - 接收线程
process_packet()通过seq_no & 0x8000识别 ACK,按序列号匹配_expected_acks并唤醒等待方。
读取长端点(如整个 JSON 定义)时使用分块读取:remote_endpoint_read_buffer() 以 512 字节为块、请求中携带已接收字节数作为 offset,直到服务端返回空块为止(见 protocol.py)。
一个典型的读写交互
以读取 vbus_voltage(float32,假定端点 ID 为 e)为例:
- 客户端发送请求:
[seqno(MSB=0)] [e | 0x8000] [4] [<无 payload>] [json_crc]; - 服务端校验 trailer 通过后,执行端点操作,回发响应:
[seqno | 0x8000] [4 字节小端 float32]; - 客户端按序列号匹配 ACK,取回 4 字节并按小端解析为
float(float的编解码定义见 protocol.hpp,即按 IEEE 754 的 32 位小端布局)。
附录:协议参数速查表
| 项目 | 值 | 依据 |
|---|---|---|
| 同步字节 | 0xAA |
legacy_protocol.hpp |
| 协议版本 | 1 |
legacy_protocol.hpp |
| 最大包长(流长度字段) | 0 ~ 127(长度字节 MSB 置 1 暂不支持) | protocol.py、legacy_protocol.cpp |
| CRC8 多项式 / 初值 | 0x37 / 0x42 |
legacy_protocol.hpp |
| CRC16 多项式 / 初值 | 0x3d65 / 0x1337(JSON CRC 初值 0x0001,即协议版本 1) |
legacy_protocol.hpp、endpoints_template.j2 |
| 序列号掩码 | 0x7fff,客户端出站序列号额外置位 0x80 |
legacy_protocol.cpp |
| 端点 ID 响应位 | 0x8000 |
legacy_protocol.cpp |
| 端点 0 trailer | 协议版本 1 |
legacy_protocol.cpp |
| 其他端点 trailer | JSON 定义 CRC16(初值 1) | legacy_protocol.cpp |
| 客户端重发参数 | 最多 5 次、超时 5.0 s | protocol.py |
延伸阅读
- 协议在固件中的完整收发实现:Firmware/fibre-cpp/legacy_protocol.cpp、Firmware/fibre-cpp/legacy_protocol.hpp
- 序列化与端点编解码:Firmware/fibre-cpp/protocol.hpp、Firmware/fibre-cpp/include/fibre/simple_serdes.hpp
- 接口定义与自动生成:Firmware/odrive-interface.yaml、Firmware/fibre-cpp/endpoints_template.j2
- Python 客户端实现:tools/odrive/pyfibre/fibre/protocol.py
- 传输层接线与配置:Firmware/communication/interface_usb.cpp、Firmware/communication/interface_uart.cpp、docs/usb.rst、docs/uart.rst
- 基于该协议的上层用法:docs/native-protocol.rst、tools/odrive_demo.py