STM32F103+EC200S 4G DTU实战:MQTT接入阿里云与远程LED控制
2026/9/12 23:19:54 网站建设 项目流程

简介:面向单片机开发者的STM32F103+EC200S 4G项目例程,以MQTT协议将温度数据实时上传至阿里云物联网平台,同时接收云端JSON指令控制LED灯,适用于物联网课程设计、毕业设计以及4G DTU产品原型验证。项目代码基于KEIL标准库编写,兼容STM32F103系列,根据芯片型号调整Flash容量后即可复用;程序中对单片机与EC200S模块的接线均有清晰定义,且关键函数附带注释,方便读者理解数据采集、组网上云与指令解析的完整链路。压缩包总计112个文件、1.83MB,以C/H源代码和KEIL工程文件为主体,另外包含hex固件、PDF说明、BMP调试参考图、文本配置及清理脚本等,结构划分直观,便于按需查阅和二次开发。目前已有159人学习下载,整体难度适中,对有一定单片机基础、希望快速搭建4G物联网方案的开发者是性价比较高的参考资源。

1. 4G DTU 方案选型:STM32F103 与 EC200S 组合为什么还不过时

STM32F103 加 EC200S-4G模块,本质上是一个价格敏感、需求明确的 4G DTU 方案:温度数据通过传感器进单片机,再经 EC200S 的 4G 网络,用 MQTT 协议推到阿里云物联网平台;云端下发的 JSON 格式指令经过同一条链路回到单片机,让指定 GPIO 驱动 LED。市面上的 DTU 盒子很多,但遇到“上报频率要 5 秒一次”“指令要透传成自定义协议”这类需求,盒子里固件往往改不动。自己用 STM32F103 驱动 EC200S,既保留协议控制权,又能直接操作底层 GPIO。这套方案适合做设备远程监控、农业四情、冷链运输的嵌入式或物联网工程师,目标是快速复制链路、填对连接参数、跑通第一次收发,并且知道失败时该看哪条日志。

2. STM32F103 最小系统与 EC200S 的硬件连接和 AT 拨号

硬件连接是第一道坑。EC200S 的 UART 电平不是 3.3V,而是 1.8V;如果直接把 STM32F103 的 PA9/PA10 接上去,刚开始 AT 指令能通,一进入 TCP 透传、数据量上来,就会随机丢字。原因不在代码,而在电平和供电。这一章先把最小系统和串口分配讲清楚,再把 AT 拨号链路走通。

2.1 最小系统:晶振、Boot 和串口1/串口3使用差异

用 STM32F103C8T6 做底板时,最少需要 8MHz 晶振加两个负载电容、BOOT0 下拉到地、NRST 上拉,VDDA/VSSA 做去耦。串口选择上,我习惯用 USART1 接 EC200S,用 USART3 做调试输出。为什么不是反过来?STM32F103 的 USART1 挂在 APB2 上,时钟 72MHz,分频精度更好;USART3 挂在 APB1 上,时钟 36MHz,115200 波特率没问题,但把 EC200S 的 AT 波特率提到 460800 时,USART3 的分频误差会明显变大。另外 USART1 的 RX/TX 是 PA10/PA9,和 JTAG 部分引脚复用,默认状态不冲突;USART3 在 PB11/PB10,如果这些引脚被 ADC 或 PWM 占用,就要做重映射。

功能STM32F103 引脚备注
EC200S TX -> STM32 RXPA10 (USART1_RX)模块输出 1.8V,需电平转换
EC200S RX <- STM32 TXPA9 (USART1_TX)注意电平方向
调试串口 TXPB10 (USART3_TX)只输出日志
调试串口 RXPB11 (USART3_RX)可接收调试命令
LED 控制输出PA1开漏或三极管驱动
温度模拟量输入PA0ADC1_IN0

这就是“stm32f103 串口1和串口3使用差异”的典型场景:同一个波特率设置,时钟源、中断优先级、引脚冲突都不一样。业务串口和调试串口分开,MQTT 数据流和日志才不会互相干扰。

2.2 EC200S 供电、电平转换和 SIM 卡电路

EC200S 工作电压 3.4V 到 4.3V,推荐 3.8V,瞬间发射电流能到 2A。用 AMS1117-3.3 给 MCU 供电可以,但 4G 模组的电源必须单独走 DC-DC 或锂电池,并且靠近模组 VBAT 放两个 100µF 钽电容和 22µF 陶瓷电容。电平转换用 TXS0102 或 2N7001T,如果只用单向通信,也可以用三极管搭反相电路,但注意 EC200S 的 RX 通常需要 1.8V 上拉。SIM 卡要加 ESD 防护,串联 22Ω 电阻,并预留 SIM_DET 检测脚,这样在代码里能区分“没插卡”和“没信号”。

注意:EC200S 的串口电平不是 3.3V,也不是 5V,任何“直接飞线”的做法都会在高速透传时丢包。

2.3 用 AT 指令完成网络注册和获取 IP

下面是我在调试串口敲的最小组,顺序不能乱:

ATE0 AT+CPIN? AT+CSQ AT+CREG? AT+CGDCONT=1,"IP","cmnet" AT+CGACT=1 AT+QIACT=1 AT+QIACT?

参数说明:ATE0关闭回显,减少串口干扰;AT+CPIN?返回值是READY才能继续,ERROR说明 SIM 卡没识别;AT+CSQ第一项是信号强度,20 以上算健康,10 以下长连接基本稳不住;AT+CREG?返回0,11,5才是已注册网络;cmnet是中国移动默认 APN,联通、电信请改成运营商对应的默认 APN。AT+QIACT=1激活 PDP 上下文,最后一条AT+QIACT?会显示模块拿到的 IP 地址。这一步通过后,模块已经有了可用数据链路。

2.4 建立 TCP 链路到阿里云 MQTT broker

STM32 不能像 PC 一样直接 socket,需要先让 EC200S 建立 TCP 连接。常见做法是用模块的 AT 指令打开一个 TCP client:

AT+QIDNSCFG=1,"8.8.8.8" AT+QIOPEN=1,0,"TCP","<broker域名或IP>",1883,12345,0

AT+QIOPEN的第一个参数是 contextID,必须和前面AT+CGDCONT使用的 ID 一致;第二个参数是连接 ID,后续AT+QISEND收发数据都用它;TCP是服务类型;broker 地址可以用阿里云平台分配的 MQTT endpoint 域名,部分模组固件只认 IP,需要用 DNS 解析后再填。端口 1883 是标准 MQTT 明文端口,本地端口可以随机。最后一个参数是收据模式,EC200S 不同固件版本取值不同,建议选 buffer access 模式,后面用AT+QISENDAT+QIRD收发数据,这样能明确知道每次收到的数据长度,避免透传模式下串口粘包。返回CONNECT OK后,TCP 链路就建好了。

3. MQTT 协议在 STM32 上的移植与报文封装

TCP 链路通上之后,MQTT 报文由 STM32F103 自己组装。我不用 AT 指令自带的 MQTT 功能,因为要定制 topic 和 payload,还要在收到下发指令后直接操作 GPIO。手写 MQTT 报文不复杂,难的只是协议状态和超时管理。

3.1 读懂 MQTT 报文:固定报头与剩余长度

MQTT 控制报文分为固定报头、可变报头和有效载荷。固定报头第一个字节的高 4 位是报文类型,低 4 位是标志位。常用类型如下:

控制报文报文类型值典型标志
CONNECT0x100
CONNACK0x200
PUBLISH0x30Qos 标志在 bit1/bit2
SUBSCRIBE0x82通常为 0x2
SUBACK0x900
PINGREQ0xC00
PINGRESP0xD00
DISCONNECT0xE00

固定报头第二个字段是剩余长度,编码规则是每个字节低 7 位存放长度值,最高位表示是否还有后续字节。比如长度 321,先取 321 对 128 取余得到 65,加上最高位 0x80 变成 0xC1,再取 321 整除 128 得到 2,所以编码结果是0xC1 0x02。STM32F103 内存有限,建议把发送缓冲区设成 512 字节,MQTT 报文长度控制在 200 字节以内。

3.2 裁剪一个轻量级 MQTT 客户端

网上能搜到不少“mqtt协议在stm32上的移植”代码,多半是基于 paho 嵌入式 C 或自研小栈。我的建议是不追求完整实现,按需裁剪。一个最小客户端只需要 CONNECT、PUBLISH、SUBSCRIBE、PINGREQ 四个功能,收到 CONNACK、SUBACK、PINGRESP 时更新状态。接口可以收敛成下面几个函数:

typedef struct { uint8_t *buf; uint16_t buf_len; uint16_t (*write)(uint8_t *data, uint16_t len); uint16_t (*read)(uint8_t *data, uint16_t len); uint32_t (*get_ms)(void); } mqtt_client_t; int mqtt_encode_connect(uint8_t *buf, const char *client_id, const char *username, const char *password); int mqtt_encode_publish(uint8_t *buf, const char *topic, const uint8_t *payload, uint16_t plen, uint8_t qos); int mqtt_encode_subscribe(uint8_t *buf, uint16_t pid, const char *topic, uint8_t qos); int mqtt_encode_pingreq(uint8_t *buf);

逻辑说明:writeread接口由串口层提供,底层走 EC200S 的AT+QISENDAT+QIRDget_ms返回毫秒时间戳,用于心跳和超时判断。编码函数只负责把报文写入buf,返回报文总长度,由调用方执行发送。这样移植时不依赖具体 RTOS 或裸机循环。

3.3 CONNECT 和 PUBLISH 报文生成

最核心的 CONNECT 报文代码如下:

static uint16_t encode_len(uint8_t *dst, uint32_t len) { uint8_t i = 0; do { uint8_t d = len % 128; len /= 128; if (len) d |= 0x80; dst[i++] = d; } while (len); return i; } static uint16_t append_str(uint8_t *dst, const char *s) { uint16_t len = strlen(s); dst[0] = len >> 8; dst[1] = len; memcpy(&dst[2], s, len); return len + 2; } int mqtt_encode_connect(uint8_t *buf, const char *client_id, const char *username, const char *password) { uint8_t vhead[128]; uint16_t vlen = 0; uint16_t p = 0; // 协议名 MQTT 和协议级别 5 vhead[vlen++] = 0x00; vhead[vlen++] = 0x04; memcpy(&vhead[vlen], "MQTT", 4); vlen += 4; vhead[vlen++] = 0x05; // 连接标志: 0xC2 表示启用用户名校验、密码校验、清理会话 vhead[vlen++] = 0xC2; // Keep Alive 60 秒 vhead[vlen++] = 0x00; vhead[vlen++] = 0x3C; vlen += append_str(&vhead[vlen], client_id); vlen += append_str(&vhead[vlen], username); vlen += append_str(&vhead[vlen], password); buf[0] = 0x10; uint8_t n = encode_len(&buf[1], vlen); memmove(&buf[1 + n], vhead, vlen); return 1 + n + vlen; }

参数说明:连接标志0xC2二进制是1100 0010,bit7 表示后面有 username,bit6 表示有 password,bit1 表示 clean session。阿里云平台要求这两个字段都非空,所以这里没有留可选分支。Keep Alive 设成 60 秒,但实际工程里建议 30 秒主动发一次 PINGREQ,防止运营商 NAT 把链路断开。

PUBLISH 报文的生成同样简单,但要区分 QoS:

int mqtt_encode_publish(uint8_t *buf, const char *topic, const uint8_t *payload, uint16_t plen, uint8_t qos) { uint8_t vhead[256]; uint16_t vlen = 0; uint16_t p = 0; p += append_str(&vhead[p], topic); // QoS1 下需要包标识符,demo 里固定写 0x0001 if (qos > 0) { vhead[p++] = 0x00; vhead[p++] = 0x01; } memcpy(&vhead[p], payload, plen); p += plen; vlen = p; buf[0] = 0x30 | (qos << 1); uint16_t n = encode_len(&buf[1], vlen); memmove(&buf[1 + n], vhead, vlen); return 1 + n + vlen; }

这里buf[0] = 0x30 | (qos << 1)在 QoS1 时得到0x32,对应 MQTT 的 PUBLISH 且需确认。包标识符一般用自增计数或随机数,阿里云对同一连接内这两个字节没有严格限制,但发送端不能重复用同一个值太快。发布消息后,如果使用 QoS1,必须等 PUBACK 才能发下一条,否则会加重 4G 上行负担。

3.4 用 PINGREQ 维持心跳

EC200S 的 TCP 连接看起来是通的,但基站可能已经回收资源。裸机主循环里要周期发送 PINGREQ:

if (now - last_ping >= 30000) { int len = mqtt_encode_pingreq(buf); uart_write(buf, len); last_ping = now; ping_retry++; }

如果连续两个心跳周期没收到 PINGRESP,不应继续等,而应主动关闭 TCP 重连。判断逻辑放在接收解析里,收到 PINGRESP 就把ping_retry清零。

4. STM32F103 接入阿里云物联网平台:MQTT 参数与 JSON Topic 设计

阿里云物联网平台的三元组是 ProductKey、DeviceName、DeviceSecret。连接 broker 时要把它换成标准的 MQTT 协议字段。很多人在这一步卡住,因为密码不是直接填 DeviceSecret,而是用 HMAC-SHA1 算法签出来的。

4.1 生成阿里云 MQTT 连接参数

阿里云控制台“设备详情”页通常会给出 MQTT 连接参数示例,开发前期最快的方式是先把这些参数复制到aliyun_config.h。当要自己生成密码时,常见做法是用下面这套 python 逻辑:

import base64 import hmac import hashlib import urllib.parse product_key = "a1XXXXXX" device_name = "temp01" device_secret = "9c0dXXXXXXXXXX" timestamp = "789" client_id = f"{device_name}|securemode=3,signmethod=hmacsha1,timestamp={timestamp}|" username = f"{device_name}&{product_key}" content = f"clientId{client_id}deviceName{device_name}productKey{product_key}timestamp{timestamp}" sign = hmac.new( device_secret.encode(), content.encode(), hashlib.sha1 ).digest() password = urllib.parse.quote_plus(base64.b64encode(sign).decode()) print("client_id :", client_id) print("username :", username) print("password :", password)

逻辑说明:client_id里用管道符分隔了设备名和签名参数,这里的timestamp必须和签名内容里使用的一致。password是先用 DeviceSecret 对拼接内容做 HMAC-SHA1,再做 Base64 编码,最后做 URL 编码。不同 SDK 生成的字符串可能略有差异,如果某一端连不上,先检查/+=是否被正确转义。对 STM32F103 来说,不需要在 MCU 里跑 HMAC-SHA1,开发阶段预先算好参数烧进固件即可。

注意:一机一密设备每次上线都可以用固定签名参数,只要 DeviceSecret 不泄露,动态计算签名不是必须项。

4.2 物模型属性上报和属性设置的 Topic 与 JSON 格式

在阿里云控制台定义物模型时,我建了两个属性:Temperature类型为 float 只读,LEDSwitch类型为 bool 可写。上报 topic 使用:

/sys/{productKey}/{deviceName}/thing/event/property/post

payload 按官方 JSON 格式封装:

{ "id": "1001", "version": "1.0", "params": { "Temperature": 25.6 }, "method": "thing.event.property.post" }

下发属性设置的 topic 是:

/sys/{productKey}/{deviceName}/thing/service/property/set

云端下发的数据是 JSON 格式,示例:

{ "method": "thing.service.property.set", "id": "123", "params": { "LEDSwitch": 1 }, "version": "1.0" }

这里params.LEDSwitch就是要解析出来的值。MCU 收到后把它映射到 PA1 的输出状态,1 表示亮,0 表示灭。

4.3 用 cJSON 在 STM32F103 上组包和解包

组包用 cJSON 非常方便:

cJSON *root = cJSON_CreateObject(); cJSON_AddStringToObject(root, "id", "1001"); cJSON_AddStringToObject(root, "version", "1.0"); cJSON *params = cJSON_AddObjectToObject(root, "params"); cJSON_AddNumberToObject(params, "Temperature", 25.6); cJSON_AddStringToObject(root, "method", "thing.event.property.post"); char *payload = cJSON_PrintUnformatted(root); mqtt_publish(topic, payload, strlen(payload), 1); free(payload); cJSON_Delete(root);

解析下发的 JSON 时,注意 payload 不一定以\0结尾,最好用cJSON_ParseWithLength,老版本没有这个函数就先拷贝到静态数组再cJSON_Parse

cJSON *root = cJSON_ParseWithLength(buf, len); cJSON *method = cJSON_GetObjectItem(root, "method"); if (strcmp(method->valuestring, "thing.service.property.set") == 0) { cJSON *params = cJSON_GetObjectItem(root, "params"); cJSON *led = cJSON_GetObjectItem(params, "LEDSwitch"); int onoff = led->valueint; HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, onoff ? GPIO_PIN_RESET : GPIO_PIN_SET); } cJSON_Delete(root);

这段代码的逻辑是先取method字段,确认这是属性设置而不是别的事件,再取params.LEDSwitch的值。cJSON 在 STM32F103 上会调用 malloc,高频上报会产生内存碎片,建议把上报周期控制在 1 秒以上,并定期重启或使用静态内存池版本。

4.4 属性设置的下行回复

阿里云平台下发的属性设置是服务调用,设备端需要显式回复。回复 topic 是:

/sys/{productKey}/{deviceName}/thing/service/property/set_reply

payload 要携带原请求的id

{ "code": 0, "id": "123" }

不回复会导致控制台一直显示设备不在线或操作超时。回复成功后,云端才认为指令执行完成。

5. 温度上报与 LED 下发控制的完整 MQTT 收发流程

代码写到这里,相当于把协议栈和业务逻辑都塞进状态机。温度上报不是简单延时,4G 网络可能丢 TCP 包,MQTT 要处理确认,才能避免数据堆积。

5.1 温度采样与滤波

温度采集用 NTC 分压加 ADC 采样。连续采样 16 次,去掉最大值最小值后取平均,再把 ADC 值换算成温度和电阻:

float read_temperature(void) { uint32_t sum = 0; uint16_t min_v = 0xFFFF; uint16_t max_v = 0; for (int i = 0; i < 16; i++) { uint16_t v = adc_read(); sum += v; if (v < min_v) min_v = v; if (v > max_v) max_v = v; } uint16_t avg = (sum - min_v - max_v) / 14; float voltage = avg * 3.3f / 4096.0f; float r_ntc = 10.0f * voltage / (3.3f - voltage); return 1.0f / (1.0f / 298.15f + 1.0f / 3950.0f * logf(r_ntc / 10.0f)) - 273.15f; }

参数说明:这里假定 NTC 是 10K B 值 3950,电源用 3.3V。r_ntc计算公式来自 NTC 分压电路,分母里的3.3f - voltage如果接近 0,说明短路或断路,应该在代码里加阈值判断。logf需要连接浮点数学库,编译时加-lm

5.2 上报状态机与 QoS1 确认

上报链路我用状态机管理,状态包括空闲、连接中、就绪、等待发布确认:

typedef enum { DTU_IDLE, DTU_CONNECTING, DTU_READY, DTU_WAIT_PUBACK } dtu_state_t; dtu_state_t state = DTU_IDLE; uint32_t next_report = 0; while (1) { mqtt_parse_rx(); if (state == DTU_READY && HAL_GetTick() >= next_report) { mqtt_publish("topic", payload, len, 1); state = DTU_WAIT_PUBACK; next_report = HAL_GetTick() + 5000; } if (state == DTU_WAIT_PUBACK && mqtt_puback_flag) { mqtt_puback_flag = 0; state = DTU_READY; } mqtt_keepalive(); }

逻辑说明:只有在DTU_READY状态才允许发布下一条数据。mqtt_puback_flag由 MQTT 解析模块在收到 PUBACK 时置位。如果 10 秒没收到 PUBACK,就认为链路异常,把状态切回DTU_IDLE,触发重连。

5.3 接收缓冲区与 JSON 解析控制 LED

EC200S 如果工作在 buffer access 模式,收到 TCP 数据会先报+QIURC: "recv",<连接ID>,MCU 再用AT+QIRD=<连接ID>,<长度>读取。读回来的数据要放进环形缓冲区,MQTT 解析模块逐字节处理。处理函数如下:

static int handle_property_set(char *buf, int len) { cJSON *root = cJSON_ParseWithLength(buf, len); if (!root) return -1; cJSON *method = cJSON_GetObjectItem(root, "method"); if (strcmp(method->valuestring, "thing.service.property.set") != 0) { cJSON_Delete(root); return 0; } cJSON *params = cJSON_GetObjectItem(root, "params"); cJSON *led = cJSON_GetObjectItem(params, "LEDSwitch"); int onoff = led->valueint; GPIO_WriteBit(GPIOA, GPIO_Pin_1, onoff ? Bit_RESET : Bit_SET); char *id = cJSON_GetObjectItem(root, "id")->valuestring; char reply[64]; snprintf(reply, sizeof(reply), "{\"code\":0,\"id\":\"%s\"}", id); mqtt_publish(reply_topic, reply, strlen(reply), 0); cJSON_Delete(root); return 0; }

这里的strcmp判断必须是完整匹配,不能用strncmp加短匹配,否则会把thing.service.property.set_reply也当成处理对象。LEDSwitch在阿里云物模型里是布尔型,cJSON 解析后valueint为 0 或 1,不能直接用valuestring

5.4 串口日志与异常识别

调试阶段最有用的是在 USART3 打时间戳和状态:

printf("[%lu] state=%d csq=%d puback=%d\r\n", HAL_GetTick(), state, csq_value, mqtt_puback_flag);

日志里如果看到state长时间停在DTU_WAIT_PUBACK,说明上行 TCP 数据到了 broker,但确认没回来;如果state经常回DTU_IDLE,要先查信号,再查模块是否被 reset。把日志改成固定格式后放现场跑一晚上,第二天回来翻串口文件,基本能定位是网络问题还是协议问题。

6. 调稳这套 4G DTU 方案的具体技巧

现场设备和开发板最大的区别是环境不确定。同样的固件,在办公室能跑一天,到工业现场可能两小时掉线一次。这里分享三个我常用的稳定性技巧。

6.1 先看 CSQ 再看代码

排查任何 4G 上传问题,第一步是敲AT+CSQ。返回值第一项如果是 99,说明模块没找到网络;如果是 10 以下,说明信号差,TCP 长连接必然会频繁断开。弱信号环境里,把上报周期从 5 秒拉长到 30 秒,MQTT Keep Alive 从 60 秒改到 30 秒,能让模块早一点发现链路假死,而不是在基站侧默默超时。信号值无法通过代码优化,只能从天线和电源下手。

6.2 MQTT 掉线重连用指数退避

掉线后立即重连,很可能导致模块和基站之间反复冲突。我用指数退避:第一次 3 秒,第二次 6 秒,第三次 12 秒,最多 60 秒封顶。重连前先关闭旧 socket,再做AT+QIACT检查网络,再执行 MQTT CONNECT。整个过程要独立做成一个dtu_reconnect函数,避免主循环里到处散落重连代码。只要 DeviceSecret 不换,每次重连都使用预先算好的 MQTT 参数,不需要重新跑 HMAC-SHA1。

6.3 在 buffer access 模式下组包

透传模式看似简单,但串口数据没有帧边界,MQTT 报文长度超过串口缓冲时会被拆成多段。我建议用 buffer access 模式,每次AT+QIRD返回明确长度,然后按 MQTT 固定报头的剩余长度字段判断是否收完。解析时把 URC 上报的+QIURC: "recv",0当成一次接收事件,优先处理数据读取,再处理业务上报。把AT+CSQ、重连次数和+QIURC三路日志同时挂上,现场问题基本半小时内能定位。

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

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

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

立即咨询