CAN总线是工业控制领域广泛使用的串行通信协议,采用差分信号传输,具备总线仲裁、错误检测和自动重传机制,在电磁干扰较强的工业现场中能够保持较高的通信可靠性和实时性。搞工控的开发者大概率绕不开这东西,今天就把我自己折腾SocketCAN的整个过程梳理一遍,从内核配置到报文解析,能踩的坑基本都踩过了。
## 一、先确认内核有没有启用CAN
不是所有板子出厂就给你配好CAN的。拿到一台工业一体机,第一步先看内核配置。
```bash
zcat /proc/config.gz | grep CAN
```
如果输出里能看到 `CONFIG_CAN=y` 和 `CONFIG_CAN_RAW=y`,说明内核已经编进去了。要是没有 `/proc/config.gz` 这个文件,那得去内核源码目录看 `.config`,或者直接 `ls /lib/modules/$(uname -r)/kernel/net/can/` 看有没有模块。
有些板子CAN驱动是编译成模块的,需要手动加载:
```bash
modprobe can
modprobe can_raw
modprobe can_dev
# 具体的芯片驱动,比如NXP的mcp2515
modprobe mcp251x
```
加载完之后 `ip link` 看一下,应该能看到 `can0` 之类的接口。
## 二、CAN接口初始化
这块其实不难,但参数设错了通信就全是垃圾数据。
```bash
# 设置波特率,工业上最常用500kbps
ip link set can0 type can bitrate 500000
# 开启接口
ip link set can0 up
```
如果你想确认波特率设对了,可以用 `ip -details link show can0` 看一下当前配置。工控现场常用的波特率有125k、250k、500k和1M,具体得看你对接的设备是什么。汽车上一般500k或者1M,工业自动化里250k和500k比较常见。
还有个坑——CAN控制器有个回环模式(loopback),调试的时候挺好用的,可以自发自收验证驱动没问题:
```bash
ip link set can0 type can bitrate 500000 loopback on
ip link set can0 up
```
## 三、Python发送CAN报文
直接写socket太麻烦了,用 `python-can` 库省事很多。
```bash
pip install python-can
```
发送代码:
```python
import can
# 创建总线接口
bus = can.interface.Bus(channel='can0', interface='socketcan')
# 构造一帧标准报文
msg = can.Message(
arbitration_id=0x123, # CAN ID
data=[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08],
is_extended_id=False # 标准帧(11位ID)
)
try:
bus.send(msg)
print(f"发送成功: ID=0x{msg.arbitration_id:X}, 数据={msg.data}")
except can.CanError as e:
print(f"发送失败: {e}")
finally:
bus.shutdown()
```
如果是扩展帧(29位ID),把 `is_extended_id` 改成 `True` 就行。扩展帧的ID范围更大,工业上一些复杂系统会用扩展帧来区分不同设备类型。
## 四、接收并解析CAN报文
接收这块稍微复杂点,需要区分标准帧和扩展帧,还要把数据字段提取出来。
```python
import can
bus = can.interface.Bus(channel='can0', interface='socketcan')
print("开始监听CAN总线...")
try:
while True:
msg = bus.recv(timeout=1.0)
if msg is None:
continue
# 判断帧类型
frame_type = "扩展帧" if msg.is_extended_id else "标准帧"
# 提取ID和数据
can_id = msg.arbitration_id
data_bytes = list(msg.data)
dlc = msg.dlc # 数据长度码
# 格式化输出
data_hex = " ".join(f"{b:02X}" for b in data_bytes)
print(f"[{frame_type}] ID: 0x{can_id:08X} | DLC: {dlc} | 数据: {data_hex}")
except KeyboardInterrupt:
print("\n停止监听")
finally:
bus.shutdown()
```
实际项目里光打印出来没用,还得解析具体含义。CAN报文的数据域最多8个字节,怎么拆分完全取决于上层协议定义。比如某个设备的协议规定byte0是命令码,byte1-2是温度值(大端序),那你得自己写解析逻辑:
```python
def parse_temperature(data):
if len(data) < 3:
return None
command = data[0]
# 大端序读取两个字节
raw_temp = (data[1] << 8) | data[2]
# 换算成实际温度,假设精度0.1℃
actual_temp = raw_temp * 0.1
return command, actual_temp
```
## 五、多帧报文组装与解析:ISO-TP协议
CAN单帧最多8字节,传不了多少东西。工业上传输较长数据时会用ISO-TP(ISO 15765-2)协议做分包传输。
ISO-TP的核心思路是:把长数据切成多个CAN帧,每帧加上协议头,接收端按序号重组。分单帧(SF)、首帧(FF)、连续帧(CF)和流控帧(FC)四种类型。
简单说一下流程:发送方先发一个首帧,里面带总长度信息;接收方回一个流控帧,告诉发送方"可以继续发,每次发几帧、间隔多久";然后发送方就开始连续发数据帧。
Python里可以用 `cantools` 配合 `isotp` 库处理:
```python
import isotp
# 配置ISO-TP参数
params = {
'stmin': 10, # 连续帧最小间隔(ms)
'blocksize': 8, # 每个块最多8帧
'tx_data_length': 8, # 单帧数据长度
}
# 创建ISO-TP层
isotp_socket = isotp.socket()
isotp_socket.bind('can0', isotp.Address(isotp.AddressingMode.Normal_11bits, txid=0x7E0, rxid=0x7E8))
# 发送多字节数据
isotp_socket.send(b'\x22\xF1\x90') # UDS读取VIN指令
# 接收响应
response = isotp_socket.recv()
print(f"响应数据: {response.hex()}")
```
这段代码模拟的是UDS诊断协议里读取车辆识别码的场景。工业上虽然不一定用UDS,但ISO-TP的传输机制是通用的。
## CAN报文结构说明
| 字段 | 位数 | 说明 |
|------|------|------|
| SOF | 1 | 帧起始位,固定为0 |
| Arbitration ID | 11/29 | 仲裁ID,标准帧11位,扩展帧29位 |
| RTR | 1 | 远程传输请求位,0为数据帧,1为远程帧 |
| IDE | 1 | 标识符扩展位,0为标准帧,1为扩展帧 |
| DLC | 4 | 数据长度码,0-8表示数据字节数 |
| Data | 0-64 | 数据域,最多8字节(64位) |
| CRC | 15 | 循环冗余校验,由硬件自动计算 |
| ACK | 2 | 应答位和应答界定符 |
| EOF | 7 | 帧结束,7个隐形位 |
## 常见问题
**Q1:SocketCAN接收数据有延迟怎么办?**
先检查接收缓冲区大小,默认的socket接收缓冲区可能不够。用 `socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 65536)` 把缓冲区调大。另外CAN总线负载率高的时候,考虑把接收放到独立线程里,别让主线程阻塞。
**Q2:同一总线上多个设备ID冲突怎么处理?**
CAN协议本身有总线仲裁机制,ID越小优先级越高,但ID冲突会导致总线错误。建议在系统设计阶段就规划好每个节点的ID分配表,避免运行时冲突。如果设备数量多,可以用扩展帧的29位ID来扩大寻址空间。
**Q3:python-can和直接操作socket有什么区别?**
python-can是对Linux SocketCAN的封装,底层还是走socket接口,但帮你处理了帧格式封装、超时管理这些细节。如果你的应用逻辑不复杂,直接用socket也行,性能差别不大。但跨平台或者需要切换不同硬件接口时,python-can的抽象层会方便很多。