3、TCP/IP协议栈适配:PPP拨号流程、TCP/UDP Socket编程、MQTT协议移植、HTTP/HTTPS请求封装

这一章,我们真正开始动手了。

前面两章我们把模组的硬件接口和AT指令体系摸了个透,但说实话,那只是开胃菜。真正的硬骨头,是把数据从模组里「吹」出去,送到云端。这中间要过好几道关:先得让模组连上运营商网络(PPP拨号),然后建立Socket通道,最后才是应用层协议的移植和封装。

我个人习惯把这一章叫做「数据通路搭建」。你想想看,没有这条路,你的传感器数据、控制指令全憋死在模组里,跟废铁没区别。

本章核心脉络:物理链路 → PPP拨号(获取IP) → Socket编程(TCP/UDP通道) → 应用协议(MQTT/HTTP/HTTPS)。每一步都是下一层的基础,跳不过去。

TCP/IP协议栈适配全流程 TCP/IP协议栈适配全流程 ① 物理链路(UART/SPI) ② PPP拨号(获取IP) ③ TCP/UDP Socket编程 ④ MQTT协议移植 ⑤ HTTP/HTTPS封装 💡 说明:每一步都依赖上一步成功。PPP拨号失败,Socket就建不起来;Socket不通,MQTT/HTTP全是空谈。 调试时建议逐层验证,不要跳步。

3.1 PPP拨号流程:从AT指令到IP连通

PPP拨号,说白了就是让模组跟基站「握手」,拿到一个IP地址。没有这个IP,后面所有网络操作都白搭。

我记得第一次调PPP拨号时,卡了整整两天。模组一直返回 NO CARRIER,我以为是天线没接好,查了半天硬件,最后发现是APN配错了。嗯,这种低级错误,犯过一次就再也不会忘了。

3.1.1 标准拨号流程

大部分蜂窝模组(移远、广和通、中移)的PPP拨号流程大同小异。核心就这几步:

  1. AT+CFUN=1 —— 启动全功能,让射频模块工作。
  2. AT+CGDCONT=1,"IP","CMNET" —— 定义PDP上下文,APN是关键。移动用CMNET,联通用3GNET,电信用CTNET。
  3. ATD*99# —— 发起PPP拨号呼叫。模组返回 CONNECT 表示成功。
  4. PPP协商 —— LCP认证 → PAP/CHAP鉴权 → NCP获取IP。这一步由主机的PPP协议栈完成。

我的经验:APN配错是最常见的拨号失败原因。建议先用AT+COPS?查询当前运营商,再确认APN。另外,有些模组需要先AT+CGACT=1激活PDP上下文,再拨号。

3.1.2 主机侧PPP协议栈配置

模组只负责物理层和链路层的连接,PPP协议栈跑在主机侧(MCU或Linux)。以Linux为例,常用的工具是 pppd:

# pppd 拨号脚本示例
# /etc/ppp/peers/gprs
connect "/usr/sbin/chat -v -f /etc/chatscripts/gprs.chat"
/dev/ttyUSB0
115200
noauth
defaultroute
usepeerdns
persist
maxfail 0

对应的chat脚本:

# /etc/chatscripts/gprs.chat
ABORT "BUSY"
ABORT "NO CARRIER"
ABORT "ERROR"
"" AT
OK AT+CFUN=1
OK AT+CGDCONT=1,"IP","CMNET"
OK ATD*99#
CONNECT ""

拨号成功后,你会看到 ppp0 接口出现,并分配到一个IP地址(通常是10.x.x.x或100.x.x.x的内网IP)。

⚠️ 避坑指南:我曾经遇到过拨号成功后几分钟就断连的情况。排查下来是 LCP echo 超时导致。解决方案是在pppd配置里加上 lcp-echo-interval 30 和 lcp-echo-failure 5,让PPP协议定期保活。

3.2 TCP/UDP Socket编程:模组上的网络通道

PPP拨号成功后,模组就有了IP连通性。但应用层要收发数据,还得通过Socket。蜂窝模组的Socket编程跟PC上不太一样——它通常通过AT指令来操作。

3.2.1 模组Socket AT指令集

以移远EC20为例,常用的Socket指令如下:

功能 AT指令 说明
创建Socket AT+QIOPEN=1,0,"TCP","192.168.1.100",8080,0,0 参数:contextID, connectID, 协议, 服务器IP, 端口, 本地端口, 模式
发送数据 AT+QISEND=0,10 指定connectID和长度,然后输入数据
接收数据 +QIURC: "recv",0,<len> URC主动上报,主机读取
关闭Socket AT+QICLOSE=0 关闭指定connectID

我个人习惯把Socket操作封装成一个简单的API层,这样上层应用不用关心AT指令的细节。比如这样:

// 伪代码:Socket API封装
int tcp_connect(const char *ip, uint16_t port) {
    char cmd[128];
    sprintf(cmd, "AT+QIOPEN=1,0,\"TCP\",\"%s\",%d,0,0", ip, port);
    send_at(cmd);
    // 等待 +QIOPEN: 0,0 表示成功
    return wait_response("+QIOPEN: 0,0", 10000) ? 0 : -1;
}

int tcp_send(int connect_id, const uint8_t *data, int len) {
    char cmd[32];
    sprintf(cmd, "AT+QISEND=%d,%d", connect_id, len);
    send_at(cmd);
    // 等待 ">" 提示符,然后发送数据
    return send_raw_data(data, len);
}

经验之谈:TCP连接建立后,模组默认有保活机制,但不同模组差异很大。我建议应用层自己做心跳,每30-60秒发一个空包或自定义心跳帧。别完全依赖模组的TCP keepalive,有些模组实现得并不靠谱。

3.2.2 TCP vs UDP 选型

这个选择题其实不难做。我列个对比表:

特性 TCP UDP
可靠性 有确认重传,可靠 无确认,可能丢包
连接开销 三次握手,开销大 无连接,开销小
适用场景 控制指令、文件上传 实时数据上报、音视频
模组资源消耗 需要维护状态,内存占用高 无状态,资源占用低

我的建议是:能用TCP就用TCP。蜂窝网络环境复杂,丢包、抖动是常态,TCP的重传机制能省你很多事。除非你的数据量极大且允许丢包(比如GPS轨迹点),才考虑UDP。

3.3 MQTT协议移植:轻量级物联网通信

MQTT是物联网的事实标准。为什么?因为它协议头小(最小2字节)、支持QoS分级、有发布/订阅模型。说白了,就是为低带宽、高延迟、不稳定的网络设计的。

3.3.1 MQTT协议栈选型

在MCU上移植MQTT,主流选择就几个:

  • Eclipse Paho MQTT-C —— 纯C实现,资源占用小,适合RTOS。
  • MQTT-C —— 一个轻量级实现,只有两个文件,我经常用在STM32上。
  • 模组内置MQTT —— 移远、中移等模组直接提供AT+MQTTCONN指令,省去移植工作。

我个人更倾向于用模组内置的MQTT AT指令。原因很简单:省心。模组厂商已经把TCP连接、TLS握手、心跳保活都封装好了,你只需要发几条AT指令就行。

// 移远模组MQTT AT指令示例
AT+QMTCFG="aliauth",0,"productKey","deviceName","deviceSecret"
// 配置阿里云三元组

AT+QMTOPEN=0,"iot-06z00xxxxxxxx.mqtt.iothub.aliyuncs.com",1883
// 打开MQTT连接,返回 +QMTOPEN: 0,0

AT+QMTCONN=0,"clientId"
// 发起连接,返回 +QMTCONN: 0,0,0

AT+QMTSUB=0,1,"/topic/test",1
// 订阅主题,QoS 1

AT+QMTPUB=0,0,0,0,"/topic/test","hello mqtt"
// 发布消息,QoS 0

⚠️ 注意:MQTT的Client ID必须全局唯一。我曾经因为两个设备用了相同的Client ID,导致其中一个被踢下线。排查了半天才发现是ID写死了。建议用设备的IMEI或MAC地址作为Client ID的一部分。

3.3.2 QoS等级选择策略

MQTT有三个QoS等级,选哪个?我直接给结论:

  • QoS 0 —— 最多发一次。适合传感器周期性上报,丢了下一轮还有。
  • QoS 1 —— 至少发一次。适合控制指令,但要注意幂等性(设备可能收到重复指令)。
  • QoS 2 —— 恰好发一次。开销最大,蜂窝网络下慎用。除非是金融支付类场景,否则别碰。

我在项目中90%的场景用QoS 0或1。QoS 2在弱网环境下会导致大量重传和协议栈积压,反而降低整体可靠性。

3.4 HTTP/HTTPS请求封装:RESTful API的接入

虽然MQTT是物联网首选,但有些场景必须用HTTP——比如设备OTA升级、对接第三方云平台(AWS IoT Core、阿里云HTTP API)、上传日志文件等。

3.4.1 HTTP请求的AT指令实现

模组通常提供HTTP AT指令,用法跟Socket类似:

// 移远模组HTTP GET示例
AT+HTTPINIT
// 初始化HTTP服务

AT+HTTPPARA="URL","http://api.example.com/data"
// 设置URL

AT+HTTPACTION=0
// 0=GET, 1=POST, 2=HEAD
// 返回 +HTTPACTION: 0,200,

AT+HTTPREAD
// 读取响应数据

AT+HTTPTERM
// 终止HTTP服务

POST请求需要先设置 CONTENT 参数:

AT+HTTPPARA="CONTENT","application/json"
AT+HTTPDATA=,10000
// 输入POST body数据
AT+HTTPACTION=1

3.4.2 HTTPS与TLS握手

HTTPS比HTTP多了TLS加密。模组做TLS握手,资源开销不小。我遇到过一个问题:模组在TLS握手阶段反复超时,最后发现是模组内部证书存储区满了。

我的建议:如果服务器支持,优先用MQTT over TLS,而不是HTTPS。MQTT的TLS握手开销比HTTPS小,而且连接可以保持,省去每次请求都重新握手的成本。如果必须用HTTPS,建议使用会话复用(Session Resumption)来减少握手次数。

HTTPS的AT指令跟HTTP几乎一样,只是URL改成 https:// 开头:

AT+HTTPPARA="URL","https://api.example.com/secure"
// 模组会自动处理TLS握手,前提是已经烧录了CA证书

AT+SSLCFG="cacert",0,"/certs/ca.pem"
// 有些模组需要先配置证书路径

3.4.3 HTTP请求封装成API

在实际项目中,我不会让应用层直接操作AT指令。我会封装一层:

// 封装后的HTTP API
typedef struct {
    int status_code;
    char *body;
    int body_len;
} http_response_t;

http_response_t http_get(const char *url) {
    http_response_t resp = {0};
    send_at("AT+HTTPINIT");
    send_at("AT+HTTPPARA=\"URL\",\"%s\"", url);
    send_at("AT+HTTPACTION=0");
    // 等待 +HTTPACTION: 0,200,
    resp.status_code = parse_status_code();
    if (resp.status_code == 200) {
        resp.body = read_http_body();
    }
    send_at("AT+HTTPTERM");
    return resp;
}

这样上层代码就干净了:

http_response_t resp = http_get("http://api.example.com/status");
if (resp.status_code == 200) {
    // 解析JSON
    json_parse(resp.body);
}

本章小结:TCP/IP协议栈适配,说白了就是打通「模组→网络→云端」这条数据通路。PPP拨号是基础,Socket是通道,MQTT和HTTP是应用。每一步都有坑,但只要你按流程逐层验证,大部分问题都能定位。下一层的内容,我们会在后续章节深入展开。