news 2026/9/8 13:47:47

STM32实战:DHT11温湿度采集+OLED显示+蓝牙传输全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32实战:DHT11温湿度采集+OLED显示+蓝牙传输全解析

你在学完点灯、按键、串口打印之后,大概率会刷到这样一个综合实验:用STM32读取DHT11温湿度,数据一边显示在0.96寸OLED屏上,一边通过HC-05蓝牙模块发给手机串口助手。这套组合几乎是STM32入门玩家的第一个“缝合怪”项目——STM32负责调度,DHT11负责采集,OLED负责展示,HC-05负责把数据无线送出去。我第一次把整条链路跑通的时候,最大感受是:原来嵌入式代码里的“数据流”是这么一回事。这篇文章就把这个实验从硬件接线、模块原理、CubeMX配置、驱动代码到联调排障全拆一遍,适合刚学会HAL库基础、想做一个能拿得出手的综合实验的读者,也适合直接拿来做毕业设计里的功能模块。

1. 这个实验到底在做什么:整体设计思路

1.1 四个核心模块各司其职

先理清楚每个模块在系统里的角色,这决定了你后面写代码时脑子里要装着什么。

STM32主控是整个系统的“调度中心”。它负责三件事:一是按照DHT11的单总线时序去拉数据线、读电平、解析温湿度;二是把解析出的数据变成OLED能理解的I2C指令去刷新显示;三是把同一个数据格式化成字符串,通过USART1发送给HC-05,再由HC-05的蓝牙射频发到手机端。

DHT11温湿度传感器是“采集员”。它内部集成了一个电阻式感湿元件和一个NTC测温元件,输出的是单总线数字信号。所谓单总线,就是数据线只有一根,主机和传感器共用这根线,靠严格的电平时序来区分数据0和数据1。它的特点是便宜、协议经典、足够教学用,精度是湿度±5%RH、温度±2℃,属于“能用但不精细”的级别。

OLED显示屏是“本地展示窗口”。0.96寸、128x64像素、I2C接口,驱动芯片是SSD1306。它本身没有字库,所有字符、汉字、数字都要自己往显存里填点阵数据。I2C只占SCL和SDA两根线,比LCD1602省了一大堆引脚,而且可视角度和对比度都更好。

HC-05蓝牙模块是“无线快递员”。它工作在经典蓝牙的SPP协议上,相当于把一根串口线变成无线透传通道。模块和STM32之间依然是UART串口通信,模块和手机之间走蓝牙射频。手机端只要用任意一款蓝牙串口App连接HC-05,就能实时收到STM32发来的字符串。

数据流向用一句话概括就是:DHT11→GPIO→STM32解析→I2C→OLED,同时STM32→USART1→HC-05→手机。两条展示路径共享同一个数据源,这是这个实验最有价值的地方——你第一次在一套代码里同时见识了单总线协议、I2C协议和UART串口协议。

1.2 为什么是这套组合,而不是其他方案

很多人问,为什么大家都拿DHT11、HC-05、OLED这三件套做入门实验,换成DHT22、BLE模块、LCD1602行不行?当然行,但各有各的取舍。

DHT11的核心价值不在精度,而在它逼你理解“时序协议”。它的单总线时序非常典型:主机发起始信号、传感器应答、按位传输、校验和验证,是嵌入式里最朴素的通信协议模型。换AHT20这种I2C接口的传感器当然更好用,但你就少了一次亲手和一根数据线较劲的机会。而且DHT11模块才几块钱,坏了不心疼。

HC-05选型背后的逻辑也值得一提。它是经典蓝牙BR/EDR,走的是SPP串口配置文件,手机连上之后直接就是一个虚拟串口,收发透明得就像插了根线。相比之下,BLE低功耗蓝牙用的是GATT服务和特征值,手机端要写App或者用特定的调试工具才能处理收发,对新手来说调试门槛高得多。HC-05用AT指令就能配置主从模式、波特率、配对密码,玩一圈下来你对串口通信的理解会扎实不少。缺点是经典蓝牙功耗高、速度低、iOS系统对SPP支持很差,所以如果目标是做产品原型,我反而建议直接上BLE;但作为学习和做演示,HC-05是性价比最高的选择。

OLED vs LCD1602,这个基本不用犹豫。LCD1602需要8根或4根数据线加控制线,I2C转接板虽然省线但显示内容还是局限于字母数字和自定义字符;OLED只用两根线,能显示汉字、画曲线,3.3V供电和STM32直接兼容,外观也好看,做成实物展示的时候很加分。

软件方案上,我推荐ST官方STM32CubeMX生成初始化代码,配合HAL库在Keil里写逻辑。这套流程现在已经是主流,好处是时钟树、引脚复用、外设初始化全都可以可视化配置,生成的工程结构清晰,网上资料也最多。标准库和寄存器版本不是不能学,只是对于这个实验来说,HAL库能让你把精力集中在业务逻辑而不是寄存器位操作上。

1.3 一轮完整的工作流程长什么样

整个系统上电后其实是一个循环:采集→显示→上报→等待→再采集。我习惯把每一轮拆成以下几步来看。

第一步是初始化。CubeMX生成的SystemClock_Config会先把系统时钟配好;然后MX_GPIO_Init配置DHT11数据引脚;MX_I2C1_Init配好I2C总线;MX_USART1_UART_Init配好串口。OLED这边还需要额外执行一段SSD1306的初始化序列,把屏幕点亮、清屏。

第二步是DHT11采集。主循环里每2秒拉低DHT11的数据线至少18ms发起起始信号,然后切换引脚为输入模式,等待传感器应答。DHT11返回40位数据,分别是湿度整数、湿度小数、温度整数、温度小数和校验和。STM32校验通过后,拆出实际的温度和湿度值。

第三步是数据展示。把温湿度格式化成字符串,一部分写进OLED的显存刷新显示,一部分通过HAL_UART_Transmit阻塞发送给HC-05。HC-05在数据透传模式下不关心内容是什么,它只负责把从串口收到的字节原封不动地通过蓝牙发给已连接的手机。

第四步是等待。DHT11的典型采集周期是2秒,读太频繁会让传感器来不及更新内部数据,导致读出来的永远是上次的缓存甚至全是0xFF。所以主循环最后HAL_Delay(2000),给传感器留足刷新时间。

整个流程看起来不复杂,真正实现的时候你会发现处处都是细节,接下来我从硬件接线开始逐层展开。

2. 硬件接线和模块原理:动手之前先把底细摸清

2.1 引脚分配与接线明细

我用的是STM32F103C8T6最小系统板,因为它是目前最常见的入门板子,CubeMX里型号好选,资料也多。引脚分配如下表,照着接就行。

模块模块引脚STM32引脚补充说明
DHT11DATAPA0模块自带上拉电阻则无需外接,否则接4.7k~10k上拉到3.3V
DHT11VCC3.3V 或 5V大部分模块兼容3.3V~5V,建议先看模块丝印
DHT11GNDGND
HC-05TXDPA10 (USART1_RX)交叉连接,模块发送给STM32接收
HC-05RXDPA9 (USART1_TX)交叉连接,STM32发送给模块接收
HC-05VCC5V多数HC-05模块板载稳压,需要5V输入
HC-05GNDGND
OLEDSCLPB8 (I2C1_SCL)
OLEDSDAPB9 (I2C1_SDA)
OLEDVCC3.3V
OLEDGNDGND
ST-LinkSWDIOPA13下载调试线
ST-LinkSWCLKPA14下载调试线

这里有几个接线细节值得多说一句。首先,所有模块必须和STM32共地,也就是所有GND接到同一个参考地。如果不共地,串口和I2C的信号电平没有共同参考点,会出现“偶尔能通信、偶尔乱码”的疑难杂症。

其次,HC-05的电平兼容问题。市面上的HC-05模块绝大多数板载了3.3V稳压和电平转换电路,所以VCC接5V,逻辑引脚TX/RX输出的是3.3V电平,可以直接接STM32的PA9、PA10,不需要额外分压。但如果你买的是老式裸板或者那种没有电平转换的模块,RX引脚不能直接接5V TTL电平,需要用电阻分压或者电平转换模块。判断方法很简单:查模块背面的丝印,写了“3.3V-5V”或者“TTL level”的就放心接,只写“5V”的就要留意。

第三,杜邦线尽量短。DHT11的数据线如果飞了20cm以上,再碰上接触不良,读到全0xFF的概率会直线上升。我自己调试的时候用过长杜邦线,DHT11返回值一直在校验错误摸不着头脑,换短线和独立供电后立刻就好。

2.2 DHT11单总线协议细节拆解

DHT11的通信协议值得花一整节细讲,因为所有“读不到数据”“读出来是255”“温湿度固定不变”的问题,根源都在于对这个时序的理解不到位。

先看总线的空闲状态。总线空闲时是高电平,因为数据线上有上拉电阻。主机要开始一次通信,先把总线拉低至少18ms,这就是起始信号。拉低的时间必须足够长,DHT11才能从它的低功耗状态中唤醒。起始信号结束后,主机释放总线(拉高),然后延迟20~40微秒。

接下来是DHT11的应答信号。传感器检测到起始信号后会拉低总线80微秒,再释放并拉高80微秒。主机在发出起始信号后,要把数据线切换成输入模式,然后等待这个80us低电平→80us高电平的应答。

应答完成之后就是40位数据。每一位的传输方式都是:DHT11先拉低总线50微秒,然后释放。重点来了,数据0和数据1的区别在高电平持续的时间——如果高电平持续26~28微秒就结束,表示这一位是0;如果高电平持续70微秒左右,表示这一位是1。主机读位的标准动作是:等到低电平结束,延迟40微秒,然后读引脚电平。延迟40us后如果引脚还是高,说明是高电平长脉宽,这一位就是1;如果已经变低了,说明是短脉宽的0。

40位数据的排列顺序是固定的:湿度整数8位、湿度小数8位、温度整数8位、温度小数8位、校验和8位。其中湿度小数和温度小数在DHT11上通常读出的是0,因为它的分辨率就到这儿了。校验和等于前四个字节相加再取低8位,如果算出来对不上,这一帧数据就得丢弃。

实际写代码的时候有三个最重要的注意事项。第一,上电之后必须等待1到2秒再读第一次,否则模块内部还在自检,数据线上不会有应答。第二,两次读取间隔不要小于1秒,DHT11的数据更新周期最快也就2Hz左右,读太频繁等于催一个动作还没做完的人,不会有结果。第三,所有微秒级延时都要用微秒延时函数,不能用HAL_Delay,因为HAL_Delay最小单位是1ms,误差大到完全没法跑这个时序。

2.3 OLED显示模块与SSD1306驱动逻辑

0.96寸I2C OLED用的是SSD1306驱动芯片,内部有一块128x64的显存GDDRAM,共8192个位,也就是1KB。屏幕上每一个像素点对应显存里的一个比特,写1点亮,写0熄灭。这块显存被分成8页(Page0到Page7),每页128字节,正好对应屏幕从上到下的8个水平条带。

I2C通信时,SSD1306的从机地址通常是0x3C(7位地址),在HAL库发送时要把地址左移一位变成0x78,也就是把读写位留出来。每次I2C传输,第一个字节必须是控制字节:0x00表示后面跟的是命令,0x40表示后面跟的是显示数据。命令用来设置显示开关、地址模式、页地址、列地址等,数据用来填充显存。

显示一个字符或汉字的本质是“把点阵数据写进正确的显存位置”。以16x16汉字为例,它占2页,每页连续16个字节。写的时候先设置页地址和列地址,在第一页从左到右写16字节(汉字上半部分),然后把页地址加1,再写16字节(汉字下半部分),屏幕上就出现了一个完整的16x16汉字。

很多人OLED显示乱码、花屏,基本都是两个原因:一是I2C地址写错,0x3C写成了0x3D都没察觉;二是操作列地址的时候高低字节搞反了,SSD1306的列地址设置必须分开写两个命令——先写高四位(0x10 | 高4位),再写低四位(0x00 | 低4位)。

另外提一句关于硬件I2C和模拟I2C的选择。STM32F1系列的硬件I2C在HAL库下偶尔会出现总线忙(BUSY)导致卡死的情况,这是老生常谈的问题。我的建议是:如果只是做这个实验,硬件I2C正常用没问题;如果后面项目里I2C设备多了、通信频繁了、或者你不想花时间排查偶发卡死,直接改用GPIO模拟I2C,两根引脚任意映射,稳定性反而更好。

2.4 HC-05蓝牙模块的工作模式与关键配置

HC-05最让人困惑的就是它有两种模式:AT指令配置模式和数据透传模式。搞不明白这两个模式的区别,很多人会在“为什么串口助手发AT没反应”上卡半天。

AT模式是用来配置模块参数的,比如修改蓝牙名称、配对密码、串口波特率、主从角色。进入AT模式的方法是:按住模块上的小按键不要松手,然后给模块上电。看到模块上的LED变成慢闪(大致2秒闪一次),说明已经进入AT模式。此时模块的串口波特率固定是38400,不是默认的9600。所以用USB转TTL接电脑,串口助手要选38400,同时勾选“发送新行”,也就是在每条AT指令末尾加CR+LF(回车换行)。比如发AT,正确的发送内容是“AT\r\n”。

常用AT指令我整理在这张表里,够用就行。

AT指令作用示例
AT测试通信是否正常返回OK代表正常
AT+NAME=xxx修改蓝牙名称AT+NAME=MyTemp
AT+PSWD=1234修改配对密码AT+PSWD=1234
AT+UART=9600,0,0设置波特率、停止位、校验位设置为9600、1位停止、无校验
AT+ROLE=0设置从模式0是从机,1是主机,2是回环
AT+CMODE=1任意设备可连接1为任意地址连接,0为指定地址

配置完成后重新上电,模块就回到数据透传模式了。在这种模式下,模块就是个桥梁:STM32通过串口发给HC-05的字节,HC-05原封不动地通过蓝牙发给手机;手机App发的字节,也会从HC-05的TXD发出来,被STM32的串口收到。这个模式默认波特率是9600,如果你在AT模式里改过,就要保证和STM32端串口配置一致,比如两边都设成9600。

手机连接HC-05时有一个非常容易踩的坑:很多安卓手机把“经典蓝牙设备”和“BLE设备”分开列在系统蓝牙设置的不同位置。HC-05是经典蓝牙,必须在“已配对的设备”或“可用设备”列表里刷新,不要只盯着“新设备”里的BLE列表。配对密码默认是1234,只要之前没有在AT模式里改过。

还有一个常见疑问是HC-05和HC-06的区别。HC-05支持主从一体,能进AT模式,适合学习;HC-06是从机且不能切换主从,不能用来做主机。买的时候看准型号,标了HC-05的才有AT+ROLE之类的指令,HC-06只能用一部分AT指令。网上有些人说HC-06也能进入AT模式,那是另一套指令集,别搞混。

3. CubeMX基础工程与驱动代码实战

3.1 先搭一个不折腾的CubeMX工程

如果你已经会建CubeMX工程,可以直接跳到3.2;如果是第一次从零配,跟着下面的步骤走一遍就行,我尽量把每一步背后的原因说清楚。

打开STM32CubeMX,新建工程,芯片型号选STM32F103C8Tx。RCC设置里,HSE选择Crystal/Ceramic Resonator,这样用的是板载8MHz晶振。SYS设置里,Debug选择Serial Wire,这一步极其重要但经常被忽略——如果Debug选项是No Debug,程序烧第一次能成功,第二次再下载时Keil就会报no stm32 target found,因为你第一次烧录把SWD引脚复用成普通GPIO了。选Serial Wire等于把PA13、PA14保留给调试器。

时钟树页面,把HCLK改成72,CubeMX会自动帮你算好PLL倍频系数。F103最大主频就是72MHz,超过这个值芯片跑不稳,不值得为了几MHz性能去挑战散热和稳定性。

然后配置外设。USART1选择Asynchronous异步模式,波特率先设9600,8位数据位、无校验、1位停止位,默认配置就行,勾选USART1全局中断——这步等后面做串口接收指令时会用到。I2C1选择I2C模式,速度选Fast Mode 400kHz,如果后续OLED通信不稳定,再改回Standard Mode 100kHz。GPIO这里,PA0保持默认输入模式,然后在GPIO设置里把PA0的User Label改成DHT11,方便代码里识别这是DHT11数据线,注意实际运行时引脚模式需要在代码里切换成开漏输出。

工程设置里Toolchain选MDK-ARM V5,Code Generator勾选Generate peripheral initialization as a pair of .c/.h files,这样每个外设单独生成文件,代码结构清晰。最后生成代码,用Keil打开。

3.2 微秒级延时:这个实验最容易翻车的地方

DHT11的时序里到处都是几十微秒级别的等待,而HAL_Delay()做不到微秒级精度,它基于SysTick,最小粒度1ms。如果直接用HAL_Delay(30)去实现30微秒延时,实际可能延时1~2ms,DHT11压根不会理你,读回来的都是0xFF。

微秒延时方案有两种。一是重新配置SysTick让它的中断周期变成1us,这样HAL_Delay单位就变成微秒,但会改变HAL库内部的时间基准,后续HAL_Delay、HAL_GetTick全部要跟着调整,不建议新手这么干。二是用ARM内核的DWT跟踪单元,读取CPU周期计数器,精度高而且完全不影响SysTick。我推荐用DWT。

DWT延时代码很短,本质就是记下当前Cycle Count,然后忙等待目标差值:

static void Delay_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } static void Delay_Us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000U); while ((DWT->CYCCNT - start) < ticks); }

这里SystemCoreClock在F103配置为72MHz主频时是72000000,所以1微秒对应72个时钟周期。注意ticks最大值问题,如果us特别大,us * 72可能超出uint32_t范围,但实际DHT11读取用到的延时最长也就几百微秒,完全不用担心。

有了这个函数之后,DHT11的时序才能精确控制,所以这个函数是整个传感器驱动的地基。

3.3 DHT11驱动代码的完整写法

DHT11驱动我习惯分成三个函数:起始信号、读取一字节、整体读取校验。下面这套代码在HAL库下可以直接用,我加了一些注释方便理解。

首先是起始信号。注意PA0在CubeMX里我配置成输入模式,为了方便发起始信号,先把引脚重新初始化为开漏输出带上拉。开漏输出的好处是,拉低就是主动拉低,释放(写1)就是靠外部上拉电阻回高,和单总线协议天然契合。

#define DHT11_PORT GPIOA #define DHT11_PIN GPIO_PIN_0 void DHT11_Start(void) { GPIO_InitTypeDef gpio = {0}; HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); HAL_Delay(20); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); Delay_Us(30); gpio.Pin = DHT11_PIN; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(DHT11_PORT, &gpio); while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET); while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET); while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET); }

这个函数的意思先拉低总线20ms,释放后延时30us,然后切换为输入模式,接着等待三个电平跳变:等DHT11拉低(应答信号低电平)、等它拉高(应答信号高电平)、等它再次拉低(开始传第一位数据)。三个while都通过后,说明传感器已经就绪,可以开始读位了。

然后是读取一字节和整体的读取。读取一字节其实就是循环8次读位,位的判断方式就是前面讲的高电平脉宽区分法:

uint8_t DHT11_ReadByte(void) { uint8_t data = 0; for (int i = 0; i < 8; i++) { while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET); Delay_Us(40); if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { data |= (0x80 >> i); } while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET); } return data; }

读位时先等引脚从低变高,也就是等50us的低电平结束;然后延时40us;此时如果引脚还维持高电平,说明总的高电平宽度至少有70us以上,这一位就是1,否则是0。

整体读取函数把40位数据按顺序装进数组,然后做校验:

uint8_t DHT11_Read(uint8_t *humi_int, uint8_t *humi_dec, uint8_t *temp_int, uint8_t *temp_dec) { uint8_t buf[5] = {0}; DHT11_Start(); for (int i = 0; i < 5; i++) { buf[i] = DHT11_ReadByte(); } uint8_t checksum = buf[0] + buf[1] + buf[2] + buf[3]; if ((checksum & 0xFF) != buf[4]) { return 1; // 校验失败 } *humi_int = buf[0]; *humi_dec = buf[1]; *temp_int = buf[2]; *temp_dec = buf[3]; return 0; }

这里如果你希望代码更健壮,可以在每个while等待里加超时计数,比如超过几百微秒就退出并返回失败。教学代码一般省略这个,但实际项目我一定会加,否则万一DHT11没接好或者数据线被占住,主循环会死等在一个while里,整个系统看起来像卡死了一样。

3.4 OLED显示与中文/ASCII的实现

OLED驱动我建议先打好底层三个函数:写命令、写数据、设置坐标。后续所有显示功能都建立在它们之上。

#define OLED_ADDR 0x78 void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 100); } void OLED_WriteData(uint8_t data) { uint8_t buf[2] = {0x40, data}; HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 100); } void OLED_SetPos(uint8_t x, uint8_t y) { OLED_WriteCmd(0xB0 + y); OLED_WriteCmd(((x & 0xF0) >> 4) | 0x10); OLED_WriteCmd((x & 0x0F)); }

写命令函数里,第一个字节0x00告诉SSD1306接下来的字节是命令;写数据的第一个字节是0x40,告诉它接下来是显存数据。设置坐标函数里,0xB0~0xB7设置页地址(0~7),后面两个命令分别设置列地址的高4位和低4位。

屏幕初始化的序列比较长,但基本是固定的,我贴一段最常用的:

void OLED_Init(void) { HAL_Delay(50); OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0x20); // 设置内存地址模式 OLED_WriteCmd(0x00); // 水平地址模式 OLED_WriteCmd(0xB0); // 设置起始页地址 OLED_WriteCmd(0x00); // 设置列地址低字节 OLED_WriteCmd(0x10); // 设置列地址高字节 OLED_WriteCmd(0x40); // 设置起始行地址 OLED_WriteCmd(0x81); // 对比度设置 OLED_WriteCmd(0xCF); // 对比度值 OLED_WriteCmd(0xA1); // 设置段重映射,0xA0是正常方向,0xA1为水平翻转 OLED_WriteCmd(0xC0); // 设置COM扫描方向,0xC0为正常,0xC8为垂直翻转 OLED_WriteCmd(0xA6); // 正常显示,0xA7为反色 OLED_WriteCmd(0xA8); // 设置多路复用比 OLED_WriteCmd(0x3F); // 1/64 duty OLED_WriteCmd(0xA4); // 显示内容按GDDRAM显存输出 OLED_WriteCmd(0xD3); // 设置显示偏移 OLED_WriteCmd(0x00); // 偏移为0 OLED_WriteCmd(0xD5); // 设置时钟分频 OLED_WriteCmd(0xF0); // 推荐参数 OLED_WriteCmd(0xD9); // 设置预充电周期 OLED_WriteCmd(0x22); // 推荐参数 OLED_WriteCmd(0xDA); // 设置COM引脚硬件配置 OLED_WriteCmd(0x02); // 推荐参数 OLED_WriteCmd(0xDB); // 设置VCOMH OLED_WriteCmd(0x49); // 推荐参数 OLED_WriteCmd(0x8D); // 设置电荷泵 OLED_WriteCmd(0x14); // 启用电荷泵,给屏幕升压 OLED_WriteCmd(0xAF); // 开启显示 OLED_Clear(); }

OLED_Clear就是往所有显存地址写0x00,实现清屏:

void OLED_Clear(void) { for (uint8_t page = 0; page < 8; page++) { OLED_SetPos(0, page); for (uint8_t i = 0; i < 128; i++) { OLED_WriteData(0x00); } } }

显示ASCII字符,需要一张8x16的ASCII点阵字库,按ASCII码索引,每个字符占16字节,一行上半部分8字节、下半部分8字节。显示函数把每个字符拆成上半页和下半页分别写入:

void OLED_ShowChar(uint8_t x, uint8_t y, char c) { for (int i = 0; i < 8; i++) { OLED_SetPos(x, y); OLED_WriteData(ASCII_F8x16[(c - 32) * 16 + i]); OLED_SetPos(x, y + 1); OLED_WriteData(ASCII_F8x16[(c - 32) * 16 + i + 8]); x++; } }

显示字符串就是循环调用ShowChar。显示汉字稍微不同,汉字是16x16点阵,占两页,每页写16个字节,字模可以用PCtoLCD2002之类的工具取,取模方式选“逐行式”即可。这里给一个显示汉字的核心函数:

extern const uint8_t CN_F16x16[][32]; void OLED_ShowCN(uint8_t x, uint8_t y, uint8_t index) { for (int i = 0; i < 16; i++) { OLED_SetPos(x, y); OLED_WriteData(CN_F16x16[index][i]); OLED_SetPos(x, y + 1); OLED_WriteData(CN_F16x16[index][i + 16]); x++; } }

这段逻辑理解透了,以后自己写图形界面、画菜单都不是问题。

3.5 主循环:采集、显示、蓝牙上报串起来

所有驱动写完后,主循环就很简单了。核心代码如下:

int main(void) { uint8_t humi_int = 0, humi_dec = 0; uint8_t temp_int = 0, temp_dec = 0; char msg[64] = {0}; HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); MX_USART1_UART_Init(); Delay_Init(); OLED_Init(); OLED_ShowString(0, 0, "Temp: "); OLED_ShowString(0, 2, "Humi: "); HAL_Delay(2000); while (1) { if (DHT11_Read(&humi_int, &humi_dec, &temp_int, &temp_dec) == 0) { sprintf(msg, "T:%d.%d C H:%d.%d %%\r\n", temp_int, temp_dec, humi_int, humi_dec); OLED_ShowString(0, 0, "Temp: "); OLED_ShowChar(6 * 8, 0, temp_int / 10 + '0'); OLED_ShowChar(7 * 8, 0, temp_int % 10 + '0'); OLED_ShowChar(8 * 8, 0, '.'); // ... 完整显示逻辑 HAL_UART_Transmit(&huart1, (uint8_t *)msg, strlen(msg), 100); } HAL_Delay(2000); } }

OLED的显示逻辑我这里是示意,完整的显示需要把每个数字转成字符再定位写入,比较繁琐但不难。字符串msg同时用于OLED和蓝牙上报,“T:28.3 C H:60.0 %\r\n”这种格式在手机蓝牙串口App里显示非常直观,也被我后续很多项目沿用。

串口发送用的是HAL_UART_Transmit阻塞模式,在2秒一次的频率下完全够用。如果你还想让手机发指令给STM32,比如发“R”就回传一次数据,可以在串口接收中断回调里处理,这个属于扩展内容,放到第5章再聊。

4. 联调中的坑与排查经验

4.1 程序下载不进去:error: no stm32 target found!

这个报错堪称新手第一杀手,完整的报错后面还会跟着一段“if your product embeds debug authentication...”云云。我第一次遇到时以为是芯片烧了,后来才知道基本都是连接问题。

按顺序排查这几点:第一,ST-Link的四根线SWDIO、SWCLK、GND、3.3V是不是都接对了,SWDIO对应PA13,SWCLK对应PA14,不要和串口的TX/RX搞混。第二,目标板有没有独立供电,很多最小系统板只靠ST-Link的3.3V供电能下载,但带负载之后电压跌落严重就连接不上了,这时候给板子单独供5V,然后ST-Link和板子共地。第三,按住板子上的复位按键不松,点击Keil里的下载按钮,看到开始下载的瞬间松开复位键,这个方法能救回不少死于SWD引脚被占用的板子。第四,如果以上都不行,用STM32CubeProgrammer连接,遇到读保护提示就Select All然后Full Chip Erase全片擦除,擦完芯片恢复出厂般的畅通。

这个问题的根源,绝大多数时候是上一次烧录的程序把SWD引脚复用成了普通IO,或者芯片启用了读保护。CubeMX里SYS的Debug选项记得选Serial Wire,能避免大部分二次下载失败的情况。

4.2 ST-Link的虚拟串口出现黄色感叹号

有个现象很迷惑:程序下载正常,但设备管理器里ST-Link虚拟出的COM口一直带黄色感叹号,串口助手也打不开。这个通常是驱动签名、版本兼容或者电脑USB供电的问题。

解决办法比较直接:去ST官网下载最新版STM32 ST-LINK Utility或直接装STSW-LINK009驱动包,重装驱动。如果重装后还是感叹号,换一个USB口试试,有些前置USB口供电不稳,ST-Link的一部分功能会失灵。注意辨认这个感叹号的设备名,如果带着“STM32 Virtual COM Port”字样,就说明是VCP虚拟串口驱动问题,和你的业务代码无关,别在代码里瞎改。

4.3 DHT11读不到数据的几类原因

DHT11读出来全是0xFF、读出来校验一直失败、温湿度永远不变,这三种现象分别对应不同的原因,但很多都是从同一个源头分叉出来的。

先查上电等待。代码里如果初始化之后马上读DHT11,大概率失败。必须在主循环前加至少2秒的延时,让传感器完成上电自检。再查引脚模式。DHT11的数据线必须配置成输入上拉模式,或者开漏输出带上拉,不能配成推挽输出之后还想读它,推挽输出会一直怼着电平,传感器没法拉低总线。然后是上拉电阻。模块自带上拉电阻的还好,如果是裸传感器,数据线必须接4.7k到10k欧姆到3.3V,否则总线电平漂移,时序全部对不上。

还有一种隐蔽情况:微秒延时函数有问题。如果你用了HAL_Delay来模拟微秒,或者DWT的SystemCoreClock和你实际时钟配置不一致,整个时序都会偏,表现出来就是“有时候能读出来,有时候不行”,而且概率还很随机。建议用一个逻辑分析仪或者示波器去看数据线上的波形,这是最直观的定位方式。没有示波器的话,可以用一个简单的方法佐证:把读取函数的返回值打印到串口,观察是固定错误还是随机错误,固定错误多半是硬件/配置问题,随机错误多半是时序/电源问题。

4.4 OLED不亮或花屏的排查思路

OLED“完全没反应”和“显示乱码”是两条不同的排查路径。

完全没反应,先量I2C地址。用一段I2C扫描代码,把总线上所有从机地址打印出来,看看有没有0x3C或0x3D。如果扫描不到地址,检查OLED的VCC和GND是否正常、SCL/SDA是不是接反了、上拉电阻是否有。扫描到了但不显示,看初始化序列有没有执行,SSD1306的0x8D 0x14两条命令是启用内部电荷泵的,漏掉这一步屏幕就永远不亮,这是最常见的“代码没问题但就是不亮”的原因。

显示乱码或者花屏,先考虑列地址问题。0.96寸OLED常见的方向设置是0xA1段重映射和0xC8 COM扫描方向,这两个参数设置错会导致镜像显示或者上下颠倒。如果只在某一块区域显示花屏,大概率是坐标设置函数里列地址高低字节写反了,或者页地址超出了0xB0~0xB7范围。

还有一个容易忽略的点:I2C速度。400kHz在长线或者接触不良的情况下很容易出错,直接在CubeMX里把I2C速度降到100kHz试试,如果问题消失,说明就是电气特性问题,不是代码逻辑问题。

4.5 蓝牙连不上、收不到数据的常见情况

HC-05的问题我按“AT模式阶段”和“透传模式阶段”分开说。

AT模式阶段最典型的问题是发送AT没反应。先确认波特率是38400而不是9600,很多模块出厂默认通信波特率9600,但AT模式固定38400。再确认串口助手勾选了发送新行(\r\n),HC-05的AT指令必须带回车换行才能识别。如果用的是USB转TTL,检查TXD是不是接到了模块的RXD、RXD接TXD,同时共地。还有,确认模块的LED是不是慢闪,如果LED快闪说明还在数据模式,按着按键重新上电。

透传模式阶段,第一个坑是手机搜不到模块。前面已经说过,安卓手机的蓝牙设置里经典蓝牙和BLE是分开的,HC-05要在经典设备列表里找,不要在“新设备”的BLE列表里翻。第二个坑是能配对但发数据没反应。先看串口接线,模块TXD接STM32的PA10,RXD接PA9,共地;再看波特率,模块在AT模式里设置的波特率必须和STM32的USART1初始化一致,两边都是9600就都9600,一个改了另一个没改,数据全是乱码或者根本收不到。

另外一个很低级但常有的事情:串口助手连接的COM口选错了。ST-Link的VCP是一个COM口,USB转TTL又是一个COM口,蓝牙手机App是虚拟COM,如果同时插着好几个设备,很容易发到错误的端口上去。

4.6 常见问题速查表

现象可能原因解决办法
下载时error: no stm32 target foundSWD线接错、未共地、芯片读保护检查SWDIO/SWCLK/GND,按复位下载,CubeProgrammer全擦除
串口打印乱码波特率不匹配、共地问题统一波特率,检查GND连接
DHT11读回0xFF上电未等待、引脚模式错误、无上拉初始化后延时2秒,配置上拉输入,加上拉电阻
DHT11校验失败微秒延时不准、杜邦线过长用DWT延时,缩短杜邦线
OLED不亮I2C地址错、电荷泵未启用I2C扫描地址,初始化序列0x8D 0x14
OLED花屏列地址设置错、I2C速度过快检查SetPos高低字节,降速到100kHz
蓝牙AT无响应波特率不是38400、未加回车换行设置38400,勾选发送新行
手机搜不到HC-05在BLE列表翻找经典设备到“可用设备”或经典蓝牙列表查找
蓝牙连上但无数据TX/RX接反、波特率不一致交叉连接,统一模块与MCU波特率

这张表我建议直接保存,调试的时候对着看,能省下大量瞎试的时间。

5. 从实验迈向一个真正的小项目

5.1 把数据协议封装得更规范一点

主循环里sprintf直接拼字符串“T:28.3 C H:60.0 %”方便人眼看,但如果后面要接上位机、手机App、云平台,这种格式解析起来并不高效。建议设计一个最简单的帧协议,比如:

帧头帧头长度类型数据区校验
0xAA0x550x060x01T_int T_dec H_int H_dec累加和

发送端拼帧,接收端按状态机解析。手机App或者电脑上位机收到后,先校验帧头帧尾,再按长度读数据区,最后校验和确认数据有效。这个协议框架虽然简单,但已经具备了一个通信协议的雏形,后续扩展到WiFi模块、云平台MQTT都沿用的同一套思想。

5.2 从轮询改成定时中断驱动

主循环每2秒HAL_Delay一次虽然逻辑简单,但“死等延时”对后续扩展不友好。比如你想加按键响应、加OLED动画、加报警,主循环被HAL_Delay卡住2秒,其他事全被拖累。

改进方案是用一个定时器,比如TIM3产生1秒中断,在中断回调里置一个标志位,主循环检测到标志位再执行采集和上报,这样主循环的while(1)是空的,随时可以响应其他事件。注意DHT11的时序读取本身不要放进中断里,因为它的微秒级等待不能被更高优先级的事件打断,一旦被打断,时序就毁了。正确做法是中断里降低频任务触发频率,读取动作还是放在主循环里一次完成。

5.3 可以继续扩展的方向

这个实验的架构其实是一个小型的物联网终端雏形:传感器采集+本地显示+无线上报。在这个框架上扩展,路径非常清晰。

加一个ESP8266或ESP32模块,把温湿度数据通过WiFi上报到局域网或云平台,配合手机App或者网页Dashboard,就变成一个真正的远程监控系统。加一个继电器模块和阈值判断逻辑,温度过高或者湿度过低时自动控制风扇、加热器或者加湿器,这就是一个智能家居控制节点,网上很多“基于STM32的智能台灯”“智能温室系统”的毕业设计都是这个思路的变体。OLED上还可以画温湿度曲线、做一个简单的菜单界面,用按键切换显示页面,这几乎是STM32GUI开发的入门模板。

数据存储方向,可以加一片Flash或者用STM32内部Flash保存历史数据,做成一个可回溯的温湿度记录仪。如果你手头有K210或OpenMV这样的视觉模块,也可以让它和STM32通信,把视觉识别结果和温湿度一起上传,玩法就更多了。

这个项目最大的意义,我觉得不在于它用了多少新技术,而在于它让你从头到尾亲手打通了一条数据链路。以后再遇到新的传感器、新的通信模块,你都可以拿这套框架去套:底层驱动写时序,中间层做数据解析,上层做展示和上报,分层的思路一旦形成,写项目代码会顺很多。

最后再分享一个小细节。我在做这个实验时踩过最深的一次坑,是DHT11和OLED单独验证都正常,合在一起就偶发失败,排查了半天,最后发现是杜邦线接触不良加上板子用电脑USB口供电,OLED峰值电流拉低了一下电压,导致DHT11的数据时序在高电平时识别不稳。所以联调时,如果多个模块合体后出现“单模块都好、组合起来时好时坏”的诡异问题,先怀疑供电和接线,不要急着怀疑代码。补充一句,我用了一个很小的经验:所有GND线用黑色杜邦线集中拧在一个排针上,再和USB转TTL、ST-Link共地,从那以后莫名其妙的通信问题少了很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 13:47:01

从记录到契约:系统生命周期中的文档价值与落地方法

干了这么多年信息系统建设&#xff0c;我越来越认同一个判断&#xff1a;文档在整个系统生命周期里&#xff0c;既是"知识载体"&#xff0c;也是"沟通契约"。说直白点&#xff0c;它不只是把过程记下来给别人看&#xff0c;更是让所有参与的人——业务方、…

作者头像 李华
网站建设 2026/9/8 13:46:35

C#实现SICK RFID读卡器TCP异步通信:从协议解析到断线重连实践

简介&#xff1a;面向需与德国SICK RFID读卡器RFU630通信的C#开发者&#xff0c;这份资源提供了一套基于TCP客户端的异步读取程序&#xff0c;可应用于自动化、物流、生产流程中的物体识别与追踪场景&#xff0c;解决工业设备数据采集、多线程处理和界面卡顿等问题。压缩包共32…

作者头像 李华
网站建设 2026/9/8 13:46:30

AI编程工具选型实测:免费与付费方案如何组合最划算?

我前前后后把市面上叫得出名字的AI编程工具都试了个遍&#xff0c;从免费插件到按月订阅的付费方案&#xff0c;踩过不少坑&#xff0c;也总结出了一套自己的选型逻辑。今天这篇不是给你罗列一堆官网介绍&#xff0c;而是说点实际使用的真话&#xff1a;哪些钱值得花&#xff0…

作者头像 李华
网站建设 2026/9/8 13:42:49

从PR淹没到自动化评审:Hermes如何用大语言模型重塑代码质量门禁

最近团队把代码评审的活儿交给了一个自动化工具&#xff0c;叫 Hermes。一开始只是抱着试试看的心态&#xff0c;想着能帮我们少点重复劳动&#xff0c;结果跑了一段时间&#xff0c;这玩意儿确实把 GitHub 上的 PR 审查流程捋顺了不少。今天就把我们接入 Hermes 做自动化代码评…

作者头像 李华
网站建设 2026/9/8 13:41:11

S7-1200与WinCC立体车库控制系统实战:从PLC程序到PLCSIM仿真

先说个事&#xff1a;我见过不少人把立体车库的电气控制系统想得很简单&#xff0c;觉得无非就是几个电机正反转、几个限位开关、一块触摸屏。真把项目接到手里才明白&#xff0c;麻烦的不是单个动作&#xff0c;而是“一排车位、两层甚至三层、还要防止人和车同时出问题”的调…

作者头像 李华
网站建设 2026/9/8 13:41:04

视频加载技术实现与用户体验优化全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华