☰
Quectel CMUX驱动实战:一条物理串口复用AT、PPP与日志通道
2026/10/7 9:15:13 网站建设 项目流程

简介:嵌入式开发中,主控串口数量不足是常见瓶颈,而串口复用技术可通过软件将一条物理UART拆分为多条逻辑通道,无需改板就能同时承载AT指令、PPP拨号与日志输出。GSM 07.10协议定义了基于DLCI的多路复用帧格式,Linux内核通过n_gsm线路规程实现对该协议的解析与分发,生成ttyGSM设备节点供应用层访问。Quectel CMUX驱动基于这一机制,针对移远模组做了参数适配与异常恢复,适用于车载终端、工业路由器等场景。本文从内核配置、AT+CMUX激活到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 模组通过 AT+CMUX 命令开放这个能力;第二,主控侧有对应内核支持,Android 内核一般默认开启,纯 Linux 系统需要自己确认。满足这两个条件后,如果你的设备只有一个串口可用,但需要 AT + 数据 + 日志三路通信,那么 CMUX 是标准解。

反过来,如果你的主控有多个 UART,或者模组 USB 口可以被可靠占用,那就不值得开 CMUX。多一层协议就多一份排查负担,UART 是裸字节流,CMUX 帧会吃掉一部分带宽,波特率不高时吞吐损失尤其明显。我见过有人为省一颗串口芯片强行上 CMUX,结果 9600 波特率下数据吞吐连一半都跑不满,这就是典型的高射炮打蚊子。另外,CMUX 不能跨物理链路做负载均衡,也不能替代硬件流控,这些边界在立项时就要想清楚。

3. 用 AT+CMUX 打开模组多路复用:内核配置和最小操作步骤

3.1 先确认内核带不带 n_gsm 支持

在动手写任何代码之前,先确认系统里是否已经有 n_gsm。不同内核版本的模块名和 ioctl 定义略有差异,但基本检查路径是一致的。打开串口设备,观察 /dev 下是否出现 ttyGSM 相关节点,或者直接查内核配置。

# 检查当前内核是否启用了 n_gsm cat /boot/config-$(uname -r) | grep GSM # 如果输出 CONFIG_N_GSM=y,说明已经编译进内核 # 如果是 =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 设置线速、发送 AT+CMUX 进入多路复用态

模组默认上电后是普通 AT 模式,物理串口直接透传 AT 字节流。要进入多路复用态,需要先发一条 AT+CMUX 命令,让模组把协议栈切到 07.10 帧模式。这里有一个先后顺序容易踩坑:必须先发命令让模组切模式,再切换内核线路规程,顺序反了两边握手直接失败。

# 打开物理串口,先配置波特率 115200、raw 模式 stty -F /dev/ttyUSB2 115200 raw -echo # 发送 AT+CMUX 进入多路复用态 # 我这里常用参数组是 0,0,0,127,0,32,0,0,0 # 含义分别是:基本选项、无纠错、自动波特率、N1=127、T1 默认、N2=32 echo -e "AT+CMUX=0,0,0,127,0,32,0,0,0\r" > /dev/ttyUSB2 # 正常情况下模组返回 OK # 如果返回 ERROR,先确认模组固件是否支持 CMUX

参数说明:第一个 0 是 mode,0 表示基本选项(Basic Option),这是兼容性最好的封装方式;第二个 0 是 subset,0 表示不带纠错,1 表示带纠错,带纠错会显著降低吞吐,一般不用;第三个 0 是波特率标识,0 表示自动,也可以写 1 到 5 对应 9600 到 115200 等具体速率。N1=127 是最大帧长,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/ttyGSM1,DLCI 2 对应 /dev/ttyGSM2,一一对应,没有偏移。这里也顺带解释一个常见困惑:DLCI 0 是控制通道,内核自己管理,不需要也不应该手动使能。如果你在手册里看到 DLCI 0 相关的配置项,那多半是模组侧的初始化参数,不是主控侧该碰的东西。

4. 把 DLCI 通道映射成业务可用能力:AT、PPP 和 Android RIL

4.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' AT+CGDCONT=1,\"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 要复杂一层,因为 rild(RIL 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:tty,rild 需要 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 AT+CMUX 返回 ERROR,模组不进入复用态

现象:串口上发送 AT+CMUX 后模组返回 ERROR,而不是 OK,或者干脆无响应。

原因:最常见的是模组固件版本不支持 CMUX,或者在当前接口模式(比如某些 USB 枚举模式)下禁用该功能。其次是参数值超出模组能力范围,N1 超过固件限制时也会报错。还有一个隐蔽原因:波特率不匹配,主机发的指令模组根本没收到,自然无响应。

解决:先用 ATI 和 AT+CMUX=? 查询模组固件版本和能力列表,确认 CMUX 可用。再逐步缩小参数,把指令降为 AT+CMUX=0,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 参数不匹配。比如内核设 mtu=1500,模组侧 N1=127,帧大小超过模组能力时会被丢。也可能是 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 后重新执行 AT+CMUX 命令并重建 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 通公网,说明数据通道也正常。这个验证顺序是从上到下的,哪一步断了就知道问题在哪一段。另外建议在项目收尾时把 AT+CMUX 的参数组合固化进配置文档,不要靠现场工人临场发挥。

做这类驱动适配,我最大的习惯是保持一条可复现的操作记录,从内核配置到模组参数到业务层脚本全部落库。所谓玄学问题,大多数是参数没有对齐或者时序没有保证。希望这组步骤能帮你少走几轮弯路,一次把 CMUX 链路调通。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询