简介:面向嵌入式入门与传感器采集实践者的一套STM32环境监测工程源码,围绕STM32F103C8T6主控,整合DHT11温湿度、SGP30空气质量传感器与CH340 USB转串口,重点演示ADC模拟量读取、USART串口收发及引脚接线与函数功能说明,适合想打通「传感器采集—模数转换—串口上传」完整链路的初中级开发者参考。压缩包共211个文件,以37个c源文件与40个h头文件构成工程主体,另含38个o目标文件、37个crf编译中间文件及uvprojx、uvoptx等Keil工程配置,并附hex烧录文件、map映射表与bat清理脚本,整体约5.36MB,目录结构贴近实际Keil工程,便于直接打开编译与二次修改。该资源已有2152人学习下载,可帮助读者理解外设初始化流程、ADC通道配置与串口数据上传思路,并借助接线说明快速复现实验。
1. 一块不到三十块的板子,把温湿度、空气质量、电池电压全测了
手头有个环境监测的小活,预算卡得死,要求却不少:温湿度要有、TVOC 和 CO₂ 等效值要有、还得顺带看一眼供电电池还剩多少电,数据能通过串口吐到电脑上。翻了一圈方案,最后落在这套组合上——STM32F103C8T6 做主力,DHT11 管温湿度,SGP30 管空气质量,CH340 把串口转成 USB,再用 ADC 采一路电池分压。整套东西元件成本压得很低,代码量也不大,特别适合拿来练手或者做小批量采集节点。
这套资源的价值不在于芯片多新,而在于它把「多传感器挂一条总线 + 模拟量采集 + 串口上传」这条最典型的嵌入式数据链路走通了。新手能照着把线接对、把数据读出来;熟手更关心的是 SGP30 的基线怎么存、DHT11 时序为什么老翻车、ADC 采电池电压怎么算才准。下面按接线、驱动、采集、排坑、进阶的顺序拆开讲,每一步都能直接抄。
2. 硬件接线与供电:先把电气这关过了再谈代码
2.1 引脚分配与总线拓扑
这套方案里,DHT11 走单总线,SGP30 走 I²C,CH340 走 UART,电池电压走 ADC,四路互不打架。F103C8T6 的引脚够用,我一般这么分:
| 功能 | 外设 | STM32 引脚 | 备注 |
|---|---|---|---|
| DHT11 数据 | GPIO 双向 | PA0 | 需 4.7k~10k 上拉 |
| SGP30 SCL | I²C1 | PB6 | 复用开漏 |
| SGP30 SDA | I²C1 | PB7 | 复用开漏 |
| CH340 TXD | USART1_RX | PA10 | 交叉连接 |
| CH340 RXD | USART1_TX | PA9 | 交叉连接 |
| 电池分压 | ADC1_IN0 | PA0 冲突需换 | 建议改 PA1 |
注意表里 PA0 那行,DHT11 和 ADC 抢同一个脚是新手最容易犯的错。DHT11 用普通 GPIO 推挽/浮空输入切换就行,ADC 那路我一般挪到 PA1(ADC1_IN1),分压电阻用 100k + 100k 把电池电压砍一半,保证不超过 3.3V 参考。
SGP30 是 1.8V 逻辑器件,但模块板一般自带电平转换和 3.3V 稳压,直接接 3.3V 供电即可。它的 I²C 地址固定 0x58(7 位),总线上拉电阻模块通常已经焊了 10k,自己再并就没必要。DHT11 供电 3.3V~5V 都行,但数据脚上拉电阻别省,省了就是随机读失败。
2.2 供电与去耦的实际处理
SGP30 在预热和测量瞬间电流会跳变,如果和 DHT11、CH340 共用一条细长的杜邦线供电,很容易出现 I²C 通信偶发 NACK。我的做法是每个模块电源脚就近并一个 0.1µF 陶瓷电容,SGP30 额外并一个 10µF 钽电容。CH340 的 USB 5V 进来后,整板用一颗 AMS1117-3.3 降压,注意 1117 压差大、发热明显,输入输出各放 10µF 和 0.1µF。
提示:如果整板由 USB 供电,电池分压那路要断开,否则测出来的是 USB 电压经分压后的值,不是电池真实电压。
接线顺序建议先只接 CH340 和 MCU,确认串口能打印,再逐个加传感器。一次性全焊上去,出问题时分不清是哪个环节,血泪经验。
3. DHT11 与 SGP30 驱动:时序和协议各踩各的坑
3.1 DHT11 单总线时序实现
DHT11 的坑集中在时序精度上。它用一根线做双向通信,主机拉低至少 18ms 发起始信号,然后释放,DHT11 回应 80µs 低 + 80µs 高,接着逐位传 40 位数据,每位用高电平持续时间区分 0 和 1:26~28µs 是 0,70µs 是 1。用 HAL 库的HAL_Delay做微秒级延时根本不够,得用 DWT 或者定时器。
/* 微秒级延时,基于 DWT 周期计数器,F103 主频 72MHz */ static void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000U); while ((DWT->CYCCNT - start) < ticks) { } } /* 读一位:等待上升沿后测高电平宽度 */ static uint8_t dht11_read_bit(void) { uint32_t t = 0; while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET); /* 等低电平结束 */ while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_SET) t++; /* 测高电平 */ delay_us(1); /* 粗略标定,实际用 DWT 差值更准 */ return (t > 30) ? 1 : 0; /* 阈值按实测调 */ }上面这段是示意,真正稳的做法是用输入捕获或者 DWT 直接算高电平周期数,阈值按自己板子实测标定。t > 30这个数不是固定的,主频、编译优化、上拉电阻都会影响,我一般先打印原始周期数,看 0 和 1 的分布再定阈值。DHT11 两次读取间隔必须大于 1 秒,读太快它直接不响应,这是数据手册写死的,别挑战。
3.2 SGP30 的 I²C 初始化与基线机制
SGP30 比 DHT11 规矩,但有个必须理解的点:它输出的是 TVOC 和 CO₂ 等效值,内部有基线校准机制。上电后要先发0x2003命令做初始化,然后每 1 秒读一次,读之前发0x2008触发测量。更关键的是基线:芯片运行一段时间后会把当前环境当作参考,如果每次上电都从零开始,前几个小时的数据漂得没法看。
/* SGP30 初始化与测量命令,I²C 地址 0x58<<1 */ #define SGP30_ADDR (0x58 << 1) #define CMD_INIT 0x2003 #define CMD_MEASURE 0x2008 #define CMD_BASELINE_GET 0x2015 #define CMD_BASELINE_SET 0x201E uint8_t sgp30_init(I2C_HandleTypeDef *hi2c) { uint8_t cmd[2] = {CMD_INIT >> 8, CMD_INIT & 0xFF}; if (HAL_I2C_Master_Transmit(hi2c, SGP30_ADDR, cmd, 2, 100) != HAL_OK) return 1; HAL_Delay(15); /* 初始化后等待,手册要求 */ return 0; }参数说明:CMD_INIT是软复位加初始化,发完等 10~15ms;CMD_MEASURE每次读数据前发,然后等 12ms 再读 6 字节(CO₂ 两字节 + TVOC 两字节 + CRC 各一字节)。CRC 校验别跳过,I²C 偶发干扰时没 CRC 会读出离谱值。基线用0x2015读、0x201E写,建议运行满 12 小时后读一次基线存到 Flash,下次上电先写回去,数据稳定性提升明显。
3.3 CH340 串口与数据帧格式
CH340 本身不需要配置,插上电脑装好驱动就是一个 COM 口,MCU 侧按 115200、8N1 配 USART1 即可。真正要设计的是上传帧格式。我一般用简单的文本行:T=25.3,H=60,R=412,C=800,B=3.92\n,字段固定、逗号分隔,上位机按sscanf解析,调试时人眼也能直接看。
/* 组帧并发送,printf 重定向到 USART1 */ int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 10); return ch; } void report_frame(float t, float h, uint16_t tvoc, uint16_t co2, float vbat) { printf("T=%.1f,H=%.1f,R=%u,C=%u,B=%.2f\r\n", t, h, tvoc, co2, vbat); }printf重定向要注意别在中断里调用,阻塞发送会拖慢主循环。如果采集频率高,改成 DMA 发送或者自己组缓冲区。\r\n别只发\n,有些串口助手不认。
4. ADC 采电池电压:分压、参考源与滤波三件事
4.1 分压电路与量程换算
电池电压一般 3.7V~4.2V(锂电)或 6V~12V(多节),直接进 ADC 会超 3.3V。用两颗等值电阻分压,ADC 读到的是实际电压的一半。换算公式:
/* 12 位 ADC,参考 3.3V,分压比 2:1 */ float adc_to_voltage(uint16_t raw) { float vref = 3.3f; float v_adc = raw * vref / 4095.0f; return v_adc * 2.0f; /* 分压比 2 */ }4095是 12 位满量程,别写成 4096,差一位在低压段就是几十毫伏的误差。分压电阻别选太小,100k+100k 静态电流约 20µA,对电池友好;但电阻大了输入阻抗高,ADC 采样时间要拉长,F103 的采样周期设到 239.5 个 ADC 时钟比较稳。
4.2 多次采样与中值滤波
单次 ADC 读数跳得厉害,尤其电池带负载时。我一般连续采 16 次,去掉最大最小各 4 个,剩下取平均。这个做法比单纯平均抗脉冲干扰好,代码也不复杂:
uint16_t adc_read_filtered(ADC_HandleTypeDef *hadc) { uint16_t buf[16]; for (int i = 0; i < 16; i++) { HAL_ADC_Start(hadc); HAL_ADC_PollForConversion(hadc, 10); buf[i] = HAL_ADC_GetValue(hadc); HAL_ADC_Stop(hadc); } /* 简单插入排序后取中间 8 个平均 */ for (int i = 1; i < 16; i++) { uint16_t key = buf[i]; int j = i - 1; while (j >= 0 && buf[j] > key) { buf[j+1] = buf[j]; j--; } buf[j+1] = key; } uint32_t sum = 0; for (int i = 4; i < 12; i++) sum += buf[i]; return sum / 8; }参数说明:采样次数 16 是经验值,再多收益递减;去掉头尾各 4 个对应 25% 截断,环境干扰大可以加到各 6 个。ADC 时钟别超过 14MHz,F103 的 ADC 分频在SystemClock配置里设,72MHz 六分频正好 12MHz。
注意:如果电池和 USB 同时供电,分压点电压会被 USB 侧拉高,测出来偏大。要么加二极管隔离,要么软件上判断 USB 插入状态后跳过电池测量。
5. 避坑排查:这五处翻车点我几乎每次都遇到
5.1 DHT11 一直返回 0 或校验失败
现象:串口打印温湿度全是 0,或者校验和不匹配。原因多半是上拉电阻没接、延时不准,或者两次读取间隔小于 1 秒。解决:先确认数据脚有 4.7k~10k 上拉到 3.3V,再用示波器或逻辑分析仪看起始信号是否拉低够 18ms,最后把读取周期改成 2 秒一次。校验失败偶尔出现属正常,连续三次失败再报错。
5.2 SGP30 读数为 400/0 不动
现象:CO₂ 恒定 400,TVOC 恒定 0。原因通常是没发初始化命令,或者测量命令后等待时间不够就读取。解决:确认上电先发0x2003并等 15ms,每次测量发0x2008后等 12ms 再读,且读取间隔保持 1 秒。如果还是不动,检查 I²C 上拉和地址是否写成了 8 位形式(0xB0 而非 0x58)。
5.3 串口能发不能收或乱码
现象:电脑收到乱码,或者发命令 MCU 没反应。原因:波特率不匹配、TX/RX 没交叉、CH340 驱动没装对。解决:先确认 MCU 和串口助手都是 115200 8N1,再检查 PA9 接 CH340 的 RXD、PA10 接 TXD,最后设备管理器里看 COM 口有没有黄色感叹号。乱码九成是波特率,剩下一成是晶振配置和实际不符。
5.4 ADC 读数满量程或恒定 4095
现象:电池电压算出来永远 4V 以上。原因:分压电阻虚焊、ADC 引脚配置成了模拟但没使能时钟,或者输入电压真的超了 3.3V。解决:万用表量分压点实际电压,对照公式反推;检查__HAL_RCC_ADC1_CLK_ENABLE()有没有调;确认 GPIO 模式是GPIO_MODE_ANALOG。
5.5 整板工作几分钟后数据全乱
现象:刚上电正常,跑一会儿 I²C 开始 NACK、串口丢帧。原因:AMS1117 发热导致 3.3V 跌落,或者 SGP30 电流跳变引起电源纹波。解决:量 3.3V 轨在 SGP30 测量瞬间的波形,加 10µF 电容;1117 输入输出压差大时换 DC-DC 或者降低输入电压。这类问题用万用表看不出来,得上示波器。
6. 进阶:把基线存进 Flash,让 SGP30 上电即稳
SGP30 的基线机制是这套方案里最值得深挖的点。芯片内部维护一个基线值,运行越久越贴合当前环境,但掉电就丢。如果不处理,每次上电前 12 小时的数据基本只能看趋势,不能看绝对值。我的做法是运行满 12 小时后读一次基线,写进 F103 的 Flash 末页,下次上电初始化完立刻写回去。
/* 基线存取,借用 Flash 最后一页 0x0801FC00,1KB 页 */ #define BASELINE_ADDR 0x0801FC00 #define BASELINE_MAGIC 0x5A5A typedef struct { uint16_t magic; uint16_t co2_base; uint16_t tvoc_base; } baseline_t; void baseline_save(uint16_t co2, uint16_t tvoc) { baseline_t b = {BASELINE_MAGIC, co2, tvoc}; HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef e = {FLASH_TYPEERASE_PAGES, FLASH_BANK_1, 63, 1}; uint32_t err; HAL_FLASHEx_Erase(&e, &err); uint32_t *p = (uint32_t *)&b; for (int i = 0; i < sizeof(b) / 4; i++) HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, BASELINE_ADDR + i * 4, p[i]); HAL_FLASH_Lock(); }参数说明:F103C8T6 只有 64KB Flash,页大小 1KB,最后一页起始地址 0x0800FC00 是 63 页(从 0 计),上面写的 0x0801FC00 是按 128KB 型号算的,C8T6 要改成 0x0800FC00,这个地址写错就是 HardFault,我第一次就栽在这。magic用来判断 Flash 里是不是有效数据,首次上电读到 0xFFFF 就跳过写入基线。
验证方法很直接:烧录后跑够时间,串口打印当前基线,断电再上电,看初始化后读回的基线是否一致。如果一致,说明存取链路通了;如果不一致,先查地址和页号,再查擦除是否成功。我习惯在baseline_save后立刻回读比对,不一致就打印错误码,比事后猜强。
从那以后我每次做带基线类传感器的板子,都强制走一遍「跑够时间 → 存基线 → 断电重启 → 回读比对」这个流程,不省这一步。希望帮到你。
本文还有配套的精品资源,点击获取