
简介面向嵌入式开发者与STM32初学者的气体监测系统源代码包以STM32单片机为主控整合MQ5气体传感器、OLED屏幕、蜂鸣器报警及串口调试助手通信解决天然气/液化气泄漏实时监测场景中数据采集、显示、声光报警与上位机传输问题。压缩包共235个文件主要包含.c/.h源程序、Keil工程配置文件.uvprojx/.uvoptx以及编译生成的.o/.d/.crf等中间文件整体大小6.63MB目录结构清晰便于按模块检索阅读。已有265人学习浏览适合用来学习STM32的ADC模拟量采集、I2C驱动OLED显示、GPIO与定时器控制蜂鸣器以及USART串口通信等典型外设编程。全套源码覆盖硬件初始化、传感器数据读取与滤波处理、浓度阈值判断、报警触发、OLED信息刷新和串口数据上报等功能模块并附带常用外设驱动如OLED驱动开发者可直接在此工程上快速搭建气体浓度监测样机也可将其中驱动与处理逻辑迁移到其他STM32项目中缩短嵌入式监测系统的开发与调试周期。1. STM32 单片机把 MQ5、OLED、蜂鸣器串成一条完整的气体报警链路STM32 驱动 MQ5 传感器再把浓度同时送到 OLED 屏幕、蜂鸣器和串口调试助手这套组合常见于家用燃气报警、气体检测毕业设计和环境监测小项目。核心价值不在于某个元器件本身而是把模拟量采集、显示、报警和上位机通信这四件事用一份固件串起来MQ5 输出的是模拟电压STM32 用 ADC 读进来换算成浓度或电压值一路给 OLED 做可视化一路和阈值比较触发蜂鸣器一路通过 UART 发给串口调试助手。适合刚接触 HAL 库的开发者、做智能家居相关设计的工程师以及需要快速出演示样机的硬件爱好者。很多人卡在 MQ5 的“预热漂移”和 OLED 的显示刷新上本文会把这些点拆开讲。2. MQ5 传感器信号链分压电路、ADC 采集与浓度换算2.1 MQ5 的敏感特性和 STM32 采集前提MQ5 属于半导体气敏传感器内部有一个加热电阻和一个二氧化锡气敏层。当空气中可燃气体浓度升高气敏层电导率变化表现为传感器输出端电压变化。它最典型的特性是对液化气、天然气、城市煤气敏感对酒精和烟雾也有响应所以用在燃气报警场景时要明确“检测的是什么气体”否则会出现误报。硬件上 MQ5 模块通常已经集成了比较器LM393和电位器输出两种信号数字量 DOUT 和模拟量 AOUT。做浓度显示必须用 AOUT 接 STM32 的 ADC 引脚。常见的模块供电是 5V但 STM32 的 ADC 输入范围通常是 0 到 3.3V直接接可能超量程。常见做法是先用万用表测一下 AOUT 在洁净空气和气体泄漏时的大致电压如果最高超过 3.3V就需要加分压电阻。我一般会在 AOUT 和地之间接两只 10kΩ 电阻分压或者用模块自带的电位器把输出调低。ADC 引脚选择上F103 系列用 PA0-PA7 或者 PB0、PB1 都可以建议优先选 PA1因为很多最小系统板直接把 PA1-WKUP 引出来接线方便。参考电压用 VDDA也就是 3.3V这是后续换算的基础。2.2 用 HAL 库 ADC 轮询读取 MQ5 电压的最小代码 ADC 采集在 STM32CubeMX 里配置为单通道、连续转换模式采样时间可以选 239.5 周期这样能降低信号源阻抗带来的误差。轮询方式最简单适合 MQ5 这种变化缓慢的气体信号不需要中断和 DMA代码量也最少。// adc_mq5.c #include adc.h uint16_t mq5_adc_value; float mq5_voltage; void MQ5_Read_Voltage(void) { HAL_ADC_Start(hadc1); // 启动 ADC 转换 if (HAL_ADC_PollForConversion(hadc1, 10) HAL_OK) { mq5_adc_value HAL_ADC_GetValue(hadc1); // 12 位 ADC范围 0~4095 mq5_voltage (float)mq5_adc_value * 3.3f / 4095.0f; } HAL_ADC_Stop(hadc1); }这里的逻辑是每次读取先启动一次转换轮询等待转换完成再把寄存器里的 12 位值换算成电压。PollForConversion的第二个参数是超时时间单位毫秒MQ5 信号本身响应慢10ms 足够。如果返回HAL_TIMEOUT说明配置可能有问题常见原因是 CubeMX 里没有把 ADC 通道使能或者引脚被其他外设占用。值得注意ADC 连续转换模式下HAL_ADC_Start之后也可以不做Stop直接反复读取。但轮询读取时我习惯要么配成单次转换要么每一轮都Start/Stop避免缓冲区里存的是旧数据。实际项目中 ADC 值落在 0 到 3.3V 之间千万别直接把 ADC 原始值当电压发送到串口调试助手那边会看到一堆无意义的大数字。2.3 从 ADC 数值到浓度估算的换算和校准MQ5 输出和气体浓度不是线性关系在数据手册里通常给的是图线横轴是浓度ppm纵轴是 Rs/R0。这里的 Rs 是传感器在不同浓度下的电阻R0 是传感器在洁净空气中的电阻。如果只做阈值报警最简单的方法是直接标定电压阈值如果希望显示 ppm则需要“两点校准法”。校准点环境条件读取值换算基准零点洁净空气Rs/R0 1mV 基准电压标定点已知浓度气体Rs/R0 对应值反查曲线实际工程里我一般不会追求精准 ppm因为 MQ 系列传感器本身漂移大做不了计量级检测。常见做法是用 ADC 电压值作为显示单位同时给一个百分比条或者说“电压越高浓度越高”的相对指示。如果一定要换算 ppm可以在程序里预置一个线性表把手册图线上取 3 到 5 个点做分段线性插值。比如对液化石油气从 200ppm 到 10000ppmRs/R0 变化范围可以近似对应到电压区间但每个传感器个体差异很大最终要靠电位器和标定气调整。3. OLED 显示与蜂鸣器报警HAL 库驱动 OLED 屏幕全流程的关键点3.1 用 I2C 驱动 0.91 英寸 OLED 的初始化顺序0.91 英寸 OLED 大多使用 SSD1306 驱动芯片接口是 I2C 或 SPI。HAL 库驱动 OLED 屏幕全流程看起来复杂拆开就三步I2C 初始化、SSD1306 初始化序列、显存刷新。开发中最常见的是 4 引脚 I2C 版本SCL 接 PB8SDA 接 PB9F103 的 I2C1 默认引脚OLED 的 VCC 可以接 3.3V部分模块也能接 5V但最好确认模块背面丝印。用 HAL 库操作时I2C 速率设置在 400kHz 能显著减少画面刷新时间。SSD1306 初始化序列不需要自己发明直接从 Adafruit 库或常规 OLED 驱动里移植即可核心是发送一系列配置命令打开内部电荷泵、设置分频比、亮度和寻址模式。启动时要加一个断电复位延时通常是 100ms。// oled_i2c.c uint8_t oled_init_cmd[] { 0xAE, 0x00, // 关闭显示 0x20, 0x00, // 水平寻址模式 0xB0, 0x10, 0x00, // 页地址范围 0x40, 0x00, // 起始行 0x81, 0xCF, 0x00, // 对比度 0xA1, 0xA6, 0xA8, 0x3F, // 段重映射、正常显示、复用比 0xC8, 0xD3, 0x00, // 扫描方向、显示偏移 0xD5, 0x80, 0xD9, 0xF1, // 时钟分频、充电周期 0xDA, 0x12, 0x8D, 0x14, // 引脚配置、电荷泵开启 0xAF // 开启显示 }; void OLED_Init(void) { HAL_Delay(100); HAL_I2C_Mem_Write(hi2c1, 0x78, 0x00, I2C_MEMADD_SIZE_8BIT, oled_init_cmd, sizeof(oled_init_cmd), 20); }这段代码有两个容易出错的地方。第一I2C 从机地址是 0x78 还是 0x7A看模块的地址引脚电平大部分是 0x78如果写失败改成 0x7A 再试。第二HAL_I2C_Mem_Write的第三个参数是 0x00表示后面的数据都作为控制字节发送OLED 会识别为命令流。如果是写显示数据则把地址设为 0x40。初始化命令里0xAF放最后是因为此时电荷泵已经打开显示才有供电。有些简化版驱动把0x8D, 0x14漏掉结果 OLED 一直黑屏这是最典型的坑。用逻辑分析仪或者示波器查看 SDA 上的 ACK可以快速定位是接线问题还是时序问题。3.2 OLED 显示汉字与传感器数据的取模方式OLED 屏幕默认无法直接显示汉字需要把字模点阵存进 Flash。SSD1306 是 128×32 或 128×64 像素0.91 英寸的 128×32 OLED 只有 4 页设计界面时要考虑刷新量。汉字取模通常用 PCtoLCD2002 取模软件设置“阴码、列行式、逆向”格式16×16 点阵的汉字在显示时按 16 字节一簇写入显存缓冲区。// oled_display.c const uint8_t han_zi_dang[] { 0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; void OLED_ShowString(uint8_t x, uint8_t y, char *str) { // 遍历字符串逐个字符写入显存缓存 while (*str) { OLED_ShowChar(x, y, *str); x 8; if (x 120) { x 0; y 2; } } OLED_Refresh(); // 一次性把所有缓存推到 SSD1306 }显示数字时snprintf把浮点电压转成带小数的字符串再逐位显示。这里的关键是“先写缓存最后刷新”不要每显示一个字符就调一次 I2C 发送否则屏幕会闪烁。OLED_Refresh 负责把 1024 字节显存缓冲写到 SSD1306 的 GDDRAM 中刷新一次大约需要消耗几毫秒 I2C 带宽对 MQ5 这种低频数据1 秒刷新 10 次足够。3.3 蜂鸣器报警的阈值逻辑与防抖蜂鸣器报警不能只写一个简单的if (voltage threshold) BEEP_ON;。MQ5 上电初期电阻会剧烈变化电压可能瞬间冲高导致误报警。一般会让系统启动后先预热 3 分钟预热期间只显示不判断。阈值判断时还要加入“连续 N 次超过阈值才报警”的防抖逻辑。// alarm.c #define MQ5_ALARM_VOLTAGE 1.2f #define MQ5_HYSTERESIS 0.15f #define MQ5_ALARM_COUNT 5 uint8_t over_count 0; uint8_t alarm_active 0; void MQ5_Alarm_Handler(float voltage) { if (voltage (alarm_active ? MQ5_ALARM_VOLTAGE - MQ5_HYSTERESIS : MQ5_ALARM_VOLTAGE)) { if (over_count 255) over_count; } else { over_count 0; } if (over_count MQ5_ALARM_COUNT) { alarm_active 1; HAL_GPIO_WritePin(BEEP_GPIO_Port, BEEP_Pin, GPIO_PIN_SET); } else if (over_count 0 alarm_active) { alarm_active 0; HAL_GPIO_WritePin(BEEP_GPIO_Port, BEEP_Pin, GPIO_PIN_RESET); } }这里用“报警开启后带回差”的方式防止临界抖动浓度升高到 1.2V 开始报警之后降到 1.05V 以下才撤销报警。如果直接用同一个阈值传感器噪声会让蜂鸣器在阈值附近反复开关声音会变成“哒哒哒”的断续声。阈值参数放在宏定义顶部便于不同气体、不同布防场景快速调整。4. MQ5 数据发送到串口调试助手串口初始化、数据帧与解析4.1 串口发送轮询、中断和 DMA 的取舍STM32 把数据发给串口调试助手首选的是 USART 的轮询发送因为数据量小、频率低代码最简单。HAL 库中最常见的写法是HAL_UART_Transmit在接收端用 SSCOM、XCOM 或者 openocd 自带的串口监视器观察。如果要提高效率可以把发送放到中断或 DMA但对 MQ5 这类每秒钟发送 1 到 2 条报文的应用完全是杀鸡用牛刀。用 CubeMX 配置串口时模式选Asynchronous波特率设 9600 或 115200。这里有一个经常被忽略的点如果 OLED 和串口同时工作I2C 的中断优先级不要高于 UART 的中断优先级否则串口可能丢字节。调试助手那边要注意接收区如果开的是 HEX 显示而代码发的是 ASCII 字符串会看到一堆 16 进制数容易误判为乱码。// uart_send.c #include stdio.h #include usart.h void MQ5_Send_Raw(float voltage, uint16_t adc_val) { char buf[64]; int len snprintf(buf, sizeof(buf), MQ5:AD%d,V%.2f\r\n, adc_val, voltage); HAL_UART_Transmit(huart1, (uint8_t*)buf, len, 100); }这段代码把 ADC 原始值和电压值同时发出去因为有些时候只看换算后的电压没法判断 ADC 配置是否异常。比如浓度没变但 ADC 值跳变到 4095那大概率是引脚悬空或者参考电压不对。字符串中间的\r\n是给串口调试助手的“接收换行”用的会让报文在调试助手界面里一条条分开不至于黏成一行。HAL_UART_Transmit最后一个参数是超时时间单位毫秒。如果设置成 10 而缓冲区还没发完函数会返回超时错误所以批量上位机软件里一般给 100ms。若出现一段时间后串口停止输出优先检查是否在中断服务函数里重复调用HAL_UART_Transmit这会导致阻塞甚至死锁。4.2 组装 MQ5 数据帧的推荐格式与代码串口数据发送格式我一般推荐带帧头、长度、数据和校验而不是裸发字符串。裸发适合人眼观察但后续如果要做上位机解析、进数据库没有帧边界会非常痛苦。一个简单的帧格式可以设计成字段长度说明帧头2 字节0xAA 0x55数据长度1 字节有效载荷字节数数据类型1 字节0x01 表示 MQ5 电压数据载荷2 字节电压值放大 100 倍后发送高位在前校验和1 字节前面所有字节求和取低 8 位这样一帧只有 7 个字节用十六进制显示在串口调试助手里很紧凑。以下代码是一个可移植的封装// mq5_frame.c void MQ5_Send_Frame(float voltage) { uint8_t frame[8]; uint16_t v100 (uint16_t)(voltage * 100.0f); uint8_t payload_len 3; // 类型 2 字节数据 frame[0] 0xAA; frame[1] 0x55; frame[2] payload_len; frame[3] 0x01; frame[4] (v100 8) 0xFF; frame[5] v100 0xFF; frame[6] frame[0] frame[1] frame[2] frame[3] frame[4] frame[5]; HAL_UART_Transmit(huart1, frame, 7, 100); }帧格式里用“放大 100 倍”而不是直接发浮点数是为了避免上位机做浮点解析的麻烦。收到0xAA 0x55 03 01 00 78这样的帧上位机直接把最后两个字节拼成0x0078 120再除以 100 得到 1.20V。校验和如果不对应丢弃整帧而不是盲目解析这在串口噪声较大的环境格外重要。这里还能顺带解决“串口调试助手显示中文乱码”的问题snprintf发送中文时如果源码文件用的是 GB2312 编码而调试助手默认 UTF-8就会显示乱码。为了避免编码问题数据帧尽量只发 ASCII 或纯二进制。4.3 用 SSCOM 或 XCOM 观察数据时的常见坑串口调试助手有免费工具和商业工具SSCOM、XCOM、正点原子串口调试助手这些都很常见。配置串口时最容易踩的坑是“打开串口失败”原因常常是上一次关闭时没有释放串口或者驱动没有正确安装。另一类坑是开发板的 USB 转串口芯片型号CH340 需要装 CH340 驱动CP2102 需要装 CP210x 驱动系统里如果没有对应驱动设备管理器里会显示一个带感叹号的未知设备。发送频率方面MQ5 信号本身慢1 秒发一次已经足够。如果发现串口调试助手里数据显示频率远高于设定值检查主循环里是否有多个地方调用HAL_UART_Transmit可能污染物数据发了两遍。还有一个细节单片机复位后串口调试助手的 DTR 或 RTS 信号可能会让 STM32 进入写入模式导致程序不运行部分开发板需要在调试助手里取消勾选 DTR/RTS。5. 让报警更稳的三个技巧迟滞、OLED 刷新和日志分级报警阈值迟滞的具体做法是把“进入报警”和“退出报警”拆成两个阈值中间留出一段区间。上文代码里的MQ5_HYSTERESIS就是一个简单的实现。这个技巧对 MQ5 特别有效因为传感器的输出噪声峰值可能达到 0.05V 到 0.1V如果只有一个阈值电压刚过线就报警、刚回线就停蜂鸣器会频繁通断。更好的做法是在报警状态下每 200ms 判断一次在非报警状态下每 500ms 判断一次减少无谓的比较计算。OLED 刷新上不要让主循环每轮都刷屏。MQ5 电压一秒可能变化幅度很小而屏幕快速刷新会占据 I2C 总线间接影响串口发送的实时性。常见的做法是用一个 1 秒的软件定时器到时间才读取一次 MQ5 电压、刷新一次 OLED。刷新时还要注意“局部刷新”和“全屏刷新”的差异全屏OLED_Refresh()会把整块显存发给屏幕对 128×32 的 OLED 来说数据量约 512 到 1024 字节耗时不长但如果界面元素多最好只更新变化区域比如直接把数值所在的字符位置重新OLED_ShowChar再调用小范围刷新函数有些驱动没有局部刷新函数那就只能在显存缓存里修改对应位置的字节最后统一发送。日志分级适合做工程化。串口不只发 MQ5 数据还可以把系统状态、报警事件打包发给调试助手。我见过不少同学把printf打得满天飞结果一旦要定位问题文档里已有的大量数据淹没关键日志。建议把输出分成三个级别INFO正常数据、WARN超过阈值 80%、ALARM触发报警。定义一组宏就能控制哪些级别的日志可以通过串口输出例如#define LOG_LEVEL LOG_LEVEL_INFO #define LOG_INFO(...) do { if (LOG_LEVEL LOG_LEVEL_INFO) printf(__VA_ARGS__); } while(0) #define LOG_WARN(...) do { if (LOG_LEVEL LOG_LEVEL_WARN) printf(__VA_ARGS__); } while(0) #define LOG_ALARM(...) do { if (LOG_LEVEL LOG_LEVEL_ALARM) printf(__VA_ARGS__); } while(0)这样调试时只开 ALARM 级别就看到报警事件需要观察趋势时再打开 INFO 级别获得每秒一条的浓度序列。把这套串口日志分级和 OLED 显存局部刷新配合起来整个固件在调试和交付两种模式下都能保持稳定。实际量产时还能把LOG_LEVEL改成编译期 0让所有日志代码不参与编译不占 Flash 和 CPU 时间。本文还有配套的精品资源点击获取