From 0ac68605ddc2a38b59fb5a9d9c7568076f6dff83 Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Thu, 1 Oct 2026 06:35:57 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9C=8B=E9=97=A8=E7=8B=97=20=C2=A73.2=20?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E5=9B=BE=E9=87=8D=E7=BB=98=E4=B8=BA=20SVG?= =?UTF-8?q?=EF=BC=9A=E4=BB=BB=E5=8A=A1=E7=BD=AE=E4=BD=8D=E2=86=92=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E8=80=85=E5=88=A4=E5=AE=9A=E2=86=92=E5=96=82=E7=8B=97?= =?UTF-8?q?=20+=20=E6=97=B6=E9=97=B4=E8=BD=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/images/看门狗心跳模型.svg | 126 +++++++++ docs/子系统设计/看门狗设计说明.md | 36 ++- docs/架构设计/图形绘制流程.md | 1 + tools/arch_diagram/README.md | 1 + tools/arch_diagram/gen_watchdog_svg.py | 354 +++++++++++++++++++++++++ 5 files changed, 498 insertions(+), 20 deletions(-) create mode 100644 docs/images/看门狗心跳模型.svg create mode 100644 tools/arch_diagram/gen_watchdog_svg.py diff --git a/docs/images/看门狗心跳模型.svg b/docs/images/看门狗心跳模型.svg new file mode 100644 index 00000000..30d6cb44 --- /dev/null +++ b/docs/images/看门狗心跳模型.svg @@ -0,0 +1,126 @@ + +看门狗心跳模型 · 任务置位 → 校验者判定 → 校验者喂狗 +IWDG + 单字心跳位图 · 由 defaultTask 独占喂狗权(唯一喂狗者) + +生产者(任务置位) + +心跳位图 + +校验者(喂狗者) + +喂狗 + +停喂 / 复位 + +各任务主循环(生产者)—— 每轮只置位,不清零 +s_hb_bits |= (1u << MY_ID) + + +b0 +defaultTask +配置属主 / syslog 唯一写者 · 50ms 基准 + + +b1 +netTask +net_poll 循环 · 1ms 节拍 + + +b2 +ftpTask +lftpd 事件循环(需在 lftpd 侧埋点) + + +b4 +uiTask +LVGL 主循环 · 10ms 节拍 + + +b5 +timeSyncTask +NTP 轮询 · ≤50ms + +心跳位图 s_hb_bits(32 位单字) +就绪掩码 0x37 = bit0|1|2|4|5(bit3=testTask 默认不在内) + +0 + +1 + +2 + +3 + +4 + +5 + + + + + + + + + +defaultTask 主循环(唯一喂狗者 · 消费者) + +① 节流:每 100ms 抽样一次 +now - last_tick < 100ms 则直接返回(defaultTask 50ms 循环的整数倍) + + + +② 取走并清零(单字原子) +snapshot = s_hb_bits; s_hb_bits = 0; ← 读-改-写天然原子,免临界区 + + + +③ 掩码判定 +snapshot == 0x37(bit0|1|2|4|5 全中)? + + + + +快照 + + + + + + + +是 +否 + +全员到位 → 喂狗 +s_miss_streak = 0 +IWDG->KR = 0xAAAA; + +有缺席 → 累计,不喂狗 +s_miss_total++ / streak++ +连续 8 次 → 打 [WD] task stall 后停喂 + +停喂之后:时间轴上的三个时刻(0.8s 停喂 → 1.7s 最坏溢出 → 2.5s 标称) + +仍照常喂狗 +miss_streak 1 → 8 + +已停喂 → 等 IWDG 自然溢出 +worst case(LSI 47kHz) + +标称溢出窗口 +LSI 32kHz + +0s + +0.8s + +1.7s + +2.5s + + + +裕度 ≈ 900ms +喂狗 = "所有被监视任务都在 100ms 窗口内动过";任一任务卡死 → 位图长期残缺 → 停喂 → IWDG 复位(保留 IWDGRSTF 权威归因)。对应 task_watchdog.c / 设计说明 §3.2。 + diff --git a/docs/子系统设计/看门狗设计说明.md b/docs/子系统设计/看门狗设计说明.md index d844e91f..59d674a5 100644 --- a/docs/子系统设计/看门狗设计说明.md +++ b/docs/子系统设计/看门狗设计说明.md @@ -204,30 +204,26 @@ IWDG 是**"写键值"式外设**:写 `0x5555` 解锁 → 配 `PR`/`RLR` → ### 3.2 采用方案:任务置位 → 校验者判定 → 校验者喂狗 -``` - ┌──────────────── 各自任务循环 ────────────────┐ - │ defaultTask / netTask / ftpTask / uiTask │ - │ / timeSyncTask / testTask │ - │ 每轮循环:s_hb_bits |= (1u << MY_ID) │ ← 无锁,单字原子写 - └───────────────────────┬──────────────────────┘ - │ (位图,仅置位,不清零) - ▼ - ┌──────────── defaultTask(唯一喂狗者)─────────┐ - │ 每 100 ms 抽样: │ - │ snapshot = s_hb_bits; s_hb_bits = 0; │ ← 取走并清零 - │ if (snapshot != ALL_REQUIRED_MASK) │ - │ → 有任务在 100 ms 内没动过 │ - │ → 累计未达标次数 / 记录缺失任务名 │ - │ if (连续 N 次未达标) HAL_IWDG_Refresh(); │ ← 只在全员健康时喂 - └───────────────────────────────────────────────┘ - │ - ▼ - IWDG(超时 2.5 s,PR=4 / RLR=1249) -``` +![看门狗心跳模型:任务置位 → 校验者判定 → 校验者喂狗](../images/看门狗心跳模型.svg) + +> 图源:`tools/arch_diagram/gen_watchdog_svg.py`(改图改脚本重跑,见 +> [`../架构设计/图形绘制流程.md`](../架构设计/图形绘制流程.md))。 **语义**:喂狗 = "**所有被监视的任务都在窗口内动过**"。 任何单个任务卡死 → 位图长期残缺 → 喂狗停止 → 2.5 s 后 IWDG 复位。 +具体到代码(`App/util/task_watchdog.c`): + +| 阶段 | 代码 | +|---|---| +| ① 任务置位(生产者) | `s_hb_bits \|= (1u << id)`(`task_wd_beat`,仅置位不清零) | +| ② 校验者取走清零 | `snapshot = s_hb_bits; s_hb_bits = 0u;`(`task_wd_poll`,单字原子) | +| ③ 掩码判定 | `snapshot == wd_required_mask()`(默认 `0x37` = bit0\|1\|2\|4\|5) | +| ④ 判定为真 → 喂狗 | `s_miss_streak = 0u; IWDG->KR = 0x0000AAAAu;` | +| ④ 判定为假 → 累计 | `s_miss_total++`;连续 8 次(800 ms)后打 `[WD] task stall` 并**停喂** | + +> 图中时间轴的四个时刻(0 / 0.8 s / 1.7 s / 2.5 s)与约 900 ms 裕度的推导见 §4.3。 + ### 3.3 为什么心跳位图用"取走并清零" ``` diff --git a/docs/架构设计/图形绘制流程.md b/docs/架构设计/图形绘制流程.md index 2bdebdb7..e766add1 100644 --- a/docs/架构设计/图形绘制流程.md +++ b/docs/架构设计/图形绘制流程.md @@ -48,6 +48,7 @@ | `gen_cfg_arch_svg.py` | `docs/images/配置文件分层架构.svg` | 配置引擎分层 | | `gen_net_stack_svg.py` | `docs/images/BSD网络栈分层.svg` | BSD 网络栈分层 | | `gen_memory_svg.py` | `docs/images/内存布局_*.svg` | RAM / FLASH 布局 | +| `gen_watchdog_svg.py` | `docs/images/看门狗心跳模型.svg` | `TASKS` / `REQ_BITS` / `POLL_STEPS` / `TIMELINE` | --- diff --git a/tools/arch_diagram/README.md b/tools/arch_diagram/README.md index da9f7993..07924c27 100644 --- a/tools/arch_diagram/README.md +++ b/tools/arch_diagram/README.md @@ -15,6 +15,7 @@ | `gen_frame_svg.py` | 协议帧格式图 | RemoDispBus 帧结构 | | `gen_cfg_arch_svg.py` | 配置引擎架构图 | 配置分层与持久化 | | `gen_partition_svg.py` | 分区图 | 存储分区 | +| `gen_watchdog_svg.py` | 看门狗心跳模型图 | IWDG + 任务心跳位图(置位→判定→喂狗 + 时间轴) | ## 使用方式 diff --git a/tools/arch_diagram/gen_watchdog_svg.py b/tools/arch_diagram/gen_watchdog_svg.py new file mode 100644 index 00000000..5f9c950e --- /dev/null +++ b/tools/arch_diagram/gen_watchdog_svg.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""生成 docs/images/看门狗心跳模型.svg —— IWDG + 任务心跳位图流程图(纯标准库)。 + +对应 docs/子系统设计/看门狗设计说明.md §3.2「采用方案: +任务置位 → 校验者判定 → 校验者喂狗」。 + +结构:左右两栏 + 底部时间轴 + 左 = 各任务循环(生产者):s_hb_bits |= (1u << MY_ID),仅置位不清零 + 右 = defaultTask 唯一喂狗者(消费者):100ms 抽样 → 取走清零 → 掩码判定 + 全中 → 喂狗;有缺 → 累计 miss_streak(容忍 8 次) + 下 = 停喂 → IWDG 自然溢出 → 复位(时间轴:0 / 0.8s / 1.7s / 2.5s) + +为什么不用 Mermaid:原 ASCII 图是"生产者-消费者 + 判定分支",需要精确对齐 +位图行与判定框,且时间轴的"包含关系"必须按比例表达,Mermaid 做不到。 + +用法(仓库根目录,任意 python3): + python tools/arch_diagram/gen_watchdog_svg.py +输出: + docs/images/看门狗心跳模型.svg +改流程时:编辑本文件顶部的 TASKS / BITS / POLL_STEPS / TIMELINE 数据后重跑。 +""" +import html +import math +import os + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.path.join(ROOT, "docs", "images", "看门狗心跳模型.svg") + +# ----------------------------- 画布与样式 ----------------------------- +W = 1440 +PAD = 40 +FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" + +TITLE_FONT = 21 +SUB_FONT = 13 +PANEL_TITLE_FONT = 15 +CARD_TITLE_FONT = 14 +CARD_NOTE_FONT = 11.5 +LABEL_FONT = 11.5 +FOOT_FONT = 12 + +# 调色板(docs/架构设计/图形绘制流程.md §3.1) +C_PROD, BG_PROD = "#2563eb", "#eff6ff" # 蓝:生产者(任务置位) +C_BIT, BG_BIT = "#0d9488", "#f0fdfa" # 青:心跳位图 +C_CONS, BG_CONS = "#7c3aed", "#f5f3ff" # 紫:校验者(喂狗者) +C_OK = "#16a34a" # 绿:喂狗 +C_WARN, BG_WARN = "#d97706", "#fffbeb" # 琥珀:累计缺失 +C_FAIL, BG_FAIL = "#e11d48", "#fff1f2" # 玫红:停喂 / 复位 + +ARROW_W = 2.2 + +# ----------------------------- 数据(改这里) ----------------------------- +# 任务(生产者):bit = 心跳位序号(task_watchdog.h 的枚举值) +TASKS = [ + dict(name="defaultTask", bit=0, note="配置属主 / syslog 唯一写者 · 50ms 基准"), + dict(name="netTask", bit=1, note="net_poll 循环 · 1ms 节拍"), + dict(name="ftpTask", bit=2, note="lftpd 事件循环(需在 lftpd 侧埋点)"), + dict(name="uiTask", bit=4, note="LVGL 主循环 · 10ms 节拍"), + dict(name="timeSyncTask", bit=5, note="NTP 轮询 · ≤50ms"), +] + +# 必需掩码(wd_required_mask):5 位全要;bit3 = testTask 不在默认掩码 +REQ_BITS = [0, 1, 2, 4, 5] + +# 校验者判定步骤(右栏自上而下) +POLL_STEPS = [ + dict(title="① 节流:每 100ms 抽样一次", + note="now - last_tick < 100ms 则直接返回(defaultTask 50ms 循环的整数倍)", + color=C_CONS, bg=BG_CONS), + dict(title="② 取走并清零(单字原子)", + note="snapshot = s_hb_bits; s_hb_bits = 0; ← 读-改-写天然原子,免临界区", + color=C_BIT, bg=BG_BIT), + dict(title="③ 掩码判定", + note="snapshot == 0x37(bit0|1|2|4|5 全中)?", + color=C_CONS, bg=BG_CONS), +] + +# 判定分支结果 +BRANCH_OK = dict(title="全员到位 → 喂狗", + note="s_miss_streak = 0", + note2="IWDG->KR = 0xAAAA;", + color=C_OK, bg="#f0fdf4") +BRANCH_NG = dict(title="有缺席 → 累计,不喂狗", + note="s_miss_total++ / streak++", + note2="连续 8 次 → 打 [WD] task stall 后停喂", + color=C_WARN, bg=BG_WARN) + +# 时间轴刻度(秒)与事件 +T_AXIS = [0.0, 0.8, 1.7, 2.5] +TIMELINE = [ + dict(seg=(0.0, 0.8), label="仍照常喂狗", sub="miss_streak 1 → 8", color=C_OK), + dict(seg=(0.8, 1.7), label="已停喂 → 等 IWDG 自然溢出", sub="worst case(LSI 47kHz)", color=C_FAIL), + dict(seg=(1.7, 2.5), label="标称溢出窗口", sub="LSI 32kHz", color=C_WARN), +] + +FOOT = ("喂狗 = \"所有被监视任务都在 100ms 窗口内动过\";任一任务卡死 → 位图长期残缺 → " + "停喂 → IWDG 复位(保留 IWDGRSTF 权威归因)。对应 task_watchdog.c / 设计说明 §3.2。") + + +# ----------------------------- 工具函数 ----------------------------- +def esc(s): + return html.escape(s, quote=True) + + +def text_w(s, fs): + """粗略估算文本像素宽:CJK/全角 ≈ 1.0em,其余 ≈ 0.58em(宁可偏大)。""" + w = 0.0 + for ch in s: + w += fs * (1.0 if ord(ch) >= 0x2E80 else 0.58) + return w + + +# ----------------------------- 生成 ----------------------------- +def build(): + out = [] + add = out.append + + def line(x1, y1, x2, y2, color, head=True, width=ARROW_W, dash=False): + d = ' stroke-dasharray="7 5"' if dash else '' + add('' % (x1, y1, x2, y2, color, width, d)) + if head: + ang = math.atan2(y2 - y1, x2 - x1) + hl, hw = 11.0, 5.5 + bx, by = x2 - hl * math.cos(ang), y2 - hl * math.sin(ang) + px, py = -math.sin(ang) * hw, math.cos(ang) * hw + add('' + % (x2, y2, bx + px, by + py, bx - px, by - py, color)) + + def box(x, y, w, h, color, bg, title, note, title_fs=CARD_TITLE_FONT, + note_fs=CARD_NOTE_FONT, stroke_op=0.55, note2=None): + add('' + % (x, y, w, h, bg, color, stroke_op)) + add('%s' + % (x + 14, y + 24, title_fs, esc(title))) + add('%s' + % (x + 14, y + 24 + note_fs + 7, note_fs, esc(note))) + if note2: + add('%s' + % (x + 14, y + 24 + (note_fs + 7) * 2, note_fs, esc(note2))) + + # ---- 布局参数 ---- + PANEL_Y = 148 + PANEL_H = 584 # 由左栏内容(5 任务 + 心跳位图带 + 位格)反推 + LB_X, LB_W = PAD, 470 # 左栏:任务(生产者) + RB_X = LB_X + LB_W + 34 # 右栏:校验者(消费者) + RB_W = W - PAD - RB_X + + TASK_H, TASK_GAP = 62, 18 + sx = LB_X + 22 + sw = LB_W - 44 + + # ---- 标题 ---- + add('%s' + % (PAD, TITLE_FONT, esc("看门狗心跳模型 · 任务置位 → 校验者判定 → 校验者喂狗"))) + add('%s' + % (PAD, SUB_FONT, + esc("IWDG + 单字心跳位图 · 由 defaultTask 独占喂狗权(唯一喂狗者)"))) + + # ---- 图例 ---- + lx, ly = PAD, 110 + for color, label in [(C_PROD, "生产者(任务置位)"), (C_BIT, "心跳位图"), + (C_CONS, "校验者(喂狗者)"), (C_OK, "喂狗"), + (C_FAIL, "停喂 / 复位")]: + add('' % (lx, ly - 12, color)) + lx += 22 + add('%s' % (lx, ly, esc(label))) + lx += text_w(label, 12) + 30 + + # ---- 左栏:任务(生产者) ---- + add('' % (LB_X, PANEL_Y, LB_W, PANEL_H)) + add('%s' + % (LB_X + 20, PANEL_Y + 30, PANEL_TITLE_FONT, + esc("各任务主循环(生产者)—— 每轮只置位,不清零"))) + add('%s' + % (LB_X + 20, PANEL_Y + 54, C_PROD, esc("s_hb_bits |= (1u << MY_ID)"))) + + task_y = [] + y = PANEL_Y + 72 + for t in TASKS: + add('' % (sx, y, sw, TASK_H, BG_PROD, C_PROD)) + # 位号徽标 + add('' + % (sx + 12, y + 10, C_PROD)) + add('%s' + % (sx + 29, y + 26.5, esc("b" + str(t["bit"])))) + add('%s' + % (sx + 56, y + 26, CARD_TITLE_FONT, esc(t["name"]))) + add('%s' + % (sx + 56, y + 46, CARD_NOTE_FONT, esc(t["note"]))) + task_y.append(y + TASK_H / 2.0) + y += TASK_H + TASK_GAP + + # ---- 心跳位图(左栏底部横带) ---- + bm_y = y + 6 + bm_h = 48 + add('' % (sx, bm_y, sw, bm_h, BG_BIT, C_BIT)) + add('%s' + % (sx + 14, bm_y + 20, C_BIT, esc("心跳位图 s_hb_bits(32 位单字)"))) + add('%s' + % (sx + 14, bm_y + 38, esc("就绪掩码 0x37 = bit0|1|2|4|5(bit3=testTask 默认不在内)"))) + # 各位方格 + bx0 = sx + 14 + bw, bgap = 34, 6 + for i in range(6): + cx = bx0 + i * (bw + bgap) + cy = bm_y + bm_h + 8 + on = i in REQ_BITS + add('' + % (cx, cy, bw, BG_BIT if on else "#f1f5f9", C_BIT if on else "#94a3b8", + 0.7 if on else 0.4)) + add('%s' + % (cx + bw / 2.0, cy + 15, C_BIT if on else "#94a3b8", esc(str(i)))) + + # 各任务 → 位图:右侧竖直总线汇入位图带右缘 + bus_x = sx + sw - 4 + for t in TASKS: + ty = task_y[TASKS.index(t)] + line(sx + sw, ty, bus_x, ty, C_PROD, head=False) + line(bus_x, task_y[0], bus_x, bm_y + bm_h / 2.0, C_PROD, head=False) + line(bus_x, bm_y + bm_h / 2.0, sx + sw, bm_y + bm_h / 2.0, C_PROD) + + # ---- 右栏:校验者(defaultTask) ---- + add('' % (RB_X, PANEL_Y, RB_W, PANEL_H)) + add('%s' + % (RB_X + 20, PANEL_Y + 30, PANEL_TITLE_FONT, + esc("defaultTask 主循环(唯一喂狗者 · 消费者)"))) + + st_x = RB_X + 22 + st_w = RB_W - 44 + STEP_H, STEP_GAP2 = 62, 40 + sy = PANEL_Y + 56 + step_cy = [] + for i, s in enumerate(POLL_STEPS): + box(st_x, sy, st_w, STEP_H, s["color"], s["bg"], s["title"], s["note"]) + step_cy.append(sy + STEP_H / 2.0) + if i < len(POLL_STEPS) - 1: + line(st_x + st_w / 2, sy + STEP_H, st_x + st_w / 2, sy + STEP_H + STEP_GAP2, + s["color"]) + sy += STEP_H + STEP_GAP2 + + # 位图 → 校验者 ②(取走清零):正交走线(右出 → 上折 → 入盒) + tap_y = bm_y + bm_h / 2.0 + line(sx + sw, tap_y, st_x - 22, tap_y, C_BIT, head=False) + line(st_x - 22, tap_y, st_x - 22, step_cy[1], C_BIT, head=False) + line(st_x - 22, step_cy[1], st_x, step_cy[1], C_BIT) + add('%s' + % (sx + sw + 6, tap_y - 8, LABEL_FONT, C_BIT, esc("快照"))) + + # ---- 判定分支:喂狗 / 累计停喂 ---- + br_y = sy - STEP_GAP2 + 16 + br_w = (st_w - 24) / 2.0 + ok_x, ng_x = st_x, st_x + br_w + 24 + + # 分支引线:从 ③ 下沿分叉(fork 线画在 ③ 之下 8px 处,避免压住盒底) + fork_y = step_cy[2] + STEP_H / 2.0 + line(st_x + st_w / 2, fork_y, st_x + st_w / 2, fork_y + 14, C_CONS, head=False) + line(st_x + br_w / 2.0, fork_y + 14, ok_x + br_w / 2.0, fork_y + 14, C_OK, head=False) + line(st_x + st_w / 2, fork_y + 14, ng_x + br_w / 2.0, fork_y + 14, C_WARN, head=False) + line(ok_x + br_w / 2.0, fork_y + 14, ok_x + br_w / 2.0, br_y, C_OK) + line(ng_x + br_w / 2.0, fork_y + 14, ng_x + br_w / 2.0, br_y, C_WARN) + + add('%s' + % (ok_x + br_w / 2.0, br_y - 6, C_OK, esc("是"))) + add('%s' + % (ng_x + br_w / 2.0, br_y - 6, C_WARN, esc("否"))) + + br_h = 84 + box(ok_x, br_y, br_w, br_h, BRANCH_OK["color"], BRANCH_OK["bg"], + BRANCH_OK["title"], BRANCH_OK["note"], title_fs=13, note_fs=10.5, + note2=BRANCH_OK["note2"]) + box(ng_x, br_y, br_w, br_h, BRANCH_NG["color"], BRANCH_NG["bg"], + BRANCH_NG["title"], BRANCH_NG["note"], title_fs=13, note_fs=10.5, + note2=BRANCH_NG["note2"]) + + # ---- 时间轴 ---- + tl_y = PANEL_Y + PANEL_H + 34 + tl_h = 150 + add('' % (PAD, tl_y, W - 2 * PAD, tl_h)) + add('%s' + % (PAD + 20, tl_y + 28, PANEL_TITLE_FONT, + esc("停喂之后:时间轴上的三个时刻(0.8s 停喂 → 1.7s 最坏溢出 → 2.5s 标称)"))) + + ax_x0, ax_x1 = PAD + 60, W - PAD - 60 + ax_y = tl_y + 62 + span = T_AXIS[-1] + def ax_pos(t): + return ax_x0 + (ax_x1 - ax_x0) * (t / span) + + # 分段色带 + for it in TIMELINE: + s0, s1 = it["seg"] + x0, x1 = ax_pos(s0), ax_pos(s1) + add('' % (x0, ax_y, x1 - x0, it["color"], )) + add('%s' + % ((x0 + x1) / 2.0, ax_y + 34, it["color"], esc(it["label"]))) + add('%s' + % ((x0 + x1) / 2.0, ax_y + 50, esc(it["sub"]))) + + # 刻度 + for t in T_AXIS: + x = ax_pos(t) + line(x, ax_y - 6, x, ax_y + 14, "#475569", head=False, width=1.6) + add('%ss' % (x, ax_y - 12, esc(("%g" % t)))) + + # 停喂点竖标 + 裕度 + x_stop, x_worst = ax_pos(0.8), ax_pos(1.7) + add('' % (x_stop, ax_y - 4, x_stop, ax_y + 20, C_FAIL)) + add('' % (x_worst, ax_y - 4, x_worst, ax_y + 20, C_WARN)) + add('' % (x_stop, ax_y + 68, x_worst, ax_y + 68, C_OK)) + add('%s' + % ((x_stop + x_worst) / 2.0, ax_y + 84, C_OK, esc("裕度 ≈ 900ms"))) + + # ---- 页脚 ---- + total_h = tl_y + tl_h + 34 + add('%s' + % (PAD, total_h - 14, FOOT_FONT, esc(FOOT))) + + svg = ('\n%s\n\n' + % (W, total_h, W, total_h, FONT, "\n".join(out))) + return svg, total_h + + +def main(): + svg, h = build() + os.makedirs(os.path.dirname(OUT), exist_ok=True) + with open(OUT, "w", encoding="utf-8", newline="\n") as f: + f.write(svg) + import xml.etree.ElementTree as ET + ET.fromstring(svg) + print("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, h, OUT)) + + +if __name__ == "__main__": + main()