简介:这份资源面向学习STM32嵌入式开发的学生与电子爱好者,围绕环境温湿度监测这一典型课程设计或竞赛题目,提供一套可直接参考的完整实现方案。系统以STM32为主控,通过DS18B20采集温度、DHT11采集湿度,将数据实时显示在LCD液晶屏上,并支持手动设置温湿度上下限;一旦超限即触发声光报警,声音部分由无源蜂鸣器播放音乐,光源部分采用LED闪烁,且报警方式可按需差异化设计,便于在答辩或作业中体现个性。压缩包共195个文件、约4.34MB,以C源文件与头文件为主体,配合工程配置、编译中间产物及可烧录的hex文件,覆盖从源码到固件的完整链路,目录结构清晰,便于按模块查阅与二次修改。目前已有8727人学习下载,适合需要快速搭建实验环境、理解传感器驱动与报警逻辑的读者参考借鉴。
1. 从一颗传感器到一套能交付的监测系统
做嵌入式课设最怕的不是写不出代码,而是做出来的东西“只能演示五分钟”。我见过太多温湿度监测项目,传感器往面包板上一插,串口助手能打印数字就算完事,一到答辩被问“采样周期怎么定”“数据异常怎么处理”“掉电了历史数据还在不在”就答不上来。基于 STM32 的环境温湿度监测系统设计,本质上要解决的是从“能读数”到“能长期稳定运行、能追溯、能报警”的工程化问题。这套方案面向正在做嵌入式毕设或课程设计的学生,也适合需要快速搭一套环境监测原型的从业者,往下我会把需求、选型、模块划分、关键代码和验收方法一层层拆开,让你照着就能落地。
2. 需求与选型:先把边界划清楚再动手
2.1 功能需求与非功能需求的拆解
很多人一上来就选芯片、画原理图,结果做到一半发现需求没想全,返工成本极高。我一般会先把需求分成两类:功能需求回答“系统要做什么”,非功能需求回答“做到什么程度算合格”。这套温湿度监测系统的功能需求通常包括:实时采集环境温度与湿度、本地显示当前数值、超阈值声光报警、历史数据存储与查询、通过串口或无线方式上传数据。非功能需求则容易被忽略,却恰恰是答辩时拉开差距的地方——采样周期可配置(比如 1 秒到 60 秒可调)、测量精度满足常见场景(温度 ±0.5℃、湿度 ±3%RH 量级)、连续运行 24 小时不丢数据、断电后历史记录不丢失、报警阈值可由用户设定。
把这两类需求写成一张表,后面每做一个模块都能对应回去,验收时也有据可依。我习惯在需求表里额外加一列“验收方式”,比如“连续运行 24 小时不丢数据”对应的验收方式是“导出存储记录,检查时间戳连续性”,这样需求就不是一句空话,而是可检验的条目。
2.2 主控与传感器的选型逻辑
主控选 STM32 系列是这类项目的常规做法,原因不玄学:外设齐全(至少两路 I2C/SPI、一路 UART、若干 GPIO 和 ADC)、资料多、开发板便宜、调试工具链成熟。具体型号上,资源够用即可,不必追求高性能——采集温湿度、驱动显示屏、读写存储、跑一个简单的状态机,主流的中低端型号完全撑得住。选型时我关注三个点:Flash 和 RAM 是否够放显示字库和缓冲区、是否有硬件 I2C(软件模拟 I2C 在长线缆上容易翻车)、低功耗模式是否满足需求(如果是电池供电场景)。
传感器方面,数字式温湿度传感器比模拟式更省心,出厂已校准、直接输出数字量、免去 ADC 采样和换算误差。常见的有单总线接口和 I2C 接口两类,前者接线简单但对时序敏感,后者总线稳定、便于挂多个器件。我的建议是:如果项目只测一路温湿度,单总线够用;如果要扩展多点监测,优先选 I2C 接口,总线上挂多个传感器地址不冲突即可。选型时务必确认供电电压与主控 IO 电平匹配,3.3V 系统接 5V 器件要么加电平转换,要么直接换器件,别指望“应该没事”。
2.3 存储、显示与通信方式的取舍
存储方案取决于数据量和查询需求。数据量小、只存最近若干条记录,用主控内部 Flash 的模拟 EEPROM 就够;数据量大、要存几天甚至几周的历史曲线,外挂 SPI Flash 或 SD 卡更合适。SD 卡的好处是拔下来就能在电脑上读,缺点是文件系统(通常用 FatFs)增加代码复杂度,且卡的质量参差不齐,劣质卡在反复写入时容易出错。我一般会建议:课设级别用内部 Flash 存最近几百条记录,够演示也够答辩;毕设级别如果要画历史曲线,上 SD 卡并做好写入失败的重试。
显示用 OLED 或 LCD 字符屏都行,OLED 体积小、显示信息密度高,适合展示“温度/湿度/时间/状态”四行信息。通信方式上,串口是最低成本的方案,接 USB 转串口就能在电脑上看数据;如果项目要求“远程监测”,可以加无线模块,但要注意无线模块的供电和天线布局,别把模块贴在主控晶振旁边,干扰起来很玄学。
3. 总体架构设计:三层结构与五个模块的职责划分
3.1 分层架构与数据流
这套系统我按三层来组织:感知层负责传感器采集,处理层负责数据校验、阈值判断、存储调度,交互层负责显示、报警和上传。三层之间通过一个全局的数据结构传递,而不是各模块直接互相调用,这样任何一层出问题都容易定位。数据流是这样的:定时器触发采集 → 传感器驱动读出原始值 → 处理层做单位换算和合理性校验 → 写入环形缓冲区 → 显示模块刷新、存储模块按周期落盘、报警模块比对阈值。
用环形缓冲区而不是直接写存储,是为了解耦采集速度和存储速度。传感器可能 1 秒采一次,但 Flash 写入有寿命限制,不可能每秒写一次,所以采集先进缓冲区,存储模块每 10 次采集或每 1 分钟批量落盘一次。这个设计在答辩时是个加分点,因为它体现了对器件特性的理解,而不是“能跑就行”。
3.2 模块划分与接口约定
五个核心模块的职责我列成下面这张表,每个模块只暴露少量接口,内部实现随便改,只要接口不变其他模块就不受影响。
| 模块 | 职责 | 对外接口 | 依赖 |
|---|---|---|---|
| 传感器驱动 | 初始化、读原始值、换算 | sensor_init()、sensor_read() | 硬件 I2C/单总线 |
| 数据处理 | 校验、滤波、单位换算 | data_process() | 传感器驱动 |
| 存储管理 | 环形写入、读取、擦除 | storage_write()、storage_read() | Flash/SD |
| 显示交互 | 刷新界面、按键响应 | display_update()、key_scan() | 数据处理 |
| 报警控制 | 阈值比对、声光输出 | alarm_check() | 数据处理 |
接口约定里有个细节值得说:sensor_read()返回的是原始值还是换算后的值?我的做法是返回原始值加一个状态码,换算放在数据处理层,这样传感器更换型号时只改驱动和换算系数,上层逻辑不动。这种“驱动只负责搬运、业务只负责解释”的分法,是嵌入式项目里少返工的关键。
3.3 主循环与中断的协作方式
主循环用时间片轮询,而不是一堆delay()堆在一起。具体做法是:SysTick 提供 1ms 基准,主循环里维护几个软件定时器,采集任务每 1 秒触发一次,显示任务每 200ms 刷新一次,存储任务每 60 秒落盘一次,按键扫描每 20ms 一次。中断只做最短的事——串口接收中断把字节丢进缓冲区就返回,具体解析在主循环里做。这样即使某个任务偶尔超时,也不会把整个系统卡死。
/* 软件定时器结构,主循环轮询判断是否到期 */ typedef struct { uint32_t period; /* 周期,单位 ms */ uint32_t last; /* 上次触发时刻 */ void (*task)(void); } soft_timer_t; static soft_timer_t timers[] = { {1000, 0, task_sample}, /* 每 1s 采集一次 */ {200, 0, task_display}, /* 每 200ms 刷新显示 */ {60000,0, task_storage}, /* 每 60s 落盘一次 */ {20, 0, task_key}, /* 每 20ms 扫描按键 */ }; void main_loop(void) { uint32_t now = millis(); for (int i = 0; i < sizeof(timers)/sizeof(timers[0]); i++) { if (now - timers[i].last >= timers[i].period) { timers[i].last = now; timers[i].task(); /* 到期执行,任务内部不得长时间阻塞 */ } } }这段代码的关键在于now - timers[i].last的写法,用无符号减法处理了计数器溢出,比now >= last + period更稳妥。每个任务函数内部要保证快速返回,采集任务里读传感器如果走 I2C 阻塞方式,单次耗时通常在毫秒级,可以接受;但如果传感器响应慢,就要考虑改成状态机非阻塞。参数period根据实际需求调整,采集周期太短会增加功耗和存储压力,太长则曲线不平滑,1 秒是个折中的起点。
4. 关键模块实现:采集、存储与报警的落地细节
4.1 传感器采集与数据校验
采集模块最容易踩的坑是“读到了但读错了”。数字传感器返回的数据里通常带校验位,比如某些器件每字节后跟一个 CRC,或者整帧有校验和。我的做法是每次读取后先校验,校验失败就重试最多三次,三次都失败则标记本次数据无效,而不是把错误值直接显示出去。无效数据在缓冲区里用特殊标记占位,显示时显示“--”,存储时可以选择跳过或记录错误码。
/* 采集并校验,失败重试,返回 0 成功,-1 失败 */ int sensor_read_checked(int16_t *temp, uint16_t *humi) { for (int retry = 0; retry < 3; retry++) { uint8_t raw[6]; if (sensor_read_raw(raw) != 0) continue; /* 总线错误 */ if (!frame_crc_ok(raw, sizeof(raw))) continue; /* 校验失败 */ *temp = (int16_t)((raw[0] << 8) | raw[1]); /* 原始值,未换算 */ *humi = (uint16_t)((raw[2] << 8) | raw[3]); return 0; } return -1; /* 三次都失败,上层标记无效 */ }这里把换算留给上层,temp和humi是原始码值,换算公式和系数写在数据处理模块里。重试次数设 3 次是经验值,再多会拖慢采集节奏,再少则偶发干扰容易误判。如果项目里传感器走的是单总线,还要注意时序延时不能被打断,采集期间最好关掉其他中断,或者用硬件定时器配合 DMA,避免时序被拉长导致读失败。
4.2 环形存储与掉电保护
存储模块用环形队列的思路:Flash 划分成固定大小的记录区,每条记录带时间戳、温度、湿度、状态标志,写满后从头覆盖最旧的记录。这样不需要文件系统,也不需要动态内存,实现简单且可靠。关键点是写入前先擦除目标扇区,而 Flash 擦除是按扇区来的,所以记录区大小要按扇区对齐,一条记录跨扇区会非常麻烦。
#define REC_SIZE 16 /* 每条记录 16 字节 */ #define REC_PER_SEC 128 /* 每扇区可存记录数,按实际扇区大小算 */ #define SEC_COUNT 8 /* 使用 8 个扇区做环形区 */ typedef struct { uint32_t timestamp; /* 秒级时间戳 */ int16_t temp; /* 温度,0.1℃ 为单位 */ uint16_t humi; /* 湿度,0.1%RH 为单位 */ uint8_t status; /* bit0 温度报警,bit1 湿度报警 */ uint8_t crc; /* 记录校验 */ } record_t; static uint16_t write_index = 0; /* 当前写位置,掉电前存入备份寄存器 */ int storage_write(const record_t *rec) { uint32_t addr = FLASH_BASE_ADDR + write_index * REC_SIZE; if (write_index % REC_PER_SEC == 0) { flash_erase_sector(addr); /* 跨扇区时先擦除 */ } if (flash_write(addr, rec, sizeof(record_t)) != 0) { return -1; /* 写入失败,上层可重试或报警 */ } write_index = (write_index + 1) % (REC_PER_SEC * SEC_COUNT); backup_index(write_index); /* 写入备份寄存器,掉电可恢复 */ return 0; }write_index存到备份寄存器(或内部 Flash 的保留页)是掉电保护的核心,否则重启后不知道写到哪了,要么覆盖有效数据,要么重复写同一位置。记录里带 CRC 是为了读取时能识别损坏记录,环形区里难免有写到一半掉电的情况,读出来校验不过就跳过。参数REC_SIZE要和结构体实际大小对齐,编译器可能会做填充,用sizeof校验一下,别拍脑袋定 16 字节结果结构体实际 20 字节,写进去就错位了。
4.3 阈值报警与状态机
报警模块不是简单的if (temp > threshold) beep(),那样会出现“温度在阈值附近抖动导致蜂鸣器疯狂响”的问题。我的做法是加迟滞:超过上限触发报警,降到上限减 1℃ 才解除;低于下限触发,升到下限加 1℃ 才解除。同时报警状态要能区分“温度报警”“湿度报警”“传感器故障”三种,显示和上传时分别标识。
typedef enum { ALARM_NONE, ALARM_TEMP, ALARM_HUMI, ALARM_SENSOR } alarm_t; static alarm_t alarm_state = ALARM_NONE; void alarm_check(int16_t temp, uint16_t humi, int valid) { if (!valid) { alarm_state = ALARM_SENSOR; alarm_output(ALARM_SENSOR); return; } /* 迟滞判断:进入用上限,退出用上限减回差 */ if (temp > TEMP_HIGH || (alarm_state == ALARM_TEMP && temp > TEMP_HIGH - HYST)) { alarm_state = ALARM_TEMP; } else if (humi > HUMI_HIGH || (alarm_state == ALARM_HUMI && humi > HUMI_HIGH - HYST)) { alarm_state = ALARM_HUMI; } else { alarm_state = ALARM_NONE; } alarm_output(alarm_state); }HYST回差取 1℃ 或 2%RH 量级,太小起不到防抖作用,太大则报警迟钝。这段逻辑放在主循环里每采集周期调用一次,alarm_output里根据状态驱动蜂鸣器和 LED,不同状态用不同闪烁频率区分。答辩时如果被问“为什么不用中断做报警”,可以回答:报警判断依赖采集结果,采集本身是周期任务,放主循环里逻辑更清晰,中断里只做紧急停机这类硬实时动作。
5. 避坑与排查:五个最容易翻车的点
5.1 传感器读数偶尔跳变或全为 0xFF
现象是显示数值偶尔跳到极值,或者连续读到 0xFF。原因通常是总线时序问题:I2C 上拉电阻阻值偏大导致上升沿变缓,或者单总线延时被中断打断。解决办法是先查硬件——I2C 上拉一般 4.7kΩ,线长超过 30cm 要减小阻值或降低速率;单总线采集期间关中断或用临界区保护。软件上加 CRC 校验和重试,把偶发错误挡在显示层之外。
5.2 存储数据重启后错乱或丢失
现象是断电重启后历史记录读出来是乱码,或者最新记录被覆盖。原因多半是写索引没做掉电保护,或者擦除扇区时把还没读的数据擦了。解决方法是把写索引存备份寄存器并加魔数校验,上电先验证魔数再决定是否重建索引;擦除前确认目标扇区确实是最旧的那一段,环形区的边界计算用取模而不是减法,避免负数下标。
5.3 显示刷新导致采集节奏被打乱
现象是屏幕刷新时采集间隔明显变长,曲线出现缺口。原因是显示驱动里用了阻塞延时,或者刷屏函数执行时间过长。解决办法是把显示拆成“组帧”和“发送”两步,发送用 DMA 或至少保证单次传输时间可控;显示任务里不做任何delay(),需要延时的场合用状态机分步执行。如果屏幕是 SPI 接口,降低刷新频率到 5Hz 通常肉眼已经够用,没必要 30Hz 全刷。
5.4 报警蜂鸣器干扰采集或复位
现象是一报警系统就重启,或者报警时读数异常。原因是蜂鸣器是感性负载,通断瞬间产生反向电动势,通过电源或地线干扰主控。解决办法是蜂鸣器两端并续流二极管,驱动三极管基极加限流电阻,蜂鸣器电源单独走线并在靠近器件处加去耦电容。如果用的是有源蜂鸣器,确认驱动电流没超过 IO 口能力,超了就上三极管,别直接灌 IO。
5.5 长时间运行后时间戳漂移
现象是跑一天后记录的时间比实际慢了几秒到几十秒。原因是用了软件延时做秒计数,或者晶振精度不够且没校准。解决办法是时间基准用外部 RTC 晶振(32.768kHz)而不是内部 RC,软件上每秒校准一次;如果没有 RTC,至少用定时器中断累加并在上位机侧做时间同步。课设级别漂移几秒可以接受,但要在报告里说明误差来源,这比假装没漂移更显专业。
6. 验收与答辩:怎么证明这套系统真的能跑
验收不是把设备通电看它亮不亮,而是按需求表逐条打勾。我一般会准备一份验收清单,分三块:功能验收、稳定性验收、边界验收。功能验收对照需求表,采集、显示、存储、报警、上传逐项操作并记录结果;稳定性验收至少连续跑 24 小时,中途人为制造一次断电,重启后检查历史记录是否完整、时间戳是否连续;边界验收包括传感器拔掉(应显示故障而非乱码)、存储写满(应覆盖最旧记录而非死机)、阈值调到极限值(报警应正常触发和解除)。
答辩时最容易被追问的是“你怎么知道数据是准的”。我的建议是准备一个参照:用一台经过校准的温湿度计放在同一环境,记录同一时段的读数,做一张对比表,列出最大偏差和平均偏差。偏差在传感器标称精度内就说明系统链路没问题,超了就分析是传感器本身还是换算系数的问题。这张表比任何口头解释都有说服力。
还有一个技巧:把关键设计决策写成“问题—方案—验证”三列,比如“问题:Flash 写入寿命有限;方案:环形缓冲加批量落盘;验证:连续写入 1 万次无坏块”。答辩老师顺着这条线问,你顺着这条线答,节奏就在你手里。代码不用全贴进报告,但主循环调度、存储写入、报警迟滞这三段核心逻辑要能指着讲清楚每一行的意图。
从那以后我每次带项目,验收前都强制自己走一遍“拔电、拔传感器、写满存储”这三步破坏性测试,因为演示时一切正常不代表交付时不出问题,而这三步能提前暴露八成以上的坑。希望这套结构能帮到你,照着搭一遍,报告和答辩都能稳。
本文还有配套的精品资源,点击获取