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 @@
+
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)
-```
+
+
+> 图源:`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'
+ % (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()