/* ESP32-S3 入口。只在 ESP-IDF 目标里编译,宿主机构建不包含本文件。 */ #include #include "board_pins.h" #include "esp_log.h" #include "esp_system.h" #include "evlog.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "hal_gpio.h" #include "hal_i2c.h" #include "hal_lock.h" #include "hal_time.h" #include "i2cbus.h" #include "ina226.h" #include "pca9555.h" #include "phonerail.h" #include "rail_cfg.h" #include "sched.h" #include "slot.h" #include "t_actuator.h" #include "t_safety.h" #include "tasks.h" static const char *TAG = "phonerail"; /* 启动自检(需求 §10.4):五路的地址 / 位号 / 引脚无重复、无越界, 而且那张五路表和四个驱动自己的表没有分家。 ⚠️ **不过就 abort,不降级。** 这里查的全是编译进固件的常量表, 查不过 = 固件本身写错了,不是硬件坏了 —— §10.10 的降级矩阵管的是后者。 带着一张错表跑起来的样子是「命令发给了另一路」,五台手机全在跑、 一个错误码都不报,谁也不会去怀疑它。 */ static void selftest_or_die(void) { /* 五个任务那张表(§10.3):周期与优先级的相对顺序。同样是编译进去的常量, 同样不过就 abort —— 优先级写反了板子照跑,只是巡检会在总线忙时被挤后。 */ if (!task_table_ok()) { ESP_LOGE(TAG, "任务表自检失败:周期或优先级与 §10.3 对不上"); abort(); } const uint32_t bad = rail_selftest(); if (bad == 0) { ESP_LOGI(TAG, "五路配置表自检通过"); return; } for (uint32_t bit = 1; bit != 0; bit <<= 1) { if ((bad & bit) != 0) { ESP_LOGE(TAG, "自检失败:%s (0x%02x)", rail_selftest_str(bit), (unsigned)bit); } } abort(); } /* ---- 五个任务(需求 §10.3) --------------------------------------------- 任务体只在这个文件里,而且都短得能一眼看完 —— 真正的活在 main/ 的其它 .c 里, 那些在宿主机上编得过、测得到。**任务体里不许出现判据与时序**:写进死循环里的 东西,宿主机一行也跑不到。 */ /* 巡检快照。**文件作用域**(A8 时它还在 task_safety 里):t_actuator 的硬重启 序列要等 PG,而 PG 挂在 PCA9555 上、已经被巡检每 20 ms 读一遍了(§10.3)。 执行器再去读一次等于凭空多一份总线占用和一份两边不一致的可能。 ⚠️ 这是跨任务读,但读的只有两个 `bool`(`io_ok` 与一个位), 字节级读写在 Xtensa 上是原子的,不值得为它加第三把锁(hal_lock.h:只有两把)。 */ static safety_snap_t s_snap; /* 喂给 t_actuator 的 PG 取值。返回 false = **这一刻读不到** (PCA9555 失联,或者巡检还没跑过一圈)—— 与「读到了,是低」是两回事。 */ static bool act_pg_from_safety(void *ctx, int rail, bool *pg) { (void)ctx; /* ⭐ R2:PCA9555 变成**每路一片**(五片同构、同地址 0x20,靠 mux 分开), 所以快照里的输入也是每路一份 —— R1 那时全板只有一片,只有一份。 ⚠️ 这里的 `rail - 1` 是**数组下标**,不是由路号算出来的器件编号 (rail_cfg.h 禁的是后者)。器件是哪一片由 `slot_*` 那一层查表决定。 */ if (rail < 1 || rail > PHONERAIL_RAIL_COUNT) { return false; } if (!s_snap.io_ok[rail - 1]) { return false; } /* 极性只在驱动里出现一次。⚠️ R2 的判读函数**不带 rail** —— 五片逐格相同, 位号是板级常量(pca9555.h),传路号进去反而会让人以为五片位表不一样。 */ *pg = pca9555_efuse_pg(&s_snap.io[rail - 1]); return true; } /* A10 的事件出口,A11 把它接到了环形缓冲上(§10.7:`t_safety` 只入队、不落盘)。 `evlog_put_safety()` 自己就是 `safety_ev_fn` 的形状,这里包一层只为了顺带 打一行串口日志 —— 板子插着 USB 调试时,事件同时进日志流与 flash。 ⚠️ 这个回调是在断电之后、不持任何锁的情况下被调的:**入队在前,打印在后**。 `ESP_LOGW` 会去抢串口,把它放在入队之前就是在事件最要紧的那一刻多等一会儿。 */ static void log_safety_event(void *ctx, const safety_event_t *ev) { evlog_put_safety(ctx, ev); ESP_LOGW(TAG, "安全事件 t=%u ms CH%u %s V=%d mV I=%d uA", (unsigned)ev->t_ms, (unsigned)ev->rail, t_safety_ev_str((safety_ev_t)ev->type), (int)ev->bus_mv, (int)ev->current_ua); } static void task_safety(void *arg) { (void)arg; safety_snap_t *const snapp = &s_snap; t_safety_init(snapp); sched_periodic_t tick; sched_periodic_init(&tick, task_desc(TASK_SAFETY)->period_ms); char line[128]; for (;;) { (void)t_safety_step(snapp); /* 器件读失败不在这里处理:§10.2 失联 ≠ 断电 */ /* 每 5 s 报一次总线占用。§10.3 手算是「约 1.5 ms / 约 8 %」, 首板上电时要拿逻辑分析仪复核这个数(§10.3 末尾那句)。 */ if ((snapp->cycles % 250u) == 0u) { t_safety_format(snapp, line, sizeof(line)); ESP_LOGI(TAG, "%s (预算 %u us, 过载 %u, 最迟 %u ms)", line, (unsigned)t_safety_budget_us(), (unsigned)tick.overruns, (unsigned)tick.worst_late_ms); } sched_periodic_wait(&tick); } } /* 下面四个是骨架:周期与优先级已经按 §10.3 定好,任务体等各自的包。 现在就把它们建起来,是为了让 A9~A16 只需要往里填,不用再动启动流程。 */ static void task_thermal(void *arg) { (void)arg; sched_periodic_t tick; sched_periodic_init(&tick, task_desc(TASK_THERMAL)->period_ms); for (;;) { /* A16:两路 NTC(IO4/IO5)→ 风扇 PWM 闭环(TR-6 的 TACH 采样窗口)。 ⚠️ R2 没有 LM75 了(载板整个不存在,计划 §5.10)。 真正的任务体在 `main/t_thermal.c` 里,由 A16 那一包填; **这里不 include 它**,免得两个人同时改到同一处。 */ sched_periodic_wait(&tick); } } /* A9 的三个状态机。任务体还是三行:推一步 / 拿它算出来的下一个截止时刻 / 睡到那时。 判据与时序全在 `t_actuator_step()` 里,那份在宿主机上跑得到。 */ static void task_actuator(void *arg) { (void)arg; for (;;) { /* A12 会把这里换成「带超时地等命令队列」:超时值就是这个返回值。 */ const TickType_t t = pdMS_TO_TICKS(t_actuator_step()); /* ⚠️ `CONFIG_FREERTOS_HZ=100` ⇒ 一个 tick 是 10 ms,`pdMS_TO_TICKS(5)` 是 **0**,而 `vTaskDelay(0)` 只是让一次 CPU —— 那会让这个中优先级任务 在收尾重试时空转,把 t_link / t_log 饿死。至少睡一个 tick。 早醒没关系:每个状态都是拿绝对时刻算的,早来就再返回一个剩余时间。 */ vTaskDelay(t == 0 ? 1 : t); } } static void task_link(void *arg) { (void)arg; for (;;) { /* A12~A14:命令处理器 + 两条传输链路。 */ vTaskDelay(pdMS_TO_TICKS(100)); } } /* A11:唯一碰 flash 的任务。§10.7「`t_log` 空闲时批量写」—— 醒得比攒批周期密(100 ms),但 `evlog_due()` 说不用写就立刻回去睡: 代价是每秒 10 次几十条指令的问询,换来的是掉电丢失窗口的上界就是 `EVLOG_MAX_AGE_MS`,而不是「上一次醒到下一次醒」。 */ static void task_log(void *arg) { (void)arg; evlog_stats_t st; uint32_t reported = 0; for (;;) { if (evlog_due()) { (void)evlog_flush(EVLOG_FLUSH_MAX); } /* 丢过事件就报出来。§10.7 的日志是排查排线接触不良的唯一线索, 而「日志自己丢过东西」这件事必须比日志本身更显眼。 */ evlog_stats(&st); if (st.dropped != reported) { reported = st.dropped; ESP_LOGW(TAG, "事件缓冲丢过 %u 条(落盘 %u / 入队 %u,IO 错 %u)", (unsigned)st.dropped, (unsigned)st.stored, (unsigned)st.queued, (unsigned)st.io_errors); } vTaskDelay(pdMS_TO_TICKS(100)); } } /* USB2517I 的复位脉冲 —— **整个固件里唯一碰 `HUB_RST#` 的地方,而且只在启动调一次**。 低有效(板上 R716 上拉到 +3V3_HUB),给 1 ms 足够:手册要的是几十微秒量级。 ⛔⛔ **运行期一个字都不许再写它**(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) { const esp_reset_reason_t why = esp_reset_reason(); if (why != ESP_RST_POWERON) { ESP_LOGI(TAG, "复位原因 %d 不是上电复位 —— 不动 HUB_RST#(保住 USB 控制台)", (int)why); return; } 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; } (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) { const task_desc_t *d = task_desc(id); /* ⚠️ ESP-IDF 的 xTaskCreate 收的 stack_depth 是**字节**(原版 FreeRTOS 是字)。 */ if (xTaskCreate(fn, d->name, d->stack_bytes, NULL, d->prio, NULL) != pdPASS) { ESP_LOGE(TAG, "建任务失败:%s", d->name); abort(); } } void app_main(void) { ESP_LOGI(TAG, "PhoneRail %s boot, %d rails, t=%u ms", phonerail_version(), PHONERAIL_RAIL_COUNT, (unsigned)hal_time_now_ms()); /* ⛔ 关掉 IDF I²C 驱动自己的错误日志(2026-09-27 上板实测)。它每次 NAK 打三行 E 级日志, 打印是同步的、发生在**我们持着总线锁的时候**,一次 NAK 就吃掉十几 ms —— 20 ms 的巡检节拍直接被它的日志拖垮,而且刷屏把真正有用的启动日志全冲掉。 失败并没有丢:每一次都进 `t_safety` 的 i2c_err 计数,每 10 s 汇总打印一次。 */ esp_log_level_set("i2c.master", ESP_LOG_NONE); selftest_or_die(); /* ⭐ R2:**五根** `EFUSE_EN` 配成输出并全部拉低(R1 是十根:另外五根 `VBUS_EN` 已经挪到刀片的 PCA9555 上了)。**先于一切外设**:DAC / eFuse 一上电就有 输出,这五根线这时必须已经确定是低的。 */ hal_err_t err = rail_en_init(); if (err != HAL_OK) { ESP_LOGE(TAG, "EN 引脚初始化失败:%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 还没写), 这是预期的,不是故障。 */ /* 执行器:PG 从巡检的快照里取,本任务自己不碰 I²C(t_actuator.h 开头那一段)。 ⚠️ 必须在 `spawn(TASK_SAFETY, ...)` 之前:判据要关路时会先 `t_actuator_abort()`, 而没 init 的执行器只会直接返回,那时序列还在跑,电关掉几秒又会被抬回来。 */ t_actuator_init(act_pg_from_safety, NULL); /* 事件日志(§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); spawn(TASK_LOG, task_log); ESP_LOGI(TAG, "五个任务已启动(t_safety %u ms / t_thermal %u ms)," "I²C 预算 %u us 每周期", (unsigned)task_desc(TASK_SAFETY)->period_ms, (unsigned)task_desc(TASK_THERMAL)->period_ms, (unsigned)t_safety_budget_us()); }