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 是电流——那就可以一次搞定。

批量读的流程:

  1. 主站发送 SDO 下载请求(读操作),指定索引 0x2100,子索引 0x00
  2. 从站返回的数据,包含速度、位置、电流三个值
  3. 主站按约定格式解析数据
// 伪代码示例:批量读取三个参数
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 算,那基本每次都会超时重发,然后重发又撞上延迟,恶性循环。

超时处理的三个关键点:

  1. 超时时间要合理:一般建议 100ms~500ms,具体看从站手册
  2. 重试机制:超时后重试 2~3 次,仍失败则报错
  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 高效读写 多参数批量读写 对象字典数组/记录类型 一次请求,多个参数 SDO 队列机制 FIFO 缓冲区 非阻塞发送 + 后台处理 超时处理 超时时间设定 重试机制(≤3次) 队列清理 + 错误回调 目标:不阻塞、不丢包、不卡死

嗯,这张图基本把本章的核心逻辑串起来了。你写代码时,可以照着这个结构来设计你的 SDO 模块。

最后说一句:SDO 虽然慢,但胜在可靠。用好批量读写、队列和超时,它完全可以满足大部分运动控制场景。我做了这么多年,真正需要上“块传输”的项目,一只手数得过来。


公众号:蓝海资料掘金营,微信deep3321