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-ID0x20和 1000 ms Heartbeat 正常启动。由于串口内容来自发送程序自身,0x720的 DATA 和周期仍需另一台 CAN 接收设备独立抓包确认。记录见第 16 课上板记录。
先算出应该看到什么
Boot-up 和 Heartbeat 的基础编号是 0x700。本地 Node-ID 是 0x20:
0x700 + 0x20 = 0x720
因此验收目标不是任意日志,而是总线上的:
| 时刻 | CAN-ID | DLC | DATA |
|---|---|---|---|
| 节点完成初始化 | 0x720 | 1 | 00 |
| 之后每 1000 ms | 0x720 | 1 | 当前 NMT 状态 |
先看程序最终怎样走到这两种报文:

这一课会按图中的编号逐段写代码。每完成一段先编译,再继续下一段。
创建工程目录
下面以课程仓库之外的练习目录为例。$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
本课最终会有下面几类文件:

现在不要求读懂 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.c 和 OD.h 位于:
examples/lab_16_canopennode_heartbeat/main/OD.cexamples/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_twai | ESP-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() 仍然需要读取它。
这里按顺序完成:
- 把
0x1017Producer Heartbeat Time 设为 1000 ms; CO_new()创建协议栈对象;CO_CANmodule_disable()确保通信初始化从配置状态开始;CO_CANinit()把协议栈接到刚才的 TWAI 句柄;CO_CANopenInit()载入对象字典和 Node-ID;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 捕获一次
0x720Heartbeat。
当前课程仓库中的工程位于 examples/lab_16_canopennode_heartbeat/。