基于STM32F103C8T6与ESP8266的WiFi智能插座开发实战
2026/9/16 8:30:00 网站建设 项目流程

简介:一套基于STM32F103C8T6的WiFi智能插座完整设计方案,包含原理图、PCB、MCU固件源码与Android APP源码,适合物联网嵌入式开发者及电子设计爱好者参考学习。方案实现远程/本地APP遥控开关、定时循环开关、预约倒计时、万能学习遥控、插座温度检测过载断电、防盗报警联动等实用功能,覆盖智能家居常用场景。资源包共925个文件,压缩后47.06MB,其中png为原理图与界面预览,pcbdoc/schdoc为PCB与原理图工程,c/h为STM32底层驱动及应用逻辑,java/xml为Android客户端源码,hex为可直接烧录的固件。已有1543人学习下载,资料结构清晰,从硬件设计到软件实现均有呈现,适合需要完整项目参考或进行二次开发的学习者。

1. 基于 STM32F103C8T6 的 WiFi 智能插座项目,到底在做什么

画过 STM32F103C8T6 最小系统板的人很多,看得见的是 LED 在闪,看不见的是这块板子能不能扛住 WiFi 模块启动时几百毫安级电流的冲击、继电器在 220V 侧断开时会不会拉弧、手机在客厅通过 APP 操作卧室插座时消息链路是否真的通。这个项目标题把四件事压在一起:原理图决定电气逻辑,PCB 决定长期通电的可靠性,MCU 源码决定控制与协议,APP 源码决定用户侧体验。本文就从这四条线展开,从原理图一路讲到 APP 联调,适合能调通串口、画过两层板、但第一次把联网和设备控制串起来的工程师。

2. 原理图与 PCB 设计:把 STM32F103C8T6 最小系统做成可以长期通电的插座板

为什么用 STM32 做主控而不是让 ESP8266 裸跑?这个选择背后是可靠性。ESP8266 的优势是自带 WiFi 协议栈,劣势是 GPIO 驱动能力和代码可维护性普通。智能插座控制继电器、保存状态、处理本地逻辑,这些活更适合一个外设丰富、芯片资料密集的 MCU。STM32F103C8T6 作为 48 引脚的中等容量芯片,价格透明,国产替代型号也多,后期换芯片不用改板子。WiFi 部分用 ESP-01S 这类模组做通信协处理器,MCU 只通过串口操作它,协议栈跑在模组里,两边职责清晰,调试时也容易定位问题。

2.1 最小系统与引脚功能分配:从最小系统板到自主布线

很多人的第一块板就是 STM32F103C8T6 最小系统板,但它把晶振、供电、调试口都提前画好了。自己设计原理图时要补齐这几样:8MHz 外部晶振加两颗 20pF 负载电容,NRST 引脚上拉 10kΩ 电阻,BOOT0 下拉到地,VDDA 与 VSSA 之间加 10uF 和 100nF 去耦,SWD 调试口引出 PA13、PA14。如果不需要外部电池备份,VBAT 直接接 3.3V 即可,这是最省事也最不容易出错的接法。

引脚分配要遵循一个原则:先看 IO 上电默认电平,再决定哪个引脚控制继电器,而不是把需求反过来强塞给 GPIO。表 1 是这套设计里常用的一组分配。

引脚复用功能方向说明
PA1通用输出OUT继电器驱动,复位期间默认浮空,外部加下拉
PA2USART2_TXOUT连接 ESP-01S 的 RXD
PA3USART2_RXIN连接 ESP-01S 的 TXD,3.3V 电平直连
PA9USART1_TXOUT调试日志输出,接 USB 转 TTL
PA10USART1_RXIN调试命令输入
PB12通用输出OUT状态 LED

PA0 不建议给继电器,它是 WKUP 引脚,复位期间如果电路噪声耦合到驱动管,容易引起上电瞬间误动作。如果必须用,就要在驱动管基极加一个 10kΩ 下拉电阻分压。继电器驱动 IO 的初始化代码建议放在所有外设初始化之前,先写低电平再开外设时钟。

// 主流程中最早执行的 GPIO 初始化 __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitTypeDef gi = {0}; gi.Pin = GPIO_PIN_1; gi.Mode = GPIO_MODE_OUTPUT_PP; gi.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &gi); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET);

代码说明:先把 PA1 配置成推挽输出并输出低电平,之后才允许其他外设初始化继续执行。GPIO_SPEED_FREQ_LOW不是让继电器变慢,而是降低上升沿的 dV/dt,减少对旁边天线和晶振的干扰;继电器的开关速度由三极管和继电器机械结构决定,MCU 侧不需要高速翻转。GPIO_PIN_RESET就是低电平,这是保证上电不误吸合的关键。

2.2 继电器驱动与电源设计:为什么你的插座板容易复位

继电器驱动电路用经典的低边 NPN 方案。STM32F103C8T6 的 GPIO 输出高电平约 3.3V,SRD-05VDC-SL-C 这种 5V 继电器线圈不能用 IO 直接推,要经过一颗 1kΩ 基极电阻接到 S8050 的基极。S8050 集电极接继电器线圈负极,线圈正极接 5V,发射极接地。线圈两端并联一颗 1N4148 二极管,阴极接 5V,反向电动势靠这个二极管泄放。这三行逻辑看着简单,却是整个原理图里最容易被抄错的点:二极管方向反了,三极管关断瞬间直接击穿。

电源侧要分两条路走。220V 交流经过 AC-DC 模块变成 5V,5V 直接给继电器线圈供电;3.3V 由 AMS1117 从 5V 线性稳压出来。这里有一个常见坑:ESP-01S 在启动瞬间会抽取几百毫安级电流,AMS1117 在这种瞬态下压降会接近极限,3.3V 掉到 2.6V 左右,模块表现为持续复位或者 TCP 反复断开。我一般会在 3.3V 主干上加 470uF 电解电容,同时让 ESP8266 模组供电走独立的 RC 滤波。

提示:ESP-01S 与 STM32 都是 3.3V 电平,可以直接相连。如果用的是 5V 供电的 ESP8266 开发板,TX/RX 就必须加电阻分压或电平转换,否则长期使用可能损坏 MCU 的 IO。

更彻底一些的做法,是用一颗 PMOS 或负载开关芯片做 ESP8266 电源开关,让 ESP 在 MCU 自检完成后再上电。下面的代码片段表达的是这个时序。

// 上电时序:继电器先闭锁,再给 ESP8266 供电 HAL_GPIO_WritePin(RELAY_GPIO_Port, RELAY_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(ESP_PWR_GPIO_Port, ESP_PWR_Pin, GPIO_PIN_SET); HAL_Delay(500); // 等待 ESP8266 AT 固件启动完成 // 500ms 后串口应能正常回 AT esp_send_at_wait("AT\r\n", 1000);

逻辑说明:ESP_PWR_GPIO_Pin控制负载开关,高电平导通。500ms 是经验值,AT 固件冷启动后需要一段时间准备,隔 100ms 就发 AT 大概率收到空响应。这里把继电器先置低,即使用户按物理按键关机再重启,板子也不会在复位瞬间把继电器误吸合,对 220V 负载来说这是底线要求。

2.3 PCB 布线规则与 DRC 检查:AD20 下单前必过的细节

两层板足够应付这个设计。布局顺序按电流路径走:AC 输入端子放在板边,AC-DC 模块靠近端子,继电器次之,MCU 和 ESP8266 模组放在远离强电的一侧,强弱电之间画一条清晰的分界线。这条线上开 1mm 宽阻焊桥或物理开槽,继电器引脚之间也开槽,目的是拉长爬电路径,避免 220V 在潮湿环境下沿板面跳火。开槽在大多数 PCB 软件里用线切割层画,板厂能识别。

走线宽度方面,IPC-2221 的经验值可以参考:1oz 铜厚、10℃ 温升情况下,0.3mm 外层走线能承受的电流大约在 0.5A 到 0.8A 之间。控制信号线用 0.3mm 完全够,5V 电源主干建议 0.5mm 以上,220V 侧走线至少 1.5mm,两线间距保持 2mm 以上。ESP8266 天线区域正下方不铺铜,天线向板边伸出且周围净空 5mm 以上。晶振靠近 STM32F103C8T6 的 OSC 引脚放置,走线不要与继电器控制线平行长距离并排。

AD20 里 DRC 不是默认就好用,下单前建议过一遍这五项:线宽规则、间距规则、电源网络铺铜方式、过孔外径与孔径、是否有未连接引脚。将 Board Rules 里的最小间距设为 0.2mm,控制区最小线宽 8mil,电源与强电网络单独加宽;敷铜方式选 Solid,网络连接到地。导出文件时不要发 AD 工程给板厂,而是用 File -> Fabrication Outputs -> Gerber Files 导出 Gerber,避免版本兼容性导致钻孔或焊盘错位。下表是打样前评审单上逐项打勾的内容。

检查项本设计设置常见失败现象
最小线宽与间距控制区 8mil,电源 0.5mm板厂报断裂,DRC 报间距错误
强弱电开槽分界线及继电器引脚间 1mm高温高湿下打火、阻焊烧焦
天线净空净空区无铜且边缘伸出WiFi 信号差,连接速率不稳定
晶振走线靠近 MCU,包地启动失败,时钟偏差导致串口乱码
敷铜网络地网络实心敷铜浮铜充电导致 ESD 损伤

3. MCU 固件:STM32F103C8T6 在 Keil 下跑通 ESP8266 AT 指令状态机

原理图解决的是“能不能通电”,固件解决的是“能不能听话”。开发环境用 Keil MDK,也可以用 IAR,CubeMX 生成的工程两种工具链都能导出。可能是受 STM32 生态影响,“stm32f103c8t6 use_hal_driver”已经是很多人的搜索方式,下面的代码统一基于 HAL 库,不用老的标准外设库。

3.1 用 CubeMX 生成的最小固件框架

CubeMX 配置里,RCC 选择 Crystal/Ceramic Resonator,SYS 选择 Serial Wire 保留 SWD 调试,时钟树将 8MHz 外部晶振倍频到 72MHz。串口开两个:USART1 用于调试日志,115200-8-N-1;USART2 接 ESP8266,波特率同样用 115200。GPIO 页把 PA1 设成输出,初始电平设为 Low。生成代码时 Toolchain 选 Keil MDK-ARM V5,如果电脑装的是 AC6 编译器也可以,注意确认优化等级不会把延时函数优化掉。

中断回调里不要直接做字符串解析。AT 指令的返回是异步的:发一条AT+CWMODE可能几毫秒就回 OK,AT+MQTTCONN则可能等几秒才回 CONNACK,中间还会混入路由器掉线事件。所以串口这一层只负责收字节,主循环负责解析。工程目录建议拆成三个文件:esp_at.c放 AT 命令的拼接与发送,app_protocol.c放 JSON 解析与设备状态机,flash_store.c放状态保存。主循环是一个 while(1) 轮询事件标志,优先级是接收事件、超时事件、心跳事件。

这里没有上 FreeRTOS,是因为这套逻辑的状态很少。如果要在 STM32F103C8T6 上继续加定时任务和 OTA,再移植 FreeRTOS 也不晚,但裸机状态机的调试信息更直观,适合先把链路跑通。

3.2 串口异步接收与 AT 应答解析

ESP8266 的 AT 固件返回值有两种格式:单行的 OK/ERROR 和多行的 + 事件。最常见可靠的接收方式是 DMA 加空闲中断,一帧数据到达后自动进回调,不用每收一个字节进一次中断。

#define ESP_RX_MAX 512 static uint8_t esp_rx[ESP_RX_MAX]; static volatile uint16_t esp_len; void esp_uart_start(void) { // 打开空闲中断接收,数据装入 esp_rx HAL_UARTEx_ReceiveToIdle_DMA(&huart2, esp_rx, ESP_RX_MAX); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART2) { esp_len = Size; // 记录本次一帧的长度 // 立即重新挂载接收缓冲,避免漏掉下一帧 HAL_UARTEx_ReceiveToIdle_DMA(&huart2, esp_rx, ESP_RX_MAX); } }

逻辑说明:HAL_UARTEx_ReceiveToIdle_DMA在收到空闲条件时把已接收长度通过RxEventCallback抛出来,这里只做长度记录和重新挂载。512 字节对单条 MQTT 报文足够,实际 AT 响应通常不超过 200 字节。如果缓冲区被完全填满则没有空闲中断,此时回调也会被触发,处理逻辑一样,不会丢帧。

主循环里按关键词分流,解析前先把缓冲区末尾补上字符串结束符。

void esp_poll(void) { if (!esp_len) return; if (esp_len >= ESP_RX_MAX) esp_len = ESP_RX_MAX - 1; esp_rx[esp_len] = '\0'; // 保证 strstr 不会越界 if (strstr((char *)esp_rx, "OK")) { esp_wait_ok = 0; } else if (strstr((char *)esp_rx, "ERROR")) { esp_wait_ok = 1; } else if (strstr((char *)esp_rx, "WIFI DISCONNECT")) { app_on_wifi_lost(); } else if (strstr((char *)esp_rx, "+MQTTDISCONN")) { app_on_mqtt_lost(); } esp_len = 0; }

代码说明:strstr匹配的是裸关键字,注意 OK 与 ERROR 要分开判断,因为 AT 固件在收到错误指令时可能只回 ERROR。+MQTTDISCONN是较新 AT 固件里的事件,旧固件没有,需要先确认自己刷的固件支持哪些事件。

发送指令的封装要带超时和重试。这里给出一个常用的“发指令等结果”函数:

uint8_t esp_send_at_wait(const char *cmd, uint32_t timeout_ms) { esp_wait_ok = 0xFF; // 0xFF 表示还没有收到任何结果 HAL_UART_Transmit(&huart2, (uint8_t *)cmd, strlen(cmd), 100); uint32_t t0 = HAL_GetTick(); while ((HAL_GetTick() - t0) < timeout_ms) { esp_poll(); if (esp_wait_ok == 0) return 1; // 收到 OK if (esp_wait_ok == 1) return 0; // 收到 ERROR } return 0; // 超时未应答 }

参数说明:cmd 必须带\r\n,AT 固件不识别裸字符串。timeout_ms 要按指令类型区分:查询类给 1000 到 2000ms,连接类给 5000ms,因为路由器弱网下AT+CWJAP可能在 3 到 4 秒才返回。把esp_poll()放进等待循环里,是为了让 DMA 中断只标记长度、解析和状态更新都在这里完成,这本身就是一个小型状态机。

3.3 继电器控制、Flash 掉电保存与上电安全时序

继电器控制本身很简单,麻烦的是状态怎么保存、上电怎么恢复。

void relay_set(uint8_t on) { HAL_GPIO_WritePin(RELAY_GPIO_Port, RELAY_Pin, on ? GPIO_PIN_SET : GPIO_PIN_RESET); }

STM32F103C8T6 没有 EEPROM,要用内部 Flash 模拟。Flash 一页 1KB,如果每次状态变化都擦一页,寿命很快耗尽。实际做法是把状态按 4 字节对齐写进页内的顺序位置,先写满一页再擦除。简化版实现如下。

#define ADDR_FLASH_PAGE_63 0x0800FC00 // 最后一页的起始地址 void state_save(uint32_t marker) { HAL_FLASH_Unlock(); FLASH_ErasePage(ADDR_FLASH_PAGE_63); // 整页擦除 HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, ADDR_FLASH_PAGE_63, marker); HAL_FLASH_Lock(); }

逻辑说明:Flash 最小擦除单位是页,FLASH_ErasePage会先把整页清成 0xFF。HAL_FLASH_Program以字为单位写入,页地址必须 4 字节对齐。写前调用HAL_FLASH_Unlock,写完必须HAL_FLASH_Lock,这是 HAL 库的固定流程。产品级做法会把这个操作封装成带磨损均衡的模块,但项目调试期先跑通数据回读即可。

上电安全时序是这套代码的收尾重点。复位后先写死继电器断开,等待 500ms 让 DC-DC 输出稳定,再读 Flash。如果状态位有效,再按原状态恢复。

relay_set(0); HAL_Delay(500); uint32_t saved = state_read(); if (saved == STATE_ON_MARKER) { relay_set(1); }

这个设计符合“断电记忆”的用户预期:拔电时继电器是开的,重新上电后自动恢复为开。如果产品要求“上电必须关断”,这里就不要恢复,改成等 APP 下发指令后再动作。两种策略由产品定义,代码结构是同一套,只差一行判断。

4. APP 控制链路:MQTT 远程与局域网 TCP 双通道

APP 与插座之间“说话”的协议,我一般直接选 MQTT。为什么不是 HTTP?HTTP 是请求-响应,服务器不知道设备在线状态,设备要上报状态必须靠轮询,轮询太短费流量费电,太长又不实时。MQTT 是发布订阅模型,设备端保持长连接,APP 发一条命令,服务端立刻推给设备,设备的回执也能推回来,智能插座选它很自然。

4.1 通信协议先行:用 JSON 设计一版可扩展的消息格式

写代码前先定义消息格式。下面是一套很简洁的 JSON 协议。

{"type":"set","on":true,"seq":12} {"type":"state","on":true,"seq":12} {"type":"ack","seq":12,"on":true,"err":0}

type 字段区分消息方向:set 是 APP 下发的控制指令,state 是设备主动上报的状态,ack 是设备对 set 的应答。on 字段控制继电器开合。seq 是自增序号,用于去重,因为 MQTT QoS1 会重复投递消息,设备侧记录最近收到的一个 seq,相同 seq 直接丢弃。

消息类型方向用途关键字段
setAPP 到设备下发控制on
state设备到 APP状态上报on, err
ack设备到 APP命令应答seq, err

这个协议可以继续加字段而不破坏兼容性,比如 power、temp 这些遥测字段,旧版 APP 忽略未知字段即可。消息主题建议把命令和状态分开:home/{device_id}/cmd用于控制,home/{device_id}/state用于状态通知。如果不分开,设备发一条 state 会被自己订阅到,形成消息回环。

4.2 Android 端 MQTT 客户端最小实现

APP 源码部分,Android 端用 Eclipse Paho Android Client 库就够,不需要引入额外框架。初始化连接的核心代码如下。

String broker = "tcp://192.168.1.100:1883"; MqttClient client = new MqttClient(broker, MqttClient.generateClientId(), new MemoryPersistence()); MqttConnectOptions options = new MqttConnectOptions(); options.setCleanSession(true); options.setConnectionTimeout(10); // 连接超时 10 秒 options.setKeepAliveInterval(30); // 心跳间隔 30 秒 options.setAutomaticReconnect(true); client.setCallback(new MqttCallback() { @Override public void connectionLost(Throwable cause) { } @Override public void messageArrived(String topic, MqttMessage message) throws Exception { // 在 UI 线程解析 message 并更新开关状态 } @Override public void deliveryComplete(IMqttDeliveryToken token) { } }); client.connect(options); client.subscribe("home/switch/1/state", 1);

参数说明:broker 地址在局域网调试时填开发板的 IP,远程控制时填云主机或公网 broker 的 IP 或域名,不能写 localhost,手机端连不上。clientId 必须全局唯一,Paho 自带的generateClientId()测试够用,但两台手机如果用同一个 clientId,后连接的会把前者踢下线。keepAliveInterval 设 30 秒,太短会产生大量心跳空包,太长 NAT 超时后服务端可能收不到心跳,导致消息堆积。

下发命令的代码也很直接。

JSONObject obj = new JSONObject(); obj.put("type", "set"); obj.put("on", isChecked); obj.put("seq", seqCounter++); MqttMessage msg = new MqttMessage( obj.toString().getBytes(StandardCharsets.UTF_8)); msg.setQos(1); client.publish("home/switch/1/cmd", msg);

代码说明:QoS 1 保证消息至少到达一次,配合协议里的 seq 去重,不会因为重复投递导致继电器反复动作。消息体按 UTF-8 编码,避免特殊字符在传输途中出现乱码。MqttClient的构造和connect都会抛MqttException,完整工程里需要 try-catch 或统一抛出,这里不展开。

4.3 配网与断线重连:智能插座能不能在日常环境里用起来

这一层最容易忽略。家用插座往往放在不方便插 USB 转 TTL 的位置,配 WiFi 凭据不能靠每次烧固件。常见做法是 SoftAP 配网:设备上电后进入 AP 模式,手机先连上这个暂时没有外网的 WiFi,然后用 APP 访问 192.168.4.1,提交路由器名和密码。手机状态栏显示“无 internet 连接”属于正常现象,因为 AP 本身只用来配网,不是用来上外网的,配网完成后设备切回 STA 模式。AT 指令下这一整套同样由串口指令完成。

断线重连的策略要定义在 MCU 侧,不能全指望 AT 固件。ESP8266 掉线后可能长时间保持在未连接状态,我一般在固件里监听WIFI DISCONNECTED事件,如果 30 秒内没有收到任何推送消息,主动执行AT+RST。重连不能疯狂发AT+CWJAP,要加指数退避,比如第一次 1 秒后重试,第二次 3 秒,第三次 10 秒,避免路由器被多个设备同时扫描打满。

事件处理策略参数建议
WIFI DISCONNECTED启动重连计数器指数退避 1s / 3s / 10s
MQTT DISCONNECTED等待重连,超过 3 次复位模块最多重试 3 次后 AT+RST
收到陌生 topic 消息直接丢弃按 topic 前缀过滤
seq 重复直接丢弃记录最近 50 条 seq

这套策略看着规则多,代码里就是一个事件计数器加一个定时器。APP 端也要处理“设备离线”的状态,UI 上显示为灰色而不是停留在上一次开关状态,避免用户以为已经发出去了,其实设备根本没收到。

5. 联调三板斧:串口日志、adb 日志和继电器声

PCB、固件、APP 都齐了之后,联调阶段拼的不是原理,而是定位手段。下面三个方法能解决大部分“为什么控制不了”的问题。

5.1 两路串口并联观察:一路发 AT,一路看日志

调试串口接 PA9/PA10,波特率 115200。MCU 固件里把 USART2 收到的所有原始 AT 响应转发到 USART1。

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART2) { HAL_UART_Transmit(&huart1, esp_rx, Size, 100); esp_len = Size; HAL_UARTEx_ReceiveToIdle_DMA(&huart2, esp_rx, ESP_RX_MAX); } }

这条代码的价值在于,调试串口不但能看到 MCU 自己打印的日志,还能看到 ESP8266 的原始回显。用一个串口助手就能判断是“MCU 没发出发 AT”还是“固件不回 OK”,问题定位速度快很多。我在第一次联调时遇到的现象是 APP 显示已连接,但继电器不动,串口里看到+MQTTDISCONN,一瞬间就锁定是 MQTT 断线重连逻辑没写好,而不是继电器驱动坏了。

5.2 adb 过滤日志代替抓包工具

APP 联调时不必一开始就上抓包工具。自研 APP 完全可以直接用 adb 看 Logcat,很多“app 抓包失败”的情况是因为混淆包和 TLS 加密,而不是网络问题。先确认手机与开发板在同一个网段,再执行。

adb logcat -v time | grep -iE "mqtt|socket|switch"

参数说明:-v time在每行前加时间戳,grep 同时过滤 mqtt、socket、switch 三个关键词。Paho 库会把 connect、subscribe、publish 的关键节点打到 Log 级别,能看到 “Connecting to...” 却看不到 “Connected”,说明 TCP 握手失败,问题在网络和端口,而不是协议本身。这一步配合 5.1 的串口日志,基本能把“APP 没发出”和“设备没收到”区分开。

5.3 一个本地测试模式:不看 APP 也能验证继电器

最后分享一个非常实用的调试手法:在固件里加一个本地测试模式,上电时长按 PA0 按键 3 秒,跳过 WiFi 连接,直接读取 Flash 状态并翻转继电器。这个模式适合生产测试或返修场景,不需要 WiFi 环境就能验证继电器驱动、Flash 读写和电源时序是否正常。平时测试听到继电器“咔嗒”一声,基本就可以排除硬件侧问题。如果继电器声音正常而 APP 没有状态变化,优先检查 MQTT 主题是否匹配,不要先去怀疑继电器驱动电路。

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

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

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

立即咨询