4、SDO下载协议详解:下载请求、下载响应、分段下载与快速下载的区别、代码示例

SDO下载,说白了就是主站往从站里写数据的过程。很多初学者容易把上传和下载搞反——记住一点:下载是主站发数据给从站。我刚开始调CANopen时也犯过这个迷糊,后来养成了一个习惯:每次看日志先确认方向,再往下分析。

这一节我们重点拆解SDO下载的两种模式:快速下载和分段下载。它们各自适用什么场景?报文长什么样?代码怎么写?咱们一个一个来。

核心要点:SDO下载的本质是主站通过CAN报文,把数据写入从站的对象字典。快速下载用于≤4字节的数据,分段下载用于大数据块。

4.1 快速下载协议

快速下载是最简单的SDO服务。主站发一个请求帧,从站回一个响应帧,数据就写进去了。前提是数据长度不超过4字节。

4.1.1 请求报文格式

字节 位7-5 位4 位3-0 说明
Byte 0 001 0 n CCS=1(下载请求), n=有效数据字节数-1
Byte 1-2 索引(Index),小端格式
Byte 3 子索引(SubIndex)
Byte 4-7 数据(Data),最多4字节,靠左对齐

这里有个细节:n字段表示有效数据字节数减1。比如你要写1个字节,n=0;写4个字节,n=3。我见过不少新手把n填成实际字节数,结果从站直接报异常——嗯,这个坑我当年也踩过。

4.1.2 响应报文格式

字节 位7-5 位4 位3-0 说明
Byte 0 011 0 0 CCS=3(下载响应), 保留位全0
Byte 1-2 保留(通常回显请求的索引)
Byte 3 保留(通常回显请求的子索引)
Byte 4-7 保留(全0)

响应报文很简单,从站收到请求后,如果写入成功,就回一个CCS=3的帧。如果失败,会回一个SDO Abort帧——这个我们后面专门讲。

4.1.3 快速下载示例

假设我们要往对象字典0x2000, 子索引0x01写入一个32位数据0x12345678:

// 主站发送:快速下载请求
// CAN ID: 0x600 + NodeID (假设NodeID=1, 则CAN ID=0x601)
// 数据: 23 00 20 01 78 56 34 12
//
// 字节解析:
// 0x23 = 001 0 0011
//        ^^^ ^ ^^^^
//        |   |   +-- n=3 (4字节数据, n=4-1=3)
//        |   +------ e=0 (普通下载)
//        +---------- CCS=1 (下载请求)
// 0x00 0x20 = 索引 0x2000 (小端)
// 0x01 = 子索引 0x01
// 0x78 0x56 0x34 0x12 = 数据 0x12345678 (小端)

// 从站响应:快速下载响应
// CAN ID: 0x580 + NodeID (假设NodeID=1, 则CAN ID=0x581)
// 数据: 60 00 20 01 00 00 00 00
//
// 0x60 = 011 0 0000
//        ^^^ ^ ^^^^
//        |   |   +-- 保留
//        |   +------ 保留
//        +---------- CCS=3 (下载响应)
我的调试习惯:在写从站代码时,我会在快速下载的处理函数里加一个打印,把收到的索引、子索引和数据值都打出来。这样主站一发请求,我立刻就能在终端看到对不对。省得翻来覆去对报文。

4.2 分段下载协议

当你要写的数据超过4字节——比如固件升级、配置文件下载——就得用分段下载了。分段下载把数据切成一段一段发,每段最多7字节(因为CAN数据场最多8字节,去掉1字节控制字,剩7字节数据)。

4.2.1 分段下载流程

整个流程分三步:

  1. 初始化段下载:主站发一个分段下载请求,告诉从站要写哪个对象、总数据长度是多少。
  2. 数据分段传输:主站一帧一帧发数据,从站每帧都要确认。
  3. 结束段下载:最后一帧发完,从站确认,整个下载完成。

我个人习惯把分段下载想象成寄快递:先填快递单(初始化),然后一箱一箱发货(分段传输),最后签收确认(结束)。

4.2.2 初始化段下载请求

字节 位7-5 位4 位3-0 说明
Byte 0 001 1 0 CCS=1, e=1(分段下载), n=0
Byte 1-2 索引
Byte 3 子索引
Byte 4-7 总数据长度(4字节, 小端)

4.2.3 分段传输请求

字节 位7-5 位4 位3-0 说明
Byte 0 000 t n CCS=0, t=1(最后一帧)/0(中间帧), n=有效数据字节数-1
Byte 1-7 数据(最多7字节)

注意:分段传输的响应帧和快速下载不一样。从站收到每一段后,会回一个分段响应,CCS=1,同时带一个toggle位来防止丢帧。

4.2.4 分段下载示例

假设我们要写入一个10字节的数据块到0x2001, 子索引0x00:

// 第1步:初始化段下载
// 主站发送: 21 01 20 00 0A 00 00 00
// 0x21 = 001 0 0001 -> CCS=1, e=1, n=0
// 索引: 0x2001, 子索引: 0x00
// 数据长度: 0x0000000A (10字节)

// 从站响应: 60 01 20 00 00 00 00 00
// 0x60 = 011 0 0000 -> CCS=3, 下载响应

// 第2步:发送第1段 (7字节)
// 主站发送: 00 01 02 03 04 05 06 07
// 0x00 = 000 0 0110 -> CCS=0, t=0(中间帧), n=6(7字节数据)
// 数据: 01 02 03 04 05 06 07

// 从站响应: 20 00 00 00 00 00 00 00
// 0x20 = 001 0 0000 -> CCS=1, 分段响应

// 第3步:发送第2段 (最后3字节)
// 主站发送: 12 08 09 0A 00 00 00 00
// 0x12 = 000 1 0010 -> CCS=0, t=1(最后一帧), n=2(3字节数据)
// 数据: 08 09 0A

// 从站响应: 30 00 00 00 00 00 00 00
// 0x30 = 001 1 0000 -> CCS=1, t=1(最后一帧确认)
我曾经踩过的坑:有一次调试固件升级,发现从站总是在收到第3段后报错。查了半天,原来是主站发的toggle位没按协议翻转——从站检测到toggle位不变,认为丢帧了,直接abort。从那以后,我写分段传输代码时,都会专门写一个toggle位检查函数。

4.3 快速下载 vs 分段下载

对比项 快速下载 分段下载
数据长度 ≤4字节 任意长度
报文交互 1次请求 + 1次响应 初始化 + N次分段 + N次响应 + 结束
控制字 CCS=1, e=0 CCS=1, e=1(初始化); CCS=0(分段)
适用场景 配置参数、状态设置 固件升级、大数据块
效率 高(2帧搞定) 低(每段都要确认)
可靠性 一般(无分段确认) 高(有toggle位和分段确认)

你想想看,如果你只是改一个电机转速(4字节以内),用快速下载就够了。但如果你要下载一个100KB的固件,不用分段下载根本传不完。

4.4 SDO下载状态机

下面这张图展示了从站处理SDO下载的完整状态流转。我画这张图时参考了CiA 301标准,也结合了自己调试时的实际经验。

SDO下载状态机 空闲 (Idle) 快速下载处理 分段初始化 分段传输中 下载完成 异常 (Abort) CCS=1, e=0 CCS=1, e=1 CCS=0 发送响应 初始化成功 收到下一段 t=1 最后一帧 任何错误 说明:从站收到SDO下载请求后,根据CCS和e位判断进入快速下载还是分段下载模式。

4.5 从站代码示例

下面是一个简化的从站SDO下载处理代码。我把它写成了C语言风格,实际项目中你可能会用CANopen协议栈(比如CANfestival、Lely),但核心逻辑是一样的。

// SDO下载处理函数
// 返回值: 0=成功, 非0=异常码
uint32_t SDO_Download_Handler(uint8_t *rx_data, uint8_t *tx_data)
{
    uint8_t  ccs   = (rx_data[0] >> 5) & 0x07;
    uint8_t  e     = (rx_data[0] >> 4) & 0x01;
    uint8_t  n     = rx_data[0] & 0x0F;
    uint16_t index = (rx_data[2] << 8) | rx_data[1];  // 小端
    uint8_t  sub   = rx_data[3];

    // 检查CCS
    if (ccs != 1 && ccs != 0) {
        return SDO_ABORT_CMD;  // 命令错误
    }

    // 快速下载 (CCS=1, e=0)
    if (ccs == 1 && e == 0) {
        uint8_t data_len = n + 1;  // 有效数据字节数
        if (data_len > 4) {
            return SDO_ABORT_LEN;  // 长度错误
        }
        // 写入对象字典
        if (WriteObjectDict(index, sub, &rx_data[4], data_len) != 0) {
            return SDO_ABORT_ACCESS;  // 访问错误
        }
        // 构造响应
        tx_data[0] = 0x60;  // CCS=3
        tx_data[1] = rx_data[1];
        tx_data[2] = rx_data[2];
        tx_data[3] = rx_data[3];
        for (int i = 4; i < 8; i++) tx_data[i] = 0;
        return 0;
    }

    // 分段下载初始化 (CCS=1, e=1)
    if (ccs == 1 && e == 1) {
        uint32_t total_len = (rx_data[7] << 24) | (rx_data[6] << 16) |
                             (rx_data[5] << 8)  | rx_data[4];
        // 初始化分段下载上下文
        seg_dl_context.index      = index;
        seg_dl_context.sub        = sub;
        seg_dl_context.total_len  = total_len;
        seg_dl_context.received   = 0;
        seg_dl_context.toggle     = 0;
        // 分配缓冲区(实际项目用动态或静态)
        seg_dl_context.buffer     = malloc(total_len);
        if (seg_dl_context.buffer == NULL) {
            return SDO_ABORT_MEM;  // 内存不足
        }
        // 构造响应
        tx_data[0] = 0x60;
        tx_data[1] = rx_data[1];
        tx_data[2] = rx_data[2];
        tx_data[3] = rx_data[3];
        for (int i = 4; i < 8; i++) tx_data[i] = 0;
        return 0;
    }

    // 分段传输 (CCS=0)
    if (ccs == 0) {
        uint8_t  t     = (rx_data[0] >> 4) & 0x01;  // toggle位
        uint8_t  seg_n = rx_data[0] & 0x0F;
        uint8_t  seg_len = seg_n + 1;

        // 检查toggle位
        if (t != seg_dl_context.toggle) {
            return SDO_ABORT_TOGGLE;  // toggle错误
        }

        // 检查缓冲区溢出
        if (seg_dl_context.received + seg_len > seg_dl_context.total_len) {
            return SDO_ABORT_LEN;
        }

        // 拷贝数据到缓冲区
        memcpy(&seg_dl_context.buffer[seg_dl_context.received],
               &rx_data[1], seg_len);
        seg_dl_context.received += seg_len;

        // 翻转toggle位
        seg_dl_context.toggle ^= 1;

        // 构造响应
        tx_data[0] = 0x20 | (t << 4);  // CCS=1, 带toggle
        for (int i = 1; i < 8; i++) tx_data[i] = 0;

        // 如果是最后一帧,写入对象字典
        if (t == 1) {
            if (WriteObjectDict(seg_dl_context.index,
                                seg_dl_context.sub,
                                seg_dl_context.buffer,
                                seg_dl_context.total_len) != 0) {
                free(seg_dl_context.buffer);
                return SDO_ABORT_ACCESS;
            }
            free(seg_dl_context.buffer);
            tx_data[0] = 0x30;  // 最后一帧确认
        }
        return 0;
    }

    return SDO_ABORT_CMD;
}
代码调试建议:我在写分段下载时,会在每个分段处理完后打印当前接收进度,比如"Received 7/1024 bytes"。这样一旦卡住,我马上知道是第几段出了问题。另外,toggle位的检查一定要严格——我曾经因为少写了一个异或,导致从站和主站toggle位不同步,整个下载流程全乱套。

4.6 常见问题与避坑

  • 快速下载n字段填错:n=数据字节数-1,不是实际字节数。写1字节填0,写4字节填3。
  • 分段下载toggle位不翻转:每发一段,toggle位必须翻转。从站收到后也要翻转,否则会认为丢帧。
  • 分段下载最后一帧t位没置1:如果t=0,从站会一直等下一段,永远不会结束。
  • 索引/子索引写错:特别是小端格式,索引0x2001在报文里是0x01 0x20,别搞反了。
  • 缓冲区溢出:分段下载时,如果总长度声明是100字节,但实际发了120字节,从站要能检测并报错。
我曾经遇到的一个诡异问题:有一次调试,快速下载写4字节数据,从站总是只收到前3字节。查了半天,发现是主站把n填成了2(表示3字节),但数据场却放了4字节。从站严格按照n来取数据,所以第4字节被忽略了。这个教训告诉我:永远不要假设主站发的数据是对的,从站要做严格的长度检查。