简介:这是一份面向STM32嵌入式开发学习者和本科毕业设计学生的环境监测系统完整源码项目,基于STM32微控制器实现多类环境参数的采集、处理与显示,可直接用于课程设计或毕业设计参考。压缩包共1100个文件,以613个C源码文件和312个头文件为主体,同时包含IAR工程文件、Keil工程配置文件、Hex固件、库文件及说明文档,覆盖从底层驱动到应用逻辑的完整代码链,整体大小为31.88MB。已有183人学习下载。源码均经过本地编译验证、可正常运行,评审得分在95分以上,项目难度适中,结构清晰,适合需要快速理解STM32外设驱动、传感器数据采集与嵌入式系统整合开发的读者。无论是按要求复现实验,还是在此基础上扩展功能完成毕业设计,都能获得扎实的起点。
1. 从 DSP 库文件看 stm32 环境监测系统的工程组成
一个环境监测毕设源码包,真正决定它能不能编译过、跑得稳的,往往是藏在工程目录里那几个不起眼的.a静态库文件和数学查表.c文件。打开这个工程,我习惯先不看main.c,而是先确认libarm_cortexM4lf_math.a这种 DSP 库文件与当前编译器、芯片型号是不是匹配。这个包里的库针对 Cortex-M4 内核做了小端/大端、带不带 FPU 的拆分,说明目标板大概率是 STM32F4 系列,ADC 采集到的浮点数据可以直接用硬件浮点单元做滤波和特征计算,不需要软件模拟 double,跑起来差距非常明显。整个系统覆盖温湿度采集、光照/粉尘检测、OLED 显示和串口上报这些常见环节,难度在毕业设计层级里中等偏上,能在一周内理顺数据流,又不至于一眼看完毫无收获。源码包说明中标注“评审分 95 分以上”,这种分数只能当参考,关键是编译链能不能一次跑通,以及换一块板子后还稳不稳定,下面按工程实际组织方式逐层拆开。
2. 环境监测系统的架构和传感器选型
2.1 系统分层:采集、处理、显示的边界划分
一套完整的 STM32 环境监测工程,数据流通常切成四层:底层用 HAL 库驱动 ADC、DMA、GPIO 和定时器完成物理量采集;中间层把 ADC 原始码值换算成工程单位,再用平滑滤波去掉传感器噪声;上层把温度、湿度、光照、粉尘浓度打包成结构化文本;最外面是 OLED 和串口这种人机出口。分层的好处是替换传感器时不用动业务逻辑,比如把 DHT11 换成 SHT30,只需要重写中间层的读取函数,上报和显示代码原封不动。这个源码在目录结构上也是按这个思路布置的,Core/Src下放外设初始化,App层放数据处理和调度循环,DSP 源文件单独建组,和业务代码分开。实际动手改的时候,建议先沿着main.c -> app_task.c -> sensor_xxx.c这条调用链走一遍,搞清楚每个.c文件是被谁 include 的,再动传感器驱动,否则很容易出现“显示正常但上报数据全是 0”这种边界问题。
2.2 传感器参数与量程设计
环境监测的传感器选型讲究“够用且好买”,不是越贵越好,毕设场景尤其如此。下面这张表是这个工程里常见的传感器组合和关键参数,换传感器时直接对照接口类型和输出方式就能判断驱动力度。
| 传感器 | 输出方式 | 供电 | 接口 | 典型量程 | 分辨率 |
|---|---|---|---|---|---|
| DHT11 | 单总线数字 | 3.3V/5V | 开漏 GPIO | 温度 0~50℃、湿度 20%~90%RH | 1℃、1%RH |
| BME280 | I2C/SPI 数字 | 3.3V | I2C | 温度 -40~85℃、湿度 0~100%RH | 0.01℃、0.008%RH |
| GP2Y1010AU0F | 模拟电压 | 5V | ADC | 粉尘浓度 0~0.5mg/m³ | 0.1V/0.1mg |
| 光敏电阻 + 分压 | 模拟电压 | 3.3V | ADC | 0~3.3V | 12bit |
| 土壤湿度探头 | 模拟电压 | 3.3V | ADC | 0~3.3V | 12bit |
DHT11 的温湿度精度一般,但胜在驱动简单、不容易坏,做毕设演示足够;如果评审老师追问“湿度误差”,就把 BME280 的 I2C 驱动补上,代码里保留原有 DHT11 路径,两个传感器同时采,上报字段分开。GP2Y1010AU0F 这类粉尘传感器输出的是模拟电压,需要接运放或直接用 ADC 采样,记得先确认 ADC 输入引脚不是 5V 容忍脚,否则长时间工作容易烧引脚。
2.3 为什么源码包里会出现 CMSIS-DSP 库
看到arm_common_tables.c和arm_dct4_init_f32.c时不要觉得多余,这两个文件是 FFT 和 DCT 变换的旋转因子表与初始化表,f32后缀表示单精度浮点版本。环境监测工程用 DSP 库最常见的场景不是做频谱分析,而是做均值滤波、标准差计算和简单的滑动窗统计。比如粉尘传感器原始电压毛刺大,直接映射成浓度值会不停跳动,用arm_mean_f32先做一段窗口平均,曲线立刻平滑一个数量级:
#include "arm_math.h" #define SAMPLE_BUF_SIZE 64 float32_t adc_volt_buf[SAMPLE_BUF_SIZE]; float32_t dust_volt; // 对 64 点电压样本做一次均值滤波,结果写入 dust_volt arm_mean_f32(adc_volt_buf, SAMPLE_BUF_SIZE, &dust_volt);arm_mean_f32的第二个参数是 blockSize,也就是参与平均的样本个数,第三个参数是结果指针。注意传入的数组和结果变量都必须是float32_t,不要直接用float以外的类型,否则在开启 FPU 的工程里可能被隐式转成 double,触发软件浮点库的额外开销,代码能跑但 CPU 占用率高不少。如果评审老师关心数据可靠性,还可以把标准差也加上:先用arm_std_f32算出窗口内波动幅度,波动太大时在串口日志里打一条 warning,表示当前环境不稳定。这个细节在毕业设计答辩时很容易成为加分项。
3. 源码工程实现:从 CubeMX 配置到串口输出
3.1 引脚规划与初始化逻辑
拿到这个工程后,先用 STM32CubeMX 打开.ioc文件看引脚分配,不要直接编译,因为板子型号和外设引脚可能和你手上的开发板不一致。下面是一组兼容性较好的分配方式,适合把系统接到一块普通 F4 开发板上调试:
| 外设 | 引脚 | 工作模式 | 说明 |
|---|---|---|---|
| DHT11 | PB5 | GPIO 开漏输出/输入 | 单总线数据线 |
| 光敏电阻分压 | PA0 | ADC1_IN0 | 光照强度采样 |
| 粉尘传感器 | PA1 | ADC1_IN1 | 粉尘电压采样 |
| 土壤湿度探头 | PA2 | ADC1_IN2 | 土壤湿度采样 |
| OLED | PB6/PB7 | I2C1 | SSD1306 显示屏 |
| 串口调试口 | PA9/PA10 | USART1 | 数据上报 |
CubeMX 里的关键设置是 ADC 要打开 ScanConvMode 和 ContinuousConvMode,通道数设为 3,DMA 选择 Circular 模式。初始化生成的代码里,MX_ADC1_Init会逐通道配置采样时间和 Rank,下面这段是手动补出来的多通道配置片段:
ADC_ChannelConfTypeDef sConfig = {0}; // 通道 0:光敏电阻 sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_84CYCLES; HAL_ADC_ConfigChannel(&hadc1, &sConfig); // 通道 1:粉尘传感器 sConfig.Channel = ADC_CHANNEL_1; sConfig.Rank = ADC_REGULAR_RANK_2; HAL_ADC_ConfigChannel(&hadc1, &sConfig); // 通道 2:土壤湿度 sConfig.Channel = ADC_CHANNEL_2; sConfig.Rank = ADC_REGULAR_RANK_3; HAL_ADC_ConfigChannel(&hadc1, &sConfig);Rank 必须从 1 开始按顺序排列,不能跳号。SamplingTime 决定采样电容充电时间,84Cycles 对应 12MHz 左右 ADC 时钟下约 7us,适合中等阻抗的信号源;如果传感器输出阻抗大导致读数偏低,就把采样时间调到 480Cycles。DMA 模式选 Circular,配合HAL_ADC_Start_DMA启动后,CPU 不用管每次转换,DMA 会自动把三个通道的结果轮询写到内存数组里。
3.2 ADC 与 DMA 的缓冲设计
启动 DMA 采样时,缓冲区长度不是 3,而是一个通道一组、一组若干次采样。常见做法是每个通道存 32 或 64 个点,这样滤波和 DMA 中断解耦,即使主循环偶尔忙不过来,数据也不会丢:
#define ADC_CH_NUM 3 #define ADC_SAMPLE_CNT 64 uint16_t adc_raw_buf[ADC_CH_NUM * ADC_SAMPLE_CNT]; // 三通道循环采样,共 192 个采样点 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_raw_buf, ADC_CH_NUM * ADC_SAMPLE_CNT);DMA 传输完成中断里只置一个通道就绪标志,不做业务处理。主循环发现标志位置位后,按adc_raw_buf[i * ADC_SAMPLE_CNT + j]的索引把各通道数据取出来。这里有个很容易踩的坑:CubeMX 默认生成的是单缓冲模式,DMA 写满后停住,需要再次调用HAL_ADC_Start_DMA才能继续。如果发现上位机数据每隔一小段就卡一下,多半是缓冲模式没改成 Circular,或者NbrOfConversion没改成 3,改完以后数据流就连续了。
换算电压时要用 3.3V 而不是 5V 作为参考,如果板上有外部基准或者 PA0 接了分压电阻,还要先测一下空载电压:
float adc_volt = (float)adc_raw_buf[0] * 3.3f / 4095.0f;这段代码里 4095 是 12 位 ADC 的满量程值,如果用的是 16 位外部 ADC 或 STM32F3 系列的 12 位但配置成 10 位,都要对应调整。电流型传感器一般外接 I/V 电阻,换算公式要额外乘一个跨阻系数,不要直接套电压映射。
3.3 DHT11 单总线时序读取
DHT11 的时序精度是微秒级的,HAL_Delay最小粒度只有 1ms,直接用会读到全 0xFF。工程里一般会补一个基于 DWT 的微秒延时函数,DWT 是内核调试外设,不需要额外定时器:
static void delay_us(uint32_t us) { DWT->CYCCNT = 0; while (DWT->CYCCNT < us * (SystemCoreClock / 1000000)); }读取一个字节的核心逻辑是判断高电平宽度:DHT11 的数据位 “0” 高电平约 26us,“1” 高电平约 70us,所以采样点放在高电平开始后的 40us 处最稳妥:
uint8_t dht11_read_byte(void) { uint8_t i, byte = 0; for (i = 0; i < 8; i++) { uint16_t timeout = 10000; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) == GPIO_PIN_RESET) if (--timeout == 0) return 0xFF; delay_us(40); if (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) == GPIO_PIN_SET) { byte |= (0x80 >> i); } timeout = 10000; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) == GPIO_PIN_SET) if (--timeout == 0) return 0xFF; } return byte; }逻辑说明:第一个 while 等待低电平结束,代表数据位开始;延时 40us 后判断数据线电平,如果还是高,说明高电平宽度超过 40us,判断为 “1”,否则为 “0”。两个 while 都加了 timeout,防止传感器损坏或接线松动时主循环卡死。返回后还要校验湿度整数、湿度小数、温度整数、温度小数、校验和五位数据,校验和等于前四位累加的低 8 位才认为本次读取有效;无效数据直接丢弃,等下一轮采样周期再读,不要用脏值刷新 OLED。
3.4 串口数据打包与 printf 重定向
上报数据用 JSON 行格式,每个字段是 key-value,一行一条记录,后续用上位机解析非常方便。MDK 工程里重定向printf到串口,最省事的方式是开启 MicroLIB 然后实现fputc:
int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF); return ch; }主循环里这样输出一行数据:
printf("{\"temp\":%.1f,\"humi\":%.1f,\"dust\":%.3f,\"light\":%.1f}\r\n", temp, humi, dust_volt, light_lux);逻辑说明:%.1f表示保留一位小数,dust_volt已经是换算后的电压值而不是原始 ADC 码。注意转义字符\"必不可少,否则上位机json.loads直接报错。GCC 工具链下fputc改成_write或_write_r,具体函数名看 syscalls 实现;IAR 则是在DLib里配置__write回调。串口波特率建议固定 115200,8N1,和本节后面 Python 脚本保持一致,如果接蓝牙模块或 ESP8266,波特率可能需要降到 9600 提高稳定性。
4. 编译链接与 DSP 库排错
4.1 Keil 里正确链接 math 库
源码包里那六个.a文件不是全都要加入工程,选错一个就会出现一堆 “undefined symbol” 或者 “library is not compatible” 错误。先看文件命名的三段信息:第一段是编译器厂商,libarm是 ARMCC/AC5 和 AC6 通用,iar是 IAR 专用;第二段cortexM4说明目标内核是 Cortex-M4;第三段带f表示硬浮点版本,带b表示大端。STM32F407/F429 这种带 FPU 的芯片,在 Keil 里用 AC5 编译时选libarm_cortexM4lf_math.a,用 IAR 时选iar_cortexM4lf_math.a。F103 是 Cortex-M3 内核,不能直接用这些库,需要换成arm_cortexM3l_math.a。
把库文件加入工程后,还要在 C/C++ 选项卡里定义两个宏,缺少任何一个都会导致arm_math.h条件编译走错分支:
ARM_MATH_CM4 __FPU_PRESENT = 1如果使用的是 AC6,还需要确保 Target 页里选了 “Use FPU” 而不是 “Not used”。编译一次后,打开生成的.map文件确认库有没有真正链接进去。Windows 命令行下在工程目录执行:
findstr /C:"arm_mean_f32" build\project.map有输出说明arm_mean_f32已经解析到,库里对应的目标文件被链接器拉进来了;没有输出则说明函数被优化掉或者头文件路径不对。检查时注意.map文件路径以工程实际输出位置为准。
4.2 常见链接错误与定位方法
最典型的错误是L6218E: Undefined symbol arm_rfft_fast_init_f32,这种符号名字以arm_开头但库文件找不到。原因一般是把.a文件加进工程后,编译器选项里的宏定义没配对,arm_math.h认为当前芯片不支持 DSP 指令,很多接口函数被#ifdef排除掉了。解决办法不是换库,而是回 4.1 节核对ARM_MATH_CM4宏。
第二种常见问题是编译时报错提示某个源文件里的浮点函数和libarm_cortexM4lf_math.a不兼容。这通常是工程里部分.c文件用 AC5 编译、部分用 AC6 编译导致的混链。Keil 中选中所有源文件,统一在 Options 里修改编译器版本,不要只改单个文件。第三种问题是 IAR 工程误用了libarm开头库,链接器直接报Fatal Error[Lc002],把库换成iar_前缀即可。
4.3 硬件层面的稳定性问题
软件编译过了不代表系统能稳定工作。晶振起振失败是最隐蔽的一个坑,系统时钟没有跑在 168MHz,ADC 采样率和 DHT11 时序都会偏,表现是串口输出乱码或者温湿度每隔几十秒跳一次。换板子时先检查 CubeMX 里 HSE_VALUE 是不是 25MHz 或 8MHz,和开发板实际晶振对齐;如果晶振匹配没问题,再排查 PLL 配置。ADC 参考电压不稳定会导致同样的光线下电压值漂移,尽量用开发板上独立的 3.3V 基准给 PA0 供电,不要把伺服电机或继电器和传感器共用电源。DHT11 引线超过 20cm 时,建议在数据线和 GND 之间加一个 4.7kΩ 上拉到 3.3V,采样周期不要低于 1 秒,很多偶发读数为 0 的问题都能靠这两条改善。
注意:换芯片型号后一定要在 Debug 页重新选 Flash 算法,否则烧录到一半会报 “No Algorithm found”,这个和 DSP 库选择是独立问题,但经常被误判为代码问题。
5. 上位机验证与传感器数据校准
5.1 用 Python 把串口数据画成实时曲线
调试环境监测系统时,光看串口助手里的文本不够直观,一个几十行的 Python 脚本就能把温度曲线实时画出来,也方便答辩时演示数据是连续变化的:
import serial import json import matplotlib.pyplot as plt from collections import deque ser = serial.Serial("COM3", 115200, timeout=1) temperatures = deque(maxlen=200) plt.ion() fig, ax = plt.subplots() while True: line = ser.readline() if line.startswith(b"{"): data = json.loads(line) temperatures.append(data["temp"]) ax.clear() ax.plot(temperatures) ax.set_ylabel("temperature (C)") ax.set_xlabel("sample") plt.pause(0.1)deque(maxlen=200)只保留最近 200 个点,内存占用恒定,长时间挂机也不会卡。json.loads(line)要求单片机侧输出的每一行都是完整 JSON,不能有额外的调试打印混进去,所以业务代码里的printf调试信息要打到另一个串口或用LOG_LEVEL宏关掉。如果曲线跳变频繁,先去查滤波窗口长度,而不是怀疑传感器坏了。
5.2 两点校准与线性修正
环境监测系统里最容易丢分的环节是“测出来的温度/湿度与标准表对不上”。先用传感器采两个已知状态下的原始电压或 ADC 码值,比如 0℃ 冰水混合物和沸水(考虑海拔,沸水温度要修正),也可以用标准温湿度计做两个稳定工况点:
| 校准点 | 参考值 | 传感器读值 |
|---|---|---|
| 低温点 | 15.0℃ | 15.8℃ |
| 高温点 | 30.0℃ | 31.2℃ |
校准后的输出用线性映射修正:
y_out = (y_s1 - y_s0) / (x_s1 - x_s0) * (x_raw - x_s0) + y_s0其中x_raw是传感器当前读值,x_s0和x_s1是两个校准点的传感器读值,y_s0和y_s1是参考值。实现时把四个系数写成结构体,在校准模式下通过串口命令写入 Flash,不要每次编译都改代码。DHT11 出厂时以 5V 供电标定,3.3V 供电会在湿度高位产生正偏差,这是很多环境监测项目读数偏高的原因,校准能修正一部分,但极端湿度下误差依然存在,答辩时主动说明这一点反而显得严谨。
5.3 扩展成远程上报节点的最小改动
源码里串口输出的 JSON 行已经让系统具备接入外部模块的能力。把 DHT11 和 ADC 数据打包后,不打印到调试串口,而是通过预留的 USART2 发到一个 AT 指令 WiFi 模块,模块以 TCP 方式把数据推到局域网服务器。需要注意 TX 引脚的电平匹配,5V 模块要在 STM32 TX 和模块 RX 之间串一个 1kΩ 电阻。整个工程的数据流不改变,只替换出口层,评审时可以在现场用手机热点演示一次远程查看数据,这比单纯在屏幕上显示数字更有说服力。
本文还有配套的精品资源,点击获取