
简介这是一份基于STM32F103C8T6与DHT11温湿度传感器的完整嵌入式工程代码面向正在学习STM32标准库、GPIO与单总线通信的电子类学生或开发人员可用于快速掌握DHT11的驱动编写与数据解析思路。压缩包内共有138个文件其中包含33个H头文件与32个C源文件覆盖stm32f10x系列标准外设库、定时器、串口、GPIO配置等模块另有Keil工程配置文件、链接脚本、编译生成的axf/hex以及一键清理脚本整体大小约2.57MB目录结构完整可直接打开工程查看或烧录验证。目前已有4930人学习下载。通过阅读代码可明确DHT11的单总线时序、启动信号发送、40位数据脉冲读取与校验过程并能借助串口发送函数将温湿度实时打印出来为后续接入OLED显示或物联网云平台提供可复用基础。1. 项目整体设计与思路拆解1.1 这个经典组合为什么经久不衰先说说STM32F103C8T6这块芯片。Cortex-M3内核主频最高72MHz64KB Flash、20KB SRAMLQFP48封装。这个配置放在今天看算不上豪华但它的定位极其精准入门学习、小项目原型验证、成本敏感型量产方案它都能覆盖。加上“最小系统板”这种形态的出现十几块钱就能拿到一块板子焊接排针插面包板就能干活直接拉低了嵌入式开发的门槛。而DHT11这颗温湿度传感器单总线协议一根数据线就能完成双向通信数字信号输出不需要ADC采样数据手册上明确写着湿度测量范围20%-90%RH、精度±5%RH温度范围0-50℃、精度±2℃采样周期1秒。从参数上看它确实“不够高级”但架不住它便宜、稳定、协议简单——一颗DHT11只要几块钱而且几乎不会烧毁特别适合用来理解“时序通信”这个概念。这个项目解决了什么问题呢说白了就是一件事让STM32通过一根GPIO引脚按照DHT11规定的时序把温湿度数据读出来。这个看似简单的过程背后涉及GPIO模式切换、微秒级延时控制、时序竞争、校验和验证等好几个嵌入式核心技能点。学会了DHT11后面再去玩DS18B20、单总线器件、甚至自己定义一个简单通信协议思路都是相通的。所以这篇内容适合嵌入式入门者、正在做课设的在校生、以及想快速搭建环境监测节点的创客。1.2 方案选型要提前想明白的问题我见过不少新手一上来就纠结“为什么不用SHT30或AHT20”。我的观点很简单每个传感器都有自己的定位。SHT30用I2C接口数据更精准但引脚多两根SCL和SDA协议复杂度也更高AHT20也是I2C性能和DHT11不是一个量级。但如果你的目标处理器是STM32F103C8T6这种入门级MCU第一优先级是“跑通整个流程”那DHT11的单总线协议反而是最好的教学素材——因为它逼着你学会精确控制GPIO输出时序、学会用延时函数丈量时间而不是依赖硬件I2C外设把一切都“自动”搞定。另外还要考虑实际应用场景。如果是要做精确的农业大棚监测DHT11确实不够用但如果是做一个桌面小气象站、智能家居温湿度采集节点DHT11完全能胜任。选型逻辑从来不是“越贵越好”而是“够用、稳定、成本合理”。STM32F103C8T6 DHT11这个组合恰好就是低成本温湿度采集方案里最经典也最耐打的一个。2. 硬件准备与开发环境配置2.1 硬件清单与引脚规划先说需要准备的硬件非常简单STM32F103C8T6最小系统板一块DHT11传感器模块一个推荐买模块自带上拉电阻和滤波电容省事很多面包板一块、杜邦线若干ST-Link V2下载器一个或USB转TTL串口模块用于查看调试输出电脑上装好Keil MDK或STM32CubeIDE配合STM32CubeMX生成初始化代码关于引脚规划DHT11只需要一个GPIO引脚作为数据线。我习惯把它接到PA0上原因很朴素PA0靠近板子的丝印标识接线不容易看错。你也可以用PB0、PB1、PC13等任意普通GPIO只要代码里的宏定义跟着改就行。具体的接线方式见下表DHT11模块引脚STM32F103C8T6引脚说明VCC电源正3.3V板上引脚供电范围3.3V~5V建议先接3.3V跟IO电平匹配GND电源负GND共地是必须的DATA数据PA0数据线需要外接4.7kΩ~10kΩ上拉电阻到VCC需要注意的是如果你买的是单独的DHT11裸传感器四根引脚塑料封装带透气孔的那种片内没有上拉电阻必须在DATA线上加一个4.7kΩ到10kΩ的上拉电阻到VCC。如果你买的是模块小板子带三个引脚上面通常有标注大多数模块已经集成好了4.7kΩ上拉电阻直接接线即可。这个问题不处理好会直接导致后面读取数据时电平漂移时序完全乱掉。2.2 CubeMX工程初始化要点我用STM32CubeMX来生成工程版本不限选芯片型号时搜索“STM32F103C8Tx”即可。有几个配置项是必须注意的第一SYS选项卡里的Debug一定要选择Serial Wire否则程序第一次下载进去之后第二次就下不进去了只能通过Bootloader跳线或串口ISP方式擦除Flash才能恢复。这个坑几乎是STM32新手必踩的我早期就吃过这个亏下载器报错“No target connected”排查了半天才发现是SWD引脚被禁用掉了。第二时钟树配置。STM32F103C8T6默认使用内部HSI 8MHz时钟但CubeMX会推荐你配置HSE外部晶振。市面上的最小系统板一般焊了8MHz晶振在RCC选项卡里选Crystal/Ceramic Resonator然后时钟树配置界面里把HCLK设置到72MHzCubeMX会自动计算PLL倍频系数。如果你用的板子没有外部晶振那就选内部时钟但串口波特率和DWT微秒延时的准确性会差一些建议还是用带晶振的板子。第三GPIO配置。在Pinout视图里点击PA0选择GPIO_Output这里先设置成推挽输出因为DHT11通信的起始信号需要主机拉低数据线初始电平设为High代表空闲状态下数据线被上拉到高电平。这个“空闲高电平”的概念在后面理解单总线协议时会非常关键。第四串口配置。为了调试方便我把USART1的TXPA9和RXPA10启用模式选择Asynchronous波特率设1152008位数据无校验1位停止位——这就是最常用的115200-8-N-1配置。后面会把温湿度数据通过串口打印到电脑上用串口助手查看结果验证读取是否成功。2.3 从标准库到HAL库的迁移思考很多网上教程还在用标准库写DHT11驱动老代码喜欢直接操作寄存器GPIOA-CRL、GPIOA-ODR代码短但可读性差。在HAL库环境下标准库的GPIO配置宏全部不可用需要重新组织。不过HAL库有一个好处GPIO模式的切换封装成了简单的结构体配置函数逻辑更清晰。后面在写代码的时候我会把标准库和HAL库的对应关系顺便点一下方便你读老教程时能对上号。3. DHT11单总线协议深度剖析3.1 单总线通信的本质要驱动DHT11必须先把它的通信协议吃透。DHT11用的是单总线协议物理层只有一根数据线主机和从机都挂在线上通过控制引脚输出和输入状态的切换来完成双向通信。这根线平时处于“空闲状态”由上拉电阻拉到高电平。所有通信动作都表现为高电平与低电平的交替切换时序严格要求到微秒级别。这里用生活化的方式打个比方想象一根电话线通话双方约定好了“先响三声、对方接起、然后按一长一短的节奏说话”。DHT11的通信就是这么规定的——主机先“打电话”发出起始信号DHT11“接听”响应信号然后按固定节奏把40位数据逐个“说”出来。整个过程中节奏时序比内容本身更重要因为每一位数据的含义是靠高低电平持续的时间长度来区分的。3.2 完整通信时序拆解整个通信过程分三个阶段起始信号、响应信号、数据输出。第一阶段主机拉低数据线至少18毫秒然后释放这是告诉DHT11“我要开始读你了”。为什么非得18毫秒因为DHT11的内部是低速RC振荡器需要一个足够长的低电平来触发它的唤醒逻辑。实测下来20毫秒比较保险超过30毫秒也没问题。第二阶段DHT11检测到起始信号后会先拉低数据线约80微秒然后拉高约80微秒这是一个“应答脉冲”意思是“我准备好了”。主机在这段时间要做的就是把GPIO切换成输入模式然后开始监测电平变化。第三阶段DHT11连续输出40位数据。每一位的格式都相同先拉低约50微秒然后拉高高电平持续的时间长短决定这一位是0还是1。数据手册上规定高电平持续26~28微秒表示数据位“0”持续70微秒表示数据位“1”。实际测量中DHT11输出的0位高电平典型值在27微秒左右1位高电平在70微秒左右容差范围比较大这给代码实现留了不少余地。3.3 数据格式与校验原理DHT11输出的40位数据具体组成是8位湿度整数数据 8位湿度小数数据 8位温度整数数据 8位温度小数数据 8位校验和。对于这种只传整数的低端传感器小数位通常读出来是0所以实际有效数据就是湿度和温度的整数部分。这里很多人有个误区觉得40位数据很难处理其实就是把8位作为一个字节连续读5个字节。第1字节是湿度整数第2字节是湿度小数第3字节是温度整数第4字节是温度小数第5字节是校验和。校验规则很简单前4个字节相加取低8位如果和第5字节相等说明这次通信没被干扰数据有效。这个校验思路非常经典在很多通信协议里都能看到“累加和校验”的变体。4. 核心代码实现HAL库实战4.1 微秒级延时方案HAL库自带的HAL_Delay只能精确到毫秒级而DHT11的时序要求是微秒级所以必须自己实现一个微秒延时。常见方案有两种一是用SysTick定时器重写延时函数二是用DWTData Watchpoint and Trace模块。我推荐DWT方式因为实现起来最简洁不干扰SysTick也不会影响HAL_Delay的正常工作。// dwt_delay.c #include dwt_delay.h void DWT_Delay_Init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNT_EN_Msk; } void DWT_Delay_us(uint32_t us) { DWT-CYCCNT 0; while (DWT-CYCCNT us * (SystemCoreClock / 1000000U)); }原理很直观DWT-CYCCNT是CPU周期计数器72MHz主频下1微秒就是72个周期。先把计数器清零然后循环等待计数到us乘以每微秒周期数。为什么选DWT而不是简单空循环因为C编译器在优化时会“自作聪明”地删掉没有副作用的空循环代码而DWT计数循环依赖硬件寄存器状态不容易被优化掉。4.2 GPIO模式切换DHT11通信过程中GPIO引脚需要在推挽输出和上拉输入之间来回切换。在HAL库中每次切换都要调用HAL_GPIO_Init重新配置GPIO结构体。频繁调用确实有轻微的延时开销但DHT11的时序容差较大约1~2微秒的开销不影响结果我实测下来很稳。#define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_0 void DHT11_Pin_Mode_Output(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); } void DHT11_Pin_Mode_Input(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); }GPIO速度为什么要选LOW不是HIGH这里有个经验点DHT11的单总线通常工作在低速状态推挽输出的翻转速率没必要追求极速。如果设置成HIGH反而容易在长线上产生振铃干扰影响调试。嵌入式开发里有一个很重要的原则——够用就好不要盲目堆高速配置。4.3 DHT11驱动完整实现下面是完整的DHT11读取函数包含起始信号、响应检测、40位数据读取和校验。这个代码我整理过很多次可读性和稳定性折中得比较好。// dht11.c #include dht11.h uint8_t DHT11_Data[5] {0, 0, 0, 0, 0}; void DHT11_Pin_Mode_Output(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); } void DHT11_Pin_Mode_Input(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); } uint8_t DHT11_ReadData(void) { uint8_t i, j; uint8_t temp 0; // 主机起始信号 DHT11_Pin_Mode_Output(); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 拉低至少18ms HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); DWT_Delay_us(30); // 释放后等待30us让DHT11感知到上升沿 // 切换输入模式 DHT11_Pin_Mode_Input(); // 检测响应信号先低后高各80us if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { return 0; // 数据线仍为高DHT11没响应 } while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_RESET); // 等待80us低电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET); // 等待80us高电平结束 // 读取40位数据 for (j 0; j 5; j) { temp 0; for (i 0; i 8; i) { while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_RESET); // 等待50us低电平结束 DWT_Delay_us(40); // 高电平持续40us后采样 if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { temp | (0x80 i); // 高电平仍为高 → 数据位1 } while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET); // 等待高电平结束 } DHT11_Data[j] temp; } // 校验和验证 if (DHT11_Data[4] (DHT11_Data[0] DHT11_Data[1] DHT11_Data[2] DHT11_Data[3])) { return 1; // 校验通过 } return 0; }这段代码里最关键的判断逻辑在数据位读取部分。每一位的50微秒低电平结束后我用DWT_Delay_us(40)先等40微秒然后再采样电平。如果此时数据线还是高电平说明这一位的高电平持续时间在40微秒以上那必然是数据位“1”因为“0”的高电平只有26~28微秒等40微秒后早就变回低电平了。这种先延时再采样的做法规避了精确测量脉冲宽度的复杂度是处理低速单总线设备时的常用技巧。4.4 主程序与串口输出读取逻辑写好后主程序就很简单了。把温湿度数据通过串口打印出来核心代码如下// main.c 片段 #include main.h #include usart.h #include gpio.h #include dht11.h #include dwt_delay.h #include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); DWT_Delay_Init(); while (1) { if (DHT11_ReadData() 1) { printf(Humidity: %d.%d%%RH, Temperature: %d.%dC\r\n, DHT11_Data[0], DHT11_Data[1], DHT11_Data[2], DHT11_Data[3]); } else { printf(DHT11 read failed\r\n); } HAL_Delay(1000); // DHT11采样周期1秒读取间隔至少大于1秒 } }注意读取间隔必须大于1秒。DHT11数据手册里明确写了“采样周期为1秒”也就是说传感器每秒钟最多更新一次内部数据。如果读取间隔小于1秒你读到的很可能还是上一次的数据看起来就是“数据不变”或者“偶尔读失败”。我见过不少人在这上面犯迷糊代码本身没毛病单纯是读得太勤了。关于printf重定向上面的fputc函数在ARMCC编译器下有效。如果你用的是GCC编译器比如STM32CubeIDE环境需要换成int _write(int fd, char *ptr, int len) { HAL_UART_Transmit(huart1, (uint8_t *)ptr, len, 0xFFFF); return len; }我用表格把两种编译器的差异列出来方便你对照项目ARMCCKeil MDKGCCSTM32CubeIDE重定向函数int fputc(int ch, FILE *f)int _write(int fd, char *ptr, int len)需要包含头文件stdio.hsys/unistd.hMicroLIB需要在魔术棒里勾选Use MicroLIB无需额外设置5. 常见问题与排查技巧实录5.1 问题速查表我把这几年实操中遇到的高频问题整理成了一个表格因为DHT11是低速器件很多问题的根源都很类似排查起来有迹可循。现象可能原因解决办法读到的数据全是0引脚没有上拉DHT11无法拉高电平在DATA线上加4.7kΩ~10kΩ上拉电阻到VCC一直返回read failed起始信号低电平时间不够DHT11没被唤醒HAL_Delay(20)确保低电平维持20ms以上数据校验和不通过时序采样点偏移或线上干扰检查DWT延时准确性SystemCoreClock是否等于72MHz缩短杜邦线长度温湿度读数不变读取频率大于1秒/次读到的是缓存数据读取间隔至少保证1秒用HAL_Delay(1000)板子下载不了程序CubeMX里Debug没有选Serial WireBoot0跳线拉高后再擦除或短接NRST复位引脚触发下载偶尔读出来的温湿度跳变杜邦线接触不良或电源纹波大检查面包板插孔连接尝试VCC接5VDHT11模块支持3.3V~5V数据位识别错误输入模式没有配置上拉引脚浮空GPIO_InitStruct.Pull设为GPIO_PULLUP5.2 时序采样点的实战心得关于那个40微秒的采样延时我还想多说两句。DHT11数据位时序的判定核心是高电平宽度理论上只要在两个时间点做判断就能区分0和1高电平持续超过40微秒就按1处理否则按0处理。为什么我在代码里用的是“延时40微秒再采样”而不是“循环测量高电平宽度直到变低然后用计数器判断”因为第一种方式更稳定耗时固定逻辑简单。第二种方式虽然也能用但在中断密集或系统负载高的环境中宽度的测量值容易受干扰。实测下来GPIO切换大约消耗1~2微秒DWT_Delay_us函数本身也有几纳秒的误差这些在DHT11宽裕的容差范围内完全不是问题。但有两点必须提醒第一读取DHT11时不要开抢占式的中断尤其是SysTick中断因为时序的关键在于连续电平监测中间如果插入一段长中断处理很容易错过边缘跳变第二不要在多个地方同时调用DWT_Delay_usDWT-CYCCNT只有一个如果你在另一个中断里也用DWT延时并清零计数器会互相干扰。5.3 硬件排查三板斧代码逻辑没问题但就是读不出来的时候我习惯按“先看电平、再看供电、最后看连接”的顺序排查。首先是电平——用示波器或逻辑分析仪抓DATA引脚的波形起始信号有没有拉低20毫秒响应信号的两段80微秒电平看不看得到数据位的高电平宽度是27微秒还是70微秒如果连起始信号都看不到问题在主机端如果起始信号正常但没有响应信号问题在传感器端或上拉电阻。其次是供电——DHT11供电不足是很多“疑难杂症”的根源特别是面包板上同时带多个模块时VCC电压可能被拉到3V以下有条件的话用万用表实测一下芯片电源引脚对地的电压。最后是连接——检查杜邦线是不是插歪了面包板内部是不是有断路这些看似低级的问题在快速原型验证阶段反而发生频率最高。6. 进一步扩展的方向DHT11只是温度采集的一个起点跑通这个项目之后想往哪个方向发展都会顺畅很多。如果你对显示感兴趣可以接一块0.96寸OLED或1602LCD把温湿度实时显示出来这就是一个标准的桌面气象站了。如果你想做物联网可以把数据通过ESP8266或ESP01S发送到云端平台STM32作为采集节点上报温湿度这是智能家居安防系统里最常见的场景之一。如果你觉得DHT11精度不够用还可以无缝切换到DHT22AM2302两者通信协议兼容代码几乎不用改只是数据格式上温度的分辨率从1℃变成0.1℃。另外热词里提到了“国产替代”原厂STM32F103C8T6这几年价格波动较大很多项目已经切换到GD32F103C8T6等同封装兼容芯片。如果你的工程是用CubeMX生成的标准HAL工程切换到GD32时基本可以直接编译烧录这也是这套生态的一个红利——代码的硬件抽象层做得好芯片替换带来的改动量非常小。最后再分享一个小技巧如果想把DHT11的读取放到中断或定时器里建议在数据读取函数中临时关闭其他高优先级中断读完再恢复。虽然DHT11时序容差大但40微秒内的采样点如果被打断两次以上误判概率会明显上升。我这里处理了十几年单片机踩过的坑不少但DHT11确实是我见过最“皮实”的传感器之一——只要你给的时序不离谱它基本都会老实响应。跑通这个项目之后你会发现所谓“驱动”其实没那么玄乎无非就是按约定的节奏把电平高低按时序走一遍而已。本文还有配套的精品资源点击获取