跳到主要内容

16. 创建 CANopenNode Heartbeat 节点

学习形式编程实验
硬件要求ESP32-C3 + CAN 接收端
配套 Demolab_16_canopennode_heartbeat
本课动作仅本地节点发包

这一课让 ESP32-C3 成为 Node 0x20。程序启动后,它先发送 Boot-up,随后每隔 1000 ms 发送 Heartbeat。

本课不读取电机、不写电机对象,也不会产生运动。

当前验证状态:工程已使用 ESP-IDF v5.5.4 编译并通过 /dev/ttyUSB0 烧录到 ESP32-C3,固件大小为 174592 字节。串口确认 CANopenNode 使用 GPIO19/18、500 kbit/s、Node-ID 0x20 和 1000 ms Heartbeat 正常启动。由于串口内容来自发送程序自身,0x720 的 DATA 和周期仍需另一台 CAN 接收设备独立抓包确认。记录见第 16 课上板记录

先算出应该看到什么

Boot-up 和 Heartbeat 的基础编号是 0x700。本地 Node-ID 是 0x20

0x700 + 0x20 = 0x720

因此验收目标不是任意日志,而是总线上的:

时刻CAN-IDDLCDATA
节点完成初始化0x720100
之后每 1000 ms0x7201当前 NMT 状态

先看程序最终怎样走到这两种报文:

从硬件参数到 Boot-up 和 Heartbeat 的程序运行顺序

这一课会按图中的编号逐段写代码。每完成一段先编译,再继续下一段。

创建工程目录

下面以课程仓库之外的练习目录为例。$HOME 表示当前用户的主目录,安装位置不同就替换成自己的实际路径。

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

idf.py create-project 创建标准 ESP-IDF 工程骨架,set-target 把目标芯片写入工程配置。

完成后应至少看到:

canopennode_heartbeat_practice/
├── CMakeLists.txt
└── main/
├── CMakeLists.txt
└── canopennode_heartbeat_practice.c

把自动生成的源文件改成后面课程统一使用的名字:

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

本课最终会有下面几类文件:

Heartbeat 工程中的文件怎样组成固件

现在不要求读懂 OD.c 的每一行。先记住:app_main.c 写程序流程,OD.c/OD.h 提供 CANopen 节点必须使用的对象字典,idf_component.yml 声明官方组件依赖。

声明官方组件依赖

main/ 下新建 idf_component.yml

# 组件管理器会固定 CANopenNode 0.1.0,并在构建前拒绝低于 5.5.4 的 ESP-IDF。
dependencies:
idf:
version: ">=5.5.4"
espressif/canopennode: "=0.1.0"

这里不是把组件源码复制进工程。它告诉 ESP-IDF 组件管理器:本工程需要哪个官方组件以及哪个版本。

完成依赖文件后运行:

# 重新解析组件依赖,并生成与当前配置匹配的构建文件。
idf.py reconfigure

第一次执行时组件管理器会下载依赖。成功后工程根目录会出现 dependencies.lock,并生成类似 managed_components/espressif__canopennode/ 的目录。不要手工修改这个受管理目录。

准备对象字典文件

CANopenNode 节点必须有对象字典。课程的完整 OD.cOD.h 位于:

  • examples/lab_16_canopennode_heartbeat/main/OD.c
  • examples/lab_16_canopennode_heartbeat/main/OD.h

这两个文件由 CANopen 对象字典工具生成,包含通信对象和 C 变量定义。本课先使用已经生成好的文件,第 17 课再逐项阅读它们。

修改 main/CMakeLists.txt

# app_main.c 写节点流程,OD.c 提供对象字典;两者都必须进入同一个 main 组件。
# CANopenNode 通过 TWAI 访问总线,通过 esp_timer 获得协议循环的真实时间差。
idf_component_register(
SRCS "app_main.c" "OD.c"
INCLUDE_DIRS "."
REQUIRES espressif__canopennode esp_driver_twai esp_timer
)

逐项看:

项目含义
SRCS参与编译的本课源码和对象字典源码
INCLUDE_DIRS允许从当前目录包含 OD.h
espressif__canopennode组件在构建系统中的实际名称
esp_driver_twaiESP-IDF TWAI 驱动
esp_timer计算两次协议处理之间的时间

第一次编译:只确认工程骨架

先不要急着初始化 CAN。把 main/app_main.c 写成下面这样:

// 第一轮只验证 app_main.c、OD.c 和官方组件已经正确参与构建。
// 这里没有创建 TWAI,也没有调用 CANopenNode,因此不会产生任何 CAN 报文。
#include "esp_log.h"

static const char *TAG = "lab_16_heartbeat";

void app_main(void)
{
ESP_LOGI(TAG, "Heartbeat lesson source is running");
}

然后执行:

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

这一轮成功,只证明下面四件事已经接通:工程目录、CMake、官方组件依赖和对象字典文件。它还没有创建 TWAI,也不会发送 CAN 报文。

从参数开始写 app_main.c

第一次编译通过后,再补齐头文件和本课配置。先把文件头部改成:

// FreeRTOS 提供循环延时,esp_timer 提供真实微秒时间,TWAI 连接物理 CAN,
// CANopen.h 与 OD.h 分别提供协议栈 API 和本节点对象字典。
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_twai.h"
#include "esp_twai_onchip.h"
#include "CANopen.h"
#include "OD.h"

enum {
// 电机使用 Node-ID 0x01,本地节点改用 0x20,避免两个节点产生相同 COB-ID。
LOCAL_NODE_ID = 0x20,
// GPIO19/18 来自底板上 ESP32-C3 与 TJA1050 的数字连接。
CAN_TX_GPIO = 19,
CAN_RX_GPIO = 18,
CAN_BITRATE = 500000,
// 对象 0x1017 使用毫秒,本课设置为每 1000 ms 发送一次 Heartbeat。
HEARTBEAT_TIME_MS = 1000,
// 循环每 10 ms 让出 CPU,但传给协议栈的仍是实测 elapsed_us,不是固定 10 ms。
CANOPEN_LOOP_MS = 10,
};

static const char *TAG = "lab_16_heartbeat";

这些值分成三类:

来源参数
底板原理图TX GPIO19、RX GPIO18
电机通信设置500 kbit/s
本地节点规划ESP32 Node-ID 0x20、Heartbeat 1000 ms

ESP32 和电机不能使用相同 Node-ID。电机是 0x01,所以本地选 0x20

接着保留 app_main(),先只打印这些参数:

// 初始化前先打印最终采用的硬件参数和节点参数。
// 串口值若与接线、位速率或 Node-ID 规划不一致,应在创建 TWAI 前修正。
void app_main(void)
{
ESP_LOGI(TAG, "ESP32-C3 CANopenNode heartbeat node");
ESP_LOGI(TAG, "TWAI TX GPIO%d, RX GPIO%d, bitrate %d bit/s",
CAN_TX_GPIO, CAN_RX_GPIO, CAN_BITRATE);
ESP_LOGI(TAG, "Local Node-ID 0x%02X, Producer Heartbeat %d ms",
LOCAL_NODE_ID, HEARTBEAT_TIME_MS);
}

再次运行 idf.py build。这一轮用于发现头文件名、常量和格式化参数错误。编译通过后再向函数中加入硬件初始化。

创建唯一的 TWAI 节点

把下面代码放到三条启动日志之后:

// twai 是 ESP32-C3 片内 CAN 控制器的句柄;TJA1050 只是外部收发器,不创建第二个节点。
twai_node_handle_t twai = NULL;
twai_onchip_node_config_t twai_config = {
// TX/RX 是芯片与 TJA1050 之间的数字引脚,不是总线上的 CANH/CANL。
.io_cfg.tx = CAN_TX_GPIO,
.io_cfg.rx = CAN_RX_GPIO,
.io_cfg.quanta_clk_out = GPIO_NUM_NC,
.io_cfg.bus_off_indicator = GPIO_NUM_NC,
.bit_timing.bitrate = CAN_BITRATE,
// CANopenNode 可能连续排入管理报文,预留 5 个发送槽位减少短时拥塞。
.tx_queue_depth = 5,
};
ESP_ERROR_CHECK(twai_new_node_onchip(&twai_config, &twai));

twai 是后续交给 CANopenNode 的控制器句柄。工程中只创建这一个。

这里调用的是 twai_new_node_onchip(),它只创建 ESP32-C3 片内控制器节点。TJA1050 是板上的外部收发器,不需要在 C 代码里创建第二个驱动。

现在可以做第三次 idf.py build。若这一轮失败,问题只可能集中在 TWAI 头文件、GPIO 配置或驱动组件依赖,不必同时排查 CANopenNode 主循环。

创建并初始化 CANopenNode

// OD_INIT_CONFIG() 把对象字典生成文件中的数量和功能开关装入 co_config。
// co_config 的生命周期必须覆盖 CO_t,不能把它放在会提前返回的临时函数栈中。
CO_config_t co_config = {0};
OD_INIT_CONFIG(co_config);

// 0x1017 是 Producer Heartbeat Time,单位为毫秒;协议栈会按它自动安排发送时机。
OD_PERSIST_COMM.x1017_producerHeartbeatTime = HEARTBEAT_TIME_MS;

// CO_new 只创建协议栈对象,还没有绑定 TWAI,也没有让控制器进入正常模式。
CO_t *co = CO_new(&co_config, NULL);
if (co == NULL) {
ESP_LOGE("heartbeat", "CO_new failed");
return;
}

// err_info 接收初始化阶段的对象字典错误位置,返回失败时可用于进一步定位。
uint32_t err_info = 0;
CO_CANmodule_disable(co->CANmodule);

// CO_CANinit 的位速率单位是 kbit/s,所以要把 500000 bit/s 除以 1000。
ESP_ERROR_CHECK(CO_CANinit(co, twai, CAN_BITRATE / 1000) == CO_ERROR_NO
? ESP_OK : ESP_FAIL);
ESP_ERROR_CHECK(CO_CANopenInit(co, NULL, NULL, OD, NULL, 0,
HEARTBEAT_TIME_MS, 1000, 500, false,
LOCAL_NODE_ID, &err_info) == CO_ERROR_NO
? ESP_OK : ESP_FAIL);
// 前面所有配置成功后才进入正常模式;从此控制器才真正参与总线通信和 ACK。
CO_CANsetNormalMode(co->CANmodule);

co_config 必须放在 app_main() 中,不能放进一个执行完就返回的临时初始化函数。CO_new() 会保存它的地址,后续 CO_process() 仍然需要读取它。

这里按顺序完成:

  1. 0x1017 Producer Heartbeat Time 设为 1000 ms;
  2. CO_new() 创建协议栈对象;
  3. CO_CANmodule_disable() 确保通信初始化从配置状态开始;
  4. CO_CANinit() 把协议栈接到刚才的 TWAI 句柄;
  5. CO_CANopenInit() 载入对象字典和 Node-ID;
  6. CO_CANsetNormalMode() 允许控制器真正参与总线通信。

完成后再次执行 idf.py build。这时程序已经能启动 CANopenNode,但如果没有持续处理协议时间,Heartbeat 仍不会周期运行。

让协议一直运行

初始化完成不等于协议会自动永久运行。CANopenNode 还可能要求重新初始化通信,所以完整主循环分成外层“处理复位请求”和内层“持续处理协议”:

// CANopenNode 不会在初始化后自动永久运行,应用必须持续把真实经过时间交给 CO_process()。
CO_NMT_reset_cmd_t reset = CO_RESET_NOT;
int64_t last_us = esp_timer_get_time();

while (reset == CO_RESET_NOT) {
int64_t now_us = esp_timer_get_time();
// 使用两次时间戳之差,可把 FreeRTOS 调度造成的延迟计入协议时间。
uint32_t elapsed_us = (uint32_t)(now_us - last_us);

// 返回值可能要求通信复位;本段先在收到非 CO_RESET_NOT 时退出内层循环。
reset = CO_process(co, false, elapsed_us, NULL);
last_us = now_us;
vTaskDelay(pdMS_TO_TICKS(CANOPEN_LOOP_MS));
}

elapsed_us 告诉协议栈真实经过了多少微秒。Heartbeat 的 1000 ms 就靠这些时间累积,而不是应用每秒手工发送一帧。

参考工程在这段循环外还保留了 CO_RESET_COMM 的重新初始化框架,并在退出时调用 CO_delete()twai_node_delete()。写到这里后,把自己的文件与完整参考源码逐段比较,重点检查顺序和错误处理,不要直接整文件覆盖。

完整参考源码位于配套 Demo 的 examples/lab_16_canopennode_heartbeat/main/app_main.c

构建、烧录和监视

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

不写 -p 时,ESP-IDF 会尝试自动寻找串口。连接多个串口设备时,再明确指定:

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

退出监视器使用 Ctrl+]

串口日志中的 CANopenNode running 只能证明程序走到了初始化末尾。要证明 CANopen 节点真正出现在总线上,还要用另一台 CAN 接收设备看到 0x720 DATA 00 和周期 0x720

本课过关条件

  • 固件构建和烧录成功;
  • 启动后没有崩溃或反复复位;
  • 总线捕获到一次 0x720 [1] 00
  • 此后约每 1000 ms 捕获一次 0x720 Heartbeat。

当前课程仓库中的工程位于 examples/lab_16_canopennode_heartbeat/