4、SDO下载协议详解:下载请求、下载响应、分段下载与快速下载的区别、代码示例
SDO下载,说白了就是主站往从站里写数据的过程。很多初学者容易把上传和下载搞反——记住一点:下载是主站发数据给从站。我刚开始调CANopen时也犯过这个迷糊,后来养成了一个习惯:每次看日志先确认方向,再往下分析。
这一节我们重点拆解SDO下载的两种模式:快速下载和分段下载。它们各自适用什么场景?报文长什么样?代码怎么写?咱们一个一个来。
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 分段下载流程
整个流程分三步:
- 初始化段下载:主站发一个分段下载请求,告诉从站要写哪个对象、总数据长度是多少。
- 数据分段传输:主站一帧一帧发数据,从站每帧都要确认。
- 结束段下载:最后一帧发完,从站确认,整个下载完成。
我个人习惯把分段下载想象成寄快递:先填快递单(初始化),然后一箱一箱发货(分段传输),最后签收确认(结束)。
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(最后一帧确认)
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标准,也结合了自己调试时的实际经验。
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;
}
4.6 常见问题与避坑
- 快速下载n字段填错:n=数据字节数-1,不是实际字节数。写1字节填0,写4字节填3。
- 分段下载toggle位不翻转:每发一段,toggle位必须翻转。从站收到后也要翻转,否则会认为丢帧。
- 分段下载最后一帧t位没置1:如果t=0,从站会一直等下一段,永远不会结束。
- 索引/子索引写错:特别是小端格式,索引0x2001在报文里是0x01 0x20,别搞反了。
- 缓冲区溢出:分段下载时,如果总长度声明是100字节,但实际发了120字节,从站要能检测并报错。