IIC控制器这东西,我的态度经历了三个阶段:第一次用单片机自带硬件IIC,读寄存器读得一肚子火;第二次全面改用GPIO软件模拟,时序随心所欲但CPU被占得死死的;第三次才静下心来,自己用Verilog写了一个精简的IIC控制器,再配套写C语言驱动。回头看,这三个阶段分别暴露出了对IIC理解的不同层次。
这篇内容就是把第三次的经验完整记录下来。我会把Verilog侧的状态机、分频、时序余量讲细,也会把C语言侧的驱动封装、中断轮询取舍讲透,最后再分享仿真和实测中排查过的几个典型问题。适合三类人看:想把IIC从"会用"变成"会写"的人,正在做FPGA加外设驱动但总是对不稳时序的人,以及需要把一套IIC控制器复用到多个项目里的人。
1. 这个IIC控制器的定位:为什么硬件和软件两套代码都得写
1.1 IIC协议里三个绕不开的关键点
IIC这个协议,表面上只有两根线:SCL是时钟,SDA是数据。但真正自己实现过的人都知道,它的简单是"看着简单",做起来到处是细节。
第一个关键点是所有数据位都是在SCL高电平期间有效。SDA必须在SCL拉高之前稳定下来,SCL拉高之后SDA不能变,只有当SCL拉低之后SDA才允许切换。这句话是整个发送状态机的设计依据,也是很多第一次写Verilog实现的人最容易翻车的地方——他把SDA和SCL当成了两个独立的信号去控制,结果波形一出来,数据建立时间和保持时间全乱。
第二个关键点是起始条件和停止条件的特殊性。起始条件是在SCL为高的时候,SDA从高往下跳变;停止条件是在SCL为高的时候,SDA从低往上跳变。也就是说,这两个条件都发生在SCL高电平期间,这与普通数据位"SCL高时SDA必须稳定"是相反的操作。如果你的状态机仍然按数据位的逻辑去处理,就会在产生START和STOP时被SDA的跳变干扰,导致从机误判。
第三个关键点是ACK/NACK的采样窗口。主机每发送完8个数据位,在第9个时钟周期释放SDA并将其设为输入,然后等从机把SDA拉低表示ACK。这个采样点必须在SCL高电平中段完成,而不是在上升沿立刻采样。很多人图省事在上升沿直接采样,结果遇到慢速从机或者长走线时,经常会误判为NACK。
1.2 软件模拟、硬件控制器、软硬混合的取舍
纯软件模拟IIC,也叫bit-banging,本质上是用GPIO的两根引脚模拟SCL和SDA的电平跳变,配合延时函数制造时序。它的优点是实现简单、不依赖硬件外设,想挂几个从机就挂几个从机;缺点也十分明显:时序完全靠CPU的延时精度,一旦系统里开了中断或者任务调度,时序就容易漂,而且每传输一个字节都要占用大量CPU周期。以前我在某个项目里用软件模拟IIC读传感器的数据,CPU占用率直接上去两成,后来数据量一大就彻底扛不住。
硬件控制器,就是用Verilog在FPGA里做一个专用的IIC时序引擎。CPU只需要往寄存器里写入从机地址、寄存器地址和数据,剩下的时钟产生、起始条件、逐位移位、ACK采样这些全部由硬件完成。好处是CPU几乎零负担,时序完全稳定;坏处是逻辑设计的工作量在那里摆着,状态机、分频、边沿处理,每一个环节都得自己验证。
这里大家应该能理解我为什么说"两套代码都得写"了:Verilog控制器解决的是物理时序问题,C语言驱动解决的是协议逻辑问题,两者根本是不同层面的东西。纯Verilog的控制器如果没有人写C驱动去封装,使用方就得面对十几个寄存器裸奔配置;纯C语言的驱动如果底层的时序引擎你没写出来,那它还是一套软件模拟。我这次做的方案,就是FPGA里有硬件IIC控制器,C语言侧封装出i2c_write_byte、i2c_read_byte这类接口,上层完全无关时序细节。
2. 我的Verilog实现方案:从时序图到状态机的落地过程
2.1 模块边界与寄存器规划
写Verilog模块之前,我习惯先把模块边界画清楚。我这次设计的IIC控制器对外暴露两组接口:一组是寄存器总线接口,连接MCU或者片上CPU核,通过它读写控制、状态和数据寄存器;另一组是物理IIC接口,也就是两根双向的inout引脚SCL和SDA。
模块内部还留了一个可配置项:目标时钟频率和总线速率。我用参数把系统时钟频率CLK_FREQ和IIC总线频率SCL_FREQ全部参数化,这样从100kHz标准模式切到400kHz快速模式,只需要在实例化时改一个参数,逻辑本身不用动。
寄存器规划上,我砍到了最少的四个:
- 控制寄存器(CR):bit0是START触发,bit1是STOP触发,bit2是读写方向,bit3是控制器使能。
- 数据寄存器(DR):发送时写入要发出的字节,接收时读出已收到的字节。
- 状态寄存器(SR):bit0表示忙,bit1表示ACK失败,bit2表示一字节传输完成,bit3表示总线上有仲裁丢失。
- 预分频寄存器(PRER):存放分频系数,由硬件在复位时自动计算,也可以由软件覆写。
这个寄存器规划不是随便拍的。控制寄存器只放触发信号而不是直接放SCL/SDA的IO电平,是为了让软件层始终停留在"发一条命令、等一个结果"的抽象级别上,不会误碰物理时序。
2.2 SCL时钟产生与分频计算
IIC总线速率标准模式是100kbit/s,快速模式是400kbit/s。SCL的每个周期由高电平时间和低电平时间组成,在标准实现里我取它们相等,各占一半周期。
分频系数计算非常简单:
localparam integer DIV_CNT = CLK_FREQ / (SCL_FREQ * 2); // 例如系统时钟50MHz,SCL频率100kHz: // DIV_CNT = 50_000_000 / (100_000 * 2) = 250 // 即每250个系统时钟翻转一次SCL我在这里用了一个计数器,计到DIV_CNT减1时翻转SCL输出并清零。同时这个计数器的值还用来标记SCL电平阶段的中间点,因为ACK采样和数据位变化都要避开SCL的边沿。
这里特别说明一个初学者容易忽略的点:预分频值必须比单次状态跳转的最小耗时要大,否则SCL波形会出现畸形脉冲。我实测下来分频值小于4的时候,整个状态机完全没法工作,因为从一个SCL段切换到另一个SCL段的组合逻辑路径本身就占了好几个周期,分频太细等于把所有操作都挤在了一个时钟里,必然触发毛刺。
2.3 主状态机的设计与关键代码
状态机设计参考了经典的主模式流程:IDLE空闲、START起始、SHIFT移位、ACK采样、STOP停止。下面是核心状态机骨架,做减法之后保留了最关键的判断逻辑:
localparam S_IDLE = 4'd0; localparam S_START = 4'd1; localparam S_SHIFT = 4'd2; localparam S_ACK = 4'd3; localparam S_STOP = 4'd4; localparam S_END = 4'd5; reg [3:0] state, next_state; reg [3:0] bit_cnt; // 当前已发送/接收的位数 reg [7:0] shift_reg; // 移位寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; end else begin state <= next_state; end end always @(*) begin case (state) S_IDLE: next_state = cr_start ? S_START : S_IDLE; S_START: next_state = S_SHIFT; S_SHIFT: next_state = (bit_cnt == 4'd8) ? S_ACK : S_SHIFT; S_ACK: next_state = (need_stop) ? S_STOP : S_END; S_STOP: next_state = S_END; S_END: next_state = cr_start ? S_START : S_IDLE; default: next_state = S_IDLE; endcase end这段代码里的need_stop是控制寄存器里提前设好的停止标志。SHIFT状态里每来一个SCL低电平段,就把shift_reg左移一位,把新数据放到SDA上;到了SCL高电平段,硬件自动采样SDA并存入移位寄存器的最低bit。这样发送和接收其实共用同一个移位逻辑,因为IIC本来就是半双工协议,同一时刻数据方向只能有一个。
这里我特地用了4位bit_cnt,计数到8就切到ACK状态。如果在SHIFT状态里直接判断bit_cnt == 3'd7后马上进ACK,会少一个时钟周期,导致第8位数据的最后一个SCL高电平采样点被吃掉。这种边界问题仿真不容易看,上板后往往表现为最后一个bit移位丢失。
2.4 SDA输出使能的三态控制与边沿处理
SDA是双向线,发送时输出高或者低,但ACK采样时和作为接收方时必须释放总线,让从机去拉低。所以SDA的驱动必须是一个三态门,而不是简简单单地赋值成1或0。
assign sda_io = sda_out_en ? sda_out : 1'bz;这个sda_out_en信号在SHIFT状态且当前发送方向时打开,在ACK状态和接收数据时必须关闭。关闭SDA输出使能之后,sda_io就处于高阻态,这时候如果从机拉低,我们用以上升沿采样的寄存器读回SDA的值,就能拿到ACK信号。
另外一个细节是SCL的高低切换时机。我的做法是:在SCL计数器计数完成、SCL电平反转的同时,根据下一个阶段的需求去更新SDA输出。比如数据位的SDA变化必须发生在SCL低电平期间,所以我在SCL从高变低后立刻更新sda_out;而起始条件的SDA跳变则不要等计数器自然翻转,要在一个特殊分支里先拉高SCL,再跳SDA。这个分支单独写一个逻辑块,别和普通数据位混在一起,否则后面改一个功能会连带影响另一个。
关于边沿还有一个容易被一句"时序上没问题"带过的点:IIC输入信号需要做两级同步,否则从机应答或者总线仲裁时,异步信号直接进状态机大概率会触发亚稳态。我在所有连到状态机的SDA采样值前都加了两个D触发器做同步打拍,虽然引入了一点延迟,但换来了稳定,这个代价非常值得。
3. C语言驱动层:把硬件控制器用起来
3.1 寄存器映射与驱动骨架
硬件模块写好后,C语言侧要做的事情,是把寄存器的地址映射成清晰的数据结构,再往上面封装标准接口。我习惯的做法是先定义一个寄存器基地址指针,再把四个寄存器按偏移排好:
#define I2C_BASE_ADDR 0x40014000 typedef volatile struct { uint32_t CR; // 0x00 控制寄存器 uint32_t SR; // 0x04 状态寄存器 uint32_t DR; // 0x08 数据寄存器 uint32_t PRER; // 0x0C 预分频寄存器 } I2C_Regs; #define I2C ((I2C_Regs *)I2C_BASE_ADDR) #define CR_START (1u << 0) #define CR_STOP (1u << 1) #define CR_WR (1u << 2) #define SR_BUSY (1u << 0) #define SR_ACKFAIL (1u << 1) #define SR_DONE (1u << 2)这里有一点要特别注意:如果模块支持8位和32位两种总线接口,寄存器寻址的偏移会不一样。我上面是按32位偏移写的,每个寄存器隔4个字节。如果你在Verilog侧把寄存器地址译码成按字节递增,那么C侧的struct偏移要逐一对应,不能想当然。
驱动骨架分三层:最底层是寄存器读写函数,中间层是带超时的等待机制,最上层才是面向用户的收发接口。中间层的超时机制特别关键,因为一旦总线上出现从机拉死SDA的故障,硬件会一直卡在等待状态,没有超时的话整个CPU就死锁了。我用一个简单的计数器实现超时:每次等待循环里计一个上限,比如100万次,超过之后强制触发STOP并返回错误码。
3.2 标准读写流程的封装
写入一个寄存器的完整流程是:先启动START,然后发送设备地址加写标志,等待ACK;再发送寄存器地址,等待ACK;最后发送数据字节,等待ACK;最后发STOP。把它翻译成C代码:
int i2c_write_reg(uint8_t dev_addr, uint8_t reg_addr, uint8_t data) { uint32_t timeout; // 等待上一次传输彻底结束 timeout = TIMEOUT_CNT; while ((I2C->SR & SR_BUSY) && timeout--) { // 空等,可在RTOS环境换成任务延时 } if (timeout == 0) return -1; // 设置成写方向,触发START I2C->CR = CR_START | CR_WR; // 发送设备地址(低1位为0,表示写) I2C->DR = (dev_addr << 1) | 0; if (wait_ack() != 0) return -2; // 发送寄存器地址 I2C->DR = reg_addr; if (wait_ack() != 0) return -3; // 发送数据 I2C->DR = data; if (wait_ack() != 0) return -4; // 触发STOP I2C->CR |= CR_STOP; return 0; }wait_ack()里我会同时监听两个状态位:SR_DONE表示一个字节传输完成,SR_ACKFAIL表示从机没有拉低SDA应答。如果先遇到SR_ACKFAIL,直接返回错误,避免继续傻等。
读取数据是IIC协议里稍微绕一点的动作。要读多字节时,前N-1个字节读完以后主机需要回ACK,表示"继续发",最后一个字节之前主机必须提前把ACK屏蔽,让从机收到NACK自动释放总线,然后再发STOP。这个NACK的提前预置一定要放在读倒数第二个字节的ACK采样阶段完成,而不是读完最后一个字节才去配置,否则从机已经释放总线,你的STOP发出去也没有意义。很多第一次写IIC读驱动的人会在这一步卡很久,我当初也同样栽过。
3.3 中断模式还是轮询模式
寄存器控制器在工作时,CPU有两种获取完成事件的方式。
轮询模式代码简单,上面的wait_ack()就是轮询。但缺点是CPU一直占着总线资源转圈,尤其在RTOS里,高优先级任务会把驱动卡死,一旦超时机制写得不够好,整个系统都能被拖停。
中断模式的思路是:硬件在每完成一个字节传输时拉高irq信号,C语言侧注册对应的中断服务函数,在中断里读取状态寄存器、判断下一步动作,然后写入新的数据或触发STOP。这样CPU不用空等,但要小心一个跨域问题:中断服务函数和主程序共享了控制寄存器,必须用原子操作或者临界区去保护CR的读写,否则一个清除动作会吃掉另一个刚写入的START触发位。
我在实际项目里综合了两者:单字节快速操作用轮询,多字节连续读写用中断配合一个小的DMA式状态机。中断服务函数只负责往一个缓冲区里搬运数据,真正协议层的下一步判断在非中断上下文里做,这样能避免在中断里做太多逻辑导致响应延时不稳定。
3.4 备用的GPIO模拟IIC方案
尽管写了硬件控制器,我还是在C代码库里保留了一份纯GPIO模拟IIC的实现,这不是多余。硬件控制器出现异常、或者需要在极早期调试启动代码时,GPIO模拟是最后的逃生通道。
简化版本的核心就是严格控制SDA跳变和SCL之间的关系:
static void i2c_delay_us(uint32_t us) { volatile uint32_t loops = us * 10; // 按主频粗略换算 while (loops--) { __asm volatile("nop"); } } static void i2c_start(void) { SDA_H(); SCL_H(); i2c_delay_us(5); SDA_L(); // SCL高期间SDA拉低,产生起始条件 i2c_delay_us(5); SCL_L(); } static void i2c_stop(void) { SDA_L(); SCL_H(); i2c_delay_us(5); SDA_H(); // SCL高期间SDA拉高,产生停止条件 }注意这份代码里我特意用了独立延时函数而不是for循环里随便写个数,因为不同编译器优化等级下循环体可能被完全优化掉,那时候延时直接等于零,IIC时序全线崩溃。把延时函数用volatile变量加上空汇编指令来防优化,是软件模拟里最基本的保命手段。这份代码平时不用管,但真到了硬件控制器完全没法用的场合,它能救急。
4. 仿真与实测:从波形检查到几个真实故障的排查链路
4.1 仿真时我重点盯哪几个波形位置
写Verilog控制器,仿真不是跑通一遍就完事。我自己的检查清单里有三条铁律:
第一,逐位核对"SCL高电平期间SDA是否稳定"。用仿真波形一条线一条线地看,在数据位的SCL高电平段,SDA应该是一条平的直线,任何一个小毛刺或跳变都意味着你的数据建立时间不够,或者SDA在错误的时间点被更新了。
第二,核对STOP之后SDA和SCL是否都处于高电平。IIC协议规定总线空闲时两线都必须为高。仿真里经常看到STOP状态结束后SDA还保持低,这说明三态控制没有在结束后释放总线,后面再启动新传输会直接锁死总线。
第三,核对ACK采样点。仿真里我倾向于加一个故意慢应答的从机模型来验证:从机在第9个SCL周期的后半段才把SDA拉低,如果你的采样点设计在上升沿,这里就会报NACK;设计在SCL高电平中间,就能正确采样到ACK。用这种边界模型逼出问题,比用标准应答模型跑一百次都有用。
4.2 实测中遇到的三个典型故障
第一个故障是SCL波形上的毛刺。现象是逻辑分析仪抓到的SCL在上升沿附近有高频抖动,后来定位到原因竟然是分频输出和状态机跳变共用了同一个时钟边沿,组合逻辑在翻转SCL的同时改变了三态使能,导致SDA线上出现一个短暂的驱动冲突。解决办法是把SCL输出寄存器和SDA三态控制器分在两个不同的状态下更新,让它们不要踩在同一个时钟沿上。
第二个故障是SDA被从机拉死。现象是传输完第一字节之后就卡住不再推进,测量发现SDA一直是低电平。刚开始怀疑是代码问题,后来把排线拔掉测试才确认是从机的内部状态异常导致一直拉低SDA,主机端怎么发时钟都等不到它释放。处理办法是在驱动里增加了总线恢复函数:连续拉9个SCL时钟而不发START,利用"没有START的SCL脉冲会被从机视为空操作"这个特性让从机复位,然后再发STOP释放总线。这个恢复流程放进驱动层,遇到总线挂死时可以快速重启而不需要断电。
第三个故障是速率提高到400kHz之后就稳定性下降。原因不复杂但容易忽略:总线上挂了多个设备,它们对上拉电阻和分布电容的要求各不相同,400kHz模式下对上升沿时间的要求更高,原来的4.7k欧姆上拉电阻带不动,换成2.2k欧姆之后问题消失。这不是代码能解决的,属于PCB和电路层面的约束,但也说明做IIC控制器不能只盯着逻辑看,整个链路都要检查。
4.3 从"现象到根因"的完整排查链路
我把当时排查SDA卡死的链路完整梳理一遍,这个思路比具体代码更值得分享。
第一步,先用逻辑分析仪确认故障现象:SDA在ACK阶段保持低电平,SCL还有时钟输出,但主机端状态机已经停在等待ACK状态不再前进。
第二步,排除主机逻辑。我写了一个回环测试:在FPGA内部把SDA输出强制做成短线连接到SDA输入,把主机发出去的0x55字节原样读回来。这一步确认了主机侧的状态机和移位寄存器没有丢位错位。如果这一步就出错,那问题一定在Verilog逻辑内部,没必要往下查。
第三步,换用最简单的从机,只用一粒传感器芯片。把总线频率降到10kHz这么低,再抓到波形看,结果发现ACK是正常的。这说明主机逻辑能发能收,问题不在主机端。
第四步,把频率拉回100kHz,重新抓波形,发现第一个数据字节之后从机确实在正常拉低SDA做ACK,但主机此时的三态使能没有释放,还在继续驱动SDA输出低。这样一来,从机想拉低的线和主机正在驱动的低电平就撞在一起了。虽然最终看到的都是低,但从机端的内部状态会因为总线上的负载和驱动能力的差异而变得不可控。
第五步,回到代码检查,终于找到根因:ACK状态里我没有把发送方向位清掉,导致sda_out_en在ACK阶段还是开启的。修复之后就是加一行对方向位的判断,ACK阶段必须关闭输出使能。就这么一个看起来很不起眼的小点,折腾了将近半天。
这个排查链路的价值在于它的顺序:先用回环测试把主机内部逻辑隔离出来,再换最简单的从机排除协议层面的兼容性问题,最后才去怀疑寄存器配置。如果你一上来就盯着代码逐行进位,大概率会在里面绕很久。
5. 移植与扩展:这套控制器怎么复用到别的项目
5.1 参数化是移植的第一前提
我设计时把系统时钟和总线速率都做成了参数,实际用下来这个决策帮了大忙。第一个项目系统时钟是50MHz,直接例化成100kHz;第二个项目换成了75MHz的时钟,只需要把参数改一下,重新综合一遍,控制器本身一行代码没动。这里有个经验:预分频参数尽量用加一减一后的整除值,否则分频后的实际SCL频率偏差会超出从机的容忍范围。比如目标100kHz,用50MHz时钟分频得到DIV_CNT是250,实际有万分之几的偏差,完全没问题;但如果目标速率是110kHz而50MHz除以两倍后得到227.27,就得仔细算一下舍入带来的误差会不会超过从机的规格上限。
如果你要支持运行时动态切换100kHz和400kHz,不要把所有分频逻辑写成固定参数,而是把预分频值做成寄存器,软件在初始化时写进去。硬件侧只加一个比较器,计数器等于PRER寄存器的值时翻转SCL。
5.2 从单主到多主的扩展点
我目前写的这套控制器定位是单主模式,也就是FPGA永远当主机,总线上所有从机都挂在它下面。如果要支持多主,需要额外做仲裁逻辑:发送每个数据位时,软件层要先去检测SDA线上的实际值和自己想发送的值是否一致,一旦不一致说明总线被其他主机抢占,必须立刻释放总线,停止驱动SCL,然后等总线空闲再重试。
这个仲裁逻辑在Verilog侧并不复杂,问题在于软件侧的退避策略。遇到仲裁失败,不能马上重试,否则会和其他主机一直碰撞;需要等待一个随机退避时间再发起。如果项目里确实需要多主,我建议在现有模块上增加一个独立的仲裁失败中断,用断点中断触发上层RTOS做延时调度,而不是在驱动里空循环。这样既保留了硬件控制器的效率,又不至于因为仲裁逻辑污染了现有状态机的简洁性。
5.3 给上层应用留一个干净的接口
驱动层的最终目标,是让上层的传感器和应用层代码完全感觉不到IIC的存在。我封装了两组常用接口,一组是读写寄存器:
int i2c_reg_write(uint8_t dev, uint8_t reg, uint8_t val); int i2c_reg_read(uint8_t dev, uint8_t reg, uint8_t *val);另一组是裸读写EEPROM这类按块访问的设备:
int i2c_burst_read(uint8_t dev, uint16_t mem_addr, uint8_t *buf, uint16_t len); int i2c_burst_write(uint8_t dev, uint16_t mem_addr, const uint8_t *buf, uint16_t len);这些接口内部再根据是否需要多字节连续传输、是否需要处理16位寄存器地址,自动选择轮询或中断模式。上层代码只负责传参和收结果,这样同一个控制器换到不同项目时,上层代码几乎不用改。
封装到这一步之后,新的项目接入一个IIC设备,核心工作量就只剩看数据手册、把设备地址和寄存器表写进配置文件,整个过程大约半天就能跑通首发版本。
最后再分享一个我始终保留的习惯:不管Verilog控制器写得多顺手,手边永远放一份GPIO模拟IIC的最小实现。硬件控制器出问题时它能救命,调试早期它可以当参照波形,更重要的是它逼着你把IIC协议的每一个细节再过一遍。用硬件把时序做稳定,用软件把接口做简洁,用GPIO模拟兜底,这三件事配合起来,IIC控制器这个看似鸡肋的基础模块,才真正能让人放心地交到任何项目里。