11. 用 SDO 读取电机参数
上一课已经拆开过下面两帧报文:
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
不会设置 电机模式、位置或速度
因此,本课程序不会命令电机运动。完成实验后,你应该能自己回答:0x601、0x581、0x40、0x43 和 0x80 分别来自哪里。
一次读取为什么需要两帧报文
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 相同:

这就是一次 SDO Upload。Upload 容易让初学者误会,因为从 ESP32 的角度看明明是在“读取”。CANopen 以 SDO Server 为参照:数据从拥有对象字典的电机上传给 Client,所以读取操作叫 Upload。本课后面直接称为“SDO 读取”,代码函数仍按规范命名为 canopen_sdo_upload()。
CAN in Automation 的 SDO 介绍说明,SDO 用来访问设备对象字典,采用 Client/Server 的确认式通信。数据不超过 4 字节时,可以直接放入初始化响应中,这种方式叫 Expedited transfer,常译为快速传输。
本课读取的 UNSIGNED8 和 UNSIGNED32 都不超过 4 字节,因此一问一答就能完成,不需要学习分段传输。
0x601 和 0x581 是怎样算出来的
CANopen 预定义了一条默认 SDO 通信通道。客户端通过这条通道向设备提出请求,设备再通过同一条通道对应的另一个 CAN-ID 返回响应。两个方向的基础编号分别是:
| 方向 | 基础 CAN-ID | 加上本课 Node-ID 0x01 | 实际 CAN-ID |
|---|---|---|---|
| Client 请求 Server | 0x600 | 0x600 + 0x01 | 0x601 |
| Server 响应 Client | 0x580 | 0x580 + 0x01 | 0x581 |
这里的“通道”不是一根新的 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 transmit 和 SDO 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] | 0x00 | Index 低字节 |
DATA[2] | 0x10 | Index 高字节 |
DATA[3] | 0x00 | Sub-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-ID | 0x581 | 确认来自 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 解码 |
快速上传成功响应常见的命令字为:
| 命令字 | 有效对象数据长度 | 有效数据所在位置 |
|---|---|---|
0x4F | 1 字节 | DATA[4] |
0x4B | 2 字节 | DATA[4..5] |
0x47 | 3 字节 | DATA[4..6] |
0x43 | 4 字节 | DATA[4..7] |
0x42 | 快速响应,但命令字未说明长度 | 需要事先知道对象类型 |
本课只读取 1 字节和 4 字节标准对象,因此程序处理 0x4F、0x43,同时兼容常见的 0x4B 和 0x42。以后需要读取 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 请求。收到对应响应或超时之后,才读取下一个对象。这样程序始终知道当前在等哪一个 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 始终显示为三位十六进制,例如 0x001、0x581。%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 设备拒绝请求时返回的错误编号
这个接口故意没有 download 或 write 函数。本课程序从接口层面就无法写电机对象。
第四步:写出 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;
}
}
把函数按执行顺序重新读一遍:
- 检查 Node-ID 和输出指针;
- 组装
0x40 + Index + Sub-index; - 用
0x600 + Node-ID发送; - 只等待到调用者给出的截止时间;
- 核对
0x580 + Node-ID、DLC、Index 和 Sub-index; 0x80按 Abort 处理,成功命令字按长度复制数据;- 返回给
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 必须先读。只有它说明存在 01 到 04,程序才继续读取 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 |
匹配的 0x581 | Node-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 课程会解释正式协议栈怎样承担这些工作。
在进入下一课之前,应该能独立完成下面三个小练习:
- 电机 Node-ID 改为
0x0A时,算出默认 SDO 请求和响应 CAN-ID; - 为对象
0x1018:02写出 8 字节读取请求; - 解释为什么
TX acknowledged与收到成功的0x581不是同一件事。
答案分别是:
请求 0x60A,响应 0x58A
40 18 10 02 00 00 00 00
ACK 只确认 CAN 帧被其他节点正确接收;0x581 才是 SDO Server 的协议响应
下一课会比较 PDO 和 SDO:为什么读取配置参数适合一问一答的 SDO,而周期位置、速度等实时数据通常不会一直用同一种方式传输。
资料链接
- CAN in Automation:SDO protocol:SDO Client/Server、确认式通信和快速传输的官方介绍。
- CAN in Automation:CiA 301 CANopen poster:核对默认 SDO CAN-ID、SDO services、对象字典和 Abort Code。
- ESP-IDF v5.5.4:ESP32-C3 TWAI 驱动:核对 TWAI 节点创建、收发回调、过滤器和发送完成事件。
- 第 10 课:对象字典、数据类型和小端序:回顾 Index、Sub-index、数据类型与字节顺序。