“第五次作业”这几个字看起来平平无奇,很多人在拿到题目时甚至不知道该从哪儿下手。我这次做的是一个嵌入式方向的课程作业:基于 STM32 的智能仓库温湿度监测系统。东西不复杂,但整个流程走下来,从方案设计、硬件选型、代码调试到上位机数据记录,踩坑和收获都不少。这篇就把整个思路和实操过程掰开揉碎了讲一遍,适合正在做类似嵌入式课程设计、或者第一次接触单片机项目的人参考。
得先说明一点,作业要求本身通常不会写得太细,很多细节是基于常见课程实践的合理补齐。我做的方案是:用 STM32F103C8T6 最小系统板作为主控,接一个 DHT11 温湿度传感器采集环境数据,数据通过 I2C 接口显示在 0.96 寸 OLED 屏幕上,同时 STM32 通过串口把数据发给电脑端的上位机程序,上位机负责实时显示并存储成 CSV 文件。这套方案覆盖了传感器采集、主控处理、本地显示、通信传输、上位机解析这几个嵌入式开发的核心环节,作为“第五次作业”来说,工作量适中,技术点也足够丰富。
1. 整体设计与思路拆解
1.1 为什么选这个题目方向
“第五次作业”这类课程任务通常出现在嵌入式、物联网或者单片机相关课程的中后期,前四次作业大概率已经覆盖了 GPIO 控制、定时器、中断、串口通信这些基础知识点。第五次作业的价值在于把这些零散的知识点串成一个相对完整的系统。
我选择温湿度监测这个方向,核心原因有三个。第一,DHT11 传感器足够便宜,模块化程度高,几块钱就能买到,坏了也不心疼。第二,这个题目能把单总线协议、I2C 显示、串口通信、数据解析这几个嵌入式常用技能全部用上,覆盖率很高。第三,应用场景非常好解释,仓库监测、机房环境监控、智能农业大棚这些都是刚需场景,写实验报告和答辩的时候很好展开。
如果你们作业没有指定方向,我建议优先选这种带传感器、带显示、带通信的题目。纯点灯太单薄,纯算法又偏离硬件课程的核心目标。温湿度加上一个 OLED 显示和串口上报,整体难度曲线对大多数人是友好的。
1.2 硬件方案选型和成本控制
硬件选型是很多第一次做项目的人最容易纠结的地方。我的建议是,在满足作业要求的前提下,优先用熟悉、资料多、调试方便的芯片。
主控选了 STM32F103C8T6,也就是俗称的“蓝丸”最小系统板。这颗芯片是 Cortex-M3 内核,主频 72MHz,Flash 64KB,RAM 20KB,做温湿度采集这种轻量级任务绰绰有余。更重要的是它的生态太成熟了,不管是标准外设库、HAL 库还是寄存器操作,网上资料一抓一大把,遇到问题随便搜就有答案。
传感器选了 DHT11,虽然它的精度和采样频率跟 SHT30、DHT22 这些传感器没法比,但作为课程作业来说完全够了。DHT11 的湿度测量范围是 20% 到 90% RH,精度 ±5% RH,温度测量范围 0℃ 到 50℃,精度 ±2℃。这些参数对仓库环境监测这种场景来说是合理的,而且它的数字单总线协议很有代表性,理解了这个协议,以后用其他单总线传感器基本是相通的。
显示部分用了 0.96 寸 SSD1306 驱动的 OLED,I2C 接口,四根线搞定。I2C 总线只需要两根信号线,SCL 和 SDA,一根电源一根地,接线非常简洁。相比 1602 液晶屏,OLED 不需要背光,显示效果好,而且不需要占一堆 GPIO 口。
时间基准用了 TIM2 定时器做 1s 周期的数据采集触发,通过串口 1 的 TX 引脚发送数据到 USB 转 TTL 模块,再转发到电脑的串口调试助手或者自写的 Python 上位机。整块板子的物料成本大概在 40 块钱左右,对学生党很友好。
1.3 软件框架和模块划分
写嵌入式程序最怕的就是把所有代码堆在一个 main.c 里,第二次看就不知道哪儿是哪儿了。我这次把程序按功能划分成了几个模块:
- dht11.c:DHT11 传感器驱动,负责初始化引脚、发起读取时序、解析 40bit 数据
- oled.c:SSD1306 驱动,负责初始化显示屏、清屏、显示字符串和数字
- usart.c:串口驱动,负责初始化串口、重定向 printf 实现格式化输出
- main.c:主逻辑,负责初始化各模块、启动定时器、在定时器中断里触发采样和显示
每个模块单独成文件,头文件里只暴露必要的函数接口。这样做的好处很明显,调试某个模块的时候不需要牵扯其他代码,而且如果后面要把 DHT11 换成 DHT22,只需要改 dht11.c 里的底层解析逻辑,上层不用动。
数据流的设计也很直白:传感器数据 → 单片机内存 → OLED 显示 / 串口发送 → 上位机接收解析 → 存储。这个链路短期内看起来是单向的,但实际上留了一个扩展口:如果后续要加上下位机控制功能,比如超温报警风扇,只需要在上位机软件里加一个命令发送接口,把数据流反向打通就行。
2. 核心细节解析与实操要点
2.1 DHT11 单总线通信协议深入理解
DHT11 用一根数据线完成双向通信,这条线是开漏结构,所以需要一个 4.7kΩ 到 10kΩ 的上拉电阻。很多现成模块上已经集成好了上拉电阻,用模块的话就不用操心这件事,但如果自己用裸传感器焊电路板,这个电阻必须有,否则通信绝对不稳定。
单总线通信的起始时序是这样:主机先把数据线拉低至少 18ms,然后释放,这个低电平时间告诉传感器“准备开始传输”。紧接着传感器拉低数据线 80us,再释放 80us,这算是传感器的应答信号。应答之后,传感器开始连续发送 40bit 数据。
这 40bit 数据的读取方式有个非常容易踩坑的细节。DHT11 并没有真正的时钟信号,它靠高电平的持续时间来表示 0 或 1:50us 左右的高电平表示 0,70us 左右的高电平表示 1。所以读取时需要在每次电平跳变后开启一个精确的计时窗口,等高电平结束后,通过记录的高电平时间判断当前位是 0 还是 1。
DHT11 传输的 40bit 数据中,前 8 位是湿度整数部分,第 9 到 16 位是湿度小数部分,第 17 到 24 位是温度整数部分,第 25 到 32 位是温度小数部分,最后 8 位是校验和。校验方法非常简单,把前 4 个字节相加,如果低位字节等于第 5 个字节,数据有效。
我实际测试的时候发现一个问题:直接从 DHT11 数据手册拷贝的时序代码,在某些 GPIO 配置下读出来的数据偶尔会全 0 或者全 1。排查到最后发现是 GPIO 的输出速度和上下拉配置不对,数据线模式切换的时候 GPIO 必须设置成开漏输出、带上拉的模式才能正常读,否则信号质量不好。这个经验在后面的常见问题部分会详细展开。
2.2 SSD1306 OLED 显示要点
OLED 用的是 SSD1306 控制器,I2C 接口最常用的地址是 0x3C,也有少部分是 0x3D。初始化的时候如果屏幕不亮,第一件事就是确认地址对不对。可以用 I2C 扫描程序把总线上所有设备的地址扫一遍,一劳永逸。
SSD1306 内部有一块 1KB 的 GDDRAM 显存,对应 128×64 像素。控制方式是通过写命令和写数据来刷新显存内容。比较推荐的做法是先在内存里维护一个 1024 字节的显存缓冲数组,所有绘制操作都对这个数组进行,需要更新显示的时候一次性把整个数组通过 I2C 总线刷到屏幕的 GDDRAM 里。这样做能避免频繁调用 I2C 通信导致屏幕闪烁,而且修改代码逻辑也更清晰。
显示中文字符这件事,如果做作业的时候没有特殊要求,我建议直接用原生的英文字库,通过取模软件生成 ASCII 字符点阵。中文需要用到 GB2312 编码的 16×16 或 12×12 点阵字库,虽然也不难,但每个汉字要自己取模,工作量会多不少。我这里直接用了 6×8 和 8×16 两套 ASCII 点阵,显示“Temp” “Humi” 这类英文标签完全够了。
2.3 串口发送与上位机对接
串口部分作为一种非常成熟的技术方案,配置比较标准,波特率设置为 9600 或 115200 都可以。要注意的是 DHT11 的采样周期最短是 1s,所以串口数据不需要发太快,否则会导致上位机收到大量重复数据。我设置的是每 2s 发送一帧,帧格式是固定长度的字符串,例如:
TEMP:25.3,HUMI:60.5这个格式简单、可靠、便于解析。如果你想做得更严谨,可以加上帧头帧尾和校验,比如:
$TEMP:25.3,HUMI:60.5*3F其中 3F 是前面所有字符的异或校验值。但课程作业阶段,固定字符串配合换行符已经足够。
上位机我写了一个简单的 Python 脚本,用 pyserial 库读取串口数据,正则表达式提取温度和湿度数值,然后实时显示在控制台,同时追加写入 CSV 文件。这个脚本大概 60 行,配合 matplotlib 还能画实时曲线。
2.4 定时器中断设计思路
采集和显示逻辑放在中断里还是主循环里,是很多初学嵌入式的人纠结的问题。我这次把定时器中断用于产生 2s 的采样节拍,中断服务函数里只置一个标志位,真正的采集和显示逻辑放在主循环里判断这个标志位。这是嵌入式开发中一个非常经典的做法,中断服务函数保持短小精简,复杂逻辑放在主循环,避免在中断里耗时太长影响实时性。
具体的定时器配置使用的是 TIM2,在 APB1 总线上,时钟源是 72MHz 经过分频后的 36MHz(如果 APB1 的预分频系数不等于 1)。我设置预分频值 35999,重装载值 3999,算下来定时周期就是 (36000/36000000) × 4000 秒 = 4 秒?不对,我实际使用的是预分频 7199、重装载 9999,这样定时器计数频率是 72MHz/(7199+1) = 10kHz,周期为 0.1ms,重装载 9999 后就是 1s 中断一次。在中断里再计数两次,得到 2s 的采集周期。这个参数计算的实际过程,书里写得很简单,但自己算的时候容易搞混时钟树的分配关系。
3. 实操过程与核心环节实现
3.1 硬件连接与接线检查
我用的 STM32F103C8T6 最小系统板的引脚定义和常见的 Arduino Nano 有所不同,接线之前一定先查板子的原理图,不要凭感觉接。核心接线如下表:
| 模块 | 信号线 | STM32 引脚 |
|---|---|---|
| DHT11 | DATA | PA0 |
| OLED | SCL | PB6 (I2C1_SCL) |
| OLED | SDA | PB7 (I2C1_SDA) |
| 串口模块 | RX | PA9 (USART1_TX) |
| 串口模块 | TX | PA10 (USART1_RX) |
DHT11 模块的 VCC 接 3.3V 还是 5V,要看模块上有没有电平转换电路。市售很多 DHT11 模块是客服说支持 3.3V 到 5V 宽电压,但保险起见,我直接接 3.3V,和 STM32 的电平匹配最稳。OLED 的 VCC 也是接 3.3V,不接 5V,免得时间长了把 SSD1306 烧了。
串口模块要接成交叉线:单片机的 TX 接 USB 转 TTL 模块的 RX,单片机的 RX 接模块的 TX,接线错误会导致数据出不来。我刚开始就接反了,串口助手什么都收不到。另外,USB 转 TTL 模块和最小系统板最好共地,否则通信会漂移,数据时通时断。这个共地问题在 3.3V 和 5V 混合供电的系统里很重要,缺了地线,电平参考点不一致,串口信号根本没有意义。
3.2 代码实现与关键配置
主程序框架用标准外设库实现,这里挑几个关键的部分说一下。
DHT11 的读取函数是最容易出问题的。核心流程可以抽象成:拉低 18ms → 释放并等待传感器响应 → 读取 40bit 数据 → 校验。
读取每一位的代码逻辑如下:
uint8_t dht11_read_byte(void) { uint8_t i, byte = 0; for (i = 0; i < 8; i++) { while (DHT11_DATA_IN() == 0); // 等待低电平结束 delay_us(40); // 在40us时采样 if (DHT11_DATA_IN() == 1) // 仍然为高,说明是1 { byte |= (1 << (7 - i)); while (DHT11_DATA_IN() == 1); // 等待高电平结束 } } return byte; }这里的判断逻辑是:DHT11 的 0 信号高电平持续 26-28us,1 信号高电平持续 70us。从高电平开始计时,40us 时采样,如果此时还是高电平,那说明这是一位 1,否则就是 0。这个方法简单可靠,比单纯测量高电平宽度然后判断长于/短于某个阈值更容易理解。
HAL 库用户需要特别注意一点:HAL 库的延时函数在系统时钟配置不当时误差会变大。如果发现读出来的数据老是校验失败,先检查系统时钟是否真的跑在手册标称的频率上,可以翻转一个 GPIO 然后拿示波器看实际频率,或者用逻辑分析仪配合测量高电平宽度。
OLED 显示部分的初始化序列比较长,一般不用自己从头调,直接参考厂商提供的初始化代码即可。关键是设置合适的内存访问模式和段重映射,否则屏幕上显示的字符可能是镜像的。
3.3 上位机代码和数据处理
上位机用 Python 实现,依赖 pyserial 库。安装命令很简单:
pip install pyserial核心读取循环如下:
import serial import csv import re import time ser = serial.Serial('COM7', 115200, timeout=1) csv_file = open('env_data.csv', 'a', newline='') writer = csv.writer(csv_file) while True: line = ser.readline().decode('utf-8', errors='ignore').strip() if line: match = re.match(r'TEMP:([\d.]+),HUMI:([\d.]+)', line) if match: temp = float(match.group(1)) humi = float(match.group(2)) print(f'{time.strftime("%Y-%m-%d %H:%M:%S")} -> {temp} C, {humi} %') writer.writerow([time.strftime('%Y-%m-%d %H:%M:%S'), temp, humi]) csv_file.flush()串口号 COM7 是自己机器上识别出来的,需要去设备管理器里看。这个脚本有几个值得注意的设计:一是 CSV 文件用追加模式写入,即使程序中途崩溃之前的数据也不会丢;二是每次写入都调 flush,保证数据即时落盘,避免程序异常退出时缓冲区里的数据丢失;三是正则表达式匹配可以过滤掉无效行,不会因为串口收到的噪声数据导致程序崩溃。
这里我遇到过一个小坑:pyserial 的 readline 在读不到换行符时会一直阻塞,如果单片机侧发的字符串没有加 \r\n 结尾,那 Python 端永远等不到完整的一行数据。所以单片机端发送数据时,字符串末尾要加上 \r\n。
3.4 系统联调的完整流程
我联调的时候分了三个阶段进行。第一阶段先让 OLED 屏亮起来,随便显示一些静态文字,确认I2C通信正常。第二阶段让 DHT11 的数据成功显示在屏幕上,通过对着传感器吹气、用手握住排气等方式观察数值变化。第三阶段才接通串口,让数据同时送到上位机,验证存储文件。
分阶段调的好处是出现问题时能快速定位。如果一开始就把所有设备接好然后写完全部代码再上电,一旦屏幕不亮,你根本没法确定是硬件问题还是代码问题,调试效率极低。先跑通最底层的显示链路,再往上加传感器数据,最终再连电脑,每一步都有确定的验证标准。
4. 常见问题与排查技巧实录
4.1 OLED 屏幕不显示的排查思路
我遇到的第一个大坑是 OLED 怎么都不亮,排查下来分了几个层次。先是测供电,确认 VCC 和 GND 之间有 3.3V 电压,这个测量是基础但必须做。然后测 SCL 和 SDA 引脚,发生通信时应该有波动的电平,这就说明硬件通路是通畅的。然后怀疑地址问题,我写了段 I2C 扫描代码,把总线上探测到的所有设备地址打印出来,很快就确认了地址是 0x3C 没错。
最后发现问题是 I2C 引脚配置错了。我用的标准外设库里,PB6 和 PB7 复用为 I2C1 引脚时,需要先把引脚配置为复用开漏输出模式,并且打开 I2C1 的时钟。这个配置很容易漏,漏了之后 I2C 总线根本没有波形输出,屏幕自然黑屏。另外我犯过的错误是在初始化 I2C 前先调用了 OLED 初始化函数,导致在时钟没有开启的状态下就去操作寄存器,程序卡死在等待标志位的地方。
4.2 DHT11 数据一直为 0 或校验失败
DHT11 数据异常是我这次工程里最费时间的问题。从显示结果看,温度和湿度一直是 0,校验和过不了。我首先想到的是时序问题,就开始调延时函数的准确性,后来发现自己手写的延时函数在开启优化编译选项后,循环变量被编译器优化掉了,延时时间直接变成 0。解决办法是使用 DWT 周期计数器或者改用定时器来保证微秒级延时的准确性。
还有一个让数据变 0 的原因是引脚模式配置。DHT11 的数据线在输出低电平拉低总线之后,必须立即切换成输入模式来读取传感器反馈的响应信号。很多第一次做这个模块的人会在初始化的最后设置 GPIO 为推挽输出,然后读取的时候忘记切换,导致读到的电平始终是引脚输出状态而不是总线状态。我改成了开漏输出带上拉,这样输出时整个总线是定的,输入时也不用切换模式,逻辑更稳定。
校验失败的问题主要集中在读取过程中,如果某一位因为时序抖动读错,校验和必然对不上。我的处理是:超过三次读取失败就丢弃当前数据,下次定时器中断再重新读取,绝不把错误数据上传给上位机。这一条对后面做数据清洗和显示逻辑来说非常重要,宁可少显示一帧,也不要显示错误数据。
4.3 串口数据乱码的处理
串口收到的数据是乱码,通常只有两个原因:波特率不匹配,或者是时钟配置不对导致波特率实际值和预期值差太多。
我刚开始用的 9600 波特率,上位机收到的全是乱码。一查芯片时钟树,发现内部 RC 振荡器误差太大,而我没有切换到外部晶振。解决方法是主板上有 8MHz 晶振的话,在 SystemInit 函数里把系统时钟来源切换到 HSE,同时配置好 PLL 倍频系数。时钟配置好之后,9600 和 115200 都试过,数据都正常。如果用的开发板没有外部晶振,建议换用 9600 或者 4800 波特率,内部 RC 在低频时误差相对更小,容错性更好。
还有一种乱码是电平不匹配造成的。有些 USB 转 TTL 模块是 5V 电平的,直接接 STM32 的 3.3V 引脚,时间长了会把芯片的引脚拉坏。最好是用带电平转换功能的模块,或者加一级 3.3V 稳压和电平转换。
4.4 定时器触发时间不准确
定时器的时间参数看起来很好算,实际上容易出问题的点有两个。一个是分频系数和重装载值到底要不要加 1,很多人在算的时候会漏掉这个“加 1”。第二个是 APB1 的分频系数对定时器时钟的影响,当 APB1 预分频不是 1 时,定时器的时钟是 APB1 的两倍,这个规则在参考手册里写得很隐蔽,但如果你忽视了它,算出来的定时时间会差一倍。
我建议的检验方法是:把计算出来的定时周期和实际观察到的现象做交叉验证。比如给 LED 翻转电平,观察闪烁周期,然后用手表计时 60 秒,数 LED 翻转了多少次,就知道定时是否准确。放在本次项目里,每 2s 一帧的数据,用手机秒表数 10 帧数据是不是恰好 20 秒,这个验证简单直接。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查步骤与解决办法 |
|---|---|---|
| OLED 不亮 | I2C 地址错误、引脚复用配置缺失、供电不足 | 先扫 I2C 地址确认设备在线;检查 PB6/PB7 复用开漏配置;测 VCC 电压 |
| OLED 显示乱码或镜像 | SSD1306 初始化参数不对、滑移设置错误 | 检查段重映射和 COM 扫描方向命令;初学时直接用厂家初始化序列 |
| DHT11 数据全 0 | GPIO 模式配置错误、延时函数不准确、未等应答 | 数据脚用开漏输出带上拉;用 DWT 实现微秒延时;检查响应时序 |
| DHT11 校验失败 | 引脚上拉了没有、总线干扰、读取时序太快 | 加上拉电阻;线材缩短;降低主频或优化延时精度 |
| 串口乱码 | 波特率不匹配、时钟源不准、电平不匹配 | 确认上位机和下位机波特率一致;切换外部晶振;用 3.3V 电平的串口模块 |
| 定时不准确 | 分频系数加 1 漏掉、APB1 时钟倍率忽略 | 跑测试程序实测翻转频率;查参考手册时钟树 |
5. 项目之外:可从这次作业延伸的方向
做完这套温湿度采集系统之后,站在课程设计的角度其实已经合格了,但如果你想让这个“第五次作业”拿高分,或者真的想把它往实际应用方向推进,还有很多值得顺手扩展的地方。
硬件层面,DHT11 的精度确实一般,换成 SHT30 之后,I2C 通信方式不变,精度和稳定性都会上一个大台阶。OLED 可以升级成 TFT 彩屏,甚至加一个按键实现菜单切换、历史数据回看。如果想要脱离电脑独立工作,加一个 SD 卡模块,把数据直接记录到本地 CSV 文件,那就成了一个小型的数据记录仪,仓库巡检这种场景也能实际用上。
软件层面,上位机可以从命令行版升级成带界面的工具,用 PyQt 或 Tkinter 写一个小窗口,显示实时数值曲线,数据存到 SQLite 数据库。如果再进一步,用 MQTT 协议把这套数据上报到本地服务器,通过浏览器远程查看,那就是一个标准的物联网项目雏形了。
通信方式上,把有线串口换成 ESP8266 模块连 WiFi,上位机逻辑不变,采集端变成无线传输,灵活性会高很多。虽然 ESP8266 自身的 AT 固件配置会带来新的复杂度,但这个过程对理解物联网通信链路非常有帮助。如果后续作业刚好是“做一个远程监控系统”,这个扩展点可以直接复用。
回到这次作业本身,我做完之后最大的感受是:嵌入式项目的难点不在于单个知识点有多难,而在于把多个知识点串起来的时候,任何一环出了问题都会导致整体失败。硬件的问题可能隐藏在你没有怀疑到的那根跳线上,软件的问题可能出在你觉得“肯定没问题”的延时函数里。这种系统性排查和解决问题的能力,才是“第五次作业”真正让你练的东西。
如果你们课程进度正常,可能很快会迎来期末考试或者课程设计答辩,建议把这次作业的过程数据保留好,包括硬件接线图、调试过程记录、上位机脚本和几次失败的截图。写报告的时候这些素材非常加分,答辩时也可以讲清楚“我遇到过什么问题、怎么定位的、最后怎么解决的”,这比只说“全部一次跑通”要有说服力得多。