1. 为什么STM32F1至今仍是入门嵌入式的首选如果你在电子论坛或者技术群里问一句“新手该从哪款单片机入手”十个人里至少有六个会告诉你STM32F1系列。这个答案从十年前一直延续到今天多少有点反直觉——毕竟现在Cortex-M4、M7甚至带NPU的芯片满天飞一颗主频只有72MHz、Flash最大也就512KB的老将凭什么还能占据入门市场的半壁江山答案其实不复杂。STM32F1系列典型型号如STM32F103C8T6、STM32F103ZET6是ST在2007年前后推出的基于ARM Cortex-M3内核的通用MCU它的定位就是“够用、便宜、资料多”。72MHz主频、64KB到512KB Flash、20KB SRAM、丰富的外设USART、SPI、I2C、ADC、定时器、CAN这套配置放在今天做温湿度采集、电机控制、简单人机交互、数据记录仪这类任务依然绰绰有余。更关键的是它的中文资料、开源例程、开发板生态极其庞大你遇到的几乎每一个问题都有人踩过坑并留下了解决方案。我这次要聊的就是围绕STM32F1系列做的一个典型小项目用DHT11温湿度传感器采集环境数据通过STM32F103读取并在串口或OLED上显示。这个组合看起来简单但真正动手做的时候从供电、时序、数据校验到代码结构处处都是细节。很多新手卡在“读出来全是0”或者“湿度值乱跳”上一卡就是好几天。这篇文章我会把整个链路拆开从选型理由、硬件连接、DHT11单总线协议解析、代码实现、常见故障排查到实际调试中的经验技巧全部讲透。无论你是刚接触STM32的新手还是想复习一下单总线驱动写法的老手都能从里面找到能直接用的东西。2. DHT11与STM32F1的硬件搭配细节2.1 DHT11到底是个什么样的传感器DHT11是一款含有已校准数字信号输出的温湿度复合传感器内部由一个电阻式感湿元件和一个NTC测温元件组成并连接到一个8位单片机。它的测量范围是温度0到50摄氏度精度正负2摄氏度湿度20%到90%RH精度正负5%RH。采样周期不低于1秒也就是说你最快每秒读一次再快它也不会给你新数据。它的通信方式叫“单总线”一根数据线既传命令又传数据双向半双工。这个设计的好处是省引脚坏处是对时序要求极其严格。DHT11的数据线需要外接一个4.7k到10k的上拉电阻到3.3V或5V因为它的输出是开漏结构没有上拉电阻的话总线拉不高读出来全是0。很多人第一次用DHT11会犯一个错误以为它和I2C传感器一样接上SCL、SDA就能用。实际上DHT11只有一根DATA线而且它对上拉电阻的阻值、线长、电源质量都很敏感。线太长、上拉太大、电源纹波大都会导致通信失败。2.2 STM32F103与DHT11的引脚连接方案以最常见的STM32F103C8T6最小系统板为例我一般把DHT11的DATA脚接到PA0或者PB12这类普通GPIO上。为什么不接带特殊功能的引脚因为DHT11的时序需要精确的微秒级延时用普通GPIO配合__NOP()或者定时器做延时最可控。如果接到I2C或SPI的复用引脚上虽然也能用但初始化时容易忘记关闭复用功能导致电平异常。供电方面DHT11可以接3.3V也可以接5V。接3.3V时STM32的GPIO识别高电平没问题接5V时如果STM32的引脚不是5V容忍的就需要加电平转换。STM32F103的大部分GPIO是5V容忍的标注FT的引脚但为了省事我建议直接接3.3V这样总线和MCU同电平最稳定。实际接线就三根VCC接3.3VGND接GNDDATA接PA0然后在DATA和VCC之间并一个4.7k电阻。如果你用的是模块比如那种三针的DHT11模块板上通常已经集成了上拉电阻直接插就行。但要注意有些廉价模块的上拉电阻是10k线长超过20厘米时可能拉高不够快读数据会失败。这种情况可以把上拉换成4.7k或者缩短线长。2.3 电源与去耦的实战经验DHT11虽然功耗不高但它对电源噪声比较敏感。我遇到过好几次这样的情况用USB供电时读数据正常一换成电池供电就开始乱跳。后来用示波器看发现电池供电时3.3V轨上有几十毫伏的纹波正好落在DHT11的敏感频段。解决办法很简单在DHT11的VCC和GND之间并一个0.1uF的陶瓷电容位置尽量靠近传感器引脚。如果条件允许再并一个10uF的钽电容。这样能把电源纹波压下去读数稳定性会明显提升。另外STM32F103的VDDA和VSSA也要做好滤波虽然DHT11不直接依赖ADC但电源干净对整个系统都有好处。还有一点DHT11的DATA线如果走线较长比如超过半米最好用屏蔽线或者在外面套一个磁环。我在一个工业环境的小项目里DHT11的线拉了1.5米结果每次电机启动时湿度就跳变。后来加了磁环并缩短到0.5米问题才解决。所以如果你要做产品传感器尽量靠近MCU别为了布线方便把线拉太长。3. 单总线协议里那些容易读错的时间窗口3.1 一次完整通信的时序拆解DHT11的通信过程分四步主机发送起始信号、DHT11响应、传输40位数据、总线释放。每一步的时间窗口都很窄差几微秒就可能读错。主机起始信号MCU把DATA线拉低至少18毫秒然后拉高20到40微秒接着释放总线设为输入模式。DHT11检测到这个起始信号后会等待20到40微秒然后把总线拉低80微秒作为响应信号再拉高80微秒之后开始传输数据。40位数据里前16位是湿度整数和小数中间16位是温度整数和小数最后8位是校验和。校验和等于前四个字节相加的低8位。如果校验失败说明传输过程中有误码这次数据就不能用。每一位数据的表示方式每bit以50微秒的低电平开始然后是26到28微秒的高电平表示“0”70微秒的高电平表示“1”。所以判断0和1的关键就是测量高电平的持续时间。这个时间窗口很窄如果用软件延时来采样必须保证延时函数的精度。3.2 微秒延时为什么不能用HAL_DelayHAL库的HAL_Delay()是基于SysTick的毫秒级延时最小分辨率是1毫秒。而DHT11的时序需要微秒级判断比如区分26微秒和70微秒用毫秒延时根本做不到。正确的做法是用__NOP()指令做空循环延时或者用定时器做微秒级计时。以STM32F103主频72MHz为例一个__NOP()大约14纳秒1/72MHz循环几十次就能得到微秒级延时。但要注意编译器优化等级会影响__NOP()的实际执行时间。我一般把延时函数写成__attribute__((optimize(O0)))禁止编译器优化保证每次循环时间一致。更稳妥的方案是用一个基本定时器比如TIM4做一个微秒计数器。初始化时把预分频设为71这样计数器每加1就是1微秒。需要延时时读取当前计数值循环等待直到达到目标值。这种方法不受编译器优化影响精度也更高。我在实际项目中更倾向用定时器方案因为一旦调通换芯片或换主频时只需要改预分频值延时逻辑不用动。3.3 读取数据时的临界区保护DHT11的读取过程需要连续几十毫秒不能被打断。如果在这期间发生了中断比如SysTick中断或者串口接收中断时序就会被破坏读出来的数据大概率是错的。所以读取DHT11时我一般会在起始信号之前关闭全局中断读完40位数据后再打开。用__disable_irq()和__enable_irq()这对函数就行。但要注意关中断的时间不能太长否则会影响其他实时任务。DHT11一次读取大约需要4到5毫秒起始18毫秒加响应和数据传输关中断5毫秒对大多数应用来说是可以接受的。如果你的系统里有对中断延迟要求极高的任务那就需要考虑用DMA或者硬件外设来辅助但DHT11这种单总线协议很难用硬件外设直接支持所以关中断仍是最简单的方案。还有一个细节关中断之前要确保SysTick的计数不会溢出。如果关中断时间超过1毫秒SysTick的毫秒计数会丢导致HAL_GetTick()返回值偏小。对于DHT11这种5毫秒左右的关中断影响不大但如果你的程序依赖精确的毫秒计时就要在关中断后手动补偿。4. 从零写一个稳定的DHT11驱动4.1 GPIO模式切换的封装DHT11的DATA线需要在输出和输入之间切换。输出时用于主机拉低总线输入时用于读取DHT11的响应和数据。STM32的GPIO模式切换需要配置CRL或CRH寄存器用HAL库的话就是HAL_GPIO_Init()。我一般封装两个函数DHT11_SetPinOutput()和DHT11_SetPinInput()。输出模式配置为推挽输出、无上拉、50MHz速度输入模式配置为浮空输入或上拉输入。这里有个坑如果配置成上拉输入而外部已经有4.7k上拉电阻内部上拉会并联导致上拉阻值变小拉高更快但也会增加功耗。一般用浮空输入就行外部上拉已经够了。切换模式时要注意先设置模式再操作电平否则可能出现短暂的输出冲突。比如从输入切到输出时如果ODR寄存器里还是上次的值可能会瞬间输出高电平或低电平。我习惯在切到输出模式之前先把ODR对应的位设成1高电平这样切过去之后总线不会被意外拉低。4.2 起始信号与响应检测的代码实现起始信号的代码逻辑把DATA脚设为输出拉低至少18毫秒然后拉高20到40微秒再设为输入等待DHT11拉低。等待的时候要加超时防止DHT11没接好导致死循环。void DHT11_Start(void) { DHT11_SetPinOutput(); HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); Delay_ms(20); // 拉低至少18ms HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); Delay_us(30); // 拉高20-40us DHT11_SetPinInput(); }响应检测DHT11会先拉低80微秒再拉高80微秒。我们需要在设为输入后等待总线变低再等待总线变高。如果超时还没等到就返回错误。uint8_t DHT11_WaitResponse(void) { uint32_t timeout 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_SET) { if (timeout 10000) return 1; // 超时 } timeout 0; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_RESET) { if (timeout 10000) return 1; } return 0; }这里的超时计数用简单的循环变量就行因为每次循环大约几百纳秒10000次差不多几毫秒足够覆盖80微秒的响应时间。4.3 40位数据的逐位读取与校验读取每一位数据的逻辑先等待50微秒的低电平结束然后延时30微秒后采样。如果此时是高电平说明是“1”如果是低电平说明是“0”。为什么延时30微秒因为“0”的高电平只有26到28微秒30微秒后已经变低而“1”的高电平有70微秒30微秒时还是高。uint8_t DHT11_ReadByte(void) { uint8_t byte 0; for (int i 0; i 8; i) { while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_RESET); // 等待低电平结束 Delay_us(30); byte 1; if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_SET) { byte | 1; } while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_SET); // 等待高电平结束 } return byte; }读完5个字节后计算校验和if (data[4] (data[0] data[1] data[2] data[3]))相等则数据有效。注意DHT11的小数字节通常为0所以湿度就是data[0]温度就是data[2]。4.4 数据滤波与异常值处理即使代码写对了DHT11偶尔也会返回异常值比如湿度突然跳到90%又跳回来。这可能是电源波动或电磁干扰导致的。我一般会在应用层加一个简单的滑动平均滤波连续读5次去掉一个最大值和一个最小值剩下的取平均。这样能过滤掉大部分偶发跳变。另外如果校验失败不要立刻丢弃可以重试一次。DHT11的采样周期是1秒所以重试间隔至少1秒。我通常的做法是第一次读失败等1秒再读一次如果还失败就上报错误等下一次周期再试。不要连续快速重试因为DHT11内部需要时间完成一次测量。5. 调试过程中最常遇到的五个坑5.1 读出来全是0或者全是1这是最典型的问题。原因通常有三个上拉电阻没接、DATA线接错引脚、或者延时函数不准。先检查硬件用万用表量DATA线在空闲时是不是3.3V如果不是说明上拉没起作用。再检查代码确认DHT11_PIN和DHT11_PORT定义正确别把PA0写成PA1。如果硬件没问题那就是延时函数的问题。用示波器看起始信号的低电平时间是不是18到20毫秒高电平是不是20到40微秒。如果低电平只有几毫秒说明Delay_ms(20)实际没延时那么久可能是系统时钟配置错了。STM32F103默认用内部8MHz RC振荡器如果没配置PLL到72MHzHAL_Delay()的毫秒数会差很多。5.2 湿度值偶尔跳到90%以上DHT11的湿度测量范围是20%到90%超过90%时它可能输出90%或者乱码。如果你在潮湿环境比如浴室、温室使用读到90%以上是正常的但如果是干燥环境突然跳到90%那就是干扰。我遇到过一次DHT11和电机共用一组电源电机启动时湿度就跳到90%。后来给DHT11单独加了一个LDO供电问题消失。所以如果系统里有大功率负载传感器供电一定要隔离或者加滤波。5.3 校验和总是对不上校验和错误说明数据传输过程中有误码。最常见的原因是读取过程中被中断打断。检查一下有没有开启SysTick以外的中断比如串口接收中断、定时器中断。如果有在读取DHT11之前关中断读完再开。另一个原因是延时函数的精度不够。比如用HAL_Delay(1)来延时1毫秒但实际可能延时了1.2毫秒累积起来就会导致采样点偏移。建议用定时器做微秒延时或者用__NOP()循环并校准。5.4 换一块板子就读不出来了这通常是因为不同板子的晶振频率或PLL配置不同。比如一块板子用8MHz晶振另一块用12MHz如果延时函数是按72MHz写的换到另一块板子上主频可能不是72MHz延时就不准了。解决办法是在SystemClock_Config()里确认系统时钟频率并让延时函数根据SystemCoreClock变量动态计算循环次数。还有一种情况不同批次的DHT11响应时间略有差异。有的DHT11响应快有的慢。如果代码里的超时阈值太紧换一个传感器就可能超时。建议把超时阈值放宽一些比如等待响应的循环次数从10000增加到20000。5.5 用HAL库的GPIO读写太慢HAL库的HAL_GPIO_ReadPin()和HAL_GPIO_WritePin()函数有函数调用开销在72MHz下每次调用可能消耗几百纳秒。对于DHT11这种微秒级时序几百纳秒的偏差累积起来可能影响判断。如果发现读数据不稳定可以直接操作寄存器用GPIOA-IDR读输入用GPIOA-BSRR写输出。这样能省掉函数调用时序更准。比如读PA0if (GPIOA-IDR GPIO_PIN_0)拉低PA0GPIOA-BRR GPIO_PIN_0拉高PA0GPIOA-BSRR GPIO_PIN_0。直接操作寄存器在STM32F1上很常见尤其是对时序敏感的场景。6. 把数据用起来串口打印与OLED显示6.1 串口重定向printf的配置读出温湿度后最方便的查看方式就是串口打印。STM32F1的USART1接PA9和PA10配置成115200波特率、8位数据、无校验、1位停止位。要让printf()输出到串口需要重定向fputc()函数int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF); return ch; }然后在Keil或STM32CubeIDE里勾选“Use MicroLIB”否则printf()可能无法正常工作。打印格式可以写成printf(Temp: %d C, Humi: %d %%\r\n, temp, humi);。注意DHT11的温度和湿度都是整数不需要小数点。6.2 OLED显示温湿度的页面布局如果你用的是0.96寸I2C OLEDSSD1306驱动可以用现成的库比如u8g2或自己写的驱动。显示内容我一般分三行第一行显示“DHT11 Sensor”第二行显示“Temp: 25 C”第三行显示“Humi: 60 %”。字体用6x8或8x16都行8x16更清晰但占空间。刷新频率不要太高DHT11本身1秒才更新一次数据OLED每秒刷新一次就够了。如果刷新太快OLED的I2C通信会占用CPU时间反而影响DHT11的读取时序。我一般把OLED刷新放在DHT11读取之后两者间隔几百毫秒避免相互干扰。6.3 数据记录与阈值报警的扩展思路如果你想把数据记录下来可以加一个SD卡模块用SPI接口每隔一分钟把温湿度写入CSV文件。或者用STM32F1的RTC做时间戳记录每次采集的时间。阈值报警也很简单当温度超过30度或湿度低于30%时点亮一个LED或者让蜂鸣器响。这些扩展都不复杂核心还是先把DHT11的读取做稳定。我在一个温室监控项目里用STM32F103加DHT11和ESP8266做了个简单的物联网节点。DHT11每5秒读一次数据通过串口发给ESP8266再上传到服务器。运行了半年多除了偶尔因为电源波动导致一次校验失败外整体很稳定。关键就是电源滤波和关中断保护做到位了。7. 关于STM32F1与DHT11组合的个人体会这个组合我用了很多年从学生时代的课程设计到后来的小批量产品DHT11和STM32F103的搭配几乎没让我失望过。它不高端但足够可靠它不复杂但细节很多。每次有人问我“为什么我的DHT11读不出来”我都能从上面那些坑里找到对应的原因。如果你刚开始接触STM32我建议就从DHT11这个项目入手。它涉及GPIO输入输出切换、微秒延时、单总线协议解析、校验和计算、串口通信几乎涵盖了嵌入式开发的基础技能。把这个项目跑通你对STM32的GPIO和定时器会有完全不一样的理解。最后分享一个小技巧在DHT11的DATA线和GND之间并一个100pF的电容能滤掉高频噪声对读数稳定性有帮助。这个电容不要太大否则会延缓边沿导致时序判断出错。100pF到1nF之间比较合适具体值可以根据实际布线调整。我在几个干扰较大的环境里试过加了之后校验失败率明显下降。