ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OPENPPP2:嵌入式PPP协议栈的状态机设计与网关集成

OPENPPP2:嵌入式PPP协议栈的状态机设计与网关集成 几个月前我在调一个嵌入式工业网关的通信链路设备通过串口挂载蜂窝模块运营商给的是一条标准的点对点线路底层必须走PPP协议栈来建链、认证和协商网络层参数。当时我接触到了这套自研的OPENPPP2网络驱动模式第一反应是“又是一个PPP轮子”真正用下来才发现它把驱动模式、状态机和收发路径拆得非常清楚解决了不少传统pppd在嵌入式场景下的老大难问题。这篇文章就围绕OPENPPP2展开聊聊PPP协议栈为什么还要重新实现、状态机是怎么流转的、收发路径如何设计以及我在ARM Linux网关上的完整集成记录。想搞DTU、工业路由器或者蜂窝模组拨号链路的朋友应该都能从里面找到能直接抄作业的部分。1. 老协议的新问题为什么要重新实现一个PPP驱动1.1 PPP协议三十年地位依然特殊很多人觉得PPP是上世纪拨号上网时代的遗物早该淘汰了。但真做工业联网就知道PPP或者说PPPoE这类点对点链路协议至今还在大量设备上跑着。蜂窝模块的AT拨号、电力集抄终端、工业DTU、部分专线接入设备底层都是PPP在维持链路。PPP解决的核心问题可以简单理解为“在两台设备之间用一条物理链路传网络层数据”时需要回答几个问题链路的参数是什么、两端身份怎么认证、IP地址从哪来、链路断了怎么发现。它把这些答案分成了三个子协议LCPLink Control Protocol链路控制协议负责建链、配置参数、链路质量检测、拆链。认证协议PAP或CHAP确认对端是不是合法设备。NCPNetwork Control Protocol网络控制协议IP场景下就是IPCP负责协商IP地址、DNS等。PPP帧本身沿用HDLC的封装思路一帧数据由Flag0x7E、Address0xFF、Control0x03、Protocol、Info和FCS组成。这种帧结构在点对点线路上非常简单直接裁剪起来也容易。1.2 pppd强归强嵌入式场景真的不合适Linux自带的pppd非常成熟功能全面是很多网关产品的首选。但我实际用下来它在嵌入式设备上有几个绕不开的问题。第一是体积和依赖。pppd是完整的用户态守护进程会加载PAM认证模块、插件机制、一大堆脚本配置。嵌入式设备往往连完整的glibc都不一定想带要裁剪pppd并不是做不到但每裁一个功能都要重新测试成本和风险都高。第二是资源开销。网关产品经常同时挂多个模块、多路串口多条PPP链路需要同时维持。pppd的典型做法是一个进程管一条链路进程切换、内存开销、配置文件复杂度都成倍增长。在只有几十MB内存的ARM盒子上这很奢侈。第三是调度时机不可控。pppd的定时器、读写循环、脚本调用都有自己的调度逻辑和设备主业务逻辑互相抢资源。做产品的人最怕这种“黑盒调度”出了问题都不知道从哪个日志开始查。1.3 OPENPPP2的定位轻量协议栈而不是完整进程OPENPPP2的出发点不是重新发明协议而是在嵌入式场景里用更可控的方式实现PPP协议栈。它的几个核心设计目标可裁剪通过编译宏决定是否包含CHAP、IPv6CP、压缩、回拨等功能不需要的代码直接不编进去。事件驱动状态机由事件驱动不依赖阻塞式读写线程方便和主业务逻辑共存。多链路复用同一套协议栈代码可以维护多条PPP链路链路间只隔离状态和缓冲不隔离进程。驱动抽象同一套LCP/NCP逻辑可以跑在串口、USB CDC、虚拟以太网等不同物理通道上网络驱动模式可以单独配置。如果把pppd比作一个全套装修好的精装房OPENPPP2就是一套可以按房间拆开的模块化家具。前者搬进嵌入式小户型太挤后者能根据房间大小自由组合。下面是两张方案的对比维度pppdOPENPPP2运行形态用户态守护进程协议栈库嵌入业务进程链路承载通常一个进程管一条链路一套代码管多条链路依赖PAM、脚本、插件框架基本无外部依赖调度方式独立事件循环业务侧统一事件驱动裁剪粒度按功能模块裁中等复杂按编译宏裁很细调试手段系统日志、脚本钩子状态机事件日志逐帧可查2. 从LCP到NCPOPENPPP2的状态机流转2.1 LCP阶段链路参数是怎么谈出来的PPP链路建立的第一步永远是LCP协商。双方互发Configure-Request请求里携带各种配置选项MRU最大接收单元默认1500、认证协议PAP或CHAP、魔术字、协议字段压缩、地址与控制字段压缩等。收到对方的Configure-Request后本端会逐个检查选项选项能接受对应回复Configure-Ack表示“这个配置我认了”。选项完全不认识回复Configure-Reject让对方下一条请求别带这个选项。选项值可以商量比如MRU我这边只支持1400就回复Configure-Nak附上我期望的值。这个过程不是谈一次就完而是反复交换配置请求直到双方都能接受或者重传超时链路失败。LCP的默认重传间隔一般是3秒最大重试次数限制在5次左右。OPENPPP2把这些参数做成可配置项方便在不同物理链路上调整。魔术字Magic Number这个选项容易被忽略但它是防回环的关键。PPP链路两端如果通过交叉线或者虚拟通道连接本端发出的配置请求可能被某种机制原封不动传回本端。如果没有魔术字设备会以为在对端说话实际上在跟自己握手然后进入死循环。魔术字给每个站点一个随机标识当收到消息里的魔术字等于本地魔术字说明这条消息是自己发出去的直接丢弃。OPENPPP2在LCP初始化时就生成魔术字协商失败重置链路也会重新生成避免两次链路复用同一个魔术字引起歧义。2.2 认证阶段PAP明文与CHAP挑战LCP协商通过后如果双方配置了认证链路会进入认证阶段。PPP这边主要就是两种认证方式。PAP的逻辑最简单客户端直接发用户名和密码服务端校验通过就回Authenticate-Ack不通过回Authenticate-Reject。整个过程密码在链路上一字排开没有任何保密措施。链路一旦被人抓包密码等于裸奔。现在新设备基本不会主动选PAP但很多早期蜂窝模块只支持PAP兼容老模组时还是得留着。CHAP则完全不一样。服务端随机生成一段Challenge发给客户端客户端用“密码 Challenge”做MD5摘要把摘要回给服务端。服务端自己也算一遍比对一致才认证通过。整个过程中密码本身不出现在链路上抓包只能抓到挑战值和摘要值安全性比PAP高了一个档次。OPENPPP2默认推荐CHAP并且在实现里对Challenge的长度、摘要计算做了统一管理避免不同编译器下大小端问题导致校验不一致。2.3 NCP阶段IP地址和路由参数从哪里来认证通过后链路进入网络阶段。IPv4场景对应IPCPIPv6场景对应IPv6CP。IPCP协商最重要的内容是本地IP地址、对端IP地址、DNS服务器地址。有三种典型情况两端都是静态IP协商就是一个确认过程。本端动态获取服务端分配地址OPENPPP2解析IPCP Option后把拿到的地址写入网络接口。两端都是动态地址比如某些专线场景双方同时申请地址这种处理起来最麻烦需要禁止IP地址冲突检测。协商完成后协议栈会把网络接口的状态置为Up业务侧就可以通过这个接口收发IP报文了。如果IPCP协商失败LCP会发起Terminate流程把链路状态打回Dead新一轮从头再来。OPENPPP2在这个阶段有完整的状态迁移日志哪一步失败能直接看到。2.4 状态机是怎么落地的一张状态表加事件队列OPENPPP2的状态机没有用复杂的框架核心就是一张二维状态迁移表行是当前状态列是触发事件格子里是执行动作和下一状态。这张表在编译期定义成只读常量运行期通过事件驱动框架查表执行。简化后的状态表示意当前状态事件动作下一状态Dead物理层载波检测到初始化本端魔术字等参数发送Configure-RequestEstablishEstablish收到Configure-Ack如果开启了认证发送认证请求否则发送IPCP协商AuthenticateAuthenticate认证通过发送IPCP Configure-RequestNetworkNetworkIPCP协商完成上报网络接口UpOpenOpen收到Terminate-Request回复Terminate-Ack清理接口Dead用C实现大概长这样static const struct ppp_state_action state_table[PPP_STATE_MAX][PPP_EVENT_MAX] { [PPP_STATE_DEAD] { [PPP_EVENT_CARRIER_ON] { .action ppp_lcp_start, .next PPP_STATE_ESTABLISH }, }, [PPP_STATE_ESTABLISH] { [PPP_EVENT_LCP_ACK_RCVD] { .action ppp_auth_start, .next PPP_STATE_AUTHENTICATE }, [PPP_EVENT_LCP_TIMEOUT] { .action ppp_link_abort, .next PPP_STATE_DEAD }, }, [PPP_STATE_AUTHENTICATE] { [PPP_EVENT_AUTH_OK] { .action ppp_ipcp_start, .next PPP_STATE_NETWORK }, [PPP_EVENT_AUTH_FAIL] { .action ppp_link_terminate, .next PPP_STATE_DEAD }, }, };这个方案的工程价值在于增设状态或事件时不用改一堆if-else只要加一行状态表项和对应的动作函数。我做过的很多协议栈后期维护最怕的就是把判断逻辑散落在各个回调里状态一多就乱。表格化之后整个链路生命周期一眼就能看全。2.5 超时与重传的工程细节PPP链路有个特点状态机跑在协议任务里但包是从物理中断或驱动回调里进来的。如果直接在中断上下文里执行状态迁移很容易和协议任务里的超时处理抢锁。OPENPPP2的做法是在中断或驱动回调里只做一件事把事件丢进对应链路的专用队列。协议任务从队列里取事件查状态表执行动作。重传定时器也是同样的思路维护在协议任务里用单调时钟而不是系统实时时钟避免设备改了系统时间导致定时器错乱。链路闪断重连时定时器要能随时重置旧定时器不能干扰新链路的状态机。3. “网络驱动模式”的收发路径是怎么设计的3.1 这里说的驱动模式到底指什么OPENPPP2里“网络驱动模式”有两层含义。第一层是底层物理通道用哪种方式收包中断模式、轮询模式、混合模式。第二层是协议栈以什么姿态接入系统网络接口。理解清楚这两层后面配参数才不会懵。底层收包模式的选择主要看物理通道类型串口UART适合中断模式字符逐个进来每收到一个字节触发一次中断。低速下响应很及时但高速时中断太频繁CPU大量时间花在上下文切换上。USB CDC适合轮询模式主机和设备之间有批量传输端点数据按包到达轮询批量取包比逐个中断高效很多。带DMA的以太网PHY或高速串口适合混合模式DMA完成一次批量搬运后触发一次中断驱动在中断里取走整批数据既有轮询的效率又有中断的及时性。第二层接入方式OPENPPP2支持两种对接直接对接业务侧协议栈或者通过系统虚拟网卡接口对外提供网络设备。前者适合协议栈深度嵌入产品固件的场景后者适合跑在Linux上、需要和标准网络工具链兼容的场景。我这次集成用的是后者创建tun设备OPENPPP2把IP报文解出来之后直接塞进tun接口。串口、USB CDC、以太网PHY三种通道的驱动模式选择可以参考这个表物理通道推荐模式原因低速串口115200以下中断模式字节速率可控响应及时高速串口1Mbps以上混合模式减少中断次数配合DMA取包USB CDC轮询模式批量端点天然适合批量取包以太网PHY混合模式DMA搬运后统一中断处理性能最好3.2 收包侧的逐字节状态机PPP链路本质上是字节流驱动层必须自己完成“找帧边界”的工作。OPENPPP2在驱动层维护一个很小的接收状态机逐字节处理不依赖整帧缓冲。接收状态机的流程enum ppp_rx_state { RX_WAIT_FLAG, // 等待起始标志 0x7E RX_RECEIVE, // 正常接收数据 RX_ESCAPE, // 遇到转义符 0x7D }; void ppp_rx_byte(struct ppp_dev *ppp, uint8_t byte) { switch (ppp-rx_state) { case RX_WAIT_FLAG: if (byte PPP_FLAG) { ppp-rx_state RX_RECEIVE; ppp-rx_len 0; ppp-rx_fcs PPP_INIT_FCS; } break; case RX_RECEIVE: if (byte PPP_FLAG) { // 一个帧完整结束FCS校验通过就派发 if (ppp-rx_len 4 ppp-rx_fcs PPP_GOOD_FCS) { ppp_dispatch_frame(ppp); } ppp-rx_len 0; ppp-rx_fcs PPP_INIT_FCS; } else if (byte PPP_ESCAPE) { ppp-rx_state RX_ESCAPE; } else { ppp_rx_append(ppp, byte); } break; case RX_ESCAPE: // 转义后的字节要异或 0x20 还原 ppp_rx_append(ppp, byte ^ 0x20); ppp-rx_state RX_RECEIVE; break; } }这个状态机是HDLC透明传输的基础实现。PPP帧里不可能直接出现0x7E因为0x7E是标志字节。发送端遇到数据里的0x7E会把它拆成两个字节0x7D 0x5E遇到0x7D拆成0x7D 0x5D。接收端看到0x7D就把后面那个字节异或0x20还原出原始数据。这也是为什么很多PPP抓包工具里看到连续的7D 5E其实就是原始数据里的7E。帧接收完成后先检查FCS。PPP用CRC16校验虽然CRC32的检错能力更强但PPP标准里CRC16是主流OPENPPP2默认用的是CRC16。FCS字段本身在传输前也要做转义处理这一点很多人容易漏。3.3 发包侧的封装与填充发送路径和接收路径是一个镜像过程。协议栈把待发送的IP报文交给驱动层后驱动层需要填充地址字段0xFF、控制字段0x03、协议字段。对数据区做字节填充把0x7E转义成0x7D 0x5E把0x7D转义成0x7D 0x5D。计算FCS并追加到帧尾FCS字段本身也参与转义处理。在帧首尾添加0x7E标志。有一个很典型的坑只在数据区做转义忘记FCS字段也会包含0x7E或者0x7D。一旦FCS里出现0x7E对端接收时会把这个字节当成帧结束标志导致FCS错位校验必然失败链路看起来就是“收包正常、发包对方收不到”。OPENPPP2在发送路径上统一处理整个帧数据区和FCS一起转义从源头上规避了这个问题。3.4 缓冲管理与协议派发PPP链路速率不高但小包很多。如果每收一帧都动态分配内存碎片化和分配开销在长期运行的设备上是笔不小的成本。OPENPPP2的处理方式是预分配帧缓冲池每个链路一个独立的池子接收帧时直接从池子里取固定大小的缓冲区用完还回去。收完一帧后根据以太网类型字段也就是PPP帧里的Protocol字段做派发0xC021 开头的走LCP状态机。0xC023/0xC223 走PAP/CHAP认证逻辑。0x8021 走IPCP协商。0x0021 是IPv4报文上送网络接口。0x0057 是IPv6报文上送IPv6协议栈。上送环节尽量保持“零拷贝”思路。缓冲区在接收时已经把整个帧放在连续内存里上送网络接口时只传缓冲区的指针和长度不重新组装一个新包。要上送IP报文时驱动层把PPP帧头字段裁掉把数据区起始地址后移就能让上层拿到一个完整的IP报文。这种方式在速率不高的PPP链路上完全够用还能替设备省出不少内存。4. 在ARM Linux网关上的集成实录从零跑到稳定4.1 开发环境与整体架构我这次的目标平台是Cortex-A7的双网口Linux网关物理通道是UART接一个4G模块需要拨号建立一条点对点数据链路。跑在Linux上所以网络接口用了tun设备OPENPPP2作为用户态协议栈运行在业务进程里。整体架构分四层硬件驱动层UART驱动负责收发原始数据。物理接入层AT指令控制模块拨号建立物理链路。OPENPPP2协议栈层完成LCP、认证、IPCP协商维护链路状态。系统网络层协商完成后把获取到的IP地址配置到tun接口让Linux网络栈正式接管。这种分层的好处是哪一层出问题就在哪一层查不用整个链路推倒重来。物理链路没起来就不该进LCPLCP没通就不该开IPCPIPCP没协商完tun接口就不该配置地址。4.2 参数配置与初始化代码OPENPPP2启动前需要配置链路参数。下面是一个实际用过的配置示例static const struct ppp_config ppp_cfg { .dev_name ppp0, .mode PPP_MODE_TUN, .phy_mode PPP_PHY_UART, .baudrate 115200, .device /dev/ttyS1, .local_addr { 0 }, // 0 表示交给对端分配 .peer_addr { 0 }, .auth_proto PPP_AUTH_CHAP, // 优先 CHAP .username industrial_gw, .password ****************, .mru 1500, .lcp_retry_ms 3000, .lcp_max_retry 5, .ipcp_retry_ms 1000, .ipcp_max_retry 5, .keepalive_ms 10000, };初始化流程大致是注册UART驱动回调把串口收上来的字节流喂给OPENPPP2的接收状态机。通过AT指令序列把模块附网、拨号到运营商P2P接入点。创建tun设备设置好UP标志。调用OPENPPP2的启动接口状态机进入Dead状态。物理层载波检测到触发Carrier On事件状态机开始LCP流程。这里有几个细节要注意。串口波特率必须和模块协商一致否则LCP请求发出去对方根本解不出来表现为本端不断重传、对端毫无回应。AT拨号这一步必须等模块返回成功不能一上来就启动LCP否则物理链路还没就绪状态机会直接超时回到Dead。4.3 联调我怎么确认链路真的建起来了链路联调最怕的就是“线路Up了但啥都不通”所以我把验证拆成三层。第一层看原始字节流。用逻辑分析仪或者串口助手抓串口数据看有没有连续的0x7E标志有没有7D转义序列。这一层能确认物理通道通不通、字节序对不对。第二层看LCP协商过程。OPENPPP2在启动时打开状态机事件日志能看到链路从Dead到Establish到Authenticate再到Network的过程。如果卡在Establish大概率是参数协商不匹配可以手动设小MRU验证一下如果卡在Authenticate优先检查用户名密码和CHAP算法是否一致。第三层看IP层连通性。链路进入Open状态后tun接口应该被分配了IP地址。直接用PING测试对端网关ip addr show ppp0 ping -I ppp0 192.168.100.1PING通则说明LCP、认证、IPCP三层都正常IP报文已经能完整走上行链路。我实测下来正常情况下从串口收到AT拨号成功到PING通大约在2秒左右主要时间花在LCP重传等待和认证处理上。4.4 踩过的坑和调优记录联调过程中遇到几个问题这里完整记录一下排查思路。第一个问题是LCP重传风暴。设备上电后串口疯狂输出Configure-Request对端完全不回应。排查过程是先抓串口电平确认模块AT侧有没有数据回显。结果发现模块根本没进入数据模式AT拨号并没有真正成功。根因是开机后模块需要等待网络注册我拨号指令发太早了。解决方法是AT拨号前先轮询模块返回的注册状态码注册成功后再发拨号指令问题立即消失。这提醒我LCP重传风暴很多时候根因不在LCP本身而在物理链路没ready。第二个是CHAP认证偶发失败。设备重启后有时会认证失败但重启几次又好了。最后定位到密码字符串里有一个不可见字符配置从网页后台传入时被转义处理过和模块内部保存的密码不一致导致MD5摘要始终对不上。这类问题排查时不要只盯着协议层先把两端真实使用的字节序列逐字节打印出来比对一遍。第三个是MTU不一致导致的分片。运营商P2P链路的MRU有时只有1492我方配置1500IPCP协商时对端会发Config-Nak请求把MRU降下来。如果协议栈实现太粗糙忽略Nak直接Ack链路建立后大包就会分片小包正常、大包丢表现非常诡异。OPENPPP2在LCP里完整处理了Config-Nak路径但这提醒了我要仔细看协商日志不要只看最终状态。调优方面我最终把LCP重传间隔从默认的3秒缩短到1秒链路恢复速度明显提升。代价是对端参数也要能接受有些老模块的处理逻辑跟不上太快的重传反而会把重传当成新会话导致两边各自发起Terminate。所以这个参数调的时候要实测不能拍脑袋。另外帧缓冲池的数量不能省。低速链路下确实不太用得到但链路抖动时重传和突发小包会同时涌进来缓冲池太少会导致丢帧表现为偶发PING丢包。我最后把缓冲池从16个提到32个稳定跑了两周没再复现。如果你也在做类似的嵌入式链路接入我的建议是先把物理通道跑通再进LCP这是所有PPP问题排查的前提。OPENPPP2这套驱动模式的可贵之处在于它能让你清清楚楚看到链路到底卡在哪一步而不是一锅粥式地靠猜。这套东西我们目前还在往USB虚拟网卡方向扩展已经在另一款产品上跑稳定了。
返回列表