Verilog实现的SPI通信协议(主机模式)
一、前言
最近在使用FPGA调试一个MCP2515CAN芯片的时候,需要用到SPI通信协议,也在网上看了许多不同人写的博客,也学习了很多种不同的写法,从结果来看,网上给出的大部分例子都能实现SPI通信协议,但是我也发现了一个共同的问题,就是很多人在实现SPI协议的实现都使用了状态机,而且是一个很长的状态机,每发送一位就有一个状态,这就会导致代码看起来特别长,各个信号的逻辑关系也比较混乱,同时,网上例程实现的大都是8位的SPI协议,然而有些器件通信协议并不一定是8位的,就比如说我使用的这个MCP2515芯片,从器件手册里可以看到该芯片的为32位的SPI(就是在一个CS有效期间,发送的数据位数,一般而言都是一次发送一个字节的数据,对应一次CS有效信号,但是该芯片是一次发送4个字节的数据,对应一次CS有效),如果还使用一位对应一个状态的写法,那代码将会变得更复杂。因此我在这里分享一种使用状态量少、且可配置位宽的SPI协议的写法,相互学习,对于SPI时序的讲解,网上已经有很多人讲得够好了,我就不在这里献丑了。由于本人学习FPGA的时间也不是很长,程序中难免会有错误的地方,如果有人发现有不对地方,希望各位能指出。
二、模块的划分
通过对SPI的通信时序分析,可以将SPI模块分为两个部分,一个是SPI_Clock模块,负责产生SPI通信所需要的SCK,同时将SCK的两个边沿以脉冲形式输出,以供SPI_Master模块接收及发送数据使用,加一个是SPI_Master模块,负责接收的发送及接收,同时控制SPI_Clock模块SCK时钟信号的输出,其框图如图1所示(由于电脑没有装啥画图软件,就用平板手画了一下,各位将就看下吧,能看懂就行)。
其中模块输入输出信号分别为:
- CLK:系统时钟信号
- Rst:模块复位信号
- WrRdReq: 数据读写请求,上升沿有效
- WrData: 要发送的数据
- RdData: 读取到的数据
- DataValid: 读取数据有效信号,脉冲输出,宽度为一个CLK
- Busy: 模块忙信号
三、状态的划分
从我看的FPGA书或者其他人写的代码来看,一个FPGA程序很重要的部分就是状态机,基本上每一个FPGA程序中都会有状态机的身影,由此可以看出状态机对于一个FPGA程序的重要性,好的状态划分可以让程序写起来更轻松、更合理,因此在开始写SPI的代码之前我们需要先进行状态的划分,且不可粗暴的按照一位一个状态来分。
1、SPI_Clock模块
该模块仅仅将CLK时钟输入进行分频,无需状态机。
2、SPI_Master模块
对于SPI通信来讲,其实就两种状态,一是空闲状态,没有数据需要发送或者接收,二就是工作状态(SPI在发送数据的同时也在接收数据)。但是在数据接收完成后需要对外输出一个DataValid脉冲信号,因此我们将输出数据有效脉冲单独划分为一个状态,同时需要一个时钟周期将要发送的数据锁存下来,故一共有四个状态,分别为:
- IDLE: 空闲状态;
- START: 启动状态,进行数据的锁存;
- RUNNING: 运行状态,接收(发送)数据中;
- DELIVER: 数据转发状态,输出数据有效脉冲信号;
对应到时序图如图2所示:
3、状态跳转分析
在对状态进行划分之后,就需要清楚的知道各个状态间跳转的条件,对各个状态进行分析后可以知道,在模块没有动作时,即没有数据需要发送、接收时,模块处于IDLE状态;当模块处于IDLE状态时,如果检测到数据发送或接收请求,则跳转到START状态,将要发送的数据锁存下来;当模块处于START状态时,立即跳转到RUNNING状态,进行数据的发送及接收;当模块处于RUNNING状态时,如果检测到数据已经发送(接收)完成,则跳转到DELIVER状态;当模块处于DELIVER状态时,立即跳转到IDLE状态。由此,我们可以画出如图3所示状态跳转图:
那如何检测数据发送接收请求呢,我这里使用的是上升沿有效,即检测到WrRdReq有上升沿时,认为有数据需要发送;对于数据数据发送完成的检测,由图2以及SPI发送及接收的原理可知,对于8位SPI而言,当统计到16个SCK时钟边沿时,代表数据已经发送(接收完成),同时对于位宽为DATA_WIDTH为SPI,当统计到DATA_WIDTH*2个SCK时钟边沿时,代表数据已经发送完成,由此,我们需要对SCK的边沿进行计数,已判断数据的发送(接收)状态。
四、Verilog程序的编写
1、SPI_Clock.v
由于SPI_Clock.v的代码比较简单,就直接贴代码了,不进行分析了。
/**
*******************************************************************************************************
* File Name: SPI_Clock.v
* Author: NUC-何鑫
* Version: V1.0.0
* Date: 2019-8-28
* Brief: SPI时钟发生模块
*******************************************************************************************************
* History
* 1.Author: NUC-何鑫
* Date: 2019-8-28
* Mod: 发布第一版
*
*******************************************************************************************************
*/
module SPI_Clock#
(
parameter CLK_FREQ = 50,
parameter CPOL = 1'b0,
parameter SPI_CLK_FREQ = 1000
)
(
input Clk_I,
input RstP_I,
input En_I,
output SCK_O,
output SCKEdge1_O, /* 时钟的第一个跳变沿 */
output SCKEdge2_O /* 时钟的第二个跳变沿 */
);
/* SPI时序说明:1、当CPOL=1时,SCK在空闲时候为低电平,第一个跳变为上升沿
2、当CPOL=0时,SCK在空闲时为高电平,第一个跳变为下降沿
*/
/* 时钟分频计数器 */
localparam CLK_DIV_CNT = (CLK_FREQ * 1000)/SPI_CLK_FREQ;
reg SCK;
reg SCK_Pdg, SCK_Ndg;
reg[31:0] ClkDivCnt;
/* 时钟分频计数器控制块 */
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I)
ClkDivCnt <= 32'd0;
else if(!En_I)
ClkDivCnt <= 32'd0;
else begin
if(ClkDivCnt == CLK_DIV_CNT - 1)
ClkDivCnt <= 32'd0;
else
ClkDivCnt <= ClkDivCnt + 1'b1;
end
end
/* SCK控制块 */
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I)
SCK <= (CPOL) ? 1'b1 : 1'b0;
else if(!En_I)
SCK <= (CPOL) ? 1'b1 : 1'b0;
else begin
if(ClkDivCnt == CLK_DIV_CNT - 1 || (ClkDivCnt == (CLK_DIV_CNT >> 1) - 1))
SCK <= ~SCK;
else
SCK <= SCK;
end
end
/* SCK上升沿检测块 */
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I)
SCK_Pdg <= 1'b0;
else begin
if(CPOL)
SCK_Pdg <= (ClkDivCnt == CLK_DIV_CNT - 1) ? 1'b1 : 1'b0;
else
SCK_Pdg <= (ClkDivCnt == (CLK_DIV_CNT >> 1) - 1) ? 1'b1 : 1'b0;
end
end
/* SCK下降沿检测块 */
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I)
SCK_Ndg <= 1'b0;
else begin
if(CPOL)
SCK_Ndg <= (ClkDivCnt == (CLK_DIV_CNT >> 1) - 1) ? 1'b1 : 1'b0;
else
SCK_Ndg <= (ClkDivCnt == CLK_DIV_CNT - 1) ? 1'b1 : 1'b0;
end
end
/* 根据CPOL来选择边沿输出 */
assign SCKEdge1_O = (CPOL) ? SCK_Ndg : SCK_Pdg;
assign SCKEdge2_O = (CPOL) ? SCK_Pdg : SCK_Ndg;
assign SCK_O = SCK;
endmodule
2、SPI_Master.v
该模块的代码分为不同部分进行编写,会让代码写起来逻辑更清晰,不易混乱,同时更易懂。
1)状态机部分
状态机采用的是三段式状态机写法,至于具体什么是三段式状态机这里不做解释,想了解的朋友请自行百度,同时个人建议写状态时采用该写法,可将控制逻辑与状态机分离,使用代码逻辑性更强。
/* 主状态机 */
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I)
MainState <= IDLE;
else
MainState <= NxtMainState;
end
aaa@qq.com(*) begin
NxtMainState = IDLE;
case(MainState)
IDLE: NxtMainState = (WrRdReq_Pdg) ? START: IDLE;
START: NxtMainState = RUNNING;
RUNNING: NxtMainState = (RecvDoneFlag) ? DELIVER : RUNNING;
DELIVER: NxtMainState = IDLE;
default: NxtMainState = IDLE;
endcase
end
在这里我没有写出WrRdReq_Pdg以及RecvDoneFlag的具体实现,但是前面已经分析过这两个信号是怎么来的了,需要具体代码的看最后整体代码部分。
2)数据发送部分
数据发送部分比较简单,使用移位寄存器根据SPI的时钟进行数据移位就行了,同时需要注意的是高位或者低位在前,我这里使用的是高位在前,以及不同的SPI模式下输出数据的时刻不同,我这里都有进行处理。
/* 发送数据控制块 */
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I)
WrDataLatch <= 0;
else begin
case(MainState)
START: WrDataLatch <= Data_I; /* 先保存需要发送的数据 */
RUNNING: begin
/* 如果CPHA=1,则在时钟的第一个边沿输出,否则在第二个边沿输出 */
if(CPHA == 1'b1 && SCKEdge1)
WrDataLatch <= {WrDataLatch[DATA_WIDTH - 2:0], 1'b0};
else if(CPHA == 1'b0 && SCKEdge2)
WrDataLatch <= {WrDataLatch[DATA_WIDTH - 2:0], 1'b0};
else
WrDataLatch <= WrDataLatch;
end
default: WrDataLatch <= 0;
endcase
end
end
3)数据接收部分
数据接收部分与数据发送数据部分一样,使用移位寄存器即可,注意事项同上。
/* 接收数据控制块 */
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I)
RdDataLatch <= 0;
else begin
case(MainState)
START: RdDataLatch <= 0;
RUNNING: begin
/* 如果CPHA = 1,则在时钟的每二个边沿对数据进行采样,
否则在第一个边沿采样 */
if(CPHA == 1'b1 && SCKEdge2)
RdDataLatch <= {RdDataLatch[DATA_WIDTH - 2:0], MISO_I};
else if(CPHA == 1'b0 && SCKEdge1)
RdDataLatch <= {RdDataLatch[DATA_WIDTH - 2:0], MISO_I};
else
RdDataLatch <= RdDataLatch;
end
default: RdDataLatch <= RdDataLatch;
endcase
end
end
4)其余信号部分
/* 接收完成标志 */
assign RecvDoneFlag = (SCKEdgeCnt == DATA_WIDTH * 2);
/* 数据接收完成时输出一个时钟宽度的脉冲信号 */
assign DataValid_O = (MainState == DELIVER) ? 1'b1 : 1'b0;
/* 读取到的数据 */
assign Data_O = RdDataLatch;
/* 模块忙信号 */
assign Busy_O = (MainState == IDLE) ? 1'b0 : 1'b1;
/* 将要发送的数据发送到MOSI线上 */
assign MOSI_O = (MainState == RUNNING) ? WrDataLatch[DATA_WIDTH - 1] : 1'bz;
/* 片选 */
assign CS_O = (MainState == RUNNING) ? 1'b0 : 1'b1;
/* SPI时钟使能信号 */
assign SCKEnable = (MainState == RUNNING) ? 1'b1 : 1'b0;
五、仿真结果
在这里我分别仿真了8位,16位,32位SPI,结果分别如图4,图5,图6所示。
由仿真结果看,不同位宽的数据,都能正确的进行收发,同时经过我实际使用,代码也是没有问题的。 当然,我这里仅仅仿真了模式0,即CPOL,CPHA都为0,因为我实际中使用的也是这个模式, 但是代码中我对不同的模式都是有进行处理的,但是我没有进行仿真,如果有人发现在其余模式下该代码无法使用,希望可以留言指出,我会对其进行改正。
六、总结
在这篇博客里我简单的写了一下我所实现的SPI,并没有对SPI的原理进行具体的分析,如果有人想对SPI通信原理进行更基础的了解,需要去查找相关资料。同时我这种写法也不一定好,希望可以起到一个抛砖引玉的作用。
七、整体代码
/**
*******************************************************************************************************
* File Name: SPI_Master.v
* Author: NUC-何鑫
* Version: V1.0.0
* Date: 2019-8-28
* Brief: SPI主机模块代码
*******************************************************************************************************
* History
* 1.Author: NUC-何鑫
* Date: 2019-8-28
* Mod: 发布第一版
*
* 2.Author: NUC-何鑫
* Date: 2020-2-7
* Mod: 优化控制逻辑,添加SPI片选信号,增加数据宽度可配置功能
*
*******************************************************************************************************
*/
module SPI_Master#
(
parameter CLK_FREQ = 50, /* 模块时钟输入,单位为MHz */
parameter SPI_CLK = 1000, /* SPI时钟频率,单位为KHz */
parameter CPOL = 0, /* SPI时钟极性控制 */
parameter CPHA = 0, /* SPI时钟相位控制 */
parameter DATA_WIDTH = 8 /* 数据宽度 */
)
(
input Clk_I, /* 模块时钟输入,应和CLK_FREQ一样 */
input RstP_I, /* 异步复位信号,低电平有效 */
input WrRdReq_I, /* 读/写数据请求 */
input[DATA_WIDTH - 1:0] Data_I, /* 要写入的数据 */
output[DATA_WIDTH - 1:0] Data_O, /* 读取到的数据 */
output DataValid_O, /* 读取数据有效,上升沿有效 */
output Busy_O, /* 模块忙信号 */
output SCK_O, /* SPI模块时钟输出 */
output MOSI_O, /* MOSI_O */
input MISO_I, /* MISO_I */
output CS_O
);
localparam IDLE = 0; /* 模块空闲 */
localparam START = 1;
localparam RUNNING = 2; /* 模块运行中 */
localparam DELIVER = 3; /* 数据转发 */
reg[7:0] MainState, NxtMainState;
wire SCKEdge1, SCKEdge2;
wire SCKEnable;
wire RecvDoneFlag;
reg[7:0] SCKEdgeCnt;
reg[DATA_WIDTH - 1:0] WrDataLatch;
reg[DATA_WIDTH - 1:0] RdDataLatch;
/* 读写信号上升沿检测 */
wire WrRdReq_Pdg;
reg WrRdReq_D0, WrRdReq_D1;
/* 检测写请求的上升沿 */
assign WrRdReq_Pdg = (WrRdReq_D0) && (~WrRdReq_D1);
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I) begin
WrRdReq_D0 <= 1'b0;
WrRdReq_D1 <= 1'b0;
end else begin
WrRdReq_D0 <= WrRdReq_I;
WrRdReq_D1 <= WrRdReq_D0;
end
end
/* 主状态机 */
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I)
MainState <= IDLE;
else
MainState <= NxtMainState;
end
aaa@qq.com(*) begin
NxtMainState = IDLE;
case(MainState)
IDLE: NxtMainState = (WrRdReq_Pdg) ? START: IDLE;
START: NxtMainState = RUNNING;
RUNNING: NxtMainState = (RecvDoneFlag) ? DELIVER : RUNNING;
DELIVER: NxtMainState = IDLE;
default: NxtMainState = IDLE;
endcase
end
/* 发送数据控制块 */
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I)
WrDataLatch <= 0;
else begin
case(MainState)
START: WrDataLatch <= Data_I; /* 先保存需要发送的数据 */
RUNNING: begin
/* 如果CPHA=1,则在时钟的第一个边沿输出,否则在第二个边沿输出 */
if(CPHA == 1'b1 && SCKEdge1)
WrDataLatch <= {WrDataLatch[DATA_WIDTH - 2:0], 1'b0};
else if(CPHA == 1'b0 && SCKEdge2)
WrDataLatch <= {WrDataLatch[DATA_WIDTH - 2:0], 1'b0};
else
WrDataLatch <= WrDataLatch;
end
default: WrDataLatch <= 0;
endcase
end
end
/* 接收数据控制块 */
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I)
RdDataLatch <= 0;
else begin
case(MainState)
START: RdDataLatch <= 0;
RUNNING: begin
/* 如果CPHA = 1,则在时钟的每二个边沿对数据进行采样,
否则在第一个边沿采样 */
if(CPHA == 1'b1 && SCKEdge2)
RdDataLatch <= {RdDataLatch[DATA_WIDTH - 2:0], MISO_I};
else if(CPHA == 1'b0 && SCKEdge1)
RdDataLatch <= {RdDataLatch[DATA_WIDTH - 2:0], MISO_I};
else
RdDataLatch <= RdDataLatch;
end
default: RdDataLatch <= RdDataLatch;
endcase
end
end
/* 时钟边沿计数块 */
aaa@qq.com(posedge Clk_I or posedge RstP_I) begin
if(RstP_I)
SCKEdgeCnt <= 7'd0;
else begin
case(MainState)
RUNNING: begin
if(SCKEdge1 || SCKEdge2) /* 统计两个时钟边沿数量 */
SCKEdgeCnt <= SCKEdgeCnt + 1'b1;
else
SCKEdgeCnt <= SCKEdgeCnt;
end
default: SCKEdgeCnt <= 7'd0;
endcase
end
end
/* 接收完成标志 */
assign RecvDoneFlag = (SCKEdgeCnt == DATA_WIDTH * 2);
/* 数据接收完成时输出一个时钟宽度的脉冲信号 */
assign DataValid_O = (MainState == DELIVER) ? 1'b1 : 1'b0;
/* 读取到的数据 */
assign Data_O = RdDataLatch;
/* 模块忙信号 */
assign Busy_O = (MainState == IDLE) ? 1'b0 : 1'b1;
/* 将要发送的数据发送到MOSI线上 */
assign MOSI_O = (MainState == RUNNING) ? WrDataLatch[DATA_WIDTH - 1] : 1'bz;
/* 片选 */
assign CS_O = (MainState == RUNNING) ? 1'b0 : 1'b1;
/* SPI时钟使能信号 */
assign SCKEnable = (MainState == RUNNING) ? 1'b1 : 1'b0;
/* 实例化一个SPI时钟模块 */
SPI_Clock#
(
.CLK_FREQ(CLK_FREQ),
.CPOL(CPOL),
.SPI_CLK_FREQ(SPI_CLK)
)
SPI_Clock_Inst
(
.En_I(SCKEnable),
.Clk_I(Clk_I),
.SCKEdge1_O(SCKEdge1),
.SCKEdge2_O(SCKEdge2),
.RstP_I(RstP_I),
.SCK_O(SCK_O)
);
endmodule