前阵子调一块采集板,FPGA要从板边的EEPROM里读校准系数,结果读回来整片都是0xFF。抓波形一看,ACK那拍前面一切正常,可第九个时钟过去之后SDA根本没被拉低。查到最后,问题不在EEPROM,而在我自己写的驱动状态机里——应答采样那拍比理论值慢了半个时钟周期。
这件事让我意识到,IIC这种看起来简单的协议,真正自己写驱动的时候,细节全藏在时序边界里。这篇笔记就把我从源码到仿真、再下板验证的完整过程整理出来,包括状态机设计、三态IO处理、模拟从机的搭建,以及上板后遇到的真实问题和排查手段。如果你也准备在FPGA里实现IIC驱动、或者正在被各种IP核的配置界面搞得头疼,这篇应该能帮你少走不少弯路。
1. 为什么放着现成IP核不用,偏要手写这段时序驱动
1.1 我原本觉得用IP核就差不多了
说实话,刚开始我并没有打算手写IIC。现在的开发工具里基本都集成了IIC控制器IP,图形界面点几下,引脚一分配,理论上就能跑。但我实际用下来,IP核最大的问题不是不能用,而是当协议出问题的时候,你很难插手看它内部到底在干什么。
比如说,读EEPROM回读全FF,用IP核我只能怀疑从机地址、寄存器地址、引脚约束,但IP核内部从哪一个状态卡住、ACK是在哪个时钟沿采样的,这些在仿真模型里看得不直观。手写驱动有一个天然优势:每个状态都摊在你眼前,出问题直接看状态机跳转和引脚波形就行,不需要去猜“封装好的模块内部逻辑”。
再者,很多项目里的IIC从机并不是标准EEPROM。有些传感器的寄存器映射带保留位、有些需要主机在特定位置插入延时,还有些从机要求停止信号之后必须隔多久才能再次发起传输。IP核虽然也支持自定义,但配置项一多,反而比写状态机还费劲。
1.2 自己写驱动的几个实际收益
我自己写完这版之后,总结了四个看得见的收益。
第一是时序可控。想把SCL从400kHz降到100kHz,改一个分频参数就行;想让停止信号之后多留几个周期再操作,直接在状态机里加一拍。手写驱动对“每一拍在做什么”是透明的。
第二是便于在数据通路里集成。很多场景下FPGA内部不只有IIC这一件事,还要同时处理采样、滤波、存储。手写驱动可以把IIC读写封装成顶层模块里的一个“动作”,由主状态机按需触发,而不是让一个常年运行的IP核占着总线。
第三是仿真可控。手写状态下,我可以自己在testbench里做一个模拟从机,想让它返回ACK就返回ACK,想让它NACK就NACK,甚至故意让它延迟应答。这种“故障注入”能力在验证驱动健壮性的时候非常有用。
第四是能理解协议本身。这点看起来虚,实际很实。你在波形里亲眼看懂起始条件、停止条件、ACK的第九拍,以后遇到任何挂在IIC总线上的芯片,看数据手册都会快很多。
1.3 在什么情况下我还是建议你选IP核
自己写驱动虽好,但我得把丑话说在前面:不是所有场景都值得手写。
如果系统里挂了多个不同类型的IIC从机,还要处理总线仲裁、多主机切换、时钟拉伸,那IP核或者带硬件IIC外设的处理器更合适。我们FPGA里写软核IIC,本质上是靠主状态机逐拍“咬”时序,在高速总线或复杂仲裁场景下,状态机会变得很庞大,反而不如硬件外设可靠。
还有工期紧张的情况。如果项目就剩三天,能力验证优先,直接调IP核是最稳的。手写驱动更适合你想把这块做成可复用模块、或者需要在既有代码基础上做深度定制的时候。
2. 先吃透波形:SCL高低电平、起始终止和应答位不能只靠感觉
2.1 IIC时序的四根“硬骨头”
写代码之前,我花了大半天把IIC时序重新理了一遍。很多初学者写驱动翻车,就是没把这几条基础边界搞死。
第一,起始条件和停止条件。起始条件是SCL保持高电平期间,SDA从高变低;停止条件是SCL保持高电平期间,SDA从低变高。注意,这两条边沿都是SDA在SCL高电平期间跳变。为什么要这么设计?因为从机可以随时监听这条总线,它需要一种“和普通数据传输完全不同”的信号来识别边界。普通数据位要求SDA只能在SCL低电平期间改变,而起始和停止偏偏在高电平期间改变,两者天然区分。
第二,数据位的稳定性。除起始和停止外,SDA的电平必须在SCL高电平期间保持不变,从机在这个窗口采样。也就是说,SDA只能在SCL低电平期间完成翻转。我们写状态机的时候,时序上必须保证:先保证SCL为低,再去改变SDA,然后再让SCL变高去“锁存”这个数据。
第三,应答机制。每个字节传完之后,发送方释放SDA,接收方在第九个SCL时钟周期拉低SDA表示应答。主写模式下,从机应答;主读模式下,主机应答。如果从机不应答,说明地址错误、总线忙或者从机内部异常。
第四,字节顺序。IIC数据是按MSB先发的,从第7位到第0位。这个看似简单,但移位寄存器的方向写反,整个帧就会错。
2.2 从EEPROM随机读看一帧总线的完整流程
我用的是某款常见的IIC EEPROM,7位从机地址加上读写位之后,写地址是0xA0,读地址是0xA1。对EEPROM做随机读,整帧流程分成两段。
第一段是写寄存器地址,也叫“伪写”:主机发出起始条件,然后发0xA0(从机地址加写方向),等待从机ACK;接着发一个字节寄存器地址,等待从机ACK;再发停止条件。这一段的作用是让EEPROM内部的地址指针指向目标位置。
第二段才是真正读数据:主机再次发出起始条件,发0xA1(从机地址加读方向),等待从机ACK;然后主机每发一个SCL时钟,从机就往SDA上送一个数据位,主机在第八个数据位结束后,如果还想继续读下一字节,就回ACK;如果只读这一字节,就回NACK,然后发停止条件。
这里有个关键点:读阶段的起始条件,跟第一段之间不插入停止条件,而是直接发起第二次起始。这种“没有停止就直接开始”的信号叫重复起始条件。很多写IIC驱动的人第一版漏掉这个东西,改成“停止→开始”,在部分器件上也能跑,但在严格要求连续读的器件上就会出错。
2.3 用时钟还是用延时:分频参数的计算
驱动IIC有两种基本路子:一种是用延时函数,一种是用系统时钟分频产生SCL。在FPGA里,我强烈建议用分频方式,因为延时函数依赖语句执行时间,综合后延时不一定和仿真一致,很容易出现“仿真过了,上板翻车”。
我板上的系统时钟是50MHz,目标SCL频率是400kHz。分频系数怎么算?SCL周期 = 1 / 400kHz = 2.5微秒;系统时钟周期 = 1 / 50MHz = 20纳秒;所以一个SCL周期需要2.5微秒 / 20纳秒 = 125个系统时钟周期。SCL高低各占一半的话,就是62.5个周期。
实际代码里不可能有0.5个周期,所以我让SCL高电平持续63个周期、低电平持续62个周期,也就是高低电平各约1.24微秒和1.26微秒。IIC标准要求SCL高电平最小0.6微秒、低电平最小1.3微秒。有人看到低电平1.26微秒可能觉得不满足要求,实际标准里还留了上升沿和下降沿的余量,在低速板级环境下这个偏差完全可用。如果你比较保守,可以把半周期基数统一改成64,再把SCL降到380kHz左右,这样高低电平都超过标准最小值,稳妥很多。
我代码里没有写死分频数,而是定义了两个参数:
localparam SYS_CLK_PERIOD_NS = 20; // 50MHz localparam SCL_PERIOD_NS = 2500; // 400kHz localparam SCL_HALF_DIV = (SCL_PERIOD_NS / SYS_CLK_PERIOD_NS / 2) - 1;把两个半周期分开计数,一半给高电平,一半给低电平,调频率只需改参数,不用改状态机逻辑。
3. 源码模块拆解:可复用主状态机与容易出问题的三态SDA口
3.1 模块划分与接口约定
驱动我没有写在一个大文件里,而是按功能拆成三个模块,后面加功能的时候不用推翻重来。
第一个是顶层模块iic_top,对外提供读写接口。主机只需要给它几个信号:系统时钟、复位、读写请求、寄存器地址、写数据、读数据回传,以及一个busy标志表示驱动忙不忙。上层逻辑不关心SDA怎么翻转,只关心“发一个写命令”和“发一个读命令”。
第二个是时钟分频模块,负责根据系统时钟生成SCL半周期计数。我在里面也顺带过滤了毛刺,分频计数器的翻转沿同时用作状态机的节拍。
第三个是IIC主状态机模块,这是核心。它内部维护当前状态、位计数器和引脚输出方向。对外只操作两条线:scl输出和sda双向端口。
3.2 核心状态机的状态转移
状态机我没有刻意追求诡异写法,就是用最直白的单进程状态机。状态列表大概长这样:
localparam ST_IDLE = 4'd0; localparam ST_START = 4'd1; localparam ST_SEND_ADDR = 4'd2; localparam ST_WAIT_ACK = 4'd3; localparam ST_SEND_REG = 4'd4; localparam ST_WAIT_ACK2 = 4'd5; localparam ST_RESTART = 4'd6; localparam ST_SEND_RADDR= 4'd7; localparam ST_RDATA = 4'd8; localparam ST_SEND_ACK = 4'd9; localparam ST_STOP = 4'd10;中间状态看起来多,实际每一段逻辑串起来就是一个完整读写流程。关键点在于:我并没有为“读”和“写”各写一套独立逻辑,而是让同一套状态机按方向信号跳转。
比如ST_SEND_ADDR这个状态,发的是从机地址加方向位。方向为写,就发0xA0;方向为读,先发0xA0走伪写,然后再走ST_RESTART。这样一来,驱动只需要一个“读写模式选择”输入,就可以完整覆盖EEPROM的随机读流程。
状态跳转上有个容易写歪的地方:发完一个字节之后,必须留出一个“释放SDA”的拍,让从机有机会把SDA拉低。我采取的做法是:在第8个数据位结束后,把sda输出置为高阻,然后在第9个时钟沿采样SDA。如果SDA为低,表示从机应答;如果为高,就进入NACK处理。
这段逻辑的核心代码如下:
// 在第九拍采样ACK if (bit_cnt == 3'd7 && half_cnt == SCL_HALF_DIV) begin ack_in <= sda_in; bit_cnt <= 3'd0; // 根据ack_in决定下一步状态 if (sda_in == 1'b0) next_state <= ST_NEXT_BYTE; else next_state <= ST_STOP_AND_BACK; end如果你把采样点提前或者错后,就会出现我开头说的那种“ACK明明有,驱动却认为没有”的怪现象。
3.3 三态SDA的正确写法
IIC的SDA是双向端口,FPGA里必须用inout声明,而且不能简单地给它赋值,否则约束阶段就会报错。
我用到的方法是通过一个输出使能信号sda_en来控制。当需要驱动SDA时,使能拉高,SDA按照发送方向输出;当需要释放总线等待从机应答时,使能拉低,SDA变成高阻态。
这样写:
assign sda = sda_en ? sda_out_reg : 1'bz; assign sda_in = sda; // 回读总线状态仿真里看到高阻态,一般显示为高电平,因为testbench会给总线加上拉模型。真实板子上,IIC总线必须有外部上拉电阻。这个电阻的作用是:总线上所有器件都释放SDA时,SDA被上拉到高;任何器件拉低SDA,总线就被拉低。这就是“线与”逻辑。
很多第一次写IIC驱动的人,最容易在这个三态口上栽跟头。忘记把使能信号在应答窗口拉低,从机永远没有机会应答;或者使能信号切换时机不对,导致SDA到第九拍才变成高阻,从机压根没驱动到线上。这都需要结合波形一点一点看。
3.4 几个实战中要盯死的状态细节
再分享三个我在调试过程中反复确认过的小细节,每一个单拎出来都能让人卡一下午。
第一个是每字节结束后的“释放窗口”。不要在这个窗口里对SDA做任何控制,让从机自己拉低。如果驱动在这个期间强输出高电平,从机即使想应答也拉不动线,因为总线被主机占住了。这本质上违背了IIC“开漏+上拉”的物理模型。
第二个是NACK之后的安全处理。从机可能因为地址不对、内部忙等原因不应答,此时如果状态机还停在等ACK的循环里,总线就会永久卡住。我给的策略很简单:检测到NACK,直接跳到停止状态,发完停止条件后回到空闲状态,并把错误标志位置高。上层逻辑看到错误标志,可以决定重试还是报错。
第三个是读方向切换时的“一拍缓冲”。在主机从发送数据切换到接收数据时,SDA方向从输出变成输入不能和采样发生在同一拍。因为引脚方向切换之后,线上电平需要时间稳定,如果立即采样,采到的很可能是高阻态带来的毛刺值。多打一拍再采样,浪费不了多少时间,但能避开一堆随机错误。
4. 仿真先行:搭一个假的从机,先把读写流程在电脑上跑通
4.1 为什么仿真阶段就要把从机模型写出来
很多人写IIC驱动,testbench里就放一个简单的寄存器组,赋值完就完事。但我的经验是:如果你不把从机的总线响应行为建出来,仿真通过的上板不一定通过。
举个例子,很多仿真把从机数据直接放在一个always块里,等SCL上升沿一到来就赋到SDA上。这看起来合理,但忽略了IIC从机的响应时序:从机要在SCL高电平期间采样数据,在SCL低电平期间输出下一位。如果不按这个节奏写模型,DUT发出的时序是否正确,根本得不到有效验证。
所以我在testbench里做了一个“行为级模拟从机”,不关注EEPROM内部的存储结构,只关注总线协议:地址匹配时应答、收到寄存器地址后保存、读阶段按地址回数据、最后一个字节等待主机NACK。这样DUT时序对不对,一眼就能看出来。
4.2 testbench的骨架与关键行为
testbench的结构不复杂,DUT就是我们的IIC驱动,模拟从机挂在SDA和SCL上,再加一个虚拟上拉模型。上拉模型我习惯这样写:
assign sda = sda_drv ? 1'b0 : 1'bz; pullup(sda);也就是说从机要拉低SDA就置sda_drv为1,否则释放总线,由pullup把电平拉高。这样一个模型能同时模拟真实从机的开漏输出和总线上拉。
模拟从机的核心行为,最关键的是应答逻辑。伪代码如下:
// 检测到起始条件后,接收8位地址 // 如果地址匹配,在第九拍拉低SDA if (bit_cnt == 7 && scl == 1'b0) begin bit_cnt <= 0; if (addr_ok) sda_drv <= 1; // 拉低,产生ACK else sda_drv <= 0; // 释放,产生NACK end注意拉低SDA的时机必须发生在SCL低电平窗口内,并且保持到第九个SCL高电平结束。在仿真里,你可以对着波形检查DUT的SCL和模拟从机的SDA驱动窗口是否对齐,直观程度比上板抓波形高很多。
4.3 验证读回数据的三种检查方法
我在验证驱动的时候,不只是看波形。波形只能说明时序对,不能说明数据对不对。
第一层检查是看波形,重点观察起始条件、停止条件、ACK位置和SCL/SDA的数据对应关系。这一层能发现90%的时序问题。
第二层检查是数据断言。在testbench里等DUT的read_valid信号拉高,然后把读回的数据和testbench预存的值做比较。
reg [7:0] expected; always @(posedge read_valid) begin if (read_data == expected) $display("READ OK"); else $display("READ FAIL, exp=%h, got=%h", expected, read_data); end第三层检查是故障注入。模拟从机故意在某个地址返回NACK,或者故意拖慢应答,看DUT是否还能按计划回到空闲状态并拉高错误标志。这一步做一次,以后在真实板子上遇到从机不响应,心里就不慌了。
我记得有一次仿真全部通过,结果上板头一次读EEPROM,数据依然全FF。后来发现是仿真里的模拟从机只响应了0xA0地址,但板子上EEPROM的A0、A1、A2引脚被我接错电平,实际地址变成了0xA2。这也是为什么仿真和真机要分开看待:仿真验的是驱动逻辑,真机验的是硬件连线。
5. 上板前后最容易翻车的三个地方与验证过程
5.1 引脚约束和上下拉电阻:不是把SDA/SCL连上就行
下板之前,我本来觉得约束写清楚引脚位置就没问题了。结果第一次跑,SDA引脚上的信号幅度很奇怪,SCL还能看到时钟,SDA上却几乎没有低电平。后来拿万用表量才发现,我选的那个引脚,板子上漏接了上拉电阻,总线一直浮在高位,数据根本传不下去。
这里给一个很实用的检查清单:
第一,从原理图里确认SCL和SDA引脚是否连到了对外接口或芯片,确认没有和板上其他复用功能冲突。很多开发板同一引脚默认接了LED或按键,直接复用会导致信号被其他外设拉偏。
第二,确认上拉电阻。IIC标准里上拉电阻一般在1k到10k之间,常见4.7k。如果板子已经放了上拉,就不需要额外处理。如果是自己画的板子,一定要至少留两个电阻位,不然只能飞线,调试会非常痛苦。
第三,IO电压要和从机匹配。FPGA的IO bank供电如果是3.3V,从机也必须是3.3V逻辑的3.3V供电。如果从机是1.8V的传感器,总线电平不匹配,就需要电平转换或者开漏加外部上拉到1.8V。
我在约束文件里会写成下面这样,目的就是把引脚物理属性和逻辑属性一起定下来,避免后续反复查:
set_property PACKAGE_PIN M18 [get_ports scl] set_property IOSTANDARD LVCMOS33 [get_ports scl] set_property PACKAGE_PIN M19 [get_ports sda] set_property IOSTANDARD LVCMOS33 [get_ports sda] set_property PULLUP true [get_ports sda] set_property PULLUP true [get_ports scl]注意,PULLUP属性在FPGA内部是弱的可编程上拉,一般不足以替代外部上拉电阻。它更多是防止引脚悬空。真正的总线偏置还是得靠板级电阻。
5.2 用开发工具自带的逻辑分析仪抓真实波形
上板之后,靠逻辑分析仪才是正经的调试手段。我不建议只靠自带示波器看两个电平,因为IIC数据位和总线电平变化太快,人工数波形很容易看漏。开发工具里一般都有集成逻辑分析仪IP,加在SDA和SCL上,触发条件设为“SDA下降沿且SCL为高”,就能正好抓到来一次起始条件。
抓波形的时候,有两点要注意。
第一点,采样深度。IIC一帧数据包含起始条件、8位地址、应答位、8位数据、停止条件,加起来怎么也得几十拍。如果采样深度设得太小,只抓到前半段没抓到后半段,等于没抓。一般建议至少抓到从起始条件到停止条件整段波形。
第二点,触发时机。可以先用“下降沿触发”抓一下,看是否抓到起始条件。如果没有,说明SDA和SCL可能接反了或者约束有问题。抓到之后,再把触发条件改成“SCL高且SDA上升沿”,专门抓停止条件,验证驱动有没有正确结束总线。
下面这张表是我当时整理出来的“典型故障速查表”,现在每次调试都先对着过一遍。
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 读回全FF | 从机地址错误、伪写地址未写入、上拉失效 | 用逻辑分析仪抓完整帧,核对地址字节 |
| ACK始终为高 | 驱动没有释放SDA、引脚约束错误 | 检查状态机应答窗口使能信号 |
| SCL频率明显偏快 | 分频参数计算错误、锁相环倍频未计入 | 回算分频参数,抓SCL波形数周期 |
| 偶发卡死 | 写入后缺停止条件、状态机停在等待应答 | 抓停止条件,核对状态机跳转 |
5.3 我踩过的坑与对策清单
具体到我的验证过程,最折腾的是“地址伪写阶段少了停止条件”。
第一次写读时序时,我只做了“起始 + 地址 + 寄存器地址 + 重复起始 + 读地址 + 数据”,没有在伪写完成后插入停止条件。仿真里看起来一切正常,因为模拟从机不在意停止条件。但真机上的EEPROM内部状态机比较严格,少了停止条件,它认为总线还没结束,后续的重复起始就被忽略了,于是读数据永远返回0xFF。
这个问题在仿真里根本看不出来,因为模拟从机写得太宽松。后来我把testbench的模拟从机改成“没有停止条件就不响应任何后续命令”,仿真立刻复现了真机故障。这个案例也说明:仿真模型要尽量贴近真实器件的时序要求,不要图省事。
另一个坑是SCL频率。当时我把50MHz时钟直接拿来分频,但没意识到主时钟经过了锁相环倍频成了100MHz。分频系数还是按50MHz算,实际SCL跑到了800kHz,EEPROM在400kHz模式下能工作,但时序余量变小,偶尔有偶发错误。把分频参数按100MHz重算后,问题消失。
6. 这版驱动留了哪些优化口子:突发传输、仲裁与时钟拉伸
6.1 单字节读写到多字节的顺序读
我当前这版驱动做的是单字节随机读和单字节写,但在实际项目里,经常需要一次性从EEPROM读出一片连续数据。要做支持多字节顺序读,其实只在现有状态机上加一个“地址自增”逻辑就行。
EEPROM自带地址自增功能:只要主机在读阶段连续回ACK,从机就会自动把地址指针加1,然后在下一个字节里输出新地址指向的数据。所以驱动只需要在读完一个字节后,判断“还要不要继续读”,如果要,就回ACK而不是NACK,然后回到读数据状态继续读下一字节。
伪代码思路:
if (read_len > current_len) begin sda_en <= 1'b1; // 主机回ACK next_state <= ST_RDATA; end else begin sda_en <= 1'b0; // 主机回NACK next_state <= ST_STOP; end这个改法不需要动起始、地址、伪写这些状态,改动集中在一小段判断逻辑里,所以我在设计的时候就特意保留了read_len信号接口。
6.2 加入仲裁检测
我这块板子目前只有一个主机,所以没有写总线仲裁。但如果你的系统里可能有两个FPGA或者一个MCU同时挂在同一根IIC总线上,就必须考虑仲裁。
IIC仲裁的基本原理是:主机在发送数据位时,不断回读SDA。如果自己输出的是1,但读到SDA被拉低,说明有其他设备也在发数据且发了0,那么当前主机就应该停止发送,退出竞争。
在状态机里加仲裁其实改动也不大:在SDA输出高电平的半周期,比较一下sda输入是否真的为高。如果发现被拉低,就立即进入“仲裁失败”状态,释放总线并置仲裁错误标志。
不过老实说,FPGA软核做仲裁不是强项。因为仲裁要求每个SCL半周期都实时监测并快速反应,状态机在分频基础上工作,反应时间固定在一个节拍内,慢速IIC没问题,400kHz下也够用,但更高频率或者网络上有多个快速主机的场景,还是优先考虑硬件IIC外设。
6.3 协议层之外的优化:把驱动包成任务
最后一件事我想聊聊接口优化。这版驱动的对外接口虽然简洁,但上层逻辑在联合其他模块时,还是会出现“想发一个命令,结果需要等busy信号拉低很久”的等待问题。
一个比较好用的思路是:把IIC驱动的状态机抽象成“服务”,上层用自己的状态机去调用。比如我想读EEPROM地址0x10的一个字节,上层状态机会拉高read_req,同时把地址和数据放在总线上,然后等busy信号下降。读取结果在read_valid上升沿时锁存。这样上层流程就变得像一个函数调用,逻辑很清晰。
之后如果还要接入串口命令,只需在顶层判断串口解析出的指令,再映射成IIC读/写请求即可。这套封装让模块之间耦合度很低,单测、集成测试都好写。
最后再分享一个个人习惯:上板的第一晚,先让FPGA以100kHz把EEPROM的内容读回来,对比仿真里的预期值。确认数据一致之后,再把频率调回400kHz。降低频率不会让逻辑出错,但能放大很多毛刺和时序边际问题。我已经用这个办法省了不知道多少冤枉时间,希望这篇笔记帮你在IIC驱动的路上同样省心。