简介:基于STM32与ESP8266的智能家居系统完整工程包,面向物联网开发者、嵌入式学习者以及智能家居方案设计人员,解决家庭环境实时监控与远程控制需求。STM32作为主控负责温湿度、光照、烟雾等传感器的数据采集与处理,ESP8266通过Wi-Fi将数据上传至云端或App端,并支持微信小程序查看室内状态、控制智能灯泡与插座。压缩包共127个文件,以C/H源码为主,涵盖STM32外设驱动、MQTT通信、ESP8266固件与传感器驱动;同时附带了微信小程序前端JS/WXML/WXSS、JSON配置、Keil工程文件、PNG原理图及Markdown说明文档,整体仅441KB,目录划分清晰,便于二次开发与移植。已有244人学习下载,适合想从零搭建或改造智能家居原型、需要参考前后端完整联调思路的开发者,也可作为基于STM32+Wi-Fi模块的课程设计或毕设项目参考。
1. 一套不用云平台也能跑的智能家居控制链路
把智能插座从米家App里拆出来,连到自家局域网里用——这件事在绝大多数Wi-Fi模组方案里做不到,因为云端固件和平台绑定死了。但如果主控是STM32F103C8T6,Wi-Fi选ESP8266-01S,整个控制闭环可以完全绕开第三方云平台:传感器数据经STM32的ADC和GPIO采集后,通过USART1送给ESP8266,ESP8266以MQTT协议发布到自建Broker或局域网服务器,微信小程序用WebSocket订阅同一主题就能拿到数据;反向控制则走订阅/发布对称路径。这个组合适合做毕设、样板间或小批量智能家居设备的工程师,成本在二十元上下,每个环节都能断点调试,不用抓瞎。下面从硬件分工开始讲,再落到标准库代码、AT指令序列和小程序端,最后补几个资源包里没写清楚的坑。
2. STM32F103与ESP8266-01S的串口分工与传感器挂载
2.1 为什么选F103C8T6而不是F407或直接上NodeMCU
不少初学者会问:ESP8266本身也是单片机,能读传感器能联网,为什么非要挂一颗STM32?
原因在于实时性和外设资源。ESP8266虽然便宜,但GPIO模拟时序受Wi-Fi协议栈和FreeRTOS调度影响很大,DHT22这种单总线传感器在Wi-Fi发包高峰期经常读到数据位翻转,温湿度变成乱码。STM32F103C8T6的Cortex-M3内核主频72MHz,定时器、ADC、USART都走硬件外设,采样时序稳定。另一层原因是标准库封装完整,ADC多通道扫描、DMA传输、中断优先级这些在F1上闭眼就能配,不需要像F407那样考虑DMA2的流映射冲突——对于智能家居这种低速数据量,F1算力已经过剩。
在Keil 5里新建标准库工程时,记得用Keil5安装STM32芯片包后选择STM32F103C8设备,宏定义处写STM32F10X_MD, USE_STDPERIPH_DRIVER。如果手头是C6T6,芯片包版本选Low-density,但多数智能家居项目直接用MD就够,C8T6的64KB Flash能装下MqttKit、传感器驱动和RTOS三件套。
2.2 外设资源分配与ESP8266连接原理图
这个项目的核心理念是“STM32做采集与决策,ESP8266只做网络管道”。为此,串口资源分配如下:
| 外设 | 功能 | 引脚 | 波特率 |
|---|---|---|---|
| USART1(PA9/PA10) | 与ESP8266通信 | PA9-TX / PA10-RX | 115200 |
| USART2(PA2/PA3) | 调试日志输出 | PA2-TX / PA3-RX | 460800 |
| ADC1(PA0) | 烟雾传感器(MQ-2) | PA0 | 12bit |
| ADC1(PA1) | 光照传感器(光敏电阻) | PA1 | 12bit |
| PB12 | 智能继电器(控制灯具) | 推挽输出 | GPIO |
| PB13 | 蜂鸣器报警 | 推挽输出 | GPIO |
ESP8266与STM32的连接原理图相对简单:ESP8266的RX接PA9,TX接PA10,供电必须单独接3.3V且保证峰值电流500mA以上。很多教程把ESP8266直接接在STM32开发板的3.3V引脚上,结果Wi-Fi发射时电压跌落导致复位——这是硬件上最常见的坑,后面专门讲。
串口1的初始化片段:
USART_InitTypeDef USART_InitStructure; GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); USART_InitStructure.USART_BaudRate = 115200; 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_ITConfig(USART1, USART_IT_RXNE, ENABLE);这段配置里需要注意GPIO_Mode_AF_PP和GPIO_Mode_IN_FLOATING配对。TX引脚必须配置为复用推挽,RX引脚保持浮空输入,否则电平匹配不对会在ESP8266侧出现乱码。波特率115200对ESP8266的AT固件是默认值,但如果你用自编译固件,两边的波特率必须一致,这里没有协商机制。
2.3 串口帧结构设计:设备端与Wi-Fi模块的对话协议
STM32和ESP8266之间不能直接裸传传感器数值,因为网络侧可能需要携带设备ID、数据类型、时间戳,控制侧还需要区分“查询”“设置”“恢复出厂”等命令。工程上我一般设计一个带CRC校验的简版帧:
| 字节偏移 | 内容 | 说明 |
|---|---|---|
| 0 | 0xAA | 帧头 |
| 1 | 0x55 | 帧头校验 |
| 2 | 命令字 | 0x01查询 / 0x02上报 / 0x10控制 |
| 3 | 数据长度 | 负载字节数 |
| 4..N | 负载 | JSON字符串或二进制数据 |
| N+1 | CRC8 | 从帧头累加到数据区末尾 |
STM32侧收到的每个控制帧都先查CRC再执行,CRC不合直接丢弃。这一步能过滤掉绝大部分因串口噪声产生的伪指令——尤其当排线较长,ESP8266的TX信号耦合到STM32的RX线上时,没有校验的裸协议会随机开灯。
3. STM32标准库工程里的数据采集与串口协议解析
3.1 TIM2毫秒时基与GPIO模拟时序
DHT22(温湿度传感器)用的是单总线协议,虽然片内没有标准的硬件单总线控制器,但用TImer做时基 + 输入捕获中断完全能解析。先配置TIM2为1ms滴答,给上层提供delay_us基础函数。
void TIM2_Init(void) { TIM_TimeBaseInitTypeDef TIM_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_InitStructure.TIM_Period = 72 - 1; // 1MHz计数,1ms中断 TIM_InitStructure.TIM_Prescaler = 1000 - 1; TIM_InitStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_InitStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_InitStructure); TIM_Cmd(TIM2, ENABLE); }这里TIM_Period=71、TIM_Prescaler=999的实际效果是72MHz / (72 × 1000) = 1kHz,即1ms中断一次。如果把Prescaler改成71、Period改成7199,就是50Hz的PWM输出时基,能直接驱动舵机或LED呼吸灯——同一颗TIM2可以做不同用途,但中断回调里要区分TIM_IT_Update事件。
DHT22读取的关键在起始信号的时序:主机拉低18ms唤醒,然后拉高20~40us等待从机响应。之后STM32用轮询GPIO的方式采集电平宽度:
uint8_t DHT22_ReadByte(void) { uint8_t data = 0; for (int i = 0; i < 8; i++) { while (GPIO_ReadInputDataBit(DHT22_GPIO_PORT, DHT22_GPIO_PIN) == RESET); delay_us(40); if (GPIO_ReadInputDataBit(DHT22_GPIO_PORT, DHT22_GPIO_PIN) == SET) { data |= (0x80 >> i); } while (GPIO_ReadInputDataBit(DHT22_GPIO_PORT, DHT22_GPIO_PIN) == SET); } return data; }逻辑是:当引脚拉低后开始等待上升沿,上升沿到来后延时40us再采样。DHT22数据位的高电平宽度恰好是26us(0)或70us(1),延时40us采样落在区分区间内,能稳定分辨两种电平。若读到全是0xFF,优先检查GPIO是否配置成开漏并外接4.7k上拉电阻,而不是怀疑传感器坏了。
3.2 ADC多通道规则组连续扫描
烟雾传感器(MQ-2)输出的是模拟电压,接在PA0上;光敏电阻的分压接PA1。这里用ADC1规则组连续扫描模式,这样可以在一次DMA搬运里拿到两个通道的数据。
void ADC_Scan_Init(void) { ADC_InitTypeDef ADC_InitStructure; DMA_InitTypeDef DMA_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)adc_values; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = 2; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_Init(DMA1_Channel1, &DMA_InitStructure); ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode = ENABLE; ADC_InitStructure.ADC_ContinuousConvMode = ENABLE; ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel = 2; ADC_Init(ADC1, &ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); }DMA配置里DMA_PeripheralDataSize_HalfWord和DMA_MemoryDataSize_HalfWord必须成对出现,因为ADC采样结果是12bit,用Byte宽度的DMA搬运会截断高四位。adc_values声明为uint16_t[2],索引0对应通道0(烟雾),索引1对应通道1(光照)。 转换完成后,烟雾浓度阈值判断建议用滑动平均而不是单次采样,我一般取最近10次的平均值再与阈值比较,防止MQ-2预热期瞬时波动触发误报。
3.3 串口接收状态机与CRC校验
ESP8266和STM32之间是双向通信,接收端必须做状态机。F103的串口1中断里每次只收一个字节,把数据喂给状态机,而不是等一个完整包再一次性处理——不然在BSS段里开大缓冲容易爆SRAM。
typedef enum { FRAME_STATE_HEAD1, FRAME_STATE_HEAD2, FRAME_STATE_CMD, FRAME_STATE_LEN, FRAME_STATE_DATA, FRAME_STATE_CRC } frame_state_t; void USART1_IRQHandler(void) { uint8_t byte = USART_ReceiveData(USART1); static frame_state_t state = FRAME_STATE_HEAD1; static uint8_t frame[64]; static uint8_t index = 0; switch (state) { case FRAME_STATE_HEAD1: if (byte == 0xAA) state = FRAME_STATE_HEAD2; break; case FRAME_STATE_HEAD2: if (byte == 0x55) { state = FRAME_STATE_CMD; index = 0; } else state = FRAME_STATE_HEAD1; break; case FRAME_STATE_CMD: frame[index++] = byte; state = FRAME_STATE_LEN; break; case FRAME_STATE_LEN: frame[index++] = byte; frame_len = byte; state = FRAME_STATE_DATA; break; case FRAME_STATE_DATA: frame[index++] = byte; if (index >= frame_len + 2) state = FRAME_STATE_CRC; break; case FRAME_STATE_CRC: if (CRC8_Check(frame, index) && byte == 0x00) Process_Command(frame, frame_len); state = FRAME_STATE_HEAD1; break; default: state = FRAME_STATE_HEAD1; break; } }CRC8校验我采用多项式0x31(X8 + X5 + X4 + 1),查表法CPU占用极低,在72MHz主频下单片处理一个64字节的帧只需要几十微秒,对中断处理时间完全无压力。注意帧长度字段存放在frame[1]位置,而数据区从frame[2]开始,很多移植代码栽在这:数据字节数算错以后,CRC永远过不了,表现就是STM32偶尔能收到消息但从不执行控制指令。
4. ESP8266的MQTT固件改造与微信小程序上报链路
4.1 方案A:ESP8266刷AT固件,STM32内嵌MqttKit
资源包里出现MqttKit.c并不是偶然,它意味着这套系统把MQTT协议栈放在了STM32侧,ESP8266只承担透明的TCP透传工作。这种方案的好处是Wi-Fi模块可以被替换成任何串口网卡,不绑定乐鑫生态,缺点是STM32的Flash要多占约12KB,F103C8T6在剩余空间上仍够用。
关键配置是ESP8266进入透传模式:
AT+RST AT+CWMODE=1 AT+CWJAP="MySmartHome","password123" AT+CIPSTART="TCP","192.168.1.100",1883 AT+CIPMODE=1 AT+CIPSENDAT+CIPMODE=1开启透传后,STM32发往串口的所有字节都会原封不动地从TCP连接发出去;反过来,TCP收到的数据直接出现在串口RX上。这时MqttKit只需要维护CONNECT、PUBLISH、SUBSCRIBE三个报文结构,不需要关心Wi-Fi链路细节。透传模式下重启ESP8266会丢连接,所以STM32要在启动时等待ESP8266返回WIFI GOT IP和CONNECT OK,超时5秒后自动重连,这是资源包里没有明说的时序依赖。
4.2 方案B:ESP8266跑AT-MQTT固件,直接响应主题指令
如果你不想让STM32的代码量膨胀,可以把ESP8266换成乐鑫官方AT固件v2.2.0以上版本,它原生提供AT+MQTTUSERCFG和AT+MQTTCONN指令。STM32侧只需要解析固件返回的+MQTTSUB事件即可,代码量能砍掉一半左右。
AT+MQTTUSERCFG=0,1,"smart_home_1","user","secret",0,0,"" AT+MQTTCONN=0,"192.168.1.100",1883,1 AT+MQTTSUB=0,"device/1/cmd",1第一个参数0代表链路ID,固定填0即可;第二个参数1表示使用SSL?实际上这里填的是(0关闭/1开启)——但一般家庭局域网不用SSL,填0可以减少握手开销。使用ESP8266入门教程里最常见的问题是AT+MQTTUSERCFG中的client_id不要重名,否则Broker会把旧连接踢掉,表现为ESP8266反复掉线重连,日志里出现连接被拒绝。
4.3 微信小程序端的数据订阅与指令下发
小程序侧不能直连原生TCP,要么通过自建网关做HTTP转发,要么用WebSocket桥接MQTT Broker。轻量做法是在现有Broker旁边加一个websocket-mqtt代理,小程序里用wx.connectSocket连接后按MQTT协议发送PUBLISH。这里给出一个简化版的小程序控制代码:
const mqtt = require('../../utils/mqtt.min.js'); const client = mqtt.connect('ws://192.168.1.100:8083/mqtt', { clientId: 'wechat_' + Date.now(), username: 'user', password: 'secret' }); client.on('connect', function () { client.subscribe('device/1/status', { qos: 1 }); }); client.on('message', function (topic, message) { const data = JSON.parse(message.toString()); if (topic === 'device/1/status') { this.setData({ temperature: data.temp, humidity: data.hum, smoke: data.smoke }); } }); function toggleRelay() { client.publish('device/1/cmd', JSON.stringify({ relay: 1 }), { qos: 1 }); }需要说明的是mqtt.min.js是小程序常用的MQTT客户端封装,底层基于WebSocket,与原生TCP的MQTT在报文格式上完全一致。{ qos: 1 }表示至少送达一次,如果网络抖动导致重复消息,STM32侧要根据命令序号去重——我一般在控制帧里加一个递增的sequence字段,STM32只执行比上次序号大的指令。这样即使小程序重发,继电器也不会来回抖。
4.4 Broker主题设计
| 主题 | QoS | 方向 | 负载示例 |
|---|---|---|---|
device/1/status | 1 | 设备→APP | {"temp":26.5,"hum":48,"smoke":0} |
device/1/cmd | 1 | APP→设备 | {"seq":12,"relay":1} |
device/1/ack | 1 | 设备→APP | {"seq":12,"result":"ok"} |
gateway/1/online | 0 | 设备→APP | {"ip":"192.168.1.106"} |
主题层级用设备ID区分多设备,适合扩展到套房、办公室场景。QoS0的心跳主题即使丢失也无所谓,但指令和状态反馈必须用QoS1,否则偶尔丢一条控制指令,用户体验是灾难性的。
5. 供电复位、串口回环与OTA遗留的三个必查项
第一,ESP8266的3.3V供电不能和STM32共用LDO。ESP8266发射瞬间会拉出300mA以上的电流,而很多F103核心板的AMS1117-3.3输出能力在800mA左右,看起来够用,但DC-DC的带宽不足导致纹波瞬间抬高到200mV以上,STM32复位阈值刚好卡在边缘。最稳的做法是给ESP8266单独的ME6211稳压芯片供电,并在VCC和GND之间放470uF电解电容和100nF陶瓷电容并联。我之前调试时遇到“ESP8266单独工作正常、一接STM32就随机重启”,查到最后就是共地之后LDO自激。
第二,串口回环是一个隐蔽问题。如果ESP8266的TX和RX信号线在PCB上并行走线超过5cm,Wi-Fi射频会通过串口线耦合进RX数据线,空闲状态下STM32的串口中断会不断收到0xFF。解决方式有两个:一是把USART1波特率降到57600,缩短数据线上的翻转沿时间;二是在代码里打开串口接收超时机制,超过200ms没收到一个帧就清空FIFO并复位状态机。相比于加磁珠,这两个软件措施更直接。
第三,OTA升级区域要提前规划。F103C8T6的64KB Flash被拆成两个16KB区用于Bootloader和App区,如果出厂时不规划分区,后面做在线升级只能把整个Flash擦掉重烧,要把STM32的Boot0跳到高电平后才能进入串口下载。资源包里如果带了stm32f10x_flash.c,说明固件代码里已经预留了Flash写接口,可以在App区直接接收分块镜像;分块写入时每块512字节,写完一页读取校验一次,失败就回滚到上一页。
最后一个建议:把这套系统往企业应用推进时,把“家庭”这个主题设计成可配置参数。酒店和办公室场景里,每个房间本质上就是一台独立的STM32网关,它们共享同一套MQTT Broker——你只需要让管理端根据device/前缀分发权限,就能在小程序里同时管理几十个房间的灯光和空调。如果要加人工智能层面的自动调节,最可靠的做法是在STM32上放一个简单的线性回归模型,输入是过去24小时的温湿度序列,输出是空调目标温度;训练在云端做,推理放MCU端,这样断网时设备也能自我决策。这类系统做出来的价值不在于功能多炫,而在于每个环节都能复现、能调试、能维护。
本文还有配套的精品资源,点击获取