I2C接口控制设计与实现

IIC系列文章:
(1)I2C 接口控制器理论讲解
(2) I2C接口控制设计与实现
(3)I2C连续读写实现
(4)使用IIC进行多数据读取测试

文章目录

  • 前言
  • 一、I2C 控制器实现思路解析
  • 二、状态机实现
  • 三、仿真验证


前言

根据完整的 I2C 传输时序,提取出通用的底层传输单元。

提示:以下是本篇文章正文内容,下面案例可供参考

一、I2C 控制器实现思路解析

根据 I2C 传输的时序特点,是很容易分析总结出来,I2C 单纯的读或者写时序就像是时间轴上的一段连续的操作,我们只需要在指定的时间将 SDA 或者 SCL 信号拉高、拉低、或者设置为三态就可以了。
I2C接口控制设计与实现_第1张图片
根据时序图,从主机角度来描述一次写入单字节数据过程如下:
单字节地址写单字节数据过程:
1、主机设置 SDA 为输出;
2、主机发起起始信号;
3、主机传输器件地址字节,其中最低位为 0,表明为写操作;
4、主机设置 SDA 为三态门输入,读取从机应答信号;
5、读取应答信号成功,主机设置 SDA 为输出,传输 1 字节地址数据;
6、主机设置 SDA 为三态门输入,读取从机应答信号;
7、读取应答信号成功,主机设置 SDA 为输出,传输待写入的数据;
8、设置 SDA 为三态门输入,读取从机应答信号;
9、读取应答信号成功,主机产生 STOP 位,终止传输。
双字节地址写单字节数据过程:
1、主机设置 SDA 为输出;
2、主机发起起始信号;
3、主机传输器件地址字节,其中最低位为 0,表明为写操作;
4、主机设置 SDA 为三态门输入,读取从机应答信号;
5、读取应答信号成功,主机设置 SDA 为输出,传输地址数据高字节;
6、主机设置 SDA 为三态门输入,读取从机应答信号;
7、读取应答信号成功,主机设置 SDA 为输出,传输地址数据低字节;
8、设置 SDA 为三态门输入,读取从机应答信号;
9、读取应答信号成功,主机设置 SDA 为输出,传输待写入的数据;
10、设置 SDA 为三态门输入,读取从机应答信号;
11、读取应答信号成功,主机产生 STOP 位,终止传输。
同样的,I2C 读操作时序根据不同 I2C 器件具有不同的器件地址字节数,单字节读操作分为 1 字节地址段器件单字节数据读操作和 2 字节地址段器件单字节数据读操作。下图 1和下图 2 分别为不同情况的时序图。

I2C接口控制设计与实现_第2张图片
根据时序图,从主机角度来描述一次读取单字节数据过程如下:
单字节地址读取单字节数据过程:
1、主机设置 SDA 为输出;
2、主机发起起始信号;
3、主机传输器件地址字节,其中最低位为 0,表明为写操作;
4、主机设置 SDA 为三态门输入,读取从机应答信号;
5、读取应答信号成功,主机设置 SDA 为输出,传输 1 字节地址数据;
6、主机设置 SDA 为三态门输入,读取从机应答信号;
7、读取应答信号成功,主机发起起始信号;
8、主机传输器件地址字节,其中最低位为 1,表明为读操作;
8、设置 SDA 为三态门输入,读取从机应答信号;
9、读取应答信号成功,主机设置 SDA 为三态门输入,读取 SDA 总线上的一个字节的
数据;
10、产生无应答信号(高电平)(无需设置为输出高电平,因为总线会被自动拉高);
11、主机产生 STOP 位,终止传输。
双字节地址读取单字节数据过程:
1、主机设置 SDA 为输出;
2、主机发起起始信号;
3、主机传输器件地址字节,其中最低位为 0,表明为写操作;
4、主机设置 SDA 为三态门输入,读取从机应答信号;
5、读取应答信号成功,主机设置 SDA 为输出,传输地址数据高字节;
6、主机设置 SDA 为三态门输入,读取从机应答信号;
7、读取应答信号成功,主机设置 SDA 为输出,传输地址数据低字节;
8、设置 SDA 为三态门输入,读取从机应答信号;
9、读取应答信号成功,主机发起起始信号;
10、主机传输器件地址字节,其中最低位为 1,表明为读操作;
11、设置 SDA 为三态门输入,读取从机应答信号;
12、读取应答信号成功,主机设置 SDA 为三态门输入,读取 SDA 总线上的一个字节的数据;
13、主机设置 SDA 输出,产生无应答信号(高电平)(无需设置为输出高电平,因为总线会被自动拉高);
14、主机产生 STOP 位,终止传输。

从图中可以看到,无论是写入数据还是从EEPROM中读出数据,都需要一系列的较多重复的步骤,而每个步骤的核心部分就是8位数据的发送,相伴随着的是是否有起始位、是否有结束位、是否有应答位等等。既然如此,那我们将完整的读写操作时序进行分析归纳,得到一个通用的底层传输模块,使用该模块就能够完成 I2C 协议中每一段的操作,比如带起始位的写、带停止位的写、带停止位的读。然后再在上层模块多次使用该底层模块来实现一次完整的数据传输。以完整的读操作为例,如下图:

I2C接口控制设计与实现_第3张图片
如果我们将起始位、停止位、应答位分别和就近的一次 8 位数据传输组合到一起,就可以发现对于一次完整的读操作,实际可以分成以下三种小的传输:
 第一次和第三次,起始位+写数据(7 位器件 ID + 1 位读写控制位),然后检查从机应答位,也就是带起始位的单字节写操作。
 第二次,写数据(8 位 EEPROM 的寄存器地址),然后检查从机应答位。
 第四次,读数据(从 EEPROM 中读出 8 位数据)+ 应答位(根据需要给出应答(0)或者无应答(1))+ 停止位,也就是带停止位的读操作而对于写操作,最后一次则是写 8 位数据,然后检查应答位,然后再产生停止位,也就是带停止位的写操作。
我们基本可以总结出 I2C 传输协议中包含的各种情况,为以下五种:
 带起始位的写操作,不带停止位,主要对应每次开始传输时候的一段。
 不带起始位的写操作,也不带停止位,主要对应传输器件地址和连续写多个字节数据到器件中的操作,比如 EEPROM 的写写入操作。
 不带起始位的写操作,但是带停止位,主要对应写入数据时候的一段,也就是完整写的最后一段。
 不带起始位的读操作,也不带停止位,主要对应从器件中连续读出多个字节数据的操作,比如 EEPROM 的页读取操作。
 不带起始位的读操作,但是带停止位,主要对应从器件中读出最后一个数据的那一段。
如果用图示的形式表达,就是下面的样子:

I2C接口控制设计与实现_第4张图片
总结下来,发现其实非常的简单,每段在传输的时候,只需要确定当前这个字节的传输之前是否需要加入起始位,以及当前这个字节的传输结束后是否需要加入停止位就结束了。因此,我们完全可以编写一个能够根据指令的灵活的决定是否在每个字节的传输之前加入起始位,在每个字节之后是否加入停止位的逻辑,来应对这多种传输情况。

假设我们有下图这样一个模块,这个模块就像是一个搬运工,他的职责就是按照命令端口(CMD)的指示,将 Data 端口上的 8 位数据按照一定的格式发出去。
I2C接口控制设计与实现_第5张图片

I2C接口控制设计与实现_第6张图片

二、状态机实现

I2C接口控制设计与实现_第7张图片
从上面的状态转移图可以看出,这里总共分成了 7 个状态,刚开始复位(RST)后的默认状态(IDLE)、产生起始信号状态(GEN_STA)、写数据状态(WR_DATA)、读数据状态(RD_DATA)、检测从机是否应答状态(CHECK_ACK)、给从机应答状态(GEN_ACK)、产生停止位状态(GEN_STO),通过这些状态我们就能组合成基本的 I2C 读写时序了。于是就可以定义如下几个状态:

localparam
		IDLE      = 8'b00000001,   //空闲状态
		GEN_STA   = 8'b00000010,   //产生起始信号
		WR_DATA   = 8'b00000100,   //写数据状态
		RD_DATA   = 8'b00001000,   //读数据状态
		CHECK_ACK = 8'b00010000,   //检测应答状态
		GEN_ACK   = 8'b00100000,   //产生应答状态
		GEN_STO   = 8'b01000000;   //产生停止信号

当然为了方便组合成对应传输情况,我们在这里定义了组合成 Cmd 里面出现的 6 个命令(写请求、起始位请求、读请求、停止位请求、应答位请求、无应答请求),同样为了更直观我们和在代码里面处理,我们也将其定义成了参数:

localparam 
		WR   = 6'b000001,   //写请求
		STA  = 6'b000010,   //起始位请求
		RD   = 6'b000100,   //读请求
		STO  = 6'b001000,   //停止位请求
		ACK  = 6'b010000,   //应答位请求
		NACK = 6'b100000;   //无应答请求

例如:我们来一次写器件地址操作,那么我们根据写操作时序来就是,起始位、器件地址、最低位为 0(表示写)、停止位,如果我们简化一下就可以用 Cmd 加上器件地址就可以了,那么此时的 Cmd 命令里面就应该含有写请求、起始位请求、停止位请求,这时的 Cmd 就可以用个小技巧(Cmd=WR | STA | STO)那么这个 Cmd 到时候怎么来用呢,这就是下面要说的上面定义的 7 种状态的 IDLE 状态。
在 Go 脉冲控制信号的控制下,使能 en_div_cnt 来让上面的计数器模块运行,同时将Cmd 命令和上面定义的几种命令进行按位与运算,最终来确定状态的跳转。当然这个是有优先级的,因为只有产生了起始信号(STA),才能进行写操作(WR)和读操作(RD),所以空闲状态代码里面可以按照这个优先级来将 Cmd 和上面的几种命令进行按位与运算,如果运算结果为 1,就可以跳转到下面对应的状态,如果运算结果为 0 就接着往下来判断,代码如下:

IDLE:
				begin
					Trans_Done <= 1'b0;
					i2c_sdat_oe <= 1'd1;
					if(Go)begin
						en_div_cnt <= 1'b1;
						if(Cmd & STA)
							state <= GEN_STA;
						else if(Cmd & WR)
							state <= WR_DATA;
						else if(Cmd & RD)
							state <= RD_DATA;
						else
							state <= IDLE;
					end
					else begin
						en_div_cnt <= 1'b0;
						state <= IDLE;
					end
				end

根据上面的代码,显然写器件地址操作的 Cmd 命令里面的条件会首先跳转到 GEN_STA这个状态,根据 i2c 总线协议规定,在时钟(SCL)为高电平的时候,数据总线(SDA)由高到低的跳变为总线起始信号,所以这里刚开始第一步将 i2c_sdat_o 设置为 1,i2c_sdat_oe使能,第二步将总线时钟(SCL,代码里面 i2c_sclk)拉高,第三步将第一步已经被上拉电阻拉高的 i2c_sdat 再拉低(代码里面是通过拉低 i2c_sdat_o 来间接拉低 i2c_sdat),此时的i2c_sclk 应该还是维持高电平,第四步将时钟总线 i2c_sclk 拉为低电平。
【注:】
(1)对于 i2c 总线,要求连接到总线上的输出端必须是开漏输出结构,给不了高电平,所以总线上所有的高电平应该是由上拉电阻上拉达到效果的,而不是由主机直接给总线赋值 1 就能实现,所以我们在写这个逻辑的时候也应该遵循这个标准,当总线上要输出低电平的时候,我们就直接给总线赋值 0,要输出高电平的时候,只能将总线设置成高阻态,这样再由外部上拉电阻来上拉成高电平,这里为了方便理解,就把我们给赋值给总线的值先赋值给i2c_sdat_o,然后再控制使能信号 i2c_sdat_oe,通过这两个信号间接的来给数据总线 SDA(代码里面是 i2c_sdat)赋值。
(2)在下面程序中,当i2c_sdat_oe为高电平时,SDA信号线作为输出(对于FPGA来讲)。当i2c_sdat_oe为低电平时,SDA信号线为高阻态,其状态受从机影响,可以作为数据输入线。

assign i2c_sdat = !i2c_sdat_o && i2c_sdat_oe ? 1'b0:1'bz;       

本实验SCL时钟信号要满足400kHz左右,则需要通过系统时钟进行计数分频得到;这里将一个操作分成了 4 步来完成,这也是为什么计算SCL_CNT_M 的时候除以 4 的原因。

//系统时钟采用50MHz
	parameter SYS_CLOCK = 50_000_000;
	//SCL总线时钟采用400kHz
	parameter SCL_CLOCK = 400_000;
	//产生时钟SCL计数器最大值
	localparam SCL_CNT_M = SYS_CLOCK/SCL_CLOCK/4 - 1;


	   reg [19:0]div_cnt;
		reg en_div_cnt;
		always@(posedge Clk or negedge Rst_n)
		if(!Rst_n)
			div_cnt <= 20'd0;
		else if(en_div_cnt)begin
			if(div_cnt < SCL_CNT_M)
				div_cnt <= div_cnt + 1'b1;
			else
				div_cnt <= 0;
		end
		else
			div_cnt <= 0;
	
		wire sclk_plus = div_cnt == SCL_CNT_M;

【注:】
在时钟(SCL)为高电平的时候,数据总线(SDA)由高到低的跳变为总线起始信号,
在时钟(SCL)为高电平的时候,数据总线(SDA)由低到高的跳变为总线停止信号。

I2C接口控制设计与实现_第8张图片

产生起始信号状态代码如下:

GEN_STA:

你可能感兴趣的:(FPGA代码分享,网络,fpga开发,fpga,网络协议)