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凭什么能跑LINUART硬件特性与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的LBDLIN Break Detection中断是独立的示例代码里会单独注册一个处理函数。2.2 硬件连接与阻抗不是一堆线怼上去就能通LIN物理层要求一颗LIN收发器常用型号多为TJA1020、MCP2003之类。这些收发器是真实存在的型号但示例代码关心的是收发器与MCU的引脚连接。典型接法是MCU的USART_TX接收发器的TXDUSART_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配置波特率96008数据位无校验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.cHardware/下的lin.c、lin.h、uart.c、uart.hProject/下的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主频。如果主频不是72MHzUSART波特率会算错这是很隐蔽的一个坑。3.2 引脚不够用怎么办重映射配置F103的USART1是PA9/PA10USART2是PA2/PA3USART3是PB10/PB11。示例代码默认用的是USART1但实际你的板子可能把LIN收发器接到了别的引脚。需要重映射时开启AFIO时钟并调用GPIO_PinRemapConfig。比如把USART3重映射到PC10/PC11RCC_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到0x3FPID是通过线性移位计算的这部分下一章详细讲。数据长度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后马上发syncsync被吞现象逻辑分析仪看到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三步过了才敢把程序烧到产线上。这个过程看着土但真的能救回一整天的时间希望帮到你。本文还有配套的精品资源点击获取