前言
在之前的文章中,我们都是利用ESP32自带的WiFi进行网络连接。但在户外或者没有WiFi覆盖的角落,想要让设备联网,就得请出“4G模块”了。

通常大家驱动4G模块(比如SIM800, Air724, EC20等)最原始的方法是用UART发送AT指令。
比如 AT+HTTPINIT、AT+HTTPPARA… 这种方式不仅繁琐,而且解析返回字符串简直是噩梦,写出来的代码全是状态机,一旦模块吐点乱码,程序直接暴毙。手动解析AT指令简直是坏文明!
为了优雅地使用4G模块,我决定使用 PPPoS(Point-to-Point Protocol over Serial)。 简单来说,就是把串口“伪装”成一个网卡。这样底层的TCP/IP协议栈(LwIP)就能直接接管网络,我们写上层代码时,完全不用关心是在用WiFi还是4G,直接调标准的Socket接口就完事了。

准备工作
1. 硬件连接
先确认模块的具体型号与固件支持 UART PPP 拨号,不能仅凭 Cat.1/Cat.4 分类判断。 我用的是合宙的Air780EG, 这个模块属于4G+GNSS二合一模块, 在制作需要定位的设备很方便. 将模块的 TX/RX 接到 ESP32-C3 的串口引脚上,记得共地,并按模块资料核对 UART 电平;电平不同需要转换,不能直接连接。
| ESP32-C3 | Air780EG / 模块端 | 用途 |
|---|---|---|
| GPIO21(示例 TX) | UART RX | 发送到模块 |
| GPIO20(示例 RX) | UART TX | 接收模块数据 |
| GND | GND | 共地 |
| GPIO7(原项目) | RESET 控制电路 | 电平、驱动方式与脉宽按硬件资料确认 |
| GPIO8(原项目 RX) | GPS TX | 可选的独立定位输出 |
模块电源不接 ESP32 的 GPIO。供电电压、峰值电流和去耦按模块或开发板资料设计;文末的 3.9V 是我的项目配置,不是所有 4G 模块的通用参数。
PS: 模块进入PPPOS状态以后GPS就不能走主串口输出 好消息是我们可以使用ESP32C3的另外一个串口和GPS串口对接, 这样就可以同时使用GPS和4G PPPOS拨号了 比如说我用的是GPIO8和GPSTX连接, TXD0和RXD0接4G模块的主串口
核心代码实现
示例项目可以通过下面命令创建
idf.py create-project-from-example "espressif/esp_modem=1.0.3:pppos_client"
接下来基于 ESP-IDF 的 pppos_client 示例补充说明。请先构建原示例,再按自己的模块修改;下方只展示关键流程,包含头文件、Kconfig 和模块适配的完整工程应以组件示例为起点。
PPPoS 并不是完全不用 AT 指令。 注册网络、配置 APN 和拨号通常仍由 esp_modem 通过 AT 指令完成;进入数据模式后,上层 HTTP、MQTT 或 Socket 流量交给 lwIP,不再逐个解析 AT 网络请求。
整个过程其实就分三步:定义事件、初始化DCE/DTE、切换到数据模式。
1. 事件处理 (Event Handler)
PPPoS 的运行依赖于事件驱动。我们需要监听 IP 层面的事件,当拿到 IP 地址时,才算真正联网成功。
static EventGroupHandle_t event_group = NULL;
static const int CONNECT_BIT = BIT0;
static const int DISCONNECT_BIT = BIT1;
// 监听 IP 获取事件
static void on_ip_event(void *arg, esp_event_base_t event_base,
int32_t event_id, void *event_data)
{
if (event_id == IP_EVENT_PPP_GOT_IP) {
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
// 打印一下获取到的IP信息,看着就舒服
ESP_LOGI(TAG, "Modem Connect to PPP Server");
ESP_LOGI(TAG, "IP : " IPSTR, IP2STR(&event->ip_info.ip));
ESP_LOGI(TAG, "Netmask : " IPSTR, IP2STR(&event->ip_info.netmask));
ESP_LOGI(TAG, "Gateway : " IPSTR, IP2STR(&event->ip_info.gw));
// 通知主任务:我们连上网了!
xEventGroupSetBits(event_group, CONNECT_BIT);
} else if (event_id == IP_EVENT_PPP_LOST_IP) {
ESP_LOGW(TAG, "Modem Disconnect from PPP Server");
xEventGroupSetBits(event_group, DISCONNECT_BIT);
}
}
2. 初始化与拨号
这一步是重头戏。我们需要初始化 Netif(网络接口),配置 DTE(数据终端设备,即ESP32C3端的UART),然后初始化 DCE(数据通信设备,即4G模块)。
void app_main(void)
{
// 1. 初始化网络接口和事件循环
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
event_group = xEventGroupCreate();
if (event_group == NULL) {
ESP_LOGE(TAG, "Failed to create event group");
return;
}
ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &on_ip_event, NULL));
// 1.5 重启模块, 我的模块Reset脚接的GPIO7
// 仅对应原项目的低有效 RESET 控制电路;不要直接套用到其他模块。
gpio_config_t io_config = {.pin_bit_mask = BIT64(7),
.mode = GPIO_MODE_OUTPUT};
ESP_ERROR_CHECK(gpio_config(&io_config));
ESP_ERROR_CHECK(gpio_set_level(7, 0));
vTaskDelay(pdMS_TO_TICKS(500));
ESP_ERROR_CHECK(gpio_set_level(7, 1));
// 此后应按模块手册等待启动完成,再发送 AT 指令。
// 2. 配置 PPP 网络接口
esp_modem_dce_config_t dce_config = ESP_MODEM_DCE_DEFAULT_CONFIG("cmnet"); // APN通常是cmnet
esp_netif_config_t netif_ppp_config = ESP_NETIF_DEFAULT_PPP();
esp_netif_t *esp_netif = esp_netif_new(&netif_ppp_config);
if (esp_netif == NULL) {
ESP_LOGE(TAG, "Failed to create PPP netif");
return;
}
// 3. 配置串口 (DTE)
esp_modem_dte_config_t dte_config = ESP_MODEM_DTE_DEFAULT_CONFIG();
dte_config.uart_config.tx_io_num = 21; // TX引脚
dte_config.uart_config.rx_io_num = 20; // RX引脚
dte_config.uart_config.flow_control = ESP_MODEM_FLOW_CONTROL_NONE; // 没接流控线就选NONE
// 4. 创建 Modem 对象 (这里根据实际型号选择, 由于没有Air780eg的配置,所以使用的ESP_MODEM_DCE_CUSTOM)
ESP_LOGI(TAG, "Initializing esp_modem...");
// CUSTOM 需要提供该组件版本要求的自定义 DCE 实现;
// 仅设置此枚举不会自动获得 Air780EG 支持,请参考原示例的模块适配。
esp_modem_dce_t *dce = esp_modem_new_dev(ESP_MODEM_DCE_CUSTOM, &dte_config, &dce_config, esp_netif);
if (dce == NULL) {
ESP_LOGE(TAG, "Failed to create modem; check the DCE implementation");
return;
}
// 5. 检查信号质量, 如果没有信号, 很大概率会有问题, 4G模块可能没有正常工作
int rssi, ber;
if (esp_modem_get_signal_quality(dce, &rssi, &ber) == ESP_OK) {
ESP_LOGI(TAG, "Signal quality: rssi=%d, ber=%d", rssi, ber);
}
// 6. 关键步骤:切换到 DATA 模式!
// 这一步之后,串口就变成了透明传输的网卡通道,不能再发AT指令了
ESP_LOGI(TAG, "Switching to Data Mode...");
esp_err_t err = esp_modem_set_mode(dce, ESP_MODEM_MODE_DATA);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to enter data mode!");
return;
}
// 7. 等待获取 IP
ESP_LOGI(TAG, "Waiting for IP address...");
EventBits_t bits = xEventGroupWaitBits(event_group, CONNECT_BIT, pdFALSE,
pdFALSE, pdMS_TO_TICKS(60000));
if ((bits & CONNECT_BIT) == 0) {
ESP_LOGE(TAG, "PPP timeout; check power, registration, APN and UART");
return;
}
// 获得 IP 后再验证 DNS 和实际网络请求;有 IP 不等于公网一定可达。
// 原示例若已注册 ping 控制台命令,可从控制台执行 ping example.com。
ESP_LOGI(TAG, "PPP got IP; ready to test DNS and connectivity");
}
原项目在初始化前增加了模块复位。是否需要这一步、RESET 的驱动方式与启动等待时间都取决于模块和固件,不能把上述 500ms 脉冲当成通用要求。
如果一切顺利就会看到下图ping成功的输出

踩坑小记
在调试过程中遇到过几个坑,顺便记录一下:
- 供电问题:4G模块瞬时电流很大,普通的3.3V LDO通常扛不住,导致模块会反复重启, 建议单独给模块供电。我用了一个DCDC提供3.9V电压给模块.
- 流控:如果你的线没接 RTS/CTS,一定要在配置里把
flow_control设为ESP_MODEM_FLOW_CONTROL_NONE,否则数据发不出去。 - APN:虽然现在很多卡都能自动识别APN,但最好还是显式指定一下(移动通常是
cmnet,联通3gnet)。 - 4G模块初始化:原项目用 GPIO 控制 RESET。先确认模块启动完成、AT 可响应,再进行注册与拨号。
按现象排查
| 现象 | 优先检查 |
|---|---|
| AT 无响应 | 供电、启动时间、TX/RX 交叉连接、波特率、电平转换 |
| 模块反复重启 | 发射时电源压降、线材、去耦与电源峰值能力 |
| 能发 AT,但拨号失败 | SIM 状态、注册状态、套餐与 APN、模块 PPP 支持 |
已注册网络,但没有 IP_EVENT_PPP_GOT_IP | DCE 适配、拨号日志、UART 流控、PPP 配置 |
| 已有 IP,但域名请求失败 | DNS;先区分地址可达性和域名解析问题 |
| 大量数据传输时断线 | 供电、UART 缓冲与波特率、硬件流控配置 |
本文没有记录完整的 ESP-IDF 版本与可独立构建工程,因此这里修正的是展示代码与排查指引,不代表已完成新一轮硬件或编译验证。
总结
使用 PPPoS 后,4G 模块的使用体验和 WiFi 几乎没有区别。
配合我在《ESP32异步网络请求》中介绍的 AsyncHTTP 或者 MQTT 库,就可以轻松地把设备部署到野外了。
那么,古尔丹,代价是什么呢?
答案是功耗爆炸. 由于4G模块一直处于数据模式, 如果需要发送一些功耗优化的AT指令, 比如飞行模式, 休眠. 那么会异常麻烦, 每次都需要切换回Command模式再发送指令. 如果和我一样是自带的GPS功能的4G模块, 那么功耗更是爆炸, 想要独立控制GPS电源必须通过AT指令, 来回切换失败风险很高.