跳到主要内容

11. 用 SDO 读取电机参数

学习形式编程实验
硬件要求完整实验平台
配套 Demolab_11_sdo_read
本课动作只读电机对象

上一课已经拆开过下面两帧报文:

ESP32 -> 电机 CAN-ID 0x601 DATA 40 00 10 00 00 00 00 00
电机 -> ESP32 CAN-ID 0x581 DATA 43 00 10 00 92 01 04 00

我们当时只解释了 00 10 00 怎样表示对象地址 0x1000:00,以及 92 01 04 00 怎样按小端序得到 0x00040192

这一课把中间缺少的过程补上:从一个空的 ESP-IDF 工程开始,让 ESP32-C3 亲自发送第一帧,等待电机返回第二帧,然后由程序检查并解码结果。

这次实验只有读取功能:

会发送 SDO 读取请求
会读取 0x1000、0x1001 和 0x1018 等 CiA 301 标准对象
不会发送 NMT 控制命令
不会写入 任何对象
不会访问 0x6040 Controlword
不会设置 电机模式、位置或速度

因此,本课程序不会命令电机运动。完成实验后,你应该能自己回答:0x6010x5810x400x430x80 分别来自哪里。

一次读取为什么需要两帧报文

ESP32 想知道电机的 Device Type,但这个值保存在电机的对象字典里。ESP32 不能直接读取另一颗芯片的变量,只能先通过 CAN 总线提出请求:

请把对象 0x1000:00 的当前值发给我。

电机收到请求后查找自己的对象字典,再返回数据或错误。于是一次读取至少包含两个方向:

ESP32 发请求 -> 电机
电机发响应 -> ESP32

CANopen 把主动访问另一台设备对象字典的一方叫 SDO Client,把拥有这份对象字典、负责回答的一方叫 SDO Server

在本课里:

ESP32 SDO Client,提出读取请求
电机 SDO Server,返回对象值或错误

这里的 Client 和 Server 只说明这次 SDO 通信中谁请求、谁回答,不表示 ESP32 的硬件等级比电机高。

先沿下图从上向下看。注意请求和响应使用不同 CAN-ID,但中间的 Index 和 Sub-index 相同:

读取 0x1000:00 时的 SDO 请求和响应

这就是一次 SDO UploadUpload 容易让初学者误会,因为从 ESP32 的角度看明明是在“读取”。CANopen 以 SDO Server 为参照:数据从拥有对象字典的电机上传给 Client,所以读取操作叫 Upload。本课后面直接称为“SDO 读取”,代码函数仍按规范命名为 canopen_sdo_upload()

CAN in Automation 的 SDO 介绍说明,SDO 用来访问设备对象字典,采用 Client/Server 的确认式通信。数据不超过 4 字节时,可以直接放入初始化响应中,这种方式叫 Expedited transfer,常译为快速传输。

本课读取的 UNSIGNED8UNSIGNED32 都不超过 4 字节,因此一问一答就能完成,不需要学习分段传输。

0x6010x581 是怎样算出来的

CANopen 预定义了一条默认 SDO 通信通道。客户端通过这条通道向设备提出请求,设备再通过同一条通道对应的另一个 CAN-ID 返回响应。两个方向的基础编号分别是:

方向基础 CAN-ID加上本课 Node-ID 0x01实际 CAN-ID
Client 请求 Server0x6000x600 + 0x010x601
Server 响应 Client0x5800x580 + 0x010x581

这里的“通道”不是一根新的 CAN 线,也不是 ESP32-C3 中的第二套 TWAI。它只是约定好的一对 CAN-ID:一个负责请求,一个负责响应。所有报文仍然经过同一对 CANH、CANL。

CANopen 设备还可以配置额外的 SDO 通道,所以有些资料会把默认通道写成“第一个 SDO Server 通道”。其中“第一个”只是通道编号,不是第一台设备、第一次请求,也不是 Node-ID 0x01。本实验只使用默认通道,暂时不需要配置额外通道。

这些基础编号由 CANopen 通信规范定义,不是 ESP32 或电机厂家自己选择的。可以在 CiA 301 CANopen 海报 第 1 页的 Pre-defined connection set 表中核对 SDO transmitSDO receive

这里一定要分清两类数字:

CAN-ID 0x601 找到 Node-ID 0x01 的 SDO 请求通道
CAN-ID 0x581 找到 Node-ID 0x01 的 SDO 响应通道
对象地址 0x1000:00 说明本次具体读取哪个参数

假如电机 Node-ID 改成 0x05,默认通道会变成:

请求 CAN-ID = 0x600 + 0x05 = 0x605
响应 CAN-ID = 0x580 + 0x05 = 0x585

对象地址仍然可以是 0x1000:00。Node-ID 选择设备,对象地址选择这台设备中的参数。

亲手构造第一条读取请求

要读取 0x1000:00,SDO 请求的 8 个 DATA 字节如下:

位置本次值用途
DATA[0]0x40发起 SDO 读取
DATA[1]0x00Index 低字节
DATA[2]0x10Index 高字节
DATA[3]0x00Sub-index
DATA[4..7]00 00 00 00读取请求中未使用

先在 C 语言中写出 Index:

// 本次 SDO 上传读取 0x1000:00,也就是设备类型对象。
uint16_t index = 0x1000;

CANopen 多字节字段使用小端序,所以不能直接猜两个字节的位置。低 8 位放在前面,高 8 位放在后面:

// CANopen 使用小端序:Index 低字节放在 DATA[1],高字节放在 DATA[2]。
request[1] = (uint8_t)(index & 0xFF);
request[2] = (uint8_t)(index >> 8);

代入 0x1000

index & 0xFF = 0x00
index >> 8 = 0x10

于是完整请求可以这样生成:

// CANopen 使用小端序:Index 低字节放在 DATA[1],高字节放在 DATA[2]。
uint8_t request[8] = {0};
request[0] = 0x40;
request[1] = (uint8_t)(index & 0xFF);
request[2] = (uint8_t)(index >> 8);
request[3] = 0x00;

最终发送出去的 DATA 就是:

40 00 10 00 00 00 00 00

这也直接回答了“ESP32 发送的数据怎么和 0x1000 扯上关系”:0x1000 被拆成低字节 00 和高字节 10,放进了 DATA[1]DATA[2];紧接着的 DATA[3] 是 Sub-index 00

0x40 是 CiA 301 定义的 Initiate SDO Upload 请求命令字。现在不用拆它的每一个 bit,只要先确认这条规则来自规范中的 SDO services 表,而不是程序碰巧写了一个 0x40

成功响应怎样告诉我们数据长度

电机针对 0x1000:00 返回过下面这帧真实报文:

CAN-ID 0x581 DLC 8 DATA 43 00 10 00 92 01 04 00

逐项对照:

位置程序需要做什么
CAN-ID0x581确认来自 Node-ID 0x01 的 SDO 响应通道
DATA[0]0x43确认是成功响应,有效数据为 4 字节
DATA[1..2]00 10按小端序还原并核对 Index 0x1000
DATA[3]00核对 Sub-index 0x00
DATA[4..7]92 01 04 00按对象类型 UNSIGNED32 解码

快速上传成功响应常见的命令字为:

命令字有效对象数据长度有效数据所在位置
0x4F1 字节DATA[4]
0x4B2 字节DATA[4..5]
0x473 字节DATA[4..6]
0x434 字节DATA[4..7]
0x42快速响应,但命令字未说明长度需要事先知道对象类型

本课只读取 1 字节和 4 字节标准对象,因此程序处理 0x4F0x43,同时兼容常见的 0x4B0x42。以后需要读取 3 字节自定义对象时,再补充 0x47

不能只看到 0x581 就把它当成当前答案。程序还必须核对响应里的 Index 和 Sub-index。总线上可能同时有 Heartbeat,也可能出现其他 SDO 通信;不核对地址,就有机会把别的响应解释成 0x1000:00

对象不存在时,电机也会回答

如果请求的对象或 Sub-index 不存在,正确的 SDO Server 不会返回一个假的数值。它会返回命令字 0x80,并在最后 4 字节给出 SDO Abort Code

例如,下面是一种可能的响应:

CAN-ID 0x581 DLC 8 DATA 80 18 10 05 11 00 09 06

先按同样方法检查:

DATA[1..2] 18 10 -> Index 0x1018
DATA[3] 05 -> Sub-index 0x05
DATA[0] 80 -> SDO Abort

最后四个字节同样是小端序:

11 00 09 06 -> 0x06090011

0x06090011 表示 Sub-index 不存在。有些设备对类似请求也可能返回 0x06020000,表示对象不存在,应以设备实际响应为准。

收到 Abort 不等于 CAN 总线坏了。恰恰相反,它通常说明下面几件事已经成功:

请求到达了目标节点
目标节点识别了 SDO 格式
目标节点检查了对象字典
目标节点通过 0x581 返回了明确结果

失败的是“这个对象地址能否访问”,不是“有没有通信”。程序应该打印 Abort Code,然后继续,而不是崩溃或无限重试。

本课会先读取 0x1018:00。如果电机回答最高 Sub-index 是 0x04,程序才尝试读取不存在的 0x1018:05,用它安全地观察一次 Abort。若某台设备真的实现了 0x1018:05,程序会跳过或报告它存在,不会把猜测当事实。

等待响应不能写成固定延时

下面这种写法不能证明已经收到响应:

// 错误示例:固定延时不能证明响应已经到达,也不能区分超时和 Abort。
send_request();
vTaskDelay(pdMS_TO_TICKS(100));
decode_last_frame();

等待 100 ms 只代表时间过去了。它没有说明:

是否真的收到 CAN 报文
收到的 CAN-ID 是否为 0x581
Index 和 Sub-index 是否匹配
响应是成功还是 Abort

正确做法是为这次请求算出截止时间,并逐帧检查。在截止时间之前收到无关帧,就忽略并继续等;到期仍没有匹配响应,才返回超时。

看下面这张图时,从顶部沿竖直主线向下读。右侧分支表示“当前帧不是答案”或“设备明确拒绝了请求”:

SDO 客户端检查响应的顺序

本课程序一次只发出一个 SDO 请求。收到对应响应或超时之后,才读取下一个对象。这样程序始终知道当前在等哪一个 Index 和 Sub-index。

创建本课工程

课程参考工程位于:

can-canopen-course/examples/lab_11_sdo_read

下面不直接从参考工程开始,而是亲手创建一个同名练习工程。这样每个文件为什么出现、它负责什么,都能看清楚。

打开终端,加载 ESP-IDF v5.5.4:

# 在当前终端加载 ESP-IDF 环境;新开终端后需要重新执行。
source "$HOME/esp/esp-idf-v5.5.4/export.sh"
idf.py --version

$HOME 表示当前用户的主目录。如果 ESP-IDF 安装在其他位置,请把路径换成自己的实际路径。source 设置的环境只对当前终端有效,新开终端后需要重新执行。

版本输出应为:

ESP-IDF v5.5.4

在自己的练习目录创建标准工程:

# 创建一个全新的练习工程,并进入工程目录核对生成结果。
mkdir -p "$HOME/esp/can-course-work"
test ! -e "$HOME/esp/can-course-work/lab_11_sdo_read"
idf.py create-project -p "$HOME/esp/can-course-work/lab_11_sdo_read" lab_11_sdo_read
cd "$HOME/esp/can-course-work/lab_11_sdo_read"

test ! -e ... 没有输出并返回成功,才表示目标路径尚不存在。若路径已经存在,先换一个新名称,不要覆盖以前的实验。

标准命令会生成一个最小主源文件。为了使用本课的文件名,把它改名:

# 把自动生成的入口文件改成课程后续统一使用的名称。
mv main/lab_11_sdo_read.c main/app_main.c

最终源码分为三部分:

main/can_bus.c 只负责收发 Classical CAN 帧
main/canopen_sdo.c 只负责组装和检查 SDO 读取
main/app_main.c 决定按什么顺序读取哪些对象

这种拆分不是为了追求文件数量。它让我们能分别回答“CAN 帧有没有发出去”和“SDO 内容是否正确”。

第一步:声明 CAN 总线接口

新建 main/can_bus.h

// 这个头文件只描述“怎样收发一帧 CAN”,不出现 SDO、Index 或对象类型。
// 这样 CAN 层可以单独测试,后面的 CANopen 层只通过这些函数使用总线。
#pragma once

#include <stdint.h>

#include "driver/gpio.h"
#include "esp_err.h"

// GPIO 来自本教程底板连接,位速率必须与电机当前配置保持一致。
#define CAN_BUS_TX_GPIO GPIO_NUM_19
#define CAN_BUS_RX_GPIO GPIO_NUM_18
#define CAN_BUS_BITRATE 500000

typedef struct {
// 本课只使用 11 位标准 CAN-ID,但用 uint32_t 保存可直接配合驱动和日志格式。
uint32_t id;
// DLC 表示 data 中有几个有效字节,Classical CAN 的范围是 0~8。
uint8_t dlc;
uint8_t data[8];
} can_bus_frame_t;

// init 创建控制器和队列;send 等待发送完成;receive 最多等待 timeout_ms。
esp_err_t can_bus_init(void);
esp_err_t can_bus_send(uint32_t id, const uint8_t *data, uint8_t dlc,
uint32_t timeout_ms);
esp_err_t can_bus_receive(can_bus_frame_t *frame, uint32_t timeout_ms);
void can_bus_log_frame(const char *direction, const can_bus_frame_t *frame);

这个头文件先确定四件事:

GPIO19 ESP32-C3 输出 TWAI TX 数字信号
GPIO18 ESP32-C3 接收 TWAI RX 数字信号
500000 CAN 位速率为 500 kbit/s
can_bus_frame_t 程序内部保存一帧 11 位 Classical CAN 报文

can_bus_send()can_bus_receive() 只认识 CAN-ID、DLC 和 DATA。它们不知道 0x1000 是 Device Type,也不知道什么叫 SDO。这正是 CAN 层与 CANopen 层的区别。

第二步:初始化 TWAI,并把中断数据交给任务

新建 main/can_bus.c。先写头文件、队列和两个回调:

// 本文件把 TWAI 中断接口包装成普通任务可调用的同步 CAN 接口。
// 两个队列分别传递“收到的一帧”和“上一帧是否发送成功”,不要混用。
#include "can_bus.h"

#include <inttypes.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>

#include "driver/gpio.h"
#include "esp_check.h"
#include "esp_log.h"
#include "esp_twai.h"
#include "esp_twai_onchip.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"

#define RX_QUEUE_LENGTH 24
#define TX_DONE_QUEUE_LENGTH 1

static const char *TAG = "can_bus";
static twai_node_handle_t s_twai_node;
static QueueHandle_t s_rx_queue;
static QueueHandle_t s_tx_done_queue;

static bool IRAM_ATTR on_rx_done(twai_node_handle_t handle,
const twai_rx_done_event_data_t *event,
void *user_ctx)
{
(void)event;
(void)user_ctx;

// 回调运行在中断环境,task_woken 用于请求中断退出后立即调度被唤醒的任务。
BaseType_t task_woken = pdFALSE;

// data 是本次回调的临时缓冲区,回调结束后就失效,所以后面必须复制进队列记录。
uint8_t data[TWAI_FRAME_MAX_LEN];
twai_frame_t twai_frame = {
.buffer = data,
.buffer_len = sizeof(data),
};

// 只有成功取到完整 CAN 帧,才把数据复制到软件队列。
if (twai_node_receive_from_isr(handle, &twai_frame) == ESP_OK) {
can_bus_frame_t frame = {
.id = twai_frame.header.id,
.dlc = (uint8_t)twai_frame.header.dlc,
};
// 即使驱动给出异常 DLC,也绝不能写出 frame.data 的边界。
if (frame.dlc > sizeof(frame.data)) {
frame.dlc = sizeof(frame.data);
}
// 驱动缓冲区离开回调后不再可靠,因此把有效字节复制到自己的记录中。
memcpy(frame.data, data, frame.dlc);
// 队列会复制 frame 的全部字段,不会只保存这个局部变量的地址。
// 队列已满时本课选择丢掉新帧;不能在中断中阻塞等待空位。
(void)xQueueSendFromISR(s_rx_queue, &frame, &task_woken);
}

return task_woken == pdTRUE;
}

static bool IRAM_ATTR on_tx_done(twai_node_handle_t handle,
const twai_tx_done_event_data_t *event,
void *user_ctx)
{
(void)handle;
(void)user_ctx;

BaseType_t task_woken = pdFALSE;
// 驱动的发送完成事件包含 ACK 结果;转换为 0/1 后交给正在等待的普通任务。
uint8_t success = event->is_tx_success ? 1 : 0;
// 完成队列只有一个槽位,overwrite 保证里面始终是最近一次发送结果。
(void)xQueueOverwriteFromISR(s_tx_done_queue, &success, &task_woken);
return task_woken == pdTRUE;
}

ESP-IDF v5.5.4 的新 TWAI 驱动在收到报文和发送完成时调用这些回调。回调运行在中断环境,不能在里面等待,也不适合直接打印长日志。因此代码只做两件很短的事:

on_rx_done 从驱动取出报文,复制到 FreeRTOS 接收队列
on_tx_done 把本次发送是否成功写入完成队列

普通任务再从队列取数据、等待和打印。这样慢速串口日志不会堵住中断。

继续在同一文件中加入统一日志函数:

// 发送和接收统一走这个函数,串口中的 CAN-ID、DLC、DATA 格式才能直接逐项对照。
void can_bus_log_frame(const char *direction, const can_bus_frame_t *frame)
{
// 8 字节最多显示成“00 11 22 33 44 55 66 77”,24 字节空间足够容纳结尾 \0。
char data_text[3 * 8] = {0};
size_t used = 0;

// frame->dlc 在接收回调和发送入口都已经限制为不超过 8。
for (uint8_t i = 0; i < frame->dlc; i++) {
int written = snprintf(data_text + used, sizeof(data_text) - used,
"%s%02X", i == 0 ? "" : " ", frame->data[i]);
if (written < 0 || (size_t)written >= sizeof(data_text) - used) {
break;
}
used += (size_t)written;
}

ESP_LOGI(TAG, "%s CAN-ID 0x%03" PRIX32 " DLC %u DATA [%s]",
direction, frame->id, frame->dlc, data_text);
}

0x%03 让 11 位 CAN-ID 始终显示为三位十六进制,例如 0x0010x581%02X 让每个 DATA 字节始终显示两位,避免把十六进制和十进制混在一起。

现在加入初始化函数:

// 初始化顺序是“先建队列、再建节点、注册回调、最后启动”。
// 节点一旦启动就可能产生中断,因此不能把队列创建放到最后。
esp_err_t can_bus_init(void)
{
// 接收队列稍大,用来吸收 Heartbeat 与 SDO 响应短时间连续到达的情况。
s_rx_queue = xQueueCreate(RX_QUEUE_LENGTH, sizeof(can_bus_frame_t));
ESP_RETURN_ON_FALSE(s_rx_queue != NULL, ESP_ERR_NO_MEM, TAG,
"failed to create RX queue");

// 同一时刻只有本任务发出一条 SDO 请求,所以发送完成队列只需要一个槽位。
s_tx_done_queue = xQueueCreate(TX_DONE_QUEUE_LENGTH, sizeof(uint8_t));
ESP_RETURN_ON_FALSE(s_tx_done_queue != NULL, ESP_ERR_NO_MEM, TAG,
"failed to create TX completion queue");

twai_onchip_node_config_t node_config = {
.io_cfg = {
.tx = CAN_BUS_TX_GPIO,
.rx = CAN_BUS_RX_GPIO,
.quanta_clk_out = GPIO_NUM_NC,
.bus_off_indicator = GPIO_NUM_NC,
},
.bit_timing.bitrate = CAN_BUS_BITRATE,
// 仲裁或瞬时发送失败时由驱动最多重试 3 次;最终结果仍通过 on_tx_done 返回。
.fail_retry_cnt = 3,
// 深度 4 是驱动内部发送队列,不是上面的“发送完成队列”。
.tx_queue_depth = 4,
.flags.no_receive_rtr = true,
};

ESP_RETURN_ON_ERROR(twai_new_node_onchip(&node_config, &s_twai_node),
TAG, "failed to create TWAI node");

twai_mask_filter_config_t filter = {
.id = 0,
.mask = 0,
.is_ext = false,
.no_classic = false,
.no_fd = true,
};
// SDO 等待期间仍要允许 Heartbeat 进入接收队列,具体响应筛选由 SDO 层完成。
ESP_RETURN_ON_ERROR(twai_node_config_mask_filter(s_twai_node, 0, &filter),
TAG, "failed to configure accept-all filter");

twai_event_callbacks_t callbacks = {
.on_rx_done = on_rx_done,
.on_tx_done = on_tx_done,
};
ESP_RETURN_ON_ERROR(
twai_node_register_event_callbacks(s_twai_node, &callbacks, NULL),
TAG, "failed to register TWAI callbacks");
ESP_RETURN_ON_ERROR(twai_node_enable(s_twai_node), TAG,
"failed to enable TWAI node");

ESP_LOGI(TAG, "TWAI ready: TX GPIO%d, RX GPIO%d, %d bit/s, normal mode",
CAN_BUS_TX_GPIO, CAN_BUS_RX_GPIO, CAN_BUS_BITRATE);
return ESP_OK;
}

这里使用正常模式,不是自测试或只听模式。过滤器的 id=0, mask=0 表示暂时接收所有标准帧,便于在等待 SDO 时也看到 Heartbeat。no_fd=true 明确只使用 Classical CAN;ESP32-C3 的片内 TWAI 控制器不支持 CAN FD。

最后加入发送和接收函数:

// 这个函数不仅把帧交给驱动,还等待 on_tx_done 返回 ACK 结果。
// “成功放入发送队列”和“总线上真的有节点 ACK”是两个不同阶段。
esp_err_t can_bus_send(uint32_t id, const uint8_t *data, uint8_t dlc,
uint32_t timeout_ms)
{
ESP_RETURN_ON_FALSE(s_twai_node != NULL, ESP_ERR_INVALID_STATE, TAG,
"TWAI is not initialized");
ESP_RETURN_ON_FALSE(id <= TWAI_STD_ID_MASK, ESP_ERR_INVALID_ARG, TAG,
"CAN-ID is not an 11-bit standard identifier");
ESP_RETURN_ON_FALSE(data != NULL && dlc <= 8, ESP_ERR_INVALID_ARG, TAG,
"invalid data or DLC");

// 先复制一份只用于日志,避免日志函数依赖调用者缓冲区的生命周期。
can_bus_frame_t log_frame = {
.id = id,
.dlc = dlc,
};
// 驱动缓冲区离开回调后不再可靠,因此把有效字节复制到自己的记录中。
memcpy(log_frame.data, data, dlc);
can_bus_log_frame("TX", &log_frame);

// 清掉上一帧残留的完成结果,否则本次等待可能误读到旧 ACK。
xQueueReset(s_tx_done_queue);
twai_frame_t frame = {
.header.id = id,
.header.dlc = dlc,
.buffer = (uint8_t *)data,
.buffer_len = dlc,
};
// transmit 返回 ESP_OK 只表示驱动接受了这帧,下面仍要等待发送完成事件。
ESP_RETURN_ON_ERROR(twai_node_transmit(s_twai_node, &frame, timeout_ms),
TAG, "failed to queue TWAI frame");

uint8_t tx_success = 0;
if (xQueueReceive(s_tx_done_queue, &tx_success,
pdMS_TO_TICKS(timeout_ms)) != pdTRUE) {
ESP_LOGW(TAG, "TX completion timeout for CAN-ID 0x%03" PRIX32, id);
return ESP_ERR_TIMEOUT;
}
if (tx_success == 0) {
ESP_LOGW(TAG, "TX failed for CAN-ID 0x%03" PRIX32
"; check bitrate, wiring, termination, and peer power", id);
return ESP_FAIL;
}

ESP_LOGI(TAG, "TX acknowledged: CAN-ID 0x%03" PRIX32, id);
return ESP_OK;
}

esp_err_t can_bus_receive(can_bus_frame_t *frame, uint32_t timeout_ms)
{
ESP_RETURN_ON_FALSE(frame != NULL, ESP_ERR_INVALID_ARG, TAG,
"frame is NULL");
ESP_RETURN_ON_FALSE(s_rx_queue != NULL, ESP_ERR_INVALID_STATE, TAG,
"TWAI is not initialized");

// xQueueReceive 会把完整结构体复制到调用者提供的 frame;超时不是总线驱动错误。
return xQueueReceive(s_rx_queue, frame, pdMS_TO_TICKS(timeout_ms)) == pdTRUE
? ESP_OK
: ESP_ERR_TIMEOUT;
}

twai_node_transmit() 把报文交给驱动,并不等于报文已经得到总线上的 ACK。程序继续等待 on_tx_done 的结果:

TX acknowledged 至少有另一个正常工作的 CAN 节点确认收到这一帧
TX failed 优先检查供电、共地、CANH/CANL、终端和位速率

ACK 只能证明 CAN 层有另一个节点响应,不能证明电机已经成功读取对象。真正的 SDO 成功还需要收到匹配的 0x581 响应。

第三步:声明只读 SDO 接口

新建 main/canopen_sdo.h

// SDO 层把成功数据和 Abort Code 放在同一个结果结构中,调用者可以区分
// “读到了几个字节”和“设备为什么拒绝请求”,而不需要接触原始 CAN 帧。
#pragma once

#include <stdint.h>

#include "esp_err.h"

typedef struct {
// expedited SDO 本课最多返回 4 个有效字节;size 指出这次究竟返回 1、2 还是 4。
uint8_t size;
uint8_t data[4];
// 只有收到命令字 0x80 的 Abort 响应时才填写;成功时保持为 0。
uint32_t abort_code;
} canopen_sdo_result_t;

esp_err_t canopen_sdo_upload(uint8_t node_id, uint16_t index,
uint8_t sub_index, uint32_t timeout_ms,
canopen_sdo_result_t *result);

uint8_t canopen_sdo_as_u8(const canopen_sdo_result_t *result);
uint16_t canopen_sdo_as_u16(const canopen_sdo_result_t *result);
uint32_t canopen_sdo_as_u32(const canopen_sdo_result_t *result);
const char *canopen_sdo_abort_name(uint32_t abort_code);

canopen_sdo_result_t 保存三种结果信息:

size 成功响应带回几个有效字节
data[4] 成功响应的对象数据
abort_code 设备拒绝请求时返回的错误编号

这个接口故意没有 downloadwrite 函数。本课程序从接口层面就无法写电机对象。

第四步:写出 SDO 读取过程

新建 main/canopen_sdo.c。先写常量和小端序辅助函数:

// SDO 的 Index 和多字节数据都使用小端序。把转换集中在这里,
// 后面的状态机可以直接围绕“对象地址”和“响应类型”阅读。
#include "canopen_sdo.h"

#include <inttypes.h>
#include <string.h>

#include "can_bus.h"
#include "esp_check.h"
#include "esp_log.h"
#include "esp_timer.h"

// 对 Node-ID 为 N 的默认 SDO Server,请求 CAN-ID 是 0x600+N,响应是 0x580+N。
#define SDO_REQUEST_BASE_ID 0x600
#define SDO_RESPONSE_BASE_ID 0x580
#define SDO_UPLOAD_REQUEST 0x40
#define SDO_ABORT_RESPONSE 0x80

static const char *TAG = "canopen_sdo";

static uint16_t read_le_u16(const uint8_t *data)
{
// data[0] 是低字节,data[1] 左移 8 位后成为高字节。
return (uint16_t)data[0] | ((uint16_t)data[1] << 8);
}

static uint32_t read_le_u32(const uint8_t *data)
{
return (uint32_t)data[0] |
((uint32_t)data[1] << 8) |
((uint32_t)data[2] << 16) |
((uint32_t)data[3] << 24);
}

static void write_le_u16(uint8_t *data, uint16_t value)
{
// Index 低字节写入 DATA[1],高字节写入 DATA[2]。
data[0] = (uint8_t)(value & 0xFF);
data[1] = (uint8_t)(value >> 8);
}

三个函数分别完成:从两个字节读取 16 位小端数、从四个字节读取 32 位小端数、把 16 位 Index 写进两个字节。它们把位移操作集中起来,后面的协议代码会更容易读。

继续加入常见 Abort Code 的文字说明和数值转换函数:

// Abort Code 是设备返回的 32 位编号。这里仅翻译本课常见值,
// 未列出的编号保留为 unknown,串口仍会打印原始十六进制值供查表。
const char *canopen_sdo_abort_name(uint32_t abort_code)
{
switch (abort_code) {
case 0x05040000:
return "SDO protocol timed out";
case 0x06010001:
return "attempt to read a write-only object";
case 0x06020000:
return "object does not exist";
case 0x06070010:
return "data type or length mismatch";
case 0x06090011:
return "sub-index does not exist";
case 0x08000000:
return "general error";
default:
return "unknown abort code";
}
}

uint8_t canopen_sdo_as_u8(const canopen_sdo_result_t *result)
{
// 调用者必须先确认 result->size == 1,避免把短响应误当成其他类型。
return result->data[0];
}

uint16_t canopen_sdo_as_u16(const canopen_sdo_result_t *result)
{
return read_le_u16(result->data);
}

uint32_t canopen_sdo_as_u32(const canopen_sdo_result_t *result)
{
return read_le_u32(result->data);
}

最后写本课最重要的 canopen_sdo_upload()

// 完成一次只读 SDO 上传:组请求、发送、等待匹配响应、识别 Abort,再复制有效数据。
// 函数只返回原始字节和长度;对象究竟是有符号还是无符号,由调用者按对象字典决定。
esp_err_t canopen_sdo_upload(uint8_t node_id, uint16_t index,
uint8_t sub_index, uint32_t timeout_ms,
canopen_sdo_result_t *result)
{
ESP_RETURN_ON_FALSE(node_id >= 1 && node_id <= 127,
ESP_ERR_INVALID_ARG, TAG, "invalid Node-ID");
ESP_RETURN_ON_FALSE(result != NULL, ESP_ERR_INVALID_ARG, TAG,
"result is NULL");

// 清掉调用者上一次留下的数据和 Abort Code,防止失败后误读旧结果。
memset(result, 0, sizeof(*result));

// expedited upload 请求固定为 8 字节:0x40、Index 低/高字节、Sub-index,其余填 0。
uint8_t request[8] = {0};
request[0] = SDO_UPLOAD_REQUEST;
write_le_u16(&request[1], index);
request[3] = sub_index;

// 请求和响应 CAN-ID 都由目标 Node-ID 计算,不能直接写死为 0x601/0x581。
const uint32_t request_id = SDO_REQUEST_BASE_ID + node_id;
const uint32_t response_id = SDO_RESPONSE_BASE_ID + node_id;

ESP_LOGI(TAG, "Read request: Node-ID 0x%02X, object 0x%04X:%02X",
node_id, index, sub_index);
ESP_RETURN_ON_ERROR(can_bus_send(request_id, request, sizeof(request),
timeout_ms),
TAG, "failed to send SDO request");

// 使用绝对截止时间;忽略 Heartbeat 等无关帧时不会重新开始一整轮超时。
int64_t deadline_us = esp_timer_get_time() + (int64_t)timeout_ms * 1000;

while (true) {
int64_t remaining_us = deadline_us - esp_timer_get_time();
if (remaining_us <= 0) {
ESP_LOGW(TAG, "SDO timeout: object 0x%04X:%02X", index, sub_index);
return ESP_ERR_TIMEOUT;
}

uint32_t remaining_ms = (uint32_t)((remaining_us + 999) / 1000);
can_bus_frame_t frame = {0};
esp_err_t ret = can_bus_receive(&frame, remaining_ms);
if (ret == ESP_ERR_TIMEOUT) {
ESP_LOGW(TAG, "SDO timeout: object 0x%04X:%02X", index, sub_index);
return ret;
}
ESP_RETURN_ON_ERROR(ret, TAG, "CAN receive failed");
can_bus_log_frame("RX", &frame);

// 接收队列中还可能有 Heartbeat。CAN-ID 或 DLC 不匹配时继续等,但截止时间不变。
if (frame.id != response_id || frame.dlc != 8) {
continue;
}

// 即使 CAN-ID 正确,也要核对 Index/Sub-index,避免把另一笔 SDO 响应配给当前请求。
uint16_t response_index = read_le_u16(&frame.data[1]);
uint8_t response_sub_index = frame.data[3];
if (response_index != index || response_sub_index != sub_index) {
ESP_LOGW(TAG,
"Ignore unrelated SDO response for 0x%04X:%02X",
response_index, response_sub_index);
continue;
}

// 0x80 表示 SDO Abort,DATA[4..7] 按小端序携带 32 位错误编号。
if (frame.data[0] == SDO_ABORT_RESPONSE) {
result->abort_code = read_le_u32(&frame.data[4]);
ESP_LOGW(TAG, "SDO Abort 0x%08" PRIX32 ": %s",
result->abort_code,
canopen_sdo_abort_name(result->abort_code));
return ESP_ERR_INVALID_RESPONSE;
}

// expedited upload 的命令字同时编码有效数据长度,不能无条件复制 4 字节。
switch (frame.data[0]) {
case 0x4F:
result->size = 1;
break;
case 0x4B:
result->size = 2;
break;
case 0x43:
result->size = 4;
break;
case 0x42:
result->size = 4;
ESP_LOGW(TAG, "Response 0x42 does not indicate data length; "
"keep all four data bytes");
break;
default:
ESP_LOGW(TAG, "Unexpected SDO command byte 0x%02X",
frame.data[0]);
return ESP_ERR_INVALID_RESPONSE;
}

// 前面已经由命令字得到 size,此处只复制真正有效的 DATA[4..]。
memcpy(result->data, &frame.data[4], result->size);
return ESP_OK;
}
}

把函数按执行顺序重新读一遍:

  1. 检查 Node-ID 和输出指针;
  2. 组装 0x40 + Index + Sub-index
  3. 0x600 + Node-ID 发送;
  4. 只等待到调用者给出的截止时间;
  5. 核对 0x580 + Node-ID、DLC、Index 和 Sub-index;
  6. 0x80 按 Abort 处理,成功命令字按长度复制数据;
  7. 返回给 app_main,再决定如何解释对象类型。

这里没有用固定延时冒充响应,也不会把一帧 0x701 Heartbeat 当成 SDO 结果。

第五步:决定读取哪些标准对象

打开生成的 main/app_main.c,替换为下面内容:

// app_main 只决定“读哪些对象、按什么类型解释”;原始 CAN 收发和 SDO 状态机
// 分别留在 can_bus.c 与 canopen_sdo.c,避免三层逻辑混在一个函数里。
#include <inttypes.h>
#include <stdint.h>

#include "can_bus.h"
#include "canopen_sdo.h"
#include "esp_err.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

#define MOTOR_NODE_ID 0x01
#define SDO_TIMEOUT_MS 1000

static const char *TAG = "lab_11_sdo_read";

static esp_err_t read_u8(const char *name, uint16_t index, uint8_t sub_index,
uint8_t *value)
{
canopen_sdo_result_t result;
esp_err_t ret = canopen_sdo_upload(MOTOR_NODE_ID, index, sub_index,
SDO_TIMEOUT_MS, &result);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "%s 0x%04X:%02X failed: %s",
name, index, sub_index, esp_err_to_name(ret));
return ret;
}
// 上传成功仍要检查长度;对象表若写错类型,CAN 层成功也不能直接解释数据。
if (result.size != 1) {
ESP_LOGW(TAG, "%s returned %u bytes; expected 1", name, result.size);
return ESP_ERR_INVALID_SIZE;
}

*value = canopen_sdo_as_u8(&result);
ESP_LOGI(TAG, "%s 0x%04X:%02X = 0x%02X (%u)",
name, index, sub_index, *value, *value);
return ESP_OK;
}

static esp_err_t read_u32(const char *name, uint16_t index, uint8_t sub_index,
uint32_t *value)
{
canopen_sdo_result_t result;
esp_err_t ret = canopen_sdo_upload(MOTOR_NODE_ID, index, sub_index,
SDO_TIMEOUT_MS, &result);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "%s 0x%04X:%02X failed: %s",
name, index, sub_index, esp_err_to_name(ret));
return ret;
}
if (result.size != 4) {
ESP_LOGW(TAG, "%s returned %u bytes; expected 4", name, result.size);
return ESP_ERR_INVALID_SIZE;
}

*value = canopen_sdo_as_u32(&result);
ESP_LOGI(TAG, "%s 0x%04X:%02X = 0x%08" PRIX32 " (%" PRIu32 ")",
name, index, sub_index, *value, *value);
return ESP_OK;
}

static void demonstrate_abort(uint8_t highest_identity_sub_index)
{
// 只有设备明确没有实现 0x1018:05 时才做 Abort 演示,避免把合法对象当成错误请求。
if (highest_identity_sub_index >= 5) {
ESP_LOGW(TAG, "Identity object implements sub-index 0x05; skip Abort demo");
return;
}

ESP_LOGI(TAG, "Abort demo: read unsupported Identity sub-index 0x05");
canopen_sdo_result_t result;
esp_err_t ret = canopen_sdo_upload(MOTOR_NODE_ID, 0x1018, 0x05,
SDO_TIMEOUT_MS, &result);
if (ret == ESP_ERR_INVALID_RESPONSE && result.abort_code != 0) {
ESP_LOGI(TAG, "Abort was handled; program continues normally");
} else if (ret == ESP_OK) {
ESP_LOGW(TAG, "0x1018:05 exists on this device; no Abort was returned");
} else {
ESP_LOGW(TAG, "Abort demo ended with %s", esp_err_to_name(ret));
}
}

void app_main(void)
{
ESP_LOGI(TAG, "First read-only CANopen SDO experiment");
ESP_LOGI(TAG, "Target %s, TX GPIO%d, RX GPIO%d, bitrate %d bit/s",
CONFIG_IDF_TARGET, CAN_BUS_TX_GPIO, CAN_BUS_RX_GPIO,
CAN_BUS_BITRATE);
ESP_LOGI(TAG, "Motor Node-ID 0x%02X; SDO request 0x%03X, response 0x%03X",
MOTOR_NODE_ID, 0x600 + MOTOR_NODE_ID,
0x580 + MOTOR_NODE_ID);
ESP_LOGW(TAG, "Read-only: this program has no SDO write or motor command");

// 总线初始化失败就停止实验;没有有效 TWAI 节点时继续发 SDO 没有意义。
ESP_ERROR_CHECK(can_bus_init());

uint32_t device_type = 0;
uint8_t error_register = 0;
uint8_t identity_entries = 0;
uint32_t vendor_id = 0;
uint32_t product_code = 0;
uint32_t revision = 0;
uint32_t serial_number = 0;

(void)read_u32("Device Type", 0x1000, 0x00, &device_type);
(void)read_u8("Error Register", 0x1001, 0x00, &error_register);

// 0x1018:00 先告诉我们 Identity 对象实际实现了多少个子项,再决定是否读取 01~04。
if (read_u8("Identity entries", 0x1018, 0x00,
&identity_entries) == ESP_OK) {
if (identity_entries >= 1) {
(void)read_u32("Vendor ID", 0x1018, 0x01, &vendor_id);
}
if (identity_entries >= 2) {
(void)read_u32("Product Code", 0x1018, 0x02, &product_code);
}
if (identity_entries >= 3) {
(void)read_u32("Revision Number", 0x1018, 0x03, &revision);
}
if (identity_entries >= 4) {
(void)read_u32("Serial Number", 0x1018, 0x04, &serial_number);
}
demonstrate_abort(identity_entries);
}

ESP_LOGI(TAG, "Read-only SDO experiment finished; listening to CAN frames");
while (true) {
can_bus_frame_t frame;
if (can_bus_receive(&frame, 1000) == ESP_OK) {
can_bus_log_frame("RX", &frame);
}
}
}

read_u8()read_u32() 都先调用同一个 SDO 读取函数,然后检查响应长度是否符合对象类型。这样即使设备返回格式异常,程序也不会把一个字节硬当成四字节数。

0x1018:00 必须先读。只有它说明存在 0104,程序才继续读取 Vendor ID、Product Code、Revision Number 和 Serial Number。代码没有假设每台设备都一定实现四项。

每个读取结果都被单独处理,某个对象 Abort 或超时不会让程序崩溃。这里的 (void) 明确表示:我们已经知道函数可能失败,本实验选择继续测试下一个标准对象。

第六步:把三个源文件交给构建系统

main/CMakeLists.txt 改为:

# 三个源文件分别负责实验入口、CAN 帧收发和 SDO 协议;少写任意一个都不会进入最终固件。
# esp_timer 用于维护整次 SDO 上传的绝对超时时间。
idf_component_register(
SRCS
"app_main.c"
"can_bus.c"
"canopen_sdo.c"
INCLUDE_DIRS "."
REQUIRES esp_driver_twai esp_driver_gpio esp_timer
)

SRCS 告诉 ESP-IDF 需要编译三个 .c 文件;INCLUDE_DIRS "." 让它们能找到同目录的头文件;REQUIRES 声明本组件使用 TWAI、GPIO 和高精度计时器组件。

新建顶层 sdkconfig.defaults

CONFIG_IDF_TARGET="esp32c3"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y

4 MB 与本实验 ESP32-C3 模块实际 Flash 容量一致,可以避免“检测到 4 MB,但固件头声明 2 MB”的启动警告。

此时源码树应为:

lab_11_sdo_read/
├── CMakeLists.txt
├── sdkconfig.defaults
└── main/
├── CMakeLists.txt
├── app_main.c
├── can_bus.c
├── can_bus.h
├── canopen_sdo.c
└── canopen_sdo.h

可以与课程参考实现逐文件比较:

# 逐文件比较练习代码与参考实现,先处理第一处差异。
diff -ru \
"$HOME/esp/can-canopen-course/examples/lab_11_sdo_read/main" \
"$HOME/esp/can-course-work/lab_11_sdo_read/main"

diff 没有输出表示文件一致;有输出时,行首的 -+ 会指出两边的差异。课程仓库不在 $HOME/esp 时,请替换为实际路径。

构建验证

在练习工程目录运行:

# 在当前终端加载 ESP-IDF 环境;新开终端后需要重新执行。
source "$HOME/esp/esp-idf-v5.5.4/export.sh"
cd "$HOME/esp/can-course-work/lab_11_sdo_read"
idf.py set-target esp32c3
idf.py build

set-target 会生成或更新 sdkconfig 并清理不适用于其他芯片的旧缓存。构建成功结尾应看到:

Project build complete.

课程参考工程已经使用 ESP-IDF v5.5.4 为 ESP32-C3 实际构建通过,最终应用二进制为:

build/lab_11_sdo_read.bin
大小 0x33190 字节

本次构建没有应用源码编译错误或警告。

烧录并观察真实响应

先按第 04 课完成接线和断电电阻检查,再确认:

ESP32-C3 与电机共地
CANH 对 CANH,CANL 对 CANL
总线两端终端匹配已经处理
电机与底板使用 12 V 供电
电机 Node-ID 为 0x01
电机 CAN 位速率为 500 kbit/s

连接 USB 后,在工程目录运行:

# 烧录固件并打开串口监视器;按 Ctrl+] 退出监视器。
idf.py flash monitor

只有一个可用串口时,idf.py 通常会自动选择,不必先写设备名。若电脑连接了多个串口设备,再明确指定,例如:

# 烧录固件并打开串口监视器;按 Ctrl+] 退出监视器。
idf.py -p /dev/ttyACM0 flash monitor

实际端口也可能是 /dev/ttyUSB0 或其他名称,先用下面命令查看:

# 列出实际生成的文件,名称和层级应与正文给出的结构一致。
ls -l /dev/ttyACM* /dev/ttyUSB* 2>/dev/null

退出串口监视器使用 Ctrl+]

课程参考工程已经通过 /dev/ttyUSB0 实际烧录到 ESP32-C3。烧录工具完成三段固件写入和 Hash 校验,程序随后成功初始化 TWAI、读取电机对象、处理一次 Abort,并持续收到电机 Heartbeat。

完整验证记录保存在课程仓库中的下面位置:

docs/images/sources/lab_11_sdo_read_hardware_validation_2026-08-11.txt

可以点击第 11 课只读 SDO 上板记录打开。如果当前阅读工具不能跳转,请先进入课程仓库 can-canopen-course,再按照上面的相对路径找到该文件。

/dev/ttyUSB0 只是本次验证机器上的端口名称,不代表其他电脑也一定使用这个名称。你仍应以自己电脑实际检测到的端口为准。

从日志判断每一层是否成功

启动后首先应看到配置:

First read-only CANopen SDO experiment
Target esp32c3, TX GPIO19, RX GPIO18, bitrate 500000 bit/s
Motor Node-ID 0x01; SDO request 0x601, response 0x581
Read-only: this program has no SDO write or motor command
TWAI ready: TX GPIO19, RX GPIO18, 500000 bit/s, normal mode

读取 Device Type 时,关键日志应具有下面的结构:

Read request: Node-ID 0x01, object 0x1000:00
TX CAN-ID 0x601 DLC 8 DATA [40 00 10 00 00 00 00 00]
TX acknowledged: CAN-ID 0x601
RX CAN-ID 0x581 DLC 8 DATA [43 00 10 00 92 01 04 00]
Device Type 0x1000:00 = 0x00040192 (262546)

每一行能证明的事情不同:

看到的日志可以证明还不能证明
TWAI ready驱动初始化调用成功物理总线一定接对
TX acknowledged总线上至少有另一个活动节点以相同位速率正确接收了 CAN 帧对方已成功执行 SDO
匹配的 0x581Node-ID 0x01 的 SDO Server 作出了响应每个对象都一定存在
0x1000:00 = ...命令字、地址和长度检查通过,数据已按类型解码数值在所有型号上都相同

本课参考程序从这台 IG35EC020 实际读取到:

0x1000:00 Device Type = 0x00040192
0x1018:00 Identity entries = 0x04
0x1018:01 Vendor ID = 0x000004CD
0x1018:02 Product Code = 0x00005AB9
0x1018:03 Revision Number = 0x78901234
0x1018:04 Serial Number = 0x56789012

本次上板日志保存在:

docs/images/sources/lab_11_sdo_read_hardware_validation_2026-08-11.txt

更早的完整请求和响应保存在:

docs/images/sources/ig35ec020_sdo_read_2026-08-05.txt

对应的文件链接是第 11 课只读 SDO 上板记录IG35EC020 实际 SDO 读取记录。这些值用于对照通信过程,不应当写死成程序判断条件。

Abort 演示应出现类似:

Abort demo: read unsupported Identity sub-index 0x05
TX CAN-ID 0x601 DLC 8 DATA [40 18 10 05 00 00 00 00]
RX CAN-ID 0x581 DLC 8 DATA [80 18 10 05 ...]
SDO Abort 0x........: ...
Abort was handled; program continues normally

中间的具体 Abort Code 由电机实际实现决定。重点是程序识别 0x80、解码错误编号并继续运行。

没有读到结果时,从哪一行开始查

TWAI ready 都没有

这是软件初始化问题。保留从复位开始的完整串口日志,检查芯片目标、GPIO 是否可用以及所有 esp_err_t 返回值。

出现 TX failed,没有 TX acknowledged

这时还没有进入 SDO 内容判断,优先检查 CAN 物理层:

电机和底板是否上电
是否共地
CANH、CANL 是否接反或断路
终端是否正确
双方是否都为 500 kbit/s
收发器是否处于正常模式

TX acknowledged,但 SDO timeout

CAN 层已有节点 ACK,但没有收到当前请求对应的 0x581。继续检查:

电机 Node-ID 是否真是 0x01
总线上 ACK 的是否其实是另一台设备
请求 CAN-ID 是否按 0x600 + Node-ID 计算
电机当前是否提供默认 SDO Server
接收代码是否错误过滤了 0x581

如果同时能看到电机的 0x701 Heartbeat,说明 Node-ID 0x01 和接收方向基本正确,此时更应检查请求内容与 SDO 响应处理。

收到 0x581,但程序继续等待

对照日志检查它的 Index 和 Sub-index。程序只接受与当前请求完全相同的对象地址。无关 SDO 响应被忽略是正确行为。

收到 SDO Abort

先记录完整的请求、响应和 Abort Code,再查错误含义。Abort 说明设备明确回答了这次请求,不要把它当成“电机没连接”。对象可能不存在、Sub-index 可能超出范围,也可能不允许读取。

这一课真正完成了什么

现在再回看最初的两帧,已经不需要背整串字节:

0x601 = 0x600 + 0x01 发给电机 Node-ID 1 的默认 SDO 请求
0x40 读取请求命令字
00 10 + 00 对象地址 0x1000:00

0x581 = 0x580 + 0x01 电机 Node-ID 1 的默认 SDO 响应
0x43 成功,携带 4 字节对象数据
00 10 + 00 回显并确认对象地址 0x1000:00
92 01 04 00 电机返回的实际数据
0x00040192 按 UNSIGNED32 和小端序得到的结果

这一课只手工实现了理解协议所需的最小 SDO 读取流程。它帮助我们看清每个字节,但还不是完整 CANopen 协议栈:没有分段传输、块传输、多 SDO 通道、并发调度和完整错误管理。后面的 CANopenNode 课程会解释正式协议栈怎样承担这些工作。

在进入下一课之前,应该能独立完成下面三个小练习:

  1. 电机 Node-ID 改为 0x0A 时,算出默认 SDO 请求和响应 CAN-ID;
  2. 为对象 0x1018:02 写出 8 字节读取请求;
  3. 解释为什么 TX acknowledged 与收到成功的 0x581 不是同一件事。

答案分别是:

请求 0x60A,响应 0x58A
40 18 10 02 00 00 00 00
ACK 只确认 CAN 帧被其他节点正确接收;0x581 才是 SDO Server 的协议响应

下一课会比较 PDO 和 SDO:为什么读取配置参数适合一问一答的 SDO,而周期位置、速度等实时数据通常不会一直用同一种方式传输。

资料链接