简介:面向嵌入式初学者的STM32单片机气体监测与报警完整工程,以MQ9传感器检测可燃气体浓度,通过OLED屏实时显示,超阈值时驱动蜂鸣器报警,并将数据经串口发送至PC调试助手,覆盖传感器采集、逻辑判断、人机交互与串口通信等典型功能。资源包共237个文件,约6.61MB,主体为C语言源码(.c/.h)、编译生成的目标文件(.o)、链接映射(.map)以及Keil工程配置(.uvprojx/.uvoptx)等,既可直接打开工程编译烧录,也可对照源码学习STM32外设初始化和数据处理流程。已有295人学习下载。代码中涵盖GPIO、ADC、I2C、定时器及串口等模块的驱动写法,并针对OLED显示和蜂鸣器控制给出了完整实现;开发者可在此基础上扩展Wi-Fi/4G远程报警或接入云平台,适合课程设计、毕业设计及工业安全类项目参考。
1. STM32单片机+MQ9+OLED+蜂鸣器+串口的组合,是一套气体检测最小闭环
MQ9传感器的AO引脚看起来像“浓度输出”,实际拿万用表量出来的是分压值,不是ppm;OLED要显示汉字得先弄明白SSD1306的显存寻址方向;蜂鸣器会在阈值附近疯狂抖动,这些问题只要做过一次就都知道。把这四样东西和一个串口调试助手串起来,你得到的不只是“能跑”的源代码,而是一条从物理量采集到本地显示、本地报警、远程观测的完整数据链路。这套方案最常见的落地场景是煤气泄漏演示、室内空气质量监测和本科毕设,处理器选STM32F103C8T6这类入门型号就足够。下面按硬件连接、HAL库工程、报警协议、标定排错的顺序,把每步的参数说清楚。
2. MQ9传感器的输出特性与OLED、蜂鸣器、串口的硬件连接
2.1 MQ9的AO引脚输出的是电阻分压,不是浓度数值
MQ9属于半导体气敏传感器,内部有一片加热电阻和一层二氧化锡敏感材料。加热电阻在通电后把敏感层加热到200℃以上,空气中的一氧化碳和可燃气体吸附到敏感层表面,会改变其电阻值 Rs。传感器模块上的AO引脚把这个 Rs 和一个固定负载电阻 RL 做串联分压,输出电压Vout = Vcc × RL / (Rs + RL)。所以AO引脚电压升高,说明气体浓度升高,但电压与浓度之间是对数关系,想得到ppm必须查数据手册里的灵敏度曲线做标定。
DO引脚则完全不同,它接的是模块上自带的一个比较器(常见型号LM393),电位器设定一个阈值电压,AO电压超过阈值时DO输出低电平。用DO引脚做报警可以,但阈值一旦调死就只能在模块上拧电位器,把阈值放到STM32的ADC采集里做软阈值,后面想改报警点就只需改一个宏定义。MQ9模块的VCC一般接5V,加热电流约150mA,AO输入到STM32的ADC引脚前确认一下模块输出幅度,多数模块自带电压比较输出,AO在洁净空气中的静态电压在0.3V到0.8V之间,可以直接进3.3V的ADC引脚。
2.2 OLED的I2C地址选择与引脚复用分配
0.96吋OLED模块的驱动芯片以SSD1306居多,0.91吋的用SSD1315,两者指令集基本兼容。I2C地址由模块背面的R2电阻决定,常见的0x3C是从机地址左移一位前的原始值,在HAL库的I2C_Addr配置里要填入左移后的地址,也就是0x3C << 1。另一个地址是0x3D,只在R2电阻被焊到另一侧时才出现,代码里预留一个宏定义即可。
引脚分配上,STM32F103C8T6的I2C1走PB6(SCL)和PB7(SDA),USART1走PA9(TX)和PA10(RX),ADC1的通道0走PA0,四组外设互不冲突。触摸屏或者SPI版本的OLED不推荐,SPI版需要额外占用CS和DC两个引脚,在F103的48脚封装上没这个余量。
| 外设 | MCU引脚 | 说明 |
|---|---|---|
| MQ9 AO | PA0 (ADC1_IN0) | 模拟输出,输入范围0~3.3V |
| OLED SCL | PB6 (I2C1_SCL) | 接SSD1306的SCL |
| OLED SDA | PB7 (I2C1_SDA) | 接SSD1306的SDA |
| 蜂鸣器控制 | PA2 (GPIO输出) | 经S8050三极管驱动 |
| USART1 TX | PA9 | 接USB转TTL的RX |
| USART1 RX | PA10 | 调试时可接,正常运行可不接 |
2.3 蜂鸣器的有源与无源决定了驱动代码
有源蜂鸣器内部自带振荡电路,通电就响,频率固定,GPIO输出高电平驱动即可。无源蜂鸣器需要输入20Hz到10kHz的方波才能发声,用HAL库的PWM输出做驱动时,得先配置一个定时器通道。在这个方案里用有源蜂鸣器代码最简单:一只S8050三极管,基极串1kΩ电阻接PA2,发射极接地,集电极接蜂鸣器负极,蜂鸣器正极接5V。PA2输出高电平,三极管导通,蜂鸣器工作。注意不要直接把蜂鸣器接在PA2上,蜂鸣器工作电流约30mA,超过了STM32 GPIO的灌电流能力。
3. 用CubeMX配置HAL库工程,点亮OLED并读取MQ9的ADC数据
3.1 CubeMX里的配置清单:ADC、I2C、USART、GPIO
打开STM32CubeMX新建F103C8T6工程,按下面的清单勾选配置。ADC1勾选IN0通道,I2C1速度选100kHz的Standard Mode,USART1波特率调成115200、8位数据、1位停止位。蜂鸣器引脚PA2配置为GPIO_Output,用户标签改成BEEP。PA10不用可以保持输入模式,但建议下拉。
| 外设 | 配置项 | 数值/模式 |
|---|---|---|
| ADC1 | IN0 | PA0,采样时间55.5Cycles |
| I2C1 | I2C Speed Mode | Standard Mode 100kHz |
| USART1 | Baud Rate | 115200 |
| GPIO | PA2 | Output Push-Pull,初始Low |
| RCC | HSE | Crystal/Ceramic Resonator |
ADC的采样时间在F103上默认55.5个周期,对MQ9这种输出阻抗较高的传感器足够。注意ADC的时钟不要超过14MHz,系统时钟72MHz时,ADC预分频选6,得到12MHz。随后在Project Manager里生成MDK-ARM工程,用Keil5打开编译一次,确认没有缺少芯片包的问题。之前遇到过Keil5安装后找不到STM32F1系列设备的情况,Pack Installer里勾选Keil::STM32F1xx_DFP后重新编译即可。
3.2 用HAL库点亮OLED屏幕的最小代码
HAL库驱动的OLED代码,核心就是通过I2C向SSD1306的寄存器写命令和数据。SSD1306的上电初始化序列不能省,下面这段代码去掉注释后大概是初始化屏幕的最短路径。OLED_WriteCmd负责往I2C总线上发控制字节和命令字节,0x00表示后面跟的是命令,0x40表示后面跟的是数据。
// oled12864.c static void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[] = {0x00, cmd}; // 0x00控制字节: 表示后续为命令 HAL_I2C_Master_Transmit(&hi2c1, 0x3C << 1, buf, 2, 100); } static void OLED_WriteData(uint8_t dat) { uint8_t buf[] = {0x40, dat}; // 0x40控制字节: 表示后续为数据 HAL_I2C_Master_Transmit(&hi2c1, 0x3C << 1, buf, 2, 100); } void OLED_Init(void) { HAL_Delay(100); // 等待内部DC-DC稳定 OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0x20); OLED_WriteCmd(0x02); // 页寻址模式 OLED_WriteCmd(0x8D); OLED_WriteCmd(0x14); // 开启电荷泵 OLED_WriteCmd(0xA8); OLED_WriteCmd(0x3F); // 1/64复用比 OLED_WriteCmd(0xD3); OLED_WriteCmd(0x00); // 无显示偏移 OLED_WriteCmd(0x40); // 显示起始行=0 OLED_WriteCmd(0xA1); // 段重映射,列地址0对应SEG0 OLED_WriteCmd(0xC8); // COM扫描方向,从上到下 OLED_WriteCmd(0xDA); OLED_WriteCmd(0x12); // COM引脚配置 OLED_WriteCmd(0x81); OLED_WriteCmd(0xCF); // 对比度0xCF OLED_WriteCmd(0xD9); OLED_WriteCmd(0xF1); // 预充电周期 OLED_WriteCmd(0xDB); OLED_WriteCmd(0x40); // VCOMH倍率 OLED_WriteCmd(0xA4); // 使用RAM内容显示 OLED_WriteCmd(0xA6); // 正常显示,不反色 OLED_WriteCmd(0x2E); // 关闭滚动 OLED_WriteCmd(0xAF); // 开启显示 OLED_Clear(); }代码里0x3C << 1这个写法经常漏掉,HAL库的I2C_Addr参数要求7位地址左移一位后的8位地址。OLED的显存是128×64位,内部按页分成8页,每页8行像素,调用OLED_Clear时往0xB0到0xB7这8个页地址逐页填充0x00即可。显示字符时,按页地址和列地址定位,然后逐个发送字符的点阵数据。这种HAL库驱动OLED的写法,整个流程比标准外设库版本多了两步地址换算,但代码可移植性好,换到G031或L431时不改逻辑只改初始化。
3.3 MQ9的ADC采集与电压换算
MQ9的AO引脚接到PA0后,用ADC1的IN0通道做轮询采集。以下代码把ADC值换算成电压,再除以一个标定系数得到等效浓度显示占空比:
// mq9.c #define MQ9_VREF 3.3f #define MQ9_RO 10.0f // 洁净空气中标定的Ro值,单位kΩ #define MQ9_RL 10.0f // 模块上的负载电阻 float MQ9_ReadVoltage(void) { // 1. 开启一次ADC转换 HAL_ADC_Start(&hadc1); // 2. 等待转换完成,超时100ms HAL_ADC_PollForConversion(&hadc1, 100); // 3. 读取12位转换结果(0~4095) uint16_t adc = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); return (float)adc / 4095.0f * MQ9_VREF; }HAL_ADC_PollForConversion阻塞等待转换结束,100ms超时对50Hz的采样需求来说够宽裕。采样结果右对齐存进ADC数据寄存器,读数除以4095再乘参考电压,得到的就是AO引脚的实际电压。注意一旦配置了ADC的连续转换模式,HAL_ADC_Stop必须调用,否则下次HAL_ADC_Start会直接返回HAL_BUSY。想要得到ppm级别浓度,还需要公式ppm = A * pow(Rs/Ro, B),A和B是MQ9对一氧化碳和甲烷的两组不同系数,后面第5章单独说标定。
3.4 MQ9数据发送到串口调试助手的格式化输出
串口发送这件事,用HAL库的阻塞发送函数就能完成。打印内容设计成一行CSV,第一列是ADC原始值,第二列是电压,第三列是报警状态,串口调试助手里用文本模式就能直接看明白。
// main.c 主循环中用到的串口打印函数 #include <stdio.h> #include <string.h> void MQ9_SendToUART(uint16_t adc_val, float vol, uint8_t is_alarm) { char buf[64]; // 格式化一行CSV: ADC原始值, 电压值, 报警状态 int len = snprintf(buf, sizeof(buf), "%u,%.2f,%s\r\n", adc_val, vol, is_alarm ? "ALARM" : "NORMAL"); HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 100); }snprintf把数据拼装成字符串,HAL_UART_Transmit最后一个参数是超时时间,串口空闲时这条发送几乎是瞬时完成。注意\r\n不能省,正点原子XCOM和SSCOM这类串口调试助手都是靠回车换行来断帧的,少了\n显示出来就是一行接一行连在一起。每200ms发一帧即可,发太快OLED刷不过来,串口界面也被刷屏。
4. 阈值报警逻辑与串口数据协议设计:蜂鸣器不抖动的关键
4.1 滞回比较:在阈值附近给蜂鸣器一个缓冲区
直接拿采集电压和阈值做比较,电压在阈值上下波动时蜂鸣器会频繁开启关闭,继电器或蜂鸣器的触点寿命很快耗尽。解决做法是滞回比较:进入报警状态用高阈值,退出报警状态用低阈值,两者之间留一个电压滞回区间。
// alarm.c #define ALARM_ON_V 0.80f // 电压高于0.80V进入报警 #define ALARM_OFF_V 0.65f // 电压低于0.65V退出报警,两者之间为滞回区 static uint8_t alarm_active = 0; void Alarm_Task(float voltage) { if (!alarm_active && voltage > ALARM_ON_V) { alarm_active = 1; // 刚越过高阈值,拉响蜂鸣器 HAL_GPIO_WritePin(BEEP_GPIO_Port, BEEP_Pin, GPIO_PIN_SET); } else if (alarm_active && voltage < ALARM_OFF_V) { alarm_active = 0; // 已回落到低阈值以下,解除报警 HAL_GPIO_WritePin(BEEP_GPIO_Port, BEEP_Pin, GPIO_PIN_RESET); } }滞回区间拉大后,抗抖动能力取决于0.15V这个差值。如果现场传感器噪声较大,可以适当把滞回区间扩大到0.2V到0.25V,换取更稳定的报警输出。报警状态用alarm_active这个变量记住,别用读GPIO当前电平来判断,因为蜂鸣器驱动三极管存在导通延迟,读回来的状态可能滞后一拍。
4.2 数据帧格式选CSV纯文本,调试点位省心
面向串口调试助手设计数据帧时,两种流派各有利弊。JSON格式解析方便,但每帧多出十来个花括号和引号字符,对115200波特率来说开销可接受,但对单片机代码量不友好。CSV纯文本格式最简单,串口调试助手里一眼能看出数值变化曲线。
// 发送报警事件时,补充一条文本带状态标记 void MQ9_SendEvent(const char *event) { char buf[32]; int len = snprintf(buf, sizeof(buf), "EVT:%s\r\n", event); HAL_UART_Transmit(&huart1, (uint8_t *)buf, len, 100); }常规数据每200ms一条,事件数据只在状态翻转时发送一次。这样串口调试助手里data是一行行CSV,事件是零星的EVT:ALARM_ON,屏幕显示与外部观测都能对得上。波特率115200按满负荷计算约每秒发送11KB数据,一条CSV约30字节,整条串口带宽只用掉不到2%,实际传输稳定可靠。
4.3 蜂鸣器报警的3种模式与ADC采样节奏配合
报警并不一定只能输出固定高电平,可以在alarm_active为真的基础上增加变化。简单的做法是让蜂鸣器周期性开启关闭,利用HAL_Delay做1Hz方波;进阶做法是配置一个PWM输出在报警期间开启,无源蜂鸣器发出不同音调的高频提示音。
| 报警模式 | 实现方式 | 适用场景 |
|---|---|---|
| 常响 | GPIO置高 | 紧急泄气,需持续提示 |
| 1Hz间断 | 在报警任务里翻转GPIO | 家庭燃气报警,省电 |
| PWM变频 | 定时器通道输出方波,报警时改占空比 | 无源蜂鸣器,音调变化更明显 |
ADC采样节奏建议固定200ms一次,OLED每5次刷新一次(即1秒刷一次),报警任务每次采样后执行。MDK软件仿真里看到的ADC数值和串口调试助手收到的不一致时,多半是采样时间不够导致ADC采样电容没充满,把ADC采样周期从55.5Cycles调大到239.5Cycles能解决。这个参数属于调试中最常遇到的ADC采样点跳变问题。
5. 拿串口调试助手做整链路验证:标定、滤波与3个必查点
5.1 MQ9通电预热与Ro标定:让基线先站稳
MQ9刚上电时,敏感层表面会吸附水蒸气和残留气体,输出电压会持续漂移几分钟到一个小时。初次使用必须先让传感器通电预热至少5分钟,再做Ro标定。做法是传感器放在洁净空气环境中,读取10次电压取平均,通过数据手册的公式反推Ro:
// 标定模式下,把当前Rs认定为Ro // 串口调试助手里看到的电压换算:Rs = RL * (3.3 / Vout - 1) // 读取50次后打印平均值,将该电压对应的Rs作为Ro存入EEPROM把Ro存放在Flash里的好处是断电不丢。没有EEPROM的F103C8T6可以用内部Flash最后两页存参数,也可以用I2C外挂一个AT24C02,后者的做法更常规。标定完成后再测气体浓度时,Rs/Ro的比值变化就完全由目标气体浓度决定,此时串口打印出的电压变化才有标定意义。
5.2 滑动平均滤波:ADC数据不抖,OLED显示才稳
MQ9的模拟输出本身带有噪声,用滑动平均能把电压的随机抖动压下去。
// filter.c #define FILTER_SIZE 10 static float buf[FILTER_SIZE]; static uint8_t idx = 0; float MQ9_SlidingAvg(float new_val) { static float sum = 0.0f; // 先减掉最旧的一个采样值,再加新值 sum -= buf[idx]; sum += new_val; buf[idx] = new_val; idx = (idx + 1) % FILTER_SIZE; return sum / FILTER_SIZE; }滑动平均对周期性噪声和随机噪声都有抑制效果,窗口10在200ms采样周期下对应2秒的响应延迟。要缩短报警响应时间,就把窗口缩小到5甚至3,代价是输出曲线会有毛刺。
5.3 用XCOM或SSCOM验证串口数据对不上的3个排查点
把USB转TTL模块连接到PA9和GND,打开正点原子XCOM或SSCOM串口调试助手,波特率设115200、文本模式显示。数据对不上时按下面的顺序查,基本能覆盖九成问题。
第一是检查串口调试助手的波特率是否和CubeMX配置一致,这一步占了串口乱码原因的一半。第二是检查USB转TTL模块的RX是否接到了板子的PA9,交错后串口完全没输出。第三是检查代码里是否有sprintf浮点格式化引起堆栈溢出,F103的默认栈大小0x400在调用snprintf输出浮点时可能不够,在启动文件里把Stack Size改成0x800就能稳定运行。
OLED屏幕显示和串口数据都对上了之后,这套基于STM32单片机、MQ9传感器、OLED显示和蜂鸣器报警的最小系统就算完整跑通。把阈值变量改成宏定义、串口帧加个类型字段,这套框架换传感器探头、换显示模块都可以直接接着改。
本文还有配套的精品资源,点击获取