06. 用 ESP32-C3 接收第一帧 CAN 报文
前面已经知道一帧 CAN 报文里有 CAN-ID、DLC 和 DATA,也知道发送方需要其他节点在 ACK 位确认这一帧。现在把这些知识变成一个真正运行在 ESP32-C3 上的程序。
这一课不直接打开完整代码照着分析。我们会创建一个空的 ESP-IDF 工程,从 app_main() 开始,一段一段写出接收程序。程序运行后,电机在 CAN 总线上发送报文,ESP32-C3 负责接收,并把实际收到的 CAN-ID、DLC 和 DATA 输出到串口。本教程设备实测结果为:
RX CAN-ID 0x701 DLC 1 DATA 05
本课只接收和打印 CAN 报文:
- 不使用 CANopenNode;
- 不发送 SDO;
- 不写电机对象;
- 不发送运动命令。
程序工作在 CAN 正常模式,所以 TWAI 硬件会对正确收到的报文自动发送 ACK。这个 ACK 是 CAN 控制器完成的总线动作,不是电机控制命令。
上电前再检查一次
第 04 课已经完成接线和电阻检查。现在确认:
底板 J3 H -> 电机 CANH
底板 J3 L -> 电机 CANL
底板 GND -> 电机 GND
底板与电机 -> 12V 供电
CANH 与 CANL -> 断电测量接近 60Ω
ESP32-C3 与底板上 TJA1050 之间使用:
TWAI TX -> GPIO19
TWAI RX -> GPIO18
GPIO19 和 GPIO18 传输的是芯片内部 CAN 控制器与 TJA1050 之间的数字信号。连接电机的仍然是 CANH、CANL。接线不确定时,先回到第 04 课,不要靠试接排查。
写代码前先弄清 TWAI 是什么
前几课学习的是 CAN。现在打开 ESP-IDF 文档和头文件,却会看到大量以 twai_ 开头的名字:
esp_twai.h
twai_frame_t
twai_new_node_onchip()
twai_node_enable()
这里并没有换成另一种通信协议。
TWAI 的英文全称是 Two-Wire Automotive Interface,ESP-IDF 中文文档称为“双线汽车接口”。它是 Espressif 在芯片和软件中使用的名称。对本教程使用的 ESP32-C3 来说,可以先记住:
ESP32-C3 里的 TWAI 控制器
就是我们用来收发 Classical CAN 报文的片内 CAN 控制器
所以,从通用知识切换到 ESP32-C3 代码时,会出现下面的名称对应关系:
| 通用说法 | ESP32-C3 和 ESP-IDF 中看到的名称 |
|---|---|
| CAN 控制器 | TWAI 控制器 |
| CAN 驱动 | TWAI 驱动 |
| 一帧 CAN 报文 | twai_frame_t |
| 创建并启动 CAN 控制器 | 调用 twai_new_node_onchip() 和 twai_node_enable() |
这只是实现名称发生了变化,CAN-ID、DLC、DATA、仲裁、ACK 等规则并没有改变。
TWAI 在硬件的哪个位置
把通信路径拆开看:
程序
|
| 调用 ESP-IDF TWAI 驱动
v
ESP32-C3 内部 TWAI 控制器
|
| TX/RX 数字信号,GPIO19/GPIO18
v
TJA1050 CAN 收发器
|
| CANH/CANL 差分电气信号
v
电机
TWAI 控制器负责:
- 组成和识别 CAN 帧;
- 处理 CAN-ID 仲裁;
- 检查 CRC;
- 自动发送 ACK;
- 检测并统计总线错误。
TJA1050 负责:
- 把控制器的 TX 数字信号转换成 CANH、CANL 电压;
- 把 CANH、CANL 上的电压变化转换成 RX 数字信号。
因此,TWAI 控制器不能代替 TJA1050,TJA1050 也不会解析 CAN-ID 或 DATA。一个负责报文规则,一个负责总线电气信号。
ESP32-C3 的 TWAI 控制器只支持 Classical CAN,不支持 CAN FD。本教程中的 IG35EC020 同样使用 Classical CAN,所以二者可以在 500 kbit/s 下通信。
后面看到 twai_ 开头的类型或函数时,可以先在脑中把它读成“ESP32 的 CAN 控制器类型或函数”。
先分清两个工程目录
建议把自己敲代码的工程放在:
$HOME/esp/can-course-work/lab_06_twai_receive
课程提供的完整参考工程位于:
$HOME/esp/can-canopen-course/examples/lab_06_twai_receive
前一个是你的练习工程,后一个用于最后核对。课程仓库不在 $HOME/esp/can-canopen-course 时,把参考工程路径换成自己的实际路径。
不要直接修改参考工程。自己从空工程写一遍,遇到编译错误时才会知道头文件、类型、函数和组件依赖分别起什么作用。
第一步:加载 ESP-IDF 环境
打开一个新终端,执行:
# 在当前终端加载 ESP-IDF 环境;新开终端后需要重新执行。
source "$HOME/esp/esp-idf-v5.5.4/export.sh"
idf.py --version
echo "$IDF_PATH"
source 会在当前终端中设置工具链、Python 环境和 IDF_PATH。这些设置只属于当前终端,新开终端后需要重新执行。
版本应为:
ESP-IDF v5.5.4
IDF_PATH 应指向你实际安装的 esp-idf-v5.5.4。如果安装位置不同,修改上面的路径,不要照抄其他人的用户目录。
第二步:创建空工程
先建立存放练习的目录:
# 创建课程工作目录并进入该目录。
mkdir -p "$HOME/esp/can-course-work"
cd "$HOME/esp/can-course-work"
确认同名工程还不存在:
# 先确认目标路径不存在,避免覆盖之前的工程或源码。
test -e lab_06_twai_receive && echo "目录已存在,先不要覆盖" || echo "可以创建"
看到“可以创建”后执行:
# 创建一个全新的练习工程,并进入工程目录核对生成结果。
idf.py create-project -p "$HOME/esp/can-course-work/lab_06_twai_receive" lab_06_twai_receive
cd "$HOME/esp/can-course-work/lab_06_twai_receive"
pwd
find . -maxdepth 2 -type f | sort
create-project 生成 ESP-IDF 的基本工程结构。-p 后面是新工程的完整路径,最后一个 lab_06_twai_receive 是项目名称。
此时主要文件应类似:
./CMakeLists.txt
./main/CMakeLists.txt
./main/lab_06_twai_receive.c
它们的分工是:
| 文件 | 作用 |
|---|---|
顶层 CMakeLists.txt | 告诉 ESP-IDF 这是一个什么项目 |
main/CMakeLists.txt | 告诉构建系统要编译哪个 C 文件、依赖哪些组件 |
main/lab_06_twai_receive.c | 存放我们要写的程序 |
先不要创建 build/,它会在第一次构建时自动生成。
第三步:补上工程配置
声明 main 组件依赖
打开 main/CMakeLists.txt。可以使用自己熟悉的编辑器;习惯在终端中编辑时可以执行:
# 用终端编辑器打开本步要修改的文件。
nano main/CMakeLists.txt
把内容改为:
# 只编译本课的接收程序;REQUIRES 让编译器能够找到 TWAI 与 GPIO 驱动头文件。
idf_component_register(
SRCS "lab_06_twai_receive.c"
INCLUDE_DIRS "."
REQUIRES esp_driver_twai esp_driver_gpio
)
这里不是 C 代码,而是构建规则:
SRCS指定要编译的源文件;INCLUDE_DIRS "."表示当前目录可以放头文件;REQUIRES声明程序要使用 TWAI 驱动和 GPIO 驱动组件。
如果忘记 esp_driver_twai,后面可能出现找不到 TWAI 头文件或链接符号的错误。把依赖写在这里,工程需要什么会一目了然。
固定开发板 Flash 容量
在工程顶层新建 sdkconfig.defaults:
# 用终端编辑器打开本步要修改的文件。
nano sdkconfig.defaults
写入:
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
这是本教程底板实际使用的 4 MB Flash。它可以避免固件头仍按 2 MB 配置而产生容量不一致警告。
第四步:写出第一版可编译程序
打开源文件:
# 用终端编辑器打开本步要修改的文件。
nano main/lab_06_twai_receive.c
删除自动生成的内容,先写入下面这一版:
// 第一版程序只打印芯片、引脚和位速率,不启动 TWAI。
// 先让它独立编译,可以把“工程配置错误”和“CAN 接收逻辑错误”分开排查。
#include <inttypes.h>
#include <stdbool.h>
#include <stdio.h>
#include <string.h>
#include "driver/gpio.h"
#include "esp_chip_info.h"
#include "esp_log.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 RX_QUEUE_LENGTH 20
static const char *TAG = "twai_receive";
void app_main(void)
{
esp_chip_info_t chip_info;
esp_chip_info(&chip_info);
ESP_LOGI(TAG, "Starting CAN receive lab");
ESP_LOGI(TAG, "Chip: %s, cores: %u", CONFIG_IDF_TARGET, chip_info.cores);
ESP_LOGI(TAG, "TWAI TX GPIO%d, RX GPIO%d, bitrate %d bit/s",
CAN_TX_GPIO, CAN_RX_GPIO, CAN_BITRATE);
}
先理解这几十行,不要急着添加驱动。
#include 在做什么
C 编译器读到 esp_chip_info_t 或 ESP_LOGI() 时,必须先知道这些名字代表什么。#include 会把相应头文件中的类型声明、函数声明和宏定义提供给当前源文件。
这些头文件可以按用途看:
inttypes.h、stdbool.h、stdio.h、string.h C 标准库
driver/gpio.h GPIO 编号
esp_chip_info.h、esp_log.h 芯片信息和日志
esp_twai.h、esp_twai_onchip.h TWAI 类型和函数
freertos/FreeRTOS.h、freertos/queue.h FreeRTOS 队列
现在有些头文件还没用到。它们会在接下来的代码中逐个出现。
#define 在做什么
下面这四行给固定数值起了名字:
// 引脚、位速率和队列长度集中在这里,换硬件时不用改接收逻辑。
#define CAN_TX_GPIO GPIO_NUM_19
#define CAN_RX_GPIO GPIO_NUM_18
#define CAN_BITRATE 500000
#define RX_QUEUE_LENGTH 20
前三个值来自底板原理图和电机通信参数。以后看到 CAN_BITRATE,就知道它表示 CAN 波特率,而不是一个含义不明的数字 500000。
RX_QUEUE_LENGTH 不是 CAN 一帧能装多少字节。它表示软件队列最多暂存 20 条接收记录。
app_main() 在做什么
ESP-IDF 启动完成后会调用 app_main()。它相当于普通 C 程序中的 main(),是应用代码开始执行的位置。
esp_chip_info(&chip_info) 中的 & 表示把变量 chip_info 的地址交给函数,让函数把芯片信息填入这个变量。
现在先检查工程能不能编译:
# 先选择 ESP32-C3,再编译工程;这一步不会烧录开发板。
idf.py set-target esp32c3
idf.py build
第一次构建会生成 sdkconfig 和 build/。看到 Project build complete,说明空工程、目标芯片、组件依赖和第一段 C 代码都没有问题。
如果这里失败,不要继续堆后面的代码。错误通常离刚才修改的位置很近,例如:
- 文件名与
SRCS不一致; - 代码末尾漏了分号;
- 双引号写成了中文引号;
set-target没有使用加载好的 v5.5.4 环境。
第五步:设计一条接收记录
硬件收到 CAN 帧时,程序需要保存 CAN-ID、DLC 和 DATA。把下面代码插入四个 #define 之后、TAG 之前:
// 一条队列记录同时保存 CAN-ID、DLC 和 DATA,出队后不会再依赖驱动缓冲区。
typedef struct {
uint32_t id;
uint8_t dlc;
uint8_t data[TWAI_FRAME_MAX_LEN];
} can_rx_message_t;
struct 可以把几个相关变量装在一起。这里定义的一条 can_rx_message_t 就是一份准备交给日志任务的 CAN 接收记录:
id 保存 CAN-ID
dlc 保存数据长度
data 保存最多 8 字节 Classical CAN 数据
typedef 给整个结构体起名,因此后面可以直接写:
// 声明一条接收记录;message.id、message.dlc 和 message.data 分别访问三个字段。
can_rx_message_t message;
在 TAG 后面先加入队列句柄:
// 队列句柄由 app_main 创建,接收回调和普通任务通过它交接完整报文。
static QueueHandle_t s_rx_queue;
s_rx_queue 保存 FreeRTOS 队列的句柄。句柄可以理解为操作某个系统对象时使用的“引用”,后面创建、写入和读取的都是同一个队列。
这里的 static 表示这个变量只在当前 C 文件中使用,同时它会在程序整个运行期间一直存在,不会随着某次函数调用结束而消失。
现在把 app_main() 中最后一条日志之后、右花括号之前加入:
// 队列提前分配固定空间,中断回调只写入,不做动态内存分配。
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;
}
ESP_LOGI(TAG, "RX queue created");
xQueueCreate() 的两个参数分别是:
RX_QUEUE_LENGTH 队列能保存 20 条记录
sizeof(can_rx_message_t) 每条记录占多少字节
sizeof(...) 让编译器计算结构体大小,不需要人工数成员占用的字节。
再次执行:
# 编译当前工程,先处理出现的第一条错误。
idf.py build
这一版仍然收不到 CAN 帧,但已经有了存放接收记录的容器。逐步构建的意义是:如果现在报错,问题只可能来自刚加入的结构体、全局变量或队列代码。
第六步:写接收回调
CAN 报文随时可能到达,程序不能指望它恰好出现在某个固定延时之后。TWAI 驱动收到一帧后,会调用我们登记的接收函数。
回调需要记录队列装满时丢掉了多少帧。先在 s_rx_queue 下一行加入:
// 队列塞满时只累计丢帧数,日志留到普通任务中输出。
static volatile uint32_t s_dropped_frames;
volatile 告诉编译器,这个数可能由中断回调随时改变,每次读取都要取得当前值。
把下面整个函数放在 app_main() 前面:
// 这是 TWAI 接收中断回调。中断会打断普通任务,所以这里只做三件事:
// 从驱动取帧、复制到自己的结构体、放入队列;字符串格式化和日志留给普通任务。
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;
// rx_frame 只描述一帧,真正的 DATA 由驱动写进 rx_data。
// buffer_len 明确告诉驱动目标数组有多大,防止驱动写出数组边界。
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;
}
这段代码第一次看会比较密。按执行顺序拆开就容易一些。
1. 驱动把什么交给回调
函数的三个参数中,本课真正使用的是 handle:
// handle 指向触发本次事件的 TWAI 节点,回调用它取出刚收到的帧。
twai_node_handle_t handle
它指向触发这次接收事件的 TWAI 节点。event 和 user_ctx 是驱动预留的信息,本课暂时不用。下面两行明确告诉编译器“这是有意不用”,避免未使用参数警告:
// 当前实验不读取事件详情和用户参数,显式丢弃可避免编译器给出未使用警告。
(void)event;
(void)user_ctx;
IRAM_ATTR 表示这个回调需要适合在中断环境执行。中断会暂时打断普通任务,因此回调必须尽快完成。
2. 先准备驱动接收缓冲区
// 先准备 DATA 缓冲区,再把地址和容量交给驱动填写。
uint8_t rx_data[TWAI_FRAME_MAX_LEN];
twai_frame_t rx_frame = {
.buffer = rx_data,
.buffer_len = sizeof(rx_data),
};
rx_data 是 8 字节数组。twai_frame_t 告诉驱动把报文数据写到这个数组,并说明数组有多大。
.buffer = ... 这种写法叫指定成员初始化。它直接写出正在给结构体的哪个成员赋值,比依赖成员顺序更清楚。
3. 从驱动取出这一帧
// 从 TWAI 节点取出当前帧;失败时不能继续使用 rx_frame。
twai_node_receive_from_isr(handle, &rx_frame)
函数名中的 from_isr 表示它可以在中断服务过程里调用。返回 ESP_OK 后,rx_frame.header.id、dlc 和 rx_data 才包含有效接收结果。
这里使用一次 if,不在一个回调中反复循环读取。驱动每完成一帧接收就触发一次回调,这次只处理对应的一帧。
4. 复制成自己的记录
驱动使用的 rx_frame 和栈上数组只在当前回调中有效,所以不能把它们的地址直接留给普通任务。代码新建 message,复制 CAN-ID、DLC 和 DATA:
// 把驱动帧的 CAN-ID 和 DLC 复制到自有记录,避免回调结束后继续引用临时缓冲区。
can_rx_message_t message = {
.id = rx_frame.header.id,
.dlc = (uint8_t)rx_frame.header.dlc,
};
memcpy() 复制数据字节。复制前再次限制 data_len 不超过目标数组,避免越界写入。
5. 把记录交给普通任务
// 回调运行在中断环境,只能用 FromISR 版本把记录交给普通任务。
xQueueSendFromISR(s_rx_queue, &message, &high_task_woken)
FreeRTOS 队列会复制整条 message,因此回调结束后,队列里的内容仍然有效。如果队列已满,函数不会在中断中等待,而是返回失败并增加 s_dropped_frames。
回调里没有 printf() 或 ESP_LOGI()。串口输出可能很慢,把它放进中断会妨碍下一帧接收。
第七步:写报文打印函数
回调已经能把二进制数据放入队列,接下来需要把每个字节转成便于阅读的两位十六进制文本。
把下面函数放在 twai_rx_callback() 后、app_main() 前:
// 这段函数运行在普通任务中,可以安全地格式化字符串并输出串口日志。
static void print_can_frame(const can_rx_message_t *message)
{
// 每个字节最多占用“空格 + 两位十六进制”3 个字符,末尾再预留字符串结束符 \0。
char data_text[TWAI_FRAME_MAX_LEN * 3 + 1] = {0};
size_t used = 0;
// 同时检查报文 DLC 和本地数组上限。即使 DLC 异常,也不会访问 data[8] 之后的内存。
for (uint8_t i = 0; i < message->dlc && i < TWAI_FRAME_MAX_LEN; i++) {
// 第一个字节前不加空格,后续字节前补一个空格,最终得到类似“01 02 A0”的文本。
int written = snprintf(data_text + used, sizeof(data_text) - used,
"%s%02X", i == 0 ? "" : " ", message->data[i]);
// snprintf() 返回负数表示格式化失败;返回值超过剩余空间表示内容被截断。
if (written < 0 || (size_t)written >= sizeof(data_text) - used) {
break;
}
used += (size_t)written;
}
// DLC 为 0 时显式打印 <empty>,避免读者误以为串口漏掉了 DATA。
const char *payload = message->dlc == 0 ? "<empty>" : data_text;
ESP_LOGI(TAG, "RX CAN-ID 0x%03" PRIX32 " DLC %u DATA %s",
message->id, message->dlc, payload);
}
message 是结构体指针,所以访问成员时使用 message->id。它等价于“找到这个指针指向的结构体,再取出 id”。
每个数据字节使用 %02X 输出:
X:使用大写十六进制;2:至少显示两位;0:不足两位时在左边补零。
因此数值 5 会打印为 05,不会与十进制的 5 混淆。
CAN-ID 使用:
// 标准 CAN-ID 固定显示为三位十六进制,便于直接和抓包结果对照。
0x%03" PRIX32
%03 让标准 CAN-ID 至少显示三位,例如 0x001、0x123、0x701。PRIX32 来自 inttypes.h,用于按 uint32_t 的正确格式打印十六进制数。
第八步:把 TWAI 控制器接进 app_main
现在队列、回调和打印函数都准备好了。把现有的 app_main() 整个替换为下面版本:
// app_main() 运行在普通 FreeRTOS 任务中:它负责初始化硬件、等待队列并打印报文。
// 中断回调只负责“生产”报文,app_main() 负责“消费”报文,两者通过 s_rx_queue 解耦。
void app_main(void)
{
esp_chip_info_t chip_info;
esp_chip_info(&chip_info);
ESP_LOGI(TAG, "Starting CAN receive lab");
ESP_LOGI(TAG, "Chip: %s, cores: %u", CONFIG_IDF_TARGET, chip_info.cores);
ESP_LOGI(TAG, "TWAI TX GPIO%d, RX GPIO%d, bitrate %d bit/s",
CAN_TX_GPIO, CAN_RX_GPIO, CAN_BITRATE);
ESP_LOGW(TAG, "Receive-only application: no CAN data frames or motor commands are sent");
// 队列包含 RX_QUEUE_LENGTH 个槽位,每个槽位保存一份完整 can_rx_message_t。
// xQueueSendFromISR() 会复制结构体内容,因此回调结束后队列中的数据仍然有效。
s_rx_queue = xQueueCreate(RX_QUEUE_LENGTH, sizeof(can_rx_message_t));
if (s_rx_queue == NULL) {
// 队列创建失败通常表示内存不足。此时不能启动 TWAI,否则回调没有地方存放报文。
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,
};
// node 是驱动返回的控制器句柄。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,
};
// 过滤器编号 0 接收全部标准 Classical CAN 数据帧,便于第一次上板观察真实总线流量。
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 standard Classical CAN frames");
ESP_LOGI(TAG, "Power-cycle the motor if you want to capture its first startup frame");
// 保存上一次已经报告的丢帧数,只有计数变化时才打印,避免重复刷屏。
uint32_t last_drop_count = 0;
while (true) {
can_rx_message_t message;
// portMAX_DELAY 让任务睡眠到队列真正有数据,不用在 while 中空转占用 CPU。
if (xQueueReceive(s_rx_queue, &message, portMAX_DELAY) == pdTRUE) {
print_can_frame(&message);
}
// 中断中不打印日志;这里回到普通任务后再报告队列满造成的累计丢帧数。
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;
}
}
}
接下来分四段看 app_main() 新增了什么。
1. 告诉驱动硬件怎样连接
// 节点配置同时说明收发引脚、仲裁位速率和工作模式。
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-IDF:
TX GPIO19
RX GPIO18
bitrate 500000 bit/s
quanta_clk_out 未连接可选时钟输出
bus_off_indicator 未连接可选状态输出
接收内容 只接收携带 DATA 的普通数据帧
GPIO_NUM_NC 中的 NC 表示 Not Connected。
本课要观察的是 CAN-ID、DLC 和 DATA,因此设置 .flags.no_receive_rtr = true,让驱动忽略不携带 DATA 的请求帧。这样回调收到的都是当前实验真正要处理的数据帧,不需要为了暂时用不到的帧类型增加额外分支。
应用程序没有调用 twai_node_transmit()。但是正常模式下驱动需要发送队列至少有一个位置,因此配置为 tx_queue_depth = 1。它不会凭空产生 CAN 数据帧。
下面两行创建节点:
// 创建一个片上 TWAI 节点,并保存后续注册回调和启停所需的句柄。
twai_node_handle_t node = NULL;
ESP_ERROR_CHECK(twai_new_node_onchip(&node_config, &node));
node 是后续操作这个 TWAI 控制器的句柄。ESP_ERROR_CHECK() 检查函数返回值;如果创建失败,ESP-IDF 会打印错误并停止,而不是继续使用无效句柄。
2. 为什么使用正常模式
虽然应用程序只读取报文,这里仍然没有开启 listen-only。
当前总线通常只有 ESP32-C3 和电机两个节点。电机发送一帧后,需要另一个节点在 ACK 位确认正确接收。如果 ESP32-C3 进入 listen-only,它只能观察,不能写 ACK,电机就可能不断重发并累计错误。
正常模式下:
- 本程序不主动发送数据帧;
- TWAI 硬件会自动 ACK 正确收到的帧;
- 自动 ACK 不需要调用发送函数;
- ACK 只确认 CAN 帧被收到,不代表执行了电机命令。
3. 第一次实验先不过滤 CAN-ID
// 第一次实验接收全部标准帧,先不要用过滤器掩盖接线或位速率问题。
twai_mask_filter_config_t filter = {
.id = 0,
.mask = 0,
.is_ext = false,
.no_classic = false,
.no_fd = true,
};
id = 0、mask = 0 表示标准 CAN-ID 的各个位都不参加筛选,也就是接收所有标准 ID。
is_ext = false 选择 11 位标准帧;no_fd = true 排除 CAN FD。本实验使用 ESP32-C3 TWAI 和 Classical CAN 电机,因此不会把 CAN FD 当成目标格式。
第一次接收时先观察总线上到底有什么。等后续知道自己只关心哪些 CANopen 报文,再缩小过滤范围。
4. 注册回调、启动节点、等待队列
// 把接收完成事件绑定到回调,注册成功后再启动 TWAI 节点。
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));
必须先把 twai_rx_callback 注册给驱动,再启动 TWAI 节点。节点启动后,报文可能随时到达。
主循环使用:
// 任务阻塞等待下一帧,不用空转轮询占用 CPU。
xQueueReceive(s_rx_queue, &message, portMAX_DELAY)
没有报文时,portMAX_DELAY 让当前任务一直睡眠等待,不需要每隔几毫秒检查一次。收到记录后,任务被唤醒并调用 print_can_frame()。这样形成了完整路径:

CANH/CANL
-> TJA1050
-> ESP32-C3 TWAI 控制器
-> 中断接收回调
-> FreeRTOS 队列
-> app_main 普通任务
-> 串口日志
第九步:核对自己拼出的文件
先检查完整源文件是否按下面顺序排列:
1. #include
2. 四个 #define
3. can_rx_message_t 结构体
4. TAG、s_rx_queue、s_dropped_frames
5. twai_rx_callback()
6. print_can_frame()
7. app_main()
编译器不在乎第 5、6 两个函数谁先写,但它们都应放在 app_main() 前面,这样 app_main() 调用时已经看过函数定义。
课程参考工程在本机默认位置时,可以执行:
# 逐文件比较练习代码与参考实现,先处理第一处差异。
diff -u \
"$HOME/esp/can-canopen-course/examples/lab_06_twai_receive/main/lab_06_twai_receive.c" \
main/lab_06_twai_receive.c
diff -u 会逐行比较两个文件:
- 没有任何输出:两份源码相同;
- 以
-开头:参考文件中有、你的文件中没有; - 以
+开头:你的文件中有、参考文件中没有。
参考工程不在默认路径时,只替换第一条路径。不要为了消除差异盲目覆盖自己的文件,先看差异是不是缩进、注释,还是确实漏了一行代码。
还可以分别核对构建文件:
# 逐文件比较练习代码与参考实现,先处理第一处差异。
diff -u \
"$HOME/esp/can-canopen-course/examples/lab_06_twai_receive/main/CMakeLists.txt" \
main/CMakeLists.txt
diff -u \
"$HOME/esp/can-canopen-course/examples/lab_06_twai_receive/sdkconfig.defaults" \
sdkconfig.defaults
第十步:完成构建
确认仍在自己的练习工程:
# 先打印当前位置,确认这些命令是在本课练习工程中执行。
pwd
路径末尾应是:
can-course-work/lab_06_twai_receive
然后执行:
# 编译当前工程,先处理出现的第一条错误。
idf.py build
参考工程已使用 ESP-IDF v5.5.4 为 ESP32-C3 实际构建通过。成功时末尾会出现类似:
lab_06_twai_receive.bin binary size 0x31d00 bytes.
Project build complete. To flash, run:
idf.py flash
固件大小可能随代码和补丁版本略有变化,判断成功的关键是 Project build complete。
如果构建失败,先看终端中出现的第一条 error:,不要只看最后一行 ninja failed。常见情况包括:
| 第一条错误 | 优先检查 |
|---|---|
No such file or directory 指向 TWAI 头文件 | main/CMakeLists.txt 的 REQUIRES |
unknown type name | 对应 #include 是否遗漏,类型名是否拼错 |
expected ';' | 报错行和上一行是否漏分号 |
implicit declaration of function | 函数名、头文件、函数定义顺序 |
undeclared | 变量是否定义在当前函数可见的位置 |
修改后再次运行 idf.py build 即可,通常不需要删除 build/。
第十一步:烧录并打开串口监视器
确认 ESP32-C3 已经通过 USB 连接电脑,并且终端仍位于自己的实验工程目录。通常不需要先查 /dev/ttyACM0 或 /dev/ttyUSB0,直接执行:
# 烧录固件并打开串口监视器;按 Ctrl+] 退出监视器。
idf.py flash monitor
这条命令依次完成两件事:
flash 自动寻找可用串口,把 build/ 中的固件烧录到 ESP32-C3
monitor 烧录完成后继续占用当前终端,显示 ESP32-C3 发出的串口日志
烧录结束后不需要再打开其他串口软件。当前终端会进入 ESP-IDF Monitor,并出现类似提示:
--- idf_monitor on /dev/ttyACM0 115200 ---
--- Quit: Ctrl+] | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H ---
随后显示启动信息和程序中的 ESP_LOGI()、ESP_LOGW() 日志。这里的 /dev/ttyACM0 是工具实际找到的设备,不要求每台电脑都相同。
固件已经烧录,只想重新看串口
执行:
# 只重新打开串口监视器,不会再次烧录固件。
idf.py monitor
它不会重新编译或烧录,只会自动寻找串口并打开监视器。
如果监视器打开后程序已经运行了一段时间,看不到最前面的启动日志,可以依次按:
Ctrl+T
Ctrl+R
这是先按 Ctrl+T 进入监视器快捷键菜单,再按 Ctrl+R 复位开发板,不是同时按三个键。复位后就能从芯片启动信息开始重新观察日志。
常用操作还有:
Ctrl+] 退出串口监视器
Ctrl+T,Ctrl+H 显示监视器快捷键帮助
什么时候才需要手工指定串口
出现下面情况时,再使用 -p:
- 电脑同时连接了多块开发板;
- 还连接了其他 USB 串口设备;
- 自动识别提示找不到串口或无法确定使用哪一个。
先查看当前串口:
# 列出实际生成的文件,名称和层级应与正文给出的结构一致。
ls /dev/ttyACM* /dev/ttyUSB* 2>/dev/null
假设确定 ESP32-C3 是 /dev/ttyACM0,再执行:
# 烧录固件并打开串口监视器;按 Ctrl+] 退出监视器。
idf.py -p /dev/ttyACM0 flash monitor
因此 -p 是需要时才用的选择,不是每次烧录都必须填写。
如果提示没有串口权限,检查当前用户是否属于系统使用的串口用户组。不要直接使用 sudo idf.py,否则 root 用户与当前用户的 ESP-IDF 环境和生成文件可能混在一起。
第十二步:先判断程序运行到哪里
启动后应先看到:
I (...) twai_receive: Starting CAN receive lab
I (...) twai_receive: Chip: esp32c3, cores: 1
I (...) twai_receive: TWAI TX GPIO19, RX GPIO18, bitrate 500000 bit/s
W (...) twai_receive: Receive-only application: no CAN data frames or motor commands are sent
I (...) twai_receive: TWAI ready; waiting for standard Classical CAN frames
I (...) twai_receive: Power-cycle the motor if you want to capture its first startup frame
这些日志证明:
- 固件已经进入
app_main(); - 当前目标芯片是 ESP32-C3;
- 程序采用 GPIO19、GPIO18 和 500 kbit/s;
- 队列、TWAI 节点、过滤器和回调均已成功配置;
- TWAI 节点已经启动。
它们还不能证明 CANH、CANL 接线正确,也不能证明电机正在发送。
本教程设备上的实际运行结果
参考工程完成编译后,执行了没有指定端口的命令:
# 烧录固件并打开串口监视器;按 Ctrl+] 退出监视器。
idf.py flash monitor
ESP-IDF 自动找到 /dev/ttyUSB0,识别到 ESP32-C3,完成烧录和写入校验。随后串口出现:
I (278) twai_receive: Starting CAN receive lab
I (278) twai_receive: Chip: esp32c3, cores: 1
I (278) twai_receive: TWAI TX GPIO19, RX GPIO18, bitrate 500000 bit/s
W (278) twai_receive: Receive-only application: no CAN data frames or motor commands are sent
I (288) twai_receive: TWAI ready; waiting for standard Classical CAN frames
I (298) twai_receive: Power-cycle the motor if you want to capture its first startup frame
I (308) twai_receive: RX CAN-ID 0x701 DLC 1 DATA 05
I (1178) twai_receive: RX CAN-ID 0x701 DLC 1 DATA 05
I (2178) twai_receive: RX CAN-ID 0x701 DLC 1 DATA 05
I (3178) twai_receive: RX CAN-ID 0x701 DLC 1 DATA 05
前三条接收日志的时间分别约为 1178 ms、2178 ms、3178 ms,相邻间隔约 1000 ms。这次实测说明当前硬件上已经形成下面这条真实链路:
电机发送 CAN 报文
-> CANH/CANL
-> TJA1050
-> GPIO18
-> ESP32-C3 TWAI
-> 接收回调和 FreeRTOS 队列
-> 串口打印
编译成功只能证明程序可以生成固件;这里连续出现的 RX CAN-ID 才是接收 Demo 已经在真实 CAN 总线上工作的证据。
第十三步:为什么电机发来的是 0x701
电机正在发送周期报文时,会看到类似:
I (...) twai_receive: RX CAN-ID 0x701 DLC 1 DATA 05
先只用已经学过的 CAN 知识拆解:
CAN-ID = 0x701
DLC = 1
DATA = 05
这是一帧 11 位标准 Classical CAN 数据帧,携带 1 字节数据。但新的问题马上出现了:为什么是 0x701,而不是 0x123 或其他编号?
这个编号不是接收程序设置的
回头检查本课源文件,会发现程序只设置了接收全部标准 CAN-ID 的过滤器:
// 第一次实验接收全部标准帧,先不要用过滤器掩盖接线或位速率问题。
twai_mask_filter_config_t filter = {
.id = 0,
.mask = 0,
.is_ext = false,
.no_classic = false,
.no_fd = true,
};
源文件中没有写 0x701。ESP32-C3 只是把总线上实际收到的 CAN-ID 打印出来,所以这个编号来自电机。
CAN 只负责传送编号,CANopen 规定编号的用途
前面学习的 CAN 规定了一帧怎样发送、怎样仲裁和怎样校验,但它没有规定 0x701 必须表示什么。设备使用的上层协议会进一步约定各个 CAN-ID 的用途。
IG35EC020 使用的上层协议是 CANopen。厂家手册第 8 页同时给出了两个关键参数:

图源:IG35EC硬件手册.pdf,PDF 第 8 页。
从图中可以读出:
CANopen 波特率:出厂默认 500 kbit/s
节点地址:出厂默认 1
节点地址也叫 Node-ID。当前电机的默认 Node-ID 是十进制 1,写成十六进制仍然是 0x01。
用 Node-ID 算出 0x701
CANopen 为节点的 Boot-up 和 Heartbeat 报文规定了下面的 CAN-ID 计算方式:
CAN-ID = 0x700 + Node-ID
把当前电机的 Node-ID 0x01 代入:
0x700
+ 0x001
-------
0x701
所以串口收到 0x701 并不是巧合:
0x700 CANopen 给 Boot-up 和 Heartbeat 使用的基础编号
0x01 当前电机的 Node-ID
0x701 Node-ID 为 1 的电机发出的 Boot-up/Heartbeat CAN-ID
多个节点不是都使用 0x701
0x701 已经是一次计算的结果,不能理解成“所有设备都要加上 0x701”。每个节点发送 Boot-up 或 Heartbeat 时,都用自己的 Node-ID 与基础编号 0x700 相加:
| 设备的 Node-ID | Boot-up/Heartbeat CAN-ID | 计算过程 |
|---|---|---|
1(0x01) | 0x701 | 0x700 + 0x01 |
2(0x02) | 0x702 | 0x700 + 0x02 |
3(0x03) | 0x703 | 0x700 + 0x03 |
32(0x20) | 0x720 | 0x700 + 0x20 |
假设同一条 CANopen 总线上有三个节点:
电机 A Node-ID 1 Heartbeat 使用 0x701
电机 B Node-ID 2 Heartbeat 使用 0x702
ESP32-C3 Node-ID 0x20 Heartbeat 使用 0x720
这样接收方只看 CAN-ID,就能知道是哪一个节点发来的 Heartbeat。同一条 CANopen 总线上的正常节点必须使用不同的 Node-ID,否则它们可能生成相同 CAN-ID,接收方就无法区分,发送时也可能发生冲突。
还要注意,0x700 + Node-ID 只用于 Boot-up 和 Heartbeat 这一类报文。同一个节点发送其他 CANopen 报文时会使用别的基础编号,不能把该节点的所有报文都算成 0x701。
例如,后面学习 SDO 时会看到:Node-ID 为 1 的电机,其 SDO 请求使用 0x601,响应使用 0x581。这时仍然是同一台 Node-ID 为 1 的电机,只是报文用途不同,所以 CAN-ID 也不同。
如果把当前电机 Node-ID 改成 2,它的 Heartbeat CAN-ID 就会变成:
0x700 + 0x02 = 0x702
本课程序不需要跟着修改,因为它当前接收所有标准 CAN-ID;串口会直接打印新收到的 0x702。
DATA 05 又说明什么
CANopen Heartbeat 使用 1 个数据字节报告节点当前所处的 NMT 状态。本次实测报文是:
CAN-ID 0x701
DLC 1
DATA 05
其中 05 表示电机当前处于 Operational 状态,可以先理解为“节点已经正常运行”。日志中这帧约每 1000 ms 出现一次,所以它是电机周期发送的 Heartbeat。
这里第一次接触了三个 CANopen 名称:
Node-ID 区分 CANopen 总线上的节点
Heartbeat 节点周期报告自己仍在线以及当前状态
NMT 状态 CANopen 节点当前所处的通信运行状态
这些名称现在都对应着亲眼看到的 0x701 DLC 1 DATA 05,不需要脱离日志背定义。后面的 CANopen 课程会继续解释其他 Node-ID 怎样组合出 SDO、PDO 等报文编号。
为什么重新上电可能看到 DATA 00
如果 ESP32-C3 已经进入等待状态后再给电机上电,还可能捕获:
I (...) twai_receive: RX CAN-ID 0x701 DLC 1 DATA 00
这时 CAN-ID 仍然是 0x700 + 0x01 = 0x701,但 DATA 00 表示 Boot-up:电机完成 CANopen 初始化后,用这一帧告诉总线上的其他节点“我刚刚启动完成”。
Boot-up 通常只发送一次,随后才按周期发送 Heartbeat。因此,没有看到 DATA 00 不代表失败,它可能在 TWAI 准备好之前已经发完。持续收到 0x701 DATA 05 已经证明电机的 Heartbeat 正在工作。
如果底板和电机共用一个同时断开的 12V 电源,重新上电也可能让 ESP32-C3 一起复位,更容易错过启动瞬间。不要为了抓一帧日志随意改动供电线路。
一条 RX 日志能证明什么
只看到:
TWAI ready; waiting for standard Classical CAN frames
只能证明驱动初始化成功。
进一步看到:
RX CAN-ID 0x701 DLC 1 DATA 05
说明:
- GPIO 和 TJA1050 的接收路径能够工作;
- CANH、CANL 传来了一帧通过格式和 CRC 检查的报文;
- 当前 500 kbit/s 配置与发送节点兼容;
- 中断回调成功取出报文;
- FreeRTOS 队列把报文交给了普通任务;
- 打印函数正确显示了 CAN-ID、DLC 和 DATA。
由于 ESP32-C3 工作在正常模式,它也会自动 ACK 正确接收的帧。但这里仍然没有向电机发送任何控制数据。
一直没有 RX 日志怎么办
先找有没有 TWAI ready。
没有这句话:从它前面的第一条初始化错误开始检查程序。
有 TWAI ready,但一直没有 RX CAN-ID:按下面顺序排查硬件和参数。
- 电机 12V 是否上电,指示灯是否亮起;
- CANH 是否接 CANH,CANL 是否接 CANL;
- 底板和电机是否共地;
- 全部断电后,CANH 与 CANL 是否接近 60Ω;
- 电机波特率是否仍为 500 kbit/s;
- TJA1050 的 S 脚是否处于正常高速模式;
- GPIO19、GPIO18 是否与当前底板原理图一致。
一次只检查或修改一项。否则突然恢复通信时,很难知道真正的问题在哪里。
出现下面日志:
RX software queue full; dropped frames: ...
表示 CAN 帧到达速度超过串口打印速度,20 条记录的队列已经装满。只有电机低频周期报文时通常不会发生;高负载总线的处理放到后面的错误与性能章节。
最后确认没有发送代码
在自己的工程中执行:
# 只搜索关键调用,快速确认代码中是否包含对应操作。
grep -nE "twai_node_transmit|0x6040|0x607A" main/lab_06_twai_receive.c
正常情况没有任何匹配。这说明代码中没有:
- 调用 TWAI 数据帧发送函数;
- 写入 CiA 402 Controlword;
- 写入目标位置。
本课只使用 ESP-IDF 原生 TWAI 接收功能,还没有进入 CANopen 协议栈。
官方资料
ESP-IDF v5.5.4 ESP32-C3 TWAI 编程指南:
ESP-IDF v5.5.4:ESP32-C3 TWAI 编程指南
本课用到的 on-chip node API 包括:
twai_new_node_onchip()
twai_node_config_mask_filter()
twai_node_register_event_callbacks()
twai_node_receive_from_isr()
twai_node_enable()
完成这一课后,你不只是“运行过一个 CAN Demo”,而是已经亲手写出了从 TWAI 中断接收、经过 FreeRTOS 队列、最后打印 CAN 帧的完整数据路径。
下一课会从实测的 0x701 DLC 1 DATA 05 出发,说明 CANopen 在 CAN 之上增加了哪些共同规则,以及 Node-ID、CAN-ID 和 COB-ID 分别表示什么。