
之前搭过不少单片机小项目这次把“酒精检测系统”从硬件选型到代码实现完整梳理了一遍。很多同学拿到这类题目时最缺的不是电路图而是一套能直接跑起来的工程思路传感器怎么采、数据怎么滤波、报警怎么分级、OLED 怎么显示。本文就以 STM32F103C8T6 为核心配合 MQ-3 酒精传感器、0.96 寸 OLED 屏、蜂鸣器和按键从 CubeMX 配置到 HAL 库代码逐层展开。不管你是做课程设计、毕业设计还是想了解 MQ-3 这类气体传感器的实际用法这篇文章都可以直接照着做。1.1 应用场景酒精检测系统最常见的需求来自两个方向一是安全监管比如驾驶员酒驾预警、化工厂或精密车间禁止酒后上岗二是教学演示很多嵌入式课程设计和毕业设计都会选这个题目因为它覆盖了 ADC 采样、传感器校准、显示驱动、GPIO 控制和告警逻辑几乎把 STM32 最基础的外设都用了一遍。这套系统的价值在于它不只是“用 MQ-3 读一个电压”而是把数据从传感器一路传递到 OLED 屏和蜂鸣器形成完整的“采集—处理—显示—告警”闭环。理解了这条链路后续做温湿度检测、烟雾报警、空气质量监测等项目都是同一套套路。1.2 MQ-3 酒精传感器原理MQ-3 是经典的半导体酒精传感器内部核心是一个用二氧化锡SnO2材料制成的气敏电阻。当空气中存在酒精蒸汽时敏感材料表面的电导率会发生变化等效电阻下降。传感器模块内部一般会串联一个负载电阻并通过电压比较器输出模拟电压 AO 和数字电平 DO。实际使用中MQ-3 需要注意两点预热时间传感器内部有加热丝上电后需要预热几分钟读数才会稳定。绝对浓度难标定MQ-3 对酒精、汽油、烟雾等多种气体都有响应而且个体离散性大。想测出“精确到 mg/100mL 的呼气酒精浓度”必须做标准气体标定。所以本文把目标定为“相对浓度检测分级告警”这也是大多数开源课程项目的通用做法。1.3 为什么选择 STM32对比 51 单片机和 ArduinoSTM32 的优势非常明显12 位 ADC采样分辨率比 51 的 8 位/10 位 ADC 高得多内置 I2C、USART、定时器OLED 和串口都能直接驱动HAL 库加 STM32CubeMX 图形化配置开发效率高资料丰富正点原子、野火、江协科技等团队都有大量入门教程。这套系统选用的是最常见的 STM32F103C8T6也就是大家常说的“蓝丸”核心板价格便宜、引脚够用、资料最多。2. 系统总体设计与模块选型2.1 功能需求清单在设计之前先把功能拆清楚功能说明酒精浓度采集MQ-3 输出模拟电压STM32 通过 ADC1 采集数据滤波多次采样取平均降低传感器噪声OLED 显示显示当前电压、浓度指数、告警等级分级告警正常/警告/严重三级对应 LED 和蜂鸣器不同动作一键校准按键触发基线校准消除传感器个体差异串口调试通过 USART1 输出采样数据方便上位机观察2.2 模块选型表模块型号/规格作用主控STM32F103C8T6 核心板数据处理和控制酒精传感器MQ-3 模块酒精浓度检测显示屏0.96 寸 OLEDSSD1306I2C 接口数据显示蜂鸣器有源蜂鸣器5V/3.3V 均可声光告警LED板载 PC13 LED 或外接 LED告警指示按键轻触按键手动校准烧录器ST-Link V2程序下载和调试2.3 工作流程系统上电后先初始化时钟、ADC、I2C、串口和 GPIO随后 OLED 显示开机画面。进入主循环后程序连续读取 MQ-3 的模拟电压经过平均值滤波后与标定的基线电压比较得到一个相对浓度指数。根据指数大小切换“正常/警告/严重”三种状态同时刷新 OLED 和控制蜂鸣器。这里最关键的环节是“基线校准”。因为 MQ-3 在不同温湿度、不同批次下干净空气中的输出电压都不一样。如果不校准就直接拿绝对电压做阈值结果很容易误报或漏报。2.4 硬件框图下面用文字画出信号流向MQ-3 模块 AO 输出 │ │ 经过 10K10K 分压防止电压超过 3.3V ▼ STM32F103C8T6 PA1 (ADC1_IN1) │ ├── I2C1(PB6/PB7) ──→ 0.96 寸 OLED ├── USART1(PA9) ──→ 串口调试助手 ├── PB12 ──→ 有源蜂鸣器 ├── PC13 ──→ 板载 LED └── PB13 ──→ 校准按键3. 开发环境与工具链准备3.1 开发板与烧录器本文以 STM32F103C8T6 蓝丸核心板为例烧录器使用 ST-Link V2。接线方式很固定ST-Link 的 SWDIO 接 PA13SWCLK 接 PA14GND 接 GND3.3V 接 3.3V。如果核心板本身没有外部供电通过 ST-Link 的 3.3V 引脚也能给板子供电但建议单独供电更稳定。3.2 软件工具软件用途STM32CubeMX图形化配置引脚、时钟和外设Keil MDK5编写代码、编译下载串口调试助手查看 printf 输出的调试数据STM32CubeProgrammer连接异常时擦除芯片、恢复 SWDPCtoLCD2002OLED 字模取模工具版本上不需要纠结CubeMX 使用当前较新的版本即可Keil MDK5 安装时记得把对应芯片的 Device Family Pack 装好。如果你以前装过 Keil C51再装 MDK 时两者可以共存只是安装路径和许可证需要分别处理。3.3 常见工具链问题预告很多初学者在烧录时都会遇到类似报错Error: no STM32 target found! If your product embeds Debug Authentication, please perform a recovery sequence to reactivate it.这个报错的本质是 ST-Link 没有和芯片建立 SWD 连接。常见原因有SWDIO/SWCLK 接反、目标板没供电、芯片被读保护、或者 ST-Link 固件异常。遇到时先检查接线和供电再换 STM32CubeProgrammer 试一次连接。如果芯片开了读保护可以执行全片擦除Mass Erase后再烧录。这个问题在后面第 8 章会再展开。4. STM32CubeMX 初始化配置4.1 新建工程与芯片选择打开 STM32CubeMX新建工程在芯片搜索框输入STM32F103C8选择 LQFP48 封装的 STM32F103C8Tx。注意不需要在 CubeMX 里选择“开发板”蓝丸核心板直接用裸芯片配置即可。4.2 时钟配置在 Clock Configuration 页面配置系统时钟为 72MHz。蓝丸核心板默认带有 8MHz 晶振RCC 中启用 HSEPLL 设置为 9 倍频AHB/APB 分频保持默认即可。72MHz 是 F103 的主频上限ADC 和外设都依赖这个时钟树。4.3 外设配置需要配置的外设和对应引脚如下外设配置项引脚ADC1IN112 位分辨率采样时间 55.5 Cycles软件触发PA1I2C1快速模式 400kHz或标准模式 100kHzPB6/PB7USART11152008 数据位1 停止位无校验PA9/PA10GPIOPB12 为推挽输出PC13 为推挽输出PB13 为上拉输入PB12/PC13/PB13GPIO 的初始电平设置为HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_SET); // 蜂鸣器默认关闭 HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); // LED 默认关闭这里蜂鸣器采用“低电平响”的有源蜂鸣器模块。不同模块极性不同如果你的蜂鸣器是高电平触发后面逻辑反过来写就行。4.4 生成 MDK 工程在 Project Manager 页面设置好工程名称和路径Toolchain 选择 MDK-ARM然后点击 GENERATE CODE。生成后的工程里已经有 SystemClock_Config、MX_GPIO_Init、MX_ADC1_Init、MX_I2C1_Init、MX_USART1_UART_Init 这些函数我们只需要在这些基础上添加模块代码。5. 硬件电路搭接5.1 MQ-3 酒精传感器MQ-3 模块一般有 4 个引脚VCC、GND、DO、AO。VCC 接 5VGND 接 GNDDO 是数字比较输出AO 是模拟输出接 STM32 的 PA1。这里有一个容易踩坑的细节MQ-3 模块在 5V 供电时AO 输出电压可能超过 3.3V直接接到 STM32 的 PA1 有烧引脚的风险。稳妥的做法是在 AO 和 PA1 之间加一个分压电路用两个 10K 电阻串联从中间取电压。分压比是 10K/(10K10K)0.5也就是说 ADC 读到的电压只有传感器实际输出电压的一半。代码里要把采样电压乘以 2也就是后面定义的DIVIDER_RATIO2.0f。5.2 OLED 显示屏0.96 寸 OLED 屏的 I2C 接口有 4 个引脚VCC3.3V、GND、SCL、SDA。SCL 接 PB6SDA 接 PB7对应 STM32 的 I2C1。OLED 地址通常是 0x787 位地址 0x3C 左移一位如果你的屏是 0x7A需要修改驱动里的OLED_ADDR。5.3 蜂鸣器、LED 与按键蜂鸣器接 PB12控制信号经过一个 NPN 三极管如 S8050再驱动蜂鸣器不要直接用 GPIO 推蜂鸣器电流不够。板载 LED 在 PC13低电平点亮。校准按键接在 PB13 和 GND 之间PB13 配置为上拉输入按下时读到低电平。为了消抖代码里会在检测到按下后延时 30ms 再确认。5.4 接线汇总表模块引脚STM32 引脚说明MQ-3 VCC5V传感器供电MQ-3 GNDGND共地MQ-3 AOPA1经两个 10K 分压后接入OLED SCLPB6I2C1_SCLOLED SDAPB7I2C1_SDAOLED VCC3.3VOLED 供电蜂鸣器控制PB12经三极管驱动LEDPC13低电平点亮按键PB13按下接地6. 核心代码实现这一章是重点。为了让代码结构清楚我们把工程按职责分成三个模块ssd1306.c负责 OLED 驱动alcohol.c负责传感器采样和校准main.c只负责应用逻辑。6.1 工程目录规划CubeMX 生成的工程默认有Core/Src和Drivers。可以继续在Core/Src下新建两个文件或者单独建一个App目录Core/ ├── Inc/ │ ├── main.h │ ├── ssd1306.h │ └── alcohol.h └── Src/ ├── main.c ├── ssd1306.c └── alcohol.c把文件放进 Keil 工程时记得在 Target 里把新加的.c文件加入 Source Group并在 Include Paths 里添加头文件路径。6.2 OLED 驱动库 ssd1306先看头文件ssd1306.h#ifndef __SSD1306_H #define __SSD1306_H #include main.h #define OLED_WIDTH 128 #define OLED_HEIGHT 64 #define OLED_ADDR 0x78 /* I2C 写地址7 位地址 0x3C 左移一位 */ void OLED_Init(void); void OLED_Clear(void); void OLED_Refresh(void); void OLED_ShowString(uint8_t x, uint8_t y, const char *str); void OLED_ShowNum(uint8_t x, uint8_t y, uint32_t num, uint8_t len); void OLED_ShowFloat(uint8_t x, uint8_t y, float val, uint8_t intLen, uint8_t decLen); void OLED_DrawLine_H(uint8_t x0, uint8_t x1, uint8_t y); #endifOLED_ShowFloat这里的y是页号0~7128x64 的 OLED 被分成 8 页每页 8 行像素。这种“页寻址”概念是 SSD1306 的核心理解了页写显示函数就顺了。再看ssd1306.c。驱动里维护了一个 1024 字节的显存缓冲区所有绘制先写缓冲区最后通过OLED_Refresh()一次性刷到屏幕上。这样能避免频繁 I2C 操作显示也不会有闪烁。#include ssd1306.h #include i2c.h #include string.h static uint8_t oled_buf[OLED_WIDTH * OLED_HEIGHT / 8]; static uint8_t OLED_SendCmd(uint8_t cmd) { return HAL_I2C_Mem_Write(hi2c1, OLED_ADDR, 0x00, I2C_MEMADD_SIZE_8BIT, cmd, 1, 10); } static void OLED_DrawPixel(uint8_t x, uint8_t y, uint8_t color) { uint16_t idx; if (x OLED_WIDTH || y OLED_HEIGHT) { return; } idx x (y / 8) * OLED_WIDTH; if (color) { oled_buf[idx] | (1 (y % 8)); } else { oled_buf[idx] ~(1 (y % 8)); } } void OLED_Refresh(void) { OLED_SendCmd(0x21); /* 设置列地址范围 */ OLED_SendCmd(0x00); OLED_SendCmd(0x7F); OLED_SendCmd(0x22); /* 设置页地址范围 */ OLED_SendCmd(0x00); OLED_SendCmd(0x07); HAL_I2C_Mem_Write(hi2c1, OLED_ADDR, 0x40, I2C_MEMADD_SIZE_8BIT, oled_buf, sizeof(oled_buf), 50); } void OLED_Clear(void) { memset(oled_buf, 0, sizeof(oled_buf)); }初始化函数主要是向 SSD1306 写入控制寄存器包括打开电荷泵、设置对比度、设置扫描方向等。这段序列是 SSD1306 类屏幕通用的可以直接照用void OLED_Init(void) { HAL_Delay(50); OLED_SendCmd(0xAE); /* 关闭显示 */ OLED_SendCmd(0x20); /* 设置内存寻址模式 */ OLED_SendCmd(0x00); /* 水平寻址 */ OLED_SendCmd(0xB0); /* 设置页起始地址 */ OLED_SendCmd(0xC8); /* COM 扫描方向从上到下 */ OLED_SendCmd(0x00); /* 列地址低四位 */ OLED_SendCmd(0x10); /* 列地址高四位 */ OLED_SendCmd(0x40); /* 显示起始行 0 */ OLED_SendCmd(0x81); /* 设置对比度 */ OLED_SendCmd(0xCF); OLED_SendCmd(0xA1); /* 段重映射 */ OLED_SendCmd(0xA6); /* 正常显示 */ OLED_SendCmd(0xA8); /* 设置复用比 */ OLED_SendCmd(0x3F); OLED_SendCmd(0xD3); /* 设置显示偏移 */ OLED_SendCmd(0x00); OLED_SendCmd(0xD5); /* 设置时钟分频 */ OLED_SendCmd(0x80); OLED_SendCmd(0xD9); /* 设置预充电周期 */ OLED_SendCmd(0xF1); OLED_SendCmd(0xDA); /* 设置 COM 引脚配置 */ OLED_SendCmd(0x12); OLED_SendCmd(0xDB); /* 设置 VCOMH */ OLED_SendCmd(0x40); OLED_SendCmd(0x8D); /* 打开电荷泵 */ OLED_SendCmd(0x14); OLED_SendCmd(0xAF); /* 开启显示 */ OLED_Clear(); OLED_Refresh(); }字符显示部分本文使用精简的 6x8 字模通过一个字符映射表font_map查表显示。字模用 PCtoLCD2002 按“列行式、6x8、低位在前”生成。下面只保留了本项目需要的字符如果你想显示更多内容用取模工具生成后追加到表里即可。static const char font_map[] 0123456789:.-ACEGHILMNORTVWSB; static const uint8_t font6x8[][6] { {0x00,0x00,0x00,0x00,0x00,0x00}, /* */ {0x3E,0x41,0x41,0x41,0x3E,0x00}, /* 0 */ {0x00,0x42,0x7F,0x40,0x00,0x00}, /* 1 */ {0x62,0x49,0x49,0x49,0x46,0x00}, /* 2 */ {0x41,0x49,0x49,0x49,0x36,0x00}, /* 3 */ {0x18,0x14,0x13,0x10,0x10,0x00}, /* 4 */ {0x47,0x41,0x45,0x45,0x3B,0x00}, /* 5 */ {0x3E,0x41,0x49,0x49,0x38,0x00}, /* 6 */ {0x01,0x71,0x05,0x09,0x03,0x00}, /* 7 */ {0x36,0x49,0x49,0x49,0x36,0x00}, /* 8 */ {0x06,0x49,0x49,0x49,0x3E,0x00}, /* 9 */ {0x00,0x00,0x36,0x00,0x00,0x00}, /* : */ {0x00,0x00,0x00,0x60,0x60,0x00}, /* . */ {0x00,0x00,0x08,0x08,0x08,0x00}, /* - */ {0x7E,0x09,0x09,0x09,0x7E,0x00}, /* A */ {0x3E,0x41,0x41,0x41,0x22,0x00}, /* C */ {0x7F,0x49,0x49,0x49,0x41,0x00}, /* E */ {0x3E,0x41,0x49,0x49,0x3A,0x00}, /* G */ {0x7F,0x08,0x08,0x08,0x7F,0x00}, /* H */ {0x41,0x41,0x7F,0x41,0x41,0x00}, /* I */ {0x7F,0x40,0x40,0x40,0x00,0x00}, /* L */ {0x7F,0x02,0x0C,0x02,0x7F,0x00}, /* M */ {0x7F,0x02,0x0C,0x08,0x7F,0x00}, /* N */ {0x3E,0x41,0x41,0x41,0x3E,0x00}, /* O */ {0x7F,0x09,0x09,0x29,0x46,0x00}, /* R */ {0x01,0x01,0x7F,0x01,0x01,0x00}, /* T */ {0x1F,0x20,0x40,0x20,0x1F,0x00}, /* V */ {0x7F,0x20,0x18,0x20,0x7F,0x00}, /* W */ {0x36,0x49,0x49,0x41,0x32,0x00}, /* S */ {0x7F,0x41,0x49,0x49,0x36,0x00}, /* B */ }; static void OLED_ShowChar(uint8_t x, uint8_t y, char c) { const char *p strchr(font_map, c); uint8_t idx, i, b; if (p NULL) { return; } idx (uint8_t)(p - font_map); for (i 0; i 6; i) { for (b 0; b 8; b) { OLED_DrawPixel(x i, y * 8 b, (font6x8[idx][i] b) 0x01); } } } void OLED_ShowString(uint8_t x, uint8_t y, const char *str) { while (*str) { OLED_ShowChar(x, y, *str); x 6; str; } }为了显示整数和浮点数再封装两个工具函数static void U2S(char *buf, uint32_t val, uint8_t len) { int8_t i; for (i len - 1; i 0; i--) { buf[i] 0 (val % 10); val / 10; } buf[len] \0; } void OLED_ShowNum(uint8_t x, uint8_t y, uint32_t num, uint8_t len) { char buf[11]; U2S(buf, num, len); OLED_ShowString(x, y, buf); } void OLED_ShowFloat(uint8_t x, uint8_t y, float val, uint8_t intLen, uint8_t decLen) { char buf[16]; uint32_t ip (uint32_t)val; uint32_t fp (uint32_t)((val - ip) * 100.0f); U2S(buf, ip, intLen); buf[intLen] .; char d[3]; U2S(d, fp, decLen); buf[intLen 1] d[0]; buf[intLen 2] d[1]; buf[intLen 3] \0; OLED_ShowString(x, y, buf); } void OLED_DrawLine_H(uint8_t x0, uint8_t x1, uint8_t y) { uint8_t x; for (x x0; x x1; x) { OLED_DrawPixel(x, y, 1); } }6.3 传感器采集与校准模块 alcoholalcohol.h定义三个等级和对外接口#ifndef __ALCOHOL_H #define __ALCOHOL_H #include main.h typedef enum { LEVEL_NORMAL 0, LEVEL_WARN, LEVEL_ALARM } Level_t; float Sensor_GetVoltage(void); void Sensor_Calibrate(void); Level_t Sensor_GetLevel(float volt); float Sensor_GetRatio(float volt); extern float g_base_voltage; #endifalcohol.c里完成核心的采样和校准逻辑。这里必须说明一个原则MQ-3 的输出电压与浓度之间不是简单的线性关系而且受环境温湿度影响很大。所以代码里不直接输出“ppm 浓度”而是计算“相对基线电压的变化比例”。当这个比例超过 30% 判为警告超过 60% 判为严重。阈值可以在后面根据实际测试微调。#include alcohol.h #include adc.h #define VOLT_REF_MV 3300.0f #define DIVIDER_RATIO 2.0f #define SAMPLE_TIMES 16 #define CALIBRATE_TIMES 50 float g_base_voltage 0.0f; static uint16_t Read_ADC_Average(uint8_t times) { uint32_t sum 0; uint8_t i; for (i 0; i times; i) { HAL_ADC_Start(hadc1); if (HAL_ADC_PollForConversion(hadc1, 10) HAL_OK) { sum HAL_ADC_GetValue(hadc1); } HAL_ADC_Stop(hadc1); HAL_Delay(1); } return (uint16_t)(sum / times); } float Sensor_GetVoltage(void) { uint16_t adc Read_ADC_Average(SAMPLE_TIMES); float mv (adc / 4095.0f) * VOLT_REF_MV * DIVIDER_RATIO; return mv / 1000.0f; } void Sensor_Calibrate(void) { float sum 0.0f; uint8_t i; for (i 0; i CALIBRATE_TIMES; i) { sum Sensor_GetVoltage(); HAL_Delay(20); } g_base_voltage sum / CALIBRATE_TIMES; } float Sensor_GetRatio(float volt) { float diff volt - g_base_voltage; float ratio; if (g_base_voltage 0.01f) { ratio (diff / g_base_voltage) * 100.0f; } else { ratio 0.0f; } if (ratio 0.0f) { ratio 0.0f; } if (ratio 100.0f) { ratio 100.0f; } return ratio; } Level_t Sensor_GetLevel(float volt) { float ratio Sensor_GetRatio(volt); if (ratio 60.0f) { return LEVEL_ALARM; } if (ratio 30.0f) { return LEVEL_WARN; } return LEVEL_NORMAL; }6.4 主程序逻辑main.c里只做流程编排。CubeMX 自动生成的初始化函数保持不变我们重点关注用户代码区。/* USER CODE BEGIN 4 */ static void Display_Update(float volt, Level_t level) { float ratio Sensor_GetRatio(volt); uint8_t bar, x; OLED_ShowString(0, 0, ALCOHOL TEST); OLED_ShowString(0, 2, VOLT:); OLED_ShowFloat(40, 2, volt, 1, 2); OLED_ShowString(70, 2, V); OLED_ShowString(0, 3, LEVEL:); switch (level) { case LEVEL_NORMAL: OLED_ShowString(42, 3, NORMAL); break; case LEVEL_WARN: OLED_ShowString(42, 3, WARN); break; case LEVEL_ALARM: OLED_ShowString(42, 3, HIGH); break; default: break; } bar (uint8_t)ratio; for (x 0; x 127; x) { if (x bar) { OLED_DrawLine_H(x, x, 40); } else { OLED_DrawPixel(x, 40, 0); } } OLED_DrawLine_H(0, 127, 38); OLED_DrawLine_H(0, 127, 46); OLED_Refresh(); } static void Alarm_Control(Level_t level) { switch (level) { case LEVEL_NORMAL: HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_SET); HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); break; case LEVEL_WARN: HAL_GPIO_TogglePin(BUZZER_GPIO_Port, BUZZER_Pin); HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); break; case LEVEL_ALARM: HAL_GPIO_WritePin(BUZZER_GPIO_Port, BUZZER_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); break; default: break; } } /* USER CODE END 4 */主循环如下int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); MX_I2C1_Init(); MX_USART1_UART_Init(); OLED_Init(); OLED_ShowString(16, 0, ALCOHOL); OLED_ShowString(16, 2, STARTING); OLED_Refresh(); Sensor_Calibrate(); printf(Calibrate done, base%.3fV\r\n, g_base_voltage); OLED_Clear(); while (1) { float volt Sensor_GetVoltage(); Level_t level Sensor_GetLevel(volt); /* 按键校准按下 PB13 */ if (HAL_GPIO_ReadPin(BTN_GPIO_Port, BTN_Pin) GPIO_PIN_RESET) { HAL_Delay(30); if (HAL_GPIO_ReadPin(BTN_GPIO_Port, BTN_Pin) GPIO_PIN_RESET) { Sensor_Calibrate(); OLED_ShowString(0, 5, CALIB OK); OLED_Refresh(); HAL_Delay(500); OLED_Clear(); } while (HAL_GPIO_ReadPin(BTN_GPIO_Port, BTN_Pin) GPIO_PIN_RESET); } Display_Update(volt, level); Alarm_Control(level); printf(volt%.3fV level%d\r\n, volt, level); HAL_Delay(200); } }6.5 串口 printf 重定向Keil 工程里使用 MicroLIB 时可以用最简单的重定向方式。在usart.c或main.c中加入#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFF); return ch; }然后在 Keil 的 Options for Target 里勾选 Use MicroLIB这样printf就能正常输出到串口调试助手。7. 运行与验证7.1 编译烧录在 Keil 中编译工程确认没有报错后用 ST-Link 下载。第一次连接时如果 Keil 识别不到芯片回到第 3.3 节排查。下载成功后按住复位键系统会先显示开机画面完成自动校准。7.2 实验演示校准完成之后可以做三组实验干净空气OLED 上 LEVEL 显示 NORMAL蜂鸣器不响LED 熄灭。靠近酒精拿一小杯医用酒精放在 MQ-3 传感器附近不要直接接触几秒后 OLED 电压上升LEVEL 变为 WARN 或 HIGH蜂鸣器开始报警。移开酒精把酒精杯移开等待传感器恢复浓度指数下降报警解除。7.3 预期结果串口调试助手在 115200 波特率下会周期输出类似下面内容Calibrate done, base0.620V volt0.624V level0 volt0.631V level0 volt0.912V level1 volt1.346V level2level0表示正常level1表示警告level2表示严重。因为我这里设置了DIVIDER_RATIO2.0所以打印的volt是传感器实际输出端的电压而不是 ADC 引脚上的分压值。8. 常见问题与排查思路8.1 问题速查表问题现象常见原因解决思路ST-Link 烧录报 no STM32 target foundSWD 接线错误、目标未供电、芯片读保护检查 SWDIO/SWCLK/GND单独供电用 CubeProgrammer 全片擦除电脑上 STM32 Virtual COM Port 显示叹号ST-Link 的 VCP 驱动没装好重新安装 ST-Link 官方驱动OLED 黑屏或花屏I2C 地址错误、接线松动、缺上拉电阻核对 OLED_ADDR检查 SCL/SDAI2C 引脚加 4.7K 上拉ADC 读数一直 4095引脚输入悬空、分压电路没接、PA1 配置错误检查分压电阻和接线确认 CubeMX 里 PA1 配成 ADC1_IN1MQ-3 读数一直漂预热不足、供电波动、环境气流干扰预热 3~5 分钟多次采样平均重新按键校准蜂鸣器在干净空气中也报警校准基线不准在通风良好的位置重新按键校准一靠近酒精蜂鸣器就乱叫传感器灵敏度过高把Sensor_GetLevel里的 30/60 阈值调大printf 没有输出没有勾选 MicroLIB或重定向函数缺失在 Keil 里勾选 Use MicroLIB并确认 fputc 实现8.2 典型问题详解先说最折磨人的no STM32 target found。这类报错在 ST-Link 连接时很常见尤其是把程序下载进芯片之后如果代码里把 SWD 引脚 PB3/PB4 之类的引脚复用掉了下次可能就连不上。蓝丸核心板一般不会遇到但如果是自制板或者改了引脚映射就要小心。排查顺序是先看 ST-Link 的指示灯是否正常再量目标板 3.3V 电压最后用 STM32CubeProgrammer 的 Connect 按钮试一次。如果提示芯片处于读保护状态执行 Full chip erase 解锁。再说 OLED 黑屏。很多人第一次用 I2C 接口的 OLED 时总以为只要接线对就能亮但 SSD1306 有几类很隐蔽的坑一是地址不对有的屏是 0x7A二是 I2C 总线上没有上拉电阻虽然一些模块自带但有些裸屏没有三是模块供电不足I2C 通信会不稳定。遇到花屏、显示一半、显示乱码优先查这三项。ADC 读数恒为 4095 的问题也很好定位。4095 是 12 位 ADC 满量程说明 PA1 输入电压超过了参考电压或者引脚悬空导致读数随机。如果分压电路没焊MQ-3 的 AO 直接接 5V 电压进来读数就会一直是满量程。反过来如果用 3.3V 给 MQ-3 模块供电AO 输出范围也会变小需要重新调整分压比。9. 最佳实践与工程建议9.1 采样与滤波MQ-3 属于化学类传感器输出信号中有明显的低频噪声。最简单有效的处理方式是“多次采样取平均”本文用了 16 次。如果想更平滑可以改用滑动平均滤波#define FILTER_SIZE 10 static uint16_t filter_buf[FILTER_SIZE]; static uint8_t filter_index 0; static uint32_t filter_sum 0; uint16_t ADC_LowPass(uint16_t value) { filter_sum - filter_buf[filter_index]; filter_buf[filter_index] value; filter_sum value; filter_index; if (filter_index FILTER_SIZE) { filter_index 0; } return (uint16_t)(filter_sum / FILTER_SIZE); }滑动平均的优点是每个采样点都会参与计算不需要每轮重新读 16 次 ADC响应速度更快。9.2 校准策略MQ-3 的基线电压会受到温度、湿度、传感器老化影响因此“一次校准、永久使用”是不可取的。建议上电后延时一段时间再自动校准避开加热丝启动造成的电压波动。提供手动校准按键传感器使用一段时间后重新标定。如果环境温度变化明显采集到的绝对电压不能直接和旧基线比较需要重新校准。9.3 安全与合规声明这里必须强调本文这套基于 MQ-3 的开源酒精检测系统定位是教学演示和阈值告警实验不能替代经过国家计量认证的呼气式酒精检测仪。MQ-3 对汽油、油烟、酒精等多种挥发性气体都有响应无法作为执法或医疗判定依据。如果要把这类产品用于实际监管场合比如厂区岗前酒精检测、车辆防酒驾联动需要更换电化学式酒精传感器并完成计量校准、环境补偿和系统认证。普通工程人员在接手类似需求时一定要把“演示功能”和“合规产品”的边界划清楚避免法律风险。9.4 把项目整理成真正的开源仓库既然标题写了“开源”就建议按规范把工程整理成仓库而不是只丢一个压缩包。一套合格的开源硬件工程至少包含alcohol-detector-stm32/ ├── README.md # 项目简介、接线图、使用方法 ├── LICENSE # 开源协议 ├── docs/ │ ├── wiring.md # 接线文档 │ └── demo.mp4 # 演示视频 ├── firmware/ │ ├── Core/ # CubeMX 生成的工程代码 │ └── App/ # ssd1306.c alcohol.c 等模块 └── hardware/ ├── schematic.pdf # 原理图 └── bom.csv # 物料清单协议建议选 MIT 或 GPL。MIT 更宽松别人可以自由学习修改GPL 则要求衍生项目也必须开源。在 README 里写清楚“项目仅用于学习不构成计量设备”能避免很多后续麻烦。10. 总结与扩展方向10.1 下一步可以做什么这套系统跑通之后建议继续做几个方向的扩展。一是联网上报。把 USART1 接到 ESP8266 模块就能把浓度数据上传到云平台做成物联网酒测终端。二是加继电器联动。当检测到酒精浓度超标时通过继电器断开设备电源这就是简易的“酒后禁止启动”演示。三是换更高精度的传感器。把 MQ-3 替换为电化学酒精传感器配合运放和温度补偿电路测量精度会明显提升。代码层面也可以优化。现在主循环用了多个HAL_Delay后续如果接入 Wi-Fi、按键扫描、显示器刷新多个任务建议引入定时器中断或 FreeRTOS把每个任务拆成独立模块。10.2 动手建议纸上得来终觉浅。建议你拿到板子后先把 MQ-3 上电预热几分钟在干净空气里按住按键校准一次然后拿一小杯酒精靠近传感器观察 OLED 上的电压和 LEVEL 变化。这个过程会让你对“传感器预热”“基线漂移”“阈值分级”有非常直观的理解。接着自己改一改阈值调一调滤波参数把本文的代码变成你自己的工程比单纯复制粘贴收获大得多。如果这篇文章对你有帮助可以先收藏备用等硬件到货后照着一步步操作。后续我也会继续整理 OLED 驱动、ESP8266 联网、FreeRTOS 移植等专题欢迎一起交流。