STM32+ESP8266+OneNet智能家居实战:MQTT协议从硬件到云端全解析 1. 项目缘起与整体设计思路1.1 为什么选择 STM32 OneNet 这套组合这个项目是我 2022 年做的一个智能家居控制系统核心架构就是 STM32 做主控ESP8266 负责联网数据上云走 OneNet 平台通信协议用 MQTT。当时市面上做智能家居的方案很多有直接用 ESP32 单芯片搞定的也有用树莓派跑 HomeAssistant 的但我最后还是选了 STM32 ESP8266 这个组合原因有几个。第一是成本可控。STM32F103C8T6 这颗芯片当时散买也就十块钱出头ESP8266-01S 模块五六块钱加上一些继电器、传感器整个硬件成本能压在五十块以内。对于学生做毕设或者个人练手来说这个成本非常友好。第二是分工清晰STM32 负责实时性要求高的本地控制逻辑比如按键响应、传感器轮询、继电器状态管理ESP8266 只负责透传数据到云端各司其职调试的时候也容易定位问题出在哪一环。第三是学习价值高你能同时接触到裸机开发、串口通信、AT 指令、MQTT 协议、云平台接入这一整条链路比直接用一个封装好的模组学到的东西多得多。OneNet 这个平台当时选它主要是因为它对个人开发者免费MQTT 接入文档写得还算清楚而且支持物模型和多协议接入。虽然后来 OneNet 的版本迭代比较多但 2022 年那会儿的旧版 MQTT 接入方式用起来还是挺顺手的。1.2 系统整体架构拆解整个系统的数据流是这样的STM32 通过 GPIO 读取温湿度传感器我用的是 DHT11的数据同时管理继电器模块控制家电开关。STM32 和 ESP8266 之间通过 UART 串口通信STM32 发 AT 指令给 ESP8266ESP8266 负责建立 TCP 连接、完成 MQTT 握手、发布和订阅消息。云端 OneNet 收到数据后可以在网页端或者手机 App 上展示同时也能下发控制指令回来。这里有个关键设计决策MQTT 的报文拼装到底放在 STM32 端还是 ESP8266 端。我选择的是在 STM32 端拼装 MQTT 报文ESP8266 只做透传。为什么这么选因为 ESP8266 如果刷了支持 MQTT 的固件虽然能省掉 STM32 拼报文的工作但固件版本五花八门AT 指令集不统一调试起来反而更麻烦。让 ESP8266 老老实实做透传STM32 端自己控制 MQTT 报文的每一个字节虽然代码量大一些但可控性最强出了问题也容易抓包分析。提示如果你用的是 ESP8266 的 AT 固件务必确认固件版本支持ATCIPSTART建立 TCP 连接和ATCIPSEND发送数据这两个指令是整个透传方案的基础。1.3 功能边界与适用人群这个系统实现的功能包括温湿度数据实时上报、继电器控制家电开关、云端数据展示、远程控制指令下发。没有做视频监控、语音控制这些复杂功能因为那些需要更强的算力和带宽超出了 STM32F103 的能力范围。适合谁来参考如果你正在做基于 STM32 的毕业设计或者想入门物联网开发但不知道从哪下手这个项目是一个很好的练手选择。它涉及的知识点覆盖面广但每个环节的难度都不算太高只要你有基本的 C 语言基础和单片机开发经验就能跟上。如果你是完全零基础的小白建议先补一下 STM32 的 GPIO 操作和串口通信再来看这个项目会轻松很多。2. 硬件选型与核心细节解析2.1 主控芯片与外围模块的搭配逻辑主控我用的 STM32F103C8T6也就是常说的“蓝板”或“最小系统板”。这颗芯片是 Cortex-M3 内核72MHz 主频64KB Flash20KB RAM对于这个项目来说绰绰有余。选它还有一个很实际的原因资料多。江科大、正点原子这些教程铺天盖地遇到问题搜一下基本都能找到答案。ESP8266 模块我用的是 ESP-01S体积小、价格便宜但有个坑要注意它的供电必须是 3.3V而且峰值电流能到 300mA 左右如果你直接用 STM32 板子上的 3.3V 输出给它供电很可能会因为电流不够导致模块反复重启。我的做法是单独用一个 AMS1117-3.3 稳压芯片给它供电输入接 5V输出 3.3V这样稳定性好很多。继电器模块用的是常见的 4 路 5V 继电器光耦隔离的那种。这里有个细节STM32 的 GPIO 输出是 3.3V而继电器模块的输入信号如果是 5V 电平触发直接接上去可能驱动不了。我选的这款继电器模块支持 3.3V 触发所以可以直接连。如果你手头的继电器模块只支持 5V 触发中间需要加一个电平转换电路或者用三极管做驱动。DHT11 温湿度传感器是单总线协议接一个 GPIO 就行。它的精度一般温度 ±2℃湿度 ±5%但对于智能家居这种场景够用了。如果你对精度要求高可以换成 SHT30 或者 AHT20走 I2C 接口代码稍微改一下就行。2.2 STM32 端 MQTT 报文拼装的实现要点MQTT 协议的核心报文结构分为固定头、可变头和有效载荷三部分。固定头第一个字节是报文类型和标志位第二个字节开始是剩余长度。这里有个容易踩坑的地方剩余长度的编码方式。如果剩余长度小于 128 字节用一个字节表示就行如果大于等于 128 字节需要用多个字节每个字节的低 7 位有效最高位表示是否还有后续字节。我在 STM32 端实现的时候写了一个MQTT_EncodeRemainingLength函数专门处理这个编码。举个例子如果剩余长度是 200二进制是 11001000那么第一个字节存低 7 位即 1001000最高位置 1 表示还有后续得到 0xC8第二个字节存剩下的 1得到 0x01。最终编码结果是0xC8 0x01。CONNECT 报文的可变头里需要填协议名、协议级别、连接标志、保持连接时间。协议名是 “MQTT”协议级别用 0x04 表示 MQTT 3.1.1。连接标志位里要设置用户名密码标志、清理会话标志等。有效载荷里依次填客户端 ID、用户名、密码。OneNet 旧版 MQTT 接入时客户端 ID 用设备 ID用户名用产品 ID密码用鉴权信息这些在 OneNet 控制台创建设备后都能拿到。PUBLISH 报文用来上报数据主题格式一般是$dp或者自定义的 topic。OneNet 的物模型接入方式下数据需要按照它规定的 JSON 格式上传。比如上传温度和湿度payload 大概是{temp:25,humi:60}这样的结构。SUBSCRIBE 报文用来订阅控制指令的下发主题订阅成功后云端下发的消息会通过 ESP8266 透传给 STM32STM32 解析后执行相应动作。2.3 ESP8266 的 AT 指令配置流程ESP8266 上电后需要依次发送一系列 AT 指令完成初始化。我整理了一个标准的配置流程ATRST // 复位模块 ATCWMODE1 // 设置为 Station 模式 ATCWJAPWiFi名称,WiFi密码 // 连接路由器 ATCIPMUX0 // 设置单连接模式 ATCIPSTARTTCP,183.230.40.39,6002 // 连接 OneNet MQTT 服务器 ATCIPMODE1 // 开启透传模式 ATCIPSEND // 进入透传发送这里有几个关键点。ATCIPSTART里的 IP 和端口是 OneNet 旧版 MQTT 服务器的地址如果你用的是新版 OneNet 或者别的平台这个地址需要相应修改。ATCIPMODE1开启透传模式后之后发送的所有数据都会直接通过 TCP 发出去不再需要每次发ATCIPSEND。但要注意透传模式下退出需要用而且前后要留至少 1 秒的静默时间否则会被当成普通数据发出去。注意ESP8266 的 AT 指令响应时间不确定尤其是ATCWJAP连接路由器可能需要几秒钟。STM32 端在发送指令后需要等待模块返回 “OK” 或 “” 等预期响应不能发完就立刻发下一条。我一般用超时重试机制每条指令最多等 5 秒超时就重发重试 3 次还不行就复位模块重新来。2.4 数据上报频率与心跳机制的设计数据上报频率我设的是 5 秒一次。为什么选 5 秒因为 DHT11 本身采样周期就不宜太快 datasheet 建议间隔不低于 1 秒而 MQTT 的 PUBLISH 报文如果发得太频繁ESP8266 的发送缓冲区和网络带宽都会有压力。5 秒是一个比较平衡的值既能保证数据的实时性又不会给系统造成太大负担。MQTT 的 Keep Alive 我设的是 60 秒。这个值决定了客户端在没有发送任何报文的情况下最多多久必须发一次 PINGREQ 心跳包。如果设得太短会增加不必要的网络流量设得太长服务器可能认为客户端已经掉线。60 秒是 MQTT 协议推荐的常见值配合 5 秒一次的数据上报实际上心跳包很少需要单独发因为数据上报本身就起到了保活的作用。但这里有个隐患如果网络断开或者 OneNet 服务器主动断开连接STM32 端需要能检测到并重新连接。我的做法是在每次发送 PUBLISH 报文后检查 ESP8266 的返回如果连续几次发送失败就触发重连流程先发退出透传再重新走一遍ATCIPSTART和 MQTT CONNECT 流程。3. 实操过程与核心环节实现3.1 开发环境搭建与工程配置开发环境我用的是 Keil MDK5配合 STM32F1 的芯片包。安装 Keil5 的时候有个坑如果你电脑上同时装了 Keil C51两者可能会冲突。我的建议是分开装在不同目录或者干脆用 STM32CubeIDE免费而且没有版权问题。新建工程后需要把 STM32F10x 标准外设库或者 HAL 库添加到工程里。我用的标准库因为代码量小、执行效率高而且网上例程多。需要添加的文件包括启动文件startup_stm32f10x_md.s、系统初始化文件system_stm32f10x.c、以及 GPIO、USART、RCC 这些外设的驱动文件。时钟配置很关键。STM32F103 外部晶振是 8MHz经过 PLL 倍频到 72MHz 作为系统时钟。APB1 总线时钟是 36MHzAPB2 是 72MHz。USART1 挂在 APB2 上所以波特率计算时用的是 72MHz。我用的波特率是 115200这个速率和 ESP8266 通信足够快而且误差小。串口初始化代码大概长这样void USART1_Init(u32 bound) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // PA9 TX GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOA, GPIO_InitStructure); // PA10 RX GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate bound; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); USART_Cmd(USART1, ENABLE); }3.2 DHT11 驱动与数据采集DHT11 是单总线器件时序要求比较严格。它的通信过程是主机拉低总线至少 18ms 作为起始信号然后释放总线DHT11 响应后会拉低总线 80us 再拉高 80us之后开始传输 40 位数据每位数据以 50us 低电平开始高电平的持续时间决定数据是 0 还是 1——26-28us 表示 070us 表示 1。在 STM32 上实现的时候需要用微秒级延时。我直接用 SysTick 定时器做延时基准delay_us函数用循环计数实现。读数据的时候要关中断因为中断会打断时序导致读取失败。实测下来如果不关中断读取成功率大概只有六七成关了中断之后基本百分之百成功。u8 DHT11_Read_Byte(void) { u8 i, dat 0; for(i 0; i 8; i) { while(DHT11_DATA_IN() 0); // 等待50us低电平结束 delay_us(40); dat 1; if(DHT11_DATA_IN() 1) dat | 1; while(DHT11_DATA_IN() 1); // 等待剩余高电平结束 } return dat; }读出来的数据是 5 个字节湿度整数、湿度小数、温度整数、温度小数、校验和。校验和等于前四个字节相加的低 8 位。如果校验不通过直接丢弃这次数据重新读。3.3 MQTT 连接 OneNet 的完整流程连接 OneNet 的 MQTT 服务器第一步是建立 TCP 连接。ESP8266 发送ATCIPSTARTTCP,183.230.40.39,6002如果返回 “CONNECT” 和 “OK” 就说明 TCP 通了。然后发送 MQTT CONNECT 报文。CONNECT 报文的拼装我写了一个函数把各个字段按顺序填入缓冲区。客户端 ID 用 OneNet 设备详情里的设备 ID用户名用产品 ID密码用鉴权信息。这些字符串需要先算长度然后按 MQTT 的字符串编码格式2 字节长度 内容填入。发送 CONNECT 报文后OneNet 会返回 CONNACK 报文固定头是0x20 0x02可变头第一个字节是连接确认标志0x00 表示连接成功非零表示失败。我在这里加了一个判断如果 CONNACK 返回失败就打印错误码方便排查。连接成功后需要发送 SUBSCRIBE 报文订阅控制指令主题。OneNet 旧版 MQTT 的下发主题一般是$creq/开头的具体格式在设备详情页能看到。订阅成功后云端下发的控制指令会通过 ESP8266 透传到 STM32 的串口接收缓冲区STM32 解析 JSON 后执行相应操作。数据上报用 PUBLISH 报文QoS 等级我用的 0也就是最多发一次不保证到达。为什么不用 QoS 1因为 QoS 1 需要处理 PUBACK 确认报文代码复杂度增加不少而且对于温湿度这种周期性上报的数据丢一两个包影响不大下一个周期就补上了。但如果是控制指令的下发建议用 QoS 1确保指令不丢。3.4 云端数据展示与远程控制联调OneNet 控制台里可以创建数据流模板把温度、湿度、继电器状态这些数据流定义好。设备上线后数据流会自动出现在设备详情页可以看实时数据曲线。也可以创建应用用拖拽的方式做一个简单的数据展示界面支持手机端查看。远程控制的联调过程是这样的在 OneNet 控制台或者手机 App 上点击控制按钮云端会向设备订阅的主题下发一条 JSON 指令比如{relay1:1}表示打开继电器 1。STM32 收到后解析这个 JSON把对应的 GPIO 拉高继电器吸合家电通电。同时 STM32 会把这个状态变化作为一条数据上报回去云端界面上的状态图标就会更新。联调的时候我遇到过一个坑OneNet 下发的 JSON 指令里字段名和我在 STM32 端解析用的字符串不一致导致解析失败。后来我在 OneNet 的物模型定义里把字段名固定下来STM32 端也按同样的字段名解析问题就解决了。所以建议在项目开始之前先把云端和设备的字段名约定好避免后期来回改。提示调试 MQTT 通信的时候可以用 MQTTX 这个客户端工具模拟设备连接 OneNet先验证云端配置是否正确再调 STM32 端的代码。这样能把问题范围缩小不用每次都烧录 STM32 来测试。4. 常见问题与排查技巧实录4.1 ESP8266 连接失败与超时问题ESP8266 连接 OneNet 失败是最常见的问题表现是ATCIPSTART返回 “ERROR” 或者 “CLOSED”。排查思路按顺序来先确认 WiFi 是否连接成功发ATCWJAP?查询当前连接状态如果 WiFi 没问题检查 OneNet 的 IP 和端口是否写对旧版 MQTT 服务器地址是183.230.40.39:6002新版可能不一样如果地址也对那可能是模块固件版本太老不支持某些 AT 指令需要重新刷固件。刷 ESP8266 固件的时候接线要注意GPIO0 拉低进入下载模式GPIO2 拉高CH_PD 拉高。刷写工具用官方的 Flash Download Tool选对固件文件和地址。刷完之后 GPIO0 要拉高才能正常运行。我遇到过刷完固件后模块没反应的情况后来发现是 GPIO0 忘了拉高模块一直停在下载模式。还有一个坑是a fatal esptool.py error occurred: failed to connect to esp8266: timed out这个错误一般是串口被占用或者接线不对。检查一下串口线是不是只接了 TX 和 RX没有接地线或者串口被其他软件占用了关掉其他串口工具再试。4.2 MQTT 连接被服务器拒绝的排查CONNACK 返回非零错误码说明 MQTT 连接被 OneNet 拒绝了。常见的错误码和原因错误码含义排查方向0x01协议版本不支持检查协议级别是否填的 0x040x02客户端 ID 无效检查设备 ID 是否和 OneNet 上一致0x04用户名或密码错误检查产品 ID 和鉴权信息0x05未授权检查设备是否已在 OneNet 上激活我遇到最多的是 0x04原因是鉴权信息填错了。OneNet 的鉴权信息是一串比较长的字符串复制的时候容易多复制空格或者少复制字符。建议直接从控制台复制粘贴到代码里之后再用串口打印出来核对一遍。还有一个隐蔽的问题MQTT 报文的剩余长度编码错误。如果剩余长度算错了服务器解析报文时会出错可能直接断开连接。我写了一个校验函数在发送前把拼好的报文打印出来人工核对一下固定头和剩余长度是否正确。4.3 数据上报成功但云端不显示有时候 STM32 这边显示 PUBLISH 报文发送成功了但 OneNet 控制台上看不到数据。这种情况一般是数据格式不对。OneNet 的物模型接入要求数据按照特定的 JSON 格式上传比如{temp:25}如果字段名和物模型里定义的不一致云端会丢弃这条数据。排查方法在 OneNet 控制台的设备日志里看有没有收到数据。如果日志里有数据但解析失败说明格式有问题如果日志里根本没有数据说明 PUBLISH 报文没有真正到达服务器可能是主题写错了或者 QoS 等级不匹配。还有一种可能是数据流没有创建。OneNet 旧版需要先在数据流模板里定义好数据流名称设备上报的数据才能被正确解析和展示。如果数据流名称对不上数据会被丢弃。4.4 系统长时间运行后死机或重启这个问题我调试了很久才找到原因。系统跑几个小时之后STM32 会莫名其妙死机或者重启。排查下来发现是串口接收缓冲区溢出导致的。ESP8266 透传模式下云端下发的数据会随时通过串口发过来如果 STM32 的串口接收中断处理不及时数据就会丢失严重时会导致缓冲区指针越界引发 HardFault。解决办法是加大串口接收缓冲区并且在中断里只做数据搬运不做解析。解析工作放到主循环里做这样中断处理时间短不容易丢数据。另外在解析 JSON 的时候加了长度校验防止越界访问。还有一个原因是电源问题。ESP8266 在发送数据时电流会突然增大如果供电不足会导致电压跌落STM32 可能因此复位。我在 ESP8266 的电源引脚旁边并了一个 100uF 的电解电容和一个 0.1uF 的陶瓷电容电压跌落问题明显改善。4.5 常见问题速查表现象可能原因解决方法ESP8266 无响应供电不足或接线错误单独供电检查 TX/RX 交叉连接ATCWJAP 返回 ERRORWiFi 密码错误或信号弱核对密码靠近路由器测试TCP 连接失败IP 端口错误或模块固件问题核对 OneNet 地址刷新固件CONNACK 返回 0x04鉴权信息错误重新复制鉴权信息核对无空格数据上报后云端无显示JSON 格式或字段名不匹配核对物模型定义查看设备日志运行一段时间后死机串口缓冲区溢出或电源不稳加大缓冲区增加滤波电容继电器不动作GPIO 电平不匹配或驱动能力不足检查继电器触发电压加驱动电路提示调试的时候建议在 STM32 端加一个 LED 指示灯用不同的闪烁频率表示不同的状态比如慢闪表示正在连接 WiFi快闪表示 MQTT 已连接常亮表示数据上报正常。这样不用接串口线就能大致判断系统运行状态非常实用。5. 项目扩展与个人实操体会5.1 可以继续往上加的功能这个基础版本跑通之后可以扩展的方向挺多的。比如加一个 OLED 屏幕本地显示温湿度和继电器状态这样即使网络断了也能看到当前数据。OLED 用 I2C 接口STM32 的 I2C 外设直接驱动代码量不大。还可以加红外接收头学习家里的空调遥控器编码实现红外控制空调。或者加一个蜂鸣器温度超过阈值的时候本地报警。这些外设的驱动都不复杂关键是 GPIO 的分配要提前规划好别到时候引脚不够用。如果想让控制更灵活可以把继电器换成可控硅或者固态继电器支持调光或者调速。但这就涉及到交流电了安全风险比较高不建议没有电工基础的人尝试。5.2 我在这个项目里踩过的坑最大的坑是 ESP8266 的固件版本问题。我一开始用的模块是卖家刷好的 AT 固件版本比较老不支持ATCIPMODE1透传模式。后来重新刷了安信可的最新 AT 固件才搞定。所以建议拿到模块后先发ATGMR查一下固件版本确认支持你需要的指令。第二个坑是 MQTT 报文的剩余长度编码。我一开始只处理了小于 128 字节的情况结果上报数据稍微长一点就出问题。后来补上了多字节编码的逻辑才稳定。这个细节在 MQTT 协议文档里有详细说明但很容易被忽略。第三个坑是 DHT11 的时序。我一开始没关中断读取成功率很低。后来在读取函数前后加了__disable_irq()和__enable_irq()问题就解决了。但要注意关中断的时间不能太长否则会影响串口接收。DHT11 一次读取大概需要 4ms 左右这个时间关中断是可以接受的。5.3 给后来者的几点建议如果你打算做类似的项目我的建议是先把 ESP8266 单独调通用串口助手手动发 AT 指令确认能连接 WiFi、能建立 TCP、能发 MQTT 报文。这一步通了之后再接 STM32用代码自动发指令。这样能把问题分离开不会一上来就面对一堆不确定因素。另外OneNet 的文档虽然不算特别详细但设备日志功能很有用。调试的时候多看看设备日志能看到云端到底收到了什么、解析结果是什么比盲目猜要高效得多。最后代码里一定要加足够的串口打印信息。STM32 的串口资源很宝贵但调试阶段可以牺牲一个串口专门用来打印日志。等系统稳定了再把日志精简掉。我习惯用printf重定向到串口配合#define DEBUG宏来控制日志开关发布的时候关掉宏就行。这个项目我从硬件搭建到云端联调前后花了大概两周的业余时间。中间断断续续踩了不少坑但每解决一个问题对整个链路的理解就深一层。现在回头看STM32 ESP8266 OneNet 这套方案虽然不算最新但作为学习物联网全栈开发的入门项目性价比还是很高的。