YAOTU INSIGHTS

Quectel CMUX驱动实战:一条物理串口复用AT、PPP与日志通道

Quectel CMUX驱动实战:一条物理串口复用AT、PPP与日志通道
简介嵌入式开发中主控串口数量不足是常见瓶颈而串口复用技术可通过软件将一条物理UART拆分为多条逻辑通道无需改板就能同时承载AT指令、PPP拨号与日志输出。GSM 07.10协议定义了基于DLCI的多路复用帧格式Linux内核通过n_gsm线路规程实现对该协议的解析与分发生成ttyGSM设备节点供应用层访问。Quectel CMUX驱动基于这一机制针对移远模组做了参数适配与异常恢复适用于车载终端、工业路由器等场景。本文从内核配置、ATCMUX激活到DLCI映射与PPP拨号完整梳理CMUX驱动的落地过程与常见故障排查帮助开发者少走弯路。1. 这个驱动解决的真问题一个物理串口怎么同时跑 AT、PPP 和日志Quectel 的 Linux/Android CMUX Driver V2.0.1 是一套把 GSM 07.10 多路复用协议落到内核串口层的驱动方案它要解决的是嵌入式开发里最常见的串口不够用。主控只有一个空闲 UART但设备上同时要跑 AT 指令、拨号数据、GPS 输出和调试日志传统做法要么加一颗串口扩展芯片要么换主控要么牺牲某一路功能。这套驱动的价值在于不用改硬件把一条物理串口拆成逻辑上的多条独立通道现有的 EC20、EC25 等移远模组大多支持这个特性。它对两类人最有用。一类是做车载终端、电力采集、工业路由器的硬件串口数量被主控封装死死限制出货后才发现需要更多通信口另一类是存量设备升级板子已经画完、模具已经开好只能靠软件层面把串口复用起来。无论哪一类你最终都要面对同一个问题驱动层怎么把 GSM 07.10 的 DLCI 通道映射成应用能直接 open 的设备节点。这篇文章会用一次完整的落地过程把协议原理、最小复现步骤、参数取舍和暗坑都讲一遍。2. GSM 07.10 协议与 CMUX 驱动的作用边界2.1 一条物理串口上的多路逻辑通道是怎么拆出来的GSM 07.10 本质上是一套串行链路的多路复用协议类似把一条公路改造成多条车道。物理链路上跑的是加了帧头的分包每一帧的地址字段里带一个 DLCI数据链路连接标识用来区分这条路属于哪条逻辑通道。DLCI 0 固定是控制通道负责链路的建立、拆除和流控协商DLCI 1 往上是业务通道可以分别映射 AT 指令、拨号数据、NMEA 定位信息等。这个协议早期是给 GSM 手机内部用的让应用处理器和基带处理器之间只通过一组 UART 就能交换信令、语音和数据。后来移远、SIMCom 这类模组厂商把这个能力开放出来让外部主控也能用同样的方式跟模组通信。我最初接触 EC20 时也犯过嘀咕模组本身有 USB 口直接虚拟出多个串口不好吗后来在纯 UART 项目里用了一次才明白USB 口在真实工业设备上经常被供电、静电和线缆长度坑到而 UART 只有三根线可靠性完全不同此时 CMUX 就是唯一不吃硬件的方案。2.2 驱动在 Linux 内核里的位置线路规程与 ttyGSM在 Linux 侧实现 CMUX 并不需要应用层写协议栈内核里的 n_gsm 线路规程line discipline已经封装好了 GSM 07.10 的帧解析和 DLCI 管理。线路规程是串口驱动之上的一层普通模式下的线路规程直接透传字节切换到 N_GSM 之后内核串口驱动收到的字节会先被拆成 07.10 帧再按 DLCI 分发到对应的 ttyGSM 子设备。Quectel 官方在这套驱动包里的工作主要是围绕 n_gsm 做适配和补充包括针对不同内核版本的补丁、与模组固件的参数对齐以及 Android 侧 RIL 需要的通道映射。V2.0.1 这个版本从命名看是跟随 EC20 的 cmux 行为和 GSM07.10 标准做的对齐发布典型的内核选项是 CONFIG_N_GSM。编译进内核后会生成 /dev/ttyGSM0 到 /dev/ttyGSM3 这类子设备每个对应的就是一条 DLCI。注意这里有个容易混淆的概念n_gsm 是内核标准线路规程不是 Quectel 独有的代码。移远的驱动包做的事情是把标准 n_gsm 和自家模组的打开时序、参数推荐值、异常恢复流程绑定在一起减少现场适配工作量。所以你在做方案评估时不要把它当成黑匣子底层仍然是公开的内核代码出了问题可以直接读源码定位。2.3 什么场景必须开 CMUX什么场景不该开开 CMUX 有两个前提条件。第一模组固件支持该功能EC20、EC25 等多数 LTE 模组通过 ATCMUX 命令开放这个能力第二主控侧有对应内核支持Android 内核一般默认开启纯 Linux 系统需要自己确认。满足这两个条件后如果你的设备只有一个串口可用但需要 AT 数据 日志三路通信那么 CMUX 是标准解。反过来如果你的主控有多个 UART或者模组 USB 口可以被可靠占用那就不值得开 CMUX。多一层协议就多一份排查负担UART 是裸字节流CMUX 帧会吃掉一部分带宽波特率不高时吞吐损失尤其明显。我见过有人为省一颗串口芯片强行上 CMUX结果 9600 波特率下数据吞吐连一半都跑不满这就是典型的高射炮打蚊子。另外CMUX 不能跨物理链路做负载均衡也不能替代硬件流控这些边界在立项时就要想清楚。3. 用 ATCMUX 打开模组多路复用内核配置和最小操作步骤3.1 先确认内核带不带 n_gsm 支持在动手写任何代码之前先确认系统里是否已经有 n_gsm。不同内核版本的模块名和 ioctl 定义略有差异但基本检查路径是一致的。打开串口设备观察 /dev 下是否出现 ttyGSM 相关节点或者直接查内核配置。# 检查当前内核是否启用了 n_gsm cat /boot/config-$(uname -r) | grep GSM # 如果输出 CONFIG_N_GSMy说明已经编译进内核 # 如果是 m可以手动加载模块 # 加载模块如果是 m 的情况 modprobe n_gsm # 检查是否出现 ttyGSM 子设备 ls /dev/ttyGSM*如果这一行 grep 没有任何输出说明内核没编这个功能。常见做法是用 menuconfig 重新配置内核Device Drivers - Character devices - Serial drivers - GSM 0710 tty multiplexor。注意Android 的 vendor 内核和主线内核在配置路径上可能略有不同但选项名称基本一致。这个开关配好之后才谈得上后面的事。3.2 设置线速、发送 ATCMUX 进入多路复用态模组默认上电后是普通 AT 模式物理串口直接透传 AT 字节流。要进入多路复用态需要先发一条 ATCMUX 命令让模组把协议栈切到 07.10 帧模式。这里有一个先后顺序容易踩坑必须先发命令让模组切模式再切换内核线路规程顺序反了两边握手直接失败。# 打开物理串口先配置波特率 115200、raw 模式 stty -F /dev/ttyUSB2 115200 raw -echo # 发送 ATCMUX 进入多路复用态 # 我这里常用参数组是 0,0,0,127,0,32,0,0,0 # 含义分别是基本选项、无纠错、自动波特率、N1127、T1 默认、N232 echo -e ATCMUX0,0,0,127,0,32,0,0,0\r /dev/ttyUSB2 # 正常情况下模组返回 OK # 如果返回 ERROR先确认模组固件是否支持 CMUX参数说明第一个 0 是 mode0 表示基本选项Basic Option这是兼容性最好的封装方式第二个 0 是 subset0 表示不带纠错1 表示带纠错带纠错会显著降低吞吐一般不用第三个 0 是波特率标识0 表示自动也可以写 1 到 5 对应 9600 到 115200 等具体速率。N1127 是最大帧长T1 和 N2 控制超时和重传次数这些参数需要和内核 n_gsm 的配置保持一致否则会出现能握手但传几包数据后断开的现象。3.3 切换内核线路规程到 N_GSM模组侧已经进入帧模式主控侧也必须把同一个物理串口的线路规程从默认值切换到 N_GSM。这一步是用 ioctl 完成的不能用简单的 shell 命令替代。下面的 C 代码是最小可运行版本重点是把配置结构体的 initiator 和 mru/mtu 设对。#include stdio.h #include fcntl.h #include unistd.h #include termios.h #include linux/gsmmux.h #include sys/ioctl.h #define N_GSM 21 int main(void) { int fd, ret; struct gsm_config gsm; /* 打开物理串口注意这里要用同一个端口 */ fd open(/dev/ttyUSB2, O_RDWR | O_NOCTTY); if (fd 0) { perror(open); return 1; } /* 切换到 N_GSM 线路规程 */ int ldisc N_GSM; ret ioctl(fd, TIOCSETD, ldisc); if (ret 0) { perror(TIOCSETD); return 1; } /* 读取当前配置再按需修改 */ ret ioctl(fd, GSMIOC_GETCONF, gsm); if (ret 0) { perror(GSMIOC_GETCONF); return 1; } /* 主控作为发起方基本选项封装 */ gsm.initiator 1; gsm.encapsulation 0; gsm.mru 127; gsm.mtu 127; ret ioctl(fd, GSMIOC_SETCONF, gsm); if (ret 0) { perror(GSMIOC_SETCONF); return 1; } printf(N_GSM enabled\n); close(fd); return 0; }这里的逻辑关键有三个。第一个是 TIOCSETD 把线路规程换掉之后该端口上的普通读写语义就变了不再是一字节一字节透传而是按 07.10 帧收发第二个是 GSMIOC_GETCONF 和 GSMIOC_SETCONF 的配对使用一定先读再写只改自己关心的字段避免覆盖内核默认值第三个是 initiator 必须设 1表示由这端发起链路建立如果设成 0 就成了被叫端两边会互相等对方先说话链路永远起不来。3.4 启用具体的 DLCI 通道并验证设备节点线路规程切好之后ttyGSM 设备还不会自动出现需要用 GSMIOC_ENABLE 逐个打开需要的 DLCI。这一步很容易被忽略因为内核不会默认帮你把所有通道建好。打开哪个通道取决于你准备让哪路业务跑在哪条逻辑链路上。#include stdio.h #include fcntl.h #include linux/gsmmux.h #include sys/ioctl.h int main(void) { int fd open(/dev/ttyUSB2, O_RDWR | O_NOCTTY); int dlci 1; /* 第一个用户通道DLCI 1 */ int ret; /* 使能 DLCI 1内核会创建 /dev/ttyGSM1 */ ret ioctl(fd, GSMIOC_ENABLE, dlci); if (ret 0) { perror(GSMIOC_ENABLE); return 1; } /* 再开一路给数据例如 DLCI 2 */ dlci 2; ret ioctl(fd, GSMIOC_ENABLE, dlci); if (ret 0) { perror(GSMIOC_ENABLE); return 1; } close(fd); return 0; }参数说明GSMIOC_ENABLE 传入的 int 值就是 DLCI 编号不要和后面生成的 ttyGSM 节点的后缀弄混。DLCI 1 对应 /dev/ttyGSM1DLCI 2 对应 /dev/ttyGSM2一一对应没有偏移。这里也顺带解释一个常见困惑DLCI 0 是控制通道内核自己管理不需要也不应该手动使能。如果你在手册里看到 DLCI 0 相关的配置项那多半是模组侧的初始化参数不是主控侧该碰的东西。4. 把 DLCI 通道映射成业务可用能力AT、PPP 和 Android RIL4.1 通道分配哪条 DLCI 给 AT哪条给数据模组进入复用状态后通道分配需要主控侧和业务侧达成一致。没有任何标准强制规定 DLCI 1 必须留给 AT 指令但正常工程上不会乱来。移远的推荐实践和绝大多数现网项目一样DLCI 1 固定做 AT 指令通道DLCI 2 做拨号数据通道或 PPP 链路DLCI 3 留给 GPS NMEA 输出或者日志有特殊需求的再往后排。这个约定俗成的分配方式能减少后续维护的心智负担接手项目的人不用猜。还有一个细节值得注意AT 通道和业务数据通道不能共享同一条 DLCI。因为 GSM 07.10 的帧头里 DLCI 是唯一的区分标识同一逻辑链路上混传 AT 指令和 PPP 帧会让上层协议完全无法解析。我在项目里见过有人为了省通道数把 AT 和 PPP 放在同一条 DLCI 上结果数据拨号期间任何 AT 指令都收不到响应因为 PPP 帧把链路占死了。宁可多用一条 DLCI也不要混用。4.2 用 ttyGSM 设备节点起 PPP 拨号通道映射到设备节点之后用法就和普通串口拨号几乎一致。下面是 PPP 拨号的最小命令序列其中用到的 chat 脚本和 pppd 配置都是常规套路唯一的不同是设备路径换成了 ttyGSM2。# 建立 PPP 连接DLCI 2 对应 /dev/ttyGSM2 # 先把 ttyGSM2 做成普通串口设备 stty -F /dev/ttyGSM2 115200 raw # 用 pppd 发起拨号 # 注意 modem 参数要指向 ttyGSM2不是原来的物理串口 pppd /dev/ttyGSM2 115200 \ lock \ crtscts \ connect /usr/sbin/chat -v \ -T 10099 \ -s \ AT \ OK ATCGDCONT1,\IP\,\cmnet\ \ OK ATD*99# \ CONNECT \ noipdefault \ usepeerdns \ defaultroute \ ipcp-accept-local参数说明connect 参数里的 chat 脚本完成从 AT 拨号到 CONNECT 的握手链路建立后 pppd 接管该设备进行 PPP 帧收发。crtscts 打开硬件流控CMUX 环境里强烈建议打开否则数据量大时容易因为缓冲溢出丢帧。noipdefault 避免 pppd 使用本机默认地址usepeerdns 让 DNS 从对端获取。这里需要留个心眼拨号通道的波特率和物理串口保持一致不要在 n_gsm 内部设备上再改波特率会导致出入队速度不匹配。4.3 Android 侧让 RIL 和 CMUX 并存Android 上处理 CMUX 比纯 Linux 要复杂一层因为 rildRIL daemon默认会抢占一个 AT 口。常用的做法是把 rild 的串口路径改到对应的 ttyGSM 节点上数据通道则独立给 PPP 或 native 网络栈使用。在 build 配置里修改 device 的 BoardConfig 或 init.rc 中的 ril 服务参数是比较典型的手段。这里给出一个 init.rc 风格的配置片段用来描述如何把 rild 指向 DLCI 1 所对应的设备# init.rc 中修改 rild 启动参数 service ril-daemon /system/bin/rild \ -- -d /dev/ttyGSM1 class main user root group radio cache inet misc socket rild stream 660 radio radio socket rild-debug stream 660 radio radio逻辑说明rild 进程通过 -d 参数指定 AT 通道设备这里把默认的串口或 USB 虚拟串口替换成 ttyGSM1。后面的 socket 配置保持不变RIL 的上层接口不感知底层设备变更。注意 user 和 group 的权限ttyGSM 节点默认属于 root:ttyrild 需要 radio 组权限才能打开必要时在 ueventd.rc 里补一条 /dev/ttyGSM* 的权限规则。Android 的坑主要在地上一是 selinux 策略需要放行 rild 对新的 ttyGSM 节点访问二是 CTS 里某些测试用例要求 AT 口路径稳定改动后要回归。我做过一个项目忽略 selinux 直接调工装结果所有 AT 指令被 audit 日志里的 denied 拦掉排查了整整一天。建议在 device manifest 或 vendordata 分区里把权限一次性配好不要指望临时 adb shell setenforce 0 能带到量产。5. 避坑CMUX 驱动落地时的高频故障与排查记录5.1 ATCMUX 返回 ERROR模组不进入复用态现象串口上发送 ATCMUX 后模组返回 ERROR而不是 OK或者干脆无响应。原因最常见的是模组固件版本不支持 CMUX或者在当前接口模式比如某些 USB 枚举模式下禁用该功能。其次是参数值超出模组能力范围N1 超过固件限制时也会报错。还有一个隐蔽原因波特率不匹配主机发的指令模组根本没收到自然无响应。解决先用 ATI 和 ATCMUX? 查询模组固件版本和能力列表确认 CMUX 可用。再逐步缩小参数把指令降为 ATCMUX0,0,0,127,0,32,0,0,0 这种保守组合。最后检查物理串口接线和波特率排除硬件层干扰。这一步排查顺序不要反先软件后硬件。5.2 切换线路规程后 ttyGSM 节点不出现现象TIOCSETD 成功但 /dev/ttyGSM1、/dev/ttyGSM2 一个都没生成。原因GSMIOC_ENABLE 没有被调用或调用了但 DLCI 编号传错。另一个可能原因是内核配置里 n_gsm 默认只开了固定的通道数设备节点创建失败且 dmesg 里没有对应日志。解决先用 ls /dev/ttyGSM* 确认节点状态再检查代码里是否调用了 GSMIOC_ENABLE。如果确认调用无误用 dmesg 查内核输出看是否报 ENOMEM 或者 tty 注册失败。内核日志里如果出现 gsm_device_create failed通常是因为 tty 设备号不够或者已经存在同名节点检查 /dev 下是否残留旧节点。5.3 数据通道出现乱码和周期性丢包现象AT 通道正常DLCI 2 的数据通道传大文件时频繁报错PPP 拨号能达到认证阶段但一传大数据就断。原因n_gsm 的 mru/mtu 和模组侧 N1 参数不匹配。比如内核设 mtu1500模组侧 N1127帧大小超过模组能力时会被丢。也可能是 tty 的硬件流控没有打开缓冲溢出后帧头错位后续所有帧解析全部混乱。解决把内核侧 gsm.mru 和 gsm.mtu 统一设为 127跟模组 N1 参数保持一致。打开 tty 的 CRTSCTS 硬件流控。如果物理链路没有 RTS/CTS 线就只能降低数据速率或者缩小帧长别指望软件能扛住缓冲溢出。5.4 切到 N_GSM 后物理串口再也回不到普通 AT 模式现象运行了切换线路规程的程序后同一个 tty USB 设备 open 后收到乱码无法用 AT 指令恢复。原因N_GSM 线路规程已经把物理串口的接收路径接管模组也从 AT 模式切到了帧模式。此时直接向物理 tty 写 AT 指令字节会被当成 07.10 帧的一部分模组侧无法识别。解决这是最容易翻车的地方。恢复手段通常是让模组掉电重启或者触发模组的复位引脚让模组重新进入普通 AT 模式。主控侧的程序最好设计成启动阶段判断如果发现链路已经处于 CMUX 态就先用 ioctl 把 DLCI 全部 disable 再关闭。我的血泪经验是在量产脚本里给模组复位留一个 GPIO否则现场只能拔电极不优雅。5.5 Android 设备休眠唤醒后 CMUX 链路假死现象Android 平板息屏休眠唤醒后发现 ttyGSM1 仍存在但发送 AT 指令无响应拨号也无法重连。原因内核串口驱动在 suspend 时挂起了物理 UART唤醒后线路规程的定时器和 DLCI 状态没有恢复正常模组侧因为长时间没收到帧可能已经自动关闭逻辑链路。解决在驱动或内核的 suspend/resume 回调里增加对 n_gsm 链路的复位流程。常见做法是在 resume 后重新执行 ATCMUX 命令并重建 DLCI。如果嫌底层改起来麻烦可以在应用层加一个看门狗线程定时 ping AT 通道超时就重启整个 CMUX 链路。Android 下还要注意 wakelock 不能长占否则功耗直接爆炸。6. 最后用抓帧和 AT 通道自检验证 CMUX 是否真正生效CMUX 链路到底建没建起来很多时候 dmesg 不报错但业务就是不通。我一般会在验证阶段做两件事一是抓物理链路上的 07.10 帧二是通过 AT 通道自检确认模组侧状态。抓帧可以用逻辑分析仪接在 UART 的 TX/RX 上重点观察帧的第一个字节地址字段。07.10 帧的地址字节里低 3 位是 DLCI 编号最高位是控制位。如果看到地址字段轮流出现 0x01 和 0x03 这类值说明链路确实在按 DLCI 分发而不是裸字节流。如果没有逻辑分析仪可以在代码里加一段统计记录内核接收到的帧总数和错误帧数对比一段时间内的比例稳定在 5% 以下基本可以接受。AT 通道自检的方法更直接。给 ttyGSM1 发 AT 指令如果模组正常返回说明控制通道工作正常然后再给 ttyGSM2 发 PPP 拨号如果链路建立并且能 ping 通公网说明数据通道也正常。这个验证顺序是从上到下的哪一步断了就知道问题在哪一段。另外建议在项目收尾时把 ATCMUX 的参数组合固化进配置文档不要靠现场工人临场发挥。做这类驱动适配我最大的习惯是保持一条可复现的操作记录从内核配置到模组参数到业务层脚本全部落库。所谓玄学问题大多数是参数没有对齐或者时序没有保证。希望这组步骤能帮你少走几轮弯路一次把 CMUX 链路调通。本文还有配套的精品资源点击获取