做嵌入式Linux开发,凡是跟4G模块打过交道的,十有八九都经历过这种场景:调试时用的移动卡插上去,PPP拨号一次就通,等到设备发到现场、客户换上自己的联通卡或电信卡,死活拨不上去,最后问题单全堆到你头上。原因其实不复杂——拨号脚本里的APN、用户名这些参数是写死的。这篇文章以移远EC20模块为例,把“多运营商自适应PPP拨号”这条链路完整拆开讲一遍:通过AT指令读取IMSI识别当前插入的是哪家运营商的SIM卡,再根据识别结果动态生成PPP拨号参数和网络配置。适合正在做4G联网设备、或者已经被“换卡就死”坑过一轮的嵌入式Linux工程师参考。
1. 为什么一个“拨号”要有运营商意识
1.1 写死配置文件,迟早要背锅
先复盘一个很典型的Bug。有个网关项目,第一版固件里拨号脚本特别简单:pppd挂在ttyUSB0上,chat脚本里APN固定填cmnet,发ATD99**1#。开发阶段一直插的移动卡,一切正常,产品经理的注意力全放在MQTT和业务逻辑上,没人较真过“如果客户用联通卡怎么办”。直到有一批货发到现场,客户装了联通卡,上报模块一直处于“PPP连接失败”状态。
我去现场排查,第一件事就是AT+COPS?读当前PLMN,结果是CHN-CUGSM,说明网络实际已经注册上了。再执行AT+CIMI读IMSI,前缀是46001,一眼就知道这是联通卡。可系统里拨号配置文件还死死写着cmnet。把APN改成3gnet,重拨,网络立刻通了。
这个事情启发我:嵌入式设备做4G联网,运营商识别不是可选项,而是基本需求。尤其是公版方案批量出货时,你根本不知道客户会往设备里插什么卡。与其让售后背着工具包跑现场,不如在一开始就把自动识别的逻辑做实。
1.2 IMSI、PLMN、APN:先理清这三个概念
在展开代码之前,先把三个经常混在一起的名词捋清楚。
IMSI是SIM卡的全球唯一标识,国际移动用户识别码。它写在USIM卡里,长度15位,模块可以通过AT+CIMI读出来。IMSI的前3位是MCC(移动国家码),中国是460;紧随其后的2到3位是MNC(移动网络码),用来区分运营商。对于国内卡,只看前5位基本就能确定运营商。关键是,IMSI不依赖网络信号也能读到,只要卡和模块触点接触良好、供电正常,就能返回。
PLMN是模块当前注册到的网络标识,由MCC+MNC组成。通过AT+COPS?可以读到。它适合用来确认当前网络,但有个致命问题:在模块开机不久、还没找到网络的时候,返回结果可能为空或者停留在上一次的值。如果你靠它来选APN,刚上电那几秒很容易误判。
APN是接入点名称,运营商给设备上网用的“网关入口”。移动常见的公网APN是cmnet,联通最常用的是3gnet或uninet,电信主推的是ctnet。APN填错了,模块在核心网侧就无法建立PDN连接,表现就是PPP拨号认证通过、链路建立,但拿不到数据,或者直接报错断开。
把这几个概念放到一起,我的做法就很清晰了:开机后用AT+CIMI拿IMSI,按前5位判断运营商,然后选择对应的APN、拨号号码和认证方式。读不到IMSI的时候,再用AT+COPS?兜底。最后实在不行才走默认配置并打告警日志。
| 运营商 | IMSI前缀(MNC) | 常用APN | 拨号号码 |
|---|---|---|---|
| 中国移动 | 46000 / 46002 / 46007 | cmnet | 99**1# |
| 中国联通 | 46001 / 46006 | 3gnet | 99**1# |
| 中国电信 | 46003 / 46005 / 46011 | ctnet | 99**1# |
2. IMSI识别:让模块先告诉你“我是谁”
2.1 EC20上电后的AT通道准备
EC20模块通过USB挂载到嵌入式Linux后,通常会在/dev下枚举出一串ttyUSB设备。不同的固件版本、不同的硬件设计,设备节点编号并不完全一致,所以第一步不是急着写读IMSI的代码,而是先确认哪个口是AT命令口。
我的习惯是在板子上做一次端口探测。写个简单的串口工具,轮询每个ttyUSB口,能收到OK的就是AT口。下面是这种工具最基础的样子:
// atcmd.c // 用法: atcmd /dev/ttyUSB2 "AT+CIMI" #include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <termios.h> int main(int argc, char *argv[]) { if (argc < 3) { fprintf(stderr, "Usage: %s <serial-device> <at-command>\n", argv[0]); return 1; } int fd = open(argv[1], O_RDWR | O_NOCTTY | O_NDELAY); if (fd < 0) { perror("open"); return 1; } struct termios oldtio, newtio; tcgetattr(fd, &oldtio); tcgetattr(fd, &newtio); cfsetispeed(&newtio, B115200); cfsetospeed(&newtio, B115200); newtio.c_cflag |= (CLOCAL | CREAD); newtio.c_cflag &= ~CSTOPB; newtio.c_cflag &= ~PARENB; newtio.c_cflag &= ~CSIZE; newtio.c_cflag |= CS8; newtio.c_iflag = IGNPAR; newtio.c_oflag = 0; newtio.c_lflag = 0; tcflush(fd, TCIOFLUSH); tcsetattr(fd, TCSANOW, &newtio); char cmd[128]; snprintf(cmd, sizeof(cmd), "%s\r\n", argv[2]); write(fd, cmd, strlen(cmd)); usleep(500000); char buf[512] = {0}; int n = read(fd, buf, sizeof(buf) - 1); if (n > 0) { buf[n] = '\0'; printf("%s", buf); } tcsetattr(fd, TCSANOW, &oldtio); close(fd); return 0; }编译成arm版拷到板子里,先跑一句:
./atcmd /dev/ttyUSB2 "AT"能返回OK,说明这个口就是AT口。很多EC20固件里ttyUSB2通常是AT口,但千万别依赖编号,因为同一个模块在不同Linux内核版本下枚举顺序都可能变。把这个工具做成通用件,后面所有AT操作都能复用它。
确认AT口之后,要等模块就绪和SIM卡就绪。上电后模块需要一点时间完成内部初始化,建议用循环轮询的方式等两个条件:AT命令有响应、AT+CPIN?返回+CPIN: READY。前者说明串口链路通,后者说明卡已经识别,没有这两个条件直接读IMSI很容易读到空值或者报ERROR。
2.2 用AT+CIMI读取IMSI并解析运营商
模块准备就绪后,读IMSI就很简单了:
./atcmd /dev/ttyUSB2 "AT+CIMI"正常情况下模块返回一行数字,类似:
460012345678901 OK用脚本处理时,把这行数字抓出来,取前5位判断运营商。下面这段是busybox ash环境下可直接跑的识别逻辑:
#!/bin/sh TTY_AT=/dev/ttyUSB2 ATCMD=/usr/bin/atcmd # 等待模块响应 i=0 while [ $i -lt 30 ]; do R=$( $ATCMD $TTY_AT "AT" 2>/dev/null ) case "$R" in *OK*) break ;; esac sleep 1 i=$((i + 1)) done # 等待SIM卡READY i=0 while [ $i -lt 10 ]; do R=$( $ATCMD $TTY_AT "AT+CPIN?" 2>/dev/null ) case "$R" in *READY*) break ;; esac sleep 1 i=$((i + 1)) done # 读取IMSI并取前5位 IMSI=$( $ATCMD $TTY_AT "AT+CIMI" 2>/dev/null | sed -n 's/^\([0-9]\{15\}\)$/\1/p' ) MCCMNC=$( echo "$IMSI" | cut -c 1-5 ) case "$MCCMNC" in 46000|46002|46007) OPERATOR="cmcc" APN="cmnet" ;; 46001|46006) OPERATOR="cucc" APN="3gnet" ;; 46003|46005|46011) OPERATOR="ctcc" APN="ctnet" ;; *) OPERATOR="unknown" APN="cmnet" ;; esac echo "IMSI=$IMSI MCCMNC=$MCCMNC OPERATOR=$OPERATOR APN=$APN"这里有个细节:sed -n 's/^\([0-9]\{15\}\)$/\1/p'是为了只保留纯15位数字行,把AT回显、OK这些行过滤掉。如果你的AT工具已经做了过滤,这里可以简化成直接取最后一个数字字段。
2.3 我见过几种“读不到IMSI”的奇葩原因
IMSI识别逻辑看着简单,真正被坑过的次数不少。最常见的是下面几种:
SIM卡没插到位或金属触点氧化。EC20模块的卡座如果焊接偏了一点点,卡插进去就是接触不良。表现出来就是AT+CPIN?一直返回ERROR或者+CME ERROR。这种问题在量产板里很常见,焊接虚焊的批次能坑掉你两三天。
上电太快,模块没完全起来就发指令。模块还在初始化阶段,AT通道还没建立,发过去的命令没人接收。所以我习惯把AT等待循环放到一个独立的函数里,所有AT操作的前置条件都是“模块已就绪”。
SIM卡热插拔之后,读到的还是旧卡的IMSI。这个坑非常隐蔽。有几次我在帮客户定位问题时,一边用USB转串口连着模块,一边手动拔卡换卡,结果AT+CIMI返回的始终是上一张卡的数据。后来发现模块内部的SIM卡状态机还停留在旧状态,光发AT指令刷新不了。解决办法是重启模块的射频功能,最省事的两条指令:
./atcmd /dev/ttyUSB2 "AT+CFUN=0" sleep 1 ./atcmd /dev/ttyUSB2 "AT+CFUN=1"有些固件还需要彻底断电或者拉一下复位脚。所以我在量产固件里做的约定是:换卡操作必须断电进行,软件检测到IMSI非法或连续N次读到相同异常值时,自动执行一次模块软复位。
如果IMSI始终读不到,我会再尝试AT+COPS?拿到当前PLMN作为兜底判断,再不行就按默认移动配置去拨,并把告警信息上报到平台。毕竟产品不能因为识别失败就彻底离线,先拨上去再慢慢纠正也算一种容错。
3. 生成拨号参数:从IMSI到pppd配置
3.1 整个拨号脚本的执行骨架
识别出运营商之后,接下来就是把“应该用哪个APN、哪个拨号号码”翻译成pppd能执行的配置。我在产品里通常把这些脚本放在/etc/ppp目录下,整体分为三块:主控脚本ec20_auto_dial.sh、chat对话模板、pppd的peer配置文件。
主控脚本的任务是:调用上面的IMSI识别逻辑,生成对应的chat脚本,然后拉起pppd进程。生成chat脚本的代码是这样:
gen_chat_conf() { cat > /tmp/chat.conf <<EOF TIMEOUT 10 ABORT "NO CARRIER" ABORT "ERROR" "" AT OK ATE0V1 OK "AT+CGDCONT=1,\"IP\",\"$APN\"" OK ATD*99***1# CONNECT "" EOF }注意APN是动态填入的,其他部分大致通用。生成完chat脚本,再拉起pppd:
start_pppd() { killall pppd 2>/dev/null sleep 1 pppd /dev/ttyUSB3 115200 \ lock crtscts modem noauth noipdefault \ defaultroute usepeerdns \ connect "/usr/sbin/chat -v -t 15 -f /tmp/chat.conf" \ logfile /var/log/pppd.log & # 等待接口出现 for i in $(seq 1 20); do ip addr show ppp0 >/dev/null 2>&1 && break sleep 1 done }这个脚本里,我把PPP数据口写成了/dev/ttyUSB3,但正如前面说的,不同板子枚举不同,生产时应该通过配置项指定,或者通过AT口信息自动推算。
3.2 chat脚本里的时序细节
很多新手第一次看到chat脚本会觉得莫名其妙:这些字符串是干什么的?其实chat的本质就是一个“把模块返回的字符串和我们要发送的字符串配对”的状态机。每一行由两个字段组成:期望收到的字符串,然后发送的字符串。比如OK ATE0V1的意思就是:当模块返回OK之后,发送ATE0V1。
拨号过程大致是:先发一个空命令唤醒模块,这时模块会回OK;然后发ATE0V1关闭回显、打开详细错误提示;接着发AT+CGDCONT设置PDP上下文,也就是把APN写进模块;等OK后发ATD99**1#发起数据呼叫;模块最终返回CONNECT,chat脚本就认为链路建立完成,剩下的交给pppd去跑LCP、PAP/CHAP认证和IPCP协商。
这里有几个容易出问题的地方。
TIMEOUT太短。在信号不好的地方,AT+CGDCONT设置之后模块需要时间去核心网完成PDP上下文激活,可能几秒内没有响应。TIMEOUT设置成10秒比较稳妥,有些卡甚至要15秒。太短,模块稍微慢一点,chat就误判超时,杀掉整个PPP连接。
ABORT "ERROR" vs 返回ERROR。设置APN时偶发ERROR,常见原因包括:模块不支持该APN组合、当前网络不允许该PDP类型。如果你把ABORT "ERROR"放在chat脚本开头,一旦设置APN失败,pppd会直接放弃拨号。可问题在于,APN设置失败往往是临时的,第二次重发可能就成功了。所以我没有把ERROR作为全局ABORT条件,而是让chat脚本遇到ERROR后继续尝试,或者干脆由主控脚本重试整个流程。
回显干扰。模块默认ATE1回显模式时,你发送的AT命令会原样回显在串口里,而chat是按顺序匹配关键字匹配的,回显可能打乱匹配。所以脚本里第二步就发ATE0V1关闭回显。这条指令务必放在任何涉及关键字匹配的操作之前。
CGDCONT的引号转义。AT+CGDCONT=1,"IP","$APN"在chat里必须写成双引号包裹的形式,busybox的字符串拼接很容易在这里翻车。建议先把完整的字符串变量echo出来看一眼再进chat,别等到拨号失败才回头找引号问题。
3.3 pppd参数怎么选,尤其是电信卡
pppd参数里我固定用且必须用的有几个:
- noauth:模块侧不需要认证pppd,这个参数表示本端不必向对端认证,绝大多数拨号脚本里都会出现。
- noipdefault:不继承本机IP作为PPP本地IP,让IPCP从对端动态获取。如果不加,在某些内核配置下pppd会尝试用eth0的IP去当本地地址,导致拨号拿到的是内网冲突地址。
- defaultroute:拨号成功后自动把默认路由指向ppp0。这个参数省心,但如果设备上有其他网络接口(比如有线以太网在跑业务),要注意默认路由冲突的问题,后面我会再提。
- usepeerdns:pppd在IPCP协商后会把运营商下发的DNS写入resolv.conf。用这个参数能省去手动配置DNS的麻烦。
电信卡在LTE模式下,拨号流程其实和移动、联通已经非常接近,通用的ATD99**1#和ctnet的APN组合大多能直接拨通。但如果你的固件是老版本或者遇到的是电信老的3G EVDO网络,就可能需要补充用户名密码,电信3G时代的典型配置是用户名ctnet@mycdma.cn、密码vnet.mobi。尽管现在LTE普及后很多核心网不检查这个字段,但我仍然把它的配置入口保留着,万一遇到兼容性问题,至少不用重新编译固件。
另外一个与运营商无关但很常见的点:MTU。pppd默认MTU是1500,但4G网络的物理链路实际MTU往往低于这个值,尤其在叠加隧道协议后更加明显。拨号通了大包ping不通、小包能通,多半就是MTU的问题。我习惯在pppd参数里加上mtu 1400 mru 1400,这个值对绝大多数运营商都安全。
4. 拨通之后:网络配置与多运营商切换验证
4.1 IP、路由、DNS三件套
拨号成功后,第一时间检查三样东西:IP、路由、DNS。
ip addr show ppp0 ip route show cat /etc/resolv.conf正常情况下,ppp0会拿到一个运营商内网地址(移动通常以10.或100.开头,联通可能是10.或172.,电信可能是10.或100.),默认路由会指向ppp0,DNS是运营商下发的公网DNS。
有一个经常被忽略的点:如果设备上同时存在eth0和ppp0,内核的默认路由只能有一个。defaultroute把默认路由指向ppp0后,原来走eth0的流量也会跟着跑到ppp0上来。这在产品设计上可能是好事,也可能是灾难。如果设备既要走4G上报,又要保留以太网本地管理,就得靠ip rule和独立路由表做分流,不能只依赖defaultroute。
4.2 实测:移动卡换联通卡,脚本做了什么
为了验证自适应逻辑,我在测试板上做了三次换卡测试。过程很简单:断电,拔卡,插新卡,上电,看日志。
第一次插移动卡,系统启动后,脚本日志输出:
IMSI=460023456789012 MCCMNC=46002 OPERATOR=cmcc APN=cmnetpppd很快就拿到了IP,ping 223.5.5.5和114.114.114.114都正常。
第二次换联通卡。这次特意在上电60秒后才执行拨号脚本,模拟现场设备已经跑起来、网络突然变化的情况。脚本先读到IMSI前缀46001,自动把APN切成3gnet,重新生成chat脚本后杀掉旧pppd,再拉起新pppd。整个过程大约花了8秒,其中大部分时间花在pppd的LCP重协商上。日志显示联通侧给的IP是10.86.开头,默认路由和DNS都更新了。
第三次换电信卡。这里遇到过一个小波折:脚本第一次拨号时AT+CGDCONT=1,"IP","ctnet"返回OK,但紧接着ATD99**1#在3秒内超时,chat报NO CARRIER。我加了详细日志后发现,模块在设置PDP上下文后,需要先把旧的PDP上下文去激活,否则直接发起新呼叫会撞上残留状态。解决方式是在CGDCONT之前加一条AT+CGACT=0,1,先确保PDP上下文去激活。
这个案例说明,IMSI识别只是第一步,真正让系统在不同运营商之间稳定切换,还需要考虑模块侧的残留状态、PDP上下文管理这些问题。
4.3 拨号成功但Ping不通,按什么顺序查
如果ppp0已经UP,也拿到了IP,但就是ping不通外网,我建议按这个顺序排查:
第一步看路由。ip route show,如果默认路由没有指向ppp0,或者出现多条默认路由,先处理路由优先级。可以用ip route add default dev ppp0手动加,但最终要在脚本里固化。
第二步看DNS。cat /etc/resolv.conf。如果只有127.0.0.1这种本地解析器而没有运营商DNS,大概率是usepeerdns没生效,或者pppd启动后resolv.conf被别的进程覆盖了。嵌入式设备常见的有几类:systemd-resolved接管、udhcpc覆盖、固件自己的DNS配置脚本跟pppd打架。解决思路是用ip-up脚本统一写resolv.conf,别让多个机制互相抢。
第三步看MTU。大包不通小包通,十有八九是MTU问题。在pppd里加mtu 1400 mru 1400,或者用ip link set ppp0 mtu 1400临时测试对比。
第四步看防火墙。有些产品的iptables规则默认放行eth0但没放行ppp0,或者对ppp0走的是DROP政策。检查一下filter表的INPUT/FORWARD链,有没有把ppp0挡掉。
第五步看运营商侧。有些物联网卡是定向流量卡,只允许访问白名单域名或IP,你ping 114可能本来就不该通。这种时候换ping一下卡商提供的测试域名,或抓包确认有没有SYN出去、有没有SYN-ACK回来。
这个排查顺序救过我很多次。尤其是“定向流量卡”这个问题,最早遇到时排查了两天,一度以为是模块硬件问题,最后才知道是卡套餐限制。
5. 工程化收尾:自动重拨、看门狗与日志
5.1 别信“永不断线”,要做断线自动恢复
4G网络的稳定性再怎么样也赶不上有线网络,地下室、隧道、偏远山区,信号一弱,PPP连接说断就断。所以做完自适应拨号,下一步必须是断线自动恢复。我用的是一个轻量级看门狗脚本,思路简单但很实用:周期检查ppp0是否存在、能不能ping通一个固定目标,连续失败达到阈值就主动重启整个拨号流程。
#!/bin/sh # /etc/ppp/netdog.sh PING_TARGET=${PING_TARGET:-223.5.5.5} FAIL_COUNT=0 THRESHOLD=3 while true; do if ip addr show ppp0 >/dev/null 2>&1; then if ping -I ppp0 -c 1 -W 3 "$PING_TARGET" >/dev/null 2>&1; then FAIL_COUNT=0 else FAIL_COUNT=$((FAIL_COUNT + 1)) fi else FAIL_COUNT=$((FAIL_COUNT + 1)) fi if [ "$FAIL_COUNT" -ge "$THRESHOLD" ]; then logger -t netdog "ppp link bad, re-dialing..." /usr/bin/ec20_auto_dial.sh FAIL_COUNT=0 fi sleep 10 done这个脚本要配合进程守护,可以用init脚本、systemd服务或者supervisor来拉起。在这里我多说一句:ping目标不要选自己对端网关,因为PPP链路就算通了,网关也可能因为运营商策略不回应ICMP;也不要选某个特定域名,DNS解析本身可能故障。选一个知名公共IP最稳。
如果你的设备完全没有外网需求,只需要内网通信,那PING_TARGET可以换成运营商网络的网关IP,配合ip route show里查到的默认网关填进去,一样有效。
5.2 日志与状态输出设计
嵌入式设备出问题时,没有日志等于没有线索。我在拨号这套逻辑里做了三层输出。
第一层是pppd自己的日志,通过logfile /var/log/pppd.log记录。pppd的调试信息非常详细,LCP协商阶段、认证方式、IPCP拿到的地址都会打印。一旦拨号失败,先翻这个日志。
第二层是主控脚本的日志。比如识别到哪个IMSI、选择了哪个APN、第几次重拨、耗时多少。简单起见,用logger写进syslog或直接写文件。我习惯于写文件,因为现场工程师不一定有syslog查看工具。
第三层是状态文件。脚本每次拨号成功后,把运营商、APN、IP、DNS、时间戳这些信息写到一个固定路径,比如/var/run/ec20_status.json,方便上层业务或者远程平台直接读取,判断当前模块到底处于什么状态。
cat > /var/run/ec20_status.json <<EOF { "operator": "$OPERATOR", "apn": "$APN", "imsi": "$IMSI", "ip": "$(ip -4 addr show ppp0 | awk '/inet/ {print $2}')", "uptime": "$(cat /proc/uptime | awk '{print $1}')", "timestamp": "$(date '+%Y-%m-%d %H:%M:%S')" } EOF上层的业务代码可以读这个文件,比直接解析pppd输出要干净得多。
5.3 做深一点:信号强度上报与多模块冗余
既然已经把运营商识别和自动拨号做成了通用能力,多走一步就能覆盖更多生产场景。
信号强度纳入重拨策略。用AT+CSQ读取信号强度,返回的rssi值可以换算成dBm。如果信号低于某个阈值,比如-105dBm,即使当前链路还通,也应该提前预警,而不是等彻底断线后再重拨。这样运营维护能看到信号弱的具体位置,对天线布局和现场选址都有指导意义。
多模块冗余。有些工业网关会装两只4G模块,主模块挂了自动切到备模块。这种情况下,整个IMSI识别和拨号逻辑要封装成函数库,传入不同的ttyUSB口和模块名,生成不同的ppp0/ppp1接口。我在上一代产品里做过一次,核心代码其实复用度很高,难点在于路由策略:两只模块同时在线时,怎么让业务流量走主模块、心跳流量走备模块,需要配合ip rule和多路由表来设计。
PPP拨号之前的联网自检。严格来说,在拨号之前可以先去尝试AT+CGACT=1,1激活PDP上下文,确认核心网允许建立PDN,再进入PPP拨号阶段。这个预检步骤可以提前把“APN错、卡没激活、定向卡限制”这些原因挡在拨号之前,缩短失败后的重拨周期。
我实际投入生产环境后的体会是:这一段“自适应IMSI识别 + 动态生成PPP配置 + 看门狗自动重拨”是4G物联网设备稳定性的地基。不要觉得复杂,认认真真把每一层拆开做,后面产品的网络问题会少掉一大半。如果你现在也在做类似的嵌入式Linux联网项目,直接把上面的脚本拿去当骨架改吧,至少能省掉一趟现场出差的时间。