固件 A15:启动顺序 + 降级矩阵(bringup.{h,c},已上板)

- 启动序列从 app_main.c 拆进 main/bringup.{h,c},宿主机可测:
  bringup_devices()(§10.9 第 1~4 步)→ 起 t_safety → bringup_power_restore()(第 5~6 步)
- 六片 INA226 写 Config/Calibration(之前没做,电流恒 0);输入侧 0x43 登记对调接
- 按「意图」逐路上电、错开 100 ms、每路等 PG 500 ms,等不到关回去并记 LOG_EV_PG_TIMEOUT(17)
- 意图只由 power 命令改(cmd_env_t.intent → NVS phonerail/rail_want)
- §10.10 逐行降级:INA/DAC/PCA9555/mux/PRSNT#/CH224A;mux 失联时不碰主干
- BOOT 事件 arg = 复位原因;HUB_RST# 仍只在上电复位时脉冲
- rail_volt_init() 一片失败不再放弃另一片;CH224A 主干 / 通道 5 两处都找
- sim:sim_gpio_rise_us / sim_gpio_set_hook,sim_gpio_drive 可在配置前调
- test_bringup.c 24 例 + test_cmd 意图 1 例,共 425 例全绿;版本 0.1.0-a15
- rack:事件表加 17 PG_TIMEOUT(已部署)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-27 13:24:17 +08:00
共同撰写人 Claude Opus 5.5
父节点 da6af4ef1c
当前提交 c85eec53e6
共修改 24 个文件,包含 1479 行新增和 201 行删除
+116 -142
查看文件
@@ -3,6 +3,7 @@
#include <string.h>
#include "board_pins.h"
#include "bringup.h"
#include "cmd.h"
#include "cmd_queue.h"
#include "esp_log.h"
@@ -26,6 +27,8 @@
#include "t_safety.h"
#include "tasks.h"
#include "mlink_esp.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "ulink_esp.h"
static const char *TAG = "phonerail";
@@ -236,44 +239,77 @@ static void task_log(void *arg)
}
}
/* USB2517I 的复位脉冲 —— **整个固件里唯一碰 `HUB_RST#` 的地方,而且只在启动调一次**。
/* ---- 每路「意图」的持久化(A15,§10.9 第 4 步「上次的每路使能状态」)--------
一个字节,bit0 = CH1。只有 `power` 命令改它(cmd.h 的 `cmd_intent_fn`)。
与网络配置同一个 NVS 命名空间(mlink_esp.c),各用各的键。
新板 / 读不到 = 0 = 一路都不开:宁可让人手动开一次,也不在不知道的情况下给五台手机上电。 */
#define WANT_NS "phonerail"
#define WANT_KEY "rail_want"
低有效(板上 R716 上拉到 +3V3_HUB),给 1 ms 足够:手册要的是几十微秒量级。
static uint8_t s_want;
⛔⛔ **运行期一个字都不许再写它**(R2 D13 代价 2)。ESP32 自己的
USB-Serial-JTAG 就挂在这个 Hub 的第 6 个下行口上,所以复位 Hub =
**自己把自己的控制台拔了**,而且下一次 `idf.py flash` 也进不来
—— 到那时候唯一的退路是 J33 那个 5P 应急串口(UART0,IO43/IO44),
得拆机接线。写一行 "Hub 卡了就复位一下" 的自愈代码,代价是整机变砖。
⚠️ 这一下放在 `rail_en_init()` 之后、其它一切之前:Hub 复位会让 USB 重新枚举,
期间串口日志是丢的。越早做,后面越多的启动日志能被电脑收到。
失败**不 abort**:Hub 没起来只影响调试与手机的数据口,五路供电照常
(§10.2:失联 ≠ 断电)。
⛔ **只在上电复位(`ESP_RST_POWERON`)时脉冲**(2026-09-27 上板改)。
原来是「每次启动都脉冲」,而 ESP32 的每一次重启都算启动:esptool 经 USB 复位芯片、
崩溃 / 看门狗重启、`esp_restart()` —— 每一次都把 Hub 复位、把自己的 COM 口拔掉。
实测后果:板上一旦有这份固件,`esptool --before usb_reset` 连三次全断
(`ClearCommError PermissionError 13`),只能走 J33 手动进下载模式。
上电复位时 Hub 自己也刚上电,脉冲没有代价;J33 的 EN 脚复位在 S3 上也报 POWERON,
应急路径不受影响。其余原因一律不碰 `HUB_RST#`(此时它仍是复位态的高阻,靠 R716 拉高)。 */
static void hub_reset_pulse_once(void)
static void nvs_up(void)
{
const esp_reset_reason_t why = esp_reset_reason();
if (why != ESP_RST_POWERON) {
ESP_LOGI(TAG, "复位原因 %d 不是上电复位 —— 不动 HUB_RST#(保住 USB 控制台)", (int)why);
return;
esp_err_t e = nvs_flash_init();
if (e == ESP_ERR_NVS_NO_FREE_PAGES || e == ESP_ERR_NVS_NEW_VERSION_FOUND) {
nvs_flash_erase(); /* 同 mlink_esp.c:格式不认就清掉重来 */
e = nvs_flash_init();
}
hal_err_t err = hal_gpio_config(BOARD_PIN_HUB_RST_N, HAL_GPIO_OUTPUT);
if (err != HAL_OK) {
ESP_LOGE(TAG, "HUB_RST# 配置失败:%s —— 跳过复位脉冲", hal_err_str(err));
return;
if (e != ESP_OK) {
ESP_LOGE(TAG, "NVS 起不来:%s —— 本次按「一路都不开」启动", esp_err_to_name(e));
}
}
static uint8_t want_load(void)
{
nvs_handle_t h;
uint8_t v = 0;
if (nvs_open(WANT_NS, NVS_READONLY, &h) == ESP_OK) {
if (nvs_get_u8(h, WANT_KEY, &v) != ESP_OK) {
v = 0;
}
nvs_close(h);
}
return v;
}
static void want_save(void *ctx, int rail, bool on)
{
(void)ctx;
const uint8_t v = bringup_want_update(s_want, rail, on);
if (v == s_want) {
return; /* 没变就不写 flash */
}
nvs_handle_t h;
esp_err_t e = nvs_open(WANT_NS, NVS_READWRITE, &h);
if (e == ESP_OK) {
e = nvs_set_u8(h, WANT_KEY, v);
if (e == ESP_OK) {
e = nvs_commit(h);
}
nvs_close(h);
}
if (e == ESP_OK) {
s_want = v;
} else {
/* 存不下不影响这一次的开关(已经生效了),只影响下次开机恢复成什么样。 */
ESP_LOGW(TAG, "意图没存进 NVS:%s", esp_err_to_name(e));
}
}
static void log_report(const bringup_report_t *r)
{
ESP_LOGI(TAG, "启动:降级位 0x%02x,空槽 0x%02x,盲跑 0x%02x,PCA 失联 0x%02x,PD %u mA → 上限 %u mW",
(unsigned)r->deg, (unsigned)r->empty, (unsigned)r->blind, (unsigned)r->io_lost,
(unsigned)r->pd_ma, (unsigned)r->pd_limit_mw);
if ((r->deg & BRINGUP_DEG_MUX) != 0) {
ESP_LOGE(TAG, "TCA9548A 复位后仍选不中 —— 五路一路都不上电(§5.8)");
}
if ((r->deg & BRINGUP_DEG_DAC_NVM) != 0) {
ESP_LOGW(TAG, "DAC 上电值不是 3.90 V(NVM 被改过?):%u %u %u %u %u mV",
r->dac_nvm_mv[0], r->dac_nvm_mv[1], r->dac_nvm_mv[2], r->dac_nvm_mv[3],
r->dac_nvm_mv[4]);
}
(void)hal_gpio_write(BOARD_PIN_HUB_RST_N, 0); /* 低 = 复位 */
hal_time_delay_ms(1);
(void)hal_gpio_write(BOARD_PIN_HUB_RST_N, 1); /* 放开,之后再也不动 */
ESP_LOGI(TAG, "USB Hub 已复位一次(运行期不再动 HUB_RST#)");
}
static void spawn(task_id_t id, TaskFunction_t fn)
@@ -299,96 +335,38 @@ void app_main(void)
selftest_or_die();
/* ⭐ R2:**五根** `EFUSE_EN` 配成输出并全部拉低(R1 是十根:另外五根 `VBUS_EN`
已经挪到刀片的 PCA9555 上了)。**先于一切外设**:DAC / eFuse 一上电就有
输出,这五根线这时必须已经确定是低的。 */
hal_err_t err = rail_en_init();
const esp_reset_reason_t why = esp_reset_reason();
/* 事件日志**最先**起(A15 挪到这里):下面的启动序列会记事件(缺件 / mux / PD),
它们必须排在这次的 BOOT 之后,否则时间线上看着像是上一次运行留下的。
BOOT 的 arg = 复位原因 —— §10.10「看门狗复位 … 记一条复位事件」就是它。
只碰 flash,不碰总线,放在 EN 脚配置之前也安全(那几根线此刻靠下拉关着)。
⚠️ 失败**不 abort**:日志是排查手段,不是安全链的一环(§10.2)。 */
hal_err_t err = evlog_init_ex((uint8_t)why);
if (err != HAL_OK) {
ESP_LOGE(TAG, "EN 引脚初始化失败:%s", hal_err_str(err));
ESP_LOGE(TAG, "事件日志落盘不可用:%s —— 事件只留在 RAM 里", hal_err_str(err));
} else {
evlog_stats_t st;
evlog_stats(&st);
ESP_LOGI(TAG, "事件日志:容量 %u 条,flash 里已有 %u~%u(复位原因 %d)",
(unsigned)evlog_capacity(), (unsigned)st.oldest_seq, (unsigned)st.next_seq,
(int)why);
}
nvs_up();
s_want = want_load();
/* §10.9 第 1~4 步:EN 脚安全态 → Hub 脉冲(仅上电复位)→ 总线 → 认件 / 初始化。
整段在 bringup.c 里、宿主机上测得到(A15 之前它在这里,一行也测不到)。
缺件一律降级不 abort;返回错误 = 引脚 / 配置常量写错了,那才 abort。 */
static bringup_report_t boot;
const bringup_in_t in = { .power_on_reset = (why == ESP_RST_POWERON) };
err = bringup_devices(&in, &boot);
if (err != HAL_OK) {
ESP_LOGE(TAG, "启动序列失败(固件配置错误):%s", hal_err_str(err));
abort();
}
/* ⚠️ R2:这一步关掉的**只有五路直供**(`EFUSE_EN` 是 GPIO 直连)。
`VBUS_EN` 已经挪到刀片的 PCA9555 P0.4 上了(计划 §5.4),此刻还不受控 ——
它靠的是硬件:PCA9555 复位后全是输入(高阻)+ R701 100 kΩ 下拉 = VBUS 断。
真正"固件说了算"要等到下面的 `slot_init()`。 */
ESP_LOGI(TAG, "五路 eFuse 使能已全部拉低(VBUS 此刻靠下拉电阻关着)");
/* USB Hub 复位脉冲 —— 只在上电复位时、只此一次,见函数头上那一段。 */
hub_reset_pulse_once();
/* 锁必须早于任何一个用总线的任务。
⚠️ R2 之后**全系统只剩一把** `HAL_LOCK_I2C`(595 那把随着移位链一起退役),
所以「两把锁嵌套」在物理上不可能了;代价是执行器与巡检开始争同一把锁
(计划 §5.6)。 */
(void)hal_lock_init();
const hal_i2c_cfg_t i2c = {
.sda_gpio = BOARD_PIN_I2C_SDA, /* ⭐ R2:IO41(R1 是 IO14) */
.scl_gpio = BOARD_PIN_I2C_SCL, /* ⭐ R2:IO42(R1 是 IO15) */
.freq_hz = 400000,
};
err = hal_i2c_init(&i2c);
if (err != HAL_OK) {
ESP_LOGE(TAG, "I²C 初始化失败:%s", hal_err_str(err));
abort();
}
/* ⭐ R2 新增:带 mux 的总线门面。配 `MUX_RST#`、认 TCA9548A(0x70)、把通道全关。
**必须在 `hal_i2c_init()` 与 `hal_lock_init()` 之后**(i2cbus.h)。
⚠️ 失败**不 abort**:mux 认不到是**运行期**的降级情形(计划 §5.8 —— 判据会
先脉冲 `MUX_RST#` 再决定要不要五路全断),不是固件写错了。这与
`selftest_or_die()` 的分界线始终是同一条:常量表错 = abort,器件不在 = 降级。 */
err = i2cbus_init();
if (err != HAL_OK) {
ESP_LOGE(TAG, "TCA9548A(0x%02x) 初始化失败:%s —— 五路的电压电流这一刻全看不见",
(unsigned)TCA9548A_ADDR, hal_err_str(err));
}
/* ⭐ R2 新增:五根 `PRSNT#` 配成上拉输入并立刻读一遍(`slot_prsnt_init()` 自带
一次 poll)。**必须在 `slot_init()` 之前**:下面要拿它决定哪几槽值得初始化。
⚠️ 这一步失败就 abort:它只可能因为引脚号非法而失败,而那正是
`rail_selftest()` 的 `PINS` 位刚刚查过的东西 —— 走到这里还失败 = 固件写错了。
而且降级的方向是**不安全**的那一侧:`slot_present()` 读不到时返回"在位",
于是空槽也会被允许上电 = 金手指裸露带 12V(计划 §5.8)。 */
err = slot_prsnt_init();
if (err != HAL_OK) {
ESP_LOGE(TAG, "PRSNT# 引脚初始化失败:%s", hal_err_str(err));
abort();
}
/* ⭐⭐ R2 必须有的一行(计划 §5.9):底板输入侧那片 INA226(U13, 0x43)的
`IN+`/`IN−` **在板上是对调接的**(pin9 = `VIN_SW` 上游、pin10 = `+12V` 下游)。
漏了这一行,正常供电时它读回来的电流是负的,SW-8 会把「板子在好好工作」
判成**反灌**,于是一上电就断 VBUS —— 而电压、功率、温度全都正常,
日志里只会看到反复的 `LOG_EV_REVERSE`。
⚠️ 这是纯软件状态(驱动里一个标志位),不碰总线,所以放在认件之前也没关系;
但必须早于 `t_safety` 起来,那才是读它的人。 */
ina226_set_reversed(INA226_ADDR_INPUT, true);
/* ⭐ R2 新增:逐片刀片的 PCA9555 初始化(自带 `i2cbus_xact`,各走各的 mux 通道)。
驱动里那个框保证了顺序 **输出锁存器先清零、方向最后改** —— 反过来的那一刻
P0.0~P0.4 全输出 1 = 三个键一起按下 + EDL 短接 + VBUS 上电(计划 §5.3)。
这一步之后 `VBUS_EN` 才真的由固件说了算。
⚠️ 失败**不 abort**(同 `i2cbus_init()`:这是运行期降级)。代价写在 §5.4:
PCA9555 失联 ⇒ 该路 VBUS **关不掉**,兜底只剩关 `EFUSE_EN`。 */
for (int rail = 1; rail <= PHONERAIL_RAIL_COUNT; rail++) {
if (!slot_present(rail)) {
/* 空槽不去初始化:那一片器件本来就不在,只会白等五次 NAK。
这一路也不许上电 —— 判据在 `t_safety` 里每圈查(计划 §5.8)。 */
ESP_LOGW(TAG, "槽 %d 没插刀片(PRSNT# 为高):跳过初始化,且不许上电", rail);
continue;
}
const hal_err_t e = slot_init(rail);
if (e != HAL_OK) {
ESP_LOGE(TAG, "槽 %d 的 PCA9555(0x%02x) 初始化失败:%s —— 这一路的 VBUS "
"关不掉了,兜底只剩 EFUSE_EN",
rail, (unsigned)PCA9555_ADDR, hal_err_str(e));
}
}
/* ⚠️ 还没做的:INA226 的 Config/Calibration 与 DAC 的上电值(A15 那一包)。
在那之前 t_safety 读回来的电流恒为 0(Calibration 还没写),
这是预期的,不是故障。 */
log_report(&boot);
/* 执行器:PG 从巡检的快照里取,本任务自己不碰 I²C(t_actuator.h 开头那一段)。
⚠️ 必须在 `spawn(TASK_SAFETY, ...)` 之前:判据要关路时会先 `t_actuator_abort()`,
@@ -396,15 +374,31 @@ void app_main(void)
t_actuator_init(act_pg_from_safety, NULL);
/* 命令处理器(A12)。status 读巡检快照;热管理快照等 A16 把任务体接上再给。
`power` 命令的意图经 `want_save` 存 NVS(A15)。
队列要在执行器任务起来之前建好 —— 那个任务第一件事就是等它。 */
const cmd_env_t cmd_env = { .safety = &s_snap, .thermal = NULL };
const cmd_env_t cmd_env = { .safety = &s_snap, .thermal = NULL, .intent = want_save };
cmd_init(&cmd_env);
s_cmd_q = xQueueCreate(CMD_QUEUE_DEPTH, sizeof(cmd_msg_t));
if (s_cmd_q == NULL) {
ESP_LOGE(TAG, "命令队列建不起来");
abort();
}
/* A13:USB 命令链路。装驱动 + 把日志复制一份到 USB(UART0 照旧)。
/* 事件出口。要在巡检起来之前接上,否则第一批事件没人接。 */
t_safety_set_event_sink(log_safety_event, NULL);
/* ⭐ 巡检**先于上电**起来(bringup.h「分两段」那一段):
五路一开始带电,SR-5 / SW-8 就已经在看了。 */
spawn(TASK_SAFETY, task_safety);
/* §10.9 第 5~6 步:按上次的意图逐路上电、错开 100 ms、等 PG(阻塞,最坏约 0.9 s)。 */
bringup_power_restore(s_want, &boot);
ESP_LOGI(TAG, "上电恢复:意图 0x%02x → 开着 0x%02x(等不到 PG 0x%02x,确认不了 0x%02x)",
(unsigned)boot.want, (unsigned)boot.on, (unsigned)boot.pg_fail,
(unsigned)boot.unverified);
/* §10.9 第 7 步:链路与其余任务。
A13:USB 命令链路。装驱动 + 把日志复制一份到 USB(UART0 照旧)。
失败不 abort:USB 命令链路不可用,供电与别的链路照常(§10.2)。 */
err = ulink_esp_init();
if (err != HAL_OK) {
@@ -416,26 +410,6 @@ void app_main(void)
ESP_LOGE(TAG, "WiFi 命令链路不可用:%s", hal_err_str(err));
}
/* 事件日志(§10.7)。**要在接事件出口之前 init**,否则第一条事件进的是
一个还没扫过 flash 的缓冲。
⚠️ 失败**不 abort**:没有 `evlog` 分区时日志落不了盘,但五路照供电
—— 日志是排查手段,不是安全链的一环(§10.2)。这与 `selftest_or_die()`
的区别在于:那里错的是「命令会发到哪一路」,这里错的只是「记不下来」。 */
err = evlog_init();
if (err != HAL_OK) {
ESP_LOGE(TAG, "事件日志落盘不可用:%s —— 事件只留在 RAM 里", hal_err_str(err));
} else {
evlog_stats_t st;
evlog_stats(&st);
ESP_LOGI(TAG, "事件日志:容量 %u 条,flash 里已有 %u~%u",
(unsigned)evlog_capacity(), (unsigned)st.oldest_seq,
(unsigned)st.next_seq);
}
/* 事件出口。同样要在巡检起来之前接上,否则第一批事件没人接。 */
t_safety_set_event_sink(log_safety_event, NULL);
spawn(TASK_SAFETY, task_safety);
spawn(TASK_THERMAL, task_thermal);
spawn(TASK_ACTUATOR, task_actuator);
spawn(TASK_LINK, task_link);