给温控面板加中文界面那次,我被一个字库卡了整整两天。16 乘 16 的 GB2312 全字库 267KB,我手上那颗 F103 片上 Flash 一共 512KB,固件已经吃掉 180KB,再塞个开机 logo 图(240 乘 240 的 RGB565 就是 112KB),编译一按直接 region RAM overflowed 加 no space in FLASH。当时我干过一件蠢事:把字库裁到只留 800 个常用字,结果客户报警文案里有个"氟"字,屏上显示成一个方块。
后来外挂了一颗 W25Q64,8MB 空间,字库、图片、日志随便放。更舒服的是 QSPI 的内存映射模式,挂上以后取点阵就是一句 *(uint8_t*)(0x90000000 + offset),跟读片内 Flash 手感一模一样。这一篇讲怎么把这颗片子挂对、映射对、写对。

原理人话版
普通 SPI 只有一根 MOSI 一根 MISO,数据一位一位串着走。QSPI(Quad SPI)把数据线扩到四根 IO0 到 IO3,一个时钟周期能搬 4 位,同样时钟频率下吞吐量是单线的 4 倍。80MHz 时钟下四线理论带宽 40MB/s,单线只有 10MB/s。刷一张 112KB 的开机图,四线大约 3ms,单线要 11ms 以上,开机那一瞬间的观感差别很明显。
STM32 的 QSPI 外设有两种玩法,分工差得很远,挂片子之前得先分清:
- 间接模式(Indirect):你往控制寄存器里填命令、地址、数据长度,然后从数据寄存器一个个搬字节。像手动挡的普通 SPI,写数据、擦扇区、读 ID 这些带副作用的操作都得走这条路。
- 内存映射模式(Memory-Mapped) :告诉 QSPI 外设"以后 CPU 读 0x90000000 开头这块地址,你自动帮我发读命令去外部 Flash 取"。配好之后 CPU 拿指针直接解引用,
memcpy也能用,字库取点阵、LVGL 读图片资源都能当普通数组访问。★ 但这个模式只读,你想写想擦,必须先退出映射。
外部 Flash 的脾气和片上 Flash 一样犯人嫌:只能把 1 写成 0,改回 1 得整块擦;擦除粒度是 4KB 扇区(也支持 32KB、64KB 块);编程粒度是 256 字节页,一次最多写一页。

CubeMX 怎么配(以 W25Q64 8MB 为例)
外设选 QUADSPI,模式选 Bank 1 with Quad SPI Lines。几个参数一个都不能猜:
- Flash Size :填
log2(容量字节) - 1。W25Q64 是 8MB 等于 2 的 23 次方字节,这里填 22。填错的后果见坑 2。 - Clock Prescaler:先填大点,比如 QSPI 内核时钟 100MHz 就填 3(等于 25MHz)跑通,稳了再往上提。
- Chip Select High Time :至少
2 cycles,W25Q 系列要求 CS 拉高保持 50ns 以上。 - Sample Shifting :高速下如果读回来的数据整体错半个字节,把它切到
Sample shifting half cycle。 - Fifo Threshold:填 4 够用。
引脚看你的芯片封装,F4 常见组合是 PB2=CLK、PB6=NCS、PF8=IO0、PF9=IO1、PF7=IO2、PF6=IO3。全部设成 Very High 速度,AF 复用由 CubeMX 自动填。
时钟树里确认 QSPI 的内核时钟源,别让它跟着某个被你降频的分支走,不然实际速率和你算的对不上。
代码:读 ID、开四线、写数据、切映射
先把最基础的"发一条命令并等它做完"包起来。W25Q 的忙状态在状态寄存器 1 的 bit0。
c
#define W25Q_CMD_WREN 0x06 // 写使能, 每次擦/写前都要发
#define W25Q_CMD_RDSR1 0x05 // 读状态寄存器1, bit0=BUSY
#define W25Q_CMD_RDSR2 0x35 // 读状态寄存器2, bit1=QE
#define W25Q_CMD_WRSR2 0x31 // 写状态寄存器2
#define W25Q_CMD_ERASE_4K 0x20 // 扇区擦除, 4KB
#define W25Q_CMD_QPP 0x32 // 四线页编程, 一次最多256字节
#define W25Q_CMD_QREAD 0x6B // 四线快速读, 需要8个dummy cycle
#define W25Q_CMD_JEDEC_ID 0x9F
// 读状态寄存器, 拿来等 BUSY 清零
static uint8_t qspi_read_sr(uint8_t cmd) {
QSPI_CommandTypeDef s = {0};
uint8_t v = 0;
s.InstructionMode = QSPI_INSTRUCTION_1_LINE;
s.Instruction = cmd;
s.AddressMode = QSPI_ADDRESS_NONE;
s.DataMode = QSPI_DATA_1_LINE;
s.NbData = 1;
s.DummyCycles = 0;
HAL_QSPI_Command(&hqspi, &s, 100);
HAL_QSPI_Receive(&hqspi, &v, 100);
return v;
}
// 等到不忙为止, 擦一个4KB扇区大约45ms, 超时给足
static void qspi_wait_busy(uint32_t timeout_ms) {
uint32_t t0 = HAL_GetTick();
while (qspi_read_sr(W25Q_CMD_RDSR1) & 0x01) {
if (HAL_GetTick() - t0 > timeout_ms) return; // 超时兜底, 别死等
}
}
开 QE 位这一步最容易被跳过,也最容易让人怀疑焊接。W25Q 出厂 QE(Quad Enable)默认是 0,四根线里的 IO2 和 IO3 此时还兼着 /WP 和 /HOLD 的老本行,四线命令发下去它根本不按四线回数据:
c
void qspi_enable_quad(void) {
uint8_t sr2 = qspi_read_sr(W25Q_CMD_RDSR2);
if (sr2 & 0x02) return; // QE 已经是1, 不用重复写(状态寄存器也有寿命)
sr2 |= 0x02;
QSPI_CommandTypeDef s = {0};
s.InstructionMode = QSPI_INSTRUCTION_1_LINE;
s.Instruction = W25Q_CMD_WREN; // 写使能
s.AddressMode = QSPI_ADDRESS_NONE;
s.DataMode = QSPI_DATA_NONE;
HAL_QSPI_Command(&hqspi, &s, 100);
s.Instruction = W25Q_CMD_WRSR2;
s.DataMode = QSPI_DATA_1_LINE;
s.NbData = 1;
HAL_QSPI_Command(&hqspi, &s, 100);
HAL_QSPI_Transmit(&hqspi, &sr2, 100);
qspi_wait_busy(100);
}
写数据要守两条规矩:先擦后写,且一次别跨 256 字节页边界。
c
// 擦一个4KB扇区; addr 会被芯片自动对齐到扇区首
void qspi_erase_sector(uint32_t addr) {
QSPI_CommandTypeDef s = {0};
s.InstructionMode = QSPI_INSTRUCTION_1_LINE;
s.Instruction = W25Q_CMD_WREN;
s.AddressMode = QSPI_ADDRESS_NONE;
s.DataMode = QSPI_DATA_NONE;
HAL_QSPI_Command(&hqspi, &s, 100);
s.Instruction = W25Q_CMD_ERASE_4K;
s.AddressMode = QSPI_ADDRESS_1_LINE;
s.AddressSize = QSPI_ADDRESS_24_BITS; // 16MB以内用24位地址
s.Address = addr;
HAL_QSPI_Command(&hqspi, &s, 100);
qspi_wait_busy(1000); // 4KB扇区典型45ms, 最坏400ms
}
// 页内写, len 必须保证 addr%256 + len <= 256
void qspi_write_page(uint32_t addr, uint8_t *buf, uint16_t len) {
QSPI_CommandTypeDef s = {0};
s.InstructionMode = QSPI_INSTRUCTION_1_LINE;
s.Instruction = W25Q_CMD_WREN;
s.AddressMode = QSPI_ADDRESS_NONE;
s.DataMode = QSPI_DATA_NONE;
HAL_QSPI_Command(&hqspi, &s, 100);
s.Instruction = W25Q_CMD_QPP;
s.AddressMode = QSPI_ADDRESS_1_LINE;
s.AddressSize = QSPI_ADDRESS_24_BITS;
s.Address = addr;
s.DataMode = QSPI_DATA_4_LINES; // 数据走四线
s.NbData = len;
HAL_QSPI_Command(&hqspi, &s, 100);
HAL_QSPI_Transmit(&hqspi, buf, 1000);
qspi_wait_busy(100); // 页编程典型0.7ms
}
字库和图片都烧进去以后,切内存映射模式,之后就当普通只读数组用:
c
void qspi_enter_memory_mapped(void) {
QSPI_CommandTypeDef s = {0};
QSPI_MemoryMappedTypeDef cfg = {0};
s.InstructionMode = QSPI_INSTRUCTION_1_LINE;
s.Instruction = W25Q_CMD_QREAD; // 0x6B 四线输出快速读
s.AddressMode = QSPI_ADDRESS_1_LINE;
s.AddressSize = QSPI_ADDRESS_24_BITS;
s.DataMode = QSPI_DATA_4_LINES;
s.DummyCycles = 8; // ★ 0x6B 固定8个, 少一个数据全错位
s.SIOOMode = QSPI_SIOO_INST_EVERY_CMD;
cfg.TimeOutActivation = QSPI_TIMEOUT_COUNTER_DISABLE;
HAL_QSPI_MemoryMapped(&hqspi, &s, &cfg);
}
// 映射之后取 16x16 点阵: 一个字 32 字节
#define QSPI_BASE 0x90000000U
#define FONT16_OFFSET 0x00000000U // 字库烧在外部Flash起始处
const uint8_t *font16_get(uint32_t index) {
return (const uint8_t *)(QSPI_BASE + FONT16_OFFSET + index * 32);
}
要写要擦的时候,先 HAL_QSPI_Abort(&hqspi) 退出映射,做完再切回来。映射状态下发写命令,外设不理你,严重时总线卡死。

10 个真实翻车点
1. QE 位没置,四线读全是 0xFF。 头号杀手,也是最容易怀疑焊接的坑。W25Q 出厂 QE=0,IO2 和 IO3 还当 /WP 和 /HOLD 用,你发 0x6B 四线读,它只按单线回,数据一片 0xFF 或者全 0x00。上电初始化里读状态寄存器 2 确认 bit1,是 0 就写进去。我当年为这个把片子拆下来换了一颗,回头发现是软件的事。
2. Flash Size 填错,映射越界或只能用一半。 这个字段是 log2(字节数) - 1,8MB 填 22。填成 23 你会看到高地址读出来的东西回卷(地址镜像),填成 21 则后 4MB 直接访问不到。字库刚好落在越界区,屏上就是一片乱码点阵。
3. Dummy cycles 配错,数据整体错位。 ★ 0x6B 四线输出快速读固定要 8 个 dummy cycle,0xEB 四线 IO 读一般是 6 个(看具体型号手册)。你按 0 配,读回来的每个字节都往后错一截,点阵图案看着像被切了一刀。这个坑的表现很迷惑人,因为数据"看起来有内容",只是全错。
4. 页编程跨 256 字节边界,数据回卷覆盖。 芯片规矩:一条页编程命令只在当前 256 字节页内有效,你从 0x1F0 开始写 64 字节,超出页尾的那部分不会流进下一页,它会回卷到本页开头 0x100,把已经写好的数据盖掉。写大块数据必须自己切段,第一段长度取 256 - (addr % 256)。
5. 没擦就写。 和片上 Flash 一个道理,只能 1 写 0。你在写过的地方直接写新数据,得到的是新旧数据按位相与的结果。改一个字节也得把它所在的 4KB 扇区读出来、擦掉、改完整块写回。
6. 内存映射模式下写擦,卡死或者静默失败。 ★ 映射是只读通道,外设已经被配置成"自动响应总线读请求",这时候发 WREN、页编程,命令要么被吞要么把外设状态搞乱。规矩:写之前 HAL_QSPI_Abort() 退映射,写完重新 HAL_QSPI_MemoryMapped()。要是你的代码正跑在映射区(比如把字库解析函数放外部 Flash 执行),退映射的那一刻就取不到指令,直接死。
7. 没等 BUSY 就读,读到旧数据。 擦除和编程是异步的,命令发完芯片自己慢慢做。4KB 扇区擦除典型 45ms、最坏 400ms;页编程典型 0.7ms。你发完擦除立刻回头读,读到的是擦之前的内容,还以为擦除没生效。每个写操作后面都跟一次 qspi_wait_busy。
8. 整片擦除把看门狗喂饿了。 W25Q64 整片擦除要 20 到 40 秒。你调了 Chip Erase 又开着 2 秒喂狗周期的看门狗,擦到一半直接复位,Flash 处于半擦状态,重启后读出来一堆 0xFF 混着旧数据。要么改用逐扇区擦并在循环里喂狗,要么这段临时关狗。
9. IO2 和 IO3 悬空,单线阶段就锁死。 上电初始化那会儿你还在用单线命令(读 ID、开 QE),此时 IO3 兼 /HOLD、IO2 兼 /WP。这两脚悬空又被外界干扰拉低,/HOLD 一低整个通信直接冻结,你连 JEDEC ID 都读不出来。硬件上给这两脚各加个 10K 上拉最省心。
10. 字库偏移算错,取出来是别人的点阵。 16 乘 16 点阵一个字 32 字节,24 乘 24 是 72 字节(每行 3 字节乘 24 行)。GB2312 的索引公式是 ((区码 - 0xA1) * 94 + (位码 - 0xA1)) * 单字字节数。这里少减一个 0xA1,或者按 94 算成 96,整屏汉字集体偏移几个字,显示出来像乱码但又莫名有规律。烧字库前先在 PC 上按同一套公式抽查几个字。
完整例程骨架
上电先验片子在不在(读 JEDEC ID 应该是 0xEF4017),再开 QE,然后切映射给 GUI 用;需要写日志时短暂退映射。
c
int main(void) {
HAL_Init(); SystemClock_Config();
MX_GPIO_Init(); MX_QUADSPI_Init();
uint32_t id = qspi_read_jedec_id();
if (id != 0x00EF4017) { error_led(); } // W25Q64 认不出来, 先查硬件
qspi_enable_quad(); // 开QE, 四线才有效
qspi_enter_memory_mapped(); // 之后字库/图片当只读数组用
lcd_init();
show_logo((const uint16_t *)(0x90000000 + LOGO_OFFSET)); // 直接指针喂屏
while (1) {
gui_task();
if (need_save_log()) {
HAL_QSPI_Abort(&hqspi); // 退映射
qspi_erase_sector(LOG_ADDR);
qspi_write_page(LOG_ADDR, log_buf, 256);
qspi_enter_memory_mapped(); // 切回映射
}
}
}
分区建议写死在一个头文件里:字库区从 0 开始占 1MB,图片区 1MB 到 3MB,日志区留 3MB 往后。地址一旦定下来别随便挪,挪一次所有资源都得重烧。
适合:需要中文字库、开机动画、多语言资源、离线地图或音频片段的产品;也适合把运行日志、历史曲线这类高频写入从片上 Flash 挪出去(外部 Flash 便宜、寿命单独耗)。不适合:只存几十字节参数(片上 Flash 或 FRAM 更省事,不值得为它多焊一颗片子加六根线);也不适合要求随机字节改写的场景(擦除粒度 4KB,改一字节等于读改擦写一整块)。铁律四条:QE 位必须置、Flash Size 按 log2 减一算、dummy cycles 按命令手册配、写擦之前必须退出内存映射。这四条焊死,8MB 空间随你用。
外挂 Flash 真正好用的地方不是容量,是那句 *(uint8_t*)(0x90000000 + offset)。资源一旦能当数组读,写 GUI 的心态就完全不一样了。