☰
STM32F103与MQ2烟雾传感器:从ADC采集到浓度标定的完整实现
2026/10/15 16:07:47 网站建设 项目流程

简介:基于MQ2烟雾传感器与STM32F103的嵌入式气体检测项目,面向电子、嵌入式方向初学者及开发者,解决烟雾、酒精、甲烷等可燃气体浓度采集、电压换算与串口实时显示的问题。压缩包共179个文件,大小4.3MB,除核心的main.c、MQ2驱动、串口通信实现外,还包含启动汇编文件、标准外设库中的定时器/Flash/RCC/ADC源文件,以及hex、axf、map、uvprojx等工程构建与烧录配置,目录结构清晰,可直接在Keil环境编译调试。目前已有16167人学习下载,教程与注释帮助理解MQ2电阻随浓度变化转换为电压值、再经STM32F103的ADC采样并输出的完整链路。通过该项目可系统掌握GPIO、ADC、UART等外设的初始化与中断处理方式,并复用工程框架快速开展气体报警、环境监测类设计,适合作为毕设或课程设计的实用参照。

1. 一颗 MQ2 和一个 STM32F103,为什么烟雾报警人人能抄却有人翻车

基于MQ2烟雾传感器的STM32F103程序,在嵌入式清单上常被写成“读一个ADC、比较一个阈值、拉一个蜂鸣器”的小Demo。可真按这个量级去搭,很多人第一个晚上就翻车:上电狂报警、隔天数值漂移、换块板子阈值全变。我手里这套程序同样用的是 F103 和 MQ2,但核心在于把“传感器回传的电压”变成“可信的浓度判断链路”,覆盖硬件接线、稳定采集、标定换算和报警逻辑四个环节。适合正在做烟雾报警、排气扇联动、燃气泄漏提示这类产品的开发者,也适合刚把F103点亮的同学拿来当第一个带传感器的完整工程。

2. MQ2 的电气特性与 STM32F103 硬件接线:先看懂黑匣子再写代码

MQ2 看起来是个四引脚模块,实际上里面藏着一个加热丝、一个二氧化锡敏感体和一个负载电阻。很多教程让你“AO 接 PA0,读 ADC 卡阈值”,但你连 AO 输出的是电压还是电阻都不知道,后面所有换算都是空中楼阁。这章先把电气关系讲透,再给接线方案。

2.1 MQ2 为什么能感知烟雾:敏感体、加热丝与 RL 分压的三角关系

MQ2 的核心是二氧化锡(SnO2)敏感层,它被加热丝加热到一定工作温度后,表面会吸附空气中的氧气,形成一个高阻层。当环境里出现可燃气体或烟雾颗粒时,敏感体表面的化学反应改变了电导率,等效电阻 RS 会明显下降。这个 RS 不是直接输出的,模块内部把它和负载电阻 RL 串联,从 RL 两端取电压,也就是 AO 引脚上的模拟电压。

所以整个换算关系只有一个公式:Vout = VCC × RL / (RS + RL)。注意这里的逻辑是反直觉的——RS 下降时 Vout 上升,也就是说 ADC 读数越大,代表气体浓度越高。很多人在这一步就搞反,把高电压当成“空气干净”,后面所有阈值都白调。模块上的 RL 一般印在丝印里,常见的是 5kΩ,也有 1kΩ 的版本;不确定就万用表量 AO 对 GND 之间的电阻,量不出就按 5k 估,标定时会重新校正。

这个公式是整个程序的数学地基。RS 不直接可读,但 Vout 可读,F103 的 ADC 读到的就是 Vout 经过分压后的电压值。后面计算浓度、算 R0、做拟合,全部从这一步推导,所以我会在代码注释里把 Vout 到 RS 的转换单独写一行,避免一个月后回看代码时重新推一遍。

2.2 三种输出怎么选:DO 数字量、AO 模拟量与 I2C 模块,我为什么只用 AO

市面上常见的 MQ2 模块有三种形态:裸传感器+负载电阻、带比较器的全功能模块、带 I2C 接口的集成模块。带比较器的模块除了 AO 还有一个 DO 引脚,DO 内部是一个运放加比较器,阈值由板上的蓝色电位器调节。DO 的问题是它只告诉你“有没有超过某个电压”,而这个电压不等于浓度,电位器还会随温度漂移,今天调的阈值过两天就变了。我调试时见过太多人拧电位器拧到怀疑人生,最后发现是温漂。

带 I2C 的集成模块则走了另一个极端,它把 ADC、标定、浓度换算全做在模块内部,F103 这边退化成读寄存器。这种模块用在量产产品里省事,但作为学习项目几乎学不到链路。我的取舍很明确:调试和原型阶段只用 AO,把阈值判断和标定逻辑全部放在 STM32F103 程序里。这样做的另一个好处是,同一块硬件,改软件就能调整灵敏度,不用焊电阻、不用拧电位器。

那些“MQ2+DO 直接触发中断”的接法我不推荐:DO 输出的上升沿不一定是烟雾,可能是温度骤变、湿度波动,也可能是电位器老化。数字量看着简单,实际上是个黑匣子,出了问题连排查入口都没有。

2.3 STM32F103 的接线方案与供电边界:从 PA0 到 VCC 的一整条链路

接线表如下,照着焊基本不会出错,但供电部分有几个边界条件必须先讲清楚。MQ2 的加热丝工作电流在 150mA 左右,冷态瞬间可能更高,这已经不是单片机的 3.3V 稳压器能扛的电流了。

模块引脚接 STM32F103 端说明
VCC5V 电源(USB 5V 或稳压 5V)加热丝供电,约 150mA,不要接 3.3V
GNDGND必须与 MCU 共地,否则 ADC 参考混乱
AOPA0(ADC1_CH0)模拟电压输出,范围约 0V ~ VCC
DO不接模块内置比较器,阈值由电位器决定

我一般用 USB 的 5V 给模块供电,F103 板子单独用 3.3V,两个电源负极接在一起。如果你只有一个 5V 电源,可以在 F103 的 VDD 前加一颗 LDO 转 3.3V,但要注意 LDO 的压差和散热。另一点容易被忽略:AO 到 PA0 的走线如果超过 10cm,建议在 PA0 对 GND 加一个 100nF 电容,滤掉空间耦合的噪声;要求更高就在 AO 和 PA0 之间串一个 1kΩ 电阻,和电容组成低通滤波。

还有一个判断硬件是否正常的土办法:上电预热后,用万用表量 AO 对 GND 的电压,干净空气下应该在 0.2V 到 1V 之间。如果量出来是 0V 或者接近 5V,先别写代码,模块电源、RL、接线总有一个有问题。这个习惯能帮你省掉大量“读 ADC 全是乱数”的排错时间。

3. 驱动与 ADC 采集:用 HAL 库在 F103 上稳定读回 MQ2 电压

硬件接好之后,第一件事不是写浓度换算,而是让 F103 能稳定地把 AO 上的电压读回来。这章用 HAL 库,配的是最常见的 STM32F103C8T6,PA0 作为 ADC1 的通道 0。整个驱动只做三件事:初始化、单次采集、软件滤波。

3.1 最小工程结构:GPIO、ADC、串口三件事先跑通

用 CubeMX 生成工程时,只需要打开 ADC1、USART1 和一个普通 GPIO(驱动蜂鸣器)。ADC 配置选 Channel0,分辨率 12 位,扫描模式关闭,连续转换关闭;USART1 配 115200-8-N-1;蜂鸣器引脚设为推挽输出。先把这三件事跑通,再往里面加逻辑。

我见过不少人在工程里同时开了 ADC 的 DMA、定时器触发、多通道扫描,结果数据一直不对,最后发现是通道配置冲突。这个项目的数据量级是“每秒读一两次”,完全不需要 DMA 和定时器触发。下面的初始化代码是精简过的,注释里写了每个配置的原因。

#include "adc.h" // 初始化 ADC1 通道0(PA0),12 位分辨率,采样时间取最大档 void MQ2_ADC_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; ADC_ChannelConfTypeDef sConfig = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_ADC1_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_0; GPIO_InitStruct.Mode = GPIO_MODE_ANALOG; // 模拟输入,不能用浮空或上拉 HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = DISABLE; // 单通道,不需要扫描 hadc1.Init.ContinuousConvMode = DISABLE; // 轮询取单次,省电且够用 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.NbrOfConversion = 1; // 只转换一个通道 hadc1.Init.ExternalTrigConv = ADC_SOFTWARE_START; // 软件触发,不依赖定时器 HAL_ADC_Init(&hadc1); sConfig.Channel = ADC_CHANNEL_0; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; // 最长采样时间 HAL_ADC_ConfigChannel(&hadc1, &sConfig); } // 读一次原始值,出错返回 0xFFFF uint16_t MQ2_ReadRaw(void) { HAL_ADC_Start(&hadc1); if (HAL_ADC_PollForConversion(&hadc1, 50) == HAL_OK) { return (uint16_t)HAL_ADC_GetValue(&hadc1); } return 0xFFFF; }

这里有两个参数值得较真。第一个是ADC_SAMPLETIME_239CYCLES_5,这是 F103 上最长的 ADC 采样时间,配合 14MHz 的 ADC 时钟,单次采样的等效窗口接近 18 微秒,比默认的 1.5 周期长了一个数量级。较长的采样窗口对高频毛刺有天然平滑作用,代价是转换变慢,但对 MQ2 这种秒级响应的传感器完全无所谓。

第二个是ExternalTrigConv设置为软件触发。F103 的 ADC 支持定时器触发、外部引脚触发和软件触发,这个项目里信号变化慢、采样频率低,软件触发最简单也最可控。如果你后面想加“每秒采样一次”的精确节奏,可以改用定时器触发,但当前阶段没必要。

串口初始化代码我就不贴了,标准库和 HAL 库都有现成模板。初始化完成后,在主循环里读一次原始值,用串口打印出来,确认数据在 100 到 3000 之间波动(12 位 ADC 满量程 4095)。如果打印出来的值乱跳、幅度超过几百个 LSB,先别急着滤波,回头检查 AO 到 PA0 的走线和供电。

3.2 单次转换与持续采样的区别:为什么轮询模式反而够用

F103 的 ADC 支持连续转换模式,开启后 ADC 会不停地转换、不停地把结果丢进数据寄存器,配合 DMA 可以做到“后台无感采集”。很多教程推荐这种模式,因为它看起来高效、先进。但在这个项目里,轮询模式才是更合理的选择。

理由很简单:MQ2 的气体响应时间在 1 秒到 10 秒之间,电信号变化远比温度传感器慢。我们 500ms 读一次就足够了,连续转换每秒会产生几千个数据点,大部分是冗余。轮询模式代码更短、错误更容易定位,而且不会出现“DMA 缓冲区和主循环读写竞争”这种隐蔽 bug。我见过有人在连续转换模式下忘了清标志位,结果读到的永远是上一次的旧数据。

轮询模式还有一个额外的好处:每次采集都是“主动发起”,可以在采集前插入延时,让 ADC 的采样电容充分充电。这在输入源阻抗较高时特别重要。MQ2 模块的 AO 输出阻抗通常在几十千欧级别,如果不给采样电容足够的充电时间,读出来的值会系统性偏低。长采样时间加上轮询节奏,正好缓解了这个问题。

3.3 滤波不靠玄学:排序中值平均,5 行代码压掉毛刺

MQ2 的模拟输出在静态环境下也有几十个 LSB 的起伏,加上偶尔的尖峰干扰,直接拿单次读数做阈值判断,结果就是报警器不停地“响一下、停一下”。我常用的是排序中值平均:采集 10 次,去掉最大的 3 个和最小的 3 个,剩下 4 个取平均。这个算法能同时压制随机噪声和脉冲尖峰。

// 采集10次,去掉最大最小各3个,剩下4个取平均 #define MQ2_SAMPLE_NUM 10 #define MQ2_KEEP_NUM 4 uint16_t MQ2_ReadStable(void) { uint16_t buf[MQ2_SAMPLE_NUM]; uint16_t tmp; int i, j; uint32_t sum = 0; for (i = 0; i < MQ2_SAMPLE_NUM; i++) { buf[i] = MQ2_ReadRaw(); HAL_Delay(50); // 每次间隔50ms,总耗时500ms } // 冒泡排序,数据量小,不用快排 for (i = 0; i < MQ2_SAMPLE_NUM - 1; i++) { for (j = 0; j < MQ2_SAMPLE_NUM - 1 - i; j++) { if (buf[j] > buf[j + 1]) { tmp = buf[j]; buf[j] = buf[j + 1]; buf[j + 1] = tmp; } } } for (i = (MQ2_SAMPLE_NUM - MQ2_KEEP_NUM) / 2; i < (MQ2_SAMPLE_NUM + MQ2_KEEP_NUM) / 2; i++) { sum += buf[i]; } return (uint16_t)(sum / MQ2_KEEP_NUM); }

采样参数是可调的,不要盲目照抄。如果你做的是燃气泄漏这类需要快速响应的场景,可以把采样数降到 5 次,只去掉最大最小各 1 个,总耗时从 500ms 缩短到 250ms;如果你做的是室内空气质量监测,不要求响应速度,可以把采样数提高到 20 次,滤波效果更好。判断标准只有一个:滤波后的曲线能否跟上真实的烟雾变化。

这段代码写完后,串口打印MQ2_ReadStable(),正常情况下的读数应该是一条平稳的线,偶尔有小幅波动。如果还是锯齿状乱跳,问题大概率在硬件:检查 AO 到 PA0 的线是否过长、5V 电源纹波是否过大、模块是否靠近发热源。

4. 从电压到浓度:标定方法、对数拟合与阈值判断的取舍

读回稳定的 ADC 值只是完成了“感知”,离“报警”还差两个关键步骤:把电压换算成浓度,给浓度设一个不会乱响的报警逻辑。这章是整个程序最容易被低估的部分,很多项目死在“拿出厂阈值直接上电”。

4.1 标定不是对着空气测一下:基准电压、预热时间的标准动作

MQ2 的个体差异很大,同一批次的两块模块,在相同环境下的 AO 电压可能差 20% 以上。所以“标定”不是可选步骤,而是每块板子上电后必须做的动作。标准流程是:先给模块通电预热 10 分钟以上,让加热丝和敏感体达到热平衡;然后在确认无烟雾、无明显气味的干净空气下标定;最后把测到的电压换算成 R0 存到程序里。

具体操作时,我会在程序里加一段“标定模式”:长按按键进入标定,程序连续采集 10 次MQ2_ReadStable()取平均,把这个电压值换算成 R0,写入 Flash。这样量产时每块板子都可以在生产线上快速标定,不需要人工记录数据。标定代码的核心就三行:

// 标定:在干净空气下调用,返回值是当前环境的 R0 uint32_t MQ2_CalibrateR0(void) { uint16_t adc_val = MQ2_ReadStable(); uint32_t raw_mv = (uint32_t)adc_val * 3300 / 4095; // 还原成毫伏 // Vout = VCC * RL / (RS + RL) => RS = RL * (VCC - Vout) / Vout uint32_t rs = (uint32_t)MQ2_RL_OHM * (5000 - raw_mv) / raw_mv; return rs; // 这就是 R0 }

注意这里的 3300 是 F103 的 ADC 参考电压,如果你的板子参考电压不是精确的 3.3V(有些板子用 2.5V 基准),必须实测并用实测值替换。

标定中最常见的错误是在模块刚上电时就采集基准。冷态的 MQ2 输出和热平衡后相差很大,我曾见过新模块上电时 AO 电压只有热平衡后的 60%,拿这个值做基准,等模块热起来后误报一整天。所以预热时间不是玄学,是敏感材料的物理特性决定的硬约束。

4.2 浓度换算的三种做法:查表、对数线性拟合、分段线性

拿到 R0 之后,每次采样的电压都能算出当前的 RS,再算 RS/R0 比值。这个比值和气体浓度的关系,在传感器手册上是一条对数坐标下的近似直线:log(ppm) 与 log(RS/R0) 呈线性关系。理论上有三种处理方式,我按实用程度排序:

第一种是对数线性拟合,也是最推荐的做法。从手册曲线上取两个点,算出斜率和截距,程序里用expf和logf实时计算。第二种是查表法,把对数曲线上的一系列点预先算好,放进常量数组,运行时二分查找加线性插值。适合不想引入浮点库的场景。第三种是分段线性,把曲线切成几段,每段用直线近似,精度最低但也最简单。

下面是对数拟合的代码实现。注意拟合系数要自己标定,下面代码里的-1.8和3.6是我用来演示的示范值,不是通用值。每个项目都必须根据实际传感器和标定气体重新拟合。

// 转换:ADC 原始值 -> 标称 ppm // 公式:log(ppm) = a * log(RS/R0) + b // a、b 由手册或实测标定点拟合得到 int MQ2_ToPpm(uint16_t adc_val) { uint32_t raw_mv = (uint32_t)adc_val * 3300 / 4095; float rs = 0.0f; if (raw_mv == 0) raw_mv = 1; // 防除零 rs = (float)MQ2_RL_OHM * (5000.0f - raw_mv) / raw_mv; float ratio = rs / (float)MQ2_R0; // 示范系数,真实项目必须重新拟合 float log_ppm = -1.8f * logf(ratio) + 3.6f; int ppm = (int)(expf(log_ppm) * 100.0f); if (ppm < 0) return 0; if (ppm > 10000) return 10000; // 截断,防止异常值 return ppm; }

这段代码里有两个“工程味道”很浓的细节。一是raw_mv == 0的防除零保护,AO 引脚如果意外接地,除零会让浮点运算直接崩掉;二是浓度截断到 10000ppm,MQ2 对高浓度会进入饱和区,输出不再线性,截断能避免界面显示离谱数字。

拟合参数怎么来?最可靠的办法是用标准气体标定,但业余条件下做不到。退而求其次:用打火机气体(丁烷)做定性标定,先记录干净空气下的读数,再记录靠近气源后的读数,算出这两个状态下的 log(RS/R0) 差值,用它来设置“相对报警阈值”。对大多数烟雾报警场景,绝对 ppm 并不重要,重要的是“当前值比基线高了多少”。

4.3 报警判断别只比大小:回差(迟滞)与防抖动

最朴素的报警逻辑是if (ppm > 400) 报警,但实际运行起来一定会翻车。因为浓度在阈值附近波动时,程序会在“报警-解除-报警-解除”之间反复横跳,蜂鸣器听起来像在发摩斯电码。解决办法是引入回差,也叫迟滞:触发报警的阈值高,解除报警的阈值低,中间留一个缓冲区。

我常用的参数是触发 400ppm、解除 300ppm。浓度升到 400 报警,之后要降到 300 以下才会解除。100ppm 的回差区间让系统对阈值附近的波动不敏感。如果误报还是频繁,加大回差到 200ppm;如果报警响应太迟钝,缩小回差。

#define MQ2_ALARM_PPM 400 // 触发报警的浓度 #define MQ2_RELEASE_PPM 300 // 解除报警的浓度,低于这个才关 void MQ2_AlarmLoop(void) { static uint8_t alarm_flag = 0; int ppm = MQ2_ToPpm(MQ2_ReadStable()); // 用回差避免在阈值附近抖动 if (alarm_flag == 0 && ppm >= MQ2_ALARM_PPM) { alarm_flag = 1; HAL_GPIO_WritePin(BEEP_GPIO_Port, BEEP_Pin, GPIO_PIN_SET); } else if (alarm_flag == 1 && ppm < MQ2_RELEASE_PPM) { alarm_flag = 0; HAL_GPIO_WritePin(BEEP_GPIO_Port, BEEP_Pin, GPIO_PIN_RESET); } }

alarm_flag是关键,它把“当前是否在报警”作为状态记忆下来,而不是每次都独立判断。这样触发了就锁住,直到浓度明显下降才解锁。我建议再加一个“持续确认”:连续 3 次采样都超过 400ppm 才触发报警,进一步滤掉瞬态干扰。代码上就是在alarm_flag == 0的分支里加一个计数变量,连续超标才拉高蜂鸣器。

回差加持续确认,这套组合拳能解决 90% 的误报问题。剩下的误报根源是基线漂移,这在第 5 章的踩坑记录里单独讲。

5. MQ2 + STM32F103 的避坑排查:五条实战踩坑记录

这章写的都是我在调试 MQ2 和 F103 时真实遇到过的现象。每一条的排错思路都通用,你可以按“现象 → 原因 → 解决”的顺序对照排查。

5.1 现象:上电立刻报警,蜂鸣器停不下来

刚写完报警逻辑,一上电蜂鸣器就叫,而且怎么等都不停。这时候先别怀疑代码,大概率是以下三个原因之一:模块没有预热,刚上电的 RS 偏低导致读数虚高;标定基准是在冷态下测的,和热平衡后的实际值差很远;DO 引脚被误配成了推挽输出且默认电平为高。

我的排查顺序是:先用串口打印原始 ppm 值,看它是不是真的超标。如果 ppm 显示几千,说明是传感器状态问题——断电等模块冷却,重新上电后预热 10 分钟再观察;如果 ppm 正常但蜂鸣器仍响,检查 GPIO 配置,确认用的是HAL_GPIO_WritePin拉低还是拉高触发,不同蜂鸣器模块的电平逻辑不一样。

解决办法分两层:软件上在开机后加 120 秒的“静默期”,期间不判断报警只采集数据,等模块稳定后再进入正常逻辑;硬件上确保标定动作在预热完成后执行,不要在产线上一上电就按标定键。

5.2 现象:读数跟着系统电压波动,DCDC 纹波灌进 AO

板子接上电机或继电器后,ADC 读数开始周期性波动,波动幅度能到几百个 LSB。量 AO 对 GND 的电压,发现它确实在跳,这就不是 STM32 的问题,而是电源被污染了。

MQ2 的加热丝是纯阻性负载,电流 150mA 左右,如果和电机、继电器共用同一路 5V 电源,负载变化会让 5V 电压跟着起伏,AO 输出自然不稳。更隐蔽的是 DCDC 的开关纹波,它可能直接通过地线耦合到 ADC 参考端。

解决:把 MQ2 的供电和 MCU 供电分开,只共地;AO 到 PA0 之间加一个 RC 低通,100Ω 电阻串联加 10μF 电容到地,截止频率约 160Hz;如果纹波还是大,在软件里把采样次数从 10 次提高到 20 次,平均掉周期性的噪声。

5.3 现象:同一块板子换个房间数值差一截

板子在客厅标定好,拿到厨房数值整体高了 300ppm,或者拿到地下室数值低了一截。这不是传感器坏了,而是 MQ2 对温度和湿度都敏感,环境的绝对湿度和交叉气体(酒精、油烟、香水)都会改变敏感体电阻。

这种漂移没法靠“重新标定一次”根治,因为环境还在变。我常用的工程解法是引入动态基线:开机后持续跟踪一段时间内的最低读数,把它当成“当前环境的基线浓度”,报警判断用“当前值减基线”而不是“当前绝对值”。这样换房间、换季节,系统都能自动适应。

注意动态基线不是万能的,它会把缓慢泄漏也当成基线吃掉。折中方案是把基线更新周期设成 1 小时,并且只在系统未报警时更新。第 6 章会给具体实现。

5.4 现象:单片机重启后第一次采集总是跳变

复位后第一次读 ADC,偶尔会读出一个接近满量程的异常值,之后恢复平直。原因是 ADC 上电后参考电压还没完全稳定,或者 PA0 之前由别的外设复用,切换回模拟功能后第一次转换的采样电容状态不对。

解决很简单:初始化完成后主动弃采两次,或者让MQ2_ReadStable()在排序平均前丢弃每组采样的前两个值。代码上就是在MQ2_ReadRaw()后面补两个空读:

uint16_t MQ2_ReadRaw(void) { HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 50); // 第一次弃采,等参考稳定 HAL_ADC_GetValue(&hadc1); HAL_ADC_Start(&hadc1); if (HAL_ADC_PollForConversion(&hadc1, 50) == HAL_OK) { return (uint16_t)HAL_ADC_GetValue(&hadc1); } return 0xFFFF; }

这个习惯对任何 ADC 采集项目都适用。你要是用到了多通道切换,每个通道切换后最好也弃采一次,因为采样电容里的残留电荷需要时间泄放。

5.5 现象:AO 悬空时随机出数,数据里偶尔冒出 4095

模块没插稳,或者漏焊了一个引脚,AO 线悬空,这时 ADC 读出的数值没有任何规律,偶尔还会跳到满量程 4095。这是浮空输入的典型表现——引脚电压由漏电流和空间电场决定,完全不可控。

解决:先从硬件查起,用万用表量 AO 对 GND 的电阻,正常应该在几千欧到几十千欧,如果量出来是无穷大,说明模块内部断路或线没焊上。软件侧做一个合理性检查,ADC 值超过 4000 时视为无效帧,丢弃重启采样。

不要试图用 GPIO 内部上拉解决问题,模拟输入模式下内部上拉是不生效的,而且会破坏 AO 的输出阻抗匹配。

5.6 调试思路:四步倒查法,别一上来就怀疑 STM32

把上面的踩坑经验串起来,我排错时永远按这个顺序:第一步看原始 ADC 值是否随环境变化,如果静止不动,问题在传感器或接线;第二步看电压范围是否合理,0.2V 到 1V 是正常区间,超出说明供电或模块异常;第三步看滤波后的曲线形状,锯齿状说明采样参数不合适或电源有纹波;第四步才回头看阈值判断逻辑。

直接跳到最后一步去调阈值,往往会把代码改得面目全非,最后发现是传感器没预热。四步倒查法能让你在十分钟内定位到根因。

6. 进阶技巧:用自动基线跟踪和串口曲线,把漂移从坑变成信号

如果上面的功能都稳定了,恭喜你已经拥有一个能工作的烟雾报警器。这章再往前走一步,解决最后几个“看着能用、用着难受”的问题。

6.1 自动基线跟踪:一分钟滑动最小值的“假基线”

针对第 5.3 节的漂移问题,我用的方案是滑动最小值基线。每 60 秒更新一次基线值,取这段时间内读到的最小浓度作为“当前环境的干净水平”。判断报警时用当前浓度 - 基线浓度,而不是直接用绝对浓度。这段代码简单到只有十行左右:

static uint16_t base_ppm = 0; // 当前基线 static int min_ppm = 9999; // 周期内的最小值 static uint32_t last_reset = 0; void MQ2_AutoBaseUpdate(int ppm) { if (ppm < min_ppm) min_ppm = ppm; if (HAL_GetTick() - last_reset > 60000) { base_ppm = min_ppm; // 周期最小值成为新基线 min_ppm = 9999; last_reset = HAL_GetTick(); } } // 报警判断改用差值 int delta = ppm - base_ppm; if (delta > 200) { /* 触发报警 */ }

为什么用“最小值”而不用“平均值”?因为打火机气体一喷,浓度瞬间上升,平均值会被拉高,而最小值能保持在一个相对真实的环境底数上。真火情是突变信号,1 分钟内浓度不会回落到正常水平,所以这个“假基线”不会把火灾吃掉。只有在系统未报警时才调用更新,否则基线会被真实浓度污染。

6.2 用串口打印曲线:一根杜邦线上的简易仪表盘

最后分享一个调试习惯:把浓度数据实时打印到串口,配合串口绘图工具看波形。不花钱、不接屏,就能把传感器的响应曲线看得清清楚楚。

printf("ADC=%4d ppm=%5d base=%5d delta=%5d\r\n", adc_val, ppm, base_ppm, delta);

注意格式里用了\r\n而不是\n,很多串口绘图工具和上位机按回车来刷行缓存,只发换行会导致显示错位。波特率固定用 115200,这个速度下每秒打印两行完全不会堵塞主循环。

我调试时习惯把原始 ADC 值、换算 ppm、基线 ppm 和差值全部打出来,而不是只打印最终结果。因为当报警逻辑出错时,能看到每一层的输入输出,能立刻判断是采集、换算还是判断环节出了问题。这个习惯帮我省掉过数不清的翻车时间。

某开发者跟我抱怨过,他之前用某个 MQ2 模块的 DO 引脚做产品,卖出去几十台,三个月后反馈误报率飙升,最后发现是电位器受潮漂移。我后来做这类项目,无论多着急,都会把 AO 通道留出来,软件里保留一条“原始读数调试开关”,把阈值判断全部交给代码而不是电位器。这个习惯让后续维护轻松太多——改一行参数就能调整灵敏度,不用拆机。

希望这套方案能帮你把基于 MQ2 烟雾传感器的 STM32F103 程序从“能读数”做到“能信”,少走几段弯路。

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

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

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

立即咨询