你在学完点灯、按键、串口打印之后大概率会刷到这样一个综合实验用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.3VDHT11VCC3.3V 或 5V大部分模块兼容3.3V~5V建议先看模块丝印DHT11GNDGNDHC-05TXDPA10 (USART1_RX)交叉连接模块发送给STM32接收HC-05RXDPA9 (USART1_TX)交叉连接STM32发送给模块接收HC-05VCC5V多数HC-05模块板载稳压需要5V输入HC-05GNDGNDOLEDSCLPB8 (I2C1_SCL)OLEDSDAPB9 (I2C1_SDA)OLEDVCC3.3VOLEDGNDGNDST-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的从机地址通常是0x3C7位地址在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指令末尾加CRLF回车换行。比如发AT正确的发送内容是“AT\r\n”。常用AT指令我整理在这张表里够用就行。AT指令作用示例AT测试通信是否正常返回OK代表正常ATNAMExxx修改蓝牙名称ATNAMEMyTempATPSWD1234修改配对密码ATPSWD1234ATUART9600,0,0设置波特率、停止位、校验位设置为9600、1位停止、无校验ATROLE0设置从模式0是从机1是主机2是回环ATCMODE1任意设备可连接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的才有ATROLE之类的指令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改成72CubeMX会自动帮你算好PLL倍频系数。F103最大主频就是72MHz超过这个值芯片跑不稳不值得为了几MHz性能去挑战散热和稳定性。然后配置外设。USART1选择Asynchronous异步模式波特率先设96008位数据位、无校验、1位停止位默认配置就行勾选USART1全局中断——这步等后面做串口接收指令时会用到。I2C1选择I2C模式速度选Fast Mode 400kHz如果后续OLED通信不稳定再改回Standard Mode 100kHz。GPIO这里PA0保持默认输入模式然后在GPIO设置里把PA0的User Label改成DHT11方便代码里识别这是DHT11数据线注意实际运行时引脚模式需要在代码里切换成开漏输出。工程设置里Toolchain选MDK-ARM V5Code Generator勾选Generate peripheral initialization as a pair of .c/.h files这样每个外设单独生成文件代码结构清晰。最后生成代码用Keil打开。3.2 微秒级延时这个实验最容易翻车的地方DHT11的时序里到处都是几十微秒级别的等待而HAL_Delay()做不到微秒级精度它基于SysTick最小粒度1ms。如果直接用HAL_Delay(30)去实现30微秒延时实际可能延时1~2msDHT11压根不会理你读回来的都是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对应PA13SWCLK对应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\nHC-05的AT指令必须带回车换行才能识别。如果用的是USB转TTL检查TXD是不是接到了模块的RXD、RXD接TXD同时共地。还有确认模块的LED是不是慢闪如果LED快闪说明还在数据模式按着按键重新上电。透传模式阶段第一个坑是手机搜不到模块。前面已经说过安卓手机的蓝牙设置里经典蓝牙和BLE是分开的HC-05要在经典设备列表里找不要在“新设备”的BLE列表里翻。第二个坑是能配对但发数据没反应。先看串口接线模块TXD接STM32的PA10RXD接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 0x14OLED花屏列地址设置错、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共地从那以后莫名其妙的通信问题少了很多。