LIN 总线主从调度与报文超时实战:每隔十几秒丢一帧、整机待机电流 12mA——我把调度表时隙、休眠帧和那几个超时值从头算了一遍
上一篇(Type-C 口工业现场防护)文末留了两个方向:LIN 总线的主从调度与报文超时,或者差分升级与签名链怎么结合。这周的返修件先到了 LIN 那一支——一批新风/暖通控制器的现场反馈堆在桌上,两条症状同时出现,而且互相”掩护”。这篇来还债。
引子:两个症状互相掩护
先交代现场。一套工业新风控制器,12V 直流母线供电,板间通信走 LIN:
- 1 个主机(主控板,同时是网关,负责跟上层走 RS485/Modbus);
- 8 个从节点:风阀执行器 ×3、温湿度采集 ×2、按键板 ×1、显示面板 ×1、灯光驱动 ×1;
- 波特率 19200 bit/s,最长一段线约 12 m;
- LDF(LIN 描述文件)是客户给的,主控固件里的调度表按它实现。
反馈分成两拨,来自不同现场,一开始我以为是两件事:
症状一:显示面板那个帧,平均十几秒丢一帧。 不是”没数据”,是整帧没有任何应答——总线上只有帧头,应答段一片隐性。其它帧全好。抓了几十分钟报文,丢帧间隔从 8 秒到 40 秒不等,毫无规律。
症状二:整机待机电流降不下去,实测 12mA。 设计要求待机 <100µA。这条更贵——现场是太阳能加铅酸供电,12mA 意味着电池两天就空。
第三个症状是我自己去产线看的,它把前两个串起来了:产线用 UDS on LIN 给从节点下发配置,通过率大概三成,同一套工装、同一批板子,重试几次能过,客户一直当”工装接触不良”处理。
三个症状,最后定位出四个独立根因,而且其中两个就出在同一张表上。这一篇不写”LIN 是什么”,写的是”这张表怎么排、这几个超时值怎么定,以及为什么 LIN 网里没有 ACK 这件事会让你的故障计数变得毫无意义”。
这一篇和站内已有的总线文章不重复:09-25 那篇《RS485/Modbus 抗干扰》讲的是差分总线的共模与接地;09-22 那篇《CAN 错误帧排查》讲的是多主仲裁下的错误计数;这一篇讲的是单线、单主、时间触发这套完全不同的模型——以及它特有的”应答缺失”和”整网静不下来”。
先给结论
- 丢帧不是干扰,是调度表排小了。 LIN 的每一帧都有”标称”和”最大”两套时间,最大 = 1.4 × 标称。那张表里 0x2C 这一格,时隙给了 5ms,但 4 字节帧的帧最大时间是 6.125ms。时隙比帧最大时间短,就意味着从节点的应答还没发完、主机的下一个同步间隔段(break)已经来了——这不是”偶尔撞上”,而是每次都在悬崖边上走,只要从节点被高优先级中断拖一下,这一帧就没了。
- 单帧效率的天花板是 46.08%。 帧头固定 34 个位时间,8 字节帧的应答是 90 个位时间,最小合法时隙还要再乘 1.4。别指望 LIN “跑得快”,要算的是”排得下”。
- LIN 帧没有 ACK,也没有重传。 主机发帧头、从节点发应答,应答段是不是真有人发,协议层不给任何确认。判断从节点活着,只能靠主机自己的 UART 在”应答窗口”里超时——这是本篇”报文超时”三个层次里最要紧的一层,也是把”偶发丢帧”变成”可统计”的抓手。
- 0x3C 和 0x3D 永远用经典校验(只算 8 个数据字节)。给诊断帧配了增强校验,症状是”信号帧全好、诊断一条都不过”——产线通过率三成就是这么来的。
- 休眠不是”把 MCU 睡下去”就完事。 主节点那个 1kΩ 上拉是整网最大的常驻耗电项:12V、8 个从节点的网络里,光是显性位的电流就是 12.4mA。主上拉必须能断开(经收发器 INH 或外加开关),否则后面所有低功耗设计都是白做的。
- 从节点可以不要晶振,因为同步场每帧给它校一次时钟。代价是帧内只有 ±2% 的漂移预算,长帧(8 字节)末尾最吃紧——所以”短帧全好、长帧偶发校验错”是这套机制的典型症状。
一、先把 LIN 的账摊开:单线、单主、时间触发
先把模型立正。做惯了 RS485/Modbus 和 CAN 的人,上手 LIN 最容易错的就是拿旧模型套。
| 维度 | LIN | RS485 / Modbus RTU(站内 09-25 篇) | CAN / CAN FD(站内 09-22 篇) |
|---|---|---|---|
| 物理层 | 单线 + 共地,12V 电平,线与 | 差分 A/B,共模 −7~+12V | 差分 CAN_H/CAN_L |
| 拓扑 | 总线式,1 主 + 最多 15 从,≤40 m | 总线式,32 标准负载 / 247 地址 | 总线式,128+ 节点 |
| 速率 | 1~20 kbit/s(典型 19.2k) | 9600 / 19200 / 115200 | 最高 1 Mbit/s(FD 5 Mbit/s) |
| 谁说话 | 只有主机发帧头,从节点只在被点到时发应答 | 主机轮询(半双工) | 多主,CSMA/CD + 仲裁 |
| 触发方式 | 时间触发,调度表决定谁在什么时候上总线 | 事件触发(软件轮询) | 事件触发 |
| 冲突处理 | 不需要:同一时刻只可能有一个发送者 | 靠软件顺序 | 靠 ID 优先级仲裁 |
| 帧内确认 | 没有 ACK | 从站正常响应才算通 | 有 ACK 位 + 错误帧 + 自动重传 |
| 重传 | 协议层不重传 | 靠主机超时重试 | 协议层自动重传 |
| 时钟精度 | 从节点可无晶振(同步场每帧校准) | 各节点各自晶振,±2~3% | 晶振,要求更严 |
| 典型场景 | 车身、家电、执行器、传感器簇 | 工业现场仪表、PLC | 整车、工业控制 |
这张表里最关键的一行是”谁说话“。LIN 的通信模型拆成两个角色:
- 帧头(Header) = 同步间隔段 + 同步场 + 受保护 ID,永远由主机发;
- 应答(Response) = 1~8 个数据字节 + 校验和,由”发布者”发。
一个帧 ID 只有一个发布者。如果发布者是主机自己,那整帧都是主机在发(主机节点里的从任务);如果发布者是从节点,主机发完帧头就闭嘴、切到接收,把总线让给那个从节点。
这就解释了为什么”某个 ID 丢帧”和”整网都很正常”可以同时成立——每个 ID 是一条独立的、只在它自己时隙里出现的时间片,时隙没排对,倒霉的只有那一个 ID。
还有一个概念要先立住:帧 ID 不代表”发给谁”,而代表”这是什么”。它更像 CAN 的 ID,而不是 Modbus 的从站地址。”哪个节点收这一帧”是在 LDF 里事先约定的,运行期没有地址字段。这一点搞错,会出现”我明明广播了,怎么只有一半节点响应”这类玄学。
二、一帧到底多长:34 个位时间和那个 1.4
LIN 的帧结构是这么几段:
| 间隔段 break | 分隔位 | 同步场 0x55 | 受保护ID (PID) | 数据 1..8 字节 | 校验和 |
|<------ 帧头(主机发)------>|<------ 应答(发布者发)------->|
>=13 个显性位 >=1 个隐性位
规范把帧长算得非常死(LIN 2.2A §2.3.2 / ISO 17987):
T_Header_Nominal = 34 × T_Bit
T_Response_Nominal = 10 × (N_Data+1) × T_Bit
T_Frame_Nominal = 帧头标称 + 应答标称
T_Header_Maximum = 1.4 × T_Header_Nominal
T_Response_Maximum = 1.4 × T_Response_Nominal
T_Frame_Maximum = 帧头最大 + 应答最大
那两个常数是这么来的:
- 34 = 间隔段 13 个显性位 + 分隔位 1 位 + 同步场 10 位(8 数据 + 起止位)+ PID 10 位;
- 10 × (N+1) = 每个字节都是标准 UART 帧 10 位(8N1),数据 N 字节加校验和 1 字节;
- 1.4 = 规范额外给 40% 时长余量,分摊在帧头和应答各一侧。这 40% 不是浪费,是留给应答空间(PID 之后到第一个数据字节之间的间隔)和字节间间隔的——LIN 是软件在响应,不可能像硬件 UART 一样零延迟。
19200 bit/s(T_Bit = 52.083µs)下算出来是:
| 项目 | 公式 | 标称 | 最大(×1.4) |
|---|---|---|---|
| 帧头(34 位) | 34 × T_Bit | 1.771 ms | 2.479 ms |
| 应答 8 字节(90 位) | 90 × T_Bit | 4.688 ms | 6.562 ms |
| 应答 4 字节(50 位) | 50 × T_Bit | 2.604 ms | 3.646 ms |
| 应答 2 字节(30 位) | 30 × T_Bit | 1.563 ms | 2.188 ms |
| 整帧 8 字节 | — | 6.458 ms | 9.042 ms |
| 整帧 4 字节 | — | 4.375 ms | 6.125 ms |
| 整帧 2 字节 | — | 3.333 ms | 4.667 ms |
再补一张常见波特率的对照(单位 ms,取最大帧长):
| 波特率 | T_Bit | 2 字节帧最大 | 4 字节帧最大 | 8 字节帧最大 | 8 字节帧率上限 |
|---|---|---|---|---|---|
| 2400 | 416.67 µs | 37.33 | 49.00 | 72.33 | 14 帧/s |
| 4800 | 208.33 µs | 18.67 | 24.50 | 36.17 | 28 帧/s |
| 9600 | 104.17 µs | 9.33 | 12.25 | 18.08 | 55 帧/s |
| 19200 | 52.08 µs | 4.67 | 6.13 | 9.04 | 111 帧/s |
| 20000 | 50.00 µs | 4.48 | 5.88 | 8.68 | 115 帧/s |
这张表是 LIN 网设计的第一块基石。 它告诉你三件事:
- 一帧 8 字节能占 9ms 已经是上限,一个 20ms 周期里最多塞两帧 8 字节。如果 LDF 里 8 字节帧有三四条还要 20ms 周期,那这张表本身就不成立。
- 单帧效率天花板 = 80 / 173.6 = 46.08%。其中 34 位的帧头占了整帧的 27%,是最大的固定开销——所以 LIN 里减少帧数比提高速率值钱。
- “最大”那一列才是排时隙用的数,不是”标称”那一列。这是症状一的直接原因,第五节细说。
三、帧 ID 与 PID:那两个奇偶位不是装饰
帧 ID 只有 6 位(0~63),加上 2 位奇偶凑成一个字节,叫 PID(Protected Identifier):
bit7 bit6 | bit5 ... bit0
P1 P0 | ID5 ... ID0 (总线位序 LSB 先发)
P0 = ID0 ⊕ ID1 ⊕ ID2 ⊕ ID4
P1 = ¬(ID1 ⊕ ID3 ⊕ ID4 ⊕ ID5)
64 个 ID 的分工:
| ID 范围 | 用途 |
|---|---|
| 0x00 ~ 0x3B(0~59) | 信号承载帧(无条件帧 / 事件触发帧 / 零星帧) |
| 0x3C(60) | 主机请求帧 MRF,诊断与配置 |
| 0x3D(61) | 从机应答帧 SRF,诊断与配置 |
| 0x3E(62) | 保留:用户自定义 |
| 0x3F(63) | 保留:未来扩展 |
几个算出来的实例,建议自己拿笔核一遍:
| 帧 ID | P0 | P1 | PID | 类别 |
|---|---|---|---|---|
| 0x00 | 0 | 1 | 0x80 | 信号帧 |
| 0x01 | 1 | 1 | 0xC1 | 信号帧 |
| 0x12 | 0 | 1 | 0x92 | 信号帧 |
| 0x2C | 1 | 1 | 0xEC | 信号帧 |
| 0x3C | 0 | 0 | 0x3C | MRF(巧了,PID 等于原值) |
| 0x3D | 1 | 0 | 0x7D | SRF |
| 0x3E | 1 | 1 | 0xFE | 用户自定义 |
| 0x3F | 1 | 0 | 0xBF | 保留 |
从这张表能一眼看出奇偶位存在的意义:
- PID 永远不可能是 0x00,也不可能是 0xFF。所以从节点如果在 PID 位置收到全显性(0x00)或全隐性(0xFF),不用算别的,直接判帧头错误——这正好覆盖”总线被某节点拉死”和”波特率差到边沿都丢了”这两种最常见的硬故障。
- 0x3E 的 PID 是 0xFE、0x00 的 PID 是 0x80,都是”离全 0/全 1 只差一位”的形态,任何一位翻转都逃不过奇偶检查。
从节点收到一个 ID 之后的动作,只跟奇偶位和一张表有关:
收到 PID
├─ 奇偶校验不过 -> 丢弃,判帧头错误(不要往下走)
├─ ID 不在自己的订阅表里 -> 丢弃,回到"等下一个 break"
├─ ID 是自己的发布帧 -> 准备好数据,在应答空间后发出去
└─ ID 是自己的订阅帧 -> 接收应答,校验和过了就交给应用
这里有一个只有 LIN 才有的坑:从节点对帧头的判断必须完全基于 PID,不能等数据。因为”这个 ID 我该不该发应答”必须在 PID 结束前决定——PID 一结束,应答空间就开始了(最坏情况是整个 40% 余量),你没有时间再做额外判断。
四、校验和:经典还是增强,这个配置型故障最坑
LIN 有两套校验和算法:
| 类型 | 算法 | 用在哪 |
|---|---|---|
| 经典校验 | 只累加数据场 | LIN 1.3 从节点;0x3C / 0x3D 永远用经典 |
| 增强校验 | 累加 PID + 数据场 | LIN 2.x 的信号承载帧 |
算法本身是”八位和带进位“再加逐位取反:
sum = 0
for each byte:
sum += byte
if sum >= 256: sum -= 255 <-- 注意是减 255,不是 mod 256
checksum = (~sum) & 0xFF
接收侧自检反过来:把收到的数据和校验和按同样规则一起求和(不再取反),结果应该是 0xFF。
举个能手工核的例子。帧 ID = 0x2C → PID = 0xEC,数据 = 11 22 33:
经典校验: sum_carry(0x11,0x22,0x33) = 0x66 -> ~0x66 = 0x99
核对: sum_carry(0x11,0x22,0x33,0x99) = 0xFF ✓
增强校验: sum_carry(0x11,0x22,0x33,0xEC) = 0x53 -> ~0x53 = 0xAC
核对: sum_carry(0x11,0x22,0x33,0xEC,0xAC) = 0xFF ✓
这里”带进位”三个字要抠住:
0x11+0x22+0x33 = 102,102 + 236 = 338。338 不是减 256 得 82(0x52),而是减 255 得 83(0x53),最后取反得到 0xAC。如果按普通八位加法(mod 256)算,会得到 0xAD——和正确值只差 1,看起来”差不多”,但接收侧自检永远对不上 0xFF。我第一次写这段代码就是直接sum()之后取反,自检那一步死活过不去,回头才发现问题在这 255 和 256 之间。
然后是我这次踩的那一脚。客户的 LDF 是 LIN 2.1 的,主控固件里信号帧用增强校验没问题,但 0x3C/0x3D 也一并用成了增强。后果:
- 信号承载帧:全好。因为主机发帧头、从节点发应答,两侧算法一致;
- 诊断帧:一条都过不去。从节点按经典校验算,主机按增强算,每次都是校验错。而且 LIN 诊断是”主机请求 → 从节点应答”的严格配对,前一条失败后一条也就不发了,表现出来就是”产线配置失败、重试几次能过”——重试能过,是因为现场混着几块老固件的板子。这就是”三成通过率”的真相。
记住这条铁律:0x3C 与 0x3D 无条件用经典校验。 这不是”默认值该设成什么”的问题,是规范硬性规定(LIN 2.2A §2.3.1:Frame identifiers 60 (0x3C) to 61 (0x3D) shall always use classic checksum)。
还有一个连带的坑:如果集群里混了 LIN 1.3 的从节点,那所有跟它通信的帧都得退回经典校验。混用会出现”只有那个老节点收不到、别的都正常”,而且报文时间、电平都是对的——校验和类型错了是一种纯粹的”配置型故障”,抓包工具不解码就完全看不出来。
五、调度表:时隙、时间基、jitter 与那个被顶掉的应答
这是本篇主线。主机不是”想发就发”,它按一张调度表跑:表里每一项是”帧 ID + 时隙”,主机的时基任务走到哪一格,就在那一格的起点发对应 ID 的帧头。
规范给的硬约束有两条:
1) T_Frame_Slot > jitter + T_Frame_Maximum
2) 时隙必须是时间基(time base)的整数倍,且从时基的节拍(tick)起点开始
- jitter 是”这一帧实际起发时刻离时隙起点的偏差”,在 LDF 里定,反映主机软件时基的精度;
- 切换调度表时,必须等当前时隙结束才能切;
- 时间基常用 5ms 或 10ms,也有 1ms / 2ms 的紧凑配置。时间基越小,时隙排得越紧,但对主机定时器精度要求越高。
现在看客户那张表的原始版本(时间基 5ms,19.2 kbit/s):
| 帧 ID | PID | 字节 | 帧最大(ms) | 时隙(ms) | 余量 | 判定 |
|---|---|---|---|---|---|---|
| 0x00 | 0x80 | 8 | 9.042 | 10 | +0.958 | OK |
| 0x01 | 0xC1 | 4 | 6.125 | 10 | +3.875 | OK |
| 0x12 | 0x92 | 2 | 4.667 | 5 | +0.333 | 余量仅 7%,危险 |
| 0x2C | 0xEC | 4 | 6.125 | 5 | −1.125 | 时隙 < 帧最大! |
一眼就能看出来:0x2C 那一格,4 字节帧的最大时间是 6.125ms,时隙只给了 5ms(一个时间基)。这不是”差一点点”,是排错了。
为什么症状不是”每次都丢”,而是”十几秒丢一次”? 因为”帧最大时间”是最坏情况:它假设应答空间和字节间间隔都吃满了 40% 余量。正常情况下一帧只花 4.4ms 左右(标称 4.375ms + 一点软件开销),5ms 的时隙勉强够用。只有当 0x2C 那个从节点(显示面板)正好被高优先级任务拖住——比如它同时在刷 LCD、写 Flash、或被 ADC 中断占了几十微秒——应答里的字节间隙变大,整帧超过 5ms,主机的下一个 break 就把它顶掉了。
对应到报文上:
主机发 0x2C 帧头 ... 从节点应答开始 ... 从节点发到第 3 个字节 ...
主机 |<--- 下一个时隙到点,发出 break(13 个显性位)--->|
从节点的应答被强行截断,整帧作废
主机侧:这一格收到 0 个有效字节
从节点侧:它以为自己在正常发,校验和都发出去了
注意这里是非对称的:从节点完全不知道自己的应答被顶掉了(它照常把校验和发完),只有主机知道”这一格没收到东西”。这就是为什么丢帧必须由主机侧统计。
规范里其实有一句配套的话:接收节点应接受直到下一个 break 之前的帧,即使它超过 T_Frame_Maximum。也就是说从节点(订阅者)对”帧太长”是宽容的——苛刻的是主机,因为主机是那个决定”什么时候break”的人。
排查这格的三个动作
- 拿 LDF 里的帧长度重新算帧最大时间,别复用别人给的时隙数——LDF 是设计描述,不是”保证正确”的证明;
- 在主机侧给每一格加”该格是否收到有效应答”的计数(下一节),把它和总线上抓到的载荷对齐;
- 把这一格的从节点单独喂一次最坏负载:让它同时做重活(刷屏 / 写 Flash),看丢帧率是否从”十几秒一次”变成”几十毫秒一次”——这一步能直接把结论钉死。
时隙该怎么定
规则很简单,但需要算:
T_Frame_Slot >= ceil( (jitter + T_Frame_Maximum) / 时间基 ) × 时间基
以 8 字节帧为例(19200,jitter 按 1ms 留):
| 时间基 | 该帧至少占几个 mini-slot | 实际时隙 |
|---|---|---|
| 1 ms | 11 | 11 ms |
| 2 ms | 6 | 12 ms |
| 5 ms | 3 | 15 ms |
| 10 ms | 2 | 20 ms |
时间基选小了精度吃紧,选大了时隙粒度浪费。我们最后把时间基从 5ms 改成 2ms,是因为表里 2 字节帧和 4 字节帧占了大半——5ms 的时间基会让 2 字节帧(帧最大 4.667ms)被迫占 5ms,还好;但 4 字节帧(6.125ms)就得跳到 10ms,一次浪费 3.9ms,而这张表里有两帧 4 字节。换成 2ms 时间基以后,4 字节帧占到 8ms(4 个 mini-slot)就够,整表周期反而短了。
改后的表(时间基 2ms):
| 帧 ID | PID | 字节 | 帧最大(ms) | 时隙(ms) | 余量 | 判定 |
|---|---|---|---|---|---|---|
| 0x00 | 0x80 | 8 | 9.042 | 12 | +2.958 | OK |
| 0x01 | 0xC1 | 4 | 6.125 | 10 | +3.875 | OK |
| 0x12 | 0x92 | 2 | 4.667 | 6 | +1.333 | OK |
| 0x2C | 0xEC | 4 | 6.125 | 10 | +3.875 | OK |
| 周期 | 38 ms | 负载 68.3% |
改前那张表周期 30ms、帧最大时间之和 25.959ms,负载 86.5%。这个数偏高不只是”浪费带宽”,它还有一个直接后果:诊断调度表插不进去,而产线配置正好要靠诊断调度表——”三成通过率”里也有一部分账要算在这里。
经验值:整网总线负载(各帧最大时间之和 / 调度表周期)建议 < 70%,给诊断调度表和运行时切换留空间。超过 80% 的,先砍帧数(合并信号),再考虑提速。
六、报文超时:LIN 没有 ACK,那”活着”怎么判
这是本篇第二个核心。LIN 的应答段没有确认机制:主机发完帧头就撒手,从节点发不发、发得对不对,协议层都不告诉主机。所以”报文超时”在 LIN 里是三个完全不同的层次,必须分开设计:
| 层次 | 是谁的职责 | 典型的量 | 失败了意味着什么 |
|---|---|---|---|
| ① 位/字节级 | 收发器 + MCU 的 UART | 帧错误、位采样错误、校验和错误 | 物理层或波特率问题 |
| ② 帧级(应答窗口) | 主机的软件 | 应答窗口超时(≈ T_Response_Max) | 从节点没应答,或应答被截断 |
| ③ 会话级 | 应用/诊断层 | 诊断响应超时、重复请求间隔 | 从节点在忙、在初始化、或死了 |
第 ② 层是 LIN 特有的,也是最容易被漏掉的一层。 具体做法:主机在发完帧头(PID 的停止位)之后启动一个定时器,长度取 T_Response_Max 再留一点裕量;在这个窗口内:
- 收到任何字节 → 把超时定时器按字节重启(帧内看门狗),继续收;
- 窗口内一个字节都没收到 → 记
no_response,判定”该从节点没应答”; - 收到字节但校验和不过 → 记
checksum_err(区别于no_response,两者根因完全不同); - 收到数据但长度和 LDF 不符 → 记
len_err。
这三类计数分开统计,是把”偶发丢帧”变成可统计问题的关键。我们在客户现场就是靠这三条计数把症状一定位的:
修好前(72 小时):
帧 ID 0x2C: no_response 17,203 次 checksum_err 4 次 len_err 0 次
帧 ID 0x01: no_response 3 次 checksum_err 0 次
帧 ID 0x00: no_response 0 次 checksum_err 0 次
帧 ID 0x12: no_response 8 次 checksum_err 1 次
那 4 次 checksum_err 和零星的 no_response 是总线上的随机扰动(正常的);17,203 次 no_response 集中在一个 ID 上,这是”调度问题”而不是”干扰问题”——干扰不会这么挑剔地只打一个 ID。这个判据非常有用:
no_response集中在某一个 ID → 先查那个 ID 的时隙、发布节点软件响应延迟;no_response均匀分布在所有 ID → 查主机自己的时基任务(是不是被别的任务挤住了)、查波特率;checksum_err为主,且集中在长帧 → 查从节点时钟漂移、边沿时间常数;len_err出现 → 主从两侧 LDF 版本不一致(帧长约定不同)。
第 ③ 层(会话级)在 LIN 里对应的是诊断会话,规范里给了分段传输的 PCI 帧计数和若干响应时间约束。实际工程中最常用的做法是:主机的诊断任务给每个请求配一个总超时(覆盖”请求帧 + 响应帧 + 分段”的全过程),超时即判本次会话失败并重新发起,同一 NAD 连续失败 N 次就把该节点标为”离线”并在 RS485 上行报文里带出去。这个”节点离线”标记比丢帧计数更有用——上层 Modbus 侧能直接看到”3 号风阀执行器不可达”,而不是”总线质量不好”。
一个容易做错的细节:主机的应答窗口定时器不能用调度表的时隙来做。时隙是循环的(比如 10ms),而应答窗口是 6.5ms 级的,两者不是一个量级;用同一套计数会互相污染。我们在固件里给每个时隙配了两个独立计时器:时隙计时器(决定何时发下一个帧头)和应答看门狗(决定本格是否算失败)。
七、休眠与唤醒:0x3C + 0x00 + 0xFF×7,以及那个必须能断的 1k
现场症状二(12mA 待机电流)出在这一节,而且它和第五节那张表也有关系。
休眠
LIN 网络有两种进休眠的路径:
① 命令式:主机发一帧 MRF(ID = 0x3C),数据场第一个字节 = 0x00,其余全 0xFF:
ID = 0x3C
数据 = 00 FF FF FF FF FF FF FF
校验和 = 00(经典,只算这 8 个数据字节)
- 从节点只看数据场第一个字节,其余字节忽略;
- 从节点收到休眠命令后可以按应用决定是否真睡——这条很容易被忽视,”收到命令没睡”在规范上不算错,但你的低功耗指标会不达标;
- 规范另外还有一条硬的:总线静默(没有任何显/隐性跳变)4~10 秒,所有节点必须进入休眠。这条是不能拒绝的。
② 自动式:就是上面那条”总线静默 4 秒”。
唤醒
- 唤醒信号不是一个帧,是总线上一个持续 250µs ~ 5ms 的显性电平;
- 其它节点以 >150µs 为阈值判定”这是唤醒信号”;
- 收到唤醒的节点须在唤醒脉冲结束起 100ms 内准备好接收帧头;主机也必须在这 100ms 内开始发帧头;
- 唤醒者若 150ms 内没看到帧头,可以重发;最多 3 次;3 次都不成,必须等 ≥1.5 秒才能发第 4 次。
这里有个我们现场踩到的坑,值得单独说:主机的同步间隔段本身就是 13 个显性位,在 19200 下是 677µs(13 × 52.083µs),完全落在 250µs~5ms 的唤醒脉冲窗口里。所以主机”发第一帧”这件事本身就能当唤醒信号用——但这一帧通常会被丢掉,因为从节点还在做初始化(规范允许它花 100ms)。这不是 Bug,是预期行为。 现场判”唤醒失败”要看后续帧,不要盯这一帧,我们一开始就是被这一帧骗了,反复”修”了三天。
而这次现场真实的唤醒故障是:主机自己用软件延时打了一个 180µs 的显性脉冲。180µs < 250µs 的下限,同时 >150µs 的判定阈值——正好卡在”有些节点认、有些节点不认”的边界上。改法是把唤醒脉冲改成用 UART 发一个 break(硬件生成 13 个位时间的显性电平)或者把延时放宽到 1ms。
12mA 待机电流的账
现在算症状二。这是一个 12V、8 个从节点的网络:
主节点上拉 : 1 kΩ(串一只防反灌二极管,规范 900~1100Ω)
从节点上拉 : 30 kΩ x 8(规范 20/30/60,典型 30kΩ,现代收发器内置)
网络并联电阻 : 1k || (30k/8) = 789 Ω (规范区间 537~863Ω)
接收门限 : 显性 <= 4.8V,隐性 >= 7.2V(40% / 60% 型号 VBAT)
发送电平 : 显性 <= 2.4V,隐性 >= 9.6V(20% / 80% 型号 VBAT)
显性位电流(经主上拉): (12 - 0.7 - 1.5)/1k = 9.80 mA
每个从节点贡献 : (12 - 0.7 - 1.5)/30k = 0.327 mA x8 = 2.61 mA
显性合计 : 12.41 mA <-- 这就是那 12mA
关键认识:这 12mA 里绝大部分是”通信”的代价,不是”芯片没睡”的代价。 用户最初以为是某个从节点没进低功耗模式,换了两版固件都没用,因为根因在主机那一侧:
- 主节点的 1kΩ 上拉接在常电 12V 上,没走收发器的 INH 引脚。INH 在 Sleep 模式会被拉低,正常用法是拿它去控制外部稳压器或主上拉的通断。接成常电以后,总线在”无人驱动”时也一直挂着 1kΩ,隐性态下从节点的 30kΩ 分压路径持续漏电,显性态下直接 10mA 级。主上拉不断开,整网永远静不下来。
- 主机软件收到了上层”休眠”指令,把调度表停了,但没发休眠帧。从节点因此还在正常工作模式,等着帧头。从节点没退、收发器没进 Sleep,它的 30kΩ 上拉还在,整网的隐性电流也就不可能下去。
- 其中两个从节点的收发器使能脚(EN)悬空,靠内部默认上拉保持唤醒。这个属于原理图上的漏项。
改法四条:
- 主上拉经收发器 INH 或外加 PMOS 开关供电,Sleep 时断开;
- 主机休眠流程固化为三步:先发 0x3C 休眠帧 → 等两个调度表周期(确保从节点收到了)→ 再关调度表、断上拉、进低功耗。顺序不能反;
- 所有从节点收发器 EN 明确驱动(不允许悬空);
- 从节点的 MCU 在收到休眠命令后先回完当前应答,再关外设、进停机模式,最后才把收发器 EN 拉低(收发器 EN 拉低会丢总线,顺序反了会吃掉正在发的最后几个字节)。
改完实测:12.41mA → 42µA。剩下的 42µA 主要是从节点 MCU 的停机电流加收发器 Sleep 电流,属于正常水平。
顺带说一句:如果你要测”整网是不是真静下来了”,最省事的办法是在主机上拉电阻上串一只 10Ω 电阻,用示波器看它两端的电压。 有通信时是几十 mV 的脉冲串,休眠后应该是一条平线。用电流探头夹总线串一小段也行,但串电阻更直接——这一步比看固件日志可靠得多。
八、物理层三笔账:上拉、时间常数、门限
做了三个症状的定位之后,我顺手把这块板的物理层也量了一遍,发现了两个”不算故障但已经踩线”的地方。
第一笔:上拉电阻与网络电阻
| 项目 | 规范值 | 我们的板子 |
|---|---|---|
| 主节点上拉 | 900 ~ 1100 Ω(典型 1k,串二极管) | 1kΩ + 二极管 ✓ |
| 从节点上拉 | 20 / 30 / 60 kΩ | 30kΩ(内置) ✓ |
| 网络并联电阻 | 537 ~ 863 Ω | 789 Ω ✓ |
| 节点数 | 2 ~ 16 | 9 ✓ |
上拉电阻的作用是”把总线拉回隐性”,同时也是那个”驱动器拉低时吃电流”的负载。主上拉 1k / 从上拉 30k 这个 25:1 的不对称是有意的:主节点只有一个,它出电流把总线拉高,同时给所有节点提供确定的隐性基准;从节点很多,所以每个只出 30k,避免节点数一多总电阻掉下去、显性电流失控。
第二笔:边沿与时间常数(这笔超了)
总线是一个 RC 网络,时间常数:
tau = R_network × C_bus
- 总线线缆电容:100~150 pF/m,12m 的线约 1.2~1.8nF;
- 每个节点呈现约 220 pF,主机 220pF + 8 个从节点 1760pF ≈ 2.0nF;
- 合计约 3.5nF,加上接头和分支的杂散,我们实测 6.8nF(规范上限 10nF);
tau = 789Ω × 6.8nF = 5.37µs,已经超出规范要求的 1.0~5.0µs 上限。
上升沿到达各门限需要的时间(从 0 起):
| 目标 | 时间(tau = 5.37µs) |
|---|---|
| 40% VBAT(接收判显性的门限) | 2.74 µs |
| 60% VBAT(接收判隐性的门限) | 4.92 µs |
| 80% VBAT(发送侧隐性要求) | 8.64 µs |
19200 下位宽 52.08µs,4.92µs 只占 9.4%,所以这不是这次丢帧的原因——这一点要说清楚,不能因为”边缘超了一点”就把结论往物理层引。我们最后通过缩短一段 12m 的线到 6m 把 tau 压到 4.1µs,算”顺手合规”,但它在因果链上只排第三。
顺带一个常被忽略的约束:LIN 收发器把压摆率限制在约 2V/µs(EMI 要求)。12V 跳变对应约 6µs 的边沿,这和上面的 RC 时间常数是同一量级——别指望靠更快边沿去救时序,方向就错了。
第三笔:门限的百分比表达
LIN 的电平门限全部按 VBAT 的百分比定义,这一点和 RS485 的绝对值定义完全不同:
| 显性(逻辑 0,低) | 隐性(逻辑 1,高) | |
|---|---|---|
| 发送侧要求 | ≤ 20% VBAT(12V 时 ≤ 2.4V) | ≥ 80% VBAT(≥ 9.6V) |
| 接收侧判定 | ≤ 40% VBAT(≤ 4.8V) | ≥ 60% VBAT(≥ 7.2V) |
收发两侧门限不重合(20/40 与 80/60),留出的这 20% 是给地偏移和分压误差的。这带来两条实践结论:
- 地线一定要接。LIN 用单线通信,参考是”节点地”。两个节点如果不共地,或者地之间有几百 mV 的偏移,看似”电平是对的”(因为都是对本地地量的),实际到对方那儿可能已经越过门限。40% 门限看着宽松,但 12V × 20% = 2.4V 的裕量在长线和多节点分压之后并不夸张;
- 不要用绝对电压去判总线状态。写固件里”总线是否有人拉低”的判断时,按比例来,别写死 2.4V——VBAT 在 7~18V 之间变(12V 系统),死阈值会随供电漂移。
九、波特率偏差预算:为什么从节点可以不要晶振
这一节是为了回答一个我一开始想不通的问题:为什么 LIN 的从节点可以用内部 RC 振荡器,而 RS485 的从站必须配晶振?
因为每一帧都会重新校准:
- 同步场固定是 0x55,位流是
0101 0101,是全帧里跳变最密的模式; - 从节点量”第一个下降沿到最后一个下降沿“的时间,等于 8 × T_Bit。19200 下是 416.67µs,除 8 得位宽 52.083µs,反推波特率;
- 用它校准本帧剩下的部分——帧头之后的所有位都用这个新校准的位宽来采样和发送。
偏差预算(ISO 17987-4,12V 系统):
| 参数 | 含义 | 上限 |
|---|---|---|
| F_TOL_RES_COMMANDER | 主节点离标称位率 | < ±0.5% |
| F_TOL_RES_RESPONDER | 从节点自身精度(不用同步场) | < ±1.5% |
| F_TOL_UNSYNCH | 从节点同步前的初偏 | < ±14% |
| F_TOL_SYNCH | 从节点同步后、一帧之内的累计漂移 | < ±2% |
| F_TOL_RESPONDER_to_RESPONDER | 两个从节点之间的相对偏差 | < ±2% |
(24V 系统的 BR_Range_20K 更严:主/从离标称均 <±0.3%,从到从 <±0.6%。)
这几个数的工程含义:
- ±14% 是给”判 break”用的。从节点看到 ≥9.5 个连续显性位就判为间隔段(不要求验证分隔位确实是 1 位)。13 个显性位在 19200 下是 677µs,比 9.5 位的门槛留了近 40% 余量,正是给 ±14% 初偏准备的。
- ±2% 是给”帧内漂移”用的。同步场校准发生在帧头中间,8 字节帧的应答部分还有 90 个位时间。如果从节点的 RC 在这段时间里漂了超过 ±2%,末尾几个位的采样点就偏出窗口——症状精确对应”短帧全好、长帧偶发校验错”。
- 所以主节点一定要准,从节点可以糙。整网的位宽基准是主节点,而且从节点每帧只能校准一次、不能积累。反过来讲,把从节点的 RC 标到 1% 以内是浪费钱(把主节点做准才是关键)。
这一条也直接服务于第三节那个”从节点在 PID 结束前必须决定发不发”的约束:从节点必须在这个 ±14% 的窗口内可靠地识别出 break,然后立刻用同步场重新校准,任何”先接收同步场、再判断我该不该应答”的实现都是错的顺序。
十、固件实现:一个零 malloc、无 %f 的 LIN 从节点/主机骨架
按站内一贯的铁律(业务路径零 malloc、禁用 %f,见 09-14 / 09-28 / 10-01 几篇),把这次的实现骨架记下来。核心是静态缓冲 + 中断只做搬运 + 状态机放主循环。
数据结构(全静态)
/* 一帧的最大尺寸:8 数据 + 1 校验 = 9 字节 */
#define LIN_MAX_DATA 8
#define LIN_TX_BUF_SIZE 9 /* 8 数据 + 校验和 */
typedef struct {
uint8_t fid; /* 0x00..0x3F */
uint8_t pid; /* 含 P0/P1 */
uint8_t dlc; /* 1..8 */
uint8_t slot_ticks; /* 时隙,单位 = 时间基 tick */
uint8_t csum_enhanced; /* 1=增强, 0=经典(0x3C/0x3D 强制 0) */
} lin_slot_t;
/* 统计:把"偶发"变成"可统计"的全部家当 */
typedef struct {
uint16_t no_response; /* 应答窗口内一个字节都没收到 */
uint16_t checksum_err; /* 收到字节但校验不过 */
uint16_t len_err; /* 长度与 LDF 不符 */
uint16_t header_err; /* 自己发出去被顶掉/帧头异常 */
uint32_t last_fault_tick; /* 最后一次故障的时刻,便于和上层日志对齐 */
} lin_stats_t;
static lin_stats_t g_stat[64]; /* 按帧 ID 分桶:64 * 10B = 640B,可控 */
static uint8_t g_rx[LIN_TX_BUF_SIZE];
static volatile uint8_t g_rx_cnt;
static volatile uint8_t g_rx_active; /* 应答可接收窗口 */
应答看门狗(第六节第 ② 层)
/* 位时间用整数微秒表示,避免任何浮点 */
#define BIT_US(baud) (1000000UL / (baud))
/* 应答最大时间:10*(dlc+1)*T_Bit*1.4,全部用整数算 */
#define RESP_MAX_US(baud,dlc) \
(14UL * 10UL * ((dlc) + 1U) * BIT_US(baud) / 10UL)
static void lin_start_rx_window(const lin_slot_t *s)
{
g_rx_cnt = 0;
g_rx_active = 1;
/* 帧内看门狗:每收到一个字节就重启一次 */
timer_arm_one_shot(TIMER_LIN_RESP, RESP_MAX_ISR_TICKS(s));
}
/* USART 接收中断里只做两件事:搬字节 + 重启看门狗 */
void LIN_USART_IRQHandler(void)
{
if (USART_RXNE) {
if (g_rx_active && g_rx_cnt < LIN_TX_BUF_SIZE) {
g_rx[g_rx_cnt++] = USART_RX();
timer_restart(TIMER_LIN_RESP); /* 字节间看门狗 */
} else {
USART_CLEAR_ORE(); /* 溢出:总线上的东西我们不认 */
}
}
}
/* 看门狗到期:本轮应答结束(正常或超时) */
void lin_resp_timeout_isr(void)
{
g_rx_active = 0;
if (g_rx_cnt == 0) {
g_stat[g_cur_fid].no_response++;
g_stat[g_cur_fid].last_fault_tick = tick_now();
return; /* 从这个 ID 就没打算发过应答也算 */
}
const lin_slot_t *s = &g_schedule[g_cur_slot];
if (g_rx_cnt != (uint8_t)(s->dlc + 1U)) {
g_stat[g_cur_fid].len_err++;
} else if (!lin_csum_ok(s, g_rx)) {
g_stat[g_cur_fid].checksum_err++;
} else {
app_on_frame(s->fid, g_rx, s->dlc);
}
}
校验和(无浮点、无除法)
/* LIN 的"八位和带进位":和 >= 256 就减 255 */
static uint8_t lin_sum_carry(const uint8_t *p, uint8_t n)
{
uint16_t s = 0;
while (n--) {
s += *p++;
if (s >= 256U) s -= 255U;
}
return (uint8_t)(~s);
}
static uint8_t lin_checksum(const lin_slot_t *s, const uint8_t *data)
{
/* 0x3C/0x3D 强制经典;其余按 LDF 配置 */
uint16_t s = 0;
if (s->csum_enhanced) {
s = s->pid;
}
for (uint8_t i = 0; i < s->dlc; i++) {
s += data[i];
if (s >= 256U) s -= 255U;
}
return (uint8_t)(~s);
}
三个纪律,跟站内其他工程一致:
- 所有缓冲静态分配,
g_rx9 字节、统计 640 字节、调度表按帧数 × sizeof(lin_slot_t),全部编译期确定,运行期一字节都不申请; - 上报 JSON 自己做定点格式化,一个
%f都不要(Release 版 newlib-nano 关掉nanoprintffloat时%f会静默输出空字符串,整帧 JSON 变"x":,被服务端丢弃——这个坑 10-01 那篇写过); - 统计按帧 ID 分桶,并且每条故障都带一个
last_fault_tick,这样上层把”故障计数”和”现场日志时间戳”对齐时不用猜。
主机的两个计时器别混用
/* 主机侧每个时隙两个独立计时器,绝对不能共用 */
typedef struct {
uint16_t slot_ticks; /* 时隙 -> 决定何时发下一个帧头(循环) */
uint16_t resp_ticks; /* 应答窗口 -> 决定本格是否算失败(一次性) */
} lin_master_timing_t;
这一点在第六节说过,但值得在代码里再强调一次:时隙是循环量(10ms 级),应答窗口是一次性量(6.5ms 级),用同一个计数器会互相污染,症状是”故障计数和实际丢帧对不上”。
十一、改前改后账单
四个根因、四处改动,最后落到这几个数上(现场连续跑 72 小时统计):
| 指标 | 改前 | 改后 |
|---|---|---|
0x2C 帧 no_response(72h) |
17,203 次 | 0 次 |
全部帧 no_response(72h) |
17,214 次 | 41 次(随机扰动,正常) |
| 0x2C 时隙 | 5 ms(< 帧最大 6.125ms) | 10 ms |
| 调度表时间基 | 5 ms | 2 ms |
| 调度表周期 | 30 ms | 38 ms |
| 总线负载(帧最大和/周期) | 86.5% | 68.3% |
| 诊断调度表能否插入 | 经常插不进 | 正常 |
| 产线配置通过率 | ~30% | 100% |
| 待机电流 | 12.41 mA | 42 µA |
| 唤醒成功率 | 按 2~3 次 | 1 次 |
| 物理层时间常数 tau | 5.37 µs(超 5.0µs 上限) | 4.1 µs |
改动清单(四条,按重要性排序):
- 把 0x2C 的时隙从 5ms 改到 10ms,时间基从 5ms 改到 2ms,重排整张调度表,负载从 86.5% 降到 68.3%;
- 0x3C / 0x3D 的校验和类型改为经典(代码里对这两个 ID 强制,不接受 LDF 配置覆盖,避免再被配置错);
- 主节点 1kΩ 上拉改由收发器 INH 供电,休眠流程固化为”发休眠帧 → 等两个调度表周期 → 关调度表 → 断上拉 → 进低功耗”;所有从节点收发器 EN 明确驱动;从节点收到休眠命令后最后一步才拉低 EN;
- 唤醒脉冲从 180µs 软件延时改为硬件 break(13 个位时间,677µs),并且主机在自己唤醒之后按 100ms 才发第一个帧头。
第 2 条和第 4 条改动量都不超过 10 行代码,但第 2 条单独就能把产线通过率从 30% 拉到 100%。
我的 LIN 落地自查清单
按这次的经验整理,改板/改固件时照着走一遍:
- ☐ 调度表里每一格的时隙 ≥ jitter + T_Frame_Maximum(用”最大”算,不是”标称”)
- ☐ 所有时隙都是时间基的整数倍,且从 tick 起点开始
- ☐ 整网总线负载(帧最大时间之和 / 周期)实测 < 70%,给诊断调度表留了空间
- ☐ 0x3C / 0x3D 使用经典校验,且代码层面强制(不给配置错的机会)
- ☐ 混用 LIN 1.3 从节点时,与之通信的帧全部回退经典校验
- ☐ 帧 ID 的 P0/P1 奇偶按公式实现,并对每个 ID 做过单测
- ☐ 主机侧有应答窗口看门狗,且与时隙计时器相互独立
- ☐
no_response/checksum_err/len_err/header_err按帧 ID 分桶统计,每条带时间戳 - ☐ 故障判读规则写进文档:单 ID no_response → 调度;全 ID 均匀 → 主机时基;长帧 checksum_err → 时钟漂移
- ☐ 休眠流程顺序固定:先发 0x3C 休眠帧 → 等(≥2 个调度表周期)→ 关调度 → 断上拉 → 低功耗
- ☐ 主节点 1kΩ 上拉经 INH 或 PMOS 开关供电,Sleep 时确实断开
- ☐ 从节点收发器 EN 明确驱动,不允许悬空;从节点 MCU 最后一步才拉低 EN
- ☐ 唤醒脉冲宽度实测落在 250µs ~ 5ms,且主机在 100ms 之后才发第一个帧头
- ☐ 唤醒重试不超过 3 次,之后至少等 1.5s
- ☐ 网络电阻实测在 537~863Ω 区间,主上拉 900~1100Ω 且串了防反灌二极管
- ☐ 总线时间常数 tau 实测 ≤ 5.0µs(用电流探头或串电阻看上升沿)
- ☐ 所有节点共地良好;固件里判总线电平用 VBAT 百分比,不写死绝对电压
- ☐ 从节点波特率用同步场每帧校准;长帧(8 字节)单独做过温漂测试
- ☐ 固件零
malloc、业务路径无%f(Release 版本nm验过) - ☐ 总线级测试做过:最坏负载下的时隙压力测试、断电顺序测试、休眠电流测试、唤醒成功率 100 次
相关阅读
- RS485/Modbus 抗干扰实战:一条”隔几小时丢一帧”的产线总线 —— 同样是”偶发丢帧”,但那一条是差分总线的共模问题,和本篇的”故意排错时隙”正好是一对反例
- CAN 总线错误帧排查实战:一条”隔几小时报一次总线-off”的产线设备 —— 有 ACK、有错误帧、有自动重传的总线是怎么做故障归因的,可以和 LIN 的”什么都没有”对照着看
- 端接拓扑与点对多点走线策略实战:一条挂了 6 片器件的共享时钟 —— 拓扑与终端匹配的通用版本,LIN 的总线式拓扑同样适用
- 上拉/下拉电阻取值实战:同一颗 I2C,为什么别人 4.7kΩ 我得换 2.2kΩ 才稳 —— LIN 的 1k / 30k 不对称上拉,和这篇讲的”上拉决定边沿与功耗”是同一套权衡
- 嵌入式低功耗设计实战:从 12mA 到 40μA,电池设备续航翻十倍的踩坑记 —— 同样是”12mA 到 40µA”,但那篇是整机电源架构,这篇是”总线侧”那一份账,两篇合起来才是完整的待机电流
- CAN FD 与经典 CAN 混合组网实战:一开 BRS,老节点就”广播错误帧” —— 同样”新旧版本混在一个网上”的麻烦,CAN 那边是帧格式,LIN 这边是校验和模型
本文配套脚本:_tools/lin_sched_calc.py —— LIN 主从调度与超时现场估算器。mode: frame(按波特率和数据字节数算帧头/应答/整帧的标称与最大时间、最小合法时隙、时间基建议)、rate(常见波特率的帧时间与帧率对照)、sched(调度表体检:逐格算帧最大时间、判断时隙是否合法、给出周期与总线负载)、pid(帧 ID → PID 与类别,-a 出 64 个 ID 全表)、csum(经典/增强校验和,含接收侧自检核对)、diag(诊断帧布局、PCI 类型、NAD 约定、休眠帧字节,-b 可直接组一帧主机请求帧)、net(网络并联电阻、门限电压、显性位电流、时间常数与上升沿)、sync(同步场边沿口径与波特率偏差预算)、toler(ISO 17987-4 偏差预算全表)、timing(休眠/唤醒定时常数与常见休眠失败根因)、triage(现场症状分诊表)。纯标准库,全离线,不联网。
下次打算写:差分升级和签名链怎么结合(bsdiff 已经写过,这次讲”差分包也要签名、而且要防降级”那一层),或者 RT-Thread 设备框架下的传感器驱动分层。哪个先写,看这周的活先到哪个。