当我们理解了SPI的通讯协议后, 本文使用Verilog 语言来编写一个简单的SPI通讯模块,用这个模块展示一个完整的SPI通讯方式。

图1
SPI模块(Verilog 代码 :默认 CPOL = 0 CPHA = 0)
-
设计思路
设计思路由简单的SPI收发工程到完善的SPI工程。接下来,由下面的三个步骤进行说明。
- 设计一个SPI发送移位寄存器
- 设计完整的SPI接收,发送的工程
- 在完整的SPI收发工程上,包装了一个上层使用接口
-
SPI发送移位寄存器代码实现
实现简单的MOSI发送。
工程代码:
module test_spi(
input clk,
input ce, //片选
output spi_clk, //时钟
output reg mosi = 1,
input miso,
input reset
);
reg [7:0] data = 8'hd3; //通过MOSI发送的数据,可以修改
always @ (posedge clk)
if(~ce) //当片选为低时有效
data <= {data[6:0], 1'b1}; //由高位开始发送,之后每过一个时钟周期,数据移位
always @ (posedge clk)
if(~ce)
mosi <= data[7]; //与时钟同步发送数据
assign spi_clk = ~clk;
仿真文件:
`timescale 1ns / 1ps
module test_spi_sim(
);
reg reset = 0;
reg clk = 0;
always #5 clk = ~clk;
reg ce = 1;
initial
begin
ce = 1;
#200;
@(posedge clk);
ce = 0;
@(posedge clk);
@(posedge clk);
@(posedge clk);
@(posedge clk);
@(posedge clk);
@(posedge clk);
@(posedge clk);
@(posedge clk);
ce = 1;
#400;
$stop;
end
wire spi_clk;
wire mosi;
wire miso = mosi;
test_spi test_spi_inst
(
.clk (clk),
.ce (ce),
.spi_clk(spi_clk),
.mosi (mosi),
.miso (miso),
.reset (reset)
);
endmodule
仿真波形图如图2所示:

图2 仿真波形图
通过图2可以看出,数据8’hd3被正确发送。在片选ce为低的期间,在spi_clk的下降沿,mosi更换数据;同时,我们也发现, spi_clk 一直没有停止, miso 也没有接收的逻辑。
-
完整SPI收发器代码实现
在完整SPI收发器工程中,改进了上一个工程的问题。主要改进了在片选cs为高的时候,时钟不能翻转,以及增加了miso的接收逻辑。
工程代码:
module test_spi(
input clk,
input ce,
output spi_clk,
output reg mosi = 1,
input miso,
input reset
);
reg [7:0] tx_data = 8'h6a; //通过MOSI发送的数据,可以修改
always @ (posedge clk)
if(~ce)
tx_data <= {tx_data[6:0], 1'b1};
always @ (posedge clk)
if(~ce)
mosi <= tx_data[7];
assign spi_clk = ce ? 1'b0 : ~clk; //通过片选是否有效来控制spi_clk
reg [7:0] rx_data = 8'hff; //miso使用spi_clk采样数据
always @ (posedge spi_clk)
rx_data <= {rx_data[6:0], miso};
endmodule
仿真结果,如图3所示:

图3 仿真波形图
根据图3可以看出,SPI基本功能已经实现(默认CPOL = 0,CPHA = 0),接下来将在此基础上增加上层模块的接口。
-
有上层使用接口的SPI收发代码实现
工程代码:
`timescale 1ns / 1ps
module simple_spi
(
input phy_clk,
input [7:0] spi_din, // SPI需要发送的数据
input spi_data_vld, // SPI数据 有效, 一个时钟宽度(phy_clk)
output reg [7:0] spi_dout = 8'hff, // SPI接收到的数据
output reg spi_data_rdy = 0, // SPI接收数据有效 , 一个时钟宽度(phy_clk)
output reg spi_cs = 1, // SPI片选 pin
output reg spi_clk = 0, // SPI时钟 pin
output reg spi_mosi = 1, // SPI主设备输出数据(从设备接收数据) pin
input spi_miso, // SPI主设备接收数据(从设备输出数据) pin
input rst_n
);
reg [7:0] tx_data = 8'hff;
reg [3:0] tx_cnt = 0;
reg [1:0] tx_st = 0;
always @ (posedge phy_clk or negedge rst_n)
if(!rst_n)
begin
spi_mosi <= 1;
spi_cs <= 1;
spi_clk <= 0;
tx_st <= 0;
end
else case (tx_st)
0:
begin
tx_cnt <= 0;
spi_mosi <= 1;
spi_cs <= 1;
spi_clk <= 0;
if(spi_data_vld)
begin
tx_data <= spi_din;
tx_st <= 1;
end
else tx_data <= 8'hff;
end
1:
begin
spi_cs <= 0;
tx_st <= 2;
end
2:
begin
spi_clk <= 0;
spi_mosi <= tx_data[7];
tx_data <= {tx_data[6:0], 1'b1};
if(tx_cnt == 8)
tx_st <= 0;
else
tx_st <= 3;
end
3:
begin
spi_clk <= 1;
tx_cnt <= tx_cnt + 1;
tx_st <= 2;
end
default : tx_st <= 0;
endcase
//=======================================================================================
reg spi_clk_r = 0;
always @ (posedge phy_clk )
spi_clk_r <= spi_clk;
wire rx_pos_clk = ({spi_clk_r, spi_clk} == 2'b01) ? 1'b1 : 1'b0;
reg [2:0] rx_cnt = 0;
always @ (posedge phy_clk )
if(spi_cs)
begin
spi_data_rdy <= 0;
rx_cnt <= 0;
end
else if(rx_pos_clk)
begin
spi_dout <= {spi_dout[6:0], spi_miso};
rx_cnt <= rx_cnt + 1;
if(rx_cnt == 7) spi_data_rdy <= 1;
else spi_data_rdy <= 0;
end
else spi_data_rdy <= 0;
//=======================================================================================
endmodule
详细分析上述工程代码如下:
接口信号
input phy_clk, // SPI模块 操作时钟
input [7:0] spi_din, // SPI需要发送的数据
input spi_data_vld, // SPI数据 有效, 一个时钟宽度(phy_clk)
系统上层代码 可以使用这两个信号, 将需要发送的数据传送到 当前这个模块, 用于数据发送。
output reg [7:0] spi_dout = 8’hff, // SPI接收到的数据
output reg spi_data_rdy = 0, // SPI接收数据有效 , 一个时钟宽度(phy_clk)
系统上层代码 可以使用这两个信号, 将当前模块所接收到的SPI数据发送给上层, 供上层模块使用。
output reg spi_cs = 1, // SPI片选 pin
output reg spi_clk = 0, // SPI时钟 pin
output reg spi_mosi = 1, // SPI主设备输出数据(从设备接收数据) pin
input spi_miso, // SPI主设备接收数据(从设备输出数据) pin
这 4 个信号为SPI的接口信号, 用于连接其他SPI的设备(pin )
input rst_n // 模块 reset 信号
发送状态机
复位状态: 状态机复位, spi_mosi = 1 , 保持高电平; spi_cs = 1,一般情况下SPI的从设备都是cs = 0 有效,但用户需要注意, 也有一些从设备是cs = 1 有效的。 如果遇到这样的芯片,注意修改当前缺省值。 spi_clk = 0, 对应使用的是 CPOL = 0 (标准协议), 如果用户使用 CPOL = 1 的标准, 需要修改这个值。
状态0:等待上层模块发送命令(那些数据需要SPI模块发送出去), 同时保持 cs = 1, spi_clk = 0 ; 如果得到上层的命令,将数据所存为tx_data
状态1:spi_cs = 0 , 片选使能
状态2:产生 spi_clk = 0, 在spi_clk = 0 (低电平期间) 将tx_data 的最高位 赋值给 spi_mosi , 同时 将tx_data 做 逻辑左移,最后一位补一; 如果计数器 (tx_cnt == 8) ,表示当前的数据已经发送完毕了,跳转状态机 到 0; 如果计数器 没有加到 8, 跳转到状态机 3
状态3: 产生 spi_clk = 1 ,同时 计数器加一 , 表示tx_data 已经发送 了 1 bit , 跳转状态机到2;
接收代码
reg spi_clk_r = 0; always @ (posedge phy_clk ) spi_clk_r <= spi_clk; wire rx_pos_clk = ({spi_clk_r, spi_clk} == 2'b01) ? 1'b1 : 1'b0;
得到 spi_clk 的上升沿, 因为当前代码为(CPOL = 0, CPHA = 0) 表示时钟上升沿接收数据。
reg [2:0] rx_cnt = 0; always @ (posedge phy_clk ) if(spi_cs) begin spi_data_rdy <= 0; rx_cnt <= 0; end else if(rx_pos_clk) begin spi_dout <= {spi_dout[6:0], spi_miso}; rx_cnt <= rx_cnt + 1; if(rx_cnt == 7) spi_data_rdy <= 1; else spi_data_rdy <= 0; end else spi_data_rdy <= 0;
在片选无效时, 接收计数器(rx_cnt) 清零。 每当得到一个spi_clk 的上升沿(rx_pos_clk) , spi_miso (其他设备发送, 当前模块接收)信号被存储到spi_dout 中,同时计数器(rx_cnt) 加一。当接收8-bit后, spi_data_rdy = 1 , 通知上层模块 , SPI模块得到一个byte的数据了;其他情况 spi_data_rdy = 0
仿真工程代码:
`timescale 1ns / 1ps
module simple_spi_sim(
);
reg clk = 0;
always clk = #10 ~clk;
reg rst_n = 0;
reg [7:0] spi_din = 8'hff;
reg spi_data_vld = 0;
initial
begin
spi_data_vld = 0;
rst_n = 0;
#200;
rst_n = 1;
#400;
@(posedge clk);
spi_din = 8'h5d;
spi_data_vld = 1;
@(posedge clk);
spi_data_vld = 0;
#400;
@(posedge clk);
spi_din = 8'h3c;
spi_data_vld = 1;
@(posedge clk);
spi_data_vld = 0;
#400;
@(posedge clk);
spi_din = 8'h47;
spi_data_vld = 1;
@(posedge clk);
spi_data_vld = 0;
#200000;
$stop;
end
wire [7:0] spi_dout;
wire spi_data_rdy;
wire spi_cs;
wire spi_clk;
wire spi_data;
simple_spi simple_spi_inst
(
.phy_clk (clk),
.spi_din (spi_din), // SPI需要发送的数据
.spi_data_vld (spi_data_vld), // SPI数据 有效, 一个时钟宽度(phy_clk)
.spi_dout (spi_dout), // SPI接收到的数据
.spi_data_rdy (spi_data_rdy), // SPI接收数据有效 , 一个时钟宽度(phy_clk)
.spi_cs (spi_cs), // SPI片选 pin
.spi_clk (spi_clk), // SPI时钟 pin
.spi_mosi (spi_data), // SPI 主设备输出数据(从设备接收数据) pin
.spi_miso (spi_data), // SPI主设备接收数据(从设备输出数据) pin
.rst_n (rst_n)
);
//==================================================================
endmodule
仿真波形如图4所示:
图4 仿真波形
spi_din, spi_data_vld 为仿真上层模块数据信号, 当前仿真模块 仿真上层模块 发送数据 为 8’h5d, 8’h3c, 8’h47。(蓝色信号)
spi_dout, spi_data_rdy 为上层模块接收到的信号(从其他设备发送过来, 当前仿真程序 是将 spi_mosi 和 spi_miso 信号连接, loopback 回环测试) (浅粉色)
spi_cs, spi_clk, spi_data 代表SPI模块 pin , 其中spi_data 是将spi_mosi 和 spi_miso 连接。回环测试。
图中我们可以看出:
首先是cs 信号 为 0, 然后 发送spi_clk 时钟信号, 同时配合spi_clk 发送spi_data 信号,这里我们可以看出, spi_data 是在spi_clk 下降沿变化数据的。当 8bit 发送完成, cs = 1 。 表示当前操作结束。
接收模块在cs = 1 时 ,不做任何动作; 在cs = 0 时, 每当spi_clk时钟上升沿时, 采样一个bit 数据。 当 8-bit 数据接收到后, 产生spi_data_rdy 信号。
总结
由上面3个SPI实验工程,从简单到复杂的实现过程中,编写一个实现基本功能的SPI模块 还是比较简单使用的, 可以用来处理一些SPI的设备。 可以帮助大家迅速理解SPI的协议。
不足之处是spi_clk 时钟 最大只能是当前时钟(phy_clk)的 二分之一, 甚至更低, 对于高速SPI设备 不能发挥出SPI的效率。 同时 ,不是很容易添加复杂的spi 操作 向SPI FLASH 操作等等。之后的文章会针对不足进行改进。
其实当大家理解了SPI协议后, 会发现SPI协议要比 I2C, UART协议还要简单 。 SPI协议 其实就是将数据移位输出(发送),移位输入(接收),同时配合上cs, clk 信号即可。
SPI系列文章链接:
SPI 通讯协议 及 SPI 相关工程 详解

WILLIAM 老师您好,移位寄存器代码,后面几个工程的代码我看您都是写的data <= { data [ 6 : 0 ], 1'b1 } ; 第一个 的data <= { data [7 : 0 ] , 1'b1 } ; 是不是打错了呀?
谢谢你的回复,已改正!
老师,simple_spi里spi_dout同步到系统时钟那里还是不太理解,周六可以详细讲一下么
这里的spi_dout 只是在phy_clk 时钟域下,利用提取的rx_pos_clk (spi 上升沿信号),将miso的数据保存到spi_dout中,这时,spi_dout 的数据和phy_clk 是在一个时钟域下的。