09. NMT、Boot-up 和 Heartbeat
第 06 课的 ESP32-C3 程序收到过这样一帧报文:
RX CAN-ID 0x701 DLC 1 DATA 05
如果继续观察,会发现它不是偶然出现一次,而是大约每隔 1 秒重复一次:
0x701 [1] 05
0x701 [1] 05
0x701 [1] 05
报文没有位置、速度或故障码,只有一个字节 05。电机为什么还要一直发送它?
控制器不仅需要读写设备参数,还需要知道:
这个 CANopen 节点是否还在线?
它当前允许进行哪一类 CANopen 通信?
它是不是刚刚完成了一次启动?
CANopen 用 NMT 管理节点的通信状态,用 Boot-up 报告一次启动完成,再用 Heartbeat 周期报告节点仍然在线以及当前 NMT 状态。
本课先看懂这些报文,再写一个只监听电机的程序。程序不会发送 NMT、SDO、PDO 或运动命令。
先读懂已经收到的报文
第 07 课已经算过 CANopen 默认的 Boot-up/Heartbeat 报文 CAN-ID:
CAN-ID = 0x700 + Node-ID
本教程电机的 Node-ID 是 0x01:
0x700 + 0x01 = 0x701
因此,0x701 表明这条 Boot-up 或 Heartbeat 来自 Node-ID 0x01。0x700 是 CANopen 规定的基础编号,不是 ESP32 或电机厂家临时选的数字。
这类报文的 DLC 固定为 1,唯一的数据字节用于表示启动完成或 NMT 状态:
| DATA | 含义 | 怎样理解 |
|---|---|---|
00 | Boot-up | 节点刚完成 CANopen 初始化 |
04 | Stopped | 节点处于 NMT 停止状态 |
05 | Operational | 节点处于 NMT 运行状态 |
7F | Pre-operational | 节点处于 NMT 预运行状态 |
所以实测报文可以逐项读成:
CAN-ID 0x701 来自 Node-ID 0x01 的 Boot-up/Heartbeat 报文
DLC 1 数据区只有 1 字节
DATA 05 当前 NMT 状态是 Operational
这里的 Operational 是 CANopen 通信状态。它不表示电机正在转,也不表示 CiA 402 驱动已经使能。这个区别非常重要,后面会单独说明。
NMT 管理的是什么
一个 CANopen 节点可以接收 Heartbeat、响应参数访问,也可能周期交换过程数据。节点刚启动时,不应该在所有初始化工作完成前就参与全部通信。
CANopen 因此给每个节点规定了一组通信状态。负责管理这些状态的机制叫 NMT,英文全称是 Network Management,中文通常译为网络管理。
可以先把 NMT 状态理解成节点当前开放的“通信工作方式”:
Initializing:正在初始化
节点刚上电或复位,正在准备 CAN 控制器、对象字典和应用程序。
这段时间还没有完成正常 CANopen 启动。初始化结束时,节点会发送一次 DATA 为 00 的 Boot-up 报文。
Pre-operational:预运行
状态值是 0x7F。
节点已经完成初始化,可以进行 NMT 和 SDO 等配置通信,但通常还不进行正常 PDO 过程数据交换。这个状态适合在设备正式工作前读取参数、修改配置。
Operational:运行
状态值是 0x05。
节点已经进入 CANopen 的正常通信状态,可以按照配置使用 PDO,同时仍可处理允许的其他 CANopen 服务。
这里的“运行”说的是 CANopen 节点,不是机械运动。电机可以在 NMT Operational 状态下一动不动。
Stopped:停止
状态值是 0x04。
节点停止大部分 CANopen 通信,但仍保留 NMT 和错误控制所需的通信能力,以便网络管理者知道它还在线,并再次改变它的状态。
把四种状态放在一起看:
Initializing 节点正在启动
Pre-operational 已启动,可以配置,尚未开放正常 PDO 通信
Operational CANopen 正常通信
Stopped 大部分 CANopen 通信停止,仍接受网络管理
不同设备上电后的自动行为可能不同。有的节点发送 Boot-up 后停在 Pre-operational,等待 NMT 命令;有的产品根据启动配置很快进入 Operational。判断当前设备的实际状态,要看它随后发送的 Heartbeat 数据,而不是凭设备类型猜测。
NMT 怎样改变节点状态
CANopen 网络中通常有一个 NMT 管理者,向节点发送 NMT 命令。NMT 命令使用 CAN-ID 0x000,数据区有 2 字节:
CAN-ID 0x000
DLC 2
DATA[0] 命令
DATA[1] 目标 Node-ID,00 表示所有节点
常见命令如下:
| DATA[0] | 命令 |
|---|---|
01 | Start Remote Node,进入 Operational |
02 | Stop Remote Node,进入 Stopped |
80 | Enter Pre-operational |
81 | Reset Node |
82 | Reset Communication |
例如 01 01 的含义是让 Node-ID 0x01 进入 Operational,而不是让电机开始旋转。
本课只需要知道节点状态从哪里来,实验程序不会发送这些命令。复位节点也可能改变设备当前工作状态,因此不能把 NMT 命令当成无害的串口测试字符随便发送。
Boot-up 为什么只出现一次
Boot-up 是节点完成 CANopen 初始化时发出的启动通知。对于 Node-ID 0x01,典型报文是:
CAN-ID 0x701
DLC 1
DATA 00
它表达的是:
Node-ID
0x01已经完成这一次 CANopen 初始化。
Boot-up 不是周期报文。节点每次上电或发生相应复位后只发送一次,所以很容易错过:
电机先上电并发出 Boot-up
ESP32 后启动串口监视器
这种顺序下,监视器看不到 00 很正常。只要后续 Heartbeat 持续到达,就不能因为没看到 Boot-up 而断言通信失败。
想专门捕获 Boot-up,应先让 ESP32 的监听程序显示 TWAI ready,再重新给电机上电。重新烧录或复位 ESP32 只重启了监听端,并不会让已经上电的电机重新发送 Boot-up。
Heartbeat 为什么不断重复
节点启动后,可以按照配置的周期发送 Heartbeat。每一帧都带着节点当前 NMT 状态:
0x701 [1] 05
如果它约每 1000 ms 出现一次,接收端就能同时知道两件事:
刚刚又收到一帧 节点仍然在线,通信仍在继续
DATA 是 05 节点当前是 NMT Operational
发送 Heartbeat 的节点叫 Heartbeat Producer,也就是心跳生产者。监视它的控制器叫 Heartbeat Consumer,也就是心跳消费者。
消费者不会要求周期绝对等于某个毫秒数。总线调度和任务运行可能带来少量偏差,实际看到 1000 ms、1001 ms 都很正常。消费者通常设置一个比发送周期更长的超时时间。例如发送周期约 1000 ms,本课程序连续 3000 ms 没有再收到 0x701 才报告超时。
CANopen 资料把 Boot-up、Heartbeat 等内容归在 NMT error control 下。这里的 error control 不是说每一帧 Heartbeat 都在报告故障,而是接收端可以通过状态值和报文是否按时到达,发现节点复位、停止响应或通信中断。
从上电到周期 Heartbeat
看图时先沿竖直箭头看启动过程,再看底部三帧重复的 Heartbeat:

对于本教程中 Node-ID 0x01 的电机,整个过程可以写成:
电机上电或复位
-> 完成 CANopen 初始化
-> 发送一次 0x701 [1] 00 Boot-up
-> 进入配置所决定的 NMT 状态
-> 周期发送 0x701 [1] 当前状态
图中 Heartbeat 的 05 来自本项目实际观察,表示电机当前处于 NMT Operational。另一台设备若停在 Pre-operational,周期数据会是 7F,不能把 05 写死成所有 CANopen 设备的固定心跳数据。
NMT Operational 不等于电机已经使能
现在同时出现了两套“状态”,必须分清:
NMT 状态 描述 CANopen 节点当前怎样参与通信
CiA 402 状态 描述电机驱动器是否就绪、使能、故障等
收到:
0x701 [1] 05
只能证明节点报告自己处于 NMT Operational。它不能单独证明:
- 电机已经 Operation enabled;
- 功率级已经使能;
- 电机正在运动;
- 目标位置已经到达;
- 电机没有 CiA 402 故障。
这些驱动状态要到后面读取 CiA 402 Statusword 才能判断。本课不会读取或写入任何电机控制对象。
动手写一个 Heartbeat 观察程序
这个实验沿用第 06 课的 TWAI 接收路径,在普通任务中只挑出 0x701,识别 Boot-up 和 Heartbeat,并计算相邻 Heartbeat 的时间间隔。
写代码前先把判断顺序画出来:

程序必须先核对 CAN-ID 和 DLC,最后才解释 DATA[0]。否则其他节点的一字节报文也可能被误写成“电机 Heartbeat”。下面的每个判断函数都能在这张图中找到位置。
程序的 CAN 行为是:
主动发送 CAN 数据帧 不会
发送 NMT 命令 不会
发送 SDO 或 PDO 不会
写电机对象 不会
让电机运动 不会
正常接收并自动 ACK 会
创建练习工程
打开新终端并加载 ESP-IDF v5.5.4:
# 在当前终端加载 ESP-IDF 环境;新开终端后需要重新执行。
source "$HOME/esp/esp-idf-v5.5.4/export.sh"
idf.py --version
如果你的 ESP-IDF 安装在其他目录,请把路径换成实际位置。环境加载只对当前终端有效。
创建工程前先检查同名目录:
# 先确认目标路径不存在,避免覆盖之前的工程或源码。
mkdir -p "$HOME/esp/can-course-work"
test -e "$HOME/esp/can-course-work/lab_09_canopen_heartbeat" \
&& echo "目录已存在,先不要覆盖" \
|| echo "可以创建"
看到“可以创建”后执行:
# 创建一个全新的练习工程,并进入工程目录核对生成结果。
idf.py create-project \
-p "$HOME/esp/can-course-work/lab_09_canopen_heartbeat" \
lab_09_canopen_heartbeat
cd "$HOME/esp/can-course-work/lab_09_canopen_heartbeat"
写构建配置
把 main/CMakeLists.txt 改成:
# 本课除 TWAI/GPIO 外还依赖 esp_timer,用微秒时间戳测量 Heartbeat 周期和静默时间。
idf_component_register(
SRCS "lab_09_canopen_heartbeat.c"
INCLUDE_DIRS "."
REQUIRES esp_driver_twai esp_driver_gpio esp_timer
)
与第 06 课相比,多出的 esp_timer 用于取得微秒级系统时间,从而计算相邻 Heartbeat 的周期。
在工程顶层创建 sdkconfig.defaults:
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
它与本教程底板的 4 MB Flash 一致。
第一步:先写出能编译的程序骨架
打开 main/lab_09_canopen_heartbeat.c,删除自动生成的内容。先写头文件、固定参数和一个临时的 app_main():
// 日志标签固定为 canopen_heartbeat,串口里可以快速筛出本课输出。
#include <inttypes.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "driver/gpio.h"
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_twai.h"
#include "esp_twai_onchip.h"
#include "freertos/FreeRTOS.h"
#include "freertos/queue.h"
#define CAN_TX_GPIO GPIO_NUM_19
#define CAN_RX_GPIO GPIO_NUM_18
#define CAN_BITRATE 500000
#define MOTOR_NODE_ID 0x01
#define NMT_ERROR_CONTROL_BASE_ID 0x700
#define MOTOR_HEARTBEAT_CAN_ID (NMT_ERROR_CONTROL_BASE_ID + MOTOR_NODE_ID)
#define RX_QUEUE_LENGTH 20
#define HEARTBEAT_TIMEOUT_MS 3000
static const char *TAG = "canopen_heartbeat";
void app_main(void)
{
ESP_LOGI(TAG, "Motor Node-ID 0x%02X, expected CAN-ID 0x%03X",
MOTOR_NODE_ID, MOTOR_HEARTBEAT_CAN_ID);
}
先看这几个 CANopen 参数是怎样落到代码里的:
// 头文件保护避免重复包含;这里只暴露其他源文件真正需要的类型和函数。
#define MOTOR_NODE_ID 0x01
#define NMT_ERROR_CONTROL_BASE_ID 0x700
#define MOTOR_HEARTBEAT_CAN_ID (NMT_ERROR_CONTROL_BASE_ID + MOTOR_NODE_ID)
第一行来自电机配置,第二行来自 CANopen 的默认编号规则,第三行让 C 预处理器完成加法:
MOTOR_HEARTBEAT_CAN_ID = 0x700 + 0x01 = 0x701
这样写比直接定义 0x701 更容易检查来源。以后连接 Node-ID 0x05 的设备,只修改 MOTOR_NODE_ID,表达式就会得到 0x705。
另外两个数字属于本程序的软件策略:
RX_QUEUE_LENGTH 队列最多暂存 20 条接收记录
HEARTBEAT_TIMEOUT_MS 连续 3000 ms 没收到心跳就报告一次超时
它们不是 CANopen 对所有设备规定的固定值。特别是超时时间,应根据设备实际 Heartbeat 周期选择。
现在执行第一次构建:
# 先选择 ESP32-C3,再编译工程;这一步不会烧录开发板。
idf.py set-target esp32c3
idf.py build
这一版还没有启动 TWAI,也收不到 CAN 帧。此时构建只是确认工程名、组件依赖、头文件和第一批常量都写对了。串口最终会打印计算后的 0x701,说明程序使用的 CAN-ID 与手算结果一致。
第二步:准备保存一帧报文的结构体和队列
程序收到一帧后,回调不能慢慢打印日志。它要先复制 CAN-ID、DLC 和 DATA,再通过 FreeRTOS 队列交给普通任务。
找到第一步写的这一行:
// 日志标签固定为 canopen_heartbeat,串口里可以快速筛出本课输出。
static const char *TAG = "canopen_heartbeat";
用下面整段替换它:
// 队列句柄由 app_main 创建,接收回调和普通任务通过它交接完整报文。
typedef struct {
uint32_t id;
uint8_t dlc;
uint8_t data[TWAI_FRAME_MAX_LEN];
} can_rx_message_t;
static const char *TAG = "canopen_heartbeat";
static QueueHandle_t s_rx_queue;
static volatile uint32_t s_dropped_frames;
can_rx_message_t 是程序自己的接收记录:
id 保存 CAN-ID
dlc 保存有效数据长度
data 保存 Classical CAN 的数据字节
s_rx_queue 指向稍后创建的队列。s_dropped_frames 记录“报文到达了,但软件队列已经装满”的次数。
这里把 volatile 用在丢帧计数上,是因为它会在接收回调中改变,也会在普通任务中读取。它提醒编译器每次都读取当前值。
第三步:让 TWAI 回调把报文送进队列
把下面函数放在临时 app_main() 前面:
// 回调只负责把 TWAI 帧安全交给队列,不在中断中判断 NMT 状态或打印日志。
static bool IRAM_ATTR twai_rx_callback(twai_node_handle_t handle,
const twai_rx_done_event_data_t *event,
void *user_ctx)
{
(void)event;
(void)user_ctx;
// FreeRTOS 通过这个变量告诉回调:入队后是否有更高优先级任务需要立即运行。
BaseType_t high_task_woken = pdFALSE;
// 驱动把 DATA 写入 rx_data;rx_frame 同时保存 CAN-ID、DLC 和缓冲区容量。
uint8_t rx_data[TWAI_FRAME_MAX_LEN];
twai_frame_t rx_frame = {
.buffer = rx_data,
.buffer_len = sizeof(rx_data),
};
// 只有成功取到完整 CAN 帧,才把数据复制到软件队列。
if (twai_node_receive_from_isr(handle, &rx_frame) == ESP_OK) {
can_rx_message_t message = {
.id = rx_frame.header.id,
.dlc = (uint8_t)rx_frame.header.dlc,
};
// DLC 来自总线,复制前仍要限制在本地 DATA 缓冲区范围内。
size_t data_len = message.dlc;
if (data_len > sizeof(message.data)) {
data_len = sizeof(message.data);
}
// 驱动缓冲区离开回调后不再可靠,因此把有效字节复制到自己的记录中。
memcpy(message.data, rx_data, data_len);
// 中断中必须使用 FromISR API;队列满时只计数,不在这里打印日志。
if (xQueueSendFromISR(s_rx_queue, &message, &high_task_woken) != pdTRUE) {
s_dropped_frames++;
}
}
// 返回 true 时,驱动会在退出中断后尽快调度刚被队列唤醒的任务。
return high_task_woken == pdTRUE;
}
这段接收路径在第 06 课已经逐行写过。现在顺着数据流再走一遍:
TWAI 收到一帧
-> 驱动调用 twai_rx_callback()
-> twai_node_receive_from_isr() 取出这一帧
-> 复制到 can_rx_message_t
-> xQueueSendFromISR() 把记录送入队列
-> 回调立刻结束
函数名中的 from_isr 表明这些接口可以在中断环境使用。回调里没有 printf() 或 ESP_LOGI(),因为串口输出可能阻塞很久。真正的协议判断和日志留给普通任务。
memcpy() 前先限制 data_len,保证复制长度不会超过 message.data。虽然本实验只处理 1 字节 Heartbeat,这个接收层仍按一条完整 Classical CAN 数据帧保存。
第四步:把状态数值翻译成名称
接收层只知道 DATA 是一个字节,不知道 05 代表什么。现在增加一个只负责“数值到名称”的小函数。
把它放在接收回调之后、临时 app_main() 之前:
// DATA[0] 保存 NMT 状态数值。集中转换为名称后,未知值仍保留为 UNKNOWN,
// 不会把尚未识别的厂商状态误写成某个标准状态。
static const char *nmt_state_name(uint8_t state)
{
switch (state) {
case 0x04:
return "STOPPED";
case 0x05:
return "OPERATIONAL";
case 0x7F:
return "PRE-OPERATIONAL";
default:
return "UNKNOWN";
}
}
调用 nmt_state_name(0x05) 时,函数返回字符串 "OPERATIONAL"。
switch 会拿 state 与每个 case 比较。标准表中没有的值走 default,打印为 UNKNOWN。程序只报告自己能够确认的含义,不给未知数值随意起名字。
这里没有给 0x00 返回 INITIALIZING。因为总线上收到 DATA 00 时,要把整帧解释成一次 Boot-up,而不是普通的周期 Heartbeat。下一步会单独处理它。
第五步:写 Boot-up 和 Heartbeat 解析函数
解析函数需要三份信息:
message 当前收到的 CAN 报文
now_us 当前接收时间,单位为微秒
last_heartbeat_us 上一帧 Heartbeat 的时间
把下面函数放在 nmt_state_name() 后面、临时 app_main() 前面:
// 这里只处理已经按 CAN-ID 筛选出的目标电机报文。
// Boot-up 与 Heartbeat 使用同一个 CAN-ID,必须继续根据 DLC 和 DATA[0] 区分。
static void process_motor_heartbeat(const can_rx_message_t *message,
int64_t now_us,
int64_t *last_heartbeat_us)
{
// esp_timer_get_time() 返回微秒;日志显示毫秒更便于和 Heartbeat 周期对照。
int64_t now_ms = now_us / 1000;
if (message->dlc != 1) {
ESP_LOGW(TAG,
"RX time=%" PRId64 " ms CAN-ID 0x%03" PRIX32
" DLC %u; expected DLC 1 for Boot-up/Heartbeat",
now_ms, message->id, message->dlc);
return;
}
// DLC 已确认是 1,此时读取 DATA[0] 才不会访问不存在的数据字节。
uint8_t state = message->data[0];
if (state == 0x00) {
ESP_LOGI(TAG,
"RX time=%" PRId64 " ms CAN-ID 0x%03" PRIX32
" DLC 1 DATA 00 | Node-ID 0x%02X BOOT-UP",
now_ms, message->id, MOTOR_NODE_ID);
ESP_LOGI(TAG, "Boot-up means the node finished CANopen initialization");
return;
}
// 0 表示还没有上一帧 Heartbeat。第一帧只能记录时间,不能计算周期。
if (*last_heartbeat_us == 0) {
ESP_LOGI(TAG,
"RX time=%" PRId64 " ms CAN-ID 0x%03" PRIX32
" DLC 1 DATA %02X | Node-ID 0x%02X HEARTBEAT %s | period first",
now_ms, message->id, state, MOTOR_NODE_ID,
nmt_state_name(state));
} else {
int64_t period_ms = (now_us - *last_heartbeat_us + 500) / 1000;
ESP_LOGI(TAG,
"RX time=%" PRId64 " ms CAN-ID 0x%03" PRIX32
" DLC 1 DATA %02X | Node-ID 0x%02X HEARTBEAT %s"
" | period=%" PRId64 " ms",
now_ms, message->id, state, MOTOR_NODE_ID,
nmt_state_name(state), period_ms);
}
*last_heartbeat_us = now_us;
}
不要先被函数参数里的 * 绊住。按用途理解:
// message 指向当前报文;const 表示解析函数只读取它,不修改队列中的数据。
const can_rx_message_t *message
message 指向当前报文。函数只读取它,因此前面加 const。读取结构体成员时写 message->dlc、message->data[0]。
// 传入时间变量的地址,函数才能把最新 Heartbeat 时刻写回主循环。
int64_t *last_heartbeat_us
这里传入的是“上一帧时间变量的地址”。函数内部写:
// 处理完成后记住本帧时间,下一帧到来时才能计算实际周期。
*last_heartbeat_us = now_us;
就能把调用者保存的时间更新成当前时间。下一帧到来时,这个数仍然存在,可以拿来计算周期。
函数的判断顺序与协议格式一致。
首先检查 DLC:
// Boot-up 和 Heartbeat 的 DLC 必须为 1,长度不符的报文直接忽略。
if (message->dlc != 1)
Boot-up/Heartbeat 应只有 1 个数据字节。长度不对就打印警告并 return,不能继续读取并解释一个格式不符的报文。
然后单独检查 00:
// 状态字节为 0 表示 Boot-up,不能当作普通 Heartbeat 状态输出。
if (state == 0x00)
命中后打印 BOOT-UP 并返回。Boot-up 不参与 Heartbeat 周期计算,否则“启动通知到第一帧心跳的间隔”会被误写成心跳周期。
最后处理非零状态。第一次 Heartbeat 到来时,*last_heartbeat_us 还是 0,没有上一帧可以相减,所以打印 period first。第二帧开始才计算:
// 用相邻两帧时间差计算 Heartbeat 周期,加 500 后整除可完成毫秒四舍五入。
int64_t period_ms = (now_us - *last_heartbeat_us + 500) / 1000;
esp_timer_get_time() 使用微秒,除以 1000 后得到毫秒。加 500 是把微秒结果四舍五入到最接近的毫秒,不会修改 CAN 报文。
第六步:替换临时 app_main,创建并启动 TWAI
前面的函数都只是准备工作。现在删除第一步写的临时 app_main(),换成真正版本。
先写 app_main() 的前半段:
// app_main() 负责硬件初始化和超时检查;接收中断只负责把原始帧送进队列。
void app_main(void)
{
ESP_LOGI(TAG, "CANopen NMT error-control observation lab");
ESP_LOGI(TAG, "Target: %s", CONFIG_IDF_TARGET);
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, "Motor Node-ID 0x%02X, expected CAN-ID 0x%03X",
MOTOR_NODE_ID, MOTOR_HEARTBEAT_CAN_ID);
ESP_LOGW(TAG, "Listen only: no NMT, SDO, PDO, or motor command is sent");
ESP_LOGW(TAG, "Power-cycle the motor to capture CAN-ID 0x%03X DATA 00 Boot-up",
MOTOR_HEARTBEAT_CAN_ID);
// 队列必须早于 TWAI 节点创建,否则节点启动后立即到达的帧没有安全的存放位置。
s_rx_queue = xQueueCreate(RX_QUEUE_LENGTH, sizeof(can_rx_message_t));
if (s_rx_queue == NULL) {
ESP_LOGE(TAG, "Failed to create RX queue");
return;
}
twai_onchip_node_config_t node_config = {
.io_cfg = {
.tx = CAN_TX_GPIO,
.rx = CAN_RX_GPIO,
.quanta_clk_out = GPIO_NUM_NC,
.bus_off_indicator = GPIO_NUM_NC,
},
.bit_timing.bitrate = CAN_BITRATE,
.tx_queue_depth = 1,
.flags.no_receive_rtr = true,
};
// ESP_ERROR_CHECK 在初始化失败时立即给出错误,避免后续继续使用无效句柄。
twai_node_handle_t node = NULL;
ESP_ERROR_CHECK(twai_new_node_onchip(&node_config, &node));
twai_mask_filter_config_t filter = {
.id = 0,
.mask = 0,
.is_ext = false,
.no_classic = false,
.no_fd = true,
};
// 接收层保留所有标准帧,目标 0x701 的筛选放到普通任务中,便于排查总线是否还有其他流量。
ESP_ERROR_CHECK(twai_node_config_mask_filter(node, 0, &filter));
twai_event_callbacks_t callbacks = {
.on_rx_done = twai_rx_callback,
};
ESP_ERROR_CHECK(twai_node_register_event_callbacks(node, &callbacks, NULL));
ESP_ERROR_CHECK(twai_node_enable(node));
ESP_LOGI(TAG, "TWAI ready; waiting for motor Boot-up/Heartbeat");
先不要补右花括号,下一步的主循环仍属于这个 app_main()。
创建顺序不能随意交换:
1. 先创建软件队列
2. 再创建 TWAI 节点
3. 配置接收过滤器
4. 注册接收回调
5. 最后启用 TWAI
队列必须先存在,因为 TWAI 一启用,CAN 帧就可能到达并触发回调。若先启动 TWAI,回调可能向一个尚未创建的队列写数据。
node_config 把硬件参数交给驱动:
.tx / .rx GPIO19 / GPIO18
.bit_timing.bitrate 500000 bit/s
.no_receive_rtr 不接收远程帧
过滤器当前接收所有 11 位标准 Classical CAN 数据帧。真正只处理 0x701 的判断放在普通任务里,这样排查时接收层仍具备观察其他标准帧的能力。
每个返回 esp_err_t 的驱动调用都放进 ESP_ERROR_CHECK()。初始化失败时,ESP-IDF 会打印具体错误并停止当前程序,而不是带着无效的 TWAI 句柄继续运行。
第七步:在主循环中筛选、解析并检查超时
紧接上一段 ESP_LOGI() 后继续写下面代码,最后的两个右花括号分别结束 while 和 app_main():
// 这三个变量跨越多次循环保存状态,分别用于计算周期、防止超时刷屏和报告丢帧。
int64_t last_heartbeat_us = 0;
bool timeout_reported = false;
uint32_t last_drop_count = 0;
while (true) {
can_rx_message_t message;
// 最多等待 100 ms;即使没有新帧,任务也会定期醒来执行下面的超时检查。
if (xQueueReceive(s_rx_queue, &message, pdMS_TO_TICKS(100)) == pdTRUE &&
message.id == MOTOR_HEARTBEAT_CAN_ID) {
int64_t now_us = esp_timer_get_time();
process_motor_heartbeat(&message, now_us, &last_heartbeat_us);
// Boot-up 的 DATA[0] 为 0,不算周期 Heartbeat;只有非零状态才能解除超时。
if (message.dlc == 1 && message.data[0] != 0x00) {
if (timeout_reported) {
ESP_LOGI(TAG, "Heartbeat reception recovered");
}
timeout_reported = false;
}
}
// 从未收到 Heartbeat 时不计算静默时间;同一次超时只报告一次。
if (last_heartbeat_us != 0 && !timeout_reported) {
int64_t silence_ms = (esp_timer_get_time() - last_heartbeat_us) / 1000;
if (silence_ms >= HEARTBEAT_TIMEOUT_MS) {
ESP_LOGW(TAG,
"Heartbeat timeout: no CAN-ID 0x%03X for %" PRId64 " ms",
MOTOR_HEARTBEAT_CAN_ID, silence_ms);
timeout_reported = true;
}
}
// 丢帧计数由中断更新,普通任务只在数值变化时打印,避免在中断里做慢速串口输出。
uint32_t current_drop_count = s_dropped_frames;
if (current_drop_count != last_drop_count) {
ESP_LOGW(TAG, "RX software queue full; dropped frames: %" PRIu32,
current_drop_count);
last_drop_count = current_drop_count;
}
}
}
主循环第一次出现的三个变量各自保存一种“上一次状态”:
last_heartbeat_us 上一帧 Heartbeat 在什么时间到达
timeout_reported 当前这次超时是否已经打印过
last_drop_count 上一次已经报告到多少条软件丢帧
接收判断分成两部分:
// 只有 xQueueReceive 返回 pdTRUE,message 中才是一条新的有效报文。
xQueueReceive(...) == pdTRUE
表示队列真的交出了一条报文;
// 队列会收到多种 CAN 帧,CAN-ID 匹配后才交给 Heartbeat 解析函数。
message.id == MOTOR_HEARTBEAT_CAN_ID
表示这条报文的 CAN-ID 是由电机 Node-ID 算出的 0x701。两项都成立才调用解析函数,其他 CAN-ID 不会被错当成电机 Heartbeat。
调用时写:
// 把目标节点的 Heartbeat 交给解析函数,同时更新最后接收时间。
process_motor_heartbeat(&message, now_us, &last_heartbeat_us);
两个 & 都表示“把变量的地址交给函数”。解析函数只读取 message,但需要更新 last_heartbeat_us,所以后一个地址让新时间能够保留下来。
第 06 课使用 portMAX_DELAY 一直等待报文。本课改成最多等待 100 ms:
// 最长阻塞 100 ms,既避免空转,也保证超时检查能及时运行。
pdMS_TO_TICKS(100)
若 100 ms 内没有报文,任务会醒来检查一次静默时间。它不是用固定延时假装协议响应;Heartbeat 仍然只能来自 TWAI 实际收到的 CAN 帧。
超时判断中的第一个条件:
// 收到过第一帧后才检查超时,避免上电阶段立刻误报。
last_heartbeat_us != 0
保证程序至少真正收到过一帧 Heartbeat,才开始判断后续是否中断。刚启动且从未见过电机时,不会不断打印“心跳超时”。
第二个条件 !timeout_reported 防止相同故障每 100 ms 刷屏。报告一次后将它设为 true;下一帧有效 Heartbeat 到达时再恢复为 false,并打印 Heartbeat reception recovered。
第八步:核对最终文件
现在源文件应按下面顺序排列:
1. #include
2. #define
3. can_rx_message_t、队列和全局变量
4. twai_rx_callback()
5. nmt_state_name()
6. process_motor_heartbeat()
7. app_main()
参考工程位于默认课程目录时,可以逐行比较:
# 逐文件比较练习代码与参考实现,先处理第一处差异。
diff -u \
"$HOME/esp/can-canopen-course/examples/lab_09_canopen_heartbeat/main/lab_09_canopen_heartbeat.c" \
main/lab_09_canopen_heartbeat.c
没有输出表示两份文件相同。有差异时,先看行首:- 是参考文件中的内容,+ 是自己文件中的内容。不要不看差异就覆盖文件,先判断是不是漏了花括号、分号或某一段代码。
最终再构建一次:
# 编译当前工程,先处理出现的第一条错误。
idf.py build
如果出现编译错误,先找终端中第一条 error::
| 第一条错误 | 优先检查 |
|---|---|
unknown type name | 对应头文件、类型名和代码顺序 |
expected ... before | 报错行以及上一行的分号、括号 |
undefined reference to app_main | 临时 app_main() 是否删除后忘了写正式版本 |
expected declaration or statement at end of input | 第七步末尾的两个右花括号是否完整 |
| TWAI 头文件找不到 | main/CMakeLists.txt 的 REQUIRES |
构建、烧录和监视串口
确认当前目录是自己的 Lab 09 工程,然后执行:
# 先选择 ESP32-C3,再编译工程;这一步不会烧录开发板。
idf.py set-target esp32c3
idf.py build
idf.py flash monitor
idf.py flash monitor 默认会自动查找可用串口,不必每次都写设备名。只有连接了多个串口设备或自动识别失败时,才指定端口,例如:
# 烧录固件并打开串口监视器;按 Ctrl+] 退出监视器。
idf.py -p /dev/ttyUSB0 flash monitor
退出串口监视器按 Ctrl+]。
成功启动后,先看参数是否正确:
TWAI TX GPIO19, RX GPIO18, bitrate 500000 bit/s
Motor Node-ID 0x01, expected CAN-ID 0x701
Listen only: no NMT, SDO, PDO, or motor command is sent
TWAI ready; waiting for motor Boot-up/Heartbeat
本项目实际验证结果
参考工程使用 ESP-IDF v5.5.4 为 ESP32-C3 构建成功:
Project build complete.
lab_09_canopen_heartbeat.bin binary size 0x2f510 bytes.
使用 idf.py flash monitor 自动识别到 /dev/ttyUSB0,完成烧录和校验。连接本教程底板和 IG35EC020 后,收到:
I (...) canopen_heartbeat: RX time=1614 ms CAN-ID 0x701 DLC 1 DATA 05 | Node-ID 0x01 HEARTBEAT OPERATIONAL | period=1000 ms
I (...) canopen_heartbeat: RX time=2614 ms CAN-ID 0x701 DLC 1 DATA 05 | Node-ID 0x01 HEARTBEAT OPERATIONAL | period=1001 ms
I (...) canopen_heartbeat: RX time=3615 ms CAN-ID 0x701 DLC 1 DATA 05 | Node-ID 0x01 HEARTBEAT OPERATIONAL | period=1001 ms
这些是电机在真实 CAN 总线上发送、ESP32-C3 实际接收到的数据。由它们可以确认:
CAN-ID 连续为 0x701 当前发送节点的 Node-ID 与 0x01 相符
DATA 连续为 05 节点报告 NMT Operational
周期约 1000 ms 电机 Heartbeat 已启用且持续到达
本次验证期间没有重新给电机上电,因此没有把 0x701 [1] 00 记录为实测结果。要完成 Boot-up 观察,在程序已经显示 TWAI ready 后重新给电机上电。若捕获成功,程序会打印类似:
RX time=... ms CAN-ID 0x701 DLC 1 DATA 00 | Node-ID 0x01 BOOT-UP
Boot-up means the node finished CANopen initialization
这里的省略号只是日志时间会随启动顺序改变;CAN-ID、DLC 和 DATA 必须来自实际收到的报文。
看不到预期报文时怎样排查
一直没有 0x701
先确认第 04 课的接线和终端电阻,再核对:
ESP32-C3 TX/RX GPIO19 / GPIO18
CAN bitrate 500000 bit/s
电机 Node-ID 0x01
CANH/CANL 没有接反
两端设备 已供电并共地
若能收到其他 CAN-ID,但没有 0x701,可能是电机 Node-ID、Heartbeat 配置或设备当前设置与教程记录不同。先保留完整抓包,不要立刻发送复位命令试错。
只有 05,没有 00
这通常表示监听程序启动时,电机已经完成启动,Boot-up 已经发完。先启动 ESP32 监听,再重新给电机上电。
周期不是刚好 1000 ms
1000 ms 附近的小幅偏差正常。若周期突然变成数秒、持续缺帧或出现超时,再检查总线负载、供电、接线和错误计数。
收到 7F 或 04
这不表示 CAN 报文格式错误。7F 是 NMT Pre-operational,04 是 NMT Stopped。先按状态含义记录现象,不要为了追求 05 而盲目控制电机。
这一课学会了什么
回到开头的实测报文:
0x701 [1] 05
现在可以完整解释为:Node-ID 0x01 的 CANopen 节点正在周期发送 Heartbeat,并报告自己的 NMT 状态为 Operational。
再看到:
0x701 [1] 00
应该理解为同一节点刚完成一次 CANopen 初始化并发送 Boot-up,而不是“状态 00 的普通周期心跳”。
最后记住两种状态不能混为一谈:
NMT Operational CANopen 通信节点处于正常通信状态
电机使能/运动 要由 CiA 402 状态和控制过程另行判断
Heartbeat 只能报告节点是否在线和当前 NMT 状态。如果还想读取设备类型、错误状态或实际位置,就要先学会 CANopen 怎样给这些参数编号。下一课将从“怎样找到设备类型”开始,解释对象字典、参数地址和多字节数值的排列方式。
资料链接
CAN in Automation:Error control protocols:
该页面说明 Boot-up、Heartbeat 和对应的错误控制用途。
CANopenNode:NMT and Heartbeat:
该页面列出了 NMT 状态值、NMT 控制命令和 Heartbeat 处理接口。
CANopenNode:Default CAN identifiers:
该页面可以核对 NMT、SDO、PDO 和 Heartbeat 的默认 CAN-ID 基础编号。