前两帖完成了系统方案和单关节控制规划,这一帖汇报把 6 个关节"连成一条臂"的关键设计:CAN 分布式通信协议。协议编解码器(JointCanProtocol)已写成不依赖硬件的纯 C++ 库,协议的逻辑正确性已在电脑本机(PlatformIO native 环境,无需任何硬件)通过 8/8 软件级单元测试;CAN 硬件链路(FDCAN 外设、收发器、多节点总线)要等出差结束接实物后再联调验证。
一、为什么把协议独立成可测试的库
关节节点(STM32G0B1,FDCAN)、主控(ESP32-S3,TWAI/CAN 控制器)、上位机(Python 调试工具)三方必须对每一字节有完全一致的理解。任何一方的编解码偏差,在总线上就是莫名其妙的控制异常,而且极难排查。
所以我把协议编解码抽成不依赖任何硬件的纯 C++ 库(header-only),三方共用同一份代码,并用 PlatformIO 的 native 环境在电脑上直接做单元测试——不需要硬件就能验证协议逻辑的正确性。
二、CAN ID 分配
标准 11 位帧 ID,按"组号 + 节点号"两层编址,1 Mbps:
ID = (组号 << 7) | 节点号
| 组号 | 报文 | 方向 | 说明 |
|---|---|---|---|
| 0x0 | SYNC | 主控广播 | 同步锁存:所有关节同一时刻执行新目标 |
| 0x1 | CMD | 主控 → 关节 | 控制模式 + 目标值 |
| 0x2 | FEEDBACK | 关节 → 主控 | 实时角度 + 速度(默认 100Hz) |
| 0x3 | STATUS | 关节 → 主控 | 状态机 + 错误标志(默认 1Hz/状态变化) |
| 0x4 | CONFIG | 预留 | 在线参数配置(PID 等) |
节点号 0 为广播/主控,1~6 对应关节 J1~J6。ID 数值越小总线优先级越高——SYNC(0x000)优先级最高,保证同步信号永远不被普通流量阻塞。
三、帧格式(数据场固定 8 字节)
CMD 帧(主控 → 关节)——机器人的"指令":
| 字节 | 内容 |
|---|---|
| 0 | 模式:0=下电 1=力矩 2=速度 3=位置 4=使能 |
| 1~4 | 目标值 float32(V / rad/s / rad,随模式) |
| 5 | 指令序列号(丢帧检测) |
| 6~7 | 预留 |
FEEDBACK 帧(关节 → 主控):
| 字节 | 内容 |
|---|---|
| 0~3 | MA900 绝对角度 float32(rad) |
| 4~7 | 轴速度 float32(rad/s) |
STATUS 帧(关节 → 主控):
| 字节 | 内容 |
|---|---|
| 0 | 状态机:INIT / READY / RUNNING / ERROR |
| 1 | 错误标志位:编码器CRC超限、过流(预留)、过热(预留)、CAN失联 |
| 2~3 | MA900 CRC 累计错误数(上一帖的可观测设计在这里用上) |
| 4~5 | 上电秒数 |
四、同步机制:SYNC 广播帧
六轴联动时,如果 6 个 CMD 帧先后到达,各关节执行时刻就有先后,末端轨迹会"抖"。解法是两段式提交:
主控: CMD(J1) CMD(J2) ... CMD(J6) → 各关节暂存目标,不执行
主控: SYNC(广播) → 所有关节同一控制周期锁存执行
SYNC 帧带序列号,关节可以检测 SYNC 丢失;若长时间收不到任何 CMD/SYNC,关节自动进入 ERR_CAN_TIMEOUT 保护态——机械臂不会在主控死机时"失控甩动"。
五、单元测试结果(软件级,电脑本机运行)
协议库用 PlatformIO native 环境测试——注意这是 PC 上的软件级测试,验证的是协议编解码逻辑(字节布局、ID 编址、float 无损往返),不涉及 CAN 硬件链路;硬件联调列入出差结束后的计划。8 个用例全部通过:
| 测试用例 | 验证内容 | 结果 |
|---|---|---|
| test_make_id_layout | ID 位域布局正确(SYNC=0x000 等) | |
| test_id_roundtrip | 组号/节点号编码解码互逆 | |
| test_sync_encode | SYNC 帧格式 | |
| test_cmd_roundtrip | CMD 帧 float 目标值无损往返 | |
| test_cmd_reject_wrong_group | 解码器拒绝错误组号的帧 | |
| test_feedback_roundtrip | FEEDBACK 帧含负速度往返 | |
| test_feedback_byte_layout | float32 little-endian 字节布局 | |
| test_status_roundtrip | STATUS 帧错误位/计数器往返 |
完整测试代码如下:
/**
* @file test_can_protocol.cpp
* @brief JointCanProtocol 编解码器单元测试(PlatformIO native 环境)
* 运行:pio test -e native
*/
#include <unity.h>
#include "../lib/JointCanProtocol/JointCanProtocol.h"
using namespace jointcan;
// ---------------- ID 编解码 ----------------
void test_make_id_layout() {
// SYNC 广播 ID 应为 0x000(总线最高优先级)
TEST_ASSERT_EQUAL_UINT16(0x000, makeId(Group::SYNC, 0));
// J1 的 CMD:组 1 << 7 | 1 = 0x81
TEST_ASSERT_EQUAL_UINT16(0x81, makeId(Group::CMD, 1));
// J6 的 FEEDBACK:组 2 << 7 | 6 = 0x106
TEST_ASSERT_EQUAL_UINT16(0x106, makeId(Group::FEEDBACK, 6));
// J3 的 STATUS:组 3 << 7 | 3 = 0x183
TEST_ASSERT_EQUAL_UINT16(0x183, makeId(Group::STATUS, 3));
}
void test_id_roundtrip() {
for (uint8_t node = 0; node <= MAX_NODES; node++) {
uint16_t id = makeId(Group::CMD, node);
TEST_ASSERT_EQUAL(Group::CMD, groupOf(id));
TEST_ASSERT_EQUAL_UINT8(node, nodeOf(id));
}
}
// ---------------- SYNC 帧 ----------------
void test_sync_encode() {
SyncFrame msg{42};
Frame f = encodeSync(msg);
TEST_ASSERT_EQUAL_UINT16(0x000, f.id);
TEST_ASSERT_EQUAL_UINT8(42, f.data[0]);
}
// ---------------- CMD 帧 ----------------
void test_cmd_roundtrip() {
CmdFrame tx{Mode::VELOCITY, 3.5f, 7};
Frame f = encodeCmd(2, tx);
TEST_ASSERT_EQUAL_UINT16(makeId(Group::CMD, 2), f.id);
CmdFrame rx{};
TEST_ASSERT_TRUE(decodeCmd(f, rx));
TEST_ASSERT_EQUAL(static_cast<uint8_t>(Mode::VELOCITY), static_cast<uint8_t>(rx.mode));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 3.5f, rx.target);
TEST_ASSERT_EQUAL_UINT8(7, rx.seq);
}
void test_cmd_reject_wrong_group() {
// FEEDBACK 帧不应被 decodeCmd 接受
Frame f = encodeFeedback(1, FeedbackFrame{0.0f, 0.0f});
CmdFrame rx{};
TEST_ASSERT_FALSE(decodeCmd(f, rx));
}
// ---------------- FEEDBACK 帧 ----------------
void test_feedback_roundtrip() {
FeedbackFrame tx{1.2345f, -6.789f};
Frame f = encodeFeedback(4, tx);
FeedbackFrame rx{};
TEST_ASSERT_TRUE(decodeFeedback(f, rx));
TEST_ASSERT_FLOAT_WITHIN(0.0001f, 1.2345f, rx.angle);
TEST_ASSERT_FLOAT_WITHIN(0.0001f, -6.789f, rx.velocity);
}
void test_feedback_byte_layout() {
// 验证 little-endian 布局:angle=1.0f 应为 00 00 80 3F
// (字节布局钉死成约定,防止未来跨平台移植踩坑)
Frame f = encodeFeedback(1, FeedbackFrame{1.0f, 2.0f});
TEST_ASSERT_EQUAL_UINT8(0x00, f.data[0]);
TEST_ASSERT_EQUAL_UINT8(0x00, f.data[1]);
TEST_ASSERT_EQUAL_UINT8(0x80, f.data[2]);
TEST_ASSERT_EQUAL_UINT8(0x3F, f.data[3]);
// velocity=2.0f 应为 00 00 00 40(最高字节)
TEST_ASSERT_EQUAL_UINT8(0x40, f.data[7]);
}
// ---------------- STATUS 帧 ----------------
void test_status_roundtrip() {
StatusFrame tx{State::ERROR,
static_cast<uint8_t>(ERR_ENCODER_CRC | ERR_CAN_TIMEOUT),
1234, 5678};
Frame f = encodeStatus(6, tx);
StatusFrame rx{};
TEST_ASSERT_TRUE(decodeStatus(f, rx));
TEST_ASSERT_EQUAL(static_cast<uint8_t>(State::ERROR), static_cast<uint8_t>(rx.state));
TEST_ASSERT_EQUAL_UINT8(ERR_ENCODER_CRC | ERR_CAN_TIMEOUT, rx.errorFlags);
TEST_ASSERT_EQUAL_UINT16(1234, rx.encoderCrcErr);
TEST_ASSERT_EQUAL_UINT16(5678, rx.uptimeSec);
}
// ---------------- 入口 ----------------
int main(int argc, char** argv) {
(void)argc; (void)argv;
UNITY_BEGIN();
RUN_TEST(test_make_id_layout);
RUN_TEST(test_id_roundtrip);
RUN_TEST(test_sync_encode);
RUN_TEST(test_cmd_roundtrip);
RUN_TEST(test_cmd_reject_wrong_group);
RUN_TEST(test_feedback_roundtrip);
RUN_TEST(test_feedback_byte_layout);
RUN_TEST(test_status_roundtrip);
return UNITY_END();
}
其中第 7 个用例(test_feedback_byte_layout)值得特别说明:float 的字节序问题在"同一种 MCU 对同一种 MCU"时不会暴露,但一旦以后有人用大端平台的工具解析总线数据就会踩坑。把字节布局写进测试里,等于把它变成了不容许被破坏的约定。