资讯动态

STM32F1驱动DHT11实战:从时序解析到串口打印的避坑指南

发布时间:2026/10/28 1:07:36 来源:尧图企业网站定制
1. 为什么STM32F1到今天还值得花时间折腾STM32F1系列具体点说就是STM32F103这颗芯片在嵌入式圈子里属于那种“你绕不过去”的存在。我接触过的项目里从学生做的温湿度采集小盒子到工厂里跑的简易PLC控制器再到各种电机驱动板STM32F103的身影到处都是。它用的是ARM Cortex-M3内核主频常见72MHz片上Flash从16KB到1MB不等SRAM从6KB到96KB封装形式覆盖LQFP48、LQFP64、LQFP100甚至BGA。这些参数放到今天看确实不算亮眼但它的外设资源极其均衡多路USART、SPI、I2C、CAN、USB、ADC、DAC、定时器几乎你能想到的嵌入式基础外设它都有。那为什么现在还要聊它因为对于绝大多数入门和中级嵌入式项目来说STM32F1的性价比和生态成熟度依然是碾压级的。你花十几块钱买一片STM32F103C8T6最小系统板插上电脑就能开始写代码标准外设库、HAL库、LL库三种开发方式任选社区里随便搜一个报错信息都能找到十年前就有人踩过的坑和解决方案。更关键的是它的资料多到令人发指——中文参考手册、固件库使用手册、各种开发板的原理图全部公开可查。这意味着你遇到问题时几乎不可能成为“第一个遇到这个问题的人”。这篇文章我打算围绕一个非常具体的场景来展开用STM32F1驱动DHT11温湿度传感器把采集到的数据通过串口打印出来同时把整个开发流程中那些“教程里不会写但实际一定会遇到”的细节全部摊开讲。DHT11这个传感器便宜、简单、单总线通信但它的时序要求极其严格微秒级的延时偏差就会导致读取失败。很多新手照着教程接线、抄代码结果串口助手上一片乱码或者干脆读不出数据然后就卡住了。我会从工程搭建、时钟配置、GPIO模式选择、微秒延时实现、DHT11时序解析、数据校验、串口重定向这几个维度把每一步的“为什么”讲清楚。适合谁看如果你刚拿到一块STM32F103开发板想从点灯进阶到实际传感器采集这篇文章就是为你写的。如果你已经用过DHT11但总是读失败我也会把常见故障的排查路径整理成表格方便你对照定位。如果你是想做温湿度监控、农业大棚采集、机房环境监测这类项目这套代码可以直接拿去改。我不打算用那种“第一步打开Keil第二步新建工程”的流水账写法而是把每个关键决策背后的逻辑讲透让你看完之后能自己判断遇到的新问题该怎么解。2. 开发环境搭建与工程结构设计2.1 工具链选型Keil、STM32CubeMX还是PlatformIO开发STM32F1工具链的选择直接决定了你后面调试的效率。我试过三种主流方案各有各的适用场景。Keil MDK是传统嵌入式开发者的首选配合ST-Link调试器断点、单步、寄存器查看都很顺手。它的优势在于编译器优化成熟生成的代码体积小而且网上绝大多数STM32F1的例程都是Keil工程直接拿来就能编译。缺点是Keil的编辑器体验一般代码补全和跳转不如现代IDE流畅而且需要手动管理工程文件结构。STM32CubeMX是ST官方推出的图形化配置工具你可以通过点选界面配置时钟树、引脚复用、外设参数然后一键生成初始化代码。对于STM32F1来说CubeMX支持生成HAL库和LL库两种代码。HAL库抽象层次高移植方便但代码效率略低LL库更接近寄存器操作执行效率高但可读性稍差。我个人的习惯是新手用CubeMX生成HAL库框架快速把外设跑通等项目稳定后如果对性能有要求再把关键部分换成LL库或直接操作寄存器。PlatformIO是近几年流行起来的嵌入式开发平台基于VS Code用platformio.ini文件管理工程配置。它的优势是跨平台、依赖管理清晰、支持多种框架Arduino、HAL、libopencm3等。如果你习惯用VS Code写代码PlatformIO的体验非常好。但它对STM32F1的调试支持不如Keil原生需要额外配置OpenOCD。我最终选择的是Keil MDK STM32CubeMX的组合。CubeMX负责生成时钟和GPIO初始化代码Keil负责编写业务逻辑和调试。这样既保证了初始化代码的可靠性又保留了调试的便利性。2.2 工程目录结构别把所有文件堆在根目录很多新手拿到例程后所有.c和.h文件都堆在一个文件夹里工程稍微大一点就乱成一锅粥。我建议从一开始就建立清晰的目录结构后面加外设、加功能模块时你会感谢自己。我的标准目录结构是这样的Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32f1xx_hal_conf.h │ │ └── stm32f1xx_it.h │ └── Src/ │ ├── main.c │ ├── stm32f1xx_it.c │ └── system_stm32f1xx.c ├── Drivers/ │ ├── STM32F1xx_HAL_Driver/ │ └── CMSIS/ ├── BSP/ │ ├── Inc/ │ │ ├── dht11.h │ │ ├── delay.h │ │ └── usart.h │ └── Src/ │ ├── dht11.c │ ├── delay.c │ └── usart.c └── App/ ├── Inc/ │ └── app_main.h └── Src/ └── app_main.cCore放CubeMX生成的初始化代码Drivers放HAL库和CMSIS核心文件BSP放板级支持包DHT11驱动、延时函数、串口驱动App放应用层逻辑。这样分层之后换一块板子只需要改BSP层应用层代码几乎不用动。2.3 时钟树配置72MHz不是随便设的STM32F103的最高主频是72MHz但并不是所有型号都能跑到这个频率。比如STM32F103C8T6可以但某些低功耗型号最高只有48MHz或36MHz。配置时钟树之前先确认你手上芯片的数据手册。CubeMX里配置时钟的步骤选择HSE外部高速时钟为Crystal/Ceramic Resonator然后在Clock Configuration标签页里把PLL Source选为HSEPLL Mul设为9倍频。假设你的外部晶振是8MHz那么8MHz × 9 72MHz这就是SYSCLK。然后AHB Prescaler设为1APB1 Prescaler设为2APB1最高36MHzAPB2 Prescaler设为1APB2最高72MHz。这里有个细节APB1上的外设如USART2、USART3、I2C1、SPI2时钟频率是36MHz而APB2上的外设如USART1、SPI1、ADC1时钟频率是72MHz。如果你用USART1做串口打印波特率计算时的时钟源就是72MHz如果用USART2时钟源就是36MHz。这个区别在计算波特率寄存器值时必须搞清楚否则串口输出就是乱码。注意如果你用的是STM32F103C8T6最小系统板板载晶振通常是8MHz但有些廉价板子用的是12MHz晶振。买之前一定看清楚商品描述或者拿到板子后用示波器测一下晶振引脚。晶振频率不对时钟配置全错后面所有外设都不工作。3. DHT11驱动核心细节与时序解析3.1 DHT11的单总线协议到底特殊在哪DHT11用的是单根数据线完成双向通信这根线既要输出又要输入所以GPIO的模式必须动态切换。通信过程是这样的主机STM32先拉低总线至少18毫秒然后释放总线DHT11检测到这个起始信号后会拉低总线80微秒作为响应接着拉高80微秒然后开始传输40位数据。每一位数据都以50微秒的低电平开始后面的高电平持续时间决定数据是0还是1高电平持续26到28微秒表示0持续70微秒表示1。这个时序的容错窗口非常窄。以判断0和1为例分界点大约在40微秒左右。如果你的延时函数误差超过10微秒就可能把1读成0或者把0读成1。更麻烦的是DHT11对起始信号的响应时间也有要求主机拉低总线的时间必须在18毫秒到30毫秒之间太短了传感器不响应太长了传感器可能进入异常状态。我实测下来用HAL_Delay()做毫秒级延时没问题但微秒级延时绝对不能依赖HAL库。HAL库的微秒延时通常用SysTick或者DWT实现但SysTick默认配置是1毫秒中断改成1微秒中断会严重影响系统性能。所以必须自己写一个基于DWTData Watchpoint and Trace或者定时器的微秒延时函数。3.2 微秒延时函数DWT方案与定时器方案对比DWT是Cortex-M3内核自带的一个调试组件其中有一个32位的循环计数器CYCCNT每经过一个CPU时钟周期就加1。在72MHz主频下CYCCNT每增加72个数就是1微秒。用DWT做微秒延时的代码如下// delay.c #include delay.h static uint32_t fac_us 0; void delay_init(void) { // 使能DWT外设 CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; // 计算每微秒的时钟周期数 fac_us SystemCoreClock / 1000000; } void delay_us(uint32_t us) { uint32_t start DWT-CYCCNT; uint32_t ticks us * fac_us; while ((DWT-CYCCNT - start) ticks); }这个方案的优点是精度高、不占用定时器资源、代码简单。缺点是DWT在部分低端调试器上可能不可用而且如果系统进入低功耗模式DWT计数器会停止。另一种方案是用一个通用定时器比如TIM4做微秒延时。配置定时器预分频器为71这样计数器每72个时钟周期溢出一次也就是1微秒。然后通过读取计数器的值来计算延时。这种方案不依赖调试组件但占用一个定时器资源而且中断优先级配置不当可能影响其他外设。我最终选择DWT方案因为STM32F103的DWT功能稳定而且不占用宝贵的外设资源。但要注意DWT的CYCCNT是32位的在72MHz下大约59.6秒就会溢出一次。对于DHT11这种单次通信不超过5毫秒的场景完全不用担心溢出问题。3.3 GPIO模式切换开漏输出与浮空输入的配合DHT11的数据线在通信过程中需要在输出和输入之间切换。STM32的GPIO有四种输出模式和四种输入模式选错了会导致通信失败。主机拉低总线时GPIO配置为推挽输出输出低电平。主机释放总线时如果配置为推挽输出并输出高电平那么总线会被强行拉高但DHT11在响应阶段也会拉低总线这时候如果STM32还在输出高电平就会和DHT11形成电平冲突严重时可能损坏引脚。正确的做法是主机释放总线时把GPIO切换为浮空输入模式或者上拉输入模式让总线由上拉电阻拉到高电平。DHT11拉低总线时STM32读取到低电平DHT11释放总线时上拉电阻把总线拉高STM32读取到高电平。这样就不会有电平冲突。具体代码实现// dht11.c #define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_1 // 配置为推挽输出 static void DHT11_SetOutput(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_HIGH; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); } // 配置为浮空输入 static void DHT11_SetInput(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin DHT11_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_NOPULL; HAL_GPIO_Init(DHT11_GPIO_PORT, GPIO_InitStruct); }提示DHT11的数据线必须接一个4.7kΩ到10kΩ的上拉电阻到3.3V。很多DHT11模块自带上拉电阻但如果你买的是裸传感器一定要自己加。没有上拉电阻总线释放后无法回到高电平通信必然失败。4. 完整实操流程与代码实现4.1 硬件接线与供电检查先确认硬件连接。DHT11有三个引脚有些型号是四个引脚其中一个悬空VCC、DATA、GND。VCC接3.3VGND接GNDDATA接STM32的PA1或者其他任意GPIO代码里改一下宏定义就行。供电电压很关键。DHT11的工作电压范围是3.3V到5.5V但如果你用5V供电而STM32的GPIO是3.3V电平那么DHT11输出的高电平是5V直接接到STM32引脚上可能损坏芯片。所以要么用3.3V给DHT11供电要么在数据线上加电平转换电路。我建议直接用3.3V供电简单省事。接线完成后先用万用表测一下DHT11的VCC和GND之间是不是3.3V确认供电正常。然后测一下DATA线对GND的电压应该是3.3V左右上拉电阻把总线拉高了。如果测出来是0V检查上拉电阻是否接好或者DHT11是否已经损坏。4.2 DHT11读取函数的完整实现下面是DHT11读取的完整代码我加了详细的注释说明每一步的意图// dht11.c #include dht11.h #include delay.h #define DHT11_GPIO_PORT GPIOA #define DHT11_GPIO_PIN GPIO_PIN_1 // 读取一个字节 static uint8_t DHT11_ReadByte(void) { uint8_t byte 0; for (int i 0; i 8; i) { // 等待50us低电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_RESET); // 延时40us后采样 delay_us(40); byte 1; if (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { byte | 1; } // 等待高电平结束 while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET); } return byte; } // 读取温湿度 uint8_t DHT11_ReadData(uint8_t *temp, uint8_t *humi) { uint8_t buf[5] {0}; // 主机拉低总线至少18ms DHT11_SetOutput(); HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_RESET); HAL_Delay(20); // 主机释放总线切换为输入模式 HAL_GPIO_WritePin(DHT11_GPIO_PORT, DHT11_GPIO_PIN, GPIO_PIN_SET); DHT11_SetInput(); // 等待DHT11响应低电平 uint32_t timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { if (timeout 10000) return 1; // 超时 } // 等待响应低电平结束 timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_RESET) { if (timeout 10000) return 2; } // 等待响应高电平结束 timeout 0; while (HAL_GPIO_ReadPin(DHT11_GPIO_PORT, DHT11_GPIO_PIN) GPIO_PIN_SET) { if (timeout 10000) return 3; } // 读取40位数据 for (int i 0; i 5; i) { buf[i] DHT11_ReadByte(); } // 校验前四个字节之和等于第五个字节 if (buf[4] ! (buf[0] buf[1] buf[2] buf[3])) { return 4; // 校验失败 } *humi buf[0]; *temp buf[2]; return 0; // 成功 }这段代码里有几个关键点需要展开说。第一个是超时处理。DHT11如果没接好或者损坏总线可能一直保持高电平或低电平如果没有超时退出机制程序就会死循环。我在每个等待循环里都加了timeout计数超过阈值就返回错误码。这个阈值10000是我实测出来的在72MHz主频下10000次循环大约对应几毫秒足够覆盖DHT11的正常响应时间。第二个是采样时机。DHT11的每一位数据都以50微秒低电平开始然后高电平持续26到28微秒表示0持续70微秒表示1。我在等待低电平结束后延时40微秒再采样。这个40微秒是分界点如果高电平持续26微秒40微秒后已经变成低电平读到0如果高电平持续70微秒40微秒后还是高电平读到1。这个采样点选在40微秒容错窗口大约有12微秒对于DWT延时来说完全够用。第三个是校验。DHT11的40位数据是湿度整数、湿度小数、温度整数、温度小数、校验和。校验和等于前四个字节之和的低8位。如果校验失败说明数据传输过程中出现了位错误这次读取的数据不可信应该丢弃并重新读取。4.3 串口重定向与数据打印读取到温湿度后需要把数据打印到串口助手。STM32的HAL库默认没有实现printf需要重定向fputc函数// usart.c #include usart.h #include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, HAL_MAX_DELAY); return ch; }然后在main函数里初始化USART1波特率设为1152008位数据位无校验1位停止位。主循环里每隔2秒读取一次DHT11打印格式如下// main.c while (1) { uint8_t temp, humi; uint8_t ret DHT11_ReadData(temp, humi); if (ret 0) { printf(Temperature: %d C, Humidity: %d %%\r\n, temp, humi); } else { printf(DHT11 read error: %d\r\n, ret); } HAL_Delay(2000); }注意DHT11的采样周期不能小于1秒。如果你连续快速读取传感器可能还没完成上一次测量返回的数据会是上一次的旧值或者直接失败。我建议采样间隔至少2秒这样数据稳定可靠。5. 常见问题排查与避坑经验实录5.1 读取失败问题速查表现象可能原因排查方法解决方案串口无输出串口未初始化或波特率不对检查USART配置和串口助手设置确认波特率115200TX/RX接线正确一直返回错误码1DHT11未响应起始信号测DATA线电压检查上拉电阻加上拉电阻确认DHT11供电正常一直返回错误码4校验失败数据位错误检查延时函数精度用示波器测微秒延时换DWT方案温度湿度值固定不变传感器未更新数据检查采样间隔采样间隔改为2秒以上数据偶尔正确偶尔错误时序抖动或电源噪声检查电源纹波缩短杜邦线加100nF去耦电容线长不超过20cm上电后第一次读取失败DHT11上电需要稳定时间上电后延时1秒再读取在初始化后加HAL_Delay(1000)5.2 我踩过的三个坑第一个坑是杜邦线太长。我一开始用20cm的杜邦线连接DHT11读取成功率只有50%左右。后来把线缩短到10cm以内成功率立刻提升到95%以上。原因是单总线协议对信号完整性要求高长线带来的分布电容会导致上升沿变缓DHT11可能识别不到正确的电平。如果你必须用长线建议在DATA线和GND之间加一个100pF的小电容可以改善信号质量。第二个坑是电源噪声。我用USB供电给STM32和DHT11同时USB还在给其他设备供电结果DHT11读取频繁失败。后来在DHT11的VCC和GND之间加了一个100nF的陶瓷电容问题解决。DHT11内部有振荡电路电源噪声会影响它的时序精度。这个电容在DHT11模块上通常已经焊好了但如果你买的是裸传感器一定要自己加。第三个坑是HAL_Delay的精度问题。HAL_Delay依赖SysTick中断如果中断优先级配置不当或者有其他高优先级中断频繁触发HAL_Delay的实际延时时间会偏长。DHT11的起始信号要求拉低18到30毫秒我用HAL_Delay(20)本来以为够了结果因为系统里有其他中断实际拉低时间可能超过30毫秒导致DHT11不响应。后来我改用DWT做毫秒延时问题解决。所以对于时序敏感的场合不要依赖HAL_Delay。5.3 进阶优化建议如果你想让这个项目更稳定可以考虑以下几个优化方向。第一给DHT11读取加互斥锁。如果你的系统里有多个任务或中断可能调用DHT11读取函数必须加互斥保护否则两个任务同时操作GPIO会导致时序混乱。最简单的做法是用一个全局标志位读取前检查标志位读取过程中置位读取完成后清零。第二增加数据滤波。DHT11的精度是±2°C和±5%RH单次读取的值可能有波动。你可以连续读取三次取中间值作为最终结果。这样能有效滤除偶发的异常值。第三把数据上传到上位机。串口打印只是最基础的展示方式你可以把数据通过ESP8266传到手机APP或者存到SD卡里做长期记录。STM32F1的SPI接口接SD卡模块很方便FATFS文件系统也有现成的移植方案。第四低功耗改造。如果你的项目是电池供电可以把STM32F1配置为Stop模式每隔一段时间用RTC唤醒读取一次DHT11后继续休眠。STM32F103在Stop模式下的电流大约20微安用两节AA电池可以撑好几个月。6. 从DHT11延伸到STM32F1的通用开发思路DHT11这个项目虽然小但它涵盖了STM32F1开发的几个核心技能GPIO模式切换、微秒级延时、单总线协议解析、串口通信、错误处理。把这几个点吃透之后你再去看其他单总线设备比如DS18B20温度传感器或者I2C设备比如OLED屏幕会发现套路是相通的。我个人的经验是学STM32F1不要贪多先把一个外设玩透。比如你搞定了DHT11就理解了GPIO的输入输出切换、时序的精确控制、超时机制的设计。然后你再去看SPI通信会发现无非是多了时钟线和片选线数据采样时机从软件延时变成了硬件时钟。再去看I2C无非是多了起始条件和应答机制。底层逻辑都是一样的配置外设、等待标志位、读写数据、处理错误。另外STM32F1的参考手册一定要放在手边。很多问题你在网上搜半天不如直接翻手册看寄存器描述。比如GPIO的CRL和CRH寄存器每个引脚占4个位MODE位和CNF位组合决定了引脚的工作模式。你把这些寄存器的含义搞清楚配置GPIO就是几行代码的事根本不需要HAL库。最后说一个我自己的习惯每做一个新项目我都会在BSP层写一个通用的错误处理框架。每个驱动函数返回一个错误码0表示成功非0表示不同类型的错误。应用层根据错误码决定是重试、报警还是忽略。这样代码的可维护性会好很多调试的时候也能快速定位问题出在哪个环节。DHT11的读取函数里我返回了1到4四种错误码分别对应不同的失败原因就是这个思路的体现。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑