在今年二月份的时候我写了一篇关于SPI模式的Verilog代码实现的博客(原文 ),当时由于时间关系,我只测试了SPI的一种通信模式(CPOL = 0, CPHA = 0),在该模式下通信正常,但是其它模式没有进行仔细测试,经网友提醒发现该代码在其他模式下会出现数据错位的情况,于是我花了一点时间仔细分析了SPI的工作模式,并重写了SPI主机代码,相对于以前的程序,该版本更为简洁、易懂,同时,在新的程序中,我使用了输入信号来告诉模块需要发送的数据位宽,实现动态的改变需要发送的数据位数。
关于SPI的通信时序,我也是参考其他博主的博文进行学习,这里我贴上一个我认为写的比较好的博文,供需要的网友学习,博文地址:SPI总线协议及SPI时序图详解。
在这里我主要分析一下怎么在四种SPI通信模式的时序中提取出共同特点,以方便我们编写出通用性高、简洁的代码。
在上图中,我画出了CPOL、CPHA取不同值时的时钟、数据波形图,当CPOL、CPHA取不同值时,一共有四种组合,也即组成了SPI的四种通信模式。
在分析之前,我们先规定以数据变化的时钟边沿为0时刻,参考上图,当CPHA = 0时,以红色竖线所对应的时钟边沿为0时刻,当CPHA = 1时,以蓝色竖线所对应的时钟边沿为0时刻。
1.时钟0时刻电平与CPOL、CPHA的关系
对于SPI通信而言,SCK时钟线并不是一直有时钟,而是只有在通信的时候才有时钟产生,因此,我们就需要确定当SPI总线空闲的时候,也就是0时刻的时候,SCK时钟线处于什么电平,我们分析上图可以得出以下四条结论:
CPOL = 0,CPHA = 0时,0时刻SCK电平为低电平;
CPOL = 0,CPHA = 1时,0时刻SCK电平为高电平;
CPOL = 1,CPHA = 0时,0时刻SCK电平为高电平;
CPOL = 1,CPHA = 1时,0时刻SCK电平为低电平。
由以上四条结论我们又可以总结出SCK的空闲电平与CPOL、CPHA的关系为:
SCKIDLE = CPOL^CPHA
2.数据采样、输出与时钟边沿的关系
要使代码具有通用性,就需要知道不同模式下,数据的采样、输出与时钟边沿的关系,以及有什么共同的特点,我们分析上图同样可以得出四条结论(以0时刻开始分析):
CPOL = 0,CPHA = 0,数据在SCK的上升沿采样,下降沿输出;
CPOL = 0,CPHA = 1,数据在SCK的上升沿输出,下降沿采样;
CPOL = 1,CPHA = 0,数据在SCK的上升沿输出,下降沿采样;
CPOL = 1,CPHA = 1,数据在SCK的上升沿采样,下降沿输出。
由以上四条结论我们可以总结出:CPOL^CPHA为真时,数据在SCK的上升沿输出,下降沿采样,CPOL ^CPHA为假时,数据在SCK的上升沿采样,下降沿输出。但是实际我们在编写SPI主机代码的时候,SCK的时钟往往都是由分频器产生的,由于前面我们分析的不同模式SCK的空闲电平不一样,如果以SCK的上升沿、下降沿来确定数据的采样以及输出,就会带来额外的复杂度,因为空闲电平不一样,产生上升沿及下降沿的时刻也不一样。因此,我们需要进一步提取出共同点,仔细分析通信时序,我们可以得到一个非常简洁的结论:对于任意的CPOL、CPHA组合,数据采样都在第一个时钟边沿进行,数据输出都在第二个时钟边沿进行!! 程序中我们更容易知道什么时候SCK进行第一次变化,什么时候进行第二次变化,这就给我们程序的编写带来了极大的方便。
对于SPI时钟模块,主要的功能为产生指定频率的SCK时钟,以及输出控制信号给SPI控制模块使用,该模块代码比较简单,模块输入输出信号及功能由下表所示:
信号名称 | 方向 | 位宽 | 功能 |
---|---|---|---|
Clk_I | 输入 | 1 | 模块时钟 |
RstP_I | 输入 | 1 | 模块复位信号,高电平有效 |
SPI_InCtrl_O | 输出 | 1 | SPI主机数据采样脉冲 |
SPI_OutCtrl_O | 输出 | 1 | SPI主机数据输出脉冲 |
SPI_SCK_O | 输出 | 1 | SPI主机SCK时钟 |
同时有四个入口参数,由下表所示:
参数名称 | 参数功能 |
---|---|
CLK_FREQ | 输入时钟频率,单位为MHz |
CPOL | SPI参数,时钟极性 |
CPHA | SPI参数,时钟相位 |
SPI_CLK_FREQ | SPI时钟频率,单位为Hz |
在该模块中,我们使用计数的方式来产生指定频率的时钟,首先使用入口参数来确定我们计数器的归零值,计算方式如下:
/* 时钟分频计数器 */
localparam CLK_DIV_CNT = (CLK_FREQ * 1000) / SPI_CLK_FREQ;
使用位宽计算函数来确定计数器所需要的位宽:
/* 位宽计算函数 */
function integer clogb2(input integer depth);
begin
for(clogb2 = 0; depth > 0; clogb2 = clogb2 + 1)
depth = depth >> 1;
end
endfunction
reg[clogb2(CLK_DIV_CNT) - 1 : 0] ClkDivCnt;
最重要的,使用入口参数CPOL、CPHA来确定时钟的空闲电平:
/* 根据SPI模式来决定SCK空闲的极性
___ ___ ___ ___ ___
CPOL = 0: ___| |___| |___| |___| |___|
___ ___ ___ ___ ___
CPOL = 1: |___| |___| |___| |___| |___
_______ _______ _______ _______ _______
CPHA = 0: X_______X_______X_______X_______X_______X
___ _______ _______ _______ _______ ___
CPHA = 1: ___X_______X_______X_______X_______X___
分析SPI模式对应的波形图,由数据线变化的时钟沿为起始时钟,可以得到以下分析结果:
1、当CPOL = 0,CPHA = 1以及CPOL = 1,CPHA = 0时,SCK的起始电平为高电平。
2、当CPOL = 0,CPHA = 0以及CPOL = 1,CPHA = 1时,SCK的起始电平的低电平。
3、主机及从机都在时钟的奇数边沿对数据进行采样,偶数边沿进行数据的变化。
由以上分析结果我们可以知道:
1、SCK的起始电平,也即空闲电平为CPOL^CPHA;
2、SPI_InCtrl(数据采样信号)为时钟的奇数边沿,SPI_OutCtrl(数据变化信号)为时钟的偶数边沿。
*/
localparam SCK_IDLE = CPHA ^ CPOL;
至此,我们就确定的模块的主要参数,由我们之前分析的结果,SPI主机的数据采样边沿为第一个时钟边沿,输出边沿为第二个时钟边沿,同时我们所计算的计数器归零值为一个SCK周期的计数值,因此,我们在计数器计数到CLK_DIV_CNT/2的时候输出第一个边沿,也就是主机的采样边沿,计数到CLK_DIV_CNT的时候输出第二个时钟边沿,也就是主机的输出边沿。这样,我们就不需要额外的判断SCK的上升沿或者下降沿。
/* 时钟分频计数器控制块 */
always@(posedge Clk_I) begin
if(RstP_I) begin
ClkDivCnt <= 0;
end else if(ClkDivCnt == CLK_DIV_CNT - 1) begin
ClkDivCnt <= 0;
end else begin
ClkDivCnt <= ClkDivCnt + 1'b1;
end
end
/* SCK控制块 */
always@(posedge Clk_I) begin
if(RstP_I) begin
SCK <= SCK_IDLE;
end else if(ClkDivCnt == CLK_DIV_CNT - 1 || (ClkDivCnt == (CLK_DIV_CNT >> 1) - 1)) begin
SCK <= ~SCK;
end else begin
SCK <= SCK;
end
end
/* 数据输出信号 */
always@(posedge Clk_I) begin
if(RstP_I) begin
SPI_OutCtrl <= 1'b0;
end else begin
SPI_OutCtrl <= (ClkDivCnt == CLK_DIV_CNT - 2) ? 1'b1 : 1'b0;
end
end
/* 数据采样信号 */
always@(posedge Clk_I) begin
if(RstP_I) begin
SPI_InCtrl <= 1'b0;
end else begin
SPI_InCtrl <= (ClkDivCnt == (CLK_DIV_CNT >> 1) - 2) ? 1'b1 : 1'b0;
end
end
SPI主机控制模块负责数据的采样、输出控制,模块输入输出信号由下表所示:
信号名称 | 方向 | 位宽 | 功能 |
---|---|---|---|
Clk_I | 输入 | 1 | 模块时钟 |
RstP_I | 输入 | 1 | 模块复位信号,高电平有效 |
WrRdReq_I | 输入 | 1 | 数据发送、读取请求,高电平有效 |
WrRdReqAck_O | 输出 | 1 | 模块对发送、读取请求的ACK |
WrRdFinish_O | 输出 | 1 | 数据读取、发送完成信号 |
WrRdDataBits_I | 输入 | 7 | 要发送、读取的数据位宽 |
WrData_I | 输入 | 32 | 要发送的数据 |
RdData_O | 输出 | 32 | 读取到的数据 |
SPI_InCtrl_I | 输入 | 1 | 数据采样脉冲 |
SPI_OutCtrl_I | 输出 | 1 | 数据输出脉冲 |
MOSI_O | 输出 | 1 | MOSI |
MISO_I | 输入 | 1 | MISO |
CS_O | 输出 | 1 | CS |
该模块由状态机驱动,状态定义及转移请看代码:
/* 主状态机 */
always@(posedge Clk_I) begin
if(RstP_I) begin
CurrentState <= S_RST;
end else begin
CurrentState <= NextState;
end
end
always@(*) begin
NextState = S_RST;
case(CurrentState)
S_RST: NextState = S_IDLE; /* 复位状态,在该状态对所有寄存器进行复位 */
/* 检测到数据读写请求,则跳转到数据锁存状态 */
S_IDLE: NextState = (WrRdReq_I) ? S_ACK: S_IDLE;
/* 拉高信号忙,并等待外部请求拉低 */
S_ACK: NextState = (WrRdReq_I) ? S_ACK : S_RUN;
/* 等待数据发送完成 */
S_RUN: NextState = (WrRdBitsCnt == WrRdBitsLatch) ? S_END : S_RUN;
/* 在该状态对外发送一个时钟周期宽度的脉冲信号,表示读取数据完成 */
S_END: NextState = S_IDLE;
default: NextState = S_RST;
endcase
end
数据输出:
/* 发送数据控制块 */
always@(posedge Clk_I) begin
case(CurrentState)
S_RST, S_IDLE: WrDataLatch <= 32'd0;
S_ACK: WrDataLatch <= WrData_I; /* 先保存需要发送的数据 */
S_RUN: begin
if(SPI_OutCtrl_I) begin
WrDataLatch <= {WrDataLatch[30:0], 1'b0};
end else begin
WrDataLatch <= WrDataLatch;
end
end
default: WrDataLatch <= 32'd0;
endcase
end
数据采样:
/* 接收数据控制块 */
always@(posedge Clk_I) begin
case(CurrentState)
S_RST, S_ACK: RdDataLatch <= 32'd0;
S_RUN: begin
if(SPI_InCtrl_I) begin
RdDataLatch <= {RdDataLatch[30:0], MISO_I};
end else begin
RdDataLatch <= RdDataLatch;
end
end
default: RdDataLatch <= RdDataLatch;
endcase
end
SPI Master模块主要完成SPI_Master_Clock模块与SPI_Master_Ctrl模块的连线,其整体的RTL级视图如图所示:
在这里我直接将CS信号作为SPI_Master_Colck模块的复位信号,这样SPI_Master_Ctrl模块里面就不需要额外的处理对SCK时钟的使能信号。
发送数据位数为32位,SPI频率为8MHz,可以看到SPI主机发送的数据SPI从机正确接收,SPI从机发送的数据主机也正确接收。
发送32位数据
发送8位数据,注意,发送的有效数据放在高位,接收到的有效数据放在低位
发送32位数据
发送24位数据,注意,发送的有效数据放在高位,接收到的有效数据放在低位
发送8位数据,注意,发送的有效数据放在高位,接收到的有效数据放在低位
由以上仿真结果可以看出,SPI主机模块对于四种通信模式都能正常工作,同时对于动态改变位宽也能正常功能。
这篇博客是我上一篇关于SPI主机模块的更新文章,间隔了有半年时间,这半年间也对FPGA的代码理解也更为深入,因此也觉得曾经写的主机代码不是很好,也就没有去更新那篇博文,而是另外写了这一篇,在这份代码里也肯定有我没有考虑到的地方,也希望有发现的网友能够指出错误,共同学习。相关代码我已上传论坛,有需要的可以去下载。