news 2026/10/16 4:17:54

STM32 HAL库实战:DHT11温湿度采集与OLED显示驱动移植

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 HAL库实战:DHT11温湿度采集与OLED显示驱动移植

简介:这是一份面向嵌入式与物联网初学者的STM32实战工程,基于HAL库实现DHT11温湿度采集与OLED实时显示,适合想打通传感器驱动与显示交互的开发者练手。压缩包共193个文件,约6.9MB,以h头文件、c源文件、o目标文件、crf编译中间文件为主,另含ioc配置、uvprojx与uvoptx工程文件、hex烧录固件及map、lst等编译产物,完整保留了从源码到可执行文件的工程结构。资源围绕单总线时序读取40位温湿度数据、I2C驱动SSD1306显示等核心环节展开,读者可据此理解HAL库外设初始化、GPIO与I2C配置、数据解析及异常处理思路,并直接编译烧录验证效果。目前已有772人学习下载,适合作为课程设计、毕业设计或物联网入门项目的参考模板。

1. 从一根数据线到屏幕数字:这套 DHT11+OLED 方案到底能跑多快

如果你手头正捏着一块 STM32 最小系统板、一个蓝色 DHT11 模块和一块 0.96 寸 OLED,却卡在“时序对不上、屏幕不亮、读出来全是 255”的阶段,那这套基于 HAL 库的 DHT11 温湿度采集加 OLED 显示方案就是冲着你来的。它解决的不是“物联网”三个字听起来那么宏大的问题,而是一个具体到引脚和微秒的落地问题:用单根 GPIO 把温湿度读回来,再通过 I2C 把数字推到屏幕上,全程不占用额外定时器资源,也不依赖外部中断。适合刚接触 HAL 库、想找一个能直接编译下载、现象肉眼可见的入门级综合练习的人。我见过太多人卡在 DHT11 的 40 位数据解析上,也见过 OLED 初始化顺序写反导致整屏无反应的翻车现场,这套东西的价值就在于把这两个坑都填了,让你把注意力放在“为什么这样写能跑通”上,而不是在玄学里反复烧录。

2. DHT11 单总线时序拆解:为什么 HAL 库下必须自己数微秒

2.1 单总线不是标准协议,别指望硬件外设替你干活

DHT11 用的是单根数据线完成双向通信,主机拉低至少 18ms 作为起始信号,然后释放总线,等待传感器响应。传感器会先拉低 80 微秒、再拉高 80 微秒表示“我在”,接着连续输出 40 位数据,每一位以 50 微秒低电平开始,高电平持续时间决定是 0 还是 1——26 到 28 微秒是高电平为 0,70 微秒左右为 1。问题来了:STM32 的 HAL 库并没有为这种自定义单总线协议提供现成外设,你只能用 GPIO 配合微秒级延时手动翻转电平。常见做法是把数据引脚配置成开漏输出加外部上拉,或者直接在推挽输出和浮空输入之间来回切换。我一般会在 GPIO 初始化时先把引脚设成输出模式并拉高,读的时候再切成输入,读完立刻切回输出,避免总线悬空时被干扰。

2.2 微秒延时用 DWT 还是定时器,选错了误差能到 30%

HAL 库自带的 HAL_Delay 最小分辨率是毫秒,根本不够用。网上流传的 for 循环空跳延时在编译器优化等级变化时直接失效,这是血泪经验。靠谱的做法有两种:一是启用 DWT 周期计数器,用 CPU 主频算微秒;二是拿一个基本定时器,预分频到 1MHz 后读计数值。我倾向于 DWT,因为它不占用任何外设,初始化就三行代码。下面这段是核心的微秒延时和引脚方向切换逻辑,直接抄进你的工程就能用:

// 启用 DWT 做微秒延时,放在 main 初始化靠前位置 CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; // 微秒级延时,注意 SystemCoreClock 要和你实际主频一致 void DHT11_DelayUs(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); } // 切换引脚方向:0 输出,1 输入 void DHT11_SetPinDir(uint8_t isInput) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = DHT11_PIN; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; GPIO_InitStruct.Mode = isInput ? GPIO_MODE_INPUT : GPIO_MODE_OUTPUT_PP; HAL_GPIO_Init(DHT11_PORT, &GPIO_InitStruct); }

逻辑说明很直白:DWT 的 CYCCNT 每个 CPU 周期加一,用主频换算出微秒对应的周期数,然后死等。参数上唯一要盯的是 SystemCoreClock,如果你用的是 HSE 倍频到 72MHz 或 168MHz,这个变量在 SystemClock_Config 之后会自动更新,但如果你在时钟配置前就调用延时,算出来的 ticks 会偏小,读出来的数据全是 0。引脚方向切换之所以要重新调用 HAL_GPIO_Init,是因为 HAL 库没有提供单独改方向的函数,只能整组重配,好在只影响一个引脚,开销可以接受。

2.3 读 40 位数据的完整流程与超时兜底

起始信号拉低 18ms 后释放,接着等传感器响应。这里必须加超时判断,否则传感器没接好或者线太长导致信号畸变时,程序会死在 while 循环里。我一般给每个等待阶段设 100 微秒超时,超时就返回错误码。40 位数据分 5 个字节:湿度整数、湿度小数、温度整数、温度小数、校验和。校验和等于前四个字节相加取低八位。下面是对应的读取函数骨架:

uint8_t DHT11_Read(float *temp, float *humi) { uint8_t buf[5] = {0}; uint8_t i, j; // 主机起始信号 DHT11_SetPinDir(0); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 至少 18ms,给 20ms 留余量 HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); DHT11_DelayUs(30); DHT11_SetPinDir(1); // 等待传感器响应低电平,超时 100us uint32_t t = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { if (++t > 100) return 1; DHT11_DelayUs(1); } t = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) { if (++t > 100) return 2; DHT11_DelayUs(1); } // 读取 40 位 for (i = 0; i < 5; i++) { for (j = 0; j < 8; j++) { t = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_RESET) { if (++t > 100) return 3; DHT11_DelayUs(1); } DHT11_DelayUs(40); // 跳过 26-28us 的 0 高电平,等 40us 后采样 buf[i] <<= 1; if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) buf[i] |= 1; t = 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) == GPIO_PIN_SET) { if (++t > 100) return 4; DHT11_DelayUs(1); } } } if (buf[4] != (uint8_t)(buf[0] + buf[1] + buf[2] + buf[3])) return 5; *humi = buf[0] + buf[1] * 0.1f; *temp = buf[2] + buf[3] * 0.1f; return 0; }

参数上唯一需要根据你的主频调整的是 DHT11_DelayUs 里的 SystemCoreClock,以及超时阈值 100 这个数。如果你用 8MHz 内部时钟跑,100 微秒超时可能太紧,建议放到 200。采样点选在 40 微秒后,是因为 0 的高电平只有 26 到 28 微秒,40 微秒时已经变低,而 1 的高电平有 70 微秒,40 微秒时还是高,这样区分最稳。校验失败返回 5,调用方看到这个码就知道是数据错了,重读一次通常能过。

3. OLED 显示驱动移植:I2C 地址和显存刷新顺序别搞反

3.1 0.96 寸 OLED 的 I2C 地址到底是 0x78 还是 0x3C

市面上常见的 0.96 寸 OLED 模块,驱动芯片多为 SSD1306,I2C 从机地址有 0x78 和 0x7A 两种写法,这是 8 位地址格式。换算成 HAL 库用的 7 位地址就是 0x3C 和 0x3D。如果你用 HAL_I2C_Master_Transmit 发数据,第二个参数要填 0x3C 左移一位后的值,也就是 0x78。很多人直接填 0x3C 发现没反应,就是这里搞混了。我一般会在初始化前先发一个空命令探测,如果 HAL_I2C_IsDeviceReady 返回 HAL_OK,说明地址对了。下面这段是 SSD1306 的初始化命令序列,按顺序发就行:

// SSD1306 初始化命令表,通过 I2C 发送 static const uint8_t ssd1306_init_cmds[] = { 0xAE, 0x20, 0x10, 0xB0, 0xC8, 0x00, 0x10, 0x40, 0x81, 0xFF, 0xA1, 0xA6, 0xA8, 0x3F, 0xA4, 0xD3, 0x00, 0xD5, 0xF0, 0xD9, 0x22, 0xDA, 0x12, 0xDB, 0x20, 0x8D, 0x14, 0xAF }; void OLED_Init(void) { HAL_Delay(100); for (uint8_t i = 0; i < sizeof(ssd1306_init_cmds); i++) { uint8_t buf[2] = {0x00, ssd1306_init_cmds[i]}; // 0x00 表示命令 HAL_I2C_Master_Transmit(&hi2c1, 0x78, buf, 2, 100); } OLED_Clear(); }

逻辑上,每个命令前要加一个控制字节 0x00 表示后面跟的是命令,写显存数据时控制字节是 0x40。参数里 0x78 就是 7 位地址 0x3C 左移一位的结果。如果你模块背面电阻跳线不同,地址可能是 0x7A,对应 7 位 0x3D,改一下就行。初始化里 0xAE 是关显示,0xAF 是开显示,中间那一串是时钟、复用率、对比度、扫描方向等配置,照抄即可,不用逐条背。

3.2 显存刷新:先写页地址再写列地址,顺序反了花屏

SSD1306 的显存按页组织,128x64 分 8 页,每页 128 列。写数据前要先设页地址和列地址,再连续写 128 个字节。顺序必须是先设页再设列,反过来会花屏或者只显示半屏。我一般把显存数组定义成 8 页乘 128 列,刷新时逐页发送。下面这个函数把整个显存刷到屏幕:

uint8_t OLED_GRAM[8][128]; void OLED_Refresh(void) { for (uint8_t page = 0; page < 8; page++) { // 设置页地址 uint8_t cmd[3] = {0x00, 0xB0 + page, 0x00}; HAL_I2C_Master_Transmit(&hi2c1, 0x78, cmd, 2, 100); cmd[1] = 0x10; // 列地址高四位 HAL_I2C_Master_Transmit(&hi2c1, 0x78, cmd, 2, 100); // 发送该页 128 字节显存,控制字节 0x40 uint8_t buf[129]; buf[0] = 0x40; for (uint8_t col = 0; col < 128; col++) buf[col + 1] = OLED_GRAM[page][col]; HAL_I2C_Master_Transmit(&hi2c1, 0x78, buf, 129, 200); } }

参数说明:0xB0 加页号是页地址命令,0x00 和 0x10 分别是列地址低四位和高四位,合起来定位到第 0 列。控制字节 0x40 告诉 SSD1306 后面是显存数据。每次传输 129 字节是因为 I2C 一帧最多发这么多,再多要分包。如果你屏幕是 128x32 的,页数改成 4 就行。刷新频率不用太高,200ms 一次肉眼看着就很流畅,太频繁反而占 I2C 带宽。

3.3 把温湿度数字画到屏幕上:字库和坐标换算

显示数字需要字库,常见做法是用 PC 端取模软件生成 8x16 或 16x16 的点阵数组。我一般把 0 到 9 和一个小数点做成一个二维数组,每个字符 16 字节,对应 8x16 像素。显示时先算出字符在显存里的页和列,再把字节按位或进去。下面是一个显示两位整数加一位小数的函数片段:

// 8x16 数字字库,每个数字 16 字节,高位在前 const uint8_t Font8x16[11][16] = { /* 0-9 和小数点的点阵数据 */ }; void OLED_ShowTemp(float temp) { uint8_t intPart = (uint8_t)temp; uint8_t decPart = (uint8_t)((temp - intPart) * 10); uint8_t x = 0; OLED_ShowChar(x, 0, intPart / 10 + '0'); OLED_ShowChar(x + 8, 0, intPart % 10 + '0'); OLED_ShowChar(x + 16, 0, '.'); OLED_ShowChar(x + 24, 0, decPart + '0'); OLED_Refresh(); }

坐标换算上,x 是列,范围 0 到 127,y 是页,范围 0 到 7。每个 8x16 字符占一页高、8 列宽,所以下一个字符 x 加 8。字库数据可以用取模软件生成,设置成“阴码、逐列式、顺向”,和上面显存数组的排列方式对上就行。如果显示出来是乱码,先检查取模软件的选项是不是和代码里的移位方向一致,这是最常见的翻车点。

4. 避坑与排查:读出来全是 255 或者屏幕不亮时先看这几条

4.1 现象:湿度温度恒定 255 或 0,校验和永远不对

原因通常是微秒延时不准。如果你用 HAL_Delay 或者空循环,编译器优化等级一变,延时直接偏几倍,采样点全错。解决方法是换 DWT 或定时器,并且确认 SystemCoreClock 的值和实际主频一致。另一个原因是引脚方向切换后没有留够释放时间,传感器还没拉低你就开始读了。我一般会在释放总线后加 30 微秒延时再切输入,给传感器反应时间。

4.2 现象:OLED 整屏不亮,但 I2C 扫描能扫到地址

先查初始化命令里 0xAF 有没有发出去,这是开显示命令,漏了就一直黑屏。再查对比度命令 0x81 后面的参数是不是 0xFF,有些模块默认对比度很低,看起来像没亮。如果都对了还不亮,拿万用表量一下 VCC 和 GND,有些模块需要 3.3V 和 5V 都接,只接一路供电不足。我遇到过一块模块背面有个电阻没焊,导致复位脚一直拉低,这种只能换模块。

4.3 现象:屏幕亮但显示花屏、半屏或者滚动

先看页地址和列地址的设置顺序,必须是先页后列。再看显存数组的列方向,SSD1306 默认列地址从 0 到 127 递增,但有些模块的扫描方向被硬件改成反向,需要在初始化里改 0xA1 和 0xC8 这两个命令。花屏还有一种可能是 I2C 速率太高,100kHz 稳,400kHz 在长杜邦线上容易出错,把时钟降下来试试。

4.4 现象:读几次之后程序卡死,看门狗复位

这是超时没加够。DHT11 在电源波动或者线太长时,响应信号可能超过 100 微秒,你的 while 循环一直等,看门狗就复位了。把每个等待阶段的超时阈值从 100 加到 200 或者 300,并且在返回错误后让调用方延时 1 秒再重试,不要连续猛读。DHT11 本身采样率就低,1Hz 最稳,读太快内部会来不及转换。

4.5 现象:温度值跳变好几度,湿度偶尔超过 100%

电源噪声是主因。DHT11 对供电敏感,和 OLED 共用一路 3.3V 时,OLED 刷新瞬间的电流波动会干扰传感器。常见做法是在 DHT11 的 VCC 和 GND 之间并一个 100nF 电容,位置尽量靠近传感器引脚。软件上可以在读之前先关 OLED 显示,读完再开,牺牲一点刷新率换稳定。如果还跳,把数据线缩短到 20 厘米以内,杜邦线换成排线。

5. 进阶技巧:把数据打包成整型传输,顺带解决浮点显示的精度问题

浮点数在单片机上显示和传输都别扭,我后来习惯把温湿度放大 10 倍存成 int16_t,比如 23.5 度存成 235,湿度 56% 存成 560。这样校验和计算、串口发送、屏幕显示全用整数,省掉浮点运算库,代码体积小一圈。显示的时候再拆成整数和小数两位,用取模和除法还原。下面这个转换和显示的组合函数,我每次做类似项目都直接复制:

// 把 DHT11 读到的浮点转成放大 10 倍的整数 int16_t temp_x10 = (int16_t)(temp * 10 + 0.5f); int16_t humi_x10 = (int16_t)(humi * 10 + 0.5f); // 显示整数部分和小数部分,避免浮点格式化 void OLED_ShowInt10(int16_t val, uint8_t x, uint8_t page) { uint8_t neg = 0; if (val < 0) { neg = 1; val = -val; } uint8_t intPart = val / 10; uint8_t decPart = val % 10; if (neg) OLED_ShowChar(x, page, '-'); OLED_ShowChar(x + 8, page, intPart / 10 + '0'); OLED_ShowChar(x + 16, page, intPart % 10 + '0'); OLED_ShowChar(x + 24, page, '.'); OLED_ShowChar(x + 32, page, decPart + '0'); }

加 0.5 再取整是为了四舍五入,不然 23.49 会显示成 23.4,看着别扭。负温度处理也简单,先记符号再取绝对值。这套整数方案还有一个好处:如果你后面要加串口上传或者无线模块,直接发两个 int16_t 就行,不用再折腾浮点字节序。我一般会在主循环里每 2 秒读一次传感器,刷新一次屏幕,同时把整数通过串口打印出来,方便对照。从那以后我每次做带传感器的显示项目,都强制先把数据转成整数再进显示层,浮点只在最外层算一次,省得后面到处改。希望帮到你。

本文还有配套的精品资源,点击获取

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

MASS框架解析:递归自监督驱动的多Agent协同演化

1. 项目概述&#xff1a;这不是又一个“多Agent炒作”&#xff0c;而是自监督范式的一次实质性跃迁最近刷到“RSI新突破”这个说法&#xff0c;不少技术社区里都在传UC Berkeley和Sakana AI联合发布的MASS框架。先说结论&#xff1a;它不是把现有Agent堆叠再包装一遍的营销话术…

作者头像 李华
网站建设 2026/10/16 4:15:20

用Python读取Abaqus ODB:后处理自动化入门

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

作者头像 李华
网站建设 2026/10/16 4:15:15

ISO26262:2018与2011版核心差异实战解析

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

作者头像 李华
网站建设 2026/10/16 4:12:55

Context-Mode详解:从环境变量到上下文窗口的状态管理实践

1. 从一次多任务切换的崩溃讲起&#xff1a;context-mode 到底在解决什么问题上个月我在做一个跨平台小工具&#xff0c;前后开了六个项目窗口&#xff1a;前端代码、后端接口临时文件、数据库查询记录、还有三份不同需求的调研笔记。为了赶进度&#xff0c;我每隔十几分钟就要…

作者头像 李华
网站建设 2026/10/16 4:12:31

OpenCode 安装配置完全指南:从零搭建终端 AI 编程助手实战

1. 为什么需要一份靠谱的安装配置指南写这篇文章的直接原因&#xff0c;是我最近折腾 OpenCode&#xff08;一个跑在终端里的开源 AI 编程助手&#xff09;时踩了一堆坑。这东西定位很有意思&#xff1a;它不算完全对标某类闭源的 AI 编程工具&#xff0c;而是把“终端 编辑器…

作者头像 李华