十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

FPGA驱动PCF8591实战:用Verilog实现I2C接口ADC采样

FPGA驱动PCF8591实战:用Verilog实现I2C接口ADC采样 简介面向FPGA与Verilog学习者的PCF8591 AD采样完整工程基于I²C总线实现模拟信号采集涵盖0~3.3V模拟量采样、数码管动态显示、串口数据传输及I²C通信状态机设计适合进阶数字电路与嵌入式系统开发的读者。工程打包完整共145个文件、压缩包4.15MB以.v源码、Quartus工程文件.qpf/.qsf、配置文件.sof/.pof及readme说明为主同时保留编译仿真产生的中间文件便于对照查错与二次开发。整个工程顶层与底层模块划分清晰PCF8591作为带I²C接口的8位ADC使用源码结构完整、包含采样、显示和串口发送等模块是理解FPGA数据采集链路和I²C协议落地的较好范例。目前已有1802人学习适合希望从基础语法走向完整项目实践、进一步巩固Verilog与数字系统设计能力的人群。1. 项目概述与核心要点做FPGA开发的同学应该都有一个共同的感受跑马灯、按键消抖、UART收发这类常规练习玩腻了之后总想找个稍微有点“真实感”的外设来折腾。PCF8591就是一个非常合适的切入点——它既有I2C总线的时序复杂度又有真实ADC转换的物理过程还能顺便体验一把模拟信号采集的完整链路。这个项目的本质是用Verilog在FPGA上实现一个I2C主机控制器去驱动PCF8591完成8位AD采样。PCF8591是NXP原Philips出品的一款8位逐次逼近型ADC/DAC二合一芯片I2C接口4路模拟输入1路模拟输出。单片机驱动它的教程满网都是但换成FPGA来做事情就变得有意思了。为什么说“有意思”因为单片机跑I2C通常是硬件外设自动处理时序写个寄存器配置就完事而FPGA没有现成的I2C外设可用你需要自己用Verilog把I2C的时序逐bit地“抠”出来。起始条件、停止条件、ACK应答、数据位的建立保持时间每一个环节都得精确到us级别。这个项目适合谁两种人一是FPGA入门后想接触真实外设协议的学习者二是在校学生准备电子竞赛或毕业设计时需要一个“模拟信号采集”功能模块的。做完这个项目你对I2C协议的理解会上升好几个层次而且这个I2C主控代码稍加改造就能驱动其他I2C器件比如EEPROM、温湿度传感器SHT30等复用价值很高。整体工程不大但麻雀虽小五脏俱全I2C主控状态机、时钟分频、寄存器配置逻辑、ADC数据缓存、仿真验证用的I2C从机模型该有的全都有。下面我把整个项目的设计思路、核心代码、踩坑记录逐一拆开讲。2. 整体设计与方案选型2.1 为什么选PCF8591作为采样前端先聊一下器件选型。FPGA内部虽然有XADCXilinx 7系列及以后这种片上ADC资源但它的外部引脚是专用的而且用起来有不少限制。PCF8591这种外挂I2C接口ADC的最大好处是通用性和可移植性——你可以把这套代码搬到任意一款FPGA上只要引脚约束改一下就行。可能有同学会问直接用FPGA内置ADC不香吗分场景。如果你是做高速采样MSPS级别那肯定得上FPGA内置ADC或者外挂高速ADC芯片但如果只是低速的电压监测、温度采集、电位器读数这类应用PCF8591的8位分辨率加I2C接口完全够用而且PCB布线极度友好。PCF8591的关键参数8位逐次逼近型ADC转换时间约90us典型值I2C接口最高速率100kHz标准模式4路模拟输入可配置单端或差分工作电压2.5V~6V兼容3.3V和5V逻辑内置片上跟踪保持电路注意一个细节PCF8591的数据手册上写的I2C速率是100kHz标准模式你用FPGA做主机时SCL的频率建议控制在100kHz以内不要为了追求速度强行跑到400kHz还指望它稳定工作。我实测过400kHz下偶尔也会出数据但不保证长期稳定高速模式不是它的设计目标。2.2 架构拆解I2C主控与采样状态机的分层整个FPGA工程我分成了三个层次每一层职责单一调试起来非常方便第一层时钟分频层。我用的开发板主时钟是50MHzPCF8591的I2C时钟要100kHz所以需要先做一个分频模块产生SCL时钟和延时计数基准。这里我用了一个简单的计数器分频50MHz ÷ 100kHz 500所以每250个时钟周期翻转一次SCL半周期这样SCL就是100kHz了。第二层I2C主控层。这是整个项目的核心负责产生I2C协议的起始条件、数据发送、数据接收、ACK检测、停止条件。对外暴露的接口很简单一个启动信号、一个读写方向标志、一个8位数据输入、一个8位数据输出、一个忙标志和完成标志。上层模块不需要关心I2C协议细节只需要拉高启动信号等着完成就行。第三层采样状态机层。这层负责组织一轮完整的采样流程发送器件地址写方向→ 发送控制字选通道→ 发送器件地址读方向→ 读取一个字节的ADC数据 → 锁存输出。这层是纯逻辑状态机不涉及时序细节只控制I2C主控的启动时机和数据流向。这种分层设计还有一个好处如果你想换成别的I2C器件只需要改第三层的东西第二层的I2C主控几乎不用动。比如后面我拿来读SHT30温湿度传感器就是同样的套路只改了器件地址和寄存器配置流程。2.3 两种可选的实现策略做I2C主控有两种常见写法状态机直写法和多bit移位法。状态机直写法是把起始、停止、每个bit的发送接收都拆成独立的state代码直观但状态数量较多多bit移位法则把8个数据bit的操作统一处理用计数器控制循环次数代码更紧凑。这个项目我选了状态机直写法。理由很简单I2C的时序本来就是由一组离散的步骤组成的——起始条件、地址bit7...bit0、ACK、数据bit7...bit0、ACK、停止条件。用状态机直写法可以让每一步跟时序图中的波形一一对应调试的时候对着仿真波形一眼就能看出问题在哪。缺点就是case分支多一些但I2C本来就是低速协议状态再多也不会有什么性能压力。3. 核心细节解析与实操要点3.1 I2C协议关键时序的误区与细节先说最容易被新手忽略的点起始条件和停止条件的时序要求。起始条件SCL为高电平期间SDA由高跳变到低。停止条件SCL为高电平期间SDA由低跳变到高。这里有个关键细节——SDA的跳变必须发生在SCL为高电平期间而数据传输时SDA只能在SCL为低电平期间变化。我来解释一下为什么这样设计I2C总线靠SDA在SCL高电平期间的跳变来识别起始和停止条件如果数据位也在SCL高电平期间跳变接收方就分不清这是数据还是起始/停止条件了。所以协议强制规定数据在SCL低电平期间更新、高电平期间保持稳定。写Verilog时必须严格遵守这个时序。具体到代码里起始条件的产生是这样的// 起始条件SCL高电平期间SDA拉低 start_state: begin SCL_CTRL 1b1; // SCL置高 SDA_CTRL 1b1; // SDA先拉高确保总线空闲 // 延时数微秒后 SDA_CTRL 1b0; // SCL仍为高SDA拉低 → 产生起始条件 end这里必须注意SDA要先拉高再拉低不能直接从上一个状态直接跳变到低。如果SDA本来就是低电平直接拉低不产生跳变起始条件就丢了。ACK应答位这块也容易出问题。PCF8591作为从机收到主机发送的地址字节和控制字节后会在第9个SCL时钟周期拉低SDA作为应答反过来主机读取数据时主机需要在第9个时钟周期释放SDA总线即不驱动SDA让从机来控制SDA的电平。如果主机在读数据阶段仍然强制驱动SDA就会跟从机发的数据冲突导致读出来的全部是FF或者00。我在代码里是这样处理的读数据阶段的ACK检测先把SDA设置成高阻态inout端口赋值1bz然后采样外部SDA引脚的电平判断是否为低。如果SDA一直保持高电平说明从机没有应答需要做超时处理或者重新发送起始条件。3.2 PC F8591控制字配置与通道选择PCF8591的控制字寄存器是8位的它决定了芯片的工作模式。各位的含义如下位名称功能说明7未用写06D/A使能1使能模拟输出5模拟输入方式0单端输入1差分输入4未用写03:2自动增量00表示采样后通道不自动切换1:0通道选择00~11对应AIN0~AIN3单端模式是我这个项目的主力用法四个通道每个引脚独立对GND测量适合采集多个独立电压信号。如果你用的是差分模式控住字第5位写1通道选择变成01通道0和1差分10通道2和3差分11通道3和0差分。需要注意差分模式下控制字第1:0位的编码含义和单端模式是不同的别照搬。这里分享一个我的习惯控制字做成参数定义方便后续修改通道配置。parameter CTRL_DAC_EN 8b0100_0000; // 使能DAC parameter CTRL_ADC_SINGLE 8b0000_0000; // 单端输入 parameter CTRL_ADC_CH0 8b0000_0000; // 通道0 parameter CTRL_ADC_CH1 8b0000_0001; // 通道1 parameter CTRL_ADC_CH2 8b0000_0010; // 通道2 parameter CTRL_ADC_CH3 8b0000_0011; // 通道3 // 组合单端 通道2 // ctrl_reg CTRL_ADC_SINGLE | CTRL_ADC_CH2;另外一个很重要的坑PCF8591的第一次读取数据是上一次转换的结果。换句话说你配置通道后立刻发起读操作读回来的是配置之前那个通道的数据。因为ADC转换需要时间约90us芯片在收到控制字后开始转换新的通道转换完成前你读到的都是旧数据。解决方案有两个一是配置通道后延时100us以上再启动读取二是像我这个项目一样连续读两次丢弃第一次的数据取第二次的结果。在代码里就是采样状态机里读两次第一次读到的数据直接不管第二次读到的才是有效值。3.3 上拉电阻与电气连接要点做I2C实验时最常碰到的一个问题是SDA和SCL信号在示波器上看全是平的没波形。十有八九是没接上拉电阻。I2C总线的SDA和SCL都是开漏结构必须有上拉电阻才能输出高电平。PCF8591的I2C引脚内部没有上拉必须在外部接上拉电阻。阻值选择有讲究标准模式下100kHz我用的是4.7kΩ总线电容不大时完全没问题如果总线较长超过20cm换成2.2kΩ或1kΩ更保险。这个阻值不是越小越好太小的上拉电阻会让信号的上升沿变快但也会增加电流消耗。FPGA侧的IO引脚如果是3.3V的LVCMOS上拉电阻电源直接用3.3V。PCF8591的VDD我用的是3.3V这样I2C电平标准和FPGA IO直接兼容不需要额外的电平转换芯片。还有一个容易踩的坑VREF参考电压必须接好。PCF8591的基准电压引脚是VREF默认情况下最好接到VDD上这样ADC的满量程就是3.3V。如果你用内部参考把AGND接VREF也行但我在实际项目中更倾向于把VREF直接连VDD简单粗暴采样结果也好解释。VREF引脚不要悬空否则采样值会飘得离谱。4. 实操过程与核心代码实现4.1 顶层模块设计顶层模块的接口设计上我尽量保持了模块的通用性module pcf8591_adc_top( input wire clk_50m, // 50MHz系统时钟 input wire rst_n, // 低电平复位 input wire [1:0] ch_sel, // 通道选择 00~11 input wire start_samp, // 启动采样单脉冲 output wire [7:0] adc_data, // 8位AD采样结果 output reg sample_done,// 采样完成标志高有效一个周期 inout wire i2c_scl, // I2C时钟 inout wire i2c_sda // I2C数据 ); // 模块内部例化时钟分频、I2C主控、采样状态机 // 注意i2c_scl和i2c_sda必须是inout类型 // 因为I2C协议中总线的驱动权在主机和从机之间切换这里有个设计细节i2c_scl为什么也是inout而不是纯输出因为I2C协议支持时钟同步和时钟拉伸从机拉低SCL请求主机等待PCF8591不拉伸时钟但为了通用性我把SCL也都定义成inout在FPGA内部用三态控制。如果确定只接PCF8591不接其他设备SCL做成纯输出也行。4.2 I2C主控状态机核心代码I2C主控是整个工程最核心的部分我直接贴关键代码。注意这里的代码是简化版去掉了延时计数的细节重点展示状态流转逻辑// I2C主控状态机核心状态定义 localparam IDLE 4d0; localparam START 4d1; // 产生起始条件 localparam SEND_ADDR 4d2; // 发送7位地址读写位 localparam WAIT_ACK1 4d3; // 等待从机ACK localparam SEND_DATA 4d4; // 发送数据/控制字 localparam WAIT_ACK2 4d5; // 等待从机ACK localparam SEND_READ 4d6; // 发送读方向地址 localparam WAIT_ACK3 4d7; // 等待ACK localparam READ_DATA 4d8; // 读取8位数据 localparam SEND_NACK 4d9; // 发送非应答主机读完后 localparam STOP 4d10; // 产生停止条件 // 数据发送在SCL低电平期间更新SDA // 每bit占用9个时钟周期一个SCL周期 2个半周期 always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin // 复位逻辑 end else begin case (state) SEND_ADDR: begin // 在SCL低电平时依次移出地址bit[7:0] // bit_counter从7减到0每个SCL高电平期间SDA保持稳定 if (scl_high) begin // SCL为高保持SDA不变 sda_out tx_data[bit_counter]; end else begin // SCL为低更新SDA if (bit_clk_rise) bit_counter bit_counter - 1b1; end end // 其他状态类似... endcase end end这个状态机的操作流程是上层模块拉高start信号后主控先产生起始条件然后发送地址字节7位地址0x90加一位读写标志等待ACK接着发控制字等ACK再发一次地址读方向等ACK读8个bit数据主机发NACK告诉从机不用再发了产生停止条件整个事务结束拉高done信号。这里要特别注意bit_counter的更新时机。数据线的跳变发生在SCL低电平期间而bit计数器应该在SCL的下降沿附近更新确保在下一个SCL高电平采样窗口到来之前SDA已经稳定。我在代码里是用bit_clk_rise这个辅助信号来同步的仿真和实测都证明这样做不会出现建立时间问题。4.3 采样状态机的组织方式采样状态机负责把I2C主控串起来执行一轮完整的“配置通道 读取数据”操作。我设计的状态流程如下localparam S_IDLE 3d0; localparam S_WR_CTRL 3d1; // 发地址(写)控制字配置通道 localparam S_DELAY 3d2; // 等待ADC转换至少100us localparam S_RD_1ST 3d3; // 第一次读丢弃无效数据 localparam S_RD_2ND 3d4; // 第二次读锁存有效数据 localparam S_DONE 3d5; // 完成一轮采样S_DELAY状态是很多人容易漏掉的。ADC转换不是瞬间完成的PCF8591从收到控制字到转换完成约需要90us。如果你的主时钟是50MHz意味着至少要等待4500个时钟周期。我用一个计数器实现延时配置为120us左右留了充足的余量。S_RD_1ST读出来的数据直接丢弃这个我刚才解释过——第一读拿到的是上一次转换的旧值。除非你的应用不关心首采的准确性否则建议还是读两次稳妥。4.4 仿真验证策略用Verilog写一个I2C从机模型仿真是这个项目调试过程中最关键的一环。没有仿真的话你把代码烧到板子上波形在示波器上乱七八糟根本不知道是FPGA时序问题还是PCF8591电气连接问题。所以我强烈建议先写仿真仿真通过了再上板。我设计了一个简单的PCF8591行为模型放在testbench里它模拟了真实的I2C从机行为// PCF8591行为模型的简化版本用于仿真 module pcf8591_model( inout wire scl, inout wire sda ); reg [7:0] addr_reg; reg [7:0] ctrl_reg; reg [7:0] adc_result 8hA5; // 模拟ADC采样结果 // 检测起始条件、接收数据、发送ACK、发送数据 // 关键在仿真中模拟从机的ACK和读数据行为 endmodule仿真时设置的adc_result 8hA5是固定的已知值。如果FPGA读回来的数据正好是0xA5说明I2C时序、状态机逻辑、数据位序全部正确如果跟预期不一致就可以通过波形逐bit检查是哪一步出了差错。Run仿真的时候务必把时间尺度设置得足够细timescale 1ns/1ps。I2C的速度是100kHz一个SCL周期是10us仿真整体跑一轮采样流程大概需要几百微妙。用Vivado自带的仿真器跑这个仿真内存消耗不大速度也能接受。如果用了ModelSim或Questa体验更佳。5. 常见问题与排查技巧实录5.1 总线锁死的处理I2C常见的故障之一就是总线死锁——SDA一直被拉低怎么操作都释放不了。这通常发生在数据传输过程中突然掉电、复位从机还在等后续的时钟而主机已经停了。我踩过的坑是这样的FPGA复位之后I2C主控直接启动但此时PCF8591可能还处于上一个未完成事务的中间状态SDA被它锁死。解决方案是在每次启动I2C事务之前先产生9个时钟脉冲如果SDA在第9个脉冲后能够释放为高电平则说明总线正常。在代码里我加了一个bus_clear功能复位后先执行这个操作// 总线清理产生9个SCL时钟脉冲 // 每个时钟脉冲期间检测SDA电平若在第9个脉冲后SDA为高总线恢复 bus_clear_state: begin if (clk_cnt 9) begin // 产生SCL翻转同时保持SDA为高 scl_out scl_out ? 1b0 : 1b1; clk_cnt clk_cnt 1; end else if (!sda_in) begin // SDA仍为低需要再次尝试或报错 end else begin // 总线正常进入正常流程 end end这个9个时钟脉冲的机制原理是I2C从机依靠SCL时钟边沿来推进内部状态机给9个时钟脉冲一个字节 一个ACK位从机会完成当前字节的接收并释放SDA。如果9个脉冲不够再补一组即可。5.2 数据读回来是FF或00的排查这个问题的排查路径很清晰。读回来全是FF说明SDA线被上拉电阻拉高了FPGA读到的每bit都是1。原因通常是FPGA在读数据阶段没有正确释放SDA总线三态门没打开或者PCF8591没被正确寻址地址发送错误从机没有响应所以SDA一直保持高电平。读回来全是00说明SDA一直被拉低。这时先检查从机是否处于死锁状态执行上面说的总线清理操作再检查PCF8591的A0、A1、A2地址引脚有没有接对地——如果地址引脚悬空某些芯片的地址检测可能不稳定导致从机设备地址匹配出错它会一直等待而主机又在等它的应答总线就僵住了。我还遇到过一种特殊的情况第一次读的值正常之后每次读都变成FF。排查了很久才发现是主机在读完最后一个字节后没有发送NACK信号就直接产生了停止条件。PCF8591在收到NACK之前会一直认为主机还要继续读数据所以它会准备下一个字节的数据。而主机已经停止时钟两边不同步了。记住主机读多字节时最后一字节必须发NACK再跟STOP。5.3 采样值跳动或不准的问题如果你的采样结果有较大的噪声波动先不要怀疑FPGA代码先检查硬件模拟输入引脚悬空——悬空的模拟引脚会采集到随机的环境噪声表现为采样值乱跳。接一个干净的直流电压比如3.3V经电位器分压或者直接接GND看读数是否稳定。VREF不稳定——VREF如果直接来自LDO的输出供电纹波会直接映射到采样结果上。我习惯在VREF引脚附近放一个10uF100nF的组合去耦电容。数字噪声串扰——FPGA的IO翻转会产生不小的数字噪声如果PCF8591离FPGA芯片太近或者地平面分割不合理噪声会耦合到模拟输入端。在PCB布局时模拟部分和数字部分最好分区处理。如果你确定硬件没问题代码上还有一个容易忽略的点读取出的8位数据没有经过同步处理就直接跨时钟域使用。如果你的采样结果要送到另一个时钟域比如100MHz的显示刷新逻辑需要加两级触发器同步避免亚稳态造成的数据抖动。我的实际操作是PCF8591读回来的数据先锁存到reg里然后打两拍再输出reg [7:0] adc_data_sync1, adc_data_sync2; always (posedge clk_50m or negedge rst_n) begin if (!rst_n) begin adc_data_sync1 8d0; adc_data_sync2 8d0; end else begin adc_data_sync1 adc_data_raw; adc_data_sync2 adc_data_sync1; end end assign adc_data adc_data_sync2;5.4 常见问题速查表现象可能原因排查方向SCL/SDA无波形上拉电阻没接检查I2C上拉电阻4.7kΩ到3.3VSDA一直为低总线死锁 / 地址错误总线清理检查A0~A2地址引脚读回全FFSDA未释放 / 从机无响应检查三态门控制用示波器看SDA波形读回全00SDA被拉低 / 从机锁死总线清理上拉电阻是否装错采样值不稳定模拟输入悬空 / VREF纹波接干净信号源加强去耦首采数据错误未处理转换延时增加延时或读两次丢弃第一次6. 工程扩展与后续优化方向做完基础的单通道AD采样后面你可以沿着几个方向继续扩展。每个方向都不难但都能把项目往实用化推进一大步。多通道循环采样是最直接的第一步。PCF8591本身支持4路单端输入控制字节里的自动增量位bit2置1可以让芯片在每次转换完成后自动切换通道。这样你只需要在采样状态机里按顺序读取4个通道的数据存到4个寄存器里即可。我实测下来4通道轮询模式下每通道约120us的更新率人机接口上完全够用了。滑动平均滤波是我第二个推荐加的功能。AD采样最大的痛点是噪声尤其是电位器这类模拟信号源。我之前做了一个8点滑动平均滤波器在Verilog里实现非常简单——8个寄存器存历史数据每来一个新数据减去最旧的数据加上新的再除以8右移3位即可。这样做完之后采样值的抖动幅度从±5个字缩小到了±1个字效果立竿见影。卡尔曼滤波是下一个进阶方向。有同学可能会问卡尔曼滤波能在FPGA上跑吗当然能。对于一维标量的AD采样来说卡尔曼滤波的状态方程非常简单整个计算只涉及乘法和加法FPGA实现起来并不复杂。我在另一个项目里用定点数实现了一阶卡尔曼滤波输入8位AD值输出滤波后的估计值稳态误差比滑动平均还要小一个档次。不过这个工程量稍大适合对数学基础和Verilog都有一定积累的同学。把AD采样接到UART或以太网上也是不错的扩展。我最初做这个项目就是为了给一个虚拟示波器上位机供数据。FPGA侧采样AD值通过UART以115200波特率发送到PC上再用Python脚本实时绘图。这种“FPGA采集 上位机可视化”的组合在调试和演示时效果非常出彩。从系统眼光来看PCF8591这个项目虽然芯片简单但它打通了“模拟信号 → ADC → I2C → FPGA逻辑 → 输出接口”的完整链路。做完之后你会发现无论以后是接高速ADC还是别的I2C传感器思路都是相通的。把I2C这个基础协议吃透了很多外设对你来说都是“换个芯片地址”的事。本文还有配套的精品资源点击获取
返回列表