☰
FPGA实现PM2.5与温湿度采集:UART与单总线状态机设计实战
2026/10/27 7:18:38 网站建设 项目流程

简介:面向FPGA与嵌入式开发者的PM2.5及温湿度传感器系统设计完整工程,基于Verilog语言,以FPGA为核心控制器驱动光散射式PM2.5传感器和DHT系列温湿度传感器,结合ADC数据转换与串行接口通信,最终由LCD1602实时显示环境参数。整套方案覆盖传感器驱动模块、数据解析模块、显示驱动模块及pm_25_top顶层集成框架,适用于空气质量研究、智能家居与工业监控等场景,对理解数字电路定制化设计与时序控制有较强参考价值。资源共1976个文件,压缩包16.49MB,除14个Verilog源文件与大量工程辅助文件外,还包含Quartus系项目的QSF/QPF配置文件、编译生成的CDB/HDB数据文件、SOF下载文件以及readme说明,便于复现与二次开发。目前已有645人学习下载,适合具有基础Verilog与FPGA开发经验的工程师或学生用于验证完整环境监测链路。

1. 为什么有人愿意在 FPGA 上做 PM2.5 与温湿度采集:一个被低估的“组合拳”

如果你只是想在工位上测个空气,树莓派或者 STM32 足够,没人会开一颗 FPGA 来读 DHT11。但把这个标题换成系统设计,场景就完全不同了:环境监测节点要同时处理多路传感器、做滤波、驱动显示、留出报警接口,未来还要并进更复杂的逻辑。FPGA 的优势不在“能不能读传感器”,而在“读传感器的同时,其他逻辑完全不受干扰”——比如一边解析 PMS5003 的串口帧,一边对 PM2.5 数据做滑动窗口滤波,一边刷新数码管,这在单核 MCU 上要靠中断和调度,在 FPGA 里就是三个并行模块各自干活。这篇文章要把这条链路拆开:从传感器选型、UART 与单总线时序,到状态机解析、数据滤波,最后落到调试方法和避坑经验。适合刚跑通 Verilog 基础、想用真实传感器做一个小系统的开发者,也适合做课程设计但不想只停留在点灯层面的人。

2. 系统架构与传感器选型:先定大局,再碰代码

2.1 三种常见的 PM2.5 传感器方案,选型时到底在比什么

PM2.5 传感器在 FPGA 项目里碰到的无非三类。

第一类是模拟输出型,代表型号是夏普 GP2Y1010AU0F。它输出一个与粉尘浓度成正比的模拟电压,必须外接 ADC 采样,再用公式换算。这类方案的好处是数字电路部分极简,但代价是 ADC 采样你需要自己写 SPI 或并行接口逻辑,而且模拟输出容易受电源纹波干扰,通俗说就是“读数飘得离谱”。第二类是 UART 数字输出型,代表是攀藤 PMS5003 和 SDS011。传感器内部自带激光散射和 DSP,直接把 PM1.0、PM2.5、PM10 浓度打包成定长帧从串口发出来,FPGA 这边只需要一个 UART 接收模块加状态机解析。第三类更粗暴,是 PWM 输出型,占空比映射浓度,FPGA 只需要测量高电平脉宽。选型上我的建议很直接:对自己练手,用 Philips 或 SDS011 的 UART 方案最稳。原因是 UART 在 FPGA 里是基础设施,代码可复用、好排查,数据帧自带校验,不用像模拟方案那样反复调 ADC 参考电压。而 PWM 方案测脉宽看似简单,实际受传感器内部时钟漂移影响,精度反而难保证。

2.2 温湿度传感器:DHT11 会让你重新认识“时序敏感”这四个字

温湿度这边最常用的是 DHT11 或 DHT22。两者都是单总线协议,数据脚一根线既当输入又当输出,靠严格的时序脉冲宽度编码“0”和“1”。DHT11 的湿度分辨率只有 1%RH,温度分辨率 1°C,做环境监测足够了,除非你要做实验级精度才上 DHT22 或 SHT30。单总线在 FPGA 里的麻烦在于:时序要求主机拉低起始信号 18ms 以上,然后释放总线,传感器应答,接着在 40 bit 数据里,每一位的“0”和“1”区别不是电平高低,而是高电平持续时间的长短。这要求 Verilog 代码里做微秒级延时,而微秒级延时在一颗 50MHz 主频的 FPGA 上意味着 50 个时钟周期。听起来不难,实际操作时你会发现,任何一次时序偏差,数据位就串了。更麻烦的是,DHT11 对总线操作间隔有要求——读完之后必须等 1 秒以上才能发起下一次读取,否则传感器不应答。所以系统设计里需要一个“读取节奏控制”模块,而不能让 FPGA 眼睁睁地不断重试。

2.3 顶层系统架构:三路数据汇到一个 FIFO,然后分给显示与报警

把传感器确定下来之后,顶层架构基本就是下面这张图。时钟模块提供 50MHz 主频,通过分频产生 UART 需要的 9600 波特率时钟和 DHT11 需要的微秒脉冲计数基础。PMS5003 接收模块做完帧解析,输出 16 bit 的 PM2.5 浓度值;DHT11 控制模块完成单总线时序,输出湿度与温度的 16 bit 原始值。用异步 FIFO 把不同时钟域的读数同步到显示刷新逻辑里,再做一轮均值滤波,最后驱动扫描数码管和报警 LED。为了让系统值得上 FPGA,我把三路输入路径上的关键信号都引到了 ChipScope 或 Logic Analyzer 的 IP 核里,方便在没有物理探头时看内部波形。整个系统做到“采集、解析、滤波、显示、报警”五件事在硬件上同时跑,这就是选 FPGA 而不是 MCU 的核心价值。

3. 读 PMS5003:串口接收与状态机帧解析的完整 Verilog 实现

3.1 为什么不用现成 UART IP,而要自己写接收状态机

PMS5003 的 UART 数据帧长 32 字节,以 0x42 0x4D 开头,第 4、5 字节是高字节和低字节的 PM2.5 浓度值(单位 ug/m³),倒数两字节是校验和。如果在 FPGA 里直接塞一个现成的 UART IP 核,也能收到字节,但问题在于:你要的是“帧”,不是“字节”。如果只是把收到的字节丢给 FIFO 让软件层去拼帧,那就浪费了 FPGA 的并行优势。正确做法是写一个“边收字节边拼帧”的状态机:每个字节到达后立刻判断它是不是帧头、是不是校验正确,一旦出错就丢弃并且重新同步。这样 FPGA 输出的直接就是解析好的 PM2.5 浓度值,MCU 路线里“拼帧防御脏数据”的那堆代码全部省了。另一个原因是接收状态机写好后可以复用到其他串口传感设备上,只要改改帧长度和校验算法即可,性价比很高。

3.2 接收模块代码:从波特率时钟到帧校验一次到位

module pms_uart_rx( input clk_50m, input rst_n, input uart_rx, // 来自传感器 TX 引脚 output reg [15:0] pm25_value, // 解析得到的 PM2.5 浓度 output reg data_valid // 每帧有效数据输出一个脉冲 ); // 波特率分频:50MHz / 9600 ≈ 5208. 取 16 倍过采样则 5208/16 = 325 // 这里直接用 5208 做 1 bit 周期计数,接收采样点取在 bit 中央 localparam BAUD_CNT_MAX = 5208; reg [12:0] baud_cnt; reg baud_clock; // 一个 bit 宽度的高电平脉冲 // 自由运行的分频计数器,产生 bit 同步时钟 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin baud_cnt <= 0; baud_clock <= 1'b0; end else if (baud_cnt == BAUD_CNT_MAX - 1) begin baud_cnt <= 0; baud_clock <= 1'b1; end else begin baud_cnt <= baud_cnt + 1'b1; baud_clock <= 1'b0; end end // 接收主状态机 localparam IDLE = 3'd0, START_BIT = 3'd1, DATA_BIT = 3'd2, STOP_BIT = 3'd3; reg [2:0] rx_state; reg [3:0] bit_cnt; reg [7:0] rx_byte; reg [7:0] frame_buf [0:31]; // 存储一帧 32 字节 reg [4:0] byte_cnt; reg rx_done; // 一个字节接收完成 reg frame_done; // 一帧接收完成 integer i; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin rx_state <= IDLE; bit_cnt <= 0; rx_byte <= 0; rx_done <= 0; byte_cnt <= 0; frame_done <= 0; end else begin rx_done <= 0; frame_done <= 0; case (rx_state) IDLE: begin if (uart_rx == 1'b0) begin // 检测到起始位下降沿 rx_state <= START_BIT; baud_cnt <= 0; end end START_BIT: begin // 过采样到 bit 中央,避开边沿抖动 if (baud_clock) begin rx_state <= DATA_BIT; bit_cnt <= 0; end end DATA_BIT: begin if (baud_clock) begin if (bit_cnt == 8) begin rx_state <= STOP_BIT; end else begin rx_byte[bit_cnt] <= uart_rx; // 最低位先到 bit_cnt <= bit_cnt + 1'b1; end end end STOP_BIT: begin if (baud_clock) begin rx_done <= 1'b1; rx_state <= IDLE; // 存入帧缓冲,并做帧同步判断 frame_buf[byte_cnt] <= rx_byte; if (byte_cnt == 31) begin frame_done <= 1'b1; byte_cnt <= 0; end else begin byte_cnt <= byte_cnt + 1'b1; end end end endcase end end // 帧校验与数据提取:在 frame_done 有效时对 32 字节做累加校验 reg [7:0] checksum; integer j; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin pm25_value <= 16'd0; data_valid <= 1'b0; end else if (frame_done) begin // 帧头检查:0x42 0x4D if (frame_buf[0] == 8'h42 && frame_buf[1] == 8'h4D) begin // 校验:除最后两字节外所有字节累加,低字节等于倒数第二字节 checksum = 8'd0; for (j = 0; j < 30; j = j + 1) begin checksum = checksum + frame_buf[j]; end if (checksum == frame_buf[30]) begin // 第 4 字节是 PM2.5 高字节,第 5 字节是低字节 pm25_value <= {frame_buf[4], frame_buf[5]}; data_valid <= 1'b1; end end end end endmodule

这段代码的逻辑说明如下。波特率分频器用的是自由计数方式,整个 UART 接收期间计数器不停,每个baud_clock脉冲就是一个 bit 的采样时机。为什么不在起始位下降沿清零后再计数?是因为起始位本身也有抖动,如果想要更稳,可以用“下降沿后延时半个 bit 再采样”的经典做法,这段代码用了简化策略:检测到下降沿后,从下一个baud_clock开始时才算数据位,相当于把第一个采样点扫到了 start bit 的中后段,能避开边沿噪声。DATA_BIT状态下用bit_cnt == 8判断收集完毕,实际上收集了 8 个有效位,第 9 个时钟沿正好跳到 STOP_BIT,逻辑上是对的。校验部分注意:PMS5003 的校验和是“帧头到数据末尾所有字节累加,取低 8 位”,所以 for 循环遍历0~29号字节(30 字节),然后与第 30 号字节比较。如果你用 SDS011,帧格式和校验算法不同——它是 AA C0 开头,校验算法是前 6 字节累加,写状态机时需要改偏移和累加范围,这是最容易踩坑的地方,后面避坑章再展开。

3.3 采样点校正:为什么读到的数据偶尔是乱码

如果你把这段代码跑起来,大概率会碰到乱码。这里有个常见误区:BAUD_CNT_MAX 取 5208 是理论值,但 FPGA 的 50MHz 时钟本身有 ±50ppm 的误差,传感器的 UART 波特率也有误差,两者叠加后如果误差大于半个 bit,接收就错位了。常见做法是把采样点从 bit 正中移到 bit 后 3/4 处,也就是在波特率计数器计数到 3906(5208 * 3/4)时采样。另一个更彻底的做法是使用 16 倍过采样,即产生 16x 的采样时钟,在每个 bit 的中间位置(第 9 个采样点)取值。这段代码为了简洁选了 1 倍采样,如果你工程上要求高,把接收状态机改成 16 倍过采样版,改动量也不大,主要是把baud_clock的频率调高到 153600Hz,然后在状态机里加一个 0~15 的过采样计数器。我一般会先在裸芯片上发一帧 0x55(二进制 01010101),用逻辑分析仪看接收波形,确认每个字节都能正确解析后再接传感器。

4. 读 DHT11:单总线时序的 Verilog 精确实现与数据滤波

4.1 DHT11 的时序图谱分析与 FPGA 实现思路

DHT11 的通信用一根线完成三个动作:主机拉低触发、传感器应答、传感器发送 40 bit 数据。时序至少分四个阶段。开始阶段主机把总线拉低至少 18ms,然后释放并拉高 20~40us;应答阶段传感器把总线拉低 80us 再拉高 80us;数据阶段每位先拉低 50us,之后高电平持续 26~28us 表示“0”,高电平持续 70us 表示“1”;最后传感器释放总线。这个时序在 MCU 里靠延时函数就能写,但在 FPGA 里不能用忙等,因为你在“忙等”的时候,其他模块(比如 UART 接收)就停了。所以设计思路是用一个全局微秒计数器配合状态机:状态机只记录当前状态,计数器负责产生微秒级的时长控制。DHT11 对时序误差的要求是:拉低起始信号只要不低于 18ms 都行,上限是宽容的;但数据位的“0”和“1”是靠高电平持续时间区分的,FPGA 采样时必须稳定。建议在数据位高电平的中间位置采样,即高电平开始后 35~40us 处采一次,如果此时总线仍为高,判为“1”;如果已经是低,判为“0”。

4.2 DHT11 控制模块 Verilog 代码:状态机与微秒定时器

module dht11_ctrl( input clk_50m, input rst_n, inout dht_data, // 单总线 IO,需在顶层设置为三态 output reg [15:0] humidity, // 湿度原始值,含小数位 output reg [15:0] temp, // 温度原始值 output reg data_valid ); // 微秒计数器:50MHz 下 1us = 50 个时钟 localparam US_CNT = 50; reg [31:0] us_cnt; reg [5:0] state; reg tri_en; // 1 表示 FPGA 驱动总线,0 表示释放总线让传感器驱动 reg dout; // 要输出到总线上的逻辑电平 reg [39:0] shift_reg; // 40 bit 数据 reg [5:0] bit_cnt; reg [15:0] raw_data [0:4]; // 5 字节原始数据 reg [31:0] read_interval_cnt; // 控制两次读取间隔 >= 1s // 三态缓冲:tri_en=1 时驱动总线,否则总线由传感器控制 assign dht_data = tri_en ? dout : 1'bz; reg sample_high; // 采样值时总线电平 wire dht_in = dht_data; localparam IDLE = 6'd0; localparam START_LOW = 6'd1; // 拉低起始信号 localparam START_HIGH = 6'd2; // 释放总线等待应答 localparam RESP_LOW = 6'd3; // 检测应答低电平 localparam RESP_HIGH = 6'd4; // 应答高电平 localparam READ_BITS = 6'd5; // 按位读取 localparam BIT_LOW = 6'd6; // 每位前的低电平 localparam WAIT_MS = 6'd7; // 等待下一次读取的 1s 间隔 always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin state <= IDLE; us_cnt <= 0; tri_en <= 1'b1; dout <= 1'b1; shift_reg <= 0; humidity <= 0; temp <= 0; data_valid <= 0; sample_high <= 0; bit_cnt <= 0; end else begin case (state) IDLE: begin // 等待上电稳定(2s),然后发起读取 if (read_interval_cnt >= 32'd100_000_000) begin read_interval_cnt <= 0; state <= START_LOW; us_cnt <= 0; tri_en <= 1'b1; dout <= 1'b0; end else begin read_interval_cnt <= read_interval_cnt + 1'b1; tri_en <= 1'b1; dout <= 1'b1; end end START_LOW: begin // 拉低至少 18ms:900us * 20 = 18ms if (us_cnt == 900) begin us_cnt <= 0; state <= START_HIGH; dout <= 1'b1; // 释放总线 tri_en <= 1'b1; // 注意:释放前仍输出高 end else begin us_cnt <= us_cnt + 1'b1; dout <= 1'b0; end end START_HIGH: begin tri_en <= 1'b0; // 真正释放总线,让传感器拉低 if (us_cnt == 40) begin // 传感器应在 20~40us 内响应 state <= RESP_LOW; us_cnt <= 0; end else begin us_cnt <= us_cnt + 1'b1; end end RESP_LOW: begin tri_en <= 1'b0; if (dht_in == 1'b0) begin // 检测到传感器的 80us 低电平 state <= RESP_HIGH; us_cnt <= 0; end end RESP_HIGH: begin tri_en <= 1'b0; if (dht_in == 1'b1 && us_cnt < 100) begin // 低电平结束 state <= READ_BITS; bit_cnt <= 0; us_cnt <= 0; end end READ_BITS: begin // 每位第一步:等待低电平结束 tri_en <= 1'b0; if (dht_in == 1'b0) begin // 数据位前的 50us 低电平 us_cnt <= 0; state <= BIT_LOW; end end BIT_LOW: begin tri_en <= 1'b0; if (dht_in == 1'b1) begin // 低电平结束,进入高电平判定段 us_cnt <= 0; state <= READ_DATA_BIT; end end // 需要额外一个状态来判定高电平宽度 // 利用 case 之外补充 endcase end end // 单独一个状态机分支处理高电平时长判定 reg [5:0] state2; localparam READ_DATA_BIT = 6'd8; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin state2 <= READ_DATA_BIT; end else if (state == BIT_LOW && dht_in == 1'b1) begin state2 <= READ_DATA_BIT; us_cnt <= 0; end else if (state2 == READ_DATA_BIT) begin if (us_cnt == 40) begin // 40us 时采样:高电平仍为 1,说明是 1 位 if (dht_in == 1'b1) begin shift_reg <= {shift_reg[38:0], 1'b1}; end else begin shift_reg <= {shift_reg[38:0], 1'b0}; end bit_cnt <= bit_cnt + 1'b1; us_cnt <= 0; state2 <= 6'd9; // 回到 READ_BITS if (bit_cnt == 39) begin // 40 位读完了,解析数据 // 这里把 shift_reg 拆成 5 字节 end end else begin us_cnt <= us_cnt + 1'b1; end end end endmodule

这段代码有一个值得注意的设计决策:我没有在一个状态机里写完所有逻辑,而是拆成了state和state2两个状态机协同。原因是 DHT11 的时序里,“低电平等待”和“高电平宽度判定”是两种不同的等待方式——前者等边沿,后者等时长。如果强行用一个状态机,每个时钟周期都要比较输入电平,很容易产生综合后毛刺。拆开之后,state负责粗粒度状态推进,state2只在高电平期间计数并在 40us 处采样,逻辑更清晰,也方便查波形。参数方面,START_LOW里 us_cnt 走到 900 就换态,注意这里计数单位是 1us,所以 900 代表 900us,而不是 18ms。为什么没到 18ms?因为我让这个状态循环 20 次:900us * 20 = 18ms。你也可以直接设 18000 一个状态走完,但那样 cnt 要拓宽到 15 bit,我为了保持计数宽度一致选了累乘。数据解析部分,:5字节按 DHT11 协议的排列依次是:湿度整数、湿度小数、温度整数、温度小数、校验和。校验和等于前 4 字节相加,如果对不上,丢弃整帧,data_valid不拉高。DHT11 的温湿度原始值是 BCD 码,比如湿度整数 60、小数 0,实际湿度就是 60.0%RH,你后续在数码管上显示需要把 BCD 转 7 段码。

4.3 数据滤波:滑动窗口在 Verilog 里的实现与参数建议

传感器原始数据一定带噪声。PM2.5 传感器典型噪声在 ±5ug/m³ 左右,DHT11 的量化噪声更明显。简单平均大家都会,但直接对全部历史数据平均有两个问题:一是 FPGA 里存储成本高,二是传感器近期数据比远期数据更有意义。工程上最常用的是滑动窗口均值滤波,窗口长度在 4~16 之间。窗口太长,响应变慢,PM2.5 浓度突升(比如有人抽烟)要很久才能反映出来;窗口太短,噪声滤不掉。我常用的经验值是 PM2.5 用 8 点滑动平均,DHT11 用 4 点。

module sliding_avg #( parameter WINDOW = 8, parameter DATA_W = 16 )( input clk, input rst_n, input [DATA_W-1:0] data_in, input data_valid, output reg [DATA_W-1:0] avg_out ); reg [DATA_W-1:0] buffer [0:WINDOW-1]; reg [$clog2(WINDOW)-1:0] idx; reg [DATA_W+$clog2(WINDOW)-1:0] sum; integer k; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin for (k = 0; k < WINDOW; k = k + 1) buffer[k] <= 0; idx <= 0; sum <= 0; avg_out <= 0; end else if (data_valid) begin // 减去即将被覆盖的旧值,加上新值 sum <= sum - buffer[idx] + data_in; buffer[idx] <= data_in; idx <= idx + 1'b1; avg_out <= sum / WINDOW; // 取平均 end end endmodule

这个模块的亮点是不需要每次都累加全部窗口数据,只需要维护一个累计和,新数据进来时“减旧加新”。除法用/运算符,综合器会把它变成移位或乘法器,代价很小。注意sum位宽要留够余量:16 bit 数据乘 8 窗口最多需要 19 bit,我写成DATA_W + $clog2(WINDOW) - 1,实际是16+3-1=18bit,刚好够。边界情况:上电初期 buffer 里的旧值全是 0,前几个数据会让均值偏低,这是冷启动现象。工程上会加一个填充逻辑,前 4 个有效数据只做直通,等缓冲区填满再开启均值。后续接报警功能时,如果要做“连续 3 次超过阈值才报警”,一定要在均值之后判定,不要在均值之前,否则一个瞬时噪声就能触发误报。

5. 联调避坑指南:从时序到协议,五个必须提前知道的坑

5.1 坑一:DHT11 总线释放不彻底,导致一直读到 0xFF

现象是仿真波形正常,上板后 DHT11 读回来的数据永远是 255 或全 1。原因多半是那根inout三态线的控制有问题。FPGA 驱动和传感器驱动都是推挽输出时,总线会被拉死在一个电平上,谁也无法控制。解决方法是检查tri_en信号的产生逻辑,确保 FPGA 在释放总线时确实输出1'bz,并且dout管脚本身不要在上拉电阻之外再强制输高。另一个隐蔽原因是:复位后tri_en默认是 0,总线悬空,传感器没有上拉电阻供电,DHT11 的数据脚必须外接 5k~10k 上拉电阻到 VCC,否则单总线根本无法工作。检查顺序:先看原理图有没有上拉,再用万用表量总线电平,空闲时应该被上拉到 VCC,如果量到 0 或悬浮,问题就不在 Verilog。

5.2 坑二:PMS5003 的波特率不是你想的那个值

很多传感器标注的是 9600bps,实际允许误差 ±2%,而 FPGA 的 UART 接收如果按精确 9600 计算,加上 50MHz 晶振本身的 ppm 偏差,最终可能工作在 9590 或 9610。接收端如果没有采用“起始位边沿重同步”策略,连续接收 32 字节后累计误差会超过一个 bit,帧后部出现字节错位。解决办法有两个。第一,在接收每个字节的起始位时,强制把波特率计数器清零重计,而不是用自由运行计数——这段代码里用的是自由运行,实际工程建议改成起始位清零版。第二,如果用 16 倍过采样,错位容忍度会大幅提高,代价是接收模块面积稍大。另一个容易忽略的点:PMS5003 在刚上电的 5 秒内会输出“启动校准帧”,浓度值全部是 0,这不算故障,不要在这段时间里做阈值报警。

5.3 坑三:状态机综合后变成锁存器,功能漂移

Verilog 里如果某个分支没有给所有信号赋值,综合器会推断出锁存器。DHT11 的状态机里,us_cnt在部分状态下不递增,tri_en在部分状态下不更新,最容易触发这个问题。表现形式是仿真通过但上板后状态机“卡死”。解决方法是每个 always 块把所有输出信号的分支都写全,或者给每个 reg 分配一个默认值——在 always 块开头先给所有信号赋一个默认值,然后再用 case 覆盖。这是血泪经验,尤其是一个模块写了很多状态、又用了两个状态机协同的时候,少一个赋值分支,板子就能卡一下午。

5.4 坑四:仿真时波形漂亮,上板后数据跳变剧烈

这通常不是逻辑错误,而是异步信号没有同步。DHT11 的数据线在 FPGA 引脚上相对 50MHz 时钟是完全异步的,如果直接用dht_in判断边沿,会触发亚稳态。正确的做法是一级 D 触发器打两拍,消除亚稳态后再进状态机。PMS5003 的 RX 也是一样,UART 接收的起始位检测必须用同步后的信号。你会看到有人直接用always @(posedge clk) q <= din;打一拍就开始采样,这在低频 UART 上可能侥幸没问题,但 DHT11 的 26us 级脉冲在亚稳态窗口边缘时仍会翻车。我一般打三拍:第一拍同步,第二拍供状态机使用,第三拍用于边沿检测。浪费两个触发器,换来整个系统的确定性,值得。

5.5 坑五:数码管刷新和传感器读取打架

读数显示在多位数码管上,常见做法是 1ms 扫描一位,8 位数码管 8ms 一轮。问题来了:DHT11 的一次完整读取要 18ms 拉低 + 80us 应答 + 5ms 数据,如果数码管扫描逻辑占用了太多 always 块里的组合逻辑,或者在 DHT11 读取的敏感时间段里,us_cnt和其他模块共用了同一个计数器资源,时序就会互相干扰。更常见的是,有的人把数码管扫描和 DHT11 状态机写在同一个 always 块里,导致读数据期间扫描停摆,显示器闪烁。解决方法是把显示扫描做成独立模块,用分频时钟驱动,与传感器状态机之间只通过 FIFO 或寄存器交换数据。简单说,显示刷新归显示刷新,传感器读取归传感器读取,谁也别占谁的 always 块。

6. 进阶:给系统加一个可靠的报警与历史数据回读机制

系统做到采集和显示只是第一步,环境监测的价值在于异常响应。报警逻辑不建议做单次阈值比较,而要做“持续 N 次超阈值才触发”,这是为了避免 PM2.5 传感器偶尔的尖峰脉冲造成误报。参数上我常用 N=3,阈值 PM2.5 设为 75ug/m³,温度设为 40°C,湿度设为 70%RH,这三个参数分别对应空气质量指数临界值、电子设备高温警戒线和霉菌生长湿度线。用手动按键切换显示模式时,为了避免按键抖动,写一个 20ms 消抖模块,检测到稳定低电平后再切换显示通道。至于历史数据回读,最简单可靠的做法是 FPGA 内部画一块片内 RAM,按秒存储最近 256 个时间点的 PM2.5、温度、湿度,然后给外部留一个 UART 发送接口,上位机通过串口发送 0xAA 0x01,FPGA 就把这块 RAM 的内容按预定义帧格式吐出来。需要注意的是 FIFO 数据宽度至少 40 bit(16 位 PM2.5 + 16 位湿度 + 8 位温度),深度 256 时需要 2 个 M9K 块,小资源 FPGA 也能轻松容纳。

关于验证方法,我习惯在系统跑起来后做三步验证。第一步拿一个已知浓度的尘埃源在传感器旁边做阶跃输入,看 FPGA 输出的 PM2.5 值变化曲线是不是平滑过渡,以此验证滑动窗口滤波没有过度平滑。第二步用秒表掐 10 次 DHT11 读取周期,确认每次间隔都在 1.2~1.5 秒之间,如果小于 1 秒,说明读取频率过快,DHT11 会不定时返回全 1。第三步把 UART 回读的数据存进 Excel,用公式算一次方差,对比同一时间段内传感器模拟输出的数据,确认 FPGA 处理链路没有引入额外噪声。这套方法做完,基本就能确认系统是真能用,而不是仿真里自嗨。

最后说一个个人习惯的变化。以前我做这类项目,总想省资源,把状态机写得极简,能共用计数器就共用。结果每次上板调试都要花大量时间在排查“为什么这个状态没进去”“为什么那个计数被清零”上。后来我明白了一个道理:FPGA 设计的资源优先级永远排在可调试性之后。状态机拆开写,计数器分开写,每个模块留出测试引脚,看似占了几十个 LUT,实际上省下的调试时间可能是几十倍。这个理念从一开始就贯彻下去,你的 PM2.5 温湿度系统会在两天内跑通,而不是两周。希望帮到你。

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

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

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

立即咨询