简介:面向嵌入式入门与传感器采集实践者的一套STM32环境监测工程源码,围绕STM32F103C8T6主控,整合DHT11温湿度、SGP30空气质量传感器与CH340 USB转串口,重点演示内置ADC模拟信号读取、USART串口收发及引脚接线与函数功能说明,适合想打通「传感器采集—模数转换—串口上传」完整链路的读者对照学习。压缩包共211个文件,约5.36MB,以37个c源文件与40个h头文件构成工程主体,配套38个o、37个crf等Keil编译中间文件,另有uvprojx工程文件、hex烧录文件、map映射表及少量txt、htm说明文档,目录结构贴近真实Keil工程,便于直接打开编译与二次修改。该资源已有2152人学习下载,可帮助读者理解ADC通道配置与转换结果读取、USART与CH340的数据透传方式,并借助接线说明和代码注释快速定位初始化与采集函数,减少环境搭建与调试中的摸索成本。
1. 从一块最小系统板说起:这套多传感器采集方案到底解决什么问题
手上只有一块 STM32F103C8T6 最小系统板,却要同时接 DHT11 温湿度、SGP30 空气质量、还要把数据通过 CH340 串口送到上位机,顺带用 ADC 采一路模拟量——这是很多毕设和课设里最典型的一类需求:传感器种类多、协议各不相同、数据要能实时看到、还要能写进报告里讲清楚。难点不在单个模块,而在于把单总线、I2C、UART、ADC 这四套时序揉进一个主循环里还不互相打架。这套方案就是围绕这个场景搭的:以 STM32F103C8T6 为主控,DHT11 走单总线、SGP30 走 I2C、CH340 做 USB 转串口、ADC 采模拟通道,最终形成一个可采集、可显示、可复现的最小数据采集系统。适合正在做环境监测类毕设、嵌入式课设,或者需要一套能直接改参数就上手的采集框架的人。
2. 需求拆解与器件选型:四类信号为什么必须分开处理
2.1 先把需求翻译成接口清单
拿到题目别急着画原理图,先把「要采什么、采完给谁看、多久采一次」三件事定下来。这套方案的需求可以拆成四条:温湿度要能读到整数级精度、空气质量要能读到 TVOC 和 CO2 等效值、模拟量要能反映 0~3.3V 范围内的电压变化、所有数据要能通过一根 USB 线在电脑串口助手里看到。翻译成接口就是:DHT11 占一个 GPIO 做单总线双向通信,SGP30 占 I2C1 的 PB6/PB7,CH340 占 USART1 的 PA9/PA10,ADC 用 PA0 采一路外部电压。
这里有个容易被忽略的点:DHT11 的单总线是「半双工」的,同一根线上既要发启动信号又要收 40 位数据,所以这个 GPIO 必须能在输出和输入之间切换。很多人一开始把它配成纯输入或纯输出,结果读出来全是 0 或者 255,这就是时序没配对。我一般会在初始化时先配成推挽输出,读完启动信号后再切成浮空输入,读完再切回去,这个切换动作要写成一个函数,别散在主循环里。
2.2 选型对比:为什么不用别的方案
| 器件 | 本方案选择 | 常见替代 | 选择理由 |
|---|---|---|---|
| 主控 | STM32F103C8T6 | STM32F103RCT6 / ESP32 | 48 引脚够用,价格低,资料多,ADC 和 I2C 外设齐全 |
| 温湿度 | DHT11 | DHT22 / SHT30 | 单总线省引脚,精度够课设用,成本最低 |
| 空气质量 | SGP30 | MQ-135 | 数字接口免标定,直接出 TVOC/CO2 等效值 |
| 串口 | CH340 | CP2102 / 原生 USB | 模块便宜,驱动成熟,接线只有 TX/RX/GND |
| 模拟量 | 片内 ADC | 外置 ADS1115 | 省一路 I2C 地址,12 位精度够看趋势 |
这张表不是让你照抄,而是提醒你:选型要能说出「为什么不是另一个」。答辩时老师最爱问的就是「你为什么用 DHT11 不用 DHT22」,答案不是「便宜」,而是「本场景精度要求到整数度即可,DHT11 的单总线时序我已经验证稳定,换 DHT22 要改时序参数反而增加风险」。
2.3 引脚分配与资源占用
把引脚定死再动手,能省掉后面大量返工。这套方案的分配是:PA9/PA10 给 USART1 接 CH340,PB6/PB7 给 I2C1 接 SGP30,PA0 给 ADC1_IN0 采模拟量,PA1 给 DHT11 单总线。剩下的 PA2~PA7 留作调试或扩展。注意 CH340 模块的 TX 要接单片机的 PA10(RX),RX 接 PA9(TX),交叉接线,这个接反了串口助手一个字都收不到,是血泪经验里排第一的翻车点。
提示:SGP30 上电后需要一段初始化时间才能出有效数据,通常的做法是上电后先延时 15 秒左右再开始读,期间可以先把 DHT11 和 ADC 跑起来,别让主循环空等。
3. 总体架构设计:主循环调度与四路采集的职责划分
3.1 分层结构:驱动层、采集层、上报层
整套程序我一般分成三层。驱动层负责最底层的时序:DHT11 的微秒级延时和位判断、SGP30 的 I2C 读写封装、ADC 的启动与转换、USART 的收发。采集层负责「什么时候采、采完存哪」:给每个传感器定一个采集周期,DHT11 两秒一次(它本身响应就慢),SGP30 一秒一次,ADC 一百毫秒一次,采到的值放进一个结构体。上报层负责格式化:把结构体拼成一行可读的字符串,通过 CH340 发出去。
这样分层的好处是改一个传感器不影响别的。比如你后来想把 DHT11 换成 SHT30,只动驱动层和采集层里对应的那个函数,上报层的格式完全不用改。很多课设代码把所有逻辑塞进 main 的 while(1) 里,改一处牵全身,最后自己都不敢动。
3.2 主循环调度:用时间戳而不是 delay
新手最容易犯的错是在主循环里写HAL_Delay(2000)等 DHT11。这一等,ADC 和串口全停了,数据看起来「卡顿」。正确做法是用一个毫秒级的时间戳做非阻塞调度:
/* 采集调度结构体:记录每路的上次采集时刻和周期 */ typedef struct { uint32_t last_tick; /* 上次采集的毫秒时间戳 */ uint32_t period_ms; /* 采集周期 */ } TaskTimer; TaskTimer dht_task = {0, 2000}; /* DHT11 两秒一次 */ TaskTimer sgp_task = {0, 1000}; /* SGP30 一秒一次 */ TaskTimer adc_task = {0, 100}; /* ADC 一百毫秒一次 */ /* 在主循环里判断是否到点,到点才采,不阻塞 */ if (HAL_GetTick() - dht_task.last_tick >= dht_task.period_ms) { dht_task.last_tick = HAL_GetTick(); DHT11_Read(&g_data.temp, &g_data.humi); }HAL_GetTick()返回的是上电以来的毫秒数,用「当前减上次」判断而不是「等于某个值」,能避开计数器溢出问题。period_ms就是每路的采集节奏,DHT11 给 2000 是因为它两次采样之间至少要隔 1 秒以上,给太密读出来是旧值甚至报错。这段调度的核心思想是:主循环转得飞快,但每路只在到点时干活,其余时间都在空转,所以串口输出是连续的,不会因为等某个传感器而卡住。
3.3 数据流:从寄存器到串口字符串
数据从传感器到串口要经过三次转换:DHT11 的 40 位原始字节要拆成湿度和温度两个整数;SGP30 的 I2C 读回来是两个字节的 CO2 等效值和两个字节的 TVOC,要按大端拼成 16 位;ADC 的 12 位原始值要按电压 = 原始值 × 3.3 / 4096换算成毫伏。这三步都在采集层完成,上报层拿到的已经是「人话」数据。
我一般会定义一个全局结构体g_data,字段包括temp、humi、co2、tvoc、adc_mv,采集层往里写,上报层从里读。这样调试时在 Keil 的 watch 窗口直接看这个结构体,就知道每一路采到没有、值对不对,比一个个打印快得多。
4. 关键模块实现:单总线、I2C、ADC 与串口的落地细节
4.1 DHT11 单总线:微秒级时序是成败关键
DHT11 的通信全靠一根线上的高低电平持续时间来区分 0 和 1。主机先拉低至少 18 毫秒做启动信号,然后释放总线,DHT11 回应 80 微秒低电平加 80 微秒高电平,接着开始发 40 位数据:每一位都是 50 微秒低电平开头,然后高电平持续 26~28 微秒表示 0,持续 70 微秒表示 1。判断 0 和 1 的方法是在高电平开始后延时约 40 微秒再读电平,读到高就是 1,读到低就是 0。
/* 读一位:先等低电平结束,再延时 40us 采样 */ static uint8_t DHT11_ReadBit(void) { uint8_t retry = 0; while (DHT11_PIN_READ() == 0 && retry++ < 100); /* 等 50us 低电平过去 */ Delay_us(40); /* 越过 0 码的 26~28us */ uint8_t bit = DHT11_PIN_READ(); /* 此时高电平即 1 */ retry = 0; while (DHT11_PIN_READ() == 1 && retry++ < 100); /* 等这一位高电平结束 */ return bit; }Delay_us必须准,用 SysTick 或者定时器做,别用空循环凑,不同优化等级下空循环的耗时完全不一样,这是玄学翻车的重灾区。retry计数是防死等:万一传感器没接好,while会卡死,加个上限就能超时退出返回错误。读完整 40 位后要校验:前四个字节是湿度整数、湿度小数、温度整数、温度小数,第五个字节是前四个的和,对不上就丢弃这次数据重读。
4.2 SGP30 的 I2C 读写:命令字和 CRC 别漏
SGP30 的 I2C 地址是 0x58(7 位),每次操作都是「发两个字节命令 + 读回数据」。初始化要发0x2003启动空气测量,读 CO2 用0x2008,读 TVOC 用0x2009。注意它返回的每个 16 位值后面还跟一个 CRC 字节,很多人只读两个字节就完事,结果数据偶尔跳变,就是没校验。
/* 读 SGP30 的 CO2 等效值,带 CRC 校验 */ uint16_t SGP30_ReadCO2(void) { uint8_t cmd[2] = {0x20, 0x08}; uint8_t buf[3]; HAL_I2C_Master_Transmit(&hi2c1, 0x58 << 1, cmd, 2, 100); HAL_Delay(12); /* 命令后需等待约 12ms 出数据 */ HAL_I2C_Master_Receive(&hi2c1, 0x58 << 1, buf, 3, 100); if (SGP30_CRC8(buf, 2) != buf[2]) return 0xFFFF; /* 校验失败返回错误码 */ return (buf[0] << 8) | buf[1]; /* 大端拼接 */ }0x58 << 1是因为 HAL 库的地址参数要左移一位带上读写位。HAL_Delay(12)是命令执行时间,给短了读回来是上一次的旧值。CRC 算法是多项式 0x31、初值 0xFF,网上有现成实现,但一定要自己验一遍:拿一组已知数据算,对不上就是移位方向搞反了。SGP30 还有个特点,刚上电的前十几秒读出来是固定值 400 和 0,这是它的基线建立期,不是坏了,程序里可以加个标志位,前 15 秒不显示空气质量。
4.3 ADC 采集:校准和参考电压
片内 ADC 用 PA0,配置成 12 位、单次转换、软件触发。初始化后先做一次校准HAL_ADCEx_Calibration_Start(&hadc1),不做校准的话零点漂移能到几十毫伏。转换流程是「启动 → 等 EOC → 读值」,读到的 0~4095 对应 0~3.3V。
/* 读一次 ADC 并换算成毫伏 */ uint16_t ADC_Read_mV(void) { HAL_ADC_Start(&hadc1); if (HAL_ADC_PollForConversion(&hadc1, 10) != HAL_OK) return 0; uint32_t raw = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); return (uint16_t)(raw * 3300 / 4096); /* 12 位满量程 4096,参考 3.3V */ }3300 / 4096里的 4096 是 2 的 12 次方,别写成 4095,虽然差一点点但答辩时被问到会尴尬。如果采的是外部传感器分压后的信号,记得在换算前乘回分压比。ADC 的输入阻抗有限,信号源内阻大的话读数会偏低,通常的做法是在输入端加个电压跟随器,课设阶段信号源内阻小可以省掉。
4.4 CH340 串口上报:格式化输出与重定向
CH340 接 USART1,波特率 115200、8 数据位、无校验、1 停止位。上报格式我一般用一行搞定,方便串口助手直接看:
/* 每轮采集完成后拼一行发出去 */ printf("T:%dC H:%d%% CO2:%dppm TVOC:%dppb ADC:%dmV\r\n", g_data.temp, g_data.humi, g_data.co2, g_data.tvoc, g_data.adc_mv);要让printf走串口,需要重定向fputc:
/* 把 printf 的输出重定向到 USART1 */ int fputc(int ch, FILE *f) { HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 100); return ch; }重定向后记得在 Keil 里勾选「Use MicroLIB」,否则printf会卡在半主机模式里出不来,现象就是程序一下载就停住,这也是个经典坑。波特率两端必须一致,串口助手设成 115200,设成 9600 看到的就是乱码。
5. 避坑与排查:这套方案里最容易翻车的五个点
5.1 串口一个字节都收不到
现象是串口助手打开后空白,或者只有乱码。原因通常是三选一:TX/RX 没交叉接、波特率两端不一致、fputc重定向没生效。排查顺序是先确认接线(CH340 的 TX 对 PA10),再用示波器或逻辑分析仪看 PA9 有没有波形,有波形但乱码就是波特率问题,没波形就是重定向或 USART 初始化没跑通。我一般会在main开头先发一句固定的"boot\r\n",能收到这句说明链路是通的,收不到就别往下查传感器了。
5.2 DHT11 读数一直是 0 或 255
现象是温湿度固定不变或者明显错误。原因多半是时序延时不准,尤其是Delay_us用了空循环,编译器一优化时间就变了。解决办法是把微秒延时改成基于 SysTick 或定时器的实现,并且在读位函数里保留retry超时。还有一种情况是上电后第一次读失败,这是正常的,DHT11 上电需要约 1 秒稳定,程序里第一次读之前先延时 1 秒,失败就重试,连续三次失败再报错。
5.3 SGP30 数据不跳变或一直是 400
现象是 CO2 永远显示 400、TVOC 永远 0。原因是没发启动测量命令0x2003,或者上电后没等够基线建立时间。解决方法是初始化里先发0x2003,然后延时 15 秒再开始读,读的时候带上 CRC 校验,校验失败就丢弃。如果换了环境后数据长期不动,可以发0x20 0x15做一次基线校准,但这个命令不能频繁发,通常只在已知洁净空气环境下用一次。
5.4 ADC 读数跳动大
现象是同一路电压读出来上下差几十甚至上百。原因是没做校准、参考电压不稳、或者信号源内阻太大。先做一次HAL_ADCEx_Calibration_Start,再在软件里做几次采样取平均(比如连读 8 次去掉最大最小再平均),能明显压住跳动。如果还跳,检查 3.3V 供电是不是从 USB 直接取的,USB 供电本身有纹波,加个 100nF 电容到地通常能改善。
5.5 多路一起跑就死机
现象是单独测每个传感器都正常,四个一起跑程序就卡住或复位。原因通常是某个驱动里的while死等没有超时,比如 I2C 等待标志位、DHT11 等电平,某个传感器没响应就永远卡在那。解决办法是给所有while等待加计数上限,超时就返回错误码,主循环里对错误码做处理而不是直接忽略。另外检查栈空间,printf和浮点运算吃栈,栈给小了会 HardFault,把启动文件里的栈大小从默认的 0x400 加到 0x800 通常能解决。
6. 验收与答辩:怎么证明这套采集系统真的跑通了
验收这件事,老师看的不是你代码写得多漂亮,而是「数据是不是真的、系统是不是稳的、你能不能讲清楚」。我一般会准备三样东西:一份连续运行记录、一张引脚接线表、一段能现场复现的演示流程。
连续运行记录的做法是让系统上电跑至少 30 分钟,串口助手开日志保存,事后看日志里有没有断档、有没有异常值。合格的记录应该是每秒都有输出、温湿度在合理范围缓慢变化、CO2 在 400~1000 之间波动、ADC 值随你用手捏住传感器或改变输入电压而响应。如果日志里出现大段重复值或者突然归零,说明某一路在超时重试,得回去查那一章的排查点。
现场演示的流程我建议固定成四步:先上电,指着串口助手说「这是 CH340 送出来的实时数据」;然后对着 DHT11 哈一口气,看湿度上升;再用酒精棉靠近 SGP30,看 TVOC 跳变;最后用杜邦线把 PA0 接到 3.3V 和 GND,看 ADC 在 3300 和 0 之间切换。这四步每步都对应一个模块,讲的时候一句话带过原理即可,重点是让老师看到「输入变了、输出跟着变」,这就是系统活着的证据。
答辩时最容易被追问的是「你的采样周期怎么定的」「DHT11 时序为什么这么写」「SGP30 的 CRC 有什么用」。前两个在第 3、4 章里都有答案,CRC 那个你就说「防止 I2C 传输误码导致数据跳变,校验不过就丢弃本次数据」,一句话就够。还有个高频问题是「如果传感器拔了会怎样」,标准答案是「驱动层有超时计数,拔了会返回错误码,主循环继续跑其他路,不会死机」——这正好是你加了retry的价值,讲出来就是加分项。
注意:验收前把串口助手的波特率、数据位、停止位截图存好,现场换电脑时照着设,别到了讲台上现调。
从那以后我每次做这类多传感器采集,都会在main开头先发一句启动标志、给每个while加超时、上电先跑 30 分钟日志再交,这三件事做完,现场翻车的概率能压到很低。希望帮到你。
本文还有配套的精品资源,点击获取