STM32 QSPI Winbond W25Q Flash Chip Read/Write

Overview

This tutorial builds an STM32 QSPI Flash Chip driver from scratch for Winbond’s W25Q-series NOR flash, using the STM32H7’s dedicated QUADSPI peripheral. Adafruit sells three DIP-breakout versions of this same chip family — the 5632 (W25Q16, 16 Mbit/2 MByte), 5633 (W25Q64, 64 Mbit/8 MByte), and 5634 (W25Q128, 128 Mbit/16 MByte) — identical in pinout, wiring, and logic level, differing only in capacity. This tutorial wires one of these breakouts to a Nucleo-H753ZI over QUADSPI. It builds a small C++ driver class around it: JEDEC ID identification, sector/block/chip erase, and paged read/write. This first pass runs the chip in single-line instruction/address/data phases — electrically identical to standard SPI wiring, and enough to exercise the full command set — without yet using the chip’s four-line Quad Read / Quad Page Program commands, which is QUADSPI’s real throughput advantage over plain SPI.

This is a companion pair with the SPI version of this same tutorial, built on identical hardware — the STM32’s QUADSPI peripheral is a genuinely different implementation from SPI1, not just “SPI with extra pins,” and the two posts are worth reading together if you’re deciding which mode fits your project. The most interesting difference between them isn’t the wiring, it’s chip-select: see “Why chip-select doesn’t need manual handling” below.

If you’ve already read STM32 SPI Flash W25Q16JV W25Q64JV External Storage Interface, this post covers similar chip territory but a different scope: that post targets a Nucleo-F446RE and covers the same class of chip as a raw SPI flash driver. This post targets the Nucleo-H753ZI, uses the Adafruit breakout boards specifically (including the 128 Mbit capacity), drives the chip over QUADSPI instead of plain SPI, and is one half of a matched SPI/QSPI pair on identical hardware.

What You Will Learn

  • How to wire an Adafruit QSPI DIP flash breakout (W25Q16/W25Q64/W25Q128) to an STM32 Nucleo board’s QUADSPI peripheral
  • How to write a C++ HAL QUADSPI driver class for JEDEC ID identification, sector/block/chip erase, and paged read/write with automatic page-boundary splitting
  • Why this driver lets QUADSPI’s hardware NCS handle chip-select automatically, instead of the manual GPIO the SPI version of this driver needs — and why that’s a real difference in peripheral capability, not just a style choice
  • How to bridge CubeMX-generated plain-C main.c into a C++ driver class safely, with a null-pointer guard against use-before-init
  • How to diagnose a “chip not detected” wiring fault by reading the raw JEDEC bytes instead of just a pass/fail result — and what a clean QUADSPI transaction returning all-zero data actually means

Prerequisites

You should be comfortable creating and building a basic STM32CubeIDE project and have a working knowledge of C and C++. Familiarity with the general shape of an SPI-style transaction (clock, data lines, chip-select) is helpful but not required — this tutorial explains the multi-phase command structure this specific chip requires as it goes. Debug output in this project is redirected to printf over USART1 through an external FTDI USB-to-serial adapter; if you haven’t set this up before, any STM32CubeIDE project generated from the C++ integration post‘s conventions already has it configured.

Materials List

  • Nucleo-H753ZI development board
  • Adafruit QSPI DIP Flash breakout — any of 5632 (W25Q16), 5633 (W25Q64), or 5634 (W25Q128). All three are pin-identical; this tutorial’s hardware testing used the 5634.
  • Breadboard and jumper wires
  • FTDI USB-to-serial adapter, for USART1 debug output
  • USB cable (Micro-USB or USB-C, depending on your Nucleo board’s ST-LINK connector)

Board Diagram

STM32H753ZI

Project Structure

H753_CPP_W25Q_QSPI_Flash_Read_Write_01/
├── W25Q/
│   ├── w25q_commands.h
│   ├── W25QFlashQSPI.hpp
│   ├── W25QFlashQSPI.cpp
│   ├── w25q_validation.hpp
│   └── w25q_validation.cpp
├── Core/
│   ├── Inc/
│   │   ├── main.h
│   │   └── entryPointCPP.hpp
│   └── Src/
│       ├── main.c
│       └── entryPointCPP.cpp
├── Doxyfile
├── H753_CPP_W25Q_QSPI_Flash_Read_Write_01.ioc
├── H753_CPP_W25Q_QSPI_Flash_Read_Write_01.pdf
├── H753_CPP_W25Q_QSPI_Flash_Read_Write_01.txt
├── LICENSE
├── README.md
├── project_mainpage.dox
├── STM32H753ZITX_FLASH.ld
└── STM32H753ZITX_RAM.ld

Hardware Configuration / Pinouts

Overview

The Adafruit breakout exposes eight pins — 3V, G, CS, IO0, IO1, IO2, IO3, CLK — identical across all three capacity variants. All four IOx lines are wired here, even though this first-pass driver only drives single-line phases (command and address out on IO0, data in on IO1) — the wiring is already in place for a future quad-mode upgrade that uses all four data lines at once.

Adafruit QSPI DIP breakout pinout diagram, showing 3V, CS, IO3, IO1, CLK, IO2, IO0, and G pins
Pinout reference for the Adafruit QSPI DIP flash breakout (from Adafruit’s own learning guide) — identical across the 5632/5633/5634 capacity variants.

These breakouts are 3.3V logic and power only — the Nucleo-H753ZI’s 3.3V rail and GND are the only power connections needed. Never connect 5V to 3V.

Breakout PinNucleo-H753ZI PinFunction
3V3V33.3V power
GGNDGround
CLKPF10QUADSPI_CLK
CSPB10QUADSPI_BK1_NCS (hardware-managed, see below)
IO0PF8QUADSPI_BK1_IO0
IO1PF9QUADSPI_BK1_IO1
IO2PF7QUADSPI_BK1_IO2
IO3PF6QUADSPI_BK1_IO3

Debug output uses the standard USART1 setup on this site: PA9 (TX) and PA10 (RX), asynchronous mode, through an external FTDI USB-to-serial adapter rather than the board’s onboard ST-LINK virtual COM port.

FTDI Pinouts

FTDI to USB Pinout from right to left

  • Pin 1 – GND
  • Pin 4 – TX
  • Pin 5 – RX
  • USB Mini – Connect to PC via USB cable

Make sure the jumper is set to 5V — the FTDI board is powered from the USB mini cable, and a computer’s USB port supplies 5V.

FTDI USART to USB
FTDI USART to USB pin outs

Schematic

Why chip-select doesn’t need manual handling

The SPI version of this tutorial drives chip-select as a plain GPIO the driver toggles by hand, because generic SPI hardware has no built-in concept of a multi-phase command — each HAL_SPI_Transmit/HAL_SPI_Receive call is an independent transaction as far as SPI1 is concerned, so software has to bracket chip-select across the whole opcode/address/data sequence itself.

QUADSPI is different: its command model isn’t simpler than SPI’s — it’s the identical opcode/address/dummy-cycles/data phase structure under the hood — but the peripheral itself natively understands that phase structure, expressed as a single QSPI_CommandTypeDef passed to one HAL_QSPI_Command() call. Because QUADSPI already knows where one command starts and ends, it can safely own NCS (PB10, wired above) in hardware for the whole instruction/address/data sequence, with no software gap in the middle for chip-select to be dropped by mistake. That’s why PB10 is configured as QUADSPI_BK1_NCS in the .ioc, not as a GPIO output — same electrical requirement as the SPI version (one continuous chip-select period per command), solved by hardware here instead of by software, because this is the one peripheral of the two that actually models the concept of a command phase.

This atomicity has a limit worth knowing before writing multi-task code against this driver: each individual HAL_QSPI_Command()/Transmit()/Receive() call is hardware-atomic with respect to NCS, but several public methods below — writeBuffer(), eraseSector() — are themselves a sequence of multiple separate commands (Write Enable, then Page Program, then a polling loop on the status register). Only each individual command is atomic; the sequence across commands is not. See the driver class’s Doxygen @warning in the code walkthrough below for the concrete failure mode.

Pinouts & Configurations

The complete CubeMX pin assignment and peripheral configuration is captured in the IOC report below.

Project Setup

Create a new STM32CubeIDE project targeting the Nucleo-H753ZI board, with C++ enabled (even though main.c itself stays plain C — see the C/C++ bridge section below). In the .ioc pinout view:

  • Enable QUADSPI in Bank 1, Single Flash Mode, with all four IO0–IO3 lines assigned (PF8/PF9/PF7/PF6) plus CLK (PF10) and the hardware NCS (PB10) — do not configure NCS as a plain GPIO, per the chip-select explanation above.
  • Set the QUADSPI flash size parameter for the capacity you’re using (16/64/128 Mbit) — this project’s .ioc uses FlashSize = 23 for the 128 Mbit/16 MByte Adafruit 5634.
  • Disable the Cortex-M7’s D-Cache (Project Manager → Advanced Settings, or directly in the CORTEX_M7 IP block) — required for QUADSPI safety on this core. This project’s .ioc already has it disabled.
  • Leave the QUADSPI clock prescaler at CubeMX’s conservative default (255) for the first hardware bring-up — this tutorial’s own testing ran cleanly at that value with no tuning needed. See “Real hardware output” below.
  • Enable USART1 in Asynchronous mode on PA9/PA10 for printf redirect, per this site’s standard debug UART convention.

Code Walkthrough

This project’s flash driver is written as a C++ class, but CubeMX regenerates main.c as plain C every time the .ioc file changes — putting C++ code directly in main.c would get silently discarded on the next regeneration. The bridge pattern below (from the C++ integration post) solves this: main.c stays plain C and calls a small set of extern "C" functions that wrap the real C++ driver object.

w25q_commands.h — command opcodes and geometry

The Winbond command set and memory geometry constants, shared between this QSPI driver and its SPI counterpart — the opcodes and geometry are identical regardless of which transport carries them. Adapted from the command set documented in Adafruit’s own Adafruit_SPIFlash library (MIT License), which in turn reflects the standard JEDEC-style command set used across essentially all Winbond W25Q parts.

C
/*
 * w25q_commands.h
 *
 * Winbond W25Q-series SPI/QSPI flash command opcodes and geometry constants.
 * Shared between the SPI and QSPI variants of this driver -- the opcodes and
 * memory geometry are identical regardless of transport.
 *
 * Adapted from the command set documented in Adafruit_SPIFlash's
 * Adafruit_FlashTransport.h (https://github.com/adafruit/Adafruit_SPIFlash,
 * MIT License, Copyright (c) 2019 hathach for Adafruit Industries), which in
 * turn reflects the standard JEDEC-style command set used across essentially
 * all Winbond W25Q parts (confirmed against the W25Q16/W25Q64/W25Q128
 * datasheets used by the Adafruit 5632/5633/5634 breakout boards).
 *
 *  Created on: Aug 30, 2026
 *      Author: johng
 */

#ifndef W25Q_W25Q_COMMANDS_H_
#define W25Q_W25Q_COMMANDS_H_

/** @brief Winbond W25Q command opcodes. */
enum {
  W25Q_CMD_READ = 0x03,               ///< Single-line Read Data
  W25Q_CMD_FAST_READ = 0x0B,          ///< Single-line Fast Read (1 dummy byte)
  W25Q_CMD_QUAD_READ = 0x6B,          ///< 1-line address, 4-line data (QSPI variant only)

  W25Q_CMD_READ_JEDEC_ID = 0x9F,      ///< Manufacturer + memory type + capacity

  W25Q_CMD_PAGE_PROGRAM = 0x02,       ///< Single-line Page Program
  W25Q_CMD_QUAD_PAGE_PROGRAM = 0x32,  ///< 1-line address, 4-line data (QSPI variant only)

  W25Q_CMD_READ_STATUS1 = 0x05,       ///< Status Register 1 (BUSY, WEL, block-protect bits)
  W25Q_CMD_READ_STATUS2 = 0x35,       ///< Status Register 2 (QE and other bits)
  W25Q_CMD_WRITE_STATUS1 = 0x01,
  W25Q_CMD_WRITE_STATUS2 = 0x31,

  W25Q_CMD_WRITE_ENABLE = 0x06,
  W25Q_CMD_WRITE_DISABLE = 0x04,

  W25Q_CMD_ERASE_SECTOR = 0x20,       ///< Erase the 4KB sector containing the given address
  W25Q_CMD_ERASE_BLOCK = 0xD8,        ///< Erase the 64KB block containing the given address
  W25Q_CMD_ERASE_CHIP = 0xC7,

  W25Q_CMD_ENABLE_RESET = 0x66,       ///< Must precede W25Q_CMD_RESET
  W25Q_CMD_RESET = 0x99,
};

/** @brief Memory geometry constants common to the W25Q16/W25Q64/W25Q128 family. */
enum {
  W25Q_PAGE_SIZE = 256,               ///< Max bytes per Page Program command
  W25Q_SECTOR_SIZE = 4 * 1024UL,      ///< Erase granularity for W25Q_CMD_ERASE_SECTOR
  W25Q_BLOCK_SIZE = 64 * 1024UL,      ///< Erase granularity for W25Q_CMD_ERASE_BLOCK
};

/** @brief Winbond's JEDEC manufacturer ID byte, returned first by W25Q_CMD_READ_JEDEC_ID. */
#define W25Q_JEDEC_MANUFACTURER_ID 0xEF

#endif /* W25Q_W25Q_COMMANDS_H_ */

W25QFlashQSPI.hpp — the driver class declaration

Same public API shape as the SPI version’s driver class — JEDEC ID, status/ready polling, sector/block/chip erase plus a range-erase helper, and paged read/write — built on HAL_QSPI_Command()/Transmit()/Receive() in indirect mode. The class-level Doxygen comment documents the thread-safety mechanism in QUADSPI-specific terms: each individual command is hardware-atomic with respect to NCS, but a sequence of commands (as several public methods below issue) is not.

C
/*
 * W25QFlashQSPI.hpp
 *
 * STM32 HAL QUADSPI driver for the Winbond W25Q-series QSPI DIP breakout
 * boards (Adafruit 5632/5633/5634 -- W25Q16/W25Q64/W25Q128), operated in
 * single-line-address / single-line-data indirect mode -- see the class-level
 * note below on why this isn't using all four IO lines for data yet.
 *
 * Adapted from Adafruit_SPIFlash (https://github.com/adafruit/Adafruit_SPIFlash,
 * MIT License, Copyright (c) 2019 hathach for Adafruit Industries) as a design
 * reference for the command set and driver structure -- this is an independent
 * STM32 HAL implementation, not a direct port of Adafruit's Arduino source
 * (which depends on Arduino-only types this toolchain doesn't have).
 *
 *  Created on: Sep 3, 2026
 *      Author: johng
 */

#ifndef W25Q_W25QFLASHQSPI_HPP_
#define W25Q_W25QFLASHQSPI_HPP_

#include "main.h"
#include "w25q_commands.h"

/**
 * @brief STM32 HAL QUADSPI driver for a Winbond W25Q-series flash chip
 *        (Adafruit 5632/5633/5634), operated in indirect mode with
 *        QUADSPI's own hardware-managed NCS (see the companion SPI driver,
 *        W25QFlashSPI, for why that one manages chip-select manually instead
 *        -- same underlying command structure, different peripheral
 *        capability).
 *
 * This first pass uses single-line instruction/address/data phases only
 * (@c QSPI_INSTRUCTION_1_LINE / @c QSPI_ADDRESS_1_LINE / @c QSPI_DATA_1_LINE)
 * -- electrically compatible with standard SPI wiring and sufficient to
 * prove out the command set, but not yet using @c W25Q_CMD_QUAD_READ /
 * @c W25Q_CMD_QUAD_PAGE_PROGRAM's 4-line data phase, which is QUADSPI's
 * actual throughput advantage over plain SPI.
 *
 * @warning Not thread-safe or ISR-safe. Each individual
 *          @c HAL_QSPI_Command()/@c HAL_QSPI_Transmit()/@c HAL_QSPI_Receive()
 *          pair IS atomic with respect to NCS -- QUADSPI's hardware holds
 *          NCS asserted for the whole instruction/address/data sequence of
 *          a single command with no software gap to land in, unlike the SPI
 *          driver's manual brackets. But several public methods here
 *          (writeBuffer(), eraseSector(), waitUntilReady()) are themselves a
 *          *sequence* of multiple separate hardware-atomic commands -- e.g.
 *          writeBuffer() issues Write Enable as one complete command, then
 *          Page Program as a second, separate complete command, then
 *          waitUntilReady() polls the status register as a loop of further
 *          separate commands. Only each individual command is atomic; the
 *          sequence across commands is not. If a context switch lands in
 *          the gap *between* two of these hardware-atomic commands -- e.g.
 *          right after Write Enable completes but before Page Program is
 *          issued -- and a second task's call into this same driver
 *          instance runs in that gap, its own command sequence interleaves
 *          with the first task's half-finished operation on the same
 *          physical QUADSPI peripheral. If this driver is reused in a
 *          multi-task project, the caller is responsible for serializing
 *          all access (e.g. a mutex held for the duration of each public
 *          method call, not just each individual command).
 */
class W25QFlashQSPI {
public:
	/**
	 * @brief Constructs the driver.
	 * @param qspiTimeoutMs Per-transaction HAL_QSPI_Command/Transmit/Receive
	 *        timeout, in milliseconds, used for every QUADSPI transfer this
	 *        driver issues. Distinct from waitUntilReady()'s timeout, which
	 *        bounds the total time spent polling the BUSY bit across many
	 *        such transactions. Defaults to 100ms; pass a different value if
	 *        a specific operation (e.g. a slow bus speed) needs more headroom.
	 */
	explicit W25QFlashQSPI(uint32_t qspiTimeoutMs = 100);

	/**
	 * @brief Resets the chip and verifies communication via the JEDEC ID.
	 *
	 * Unlike every other public method below, a HAL-level QUADSPI failure
	 * here calls Error_Handler() rather than returning false. A
	 * communication failure on the very first transaction at startup
	 * indicates a setup problem (bad wiring, dead chip, wrong pin config)
	 * that retrying can't fix -- matching how CubeMX's own generated
	 * peripheral-init code already behaves elsewhere in main.c. A *wrong*
	 * JEDEC ID (chip present and responding, just not a W25Q) is not
	 * treated as a HAL failure and simply returns false -- that's a
	 * legitimate, non-fatal "wrong/missing chip" outcome, not a bus fault.
	 * @retval true if a W25Q-family JEDEC manufacturer ID (0xEF) was read back.
	 */
	bool Init();

	/**
	 * @brief Reads the 3-byte JEDEC ID (manufacturer, memory type, capacity).
	 *
	 * Distinguishes a failed QUADSPI transfer from a successful transfer
	 * that simply read back an unexpected ID (wrong/missing chip) -- the
	 * return value reports the former, id's contents (valid only when this
	 * returns true) determine the latter. Not escalated to Error_Handler()
	 * -- see the class-level note on Init() vs. every other method; use
	 * Init() itself for the fail-hard startup check.
	 * @param id Destination for the 24-bit JEDEC ID (manufacturer byte in
	 *           bits [23:16]). Left unmodified if this returns false.
	 * @retval true if the QUADSPI transfer succeeded (regardless of whether
	 *         id turned out to be a genuine W25Q manufacturer ID), false if
	 *         the transfer itself failed -- caller may retry; not escalated
	 *         to Error_Handler() (see Init()).
	 */
	bool readJEDECID(uint32_t &id);

	/**
	 * @brief Reads both status registers.
	 * @param status1 Destination for Status Register 1 (BUSY, WEL, block-protect bits).
	 * @param status2 Destination for Status Register 2 (QE and other bits).
	 * @retval true on success, false if either QUADSPI transfer failed --
	 *         caller may retry; not escalated to Error_Handler() (see Init()).
	 */
	bool readStatus(uint8_t &status1, uint8_t &status2);

	/**
	 * @brief Polls Status Register 1's BUSY bit until clear or timeout.
	 * @param timeout_ms Maximum time to wait, in milliseconds.
	 * @retval true if the chip became ready before the timeout.
	 */
	bool waitUntilReady(uint32_t timeout_ms = 100);

	/**
	 * @brief Erases the single 4KB sector containing the given address.
	 * @param addr Any address within the sector to erase.
	 * @retval true on success, false if the erase command's QUADSPI transfer
	 *         failed or the BUSY-wait timed out -- not escalated to
	 *         Error_Handler() (see Init()).
	 */
	bool eraseSector(uint32_t addr);

	/**
	 * @brief Erases every sector spanned by [addr, addr + len), computing
	 *        the sector boundaries automatically -- the erase-side
	 *        counterpart to writeBuffer()'s automatic page-splitting.
	 *
	 * Needed for any write larger than one sector (4KB): a caller who only
	 * erases the sector addr starts in, then calls writeBuffer() with a
	 * length that spans into a second sector, gets silently corrupted data
	 * in that second sector -- Page Program can only clear bits, and an
	 * unerased sector still holds whatever was there before. Matching
	 * writeBuffer()'s "any length" contract on the write side without this
	 * on the erase side would leave that exact landmine for the caller to
	 * find the hard way.
	 *
	 * @warning Erase always operates on whole 4KB sectors -- there is no
	 *          finer granularity on this chip. If addr isn't sector-aligned,
	 *          or addr+len doesn't end exactly on a sector boundary, this
	 *          WILL also erase any other data sharing those same sectors,
	 *          even data outside the [addr, len) range you asked for. This
	 *          is true of eraseSector()/eraseBlock() too, but is easier to
	 *          trigger by surprise here since the caller may not be
	 *          thinking in sector-aligned terms at all when picking addr
	 *          and len for a write. If you're storing multiple independent
	 *          pieces of data on this chip, keep each one sector-aligned
	 *          (or give each its own dedicated sector(s)) precisely so an
	 *          erase of one doesn't silently take out another.
	 * @param addr Starting address of the range to erase.
	 * @param len  Length of the range, in bytes.
	 * @retval true on success, false if any sector's erase failed -- not
	 *         escalated to Error_Handler() (see Init()). Sectors already
	 *         erased before a failing one remain erased.
	 */
	bool eraseRange(uint32_t addr, uint32_t len);

	/**
	 * @brief Erases the single 64KB block containing the given address.
	 * @param addr Any address within the block to erase.
	 * @retval true on success, false if the erase command's QUADSPI transfer
	 *         failed or the BUSY-wait timed out -- not escalated to
	 *         Error_Handler() (see Init()).
	 */
	bool eraseBlock(uint32_t addr);

	/**
	 * @brief Erases the entire chip. Can take tens of seconds on larger
	 *        capacities -- verify the timeout against the specific chip's
	 *        datasheet (16/64/128 Mbit erase times differ significantly).
	 * @retval true on success, false if the erase command's QUADSPI transfer
	 *         failed or the BUSY-wait timed out -- not escalated to
	 *         Error_Handler() (see Init()).
	 */
	bool eraseChip();

	/**
	 * @brief Reads len bytes starting at addr into buf.
	 * @param addr Starting flash address to read from.
	 * @param buf  Destination buffer, must be at least len bytes.
	 * @param len  Number of bytes to read.
	 * @retval true on success, false if the QUADSPI transfer failed --
	 *         caller may retry; not escalated to Error_Handler() (see Init()).
	 */
	bool readBuffer(uint32_t addr, uint8_t *buf, uint32_t len);

	/**
	 * @brief Writes len bytes starting at addr, paging internally at 256-byte
	 *        boundaries as the chip's Page Program command requires.
	 * @param addr Starting flash address to write to. The destination must
	 *             already be erased -- flash bits can only be cleared
	 *             (1 -> 0), never set, outside of an erase operation.
	 * @param data Source buffer of len bytes to write.
	 * @param len  Number of bytes to write.
	 * @retval true on success, false if any page's QUADSPI transfer failed
	 *         or its BUSY-wait timed out -- not escalated to Error_Handler()
	 *         (see Init()). Pages already written before the failing one
	 *         remain written; the caller can inspect/retry from the failure
	 *         point.
	 */
	bool writeBuffer(uint32_t addr, const uint8_t *data, uint32_t len);

private:
	QSPI_HandleTypeDef *_hqspi;

	/** @brief Per-transaction HAL_QSPI_Command/Transmit/Receive timeout, in milliseconds. See the constructor. */
	uint32_t _qspiTimeoutMs;

	/**
	 * @brief Issues a single-byte instruction with no address and no data
	 *        phase, e.g. Write Enable, Reset.
	 * @param instruction Command opcode.
	 * @retval HAL_OK, or the HAL error/timeout code from the underlying
	 *         QUADSPI transfer.
	 */
	HAL_StatusTypeDef runCommand(uint8_t instruction);

	/**
	 * @brief Issues an instruction with no address, then reads len bytes of
	 *        response, e.g. Read Status, Read JEDEC ID.
	 * @param instruction Command opcode.
	 * @param resp        Destination buffer for the response.
	 * @param len         Number of response bytes to read.
	 * @retval HAL_OK, or the HAL error/timeout code from the underlying
	 *         QUADSPI transfer.
	 */
	HAL_StatusTypeDef readCommand(uint8_t instruction, uint8_t *resp, uint32_t len);

	/**
	 * @brief Issues an instruction with no address, then writes len bytes of
	 *        data, e.g. Write Status.
	 * @param instruction Command opcode.
	 * @param data        Data bytes to write following the instruction.
	 * @param len         Number of data bytes.
	 * @retval HAL_OK, or the HAL error/timeout code from the underlying
	 *         QUADSPI transfer.
	 */
	HAL_StatusTypeDef writeCommand(uint8_t instruction, const uint8_t *data, uint32_t len);

	/**
	 * @brief Issues Write Enable, then an instruction followed by a 3-byte
	 *        address with no data phase, e.g. Sector Erase, Block Erase.
	 * @param instruction Erase command opcode.
	 * @param addr        Address identifying the sector/block to erase.
	 * @retval HAL_OK, or the HAL error/timeout code from the underlying
	 *         QUADSPI transfer.
	 */
	HAL_StatusTypeDef eraseCommand(uint8_t instruction, uint32_t addr);

	/**
	 * @brief Issues Write Enable (0x06). Required before any program/erase command.
	 * @retval HAL_OK, or the HAL error/timeout code from the underlying
	 *         QUADSPI transfer.
	 */
	HAL_StatusTypeDef writeEnable();
};

#endif /* W25Q_W25QFLASHQSPI_HPP_ */

W25QFlashQSPI.cpp — the implementation

makeBaseCommand() pre-fills the fields that never vary across this driver’s commands — single-line instruction, no alternate bytes, no dummy cycles, DDR disabled — so every command-building method only sets what’s actually different about its own command shape. Init() is the one method that escalates a HAL failure straight to Error_Handler() rather than returning false, deliberately: a communication failure on the very first transaction at startup means a setup problem (bad wiring, dead chip, wrong pin config) that retrying can’t fix, matching how CubeMX’s own generated peripheral-init code already behaves elsewhere in main.c. A wrong JEDEC ID — chip present and responding, just not a W25Q — is a different, non-fatal outcome and simply returns false.

eraseRange() is the erase-side counterpart to writeBuffer()‘s automatic page-splitting: it computes which 4KB sectors a byte range spans and erases each one. Without it, a caller erasing only the sector an address starts in, then writing a length that spans into a second sector, would get silently corrupted data in that second sector — Page Program can only clear bits, and an unerased sector still holds whatever was there before.

C
/*
 * W25QFlashQSPI.cpp
 *
 *  Created on: Sep 3, 2026
 *      Author: johng
 */

#include "W25QFlashQSPI.hpp"

extern QSPI_HandleTypeDef hqspi;

namespace {

/**
 * @brief Builds a QSPI_CommandTypeDef with the fields that never vary across
 *        this driver's commands already filled in (single-line instruction,
 *        no alternate bytes, no dummy cycles, DDR disabled). Callers set
 *        Instruction, AddressMode/AddressSize/Address, and DataMode/NbData
 *        on top of this base for their specific command shape.
 */
QSPI_CommandTypeDef makeBaseCommand() {
	QSPI_CommandTypeDef cmd = {0};
	cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE;
	cmd.AlternateByteMode = QSPI_ALTERNATE_BYTES_NONE;
	cmd.DummyCycles = 0;
	cmd.DdrMode = QSPI_DDR_MODE_DISABLE;
	cmd.DdrHoldHalfCycle = QSPI_DDR_HHC_ANALOG_DELAY;
	cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD;
	return cmd;
}

} // namespace

W25QFlashQSPI::W25QFlashQSPI(uint32_t qspiTimeoutMs) : _hqspi(&hqspi), _qspiTimeoutMs(qspiTimeoutMs) {
}

HAL_StatusTypeDef W25QFlashQSPI::runCommand(uint8_t instruction) {
	QSPI_CommandTypeDef cmd = makeBaseCommand();
	cmd.Instruction = instruction;
	cmd.AddressMode = QSPI_ADDRESS_NONE;
	cmd.DataMode = QSPI_DATA_NONE;

	HAL_StatusTypeDef status = HAL_QSPI_Command(_hqspi, &cmd, _qspiTimeoutMs);
	return status;
}

HAL_StatusTypeDef W25QFlashQSPI::readCommand(uint8_t instruction, uint8_t *resp, uint32_t len) {
	QSPI_CommandTypeDef cmd = makeBaseCommand();
	cmd.Instruction = instruction;
	cmd.AddressMode = QSPI_ADDRESS_NONE;
	cmd.DataMode = (len > 0) ? QSPI_DATA_1_LINE : QSPI_DATA_NONE;
	cmd.NbData = len;

	HAL_StatusTypeDef status = HAL_QSPI_Command(_hqspi, &cmd, _qspiTimeoutMs);
	if (status == HAL_OK && len > 0) {
		status = HAL_QSPI_Receive(_hqspi, resp, _qspiTimeoutMs);
	}
	return status;
}

HAL_StatusTypeDef W25QFlashQSPI::writeCommand(uint8_t instruction, const uint8_t *data, uint32_t len) {
	QSPI_CommandTypeDef cmd = makeBaseCommand();
	cmd.Instruction = instruction;
	cmd.AddressMode = QSPI_ADDRESS_NONE;
	cmd.DataMode = (len > 0) ? QSPI_DATA_1_LINE : QSPI_DATA_NONE;
	cmd.NbData = len;

	HAL_StatusTypeDef status = HAL_QSPI_Command(_hqspi, &cmd, _qspiTimeoutMs);
	if (status == HAL_OK && len > 0) {
		status = HAL_QSPI_Transmit(_hqspi, const_cast<uint8_t *>(data), _qspiTimeoutMs);
	}
	return status;
}

HAL_StatusTypeDef W25QFlashQSPI::eraseCommand(uint8_t instruction, uint32_t addr) {
	HAL_StatusTypeDef status = writeEnable();
	if (status != HAL_OK) {
		return status;
	}

	QSPI_CommandTypeDef cmd = makeBaseCommand();
	cmd.Instruction = instruction;
	cmd.AddressMode = QSPI_ADDRESS_1_LINE;
	cmd.AddressSize = QSPI_ADDRESS_24_BITS;
	cmd.Address = addr;
	cmd.DataMode = QSPI_DATA_NONE;

	status = HAL_QSPI_Command(_hqspi, &cmd, _qspiTimeoutMs);
	return status;
}

HAL_StatusTypeDef W25QFlashQSPI::writeEnable() {
	HAL_StatusTypeDef status = runCommand(W25Q_CMD_WRITE_ENABLE);
	return status;
}

bool W25QFlashQSPI::Init() {
	// Fail-hard: see the class-level Doxygen note on Init() for why a HAL
	// failure here calls Error_Handler() while every other method below
	// propagates instead.
	HAL_StatusTypeDef status = runCommand(W25Q_CMD_ENABLE_RESET);
	if (status != HAL_OK) {
		Error_Handler();
	}

	status = runCommand(W25Q_CMD_RESET);
	if (status != HAL_OK) {
		Error_Handler();
	}

	HAL_Delay(1); // tRST: chip reset recovery time

	uint32_t id = 0;
	if (!readJEDECID(id)) {
		Error_Handler(); // QUADSPI transfer itself failed -- distinct from a merely wrong id below
	}

	bool detected = ((id >> 16) & 0xFF) == W25Q_JEDEC_MANUFACTURER_ID;
	return detected; // a *wrong* ID is a legitimate non-fatal outcome, not a HAL fault
}

bool W25QFlashQSPI::readJEDECID(uint32_t &id) {
	// Not escalated to Error_Handler() here -- this is a caller-facing
	// method, not Init() itself (which calls this and escalates on our
	// behalf). The bool return lets the caller tell "QUADSPI transfer
	// failed" apart from "transfer succeeded, id just isn't a valid W25Q ID".
	uint8_t resp[3] = { 0, 0, 0 };
	HAL_StatusTypeDef status = readCommand(W25Q_CMD_READ_JEDEC_ID, resp, sizeof(resp));
	if (status != HAL_OK) {
		return false;
	}

	id = ((uint32_t)resp[0] << 16) | ((uint32_t)resp[1] << 8) | resp[2];
	return true;
}

bool W25QFlashQSPI::readStatus(uint8_t &status1, uint8_t &status2) {
	HAL_StatusTypeDef status = readCommand(W25Q_CMD_READ_STATUS1, &status1, 1);
	if (status != HAL_OK) {
		return false;
	}

	status = readCommand(W25Q_CMD_READ_STATUS2, &status2, 1);
	if (status != HAL_OK) {
		return false;
	}

	return true;
}

bool W25QFlashQSPI::waitUntilReady(uint32_t timeout_ms) {
	uint32_t start = HAL_GetTick();
	uint8_t chipStatus = 0;

	do {
		HAL_StatusTypeDef status = readCommand(W25Q_CMD_READ_STATUS1, &chipStatus, 1);
		if (status != HAL_OK) {
			return false; // QUADSPI transfer failure -- propagate, don't escalate (see Init())
		}

		if ((chipStatus & 0x01) == 0) { // BUSY bit clear
			return true;
		}
	} while ((HAL_GetTick() - start) < timeout_ms);

	return false; // BUSY-bit timeout -- an expected outcome on a long erase,
	              // not a HAL communication failure either way.
}

bool W25QFlashQSPI::eraseSector(uint32_t addr) {
	HAL_StatusTypeDef status = eraseCommand(W25Q_CMD_ERASE_SECTOR, addr);
	if (status != HAL_OK) {
		return false;
	}

	bool ready = waitUntilReady(500); // datasheet typical/max sector erase time varies by capacity
	return ready;
}

bool W25QFlashQSPI::eraseRange(uint32_t addr, uint32_t len) {
	if (len == 0) {
		return true;
	}

	uint32_t firstSector = addr / W25Q_SECTOR_SIZE;
	uint32_t lastSector = (addr + len - 1) / W25Q_SECTOR_SIZE;

	for (uint32_t sector = firstSector; sector <= lastSector; sector++) {
		if (!eraseSector(sector * W25Q_SECTOR_SIZE)) {
			return false;
		}
	}

	return true;
}

bool W25QFlashQSPI::eraseBlock(uint32_t addr) {
	HAL_StatusTypeDef status = eraseCommand(W25Q_CMD_ERASE_BLOCK, addr);
	if (status != HAL_OK) {
		return false;
	}

	bool ready = waitUntilReady(3000); // datasheet typical/max block erase time varies by capacity
	return ready;
}

bool W25QFlashQSPI::eraseChip() {
	HAL_StatusTypeDef status = writeEnable();
	if (status != HAL_OK) {
		return false;
	}

	status = runCommand(W25Q_CMD_ERASE_CHIP);
	if (status != HAL_OK) {
		return false;
	}

	// Chip erase time scales heavily with capacity (16/64/128 Mbit) -- verify
	// this timeout against the specific chip's datasheet before relying on it.
	bool ready = waitUntilReady(100000);
	return ready;
}

bool W25QFlashQSPI::readBuffer(uint32_t addr, uint8_t *buf, uint32_t len) {
	QSPI_CommandTypeDef cmd = makeBaseCommand();
	cmd.Instruction = W25Q_CMD_READ;
	cmd.AddressMode = QSPI_ADDRESS_1_LINE;
	cmd.AddressSize = QSPI_ADDRESS_24_BITS;
	cmd.Address = addr;
	cmd.DataMode = QSPI_DATA_1_LINE;
	cmd.NbData = len;

	HAL_StatusTypeDef status = HAL_QSPI_Command(_hqspi, &cmd, _qspiTimeoutMs);
	if (status == HAL_OK) {
		status = HAL_QSPI_Receive(_hqspi, buf, _qspiTimeoutMs);
	}

	return status == HAL_OK;
}

bool W25QFlashQSPI::writeBuffer(uint32_t addr, const uint8_t *data, uint32_t len) {
	uint32_t written = 0;

	while (written < len) {
		uint32_t pageOffset = (addr + written) % W25Q_PAGE_SIZE;
		uint32_t chunk = W25Q_PAGE_SIZE - pageOffset;
		if (chunk > (len - written)) {
			chunk = len - written;
		}

		HAL_StatusTypeDef status = writeEnable();
		if (status != HAL_OK) {
			return false;
		}

		QSPI_CommandTypeDef cmd = makeBaseCommand();
		cmd.Instruction = W25Q_CMD_PAGE_PROGRAM;
		cmd.AddressMode = QSPI_ADDRESS_1_LINE;
		cmd.AddressSize = QSPI_ADDRESS_24_BITS;
		cmd.Address = addr + written;
		cmd.DataMode = QSPI_DATA_1_LINE;
		cmd.NbData = chunk;

		status = HAL_QSPI_Command(_hqspi, &cmd, _qspiTimeoutMs);
		if (status == HAL_OK) {
			status = HAL_QSPI_Transmit(_hqspi, const_cast<uint8_t *>(data + written), _qspiTimeoutMs);
		}

		if (status != HAL_OK) {
			return false;
		}
		if (!waitUntilReady(50)) { // page program typical max is a few ms; generous margin
			return false;
		}

		written += chunk;
	}

	return true;
}

entryPointCPP.hpp / entryPointCPP.cpp — the C/C++ bridge

The driver object is constructed inside initW25QFlash(), called from main() deliberately after HAL_Init()/MX_GPIO_Init()/MX_QUADSPI_Init() have already run — not as a global static C++ object, which would construct too early. C++ global constructors run inside __libc_init_array() before main() even starts, before any peripheral exists to construct against.

Every bridge function checks w25qFlash == nullptr || !w25qReady before touching the driver object, guarding against a call made before initW25QFlash() has run (or after a failed Init()). Without this guard, calling any of these functions too early would dereference a null pointer and hard-fault — a worse failure mode for a flash driver than, say, an LED simply not blinking.

One function deliberately breaks that pattern: w25qReadJEDECIDRaw() checks only that the driver object exists, not that w25qReady is true — because its entire purpose is to show what QUADSPI actually reads back on a boot where detection failed. See how it’s used in main.c below, and “Real hardware output” further down for what it caught on this project’s own real hardware bring-up.

C
/*
 * entryPointCPP.hpp
 *
 * C-callable entry points bridging CubeMX-generated main.c (plain C, subject
 * to regeneration whenever the .ioc changes) into the C++ W25QFlashQSPI driver.
 *
 * extern "C" here prevents C++ name mangling on these declarations, so
 * main.c can call them directly by name. The driver object itself is
 * constructed inside initW25QFlash(), deliberately called from main() after
 * HAL_Init()/MX_GPIO_Init()/MX_QUADSPI_Init() have already run -- not as a
 * global static C++ object, which would construct too early (C++ global
 * constructors run in __libc_init_array() before main() even starts, before
 * any peripheral is configured).
 *
 *  Created on: Sep 3, 2026
 *      Author: johng
 */

#ifndef INC_ENTRYPOINTCPP_HPP_
#define INC_ENTRYPOINTCPP_HPP_

#include "main.h"
#include "stdbool.h"

#ifdef __cplusplus
extern "C" {
#endif

/**
 * @brief Constructs the W25QFlashQSPI driver and verifies communication with
 *        the chip. Must be called after MX_QUADSPI_Init() (and therefore
 *        after HAL_Init()/SystemClock_Config()) so the QUADSPI peripheral is
 *        already configured before the driver touches it.
 * @retval None -- check w25qIsReady() to see whether verification succeeded.
 */
void initW25QFlash(void);

/**
 * @brief Reports whether initW25QFlash() has run and successfully verified
 *        the chip's JEDEC ID. Every other w25q* function below returns false
 *        without touching hardware if this is false, guarding against a call
 *        made before initW25QFlash() (or after a failed begin()).
 * @retval true if the driver is constructed and ready for use.
 */
bool w25qIsReady(void);

/**
 * @brief Reads len bytes starting at addr into buf.
 * @param addr Starting flash address to read from.
 * @param buf  Destination buffer, must be at least len bytes.
 * @param len  Number of bytes to read.
 * @retval true on success, false if not ready or on QUADSPI error.
 */
bool w25qReadBuffer(uint32_t addr, uint8_t *buf, uint32_t len);

/**
 * @brief Writes len bytes starting at addr, paging internally at 256-byte
 *        boundaries. The destination must already be erased.
 * @param addr Starting flash address to write to.
 * @param data Source buffer of len bytes to write.
 * @param len  Number of bytes to write.
 * @retval true on success, false if not ready or on QUADSPI error.
 */
bool w25qWriteBuffer(uint32_t addr, const uint8_t *data, uint32_t len);

/**
 * @brief Erases the 4KB sector containing addr.
 * @param addr Any address within the sector to erase.
 * @retval true on success, false if not ready or on QUADSPI error.
 */
bool w25qEraseSector(uint32_t addr);

/**
 * @brief Reads the 3-byte JEDEC ID (manufacturer, memory type, capacity).
 * @param id Destination for the 24-bit JEDEC ID (manufacturer byte in bits
 *           [23:16]). Left unmodified if this returns false.
 * @retval true on success, false if not ready or the QUADSPI transfer failed.
 */
bool w25qReadJEDECID(uint32_t *id);

/**
 * @brief Diagnostic-only: reads the raw JEDEC ID bytes over QUADSPI even when
 *        initW25QFlash() reported the chip as not detected. Bypasses the
 *        w25qReady gate (unlike w25qReadJEDECID()) since the whole point is
 *        to see what actually came back on a failed-detection boot.
 * @param id Destination for the 24-bit value read (manufacturer byte in bits
 *           [23:16]), written even if the returned status isn't HAL_OK.
 * @retval true if the QUADSPI transfer itself completed without a HAL error.
 */
bool w25qReadJEDECIDRaw(uint32_t *id);

/**
 * @brief Runs the destructive W25QValidation test suite and prints results.
 *
 * Overwrites the first sector (W25Q_SECTOR_SIZE bytes) starting at
 * W25Q_VALIDATION_BASE_ADDR. See W25Q_VALIDATION_TEST_ENABLE in main.c --
 * this is meant to be called instead of the normal demo path, not alongside
 * it, and only when destroying that sector's contents is acceptable.
 * @retval None. Prints "flash not initialized" and returns immediately if
 *         initW25QFlash() hasn't succeeded yet.
 */
void w25qRunValidation(void);

#ifdef __cplusplus
}
#endif

#endif /* INC_ENTRYPOINTCPP_HPP_ */
C
/*
 * entryPointCPP.cpp
 *
 *  Created on: Sep 3, 2026
 *      Author: johng
 */

#include "entryPointCPP.hpp"
#include "W25QFlashQSPI.hpp"
#include "w25q_validation.hpp"
#include <stdio.h>

/** @brief The single W25QFlashQSPI driver instance, constructed in initW25QFlash(). */
static W25QFlashQSPI *w25qFlash = nullptr;

/** @brief Set once begin() succeeds; guards every bridge function below against use-before-init. */
static bool w25qReady = false;

void initW25QFlash(void) {
	w25qFlash = new W25QFlashQSPI();
	w25qReady = w25qFlash->Init();
}

bool w25qIsReady(void) {
	return w25qReady;
}

bool w25qReadBuffer(uint32_t addr, uint8_t *buf, uint32_t len) {
	if (w25qFlash == nullptr || !w25qReady) {
		return false;
	}
	bool value = w25qFlash->readBuffer(addr, buf, len);
	return value;
}

bool w25qWriteBuffer(uint32_t addr, const uint8_t *data, uint32_t len) {
	if (w25qFlash == nullptr || !w25qReady) {
		return false;
	}
	bool value = w25qFlash->writeBuffer(addr, data, len);
	return value;
}

bool w25qEraseSector(uint32_t addr) {
	if (w25qFlash == nullptr || !w25qReady) {
		return false;
	}
	bool value = w25qFlash->eraseSector(addr);
	return value;
}

bool w25qReadJEDECID(uint32_t *id) {
	if (w25qFlash == nullptr || !w25qReady) {
		return false;
	}
	bool value = w25qFlash->readJEDECID(*id);
	return value;
}

bool w25qReadJEDECIDRaw(uint32_t *id) {
	if (w25qFlash == nullptr) {
		return false;
	}
	return w25qFlash->readJEDECID(*id);
}

void w25qRunValidation(void) {
	if (w25qFlash == nullptr || !w25qReady) {
		printf("W25Q validation skipped -- flash not initialized\r\n");
		return;
	}
	W25QValidation validation(*w25qFlash);
	validation.RunAll();
}

main.c — the STM32 QSPI Flash Chip demo

After the standard CubeMX-generated peripheral init, the demo path: read and print the JEDEC ID, erase the first sector, write a short message into it, read it back, and confirm the round trip matched byte-for-byte. This is the actual read/write this tutorial is about, as opposed to the identify-only JEDEC ID check that precedes it. The W25Q_VALIDATION_TEST_ENABLE macro (commented out by default) swaps this demo path for the destructive validation suite instead — see “Validating the driver” below.

The else branch — the chip not being detected — also calls w25qReadJEDECIDRaw() and prints the raw bytes it got back, regardless of whether the transfer looked successful. This was added as a debugging aid after hitting exactly this failure on real hardware once, and it’s kept in deliberately: a plain “not detected” message tells you nothing about why, while the raw bytes usually do. See “Real hardware output” below for what this caught.

C
/* USER CODE BEGIN Header */
/**
  ******************************************************************************
  * @file           : main.c
  * @brief          : Main program body
  * @project
  *                   H753_CPP_W25Q_QSPI_Flash_Read_Write_01
  ******************************************************************************
  * @attention
  *
  * Copyright (c) 2026 STMicroelectronics.
  * All rights reserved.
  *
  * This software is licensed under terms that can be found in the LICENSE file
  * in the root directory of this software component.
  * If no LICENSE file comes with this software, it is provided AS-IS.
  *
  ******************************************************************************
  */
/* USER CODE END Header */
/* Includes ------------------------------------------------------------------*/
#include "main.h"

/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include <stdio.h>
#include <string.h>
#include "entryPointCPP.hpp"

/* USER CODE END Includes */

/* Private typedef -----------------------------------------------------------*/
/* USER CODE BEGIN PTD */

/* USER CODE END PTD */

/* Private define ------------------------------------------------------------*/
/* USER CODE BEGIN PD */

// Uncomment to run the destructive W25QValidation suite instead of the normal
// JEDEC-ID demo. Overwrites the first sector -- see w25q_validation.hpp.
 //  #define W25Q_VALIDATION_TEST_ENABLE

/* USER CODE END PD */

/* Private macro -------------------------------------------------------------*/
/* USER CODE BEGIN PM */

/* USER CODE END PM */

/* Private variables ---------------------------------------------------------*/

QSPI_HandleTypeDef hqspi;

UART_HandleTypeDef huart1;

/* USER CODE BEGIN PV */

/* USER CODE END PV */

/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
static void MX_USART1_UART_Init(void);
static void MX_QUADSPI_Init(void);
/* USER CODE BEGIN PFP */

/* USER CODE END PFP */

/* Private user code ---------------------------------------------------------*/
/* USER CODE BEGIN 0 */

#ifdef REDIRECT_PRINTF
#define PUTCHAR_PROTOTYPE int __io_putchar(int ch)

PUTCHAR_PROTOTYPE
{
  HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF);

  return ch;
}
#endif

/* USER CODE END 0 */

/**
  * @brief  The application entry point.
  * @retval int
  */
int main(void)
{

  /* USER CODE BEGIN 1 */

  /* USER CODE END 1 */

  /* MCU Configuration--------------------------------------------------------*/

  /* Reset of all peripherals, Initializes the Flash interface and the Systick. */
  HAL_Init();

  /* USER CODE BEGIN Init */

  /* USER CODE END Init */

  /* Configure the system clock */
  SystemClock_Config();

  /* USER CODE BEGIN SysInit */

  /* USER CODE END SysInit */

  /* Initialize all configured peripherals */
  MX_GPIO_Init();
  MX_USART1_UART_Init();
  MX_QUADSPI_Init();
  /* USER CODE BEGIN 2 */

  printf("\x1b[2J\x1b[H");	// Clear the dumb terminal screen
  printf("Hello World!!!!!\r\n");
  printf("Project: H753_CPP_W25Q_QSPI_Flash_Read_Write_01\r\n");
  fflush(stdout);


  initW25QFlash();
  if (w25qIsReady()) {
#ifdef W25Q_VALIDATION_TEST_ENABLE
	  w25qRunValidation();
#else
	  uint32_t jedecId = 0;
	  if (w25qReadJEDECID(&jedecId)) {
		  printf("W25Q flash detected, JEDEC ID: 0x%06lX\r\n", (unsigned long)jedecId);
	  } else {
		  printf("W25Q flash QUADSPI read failed after Init() succeeded -- check for a loose connection\r\n");
	  }

	  // Demo: erase a sector, write a short message, read it back, and
	  // confirm it round-tripped -- the actual read/write this tutorial is
	  // about, as opposed to the identify-only JEDEC ID check above.
	  const uint32_t demoAddr = 0x000000;
	  const char demoMessage[] = "Hello from the H753, stored in W25Q flash via QSPI!";

	  if (!w25qEraseSector(demoAddr)) {
		  printf("Demo erase failed\r\n");
	  } else if (!w25qWriteBuffer(demoAddr, (const uint8_t *)demoMessage, sizeof(demoMessage))) {
		  printf("Demo write failed\r\n");
	  } else {
		  uint8_t readBack[sizeof(demoMessage)] = { 0 };
		  if (!w25qReadBuffer(demoAddr, readBack, sizeof(readBack))) {
			  printf("Demo read failed\r\n");
		  } else {
			  printf("Wrote: \"%s\"\r\n", demoMessage);
			  printf("Read : \"%s\"\r\n", readBack);
			  if (memcmp(demoMessage, readBack, sizeof(demoMessage)) == 0) {
				  printf("Read-back matches -- write/read round trip OK\r\n");
			  } else {
				  printf("Read-back MISMATCH\r\n");
			  }
		  }
	  }
#endif
  } else {
	  printf("W25Q flash NOT detected -- check wiring\r\n");
	  uint32_t rawId = 0;
	  bool transferOk = w25qReadJEDECIDRaw(&rawId);
	  printf("  diagnostic raw JEDEC read: transfer %s, bytes 0x%06lX\r\n",
			  transferOk ? "OK" : "FAILED", (unsigned long)rawId);
  }

  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }
  /* USER CODE END 3 */
}

/**
  * @brief System Clock Configuration
  * @retval None
  */
void SystemClock_Config(void)
{
  RCC_OscInitTypeDef RCC_OscInitStruct = {0};
  RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};

  /** Supply configuration update enable
  */
  HAL_PWREx_ConfigSupply(PWR_LDO_SUPPLY);

  /** Configure the main internal regulator output voltage
  */
  __HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE0);

  while(!__HAL_PWR_GET_FLAG(PWR_FLAG_VOSRDY)) {}

  /** Initializes the RCC Oscillators according to the specified parameters
  * in the RCC_OscInitTypeDef structure.
  */
  RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI;
  RCC_OscInitStruct.HSIState = RCC_HSI_DIV1;
  RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT;
  RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
  RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSI;
  RCC_OscInitStruct.PLL.PLLM = 4;
  RCC_OscInitStruct.PLL.PLLN = 60;
  RCC_OscInitStruct.PLL.PLLP = 2;
  RCC_OscInitStruct.PLL.PLLQ = 4;
  RCC_OscInitStruct.PLL.PLLR = 2;
  RCC_OscInitStruct.PLL.PLLRGE = RCC_PLL1VCIRANGE_3;
  RCC_OscInitStruct.PLL.PLLVCOSEL = RCC_PLL1VCOWIDE;
  RCC_OscInitStruct.PLL.PLLFRACN = 0;
  if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK)
  {
    Error_Handler();
  }

  /** Initializes the CPU, AHB and APB buses clocks
  */
  RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK
                              |RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2
                              |RCC_CLOCKTYPE_D3PCLK1|RCC_CLOCKTYPE_D1PCLK1;
  RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
  RCC_ClkInitStruct.SYSCLKDivider = RCC_SYSCLK_DIV1;
  RCC_ClkInitStruct.AHBCLKDivider = RCC_HCLK_DIV2;
  RCC_ClkInitStruct.APB3CLKDivider = RCC_APB3_DIV2;
  RCC_ClkInitStruct.APB1CLKDivider = RCC_APB1_DIV2;
  RCC_ClkInitStruct.APB2CLKDivider = RCC_APB2_DIV2;
  RCC_ClkInitStruct.APB4CLKDivider = RCC_APB4_DIV2;

  if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_4) != HAL_OK)
  {
    Error_Handler();
  }
}

/**
  * @brief QUADSPI Initialization Function
  * @param None
  * @retval None
  */
static void MX_QUADSPI_Init(void)
{

  /* USER CODE BEGIN QUADSPI_Init 0 */

  /* USER CODE END QUADSPI_Init 0 */

  /* USER CODE BEGIN QUADSPI_Init 1 */

  /* USER CODE END QUADSPI_Init 1 */
  /* QUADSPI parameter configuration*/
  hqspi.Instance = QUADSPI;
  hqspi.Init.ClockPrescaler = 255;
  hqspi.Init.FifoThreshold = 1;
  hqspi.Init.SampleShifting = QSPI_SAMPLE_SHIFTING_NONE;
  hqspi.Init.FlashSize = 23;
  hqspi.Init.ChipSelectHighTime = QSPI_CS_HIGH_TIME_1_CYCLE;
  hqspi.Init.ClockMode = QSPI_CLOCK_MODE_0;
  hqspi.Init.FlashID = QSPI_FLASH_ID_1;
  hqspi.Init.DualFlash = QSPI_DUALFLASH_DISABLE;
  if (HAL_QSPI_Init(&hqspi) != HAL_OK)
  {
    Error_Handler();
  }
  /* USER CODE BEGIN QUADSPI_Init 2 */

  /* USER CODE END QUADSPI_Init 2 */

}

/**
  * @brief USART1 Initialization Function
  * @param None
  * @retval None
  */
static void MX_USART1_UART_Init(void)
{

  /* USER CODE BEGIN USART1_Init 0 */

  /* USER CODE END USART1_Init 0 */

  /* USER CODE BEGIN USART1_Init 1 */

  /* USER CODE END USART1_Init 1 */
  huart1.Instance = USART1;
  huart1.Init.BaudRate = 19200;
  huart1.Init.WordLength = UART_WORDLENGTH_8B;
  huart1.Init.StopBits = UART_STOPBITS_1;
  huart1.Init.Parity = UART_PARITY_NONE;
  huart1.Init.Mode = UART_MODE_TX_RX;
  huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
  huart1.Init.OverSampling = UART_OVERSAMPLING_16;
  huart1.Init.OneBitSampling = UART_ONE_BIT_SAMPLE_DISABLE;
  huart1.Init.ClockPrescaler = UART_PRESCALER_DIV1;
  huart1.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_NO_INIT;
  if (HAL_UART_Init(&huart1) != HAL_OK)
  {
    Error_Handler();
  }
  if (HAL_UARTEx_SetTxFifoThreshold(&huart1, UART_TXFIFO_THRESHOLD_1_8) != HAL_OK)
  {
    Error_Handler();
  }
  if (HAL_UARTEx_SetRxFifoThreshold(&huart1, UART_RXFIFO_THRESHOLD_1_8) != HAL_OK)
  {
    Error_Handler();
  }
  if (HAL_UARTEx_DisableFifoMode(&huart1) != HAL_OK)
  {
    Error_Handler();
  }
  /* USER CODE BEGIN USART1_Init 2 */

  /* USER CODE END USART1_Init 2 */

}

/**
  * @brief GPIO Initialization Function
  * @param None
  * @retval None
  */
static void MX_GPIO_Init(void)
{
  GPIO_InitTypeDef GPIO_InitStruct = {0};
  /* USER CODE BEGIN MX_GPIO_Init_1 */

  /* USER CODE END MX_GPIO_Init_1 */

  /* GPIO Ports Clock Enable */
  __HAL_RCC_GPIOC_CLK_ENABLE();
  __HAL_RCC_GPIOF_CLK_ENABLE();
  __HAL_RCC_GPIOH_CLK_ENABLE();
  __HAL_RCC_GPIOB_CLK_ENABLE();
  __HAL_RCC_GPIOD_CLK_ENABLE();
  __HAL_RCC_GPIOA_CLK_ENABLE();

  /*Configure GPIO pins : STLINK_RX_Pin STLINK_TX_Pin */
  GPIO_InitStruct.Pin = STLINK_RX_Pin|STLINK_TX_Pin;
  GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
  GPIO_InitStruct.Pull = GPIO_NOPULL;
  GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
  GPIO_InitStruct.Alternate = GPIO_AF7_USART3;
  HAL_GPIO_Init(GPIOD, &GPIO_InitStruct);

  /* USER CODE BEGIN MX_GPIO_Init_2 */

  /* USER CODE END MX_GPIO_Init_2 */
}

/* USER CODE BEGIN 4 */


/**
 * @brief  This function is executed in case of error occurrence.
 * @param  file The file name as a string.
 * @param  line The line number in the file.
 * @retval None
 */
void _Error_Handler(const char *file, int line)
{
	__disable_irq();

#ifdef REDIRECT_PRINTF
	char buf[80];

	snprintf(buf, sizeof(buf),
			"Trapped in _Error_Handler(). Called from: %s, line: %d\r\n",
			file, line);
	printf("%s", buf);
#endif

	/* User can add an implementation to report the HAL error return state. */
	while(1)
	{
	}
}

/* USER CODE END 4 */


#ifdef  USE_FULL_ASSERT
/**
  * @brief  Reports the name of the source file and the source line number
  *         where the assert_param error has occurred.
  * @param  file: pointer to the source file name
  * @param  line: assert_param error line source number
  * @retval None
  */
void assert_failed(uint8_t *file, uint32_t line)
{
  /* USER CODE BEGIN 6 */
  /* assert_param() can fire from inside any HAL call -- including before
   * REDIRECT_PRINTF's UART is fully set up, or from an unusual calling
   * context -- so this only attempts to report when REDIRECT_PRINTF is
   * available, then halts either way. Continuing after a failed HAL
   * parameter check is undefined behavior, not something safe to log
   * and ignore. */
  __disable_irq();

#ifdef REDIRECT_PRINTF
  {
    char buf[96];

    snprintf(buf, sizeof(buf),
             "assert_param failed: file %s, line %lu\r\n",
             (char *)file, (unsigned long)line);
    printf("%s", buf);
  }
#endif

  while (1)
  {
  }
  /* USER CODE END 6 */
}
#endif /* USE_FULL_ASSERT */

Validating the driver

Alongside the normal demo path, this project includes the same destructive W25QValidation suite as its SPI counterpart (enabled via W25Q_VALIDATION_TEST_ENABLE in main.c — it overwrites the first sector, so it’s kept separate from the normal demo path), ported over with no changes beyond the constructor’s parameter type: an erased-state check (every byte reads 0xFF after erase), a small read/write round trip, a 20-pattern stuck-at/bit-coupling sweep, and a page-boundary test exercising writeBuffer()‘s internal page-splitting. The suite itself is transport-agnostic — it only calls the public W25QFlashQSPI methods, the same way the SPI version only calls W25QFlashSPI‘s. One representative test:

C
bool W25QValidation::runErasedStateTest() {
	_report.testsRun++;
	_report.failedTest = W25Q_VALIDATION_TEST_ERASED_STATE;
	_report.failedPhase = W25Q_VALIDATION_PHASE_ERASE;

	printf("[TEST 1] %s\r\n", getTestName(W25Q_VALIDATION_TEST_ERASED_STATE));

	bool erased = _flash.eraseSector(W25Q_VALIDATION_BASE_ADDR);
	if (!erased) {
		recordFailure(W25Q_VALIDATION_TEST_ERASED_STATE, W25Q_VALIDATION_PHASE_ERASE,
				W25Q_VALIDATION_BASE_ADDR, 0xFF, 0x00);
		printFailure();
		return false;
	}

	_report.failedPhase = W25Q_VALIDATION_PHASE_VERIFY;

	uint8_t buf[W25Q_VALIDATION_SMALL_RANGE_SIZE];
	bool readOk = _flash.readBuffer(W25Q_VALIDATION_BASE_ADDR, buf, sizeof(buf));
	if (!readOk) {
		recordFailure(W25Q_VALIDATION_TEST_ERASED_STATE, W25Q_VALIDATION_PHASE_VERIFY,
				W25Q_VALIDATION_BASE_ADDR, 0xFF, 0x00);
		printFailure();
		return false;
	}

	for (uint32_t offset = 0; offset < sizeof(buf); offset++) {
		if (buf[offset] != 0xFF) {
			recordFailure(W25Q_VALIDATION_TEST_ERASED_STATE, W25Q_VALIDATION_PHASE_VERIFY,
					W25Q_VALIDATION_BASE_ADDR + offset, 0xFF, buf[offset]);
			printFailure();
			return false;
		}
	}

	printf("  PASS\r\n");
	return true;
}

This tutorial’s own hardware run used the normal demo path, not this suite — see “Real hardware output” below. Running the full suite is still the right first step if you push the QUADSPI clock prescaler faster than this project’s conservative default (255): as the SPI companion post found the hard way, a light JEDEC-ID spot-check surviving repeated resets doesn’t validate a clock speed the way a full erase/write/read regression pass does. That kind of speed-tuning work — along with actually using the chip’s four-line Quad Read / Quad Page Program commands this driver wires for but doesn’t yet issue — is exactly the “QUADSPI DMA / memory-mapped mode” follow-up this post’s project has queued for later, not something this tutorial itself does.

Real hardware output

This project’s own hardware bring-up ran into a real wiring fault worth walking through, since it’s a good demonstration of the diagnostic print described above. The first boot printed W25Q flash NOT detected -- check wiring — but critically, the boot log showed nothing else wrong before that: no hang in Error_Handler(), meaning Init()‘s reset commands and the JEDEC ID read had all completed as clean QUADSPI transactions. That already rules out a totally dead bus (bad clock, bad power) — something was responding, just not with a valid chip ID.

The added w25qReadJEDECIDRaw() diagnostic print confirmed it: transfer OK, bytes 0x000000. A clean transfer that reads back all zero bytes is the signature of a floating or unselected response line — most often a chip that’s never actually being selected, so its outputs stay high-impedance and the MCU’s input pins read whatever their default/floating state is. In this case, the chip-select wire was plugged into the wrong physical header pin on the Nucleo board — electrically disconnected from PB10 even though the .ioc and schematic were both correct. Moving it to the right pin fixed detection immediately, with no code changes:

Real hardware output, after correcting the chip-select wiring — JEDEC ID readback and write/read-back round trip, both clean on the QUADSPI peripheral’s default (most conservative) clock prescaler.

0xEF4018 is Winbond’s manufacturer ID (0xEF), the standard W25Q family code (0x40), and the 128 Mbit capacity byte (0x18) — confirming the board on hand was the Adafruit 5634. Unlike the SPI companion post, no clock-speed tuning was needed here: this ran cleanly on QUADSPI’s default prescaler of 255 on the very first successful attempt once the wiring was corrected.

Documentation

This project includes Doxygen-generated documentation built from the inline comments throughout the flash driver, the validation suite, and the C/C++ bridge layer — including the QSPI-specific thread-safety warning and the sector-alignment gotcha called out in the code above.

The documentation is available as a separate download in the Project Downloads section below. Once downloaded, view it locally by opening:

docs/html/index.html

in a web browser.

Project Downloads

The complete project source code used in this tutorial is available for download, along with the browsable Doxygen documentation. The IOC configuration is available above in the Pinouts & Configurations section — regenerate the CubeMX project from it, then drop in the source below.

  • Flash Driver (W25Q/W25QFlashQSPI.cpp / .hpp, w25q_commands.h)
  • Validation Suite (W25Q/w25q_validation.cpp / .hpp)
  • Application Source (main.c / main.h, entryPointCPP.cpp / .hpp)
  • Linker Scripts (STM32H753ZITX_FLASH.ld / STM32H753ZITX_RAM.ld)

Browsable Doxygen documentation for the flash driver, the validation suite, and the C/C++ bridge layer:

If you have questions or run into trouble getting the boards programmed and talking to each other, post in the Tutorial Support forum and I will work through it with you. If project source is not linked in the tutorial, it may be available on request — use the email contact option in the site footer.