基于Verilog的I2C控制器设计与C驱动实现:从状态机到排障实战
搞FPGA的朋友应该都有过这种经历板子上挂了一颗I2C接口的EEPROM或者温湿度传感器第一反应是去翻有没有现成的控制器IP结果找了一圈要么配置项多到头皮发麻要么和自己的系统总线对不上。折腾半天不如关掉文档自己写一个。I2C协议本身不复杂两根线、一串时钟、几个状态但真要用Verilog实现一个IIC控制器再配一套C语言驱动需要考虑的细节比我预想的多得多分频怎么算、状态机怎么跳、三态门怎么表达、从机不应答怎么办。这篇文章把我在实际项目中从零写IIC控制器的过程完整拆开Verilog实现协议时序C语言实现业务驱动两条腿一起走。适合想在FPGA上接传感器、存储器或者想彻底吃透这个总线的开发者参考。1. 整体设计与思路拆解1.1 先把协议翻译成可执行的动作I2C这种总线大家日常用的时候都是直接调库函数read写一个寄存器地址read几字节回来根本不会去想总线上到底发生了什么。但自己做控制器第一步就必须把协议从波形图翻译成“操作序列”。一个最简单的写操作实际发生的事情是这样的总线空闲时SCL和SDA都被外部上拉电阻拉高主设备先把SDA从高拉低而这个动作发生时SCL必须维持高电平这个“SCL高电平期间SDA由高到低”的组合就是起始条件随后SCL开始输出时钟脉冲SDA上的数据在SCL高电平时被从设备采样先传地址字节地址字节由7位从机地址加1位读写标志组成第8个时钟结束主设备释放SDA在第9个时钟让从机把SDA拉低表示应答后面传数据字节同样在第9拍等待应答全部传完主设备在SCL高电平时把SDA从低拉高停止条件产生总线回到空闲。这个流程听起来很简单但每次拉高、拉低、释放、采样在硬件上一拍都不能错。起始条件要求“SCL为高时SDA下降”如果写代码时让SDA和SCL同一拍变化仿真波形里可能也看不出毛病上板用示波器量时序歪到哪里去都不知道。所以动手写Verilog之前我习惯先做一张动作表把起始、停止、地址、数据、应答和时钟之间的相对关系列清楚后面写状态机就是照着这张表填状态。1.2 硬件控制器与软件驱动如何分工确定了协议动作下一个问题是哪些活儿该硬件干哪些活儿该软件干我的分工原则很简单Verilog控制器只负责“时序层”也就是管理SCL的高低、SDA数据线的驱动与释放、起始停止条件的生成、ACK的采样检测、数据位的逐位移入移出。而C语言驱动负责“业务层”比如从设备地址是多少、寄存器地址怎么传、读几个字节、读完怎么处理以及出错时重试逻辑。这样分工的好处是边界干净。控制器做成一个可复用的时序引擎换一颗从设备只需要改C代码里的设备地址和寄存器映射表RTL一行都不用动。反过来如果哪天觉得驱动层要在某个特定时序点上插入动作也只动控制器不影响上层逻辑。实际连接上Verilog控制器挂在系统总线上C代码通过寄存器读写访问它。控制器内部维护状态和总线方向对外暴露少量寄存器比如控制寄存器、状态寄存器、数据寄存器。C驱动做的事情就是设置寄存器、发起传输、轮询状态、取回数据和操作一颗I2C控制芯片的原理是一致的。1.3 为什么状态机是正确的架构刚开始入门时我也想过I2C时序这么简单能不能直接用延时翻转GPIO的方式在Verilog里写一堆counter和if每一拍拉高拉低能跑但非常不优雅而且有几个问题绕不开。一是可维护性差。一个带随机读的EEPROM操作涉及起始、地址、应答、数据、停止、重复起始如果用if展开代码里全是分散的计数器条件改一个时序参数要翻半天。二是时序对齐困难。总线动作之间有严格的先后关系比如SDA要先变化SCL再拉高这个“先”至少要隔一个时钟如果单纯靠计数容易产生毛刺。三是仿真不好查。波形展开一片想定位哪个if出了问题头都大。状态机的好处是让每个协议动作对应到一个明确的状态状态迁移的条件也明确。起始条件就是“空闲态收到触发信号把SDA拉低”应答等待就是“发送完第8位后释放SDA进入采样态”。调试的时候看波形当前是哪个状态一目了然改起来也只需要调整对应状态的输出。后面我写的整个控制器主逻辑就是一个状态机加几个辅助模块结构非常清晰。2. Verilog控制器核心模块拆解2.1 四个模块各管一段把控制器拆分到模块级通常由四部分组成时钟分频模块、主状态机模块、IO控制模块、寄存器接口模块。时钟分频模块负责把系统时钟分频成SCL时钟这个模块决定了I2C速率主状态机模块是核心它决定当前处于协议中的哪个阶段以及每个阶段SCL和SDA应该是什么电平IO控制模块处理inout引脚的三态把内部要输出的电平转换成总线上真正的开漏信号同时把SDA上的输入电平采样回内部寄存器接口模块负责和CPU总线握手让C代码可以读写控制寄存器和数据寄存器。模块之间的连线并不复杂状态机产生控制信号IO控制模块执行寄存器接口模块把外部命令转换成状态机的触发条件把内部状态转换成可读状态寄存器。拆开之后任何一个模块出问题仿真时都能在很小的范围内定位。2.2 分频器的计算与写法I2C标准模式100kHz快速模式400kHz。控制器要产生SCL本质就是把系统时钟分频。以50MHz系统时钟为例要得到100kHz的SCL分频系数是500。一个SCL周期需要500个系统时钟周期半周期就是250。分频器的代码很简单核心是设置一个计数器计数到半个周期就翻转一次。但有几个地方必须注意分频系数必须是偶数否则占空比会偏使能信号切换时SCL要保持在高电平避免在总线上产生半个多余脉冲。我写的分频器长这样module i2c_clk_gen #( parameter DIV_NUM 500 )( input wire clk, input wire rst_n, input wire en, output reg scl_clk ); localparam HALF_DIV DIV_NUM / 2; reg [15:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 16d0; scl_clk 1b1; end else if (!en) begin cnt 16d0; scl_clk 1b1; end else if (cnt HALF_DIV - 1) begin cnt 16d0; scl_clk ~scl_clk; end else begin cnt cnt 1b1; end end endmodule禁用时把scl_clk复位为高电平和I2C总线空闲电平保持一致这个细节非常重要。有些分频器禁用时停在低电平结果停止条件都没发出去总线就莫名其妙被占用。2.3 三态门开漏输出怎么用代码表达I2C物理层是开漏结构任何设备都只能主动拉低不能主动拉高拉高靠外部上拉电阻。这种结构的好处是多个设备可以共用总线而且不会出现一个设备输出高、另一个输出低导致的短路。反映到Verilog里inout端口在输出低电平时要驱动0不输出的时候必须置为高阻态由外部电阻把电平拉高。代码上可以这样控制SDA和SCL两个inout端口assign scl (scl_o_en !scl_o) ? 1b0 : 1bz; assign sda (sda_o_en !sda_o) ? 1b0 : 1bz;scl_o_en为1时控制器只想拉低SCL所以把引脚驱为0scl_o为1时控制器不输出引脚变高阻上拉电阻把电平拉高。SDA同理。sda_o_en还承担一个功能在等待从机应答时主设备释放SDA于是把sda_o_en置0让从机可以把SDA拉低读数据时也是这个原理SDA由从机驱动。这里最容易犯的错误是直接把scl和sda定义成reg在always块里赋值成1。如果inout引脚被驱动为高电平而外部从机同时拉低轻则数据错误重则烧引脚。所以三态控制这部分一定要单独处理。2.4 主状态机的迁移设计主状态机是整个控制器的灵魂。我把一次完整的写操作拆成这些状态空闲、起始、发送地址高位、发送地址低位、等待地址应答、发送数据高位、发送数据低位、等待数据应答、停止。高速阶段状态机还需要在每个bit内部再细分半步比如发送bit时先让SDA稳定再拉高SCL保持一段时间再拉低SCL再更新下一bit数据。这些步骤可以通过子状态计数器实现。我用的方法是每个协议状态内部再维持一个bit_counter从0到7依次处理8个数据位每处理完一个位判断是否需要跳到应答状态。发送一个字节的核心状态机片段大致长这样case (state) SEND_BYTE: begin // 在SCL低电平期间更新SDA sda_o tx_buffer[7]; state SET_SCL_HIGH; end SET_SCL_HIGH: begin scl_o_en 1b1; // 由三态配置决定是否真正驱动 scl_o 1b1; state SAMPLE; // 从设备在SCL高电平期间采样SDA end SAMPLE: begin // 如果当前是读方向此处采样SDA if (bit_counter 3d7) begin bit_counter 3d0; state ACK_SAMPLE; end else begin bit_counter bit_counter 1b1; state SEND_BYTE; end end ACK_SAMPLE: begin // 释放SDA检测从机应答 sda_o_en 1b0; state ACK_CHECK; end ACK_CHECK: begin if (sda_i 1b0) begin // 从机应答继续下一字节 state DATA_SEND; end else begin // 无应答记录错误准备停止 ack_error 1b1; state STOP; end end endcase这个写法把SCL拉高和SDA稳定的时间分开了SCL拉高前SDA已经准备好满足建立时间要求。SAMPLE状态里采样SDA对应从设备在SCL高电平期间输出数据的规则。2.5 读操作收尾为什么要发NACK很多初学者写读操作时卡在一个地方连续读好几个字节最后一个字节收完状态机怎么结束答案是在最后一个字节的应答位主设备要发出NACK而不是ACK。原因是从设备看到NACK后才知道主设备不想再要数据了于是停止驱动SDA主设备随后发停止条件。如果主设备每读一个字节都发ACK从设备会一直认为还要继续发永远等不到停止条件。所以读操作的应答状态需要区分“普通ACK”和“末尾NACK”。主设备在最后一个字节的第9个时钟周期释放SDA不做任何驱动。SDA被上拉电阻拉高这个高电平就是NACK。这个细节在验证从设备模型时最容易漏漏掉的结果是时序死锁主设备等从设备释放总线从设备等主设备发停止两边僵住。3. C语言驱动的设计与实现3.1 寄存器映射控制器与CPU之间的接口Verilog控制器再漂亮最终要能被C代码操作。约定一组寄存器接口是实现软硬件握手的第一步。我常用的映射关系是偏移地址寄存器名作用0x00CTRL控制使能、起始触发、停止触发、读写方向、ACK模式0x04STAT状态忙、完成、应答错误、FIFO空满0x08DATA数据写时送入待发字节读时取回收字节0x0CCONFIG配置分频系数、中断使能C语言里直接用指针访问这些地址。比如把基地址设为0x20000000那么#define I2C_BASE_ADDR 0x20000000 #define I2C_CTRL (*(volatile uint32_t *)(I2C_BASE_ADDR 0x00)) #define I2C_STAT (*(volatile uint32_t *)(I2C_BASE_ADDR 0x04)) #define I2C_DATA (*(volatile uint32_t *)(I2C_BASE_ADDR 0x08)) #define I2C_CONFIG (*(volatile uint32_t *)(I2C_BASE_ADDR 0x0C))写一个字节的操作流程是把从设备地址和读写标志写入CTRL把要发的数据写入DATA置起始触发位然后轮询STAT里的忙标志等传输完成。整个过程和用一颗外部I2C控制器芯片很相似。3.2 GPIO模拟I2C不打控制器也能先跑除了操作寄存器版控制器还有一种纯软件的I2C实现方式用GPIO直接翻转也就是常说的“软件I2C”。这种方式不需要任何硬件控制器任意两颗GPIO就能跑非常适合在没有I2C外设的单片机上模拟以及在FPGA控制器还没调试好时先验证从设备的读写地址。我先把IO操作抽象成宏#define SCL_HIGH() gpio_write(SCL_PIN, 1) #define SCL_LOW() gpio_write(SCL_PIN, 0) #define SDA_HIGH() gpio_write(SDA_PIN, 1) #define SDA_LOW() gpio_write(SDA_PIN, 0) #define SDA_READ() gpio_read(SDA_PIN)然后实现起始、停止、字节读写三个核心函数static void i2c_start(void) { SDA_HIGH(); SCL_HIGH(); delay_us(1); SDA_LOW(); // SCL为高时SDA拉低产生起始条件 delay_us(1); SCL_LOW(); // 拉低SCL准备传数据 } static void i2c_stop(void) { SDA_LOW(); SCL_HIGH(); delay_us(1); SDA_HIGH(); // SCL为高时SDA拉高产生停止条件 delay_us(1); } static void i2c_write_byte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) { SDA_HIGH(); } else { SDA_LOW(); } data 1; SCL_HIGH(); // 从机在SCL高电平期间采样SDA delay_us(1); SCL_LOW(); // 拉低SCL允许主机改变SDA delay_us(1); } // 第9个时钟释放SDA读取从机应答 SDA_HIGH(); SCL_HIGH(); delay_us(1); ack_flag SDA_READ(); // 0: 从机应答1: 无应答 SCL_LOW(); delay_us(1); }写字节函数里第9个时钟的那段代码是核心。发送完8位数据后释放SDA从设备如果正常会把SDA拉低于是读回0如果读回1就说明地址错了、设备没上电或者总线异常。delay_us的时间决定了I2C速率通常1us左右对应100kHz量级这个值可以根据需要调整。3.3 应用层API把读写寄存器变成一句话有了基础的起始、停止、字节读写封装业务读写就水到渠成。向从设备某个寄存器写一个字节分解动作是起始、发设备地址加写标志、等应答、发寄存器地址、等应答、发数据字节、等应答、停止。对应代码uint8_t i2c_write_reg(uint8_t dev_addr, uint8_t reg_addr, uint8_t data) { i2c_start(); i2c_write_byte((dev_addr 1) | 0x00); // 写方向 if (ack_flag) { i2c_stop(); return 1; } i2c_write_byte(reg_addr); if (ack_flag) { i2c_stop(); return 1; } i2c_write_byte(data); if (ack_flag) { i2c_stop(); return 1; } i2c_stop(); return 0; }读操作稍微绕一点要先写设备地址和寄存器地址再发一个重复起始条件然后发设备地址加读标志之后读字节。重复起始条件在协议里就是一个新的起始只不过前面没有停止条件。实际写起来就是再一次调用start函数。很多刚接触的读者容易漏掉这一步直接发读地址结果从设备根本不响应。串行EEPROM这种设备一次要读多字节可以在此基础上扩展成连续读核心就是每读完一个字节后发ACK直到最后一个字节发NACK再停止。3.4 查询与中断的选择C驱动里一个现实问题是一个多字节的读写操作控制器忙多久才结束如果采用查询模式CPU就在while循环里等状态寄存器中的忙标志清零。低速100kHz下一字节大约90微秒几个字节几百微秒对大多数应用影响不大。但如果系统里还有其他实时性高的任务几百微秒的忙等是不可接受的。这时可以启用中断控制器在完成一个字节或者整次传输时拉一个中断C代码在中断服务函数里读状态、填数据。中断模式的核心还是那组寄存器只是把轮询等待换成了事件回调。我自己的选择是驱动底层按查询模式写同时在寄存器版本里预留中断使能位。业务简单时用查询业务复杂时把底层查询循环替换成等待中断信号量的方式改动量很小。4. 联调排障实录仿真过了不等于板子会走4.1 现象一SDA一直高起始条件根本出不来这个问题常见于第一次上板示波器量SDA始终是高电平起始条件的下降沿永远看不到。排查思路是先从代码上确认起始条件状态机真正被执行了。常见原因有三个控制寄存器的起始触发位没写进CTRL、状态机里起始条件的条件判断写错、三态门在SDA输出低电平时没有把sda_o_en置1。前两个属于寄存器配置和状态机问题最后一个属于IO控制问题。检查时我习惯一层层剥先在仿真里看CTRL寄存器的值有没有拉到触发信号再看状态机有没有进入起始状态最后看IO模块有没有输出低电平。仿真没问题再上板量波形能用逻辑分析仪抓起始条件最好没有逻辑分析仪就用示波器单次触发触发点设在SDA下降沿。4.2 现象二时通时断逻辑分析仪一抓一个准时通时断是最让人抓狂的问题因为很多时候仿真完全正常上板就是看运气。我遇到过一次原因是分频系数算错用了500分频但代码里写成了每计数到500翻转一次实际SCL是50kHz不是100kHz。从设备手册要求的最小周期没问题但在多字节连续操作时因为每个字节之间的间隙不稳定偶发超时。另一个常见原因是总线上的上拉电阻阻值太大上升沿变缓从设备在SCL高电平期间采样SDA时电平还没稳定。这种问题在低速下不明显400kHz快速模式下就暴露了。处理方法是把上拉电阻从10k降到4.7k或者2.2k但也不能太小否则会加重设备驱动能力负担具体阻值需要结合总线电容和挂载设备数量折中。4.3 现象三读回全是0xFF读到的数据全是0xFF基本可以判定从设备没有真正驱动总线的数据线。要么是读方向地址发错了要么是从设备在应答位没有被正确释放。如果发的是设备地址加读标志从设备根本没匹配那么SDA线在第9个时钟不会被拉低数据永远是高电平读回来就是全1。检查时先确认地址是否正确再看应答标志有没有被置位。有些初学者容易忽略设备地址是7位左移一位后最低位才是读写标志地址位放错高位从设备永远匹配不上。还有一种情况是本来应该在停止条件前发NACK但代码发了ACK从设备还在继续驱动SDA此时数据线被拉成不确定状态读回的内容就可能出错。4.4 现象四仿真OK、上板拉胯仿真没问题上板不行最先怀疑的对象应该是时延问题。RTL仿真里的信号跳变是理想的0时刻从0变成1真实器件的IO翻转有建立时间、保持时间和传播延迟。状态机里如果SDA刚变化完立刻拉高SCL在仿真里从设备采样一定成功在真实芯片上可能正好错过建立时间窗口。解决办法是在状态机里给SDA稳定预留足够的余量。比如SCL拉高前一两个周期就让SDA先变化SCL拉低后再等待若干周期再更新下一bit。这会让I2C实际速度慢一点点但换来的稳定性非常值得。4.5 调试I2C的几点工具经验调试时我建议手里至少有三样东西一个示波器、一个逻辑分析仪、一个能自己当主机的调试器。示波器用于看波形质量比如上升沿、下降沿、毛刺逻辑分析仪用于解码总线内容抓地址、数据、ACK位一目了然主机调试器可以在不依赖FPGA的情况下单独测试从设备确定设备和驱动没问题缩小排查范围。买逻辑分析仪时留意采样率至少要高于SCL频率的10倍不然解码都可能出错。软件I2C模拟时把SCL频率降到10kHz甚至更低也有助于排查问题。5. 一点个人经验收尾整个IIC控制器项目做完我最大的体会是I2C协议表面上简单真正动手实现之后才知道很多“软细节”藏在时序关系里。比如起始条件是SCL高时SDA下降实际代码里SDA要先准备好再拉高SCL不然会产生额外的毛刺比如应答检测要把SDA释放成高阻如果直接驱动高电平从机拉低时就会冲突。这些坑只看文档是记不住的跑一遍代码、抓一次波形比任何教程都管用。最后分享一个小技巧在验证Verilog控制器时不要急着挂真实从设备先在testbench里写一个行为级的I2C从机模型只响应设备和寄存器地址打印出收到的数据。这样能快速验证控制器的起始、停止、地址发送和应答检测逻辑等这些基础功能稳定了再上板接真实EEPROM效率会高很多。这个办法看起来简单但能帮你把“控制器问题”和“从设备问题”分开排查时间至少省一半。