STM32 SPI Basics: Reading and Writing a W25Q Serial Flash Chip
SPI (Serial Peripheral Interface) is the bus of choice when you need speed: TFT displays, SD cards, external flash memory, fast ADCs and radio modules such as the nRF24L01 all use it. It runs at many megahertz, much faster than I2C, and its protocol is simple enough that you can understand every byte on the wire.
This tutorial teaches SPI on STM32 using a Winbond W25Q series flash chip, available as small breakout modules. Flash memory is a good learning device because it has a clear command set, gives a known ID you can check, and lets you store data that survives power loss, such as logs or settings. You will read the JEDEC ID, erase a 4 KB sector, write a page and read it back.
How SPI Works
SPI uses four signals. SCK is the clock from the master. MOSI (Master Out Slave In) carries data to the device, and MISO (Master In Slave Out) carries data back. CS (chip select, also called NSS or SS) is pulled low to select one device. Data moves in both directions at the same time: for every byte you send, you receive one byte. When you only want to read, you send dummy bytes (usually 0xFF) to generate the clock.
The clock polarity (CPOL) and phase (CPHA) together form the SPI mode. The W25Q series supports mode 0 (CPOL = 0, CPHA = 0) and mode 3. Mode 0 is used here: the clock idles low, and data is sampled on the first (rising) edge.
| Command | Code | Bytes after the code | Purpose |
|---|---|---|---|
| JEDEC ID | 0x9F | Read 3 bytes | Manufacturer, memory type, capacity |
| Write Enable | 0x06 | None | Must precede every erase or program |
| Read Status Register-1 | 0x05 | Read 1 byte | Bit 0 = BUSY, bit 1 = WEL |
| Sector Erase (4 KB) | 0x20 | 3 address bytes | Sets all bytes in the sector to 0xFF |
| Page Program | 0x02 | 3 address bytes + 1 to 256 data bytes | Writes within one 256-byte page |
| Read Data | 0x03 | 3 address bytes, then read | Reads any number of bytes |
Flash has one rule that surprises beginners: programming can only change bits from 1 to 0. To get 1s back you must erase, and the smallest erasable unit is a 4 KB sector. So the normal cycle is erase, then program, then read.
Wiring
| W25Q module pin | Nucleo-F401RE | Blue Pill |
|---|---|---|
| VCC | 3V3 | 3.3 |
| GND | GND | G |
| CLK | PA5 (SPI1_SCK, D13) | PA5 (SPI1_SCK) |
| DO (MISO) | PA6 (SPI1_MISO, D12) | PA6 (SPI1_MISO) |
| DI (MOSI) | PA7 (SPI1_MOSI, D11) | PA7 (SPI1_MOSI) |
| CS | PB6 (GPIO output, D10) | PA4 (GPIO output) |
| WP and HOLD | 3V3 (if not pulled up on the module) | 3.3 |
CubeMX Configuration
- Under Connectivity, SPI1, set Mode to Full-Duplex Master and Hardware NSS Signal to Disable. We drive CS ourselves as a GPIO.
- Set Data Size 8 Bits, First Bit MSB First, Clock Polarity Low, Clock Phase 1 Edge. This is SPI mode 0.
- Set the Prescaler to 8. SPI1 is on APB2 (84 MHz), so the baud rate becomes 10.5 Mbit/s, well within the chip's limit and safe on jumper wires.
- Set PB6 to GPIO_Output with user label FLASH_CS and GPIO output level High, so the chip is deselected at power-up.
- Enable USART2 for printing and generate the code.
W25Q Driver and Test
/* USER CODE BEGIN Includes */
#include <stdio.h>
#include <string.h>
/* USER CODE END Includes */
/* USER CODE BEGIN 0 */
#define CS_LOW() HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET)
#define CS_HIGH() HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET)
int _write(int file, char *ptr, int len)
{
(void)file;
HAL_UART_Transmit(&huart2, (uint8_t *)ptr, (uint16_t)len, HAL_MAX_DELAY);
return len;
}
static void w25q_read_id(uint8_t id[3])
{
uint8_t tx[4] = { 0x9F, 0xFF, 0xFF, 0xFF };
uint8_t rx[4] = { 0 };
CS_LOW();
HAL_SPI_TransmitReceive(&hspi1, tx, rx, 4, 100); /* full duplex */
CS_HIGH();
memcpy(id, &rx[1], 3); /* rx[0] arrived while sending 0x9F */
}
static void w25q_write_enable(void)
{
uint8_t cmd = 0x06;
CS_LOW();
HAL_SPI_Transmit(&hspi1, &cmd, 1, 100);
CS_HIGH();
}
/* Poll Status Register-1 until BUSY (bit 0) clears */
static void w25q_wait_busy(void)
{
uint8_t cmd = 0x05, status;
CS_LOW();
HAL_SPI_Transmit(&hspi1, &cmd, 1, 100);
do {
HAL_SPI_Receive(&hspi1, &status, 1, 100); /* keeps clocking dummy bytes */
} while (status & 0x01);
CS_HIGH();
}
static void w25q_sector_erase(uint32_t addr)
{
uint8_t cmd[4] = { 0x20, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr };
w25q_write_enable();
CS_LOW();
HAL_SPI_Transmit(&hspi1, cmd, 4, 100);
CS_HIGH(); /* erase starts when CS goes high */
w25q_wait_busy();
}
/* Write up to 256 bytes; must not cross a 256-byte page boundary */
static void w25q_page_program(uint32_t addr, const uint8_t *data, uint16_t len)
{
uint8_t cmd[4] = { 0x02, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr };
w25q_write_enable();
CS_LOW();
HAL_SPI_Transmit(&hspi1, cmd, 4, 100);
HAL_SPI_Transmit(&hspi1, (uint8_t *)data, len, 100);
CS_HIGH();
w25q_wait_busy();
}
static void w25q_read(uint32_t addr, uint8_t *buf, uint16_t len)
{
uint8_t cmd[4] = { 0x03, (uint8_t)(addr >> 16), (uint8_t)(addr >> 8), (uint8_t)addr };
CS_LOW();
HAL_SPI_Transmit(&hspi1, cmd, 4, 100);
HAL_SPI_Receive(&hspi1, buf, len, 100);
CS_HIGH();
}
/* USER CODE END 0 */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init(); /* PB6 = FLASH_CS, starts high */
MX_USART2_UART_Init();
MX_SPI1_Init(); /* mode 0, 8-bit, MSB first, 10.5 Mbit/s */
/* USER CODE BEGIN 2 */
uint8_t id[3];
w25q_read_id(id);
printf("\r\nJEDEC ID: %02X %02X %02X\r\n", id[0], id[1], id[2]);
if (id[0] != 0xEF)
{
printf("Not a Winbond chip or wiring problem\r\n");
}
const uint32_t addr = 0x000000; /* first sector */
const char msg[] = "Hello from STM32 over SPI!";
uint8_t readback[sizeof(msg)] = { 0 };
w25q_sector_erase(addr);
w25q_read(addr, readback, 4);
printf("After erase: %02X %02X %02X %02X\r\n",
readback[0], readback[1], readback[2], readback[3]);
w25q_page_program(addr, (const uint8_t *)msg, sizeof(msg));
w25q_read(addr, readback, sizeof(msg));
printf("Read back: %s\r\n", (char *)readback);
printf("Verify: %s\r\n",
memcmp(msg, readback, sizeof(msg)) == 0 ? "OK" : "FAILED");
/* USER CODE END 2 */
while (1)
{
}
}
Expected output with a W25Q32: JEDEC ID: EF 40 16 After erase: FF FF FF FF Read back: Hello from STM32 over SPI! Verify: OK
Key Points in the Code
- The JEDEC ID's first byte 0xEF identifies Winbond. The third byte encodes capacity: 0x16 for the 32 Mbit W25Q32, 0x17 for the 64 Mbit W25Q64 and 0x18 for the 128 Mbit W25Q128.
HAL_SPI_TransmitReceiveis used for the ID so you can see that SPI is full duplex. The first received byte is meaningless because the chip was still receiving the command.HAL_SPI_Receivein full-duplex master mode still has to clock the bus, so the HAL sends the receive buffer's current contents as dummy bytes. The flash ignores MOSI while it is sending data, so this is safe here.- CS must stay low for the entire command, and erase and program operations only begin when CS returns high. The driver then polls the BUSY bit before doing anything else.
- Write Enable is cleared automatically after each erase or program, so it must be sent again every time.
Troubleshooting
- ID reads FF FF FF: MISO not connected, or CS never goes low. Check the FLASH_CS label matches the pin.
- ID reads 00 00 00: chip not powered, or WP and HOLD are floating. HOLD low pauses the chip.
- Read-back shows old data mixed with new: you programmed without erasing first.
- Data wraps to the start of the page: the write crossed a 256-byte page boundary. Split long writes at page boundaries.
- Random errors at high speed with jumper wires: increase the SPI prescaler to 16 or 32.
Summary
You configured SPI1 in mode 0 with a software chip select, read a W25Q flash chip's JEDEC ID, and implemented the erase, program and read cycle using HAL_SPI_Transmit, HAL_SPI_Receive and HAL_SPI_TransmitReceive. The same pattern of command byte, address bytes and data applies to most SPI devices. Next, you will offload data transfers from the CPU using DMA.