Skip to content

Latest commit

 

History

History
196 lines (139 loc) · 17 KB

File metadata and controls

196 lines (139 loc) · 17 KB

PAL Level 2 技术总结报告

日期: 2026-07-02 ~ 2026-07-04
目标: ESP32-S3 N16R8 — C 层 PAL Core 0 (WiFi, WebSocket, TaskMgr, Watchdog, VirtualUART) + MicroPython Core 1 (Python 执行引擎)
结果: 未能实现稳定运行。 回退到 Level 1 (纯 MPY 固件)。


最终的架构设计(未成功实现)

Core 0 (C):  TaskMgr (prio 23) + WebSocket (prio 21) + Watchdog (prio 24)
Core 1 (C):  WiFi task → WebSocket task
             或
             所有业务逻辑由 MPY boot.py 在 Core 0/1 执行

遇到的技术困难(按时间顺序)

时间线:2026-06-30 ~ 07-03,整整 4 天

这不是一个周末 hack 失败的故事。这是一个大一学生面对 ESP-IDF + MPY 构建系统打了 4 天仗的真实记录。

0. 根因:ESP-IDF 的 MicroPython 过于庞大,超出了个人开发者的调试能力

这是所有问题的根源。 MPY 的 ESP32 port 不是一个简单的"把 Python 跑在 ESP32 上"——它是一个完整的操作系统级项目:

  • MicroPython VM 内核(~10 万行 C)
  • ESP-IDF 构建系统(CMake + Kconfig + Component Manager,~100 万行)
  • FreeRTOS 双核任务调度
  • SPIRAM 驱动(OCT 模式、内存映射、cache 一致性)
  • WiFi + TCP/IP 协议栈
  • 外设驱动(SPI/I2C/UART/PWM/ADC)

当你要写一个 PAL Core 0(自己的 C 代码)踩在 MPY 上面时,你必须理解上面几乎每一项——因为任何一项的配置错误都会导致编译失败运行时崩溃,而报错信息不会告诉你哪出错了。

作者不会看 CMake,不会调 Kconfig,不懂 SPIRAM cache 一致性。这些问题中的任何一个单独拿出来,对一个有经验的嵌入式工程师来说可能只是 30 分钟的活——但对一个大一学生来说,每一个都是天堑。4 天的时间里,解决的不是"怎么做 PAL 双核",而是"怎么让 MPY 和 C 共存不崩"。

1. SPIRAM 始终无法通过 sdkconfig.defaults 自动启用

  • 现象: build 后的 sdkconfig 始终显示 # CONFIG_SPIRAM is not set
  • 根因: ESP-IDF 的 Kconfig 系统中 CONFIG_SPIRAM 需要在 CONFIG_SPIRAM_MODE_OCT 之前被显式设为 y,而我们准备的 sdkconfig.spiram_octsdkconfig.pal 文件被 MPY build system 的层层 sdkconfig merge 顺序覆盖。即使用了 -DMICROPY_BOARD=ESP32_GENERIC_S3 -DMICROPY_BOARD_VARIANT=SPIRAM_OCT,variant 的 sdkconfig 追加机制也未能正确传递这些选项。
  • 绕过方式: set-target 后手动用 Python sed sdkconfig 文件注入 CONFIG_SPIRAM=y 等配置。但每次 rm -rf build 后都必须重新注入,与 idf.py 的 cmake 缓存机制冲突。
  • 结果: 无 SPIRAM 时 mp_task_heap allocation failed → boot loop。

2. esp_wifi_init() 在 app_main 上下文触发 IWDT

  • 现象: Core 0 Interrupt Watchdog Timeout
  • 根因: pal_core0_setup()app_main 中做 ESP_ERROR_CHECK(esp_event_loop_create_default()) + ESP_ERROR_CHECK(esp_wifi_init(&cfg))。ESP-IDF v5.5.3 的 bootloader/freertos 初始化会在 app_main 返回前对 main task 做 IWDT 监测,WiFi 初始化耗时超过约 5.2 秒即触发看门狗复位。
  • 尝试修复 1: 把 WiFi 移到 FreeRTOS task_manager_task (prio 23) 中延迟 2s 执行 → 无法确认是否工作,因为 LOG_DEFAULT_LEVEL_ERROR 吞了日志。
  • 尝试修复 2: 把 WiFi 移到专用 wifi_task (Core 1) → 代码写了,但编译未通过(git submodule 错误)。
  • 结果: WiFi 无法稳定启动。

3. LOG_DEFAULT_LEVEL_ERROR 掩盖所有 ESP_LOGI

  • 现象: 串口只有 MPY REPL >>> 和内存/boot 信息,PAL 的日志完全消失
  • 根因: MPY 的 sdkconfig.base 默认 CONFIG_LOG_DEFAULT_LEVEL_ERROR=yESP_LOGI() 全被过滤
  • 修复: 注入 CONFIG_LOG_DEFAULT_LEVEL_INFO=y 到 sdkconfig
  • 耗时: 多次编译循环才定位到这个原因

4. CMake/IDF_TARGET 缓存问题

  • 现象: Target 'esp32s3' in sdkconfig does not match currently selected IDF_TARGET 'esp32'
  • 根因: rm -rf buildidf.py set-target esp32s3 必须在同一个 CMake 会话中执行。如果 set-target 失败(比如 git submodule 错误),残留的 build 目录会导致下一次 set-target 使用错误的 target 缓存
  • 影响: 每次失败后必须手动 rm -rf build,再 set-target,再注入 sdkconfig,再 build。四个步骤中任何一步失败都要从头开始

5. NAS Git submodule 错误 & tinyusb 编译报错

  • 现象: git remote-https is not a git command 导致 idf_component_manager 无法 fetch mdns/tinyusb
  • 根因: NAS 的 git 编译时未包含 HTTPS 支持,ESP-IDF Component Manager 在 set-target 时尝试 git fetch origin 失败
  • 尝试修复: 设 -D IDF_COMPONENT_MANAGER=0 跳过
  • 新增问题: 跳过后 cmake 缺少 espressif__mdns 等 managed component 的路径引用;tinyusb 一直报错,即使手动复制组件也因 MPY 的 CMake 依赖链无法正确解析
  • 核心痛点: tinyusb 并不是 PAL Level 2 需要的(我们不需要 USB 设备栈),但它是 MPY ESP32 port 的硬依赖——移除它意味着 fork MPY 源码改 CMake,超出个人开发者能力范围

6. 交叉编译的致命困境:MPY 必须 Linux,ESP-IDF 两者都行

这是整个 Level 2 尝试中最根本的工程困难

组件 Windows Linux
ESP-IDF (C 层) ✅ 官方支持 ✅ 官方支持
MicroPython 编译 不支持 ✅ 必须 Linux/WSL
WSL 作者的 WSL 环境不可用

MPY 的编译强制要求 Linux 环境。 作者尝试的路线:

  1. WSL → 不可用。花了一整个晚上排查 WSL 问题,未能修复。
  2. NAS (Linux Debian) 编译 MPY + Windows 编译 ESP-IDF → 拼接 → 失败。MPY 编译产物 (.a, .elf) 和 ESP-IDF 的 CMake 系统你中有我、我中有你——libmpy.a 依赖 ESP-IDF 的 FreeRTOS/SPI/WiFi 库,main.c 又依赖 MPY 的头文件和初始化函数。强行分平台编译会导致 ABI 不一致、符号缺失、链接失败。
  3. 全部 NAS 编译 — 唯一能走通的路线。但 NAS 性能极差,编译一次约 10 分钟。每次修复一个问题 → 等 10 分钟 → 烧录 → 发现新问题 → 再等 10 分钟。调试反馈循环以 10 分钟为单位。

8. C 与 MicroPython 的内存共存问题——运行时的最深坑

即便编译通过、固件烧录成功,运行时的内存问题仍然致命。

认知误区:MPY 不是独立系统。 作者原本以为 MicroPython 是一个独立的运行时——像 Node.js 或 CPython 那样,有自己的内存管理,可以单独分配一块内存区域给它跑。但实际上,MPY ESP32 port 是 FreeRTOS 上的一个虚拟机壳——底层 SDK 就是 ESP-IDF,和 Core 0 共享同一个 FreeRTOS 堆、同一个 SPIRAM、同一个 linker script。所谓的"Core 1 跑 MicroPython",本质上是 FreeRTOS 的一个 task 跑 mp_task() 函数,和其他 C task 共享一切。

这意味着 Level 2 的双核架构并没有实现最根本的隔离:

设想的:                         实际的:
Core 0 (C)    |  Core 1 (MPY)   Core 0 (C, FreeRTOS task1)
独立内存       |  独立内存        共用 heap, 共用 linker script
硬件隔离       |  硬件隔离        FreeRTOS task 调度, 无隔离
只有虚拟串口通信 |                mp_task() 和 ws_server 跑同一块内存

Core 0 代码跑通只用了第一个晚上。 ws_server、task_manager、nvs_storage 这些纯 C 组件很快就测试通过了。真正的噩梦是从"往 Core 1 塞一个 REPL 进去当 executor"开始的——那一刻,C 和 MPY 必须共享内存空间,难度指数级增长:

  • 内部 SRAM 不足:ESP32-S3 内部 SRAM ~512KB,MPY VM 本身占 ~200KB,WebSocket 任务+WiFi 协议栈又占 ~100KB。Core 0 的 TaskMgr + ws_server 一跑,SRAM 就满了。
  • 必须开外部 SPIRAM:借鉴了 esp-claw 的做法——把 MPY heap 和 WebSocket buffer 放到 PSRAM。但 PSRAM 的开启过程(问题 #1)已经卡了无数次。
  • 独立划分内存区域失败:想给 Core 0 和 Core 1 分配独立的 heap 区域,但 ESP-IDF 的 linker script 和 FreeRTOS 的 heap_caps_* API 不支持这种"软件级地址空间隔离"。作者不会改 linker script,AI 也不会。
  • 考虑过魔改 bootloader 实现 Core 0/1 彻底隔离:理论上可以在 boot 阶段为两个 core 分配独立的物理地址空间,让 Core 0 的 C 代码和 Core 1 的 MPY VM 完全不共享内存。但这太底层了——需要深入 ESP32-S3 的 MMU、cache 配置、启动流程。作者确实不会。让 AI 来,AI 在这方面也不占优势——LLM 的训练数据里几乎没有"ESP32-S3 bootloader 魔改实现双核内存隔离"这种东西。
  • GC 回收与 C 指针的冲突:MicroPython 的 GC 会移动对象,C 层如果持有指向 MPY heap 的裸指针,GC 触发后指针悬空,随机崩溃。需要 mp_obj_t 引用计数管理,但 PAL Core 0 的 C 代码不是 MPY C 扩展——它不知道什么叫 mp_obj_t
  • 莫名其妙奇奇怪怪的错误:某些情况下固件能启动但不能连 WiFi,某些情况下 WiFi 连接但 WebSocket 握手失败,某些情况下一切正常但运行 30 秒后看门狗复位。这些错误不可复现、不可定位、不可修复——因为问题可能出在 SPIRAM cache 一致性、FreeRTOS 任务优先级反转、MPY GC 时机、WiFi 协议栈内存分配失败等十几个环节中的任何一环。

作者不会修这些。 找 AI 修,AI 面对的是"可能是 A 也可能是 B 也可能是 C"的多重不确定性,盲目尝试,无法收敛。从 6 月 30 日到 7 月 3 日,足足 4 天,大部分时间就在修这些运行时错误。最终决定放弃。

9. 为什么中间成功过,但 WebSocket 一跑 RAM 就爆炸

这可能是整个 Level 2 尝试中最令人沮丧的时刻——它曾经工作过。 有那么一瞬间,Core 0 的 ws_server 和 Core 1 的 MPY REPL 同时在线,能接收 JSON 命令。然后 WebSocket 客户端一连接,RAM 就炸了。不是 crash——是内存分配失败,随后一切连锁崩溃。

根本原因:单片机没有操作系统级的进程隔离

这是整个 Level 2 失败最核心的技术原因。Windows 和 Linux 能做到的事——给一个进程分配独立的虚拟地址空间、让它的内存访问越界时触发 segfault 而不是踩坏隔壁进程的数据、让 GC 和 malloc 互不干扰——单片机做不到。

Linux/Windows ESP32-S3 + FreeRTOS
进程模型 独立进程,虚拟地址空间 FreeRTOS task,共享物理地址空间
内存隔离 MMU 页表,硬件隔离 无。所有 task 共享一个 heap
内存耗尽保护 OOM killer 杀进程 无。malloc 返回 NULL,谁拿到谁先崩
MPY 的定位 一个独立进程 (CPython) FreeRTOS 上一个 mp_task() 函数
GC 可见性 GC 管理自身进程的 heap GC 只扫 MPY heap,C 层分配对它透明

ESP32-S3 的 MPY 代码确实过于庞大。 MicroPython 的 ESP32 port 不是一个小脚本引擎——它是一个完整的操作系统级项目:VM 内核 ~10 万行 C,底层 FreeRTOS 任务调度,加上 ESP-IDF 的 WiFi/SPI/I2C 驱动栈。当你在上面再加一个 PAL Core 0(WebSocket server + Task Manager + Watchdog),你不是在"给 Python 加功能"——你是在 FreeRTOS 上跑了两个互相不认识、却共享同一块物理 SRAM 的运行时系统。

虚拟机不能完全隔离。 MPY 的"虚拟机"隔离的是 Python 字节码的执行边界(Python 代码不能直接访问 C 指针),但隔离不了内存——mp_task() 的 heap 和 ws_server 的 malloc 都来自同一个 FreeRTOS pvPortMalloc()。单片机没有虚拟机管理器(Hypervisor),没有嵌套页表。ESP32-S3 有一个轻量级的 MPU,但 MicroPython 没有用它做隔离。

MPY 和 C 之间没有办法像 Windows 和 Linux 那样隔离。 这是最主要的原因,也是最诚实的原因。不是代码写得不够好,不是不够努力——是硬件和软件栈的基础设施不支持"两个运行时在一个芯片上和平共处"。FreeRTOS 的设计哲学是"所有 task 是协作的、互相信任的",而 PAL Level 2 的需求是"Core 0 不信任 Core 1,Core 1 被 Agent 操控可能写出任何代码"——这两者从根本上矛盾。

  • MPY 的 GC 是标记-清除(mark-sweep),不是引用计数。 这意味着 GC 只在 heap 快满的时候才触发,触发时机不可预测。C 层分配的内存对 GC 不可见——GC 不知道 ws_server 的 cJSON 树占了多少内存,它只看到 MPY heap 上还有空位,就不触发回收。实际上 cJSON 已经占了一半 SRAM,但 GC 不知道。
  • 终端级隔离的缺失。 单片机不像 Windows/Linux——没有独立进程、没有虚拟地址空间、没有 MMU(ESP32-S3 只有简单的 MPU,MicroPython 没有用)。FreeRTOS task 只是一个函数调用栈加上优先级,所有 task 共享同一个物理地址空间。C 写 0x3FCE0000,MPY GC 扫 0x3FCE0000,谁也不认识谁,但写的是同一块物理 SRAM。
  • 中间成功过的原因是当时没有压满内存。 Core 0 单独跑时 ws_server 占用 ~50KB,TaskMgr ~20KB,SRAM 还剩 ~250KB。MPY REPL 单独跑时 VM 占用 ~200KB,SRAM 还剩 ~150KB。两个同时跑,加起来刚好踩在 512KB 的线上——能启动,但不稳。一个 WebSocket 客户端连接,TCP 缓冲区 + HTTP 握手 + cJSON 解析瞬间吃掉 ~80KB,内存就崩溃了。
  • 如果把 MPY heap 全部放在 SPIRAM 上,理论上能解决——esp-claw 就是这么做的。但 MPY 的 SPIRAM 配置不是改一个 flag——它涉及 MICROPY_GC_SPLIT_HEAPmp_task_heap vs mp_system_heap、SPIRAM 的 cache 策略(MALLOC_CAP_SPIRAM vs MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)、以及 PSRAM 速度比 SRAM 慢 4-8 倍导致 GC 扫描时间指数增长。每改一个参数都需要理解它对全局的影响——作者不会,AI 盲目改,就掉进了无底洞。

核心事实:单片机上没有"给 Python 分配 256MB 堆、给 C 分配 256MB 堆"这种操作系统级的内存隔离。MicroPython 和你的 C 代码跑在同一块物理 SRAM 上,用一个 FreeRTOS malloc,谁先吃掉最后一块内存谁就赢,输的那个拿不到内存就崩溃。没有仲裁者。

9. Agent 修 bug 的"无底洞"效应

  • Agent(Claude Code)在一次尝试中创建了 7 个新文件(pal_bridge.c, boot.py, manifest.py 等),修改了 21 个文件
  • pal_bridge.cSTATIC 而非 MPY 标准的 static
  • manifest.py 路径、lockfiles 冲突等额外问题
  • 每轮修 bug 要让 Agent 重新理解整个 ESP-IDF + MPY 的构建上下文,token 消耗以亿计
  • 累计浪费数亿 token,API 费用约 40+ 元,最终未能收敛
  • 核心问题:CMake/Kconfig 的报错信息对 LLM 不友好——一个 component not found 可能有 5 种不同根因(路径不对、git submodule 缺失、sdkconfig 选项未开、Kconfig 依赖链断裂、managed component 版本冲突),Agent 只能盲目尝试,无法精确定位

Level 1 固件

  • 位置: F:\MCU\DA_CHUANG\esp32_pal\firmware\ESP32_S3_MPY.bin (1.75MB)
  • 源码: F:\MCU\DA_CHUANG\esp32_pal\ (pal_main.py, wifi_recv.py 等)
  • 功能: 纯 MPY — 通过串口执行 Python 代码 / 通过 WiFi TCP 收发数据
  • 状态: 已验证可启动(之前通过串口和 WiFi 通讯测试通过)
  • 注意: 当前烧录后报 overlaps bootloader stack — 可能是因为 bootloader/分区表与固件不匹配,需要用 Level1 对应的 bootloader

Level 2 备份位置

NAS: /vol1/1000/PAL/micropython/ports/esp32/.backup_level2_20260702_155455/

  • pal/ (C 组件源码)
  • sdkconfig.pal, sdkconfig.ws
  • build_pal.sh
  • git diff patch

建议的下一步(给未来参考)

  1. 首先在纯 MPY 环境验证 WiFi + WebSocket

    • 用 Level 1 的 ESP32_S3_MPY.bin + 对应 bootloader/分区表
    • 在 REPL 中手动测试 network.WLANsocket
    • 确认 WiFi 连接稳定
  2. boot.py 方案

    • 不改 C 层
    • modules/boot.py (已写好: F:\MCU\DA_CHUANG\micropython_standalone\ports\esp32\modules\boot.py)
    • freeze 进固件,MPY 启动时自动执行 WiFi + WebSocket + TaskMgr
    • 如果 MPY REPL 空转导致 Core 0 饿死 WebSocket,用 _thread 放 Core 1
  3. PC 编译需要解决环境问题

    • 修复 Python venv 路径: 要么创建 C:\Users\kakun\.espressifD:\ESP\.espressif 的 junction,要么设 IDF_PYTHON_ENV_PATH 环境变量
    • 或者继续用 NAS,但需要先修复 git HTTPS 支持

Level 2 后门: 如果 boot.py 方案无法支持 C 层 VirtualUART

Level 2 的本质需求是"网络化的串口"——PC 通过 WebSocket 发送 Python 代码到 ESP32,ESP32 执行后返回结果。

在纯 MPY 中这完全可行: Level 1 的 pal_main.py 已经实现了这个模式(只是走硬件 UART 而非 WebSocket)。把 pal_main.py 的 UART 换成 socket WebSocket 就是 VirtualUART。