跳到主要内容

09. NMT、Boot-up 和 Heartbeat

学习形式编程实验
硬件要求完整实验平台
配套 Demolab_09_canopen_heartbeat
本课动作只监听 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 0x010x700 是 CANopen 规定的基础编号,不是 ESP32 或电机厂家临时选的数字。

这类报文的 DLC 固定为 1,唯一的数据字节用于表示启动完成或 NMT 状态:

DATA含义怎样理解
00Boot-up节点刚完成 CANopen 初始化
04Stopped节点处于 NMT 停止状态
05Operational节点处于 NMT 运行状态
7FPre-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]命令
01Start Remote Node,进入 Operational
02Stop Remote Node,进入 Stopped
80Enter Pre-operational
81Reset Node
82Reset 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:

CANopen 节点从启动到周期 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 帧后识别目标节点 Boot-up 和 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->dlcmessage->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() 后继续写下面代码,最后的两个右花括号分别结束 whileapp_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.txtREQUIRES

构建、烧录和监视串口

确认当前目录是自己的 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 附近的小幅偏差正常。若周期突然变成数秒、持续缺帧或出现超时,再检查总线负载、供电、接线和错误计数。

收到 7F04

这不表示 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:

CiA:Error control protocols

该页面说明 Boot-up、Heartbeat 和对应的错误控制用途。

CANopenNode:NMT and Heartbeat:

CANopenNode NMT 与 Heartbeat

该页面列出了 NMT 状态值、NMT 控制命令和 Heartbeat 处理接口。

CANopenNode:Default CAN identifiers:

CANopenNode 默认 CANopen 标识符

该页面可以核对 NMT、SDO、PDO 和 Heartbeat 的默认 CAN-ID 基础编号。