ODrive 通信协议(Fibre Native Protocol)深度解析:端点操作、数据包格式与 CRC 校验

原创2026-10-03 12:21:261,616 阅读
文章标签:嵌入式固件硬件开发智能硬件机器人

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)的解析流程为:

  1. 读取 16 位序列号,通过 seq_no & 0x8000 区分响应(ACK)与请求;
  2. 读取端点 ID 并解析 expect_response = endpoint_id & 0x8000,然后屏蔽 MSB;
  3. 校验 trailer:expected_trailer = endpoint_id ? fibre::json_crc_ : PROTOCOL_VERSION,不一致则丢弃该包;
  4. 读取期望响应长度(超出本地 MTU 时截断),调用 endpoint_handler(endpoint_id, &input_buffer, &output_buffer) 执行端点操作;
  5. 若需要响应,以 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 ⇒ 0x61
  • 0x05, 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 ⇒ 0x672E
  • 0x05, 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 固件)处理流程

  1. 底层流(UART DMA / USB CDC)收到字节;
  2. PacketUnwrapper 完成同步、拆帧与 CRC 校验(流变体);
  3. LegacyProtocolPacketBased::on_read_finished() 校验序列号、端点 ID、trailer,并按需截断期望响应长度;
  4. endpoint_handler(endpoint_id, ...) 通过 switch 分发到具体端点(模板生成逻辑见 endpoints_template.j2),内部再落到 Property<T> 的 read() / exchange()(见 protocol.hpp,其中 exchange 先读旧值、再写入新值并返回旧值);
  5. 需要响应时以 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):

  1. 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;
  2. 数据经 StreamBasedPacketSink 封装为流帧(0xAA + 长度 + CRC8 + 包 + CRC16),由传输层发出;
  3. 接收线程 process_packet() 通过 seq_no & 0x8000 识别 ACK,按序列号匹配 _expected_acks 并唤醒等待方。

读取长端点(如整个 JSON 定义)时使用分块读取:remote_endpoint_read_buffer() 以 512 字节为块、请求中携带已接收字节数作为 offset,直到服务端返回空块为止(见 protocol.py)。

一个典型的读写交互

以读取 vbus_voltage(float32,假定端点 ID 为 e)为例:

  1. 客户端发送请求:[seqno(MSB=0)] [e | 0x8000] [4] [<无 payload>] [json_crc];
  2. 服务端校验 trailer 通过后,执行端点操作,回发响应:[seqno | 0x8000] [4 字节小端 float32];
  3. 客户端按序列号匹配 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

延伸阅读

登录后查看全文
ODrive