
搞嵌入式或者汽车电子的朋友对CAN总线应该都不陌生。车载网络里到处是CAN发动机、车身控制器、电池管理、仪表全靠一根差分对在传数据。去年我在某个项目上写了一套CAN通信代码当时主要是验证功能跑得倒也正常但一放到真实工程里就暴露出不少问题配置写死在代码里、接收靠轮询、多帧数据来不及处理会丢、出错之后不会恢复传输中断只能重启。这次出的升级版把这些问题逐个收拾掉了顺手把结构重构成能直接拿到其他项目复用的模块。这篇文章主要讲清楚升级版是怎么设计的、每个模块为什么这么写、移植时又踩了哪些坑适合正在写或者准备用CAN通信代码的嵌入式工程师尤其是想从“能收发”往“可靠收发”走一步的朋友。1. 升级版究竟升级了什么先给初版“算算账”1.1 初版代码的几个经典痛点先说初版代码。当时验证功能思路很简单初始化一个CAN控制器配置好波特率然后在主循环里轮询接收标志位有帧就进中断或者标志判断里读出来打印到串口。代码几十行就能跑通在桌面上用两个开发板对测效果挺理想。可事情一旦换到真实项目里问题就全冒出来了。第一个痛点是配置硬编码。波特率、滤波ID、工作模式全都写在初始化函数里换一个MCU型号整个文件就得重写甚至同一个芯片换了一个应用场景也要重新翻代码改参数。这个问题的根源不是配置项本身多而是配置和驱动逻辑搅在一起一改配置就容易把驱动逻辑改坏。第二个痛点是纯轮询收发。主循环在等着一帧数据的时候如果总线上一段时间没有报文整个CPU全被空转占掉中断里有数据要处理的时候主循环又在忙其他的两边一撞就丢帧。说白了就是没有缓冲机制数据来一条收一条收不过来就放弃。第三个痛点是错误处理约等于零。初版代码只在出错时点亮一颗LED具体是什么错误、需不需要恢复、恢复之后怎么重新参与通信完全没有设计。CAN总线上任何一个节点出错进入bus-off状态如果不处理整个节点就永远脱离网络了这在整车上是很严重的问题。1.2 升级版定下的四个目标升级版动手之前我给自己定了四个目标后面所有代码都是围绕这四个目标展开的。一是可配置。所有的波特率、过滤器、工作模式、中断优先级全部通过配置文件注入驱动代码里不允许出现魔法数字。这样换个场景只需要改配置头文件驱动层一行不用动。二是可移植。把代码分成两层跟具体芯片寄存器相关的HAL层和通用逻辑的协议/驱动层。换芯片只改HAL层上层逻辑原封不动搬走。这一步看似费工但_project多了以后回报很高。三是可靠。错误计数器要能读出来状态迁移要有清晰逻辑bus-off要能自动恢复收发要有缓冲区不能让“来不及处理”成为丢帧的借口。四是可扩展。数据结构里预留好扩展帧、远程帧、CAN FD帧的位置后续如果需要诊断协议或升级到CAN FD不用把整个模块重写。这四条定下来整个代码的骨架就比较清楚了。下面直接说架构和实现。2. 代码怎么分层才经得起折腾一个清晰的框架比什么都重要2.1 从“裸机直写”到“分层模块”的思路初版代码最大的问题就是“一把梭”。所有逻辑都堆在一个文件里寄存器操作、协议解析、应用处理全部混在一起看着代码不多维护起来很痛苦。升级版第一件事就是把职责拆开。我采用的是四层结构应用层只需要调用发送接口、注册接收回调完全不关心CAN控制器寄存器长什么样。协议/驱动层负责帧封装、解析、校验、过滤规则匹配这里不出现具体寄存器操作。HAL层封装所有跟芯片相关的寄存器读写、中断入口、时钟配置这一层换平台时才动。配置层一份配置文件定义波特率、过滤器、通道数量和回调函数。打个比方这就像快递链条。HAL层是各家物流网点负责把包裹搬上车协议层是运输路线和分拣规则应用层是收件人只关心包裹到没到、内容对不对。各干各的哪一环坏了只修那一环不用把整条链拆了。层与层之间的接口要定义干净。我用一个can_send(ch, msg)和一个接收回调can_on_rx(ch, msg)作为上下层之间的唯一通道。HAL层收到中断把数据搬进缓冲区驱动层在缓冲区里取帧应用层在回调里拿最终结果。这样每一层都能单独测试也方便将来换成RTOS环境。2.2 核心数据结构是怎么设计的升级版里最重要的一个结构体是报文消息所有上层收发都靠它传递数据。我把它设计成这样typedef struct { uint32_t id; /* 标准帧11bit扩展帧29bit */ uint8_t data[64]; /* 经典CAN最多8字节CAN FD最多64字节 */ uint8_t dlc; /* 当前帧实际数据长度 */ uint8_t ch; /* 通道号多路CAN时用 */ uint8_t flags; /* bit0: 扩展帧; bit1: 远程帧; bit2: FD帧 */ uint32_t ts_ms; /* 接收时间戳单位毫秒 */ } can_msg_t;这里每个字段都有讲究。id用uint32_t是为了同时容纳标准帧和扩展帧避免为ID类型单独开两个结构体。data[64]看着有点浪费但我特意留到64字节因为后续如果做CAN FD升级数据结构这块就不用再动了。flags比单纯一个bool好用它把所有“帧类型”信息合成一个字节将来加新类型也容易。ts_ms是最容易被忽略的字段但调试的时候特别有用——能知道某条报文距离上一条过去了多久定位周期超时问题靠它。配置文件里的过滤器和运行参数也用结构体统一管理typedef struct { uint32_t id; /* 参考ID */ uint32_t mask; /* 掩码0表示不关心1表示必须匹配 */ uint8_t extended; /* 1-扩展帧0-标准帧 */ } can_filter_t; typedef struct { uint32_t baudrate; /* 期望波特率如500000 */ uint8_t sample_pos; /* 采样点百分制建议75~85 */ uint8_t mode; /* 0-正常1-回环2-静默 */ can_filter_t filters[8]; uint8_t filter_num; uint8_t fd_enable; /* 是否启用CAN FD */ } can_config_t;为什么过滤规则要用“ID 掩码”而不是给一串ID列表因为当前市面上的CAN控制器几乎都支持“掩码模式”内核里是一套位比较逻辑比逐个ID匹配省资源而且能通过一个配置覆盖一段地址范围的帧。比如要接收ID 0x100到0x10F的所有帧列表模式要写16条掩码模式一条就够ID设为0x100掩码设为0x1F0低4位不关心。2.3 环形缓冲区中断和主循环之间的“快递中转站”这个问题在初版代码里完全没处理升级版必须补上。真实总线上数据不会等你主循环可能在刷写Flash或者在处理优先级更高的事中断里读出来的帧不可能一直保存在寄存器里必须搬到内存缓冲区。收发两端速度不匹配就用一个队列来“削峰填谷”。我用的是一块环形缓冲区变量就两个head写指针和tail读指针。#define RX_FIFO_SIZE 32 typedef struct { can_msg_t buf[RX_FIFO_SIZE]; volatile uint16_t head; volatile uint16_t tail; uint32_t overruns; } can_rx_fifo_t; static void fifo_push(can_rx_fifo_t *f, const can_msg_t *m) { uint16_t next (uint16_t)((f-head 1) % RX_FIFO_SIZE); if (next ! f-tail) { f-buf[f-head] *m; f-head next; } else { f-overruns; } } static int fifo_pop(can_rx_fifo_t *f, can_msg_t *m) { if (f-head f-tail) { return 0; } *m f-buf[f-tail]; f-tail (uint16_t)((f-tail 1) % RX_FIFO_SIZE); return 1; }这套代码看着简单有几个关键点必须注意。head和tail加了volatile因为它们在中断和主循环两侧被访问。单生产者单消费者模式下中断只写head主循环只写tail不需要加锁但如果你用了我上面这种“先取next再判断满”的写法要确认编译器的内存序处理。更稳妥的做法是在fifo_push前后关闭CAN接收中断但那样会增加中断关闭时间我在实测中采用“只用两个变量顺序写”的方案跑了一年多没出问题。如果你的平台有DMA或者双核共享内存这里需要重新评估。缓冲区大小RX_FIFO_SIZE选32帧。经典CAN一条报文最多8字节32帧也就256字节对RAM压力几乎为零。但如果你跑的是那种一包接一包的刷写场景建议评估峰值帧率算出一个保守值再往上加。我后面的调试经历里就因为这个吃了亏。3. 核心代码实现全过程该抄作业的环节都在这了3.1 配置项与波特率计算用公式替代魔法数字配置文件的引入让“换个波特率就要翻代码”这件事彻底成为过去。但配置只是第一步波特率怎么计算成寄存器值里面的坑不少。CAN的位时间由四段组成同步段、传播段、相位缓冲段1、相位缓冲段2。波特率公式是can_bus_clk / (presc * (1 seg1 seg2))其中同步段固定在1个TQseg1是传播段加相位缓冲段1seg2是相位缓冲段2。采样点就是(1 seg1) / (1 seg1 seg2)一般建议75%到85%之间放在比较靠后的位置是因为总线存在信号传播延迟和节点时钟偏差采样点太靠前容易误判显性位的边沿。我写了一个工具函数传入外设时钟、目标波特率和采样点百分比自动算出预分频和段长度static int can_brr_calc(uint32_t sys_clk, uint32_t baud, uint8_t sample_point, uint32_t *brr_reg) { uint32_t target sys_clk / baud; for (uint32_t ps 1; ps 64; ps) { if (target % ps ! 0) { continue; } uint32_t tq target / ps; if (tq 8 || tq 25) { continue; } uint8_t seg1 (uint8_t)((tq * sample_point / 100) - 1); uint8_t seg2 (uint8_t)(tq - seg1 - 2); if (seg1 1 seg2 1 seg2 8) { *brr_reg (ps - 1) | ((seg1 - 1) 8) | ((seg2 - 1) 12); return 0; } } return -1; }这里brr_reg的位域定义是我按通用CAN控制器画的示意具体芯片可能不同但“预分频、时间段1、时间段2”三段式的思路是通用的。拿到实际数据手册后把位域的位置对应改一下就能用。说一个经验target sys_clk / baud能整除的情况最理想如果除不尽别硬凑。有一条CAN规则是位时间TQ总数差值不能太大8到25这个范围是我在实际选型时常用的安全区间。波特率偏差大于1.5%左右就很难在一个长报文上保证同步了所以优先选能整除的预分频值。采样点这个值也值得认真选。我在一个项目里刚开始用50%的采样点点对点通信没问题可一旦总线挂了十几个节点、线缆长度超过3米错误帧明显变多。改成80%之后问题消失。原因就是采样点太靠中间干扰叠加后很容易在采样时刻读到错误电平。建议不要低于70%。3.2 初始化流程的顺序不能乱初始化代码很多人随手写先配置GPIO再开启时钟或者先配CAN控制器再配过滤器。实际上顺序错了大概率跑不通。我整理了一个稳定的初始化顺序写成函数后就不用再动开启CAN外设时钟、GPIO时钟配置CAN_TX、CAN_RX引脚为复用功能先退出初始化模式再进设置模式按控制器状态机要求操作把波特率寄存器值写入位时序寄存器设置工作模式正常/回环/静默配置过滤器组和掩码清空中断标志使能收发中断请求进入正常模式确认模式切换成功。这里第3步和第8步是最容易漏的。很多控制器有“初始化模式”和“正常模式”之间的切换过程模式切换不是瞬间完成要读模式状态寄存器确认如果直接写一个“请求正常模式”的位然后立刻开中断中断往往没真正触发。初始化失败的情况真要遇到我建议把模式状态寄存器、时钟状态寄存器、错误寄存器全部打出来先确认控制器是不是真的进了想要的状态。别急着改GPIO配置因为大部分“一上电不能通信”的问题根本不是引脚配错而是模式没切过去。3.3 报文发送阻塞、超时与发送完成标志发送函数的设计核心是“不能因为发送失败把主循环堵死”。我种做法是发送函数只负责“把帧写入发送邮箱并请求发送”发送完成与否交给发送完成中断来置标志。主循环调用发送函数时等待一个超时时间超时就返回错误。int can_send(uint8_t ch, can_msg_t *msg) { can_dev_t *dev g_can[ch]; uint32_t tickout get_ticks() 10; while (dev-tx_busy) { if (tick_before(get_ticks(), tickout)) { return CAN_ERR_TIMEOUT; } } if (dev-mode CAN_MODE_LOOPBACK) { /* 回环模式直接进接收路径不走总线 */ } fill_tx_mailbox(dev, msg); dev-tx_busy 1; return CAN_OK; }这个实现看着简单背后的思路是“忙标志”管理。tx_busy在发送完成中断里被清零void CAN_TX_ISR(can_dev_t *dev) { if (tx_ok_flag(dev)) { dev-tx_busy 0; clear_tx_ok_flag(dev); } }为什么要用发送完成中断而不是发送请求后直接说完成因为硬件写进邮箱只是“排队成功”真正把报文发送到总线上还需要时间如果邮箱忙或总线正处于错误恢复阶段请求发送的动作可能失败。等发送完成中断拉低tx_busy才能准确知道这帧已经送出去了。发送失败时控制器会置一个错误标志此时及时清标志并返回应用层才能决定是否重发。阻塞等待的10ms是拍脑袋定的吗不是。500kbps下最长的经典CAN报文扩展帧8字节数据总长约145ns到160ns换算下来1ms能传几千帧发送一帧从请求到送出的时间在微秒到几百微秒量级。10ms的超时足够宽容又能瞒住“控制器已经异常”这种严重情况。如果你的波特率特别低比如125kbps单帧时间会更长超时值要相应放大。3.4 中断接收、过滤器与回调处理接收这半边是升级版的重头戏。我的做法是CAN接收中断里用最快的速度把邮箱里的帧搬进环形缓冲区清标志位然后把“有数据”这件事用一个全局标志暴露给主循环主循环或应用层再从缓冲区里取帧处理。中断服务函数里只有三个动作任何多余判断都往外拿void CAN_RX_ISR(can_dev_t *dev) { while (rx_mailbox_pending(dev)) { can_msg_t m; read_rx_mailbox(dev, m); fifo_push(dev-rx_fifo, m); } clear_rx_flag(dev); }这里用while循环把邮箱里所有待读帧一次搬完清一次标志就行。如果你在中断里只读一帧就退出而硬件FIFO里还有好几帧下次中断什么时候来就不确定了——有的控制器会立刻重新触发有的不会表现就是“丢帧其实是没读干净”。这是我踩过的一个坑。过滤器这块单独说一下。掩码模式下的“1”到底是“必须检查”还是“忽略”不同芯片定义不一样有的甚至反过来。我看到很多初学者在这里栽过配置完过滤器之后一条帧都收不到或者什么帧都能收到。所以我的建议是先回环模式跑一遍确认过滤逻辑跟预期一致再接真实总线。void CAN_FILTER_CFG(can_dev_t *dev, can_filter_t *f, int num) { /* 对每个f按芯片寄存器要求写入ID和掩码 */ for (int i 0; i num; i) { set_filter_id(dev, i, f[i].id); set_filter_mask(dev, i, f[i].mask); set_filter_ext(dev, i, f[i].extended); } }应用层收数据的回调我故意做得很薄回调里只允许做两件事拷贝消息、置一个“应用层待处理”的信号量或标志。绝对不要在回调里做协议解析、打印或者Flash写入。原因很简单回调如果执行时间过长接收缓冲区很快就满了新帧只能被丢弃。真实项目中我有一次把一条UDS响应解析放在接收回调里结果刷写速率一下子掉了30%去掉之后立刻恢复。3.5 错误状态迁移与自动恢复错误处理是升级版里最值钱的部分。CAN控制器内部有发送错误计数和接收错误计数两个寄存器根据计数大小节点会处于三种状态状态错误计数范围行为表现错误主动0 ~ 127正常参与通信出错时可以主动发送错误帧错误被动128 ~ 255只监听总线不能主动发错误帧重传前需要等待6个隐藏位总线关闭256以上完全脱离总线既不收发也不应答真实项目里最容易出现的是连上总线后谁都不说话查了半天发现某个节点的错误计数已经超过256处于bus-off状态。这种情况下的恢复策略是void can_err_state_update(can_dev_t *dev) { uint8_t tec get_tec(dev); uint8_t rec get_rec(dev); if (dev-err_state ERR_BUSOFF) { if (tec 0) { /* 控制器内部已经恢复重新参与通信 */ dev-err_state ERR_ACTIVE; request_normal_mode(dev); } return; } if (tec 128 || rec 128) { dev-err_state ERR_PASSIVE; } else { dev-err_state ERR_ACTIVE; } }总线关闭的恢复不同控制器有不同策略。有些硬件会自动重发等待有些需要软件把控制器复位重来一遍。我的建议是能软件恢复就不要硬件断电。在整车环境里一个节点反复重启会拖累整个网络的稳定性而且重启过程中如果总线上正好在跑多帧传输也容易干扰其他节点。错误中断里除了读计数还要记一条日志。哪怕是往一个全局数组里塞一个错误码也比“亮个灯然后什么都不知道”强。我调试时最常用的做法是把错误码和时间戳打印出来看是发送错误、接收错误还是应答错误——总线负载高时这三种错误的处理思路完全不一样。4. 移植和调试中遇到的典型问题这些问题基本人人都遇到过4.1 波特率差一点点总线就静默我遇到过一个很典型的问题两个板子都配置成500kbps软件里波特率寄存器看起来也对但连上总线就是通信失败用示波器看波形却看不出问题。后来用CAN协议分析仪去读实际波特率发现一个节点实际是497kbps另一个是503kbps差了1.2%。单帧短的时候没事一碰上连续多帧传输偶尔就撞出错误帧表现成“时好时坏”。原因是两个节点用了不同的时钟源一个外接20MHz晶振精度高另一个用的是MCU内部RC振荡器标称20MHz实际偏差在±1%以上。这种问题在实验室用开发板测试时不容易出现因为开发板上都有晶振一放到量产的控制器上就暴露了。排查思路是先用工具函数把波特率误差算出来再确认两个节点的时钟源精度如果对精度没把握优先选低波特率比如把500k降到250k容忍度会高很多。4.2 过滤器把该收的帧全挡了过滤器的问题是“配了等于没配”和“不配就收垃圾”两个方向都存在。有过一次经历我把过滤器配置成“全部通过”结果总线上所有节点的报文都进了缓冲区主循环被塞到死循环其它任务全卡住。问题根源不是缓冲区太小而是“所有帧都接收”这个配置让中断频率变得极高CPU大部分时间花在搬数据上。反过来把掩码配成“全部必须精确匹配”之后一条帧都收不到。排查时看了半天最后发现是掩码寄存器里面位定义里1表示忽略而不是必须匹配我之前理解反了。所以这个坑的教训是写过滤寄存器前先把数据手册里“mask”位的含义抄到代码注释里避免下次再踩。过滤器还有一个容易出错的地方是扩展帧和标准帧的ID存放位置不一样。29位扩展ID在寄存器里被拆成两段低16位和高13位你如果按标准帧的11位格式往寄存器里塞扩展帧永远收不到。4.3 字节序、位序、DLC的坑CAN协议本身并没有规定数据场的字节序用大端还是小端完全看应用层怎么定义。做整车诊断的时候经常遇到两种格式混用有的信号按Motorola格式高字节在前有的按Intel格式低字节在前。初版代码里我直接拿memcpy复制数据结果解析出来的值完全不对。正确的做法是写专门的打包/解包函数所有跨字节的信号都明确指定字节序和位序不要依赖编译器对齐和平台端序。要警惕DLC。DLC是数据长度代码在某些变种协议里它代表的是“数据长度相对8的倍数”而不是真正的字节数。CAN FD出现后DLC编码方式变了8字节以下表示通常长度8以上按离散值对应到12、16、20、24等。所以我在数据结构里专门存真实的dlc收发转换时在驱动层做一次编码映射上层永远只跟“真实字节数”打交道。4.4 高负载下丢帧与接收溢出在某个刷写场景里上位机以接近零间隔的方式发UDS写入请求一包接一包不停地发升级版代码出现了偶发丢帧。排查时先看接收缓冲区overruns计数器发现确实有溢出于是把RX_FIFO_SIZE从16改成32情况好了很多但压力再大时还是偶发。进一步的定位是主循环里在处理其它任务处理时间超过了相邻两帧的间隔缓冲区的读取速度跟不上写入速度。最终方案不是无限加大缓冲区而是做了三件事把CAN接收中断的优先级提到最高一级确保中断响应不会被别的任务阻塞中断里只拷贝不做处理主循环里的耗时操作全部改到空闲时间片执行。这样改动之后即使缓冲区只有16帧也很稳。这里想强调一点缓冲区空间是预算不是容量。如果设计时让缓冲区经常处于接近满的状态说明系统整体处理能力不足该优化的是处理路径而不是缓冲区。4.5 “能发不能收”的排查流程“能发不能收”是我收到问得最多的问题。这类问题的排查有一个固定套路按顺序走一遍大部分都能快速定位。第一先确认是不是回环模式没关。如果控制器的回环模式开启发出的帧不会出现在总线上自己又能收到从本机看“收发都正常”另一台设备当然什么都收不到。第二检查接收中断是否使能这个看似简单但很多芯片的中断使能位是多级开关有一级漏配就全断。第三检查过滤器。第四确认总线PHY芯片的STB、RS引脚电平有一款常见的收发器芯片引脚不接电阻默认进入静默模式只收不发。这类问题最容易蒙住人因为“能收到”说明总线物理层没断但“不能发”又指向驱动层实际上硬件配置就有问题。排查顺序最好整理成一张自检清单我在代码里都加了相应的调试宏每步都往串口打一个状态标记把定位时间从几小时压缩到几分钟。5. 性能、可靠性与后续扩展跑通只是第一步5.1 CPU占用率怎么量化别拍脑袋很多人说“我的CAN代码占CPU很少”实际上是凭感觉。量化方法很简单在接收中断入口拉高一个GPIO出口拉低用示波器看这个GPIO的占空比就是接收中断消耗的时间比例。同理发送完成中断、错误中断都可以各分配一个GPIO来测。我实测过一组数据500kbps总线下帧间隔大约几十微秒一帧接收中断从进入ISR到退出大概是2微秒到4微秒取决于读取邮箱和拷贝次数占空比通常不到10%。但如果中断里做了协议解析或者调用慢速外设这个比例能飙到50%以上。做实时系统时这个数字必须心中有数。5.2 重传策略别做成“重传风暴”发送失败后要不要重传我的结论是只重传重要的、应用层需要保证送达的帧而且重传要有退避机制。初版代码里我做过一个错误的示范发送失败后无限重传总线负载一高几个节点都在疯狂重发错误帧越来越多形成恶性循环。升级版的策略是发送失败时先进入一个“退避状态”间隔指数增长重试最多重试3次同时把失败次数上报到应用层由应用层决定是降级处理还是放弃。对于周期性报文简单跳过一帧往往比重传更合理因为下一帧很快就来了对于偶发性事件帧才需要可靠性保证。5.3 回环模式让调试效率翻倍升级版的配置里保留了三种模式正常、回环、静默。回环模式不经过总线在内部把发送数据直接送回接收路径最适合单板自测。写新代码或者改过滤器配置之后先切到回环模式验证一遍驱动逻辑再把模式改成正常接总线能省掉大量双板联调的痛苦。回环模式还有一个好处可以提前验证中断优先级和缓冲区是否够用。因为回环模式下收发频率完全由你自己控制可以压极限速率把缓冲区溢出问题在接真实总线之前暴露出来。5.4 给CAN FD留好接口经典CAN在8字节数据场和1Mbps速率之下已经越来越紧张CAN FD把数据场扩到最多64字节还能在数据段用更高的速率传输。升级版在设计时已经把消息结构的data[64]和flags的FD位预留好底层HAL层只需要增加一组寄存器操作驱动层的帧封装逻辑要改动的地方不多。但有一点要特别提醒CAN FD不是所有控制器都支持换硬件之前要先确认。而且CAN FD的DLC编码规则和位时序跟前文介绍的不一样数据段的采样点也要重新算。如果你现在用的还是经典CAN控制器数据结构先按上面的方式预留将来迁移时会轻松很多。最后再分享一点个人体会。CAN通信代码真正做到“可靠”不是把寄存器的每一个位配对就行而是在“配置、缓冲、异常处理、分层结构”这些看不见的地方下功夫。我先做回环自测再用GPIO把中断的CPU占用量出来最后才敢上真实总线。这套代码现在被我抽成固定模板换平台只需要改HAL层和配置头文件。如果读者你现在正从零写一套CAN驱动我建议不要只盯着“能收到数据”这个目标多花一点时间想想“收到之后怎么办、出错之后怎么办”这个投入绝对值得。