STM32F10x bxCAN通信配置、位时序与Bus-Off排障
1. 为什么要把CAN通信压在STM32F10x这颗老芯片上
第一次被问到"CAN在STM32F10x上怎么搭起来"的时候,我正蹲在车间里对着一台报警不停的小设备发愁。问题不在协议本身,而在于很多人把CAN想得太简单:以为接两根线、配个波特率、调个发送函数就能跑通,结果一上车就报错帧,一上电就总线关闭。这活儿真正的门槛从来不在于"会不会写初始化",而在于对位时序、过滤器、邮箱机制、错误状态机这几块的理解深度。
STM32F10x 的 CAN 外设,官方叫法是 bxCAN(Basic Extended CAN)。它的特点是硬件的活儿干得相当多:报文收发自动完成、标识符过滤在硬件层就做掉了、三组发送邮箱可以连续排三条报文、两组接收FIFO各能缓存三条。这意味着 CPU 不需要像用 SPI 外挂 CAN 控制器那样一个字节一个字节地拼寄存器,中断响应也可以做得比较宽松。对绝大多数工业控制、车载辅助设备、电机驱动、电池管理这类场景来说,这套外设的配置够用且成本极低,一颗芯片就把主控和总线通信全包了。
但它的坑也很典型。第一,位时序参数的编码方式跟很多人脑子里想的"直接填数字"不一样,填错一位就通信不上,而且现场表现是"没有任何报错,就是收不到"。第二,过滤器组的ID排布方式很绕,标准帧ID要左移5位再塞进寄存器,这个细节几乎所有新手都会栽一次。第三,发送邮箱和接收FIFO都是有限资源,写代码时如果不管邮箱满不满、FIFO溢不溢出,跑个几小时就开始丢帧。第四,也是最要命的,这套外设有完整的错误计数器与状态机,一旦总线出问题会自己进 Bus-Off,而很多人根本不知道有这个状态,还在那儿查"为什么发不出去"。
这篇内容我打算按"硬件准备 → 位时序计算 → 寄存器与代码实现 → 现场排障"的顺序完整走一遍,中间把我自己踩过的坑、以及带人时看到的高频错误都摊开说。适合刚上手这套外设的开发者,也适合已经在用但被偶发丢帧折磨的人。代码基于标准外设库(StdPeriph)来写,HAL 的用户也能看懂,逻辑是通的,只是函数名换了。
1.1 bxCAN三邮箱双FIFO到底解决了什么实际问题
先说清楚资源模型,这决定了你后面代码怎么写。发送侧有三个邮箱,本质上是三个硬件缓冲区加一套仲裁逻辑。你把报文丢进邮箱,外设自己在总线空闲时按ID优先级抢占总线,成功发出后邮箱才空出来。这个机制好处是可以连排三条,写代码时不用每条都等发送完成。但它带来的一个副作用是:如果你一次性往三条邮箱里塞不同ID的报文,硬件会按ID数值从小到大发,而不是按你塞入的顺序发。我见过一个做多轴同步的项目,就是因为没注意到这点,以为时间戳能对齐,结果几路指令的实际发出顺序和预想的不一样,同步误差上来了。
接收侧有两组FIFO,各三条深度,配合14组可编程过滤器。过滤器的价值在于:总线上所有报文都会进来,但你可以让硬件只把关心的那几帧放进FIFO,其余的直接在硬件层丢弃,CPU 完全感知不到。这一点在总线负载高的时候非常关键——一条 500kbps 的总线,如果满载跑,每秒有几千帧,靠软件逐个判断ID会把CPU吃光。用过滤器组把有效ID筛出来,CPU的负担就只剩处理那几十帧了。
过滤器组的分配上有个原则:不要把14组全用满。实际上大多数项目两到三组就够了。剩下的留着做扩展,也方便调试期临时加一个"全通过"的过滤器观察总线。还有一点,过滤器组是全局资源,两个CAN控制器共享(对于双CAN的型号,比如互联型),这一点在同时用CAN1和CAN2时必须提前规划好编号分配,不能各写各的。
1.2 软件模拟方案和硬件外设方案的成本对比
有人会问,用普通IO配合定时器软件模拟CAN时序行不行。我的答案是:技术上能,工程上不建议,除非你的波特率极低、节点极少、且不要求实时性。软件模拟的难点在于位填充、CRC校验、错误帧的时序响应,尤其是仲裁失败后的重发时机,这些在硬件里都是自动的,软件做起来时序抖动大,稍微有点中断延迟就可能错过采样点。硬件外设则是把整个协议栈的物理层和部分数据链路层都固化掉了,你只需要配置和搬运数据。
从成本角度看,STM32F10x 自带CAN,省掉一颗外挂控制器和相应的晶振、外围电路,BOM成本和PCB面积都省了,这是它最大的优势。但代价是你要花时间啃手册——大约两三天的学习曲线,之后就是熟练活儿了。我个人的判断是:只要项目里CAN是主通信方式,用片内外设是唯一合理选择;如果只是偶尔发一帧状态,那用其他总线更省心。
2. 动手之前的硬件准备与避坑清单
软件跑不通的时候,有很大比例的问题其实出在板子上。我在带人过程中形成的习惯是:先不谈代码,先把万用表拿出来测三个东西——收发器供电、CAN_H/CAN_L的静态电平、终端电阻阻值。这三项过了,再谈软件配置。
收发器部分,板子上需要一个把逻辑电平转成差分信号的芯片,也就是常说的CAN收发器。供电一般是5V或3.3V,看型号而定,注意和MCU的IO电平匹配。3.3V的MCU连5V供电的收发器是没问题的,因为收发器的RXD输出电平通常跟随它的供电电压,如果收发器是5V供电、RXD输出5V电平,直接接3.3V的MCU引脚是有风险的,虽然很多引脚容忍5V,但长期工作不稳。稳妥做法是选3.3V供电的收发器型号,或者做电平转换。这一点在选型阶段就要定下来,别等焊完了才想起来。
TX和RX的连线也要小心。CAN控制器的TX是输出,接收发器的TXD(输入);控制器的RX是输入,接收发器的RXD(输出)。名字看起来是"TXD接TXD"还是"TXD接RXD",很多人在画板子的时候搞混,尤其是不同厂家的命名习惯不一致。判断方法很简单:找数据手册里的引脚类型标注,一个输出、一个输入,对着接就对了。
还有STB(待机)引脚,不少收发器有这个引脚,低电平有效工作,高电平进待机。如果设计时没接,靠内部下拉可能工作,但抗干扰能力差;更糟糕的是有些型号默认就是待机状态,一上电就不工作。我的习惯是把STB直接接GND,如果要低功耗控制,再用一个IO去拉,这样调试的时候能确定它一定是工作状态。
2.1 终端电阻的位置比阻值更容易出错
CAN总线是差分总线,两端各需要一个120Ω的终端电阻,并联之后在总线上形成60Ω的直流阻抗。这个规则人人都知道,但错误率极高。
最常见的错误是在每个节点上都焊一个120Ω。两三个节点的时候问题不明显,四五个节点之后总线负载变得很重,信号幅度被压下去,短距离还能勉强跑,长距离就开始出错误帧。我见过一个六节点的柜内总线,每个节点都自带120Ω,结果一加长到五米就开始随机丢帧,查了两天才发现是这个原因。
正确的做法是:整个总线只有物理上的最远两端各一个120Ω,中间节点全部不装。板子上如果要留位置,就留一个可选焊盘,标注清楚"仅线缆末端安装"。另外一个技巧是,调试阶段可以先只装一端的电阻,另一端用万用表测总阻抗,如果测出来是120Ω说明只有一端装了,如果是60Ω说明两端都装了,如果只有几十欧姆那就是装多了。
关于电阻功率,0805封装的1/10W在大多数场合够用,但如果是强干扰环境或者长距离,建议上1206。电阻的精度建议1%,因为两端电阻不匹配会产生共模噪声。
2.2 双绞、屏蔽与地线处理的实际取舍
CAN_H 和 CAN_L 必须双绞,这是硬性要求。绞距越密抗干扰越好,常见的是每米20到30绞。如果用普通排线或者散线,短距离(半米以内)勉强能通,但一旦有电机、变频器、继电器在附近动作,就是随机错误帧的开始。
屏蔽层怎么接是个老话题。我的实践是:屏蔽层单端接地,接在电源地或者机壳地上,只在一端接,不要两端都接。两端都接会形成地环路,反而把干扰引进来。如果现场电磁环境特别恶劣,可以在屏蔽层和地之间串一个几纳法的电容做高频泄放,但这是补救措施,不如一开始把布线走好。
地线方面,如果几个节点之间有较大的地电位差,差分信号共模电压会超标,表现就是通信不稳定、错误帧多。这种情况下要么加隔离收发器(带隔离电源的那种),要么用一个共模电感串在CAN_H和CAN_L上。共模电感选型注意它的额定电流要覆盖总线的直流偏置,一般几十毫安就够。我做过一个跨柜体的项目,两个柜子由不同的配电回路供电,地电位差测出来有接近两伏,不加共模电感时误码率极高,加上之后就跑得很稳。
2.3 上电自检:先用回环模式确认板子没焊错
板子焊完、程序还没上,我建议第一版代码就用回环模式(Loopback)跑一遍。回环模式下,控制器自己发的帧自己收,不依赖外部总线和收发器,可以验证控制器的初始化、过滤器、收发流程是否正常。这一步过了,说明MCU这边没问题。
然后再切到正常模式,接上收发器,用一台外部设备(或者另一块板子)做对发测试。顺序不要颠倒,如果一开始就用正常模式,通信不上你没法区分是MCU的问题还是硬件的问题,排查成本翻倍。
正常模式下的自检项:先量CAN_H和CAN_L对地的静态电平,收发器工作时两者都应在2.5V左右, recessive 状态下两者电压接近,dominant 时差值约2V。如果两个引脚对地都是0V,说明收发器没供电或者处于待机;如果都是5V或者都是3.3V,说明输出级有问题。这些测量非常快,两分钟能做完,但能省掉半天的瞎找。
3. 位时序参数怎么算才不会翻车
这是我觉得最值得单独拎出来讲的部分。位时序配错是"收不到数据"的头号原因,而且它不会报错,控制器会安安静静地等一个永远不会到来的正确波形。
先讲原理。CAN的一个位被切成若干个时间份额(Time Quantum,简称tq),一个位由三部分组成:同步段(固定1个tq)、时间段1(BS1)、时间段2(BS2)。采样点落在BS1和BS2的交界处。位时间 = 1 + BS1 + BS2 个tq。波特率 = CAN时钟 / (预分频 × 位时间的tq数)。
STM32F10x 的CAN时钟来自APB1总线。系统跑72MHz时,APB1通常是36MHz(2分频)。这个前提非常重要,如果你改过时钟树,比如用内部RC做时钟源跑到64MHz,那APB1就是32MHz,所有参数都得重算。我见过有人抄了一份36MHz下的配置,结果板子用的是内部时钟,波特率直接差了百分之十几,通信时好时坏。
具体到寄存器编码,有两个反直觉的地方需要记住。第一,预分频寄存器里填的值是"实际分频数减一",填3表示4分频。第二,BS1和BS2的编码值也是"实际tq数减一",填13表示这一段的长度是14个tq。这两处如果按字面值填,算出来的波特率会正好差一截。
3.1 一套固定位段时间、只调预分频的配置思路
我在项目里固定用一套位段时间:位时间总共18个tq,其中BS1占14个tq、BS2占3个tq,加上同步段1个,采样点在 (1+14)/18 = 83.3% 的位置。这套参数的好处是:任意波特率只需要改预分频一个数字,采样点位置恒定不变,代码里只有一个常量需要动,出错的概率大幅下降。
按APB1等于36MHz来算:
| 目标波特率 | 预分频寄存器值 | 实际分频 | 分频后时钟 | 位tq总数 | BS1段 | BS2段 | 采样点 |
|---|---|---|---|---|---|---|---|
| 1000 kbps | 1 | 2 | 18 MHz | 18 | 14 | 3 | 83.3% |
| 500 kbps | 3 | 4 | 9 MHz | 18 | 14 | 3 | 83.3% |
| 250 kbps | 7 | 8 | 4.5 MHz | 18 | 14 | 3 | 83.3% |
| 125 kbps | 15 | 16 | 2.25 MHz | 18 | 14 | 3 | 83.3% |
| 100 kbps | 19 | 20 | 1.8 MHz | 18 | 14 | 3 | 83.3% |
验算一下500kbps这行:36MHz ÷ 4 = 9MHz,9MHz ÷ 18 = 500kHz,对得上。100kbps:36MHz ÷ 20 = 1.8MHz,1.8MHz ÷ 18 = 100kHz,也对。预分频寄存器的取值范围是1到1024,上面这些都远在范围内。
采样点为什么定在83.3%而不是理论推荐的87.5%?因为在18个tq的框架下要精确做到87.5%需要15.75个tq,取整不划算。83.3%落在行业普遍认可的可接受区间内(一般75%到87.5%都能正常工作),而且由于所有波特率共用同一套位段,总线上的节点即使来自不同厂家、波特率相同,采样点也容易对齐。实际项目里这套参数跑过好几年,没出过问题。
如果你的APB1不是36MHz,比如是32MHz,那所有预分频值都要重算。以500kbps为例:32MHz ÷ 18 = 1.777MHz,再除以500k得到3.555,不是一个整数分频,这时候就得调整位tq总数。可以改成位时间16个tq(BS1取12、BS2取3),32MHz ÷ 16 = 2MHz,÷500k = 4,预分频填3。采样点 (1+12)/16 = 81.25%,也够用。所以结论是:先定APB1频率,再选一个能让分频结果落成整数的tq总数,最后微调采样点,顺序别反。
3.2 SJW取值与时钟容差的隐藏关系
再同步跳转宽度(SJW)经常被随手填个1了事,其实它有实际作用。SJW决定了一次重同步最多能补偿多少个tq的相位误差。如果两个节点的晶振有偏差,累积的相位漂移需要靠重同步来吸收,SJW太小就吸收不了,表现为长帧传输时偶尔出错。
我的经验值是:SJW取2个tq,配合上面83.3%的采样点,能容忍大约0.5%以内的时钟偏差(含温漂)。普通无源晶振的精度通常在±20ppm到±50ppm,温漂加老化后一般不会超过±100ppm,加上收发器的传播延迟抖动,2个tq的SJW有足够余量。
真正需要警惕的是用内部RC振荡器做时钟源的情况。内部RC的精度只有±1%左右,温漂能到±2%,两个都用内部RC的节点之间最大偏差可能接近4%,这个量级SJW再大也救不回来。所以只要涉及CAN通信,时钟源必须是外部晶振,这一条没有商量余地。我见过为了省两个元件用内部RC的板子,室温下通信正常,一进高温车间就间歇性报错,最后整批返工换晶振,省下的成本翻十倍赔回去。
另外一个隐蔽问题是时钟的稳定性。有些板子的晶振负载电容选得不对,起振偏慢或者频率有轻微偏差,用示波器看不出来,但通信跑久了会出问题。判断方法是连续跑几个小时的高负载传输,统计错误帧数量,如果错误帧随时间增长,就要怀疑时钟质量。
4. 从零写起:初始化、过滤器、收发全流程
代码部分我按标准外设库的写法来组织,因为这套库在F10x的项目里存量最大。整体顺序是:开时钟、配引脚、复位外设、配位时序、配过滤器、开中断、使能。这个顺序不要乱,尤其复位外设这一步很多人省掉,如果程序支持软复位或者之前跑过别的模式,不复位会带进残留状态。
引脚配置上有个细节:CAN_RX 建议配成上拉输入,不要配成浮空输入。原因是在收发器还没上电或者总线断开的时候,RX引脚悬空可能被干扰成随机电平,控制器会误以为收到了数据,产生噪声错误。加上内部上拉(大约40kΩ)能把它拉到确定的高电平(recessive),这是CAN的空闲态。CAN_TX 配成复用推挽输出,速度选50MHz足够,因为CAN最高才1Mbps。
时钟方面,CAN1挂在APB1上,使能对应位就行。如果你用的是带重映射的封装,比如某些100脚以下的型号CAN引脚不在PA11/PA12,那需要先开AFIO时钟、配置重映射寄存器,再配引脚。这一步漏了的话引脚就是普通IO,怎么调都不通。判断方法是量TX引脚,正常工作时它应该有一串高低跳变;如果一直是高电平或者一直是低,八成是重映射没配。
4.1 初始化函数的具体写法与每行参数的意图
void CAN1_Init_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; CAN_InitTypeDef CAN_InitStructure; CAN_FilterInitTypeDef CAN_FilterInitStructure; NVIC_InitTypeDef NVIC_InitStructure; /* 1. 时钟:GPIOA 走 APB2,CAN1 走 APB1 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); /* 2. 引脚:RX 上拉输入防悬空,TX 复用推挽 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* 3. 复位外设,清掉可能的残留状态 */ CAN_DeInit(CAN1); CAN_StructInit(&CAN_InitStructure); /* 4. 工作模式与自动特性 */ CAN_InitStructure.CAN_TTCM = DISABLE; /* 时间触发模式,不用 */ CAN_InitStructure.CAN_ABOM = ENABLE; /* 自动离线恢复,重要 */ CAN_InitStructure.CAN_AWUM = DISABLE; /* 自动唤醒,不用 */ CAN_InitStructure.CAN_NART = DISABLE; /* 关闭"只发一次",允许自动重传 */ CAN_InitStructure.CAN_RFLM = DISABLE; /* FIFO 溢出时覆盖最旧帧 */ CAN_InitStructure.CAN_TXFP = DISABLE; /* 发送按 ID 优先级而非入队顺序 */ CAN_InitStructure.CAN_Mode = CAN_Mode_Normal; /* 5. 位时序:18tq,BS1=14,BS2=3,采样点 83.3% */ CAN_InitStructure.CAN_SJW = CAN_SJW_2tq; CAN_InitStructure.CAN_BS1 = CAN_BS1_14tq; CAN_InitStructure.CAN_BS2 = CAN_BS2_3tq; CAN_InitStructure.CAN_Prescaler = 4; /* 500kbps @ APB1=36MHz */ CAN_Init(CAN1, &CAN_InitStructure); /* 6. 过滤器见下一节 */ /* 7. 中断使能见 4.3 节 */ }逐条解释几个容易被忽略的位。CAN_ABOM我强烈建议打开。它的作用是:一旦控制器进入离线状态(Bus-Off),在检测到总线上连续128次11个隐性位之后自动恢复到正常状态。如果不打开,控制器进了离线就永远出不来,只能靠软件检测后手动复位外设。调试阶段你可能会频繁制造错误帧,不打这个位会以为程序死了。生产环境也建议打开,因为现场干扰导致的偶发离线能自愈。
CAN_NART关掉意味着允许自动重传。这个在调试时很方便,发送失败硬件会自动重发;但在某些极端场景下,比如总线上一片混乱,自动重传会让发送邮箱一直不空出来,占着资源。如果你的应用能容忍丢帧且需要快速失败,可以考虑打开它。我一般保持关闭,靠超时机制兜底。
CAN_RFLM控制FIFO溢出时的行为。关掉表示新帧覆盖最旧的帧,打开表示新帧直接丢弃。对于实时性要求高的场景,我倾向于关掉(要最新的数据),但如果每帧都不能丢,就得打开并且配合足够深的软件缓冲。这个选择取决于你的业务语义,没有标准答案,但一定要想清楚。
CAN_TXFP控制发送顺序。关掉时硬件按ID优先级发送,打开时按入队顺序发送。前面提过多轴同步的坑就出在这里。如果业务上要求"先发的先出去",就把它打开;如果要求关键报文优先,就保持关闭。
4.2 过滤器配置:标准帧ID为什么必须左移5位
过滤器是新手最大的拦路虎。STM32F10x有14组过滤器(编号0到13),每组可以工作在32位或16位模式,每个模式下又分屏蔽位模式和标识符列表模式。听起来复杂,但常用的就两种:全通过(收所有帧)和精确匹配(只收指定ID)。
先说清楚寄存器的排列。32位模式下,一个过滤器由两个32位寄存器组成,分别叫 ID 和 MASK。ID里的内容是:STDID[10:0] 左移5位,然后接下来两位是 IDE 和 RTR,再往后是 EXID。标准帧ID只占11位,而寄存器里给它留的位置是从第5位开始的,所以必须左移5位。这就是"为什么"——不是随便定的偏移,是CAN帧格式里仲裁段的结构决定的:11位ID之后紧跟的是RTR位(远程帧标志),然后是IDE(扩展帧标志),硬件寄存器直接按这个排布映射。
用代码写清楚:
/* 只接收标准帧 ID = 0x123 的数据帧,其余全部过滤掉 */ CAN_FilterInitStructure.CAN_FilterNumber = 0; CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdMask; CAN_FilterInitStructure.CAN_FilterScale = CAN_FilterScale_32bit; CAN_FilterInitStructure.CAN_FilterIdHigh = (0x123 << 5) & 0xFFFF; CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000; /* 标准帧,不带扩展ID */ CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0xFFE0; /* 高11位全比较 */ CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0006; /* 比较 IDE 与 RTR */ CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_Filter_FIFO0; CAN_FilterInitStructure.CAN_FilterActivation = ENABLE; CAN_FilterInit(CAN1, &CAN_FilterInitStructure);MASK 的规则是:某一位写1表示"这一位必须和ID里的值一致",写0表示"这一位不关心"。所以高11位掩码全1(0xFFE0 覆盖了高16位中的第15到第5位,正好11位),就是要求ID完全匹配。低16位里的 0x0006 对应的是第2位(IDE)和第1位(RTR),把它们也纳入比较,就可以做到"只收标准数据帧,不收远程帧和扩展帧"。如果不想这么严格,把 MASK 低位改成 0x0000,就只比较ID,不管帧格式。
调试期我建议先配一组"全通过",用来确认硬件通路和总线上的实际报文:
CAN_FilterInitStructure.CAN_FilterNumber = 1; CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdMask; CAN_FilterInitStructure.CAN_FilterScale = CAN_FilterScale_32bit; CAN_FilterInitStructure.CAN_FilterIdHigh = 0x0000; CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_Filter_FIFO1; CAN_FilterInitStructure.CAN_FilterActivation = ENABLE; CAN_FilterInit(CAN1, &CAN_FilterInitStructure);掩码全0就是"什么都不比较",所有报文都进FIFO1。这样你可以把FIFO1的内容打印出来,看看总线上到底在跑什么ID、什么数据、速率多少,对于逆向对接别人的设备特别有用。有一次对接一个第三方控制器,文档写的ID和实际跑的不一致,就是靠这个方法十分钟定位的。
还有一个细节:过滤器组的编号和FIFO的绑定关系是每组建模的。多组过滤器可以绑到同一个FIFO,也可以分开。绑定到同一FIFO时,进入FIFO的报文按到达顺序排列,不区分来源组。这个特性在需要区分优先级时可以用:把关键报文绑到FIFO0、普通报文绑到FIFO1,中断里给FIFO0更高的处理优先级。
4.3 发送邮箱的选取与超时兜底
发送函数的关键在于两点:邮箱满了要能返回失败,发送失败了要能超时退出。前者防止程序在没邮箱时死等,后者防止在总线异常时无限阻塞。
/* 返回值:0 成功,1 无可用邮箱,2 发送超时 */ uint8_t CAN1_SendStdData(uint16_t stdId, uint8_t *buf, uint8_t len) { CanTxMsg TxMsg; uint8_t mailbox; uint32_t timeout = 0x000FFFFF; if (len > 8) len = 8; TxMsg.StdId = stdId & 0x7FF; /* 标准帧ID只有11位,超出的必须截掉 */ TxMsg.ExtId = 0x00000000; TxMsg.IDE = CAN_Id_Standard; TxMsg.RTR = CAN_RTR_Data; TxMsg.DLC = len; for (uint8_t i = 0; i < len; i++) TxMsg.Data[i] = buf[i]; mailbox = CAN_Transmit(CAN1, &TxMsg); if (mailbox == CAN_TxStatus_NoMailBox) return 1; /* 三个邮箱全占着,调用方需要稍后重试 */ while (CAN_TransmitStatus(CAN1, mailbox) == CAN_TxStatus_Failed) { if (--timeout == 0) { CAN_CancelTransmit(CAN1, mailbox); return 2; /* 超时,把邮箱抢回来,避免死锁 */ } } return 0; }这里有个容易被忽略的写法细节:CAN_TransmitStatus返回CAN_TxStatus_Failed表示发送还未完成或者失败,返回CAN_TxStatus_Ok才是成功。循环等待时一定要有超时,因为如果总线上没有其他节点给出应答(比如只有一块板子自己发),控制器会一直重传,邮箱永远不空。这就是为什么单板调试时如果不接第二个节点,发送函数会卡在那里——很多人第一次遇到这个现象会以为程序跑飞了。
超时值我用了 0x000FFFFF,在实际主频下大概是几十到上百毫秒量级。具体多少合适取决于你的业务:控制指令要求快速失败,可以调到更小;状态上报可以放宽。不建议用无限等待,也不建议用太小的值,因为总线上偶发的仲裁冲突会导致重传,太小会误判失败。
CAN_CancelTransmit这一步很重要。超时后如果不撤销,那个邮箱会一直被占着,发三次之后就没有可用邮箱了,整个通信瘫痪。撤销操作会尝试把报文从发送队列里拿掉,如果已经发出去了就无能为力(这时候它自然就空了),但至少保证了资源不会永久泄漏。
4.4 接收中断与环形缓冲区的搭建
接收中断的处理原则是:中断里只做搬运,不做解析。原因是一帧CAN报文进来,如果后面还有帧排队,中断处理时间长了就会溢出FIFO。FIFO只有3帧深度,按500kbps、每帧大约130微秒算,3帧不到400微秒,主循环里如果有耗时的活儿,中断处理稍慢一点就溢出。
所以标准做法是中断里把数据拷到一个软件环形缓冲区,主循环里再去处理。环形缓冲区的实现要点是读写指针的原子性。在单生产者(中断)单消费者(主循环)模型下,读指针只有主循环改、写指针只有中断改,天然的原子,不需要关中断保护。这个特性很值得利用,能省掉频繁的开关中断。
#define CAN_RB_SIZE 64 /* 必须是2的幂,方便用掩码取模 */ #define CAN_RB_MASK (CAN_RB_SIZE - 1) typedef struct { uint16_t id; uint8_t len; uint8_t data[8]; } CanFrame_t; static volatile CanFrame_t s_rb[CAN_RB_SIZE]; static volatile uint16_t s_wr = 0; static volatile uint16_t s_rd = 0; void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg rx; uint16_t next; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) != RESET) { CAN_Receive(CAN1, CAN_FIFO0, &rx); CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); next = (s_wr + 1) & CAN_RB_MASK; if (next != s_rd) /* 未满才写入,满了丢新帧 */ { s_rb[s_wr].id = rx.StdId; s_rb[s_wr].len = rx.DLC; for (uint8_t i = 0; i < rx.DLC && i < 8; i++) s_rb[s_wr].data[i] = rx.Data[i]; s_wr = next; } /* 满了的情况,可以在这里加一个计数用于诊断 */ } }主循环里取帧的逻辑就是标准的环形队列出队:判断s_rd != s_wr,取出、s_rd = (s_rd + 1) & CAN_RB_MASK。这个模型简单到没什么可调试的,出问题一般是忘了清中断标志,导致中断反复进入。注意上面代码里CAN_ClearITPendingBit的位置:在读完之后清,不要放在最前面。
中断向量名有个坑:F10x里CAN1的接收FIFO0中断和USB的低优先级中断共用同一个向量,名字是USB_LP_CAN1_RX0_IRQHandler。如果项目里同时用了USB,必须在里面判断是哪个外设触发的。如果只用了CAN,那直接往里写就行。这个共用关系在手册的中断向量表里写得很清楚,但很多人是在编译报"重复定义"的时候才发现的。
NVIC 的优先级配置也要注意。CAN接收中断建议给比较高的抢占优先级,但不要高到把所有中断都压住。如果系统里有时基中断(比如1ms的定时器),CAN中断的优先级要低于或者等于它,否则可能出现时序相关的问题。我一般把CAN接收中断设成中等偏上,具体数值看整个优先级分配表。
4.5 中断服务函数里必须处理的边界情况
有几个边界情况很隐蔽,值得单独列一下。
第一个是FIFO里其实有不止一帧。中断触发后,如果一次只读一帧,剩下的帧会继续触发中断,逻辑上没问题,但中断次数会变多。更好的做法是循环读,直到CAN_MessagePending返回0:
while (CAN_MessagePending(CAN1, CAN_FIFO0) > 0) { CAN_Receive(CAN1, CAN_FIFO0, &rx); /* 入队 */ }注意CAN_MessagePending的返回值是0到3,代表当前FIFO里待处理的帧数。这样一次中断能把积压的帧全部取走。
第二个是溢出标志。如果FIFO真的溢出了,外设有对应的溢出标志位。建议在中断里读一下并做计数,这个计数在调试期是判断系统是否吃紧的重要指标。如果这个计数一直增长,说明主循环处理太慢或者FIFO深度不够(深度改不了,只能优化处理速度或者增加软件缓冲深度)。
第三个是远程帧。如果总线上可能有远程帧(RTR位置1的帧),接收到的rx.RTR就是CAN_RTR_Remote,此时的DLC表示请求的数据长度,但Data里没有有效数据。如果你直接按数据帧解析,会读到垃圾值。处理方式是在中断里判断一下 RTR 位,或者干脆在过滤器里把它过滤掉。
5. 现场踩坑实录与排查速查表
做这行时间长了会发现,CAN的问题翻来覆去就那么几类。我整理了一份对照表,遇到现象先查表,比从代码开始翻效率高得多。
| 现象 | 高概率原因 | 快速验证方法 |
|---|---|---|
| 完全收不到任何帧 | 波特率不匹配 / 接线反了 / 终端电阻缺失或过多 | 用示波器看CAN_H与CAN_L波形,测位宽反推波特率 |
| 偶尔丢帧,错误帧计数增长 | 采样点偏差 / SJW太小 / 时钟精度不足 | 换外部晶振,检查位时序参数 |
| 发不出去,发送函数一直超时 | 总线上没有其他节点应答 / 收发器未工作 | 接第二块板子,测收发器供电与STB引脚电平 |
| 一上电就通信不上,复位后正常 | 收发器上电时序 / 初始化顺序问题 | 上电后延时100ms再初始化CAN |
| 运行几小时后通信中断 | 进入Bus-Off后未恢复 / 邮箱泄漏 | 检查ABOM位,读错误计数器ESR |
| 短距离正常,长线缆异常 | 终端电阻位置错误 / 未双绞 / 共模干扰 | 测总线直流阻抗应为60Ω左右 |
| 收到的ID和预期对不上 | 过滤器ID未左移5位 / 掩码位配错 | 用全通过过滤器打印实际ID |
5.1 用示波器反推波特率的实操方法
现场最有效的手段就是示波器。接在CAN_H和CAN_L之间(差分探头最好,单端探头就分别看两个通道再相减),抓一帧报文,量出单个位的时间宽度,倒过来就是波特率。
比如量到一位是2微秒,那波特率就是500kbps。量的时候注意找一段连续的相同电平,因为CAN有位填充机制,连续5个相同电平之后会插入一个反相位,那一位不属于数据位。找那种长短交错明显的段,量最短的那个单元比较准。
另外看波形幅度也能判断很多问题。正常dominant时差分电压在1.5V到3V之间,recessive时接近0V。如果幅度明显偏小,说明终端电阻装多了或者线缆阻抗不匹配;如果波形上有明显的振铃,说明终端电阻缺失或者分支太长。
5.2 Bus-Off状态与错误计数器的读法
这是我最想强调的一块,因为绝大多数"通信跑一段时间就断"的问题根源都在这里。
CAN控制器内部有两个错误计数器:发送错误计数(TEC)和接收错误计数(REC)。正常工作时两者都应该在0附近。每次发送出错,TEC加8;发送成功,TEC减1。接收侧类似,出错加8,成功减1。当TEC超过255,控制器进入Bus-Off状态,主动脱离总线,不再发送任何东西(包括错误帧),避免拖垮整个网络。
进入Bus-Off之后,如果CAN_ABOM打开了,硬件会自动尝试恢复;如果没打开,就必须软件介入。软件恢复的写法是:检测到进入离线状态后,清除初始化请求位,重新走一遍初始化,然后退出初始化模式。这个过程大约需要几百微秒,期间该节点是不通信的。
读错误计数器的代码如下:
uint8_t CAN1_GetErrCounters(uint8_t *tec, uint8_t *rec) { uint32_t esr = CAN1->ESR; *tec = (uint8_t)((esr >> 16) & 0xFF); /* TEC[7:0] */ *rec = (uint8_t)((esr >> 24) & 0xFF); /* REC[7:0] */ return (uint8_t)((esr >> 2) & 0x07); /* LEC[2:0] 最近一次错误类型 */ }LEC这个字段非常有用,它记录了最近一次错误的具体类型:位错误、格式错误、ACK错误、填充错误等。对着这个值去查,能直接定位到问题方向。ACK错误意味着发出去的帧没有任何节点应答,通常是总线上只有自己一个节点或者波特率不匹配;位错误意味着自己发的电平和总线上读到的不一致,通常是硬件问题或者两个节点同时发送冲突;填充错误一般是干扰或者波特率偏差。
我的习惯是在调试版本里每秒钟打印一次TEC和REC,跑压力测试的时候盯着看。如果TEC在0到几十之间波动,正常;如果一路涨到128以上(错误被动状态),说明链路质量已经很差了;涨到255就是Bus-Off。这个曲线比任何日志都直观。
5.3 干扰环境下的加固手段
工业现场的干扰源太多了,变频器、伺服、继电器、接触器动作,每一个都能在总线上留下痕迹。除了前面说的双绞、屏蔽、终端电阻,还有几个实操层面的加固手段。
第一是软件上的重发策略。既然硬件会重发,为什么还要软件重发?因为硬件重发在邮箱被撤销或者超时后会放弃。对于关键指令(比如急停、模式切换),我一般在外层再包一层确认机制:发出去之后等对方的应答帧,超时未应答就重发,重发三次失败就上报故障。这个机制的代价是要维护一个待确认队列,但可靠性提升明显。
第二是通信速率的取舍。抗干扰能力和波特率是反比关系,同样的线缆和布线,125kbps的稳定性远高于500kbps。如果业务允许,把速率降下来是最省钱的加固手段。有些项目为了"看起来先进"硬上1Mbps,实际线缆长度和布线都达不到要求,最后问题一堆。
第三是报文频率控制。总线负载率建议控制在30%以内,超过50%之后延迟抖动会明显变大,实时性就没法保证了。算负载率的公式是:每帧的位数(标准帧约111位,含填充和帧间隔)乘以每秒帧数,除以波特率。比如500kbps下,每秒发450帧标准帧,负载率就是 450×111÷500000 ≈ 10%。这个计算在方案设计阶段就要做,不要等到现场发现延迟超标再回头改协议。
6. 几个真正影响交付的个人经验
写到这里,前面讲的都是"怎么做",这一节说说"怎么做得让人放心"。
第一个经验是关于参数配置表。我现在的项目里,CAN的位时序参数、过滤器分配、报文ID表、周期、超时时间,全都写在一份单独的配置文件或者头文件里,不散落在各个模块。原因很简单:调试阶段一定会改参数,如果散落在十个地方,改漏一处就是几小时的排查。集中管理的另一个好处是,交接给别人的时候,对方看一个文件就能理解整个通信设计,不用满代码库找。
第二是关于模式切换的封装。回环模式、静默模式、正常模式这三种,我建议做一个统一的接口,通过一个函数参数切换,而不是在不同的地方写不同的初始化。调试的时候用回环自测,对接的时候用静默模式只听不发(观察别人的总线在跑什么),上线的时候切正常模式。静默模式特别有用,它能让你在不干扰现有网络的前提下摸清总线上所有报文,做协议对接时能省下大量时间。
第三是关于压测。很多项目在实验室跑得好好的,到现场第一天就出问题,原因是没有做过压力测试。我的做法是:用两块板子对发,一块满负载发送(把总线跑到50%以上),另一块接收并校验序号连续性,连续跑24小时,统计丢帧数和错误计数器曲线。这个测试能暴露绝大部分时序和缓冲问题。如果跑24小时零丢帧、错误计数器不增长,那基本可以放心交付了。
第四是关于代码分层。CAN的驱动层只负责"把一帧数据发出去"和"收一帧数据上来",不掺杂任何业务语义;业务层在驱动之上做协议解析。这个分层的好处是换协议的时候不用动驱动,也方便写单元测试。我见过把业务判断写在中断里的代码,改一个协议要动中断服务函数,风险极高。
最后分享一个调试时的习惯。我会在板子上留一个串口,专门输出CAN的统计信息:累计收发帧数、FIFO溢出次数、发送超时次数、TEC和REC当前值、最近一次错误类型。这五六个数字在正常运行时没什么用,一旦出问题,它们能在三十秒内告诉你问题在哪个方向。这个习惯是从一次深夜排障中学来的:当时没有这些数据,只能靠猜,猜了六个小时;后来加上统计,同类问题十分钟定位。诊断信息的成本很低,收益却很高,值得在每个项目里都做进去。