简介:一套基于 STM32F10x 系列单片机与 MQ-7 一氧化碳传感器的环境监测项目源代码,面向嵌入式初学者与电子设计爱好者,旨在实现一氧化碳浓度的实时采集、本地显示、超标报警与数据上报。项目中,STM32 通过 ADC 模块读取 MQ-7 输出的模拟信号,经换算得到气体浓度值,并实时显示在 OLED 屏幕上;同时设定报警阈值,当浓度超标时驱动蜂鸣器发出警报,并将浓度数据通过串口发送至调试助手,方便上位机监控与记录。工程使用标准外设库开发,压缩包为 rar 格式,共 229 个文件,大小约 6.34MB,其中包含多个 C 语言源文件、头文件、Keil 工程配置文件、启动文件、链接脚本以及编译产生的目标文件等,目录结构完整,可直接用 Keil 打开并重新编译。该资源已有 964 人浏览学习,适合作为单片机课程设计、毕业设计或综合实验的参考。学习本项目,可以掌握传感器信号采集、OLED 显示驱动、蜂鸣器控制、串口通信以及工程组织等多个嵌入式开发要点,是一份难得的实战案例。
1. 一套能跑通的气体监测小系统,拆开看就是四个外设的协同
不少嵌入式初学者卡在“传感器采集 + 显示 + 报警 + 上传”这种组合项目上,不是因为某个外设驱动多难,而是不清楚数据从传感器到串口助手这条链路里,每个环节该做什么、时序怎么配合。这套基于STM32的方案,用MQ-7一氧化碳传感器做气体浓度采集,通过ADC把模拟电压转为数字量,一路送到OLED屏幕刷新显示,一路用阈值判断驱动蜂鸣器报警,同时把浓度值按帧格式通过USART发给串口调试助手。适合正在做课程设计、实验室课题或者想练手完整数据链路的开发者。和网上那些只贴片段代码的项目不同,下面把硬件连接、HAL库配置、数据换算和联调顺序一次讲透。
2. MQ-7一氧化碳传感器工作原理与STM32的ADC采集实现
2.1 MQ-7的加热周期与电导率变化逻辑
MQ-7属于气敏电阻型传感器,内部有两组发热丝和一组测量电极。低电导率对应的就是清洁空气,当一氧化碳浓度升高,二氧化锡半导体表面吸附气体分子,电导率上升,测量电极两端的电压随之改变。关键点在于MQ-7需要交替工作在两个电压下:加热电压1.5V(持续60秒)和5V(持续90秒),这个高低压循环让传感器内部温度周期性变化,从而提升对一氧化碳的灵敏度。直接用5V给加热丝供电,读出的数据会漂移得很厉害。
常见做法是通过单片机GPIO控制三极管或MOS管切换加热电压,但简化版电路常用一个固定5V加热配合软件延时来近似。实际项目里,如果只是室内一氧化碳浓度监测(非工业级),固定5V加热也能拿到可用的变化趋势,阈值判断依然有效。但要注意,传感器上电后需要预热3到5分钟,输出电压才会进入稳定区间。刚上电就读数据,ADC值会虚高,容易误报警。
MQ-7模块输出的是模拟电压(AO引脚),量程大约0到5V,对应浓度从几十ppm到1000ppm左右。STM32的ADC输入范围通常是0到3.3V,所以模块的AO不能直接接PA0这类引脚,最好先确认模块是否板载了电压比较电路。很多市售MQ-7模块会同时输出AO和DO,DO端是电位器可调的阈值比较输出(接LM393比较器),AO才是原汁原味的模拟量。如果模块上没有做电平转换,需要在AO和STM32的ADC引脚之间加分压电阻,典型用10K+4.7K分压,把5V映射到约3.2V。
2.2 Cubemx配置ADC为单通道扫描模式
以STM32F103C8T6为例,PA1作为ADC1通道1的输入引脚。打开STM32CubeMX,在Pinout视图里把PA1配置为ADC1_IN1,然后在Analog -> ADC1里做如下设置:
- Mode: Independent mode
- Scan Conversion Mode: Disabled
- Continuous Conversion Mode: Enabled
- Discontinuous Conversion Mode: Disabled
- Number Of Conversion: 1
- External Trigger Source: Timer 1 Trigger Out event
- Rank 1采样时间: 55.5 Cycles
这里把连续转换模式打开,配合定时器触发,可以保证每次读取的电压值都是当前时刻的真实采样,而不是上一次转换的残留。采样时间设长一点,对高频噪声有一定抑制作用。如果用的是LQFP48封装的芯片,PA1在其他复用功能冲突时会显示黄色警告,此时先把冲突引脚的功能关掉。
配置完成后生成代码,HAL库会自动初始化ADC,主循环里只需要开启转换并等待结果即可。下面这段代码实现了单次读取电压值并换算为传感器输出电压的完整逻辑。
/* stm32f1xx_hal_msp.c中ADC的MSP初始化由CubeMX自动生成,用户无需手工写GPIO配置 */ uint16_t adc_value = 0; float voltage = 0.0f; float sensor_voltage = 0.0f; HAL_ADC_Start(&hadc1); if (HAL_ADC_PollForConversion(&hadc1, 100) == HAL_OK) { adc_value = HAL_ADC_GetValue(&hadc1); } HAL_ADC_Stop(&hadc1); /* STM32F103的ADC是12位分辨率,参考电压默认接VDDA(3.3V) */ voltage = (float)adc_value * 3.3f / 4096.0f; /* 如果使用了分压电阻,需要按比例还原传感器实际输出电压 */ sensor_voltage = voltage * (14.7f / 10.0f);这段代码的逻辑是从启动ADC转换到轮询完成,拿到12位原始值后先换算成分压后的电压,再按分压比还原成模块AO的真实输出电压。HAL_ADC_Stop非常重要,如果不停止转换,下一次启动时会有些芯片在连续模式下直接返回上次缓存结果,导致数据不更新。参数说明上,参考电压3.3V必须和实际硬件一致,如果板子是用LDO输出的3.3V且精度一般,建议在代码里加一个软件校准系数。
2.3 浓度与电压的数据拟合公式
MQ-7的数据手册会给出一个灵敏度特性曲线图,横轴是气体浓度(ppm),纵轴是传感器电阻比(Rs/Ro)。但手册并不直接给出线性公式,需要自己取点拟合。更常用的做法是拿一个已知浓度的标准气标定,但个人开发者没有这个条件,所以一般退而求其次:用电压变化做线性映射。比如预热稳定后测得清洁空气电压为0.3V,某次测量电压为1.2V,可以近似认为浓度成比例上升。
也就是说,项目里展示的“浓度值”本质上是电压的线性折算,不代表真实ppm精度。真正的工业级一氧化碳检测仪需要定期用标准气体校准,个人DIY项目重视的是相对变化趋势和阈值报警的可靠性。如果你需要写报告或答辩,如实说明“本系统实现的是浓度相对指示,绝对精度受限于传感器个体差异”,比强行宣称高精度更有说服力。
3. OLED显示屏驱动:HAL库点亮0.96寸SSD1306并实时刷新浓度
3.1 I2C接口的OLED与STM32接线
这个项目里OLED选I2C接口的型号最省引脚,0.96寸的SSD1306驱动芯片只需要SDA和SCL两根线外加VCC和GND。以STM32F103C8T6来说,硬件I2C1对应PB6(SCL)和PB7(SDA),拉上拉电阻到3.3V。OLED模块的VCC接3.3V还是5V要仔细看模块背面的丝印说明,SSD1306的驱动电压范围是3.3V到5V,但逻辑电平要能匹配STM32的3.3V输出。建议VCC接3.3V,避免电平不匹配导致通信不稳定。
CubeMX里把PB6和PB7配置为I2C1的SCL和SDA,I2C1的速度模式设为Standard Mode(100KHz)就行。速度快了线长的话容易出现ACK失败,OLED这种小屏显示刷新率要求不高,100KHz足够。生成代码后,HAL库提供的I2C底层接口是HAL_I2C_Mem_Write,需要基于这个函数移植SSD1306的驱动。
3.2 SSD1306初始化序列与显存刷新流程
SSD1306的初始化本质上就是向它的控制寄存器写入一系列配置参数。标准流程是:关闭显示、设置显示时钟分频、设置多路复用比、设置显示偏移、开启内部升压、设置对比度、开启显示。这组序列值在绝大多数SSD1306数据手册里都能找到,复制过来注意I2C地址是0x3C还是0x3D,取决于模块上地址选择电阻的焊接位置。
/* ssd1306.h中定义的I2C地址和屏幕参数 */ #define OLED_ADDR 0x3C /* 7位地址,左移一位后是0x78 */ #define OLED_WIDTH 128 #define OLED_HEIGHT 64 /* ssd1306.c中初始化函数的核心部分 */ static void OLED_Init_Sequence(void) { uint8_t init_cmds[] = { 0xAE, 0x00, /* 关闭显示 */ 0xD5, 0x00, 0x80, /* 显示时钟分频 */ 0xA8, 0x00, 0x3F, /* 多路复用比:64行 */ 0xD3, 0x00, 0x00, /* 显示偏移:0 */ 0x40, /* 起始行0 */ 0x8D, 0x00, 0x14, /* 开启内部升压 */ 0x20, 0x00, 0x00, /* 内存寻址模式:水平 */ 0xA1, /* 列段重映射 */ 0xC8, /* 行扫描方向重映射 */ 0xDA, 0x00, 0x12, /* COM引脚硬件配置 */ 0x81, 0x00, 0xCF, /* 对比度:0xCF */ 0xD9, 0x00, 0xF1, /* 预充电周期 */ 0xDB, 0x00, 0x40, /* VCOMH电压 */ 0xA4, /* 关闭全屏点亮 */ 0xA6, /* 正常显示,非反色 */ 0xAF /* 开启显示 */ }; OLED_Write_Command(init_cmds, sizeof(init_cmds)); }这段初始化序列的思路是按SSD1306的寄存器地址逐一写入配置值,顺序不能乱,尤其是0x8D这个开升压命令必须在显示开启之前完成,否则屏幕大概率不亮。OLED_Write_Command函数内部通过HAL_I2C_Mem_Write实现,区别在于控制字节要写成0x00(命令模式)而不是0x40(数据模式)。参数说明上,对比度0xCF是常用的可视性较好值,如果你在强光下用,可以调大到0xFF,但注意功耗会上升。
3.3 显示浓度值的坐标规划与刷新策略
OLED显示一氧化碳浓度,最少需要两行内容:一行是标题(比如 "CO Concentration"),一行是动态变化的具体数值。128x64的屏幕为这个布局留了足够空间。刷新策略上有一个细节容易被忽略:SSD1306没有局部刷新,每次写入数据都是往显存缓冲区里改,然后用一次大数据包把整个缓冲区送给屏幕。这意味着你不可能只刷新某个数字而不影响其他区域。
/* 主循环中更新显示的核心代码 */ char disp_buf[8]; sprintf(disp_buf, "%05.1f", concentration_ppm); OLED_ClearBuffer(); OLED_SetCursor(0, 0); OLED_DrawString("CO:", &font_7x10, OLED_COLOR_WHITE); OLED_SetCursor(28, 0); OLED_DrawString(disp_buf, &font_7x10, OLED_COLOR_WHITE); OLED_SetCursor(68, 0); OLED_DrawString("ppm", &font_7x10, OLED_COLOR_WHITE); OLED_SendBuffer();这段代码先把显存缓冲区清空,再在指定坐标画标题和数值,最后一次性把缓冲内容推到屏幕。清屏再重绘的缺点是会有一瞬间的闪烁,但100Hz以下刷新率人眼基本无感。如果你想避免闪烁,可以改成先改缓冲区里的具体像素,再整包发送。OLED_SendBuffer内部是一次I2C写操作,数据量是128乘64除以8等于1024个字节,加上控制字节,总耗时约10毫秒,这部分时间不宜放在中断里做。
4. 蜂鸣器报警逻辑与串口数据上行的联合设计
4.1 有源蜂鸣器与无源蜂鸣器的驱动差别
蜂鸣器报警电路是整个系统的输出执行机构,驱动方式取决于蜂鸣器类型。有源蜂鸣器内部自带振荡电路,只要通电就会发声,控制逻辑非常简单:阈值超标就拉高GPIO,反之拉低。无源蜂鸣器必须给一定频率的方波才能发声,改变频率还能改变音调,适合做“嘀嘀嘀”的交替报警音。本项目如果选有源蜂鸣器,代码量最少,可靠性也最高,一个GPIO口就能搞定,不占用定时器资源。
CubeMX里把PB5配置为GPIO_Output,初始化好默认电平为低,报警时拉高。硬件上蜂鸣器模块通常自带三极管驱动,所以STM32的GPIO直接接模块的I/O引脚就行,不需要额外加放大电路。如果是裸蜂鸣器(两脚那种),必须通过三极管或ULN2003驱动,不能直接接GPIO。
4.2 两级阈值报警与回差消抖
一氧化碳报警不能只设一个点,因为室内和工业现场的安全标准不同。常见做法是设两个阈值:轻度报警(比如50ppm)和重度报警(比如150ppm)。轻度报警蜂鸣器慢速响(每500ms切换一次),重度报警快速响(每100ms切换一次)。同时还要加回差机制,防止浓度在阈值附近波动时蜂鸣器反复通断。回差的意思是:浓度升到50ppm以上才触发报警,但降到45ppm以下才解除报警,中间5ppm的区间是滞回带。
/* 报警状态机的核心逻辑 */ #define ALARM_LOW_THRESHOLD 50.0f #define ALARM_LOW_RELEASE 45.0f #define ALARM_HIGH_THRESHOLD 150.0f #define ALARM_HIGH_RELEASE 140.0f uint8_t alarm_state = 0; /* 0正常,1轻度,2重度 */ uint32_t last_toggle = 0; uint8_t buzzer_out = 0; if (concentration_ppm >= ALARM_HIGH_THRESHOLD) { alarm_state = 2; } else if (concentration_ppm >= ALARM_LOW_THRESHOLD) { alarm_state = 1; } else if (alarm_state == 1 && concentration_ppm <= ALARM_LOW_RELEASE) { alarm_state = 0; } else if (alarm_state == 2 && concentration_ppm <= ALARM_HIGH_RELEASE) { alarm_state = 0; } uint32_t now = HAL_GetTick(); if (alarm_state == 1 && (now - last_toggle >= 500)) { buzzer_out = !buzzer_out; last_toggle = now; } else if (alarm_state == 2 && (now - last_toggle >= 100)) { buzzer_out = !buzzer_out; last_toggle = now; } else if (alarm_state == 0) { buzzer_out = 0; } HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, buzzer_out ? GPIO_PIN_SET : GPIO_PIN_RESET);这段代码用HAL_GetTick做非阻塞延时,主循环不会因为蜂鸣器而卡住。回差值在轻度报警和重度报警里分别用RELEASE值实现,逻辑上先判断是否升级到重度,再判断是否降级。这里有一个容易踩的坑:如果不小心把重度报警的解除条件写成降到90ppm就解除,那么从重度回到正常可能直接跳跃,导致在140到90这个区间内蜂鸣器一直响,所以重度释放值要设置在轻度释放值之下且留有余量。
4.3 串口数据帧格式设计与打包输出
串口调试助手接收的数据必须要有明确格式,否则你打开SSCOM看到的全是乱码或者无法解析的数据。常见做法是设计一个简单的自定义文本帧:帧头、数据值、帧尾和校验。文本帧的好处是直接用串口助手人类可读,不需要上位机解析程序。
/* 通过串口发送格式化数据帧 */ void Send_CO_Data(float ppm, uint8_t alarm_level) { uint8_t tx_buf[32]; uint16_t len = 0; tx_buf[len++] = '$'; tx_buf[len++] = 'C'; tx_buf[len++] = 'O'; tx_buf[len++] = ','; len += sprintf((char *)&tx_buf[len], "%.1f", ppm); tx_buf[len++] = ','; tx_buf[len++] = '0' + alarm_level; tx_buf[len++] = '#'; tx_buf[len++] = '\r'; tx_buf[len++] = '\n'; HAL_UART_Transmit(&huart1, tx_buf, len, 100); }帧结构用$CO开头,#结尾,中间是浓度和报警等级,以逗号分隔。HAL_UART_Transmit的最后一个参数是超时时间100毫秒,如果串口未打开或者线路异常,这个函数会阻塞100毫秒后返回错误码,实际使用中这个错误码可以丢弃不处理,但如果你在追求实时性,可以用DMA方式代替阻塞发送。波特率设置通常用9600或者115200,数据位8、停止位1、无校验,这是最通用的配置,SSCOM和XCOM都能直接识别。
5. 系统联调顺序与数据校准的实用技巧
5.1 先点亮屏幕再调传感器,最后测串口
联调阶段最忌讳一口气把全部外设接好上电,一旦出问题很难定位是哪个环节。推荐的调通顺序是:先刷一个OLED亮屏测试程序,确认I2C通信和初始化序列没问题;再单独读ADC,用串口把原始电压值打印出来,看传感器预热后电压是否稳定;再写蜂鸣器测试代码,用按键或直接改阈值触发;最后才把四部分合在一起跑。
这样每步只验证一个模块,出了一个新问题,修复时间不会超过五分钟。常见问题是I2C的SDA拉不低(模块没供电或地址错误)和ADC一直读数不变(连续模式下没有重新启动转换),这些都是单步测试阶段就能立刻暴露的。
5.2 预热特性和数据滤波的妥协建议
MQ-7的响应时间大约是30到60秒,但恢复时间可能长达几分钟。这意味着浓度突然升高时,显示值不会瞬间跳到峰值,而是缓慢爬升。如果你的项目演示时被人怀疑“数据变化太慢”,这不是BUG,是传感器本身的特性。为了视觉效果好一点,可以在代码里对连续多次ADC采样做滑动平均滤波,比如每200毫秒采一次,取最近16次平均值。这能让数字跳动更平滑,但对真实响应时间几乎没有帮助。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 传感器预热时间 | 3-5分钟 | 上电后前几分钟电压漂移大 |
| ADC采样间隔 | 200ms | 太频繁噪声大,太慢响应差 |
| 滑动滤波窗口 | 16次 | 约3.2秒的平滑窗口 |
| 串口发送间隔 | 1秒 | 与OLED刷新频率对齐 |
| 轻度报警阈值 | 50ppm | 可调,需验证模块输出电压对应关系 |
| 重度报警阈值 | 150ppm | 可调,需回差配合 |
在代码里串口发送间隔和OLED刷新频率最好共用同一个定时器分频逻辑,避免各刷各的导致数据看起来不同步。你可以在主循环里用一个500毫秒的软件计数器,每两次计数触发一次串口和OLED同时更新,这样调试助手里显示的值和屏幕上完全一致,答辩现场不会出现两边数值对不上的尴尬。
5.3 用万用表验证传感器输出电压与代码读数的对应关系
一个很实用但很多人没做的就是硬件层面对比:把万用表打在直流电压挡,红表笔接MQ-7模块的AO引脚,黑表笔接GND,看万用表的电压读数,再对比串口打印出来的电压值。两者误差应该在0.05V以内。如果误差明显,排查方向是:ADC参考电压不准(板载LDO输出可能只有3.26V左右)、分压电阻精度不够、或者是ADC的采样时间过短导致电容充电不充分。这一步做好了,后面所有浓度换算都有底。分压电阻的精度会影响整个量程的一致性,有条件就用1%精度金属膜电阻,5%的碳膜电阻在温度变化下偏差较大。如果想要更稳,直接把模块改为3.3V供电,省掉分压电阻的环节,代价是量程上限降低,但家庭环境的一氧化碳检测够用。
本文还有配套的精品资源,点击获取