【2026 机器人模块大赛】桌面级六轴机械臂关节模块——CAN分布式通信协议设计与单元测试

前两帖完成了系统方案和单关节控制规划,这一帖汇报把 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 等) :white_check_mark:
test_id_roundtrip 组号/节点号编码解码互逆 :white_check_mark:
test_sync_encode SYNC 帧格式 :white_check_mark:
test_cmd_roundtrip CMD 帧 float 目标值无损往返 :white_check_mark:
test_cmd_reject_wrong_group 解码器拒绝错误组号的帧 :white_check_mark:
test_feedback_roundtrip FEEDBACK 帧含负速度往返 :white_check_mark:
test_feedback_byte_layout float32 little-endian 字节布局 :white_check_mark:
test_status_roundtrip STATUS 帧错误位/计数器往返 :white_check_mark:

完整测试代码如下:

/**
 * @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"时不会暴露,但一旦以后有人用大端平台的工具解析总线数据就会踩坑。把字节布局写进测试里,等于把它变成了不容许被破坏的约定。

软件模块分层设计是正确方向,正规大公司开发这是最基本要求,不然硬件缺料需要变更时候会让人崩溃,个人diy时候很少注意这个问题。
可能嵌入式谈架构设计听起来有点太虚了,但实际开发时候不一定多庞大复杂,但基本设计思想是不变的:

  • 分层: 将系统设计切割成若干“层”,每层调用下层接口,为上层提供服务。比如:底层硬件抽象层屏蔽芯片差异,业务逻辑层只需要关心实现功能。
  • 模块化: 横向切割成一个个功能独立的“模块”。每个模块就像一块乐高积木(例如:按键模块、LED模块、温度传感器),按需使用,内部高内聚,彼此低耦合。
  • 抽象: 清晰、稳定的接口定义。业务逻辑层不需要知道LED接在哪个GPIO口,它只需要调用LED_On()这个接口。底层实现变了,接口可以保持不变。接口名字定义也要通俗易懂,可以参考下Linux或者FreeRTOS的一些命名规则。

有时间可以看看谷歌的Pigweed,这是一个嵌入式库(或模块)的集合。使用 Pigweed 模块可​​以在 32 位微控制器( 即嵌入单电路芯片中的小型计算机 )上实现更快、更可靠的开发。不是说一定用这个库,更多是学习它软件分层设计的思想。

感谢指点! Pigweed非常棒,值得好好学习!

把协议编解码抽成纯 C++ 库、三方共用同一份代码这个思路很赞,从根上杜绝了主控、关节、上位机对字节定义不一致导致的"玄学"bug。特别是 SYNC 广播的两段式提交设计,用 ID 优先级保证同步信号不被阻塞,六轴联动时末端不会抖,这个细节处理得很到位。另外把 float 字节序钉进单元测试这个做法很老练,现在看着多余,等将来换平台或接第三方工具时就知道有多省心了。