STM32+MQ4气体检测报警器:ADC采样、OLED显示与串口上报全链路实现
2026/9/12 3:20:51 网站建设 项目流程

简介:一套基于STM32单片机与MQ4气体传感器的天然气浓度监测报警系统完整工程代码,面向嵌入式入门开发者及电子设计爱好者,可用于家庭、工厂等场景的可燃气体泄漏检测。压缩包共232个文件,大小仅6.48MB,以C语言源码(.c/.h)、编译中间文件(.o/.d/.crf)及Keil工程文件(.uvprojx)为主,并包含OLED驱动、定时器、ADC等底层模块源码,结构清晰,便于直接查阅与二次开发。目前已有588人学习下载。工程覆盖系统初始化、MQ4数据采集与阈值判断、OLED屏实时显示、蜂鸣器报警以及串口数据上传等核心环节,代码注释与模块划分较规范,可帮助读者快速掌握STM32的GPIO、ADC、I2C、串口等外设编程方法,同时为完整的嵌入式软硬件联调提供实践参考。

1. 从一颗甲烷传感器讲起:为什么这个工程值得拆开看

做嵌入式这几年,真正把"采集-处理-显示-报警-上报"一条链路完整跑通的例子其实不多。很多项目停留在点灯或者读个温湿度,一旦涉及模拟量前端、阈值联动和串口交互,就暴露出各种接地问题。这个STM32+MQ4+OLED+蜂鸣器的工程恰好覆盖了完整闭环:MQ4输出模拟电压,STM32通过ADC采样换算成浓度,OLED实时绘制,超限时蜂鸣器动作,同时把数据送往串口调试助手。它看起来像一个课设,实际却把工业气体报警器的最小可行模型做出来了——硬件上省掉了运放和比较器,完全依赖MCU内部ADC和软件滤波,这种做法的边界在哪里、精度能到多少、现场部署要改什么,正是值得逐层拆解的内容。

工程本身基于标准外设库而非HAL,这对想从寄存器层面理解ADC和串口的人反而是优势。文件列表里的stm32f10x_adc.c、stm32f10x_tim.c、OLED.c和i2c驱动,基本对应了该项目最核心的四个功能模块。接下来按"硬件接线与初始化顺序→ADC采集与浓度换算→串口上报与OLED联动→蜂鸣器驱动与抗干扰"这条路径展开,代码可以直接抄,参数也能直接改。

2. 硬件选型与引脚分配:先看懂这张接线表再动手

MQ4传感器的输出特性决定了整个软件架构:它内部是加热电阻和二氧化锡气敏层,空气中甲烷浓度升高时电导率变化,通过负载电阻转化为0~5V的模拟电压。注意,MQ4模块分数字输出和模拟输出两路,本项目必须接模拟输出(通常标着AO),数字输出(DO)只接比较器阈值,精度完全不能用。STM32F103的ADC输入范围是0~3.3V,所以MQ4模块的VCC不能接5V,要接3.3V,否则ADC会削顶。这是新手最容易摔的第一个跟头:接5V供电、输出超过3.3V、ADC读数满量程、浓度曲线完全失真。

OLED用的是I2C接口的0.96寸屏(SSD1306控制器),SDA和SCL分别接到PB7和PB6(I2C1),蜂鸣器建议用有源蜂鸣器接PC13,高电平驱动即可——有源蜂鸣器内部带振荡源,只要给电平就响,不需要PWM。如果想用无源蜂鸣器,就要把PC13改成TIM定时器输出PWM,这个后面单独讲。以下是完整的引脚分配表,照这个接线可以少走一半弯路:

外设引脚STM32端口说明
MQ4模块AOPA1模拟输入,接ADC1通道1
MQ4模块VCC3.3V严禁接5V,会超ADC量程
OLEDSDAPB7I2C1数据线,需上拉
OLEDSCLPB6I2C1时钟线,需上拉
蜂鸣器I/OPC13高电平触发,有源蜂鸣器
串口TXPA9USART1发送,接USB转TTL的RX
串口RXPA10USART1接收,接USB转TTL的TX
地线GNDGND所有模块共地

2.1 初始化顺序:时钟、GPIO、ADC、I2C、USART一个都不能乱

我见过很多人先初始化外设再配时钟,结果I2C通信死活拉不起来。标准外设库的初始化顺序有一个隐性要求:必须先开RCC时钟,再配置GPIO模式,最后初始化外设功能。下面这段是工程初始化逻辑的精简版,覆盖了ADC和I2C的时钟使能关键点:

void Init_All_Peripherals(void) { // 1. 开启各外设时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB | RCC_APB2Periph_GPIOC | RCC_APB2Periph_AFIO, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_USART1, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); // 2. 配置调试端口,释放PC13(避免JTAG占用) GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE); // 3. 初始化USART1:115200-8-N-1 USART_InitTypeDef usart; usart.USART_BaudRate = 115200; usart.USART_WordLength = USART_WordLength_8b; usart.USART_StopBits = USART_StopBits_1; usart.USART_Parity = USART_Parity_No; usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None; usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &usart); USART_Cmd(USART1, ENABLE); // 4. ADC1初始化:PA1模拟输入,软件触发单次转换 ADC_InitTypeDef adc; adc.ADC_Mode = ADC_Mode_Independent; adc.ADC_ScanConvMode = DISABLE; adc.ADC_ContinuousConvMode = ENABLE; adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; adc.ADC_DataAlign = ADC_DataAlign_Right; adc.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_239Cycles5); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while(ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while(ADC_GetCalibrationStatus(ADC1)); }

逻辑说明:时钟顺序上先APB2后APB1,因为ADC1、USART1、GPIO都在APB2总线上,而I2C1挂在APB1;GPIO_Remap_SWJ_JTAGDisable这一行特别关键,STM32F103的PC13默认被JTAG调试口占用,不关闭JTAG复用,PC13拉高会失败,蜂鸣器永远不会响。采样时间取239.5个周期是针对MQ4这种高内阻传感器的常见配置——MQ4的输出阻抗在几K到几十K之间,采样时间太短(比如1.5周期)会导致采样电容充不满,ADC读数跳动剧烈。校准流程里,ADC_Cmd之后要等校准完成位,跳过这步读出的值会有固定偏移,整个浓度曲线会整体平移。

2.2 OLED的I2C地址与初始化:0x78还是0x7A,取决于模块电阻

0.96寸OLED模块的I2C地址由背面的R8电阻决定,默认是0x78(7位地址0x3C),也有模块焊了R7变成0x7A。工程里OLED.c的初始化函数开头通常会有这个宏:

#define OLED_ADDRESS 0x78 // 如果显示乱码或没反应,改成0x7A试试 static void OLED_WriteCmd(uint8_t cmd) { I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, OLED_ADDRESS, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, 0x00); // 控制字节:0x00表示后续是命令 while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); I2C_SendData(I2C1, cmd); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); I2C_GenerateSTOP(I2C1, ENABLE); }

逻辑说明:这个函数是I2C写寄存器的标准时序——起始位、7位地址加写位、控制字节(0x00代表后面跟的是命令,0x40代表后面是显示数据)、数据字节、停止位。每个步骤都有对应的事件标志位需要查询。0x78是8位地址形式,它包含最后一位写标志(0),所以7位地址实际是0x3C。如果OLED没显示,用逻辑分析仪抓I2C波形,先看ACK位有没有拉低,再看地址对不对,硬件排查就这两步。初始化时序上,SSD1306要求先发0xAE关显示、0x20设内存寻址模式(推荐页寻址0x02,这样逐页写数据不跳行)、0xA8设多路复用比、0xD3设显示偏移、0x40设显示起始行、0x8D开电荷泵、0xA1段重映射,完整序列大约30条命令,工程里OLED_Init()已经写好,不需要改,唯一要确认的是地址和I2C速率,工程一般设400K,如果杜邦线超过20cm建议降到100K,否则数据线振铃会导致花屏。

3. ADC采样与浓度换算:从原始值到PPM的完整链路

MQ4的数据手册里有一条敏感度特性曲线,横轴是气体浓度(PPM),纵轴是Rs/R0比值(传感器电阻与清洁空气中电阻的比值),并且是双对数坐标。这意味着"ADC读到什么值对应多少PPM"不能直接线性换算,必须走对数插值或者查表。工程里常见的做法是分段线性插值,在双对数坐标上取几个校准点,然后按对数线性关系计算。先看最基础的ADC读取函数:

uint16_t MQ4_ReadADC(void) { ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_239Cycles5); ADC_SoftwareStartConvCmd(ADC1, ENABLE); while(!ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC)); return ADC_GetConversionValue(ADC1); } float MQ4_GetVoltage(void) { uint16_t adc_val = MQ4_ReadADC(); // STM32F103 ADC是12位,参考电压3.3V return (float)adc_val * 3.3f / 4096.0f; }

逻辑说明:ADC_RegularChannelConfig在每次转换前重新配置通道,是防止多通道切换时通道残留的稳妥写法——虽然本工程只有一路模拟输入,但这样写让代码在后面加温湿度传感器时不用改结构。读取电压后,下一步是换算Rs。MQ4模块的电路本质上是一个分压器:气敏电阻Rs与负载电阻RL(模块上通常印有10K标记)串联,3.3V供电时,Vout = 3.3 × RL / (Rs + RL),所以推导出Rs = RL × (3.3 - Vout) / Vout。这个公式的物理意义是:Vout越高,Rs越小,说明气体浓度越大。注意Vout不能等于0,否则分母为0,所以MQ4上电后需要预热至少3分钟,让Rs稳定在清洁空气的基准值R0附近,这个预热时间直接影响零点的准确性。

3.1 双对数坐标系下的浓度插值计算

有了Rs/R0比值后,浓度换算利用双对数坐标上的线性关系。数据手册给出的典型曲线可以用两个关键点近似:甲烷在200PPM时Rs/R0约等于1.7,在10000PPM时Rs/R0约等于0.5。在双对数坐标上,这两个点确定一条直线,浓度取对数后与Rs/R0的对数呈线性关系。代码实现就是用对数插值:

#define MQ4_RL 10.0f // 模块负载电阻,单位KΩ #define MQ4_R0_CLEAN 9.8f // 清洁空气中实测Rs值,预热后标定 float MQ4_GetRs(float voltage) { // 分压公式反推:Rs = RL * (Vcc - Vout) / Vout float rs = MQ4_RL * (3.3f - voltage) / voltage; return rs; } float MQ4_GetPPM(float rs) { // 双对数坐标线性插值:取(200, 1.7)和(10000, 0.5)两个校准点 float ratio = rs / MQ4_R0_CLEAN; // 对数坐标下的斜率:log(PPM) = log(PPM1) + (log(ratio) - log(ratio1)) // * (log(PPM2) - log(PPM1)) / (log(ratio2) - log(ratio1)) float slope = (log(10000.0f) - log(200.0f)) / (log(0.5f) - log(1.7f)); float ppm = exp(log(200.0f) + (log(ratio) - log(1.7f)) * slope); // 低于下限直接截断 if (ppm < 10.0f) ppm = 0.0f; if (ppm > 10000.0f) ppm = 10000.0f; return ppm; }

参数说明:log和exp是C标准库的数学函数,需要包含math.h,编译时在Keil里勾选"Use MicroLIB"可以缩小浮点运算的代码体积。斜率是负数——浓度越高、比值越小,所以斜率是负的,这个负号保证了PPM随Rs/R0下降而上升。两个校准点决定精度,但出厂模块和真实环境差异很大,实际工程中应该在洁净空气下标定R0——把MQ4放到室外通风处,上电预热10分钟后,连续读50次Rs取平均值作为R0_CLEAN,这个值每家模块都有差异,范围大约在8K到15K之间,不能用固定的9.8。另外还有一个工程技巧:MQ4对温度湿度敏感,同一浓度下夏天和冬天的读数可能偏差30%以上,所以在严谨的检测设备里会加温湿度补偿,本工程没有这个传感器,只能靠软件滤波抵消部分噪声。

3.2 滑动平均滤波:为什么单次采样值不能直接用

MQ4的模拟输出叠加了加热丝带来的工频噪声和气流扰动,单次采样值跳动幅度可以达到±150mV,换算成PPM就是几百的波动。直接拿这个值做阈值判断,蜂鸣器会频繁误触发。工程里常见的处理是滑动平均滤波,维护一个环形缓冲区,每次采样替换最旧的数据,再取均值:

#define FILTER_LEN 10 static uint16_t filter_buf[FILTER_LEN]; static uint8_t filter_index = 0; uint16_t MQ4_ReadFiltered(void) { uint32_t sum = 0; filter_buf[filter_index] = MQ4_ReadADC(); filter_index = (filter_index + 1) % FILTER_LEN; for (int i = 0; i < FILTER_LEN; i++) { sum += filter_buf[i]; } return (uint16_t)(sum / FILTER_LEN); }

逻辑说明:环形缓冲区的巧妙之处在于只需要一次取模运算就能让索引回绕,每次平均都遍历整个缓冲区,计算量是O(N)级别,N取10在72MHz主频下几乎不耗时。滤波长度与响应速度存在矛盾:滤波窗口越大、曲线越平滑,但对浓度突变的响应越慢。10次采样在连续转换模式下大约耗时10×239.5周期÷72MHz≈33微秒,这个延迟可以忽略。但要注意,如果开启连续转换模式且没有软件延时,ADC会以最快速度刷新滤波缓冲区,此时滤波窗口在时间轴上只覆盖几十微秒,对工频噪声(50Hz对应周期20ms)没有任何抑制效果。正确的做法是控制采样间隔,比如每100ms采样一次,让滤波窗口覆盖1秒的噪声周期。实现方式可以在主循环加delay,更稳妥的是用定时器触发ADC:

// TIM2定时100ms触发一次ADC转换(伪代码示意) void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update)) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); uint16_t val = MQ4_ReadFiltered(); float volt = (float)val * 3.3f / 4096.0f; float rs = MQ4_GetRs(volt); float ppm = MQ4_GetPPM(rs); // 这里把ppm存到全局变量供主循环使用 } }

参数说明:定时器中断里做浮点计算是嵌入式开发要尽量避免的——如果PPM计算耗时超过中断周期,会阻塞其他中断。更合理的拆分是:中断里只采样和滤波(整数运算),主循环里做浮点换算、OLED刷新和串口发送。100ms的采样间隔对气体检测足够了,甲烷扩散速度远慢于这个周期。如果要做更精细的滤波,可以在滑动平均之后再加一阶低通滤波:y[n] = y[n-1] + alpha × (x[n] - y[n-1]),alpha取0.3左右,这样能进一步压低脉冲噪声。

4. 串口上报与OLED显示:双通道输出的完整链路

这个工程的价值在于它同时打通了"人机界面"和"远程调试"两条输出通道。OLED负责现场显示,串口负责把数据交给上位机做记录和分析。两块功能在代码层面是独立的,但共享同一份浓度数据,所以数据流设计成:定时器中断里采样滤波 → 全局变量存储 → 主循环里消费数据。主循环的结构决定了这个系统能不能扩展出按键设置阈值、Wi-Fi上报等功能——建议用状态机,而不是顺序执行的while(1)。

串口部分先用最基础的方式演示查询发送,工程里的串口初始化已经配置好USART1的GPIO复用,注意TX(PA9)要配置为AF_PP复用推挽,RX(PA10)配置为IN_FLOATING浮空输入,这个差异是GPIO模式的经典考点:

void USART1_SendString(char *str) { while (*str) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)(*str)); str++; } // 等待发送完成,避免最后一字节被关断 while (USART_GetFlagStatus(USART1, USART_FLAG_TC) == RESET); } void Report_PPM(float ppm) { char buf[64]; // 格式:CH4: 1250 PPM,回车换行结尾 sprintf(buf, "CH4: %.0f PPM\r\n", ppm); USART1_SendString(buf); }

逻辑说明:TXE标志位表示发送数据寄存器已空,可以写入下一个字节;TC标志位表示整个移位寄存器已经发送完毕。只等TXE就关USART的话,最后一字节还在移位寄存器里没发出去,上位机就会丢最后一个字符。sprintf的浮点格式化在MicroLIB下能正常工作,但如果关闭了MicroLIB,浮点会引入约1KB的代码开销,建议格式化前先把浮点转成整数:

int ppm_int = (int)(ppm + 0.5f); // 四舍五入取整 sprintf(buf, "CH4: %d PPM\r\n", ppm_int);

4.1 串口调试助手乱码与数据格式:115200-8-N-1必须对齐

串口调试助手的设置界面里,波特率必须与代码一致,本工程是115200;数据位8位、停止位1位、无校验,也就是常说的115200-8-N-1。配置不对最常见的现象是收到一堆乱码——比如波特率差一点就会把每个字节的位错位,显示成"²Ä¸"之类的变形字符。如果确认波特率正确但首字节丢失,检查PA9的GPIO配置是不是复用推挽,以及USB转TTL模块的TX/RX是否交叉连接——串口必须交叉接:STM32的TX接模块的RX,RX接模块的TX,很多人在这里直接对接导致收不到数据。

如果要在上位机做曲线监控,建议把数据格式改成CSV方式,便于XCOM或者sscom的图形化插件直接解析。XCOM的波形显示功能支持自定义分隔符,所以代码可以改成这样:

void Report_PPM_CSV(float ppm, float voltage) { char buf[64]; int ppm_int = (int)(ppm + 0.5f); // 逗号分隔:电压,浓度,方便上位机按逗号拆分 sprintf(buf, "%.2f,%d\r\n", voltage, ppm_int); USART1_SendString(buf); }

4.2 OLED屏幕显示:六位大字显示与自绘进度条

OLED显示浓度比较直观的做法是把PPM值做粗体放大——OLED默认8×16字体只有像素点阵,放大两倍需要自己写缩放函数。工程里如果提供的是标准库函数OLED_ShowString,它内部是查字库点阵,直接改大小不可行,需要自己实现一个放大函数。另一种思路是保持数字不变,在浓度超阈值时整屏变色(反色显示),这对现场告警更有效:

void OLED_ShowGasStatus(float ppm) { char buf[16]; int ppm_int = (int)(ppm + 0.5f); // 第一行:标题 OLED_ShowString(0, 0, "CH4 DETECTOR", 16); // 第二行:浓度值,5位数字 sprintf(buf, "%05d", ppm_int); OLED_ShowString(10, 3, buf, 16); // 坐标(10,3)为中心两倍大字 // 第三行:报警状态,OLED自带反色显示 if (ppm_int > 2000) { OLED_ShowString(0, 6, "!! ALARM !!", 16); } else { OLED_ShowString(0, 6, "SAFE ", 16); } }

OLED坐标说明:0.96寸OLED是128×64像素,OLED_ShowString的y参数一般取值0~7,对应8页(每页8像素高),16号字体占两页高度。上述代码里(y=3)实际是从第24像素行开始显示,如果线宽超出64像素会截断,所以显示字符串最多16个字符(128÷8=16)。反色显示需要SSD1306支持,命令是0xA7(反色)和0xA6(正常),工程里可以加一个切换函数。OLED的刷新要注意一点:SSD1306是写一次全屏刷新一次,如果在主循环里高频刷新,肉眼能看到闪烁。工程做法是只在数值变化超过一定范围时才刷新,比如PPM变化超过10才更新屏幕显示,这样既降低I2C总线负载,也消除闪烁。

4.3 主循环的调度逻辑:按键、刷新、上报互不阻塞

有了串口和OLED两个输出设备,主循环如果串行执行刷新和上报,每次sprintf加I2C写入会耗时好几个毫秒,这期间如果有按键事件就会被漏掉。参考常见的做法,主循环尽量用非阻塞条件判断,而不是delay:

volatile uint16_t g_adc_value = 0; volatile uint8_t g_new_data_flag = 0; int main(void) { Init_All_Peripherals(); OLED_Init(); OLED_Clear(); // 上电预热提示 OLED_ShowString(0, 0, "WARM UP 3MIN", 16); uint32_t last_report = 0; uint32_t last_display = 0; while (1) { if (g_new_data_flag) { g_new_data_flag = 0; float volt = (float)g_adc_value * 3.3f / 4096.0f; float rs = MQ4_GetRs(volt); float ppm = MQ4_GetPPM(rs); // 串口每秒上报一次 if (HAL_GetTick() - last_report >= 1000) { Report_PPM_CSV(ppm, volt); last_report = HAL_GetTick(); } // OLED每500ms刷新一次 if (HAL_GetTick() - last_display >= 500) { OLED_ShowGasStatus(ppm); last_display = HAL_GetTick(); } // 阈值判断:超过2000PPM拉高蜂鸣器 if (ppm > 2000) { GPIO_SetBits(GPIOC, GPIO_Pin_13); } else { GPIO_ResetBits(GPIOC, GPIO_Pin_13); } } } }

逻辑说明:g_new_data_flag在定时器中断里置1,主循环检测到标志后做数据处理。HAL_GetTick是HAL库的毫秒计数器,标准外设库工程可以自己维护一个SysTick计数器实现同样的功能。这样设计的好处是,每个外设的刷新节奏相互独立——串口1秒发一帧,OLED 500ms刷一次屏,蜂鸣器实时响应,互不阻塞。如果要加阈值按键设置,只需要在主循环里增加按键扫描分支。

5. 无源蜂鸣器与PWM驱动:从固定频率到音乐告警的进阶

工程默认用的有源蜂鸣器接高低电平就能响,但它的声音是单频的,在嘈杂工厂环境里容易被环境噪声掩盖。无源蜂鸣器需要外部提供一定频率的方波才能发声,频率不同音调就不同。这个进阶改造正好用上STM32的定时器PWM功能——工程文件列表里有stm32f10x_tim.c,说明定时器资源已经预留。把PC13从GPIO输出改成TIM的PWM通道输出,需要查引脚复用表:PC13对应TIM8_CH1或者TIM1_CH1的复用功能,但具体要用哪个得看芯片封装。更常见的做法是用PB0对应TIM3_CH3,PB1对应TIM3_CH4,这两个脚不需要重映射。接线改动也很小,把蜂鸣器正极从PC13挪到PB0即可。

无源蜂鸣器驱动频率和音调对应关系:400Hz是低沉警报,1000Hz是标准警报,2000Hz是尖锐提示。实现多段报警可以利用定时器直接修改PWM频率,不用重启定时器:

void Buzzer_SetFreq(uint16_t freq) { // 假设TIM3时钟72MHz,PSC=72-1,那么计数频率=1MHz // ARR = 1000000 / freq uint16_t arr = 1000000 / freq; TIM_SetAutoreload(TIM3, arr); // 占空比50% TIM_SetCompare3(TIM3, arr / 2); }

5.1 非阻塞的报警旋律:不用delay也能播放多段音调

如果想让报警声音有节奏感,比如"响0.2秒、停0.2秒、再响0.5秒",常见做法是维护一个音符序列,在定时器中断里切换频率。这种设计让主循环完全不需要照顾蜂鸣器,在工业现场非常实用——报警的同时主循环还能继续刷新屏幕、上报串口:

// 报警音序:频率(Hz), 持续时间(ms),0表示结束 const uint16_t alarm_seq[][2] = { {1000, 200}, {0, 150}, {1500, 200}, {0, 150}, {0, 0} }; volatile uint8_t alarm_seq_index = 0; volatile uint16_t alarm_tick = 0; void Buzzer_Update(void) { if (alarm_seq_index >= 5) return; // 播完则停 if (alarm_tick >= alarm_seq[alarm_seq_index][1]) { alarm_tick = 0; alarm_seq_index++; if (alarm_seq_index >= 5) { TIM_Cmd(TIM3, DISABLE); // 停止PWM输出 return; } uint16_t f = alarm_seq[alarm_seq_index][0]; if (f == 0) { TIM_Cmd(TIM3, DISABLE); // 静音段 } else { Buzzer_SetFreq(f); TIM_Cmd(TIM3, ENABLE); } } }

参数说明:alarm_tick在SysTick中断里每1ms加一次,这样整个音序的时基从主循环中解耦。如果同时有多个报警级别,可以把alarm_seq数组扩展成二维,按不同浓度区间选择不同的旋律——比如浓度超过2000PPM用急促音,超过5000PPM用持续长音。这套逻辑本质上就是简易的播放器状态机,理解了它,后面再扩展播放一段音乐旋律也只是改数组内容的事。注意无源蜂鸣器驱动电流大约30mA,STM32的GPIO灌电流能力有限,如果直接用引脚驱动蜂鸣器可能会拉低VDD导致MCU复位,建议加一个NPN三极管(如S8050)做开关,基极串联1K电阻,集电极接蜂鸣器负极,发射极接地,蜂鸣器正极接3.3V——这是无源蜂鸣器电路图里比较标准的驱动结构。

5.2 阈值与回差:防止临界浓度时蜂鸣器反复通断

气体浓度在阈值附近波动时,如果代码只做简单的"超过就响、低于就关",会出现蜂鸣器几十秒内反复通断的抖动现象。解决方法是引入回差(滞回比较):浓度超过上限(比如2000PPM)才启动报警,但必须回落到下限(比如1500PPM)才停止报警。这个差值400PPM就是"回差带",宽度要大于噪声波动幅度,但不能太大,否则实际已经安全了报警还在响。

#define ALARM_ON_THRESHOLD 2000 // 报警启动阈值PPM #define ALARM_OFF_THRESHOLD 1500 // 报警解除阈值PPM uint8_t alarm_active = 0; void Update_Alarm(float ppm) { if (alarm_active == 0) { if (ppm >= ALARM_ON_THRESHOLD) { alarm_active = 1; alarm_seq_index = 0; alarm_tick = 0; TIM_Cmd(TIM3, ENABLE); } } else { if (ppm <= ALARM_OFF_THRESHOLD) { alarm_active = 0; TIM_Cmd(TIM3, DISABLE); } } // 把报警状态发到串口,方便上位机记录 if (alarm_active) { USART1_SendString("ALARM ACTIVE\r\n"); } }

这个回差机制的实际意义在于:如果现场很安静、没有人在附近,反复通断的蜂鸣器只会加速继电器和蜂鸣器老化;更重要的是持续报警比断续报警更容易被注意。回差值要根据MQ4的噪声水平标定,通常是阈值的10%~20%。调试时在串口调试助手里观察浓度曲线,如果发现某个PPM值附近频繁跨越阈值,就把回差加大到噪声峰峰值的2倍。

6. 现场标定与串口日志分析:让测量数据可信的最后一步

把工程跑通很容易,难的是让测出来的浓度值可靠。MQ4出厂特性一致性差,同一个浓度的气体,不同模块输出的电压可能差30%。所以任何交付给客户或者写进论文的数据,都要经过现场标定。标定的核心是修正R0:找一个确定是洁净空气的环境(比如室外通风处),让系统预热至少10分钟——MQ4内部加热丝需要这么长时间达到热平衡,之后连续记录200个Rs值,取中位数作为R0_CLEAN。注意这里要用中位数不是平均值,因为环境里偶尔飘来的汽车尾气会产生尖峰,平均值会被拉偏,中位数天然抗离群点。

标定完成后,建议做一次响应测试:用打火机放气(不点火)靠近传感器,观察串口输出的PPM曲线。正常应该在5秒内开始上升,松开后20~60秒回落。这个过程用串口调试助手记录日志,可以把数据导出成TXT再导入Excel画曲线。一个简单的验证方法是计算从10%上升到90%响应值的时间,MQ4对甲烷的响应时间通常在10秒以内,如果超过30秒才响应,说明传感器的加热电压不对或者采样的滤波窗口太大——检查是不是把采样间隔改成了1秒导致每个数据点是上一秒的平均、掩盖了快速变化。

关于标定的精度极限要说清楚:MQ4本身不是精密仪器,它的精度在±20%以内就算合格,用来做"是否泄漏"的定性判断足够,但不要把它当成计量级仪器去出检测报告。如果需要精确浓度数据,应该换电化学传感器或者红外传感器(比如MH-Z14),代码结构只需改采集函数——因为工程已经做了数据分层:采集在中断、换算在函数、显示上报在主循环,替换传感器时只需要改MQ4_GetRs和MQ4_GetPPM两个函数,后面的链路基本不用动。

最后补一个验证手段:在串口调试助手里开启时间戳功能,每隔1秒记录一条数据,持续运行一晚上,第二天看曲线是否平稳。如果夜间曲线波动超过±50PPM,检查供电是不是用了USB供电导致纹波大;如果曲线缓慢漂移,可能是加热丝老化或者环境温湿度变化,需要在代码里加周期性自校准——比如每隔24小时在凌晨自动把当前Rs作为新的R0,这样系统就不怕季节变化了。

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

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

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

立即咨询