#!/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()