文件
phonerail/firmware/main/app_main.c
T
zhou yang和Claude Opus 5 18d3d69856 A11:事件日志(环形缓冲 + 落盘)
事件从 evlog_put*() 进一个 256 条的 RAM 环(临界区里两条指令,**不阻塞、
不碰 flash**),由最低优先级的 t_log 攒够 16 条或最老那条等满 1 s 时批量写进
新的 `evlog` 分区(17 扇区 × 4 KB = **4335 条**)。A10 的事件出口原样接上,
判据那一层一个字没动。

八条实现约定(详见需求文档 §10.7):

· 落盘不用 NVS 也不用 SPIFFS,是一块裸 data 分区 —— NVS 的页式 GC 会把
  「写一条事件」变成一次不可预测的搬移。HAL 因此多出第六样 hal_store;
· 17 个扇区不是 16:每扇区要拿一个 16 B 的槽当扇区头,4 KB 只装 255 条,
  16 扇区 = 4080,差 16 条不到 §10.7 要求的 4096;
· 序号 = flash 里的位置(epoch × 255 + 槽号),不是自增计数器 —— 重启接着数
  不用存计数器;代价是没落盘的事件还没有序号,A12 读之前要先 flush;
· 入队走关中断的临界区,**不是第三把锁**:入队方是最高优先级的 t_safety,
  停在一把 t_log 持着的锁上等于让最低优先级的任务决定巡检节拍;
· 丢事件可以,悄悄丢不行 —— 缓冲满了盖最旧的,但补一条 LOG_EV_LOST 记着丢了几条;
· 每条记录带 CRC-8(见下),写失败就放弃该扇区剩余部分,被放弃的那段在序号
  空间里是个洞,读回时给一条空记录占位(跳过的话上位机游标会卡住);
· 每次上电记一条 LOG_EV_BOOT:时间戳上电从 0 计,没有它两次上电的记录会穿插。

事件类型的数字是 flash 格式的一部分,只许往末尾加;safety_ev_t → log_ev_t
是显式 switch 不是加偏移。§10.7 里还没人产生的六类号已占住(A12/A15/A16 去填)。

27 个用例;23 条注错逐个跑过,21 条变红。第一版校验和用异或,漏掉的正是最常见
的那条:payload 全 0 的记录被掉电切在第 8 个字节上时,后 8 个字节留在 0xFF,
而 8 个 0xFF 的异或恰好是 0 —— 半条的校验和与整条一模一样,扫描把半条当成
一条真事件。换成 CRC-8 之后拦住。另外两个只在这里才踩得到的坑:「掉电重启」
不能用 sim_hal_reset()(那是换新板子,flash 一起擦,测试永远绿),为此加了
sim_hal_reboot();「读到洞就跳过」这个错被测试里 static 数组的零初始化掩盖过
一次,缓冲要先填成非零。

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 09:20:02 +08:00

257 行
11 KiB
C
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/* ESP32-S3 入口。只在 ESP-IDF 目标里编译,宿主机构建不包含本文件。 */
#include <stdlib.h>
#include "board_pins.h"
#include "esp_log.h"
#include "evlog.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "hal_i2c.h"
#include "hal_lock.h"
#include "hal_time.h"
#include "phonerail.h"
#include "rail_cfg.h"
#include "sched.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;
if (!s_snap.io_ok) {
return false;
}
*pg = pca9555_rail_pg(&s_snap.io, rail); /* 极性只在驱动里出现一次 */
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 + 载板 LM75 → 风扇 PWM 闭环(TR-6 的 TACH 采样窗口)。 */
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));
}
}
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());
selftest_or_die();
/* 十根 EN 线配成输出并全部拉低。**先于一切外设**:DAC / eFuse 一上电就有
输出,这两根线这时必须已经确定是低的。 */
hal_err_t err = rail_en_init();
if (err != HAL_OK) {
ESP_LOGE(TAG, "EN 引脚初始化失败:%s", hal_err_str(err));
abort();
}
ESP_LOGI(TAG, "五路输出与 VBUS 全部关闭");
/* 两把锁必须早于任何一个用总线的任务。 */
(void)hal_lock_init();
const hal_i2c_cfg_t i2c = {
.sda_gpio = BOARD_PIN_I2C_SDA,
.scl_gpio = BOARD_PIN_I2C_SCL,
.freq_hz = 400000,
};
err = hal_i2c_init(&i2c);
if (err != HAL_OK) {
ESP_LOGE(TAG, "I²C 初始化失败:%s", hal_err_str(err));
abort();
}
/* ⚠️ 器件初始化(INA226 的 Config/Calibration、DAC、PCA9555 方向、595 安全值)
与**降级矩阵**是 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());
}