ESP32 实战教程:用 TWAI 监听 CAN 总线并解析数据帧
本文由 AI 协助整理。ESP32 的 TWAI 是经典 CAN 控制器能力,不等于 CAN-FD;不同 ESP32 芯片和 Arduino-ESP32/ESP-IDF 版本支持情况不同。汽车和工业总线可能涉及安全关键系统,本文只做低风险监听和实验,不建议未经验证发送控制帧。

TWAI、CAN 和收发器的关系
CAN 总线使用差分线 CANH、CANL。ESP32 内部的 TWAI 控制器负责帧格式、仲裁、CRC 和错误处理,但 GPIO 输出的是逻辑信号,不能直接接到 CANH/CANL。中间必须有 CAN 收发器,例如 3.3V 兼容的 SN65HVD230 一类芯片。
可以把它们分成三层:
- ESP32 TWAI 控制器:负责 CAN 帧逻辑。
- CAN 收发器:把 TX/RX 逻辑转换成差分总线。
- CAN 网络:多个节点共享 CANH、CANL,线路两端配置终端。
缺少收发器,程序即使编译成功,也只是对着空气发送逻辑电平。收发器供电和逻辑电平还要与开发板匹配,不能因为两个模块都写着“CAN”就默认接口一样。
硬件准备与接线
- 经典 ESP32 开发板。
- 3.3V CAN 收发器模块。
- 另一个 CAN 节点或 CAN 分析仪,便于验证。
- 双绞线、稳定供电和必要的 120Ω 终端电阻。
示例使用 GPIO21 作为 RX、GPIO22 作为 TX:
| ESP32 | 收发器 |
|---|---|
| GPIO22 | TXD |
| GPIO21 | RXD |
| 3V3 | VCC |
| GND | GND |
| - | CANH / CANL |
GPIO 可以通过矩阵调整,但要避开启动、Flash、摄像头和其他外设占用。CANH 与 CANL 要保持对应,不能把它当成 UART 的 TX/RX 交叉线。
总线终端和只听模式
高速 CAN 总线一般在物理两端各有 120Ω 终端,断电测量两线之间常见约 60Ω。桌面上两块短线模块有时已经内置终端;如果每块板都打开终端,等效阻值会过低,通信可能变差。
第一次接入现有总线,建议使用 TWAI_MODE_LISTEN_ONLY。只听模式不参与 ACK,也不会主动影响总线,适合查看帧 ID 和数据。只有在明确知道协议和安全后,才使用正常模式发送。
Arduino 中调用 TWAI 驱动
当前 Arduino-ESP32 示例使用底层头文件:
#include <Arduino.h>
#include "driver/twai.h"
constexpr gpio_num_t CAN_TX = GPIO_NUM_22;
constexpr gpio_num_t CAN_RX = GPIO_NUM_21;
bool driverStarted = false;
不同 Arduino-ESP32 核心版本可能对应不同 ESP-IDF TWAI API。若 driver/twai.h 或旧配置结构无法编译,应先查看当前核心随附的 TWAI 示例与 ESP-IDF 版本,不要盲目把网上另一版本的函数签名混进来。
初始化为 500 kbit/s、只听、接受全部帧:
void setupTwai() {
twai_general_config_t general =
TWAI_GENERAL_CONFIG_DEFAULT(CAN_TX, CAN_RX,
TWAI_MODE_LISTEN_ONLY);
twai_timing_config_t timing = TWAI_TIMING_CONFIG_500KBITS();
twai_filter_config_t filter = TWAI_FILTER_CONFIG_ACCEPT_ALL();
if (twai_driver_install(&general, &timing, &filter) != ESP_OK) {
Serial.println("TWAI driver install failed");
return;
}
if (twai_start() != ESP_OK) {
Serial.println("TWAI driver start failed");
return;
}
uint32_t alerts = TWAI_ALERT_RX_DATA |
TWAI_ALERT_BUS_ERROR |
TWAI_ALERT_RX_QUEUE_FULL |
TWAI_ALERT_ERR_PASS;
if (twai_reconfigure_alerts(alerts, nullptr) != ESP_OK) {
Serial.println("TWAI alert setup failed");
return;
}
driverStarted = true;
Serial.println("TWAI listen-only started");
}
如果总线不是 500 kbit/s,应选择匹配的 timing 宏,例如 250 kbit/s。波特率不匹配时,最常见的结果不是“数据稍微不准”,而是大量总线错误。
打印接收到的帧
void printFrame(const twai_message_t& message) {
Serial.printf("ID=0x%03lx format=%s RTR=%d DLC=%d data=",
static_cast<unsigned long>(message.identifier),
message.extd ? "extended" : "standard",
message.rtr,
message.data_length_code);
if (!message.rtr) {
for (int i = 0; i < message.data_length_code; ++i) {
Serial.printf("%02X ", message.data[i]);
}
}
Serial.println();
}
void loop() {
if (!driverStarted) {
delay(1000);
return;
}
twai_message_t message{};
if (twai_receive(&message, pdMS_TO_TICKS(1000)) == ESP_OK) {
printFrame(message);
}
}
标准帧 ID 与扩展帧 ID 的位数不同,远端请求帧 RTR 也没有普通数据区。打印时保留格式、RTR、DLC 和原始字节,后续解析才不会因为只保存一个十六进制 ID 而失去上下文。
使用过滤器减少无关数据
总线监听时接受全部帧适合第一轮观察。确认目标 ID 后,可以把过滤器改成只接收某个标准 ID,降低 RX 队列压力。过滤器的位掩码写法和版本相关,学习阶段先用 ACCEPT_ALL,再参考当前 SDK 的过滤器示例修改,避免凭记忆写出一个“过滤了所有东西”的掩码。
应用层还可以在 printFrame() 后做白名单:
bool isInteresting(uint32_t id, bool extended) {
return !extended && (id == 0x180 || id == 0x280 || id == 0x380);
}
只解析已确认的 ID。看到一个陌生帧时,不要直接把字节 0、1 当温度,CAN 只负责运送数据,含义由上层协议决定。
从原始字节推导数值
假设协议文档明确说明:ID 0x180 的字节 0、1 是大端无符号转速,单位为 1 rpm;字节 2 是温度,原始值减去 40:
void decode180(const twai_message_t& message) {
if (message.identifier != 0x180 ||
message.data_length_code < 3 || message.extd || message.rtr) {
return;
}
uint16_t rpm = (static_cast<uint16_t>(message.data[0]) << 8) |
message.data[1];
int temperature = static_cast<int>(message.data[2]) - 40;
Serial.printf("rpm=%u temp=%d C\n", rpm, temperature);
}
没有协议文档时只能记录原始数据、时间和帧频率,不能把猜测写成结论。汽车 CAN 的同一个字节在不同车型和不同报文中可能代表完全不同的含义。
处理 TWAI 错误与队列溢出
void handleAlerts() {
uint32_t alerts = 0;
if (twai_read_alerts(&alerts, 0) != ESP_OK) return;
twai_status_info_t status{};
twai_get_status_info(&status);
if (alerts & TWAI_ALERT_BUS_ERROR) {
Serial.printf("bus errors=%lu\n",
static_cast<unsigned long>(status.bus_error_count));
}
if (alerts & TWAI_ALERT_RX_QUEUE_FULL) {
Serial.printf("rx missed=%lu overrun=%lu\n",
static_cast<unsigned long>(status.rx_missed_count),
static_cast<unsigned long>(status.rx_overrun_count));
}
if (alerts & TWAI_ALERT_ERR_PASS) {
Serial.println("controller became error passive");
}
}
如果接收队列满,说明程序处理不过来或总线流量太高。不要只把队列调大;同时减少串口打印、增加过滤器、把原始帧放入 FreeRTOS 队列,或降低业务处理频率。
发送帧时的安全边界
发送需要正常模式,并且总线上必须有其他节点对帧 ACK。单独一块板在正常模式发送,可能不断重试并进入错误状态。更重要的是,汽车或工业总线上的控制帧可能改变真实设备行为。
本教程只做监听。若学习发送,应先用两块实验板、独立供电、低风险 ID 和逻辑分析仪验证,禁止把试验帧直接发到正在运行的车辆控制网络。
分阶段验证
- 先确认收发器为 3.3V 逻辑兼容。
- 两端终端电阻只保留物理总线两端。
- 使用只听模式和已知波特率。
- 先接受全部帧,保存原始日志。
- 按 ID、格式、DLC 建立白名单。
- 用文档和已知测试值验证字节解析。
- 模拟高流量,观察 RX 队列和错误统计。
常见问题
| 现象 | 优先检查 |
|---|---|
| 完全没有帧 | 收发器供电、CANH/CANL、波特率和总线是否真的有活动 |
| 大量 bus error | 终端电阻、线序、波特率、收发器电平 |
| 只有发送没有接收 | RX/TX 引脚、收发器方向和正常/只听模式 |
| RX queue full | 串口打印太多、没有过滤、业务处理太慢 |
| 编译找不到 TWAI API | Arduino-ESP32 核心与示例版本不匹配 |
| 数值看起来乱 | ID、字节序、缩放系数和协议文档 |
总结
TWAI/CAN 项目最容易被忽略的是物理层。ESP32 需要外部收发器,CAN 总线需要正确终端和波特率,监听程序还要保留帧格式、DLC、错误状态等上下文。
先以只听模式观察原始帧,再建立 ID 白名单和协议解析,最后才考虑发送。把安全边界放在代码之前,CAN 总线才会是学习工具,而不是给真实设备制造惊喜的遥控器。
参考资料
打赏
如果这篇学习记录对你有帮助,欢迎扫码支持后续整理。

打赏支持:王庆岩
浙公网安备 33010602011771号