FPGA驱动PCF8591实现I2C接口AD采样:Verilog状态机设计实战
2026/9/9 12:24:07 网站建设 项目流程

简介:一份基于FPGA的PCF8591 AD采样程序,使用Verilog语言实现,主要面向电子设计竞赛选手、FPGA课程学生以及嵌入式系统开发者,帮助解决利用PCF8591完成模拟信号采集、数码管显示和串口通信的综合设计问题。程序通过I2C总线配置PCF8591,对0~3.3V范围内的模拟电压进行8位采样,并将采样结果并行驱动数码管显示,同时通过UART串口发送至上位机,覆盖了I2C协议状态机、数码管译码、异步串行通信等关键技术。压缩包共145个文件,约4.15MB,包含可综合的Verilog源码(.v)、Quartus工程配置(.qpf/.qsf)、下载文件(.sof/.pof)以及备份与说明文档(.bak/.txt/readme),工程可直接打开并烧录验证。目前已有1802人学习下载,对于希望快速熟悉FPGA数据采集链路和I2C外设控制的读者,这份资源提供了从代码设计、仿真验证到硬件调试的完整参考,具有很好的实践价值。 PCF8591这颗芯片,做嵌入式或者FPGA开发的朋友应该不陌生,一颗带I2C接口的8位AD/DA转换芯片,四路模拟输入一路模拟输出,价格便宜、接口简单,经常被拿来当ADC入门芯片用。但真到了FPGA上,用Verilog去驱动它,和新唐、ST的MCU里直接调库完全是两码事——I2C总线时序需要你自己用状态机逐bit抠,读回来的数据还要处理通道切换、旧数据丢弃这些细节,踩坑点其实不少。这篇博客我从项目设计的角度,把PCF8591在FPGA上的AD采样完整实现思路、Verilog核心代码、仿真验证以及实际调试中遇到的问题一次讲清楚,适合正在做FPGA入门项目、或者想搞清楚I2C协议在FPGA里到底怎么落地的人参考。

1. 项目思路与整体方案设计

1.1 为什么选PCF8591:芯片特性与项目适配性

先说选型逻辑。FPGA本身不带模拟前端,要做AD采样要么用开发板上的专用ADC芯片(比如AD9226这种高速并行ADC),要么自己外接一颗低速串行接口ADC。PCF8591属于后者,8位分辨率、逐次逼近型、I2C接口最大400kHz时钟,采样率虽然不高(典型几k到几十k级别),但胜在协议简单、外围电路只需一个上拉电阻,非常适合做FPGA的I2C通信练习和低速数据采集场景。

对比几种常见方案:

方案接口类型分辨率FPGA工作量适用场景
PCF8591I2C8bit需实现I2C主机状态机低速ADC采集、I2C协议学习
AD9226并行12bit无需协议,时序简单高速数据采集、图像采集
SPI接口ADC(如MCP3008)SPI10bit需实现SPI主机中低速、多通道采集

从这个表能看出来,PCF8591的核心价值其实不在性能,而在它把I2C协议的所有关键要素都走了一遍:写寄存器、读数据、重复起始、ACK应答,一套流程练下来,I2C协议在FPGA里基本就通了。

1.2 系统架构与采样流程设计

我在设计这个项目时,把整个系统拆成了三个层次:

  • 顶层模块pcf8591_top:负责时钟输入、复位、通道选择、采样启动信号,以及最终的8位采样结果输出(可以直接接LED或数码管显示)。
  • I2C主机控制器:这是核心模块,负责按I2C协议生成SCL时钟、控制SDA数据线上的数据发送和接收,内部是一个大状态机。
  • 应用逻辑层:简单点的话就是个触发信号生成器,复杂点可以在后面接FIFO或者串口发送模块。

更关键的是采样流程设计。PCF8591不是你把地址发过去就能立刻拿到当前通道的转换值,它有一个内部工作周期:主机发送控制字节后,芯片才开始对选定通道进行采样转换,而且第一次读回来的数据往往是上一次转换的残留值。所以完整的读取流程必须做成:

  1. 发送启动条件
  2. 发送器件地址(写方向,0x90)
  3. 等待ACK
  4. 发送控制字节(配置通道和输入模式)
  5. 等待ACK
  6. 发送重复起始条件(Restart)
  7. 发送器件地址(读方向,0x91)
  8. 等待ACK
  9. 读取第一个字节并回ACK(触发转换,数据丢弃)
  10. 读取第二个字节并回NACK(有效数据,说明PCF8591上电后真正的转换结果在这里)
  11. 发送停止条件

这一步是很多人容易忽略的,我第一次做的时候只读了一个字节,结果每次上电读到的第一组数据都是乱码,后来翻数据手册才搞明白第二个字节才是有效转换结果。后面代码里我会把这个流程完整写进状态机。

2. I2C时序分析与时序参数计算

2.1 PCF8591读写时序逐段拆解

I2C协议在FPGA里实现,核心就是把时序拆成状态的转移。PCF8591的数据手册里给了两个关键时序表:标准模式100kbit/s和快速模式400kbit/s。我用的是标准模式,因为FPGA里分频到400kbit/s虽然没问题,但为了兼容性和稳定性,100kbit/s更保险。

一次完整的数据字节传输,时序上要经历这么几个阶段:

  • START:SCL保持高电平期间,SDA由高变低
  • 发送8位数据:SCL低电平期间SDA准备好数据,SCL上升沿后芯片采样
  • 第9个时钟接收ACK:主机释放SDA线,芯片拉低表示应答
  • STOP:SCL高电平期间,SDA由低变高

在FPGA里,SDA的跳变必须严格控制发生在SCL低电平期间,否则会被芯片误判为启动或停止条件。这是写I2C时序最需要注意的一个点,必须在代码里用状态顺序来保证,而不是靠延迟碰运气。

2.2 寄存器与控制字节配置要点

PCF8591的器件地址是固定的,高四位1001,跟着是三位硬件地址A2、A1、A0(一般接地为000),最后一位是读写方向位。所以写地址是0x90,读地址是0x91。

控制字节是整个配置的灵魂,各bit的含义要搞清楚:

Bit位含义取值说明
7DA输出使能1使能AOUT模拟输出
6输入模式0四路单端输入,1差分输入
5自动增量1每次转换后通道自动+1
4:3通道选择00~11对应AIN0~AIN3
2:1测试模式00正常工作
0保留置0

单端四路输入模式下选择AIN0,控制字节就是8'b0000_0000,如果需要DAC同步输出,就加上0x80变成8'b1000_0000。这里有个常见的坑,不少例程写0x40,那个是选择差分输入模式,初学者直接抄板子配置的是单端输入,读出来的数据对不上号,排查半天发现是控制字的问题。

2.3 时钟分频设计与时序参数表

我用的开发板晶振是50MHz,要得到100kHz的SCL,一个完整SCL周期需要500个系统时钟周期,也就是250个周期拉高、250个周期拉低。分频系数计算:

SCL频率 = 系统时钟 / (2 * 分频计数) 100kHz = 50MHz / (2 * 250)

在代码里我定义了一个参数SCL_DIV = 250,再定义一个半周期计数器,从0数到249翻转一次SCL。这里额外多说一句,I2C标准规定数据建立时间最短250ns、保持时间最短100ns,100kHz的SCL半周期是5us,远远满足要求,所以不需要额外做建立保持时间补偿,时序上是安全的。如果后续要用400kHz模式,半周期变成1.25us,依然能满足,但不建议再往上跑了,PCF8591的最大时钟就是400kHz。

3. Verilog关键模块实现与代码解析

3.1 顶层模块接口设计

顶层模块把I2C主机封装成一个可复用的控制器,对外暴露的接口要简洁清晰:

module pcf8591_top( input clk, // 50MHz系统时钟 input rst_n, // 低电平复位 input start, // 采样启动信号 input [1:0] ch_sel, // 通道选择 00~11 output reg [7:0] adc_data, // 采样结果 output reg adc_done, // 采样完成标志 inout i2c_sda, // I2C数据线 output i2c_scl // I2C时钟线 );

需要特别注意的是i2c_sda这个inout端口。FPGA内部无法直接读外部引脚电平来同时驱动它,必须用三态门控制:

assign i2c_sda = (sda_out_en) ? sda_out : 1'bz;

sda_out_en为高时,FPGA主动拉低SDA(因为I2C是开漏结构,输出数据时只拉低或释放,不主动拉高),为低时释放总线,此时可以读取外部输入sda_in。这个三态控制如果写反了,总线就会被FPGA一直钳住,芯片收不到任何有效数据。

3.2 I2C主控制器状态机实现

核心状态机的设计是整个项目的重中之重。状态划分要细,但不要过度复杂,我用的状态序列如下:

localparam IDLE = 5'd0, START_A = 5'd1, SEND_ADDR_W = 5'd2, WAIT_ACK1 = 5'd3, SEND_CTRL = 5'd4, WAIT_ACK2 = 5'd5, RESTART = 5'd6, SEND_ADDR_R = 5'd7, WAIT_ACK3 = 5'd8, READ_DATA1 = 5'd9, // 第一字节,丢弃 SEND_ACK = 5'd10, // 主机回ACK READ_DATA2 = 5'd11, // 第二字节,有效数据 SEND_NACK = 5'd12, // 主机回NACK STOP = 5'd13, DONE = 5'd14;

每个状态内部再细分bit计数。以发送字节为例,我用一个cnt_bit计数器在0~7之间循环,状态每个SCL周期推进一位。发送字节的伪代码逻辑是:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cnt_bit <= 0; scl_half_cnt <= 0; end else if (scl_half_cnt == SCL_DIV - 1) begin scl_half_cnt <= 0; scl_reg <= ~scl_reg; if (scl_reg == 1'b0) begin // SCL从低到高,发送下一位 if (cnt_bit < 8) begin sda_out <= tx_data[7 - cnt_bit]; cnt_bit <= cnt_bit + 1; end end end else begin scl_half_cnt <= scl_half_cnt + 1; end end

注意这个if (scl_reg == 1'b0)的判断,它的含义是SCL刚完成一次从低到高的翻转后,主机可以改SDA准备下一位数据。实际调试时这个位置很容易出问题,如果数据更新时机不对,芯片采到的就是错误bit。

接收字节和发送字节的框架类似,区别在于cnt_bit小于8时主机释放SDA读数据,第9个周期根据当前状态决定回ACK还是NACK。ACK和NACK的区别就是主机是否拉低SDA:ACK拉低,NACK释放,芯片看到NACK后知道主机不再读取,会释放总线。

3.3 数据接收与转换结果输出

有效数据收到后,我加了一个简单的同步寄存逻辑:

always @(posedge clk or negedge rst_n) begin if (!rst_n) adc_data <= 8'b0; else if (state == READ_DATA2 && bit_cnt == 7 && scl_half_cnt == SCL_DIV - 1) adc_data <= rx_data; end

adc_done标志在进入DONE状态时拉高,持续一个系统时钟周期,方便外部逻辑后续触发FIFO写或者串口发送。如果只是点灯验证,直接把adc_data接到8个LED上看电压变化即可。

4. 仿真验证与上板调试实录

4.1 Testbench编写与仿真波形分析

Verilog代码写完后不能直接上板,先仿真把时序调通才是高效率的做法。Testbench的核心任务有两个:生成50MHz时钟、给start一个启动脉冲,然后观察SDA和SCL波形。

initial begin clk = 1'b0; forever #10 clk = ~clk; end initial begin rst_n = 1'b0; start = 1'b0; #100 rst_n = 1'b1; // 复位释放 #200 start = 1'b1; #20 start = 1'b0; end // inout端口需要在仿真时加入上拉 pullup(pull_sda); assign pull_sda = 1'b1; assign i2c_sda = pull_sda;

这里有个Modelsim仿真的实际坑:i2c_sda在testbench里必须同时处理pullup和驱动,否则仿真波形里SDA一直是不定态X。实际的做法是把DUT和testbench之间的inout连接用一个wire,然后单独加pullup(sda_wire);

仿真波形里要重点检查的几个点:

  • 启动条件是否在SCL高电平时SDA由高变低
  • 第一个字节0x90之后芯片是否有ACK(在第9个时钟SDA拉低)
  • 重复起始条件是否出现
  • 读地址0x91之后数据线上是否读到芯片返回的数据

如果波形里看到SDA在应该释放的时段变成0,多半是三态控制逻辑有问题,优先查sda_out_en的时序。

4.2 Modelsim/Vivado仿真常见坑与调试技巧

仿真验证这块我遇到过最蹊跷的问题是:用Vivado自带的仿真器跑波形完全正常,换到Modelsim里就各种时序怪象。后来发现是Modelsim默认对inout端口处理方式不同,需要在testbench里显式处理三态总线的上拉,否则高阻态会显示成红色锯齿状电平。

另外还有一个通用技巧:在testbench里用文本方式打印关键状态跳转,比单靠肉眼盯波形效率高得多:

always @(posedge clk) begin if (state != prev_state) begin $display("Time:%0t State changed to %0d", $time, state); prev_state <= state; end end

这样仿真完看一眼打印信息就能定位状态机卡在哪一步,不用反复放大缩小波形窗口。

4.3 上板实测流程与数据验证

仿真通过后开始上板实测。我的流程是先不接任何模拟输入,把0V和3.3V分别接到AIN0上,观察读回来的8位数据是否接近0x00和0xFF,用这种方式验证基本通路。

然后接一个电位器,调整电压时观察adc_data是否同步变化。如果手头没有电位器,用一根杜邦线悬空也行,但悬空状态下读到的数据会乱跳,这是正常的,不是代码问题。

我用的是Vivado环境加ILA(集成逻辑分析仪)来抓内部信号,抓sda、scl、state、adc_data这几个关键信号,比外接逻辑分析仪方便。ILA触发条件设成adc_done上升沿,就能稳定抓到每次采样完成时的完整总线时序。

5. 常见问题与排查技巧速查

5.1 I2C无ACK、读不到数据的排查路径

实际项目中最常见的问题就是总线无应答,数据读不出东西。我把排查经验总结成了一张速查表:

现象可能原因处理方式
SCL有波形但一直无ACKSCL频率超出芯片400kHz上限检查分频参数,实测降速到100kHz
SDA一直是低电平三态门方向控制写反检查sda_out_en极性
读地址后返回0xFF器件地址配置错或SDA未释放检查A2~A0硬件连接
数据有值但固定不变通道配置字写错核对控制字节bit4:3
首次上电读到乱码未丢弃第一个字节旧数据按两字节读取流程操作

SCL和SDA的上拉电阻也要确认,很多开发板自带了4.7kΩ上拉,但如果你是自己搭的板子,忘了加上拉电阻会导致I2C总线根本无法工作,这是硬件上的硬性要求,不是软件能绕过的。

5.2 采样值跳变与精度问题的处理技巧

8位ADC本身分辨率有限,0~3.3V映射到0~255,每一步代表大约12.9mV,所以数据跳动1~2个LSB都是正常的。但如果你发现数据跳变幅度有几十甚至上百,那就要从以下几个角度排查:

  • 电源噪声。PCF8591的VREF最好直接用3.3V稳压电源,不要用FPGA的IO口供电。
  • 输入信号源阻抗过高。PCF8591的采样电容充电时间有限,如果信号源内阻太大,采样值会偏低且不稳,加一个运放跟随器或者至少一个0.1uF电容到地。
  • 数字噪声耦合到模拟输入。采样线尽量短,远离SCL和SDA走线。

如果想做简单的滤波,可以在FPGA里对连续多次采样取平均或者做滑动窗口滤波。最简单的做法是连续采8次求和后右移3位,相当于8点平均,在低速场景下效果非常明显。如果后续想做得更平滑,可以试试滑动窗口滤波,本质上就是一个移位寄存器累加器,实现复杂度也不高。

最后再分享一个实际操作中的心得

PCF8591这个项目虽然看起来简单,但它把FPGA开发里最核心的几个基本功都串起来了:inout端口的三态控制、分频时钟设计、大状态机的编写与调试、时序仿真验证方法。做完这个项目,再去做SPI接口的ADC、I2C接口的EEPROM,甚至去读其他带I2C总线的传感器,套路都是相同的——无非是换个控制字、换个寄存器配置。

如果手头资源允许,建议在这个基础上做两个扩展:一是加一个FIFO连续采集多通道数据,配合上位机画波形;二是把I2C控制器改写成参数化模块,数据位宽和地址位宽可配,这样以后复用起来更灵活。

我踩过的最大一次坑就是控制字那一位的配置错误,差分和单端模式搞反,整整查了一个下午才发现问题。所以还是那句话,做这种接口类项目,第一件事永远是熟读数据手册,把每一个寄存器的bit位含义都弄明白,再动手写代码不迟。

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

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

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

立即咨询