简介:这份资源面向嵌入式开发初学者与汽车电子方向工程师,提供在STM32F103C8上实现LIN总线主机与从机通信的完整工程代码。LIN作为低成本低速串行协议,常用于车身传感器与执行器互联,资源围绕USART的LIN模式配置、时钟同步中断、帧结构与CRC校验、主机调度与从机响应、错误检测等核心环节展开,适合作为协议入门与项目移植的参考。压缩包共111个文件,约2.84MB,包含c与h源码、s启动文件、uvprojx与uvoptx工程配置、axf与hex可执行产物、map与lst编译映射文件及crf等中间文件,覆盖从源码到烧录的完整链路。目前已有3769人学习下载。读者可据此理解LIN帧收发流程,掌握中断服务例程与定时器调度写法,并在此基础上扩展从机节点或调整报文ID,提升嵌入式通信调试能力。
1. stm32 LIN程序:从节点通信为什么总在第一个字节就翻车
如果你手上有两块 STM32 板子,一块做主机、一块做从机,想用 LIN 总线跑通一次车门模块或座椅控制那种主从问答,大概率会卡在同一个地方:主机发完帧头,从机没反应,示波器上波形看着挺正常,逻辑分析仪却告诉你从机根本没回。这不是玄学,是 LIN 的帧结构和 UART 差得太远,很多人拿串口那套思维直接套,第一步就错了。
LIN 是单线、主从、低成本的车上子总线,典型速率 19.2 kbps,一根线加一个上拉就能挂一堆节点。STM32 这边常见做法是用 USART 的 LIN 模式(部分型号叫 LIN 模式,靠 USART_CR2 的 LINEN 位打开),配合 break 检测和同步场做自动波特率。它能解决的就是「用最便宜的 MCU 把几个执行器连起来」这件事,适合做车身小节点、传感器汇聚、执行器控制的工程师。下面按我实际调通的顺序讲,从帧结构到寄存器配置,再到踩过的坑。
2. LIN 帧到底长什么样:主机任务和从机任务的分工
2.1 帧头加响应,两段拼成一帧
LIN 的一帧分两段:帧头(Header)和响应(Response)。帧头永远由主机发,包含 break、同步场(0x55)、受保护的 ID(PID)。响应由主机或从机发,包含 1~8 字节数据和校验和。关键点在于:主机发完帧头后,总线方向要切换,谁发响应由 PID 里的 ID 决定,不是主机想发就发。
break 是至少 13 个显性位(逻辑 0)后跟一个隐性位,用来告诉所有从机「新帧开始了」。同步场固定 0x55,从机用它测量位时间,自动校准波特率。PID 是 6 位 ID 加 2 位奇偶校验,从机收到后判断这帧跟自己有没有关系。
我一般把主机任务和从机任务分开写:主机负责调度表(schedule table),按顺序发帧头;从机只做两件事——收到匹配 PID 就填数据或读数据。这个分工想清楚了,代码结构就不会乱。
2.2 校验和两种算法,选错就整帧丢弃
LIN 1.x 用经典校验和,只对数据字节求和取反;LIN 2.x 用增强校验和,把 PID 也加进去。诊断帧(ID 0x3C 和 0x3D)必须用经典校验和,普通帧用增强。很多从机不回应,就是因为主机用增强、从机按经典算,校验不过直接丢。
// 增强校验和:PID + 数据字节,累加后取反 uint8_t lin_checksum_enhanced(uint8_t pid, uint8_t *data, uint8_t len) { uint16_t sum = pid; for (uint8_t i = 0; i < len; i++) { sum += data[i]; if (sum > 0xFF) sum -= 0xFF; // 进位回卷,不是简单截断 } return (uint8_t)(~sum); } // 经典校验和:只累加数据字节 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]; if (sum > 0xFF) sum -= 0xFF; } return (uint8_t)(~sum); }逻辑说明:进位回卷是 LIN 校验和的规定动作,不是普通 uint8 溢出。参数 len 是数据长度,范围 1~8。诊断帧调用 classic,其余调用 enhanced。这个函数建议放在公共头文件里,主机从机共用,避免两边算法不一致。
2.3 PID 的奇偶校验怎么算
PID 低 6 位是 ID,高 2 位是 P0 和 P1。P0 是 ID0、ID1、ID2、ID4 的异或,P1 是 ID1、ID3、ID4、ID5 的异或。算错的话从机永远匹配不上。
uint8_t lin_make_pid(uint8_t id) { uint8_t id6 = id & 0x3F; uint8_t p0 = ((id6 >> 0) ^ (id6 >> 1) ^ (id6 >> 2) ^ (id6 >> 4)) & 0x01; uint8_t p1 = ((id6 >> 1) ^ (id6 >> 3) ^ (id6 >> 4) ^ (id6 >> 5)) & 0x01; return id6 | (p0 << 6) | (p1 << 7); }参数 id 是 0~63 的逻辑 ID,返回带校验的 PID 字节。主机发帧头时用这个值,从机收到后先反算 ID 再比对。我见过有人直接把 ID 当 PID 发,从机当然不理。
3. 用 STM32 USART LIN 模式跑通主机发送的最小配置
3.1 时钟和引脚:先确认 USART 挂在哪条总线上
以常见的 STM32F1 系列为例,USART2 挂 APB1,USART1 挂 APB2,开时钟的宏不一样。引脚方面,TX 接 LIN 收发器(或直接接上拉电阻做单线),RX 接回总线做回读。我一般用 PA2/PA3 做 USART2,配置成复用推挽。
// 使能时钟:USART2 在 APB1,GPIOA 在 APB2 RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; RCC->APB1ENR |= RCC_APB1ENR_USART2EN; // PA2 复用推挽 50MHz,PA3 浮空输入 GPIOA->CRL &= ~(0xFF << 8); GPIOA->CRL |= (0x0B << 8) | (0x04 << 12);逻辑说明:CRL 每 4 位管一个引脚,PA2 是 bit8~11,PA3 是 bit12~15。0x0B 是复用推挽输出 50MHz,0x04 是浮空输入。参数按自己板子的引脚改,别照抄。
3.2 波特率和 LIN 模式:19.2 kbps 下 BRR 怎么算
LIN 标准速率 19.2 kbps,但实际项目里 9.6 k、10.4 k 也常见。BRR 的值是 fPCLK / baud。假设 PCLK1 是 36 MHz,19200 对应 1875,即 0x753。
// 36MHz / 19200 = 1875 = 0x753 USART2->BRR = 0x753; // 开 LIN 模式,8 数据位,1 停止位 USART2->CR2 |= USART_CR2_LINEN; USART2->CR1 = USART_CR1_TE | USART_CR1_RE | USART_CR1_UE;逻辑说明:LINEN 打开后,USART 能识别 break 并支持自动波特率。TE 和 RE 同时开,因为主机发完帧头要切接收等响应。注意有些型号 LIN 模式对停止位有要求,配错会收到 framing error。
3.3 发 break 和同步场:别用普通 send 函数
break 不是发一个 0x00,而是拉低超过一个帧时间。STM32 的 LIN 模式提供 SBK 位(Send Break),置位后硬件自动发 break。
void lin_send_break(USART_TypeDef *u) { u->CR1 |= USART_CR1_SBK; // 请求发 break while (!(u->SR & USART_SR_TXE)); // 等发送空 while (!(u->SR & USART_SR_TC)); // 等发送完 } void lin_send_header(USART_TypeDef *u, uint8_t pid) { lin_send_break(u); u->DR = 0x55; // 同步场 while (!(u->SR & USART_SR_TC)); u->DR = pid; // PID while (!(u->SR & USART_SR_TC)); }逻辑说明:SBK 置位后硬件发 13 个显性位,发完自动清零。同步场固定 0x55,PID 用前面算好的值。参数 u 是 USART 实例指针。发完帧头后,如果是主机发响应,直接接着写数据;如果是从机发,主机要切接收并等超时。
3.4 主机发响应的完整一帧
void lin_master_send_frame(USART_TypeDef *u, uint8_t id, uint8_t *data, uint8_t len) { uint8_t pid = lin_make_pid(id); lin_send_header(u, pid); for (uint8_t i = 0; i < len; i++) { u->DR = data[i]; while (!(u->SR & USART_SR_TC)); } u->DR = lin_checksum_enhanced(pid, data, len); while (!(u->SR & USART_SR_TC)); }逻辑说明:这是主机既发帧头又发响应的场景,比如主机控制一个执行器。参数 id 是逻辑 ID,data 是数据指针,len 是长度。校验和用增强版。如果是从机发响应,主机这段只发帧头,然后开接收中断等数据。
4. 从机接收与响应:中断里怎么判断帧头和超时
4.1 从机状态机:break、同步场、PID 三步走
从机不能靠一个接收中断搞定,要按状态机走。收到 break 进状态 0,收到 0x55 进状态 1,收到 PID 进状态 2 并判断是否匹配。
typedef enum { LIN_WAIT_BREAK, LIN_WAIT_SYNC, LIN_WAIT_PID, LIN_RECV_DATA } lin_state_t; static lin_state_t s_state = LIN_WAIT_BREAK; static uint8_t s_pid, s_data[8], s_idx, s_len; void USART2_IRQHandler(void) { if (USART2->SR & USART_SR_RXNE) { uint8_t b = USART2->DR; switch (s_state) { case LIN_WAIT_BREAK: if (b == 0x00) s_state = LIN_WAIT_SYNC; // break 后第一个字节 break; case LIN_WAIT_SYNC: if (b == 0x55) s_state = LIN_WAIT_PID; else s_state = LIN_WAIT_BREAK; break; case LIN_WAIT_PID: s_pid = b; if (lin_pid_match(b)) { s_idx = 0; s_state = LIN_RECV_DATA; } else s_state = LIN_WAIT_BREAK; break; case LIN_RECV_DATA: s_data[s_idx++] = b; if (s_idx >= s_len + 1) { // 数据加校验和 lin_process_frame(s_pid, s_data, s_len); s_state = LIN_WAIT_BREAK; } break; } } }逻辑说明:break 在接收端表现为一个 0x00 字节加 framing error,所以状态机用 0x00 做触发。参数 s_len 要根据 PID 对应的长度提前设好,不同 ID 长度可能不同。这个状态机在 19.2 k 下跑中断完全够用。
4.2 超时处理:响应等不到怎么办
主机发完帧头后等响应,不能死等。我一般用定时器做超时,帧时间约 1.4 倍标称值。19.2 k 下一字节约 0.52 ms,8 字节加校验约 5 ms,超时设 10 ms 比较稳。
// 主机等响应超时,返回 0 表示超时 uint8_t lin_master_wait_response(USART_TypeDef *u, uint8_t *buf, uint8_t len, uint32_t timeout_ms) { uint32_t start = get_tick_ms(); uint8_t idx = 0; while (idx < len) { if (get_tick_ms() - start > timeout_ms) return 0; if (u->SR & USART_SR_RXNE) { buf[idx++] = u->DR; start = get_tick_ms(); // 收到字节重置超时 } } return 1; }逻辑说明:每收到一个字节重置超时,避免长帧被误判。参数 timeout_ms 按波特率和帧长算。返回 0 时主机要记录错误,不能直接卡死。
4.3 调度表:主机怎么按周期发不同帧
真实项目里主机不是只发一帧,而是按调度表轮询。我一般用一个数组存 ID、周期、数据指针,主循环里按时间片触发。
typedef struct { uint8_t id; uint16_t period_ms; uint8_t len; uint8_t data[8]; uint32_t last_tx; } lin_slot_t; lin_slot_t g_schedule[] = { {0x10, 10, 2, {0}, 0}, // 10ms 周期 {0x11, 50, 4, {0}, 0}, // 50ms 周期 }; void lin_schedule_run(void) { uint32_t now = get_tick_ms(); for (uint8_t i = 0; i < sizeof(g_schedule)/sizeof(g_schedule[0]); i++) { if (now - g_schedule[i].last_tx >= g_schedule[i].period_ms) { lin_master_send_frame(USART2, g_schedule[i].id, g_schedule[i].data, g_schedule[i].len); g_schedule[i].last_tx = now; } } }逻辑说明:period_ms 是调度周期,last_tx 记录上次发送时刻。参数按项目需求改,注意周期不能小于帧传输时间,否则总线会冲突。
5. 避坑与排查:LIN 调不通时先看这五条
5.1 从机不回应,示波器上帧头正常
现象:主机发完 break、0x55、PID,从机毫无动静。原因:从机 PID 匹配函数写错,或者校验和算法不一致。解决:先用逻辑分析仪抓从机 RX 引脚,确认它收到了完整帧头;再单步调试 lin_pid_match,把收到的 PID 和算出来的比对。我遇到过 P0/P1 异或顺序写反,查了半天。
5.2 收到数据但校验和总不过
现象:从机进了接收状态,数据也对,最后校验失败。原因:主机用增强校验和,从机用经典,或者进位回卷写成了简单截断。解决:统一校验和函数,诊断帧单独处理。进位回卷那行if (sum > 0xFF) sum -= 0xFF;不能省。
5.3 波特率偏差导致同步场识别失败
现象:从机偶尔能收到,偶尔丢帧。原因:主机从机时钟源不同,波特率偏差超过 2%。解决:从机用同步场自动校准,或者两边都用外部晶振。内部 RC 在温度变化下偏差大,19.2 k 下容易翻车。
5.4 break 发不出来或长度不对
现象:从机收不到 break,状态机卡在第一步。原因:SBK 位置位后没等 TC 就发下一个字节,或者 USART 没开 LIN 模式。解决:确认 LINEN 已置位,发 break 后等 TC 再发 0x55。有些型号 SBK 需要先清 TC。
5.5 总线冲突:两个节点同时发响应
现象:数据错乱,校验和频繁失败。原因:调度表里两个 ID 的响应节点配重了,或者主机发响应时从机也在发。解决:画一张 ID 分配表,每个 ID 只有一个响应节点。主机发响应的 ID 和从机发响应的 ID 分开规划。
6. 进阶:用定时器做 LIN 超时检测和错误计数
调通基本收发之后,真正让 LIN 程序稳定的是错误处理。我一般用一个定时器做字节间超时(inter-byte timeout),再用几个计数器记录错误类型,方便现场排查。
typedef struct { uint32_t rx_ok; uint32_t checksum_err; uint32_t timeout_err; uint32_t framing_err; } lin_stat_t; static lin_stat_t g_stat; // 在 USART 错误中断里累计 void USART2_ERR_IRQHandler(void) { if (USART2->SR & USART_SR_FE) { g_stat.framing_err++; USART2->DR; } if (USART2->SR & USART_SR_NE) { USART2->DR; } if (USART2->SR & USART_SR_ORE) { USART2->DR; } }逻辑说明:FE 是 framing error,break 也会触发,所以不能一见 FE 就报错,要结合状态机判断。参数 g_stat 建议通过诊断帧上报给主机,现场不用拆机就能看错误分布。
字节间超时用定时器实现:每收到一个字节重置计数器,超过 1.5 个字节时间没收到就判超时。19.2 k 下一字节约 0.52 ms,超时阈值设 0.8 ms 左右。这个值太紧会误判,太松会拖慢调度。
| 错误类型 | 常见原因 | 处理方式 |
|---|---|---|
| 校验和错误 | 算法不一致、总线干扰 | 统一算法,加屏蔽双绞 |
| 超时 | 从机没上电、PID 不匹配 | 查从机供电和 ID 配置 |
| framing error | break 正常触发、波特率偏差 | 区分 break 和真错误 |
| 总线冲突 | ID 分配重复 | 画 ID 分配表 |
最后说个我自己的习惯:每次改完 LIN 配置,先用逻辑分析仪抓一帧完整波形,把 break、0x55、PID、数据、校验和逐段对一遍,再上代码。这个动作花五分钟,能省掉后面几小时的瞎猜。LIN 这东西不难,难的是两边配置对称,任何一边偷懒都会在总线上放大成玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取