如果一个 FreeRTOS 任务会碰到 NVS、flash 擦写、OTA、驱动控制或 DMA/ISR 附近路径,它的栈就别放 PSRAM。 这不是洁癖,是稳定性设计。
最近在 ESP32-S3 项目里遇到一个很隐蔽的问题:开启 WiFi 后自动扫描,扫描阶段或扫描完成后设备必定卡死并重启。控制台没有 panic backtrace,也没有熟悉的 Guru Meditation,只能在下一次启动日志里看到几行 reset reason:
rst:0x8 (TG1WDT_SYS_RST)
rst:0xc (RTC_SW_CPU_RST)
上次复位原因: INT_WDT
第一眼看,锅很像在 WiFi:扫描、射频、信道切换、供电波动、LVGL 刷新,全都可疑。
最后查出来,真正的问题不在 WiFi 扫描本身,而在扫描/连接之后触发的配置保存链路:后台 NVS 写入任务用了 PSRAM 栈,进入 flash/cache 敏感窗口后访问外部栈,系统卡死到无法正常打印 panic,最终被中断看门狗复位。
一、为什么它看起来像 WiFi 崩了
问题最早发生在 WiFi 扫描过程中。加日志后,设备经常停在这些阶段:
stage=scan_running
stage=scan_channel_start
这会自然把注意力拉到扫描逻辑上。继续加逐信道打点后,也确实发现了第一个真实问题:wifi_scan_next 任务栈太小,日志一多就栈溢出。
***ERROR*** A stack overflow in task wifi_scan_next has been detected.
这个问题很好修,把扫描任务栈从 2KB 提到 4KB 后,扫描过程稳定了。
但诡异的地方来了:扫描任务栈修完后,设备仍然会 INT_WDT 重启。更关键的是,日志已经能跑到扫描结束、UI 回调结束、LVGL timer 结束:
WiFi 扫描完成
WiFi scan UI callback end
LVGL timer end
rst:0x8 (TG1WDT_SYS_RST)
上次复位原因: INT_WDT
这一步非常关键。
发生在 WiFi 操作之后,不等于 WiFi 驱动崩了。 它只说明 WiFi 动作触发了一条后续链路,而真正的死点可能藏在后面的异步副作用里。
二、真正的链路:WiFi 状态变化触发配置保存
项目里的 WiFi 开关、扫描结果、连接成功等行为,都会更新配置:
ConfigManager::set_wifi_enabled(true);
ConfigManager::set_last_connected_wifi(ssid);
ConfigManager::set_wifi_credential(...);
这些 setter 不会同步写 NVS,而是先把变更合并起来,再通过定时器唤醒后台保存任务 cfg_save_task:
nvs_set_u8(...);
nvs_set_str(...);
nvs_commit(...);
这个设计没问题,甚至是更合理的做法:UI 线程不被 NVS 写入卡住,频繁配置变更也能合并提交。
真正危险的是任务创建方式。当时为了节省内部 RAM,后台保存任务用了 PSRAM 栈:
TaskMemory::create_pinned_task_with_spiram(save_task, "cfg_save_task", ...);
对普通低优先级业务任务来说,这通常没什么问题;但对会执行 nvs_commit() 的任务来说,这个选择非常危险。
三、为什么 NVS 任务不能随便用 PSRAM 栈
ESP32-S3 的内部 RAM 和 PSRAM 不是同一种东西。
内部 RAM 是片上 SRAM,CPU 访问路径短,可靠性和实时性都更稳。PSRAM 是外挂 RAM,通常通过 SPI/OPI 接在芯片外部,访问依赖 cache 和外部存储控制器。
而 NVS 最终要写 flash。flash 写入或擦除期间,系统会进入一些 cache/flash 敏感窗口。你可以粗略理解成:芯片正在处理外部 flash,这时所有依赖外部存储/cache 的访问都应该格外谨慎。
任务栈如果放在 PSRAM,麻烦就在这里。
代码里看起来只是调用了一句:
nvs_commit(h);
但运行时 CPU 其实一直在隐式读写这个任务的栈:
函数调用栈帧
局部变量
返回地址
寄存器保存区
FreeRTOS 任务上下文
也就是说,任务只要还在运行,它就一直在碰自己的栈。如果栈在 PSRAM,而当前又进入了 flash/cache 敏感路径,系统就可能卡到无法继续调度、无法服务中断,甚至无法完整进入 panic handler。
这也是这类问题最坑的地方:代码看起来没有直接访问 PSRAM,实际却通过“栈”这个隐式路径一直在访问。
四、为什么没有正常 backtrace
普通异常通常会打印 backtrace,因为 CPU 还能跑进 panic handler,串口还能输出,栈和代码段也还处在可用状态。
这次不一样。INT_WDT / TG1WDT_SYS_RST 更像是系统已经卡死到无法正常响应中断或调度,最后被看门狗硬复位。
当问题发生在 flash/cache/PSRAM 这种敏感窗口里,panic handler 本身也未必能完整运行。于是你会看到非常迷惑的“三无现场”:
没有 Guru Meditation
没有完整 backtrace
没有明确崩溃行
只剩下一次重启后的 reset reason。
这类场景里,RTC retained marker 很有价值。它不能给出完整调用栈,但能告诉你死前最后经过了哪个阶段。把关键异步节点都打上 marker,往往比盯着最后一行串口日志更有用。
五、最终修复:NVS 保存任务改回内部 RAM 栈
修复方式很朴素:cfg_save_task 不再使用 PSRAM 栈,改用普通 FreeRTOS API 创建,让任务栈留在内部 RAM。
BaseType_t ok = xTaskCreatePinnedToCore(
save_task,
"cfg_save_task",
CONFIG_SAVE_TASK_STACK_SIZE,
nullptr,
CONFIG_SAVE_TASK_PRIORITY,
&s_save_task,
LVGL_TASK_CORE
);
旁边留一条原则性注释就够了:
/* NVS 写 flash 时 cache/PSRAM 处于敏感窗口,保存任务栈必须留在内部 RAM。 */
修完之后,WiFi 扫描、UI 回调、LVGL 刷新、连接成功、配置保存这条完整链路都稳定了。
六、PSRAM 栈可以用,但要挑任务
PSRAM 很香,尤其是 ESP32-S3 这种内部 RAM 紧张、UI 和网络又都吃内存的项目。但它不是内部 RAM 的透明替代品,更不适合无脑承接所有任务栈。
比较适合放 PSRAM 栈的任务:
普通业务逻辑
文件列表整理
媒体解析中的非实时任务
低优先级 UI 辅助任务
不接近 ISR、不接近 DMA、不写 flash 的后台任务
不建议放 PSRAM 栈的任务:
NVS / flash 写入或擦除任务
OTA 写入任务
WiFi / BLE 驱动控制任务
扫描启动/停止任务
ISR 附近任务
DMA 提交/完成相关任务
LVGL 主循环和显示 flush 路径
高实时性任务
可以把规则压成一句:
凡是会进入 flash/cache 敏感路径,或者对实时性、ISR、DMA、驱动状态机有要求的任务,栈优先放内部 RAM。
七、这次为什么特别容易误判
因为用户动作和崩溃根因不在同一个模块里。
完整链路其实是这样:
打开 WiFi
扫描完成
连接 WiFi
保存 WiFi 状态/凭据
NVS commit
PSRAM 栈在 flash/cache 敏感窗口出问题
INT WDT 重启
如果只看开头,会觉得是 WiFi;如果只看结尾,又会怀疑 LVGL;如果只盯着 reset reason,会被“没有 backtrace”拖进迷雾里。
真正要看的,是这次用户动作触发了哪些异步副作用。
嵌入式系统里的崩溃经常不是“谁最后打印日志,谁就是凶手”。很多时候,最后一行日志只是系统死前刚好还能说出口的一句话。
八、最后记住这条工程规则
PSRAM 适合放大块缓存、资源数据、低实时性业务状态;它不适合被当成内部 RAM 的平替,尤其不适合承接所有任务栈。
在 ESP32-S3 上,任务栈位置本身就是稳定性设计的一部分。
PSRAM 适合放数据,不适合无脑放所有栈;
凡是会进入 flash/cache 敏感路径的任务,栈必须留在内部 RAM。
