资讯动态

基于STM32的智能家居控制系统:控制闭环与状态机设计

发布时间:2026/9/13 8:47:06 来源:尧图企业网站定制
简介基于STM32的智能家居控制系统完整项目源码面向计算机科学、电子信息工程、数学等专业的大学生可作为课程设计、期末大作业或毕业设计的参考资料。项目以STM32微控制器为核心涵盖硬件搭建、软件编程、系统整合与调试全流程涉及温度、湿度、红外传感器及继电器的接入与控制实现了灯光控制、温度调节、安全监控等典型智能家居功能有助于将嵌入式理论知识与实际应用相结合。资源共249个文件以c源代码、h头文件、o目标文件及crf编译中间文件为主并包含uvprojx工程文件、axf可执行文件、hex烧录文件和htm文档压缩包约8.32MB目录结构清晰便于按模块查阅代码与工程配置。已有77人学习下载除源码外项目还涉及RTOS实时操作系统的应用以及系统调试中常见问题的排查思路对提升嵌入式开发与智能系统设计能力具有实际参考价值。1. 基于STM32的智能家居控制系统先定义控制闭环再谈传感器和代码做基于STM32的智能家居控制系统毕业设计最常见的翻车点不是代码写不出来而是功能清单列得太满手机APP、语音识别、云端联动全堆上去最后串口数据都还对不齐。单片机项目的核心在于控制闭环是否成立——传感器采集、逻辑判断、执行器响应这三步必须能在确定的周期内跑通。这套系统围绕STM32F103C8T6设计用DHT11、MQ-2、HC-SR501做环境感知继电器和蜂鸣器做执行ESP8266做无线通信再通过自定义串口协议对接手机或PC上位机。这套配置是目前毕设和课设里最主流的组合不管是做基础版还是加屏加联网都在这条主线上扩展。适合需要快速出活、又不想牺牲答辩深度的学生也适合用STM32平台做产品原型的工程师参考。2. 系统架构与选型为什么是STM32 ESP8266而不是直接上Linux板2.1 主控选型F103C8T6 在毕设场景中的边界这类系统不需要跑图像识别或复杂AI算法核心任务是多路IO的定时采样、逻辑控制和可靠通信。STM32F103C8T6在如今的生态成熟度、参考代码量上是明显的优势项HAL库和标准库的资料都足够全遇到问题基本能在已有例程里找到思路。相比之下树莓派这类Linux板虽然跑应用方便但实时响应依赖进程调度继电器切换和报警输出很难做到微秒级确定性。MCU方案的功耗、体积、成本都低一个数量级这在答辩演示时反而是加分项。但这块芯片也有明显边界RAM只有20KBFlash 64KB如果要塞进蓝牙协议栈、GUI和文件系统资源会非常紧。所以通信方案要拆出去。ESP8266模块本身跑AT固件把TCP/IP协议栈和Wi-Fi驱动封装在模块内部STM32只需通过串口发AT命令就把最耗资源的联网部分隔离在MCU之外。“STM32ESP8266”组合在毕设里经久不衰本质原因是两边各干各的谁都不是对方的瓶颈。以后想升级到ESP32串口协议也可以原样保留只改驱动层。2.2 传感器与执行器的电平匹配和 GPIO 分配画ESP8266与STM32连接原理图时最容易踩的坑是电平不匹配。STM32F103的GPIO是5V容忍的但很多模块是3.3V逻辑。DHT11是单总线数据线外部上拉到3.3V输出高电平就是3.3V可以直接接HC-SR501人体红外模块的输出电平有跳线开关如果跳到5V长期直连GPIO有烧引脚的风险建议把跳线切到3.3V或者用10kΩ电阻串联分压。继电器模块则相反它内部已经带光耦隔离但输入控制端需要驱动电流GPIO推挽输出可以直接驱动麻烦的是模块的VCC要接5V而STM32是3.3V供电所以共地必须做对。下面是典型引脚分配表适用于大部分入门开发板功能引脚模式备注DHT11数据PA1开漏输出/输入外部4.7kΩ上拉到3.3VMQ-2烟雾PA0模拟输入接模块AO口ADC通道0HC-SR501人体红外PA2输入模块跳线选择3.3V输出继电器1灯PB0推挽输出低电平还是高电平触发看模块继电器2风扇PB1推挽输出同上蜂鸣器PB2推挽输出有源蜂鸣器ESP8266 TXPA9复用推挽USART1_TXESP8266 RXPA10复用推挽USART1_RXOLED SCLPB6复用开漏I2C1时钟OLED SDAPB7复用开漏I2C1数据提示GPIO分配时要避开PA13/PA14/PA15它们默认是SWD调试口被占用会导致无法用ST-Link烧录。DHT11的上拉电阻是必须的。很多同学图省事用内部上拉也能跑但温湿度数据会偶发跳变原因是内部上拉阻值在几十kΩ单总线快速翻转时无法建立稳定的高低电平。加一颗4.7kΩ外部电阻后时序会稳很多。MQ-2模拟输出在刚上电时会有漂移过程前10秒读数偏高程序里可以加预热计数器上电后先不报警。2.3 通信协议的设计串口帧格式与状态机系统里至少有两条链路STM32与ESP8266之间的AT命令交互以及STM32与手机或PC上位机之间的数据交互。为了让串口数据可调试、可扩展建议自定义一套简单帧协议。这里是一个在课设题里常用的方案固定帧头功能码长度数据CRC。#pragma pack(1) typedef struct { uint8_t head; // 帧头固定0xA5 uint8_t cmd; // 功能码0x01查询0x02控制0x03上报 uint8_t len; // 数据长度不包含head/cmd/len/crc uint8_t data[8]; // 数据区最大8字节 uint8_t crc; // CRC8校验从cmd到data的所有字节 } app_frame_t; #pragma pack()每帧最多12字节足够塞下温湿度、烟雾浓度、人体状态和继电器状态。len让接收方知道要等待多少字节才算完整crc用于对付干扰和丢包。选择CRC8而不是简单累加和是因为串口长线传输时干扰不是均匀分布的累加和产生“恰好抵消”的概率远高过CRC8。接收端在串口中断里只做一件事把字节写入环形缓冲区解析和帧同步放到主循环或RTOS任务中。状态机只有四个状态等帧头、等功能码、等长度、收数据。收完数据校验CRC通过就回调处理函数不通过就重新同步。实际解析可以按字节推进uint8_t parse_byte(uint8_t b) { static uint8_t state 0; static app_frame_t frame; static uint8_t idx 0; switch (state) { case 0: if (b 0xA5) { state 1; } break; case 1: frame.cmd b; state 2; break; case 2: frame.len b; if (frame.len 8) { state 0; } else { idx 0; state 3; } break; case 3: frame.data[idx] b; if (idx frame.len) { state 4; } break; case 4: frame.crc b; if (crc8_check(frame)) { handle_frame(frame); } state 0; break; } return state; }这里crc8_check可以用查表法实现核心思路是从cmd开始直到最后一个data字节逐字节做CRC8移位异或。注意#pragma pack(1)不能省因为默认4字节对齐会让结构体插入padding字节串口按字节流传输时发送端和接收端结构体长度一旦不一致所有字段都会错位。3. 用STM32 HAL库驱动传感器与继电器最小可运行代码3.1 为什么用 HAL 而不是标准库CubeMX 工程生成和时序参数课程设计阶段建议用STM32CubeMX生成初始化代码。它会把时钟树、引脚复用、外设参数自动算好比手写标准库省大量查寄存器时间。当你同时启用USART、ADC、I2C、TIM时漏掉一个外设时钟会导致奇怪的死机现象用CubeMX不会犯这种低级错误。HAL库的另一个好处是代码在F1/F4系列之间迁移时只改配置结构体驱动逻辑可以复用。很多自学路径包括常见的江科大STM32基础教程也建议先把CubeMX工程跑通再上传感器驱动。CubeMX里关键参数我一般这样填外设参数值说明RCCHSECrystal/Ceramic Resonator使用8MHz晶振ClockSYSCLK72MHz通过PLL倍频USART1Baud Rate115200与ESP8266默认一致ADC1Sampling Time55.5 CyclesMQ-2输出阻抗较高TIM2Prescaler, Period71, 9991ms中断周期I2C1Speed ModeFast Mode, 400kHzOLED响应快这里有个容易忽略的点TIM2挂在APB1上如果APB1预分频系数不是1TIM2输入时钟是APB1的2倍。CubeMX会帮你算好但如果你在代码里直接改寄存器需要重新确认。ADC采样时间设长一点是因为MQ-2传感器输出阻抗可能达到几kΩ采样时间太短意味着采样电容没充到输入电压读数会偏低且抖动。3.2 DHT11 单总线时序的 HAL 实现DHT11是单总线协议主机先拉低数据线至少18ms再释放切到输入模式等待DHT11响应。之后DHT先拉低80us再拉高80us随后输出40位数据。每一位的开始都是50us低电平之后高电平时长区分0或126到28us表示070us表示1。在HAL库下切换GPIO方向可以直接操作MODER寄存器。uint8_t DHT11_Read_Data(float *temp, float *humi) { uint8_t data[5] {0}; uint8_t i; GPIOA-MODER | (1u (1 * 2)); // PA1 设为输出 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); HAL_Delay(20); // 至少18ms起始信号 GPIOA-MODER ~(3u (1 * 2)); // PA1 设为输入外部上拉拉高 delay_us(40); if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) GPIO_PIN_RESET) { while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) GPIO_PIN_RESET); delay_us(80); for (i 0; i 40; i) { while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) GPIO_PIN_RESET); delay_us(28); if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) GPIO_PIN_SET) { data[i / 8] | (0x80 (i % 8)); while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) GPIO_PIN_SET); } else { data[i / 8] ~(0x80 (i % 8)); } } if ((uint8_t)(data[0] data[1] data[2] data[3]) data[4]) { *humi data[0] data[1] * 0.1f; *temp data[2] data[3] * 0.1f; return 1; } } return 0; }delay_us(28)这个时间点放在高电平开始之后用于判断短高电平还是长高电平。如果28us后仍为高说明是1否则是0。while循环等待时如果DHT11意外拉低总线程序会卡死所以实际代码里应在while中加超时计数超过100us就退出返回0。这个超时保护很关键否则传感器脱落会导致整个系统跑飞。delay_us函数最好用定时器实现比如TIM3的计数器或者经过示波器校准的软件循环不同编译器优化级别下空循环延时差异很大。3.3 按键和继电器的防抖与延时处理按键防抖不能只靠HAL_Delay(20)。机械抖动可能持续超过20ms而且HAL_Delay会阻塞主循环和中断导致ADC采样和串口接收丢数据。正确做法是用定时器节拍TIM2产生1ms中断在中断里递增系统计数器按键扫描放主循环每10ms读一次连续两次电平一致才确认。如果需要处理多个按键可以给每个按键维护一个状态变量typedef enum { KEY_IDLE, KEY_HELD, KEY_TRIGGERED } key_state_t; key_state_t key_scan(GPIO_TypeDef *port, uint16_t pin) { static key_state_t state KEY_IDLE; static uint8_t cnt 0; if (HAL_GPIO_ReadPin(port, pin) GPIO_PIN_RESET) { cnt; if (cnt 2) { cnt 0; state (state KEY_IDLE) ? KEY_TRIGGERED : state; } } else { state KEY_IDLE; cnt 0; } return state; }cnt做连续两次采样只有检测到两次低电平才认为按键有效。KEY_TRIGGERED只能持续到下一次处理避免一次按下触发多次动作。继电器驱动方面GPIO输出电平要参考模块原理图很多模块是低电平触发。切换继电器时线圈反向电动势会在电源线上产生尖峰导致同一电源域里DHT11数据错乱。处理办法是继电器模块独立供电或者至少加一个1000uF电解电容缓冲并在继电器触点两端并联RC吸收电路。3.4 串口与 ESP8266 的无阻塞收发与ESP8266通信不要直接在串口中断里解析AT命令。ESP8266复位时可能输出一长串启动日志中断里做字符串匹配很容易把中断栈撑爆。正确做法是中断只收集字节主循环做协议解析。#define RING_BUF_SIZE 256 static uint8_t rx_buf[RING_BUF_SIZE]; static volatile uint16_t rx_head 0, rx_tail 0; uint8_t uart1_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rx_buf[rx_tail] uart1_byte; rx_tail (rx_tail 1) % RING_BUF_SIZE; HAL_UART_Receive_IT(huart1, uart1_byte, 1); } } int16_t uart1_getchar(void) { int16_t ch -1; if (rx_head ! rx_tail) { ch rx_buf[rx_head]; rx_head (rx_head 1) % RING_BUF_SIZE; } return ch; }HAL_UART_Receive_IT开启一字节中断接收每收到一字节触发一次回调回调中把字节存到环形缓冲并再次开启接收。因为每次只接收一字节所以接收大量数据时不会一下占满中断栈主循环消费速度只要大于串口平均流量就不会丢数据。rx_head和rx_tail都声明为volatile因为它们在中断和主循环之间共享否则编译器优化后可能不重新从内存读取。主循环里用uart1_getchar逐字节读取配合状态机识别AT命令结尾\r\n。4. 联调排错与参数调整从“点不亮”到“数据乱跳”4.1 电源与地线问题共地、稳压和干扰毕设调试中很多“数据乱跳”问题最终都指向电源。ESP8266在Wi-Fi发射瞬间电流可冲到300mA以上如果用USB转串口小板上的3.3V供电电压会被拉低DHT11和OLED会出现随机复位或显示花屏。推荐用AMS1117-3.3或RT9013稳压芯片输入端接5V输出端并联两个10uF陶瓷电容。注意ESP-01S这类模块板载LDO输出能力很弱最好给模块单独供电。共地是所有串口和ADC稳定的前提。STM32的GND、ESP8266的GND、继电器模块的GND必须连接成星型地否则串口收发会出现随机错位。可以用万用表量一下各模块GND之间压差超过0.1V就要留意。继电器切换时产生的电磁干扰会通过地线回流到传感器解决办法是让继电器模块地线尽量粗、尽量短不要和DHT11数据线平行走线。4.2 定时器配置和系统时钟的检查点发现HAL_Delay延时不对或者TIM中断周期偏快偏慢按这个顺序查先查RCC系统时钟是否真的跑到了72MHz。在CubeMX里如果HSE旁路电容没焊或晶振负载电容不匹配HSE可能起振失败CubeMX会自动切换到HSI 8MHz这时所有外设时序都会按比例变慢。其次查Flash等待周期F103在72MHz下必须设置2个等待周期否则CPU取指随机出错。TIM定时器问题通常出在预分频和自动重装值上。如果APB1预分频为2TIMx时钟频率是APB1的2倍也就是72MHz。想要1ms中断预分频71计数周期999。一个容易忽略的参数是TIM的Auto-Reload Preload如果不开启写入ARR寄存器会立即生效可能导致正在计数的周期被截断。建议在CubeMX里把TIM_CR1_ARPE位置1。还有如果TIM中断回调里执行任务时间超过一个周期比如读DHT11用了20ms而计数器周期是1ms就会造成事件丢失系统节拍不准。4.3 ESP8266 与 STM32 的通信调参波特率和 AT 命令ESP8266默认波特率是115200但早期ESP-01S有的默认9600。先看模块丝印再用串口助手手动发AT验证。如果收发不正常先确认接线没有交叉STM32的TX接ESP8266的RXRX接TXGND必须连通。波特率误差在正负2%内通常没问题用内部HSI时误差可能较大尽量使用外部HSE。常用AT命令如下命令返回用途ATOK验证模块存活ATRSTOK及启动日志重启模块ATCWMODE1OK设置Station模式ATCWJAPSSID,passwordWIFI CONNECTED连接Wi-FiATCIPSTARTTCP,192.168.1.10,8080CONNECT OK建立TCP连接ATCIPMODE1OK进入透传模式ATCIPSENDOK发送数据模块进入透传发送AT命令必须加\r\n。一个稳定做法是封装一个带超时的发送函数uint8_t send_at_cmd(const char *cmd, const char *expect, uint16_t timeout_ms) { uint16_t cnt 0; uart1_send(cmd, strlen(cmd)); uart1_send(\r\n, 2); while (cnt timeout_ms / 10) { if (strstr((char *)rx_buf, expect)) { return 1; } HAL_Delay(10); } return 0; }这个函数的关键是strstr搜索环缓冲区时需要先保证字符串结尾有\0因此发送命令前最好把rx_buf清空。timeout_ms通常给2000到5000Wi-Fi连接需要更久。重发设计要放在循环外避免阻塞整个控制闭环。实际项目中AT命令解析建议和传感器采集分时共享主循环不要等一个AT超时结束后再采样这样数据会滞后。4.4 数据校验和调试手段用日志输出代替硬件仿真器联调时不要只靠点灯。如果手头有ST-Link可以直接在MDK里看变量但打印日志更省时间。F103的USART2经常空闲把USART2连接到USB转TTL重定向printf到USART2。这样你可以用纯软件查看传感器数据和协议帧内容。日志格式建议使用结构化文本printf([%lu] temp%.1f humi%.1f smoke%d pir%d relay%d\r\n, HAL_GetTick(), temp, humi, smoke_val, pir_status, relay2_status);HAL_GetTick()返回上电以来的毫秒数可以排查响应时间问题。校验这一行输出是否和万用表量到的电压一致判断ADC和DHT11的标定。注意printf重定向要避免浮点格式化占用太多栈空间可以在编译选项里使用-u_printf_float或者改用整数打印否则RAM小的F103可能运行到一半跑飞。5. 基于STM32的智能家居控制逻辑重构从轮询到状态机5.1 用状态机驯服“if else 堆出来的屎山”外设驱动跑通后控制逻辑通常是这样主循环先读DHT11再读MQ-2温度大于30度就开风扇检测到烟雾就报警。传感器越多if嵌套越深最后自动模式和手动模式互相打架。常见做法是把整个控制流程改成有限状态机。状态包括IDLE无异常、COOLING降温、ALARM报警、MANUAL手动干预。状态迁移由事件触发事件可以是按键、上位机指令、定时器超时或传感器阈值。5.2 一个最小可跑状态机结构typedef enum { ST_IDLE, ST_COOLING, ST_ALARM, ST_MANUAL } sys_state_t; sys_state_t g_state ST_IDLE; uint32_t state_enter_time 0; void state_manager(void) { switch (g_state) { case ST_IDLE: if (g_temp 30.0f) { g_state ST_COOLING; state_enter_time HAL_GetTick(); } if (g_smoke threshold) { g_state ST_ALARM; state_enter_time HAL_GetTick(); } break; case ST_COOLING: relay_fan_on(); if (g_temp 27.0f || HAL_GetTick() - state_enter_time 60000) { g_state ST_IDLE; } break; // ST_ALARM 和 ST_MANUAL 类似处理 } }g_temp和g_smoke是上一轮传感器采集的全局变量状态迁移前先更新它们。state_enter_time给状态设置超时出口避免温度感应失效后风扇永远不关。状态机收益在于每个状态只处理一种行为迁移条件是集中的可枚举数值代码审查和答辩讲解时容易说清楚。5.3 验证状态切换和进一步扩展验证方法很简单用串口日志打印g_state每次迁移时的值和时间戳人为触发阈值观察是否符合预期。更进一步把状态机节点作为数据通过ESP8266上报到MQTT Broker在Node-RED里画一张状态迁移图。如果想做得更深可以把状态机的切换事件做成队列让传感器采集和状态迁移解耦只有事件发生时唤醒状态机。这样即使在低功耗模式下系统也能靠外部中断从停机状态恢复。state_enter_time的溢出问题需要注意HAL_GetTick()是32位无符号数连续运行49天才会回卷毕设场景不用担心但养成用无符号减法的习惯总没错。从一个状态迁移到另一个状态时别忘了更新state_enter_time否则超时判断会引用错误基准这是状态机最常见的bug。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价