调一串老设备的串口,抓包窗口里最先蹦出来的往往不是 IP 报文,而是一串被0x7E两头夹住的十六进制。很多人看到这玩意儿第一反应是"看不懂,直接跳过去看应用层",然后就在认证、分片、保活这些地方反复栽跟头。HDLC 和 PPP 就是架在这个位置上的两个东西——HDLC 给了链路层一套通用的分帧和差错检测骨架,PPP 在它的壳子上补齐了协商、认证、多协议复用这些几乎每个现场都会用到的能力。只要你碰过企业专线的串口链路、工业网关的透传口、蜂窝数传模块的拨号链路,或者仅仅是想搞明白为什么ifconfig里那个ppp0网卡突然没了,这两个协议迟早会挡在你面前。
我写这篇不是想复述帧格式表——手册上都有。我想讲的是:抓到一串码流之后怎么一眼判断它是 HDLC 还是 PPP、控制字段里的那个字节到底在表达什么、LCP 协商卡在哪一步、认证失败为什么日志里只有一句"链路已断开"。文中涉及的命令和配置都能在你自己的机器上跑起来,用虚拟串口造两只"猫"对拨,不需要真实的广域网设备。适合刚接手链路层调试的朋友,也适合那些"会用但说不清原理"的老手对照着复盘。
1. 从串口抓包反推:0x7E 夹住的那段十六进制到底是什么
1.1 一个 12 字节的帧,把两个协议的边界划出来
先把最常见的形态摆出来。假设你在一条 115200 的串口上抓到了这么一段:
7E FF 03 C0 21 09 01 00 08 7E去掉首尾两个7E,剩下 8 个字节。FF是地址字段,03是控制字段,C0 21是协议字段,09是 LCP 报文里的 Code,表示 Echo-Request,01是 Identifier,00 08是长度字段。这一串是 PPP 的 LCP 保活探测,一秒钟发一次都不奇怪。
再看另一种形态:
7E 0F 00 08 00 45 00 ... 7E同样是7E开头结尾,同样是地址加控制,但控制字段后面直接跟了08 00——这是以太网类型里 IPv4 的编号。地址字段是0F而不是FF,控制字段固定为00而不是03。这就是厂商私有扩展的 HDLC 封装,它在标准 HDLC 的地址/控制字段之后插了一个 2 字节的类型字段,把"这条链路上跑的是什么"这件事交给类型号去回答,而不是靠上层自己识别。
两个帧放在一起对比,你就能看出分界线在哪:PPP 用FF 03这对固定值,后面跟一个 2 字节协议号,协议号本身就是一个完整的命名空间;私有 HDLC 用0F 00这对固定值,后面跟的 2 字节在语义上更接近以太网的 EtherType。前者是"我告诉你这是 LCP 还是 IPCP 还是 IPv4",后者是"我告诉你这是 IPv4 还是 IPv6 还是保活"。理解了这个差异,后面所有的抓包判读都顺了。
还有第三种形态,也就是最"干净"的标准 HDLC:
7E 01 03 00 00 00 ... CRC CRC 7E地址01、控制03,没有协议号。这种帧在 X.25 的 LAPB 链路上很常见,03是 U 帧里的 UI(无编号信息),不做序号确认,纯承载数据。地址字段的01表示这是 DTE 侧,03表示 DCE 侧,具体哪边用哪个取决于谁是主站。
1.2 HDLC 帧头三件套:标志、地址、控制
理解 HDLC 得先接受一个前提:它是面向比特的,不是面向字节的。这句话听起来抽象,落到实际就是——标准 HDLC 在同步链路上根本不保证你的数据是字节对齐的,接收方靠标志序列01111110找帧边界,靠比特填充保证数据里不会碰巧出现这个序列。发送端每遇到 5 个连续的 1,就硬塞一个 0 进去;接收端看到 5 个 1 后面跟一个 0,就把这个 0 拿掉。这套机制在异步链路上是不用的,异步链路靠字节填充,也就是我们后面在 PPP 里会详细说的7D转义。
| 字段 | 长度 | 作用 | 常见取值 |
|---|---|---|---|
| Flag | 1 字节 | 帧定界 | 0x7E |
| Address | 1~N 字节 | 标识从站或方向 | 0x01、0x03、0x0F、0xFF |
| Control | 1~2 字节 | 帧类型与序号确认 | 0x00、0x03、0x63、0x83 |
| Information | 变长 | 承载数据 | 任意 |
| FCS | 2 或 4 字节 | 差错检测 | CRC-16 或 CRC-32 |
| Flag | 1 字节 | 帧定界 | 0x7E |
地址字段有个容易忽略的设计:每个字节的最低位是扩展位,为 1 表示这是地址的最后一个字节,为 0 表示后面还有。所以一个字节里真正能用的只有 7 位,最多 127 个地址。单字节场景下大家习惯直接用0x01、0x03、0x0F这些值,其实它们的二进制最低位都是 1,正好满足"结束"语义。
控制字段是 HDLC 的灵魂,下一节单独拆。这里先把 FCS 说清楚:标准 HDLC 默认用 CRC-16,多项式是 x^16+x^12+x^5+1,反转实现对应0x8408,初值0xFFFF,算完取反。PPP 的 FCS-16 跟这个完全一致,就是 CRC-16/X-25 那个变体。如果你自己写解析器,算出来的 FCS 是0x1234,那在线路上传输的顺序是先0x34再0x12——低位字节在前,这个细节坑过不少人。
1.3 PPP 在 HDLC 壳子上动了哪三处手术
PPP 的正式定义在 RFC 1661 里,它明说了自己是"HDLC-like framing",也就是说定位上就是个 HDLC 的变种,不是另起炉灶。但它在三个地方做了实质性改动,每一处都对应着工程上的一个痛点。
第一处是把地址和控制字段固定成FF 03。标准 HDLC 的地址用于多点链路上区分从站,控制用于序号确认和流控,而 PPP 的实际部署场景几乎清一色是点对点——一根线两头连两个设备,没有第三方。既然只有对端,地址就没有区分意义;既然上层协议(IP)自己有可靠性机制,链路层的序号确认反而成了累赘。所以 PPP 干脆把这 2 个字节的值写死,然后在 ACFC(地址控制字段压缩)协商成功后,这 2 个字节连发都不用发。你抓包时如果发现帧头是7E 21 45这种,别怀疑,那就是 ACFC 生效了,0021是 IPv4 的协议号。
第二处是加了 2 字节的协议字段。这一刀下去,PPP 从"一条链路跑一种协议"变成了"一条链路跑多种协议"。链路上同时可能有 LCP 的协商帧、CHAP 的认证帧、IPCP 的地址分配帧、IPv4 的数据帧、IPv6 的数据帧,全靠协议号区分。这种设计带来的直接好处是:认证、地址分配、链路质量监测这些功能可以各走各的协议号,互不干扰,也不需要额外开一条控制通道。
第三处是引入了完整的链路控制协议族。LCP 负责链路本身(最大接收单元、认证方式、魔术字、压缩选项),认证协议负责身份校验,NCP 负责具体网络层的参数协商。这三层是串行执行的,任何时候某一层失败了,链路就停在那个阶段,后面的都进不去。这就是为什么排错时必须先看日志停在哪个阶段——这个后面会展开。
2. HDLC 的控制字段:读懂它在说什么,比记住帧格式有用
2.1 I 帧、S 帧、U 帧的判别只看最低两位
控制字段的判别规则简单到有点反直觉:不用查表,看最低两位就行。
- 最低位是
0,这是 I 帧(信息帧),承载用户数据,同时捎带序号确认 - 最低两位是
01,这是 S 帧(监控帧),只做确认和重传请求,不带数据 - 最低两位是
11,这是 U 帧(无编号帧),用来建链、拆链、传无编号信息
I 帧的格式里,N(S) 是发送序号,N(R) 是接收序号(也就是"我期待你发下一个的编号"),P/F 位在中间。3 位序号构成模 8 的窗口,最多 7 个未确认帧;7 位序号构成模 128 的窗口,这个在 LAPB 的扩展模式下才用。
S 帧有四种,用中间两位区分:00是 RR(接收就绪),01是 REJ(拒绝,请求重传从 N(R) 开始的帧),10是 RNR(接收未就绪,相当于给对端一个流控信号),11是 SREJ(选择拒绝,只请求重传某一帧)。现场能见到的绝大多数是 RR,RNR 偶尔在设备缓冲区打满时出现,REJ 和 SREJ 基本意味着链路质量出问题了。
U 帧的编码空间最大,也最杂。建链用0x83(SNRM)或者0x93(SNRME,带扩展序号),对应的响应是0x63(UA,无编号确认)。拆链用0x43(DISC),响应同样是0x63。传无编号信息的用0x03(UI),测试链路用0xE3(TEST),对端必须原样返回。这些都是通过那 5 位 M 编码(bit 2~4 加上 bit 5~7)组合出来的,不是随便定的。
2.2 用 0x03 和 0x63 判断一次建链的握手节奏
我排查过一次串口链路起不来的问题,现象是设备指示灯显示"链路 UP",但上层业务完全不通。抓了一段码流,看到的是这样的循环:
<- 7E 01 83 ... 7E (SNRM,请求建链) -> 7E 01 63 ... 7E (UA,确认) <- 7E 01 03 00 ... 7E (UI,发数据) <- 7E 01 03 00 ... 7E (UI,发数据)83和63这一对出现,说明链路层的握手已经完成了,对端给了 UA。后面的03是 UI 帧,说明数据也在发了。那问题就不在链路层,得往上看。最后查出来是上层配置的地址映射错了,链路层本身没问题。这个案例的教训很直接:UA 出现 = HDLC 层面 OK,别在这个层面上继续耗。
反过来,如果看到的是一直重发83而没有63,那就是对端根本没响应。原因可能是波特率不匹配、接线反了(TX 和 RX 交叉)、对端没上电、或者对端配置成了 DCE 而你这端也是 DCE。如果83发了之后收到的是43(DISC),那说明对端在主动拆链,可能是它那边认为链路状态异常,或者配置层的"链路保持"没开。
还有一个容易误判的情况:收到的63是周期性的,每秒一次,但业务数据始终不通。这种"心跳型 UA"通常是设备在做链路层的心跳维持,只证明物理链路活着,不证明上层通。看到这种情况要立刻放弃在链路层找原因。
2.3 厂商私有扩展:把地址、控制字段"降级"成固定值
标准 HDLC 的那套序号确认机制在实际部署中经常被"架空"。很多厂商在实现自己的串口封装时,会把地址字段固定成0x0F、控制字段固定成0x00,然后在后面插入一个 2 字节的类型字段,直接照抄以太网的 EtherType 值:
| 类型值 | 含义 |
|---|---|
0x0800 | IPv4 |
0x86DD | IPv6 |
0x8035 | 链路保活(私有) |
0x0806 | ARP |
这种做法的逻辑很实在:链路层既然不需要寻址和流控,那这两个字段就是浪费;而上层协议的类型信息必须要传,那就借以太网的类型号体系,抓包软件也能直接识别,省得自己再造一套。
代价是,保活帧的格式变成了"私有约定"。你会看到这样的帧:
7E 0F 00 80 35 00 00 00 00 00 00 00 7E解码出来是一堆零,长度很短,没有 IP 头。它的实际内容是序列号和链路状态,按秒递增,对端连续若干次收不到就认为链路断了。由于这个格式没有公开标准,抓包软件只会把它当成未知协议显示成原始字节。
这里有个实操上很容易踩的坑:厂商私有 HDLC 的保活间隔通常默认 10 秒,连续 3 次丢失判定 down,也就是 30 秒。如果你的链路本身有间歇性误码,30 秒的容错窗口其实很窄。把间隔调大、次数调多,能显著减少"链路闪断"的假告警,代价是真正的断链要更晚才发现。这个取舍在做现场调试时必须和业务侧确认清楚。
3. PPP 建链的五个阶段:每一次协商失败都停在某个固定的位置
3.1 LCP 阶段:魔术字、MRU、ACCM 三个选项决定后面能不能跑通
PPP 的状态机是严格串行的:
- Link Dead——物理层还没起来
- Link Establishment——LCP 协商中
- Authentication——认证中(可选)
- Network-Layer Protocol——NCP 协商中,比如 IPCP
- Link Termination——拆链
第一步 LCP 是所有事情的前提。双方互相发 Configure-Request,里面塞一堆选项,对端要么回 Configure-Ack(全接受),要么回 Configure-Nak(部分值不接受,建议改),要么回 Configure-Reject(这个选项我不认,别发了)。三个响应里 Nak 和 Reject 是最容易让人迷惑的,因为它们看起来都像"失败",其实 Nak 是"能谈,改个数",Reject 是"这条免谈"。
| 选项号 | 名称 | 典型值 | 作用 |
|---|---|---|---|
| 1 | MRU | 1500 | 最大接收单元 |
| 2 | ACCM | FF FF FF FF | 异步控制字符映射,决定哪些字节要转义 |
| 3 | 认证协议 | 0xC023/0xC223 | PAP 或 CHAP |
| 5 | 魔术字 | 随机值 | 检测链路环路 |
| 7 | 协议字段压缩 | 无值 | 省掉 1 字节协议号 |
| 8 | 地址控制字段压缩 | 无值 | 省掉FF 03 |
| 9 | FCS 备选 | 32 位 | 改用 CRC-32 |
魔术字这个选项值得多说两句。它的作用只有一个:检测"自己跟自己说话"。如果一端发出的魔术字,在收到的 Configure-Request 里又出现了同样的值,说明链路成环了,必须拒绝。这个机制在串口自环、交换机环路、配置错误的桥接场景下能救命。我在一次实验室调试中遇到 PPP 起不来、日志刷屏的情况,最后发现是把两个端口用一根线短接了,TX 接到 RX,自己发的包自己收,魔术字直接把环路抓出来了。
ACCM 是另一个隐藏的坑。它的默认值是FF FF FF FF,意思是所有小于0x20的控制字符都要转义。这导致传输效率明显下降,因为你的数据里只要出现换行、制表符这些字节,就会膨胀成两个字节。协商成00 00 00 00可以彻底关掉这层转义,前提是链路上不存在会把控制字符吃掉的老式设备(比如某些早期的调制解调器会把XON、XOFF当成流控信号)。这个字段在抓包里表现为 LCP 选项里一个 4 字节的值,看到FF FF FF FF就知道还在用最保守的配置。
3.2 认证阶段:PAP 与 CHAP 在抓包里长得完全不一样
认证不是 PPP 的必选项,但一旦 LCP 里协商了认证协议,就必须走完。
PAP 只有两个报文:Authenticate-Request(Code 1)里明文携带用户名和密码,Authenticate-Ack(Code 2)表示通过,Authenticate-Nak(Code 3)表示拒绝。因为密码不加密,抓包软件能直接把用户名密码打印出来。现场看到 PAP 就要意识到这一点:这条链路上的凭据是裸奔的。
CHAP 是三次握手。服务端先发 Challenge(Code 1),带一个随机串和 Identifier;客户端算出MD5(Identifier || 密码 || 随机串),放进 Response(Code 2)发回去;服务端用自己存的密码算一遍,比对一致就回 Success(Code 3),否则回 Failure(Code 4)。整个过程密码不过线,而且 Identifier 和随机串每次都变,重放拿不到好处。
| 对比项 | PAP | CHAP |
|---|---|---|
| 握手次数 | 2 | 3 |
| 密码是否过线 | 是,明文 | 否,只传摘要 |
| 抓包能否直接看到密码 | 能 | 不能 |
| 能否重复认证 | 建链时一次 | 可周期性重新挑战 |
| 常见使用场景 | 老旧设备、调试期 | 生产环境 |
排错时判断卡在认证阶段有个简单办法:看日志里有没有出现PAP authentication failed或者CHAP authentication failed这样的字样。如果有,问题一定在凭据文件或者服务端配置上,跟链路质量、MTU、路由统统无关。如果日志只说Peer is not authorized或者干脆只有一句链路断开,那通常是服务端配置了认证要求,但客户端压根没配凭据,LCP 谈完就被踢了。
还有一个细节:CHAP 的密码不能超过一定的长度限制,而且某些实现对用户名大小写敏感。我遇到过客户端用大写用户名、服务端配的是小写,MD5 算出来的摘要永远对不上,日志里只有CHAP authentication failed,查了半小时才发现是大小写。这类问题在任何文档里都不会写,只能靠经验。
3.3 NCP 阶段:IPCP 怎么把地址和 DNS 塞给对端
认证过了之后,链路还不能跑 IP,得先协商网络层参数。IPv4 用的是 IPCP,报文格式跟 LCP 一模一样(Code、Identifier、Length、Options),只是选项号不同。
| IPCP 选项号 | 名称 | 长度 |
|---|---|---|
| 2 | IP 压缩协议 | 变长 |
| 3 | IP 地址 | 6 |
| 129 | 主 DNS | 6 |
| 131 | 备 DNS | 6 |
IP 地址选项的语义要分方向看:一端请求0.0.0.0,意思是"请你给我分配一个地址";一端请求具体的地址,意思是"我希望用这个地址"。服务端收到0.0.0.0的请求,回一个 Configure-Nak,里面写上要分配的地址,客户端再用这个地址重发 Configure-Request,服务端回 Ack,协商完成。
DNS 选项(129/131)不在最初的 IPCP 标准里,是后来补充的。很多数传模块在协商 IPCP 时会主动带上这两个选项,把运营商分配的 DNS 推给对端。如果你的设备始终拿不到 DNS,先看 IPCP 的 Configure-Request 里有没有带 129,没带的话就得手动配,别指望协商能给你。
NCP 阶段最常见的失败是"地址冲突"或者"服务端不分配地址"。前者表现为 IPCP 反复 Nak,后者表现为协商一直停在请求状态。还有一种更隐蔽的情况:IPCP 协商成功了,对端地址也拿到了,但网卡上没看到路由。这时候要检查的是有没有配defaultroute,以及 pppd 有没有权限去改路由表。
4. 搭一套能复现的实验环境:用虚拟串口把 pppd 跑起来
4.1 用 socat 造一对串口,绕开物理设备
想搞懂 PPP 的协商过程,最快的方法是自己跑一遍。没有物理串口也没关系,用 socat 造一对虚拟串口,把两端桥起来。
socat -d -d \ pty,raw,echo=0,link=/tmp/ttyV0 \ pty,raw,echo=0,link=/tmp/ttyV1这条命令会在/tmp下生成两个软链接/tmp/ttyV0和/tmp/ttyV1,分别指向两个伪终端,两端之间的数据是互通的。往ttyV0写的东西会从ttyV1读出来,反之亦然。raw,echo=0这两个参数很关键,前者关掉终端的行缓冲和各种特殊字符处理,后者关掉回显——如果不关回显,你写进去的字节会被原样吐回来,pppd 会以为链路成环了。
权限上,socat 创建的 pty 属主是当前用户,pppd 如果以普通用户身份跑倒还好,如果要改路由表就得上 root。建议的做法是先用普通用户把协商跑通,确认配置没问题,再考虑用 root 起服务。
这里有个必须提前说的坑:pppd 打开一个串口设备时,会尝试把它设成 PPP 线路规程(N_PPP)。真串口支持这个操作,但伪终端不一定支持,你会看到类似Couldn't set line discipline的报错然后直接退出。遇到这个情况有两条路:一是接受这个报错,改用notty选项让 pppd 走标准输入输出,再用 socat 的EXEC把两端接起来;二是干脆找两台真实设备,或者两个 USB 转串口模块交叉对接,这样最接近现场环境。
notty模式的基本形态是这样:
socat \ EXEC:"pppd notty noauth 10.0.0.1:10.0.0.2 debug" \ EXEC:"pppd notty noipdefault 10.0.0.2:10.0.0.1 debug"两端各自在一条管道上跑 pppd,socat 负责把两条管道的字节流对接起来。这种写法绕过了线路规程的问题,纯靠字节流跑,也能完整观察到 LCP、认证、IPCP 的过程。
4.2 pppd 的两端配置与那次"线路规程"报错
假设用虚拟串口方案,服务端和客户端的启动参数大致是这样:
# 服务端 pppd /tmp/ttyV0 115200 \ 10.10.0.1:10.10.0.2 \ local noauth \ mtu 1400 mru 1400 \ lcp-echo-interval 10 \ lcp-echo-failure 3 \ debug nodetach # 客户端 pppd /tmp/ttyV1 115200 \ noipdefault \ mtu 1400 mru 1400 \ lcp-echo-interval 10 \ lcp-echo-failure 3 \ debug nodetach10.10.0.1:10.10.0.2这个写法是 pppd 特有的语法,冒号前是本端地址,冒号后是给对端分配的地址。local的意思是"不修改串口的线路设置",在很多非标准串口上不加这个参数会直接失败。noauth表示不要求对端认证,本地实验用得上,生产环境千万别这么配。
nodetach让 pppd 留在前台,debug打开详细的协商日志。这两个组合起来,你能看到每一个 LCP、IPCP 报文的收发,比看系统日志直观得多。启动之后正常情况下会看到类似这样的输出:
sent [LCP ConfReq id=0x1 <mru 1400> <asyncmap 0x0> <magic 0x8a3c1d2f>] rcvd [LCP ConfReq id=0x1 <mru 1400> <asyncmap 0x0> <magic 0x5b7e9c11>] sent [LCP ConfAck id=0x1 <mru 1400> <asyncmap 0x0> <magic 0x5b7e9c11>] rcvd [LCP ConfAck id=0x1 <mru 1400> <asyncmap 0x0> <magic 0x8a3c1d2f>] sent [IPCP ConfReq id=0x1 <addr 10.10.0.1>] rcvd [IPCP ConfReq id=0x1 <addr 0.0.0.0>] sent [IPCP ConfNak id=0x1 <addr 10.10.0.2>] rcvd [IPCP ConfReq id=0x2 <addr 10.10.0.2>]这段日志的信息量很大:两个 ConfReq 交叉发出、互回 Ack,说明 LCP 完成;客户端发addr 0.0.0.0,服务端回 Nak 并指定10.10.0.2,客户端接受后重发,这是 IPCP 的标准流程。如果卡在某一对 ConfReq 之后没有 Ack,那就是对方不接受你的选项,得看它回的 Nak 或 Reject 里写了什么。
要加认证的话,凭据文件是这样的:
# /etc/ppp/chap-secrets(服务端) "user01" * "你的密码" 10.10.0.2 # /etc/ppp/chap-secrets(客户端) "user01" * "你的密码"服务端启动参数里加auth require-chap,客户端加user "user01"。注意客户端不要加auth,auth是"我要求对端认证",客户端是被认证方,加了反而会要求服务端也来一套。
4.3 用 pppd record 把原始码流落盘,再用抓包工具读
光看日志只能知道"协商了什么",看不到"线上跑了什么字节"。pppd 有个record选项能把串口的原始字节流完整写进文件:
pppd /tmp/ttyV0 115200 noauth local \ record /tmp/ppp-trace.bin \ debug nodetach这个文件里有完整的 PPP 帧,但因为链路是异步的,字节之间做了填充转义,直接看是十六进制乱码。用抓包软件打开时,要把封装格式选成支持方向的 PPP 形式——pppd 记录的文件在每段数据前会加一个方向字节,用来区分收和发。如果封装选错了,你会看到所有帧都被判定成格式错误,FCS 校验全红。
这个技巧的价值在于事后分析。现场环境不允许你架一台机器盯着看,但record可以一直开着,出问题之后把文件拿回来慢慢分析。我一般会在文件开头记下时间戳和配置参数,过几天再回来看也能对上。
另一个用途是验证字节填充的效果。把 record 下来的文件和 pppd 日志里显示的逻辑帧长度对比一下,如果物理字节数明显大于逻辑长度,说明 ACCM 的转义开销很大,可以考虑协商压缩。这个数字在链路带宽紧张的场景下很实在,比如 9600 波特的链路上,10% 的转义开销就意味着实际吞吐掉了 10%。
5. 现场调过的五个问题:认证、填充、MTU、保活、串口抢占
5.1 认证失败但日志只有一句"链路已断开"
这个问题在数传模块上极其常见。现象是模块上电后拨号成功、LCP 完成,然后链路断开,日志里没有任何authentication failed字样。注意,pppd 在认证失败时的日志级别依赖debug,如果没开debug,很多细节会被吞掉,只剩一句笼统的"Peer is not authorized"。
排查链路是这样的:
| 步骤 | 检查内容 | 判据 |
|---|---|---|
| 1 | 日志里有没有 LCP ConfReq 的认证选项 | 有auth选项说明对端要求认证 |
| 2 | 凭据文件是否被读取 | 用debug看有没有using password from ... |
| 3 | 用户名是否完全一致 | 包括大小写和引号 |
| 4 | 认证协议是否匹配 | 服务端require-chap,客户端不能只配 PAP |
| 5 | 密码里有没有特殊字符 | 引号和反斜杠需要转义 |
第 4 条是最容易出错的。如果服务端配了require-chap,客户端只支持 PAP,LCP 协商时服务端会拒绝客户端的认证协议选项,或者接受之后客户端压根不会算 CHAP 摘要,直接超时。反过来如果服务端只要 PAP,而客户端默认发 CHAP,也会卡住。p 参数用来强制指定客户端使用的认证方式,不清楚对端要求时可以先两个都试一遍。
5.2 字节填充让帧长对不上,FCS 校验失败
异步链路的 PPP 在发帧之前要做字节填充:数据里出现的0x7E要变成0x7D 0x5E,0x7D变成0x7D 0x5D,另外 ACCM 里置位的控制字符(默认全部)也要变成0x7D加原字节异或0x20。收端反过来做一遍。
这套规则导致一个现象:发送的逻辑帧是 20 字节,线上实际跑了 28 字节。如果你用抓包软件分析 record 文件时没有开启异步填充解码,FCS 会全部校验失败,因为它算的是填充后的字节。
排查这类问题的顺序是:先确认抓包工具的封装格式选对了,再确认 ACCM 的值。如果帧里频繁出现7D,而且分布很有规律(比如每个换行符之前都有一个),那就是 ACCM 没协商成零。有些老设备不会主动发 ACCM 选项,pppd 会用默认的FFFFFFFF,这时候只能靠对端也支持协商才能改。
还有一个更隐蔽的情况:FCS 算出来始终差一点点。原因可能是 CRC 的初始值或者最终取反的问题。自己写解析器时,标准的做法是初值FFFF、按位右移、多项式8408、最后取反。少做任何一步都会得到错误的结果,而且错误的结果看起来还挺像那么回事。
def ppp_fcs16(data: bytes) -> int: fcs = 0xFFFF for byte in data: fcs ^= byte for _ in range(8): if fcs & 1: fcs = (fcs >> 1) ^ 0x8408 else: fcs >>= 1 return fcs ^ 0xFFFF这个函数算出来的是 16 位整数,往线上放的时候低字节在前。验证方法很简单:一个只包含FF 03 C0 21的帧,算出 FCS 之后拼上去,再整体过一遍这个函数,结果应该是0xF0B8(这是 CRC-16/X-25 的"魔法残留值",任何正确的帧加上正确的 FCS 再过一遍都会得到这个数)。这个自检技巧我经常用来验证解析器的正确性。
5.3 MTU 没对齐:ping 能通,传文件就卡死
PPP 的默认 MRU 是 1500,很多设备也会老实地协商 1500。但链路的实际承载能力未必有这么大。比如某些工业模块的串口缓冲区只有 1KB 左右,超过这个长度的帧会被截断,表现出来就是小包通、大包丢。
诊断方法是逐步逼近:
ping -M do -s 1400 10.10.0.2 ping -M do -s 1472 10.10.0.2-M do禁止分片,-s指定数据长度,加上 28 字节的 IP 和 ICMP 头,1400 对应 1428 字节的帧。如果 1400 通、1472 不通,说明 MTU 大概在 1472 以下,具体值要一点点试。找到临界点之后,把 MTU 设成临界值减 20 留余量,比如临界 1450 就配 1430。
在 pppd 里配mtu 1430 mru 1430,两端要一致或者客户端不大于服务端。另外别忘了 TCP 的 MSS 也需要调整,否则大文件传输时上层照样会发出超长报文,被路由器丢弃后触发分片。
还有一个相关的坑:IPCP 协商出来的地址是私网地址,但设备上还配了一条默认路由走了别的出口。这种情况下 ping 对端能通(走 PPP 接口),但访问远端业务不通(走了错误的默认路由)。检查ip route输出,确认目标网段确实指向了ppp0。
5.4 保活超时与空闲断链
LCP 的 Echo-Request/Echo-Reply 是 PPP 用来检测链路存活的机制。lcp-echo-interval控制发送间隔,lcp-echo-failure控制连续多少次没响应就拆链。默认是 0 和 0,也就是不发探测。很多现场配置成 10 秒和 3 次,也就是 30 秒无响应就断。
这个机制在链路质量差的时候会误伤。串口链路上偶尔的误码可能导致几个 Echo 包丢失,累积到 3 次就断链,然后重连,业务跟着抖。我的做法是把 interval 调大(30 秒)、failure 调大(5 次),把容错窗口拉到 150 秒。代价是真断链时恢复慢,但对大多数周期性上报的业务来说,这个延迟可以接受。
另一种"空闲断链"跟 LCP 没关系,是运营商侧的策略。蜂窝数据链路如果一段时间没有流量,网络侧会主动释放承载,设备端的 ppp 接口就消失了。应对办法是配置persist让 pppd 自动重连,同时加一个定时任务定期发一个小包维持链路活跃。这个包不需要多大,几百字节足够,频率看运营商策略,一般几分钟一次。
判断是哪种断链,看日志里的拆除顺序:LCP 主动发了 Terminate-Request 的是本地拆的;收到 Terminate-Request 的是对端拆的;什么都没有,接口突然消失的,通常是底层承载没了,pppd 感知到串口异常后退出的。
5.5 串口设备被系统服务抢占
这个问题跟协议没关系,但杀伤力很大。现象是手动跑 pppd 一切正常,配成开机自启之后就打不开设备,日志报Couldn't open the serial port。
原因是系统里有些服务会主动扫描/dev/ttyUSB*和/dev/ttyACM*,试图识别接入的设备。它一旦打开这个设备,你的 pppd 就没法独占访问了,而且它还会往串口里写一些探测指令,把对端设备搞得一头雾水。
处理办法是三步:先找到占用设备的进程,用fuser或者lsof看谁打开了它;然后把那个服务对当前设备的扫描禁掉,通常是加一条 udev 规则或者在服务配置里排除;最后在 pppd 启动脚本里加一个等待逻辑,等设备节点稳定之后再启动,避免上电瞬间设备还没枚举完就去打开。
# 查看谁占用了串口 lsof /dev/ttyUSB0 fuser -v /dev/ttyUSB0 # 等设备出现再启动 while [ ! -e /dev/ttyUSB0 ]; do sleep 1; done pppd /dev/ttyUSB0 115200 ...这个等待逻辑看起来多余,但在冷启动场景下非常必要。USB 枚举的耗时不确定,快的时候一两秒,慢的时候十几秒,写死的sleep 5总有翻车的时候。
最后再提一点个人体会:调试链路层问题时,最容易犯的错是"跳级"。看到业务不通,直接去查应用日志、查防火墙、查路由,唯独不去看链路层到底有没有起来。我的习惯是先确认三层握手到哪一步:LCP 完成了没有、认证过了没有、IPCP 拿到地址了没有。这三个问题答清楚,排查范围立刻缩小一大半,剩下的基本都是配置细节,而不是玄学。