简介:面向FPGA学习者的DHT11温湿度传感器读写完整工程,基于Cyclone IV E系列EP4CE10F17C8器件,Quartus 18.0环境开发,实现单总线时序驱动、按键切换温度/湿度显示以及数码管动态扫描。工程采用模块化设计,顶层top_dht11例化了dht11_drive、key_debounce、dht11_key和seg_led四个子模块,代码注释清晰,便于理解单总线协议、状态机跳转和动态显示原理,适合FPGA入门者作为综合练习,也适合工程师快速移植复用。资源包共289个文件,主体为Quartus工程及编译中间文件,含5个Verilog源文件(.v)、大量tdf工程文件以及qpf、qsf等工程配置文件,另有编译报告与配置文件可供参考,压缩包大小6.19MB。目前已有1369人学习下载。获取后可在Quartus 18.0中直接打开重建工程,结合EP4CE10开发板验证温湿度采集,通过按键切换显示当前湿度或温度,能有效缩短DHT11时序调试时间。
1. 从零写DHT11的Verilog驱动:一次把单总线时序吃透
DHT11温湿度传感器在嵌入式圈子里几乎人人用过,但大多数人用的是STM32的HAL库或者Arduino库,到FPGA上就换了一套玩法。这个Quartus工程把DHT11的完整读写流程用纯Verilog实现了一遍,目标平台是Cyclone IV E的EP4CE10F17C8,配套的数码管显示模块和按键切换逻辑也一应俱全。对于想理解单总线协议本质、或者需要在FPGA上对接廉价温湿度传感器的工程师来说,这套源码的价值在于:启动信号、响应判断、40位数据读取的每一个tick都有明确的计数约束,而不是靠延时函数蒙混过关。下文按模块拆解读写时序、按键消抖和数码管动态扫描方案,并结合Quartus 18.0实际编译环境,把引脚约束与调试手段一并说清楚。
2. DHT11单总线时序拆解与Verilog驱动状态机实现
2.1 单总线协议的时间参数与读数据模型
DHT11只有一根数据线,主机和传感器之间靠高低电平的持续时间传递信息。主机发起通信时先拉低总线至少18ms,然后释放,由外部上拉电阻把总线拉回高电平。传感器检测到这个跳变序列后,输出80us低电平和80us高电平作为应答,随后发送40位数据。每一位都以50us低电平开始,如果后面跟26~28us高电平表示逻辑0,跟70us左右高电平表示逻辑1。单总线读时序的难点不在理解这几个数字,而在于FPGA的时钟是连续运行的,你不能像单片机那样靠delay函数精确等一个脉冲,必须把“在哪个时间点采样”换算成计数器边界。
提示:DHT11规格书中给出的时间参数允许一定偏差,实际驱动里只要保证拉低时间超过18ms、采样点落在位周期中段,就能可靠读取。
在这个工程里,系统时钟是50MHz,对应周期20ns。1us的延时就是50个时钟周期,1ms就是50000个周期。dht11_drive模块用一个大计数器配合状态机推进,把ms级启动、us级应答和位级采样全部落在同一个计数基准上,避免多个模块各写一套延时逻辑导致时序漂移。
2.2 dht11_drive模块的状态机划分与三态IO处理
驱动模块的状态机分为五个阶段:空闲、拉低启动、释放等待应答、读取40位数据、输出完成。每个状态内部都有一个独立计数器,状态跳转条件根据计数器和总线电平共同决定。先看模块的端口和三态IO骨架,这是整个驱动的基础:
module dht11_drive #( parameter CNT_1US = 6'd50, // 50MHz时钟下1us计数 parameter CNT_1MS = 16'd50_000 // 50MHz时钟下1ms计数 )( input sys_clk, input rst_n, inout dht11, output reg [31:0] data_valid ); localparam IDLE = 5'b0_0001; localparam START_LOW = 5'b0_0010; localparam WAIT_RESP = 5'b0_0100; localparam READ_BIT = 5'b0_1000; localparam DONE = 5'b1_0000; reg [4:0] state; reg [31:0] cnt; reg [5:0] bit_cnt; reg [39:0] data_buf; reg dht11_out; reg dht11_oe; assign dht11 = dht11_oe ? dht11_out : 1'bz;dht11_oe是输出使能信号,高电平时FPGA主动驱动总线,低电平时总线释放为高阻态。inout端口如果漏掉1'bz分支,综合器会把它当作普通输出端口处理,读方向永远拿不到传感器拉低电平的数据,这是单总线在FPGA上最常见的错误之一。还要注意dht11_out在释放总线期间要保持1,否则释放瞬间总线会被瞬时拉低,产生一个假起始沿。
2.3 启动与应答阶段的计数设定
启动阶段要把总线在START_LOW状态保持20ms,也就是从IDLE进入后,计数器从0数到CNT_1MS的40倍。释放总线后进入WAIT_RESP状态,此时开始检测下降沿。这里有一个工程细节:总线释放后,FPGA引脚输入的电平不是立刻稳定的,需要在模块入口加两级寄存器同步,既消除亚稳态,也避免在电平翻转的当拍误判边沿。
reg [1:0] dht11_sync; wire dht11_in; assign dht11_in = dht11_sync[1]; always @(posedge sys_clk or negedge rst_n) begin if (!rst_n) begin dht11_sync <= 2'b00; end else begin dht11_sync[0] <= dht11; dht11_sync[1] <= dht11_sync[0]; end end检测到应答低电平后,计数器清零,等80us后再采一次电平,确认应答信号的低电平宽度符合规格。如果宽度偏差超过20us,说明传感器没有正常启动,状态机要回到IDLE重新发起通信,而不是继续读位。这个超时判断在真实硬件上很有用,DHT11上电后需要1秒稳定时间,如果FPGA配置完成后立刻读,传感器很可能不响应。
2.4 40位数据逐位采样与校验和验证
进入READ_BIT状态后,bit_cnt从0到39循环。每一位的流程是:检测到上升沿(即50us低电平结束)后,计数器清零,在40us处采样总线电平。采样点定在40us而不是高电平结束再判断,是因为逻辑0的高电平只有26~28us,逻辑1的高电平约70us,在40us处逻辑0已经回到低电平,逻辑1还在高电平,两者区分度最大。
数据位采完后拼成40bit的临时缓冲,再按字节拆分。DHT11的数据格式是湿度整数、湿度小数、温度整数、温度小数、校验和,每字节8位,高位在前。校验和等于前四个字节相加取低8位,等校验通过再把数据搬运到data_valid输出:
wire [7:0] check_sum = data_buf[39:32] + data_buf[31:24] + data_buf[23:16] + data_buf[15:8]; always @(posedge sys_clk or negedge rst_n) begin if (!rst_n) data_valid <= 32'd0; else if (state == READ_BIT && bit_cnt == 6'd40) begin if (check_sum == data_buf[7:0]) begin data_valid[31:24] <= data_buf[39:32]; // 湿度整数 data_valid[23:16] <= data_buf[31:24]; // 湿度小数 data_valid[15:8] <= data_buf[23:16]; // 温度整数 data_valid[7:0] <= data_buf[15:8]; // 温度小数 end end end对比一下常见误用:有人直接把40位数据不加校验就送显示,一旦总线有毛刺,显示值就会乱跳。校验和能过滤掉大部分单bit翻转错误,但如果是接线不良导致的连续多位错误,校验失败后应该保留上一次正确的数据,而不是把0值塞进显示模块。我在驱动里加了一个有效标志,只有校验通过才刷新data_valid,思路同样适用于其他单总线传感器。
3. 按键消抖与温湿度显示切换的Verilog实现
3.1 机械按键的抖动模型与消抖策略选型
DHT11驱动读回来的数据同时包含湿度和温度,板上只有一个按键用来切换数码管显示内容。机械按键按下和释放时,触点会经历数ms的不稳定弹跳,直接采样会在一个按键动作里产生多个上升沿或下降沿,显示状态就会连续切换好几次。工程里的key_debounce模块采用“持续低电平计数”方案:检测到下降沿后开始计数,只有当低电平持续超过20ms才判定为一次有效按下,否则认为是一次抖动并把计数器清零。
选型上还可以用状态机消抖和移位寄存器消抖,但这个单按键场景里,计数方案的代码量最少,时序也直观。20ms的阈值根据常见轻触开关数据手册而定,实际如果按键老化,可以调到50ms,代价是按键响应延迟变大,对切换显示这种操作完全感知不到。
3.2 key_debounce模块实现与参数说明
module key_debounce #( parameter CNT_MAX = 20'd999_999 // 50MHz时钟下20ms )( input sys_clk, input sys_rst_n, input key, output reg key_flag, // 有效按下标志,单周期脉冲 output reg key_value // 消抖后的按键电平 ); reg [19:0] cnt; reg [1:0] key_sync; always @(posedge sys_clk or negedge sys_rst_n) begin if (!sys_rst_n) key_sync <= 2'b11; else begin key_sync[0] <= key; key_sync[1] <= key_sync[0]; end end wire key_negedge = key_sync[1] & ~key_sync[0]; always @(posedge sys_clk or negedge sys_rst_n) begin if (!sys_rst_n) begin cnt <= 20'b0; key_flag <= 1'b0; key_value <= 1'b1; end else if (!key_sync[0]) begin if (cnt == CNT_MAX) begin key_flag <= 1'b1; key_value <= 1'b0; end else begin cnt <= cnt + 1'b1; key_flag <= 1'b0; end end else begin cnt <= 20'b0; key_flag <= 1'b0; key_value <= 1'b1; end end endmodulekey_sync两级寄存器把外部异步信号同步到系统时钟域,同时通过组合逻辑产生key_negedge下降沿脉冲。按下期间key_flag只在计数达到20ms的那一拍拉高一次,不会持续拉高,下游的dht11_key模块用key_flag做触发就不会误触发。key_value在按键释放后恢复高电平,反映按键当前的稳定状态。
这里有个细节:CNT_MAX取值999999对应50MHz时钟下的20ms,如果把工程移植到25MHz或100MHz时钟下,这个参数必须同步调整,否则消抖时间会变成10ms或40ms。常见错误是改时钟不改参数,导致按键偶尔失灵。
3.3 dht11_key模块的数据选择与符号处理
dht11_key模块把data_valid的32位数据拆开,默认显示湿度,按一次按键切换到温度,再按切回湿度。切换信号用key_flag作为触发,而不是直接拿key_value做组合选择,否则按键按住不放时显示内容会反复跳动。
温度数据存在负数场景,DHT11温度低于0℃时,温度整数部分的最高位为1。数码管显示模块如果不处理符号位,会把负温度显示成很大的正数。工程里单独引出了sign信号,在dht11_key中把温度整数最高位解析出来:
reg temp_humi_sel; always @(posedge sys_clk or negedge sys_rst_n) begin if (!sys_rst_n) temp_humi_sel <= 1'b0; // 默认显示湿度 else if (key_flag) temp_humi_sel <= ~temp_humi_sel; end always @(*) begin if (!temp_humi_sel) begin // 湿度显示:整数部分在高16位,小数部分在低16位 data[19:16] = 4'h0; data[15:12] = data_valid[31:28]; // 湿度十位 data[11:8] = data_valid[27:24]; // 湿度个位 data[7:4] = data_valid[23:20]; // 湿度十分位 data[3:0] = data_valid[19:16]; // 湿度百分位 point = 6'b001000; sign = 1'b0; end else begin // 温度显示:同样拆成4位BCD码 data[19:16] = 4'h0; data[15:12] = data_valid[15:12]; data[11:8] = data_valid[11:8]; data[7:4] = data_valid[7:4]; data[3:0] = data_valid[3:0]; point = 6'b001000; sign = data_valid[15]; // 负数标志 end enddata位宽是20位,正好对应五位数码管,再加上符号位和单位指示,六位数码管刚好够用。实际工程中建议先把字节拆成4位BCD码再送显示,如果直接用二进制数做段码映射,译码逻辑会很绕。point信号用来点亮需要的小数点位,DHT11的湿度精度是1%,温度精度也是1%,小数点后一位就够,第二位在数码管上保留或者熄灭都可以。
4. 动态数码管显示与Quartus工程顶层集成
4.1 动态扫描显示原理与刷新频率计算
六位数码管动态扫描的原理是利用人眼视觉暂留效应,同一时刻只点亮一位,按固定频率循环扫描各位,当刷新频率高于50Hz时,人眼看到的就是所有位同时亮起。工程里设计刷新频率约1kHz,每位停留1ms,扫描一轮6ms,对应166Hz刷新率,不存在闪烁感。seg_led模块内部用计数器产生位选信号sel,每隔1ms切换一次,同时根据当前位号从data里取出对应的BCD码,经过七段译码后输出到seg_led。
选择1ms作为位切换周期是实践中的折中:太短则每个数码管点亮时间不足,亮度偏低;太长则肉眼能分辨出扫描过程,出现拖影。如果你的开发板用的是大尺寸数码管,发光效率低,可以适当延长到2ms,刷新率仍有83Hz,人眼依然感觉不到闪烁。
4.2 seg_led模块的译码与位选实现
seg_led内部包含一个BCD到七段码的译码逻辑,以及动态扫描的位选计数。代码骨架如下:
module seg_led ( input clk, input rst_n, input [19:0] data, // BCD码,每4位表示一位数字 input [5:0] point, // 小数点位置 input en, input sign, output reg [5:0] seg_sel, // 位选 output reg [7:0] seg_led // 段选,含小数点 ); reg [15:0] cnt_scan; reg [5:0] scan_pos; always @(posedge clk or negedge rst_n) begin if (!rst_n) cnt_scan <= 16'b0; else if (cnt_scan == 16'd49_999) begin cnt_scan <= 16'b0; scan_pos <= (scan_pos == 6'b100000) ? 6'b000001 : {scan_pos[4:0], 1'b0}; end else cnt_scan <= cnt_scan + 1'b1; end计数到49999后清零并移位位选信号,每一位点亮时间为1ms。后面的组合逻辑根据scan_pos选中数码管,并从data里取出当前位要显示的4位BCD码。BCD码到段码的映射用case语句把0~9各自对应的seg_led列出来,比如共阴极数码管0对应8'h3f,1对应8'h06;如果换成共阳极,段码要按位取反,这个必须和开发板原理图保持一致,否则上板显示全是乱码。
4.3 Quartus 18.0工程文件组织与引脚约束
整个工程在Quartus 18.0里编译,目标器件是Cyclone IV E系列的EP4CE10F17C8。工程根目录下除了顶层v文件和各个模块的.v源文件,还有带.bak后缀的备份文件和.ammdb、.bpm这类Quartus自动生成的中间文件。.qsf文件记录器件型号、引脚分配和综合选项,.sdc时序约束文件约束时钟。从压缩包解压后直接打开.qpf工程文件即可,但如果换了一张同型号开发板,必须重新核对引脚分配。
下面是一份典型的引脚分配表,具体网络号要以开发板原理图为准:
| 信号名 | FPGA引脚 | 电平标准 | 说明 |
|---|---|---|---|
| sys_clk | PIN_E1 | 3.3-V LVTTL | 50MHz有源晶振 |
| sys_rst_n | PIN_E16 | 3.3-V LVTTL | 复位按键 |
| dht11 | PIN_A14 | 3.3-V LVTTL | DHT11数据线,需外接4.7k上拉 |
| key | PIN_B14 | 3.3-V LVTTL | 切换显示按键 |
| sel[5:0] | PIN_C1 ~ PIN_C6 | 3.3-V LVTTL | 数码管位选 |
| seg_led[7:0] | PIN_D1 ~ PIN_D8 | 3.3-V LVTTL | 数码管段选 |
DHT11的数据线必须接一个4.7k~10k的上拉电阻,这是单总线通信的基础。如果开发板上已有上拉,直接接GPIO即可;如果自己搭电路,上拉电阻接到3.3V,EP4CE10的普通IO是3.3V电平,直接与3.3V供电的DHT11模块相连,不需要电平转换。
4.4 顶层模块例化与信号互联
顶层top_dht11把四个子模块例化并连接起来。文件列表里每个模块都有对应的.v源文件,.bak后缀是编辑器备份,不影响编译。顶层的关键是data_valid、data、point等信号的位宽和连接关系一一对应,任何一位连接错误在仿真里很难发现,但上板后显示数据会错位。
从项目里摘出来的顶层连接关系就是文章开头那段代码,这里不再重复贴整个文件。需要注意dht11这个端口在顶层仍然声明为inout,子模块的inout和顶层端口连接不需要额外处理,但编译后要在Pin Planner里确认dht11引脚被设置为双向引脚,否则FPGA内部的三态缓冲不会生效。
5. SignalTap实测DHT11时序的三个排错技巧
5.1 用SignalTap II抓取总线波形
代码编译烧录后如果数码管没有反应,第一步不是改代码,而是用Quartus自带的SignalTap II逻辑分析仪抓取dht11总线和data_valid。在SignalTap配置里添加dht11_sync、state、bit_cnt三个内部信号,采样时钟选50MHz系统时钟,触发条件设为state == READ_BIT。这样可以清楚看到传感器是否回了应答、每一位高电平时长是否在规格内。
如果state在WAIT_RESP和IDLE之间反复跳,说明传感器一直不应答,优先查上拉电阻和接线;如果state能进入READ_BIT但bit_cnt卡在某个值不动,说明某一位低电平没有按时结束,通常是信号线接触不良或干扰过大。
5.2 上电时序与首帧失败的处理
DHT11上电后需要1秒稳定时间,之后才能接受主机指令。很多人在FPGA配置完成后立刻发起读取,结果第一帧永远是失败的。可以在dht11_drive的IDLE状态加一个上电延时计数,等cnt计数到1秒后再进入启动状态。处理后上电到数码管出现稳定数值时间会延长一点,但不再出现首帧失败。
5.3 多帧校验与波形核对
建议连续读三帧,至少两帧校验通过才更新显示,校验失败时保留上一帧正确数值。这个方法成本很低,但能显著减少显示跳动。如果手头有示波器,可以直接观测dht11引脚波形,对照协议时间参数逐项核对,定位是主机拉低时间不够还是传感器应答异常。SignalTap抓取深度默认1K采样点,抓完40位数据大约需要100us,配置成2K深度就够了。
DHT11本身精度不高,±2℃和±5%RH的误差在多数室内监测场景够用。后续要接更精确的SHT30或AHT21,这套驱动框架稍作修改就能复用,核心的inout三态控制和状态机划分思路是相通的。
本文还有配套的精品资源,点击获取