wujian100驱动DHT11实战:单总线时序、微秒延时与调试全解析 1. 从裸机到传感器为什么DHT11是wujian100上手外设的第一块试金石拿到wujian100这块基于RISC-V架构的MCU开发板之后很多人第一反应是点灯。点灯确实能验证工具链、下载流程、时钟配置是否跑通但它验证不了太多东西——GPIO翻转而已不涉及时序、不涉及协议、不涉及中断。真正能检验一套开发环境是否“能用”的是驱动一个带单总线协议的传感器而DHT11恰好是最合适的那个。DHT11是一款温湿度复合传感器内部集成了电阻式湿敏元件和NTC测温元件输出的是经过校准的数字信号。它便宜、常见、资料多但它的单总线协议对时序要求相当苛刻——高电平持续时间精确到微秒级响应窗口只有几十微秒。这意味着你没法用“大概延时一下”的方式糊弄过去必须认真对待时钟频率、延时精度和GPIO方向切换。换句话说DHT11驱动写通了说明你对wujian100的GPIO操作、系统延时、中断管理已经有了基本的掌控力。这篇笔记面向的是已经跑通wujian100基本工程、能编译下载、会用串口打印调试信息的开发者。如果你还没到这一步建议先把点灯和串口例程跑一遍确认工具链没问题再来看DHT11。因为DHT11调试过程中你会反复用到串口输出没有串口基本等于盲调。我个人的习惯是每接触一块新板子外设驱动顺序是GPIO点灯 → 串口打印 → 定时器延时 → 单总线传感器 → I2C/SPI传感器。DHT11正好卡在第四步承上启下。它逼着你去搞清楚系统主频是多少、延时函数怎么实现、微秒级延时到底准不准。这些问题在点灯阶段完全暴露不出来但到了DHT11这里一个都跑不掉。另外说一句DHT11虽然简单但它的数据手册里有一些容易忽略的细节比如上电后需要等待1秒以上才能读取、两次读取间隔不能小于1秒、数据格式是“湿度整数湿度小数温度整数温度小数校验和”。这些细节如果不注意你会遇到“第一次读成功第二次读失败”或者“数据一直校验错误”之类的问题。后面我会逐一拆解。2. 单总线协议的时序本质DHT11到底在什么时刻“说话”2.1 一次完整通信的五个阶段DHT11的单总线协议本质上是一种“主机发起、从机响应”的半双工通信。整个通信过程分为五个阶段主机发送起始信号主机把数据线拉低至少18毫秒然后拉高20-40微秒。从机响应信号DHT11检测到起始信号后把数据线拉低80微秒再拉高80微秒表示“我准备好了”。从机发送40位数据每一位数据都以50微秒低电平开始然后高电平持续26-28微秒表示“0”持续70微秒表示“1”。主机读取数据主机在每位数据的低电平结束后等待30-40微秒然后采样数据线电平。总线释放通信结束后数据线由上拉电阻拉高回到空闲状态。这五个阶段里最容易出问题的是第3和第4阶段。因为判断“0”和“1”的界限是26-28微秒对70微秒如果你采样时机不对比如在低电平期间就去读那读到的永远是0。正确的做法是等待低电平结束上升沿然后延时30-40微秒再采样此时如果还是高电平就是“1”如果已经变低就是“0”。2.2 为什么延时精度决定成败wujian100的主频通常是几十兆赫兹级别假设系统时钟是48MHz那么一个时钟周期大约是20.8纳秒。一条简单的循环延时指令可能消耗几个时钟周期。如果你用for循环做微秒延时循环次数需要根据主频精确计算。举个例子假设你需要一个40微秒的延时。如果系统主频是48MHz40微秒对应1920个时钟周期。如果一条循环指令比如递减并跳转消耗3个时钟周期那么循环次数大约是640次。但实际编译出来的汇编指令可能不止3个周期还受到流水线、Flash等待周期等因素影响。所以最可靠的办法是用硬件定时器做延时或者用示波器/逻辑分析仪校准你的延时函数。我在实际调试时先用逻辑分析仪抓了一次完整的通信波形确认起始信号的低电平时间、响应信号的高低电平时间、数据位的高电平时间都符合手册要求。这一步非常关键因为如果你连波形都不对后面读数据全是白搭。2.3 上拉电阻与GPIO方向切换DHT11的数据线需要接一个4.7kΩ到10kΩ的上拉电阻。这个电阻的作用是当总线上没有任何设备主动拉低时数据线保持高电平。很多开发板上的GPIO内部有可配置的上拉电阻但阻值通常比较大几十kΩ对于DHT11这种需要快速上升沿的场景内部上拉可能不够。我建议还是外接一个4.7kΩ的电阻稳定性会好很多。另一个关键点是GPIO方向切换。主机发送起始信号时GPIO配置为推挽输出发送完起始信号后必须立即切换为输入模式或者开漏输出外部上拉否则主机会一直驱动数据线DHT11无法拉低总线来发送响应信号。这个切换时机很微妙——如果你切换太晚DHT11可能已经错过了响应窗口如果切换太早起始信号的低电平时间不够18毫秒。我的做法是拉低数据线延时18-20毫秒拉高数据线然后立刻把GPIO切为输入模式紧接着进入等待响应信号的循环。这样切换时机最紧凑。3. 在wujian100上落地GPIO配置、微秒延时与数据读取的完整实现3.1 硬件连接与GPIO初始化先确认硬件连接。DHT11通常有三个引脚有些型号是四个其中一个悬空VCC、DATA、GND。VCC接3.3VGND接地DATA接wujian100的任意一个GPIO同时接一个4.7kΩ上拉电阻到3.3V。假设我们选用GPIO P0_5作为数据线。初始化代码如下以常见的HAL风格为例#define DHT11_GPIO_PORT GPIO0 #define DHT11_GPIO_PIN GPIO_PIN_5 void dht11_gpio_init(void) { // 配置为推挽输出初始输出高电平 gpio_config_t cfg; cfg.mode GPIO_MODE_OUTPUT_PP; cfg.pull GPIO_NOPULL; cfg.speed GPIO_SPEED_FREQ_HIGH; gpio_init(DHT11_GPIO_PORT, DHT11_GPIO_PIN, cfg); gpio_set(DHT11_GPIO_PORT, DHT11_GPIO_PIN); // 拉高 }这里有个细节初始状态一定要拉高。因为DHT11上电后需要一段时间稳定如果数据线一直是低电平传感器可能进入异常状态。拉高之后等待至少1秒再开始第一次通信。3.2 微秒级延时的三种实现方案对比在wujian100上做微秒延时常见有三种方案方案实现方式精度优缺点循环延时for/while空循环低简单但受编译优化和主频影响大硬件定时器配置定时器计数高精确但占用一个定时器资源系统滴答利用SysTick中需要主频支持1微秒可能不够我最初用的是循环延时结果发现不同优化等级下延时差异很大。-O0和-O2编译出来的循环次数完全不一样导致DHT11时好时坏。后来改用硬件定时器问题立刻消失。如果你非要用循环延时建议把延时函数放在单独的.c文件里并且用__attribute__((optimize(O0)))禁止优化然后通过逻辑分析仪校准循环次数。但这种方法移植性差换一块板子或者改一下主频就得重新校准。我的推荐是如果wujian100有空闲的定时器直接用它做微秒延时。配置定时器为1微秒计数一次然后封装一个delay_us(uint32_t us)函数启动定时器、等待计数到达、停止定时器。这样精度最高而且不受编译器影响。3.3 读取40位数据的核心逻辑读取数据的函数是整个驱动的核心。我把它拆成几个步骤uint8_t dht11_read_byte(void) { uint8_t data 0; for (int i 0; i 8; i) { // 等待低电平结束上升沿 while (gpio_get(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 0); // 延时30-40微秒 delay_us(35); // 采样 data 1; if (gpio_get(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 1) { data | 1; } // 等待高电平结束下降沿 while (gpio_get(DHT11_GPIO_PORT, DHT11_GPIO_PIN) 1); } return data; }这段代码看起来简单但有几个坑第一个坑while等待循环没有超时机制。如果DHT11没接好或者坏了程序会死在这里。实际产品代码里必须加超时计数比如循环一万次还没等到就返回错误。第二个坑延时35微秒是经验值。理论上应该在低电平结束后等待30-40微秒采样但具体多少取决于你的延时精度。如果延时偏短可能采到高电平的上升沿如果延时偏长可能采到下降沿。我建议用逻辑分析仪抓波形确认采样点落在高电平的中间位置。第三个坑数据位的顺序是高位在前MSB first。上面代码里data 1再或上采样值就是高位在前。如果你搞反了读出来的数据会完全不对。3.4 校验和与数据解析DHT11一次发送40位数据格式如下第1字节湿度整数部分第2字节湿度小数部分DHT11通常为0第3字节温度整数部分第4字节温度小数部分DHT11通常为0第5字节校验和 前四字节之和的低8位解析代码如下typedef struct { uint8_t humidity_int; uint8_t humidity_dec; uint8_t temp_int; uint8_t temp_dec; } dht11_data_t; int dht11_read(dht11_data_t *out) { uint8_t buf[5]; // 发送起始信号 gpio_set_mode_output(); gpio_reset(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_ms(20); gpio_set(DHT11_GPIO_PORT, DHT11_GPIO_PIN); delay_us(30); gpio_set_mode_input(); // 等待响应 if (wait_for_level(0, 100) ! 0) return -1; if (wait_for_level(1, 100) ! 0) return -1; if (wait_for_level(0, 100) ! 0) return -1; // 读取5字节 for (int i 0; i 5; i) { buf[i] dht11_read_byte(); } // 校验 uint8_t sum buf[0] buf[1] buf[2] buf[3]; if (sum ! buf[4]) return -2; out-humidity_int buf[0]; out-humidity_dec buf[1]; out-temp_int buf[2]; out-temp_dec buf[3]; return 0; }校验失败返回-2超时返回-1。调用者根据返回值判断是否读取成功。注意DHT11的湿度小数和温度小数通常都是0但有些型号会用到所以还是保留字段。4. 调试实录从“数据全0”到稳定读取的排查链路4.1 第一次尝试串口打印全是0我第一次跑DHT11的时候串口打印出来的温湿度全是0。校验和倒是通过了因为00000但数据明显不对。这说明通信过程“看起来”完成了但读到的每一位都是0。排查思路既然校验和能过说明读了5个字节每个字节都是0x00。那问题就出在dht11_read_byte里采样时永远读到低电平。可能的原因有三个一是延时太短在低电平期间就采样了二是GPIO方向没切换主机还在驱动低电平三是上拉电阻没接数据线一直是低。我先用万用表量了数据线电压发现空闲时是3.3V上拉没问题。然后用逻辑分析仪抓波形发现主机发送起始信号后数据线确实被DHT11拉低了80微秒然后拉高80微秒响应信号正常。但到了数据位阶段波形显示高电平持续时间很短大概只有10微秒左右。这就奇怪了手册上说“1”的高电平是70微秒“0”是26-28微秒怎么只有10微秒后来发现是我用的延时函数不准。我用的是循环延时在-O2优化下循环被编译器优化掉了大部分实际延时远小于预期。导致我在高电平还没结束时就采样了而且采样点落在了低电平区域。4.2 换成硬件定时器延时把延时函数换成硬件定时器实现后重新抓波形高电平持续时间变成了26微秒和70微秒和手册一致。但串口打印还是不对这次不是全0而是湿度正常、温度一直是0。继续排查湿度能读对说明数据位读取逻辑没问题。温度是第3和第4字节为什么偏偏是0我怀疑是读取过程中间出了问题比如读到第3字节时超时了。加了一些调试打印发现读取第3字节时while等待上升沿的循环偶尔会卡住。后来发现是中断干扰。wujian100的SysTick中断每隔1毫秒触发一次如果中断恰好发生在读取数据位的关键时刻会打断延时循环导致采样时机偏移。解决办法是在读取DHT11期间关闭全局中断读完再打开。代码如下int dht11_read(dht11_data_t *out) { __disable_irq(); // ... 读取过程 ... __enable_irq(); // ... 校验和解析 ... }关闭中断的时间大约几毫秒对系统实时性影响很小但能保证DHT11时序不被干扰。4.3 两次读取间隔不足导致的失败解决了中断问题后连续读取基本稳定了。但我发现如果连续快速调用dht11_read第二次经常会失败。查手册才知道DHT11两次通信之间需要至少1秒的间隔否则传感器内部可能还没准备好。这个坑很隐蔽因为第一次读成功之后你很容易想立刻再读一次验证。结果第二次失败你还以为是代码问题。实际上只要加一个delay_ms(1000)在两次读取之间就行。我最后的做法是在dht11_read函数开头加一个静态变量记录上次读取的时间戳如果距离上次不足1秒就自动延时补齐。这样调用者不需要关心间隔问题。4.4 电源噪声导致的偶发校验错误还有一个偶发问题大概每读几十次会出现一次校验错误。检查电源发现DHT11和wujian100共用3.3V电源当板子上其他外设比如LED切换时电源上会有几十毫伏的波动。DHT11对电源波动比较敏感可能导致某一位数据出错。解决办法是在DHT11的VCC和GND之间并一个100nF的陶瓷电容靠近传感器引脚放置。加了电容之后连续读取几百次都没有再出现校验错误。这个经验告诉我单总线传感器虽然简单但电源质量对通信可靠性影响很大。尤其是DHT11这种没有内部稳压的传感器电源上的毛刺很容易耦合到数据线上。5. 把DHT11驱动封装成可复用的组件接口设计与移植要点5.1 分层设计硬件抽象层与协议层分离为了让驱动代码更容易移植到其他平台我把DHT11驱动分成两层硬件抽象层HAL提供GPIO设置方向、读写电平、微秒延时、毫秒延时、关中断/开中断这几个接口。协议层实现起始信号、响应检测、字节读取、校验和解析不直接操作寄存器。这样换一个平台时只需要重新实现HAL层的几个函数协议层代码完全不用改。比如从wujian100换到另一款RISC-V芯片只要把dht11_hal_gpio_set_output、dht11_hal_delay_us等函数重新实现即可。HAL层接口定义如下typedef struct { void (*set_output)(void); void (*set_input)(void); void (*write)(uint8_t level); uint8_t (*read)(void); void (*delay_us)(uint32_t us); void (*delay_ms)(uint32_t ms); void (*disable_irq)(void); void (*enable_irq)(void); } dht11_hal_t;协议层通过一个dht11_hal_t指针来调用这些函数。初始化时把具体平台的实现传进去。5.2 超时机制与错误码设计前面提到while等待循环必须加超时。我设计的超时机制是每次等待一个电平变化最多循环DHT11_TIMEOUT_LOOP次比如10000次如果超时则返回错误码。错误码定义错误码含义可能原因0成功--1响应超时传感器未接、引脚接错、上拉电阻缺失-2校验错误电源噪声、时序偏差、传感器故障-3数据位超时中断干扰、延时不准、传感器异常-4读取间隔不足两次调用间隔小于1秒调用者可以根据错误码快速定位问题。比如连续返回-1先检查硬件连接偶尔返回-2考虑加滤波电容返回-3检查中断和延时。5.3 移植到其他RISC-V平台的注意事项如果你要把这个驱动移植到另一款RISC-V MCU有几个点需要特别注意第一主频不同导致延时函数需要重新校准。即使你用硬件定时器定时器的时钟源也可能不同。建议在移植后先用逻辑分析仪抓一次波形确认起始信号低电平时间在18-20毫秒之间响应信号的高低电平各80微秒左右。第二GPIO方向切换的寄存器操作不同。有些MCU的GPIO方向寄存器和输出寄存器是分开的有些是合在一起的。移植时要仔细看手册确保切换方向时不会产生毛刺。第三中断控制方式不同。有些RISC-V内核用csr指令关中断有些用外设中断控制器。HAL层的disable_irq和enable_irq需要根据具体平台实现。第四上拉电阻的配置。如果新平台的GPIO内部上拉足够强比如4.7kΩ左右可以省掉外部电阻。但大多数MCU的内部上拉是几十kΩ还是建议外接。5.4 实际项目中的使用建议在实际项目中我不会在中断里调用DHT11读取函数因为关中断时间太长几毫秒会影响其他实时任务。通常的做法是在主循环里每隔2秒调用一次dht11_read读取成功后把数据存入全局变量其他任务从全局变量取数据。如果项目对温湿度实时性要求高可以考虑用状态机的方式实现非阻塞读取。把DHT11通信过程拆成多个状态每个状态执行一小段通过定时器调度。但这样代码复杂度会高很多对于DHT11这种1秒更新一次的传感器没必要。另外DHT11的精度是湿度±5%RH、温度±2℃对于一般环境监测够用但如果你需要更高精度建议换DHT22或者SHT30。DHT22的协议和DHT11类似但数据格式不同湿度16位、温度16位驱动代码需要调整。最后分享一个调试小技巧如果你没有逻辑分析仪可以用两个GPIO配合来测量时序。一个GPIO接DHT11数据线另一个GPIO在代码的关键位置翻转然后用示波器双通道观察。虽然不如逻辑分析仪方便但也能看出大致时序关系。当然最省事的还是花几十块钱买个入门级逻辑分析仪调试单总线协议效率会高很多。