1. 从一个实际需求说起:为什么要手写IIC主机写模块
做FPGA或者数字IC设计的朋友,迟早会碰到IIC这个协议。它不像SPI那样简单粗暴,也不像UART那样点对点直连,IIC用两根线就能挂一堆设备,省引脚、省成本,所以在传感器、EEPROM、各类配置芯片上用得极多。但问题也来了——很多MCU自带硬件IIC控制器,用起来方便,可一旦你转到FPGA平台,或者需要同时挂多个IIC从设备、需要精确控制时序、需要非标准速率,硬件控制器就不够灵活了,这时候就得自己用Verilog写一个IIC主机。
我这次要拆解的就是IIC主机写模块的开发。所谓“写模块”,指的是主机向从机发送数据的完整流程,包括起始条件、从机地址发送、读写位、从机应答、数据字节发送、逐字节应答、停止条件这一整套动作。它解决的核心问题是:让FPGA能够以可控的时序,向任意IIC从设备写入数据。适合谁看?如果你正在学Verilog状态机设计、正在做FPGA项目需要驱动IIC器件、或者想搞懂IIC协议底层时序到底怎么用代码实现,这篇内容就是给你准备的。
我尽量不写成教科书,而是按我实际写代码、调波形的顺序来讲,把每个决策背后的理由说清楚,把踩过的坑摆出来。代码我会给出可直接参考的Verilog实现,参数怎么算、状态怎么跳、时钟怎么分频,都会落到具体数字上。
2. IIC主机写模块的整体设计思路
2.1 先搞清楚IIC写操作的完整时序
在动手写代码之前,必须把一次完整的写操作拆成时序动作。IIC是半双工、主从架构,主机写数据到从机的流程大致是这样:
- 主机拉低SDA(数据线),此时SCL(时钟线)为高,产生起始条件(Start)。
- 主机发送7位从机地址,再加1位读写方向位(写操作为0),共8位,高位先发。
- 第9个时钟周期,主机释放SDA,从机拉低SDA表示应答(ACK)。
- 主机发送8位数据字节。
- 从机再次应答(ACK)。
- 如果还有数据,重复第4、5步。
- 主机产生停止条件(Stop):SCL为高时,SDA由低变高。
这里有个关键点:SDA只能在SCL为低电平的时候改变,SCL为高的时候SDA必须保持稳定,否则会被误判成起始或停止条件。这是IIC协议的铁律,也是写代码时最容易出错的地方。
2.2 为什么用状态机而不是计数器堆叠
我见过一些初学者用一堆计数器来拼IIC时序,结果代码又长又难改,加个功能就全乱。正确做法是用**有限状态机(FSM)**来组织。每个状态对应一个明确的协议阶段,状态之间的跳转条件清晰,调试的时候看状态信号就知道当前走到哪一步了。
我一般把状态划分为:空闲(IDLE)、起始(START)、发送地址(SEND_ADDR)、等待应答(WAIT_ACK)、发送数据(SEND_DATA)、停止(STOP)。如果要多字节写,就在发送数据和等待应答之间循环。
2.3 时钟分频的考量
IIC标准模式是100kHz,快速模式是400kHz,高速模式能到3.4MHz。FPGA的系统时钟通常是50MHz或100MHz,必须分频。分频系数怎么算?假设系统时钟50MHz,目标SCL为400kHz,那么分频系数 = 50_000_000 / (400_000 × 2) = 62.5。因为SCL的一个完整周期包含高电平和低电平各一段,所以除以2。取整后为62或63,实际速率会有微小偏差,但IIC对速率的容忍度较高,一般没问题。
注意:分频系数最好做成参数,方便不同系统时钟下复用。我习惯定义一个
CLK_DIV参数,默认值根据目标速率算好。
2.4 三态门与SDA方向控制
IIC的SDA和SCL都是开漏结构,需要外接上拉电阻。FPGA的IO一般不支持真正的开漏,所以要用三态门模拟:输出0时驱动低电平,输出1时释放为高阻,由上拉电阻拉高。Verilog里这样写:
assign sda = sda_out_en ? sda_out : 1'bz; assign scl = scl_out_en ? scl_out : 1'bz;sda_out_en为1时FPGA驱动SDA,为0时释放。SCL一般由主机一直驱动,所以scl_out_en通常为1,但为了严谨也可以做成三态。
3. 核心细节解析与实操要点
3.1 起始条件和停止条件的精确实现
起始条件的定义是:SCL为高时,SDA从高变低。停止条件是:SCL为高时,SDA从低变高。实现的时候要注意顺序:
起始条件:
- 确保SDA和SCL都为高(空闲态)。
- 拉低SDA。
- 延时半个SCL周期。
- 拉低SCL。
停止条件:
- 拉低SDA(确保SDA为低)。
- 释放SCL,让它变高。
- 延时半个SCL周期。
- 释放SDA,让它变高。
实操心得:起始和停止条件的延时不能省。我一开始为了省事,SDA和SCL同时变化,结果从机根本识别不到起始条件。后来用示波器抓波形才发现,SDA和SCL的边沿几乎重合,从机采样时无法判断先后。加上半个周期的延时后,波形干净利落,从机立刻正常应答。
3.2 数据位的发送与采样时机
发送数据位时,主机在SCL为低时改变SDA,然后在SCL拉高后保持稳定,从机在SCL高电平期间采样SDA。所以每个数据位的发送流程是:
- SCL为低,设置SDA为当前数据位。
- 延时半个周期。
- SCL拉高。
- 延时半个周期。
- SCL拉低,准备下一位。
这里有个细节:数据是高位先发(MSB first)。我习惯用一个8位寄存器data_shift,每次发送前把要发的字节载入,然后每发一位左移一次,取最高位。
3.3 应答位的处理
发送完8位数据后,第9个时钟周期是应答位。主机释放SDA(sda_out_en = 0),然后拉高SCL,在SCL高电平期间读取SDA的值。如果SDA为低,表示从机应答(ACK);如果为高,表示非应答(NACK)。
注意:读取SDA必须在SCL高电平期间进行,而且要在SCL拉高后稍等一小段时间再采样,避开边沿抖动。我一般延时四分之一周期再采样。
如果从机没有应答,主机应该产生停止条件或者重新发送。实际项目中,我通常会设置一个重试计数器,连续NACK超过3次就报错。
3.4 多字节写的状态循环
单字节写很简单,但实际应用往往要写多个字节,比如向EEPROM写一个页。这时候状态机需要在发送数据和等待应答之间循环。我的做法是加一个字节计数器byte_cnt,每完成一个字节的发送和应答,计数器加一,直到达到预设的字节数,再进入停止状态。
这里有个容易忽略的点:EEPROM的页写有页边界限制,跨页会回卷覆盖。所以写多字节时,最好在软件层面按页对齐,硬件模块只负责按顺序发送。
4. 完整Verilog实现与逐段解析
4.1 模块接口定义
先看接口。我设计的模块端口如下:
module i2c_master_write ( input wire clk, // 系统时钟 input wire rst_n, // 低电平复位 input wire start, // 启动写操作 input wire [6:0] dev_addr, // 从机地址 input wire [7:0] reg_addr, // 寄存器地址(可选) input wire [7:0] wr_data, // 要写的数据 input wire wr_en, // 数据有效 output reg done, // 操作完成 output reg ack_error, // 应答错误 inout wire sda, // IIC数据线 inout wire scl // IIC时钟线 );dev_addr是7位从机地址,reg_addr是器件内部寄存器地址(很多传感器需要先写寄存器地址再写数据),wr_data是要写的数据。done和ack_error用于指示操作结果。
4.2 时钟分频与SCL生成
parameter CLK_DIV = 62; // 50MHz -> 400kHz reg [15:0] div_cnt; reg scl_r; reg scl_en; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt <= 0; scl_r <= 1; end else if (scl_en) begin if (div_cnt == CLK_DIV - 1) begin div_cnt <= 0; scl_r <= ~scl_r; end else begin div_cnt <= div_cnt + 1; end end endscl_en控制SCL是否翻转。在空闲态,scl_en为0,SCL保持高。进入传输后,scl_en为1,SCL开始翻转。这样设计的好处是SCL的翻转严格由分频计数器控制,不受状态机跳转的影响,波形更稳定。
4.3 状态机主体
localparam IDLE = 3'd0; localparam START = 3'd1; localparam SEND_ADDR = 3'd2; localparam WAIT_ACK = 3'd3; localparam SEND_DATA = 3'd4; localparam STOP = 3'd5; reg [2:0] state, next_state; reg [7:0] shift_reg; reg [3:0] bit_cnt; reg [1:0] byte_cnt;状态跳转逻辑:
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) next_state = START; START: if (scl_low_done) next_state = SEND_ADDR; SEND_ADDR: if (bit_cnt == 8) next_state = WAIT_ACK; WAIT_ACK: if (ack_done) begin if (byte_cnt == 0) next_state = SEND_DATA; else if (byte_cnt == 1) next_state = STOP; end SEND_DATA: if (bit_cnt == 8) next_state = WAIT_ACK; STOP: if (stop_done) next_state = IDLE; endcase end这里byte_cnt用来区分是发送寄存器地址还是发送数据。实际项目中,如果不需要寄存器地址,可以简化。
4.4 数据发送与移位
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin shift_reg <= 0; bit_cnt <= 0; end else if (state == SEND_ADDR && bit_cnt == 0) begin shift_reg <= {dev_addr, 1'b0}; // 地址+写位 bit_cnt <= 0; end else if (state == SEND_DATA && bit_cnt == 0) begin shift_reg <= wr_data; bit_cnt <= 0; end else if (scl_low && bit_cnt < 8) begin shift_reg <= {shift_reg[6:0], 1'b0}; bit_cnt <= bit_cnt + 1; end endscl_low是SCL低电平期间的标志信号。每检测到一次SCL低电平,就移出一位。这样保证数据在SCL低电平时改变,符合IIC规范。
4.5 应答采样
always @(posedge clk or negedge rst_n) begin if (!rst_n) ack_error <= 0; else if (state == WAIT_ACK && scl_high && sample_point) ack_error <= sda_in; // SDA为高表示NACK endsda_in是SDA的输入值。sample_point是在SCL高电平中间产生的采样脉冲。如果采样到高电平,说明从机没有应答,置位ack_error。
5. 常见问题与排查技巧实录
5.1 从机不应答怎么办
这是最常见的问题。排查顺序如下:
| 排查项 | 可能原因 | 解决方法 |
|---|---|---|
| 从机地址 | 地址写错或左移/右移搞混 | 确认7位地址,写操作最低位为0 |
| 上拉电阻 | 未接或阻值过大 | 典型值4.7kΩ,高速模式可减小 |
| 时序 | 起始条件不标准 | 用示波器看SDA/SCL边沿 |
| 电源 | 从机未供电 | 万用表量从机VCC |
| 时钟拉伸 | 从机拉低SCL | 主机需检测SCL是否被拉低 |
实操心得:我遇到过一次从机不应答,查了半天代码没问题,最后发现是上拉电阻忘了接。IIC没有上拉电阻,SDA和SCL永远是低电平,根本没法通信。所以硬件检查永远是第一步。
5.2 SCL被从机拉低(时钟拉伸)
有些从机(比如某些EEPROM)在处理数据时会拉低SCL,强迫主机等待。如果主机不检测SCL的实际电平,继续翻转,就会出错。解决方法是在SCL拉高后检测scl_in,如果为低,就暂停分频计数器,直到SCL真正变高。
always @(posedge clk or negedge rst_n) begin if (!rst_n) scl_stretch <= 0; else if (scl_r == 1 && scl_in == 0) scl_stretch <= 1; else if (scl_in == 1) scl_stretch <= 0; endscl_stretch为1时,暂停div_cnt的计数。
5.3 数据写入后从机无响应
有时候波形看起来完全正确,但从机就是没反应。可能的原因:
- 写操作后没有产生停止条件,从机在等待后续数据。
- 寄存器地址写错了,数据写到了错误的寄存器。
- 从机需要一定的写入周期(比如EEPROM的5ms),期间不响应任何命令。
注意:EEPROM写周期是个大坑。写完一个字节后必须延时至少5ms才能发下一个命令,否则从机不响应。我一般会在软件层面加延时,硬件模块只负责单次写。
5.4 多字节写时数据错位
如果多字节写时发现数据错位,通常是bit_cnt或byte_cnt的复位时机不对。我的经验是:每完成一个字节的发送和应答,必须把bit_cnt清零,否则下一个字节会从中间开始。另外,shift_reg的载入时机也要注意,必须在SCL低电平时载入,不能在SCL高电平时改变。
5.5 仿真与实测不一致
仿真通过但上板失败,常见原因:
- 仿真时没有考虑上拉电阻的上升时间,实际波形边沿不够陡。
- 仿真时SDA和SCL是理想信号,实际有毛刺。
- 时钟频率在仿真和实际中不一致,导致分频系数不对。
我的做法是:仿真时加入一定的延迟模型,上板时先用低速(100kHz)调通,再逐步提高速率。
6. 参数计算与工具选型补充
6.1 分频系数的精确计算
假设系统时钟为F_clk,目标SCL频率为F_scl,则分频系数:
CLK_DIV = F_clk / (2 × F_scl)例如50MHz系统时钟,400kHz SCL:
CLK_DIV = 50_000_000 / (2 × 400_000) = 62.5取62,实际SCL频率为:
F_scl = 50_000_000 / (2 × 62) = 403.2kHz误差0.8%,完全可接受。如果取63,则为396.8kHz,误差0.8%。两者都行。
6.2 仿真工具与调试手段
我一般用ModelSim或Vivado自带的仿真器做功能验证,用示波器或逻辑分析仪抓实际波形。逻辑分析仪推荐带IIC协议解码功能的,能直接看到地址、数据、ACK/NACK,省去手动数位的麻烦。
实操心得:调试IIC时,逻辑分析仪的协议解码功能是神器。我第一次调IIC时,用示波器看了半天波形,数位数的眼睛都花了。后来借了一台带解码的逻辑分析仪,一眼就看出地址位少发了一位。工具选对了,效率提升十倍。
6.3 代码复用与参数化
这个模块我做了参数化设计,CLK_DIV、DEV_ADDR都可以在例化时指定。如果项目中有多个IIC从机,可以例化多个模块,或者做一个仲裁器共享一套IIC总线。共享总线时要注意,同一时刻只能有一个主机操作。
7. 写在最后的一些个人体会
这个IIC主机写模块我前后改了好几版,第一版用计数器堆叠,代码乱得没法看;第二版改成状态机,清晰多了,但没考虑时钟拉伸,碰到一个EEPROM就挂了;第三版加了时钟拉伸检测和重试机制,才算稳定。我的体会是,IIC协议本身不复杂,但细节特别多,每一个细节都可能让你调一整天。所以写代码之前,先把时序图看明白,把每个状态的进入和退出条件想清楚,比急着敲代码重要得多。
另外,仿真一定要做充分。我习惯写一个简单的IIC从机模型,模拟ACK和NACK,这样能在仿真阶段覆盖大部分异常情况。上板之后,先用低速跑通,再逐步提速。如果低速都不通,别怀疑速率,一定是时序或者硬件问题。
这个模块后续还可以扩展成读写一体,加一个读状态机,支持重复起始条件。也可以加FIFO,支持连续多字节写。这些等我后面有空再整理。