ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

FPGA实现I2C从机设计:从协议时序到Verilog代码实战

2026/9/3 19:44:13 拓冰建站 浏览量
FPGA实现I2C从机设计:从协议时序到Verilog代码实战 简介一套以Verilog硬件描述语言实现的I2C从设备slave完整设计资料面向数字IC与FPGA学习者重点讲解I2C总线协议的时序规范、从机状态机建模、地址解析和读写控制等核心环节。压缩包共包含161个文件以.v源码为核心辅以.vhd硬件描述文件、ISE工程配置.xise/.gise、仿真波形.wlf/.vcd以及C测试代码与综合日志其中.v/.vhd用于描述硬件逻辑.c用于功能验证.wlf/.vcd用于仿真分析整体压缩后仅1.39MB便于下载查阅目前已有577人学习浏览。资源内除基础I2C模块外还出现AES加密相关模块可在串行通信中嵌入安全功能通过源码与仿真记录可逐步掌握SCL边沿检测、SDA数据采样、从机地址匹配、ACK/NACK应答、数据缓冲及错误恢复等完整设计流程同时借助日志与备份文件还能了解Xilinx FPGA工具链的综合、仿真与调试过程进一步加深对I2C协议和FPGA开发流程的认识。因此这一资源特别适合作为课程设计、毕业设计或工程实践中关于I2C通信与安全扩展的参考模板。 做I2C从机端设计这件事我前后折腾过好几个版本从最初照着协议手册硬写到后来把时序图刻在脑子里踩了不少坑也沉淀了一些真正好用的设计套路。这篇帖子就把我在FPGA里实现i2cslave端的关键细节完整梳理一遍从协议层面的时序理解、状态机建模到Verilog代码落地再到调试中的疑难杂症一次讲透。内容适合正在写I2C从机逻辑的工程师也适合刚接触低速总线协议、想弄明白从机端到底该怎么设计的新手。1. 开始之前从机端设计前必须吃透的几个协议细节很多人在I2C从机设计上翻车根本不是代码写错而是对协议的理解只停留在“有起始、停止、ACK这些东西”的层面真到设计状态机的时候才发现处处是坑。我先把从机端必须吃透的协议细节理一遍这些直接决定你后面状态机怎么写、采样点怎么选。1.1 起始条件、停止条件与总线忙检测I2C总线空闲时SDA和SCL都是高电平由外部上拉电阻维持。主机发起通信时先拉低SDA这个下降沿发生在SCL高电平期间就是起始条件START。停止条件则相反SDA的上升沿发生在SCL高电平期间即停止条件STOP。从机端设计的第一件事就是把这两个条件检测出来。这里有个关键点起始和停止条件都是边沿事件不是电平状态所以不能用简单的电平采样必须做边沿检测逻辑。我在设计里用一个两级同步器把外部SDA、SCL同步到系统时钟域然后对同步后的信号做上升沿和下降沿检测。这样做的原因是外部总线信号相对内部时钟是异步的不经过同步直接采极大概率会出现亚稳态轻则状态机误触发重则整个逻辑跑飞。总线忙检测也依赖起始和停止条件检测到START就置忙标志检测到STOP就清忙标志。我做的从机里还有一个超时清忙的兜底逻辑防止主机异常掉电导致总线一直挂着这个后面在调试章节详细说。1.2 地址匹配从机怎么认出主机在叫自己起始条件之后主机发送的第一个字节是高7位从机地址加1位读写标志。每个从机把自己的地址配在寄存器里收到地址帧时逐位比较匹配成功才继续响应不匹配就直接忽略后面的数据直到总线再次出现START或STOP。地址比较的时序窗口要把握好。地址位是在SCL高电平期间被采样的从机应该在SCL上升沿采样SDA还是在高电平中间采样主流做法是SCL上升沿采样。原因是从机不知道主机什么时候翻转SCL但I2C协议规定数据在SCL低电平期间变化、高电平期间稳定所以SCL上升沿采SDA采到的一定是稳定值。反过来如果选下降沿采到的可能是刚好变化中的毛刺。地址匹配还有一个细节支持10位地址还是只支持7位地址。7位寻址下地址比较就是8个比特的事7位地址加读写位状态机里用一个移位寄存器串行接收收满8位后统一比较。我之前做过一个设计为了省寄存器边收边比较结果遇到主机地址里带着读写位变化的情况比较逻辑写得非常别扭。后来改成“先收完、再比较”的套路清晰很多时序上也完全来得及。1.3 ACK/NACK的生成时序从机反馈的窗口期地址匹配成功或每收到一个数据字节后从机需要在第9个SCL时钟周期拉低SDA告诉主机“我收到了”。如果从机忙不过来、或收到非法命令就在第9个周期保持SDA高电平也就是NACK。这个第9个周期的窗口设计是很多人的盲区。从机什么时候把SDA拉低如果等SCL已经变成高电平再拉就晚了因为这时主机已经在采样你拉低的动作会被当成数据。正确做法是在第8个SCL下降沿之后、第9个SCL上升沿之前把SDA拉低。具体到状态机里就是在接收完第8个数据位、检测到SCL下降沿时把SDA输出使能拉起来强制输出低电平。这里有个容易踩的坑SDA是开漏结构从机要拉低它得打开输出驱动要释放它得关闭输出驱动变成高阻而不是直接输出高电平。有些新手直接把SDA寄存器赋1其实驱动不了上拉的高电平总线还是被自己拉死。我见过不少从机把总线死死拉低、主机永远等不到ACK的案例根因就是这里。2. 从机状态机设计把协议翻译成硬件逻辑理解了协议细节下一步就是设计状态机。I2C从机的整体工作流程本质上是“等待起始条件 - 接收地址 - 判断读写 - 读写数据 - 等待停止条件”这么个循环。我用状态机建模时把它拆成了几个清晰的状态。2.1 状态机总览与状态转移表我的从机状态机有五个核心状态IDLE空闲、ADDR地址接收、ACK_ADDR地址应答、DATA数据传输、ACK_DATA数据应答。实际操作中读写方向是在DATA状态里根据之前收到的读写标志位分流的这样能少两个状态状态转移也更集中。状态转移的逻辑大致如下表当前状态转移条件下一状态动作说明IDLE检测到STARTADDR开始接收地址帧复位位计数ADDR收满8位地址ACK_ADDR比较地址准备应答ACK_ADDR地址匹配且为写DATA拉低SDA应答准备收数据ACK_ADDR地址匹配且为读DATA拉低SDA应答准备发数据ACK_ADDR地址不匹配IDLE忽略后续数据直到下一个STARTDATA写方向收满8位ACK_DATA准备数据应答DATA读方向发满8位ACK_DATA采样主机ACK决定继续还是停止ACK_DATA收到STOPIDLE清标志回到空闲ACK_DATA收到下一个STARTADDR支持重复起始条件这个表做出来之后状态机的骨架就定了。剩下的就是往里填计数器和数据通路的逻辑。2.2 位计数与字节组装的实现细节状态机里藏着一个位计数器它从0数到7每来一个SCL上升沿加1。因为地址帧和数据帧都是8比特位计数到7时就意味着一个字节收完了此时触发状态切换。位计数的使能条件要注意不能只要是SCL上升沿就计数。在IDLE状态下还没有合法的START这时总线上的SCL活动可能来自别的从机你跟着凑热闹就会乱套。我的设计里位计数器只有在状态机进入ADDR或DATA状态后才使能其他状态一律清零复位。字节组装的逻辑是每个SCL上升沿把移位寄存器左移一位然后把SDA采到的值填到最低位。收完8位后这个移位寄存器里就是完整的字节。地址帧和数据帧共用一个移位寄存器没问题因为它们在时间上是错开的地址判断在ACK_ADDR状态下完成之后这个寄存器就腾出来给数据用了。2.3 读操作从机怎么在时钟低电平期间把数据送到总线上读方向比写方向麻烦。写方向是从机被动接收只要卡准采样点就行。读方向是从机主动输出数据必须保证SDA上的数据在SCL高电平期间保持稳定只能等SCL低电平期间才能翻转数据。结合I2C协议从机的数据输出要在SCL下降沿之后更新这样数据有一个完整的低电平周期来稳定等到SCL上升沿时主机来采样数据早就稳了。实现上就是在检测到SCL下降沿时把移位寄存器左移一位、输出最高位或者直接把下一个数据位送到SDA上。我之前做第一版的时候图省事用“SCL高电平期间直接翻转SDA”结果主机采到的数据经常错位用示波器抓波形发现数据变化点就在时钟采样点附近根本不稳定。后来老老实实改成“下降沿更新、高电平保持”波形干净了很多。这个经验后来写进了我们团队的设计规范里。3. Verilog实现从接口到核心代码的逐层拆解协议和状态机想清楚后写代码就变成了一件照图施工的事。我用Verilog实现过一个完整的I2C从机挂在FPGA内部的总线上下面把核心模块和代码框架贴出来附上关键注释方便直接复用或改造。3.1 顶层模块接口定义从机顶层接口我习惯这样设计对外是两条总线引脚sda、scl对内是寄存器读写接口和中断/状态输出。module i2c_slave #( parameter I2C_ADDR 7h2A // 7-bit 从机地址可配置 )( input wire clk, // 系统时钟例50MHz input wire rst_n, // 异步复位低有效 inout wire sda, // I2C数据线开漏 input wire scl, // I2C时钟线输入 output reg reg_wr_en, // 寄存器写使能 output reg [7:0] reg_wr_addr, // 寄存器写地址 output reg [7:0] reg_wr_data, // 寄存器写数据 output reg reg_rd_en, // 寄存器读使能 output reg [7:0] reg_rd_addr, // 寄存器读地址 input wire [7:0] reg_rd_data // 寄存器读数据由外部寄存器堆提供 );SDA是inout类型内部输出经过三态门控制。三态门的控制信号是关键只有需要拉低总线时才输出0平时输出高阻。这个三态控制信号在代码里我命名为sda_out_en由状态机在ACK和数据输出阶段精确控制。3.2 起始/停止检测模块起始和停止检测是整个从机逻辑的“感官”必须可靠。我用两级同步器加边沿检测来实现代码结构大致如下reg sda_sync1, sda_sync2; reg scl_sync1, scl_sync2; wire sda_fall, sda_rise; wire scl_fall; always (posedge clk or negedge rst_n) begin if (!rst_n) begin sda_sync1 1b1; sda_sync2 1b1; scl_sync1 1b1; scl_sync2 1b1; end else begin sda_sync1 sda; sda_sync2 sda_sync1; scl_sync1 scl; scl_sync2 scl_sync1; end end assign sda_fall sda_sync2 ~sda_sync1; assign sda_rise ~sda_sync2 sda_sync1; assign scl_fall scl_sync2 ~scl_sync1; // START: SCL高电平时SDA下降沿 wire start_detected scl_sync2 sda_fall; // STOP: SCL高电平时SDA上升沿 wire stop_detected scl_sync2 sda_rise;两级同步器把SDA和SCL都同步到本地时钟域消除亚稳态风险。start_detected和stop_detected这两个脉冲就是状态机的“启动信号”和“结束信号”宽度是一个系统时钟周期状态机用它们做异步事件的同步触发。有个细节值得说我采样的是同步后的sda_sync2来判断SCL电平而不是直接采外面的scl这是为了保持两个信号在同一时钟域内比较避免组合逻辑的竞争。用scl_sync2而不是scl_sync1也一样只要统一就行关键是别混用。3.3 状态机主体代码框架核心状态机接收START、STOP、位计数等信号生成读写控制和应答信号。框架如下localparam IDLE 3d0; localparam ADDR 3d1; localparam ACK_ADDR 3d2; localparam DATA 3d3; localparam ACK_DATA 3d4; reg [2:0] state, next_state; reg [2:0] bit_cnt; reg [7:0] shift_reg; reg rw_flag; reg addr_match; reg sda_out_en; reg sda_out_val; // 状态转移 always (posedge clk or negedge rst_n) begin if (!rst_n) state IDLE; else state next_state; end // 下一状态组合逻辑 always (*) begin next_state state; case (state) IDLE: if (start_detected) next_state ADDR; ADDR: if (bit_cnt 3d7 scl_fall) next_state ACK_ADDR; ACK_ADDR: if (addr_match) next_state DATA; else next_state IDLE; DATA: if (bit_cnt 3d7 scl_fall) next_state ACK_DATA; ACK_DATA: if (stop_detected) next_state IDLE; else if (start_detected) next_state ADDR; else next_state DATA; default: next_state IDLE; endcase end这里DATA状态下没有区分读写方向实际在数据移位和SDA输出控制时需要根据rw_flag分支。写方向时移位寄存器用来收数据读方向时移位寄存器用来发数据共用一个寄存器没问题因为读和写不会同时发生。3.4 SDA三态输出控制与ACK生成逻辑SDA输出的控制逻辑是保证从机不“霸占”总线的关键。ACK阶段和读数据阶段需要驱动SDA其余时间必须释放SDA输出高阻。always (posedge clk or negedge rst_n) begin if (!rst_n) begin sda_out_en 1b0; sda_out_val 1b1; end else begin case (state) ACK_ADDR: begin // 地址匹配时拉低SDA应答不匹配时保持高NAK sda_out_en 1b1; sda_out_val ~addr_match; end ACK_DATA: begin if (rw_flag 1b0) begin // 写方向数据接收完成后应答 sda_out_en 1b1; sda_out_val 1b0; // 默认ACK除非FIFO满等条件 end else begin // 读方向采样主机的ACK决定是否继续发 sda_out_en 1b0; // 释放SDA让主机驱动 end end default: begin // 数据输出阶段由读逻辑控制其他时间释放SDA if (state DATA rw_flag 1b1) sda_out_en 1b1; else sda_out_en 1b0; end endcase end end assign sda sda_out_en ? (sda_out_val ? 1b0 : 1b0) : 1bz;需要注意三态赋值的细节。上面最后一行我写了两种都是输出0的写法实际应用中一般是assign sda sda_out_en ? 1b0 : 1bz;如果需要输出高电平就释放总线而不是把sda_out_val设成1。因为开漏结构下高电平全靠上拉电阻你直接输出1反而可能驱动能力不足甚至引起总线冲突。SDA永远只做两件事拉低或者释放这是I2C从机设计的基本素养。4. 调试实录从机常见的翻车现场与排查方法代码写完只是第一步真正痛苦的是上板调试。I2C从机挂在总线上一旦出问题表现往往非常诡异主机读回全0、偶尔丢一个字节、总线卡死……这些我都遇到过下面把典型的几个场景和排查思路整理出来。4.1 SDA一直被拉低、总线“死锁”的恢复处理I2C总线最经典的故障就是SDA一直为低主机根本发不出起始条件起始条件要求SDA先拉高再拉低SDA被拉死等于总线瘫痪。这种问题排查时要先分清是谁拉低了SDA是外部器件短路还是从机逻辑自己没释放总线。从机端最常见的拉死原因有两个。一个是ACK窗口期控制逻辑写错应答完忘记关闭输出使能SDA一直被我方驱动为低。另一个是状态机在某个异常状态下卡住比如收到非法START或主机在传输途中放弃从机没检测到STOP状态机一直停在DATA状态输出使能没释放。针对这两类问题我做了两个优化第一所有输出使能默认关闭只有明确需要拉低的周期才打开第二状态机里加一个超时计数器如果一段时间没收到任何有效的事件START、STOP、SCL翻转自动回到IDLE状态强制释放总线。这个超时逻辑在实际项目中救了我好几次。4.2 地址匹配失败示波器都看不到从机响应地址匹配失败的排查思路一是抓波形确认地址字节本身是否正确二是查自己的地址比较逻辑。有一次我调试一块板子主机写0x55地址0x2A加读写位0从机就是不应答。示波器抓到SDA上确实有0x55但从机逻辑里addr_match始终为0。后来查代码发现地址比较用了shift_reg[7:1]和参数I2C_ADDR比较但移位寄存器的移位方向写反了高位进了低位地址就被镜像了。这种Bug其实很难一眼看出来因为波形上数据是对的但从机内部接收的位序是反的。排查这类问题我建议在FPGA里拉出几个内部信号做观察点shift_reg的值、bit_cnt、state。用逻辑分析仪或ILA直接看状态机跑到哪一步就清楚了。不要只盯着SDA和SCL两个外部引脚内部状态往往才是定位问题的钥匙。4.3 读操作数据错位时序不对还是采样点不对读操作的数据错位比写操作更难查因为它牵涉到主机的采样点和你自己的数据更新点之间的配合。我踩过的一个典型坑是数据输出逻辑用SCL高电平期间的某个边沿来更新SDA结果实际波形数据显示SDA的跳变点几乎和SCL上升沿重合主机在这个时刻采到的数据有时候是上一个bit有时候是当前bit完全看运气。后来我把数据更新点挪到SCL下降沿问题就消失了。这也解释了为什么很多I2C从机IP核的数据移位寄存器用SCL下降沿而不是上升沿来触发。另外如果主机端用了比较快的时钟频率比如快速模式400kHz从机内部逻辑的响应时间必须算进去。从SCL边沿到SDA状态变化的延迟是主机侧建立时间要求的来源。如果这个延迟太大主机在SCL高电平期间采到的数据可能还在过渡状态里。优化思路是在时钟频率较高时让数据更新点尽量靠近SCL下降沿不要拖到下降沿之后很久才翻转SDA。4.4 常见问题速查表现象可能原因排查手段解决方案SDA一直是低总线无法启动从机输出使能未释放看内部sda_out_en信号默认关闭输出使能加超时复位从机不应答地址位序错、地址不匹配、采样点错误ILA观察shift_reg和状态统一移位方向先收完再比较读数据偶尔错位数据更新点太靠近SCL上升沿示波器看SDA变化点改为SCL下降沿更新数据主机写数据丢字节ACK窗口没卡准数第9个CLK位置在第8个下降沿后拉低SDA状态机卡死收到异常START/STOP观察状态跳转加超时保护自动回IDLE多从机冲突地址覆盖或总线竞争检查各从机地址确认地址唯一上拉电阻适当5. 从机设计的几个进阶经验与扩展思路基础功能跑通之后从机设计还有一些值得进阶考虑的点。这些点不影响基本功能但会影响系统的稳定性和扩展性。5.1 时钟拉伸从机主动让主机“慢下来”I2C协议允许从机在需要时把SCL拉低强制主机等待这就是时钟拉伸Clock Stretching。比如从机内部处理数据需要时间或者FIFO满了来不及接收可以通过拉低SCL让主机暂停处理完再释放。实现时钟拉伸的思路是当从机需要等待时把SCL方向控制打开输出低电平。主机会检测到SCL一直为低自发等待不会继续发送时钟。处理完成后再释放SCL传输继续。我在一个带内部FIFO的从机里用过这个机制效果很好彻底避免了“主机疯狂写、从机来不及存”的丢数据问题。但要注意不是所有主机都支持时钟拉伸有些I2C控制器不支持会直接超时报错。所以用之前要先确认主机的协议能力或者在主机侧做超时容错。5.2 寄存器映射与多字节读写从机收到的数据怎么和内部寄存器对应是设计里的高层逻辑。我常用的协议格式是“第一个数据字节 寄存器地址后续字节 数据”。这种格式下从机状态机要在收到第一个数据字节时把它当成寄存器地址存下来后续的数据字节才写入对应寄存器。读操作类似主机先发送寄存器地址此时是写方向然后发送重复起始条件再发送从机地址加读位从机接收到地址并匹配后开始输出对应寄存器的数据。这种“写地址-读数据”的流程在传感器芯片类从机里极其常见。状态机层面的改动不大主要是在DATA状态下增加一个“首字节处理”逻辑判断当前收到的数据是寄存器地址还是真正的数据。这个可以用一个标志位实现收完第一个字节后置位收到STOP后清零。5.3 多从机场景下的地址配置如果系统里挂了多个I2C从机每个从机的地址必须唯一。地址配置我建议做成参数而不是写死在代码里。这样同一个IP核在例化时传不同的参数就能得到不同地址的从机维护起来成本很低。另外要注意7位地址和8位地址含读写位的换算易错点。比如从机地址0x2A实际主机发送的地址字节是0x54写或0x55读。调试的时候经常有人把0x2A直接写到主机里结果从机收不到任何数据。这个问题我在给同事review代码时见过太多次了写个需求文档的时候把地址格式写清楚能省不少调试时间。最后分享点实际经验我在做这块设计的时候有一个很深的体会I2C从机看着协议简单真正做稳、做可靠还是要靠对细节的敬畏。协议手册里的时序图就那么几行但每个边沿、每个窗口、每个使能信号背后都可能藏着问题。我建议每位做从机设计的同行拿到一个陌生的I2C控制器时先花半天时间把主机端的行为摸清楚比如它发出的地址格式、读写时序是否标准、支不支持时钟拉伸然后再动手写从机逻辑能少走很多弯路。另外有个小技巧调试I2C从机时尽量用支持长时间抓波形的逻辑分析仪把整个传输过程都录下来。I2C出错经常是偶发性的肉眼盯着示波器看容易漏掉关键细节录下来慢慢分析一次就能定位问题。这个习惯帮我省下了无数个加班调试的夜晚。本文还有配套的精品资源点击获取