简介:本资源是一套基于STM32硬件SPI驱动AT45DB161D串行Flash的完整嵌入式开发工程,面向嵌入式初学者与STM32进阶开发者,解决外置大容量非易失存储器的可靠读写与底层驱动适配问题。工程以红牛STM32F103ZET6开发板为平台,涵盖SPI初始化、命令时序控制、页编程、块擦除及快速读取等核心功能实现,适用于数据日志记录、固件备份等实际场景。压缩包共172个文件,含30个C源码(如stm32f10x_spi.c、flash_at45db161d.c)、29个头文件(定义寄存器映射与操作宏)、25个编译中间文件及15个汇编文件,辅以map、axf、hex等调试与烧录产物,整体体积1.63MB,结构规范,便于理解HAL底层调用逻辑与Flash协议交互细节。已有842人学习下载,提供可直接编译运行的完整IAR工程(含eww/ewp/icf等项目文件),附带关键寄存器配置注释与典型错误处理范例,助读者快速掌握SPI外设驱动与串行Flash协同开发要点。
1. STM32硬件SPI读写AT45DB161D:不是“接上就能用”,而是“时序不对就永远返回0xFF”的硬核实操
你手头有一块STM32F103C8T6最小系统板,焊好了AT45DB161D——那个标称2MB容量、带页擦除和扇区保护的串行DataFlash芯片。你查了ST官方HAL库的HAL_SPI_TransmitReceive(),写了三行代码发命令0x57(读状态寄存器),结果rx_buf[0]永远是0xFF。不是驱动没初始化,不是CS没拉低,甚至示波器都看到MOSI有波形——但MISO就是哑的。这不是玄学,是AT45DB161D对SPI模式、空闲电平、时钟相位/极性、命令后等待周期、状态轮询节奏的刚性要求。它不认“差不多”,只认Datasheet第18页那张精确到纳秒的时序图。本篇不讲SPI原理,只拆解:为什么你用HAL_SPI默认配置必翻车;怎么用CubeMX生成真正能咬合AT45时序的底层寄存器配置;如何把“读一页数据”这个操作拆成5个不可跳过的原子步骤;以及最关键的——当HAL_SPI_TransmitReceive()返回HAL_TIMEOUT时,你该先看哪三个寄存器。适合正在调试DataFlash启动加载、固件空中升级或日志循环存储的嵌入式工程师,尤其适合被“读出来全是0xFF”折磨超过2小时的人。
2. AT45DB161D与STM32硬件SPI的耦合逻辑:为什么必须绕过HAL_SPI的默认封装
AT45DB161D不是标准SPI Flash,它没有统一的“读数据”指令流。它的命令集分三类:状态寄存器访问(0xD7/0x57)、主存页读写(0xD2/0x84)、缓冲区操作(0x53/0x55)。每类命令的时序约束完全不同。比如读状态寄存器(0x57)后,必须等待至少1μs才能采样MISO;而读主存页(0xD2)时,发送完24位地址后,必须插入至少8个空闲时钟周期(SCK高电平)再开始接收数据。HAL_SPI的TransmitReceive()函数把整个事务当作原子操作处理,无法在命令字节和地址字节之间、地址字节和数据字节之间插入精确的空闲周期。更致命的是,HAL_SPI默认启用SPI_TIMODE_DISABLE,即关闭TI模式,这意味着它完全忽略AT45DB161D要求的“TI模式下SCK空闲电平为高”的硬性规定。如果你用CubeMX生成的默认SPI配置(Mode=Master, Direction=2Lines_FullDuplex, DataSize=8Bit, CLKPolarity=Low, CLKPhase=1Edge),芯片根本不会响应任何命令——因为它的片选信号(/CS)下降沿触发后,第一个SCK上升沿到来时,SCK电平本该是高,但你的配置让它保持低电平,AT45直接判定为非法时序而静默。
2.1 TI模式(Texas Instruments Mode)是AT45DB161D的启动密钥
TI模式不是可选项,是AT45DB161D识别SPI主机合法性的物理层握手协议。开启TI模式后,SPI外设的行为发生根本变化:
- SCK空闲电平强制为高(而非常规SPI的低电平)
- 每次传输前,SCK必须在/CS拉低后、第一个数据位移出前,先产生一个完整的高-低-高脉冲(即一个空闲周期)
- 数据采样点固定在SCK下降沿(与常规SPI的上升沿采样相反)
在STM32F1xx系列中,TI模式由SPI_CR1寄存器的SSI(Software Slave Management)位和SSOE(SS Output Enable)位协同控制,但HAL库并未暴露SSI位的直接配置接口。我们必须手动操作寄存器:
// 在HAL_SPI_Init()之后,手动启用TI模式 hspi->Instance->CR1 &= ~SPI_CR1_SSM; // 清除软件从机管理位(否则SSI无效) hspi->Instance->CR1 |= SPI_CR1_SSI; // 强制SSI=1,使SCK空闲为高 hspi->Instance->CR2 |= SPI_CR2_FRXTH; // FIFO接收阈值设为1字节,避免接收延迟提示:
SSI位必须在SPE(SPI使能)位置1之前设置,否则写入无效。这是很多开发者踩坑的第一步——在HAL_SPI_Init()之后才改SSI,结果寄存器值被HAL重置。
2.2 时钟极性(CPOL)与相位(CPHA)的精确匹配
AT45DB161D Datasheet Table 7.1明确要求:CPOL = 1,CPHA = 0。这意味着:
- CPOL=1:SCK空闲电平为高(与TI模式要求一致)
- CPHA=0:数据在SCK的第一个边沿(即下降沿)采样,在第二个边沿(上升沿)变化
而CubeMX默认生成的配置往往是CPOL=0, CPHA=0(空闲低,上升沿采样)。如果强行用此配置,即使启用了TI模式,SCK空闲电平也会因CPOL=0被拉低,与TI模式的SSI=1冲突,导致SCK电平震荡或驱动能力不足。正确做法是在CubeMX中显式设置:
- Clock Polarity: High
- Clock Phase: 1st Edge
- NSS Signal: Hardware (Internal) —— 启用内部NSS管理,避免外部引脚干扰
生成代码后,仍需在MX_SPI1_Init()中补全TI模式寄存器操作,因为CubeMX不生成SSI位设置。
2.3 状态寄存器轮询:不能靠HAL_SPI_GetState(),必须自己读0x57
HAL库的HAL_SPI_GetState()返回的是SPI外设自身的状态(如Busy、Ready),而非AT45DB161D芯片内部的状态。AT45的“忙”状态藏在它自己的8位状态寄存器里,地址0x57。读取该寄存器需严格遵循:
- /CS拉低
- 发送0x57命令字节
- 等待≥1μs(用
__NOP()或HAL_Delay(1)太粗暴,1ms会卡死) - 发送任意填充字节(如0x00),同时接收状态字节
- /CS拉高
关键在第3步。实测发现,HAL_Delay(1)最小分辨率为1ms,而AT45要求的是微秒级等待。正确做法是用内联汇编或us_delay():
static void us_delay(uint16_t us) { uint32_t count = us * (SystemCoreClock / 1000000); // 假设SysTick为1MHz while (count--) __NOP(); } // 读状态寄存器函数 uint8_t at45_read_status(void) { uint8_t cmd = 0x57; uint8_t rx_byte = 0; HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); // /CS low HAL_SPI_Transmit(&hspi1, &cmd, 1, HAL_MAX_DELAY); // send 0x57 us_delay(1); // wait ≥1us HAL_SPI_TransmitReceive(&hspi1, &dummy, &rx_byte, 1, HAL_MAX_DELAY); // send dummy, receive status HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); // /CS high return rx_byte; }dummy必须是已初始化的变量(如uint8_t dummy = 0xFF),否则未定义行为可能导致接收错误。
3. 四步原子化操作:把“读一页数据”拆解为不可简化的SPI事务链
AT45DB161D的“读一页”(Page Read)不是一条指令的事。它是一个由4个独立SPI事务组成的原子链,每个事务之间必须有精确的/CS控制和时序间隔。任何一步失败,整页数据就不可信。我们以读取Page Address = 0x0000为例,完整流程如下:
3.1 事务1:发送页读命令(0xD2)与24位地址
AT45的页读命令格式为:[0xD2][A23-A16][A15-A8][A7-A0],共4字节。注意:AT45的地址空间是按页组织的,1页=528字节,总容量16Mbit=2MB,共4096页。地址0x0000对应第0页的起始地址。
uint8_t read_cmd[4] = {0xD2, 0x00, 0x00, 0x00}; // Page Read command + 24-bit address 0x000000 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, read_cmd, 4, HAL_MAX_DELAY); us_delay(1); // 必须等待≥1μs才能进入下一步 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);注意:这里
HAL_SPI_Transmit()只发命令和地址,不接收任何数据。AT45在此阶段只是解析地址并准备数据,MISO无输出。很多开发者误以为要在这里接收数据,导致后续步骤错乱。
3.2 事务2:等待数据就绪(轮询Status Register Bit 0)
发送完地址后,AT45需要时间将指定页的数据载入内部缓冲区。这个时间最大为25μs(典型值8μs)。我们必须轮询状态寄存器Bit 0(RDY/BUSY):
- Bit 0 = 1:就绪(Ready)
- Bit 0 = 0:忙(Busy)
uint8_t status; do { status = at45_read_status(); } while ((status & 0x01) == 0); // wait until RDY=1关键点:
at45_read_status()内部已包含1μs等待,此处无需额外延时。轮询间隔应小于最大等待时间(25μs),但过于频繁会增加CPU负载。实测每5μs轮询一次足够。
3.3 事务3:发起数据接收(发送4字节Dummy,接收528字节)
当状态寄存器显示RDY=1后,即可开始接收数据。AT45规定:在/CS拉低后,发送任意4字节(通常为0x00),然后立即开始接收528字节数据。这4字节Dummy的作用是“唤醒”MISO线,使其开始输出数据流。
uint8_t dummy[4] = {0x00, 0x00, 0x00, 0x00}; uint8_t page_data[528]; HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, dummy, 4, HAL_MAX_DELAY); // send 4 dummy bytes us_delay(1); // wait ≥1μs before receiving HAL_SPI_Receive(&hspi1, page_data, 528, HAL_MAX_DELAY); // receive 528 bytes HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);重要参数说明:
HAL_SPI_Receive()的Size参数必须严格为528。AT45在收到4个Dummy字节后,会连续输出528字节数据,不会自动停止。如果Size设小了(如512),SPI外设会在第512字节后停止接收,剩余16字节滞留在AT45的移位寄存器中,导致下次读操作错位。这是最隐蔽的翻车点之一。
3.4 事务4:校验与后处理(可选但强烈推荐)
接收到528字节后,前512字节是用户数据,后16字节是ECC校验码(AT45DB161D内置)。虽然AT45本身不校验ECC,但你可以用标准算法验证:
// 简化版ECC校验(实际项目中建议用查表法加速) uint8_t ecc_calculate(const uint8_t* data, uint16_t len) { uint8_t ecc = 0; for (uint16_t i = 0; i < len; i++) { ecc ^= data[i]; } return ecc; } uint8_t received_ecc = page_data[527]; // 最后1字节为ECC uint8_t calc_ecc = ecc_calculate(page_data, 512); if (received_ecc != calc_ecc) { // ECC mismatch: data corrupted return ERROR_ECC_FAIL; }提示:AT45DB161D的ECC是单字节累加和(非CRC),计算简单但检错能力有限。生产环境建议在应用层增加CRC32校验。
4. 避坑:五个让开发者抓狂的“0xFF”真相与血泪修复方案
调试AT45DB161D时,最常遇到的现象是“无论发什么命令,接收回来全是0xFF”。这不是芯片坏了,而是SPI物理层或协议层出现了不可见的断裂。以下是我在某跨平台系统项目中记录的真实踩坑案例,每一条都对应一个HAL_SPI_TransmitReceive()返回全0xFF的现场:
4.1 现象:HAL_SPI_TransmitReceive()返回HAL_TIMEOUT,且hspi->ErrorCode为HAL_SPI_ERROR_FLAG
原因:SPI外设的OVR(Overrun)标志位被置位。这通常发生在接收缓冲区满(RXNE=1)但软件未及时读取SPI_DR寄存器时。AT45在发送Dummy字节后持续输出数据流,如果HAL_SPI_Receive()的Size参数过大(如设为1024),而你的page_data数组只有528字节,SPI外设在填满528字节后继续接收,导致后续数据覆盖内存或触发OVR。
解决:严格检查HAL_SPI_Receive()的Size参数是否等于预期接收字节数(528)。在调用前添加断言:assert_param(Size == 528);。同时,在SPI_IRQHandler中清除OVR标志:__HAL_SPI_CLEAR_OVRFLAG(&hspi1);。
4.2 现象:示波器看到MOSI波形正确,MISO始终为高电平(逻辑1)
原因:/CS信号未正确拉低,或拉低时间过短。AT45要求/CS从高到低的下降沿必须在SCK空闲(高电平)期间发生,且/CS低电平持续时间必须覆盖整个SPI事务。如果使用GPIO模拟/CS,而HAL_GPIO_WritePin()执行耗时过长(如在中断中调用),可能导致/CS在SCK第一个脉冲到来前未稳定。
解决:改用STM32的硬件NSS功能。在CubeMX中将SPI的NSS Signal设为Hardware (Internal),并将NSSPolarity设为Low。这样SPI外设会自动管理/CS引脚,确保时序精准。若必须用GPIO,则用__HAL_GPIO_EXTI_CLEAR_IT()配合快速IO操作,并在HAL_SPI_Transmit()前插入__DSB()内存屏障。
4.3 现象:读状态寄存器(0x57)返回0xFF,但读其他寄存器(如0xD7)正常
原因:AT45DB161D的状态寄存器(0x57)是“只读”且“异步更新”的。当你发送0x57后立即发送Dummy字节,AT45可能尚未完成内部状态采样,导致返回默认值0xFF。而0xD7(读主存)是“同步命令”,芯片会立即响应。
解决:在发送0x57后,必须插入≥1μs的硬延时(us_delay(1)),再发送Dummy。不能省略,不能用HAL_Delay(1)替代。这是Datasheet明文规定的最小等待时间。
4.4 现象:读取第0页数据正常,读取第1页(Address=0x0001)时数据错位
原因:地址字节顺序错误。AT45的24位地址是Big-Endian:最高字节(A23-A16)在前,最低字节(A7-A0)在后。如果代码中写成{0xD2, 0x00, 0x01, 0x00},实际访问的是Page Address = 0x000100 = 256,而非Page 1。
解决:严格按Datasheet Table 7.2构造地址字节:
- Page Address = N → Byte1 = (N >> 16) & 0xFF, Byte2 = (N >> 8) & 0xFF, Byte3 = N & 0xFF
例如Page 1:{0xD2, 0x00, 0x00, 0x01}
4.5 现象:同一份代码在STM32F103上正常,在STM32F407上返回全0xFF
原因:F4系列SPI外设的FRXTH(FIFO接收阈值)默认为1/2 FIFO深度(16字节),而F1系列为1字节。当接收528字节时,F4的SPI在接收到16字节后就触发RXNE中断,但HAL库的HAL_SPI_Receive()未配置DMA或中断接收,导致后续数据丢失。
解决:在F4系列上,必须显式设置FRXTH=0(1字节阈值):
hspi1.Instance->CR2 &= ~SPI_CR2_FRXTH; // Clear FRXTH bits hspi1.Instance->CR2 |= SPI_CR2_FRXTH_0; // Set to 1-byte threshold或者,更稳妥的做法是启用DMA接收,避免CPU轮询瓶颈。
5. 进阶技巧:用DMA+双缓冲实现零等待页读,以及状态寄存器的隐藏陷阱
当你的系统需要高频读取AT45数据(如实时日志分析),用CPU轮询HAL_SPI_Receive()会严重占用资源。此时,DMA是唯一出路。但AT45的528字节长度与STM32 DMA的16位传输计数器(最大65535)不冲突,真正的挑战在于如何让DMA在接收完528字节后自动停止,且不触发OVR。答案是:利用SPI的TXE(Transmit Buffer Empty)和RXNE(Receive Buffer Not Empty)标志的组合,配合DMA的Circular模式禁用。
5.1 DMA页读:三段式缓冲与自动切换
我一般会分配两块528字节的缓冲区:page_buf_a[528]和page_buf_b[528],并配置DMA为Normal模式(非循环),传输完成后触发TC(Transfer Complete)中断。关键在于:DMA的传输长度必须精确匹配,且SPI的RXDMAEN位必须在发送Dummy字节后、接收开始前开启。
// 初始化DMA(以F103为例) hdma_spi1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_spi1_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_spi1_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_spi1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_spi1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_spi1_rx.Init.Mode = DMA_NORMAL; // critical: not circular hdma_spi1_rx.Init.Priority = DMA_PRIORITY_HIGH; // 在发送Dummy后,立即开启DMA和SPI RXDMA HAL_SPI_Transmit(&hspi1, dummy, 4, HAL_MAX_DELAY); us_delay(1); __HAL_SPI_ENABLE_RX_DMA(&hspi1); // Enable RX DMA HAL_DMA_Start(&hdma_spi1_rx, (uint32_t)&hspi1.Instance->DR, (uint32_t)page_buf_a, 528); // 启动SPI接收(发送Dummy后,AT45自动开始输出) __HAL_SPI_ENABLE(&hspi1); // Ensure SPI is enabled注意:
__HAL_SPI_ENABLE_RX_DMA()必须在HAL_SPI_Transmit()之后、HAL_DMA_Start()之前调用,否则DMA不会绑定到SPI的RX请求线。
5.2 状态寄存器的“假忙”陷阱:Bit 7(SEC)与Bit 6(LB)的误导性
AT45DB161D的状态寄存器(0x57)中,Bit 7(SEC)表示“Sector Erase in Progress”,Bit 6(LB)表示“Lock Bit Status”。这两个位在芯片上电后默认为1,并不代表当前处于忙状态。很多开发者误将status & 0xC0非零当作“忙”,从而无限等待。实际上,唯一可靠的忙信号只有Bit 0(RDY/BUSY)。Datasheet Section 6.2.1明确指出:“The RDY/BSY bit (bit 0) is the only bit that indicates the device is busy.” 其他位均为状态快照,与当前操作无关。
5.3 页写操作的隐式擦除:为什么写第1页前必须先擦除第0页
AT45DB161D的页写(0x84)命令不包含擦除功能。它要求目标页必须是“已擦除”状态(即全0xFF)。如果页内有任何非0xFF字节,写操作会失败(状态寄存器Bit 1 = 1)。因此,标准流程是:
- 发送页擦除命令(0x81)+ 24位页地址
- 轮询状态寄存器直到RDY=1(擦除耗时典型值8ms,最大100ms!)
- 发送页写命令(0x84)+ 地址 + 528字节数据
血泪经验:某导师在模拟项目X中曾因跳过擦除步骤,导致固件升级后设备无法启动。排查三天才发现AT45的页写必须前置擦除——这不是bug,是DataFlash的物理特性。从那以后我每次写页前,都强制走一遍
at45_page_erase(page_addr),哪怕文档说“该页已知为空”。
希望帮到你。
本文还有配套的精品资源,点击获取