跳到主要内容

18. 发送第一条本地 TPDO

学习形式编程实验
硬件要求ESP32-C3 + CAN 接收端
配套 Demolab_18_local_tpdo
本课动作不修改电机

第 14 课读取电机配置时发现,电机的 TPDO 当前没有直接给出可用的周期位置报文。厂家资料不足时,不适合为了演示而随意改写电机 PDO 配置。

这一课换一个可控对象:让 ESP32 本地 Node 0x20 把自己的计数器放进 TPDO1。这样可以完整观察 PDO,又不会写电机或让电机运动。

当前验证状态:工程已编译并烧录到 ESP32-C3,固件大小 179712 字节。串口确认本地 Node-ID 0x20、TPDO1 0x1A0、映射对象 0x2000:00 和 200 ms 事件定时器完成初始化;变化的数据和发送周期仍需另一台 CAN 接收设备独立抓包确认。记录见第 18 课上板记录

先看清楚代码中一个数字怎样走到 CAN 总线:

本地计数器经过对象字典和 TPDO 映射进入 0x1A0

这张图里没有电机对象。数据从 ESP32 自己的 C 变量出发,因此即使代码写错,也不会改写电机参数。

从上一课建立新练习工程

先创建一个新的标准工程,再加入本课需要的对象字典和官方组件依赖:

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

如果你的工作目录不同,替换 $HOME/esp/can-course-work,不要照搬本机绝对路径。

本课的 OD 必须包含新增的 0x20000x18000x1A00。把课程提供的生成文件和依赖声明放进新工程:

# 只复制本课需要的源码和配置,不复制 build 等构建产物。
cp "$HOME/esp/can-canopen-course/examples/lab_18_local_tpdo/main/OD.c" main/
cp "$HOME/esp/can-canopen-course/examples/lab_18_local_tpdo/main/OD.h" main/
cp "$HOME/esp/can-canopen-course/examples/lab_18_local_tpdo/main/idf_component.yml" main/

不要复制 build/managed_components/,它们应由构建系统重新生成。把自动生成的 C 文件改名为 app_main.c,修改 main/CMakeLists.txt,让它编译 app_main.cOD.c。先写一个只打印课名的 app_main() 并执行 idf.py build,确认新工程骨架没有问题。

写代码前先分成六步

从对象字典配置到周期 TPDO 的编码顺序

顺序很重要:没有映射就不知道发送谁,没有 CO_CANopenInitPDO() 就没有 PDO 模块,没有 Operational 状态就不应期待正常 PDO。

先确定报文编号

本地节点发送 TPDO1,默认基础编号为 0x180

0x180 + 0x20 = 0x1A0

因此预期看到:

报文CAN-ID方向
ESP32 Heartbeat0x720ESP32 发出
ESP32 TPDO10x1A0ESP32 发出

这次发送哪个变量

对象字典中已经有一个测试对象:

对象类型C 变量用途
0x2000:00UNSIGNED32OD_RAM.x2000_testCNT本地递增计数器

TPDO1 的映射值是:

0x20000020

按 PDO 映射格式拆开:

位段含义
31~160x2000Index
15~80x00Sub-index
7~00x2032 bit,也就是 4 字节

所以 0x1A0 的 DATA 会直接装入计数器的 4 个小端序字节。

在本课的 OD.c 中,还能找到三项互相配合的配置:

// 0x1800 决定 TPDO1 何时发送,0x1A00 决定报文 DATA 中装入哪个对象。
// 0xFE 是厂商事件触发类型,0x00C8 按毫秒解释为 200 ms;两项要一起阅读。
.x1800_TPDOCommunicationParameter = {
.transmissionType = 0xFE,
.eventTimer = 0x00C8,
},
.x1A00_TPDOMappingParameter = {
// 映射值 0x20000020 = Index 0x2000、Sub-index 0、长度 0x20 bit。
.numberOfMappedApplicationObjectsInPDO = 0x01,
.pointerTestCNT = 0x20000020,
},

0x00C8 是十六进制的 200,也就是 200 ms;映射数量 1 表示 TPDO1 只装入一个对象。OD 文件由对象字典配置生成,初学阶段应修改配置来源并重新生成,而不是随意挪动生成文件中的结构体成员。

打开 PDO 功能

sdkconfig.defaults 中加入:

CONFIG_CO_MULTIPLE_OD=y
CONFIG_CO_PDO=y

如果 CONFIG_CO_PDO 没有开启,CO_CANopenInitPDO() 和 PDO 数组不会按本实验需要生成。

现在执行第一次功能编译:

# 编译当前工程,先处理出现的第一条错误。
idf.py build

若这里报 CO_CANopenInitPDO 未声明,先检查 sdkconfig.defaults 是否保存、是否重新配置过工程,再检查 CONFIG_CO_PDO=y,不要先改 CANopenNode 组件源码。

从第 16 课主程序增加本地变量

第 16 课的 TWAI、CO_new()CO_CANinit()CO_CANopenInit() 保持不变。只在 CO_new() 前增加:

// x2000_testCNT 是 OD.h 生成的对象字典变量名;远程节点看到的是 0x2000:00。
// 先清零可以让抓到的第一批 TPDO 数值从确定状态开始增长。
OD_RAM.x2000_testCNT = 0;

这行修改的是 ESP32 RAM 中的 C 变量。由于 OD.c 已经把它登记成 0x2000:00,CANopenNode 的 PDO 模块能够通过映射找到它。

初始化 PDO

完成 CO_CANopenInit() 后继续调用:

// CO_CANopenInit() 先建立 NMT、Heartbeat 和 SDO;成功后才能初始化 PDO。
// 此函数会读取 OD 中 0x1800/0x1A00 等配置,建立 TPDO 的通信参数和映射关系。
ESP_ERROR_CHECK(CO_CANopenInitPDO(co, co->em, OD, LOCAL_NODE_ID,
&err_info) == CO_ERROR_NO
? ESP_OK : ESP_FAIL);

普通 CANopen 初始化让 NMT、Heartbeat 和 SDO Server 可用;这一步把对象字典里的 PDO 通信参数和映射参数交给 PDO 模块。

把它放在 CO_CANopenInit() 成功之后、CO_CANsetNormalMode() 之前。完成后再次执行 idf.py build,先解决函数参数和 Kconfig 问题,再写运行循环。

进入 Operational

PDO 通常在 Operational 状态下工作。本实验让本地节点内部执行:

// InternalCommand 只修改本地 CO_t 中的 NMT 状态,不会在 CAN 总线上发送 NMT 广播。
// 本地节点进入 Operational 后 PDO 才允许运行,电机 Node 0x01 的状态不受影响。
CO_NMT_sendInternalCommand(co->NMT, CO_NMT_ENTER_OPERATIONAL);

这是本地函数调用,不是在总线上广播一条控制电机的 NMT 命令。电机 Node 0x01 不会因此改变状态。

本地 Heartbeat 的 DATA 随后应为 05,表示本地节点处于 Operational。

更新变量并处理 TPDO

先在 app_main.c 的参数区加入:

// 10 ms 是应用让出 CPU 的节奏,100 ms 是测试变量改变的节奏;
// TPDO 发送周期仍由对象 0x1800 中的 200 ms eventTimer 决定。
CANOPEN_LOOP_MS = 10,
COUNTER_UPDATE_MS = 100,

进入内层协议循环前,保存两次时间:

// last_us 计算协议栈每轮真实经过时间,last_counter_us 只判断何时修改测试变量。
// 如果共用一个时间变量,其中一个更新后会破坏另一个计时条件。
int64_t last_us = esp_timer_get_time();
int64_t last_counter_us = last_us;

应用每 100 ms 增加一次计数器:

// 计数器每满 100 ms 增加 1;这里只改对象字典变量,不直接组装或发送 CAN 帧。
// CANopenNode 的 TPDO 模块稍后会按映射从该变量读取 4 个小端序字节。
if ((now_us - last_counter_us) >= COUNTER_UPDATE_MS * 1000LL) {
OD_RAM.x2000_testCNT++;
last_counter_us = now_us;
}

TPDO1 的事件定时器是 200 ms。协议循环中必须加入:

// RPDO 先处理收到的过程数据,TPDO 再检查映射变量和 200 ms 事件定时器。
// 即使本课没有配置 RPDO 数据,也保留标准处理顺序,便于后续扩展。
CO_process_RPDO(co, false, elapsed_us, NULL);
CO_process_TPDO(co, false, elapsed_us, NULL);

这意味着“变量每 100 ms 变化”和“报文每 200 ms 发送”是两件事。PDO 模块按照自己的通信参数决定发送时机。

最终循环的顺序应当是:

// 一轮先推进 NMT/Heartbeat/SDO,再处理 RPDO、TPDO,最后更新应用变量并让出 CPU。
// elapsed_us 来自真实时间戳差值;不能用 CANOPEN_LOOP_MS 直接替代。
reset = CO_process(co, false, elapsed_us, NULL);
CO_process_RPDO(co, false, elapsed_us, NULL);
CO_process_TPDO(co, false, elapsed_us, NULL);
last_us = now_us;

if ((now_us - last_counter_us) >= COUNTER_UPDATE_MS * 1000LL) {
// 变量变化不代表立刻发送;TPDO 是否发送仍由上一轮 PDO 处理和对象配置决定。
OD_RAM.x2000_testCNT++;
last_counter_us = now_us;
}
vTaskDelay(pdMS_TO_TICKS(CANOPEN_LOOP_MS));

写完后先编译,再与配套 Demo 中的 examples/lab_18_local_tpdo/main/app_main.c 逐段比较。重点核对 PDO 初始化位置、三个 process 调用和两个时间变量,不要只比较空格。

怎样解码看到的 DATA

假设抓到:

CAN-ID 0x1A0 DLC 4 DATA 14 00 00 00

四字节小端序数值为:

0x00000014 = 20

下一帧可能变为 22,因为计数器每 100 ms 增加,而 TPDO 每 200 ms 取一次当前值。它不承诺把中间每次变化都逐一发送。

完整工程和操作

工程位于 examples/lab_18_local_tpdo/,完整入口源码:

  • examples/lab_18_local_tpdo/main/app_main.c
  • examples/lab_18_local_tpdo/main/OD.h(对象字典定义)
# 在当前终端加载 ESP-IDF 环境;新开终端后需要重新执行。
cd "$HOME/esp/can-canopen-course/examples/lab_18_local_tpdo"
source "$HOME/esp/esp-idf-v5.5.4/export.sh"
idf.py build
idf.py flash monitor

本课过关条件

  • 总线出现 0x720 Heartbeat,DATA 为本地 NMT 状态;
  • 总线约每 200 ms 出现 0x1A0
  • 0x1A0 的 DLC 为 4;
  • DATA 按小端序还原后,数值持续增加;
  • 总线上没有因为本课代码出现电机控制报文。

这次实验说明:PDO 不是凭空生成的数据包,它把对象字典中已经配置好的变量按映射规则装进 CAN DATA。