FPGA基础协议二:I2C读写E2PROM
一、I2C与E²PROM
I2C常用念法:I²C(读作"I-squared-C" ),还有可选的拼写方式是I2C(读作I-two-C)以及IIC(读作I-I-
C),在中国则多以**“I方C”**称之。
1. I2C
I2C:(Inter-Integrated Circuit)即集成电路总线,是一种两线式串行总线,由PHILIPS公司开发,用于连接微控制器及其外围设备。多用于主机和从机在数据量不大且传输距离短的场合下的主从通信。
-
I2C总线由数据线SDA和时钟线SCL构成双向可收发的通信线路
-
是一种半双工通信协议。
-
总线上的主设备与从设备之间以字节(8bit)为单位进行双向的数据传输。(升级版UART?)
1.1 物理层
-
它是一个支持多设备的总线(支持多主机多从机)。
-
I2C总线只使用两条总线线路,一条双向串行数据线(SDA) 一条串行时钟线(SCL)。数据线即用来表示数据,时钟线用于数据收发同步。
-
每个连接到I2C总线的设备都有一个独立的地址,主机可以利用设备独立地址访问不同设备。
-
I2C总线通过上拉电阻接到电源。当I2C设备空闲时,设备会输出高阻态,当所有设备都空闲,都输出高阻态时,由上拉电阻把I2C总线拉成高电平。
-
I2C总线具有仲裁机制1。
-
具有三种传输模式:
**标准模式:**传输速率为100kbit/s
**快速模式:**传输速率为400kbit/s
**高速模式:**传输速率为3.4Mbit/s(不支持E2PROM)
老师说他们一般写200kbit/s
1.2 协议层
-
I2C协议空闲状态:SCL与SDA均保持高电平,并且此时无I2C设备工作。
-
I2C协议的起始信号 :SCL保持高电平的同时SDA拉低。
-
I2C协议的数据传输状态:在SCL低电平时更新SDA的数据信息。
SCL高电平时改变SDA会误发送起始/停止信号。
-
应答信号:发送端每发送一个字节,就必须在第9个SCL脉冲期间释放SDA,由接收端反馈一个应答信
号。
-
ACK:有效应答,表示成功接收端接收了该字节,低电平表示
-
NACK:非应答,表示接受失败、传输结束,高电平表示。
-
-
I2C协议的停止信号:SCL高电平时拉高SDA。
把起始位看作一个时钟周期,8位数据位8个时钟周期,应答位一个时钟周期,约为10个时钟周期。所以这里可以与UART协议进行比对,在不算校验位且每次传输1字节的uart数据帧也是10bit,冥冥之中感到了某种联系。
I2C到此为止,感觉像是一个强化以后的UART协议
2. E2PROM
EEPROM,或写作E2PROM,全称电可擦除可编程只读存储器 (英语:Electrically-Erasable Programmable Read-Only Memory),是一种可以通过电子方式多次复写的半导体存储设备。相比EPROM,EEPROM不需要用紫外线照射,也不需取下,就可以用特定的电压,来抹除芯片上的信息,以便写入新的数据。
我的C4开发板使用的是型号为24LC04B的EEPROM存储芯片。24LC04B的存储容量为512Bytes/4Kbits,其内部有两个Block,每个Block中有256个字节(一个字节为8bit)。其读写操作都是以字节(8bit)为基本单位。 24LC04B EEPROM 存储芯片的器件地址包括:
- 厂商设置的高4位1010,这里表设备代码。
- 用户需自主设置的低3位 x、x、B0 来选择块地址。
- 字节存储地址,一共8bit。
在IIC主从设备通讯时,主机在发送了起始信号后,接着会向从机发送控制命令。控制命令长度为1个字节,它的高7位为上文讲解的 IIC设备的器件地址,最低位为读写控制位。EEPROM储存芯片控制命令格式示意图,具体见下图。
因为只有两个块,所以只设置B0即可,前面两位不会对结果造成影响。
- 读写控制位为 0 时,表示主机(FPGA)要对从机(EEPROM)进行数据
写入
操作:{7’b1010xxx,1’b0} - 读写控制位为 1 时,表示主机(FPGA)要对从机(EEPROM)进行数据
读出
操作:{7’b1010xxx,1’b1}
2.1 写操作
字节写(BYTE WRITE)操作:一次写入1个字节。在数据信号线SDA上,发起始位(START)->写写控制字(Control Byte)->接收ACK->写字节地址(Word Address)->接收ACK->写数据(Data) ->接收ACK->发停止位(STOP)。
页写(PAGE WRITE)操作:一次写入16个字节(每字节为8bit)数据。在数据信号线SDA上,发起始位(START)->写写控制字(Control Byte)->接收ACK->写字节地址(Word Address)->接收ACK->写数据(Data(n)) ->接收ACK->写数据(Data(n+1))->接收ACK->(一共发送16字节(Byte)数据,中间数据省略)->写数据(Data(n+15))->接收ACK->发停止位(STOP)。
2.2 读操作
当前地址读(Current Address READ)操作:在数据信号线SDA上,发起始位(START)->写读控制字(Control Byte)->接收ACK->接收读数据(Data)->发No ACK->发停止位(STOP)。
随机地址读(RANDOM READ)操作:在数据信号线SDA上,发起始位(START)->写写控制字(Control Byte)->写读地址(Word Address)->接收ACK->再发起始位(START)->写读控制字(Control Byte)->接收读数据(Data)->发No ACK->发停止位(STOP)。
顺序地址读(SEQUENTIAL READ)操作:在数据信号线SDA上,发起始位(START)->写写控制字(Control Byte)->写读地址(Word Address)->接收ACK->再发起始位(START)->写读控制字(Control Byte)->接收读数据(Data(n)) ->接收ACK->接收读数据(Data(n+1)) ->接收ACK->…->接收读数据(Data(n+x)) ->发No ACK->发停止位(STOP)。
我更愿意把它叫做 页读,对应页写都是一次性读取多个字节,只不过它可以从指定地址一直连续读完整个内存。
然后要注意的是No Ack,这代表着是由I2C向E2PROM发送应答位,高电平表示?
二、逻辑设计
1. 实验需求
-
使用按键,模拟读写请求信号
-
收到读写请求信号时,FPGA通过I2C协议向E2PROM芯片写入单字节数据或从E2PROM芯片读出单字节数据;
-
使用串口来接收需要写入的数据和显示被读出的数据
2. 设计思路
- 一提到按键就要想到按键消抖模块。
- 需要一个读写控制模块与I2C接口模块:
- 读写控制模块负责指挥I2C接口模块发送读写命令、地址、数据,以及使用两个fifo分别寄存uart写入数据和E2PROM读出数据。
- 2C接口模块负责将这些字节并串转换为I2C协议允许的格式再传输E2PROM中,并将接收到的信息翻译再反馈给FPGA(串并转换)。
- 串口收发模块以及收发分别对应的2个fifo。
2.1 模块:
- key_debounce模块:按键消抖模块,没什么好说的。
- uart模块:分为收发两个模块,分别配备写与读两个fifo,用以缓存等待写和发的数据。
- e2prom_rwctrl模块:E2PROM读写控制模块,用来向E2PROM存储器发送读写控制命令,以及缓存写入E2PROM的数据和从E2PROM读取到的数据。
- i2c_interface模块:i2c接口模块,用来把接收到的指令并串转换为I2C协议的格式然后传输到E2PROM存储器中,或将E2PROM传输回来的信息进行串并转换传回到fifo中。
- i2c_ctrl模块:SDA总线控制模块,负责SDA这根inout双向数据总线的输入输出控制。
2.2 工作步骤:
写:
- 由上位机串行发送 x Bytes的数据到串口接收模块
uart_rx
中; uart_rx
模块将接收到的串行信号进行串并转换然后发送到e2prom_rwctrl
模块中;e2prom_rwctrl
模块,每接收到一次rx_vld
(数据接收有效信号)后,开启wrfifo
的写请求,写入一个字节,直到接收完为止;wrfifo
缓存完成后,e2prom_rwctrl
模块向i2c_interface
模块发送写请求和需要写入的数据,每次发送1个字节的数据,在收到done
信号后再次发送1个字节,知道发送完3个字节为止;i2c_interface
负责将接收到的写控制字
、写地址
、wr_data[7:0]
进行一个符合I2C协议的并串转换,并在每转换传输完成1个字节后,向e2prom_rwctrl
模块发送done信号,提醒它传输下一个字节。
读:
- 按下key,经过按键消抖模块后输出稳定的key信号到
e2prom_rwctrl
模块中; e2prom_rwctrl
模块接收到key信号后向i2c_interface
模块发送写控制字
、读地址
、读控制字
;i2c_interface
模块接收到读请求后发送写控制字
、读地址
、读控制字
,接着在一个应答信号后将接收到的SDA信号进行一个依据I2C协议的串并转换发送到e2prom_rwctrl
模块中;e2prom_rwctrl
模块接收到的所有rd_data[07:00]
缓存到读fifo中,然后以先进先出的原则依次发送给uart_tx
模块;uart_tx
模块将接收到的每一个rd_data[07:00]
进行并串转换,然后发送到上位机中,在发送途中拉高busy
信号,表示tx线已被占用:等我发完这个字节你这个fifo再传下一个进来。
以上便是本次项目全部代码的真谛,🤯顿悟这个流程后再写代码就不难了。
3. 程序框图
4. 状态机
根据主状态机产生时钟的原则,i2c_interface模块为主状态机,e2prom读写控制模块为从状态机。
4.1 从状态机
- IDLE:空闲状态;
- WR_REQ:写请求,当
wr_fifo
缓存完成后激活,向i2c_interface
模块发送命令参数cmd[3:0]
与请求信号req
以及需要写入的数据wr_data[7:0]
;
为什么叫写请求而不是写?它又不只是发送请求。
这个状态用来请求I2C接口模块向E2PROM写入数据,所以叫写请求没毛病。
- WAIT_WR_DONE:并串转换需要时间,这个状态就是用来等待转换完成然后返回一个
done
信号告诉e2prom_rwctrl
模块该发下一个字节了,并再次进入WR_WEQ
状态,如果已发送字节超过了3(这次只针对字节写操作),则进入DONE
状态;
我在这里曾经有一个疑问,为何不能把WR_REQ与WAIT状态合二为一成一个WRITE状态,这样应该更加省事,但现在想来,不够严谨,因为如果只有一个WRITE状态,就需要在同一个状态下发送几个不同的数据,有点像一个大状态里塞了几个小状态,这样来看反而更加复杂了。而通过WAIT和字节计数器来切换WR_REQ下要发送的内容反而更加严谨,也更符合状态机的思维。下面的RD序列也同理。
-
RD_REQ:读请求,当收到后激活,向
i2c_interface
模块发送读相关命令参数cmd与请求信号req以及需要写入的数据wr_data[7:0]
(包括写控制字,读地址,读控制字),3个字节都发送完成后接收rd_data[7:0]
。 -
WAIT_RD_DONE:对于读操作而言的一个等待串并转换完成的状态,当字节计数器<3时回到RD_REQ,否则进入DONE状态;
-
DONE:表操作完成,直接进入idle状态即可。
4.2 主状态机
根据老师的思路画的状态机
-
IDLE:空闲状态,这个时候
SCL
与SDA
都为高电平,等待主状态机的命令。 -
START:发送起始位,此时
SDA
将在SCL
处于高电平时拉低。起始位只存在于读写控制字之前,往后的操作是以ack应答信号为依据来进行的。因为读写控制字都是被发送的,所以应该不存在由
START
跳转到READ
这一说,不知道这里是否是为了对称美才把这条线添上去? -
WRITE:此时写入数据,即由
I2C接口模块
向E2PROM
发送串行数据,不管是发送并串转换后的读写控制字
、地址
或是被写入数据
,都是由I2C接口模块
占用SDA总线
向E2PROM
发送串行信号,所以个人认为,与其叫做WRITE
状态,不如命名为SEND
状态。 -
R_ACK:i2c接口模块
接收
来自E2PROM的应答信号。 -
READ:此时读入数据,即由
E2PROM
占用SDA总线
向I2C接口模块
发送串行数据,这时I2C接口模块
属于接收方,所以我认为将此状态命名为RECEIVE
状态会更好。 -
S_ACK:i2c接口模块是接收方时,会向E2PROM
发送
应答信号 -
STOP:发送停止位。此时
SDA
将在SCL
处于高电平时拉高。不是每一个数据帧都有停止位,起始位只存在于操作结束时的那个数据帧后面。
所以从我自己的理解的收发角度来看这样命名和连线会更好一点:
三、代码实现
1. UART模块
这里可以看我的上一篇博客FPGA基础协议一:UART,写法基本上是一致,除了这里我使用了两个fifo以及更改了输入输出位宽(串口那儿我使用了10bit的输入输出,这次则变为更标准的8bit)。
2.按键消抖模块
鉴于按键消抖模块上次偷懒没有提,这里补充一下,后面也就不再单独发了
**按键抖动:**按键抖动通常的按键所用开关为机械弹性开关,当机械触点断开、闭合时,由于机械触点的弹性作用,一个按键开关在闭合时不会马上稳定地接通,在断开时也不会一下子断开。因而在闭合及断开的瞬间均伴随有一连串的抖动。当按下一次按键,可能在A点检测到一次低电平,在B点检测到一次高电平,在C点又检测到一次低电平。同时抖动是随机,不可测的。那么按下一次按键,抖动可能会误以为按下多次按键。
综上所述,按键抖动是一种亚稳态。其因为寄存器建立保持时间不足引起的,再加上是单bit信号,所以我们这里采用双寄存器同步来消除亚稳态。
-
建立时间(setup time)是指在触发器的时钟信号上升沿到来以前,数据稳定不变的时间,如果建立时间不够,数据将不能在这个时钟上升沿被打入触发器;建立时间决定了该触发器之间的组合逻辑最大延迟。
-
保持时间(hold time)是指在触发器的时钟信号上升沿到来以后,数据稳定不变的时间,如果保持时间不够,数据同样不能被打入触发器。;保持时间决定了该触发器之间的组合逻辑的最小延迟。
这里一般采用20ms延时处理来满足建立保持时间就可以了。
模块输入输出:
module key_debounce #(parameter KEY_W = 3,TIME_DELAY = 1_000_000)( //1/50 s
input clk ,
input rst_n ,
input [KEY_W-1:0] key_in ,
output reg [KEY_W-1:0] key_out //检测到按下,输出一个周期的高脉冲,其他时刻为0
);
endmodule
这里我们可以添加一个可选参数KEY_W
用来灵活选择按键的个数,以及TIME_DELAY
来选择想要的消抖时延。
key_in代表着按键输入信号,key_out则是经消抖处理后的输出信号。
双寄存器与下降沿检测:
reg [KEY_W-1:0] key_r0 ;//同步按键输入
reg [KEY_W-1:0] key_r1 ;//打拍
//同步按键输入,并打一拍,以检测下降沿
always @(posedge clk or negedge rst_n)begin
if(!rst_n)begin
key_r0 <= {KEY_W{1'b1}};//KEY_W代表着位宽,这里代表着给r0几个位宽的1;
key_r1 <= {KEY_W{1'b1}};
end
else begin
key_r0 <= key_in;//同步
key_r1 <= key_r0;//打拍
end
end
assign nedge = ~key_r0 & key_r1;
计数器开始条件:
//检测到下降沿的时候,拉高计数器计数使能信号,延时结束时,再拉低使能信号
always @(posedge clk or negedge rst_n)begin
if(!rst_n)begin
add_flag <= 1'b0;
end
else if(nedge)begin
add_flag <= 1'b1;
end
else if(end_cnt)begin
add_flag <= 1'b0;
end
end
当检测到下降沿后,计数器开始计时20ms,完成延时后输出当前的按键信号。
3. 调用读写FIFO的IP核
UART工程中的单个FIFO实现的功能很有限,打个比方来说,单纯的串口工程就像左手换右手,中间的fifo只是让左右手传递的东西有个临时歇脚平台。
而i2c工程有所不同,除了串口这双手以外,还有第三只手,名叫E2PROM
,他负责把rx这只手传过来的东西握住(存储),再在恰当的时间传给tx。
而又因为串并转换和并串转换都需要时间,所以不能rx接多少就递过来多少,应该先找个地方放着,再一个一个发送给E2PROM
。
读取过程同理,所以我这里选择使用两个同步fifo作为数据缓冲区。
wr_fifo
: 负责存入uart_rx接收的需要写入的数据,即由uart_rx负责输入数据,并向i2c接口模块输出8bit的并行数据。所以存储和输出数据位宽定为8bit即可。
rd_fifo
: 负责将E2PROM读出来的数据转交给uart_tx发送出去,即由E2PROM负责输入数据,并向uart_tx模块进行一个并串转换再输出。位宽同上。
wrfifo wrfifo_inst (
.aclr (~rst_n ),
.data (din_data ),
.clock (clk ),
.rdreq (wr_rdreq ),
.wrreq (wr_wrreq ),
.q (wr_q ),
.empty (wr_empty ),
.usedw (wr_usedw ),
.full (wr_full )
);
assign wr_wrreq = ~wr_full && din_vld;//非满且输出有效
assign wr_rdreq = state_c == WAIT_WR && done && cnt_byte > 1;//这个时候开始提取数据写入E2PROM
rdfifo rdfifo_inst (
.aclr (~rst_n ),
.data (rd_data ),
.clock (clk ),
.rdreq (rd_rdreq ),
.wrreq (rd_wrreq ),
.q (rd_q ),
.empty (rd_empty ),
.usedw (rd_usedw ),
.full (rd_full )
);
assign rd_wrreq = ~rd_full && state_c==WAIT_RD && cnt_byte > 2 && done;//这个使用将E2PROM中读出来的数据存入
assign rd_rdreq = ~busy && rd_flag;//非满,TX不忙
//这里的rd_flag是非满信号打拍获得。
4. I2C_CTRL 总线控制模块
因为SDA这根总线比较特殊,传输是双向的,半双工,所以我们需要这样一个模块来决定SDA什么时候收,什么时候发。
最开始我觉得这样单独列出来一个模块没有必要,因为我认为在I2C接口模块完成这样一个收发切换功能也是可行的;后面意识到分开写有不少好处
- 编写逻辑更加严谨,不会使接口模块的代码过于臃肿,可读性也会增强不少,让人一看就懂
- 顶层例化更加方便,也可针对这I2C的功能进行单独仿真
这一模块最重要的是SDA总线的输出或输入使能,这里我选择输出使能,因为大部分时间都在输出。
module i2c_ctrl (
input clk ,
input rst_n ,
/*输入信号*/
input busy ,
input key ,
input din_vld ,
input [09:00] din_data,
/*输出信号*/
output dout_vld,
output [09:00] dout_data,
output scl ,
inout sda //
);
//参数定义
//中间信号定义
wire req ;
wire [03:00] cmd ;
wire s_ack ;
wire done ;
wire [09:00] wr_data ;
wire [09:00] rd_data ;
wire sda_out_en;
wire sda_out ;
wire sda_in ;
//实例化
e2promrw_ctrl u_rw_ctrl(
/* input */.clk (clk ),
/* input */.rst_n (rst_n ),
/*uart输入信号*/
/* input */.key (key ),
/* input */.din_vld (din_vld ),
/* input [07:00] */.din_data (din_data),
/* input */.busy (busy ),//发送忙碌信号,防止发送覆盖
/*i2c接口输入信号*/
/* input */.done (done ),//代表串并转换完成
/* input */.s_ack (s_ack ),//接收串口的应答信号以进行下一步操作
/* input [07:00] */.rd_data (rd_data ),
/*i2c接口输出信号*/
/* output */.req (req ),
/* output [03:00] */.cmd (cmd ),
/* output [07:00] */.wr_data (wr_data ),
/*uart输出信号*/
/* output */.dout_vld (dout_vld),
/* output [07:00] */.dout_data(dout_data)
);
i2c_interface u_i2c_interface(
/* input */.clk (clk ),
/* input */.rst_n (rst_n ),
/*输入信号*/
/* input */.req (req ),
/* input [07:00] */.wr_data (wr_data ),
/* input [03:00] */.cmd (cmd ),
/*输出到控制模块信号*/
/* output */.done (done ),
/* output */.sack (s_ack ),
/* output [07:00] */.rd_data (rd_data ),
/*输出到E2PROM*/
/* output */.scl (scl ),
/* output */.i2c_sda_oe(sda_out_en),
/* output */.i2c_sda_o (sda_out ),
/* output */.i2c_sda_i (sda_in )
);
assign sda = sda_out_en?sda_out:1'bz;
assign sda_in = sda;
endmodule
不输出的时候给予SDA高阻态即可,这样就能接收E2PROM的信息了;sda_in作为sda的输入端则要一直恒等于sda以来接收讯息。
5. E2PROM读写控制模块
有不懂的参数可以去param参数一栏中查看
E2PROM负责发送命令和数据来指挥I2C接口模块,除去读写fifo后也没有太多代码。因为在上一个模块展示了端口代码,这里不再贴出。
5.1 状态机
//参数定义
localparam IDLE = 6'b000_001,
WRITE = 6'b000_010,
READ = 6'b000_100,
WAIT_WR = 6'b001_000,
WAIT_RD = 6'b010_000,
DONE = 6'b100_000;
wire idle2write ;
wire idle2read ;
wire wait2write ;
wire write2wait ;
wire write2done ;
wire read2wait ;
wire wait2read ;
wire read2done ;
wire done2idle ;
//状态机
always @(posedge clk or negedge rst_n) begin
if (rst_n==0) begin
state_c <= IDLE ;
end
else begin
state_c <= state_n;
end
end
always @(*) begin
case(state_c)
IDLE :begin
if(idle2write)
state_n = WRITE ;
else if(idle2read)
state_n = READ ;
else
state_n = state_c ;
end
WRITE :begin
if(write2wait)
state_n = WAIT_WR ;
else
state_n = state_c ;
end
READ :begin
if(read2wait)
state_n = WAIT_RD ;
else
state_n = state_c ;
end
WAIT_WR :begin
if (wait2write) begin
state_n = WRITE;
end
else if (write2done) begin
state_n = DONE;
end else begin
state_n = state_c ;
end
end
WAIT_RD :begin
if (wait2read) begin
state_n = READ;
end
else if (read2done) begin
state_n = DONE;
end else begin
state_n = state_c ;
end
end
DONE :begin
if(done2idle)
state_n = IDLE;
else
state_n = state_c;
end
default : state_n = IDLE;
endcase
end
/* 写状态 */
assign idle2write = state_c == IDLE && wr_usedw > (`WR_BYTE-2);
assign write2wait = state_c == WRITE && (1'b1);
assign wait2write = state_c == WAIT_WR && done && cnt_byte < (`WR_BYTE-1) ;
assign write2done = state_c == WAIT_WR && end_byte;
/* 读状态 */
assign idle2read = state_c == IDLE && key;
assign read2wait = state_c == READ && (1'b1);
assign wait2read = state_c == WAIT_RD && done && cnt_byte < (`RD_BYTE-1);
assign read2done = state_c == WAIT_RD && end_byte;
/* 完成任务 */
assign done2idle = state_c == DONE && (1'b1);//转身即逝
- idle2write:idle到写状态;进入条件时当wr_fifo内存储的数据大于当前操作方式的最小写入数据量。
WR_BYTE:这里指写操作字节数量,字节写是3个,页写是18个,减去写控制字节和写地址字节等两个字节后盛夏的就是最小写入数据量。(它与RD_BYTE都被包含在param文件里,后面会单独讲解)
- write2wait:写到等待并串转换转换状态;当在写状态发完东西后立马进入。
- wait2write:完成并串转换回到写状态;收到done信号后且不是写的最后一个字节就可以回去。
- write2done:并串转换完最后一个字节就可以到done状态去了。
- idle2read:idle到读;按键按下后开始运作。
- read2wait:读到等带串并转换/并串转换状态;读状态激活后立马进入。
读操作的前三个字节都是写操作,所以前面三个应为等待并串转换。
- wait2read:等带串并转换/并串转换完成后且不是操作的最后一个字节就可以回去。
- read2done:最后一个字节完成串并转换后就能结束啦!
- done2idle:done状态激活即死亡(回到idle)。
DONE状态其本身的作用也只是标明这个操作完成了,只要它拉高一次就能完成安排给它的任务,所以不需要它活太久。
5.2 计数器
字节计数器:
每一个操作在什么时候要干什么都由它来决定,也是一个非常重要的计数器。
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cnt_byte <= 1'b0;
end
else if (add_byte) begin
if (end_byte) begin
cnt_byte <= 1'b0;
end else begin
cnt_byte <= cnt_byte + 1'b1;
end
end
end
assign add_byte = (state_c==WAIT_WR | state_c==WAIT_RD) & done;
assign end_byte = add_byte && (cnt_byte == ((state_c == WRITE || state_c == WAIT_WR)?(`WR_BYTE-1):(`RD_BYTE-1))); //写操作需要3个字节的空位,读操作则需要4个字节
起始条件:在等待串并转换/并串转换的状态下,每done一次就计数一次。
结束条件:根据当前的操作方式判断封顶字节数。
地址计数器:
不管什么操作都需要发送地址,并且分为读地址和写地址,所以这里需要两个地址计数器
//写地址计数器
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
wr_addr <= 1'b0;
end
else if (write2done) begin
wr_addr <= wr_addr + (`WR_BYTE-2);
end
end
//读地址计数器
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rd_addr <= 1'b0;
end
else if (read2done) begin
rd_addr <= rd_addr + (`RD_BYTE-3);
end
end
每完成一次操作,都与当前操作的最小写入/读取量相加。
5.3 发送TASK
根据字节计数器和当前状态来决定发送什么样的数据。为了优化代码结构,所以这里应用了TASK。
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
TX(1'b0,4'b0,10'b0);
end
else if (state_c == WRITE) begin
case(cnt_byte)
0 : TX(1'b1,(`CMD_START|`CMD_WRITE),{`I2C_ADR,wr_addr[8],`WR_BIT}) ;
1 : TX(1'b1,`CMD_WRITE,wr_addr[07:00]);
`WR_BYTE-1 : TX(1'b1,(`CMD_WRITE|`CMD_STOP),wr_q);
default : TX(1'b1,`CMD_WRITE,wr_q);
endcase
end
else if (state_c == READ) begin
case(cnt_byte)
0 : TX(1'b1,(`CMD_START|`CMD_WRITE),{`I2C_ADR,rd_addr[8],`WR_BIT}) ;
1 : TX(1'b1,`CMD_WRITE,rd_addr[07:00]);
2 : TX(1'b1,(`CMD_START|`CMD_WRITE),{`I2C_ADR,rd_addr[8],`RD_BIT}) ;
`RD_BYTE-1 : TX(1'b1,(`CMD_READ|`CMD_STOP),0);//此时收数据,不再需要传输
default : TX(1'b1,`CMD_READ,0);
endcase
end
else begin
TX(1'b0,tx_cmd,tx_data);
end
end
task TX;
input req;
input [03:00] cmd;
input [07:00] data;
begin
tx_cmd = cmd;
tx_req = req;
tx_data = data;
end
endtask
assign cmd = tx_cmd;
assign req = tx_req;
assign wr_data = tx_data;
- 写操作的前两个字节分别是写控制字和写地址,后面的都是写入数据;
- 读操作的前三个字节分别是写控制字,读地址和读控制字,剩下的则是等待读入数据,所以发送0即可。
其他时间除了请求信号以外应保持不变防止数据覆盖(这只是我的拙见)。
另外可以看到第一个字节和最后一个字节都使用了按位或 '|'
运算符,这相当于发送了两个命令,可以在下面的I2C接口模块看到它们的作用。
6. I2C接口模块
负责将数据通过串并转换/并串转换处理成符合i2c协议的数据的模块。
6.1 状态机
localparam IDLE = 7'b000_0001, //初始状态
START= 7'b000_0010, //发送起始位
SEND = 7'b000_0100, //写/发
RECEIVE = 7'b000_1000, //读/收
R_ACK= 7'b001_0000, //作为发送方接收应答位
S_ACK= 7'b010_0000, //作为接收方发送应答位
STOP = 7'b100_0000; //发送停止位
wire idle2start ;
wire idle2send ;
wire idle2receive ;
wire start2send ;
//wire start2receive ;start并不能跳转到receive,因为读数据的时候不需要起始位
wire send2rack ;
wire receive2sack ;
wire rack2stop ;
wire sack2stop ;
wire rack2idle ;
wire sack2idle ;
wire stop2idle ;
reg [06:00] state_c ;
reg [06:00] state_n ;
//状态机
always @(posedge clk or negedge rst_n) begin
if (rst_n==0) begin
state_c <= IDLE ;
end
else begin
state_c <= state_n;
end
end
always @(*) begin
case(state_c)
IDLE :begin
if (idle2start)
state_n = START;
else if(idle2send)
state_n = SEND ;
else if(idle2receive)
state_n = RECEIVE ;
else
state_n = state_c ;
end
START :begin
if (start2send) begin
state_n = SEND;
end else begin
state_n = state_c ;
end
end
SEND :begin
if(send2rack)
state_n = R_ACK ;
else
state_n = state_c ;
end
RECEIVE :begin
if(receive2sack)
state_n = S_ACK ;
else
state_n = state_c ;
end
R_ACK :begin
if(rack2idle)
state_n = IDLE ;
else if(rack2stop)
state_n = STOP ;
else
state_n = state_c ;
end
S_ACK :begin
if(sack2idle)
state_n = IDLE ;
else if(sack2stop)
state_n = STOP ;
else
state_n = state_c ;
end
STOP :begin
if(stop2idle)
state_n = IDLE ;
else
state_n = state_c ;
end
default : state_n = IDLE ;
endcase
end
assign idle2start = state_c == IDLE && req && (cmd&`CMD_START);
assign idle2send = state_c == IDLE && req && (cmd&`CMD_WRITE);
assign idle2receive = state_c == IDLE && req && (cmd&`CMD_READ);
assign start2send = state_c == START && end_bit && (cmd_r&`CMD_START);
assign send2rack = state_c == SEND && end_bit ;
assign receive2sack = state_c == RECEIVE && end_bit ;
assign rack2stop = state_c == R_ACK && end_bit && (cmd_r&`CMD_STOP);
assign sack2stop = state_c == S_ACK && end_bit && (cmd_r&`CMD_STOP);
assign rack2idle = state_c == R_ACK && end_bit && (cmd_r&`CMD_STOP) == 0;
assign sack2idle = state_c == S_ACK && end_bit && (cmd_r&`CMD_STOP) == 0;
assign stop2idle = state_c == STOP && end_bit;
- idle2start:接收到起始命令和有效请求即可,因为读写控制模块发送命令时使用的
按位或
操作,所以接收到的命令是包含两个1的,这个时候去按位与
原本的CMD_START也是可以获得真值的,这就是使用按位或
运算符的好处,下面的停止位也同理。 - idle2send:收到写命令和有效请求即可。
看到这里可能会有一点疑惑,第一个字节发送了两个命令,如此看来可以同时激活idle2start和idle2send,那这个时候idle会往哪里跳呢?所以就要注意上面状态转移always语句块的写法了,根据优先级,一定要把idle2start放到第一个if语句里,这样即使激活两个信号后根据if的优先级也能只跳到START状态。
- idle2receive:收到读命令和有效请求即可。
- start2send:这里的end_bit的条件是cnt_bit=0,用来衡量起始位是否结束。
- send2rack:这里的end_bit则是7。
- receive2sack:同上。
- rack2stop:同样是根据命令和bit计数器来决定
- sack2stop:同上。
- rack2idle:bit计数器完成计数且没有收到停止命令就能回去。
- sack2idle:同上。
- stop2idle:这里的endbit也是0。
cmd_r代表着cmd打一拍。
6.2 计数器
分为bit计数器和scl时钟计数器。这里我设想的是sda传输速率为200kbit/s左右,再根据50Mhz时钟进行换算,得到一个scl时钟周期 = 250个50Mhz的时钟周期。
localparam I2CYC = 249, //I2C一个时钟周期
I2C_HALF = 124, //I2C半个时钟周期
I2C_BE_HA = 64, //I2C前半时钟周期的中点
I2C_AF_HA = 189;//I2C后半时钟周期的中点
//scl周期计数器
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cnt_scl <= 1'b0;
end
else if (add_scl) begin
if (end_scl) begin
cnt_scl <= 1'b0;
end else begin
cnt_scl <= cnt_scl + 1'b1;
end
end
end
assign add_scl = (state_c!= IDLE);
assign end_scl = add_scl && cnt_scl == I2CYC;
//bit计数器
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
cnt_bit <= 1'b0;
end
else if (add_bit) begin
if (end_bit) begin
cnt_bit <= 1'b0;
end else begin
cnt_bit <= cnt_bit + 1'b1;
end
end
end
assign add_bit = (state_c != IDLE) && end_scl;
assign end_bit = add_bit && cnt_bit == ((state_c == SEND || state_c == RECEIVE)?3'd7:1'b0);//8个数据位+1个应答位
bit计数器的开始条件就是每完成一个scl时钟周期就+1,停止条件则根据当前状态进行判断,发送和接收都是8bit,起始位与停止位1bit即可。
6.3 SCL时钟控制
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
scl_r <= 1'b1;//空闲时间高电平
end
else if (idle2start | idle2send | idle2receive) begin
scl_r <= 1'b0;
end
else if (add_scl && cnt_scl == I2C_HALF) begin
scl_r <= 1'b1;
end
else if (end_scl && ~stop2idle) begin
scl_r <= 1'b0;
end
end
开始条件:当从空闲状态解脱时就能拉低开始了。
拉高条件:除了空闲状态,每当scl时钟计数器满足125次,即半个周期后就得拉高。
继续技术:当每计数完一个时钟周期后并且没有收到停止命令时拉低。
6.4 串并转换与并串转换
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
sda_out <= 1'b1;//空闲时间高电平
end
else if (state_c == START) begin //严谨
if (cnt_scl == I2C_BE_HA) begin
sda_out <= 1'b1;
end
else if (cnt_scl == I2C_AF_HA) begin
sda_out <= 1'b0;
end
end
else if (state_c == STOP) begin
if (cnt_scl == I2C_BE_HA) begin
sda_out <= 1'b0;
end
else if(cnt_scl == I2C_AF_HA) begin
sda_out <= 1'b1;
end
end
else if (state_c == SEND && cnt_scl == I2C_BE_HA) begin
sda_out <= tx_data[7-cnt_bit];//数据位要在时钟低电平时变化
end
else if (state_c == S_ACK && cnt_scl == I2C_BE_HA) begin
sda_out <= (cmd_r&`CMD_STOP)?1'b1:1'b0;
end
end
//串并转换器
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
rd_data_r <= 1'b0;
end
else if (state_c == RECEIVE && cnt_scl == I2C_AF_HA) begin
rd_data_r[7-cnt_bit] <= i2c_sda_i;
end
end
起始位和停止位都需要单独注意,一个是在SCL高电平时拉低,另一个则是拉高。
并串转换时要在SCL低电平时拉低数据,这里取一个低电平的中间值能够避免出错,并且让时序图也更加美观易懂。
SACK:发送应答信号,这个时候需要注意是否要发送停止位,如果此时已经是最后一个字节,那么直接拉高发送停止位即可。
串并转换则是在SCL时钟拉高时进行数据转换。
7. Param数据声明文件说明
`define CMD_START 4'b0001
`define CMD_WRITE 4'b0010
`define CMD_READ 4'b0100
`define CMD_STOP 4'b1000
//I2C外设地址参数定义
`define I2C_ADR 6'b1010_00 //6'b1010_00xy x:Block地址 y:读写控制位 WR_BIT/RD_BIT
`define WR_BIT 1'b0 //bit0
`define RD_BIT 1'b1 //bit0
//`define BYTE_WRITE
`define PAGE_WRITE
//`define RANDOM_READ
`define SEQU_READ
//读写模式定义
`ifdef PAGE_WRITE
`define WR_BYTE 18
`else
`define WR_BYTE 3
`endif
`ifdef SEQU_READ
`define RD_BYTE 19
`else
`define RD_BYTE 4
`endif
//串口参数定义
`define STOP_BIT 1'b1
`define START_BIT 1'b0
命令参数这样写的好处就在于可以通过位运算符获得意想不到的结果。
这里的BYTE_WRITE,PAGE_WRITE,RANDOM_READ,SEQU_READ分别代表字节写,页写,随机读和顺序读操作,这里使用difine和ifdef的好处就是可以通过改写define来轻松该变整个代码逻辑。比如这里我把页写注释掉,那么WR_BYTE就会变成3个字节,从而使代码执行字节写操作。
四、仿真测试
这里采用的页写和按顺序读模式。
`timescale 1 ns/1 ns
module top_tb();
//时钟复位
reg clk ;
reg rst_n ;
//输入
reg [07:00] tx_data ;
reg tx_vld ;
reg key ;
reg [07:00] rand_data ;//产生随机数输入
//输出
wire tx ;
wire busy ;
wire tx_bit ;
wire scl ;
wire sda ;
//时钟周期定义
parameter CYCLE = 20;
//复位时间定义
parameter RST_TIME = 3 ;
//模块例化
uart_tx u_tx(
/* input */.clk (clk ),
/* input */.rst_n (rst_n ),
//输入信号
/* input [01:00] */.baud_set(2'd3),
/* input */.rx_vld (tx_vld ),
/* input [09:00] */.rx_data (tx_data ),
//输出信号
/* output */.tx (tx ),
/* output */.busy (busy )
);
i2c_e2prom u_i2c_e2prom(
/* input */.clk (clk ),
/* input */.rst_n (rst_n ),
/* input */.rx (tx ),
/* input */.key_in (key ),
/* output */.tx (tx_bit),
/* output */.scl (scl ),
/* inout */.sda (sda ) //
);
i2c_slave_model u_slave(
.scl (scl ),
.sda (sda )
);
task TX;
input [9:0] data ;
begin
tx_vld = 1'b1;
tx_data = data;
#(1*CYCLE);
tx_vld = 1'b0;
@(negedge busy);
#(1*CYCLE);
end
endtask
integer i = 0,j = 0,k=0;
//产生时钟
initial begin
clk = 1;
forever
#(CYCLE/2)
clk=~clk;
end
//产生复位
initial begin
rst_n = 1;
#2;
rst_n = 0;
#(CYCLE*RST_TIME);
rst_n = 1;
end
//产生激励
initial begin
#1;
tx_data = 0;
tx_vld = 0;
rand_data = 0;
#(10*CYCLE);
for(i=0;i<100;i=i+1)begin
rand_data = {$random};
TX(rand_data);
end
#(1000*CYCLE);
end
initial begin
#1;
key = 1;
while(j<50)begin //串口发送50个字节之后
@(negedge busy);
j = j + 1;
end
#(100*CYCLE);
for(j=0;j<10;j=j+1)begin //模拟按键按下
k = {$random}%50;
key = {$random};
#(k*CYCLE);
#(50000*CYCLE);
end
key = 1;
#(1000*CYCLE);
$stop;
end
endmodule
仿真波形图:
写操作:
读操作:
可以看到后面读的时候以及接收应答信号时,收到的都是蓝色高阻态,这是因为modelsim的局限性,无法完全仿真出双向传输的真实情况所导致,当看到蓝色信号线就当自己接收到就行。
五、上板测试
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6dWA6fJD-1658675067586)(https://karmen-imgs-inside.oss-cn-chengdu.aliyuncs.com/images/2LV2YM3A}NOSBOXR7I[D%1H.png)]
尾、总结
首先,I2C协议是练习状态机和时序理解的不错选择。
其次,在对协议知根知底后,再来写代码会很容易,但这个容易只是相对的。对于初学者来讲仅仅只是提供一个写的思路而已,除了思路还得会编写方法,如果我在这之前写过更多代码且有更丰富的经验的话才会使整个过程变得更容易。
所以,代码经验不能少,理论知识也不能少。
附、知识点补充
1. Verilog 任务
任务与函数的区别:
和函数一样,任务(task)可以用来描述共同的代码段,并在模块内任意位置被调用,让代码更加的直观易读。函数一般用于组合逻辑的各种转换和计算,而任务更像一个过程,不仅能完成函数的功能,还可以包含时序控制逻辑。
比较点 | 函数 | 任务 |
---|---|---|
输入 | 函数至少有一个输入,端口声明不能包含 inout 型 | 任务可以没有或者有多个输入,且端口声明可以为 inout 型 |
输出 | 函数没有输出 | 任务可以没有或者有多个输出 |
返回值 | 函数至少有一个返回值 | 任务没有返回值 |
仿真时刻 | 函数总在零时刻就开始执行 | 任务可以在非零时刻执行 |
时序逻辑 | 函数不能包含任何时序控制逻辑 | 任务不能出现 always 语句,但可以包含其他时序控制,如延时语句 |
调用 | 函数只能调用函数,不能调用任务 | 任务可以调用函数和任务 |
书写规范 | 函数不能单独作为一条语句出现,只能放在赋值语言的右端 | 任务可以作为一条单独的语句出现语句块中 |
任务声明:
任务在模块内定义位置不限,引用位置也不限,但作用范围局限于该模块。
模块内子程序出现下面任意一个条件时,则必须使用任务而不能使用函数。
- 子程序中包含时序控制逻辑,例如延迟,事件控制等
- 没有输入变量
- 没有输出或输出端的数量大于 1
task xor_oper_iner;
input [N-1:0] numa;
input [N-1:0] numb;
output [N-1:0] numco ;
//output reg [N-1:0] numco ; //无需再注明 reg 类型,虽然注明也可能没错
#3 numco = numa ^ numb ;
//assign #3 numco = numa ^ numb ; //不用assign,因为输出默认是reg
endtask
末、参考文献
I2C的仲裁机制: 在多主的通信系统中。总线上有多个节点,它们都有自己的寻址地址,可以作为从节点被别的节点访问,同时它们都可以作为主节点向其他的节点发送控制字节和传送数据。但是如果有两个或两个以上的节点都向总线上发送启动信号并开始传送数据,这样就形成了冲突。要解决这种冲突,就要进行仲裁的判决,这就是I 2C总线上的仲裁。I2C总线上的仲裁分两部分:SCL线的同步和SDA线的仲裁。 ↩︎