这是一篇从我自己维护的储能后备管理系统里,挑出 CAN 通信那一块做的复盘。项目基于 Linux + SocketCAN,App 层是纯 C 写的。下面讲的每一个设计、每一行代码,都是真实跑在设备上的东西,不是纸上谈兵。
开头先说清楚:我为什么要单独写 CAN 这一层
做储能的人都知道,BMS(电池管理系统)和 PCS(储能变流器)之间,最核心、最不能出错的通信就是 CAN。电池的电压、电流、温度、SOC、告警状态,全都要靠 CAN 帧在设备之间传来传去。CAN 这条总线要是抖动一下,轻则数据丢帧,重则保护逻辑误判,整个系统可能就停了。
我们这个 legacy_app 是个运行在储能柜里的 App 层程序,用的是 Linux,通信走 SocketCAN。刚开始接手的时候,CAN 的读写代码其实已经能跑了,但我越读越觉得不对劲------它跟串口、TCP、UDP、WebSocket 这些通道的代码,风格不统一,而且散在各处。
真正让我下决心重构的,是一个很具体的问题:我们的储能柜有多个 CAN 口,可能同时接 BMS、接 PCS、接电表,甚至以后还要接别的设备。如果每个采样器都自己写一套 CAN 收发逻辑,那一旦 CAN 驱动要升级、要换内核接口,我就得满项目去找所有用到 CAN 的地方改。
所以我把所有通信通道统一抽象成了六个函数,让 CAN 只是其中一个"插件"。这篇文章就讲这件事------不是讲 CAN 协议本身的理论(那个网上很多),而是讲怎么把 CAN 变成一个可插拔的 HAL 层,以及在这个过程中踩过的坑。
一、HAL 六函数:所有通信通道的"公约数"
我先说清楚这个抽象长什么样。不管你是 CAN、串口、TCP 还是 WebSocket,在 App 层看来,你都只有六个动作:
c
HANDLE HAL_CommOpen(char*, char*, DWORD, int, int*); // 打开
HANDLE HAL_CommAccept(HANDLE); // 接受连接(TCP/WS 才有意义)
int HAL_CommRead(HANDLE, char*, int); // 读
int HAL_CommWrite(HANDLE, char*, int); // 写
int HAL_CommControl(HANDLE, int, void*, int); // 控制(设波特率、清缓存、设过滤等)
int HAL_CommClose(HANDLE); // 关闭
这六个函数原型在 comm_port.h 里,是整个通信层的"宪法"。我的想法很朴素:上层业务代码永远只跟这六个函数打交道,永远不直接碰 CAN 的具体实现。
这个设计不是我想出来的,是参考了早年 InfyPower 那套通信框架的思路(项目里 comm_port.h 还留着那个年代的痕迹)。但好处是真的实打实的:上层的采样器、上报器,它们根本不知道自己读的数据是来自 CAN 还是来自串口。你给它们一个 handle,它们就 Read、Write、Control,剩下的事交给驱动层。
这里有个我觉得值得说清楚的点:为什么是这六个,而不是五个或七个?
因为通信的本质就三件事:建立连接(Open/Accept)、搬运数据(Read/Write)、管理状态(Control/Close)。这六个函数是所有通信通道的"最大公约数"。至于 CAN 特有的东西------比如设置波特率、设置接收过滤器、复位总线------全部塞进 Control 这个"万能接口"里,用不同的命令码(nCmd)来区分。
这个设计有个代价,就是 Control 会变成一个巨大的 switch。但我当时权衡过:与其为了 CAN 的特例去破坏六函数的一致性,不如让 CAN 的"特例"都关在 Control 的笼子里。事实也证明,这个代价是值得的。
二、CAN 驱动是怎么实现这六个函数的
现在看 CAN 这边。CAN 驱动在 comm_socketcan.c,它的头文件 comm_socketcan.h 里有一句非常关键的宏:
c
#define _MAKE_SHARED_LIB
#ifdef _MAKE_SHARED_LIB
#define CAN_CommOpen HAL_CommOpen
#define CAN_CommAccept HAL_CommAccept
#define CAN_CommRead HAL_CommRead
#define CAN_CommWrite HAL_CommWrite
#define CAN_CommControl HAL_CommControl
#define CAN_CommClose HAL_CommClose
#endif
也就是说,CAN 驱动里写的 CAN_CommOpen,编译成动态库之后,导出的符号名就是 HAL_CommOpen。这样它就跟串口、TCP 那些驱动对外暴露一模一样的符号 ,上层加载器才能用同一套 dlsym 逻辑去解析。
我先讲 CAN_CommOpen,因为它是所有设计的入口,也是坑最多的地方。
打开一个 CAN 口:从 can1 到 can0 的坑
看代码(我简化了日志和注释):
c
HANDLE CAN_CommOpen(IN char *pPortDescriptor, IN char *pOpenParams,
IN DWORD dwPortAttr, IN int nTimeout, OUT int *pErrCode)
{
CAN_PORT_DRV *pPort = NEW(CAN_PORT_DRV, 1);
...
pPort->nSocket = socket(PF_CAN, SOCK_RAW, CAN_RAW);
...
// 解析 can1/can2
struct sockaddr_can addr;
struct ifreq ifr;
if(strcmp(pPortDescriptor, "can1") == 0) {
strcpy(ifr.ifr_name, "can0");
} else if(strcmp(pPortDescriptor, "can2") == 0) {
strcpy(ifr.ifr_name, "can1");
} else {
*pErrCode = ERR_COMM_OPENING_PARAM;
return NULL;
}
...
}
这里有个我自己都笑了的坑:业务层的 can1 对应的是内核里的 can0,can2 对应 can1。
为什么?因为业务侧的命名习惯是从 1 开始编号(can1、can2 听着顺口),而 Linux SocketCAN 内核里,第一个 CAN 接口默认就是 can0,从 0 开始。这个"错位"第一次接手时把我整懵了------配置里明明写着 can1,结果去 /sys/class/net/ 一看根本没有 can1 这个接口,只有 can0。
所以这段代码里专门做了一层映射:业务层的 can1 → 内核 can0,can2 → 内核 can1。这层映射很不起眼,但它是业务语义和底层现实之间的"翻译官"。很多嵌入式项目的"诡异 bug",本质都是这种命名错位没人说清楚。
设波特率:一个"看门狗"式的细节
接着是设置波特率,这段代码值得单独讲:
c
UINT nBaud = GetBaud_ByLevel(dwPortAttr);
...
can_do_stop(ifr.ifr_name);
can_set_bitrate(ifr.ifr_name, nBaud);
can_do_start(ifr.ifr_name);
UINT nRestartTimes = 0;
can_get_restart_ms(ifr.ifr_name, &nRestartTimes);
struct can_bittiming bitTime;
can_get_bittiming(ifr.ifr_name, &bitTime);
这里用的是 libsocketcan 提供的封装(can_do_stop、can_set_bitrate、can_do_start),而不是直接 ioctl。libsocketcan 是 SocketCAN 官方配套的用户态库,它把这些 SIOCSIF* 的 ioctl 细节封装起来了。
但真正有意思的是下面这个被注释掉的调试代码------它调用了 can_get_restart_ms 去读"总线重启了多少次":
c
// printf("---The restartTimes for %s is => %d ...\n", ifr.ifr_name, nRestartTimes, ...);
这个 restart_ms 是什么?它记录的是 CAN 控制器进入 Bus-Off(总线关闭)状态后自动恢复的次数。Bus-Off 是 CAN 协议里一个重要的错误状态:当发送错误计数超过 255,控制器会主动脱离总线,避免自己这个"捣乱的节点"拖垮整条总线。
我在调试阶段专门加了这个读取,是因为现场出现过一种情况:某个 CAN 口反复 Bus-Off,但上层采样器却"看起来一切正常"(因为数据偶尔还能读到)。后来靠这个 restart 计数,才确认是线缆接触不良导致的间歇性错误帧。这个调试代码虽然最后注释掉了,但它帮我定位了一个真实的现场问题。
一个 500 的队列长度:写给未来的自己
还有一处,是设置发送队列长度:
c
ifr.ifr_qlen = 500;
if (ioctl(pPort->nSocket, SIOCSIFTXQLEN, &ifr) < 0) {
printf("[Open CAN Socket] -- Set tx_queue_len fail: %s.\n", strerror(errno));
}
SocketCAN 默认的发送队列长度是 10。这意味着如果你一次性 write 超过 10 帧,多出来的帧会被丢掉,write 会返回 ENOBUFS。
我们储能系统在某些工况下需要连续快速下发一批 CAN 帧(比如批量查询电芯电压)。默认的 10 帧队列根本不够用,导致偶发的"数据写不出去"。所以我把队列长度调到了 500。
但这里有个我后来才意识到的更深的问题------调大队列只是治标 。如果上层真的持续以超过总线带宽的速度写帧,队列迟早还是会满。真正的解法应该是上层感知到 ENOBUFS 后主动降速或排队。这个我在后面讲 CAN_CommWrite 时会再展开。
三、读帧:一段让我找到"计数错位" bug 的代码
现在讲 CAN_CommRead。这是数据从内核到业务层的入口,也是我重构时发现第一个真 bug 的地方。
先看核心的读循环(简化):
c
int CAN_CommRead(IN HANDLE hPort, OUT char *pBuffer, IN int nBytesToRead)
{
...
while (nBytesToRead > 0) {
memset(&r_frame, 0, sizeof(r_frame));
rc = read(fd, &r_frame, sizeof(r_frame)); // 读一帧 can_frame
if(rc == sizeof(r_frame)) {
CAN_ParseReadFrameData(pBuffer, r_frame); // 解析成业务格式
nBytesToRead -= rc;
pBuffer += rc;
nTotalBytesRead += rc;
...
}
}
}
注意到一个关键点:读到的是一整个 struct can_frame(16 字节),但解析函数 CAN_ParseReadFrameData 把它转成了业务自定义的帧格式 ,长度只有 5 + 数据长度 字节。
看看解析函数:
c
void CAN_ParseReadFrameData(OUT char *pBuffer, IN struct can_frame r_frame)
{
BYTE byErrCode;
byErrCode = (BYTE)(r_frame.can_id >> 29) & 0x07;
if(byErrCode == 0x01) { // 错误帧,跳过
return;
}
pBuffer[0] = (BYTE)r_frame.can_dlc; // 数据长度
pBuffer[1] = (BYTE)((r_frame.can_id >> 24) & 0x1F); // 帧 ID 高位
pBuffer[2] = (BYTE)(r_frame.can_id >> 16);
pBuffer[3] = (BYTE)(r_frame.can_id >> 8);
pBuffer[4] = (BYTE)r_frame.can_id; // 帧 ID 低位
r_frame.can_dlc = MIN(r_frame.can_dlc, 8);
memcpy(&pBuffer[5], r_frame.data, r_frame.can_dlc); // 数据
}
这里藏着那个"计数错位"的 bug:解析函数实际只写了 5 + can_dlc 字节(CAN 数据最多 8 字节,所以最多 13 字节),但读循环里却用 rc = sizeof(r_frame) = 16 去递增指针和计数:
c
nBytesToRead -= rc; // rc = 16
pBuffer += rc; // 但实际只写了 13 字节!
nTotalBytesRead += rc;
这意味着什么? 当上层一次要求读多帧(nBytesToRead 大于一帧)时,指针会按 16 字节跳,但实际数据只写了 13 字节,中间会空出 3 字节的"黑洞"。连续读几帧之后,缓冲区的指针就彻底错位了。
这个 bug 在"一次只读一帧"的场景下完全不暴露------因为读一帧就 break 了。但一旦上层要求批量读,数据就会串位。这也是我后来下定决心重构通信层的原因之一:这种"平时不炸、特定工况才炸"的 bug,是最危险的一类。
还有一点值得说:错误帧的判断。CAN 标准里,帧 ID 的 bit 29~31 是标志位(EFF 扩展帧标志、RTR 远程帧标志、ERR 错误帧标志)。代码里 (r_frame.can_id >> 29) & 0x07 就是取出这三位,然后判断 == 0x01 表示这是一个错误帧,直接跳过不处理。
这个逻辑是对的,但写法太"魔数"了。SocketCAN 头文件里其实有现成的 CAN_ERR_FLAG(0x20000000)、CAN_EFF_FLAG、CAN_RTR_FLAG 这些宏。用宏的话,代码意图会清楚得多。我当时为了赶进度沿用了旧写法,现在回头看,这属于应该改但一直没排上期的"技术债"。
四、写帧:一次 Sleep_Ex(1) 暴露的背压问题
再看 CAN_CommWrite。这段代码里有一句让我纠结了很久的注释:
c
while (nBytesToWrite > 0) {
memset(&w_frame, 0, sizeof(w_frame));
CAN_ParseWriteFrame(&w_frame, pBuffer); // 业务格式 → can_frame
rc = write(fd, &w_frame, sizeof(w_frame));
if (rc == sizeof(w_frame)) {
nBytesToWrite -= rc;
pBuffer += rc;
nTotalBytesWritten += rc;
...
//added by Jimmy as if no sleep, will not be able to send more than 10 frames
Sleep_Ex(1); // ← 关键:不加这个 sleep,发不出 10 帧以上
}
}
那句注释是当年维护的同事留下的("Jimmy"是那个同事的名字,代码里到处都是他的痕迹)。他发现的表象是:批量发帧时,如果不 sleep 1ms,发到第 10 帧就发不出去了。
这个表象的根因,就是前面说的 SocketCAN 默认发送队列长度只有 10 。你连续 write,前 10 帧进内核队列,第 11 帧时队列满了,write 返回 ENOBUFS。同事的解法是每发一帧 sleep 1ms,给内核队列一点"消化"时间。
这个解法能跑,但它有两个问题:
第一,sleep 1ms 是拍脑袋的数字。为什么是 1ms 不是 0.5ms 或 5ms?没有任何依据,只是"试出来能跑"。换一个更慢的总线波特率(比如 50K),1ms 可能又不够了。
第二,更根本的,它用"堵"的方式掩盖了"背压"问题 。正确的做法是:write 返回 ENOBUFS 时,说明发送队列满了,上层应该把这个错误传回去,让调用方决定是降速、排队还是丢弃,而不是自己偷偷 sleep 硬扛。
不过我后来想通了------在当时的约束下(没有 RTOS、就是裸的 Linux 进程、业务不允许丢帧),这个 Sleep_Ex(1) 其实是工程上可以接受的权宜之计 。嵌入式开发里,这种"不优雅但能跑"的方案大量存在。重要的是要理解它为什么能跑,以及它的边界在哪,而不是无脑抄或者无脑批判。
五、控制接口:把 CAN 的特例都关进 switch 里
CAN_CommControl 就是前面说的那个"万能接口"。看它的 switch 结构(我只挑几个关键的命令码讲):
c
int CAN_CommControl(IN HANDLE hPort, IN int nCmd,
IN OUT void *pBuffer, IN int nDataLength)
{
...
switch (nCmd) {
case COMM_GET_CAN_BAUDRATE:
can_get_bittiming(pPort->szCanPort, &btt);
*(UINT *)pBuffer = (UINT)btt.bitrate;
break;
case COMM_SET_CAN_BAUDRATE:
UINT nBaud = GetBaud_ByLevel(*(DWORD *)pBuffer);
can_do_stop(pPort->szCanPort);
can_set_bitrate(pPort->szCanPort, nBaud);
can_do_start(pPort->szCanPort);
break;
case COMM_SET_CAN_FILTER:
setsockopt(pPort->nSocket, SOL_CAN_RAW, CAN_RAW_FILTER,
(struct can_filter*)pBuffer, nFilterSize);
break;
case COMM_RESET_CAN:
can_do_restart(pPort->szCanPort);
break;
}
}
这里能看到 CAN 特有的四个操作------查波特率、设波特率、设接收过滤、复位总线------全都以命令码的形式塞进了 Control。上层如果想给 CAN 口设个 250K 的波特率,就调 HAL_CommControl(handle, COMM_SET_CAN_BAUDRATE, &baud, sizeof(baud)),不需要知道底层是 can_set_bitrate 还是别的什么。
接收过滤器(CAN_RAW_FILTER)是 CAN 性能的关键。 CAN 总线是广播式的,一条总线上所有节点都能看到所有帧。如果不设过滤器,你的用户态进程会被总线上的所有帧"轰炸",每一帧都要从内核拷贝到用户态,再被业务代码丢掉------这是巨大的 CPU 浪费,尤其在帧率高的储能工况下。
所以 COMM_SET_CAN_FILTER 让上层能按 CAN ID + 掩码来过滤,只接收自己关心的帧。这是 SocketCAN 提供的硬件加速过滤能力(CAN_RAW_FILTER),在内核里就过滤掉了,不占用户态 CPU。这个功能看起来不起眼,但对储能这种"长时间、高帧率、多路 CAN"的场景,省下来的 CPU 很可观。
六、动态加载:把"插件化"真正落地的最后一公里
前面讲的都是 CAN 驱动"怎么实现六个函数"。但光实现还不够------上层怎么找到并加载这个 CAN 驱动? 这就是 comm_loader.c 干的事。
核心是这个函数:
c
static BOOL Load_ACommPort(COMMPORT* pCommPort)
{
static const char *pszSymName[] = {
SYM_HAL_COMMOPEN, // "HAL_CommOpen"
SYM_HAL_COMMACCEPT, // "HAL_CommAccept"
SYM_HAL_COMMREAD, // "HAL_CommRead"
SYM_HAL_COMMWRITE, // "HAL_CommWrite"
SYM_HAL_COMMCONTROL, // "HAL_CommControl"
SYM_HAL_COMMCLOSE // "HAL_CommClose"
};
HANDLE *pfnProc[] = {
(HANDLE *)&pCommPort->Open,
(HANDLE *)&pCommPort->Accept,
(HANDLE *)&pCommPort->Read,
(HANDLE *)&pCommPort->Write,
(HANDLE *)&pCommPort->Control,
(HANDLE *)&pCommPort->Close
};
pCommPort->hLib = LoadDynamicLibrary(
MakeFullFileName(szFullLibPath, getenv(ENV_VARIABLE_APP_PATH),
"comm_port", pCommPort->info.szPortDriver),
ITEM_OF(pszSymName), pszSymName, pfnProc, TRUE);
...
}
这个函数做了一件非常优雅的事:它有一个"符号名数组"和一个"函数指针数组",两个数组一一对应,然后用一个循环把动态库里的六个符号,逐个填进 COMMPORT 结构体的六个函数指针里。
而真正的 dlopen + dlsym 逻辑在 LoadDynamicLibrary 里(iBasic_Funs.c):
c
HANDLE LoadDynamicLibrary(IN const char *pszLibName, IN int nSym,
IN const char *ppszSymName[], OUT HANDLE *pfnProc[],
IN BOOL bNeedAllSym)
{
HANDLE hLib = (HANDLE)dlopen(pszLibName, RTLD_LAZY);
if (hLib == NULL) {
const char *err = dlerror();
...
return NULL;
}
for (i = 0; i < nSym; i++) {
*pfnProc[i] = (HANDLE)dlsym(hLib, ppszSymName[i]);
if (*pfnProc[i] == NULL) {
if (bNeedAllSym) { // 所有符号都必须找到
dlclose(hLib);
return NULL;
}
}
}
return hLib;
}
到这里,整个"插件化"的闭环就完整了:
- 每个通信驱动 (CAN、串口、TCP、WebSocket......)都编译成一个
.so动态库,导出统一的HAL_Comm*六个符号。 - 上层加载器 (
comm_loader.c)启动时读配置,拿到每个端口的驱动名(比如comm_socketcan.so),用dlopen打开。 dlsym逐个解析 六个HAL_Comm*符号,填进COMMPORT结构体的函数指针表。- 业务代码 只通过
COMMPORT结构体里的函数指针调用,完全不知道、也不关心底层是哪个驱动。
这就是经典的**"面向接口编程"在 C 语言里的落地**。C 语言没有类的多态,但函数指针表 + 动态加载,同样能达到"新增一个通信驱动,不用改任何上层代码"的效果。
这套机制给我带来的实际好处
我举一个真实的例子。我们项目里通信通道有 9 种:CAN、串口、TCP、UDP、WebSocket、IPC、HTTP/FTP、RPMSG...... 后来需求加了一个新的 comm_websocket_ex(用 noPoll 而不是 libwebsockets 的另一个 WebSocket 实现)。
如果当时没有这层抽象,加一个 WebSocket 变体,我得去改上层所有用到 WebSocket 的采样器和上报器。但有了这层抽象,我只需要:
- 写一个新的
comm_websocket_ex驱动,实现六个HAL_Comm*函数; - 编译成
.so,放到/app/comm_port/目录; - 在配置里把这个端口指向新驱动。
上层一行代码都不用改。这就是抽象层最实在的价值------它把"变化"关在了一个局部范围里。
七、这套设计里的真实技术债(诚实复盘)
我不想过誉自己这套东西。诚实地讲,这个 CAN HAL 层有几个我明知道、但一直没排上期去修的债:
1. 错误路径的内存泄漏。 CAN_CommOpen 里,NEW(CAN_PORT_DRV, 1) 分配了内存后,好几条错误路径直接 return NULL,没释放 pPort。因为 CAN_CommOpen 失败后设备基本也就起不来了(CAN 打不开是致命错误),所以泄漏的危害被掩盖了。但严格来说这是不干净的。
2. 前面说的"计数错位" bug (CAN_CommRead 里用 16 递增但只写 13 字节)。这个是最该修的。
3. 魔数判断错误帧 ((can_id >> 29) & 0x07 应该用 CAN_ERR_FLAG 宏)。
4. GBK 编码的中文注释。 这批老代码的中文注释是 GBK 编码,很多现代工具(包括我后来想用的 codebase-memory 这种代码图谱工具)直接读不了,被当成非 UTF-8 文件跳过了。这也逼得我后来只能靠 grep + 人肉读源码。
5. 大量被注释掉的调试代码。 文件里到处是 // Jimmy added...、// printf(...) 这种注释块。历史信息有保留价值,但太多了就成了噪音。
这些债,有的是历史遗留(接手时就有的),有的是我当时为了赶进度埋下的。它们不影响系统跑起来,但确实影响可维护性------尤其是当我想把这个项目往开源方向整理的时候,这些债就变成了必须先清理的拦路虎。
八、总结:我从这个 CAN 抽象层学到的东西
写到这里,我想把这次的收获浓缩成几条,给同样在做嵌入式通信层的朋友:
第一,抽象的价值不在"优雅",在"隔离变化"。 六函数的 HAL 层,代码本身不复杂,真正值钱的是它把"通信驱动的变化"关在了一个局部范围。新增、替换一个驱动,上层无感。这在嵌入式项目里,省下的不只是写代码的时间,更是排查"到底改哪几处"的心力。
第二,命名错位是嵌入式项目的高频坑。 业务层叫 can1、内核叫 can0,这种"从 1 还是从 0 开始"的错位,比任何算法 bug 都更隐蔽、更难查。文档里写清楚映射关系,比多写十行代码重要。
第三,理解"权宜之计"的边界。 Sleep_Ex(1) 这种方案,工程上可以用,但你要清楚它为什么能跑、它的边界在哪。它是在"没有更好的背压机制"下的妥协,不是正确答案。一旦条件允许(比如上了 RTOS 或重写了发送队列管理),就该换掉它。
第四,诚实地面对技术债。 每个长期运行的项目都有债。关键不是零债(不现实),而是知道自己有哪些债、影响面多大、什么时候该还。我列出的那五条债,就是我对这个模块的"欠账清单"。
这个 CAN HAL 层,从六函数抽象,到 SocketCAN 的具体实现,再到 dlopen/dlsym 的动态加载,最后到那些真实的技术债,我尽量把"为什么这么设计"和"哪里做得不好"都说清楚了。储能这个行业,代码不见得多高深,但"在资源受限、可靠性要求高的环境里,把一层抽象做好做稳",本身就是件挺有技术含量的事。
如果你也在做类似的嵌入式通信层,希望这篇复盘对你有用。有什么想深入讨论的------比如 SocketCAN 的错误处理、CAN FD 的适配、或者函数指针表这套模式的更多细节------欢迎来找我聊。