ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FPGA驱动DHT11温湿度传感器:Verilog状态机与单总线时序解析

FPGA驱动DHT11温湿度传感器:Verilog状态机与单总线时序解析 1. 为什么FPGA驱动DHT11看着简单翻车率却出奇的高DHT11这颗传感器在单片机圈子里几乎人手一片STM32、Arduino随便配个库就能读代码量还不到几十行。但你一旦把平台换成FPGA情况立马不一样——网上一搜FPGA DHT11 Verilog问的最多的不是怎么写而是为什么上板读出来全是0xFF、为什么状态机卡死不动、为什么仿真好好的接上真实传感器就废了。先说结论DHT11的驱动难点从来不在协议本身的复杂度而在于你用什么视角去看这条单总线。单片机里你用延时函数加GPIO翻转就能凑合但在FPGA里没有延时函数这个概念所有时序都靠时钟周期去数所有信号跳变都靠状态机去管。换句话说从C语言思维切到硬件描述语言思维这个坎才是真正卡住大部分人的地方。这篇文章要讲的就是一套我调通的、基于Verilog的DHT11完整驱动方案。从协议拆解、状态机设计、计数器时序到仿真时怎么模拟从机行为、上板后踩过的那些坑一条线捋下来。适合刚入门FPGA、准备做环境监测类小项目的朋友参考就算你用的是Altera还是Xilinx平台里面的思路和代码结构都能直接搬。我在写这套驱动之前看到一个很典型的说法DHT11驱动就是三段式状态机加几个计数器没什么难的。这句话对了一半——确实就是状态机加计数器但没什么难的这四个字只有你把时序图画到纳秒级、把每一个计数值换算到时钟周期之后才有资格说出口。2. 先把DHT11的脾气摸清楚单总线的时序本质2.1 这根线到底怎么回事DHT11用的是一种被称为单总线1-Wire的通信方式但和Maxim那套标准1-Wire协议不同DHT11走的是它自己的一套简化时序或者说更像一种自定义的单线双向串行协议。之所以很多新手栽在这里是因为这根线同时承担了主机发送启动信号、从机回应、从机传输数据三个功能而且方向会随时切换。用一个不太严谨但很好理解的类比单总线就像一条只容一辆车通过的窄巷子。主机先按喇叭拉低电平告知从机我要通信了然后退到路边等回应从机听到后回两声喇叭应答信号然后开始倒车出来输出数据数据是一个BIT一个BIT地从巷子里倒出来的主机只能在旁边看着不能插嘴。整个过程里谁在开车、谁在等待是由电平状态和时序窗口共同决定的。具体到DHT11的规格整条时序可以拆成四个阶段主机启动信号主机将总线拉低至少18ms然后释放拉高告诉DHT11准备就绪。从机应答信号DHT11检测到起始信号后先拉低约80us再拉高约80us作为响应。数据输出阶段连续输出40bit数据高位在前包括湿度整数、湿度小数、温度整数、温度小数、校验和。结束阶段从机释放总线恢复空闲高电平。这40个bit就是整个驱动的核心价值所在。每个bit的0和1不是靠电平高低区分的而是靠高电平持续的时间来区分。让我把这一句重复一遍因为这是最容易搞混的地方DHT11传输数据时每一位都是从拉低总线开始的拉低的时间通常是50us关键区别在高电平的持续时间——26到28us表示070us左右表示1。2.2 为什么FPGA比单片机更适合数这个时间用单片机读DHT11延时函数可以直接用us级的阻塞延时读引脚电平变化靠查询或者外部中断。但单片机有个天然劣势它的主频和指令周期或者RTOS的任务调度决定了延时精度存在不确定性尤其是中断嵌套、任务切换频繁时很容易把26us和70us看混。FPGA不一样。FPGA的一切动作都和全局时钟同步只要你的时钟源稳定比如50MHz晶振一个时钟周期就是固定的20ns要数50us就是2500个周期数70us就是3500个周期。这种确定性是硬件电路天然带来的不依赖任何操作系统也不被中断打扰。我用一张表把这几个关键时间参数和对应的50MHz时钟周期数列出来方便你后面写计数器时直接对表时序事件时间要求50MHz下周期数主机启动拉低≥18ms≥900000主机释放总线20~40us1000~2000从机应答拉低80us典型4000从机应答拉高80us典型4000数据位0高电平26~28us1300~1400数据位1高电平70us典型3500一位数据总时长约100us5000约看到没有位1和位0的高电平时长差了近三倍窗口非常宽裕。理论上你只要在数据位的高电平阶段把时钟周期数一数数到30us以上就判1否则判0完全来得及。但工程实现上肯定不能这么粗糙至少得留出抗干扰的冗余。2.3 上拉电阻和其他硬件前提DHT11的数据引脚是开漏输出结构所以外部电路必须接一个4.7kΩ到10kΩ的上拉电阻到VCC这个电阻决定了总线空闲时的默认电平是高电平。如果你用的是现成的DHT11模块那种四针小板上拉电阻板上已经有了直接连杜邦线就行。但如果你是用裸DHT11焊在洞洞板上上拉电阻是必须自己加的不加的话总线浮空FPGA读到的电平会随机跳变状态机直接乱套。还有一个细节很多人忽略DHT11的供电电压范围是3.3V到5V如果你的FPGA开发板IO是3.3V电平直接给DHT11供3.3V是没问题的不需要电平转换。部分老版本文档说建议5V供电但实测3.3V供电也完全能正常工作接法更安全避免IO口电压不匹配。3. 状态机与计数器的配合Verilog代码逐段拆解3.1 模块端口规划写驱动前先想清楚模块对外的接口长什么样我建议直接按能接到任意上层模块的标准来设计不要为某个特定项目写死。module dht11_ctrl( input clk, // 系统时钟50MHz input rst_n, // 低电平复位 input read_en, // 读取使能上升沿触发一次读取 inout dht11_data, // 单总线数据 output reg [7:0] humi_int, // 湿度整数 output reg [7:0] humi_dec, // 湿度小数 output reg [7:0] temp_int, // 温度整数 output reg [7:0] temp_dec, // 温度小数 output reg data_valid, // 数据有效标志 output reg busy // 忙标志 );read_en是关键它决定了模块的读取节奏。DHT11官方手册明确写了两次读取间隔不得小于1秒这个参数不能省。我在实际项目里是让上层每2秒拉高一次read_en既满足规格要求又不会让数据刷新太频繁导致显示跳动。data_valid信号是给上层看数据可信度的。每次读完40bit之后如果校验和通过置高一个周期校验失败则保持低电平。上层逻辑就靠这个信号判断当前输出的温湿度值能不能用而不是一看到变化的数字就处理。3.2 双计数器结构一个数状态时间一个数位宽整个驱动的逻辑核心是两个计数器并行工作cnt_cycle负责数当前状态下的时钟周期数cnt_bit负责数已经接收了多少个数据位。有人问为什么非要两个计数器一个不行吗答案是不行。状态机在启动信号阶段、应答阶段每个状态下可能需要不同的时间长度用cnt_cycle统一计数最灵活。但到了数据接收阶段每个bit的长度是固定的约100us而且每收完一个bit状态机要回到同一个状态去采样下一个bit的起始沿。这时cnt_bit就派上用场它决定什么时候40bit收满该跳转到完成状态。两个计数器各司其职状态机的跳转逻辑会非常清晰。我写的状态编码如下localparam IDLE 4d0, START_LOW 4d1, // 主机拉低起始 START_REL 4d2, // 主机释放总线等待从机响应 RESP_LOW 4d3, // 从机应答拉低 RESP_HIGH 4d4, // 从机应答拉高 DATA_BIT 4d5, // 数据位采样 DONE 4d6;有朋友可能会问为什么要把从机的应答信号也拆成RESP_LOW和RESP_HIGH两个状态直接在一个状态里等80us的拉低和80us的拉高不行吗理论可行但代码会变得很别扭。因为你得在一个状态里先判断当前电平是高还是低再决定等多久逻辑分支一多就容易乱。拆成两个状态每个状态内只做一件事——要么等拉低结束要么等拉高结束状态机的可读性和可维护性都更好。这就是状态机设计里高内聚、低耦合思想的体现。3.3 启动与应答状态的编码细节启动阶段的任务拉低总线至少18ms然后释放。18ms在50MHz下是90万个周期这个计数值不小用20位的计数器就能存下2^201048576。parameter CNT_18MS 20d900000; // 50MHz下18ms always (posedge clk or negedge rst_n) begin if(!rst_n) begin state IDLE; cnt_cycle 20d0; dht11_data_reg 1b1; end else begin case(state) IDLE: begin if(read_en) state START_LOW; end START_LOW: begin dht11_data_reg 1b0; // 拉低总线 if(cnt_cycle CNT_18MS) state START_REL; else cnt_cycle cnt_cycle 1b1; end START_REL: begin dht11_data_reg 1b1; // 释放总线片上拉至高 if(cnt_cycle 2000) // 40us高电平空闲 state RESP_LOW; else cnt_cycle cnt_cycle 1b1; end // ... endcase end end这里有个关键细节释放总线并不是把FPGA的引脚输出高电平而是把引脚设置成高阻态三态让上拉电阻把电平拉高。但如果你直接用三态门控制代码里处理起来就多一个分支。我的做法是定义两个内部信号dht11_data_reg数据寄存器的输出值dht11_data_oe输出使能信号数据引脚用连续赋值语句实现assign dht11_data dht11_data_oe ? dht11_data_reg : 1bz;当dht11_data_oe为高时FPGA主动驱动总线拉低为低时引脚变成高阻态外部上拉电阻把总线拉到高电平。这样做的好处是从机的应答信号和数据信号不会被FPGA的输出所干扰因为FPGA在高阻态下相当于物理上断开了对总线的驱动。3.4 数据位的采样逻辑如何区分0和1数据接收阶段是整个模块的灵魂。我们从RESP_HIGH状态跳转到DATA_BIT状态后要做的事有两件检测总线从高变低的下降沿每一位数据的起始标志在低电平结束后对高电平的持续时间进行计数判断实际代码里我采用了一种更稳妥的采样方式——检测到每个bit起始的下降沿后等待低电平结束然后在电平拉高后启动一个高电平计时器等计时器达到判定阈值时再去采样当前总线的电平状态。// 假设cnt_high_count是检测到高电平后的计时器 // HIGH_CHECK_US 设为 40us即2000个周期 // 如果40us后总线仍是高电平 - 这一位是1 // 如果40us前总线已经拉低 - 这一位是0 always (posedge clk) begin if(bit_sampling) begin if(cnt_high_count 40us_cycles) begin if(dht11_data_read) begin bit_value 1b1; bit_done 1b1; end else begin bit_value 1b0; bit_done 1b1; end end else cnt_high_count cnt_high_count 1b1; end end实测下来40us作为判定阈值非常可靠。原因是0的高电平只有26~28us1的高电平有70us左右40us正好处在两者正中间留出了超过12us的窗口余量。即使传感器个体差异或线路寄生电容导致波形边沿变缓这个阈值依然能稳定区分。40bit数据全部接收完后还需要做校验。DHT11的校验规则是前四个字节相加如果低8位等于第五个字节则数据有效。always (posedge clk or negedge rst_n) begin if(!rst_n) data_valid 1b0; else if(state DONE) begin if(checksum (humi_int humi_dec temp_int temp_dec)) data_valid 1b1; else data_valid 1b0; end else data_valid 1b0; end3.5 完整状态机跳转逻辑把上面的思路合到一起主状态机的跳转关系可以总结如下当前状态跳转条件下一状态IDLEread_en为高START_LOWSTART_LOW计数满18msSTART_RELSTART_REL计数满40usRESP_LOWRESP_LOW检测到总线拉高从机应答结束RESP_HIGHRESP_HIGH计数满80usDATA_BITDATA_BIT收到40bit数据DONEDONE完成校验busy拉低IDLERESP_LOW状态比较特殊它不像其他状态那样靠计数器超时跳转而是靠检测总线上升沿跳转。因为从机的应答拉低时间在80us左右但有波动你如果固定等80us可能还没等到结束就进下一个状态了。正确的做法是进入RESP_LOW后持续监测电平一旦检测到低电平变高电平立刻跳转到RESP_HIGH。这个上升沿检测也是Verilog里的一个基本功用两级寄存器打拍判断前一拍为低且当前拍为高。reg data_dly0, data_dly1; always (posedge clk) begin data_dly0 dht11_data_read; data_dly1 data_dly0; end assign rising_edge data_dly0 ~data_dly1;4. Testbench仿真让DHT11在ModelSim里活起来4.1 为什么必须写DHT11的行为模型很多人写完RTL代码就急着上板结果发现读回来的数据全是不合理值然后开始怀疑自己的逻辑有问题反反复复改代码、综合、下载浪费时间还不一定找得到原因。正确的做法是先写一个DHT11的behavior model放到testbench里仿真。这个模型模拟的是从机的行为当检测到主机拉低超过18ms后自动回复应答信号然后按预设的温湿度数据逐位发送40个bit。这样你可以完全控制传感器输出的内容把RTL代码的功能验证做到输出值和预期值严格一致再上板实测时剩下的问题就只可能是硬件层面的。4.2 一个可用的DHT11行为模型下面这段代码是我常用的DHT11仿真模型它会根据预设的温度湿度值生成对应的40bit串行数据module dht11_model( inout dht11_data ); reg dht11_out; reg dht11_oe; wire dht11_in; assign dht11_data dht11_oe ? dht11_out : 1bz; assign dht11_in dht11_data; // 预设值湿度25.0%温度26.0℃ reg [7:0] humi_int 8d25; reg [7:0] humi_dec 8d0; reg [7:0] temp_int 8d26; reg [7:0] temp_dec 8d0; reg [7:0] checksum; always * begin checksum humi_int humi_dec temp_int temp_dec; end reg [39:0] data_buf; integer i; // 边沿检测: 检测主机起始信号拉低 reg dht11_in_dly; always (posedge dht11_in or negedge dht11_in) begin dht11_in_dly dht11_in; end wire neg_edge dht11_in_dly ~dht11_in; reg start_low_cnt; reg started; reg sending; // 检测主机拉低持续时间 integer low_count; reg dht11_in_sync0, dht11_in_sync1; always (posedge clk) begin // 假设testbench里有clk dht11_in_sync0 dht11_in; dht11_in_sync1 dht11_in_sync0; end // 简易实现用绝对时间模拟不依赖时钟 // 用一个任务实现应答数据发送 reg dht11_out_r; reg dht11_oe_r; always (posedge neg_edge) begin if(!started) started 1b1; end // 简化直接用initial块模拟 initial begin dht11_oe 1b0; started 1b0; // 等待外部主机拉低总线并持续18ms // 实际仿真中用时间控制 end // 提供一个触发函数 task automatic respond_and_send; begin // 应答信号: 拉低80us dht11_oe 1b1; dht11_out 1b0; #80000; // 拉高80us dht11_out 1b1; #80000; // 发送40bit数据 data_buf {humi_int, humi_dec, temp_int, temp_dec, checksum}; for(i 39; i 0; i i - 1) begin // 每一位: 低50us dht11_out 1b0; #50000; dht11_out 1b1; if(data_buf[i]) #70000; // 1 else #26000; // 0 end dht11_oe 1b0; // 释放总线 end endtask // 监控主机拉低超过18ms就触发应答 // 这个简化为在仿真中手动调用上面的task endmodule实际使用中我一般不在initial块里直接调用task而是写一个监控进程检测到主机起始信号结束后自动调用respond_and_send。但为了篇幅上面这个简化模型的核心思想已经表达清楚了。要点是DHT11模型的输出数据应该在每个测试用例里固定化。比如我们要验证RTL读到的温度是不是26℃湿度是不是25%就把模型里的值设为这两数。仿真跑完后用波形图对比RTL输出的temp_int、humi_int信号值或者直接在testbench里写断言assert自动报错。这样比肉眼盯波形高效得多。4.3 仿真时容易忽略的设置用ModelSim或Vivado Simulator跑DHT11仿真有几个设置必须注意仿真时间精度DHT11的时序最小单位是微秒us但判定0和1的差值在几十微秒级别所以仿真时间精度至少设为1ns/1ps否则波形上的毛刺会被吞掉。建议在testbench文件头加timescale 1ns/1ps。总线上拉因为DHT11数据线是开漏结构仿真时需要给数据线接一个pullup属性。很多人在仿真里看到数据线全是Z状态翻来覆去查代码查不出问题其实就是忘了给sensor总线加pullup。ModelSim里用pullup(dht11_data);或tri1 dht11_data;都可以。我在Vivado里更常用后者声明信号时直接写wire tri1 dht11_data;省得额外例化pullup元件。读写冲突检查仿真时如果FPGA和DHT11模型同时驱动总线会发生双向驱动冲突波形上看到的是红色X态。这时候要检查FPGA的dht11_data_oe信号是否在释放总线高阻态期间正确拉低。最常见的问题是FPGA拉低启动信号结束后dht11_data_oe没有及时变低导致从机应答时总线被FPGA持续拉低无法产生应答波形。我自己在仿真阶段踩过一次启动拉低18ms结束后我以为状态机已经跳到START_REL了但dht11_data_oe信号忘了在START_REL状态下清零导致整个应答阶段总线一直被拉低。仿真波形里从机压根没有机会发出80us低电平。排查了好几个钟头最后用波形视图逐个检查控制信号才发现的。5. 上板实测后的三个高频坑5.1 坑一I/O Bank电压与引脚分配如果你是用Xilinx的板子比如Artix-7系列的开发板DHT11接的引脚必须在3.3V的Bank上且I/O Standard要设置成LVCMOS33。很多人用Vivado的默认引脚约束直接把DHT11接到某个空闲引脚上没注意这个引脚的Bank电压编译倒是能过上板后就是读不到数据。检查方法很简单打开Vivado的Device视图查看分配的引脚所属Bank的VCCO电压是不是3.3V。如果是1.8V或2.5V的Bank会给DHT11一个不匹配的IO标准数据线的高低电平阈值完全不同传感器自然无法正常工作。另外要注意DHT11的数据线建议不要接在支持高速差分信号的引脚上比如某些Bank的MRCC、SRCC时钟引脚相邻的特殊引脚这些引脚往往有额外的端接电阻或电容会影响信号边沿的陡峭程度。5.2 坑二时钟频率与起始拉低时间的矛盾DHT11要求主机拉低总线至少18ms才能触发一次可靠传输。如果你的FPGA跑的是200MHz主时钟18ms就是360万个周期计数器的位宽需要22位2^224194304才够。有些教程里给代码只留了20位计数器按20位最大值算只能数到52ms乍一看也够但如果你修改了分频系数或者时钟频率没对准很容易溢出。更稳妥的做法是在模块内部先把系统时钟分频到1MHz作为一路低速计数时钟专门用于DHT11的时序计数。这样每个周期恰好是1us计数器值直接对应用微秒数写代码时不用再做时钟周期换算出错的概率也大幅降低。代价是多几个触发器资源但对于现代FPGA来说完全无所谓。我在第一篇DHT11驱动里就是直接拿50MHz来数后来换到100MHz平台所有计数值都得乘2每次改代码都怕漏改。重构时改成了分频结构一劳永逸。5.3 坑三实测数据偶发跳变不是逻辑错而是电气问题上板读数据正常但偶尔跳几个数比如温度从26变成255这是很多人调通后最头疼的问题。不用急着改RTL先查一下硬件接线杜邦线是不是太长DHT11模块和FPGA之间超过20cm的杜邦线在单总线这种非差分信号上的信号完整性问题会很明显。尤其在数字电路开关噪声较大的开发板旁边长线上的寄生电感电容会让高电平脉冲宽度抖动刚好卡在0和1的判定边界。供电是否稳定把DHT11的VCC接在FPGA板上的3.3V输出针脚如果那个针脚同时还给其他模块供电比如数码管、LED矩阵电流波动会通过公共阻抗耦合到DHT11的VCC上导致信号电平抖动。建议用万用表实测一下DHT11供电引脚的电压纹波。上拉电阻是否合适模块自带的上拉通常是10kΩ如果线长、寄生电容大10kΩ上拉的充放电时间常数偏大会让信号边沿变缓。这时可以外接一个4.7kΩ的电阻并联加快上升沿速度。如果上述硬件问题都排除了还有一种可能是判断阈值太激进。比如你把0和1的判定阈值设在了28us而DHT11实际输出0高电平为26us加上线路延迟和边沿变缓很容易超过28us被判成1。我的建议是把判定阈值放宽到34us到45us之间取这个区间内的任意值稳定性都足够好。6. 从能读到好用与上层模块的对接经验6.1 数据输出的打拍与同步DHT11的humi_int、temp_int等输出信号是随着状态机跑动而变化的如果在数据未更新完毕时上层模块就把中间值拿去显示或发送会出现一瞬间的乱码。我的做法是只有当data_valid拉高时才将当前输出寄存器的值锁存到上层模块的寄存器中。上层模块不要直接读DHT11驱动的输出寄存器而是把data_valid作为自己寄存器的使能信号。// 上层例化示例 wire [7:0] humi_int; wire [7:0] temp_int; wire data_valid; reg [7:0] display_humi; reg [7:0] display_temp; always (posedge clk) begin if(data_valid) begin display_humi humi_int; display_temp temp_int; end end这个锁存操作看似简单实际是工程里常见的数据域同步思想——把异步的数据更新事件同步到自己的时钟域中避免亚稳态。6.2 数码管显示与BCD码转换DHT11输出的是二进制数而数码管按位显示需要BCD码。如果你把26这个数值直接按位拆分给数码管个位和十位的提取需要做除法取模运算。在FPGA里用组合逻辑做除法和取模资源消耗大且路径会变长。我的做法是在数据锁存后直接做一个二进制转BCD的组合逻辑模块或者更简单在显示模块里用查表法。把十六进制数0~99直接映射到一个二维查找表中表的每一项是两位BCD码资源多不了多少但极大简化了代码。// 温度值到BCD的查表实现 reg [7:0] temp_bcd_hundreds; reg [7:0] temp_bcd_tens; reg [7:0] temp_bcd_ones; always (*) begin case(display_temp) 8d0: {temp_bcd_hundreds, temp_bcd_tens, temp_bcd_ones} 12h000; 8d1: {temp_bcd_hundreds, temp_bcd_tens, temp_bcd_ones} 12h001; // ... default: {temp_bcd_hundreds, temp_bcd_tens, temp_bcd_ones} 12hFFF; // 显示错误 endcase end6.3 串口上传的上位机配合如果你打算把DHT11的数据通过UART上传到PC建议在串口协议里加上帧头、帧尾和CRC校验不要直接裸发原始字节。我在实际项目中用的是最简单的帧格式0xAA 0x55 humi_int humi_dec temp_int temp_dec checksum 0x0D 0x0A。上位机收到后按帧解析出现帧头不匹配就丢弃该字节重新搜索同步头。这种方法虽然老套但可靠。配合DHT11驱动里的校验和判断基本能做到万无一失。有一次我测试连续跑了一个星期总共几十万帧数据没有出现一帧错乱。7. 不算总结的总结这套代码还能往哪去DHT11的温湿度精度在环境监测场景里足够用但如果你需要更高精度SHT30、BME280这些I2C接口的传感器会是下一步升级方向。好消息是你这套状态机计数器单总线的设计思路迁移到I2C或SPI传感器时骨架不用动换掉数据收发那一段逻辑即可。状态机在FPGA里的地位就像函数在C语言里一样理解得越深后面写什么接口都不慌。关于代码组织我最后还想分享一个小习惯每个独立的外设驱动模块顶层例化时统一用前缀区分比如u_dht11_ctrl、u_uart_tx、u_seg_display。这样综合后的网表里每个模块的信号定位一目了然Debug时在Vivado的Schematic视图里点选模块能快速追踪信号路径。我在调DHT11驱动的那几天前后迭代了四个版本。第一版照着网上代码抄仿真都过不了第二版自己理清时序后重写仿真成功第三版上板发现引脚约束问题第四版才把电气问题、数据同步问题全部解决。回过头来看最值钱的不是那几段代码而是把从数据手册的时序图到硬件逻辑这条路完整走了一遍的过程。这种能力一旦建立后面做DS18B20、红外遥控解码、WS2812等单总线协议的外设基本上半天就能搞定一个因为套路是共通的。
返回列表