这份讲义适合从 零基础到能上手写代码。你会学到:
-
CAN 是什么,为什么工业和汽车里大量使用它
-
一帧 CAN 报文长什么样
-
仲裁、位填充、ACK、CRC、错误处理分别在干什么
-
如何在 MCU 上用 C 代码完成发送与接收
-
学完后该怎么继续进阶到 CAN FD、DBC、上位机调试
一、CAN 是什么
CAN(Controller Area Network ,控制器局域网)是一种多主机、基于消息、实时性很强的串行通信总线。
它最早用于汽车电子,现在广泛用于:
-
汽车 ECU 通信
-
工业控制
-
机器人
-
医疗设备
-
电池管理系统(BMS)
-
电机控制器
1.1 CAN 的几个关键特点
-
两根总线线缆:CAN_H 和 CAN_L
-
多节点挂载:很多设备能接在同一条总线上
-
按报文 ID 仲裁:不是按设备地址抢占,而是谁的 ID 更"优先"
-
抗干扰强:差分传输
-
可靠性高:有 CRC、ACK、错误重发、错误状态机
二、先建立整体认识:CAN 系统长什么样
一个典型节点通常由 3 部分组成:
-
MCU / CPU:运行你的应用代码
-
CAN 控制器:负责打包/解包 CAN 帧、仲裁、CRC 等
-
CAN 收发器:把逻辑信号转换成总线差分电平
2.1 系统结构图
┌──────────┐ TX/RX ┌──────────────┐ CANH/CANL ┌────────────┐
│ MCU │──────────▶│ CAN 控制器 │──────────────▶│ CAN 收发器 │
│(应用程序)│◀──────────│(协议处理) │◀──────────────│(物理层) │
└──────────┘ └──────────────┘ └─────┬──────┘
│
=============================================================│==== CAN 总线
│
┌──────────┐ ┌──────────────┐ ┌─────▼──────┐
│ MCU │──────────▶│ CAN 控制器 │──────────────▶│ CAN 收发器 │
│(另一个节点)│◀────────│(协议处理) │◀──────────────│(物理层) │
└──────────┘ └──────────────┘ └────────────┘
2.2 终端电阻
CAN 总线两端通常各接一个 120Ω 终端电阻,等效 60Ω。
120Ω 120Ω
[节点]---+------------------总线-------------------+---[节点]
| |
其他节点 其他节点
没有正确终端匹配时,波形会反射,通信会不稳定。
三、物理层:为什么 CAN 抗干扰强
CAN 使用差分信号:
-
隐性位(Recessive,逻辑 1):CAN_H 与 CAN_L 电压接近
-
显性位(Dominant,逻辑 0):CAN_H 升高,CAN_L 降低
3.1 差分示意图
隐性位(1):
CAN_H ≈ 2.5V
CAN_L ≈ 2.5V
差分电压接近 0V
显性位(0):
CAN_H ≈ 3.5V
CAN_L ≈ 1.5V
差分电压约 2V
3.2 为什么显性位能"压住"隐性位
总线上只要任何一个节点发送显性位 0 ,总线就会表现为显性位。
这就是 CAN 仲裁机制的基础。
可以把它理解为:
-
1 = 放手
-
0 = 强驱动
因此 0 会覆盖 1。
四、CAN 帧类型
常见的 CAN 帧有 4 类:
-
数据帧(Data Frame):真正传输数据
-
远程帧(Remote Frame):请求别人发送数据
-
错误帧(Error Frame):发现错误时发出
-
过载帧(Overload Frame):延迟下一帧
实际开发里最常见的是:
-
标准数据帧(11 位 ID)
-
扩展数据帧(29 位 ID)
五、最重要的内容:数据帧结构
5.1 标准帧(11 位 ID)结构
SOF | Arbitration | Control | Data | CRC | ACK | EOF
更细分:
SOF | ID(11) | RTR | IDE | r0 | DLC(4) | DATA(0~8字节) | CRC(15) | CRC Delim | ACK | ACK Delim | EOF(7)
5.2 标准帧结构图
┌───┬───────────┬────┬────┬────┬────────┬──────────────────┬────────────┬───────┬────────┐
│SOF│ ID[10:0] │RTR │IDE │ r0 │ DLC │ DATA 0~8 Byte │ CRC域 │ ACK域 │ EOF │
└───┴───────────┴────┴────┴────┴────────┴──────────────────┴────────────┴───────┴────────┘
5.3 各字段作用
SOF(Start of Frame)
帧起始,1 位显性位,告诉所有节点:一帧开始了。
ID(标识符)
不是设备地址,而是报文优先级 + 报文语义。
-
ID 越小,优先级越高
-
例如:0x100 会比 0x300 更优先发出去
RTR
-
0:数据帧
-
1:远程帧
IDE
-
0:标准帧(11 位 ID)
-
1:扩展帧(29 位 ID)
DLC(Data Length Code)
表示数据长度,标准 CAN 中一般是 0~8 字节。
DATA
真正的数据载荷。
CRC
用于检错。
ACK
接收成功的节点会在 ACK 槽中写入显性位,表示"我收到了"。
EOF
帧结束。
六、扩展帧(29 位 ID)
扩展帧把 ID 从 11 位扩展为 29 位,适合报文种类更多、网络更复杂的系统。
6.1 扩展帧示意
SOF | 11位ID | SRR | IDE | 18位扩展ID | RTR | r1 | r0 | DLC | DATA | CRC | ACK | EOF
6.2 标准帧和扩展帧对比
| 项目 | 标准帧 | 扩展帧 |
|---|---|---|
| ID 长度 | 11 位 | 29 位 |
| 最大分类能力 | 较少 | 更多 |
| 总线效率 | 更高 | 略低 |
| 常见场景 | 小中型系统 | 大型复杂系统 |
很多工业项目如果报文不多,优先用标准帧,更简单、效率更高。
七、CAN 的灵魂:仲裁机制
CAN 是多主机总线 ,多个节点都能主动发报文。
那如果两个节点同时发怎么办?
答案:按 ID 位级仲裁。
7.1 仲裁规则
-
节点一边发,一边监听总线
-
发送 1(隐性) ,但总线上读到 0(显性),说明输了
-
输掉仲裁的节点立即停止发送,等待下一次机会
-
赢的节点继续发送,且不会破坏当前帧
这叫:无损仲裁。
7.2 仲裁示例
假设两个节点同时发:
-
节点 A 发 ID =
0x100 -
节点 B 发 ID =
0x120
把关键位拿来对比:
A: 00100000000
B: 00100100000
↑
在某一位上,A 发 0,B 发 1
总线结果为 0
B 发现自己发的是 1,读到的是 0,于是退出仲裁
因此:ID 数值更小的报文优先级更高。
7.3 仲裁图示
时间 →
节点A: 0 0 1 0 0 0 ...
节点B: 0 0 1 0 1 0 ...
总线 : 0 0 1 0 0 0 ...
↑
B 在此处失败退出
八、位填充(Bit Stuffing)
为了让接收端保持同步,CAN 规定:
在 SOF 到 CRC 序列之间,如果连续出现 5 个相同位 ,发送端必须自动插入一个相反位。
8.1 例子
原始比特流:
111110
位填充后:
11111 0 0
接收端会自动去掉填充位。
8.2 为什么要这样做
因为串行通信需要时钟同步。
长时间全是 0 或全是 1,会让采样点漂移。
九、ACK、CRC、重发:CAN 为什么可靠
9.1 CRC
发送端对报文做 CRC,接收端也计算 CRC:
-
一样:认为报文完整
-
不一样:判定出错
9.2 ACK
ACK 槽默认是隐性位。
如果至少有一个节点成功接收,它会把 ACK 槽拉成显性位。
也就是说:
-
发送端发完后,看看 ACK 位有没有被别人拉低
-
没有 ACK,通常说明没人正确收到
9.3 自动重发
CAN 控制器通常支持自动重发:
-
仲裁失败 → 不是错误,稍后重发
-
CRC/ACK 等错误 → 可自动重发
十、错误类型与错误状态机
CAN 的强大之处之一在于:它不仅能发现错误,还能"自我隔离"故障节点。
10.1 常见错误类型
-
位错误(Bit Error)
-
填充错误(Stuff Error)
-
CRC 错误(CRC Error)
-
格式错误(Form Error)
-
ACK 错误(ACK Error)
10.2 节点状态
CAN 节点通常有 3 种状态:
-
Error Active:正常工作
-
Error Passive:错误变多,但还能通信
-
Bus Off:严重错误,主动退出总线
10.3 状态变化示意
Error Active --错误累计增加--> Error Passive --继续增加--> Bus Off
当节点进入 Bus Off,一般需要软件重新初始化控制器才能恢复。
十一、波特率与位时序
实际配置 CAN 时,除了"500kbps / 1Mbps"这样的总速率,还要配置位时间分段。
一个比特时间常被分成:
-
Sync Segment
-
Prop Segment
-
Phase Segment 1
-
Phase Segment 2
很多芯片还会把它们合并成更常见的配置项:
-
Prescaler
-
BS1
-
BS2
-
SJW
11.1 位时序示意
| Sync | BS1 | BS2 |
|------|--------------|---------|
^
采样点
11.2 常见波特率
-
125 kbps
-
250 kbps
-
500 kbps
-
1 Mbps
11.3 入门建议
刚开始学习时,你只要记住:
-
所有节点波特率必须一致
-
终端电阻要正确
-
线长和波特率有关:速率越高,可允许线越短
十二、报文过滤
如果总线上有很多报文,但你的节点只关心其中一部分,就可以使用过滤器(Filter)。
12.1 过滤器的作用
-
只接收感兴趣的 ID
-
减少 MCU 中断负担
-
提高系统效率
12.2 常见过滤模式
-
列表模式:只收某几个固定 ID
-
掩码模式:按位匹配一类 ID
例如:
-
收
0x100、0x101、0x102 -
或者收
0x1xx这一类
十三、一个完整的理解例子
假设有 3 个节点:
-
电机控制器:发送转速
-
电池管理器:发送电压电流
-
仪表:接收数据显示
约定:
| ID | 含义 | 发送节点 | 数据 |
|---|---|---|---|
| 0x101 | 电机转速 | 电机控制器 | 2 字节 rpm |
| 0x102 | 电池电压 | BMS | 2 字节 mV |
| 0x103 | 电池电流 | BMS | 2 字节 mA |
若电机转速报文和电池电压报文同时发:
-
0x101 < 0x102 -
所以
0x101优先发送
十四、开发中你最常碰到的几个坑
14.1 只有自己能发,别人收不到
常见原因:
-
终端电阻不对
-
收发器接线反了
-
波特率不一致
-
共地问题
14.2 能看到帧,但总报错
常见原因:
-
位时序配置不匹配
-
总线过长,波特率太高
-
干扰太强
14.3 发不出去,一直重发
常见原因:
-
总线上没有其他节点 ACK
-
节点进入错误状态
-
控制器邮箱未释放
十五、开始写代码前,你需要知道的软件抽象
不同 MCU(STM32、NXP、TI、ESP32、S32K 等)底层寄存器不同,
但从应用层看,你可以把 CAN 理解成下面几个接口:
-
can_init():初始化 -
can_send():发送一帧 -
can_receive():接收一帧 -
can_set_filter():配置过滤器
为了便于学习,下面先给你一份平台无关的 C 语言示例。
十六、C 语言数据结构设计
#include <stdint.h>
#include <string.h>
#include <stdio.h>
#define CAN_ID_STD 0
#define CAN_ID_EXT 1
#define CAN_RTR_DATA 0
#define CAN_RTR_REMOTE 1
typedef struct
{
uint32_t id; // 11位或29位ID
uint8_t id_type; // 标准帧/扩展帧
uint8_t rtr; // 数据帧/远程帧
uint8_t dlc; // 数据长度 0~8
uint8_t data[8]; // 数据区
} can_frame_t;
这个结构体表达的是"应用层眼中的一帧 CAN 数据"。
十七、平台无关的发送/接收接口
// 下面这些函数通常需要你根据具体芯片驱动去实现
int can_hw_init(uint32_t baudrate);
int can_hw_send(const can_frame_t *frame);
int can_hw_receive(can_frame_t *frame);
int can_hw_set_filter(uint32_t id, uint32_t mask, uint8_t id_type);
十八、初始化代码示例
int can_init(void)
{
uint32_t baudrate = 500000; // 500kbps
if (can_hw_init(baudrate) != 0)
{
printf("CAN init failed!\n");
return -1;
}
// 示例:接收标准ID 0x101 ~ 0x1FF
// 掩码写法依平台不同而不同,这里用概念表示
if (can_hw_set_filter(0x100, 0x700, CAN_ID_STD) != 0)
{
printf("CAN filter config failed!\n");
return -2;
}
printf("CAN init ok.\n");
return 0;
}
注:不同芯片的过滤器编码方式差异很大,上面的
id与mask是教学示意。
十九、发送一帧:C 语言示例
19.1 发送一个电机转速报文
假设:
-
报文 ID =
0x101 -
数据 2 字节表示转速 rpm
int can_send_motor_speed(uint16_t rpm)
{
can_frame_t frame;
memset(&frame, 0, sizeof(frame));frame.id = 0x101; frame.id_type = CAN_ID_STD; frame.rtr = CAN_RTR_DATA; frame.dlc = 2; frame.data[0] = (uint8_t)(rpm >> 8); frame.data[1] = (uint8_t)(rpm & 0xFF); return can_hw_send(&frame);}
19.2 调用示例
int main(void)
{
if (can_init() != 0)
return -1;
while (1)
{
uint16_t rpm = 1500;
if (can_send_motor_speed(rpm) != 0)
{
printf("send failed\n");
}
// 这里省略延时函数
}
return 0;
}
二十、接收一帧:C 语言示例
20.1 接收并解析
void can_poll_receive(void)
{
can_frame_t frame;
if (can_hw_receive(&frame) == 0)
{
printf("RX: ID=0x%lX DLC=%d\n", (unsigned long)frame.id, frame.dlc);
if (frame.id_type == CAN_ID_STD && frame.id == 0x101 && frame.dlc >= 2)
{
uint16_t rpm = ((uint16_t)frame.data[0] << 8) | frame.data[1];
printf("Motor speed = %u rpm\n", rpm);
}
}
}
20.2 主循环中轮询接收
int main(void)
{
if (can_init() != 0)
return -1;
while (1)
{
can_poll_receive();
}
return 0;
}
二十一、中断方式接收(更常见)
实际项目里,接收往往不是轮询,而是:
-
CAN 接收 FIFO 来报文
-
触发中断
-
中断中读取帧
-
放入软件队列
-
主循环再处理
21.1 典型思路图
总线来数据
│
▼
CAN FIFO
│
▼
中断服务函数 ISR
│
▼
软件环形缓冲区
│
▼
主循环 / 任务线程解析
21.2 简化示例代码
#define CAN_RX_QUEUE_SIZE 16
static can_frame_t g_can_rx_queue[CAN_RX_QUEUE_SIZE];
static volatile uint8_t g_rx_head = 0;
static volatile uint8_t g_rx_tail = 0;
static int can_queue_push(const can_frame_t *frame)
{
uint8_t next = (uint8_t)((g_rx_head + 1) % CAN_RX_QUEUE_SIZE);
if (next == g_rx_tail)
{
// 队列满
return -1;
}
g_can_rx_queue[g_rx_head] = *frame;
g_rx_head = next;
return 0;
}
static int can_queue_pop(can_frame_t *frame)
{
if (g_rx_head == g_rx_tail)
{
return -1;
}
*frame = g_can_rx_queue[g_rx_tail];
g_rx_tail = (uint8_t)((g_rx_tail + 1) % CAN_RX_QUEUE_SIZE);
return 0;
}
21.3 中断回调示意
void can_rx_isr_callback(void)
{
can_frame_t frame;
if (can_hw_receive(&frame) == 0)
{
(void)can_queue_push(&frame);
}
}
21.4 主循环处理
void app_process_can_messages(void)
{
can_frame_t frame;
while (can_queue_pop(&frame) == 0)
{
switch (frame.id)
{
case 0x101:
{
if (frame.dlc >= 2)
{
uint16_t rpm = ((uint16_t)frame.data[0] << 8) | frame.data[1];
printf("[APP] rpm = %u\n", rpm);
}
break;
}
case 0x102:
{
if (frame.dlc >= 2)
{
uint16_t mv = ((uint16_t)frame.data[0] << 8) | frame.data[1];
printf("[APP] battery voltage = %u mV\n", mv);
}
break;
}
default:
break;
}
}
}
二十二、如果你用的是 STM32,代码通常会长什么样
下面给你一个接近实际项目的 STM32 HAL 风格示例,用于帮助你把上面的抽象映射到真实平台。
注意:不同 STM32 系列(bxCAN / FDCAN)接口不同,下面更接近传统 bxCAN 风格思路。
22.1 发送示例(HAL 风格)
#include "stm32f1xx_hal.h"
extern CAN_HandleTypeDef hcan;
int stm32_can_send_std(uint16_t std_id, uint8_t *data, uint8_t len)
{
CAN_TxHeaderTypeDef tx_header;
uint32_t tx_mailbox;
tx_header.StdId = std_id;
tx_header.ExtId = 0;
tx_header.IDE = CAN_ID_STD;
tx_header.RTR = CAN_RTR_DATA;
tx_header.DLC = len;
tx_header.TransmitGlobalTime = DISABLE;
if (HAL_CAN_AddTxMessage(&hcan, &tx_header, data, &tx_mailbox) != HAL_OK)
{
return -1;
}
return 0;
}
22.2 接收回调示例
void HAL_CAN_RxFifo0MsgPendingCallback(CAN_HandleTypeDef *hcan)
{
CAN_RxHeaderTypeDef rx_header;
uint8_t data[8];
if (HAL_CAN_GetRxMessage(hcan, CAN_RX_FIFO0, &rx_header, data) != HAL_OK)
{
return;
}
if (rx_header.IDE == CAN_ID_STD && rx_header.StdId == 0x101 && rx_header.DLC >= 2)
{
uint16_t rpm = ((uint16_t)data[0] << 8) | data[1];
printf("RPM=%u\n", rpm);
}
}
22.3 初始化流程一般包含
1. 配置 GPIO(CAN TX / RX)
2. 配置 CAN 波特率与位时序
3. 配置过滤器
4. 启动 CAN 外设
5. 打开接收中断
二十三、如何设计自己的 CAN 协议
CAN 只规定"怎么传",不规定"数据含义"。
所以项目里你还需要设计自己的应用层协议。
23.1 建议先定义一张报文表
| 报文 ID | 名称 | 周期 | 发送者 | 字节定义 |
|---|---|---|---|---|
| 0x101 | MotorSpeed | 10ms | 电机控制器 | Byte0~1: rpm |
| 0x102 | BatteryVolt | 100ms | BMS | Byte0~1: mV |
| 0x103 | BatteryCurr | 100ms | BMS | Byte0~1: mA |
23.2 数据定义建议
-
明确大小端
-
明确比例因子(scale)
-
明确偏移量(offset)
-
明确物理单位
-
明确超时策略
例如:
-
Byte0~1:转速原始值
-
物理值 = raw × 1 rpm
或者:
-
Byte0~1:温度
-
物理值 = raw × 0.1 - 40 ℃
二十四、上位机抓包时你会看到什么
调试 CAN 时常用工具:
-
USB-CAN 分析仪
-
PCAN、ZLG、CANalyst、Kvaser 等
-
SocketCAN(Linux)
你通常会看到:
ID: 0x101 DLC: 2 DATA: 05 DC
把 05 DC 解析成 16 位:
0x05DC = 1500
于是可得:
转速 = 1500 rpm
二十五、学习 CAN 时最该练的 5 个能力
-
看懂一帧结构
-
看懂 ID 和优先级
-
能把物理量打包成 8 字节数据
-
能写收发代码
-
能用抓包工具定位问题
二十六、给你一个练习题
请你自己写一个报文:
-
ID =
0x120 -
数据含义:
-
Byte0~1:电压(单位 0.1V)
-
Byte2~3:电流(单位 0.01A)
-
-
假设:
-
电压 = 24.3V
-
电流 = 5.67A
-
26.1 你可以这样思考
电压原始值:
24.3 / 0.1 = 243 = 0x00F3
电流原始值:
5.67 / 0.01 = 567 = 0x0237
所以 4 个字节可以写成:
00 F3 02 37
26.2 参考代码
int can_send_power_info(void)
{
can_frame_t frame;
memset(&frame, 0, sizeof(frame));
uint16_t voltage_raw = 243;
uint16_t current_raw = 567;
frame.id = 0x120;
frame.id_type = CAN_ID_STD;
frame.rtr = CAN_RTR_DATA;
frame.dlc = 4;
frame.data[0] = (uint8_t)(voltage_raw >> 8);
frame.data[1] = (uint8_t)(voltage_raw & 0xFF);
frame.data[2] = (uint8_t)(current_raw >> 8);
frame.data[3] = (uint8_t)(current_raw & 0xFF);
return can_hw_send(&frame);
}
二十七、标准 CAN 与 CAN FD
你现在学的是经典 CAN(Classic CAN),它的特点是:
-
数据段最多 8 字节
-
速率通常到 1Mbps
而 CAN FD 是升级版:
-
数据段可超过 8 字节(最高 64 字节)
-
数据阶段速率可以更高
建议学习路线:
-
先完全吃透经典 CAN
-
再学 CAN FD
-
再接触 J1939、CANopen、UDS 等上层协议
二十八、一张总复习图
┌─────────────────────────────┐
│ CAN 总线核心 │
└─────────────┬───────────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
物理层差分传输 帧结构与ID仲裁 错误检测与恢复
(CANH / CANL) (无损仲裁、优先级) (CRC/ACK/错误状态)
│ │ │
└────────────────────┼────────────────────┘
│
▼
MCU 软件开发实现
(初始化 / 过滤 / 发送 / 接收)
二十九、学习建议:怎么学得最快
第 1 阶段:理解概念
你先重点掌握:
-
标准帧结构
-
仲裁
-
ACK
-
CRC
-
错误状态
第 2 阶段:上手代码
你要会写:
-
初始化
-
发一帧标准数据帧
-
收一帧并解析
第 3 阶段:结合工具调试
你要会:
-
看抓包
-
看 ID
-
看 DLC
-
看 data 字节
-
看波特率配置
第 4 阶段:协议设计
你再学:
-
报文表设计
-
信号缩放
-
周期报文与事件报文
-
超时监控
三十、最后用几句话把 CAN 讲透
你可以把 CAN 理解成:
一条允许很多设备一起说话的总线。
每条消息都有一个 ID。
ID 小的消息优先说。
所有人都能听到消息。
收到的人会 ACK。
出错了会自动检测、重发,严重故障的节点会自己退出总线。
如果你把下面这 6 个关键词彻底理解了,CAN 就算真正入门了:
-
差分传输
-
数据帧
-
ID 仲裁
-
位填充
-
CRC + ACK
-
错误状态机
三十一、你下一步最值得学的内容
推荐顺序:
-
我带你继续学:CAN 帧逐位解析
-
我带你学:STM32 上的 CAN 实战配置
-
我带你学:CAN FD 与经典 CAN 的区别
-
我带你学:如何设计一套自己的 CAN 应用层协议
三十二、给初学者的一句提醒
刚开始不要一上来死记寄存器。
先把"总线怎么工作 "和"一帧报文怎么流动"搞懂,再去看具体芯片驱动,你会学得快很多。