简介:本资源是一套基于STM32F103C8T6单片机的MQ2烟雾浓度检测完整工程,面向嵌入式初学者与课程设计实践者,解决气体传感器数据采集、AD转换、非线性浓度拟合及OLED可视化显示等典型嵌入式开发问题。工程采用CubeMX图形化配置,集成ADC采样、I2C OLED驱动与拟合算法核心逻辑,通过RS/R0灵敏度曲线推导出ppm浓度值(公式:ppm=pow(21.72*R0/RS,2.1101)),具备较强教学示范性与工程复用价值。压缩包含1017个文件,主体为561个C源码与247个头文件(实现外设驱动与算法模块),辅以IAR工程配置文件(.icf/.uvprojx)、编译中间产物(.o/.d/.axf)及CMSIS数学库支持文件(如arm_rfft_init_f32.c等),总大小23.8MB,结构规范、模块解耦清晰。目前已有317人学习下载,提供从硬件连接、CubeMX配置、AD校准到浓度曲线拟合的全链路可运行代码,附带关键参数注释与计算逻辑说明,便于理解传感器标定原理与嵌入式数值处理方法。
1. 项目概述与核心价值
最近在做一个智能家居相关的环境监测小项目,核心需求是实时检测空气中的烟雾或可燃气体浓度。手头正好有经典的“蓝桥杯”开发板——STM32F103C8T6,以及常见的MQ-2烟雾传感器和0.96寸OLED屏。这个组合在电子爱好者圈子里非常经典,但网上很多教程只讲到读取AD值,对于如何将原始的AD值转换成有实际物理意义的浓度值(ppm),往往一笔带过或者方法过于粗糙。这次我决定深入一步,采用拟合曲线式的浓度转化算法,让显示的数据更接近真实情况。整个过程基于STM32CubeMX进行配置,使用HAL库开发,力求清晰、可复现。如果你也在做类似的气体传感器项目,希望这篇从硬件连接到软件算法、从CubeMX配置到代码调试的完整记录,能帮你避开我踩过的那些坑。
2. 硬件平台与核心器件选型解析
2.1 主控芯片:STM32F103C8T6的考量
选择STM32F103C8T6(以下简称C8T6)作为主控,几乎是性价比和生态的平衡之选。它属于ARM Cortex-M3内核,主频72MHz,64KB Flash,20KB RAM,对于本应用绰绰有余。更重要的是,它内置了12位精度的ADC(模数转换器),这正是我们采集模拟传感器信号的核心。C8T6有2个ADC单元(ADC1和ADC2),我们通常使用ADC1。其ADC的参考电压(Vref+)默认接在VDDA(通常为3.3V),这意味着ADC的输入电压范围是0~3.3V,任何超过此范围的电压都可能损坏ADC引脚,这是硬件设计的第一条红线。
注意:STM32F103C8T6的VDDA和VSSA(模拟电源和地)必须与数字电源妥善隔离,最好通过磁珠或0欧电阻连接,并在靠近芯片处放置10uF和0.1uF的退耦电容,这是保证ADC采样稳定的硬件基础,很多采样值跳动大的问题根源就在这里。
2.2 传感器:MQ-2的工作原理与接口
MQ-2是一种广谱的半导体式气敏元件,对液化气、丙烷、氢气、烟雾等都很敏感。它的核心是一个二氧化锡(SnO2)敏感层,在洁净空气中电导率较低,当接触到目标气体时,电导率随气体浓度升高而增加。我们通过一个简单的分压电路来将这种电导率变化转换为电压变化。
典型的接法是:MQ-2模块有4个引脚(VCC, GND, DO, AO)。其中AO(Analog Output)就是模拟输出引脚,它会输出一个与气体浓度相关的电压信号。模块内部通常已经集成了负载电阻(RL),我们直接使用即可。需要注意的是,MQ-2需要预热!上电后,其内部的加热丝需要工作一段时间(通常20秒到几分钟)才能达到稳定状态,此时输出的数据才可靠。很多新手忽略这一点,直接上电读数,会发现数值漂移非常严重。
2.3 显示模块:SSD1306驱动的OLED屏
0.96寸OLED屏,驱动芯片通常是SSD1306,接口有I2C和SPI两种。为了节省IO口,我选择了I2C接口(仅需2根线:SCL和SDA)。这种屏幕自发光、对比度高、功耗低,非常适合嵌入式显示。在编程上,我们需要一个驱动库来操作它,通常包括初始化、清屏、设置光标、显示字符/字符串、显示数字等功能。网络上有很多现成的“OLED驱动代码”,但质量参差不齐,最好找一个针对HAL库优化过的、代码结构清晰的版本进行移植。
3. 基于CubeMX的工程创建与配置
3.1 时钟树(Clock Tree)配置
这是CubeMX配置的第一步,也是确保系统稳定运行的基础。对于C8T6,我们通常使用外部高速时钟(HSE)。在RCC配置中,将HSE设置为“Crystal/Ceramic Resonator”。然后打开时钟配置视图,我的习惯配置路径如下:
- 在HSE输入框输入
8(MHz),这是外部晶振的典型值。 - 将PLL源选择为
HSE。 - 将PLL倍频系数设置为
9,这样PLL时钟 = 8MHz * 9 = 72MHz。 - 将系统时钟源(SYSCLK)选择为
PLLCLK。 - APB1总线时钟(PCLK1)最高为36MHz,分频系数设为2;APB2总线时钟(PCLK2)设为72MHz。
- 将ADC时钟(ADCCLK)配置为PCLK2经过分频,通常设为6分频,得到12MHz(ADC时钟不宜超过14MHz)。
这样,我们就得到了72MHz的系统主频和12MHz的ADC时钟,性能与稳定性兼顾。
3.2 ADC1的单通道配置
我们的目标是采集MQ-2 AO引脚输出的模拟电压。假设我们将AO引脚连接到C8T6的PA1(对应ADC1的通道1)。
- 在
Pinout & Configuration标签页,找到ADC1。 - 在
IN1(通道1)选择IN1 Single-ended(单端输入模式)。 - 在右侧的
Parameter Settings中,进行关键配置:Resolution: 选择12-bit。这是ADC的精度,决定了我们能把3.3V电压分成多少份(2^12=4096份)。Scan Conversion Mode:Disabled。因为我们只采集一个通道,不需要扫描模式。Continuous Conversion Mode:Enabled。开启连续转换模式,ADC会不停地采样转换,我们只需要定时去读取结果即可,简化编程。DMA Continuous Requests:Disabled。单通道简单应用,暂时不用DMA。End Of Conversion Selection:EOC flag at the end of single conversion。每次转换完成产生标志位。Low Power Auto Wait:Disabled。Clock Prescaler: 根据时钟树配置,选择PCLK2 divided by 6。Sampling Time: 采样时间。对于MQ-2这种变化不快的信号,可以设置长一些以提高精度,比如Cycles 239.5。采样时间越长,ADC内部的采样电容充电越充分,结果越准确,但转换速度会变慢。这里239.5个周期在12MHz时钟下约等于20us,足够稳定。
- 在
NVIC Settings中,可以开启ADC全局中断,但我们用轮询方式读取,这里先不开启。
配置完成后,CubeMX会自动将PA1引脚配置为模拟输入模式。
3.3 I2C1配置用于驱动OLED
如前所述,我使用的OLED屏是I2C接口。
- 找到
I2C1,将模式设置为I2C。 - 参数设置中,
I2C Speed Mode选择Standard Mode(标准模式,100kHz)或Fast Mode(400kHz)。对于SSD1306,标准模式足够稳定。如果屏幕刷新有闪动,可以尝试切换到快速模式。 I2C Clock Speed会自动计算,保持默认即可。- 对应的SCL(PB6)和SDA(PB7)引脚会自动配置。
3.4 生成工程代码
在Project Manager标签页,设置好工程名称、路径、IDE(如MDK-ARM V5)。关键点在于Code Generator部分:
- 建议勾选
Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral,这样每个外设的代码会独立成对的文件,结构更清晰。 - 勾选
Copy all used libraries into the project folder,这样工程移植性更好。 点击GENERATE CODE,CubeMX会生成一个完整的Keil(或其他IDE)工程。
4. 软件架构与核心代码实现
4.1 ADC数据采集与滤波处理
CubeMX生成的代码已经完成了ADC的初始化。我们需要在用户代码区域编写采集函数。
首先,在main.c的/* USER CODE BEGIN PV */区域定义变量:
/* Private variables ---------------------------------------------------------*/ ADC_HandleTypeDef hadc1; uint32_t adc_value_raw = 0; float adc_voltage = 0.0; #define ADC_SAMPLE_TIMES 10 // 采样次数,用于软件滤波然后,在/* USER CODE BEGIN 2 */区域启动ADC转换:
/* USER CODE BEGIN 2 */ HAL_ADC_Start(&hadc1); // 启动ADC连续转换 /* USER CODE END 2 */接下来,我们需要一个函数来获取经过滤波的ADC值。简单的算术平均滤波能有效抑制随机干扰:
/** * @brief 获取指定次数的ADC采样平均值 * @param hadc: ADC句柄指针 * @param times: 采样次数 * @retval 滤波后的ADC原始值 */ uint32_t Get_ADC_Average(ADC_HandleTypeDef* hadc, uint8_t times) { uint32_t temp_val = 0; uint8_t t; for(t = 0; t < times; t++) { HAL_ADC_PollForConversion(hadc, 10); // 等待转换完成,超时10ms if(HAL_IS_BIT_SET(HAL_ADC_GetState(hadc), HAL_ADC_STATE_REG_EOC)) { temp_val += HAL_ADC_GetValue(hadc); // 累加ADC值 } } return temp_val / times; // 返回平均值 }在main函数的循环中,我们可以这样调用并计算电压:
/* USER CODE BEGIN WHILE */ while (1) { /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ // 1. 获取滤波后的ADC原始值 adc_value_raw = Get_ADC_Average(&hadc1, ADC_SAMPLE_TIMES); // 2. 计算电压值 (Vref = 3.3V, 12位ADC满量程值 = 4095) adc_voltage = (float)adc_value_raw / 4095.0 * 3.3; // 3. 将电压值代入拟合曲线,计算浓度ppm(算法见下一节) // float ppm = Calculate_PPM(adc_voltage); // 4. 在OLED上显示(显示部分见下下节) // OLED_Show_Info(adc_value_raw, adc_voltage, ppm); HAL_Delay(500); // 每500ms更新一次数据 } /* USER CODE END 3 */4.2 MQ-2浓度拟合曲线算法详解
这是本项目的核心难点。MQ-2的 datasheet 通常会提供一个灵敏度特性曲线图,其X轴是气体浓度(ppm,对数坐标),Y轴是传感器电阻与洁净空气中电阻的比值(Rs/R0,对数坐标)。这条曲线近似于一条直线,其斜率反映了传感器对不同气体的灵敏度。
第一步:获取关键参数R0R0是传感器在洁净空气中的电阻值。我们需要在实际使用环境中进行校准。
- 将传感器置于已知的洁净空气中(比如室外通风处),预热至少20分钟。
- 测量此时AO引脚输出的电压,记为
Vc。 - 已知模块上的负载电阻
RL(常见为10kΩ),根据分压公式:Vc = Vcc * RL / (Rs + RL),可以推导出洁净空气中的传感器电阻Rs。 - 由于
Vcc通常为5V或3.3V(需根据模块实际接线确定),Vc由ADC测得,RL已知,则可计算出Rs,这个Rs就是我们要的R0。- 公式推导:
Vc / Vcc = RL / (Rs + RL)=>Rs = RL * (Vcc / Vc - 1)
- 公式推导:
第二步:建立拟合曲线模型在检测气体时,我们测得一个电压Vg,用同样的公式可以计算出当前气体环境下的传感器电阻Rs_g。 根据 datasheet 的曲线,Rs_g/R0与气体浓度X(ppm)在双对数坐标下呈线性关系:log10(Rs_g/R0) = m * log10(X) + b其中,m是斜率,b是截距。这两个参数需要从 datasheet 的曲线中选取两个点进行拟合计算,或者查找对应气体的典型值(例如,对于液化气,m约-0.65,b约1.1)。
第三步:C语言代码实现将上述数学模型转化为代码:
// 在文件开头定义或从校准中获取 #define RL 10.0 // 负载电阻,单位kΩ #define VCC 5.0 // 模块供电电压,单位V #define R0_CLEAN_AIR 9.83 // 示例:在洁净空气中校准得到的R0值,单位kΩ #define SLOPE_M -0.65 // 曲线斜率(针对特定气体,如液化气) #define INTERCEPT_B 1.1 // 曲线截距 /** * @brief 根据ADC采集的电压计算气体浓度(ppm) * @param voltage: ADC测量得到的电压值(V) * @retval 计算出的气体浓度(ppm) */ float Calculate_PPM(float voltage) { float rs_gas; // 当前气体浓度下的传感器电阻 float ratio; // Rs/R0 float ppm; // 1. 计算当前Rs值 (kΩ) // 公式: Rs = RL * (Vcc / Vg - 1), Vg是测量电压 // 注意:Vg不能为0,程序中需做保护 if(voltage < 0.001) voltage = 0.001; // 防止除零 rs_gas = RL * (VCC / voltage - 1.0); // 2. 计算Rs/R0比值 ratio = rs_gas / R0_CLEAN_AIR; // 3. 使用对数拟合公式计算浓度 ppm = 10 ^ { [log10(ratio) - b] / m } // 即:ppm = pow(10, (log10(ratio) - INTERCEPT_B) / SLOPE_M) // 注意:ratio必须大于0,SLOPE_M不能为0 if(ratio <= 0) ratio = 0.001; ppm = powf(10.0, (log10f(ratio) - INTERCEPT_B) / SLOPE_M); return ppm; }实操心得:这里的
R0_CLEAN_AIR、SLOPE_M和INTERCEPT_B是算法的关键参数,直接影响精度。R0必须通过实地校准获得。M和B值强烈依赖于目标气体和传感器批次,最好从供应商处获取精确值,或者自己通过已知浓度的标准气体进行标定后反推。网上找到的参数仅供参考,直接套用误差可能很大。
4.3 OLED显示驱动与界面设计
我们需要一个OLED驱动库。假设我们已经移植好了一个基于HAL库I2C的oled.c和oled.h,它提供了基本的画点、显示字符/字符串、显示数字的函数。
首先,在main.c中包含头文件并初始化OLED:
/* USER CODE BEGIN Includes */ #include "oled.h" #include <stdio.h> // 用于sprintf格式化字符串 /* USER CODE END Includes */ /* USER CODE BEGIN 2 */ OLED_Init(); // OLED初始化 OLED_Clear(); // 清屏 OLED_ShowString(0, 0, (uint8_t *)"MQ-2 Monitor", 16); // 第0行,显示标题 OLED_ShowString(0, 2, (uint8_t *)"ADC:", 16); OLED_ShowString(0, 4, (uint8_t *)"Volt:", 16); OLED_ShowString(0, 6, (uint8_t *)"PPM:", 16); /* USER CODE END 2 */然后,在主循环中更新数据。为了避免屏幕频繁全刷导致的闪烁,我们采用局部更新策略,只更新数值变化的区域。
// 在while(1)循环内,获取adc_value_raw, adc_voltage, ppm后 char str_buf[20]; // 显示ADC原始值 (假设显示在2行,列坐标40像素处) sprintf(str_buf, "%4d", adc_value_raw); OLED_ShowString(40, 2, (uint8_t *)str_buf, 16); // 显示电压值,保留2位小数 sprintf(str_buf, "%5.2fV", adc_voltage); OLED_ShowString(40, 4, (uint8_t *)str_buf, 16); // 显示浓度值,由于ppm值可能跨度大,可以动态调整显示格式 if(ppm < 1000) { sprintf(str_buf, "%6.1f", ppm); } else { sprintf(str_buf, "%6.0f", ppm); } OLED_ShowString(40, 6, (uint8_t *)str_buf, 16); OLED_ShowString(90, 6, (uint8_t *)"ppm", 16); // 显示单位这样,一个简单的实时监测界面就完成了。你可以进一步增加报警阈值、历史曲线等功能。
5. 系统集成、调试与性能优化
5.1 工程集成与编译下载
将oled.c等驱动文件添加到工程的Src文件夹,头文件路径添加到IDE的配置中。检查所有函数调用无误后,编译工程。确保没有错误和警告。使用ST-Link或串口等方式将程序下载到C8T6开发板。
5.2 上电调试与问题排查
OLED不亮或乱码:
- 检查硬件:确认VCC、GND、SCL、SDA连接正确且牢固。I2C需要上拉电阻(通常4.7kΩ),如果模块上没有集成,需要在SCL和SDA线上拉到3.3V。
- 检查地址:SSD1306的I2C地址通常是0x78(写地址)或0x7A,检查驱动代码中的设备地址是否正确。
- 检查初始化序列:有些OLED模块对初始化时序要求严格,确保驱动代码中的初始化命令(如关闭显示、设置对比度、扫描方向等)正确且完整。
ADC采样值跳动大:
- 硬件滤波:在MQ-2的AO输出引脚和ADC输入引脚之间,增加一个RC低通滤波电路(例如,一个1kΩ电阻串联,一个0.1uF电容对地),可以极大抑制高频噪声。
- 软件滤波:除了平均滤波,可以尝试中值滤波或卡尔曼滤波。增加
ADC_SAMPLE_TIMES的次数(比如50次)也能显著平滑数据,但会降低响应速度。 - 电源噪声:用示波器测量开发板的3.3V和GND,看是否有明显的纹波。确保模拟部分供电干净。
- 采样时间:适当增加ADC配置中的
Sampling Time,给采样电容更充分的充电时间。
浓度值(ppm)不准或为异常值(如NaN、inf):
- 检查计算公式:仔细核对
Calculate_PPM函数中的每一个步骤,特别是对数运算log10f和幂运算powf。确保传入log10f的参数大于0。 - 检查参数:反复确认
R0_CLEAN_AIR、SLOPE_M、INTERCEPT_B、VCC、RL这几个参数的数值和单位是否正确。VCC是模块的供电电压,不一定是单片机IO的3.3V。 - 校准R0:这是最常见的误差来源。务必在通风良好的洁净空气中,等待传感器充分预热后(至少20分钟),再进行R0的校准测量。可以多次测量取平均。
- 数据范围保护:在计算
rs_gas时,voltage不能等于或接近0,也不能大于VCC。在计算log10(ratio)时,ratio必须为正数。代码中必须加入保护性判断。
- 检查计算公式:仔细核对
5.3 优化与扩展建议
- 使用DMA+定时器触发ADC:当前采用轮询方式,会占用CPU时间。可以配置一个定时器以固定频率(如10Hz)触发ADC转换,并启用DMA将转换结果自动搬运到内存中的数组。这样CPU完全被解放,只需在需要时读取数组中的最新值即可,效率更高,系统更实时。
- 增加阈值报警:可以设置一个烟雾浓度报警阈值(如200ppm),当计算出的ppm值超过该阈值时,让一个LED闪烁或蜂鸣器鸣叫。
- 数据上传:可以通过串口(UART)将ADC值、电压、ppm值实时发送到电脑上位机,用串口助手或自己编写的上位机软件进行记录和图表展示。
- 多传感器支持:C8T6的ADC1支持多通道扫描。你可以类似地接入MQ-135(空气质量)、DHT11(温湿度)等传感器,在OLED上分页或同屏显示更多环境信息。
- 改善显示:使用OLED的绘图功能,可以绘制一个简单的实时浓度曲线图,直观显示浓度变化趋势。
整个项目从硬件连接到CubeMX配置,再到核心算法实现和调试,涉及了嵌入式开发中传感器应用的几个关键环节。最重要的是理解MQ-2的模拟特性以及如何通过数学建模将电压值转化为有意义的浓度值。这个过程可能会因为传感器个体差异、环境温湿度等因素而需要反复调试和校准,但这正是嵌入式开发的乐趣所在——在软件和硬件的交汇处解决问题,让设备真正“感知”世界。
本文还有配套的精品资源,点击获取