FPGA驱动DHT11:Verilog状态机实现单总线温湿度采集
2026/8/27 1:55:53 网站建设 项目流程

简介:状态机是数字系统设计中的核心概念,它将复杂的时序流程拆解为可管理的状态跳转。单总线协议作为一种节省IO的通信方式,广泛应用于DHT11等温湿度传感器。DHT11通过一根数据线完成双工通信,其微秒级时序对驱动设计提出了挑战。理解其数据帧格式与校验规则,是正确读取温湿度数据的前提。在FPGA开发中,状态机设计不仅要处理时序逻辑,还需涉及双向IO与计数器协同。基于EP4CE10平台,使用Verilog HDL实现DHT11驱动,能够有效训练协议解析能力。该实现覆盖起始信号、响应检测、数据采样、校验及仿真验证,并支持数码管显示。这种方法同样适用于DS18B20、I2C等器件,为物联网环境监测等应用场景提供可靠基础。通过工程实践,可深入掌握单总线驱动的通用思路。 DHT11在FPGA项目里算是很经典的练习对象。很多学FPGA的人卡在它面前,是因为它和I2C、SPI不一样——一根数据线既当输入又当输出,时序还是微秒级的,逻辑分析仪一接上去,密密麻麻的波形看得人头皮发麻。我最初也在这上面折腾了差不多三天,后来把状态机理顺之后才发现,这类单总线协议驱动的难点不在代码量,而在于怎么把一个完整的时序流程拆成状态和计数器的组合。

这篇文章以我实际做过的FPGA EP4CE10驱动DHT11温湿度传感器工程为主线,用Verilog HDL实现,把从协议解析、状态机设计、双向IO处理到仿真验证、上板调试的完整过程都过一遍。项目不算大,但非常适合FPGA入门者用来练手,也能帮有一定基础的朋友理清单总线协议的驱动思路。看完之后,你不仅能驱动DHT11,再去碰DS18B20、I2C这类器件也会顺手很多。

1. 项目概述与方案选型

1.1 工程到底做了什么

这个工程的核心功能一句话就能讲清楚:用FPGA的普通IO口,按照DHT11单总线协议发起起始信号,接收传感器返回的40位温湿度数据,经过校验后提取温度和湿度值。代码里默认把数据送到开发板上的数码管显示,如果你愿意,改成UART发送到上位机也很方便。

做这个项目之前,我先明确了一个思路:DHT11驱动本身只是手段,真正的目标是训练“用状态机解析时序协议”的能力。LED流水灯和按键消抖这类基础项目做完之后,新手接触的第一个有点含金量的外设驱动往往就是DHT11或者DS18B20这类单总线器件。相比I2C和SPI,DHT11的时序足够简单,但又是标准的一对一握手流程,很适合用来建立状态机的思维方式。

工程结构上,顶层模块只暴露了两个对外接口:一个连DHT11的数据引脚,一个连数码管的段选位选。内部逻辑拆成两部分——DHT11控制核心负责时序,显示模块负责把16位温湿度数据转成BCD码后动态扫描显示。这样隔离之后,DHT11的时序逻辑可以单独复用,以后想改成串口输出,只需要换掉显示模块就行。

1.2 EP4CE10的资源能不能满足

EP4CE10是Cyclone IV E系列里的入门型号,逻辑单元10320个,嵌入式存储414Kbit,IO数量根据封装不同大约在90到179个。驱动DHT11这件事本身消耗的资源非常少,主要占资源的是数码管动态扫描的计数器以及缓存寄存器,整体算下来逻辑单元占用可能不到200个,EP4CE10跑这个项目绰绰有余。

选择EP4CE10还有一个实际的原因:它原生支持3.3V IO电平,而DHT11用3.3V供电时输出的高电平也正好是3.3V,和FPGA的IO电平直接匹配,不需要额外加电平转换。DHT11的数据引脚是开漏输出结构,使用时需要在数据线和VCC之间接一个4.7k欧姆上拉电阻。我用的开发板上没集成这个电阻,是用面包板飞线解决的。

1.3 为什么选DHT11而不是其他传感器

DHT11传感器输出的是已经数字化的温湿度数据,不需要像PT100那样做ADC采集。它的精度不算高——温度±2摄氏度、湿度±5%RH——但胜在便宜、接线简单、单总线协议逻辑清晰。在很多对精度不敏感的场合,比如机房温湿度监控、温室大棚环境采集,这个精度完全够用。如果你的项目对精度有更高要求,可以后续把驱动逻辑改到DHT22或SHT30上,思路是通用的。

DHT11单总线协议有个特殊的地方:主设备发起通信时至少要拉低总线18ms以上,这个时间比I2C的起始条件长得多,基本等于用系统时钟“数出”一个18毫秒的长脉冲。再加上数据位需要区分26到28微秒和70微秒左右两种高电平时长,整个驱动最核心的设计点就落在微秒级计数、状态跳转和数据采样上。

这也是为什么用FPGA驱动DHT11比用单片机驱动更有学习价值。单片机上很多人直接用delay_us()函数阻塞延时,CPU一条一条指令把时序拖出来,代码看着简单但很难真正理解时序的本质;FPGA里没有阻塞延时这种写法,只有“计数器数到某个值就切状态”这一种思路。写代码的过程,实际上就是在纸上把协议时序图翻译成状态机。

2. DHT11单总线协议拆解

2.1 一次完整通信的四个阶段

DHT11的通信过程可以分成四个阶段:起始信号、响应信号、数据位传输、结束。

主设备先把总线拉低,持续时间要大于18ms,一般代码里取20ms留出余量;然后主设备释放总线,总线被上拉电阻拉高。主设备释放后等待20到40微秒,DHT11会主动把总线拉低约80微秒作为响应信号,之后再把总线拉高约80微秒,表示“准备好了,我开始发数据”。

接下来DHT11开始发送40位数据。每一位数据都以约50微秒的低电平开始,后面跟着一段长度可变的高电平。如果高电平持续26到28微秒,代表数据位0;如果高电平持续约70微秒,代表数据位1。数据发完后总线回到高电平空闲状态。

阶段事件时间参数
主机发起拉低总线至少18ms,常用20ms
主机释放拉高总线20-40us
DHT11响应拉低约80us
DHT11响应拉高约80us
数据位起始拉低约50us
数据位0高电平时长26-28us
数据位1高电平时长约70us

这张表看起来简单,真正写代码时有个容易忽略的问题:DHT11的数据线是双向的。主设备拉低、DHT11也拉低、最后双方都释放靠上拉电阻拉高。这个“释放”在FPGA里对应的是把IO配置成高阻态,也就是Verilog里的z状态。inout端口操作如果没写对,总线要么被钳在低电平,要么一直是高电平,通信必然失败。

2.2 40位数据的排列和校验规则

DHT11一次返回40位数据,高位在前,顺序如下:

  1. 8位湿度整数部分
  2. 8位湿度小数部分(DHT11的小数位实际通常为0)
  3. 8位温度整数部分
  4. 8位温度小数部分
  5. 8位校验和

校验和的计算规则:前四个字节求和,取低8位,如果和第五个字节相等,说明这一帧数据有效。举个例子,假设当前湿度是45%RH,温度是26摄氏度,那么前四个字节就是0x2D、0x00、0x1A、0x00,相加等于0x47,校验字节应该就是0x47。

温度负值的处理是个容易忽略的细节。DHT11在零下温度时,温度整数部分的最高位会置1,比如-10度会表示为0x8A。接收端需要判断这个符号位,去掉0x80后再取数值,否则零下温度直接显示成一百多度,特别容易让人误判是传感器坏了。这个细节藏得比较深,我第一次在户外测试时就中招了,后来查资料才弄明白。

2.3 从协议参数到FPGA设计约束

把协议参数映射到FPGA设计之前,先确定系统时钟。EP4CE10开发板最常见的时钟是50MHz,周期20ns。如果直接用50MHz计数产生18ms延时,需要数到900000,用一个20位计数器刚好能覆盖。但更推荐的做法是先用系统时钟产生一个1us的时钟使能脉冲,再基于这个脉冲做毫秒级和微秒级延时。代码结构会更清晰,也方便后续做通用化封装。

协议里有两个时间点需要格外小心。一个是起始信号的18ms,它决定DHT11是否被唤醒;另一个是数据位中26到28us和70us的区分,它决定每个数据位解析成0还是1。如果采用“延时一半再采样”的方法判断数据位,采样点取在40us附近比较合适,因为0的高电平在28us左右已经结束,而1的高电平能持续到70us附近,40us正好能区分两者。更稳妥的做法是直接测量高电平宽度——用计数器统计从上升沿到下降沿的时长,再根据阈值判断0或1。两种方案我都在后面代码部分展开。

另一个约束是读取频率。DHT11内部实际测量周期约2秒,如果读取得太频繁,传感器来不及更新数据,返回的是旧值甚至无响应。所以状态机里通常要做一个大于1秒的空闲间隔,或者由外部控制模块每隔1.5秒发起一次读取。这个看起来不起眼的约束,在调试时最容易让人误以为代码逻辑出了问题,实际上等待时间不够而已。

3. Verilog核心实现细节

3.1 模块划分与端口定义

我把这个工程拆成两个模块。顶层模块dht11_top负责端口定义、inout引脚处理和子模块例化;底层的dht11_ctrl实现完整的DHT11时序状态机,数据读取完成后以16位并行数据输出。显示部分直接例化开发板自带的数码管控制器,不属于DHT11驱动的核心内容,这里不做展开。

顶层模块的Verilog代码:

module dht11_top( input clk_50m, input rst_n, inout dht11_data, output [5:0] seg_sel, output [7:0] seg_data ); wire [15:0] temp_humi_data; wire data_valid; dht11_ctrl u_dht11( .clk_50m (clk_50m), .rst_n (rst_n), .dht11_data (dht11_data), .temp_humi_data (temp_humi_data), .data_valid (data_valid) ); // 数码管显示模块例化省略 endmodule

dht11_ctrl模块对外输出的16位数据,高8位存温度整数,低8位存湿度整数,显示模块拿到之后直接做BCD转换就能显示。data_valid信号用来通知下游这组数据已经更新,下游模块可以用它做锁存,避免在数据更新过程中读到半个新值半个旧值。

3.2 状态机怎么设计

DHT11驱动状态机的核心任务,是把上一节的时序流程映射成离散的状态跳转。我定义的状态如下:

localparam ST_IDLE = 4'd0; localparam ST_START_LOW = 4'd1; localparam ST_START_HIGH = 4'd2; localparam ST_RESP_LOW = 4'd3; localparam ST_RESP_HIGH = 4'd4; localparam ST_READ_DATA = 4'd5; localparam ST_CHECK = 4'd6; localparam ST_DONE = 4'd7;

ST_IDLE状态做两件事:一是等待读取触发信号,二是做一段足够长的空闲延时,确保DHT11完成内部测量。ST_START_LOW把总线拉低20ms,ST_START_HIGH释放总线并等待20到40us。ST_RESP_LOW和ST_RESP_HIGH分别对应检测DHT11响应信号的拉低、拉高阶段。ST_READ_DATA进入40位数据的逐位读取循环,ST_CHECK对收到的40位数据做校验和验证,ST_DONE把数据锁存到输出寄存器。

设计原则是:每个状态只做“拉电平”、“等时间”、“采样数据”中的一件事,时间一到立刻跳转。如果某个状态承担了过多职责,代码写起来容易乱,后面调试也难定位。想让状态机一次跑通,基本靠这条原则撑着。

3.3 双向IO处理与微秒计数器

DHT11的data引脚在Verilog里必须声明为inout。FPGA内部一般用一个方向寄存器dir_reg动态选择输出或高阻,代码写法如下:

reg dir_reg; assign dht11_data = dir_reg ? 1'b0 : 1'bz; assign dht11_in = dht11_data;

dir_reg为1时,FPGA主动把总线拉低,用于发送起始信号;dir_reg为0时,FPGA释放总线,dht11_data变成高阻z,外部上拉电阻把总线拉回高电平。此时DHT11可以继续拉低或释放总线,FPGA通过dht11_in读取当前电平。注意inout引脚在FPGA内部不能直接接到普通逻辑,必须先经过三态缓冲再接到内部信号,上面两行就是标准的三态缓冲处理方式。

1us计时器是整套延时的基础。50MHz时钟下,计数50次就是1us,写一个使能脉冲:

reg [5:0] cnt_us; wire us_pulse; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin cnt_us <= 6'd0; us_pulse <= 1'b0; end else if (cnt_us == 6'd49) begin cnt_us <= 6'd0; us_pulse <= 1'b1; end else begin cnt_us <= cnt_us + 1'b1; us_pulse <= 1'b0; end end

有了us_pulse之后,毫秒级延时用另一个16位计数器实现。每次us_pulse到来加1,计数值到20000就代表20ms。16位计数器最大能计65535us,覆盖DHT11起始信号所需的20000us绰绰有余。ST_IDLE的空闲间隔需要1.5秒左右,这个用18位计数器,计到1500000即可。

3.4 数据位读取的两种方案

读取40位数据是整个驱动里最容易出错的部分。我实测过两种方案:定时采样法和高电平测宽法。

定时采样法逻辑简单——在每一位数据起始时等待50us低电平结束,检测到上升沿后延时30到40us,然后在总线还在高电平区间时做一次采样。采到高电平就是1,总线已经回到低电平就是0。做法直观,但缺点在于采样点是否合适完全取决于DHT11的时序一致性。我手头有一批DHT11模块,位0的高电平有时会超过30us,在40us采样点处总线还处于高电平,0被误判成1,导致校验和错误频出。

高电平测宽法的思路不同:在数据位上升沿启动计数器,下降沿锁存计数值,直接测出高电平持续时间,再根据阈值判断0或1。代码比定时采样法多十几行,但对时序的容错能力强很多。我最终用测宽法,换了几批DHT11都没再出现误判。核心逻辑示意:

// 在数据位检测状态中 if (bit_start) begin width_cnt <= 0; end else if (dht11_in) begin width_cnt <= width_cnt + 1; end else begin if (width_cnt > 40) // 高电平超过40us判为1 r_shift_reg <= {r_shift_reg[38:0], 1'b1}; else if (width_cnt > 0) r_shift_reg <= {r_shift_reg[38:0], 1'b0}; end

这里的width_cnt以us为单位,40是0和1的判定阈值。因为位0高电平26到28us,位1高电平大约70us,阈值设在40us位置能稳定区分。实际代码里width_cnt的位宽要留够,至少8位,因为70us需要计到70。

3.5 校验、负温度处理和输出锁存

40位数据全部收完后,把移位寄存器拆成五个字节,前四个字节相加后跟校验字节比较。校验通过就把结果打一拍输出;校验失败则丢弃这组数据,状态机回到IDLE等待下一次读取。

wire [7:0] humi_int = r_shift_reg[39:32]; wire [7:0] humi_dec = r_shift_reg[31:24]; wire [7:0] temp_int = r_shift_reg[23:16]; wire [7:0] temp_dec = r_shift_reg[15:8]; wire [7:0] check_sum = r_shift_reg[7:0]; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) temp_humi_data <= 16'd0; else if (check_pass) temp_humi_data <= {temp_int[6:0], humi_int}; end

这里输出时把temp_int的最高位去掉了,因为如果DHT11返回负数温度,最高位是符号位,表示数值的部分只有低7位。实际显示模块再做一次判断,如果原始temp_int最高位为1,就在最前面加一个负号。十六位数据里高8位是去符号后的温度,低8位是湿度,方便后续处理。

4. 仿真验证不可跳过

4.1 用testbench模拟DHT11行为

FPGA开发里仿真这一步不能省,外设驱动更是如此。没有仿真就第一次上板跑时序逻辑,一旦出错,你根本分不清是FPGA代码问题还是硬件接线问题。我先在testbench里用行为模型模拟DHT11的时序,把FPGA侧的状态机整个跑通。

testbench的DHT11模型分两部分:响应起始信号和发送40位数据。响应起始信号的逻辑是:检测到FPGA拉低总线超过18ms后释放总线,再过20us左右,模型把总线拉低80us再拉高80us,模拟DHT11的响应脉冲。数据发送按每个数据位50us低电平加对应高电平的方式循环40次。

DHT11模型的核心结构:

task send_bit(input bit val); begin dht11_data = 1'b0; // 拉低50us #50; dht11_data = 1'bz; #(val ? 70 : 26); // 高电平持续时间 end endtask

把数据位封装成task之后,在循环里逐位调用就行。testbench里可以预设一组温湿度值,比如湿度45%RH、温度26度,然后把对应的40位数据按顺序送到总线上,验证FPGA模块能否正确解析出这组数据。

4.2 仿真里最容易踩的两个坑

第一个坑是双向IO的仿真模型跟真实器件差异。真实DHT11靠开漏输出拉低总线,释放后由上拉电阻拉高,所以模型里拉低用dht11_data = 1'b0,释放用dht11_data = 1'bz。如果把释放写成了1'b1,仿真结果看起来一切正常,但真实器件根本不会主动输出强高电平,上板立刻翻车。看testbench代码时要特别注意,凡是模拟开漏器件,释放一定要写高阻。

第二个坑是仿真时间太长。起始信号20ms加上数据位传输,一次完整握手大约22ms,单次仿真不算长。但如果testbench里还加了个1.5秒的空闲延时,那仿真时间就直奔分钟级去了。解决方法是把延时参数做成parameter或define,仿真时改小到1ms,综合前改回1.5秒。也可以专门加一个sim_mode引脚,仿真时把内部计数阈值统一除以1000。

4.3 仿真波形关注哪些信号

跑完仿真后,wave窗口里重点检查这几点:起始信号释放后是否检测到了上升沿,DHT11响应信号的低电平时长是否在80us附近,40位数据是否按预期出现在移位寄存器里,校验信号是否按时拉高。把dht11_data、current_state、shift_reg这几个信号拖到波形栏,就能直观看到状态机每一步跳转和数据位的组装过程。

把状态和数据线跳变对应起来看,是学习状态机设计最直观的方式。ST_START_LOW状态下dht11_data持续拉低20ms,切到ST_START_HIGH后变成高阻z,再等到DHT11拉低响应,状态机进入ST_RESP_LOW。整个流程和协议时序图完全一致。仿真通过后再上板,心里就有底了。

5. 上板调试与实测记录

5.1 硬件连接和引脚分配

上板前先把硬件连接确认一遍。DHT11的VCC接3.3V,GND接GND,DATA接FPGA的某个IO脚。建议在DATA和VCC之间加一个4.7k上拉电阻,成品模块很多内置了上拉,但我还是习惯自己飞一个,省得在不同模块之间换来换去时出问题。FPGA侧在Quartus的Pin Planner里要把对应引脚电平标准设置成3.3V-LVTTL或3.3V-LVCMOS,和DHT11的3.3V电平匹配。

上电之后,先用示波器或逻辑分析仪看DATA线的空闲电平,正常情况下应该被上拉到3.3V高电平。如果空闲电平异常,先查接线和上拉电阻,别急着怀疑代码。等确认数据线空闲电平正常了,再下载bitstream测试,这样能省下不少排查时间。

5.2 首次上板最常出现的现象

第一次上板最常见的现象是数据一直是0xFF或者固定不变。这类问题八成出在起始信号上。用逻辑分析仪抓FPGA输出端的波形,看是否真的拉低了20ms。如果拉低时间不够18ms,DHT11根本不会被唤醒,自然不会有响应。我之前就在这个参数上栽过——起始信号只用了10000us而不是20000us,结果数据读不到,改成20000us之后一次就通了。

另一个常见现象是读到的数据校验和错误。这种情况基本出在数据位采样上。前面讲过,定时采样法很容易卡在0和1的边界误判,尤其DHT11时序一致性差一些时。解决方式就是把数据位读取改成高电平测宽法,或者在定时采样时把采样点从40us往后挪到50us附近。实测下来测宽法最稳定,换了几个不同批次的DHT11都没再出现误判。

5.3 实测数据稳定性处理

工程调通后,我连续跑了一整晚,每隔1.5秒采集一次温湿度数据,观察长时间运行的稳定性。结果发现偶发跳变仍然存在,比如湿度从45%突然跳到70%又跳回来。单独看校验逻辑每次都通过,说明数据本身没有传输出错,更像是DHT11读数偶发异常。

后来我在状态机里加了一个简单的滤波:连续读取三次,取中间值作为有效数据输出。具体做法是保存最近三次有效测量结果,排序后取中间值送出。加了这个逻辑之后,跳变现象基本消失。虽然DHT11精度本身有限,但这种软件滤波思路在FPGA内部做成本很低,不占用额外CPU资源,值得保留。

5.4 常见问题速查表

现象可能原因解决思路
数据全部为0xFFDHT11未响应检查起始信号是否大于18ms、上电后等待时间是否足够、上拉电阻是否接好
数据全部为0x00总线一直为低检查IO方向控制,确认FPGA是否一直处于输出拉低状态
偶发校验错误数据位采样点不准改用高电平测宽法,增强对时序偏差的容忍度
数据能读但周期跳变DHT11自身测量误差连续采样取中值滤波
温度显示一百多度负温度符号位没处理检查温度整数最高位

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询