给温控面板加中文界面那次我被一个字库卡了整整两天。16 乘 16 的 GB2312 全字库 267KB我手上那颗 F103 片上 Flash 一共 512KB固件已经吃掉 180KB再塞个开机 logo 图240 乘 240 的 RGB565 就是 112KB编译一按直接region RAM overflowed加no space in FLASH。当时我干过一件蠢事把字库裁到只留 800 个常用字结果客户报警文案里有个氟字屏上显示成一个方块。后来外挂了一颗 W25Q648MB 空间字库、图片、日志随便放。更舒服的是 QSPI 的内存映射模式挂上以后取点阵就是一句*(uint8_t*)(0x90000000 offset)跟读片内 Flash 手感一模一样。这一篇讲怎么把这颗片子挂对、映射对、写对。原理人话版普通 SPI 只有一根 MOSI 一根 MISO数据一位一位串着走。QSPIQuad 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 cyclesW25Q 系列要求 CS 拉高保持 50ns 以上。Sample Shifting高速下如果读回来的数据整体错半个字节把它切到Sample shifting half cycle。Fifo Threshold填 4 够用。引脚看你的芯片封装F4 常见组合是PB2CLK、PB6NCS、PF8IO0、PF9IO1、PF7IO2、PF6IO3。全部设成Very High速度AF 复用由 CubeMX 自动填。时钟树里确认 QSPI 的内核时钟源别让它跟着某个被你降频的分支走不然实际速率和你算的对不上。代码读 ID、开四线、写数据、切映射先把最基础的发一条命令并等它做完包起来。W25Q 的忙状态在状态寄存器 1 的 bit0。#defineW25Q_CMD_WREN0x06// 写使能, 每次擦/写前都要发#defineW25Q_CMD_RDSR10x05// 读状态寄存器1, bit0BUSY#defineW25Q_CMD_RDSR20x35// 读状态寄存器2, bit1QE#defineW25Q_CMD_WRSR20x31// 写状态寄存器2#defineW25Q_CMD_ERASE_4K0x20// 扇区擦除, 4KB#defineW25Q_CMD_QPP0x32// 四线页编程, 一次最多256字节#defineW25Q_CMD_QREAD0x6B// 四线快速读, 需要8个dummy cycle#defineW25Q_CMD_JEDEC_ID0x9F// 读状态寄存器, 拿来等 BUSY 清零staticuint8_tqspi_read_sr(uint8_tcmd){QSPI_CommandTypeDef s{0};uint8_tv0;s.InstructionModeQSPI_INSTRUCTION_1_LINE;s.Instructioncmd;s.AddressModeQSPI_ADDRESS_NONE;s.DataModeQSPI_DATA_1_LINE;s.NbData1;s.DummyCycles0;HAL_QSPI_Command(hqspi,s,100);HAL_QSPI_Receive(hqspi,v,100);returnv;}// 等到不忙为止, 擦一个4KB扇区大约45ms, 超时给足staticvoidqspi_wait_busy(uint32_ttimeout_ms){uint32_tt0HAL_GetTick();while(qspi_read_sr(W25Q_CMD_RDSR1)0x01){if(HAL_GetTick()-t0timeout_ms)return;// 超时兜底, 别死等}}开 QE 位这一步最容易被跳过也最容易让人怀疑焊接。W25Q 出厂 QEQuad Enable默认是 0四根线里的 IO2 和 IO3 此时还兼着 /WP 和 /HOLD 的老本行四线命令发下去它根本不按四线回数据voidqspi_enable_quad(void){uint8_tsr2qspi_read_sr(W25Q_CMD_RDSR2);if(sr20x02)return;// QE 已经是1, 不用重复写(状态寄存器也有寿命)sr2|0x02;QSPI_CommandTypeDef s{0};s.InstructionModeQSPI_INSTRUCTION_1_LINE;s.InstructionW25Q_CMD_WREN;// 写使能s.AddressModeQSPI_ADDRESS_NONE;s.DataModeQSPI_DATA_NONE;HAL_QSPI_Command(hqspi,s,100);s.InstructionW25Q_CMD_WRSR2;s.DataModeQSPI_DATA_1_LINE;s.NbData1;HAL_QSPI_Command(hqspi,s,100);HAL_QSPI_Transmit(hqspi,sr2,100);qspi_wait_busy(100);}写数据要守两条规矩先擦后写且一次别跨 256 字节页边界。// 擦一个4KB扇区; addr 会被芯片自动对齐到扇区首voidqspi_erase_sector(uint32_taddr){QSPI_CommandTypeDef s{0};s.InstructionModeQSPI_INSTRUCTION_1_LINE;s.InstructionW25Q_CMD_WREN;s.AddressModeQSPI_ADDRESS_NONE;s.DataModeQSPI_DATA_NONE;HAL_QSPI_Command(hqspi,s,100);s.InstructionW25Q_CMD_ERASE_4K;s.AddressModeQSPI_ADDRESS_1_LINE;s.AddressSizeQSPI_ADDRESS_24_BITS;// 16MB以内用24位地址s.Addressaddr;HAL_QSPI_Command(hqspi,s,100);qspi_wait_busy(1000);// 4KB扇区典型45ms, 最坏400ms}// 页内写, len 必须保证 addr%256 len 256voidqspi_write_page(uint32_taddr,uint8_t*buf,uint16_tlen){QSPI_CommandTypeDef s{0};s.InstructionModeQSPI_INSTRUCTION_1_LINE;s.InstructionW25Q_CMD_WREN;s.AddressModeQSPI_ADDRESS_NONE;s.DataModeQSPI_DATA_NONE;HAL_QSPI_Command(hqspi,s,100);s.InstructionW25Q_CMD_QPP;s.AddressModeQSPI_ADDRESS_1_LINE;s.AddressSizeQSPI_ADDRESS_24_BITS;s.Addressaddr;s.DataModeQSPI_DATA_4_LINES;// 数据走四线s.NbDatalen;HAL_QSPI_Command(hqspi,s,100);HAL_QSPI_Transmit(hqspi,buf,1000);qspi_wait_busy(100);// 页编程典型0.7ms}字库和图片都烧进去以后切内存映射模式之后就当普通只读数组用voidqspi_enter_memory_mapped(void){QSPI_CommandTypeDef s{0};QSPI_MemoryMappedTypeDef cfg{0};s.InstructionModeQSPI_INSTRUCTION_1_LINE;s.InstructionW25Q_CMD_QREAD;// 0x6B 四线输出快速读s.AddressModeQSPI_ADDRESS_1_LINE;s.AddressSizeQSPI_ADDRESS_24_BITS;s.DataModeQSPI_DATA_4_LINES;s.DummyCycles8;// ★ 0x6B 固定8个, 少一个数据全错位s.SIOOModeQSPI_SIOO_INST_EVERY_CMD;cfg.TimeOutActivationQSPI_TIMEOUT_COUNTER_DISABLE;HAL_QSPI_MemoryMapped(hqspi,s,cfg);}// 映射之后取 16x16 点阵: 一个字 32 字节#defineQSPI_BASE0x90000000U#defineFONT16_OFFSET0x00000000U// 字库烧在外部Flash起始处constuint8_t*font16_get(uint32_tindex){return(constuint8_t*)(QSPI_BASEFONT16_OFFSETindex*32);}要写要擦的时候先HAL_QSPI_Abort(hqspi)退出映射做完再切回来。映射状态下发写命令外设不理你严重时总线卡死。10 个真实翻车点1. QE 位没置四线读全是 0xFF。头号杀手也是最容易怀疑焊接的坑。W25Q 出厂 QE0IO2 和 IO3 还当 /WP 和 /HOLD 用你发 0x6B 四线读它只按单线回数据一片 0xFF 或者全 0x00。上电初始化里读状态寄存器 2 确认 bit1是 0 就写进去。我当年为这个把片子拆下来换了一颗回头发现是软件的事。2. Flash Size 填错映射越界或只能用一半。这个字段是log2(字节数) - 18MB 填 22。填成 23 你会看到高地址读出来的东西回卷地址镜像填成 21 则后 4MB 直接访问不到。字库刚好落在越界区屏上就是一片乱码点阵。3. Dummy cycles 配错数据整体错位。★ 0x6B 四线输出快速读固定要 8 个 dummy cycle0xEB 四线 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 用需要写日志时短暂退映射。intmain(void){HAL_Init();SystemClock_Config();MX_GPIO_Init();MX_QUADSPI_Init();uint32_tidqspi_read_jedec_id();if(id!0x00EF4017){error_led();}// W25Q64 认不出来, 先查硬件qspi_enable_quad();// 开QE, 四线才有效qspi_enter_memory_mapped();// 之后字库/图片当只读数组用lcd_init();show_logo((constuint16_t*)(0x90000000LOGO_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 的心态就完全不一样了。