简介:一套以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 | 检测到START | ADDR | 开始接收地址帧,复位位计数 |
| ADDR | 收满8位地址 | ACK_ADDR | 比较地址,准备应答 |
| ACK_ADDR | 地址匹配且为写 | DATA | 拉低SDA应答,准备收数据 |
| ACK_ADDR | 地址匹配且为读 | DATA | 拉低SDA应答,准备发数据 |
| ACK_ADDR | 地址不匹配 | IDLE | 忽略后续数据,直到下一个START |
| DATA | 写方向收满8位 | ACK_DATA | 准备数据应答 |
| DATA | 读方向发满8位 | ACK_DATA | 采样主机ACK,决定继续还是停止 |
| ACK_DATA | 收到STOP | IDLE | 清标志,回到空闲 |
| ACK_DATA | 收到下一个START | ADDR | 支持重复起始条件 |
这个表做出来之后,状态机的骨架就定了。剩下的就是往里填计数器和数据通路的逻辑。
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 = 7'h2A // 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 <= 1'b1; sda_sync2 <= 1'b1; scl_sync1 <= 1'b1; scl_sync2 <= 1'b1; 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 = 3'd0; localparam ADDR = 3'd1; localparam ACK_ADDR = 3'd2; localparam DATA = 3'd3; localparam ACK_DATA = 3'd4; 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 == 3'd7 && scl_fall) next_state = ACK_ADDR; ACK_ADDR: if (addr_match) next_state = DATA; else next_state = IDLE; DATA: if (bit_cnt == 3'd7 && 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 <= 1'b0; sda_out_val <= 1'b1; end else begin case (state) ACK_ADDR: begin // 地址匹配时拉低SDA应答,不匹配时保持高=NAK sda_out_en <= 1'b1; sda_out_val <= ~addr_match; end ACK_DATA: begin if (rw_flag == 1'b0) begin // 写方向:数据接收完成后应答 sda_out_en <= 1'b1; sda_out_val <= 1'b0; // 默认ACK,除非FIFO满等条件 end else begin // 读方向:采样主机的ACK,决定是否继续发 sda_out_en <= 1'b0; // 释放SDA,让主机驱动 end end default: begin // 数据输出阶段由读逻辑控制,其他时间释放SDA if (state == DATA && rw_flag == 1'b1) sda_out_en <= 1'b1; else sda_out_en <= 1'b0; end endcase end end assign sda = sda_out_en ? (sda_out_val ? 1'b0 : 1'b0) : 1'bz;需要注意三态赋值的细节。上面最后一行我写了两种都是输出0的写法,实际应用中一般是:
assign sda = sda_out_en ? 1'b0 : 1'bz;如果需要输出高电平,就释放总线而不是把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出错经常是偶发性的,肉眼盯着示波器看容易漏掉关键细节,录下来慢慢分析,一次就能定位问题。这个习惯帮我省下了无数个加班调试的夜晚。
本文还有配套的精品资源,点击获取