4. SPI Flash控制器设计:SPI协议基础、FPGA端SPI Master实现、多页读写与擦除操作
好,咱们进入第四章。这一章我打算聊聊SPI Flash控制器。为什么把它单独拎出来讲?因为固件升级的核心,说白了就是往Flash里写数据、读数据、擦数据。你想想看,如果连Flash都控制不好,那升级方案就是空中楼阁。
我在做第一个交易系统项目时,就吃过这个亏。当时图省事,直接用CPU的GPIO模拟SPI时序去读写Flash,结果速度上不去不说,还经常因为中断被打断导致时序出错。后来我痛定思痛,老老实实用FPGA实现了专用的SPI Master控制器。嗯,从那以后,Flash操作再也没出过问题。
核心要点:FPGA实现SPI Flash控制器,关键是把时序控制做到硬件化、确定性。CPU只负责下发命令和检查状态,具体的字节搬运、页边界处理、擦除等待,全部交给FPGA的状态机去干。
4.1 SPI协议基础——先别急着写代码
SPI协议其实很简单。四根线:SCK、MOSI、MISO、CS_N。主设备(咱们的FPGA)控制时钟,从设备(Flash芯片)在时钟边沿采样或输出数据。
但这里有个坑——SPI有四种模式,由时钟极性(CPOL)和时钟相位(CPHA)决定。我见过不少新手在这上面栽跟头。Flash芯片通常工作在Mode 0或Mode 3,具体要看数据手册。
| 模式 | CPOL | CPHA | 空闲时钟 | 数据采样边沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低电平 | 上升沿 |
| Mode 1 | 0 | 1 | 低电平 | 下降沿 |
| Mode 2 | 1 | 0 | 高电平 | 上升沿 |
| Mode 3 | 1 | 1 | 高电平 | 下降沿 |
我个人习惯用Mode 0。为什么?因为大部分Flash芯片默认支持Mode 0,而且上升沿采样的时序约束更容易满足。当然,如果你用的Flash芯片要求Mode 3,那就得改。记住,一切以数据手册为准。
4.2 FPGA端SPI Master实现——状态机是王道
FPGA实现SPI Master,最直接的方式就是状态机。我一般把它分成三个状态:IDLE、TRANSFER、DONE。
- IDLE:等待启动信号,CS_N拉高,SCK保持空闲电平。
- TRANSFER:按位发送和接收数据。每来一个时钟边沿,移出一位数据到MOSI,同时从MISO采样一位数据。
- DONE:传输完成,拉高CS_N(如果是单次传输),或者回到IDLE等待下一次操作。
这里有个细节:CS_N的控制。Flash芯片的很多操作(比如读、写、擦除)需要CS_N在整个命令序列期间保持低电平。你不能每传一个字节就把CS_N拉高一次,那样Flash会认为命令结束了。
我的经验:把CS_N的控制交给上层状态机,而不是SPI Master本身。SPI Master只负责字节级别的收发,CS_N由调用者(比如读操作状态机)来管理。这样设计更灵活,也更容易复用。
下面是一个简化的SPI Master Verilog代码框架。注意,这只是核心逻辑,实际项目中还需要考虑跨时钟域同步、FIFO接口等。
module spi_master #(
parameter CLK_DIV = 4 // 系统时钟分频系数,决定SCK频率
)(
input wire clk,
input wire rst_n,
// 用户接口
input wire start,
input wire [7:0] tx_data,
output reg [7:0] rx_data,
output reg done,
// SPI物理接口
output reg sck,
output reg mosi,
input wire miso,
output reg cs_n // 注意:这里cs_n由外部控制
);
// 状态机
localparam IDLE = 2'b00;
localparam TRANSFER = 2'b01;
localparam DONE = 2'b10;
reg [1:0] state, next_state;
reg [2:0] bit_cnt; // 位计数器,0~7
reg [7:0] shift_reg; // 移位寄存器
// 时钟分频
reg [3:0] clk_cnt;
wire sck_toggle = (clk_cnt == CLK_DIV/2 - 1);
// 状态转移
always @(posedge clk or negedge rst_n) begin
if (!rst_n) state <= IDLE;
else state <= next_state;
end
// 次态逻辑
always @(*) begin
next_state = state;
case (state)
IDLE: if (start) next_state = TRANSFER;
TRANSFER: if (bit_cnt == 7 && sck_toggle) next_state = DONE;
DONE: next_state = IDLE;
endcase
end
// 数据路径
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
bit_cnt <= 0;
shift_reg <= 0;
rx_data <= 0;
done <= 0;
sck <= 0; // Mode 0: 空闲低电平
mosi <= 0;
clk_cnt <= 0;
end else begin
case (state)
IDLE: begin
bit_cnt <= 0;
done <= 0;
sck <= 0;
if (start) shift_reg <= tx_data;
end
TRANSFER: begin
// 时钟分频
clk_cnt <= (clk_cnt == CLK_DIV-1) ? 0 : clk_cnt + 1;
if (sck_toggle) begin
sck <= ~sck; // 翻转SCK
if (sck) begin // SCK下降沿,移出下一位
mosi <= shift_reg[7];
shift_reg <= {shift_reg[6:0], miso};
bit_cnt <= bit_cnt + 1;
end
end
end
DONE: begin
done <= 1;
rx_data <= shift_reg;
end
endcase
end
end
endmodule
这段代码我故意写得比较简洁。实际项目中,你还需要处理连续传输的场景——比如读一个256字节的页,需要连续发送256个字节的时钟。这时候,状态机需要支持「连续模式」,即传输完一个字节后不拉高CS_N,而是继续下一个字节。
4.3 多页读写与擦除操作——这才是重头戏
Flash芯片的操作,说白了就是发命令、传地址、传数据。常见的命令有:
- 0x03:读数据(Read Data)
- 0x02:页编程(Page Program),即写数据
- 0x20:扇区擦除(Sector Erase),擦除4KB
- 0xD8:块擦除(Block Erase),擦除64KB
- 0xC7:整片擦除(Chip Erase)
- 0x05:读状态寄存器(Read Status Register)
- 0x06:写使能(Write Enable)
这里我要强调一个关键点:写操作之前必须先写使能。每次写操作(页编程、擦除)之前,都要发送0x06命令,让Flash进入写使能状态。否则,Flash会无视你的写命令。我曾经在调试时花了一整天,才发现是忘了发写使能命令。嗯,这种低级错误,犯过一次就再也不会忘了。
注意:写使能命令执行后,Flash的状态寄存器中的WEL位会置1。你可以通过读状态寄存器(0x05)来确认。但更常见的做法是直接发送写命令,因为Flash内部会处理这个时序。不过,如果你需要严格保证可靠性,建议在每次写操作前都检查一下WEL位。
4.4 多页读写——跨越页边界的艺术
Flash芯片的页大小通常是256字节。写操作(页编程)一次最多写一页,而且不能跨页。什么意思?就是说,如果你要写的数据起始地址是0x00FF,然后写10个字节,那么第2个字节会写到0x0100——这已经属于下一页了。Flash芯片不会自动帮你翻页,它会从0x0100开始继续写,但前提是CS_N没有拉高。
等等,这里有点绕。我解释清楚:
Flash的页编程命令(0x02)后面跟着3字节地址,然后是一串数据。只要你CS_N保持低电平,Flash会连续接收数据,并依次写入从起始地址开始的连续地址。但是,当地址到达页边界时,它会回绕到该页的起始地址,而不是自动进入下一页。
举个例子:起始地址0x00FF,你发送了4个字节的数据。Flash会这样写:
- 字节0 -> 地址0x00FF
- 字节1 -> 地址0x0000(回绕到页起始)
- 字节2 -> 地址0x0001
- 字节3 -> 地址0x0002
看到了吗?字节1覆盖了页首的数据。这就是所谓的页回绕。所以,如果你要写的数据跨越了页边界,必须拆分成多次页编程操作。
我设计的控制器里,有一个专门的模块来处理页边界。它会自动计算当前地址所在的页,以及该页剩余的空间,然后决定本次写多少字节。核心逻辑如下:
// 伪代码:多页写操作
function multi_page_write(start_addr, data, length):
addr = start_addr
remaining = length
while remaining > 0:
// 计算当前页剩余空间
page_offset = addr & 0xFF // 页内偏移
page_remain = 256 - page_offset // 本页剩余字节数
bytes_this_page = min(remaining, page_remain)
// 发送页编程命令
send_command(0x06) // 写使能
send_command(0x02) // 页编程
send_address(addr) // 3字节地址
send_data(data, bytes_this_page) // 发送数据
wait_for_busy() // 等待编程完成
// 更新指针
addr = addr + bytes_this_page
data = data + bytes_this_page
remaining = remaining - bytes_this_page
读操作就没有这个限制。你可以一次读任意长度的数据,Flash会自动跨页。但要注意,读操作的速度受限于SPI时钟频率。如果你的系统时钟是100MHz,SPI时钟分频到25MHz,那么理论读速度是25Mbps,约3.125MB/s。对于固件升级来说,这个速度通常够用了。
4.5 擦除操作——慢,但必须做
Flash芯片的特性是:写之前必须先擦除。因为Flash的写操作只能把1变成0,不能把0变成1。擦除操作会把整个扇区或块全部置为1(即0xFF)。
擦除操作很慢。扇区擦除(4KB)通常需要几十到几百毫秒,块擦除(64KB)需要几百毫秒到几秒。整片擦除就更慢了,可能好几秒。
所以,在固件升级方案中,我一般会这样设计:
- 先擦除目标扇区(或块)
- 然后逐页写入新固件
- 最后读取校验
擦除期间,FPGA需要等待Flash完成操作。怎么等?轮询状态寄存器。发送0x05命令,读取状态寄存器的BUSY位(bit 0)。如果为1,说明Flash还在忙;如果为0,说明操作完成。
我的建议:不要用纯轮询的方式等待擦除完成,那样会浪费FPGA的处理能力。更好的做法是:启动擦除后,FPGA去做其他事情(比如处理网络数据),然后定时检查状态寄存器。或者,用Flash的「状态寄存器读」命令配合中断——不过大多数Flash芯片没有中断引脚,所以轮询还是最通用的方法。
4.6 实战中的避坑指南
最后,分享几个我在项目中踩过的坑:
- CS_N的毛刺:FPGA上电时,IO引脚可能是高阻态或不确定状态。如果CS_N在初始化期间出现低电平毛刺,Flash可能会误判为命令开始。解决办法:在FPGA配置完成后,先拉高CS_N,再初始化SPI Master。
- 写使能丢失:每次页编程或擦除操作后,Flash会自动清除写使能位。所以,如果你连续执行多个页编程,每个页编程之前都要重新发送写使能命令。不要偷懒。
- 地址对齐:有些Flash芯片要求写操作的起始地址必须对齐到页边界(即地址的低8位为0)。虽然大多数芯片没有这个要求,但为了兼容性,我建议在固件升级方案中,总是从页边界开始写。
- 时钟频率过高:SPI时钟频率不是越高越好。Flash芯片有最大时钟频率限制,而且PCB走线长度、信号完整性都会影响实际可用的频率。我一般保守一点,取数据手册标称频率的70%。
好了,这一章的内容就到这里。SPI Flash控制器是固件升级方案的基石,把它做扎实了,后面的工作就顺风顺水。下一章,我们会聊聊如何把固件数据从网络接口搬运到Flash——也就是数据通路的设计。
公众号:蓝海资料掘金营,微信deep3321