4、SDO高效读写:多参数批量读写、SDO队列与超时处理
SDO 这东西,说白了就是 CANopen 里的“快递专线”。
每次只送一件货,但保证送到,还会给你回执。PDO 是广播,SDO 是点对点。我刚开始接触 CANopen 时,总觉得 SDO 太慢,后来才发现——不是它慢,是我没用对。
4.1 为什么需要批量读写?
你想想看,一个伺服驱动器,参数少说几十个。位置环增益、速度环增益、加减速时间、电子齿轮比……
如果每个参数都用一次 SDO 去读,光握手就要浪费大把时间。我见过有人初始化一台驱动器,写了 30 个 SDO 请求,耗时 1.2 秒。这在运动控制里,简直是灾难。
所以,批量读写就派上用场了。
核心思路:把多个参数打包,用一次 SDO 请求完成读写。
CANopen 标准里,SDO 支持“块传输”(Block Transfer),但实际项目中用得不多。我更推荐另一种方式——利用对象字典的“数组”或“记录”类型,把相关参数放在一个索引下。
4.2 多参数批量读写的实现
举个例子。我手头有个项目,需要同时读取电机的“当前速度”、“当前位置”和“电流值”。
这三个参数,如果分开读,需要 3 次 SDO 请求。但如果我把它们放在一个自定义对象里,比如索引 0x2100,子索引 0x00 是速度,0x01 是位置,0x02 是电流——那就可以一次搞定。
批量读的流程:
- 主站发送 SDO 下载请求(读操作),指定索引 0x2100,子索引 0x00
- 从站返回的数据,包含速度、位置、电流三个值
- 主站按约定格式解析数据
// 伪代码示例:批量读取三个参数
uint8_t sdo_buffer[8];
// 构造 SDO 请求:读 0x2100, 子索引 0x00
sdo_buffer[0] = 0x40; // 读命令
sdo_buffer[1] = 0x00; // 子索引
sdo_buffer[2] = 0x21; // 索引低字节
sdo_buffer[3] = 0x00; // 索引高字节
// 发送后,从站返回 8 字节数据
// 假设返回:速度(2字节) + 位置(4字节) + 电流(2字节)
int16_t speed = (sdo_buffer[4] << 8) | sdo_buffer[5];
int32_t position = (sdo_buffer[6] << 24) | (sdo_buffer[7] << 16) | ...;
int16_t current = ...;
我的经验:批量读写时,数据长度要固定。我曾经踩过坑——某个驱动器返回的数据长度不固定,导致解析错位。后来我强制要求所有批量参数必须对齐到 4 字节。
4.3 SDO 队列:让通信不再阻塞
你有没有遇到过这种情况?主站发了一个 SDO 请求,然后傻等着从站回复。这期间,其他紧急任务全被堵住了。
嗯,这就是同步 SDO 的弊端。
我建议的做法是:用 SDO 队列。
说白了,就是建一个 FIFO 缓冲区。主站把要发的 SDO 请求扔进队列,然后立刻返回,继续干别的事。后台有个任务,一条一条从队列里取出请求,发送,等待回复,处理结果。
队列的好处:
- 主站不阻塞,实时性更好
- 可以控制 SDO 的发送频率,避免总线拥堵
- 方便做超时处理
// SDO 队列结构体示例
typedef struct {
uint16_t index; // 对象索引
uint8_t subindex; // 子索引
uint8_t data[8]; // 数据缓冲区
uint8_t length; // 数据长度
uint32_t timeout_ms; // 超时时间
uint8_t retry_cnt; // 重试次数
} sdo_request_t;
// 队列缓冲区
#define SDO_QUEUE_SIZE 16
sdo_request_t sdo_queue[SDO_QUEUE_SIZE];
uint8_t sdo_queue_head = 0;
uint8_t sdo_queue_tail = 0;
4.4 超时处理:别让系统死等
为什么会超时?
原因很多:从站没上电、总线断线、从站正忙、SDO 被优先级更高的 PDO 抢占了……
我遇到过最离谱的一次,是某个国产驱动器,SDO 回复偶尔会延迟 500ms。如果按标准超时 100ms 算,那基本每次都会超时重发,然后重发又撞上延迟,恶性循环。
超时处理的三个关键点:
- 超时时间要合理:一般建议 100ms~500ms,具体看从站手册
- 重试机制:超时后重试 2~3 次,仍失败则报错
- 队列清理:超时的请求要从队列里移除,不能堵住后面的请求
注意:千万不要在中断服务函数里做 SDO 超时判断!我见过有人这么干,结果中断嵌套把系统搞崩了。超时处理应该放在主循环或低优先级任务里。
// 超时处理伪代码
void sdo_queue_process(void) {
if (sdo_queue_head == sdo_queue_tail) {
return; // 队列为空
}
sdo_request_t *req = &sdo_queue[sdo_queue_head];
uint32_t now = get_tick_ms();
if (now - req->send_time > req->timeout_ms) {
// 超时了
if (req->retry_cnt > 0) {
// 重试
req->retry_cnt--;
sdo_send(req); // 重新发送
req->send_time = now;
} else {
// 重试耗尽,报错
sdo_error_callback(req->index, req->subindex);
sdo_queue_head = (sdo_queue_head + 1) % SDO_QUEUE_SIZE;
}
} else {
// 检查是否收到回复
if (sdo_reply_received(req)) {
// 处理回复
sdo_process_reply(req);
sdo_queue_head = (sdo_queue_head + 1) % SDO_QUEUE_SIZE;
}
}
}
4.5 实战中的避坑指南
我曾经在一个包装机械项目里,用 SDO 批量读写 20 个参数。刚开始一切正常,但运行 2 小时后,总线偶尔会卡死。
排查了很久,最后发现是 SDO 队列满了,新的请求进不去,而旧的请求因为从站偶尔忙,一直没被处理。结果就是——队列堵死,系统假死。
解决方案:
- 队列深度设大一点,我一般用 32
- 加一个“队列满”的回调,通知上层降低请求频率
- 超时后直接丢弃,不要无限重试
总结一下我的习惯:
- 能用 PDO 的,绝不用 SDO
- 必须用 SDO 的,优先批量读写
- 批量读写搞不定的,上队列+超时
- 超时重试不超过 3 次
4.6 本章知识体系图
下面这张图,帮你理清 SDO 高效读写的核心逻辑:
嗯,这张图基本把本章的核心逻辑串起来了。你写代码时,可以照着这个结构来设计你的 SDO 模块。
最后说一句:SDO 虽然慢,但胜在可靠。用好批量读写、队列和超时,它完全可以满足大部分运动控制场景。我做了这么多年,真正需要上“块传输”的项目,一只手数得过来。
公众号:蓝海资料掘金营,微信deep3321