ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32+DHT11+OLED温湿度计:单总线时序与HAL库微秒延时的工程实践

STM32+DHT11+OLED温湿度计:单总线时序与HAL库微秒延时的工程实践 简介面向STM32初学者的综合实践资源围绕STM32F103C8T6最小系统板通过IIC协议读取DHT11温湿度数据并在0.96寸OLED屏幕上实时显示覆盖GPIO配置、IIC时序、传感器数据解析与OLED驱动等嵌入式基础技能。压缩包共227个文件约8.33MB以C源码与头文件、Keil工程文件、编译输出文件及工程清理脚本为主目录按用户代码、输出文件、文档、库文件等模块划分便于快速定位与按需学习。已有1523人学习下载资源内含可直接导入的Keil工程、标准外设库与OLED驱动完整展示从DHT11读取、数据校验到屏幕显示的流程可帮助理解IIC通信协议以及STM32CubeMXKeil的开发方式。完成学习后可掌握温湿度采集与OLED显示的综合调试方法为后续接入更多I2C/SPI传感器打下扎实基础。 玩单片机这几年STM32F103C8T6基本算是绕不开的一块板子。便宜、资料多、耐折腾搭配个DHT11温湿度传感器和一块OLED屏可以说是新手入门最经典的一套组合了。这小项目看着简单——读个温湿度数据扔到屏幕上显示就完了但真自己动手写一遍里面有好多细节值得掰扯掰扯比如DHT11的单总线时序、HAL库的微秒级延时问题、OLED的I2C地址确认还有中文字模怎么处理。这篇文章就把我整个调试过程完整记录下来从硬件接线到CubeMX配置从协议分析到代码实现再到最后踩过的坑一次性说清楚。1. 项目整体设计与方案选型思路1.1 这套组合为什么经典STM32F103C8T6是一颗基于ARM Cortex-M3内核的MCU主频72MHzFlash 64KBSRAM 20KB做温湿度采集显示这种任务绰绰有余。这颗芯片最吸引人的地方在于性价比高国产替代型号也很多市面上买一块最小系统板也就几块钱到十几块钱被很多人当成从51单片机过渡到ARM的跳板。DHT11是广州奥松的温湿度传感器单总线通信只能读不能写测温范围0到50摄氏度湿度范围20%到90%RH精度分别是正负2摄氏度和正负5%RH。这个精度在工业级数据采集上确实不够看但做环境监测、智能家居、大棚监控这种民用场景完全够用而且一颗传感器加一个10K上拉电阻就搞定所有事比I2C接口的SHT30、AHT20这些还要省IO口。OLED屏选I2C接口的0.96寸SSD1306方案128x64分辨率驱动芯片内置显存I2C地址默认是0x3C或者0x7A。选OLED而不是LCD1602主要是OLED自发光、对比度高、可视角度大而且不用背光静态显示功耗也就几十毫瓦特别适合这种小系统板供电的场合。1.2 选型中几个值得想清楚的取舍第一点是开发方式到底用标准外设库还是HAL库。标准库代码执行效率高、结构直观但现在ST官方已经把主推方向切到HAL库CubeMX生成的工程代码可读性好移植性也强。我这篇用的就是HAL库加CubeMX好在DHT11这种东西协议简单HAL库封装带来的性能损失根本感觉不出来。第二点是I2C用硬件还是软件模拟。STM32F103C8T6的硬件I2C模块是出了名的难用——当然这个难用更多是指网上吐槽的人多真上手其实问题不大。但稳妥起见这个项目我用的是软件模拟I2C两个GPIO口拿来当输出引脚用代码完全可控出问题也好排查。OLED的采样速率对I2C时钟要求不高软件模拟I2C跑100KHz稳稳的。第三点是DHT11的数据引脚选哪几个GPIO。这里有一点值得注意DHT11的数据线必须是双向的。在主机发送起始信号时引脚配置为推挽输出在读取从机响应和数据时引脚要切换成上拉输入。所以GPIO初始化的时候要预留好方向切换的接口这个细节新手特别容易遗漏。2. DHT11通信协议与核心细节解析2.1 单总线协议的基本时序DHT11用的是单总线协议主机和传感器之间通过一根数据线通信。数据线默认状态是高电平通信前主机先把总线拉低这个拉低的时间必须大于18毫秒DHT11才认为这是有效的起始信号。主机拉低结束之后释放总线总线被上拉电阻拉回高电平。这时候DHT11会做出响应分两步走先把总线拉低80微秒再拉高80微秒。如果主机在释放总线后的20到40微秒内检测到了这个低电平就说明传感器在线且就绪。接下来就是数据阶段的40个bit。每一位数据都是以50微秒的低电平开始的这个低电平是固定的区别在后续高电平的持续时间。先低后高高电平持续26到28微秒表示逻辑0持续70微秒表示逻辑1实测下来可以粗略按大于35微秒算1小于30微秒算0。采样方法就是在低电平结束之后等待30微秒左右再读取一次引脚状态读到高就是1读到低就是0。这个时序看起来简单但实现的难点在延时精度。HAL库的HAL_Delay是毫秒级的而DHT11需要的是微秒级延时如果用HAL_Delay读DHT11时间片根本对不上读出来的数据基本全是乱的。解决办法后面我会详细写。2.2 微秒级延时的实现方案微秒延时是DHT11驱动的核心问题常用的方案有三类一个是定时器延时比如配置一个基本定时器把定时周期设成1微秒读寄存器来判断是否到期另一个是空循环延时就是让CPU执行几个空指令耗时间还有个更好用的方式是DWT延时利用Cortex-M3内核的Data Watchpoint and Trace单元里面有个CYCCNT寄存器专门做周期计数。我推荐直接用DWT代码简短精度高不占用定时器资源。核心思路是把CYCCNT计数器清零然后不断读当前值直到它累计的周期数超过了目标微秒数对应的周期数。72MHz主频下每微秒正好72个周期。void DWT_Delay_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } void DWT_Delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * 72; while ((DWT-CYCCNT - start) ticks); }这段代码注意一个细节DWT_Delay_us里的循环条件是(DWT-CYCCNT - start)的差而不是比较大小因为CYCCNT是32位计数器数值大了会回卷但差值计算永远不会出错。我之前见过有人直接写DWT-CYCCNT start ticks主频高没问题长时间跑就有概率出bug。2.3 通信数据格式与校验逻辑DHT11一次完整通信会向主机发送40位数据即5个字节。数据格式排列如下湿度整数部分、湿度小数部分、温度整数部分、温度小数部分、校验字节。DHT11的整数和小数都有特殊含义湿度小数和温度小数通常读出为0表示没数据实际精度由整数部分体现。校验的逻辑是前四个字节相加得到的低8位必须等于第五个字节。这个校验一定要做我在调试的时候遇到过数据线接触不良导致某个bit读错的场景没有校验的话屏幕上的湿度会瞬间跳到一个离谱的值有了校验至少能及时发现数据废了选择不更新显示而不是把错误数据展示出来。DHT11数据引脚上最好外接一个10K左右的上拉电阻因为传感器内部是漏极开路输出只能拉低总线高电平全靠外部电阻拉上去。如果直接用推挽输出模式去怼这个引脚通信时序会非常微妙地跑偏调试半天都不知道问题在哪。3. 实操过程从CubeMX配置到显示代码3.1 CubeMX工程初始化配置硬件接线这一步按下面这个表来C8T6最小系统板的3.3V供电能力足够驱动DHT11和0.96寸OLED不需要额外外接电源。模块引脚说明DHT11 DATAPA0双向数据线外接10K上拉到3.3VDHT11 VCC3.3V供电电压范围3.3到5VDHT11 GNDGND接地OLED SCLPB6软件模拟I2C时钟线OLED SDAPB7软件模拟I2C数据线OLED VCC3.3V供电打开STM32CubeMX芯片型号选STM32F103C8T6在Pinout视图里把PA0设置为GPIO_OutputPB6和PB7也设置为GPIO_Output同时RCC选择外部晶振SYS的Debug选择Serial Wire。时钟树配置成最高72MHz主频这个必须改默认的8MHz会影响后面所有微秒级延时计算。GPIO的初始输出等级都设成高这样上电瞬间总线上所有设备都处于空闲状态避免DHT11误判起始信号。PA0初始化完成后再在代码里动态切换输入输出模式CubeMX这里只负责上电状态正确。3.2 DHT11驱动模块的编写DHT11驱动代码的整体流程分成三步拉低总线触发传感器、读取40位响应数据、数据校验并转换成浮点数温湿度值。每次通信完成后总线回到空闲高电平两次读取之间至少间隔1秒DHT11的采样频率上限就是1Hz连续快速读只会拿到上次缓存的数据。读取数据的核心函数如下uint8_t DHT11_ReadByte(void) { uint8_t val 0; for (uint8_t i 0; i 8; i) { while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_RESET); DWT_Delay_us(40); if (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_SET) { val | (uint8_t)(0x80 i); } while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_SET); } return val; }这段代码有个潜在的坑while等待高电平变低的循环是死等万一DHT11没响应或者数据线断开了程序会卡死在这里。合理的做法是给每个等待循环加一个超时计数比如设置一个200微秒的超时值超时了就返回失败标志主逻辑直接把这次采集作废而不是让整个系统hang住。这里再贴一段完整的读取函数把超时考虑进去uint8_t DHT11_ReadData(float *temp, float *humi) { uint8_t buf[5] {0}; HAL_GPIO_WritePin(DHT11_GPIO_Port, DHT11_Pin, GPIO_PIN_RESET); DWT_Delay_us(20000); HAL_GPIO_WritePin(DHT11_GPIO_Port, DHT11_Pin, GPIO_PIN_SET); DWT_Delay_us(30); GPIO_InitTypeDef gpio {0}; gpio.Pin DHT11_Pin; gpio.Mode GPIO_MODE_INPUT; gpio.Pull GPIO_PULLUP; HAL_GPIO_Init(DHT11_GPIO_Port, gpio); if (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_RESET) { uint16_t timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_RESET) { if (timeout 200) return 1; DWT_Delay_us(1); } timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_Port, DHT11_Pin) GPIO_PIN_SET) { if (timeout 200) return 1; DWT_Delay_us(1); } for (uint8_t i 0; i 5; i) { buf[i] DHT11_ReadByte(); } } gpio.Mode GPIO_MODE_OUTPUT_PP; HAL_GPIO_Init(DHT11_GPIO_Port, gpio); HAL_GPIO_WritePin(DHT11_GPIO_Port, DHT11_Pin, GPIO_PIN_SET); if ((buf[0] buf[1] buf[2] buf[3]) ! buf[4]) return 2; *humi (float)buf[0] (float)buf[1] / 10.0f; *temp (float)buf[2] (float)buf[3] / 10.0f; return 0; }3.3 OLED显示与汉字的处理OLED模块我用的软件模拟I2C底层驱动就三个函数I2C起始信号、I2C停止信号、I2C发送一个字节。SSD1306这个驱动芯片有个特点写命令和写数据分两条路径通过控制字节区分一个字节是0x00表示后续是命令序列0x40表示后续是显示数据。初始化的时候要发送一串初始化命令配置显示时钟、复用比、偏移、起始行、寻址模式这些寄存器配置不对的话屏幕要么不亮、要么显示错位、要么亮度异常。字形数据是很多人第一次接触OLED时头大的地方。SSD1306的显存是逐列的128x64被分成8页一页8个像素高度。中文字模取模的时候要用纵向取模、低位在前的方式和英文字模的取模方式不一样。我在代码里用的汉字取模数据就是标准16x16点阵每个汉字占32个字节具体取模可以用PCtoLCD2002这个软件字模选项选择阴码、逐行式、顺向、C51格式就能直接生成可用的数组。显示温湿度值我习惯先用sprintf格式化字符串再调用显示函数。这里要注意MDK或者GCC的printf重定向问题标准库的sprintf会把浮点数转成字符串但有些优化选项下浮点支持没开编译出来的代码直接乱码。我一般单独写一个浮点转字符串的小工具函数整数部分和小数部分分别处理稳当可靠。void OLED_ShowTempHumi(float temp, float humi) { char str[20]; int temp_int (int)temp; int temp_dec (int)((temp - temp_int) * 10); if (temp_dec 0) temp_dec -temp_dec; OLED_ShowString(0, 0, Temperature:); sprintf(str, %d.%d C, temp_int, temp_dec); OLED_ShowString(0, 2, str); OLED_ShowString(0, 4, Humidity:); sprintf(str, %d.%d %%RH, (int)humi, (int)((humi - (int)humi) * 10)); OLED_ShowString(0, 6, str); }4. 常见问题与排查技巧实录4.1 高频排查表我调试这个项目前前后后碰到的坑整理成了一张表基本覆盖了大部分新手会遇到的问题现象可能原因排查方法OLED完全不亮I2C地址错误、接线反了、模块供电不足先用I2C扫描程序确认地址检查VCC和GND是否接反OLED亮但白屏无字符初始化时序不对、显存没清空确认初始化命令是否完整调用清屏函数后再显示DHT11一直返回校验失败上拉电阻没接、GPIO模式切换有问题确认有没有10K上拉检查读bit函数的高电平采样时机温度和湿度显示为0DHT11没响应起始信号拉低时间是否大于18ms数据引脚是否误配成复用功能数据偶尔跳变线材太长、供电纹波大、EMI干扰缩短杜邦线DHT11和板子之间尽量靠近电源加100nF去耦电容程序卡死在读取DHT11while死等循环没有超时保护按前面代码加超时计数确保数据线异常时能安全退出4.2 独家避坑经验先说说OLED上电就亮的问题。OLED模块上电默认是亮屏状态因为SSD1306的寄存器默认值是显示的如果你看到上电就白亮不要慌这是正常的初始化完成后你自己控制显示内容就行。再说说DHT11的采样频率。DHT11手册写的采样周期是1秒但实际使用中我建议采集间隔放到2秒以上因为DHT11内部有个RC振荡电路连续采集中间间隔太短传感器还没来得及完成下一次测量返回的数据还是上一次的缓存。如果我主循环里加个OLED全屏刷新一秒钟刷好几十次DHT11的读数和OLED刷新互相干扰最后就改成1秒读一次DHT11、1秒刷一次OLED稳定得很。还有一个很深但很有意思的坑DHT11的供电电压范围是3.3到5V3.3V下通信时序会略微变化高电平持续时间比5V供电时短一点。如果直接用3.3V供电读bit的采样延时可以适当缩短比如40微秒的采样窗口改到35微秒更稳。这个小细节我是用逻辑分析仪抓波形时才发现的。5. 几个有效的调试技巧代码写完了不代表功能就对尤其是这种带时序协议的传感器眼睛看代码根本看不出问题在哪。我给新手几个实用的调试技巧第一逻辑分析仪或者示波器直接抓DHT11数据线的波形。不用买太贵的某宝上几十块钱的24MHz采样率逻辑分析仪就够用配合PulseView软件能直接解出单总线数据。抓一次波形你就能从图上清清楚楚看到起始信号、响应信号、每一个bit的高电平时长对比DHT11时序手册问题一目了然。第二串口打印调试信息。OLED只有在功能全部正常的时候才有意义中间调试阶段我都是把DHT11的原始缓冲区数据、校验值、解析结果通过串口发到电脑上看。串口打印还有个好处是不会占用OLED的I2C总线影响显示时序。第三先用模拟I2C扫一遍总线上实际挂载的设备地址。有些OLED模块用的SSD1306地址是0x3C有些是0x7A还有极少数是0x3D。扫描代码几行就能搞定但能省去你拿着数据手册对着查地址的半天时间。整个项目跑下来给我最大的感受其实是两个字——时序。DHT11这个传感器看起来协议简单但对微秒级延时的要求恰恰是最考验人的地方。HAL库封装得好上手快但底层延时机制不搞清楚遇到时序问题就两眼一抹黑。把DWT延时、GPIO方向切换、超时保护这套东西理解透了再去看其他单总线设备什么DS18B20、红外遥控都是一通百通的事。如果你也想做这个项目我的建议是分三步走第一步先点亮OLED并显示静态字符串第二步单独写DHT11驱动并用串口调试第三步再把两者合到一起。这三步每走完一步都是可以验证的完整成果出了问题也知道该往哪个模块排查。我见过太多人一步到位写完所有代码然后整个程序黑屏或者卡死最后连在哪查错都找不到方向。项目本身不难难的是养成一个良好的调试习惯。希望这篇文章能帮你少踩几个坑。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进