前几天我的 ESP32-S3-N16R8 不是到了嘛,尝试搞了点常规的操作,我正在想还可以干啥比较有意思的,毕竟这玩意本质上就是一个计算机。
然后,我脑子里面出现了一个疯狂的想法——让 ESP32-S3-N16R8 运行 Typecho
当时和 ChatGPT 讨论的时候,只是试探性玩笑地问了一句:
我有一个疯狂的想法:能在这玩意上面运行 typecho 吗?
没想到的是,他居然回复了:
你这个想法居然不是完全不可能!而且我查到了一个相当惊人的项目:已经有人把真正的 PHP 解释器移植到了 ESP32-S3 上。
而这个项目,正是 php-esp32,最重要的是,这个项目支持两个 ESP32 的开发板:ESP32-S3 和 ESP32-P4 ,而我买的 ESP32-S3-N16R8 正好就在支持列表里面!果然多花的 10 块钱没有白花,这次可以整狠活了
还有什么好说的?直接开整!
备份开发板
因为我还是希望保留原厂的 MicroPython 环境进行后续的学习,所以我打算对开发板进行备份,整完了活之后再刷回去。有了上一篇文章时的入门基础,现在操作就熟悉多了。
开始备份:
python -m esptool \
--chip esp32s3 \
--port /dev/ttyACM0 \
--baud 460800 \
read_flash 0x0 0x1000000 esp32s3_original_16mb.bin这里建议进行第二次备份以防万一,备份完成后校验一下两个文件的哈希:
python -m esptool \
--chip esp32s3 \
--port /dev/ttyACM0 \
--baud 460800 \
read_flash 0x0 0x1000000 esp32s3_verify_16mb.bin
cmp esp32s3_original_16mb.bin esp32s3_verify_16mb.bin不过有一说一,这个备份真的慢,16MB 的数据传输了差不多10分钟,感觉回到了上古计算机时代。
准备 PHP 编译环境
这里最好是使用 ext4 或 Btrfs 等 Linux 文件系统,以防万一 ESP-IDF 的符号链接、文件权限和编译执行遇到问题。
创建一个你喜欢的项目文件夹名称,进入项目文件夹,创建工具目录:
mkdir -p tools toolchains下载 phpflash:
curl -fL \
https://github.com/php-baremetal/flash-tool/releases/latest/download/phpflash-linux-amd64 \
-o tools/phpflash
chmod +x tools/phpflash
./tools/phpflash --version这是 php-esp32 官方推荐的构建管理工具,负责创建工程、调用 ESP-IDF 编译、烧录和串口监控。
安装 ESP-IDF 和 PHP 移植源码:
export IDF_TOOLS_PATH="$PWD/toolchains/.espressif"
./tools/phpflash system-setup \
--idf-path "$PWD/toolchains/esp-idf" \
--php-esp32-path "$PWD/toolchains/php-esp32"这会下载乐鑫 ESP-IDF、ESP32 交叉编译器,以及 php-esp32 的源码,并获取官方 PHP 源码进行移植构建准备。这套编译环境大概会占据 7GB ~ 8GB 的磁盘空间,请注意一下可用空间够不够(应该还是有这点空间的吧?)。
创建局部的项目环境变量,新建 env.sh 文件,内容如下:
export ROOT="/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32"
export IDF_TOOLS_PATH="$ROOT/toolchains/.espressif"
export IDF_PATH="$ROOT/toolchains/esp-idf"
export PHP_ESP32_DIR="$ROOT/toolchains/php-esp32"
echo "PHP-on-ESP32 environment loaded."/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32 记得替换成你实际的项目文件夹路径,后续导入开发环境只需要:
source env.sh尝试编译第一个 PHP 固件
饭要一口一口吃,测试也要一步一步来。第一步需要先验证这套开发环境是否有效,我们先不要放 Typecho,第一个目标应该是:
让 ESP32-S3 上电后执行真正的 PHP 代码,并通过串口输出 Hello World。
创建项目目录:
./tools/phpflash init php-hello按照下面的方式进行选择:
Project name [php-hello]:
Chip:
1) ESP32-P4 (esp32-p4)
2) ESP32-S3 (esp32-s3)
choice [1]: 2
Board preset:
1) Espressif ESP32-S3-DevKitC-1 (N16R8) (espressif-esp32-s3-devkitc-1-n16r8)
2) Espressif ESP32-S3-DevKitC-1 (N8R2) (espressif-esp32-s3-devkitc-1-n8r2)
3) Espressif ESP32-S3-DevKitC-1 (N8R8) (espressif-esp32-s3-devkitc-1-n8r8)
4) ESP32-S3 (octal PSRAM, generic) (generic)
5) ESP32-S3 (quad PSRAM, generic) (generic)
6) Waveshare ESP32-S3-ETH (waveshare-esp32-s3-eth)
7) Waveshare ESP32-S3-Touch-LCD-1.85B (waveshare-esp32-s3-touch-lcd-1.85b)
8) Waveshare ESP32-S3-Zero (waveshare-esp32-s3-zero)
9) Waveshare ESP32-S3-Zero (N8R8) (waveshare-esp32-s3-zero-n8r8)
choice [1]: 1
Storage type:
1) embedded
choice [1]:
Project type:
1) init-loop
2) web-server
3) event-driven
choice [1]: 1
PHP version:
1) 8.3.32
2) 8.3.33
3) 8.4.24
4) 8.4.25 (default)
5) 8.5.10
6) 8.5.9
choice [4]:
Optional extensions (comma-separated numbers, empty = none):
1) date — Real ext/date: the DateTime class and the date/time API (a UTC stub covers the core when off).
2) ctype — Character-class checks (ctype_alpha, ctype_digit, ...).
3) filter — filter_var() validation and sanitization.
4) tokenizer — The PHP tokenizer: token_get_all(), token_name(), the PhpToken class and the T_* constants, over the engine's own lexer. Wanted by frameworks and static-analysis tools.
5) session — Sessions: session_start(), $_SESSION, session_id(), and save handlers -- the files handler (point session.save_path at a writable dir like the microSD) and user handlers (session_set_save_handler).
6) opcache — Zend OPcache (no JIT): compiled bytecode is cached so each request skips re-tokenizing/compiling the sources -- a big win for frameworks where the per-request cost is compilation. Default is file-cache on the microSD (bytecode on the card, full PSRAM free for the request); see the in_memory setting.
7) mbstring — Multibyte strings (mb_strlen/substr/convert/detect/...). Built without mb_ereg unless the onig setting is on.
8) openssl — The openssl_* functions. Default is a small mbedTLS-backed subset (symmetric AES: openssl_encrypt/decrypt, cipher_iv_length, random_pseudo_bytes) -- enough for e.g. Laravel's Encrypter. The `full` setting builds the real ext/openssl instead (RSA/X.509/...). See docs/openssl.md.
9) sqlite — SQLite: read/write .db files. Default API is PDO (ext/pdo + pdo_sqlite); the 'sqlite3' type selects the SQLite3-class API instead (~45 KB smaller, no PDO).
10) led — Addressable LEDs from PHP: Baremetal\Led\Driver\* (WS2812, SK6812) + the Output\Led capability. Pin and pixel count are passed at construction.
11) wifi — WiFi from PHP: scan/join a network (STA) and create one (SoftAP). WiFi-capable SoCs only (not the ESP32-P4).
12) i2c — I2C from PHP: Baremetal\I2c\Bus and \Device (scan/probe/read/write/readReg/writeReg), with a driver framework. Pins are passed at construction.
13) spi — SPI/QSPI from PHP: Baremetal\Spi\Bus and \Device (transfer/write/read), plus the Output\Display capability and framebuffer panel drivers. Pins are passed at construction.
14) web — HTTP server from PHP in the event-driven model: Baremetal\Http\Request/\Response + serve_http(). Requests arrive as Request events; the handler returns a Response. Needs a board with a network.
choices:
Serial port (empty = autodetect) []:
Starter PHP:
1) hello
2) blink
choice [1]:
created php-hello/php-baremetal.config.toml
Next: cd php-hello && phpflash build下一步,编译!
cd php-hello
../tools/phpflash build等待编译完成后,烧录进 ESP32:
../tools/phpflash flash -p /dev/ttyACM0注意检查不要让别的程序占用 TTY ,TTY 只能被一个程序占用,如果有别的程序占用,烧录会报错。
烧录完成后,连接 TTY 查看启动信息:
../tools/phpflash monitor -p /dev/ttyACM0看到启动信息如下,说明就成功了:
I (24) boot: ESP-IDF v5.5.5 2nd stage bootloader
I (25) boot: compile time Oct 31 2026 12:23:36
I (25) boot: Multicore bootloader
I (25) boot: chip revision: v0.2
I (28) boot: efuse block revision: v1.4
I (31) boot.esp32s3: Boot SPI Speed : 80MHz
I (35) boot.esp32s3: SPI Mode : DIO
I (39) boot.esp32s3: SPI Flash Size : 16MB
I (43) boot: Enabling RNG early entropy source...
I (47) boot: Partition Table:
I (50) boot: ## Label Usage Type ST Offset Length
I (56) boot: 0 nvs WiFi data 01 02 00009000 00006000
I (63) boot: 1 phy_init RF data 01 01 0000f000 00001000
I (69) boot: 2 factory factory app 00 00 00010000 00fd0000
I (76) boot: 3 storage Unknown data 01 81 00fe0000 00020000
I (82) boot: End of partition table
I (86) esp_image: segment 0: paddr=00010020 vaddr=3c1f0020 size=bfa48h (784968) map
I (236) esp_image: segment 1: paddr=000cfa70 vaddr=3fc94b00 size=005a8h ( 1448) load
I (237) esp_image: segment 2: paddr=000d0020 vaddr=42000020 size=1ef420h (2028576) map
I (610) esp_image: segment 3: paddr=002bf448 vaddr=3fc950a8 size=048ach ( 18604) load
I (614) esp_image: segment 4: paddr=002c3cfc vaddr=40374000 size=10ac0h ( 68288) load
I (630) esp_image: segment 5: paddr=002d47c4 vaddr=50000000 size=00020h ( 32) load
I (637) boot: Loaded app from partition at offset 0x10000
I (637) boot: Disabling RNG early entropy source...
I (648) octal_psram: vendor id : 0x0d (AP)
I (648) octal_psram: dev id : 0x02 (generation 3)
I (649) octal_psram: density : 0x03 (64 Mbit)
I (651) octal_psram: good-die : 0x01 (Pass)
I (655) octal_psram: Latency : 0x01 (Fixed)
I (659) octal_psram: VCC : 0x01 (3V)
I (663) octal_psram: SRF : 0x01 (Fast Refresh)
I (668) octal_psram: BurstType : 0x01 (Hybrid Wrap)
I (673) octal_psram: BurstLen : 0x01 (32 Byte)
I (678) octal_psram: Readlatency : 0x02 (10 cycles@Fixed)
I (683) octal_psram: DriveStrength: 0x00 (1/1)
I (687) MSPI Timing: Enter psram timing tuning
I (692) esp_psram: Found 8MB PSRAM device
I (695) esp_psram: Speed: 80MHz
I (698) cpu_start: Multicore app
I (1133) esp_psram: SPI SRAM memory test OK
I (1142) cpu_start: GPIO 44 and 43 are used as console UART I/O pins
I (1142) cpu_start: Pro cpu start user code
I (1142) cpu_start: cpu freq: 160000000 Hz
I (1144) app_init: Application information:
I (1148) app_init: Project name: php-esp32
I (1152) app_init: App version: v1.5.0-9-g4ffc4a0-dirty
I (1158) app_init: Compile time: Oct 31 2026 12:23:21
I (1163) app_init: ELF file SHA256: f27c58e03...
I (1167) app_init: ESP-IDF: v5.5.5
I (1171) efuse_init: Min chip rev: v0.0
I (1175) efuse_init: Max chip rev: v0.99
I (1179) efuse_init: Chip rev: v0.2
I (1183) heap_init: Initializing. RAM available for dynamic allocation:
I (1189) heap_init: At 3FCB15C0 len 00038150 (224 KiB): RAM
I (1195) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM
I (1200) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM
I (1205) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM
I (1211) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator
I (1218) spi_flash: detected chip: boya
I (1221) spi_flash: flash io: dio
I (1224) sleep_gpio: Configure to isolate all GPIO pins in sleep state
I (1230) sleep_gpio: Enable automatic switching of GPIO sleep configuration
I (1237) main_task: Started on CPU0
I (1257) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations
I (1257) main_task: Calling app_main()
I (1257) php-esp32: starting PHP runtime
I (1267) php-esp32: php_task pinned to core 0
I (1277) php-esp32: site filesystem: 84 KiB total, 68 KiB free
I (1277) php-esp32: writable embedded FAT+WL mounted at /app (128 KiB partition)
I (1277) php-esp32: embedded source mounted at /app
I (1287) php-esp32: php_embed_init()...
PHP 8.4.25 on ESP32-S3
--- /app/index.php ---
Hello from PHP on ESP32
--- end ---
I (1517) main_task: Returned from app_main()
I (1567) php-esp32: done -- heap free: 8594727 bytes这其中,Hello from PHP on ESP32 就是我们要的结果了。
让固件自动连WIFI
确认了 PHP 固件可以正常运行,接下来创建另一个项目,测试 WIFI 连接和网页能力:
./tools/phpflash init php-web创建的过程如下:
Project name [php-web]:
Chip:
1) ESP32-P4 (esp32-p4)
2) ESP32-S3 (esp32-s3)
choice [1]: 2
Board preset:
1) Espressif ESP32-S3-DevKitC-1 (N16R8) (espressif-esp32-s3-devkitc-1-n16r8)
2) Espressif ESP32-S3-DevKitC-1 (N8R2) (espressif-esp32-s3-devkitc-1-n8r2)
3) Espressif ESP32-S3-DevKitC-1 (N8R8) (espressif-esp32-s3-devkitc-1-n8r8)
4) ESP32-S3 (octal PSRAM, generic) (generic)
5) ESP32-S3 (quad PSRAM, generic) (generic)
6) Waveshare ESP32-S3-ETH (waveshare-esp32-s3-eth)
7) Waveshare ESP32-S3-Touch-LCD-1.85B (waveshare-esp32-s3-touch-lcd-1.85b)
8) Waveshare ESP32-S3-Zero (waveshare-esp32-s3-zero)
9) Waveshare ESP32-S3-Zero (N8R8) (waveshare-esp32-s3-zero-n8r8)
choice [1]: 1
Storage type:
1) embedded
choice [1]: 1
Project type:
1) init-loop
2) web-server
3) event-driven
choice [1]: 2
PHP version:
1) 8.3.32
2) 8.3.33
3) 8.4.24
4) 8.4.25 (default)
5) 8.5.10
6) 8.5.9
choice [4]: 4
Optional extensions (comma-separated numbers, empty = none):
1) date — Real ext/date: the DateTime class and the date/time API (a UTC stub covers the core when off).
2) ctype — Character-class checks (ctype_alpha, ctype_digit, ...).
3) filter — filter_var() validation and sanitization.
4) tokenizer — The PHP tokenizer: token_get_all(), token_name(), the PhpToken class and the T_* constants, over the engine's own lexer. Wanted by frameworks and static-analysis tools.
5) session — Sessions: session_start(), $_SESSION, session_id(), and save handlers -- the files handler (point session.save_path at a writable dir like the microSD) and user handlers (session_set_save_handler).
6) opcache — Zend OPcache (no JIT): compiled bytecode is cached so each request skips re-tokenizing/compiling the sources -- a big win for frameworks where the per-request cost is compilation. Default is file-cache on the microSD (bytecode on the card, full PSRAM free for the request); see the in_memory setting.
7) mbstring — Multibyte strings (mb_strlen/substr/convert/detect/...). Built without mb_ereg unless the onig setting is on.
8) openssl — The openssl_* functions. Default is a small mbedTLS-backed subset (symmetric AES: openssl_encrypt/decrypt, cipher_iv_length, random_pseudo_bytes) -- enough for e.g. Laravel's Encrypter. The `full` setting builds the real ext/openssl instead (RSA/X.509/...). See docs/openssl.md.
9) sqlite — SQLite: read/write .db files. Default API is PDO (ext/pdo + pdo_sqlite); the 'sqlite3' type selects the SQLite3-class API instead (~45 KB smaller, no PDO).
10) led — Addressable LEDs from PHP: Baremetal\Led\Driver\* (WS2812, SK6812) + the Output\Led capability. Pin and pixel count are passed at construction.
11) wifi — WiFi from PHP: scan/join a network (STA) and create one (SoftAP). WiFi-capable SoCs only (not the ESP32-P4).
12) i2c — I2C from PHP: Baremetal\I2c\Bus and \Device (scan/probe/read/write/readReg/writeReg), with a driver framework. Pins are passed at construction.
13) spi — SPI/QSPI from PHP: Baremetal\Spi\Bus and \Device (transfer/write/read), plus the Output\Display capability and framebuffer panel drivers. Pins are passed at construction.
14) web — HTTP server from PHP in the event-driven model: Baremetal\Http\Request/\Response + serve_http(). Requests arrive as Request events; the handler returns a Response. Needs a board with a network.
choices: 11
Serial port (empty = autodetect) []:
Starter PHP:
1) hello
2) blink
choice [1]: 1
created php-web/php-baremetal.config.toml
Next: cd php-web && phpflash build创建完成后,先别急着编译,需要进行一些配置。
首先,打开 php-web/php-baremetal.config.toml ,增加下面的内容:
[web-server]
init = "init.php"然后,在 php-web/project-src 目录,创建 init.php:
<?php
echo "Starting Wi-Fi...\n";
if (!function_exists('wifi_available') || !wifi_available()) {
echo "ERROR: Wi-Fi extension unavailable!\n";
return;
}
$ssid = getenv('WIFI_SSID');
$password = getenv('WIFI_PASSWORD');
if (!$ssid) {
echo "ERROR: WIFI_SSID missing!\n";
return;
}
if (wifi_connect($ssid, $password ?: null)) {
echo "Wi-Fi connected!\n";
echo "IP: " . wifi_ip() . "\n";
} else {
echo "Wi-Fi connection failed!\n";
}这个 init.php 会在启动的时候执行一次,进行 WIFI 的连接操作,对应地,我们还要写入 WIFI 的名称和密码的配置,才能正确链接。
创建 php-web\.env 文件,写入你的 WIFI 名称和密码:
WIFI_SSID=MikuNet
WIFI_PASSWORD=www.nekopara.uk搞定后,编译:
cd php-web
../tools/phpflash build一样的方法,烧录进 ESP32:
../tools/phpflash flash -p /dev/ttyACM0烧录完成后,一样的,连接 TTY 查看启动信息:
../tools/phpflash monitor -p /dev/ttyACM0看到启动信息有连接 WIFI 信息的输出,说明就成功了:
I (24) boot: ESP-IDF v5.5.5 2nd stage bootloader
I (25) boot: compile time Oct 31 2026 13:20:54
I (25) boot: Multicore bootloader
I (25) boot: chip revision: v0.2
I (28) boot: efuse block revision: v1.4
I (31) boot.esp32s3: Boot SPI Speed : 80MHz
I (35) boot.esp32s3: SPI Mode : DIO
I (39) boot.esp32s3: SPI Flash Size : 16MB
I (43) boot: Enabling RNG early entropy source...
I (47) boot: Partition Table:
I (50) boot: ## Label Usage Type ST Offset Length
I (56) boot: 0 nvs WiFi data 01 02 00009000 00006000
I (63) boot: 1 phy_init RF data 01 01 0000f000 00001000
I (69) boot: 2 factory factory app 00 00 00010000 00fd0000
I (76) boot: 3 storage Unknown data 01 81 00fe0000 00020000
I (82) boot: End of partition table
I (86) esp_image: segment 0: paddr=00010020 vaddr=3c260020 size=d105ch (856156) map
I (249) esp_image: segment 1: paddr=000e1084 vaddr=3fc9c500 size=06e88h ( 28296) load
I (255) esp_image: segment 2: paddr=000e7f14 vaddr=40374000 size=08104h ( 33028) load
I (263) esp_image: segment 3: paddr=000f0020 vaddr=42000020 size=252c64h (2436196) map
I (708) esp_image: segment 4: paddr=00342c8c vaddr=4037c104 size=1034ch ( 66380) load
I (723) esp_image: segment 5: paddr=00352fe0 vaddr=50000000 size=00020h ( 32) load
I (733) boot: Loaded app from partition at offset 0x10000
I (734) boot: Disabling RNG early entropy source...
I (744) octal_psram: vendor id : 0x0d (AP)
I (744) octal_psram: dev id : 0x02 (generation 3)
I (744) octal_psram: density : 0x03 (64 Mbit)
I (746) octal_psram: good-die : 0x01 (Pass)
I (751) octal_psram: Latency : 0x01 (Fixed)
I (755) octal_psram: VCC : 0x01 (3V)
I (759) octal_psram: SRF : 0x01 (Fast Refresh)
I (764) octal_psram: BurstType : 0x01 (Hybrid Wrap)
I (769) octal_psram: BurstLen : 0x01 (32 Byte)
I (773) octal_psram: Readlatency : 0x02 (10 cycles@Fixed)
I (779) octal_psram: DriveStrength: 0x00 (1/1)
I (783) MSPI Timing: Enter psram timing tuning
I (788) esp_psram: Found 8MB PSRAM device
I (791) esp_psram: Speed: 80MHz
I (794) cpu_start: Multicore app
I (1229) esp_psram: SPI SRAM memory test OK
I (1238) cpu_start: GPIO 44 and 43 are used as console UART I/O pins
I (1238) cpu_start: Pro cpu start user code
I (1238) cpu_start: cpu freq: 160000000 Hz
I (1240) app_init: Application information:
I (1244) app_init: Project name: php-esp32
I (1248) app_init: App version: v1.5.0-9-g4ffc4a0-dirty
I (1254) app_init: Compile time: Oct 31 2026 13:20:39
I (1259) app_init: ELF file SHA256: 6ce5e4f41...
I (1263) app_init: ESP-IDF: v5.5.5
I (1267) efuse_init: Min chip rev: v0.0
I (1271) efuse_init: Max chip rev: v0.99
I (1275) efuse_init: Chip rev: v0.2
I (1279) heap_init: Initializing. RAM available for dynamic allocation:
I (1285) heap_init: At 3FCC17C0 len 00027F50 (159 KiB): RAM
I (1291) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM
I (1296) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM
I (1301) heap_init: At 600FE000 len 00001FE8 (7 KiB): RTCRAM
I (1307) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator
I (1314) spi_flash: detected chip: boya
I (1317) spi_flash: flash io: dio
I (1320) sleep_gpio: Configure to isolate all GPIO pins in sleep state
I (1326) sleep_gpio: Enable automatic switching of GPIO sleep configuration
I (1333) main_task: Started on CPU0
I (1353) esp_psram: Reserving pool of 32K of internal memory for DMA/internal allocations
I (1353) main_task: Calling app_main()
I (1353) php-esp32: starting PHP runtime
I (1363) php-esp32: php_task pinned to core 0
I (1373) php-esp32: site filesystem: 84 KiB total, 64 KiB free
I (1373) php-esp32: writable embedded FAT+WL mounted at /app (128 KiB partition)
I (1373) php-esp32: embedded source mounted at /app
I (1383) php-esp32: applied 2 env var(s) from .env
I (1383) php-esp32: php_embed_init()...
PHP 8.4.25 on ESP32-S3
--- web-server init: /app/init.php ---
Starting Wi-Fi...
I (1563) pp: pp rom version: e7ae62f
I (1563) net80211: net80211 rom version: e7ae62f
I (1563) main_task: Returned from app_main()
I (1573) wifi:wifi driver task: 3fcee70c, prio:23, stack:6656, core=0
I (1583) wifi:wifi firmware version: b9f67df
I (1583) wifi:wifi certification version: v7.0
I (1583) wifi:config NVS flash: enabled
I (1583) wifi:config nano formatting: disabled
I (1583) wifi:Init data frame dynamic rx buffer num: 32
I (1593) wifi:Init static rx mgmt buffer num: 5
I (1593) wifi:Init management short buffer num: 32
I (1603) wifi:Init dynamic tx buffer num: 32
I (1603) wifi:Init static tx FG buffer num: 2
I (1613) wifi:Init static rx buffer size: 1600
I (1613) wifi:Init static rx buffer num: 10
I (1613) wifi:Init dynamic rx buffer num: 32
I (1623) wifi_init: rx ba win: 6
I (1623) wifi_init: accept mbox: 6
I (1623) wifi_init: tcpip mbox: 32
I (1633) wifi_init: udp mbox: 6
I (1633) wifi_init: tcp mbox: 6
I (1633) wifi_init: tcp tx win: 5760
I (1643) wifi_init: tcp rx win: 5760
I (1643) wifi_init: tcp mss: 1440
I (1643) wifi_init: WiFi IRAM OP enabled
I (1653) wifi_init: WiFi RX IRAM OP enabled
I (1653) phy_init: phy_version 712,87e8c20e,Apr 13 2026,18:51:10
I (1703) wifi:mode : sta (1c:29:04:3d:e4:dc)
I (1703) wifi:enable tsf
I (4113) wifi:new:<6,0>, old:<1,0>, ap:<255,255>, sta:<6,0>, prof:1, snd_ch_cfg:0x0
I (4113) wifi:state: init -> auth (0xb0)
I (4253) wifi:state: auth -> assoc (0x0)
I (4263) wifi:state: assoc -> run (0x10)
I (4273) wifi:connected with MikuNet, aid = 1, channel 6, BW20, bssid = 24:cf:24:da:9b:23
I (4273) wifi:security: WPA2-PSK, phy: bgn, rssi: -25, cipher(pairwise:0x3, group:0x3), pmf:0
I (4283) wifi:pm start, type: 1
I (4283) wifi:dp: 1, bi: 102400, li: 3, scale listen interval from 307200 us to 307200 us
I (4293) wifi:set rx beacon pti, rx_bcn_pti: 0, bcn_timeout: 25000, mt_pti: 0, mt_time: 10000
I (4353) wifi:dp: 2, bi: 102400, li: 4, scale listen interval from 307200 us to 409600 us
I (4353) wifi:AP's beacon interval = 102400 us, DTIM period = 2
I (4803) wifi:<ba-add>idx:0 (ifx:0, 24:cf:24:da:9b:23), tid:0, ssn:0, winSize:64
I (4803) wifi:<ba-add>idx:1 (ifx:0, 24:cf:24:da:9b:23), tid:6, ssn:2, winSize:64
I (5813) esp_netif_handlers: sta ip: 192.168.8.166, mask: 255.255.255.0, gw: 192.168.8.1
Wi-Fi connected!
IP: 192.168.8.166
--- web-server init done ---
I (5813) php-esp32: httpd pinned to core 1 (php_task on core 0)
I (5813) php-esp32: web-server model: serving /app/index.php over HTTP on :80这里可以看到,WIFI 成功连接,IP 地址是 192.168.8.166!
打开浏览器,输入 ESP32 的 IP 地址,能看到文本:Hello from PHP on ESP32 说明操作成功。
接下来就是把 Typecho 塞进去了,但是事情没有我想象中那么简单。。。
编译 Typecho PHP 固件
在原有的基础上,复制一份已经成功验证的 Web 服务器配置,用来配置 Typecho 固件:
mkdir -p ../php-typecho/project-src
cp php-baremetal.config.toml ../php-typecho/
cp .env ../php-typecho/
cp project-src/init.php ../php-typecho/project-src/然后,把 Typecho 解压到 php-typecho/project-src 目录下。
接下来,编辑 php-baremetal.config.toml 文件,增加 Typecho 需要的 PHP 扩展:
[extensions.sqlite]
enabled = true
[extensions.mbstring]
enabled = true
[extensions.ctype]
enabled = true
[extensions.filter]
enabled = true
[extensions.date]
enabled = true
[extensions.session]
enabled = true
[extensions.tokenizer]
enabled = true尝试编译:
../tools/phpflash build结果报错:
[15/1396] cd /run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/php-typecho/build/compiled && /run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/.espressif/python_env/idf5.5_py3.13_env/bin/python /run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/wl_fatfsgen.py /run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/php-typecho/project-src --long_name_support --use_default_datetime --partition_size 0x270000 --output_file /run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/php-typecho/build/compiled/storage.bin --sector_size 4096
FAILED: CMakeFiles/fatfs_storage_bin /run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/php-typecho/build/compiled/CMakeFiles/fatfs_storage_bin
cd /run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/php-typecho/build/compiled && /run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/.espressif/python_env/idf5.5_py3.13_env/bin/python /run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/wl_fatfsgen.py /run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/php-typecho/project-src --long_name_support --use_default_datetime --partition_size 0x270000 --output_file /run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/php-typecho/build/compiled/storage.bin --sector_size 4096
Traceback (most recent call last):
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/wl_fatfsgen.py", line 247, in <module>
wl_fatfs.plain_fatfs.generate(args.input_directory)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfsgen.py", line 221, in generate
self._generate_partition_from_folder(folder_name, folder_path=path_to_folder, is_dir=True)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfsgen.py", line 30, in wrapper
func(self, *args, **kwargs)
~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfsgen.py", line 214, in _generate_partition_from_folder
self._generate_partition_from_folder(os.path.join(lower_path, path), folder_path=folder_path)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfsgen.py", line 30, in wrapper
func(self, *args, **kwargs)
~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfsgen.py", line 214, in _generate_partition_from_folder
self._generate_partition_from_folder(os.path.join(lower_path, path), folder_path=folder_path)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfsgen.py", line 30, in wrapper
func(self, *args, **kwargs)
~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfsgen.py", line 214, in _generate_partition_from_folder
self._generate_partition_from_folder(os.path.join(lower_path, path), folder_path=folder_path)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfsgen.py", line 30, in wrapper
func(self, *args, **kwargs)
~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfsgen.py", line 204, in _generate_partition_from_folder
self.write_content(split_path[1:], content)
~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfsgen.py", line 30, in wrapper
func(self, *args, **kwargs)
~~~~^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfsgen.py", line 152, in write_content
self.root_directory.write_to_file(path_from_root, content)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfs_utils/fs_object.py", line 340, in write_to_file
self.fat.allocate_chain(entity_to_write.first_cluster, clusters_cnt)
~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfs_utils/fat.py", line 97, in allocate_chain
free_cluster = self.find_free_cluster()
File "/run/media/chocola/3T-DATA02/Projects/ESP32/Typecho_on_ESP32/toolchains/esp-idf/components/fatfs/fatfs_utils/fat.py", line 83, in find_free_cluster
raise NoFreeClusterException('No free cluster available!')
fatfs_utils.exceptions.NoFreeClusterException: No free cluster available!询问 ChatGPT 得知,原因是 Typecho 的文件太多,自动生成的 FAT 文件系统镜像容量不足,导致编译失败。
修改 php-baremetal.config.toml 调整文件系统大小,找到:
[esp32.storage]
microsd = false # embedded: also mount a microSD for writable data (off = no SD support compiled)
reserve_kb = 0 # extra KB in the flash `storage` partition on top of the source (0 = just fit it)改成:
microsd = false # embedded: also mount a microSD for writable data (off = no SD support compiled)
reserve_kb = 2048 # extra KB in the flash `storage` partition on top of the source (0 = just fit it)再次尝试编译:
../tools/phpflash build这次就编译成功了,依旧刷写:
../tools/phpflash flash -p /dev/ttyACM0但是,打开浏览器,并不是我想要的 Typecho 安装界面,而是一行字:
Missing Config File
那看来得让 Typecho 适配一下 ESP32 的 PHP 环境了。
Typecho 适配 ESP32 PHP
改动其实不算多,修改了 5 个文件,修改的版本是 Typecho 1.3.0。
index.php
首先是index.php,将:
if (!defined('__TYPECHO_ROOT_DIR__') && !@include_once 'config.inc.php') {
file_exists('./install.php') ? header('Location: install.php') : print('Missing Config File');
exit;
}改为:
$path = parse_url(
$_SERVER['REQUEST_URI'] ?? '/',
PHP_URL_PATH
) ?: '/';
// 将安装请求转交给 Typecho 安装器
if ($path === '/install.php') {
require __DIR__ . '/install.php';
return;
}
// 未安装时跳转到安装页面
$config = __DIR__ . '/config.inc.php';
if (!defined('__TYPECHO_ROOT_DIR__')) {
if (!is_file($config)) {
header('Location: /install.php', true, 302);
return;
}
require_once $config;
}改动情况:
- 用
parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)显式解析请求路径,把/install.php显式require后return,避免进入路由分发。 - 移除
@include_once 'config.inc.php'的静默加载,改用is_file($config)显式判断后再require_once;未安装时发送302跳转到/install.php。
原因:
- ESP32 嵌入式 SAPI 的
REQUEST_URI处理与标准 PHP-CGI/Apache 不同,需显式解析路径才能正确区分安装请求与正常请求。 @include_once的静默失败在嵌入式环境下错误诊断困难,改用显式is_file判断更可靠。
install.php
然后是install.php,需要修改四个地方:
1.安装表单 AJAX 增加 dataType: 'json'
原版:
$.ajax({
url: form.attr('action'),
processData: false,
contentType: false,
type: 'POST',
data: new FormData(this),
success: function (data) {适配版:
$.ajax({
url: form.attr('action'),
processData: false,
contentType: false,
type: 'POST',
dataType: 'json',
data: new FormData(this),
success: function (data) {改动情况: 给安装表单的 jQuery AJAX 显式声明 dataType: 'json'。
原因: 配合下文 Response.php 的 Content-Type 修复,确保 jQuery 把安装器返回的 JSON 正确解析为对象而非字符串。
2.安装适配器脚本改用绝对路径
原版:
<?php require_once './install/' . $type . '.php'; ?>适配版:
<?php require_once __TYPECHO_ROOT_DIR__ . '/install/' . $type . '.php'; ?>改动情况: require_once 的基准从相对路径 ./install/ 改为基于 __TYPECHO_ROOT_DIR__ 的绝对路径。
原因: 嵌入式环境的当前工作目录(CWD)不一定是站点根,相对路径 ./install/... 可能找不到文件。
3.install_step_2_perform 建表阶段包裹 SQLite 事务
原版:
// init db structure
try {
$scripts = file_get_contents(__TYPECHO_ROOT_DIR__ . '/install/' . $type . '.sql');
$scripts = str_replace('typecho_', $config['dbPrefix'], $scripts);
// ... 逐条执行建表 SQL ...
}
install_success(3);
}适配版:
// init db structure
try {
if ($type === 'SQLite') {
$installDb->query('BEGIN IMMEDIATE', \Typecho\Db::WRITE);
}
$scripts = file_get_contents(__TYPECHO_ROOT_DIR__ . '/install/' . $type . '.sql');
$scripts = str_replace('typecho_', $config['dbPrefix'], $scripts);
// ... 逐条执行建表 SQL ...
}
if ($type === 'SQLite') {
$installDb->query('COMMIT', \Typecho\Db::WRITE);
}
install_success(3);
}改动情况: 当数据库类型为 SQLite 时,在执行建表 SQL 前发 BEGIN IMMEDIATE,全部建表完成后发 COMMIT。
4.install_step_3_perform 初始数据写入阶段包裹 SQLite 事务
原版:
try {
// write user
$hasher = new \Utils\PasswordHash(8, true);
$installDb->query(/* ... 写入 user/contents/options 等初始数据 ... */);
foreach ($options as $key => $value) {
// ...
$installDb->insert('table.options')->rows(['name' => $key, 'user' => 0, 'value' => $value])
);
}
} catch (\Typecho\Db\Exception $e) {
install_raise_error($e->getMessage());
}适配版:
try {
// Commit the initial site data together, reducing Flash erase/sync cycles.
$sqlite = install_get_db_type(install_get_current_db_driver()) === 'SQLite';
if ($sqlite) {
$installDb->query('BEGIN IMMEDIATE', \Typecho\Db::WRITE);
}
// write user
$hasher = new \Utils\PasswordHash(8, true);
$installDb->query(/* ... 写入 user/contents/options 等初始数据 ... */);
foreach ($options as $key => $value) {
// ...
$installDb->insert('table.options')->rows(['name' => $key, 'user' => 0, 'value' => $value])
);
}
if ($sqlite) {
$installDb->query('COMMIT', \Typecho\Db::WRITE);
}
} catch (\Typecho\Db\Exception $e) {
install_raise_error($e->getMessage());
}改动情况: 初始站点数据(用户、首篇文章、站点选项等)的批量写入同样用 BEGIN IMMEDIATE / COMMIT 包成单个事务。
原因: ESP32 用 Flash/FATFS 存储数据库文件,每次写入都会触发 Flash 擦除/同步周期。原版逐条建表 + 逐条插数据会产生几十次独立写入;合并为单个事务后只在事务提交时同步一次,大幅减少 Flash 磨损和安装耗时。BEGIN IMMEDIATE 比 BEGIN DEFERRED 更早获取写锁,避免中途遇到锁升级失败。
Common.php
var/Typecho/Common.php:Common::slug(生成 URL 友好别名)中 Unicode 单词匹配的回退分支。
原版:
mb_regex_encoding('UTF-8');
mb_ereg_search_init($str, "[\w" . preg_quote('_-') . "]+");
$result = mb_ereg_search();
$return = '';
if ($result) {
$regs = mb_ereg_search_getregs();
$pos = 0;
do {
$return .= ($pos > 0 ? '-' : '') . $regs[0];
$pos++;
} while ($regs = mb_ereg_search_regs());
}
$str = trim($return, '-_');适配版:
if (function_exists('mb_ereg_search_init')) {
mb_regex_encoding('UTF-8');
mb_ereg_search_init($str, "[\w" . preg_quote('_-') . "]+");
$result = mb_ereg_search();
$return = '';
if ($result) {
$regs = mb_ereg_search_getregs();
$pos = 0;
do {
$return .= ($pos > 0 ? '-' : '') . $regs[0];
$pos++;
} while ($regs = mb_ereg_search_regs());
}
} else {
// ESP32 keeps mbstring without the separate Oniguruma engine.
// PCRE already supports UTF-8 Unicode word characters.
preg_match_all('/[\p{L}\p{N}\p{M}\p{Pc}-]+/u', $str, $matches);
$return = implode('-', $matches[0]);
}
$str = trim($return, '-_');改动情况:
- 用
function_exists('mb_ereg_search_init')守卫原逻辑; - 函数不存在时改用 PCRE 回退:
preg_match_all('/[\p{L}\p{N}\p{M}\p{Pc}-]+/u', $str, $matches)后用-连接。
原因: ESP32 固件里的 PHP 启用了 mbstring 扩展,但没有链接独立的 Oniguruma 正则引擎(mb_ereg 系列依赖它)。PCRE 在 /u UTF-8 模式下已能通过 Unicode 属性类(\p{L} 字母、\p{N} 数字、\p{M} 附加符号、\p{Pc} 连接符)匹配原版 [\w_-]+ 的语义,作为零依赖回退实现。
SQLite.php
var/Typecho/Db/Adapter/Pdo/SQLite.php:SQLite PDO 适配器 init() 方法中 DSN 构造与连接管理。
原版:
class SQLite extends Pdo
{
use SQLiteTrait;
/**
* 判断适配器是否可用
* ...
*/
public function init(Config $config): \PDO
{
$pdo = new \PDO("sqlite:{$config->file}");
$this->isSQLite2 = version_compare($pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '3.0.0', '<');
return $pdo;
}适配版:
class SQLite extends Pdo
{
use SQLiteTrait;
/** Reuse each file handle for reads and writes on the serialized ESP32 SAPI. */
private array $esp32Connections = [];
/**
* 判断适配器是否可用
* ...
*/
public function init(Config $config): \PDO
{
$embedded = function_exists('wifi_available');
if ($embedded && isset($this->esp32Connections[$config->file])) {
return $this->esp32Connections[$config->file];
}
// ESP-IDF FATFS implements POSIX lock calls as no-ops. SQLite's Unix
// lock probing otherwise waits for a nonexistent competing process.
// The firmware serializes all PHP requests on a single task.
$dsn = $embedded
? "sqlite:file:{$config->file}?nolock=1"
: "sqlite:{$config->file}";
$pdo = new \PDO($dsn);
$this->isSQLite2 = version_compare($pdo->getAttribute(\PDO::ATTR_SERVER_VERSION), '3.0.0', '<');
if ($embedded) {
$this->esp32Connections[$config->file] = $pdo;
}
return $pdo;
}改动情况:
- 新增私有属性
$esp32Connections = [],按文件名缓存 PDO 句柄。 - 通过
function_exists('wifi_available')检测是否运行在 ESP32 嵌入式环境。 - 嵌入式环境下:命中缓存直接复用句柄;否则用
sqlite:file:<path>?nolock=1DSN 新建并缓存。 - 非嵌入式环境保持原
sqlite:<path>行为。
原因:
nolock=1:ESP-IDF 的 FATFS 把 POSIX 文件锁调用(fcntlF_SETLK)实现为空操作。SQLite 默认的 Unix 锁探测逻辑会等待一个并不存在的竞争进程,导致请求挂起。固件本身在单一任务上串行处理所有 PHP 请求,不存在并发,文件锁无意义,直接禁用。- 句柄复用:嵌入式环境资源受限,且 Flash 上的数据库文件打开/关闭成本高。串行执行模型保证复用句柄是安全的,避免每次请求重复打开文件。
Response.php
var/Typecho/Response.php HTTP 响应头发送:Response::send 中 header 循环对 Content-Type 的强制覆盖。
原版:
header('HTTP/1.1 ' . $this->status . ' ' . self::HTTP_CODE[$this->status], true, $this->status);
// set header
foreach ($this->headers as $name => $value) {
if (!in_array(strtolower($name), $sentHeaders)) {
header($name . ': ' . $value);
}
}适配版:
header('HTTP/1.1 ' . $this->status . ' ' . self::HTTP_CODE[$this->status], true, $this->status);
// TYPECHO_ESP32_HTTP_PATCH_V1: the declared response MIME type wins.
// Some embedded PHP SAPIs already expose a default Content-Type in
// headers_list(); keeping that default breaks jQuery JSON responses.
// Preserve the existing behavior for every other header.
foreach ($this->headers as $name => $value) {
if (strtolower($name) === 'content-type') {
header($name . ': ' . $value, true);
} elseif (!in_array(strtolower($name), $sentHeaders, true)) {
header($name . ': ' . $value);
}
}改动情况:
- 对
content-type(小写比较)强制使用header($name.': '.$value, true)(第三参数true表示替换同名 header),不再走"已发送则跳过"逻辑。 - 其他 header 保留原去重逻辑,
in_array同时加了严格比较true。 - 加了标记注释
TYPECHO_ESP32_HTTP_PATCH_V1便于后续审查。
原因: 嵌入式 PHP SAPI 在请求开始时可能已经默认注入了一个 Content-Type(如 text/html)到 headers_list() 中。原版逻辑会把它当作"已发送"而跳过 Typecho 自己声明的 Content-Type,导致 jQuery AJAX 收到错误的 MIME 类型、JSON 解析失败。强制覆盖 Content-Type 让 Typecho 声明的 MIME(如 application/json)生效。其他 header 不受影响,保留原去重逻辑。
改动完成了再尝试编译一版固件看看能不能正常工作,确实打开了 Typecho 的安装界面,但是点击下一步会提示:error
浏览器抓包发现会发送一个 POST 请求,返回一个 JSON:
{"success":1,"message":2,"config":null}但是没有进行跳转。
ChatGPT 建议我尝试一下在 URL 后面加上 ?step=2 强制跳转下一步,确实可以到下一步,但是点击开始安装依然 error:
依旧是一个 POST 请求,返回 JSON:
{"success":1,"message":2,"config":null}而且两次的 POST 状态码都是 302 ,算了吧,直接让 Codex 修。不过到这里,我已经看到了希望的曙光。
修补 php-esp32
后续我是让 Codex 继续推进这个项目了,不过在前面的折腾中,我也意识到了一个问题:似乎设置 reserve_kb = 2048 不足以充分利用空间,而且好像这个空间默认还是只读的,这对于 Typecho 来说并不合适——因为 Typecho 初始化要创建 config.inc.php 以及 .db 数据库文件,而且后续可能还有附件上传。
于是我就让 Codex 帮忙实现可用空间最大化,并且让网站目录变得可以读写,做了下面的工作:
这次改动都在 toolchains/php-esp32/ 里,共修改 4 个文件,分两类:一类是把嵌入式存储从只读改成可写(构建脚本 + boot.c),一类是让 HTTP 服务器具备真正 Web 服务器该有的路由、安全边界和请求语义(web_server.c)。
CMakeLists.txt
storage 分区的 FAT 镜像生成器要换掉,把:
fatfs_create_rawflash_image(storage "${PHP_EMBED_SRC}" FLASH_IN_PROJECT)改为:
fatfs_create_spiflash_image(storage "${PHP_EMBED_SRC}" FLASH_IN_PROJECT)改动情况: rawflash 生成只读裸 FAT 镜像,spiflash 生成带磨损均衡的可写 FAT 镜像。
原因: 只读镜像没法让 Typecho 写数据库和上传文件;换成 spiflash 才能配合下文 esp_vfs_fat_spiflash_mount_rw_wl 在 Flash 上持久写入。
gen-partitions.cmake
cmake/gen-partitions.cmake 的 app 分区前的预留空间从硬编码改成按启动模式动态计算,把:
# nvs + phy + alignment; secure boot pushes the app to 0x20000 = 128 K, so 128 K is the safe floor).
set(_pre_k 128)改为:
# nvs + phy + alignment). Normal boot starts at 0x10000; secure boot reserves
# 0x20000. Use the actual prefix so embedded storage can reach the end of flash.
set(_pre_k 64)
if(PHP_SECURE)
set(_pre_k 128)
endif()改动情况: _pre_k 从固定 128 KiB 改为普通启动 64 KiB、安全启动 128 KiB。
原因: 原版一律按安全启动预留,普通启动下白白浪费 64 KiB。按实际前置偏移计算后,storage 分区能多占到 Flash 末尾的 64 KiB。
boot.c
main/boot.c 改动最多,主要是把 /app 从只读挂载改成可写 FAT+WL,再补上 Typecho 需要的 ini 默认值。
1.mount_embedded() 改用可写 WL 挂载
原版:
/* Mount the optional embedded PHP source: a read-only FAT image in the internal
* 'storage' partition. Absent on microSD-only firmware (the partition may not exist, or
* exist but hold no image) -- in which case this returns false and we run from the SD. */
static bool s_app_mounted;
static bool mount_embedded(void)
{
const esp_partition_t *p = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_FAT, "storage");
if (!p) {
return false;
}
esp_vfs_fat_mount_config_t cfg = { .max_files = 5 };
if (esp_vfs_fat_spiflash_mount_ro(APP_MOUNT_POINT, "storage", &cfg) != ESP_OK) {
return false;
}
s_app_mounted = true;
return true;
}适配版:
/* Mount the optional embedded PHP source as a writable FAT filesystem with wear
* levelling on the internal 'storage' partition.
*
* WARNING: The partition must be provisioned with fatfs_create_spiflash_image().
* The old fatfs_create_rawflash_image() is incompatible with this mount method.
* Keep auto-format DISABLED: a failed mount must never silently delete Typecho data.
*/
static bool s_app_mounted;
static wl_handle_t s_app_wl = WL_INVALID_HANDLE;
static bool mount_embedded(void)
{
const esp_partition_t *p = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_FAT, "storage");
if (!p) {
ESP_LOGW(TAG, "no embedded 'storage' partition found");
return false;
}
const esp_vfs_fat_mount_config_t cfg = {
.format_if_mount_failed = false, /* Do NOT erase the user's DB/uploads. */
.max_files = 16, /* PHP + SQLite may open several files. */
.allocation_unit_size = 4096,
};
esp_err_t err = esp_vfs_fat_spiflash_mount_rw_wl(
APP_MOUNT_POINT, "storage", &cfg, &s_app_wl);
if (err != ESP_OK) {
ESP_LOGE(TAG,
"failed to mount writable /app (%s): check storage image / WL format",
esp_err_to_name(err));
s_app_wl = WL_INVALID_HANDLE;
return false;
}
s_app_mounted = true;
uint64_t total_bytes = 0, free_bytes = 0;
if (esp_vfs_fat_info(APP_MOUNT_POINT, &total_bytes, &free_bytes) == ESP_OK) {
ESP_LOGI(TAG, "site filesystem: %lu KiB total, %lu KiB free",
(unsigned long)(total_bytes / 1024), (unsigned long)(free_bytes / 1024));
}
ESP_LOGI(TAG, "writable embedded FAT+WL mounted at %s (%lu KiB partition)",
APP_MOUNT_POINT, (unsigned long)(p->size / 1024));
return true;
}改动情况: 挂载 API 从 esp_vfs_fat_spiflash_mount_ro 换成 esp_vfs_fat_spiflash_mount_rw_wl,新增 wl_handle_t 句柄;format_if_mount_failed = false,max_files 从 5 提到 16,allocation_unit_size = 4096;挂载成功后打印总容量和可用容量。
原因: Typecho 要往 /app 写 SQLite 数据库、usr/uploads 和会话文件,只读挂载根本跑不起来。format_if_mount_failed=false 是关键安全开关——自动格式化会静默擦掉用户的数据库和上传,绝对不能开。max_files=16 容纳 PHP 同时打开多个模板、插件和数据库文件。
2.新增 typecho ini 默认值钩子
/* TYPECHO_ESP32_HTTP_PATCH_V1: set writable upload/session temp locations.
* These PHP_INI_SYSTEM values must be seeded before php_embed_init(). */
static void typecho_ini_default(HashTable *ht, const char *key, const char *val)
{
zval z;
ZVAL_NEW_STR(&z, zend_string_init(val, strlen(val), 1));
zend_hash_str_update(ht, key, strlen(key), &z);
}
static void typecho_ini_defaults(HashTable *ht)
{
typecho_ini_default(ht, "upload_tmp_dir", APP_MOUNT_POINT "/usr/tmp");
typecho_ini_default(ht, "sys_temp_dir", APP_MOUNT_POINT "/usr/tmp");
typecho_ini_default(ht, "session.save_path", APP_MOUNT_POINT "/usr/sessions");
typecho_ini_default(ht, "upload_max_filesize", "512K");
typecho_ini_default(ht, "post_max_size", "768K");
#ifdef PHP_EXT_OPCACHE_ENABLED
opcache_ini_defaults(ht);
#endif
}改动情况: 新增 typecho_ini_defaults 钩子,注入 5 个 INI 默认值,并复用 opcache_ini_defaults。
原因: 嵌入式 SAPI 没有 php.ini,上传和会话的临时目录默认指向不可写位置,Typecho 后台上传和登录会话全都会失败。这里把 upload_tmp_dir/sys_temp_dir/session.save_path 全部指到可写的 /app/usr/tmp 与 /app/usr/sessions,上传和 POST 上限收到 512K/768K 以匹配 PSRAM 缓冲能力。
3.boot_php_runtime() 创建临时目录并安装 ini 钩子
原版:
php_embed_module.ub_write = esp_ub_write;适配版:
/* Create session and upload temp dirs on the writable Flash mount. */
if (s_app_mounted) {
mkdir(APP_MOUNT_POINT "/usr", 0755);
mkdir(APP_MOUNT_POINT "/usr/tmp", 0755);
mkdir(APP_MOUNT_POINT "/usr/sessions", 0755);
}
php_embed_module.ub_write = esp_ub_write;
php_embed_module.ini_defaults = typecho_ini_defaults;改动情况: /app 挂载后先 mkdir 出 usr、usr/tmp、usr/sessions 三个目录,再把 ini_defaults 钩子设为 typecho_ini_defaults。
原因: INI 里指向的目录必须真实存在,PHP 上传和会话才不会报错;钩子必须在 php_embed_init 读取 INI 之前装好。另外两个 OPcache 分支里的 opcache_ini_defaults 也一并换成了 typecho_ini_defaults(后者内部会调 opcache_ini_defaults),让上传/会话 INI 和 OPcache INI 在同一次钩子调用里一起生效。
4.卸载改为可写 WL 卸载
原版:
if (s_app_mounted) {
esp_vfs_fat_spiflash_unmount_ro(APP_MOUNT_POINT, "storage");
}适配版:
if (s_app_mounted) {
esp_err_t err = esp_vfs_fat_spiflash_unmount_rw_wl(APP_MOUNT_POINT, s_app_wl);
if (err != ESP_OK) {
ESP_LOGE(TAG, "failed to unmount writable /app: %s", esp_err_to_name(err));
} else {
s_app_mounted = false;
s_app_wl = WL_INVALID_HANDLE;
}
}改动情况: 卸载 API 配合挂载改为 esp_vfs_fat_spiflash_unmount_rw_wl,传入 WL 句柄并处理错误、复位状态。
原因: 挂载和卸载必须成对使用同一族 API,否则 WL 句柄泄漏。
web_server.c
main/models/web_server.c 这个文件改的是 HTTP 服务器本身。原版基本是把请求一股脑丢给 index.php,静态文件无差别地直出,对 Typecho 来说既跑不了安装器/后台子页面,又有把数据库和配置文件直接下载下来的风险。改动围绕路由分发和安全边界展开。
1.按路由选择入口脚本
原版:
static char s_docroot[256]; php_register_variable("SCRIPT_NAME", "/index.php", arr);
php_register_variable("PHP_SELF", "/index.php", arr);
php_register_variable("SCRIPT_FILENAME", (char *) s_web_script, arr);适配版:
static char s_docroot[256];
/* TYPECHO_ESP32_HTTP_PATCH_V1: per-request script selection. Only trusted routes are executable. */
static char s_route_script[512];
static char s_route_name[1024]; php_register_variable("SCRIPT_NAME", s_route_name, arr);
php_register_variable("PHP_SELF", s_route_name, arr);
php_register_variable("SCRIPT_FILENAME", s_route_script, arr);并新增 web_select_script(),默认路由到 index.php,仅当请求是 /install.php 或 /admin/<单段>.php(无子目录、无 ../\、文件真实存在)时才切到对应文件:
static void web_select_script(void)
{
snprintf(s_route_script, sizeof(s_route_script), "%s", s_web_script);
snprintf(s_route_name, sizeof(s_route_name), "/index.php");
const char *path = s_req.path;
if (!strcmp(path, "/admin/")) path = "/admin/index.php";
bool is_install = !strcmp(path, "/install.php");
bool is_admin = !strncmp(path, "/admin/", 7) && path[7] != '\0'
&& strchr(path + 7, '/') == NULL;
size_t n = strlen(path);
if (!is_install && !(is_admin && n >= 4 && !strcasecmp(path + n - 4, ".php"))) return;
if (strstr(path, "..") || strchr(path, '\\')) return;
char candidate[sizeof(s_route_script)];
int written = snprintf(candidate, sizeof(candidate), "%s%s", s_docroot, path);
if (written <= 0 || written >= (int) sizeof(candidate)) return;
struct stat st;
if (stat(candidate, &st) != 0 || !S_ISREG(st.st_mode)) return;
snprintf(s_route_script, sizeof(s_route_script), "%s", candidate);
snprintf(s_route_name, sizeof(s_route_name), "%s", path);
}改动情况: SCRIPT_NAME/PHP_SELF/SCRIPT_FILENAME 从硬编码 /index.php 改为按请求选定的 s_route_name/s_route_script;web_select_script() 严格限定只有安装器和 admin/ 顶层 PHP 才直接执行,其余一律走 index.php。
原因: Typecho 安装器走 /install.php、后台走 /admin/index.php,硬编码 /index.php 会让这些入口拿不到正确的自身路径,重定向和相对资源链接全错乱;同时严格限定可执行面,禁止 /var/、嵌套插件 PHP、config.inc.php 被远程执行。
2.静态资源白名单与内部路径 403
原版: web_try_static() 直接按路径找文件直出,无任何过滤。
适配版: 新增 web_is_public_asset() 和 web_is_internal_path() 两个判定函数:
static bool web_is_public_asset(const char *path)
{
if (!path || path[0] != '/' || strstr(path, "..") || strchr(path, '\\')) return false;
if (!strcmp(path, "/favicon.ico") || !strcmp(path, "/robots.txt")) return true;
static const char *const roots[] = {
"/admin/css/", "/admin/js/", "/admin/img/", "/admin/images/", "/admin/fonts/",
"/usr/themes/", "/usr/plugins/", "/usr/uploads/"
};
bool in_public_dir = false;
for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) {
if (!strncmp(path, roots[i], strlen(roots[i]))) {
in_public_dir = true;
break;
}
}
if (!in_public_dir) return false;
if (strstr(path, "/.")) return false;
const char *dot = strrchr(path, '.');
if (!dot) return false;
/* Uploaded assets can be public, but never serve scripts or active SVG/JS there. */
if (!strncmp(path, "/usr/uploads/", 13)) {
static const char *const upload_exts[] = {
".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".pdf"
};
for (size_t i = 0; i < sizeof(upload_exts) / sizeof(upload_exts[0]); i++) {
if (!strcasecmp(dot, upload_exts[i])) return true;
}
return false;
}
static const char *const exts[] = {
".css", ".js", ".mjs", ".map", ".png", ".jpg", ".jpeg", ".gif",
".webp", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot",
".avif", ".webm", ".mp4", ".pdf"
};
for (size_t i = 0; i < sizeof(exts) / sizeof(exts[0]); i++) {
if (!strcasecmp(dot, exts[i])) return true;
}
return false;
}
static bool web_is_internal_path(const char *path)
{
if (!path || path[0] != '/') return true;
if (strstr(path, "..") || strchr(path, '\\') || strstr(path, "/.")) return true;
if (!strcmp(path, "/config.inc.php") || !strcmp(path, "/init.php")) return true;
if (!strncmp(path, "/var/", 5) || !strncmp(path, "/install/", 9)) return true;
if (!strncmp(path, "/usr/", 5) && !web_is_public_asset(path)) return true;
return false;
}web_try_static() 入口加白名单守卫,web_handle() 里对内部路径直接 403:
static bool web_try_static(httpd_req_t *req)
{
const char *path = s_req.path;
if (!web_is_public_asset(path)) return false;
// ... web_split_uri();
if (web_is_internal_path(s_req.path)) {
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "Forbidden");
return ESP_OK;
}
web_select_script();改动情况: web_is_public_asset 只放行 /admin/{css,js,img,...}/、/usr/themes/、/usr/plugins/、/usr/uploads/ 下指定扩展名的静态资源,/usr/uploads/ 仅放行图片/PDF;web_is_internal_path 把 config.inc.php、init.php、/var/、/install/ 以及 /usr/ 下非公开资源一律判为内部路径,请求直接返回 403。
原因: 原版会把 /app 下任意存在的文件送出去,意味着 SQLite 数据库、config.inc.php、备份文件都能被远程下载。白名单把可服务面收窄到主题/插件/上传里的静态资源,杜绝敏感文件泄露。
3.设置 server_context 启用 POST/cookie 解析
原版:
SG(request_info).request_method = s_req.method_str;适配版:
/* A real server context enables SAPI POST parsing and read_cookies. Embed
* leaves this NULL, which silently makes every form look empty. */
SG(server_context) = s_web_req;
SG(sapi_headers).http_response_code = 200;
SG(request_info).request_method = s_req.method_str;改动情况: 设置 SG(server_context) = s_web_req 并把响应码初始化为 200。
原因: embed SAPI 默认 server_context 为 NULL,导致 SAPI 的 POST 解析和 cookie 读取被跳过,Typecho 后台所有表单提交都是空数据。设上 httpd 请求句柄后 SAPI 才会真正读 body。
4.缺省 Content-Type 采用 SAPI 声明的 MIME
原版:
if (!have_ctype) {
httpd_resp_set_type(s_web_req, "text/html; charset=UTF-8");
}适配版:
if (!have_ctype) {
snprintf(s_ctype_hdr, sizeof(s_ctype_hdr), "%s",
(h->mimetype && h->mimetype[0]) ? h->mimetype : "text/html; charset=UTF-8");
httpd_resp_set_type(s_web_req, s_ctype_hdr);
}改动情况: PHP 没显式发 Content-Type 时,优先用 SAPI 记录的 h->mimetype,仍无则回退 text/html; charset=UTF-8。
原因: 和 PHP 侧 Response.php 的强制覆盖配合,确保 JSON 等响应的 MIME 正确传给浏览器/jQuery,而不是一律被当成 text/html。
5.POST body 限流
原版: 无 body 大小检查。
适配版:
/* Limit request-body buffering in the 8 MiB PSRAM system. */
if (req->content_len > 786432) {
httpd_resp_set_status(req, "413 Payload Too Large");
httpd_resp_set_type(req, "text/plain; charset=UTF-8");
httpd_resp_send(req, "POST body exceeds 768 KiB", HTTPD_RESP_USE_STRLEN);
return ESP_OK;
}改动情况: POST body 超过 768 KiB 直接返回 413。
原因: N16R8 只有 8 MiB PSRAM,放任大 body 上传会直接 OOM。768 KiB 和 ini 里的 post_max_size 对齐。
6.异常日志钩子 + 改由 php_request_shutdown 发头
原版:
static void web_serve_one(void)
{
s_web_len = 0;
web_prepare_request();
if (php_request_startup() == SUCCESS) {
zend_try {
run_php_file(s_web_script);
} zend_catch {
} zend_end_try();
if (!SG(headers_sent)) {
sapi_send_headers();
}
php_request_shutdown(NULL);
}
}适配版:
/* Keep framework-caught PHP errors visible on the serial console. */
static void web_log_exception(zend_object *exception)
{
zval rv;
zval *message = zend_read_property(exception->ce, exception,
"message", sizeof("message") - 1, true, &rv);
if (message && Z_TYPE_P(message) == IS_STRING) {
ESP_LOGE(TAG, "%s: %s", ZSTR_VAL(exception->ce->name), Z_STRVAL_P(message));
}
}
static void web_serve_one(void)
{
s_web_len = 0;
zend_throw_exception_hook = web_log_exception;
web_prepare_request();
if (php_request_startup() == SUCCESS) {
zend_try {
run_php_file(s_route_script);
} zend_catch {
} zend_end_try();
/* Shutdown first flushes user output buffers. Typecho sets its final status,
* MIME type and cookies in an ob_start callback; sending headers before
* that callback freezes the defaults and breaks redirects / AJAX / login.
* php_output_deactivate also sends headers for an empty response. */
php_request_shutdown(NULL);
}
}改动情况: 新增 web_log_exception 装到 zend_throw_exception_hook,把框架捕获的异常打到串口;执行的脚本从 s_web_script 改为 s_route_script;删掉请求尾手动调的 sapi_send_headers(),改由 php_request_shutdown 触发。
原因: Typecho 在 ob_start 回调里才设置最终的状态码、MIME 和 Cookie,提前调 sapi_send_headers 会把默认值冻结,破坏重定向、AJAX 和登录——这正是之前安装器 POST 一直返回 302 却不跳转的根由。交给 php_request_shutdown 在输出缓冲冲刷后发送才正确。异常钩子则是为了调试:框架 try/catch 吞掉的错误 otherwise 在串口上一点痕迹都不留。
7.启动 SNTP 时间同步
/* Typecho timestamps and expiring login cookies need wall-clock time after
* every power cycle. The init script has connected Wi-Fi at this point. */
esp_sntp_setoperatingmode(SNTP_OPMODE_POLL);
esp_sntp_setservername(0, "ntp.aliyun.com");
esp_sntp_init();改动情况: Web 服务器启动时向 ntp.aliyun.com 轮询同步系统时间。
原因: ESP32 每次上电 RTC 不保留墙钟时间,Typecho 的文章时间戳和登录 Cookie 过期判断都依赖它;Wi-Fi 连上后同步一次 NTP 即可。
这些改动到位之后,Typecho 终于能完整跑完安装、登录后台、发文章了。不过还有个收尾问题:reserve_kb 到底该设多少?下面单独算一下。
调整 reserve_kb
reserve_kb = 8320 是怎么算出来的:板子是 ESP32-S3-N16R8,N16R8 即 16 MiB Flash、8 MiB PSRAM。Flash 一共 16384 KiB,要塞下四部分:前置区(bootloader + 分区表)、固定分区(nvs + phy_init)、factory(固件本体)、storage(Typecho 站点目录)。
构建时 cmake/gen-partitions.cmake 会按下面这条链推 storage 分区大小:
storage_k = ceil_to_64K( 源码占用 + 64K FAT 开销 + reserve_kb ) / 1024
factory_k = ( 16384 - 64 - storage_k - 0 ) 向下对齐到 64K其中 64 是普通启动的前置偏移 0x10000,phpstore 没启用所以是 0。源码占用按 4 KB 簇向上取整累加 project-src/ 下所有文件,本次构建实测是 2441216 B(约 2384 KiB)。这些数字在构建日志里都打出来了:
-- php-esp32: embedded 'storage' partition = 10816K (source ~2441216 B occupancy + 64K FAT + 8320K reserve)最终生成的分区表 php-typecho/build/partitions.gen.csv:
# Generated by cmake/gen-partitions.cmake -- do not edit; regenerated each build.
# Name, Type, SubType, Offset, Size
nvs, data, nvs, , 24K
phy_init, data, phy, , 4K
factory, app, factory, , 5440K
storage, data, fat, , 10816K, 所以 reserve_kb = 8320 的推算思路是:16 MiB Flash 里给固件 factory 留 5440 KiB(PHP 引擎 + ESP-IDF + web server 实测够用且有余量),前置和固定分区共约 92 KiB,剩下全给 storage,对齐到 64 KB 后是 10816 KiB;再从 storage 里扣掉源码占用 2384 KiB 和 64 KiB FAT 开销,剩下的就是额外预留:
10816 − 2384 − 64 − 48(对齐填充) = 8320 KiB这 8320 KiB(约 8.1 MiB)就是 storage 分区里真正给 Typecho 写数据库、上传附件和会话文件用的可写空间。把它写进 php-typecho/php-baremetal.config.toml:
[esp32.storage]
microsd = false
reserve_kb = 8320 # extra KB in the flash `storage` partition on top of the source (0 = just fit it)如果以后源码体积变了,重跑一次构建看日志里的 occupancy,再用 目标 storage 大小 − 源码占用 − 64K FAT 反推一下就能重算 reserve_kb。
压缩 Typecho 代码仓库
修改后的 Typecho 文件夹依然有 1.9MB,而在寸土寸金的 16MB Flash 上面,0.1MB 也至关重要。Typecho 本身代码就有不少的注释,更进一步,我们还可以执行压缩。利用 PHP 自带的 php_strip_whitespace() 就可以完成。
首先进行 PHP 压缩:
#!/bin/bash
SRC="./project-src"
DST="./project-src-min"
mkdir -p "$DST"
find "$SRC" -type f -print0 | while IFS= read -r -d '' file; do
relative="${file#"$SRC"/}"
target="$DST/$relative"
mkdir -p "$(dirname "$target")"
case "$file" in
*.php)
php -w "$file" > "$target"
;;
*)
cp -p "$file" "$target"
;;
esac
done这时文件夹大小已经从 1.9MB 变成了 1.5MB,核算后设置 reserve_kb = 8744。
编译,烧录!Typecho 启动!
编译固件:
../tools/phpflash build --clean然后烧录固件:
../tools/phpflash flash -p /dev/ttyACM0打开了浏览器进行 Typecho 的安装:




成功了!!!太疯狂了!!!我居然在 ESP32 上面运行起来了 Typecho !!!
我想,对于 ESP32-S3-N16R8 来说,每次访问请求都是一次酣畅淋漓的性能释放吧,毕竟每次点击刷新都要等五六秒,有点像华为畅享的 麒麟710 强行带鸿蒙系统的卡顿感()你永远不知道你的网上邻居在用什么奇葩玩意运行网站