简介:一份基于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工作量 | 适用场景 |
|---|---|---|---|---|
| PCF8591 | I2C | 8bit | 需实现I2C主机状态机 | 低速ADC采集、I2C协议学习 |
| AD9226 | 并行 | 12bit | 无需协议,时序简单 | 高速数据采集、图像采集 |
| SPI接口ADC(如MCP3008) | SPI | 10bit | 需实现SPI主机 | 中低速、多通道采集 |
从这个表能看出来,PCF8591的核心价值其实不在性能,而在它把I2C协议的所有关键要素都走了一遍:写寄存器、读数据、重复起始、ACK应答,一套流程练下来,I2C协议在FPGA里基本就通了。
1.2 系统架构与采样流程设计
我在设计这个项目时,把整个系统拆成了三个层次:
- 顶层模块pcf8591_top:负责时钟输入、复位、通道选择、采样启动信号,以及最终的8位采样结果输出(可以直接接LED或数码管显示)。
- I2C主机控制器:这是核心模块,负责按I2C协议生成SCL时钟、控制SDA数据线上的数据发送和接收,内部是一个大状态机。
- 应用逻辑层:简单点的话就是个触发信号生成器,复杂点可以在后面接FIFO或者串口发送模块。
更关键的是采样流程设计。PCF8591不是你把地址发过去就能立刻拿到当前通道的转换值,它有一个内部工作周期:主机发送控制字节后,芯片才开始对选定通道进行采样转换,而且第一次读回来的数据往往是上一次转换的残留值。所以完整的读取流程必须做成:
- 发送启动条件
- 发送器件地址(写方向,0x90)
- 等待ACK
- 发送控制字节(配置通道和输入模式)
- 等待ACK
- 发送重复起始条件(Restart)
- 发送器件地址(读方向,0x91)
- 等待ACK
- 读取第一个字节并回ACK(触发转换,数据丢弃)
- 读取第二个字节并回NACK(有效数据,说明PCF8591上电后真正的转换结果在这里)
- 发送停止条件
这一步是很多人容易忽略的,我第一次做的时候只读了一个字节,结果每次上电读到的第一组数据都是乱码,后来翻数据手册才搞明白第二个字节才是有效转换结果。后面代码里我会把这个流程完整写进状态机。
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位 | 含义 | 取值说明 |
|---|---|---|
| 7 | DA输出使能 | 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; endadc_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有波形但一直无ACK | SCL频率超出芯片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位含义都弄明白,再动手写代码不迟。
本文还有配套的精品资源,点击获取