ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103 LIN总线示例代码详解:从硬件适配到避坑实践

STM32F103 LIN总线示例代码详解:从硬件适配到避坑实践

简介:基于STM32F103的LIN总线通信示例工程,面向嵌入式开发者和车载总线学习者,适合用来快速理解LIN协议的中断驱动实现。代码在USART3中断服务程序中完成总线事件处理:先读取同步标志和标识符标志,标识符匹配后再组织8字节数据并计算CRC发送,整个收发链路基于TPIC1021AQDRQ1收发器、波特率19200bps,能够直接展示从同步场识别到数据校验的完整流程。资源包共38个文件,以18个h头文件与11个c源文件为主体,包含IAR工程配置文件、icf链接脚本、启动文件、HAL驱动库和README说明,目录按CMSIS与STM32F1xx_HAL_Driver分层,便于对照阅读和重新编译。压缩包仅226KB,轻量完整,适合在现有STM32F103工程中移植LIN总线驱动,或参考其中的中断服务程序与CRC算法。已有1995人学习这份代码,对需要快速搭建LIN验证环境或深入熟悉USART中断处理的人来说是实用的参考资料。

1. 说句实话:STM32F103的LINBUS示例代码到底能省你多少事

做车身控制或者终端节点的人,基本绕不开LIN总线。这东西成本低、抗干扰还行,一个主节点挂十几个从节点,控制车窗、后视镜、灯光、座椅电机,比CAN便宜太多。但真有活要干的时候你会发现,LIN并不是“串口加一根线”那么简单。break的发送与检测、同步字段、受保护ID、两种校验和、从节点的休眠唤醒,每一处都能让你从下午调到大半夜。我最早用STM32F103做LIN从节点时,被break检测折腾了好几天,后来拿到一份完整的LINBUS示例代码,很多坑直接被绕开了。这份示例代码基于标准外设库,把主从节点通信框架都搭好了,直接改引脚和ID就能跑。适合正在用F103做LIN从节点、或者刚拿到LIN收发器想快速验证硬件的工程师。今天这篇就拆开它,从硬件适配、工程结构到校验参数,把能复现的细节全部讲透。

2. LIN不是串口加一根线:帧结构、break检测与F1的硬件适配

2.1 F103凭什么能跑LIN:UART硬件特性与break机制

先拎清一个底层逻辑。STM32F103内部没有独立的LIN控制器,它的USART模块在设计时已经考虑了LIN模式。查看参考手册会发现,USART的发送器能主动拉低TX线并维持一个可配置的时间,这个低电平就是LIN总线的break信号,而降额计数可以精确控制break的位宽。接收端也有对应的break检测电路,如果RX线上检测到连续低电平超过设定阈值,就认为收到了break。整套机制在硬件层完成标记,不需要你用定时器去模拟。

帧结构方面,LIN总线上一帧由break、sync、受保护ID、数据字节和checksum组成。break是固定的低电平,sync是0x55,用于从节点同步波特率。F103的USART在RX端可以利用sync自动校准波特率吗?实际上F103没有真正的自动波特率检测,需要在主节点发送sync之前让从节点用固定波特率去接收。因此示例代码里通常把波特率预先配置为9600或者19200,从节点打开接收中断,收到break后进入帧解析状态。

uint8_t LIN_Frame[8] = {0}; volatile uint8_t LIN_State = LIN_STATE_IDLE; volatile uint8_t LIN_IdParity = 0;

// USART1中断处理:接收字节状态机 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t byte = (uint8_t)USART_ReceiveData(USART1); switch (LIN_State) { case LIN_STATE_IDLE: // 第一次收到字节说明break已经过去,这个字节是sync if (byte == 0x55) LIN_State = LIN_STATE_SYNC_OK; break; case LIN_STATE_SYNC_OK: LIN_IdParity = byte; LIN_State = LIN_STATE_DATA; break; case LIN_STATE_DATA: // 按帧长度收集数据 break; } } }

这段代码的思路是先利用中断逐字节接收,把状态机划分为sync、ID、数据。实际示例里会用DMA减少CPU占用,但中断方式更容易看清协议流程。注意F103的RX中断标志位是RXNE,必须读数据寄存器才能清除,否则会一直触发中断。写状态机时务必把break的硬件标志分开处理,USART的LBD(LIN Break Detection)中断是独立的,示例代码里会单独注册一个处理函数。

2.2 硬件连接与阻抗:不是一堆线怼上去就能通

LIN物理层要求一颗LIN收发器,常用型号多为TJA1020、MCP2003之类。这些收发器是真实存在的型号,但示例代码关心的是收发器与MCU的引脚连接。典型接法是:MCU的USART_TX接收发器的TXD,USART_RX接RXD,收发器的LIN_BUS引脚通过一个1kΩ电阻和二极管接到12V总线。收发器芯片本身需要一颗1μF的电容做电源滤波,以及在LIN_BUS上挂一个上拉电阻到12V,这个是总线节点必需的。

需要注意的是F103的TX和RX都是推挽输出,但LIN收发器的TXD通常要求开漏或者有下拉能力。有些示例为了省电会在进入休眠前把TXD置0。我在实际项目里遇到过一个问题:如果收发器的TXD和MCU的TX直连,而MCU复位期间TX引脚是高阻态,那么总线电平会被上拉电阻拉高,刚好是隐性电平,这是正确的。但如果MCU引脚配置成推挽输出且输出1,就会直接和收发器的内部处理冲突,导致break发不出来。所以示例代码里GPIO配置用的是开漏输出。这一点后面会展开说。

配置代码大致这样:

GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure;

// 打开USART1对应的GPIO时钟,默认引脚PA9/PA10 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE);

// TX引脚:开漏输出,因为LIN总线是高电平有效的隐性状态 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure);

// RX引脚:浮空输入或带上拉输入,取决于外部是否有上拉 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure);

// USART配置:波特率9600,8数据位,无校验,1停止位 USART_InitStructure.USART_BaudRate = 9600; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Tx | USART_Mode_Rx; USART_Init(USART1, &USART_InitStructure);

USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE);

这里把TX配置成开漏输出是示例代码里最关键的决定。如果用推挽,收发器TXD需要MCU强驱动,一旦总线上的负载较大,推挽反而会把隐性电平顶坏。开漏模式配合外部上拉(收发器内部一般有上拉)才是LIN标准推荐的接口形式。波特率9600对应USARTDIV是0x271,但从代码里看不到这个值,因为标准外设库的USART_Init会自动配置。如果自己写寄存器,注意USARTDIV公式里的小数部分和整数部分要分别处理。

3. 把示例代码跑起来:工程目录、引脚配置与第一个LIN帧

3.1 拿到压缩包之后:示例工程的结构先看清楚

许多下载下来的LINBUS示例代码,解压后会是一整套标准外设库工程文件夹。典型结构是:

  • Library/下的CMSIS和标准外设库源文件
  • User/下的main.c、stm32f10x_it.c
  • Hardware/下的lin.c、lin.h、uart.c、uart.h
  • Project/下的MDK-ARM工程文件

拿到资源后别急着编译,先把lin.c打开,看它封装的API。常见的接口有LIN_Init、LIN_SendBreak、LIN_SendFrame、LIN_ParseFrame。示例代码的main.c里通常会演示一个主节点周期发送帧、从节点中断接收并响应。你需要根据自己的节点角色把条件编译打开。

工程编译之前,需要确认标准外设库版本。F103的标准外设库从3.5版本之后就没有大更新,但有的示例代码是从老版本移植过来的,可能缺少RCC_APB2Periph_AFIO之类的时钟。如果编译报错类似undefined symbol: GPIO_PinRemapConfig,多半是库版本和GPIO配置调用不匹配。检查system_stm32f10x.c里的时钟树配置,默认是72MHz主频。如果主频不是72MHz,USART波特率会算错,这是很隐蔽的一个坑。

3.2 引脚不够用怎么办:重映射配置

F103的USART1是PA9/PA10,USART2是PA2/PA3,USART3是PB10/PB11。示例代码默认用的是USART1,但实际你的板子可能把LIN收发器接到了别的引脚。需要重映射时,开启AFIO时钟并调用GPIO_PinRemapConfig。比如把USART3重映射到PC10/PC11:

RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO | RCC_APB2Periph_GPIOC, ENABLE); GPIO_PinRemapConfig(GPIO_Remap_USART3, ENABLE);

这段代码里的GPIO_Remap_USART3是库自带的宏,对应的寄存器位在AFIO_MAPR中。重映射之后,原有的PB10/PB11就释放了。注意如果你还用到了其他外设比如SPI、I2C的重映射,需要检查它们是否共用AFIO_MAPR的位段。示例代码只做LIN,所以冲突概率不大。

另外LIN的break发送看的是USART发送器。F103标准库里发送break的函数不是直接的,需要操作寄存器。我习惯写一个底层函数,直接在发送引脚上拉低并且延时大于13个位时间,这样最简单:

void LIN_SendBreak(void) { // 发送break:拉低TX线至少13个位时间 USART_SendBreak(USART1); // USART_SendBreak在F103的库中会发出一个空闲帧长度的低电平 // 具体长度由USART的BDL位和时钟决定,对于9600波特率约13.8个位 }

标准外设库提供USART_SendBreak函数,但底层的时序细节还是需要验证。有些示例代码干脆自己用GPIO手动拉低TX引脚,这样的好处是可以精确控制break长度为“低电平超过11位时间”,坏处是收发器可能已经把TX方向控制了。我更信任USART_SendBreak,因为它是硬件层面直接控制。

3.3 发送第一个完整帧:主节点代码实战

主节点发送一个包含两个数据字节的帧,代码流程如下:

void LIN_Master_SendFrame(uint8_t id, uint8_t* data, uint8_t len) { uint8_t pid = LIN_ComputePID(id); // 计算受保护ID uint8_t checksum = LIN_ComputeChecksum(id, data, len);

LIN_SendBreak(); USART_SendData(USART1, 0x55); // sync字段 while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, pid); // 受保护ID while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); for (uint8_t i = 0; i < len; i++) { USART_SendData(USART1, data[i]); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); } USART_SendData(USART1, checksum); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);

}

这段代码看起来简单,但有一个关键点:LIN_SendBreak之后需要等待break发送完成,否则紧接着发送sync时,总线上的低电平可能还没恢复。标准外设库的USART_SendBreak会自动处理,但保险起见,可以在后面加一个微秒级延时。我一般加delay_us(100),确保break结束后总线回到隐性高电平。另一种方式是通过USART_FLAG_LBD标志位判断break发送完成,但F103的LBD标志是接收端标志,不是发送端。

参数方面,id范围是0x00到0x3F,PID是通过线性移位计算的,这部分下一章详细讲。数据长度len在LIN协议里可以是2、4、8,具体看帧类型。示例代码通常把len写死成sizeof数组,修改时注意对齐。

4. 帧头帧尾与校验位:主从节点参数怎么设才不会翻车

4.1 受保护ID的计算:P0和P1那两位的来历

LIN总线的ID只有6位有效,高两位是有用户可选的奇偶校验位。计算方式是:把ID的bit6和bit7分别当作P1和P0,而P1、P0由bit0到bit5按特定布尔表达式得出。公式为:

P0 = ID0 ^ ID1 ^ ID2 ^ ID4 P1 = ~(ID1 ^ ID3 ^ ID4 ^ ID5)

标准的C实现如下:

uint8_t LIN_ComputePID(uint8_t id) { uint8_t p0 = ((id >> 0) & 1) ^ ((id >> 1) & 1) ^ ((id >> 2) & 1) ^ ((id >> 4) & 1); uint8_t p1 = ~(((id >> 1) & 1) ^ ((id >> 3) & 1) ^ ((id >> 4) & 1) ^ ((id >> 5) & 1)) & 1; return (uint8_t)((id & 0x3F) | (p1 << 6) | (p0 << 7)); }

注意p1表达式里的~运算一定要和&1结合,否则会出现全部位取反的bug。示例代码里有可能把p1写成((id >> 1) ^ (id >> 3) ^ (id >> 4) ^ (id >> 5)) & 1,然后整体取反,这就错了。正确做法是先求异或,再取反,再掩码一位。这个坑我在两个项目里都踩过,现象是同一帧ID的PID有时候对有时候不对,因为异或结果本身不是0就是1,取反后在低字节上是0xFE,掩码后变成0,看似没变,但实际逻辑错了。

受保护ID在总线上传输时,不仅用来寻址,还同时判断帧是主机命令还是从机响应。如果从节点收到PID后回传数据,那么数据段的校验范围会包括这个PID,这取决于协议版本。

4.2 经典校验和增强校验:千万别混用

LIN协议有两个版本,经典校验和只覆盖数据字节,增强校验覆盖受保护ID和数据字节。LIN1.3从节点用经典校验,LIN2.0以上从节点用增强校验。同一个总线上的主节点必须和所有从节点约定同一个校验方式,否则响应的帧会被无情丢弃。

代码里常见两种校验函数:

uint8_t LIN_Checksum_Classic(uint8_t* data, uint8_t len) { uint16_t sum = 0; for (uint8_t i = 0; i < len; i++) sum += data[i]; while (sum >> 8) sum = (sum & 0xFF) + (sum >> 8); return (uint8_t)(~sum); }

uint8_t LIN_Checksum_Enhanced(uint8_t pid, uint8_t* data, uint8_t len) { uint16_t sum = 0; sum += pid; for (uint8_t i = 0; i < len; i++) sum += data[i]; while (sum >> 8) sum = (sum & 0xFF) + (sum >> 8); return (uint8_t)(~sum); }

经典校验和函数的while循环是对进位不断累加,直到高字节为0。增强校验则在初始sum里多加了pid。很多人把经典校验和套到2.0总线上,然后发现从节点永远不回复,查了几天才发现是校验和类型不对。示例代码如果在lin.h里定义了LIN_CHECKSUM_ENHANCED宏,那么你在移植到老节点时要格外小心。

4.3 波特率容差和采样点:为什么9600有时会收错

LIN协议规定从节点不需要精确的晶振,允许15%的波特率误差,但主节点必须用±1%精度的时钟。F103用外部8MHz晶振倍频到72MHz,实际波特率误差可以控制在0.5%以内,作为主节点完全够。从节点如果用内部RC时钟,误差可能到3%以上,这时就要看USART的同步字段是否起作用。

F103的USART没有硬件自动波特率检测,所以从节点必须在收到sync之后自己测得实际波特率,再去重载USARTDIV。示例代码里会有一个测量sync字段占用的时间并换算波特率的状态机,这里我不建议轻易改动它的定时器部分。测量窗口是5个字节的位时间,如果定时器不是16位,注意溢出。我曾经把定时器预分频配错,导致测得的波特率是实际值的两倍,整个总线完全瘫痪。

主节点发送时,从节点还要求采样点位置合理。当数据位为8位、停止位为1位时,USART默认在中点采样,即采样点在位时间的50%处。对于LIN总线,理想采样点在75%以上。F103的USART可以通过配置USART_InitStructure.USART_StopBits = USART_StopBits_0_5之类的位数来改变时长吗?不能。F103的采样点位置固定,所以实际误差容忍度取决于收发器芯片。如果你用了便宜的收发器,建议波特率不要超过19200。这是我从几个现场调试总结出来的经验。

5. 避坑清单:我调LIN总线时踩过的五个实实在在的坑

5.1 收不到任何帧,总线一直是高电平

现象:示波器看LIN_BUS引脚,只有12V的静态电平,没有break出来。用万用表量MCU的TX引脚,发现是低电平但总线不反映。

原因:TX引脚配置成了推挽输出,开的时候直接拉高到3.3V,而LIN总线的隐性电平是12V,收发器内部的上拉根本抬不上去,收发器检测不到正常的隐性状态,所以拒绝发送任何东西。

解决:把TX改成开漏输出,并确保收发器的TXD有上拉能力。如果收发器芯片的TXD是纯输入,没有内部上拉,那就需要在MCU和TXD之间加一个10kΩ上拉到3.3V或5V。改完配置后重新上电验证。

5.2 能收到ID但数据全错位

现象:逻辑分析仪抓到的字节流长度对,但每个字节的bit顺序错乱,或者每次错误的bit都不一样。

原因:F103的USART在接收时如果检测到break后马上进入数据接收,但break的长度可能影响了后续第一个字节的判断。尤其是break时间太长,超过一个字节的长度,导致RX端把break的末尾误判为起始位。

解决:把break检测标志位作为状态机的重置条件。在USART_GETFLAGSTATUS(USART1, USART_FLAG_LBD)置位后,清掉接收缓存并丢弃当前正在累积的字节。示例代码里通常会在状态机的开头做这个清空动作。

5.3 从节点休眠后被唤醒,但收到的第一帧总是丢失

现象:总线唤醒后,主节点发break,从节点中断唤醒,但第一个数据字节同步过程中出错,丢帧。

原因:从节点从休眠唤醒到时钟稳定需要时间,特别是使用外部晶振的F103,唤醒后的HSI切换到PLL需要几百微秒。而主节点在唤醒后可能立刻发break,从节点的USART时钟还没建立,自然收不到。

解决:主节点在唤醒后必须先发一个唤醒脉冲,然后延时至少100ms再发break。从节点这边,把唤醒中断只做系统时钟重新初始化,不要直接进LIN接收状态机。示例代码如果没有这段延时,在上电唤醒时会偶发丢帧,这是硬件特性,不是代码bug。

5.4 校验和明明对了,从节点还是反馈超时

现象:从节点能正确解析数据,但在回传响应时主节点报超时。单独给从节点发诊断帧,从节点也不回。

原因:从节点的响应帧发送和接收共用一个USART,但主节点在发送完帧头之后,还在等待TXE标志变为空闲,没有及时把USART的方向切换到接收。F103是全双工,不会真的切换方向,但是发送和接收共用一个中断入口,如果你在发送数据过程中没有清掉挂起的TXE中断,接收中断会被延后。

解决:发送完最后一个字节后,立刻调用USART_ClearITPendingBit(USART1, USART_IT_TXE),并确保发送中断没有一直开启。示例代码里如果把TXE中断和RXNE中断同时打开,需要在发送帧期间暂时失能TXE中断。这是新手最容易漏掉的细节。

5.5 LIN_SendBreak后马上发sync,sync被吞

现象:逻辑分析仪看到break后没有sync,直接来的是下一个字节的数据。

原因:USART_SendBreak函数触发的是“发送低电平并持续一个帧时间”的动作,但实际上break的结束由硬件自动处理,但紧接着写数据寄存器时,发送移位寄存器可能还保持在break状态。硬件认为break没有结束,把新数据当成break延长的一部分。

解决:在break发送完成后等待一个位时间。判断方法是读取USART_FLAG_TC,如果TC为0,说明还未完全发送完成。需要等到TC变为1再写sync。我通常的做法是:

USART_SendBreak(USART1); while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); USART_SendData(USART1, 0x55);

注意TC标志在break后能否置位取决于库的实现,如果始终不置位,那就用延时100µs替代。这个坑在不同标准外设库版本上表现不一样,建议在拿到示例代码后先做一次本机测试,用示波器确认时序。

6. 进阶验证:逻辑分析仪抓波形,用最小帧把时序看得明明白白

如果你手头有逻辑分析仪,验证LIN时序是最直接的手段。不用买昂贵的协议分析仪,普通的24MHz采样率的逻辑分析仪就能看清9600波特率的波形。设置采样率为4MHz,触发方式选下降沿,然后把总线接到CH1上。抓一个完整帧之后,把视野拉大,先看break是不是明显长于后面的字节起始位。

我习惯先抓主节点发出的帧,然后从节点不发数据,只分析帧头。此时波形应该是:一个宽的低电平(大于11个位时间),随后一个高电平回到隐性,接下来是0x55的起始位。用逻辑分析仪的“串口解码”功能,把波特率设成9600,数据位8位,校验位无,停止位1位。如果解码出来的第一字节是0x00或者乱码,说明break之后的总线电平有问题。如果第一字节是0x55,第二字节是正确的PID,那帧头基本没问题。

验证增强校验和时,可以用逻辑分析仪统计帧尾的checksum字节,抄下来和代码计算比对。有几次我以为是收发器的问题,最后发现就是从节点的校验范围算错了。从那以后,我每改一次LIN程序,都强制走一遍抓波形的流程,先看break长度,再看sync字节,最后校验checksum,三步过了才敢把程序烧到产线上。这个过程看着土,但真的能救回一整天的时间,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进