打算做个用spi flash 存储的数据记录器,所以要先试一下这东西的最大吞吐量。用来做测试的硬件规格:
- 4MB Flash:W25Q32JVSIG
- 2MB Flash:W25Q16CVSIG
- 单片机:STM32F103RCT6
- 接口:标准SPI @18MHz
单片机主频72MHz,SPI 最高频率36MHz,不过后面主要是用18MHz 测试的,因为36MHz 没办法正常通信,不确定什么原因,而且频率足够高以后,瓶颈主要就在flash 芯片和软件处理上了。
全片擦除重写
先测试最简单的场景:全片擦除、全片重写,然后读出来校验。提前说结论:不考虑擦除时间的话,4MB 容量写满大概需要16 秒左右,对应256kB/s 的速度;算上擦除时间,擦除+写入,总共按21~25 秒计算,速度也有160kB/s ~ 195kB/s,感觉还可以,能赶上百度网盘。这是SPI 频率18MHz 的结果,SPI 频率继续拉高的话,读取速度能提高,但是写入和擦除的时间应该没什么优化空间。
1、纯Arduino 实现
刚开始是用纯Arduino 库做的,软件轮询硬件SPI 收发。Flash 芯片驱动库用的SparkFun SPI SerialFlash Arduino Library。写的很乱,但是纯测试代码就不讲究了。代码如下:
cpp
#include <Arduino.h>
#include <SPI.h>
#include <SparkFun_SPI_SerialFlash.h>
// 引脚定义放在头文件里,省略
#include "app.hpp"
constexpr size_t FLASH_SIZE = 4 * 1024 * 1024; // 4MB
constexpr size_t SECTOR_SIZE = 4 * 1024; // 4KB,擦除单位
constexpr size_t PAGE_SIZE = 256; // 256B,写入单位
constexpr size_t SPI_FREQ = 18'000'000;
SFE_SPI_FLASH flash;
void loop() {
Serial.println("BEEP");
delay(2000);
}
uint32_t program_data[PAGE_SIZE / 4];
void storage_test() {
Serial.println("<< Full chip program >>");
Serial.println("-> Erase");
auto start_time = millis();
flash.erase();
auto elapsed_time = millis() - start_time;
Serial.print("Erase done, time: ");
Serial.print(elapsed_time);
Serial.println("ms");
// ============================================
Serial.println("-> Write");
for (size_t i = 0; i < PAGE_SIZE / 4; i++) {
program_data[i] = 0x07080708;
}
start_time = millis();
uint32_t min_page_time = 0xFFFFFFFF;
uint32_t max_page_time = 0;
uint64_t total_page_time = 0;
uint64_t total_transfer_time = 0;
for (size_t page_idx = 0; page_idx < FLASH_SIZE; page_idx += PAGE_SIZE) {
auto page_start_time = micros();
auto r = flash.writeBlock(page_idx, (uint8_t*)program_data, PAGE_SIZE);
auto transfer_time = micros() - page_start_time;
total_transfer_time += transfer_time;
if (!flash.blockingBusyWait(50)) {
Serial.print("Busy forever. Write failed at page: ");
Serial.println(page_idx);
return;
}
elapsed_time = micros() - page_start_time;
if (r != SFE_FLASH_READ_WRITE_SUCCESS) {
Serial.print("Write failed at page: ");
Serial.println(page_idx);
return;
}
total_page_time += elapsed_time;
if (elapsed_time < min_page_time) {
min_page_time = elapsed_time;
}
if (elapsed_time > max_page_time) {
max_page_time = elapsed_time;
}
}
Serial.print("Min page time: ");
Serial.print(min_page_time);
Serial.println("us");
Serial.print("Max page time: ");
Serial.print(max_page_time);
Serial.println("us");
Serial.print("Average page time: ");
Serial.print(total_page_time / (FLASH_SIZE / PAGE_SIZE));
Serial.println("us");
Serial.print("Average transfer time: ");
Serial.print(total_transfer_time / (FLASH_SIZE / PAGE_SIZE));
Serial.println("us");
Serial.print("Write done, time: ");
Serial.print(millis() - start_time);
Serial.println("ms");
// ============================================
Serial.println("-> Verify (Fast read full chip)");
auto ss = SPISettings(SPI_FREQ, MSBFIRST, SPI_MODE0);
SPI.beginTransaction(ss);
digitalWrite(FLASH_CS_NUM, LOW);
SPI.transfer(0x0b);
SPI.transfer(0);
SPI.transfer(0);
SPI.transfer(0);
SPI.transfer(0x55); // dummy byte
uint8_t last_byte = 0x7;
start_time = millis();
for (size_t i = 0; i < FLASH_SIZE; i++) {
auto b = SPI.transfer(0x55);
if (b + last_byte != 0xf) {
Serial.print("Verify failed at byte: ");
Serial.println(i);
goto END_TRANSACTION;
}
last_byte = b;
}
uint32_t verify_time = millis() - start_time;
Serial.println("Verify done");
Serial.print("Verify time, ms: ");
Serial.print(verify_time);
Serial.println("ms");
END_TRANSACTION:
digitalWrite(FLASH_CS_NUM, HIGH);
SPI.endTransaction();
}
extern "C" uint32_t spi_getClkFreqInst(SPI_TypeDef *spi_inst);
void setup() {
SPI.setSCLK(SCK_NUM);
SPI.setMOSI(MOSI_NUM);
SPI.setMISO(MISO_NUM);
SPI.begin();
Serial.begin(230400);
Serial.println("<< Flash test >>");
Serial.print("SPI Max freq: ");
Serial.print(spi_getClkFreqInst(SPI1) / 2 / 1000000);
Serial.println(" MHz");
Serial.print("SPI Freq: ");
Serial.print(SPI_FREQ / 1000000);
Serial.println(" MHz");
// flash.enableDebugging();
if (!flash.begin(FLASH_CS_NUM, SPI_FREQ)) {
Serial.println("Failed to initialize flash");
while (1);
}
Serial.println("Flash ok");
auto mfgID = flash.getManufacturerID();
if (mfgID != SFE_FLASH_MFG_UNKNOWN) {
Serial.print(F("Manufacturer: "));
Serial.println(flash.manufacturerIDString(mfgID));
}
else {
uint8_t unknownID = flash.getRawManufacturerID(); // Read the raw manufacturer ID
Serial.print(F("Unknown manufacturer ID: 0x"));
if (unknownID < 0x10) Serial.print(F("0")); // Pad the zero
Serial.println(unknownID, HEX);
}
Serial.print(F("Device ID: 0x"));
Serial.println(flash.getDeviceID(), HEX);
auto start_time = millis();
Serial.println("Full chip erase, write, verify");
Serial.println("Confirm? (y/n)");
while (millis() - start_time < 30000) {
if (Serial.available() > 0) {
char c = Serial.read();
if (c == 'n' || c == 'N') {
Serial.println("Test skipped");
return;
}
if (c == 'y' || c == 'Y') {
storage_test();
return;
}
}
}
Serial.println("Timeout 30s, Skip test");
}
先连上串口,STM32F103 系列芯片默认的串口引脚是:PA2 - TX, PA3 - RX。程序运行后,先读取Flash 芯片的信息,识别芯片厂商,从串口输出。然后以防万一,要求输入y 确定接下来要擦除芯片。串口信息如下:
<< Flash test >>
SPI Max freq: 36 MHz
SPI Freq: 18 MHz
Flash ok
Manufacturer: Winbond
Device ID: 0x4016
Full chip erase, write, verify
Confirm? (y/n)
串口发送y 以后开始执行测试。步骤:
- 全片擦除
- 用重复的
0x8,0x7把flash 写满 - 把4M 字节全读出来,校验相邻两个字节之和是否等于
0xf - 统计各步骤耗时,计算读写速度
测试执行结果类似下面这样:
<< Full chip program >>
-> Erase
Erase done, time: 6472ms
-> Write
Min page time: 2999us
Max page time: 3149us
Average page time: 2999us
Write done, time: 49154ms
-> Verify (Fast read full chip)
Verify done
Verify time, ms: 37992ms
这是刚开始用2MHz 频率测到的结果,相当慢了。解释一下:
- 全片擦除一共用了6.47 秒
- 写入一页256 字节加上页编程时间是3 毫秒
- 全部4MB 写满用了49 秒
- 全部读出来花了38 秒
意思是不算擦除时间,每次连续写入256 字节后就要等3 毫秒,让flash 处理写入的数据。更多详细信息可以拿着数据问AI。之后就只用18MHz 频率测试。几次测试的结果如下表:
| 测试条件 | 擦除时间 (ms) | 写页时间最小值 (µs) | 写页时间最大值 (µs) | 写页时间平均值 (µs) | 写总时间 (ms) | 读取时间 (ms) | 备注 |
|---|---|---|---|---|---|---|---|
| 2MHz | 6472 | 2999 | 3149 | 2999 | 49154 | 37992 | |
| 18MHz | 6490 | 1925 | 1999 | 1998 | 32770 | 11837 | |
| 18MHz 2 | 4590 | 1925 | 1999 | 1998 | 32771 | 11837 | 另一片芯片,擦除时间明显缩短 |
| 18MHz 寄存器 | 6580 | 1925 | 1999 | 1998 | 32771 | 4549 | 用寄存器读取 |
| 18MHz DMA | 6600 | 902 | 999 | 998 | 16387 | 4199 | 用DMA 写入 |
| SPI 36MHz | --- | --- | --- | --- | --- | --- | 通信失败,无有效数据 |
| W25Q16 2MB | 2310 | 1933 | 1998 | 1997 | 16387 | 5978 | 2MB 版本总时间减半 |
其中,
- 18MHz 2 那行是用另一片W25Q32 测试的,两片是从立创买的一批,除了擦除时间不一样,别的操作耗时都差不多
- 寄存器 那行是跳过SPI 库,直接操作寄存器读取SPI 数据,可以看到时间少了不少,后面说这个
- DMA 那行改用DMA 往Flash 写数据,相比用SPI 库,写入时间缩短了一半,详细的看后面第3 节
- 也试了W25Q16,容量减半,总时间都减半了,但是写入一页的时间基本没区别,后面就只测W25Q32
2、直接操作寄存器读取
观察一下之前代码读取SPI 数据的部分,这个for 循环每次调用SPI.transfer 读取1 字节数据,总共要循环四百万次,所以调用库函数的开销就不能忽略了,循环体内的代码要尽量精简,才能排除代码逻辑运行时间的干扰,让总时间接近实际的传输时间。
cpp
for (size_t i = 0; i < FLASH_SIZE; i++) {
auto b = SPI.transfer(0x55); // 四百万次库函数调用
if (b + last_byte != 0xf) {
Serial.print("Verify failed at byte: ");
Serial.println(i);
return;
}
last_byte = b;
}
所以后面就把这块代码改成直接操作SPI 寄存器的形式,从上面的表里能看到,读取数据总耗时差不多缩短了三分之二。
cpp
for (size_t i = 0; i < FLASH_SIZE; i++) {
// auto b = SPI.transfer(0x55);
// 直接寄存器轮询传输,避免使用库函数的开销
while (!(SPI1->SR & SPI_SR_TXE));
SPI1->DR = 0xFF;
// 等待接收非空(RXNE=1)
while (!(SPI1->SR & SPI_SR_RXNE));
auto b = SPI1->DR;
if (b + last_byte != 0xf) {
Serial.print("Verify failed at byte: ");
Serial.println(i);
return;
}
last_byte = b;
}
根据目前的数据,可以看出:
- 18MHz 频率下,读取速度能跑到接近1MB/s
- 写一页256 字节总耗时大概2 毫秒,那理论上可以做到125kB/s 的写入速度
- 就算把全片擦除时间4~6 秒加到总写入时间里,算下来平均写入速度也有100kB/s 左右
- 不确定DMA 读取相比软件轮询能快多少,感觉读取速度优化空间不大了,但是写入还有明显的优化空间
3、用DMA 写入
之前代码里用来写入数据的flash.writeBlock 每次调用会写入256 字节,但是writeBlock 内部还是调用SPI.transfer 一字节一字节传输数据的,所以和读取一样,写入时也会重复调用库函数四百万次。在代码里加入计时,查看每次调用flash.writeBlock 传输256 字节的耗时,结果如下:
Min page time: 1935us
Max page time: 1999us
Average page time: 1998us
Average transfer time: 718us
Write done, time: 32771ms
可见,每次传输256 字节平均要花718 微秒,总共会占用十秒左右的时间,用DMA 优化估计会有比较大的提升。不过Flash 芯片在写入时有一些别的限制:
- 每次写入前必须单独发一条"写使能"指令
- 每次最多写一页256 字节
4MB 容量对应16384 个256 字节,就算用了DMA,也得循环写入这么多次,而且每次DMA 传输前都得单独发一字节使能指令,不能把这条指令和数据打包到一块发送。
要用DMA,需要:
- 配置DMA
- 配置SPI 为DMA 模式
- 处理DMA 中断
按理说这个地方可以不用中断的,但是HAL 库提供的DMA 传输函数HAL_SPI_Transmit_DMA 就设计成了中断驱动非阻塞的模式,那只好把中断加上了。下面是DMA 配置和DMA 的ISR:
cpp
void setup_spi_dma() {
// 使能 DMA1 时钟
__HAL_RCC_DMA1_CLK_ENABLE();
// 配置 DMA 发送通道(通道3,从内存到 SPI1->DR)
hdma_spi1_tx.Instance = DMA1_Channel3;
hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不变
hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_spi1_tx.Init.Mode = DMA_NORMAL; // 单次传输,不循环
hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma_spi1_tx);
NVIC_EnableIRQ(DMA1_Channel3_IRQn); // 使能DMA 中断
// 将 DMA 通道关联到 SPI1 的 TX
__HAL_LINKDMA(SPI.getHandle(), hdmatx, hdma_spi1_tx);
}
void enable_spi1_dma_tx() {
SET_BIT(SPI1->CR2, SPI_CR2_TXDMAEN);
}
void disable_spi1_dma_tx() {
CLEAR_BIT(SPI1->CR2, SPI_CR2_TXDMAEN);
}
// DMA 终端服务函数
extern "C" void DMA1_Channel3_IRQHandler(void) {
HAL_DMA_IRQHandler(&hdma_spi1_tx);
}
下面是DMA 写入函数,用这个替代flash.writeBlock:
cpp
bool spi1_dma_write_page(uint32_t addr) {
uint8_t *dma_tx_buf = (uint8_t *)program_data;
dma_tx_buf[0] = 0x02; // 页编程命令
dma_tx_buf[1] = (addr >> 16) & 0xFF; // 地址[23:16]
dma_tx_buf[2] = (addr >> 8) & 0xFF; // 地址[15:8]
dma_tx_buf[3] = addr & 0xFF; // 地址[7:0]
auto* hspi1 = SPI.getHandle();
// 先发送写入使能指令
while (__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_BSY));
digitalWrite(FLASH_CS_NUM, 0);
SPI.transfer(0x06);
digitalWrite(FLASH_CS_NUM, 1);
// 发送前确保 SPI 处于空闲状态,CS 已拉低
while (__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_BSY));
enable_spi1_dma_tx();
digitalWrite(FLASH_CS_NUM, 0);
// 启动 DMA 发送
auto ret = HAL_SPI_Transmit_DMA(hspi1, dma_tx_buf, PAGE_SIZE + 4);
if (ret != HAL_OK) {
Serial.println("SPI DMA transfer failed");
Serial.print("Code: ");
Serial.println(ret);
return false;
}
// 轮询等待 DMA 传输完成(或者用 TXC 标志)
uint32_t timeout = 1000; // 适当超时值
while (__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_BSY)) {
if (--timeout == 0) return false;
}
// 清 TXC 标志
CLEAR_BIT(SPI1->SR, SPI_SR_TXE | SPI_SR_OVR | SPI_SR_MODF);
disable_spi1_dma_tx();
digitalWrite(FLASH_CS_NUM, 1);
return true;
}
用dma_tx_buf[260] 把4 字节写入指令和256 字节数据打包到一起,让DMA 一次发送。
cpp
uint32_t program_data[PAGE_SIZE / 4 + 1];
void init_tx_data_buf() {
// 把后面256字节设置为0x8 和0x7 重复序列
// 前4 个字节为命令字节
for (size_t i = 1; i < sizeof(program_data) / sizeof(program_data[0]); i++) {
program_data[i] = 0x07080708;
}
}
还有一些其他零碎要改的地方,完整代码如下,结构有点乱,但是懒得改了。
cpp
#include <Arduino.h>
#include <SPI.h>
#include <SparkFun_SPI_SerialFlash.h>
// 引脚定义放这个头文件里,省略
#include "app.hpp"
constexpr size_t FLASH_SIZE = 4 * 1024 * 1024; // 4MB
constexpr size_t SECTOR_SIZE = 4 * 1024; // 4KB,擦除单位
constexpr size_t PAGE_SIZE = 256; // 256B,写入单位
constexpr size_t SPI_FREQ = 18'000'000;
SFE_SPI_FLASH flash;
void loop() {
Serial.println("BEEP");
delay(2000);
}
uint32_t program_data[PAGE_SIZE / 4 + 1];
void enable_spi1_dma_tx() {
SET_BIT(SPI1->CR2, SPI_CR2_TXDMAEN);
}
void disable_spi1_dma_tx() {
CLEAR_BIT(SPI1->CR2, SPI_CR2_TXDMAEN);
}
void init_tx_data_buf() {
// 把后面256字节设置为0x8 和0x7 重复序列
// 前4 个字节为命令字节
for (size_t i = 1; i < sizeof(program_data) / sizeof(program_data[0]); i++) {
program_data[i] = 0x07080708;
}
}
bool spi1_dma_write_page(uint32_t addr) {
uint8_t *dma_tx_buf = (uint8_t *)program_data;
dma_tx_buf[0] = 0x02; // 页编程命令
dma_tx_buf[1] = (addr >> 16) & 0xFF; // 地址[23:16]
dma_tx_buf[2] = (addr >> 8) & 0xFF; // 地址[15:8]
dma_tx_buf[3] = addr & 0xFF; // 地址[7:0]
auto* hspi1 = SPI.getHandle();
// 先发送写入使能指令
while (__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_BSY));
digitalWrite(FLASH_CS_NUM, 0);
SPI.transfer(0x06);
digitalWrite(FLASH_CS_NUM, 1);
// 发送前确保 SPI 处于空闲状态,CS 已拉低
while (__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_BSY));
enable_spi1_dma_tx();
digitalWrite(FLASH_CS_NUM, 0);
// 启动 DMA 发送
auto ret = HAL_SPI_Transmit_DMA(hspi1, dma_tx_buf, PAGE_SIZE + 4);
if (ret != HAL_OK) {
Serial.println("SPI DMA transfer failed");
Serial.print("Code: ");
Serial.println(ret);
return false;
}
// 轮询等待 DMA 传输完成(或者用 TXC 标志)
uint32_t timeout = 1000; // 适当超时值
while (__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_BSY)) {
if (--timeout == 0) return false;
}
// 清 TXC 标志
CLEAR_BIT(SPI1->SR, SPI_SR_TXE | SPI_SR_OVR | SPI_SR_MODF);
disable_spi1_dma_tx();
digitalWrite(FLASH_CS_NUM, 1);
return true;
}
void storage_test() {
Serial.println("<< Full chip program >>");
Serial.println("-> Erase");
auto start_time = millis();
flash.erase();
auto elapsed_time = millis() - start_time;
Serial.print("Erase done, time: ");
Serial.print(elapsed_time);
Serial.println("ms");
// ============================================
Serial.println("-> Write");
init_tx_data_buf();
start_time = millis();
uint32_t min_page_time = 0xFFFFFFFF;
uint32_t max_page_time = 0;
uint64_t total_page_time = 0;
uint64_t total_transfer_time = 0;
auto ss = SPISettings(SPI_FREQ, MSBFIRST, SPI_MODE0);
SPI.beginTransaction(ss);
for (size_t page_idx = 0; page_idx < FLASH_SIZE; page_idx += PAGE_SIZE) {
auto page_start_time = micros();
auto r = spi1_dma_write_page(page_idx);
auto transfer_time = micros() - page_start_time;
total_transfer_time += transfer_time;
if (!flash.blockingBusyWait(50)) {
Serial.print("Busy forever. Write failed at page: ");
Serial.println(page_idx);
return;
}
elapsed_time = micros() - page_start_time;
if (!r) {
Serial.print("Write failed at page: ");
Serial.println(page_idx);
return;
}
total_page_time += elapsed_time;
if (elapsed_time < min_page_time) {
min_page_time = elapsed_time;
}
if (elapsed_time > max_page_time) {
max_page_time = elapsed_time;
}
}
SPI.endTransaction();
Serial.print("Min page time: ");
Serial.print(min_page_time);
Serial.println("us");
Serial.print("Max page time: ");
Serial.print(max_page_time);
Serial.println("us");
Serial.print("Average page time: ");
Serial.print(total_page_time / (FLASH_SIZE / PAGE_SIZE));
Serial.println("us");
Serial.print("Average transfer time: ");
Serial.print(total_transfer_time / (FLASH_SIZE / PAGE_SIZE));
Serial.println("us");
Serial.print("Write done, time: ");
Serial.print(millis() - start_time);
Serial.println("ms");
// ============================================
Serial.println("-> Verify (Fast read full chip)");
SPI.beginTransaction(ss);
digitalWrite(FLASH_CS_NUM, LOW);
SPI.transfer(0x0b);
SPI.transfer(0);
SPI.transfer(0);
SPI.transfer(0);
SPI.transfer(0x55); // dummy byte
uint8_t last_byte = 0x7;
uint32_t verify_time;
start_time = millis();
for (size_t i = 0; i < FLASH_SIZE; i++) {
// 直接寄存器轮询传输,避免使用库函数的开销
while (!(SPI1->SR & SPI_SR_TXE));
SPI1->DR = 0xFF;
// 等待接收非空(RXNE=1)
while (!(SPI1->SR & SPI_SR_RXNE));
auto b = SPI1->DR;
if (b + last_byte != 0xf) {
Serial.print("Verify failed at byte: ");
Serial.println(i);
Serial.print("Last byte: ");
Serial.println(last_byte);
Serial.print("Current byte: ");
Serial.println(b);
goto END_TRANSACTION;
}
last_byte = b;
}
verify_time = millis() - start_time;
Serial.println("Verify done");
Serial.print("Verify time, ms: ");
Serial.print(verify_time);
Serial.println("ms");
END_TRANSACTION:
digitalWrite(FLASH_CS_NUM, HIGH);
SPI.endTransaction();
}
extern "C" uint32_t spi_getClkFreqInst(SPI_TypeDef* spi_inst);
DMA_HandleTypeDef hdma_spi1_tx;
void setup_spi_dma() {
// 使能 DMA1 时钟
__HAL_RCC_DMA1_CLK_ENABLE();
// 配置 DMA 发送通道(通道3,从内存到 SPI1->DR)
hdma_spi1_tx.Instance = DMA1_Channel3;
hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不变
hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_spi1_tx.Init.Mode = DMA_NORMAL; // 单次传输,不循环
hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma_spi1_tx);
NVIC_EnableIRQ(DMA1_Channel3_IRQn);
// 将 DMA 通道关联到 SPI1 的 TX
__HAL_LINKDMA(SPI.getHandle(), hdmatx, hdma_spi1_tx);
}
void setup() {
SPI.setSCLK(SCK_NUM);
SPI.setMOSI(MOSI_NUM);
SPI.setMISO(MISO_NUM);
SPI.begin();
setup_spi_dma();
Serial.begin(230400);
Serial.println("<< Flash test >>");
Serial.print("SPI Max freq: ");
Serial.print(spi_getClkFreqInst(SPI1) / 2 / 1000000);
Serial.println(" MHz");
Serial.print("SPI Freq: ");
Serial.print(SPI_FREQ / 1000000);
Serial.println(" MHz");
// flash.enableDebugging();
if (!flash.begin(FLASH_CS_NUM, SPI_FREQ)) {
Serial.println("Failed to initialize flash");
while (1);
}
Serial.println("Flash ok");
auto mfgID = flash.getManufacturerID();
if (mfgID != SFE_FLASH_MFG_UNKNOWN) {
Serial.print(F("Manufacturer: "));
Serial.println(flash.manufacturerIDString(mfgID));
}
else {
uint8_t unknownID = flash.getRawManufacturerID(); // Read the raw manufacturer ID
Serial.print(F("Unknown manufacturer ID: 0x"));
if (unknownID < 0x10) Serial.print(F("0")); // Pad the zero
Serial.println(unknownID, HEX);
}
Serial.print(F("Device ID: 0x"));
Serial.println(flash.getDeviceID(), HEX);
auto start_time = millis();
Serial.println("Full chip erase, write, verify");
Serial.println("Confirm? (y/n)");
while (millis() - start_time < 30000) {
if (Serial.available() > 0) {
char c = Serial.read();
if (c == 'n' || c == 'N') {
Serial.println("Test skipped");
return;
}
if (c == 'y' || c == 'Y') {
storage_test();
return;
}
}
}
Serial.println("Timeout 30s, Skip test");
}
extern "C" {
/**
* @brief This function handles DMA Tx interrupt request.
* @param None
* @retval None
*/
void DMA1_Channel3_IRQHandler(void) {
HAL_DMA_IRQHandler(&hdma_spi1_tx);
}
}
改完以后再测试,总写入时间差不多减半,从32 秒减少到了16 秒,相应的写入速度也从125kB/s 提升到了256kB/s。串口输出结果如下:
-> Write
Min page time: 902us
Max page time: 999us
Average page time: 998us
Average transfer time: 135us
Write done, time: 16387ms
每页总写入时间1 毫秒,减去传输时间,flash 内部页编程时间大约0.8毫秒。对照W25Q32 的数据手册,上面说页编程时间为0.4~3 毫秒,所以0.8 毫秒还算可以了。
滚动擦除写入
实际对于数据记录场景,都是滚动擦除+写入,不会全片擦除。原理大概是:
- 需要写入256 字节时,判断要写入的地方事先擦除过没有
- 如果没擦除,就执行扇区擦除命令,一次最少擦除4kB,最多64kB
- 擦除之后,在已擦除扇区里持续写入,直到把扇区写满,然后再向后擦除一个扇区
要考虑的问题:
- 擦除速度比写入速度慢的多,4kB 擦除典型值45 毫秒,32kB、64kB 擦除典型值分别是120、150 毫秒
- 擦除开始后不能接着往里写,必须等它擦完
1、理论写入速度
如果选择4kB 擦除,那么擦除过程中接收到的数据全部要先存在单片机RAM 里,等擦除完了再存进去。按照之前的测量结果,4kB 对应16 个页,总写入时间约16 毫秒,所以4kB 数据量的擦除+写入周期总时间至少61 毫秒,那么写入速度就只有65kB/s。这个速度远低于之前测得全片擦除+写入速度,因为扇区擦除速度比全片擦除慢的多,4MB 容量对应1024 个4kB 扇区,所有扇区都擦一遍,至少需要46 秒。下面是理论上擦除不同扇区大小对应的周期写入速度:
| 扇区 | 擦除时间(典型) | 写时间 | 总周期(典型) | 写入速度(典型) | 擦除时间(最差) | 总周期(最差) | 写入速度(最差) |
|---|---|---|---|---|---|---|---|
| 4 kB | 45 ms | 16 ms | 61 ms | 65 kB/s | 400 ms | 416 ms | 9 kB/s |
| 32 kB | 120 ms | 128 ms | 248 ms | 129 kB/s | 1600 ms | 1728 ms | 18 kB/s |
| 64 kB | 150 ms | 256 ms | 406 ms | 157 kB/s | 2000 ms | 2256 ms | 28 kB/s |
2、实测速度
修改之前的代码,去掉全片擦除,改为按扇区擦除和写入。先要添加一个执行扇区擦除的函数:
cpp
bool flash_erase_sector(uint32_t sector_addr) {
static_assert(SECTOR_SIZE == 4 * 1024 || SECTOR_SIZE == 32 * 1024 || SECTOR_SIZE == 64 * 1024, "Sector size must be 4KB, 32KB, or 64KB");
uint8_t erase_cmd = 0x20;
if (SECTOR_SIZE == 32 * 1024) {
erase_cmd = 0x52;
}
else if (SECTOR_SIZE == 64 * 1024) {
erase_cmd = 0xd8;
}
// 必须事先调用beginTransaction
// SPI.beginTransaction(ss);
// 先发送写入使能指令
digitalWrite(FLASH_CS_NUM, LOW);
SPI.transfer(0x06);
digitalWrite(FLASH_CS_NUM, HIGH);
// 发送擦除指令
digitalWrite(FLASH_CS_NUM, LOW);
SPI.transfer(erase_cmd);
SPI.transfer(sector_addr >> 16);
SPI.transfer(sector_addr >> 8);
SPI.transfer(sector_addr);
digitalWrite(FLASH_CS_NUM, HIGH);
// SPI.endTransaction();
return true;
}
不同尺寸的擦除指令是不同的,用常量SECTOR_SIZE 设置一次要擦除的量,然后匹配对应的擦除指令。SECTOR_SIZE 可选4k、32k、64k,设置这之外的值会编译失败。
还要改写入部分,写个嵌套for 循环,完整代码放后面,先来看测试结果。运行程序后,串口输出信息如下,这是选择4kB 擦除时的数据:
<< Full chip program (by sector) >>
Sector size: 4KB
-> Write
Min page time: 998us
Max page time: 999us
Average page time: 998us
Average transfer time: 135us
Min sector erase time: 29ms
Max sector erase time: 39ms
Average sector erase time: 31ms
Average sector write time: 16ms
Write done, time: 48377ms
-> Verify (Fast read full chip)
Verify done
Verify time, ms: 4199ms
4kB 扇区最小擦除时间29 毫秒,最大39 毫秒,平均31 毫秒,比数据手册的典型值好多了。下面是擦除扇区4k,32k,64k 的测试情况,一共试了两片flash 芯片。
| 扇区大小 | 平均写入时间 | 平均擦除时间 | 最小擦除时间 | 最大擦除时间 | 平均周期 | 平均写入 | 最差周期 | 最差写入 |
|---|---|---|---|---|---|---|---|---|
| 4 kB | 16 ms | 31 ms | 29 ms | 39 ms | 47 ms | 85 kB/s | 55 ms | 72 kB/s |
| 4 kB (2) | 16 ms | 25 ms | 23 ms | 37 ms | 41 ms | 97 kB/s | 53 ms | 75 kB/s |
| 32 kB | 128 ms | 98 ms | 95 ms | 106 ms | 226 ms | 141 kB/s | 234 ms | 136 kB/s |
| 32 kB (2) | 128 ms | 101 ms | 95 ms | 133 ms | 229 ms | 139 kB/s | 261 ms | 122 kB/s |
| 64 kB | 256 ms | 113 ms | 110 ms | 122 ms | 369 ms | 173 kB/s | 378 ms | 169 kB/s |
| 64 kB (2) | 256 ms | 138 ms | 131 ms | 178 ms | 394 ms | 162 kB/s | 434 ms | 147 kB/s |
其中,平均周期是平均擦除时间加上写入时间,最差周期是最大擦除时间加上写入时间。
最大擦除时间基本上是在典型值附近,4kB 擦除时间显著优于典型值,算下来写入速度都比上面的理论值要好一点。另一方面,也可以看到擦除时间波动是比较大的,尤其是第2 片flash,擦除时间波动显著更大,它的32kB 和64kB 擦除时间最大、最小差值分别达到了32ms 和40ms,相比平均值,最大波动30%。
可以预见,不同芯片之间参数差异会比较大,同一片芯片在长时间使用中,擦除时间也可能会显著变化。所以实际应用中,应该实时监控擦除和写入时间,作为健康度指标。如果性能不再满足需求,要及时以某种方式发出提示。批量用的时候可能也应该提前筛出参数离群的芯片。
3、完整测试代码(除了app.hpp 头文件)
按扇区擦写测试的完整代码:
cpp
#include <Arduino.h>
#include <SPI.h>
#include <SparkFun_SPI_SerialFlash.h>
#include "app.hpp"
// W25Q32 4MB flash
constexpr size_t FLASH_SIZE = 4 * 1024 * 1024; // 4MB
constexpr size_t SECTOR_SIZE = 64 * 1024; // 64KB,擦除单位
constexpr size_t PAGE_SIZE = 256; // 256B,写入单位
constexpr size_t SPI_FREQ = 18'000'000;
SFE_SPI_FLASH flash;
void loop() {
Serial.println("BEEP");
delay(2000);
}
uint32_t program_data[PAGE_SIZE / 4 + 1];
void enable_spi1_dma_tx() {
SET_BIT(SPI1->CR2, SPI_CR2_TXDMAEN);
}
void disable_spi1_dma_tx() {
CLEAR_BIT(SPI1->CR2, SPI_CR2_TXDMAEN);
}
void init_tx_data_buf() {
// 把后面256字节设置为0x8 和0x7 重复序列
// 前4 个字节为命令字节
for (size_t i = 1; i < sizeof(program_data) / sizeof(program_data[0]); i++) {
program_data[i] = 0x07080708;
}
}
bool spi1_dma_write_page(uint32_t addr) {
uint8_t* dma_tx_buf = (uint8_t*)program_data;
dma_tx_buf[0] = 0x02; // 页编程命令
dma_tx_buf[1] = (addr >> 16) & 0xFF; // 地址[23:16]
dma_tx_buf[2] = (addr >> 8) & 0xFF; // 地址[15:8]
dma_tx_buf[3] = addr & 0xFF; // 地址[7:0]
auto* hspi1 = SPI.getHandle();
// 先发送写入使能指令
// while (__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_BSY));
digitalWrite(FLASH_CS_NUM, 0);
SPI.transfer(0x06);
digitalWrite(FLASH_CS_NUM, 1);
// 发送前确保 SPI 处于空闲状态,CS 已拉低
while (__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_BSY));
enable_spi1_dma_tx();
digitalWrite(FLASH_CS_NUM, 0);
// 启动 DMA 发送
auto ret = HAL_SPI_Transmit_DMA(hspi1, dma_tx_buf, PAGE_SIZE + 4);
if (ret != HAL_OK) {
Serial.println("SPI DMA transfer failed");
Serial.print("Code: ");
Serial.println(ret);
return false;
}
// 轮询等待 DMA 传输完成(或者用 TXC 标志)
uint32_t timeout = 1000; // 适当超时值
while (__HAL_SPI_GET_FLAG(hspi1, SPI_FLAG_BSY)) {
if (--timeout == 0) return false;
}
// 清 TXC 标志
CLEAR_BIT(SPI1->SR, SPI_SR_TXE | SPI_SR_OVR | SPI_SR_MODF);
disable_spi1_dma_tx();
digitalWrite(FLASH_CS_NUM, 1);
return true;
}
bool flash_erase_sector(uint32_t sector_addr) {
static_assert(SECTOR_SIZE == 4 * 1024 || SECTOR_SIZE == 32 * 1024 || SECTOR_SIZE == 64 * 1024, "Sector size must be 4KB, 32KB, or 64KB");
uint8_t erase_cmd = 0x20;
if (SECTOR_SIZE == 32 * 1024) {
erase_cmd = 0x52;
}
else if (SECTOR_SIZE == 64 * 1024) {
erase_cmd = 0xd8;
}
// 必须事先调用beginTransaction
// auto ss = SPISettings(SPI_FREQ, MSBFIRST, SPI_MODE0);
// SPI.beginTransaction(ss);
// 先发送写入使能指令
digitalWrite(FLASH_CS_NUM, LOW);
SPI.transfer(0x06);
digitalWrite(FLASH_CS_NUM, HIGH);
// 发送擦除指令
digitalWrite(FLASH_CS_NUM, LOW);
SPI.transfer(erase_cmd);
SPI.transfer(sector_addr >> 16);
SPI.transfer(sector_addr >> 8);
SPI.transfer(sector_addr);
digitalWrite(FLASH_CS_NUM, HIGH);
// SPI.endTransaction();
return true;
}
void storage_test() {
Serial.println("<< Full chip program (by sector) >>");
Serial.print("Sector size: ");
Serial.print(SECTOR_SIZE / 1024);
Serial.println("KB");
// ============================================
Serial.println("-> Write");
init_tx_data_buf();
auto ss = SPISettings(SPI_FREQ, MSBFIRST, SPI_MODE0);
SPI.beginTransaction(ss);
auto start_time = millis();
uint32_t elapsed_time = 0;
uint32_t min_page_time = 0xFFFFFFFF;
uint32_t max_page_time = 0;
uint64_t total_page_time = 0;
uint64_t total_transfer_time = 0;
uint32_t min_sector_erase_time = 0xFFFFFFFF;
uint32_t max_sector_erase_time = 0;
uint64_t total_sector_erase_time = 0;
uint64_t total_sector_write_time = 0;
for (uint32_t sector_addr = 0; sector_addr < FLASH_SIZE; sector_addr += SECTOR_SIZE) {
auto sector_start_time = millis();
flash_erase_sector(sector_addr);
if (!flash.blockingBusyWait(500)) {
Serial.print("Busy forever. Write failed at sector: ");
Serial.println(sector_addr);
return;
}
auto sector_erase_time = millis() - sector_start_time;
total_sector_erase_time += sector_erase_time;
if (sector_erase_time < min_sector_erase_time) {
min_sector_erase_time = sector_erase_time;
}
if (sector_erase_time > max_sector_erase_time) {
max_sector_erase_time = sector_erase_time;
}
auto sector_write_start_time = millis();
for (size_t page_addr = sector_addr; page_addr < sector_addr + SECTOR_SIZE; page_addr += PAGE_SIZE) {
auto page_start_time = micros();
auto r = spi1_dma_write_page(page_addr);
auto transfer_time = micros() - page_start_time;
total_transfer_time += transfer_time;
if (!flash.blockingBusyWait(50)) {
Serial.print("Busy forever. Write failed at page: ");
Serial.println(page_addr);
return;
}
elapsed_time = micros() - page_start_time;
if (!r) {
Serial.print("Write failed at page: ");
Serial.println(page_addr);
return;
}
total_page_time += elapsed_time;
if (elapsed_time < min_page_time) {
min_page_time = elapsed_time;
}
if (elapsed_time > max_page_time) {
max_page_time = elapsed_time;
}
}
auto sector_write_time = millis() - sector_write_start_time;
total_sector_write_time += sector_write_time;
}
SPI.endTransaction();
auto write_time = millis() - start_time;
Serial.print("Min page time: ");
Serial.print(min_page_time);
Serial.println("us");
Serial.print("Max page time: ");
Serial.print(max_page_time);
Serial.println("us");
Serial.print("Average page time: ");
Serial.print(total_page_time / (FLASH_SIZE / PAGE_SIZE));
Serial.println("us");
Serial.print("Average transfer time: ");
Serial.print(total_transfer_time / (FLASH_SIZE / PAGE_SIZE));
Serial.println("us");
// 扇区擦除时间
Serial.print("Min sector erase time: ");
Serial.print(min_sector_erase_time);
Serial.println("ms");
Serial.print("Max sector erase time: ");
Serial.print(max_sector_erase_time);
Serial.println("ms");
Serial.print("Average sector erase time: ");
Serial.print(total_sector_erase_time / (FLASH_SIZE / SECTOR_SIZE));
Serial.println("ms");
// 扇区写入时间
Serial.print("Average sector write time: ");
Serial.print(total_sector_write_time / (FLASH_SIZE / SECTOR_SIZE));
Serial.println("ms");
Serial.print("Write done, time: ");
Serial.print(write_time);
Serial.println("ms");
// ============================================
Serial.println("-> Verify (Fast read full chip)");
SPI.beginTransaction(ss);
digitalWrite(FLASH_CS_NUM, LOW);
SPI.transfer(0x0b);
SPI.transfer(0);
SPI.transfer(0);
SPI.transfer(0);
SPI.transfer(0x55); // dummy byte
uint8_t last_byte = 0x7;
uint32_t verify_time;
start_time = millis();
for (size_t i = 0; i < FLASH_SIZE; i++) {
// 直接寄存器轮询传输,避免使用库函数的开销
while (!(SPI1->SR & SPI_SR_TXE));
SPI1->DR = 0xFF;
// 等待接收非空(RXNE=1)
while (!(SPI1->SR & SPI_SR_RXNE));
auto b = SPI1->DR;
if (b + last_byte != 0xf) {
Serial.print("Verify failed at byte: ");
Serial.println(i);
Serial.print("Last byte: ");
Serial.println(last_byte);
Serial.print("Current byte: ");
Serial.println(b);
goto END_TRANSACTION;
}
last_byte = b;
}
verify_time = millis() - start_time;
Serial.println("Verify done");
Serial.print("Verify time, ms: ");
Serial.print(verify_time);
Serial.println("ms");
END_TRANSACTION:
digitalWrite(FLASH_CS_NUM, HIGH);
SPI.endTransaction();
}
extern "C" uint32_t spi_getClkFreqInst(SPI_TypeDef* spi_inst);
DMA_HandleTypeDef hdma_spi1_tx;
void setup_spi_dma() {
// 使能 DMA1 时钟
__HAL_RCC_DMA1_CLK_ENABLE();
// 配置 DMA 发送通道(通道3,从内存到 SPI1->DR)
hdma_spi1_tx.Instance = DMA1_Channel3;
hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE; // 外设地址不变
hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE; // 内存地址递增
hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_spi1_tx.Init.Mode = DMA_NORMAL; // 单次传输,不循环
hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma_spi1_tx);
NVIC_EnableIRQ(DMA1_Channel3_IRQn);
// 将 DMA 通道关联到 SPI1 的 TX
__HAL_LINKDMA(SPI.getHandle(), hdmatx, hdma_spi1_tx);
}
void setup() {
SPI.setSCLK(SCK_NUM);
SPI.setMOSI(MOSI_NUM);
SPI.setMISO(MISO_NUM);
SPI.begin();
setup_spi_dma();
Serial.begin(230400);
Serial.println("<< Flash test >>");
Serial.print("SPI Max freq: ");
Serial.print(spi_getClkFreqInst(SPI1) / 2 / 1000000);
Serial.println(" MHz");
Serial.print("SPI Freq: ");
Serial.print(SPI_FREQ / 1000000);
Serial.println(" MHz");
// flash.enableDebugging();
if (!flash.begin(FLASH_CS_NUM, SPI_FREQ)) {
Serial.println("Failed to initialize flash");
while (1);
}
Serial.println("Flash ok");
auto mfgID = flash.getManufacturerID();
if (mfgID != SFE_FLASH_MFG_UNKNOWN) {
Serial.print(F("Manufacturer: "));
Serial.println(flash.manufacturerIDString(mfgID));
}
else {
uint8_t unknownID = flash.getRawManufacturerID(); // Read the raw manufacturer ID
Serial.print(F("Unknown manufacturer ID: 0x"));
if (unknownID < 0x10) Serial.print(F("0")); // Pad the zero
Serial.println(unknownID, HEX);
}
Serial.print(F("Device ID: 0x"));
Serial.println(flash.getDeviceID(), HEX);
auto start_time = millis();
Serial.println("Full chip erase, write, verify");
Serial.println("Confirm? (y/n)");
while (millis() - start_time < 30000) {
if (Serial.available() > 0) {
char c = Serial.read();
if (c == 'n' || c == 'N') {
Serial.println("Test skipped");
return;
}
if (c == 'y' || c == 'Y') {
storage_test();
return;
}
}
}
Serial.println("Timeout 30s, Skip test");
}
extern "C" {
/**
* @brief This function handles DMA Tx interrupt request.
* @param None
* @retval None
*/
void DMA1_Channel3_IRQHandler(void) {
HAL_DMA_IRQHandler(&hdma_spi1_tx);
}
}
总结
由以上对比可知:如果选择4kB 擦除,每次需要等待的时间相对短一点,单片机RAM 里也可以完整放下4kB 缓冲区;如果选64kB 擦除,总体平均写入速度更快,但是单次擦除时间更长。在循环记录数据时,擦除单位越大,数据损失也越大。
我觉得32kB 擦除相对更平衡一点。考虑记录串口数据的场景,如果波特率是230400,格式8N1,一帧包含:1bit 起始位、1bit 结束位、8bit 数据,所以数据速度是22.5 kB/s。按照上面实测的最差情况,在一个擦除+写入周期261 毫秒内,只有约5.9kB 数据需要缓冲在RAM 里。把周期放宽到400 毫秒,数据量增加到9kB,单片机RAM 里准备10kB 空间作为环形缓冲就可以搞定了。此外,如果搭配实时数据压缩的话,所需的缓冲空间还可能降低。
以上是用全扇区写入时间作为保守的估计值来计算的。实测的扇区擦除时间最大是178 毫秒,按照230400 波特率,这期间能到达的数据只有4kB,16 毫秒就能写完。
64kB 擦除时,或许更能耐受短时间高速通信,因为只要当前扇区没满,就能以最大速度写入。选64kB 也可能降低擦除过程中掉电的风险,因为擦除频率较低。然后如果擦除刚好撞上了掉电,那用4kB 扇区或许可以让不确定性最小化。
顺便,考虑最大用10kB 缓冲区的情况,DS 计算的最大持续接收能力是这样的:
有 10KB 缓冲区时,瓶颈从"总周期吞吐"变为 "擦除期间缓冲区装不装得下"。
约束条件:擦除期间到达的数据必须全部塞进缓冲区,即
速率 × 擦除时间 ≤ 10KB。按实测最差情况(第 2 片芯片的最大擦除时间):
扇区 擦除时间 缓冲约束 吞吐约束 实际最大速率 对应波特率(8N1) 4KB 39ms 256 KB/s 73 KB/s ~73 KB/s ~584k 32KB 133ms 77 KB/s 123 KB/s ~77 KB/s ~616k 64KB 178ms 56 KB/s 147 KB/s ~56 KB/s ~448k 结论反转了:
- 64KB 扇区反而最慢------擦除那 178ms 只能收约 10KB,缓冲一满数据就得丢,跟后续能写多快没关系。
- 4KB 和 32KB 基本持平,差距在工程误差范围内(73 vs 77 KB/s)。
- 三者都轻松覆盖 230400 波特(约22.5KB/s),距离瓶颈还有 2.5 到3.4 倍裕量。
所以 10KB 缓冲前提下,4KB 扇区综合最优:速度没吃亏,掉电损失面最小,擦除时间抖动也最低。原文对 32KB 的推荐在这条件下仍然成立,但 64KB 可以排除。