资讯动态

FPGA驱动DHT11:从单总线协议到状态机实现温湿度采集

发布时间:2026/10/5 5:52:11 来源:尧图企业网站定制
我把“FPGA Verilog DHT11”这个组合拆开聊一聊。DHT11这颗传感器在单片机圈子里被写烂了但放到FPGA上画风完全不一样。单片机里一条delay_us()函数就能拖出来的时序到了FPGA里要变成计数器、状态机、边沿检测和一堆并行逻辑。也正是因为这种差异这个项目才特别适合当FPGA进阶路上的磨刀石。这篇文章我会按“协议原理 - 模块划分 - 完整代码 - 仿真调试 - 上板踩坑”的顺序把我实际做这个项目时的完整思路和代码直接摊开给你看包括我踩过的几个坑和最后怎么排查出来的。想入门FPGA或者想把DHT11从单片机搬到FPGA上的朋友这篇可以直接照着抄作业。1. 为什么选DHT11作为FPGA入门传感器项目1.1 DHT11能让你在FPGA里学到什么先说结论DHT11几乎是FPGA时序控制类项目里性价比最高的练手对象。它不涉及复杂的协议栈不需要外部时钟同步不依赖差分信号一根数据线就干完了所有事情。但恰恰是这根“一根线”把单总线协议、精确延时、状态机设计、并行数据拼接、校验判断这些FPGA基本功全部串起来了。我在做温度采集项目前其实已经跑过流水灯、按键消抖、数码管动态扫描这些基础实验。但那些实验都有一个共同问题思路太线性。流水灯本质就是计数器溢出翻转数码管扫描本质也是计数器分时选通它们都只需要一个always块就能写完。到DHT11这里不一样了你要先拉低总线18ms再释放并拉高20~40us然后读传感器回应最后连续采40位数据。整个过程中总线方向是固定的只有一根线输入输出全靠三态门切换时序窗口是微秒级的数据位0和1的差异只有几十微秒。这种复杂度和精确度要求逼着你必须用状态机来组织逻辑用计数器来制造微秒级延时用寄存器和移位寄存器来拼接数据。学完这个项目你对FPGA的理解会上一个台阶你会真正理解什么叫“硬件逻辑是并行的”也会习惯“一切延时时序都靠计数一切流程控制都靠状态机”的思维方式。1.2 FPGA与单片机实现DHT11的差异很多人玩DHT11是从STM32或者Arduino入手的我也一样。在那类平台上代码大概是这么个套路主机拉低18ms - 释放 - 延时30us - 读响应 - 循环40次读取数据位。每个延时都是调用现成的delay_us()函数一个函数卡在那里CPU就在原地空转等时间过去。这种写法在MCU上没问题但在FPGA上完全行不通。原因有两个。第一FPGA里没有“等”这个概念。Verilog写的硬件逻辑是并行执行的你不可能写一句“延时30us然后再干什么”因为所有always块都在同时跑。要想让某个操作持续指定的时间唯一办法是启动一个计数器数到目标值后翻一个标志位其他逻辑看到这个标志位后才继续往下走。第二单总线协议是严格的主从时序主机什么时候拉低、什么时候释放、什么时候采样必须卡在微秒级别。FPGA虽然没有CPU那么灵活的指令流但它的优势是时钟精确可控。只要你系统时钟稳定比如50MHz晶体振荡器每个周期是固定的20ns你要多少微秒的延时就数多少个时钟周期精度可以做到纳秒级。这一点比MCU上基于定时器或者指令周期的delay更可靠也更适合做这类位级协议的解析。所以你在FPGA上写DHT11驱动本质上不是在“写代码”而是在“设计一个能按照特定时序图工作的硬件电路”。这个思路的转变很重要。2. 核心原理DHT11单总线协议到底在说什么2.1 40位数据格式与校验逻辑DHT11一次完整的数据传输只有40位结构是固定的8位湿度整数部分 8位湿度小数部分 8位温度整数部分 8位温度小数部分 8位校验和。注意对DHT11来说湿度小数位和温度小数位通常读出为0所以实际有效数据就是两个整数位。校验规则很简单前四个字节相加结果的低8位等于第五个字节就算校验通过。换算成公式就是校验字节 (湿度整 湿度小 温度整 温度小) 8hFF我习惯在设计模块的注释里把校验逻辑写上因为上板调试时数据老是跳变或者全0一半以上的原因就是校验没过——不是传感器坏了而是时序采样点没对准导致读到的位错了。这个校验规则在Verilog里只用一条assign语句就能搞定但它在系统里的作用很大它帮你区分“传感器没回应”和“读到错误数据”。没回应的时候数据线被上拉电阻拉到高电平读回的全是1而采样点错了读回的数据是随机的。这两个现象在调试时是完全不同的排查路径。2.2 时序参数解析起始信号、响应信号、数据位DHT11的整个通信过程可以拆成四个阶段。我直接把实际测量得到的时序参数列出来这些都是写代码时需要卡死的关键值。第一阶段是主机起始信号。主机先把数据线拉低保持至少18ms我实际用20ms稳妥一点然后释放总线让上拉电阻把电平拉高。主机释放总线后需要等待20~40us然后才能开始读传感器的响应。第二阶段是传感器响应信号。DHT11会先拉低总线约80us再拉高约80us表示“我准备好了准备发数据”。主机在这个阶段要做的只有一件事检测到低电平的下降沿后开始计时等电平拉高再拉低意味着响应结束准备读取40位数据。第三阶段是数据位传输。每一位数据都以50us的低电平开始然后是高电平。关键差异在高电平持续的时间如果高电平持续约26~28us代表逻辑0如果持续约70us代表逻辑1。也就是说你只要在数据位低电平结束之后等待约30us然后去采样总线电平采到低就是0采到高就是1。或者换一种更稳妥的方式测量高电平的持续时间超过50us判为1否则判为0。第四阶段是传输结束。40位数据发完后DHT11会释放总线由上拉电阻拉高准备进入下一次通信。完整时序图我建议你去看DHT11手册我这里用一张表总结关键参数方便写代码时对照阶段名称总线电平持续时长主机起始拉低低18ms以上我用20ms主机释放拉高上拉高20~40us传感器响应拉低低约80us传感器响应拉高高约80us数据位0低电平短高电平低约50us高约26~28us数据位1低电平长高电平低约50us高约70us传输结束释放总线高上拉后续50us以上2.3 FPGA视角的时序设计计数器 vs 延时循环在FPGA中实现上述时序核心手段只有一个计数器。我们不去“延时”而是用计数器量时间。举例来说系统时钟是50MHz周期20ns。如果你要拉低总线20ms那就需要维持20ms / 20ns 1,000,000个时钟周期。这个数不算大用20位的计数器就能装下2^20 1,048,576。更重要的一个技巧是不要为了某个协议单独去PLL分频出一个1MHz的慢时钟。直接拿50MHz时钟和一个计数器跑需要多少时间就数多少周期这种做法的好处是精度高、逻辑简单、不会引入跨时钟域的麻烦。我见过一些初学者想先把系统时钟分频成1MHz再来做DHT11时序结果延时误差反而变大因为每次翻转时钟本身就有额外延迟。我个人的设计习惯是写一个简单的计时器模板给定一个目标计数值计数器每周期加1数到目标值后拉高一个标志位并清零。然后状态机里的每个状态都在等这个标志位标志位来了就跳转没来就原地踏步。这样写出来的代码结构非常工整后面加状态、改时序都很方便。3. 整体架构设计先画图再写代码3.1 模块划分与对外接口设计拿到需求别急着写代码先把模块图画清楚。DHT11这个项目功能边界很清晰我把它分成两个模块加一个顶层顶层模块负责例化、时钟复位接入、按键触发和结果输出。控制模块DHT11 Core负责时序协议对外暴露一个“读取完成”脉冲和一个40位的数据总线。显示/输出模块负责把读到的温湿度数据变成数码管段码或者串口字节。这样划分最大的好处是解耦。DHT11 Core不去关心数据最终是显示在数码管上还是发到串口它只负责把40位原始数据完好地采回来。显示模块也不用关心DHT11的时序细节它只等在read_done脉冲来临时把data总线上的数据取走就行。后面如果你想从DHT11换成SHT30只需要重写控制模块顶层和显示逻辑一行都不用动。顶层模块的对外信号我一般这样定义输入clk_50m、rst_n复位、read_en读使能可以用按键输出dht_data三态总线连接到芯片引脚、segment数码管段码/串口发送数据这里有个关键点dht_data在FPGA内部是inout类型。在Verilog里inout通常用三态门实现当输出使能oen有效时把数据驱动的io口oen无效时把io置为高阻z此时输入通路生效。这个逻辑很简单但很多人第一次写会漏掉高阻态导致总线永远是输出根本读不到传感器的电平。3.2 状态机设计的关键思路DHT11控制的内部逻辑我用一段式状态机——不严格来说更推荐三段式状态机。原因很简单这个状态机里既有20ms的长延时又有20~40us的短延时状态之间还存在分支跳转用一段式会把时序逻辑和组合逻辑混在一起仿真和调试都很难看。我在实际项目里用的是“两个变量”的思路状态寄存器state和计数器寄存器cnt。状态机每个周期做三件事判断当前状态根据状态决定计数器是否继续累加当计数器的目标值到达时决定是否跳转到下一个状态。状态机的状态划分非常关键我的划分如下IDLE空闲状态等待读使能信号一旦触发就进入STARTLOW状态STARTLOW拉低总线维持20ms到时间后释放总线进入STARTHIGH状态STARTHIGH释放总线并等待约30us然后进入WAIT_RESPONSE状态WAIT_RESPONSE等待传感器的80us低电平响应检测到上升沿后进入READ_START状态READ_START等待每个数据位的50us低电平结束也就是上升沿然后进入SAMPLE状态SAMPLE等待约40us采样数据线的电平判断0还是1写入移位寄存器。如果40位未读完则回到READ_START否则进入DONE状态DONE拉高read_done数据在该状态下保持稳定等待外部读取后回到IDLE这个状态机的核心思想是把每个时序阶段都变成一个“等待超时或等待沿”的过程。具体到代码实现就是四个典型的判断等时间到、等上升沿、等下降沿、等40位数据收完。我建议你在自己写代码前先把这个状态划分画出来画清楚了你再动手效率会高很多。4. Verilog代码实现与逐段解析4.1 顶层模块示例先放顶层模块。它做的事情就是把时钟复位、控制模块和输出显示模块连起来。module top_dht11 ( input wire clk_50m, input wire rst_n, input wire read_en, inout wire dht_data, output reg [7:0] seg_data, output reg [5:0] seg_sel, output reg tx // 如果不需要串口发送可删掉 ); wire dht_done; wire [39:0] dht_data_out; dht11_core u_dht11_core ( .clk_50m (clk_50m), .rst_n (rst_n), .read_en (read_en), .dht_data (dht_data), .data_out (dht_data_out), .done_pulse(dht_done) ); // 显示/输出逻辑这里只做示例数据锁存 reg [39:0] dht_data_reg; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) dht_data_reg 40d0; else if (dht_done) dht_data_reg dht_data_out; end // 后续可以接数码管/串口与DHT11时序无关 endmodule顶层模块这段其实不复杂值得强调的是dht_done这个信号它必须是一个单周期的脉冲而不是持续的高电平。因为外部模块用它做锁存使能如果是持续高电平数据会被反复锁存虽然结果一样但容易在仿真时看到奇怪的毛刺。4.2 DHT11控制核心代码下面这部分是整个项目的核心建议仔细看注释。module dht11_core ( input wire clk_50m, input wire rst_n, input wire read_en, inout wire dht_data, output reg [39:0] data_out, output reg done_pulse ); // 时间常量基于50MHz时钟周期20ns localparam IDLE 4d0; localparam START_LOW 4d1; localparam START_HIGH 4d2; localparam WAIT_RESPONS 4d3; localparam READ_START 4d4; localparam SAMPLE 4d5; localparam DONE 4d6; localparam CNT_20MS 20d1_000_000; // 20ms localparam CNT_30US 16d1_500; // 30us localparam CNT_40US 16d2_000; // 40us localparam CNT_DELAY 16d2_500; // 50us用于数据位低电平等待 reg [3:0] state, next_state; reg [19:0] cnt; // 最长计时20ms20位足够 reg dht_out_en; reg dht_out_val; reg [5:0] bit_cnt; reg [39:0] shift_reg; wire dht_in; assign dht_data dht_out_en ? dht_out_val : 1bz; assign dht_in dht_data; // 三段式状态机的第一段状态跳转 always (posedge clk_50m or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 第二段组合逻辑判断状态转移 always (*) begin next_state state; case (state) IDLE: begin if (read_en) next_state START_LOW; end START_LOW: begin if (cnt CNT_20MS) next_state START_HIGH; end START_HIGH: begin if (cnt CNT_30US) next_state WAIT_RESPONS; end WAIT_RESPONS: begin // 等待传感器把总线拉低后再次拉高即响应的上升沿 if (dht_in 1b1) // 简单写法只要看到高电平就认为响应开始 next_state READ_START; end READ_START: begin if (cnt CNT_DELAY) // 等待50us低电平结束 next_state SAMPLE; end SAMPLE: begin if (cnt CNT_40US) begin if (bit_cnt 5d40) next_state DONE; else next_state READ_START; end end DONE: next_state IDLE; default: next_state IDLE; endcase end // 第三段状态内的时序控制 // 注意这里的逻辑不只用状态还用了cnt的数值来做分支 always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt 20d0; bit_cnt 6d0; shift_reg 40d0; dht_out_en 1b0; dht_out_val 1b1; done_pulse 1b0; data_out 40d0; end else begin // 默认值 done_pulse 1b0; case (state) IDLE: begin cnt 20d0; bit_cnt 6d0; dht_out_en 1b0; // 释放总线 done_pulse 1b0; end START_LOW: begin dht_out_en 1b1; dht_out_val 1b0; // 拉低总线 cnt cnt 1b1; end START_HIGH: begin dht_out_en 1b0; // 释放总线让上拉电阻把电平拉高 dht_out_val 1b1; cnt cnt 1b1; end WAIT_RESPONS: begin dht_out_en 1b0; cnt 20d0; end READ_START: begin // 等50us低电平结束这里直接用计数器 cnt cnt 1b1; end SAMPLE: begin dht_out_en 1b0; if (cnt CNT_40US) begin cnt 20d0; // 在数据位高电平中段采样 if (dht_in) begin // 高电平持续超过一定阈值判为1 shift_reg {shift_reg[38:0], 1b1}; end else begin shift_reg {shift_reg[38:0], 1b0}; end bit_cnt bit_cnt 1b1; end else begin cnt cnt 1b1; end end DONE: begin dht_out_en 1b0; data_out shift_reg; done_pulse 1b1; end default: begin dht_out_en 1b0; end endcase end end endmodule4.3 关键参数计算50MHz下的计数阈值上面代码里的localparam数值不是随便拍的我把计算过程写出来方便你换成其他时钟频率时能举一反三。系统时钟50MHz周期 1 / 50,000,000 20ns。20ms延时需要的时钟周期数20,000,000ns / 20ns 1,000,000个周期。二进制表示需要20位所以计数器位宽是20位。我给CNT_20MS赋的值就是1_000_000。30us延时30,000ns / 20ns 1,500个周期这是CNT_30US的由来。40us对应2,000个周期。50us对应2,500个周期。这里有一个容易踩的坑计数器是从0开始数比较条件是cnt 目标值所以实际延时略小于目标值一个周期但20ns的误差对于DHT11这种微秒级宽容度的协议完全无感。真正的误差来源是状态机跳转本身要花几个周期所以我习惯把关键的采样点SAMPLE状态的40us算得稍微靠后一点宁可晚采样不要早采样。因为在数据位高电平的中间段采样是最稳的采样点早了可能还在低电平尾巴上采样点晚了可能高电平已经结束都会读到错误值。5. 仿真测试在看不到硬件之前先相信波形5.1 搭建testbench与激励写完代码不仿真直接上板这是FPGA项目里效率最低的做法。我建议在Vivado或者ModelSim里先把testbench写好用仿真验证状态机是否按预期工作再去上板调试。DHT11的testbench不算复杂核心是把传感器模型用行为级代码模拟出来。仿真时dht_data要处理好上拉电阻测试平台里声明一个wire类型变量然后接一个pullup或者直接用1来驱动。传感器模型需要响应主机的起始信号然后按固定时序返回40位数据。我写的一个简化版传感器激励模型思路大概是这样// 传感器行为模型仿真用 reg sensor_responds; reg [39:0] sensor_data; integer i; always (posedge dht_in) begin // 检测到主机释放总线的上升沿后延时20us然后模拟响应 // 因为在真实场景中时序是主机与传感器交互的 end这个模型不需要做到百分之百精确关键是能把主机发出的时序“接住”再把40位数返回。具体的信号处理逻辑就是一个计数器加几个状态和真实传感器行为一致即可。仿真过程中重点看dht_data上的波形是否符合时序要求。5.2 仿真波形中必须检查的4个关键点第一起始信号。仿真图上应该能看到dht_data被拉低约20ms然后释放释放后有一个明显的上升沿。第二响应信号。在主机释放后约20~40usdht_data上应该出现一个约80us的低电平脉冲然后拉高约80us。第三40个数据位。每个数据位都应该有约50us的低电平前缀然后要么是约26~28us的高电平0要么是约70us的高电平1。第四采样点位置。通过仿真光标卡位确认采样时刻处于高电平的中间不能顶到边沿。如果仿真波形中看到数据位的高电平宽度忽长忽短或者响应信号没有出现大概率是状态机的跳转条件写得不对。最常见的错误是WAIT_RESPONS状态里没有延时导致主机还没给传感器足够的时间启动就直接去读数据结果读到的是总线上的上拉高电平移位寄存器全是1。5.3 上板调试经验从Vivado到逻辑分析仪仿真通过后上板阶段会遇到一些仿真里暴露不出来的问题。第一个是信号完整性DHT11的数据线用得比较长或者面包板接触不良都会导致边沿不干净。建议用杜邦线压缩到最短并且在数据线上加一个4.7k欧姆上拉电阻到VCC有些模块板上自带了用模块就不需要额外加。第二个是复位时序。如果你的板子没有外部复位按键单纯用上电复位逻辑要注意FPGA配置完成后DHT11可能需要几十毫秒的稳定时间。我习惯在FPGA上电后先让状态机在IDLE状态待够200ms再允许读使能。这个延时可以放在顶层用一个计数器实现也可以在按键触发逻辑中处理。第三个问题是数据偶尔正确、偶尔全0。这种问题我的排查路径是先用逻辑分析仪抓DHT11的波形对比手册里的时序参数。然后查看每一位的采样点是否都落在高电平区间。最后检查状态机的状态跳转是否因为某个cnt没有清零而提前退出。我遇到过一次问题原因是SAMPLE状态里cnt在bit_cnt计数到40时清零了但状态还没跳到DONE多等了一个状态周期导致最后一位采到一个无效值。这个小问题用逻辑分析仪看波形时非常隐蔽因为只有最后一位是错的其余39位都对。6. 常见问题速查表与避坑技巧我在做这个项目以及帮人排查问题过程中整理出了一张高频问题排查表直接贴出来。现象可能原因排查方法数据全为1主机一直没有拉低总线或者DHT11供电异常用示波器/逻辑分析仪看dht_data上有没有20ms低电平数据全为0总线被拉死检查上拉电阻是否缺失断电测量dht_data对地电阻确认有上拉到VCC湿度数据正常温度明显偏高传感器靠近板卡发热源如FPGA芯片/稳压芯片实测板卡温升传感器远离热源间隔几次能读出正确值但经常超时起始信号低电平时间不足18ms增大CNT_20MS参数建议多留20%余量校验总是失败采样点落在数据位边沿调整SAMPLE状态的等待时间把采样点往高电平中间挪用按键触发时偶尔卡死按键消抖与总线操作冲突按键消抖后再产生read_en单脉冲或者改用定时自动触发除了上表还有两个容易忽略的细节我重点说一下。第一个是位序。DHT11手册中说的是高位先出湿度整数、湿度小数、温度整数、温度小数、校验和每一位都是MSB first。我一开始没注意这个问题直接用移位寄存器左移拼接结果发现读出来的温湿度数值是反的折腾了一晚上才反应过来。正确做法是每次采样到一个位就把shift_reg左移一位然后把新位塞到最低位这样40位全部采样完最高位就是湿度整数的第7位。第二个是模块数据输出格式。DHT11温度是整数湿度是整数但有些场景你想直接输出到数码管或者串口需要做BCD编码或者ASCII转换。我在顶层做了一个简单的数据锁存然后通过串口发出去串口模块是另外写的UART发送模块。这一步不是DHT11协议的一部分但属于“做完传感器驱动之后自然而然要做的下一步”建议一起搞明白。7. 这个项目做完后下一步可以怎么玩DHT11搞定了你其实已经掌握了一套可复用的“单总线时序解析”能力。后面至少有三个方向值得继续深入。第一个方向是换传感器。DHT11的兄弟DHT22精度更高SHT30用的是I2C接口DS18B20用的也是单总线但协议细节完全不同。你再看这些传感器时会发现套路都一样读手册画时序图划分状态机写计数器等待采样数据校验输出。换传感器只是换参数的问题。第二个方向是把自己写的DHT11模块做标准化封装加上AXI-Lite接口或者自定义的简单总线接口挂到片上系统里。这样CPU软核或硬核可以通过总线来读取温湿度数据而不是在FPGA逻辑里硬连线。这一步做通过你对IP核的封装和总线协议的理解会上一个档次。第三个方向是数据后处理。DHT11读出来的温湿度可以进行滑动窗口滤波、阈值判断、报警输出、数据上报到上位机这些功能对应到的Verilog知识点分别是滑窗累加、比较器输出、UART/以太网协议栈。我后来就把这个项目扩展成了“FPGA温湿度采集与以太网传输”数据在FPGA端做了一遍滑动窗口滤波再接发送效果比裸数据上传稳定得多这也算是这个入门项目比较完整的收尾方向。回头看看这个项目它不算难但几乎是每个FPGA学习者都绕不开的一环。它的价值不在于DHT11本身而在于它逼着你把状态机、计数器、三态门、时序分析这些最基础也最核心的FPGA技能真正用起来。我做完这个项目最大的体会就是写Verilog不能靠背代码你得能看着手册上的时序图画状态机能盯着波形图找自己的bug能随手算出一行localparam对应的物理时间。这些能力都是靠一个接一个像DHT11这样的小项目磨出来的。

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

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

免费获取报价 →
↑