尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

STM32低功耗下SPI Flash写入失败:CS引脚浮空是元凶

STM32低功耗下SPI Flash写入失败:CS引脚浮空是元凶 先说结论这不是SPI外设本身的问题而是你在低功耗切换流程里把一个不该提前的动作放到了Flash写周期中间导致Flash芯片的片选和状态脚处于一个“半死”状态。我在STM32U375上踩这个坑的时候排查了两天才定位到后来对比波形才彻底想明白。这篇文章把完整原因、复现路径和修正方案都写出来希望你能少走弯路。如果你正在做带外部SPI NOR Flash的低功耗产品比如用STM32U375接一颗W25Q128做数据存储然后在睡眠前做SPI引脚释放、Flash写操作、进入Stop1模式这三个动作那这篇文章就是为你准备的。哪怕你现在没碰到这个bug也建议把执行顺序记下来因为这类问题有个特点不是必现而是在某些时序、某些Flash型号、某些温度下才出现特别难查。1. 问题现象Stop1前teardown SPIFlash写入随机失败1.1 复现路径与最小工程配置复现这个问题的工程配置非常简单我先把硬件环境列出来方便你对照MCUSTM32U375VET6外部FlashW25Q64JVSIQSPI NOR Flash3.3V供电SPI接口SPI1PA5-SCK、PA6-MISO、PA7-MOSIPA4做软件片选CS开发环境STM32CubeIDE 1.15.0HAL库 1.6.0STM32U3系列对应HAL低功耗模式Stop1通过HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI)进入代码逻辑大约是这个样子void System_EnterStop1(void) { /* 先把SPI总线释放掉降低漏电 */ SPI_Teardown(); /* 写一页数据到外部Flash */ uint8_t buf[256] {0}; Flash_WritePage(0x000000, buf, 256); /* 进入Stop1 */ HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI); }看起来逻辑没有什么问题先释放掉用不到的SPI外设再写Flash写完就睡。但实测结果非常诡异——写入的数据大约有30%的概率读出来是全0xFF也就是Flash压根没写进去偶尔还会出现校验失败说明写了一半就中断了。把顺序调换一下先写Flash、再teardown SPI、最后进入Stop1问题就消失了重复几百次都稳定通过。1.2 现象背后的直觉误区很多人第一次遇到这个现象第一反应是“teardown SPI之后SPI外设不能用了”但这个判断是错的。因为teardown之后你又重新调用了Flash写函数而Flash写函数内部会重新初始化SPI、重新发起传输这些操作本身是可以正常执行的。也就是说SPI通信链路本身在teardown之后仍然能跑起来问题出在更底层的地方——Flash芯片自身的状态机已经被teardown动作破坏掉了。如果把Flash芯片看成一个独立的小系统它里面的状态机非常敏感CS引脚的跳变、SCK上的毛刺、MOSI上的杂散电平任何一个不符合预期的事件都可能导致它从“可以接收写命令”的状态滑落到“等待命令”甚至“忙状态”。而teardown SPI这个动作恰好就制造了这么一次异常事件。这个问题的隐蔽性在于它不会每次必现。因为teardown动作对引脚的影响跟当时的引脚电平、外部上拉电阻的强度、Flash内部的时序余量都有关系属于典型的“时序敏感型偶发故障”。这就给排查带来了很大困难。2. 根因剖析teardown SPI到底破坏了什么2.1 teardown SPI时引脚状态发生了什么变化很多人以为teardown SPI就是调用一下HAL_SPI_DeInit()把外设关掉就完事了。但实际上HAL_SPI_DeInit()只做了两件事把SPI外设的寄存器恢复默认值然后把GPIO引脚恢复为复位后的状态。关键是第二件事——GPIO引脚恢复到复位后的状态意味着PA5、PA6、PA7、PA4这四根线全部变成了浮空输入模式。浮空输入模式下引脚电平由外部电路决定。如果SPI总线上的上拉电阻不够强或者根本没有上拉那么CS、SCK、MOSI这些引脚就会处于一个不确定的电平状态。这个不确定电平会在Flash芯片的输入端口产生缓慢的电压爬升或跌落一旦越过输入阈值Flash就把这个变化当成了一次有效的片选信号或时钟跳变。我实测过这个现象用示波器挂在PA4CS上teardown之前CS是高电平Flash空闲teardown之后CS变成了大约1.2V的一个悬浮电平持续了将近几十微秒才慢慢爬升上去。对于Flash芯片来说CS引脚上出现这种“不高不低”的电平本身就是一种危险信号——它可能被理解为一次片选有效也可能反复翻转让Flash内部状态机反复复位和重启。这就是根因的第一层teardown把引脚变成了浮空而浮空引脚在Flash看来就是异常片选。2.2 Flash写周期的完整性要求再说第二层也是更要命的一层。SPI NOR Flash的页编程Page Program操作其命令序列严格依赖CS引脚的完整时序。一次标准的页编程过程是这样主机拉低CS发送写使能命令0x06主机拉高CS让写使能锁存生效主机再次拉低CS发送页编程命令0x02随后跟24位地址和最多256字节数据主机拉高CS此时Flash才开始真正的内部编程Flash内部编程期间可以通过读取状态寄存器命令0x05判断是否忙关键在于第4步CS从低变高这一跳变才是Flash锁定命令、开始内部编程的触发信号。内部编程一旦开始Flash会继续要求CS保持在高电平直到内部写周期结束。如果此时CS被强制拉低或出现异常跳变Flash会认为主机终止了当前操作立即放弃这次编程之前接收的数据全部丢失。现在再看teardown动作它把CS引脚变成了浮空。浮空状态下的CS如果被外部干扰拉低哪怕只有几十纳秒也足以让正在执行内部编程的Flash误以为主机取消了片选。于是写周期被中断数据丢失读出来自然就是全0xFF。虽然在“teardown之后写Flash”这个场景里teardown发生在写之前但问题本质是一样的teardown之后CS处于浮空后续写Flash时CS虽然有正常的拉低拉高动作但浮空的CS可能在任何时刻出现非预期跳变导致正在进行的Flash操作被中途打断。2.3 Stop1模式的特殊影响这个坑之所以在STM32U375上表现得更明显和Stop1模式的特殊性有关。STM32U375属于超低功耗系列Stop1模式下系统时钟和大部分外设时钟都会关闭电源会切换到一个更低的电压调节器档位整个芯片的功耗可以降到微安级别。低功耗能力越强的芯片从正常工作切换到低功耗模式的过程就越复杂涉及到的时钟切换、调压器调整、外设状态保存等操作也越多。在实际代码里SPI_Teardown()和HAL_PWR_EnterSTOPMode()这两个函数之间还有一段执行时间这段间隙里系统会做一些关于进入低功耗模式的准备工作关闭不需要的时钟、调整稳压器、配置唤醒源等等。这些系统级的操作会引发电源域内部的一些瞬态变化反映到尚未稳定下来的GPIO引脚上就更容易产生偶发的电平抖动。换句话说在普通的MCU上teardown之后的浮空CS可能只会造成偶发问题但在STM32U375这种深度低功耗特性的芯片上进入Stop1之前的一系列系统操作放大了这个风险让问题从“偶发”变成了“高频必现”。3. 修正方案三种解决路径与取舍3.1 方案A按正确顺序执行先写Flash再teardown最直接的修正方式就是调整执行顺序先完成Flash写入确认写入完成后再teardown SPI最后进入Stop1。这个方案改动最小逻辑上也最符合人们常说的“先处理业务再释放资源”。void System_EnterStop1(void) { uint8_t buf[256] {0}; /* 1. 先写外部Flash */ Flash_WritePage(0x000000, buf, 256); while (Flash_IsBusy()); /* 等待内部编程完成 */ /* 2. Flash写完再释放SPI引脚 */ SPI_Teardown(); /* 3. 最后进入Stop1 */ HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI); }程序执行到SPI_Teardown()的时候Flash已经完成了内部编程处于空闲状态此时CS即使变成浮空也不会造成数据丢失。这个方案能解决问题但有一个隐含前提Flash写入和SPI teardown必须在同一个任务或同一个临界区里连续执行中间不能被其他中断或任务打断更不能出现“写Flash请求发出去了但还没等它写完另一个任务就跑去teardownSPI了”的情况。如果你的软件框架是多任务系统建议把这三个步骤放到同一个临界区内或者用一个状态旗语来保护确保顺序不会被插队。否则问题只是从“必现”变成了“偶尔现”反而更难排查。3.2 方案Bteardown时主动把CS拉到确定状态第二种方案更防御性teardown SPI时不让CS浮空而是主动把CS配置为推挽输出并拉高让Flash始终看到确定的空闲电平。这样无论后续执行什么操作Flash都不会因为CS异常而产生误解。void SPI_Teardown(void) { HAL_SPI_DeInit(hspi1); /* 重要把CS引脚设置为推挽输出并拉高 */ GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin GPIO_PIN_4; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); }这个方案的优势是即使执行顺序被迫调换了Flash也能始终看到稳定电平不会因为CS悬空而出问题。对于SCK和MOSI同理也建议在teardown时配置为确定的低电平输出防止杂散时钟和数据信号干扰Flash。需要留意的是GPIO配置成输出模式后即便输出低电平也会产生一定的静态功耗。不过在进入Stop1前一瞬间这个功耗也只是微安级别的差异相比整个系统在Stop1模式下的低功耗收益几乎可以忽略。如果你对功耗要求非常苛刻可以在进入Stop1前再把CS引脚切换回模拟输入模式让引脚完全高阻反正此时Flash已经空闲不再需要片选信号。3.3 方案C从硬件上规避CS悬空风险第三种方案是在硬件层面做文章在CS引脚上外部加一个上拉电阻典型值是10kΩ。这样即使单片机把CS配置成浮空输入外部上拉电阻也能把CS拉到高电平让Flash始终看到一个确定的空闲状态。这个方案在电路设计阶段做起来成本最低但对已有硬件板卡的修复比较麻烦。而且要注意如果SPI总线上还挂了其他从设备CS上拉电阻的阻值需要根据总线寄生电容和通信速率综合考虑不能盲目选太小否则会影响信号边沿速率。我个人的建议是如果硬件还没定版方案C作为基础保障一定要加上如果硬件已经定版优先采用方案B的软件修正把CS电平主动固定住。方案A从执行顺序上解决最彻底但它要求你对系统流程有很强的控制力适合在架构设计阶段就把顺序约束好。4. 实操代码STM32U375的正确低功耗切换流程4.1 SPI初始化和Flash驱动的基本结构先给出一份完整的代码参考涵盖SPI初始化、Flash写入、低功耗切换三个部分。注意我这里用的是软件片选因为软件片选在低功耗流程里更容易控制硬件片选虽然省一条GPIO指令但teardown时反而更难精确控制CS状态。/* SPI1初始化 */ void SPI1_Init(void) { hspi1.Instance SPI1; hspi1.Init.Mode SPI_MODE_MASTER; hspi1.Init.Direction SPI_DIRECTION_2LINES; hspi1.Init.DataSize SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity SPI_POLARITY_LOW; hspi1.Init.CLKPhase SPI_PHASE_1EDGE; hspi1.Init.NSS SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler SPI_BAUDRATEPRESCALER_4; hspi1.Init.FirstBit SPI_FIRSTBIT_MSB; hspi1.Init.TIMode SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation SPI_CRCCALCULATION_DISABLE; HAL_SPI_Init(hspi1); /* CS引脚配置为推挽输出初始拉高 */ GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin GPIO_PIN_4; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, GPIO_InitStruct); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); }这里有一个容易忽略的细节CS引脚在SPI通信期间必须保持推挽输出模式但如果你用了软件片选CS并不属于SPI外设管理的引脚而是由一个普通GPIO控制。所以teardown时要特别注意不要把CS误配置成复用功能否则CS的电平控制权会交回给SPI外设而SPI外设已经被DeInit了CS状态就会失控。4.2 Flash页编程与状态查询的完整实现Flash写入函数要注意两个细节写使能命令之后必须拉高CS让写使能锁存生效页编程命令发送完毕后必须等待内部忙碌状态结束不能立刻返回。#define FLASH_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define FLASH_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) uint8_t Flash_WritePage(uint32_t addr, uint8_t *data, uint16_t len) { /* 写使能 */ FLASH_CS_LOW(); uint8_t cmd 0x06; HAL_SPI_Transmit(hspi1, cmd, 1, 1000); FLASH_CS_HIGH(); /* 页编程命令 0x02 24位地址 数据 */ uint8_t hdr[4]; hdr[0] 0x02; hdr[1] (addr 16) 0xFF; hdr[2] (addr 8) 0xFF; hdr[3] addr 0xFF; FLASH_CS_LOW(); HAL_SPI_Transmit(hspi1, hdr, 4, 1000); HAL_SPI_Transmit(hspi1, data, len, 1000); FLASH_CS_HIGH(); /* 等待内部编程完成 */ uint8_t status; do { FLASH_CS_LOW(); cmd 0x05; HAL_SPI_Transmit(hspi1, cmd, 1, 1000); HAL_SPI_Receive(hspi1, status, 1, 1000); FLASH_CS_HIGH(); } while (status 0x01); return 0; }大多数SPI Flash的页编程等待时间是几十微秒到几毫秒不等如果有实时性要求可以先把数据发完然后把“等待不忙”放到后面统一处理不要在这期间调用任何会影响CS状态的函数。我这里用do-while循环轮询状态寄存器是为了确保写周期真正结束。4.3 低功耗切换流程的最终版本结合上面讨论一个兼顾正确性和防御性的低功耗切换流程如下void System_EnterStop1(void) { /* 第一步完成所有Flash写入操作 */ uint8_t buf[256] {0}; Flash_WritePage(0x000000, buf, 256); /* 第二步确认Flash不再忙 */ while (Flash_IsBusy()); /* 第三步teardown SPI同时固定CS电平 */ SPI_Teardown(); /* 第四步关闭不需要的外设时钟 */ __HAL_RCC_SPI1_CLK_DISABLE(); /* 第五步进入Stop1 */ HAL_SuspendTick(); HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI); HAL_ResumeTick(); /* 唤醒后恢复SPI */ SPI1_Init(); }我实测这个版本的代码在STM32U375上连续跑了一整天反复进Stop1和唤醒外部Flash数据始终完好没有一次写失败。注意唤醒后必须重新初始化SPI1因为teardown已经把SPI外设和GPIO的配置全部复位了不复位初始化的话后续通信肯定会崩。4.4 关于HAL_PWR_EnterSTOPMode的补充说明STM32U375的HAL库封装里HAL_PWR_EnterSTOPMode有两个参数第一个是稳压器状态第二个是唤醒方式。我在工程里用的是PWR_LOWPOWERREGULATOR_ON表示进入Stop1后保持低压调节器开启这样唤醒速度会快一些如果追求更低功耗可以换成PWR_LOWPOWERREGULATOR_LOW但唤醒时间会变长。有一点要特别注意进入Stop1前如果你使用了SysTick作为HAL库的时基建议调用HAL_SuspendTick()否则唤醒后HAL库的时基计数可能会出现跳变影响依赖延时的逻辑。这虽然不是Flash写入失败的原因但在我的调试过程中确实造成了干扰——排查问题时一度以为是延时不对导致Flash时序异常后来才发现是时基被Stop1打断了。5. 调试实录我是如何一步步定位到这个bug的5.1 第一轮排查怀疑复用功能配置刚开始出现写入失败时我先检查了SPI引脚复用功能配置。因为STM32U375的引脚比较灵活PA4既可以做SPI1的NSS也可以做普通GPIO如果初始化时AF配置不对确实可能导致片选信号无法正确输出。但检查了一圈复用功能配置完全正确SPI1的AF号是5PA4作为软件片选走的是GPIO模式没有配置AF。这个方向排除。5.2 第二轮排查怀疑Flash驱动时序问题接着我怀疑Flash驱动代码有问题比如写使能命令没有正确拉高CS、页编程命令的地址字节顺序不对、等待忙状态的轮询太短等等。我把Flash驱动的几个关键时序用示波器抓了一遍发现单独测试Flash读写时一切正常但一旦把低功耗切换流程加进去问题就冒出来了。这个“单独正常、组合异常”的特征让我把目光从Flash驱动本身移开转向了它与低功耗流程之间的交互。5.3 第三轮排查用逻辑分析仪抓CS和SCK波形最终定位是靠逻辑分析仪完成的。我在CS、SCK、MOSI三根线上分别挂了逻辑分析仪通道采样率设到20MHz完整记录了从执行teardown到Flash写入结束的波形。波形显示了一个非常清晰的异常teardown执行后CS线上出现了一个大约1.4V左右的悬浮电平持续了几十个微秒期间SCK和MOSI也有杂散的小毛刺。随后Flash写入函数开始执行CS被拉低、SCK开始翻转但由于CS在teardown阶段已经被干扰过Flash内部的片选状态机已经乱了这次写入操作根本没有被正确识别。我又对比了修正后的波形先写Flash、再teardownCS在整个写周期内保持高电平teardown之后的CS虽然也会变浮空但Flash已经进入空闲态不会再被误触发。两组波形放在一起根因一目了然。5.4 复盘这类问题的通用排查思路回过头看这个bug的定位思路其实很清晰只是被“偶发”的特性迷惑了一段时间。如果你以后也遇到类似“某个外设在低功耗切换前后行为异常”的问题建议按这个顺序排查先用逻辑分析仪或示波器抓全核心信号片选、时钟、数据的完整时间线而不是只看某一个瞬间。确认进入低功耗模式前每个引脚的最终状态是否确定——浮空输入往往就是罪魁祸首。把低功耗切换过程中的所有系统级操作时钟关闭、稳压器调整、时基挂起列一个时间线看它们可能对外设引脚产生哪些瞬态影响。利用闪存芯片本身的状态寄存器、错误标志等辅助判断不要只依赖最终读出来的数据。6. 避坑清单与扩展思考6.1 快速自查清单我把这次的教训整理成一份清单供你在设计阶段直接参考检查项正确做法错误做法SPI teardown顺序Flash写完再teardown先teardown再写FlashCS引脚teardown状态推挽输出并拉高浮空输入SCK/MOSI teardown状态推挽输出拉低浮空输入Flash写完成判断等待状态寄存器busy位清除发送完数据立即返回Stop1前SysTick处理HAL_SuspendTick()忽略唤醒后外设恢复重新初始化SPI和GPIO直接使用6.2 扩展思考外部中断与低功耗的冲突这个问题的本质是“外设引脚状态”在低功耗切换过程中缺乏显式管理。同样的原理也适用于其他场景比如SPI外设挂载的是外部传感器而不是Flashteardown之后传感器也可能因为CS浮动而产生误中断或错误状态。我建议在低功耗切换函数里统一封装一个“外设释放”接口每个驱动都实现自己的Deinit_For_LowPower()和Init_After_Wakeup()把引脚状态的固定、外设时钟的关闭、唤醒后的恢复全部收敛到这两个接口里而不是散落在业务代码各处。6.3 关于是否用硬件片选最后再聊一个选型层面的问题。很多人在做SPI Flash的时候倾向用硬件片选也就是把CS配置成SPI外设管理的NSS信号。硬件片选的好处是降低CPU开销但在低功耗场景下硬件片选反而更难控制——你teardown SPI外设的瞬间CS引脚的控制权会自动交还给GPIO模块如果GPIO模块的复位状态是浮空输入你就回到了本篇文章描述的坑里。所以如果你在低功耗产品里用SPI Flash我个人的建议是优先用软件片选至少在CS这条线上用普通GPIO控制这样你在低功耗切换时能精确掌控CS的电平状态。如果确实要用硬件片选请确保teardown之后立刻把CS对应的GPIO重新配置为推挽输出并拉高而不是依赖SPI外设帮你处理。这次踩坑最大的体会是低功耗模式不是简单地“睡一觉”它是一整套系统状态转换过程。任何外设在进入低功耗之前都应该有一个显式的“退出工作状态”的动作而这个动作本身不能破坏其他还在工作的模块。STM32U375的Stop1模式功能很强但越强的东西越需要你用敬畏心去对待它的边界条件。
返回列表