ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32+MQ9气体报警器实战:从ADC采样到阈值校准的完整工程指南

STM32+MQ9气体报警器实战:从ADC采样到阈值校准的完整工程指南 简介资源围绕STM32单片机与MQ9气体传感器提供了一套可燃气体浓度监测与报警系统的完整源代码面向嵌入式初学者及物联网开发者解决数据采集、阈值报警、屏幕显示和串口上报等常见问题。项目以STM32为核心实时读取MQ9输出超限时蜂鸣器发声同时通过串口将数据发送到调试助手并利用OLED展示浓度与报警状态便于实时观察和二次开发。包内共237个文件压缩包大小6.61MB以36个C源文件与36个头文件为主覆盖ADC、定时器、I2C、OLED驱动等外设模块同时包含Keil工程文件和编译中间文件可直接导入工程。已有295人学习适合想系统掌握STM32外设驱动与传感器处理流程的开发者。阅读源码可学习硬件初始化、数据读取、报警逻辑、串口通信和界面绘制等关键实现也可扩展远程通信或智能家居联动功能实践价值较强。1. STM32MQ9报警器为什么总在“漂数”先拆工程再动手调试一个新焊的板子MQ9 输出的 ADC 值刚上电是满量程放几分钟又一路走低串口调试助手用 XCOM 看波形像心跳一样调了一下午最后发现不是代码问题而是传感器没预热阈值设得太武断。这个工程把 STM32F103 的 ADC、I2C、Flash、串口全用上了。工程里的 stm32f10x_adc.c、OLED.c、stm32f10x_i2c.c、stm32f10x_flash.c 说明它不是一个只有点灯的 demo而是“采集-显示-报警-通信”的完整链路。适合传感器课设、单片机比赛、入门 STM32 气体检测的人。拿到工程先看硬件连接再看滤波和阈值判断最后才是串口。2. 先把硬件立住MQ9的模拟输出、OLED的I2C地址和蜂鸣器驱动2.1 MQ9传感电路分析加热电阻、负载电阻与ADC输入范围MQ9 是半导体气体传感器内部有一根加热丝和一层二氧化锡。加热丝要保持通电传感器才能维持敏感层的工作温度所以两个电极直接接 5V 而不是 3.3V。敏感层等效为一个可变电阻 Rs空气中 Rs 较大可燃气体浓度升高后 Rs 下降。模块上的 AO 输出本质是 Rs 和 RL 对参考电压 VC 的分压表达式为AO VC × RL / (RL Rs)浓度升高Rs 下降AO 上升。工程代码里要做的只是一直读 ADC 值跟上这个变化。但第一步不是写代码而是确认 AO 的电压范围。MQ9 模块常见供电是 5V有些模块的 AO 在浓度很高时会逼近 5VSTM32F103 的 ADC 引脚在 3.3V 域内虽然很多 GPIO 标注了 5V 容忍但模拟输入长期超过 VREF 会读出 4095 并且影响可靠性。稳妥做法是加一个 2k:1k 的分压网络把最大 5V 降到 3.3V如果工程里直接接 PA0 且模块供电 3.3V就要查模块手册确认 AO 能不能满量程。代码里如果出现满量程 0x0FFF先查这一级而不是滤波。MQ9 模块一般还带一个 DO 数字输出由模块上的 LM393 比较器给出二进制电平转电位器可调阈值。这个工程用 STM32 的 ADC 读模拟量并自己控制蜂鸣器DO 不建议接给 MCU否则电位器设置的阈值会和代码里的阈值形成“双阈值”逻辑排查时容易互相干扰。2.2 STM32F103引脚分配与OLED I2C接线工程里的 OLED.c 使用 stm32f10x_i2c.c 做底层常见有三种实现硬件 I2C1、GPIO 软件模拟 I2C、或者直接复用现成 OLED 库。0.91 寸 OLED128x32与 0.96 寸 OLED128x64都是 SSD1306 驱动I2C 地址多数是 0x3C。拿到 OLED 模块后先写个扫描程序确认地址不然显示全是雪花。一个和工程匹配度较高的引脚分配表如下设备STM32 引脚说明MQ9 AOPA0ADC1_IN0分压后采样不能超过 VREFOLED SCLPB6 或任意 IO软件 I2C 可任意映射硬件 I2C1 默认 PB6OLED SDAPB7 或任意 IO与 SCL 配合蜂鸣器PB12三极管驱动不直接推蜂鸣器USART1 TXPA9接 USB 转串口 RXUSART1 RXPA10接 USB 转串口 TXPA0 对应 ADC1 通道 0所以初始化里写ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5)。OLED 不要同时挂在硬件 I2C1 和软件模拟上工程里如果 OLED.c 内部自己操作 GPIO就不要再配置 I2C1 外设否则两套代码同时抢总线。我一般直接沿用工程里的 OLED.c把 SCL/SDA 的宏在头文件里改成实际引脚省事。RCC_APB2PeriphClockCmd、RCC_AHBPeriphClockCmd 这些时钟使能函数都来自 stm32f10x_rcc.c它决定 GPIO 和 ADC 外设的门控漏了任何一句初始化都会卡死在程序里。2.3 蜂鸣器与电源用三极管驱动而不是直接推GPIO有源蜂鸣器内部自带振荡源只要给电就响典型工作电流在 20~40mA。STM32 GPIO 的拉电流能力有限直接推蜂鸣器不仅声音小还可能把 IO 拉垮。常见驱动电路是S8050 三极管基极串 1kΩ 电阻接到 PB12发射极接 GND集电极接蜂鸣器负极蜂鸣器正极接 5V蜂鸣器两端并联一个 1N4148 反向续流二极管。GPIO 输出高电平时三极管导通蜂鸣器响。初始化代码只需三行配置GPIO_InitTypeDef GPIO_InitStruct; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStruct.GPIO_Pin GPIO_Pin_12; GPIO_InitStruct.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStruct.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStruct);工程文件里有 stm32f10x_tim.c说明可能用定时器做延时或者报警节奏。如果项目用无源蜂鸣器需要 TIM 输出 2~4kHz 的 PWM 才有声音有源蜂鸣器则不必直接用 GPIO 翻转就行。MQ9 加热丝电流在百毫安级和 OLED 模块共用同一路 5V 时OLED 刷新瞬间的电流波动会耦合到 MQ9 的 AO 上ADC 采样值尾数不稳硬件设计时建议 MQ9 的 VCC 引脚就近放一颗 100nF 陶瓷电容模拟信号线再串一个 1kΩ 电阻进 PA0能少很多软件滤波工作量。3. 标准外设库下的数据链路从ADC到OLED再到串口调试助手3.1 ADCDMA连续采集让MQ9模拟量先变成稳定的数字量主循环里可以软件触发 ADC然后读 DR 寄存器但工程里既然有 stm32f10x_adc.c用 DMA 更合理。MQ9 信号变化较慢DMA 循环把 8 个采样值放进数组主循环只读数组减少 ADC 模式切换带来的噪声。这里以标准外设库为例#define ADC_BUF_SIZE 8 static uint16_t adc_buffer[ADC_BUF_SIZE]; void MQ9_ADC_Init(void) { GPIO_InitTypeDef GPIO_InitStruct; ADC_InitTypeDef ADC_InitStruct; DMA_InitTypeDef DMA_InitStruct; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); GPIO_InitStruct.GPIO_Pin GPIO_Pin_0; GPIO_InitStruct.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStruct); DMA_DeInit(DMA1_Channel1); DMA_InitStruct.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; DMA_InitStruct.DMA_MemoryBaseAddr (uint32_t)adc_buffer; DMA_InitStruct.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStruct.DMA_BufferSize ADC_BUF_SIZE; DMA_InitStruct.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStruct.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStruct.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStruct.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStruct.DMA_Mode DMA_Mode_Circular; DMA_InitStruct.DMA_Priority DMA_Priority_High; DMA_InitStruct.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel1, DMA_InitStruct); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_InitStruct.ADC_Mode ADC_Mode_Independent; ADC_InitStruct.ADC_ScanConvMode DISABLE; ADC_InitStruct.ADC_ContinuousConvMode ENABLE; ADC_InitStruct.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; ADC_InitStruct.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStruct.ADC_NbrOfChannel 1; ADC_Init(ADC1, ADC_InitStruct); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_239Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); ADC_SoftwareStartConvCmd(ADC1, ENABLE); }这里有几个参数值得说。DMA_BufferSize 设为 8主循环每轮可以拿 8 个样值做滤波如果发现 MQ9 输出高频抖动优先加大 BufferSize 而不是提高 ADC 采样频率。PeripheralInc 和 MemoryInc 一个关一个开是因为外设地址固定不变而内存地址要连续推进。ADC_SampleTime 用到 239 周期是因为 MQ9 模块输出阻抗较高采样时间太短会采到电容放电以后的值读数偏低且不稳。3.2 中值滤波与阈值判断逻辑不要拿单次ADC值做报警MQ9 对气流敏感人一走过或者门关一下AO 就会跳变。直接 if (adc 阈值) 开蜂鸣器静态气体环境也可能误报。工程里应该有一段数据平滑推荐中值滤波而不是滑动平均因为它能直接削掉尖峰。uint16_t MQ9_ReadFiltered(void) { uint16_t i, j, temp; uint16_t buf[ADC_BUF_SIZE]; for (i 0; i ADC_BUF_SIZE; i) { buf[i] adc_buffer[i]; } for (i 0; i ADC_BUF_SIZE - 1; i) { for (j i 1; j ADC_BUF_SIZE; j) { if (buf[j] buf[i]) { temp buf[i]; buf[i] buf[j]; buf[j] temp; } } } return buf[ADC_BUF_SIZE / 2]; }排序后取中间值 buf[4]。冒泡排序只有 8 个元素在 72MHz 的 MCU 上开销可忽略。接着是报警判断uint16_t raw MQ9_ReadFiltered(); if (raw ALARM_THRESHOLD_RAW) { BEEP_ON(); OLED_ShowStr(0, 2, ALARM!); } else { BEEP_OFF(); }阈值不要直接写 800、900 这种拍脑袋值。我一般用一个宏#define ALARM_THRESHOLD_RAW (baseline 180)把“上电基线”和“变化量”分开。MQ9 的绝对输出会随环境温湿度变化同一个传感器在不同地方基线可能差三四百只看绝对 ADC 值几乎没法用一个固定阈值覆盖所有场景。3.3 OLED显示与串口调试助手输出printf重定向到USART1工程要求把 MQ9 数据发送到串口调试助手最常见的做法是把 printf 重定向到 USART1#include stdio.h int fputc(int ch, FILE *f) { USART_SendData(USART1, (uint8_t)ch); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); return ch; } printf(MQ9 ADC%u, L%d\r\n, raw, alarm_level);注意只加 fputc 不够Keil 里需要勾选 MicroLIB否则会引入大量文件系统相关代码。代码里如果用 OLED_ShowNum 显示小数也要改显示函数因为 SSD1306 不带字库显示一个小数点要把点阵塞进字模表。OLED 显示汉字同理字模取逐行式、高位在前否则字会左右颠倒。串口调试助手的输出帧设计得简单一点方便在 XCOM、SSCOM 或者正点原子串口调试助手里看。推荐文本模式一帧固定字段字段示例含义时间戳[1000ms]开机后运行时间原始值ADC1528滤波后 ADC 数值变化量D120相对基线的增量报警等级L10 正常1 预警2 报警OLED 刷新速度不用太快气体浓度变化是秒级的每 200ms 刷一次即可。0.91 寸 OLED 128x32 一屏能放两行大字号建议第一行显示 ADC 原始值第二行显示状态不要在同一屏塞下所有调试信息。4. 从keilkill.bat到串口乱码编译、下载、调试里的实际问题4.1 keilkill.bat清理Keil工程临时文件的用法与副作用工程文件里有个 keilkill.bat这是 Keil 工程的清理脚本。用 Keil5 编译一次之后Objects 和 Listings 目录里会留下 .axf、.crf、.o、.d 等中间文件一个几十 KB 的源码工程膨胀到几十 MB。发布工程代码时应该把中间文件清掉只保留 .uvprojx、.uvoptx、.c、.h、startup_stm32f10x_hd.s 和芯片库。一个能用的清理脚本echo off del /s /q *.uvguix.* *.axf *.crf *.d *.o *.htm *.map *.dep rmdir /s /q Objects rmdir /s /q Listings echo Keil temp files cleaned.注意*.uvguix.*是用户图形界面布局文件不删除会影响本机调试窗口布局但发布时最好不要保留因为它里面带有机器相关的绝对路径。删除之后重新编译Keil 会重建工程。如果重新编译后仿真时“当前不会命中断点”先看是不是把启动文件 startup_stm32f10x_hd.s 也误删了Keil 工程会找不到复位向量这是清理脚本最常见的翻车点。4.2 串口乱码的定位晶振频率、波特率与USB转串口串口调试助手上面全是乱码大多数情况不是业务代码错了而是三个点没对上TXD/RXD 接反、波特率不一致、时钟频率和代码里的宏不一致。STM32F103 的 SystemInit 默认认为外部晶振是 8MHz然后倍频到 72MHz。如果开发板焊的是 12MHz 晶振而工程里 stm32f10x.h 的 HSE_VALUE 还写着 8000000那么实际产生的波特率会和配置的 115200 差很大。排查顺序很简单用杜邦线确认 USB 转 TTL 的 TX 接到 PA10USART1_RXRX 接到 PA9USART1_TXGND 必须共地。打开 stm32f10x.h把#define HSE_VALUE ((uint32_t)8000000)改成 12000000或者换成正点原子对应型号的工程模板。串口调试助手里波特率选 115200 或 9600数据位 8停止位 1无校验不要开 HEX 显示。如果输出有可读的MQ9 ADC...但偶发乱码降低波特率到 9600或者换个 USB 转 TTL 模块很多“随机乱码”来自劣质 CH340 的电源纹波。下面这张表可以对照排查现象可能原因快速验证完全无输出RX/TX 接反或未共地用杜邦线对调 TX/RX全是乱码波特率不一致9600 与 115200 来回切字符偶尔错晶振频率不对检查 HSE_VALUE 和实际晶振输出有重复前缀printf 重定向多份检查 fputc 是否被重复定义4.3 MQ9预热与阈值设定ADC原始值和ppm之间的关系陷阱很多 MQ9 教程会给出 Rs/R0 与 ppm 的曲线图但项目里如果真的用 ppm你会发现标准气体难找校准一次要花大量时间而且 MQ9 在一氧化碳和甲烷两种气体下灵敏度曲线不同。作为工程原型我建议先不碰 ppm直接用 ADC 原始值做梯度报警。先让系统上电预热 5 到 10 分钟让敏感层温度稳定再采集 baselineuint16_t baseline 0; uint8_t calibrated 0; if (calibrated 0) { baseline MQ9_ReadFiltered(); calibrated 1; } uint16_t raw MQ9_ReadFiltered(); uint16_t delta (raw baseline) ? (raw - baseline) : 0; if (delta ALARM_THRESHOLD_DELTA) { BEEP_ON(); } else { BEEP_OFF(); }这段代码的关键是不用绝对 ADC 值而是用“相对基线增量”。因为 MQ9 的原始输出在上电后会慢慢漂冬天和夏天的基线差也很大直接用绝对阈值必然导致某些环境下误报。但增量阈值的坑在于如果泄漏一直在持续baseline 会被不断增长的真实数据污染。常见做法是在一个较长时间窗口内取最小值作为基线而不是全程固定。只有当当前读数比 baseline 小 20 个码以上且持续时间超过 30 秒才整体下调 baseline点击一次串口指令可强制更新 baseline这个方法放到下一章展开。5. 离线校准与调试技巧不买标定气体也能用的分段阈值方案5.1 用串口指令切换校准模式和运行模式工程里既然有 stm32f10x_flash.c最值得利用的是把校准后的基线写进 Flash。不用买标准气体做法是在干净空气环境里预热 10 分钟然后往串口发送CAL\r\nMCU 连续采集 200 次取平均作为新基线并写入 STM32 内部 Flash 最后一页#define CAL_ADDR 0x0803F800 void SaveBaseline(uint16_t baseline) { FLASH_Unlock(); FLASH_ErasePage(CAL_ADDR); FLASH_ProgramHalfWord(CAL_ADDR, baseline); FLASH_Lock(); } uint16_t LoadBaseline(void) { return *(uint16_t*)CAL_ADDR; }写入 0x0803F800 之前先确认它不在工程代码段里。STM32F103C8T6 的 Flash 是 64KB最后 1KB 起始地址正好是 0x0803F800程序容量不大时这个页不会被占用。FLASH_Unlock 和 FLASH_Lock 要配对连续多次操作 Flash 不重新解锁会触发超时错误。5.2 分段报警阈值把“响不响”拆成“安静、预警、报警”只用一个阈值时蜂鸣器要么不响要么疯狂响。可以把 delta 拆成两档超过预警值蜂鸣器间歇响 200ms超过报警值持续响。这样轻微波动不会直接触发全功率报警也让现场确认更有层次if (delta ALARM_HIGH) { BEEP_ON(); } else if (delta ALARM_WARN) { BEEP_TOGGLE_PERIOD_200MS(); } else { BEEP_OFF(); }刷新 OLED 时把档位显示成WARN或HIGH使用者不用看数字也能判断状态。阈值标定可以用一个定性方法拿打火机放气但不点火在传感器旁晃动观察串口里 ADC 值升到多少取最低报警档的 70% 左右作为预警阈值。注意打火机气体浓度高离传感器太近会让传感器进入过冲区读数回落后还要稳定一段时间所以每次测试间隔至少 30 秒。实际部署后再根据环境温湿度把阈值微调到自己需要的灵敏度。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进