经过上一篇I2C协议原理的讲解,相比大家都已经了解I2C的工作原理 。首先想好设计程序代码用哪种方案。
1、使用线性序列机
根据I2C传输时序特点,是很容易分析出I2C单纯的读或者写时序就像是时间轴上的一段连续操作,我们只需要在指定的时间将SDA或者SCL信号拉高、拉低、或者设置为三态就可以了。
优点:代码编写方便,思路简单。
缺点:代码编写比较杂乱,难以调试分析,代码量比较多。
2、使用状态机
序列机实现方便,但是编写过程中调试麻烦,而使用状态机就可以轻松解决这个问题,比如,对于整个I2C控制器从大的角度分为3个状态:空闲状态(IDLE),完整的写操作状态(WRITE),完整的读操作状态(READ)。然后再对每个单独的读和写状态进行进一步的划分,得到分别与读和写相关联的小状态,比如对于完整的写操作:
【发起起始位】>【写器件地址】>【应答位】>【写存储器地址】>【应答位】>【写数据】>【应答位】>【停止位】
对于完整的读操作,又可细分:
【发起起始位】>【写器件地址】>【应答位】>【写存储器地址】>【应答位】>【发起始位】>【写器件地址】>【应答位】>【读数据】>【应答位】>【停止位】
假设我们有以下一个模块:
先解释下模块里面的几个用到的输入输出端口信号功能:
接口名称 | I/O | 功能描述 |
Clk | I | 模块工作时钟,50M时钟 |
Rst_n | I | 模块复位信号 |
Cmd | I | 控制总线实现各种传输操作的各种命令的组合(写、读、起始、停止、应答、无应答) |
Go | I | 整个模块的启动使能信号,为了接口使用方便,使用时希望只需要对该端口产生一个单时钟周期的脉冲即可启动一个字节完整的传输(含可能的起始位、停止位、应答位) |
Rx_DATA | O | 总线收到的8位数据,读操作时读到的数据由此端口传出 |
Tx_DATA | I | 总线要发送的8位数据,需要传输的数据经此端口传入该模块 |
Trans_Done | O | 发送或接受8位数据完成标志信号,每次传输完成都会产生一个单周期的高脉冲信号 |
ack_o | O | 从机是否应答标志,在此底层逻辑中,我们暂时不对总线上是否给出正确的应答信号做出任何处理,仅将接收到的应答信号状态存储并输出,如果没有收到正确的应答信号,该如何执行下一步,由外部其他逻辑去决定。 |
i2c_sclk | O | i2c时钟总线 |
i2c_sdat | I/O | i2c数据总线 |
需要注意的是,在上述端口中,Cmd端口的解释是控制总线实现各种传输操作的各种命令组合。组合二字表明,该端口每次可能不止传输一个命令。什么意思,其实很好理解,以写操作来说,要实现8位数据的写,肯定要传输写命令,不能传输读命令。但是同时在8位数据传输之前还有可能 需要产生起始位,因此还需要同时给该逻辑提供产生起始位的命令,或者写完要产生停止位,也要
给该逻辑提供写命令同时还要提供产生停止位的命令,所以说每一次传输,命令端口输入的都应该是好几个命令的组合。
对于Cmd端口来说,按照总结,可以归纳为需要传输如下若干个基本命令:
写命令:本次传输为主机向从机写一个自己的数据(数据内容可以是控制字段、地址字段或者写入数据字段)
读命令:本次传输为主机接受从从机读到的1个字节的数据内容。
产生起始位命令:本次传输需要在数据内容之前加上起始位
产生停止位命令:本次传输需要在应答位之后加上停止位
ACK命令:本次传输需要产生应答位(主要针对读数据操作)
NACK命令:本次传输需要产生无应答信号(主要针对读数据操作)
分析完端口之后,接下来就可以分析要实现的传输。
状态机设计:
从上面的状态转移图可以看出,这里总共分成了7个状态,刚开始复位(RST)后的默认状态(IDLE)、产生起始信号状态(GEN_STA)、写数据状态(WR_DATA)、读数据状态(RD_DATA)、检测从机是否应答状态(CHECK_ACK)、给从机应答状态(GEN_ACK)、产生停止位状态(GEN_STO),通过这些状态我们就能组合成基本的I2C读写时序。
于是就可以定义如下几个状态:
localparam
IDLE = 7'b0000001,//空闲状态
GEN_STA = 7'b0000001,//产生起始信号
WR_DATA = 7'b0000001,//写数据状态
RD_DATA = 7'b0000001,//读数据状态
CHECK_ACK = 7'b0000001,//检测应答状态
GEN_ACK = 7'b0000001,//产生应答状态
GEN_STO = 7'b0000001;//产生停止信号
上文已经提到,一次传输中包含多种可能的命令要求,为了方便组合成对应传输情况,我们在这里将传输时需要执行的可能的6个命令先定义出来,(写请求<WR>、起始位请求<STA>、读请求<RD>、停止位请求<STO>、应答位请求<ACK>、无应答请求<NACK>)
localparam
WR = 6'b000001,//写请求
STA = 6'b000001,//起始位请求
RD = 6'b000001,//读请求
STO = 6'b000001,//停止位请求
ACK = 6'b000001,//应答位请求
NACK = 6'b000001;//无应答位请求
有了上述命令,我们就可以通过给模块的Cmd端口传输多个命令的组合来唯一限定一次特定传输需求。例如I2C传输中写器件地址操作,需要在传输之前加上起始位。那么我们根据写操作时序命令就是,起始位、器件地址、最低位为零,如果我们简化写一下就是Cmd=WR|STA。
了解了端口定义,在端口脉冲控制信号的控制下,使能en_div_cnt来让序列机计数器模块运行,同时将Cmd命令和定义的几个命令按位与运算,根据运算结果来确定下一步的操作。当然这个是有优先级的,因为只有产生起始信号(STA),才能进行写操作(WR)和读操作(RD)。所以空闲状态代码里面可以按照这个优先级来将Cmd和上面的几种命令进行按位与运算,如果结果为1,就可以跳转下面进行的对应状态,如果运算结果为0,接着往下面进行判断,代码如下:
IDLE(空闲状态):
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应该还是维持高电平,第四步将时钟总线i2c_sclk拉为低电平。这里将一个操作分成4步来完成,这也是为什么SCL_CNT_M的时候除以4的原因,代码如下:
GEN_STA:
GEN_STA:
begin
if(sclk_plus)begin
if(cnt == 3)
cnt <= 0;
else
cnt <= cnt +1'b1;
case(cnt)
0:begin i2c_sdat_0 <=1; i2c_sdat_oe <= 1'd1;end
1:begin i2c_sclk <= 1'd1;end
2:begin i2c_sdat_0 <=0; i2c_sclk <= 1;end
3:begin i2c_sclk <= 1'd1;end
default:begin i2c_sdat_o <= 1; i2c_sclk <= 1;end
endcase
if(cnt == 3)begin
if(Cmd & WR)
state <= WR_DATA;
else if(Cmd & RD)
state <= RD_DATA;
end
end
end
上面的代码中产生了起始信号,接着就是发送7位器件地址和1位方向位(0:写,1:读),这里举例的是写器件地址操作,所以当然最低位为0,那么起始信号发送完后同时就要在里面判断这个Cmd中是否含有写数据(WR)命令,再次判断如果Cmd和写数据命令(WR)命令进行按位与运算,如果运算结果为1,说明命令里面有写数据请求,此时就可以跳转到写数据状态,如果运算结果为0就接着往下面判断,代码如下:
WR_DATA:
WR_DATA:
if(sclk_plus)begin
if(cnt == 31)
cnt <= 0;
else
cnt <= cnt + 1'b1;
case(cnt)
0,4,8,12,16,20,24,28:
begin
i2c_sdat_o <= Tx_DATA[7-cnt[4:2]];
i2c_sdat_oe <= 1'd1;
end
1,5,9,13,17,21,25,29:begin i2c_sclk <= 1;end
2,6,10,14,18,22,26,30:begin i2c_sclk <= 1;end
3,7,11,15,19,23,27,31:begin i2c_sclk <= 0;end
default:begin i2c_sdat_o <= 1; i2c_sclk <= 1;end
endcase
if(cnt == 31)begin
state <= CHECK_ACK;
end
end
为了让数据这个状态写的数据更加灵活,这里我们把要写入的8位数据定义成输入端口Tx_DATA,那么我们把7位器件地址和最低位(方向位)的0直接赋值给Tx_DATA,之后就可以直接统一发送给Tx_DATA里面的数据就行了,同样我们还是按照产生起始位一样的规律,写数据的时候每一个bit也分成4步来完成,第一步将要发送的数据追备好(i2c_sdat_o <=Tx_DATA[7]),i2c_sdat_oe使能,第二步将总线时钟(SCL,代码里i2c_sclk)拉高,第三步将总线时钟继续保持为高电平,第四步将数据总线i2c_sclk拉为低电平。这样就将最高位的数据通过总线发出,接着还是一样分为四步发送Tx_DATA[6].....一直到最低位Tx_DATA[0]发送完成。
写完数据后我们就要判断是否真的写成功,判断依据就是对方有没有产生应答,所以写完数据后就要跳转到应答状态(CHECK_ACK)。
检测应答状态一样分为4步,第一步将总线设置为输入,即i2c_sdat_oe设置为0,i2c_sclk拉为低电平,第二步将时钟总线i2c_sclk拉高,第三步总线时钟继续保持为高电平,读取数据总线i2c_sdat的值到ack_o,此判断对方产生是否产生应答只需要判断ack_o的值是0(应答)还是1(无应答),第四步将时钟总线i2c_sclk拉为低电平。检测应答状态代码如下:
CHECK_ACK:
CHECK_ACK:
begin
if(sclk_plus)begin
if(cnt == 3)
cnt <= 0;
else
cnt <= cnt + 1'b1;
case(cnt)
0:begin i2c_sdat_oe <= 1'd0; i2c_sclk <= 0;end
1:begin i2c_sclk <= 1;end
2:begin ack_o <= i2c_sdat; i2c_sclk <= 0;end
3:begin i2c_sclk <= 0;end
default: begin i2c_sdat_o <= 1; i2c_sclk <= 1;end
endcase
if(cnt == 3)begin
if(Cmd & STO)
state <= GEN_STO;
else begin
state <= IDLE;
Trans_DONE <= 1'b1;
end
end
end
end
检测完应答信号后要判断Cmd里面是否含有要产生停止信号的命令,如果有就跳转到产生停止信号(CEN_STO)状态,没有就跳到空闲状态(IDLE)。
如果Cmd命令中定义了需要产生的停止位,则跳转到产生停止位状态(GEN_STO),根据i2c总线协议停止位的定义,在时钟(SCL,代码里面i2c_sclk)为高电平的时候,数据总线(SDA)由低电平到高电平的跳变就是一个停止信号,所以这里刚开始第一步就是讲i2c_sdat_o设置为0,i2c_sdat_oe使能,第二步将总线时钟i2c_sclk拉高,第三步将i2c_sdat_o设置为1,让外部上拉电阻将数据总线i2c_sclk拉成高电平,此时的i2c_sclk应该还是维持高电平,第四步将时钟总线i2c_sclk拉为低电平。产生停止信号代码如下:
GEN_STO:
GEN_STO:
begin
if(sclk_plus)begin
if(cnt ==3)
cnt <=0;
else
cnt <= cnt + 1'b1;
case(cnt)
0:begin i2c_sdat_o <=0; i2c_sdat_oe <= 1'd1;end
1:begin i2c_sclk <=1;end
2:begin i2c_sdat_o <=1; i2c_sclk <= 1;end
3:begin i2c_sclk <=1;end
default:begin i2c_sdat_o <=1; i2c_sclk <= 1;end
endcase
if(cnt == 3)begin
Trans_Done <= 1'b1;
state <= IDLE;
end
end
end
到这里一个基本写器件地址操作就完成了。
当然作为一个基本的i2c操作,读操作肯定是少不了的,我们可以按照写操作和应答检测的思路来实现,带一步将总线设置为输入,即i2c_sdat_oe设置为0以使SDA信号线为输入高组态,i2c_sclk拉为低电平,第二步将总线时钟i2c_sclk拉高,第三步将总线时钟继续保持为高电平,读取数据总线i2c_sdat_oe的值到Rx_DATA[7],第四步将时钟总线i2c_sclk拉为低电平。这样就将最高位的数据总线读取出来了,接着还是一样的分成4不来读取Rx_DATA[6].....直到最低位Rx_DATA[0]读取完成。这里用到了一个小技巧,每次将读取到的数据放到Rx_DATA的最低位,读取一次,左移一次,读取完8次数据后,第一次读取的最高位数据也就通过移位的方式放到最高位。读数据状态代码如下:
RD_DATA:
RD_DATA:
if(sclk_plus)begin
if(cnt ==31)
cnt <= 0;
else
cnt <= cnt +1'b1;
case(cnt)
0,4,8,12,16,20,24,28:
begin
i2c_sdat_oe <= 1'd0;
i2c_sclk <= 0;
end
1,5,9,13,17,21,25,29:begin i2c_sclk <= 1;end
2,6,10,14,18,22,26,30:begin
i2c_sclk <= 1;
Rx_DATA <= {Rx_DATA[6:0],i2c_sdat};
end
3,7,11,15,19,23,27,31:begin i2c_sclk <= 0;end
default:begin i2c_sdat_o <= 1; i2c_sclk <= 1;end
endcase
if(cnt == 31)
state <= GEN_ACK
end
end
读完数据后跳转到产生应答状态(GEN_ACK),这个状态里面会根据Cmd里面是否含有应答位的请求(ACK)或者无应答请求(NACK)来给对方做出一个应答,产生应答信号跟写数据状态一个思路,分为4步,第一步将Cmd与ACK进行与运算,如果结果为1说明需要产生应答,此时将i2c_sdat_o赋值为0,如果不满足,接着往下判断,将Cmd与NACK进行与运算,如果结果为1说明需要产生非应答信号,此时将i2c_sdat_o赋值为1,让外部电阻将数据总线拉成高电平。将i2c_sdat_oe使能,将时钟总线i2c_sclk拉为低电平,第二步将总线时钟i2c_sclk拉高,第三步将总线时钟继续保持为高电平,第四步将时钟总线i2c_sclk拉为低电平。这样就会根据Cmd给对方做出了应答,此时还要判断Cmd里面是否含有跳转到产生停止信号(STO)的命令(通过判断Cmd&STO是否为1就能判断),如果有就要跳转到产生停止信号(GEN_STO)。
GEN_ACK:
GEN_ACK:
begin
if(sclk_plus)begin
if(cnt ==3)
cnt <= 0;
else
cnt <= cnt +1'b1;
case(cnt)
0:begin
i2c_sdat_oe <= 1'd1;
i2c_sclk <= 0;
if(Cmd & ACK)
i2c_sdat_o <= 1'b0;
else if(Cmd & NACK)
i2c_sdat_o <= 1'b1;
end
1:begin i2c_sclk <= 1;end
2:begin i2c_sclk <= 1;end
3:begin i2c_sclk <= 0;end
default:begin i2c_sdat_o <= 1; i2c_sclk <=1;end
endcase
if(cnt == 3)begin
if(Cmd & STO)
state <= GEN_STO;
else begin
state <= IDLE;
Trans_Done <= 1'b1;
end
end
end
我们根据I2C的协议标准,这里采用的是快速模式(400kb/s)来作为总线的工作时钟,当然为了让这个模块更具有通用性和灵活性,这里就将系统时钟和产生的总线工作时钟频率都写成参数化,这里系统采用50MHZ的时钟,SYS_CLOCK设置成50000000,实际也可以根据输入的时钟来更改这个值,SCL_CLOCK是用来配置时钟(SCL)总线的频率,通过这两个参数就可以计算出参数SCL_CNT_M要计数到多少就能产生期望的总线工作时钟(SCL)频率,为什么要计算除以4,在产生起始信号状态就已说明。
//系统时钟采用50MHZ
paratmeter SYS_CLOCK=50_000_000;
//SCL总线时钟采用400kHZ
paratmeter SCLK_CLOCK = 40_000;
//产生时钟SCL计数器最大值
localparam SC_CNT_M = SYS_CLOCK/SCL_CLOCK/4-1;
有了上面的SCL_CNT_M ,接下来就可以根据这个值来产生状态机部分的工作时钟sclk_plus。这个部分的逻辑运行是通过en_div_cnt的有效控制的,当然这个信号的有效与否是受脉冲信号Go来控制的,在状态机部分可以提现出来。
reg [19:0]dic_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;
对于i2c总线,要求连接到总线上的输出端必须是开漏输出结构,给不了高电平,所以总线上所有的高电平应该是有上拉电阻上拉达到的效果,而不是由主机直接给总线赋值为1就能实现,所以我们在写这个逻辑的时候也应该遵循这个标准,当总线上要输出低电平的时候,我们就直接给总线赋值为0,要输出为高电平时,只能将总线设置成高组态,这样再由外部上拉电阻上来成高电平,这是为了方便理解,就把我们给赋值给总线的值先复制给i2c_sdat_o,然后在控制使能信号i2c_sdat_oe,通过这两个信号间接的来给数据总线SDA赋值,代码是通过下面实现的
assign i2c_sdat =!i2c_sdat_o && i2c_sdat_oe ?1'b0:1'bz
未完续(请看仿真验证) 。。。。。。