ARTICLE DETAIL

资讯详情

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

I2C Slave端Verilog实现:从时序到状态机的FPGA实战指南

I2C Slave端Verilog实现:从时序到状态机的FPGA实战指南 简介面向FPGA开发者的I2C从机Verilog设计资源适合需要实现微控制器与FPGA间串行通信的工程师及硬件语言学习者。压缩包大小1.2MB共50个文件核心为17个.v源码覆盖时钟边沿检测、从机地址识别、数据串并转换、应答产生及寄存器读写等关键逻辑同时配备测试脚本、仿真配置、PDF说明文档和工程文件便于直接编译与验证。资源按rtl、bench、sim、sw等目录模块组织层次清晰方便快速定位和二次开发。目前已有3359人学习使用。代码采用状态机统一调度配合移位寄存器完成数据收发并将协议分解为同步、解码、应答等子模块读者既能深入理解I2C从机时序也可将模块直接集成到FPGA工程中用于传感器读取、外设配置等场景节省从零编写和调试时间是FPGA通信接口开发的实用参考。 做FPGA或者数字IC的工程师几乎都写过或调过I2C master但一提到“i2c slave端verilog代码”很多人就开始皱眉。我之前在项目里接了一个温湿度传感器的小板子主机是MCU从机逻辑全部放在FPGA里本来以为照着协议写个状态机就行结果前前后后折腾了快两周问题全出在时序细节上SDA释放早了、ACK少打一拍、读操作的时候数据变化产生伪起始位……一路踩坑踩过来现在再让我写I2C slave就顺手多了。这篇文章就围绕I2C slave端Verilog实现来讲面向两类人一类是刚学协议、想看懂slave代码逻辑的初学者另一类是已经在写master、需要把slave接到自己工程里的工程师。我会把协议时序、RTL设计、状态机实现、仿真验证、调试排障串起来讲代码能给到可以直接参考复用的框架也会把文档里一般不写的坑都放在明面上。1. 项目概述这个I2C Slave端到底要解决什么1.1 为什么大家都在写MasterSlave却总翻车I2C协议本身不复杂两根线一根SCL时钟一根SDA数据主从之间靠地址寻址按字节收发数据。市面上能找到的例程九成都是master端因为master掌握SCL节奏自己控制写错了大不了读回来的数据不对调试起来相对直观。slave就不一样了。SCL是外部主机给的什么时候来时钟、什么时候产生起始停止条件完全不受自己控制。你的代码必须时刻监视外部总线在别人发来地址的时候立刻响应该拉低SDA回ACK就得回ACK该释放总线就得释放总线。再加上SDA是双向端口三态控制、同步处理、跨时钟域、边沿检测一起堆上来状态机稍微写得不够严谨就很容易在仿真好端端的、上板就随机挂死。还有一个容易被忽略的问题I2C是异步总线协议而FPGA内部几乎都是同步逻辑。外部SCL进入内部之前如果不做同步处理采样到亚稳态就是灾难。这个后面会专门说。1.2 功能需求拆解与前期规划一个最基本的I2C slave模块需要支持的功能可以拆成这几部分总线监控实时检测串行时钟SCL和数据线SDA识别起始条件、停止条件。地址匹配接收首字节后解析7位从机地址和读写标志位判断是否匹配本机地址。数据接收主机写数据时逐位接收、按字节组装并应答ACK。数据发送主机读数据时从机按字节发送并响应主机的ACK/NACK。寄存器接口把I2C通信接到的数据转成FPGA内部可直接使用的寄存器读写信号。在动手写代码之前先把“本模块支持哪些操作”定清楚。比如是一个寄存器映射的从机还是纯双向FIFO寄存器地址是单字节还是多字节是否支持连续读写是否需要在从机端做时钟拉伸这几个决策会直接影响状态机的复杂程度别一上来就想着把所有功能做满我做项目时一般先做一个最小可用的单字节读写版本验证总线时序没问题再往上加连续读写。2. I2C协议关键时序与RTL设计路线2.1 Start / Stop检测与毛刺处理I2C协议里起始条件的定义是SCL为高电平时SDA发生下降沿。停止条件则是SCL为高电平时SDA发生上升沿。注意这两个条件都要求SCL处于高电平而在正常数据传输阶段SDA必须在SCL低电平期间变化SCL高电平期间保持稳定。如果SDA在SCL高电平期间跳变总线就会认为这是一个起始或停止条件通信直接被打断。所以RTL设计里SDA边沿检测不能看到跳变就算数一定要同时采样SCL的电平状态。此外为了滤掉总线上的毛刺边沿检测前要先把SCL和SDA打两拍同步reg scl_d1, scl_d2; reg sda_d1, sda_d2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin scl_d1 1b1; scl_d2 1b1; sda_d1 1b1; sda_d2 1b1; end else begin scl_d1 scl; scl_d2 scl_d1; sda_d1 sda; sda_d2 sda_d1; end end wire sda_falling sda_d2 ~sda_d1; wire sda_rising ~sda_d2 sda_d1; wire start_cond scl_d2 sda_falling; wire stop_cond scl_d2 sda_rising;这里有个细节检测sda的下降沿我用的是同步后的sda_d2和sda_d1而不是直接用输入sda因为外部信号进来之后必须与系统时钟对齐否则后续判断很容易出现毛刺或误触发。SCL和SDA在高电平空闲状态下都被上拉电阻拉高所以复位时同步寄存器要初始化为1而不是0。2.2 采样怎么采SCL高电平均采样低电平切数据I2C协议规定数据位的有效性窗口在SCL高电平期间从机必须在这个窗口内读取SDA上的电平而SDA在SCL高电平期间必须保持稳定只有当SCL处于低电平时发送方才能切换SDA。对应到slave的RTL实现上就演化出了两条规则接收数据时在SCL上升沿采样SDA或者说在SCL高电平中间采样。发送数据时在SCL下降沿之后、低电平期间更新SDA确保数据在SCL高电平到来之前已经稳定。如果发送端在SCL高电平期间去改变SDA就会产生一个伪起始或伪停止条件对方状态机立刻乱掉。这是我踩得最深的一个坑。曾经有一个版本代码写数据切换用的是SCL上升沿触发结果在仿真波形上怎么看怎么对上板之后主机一直等到超时最后抓逻辑分析仪才发现波形里每一个数据位都带一个毛刺协议分析仪的视图里数据完全对不上。2.3 读操作的“先写后读”流程I2C读从机寄存器不是主机直接把读标志位发过来、从机就输出数据这么简单。典型寄存器型从机的读操作流程是主机先把目标寄存器地址写给从机然后主机再发一个重复起始条件重新发送从机地址并把读写位拉高这时候从机才进入发送模式把对应寄存器的值一路送出去。在RTL里这意味着状态机必须记住“主机之前写进来的寄存器地址”。具体实现的思路是地址匹配后的第一帧数据默认写入一个addr_reg等到检测到重复起始条件之后再根据当前R/W位判断如果此时是读就从addr_reg指向的寄存器取数发送。这样一个状态机就能兼容“写地址写数据”和“写地址读数据”两种流程。连续读写是另一个加分项但建议先把单字节读写跑通再加。连续读的时候地址要自动加1连续写同理每个字节都要独立回ACK最后主机用NACK来终止接收。2.4 时钟拉伸的取舍I2C协议规定从机可以通过拉低SCL线来“暂停”主机让主机等待从机准备好再继续发送时钟这个机制叫时钟拉伸。但在实际FPGA工程里我一般不轻易做时钟拉伸。原因很简单slave如果做了时钟拉伸SCL就变成漏极开路的输出结构需要额外的OC门和三态控制仿真和上板都更复杂主机端软件也要有相应支持。绝大多数FPGA内部的寄存器读写操作一个系统时钟周期就能完成根本不需要暂停主机。如果你的slave后面挂的是慢速接口比如EEPROM、温度采集再考虑在写入阶段加一个busy标志去拉低SCL。如果只是给MCU提供寄存器接口直接不做时钟拉伸把SCL当纯输入简单可靠。3. Slave端RTL实现模块划分与核心代码3.1 顶层接口与三态SDA处理SDA是双向端口FPGA里一般用inout声明内部通过一个三态缓冲器控制输出方向和输出电平。需要注意inout端口不能直接赋reg必须经过连续赋值语句从机不占用总线时必须输出高阻z让外部上拉电阻把SDA拉到高电平。module i2c_slave_top #( parameter I2C_ADDR 7h2A )( input wire clk, // 系统时钟示例用50MHz input wire rst_n, input wire scl, inout wire sda, input wire clk_gen, // 预留用于后续功能扩展 output reg [7:0] reg_value // 示例寄存器 ); reg sda_out; reg sda_oe; wire sda_in; reg [7:0] addr_reg; reg [7:0] data_reg; reg [7:0] bit_cnt; reg [7:0] shift_reg; reg [3:0] state; reg rw_flag; assign sda sda_oe ? sda_out : 1bz; assign sda_in sda;sda_oe为高时从机主动驱动SDA此时sda_out给0就是拉低总线相当于ACK或发送数据0给1就是释放总线。这一点和大部分人的直觉不太一样SDA在主动驱动时输出1实际表现为释放总线由上拉电阻把线拉高输出0才是真正意义上的拉低。所以数据发送逻辑里发送位1的时候sda_oe可以置0也可以置1并让sda_out为1效果等价但置1简单一些也容易理解。3.2 状态机控制核心状态机的核心思路前面已经说过这里给一个紧凑可综合的框架。为了控制篇幅我把单字节读写的场景作为主要示例包括起始检测、地址匹配、数据接收、数据发送、重复起始处理和停止检测。localparam IDLE 4d0; localparam START 4d1; localparam ADDR 4d2; localparam ACK_ADDR 4d3; localparam WR_DATA 4d4; localparam ACK_WR 4d5; localparam RD_DATA 4d6; localparam ACK_RD 4d7; localparam STOP 4d8; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; bit_cnt 4d0; shift_reg 8d0; addr_reg 8d0; sda_oe 1b0; sda_out 1b1; rw_flag 1b0; reg_value 8d0; end else begin case (state) IDLE: begin if (start_cond) state START; else if (stop_cond) state IDLE; end START: begin bit_cnt 4d0; state ADDR; end ADDR: begin if (scl_posedge) begin shift_reg {shift_reg[6:0], sda_in}; bit_cnt bit_cnt 1b1; if (bit_cnt 4d7) state ACK_ADDR; end end ACK_ADDR: begin if (scl_posedge) begin if (shift_reg[7:1] I2C_ADDR) begin sda_oe 1b1; sda_out 1b0; rw_flag shift_reg[0]; if (shift_reg[0] 1b0) begin addr_reg 8d0; state WR_DATA; end else begin state RD_DATA; end end else begin state IDLE; end end end // 后续状态类似处理 ... endcase end end这个框架把地址匹配放在了ACK_ADDR状态里好处是只有一个地方需要比对地址后续任何数据帧进来之前都已经明确知道“这个包是不是发给我的”。真实工程里状态机还会加上寄存器地址保存、读写数据通路的分流等逻辑但总线交互层的骨架就是上面这一段。3.3 数据发送的关键SCL低电平时切换高电平时保持从机向主机发送数据时每一位数据都必须满足协议在SCL低电平期间把数据放到SDA上在SCL高电平期间保持住。所以发送状态里要用SCL下降沿作为“换下一bit”的触发点而且数据要提前一个下降沿准备好。RD_DATA: begin if (!scl_d2) begin if (scl_falling) begin sda_out data_reg[7]; data_reg {data_reg[6:0], 1b0}; bit_cnt bit_cnt 1b1; if (bit_cnt 4d7) state ACK_RD; end end end注意这个写法只适用于一个字节的发送实际工程项目里建议做成移位寄存器加计数器的形式在初始化发送数据时先把data_reg载入要发送的值然后在每个SCL下降沿移出一位。每次sda_out赋值之后立刻进入sda_oe有效状态确保从机真正驱动总线不能等下一个状态才把oe拉起来否则高电平期间总线还在释放状态驱动强度不够可能被外部干扰拉偏。还有一点数据切换点的选择如果系统时钟远快于I2C时钟比如50MHz对400kHz一个SCL周期有125个系统周期SCL下降沿之后随便选一个沿作为切换点都行但如果系统时钟只比SCL快两倍就一定要在下降沿后马上切换尽量留足数据建立时间。3.4 寄存器读写数据通路I2C总线层把一串字节识别出来之后剩下的就是“怎么转成内部寄存器读写”。写通路很简单在ACK_WR状态里把收到的数据送到reg_value同时更新addr_reg供后续读操作使用。读通路则是在地址匹配并确认是读请求时先把addr_reg指向的数据寄存器的值锁存到data_reg然后逐位移出。这两件事最好分开做不要在同一个always块里既做I2C时序又做系统业务逻辑后续维护会非常痛苦。我自己的习惯是拆成两个模块i2c_slave_bus负责纯总线时序i2c_reg_file负责寄存器映射和业务接口。bus层只暴露addr、wdata、w_en、rdata等几个简单信号这样I2C换成SPI或者UART寄存器层一个bit都不用动。代码里为了篇幅直接把reg_value放在顶层了实际项目建议按这个拆分思路去组织。4. 仿真验证Testbench就是要“逼”出问题4.1 主机模型与基础用例I2C slave的仿真核心是写一个行为级的主机模型用来产生起始条件、发送地址、读写数据、接收ACK、产生停止条件。不要直接在testbench里手动拉信号最好写成task维护起来方便很多task i2c_start; begin sda_h 1; scl_h 1; #5 sda_h 0; #5 scl_h 0; end endtask task i2c_write_byte(input [7:0] data); integer i; begin for (i 7; i 0; i i - 1) begin sda_h data[i]; #2; scl_h 1; #5; scl_h 0; #2; end // 释放SDA检测从机ACK sda_h 1b1; #2; scl_h 1; #5; ack_flag sda; scl_h 0; #2; end endtask基础用例至少要覆盖写寄存器、读寄存器、写不匹配地址、停止条件后重新启动、重复起始条件。每跑完一个用例用$display打印当前状态和关键信号方便在波形里快速定位。4.2 边界、异常与压力用例很多人写完正常流程就以为仿真结束了实际上I2C slave最容易翻车的就是边界情况。我一般会额外加这么几类用例主机在数据帧中间发停止条件从机要能回到IDLE状态。主机在发送地址之后不继续发数据而是直接发重复起始从机要能正确处理。主机在接收数据时一字节后回NACK从机收到NACK之后要立刻释放SDA并且准备接收下一个起始。SCL上有毛刺比如中间突然多了一个非常窄的窄脉冲从机不能因为这个误判起始或停止。地址不匹配的情况下从机必须不拉低ACK并且后续所有数据都要忽略直到停止条件出现。前两类用例是我实际工作中发现Bug最多的场景尤其是“中间停止”和“地址不匹配后失聪”这两个状态机写得不严谨会直接卡死。4.3 波形检查和日志输出仿真不是跑完就完事了要养成看波形的习惯。我喜欢在testbench里把SDA的变化和从机状态机的变化对齐打印出来always (posedge clk) begin if (sda ! sda_dly) $display(%0t: SDA %b - %b, state %d, $time, sda_dly, sda, state); sda_dly sda; end这样跑完仿真之后哪怕不看波形图也能从日志里看出SDA每次跳变时从机处于什么状态。如果SDA跳变时从机状态和预期不一致问题就能锁到具体状态转移上。Modelsim或QuestaSim都支持虚拟总线功能把SCL和SDA打包成一条虚拟总线波形看起来会更直观推荐这么做。5. 常见问题排查与调试实战5.1 四个高频翻车点我根据自己的经历把I2C slave调试中常见的问题整理成下面这个速查表现象可能原因排查方法主机一直收不到ACK地址不匹配、SDA三态没拉低、同步打拍引入相位偏移过大检查地址比对逻辑、确认sda_oe在ACK周期置1且sda_out为0数据读到一半主机超时从机发送数据变化点不对在SCL高电平时切换了SDA用波形工具看SDA跳变是否都发生在SCL低电平期间从机状态卡死没处理中间停止、地址不匹配后的总线释放检查状态机是否有未覆盖的输入组合增加超时返回IDLE仿真正常上板失败同步未做好、复位初值不对、外部IO没有上拉检查两级同步、确认空闲态SDA不驱动、PCB上拉电阻阻值第一个问题最容易迷惑人。之前我遇到一次主机发地址后从机有时能收到ACK有时收不到后来才发现是地址比对的状态里用到了移位寄存器而shift_reg在起始条件之后没有清零上一次的数据残留到这一次的地址比较里导致地址偶尔匹配不上。解决办法是START状态里必须显式把shift_reg清零。第二个问题SCL高电平时SDA变化在仿真里往往表现为波形上的一个尖峰直接看不是很明显放大可以看到SDA在scl为高时跳变。这个必须在协议分析仪的逻辑视图里看否则很容易被“看起来差不多”骗过去。5.2 ILA / 逻辑分析仪与定位手法上板调试时我优先推荐在FPGA里例化ILA集成逻辑分析仪抓的信号包括同步后的scl_d2、sda_d2、sda_oe、sda_out、state、shift_reg、bit_cnt。触发条件可以设置为start条件或者state等于特定值。触发深度不必设太大1024就够处理一次完整的I2C写操作。如果没有ILA条件用示波器看SDA和SCL也行但示波器看不到内部状态定位问题效率低很多。另一个实用技巧是边调试边加“诊断寄存器”每进入一个状态就通过UART或者SPI把这几个状态寄存器的值发出去这样即使不上ILA也能知道从机内部跑到哪里了。还有一个容易被忽视的点SDA的上拉电阻。FPGA开发板上的I2C上拉电阻阻值一般是2.2k到10k如果外接了长线或容性负载过大SDA边沿会变缓SCL高电平时间不够时从机采样就容易出错。我之前调一块扩展板就是这种问题把上拉电阻换小一点问题马上消失。我在实际调试中养成了一个习惯每次写完I2C slave先不急着连业务逻辑单独让MCU扫描一下从机地址确认ACK正常再继续调数据读写。地址扫描这一步看着简单却能最快地把“总线连接问题”和“协议逻辑问题”区分开。这样一路调下来踩坑数量会少很多后面加功能时心里也踏实。本文还有配套的精品资源点击获取
返回列表