
简介基于STM32单片机与SIM800C模块的MQTT实现这套源码为嵌入式联网开发提供了一套可直接参考的工程范例适合物联网方向的学生、工程师以及希望在现有产品中加入GPRS远程通信的开发者。工程中包含AT指令解析、TCP连接、MQTT发布订阅等核心代码能够支撑设备状态上传与远程控制等需求。包内共274个文件以C源文件与头文件为主还有Keil工程文件、LCD驱动、FatFs文件系统、MPU6050相关模块以及hex固件压缩包大小仅1.56MB整体结构相对完整便于从底层驱动到协议层逐段理解。目前已有192人学习下载有助于快速掌握SIM800C通过GPRS接入MQTT服务器的方法也能为无Wi-Fi环境下的物联网终端联网提供参考。通过对串口中断、TCP数据链路和MQTT报文流程的梳理读者能在此基础上扩展更多传感器数据或加入加密与校验逻辑降低重复开发成本。1. SIM800C接STM32做MQTT这个标题在解决什么问题标题里三样东西放在一起说明你不是在玩仿真而是真要把一块STM32单片机连进物联网SIM800C负责拨号上网MQTT负责和broker说话源码则解决“从AT指令到MQTT报文”这条链路上所有脏活。做这类项目最常见的是毕设里的远程监控或者工厂里一个成本受限的数据采集器STM32读传感器SIM800C用2G网络把数据推上云手机端或者Node-RED订阅同一个topic就能看到实时值。难点不在“配置AT指令让模块上网”而在MQTT协议本身模块只给你一条TCP透传通道协议报文得自己拼心跳得自己保掉线了得自己重连。这篇文章把协议栈选型、报文组装、状态机和排错方法按源码落地顺序讲透熟手可以直接从这里挑参数和边界条件。2. STM32与SIM800C的硬件通路串口、供电与网络注册2.1 最小硬件连接与电平匹配先看物理层。SIM800C对外只有一组UART和STM32的USART对接时绝大多数开发板上模块是3.3V TTL电平可以直接连。但个别模块或转接板用了1.8V逻辑这时必须加电平转换否则串口数据会以乱码形式出现调试半天找不到原因。我一般先查模块丝印和卖家标注再决定要不要加MOS管电平转换电路。最小连接表如下SIM800C引脚功能接STM32端说明VCC模块电源3.4V4.4V独立供电严禁直接接STM32的3.3V峰值电流不足GND地GND与STM32共地缺了它通信全乱RXD串口接收USART1_TX (PA9)STM32发数据给模块TXD串口发送USART1_RX (PA10)模块回复STM32PWRKEY开机键任意GPIO或手动按键拉低至少1秒触发开机SIM_VDDSIM卡供电接SIM卡座模块内部LDO无需外部供电连接里最容易忽略的是电源。SIM800C在GSM网络注册瞬间和通话/数据突发时电流峰值能到2A左右普通AMS1117稳压芯片扛不住这种瞬态跌落表现是模块随机重启、串口无响应。常见做法是用一片MP1584或TPS5430降压模块输入端接5V输出调到4.0V左右并在模块电源引脚旁边并联一个1000μF电解电容和两个100nF陶瓷电容把瞬态压降吸住。注意PWRKEY不能一直拉低开机后要释放否则模块会进入异常模式。2.2 串口参数与AT指令的底噪处理代码侧先配置USART1波特率用9600或115200SIM800C默认是自动波特率第一次上电后最好先发一个“AT”让它定住速率。用STM32标准库或HAL库都行关键是接收要用中断或者DMA不能在主循环里阻塞等待因为后续MQTT报文是异步到达的。void sim800c_uart_init(void) { // 以HAL库为例USART1 115200-8-N-1 UART_HandleTypeDef huart1; huart1.Instance USART1; huart1.Init.BaudRate 115200; huart1.Init.WordLength UART_WORDLENGTH_8B; huart1.Init.StopBits UART_STOPBITS_1; huart1.Init.Parity UART_PARITY_NONE; huart1.Init.Mode UART_MODE_TX_RX; HAL_UART_Init(huart1); // 开启接收中断每收到一个字节都会进回调 HAL_UART_Receive_IT(huart1, rx_byte, 1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 把字节放进环形缓冲区主循环里解析 ringbuf_write(sim800c_rxbuf, rx_byte); HAL_UART_Receive_IT(huart, rx_byte, 1); } }代码里把接收放进环形缓冲区是有原因的SIM800C返回的内容不是固定长度AT指令响应以\r\nOK\r\n结尾建TCP连接后返回CONNECT\r\n之后就是纯透传数据。如果不用缓冲区一条长响应过来时主循环正在处理别的任务字节就丢了。缓冲区大小建议至少给512字节MQTT的一个PINGRESP只有2字节但一个Publish响应可能带着大段topic和payload小了会溢出。串口中断里只做“写入缓冲区”这一个动作不在中断里解析协议这是避免死锁和丢数据的基本纪律。2.3 用AT指令把SIM800C拉进网络串口通了之后先做一组“开机自检”指令顺序不能乱。第一步确认模块活着第二步查SIM卡第三步查信号第四步附上GPRS。逐个等待超时不要让模块带着“SIM卡未准备好”的状态往下走。下面这段函数是这套流程里最核心的骨架int sim800c_network_ready(void) { // 1. 模块活着吗发AT期望收到OK sim800c_send_cmd(AT\r\n); if (!sim800c_wait_response(OK, 2000)) return -1; // 2. SIM卡状态常见返回 CPIN: READY sim800c_send_cmd(ATCPIN?\r\n); if (!sim800c_wait_response(READY, 3000)) return -2; // 3. 信号质量CSQ返回的第一个值建议大于12 sim800c_send_cmd(ATCSQ\r\n); if (!sim800c_wait_response(CSQ:, 3000)) return -3; // 4. 若要手动指定APN用移动卡就是 cmnet sim800c_send_cmd(ATCGDCONT1,\IP\,\cmnet\\r\n); if (!sim800c_wait_response(OK, 1000)) return -4; // 5. 附着GPRS网络 sim800c_send_cmd(ATCGATT1\r\n); if (!sim800c_wait_response(OK, 5000)) return -5; // 6. 查询网络注册状态1是本地注册5是漫游 sim800c_send_cmd(ATCREG?\r\n); if (!sim800c_wait_response(CREG: 0,1, 3000)) return -6; return 0; // 所有检查通过 }这段代码里有一个经常被忽略的点ATCGDCONT里的APN要提前确认。移动的公众物联网卡可能不是cmnet而是运营商单独分配的一个APN联通卡通常是3gnet电信2G没有。APN错了后面建TCP会一直失败或超时。另外ATCSQ返回的信号值范围是0到31一般低于12时建链稳定性很差我会在源码里把这个参数做成一个可配置项调试时通过串口命令修。到这里模块已经具备TCP通信能力但注意此时它还没有主动连接任何服务器。MQTT是应用层协议承载在TCP之上SIM800C通常用ATCIPSTARTTCP,服务器地址,端口这类指令建立连接。连接建立后模块返回CONNECT之后所有发往串口的数据都会透明地发送到TCP对端从对端收到的数据也会原样从串口吐出来。这个“透传模式”是我们接下来自己组装MQTT报文的物理基础。3. MQTT报文自己组装固定头、剩余长度与CONNECT3.1 两条实现路线的选择SIM800C能不能直接跑MQTT业界有两种做法。第一种是依赖模块固件自带的MQTT扩展AT指令比如某些4G模块固件提供了ATMQTTCFG、ATMQTTSUB之类的指令把协议栈封装在模块内部STM32只需要发几条简单命令。这个方案表面上省事实际有两个坑一是模块固件版本不同指令集差异很大换个批次可能就不兼容二是扩展指令把报文细节藏起来出错时你根本不知道是TCP断了还是MQTT层被拒排错全靠猜。第二种是自己精选报文通过TCP透传通道发送标准MQTT数据包。这样协议控制权完全在自己手里任何一个字节都能在串口上抓回来分析而且不依赖特定固件换EC20、Air724等模块时应用层代码几乎不用改。我在源码里选第二种。MQTT协议本身很轻3.1.1版本的控制报文总共才十几种实际嵌入式设备只会用到CONNECT、CONNACK、PUBLISH、SUBSCRIBE、PINGREQ、PINGRESP、DISCONNECT这几种。自己拼报文不是炫技是把不可控的黑盒变成可控的白盒排查问题时有据可查。3.2 固定头与剩余长度编码每种MQTT报文都从固定头开始。固定头第一个字节的高四位表示报文类型低四位是标志位。后面跟着“剩余长度”Remaining Length表示可变头加有效载荷的总字节数。剩余长度用变长编码表示每个字节低7位是数值最高位是“是否还有下一字节”的标记。长度小于128时只占1字节大于等于128时要拆成多字节。报文类型固定头高四位值方向用途CONNECT0x1客户端→服务器发起登录CONNACK0x2服务器→客户端登录结果PUBLISH0x3双向发布消息SUBSCRIBE0x8客户端→服务器订阅主题PINGREQ0xC客户端→服务器心跳请求PINGRESP0xD服务器→客户端心跳响应注意PUBLISH的低四位是QoS标志比如QoS0时第一个字节是0x30QoS1是0x31QoS2是0x32。网上很多新手把PUBLISH写死成0x30一旦要用QoS1就全丢所以我会在源码里用宏定义区分。剩余长度编码要写一个专门函数因为它不是简单的十进制转十六进制int mqtt_encode_remaining_length(uint8_t *buf, int len) { int index 0; do { uint8_t encoded len % 128; // 取低7位 len / 128; // 剩下的是高位部分 if (len 0) { encoded | 0x80; // 最高位置1表示后面还有字节 } buf[index] encoded; } while (len 0); return index; // 返回编码后的字节数 }这个函数是组包的地基。比如一个CONNECT报文可变头和payload总共90字节剩余长度就是0x5A一个字节如果payload里有很长的client id总长度超过127就必须用两字节编码。很多DIY源码在总长度超过127后broker直接断链多半就是这个编码函数没写好。解码时反向操作每个字节只取低7位并乘以256的n次方累加。3.3 组装并发送CONNECT报文CONNECT报文是连接broker的敲门砖它的可变头固定包含“MQTT”四个ASCII字符0x4D 0x51 0x54 0x54、协议级别3.1.1对应0x04、连接标志和Keep Alive两个字节。连接标志里bit1是Clean Session位通常置1表示不保留离线会话这样模块重启后重新订阅不会收到陈旧消息。下面是一个可直接用的组包函数int mqtt_build_connect(uint8_t *buf, const char *client_id, uint16_t keep_alive) { int pos 0; uint8_t variable_header[10]; int vh_len 0; // 协议名 MQTT长度2字节前缀 variable_header[vh_len] 0x00; variable_header[vh_len] 0x04; variable_header[vh_len] M; variable_header[vh_len] Q; variable_header[vh_len] T; variable_header[vh_len] T; // 协议级别 4 MQTT 3.1.1 variable_header[vh_len] 0x04; // 连接标志bit11 表示 Clean Session variable_header[vh_len] 0x02; // Keep Alive16位大端单位秒 variable_header[vh_len] keep_alive 8; variable_header[vh_len] keep_alive 0xFF; // payload: client id 长度 内容 int cid_len strlen(client_id); uint8_t payload[64]; payload[0] cid_len 8; payload[1] cid_len 0xFF; memcpy(payload[2], client_id, cid_len); int remaining vh_len cid_len 2; buf[0] 0x10; // CONNECT固定头 int rl_len mqtt_encode_remaining_length(buf[1], remaining); memcpy(buf[1 rl_len], variable_header, vh_len); memcpy(buf[1 rl_len vh_len], payload, cid_len 2); return 1 rl_len remaining; }组装完成后通过串口把buf发给SIM800CSIM800C处于透传模式时会原样转发给broker。broker处理完会回复CONNACK固定头第一个字节是0x20第二个字节是剩余长度2第三、四字节分别是连接确认标志和返回码。返回码0表示成功1表示协议版本不支持2表示client id被拒绝3表示服务器不可用4或5是认证失败。收到CONNACK后必须检查第4个字节不能只看“有数据回来”就算连上了。3.4 TCP透传模式切换的时序细节在发CONNECT之前要确保SIM800C已经用ATCIPSTART建立好TCP链路。建立成功后模块会主动返回一行CONNECT\r\n这行文本是模块给你的提示不是broker发的数据源码处理时必须先把这行字消费掉再进入透传状态。有些开发者没处理这个提示直接把它当成MQTT报文解析结果后面的包全部因错位而废掉。我通常在代码里等待CONNECT\r\n出现后再清空接收缓冲区并且加一个500ms延时让串口上残留的字节都排空再开始发MQTT数据。透传模式下退出发送状态的指令是注意不能跟回车换行。这个指令在调试时很有用比如发现MQTT连接不正常想回到AT模式查信号值就发再发AT\r\n。但在你的源码里重连逻辑不需要频繁退出透传断网时SIM800C会自动退出透传并返回CLOSED\r\n等提示这时候重新走AT流程建TCP即可。这一点后文重连状态机会展开。4. 心跳保活、PUBLISH报文与断线重连状态机4.1 PUBLISH报文的组装与发送设备连上broker后最频繁的操作就是把传感器数据推上去。PUBLISH报文的可变头包含topic长度、topic字符串以及QoS1/2时才有的报文标识符。对于QoS0报文短小代码如下int mqtt_build_publish_qos0(uint8_t *buf, const char *topic, const uint8_t *payload, uint16_t payload_len) { int pos 0; int topic_len strlen(topic); // 剩余长度 topic长度(2) topic内容 payload长度 int remaining 2 topic_len payload_len; buf[pos] 0x30; // PUBLISH, QoS0, DUP0, Retain0 pos mqtt_encode_remaining_length(buf[pos], remaining); buf[pos] topic_len 8; buf[pos] topic_len 0xFF; memcpy(buf[pos], topic, topic_len); pos topic_len; memcpy(buf[pos], payload, payload_len); pos payload_len; return pos; // 返回整包长度 }topic建议做成固定字符串比如device/01/sensor不要每次组topic时动态拼接因为SIM800C透传通道没有本地缓存整包必须一次性拼完放进发送缓冲区再逐字节发。如果payload是AT指令转来的十六进制字符串要注意长度别超过SIM800C单次串口发送的一包上限一般以1024字节为安全值。发布完QoS0的消息后broker不会回复不需要等待直接继续下一轮数据采集即可。这也是很多低成本设备选QoS0的原因省流量、省代码、不阻塞主循环代价是极端情况下丢消息但传感器定时上报类业务通常丢一帧可以接受。如果业务要求QoS1需要在可变头里加一个两字节的packet ID比如0x0001并且记住发送后的状态等待broker返回PUBACK固定头0x40。PUBACK没回来之前不能用同一个packet ID发下一条否则broker分不清重传还是新消息。4.2 KeepAlive心跳参数怎么设MQTT的KeepAlive是连接建立时CONNECT报文里那个16位数字单位是秒。它的含义不是“必须每隔这么久发一个包”而是“在这段时间内如果没有业务数据发出必须发一个PINGREQ”。broker如果在一个半的KeepAlive周期内没收到任何数据会认为客户端死了并断开连接。返回包PINGRESP只有两个字节0xD0 0x00非常好判断。心跳周期不能设得太激进。SIM800C在2G网络下一次PINGREQ从模块到broker的往返延迟可能几百毫秒如果KeepAlive设成5秒模块在弱信号时很容易因一两次收发超时被broker踢掉。我一般设置成30到60秒并在主循环里维护一个计数器uint32_t last_send_ms HAL_GetTick(); uint32_t keepalive_ms 60 * 1000; void mqtt_keepalive_task(void) { if (HAL_GetTick() - last_send_ms keepalive_ms) { uint8_t pingreq[2] {0xC0, 0x00}; uart_send_bytes(pingreq, 2); mqtt_pending_pingresp 1; // 置标志等待PINGRESP last_send_ms HAL_GetTick(); } }这里留一个mqtt_pending_pingresp标志作用是检测链路假死。如果发出PINGREQ后超过比如10秒还没收到PINGRESP说明TCP虽然没报错但网络已经断了需要主动进入重连流程不用傻等SIM800C返回CLOSED。这个超时检测非常关键因为插座上落灰的2G模块经常出现“TCP名义连接、实际无数据”的假死状态。4.3 断线重连的状态机断线重连是整个源码里最体现工程能力的部分。最简单的做法是死循环里反复ATCIPSTART但这样会让SIM800C在SIM卡未就绪时不断重启反而拖慢恢复。我实现了一个五状态状态机每个状态都有最小停留时间防止抖动状态触发条件动作进入下一状态NET_WAIT上电启动等待网络注册成功注册成功后到TCP_CONNECTTCP_CONNECT网络就绪发送ATCIPSTART等待CONNECT或CLOSED收到CONNECT后到MQTT_CONNECTMQTT_CONNECTTCP已通发送CONNECT报文等待CONNACK返回码0到ONLINE否则重试ONLINE收到CONNACK正常收发PUBLISH和心跳收到CLOSED或心跳超时到回退RETRY任一环节失败按1s、2s、4s递增延时后回到TCP_CONNECT达到最大重试后重启模块代码里的核心是控制重连的退避逻辑MAC层重连不能太频繁2G网络的注册本身就要好几秒连续快速重试会让SIM800C内部协议栈跟不上。下面给出这个状态机的骨架实现void mqtt_connection_task(void) { static uint8_t retry_count 0; static uint32_t next_attempt_ms 0; switch (conn_state) { case NET_WAIT: if (sim800c_network_ready() 0) { conn_state TCP_CONNECT; retry_count 0; } break; case TCP_CONNECT: sim800c_open_tcp(mqtt.example.com, 1883); conn_state WAIT_TCP_CONNECT; next_attempt_ms HAL_GetTick() 10000; break; case ONLINE: mqtt_keepalive_task(); if (sim800c_uart_has_closed()) { // 检测到CLOSED conn_state TCP_CONNECT; retry_count 0; } break; case RETRY: // 退避重连1s, 2s, 4s, 8s... 封顶30s if (HAL_GetTick() next_attempt_ms) { uint32_t delay_ms (1 retry_count) * 1000; if (delay_ms 30000) delay_ms 30000; next_attempt_ms HAL_GetTick() delay_ms; retry_count; conn_state TCP_CONNECT; } break; } }注意case ONLINE里没有直接调用阻塞式delay整个状态机在主循环里轮询调用一个周期几十毫秒完全不影响串口收发。TCP_CONNECT的10秒超时也很关键因为SIM800C的CIPSTART在弱信号下可能很久不返回没有超时的话状态机会卡死。rerty_count达到比如5时就不只是重连TCP了而是重启SIM800C模块重新走开机流程这比在透传模式里反复挣扎要可靠得多。5. 源码落地一个最小可跑的设备端骨架5.1 场景封装把传感器、SIM800C、MQTT拆成三个模块真正好维护的源码不是把AT指令和报文组包写在一个巨型文件里。我一般拆成三个文件加一个主循环sim800c.c负责所有AT指令和网络状态对外提供sim800c_open_tcp()、sim800c_send()、sim800c_check_closed()mqtt.c负责组装和解析MQTT报文不关心底层是SIM800C还是ESP8266app.c里放业务逻辑比如读温度、组装payload、决定何时上报。这样做的直接好处是测试方便在PC上可以把mqtt.c单独抽出来编译连接本地的broker验证组包逻辑不用每次都烧进板子。5.2 主循环里的一次完整上报流程下面这段代码是设备端业务主循环的片段场景做成了常见的热词场景STM32鱼缸监测每30秒上报一次水温同时订阅一个控制topic收到relay_on就打开加热棒。这个场景不需要真实传感器也可以模拟运行。#define TEMP_TOPIC fish_tank/01/temp #define CTRL_TOPIC fish_tank/01/ctrl int main(void) { uint8_t mqtt_buf[512]; uint32_t last_report_ms 0; // 初始化全部外设和模块 sim800c_uart_init(); sim800c_power_on(); while (1) { // 1. 负责网络状态机维持在正确的状态 mqtt_connection_task(); if (mqtt_get_status() MQTT_ONLINE) { // 2. 每隔上报周期读一次传感器并发布 if (HAL_GetTick() - last_report_ms 30000) { int temp_x10 read_temperature_x10(); // 比如 255 表示25.5度 char payload[16]; // 避免浮点打印用整数方式组字符串 snprintf(payload, sizeof(payload), {\t\:%d.%d}, temp_x10 / 10, temp_x10 % 10); int len mqtt_build_publish_qos0(mqtt_buf, TEMP_TOPIC, (uint8_t *)payload, strlen(payload)); sim800c_send(mqtt_buf, len); last_report_ms HAL_GetTick(); } // 3. 处理broker下发的控制消息 handle_incoming_mqtt(ctrl_callback); } // 4. 喂看门狗或者做其他低速任务 service_watchdog(); } }read_temperature_x10()返回的是放大了10倍的整型温度这样在payload里拼成25.5时完全不需要%f。嵌入式设备上%f会增加可观的浮点库开销而且2G上行带宽有限浮点字符串也更长。payload这里用极简JSON{t:25.5}总共才11个字节比用cJSON库再转字符串轻得多。如果你的数据量大可以进一步压缩成定长字符串比如前两位表示温度后两位表示水位解析端按位拆。控制topic的订阅用SUBSCRIBE报文完成报文结构不复杂固定头0x82 0x0E之后是两字节报文标识、两字节topic长度、topic内容、一字节QoS。订阅成功后broker会回复SUBACK回到连接状态后应检查一下SUBACK的返回码。控制消息的下发通常是QoS0或QoS1如果你用QoS0handle_incoming_mqtt()里读到PUBLISH报文后直接匹配topic字符串执行动作代码简单直接。5.3 减少RAM和Flash占用的三个具体做法STM32的RAM按型号从几KB到几百KB不等做2G模块方案时通常选中等容量芯片比如STM32F103C8T6只有20KB RAM。MQTT组包缓冲区、SIM800C串口环形缓冲区、AT响应解析缓冲区加起来很容易就吃光了。第一个做法是复用缓冲区组CONNECT、PUBLISH、PINGREQ共用一个静态uint8_t数组用完即覆盖不要每个模块单独申请大数组。第二个做法是缩短AT响应匹配字符串比如sim800c_wait_response(OK, ...)里只找前两个字节“OK”而不是匹配整行完整指令。第三个做法是不要存储完整的订阅消息列表只保存一个topic字符串和一个回调函数指针消息到达时直接在当前缓冲区分拣不拷贝进新数组。做完这三件事20KB RAM跑这套协议栈完全够用。6. 验证与排错三个串口日志技巧能解决九成问题代码写好烧进板子真机联调时最常见的问题集中在三处CONNECT发出去没有回应、连上了但收不到订阅消息、跑一段时间后自动断开。这三个问题都能在串口日志里找到明确证据。第一个技巧给串口输出加上时间戳和方向标记。每发一帧数据前用类似[TX]打印收到broker或模块主动上报用[RX]打印并且十六进制逐字节打出来。这样一旦某次组包多了一个字节或少了一个字节能立刻从日志对比看出。比如CONNECT报文的固定头后面剩余长度和实际payload长度不一致broker会直接断开这属于最高频的组包错误。第二个技巧CONNACK返回码对照表的实践意义。模块收到broker回复后把第四字节的返回码打印成数字你不需要猜。返回码0走后续流程返回码2说明client id跟某个在线设备冲突换个后缀再试返回码5说明用户名密码不对跟broker侧配置核对。很多看上去像SIM800C断线的问题其实是broker业务层的拒绝AT指令查不出来。第三个技巧验证本地broker连通性的替代方法。手边没有公网broker时可以在局域网内用PC跑一个轻量MQTT brokerSIM800C通过2G访问局域网IP通常是做不到的但可以把mqtt.c的协议组包部分放到PC上单独编译用同一个串口“喂”给PC上的模拟broker先把协议层验证干净再回到板子上联调2G链路。如果一定要在真机环境看完整链路就用一个带公网IP的测试broker或云厂商的IoT平台的调试页面重点观察设备是否周期性上报心跳以及上报间隔是否与KeepAlive一致。当你看到broker侧每隔60秒收到一次PINGREQ记录同时设备端串口日志里每次PINGRESP都如约而至这条“STM32 → SIM800C → 基站 → broker”的全链路就算真正稳定了。本文还有配套的精品资源点击获取