14. 读取电机的 PDO 配置
前两课已经把问题缩小到下面三项:
电机 TPDO1 默认由电机发送,CAN-ID 可能是 0x181
电机 RPDO1 默认由电机接收,CAN-ID 可能是 0x201
PDO 的 DATA 不携带对象地址,所以内容必须事先配置
这里的“可能”很重要。默认编号只能帮助我们查找,不能证明这台电机已经启用 PDO,也不能说明 DATA 中装了什么。
这一课不猜。ESP32 会使用第 11 课已经写好的只读 SDO Client,读取 IG35EC020 的 PDO 参数,然后监听总线核对实际报文。
程序不会执行 SDO 下载,不发送 NMT、SYNC、RPDO 或运动命令,也不会修改电机配置。
从哪里读取 PDO 配置
如果 PDO 的 DATA 中不带对象地址,接收程序怎么知道其中是什么?答案不在这帧 PDO 本身,而在设备对象字典里的 PDO 参数。
每条 PDO 至少涉及两组信息:
通信参数 这条 PDO 用哪个 CAN-ID、何时发送
映射参数 DATA 里依次放哪些对象、各占多少 bit
CiA 301 为这些参数安排了标准 Index 范围:
| 方向 | 通信参数 | 映射参数 |
|---|---|---|
| RPDO | 0x1400~0x15FF | 0x1600~0x17FF |
| TPDO | 0x1800~0x19FF | 0x1A00~0x1BFF |
第一条 PDO 使用每个范围的第一个 Index:
RPDO1 通信参数 0x1400,映射参数 0x1600
TPDO1 通信参数 0x1800,映射参数 0x1A00
实际程序不会只读一个数字,而是按下面顺序逐步得到结论:

通信参数回答“这条 PDO 用哪个 CAN-ID、当前是否启用”;映射参数回答“DATA 中依次放哪些对象”。两组对象缺一不可。
这些对象地址由 CANopen 规范定义,不是根据本电机日志猜出来的。电机是否实现、里面保存什么值,则需要像第 11 课一样通过 SDO 读取。
这也展示了 SDO 和 PDO 怎样配合:
SDO 读取 PDO 的配置对象
PDO 按这份配置传输过程数据
实测 TPDO1 的 CAN-ID 和启用状态
本课程序先读取:
0x1800:01 TPDO1 使用的 COB-ID
请求和真实响应为:
TX 0x601 [40 00 18 01 00 00 00 00]
RX 0x581 [43 00 18 01 81 01 00 80]
最后四个字节是:
81 01 00 80
按小端序组合:
0x80000181
这是对象字典里的 32 位 COB-ID 参数值,不能直接把整个 0x80000181 当成 CAN-ID。它的部分 bit 还携带配置标志。
本课先看两个部分:
bit 31 PDO 是否无效
低 11 bit 使用标准帧时的 CAN-ID
0x80000181 的最高 bit 为 1,所以 TPDO1 当前无效,也就是没有启用。低 11 bit 为 0x181,说明如果按照这项配置使用标准帧,它的 CAN-ID 是 0x181。
因此这次读取结果应完整表述为:
电机 TPDO1 配置了标准 CAN-ID
0x181,但 COB-ID 参数的 bit 31 为 1,所以当前处于禁用状态。
不能只看到低位 0x181 就断言总线上一定会出现 0x181。
四字节映射值怎样拆成对象地址
程序接着读取 TPDO1 映射对象:
0x1A00:00
电机返回 02,表示当前有两条映射。于是继续读取:
0x1A00:01 = 0x60620020
0x1A00:02 = 0x60640020
每条 PDO 映射是一个 32 位数,格式为:
bit 31........16 对象 Index
bit 15.........8 Sub-index
bit 7..........0 这项数据占多少 bit
拆第一条:
0x60620020
---- -- --
6062 00 20
得到:
Index 0x6062
Sub-index 0x00
长度 0x20 bit = 32 bit = 4 字节
拆第二条同理:
0x60640020
---- -- --
6064 00 20
得到对象 0x6064:00,同样占 32 bit。两项相加:
32 bit + 32 bit = 64 bit = 8 字节
CiA 402 中,0x6062:00 是 Position Demand Value,0x6064:00 是 Position Actual Value。若将来按设备要求正确启用并触发这条 TPDO,DATA 的排列应为:
DATA[0..3] 0x6062:00 的当前值,小端序
DATA[4..7] 0x6064:00 的当前值,小端序
注意,PDO 报文中不会发送数字 0x6062 和 0x6064。这两个地址只存在于映射配置中;真正的 PDO DATA 放的是对象当前值。
下图把本次读取过程串起来。先看最上面的 0x80000181,再沿着两条映射向下看:

RPDO1 实际映射了什么
同样的方法读取电机 RPDO1,得到:
0x1400:01 = 0x80000201
0x1600:00 = 2
0x1600:01 = 0x60830020
0x1600:02 = 0x60840020
逐项解释:
0x80000201 bit 31 为 1,RPDO1 禁用;低 11 bit 为 CAN-ID 0x201
0x60830020 映射对象 0x6083:00,长度 32 bit
0x60840020 映射对象 0x6084:00,长度 32 bit
0x6083:00 和 0x6084:00 分别是 Profile Acceleration 和 Profile Deceleration。它们合计 8 字节。
这只是读取到的当前映射,不表示本课会向 0x201 发送数据。RPDO1 同样处于禁用状态,程序也没有 RPDO 发送函数。
为什么没有看到 0x181
程序完成对象读取后监听总线 5 秒,得到:
RX CAN-ID 0x701 DLC 1 DATA [05]
RX CAN-ID 0x701 DLC 1 DATA [05]
RX CAN-ID 0x701 DLC 1 DATA [05]
RX CAN-ID 0x701 DLC 1 DATA [05]
RX CAN-ID 0x701 DLC 1 DATA [05]
Observation result: heartbeat=5, matching TPDO=0
TPDO1 is disabled, so no TPDO was expected or observed
这不是实验失败。它与对象字典中 0x1800:01 = 0x80000181 完全一致:
电机在线 所以每秒收到 0x701 Heartbeat
TPDO1 当前被禁用 所以没有观察到 0x181
电机还返回了 Transmission Type 1,表示同步循环类型:启用后通常与 SYNC 报文配合,每收到一次符合条件的 SYNC 才安排发送。但“Transmission Type 已配置”不等于“PDO 已启用”,而且本课没有发送 SYNC。
为了强行看到 0x181 而去写 0x1800、0x1A00 或发送 SYNC,会把一节认识 PDO 的课程变成 PDO 重配置实验。厂家又没有提供完整 EDS 和配置步骤,所以本课保持只读,把真实的禁用状态作为实验结论。
创建独立实验工程
本课参考工程在课程仓库中的位置:
examples/lab_14_pdo_inspect
它已经独立完成编译、烧录和电机通信验证。下面仍从标准命令开始建立自己的练习工程。
加载 ESP-IDF v5.5.4:
# 在当前终端加载 ESP-IDF 环境;新开终端后需要重新执行。
source "$HOME/esp/esp-idf-v5.5.4/export.sh"
idf.py --version
ESP-IDF 不在这个路径时,换成自己的实际安装位置。输出应包含:
ESP-IDF v5.5.4
创建工程:
# 创建一个全新的练习工程,并进入工程目录核对生成结果。
mkdir -p "$HOME/esp/can-course-work"
test ! -e "$HOME/esp/can-course-work/lab_14_pdo_inspect"
idf.py create-project \
-p "$HOME/esp/can-course-work/lab_14_pdo_inspect" \
lab_14_pdo_inspect
cd "$HOME/esp/can-course-work/lab_14_pdo_inspect"
mv main/lab_14_pdo_inspect.c main/app_main.c
如果 test ! -e ... 失败,说明该目录已经存在,不要覆盖旧实验。
复用第 11 课已经验证的 CAN 和 SDO 模块
本课仍通过 SDO 读取对象,所以继续使用第 11 课亲手写过的四个文件:
can_bus.c
can_bus.h
canopen_sdo.c
canopen_sdo.h
若第 11 课练习工程位于课程默认路径,可以执行:
# 只复制本课需要的源码和配置,不复制 build 等构建产物。
cp "$HOME/esp/can-course-work/lab_11_sdo_read/main/can_bus.c" main/
cp "$HOME/esp/can-course-work/lab_11_sdo_read/main/can_bus.h" main/
cp "$HOME/esp/can-course-work/lab_11_sdo_read/main/canopen_sdo.c" main/
cp "$HOME/esp/can-course-work/lab_11_sdo_read/main/canopen_sdo.h" main/
如果你的练习工程在其他位置,需要替换源路径。前一课没有通过上机验证时,不应跳过它直接进入本课,因为后面需要用 SDO 读取 PDO 配置。
这里复用的是课程中已经解释过的模块,不需要重新抄写相同源码。本课新增的程序只负责:
选择要读取的 PDO 参数对象
解码 COB-ID 和映射值
监听并统计对应 CAN-ID
先保存一条 PDO 的检查结果
打开 main/app_main.c,先写头文件、常量和结构体:
// 这一课不会修改 PDO,只把 SDO 读到的配置整理成 pdo_info_t,
// 再拿它与 5 秒监听窗口中的真实 CAN 报文比较。
#include <inttypes.h>
#include <stdbool.h>
#include <stdint.h>
#include "can_bus.h"
#include "canopen_sdo.h"
#include "esp_err.h"
#include "esp_log.h"
#include "esp_timer.h"
#define MOTOR_NODE_ID 0x01
#define SDO_TIMEOUT_MS 1000
#define PDO_OBSERVE_TIME_MS 5000
#define MAX_PDO_MAPPING_ENTRIES 8
static const char *TAG = "lab_14_pdo_inspect";
typedef struct {
// present 只说明通信参数对象可读,不等于后面的 COB-ID 子项也读取成功。
bool present;
bool cob_id_known;
// enabled 来自 COB-ID bit 31,extended_id 来自 bit 29,两者不能从 CAN-ID 数值猜测。
bool enabled;
bool extended_id;
uint32_t can_id;
uint8_t transmission_type;
uint8_t mapped_count;
// 每个映射条目给出自己的 bit 长度,累加后必须不超过 Classical CAN 的 64 bit。
uint16_t mapped_bits;
} pdo_info_t;
pdo_info_t 不是 CANopen 规范中的报文结构,而是本课程序为了保存检查结果定义的普通 C 结构体:
| 成员 | 保存什么 |
|---|---|
present | 通信参数对象是否能读到 |
cob_id_known | :01 COB-ID 是否读取成功 |
enabled | COB-ID 的 bit 31 是否允许该 PDO 生效 |
extended_id | 是否配置为 29 位扩展帧 |
can_id | 从 COB-ID 中取出的实际标识符 |
transmission_type | 何种条件触发 PDO |
mapped_count | 映射条目数量 |
mapped_bits | 所有映射合计多少 bit |
把“对象存在”和“COB-ID 读取成功”分成两个布尔值,是为了避免某个子索引 Abort 后仍然误报 PDO 已禁用。
写两个带长度检查的读取函数
继续写入:
// PDO 通信参数同时包含 UNSIGNED8 和 UNSIGNED32。
// SDO 上传成功后仍要核对实际字节数,避免把类型或对象地址写错后继续解码。
static esp_err_t read_u8(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) {
return ret;
}
// UNSIGNED8 必须恰好返回 1 字节,多或少都说明响应与预期对象类型不一致。
if (result.size != 1) {
ESP_LOGW(TAG, "0x%04X:%02X returned %u bytes; expected 1",
index, sub_index, result.size);
return ESP_ERR_INVALID_SIZE;
}
*value = canopen_sdo_as_u8(&result);
return ESP_OK;
}
static esp_err_t read_u32(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) {
return ret;
}
// COB-ID 和映射条目是 UNSIGNED32,必须拿到完整 4 字节才能做位运算。
if (result.size != 4) {
ESP_LOGW(TAG, "0x%04X:%02X returned %u bytes; expected 4",
index, sub_index, result.size);
return ESP_ERR_INVALID_SIZE;
}
*value = canopen_sdo_as_u32(&result);
return ESP_OK;
}
PDO 参数中既有 UNSIGNED8,也有 UNSIGNED32。两个函数在转换之前先核对响应长度,防止把异常响应当成合法值。
把 Transmission Type 翻译成可读文字
继续写:
// Transmission Type 的不同数值区间含义不同,不能只按“是否为 0”判断。
// 函数只翻译已经读到的配置,不会发送 SYNC、RTR 或触发 PDO。
static const char *transmission_type_name(uint8_t type)
{
if (type == 0) {
return "synchronous, acyclic";
}
if (type <= 240) {
return "synchronous, cyclic";
}
if (type == 252) {
return "synchronous RTR-only";
}
if (type == 253) {
return "event-driven RTR-only";
}
if (type == 254) {
return "event-driven, manufacturer-specific";
}
if (type == 255) {
return "event-driven, device-profile-specific";
}
return "reserved";
}
本次电机 TPDO1 返回 1,程序会显示 synchronous, cyclic。这里的函数只负责翻译配置值,不会发送 SYNC,也不会触发 PDO。
读取通信参数和映射参数
接下来是本课的核心函数。它接收一组通信 Index 和映射 Index,因此同一份逻辑可以检查 RPDO1 与 TPDO1:
// communication_index 指向 0x1400/0x1800,mapping_index 指向 0x1600/0x1A00。
// 同一函数接收这两个地址后,就能用完全相同的检查顺序处理 RPDO1 与 TPDO1。
static void inspect_pdo(const char *name, uint16_t communication_index,
uint16_t mapping_index, pdo_info_t *info)
{
ESP_LOGI(TAG, "Inspect %s: communication 0x%04X, mapping 0x%04X",
name, communication_index, mapping_index);
// Sub-index 0 告诉程序当前通信参数对象实现到哪个最高子索引,后面不能越界猜读。
uint8_t highest_sub_index = 0;
esp_err_t ret = read_u8(communication_index, 0x00, &highest_sub_index);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "%s communication object is unavailable: %s",
name, esp_err_to_name(ret));
return;
}
info->present = true;
ESP_LOGI(TAG, "%s communication highest sub-index = %u",
name, highest_sub_index);
// COB-ID 不只是 CAN-ID:bit 31 表示有效性,bit 29 表示标准帧或扩展帧。
uint32_t cob_id = 0;
if (highest_sub_index >= 1 &&
read_u32(communication_index, 0x01, &cob_id) == ESP_OK) {
info->cob_id_known = true;
// bit 31 的语义是“1=无效、0=有效”,所以这里与 0 比较后才得到 enabled。
info->enabled = (cob_id & 0x80000000U) == 0;
info->extended_id = (cob_id & 0x20000000U) != 0;
// 29 位扩展帧与 11 位标准帧使用不同掩码,不能一律只保留低 11 位。
info->can_id = info->extended_id ? (cob_id & 0x1FFFFFFFU)
: (cob_id & 0x7FFU);
ESP_LOGI(TAG,
"%s COB-ID raw=0x%08" PRIX32 ", CAN-ID=0x%03" PRIX32
", enabled=%s, frame=%s",
name, cob_id, info->can_id,
info->enabled ? "yes" : "no",
info->extended_id ? "extended" : "standard");
}
if (highest_sub_index >= 2 &&
read_u8(communication_index, 0x02,
&info->transmission_type) == ESP_OK) {
ESP_LOGI(TAG, "%s transmission type = %u (%s)",
name, info->transmission_type,
transmission_type_name(info->transmission_type));
}
// 映射对象的 Sub-index 0 保存条目数量,真正的映射描述从 Sub-index 1 开始。
uint8_t mapped_count = 0;
ret = read_u8(mapping_index, 0x00, &mapped_count);
if (ret != ESP_OK) {
ESP_LOGW(TAG, "%s mapping object is unavailable: %s",
name, esp_err_to_name(ret));
return;
}
info->mapped_count = mapped_count;
ESP_LOGI(TAG, "%s mapped object count = %u", name, mapped_count);
uint8_t read_count = mapped_count;
if (read_count > MAX_PDO_MAPPING_ENTRIES) {
ESP_LOGW(TAG, "%s reports %u mappings; inspect only the first %u",
name, mapped_count, MAX_PDO_MAPPING_ENTRIES);
read_count = MAX_PDO_MAPPING_ENTRIES;
}
// 映射条目从 Sub-index 1 开始,0 只保存条目数量。
for (uint8_t sub_index = 1; sub_index <= read_count; sub_index++) {
uint32_t mapping = 0;
if (read_u32(mapping_index, sub_index, &mapping) != ESP_OK) {
ESP_LOGW(TAG, "%s mapping sub-index 0x%02X could not be read",
name, sub_index);
continue;
}
// 32 位映射值布局为 Index[31:16]、Sub-index[15:8]、长度[7:0]。
uint16_t object_index = (uint16_t)(mapping >> 16);
uint8_t object_sub_index = (uint8_t)(mapping >> 8);
uint8_t length_bits = (uint8_t)mapping;
info->mapped_bits += length_bits;
ESP_LOGI(TAG,
"%s map %u raw=0x%08" PRIX32
" -> object 0x%04X:%02X, %u bits",
name, sub_index, mapping, object_index,
object_sub_index, length_bits);
}
ESP_LOGI(TAG, "%s mapped payload = %u bits (%u bytes)",
name, info->mapped_bits, (info->mapped_bits + 7U) / 8U);
if (info->mapped_bits > 64) {
ESP_LOGW(TAG, "%s mapping exceeds Classical CAN payload", name);
}
}
三处位运算分别对应前面手工拆过的格式:
info->enabled = (cob_id & 0x80000000U) == 0;
检查 COB-ID 的 bit 31。它为 0 才表示 PDO 有效。
info->can_id = cob_id & 0x7FFU;
标准帧 CAN-ID 占 11 bit,所以用 0x7FF 保留最低 11 bit。
uint16_t object_index = (uint16_t)(mapping >> 16);
uint8_t object_sub_index = (uint8_t)(mapping >> 8);
uint8_t length_bits = (uint8_t)mapping;
分别取出映射值中的 Index、Sub-index 和长度。
程序最多读取 8 条映射,并检查总长度是否超过 Classical CAN 的 64 bit。设备报告异常数量时不会无限循环。
监听 5 秒并核对实际报文
继续写:
// 监听窗口使用绝对截止时间。每收到一帧都重新计算剩余时间,
// 因此总线流量再多也不会把“观察 5 秒”无限延长。
static void observe_bus(const pdo_info_t *tpdo)
{
ESP_LOGI(TAG, "Observe the bus for %d ms; no PDO or motor command is sent",
PDO_OBSERVE_TIME_MS);
uint32_t heartbeat_count = 0;
uint32_t tpdo_count = 0;
int64_t deadline_us = esp_timer_get_time() +
(int64_t)PDO_OBSERVE_TIME_MS * 1000;
while (esp_timer_get_time() < deadline_us) {
int64_t remaining_us = deadline_us - esp_timer_get_time();
uint32_t wait_ms = (uint32_t)((remaining_us + 999) / 1000);
can_bus_frame_t frame;
if (can_bus_receive(&frame, wait_ms) != ESP_OK) {
break;
}
can_bus_log_frame("RX", &frame);
// Heartbeat 的 CAN-ID 是 0x700+Node-ID,并且固定只有 1 个数据字节。
if (frame.id == 0x700U + MOTOR_NODE_ID && frame.dlc == 1) {
heartbeat_count++;
}
// 只有已经成功解析标准帧 COB-ID,才用 11 位 CAN-ID 统计匹配的 TPDO。
if (tpdo->cob_id_known && !tpdo->extended_id &&
frame.id == tpdo->can_id) {
tpdo_count++;
}
}
ESP_LOGI(TAG, "Observation result: heartbeat=%" PRIu32
", matching TPDO=%" PRIu32,
heartbeat_count, tpdo_count);
// 按“对象不可读 → COB-ID 不可读 → 禁用 → 已启用但未观察到”的顺序报告,
// 避免把信息不足误判成“电机一定没有发送 TPDO”。
if (!tpdo->present) {
ESP_LOGW(TAG, "TPDO1 configuration was not readable; no TPDO claim made");
} else if (!tpdo->cob_id_known) {
ESP_LOGW(TAG, "TPDO1 COB-ID was not readable; no TPDO claim made");
} else if (!tpdo->enabled) {
if (tpdo_count == 0) {
ESP_LOGI(TAG, "TPDO1 is disabled, so no TPDO was expected or observed");
} else {
ESP_LOGW(TAG, "TPDO1 is marked disabled but matching frames were observed");
}
} else if (tpdo_count == 0) {
ESP_LOGI(TAG, "TPDO1 is configured but was not observed in this window");
}
}
计数时没有因为对象字典写着“禁用”就忽略 0x181。即使配置声称禁用,监听器仍会统计是否真的出现匹配报文,再比较两份信息。这样才能发现“配置值与总线行为不一致”的异常情况。
5 秒等待同样使用截止时间,不用固定延时假装已经检查过总线。
在 app_main() 中按顺序执行
文件最后写:
// app_main() 只编排检查顺序:初始化一次 TWAI,用只读 SDO 查配置,再监听实际报文。
// 本程序没有 download、NMT、SYNC 或 RPDO 发送路径,不会改变电机状态。
void app_main(void)
{
ESP_LOGI(TAG, "Read-only CANopen PDO inspection");
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", MOTOR_NODE_ID);
ESP_LOGW(TAG, "Safety: no SDO write, NMT, SYNC, RPDO, or motor command");
ESP_ERROR_CHECK(can_bus_init());
// 先清零,读取失败时各个 known/present 标志仍为 false,不会留下未初始化状态。
pdo_info_t rpdo1 = {0};
pdo_info_t tpdo1 = {0};
inspect_pdo("RPDO1", 0x1400, 0x1600, &rpdo1);
inspect_pdo("TPDO1", 0x1800, 0x1A00, &tpdo1);
observe_bus(&tpdo1);
ESP_LOGI(TAG, "PDO inspection finished; continue listening to CAN frames");
// 5 秒检查结束后继续打印总线帧,方便读者手动触发设备行为并观察变化。
while (true) {
can_bus_frame_t frame;
if (can_bus_receive(&frame, 1000) == ESP_OK) {
can_bus_log_frame("RX", &frame);
}
}
}
执行顺序很简单:
初始化一套 TWAI
用只读 SDO 检查 RPDO1
用只读 SDO 检查 TPDO1
监听 5 秒并统计 Heartbeat、TPDO1
继续监听总线
这里没有初始化第二套 TWAI。SDO 响应、Heartbeat 和可能出现的 PDO 都进入同一个 CAN 接收队列,再由当前任务逐帧判断。
配置构建文件
将 main/CMakeLists.txt 改为:
# 复用第 11 课已经验证的 CAN 与 SDO 模块,本课只新增 app_main.c 中的 PDO 检查逻辑。
# esp_timer 同时用于 SDO 超时和 5 秒总线观察窗口。
idf_component_register(
SRCS
"app_main.c"
"can_bus.c"
"canopen_sdo.c"
INCLUDE_DIRS "."
REQUIRES esp_driver_twai esp_driver_gpio esp_timer
)
新建顶层 sdkconfig.defaults:
CONFIG_IDF_TARGET="esp32c3"
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
完成后的源码树为:
lab_14_pdo_inspect/
├── CMakeLists.txt
├── sdkconfig.defaults
└── main/
├── CMakeLists.txt
├── app_main.c
├── can_bus.c
├── can_bus.h
├── canopen_sdo.c
└── canopen_sdo.h
课程参考工程位于:
examples/lab_14_pdo_inspect
需要核对完整文件时,从课程仓库根目录进入该路径。不要修改课程参考工程,在自己的 can-course-work 目录完成练习。
构建、烧录和监视
在练习工程目录运行:
# 在当前终端加载 ESP-IDF 环境;新开终端后需要重新执行。
source "$HOME/esp/esp-idf-v5.5.4/export.sh"
idf.py set-target esp32c3
idf.py build
idf.py flash monitor
只有一个串口设备时,让 idf.py 自动寻找即可。连接多个串口时再使用自己的实际端口,例如:
# 烧录固件并打开串口监视器;按 Ctrl+] 退出监视器。
idf.py -p /dev/ttyUSB0 flash monitor
本次课程参考工程的真实验证结果为:
ESP-IDF v5.5.4
芯片 ESP32-C3 revision v0.4
最终应用大小 0x33890 字节
构建 成功,无应用源码编译警告
烧录 成功,Hash 校验通过
TWAI GPIO19/GPIO18,500000 bit/s
电机 IG35EC020,Node-ID 0x01
上板记录保存在课程仓库中的位置:
docs/images/sources/lab_12_pdo_inspect_hardware_validation_2026-08-11.txt
可以点击PDO 只读检查上板记录打开。阅读工具无法跳转时,进入课程仓库 can-canopen-course,按上面的相对路径查找。
怎样判断实验通过
先看 CAN 和 SDO 日志:
TX CAN-ID 0x601 ...
TX acknowledged: CAN-ID 0x601
RX CAN-ID 0x581 ...
它说明:
TWAI 成功发送请求
总线上有其他活动节点进行 ACK
Node-ID 0x01 的 SDO Server 返回匹配响应
再看 PDO 参数解码结果:
RPDO1 COB-ID raw=0x80000201, CAN-ID=0x201, enabled=no
RPDO1 map 1 -> object 0x6083:00, 32 bits
RPDO1 map 2 -> object 0x6084:00, 32 bits
TPDO1 COB-ID raw=0x80000181, CAN-ID=0x181, enabled=no
TPDO1 map 1 -> object 0x6062:00, 32 bits
TPDO1 map 2 -> object 0x6064:00, 32 bits
它说明电机实现了标准 PDO1 通信和映射对象,而且程序能从 32 位参数中分离 CAN-ID、状态与映射字段。
最后看实际监听结果:
Observation result: heartbeat=5, matching TPDO=0
TPDO1 is disabled, so no TPDO was expected or observed
这说明对象字典配置与 5 秒总线行为一致。实验目标是检查并理解 PDO,不是必须看到一帧 0x181。在当前禁用配置下,贸然声称“应该周期出现 0x181”才是不正确的。
常见误解
0x181 里面包含 Node-ID,所以 DATA 不需要映射
不对。0x181 = 0x180 + 0x01 能帮助识别默认 TPDO1 和节点,但它没有说明 DATA 中放了哪些对象。具体内容必须查 0x1A00 映射或设备 EDS、手册。
看到映射对象,就说明 PDO 正在发送
不对。映射只说明“若这条 PDO 生效,DATA 怎样排列”。还要检查 COB-ID 的 bit 31、NMT 状态和触发条件,并观察真实总线。
TPDO 一定是位置,RPDO 一定是目标位置
不对。T/R 只说明相对设备的发送和接收方向。DATA 内容由每台设备的映射决定。本电机当前 TPDO1 恰好映射 0x6062、0x6064,不能照搬到另一款设备。
PDO 没有响应,所以不可靠
PDO 不采用 SDO 那种一问一答确认方式,但底层 CAN 数据帧仍然有 ACK、错误检测和自动重发等机制。PDO 没有的是“针对某个对象访问的 SDO 成功/Abort 响应”。
本课没有看到 TPDO,所以 PDO 没学会
这次已经完成了更重要的判断过程:从标准对象找到通信参数、拆出禁用位和 CAN-ID、解码映射,再用总线监听核对。启用和重映射 PDO 是会改变设备通信行为的配置操作,应在拿到完整资料并明确实验目标后单独进行。
自己拆两条映射
先不看答案。
第一条:
0x60410010
写出 Index、Sub-index 和长度。
第二条:
0x60610008
写出 Index、Sub-index 和长度,并计算两条合计多少字节。
答案:
0x60410010 -> 0x6041:00,0x10 = 16 bit = 2 字节
0x60610008 -> 0x6061:00,0x08 = 8 bit = 1 字节
合计 24 bit = 3 字节
如果这两条组成一条 PDO 映射,那么对应 PDO 的 DATA 前 2 字节是 0x6041:00 的值,第 3 字节是 0x6061:00 的值。对象地址本身不会出现在每一帧 PDO DATA 中。
整理本课结果
第 12 课已经比较过 SDO 与 PDO。本课进一步查到了这台电机的真实配置:
SDO 适合访问对象字典中的任意参数
每次访问都带对象地址,并等待成功或 Abort 响应
PDO 适合频繁变化的过程数据
通信双方先知道映射,运行时一帧直接携带数据
本课还得到了这台 IG35EC020 的真实结论:
RPDO1 和 TPDO1 参数对象都存在
两条 PDO 当前都处于禁用状态
TPDO1 配置 CAN-ID 为 0x181
TPDO1 映射 0x6062:00 和 0x6064:00
5 秒内收到 5 帧 Heartbeat,没有收到 0x181
下一课的目标是让总线上真正出现周期 PDO,并由 ESP32 解码其中的位置数据。开始写配置之前,必须先核对厂家资料、PDO 的修改步骤和掉电保存行为;如果资料不足,就改用可控的本地 CANopen 节点完成实验,不能靠猜测写电机对象。
资料链接
- CAN in Automation:PDO protocol:PDO 单帧传输、通信参数、映射参数和触发方式的官方介绍。
- CANopenNode:PDO:PDO 最大长度、默认 CAN-ID、映射对象和传输类型的实现文档。
- CAN in Automation:CANopen:对象字典通信参数范围和 CANopen 通信对象概览。
- CiA 301 CANopen 海报:默认 PDO CAN-ID 和 PDO 参数对象速查。
- 第 11 课:用 SDO 读取电机参数:回顾只读 SDO、对象地址、响应检查和 Abort。