diff --git a/docs/images/采样环缓冲_窗首下标.svg b/docs/images/采样环缓冲_窗首下标.svg new file mode 100644 index 00000000..3a991871 --- /dev/null +++ b/docs/images/采样环缓冲_窗首下标.svg @@ -0,0 +1,74 @@ + + +窗首 = head − n:head 不够减会走到负数,所以先加一圈 DEPTH 再减 +例:head = 5、n = 20 —— 最新点在 4,沿环往回退 19 步落在 25,途中跨过 0 与 39。DEPTH = 40 时 20 点窗正好占半圈,所以这条弧看起来是个半圆。 +同一个例子三种写法,只有第三种同时做到「同余正确」与「不发生下溢」: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +往回退 19 步 +0 +10 +20 +30 +head = 5 +start = 25 +环 = 40 格 +窗 = 20 格(半圈) + +✗ 5 - 20 += -15 +负数。C99 的 % 向零截断,(-15) % 40 仍是 -15; +转成无符号就是 65521,拿去索引 s_ring 即越界读。 + +✗ (uint16_t)(5 - 20) % 40 += 65521 % 40 = 1 +65536 不是 40 的倍数:(x mod 65536) mod 40 ≠ x mod 40。 +回绕后再取模不等价,读到的是环尾附近而不是窗首。 + +✓ (5 + 40 - 20) % 40 += 25 % 40 = 25 +就是窗首。加 40 不改同余类(40 ≡ 0 mod 40), +中间值恒在 [0, 2·DEPTH - 2],两种提升规则都不下溢。 + +加 DEPTH 不是「凑数」:它是环的周长,加一整圈等价于原地不动——但把减法从「可能为负」搬到了「必然非负」,取模才恢复成数学上的同余解。 + diff --git a/docs/算法/采集与算法层设计说明.md b/docs/算法/采集与算法层设计说明.md index 08d3301d..1ed621c1 100644 --- a/docs/算法/采集与算法层设计说明.md +++ b/docs/算法/采集与算法层设计说明.md @@ -197,6 +197,11 @@ flowchart TD 最后写入的一点是 `s_head - 1`,于是"以最新点为终点的 n 点窗"= 下标 `[s_head-n, s_head-1]`, 窗首(最老点)就是 `s_head - n`,落到 `[0, DEPTH - 1]` 要取模。 +取一个"head 不够减"的例子走一遍:`head = 5`、`n = 20`——最新点在 4,沿环往回退 19 步落在 25, +途中跨过 0 与 39。直接写 `5 - 20` 会得到 −15,压根不在环上。 + +![head 不够减时直接减会走到负数:沿环往回退 19 步跨过 0,加一圈 DEPTH 才回到 25](../images/采样环缓冲_窗首下标.svg) + 代码里写成 `(s_head + SAMPLE_RING_DEPTH - n) % SAMPLE_RING_DEPTH` 而不是 `(s_head - n) % DEPTH`, 是因为后者有两个独立的坑: diff --git a/tools/arch_diagram/README.md b/tools/arch_diagram/README.md index e75927ab..6dfed728 100644 --- a/tools/arch_diagram/README.md +++ b/tools/arch_diagram/README.md @@ -23,6 +23,7 @@ | `gen_wave_record_flow_svg.py` | 故障录波数据流图 | 录波段数据的四个所有者与四条「不许」:谁独占写、谁只能读、标志怎么过界 | | `gen_sample_ring_why_svg.py` | 采样环缓冲_为什么需要 | 上游 1 kHz 覆盖写只交最新一帧 vs 下游要 20 点滑窗:缺口 19 点与三个消费者 | | `gen_sample_ring_layout_svg.py` | 采样环缓冲_环形布局 | 40 格环里哪 20 格是本窗、写指针指「下一格」、回绕与三条设计不变量 | +| `gen_sample_ring_start_svg.py` | 采样环缓冲_窗首下标 | 窗首 = head − n 的圆周走法,与「直接减 / 16 位回绕 / 先加 DEPTH」三种写法对照 | ## 使用方式 diff --git a/tools/arch_diagram/gen_sample_ring_start_svg.py b/tools/arch_diagram/gen_sample_ring_start_svg.py new file mode 100644 index 00000000..2cce1633 --- /dev/null +++ b/tools/arch_diagram/gen_sample_ring_start_svg.py @@ -0,0 +1,316 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""生成 docs/images/采样环缓冲_窗首下标.svg —— 窗首 start = head − n 为什么要先加一圈 DEPTH: +head 不够减时直接减会走到负数,取模就再也回不到环上。 + +为什么不用 Mermaid:本图要读的是"沿圆环往回退 19 格,途中跨过 0"这条**弧**, +以及三种写法算出来的三个**不同的数**。Mermaid 画不出圆周上的定位与角度, +盒子宽度也随文本变,"退半圈落在哪一格"这个关系就没了。 + +与 5.2 的环形布局图分工:那张讲「窗在哪几格」(线性排开 40 格 + 指针), +本图讲「这个下标怎么算出来」(圆周 + 三种写法对照),不重复。 + +用法(仓库根目录,任意 python3): + python tools/arch_diagram/gen_sample_ring_start_svg.py +输出: + docs/images/采样环缓冲_窗首下标.svg +改内容:示例(head / n)或三种写法的结论变了才重画;调坐标 / 文案编辑下方常量后重跑。 +""" +import html +import math +import os +import sys +import xml.etree.ElementTree as ET + + +def emit(s): + """控制台打印按目标编码替换,绝不因 GBK 终端遇上 ²/− 之类字符而崩 + (generated-fresh 钩子会把本脚本拷进临时目录重跑,崩了就是一片假红)。""" + enc = getattr(sys.stdout, "encoding", None) or "ascii" + sys.stdout.write(s.encode(enc, "replace").decode(enc, "replace") + "\n") + + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.path.join(ROOT, "docs", "images", "采样环缓冲_窗首下标.svg") + +# ----------------------------- 画布与尺寸 ----------------------------- +W = 1060 +H = 600 +PAD = 46 +RIGHT = W - PAD + +FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif" +MONO = "Consolas, Cascadia Mono, DejaVu Sans Mono, monospace" + +TITLE_FONT = 21 +SUB_FONT = 13 +BOX_FONT = 13 +BODY_FONT = 11.5 +TINY_FONT = 11 +DOT_LABEL_FONT = 12 + +INK = "#0f172a" +LINE = "#334155" +MUTED = "#64748b" +FAINT = "#94a3b8" +RULE = "#cbd5e1" +WHITE = "#ffffff" + +C_BLUE = "#2563eb" +BG_BLUE = "#eff6ff" +C_ROSE = "#e11d48" +BG_ROSE = "#fff1f2" +C_GREEN = "#16a34a" +BG_GREEN = "#f0fdf4" +C_AMBER = "#d97706" + +# ----------------------------- 数据(改这里) ----------------------------- +DEPTH = 40 +WIN_N = 20 +HEAD = 5 # 示例写指针:故意取"不够减"的位置 +START = (HEAD + DEPTH - WIN_N) % DEPTH # = 25 + +TITLE = "窗首 = head − n:head 不够减会走到负数,所以先加一圈 DEPTH 再减" +SUB1 = ("例:head = 5、n = 20 —— 最新点在 4,沿环往回退 19 步落在 25,途中跨过 0 与 39。" + "DEPTH = 40 时 20 点窗正好占半圈,所以这条弧看起来是个半圆。") +SUB2 = "同一个例子三种写法,只有第三种同时做到「同余正确」与「不发生下溢」:" + +ARC_LABEL = "往回退 19 步" +CENTER_L1 = "环 = 40 格" +CENTER_L2 = "窗 = 20 格(半圈)" +HEAD_LABEL = "head = 5" +START_LABEL = "start = 25" + +# (公式, 算出来是多少, 判定, 逐条理由, 主色, 底色) +ROWS = [ + ("5 - 20", "= -15", "✗", + ["负数。C99 的 % 向零截断,(-15) % 40 仍是 -15;", + "转成无符号就是 65521,拿去索引 s_ring 即越界读。"], + C_ROSE, BG_ROSE), + ("(uint16_t)(5 - 20) % 40", "= 65521 % 40 = 1", "✗", + ["65536 不是 40 的倍数:(x mod 65536) mod 40 ≠ x mod 40。", + "回绕后再取模不等价,读到的是环尾附近而不是窗首。"], + C_ROSE, BG_ROSE), + ("(5 + 40 - 20) % 40", "= 25 % 40 = 25", "✓", + ["就是窗首。加 40 不改同余类(40 ≡ 0 mod 40),", + "中间值恒在 [0, 2·DEPTH - 2],两种提升规则都不下溢。"], + C_GREEN, BG_GREEN), +] +FOOTNOTE = ("加 DEPTH 不是「凑数」:它是环的周长,加一整圈等价于原地不动——" + "但把减法从「可能为负」搬到了「必然非负」,取模才恢复成数学上的同余解。") + +# ----------------------------- 布局 ----------------------------- +CX = 296.0 +CY = 352.0 +R = 148.0 +DOT_R = 5.0 +DOT_R_HL = 6.0 + +BOX_X = 596 +BOX_W = RIGHT - BOX_X +BOX_TOP = 186 +BOX_H = 96 +BOX_GAP = 14 + +NOTE_Y = 556 + + +# ----------------------------- 工具函数 ----------------------------- +def esc(s): + return html.escape(s, quote=True) + + +def text_w(s, fs): + """粗略估算文本像素宽:CJK/全角 ≈ 1.0em,其余 ≈ 0.58em(宁可偏大)。""" + return sum(fs * (1.0 if ord(ch) >= 0x2E80 else 0.58) for ch in s) + + +OVERFLOW = [] + + +def check_fit(s, fs, avail, where): + if text_w(s, fs) > avail: + OVERFLOW.append("%s: %.0f > %.0f [%s]" % (where, text_w(s, fs), avail, s)) + + +def txt(x, y, s, fs, fill, weight=400, anchor=None, mono=False): + a = "" if anchor is None else ' text-anchor="%s"' % anchor + w = "" if weight == 400 else ' font-weight="%d"' % weight + f = "" if not mono else ' font-family="%s"' % MONO + return ('%s' + % (x, y, fs, a, w, f, fill, esc(s))) + + +def line(x1, y1, x2, y2, color=LINE, width=2.0, dash=None): + d = "" if dash is None else ' stroke-dasharray="%s"' % dash + return ('' + % (x1, y1, x2, y2, color, width, d)) + + +def rect(x, y, w, h, fill, stroke=None, rx=8, sw=1.6, dash=None): + s = "" if stroke is None else ' stroke="%s" stroke-width="%g"' % (stroke, sw) + d = "" if dash is None else ' stroke-dasharray="%s"' % dash + return '' % ( + x, y, w, h, rx, fill, s, d) + + +def circle(cx, cy, r, fill, stroke=None, sw=1.5): + s = "" if stroke is None else ' stroke="%s" stroke-width="%g"' % (stroke, sw) + return '' % (cx, cy, r, fill, s) + + +def tri(points, fill): + return '' % ( + " ".join("%.1f,%.1f" % (px, py) for px, py in points), fill) + + +def pt(r, deg): + """圆上一点:deg 以 +x 轴为 0、屏幕坐标 y 向下(故 90° 在正下方)。""" + a = math.radians(deg) + return CX + r * math.cos(a), CY + r * math.sin(a) + + +def angle_of(idx): + """第 idx 格的角度:0 格在正上方,顺时针每格 360/DEPTH 度。""" + return -90.0 + idx * (360.0 / DEPTH) + + +def arc_arrow(r, a_from, a_to, color, width): + """从 a_from 逆时针画到 a_to(a_to < a_from),末端带箭头。""" + x0, y0 = pt(r, a_from) + x1, y1 = pt(r, a_to) + large = 1 if abs(a_to - a_from) > 180.0 else 0 + d = "M %.1f %.1f A %.1f %.1f 0 %d 0 %.1f %.1f" % (x0, y0, r, r, large, x1, y1) + out = '' % (d, color, width) + # 末端切向:用 a_to 与其后方 1° 两点之差,得到"行进方向" + xb, yb = pt(r, a_to + 1.0) + vx, vy = x1 - xb, y1 - yb + n = math.hypot(vx, vy) or 1.0 + ux, uy = vx / n, vy / n + hl = 10.0 + bx, by = x1 - ux * hl, y1 - uy * hl + px, py = -uy, ux + out += tri([(x1, y1), (bx + px * 4.6, by + py * 4.6), (bx - px * 4.6, by - py * 4.6)], color) + return out + + +def wrap(s, fs, avail, max_lines, where=""): + """按估算宽度折行(CJK 逐字可断)。超出 max_lines 用省略号收尾—— + 宁可提示文案太长,也不让字溢出盒子。""" + if text_w(s, fs) <= avail: + return [s] + lines = [] + cur = "" + for ch in s: + if text_w(cur + ch, fs) > avail: + lines.append(cur) + cur = ch + else: + cur += ch + if cur: + lines.append(cur) + if len(lines) > max_lines: + OVERFLOW.append("%s: 折行 %d > %d" % (where, len(lines), max_lines)) + lines = lines[:max_lines] + lines[max_lines - 1] = lines[max_lines - 1][:-1] + "…" + return lines + + +# ----------------------------- 生成 ----------------------------- +def build(): + out = [] + add = out.append + + avail = RIGHT - PAD + for s, fs, where in ((TITLE, TITLE_FONT, "title"), (SUB1, SUB_FONT, "sub1"), + (SUB2, SUB_FONT, "sub2")): + check_fit(s, fs, avail, where) + if OVERFLOW: + for o in OVERFLOW: + emit("OVERFLOW WARN " + o) + + add('' % (W, H, WHITE)) + + # ---- 标题区 ---- + add(txt(PAD, 42, TITLE, TITLE_FONT, INK, weight=700)) + add(txt(PAD, 68, SUB1, SUB_FONT, MUTED)) + add(txt(PAD, 88, SUB2, SUB_FONT, MUTED)) + + # ---- 左:40 格圆环 ---- + win_lo = START + win_hi = HEAD - 1 + add(circle(CX, CY, R, "none", RULE, 1.2)) + for i in range(DEPTH): + x, y = pt(R, angle_of(i)) + in_win = (i >= win_lo) or (i <= win_hi) + if i == HEAD: + add(circle(x, y, DOT_R_HL, C_AMBER)) + elif in_win: + add(circle(x, y, DOT_R_HL, C_BLUE)) + else: + add(circle(x, y, DOT_R, FAINT)) + + # 往回退的弧(贴着点圈内侧走,末端箭头落在 start 上) + add(arc_arrow(R - 18.0, angle_of(win_hi), angle_of(win_lo) - 360.0, C_ROSE, 1.8)) + lx, ly = pt(R - 70.0, angle_of(33)) + add(txt(lx, ly, ARC_LABEL, TINY_FONT, C_ROSE, weight=700, anchor="middle")) + + # 圆周上的刻度名 + for i in (0, 10, 20, 30): + x, y = pt(R + 22.0, angle_of(i)) + anchor = "middle" + if i == 10: + anchor = "start" + elif i == 30: + anchor = "end" + add(txt(x, y + 4, str(i), DOT_LABEL_FONT, MUTED, anchor=anchor)) + + # head / start 两个关键点 + hx, hy = pt(R + 26.0, angle_of(HEAD)) + check_fit(HEAD_LABEL, TINY_FONT, 200, "head label") + add(txt(hx, hy, HEAD_LABEL, TINY_FONT, C_AMBER, weight=700, anchor="start")) + sx, sy = pt(R + 26.0, angle_of(START)) + check_fit(START_LABEL, TINY_FONT, 200, "start label") + add(txt(sx, sy, START_LABEL, TINY_FONT, C_BLUE, weight=700, anchor="end")) + + # 圆心说明 + add(txt(CX, CY - 6, CENTER_L1, TINY_FONT, FAINT, anchor="middle")) + add(txt(CX, CY + 14, CENTER_L2, TINY_FONT, FAINT, anchor="middle")) + + # ---- 右:三种写法 ---- + inner = BOX_W - 32 + for i, (formula, value, mark, why, c, bg) in enumerate(ROWS): + y = BOX_TOP + i * (BOX_H + BOX_GAP) + add(rect(BOX_X, y, BOX_W, BOX_H, bg, c, rx=8, sw=1.8)) + add(txt(BOX_X + 14, y + 26, "%s %s" % (mark, formula), BOX_FONT, c, weight=700, mono=True)) + add(txt(BOX_X + BOX_W - 14, y + 26, value, BOX_FONT, c, weight=700, anchor="end", mono=True)) + yy = y + 48 + for ln in why: + for sub in wrap(ln, BODY_FONT, inner, 1, "row %d why" % i): + add(txt(BOX_X + 14, yy, sub, BODY_FONT, MUTED)) + yy += 16 + + # ---- 底部说明 ---- + add(line(PAD, NOTE_Y - 26, RIGHT, NOTE_Y - 26, RULE, 1.0)) + for i, ln in enumerate(wrap(FOOTNOTE, SUB_FONT, avail, 2, "foot wrap")): + add(txt(PAD, NOTE_Y + i * 19, ln, SUB_FONT, MUTED)) + + svg = ('\n%s\n\n' + % (W, H, W, H, FONT, "\n".join(out))) + + d = os.path.dirname(OUT) + if not os.path.isdir(d): + os.makedirs(d) + with open(OUT, "w", encoding="utf-8", newline="\n") as fp: + fp.write(svg) + ET.fromstring(svg) + emit("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, H, OUT)) + if OVERFLOW: + for o in OVERFLOW: + emit("OVERFLOW WARN " + o) + emit("!! %d overflow warnings" % len(OVERFLOW)) + + +if __name__ == "__main__": + build()