1. IIC协议基础与FPGA实现思路拆解1.1 为什么选择用Verilog HDL手写IIC控制器很多刚接触FPGA的朋友会问IIC这么常见的协议为什么不用现成的IP核非要自己手写Verilog这个问题我当年也纠结过。实际项目做下来我的体会是现成IP核确实省事但一旦遇到时序微调、多主设备仲裁、时钟拉伸处理这些非标准场景你连改都不知道从哪下手。而IIC协议本身结构足够简单两根线、几个状态机就能跑起来手写一遍对理解时序约束、三态门控制、跨时钟域处理都有极大帮助。IICInter-Integrated Circuit本质是一种同步、半双工、多主多从的串行总线。它只用两根信号线SCL串行时钟和SDA串行数据。所有设备都挂在这两根线上通过开漏输出加外部上拉电阻的方式实现“线与”逻辑。这一点是理解IIC硬件设计的关键——FPGA的IO必须配置为开漏模式或者用三态门模拟开漏否则总线会直接短路。用Verilog实现IIC控制器核心要解决四个问题时钟分频产生SCL、SDA方向动态切换、起始/停止条件生成、应答位采样与驱动。这四个问题解决了剩下的就是状态机编排的事。1.2 IIC总线的电气特性与FPGA侧约束在动手写代码之前必须先搞清楚电气层面的约束否则代码逻辑再对板子上也跑不通。IIC总线要求SCL和SDA都是开漏结构。所谓开漏就是器件只能把线拉低不能主动拉高。线要变高靠的是外部上拉电阻。这样做的好处是多个设备同时驱动总线时不会发生电源短路——只要有一个设备拉低线就是低所有设备都释放线才被上拉电阻拉高。FPGA的普通IO通常是推挽输出直接接上去会出问题。两种处理方式方式一把FPGA的IO配置为开漏输出Open-Drain很多FPGA的IO标准都支持比如LVCMOS加上开漏选项。方式二用三态门模拟。Verilog里写成assign sda sda_out_en ? sda_out : 1bz;当输出使能无效时IO呈高阻态等效于释放总线。上拉电阻的取值也有讲究。标准模式100kHz下常用4.7kΩ快速模式400kHz下常用2.2kΩ到4.7kΩ。电阻太小功耗大太大上升沿变缓高速通信会丢数据。我一般先用4.7kΩ打底示波器看波形再调。注意FPGA的Bank电压必须和IIC总线的上拉电压一致否则会出现电平不匹配轻则通信不稳定重则烧IO。1.3 整体架构设计分层状态机方案我采用的架构是三层结构顶层接口层、字节收发层、位传输层。这样分层的好处是每一层职责单一调试时能快速定位问题出在哪一层。顶层接口层负责和用户逻辑对接提供类似start、write_byte、read_byte、stop这样的命令接口以及busy、ack_error等状态反馈。字节收发层负责把8位数据按位拆开配合应答位完成一个完整字节的传输。位传输层则直接操控SCL和SDA的电平变化控制每一位的建立时间和保持时间。状态机的编码我用的是独热码加三段式写法。三段式就是第一段时序逻辑做状态寄存第二段组合逻辑做状态跳转判断第三段时序或组合逻辑做输出。这种写法综合出来的电路干净时序容易收敛而且方便加断言做仿真检查。时钟方面假设系统时钟是50MHzIIC目标速率是100kHz那么SCL的周期是10000个系统时钟周期半周期就是5000个周期。用一个计数器从0数到4999翻转SCL就能得到100kHz的方波。如果要400kHz半周期计数到62或63即可50MHz / 400kHz / 2 62.5。2. 核心模块的Verilog实现细节2.1 时钟分频与SCL生成逻辑时钟分频看起来简单但里面有个容易踩的坑SCL的高电平和低电平时间可以不对称。IIC协议只规定了最小时序没有强制占空比必须是50%。实际上为了让数据建立更稳我通常把低电平时间设得比高电平稍长一些。下面是我常用的分频参数计算方式系统时钟目标SCL频率半周期计数值实际SCL频率50MHz100kHz249100.4kHz50MHz400kHz62403.2kHz100MHz100kHz499100.2kHz100MHz400kHz124400.0kHz代码实现上我用一个clk_cnt计数器数到half_period - 1时清零并翻转scl_reg。但注意SCL不能随便翻转必须配合状态机——在起始条件、停止条件、数据位传输的不同阶段SCL的行为是不一样的。// 简化的SCL生成逻辑 always (posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt 0; scl_reg 1b1; end else if (scl_en) begin if (clk_cnt HALF_PERIOD - 1) begin clk_cnt 0; scl_reg ~scl_reg; end else begin clk_cnt clk_cnt 1; end end else begin clk_cnt 0; scl_reg 1b1; // 空闲时SCL保持高 end end实操心得空闲时SCL必须保持高电平SDA也保持高电平。如果上电后SCL是低的从设备会误认为总线忙后续通信全部失败。我在早期项目里就因为这个细节调试了整整一个下午。2.2 SDA三态门控制与方向切换SDA是双向线这是IIC实现里最容易出错的地方。Verilog里处理双向信号标准做法是用三态门assign sda sda_out_en ? sda_out : 1bz; assign sda_in sda;sda_out_en为1时FPGA驱动SDA为0时FPGA释放SDA由从设备或上拉电阻控制。方向切换的时机非常关键发送数据位时FPGA驱动SDAsda_out_en 1接收数据位时FPGA释放SDAsda_out_en 0发送应答位时FPGA驱动SDAsda_out_en 1接收应答位时FPGA释放SDAsda_out_en 0方向切换必须发生在SCL低电平期间。因为SCL高电平时SDA必须稳定这时候切换方向会导致数据毛刺从设备可能采到错误值。我一般会在状态机里专门设一个SDA_DIR_SWITCH状态确保方向切换和SCL低电平对齐。这个状态只持续一个系统时钟周期但必不可少。2.3 起始条件、停止条件与重复起始起始条件Start的定义是SCL为高时SDA由高变低。停止条件Stop的定义是SCL为高时SDA由低变高。这两个条件的生成必须严格控制顺序。以起始条件为例正确顺序是确保SCL为高、SDA为高空闲态拉低SDA等待至少4.7微秒标准模式拉低SCL进入数据传输很多人写代码时先拉低SCL再拉低SDA这就变成了一个普通的数据位变化从设备根本识别不到起始条件。这个顺序错误是新手最常犯的问题之一。重复起始Repeated Start用于在不释放总线的情况下切换读写方向或切换从设备。它的时序和起始条件一样但发生在一次传输中间而不是总线空闲时。实现上就是状态机从应答接收状态直接跳到起始条件生成状态中间不经过停止条件。// 起始条件生成片段 START: begin sda_out_en 1b1; sda_out 1b1; scl_reg 1b1; if (clk_cnt SETUP_TIME - 1) begin sda_out 1b0; // SCL高时SDA拉低 state START_HOLD; end end2.4 字节收发与应答位处理一个完整的IIC字节传输包含9个时钟周期8个数据位加1个应答位。数据是MSB先行也就是最高位先发。发送流程FPGA在SCL低电平时把数据位放到SDA上然后在SCL高电平时从设备采样。8位发完后第9个时钟周期FPGA释放SDA从设备拉低SDA表示应答ACK保持高表示非应答NACK。接收流程FPGA在第9个时钟周期释放SDA采样从设备驱动的应答位。如果采到低电平说明从设备应答了采到高电平说明从设备没应答可能是地址不对或者从设备忙。// 应答位采样逻辑 ACK_SAMPLE: begin sda_out_en 1b0; // 释放SDA if (scl_reg 1b1 clk_cnt HALF_PERIOD/2) begin ack_reg sda_in; // SCL高电平中间采样 state ACK_DONE; end end注意应答位采样点选在SCL高电平的中间位置最稳。太早采信号还没稳定太晚采可能已经过了有效窗口。我一般选在SCL高电平计数值的一半处采样。3. 完整实操流程与状态机编排3.1 状态机状态划分与跳转关系我把整个IIC控制器分成12个状态用独热码编码状态名称编码功能说明IDLE12b000000000001总线空闲SCL和SDA均为高START12b000000000010生成起始条件SEND_ADDR12b000000000100发送7位地址加读写位ACK_ADDR12b000000001000接收地址应答SEND_DATA12b000000010000发送数据字节ACK_DATA12b000000100000接收数据应答RECV_DATA12b000001000000接收数据字节SEND_ACK12b000010000000发送应答或非应答STOP12b000100000000生成停止条件REPEAT_START12b001000000000生成重复起始条件WAIT12b010000000000等待状态ERROR12b100000000000错误处理状态跳转的核心逻辑是IDLE收到start命令后进入STARTSTART完成后根据操作类型进入SEND_ADDR或直接进入SEND_DATA每个字节传输完成后根据是否有应答决定下一步。3.2 一次完整写操作的时序流程假设要向从设备地址0x50写入一个字节0xA5完整流程如下IDLESCL高、SDA高等待命令STARTSDA拉低SCL保持高生成起始条件SEND_ADDR发送10100007位地址加0写方向共8位ACK_ADDR释放SDA采样从设备应答SEND_DATA发送101001010xA5ACK_DATA释放SDA采样从设备应答STOPSCL高时SDA拉高生成停止条件IDLE回到空闲态每个数据位的时序是SCL低电平时更新SDASCL高电平时保持SDA稳定。这个规则贯穿整个传输过程是IIC协议最核心的时序约束。3.3 读操作的实现差异读操作和写操作的区别在于写操作由FPGA驱动SDA发送数据读操作由FPGA释放SDA从设备驱动数据。所以读操作的状态机需要多一个RECV_DATA状态并且在这个状态里sda_out_en必须保持为0。读操作的完整流程起始条件发送地址加读方向位1接收地址应答进入RECV_DATA接收8位数据发送应答ACK或非应答NACK如果是最后一个字节发NACK后跟停止条件如果不是最后一个字节发ACK后继续接收实操心得读操作的最后一个字节必须发NACK否则从设备会继续输出数据导致总线冲突。这个细节在单字节读取时特别容易忽略。3.4 参数计算与时钟约束以50MHz系统时钟、100kHz IIC速率为例各参数计算如下SCL周期 50MHz / 100kHz 500个系统时钟SCL半周期 250个系统时钟起始条件建立时间 4.7微秒 235个系统时钟起始条件保持时间 4.0微秒 200个系统时钟数据建立时间 250纳秒 12.5个系统时钟取13数据保持时间 0纳秒标准模式但实际留50纳秒余量在Verilog里我用参数化定义这些值parameter CLK_FREQ 50_000_000; parameter IIC_FREQ 100_000; parameter HALF_PERIOD CLK_FREQ / IIC_FREQ / 2; // 250 parameter SETUP_TIME CLK_FREQ / 1_000_000 * 5; // 250 parameter HOLD_TIME CLK_FREQ / 1_000_000 * 4; // 200这样换时钟频率或IIC速率时只改参数就行不用动逻辑代码。4. 仿真验证与常见问题排查4.1 Testbench搭建要点IIC控制器的仿真需要模拟从设备的行为。我一般写一个简化的从设备模型包含以下功能检测起始条件和停止条件接收地址并判断是否匹配产生应答位接收或发送数据字节Testbench的核心是时序检查。我会在SCL高电平期间检查SDA是否稳定如果发现SDA在SCL高时变化就报时序违规。这个检查能抓住大部分时序问题。// 简化的时序检查 always (posedge scl) begin if (sda_prev ! sda) begin $display(ERROR: SDA changed during SCL high at time %t, $time); end end4.2 常见问题速查表问题现象可能原因排查方法解决方法从设备无应答地址错误示波器看地址位核对从设备手册地址通信偶尔失败上拉电阻过大测上升沿时间减小上拉电阻SCL无输出时钟使能未打开查scl_en信号检查状态机是否进入传输态数据错位采样点偏移对比SCL和SDA相位调整采样时刻总线死锁从设备拉低SCL测SCL电平加超时复位机制起始条件识别失败SDA/SCL顺序错误看起始波形确保SCL高时SDA先变化4.3 总线死锁与超时处理IIC总线有个经典问题从设备在传输过程中复位导致SDA被持续拉低总线进入死锁状态。这时候FPGA再发起始条件也没用因为SDA一直是低的。解决方法有两种硬件复位给从设备加复位信号强制释放总线软件恢复FPGA发送9个SCL脉冲让从设备把剩余数据位发完然后发停止条件我在代码里加了一个超时计数器如果SCL被拉低超过一定时间比如10毫秒就触发错误状态尝试发送9个时钟脉冲恢复总线。// 超时检测 always (posedge clk) begin if (scl_in 1b0 scl_out_en 1b1) begin timeout_cnt timeout_cnt 1; if (timeout_cnt TIMEOUT_MAX) begin bus_error 1b1; end end else begin timeout_cnt 0; bus_error 1b0; end end4.4 多主设备仲裁的处理虽然大部分项目里FPGA是唯一的主设备但IIC协议本身支持多主。多主仲裁的规则是谁先拉低SDA谁赢。如果FPGA发送高电平但检测到SDA是低电平说明另一个主设备在拉低总线FPGA必须立即退出切换到从模式或等待。仲裁丢失的检测逻辑// 仲裁丢失检测 always (posedge clk) begin if (scl_reg 1b1 sda_out_en 1b1 sda_out 1b1 sda_in 1b0) begin arb_lost 1b1; end end这个逻辑在单主系统里用不到但加上去不占多少资源以后扩展多主系统时直接就能用。4.5 跨时钟域与亚稳态处理如果IIC控制器的时钟和系统时钟不同源sda_in信号需要做两级同步再使用否则亚稳态会导致采样错误。// 两级同步 always (posedge clk) begin sda_sync1 sda_in; sda_sync2 sda_sync1; end同步后的sda_sync2才能用于状态机判断。这个细节在低速IIC里影响不大但高速IIC比如1MHz以上必须处理。5. 代码优化与工程化建议5.1 参数化设计与可重用性好的IIC控制器应该是参数化的换时钟频率、换IIC速率、换地址位宽都不需要改逻辑。我一般把以下参数放在模块端口或localparam里CLK_FREQ系统时钟频率IIC_FREQ目标IIC速率DEV_ADDR从设备地址ADDR_WIDTH地址位宽7位或10位REG_ADDR_WIDTH寄存器地址位宽这样同一个模块可以例化多次挂不同的从设备代码复用率很高。5.2 资源占用与面积优化一个基础的IIC控制器在FPGA里占用的资源很少大概50到100个LUT加上几十个寄存器。如果资源紧张可以做以下优化状态机用二进制编码代替独热码减少寄存器数量时钟分频计数器位宽按实际需要设置不要留太多余量如果不需要多主仲裁去掉仲裁逻辑但我的建议是IIC控制器本身不大没必要为了省几十个LUT牺牲可读性和可维护性。独热码加三段式的写法综合结果稳定时序容易收敛调试也方便。5.3 上板调试的实用技巧代码仿真通过只是第一步上板调试才是真正的考验。我总结了几条实用经验先用低速跑通把IIC速率降到10kHz用示波器看波形确认起始、数据、应答、停止都正确再逐步提速。示波器双通道看SCL和SDA触发设在SDA下降沿能抓到起始条件。看数据位时确认SDA在SCL高电平期间稳定。用逻辑分析仪抓完整传输逻辑分析仪能直接解码IIC协议比示波器更直观。抓一次完整读写看地址、数据、应答是否符合预期。加LED指示状态在状态机的关键状态上挂LED比如起始条件时亮灯能快速判断代码跑到哪一步了。注意上板调试时FPGA的IO电平标准和从设备必须匹配。3.3V的FPGA接5V的从设备需要电平转换否则可能损坏IO。5.4 从IIC控制器到通用总线框架手写一遍IIC控制器之后你会发现SPI、UART这些协议的控制思路是相通的都是状态机加时钟分频加数据移位。我把IIC的代码结构抽象了一下做成了一个通用的串行总线框架换协议时只改位传输层字节收发层和接口层基本不动。这个框架后来用在多个项目里包括SPI Flash控制器、UART桥接器、单总线温度传感器驱动。代码复用率高了开发速度自然就上去了。最后分享一个小技巧IIC控制器的Verilog代码里所有时序参数都用parameter定义并且在文件头注释里写清楚每个参数的计算公式。这样过半年再回头看代码或者交接给同事都能快速理解每个参数的含义。这个习惯我坚持了好几年省下了大量沟通成本。