<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>CacheRivulet小记</title>
  <link href="https://createskyblue.github.io/" rel="alternate"/>
  <link href="https://createskyblue.github.io/feed.xml" rel="self"/>
  <id>https://createskyblue.github.io/</id>
  <updated>2026-09-21T00:00:00.000Z</updated>
  <author>
    <name>createskyblue</name>
  </author>
  <subtitle>createskyblue's blog — tech notes, embedded systems, and more</subtitle>
  <generator uri="https://createskyblue.github.io">custom-build-script</generator>
  <entry>
    <title>墨水屏 24P 驱动板改 0402 小封装，重布单面板与双面板，支持一分钟制板法</title>
    <link href="https://createskyblue.github.io/posts/epd-driver-24p-0402/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/epd-driver-24p-0402/</id>
    <published>2026-09-21T00:00:00.000Z</published>
    <updated>2026-09-21T00:00:00.000Z</updated>
    <summary>墨水屏24P驱动板
0402小封装
一分钟制板法</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>墨水屏 24P 驱动板改 0402 小封装，重布单面板与双面板</h1>
<ul>
<li>Frok原项目：<a href="https://oshwhub.com/jie326513988/esp32c3-154-ink-screen-multi-function">oshwhub ESP32C3 墨水屏多功能</a></li>
<li>修改0402小封装，重新Layout布局，重新拉线</li>
<li>适配单面板，可以单面覆铜板一分钟制板法，蚀刻参考演示（强酸/氧化剂操作，务必看原视频安全规范）：</li>
<li><ul>
<li><a href="https://www.bilibili.com/video/BV1Dr42187u8" target="_blank" rel="noopener noreferrer"> Bilibili TSINGHUAJOKING卓晴 固态双氧水制作PCB 一分钟制板法</a></li>
</ul>
</li>
<li><ul>
<li><a href="https://www.bilibili.com/video/BV1wm411f7S4" target="_blank" rel="noopener noreferrer"> Bilibili TSINGHUAJOKING卓晴 清洁盐酸用于制作PCB 一分钟制板法</a></li>
</ul>
</li>
<li><ul>
<li>警示：PCB湿刻操作必须经由相关专业人员的指导下带好护目镜、手套等个人防护并且在通风橱下进行</li>
</ul>
</li>
<li>本魔改版PCB工程：<a href="https://u.lceda.cn/join?type=project&key=d9d7f88eef897a592676e01b7a188774&inviter=b684ea91e80442fbbb556fa7a7a96fa1" target="_blank" rel="noopener noreferrer">立创 EDA 开源工程 协作邀请</a></li>
</ul>
<h2>电荷泵网络生成面板高压轨</h2>
<p>原理图为墨水屏驱动常见电荷泵：L1 10uH 与 Q1 CJ3400 形成开关节点，经 D1/D2/D3（1N5819WS）整流出 PREVGH 与 PREVGL。C2 10uF、C4 4.7uF 做输入输出储能，R1 10k 与 U1 470mΩ 参与开关侧偏置与采样路径。FPC 侧 C1、C3、C5–C12 等 1uF 电容挂在面板供电与参考网络上。</p>
<p><img src="/posts/epd-driver-24p-0402/PixPin_2026-09-21_01-02-21.webp" alt="原理图"></p>
<p><img src="/posts/epd-driver-24p-0402/cover.webp" alt="双面板"></p>
<p><img src="/posts/epd-driver-24p-0402/PixPin_2026-09-21_01-02-32.webp" alt="单层板"></p>]]></content>
    <category term="嵌入式"/>
    <category term="墨水屏"/>
    <category term="电荷泵"/>
    <category term="SPI"/>
  </entry>
  <entry>
    <title>睡眠心率呼吸床垫 BLE 低功耗物联网系统</title>
    <link href="https://createskyblue.github.io/posts/mattress-pressure-hr/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/mattress-pressure-hr/</id>
    <published>2026-09-15T00:00:00.000Z</published>
    <updated>2026-09-15T00:00:00.000Z</updated>
    <summary>睡眠心率呼吸床垫
BLE 低功耗物联网系统</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>睡眠心率呼吸床垫 BLE 低功耗物联网系统</h1>
<p><strong>项目简述</strong>：独立端到端实现睡眠生理监测 IoT 系统，包含电池供电 BLE 感知从节点、ESP32 蓝牙网关与微弱生理信号算法。攻克微弱振动信号提取、受限 MCU 算法移植、电池功耗权衡、多 BLE 设备接入、异构传感总线接入、网络异常数据容错等问题，技术可迁移至低功耗传感类产品。</p>
<h2>核心工作</h2>
<h3>BLE 低功耗床垫感知从节点</h3>
<ul>
<li><strong>采集与前端</strong>：设计压电+压力双传感协同采集策略：压力传感器做低频在位检测触发，压电传感器做高带宽生理信号采集，在位检测按需启采，兼顾续航与采集性能；完成差分模拟前端，抑制基线与环境噪声。</li>
<li><strong>算法自研与移植</strong>：针对床垫压电微弱振动信号（低信噪比、呼吸谐波与体动干扰和目标频带重叠），自研心率 / 呼吸估计算法，设计自适应滤波与运动干扰抑制链路，以置信度输出抑制体动误报；算法流式化移植至资源受限 MCU，通过增量求和与单调队列维护滑动窗口极值，将每样本开销从 O (窗口长度) 降至常数级，1 秒窗口处理实测 230ms，较初版实现提升约 12 倍；配套自建低代码拖拽式算法仿真平台（<a href="/posts/signal-analysis-lab">signal-analysis-lab</a>），完成仿真 - 实机一致性验证。</li>
<li><strong>功耗调度</strong>：基于差分闹钟实现功耗调度状态机（采样 / 同步 / 休眠），按最近任务截止时间动态计算唤醒间隔，采样窗口外对模拟前端独立断电，整机休眠功耗低至 7μA。</li>
<li><strong>数据流水线</strong>：搭建 ADC 采样 FIFO→流式处理→结果 / 波形双队列（独立时间戳，波形 LTTB 降采样）采集流水线。</li>
<li><strong>可靠性</strong>：实现低电量锁死保护与 FIFO 高水位紧急同步，防止数据积压丢失；支持 BLE 远程配置热更新。</li>
</ul>
<h3>ESP32 蓝牙网关</h3>
<ul>
<li><strong>异构传感接入架构</strong>：设计统一传感器驱动抽象层，定义统一设备接口，屏蔽 Modbus RTU、I²C、自定义 UART、GPIO 中断等多类总线差异；采用设备表注册机制，新增传感器仅需实现接口并注册，轮询框架零改动；驱动统一输出键值对契约，映射表驱动解析，新增传感器零硬编码；单线程轮询 + 队列解耦，统一读数结构供上层消费，完成温湿度、CO₂、CO、打鼾等多维环境数据汇聚。</li>
<li><strong>数据链路</strong>：实现多 BLE 从机扫描、连接与数据交互；基于 MQTT QoS 1 实现可靠上报，设计 FlashDB TSDB 本地时序缓存（7天窗口）与断网补传机制，网络恢复后按时间序批量补传并逐批确认删除，保障数据零丢失。</li>
<li><strong>交互层</strong>：面向用户提供 APP 完成配网与数据查看；网关提供可被 APP 嵌套的本地调试后台，用于图表浏览与设备运维。</li>
</ul>
<h2>技术沉淀</h2>
<p>微弱模拟信号调理降噪、生理信号算法自研与 MCU 端移植性能调优；电池 BLE 设备差分闹钟休眠唤醒功耗框架设计；异构传感总线统一抽象层与插件式扩展设计；采集流水线与波形降采样（LTTB）；BLE 多从机管理；网络异常数据缓存补发容错方案。</p>
<h3>心率信号处理算法</h3>
<p><img src="/posts/mattress-pressure-hr/pressure-hr-chain.svg" alt="心率算法通用模块：信号预处理、节拍提取、多路间隔估计、稳健统计与质量择优"></p>
<h3>传感器解耦</h3>
<p><img src="/posts/mattress-pressure-hr/sensor-decoupling-sequence.svg" alt="传感器解耦时序图：统一接口层屏蔽总线差异，设备表注册实现零改动扩展，队列实现生产者-消费者解耦"></p>
<h3>MQTT可靠上报与断网补传</h3>
<p>基于 MQTT QoS 1 实现可靠数据上报，重要数据（心率/呼吸/环境）先落盘 FlashDB TSDB（7天窗口）再上报、发送成功才标记删除；波形预览仅 RAM 队列不落盘。网络恢复后按时间序批量补传并逐批确认删除，保障数据零丢失。本地完成分钟级聚合后再上报，降低上行带宽与后端存储压力。</p>
<p><img src="/posts/mattress-pressure-hr/mqtt-offline-cache-flow.svg" alt="MQTT可靠上报与断网补传时序图：BLE主从pull模式采集，重要数据（心率/呼吸/环境）先落盘FlashDB再MQTT QoS1上报、发送成功才标记删除，波形预览仅RAM队列不落盘，断网恢复后批量补传，APP经后端中转查看（非直连网关）"></p>]]></content>
    <category term="嵌入式"/>
    <category term="低功耗"/>
    <category term="BLE"/>
    <category term="信号处理"/>
    <category term="系统设计"/>
    <category term="ESP32"/>
  </entry>
  <entry>
    <title>Fusion360 MCP AI辅助建模</title>
    <link href="https://createskyblue.github.io/posts/Fusion360_MCP/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/Fusion360_MCP/</id>
    <published>2026-09-14T00:00:00.000Z</published>
    <updated>2026-09-14T00:00:00.000Z</updated>
    <summary>Fusion360 MCP AI辅助建模</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>如可开启 Fusion360 MCP AI辅助建模</h1>
<ol>
<li>右上角用户图标 - 首选项 - 常规 - API-Fusion MCP服务器</li>
<li>MCP服务运行在 <a href="http://127.0.0.1:27182/mcp">http://127.0.0.1:27182/mcp</a></li>
</ol>
<p><img src="/posts/Fusion360_MCP/PixPin_2026-09-12_21-34-25.webp" alt="">
<img src="/posts/Fusion360_MCP/PixPin_2026-09-14_00-30-14.webp" alt=""></p>]]></content>
    <category term="3D打印"/>
    <category term="fusion360"/>
    <category term="MCP"/>
    <category term="AI"/>
  </entry>
  <entry>
    <title>PCB限位筋/PCB定位挡块 PCB Locating Rib/Stop</title>
    <link href="https://createskyblue.github.io/posts/PCB-Locating-Rib/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/PCB-Locating-Rib/</id>
    <published>2026-09-14T00:00:00.000Z</published>
    <updated>2026-09-14T00:00:00.000Z</updated>
    <summary>PCB限位筋/PCB定位挡块 PCB Locating Rib/Stop</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>PCB限位筋/PCB定位挡块 PCB Locating Rib/Stop</h1>
<p><img src="/posts/PCB-Locating-Rib/PixPin_2026-09-14_00-14-43.webp" alt=""></p>
<h2>设计参数</h2>
<table>
<thead>
<tr>
<th>参数</th>
<th>个人数值</th>
<th>备注</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>5</td>
<td>-</td>
</tr>
<tr>
<td>B</td>
<td>2.3</td>
<td>-</td>
</tr>
<tr>
<td>C</td>
<td>0.2</td>
<td>-</td>
</tr>
<tr>
<td>D</td>
<td>8</td>
<td>-</td>
</tr>
<tr>
<td>E</td>
<td>5</td>
<td>个人习惯PCB悬空5mm</td>
</tr>
<tr>
<td>F</td>
<td>1.6</td>
<td>PCB厚度</td>
</tr>
</tbody></table>
<h2>模型下载</h2>
<p>实践用的PCB定位挡块模型（3MF）：<a href="/posts/PCB-Locating-Rib/PCB%E5%AE%9A%E4%BD%8D%E6%8C%A1%E5%9D%97.3mf" target="_blank" rel="noopener noreferrer">PCB定位挡块.3mf</a></p>
<p>可导入 Fusion 360、Blender 等建模软件参考或直接 3D 打印。</p>]]></content>
    <category term="3D建模"/>
    <category term="卡扣"/>
    <category term="fusion360"/>
    <category term="3D打印"/>
  </entry>
  <entry>
    <title>Fusion 360 蜂窝镂空草图脚本：参数化生成 200×200mm 六边形阵列</title>
    <link href="https://createskyblue.github.io/posts/fusion360-honeycomb/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/fusion360-honeycomb/</id>
    <published>2026-09-13T00:00:00.000Z</published>
    <updated>2026-09-13T00:00:00.000Z</updated>
    <summary>Fusion 360 蜂窝镂空草图脚本，DXF 直接导入</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Fusion 360 蜂窝镂空草图脚本：参数化生成 200×200mm 六边形阵列</h1>
<p>一段 Fusion 360 Python 脚本，在草图平面生成 200×200mm 六边形蜂窝阵列：外接圆半径 5mm、壁厚 3mm，参数集中在脚本开头，改两个数即可调整孔径与密度。</p>
<p><img src="/posts/fusion360-honeycomb/PixPin_2026-09-13_23-19-33.webp" alt="蜂窝镂空草图">
<img src="/posts/fusion360-honeycomb/preview.webp" alt="DXF预览">
<a href="/posts/fusion360-honeycomb/HexTile_200x200.zip" target="_blank" rel="noopener noreferrer">下载 DXF</a></p>
<p>用法二选一：</p>
<ol>
<li>在 Fusion 360「工具 → 附加模块 → 脚本」中运行，或导入下方 DXF，用于外壳镂空、散热孔、通风面板。</li>
<li>把脚本发给 AI，通过 Fusion MCP (右上角用户图标-首选项-常规-API-Fusion MCP服务器) 直接帮你画。</li>
</ol>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> adsk.core, adsk.fusion, math</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">def</span><span style="color:#DCDCAA"> run</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">_context</span><span style="color:#D4D4D4">: </span><span style="color:#4EC9B0">str</span><span style="color:#D4D4D4">):</span></span>
<span class="line"><span style="color:#D4D4D4">    app = adsk.core.Application.get()</span></span>
<span class="line"><span style="color:#D4D4D4">    design = adsk.fusion.Design.cast(app.activeProduct)</span></span>
<span class="line"><span style="color:#D4D4D4">    root = design.rootComponent</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    # ============================================================</span></span>
<span class="line"><span style="color:#6A9955">    # 参数配置 — 按需修改这里</span></span>
<span class="line"><span style="color:#6A9955">    # ============================================================</span></span>
<span class="line"><span style="color:#D4D4D4">    R          = </span><span style="color:#B5CEA8">0.5</span><span style="color:#6A9955">   # 外接圆半径 (cm)，5mm → 改成 0.4 即 4mm</span></span>
<span class="line"><span style="color:#D4D4D4">    gap        = </span><span style="color:#B5CEA8">0.3</span><span style="color:#6A9955">   # 孔间壁厚 (cm)，3mm → 改成 0.2 即 2mm</span></span>
<span class="line"><span style="color:#D4D4D4">    size_x     = </span><span style="color:#B5CEA8">10.0</span><span style="color:#6A9955">  # 画布半宽 (cm)，10cm → 总宽 200mm</span></span>
<span class="line"><span style="color:#D4D4D4">    size_y     = </span><span style="color:#B5CEA8">10.0</span><span style="color:#6A9955">  # 画布半高 (cm)，10cm → 总高 200mm</span></span>
<span class="line"><span style="color:#D4D4D4">    sketch_name = </span><span style="color:#CE9178">'HexTile_200x200'</span></span>
<span class="line"><span style="color:#6A9955">    # ============================================================</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    apothem = R * math.sqrt(</span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">) / </span><span style="color:#B5CEA8">2</span><span style="color:#6A9955">   # 内切圆半径</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    col_spacing = </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> * apothem + gap  </span><span style="color:#6A9955"># 列间距 (x 方向)</span></span>
<span class="line"><span style="color:#D4D4D4">    row_spacing = </span><span style="color:#B5CEA8">1.5</span><span style="color:#D4D4D4"> * R + gap      </span><span style="color:#6A9955"># 行间距 (y 方向)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    x_min, x_max = -size_x, size_x</span></span>
<span class="line"><span style="color:#D4D4D4">    y_min, y_max = -size_y, size_y</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    margin_x = apothem + gap / </span><span style="color:#B5CEA8">2</span></span>
<span class="line"><span style="color:#D4D4D4">    margin_y = R + gap / </span><span style="color:#B5CEA8">2</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    # 在 XY 平面新建草图</span></span>
<span class="line"><span style="color:#D4D4D4">    sketch = root.sketches.add(root.xYConstructionPlane)</span></span>
<span class="line"><span style="color:#D4D4D4">    sketch.name = sketch_name</span></span>
<span class="line"><span style="color:#D4D4D4">    lines = sketch.sketchCurves.sketchLines</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    # 外框 200×200mm</span></span>
<span class="line"><span style="color:#D4D4D4">    corners = [</span></span>
<span class="line"><span style="color:#D4D4D4">        adsk.core.Point3D.create(x_min, y_min, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">        adsk.core.Point3D.create(x_max, y_min, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">        adsk.core.Point3D.create(x_max, y_max, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">        adsk.core.Point3D.create(x_min, y_max, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">    ]</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> i </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> range</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">):</span></span>
<span class="line"><span style="color:#D4D4D4">        lines.addByTwoPoints(corners[i], corners[(i + </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) % </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">])</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    # 六边形蜂窝填充（flat-top，奇数行偏移半列）</span></span>
<span class="line"><span style="color:#D4D4D4">    count = </span><span style="color:#B5CEA8">0</span></span>
<span class="line"><span style="color:#D4D4D4">    row = </span><span style="color:#B5CEA8">0</span></span>
<span class="line"><span style="color:#D4D4D4">    cy = y_min + margin_y</span></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> cy + R &#x3C;= y_max - gap / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#D4D4D4">        offset_x = col_spacing / </span><span style="color:#B5CEA8">2</span><span style="color:#C586C0"> if</span><span style="color:#D4D4D4"> (row % </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> == </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">else</span><span style="color:#B5CEA8"> 0.0</span></span>
<span class="line"><span style="color:#D4D4D4">        cx = x_min + margin_x + offset_x</span></span>
<span class="line"><span style="color:#C586C0">        while</span><span style="color:#D4D4D4"> cx + apothem &#x3C;= x_max - gap / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#D4D4D4">            pts = []</span></span>
<span class="line"><span style="color:#C586C0">            for</span><span style="color:#D4D4D4"> k </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> range</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">6</span><span style="color:#D4D4D4">):</span></span>
<span class="line"><span style="color:#D4D4D4">                angle = math.radians(</span><span style="color:#B5CEA8">30</span><span style="color:#D4D4D4"> + </span><span style="color:#B5CEA8">60</span><span style="color:#D4D4D4"> * k)  </span><span style="color:#6A9955"># flat-top 方向</span></span>
<span class="line"><span style="color:#D4D4D4">                pts.append(adsk.core.Point3D.create(</span></span>
<span class="line"><span style="color:#D4D4D4">                    cx + R * math.cos(angle),</span></span>
<span class="line"><span style="color:#D4D4D4">                    cy + R * math.sin(angle),</span></span>
<span class="line"><span style="color:#B5CEA8">                    0</span></span>
<span class="line"><span style="color:#D4D4D4">                ))</span></span>
<span class="line"><span style="color:#C586C0">            for</span><span style="color:#D4D4D4"> k </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> range</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">6</span><span style="color:#D4D4D4">):</span></span>
<span class="line"><span style="color:#D4D4D4">                lines.addByTwoPoints(pts[k], pts[(k + </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) % </span><span style="color:#B5CEA8">6</span><span style="color:#D4D4D4">])</span></span>
<span class="line"><span style="color:#D4D4D4">            count += </span><span style="color:#B5CEA8">1</span></span>
<span class="line"><span style="color:#D4D4D4">            cx += col_spacing</span></span>
<span class="line"><span style="color:#D4D4D4">        cy += row_spacing</span></span>
<span class="line"><span style="color:#D4D4D4">        row += </span><span style="color:#B5CEA8">1</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">'Done: </span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">count</span><span style="color:#569CD6">}</span><span style="color:#CE9178"> hexagons'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#DCDCAA">    print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">'R=</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">R*</span><span style="color:#B5CEA8">10</span><span style="color:#569CD6">:.1f}</span><span style="color:#CE9178">mm  gap=</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">gap*</span><span style="color:#B5CEA8">10</span><span style="color:#569CD6">:.1f}</span><span style="color:#CE9178">mm  '</span></span>
<span class="line"><span style="color:#569CD6">          f</span><span style="color:#CE9178">'col=</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">col_spacing*</span><span style="color:#B5CEA8">10</span><span style="color:#569CD6">:.2f}</span><span style="color:#CE9178">mm  row=</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">row_spacing*</span><span style="color:#B5CEA8">10</span><span style="color:#569CD6">:.2f}</span><span style="color:#CE9178">mm'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">    app.activeViewport.fit()</span></span></code></pre>]]></content>
    <category term="fusion360"/>
    <category term="3D打印"/>
    <category term="3D建模"/>
    <category term="Python"/>
    <category term="MCP"/>
  </entry>
  <entry>
    <title>外部 .mmd 文件引用渲染测试：Markdown 里嵌入 Mermaid 图</title>
    <link href="https://createskyblue.github.io/posts/mermaid-embed-test/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/mermaid-embed-test/</id>
    <published>2026-09-13T00:00:00.000Z</published>
    <updated>2026-09-13T00:00:00.000Z</updated>
    <summary>博客新增 Mermaid 图表外链渲染功能的功能验证文章</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>外部 .mmd 文件引用渲染测试：Markdown 里嵌入 Mermaid 图</h1>
<p>本文用于验证博客系统新功能：Markdown 用图片语法引用 <code>.mmd</code> 文件，浏览器端动态加载 mermaid 渲染为 SVG。</p>
<h2>外部文件引用</h2>
<p>下面的图来自同目录的 <code>渲染流程.mmd</code> 文件，写法是 <code>![渲染流程](./渲染流程.mmd)</code>：</p>
<p><img src="/posts/mermaid-embed-test/%E6%B8%B2%E6%9F%93%E6%B5%81%E7%A8%8B.mmd" alt="渲染流程"></p>
<h2>内联 mermaid 代码块</h2>
<p>同一个渲染函数同时支持直接写在文章里的 mermaid 代码块：</p>
<pre><code class="language-mermaid">sequenceDiagram
    participant M as Markdown
    participant B as 构建脚本
    participant V as ArticleModal
    M-&gt;&gt;B: ![图](./xxx.mmd)
    B-&gt;&gt;B: 复制资源 + 重写路径
    V-&gt;&gt;V: 发现 .mmd 引用
    V-&gt;&gt;V: 动态 import mermaid
    V-&gt;&gt;M: 渲染 SVG 替换占位</code></pre><h2>故意写错的图表</h2>
<p>下面的图表语法错误，应显示错误提示而不影响其他内容：</p>
<pre><code class="language-mermaid">flowchart TD
    A[缺少连线对象 -&gt;</code></pre><!-- rebuild marker -->]]></content>
    <category term="前端"/>
    <category term="JavaScript"/>
    <category term="Mermaid"/>
  </entry>
  <entry>
    <title>70 行 Node 脚本跑通 WebRTC 数据透传：信令手动换，数据点对点</title>
    <link href="https://createskyblue.github.io/posts/webrtc-datachannel-passthrough/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/webrtc-datachannel-passthrough/</id>
    <published>2026-09-13T00:00:00.000Z</published>
    <updated>2026-09-13T00:00:00.000Z</updated>
    <summary>两个终端各跑一个 70 行 Node 脚本，手动交换两行信令，即可建立点对点数据通道互发文本</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>70 行 Node 脚本跑通 WebRTC 数据透传：信令手动换，数据点对点</h1>
<h2>组成：一条 DataChannel 加两次手动信令</h2>
<p>WebRTC 数据透传只需要三样东西：两端各一个 <code>RTCPeerConnection</code>、一条 DataChannel、一次 ICE 打洞。DataChannel 可以理解为一条点对点的双向字节管道，用法接近 TCP socket；一条连接上还能随时开很多条通道，各自独立，开新通道不需要重新走信令。</p>
<p>信令（交换连接参数的 SDP）不经过服务器，两端各打印一行 token，人工复制粘贴给对方。本文用纯 JS 实现的 <a href="https://www.npmjs.com/package/werift">werift</a> 库，<code>npm install werift</code> 即装，无原生编译依赖。</p>
<p>完整脚本见附件 <a href="/posts/webrtc-datachannel-passthrough/peer.min.js" target="_blank" rel="noopener noreferrer">peer.min.js</a>，全文 70 行。</p>
<h2>信令交换与数据回传时序</h2>
<p><img src="/posts/webrtc-datachannel-passthrough/sequence.svg" alt="WebRTC 手动信令与数据回传时序"></p>
<p>人工搬运只有两次：A 的 offer token 交给 B，B 的 answer token 交回 A。连接建立后 DataChannel 全双工，任意一方发送，接收端原样回传一份副本。</p>
<h2>分步最小实现</h2>
<p><strong>① 两端各建一个对等端</strong></p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> { </span><span style="color:#9CDCFE">RTCPeerConnection</span><span style="color:#D4D4D4"> } </span><span style="color:#C586C0">from</span><span style="color:#CE9178"> "werift"</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">const</span><span style="color:#4FC1FF"> pc</span><span style="color:#D4D4D4"> = </span><span style="color:#569CD6">new</span><span style="color:#DCDCAA"> RTCPeerConnection</span><span style="color:#D4D4D4">({</span></span>
<span class="line"><span style="color:#9CDCFE">  iceServers:</span><span style="color:#D4D4D4"> [{ </span><span style="color:#9CDCFE">urls:</span><span style="color:#CE9178"> "stun:stun.l.google.com:19302"</span><span style="color:#D4D4D4"> }], </span><span style="color:#6A9955">// 公共 STUN 打洞</span></span>
<span class="line"><span style="color:#D4D4D4">});</span></span></code></pre><p><strong>② A：建通道，生成 offer，打印 token</strong></p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// "chat" 只是给这条通道起的名字（对端能读到，用来区分通道用途），不做寻址</span></span>
<span class="line"><span style="color:#569CD6">const</span><span style="color:#4FC1FF"> dc</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">pc</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">createDataChannel</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"chat"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#6A9955">// 这行必须写在 createOffer 之前：werift 生成 offer 时如果一条通道都没有，</span></span>
<span class="line"><span style="color:#6A9955">// 就不会启动 ICE gather，SDP 里连候选地址都没有，对端拿到的是废 token</span></span>
<span class="line"><span style="color:#C586C0">await</span><span style="color:#9CDCFE"> pc</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">setLocalDescription</span><span style="color:#D4D4D4">(</span><span style="color:#C586C0">await</span><span style="color:#9CDCFE"> pc</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">createOffer</span><span style="color:#D4D4D4">());</span></span>
<span class="line"><span style="color:#6A9955">// 返回时 ICE 候选收集（gathering）已完成，SDP 自带全部候选，之后无需再交换候选</span></span>
<span class="line"><span style="color:#9CDCFE">console</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">log</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">encode</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pc</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">localDescription</span><span style="color:#D4D4D4">)); </span><span style="color:#6A9955">// 一行 base64url，人工粘给 B</span></span></code></pre><p><strong>③ B：收 offer，回 answer，打印 token</strong></p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">await</span><span style="color:#9CDCFE"> pc</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">setRemoteDescription</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">decode</span><span style="color:#D4D4D4">(</span><span style="color:#C586C0">await</span><span style="color:#DCDCAA"> ask</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"offer token: "</span><span style="color:#D4D4D4">)));</span></span>
<span class="line"><span style="color:#C586C0">await</span><span style="color:#9CDCFE"> pc</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">setLocalDescription</span><span style="color:#D4D4D4">(</span><span style="color:#C586C0">await</span><span style="color:#9CDCFE"> pc</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">createAnswer</span><span style="color:#D4D4D4">());</span></span>
<span class="line"><span style="color:#9CDCFE">console</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">log</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">encode</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pc</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">localDescription</span><span style="color:#D4D4D4">)); </span><span style="color:#6A9955">// 一行 base64url，人工粘回 A</span></span>
<span class="line"><span style="color:#6A9955">// 对端通道打开时的接收处理 pc.ondatachannel 见步⑤</span></span></code></pre><p><strong>④ A：粘贴 answer token，连接自动建立</strong></p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">await</span><span style="color:#9CDCFE"> pc</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">setRemoteDescription</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">decode</span><span style="color:#D4D4D4">(</span><span style="color:#C586C0">await</span><span style="color:#DCDCAA"> ask</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"answer token: "</span><span style="color:#D4D4D4">)));</span></span>
<span class="line"><span style="color:#6A9955">// ICE / DTLS / SCTP 自动完成，DataChannel 变为 open</span></span></code></pre><p><strong>⑤ 收发与 loop back</strong></p>
<p>B 侧从头到尾没有调用过 <code>createDataChannel</code>。A 建的通道会在 B 侧通过 <code>ondatachannel</code> 事件自动送进来，拿到它才能收发：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// B 侧：收到即原样回传。双方都回传会无限乒乓，所以只让一侧回传</span></span>
<span class="line"><span style="color:#569CD6">function</span><span style="color:#DCDCAA"> loopBack</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">channel</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#9CDCFE">  channel</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">onMessage</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">subscribe</span><span style="color:#D4D4D4">((</span><span style="color:#9CDCFE">msg</span><span style="color:#D4D4D4">) </span><span style="color:#569CD6">=></span><span style="color:#9CDCFE"> channel</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">send</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">msg</span><span style="color:#D4D4D4">));</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#9CDCFE">pc</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">ondatachannel</span><span style="color:#D4D4D4"> = (</span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">) </span><span style="color:#569CD6">=></span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#DCDCAA">  loopBack</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">channel</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// A 侧：打印对端回传的内容（A 自己建的通道直接用 dc 变量收发）</span></span>
<span class="line"><span style="color:#9CDCFE">dc</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">onMessage</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">subscribe</span><span style="color:#D4D4D4">((</span><span style="color:#9CDCFE">msg</span><span style="color:#D4D4D4">) </span><span style="color:#569CD6">=></span><span style="color:#9CDCFE"> console</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">log</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"[echo]"</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">msg</span><span style="color:#D4D4D4">));</span></span>
<span class="line"><span style="color:#9CDCFE">rl</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">on</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"line"</span><span style="color:#D4D4D4">, (</span><span style="color:#9CDCFE">line</span><span style="color:#D4D4D4">) </span><span style="color:#569CD6">=></span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">dc</span><span style="color:#D4D4D4"> &#x26;&#x26; </span><span style="color:#9CDCFE">dc</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">readyState</span><span style="color:#D4D4D4"> === </span><span style="color:#CE9178">"open"</span><span style="color:#D4D4D4">) </span><span style="color:#9CDCFE">dc</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">send</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">line</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">});</span></span></code></pre><p>token 的编解码各只有一行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">const</span><span style="color:#DCDCAA"> encode</span><span style="color:#D4D4D4"> = (</span><span style="color:#9CDCFE">d</span><span style="color:#D4D4D4">) </span><span style="color:#569CD6">=></span></span>
<span class="line"><span style="color:#9CDCFE">  Buffer</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">from</span><span style="color:#D4D4D4">(</span><span style="color:#4FC1FF">JSON</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">stringify</span><span style="color:#D4D4D4">({ </span><span style="color:#9CDCFE">type:</span><span style="color:#9CDCFE"> d</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">type</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">sdp:</span><span style="color:#9CDCFE"> d</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">sdp</span><span style="color:#D4D4D4"> }), </span><span style="color:#CE9178">"utf8"</span><span style="color:#D4D4D4">).</span><span style="color:#DCDCAA">toString</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"base64url"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#569CD6">const</span><span style="color:#DCDCAA"> decode</span><span style="color:#D4D4D4"> = (</span><span style="color:#9CDCFE">line</span><span style="color:#D4D4D4">) </span><span style="color:#569CD6">=></span></span>
<span class="line"><span style="color:#4FC1FF">  JSON</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">parse</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">Buffer</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">from</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">line</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">trim</span><span style="color:#D4D4D4">(), </span><span style="color:#CE9178">"base64url"</span><span style="color:#D4D4D4">).</span><span style="color:#DCDCAA">toString</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"utf8"</span><span style="color:#D4D4D4">));</span></span></code></pre><h2>运行方式</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">node</span><span style="color:#CE9178"> peer.min.js</span><span style="color:#CE9178"> answer</span><span style="color:#6A9955">   # 终端 B：粘 offer token，打出 answer token</span></span>
<span class="line"><span style="color:#DCDCAA">node</span><span style="color:#CE9178"> peer.min.js</span><span style="color:#CE9178"> offer</span><span style="color:#6A9955">    # 终端 A：粘 answer token，然后直接打字</span></span></code></pre><p>两端打印 <code>连接状态: connected</code> 后，任意一侧输入文本回车，对端收到并原样回传。</p>
<p>werift 有两个专属的坑，脚本注释里有标注：<code>setLocalDescription</code> 的返回值里 <code>sdp</code> 为空，必须用 <code>pc.localDescription</code>；<code>SessionDescription.type</code> 是原型 getter，序列化 token 时必须显式取 <code>{type, sdp}</code>，否则 answer 侧推断类型，DataChannel 建立不可靠。</p>]]></content>
    <category term="JavaScript"/>
    <category term="通信协议"/>
    <category term="WebRTC"/>
  </entry>
  <entry>
    <title>砍掉色度低位肉眼几乎无感：一个可交互的 YUV 位掩码压缩实验页</title>
    <link href="https://createskyblue.github.io/posts/yuv-bitmask-compress-lab/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/yuv-bitmask-compress-lab/</id>
    <published>2026-09-13T00:00:00.000Z</published>
    <updated>2026-09-13T00:00:00.000Z</updated>
    <summary>浏览器里开关 YUV 24 个位掩码，实时看图片被压缩，算清省了多少空间</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>砍掉色度低位肉眼几乎无感：一个可交互的 YUV 位掩码压缩实验页</h1>
<p>下面嵌入一个纯前端的 YUV 颜色空间小实验：页面把图片从 RGB 变换到 YUV（BT.601），每个像素拆成 Y 亮度、U/V 两个色度分量，各占 8bit。你可以逐位开关 3×8 共 24 个 bit 掩码，被关闭的位直接置零，等价于把该分量量化到更少的级数；页面随即重建压缩后图像，与原图并排对比，并给出压缩率、压缩前后大小、节省空间和 PSNR 画质评分。由于人眼对亮度敏感、对色度不敏感，优先砍 U/V 的低位往往看不出差别，却能省下大量存储空间。默认的 Y6/U4/V4 档把每像素 24bit 压到 14bit，实测节省约 42% 空间。页面内置一张默认图片（base64 内嵌），也可以拖入任意本地图片，全部计算在浏览器本地完成，不上传任何数据。</p>
<iframe id="yuvLabFrame" data-auto-height="true" src="/posts/yuv-bitmask-compress-lab/yuv-lab.html" style="width:100%; height:1600px; border:1px solid #e2e8f0; border-radius:8px; margin:16px 0; display:block; background:#F5F7FA;" scrolling="no" loading="lazy" title="YUV 位掩码压缩交互实验"></iframe>]]></content>
    <category term="前端"/>
    <category term="JavaScript"/>
    <category term="Canvas"/>
    <category term="图像工具"/>
  </entry>
  <entry>
    <title>Casio fx-82ES Plus 计算器方向键失灵，绿油下铜箔微裂纹飞线修复</title>
    <link href="https://createskyblue.github.io/posts/casio-fx82es-key-repair/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/casio-fx82es-key-repair/</id>
    <published>2026-09-12T00:00:00.000Z</published>
    <updated>2026-09-12T00:00:00.000Z</updated>
    <summary>卡西欧计算器方向键失灵，绿油下铜箔断线，飞线修复</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Casio fx-82ES Plus 计算器方向键失灵，绿油下铜箔微裂纹飞线修复</h1>
<p>用了十来年的卡西欧计算器方向键↓用不了，拆开维修</p>
<p><img src="/posts/casio-fx82es-key-repair/PixPin_2026-09-12_10-02-33.webp" alt="电路板反面"></p>
<p>①是无法使用的按键，红色和蓝色是按键对应的扫描矩阵走线，其中红色线路通过过孔②去正面，蓝色线路通过过孔序号③来带有芯片的正面；</p>
<ul>
<li>使用低功耗分析仪发现，开机电流是30μA，任意按键按下后电流是50μA，按键电流大约20μA；</li>
<li>排查红色支路上的其他按键，按下后有20μA电流增量；</li>
<li>排查蓝色支路上的其他按键，发现按下后没有任何电流增量，并且对应的按键无反应，这说明有问题在蓝色支路上，进一步检查正面序号③的过孔。</li>
</ul>
<p><img src="/posts/casio-fx82es-key-repair/PixPin_2026-09-12_10-07-30.webp" alt="电路板正面"></p>
<p>现在来到电路板带芯片的正面，过孔③在丝印P134处，它被连接到序号④K17测试点，接着通过序号⑤有绿油覆盖的走线来到序号⑥的过孔重新回到背面。</p>
<p>经过测量发现序过孔④到序号⑥不通，刮开 ⑤ 处的绿油，覆铜在绿油下方已经断开。外观完全看不出来：绿油完好，铜箔的裂纹藏在下面，推测是按键按压、潮气腐蚀，细铜箔出现微裂纹。飞线处理。</p>
<p><img src="/posts/casio-fx82es-key-repair/2116236428.webp" alt="序号⑤覆铜之下线路断开处"></p>
<p><img src="/posts/casio-fx82es-key-repair/1006827852.webp" alt="飞线"></p>
<p><img src="/posts/casio-fx82es-key-repair/cover.webp" alt="飞线后维修完成，通过测试程序"></p>
<p>飞线后 ↓ 键恢复正常，通过内置隐藏的按键测试程序</p>]]></content>
    <category term="硬件拆解"/>
    <category term="故障诊断"/>
    <category term="工程调试"/>
    <category term="低功耗"/>
    <category term="按键"/>
    <category term="PCB"/>
  </entry>
  <entry>
    <title>DM3058E 内部电路板图集</title>
    <link href="https://createskyblue.github.io/posts/dm3058e-pcb/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/dm3058e-pcb/</id>
    <published>2026-09-11T00:00:00.000Z</published>
    <updated>2026-09-11T00:00:00.000Z</updated>
    <summary>RIGOL DM3058E 万用表拆机内部电路板实拍</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>DM3058E 内部电路板图集</h1>
<p>带大家看看 RIGOL DM3058E 万用表的内部电路板。</p>
<p><img src="/posts/dm3058e-pcb/cover.webp" alt="DM3058E 内部电路板 01"></p>
<p><img src="/posts/dm3058e-pcb/img/img-02.webp" alt="DM3058E 内部电路板 02"></p>
<p><img src="/posts/dm3058e-pcb/img/img-03.webp" alt="DM3058E 内部电路板 03"></p>
<p><img src="/posts/dm3058e-pcb/img/img-04.webp" alt="DM3058E 内部电路板 04"></p>
<p><img src="/posts/dm3058e-pcb/img/img-05.webp" alt="DM3058E 内部电路板 05"></p>
<p><img src="/posts/dm3058e-pcb/img/img-06.webp" alt="DM3058E 内部电路板 06"></p>
<p><img src="/posts/dm3058e-pcb/img/img-07.webp" alt="DM3058E 内部电路板 07"></p>
<p><img src="/posts/dm3058e-pcb/img/img-08.webp" alt="DM3058E 内部电路板 08"></p>
<p><img src="/posts/dm3058e-pcb/img/img-09.webp" alt="DM3058E 内部电路板 09"></p>
<p><img src="/posts/dm3058e-pcb/img/img-10.webp" alt="DM3058E 内部电路板 10"></p>
<p><img src="/posts/dm3058e-pcb/img/img-11.webp" alt="DM3058E 内部电路板 11"></p>
<p><img src="/posts/dm3058e-pcb/img/img-12.webp" alt="DM3058E 内部电路板 12"></p>
<p><img src="/posts/dm3058e-pcb/img/img-13.webp" alt="DM3058E 内部电路板 13"></p>
<p><img src="/posts/dm3058e-pcb/img/img-14.webp" alt="DM3058E 内部电路板 14"></p>
<p><img src="/posts/dm3058e-pcb/img/img-15.webp" alt="DM3058E 内部电路板 15"></p>]]></content>
    <category term="硬件拆解"/>
    <category term="万用表"/>
    <category term="DM3058E"/>
    <category term="PCB"/>
  </entry>
  <entry>
    <title>接悬空 FPC 后相邻走线串扰抬到 3 倍以上</title>
    <link href="https://createskyblue.github.io/posts/fpc-suspended-crosstalk/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/fpc-suspended-crosstalk/</id>
    <published>2026-09-11T00:00:00.000Z</published>
    <updated>2026-09-11T00:00:00.000Z</updated>
    <summary>悬空FPC抬高相邻走线串扰，中间隔GND线有隔离收益</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>接悬空 FPC 后相邻走线串扰抬到 3 倍以上</h1>
<p>同一块测试板，几根信号线本体都是悬空的——这只为了把耦合量测干净，不是产品真实接法。只在连接器上多插一根远端不接的 FPC，把悬空导体拉长后，相邻测点上的 20 kHz 方波串扰就抬到原来的 3 倍以上。中间隔接地线的测点幅度仍低一截，隔 NC 占位线的测点最脏。</p>
<h2>测试条件</h2>
<ul>
<li>干扰注入：5 Vpp、20 kHz 方波（唯一有源激励）</li>
<li>观测：示波器看各测点串扰 Vpp；A / NC / B 均为悬空高阻节点</li>
<li>对比：裸板 vs 再接一根远端悬空的 FPC</li>
<li>线序上→下：测试点 A / NC 占位 / 干扰注入 / GND（接地隔离） / 测试点 B</li>
</ul>
<p><img src="/posts/fpc-suspended-crosstalk/PixPin_2026-09-11_12-18-42.webp" alt="裸板测试点标注"></p>
<p><img src="/posts/fpc-suspended-crosstalk/PixPin_2026-09-11_12-18-18.webp" alt="接上悬空 FPC 后"></p>
<h2>串扰对比</h2>
<table>
<thead>
<tr>
<th>测点</th>
<th>裸板</th>
<th>接悬空 FPC</th>
<th>变化</th>
</tr>
</thead>
<tbody><tr>
<td>A（隔 NC）</td>
<td><img src="/posts/fpc-suspended-crosstalk/S013.BMP" alt="A 裸板"></td>
<td><img src="/posts/fpc-suspended-crosstalk/S010.BMP" alt="A 接 FPC"></td>
<td>50.5 mV → <strong>167 mV</strong>（3.3×，+10.4 dB）</td>
</tr>
<tr>
<td>NC 占位</td>
<td><img src="/posts/fpc-suspended-crosstalk/S020.BMP" alt="NC 裸板"></td>
<td><img src="/posts/fpc-suspended-crosstalk/S018.BMP" alt="NC 接 FPC"></td>
<td>63.3 mV → <strong>275 mV</strong>（表观 4.3×，读数含振铃）</td>
</tr>
<tr>
<td>干扰注入</td>
<td><img src="/posts/fpc-suspended-crosstalk/S012.BMP" alt="注入点"></td>
<td><img src="/posts/fpc-suspended-crosstalk/S012.BMP" alt="注入点"></td>
<td>激励源无变化</td>
</tr>
<tr>
<td>GND</td>
<td><img src="/posts/fpc-suspended-crosstalk/S008.BMP" alt="GND 裸板"></td>
<td><img src="/posts/fpc-suspended-crosstalk/S008.BMP" alt="GND 接 FPC"></td>
<td>-</td>
</tr>
<tr>
<td>B（隔 GND）</td>
<td><img src="/posts/fpc-suspended-crosstalk/S014.BMP" alt="B 裸板"></td>
<td><img src="/posts/fpc-suspended-crosstalk/S011.BMP" alt="B 接 FPC"></td>
<td>21.1 mV → <strong>67.7 mV</strong>（3.2×，+10.1 dB）</td>
</tr>
</tbody></table>
<p>接上 FPC 后，几个敏感测点串扰都抬到 3 倍以上，注入源本身没变。相邻引脚寄生电容粗测：裸板约 5 pF，接 FPC 后约 10 pF。</p>
<p>各测点本来都悬空；NC 占位点接上 FPC 后振铃更明显，示波器 Vpp 把过冲一并计入，所以 275 mV 偏高；即便按折算，涨幅仍然明显。</p>
<h2>简要判断</h2>
<p>20 kHz 下波长约 15 km，FPC 远小于波长，耦合走的是近场容性路径，天线辐射可忽略。悬空 FPC 等于把并行导体拉长，互容变大，受害线分到的干扰跟着变大。</p>
<p>A 与源之间隔的是 NC 线，B 与源之间隔的是 GND 线，间距一样。A/B 在裸板和接 FPC 后都在 2.4 倍上下：中间导体接地，大约带来 7.7 dB 隔离收益。浮置 NC 既挡不住耦合，还会被方波边沿激出振铃。</p>]]></content>
    <category term="嵌入式"/>
    <category term="干扰"/>
    <category term="信号处理"/>
    <category term="工程调试"/>
    <category term="测试"/>
  </entry>
  <entry>
    <title>万用表短接表笔读出微安级电流，来源是表笔热电势</title>
    <link href="https://createskyblue.github.io/posts/multimeter-probe-thermal-emf/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/multimeter-probe-thermal-emf/</id>
    <published>2026-09-11T00:00:00.000Z</published>
    <updated>2026-09-11T00:00:00.000Z</updated>
    <summary>短接表笔出现微安级电流，表笔热电势干扰微弱电流测量</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>万用表短接表笔读出微安级电流，来源是表笔热电势</h1>
<p>测低功耗器件电流时，开路电流档接近 0，短接表笔也应接近 0。实测短接后仍有微安级残余，手温或打火机加热单侧笔尖会让读数更大，确认干扰来自表笔热电势。</p>
<h2>现象</h2>
<p>仪器：RIGOL DM3058E，5½ 位台式万用表，直流电流档。</p>
<p>开路时电流档为 0。先碰到过红色表笔笔尖，再去短接测量，读数莫名其妙停在两三个微安，没有回零：</p>
<p><img src="/posts/multimeter-probe-thermal-emf/01-hand-probe.webp" alt="先碰过红色表笔笔尖后短接，电流约 2.249 µA"></p>
<h2>红色表笔电流端子与表笔尖的插入端对调</h2>
<p>同一只红色表笔，把电流端子与表笔尖分别改接到电流档的正、负端子。</p>
<p>红色表笔的电流端子接正电流端子，红色表笔它自己插入负端子，短接后读数为 +6.825 µA：</p>
<p><img src="/posts/multimeter-probe-thermal-emf/02-jack-a.webp" alt="红色表笔电流端子接正、表笔插入负，读数 6.825 µA"></p>
<p>红色表笔的电流端子接负电流端子，红色表笔插入正端子，短接后读数变为 −1.270 µA：</p>
<p><img src="/posts/multimeter-probe-thermal-emf/03-jack-b.webp" alt="红色表笔电流端子接负、表笔插入正，读数 −1.270 µA"></p>
<p>端子对调后，残余电流符号相反。</p>
<h2>打火机加热表笔</h2>
<p>用打火机分别加热红色表笔与另一只表笔的笔尖，再短接读数：</p>
<table>
<thead>
<tr>
<th>加热红色表笔</th>
<th>加热黑色表笔</th>
</tr>
</thead>
<tbody><tr>
<td><img src="/posts/multimeter-probe-thermal-emf/04-heat-red.webp" alt="打火机加热红色表笔"></td>
<td><img src="/posts/multimeter-probe-thermal-emf/06-heat-black.webp" alt="打火机加热黑色表笔"></td>
</tr>
<tr>
<td>加热后短接：+40.592 µA</td>
<td>加热后短接：−107.194 µA</td>
</tr>
<tr>
<td><img src="/posts/multimeter-probe-thermal-emf/05-heat-red-reading.webp" alt="加热红色表笔后的读数"></td>
<td><img src="/posts/multimeter-probe-thermal-emf/07-heat-black-reading.webp" alt="加热黑色表笔后的读数"></td>
</tr>
</tbody></table>
<p>单侧笔尖被加热，短接回路里出现几十到上百微安；加热不同侧，读数符号也不同。</p>
<p>加热实验过程：</p>
<video controls loop playsinline width="100%" style="border-radius: 12px;">
  <source src="/posts/multimeter-probe-thermal-emf/lighter-demo.mp4" type="video/mp4">
  你的浏览器不支持视频播放。
</video>

<p>放置一段时间后，两只表笔温度接近，再短接读数降到 0.135 µA：</p>
<p><img src="/posts/multimeter-probe-thermal-emf/08-equilibrium.webp" alt="热平衡后短接，电流 0.135 µA"></p>
<h2>热电势来源</h2>
<p>热电势并不要求两只笔尖基材一定不同。回路里凡是异种金属结点，笔尖镀膜与基材、镀层磨损处、接插件与香蕉头、氧化膜两侧，只要结点两侧存在温差，就会按塞贝克效应产生热电势。手温、打火机火焰都在制造局部温度差；电流档把热电势驱动的电流一并读出。手碰过笔尖后，汗液还可能在镀膜与氧化层之间形成微弱原电池，再叠上一点直流分量。</p>
<p>表笔插入极性决定热电势在电流表里呈现正号还是负号。哪一侧更热，热电势方向就跟着变。温度差消失后，读数回到零点附近的残余值（本次约 0.135 µA）。</p>
<p>热电势也出现在仪器内部。开机时间一长，机内功率器件、电源与外壳之间会形成温差，输入回路和开关接点上的异种金属接头同样按塞贝克效应产生毫伏级以下的寄生电势。这部分零点残余在预热过程中会缓慢变化，只能靠预热稳定、扣零或相对测量压下去，消不掉。</p>
<p><img src="/posts/multimeter-probe-thermal-emf/thermal-emf.svg" alt="表笔短接回路里的热电势示意"></p>]]></content>
    <category term="嵌入式"/>
    <category term="工程调试"/>
    <category term="故障诊断"/>
    <category term="干扰"/>
    <category term="低功耗"/>
    <category term="测试"/>
  </entry>
  <entry>
    <title>信号串扰：线缆寄生电容</title>
    <link href="https://createskyblue.github.io/posts/parasitic-cap-cable-coupling/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/parasitic-cap-cable-coupling/</id>
    <published>2026-09-11T00:00:00.000Z</published>
    <updated>2026-09-11T00:00:00.000Z</updated>
    <summary>测试线缆把 10 kHz 方波耦合进 ADC 业务通道，断开引线后波形归平</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>信号串扰：线缆寄生电容</h1>
<p>ADC 通道 1～4 全是悬空，没有接传感器。板载 10 kHz 测试方波却出现在波形上；把红端子从 ADC 引脚拔掉后，四路又一起变平。这组对照把干扰源指向外部线缆的寄生电容，并排除了板内串扰作为主要来源。</p>
<h2>步骤一：接线实物</h2>
<p>测试板输出 10 kHz 方波，业务采集侧用 ADC Stream Scope 同时看 ADC 通道 1～4 四路。四路 ADC 引脚都悬空。测试线一端是黑、红两个香蕉端子，另一端线芯外露、什么也没接。</p>
<p>测试工程：<a href="https://github.com/createskyblue/STM32L496_ADC_DMA_MuChannel_Sample" target="_blank" rel="noopener noreferrer">github.com/createskyblue/STM32L496_ADC_DMA_MuChannel_Sample</a></p>
<ul>
<li>黑端子 → 板上 10 kHz 测试信号</li>
<li>红端子 → 业务 ADC1（悬空引脚）</li>
<li>线缆远端悬空</li>
</ul>
<p><img src="/posts/parasitic-cap-cable-coupling/%E5%AE%9E%E7%89%A9-%E6%B3%A8%E5%85%A5%E6%B5%8B%E8%AF%95%E6%8E%A5%E7%BA%BF.webp" alt="黑端子接 10 kHz 方波，红端子接 ADC 业务通道 1，线缆另一端悬空"></p>
<h2>步骤二：耦合波形</h2>
<p>红端子接到悬空的通道 1 后，通道 1 出现与测试信号同频的方波，游标处约 2.733 V，幅度接近满量程。悬空引脚本身不产生信号，作为高阻抗节点却容易通过寄生路径拾取外部信号；这里的方波来自黑端子上的 10 kHz 经线缆寄生电容耦合。通道 2～4 同样悬空，也在波形上露出同相位的残余小波。</p>
<p><img src="/posts/parasitic-cap-cable-coupling/%E6%B3%A2%E5%BD%A2-%E8%80%A6%E5%90%88%E5%B9%B2%E6%89%B0.webp" alt="通道 1 耦合进 10 kHz 方波，后续通道出现残余波形"></p>
<table>
<thead>
<tr>
<th>通道</th>
<th>现象</th>
<th>视窗统计</th>
</tr>
</thead>
<tbody><tr>
<td>ADC 通道 1</td>
<td>完整 10 kHz 方波</td>
<td>min 0.000 V / max 2.823 V</td>
</tr>
<tr>
<td>ADC 通道 2</td>
<td>同步残余小波</td>
<td>min 0.361 V / max 1.070 V</td>
</tr>
<tr>
<td>ADC 通道 3</td>
<td>台阶起伏更弱</td>
<td>min 0.438 V / max 0.567 V</td>
</tr>
<tr>
<td>ADC 通道 4</td>
<td>近平坦，仍有细小起伏</td>
<td>约 0.516 V</td>
</tr>
</tbody></table>
<p>通道 2～4 上的小波，是单 ADC 规则组切换留下的。MCU 里通常只有一个 ADC，按通道 1→2→3→4 分时采样。通道 1 被耦合方波充到对应电平后，切到后面几个悬空通道，内部采样电容还带着上一通道的残余电荷，转换结果里就留下同相位的小波形。越靠后，残余幅度越小。悬空通道上的小波，是电荷被带到下一通道的结果，并非它们也直接吃到了方波。</p>
<p><img src="/posts/parasitic-cap-cable-coupling/%E9%80%9A%E9%81%93%E6%AE%8B%E7%95%99.svg" alt="四路 ADC 均悬空，通道 1 被耦合，通道 2～4 残留电荷"></p>
<h2>步骤三：断开对照接线</h2>
<p>只改一处：红端子从 ADC 引脚拔掉，黑端子仍接 10 kHz，线缆其余接法不变。</p>
<p><img src="/posts/parasitic-cap-cable-coupling/%E5%AE%9E%E7%89%A9-%E6%96%AD%E5%BC%80ADC%E5%AF%B9%E7%85%A7.webp" alt="红端子离开 ADC 引脚，其余接线保持原样"></p>
<h2>步骤四：断开后波形</h2>
<p>四路同时变平，游标附近都在 0.503 V 附近，视窗统计几乎没有起伏。</p>
<p><img src="/posts/parasitic-cap-cable-coupling/%E6%B3%A2%E5%BD%A2-%E7%A9%BA%E7%99%BD%E5%AF%B9%E7%85%A7.webp" alt="断开 ADC 引脚后四路通道全部变平"></p>
<table>
<thead>
<tr>
<th>通道</th>
<th>断开前</th>
<th>断开后</th>
</tr>
</thead>
<tbody><tr>
<td>ADC 通道 1</td>
<td>0～2.823 V 方波</td>
<td>约 0.503 V 平线</td>
</tr>
<tr>
<td>ADC 通道 2</td>
<td>0.361～1.070 V 残余</td>
<td>约 0.503 V 平线</td>
</tr>
<tr>
<td>ADC 通道 3</td>
<td>0.438～0.567 V 起伏</td>
<td>约 0.503 V 平线</td>
</tr>
<tr>
<td>ADC 通道 4</td>
<td>细小起伏</td>
<td>约 0.503 V 平线</td>
</tr>
</tbody></table>
<h2>线缆寄生电容路径</h2>
<p><img src="/posts/parasitic-cap-cable-coupling/%E8%80%A6%E5%90%88%E6%9C%BA%E5%88%B6.svg" alt="线缆内寄生电容把 10 kHz 方波耦合进 ADC 通道 1"></p>
<p>通道 1 上的方波从线缆进来：四路 ADC 引脚都悬空。两根导线并行走线时存在寄生电容，黑端子上的 10 kHz 方波经这层电容送进红端子连接的悬空通道 1。万用表粗测线间电容约 0.389 nF，量级与寄生耦合相符。红端子一拔，耦合路径断开，四路一起归平。</p>
<p><img src="/posts/parasitic-cap-cable-coupling/%E7%BA%BF%E7%BC%86%E5%AF%84%E7%94%9F%E7%94%B5%E5%AE%B9389pf.webp" alt="万用表粗测线间电容约 0.389 nF"></p>
<h2>结论</h2>
<p>经对照，四路悬空 ADC 上的 10 kHz 方波属实，板内串扰并非主要来源。原因分两层：外层是测试线缆两根导线之间的寄生电容，把黑端子上的方波送进了红端子连接的悬空通道 1；内层是单 ADC 规则组分时切换，后续悬空通道因采样电容残余电荷留下同相位小波。</p>
<p>验证方式是断开红端子后波形归平。后续用这根线做信号注入时，应把远端悬空、通道间距和走线长度当作可控变量；板级排查若只见业务通道异常，先断外部引线再查 PCB 走线，可以把板内串扰和线缆耦合分开。</p>]]></content>
    <category term="嵌入式"/>
    <category term="ADC"/>
    <category term="干扰"/>
    <category term="信号处理"/>
    <category term="工程调试"/>
  </entry>
  <entry>
    <title>硬件 SPI 多出一个时钟脉冲：HAL 的 Disable 让 SCK 失去驱动</title>
    <link href="https://createskyblue.github.io/posts/hal-spi-floating-sck/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/hal-spi-floating-sck/</id>
    <published>2026-09-08T00:00:00.000Z</published>
    <updated>2026-09-08T00:00:00.000Z</updated>
    <summary>硬件SPI
为什么第一个SCK前
多出一个异常脉冲</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>硬件 SPI 多出一个时钟脉冲：HAL 的 Disable 让 SCK 失去驱动</h1>
<h2>概要</h2>
<p>普冉 PY32（兼容 STM32F0 系列的国产 MCU）驱动一颗外部采样芯片，SPI 引脚配成 AF 复用，CS 由软件 GPIO 控制。同一组物理线路，软件 SPI 正常，硬件 SPI 发 1 字节时 SCK 出了 9 个脉冲（预期 8），多出的脉冲出现在数据之前，从设备数据整体错位。断点定位到 PY32 厂商 HAL 的 <code>HAL_SPI_Transmit</code>：它在每次传输开始前先把 SPE 清零再重新使能，关闭 SPI 这段时间 SCK 失去主动驱动，被外部周期干扰扰动出一个异常边沿。屏蔽掉这句 Disable 之后异常脉冲消失，从设备恢复正常。</p>
<p>因果链：</p>
<p><img src="/posts/hal-spi-floating-sck/causal-chain.webp" alt="多出的脉冲是怎么来的"></p>
<h2>问题是怎么来的</h2>
<p>项目需要读一颗外部采样芯片，硬件 SPI 死活驱动不了从设备。软件 SPI 可以正常驱动，说明接线和从设备本身基本正常，也因此把排查重点转向硬件 SPI 的时序和引脚状态。</p>
<p>期间 GLM 5.3 Flash、GPT 5.6 Luna 和 Sol 都接了逻辑分析仪的 MCP 能自己抓波形，三个轮番试了 14 个小时没找出根因，最后人工下场。</p>
<p><img src="/posts/hal-spi-floating-sck/no-slave-clean.webp" alt="未接从设备时抓到的 SCK 波形，起始处可见干扰"></p>
<h2>实验：8bit 数据出了 9 个时钟</h2>
<p>对比实验：分别用软件 SPI 和硬件 SPI 发送 1 字节、2 字节，接入逻辑分析仪抓波形。同样的线，软件 SPI 正常；硬件 SPI 发 1 字节时 SCK 出现 9 个脉冲（预期 8 个），多出的那个脉冲出现在数据之前，把整个字节流错位 1 bit：</p>
<p><img src="/posts/hal-spi-floating-sck/logic-analyzer-9-pulses.webp" alt="逻辑分析仪波形：箭头处是多出的第 1 个脉冲"></p>
<p><em>已降速抓取，速率与后面的示波器图不一致。</em></p>
<p>上示波器看模拟波形，确认这个干扰正是多出来的那个脉冲：</p>
<p><img src="/posts/hal-spi-floating-sck/with-slave-glitch.webp" alt="示波器：SCK 前沿前的干扰"></p>
<h2>根因：HAL 入口的 Disable 让 SCK 失去驱动</h2>
<p>断点调试定位到 <code>HAL_SPI_Transmit</code> 函数体，Disable 就在传输开始前的配置段。源码截图里两个红框分别标出 <code>__HAL_SPI_DISABLE(hspi)</code> 和 <code>__HAL_SPI_ENABLE(hspi)</code>，问题出在两个红框之间的这段区间：</p>
<p><img src="/posts/hal-spi-floating-sck/hal-source-disable-enable.webp" alt="HAL_SPI_Transmit 源码：先 Disable 再 Enable"></p>
<p>SPE 清零之后，SPI 外设不再主动驱动 SCK。对当前这颗 PY32 的 SPI/IO 实现，SCK 在该时间窗口内失去原有的主动驱动状态，实测表现为容易受到外部干扰影响。</p>
<p>CS 在调用 <code>HAL_SPI_Transmit</code> 之前就由软件拉低，整个传输期间一直有效，这个时间窗口完全落在从设备的选中期内。把断点停在 Disable 之后，程序暂停、SPI 保持关闭，此时用示波器看 SCK 引脚，能看到持续的外部周期干扰：</p>
<p><img src="/posts/hal-spi-floating-sck/disable-glitch.webp" alt="Disable 时刻 SCK 引脚出现的干扰"></p>
<p>该干扰边沿落入 CS 有效期间，最终表现为从设备多接收了一个时钟，后续数据整体错位 1 bit。</p>
<h2>缓解尝试</h2>
<p>CPOL=0 时 SCK 空闲电平是低，按空闲电平给 SCK 加下拉，想把 Disable 期间失去主动驱动的引脚钉在低电平。效果不好，干扰仍在：</p>
<p><img src="/posts/hal-spi-floating-sck/external-pulldown.webp" alt="采取外部下拉措施后的干扰"></p>
<p>实际测试中，弱下拉仍无法消除落在输入阈值附近的毛刺。</p>
<h2>最终修复</h2>
<p>把 <code>HAL_SPI_Transmit</code> 入口的 <code>__HAL_SPI_DISABLE(hspi);</code> 屏蔽掉：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 厂商 HAL 的 HAL_SPI_Transmit()，传输开始前的配置段 */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 屏蔽这句 Disable，让 SPE 保持常开 */</span></span>
<span class="line"><span style="color:#6A9955">/* __HAL_SPI_DISABLE(hspi); */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* ... 写 CR1 / CR2 配置 ... */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (((hspi)->Instance->CR1 &#x26; SPI_CR1_SPE) != SPI_CR1_SPE)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">  __HAL_SPI_ENABLE</span><span style="color:#D4D4D4">(hspi);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>屏蔽之后，在当前 HAL 的这条传输路径中 SPI 保持使能状态，原先位于 Disable 和 Enable 之间的窗口被消除；后面那句条件使能因为 <code>CR1 &amp; SPI_CR1_SPE</code> 已经等于 <code>SPI_CR1_SPE</code> 也不再触发。SCK 始终由 MCU 强驱动，实验中观察到的异常边沿消失，多余的脉冲消失，从设备恢复正常。</p>
<h2>同类问题</h2>
<p>这个现象并不是 PY32 独有。ST 社区也有过类似案例：SPI 被关闭期间 SCK 出现异常跳变，最终导致从设备多收到一个 bit。ST 官方回帖给的解法是置 AFCNTR（Master Keep IO State）。</p>
<p>Linux 侧也有人提过同一件事。Ben Wolsieffer 提交给 spi-stm32 驱动的补丁（<code>spi: stm32: enable controller before asserting CS</code>，<a href="https://www.spinics.net/lists/linux-spi/msg41140.html">邮件列表存档</a>）说明里写得很直白：STM32F4/F7 的 MOSI 和 CLK 在控制器禁用期间会浮空，而 CS 是普通 GPIO、一直被驱动，于是存在一段 CS 已选中、SPI 引脚却没人驱动的窗口，杂散信号可能扰乱通信。同一份说明还提到 STM32H7 没这个现象，因为驱动置了 AFCNTR。</p>
<h2>一些补充</h2>
<p>一些较新的 STM32 SPI 外设提供 AFCNTR 或类似的 IO 保持机制，用于在 SPI 禁用期间继续控制相关 AF 引脚，具体寄存器和行为以对应型号的参考手册为准。本文这颗 PY32 没有这个机制，只能软件规避：改 HAL 保持 SPE 常开，或者 Disable 期间用同组 GPIO 接管 SCK 电平。</p>
<p>这不算&quot;HAL 有 Bug&quot;，更准确的说法是 HAL 的 Disable → Enable 流程触发了 SPI 外设在 Disable 状态下的 IO 行为问题。结论只针对 PY32 及其 HAL 实现。</p>
<h2>工程经验</h2>
<p>SPI 速率要求不高时我更倾向软件 SPI。硬件 SPI 的优势是性能、CPU 占用和 DMA，软件 SPI 的优势是可控性：CS、SCK、MOSI 的电平和时序都由代码直接控制，不依赖 SPI 外设自身的状态机以及对应 HAL 对外设状态的处理。MCU 还可能因为成本、供应链、国产替代被换掉，换一颗就要重新踩一遍外设差异的坑。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="SPI"/>
    <category term="干扰"/>
  </entry>
  <entry>
    <title>[转载] 东北冬季低温冻停射频晶振，空调外机WiFi模块批量失联</title>
    <link href="https://createskyblue.github.io/posts/low-temp-crystal-rf-failure/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/low-temp-crystal-rf-failure/</id>
    <published>2026-09-06T00:00:00.000Z</published>
    <updated>2026-09-06T00:00:00.000Z</updated>
    <summary>东北冬季低温下空调外机WiFi模块批量失联，回温后恢复</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>[转载] 东北冬季低温冻停射频晶振，空调外机WiFi模块批量失联</h1>
<blockquote>
<p><strong>声明：</strong> 本文是网上看到 B站视频《空调开发踩坑实录：东北外机 WiFi 集体失联》后整理的分享笔记，原始链接：<a href="https://www.bilibili.com/video/BV1Ch816CEhR" target="_blank" rel="noopener noreferrer">https://www.bilibili.com/video/BV1Ch816CEhR</a>。<strong>本文作者不是该视频的作者，未参与其中的开发和测试。</strong> 文中工程细节均来自该视频内容。</p>
</blockquote>
<h2>故障现象</h2>
<p>东北冬季，一批空调外机的 WiFi 模块集体失联，常温复测却全部正常。用高低温箱逐档降温复现：常温 25°C 连接稳定、吞吐全达标；降到 0°C 连接速率下降、偶发丢包；到 -15°C 时近三分之一的样机直接断联。环境温度回到常温后，故障全部消失，确认失效与低温强相关。</p>
<p><img src="/posts/low-temp-crystal-rf-failure/low-temp-repro.webp" alt="东北外机WiFi失联的低温复现记录"></p>
<p>低温复现画面取自原视频（<a href="https://www.bilibili.com/video/BV1Ch816CEhR" target="_blank" rel="noopener noreferrer">视频链接</a>）。</p>
<h2>原因：晶振低温失效，来料以次充好</h2>
<p>WiFi 模块的射频载波由晶振提供基准频率。这批射频晶振来自居民楼小作坊，回收旧物料以次充好，温度特性达不到标称规格：低温下频率漂移超出射频电路的容许范围，模块直接用不了。常温测试全程绿灯，问题只在低温现场暴露。</p>
<h2>教训：高低温循环测试</h2>
<p>以次充好的物料在常温下测不出来。产品出厂前做高低温循环测试，把这类器件挡在研发阶段；零下二十度的客户现场不该是物料的第一次低温测试环境。</p>]]></content>
    <category term="嵌入式"/>
    <category term="故障诊断"/>
    <category term="射频"/>
    <category term="测试"/>
  </entry>
  <entry>
    <title>嘉立创实战派 S3 插快充头烧板 排查维修</title>
    <link href="https://createskyblue.github.io/posts/esp32s3-fastcharger-repair/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp32s3-fastcharger-repair/</id>
    <published>2026-09-05T00:00:00.000Z</published>
    <updated>2026-09-05T00:00:00.000Z</updated>
    <summary>语音输入板插快充头烧了，限流上电 + 热成像定位</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>嘉立创实战派 S3 插快充头烧板 排查维修</h1>
<p>嘉立创实战派 S3 做的语音输入板：麦克风拾音，转文字喂给 AI 助手，平时挂一个快充头供电。用了几天，屏幕黑了，插电脑没反应。</p>
<p><strong>快充头误升档打穿入口 DC-DC，连带伤了音频芯片。换两颗芯片，恢复正常。</strong></p>
<table>
<thead>
<tr>
<th>语音输入</th>
<th>设置</th>
<th>触控板</th>
</tr>
</thead>
<tbody><tr>
<td><img src="/posts/esp32s3-fastcharger-repair/img/lcd_screenshot%20(1).bmp" alt=""></td>
<td><img src="/posts/esp32s3-fastcharger-repair/img/lcd_screenshot%20(2).bmp" alt=""></td>
<td><img src="/posts/esp32s3-fastcharger-repair/img/lcd_screenshot%20(3).bmp" alt=""></td>
</tr>
</tbody></table>
<p><img src="/posts/esp32s3-fastcharger-repair/cover.webp" alt="拆开的实战派 S3 主板"></p>
<h2>原因：快充头误升高压</h2>
<p>板子对外只有一个 Type-C，直连 ESP32-S3 USB 引脚，中间没有隔离；供电侧先进 DC-DC 降压再喂给 ESP32-S3。</p>
<p>快充头默认输出 5V，靠 CC 线协议握手后才升到 9V/20V。线缆接触不良或 CC 识别抖动时，快充头可能误判握手完成、直接放出高压档，入口那颗 DC-DC 直接承受过压，击穿后主供电断，整板没电。</p>
<h2>排查：限流上电 + 热成像定位</h2>
<p>坏板不能直接满压上电。飞线接外部可调电源，<strong>先 5V / 50mA</strong> 试探：ESP32-S3 未启动，电源进入恒流（CC）状态，后级低阻短路。</p>
<p>切到 <strong>1V / 1A</strong>，电压低、电流足，故障点发热。用自制热成像工具扫描，发热点集中在序号 1、序号 2，两颗同型号 DC-DC：一颗供 ESP32，一颗供音频。高压从 Type-C 进来，首当其冲是入口那颗（序号 1）。</p>
<p><img src="/posts/esp32s3-fastcharger-repair/img/seq1-front.webp" alt="序号 1 位置（正面，靠近 USB 口）"></p>
<p><img src="/posts/esp32s3-fastcharger-repair/img/seq23-back.webp" alt="序号 2、序号 3 位置（背面）"></p>
<h2>绕过 DC-DC，先启动系统</h2>
<p>飞线绕过坏 DC-DC，外部直接给 3.3V；先不插屏幕，最小系统启动，避免外设干扰判断。</p>
<h2>二次故障：I²C 扫不到录音芯片</h2>
<p>接串口看日志，程序没跑起来：I²C 总线上扫不到音频 ADC。这颗芯片热成像只有 50 多度、微烫，但它挂在同一条被高压打过的供电链路上，判定连带损坏（序号 3，板子背面）。</p>
<h2>修复</h2>
<p>DC-DC 和音频芯片各换一颗。</p>
<ul>
<li>DC-DC 换掉：5V 短路消失，主供电恢复，屏幕点亮。</li>
<li>音频芯片换掉：I²C 识别正常，录音恢复。</li>
</ul>
<h2>排查方法小结</h2>
<ol>
<li>故障板先限流上电（5V/50mA），确认有无短路，不满压复现。</li>
<li>电源进 CC 恒流 = 短路，切低压大电流（1V/1A）让故障点发热，热成像定位。</li>
<li>飞线绕过疑似故障点、最小化系统，先把能跑的部分跑起来，再看串口日志找二次损伤。</li>
<li>同一条高压/电源链路上的芯片，热成像不烫也要怀疑，串口日志比温度更早报故障。</li>
</ol>]]></content>
    <category term="嵌入式"/>
    <category term="ESP32-S3"/>
    <category term="故障诊断"/>
    <category term="工程调试"/>
    <category term="语音输入"/>
  </entry>
  <entry>
    <title>10 位正弦波表位屏蔽模拟：丢高位折叠失真，丢低位只降分辨率</title>
    <link href="https://createskyblue.github.io/posts/sine-wavetable-bitmask/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/sine-wavetable-bitmask/</id>
    <published>2026-09-05T00:00:00.000Z</published>
    <updated>2026-09-05T00:00:00.000Z</updated>
    <summary>正弦波表屏蔽高位折叠失真，屏蔽低位只降分辨率</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>10 位正弦波表位屏蔽模拟：丢高位折叠失真，丢低位只降分辨率</h1>
<p>10 位正弦波表把一周期正弦量化为 0..1023，每位权重不同：高位定量程，低位定分辨率。屏蔽某位等于该位 stuck-at-0。屏蔽低位只让量化步长变粗、波形变糙；屏蔽高位使上半个周期被镜像折回下半区，形成一串折叠拱形、表观频率翻倍，即 foldover 失真。下方 Demo 可勾选 10 个位、切换周期数、自适应 Y 轴，对比原始与屏蔽后波形。</p>
<iframe id="sine-mask-frame" data-auto-height="true" scrolling="no" src="/posts/sine-wavetable-bitmask/sine-wavetable-mask-demo.html" title="10 位正弦波表位屏蔽交互演示" style="width: 100%; height: 1100px; border: 1px solid #dbe2ec; border-radius: 8px; background: #f5f7fb; overflow: hidden;"></iframe>
<script>
(() => {
  const frame = document.getElementById('sine-mask-frame');
  const measure = () => {
    let h = 0;
    try {
      const doc = frame.contentDocument || frame.contentWindow.document;
      const wrap = doc.querySelector('.wrap');
      const wrapH = wrap ? wrap.getBoundingClientRect().height : 0;
      h = Math.max(wrapH, doc.documentElement.scrollHeight, doc.body.scrollHeight);
    } catch (e) { return; }
    if (h > 0) frame.style.height = `${Math.ceil(h) + 24}px`;
  };
  const schedule = () => requestAnimationFrame(() => requestAnimationFrame(measure));
  frame.addEventListener('load', () => {
    schedule();
    try {
      const doc = frame.contentDocument;
      if (doc && doc.documentElement && window.ResizeObserver) {
        new ResizeObserver(schedule).observe(doc.documentElement);
      }
    } catch (e) {}
  });
  window.addEventListener('resize', schedule);
})();
</script>

<p><a href="/posts/sine-wavetable-bitmask/sine-wavetable-mask-demo.html" target="_blank" rel="noopener noreferrer">打开交互 Demo</a></p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="信号处理"/>
    <category term="数据可视化"/>
    <category term="Canvas"/>
    <category term="波形渲染"/>
  </entry>
  <entry>
    <title>电脑闲置两个月后电量 0% 充不进电：读取 BQ40Z50 状态并尝试解锁</title>
    <link href="https://createskyblue.github.io/posts/nh50bat-bq40z50-unseal-debug/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/nh50bat-bq40z50-unseal-debug/</id>
    <published>2026-09-03T00:00:00.000Z</published>
    <updated>2026-09-03T00:00:00.000Z</updated>
    <summary>闲置后电量 0% 充不进电，先读 BQ40Z50 再决定换芯</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>电脑闲置两个月后电量 0% 充不进电：读取 BQ40Z50 状态并尝试解锁</h1>
<p>这块 NH50BAT-4 笔记本电池闲置约两个月后，电脑显示电量 0%，接上充电器也充不进去。拆包逐节测量，其中一颗电芯是 0 V。</p>
<p>电芯坏掉只是其中一层，保护板有没有跟着锁死才决定这个包还值不值得修。下面按实际排查顺序记录：从电池接口飞线接 STM32L496 读 BQ40Z50，尝试解锁，最后按解锁结果和电芯成本决定换芯还是买整包。</p>
<p><img src="/posts/nh50bat-bq40z50-unseal-debug/cover.webp" alt="NH50BAT-4 电池包与保护板全景">
<img src="/posts/nh50bat-bq40z50-unseal-debug/assets/diagnostic-flow.svg" alt="NH50BAT-4 维修诊断流程"></p>
<h2>闲置后的电池包故障现象</h2>
<p>开机后电量一直显示 0%，插充电器也没有反应。拆包逐节测量，其中一颗电芯 0 V；单独拆出来再量还是 0 V，用可调电源设 2.7 V、200 mA 试着激活，没有充电电流，这颗按失效处理。</p>
<p>电芯失效之外还有一层要单独验证：保护板有没有因为故障或保护状态拒绝工作。这就是后面要直接读 BQ40Z50 的原因。</p>
<h2>超声波焊接与四颗电芯更换边界</h2>
<p>这个电池包的外壳使用了超声波焊接，无法像螺丝固定的外壳那样无损打开。要看到内部电池和保护板，需要先对外壳进行破坏性拆解，再重新处理外壳的固定和密封。</p>
<p>四串电池包也不适合只换一颗。新电芯和剩下三颗旧电芯的容量、内阻、电压状态都不一样，混装会带来不均衡风险，正式更换必须四颗一起换，装配前查一致性。后面为了先验证保护板，我只临时换了一颗，这组混装电芯不能当成能正常充放电的成品，全程没有对它充电或大电流放电。</p>
<h2>保护板上的 BQ40Z50 电池管理芯片</h2>
<p><img src="/posts/nh50bat-bq40z50-unseal-debug/assets/bq40z50-chip-closeup.webp" alt="BQ40Z50 保护板芯片近照"></p>
<p>保护板上的主控是 BQ40Z50，负责采集电芯信息、保存电池状态，并参与充放电保护控制。电脑显示 0% 又充不进电，光看电芯电压判断不了保护板还能不能工作，所以得直接读这颗芯片。</p>
<h2>电池接口飞线接入 STM32L496</h2>
<p>不用焊 BQ40Z50 的引脚，直接从电池接口飞线到 STM32L496，就能通过 SMBus/I²C 访问保护板。电源、地和通信线按接口丝印和实物走线确认。
<img src="/posts/nh50bat-bq40z50-unseal-debug/assets/battery-interface-flying-wire.webp" alt="NH50BAT-4 电池接口局部图">
<img src="/posts/nh50bat-bq40z50-unseal-debug/assets/cover.webp" alt="NH50BAT-4 电池包、保护板和 STM32L496 调试板"></p>
<h2>I²C 地址扫描与电池数据读取</h2>
<p>先做地址扫描确认保护板在线，扫到 BQ40Z50 的标准 7 位地址 <code>0x0B</code>，接着读标准电池数据，串口输出如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Manufacturer: SMP-LGC-2750</span></span>
<span class="line"><span>Device: C41</span></span>
<span class="line"><span>Chemistry: LION</span></span>
<span class="line"><span>Pack Voltage: 13247 mV</span></span>
<span class="line"><span>Relative SOC: 0 %</span></span></code></pre><p>四节电芯的读数：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Cell1: 3025 mV</span></span>
<span class="line"><span>Cell2: 3435 mV</span></span>
<span class="line"><span>Cell3: 3802 mV</span></span>
<span class="line"><span>Cell4: 2986 mV</span></span>
<span class="line"><span>Cell Sum: 13248 mV</span></span>
<span class="line"><span>Pack Voltage Difference: -1 mV</span></span></code></pre><p>四节之和与总压只差 1 mV，采样和通信链路是可信的。816 mV 的最大压差来自刚换上的那颗新电芯和三颗旧电芯混装，不代表四颗新电芯配组后的结果。</p>
<h2>BQ40Z50 状态读取结果</h2>
<p>能读通不代表允许充放电，状态寄存器才是关键：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>SafetyStatus:    0x00000000</span></span>
<span class="line"><span>PFStatus:        0x00000801</span></span>
<span class="line"><span>OperationStatus: 0x00007381</span></span>
<span class="line"><span>SEC:             3</span></span>
<span class="line"><span>XCHG:            SET</span></span></code></pre><p><code>SEC=3</code> 是 Sealed 状态，<code>PFStatus</code> 置了故障位，<code>XCHG</code> 表示充电被禁。芯片在线和电池不能用，这两件事同时成立。</p>
<h2>常见 Unseal Key 的发送结果</h2>
<p>先试 BQ40Z50 常见的两组默认 Unseal Key：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>0x0414</span></span>
<span class="line"><span>0x3672</span></span></code></pre><p>按 ManufacturerBlockAccess <code>0x44</code> 编码后发出去：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>[44 02 14 04]</span></span>
<span class="line"><span>[44 02 72 36]</span></span></code></pre><p>发完回读 OperationStatus，没有变化：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Post-unseal OperationStatus: 0x00007381, SEC=3</span></span>
<span class="line"><span>Unseal result: still Sealed (SEC=3); key pair was not accepted</span></span></code></pre><p>两组 key 都没被接受，说明这块的量产配置换过密钥。没有正确的 key，标准解锁流程走不下去，这也证明不了芯片本身坏了。</p>
<h2>暴力搜 key 这条路走不通</h2>
<p>两组 16 位 key 合起来是约 <code>2^32</code>（42.9 亿）种组合。只按两次 4 字节写入加地址应答的理想传输时间算，100 kHz 传完一轮是 45 天量级，1 MHz 也要 4.5 天，还没算芯片处理时间、状态回读和失败等待；我当前程序在两组 key 之间留了 10 ms 间隔，按这个节奏是 1.4 年往上。何况芯片一般会限制连续失败次数，搜到一半就会停。</p>
<p>所以提 I²C 频率没有意义，要么拿到这块电池对应的 Unseal Key，要么换一块已经配置好的保护板。</p>
<h2>换芯还是买整包</h2>
<p>继续修要同时处理三件事：四颗电芯重新采购、保护板已经记了 PF、BQ40Z50 还锁在 Sealed。</p>
<p>四颗 3400 mAh 电芯单价 16.5 元，合计 66 元；同型号完整电池包 77 元。材料价差只有 11 元，芯片、焊接、时间和解锁工具的钱都还没算进去。解锁已经失败，再买保护板芯片也没有明确收益，总成本很容易接近或超过整包。</p>
<h2>结论</h2>
<p>这块电池包的问题定位到两层：一颗电芯 0 V 失效，2.7 V/200 mA 外部激活没有充电电流；保护板侧 BQ40Z50 能通信，但处于 Sealed 且记了 PF，常见 Unseal Key 无效。芯片是好的还是坏的没法下结论，只能说标准解锁流程走不通。</p>
<p>材料上四颗电芯 66 元对整包 77 元，加上解锁失败和 PF 未清除，继续修不划算，最后买了完整电池包。</p>
<h2>调试代码与实测工程</h2>
<p>本次 STM32L496 诊断代码位于公开仓库：<a href="https://github.com/createskyblue/STM32L496_BQ40Z50_DIAG">STM32L496_BQ40Z50_DIAG</a>。</p>
<p>相关文件包括：</p>
<ul>
<li><code>Core/Src/bq40z50_diag.c</code>：地址扫描、标准电池数据、单节电压和 BQ40Z50 状态读取。</li>
<li><code>Core/Src/bq40z50_protocol.c</code>：Unseal 数据帧编码和 SEC 字段解析。</li>
<li><code>Core/Src/i2c.c</code>：STM32L496 的 I²C1 初始化。</li>
<li><code>tests/test_bq40z50_protocol.c</code>：验证 key 字节顺序和数据帧格式。</li>
</ul>]]></content>
    <category term="嵌入式"/>
    <category term="工程调试"/>
    <category term="故障诊断"/>
    <category term="STM32"/>
    <category term="I2C"/>
    <category term="复盘"/>
  </entry>
  <entry>
    <title>复古点阵 LED 手环表盘：黄色表情与绿色日期</title>
    <link href="https://createskyblue.github.io/posts/dot-matrix-watchface-expression/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/dot-matrix-watchface-expression/</id>
    <published>2026-09-01T00:00:00.000Z</published>
    <updated>2026-09-01T00:00:00.000Z</updated>
    <summary>复古点阵 LED，黄色表情与绿色日期</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>复古点阵 LED 手环表盘：黄色表情与绿色日期</h1>
<p>我想为台风厂商的一款手环制作自己的表盘。按平台的流程，想制作表盘需要先申请入驻；入驻要求提供 5 个原创设计，每个设计配一张图片和一个视频。入驻后才能拿到开发者工具，未入驻就无法继续设计表盘、制作文件并安装到自己的手环上。由于只想做一个表盘，不打算为凑数量再补做 4 个设计，最终审核未通过。现在先把这两个 HTML demo 放在博客里自娱自乐。</p>
<p>这个 HTML 页面在绿色点阵手环表盘中加入黄色 14×14 表情区，同时显示当前日期、时间和星期。页面直接在浏览器中运行，表情序列按固定节奏遍历切换，时间字段跟随系统时钟。</p>
<div style="width:100%; display:flex; justify-content:center; margin:20px 0;">
  <iframe src="/posts/dot-matrix-watchface-expression/表盘_表情.html" title="表情点阵手环表盘交互演示" loading="lazy" style="width:100%; max-width:286px; height:560px; border:0; border-radius:24px; display:block; background:transparent;"></iframe>
</div>]]></content>
    <category term="前端"/>
    <category term="CSS"/>
    <category term="JavaScript"/>
    <category term="视觉设计"/>
  </entry>
  <entry>
    <title>复古点阵 LED 手环表盘：心形心率与三行时间</title>
    <link href="https://createskyblue.github.io/posts/dot-matrix-watchface-three-row/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/dot-matrix-watchface-three-row/</id>
    <published>2026-09-01T00:00:00.000Z</published>
    <updated>2026-09-01T00:00:00.000Z</updated>
    <summary>复古点阵 LED，心形心率与三行时间</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>复古点阵 LED 手环表盘：心形心率与三行时间</h1>
<p>我想为台风厂商的一款手环制作自己的表盘。按平台的流程，想制作表盘需要先申请入驻；入驻要求提供 5 个原创设计，每个设计配一张图片和一个视频。入驻后才能拿到开发者工具，未入驻就无法继续设计表盘、制作文件并安装到自己的手环上。由于只想做一个表盘，不打算为凑数量再补做 4 个设计，最终审核未通过。现在先把这两个 HTML demo 放在博客里自娱自乐。</p>
<p>这个 HTML 页面把三行点阵数码管、星期指示、心形图标和心率读数组合成一个手环表盘展示。页面直接在浏览器中运行，时间、日期和星期跟随系统时钟更新，心率用于前端演示展示随机值。</p>
<div style="width:100%; display:flex; justify-content:center; margin:20px 0;">
  <iframe src="/posts/dot-matrix-watchface-three-row/表盘_三行.html" title="三行点阵手环表盘交互演示" loading="lazy" style="width:100%; max-width:286px; height:560px; border:0; border-radius:24px; display:block; background:transparent;"></iframe>
</div>]]></content>
    <category term="前端"/>
    <category term="CSS"/>
    <category term="JavaScript"/>
    <category term="视觉设计"/>
  </entry>
  <entry>
    <title>RT-Thread 两代串口驱动的设计分析</title>
    <link href="https://createskyblue.github.io/posts/rtthread-uart-dma-buffer-philosophy/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/rtthread-uart-dma-buffer-philosophy/</id>
    <published>2026-09-01T00:00:00.000Z</published>
    <updated>2026-09-01T00:00:00.000Z</updated>
    <summary>RT-Thread串口驱动对比分析
V1：DMA循环rb溢出覆盖,零拷贝
V2：DMA循环乒乓buf,需要拷贝</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>RT-Thread 两代串口 DMA 驱动的缓冲边界取舍</h1>
<p>项目仓库：<a href="https://github.com/RT-Thread/rt-thread" target="_blank" rel="noopener noreferrer">github.com/RT-Thread/rt-thread</a>（实时操作系统及设备驱动项目）</p>
<p>串口 DMA 接收只有一个核心取舍：接收缓冲是否直接承担 FIFO 职责。RT-Thread 的两代驱动给出了两种答案。</p>
<p><img src="/posts/rtthread-uart-dma-buffer-philosophy/dma-buffer-philosophy2.svg" alt="RT-Thread 两代串口 DMA 缓冲结构对比"></p>
<h2>经典驱动的 DMA 缓冲即 FIFO</h2>
<p>经典驱动把 DMA 缓冲区直接当作串口环形队列使用。DMA 写入数据，应用从同一块缓冲区读取，中间没有额外拷贝。</p>
<p>缓冲区写满后，DMA 继续前进并覆盖旧数据。这套方案不追求保存全部历史数据，优先保证接收路径持续运行。</p>
<p>它的哲学很直接：用最少的内存和最短的数据路径换取性能，溢出时接受旧数据损失。</p>
<h2>V2 驱动的 DMA 缓冲与 FIFO 分离</h2>
<p>V2 驱动使用一块连续的循环 DMA 缓冲区。HT 表示前半区写完，TC 表示后半区写完，软件按两个半区交替处理，形成软件意义上的 ping-pong；DMA 控制器配置的仍是一块连续内存。</p>
<p>处理完成的数据再交给下方的应用 FIFO。DMA 接收和应用读取各自拥有缓冲边界，应用 FIFO 可以选择丢弃新数据或覆盖旧数据。</p>
<p>它的哲学是把接收动作和数据管理分开，用额外内存和一次搬运换取更清晰的边界与恢复方式。</p>
<h2>取舍</h2>
<p>两种方案都无法让有限缓冲区承受无限输入。差别是资源优先级：</p>
<ul>
<li>资源紧张、输入可控、允许覆盖旧数据：DMA 缓冲即 FIFO。</li>
<li>数据完整性优先、需要明确处理溢出：连续 DMA 缓冲与应用 FIFO 分离。</li>
</ul>
<p>RT-Thread 的实现说明，串口 DMA 的关键决策是明确溢出发生时由系统保留什么、放弃什么，再选择 DMA 直写 FIFO 或连续 DMA 缓冲加软件 FIFO。</p>]]></content>
    <category term="RT-Thread"/>
    <category term="DMA"/>
    <category term="FIFO"/>
    <category term="嵌入式"/>
    <category term="系统设计"/>
  </entry>
  <entry>
    <title>U8g2 图形库文字渲染速度优化：同一行扫描线缓存后再写入</title>
    <link href="https://createskyblue.github.io/posts/u8g2-horizontal-line-cache/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/u8g2-horizontal-line-cache/</id>
    <published>2026-09-01T00:00:00.000Z</published>
    <updated>2026-09-01T00:00:00.000Z</updated>
    <summary>U8g2文字渲染速度优化：同一行扫描线缓存再写入</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>U8g2 图形库文字渲染速度优化：同一行扫描线缓存后再写入</h1>
<p>一句话：<strong>同一行的扫描线先缓存，连续线段合并后再写入屏幕。</strong></p>
<p>U8g2 绘制一个中文字符时，会把字形拆成很多水平线段。旧的写法每得到一段就操作一次 TFT，新的写法先把同一行的连续线段放进 <code>lineBuf</code>，等这一行结束后通过 DMA 一次写入。</p>
<p>U8g2 在这里运行于彩色 TFT 屏幕，像素数据需要携带颜色信息，单次写屏代价更高。这个设备只有 8KB 内存，无法直接放入 LVGL 这类完整 GUI 框架，只能保留 U8g2 的字形解码和自己的显示适配层。</p>
<p>菜单文字数量较多，逐个取模后展开成未压缩点阵会快速消耗 Flash。Flash 空间同样有限，所以字形数据需要保持压缩形式，由 U8g2 在绘制时解码成水平扫描线。后面的优化只处理这些扫描线如何写入 TFT，保留压缩字库和现有文字显示能力。</p>
<h2>两种写法的区别</h2>
<p>旧路径反复执行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>水平线段 -> 设置 TFT 窗口 -> DMA 写入 -> 等待完成</span></span></code></pre><p>新路径变成：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>水平线段 -> lineBuf -> 设置一次窗口 -> DMA 批量写入</span></span></code></pre><p>两种算法写出的像素相同，区别是屏幕提交次数。Demo 把中文字符“中”放到两块 <code>32×16</code> 屏幕上，左侧逐段提交，右侧缓存后提交。</p>
<p><img src="/posts/u8g2-horizontal-line-cache/u8g2-horizontal-line-cache.svg" alt="同一行扫描线缓存后批量写入"></p>
<h2>缓存提交的触发点</h2>
<p><code>DisplayAdapter_drawFastHLine()</code> 记录上一段的行号和结束横坐标：</p>
<ul>
<li>同一行，起点等于上一段终点：继续写入缓存；</li>
<li>换行或出现间隔：提交上一批缓存；</li>
<li>文字绘制结束：用零长度调用主动提交最后一批。</li>
</ul>
<p>项目中的提交调用是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">DisplayAdapter_drawFastHLine</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">888</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">888</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0xFFFF</span><span style="color:#D4D4D4">);</span></span></code></pre><p>这里的 <code>len</code> 是 <code>0</code>，不会真的绘制像素。<code>888,888</code> 只负责制造一次坐标边界，让缓存中的数据进入 TFT。</p>
<h2>适用范围</h2>
<p>这项优化只改变水平线段的写屏方式，不改变 U8g2 的 UTF-8 解码和字形查找。中文、英文、数字只要经过 <code>DisplayAdapter_drawFastHLine()</code>，都可以使用同一套缓存逻辑。</p>
<p>当前效果已通过交互 Demo 展示。项目没有记录独立的帧率或耗时数据，因此这里只说明写屏提交次数减少，不给出虚构的加速比例。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="性能优化"/>
    <category term="DMA"/>
    <category term="缓存"/>
  </entry>
  <entry>
    <title>Go 写的 MQTT 物联网后端：4 核 16GB 上 4000 设备并发，峰值 7.1 万条/秒</title>
    <link href="https://createskyblue.github.io/posts/iot-go-backend/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/iot-go-backend/</id>
    <published>2026-08-31T00:00:00.000Z</published>
    <updated>2026-08-31T00:00:00.000Z</updated>
    <summary>Go + MQTT 物联网后端：4000 台设备并发上报，4 核 16GB 峰值 7.1 万条/秒，200 万条零丢失</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Go 写的 MQTT 物联网后端：4 核 16GB 上 4000 设备并发，峰值 7.1 万条/秒</h1>
<p>这套物联网遥测系统跑在一台淘汰的旧安卓手机上（8 核 ARM，5.7GB 内存，Termux 当 Linux 服务器）：设备通过 MQTT 上报温度、湿度、电量，Go 后端订阅解析后写入 SQLite，网页面板实时显示速率曲线、在线设备和电量状态。最新一轮压测用 4000 台模拟设备持续上报，平均每秒处理 1.95 万条，60 秒窗口峰值 2.03 万条；同一套程序原样搬到 4 核 x86 云服务器，峰值 7.1 万条/秒，200 万条零丢失。</p>
<ul>
<li><strong>全链路</strong>：设备接入、数据解析、落库、网页监控面板与压测工具，同一套 Go 源码</li>
<li><strong>吞吐</strong>：旧手机平均 1.95 万条/秒；4 核 x86 峰值 7.1 万条/秒；1 万台设备并发建连全部成功，平均连接耗时 191.5ms</li>
<li><strong>稳定性</strong>：收到退出信号后先停止接收，再排空队列落库，最后退出，实测积压 49.6 万条零丢失</li>
<li><strong>性能定位</strong>：用 pprof 做 CPU 采样分析，火焰图交给 AI 解读定位热点</li>
</ul>
<p>下面这张面板是最新一轮压测的实况：4000 台设备每台发 500 条，1 分 44 秒收到 197 万条，平均每秒 1.95 万条，数据库写入 87MB，速率曲线全程无衰减。压测端共发出 200 万条，面板收到 197 万条，差额来自投递模式：这轮用 QoS 0（发出即算完，不逐条等对方确认），设备量超过链路处理能力时，中间的消息中转站会丢掉一部分过载消息。要求一条都不能少的业务改用 QoS 1（每条等确认，速度慢一些）。文中提到的零丢失都做过条数核对。</p>
<p><img src="/posts/iot-go-backend/dashboard.webp" alt="压测实况：4000 台设备持续上报"></p>
<h2>架构设计</h2>
<p>收消息的环节只把数据拷进缓冲队列，不做解析；解析和统计由 4 个 worker 并行处理；落库由单个 writer 攒够 1000 条后打包成一个事务写入。两级缓冲队列各 10 万容量，吸收突发流量，也给退出时留出排空时间。面板所需的总消息数、每秒速率、在线设备全部从内存读取，不查数据库，看面板不影响数据接收。</p>
<p><img src="/posts/iot-go-backend/arch.svg" alt="架构图"></p>
<h2>性能定位</h2>
<p>稳态下用 Go 自带的 pprof 抓 CPU 采样，导出火焰图（横条宽度是该函数占到的采样比例，纵轴是调用栈深度），采样结果直接交给 AI 解读，几秒就能出结论。4,964 个采样点的分布是落库路径 33.4%、MQTT 消息接收 15.7%、运行时调度 13.1%、JSON 解析 12.4%，没有一处能靠换掉单个组件解决。同一时刻的系统监控：后端程序占用 2.4 个 CPU 核、内存 58MB，消息中转程序占用 0.5 个核。</p>
<p><img src="/posts/iot-go-backend/flame-std.svg" alt="稳态 CPU 采样火焰图"></p>
<p><img src="/posts/iot-go-backend/htop.webp" alt="压测中的系统资源占用"></p>
<h2>服务规模</h2>
<p>以这台手机实测的每秒 1.95 万条为基准（换成 QoS 1 稳态 1.57 万条算更保守，结论不变），一天约能处理 16.8 亿条。按常见上报频率折算：</p>
<table>
<thead>
<tr>
<th>上报频率</th>
<th>每台每天</th>
<th>满负荷理论设备数</th>
<th>10 万台时的平均负载</th>
</tr>
</thead>
<tbody><tr>
<td>15 分钟一次</td>
<td>96 次</td>
<td>1,755 万台</td>
<td>111 条/秒（占能力 0.6%）</td>
</tr>
<tr>
<td>30 分钟一次</td>
<td>48 次</td>
<td>3,510 万台</td>
<td>56 条/秒（0.3%）</td>
</tr>
<tr>
<td>1 小时一次</td>
<td>24 次</td>
<td>7,020 万台</td>
<td>28 条/秒（0.2%）</td>
</tr>
</tbody></table>
<p>10 万台设备的真实负载只吃掉不到 1% 的吞吐能力，30 万台也只占 0.9%（每秒 167 条）。吞吐不是瓶颈，真正的约束是连接数和存储。</p>
<p><strong>连接数</strong>。常态在线的长连接模式下，每个连接占 30 到 50KB 内存，加上消息中转程序是单线程，这台手机的合理规模是 1 到 3 万台同时在线；要撑 10 万长连接就得换成多核服务器或者拆多个网关。但电池供电的设备（水表、烟感这类，休眠、唤醒、上报、立刻断开）本来不适合长连接，改走短连接后这个约束直接消失，10 万台可行。代价有两个：一是重连速率，10 万台按 15 分钟上报约 111 次/秒重连，实测建连能力约 1000 次/秒，余量 9 倍；二是必须让设备错峰唤醒（随机延迟 0 到 60 秒），否则整点会打成重连风暴。</p>
<p><strong>存储</strong>。单条约 120 字节，30 万台设备每天 1.7GB、每月 52GB。压测中观察到单张表写到 200MB 以内速度不衰减，涨到 800MB 时掉到四分之一，200MB 约等于 175 万条，也就是 1,200 台设备一个月的量。按这条边界推算分表粒度：千台级别按月分表就够，10 万台约 4 小时一张表，30 万台按小时一张表（每月 720 张，每张约 72MB）。这部分是按实测边界外推的设计值，没有跑过 30 万台的真机验证。</p>
<h2>测试环境</h2>
<table>
<thead>
<tr>
<th></th>
<th>旧手机</th>
<th>4 核 x86 云服务器（GitHub Codespaces，4 核 16GB 机型；默认机型是 2 核 8GB）</th>
</tr>
</thead>
<tbody><tr>
<td>CPU</td>
<td>8 核 ARM64，root 锁定最高频率</td>
<td>4 核 x86-64</td>
</tr>
<tr>
<td>内存</td>
<td>5.7GB + 2.6GB 内存压缩空间</td>
<td>16GB</td>
</tr>
<tr>
<td>系统</td>
<td>Android 10 + Termux</td>
<td>Ubuntu 24.04</td>
</tr>
<tr>
<td>程序</td>
<td>同一套 Go 源码，各自编译</td>
<td>同左</td>
</tr>
<tr>
<td>协议与数据</td>
<td>MQTT，单条约 120 字节（温度、湿度、电量、时间戳）</td>
<td>同左</td>
</tr>
<tr>
<td>压测工具</td>
<td>自写 Go 程序，用协程模拟设备，手机本地回环压测</td>
<td>同左</td>
</tr>
</tbody></table>
<p>并发建连这一项按有据可查的数字写：压测日志完整留档的是 1 万台设备全部建连成功、平均连接耗时 191.5ms（QoS 1，每台 3 条）；更早一轮 3 万台的实验只在架构文档里有结论、没有留下日志，所以没有写进正文。</p>]]></content>
    <category term="Go"/>
    <category term="物联网"/>
    <category term="MQTT"/>
    <category term="数据库"/>
    <category term="性能优化"/>
  </entry>
  <entry>
    <title>内存泄露分析</title>
    <link href="https://createskyblue.github.io/posts/memory-leak-tool-analysis/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/memory-leak-tool-analysis/</id>
    <published>2026-08-31T00:00:00.000Z</published>
    <updated>2026-08-31T00:00:00.000Z</updated>
    <summary>善用工具分析内存泄漏</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>内存泄露分析</h1>
<p>嵌入式设备要长期运行，部署后连续跑几个月是常态。内存一天漏几十字节，几周后系统卡死或看门狗复位，故障现场随重启一起丢。现场排查只能靠猜，猜不中就要返场，代价不小。可观测工具把这类问题在开发期抓出来：记录每一次内存分配与释放，退出时核对配对，谁漏了、漏在哪，一次跑完就有答案。这次用四种工具实测同一个泄漏程序，看它们各自怎么用、输出什么。</p>
<h2>工具核对每次malloc与free</h2>
<p>可观测的思路：让工具记录每一次 malloc 和 free，程序退出时核对配对，把未释放的块连同调用栈一起报告。趋势靠全量记录呈现，不用人眼推演。</p>
<p>实测程序 leak1 故意埋了两个泄漏：100 次循环里，create_packet 每次分配后忘了释放，lost_pointer 先分配一块又覆盖指针，旧块直接丢失。后面四个工具的输出都来自它。</p>
<ul>
<li><a href="/posts/memory-leak-tool-analysis/leak1.c" target="_blank" rel="noopener noreferrer">下载 leak1.c</a>：valgrind、massif、ASan 三节用的程序</li>
<li><a href="/posts/memory-leak-tool-analysis/leak1_mt.c" target="_blank" rel="noopener noreferrer">下载 leak1_mt.c</a>：mtrace 版，带记录开关</li>
</ul>
<h2>valgrind：未释放块与调用栈</h2>
<p>最常用的内存检查工具，不需要改代码。直接运行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">valgrind</span><span style="color:#569CD6"> --leak-check=full</span><span style="color:#CE9178"> ./leak1</span></span></code></pre><p>输出节选（真实数字）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>==16075== 51,200 bytes in 100 blocks are definitely lost in loss record 1 of 2</span></span>
<span class="line"><span>==16075==    at 0x488545C: malloc (vg_replace_malloc.c:446)</span></span>
<span class="line"><span>==16075==    by 0x108913: lost_pointer (leak1.c:15)</span></span>
<span class="line"><span>==16075== 107,350 bytes in 100 blocks are definitely lost in loss record 2 of 2</span></span>
<span class="line"><span>==16075==    at 0x488545C: malloc (vg_replace_malloc.c:446)</span></span>
<span class="line"><span>==16075==    by 0x1088C3: create_packet (leak1.c:7)</span></span>
<span class="line"><span>==16075== LEAK SUMMARY:</span></span>
<span class="line"><span>==16075==    definitely lost: 158,550 bytes in 200 blocks</span></span></code></pre><p>每个泄漏点一行：字节数、块数、调用栈。definitely lost 是确定漏掉的，still reachable 是进程结束前没释放但指针还在的，这类不算 bug。by 行直接指出泄漏发生在哪个函数的哪一行。</p>
<h2>massif：堆内存增长曲线</h2>
<p>valgrind 自带的堆画像工具，记录运行期间堆内存随时间的变化：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">valgrind</span><span style="color:#569CD6"> --tool=massif</span><span style="color:#CE9178"> ./leak1</span></span>
<span class="line"><span style="color:#DCDCAA">ms_print</span><span style="color:#CE9178"> massif.out</span></span></code></pre><p>输出是一张文本版曲线图，纵轴堆内存、横轴运行时间（节选）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>    KB</span></span>
<span class="line"><span>161.4^                                                                    ###</span></span>
<span class="line"><span>     |                                                                  @@#  :</span></span>
<span class="line"><span>     |                                                                :@@@#  :</span></span>
<span class="line"><span>     |                                                             ::@:@:@@@#  :</span></span>
<span class="line"><span>     ...</span></span>
<span class="line"><span>   0 +----------------------------------------------------------------------->ki</span></span>
<span class="line"><span>     0                                                                   149.8</span></span>
<span class="line"><span>Number of snapshots: 70</span></span>
<span class="line"><span>Detailed snapshots: [28, 34, 40, ..., 68 (peak)]</span></span></code></pre><p>正常程序的曲线锯齿但长期平稳，泄漏程序锯齿且整体持续上行。</p>
<p><img src="/posts/memory-leak-tool-analysis/memory-leak-curve.svg" alt="正常与泄漏的堆内存曲线对比"></p>
<p>只看最终数字可能漏掉缓慢增长，曲线让趋势直接可见。怀疑泄漏是每次操作漏一点的缓慢累积，先跑它看形状。</p>
<h2>ASan：编译期插桩</h2>
<p>地址消毒器，编译时改写代码里的内存访问，运行时检查：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">gcc</span><span style="color:#569CD6"> -fsanitize=address</span><span style="color:#569CD6"> -g</span><span style="color:#CE9178"> leak1.c</span><span style="color:#569CD6"> -o</span><span style="color:#CE9178"> leak1_asan</span></span>
<span class="line"><span style="color:#DCDCAA">./leak1_asan</span></span></code></pre><p>输出泄漏报告（节选）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>==934==ERROR: LeakSanitizer: detected memory leaks</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Direct leak of 107350 byte(s) in 100 object(s) allocated from:</span></span>
<span class="line"><span>    #1 0x... in create_packet /home/codespace/tmp/leak.c:4</span></span>
<span class="line"><span>    #2 0x... in main /home/codespace/tmp/leak.c:7</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Direct leak of 51200 byte(s) in 100 object(s) allocated from:</span></span>
<span class="line"><span>    #1 0x... in lost_pointer /home/codespace/tmp/leak.c:5</span></span>
<span class="line"><span></span></span>
<span class="line"><span>SUMMARY: AddressSanitizer: 158550 byte(s) leaked in 200 allocation(s).</span></span></code></pre><p>格式与 valgrind 类似：泄漏字节数、块数、调用栈。额外能查数组越界、重复释放。局限是依赖运行时环境，容器等受限环境可能无法启动，实测在 proot 容器里因 shadow memory 映射冲突直接崩溃。</p>
<h2>mtrace：glibc内置记录</h2>
<p>glibc 自带的内存记录机制，不依赖额外工具链，适合 valgrind 装不上的嵌入式环境。先在代码里开启记录：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;mcheck.h></span></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">    mtrace</span><span style="color:#D4D4D4">();</span><span style="color:#6A9955">          /* 开始记录 */</span></span>
<span class="line"><span style="color:#6A9955">    /* 业务代码 */</span></span>
<span class="line"><span style="color:#DCDCAA">    muntrace</span><span style="color:#D4D4D4">();</span><span style="color:#6A9955">        /* 结束并写日志 */</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>编译时链接调试库，运行后生成日志：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">gcc</span><span style="color:#569CD6"> -g</span><span style="color:#CE9178"> leak1_mt.c</span><span style="color:#569CD6"> -o</span><span style="color:#CE9178"> leak1_mt</span><span style="color:#569CD6"> -lmalloc_debug</span></span>
<span class="line"><span style="color:#9CDCFE">MALLOC_TRACE</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">mtrace.log</span><span style="color:#DCDCAA"> ./leak1_mt</span></span></code></pre><p>日志按行记录每次分配与释放（节选）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>= Start</span></span>
<span class="line"><span>@ ./leak1_mt:[0x984] + 0x30000224a0 0x400</span></span>
<span class="line"><span>@ ./leak1_mt:[0x9d4] + 0x30000228b0 0x200</span></span>
<span class="line"><span>@ ./leak1_mt:[0xa04] - 0x3000022ac0</span></span>
<span class="line"><span>= End</span></span></code></pre><ul>
<li>是分配，记录地址与大小；- 是释放。离线把未释放的块按调用点聚合：</li>
</ul>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>  4096 bytes in   1 blocks  @ /lib/...:(_IO_file_doallocate+e8)[0x6ec88]</span></span>
<span class="line"><span>107350 bytes in 100 blocks  @ ./leak1_mt:[0x984]</span></span>
<span class="line"><span> 51200 bytes in 100 blocks  @ ./leak1_mt:[0x9d4]</span></span>
<span class="line"><span>TOTAL: 162646 bytes in 201 blocks</span></span></code></pre><p>第一行是 printf 的内部缓冲，属于正常分配，剔除后与 valgrind 的数字一致。glibc 2.35 之后需要显式调用 mtrace()，不再靠环境变量自动开启。</p>
<h2>AI执行分析，人负责设计</h2>
<p>这些工具的输出都交给 AI 解读：让 AI 执行分析命令、读报告、把泄漏点整理成结论。人不需要逐行翻代码，知道有哪些工具、各自输出什么就够。本地开发首选 valgrind 一步到位，怀疑缓慢增长先跑 massif 看曲线，装不了工具链的环境用 mtrace。执行与分析交给 AI，人把精力留给设计。</p>
<h2>内存生命周期的所有权设计</h2>
<p>工具定位泄漏，正解在架构：每个动态内存分配都要有明确的所有权。谁分配，谁负责释放；所有权转移要显式交接，由新持有者负责。malloc 与 free 成对是设计结果，靠约定保证，不靠运气。</p>
<p>实测程序的两个泄漏点，一个忘了释放，一个覆盖指针丢了所有权，都属于所有权没有落实。工具的价值是验证所有权是否被遵守：配对漏掉一次，跑完就现形。人负责把所有权架构设计好，AI 负责跑工具，工具负责验证设计。</p>]]></content>
    <category term="free"/>
    <category term="malloc"/>
    <category term="内存泄漏"/>
    <category term="valgrind"/>
  </entry>
  <entry>
    <title>perf + FlameGraph：利用火焰图分析Linux程序性能瓶颈</title>
    <link href="https://createskyblue.github.io/posts/perf-flamegraph-basics/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/perf-flamegraph-basics/</id>
    <published>2026-08-31T00:00:00.000Z</published>
    <updated>2026-08-31T00:00:00.000Z</updated>
    <summary>perf 输出上万行看不懂，四步命令链出火焰图，热点一眼定位</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>perf + FlameGraph：利用火焰图分析Linux程序性能瓶颈</h1>
<p>吞吐上不去、并发撑不住、单请求变慢，都要先定位卡在哪一步。火焰图把采样到的调用栈画成嵌套矩形：横条越宽，这个函数占 CPU 越久；越往上叠，调用栈越深。它回答的是 CPU 时间去哪了，最宽那块就是最占 CPU 的环节。这类图严格来说叫 On-CPU 火焰图，只统计真在 CPU 上跑的时间；等 IO、等锁、等网络的阻塞时间不在图上，对应有专门的 Off-CPU 火焰图来分析。</p>
<ul>
<li><strong>读法</strong>：宽度是 CPU 占比，自下而上是调用栈由浅入深</li>
<li><strong>生成</strong>：采样后一条命令折叠出图，不改被测程序</li>
<li><strong>前提</strong>：编译保留帧指针和函数边界，否则栈展不开</li>
</ul>
<h2>图怎么读</h2>
<p><img src="/posts/perf-flamegraph-basics/flamegraph-reading.svg" alt="火焰图读法示意"></p>
<ul>
<li><strong>宽度</strong>是该函数占 CPU 的比例，不是执行时间，也不是调用次数。</li>
<li><strong>纵向</strong>是调用栈深度，父函数在下，被它调用的直接叠在上面。一条竖线从上往下读，就是一条完整的调用链。</li>
<li><strong>横向</strong>没有时间含义，同层按函数名排序，左右不代表执行先后。</li>
</ul>
<p>递归在图上是一根逐层收窄的塔，塔的高度对应递归深度。</p>
<h2>怎么生成</h2>
<p>perf、Go 的 pprof、Python 的 py-spy、浏览器的 Performance 面板都能出这类图，读法通用。下面以 perf 为例。</p>
<p>被测程序是一段示例源码 <a href="/posts/perf-flamegraph-basics/flame_demo.c" target="_blank" rel="noopener noreferrer">flame_demo.c</a>，编译时要保住栈信息，四个选项决定图能不能看：</p>
<table>
<thead>
<tr>
<th>选项</th>
<th>缺了会怎样</th>
</tr>
</thead>
<tbody><tr>
<td><code>-g</code></td>
<td>只给出地址，图上全是十六进制</td>
</tr>
<tr>
<td><code>-fno-omit-frame-pointer</code></td>
<td>帧指针丢了，默认按帧指针展开，展不开</td>
</tr>
<tr>
<td><code>-fno-inline</code></td>
<td>-O2 把热点函数吞进调用者，图塌成一片平顶</td>
</tr>
<tr>
<td><code>-fno-optimize-sibling-calls</code></td>
<td>调用链断一截，见下一节</td>
</tr>
</tbody></table>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">gcc</span><span style="color:#569CD6"> -O2</span><span style="color:#569CD6"> -g</span><span style="color:#569CD6"> -fno-omit-frame-pointer</span><span style="color:#569CD6"> -fno-inline</span><span style="color:#569CD6"> -fno-optimize-sibling-calls</span><span style="color:#569CD6"> -o</span><span style="color:#CE9178"> flame_demo</span><span style="color:#CE9178"> flame_demo.c</span></span></code></pre><p>编译好就能采样出图，两条命令：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955"># 采样：99Hz，只采用户态；把 ./flame_demo 换成 -p &#x3C;PID> 可附加到已运行的进程</span></span>
<span class="line"><span style="color:#DCDCAA">perf</span><span style="color:#CE9178"> record</span><span style="color:#569CD6"> -e</span><span style="color:#CE9178"> cycles:u</span><span style="color:#569CD6"> -F</span><span style="color:#B5CEA8"> 99</span><span style="color:#569CD6"> -g</span><span style="color:#569CD6"> -o</span><span style="color:#CE9178"> perf.data</span><span style="color:#CE9178"> ./flame_demo</span></span>
<span class="line"><span style="color:#6A9955"># 折叠并出图，产出的 SVG 可点击下钻、可按函数名搜索</span></span>
<span class="line"><span style="color:#DCDCAA">perf</span><span style="color:#CE9178"> script</span><span style="color:#569CD6"> -i</span><span style="color:#CE9178"> perf.data</span><span style="color:#D4D4D4"> | </span><span style="color:#DCDCAA">stackcollapse-perf.pl</span><span style="color:#D4D4D4"> | </span><span style="color:#DCDCAA">flamegraph.pl</span><span style="color:#D4D4D4"> > </span><span style="color:#CE9178">flame.svg</span></span></code></pre><p>采样不改被测程序，代价是统计误差，热点低于 1% 时宽度不可信。频率取 99Hz 而不是 100Hz，是为了避开与系统时钟节拍的整数倍关系，否则周期性任务会把样本全堆在同一条指令上。</p>
<p>出图结果如下：</p>
<p><img src="/posts/perf-flamegraph-basics/flame_demo_flamegraph.svg" alt="生成的火焰图"></p>
<p>四个部分各占约四分之一的 CPU，宽度接近。注意 run_all 只占 75.05%、part_fib 直接挂在 main 下，这是编译器优化留下的痕迹，下一节解释。</p>
<h2>图上的伪影</h2>
<p>火焰图是栈展开结果的图像，不是调用图的等价物，三处失真最常见。</p>
<p><strong>尾调用</strong>。函数最后一句是调用另一个函数时，-O2 会编成 <code>jmp</code> 而不是 <code>call</code>，跳转前先销毁自己的栈帧，帧指针链里就少了这一层：被调函数直接挂到更上层的调用者下面，占比也对不上（上图的 run_all 只占 75.05% 就是这么来的）。加 <code>-fno-optimize-sibling-calls</code> 可还原。</p>
<p><strong>内联</strong>。-O2 默认内联，被内联的函数在栈上不存在，热点并进调用者的块里。这是图上出现“一个平顶宽块”的常见原因之一——不过平顶也不全是伪影，函数自身计算量大、不怎么调用子函数时也会呈现平顶，需要结合代码判断。</p>
<p><strong>栈展开截断</strong>。默认按帧指针展开，遇到不带帧指针的代码（系统库、汇编优化过的数学库）就停在那里，函数在图上表现为凭空多出的宽块。老内核的展开器还有深度限制，深递归的塔会比实际浅。换 DWARF 展开（perf record 加 <code>--call-graph dwarf</code>）或 LBR 可缓解。</p>]]></content>
    <category term="性能优化"/>
    <category term="Linux"/>
    <category term="数据可视化"/>
    <category term="C语言"/>
  </entry>
  <entry>
    <title>旧手机给 AI 当沙盒：容器里编译单片机固件、跑 Web 后端</title>
    <link href="https://createskyblue.github.io/posts/old-phone-cross-compile-sandbox/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/old-phone-cross-compile-sandbox/</id>
    <published>2026-08-30T00:00:00.000Z</published>
    <updated>2026-08-30T00:00:00.000Z</updated>
    <summary>旧安卓手机容器承载 AI 测试，单片机编译与 Web 后端不占主力机</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>旧手机给 AI 当沙盒：容器里编译单片机固件、跑 Web 后端</h1>
<p>AI 做交叉编译测试，要在主力电脑上装整套 ARM 工具链，装完处理 PATH 与库冲突，测试完再卸。跑 Web 后端 demo，又要起 Node 或解释器环境。一次测试，主力机多出一堆临时环境。旧安卓手机接住这个活：Termux 里起 Debian 容器，工具链和 Web 服务都装在容器内，测试与主力机完全隔离。</p>
<h2>沙盒结构</h2>
<ul>
<li><strong>Termux</strong>：Android 上的 Linux 环境，自带 clang 与包管理器，普通应用权限即可运行</li>
<li><strong>proot-distro</strong>：容器管理工具，无 root 权限，把发行版 rootfs 挂在用户目录下运行</li>
<li><strong>Debian 容器</strong>：proot 内装 Debian（glibc），承载裸机交叉工具链与 Web 后端</li>
<li><strong>工具链</strong>：xpack arm-none-eabi-gcc 15.2.1，解压即用，无需安装</li>
</ul>
<p>容器选型有一个硬约束：Termux 官方仓库没有 arm-none-eabi-gcc；xpack 的 aarch64 Linux 版是 glibc 动态链接，Alpine（musl）容器运行时报缺动态链接器，Debian（glibc）容器可以正常执行。容器必须与工具链的 libc 匹配。</p>
<h2>单片机固件交叉编译</h2>
<p>PY32 系列固件模板（Cortex-M0+，32 KB Flash / 4 KB RAM），70 个 C 文件（HAL 库全套）加启动汇编。<code>arm-none-eabi-gcc -mcpu=cortex-m0 -mthumb</code> 交叉编译，全量构建通过：</p>
<ul>
<li>RAM 占用 2432 B / 4 KB（59.38%）</li>
<li>FLASH 占用 6720 B / 32 KB（20.51%）</li>
<li>Project.elf / Project.hex / Project.bin 三个产物完整生成</li>
</ul>
<h2>本机与手机耗时对比</h2>
<table>
<thead>
<tr>
<th>环境</th>
<th>工具链</th>
<th>并行</th>
<th>全量耗时</th>
</tr>
</thead>
<tbody><tr>
<td>本机 Windows</td>
<td>GCC 14.3.1</td>
<td>Ninja 自动并行</td>
<td>6 s（热状态）</td>
</tr>
<tr>
<td>手机（proot 容器）</td>
<td>GCC 15.2.1</td>
<td>-j4</td>
<td>44 s</td>
</tr>
<tr>
<td>手机（proot 容器）</td>
<td>GCC 15.2.1</td>
<td>-j8</td>
<td>40 s</td>
</tr>
</tbody></table>
<p><img src="/posts/old-phone-cross-compile-sandbox/compare.svg" alt="全量编译耗时对比"></p>
<p>本机首次全量编译 21 秒，之后稳定在 5-6 秒，差距来自磁盘冷缓存与杀毒软件首次扫描。手机稳定在 40-44 秒，约为本机的 7-8 倍。</p>
<h2>Web 后端：mongoose + SQLite</h2>
<p>同一个沙盒里跑另一个 demo：C 语言 mongoose 库起 HTTP 服务器，SQLite 存数据，前端 HTML 页面对接增删查接口。浏览器直接访问手机 IP 即可操作，增删查和搜索全部在手机本地完成。这一套环境同样没有进入主力机，验证完即弃。</p>
<h2>八核并行收益与瓶颈</h2>
<p>手机是 8 核，<code>-j8</code> 只比 <code>-j4</code> 快 4 秒。瓶颈不在 CPU 并行度，在 proot 虚拟化层的 IO 开销：每次文件读写都要经过 proot 路径翻译，编译过程大量临时文件的读写放大了这部分开销。手机单核性能本身也弱于主力机，并行无法补偿。</p>
<h2>适用边界</h2>
<p>值得用手机沙盒的场景：AI 驱动的工具链安装实验、交叉编译环境反复装包卸包的测试、Web 后端 demo 验证、需要隔离环境跑脚本。日常高频编译仍用主力机，6 秒与 40 秒的差距不值得为隔离付出。</p>
<p>边界两条：proot 容器无 root，涉及设备映射或特权操作受限；交叉工具链必须是 Linux aarch64 版本，与容器 libc 匹配。</p>]]></content>
    <category term="嵌入式"/>
    <category term="构建系统"/>
    <category term="性能优化"/>
    <category term="交叉编译"/>
    <category term="数据库"/>
  </entry>
  <entry>
    <title>长时计时系统的双时钟误差融合与渐进式校时</title>
    <link href="https://createskyblue.github.io/posts/dual-clock-rtc-fusion/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/dual-clock-rtc-fusion/</id>
    <published>2026-08-28T00:00:00.000Z</published>
    <updated>2026-08-28T00:00:00.000Z</updated>
    <summary>一个糟糕的硬件时钟架构，逼我用软件补偿 1 秒误差
计时 0.1 秒，显示却跳了 1 秒
外部高精度RTC：秒级颗粒度
内部时钟振荡器：MCU时间不准
最后只能让软件把误差慢慢吃掉</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>外置 RTC 秒级跳变：双时钟融合，把 1 秒相位差摊进 60 秒</h1>
<h2>概述</h2>
<p>是一个中途接手的救火项目。设备包含一个计时器组件，可显示计时的小时、分钟、秒。MCU 使用内部晶振，通过 I²C 连接外部 TCXO RTC；MCU 内部定时器提供毫秒级计时，外部 RTC 负责长期时间基准，并通过中断信号通知 MCU 秒级时间边界。虽然 RTC 本身还提供高精度时钟输出，但硬件设计并未将其接入 MCU。</p>
<p>这种双时钟架构带来了一个问题：片内定时器响应快、分辨率高，但长期会漂移；外置 3PPM RTC 长期稳定，但通过 I²C 只能获取秒级时间边界。开始计时的时机又完全随机，例如整秒后的 0.9 秒开始计时，RTC 再过 0.1 秒就跳秒，导致计时刚开始就显示 1 秒，暂停后继续时也会出现同样的跳变。</p>
<p>硬件已经定型投产，无法改板，因此只能通过软件协调两套时钟，在不改变硬件的情况下兼顾长时精度与计时显示的连续性。</p>
<p><img src="/posts/dual-clock-rtc-fusion/rtc-second-jump.webp" alt="RTC 秒级跳变示意图"></p>
<h2>解决</h2>
<ol>
<li><strong>痛点</strong>：外部 RTC 负责长期精度，但秒级时间边界与随机开始计时的时刻无法对齐；内部定时器则无法独立承担长时计时。</li>
<li><strong>解法</strong>：开始计时记录当前相位，测量到下一次 RTC 秒中断边界的时间差（0～1 秒），不立即修正，而是将相位差在后续 60 秒内渐进消化，避免显示跳变。暂停后继续时重新建立相位基准。</li>
<li><strong>结果</strong>：保留片内定时器的毫秒级响应，同时以 3PPM 外部RTC 作为长期时间基准，消除开始、暂停、继续过程中的秒级跳变；在硬件冻结的情况下，仅通过软件解决两个时钟时间尺度不一致的问题。</li>
<li><strong>根治</strong>：直接使用外部 TCXO 时间源为 MCU 提供高精度时间基准，使计时与 RTC 共用同一时钟源，从硬件层面消除两套时钟之间的相对漂移。</li>
</ol>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="RTC"/>
    <category term="定时器"/>
    <category term="TCXO"/>
    <category term="频率融合"/>
    <category term="双时钟冗余切换"/>
  </entry>
  <entry>
    <title>遍历中修改列表元素：xTaskGetNext 在任务增删时陷入无限循环</title>
    <link href="https://createskyblue.github.io/posts/esp-idf-task-iterator-stuck/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp-idf-task-iterator-stuck/</id>
    <published>2026-08-28T00:00:00.000Z</published>
    <updated>2026-08-28T00:00:00.000Z</updated>
    <summary>ESP-IDF xTaskGetNext 在生产环境遍历任务列表的失效分析</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>遍历中修改列表元素：xTaskGetNext 在任务增删时陷入无限循环</h1>
<h2>概述</h2>
<p>设备内置调试页面，每 5 秒自动刷新任务列表与内存水位，用于前期研发调试，便于远程观察运行状态；页面数据由设备 Web 服务在运行时遍历任务列表生成。</p>
<p>该功能长期运行稳定。某次自动刷新后，任务列表出现数百行重复条目，内存水位降至 16 字节，设备失去响应并重启。以下记录现象、复现过程与根因判定。</p>
<h2>现象</h2>
<p>调试接口 <code>/debug.json</code> 由页面每 5 秒自动刷新，返回任务列表和内存水位。某次刷新后，任务列表出现同一批任务的几百行重复条目，堆的&quot;历史最低剩余&quot;掉到 16 字节，设备失去响应并重启。</p>
<h2>复现</h2>
<p>最小复现工程：一个任务每毫秒创建并删除子任务，另一个任务反复调用 <code>xTaskGetNext()</code> 遍历并统计每个任务句柄的返回次数。</p>
<p>第一轮统计把&quot;句柄重复&quot;当信号，被 FreeRTOS 的句柄复用干扰（子任务删除后新任务复用同一内存地址），数据虚高。改为只统计永不删除的持久任务后，结论稳定：遍历期间任务被增删时，迭代器会卡在同一段列表反复重走，5000 次调用都不返回结束标志；挂起搅动任务后异常消失。</p>
<p><img src="/posts/esp-idf-task-iterator-stuck/iterator-stuck.svg" alt="点名的人卡在被划掉的第2人，反复重走，永远到不了名单末尾"></p>
<h2>官方回复</h2>
<p>issue 提交后，Espressif 维护者 igrr 回复：</p>
<blockquote>
<p>These private functions are intended to be used in debug contexts: GDB Stub, Core Dump, where iteration over the list of tasks happens while the application isn&#39;t running.</p>
</blockquote>
<p>这些私有函数只用于调试场景：GDB Stub、Core Dump，且这些场景在应用未运行时遍历任务列表。翻译成大白话：这是调试专用工具，设计前提是遍历时应用不运行、任务列表不动。我们把它用在了运行中的系统里，遍历期间任务增删导致卡住。</p>
<p>官方回应的边界成立，但它不消解缺陷本身：代码显式写了“遇到失效节点”的分支，正确的处理应当跳过并推进迭代器状态，或无法推进时返回 -1 终止；实际实现却只跳过不推进，形成无限循环。错误处理路径本身有 bug，这一点与“调试专用”的定位无关，后者只解释了它为何长期未被触发。</p>
<p>issue：<a href="https://github.com/espressif/esp-idf/issues/19022">#19022</a>，复现工程：<a href="https://github.com/createskyblue/esp-idf-xtaskgetnext-repro">esp-idf-xtaskgetnext-repro</a>。</p>
<h2>结论</h2>
<p>经排查：设备调试页面按 5 秒周期刷新任务列表，在任务频繁创建、删除的运行环境下，<code>xTaskGetNext()</code> 遍历出现不结束现象。迭代器反复重走同一段列表，单次刷新产生数百行重复条目，堆的历史最低水位降至 16 字节，设备失去响应并重启。</p>
<p>经最小复现工程验证，该现象可稳定复现：遍历期间存在任务增删时，迭代器连续 5000 次调用不返回结束标志；任务增删停止后，异常消失。</p>
<p>经分析，根因在 <code>xTaskGetNext()</code> 的错误处理路径：代码为“遇到失效节点”写有处理分支，但该分支仅跳过当前节点、不推进迭代器状态，遍历因此无限循环且不返回结束标志。该缺陷属“遍历中修改容器”一类已知缺陷，触发与否取决于运行场景，缺陷本身不随场景变化。</p>]]></content>
    <category term="嵌入式"/>
    <category term="ESP-IDF"/>
    <category term="ESP32-S3"/>
    <category term="工程调试"/>
    <category term="RTOS"/>
  </entry>
  <entry>
    <title>ESP32-S3 网页截图 LVGL 界面，作为 AI 视觉闭环的实机入口</title>
    <link href="https://createskyblue.github.io/posts/lvgl-web-screenshot/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/lvgl-web-screenshot/</id>
    <published>2026-08-28T00:00:00.000Z</published>
    <updated>2026-08-28T00:00:00.000Z</updated>
    <summary>ESP32 把 LVGL 界面截图到网页</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>ESP32-S3 网页截图 LVGL 界面，作为 AI 视觉闭环的实机入口</h1>
<p>界面跑在立创实战派 ESP32-S3 上，由 LVGL 9.5 渲染。要进文档只能对着屏幕拍照；模拟器又覆盖不了实机行为，AI 要验证界面必须碰真机。我给设备加了一个网页截图接口：浏览器点一下，LVGL 当前画面变成 BMP 图片回传。这个接口同时服务文档整理和 AI 视觉闭环。</p>
<p><img src="/posts/lvgl-web-screenshot/screenshot.webp" alt="实机截图效果"></p>
<h2>实机界面是 AI 视觉闭环的入口</h2>
<p>LVGL 有 PC 模拟器，能跑布局和事件逻辑，但触摸采样、手势轮询、局部刷新时序都依赖真实硬件。模拟器跑不全的界面状态，只能在实机上出现，AI 也就只能在实机上确认。截图接口把实机画面变成机器可读的图片，AI 用视觉模型识别界面状态，再决定下一步构建或修复，形成闭环。文档截图只是这个接口的副产物。</p>
<h2>总体链路</h2>
<p><img src="/posts/lvgl-web-screenshot/pipeline.svg" alt="网页截图链路"></p>
<p>请求从浏览器出发：点「截图」→ GET /screenshot → HTTP 任务把参数写进截图请求结构 → 显示任务用 LVGL 快照渲染当前屏到 PSRAM 缓冲 → BMP 编码逐行流式回传 → 浏览器 blob 预览和下载。</p>
<p>链路的硬约束是 LVGL 线程模型：渲染只允许在显示任务内发生，HTTP 任务不能碰渲染 API。两端用「请求结构 + 二进制信号量」握手。</p>
<h2>显示任务内的 LVGL 快照渲染</h2>
<p>HTTP 服务器任务收到请求后，把宽高、目标缓冲、完成信号量写进全局请求槽。显示任务每帧轮询这个槽，消费后渲染，完成后发信号量，调用方阻塞等待。</p>
<p>渲染调用 <code>lv_snapshot_take_to_draw_buf</code>，把一个 <code>lv_draw_buf_t</code> 指向调用方提供的 PSRAM 缓冲，目标是当前激活屏。前置条件是 <code>CONFIG_LV_USE_SNAPSHOT=y</code>，这个开关默认关闭。缓冲按 320×320×3 预留，RGB888 每像素 3 字节。</p>
<h2>RGB888 捕获消除 RGB565 偏色</h2>
<p>第一版用 RGB565 捕获，网页里看界面背景偏绿。逐像素算过：设计色 (237,237,237) 存进 RGB565，G 通道 6 位向上取整到 59，展开回 8 位变成 253；R、B 各 5 位向下取整到 29，展开 239。结果 (239,253,239)，绿色高了一截。</p>
<p>屏幕本身就是 RGB565，面板上实际显示的也是这个偏色，截图忠实还原了面板。文档要的是设计原色，所以捕获格式改成 RGB888，渲染器直接输出 24 位，背景精确回到 (237,237,237)。</p>
<p>字节序顺带省事：LVGL 的 RGB888 内存序是 B,G,R（<code>LV_COLOR_MAKE</code> 宏展开为 <code>{b8,g8,r8}</code>），正好是 BMP 的字节序。逐行 memcpy 直接发送，省掉逐像素转换。</p>
<h2>BMP 流式回传</h2>
<p>编码结果流式发送，不整张缓存。先发 54 字节 BMP 头，再按行从底到顶 <code>httpd_resp_send_chunk</code>。320×240 的 24 位 BMP 约 230KB，设备侧只需 307KB 的 RGB888 快照缓冲，输出侧零大缓冲。</p>
<h2>网页端截图按钮</h2>
<p>页面顶部工具栏加「📷 截图」按钮，<code>fetch(&#39;/screenshot&#39;)</code> 取回 blob，<code>URL.createObjectURL</code> 生成预览，弹窗内可下载 BMP。更新网页文件走文件管理器的 <code>/api/fs</code> 上传接口，不重烧 littlefs 镜像，避免覆盖设备上已保存的配置。</p>
<p>两个落地时的坑。一是 httpd 的 <code>max_uri_handlers</code> 默认 8，平台各端点注册完是 17 个，撞满当时 16 的上限，静态回退注册直接触发 <code>ESP_ERROR_CHECK</code> 重启，上限改成 24 解决。二是第一版按钮点了没反应，检查发现页面里 <code>takeScreenshot</code> 函数没有写进去，此后每次改网页都用 curl 校验函数存在。</p>
<h2>适用边界</h2>
<p>接口提供一次性截图，实时投屏需要定时轮询和更高压缩率的编码，本实现没有做。BMP 无压缩，单帧约 230KB，适合调试和文档。AI 闭环目前按「截图 → 视觉识别 → 决定下一步」的节奏工作，瓶颈在识别刷新率，截图链路本身开销很小。</p>]]></content>
    <category term="嵌入式GUI"/>
    <category term="LVGL"/>
    <category term="ESP32-S3"/>
    <category term="PSRAM"/>
    <category term="工程调试"/>
  </entry>
  <entry>
    <title>点阵数码管与按键中央琥珀灯：一个 CSS 拟物粒子计数器面板</title>
    <link href="https://createskyblue.github.io/posts/skeuomorphic-instrument-panel/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/skeuomorphic-instrument-panel/</id>
    <published>2026-08-27T00:00:00.000Z</published>
    <updated>2026-08-27T00:00:00.000Z</updated>
    <summary>点阵数码管用小圆点模拟发光读数，按键中央琥珀灯用明暗两态标记选中</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>点阵数码管与按键中央琥珀灯：一个 CSS 拟物粒子计数器面板</h1>
<p>HTML/CSS 搭的尘埃粒子计数器面板。下面是两个组件的设计提示词，加上拨杆声的合成参数。demo 可以直接点。</p>
<div class="demo-wrap">
  <img src="/posts/skeuomorphic-instrument-panel/panel-main.webp" alt="拟物粒子计数器面板：点阵数码管读数与按键中央琥珀灯，用 HTML/CSS 模拟的尘埃粒子计数器控制面板。" id="demoFallback" style="position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap;" />
  <iframe id="particle-panel-frame" data-auto-height="true" src="/posts/skeuomorphic-instrument-panel/particle-counter-square.html" style="width:100%; height:560px; border:1px solid #e2e8f0; border-radius:8px; margin:16px 0; display:block;" scrolling="no" loading="lazy" title="拟物粒子计数器面板 demo" onerror="var f=document.getElementById('demoFallback'); f.style.position='static'; f.style.width='100%'; f.style.height='auto'; f.style.overflow='visible'; f.style.clip='auto'; f.style.display='block';"></iframe>
</div>
<script>
(() => {
  const frame = document.getElementById('particle-panel-frame');
  if (!frame) return;
  const measure = () => {
    let h = 0;
    try {
      const doc = frame.contentDocument || frame.contentWindow.document;
      // 只认 .wrap：body 带 min-height:100vh，scrollHeight 会被 iframe 自身高度带偏，导致高度只增不减
      const wrap = doc.querySelector('.wrap');
      h = wrap
        ? wrap.getBoundingClientRect().height
        : Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
    } catch (e) { return; }
    if (h > 0) frame.style.height = `${Math.ceil(h) + 24}px`;
  };
  const schedule = () => requestAnimationFrame(() => requestAnimationFrame(measure));
  frame.addEventListener('load', () => {
    schedule();
    try {
      const doc = frame.contentDocument;
      if (doc && doc.documentElement && window.ResizeObserver) {
        new ResizeObserver(schedule).observe(doc.documentElement);
      }
    } catch (e) {}
  });
  window.addEventListener('resize', schedule);
})();
</script>

<h2>实物参考</h2>
<p>复刻自一台真实的 RION 尘埃粒子计数器（下图），整体配色与走线照它来。</p>
<p><img src="/posts/skeuomorphic-instrument-panel/reference.webp" alt="RION 粒子计数器实物参考"></p>
<h2>点阵数码管组件</h2>
<ul>
<li>结构：每个数字是一块 5×7 的小圆点矩阵，点为正圆。</li>
<li>点亮态：绿色荧光圆点，中心偏白高光，带一圈绿色光晕（radial-gradient 渐变 + box-shadow 发光）。</li>
<li>熄灭态：深绿色小圆点，隐约留下点阵网格，像没有通电的位。</li>
<li>布局：10 位显示区，数字右对齐，左边未使用的位只显示暗点。</li>
<li>底板：深色玻璃感，内凹，边缘有微弱反光。</li>
</ul>
<h2>拟物风格按键</h2>
<ul>
<li>按钮：正方形凸起键帽、圆角，顶面高光、底边暗边，用 linear-gradient 模拟受光。</li>
<li>指示灯：圆形琥珀灯装在按钮面板中央（灯在按钮上）。点亮为琥珀橙 <code>#e89400</code> 带光晕；熄灭为深色含琥珀调 <code>#5f5340</code>。</li>
</ul>
<p>为什么熄灭态不落到纯黑：跟点亮的琥珀橙同属一套色相，整排灯看起来是同一批元件；暗态也能辨认这是琥珀灯珠，不会和滑槽阴影糊在一起。真实 LED 灯珠未点亮时，透光外壳往往带自身颜色，纯黑会失真。</p>
<ul>
<li>文字：档位标签印在按钮外面的滑槽下方，避免随按键移动。</li>
<li>滑槽：凹进去的槽（inset box-shadow），四周留白统一，按钮不重叠。</li>
<li>机箱：米白金属外壳、顶部饰边、品牌标识。</li>
<li>交互：按下按钮本体下压（translateY 2px + 阴影收缩）。</li>
</ul>
<h2>按压与拨杆声的合成参数</h2>
<p>用 Web Audio 实时合成，三个声源叠加：blip + noise + thump。</p>
<p><strong>blip（咔哒主音）</strong></p>
<ul>
<li>振荡器：square / triangle</li>
<li>对数频率斜坡：<code>f(t)=f0·(f1/f0)^(t/T)</code></li>
<li>指数衰减增益：<code>g(t)=v·e^(−t/τ)</code>，<code>τ=T/ln(v/0.0001)</code></li>
<li>参数：老式键 <code>f0=1450, f1=420, T=0.03, v=0.6</code>；拨杆 <code>f0=960, f1=500, T=0.04, v=0.42</code></li>
</ul>
<p><strong>noise（机械颗粒）</strong></p>
<ul>
<li>白噪声线性衰减：<code>n[i]=(2·rand−1)·(1−i/N)</code></li>
<li>高通滤波：<code>fc≈900–1600Hz</code></li>
<li>参数：<code>T≈0.016–0.032, v≈0.26–0.34</code></li>
</ul>
<p><strong>thump（低频共鸣）</strong></p>
<ul>
<li>正弦频率斜坡：<code>f(t)=F·(0.45)^(t/T)</code></li>
<li>参数：<code>F≈200Hz, T≈0.05, v≈0.3–0.34</code></li>
</ul>]]></content>
    <category term="前端"/>
    <category term="CSS"/>
    <category term="视觉设计"/>
    <category term="Web Audio API"/>
    <category term="音效"/>
  </entry>
  <entry>
    <title>单霍尔传感器低功耗断电前应先避开迟滞临界区</title>
    <link href="https://createskyblue.github.io/posts/hall-power-cycle-counting-limit/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/hall-power-cycle-counting-limit/</id>
    <published>2026-08-26T00:00:00.000Z</published>
    <updated>2026-08-26T00:00:00.000Z</updated>
    <summary>单霍尔出厂用惩罚函数标定停靠区，降低断电计数偏移</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>单霍尔传感器低功耗断电前应先避开迟滞临界区</h1>
<h2>1. 概述</h2>
<p>单向电机可以用数字霍尔开关计数。磁铁每转一圈经过霍尔一次，输出完成一次激活和释放，产生两个边沿；固件把这一对边沿记为一个计数脉冲。</p>
<p>现有方案需要在电机停稳后切断霍尔电源以降低功耗。本文默认断电期间电机不动。重新上电后，霍尔会丢掉断电前的内部状态，按上电瞬间的磁场重新判决。单霍尔一圈只有一个计数脉冲，计数值无法说明停靠点落在进入临界区还是离开临界区。前后输出一致，也不能证明已经离开临界区。必须关断霍尔电源时，应先让停靠点以较大概率离开这两个临界区。</p>
<h2>2. 迟滞区</h2>
<p><img src="/posts/hall-power-cycle-counting-limit/hall-hysteresis.webp" alt="数字霍尔传感器的动作阈值、释放阈值与迟滞区"></p>
<p><img src="/posts/hall-power-cycle-counting-limit/hall-single-critical-zone.svg" alt="单霍尔传感器的稳定区、临界区与磁铁停靠位置"></p>
<p><img src="/posts/hall-power-cycle-counting-limit/hall-magnetic-characteristics.webp" alt="某款数字霍尔开关的数据手册磁特性参数示例"></p>
<p>数字霍尔开关有两个阈值：<code>Bop</code> 进入激活，<code>Brp</code> 退出激活。沿磁铁路径，它们对应两个临界区：</p>
<ul>
<li>进入临界区：靠近 <code>Bop</code>，即将从释放变为激活。</li>
<li>离开临界区：靠近 <code>Brp</code>，即将从激活变为释放。</li>
</ul>
<p>中间是稳定激活区，外侧是稳定释放区。迟滞防止边界附近反复跳变，也留下状态记忆。断电会清掉这份记忆。上电后若停在任一临界区，器件重新判决；单霍尔读不出停在进入侧还是离开侧。前后输出一致，也不能证明已经离开临界区。</p>
<p>电机转一圈产生两个边沿，并合成为一个计数脉冲，计数位置精度是 <code>1</code> 圈。两个临界区分别位于一次电平周期的两处边界；区分它们需要知道磁铁处于一圈中的哪半段，相当于至少 <code>0.5</code> 圈的相位分辨率。仅凭整圈计数值无法确定停靠点靠近哪一处临界区。</p>
<p>现有磁路和转速约束已经确定使用数字霍尔开关。器件型号及选型参数不展开，下文只讨论霍尔断电后丢失迟滞状态造成的计数偏差。</p>
<h3>迟滞区临界情况分析</h3>
<h4>符号约定</h4>
<p><img src="/posts/hall-power-cycle-counting-limit/%E7%AC%A6%E5%8F%B7%E7%BA%A6%E5%AE%9A.webp" alt=""></p>
<p>本节只分析停靠点处于临界区时，霍尔断电再上电产生的状态重判。上下电期间的电源暂态和假脉冲已由固件单独排除，不进入以下边沿计数。</p>
<h4>屏蔽中断时直接计数</h4>
<p>霍尔低功耗断电期间屏蔽边沿中断，重新上电并等待输出稳定后再恢复计数。一次完整旋转正常产生 <code>2</code> 个边沿，并合成为 <code>1</code> 个计数脉冲。停靠点处于临界区时，实际结果可能是 <code>1</code> 个、<code>2</code> 个或 <code>3</code> 个边沿。</p>
<p>霍尔断电时电机位置不变，重新上电后再继续旋转。按临界区位置、断电前电平和上电稳定后电平枚举，共有 8 种情况：</p>
<table>
<thead>
<tr>
<th>临界区</th>
<th>断电前电平</th>
<th>上电后电平</th>
<th>分类</th>
</tr>
</thead>
<tbody><tr>
<td>进入临界区</td>
<td><code>0</code></td>
<td><code>0</code></td>
<td>安全</td>
</tr>
<tr>
<td>进入临界区</td>
<td><code>1</code></td>
<td><code>1</code></td>
<td>安全</td>
</tr>
<tr>
<td>进入临界区</td>
<td><code>0</code></td>
<td><code>1</code></td>
<td>危险</td>
</tr>
<tr>
<td>进入临界区</td>
<td><code>1</code></td>
<td><code>0</code></td>
<td>危险</td>
</tr>
<tr>
<td>离开临界区</td>
<td><code>0</code></td>
<td><code>0</code></td>
<td>安全</td>
</tr>
<tr>
<td>离开临界区</td>
<td><code>1</code></td>
<td><code>1</code></td>
<td>安全</td>
</tr>
<tr>
<td>离开临界区</td>
<td><code>0</code></td>
<td><code>1</code></td>
<td>危险</td>
</tr>
<tr>
<td>离开临界区</td>
<td><code>1</code></td>
<td><code>0</code></td>
<td>危险</td>
</tr>
</tbody></table>
<p>断电前后电平不变的 4 种情况不会改变边沿序列。电平发生变化的另外 4 种情况分成两类：</p>
<ul>
<li><strong>丢失一个边沿</strong>：真实边沿发生在中断被屏蔽期间，预期计数为 <code>2</code>，实际只计到 <code>1</code>。</li>
<li><strong>多计一个边沿</strong>：上电后的重新判决被当成运动边沿，预期计数为 <code>2</code>，实际计到 <code>3</code>。</li>
</ul>
<p>两类错误方向相反。在两个临界区和两种重判方向等概率的理想条件下，长期平均值可能互相抵消；单次停机或单台设备的计数不能依赖这种抵消。</p>
<p><img src="/posts/hall-power-cycle-counting-limit/PixPin_2026-09-05_19-51-46.webp" alt="丢失一个边沿"></p>
<p><img src="/posts/hall-power-cycle-counting-limit/PixPin_2026-09-05_19-52-05.webp" alt="多计一个边沿的情况"></p>
<h4>不屏蔽中断时的计数偏差</h4>
<p>不屏蔽边沿中断后，原本在断电重判期间被屏蔽的边沿也会进入计数。丢失边沿的场景因此恢复为预期 <code>2</code> 个边沿、实际 <code>2</code> 个边沿。</p>
<p>多计边沿的场景会从 <code>3</code> 个增加到 <code>4</code> 个。正常旋转产生一次激活和一次释放，共 <code>2</code> 个边沿；临界区重新判决又产生一个边沿，继续旋转到稳定区时还会产生一个恢复边沿。中断全程开启后，这两个额外边沿都会被计入，所以结果是预期 <code>2</code> 个、实际 <code>4</code> 个。</p>
<p><img src="/posts/hall-power-cycle-counting-limit/PixPin_2026-09-05_19-51-25.webp" alt="如果霍尔低功耗断电时不屏蔽边沿中断"></p>
<h4>严格边沿配对的欠计数</h4>
<p>严格边沿配对同时检查边沿方向、先后顺序和时间间隔。收到第一个预期边沿后，软件进入等待状态；只有相反方向的第二个边沿在有效时间窗口内到达，才组成一对并形成一个计数脉冲。等待超时后，未完成的配对被清除，避免拿下一圈的边沿补配当前边沿。</p>
<p>严格边沿配对可以过滤多计边沿的情况，使预期 <code>2</code> 个边沿、实际 <code>2</code> 个边沿；但丢失一个边沿时，剩余边沿无法组成完整配对，整组边沿都会被舍弃，预期 <code>2</code> 个边沿、实际 <code>0</code> 个边沿。</p>
<p>边沿奇偶校验只能标记奇数边沿，无法恢复已经丢失的边沿。若把奇偶校验作为计数有效性的门槛，奇数边沿会被整组舍弃，结果等同于严格配对；若临界区额外产生一对边沿，实际边沿数为 <code>4</code>，简单奇偶校验也无法识别。</p>
<p><img src="/posts/hall-power-cycle-counting-limit/PixPin_2026-09-05_19-51-10.webp" alt="如果严格边沿配对"></p>
<h4>结论</h4>
<p>三种策略的代价如下：</p>
<table>
<thead>
<tr>
<th>策略</th>
<th>丢失边沿场景的实际边沿数</th>
<th>多计边沿场景的实际边沿数</th>
</tr>
</thead>
<tbody><tr>
<td>屏蔽中断，直接计数</td>
<td><code>1</code></td>
<td><code>3</code></td>
</tr>
<tr>
<td>不屏蔽中断</td>
<td><code>2</code></td>
<td><code>4</code></td>
</tr>
<tr>
<td>严格边沿配对</td>
<td><code>0</code></td>
<td><code>2</code></td>
</tr>
</tbody></table>
<p>正常的一圈为 <code>2</code> 个边沿、<code>1</code> 个计数脉冲。在这组临界区枚举下，屏蔽中断后直接计数的单次误差为 <code>±1</code> 个边沿；不屏蔽中断可能多出完整的一对边沿，严格配对可能丢掉完整的一个计数脉冲。因此，必须在单霍尔断电的前提下继续计数时，屏蔽中断并直接计数的代价相对可控，但它仍然不能消除临界区带来的不确定性。</p>
<h2>3. 实验现象</h2>
<p>用示波器同时监测霍尔供电（绿色，外部手动通断）与霍尔输出（黄色）。把磁铁分别停在触发位、非触发位与临界区，观察两块样品反复上电、下电时的输出。</p>
<table>
<thead>
<tr>
<th>位置</th>
<th>样品 A</th>
<th>样品 B</th>
</tr>
</thead>
<tbody><tr>
<td><strong>触发位</strong></td>
<td><img src="/posts/hall-power-cycle-counting-limit/hall-a-trigger-power-cycle.bmp" alt="样品A 触发位 上电-下电"></td>
<td><img src="/posts/hall-power-cycle-counting-limit/hall-b-trigger-power-cycle.bmp" alt="样品B 触发位 上电-下电"></td>
</tr>
<tr>
<td><strong>非触发位</strong></td>
<td><img src="/posts/hall-power-cycle-counting-limit/hall-a-nontrigger-power-cycle.bmp" alt="样品A 非触发位 上电-下电"></td>
<td><img src="/posts/hall-power-cycle-counting-limit/hall-b-nontrigger-power-cycle.bmp" alt="样品B 非触发位 上电-下电"></td>
</tr>
<tr>
<td><strong>临界区</strong></td>
<td><img src="/posts/hall-power-cycle-counting-limit/hall-a-critical-power-cycle.bmp" alt="样品A 临界区 反复上电"></td>
<td><img src="/posts/hall-power-cycle-counting-limit/hall-b-critical-power-cycle.bmp" alt="样品B 临界区 反复上电"></td>
</tr>
</tbody></table>
<p>对应现象如下：</p>
<table>
<thead>
<tr>
<th>位置</th>
<th>样品 A</th>
<th>样品 B</th>
</tr>
</thead>
<tbody><tr>
<td><strong>触发位</strong></td>
<td>上电先出现假尖峰，随后稳定到激活；下电时电源不可靠处有一个小陡坡</td>
<td>上电先出现约 <code>150 ms</code> 的释放台阶，随后才落到正确的激活；下电处有上升小陡坡</td>
</tr>
<tr>
<td><strong>非触发位</strong></td>
<td>上电稳定到释放；下电瞬间悬崖跳到激活，接着电源不可靠处有向上小陡坡</td>
<td>上电先出现假尖峰，随后约 <code>150 ms</code> 误入激活，再跳回释放；下电缓慢下降，电源不可靠处出现大量尖刺假脉冲</td>
</tr>
<tr>
<td><strong>临界区</strong></td>
<td>反复上电，输出每次可能不同</td>
<td>反复上电，输出每次可能不同 （样品B的截图没截好，看不出来，参考样品A的图）</td>
</tr>
</tbody></table>
<p>实验给出两点。第一，上电后过早打开中断会把启动尖峰和暂态台阶计进去；正确顺序是先给霍尔供电，等待输出稳定，清除旧中断标志，最后再恢复计数。第二，稳定区的最终输出通常可复现，临界区则不然。样品 A 在临界区反复上电时输出可能翻转；样品 B 多数时候前后一致，但这份一致不能证明它已经离开临界区，更不能说明它停在进入侧还是离开侧。霍尔持续供电时，计数通常更稳定，因为器件状态没有被清掉。</p>
<h2>4. 解决办法</h2>
<h3>方案一：出厂标定停靠参数</h3>
<p>这是概率缓解，无法保证形式正确。单霍尔给不出 <code>0.5</code> 圈的位置，所以不去解“现在停在哪个临界区”。每台机器出厂时，用惩罚函数学习一组电机停靠参数，让电机自己停下来时，有较大概率落在安全区。</p>
<p>它成立有三个前提：</p>
<ul>
<li>温度漂移：<code>Bop</code> / <code>Brp</code> 会随温度、批次和老化平移。出厂标的是这台机器、这只霍尔当时的迟滞宽度。换料号，或跑数年以后，原来的安全区可能缩进临界区。</li>
<li>电源暂态：上电尖峰、掉电陡坡和供电波动会改变霍尔的重新判决。标定环境若和现场供电差太远，样本无效。</li>
<li>电机可控：惩罚函数学的是停机参数，电机必须能按这组参数重复停到相近位置。停点散差大于安全区宽度时，标定没有意义。</li>
</ul>
<p>惩罚函数的核心观测是一次断电再上电的霍尔输出：</p>
<ul>
<li>前后不一致：这次停靠点让霍尔重新判决翻了面，给明确惩罚。</li>
<li>前后一致：只说明这一次没有翻面，不能当成已经安全，最多给较弱奖励。</li>
</ul>
<p>对同一台设备反复停机、断电、上电，就能采到一批样本。学到的参数要留 margin，覆盖 <code>Bop</code> / <code>Brp</code> 的温度、批次和老化漂移，以及磁路偏差和电机停点散差；具体余量由实际标定数据确定，不能只靠多次断电样本把翻面概率压低。标定结果随机器保存，后续休眠时按这组参数停机，再关闭霍尔电源。</p>
<h3>方案二：双霍尔判别区间</h3>
<p>把两个霍尔错开安装。两路输出的组合可以直接给出磁铁所在区间；其中一路接近进入或离开阈值时，另一路仍能提供稳定判断。</p>
<p><img src="/posts/hall-power-cycle-counting-limit/hall-dual-position-zones.svg" alt="双霍尔传感器通过两路电平组合判断磁铁位置区间"></p>
<h2>5. 结论</h2>
<p>单霍尔计数在持续供电时是可靠的。一旦切断霍尔电源，软件就不知道停靠点落在进入临界区还是离开临界区。一个由两边沿组成的整圈计数脉冲分辨不出停在哪一侧；断电前后输出一致，也不能证明已经离开临界区。</p>
<p>必须关闭单个霍尔电源时，出厂标定只能降低停进临界区的概率，并要给 <code>Bop</code> / <code>Brp</code> 留 margin；它缓解计数偏移，并不保证形式正确。需要连续且准确的计数时，应保持霍尔供电，或改用至少两个霍尔传感器。</p>]]></content>
    <category term="嵌入式"/>
    <category term="低功耗"/>
    <category term="电机"/>
    <category term="霍尔"/>
  </entry>
  <entry>
    <title>一半是 WiFi 蓝牙仲裁打架，一半是 NimBLE 底层库 bug</title>
    <link href="https://createskyblue.github.io/posts/ble-provisioning-wifi-conflict/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ble-provisioning-wifi-conflict/</id>
    <published>2026-08-23T00:00:00.000Z</published>
    <updated>2026-08-23T00:00:00.000Z</updated>
    <summary>ESP32-S3 WiFi 重连时发不出蓝牙包</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>一半是 WiFi 蓝牙仲裁打架，一半是 NimBLE 底层库 bug</h1>
<h2>概述</h2>
<p>设备通过 BLE 配网（BluFi）：手机连上配网蓝牙、下发 WiFi 凭据，设备以 STA 方式连接路由器。配网期间 WiFi 与蓝牙必须同时工作，但 ESP32 的 WiFi 与蓝牙共用同一射频前端，单射频按时分复用（TDM）调度，射频资源由共存仲裁器按优先级分配；官方共存矩阵将部分组合的蓝牙性能仅定性为 C1（支持但性能不稳定）。</p>
<p>某次配网中，WiFi 密码错误、路由器暂时不可达，STA 进入反复重连。此时手机能连上配网 BLE、能订阅通知，但设备主动下发的数据全部消失，点“扫描 WiFi 列表”无任何回包。以下记录现象、排查过程与根因判定。</p>
<h2>现象：连得上，发不出</h2>
<p>环境是 ESP-IDF v6.1-beta1，NimBLE，BluFi 配网。WiFi 一次就能连上时，整条配网流程正常。一旦 STA 进入反复重连，故障变成状态相关的：</p>
<ul>
<li>手机能扫到设备，能连上配网 BLE，也能订阅通知。</li>
<li>手机写下发命令，设备收得到、也处理得了。</li>
<li>设备该回给手机的数据全部消失：WiFi 列表没有，状态报告也没有。</li>
</ul>
<p>链路层是通的，GATT 写是通的，唯独板子主动发给手机的蓝牙包发不出去。</p>
<h2>第一层：WiFi 重连把蓝牙射频挤掉</h2>
<p>ESP32 的 WiFi 与蓝牙共用同一射频前端，同一时刻只有一个模块能用空口，由共存仲裁按优先级分配（TDM 时分复用）。WiFi 扫描、重连这类活动期间，蓝牙被持续压制，空口丢包、序号错乱时有发生。</p>
<p>官方共存矩阵将 WiFi 处于 SoftAP 连接中/已连接、Sniffer RX 时的蓝牙性能标为 C1（支持但性能不稳定）；STA 与 BLE 的组合官方标 Y（支持且稳定），但官方 issue 与 FAQ 里仍有大量共存故障记录：<a href="https://github.com/espressif/esp-idf/issues/17871" target="_blank" rel="noopener noreferrer">ESP-IDF #17871</a>（ESP32-S3 仅在 BLE 激活时 STA 掉线，关闭 BLE 即恢复）、<a href="https://github.com/espressif/esp-idf/issues/11280" target="_blank" rel="noopener noreferrer">#11280</a>（ESP32-C3 共存下 BLE 连接建立后立即失败，已修复）、<a href="https://docs.espressif.com/projects/esp-faq/en/latest/software-framework/coexistence.html" target="_blank" rel="noopener noreferrer">FAQ ELx200</a>（Wi-Fi 与 BLE 共存时频繁报错，官方给出修复）。本案例的 STA 反复重连属于重连风暴（反复扫描与重连），实测对蓝牙的压制同样明显。</p>
<p>STA 反复重连时，外设刚连上，NimBLE 还要补发一条 <code>LE Read Remote Version Information</code> 读对端版本。这条 HCI 交换正好撞上仲裁窗口，控制器回 HCI 错误 <code>0x1A</code>（26，Unsupported Feature or Parameter Value）。</p>
<p>如果只是空口被挤占，收和发应该一起变差。现在接收还在，只有板子往外发的包没了，说明射频干扰只是第一层。真正把发送通道关掉的，是后面那条库逻辑。</p>
<h2>第二层：NimBLE 把读版本失败当成连接失败</h2>
<p><code>BLE_GAP_EVENT_CONNECT</code> 的 status 在 NimBLE 里有多处赋值。逐一排除后，只有 <code>ble_gap_rx_rd_rem_ver_info_complete()</code> 会把任意 HCI 状态原样带进连接事件。</p>
<p>读版本 HCI 事务完成后，完成事件里的 <code>ev-&gt;status</code> 被直接当成连接事件 status，丢给广播回调，也就是 esp_blufi 的 GAP 回调。仲裁干扰让这条命令失败后，esp_blufi 打出 <code>connection failed; status=26</code>。连接其实已经建立了，失败的只是事后读版本。</p>
<p><img src="/posts/ble-provisioning-wifi-conflict/flow.svg" alt="连接事件 status 的来源与污染路径"></p>
<h2>后果：is_connected 恒为 false</h2>
<p>esp_blufi 用一个布尔量 <code>is_connected</code> 判断能不能往手机发数据。它只在连接事件 <code>status == 0</code> 时置位。连接事件被污染后，这个标志永远是 false。</p>
<p><code>btc_blufi_send_encap()</code> 看到 false 直接返回。状态报告、WiFi 列表，所有板子到手机的数据都被静默丢掉。接收方向不查这个标志，所以手机写进来的请求照常处理。表现就是“连得上、发不出”。</p>
<h2>上游已经修了库 bug，但还没进当前 IDF</h2>
<p>相关 issue 是 <a href="https://github.com/espressif/esp-idf/issues/18342" target="_blank" rel="noopener noreferrer">espressif/esp-idf #18342 &quot;Blufi connect error in Nimble&quot;</a>，同属 BluFi + NimBLE 连接错误这一块。</p>
<p>这次定位到的具体路径，已经在上游 mynewt-nimble 修掉：连接事件 status 固定为 0，读版本、读特性改成连接事件投递之后的独立 HCI 事务，失败不再污染连接状态。ESP-IDF 对应迁移是 <a href="https://github.com/espressif/esp-idf/commit/e08609e451" target="_blank" rel="noopener noreferrer">e08609e451 &quot;Migrate to NimBLE 1.9.0&quot;</a>（2026-07-25）。v6.1-beta1、v6.0.2、v6.1-rc1 都还没带上。</p>
<h2>现在能用的两步处理</h2>
<p>两层原因对应两步处理：</p>
<ul>
<li>修库 bug：IDF 本地补丁，slave 分支里把 <code>ble_gap_event_connect_call(ev-&gt;conn_handle, ev-&gt;status)</code> 改成 <code>ble_gap_event_connect_call(ev-&gt;conn_handle, 0)</code>，和上游语义对齐。</li>
<li>躲开仲裁打架：手机明确请求扫描 WiFi 列表（<code>GET_WIFI_LIST</code>）时，<code>wifi_manager_suspend_sta()</code> 先挂起 STA 自动重连，<code>SCAN_DONE</code> 立刻恢复。普通退避重连不要动。</li>
</ul>
<p>升级到带 NimBLE 1.9.0 的 IDF 后，本地补丁可以拿掉。补丁改的是 IDF 安装目录，升级会丢，更新后要重新打。</p>
<h2>补丁前后对照</h2>
<p>用 PC 蓝牙模拟手机（bleak 直连，按 BluFi 帧格式发 <code>GET_WIFI_LIST</code>）：</p>
<ul>
<li>补丁前：PC 收到 0 条通知。</li>
<li>补丁后：收到 WiFi 列表通知，158 字节，里面是真实 SSID。</li>
</ul>
<p>根因和修复都在硬件上对过。</p>
<h2>结论</h2>
<p>本设备通过 BLE 配网（BluFi），配网期间 WiFi 与蓝牙同时工作，但两者共用同一射频前端，由共存仲裁时分复用。</p>
<p>经核查，故障属实：WiFi 正常时配网流程可用，WiFi 反复重连后，设备下发的数据全部丢失。</p>
<p>原因分两层。WiFi 与蓝牙共用射频前端，重连期间仲裁持续压制蓝牙，读版本 HCI 命令失败（0x1A），空口丢包、序号错乱时有发生；NimBLE 又将该失败状态写入连接事件，esp_blufi 的 <code>is_connected</code> 恒为 false，发送通道被静默关闭。</p>
<p>验证与处置：PC 蓝牙直连下，补丁前 0 条通知，补丁后收到 158 字节真实 SSID。软件层已由上游 NimBLE 1.9.0 修复；硬件层属共存特性，扫描 WiFi 列表时挂起 STA 重连即可规避。</p>]]></content>
    <category term="嵌入式"/>
    <category term="BLE"/>
    <category term="ESP32-S3"/>
    <category term="ESP-IDF"/>
    <category term="BluFi"/>
    <category term="工程调试"/>
  </entry>
  <entry>
    <title>FlashDB将MQTT待发送消息落盘，设备掉电后从未完成记录继续发送</title>
    <link href="https://createskyblue.github.io/posts/flashdb-mqtt-outbox/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/flashdb-mqtt-outbox/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>FlashDB实现MQTT断电续传</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>FlashDB将MQTT待发送消息落盘，设备掉电后从未完成记录继续发送</h1>
<p>MQTT消息放在RAM队列中，设备一旦掉电，未发出的消息会全部丢失。</p>
<p>将消息先写入FlashDB，收到Broker ACK后再标记删除，设备重启时扫描未删除的记录继续发送，即可解决这个问题。</p>
<p>核心判断标准：<strong>记录存在代表消息未完成，标记删除代表处理完成。</strong></p>
<p><img src="/posts/flashdb-mqtt-outbox/mqtt-outbox-flow.svg" alt="FlashDB MQTT 断电续传流程"></p>
<h2>RAM队列的问题</h2>
<p>MQTT发布包含两个阶段：设备将消息交给客户端，客户端等待Broker回ACK。两个阶段之间如果发生掉电，RAM中的消息无法恢复，Broker永远收不到。</p>
<p>因此需要一个掉电不丢失的本地队列。FlashDB TSDB模式支持追加写入、顺序遍历、重启后数据保留，适合作为这个队列的存储层。</p>
<h2>存储设计：数据与状态分离</h2>
<p>TSDB每条记录由消息内容和状态索引组成。状态存储在索引中，更新状态时无需重写整条消息。</p>
<p>业务层只需要两个状态：</p>
<ul>
<li><code>FDB_TSL_WRITE</code>：记录存在，消息未完成</li>
<li><code>FDB_TSL_DELETED</code>：已收到ACK，标记删除，等待FlashDB回收</li>
</ul>
<p>&quot;等待ACK&quot;是发送过程中的临时阶段，不需要落盘。重启时该消息会被重新发送，存储该状态没有意义。</p>
<h2>发送流程</h2>
<p>每轮处理从最老的一条<code>FDB_TSL_WRITE</code>记录开始：</p>
<ol>
<li>查询队头第一条待发送记录</li>
<li>读取记录内容，发送消息</li>
<li>等待ACK</li>
<li>收到ACK后，删除该条记录</li>
<li>重新查询队头，处理下一条</li>
</ol>
<p>发送函数不直接修改数据库状态，删除作为独立操作执行。删除时从数据库重新查找第一条WRITE记录，不依赖发送函数传递的句柄或位置。这样发送和删除职责清晰，故障排查路径明确。</p>
<h2>掉电场景与投递语义</h2>
<p>ACK与删除是两个独立动作，中间的掉电窗口无法消除。各场景下的行为如下：</p>
<table>
<thead>
<tr>
<th>掉电时机</th>
<th>重启后行为</th>
</tr>
</thead>
<tbody><tr>
<td>发送前</td>
<td>记录存在，重发</td>
</tr>
<tr>
<td>消息已发出、ACK未到达</td>
<td>记录存在，可能重复发送</td>
</tr>
<tr>
<td>等待ACK过程中</td>
<td>状态仍为WRITE，回到队头重发</td>
</tr>
<tr>
<td>ACK已到达、删除未完成</td>
<td>记录存在，会重复发送</td>
</tr>
<tr>
<td>删除完成后</td>
<td>记录不存在，不再发送</td>
</tr>
</tbody></table>
<p>该方案选择**At-Least-Once（至少一次）**投递语义，宁可重复、不丢消息。消息去重由MQTT QoS机制或业务层消息ID处理。</p>
<h2>边界与扩展</h2>
<ul>
<li><strong>适用消息</strong>：Flash写入有擦写寿命和延迟开销，该方案适合QoS 1/2的高价值消息（如控制指令、告警、状态变更、计费数据）。高频、可丢弃的采样数据（如每秒一次的传感器读数）直接走RAM队列即可，落盘收益抵不过Flash磨损和吞吐开销。是否落盘应由业务层按消息类型决定，不要一刀切。</li>
<li><strong>并发发送</strong>：该设计默认单发送任务。多任务并发时需要增加互斥锁和领取机制，避免多个任务取到同一条记录。</li>
<li><strong>容量限制</strong>：单条消息长度受TSDB初始化时的最大长度配置约束，总容量受Flash扇区大小限制，需要根据业务场景设计旧消息淘汰策略。</li>
<li><strong>状态扩展</strong>：TSDB自定义状态槽位数量固定，不建议占用状态位存储重试次数、消息类型等信息。这类数据可以放在消息内容的业务头部，自定义格式解析。</li>
</ul>]]></content>
    <category term="嵌入式"/>
    <category term="MQTT"/>
    <category term="FlashDB"/>
    <category term="Flash存储"/>
    <category term="系统设计"/>
  </entry>
  <entry>
    <title>iotspool 原理：只追加、砍脏尾、重试打散</title>
    <link href="https://createskyblue.github.io/posts/iotspool-internals/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/iotspool-internals/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>不原地改，掉电只丢最后半截</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>iotspool 原理：只追加、砍脏尾、重试打散</h1>
<blockquote>
<p><strong>声明：</strong> iotspool 是一个开源项目，作者为 <a href="https://github.com/Vanderhell">Vanderhell</a>。本文是阅读该项目源码和文档后整理的学习笔记，用于理解嵌入式持久化消息队列的工程实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/Vanderhell/iotspool" target="_blank" rel="noopener noreferrer">github.com/Vanderhell/iotspool</a></p>
<p>上一篇讲怎么用。这篇只讲三个选择：消息怎么落盘、掉电怎么认账、Broker 挂了怎么重试。</p>
<h2>只追加，不回头改</h2>
<p>设备要保证采样到的数据在断网、掉电、Broker 重启之后还能发出去。RAM 顶不住，Flash 又不能随便改——写一半断电，文件就脏了。</p>
<p>落盘因此只允许两种笔：</p>
<ul>
<li><strong>入队</strong>：在文件尾巴追加一条 ENQ，带编号、主题和正文。确认落盘，才算收下。</li>
<li><strong>确认</strong>：发送成功后不回去改那条 ENQ，再追加一条 ACK，只带同一个编号。</li>
</ul>
<p>日志永远往前长。掉电窗口只剩一种：最后一条可能写了一半。中间已经落盘的记录，不会被一次半截写入弄脏。</p>
<p><img src="/posts/iotspool-internals/record.svg" alt="入队和确认是两条独立记录"></p>
<p>编号单调往上加。ACK 靠编号对上当初那条 ENQ。校验盖住整条记录；半截或校验失败，恢复时一律当脏尾巴丢掉。</p>
<h2>恢复就是顺序扫一遍</h2>
<p>开机从文件头扫到尾：</p>
<ol>
<li>完整且校验通过的 ENQ，记成待发。</li>
<li>扫到对应 ACK，从待发里拿掉。</li>
<li>读到半截或校验不过，截到上一条完整记录，停。</li>
</ol>
<p>半截永远不重放。ACK 如果刚好没写完，最坏是这条再发一次——至少一次，不会丢已经确认过的。</p>
<p>内存不囤正文，只记编号、位置和长短。待发条数决定 RAM，消息大小不影响。发送那一刻再按位置读出来。</p>
<p>文件会因为历史 ENQ 和 ACK 越滚越大。满了就把还没发出去的 ENQ 整本抄到新文件，原子换掉旧的。已确认的全部扔掉。不做段回收，逻辑短，也好验证。</p>
<p><img src="/posts/iotspool-internals/log.svg" alt="追加写日志与整理前后"></p>
<h2>重试要打散</h2>
<p>一群设备同时掉线，如果都按同一个固定间隔重试，窗口一到会一起砸 Broker。</p>
<p>所以失败后把等待上限翻倍并封顶，真正等到的时间在 0 到上限之间随机抽。发出去之后，上限收回去。调用方只问“现在有没有到期该发的”，不用自己算钟。</p>
<p><img src="/posts/iotspool-internals/backoff.svg" alt="固定间隔会扎堆，随机打散峰值"></p>
<h2>边界</h2>
<ul>
<li>文件还是 Flash，库不管，只要能追加、按位置读、确认落盘、截尾巴、整份替换。</li>
<li>默认假定调用是串行的。两个任务同时摸队列，自己加锁；中断里不要直接入队。</li>
<li>它不管 MQTT 协议，不管加密，也不保证只投递一次。重复送达上层去重。</li>
</ul>
<p>三句话：写入永远只追加；恢复只砍尾巴；内存只记位置，文件大了整本重抄。</p>]]></content>
    <category term="嵌入式"/>
    <category term="物联网"/>
    <category term="Flash存储"/>
    <category term="系统设计"/>
  </entry>
  <entry>
    <title>断电、断网、Broker 掉线：用 iotspool 给 MQTT 设备加一层掉电安全消息队列</title>
    <link href="https://createskyblue.github.io/posts/iotspool-mqtt-spool/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/iotspool-mqtt-spool/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>设备凌晨三点重启、WiFi 掉线，不丢一条遥测</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>断电、断网、Broker 掉线：用 iotspool 给 MQTT 设备加一层掉电安全消息队列</h1>
<blockquote>
<p><strong>声明：</strong> iotspool 是一个开源项目，作者为 <a href="https://github.com/Vanderhell">Vanderhell</a>。本文是阅读该项目源码和文档后整理的学习笔记，用于理解嵌入式 MQTT 场景下持久化 store-and-forward 队列的工程实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/Vanderhell/iotspool" target="_blank" rel="noopener noreferrer">github.com/Vanderhell/iotspool</a>（MQTT publish 的持久化 store-and-forward 队列，掉电不丢消息）</p>
<p>做物联网设备的人迟早要踩同一个坑：数据到了发送这一刻，网络断了、Broker 重启了、或者设备自己掉电了。直接 <code>mqtt_publish()</code> 就赌它成功，赌输了就是数据默默消失；自己在 RAM 里攒 FIFO，一掉电全没；往 Flash 里随便写，写一半断电文件就坏。</p>
<p>iotspool 专门填这个坑：一个零依赖的 C99 库，把 MQTT PUBLISH 消息写进 append-only 日志，CRC 校验，掉电自动截断尾部半截记录，Broker 不可达时指数退避重发，成功后才把 ACK 落到磁盘。支持 Linux、ESP-IDF、STM32 等裸机环境，coreMQTT/Paho/mosquitto/自研 MQTT 客户端都能对接。</p>
<p><img src="/posts/iotspool-mqtt-spool/arch.svg" alt="iotspool 位置与数据流"></p>
<h2>它解决什么问题</h2>
<p>嵌入式 MQTT 上报的常见故障模式：</p>
<ul>
<li><strong>瞬时断网</strong>：路由器重启、基站切换，几秒到几十秒恢复。RAM FIFO 能顶一阵，但满了之后丢谁、留谁要自己定策略。</li>
<li><strong>Broker 不可达</strong>：云端升级、证书轮换，可能持续几分钟到几小时。RAM 顶不住，必须落盘。</li>
<li><strong>设备意外重启</strong>：看门狗复位、掉电、HardFault。RAM 里未发送的消息全部丢失。</li>
<li><strong>写入中途断电</strong>：消息写了一半电没了，下次启动不能读到半截脏数据。</li>
</ul>
<p>iotspool 给每一条待发消息分配一个单调递增的 <code>msg_id</code>，把 ENQ（入队）记录追加到存储文件，写完后 <code>fsync</code>（或等价操作）；发送成功后再追加一条 ACK 记录标记该 <code>msg_id</code> 已确认。下次启动时 <code>iotspool_recover()</code> 扫一遍日志，尾部 CRC 不完整的半截记录直接丢弃，其余未被 ACK 的 ENQ 重新进入待发队列。</p>
<h2>五分钟在 Linux 上跑通 Demo</h2>
<p>下面是最小可运行示例，逻辑对应仓库里的 <code>examples/rpi/main.c</code>，做了精简。</p>
<h3>1. 编译</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">git</span><span style="color:#CE9178"> clone</span><span style="color:#CE9178"> https://github.com/Vanderhell/iotspool.git</span></span>
<span class="line"><span style="color:#DCDCAA">cd</span><span style="color:#CE9178"> iotspool</span></span>
<span class="line"><span style="color:#DCDCAA">cmake</span><span style="color:#569CD6"> -S</span><span style="color:#CE9178"> .</span><span style="color:#569CD6"> -B</span><span style="color:#CE9178"> build</span><span style="color:#569CD6"> -DIOTSPOOL_BUILD_TESTS=ON</span></span>
<span class="line"><span style="color:#DCDCAA">cmake</span><span style="color:#569CD6"> --build</span><span style="color:#CE9178"> build</span><span style="color:#569CD6"> -j</span></span></code></pre><p>仓库自带 POSIX 存储后端 <code>src/store_posix.c</code>，Linux/macOS/ESP-IDF VFS 都能直接用。STM32 等裸机环境需要自己实现 <code>iotspool_store_t</code> 的 5 个回调（<code>append</code> / <code>read_at</code> / <code>sync</code> / <code>size_bytes</code> / <code>truncate_to</code> / <code>replace</code>），对接到 raw Flash 或 LittleFS。</p>
<h3>2. 最小发送循环</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "iotspool.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "../../src/store_posix.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdio.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;string.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;time.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint32_t</span><span style="color:#DCDCAA"> now_ms</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> timespec ts;</span></span>
<span class="line"><span style="color:#DCDCAA">    clock_gettime</span><span style="color:#D4D4D4">(CLOCK_MONOTONIC, &#x26;ts);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4">)(</span><span style="color:#9CDCFE">ts</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">tv_sec</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">1000</span><span style="color:#D4D4D4"> + </span><span style="color:#9CDCFE">ts</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">tv_nsec</span><span style="color:#D4D4D4"> / </span><span style="color:#B5CEA8">1000000</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 替换成你自己的 MQTT 客户端发布函数，返回 0 表示成功 */</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> int</span><span style="color:#DCDCAA"> fake_mqtt_publish</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">topic</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> topic_len</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                             const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">payload</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> payload_len</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                             uint8_t</span><span style="color:#9CDCFE"> qos</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"  [MQTT] </span><span style="color:#9CDCFE">%.*s</span><span style="color:#CE9178"> -> </span><span style="color:#9CDCFE">%.*s</span><span style="color:#CE9178"> (qos=</span><span style="color:#9CDCFE">%d</span><span style="color:#CE9178">)</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">           (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4">)topic_len, topic,</span></span>
<span class="line"><span style="color:#D4D4D4">           (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4">)payload_len, (</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *)payload, qos);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#6A9955">    /* 1. 打开存储文件 */</span></span>
<span class="line"><span style="color:#4EC9B0">    iotspool_store_t</span><span style="color:#D4D4D4"> store = {</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">store_posix_open</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"/tmp/spool.bin"</span><span style="color:#D4D4D4">, &#x26;store) != IOTSPOOL_OK) {</span></span>
<span class="line"><span style="color:#DCDCAA">        fprintf</span><span style="color:#D4D4D4">(stderr, </span><span style="color:#CE9178">"open store failed</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#B5CEA8"> 1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 2. 初始化 + 从磁盘恢复未发送队列 */</span></span>
<span class="line"><span style="color:#4EC9B0">    iotspool_cfg_t</span><span style="color:#D4D4D4"> cfg = </span><span style="color:#DCDCAA">iotspool_cfg_default</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#9CDCFE">    cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">max_store_bytes</span><span style="color:#D4D4D4">    = </span><span style="color:#B5CEA8">512</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">1024</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">  /* 最多占 512KB */</span></span>
<span class="line"><span style="color:#9CDCFE">    cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">drop_oldest_on_full</span><span style="color:#D4D4D4"> = </span><span style="color:#569CD6">true</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">      /* 满了丢最老的，保新数据 */</span></span>
<span class="line"><span style="color:#9CDCFE">    cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">min_retry_ms</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">1000</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">    cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">max_retry_ms</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">60000</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">    iotspool_t</span><span style="color:#D4D4D4"> *spool = </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">    iotspool_init</span><span style="color:#D4D4D4">(&#x26;spool, &#x26;cfg, &#x26;store);</span></span>
<span class="line"><span style="color:#DCDCAA">    iotspool_recover</span><span style="color:#D4D4D4">(spool);</span><span style="color:#6A9955">   /* 空文件上调用也是安全的 */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 3. 业务侧入队：传感器采样后直接 enqueue，不等网络 */</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *payload = </span><span style="color:#CE9178">"{</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">temp</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">:23.7,</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">humi</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">:58}"</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#4EC9B0">    iotspool_msg_t</span><span style="color:#D4D4D4"> m = {</span></span>
<span class="line"><span style="color:#D4D4D4">        .topic       = </span><span style="color:#CE9178">"factory/line1/env"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">        .topic_len   = (</span><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4">)</span><span style="color:#DCDCAA">strlen</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"factory/line1/env"</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">        .payload     = (</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> *)payload,</span></span>
<span class="line"><span style="color:#D4D4D4">        .payload_len = (</span><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4">)</span><span style="color:#DCDCAA">strlen</span><span style="color:#D4D4D4">(payload),</span></span>
<span class="line"><span style="color:#D4D4D4">        .qos         = </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">        .retain      = </span><span style="color:#569CD6">false</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">    };</span></span>
<span class="line"><span style="color:#4EC9B0">    iotspool_msg_id_t</span><span style="color:#D4D4D4"> id;</span></span>
<span class="line"><span style="color:#DCDCAA">    iotspool_enqueue</span><span style="color:#D4D4D4">(spool, &#x26;m, &#x26;id);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 4. 网络任务里的发送循环 */</span></span>
<span class="line"><span style="color:#4EC9B0">    iotspool_msg_t</span><span style="color:#D4D4D4"> out = {</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#4EC9B0">    iotspool_msg_id_t</span><span style="color:#D4D4D4"> out_id = IOTSPOOL_MSG_ID_INVALID;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">iotspool_peek_ready</span><span style="color:#D4D4D4">(spool, </span><span style="color:#DCDCAA">now_ms</span><span style="color:#D4D4D4">(), &#x26;out, &#x26;out_id) == IOTSPOOL_OK) {</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">fake_mqtt_publish</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">out</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">topic</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">out</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">topic_len</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">                              out</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">payload</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">out</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">payload_len</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">out</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">qos</span><span style="color:#D4D4D4">) == </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">            iotspool_ack</span><span style="color:#D4D4D4">(spool, out_id);</span><span style="color:#6A9955">          /* QoS0 在发送成功后调 */</span></span>
<span class="line"><span style="color:#D4D4D4">        } </span><span style="color:#C586C0">else</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#DCDCAA">            iotspool_on_publish_fail</span><span style="color:#D4D4D4">(spool, </span><span style="color:#DCDCAA">now_ms</span><span style="color:#D4D4D4">());</span><span style="color:#6A9955">  /* 触发退避 */</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">    iotspool_stats_t</span><span style="color:#D4D4D4"> st;</span></span>
<span class="line"><span style="color:#DCDCAA">    iotspool_stats</span><span style="color:#D4D4D4">(spool, &#x26;st);</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"pending=</span><span style="color:#9CDCFE">%u</span><span style="color:#CE9178"> acked=</span><span style="color:#9CDCFE">%u</span><span style="color:#CE9178"> store=</span><span style="color:#9CDCFE">%u</span><span style="color:#CE9178"> bytes</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">           st</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">pending_count</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">st</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">acked_total</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">st</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">store_bytes</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    iotspool_deinit</span><span style="color:#D4D4D4">(spool);</span></span>
<span class="line"><span style="color:#DCDCAA">    store_posix_close</span><span style="color:#D4D4D4">(&#x26;store);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>运行仓库自带的完整示例：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">./build/example_rpi</span></span></code></pre><p>输出会显示 5 条传感器读数入队、随后被逐个取出、ACK 后统计归零。</p>
<h3>3. 对接真实 MQTT 客户端的关键约定</h3>
<table>
<thead>
<tr>
<th>QoS</th>
<th><code>iotspool_ack()</code> 调用时机</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>传输层确认报文已发出去之后（例如 Paho <code>MQTTClient_publishMessage</code> 返回成功）</td>
</tr>
<tr>
<td>1</td>
<td>收到 Broker 的 <code>PUBACK</code> 之后</td>
</tr>
</tbody></table>
<p>QoS 2 当前不在库的支持范围内。库保证 QoS 1 跨重启的 at-least-once 语义，重复送达由上层去重。</p>
<h2>配置项速查</h2>
<p><code>iotspool_cfg_default()</code> 返回默认值，按需改：</p>
<ul>
<li><code>max_pending_msgs</code>：RAM 里索引的最大消息条数（默认 64）。超过这个值再 enqueue 会返回 <code>IOTSPOOL_EFULL</code>，除非开了 <code>drop_oldest_on_full</code>。</li>
<li><code>max_store_bytes</code>：存储文件最大字节数（默认 256KB）。</li>
<li><code>max_payload_bytes</code> / <code>max_topic_bytes</code>：单条消息 payload/topic 的上限，用于内部分配 scratch。</li>
<li><code>min_retry_ms</code> / <code>max_retry_ms</code>：退避区间，默认 1s 到 60s。</li>
<li><code>drop_oldest_on_full</code>：队列满时是否丢最老消息。设为 <code>false</code> 时 enqueue 直接返回 <code>IOTSPOOL_EFULL</code>，由调用方决定丢还是等。</li>
<li><code>enable_sha256</code>：是否给每条记录加 SHA-256 校验（默认关，CRC32 已够检测掉电半截）。</li>
<li><code>lock</code> / <code>unlock</code>：多任务环境下传入互斥锁回调，核心 API 执行前自动加锁。ISR 不可直接调库，除非锁回调能在 ISR 中返回 <code>IOTSPOOL_EBUSY</code>。</li>
</ul>
<h2>掉电恢复的启动顺序</h2>
<p>推荐的开机流程：</p>
<ol>
<li>挂载文件系统（或初始化 Flash 驱动）。</li>
<li>打开 store 后端（<code>store_posix_open</code> 或自定义 vtable）。</li>
<li><code>iotspool_init</code> / <code>iotspool_init_inplace</code>。</li>
<li><code>iotspool_recover</code>。</li>
<li>启动传感器采样任务和 MQTT 发送任务。</li>
</ol>
<p>这一步必须放在 MQTT 连接之前，否则连接一建立、publish 任务开始跑，但队列状态还没重建，可能漏发重启前积压的消息。</p>
<h2>嵌入式无堆环境的用法</h2>
<p>默认 <code>iotspool_init</code> 会 <code>malloc</code> 内部索引和 scratch 空间。STM32 等不想用堆的场合，用静态内存版本：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#4EC9B0">iotspool_t</span><span style="color:#D4D4D4"> spool;</span></span>
<span class="line"><span style="color:#4EC9B0">iotspool_entry_t</span><span style="color:#9CDCFE"> entries</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">];</span><span style="color:#6A9955">                     /* RAM 索引 */</span></span>
<span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#9CDCFE"> scratch</span><span style="color:#D4D4D4">[</span><span style="color:#DCDCAA">iotspool_required_scratch_bytes</span><span style="color:#D4D4D4">(&#x26;cfg)];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">iotspool_init_inplace</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">spool</span><span style="color:#D4D4D4">, entries, </span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">                      scratch, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(scratch),</span></span>
<span class="line"><span style="color:#D4D4D4">                      &#x26;</span><span style="color:#9CDCFE">cfg</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">store</span><span style="color:#D4D4D4">);</span></span></code></pre><p>大小可以用 <code>iotspool_required_index_bytes(&amp;cfg)</code> 和 <code>iotspool_required_scratch_bytes(&amp;cfg)</code> 在编译期或启动时精确计算，避免多分配。</p>
<h2>适用边界</h2>
<p>iotspool 只负责&quot;把 publish 消息可靠地送到 MQTT 客户端手里&quot;，不做以下事情：</p>
<ul>
<li><strong>不做 MQTT 协议本身</strong>。连接、保活、重连、TLS、订阅都由你现有的 MQTT 栈负责。</li>
<li><strong>不做加密</strong>。CRC32/SHA-256 只检测意外损坏，不防篡改；需要机密性请用文件系统级加密或在 payload 层加密。</li>
<li><strong>不做 QoS 2</strong>。只保证 QoS 1 at-least-once，重复消息去重在 Broker 或业务层。</li>
<li><strong>不跨设备同步</strong>。日志文件是单设备本地的。</li>
</ul>
<p>如果你的设备需要&quot;断网时数据不丢、上电自动续传、Broker 抖动不雪崩&quot;这几件事，这个库刚好是那层缺的组件。</p>]]></content>
    <category term="嵌入式"/>
    <category term="物联网"/>
    <category term="MQTT"/>
    <category term="数据上报"/>
    <category term="C语言"/>
  </entry>
  <entry>
    <title>FlasbDB足以应对大部分情况，LoxDB更强但资源占用更多</title>
    <link href="https://createskyblue.github.io/posts/loxdb-vs-flashdb/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/loxdb-vs-flashdb/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>嵌入式数据库
 FlashDB vs LoxDB</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>跑得动 FlashDB 的板子不一定跑得动 loxdb：两份嵌入式数据库的资源实测与选型</h1>
<blockquote>
<p><strong>声明：</strong> loxdb 是一个开源项目，作者为 <a href="https://github.com/Vanderhell/loxdb">Vanderhell</a>；FlashDB 是一个开源项目，作者为 <a href="https://github.com/armink/FlashDB">Armink</a>。本文是阅读两个项目源码并在本地完成资源实测后整理的学习笔记，用于理解嵌入式数据库的资源消耗与设计取舍。<strong>本文作者不是这两个项目的开发者，未参与任何代码贡献。</strong> 文中工程细节与测量数据来自开源代码分析与本地编译结果，不代表项目作者的设计决策。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/Vanderhell/loxdb" target="_blank" rel="noopener noreferrer">github.com/Vanderhell/loxdb</a>（可预测内存的三引擎嵌入式数据库）；<a href="https://github.com/armink/FlashDB" target="_blank" rel="noopener noreferrer">github.com/armink/FlashDB</a>（RT-Thread 生态的极简 KV/TS 数据库）</p>
<p>loxdb 与 FlashDB 都是面向 MCU 的嵌入式数据库。实测（Cortex-M4，-Os，链接裁剪）下，FlashDB 的 KV+TS 占 10.5KB Flash、RAM 约 1KB；loxdb 同功能占 16.7KB，全功能（KV+TS+关系型+WAL）24.3KB，RAM 最少 8.2KB 句柄加可配 arena。loxdb 用多出的 Flash 换 TTL 过期、事务、关系引擎、写入前预检和可观测的崩溃恢复；FlashDB 用极简换来资源效率与多年量产验证。RAM 预算紧张、只需配置存储时选 FlashDB，需要多引擎与可诊断性时选 loxdb。</p>
<h2>loxdb 用资源换来的能力</h2>
<p>两类库的资源差距来自存储架构。FlashDB 把状态写进 Flash 按需扫描，换取约 1KB 的总 RAM；loxdb 把状态放进预分配 RAM，Flash 只做持久化快照，代价是句柄与 arena 的固定占用。</p>
<p><img src="/posts/loxdb-vs-flashdb/arch-compare.svg" alt="loxdb 与 FlashDB 架构对比"></p>
<p>loxdb 的回报集中在写入前就能感知的五项能力：</p>
<ul>
<li><strong>写入前预检</strong>：<code>lox_preflight()</code> 在初始化前验证配置与存储几何，<code>lox_admit_kv_set()</code> 在写入前返回本次操作是否会失败，<code>lox_get_pressure()</code> 随时查询剩余容量。FlashDB 的扇区 GC 失败只能在事后通过返回值察觉，部分 API 甚至不返回错误。</li>
<li><strong>TTL 过期</strong>：<code>lox_kv_set()</code> 带 ttl 参数，配合 <code>lox_kv_purge_expired()</code> 清理。FlashDB 需要自己存时间戳、自己写扫描删除逻辑。</li>
<li><strong>事务</strong>：<code>lox_txn_begin()</code> 到 <code>lox_txn_commit()</code> 让多个 key 的更新原子生效，断电也不破坏。FlashDB 每个 KV 独立写入，配网参数写到一半掉电会留下半新配置。</li>
<li><strong>可观测的崩溃恢复</strong>：<code>lox_stats()</code> 暴露 <code>recovery_count</code> 与 <code>last_recovery_status</code>，设备经历过掉电恢复可以被记录用于售后诊断。FlashDB 的状态位翻转同样防掉电，但不会上报恢复事件。</li>
<li><strong>关系型引擎与迁移</strong>：schema 建表、索引列、<code>lox_rel_find_by()</code> 按列查找；schema 带版本号，<code>on_migrate</code> 回调处理固件升级时的结构变更。FlashDB 只有 KV 默认值升级，解决不了表结构变化。</li>
</ul>
<h2>资源占用实测</h2>
<p>以下数字来自 arm-none-eabi-gcc 14.3.1 对 Cortex-M4 的 <code>-Os</code> 编译，链接时开启 <code>--gc-sections</code> 裁剪。loxdb 使用官方默认配置（64 键、8 条 TS 流、4 张表）；FlashDB 数字包含其仓库自带的 FAL 抽象层（单独编译约 2.3KB），loxdb 不含存储移植层。数字用于说明资源量级，不代表所有芯片和工具链都会得到相同结果。</p>
<table>
<thead>
<tr>
<th>配置</th>
<th>loxdb</th>
<th>FlashDB</th>
<th>差距</th>
</tr>
</thead>
<tbody><tr>
<td>KV-only（含断电保护）</td>
<td>11,109 B（KV+WAL）</td>
<td>8,400 B（含 FAL）</td>
<td>loxdb 多 3 成</td>
</tr>
<tr>
<td>KV + TS</td>
<td>16,684 B（KV+TS+WAL）</td>
<td>10,534 B（含 FAL）</td>
<td>loxdb 多 6 成</td>
</tr>
<tr>
<td>全功能 KV+TS+关系型+WAL</td>
<td>24,243 B</td>
<td>无对应功能</td>
<td>无法直接对比</td>
</tr>
</tbody></table>
<p>静态 RAM 的构成差异更大：</p>
<table>
<thead>
<tr>
<th>项目</th>
<th>loxdb</th>
<th>FlashDB</th>
</tr>
</thead>
<tbody><tr>
<td>库自身静态量</td>
<td>128 B</td>
<td>16 B</td>
</tr>
<tr>
<td>用户必须提供的数据结构</td>
<td>lox_t 句柄 8,200 B、schema 896 B</td>
<td>fdb_kvdb 876 B、fdb_tsdb 100 B</td>
</tr>
<tr>
<td>运行期动态内存</td>
<td>arena（可配 8~64KB）</td>
<td>无</td>
</tr>
</tbody></table>
<h2>能力对比</h2>
<table>
<thead>
<tr>
<th>能力</th>
<th>loxdb</th>
<th>FlashDB</th>
</tr>
</thead>
<tbody><tr>
<td>KV / TS 存储</td>
<td>有</td>
<td>有</td>
</tr>
<tr>
<td>关系型表</td>
<td>有</td>
<td>无</td>
</tr>
<tr>
<td>事务原子提交</td>
<td>有</td>
<td>无</td>
</tr>
<tr>
<td>TTL 过期</td>
<td>有</td>
<td>无</td>
</tr>
<tr>
<td>写入前预检与容量查询</td>
<td>有</td>
<td>无</td>
</tr>
<tr>
<td>断电恢复可观测</td>
<td>有（恢复计数与状态）</td>
<td>无（状态位翻转，不上报）</td>
</tr>
<tr>
<td>错误码粒度</td>
<td>16 类</td>
<td>粗粒度，部分 API 无返回</td>
</tr>
<tr>
<td>schema 版本迁移</td>
<td>有（on_migrate）</td>
<td>仅 KV 默认值升级</td>
</tr>
<tr>
<td>存储后端可替换</td>
<td>有（RAM/FS/裸 Flash）</td>
<td>绑定 FAL</td>
</tr>
<tr>
<td>C++ 封装与 JSON 导入导出</td>
<td>有</td>
<td>无</td>
</tr>
<tr>
<td>线程安全钩子</td>
<td>有（可选）</td>
<td>无（需外部加锁）</td>
</tr>
<tr>
<td>代码占用与 RAM</td>
<td>高</td>
<td>极低</td>
</tr>
<tr>
<td>成熟度与量产验证</td>
<td>新项目（v1.5.x）</td>
<td>多年量产</td>
</tr>
</tbody></table>
<h2>选型依据</h2>
<ul>
<li><strong>RAM 预算 ≥ 32KB</strong>，需要多引擎、事务、TTL 或结构化查询，看重运行时可诊断，选 loxdb。</li>
<li><strong>8~16KB RAM</strong>，只需 KV 配置存储，量产验证优先，选 FlashDB。</li>
<li>折中路径：loxdb 支持编译期裁剪（可单独关闭 TS/REL/WAL），KV-only 档约 11KB，仍高于 FlashDB 的 8.4KB。</li>
<li>建议按自己的芯片与工具链重测一次。方法可复现：<code>arm-none-eabi-gcc -Os -ffunction-sections -fdata-sections</code> 编译，链接加 <code>--gc-sections</code>，用 <code>arm-none-eabi-size</code> 读 section 尺寸。</li>
</ul>
<p>loxdb 的工程化优势（预检、自检、统计、可观测恢复）建立在 8KB 以上的 RAM 契约上，这条契约在小内存 MCU 上不成立。选型依据是 RAM 预算落在哪一档；预算允许时，功能清单才参与比较。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="数据库"/>
    <category term="FlashDB"/>
    <category term="loxdb"/>
    <category term="选型"/>
  </entry>
  <entry>
    <title>断电、重启、断点续跑：嵌入式多步流程的检查点恢复设计</title>
    <link href="https://createskyblue.github.io/posts/loxseq-intro/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/loxseq-intro/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>断电、重启、断点续跑：恢复策略按步骤声明，安全场景除外</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>断电、重启、断点续跑：嵌入式多步流程的检查点恢复设计</h1>
<blockquote>
<p><strong>声明：</strong> loxseq 是一个开源项目，作者为 <a href="https://github.com/Vanderhell/loxseq">Vanderhell</a>。本文是阅读该项目源码和文档后整理的学习笔记，用于理解嵌入式掉电恢复状态机的工程实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/Vanderhell/loxseq" target="_blank" rel="noopener noreferrer">github.com/Vanderhell/loxseq</a>（断电恢复状态机的 C 语言参考实现）</p>
<h2>概述</h2>
<p>嵌入式设备经常跑多步流程：灌装、加热、排空，或者充电、标定。任何一步执行中掉电、看门狗复位或 OTA 重启，设备就忘了自己干到哪一步。重做的代价有三种形态：重复执行会破坏（投料、加药、校准），会危险（加热中重启再加热可能过温），会浪费（小时级流程从头再来）。</p>
<p>解决办法是一套设计思想：把进度以检查点形式持久化，重启后读回检查点，按每步预设的恢复策略决定续跑、重跑、放弃还是问人。检查点不只是一个步骤号：写入可能只完成一半（撕裂写），固件升级后步骤表会变，旧记录可能比新的更新，这些都要靠校验、版本、指纹等附加信息兜住。本文以开源项目 loxseq 作为这套思想的参考实现展开，重点在思想本身。这个库的适用面很窄，后面会说明。</p>
<p><img src="/posts/loxseq-intro/power-loss-recovery.svg" alt="断电恢复流程"></p>
<h2>恢复策略按步骤声明</h2>
<p>&quot;断电后怎么办&quot;没有统一答案，同一流程里每步都不一样。把策略声明在步骤表里，执行交给状态机：</p>
<table>
<thead>
<tr>
<th>恢复策略</th>
<th>重启后行为</th>
<th>典型场景</th>
</tr>
</thead>
<tbody><tr>
<td><code>LOXSEQ_RESUME_AT_STEP</code></td>
<td>从断点继续本步</td>
<td>断电不影响步骤前提</td>
</tr>
<tr>
<td><code>LOXSEQ_RESUME_FROM_START</code></td>
<td>回到本步，重头执行</td>
<td>需要重新归零或复位</td>
</tr>
<tr>
<td><code>LOXSEQ_NEVER_RESUME</code></td>
<td>放弃流程，进入安全初始化</td>
<td>步骤状态不可信</td>
</tr>
<tr>
<td><code>LOXSEQ_OPERATOR_DECIDES</code></td>
<td>挂起，等待操作员裁决</td>
<td>需要人工判断</td>
</tr>
</tbody></table>
<p>举例：灌装可以续（液位到设定值即可），加热可以续（温度到设定值即可），加药绝不恢复（药不能加两次），排空要问人（罐里有什么只有操作员知道）。策略按步声明，机器只执行裁决，人工闸门留在该留的地方。</p>
<h2>最小集成示例</h2>
<p>以 loxseq 为例看这套思想怎么落地：三步接入。</p>
<h3>步骤表</h3>
<p>每步声明标签、动作、超时和恢复策略。动作返回 <code>LOXSEQ_STEP_RUNNING</code> 表示进行中，返回 <code>LOXSEQ_STEP_DONE</code> 表示完成。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">enum</span><span style="color:#D4D4D4"> { S_FILL, S_HEAT, S_DRAIN, STEP_COUNT };</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#4EC9B0"> loxseq_step_status_t</span><span style="color:#DCDCAA"> step_fill</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">loxseq_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">s</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> now_ms</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">u</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#D4D4D4">    (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)u;</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">loxseq_step_age_ms</span><span style="color:#D4D4D4">(s, now_ms) >= </span><span style="color:#B5CEA8">2000</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> LOXSEQ_STEP_DONE;</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> LOXSEQ_STEP_RUNNING;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#6A9955">/* step_heat、step_drain 结构相同，此处省略 */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> const</span><span style="color:#4EC9B0"> loxseq_step_def_t</span><span style="color:#9CDCFE"> steps</span><span style="color:#D4D4D4">[STEP_COUNT] = {</span></span>
<span class="line"><span style="color:#D4D4D4">    [S_FILL]  = { .tag = </span><span style="color:#CE9178">"fill"</span><span style="color:#D4D4D4">,  .action = step_fill,</span></span>
<span class="line"><span style="color:#D4D4D4">                  .timeout_ms = </span><span style="color:#B5CEA8">30000</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">                  .resume_policy = LOXSEQ_RESUME_AT_STEP },</span></span>
<span class="line"><span style="color:#D4D4D4">    [S_HEAT]  = { .tag = </span><span style="color:#CE9178">"heat"</span><span style="color:#D4D4D4">,  .action = step_heat,</span></span>
<span class="line"><span style="color:#D4D4D4">                  .timeout_ms = </span><span style="color:#B5CEA8">30000</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">                  .resume_policy = LOXSEQ_RESUME_AT_STEP },</span></span>
<span class="line"><span style="color:#D4D4D4">    [S_DRAIN] = { .tag = </span><span style="color:#CE9178">"drain"</span><span style="color:#D4D4D4">, .action = step_drain,</span></span>
<span class="line"><span style="color:#D4D4D4">                  .timeout_ms = </span><span style="color:#B5CEA8">30000</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">                  .resume_policy = LOXSEQ_NEVER_RESUME },</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><h3>存储钩子</h3>
<p>实现写、读、擦三个回调。后端持久化序列化后的 32 字节检查点，不保存 C 结构体的内存映像。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> const</span><span style="color:#4EC9B0"> loxseq_storage_t</span><span style="color:#D4D4D4"> storage = {</span></span>
<span class="line"><span style="color:#D4D4D4">    .write_checkpoint = my_flash_write,</span><span style="color:#6A9955">  /* 持久化 32 字节检查点 */</span></span>
<span class="line"><span style="color:#D4D4D4">    .read_checkpoint  = my_flash_read,</span></span>
<span class="line"><span style="color:#D4D4D4">    .erase_checkpoint = my_flash_erase,</span><span style="color:#6A9955">  /* 擦除后表现为冷启动 */</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><h3>开机流程</h3>
<p>开机先 <code>loxseq_recover()</code> 得出裁决，再按裁决启动；主循环周期性调用 <code>loxseq_tick()</code> 驱动步骤执行。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#4EC9B0"> loxseq_err_t</span><span style="color:#DCDCAA"> my_safe_init</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">loxseq_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">s</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> t</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">u</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#D4D4D4">    (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)s; (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)t; (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)u; </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> LOXSEQ_OK;</span><span style="color:#6A9955"> /* 安全初始化，示例为空实现 */</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> const</span><span style="color:#4EC9B0"> loxseq_safe_init_t</span><span style="color:#D4D4D4"> safe_init_cfg = { .run = my_safe_init, .user = </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4"> };</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">loxseq_t</span><span style="color:#D4D4D4"> seq;</span></span>
<span class="line"><span style="color:#DCDCAA">loxseq_init</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">seq</span><span style="color:#D4D4D4">, steps, STEP_COUNT, &#x26;</span><span style="color:#9CDCFE">storage</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">safe_init_cfg</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">loxseq_recovery_verdict_t</span><span style="color:#D4D4D4"> v = </span><span style="color:#DCDCAA">loxseq_recover</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">seq</span><span style="color:#D4D4D4">, LOXSEQ_REBOOT_NORMAL);</span></span>
<span class="line"><span style="color:#C586C0">switch</span><span style="color:#D4D4D4"> (v) {</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> LOXSEQ_RECOVERY_COLD_START: </span><span style="color:#DCDCAA">loxseq_start_fresh</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">seq</span><span style="color:#D4D4D4">, now_ms);     </span><span style="color:#C586C0">break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> LOXSEQ_RECOVERY_RESUME:     </span><span style="color:#DCDCAA">loxseq_start_resume</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">seq</span><span style="color:#D4D4D4">, now_ms);    </span><span style="color:#C586C0">break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> LOXSEQ_RECOVERY_RESTART:    </span><span style="color:#DCDCAA">loxseq_start_restart</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">seq</span><span style="color:#D4D4D4">, now_ms);   </span><span style="color:#C586C0">break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> LOXSEQ_RECOVERY_SAFE_INIT:  </span><span style="color:#DCDCAA">loxseq_start_safe_init</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">seq</span><span style="color:#D4D4D4">, now_ms); </span><span style="color:#C586C0">break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> LOXSEQ_RECOVERY_OPERATOR:   </span><span style="color:#DCDCAA">loxseq_start_operator_wait</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">seq</span><span style="color:#D4D4D4">);     </span><span style="color:#C586C0">break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">while</span><span style="color:#D4D4D4"> (</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">    loxseq_tick</span><span style="color:#D4D4D4">(&#x26;seq, </span><span style="color:#DCDCAA">now_ms</span><span style="color:#D4D4D4">());</span><span style="color:#6A9955">   /* 每步的动作在这里被调用 */</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>运行中分支</h3>
<p>默认流程单向推进。需要按业务条件跳转时，动作里先调 <code>loxseq_set_next_step()</code> 设目标，再返回 <code>LOXSEQ_STEP_BRANCH</code>。以质检步骤为例：合格跳去打包，不合格先跳去重工。跳转目标不限相邻；返回 <code>BRANCH</code> 却没设目标会让序列器进入 FAILED。</p>
<p>完整可编译示例（含打印步骤顺序的 main）：<a href="/posts/loxseq-intro/branch_demo.c" target="_blank" rel="noopener noreferrer">下载分支示例</a></p>
<h2>场景约束</h2>
<p>这套设计有三个明确的禁区，loxseq 的文档也写明了：</p>
<ol>
<li><strong>涉及生命安全的场景</strong>。loxseq 自己声明不提供安全等级：无 SIL 认证、无 IEC 61511、无 ISO 26262。检查点只记录软件知道的进度，不记录液位、温度、位置这些物理事实；断电期间物理过程还在演化，恢复时世界已经变了。安全场景的恢复必须止步于安全状态并等人工确认，自动续跑是错误设计。</li>
<li><strong>重大财产损失场景</strong>。恢复策略不可证明安全时，机器替人做决定就是风险。</li>
<li><strong>生产现场的主控</strong>。工厂工艺层的主控是 PLC/DCS 加安全 PLC，联锁、报警、配方都在那一层，断电恢复非常保守。检查点恢复适合的是设备内部的序列（点胶机归零分装、电池充电阶段、打印机断电续打），位置在 PLC 之下，两者叠加使用，互不替代。</li>
</ol>
<p>另外要说明的是：这个库的适用面很窄，没有公开的采用记录，项目作者也举不出确切的用户。把它当作高质量的参考实现来阅读更合适，价值在思想，不在这份代码。</p>
<h2>需要检查点恢复的四个特征</h2>
<p>用不用这套设计，判断标准是&quot;重做代价&quot;，与断电概率无关。四个特征命中其一就值得考虑：</p>
<ol>
<li>流程里有一次性或不可逆步骤，重复执行会破坏物料或设备。</li>
<li>单步分钟到小时级，重跑代价大。</li>
<li>断电后&quot;知道刚才在干什么&quot;本身就是安全条件（加热中、均衡中）。</li>
<li>重启源多样：看门狗、OTA、电池耗尽、用户关机。</li>
</ol>
<p>四个特征全不命中，重启后从头跑一遍没损失，就不需要这套设计。大多数项目属于这一类，自己写几十行状态机比引入依赖更划算。</p>
<p>即便不采用检查点恢复，这套思想里的几个设计点值得带走：恢复策略按步声明、终结写墓碑防止重做、步骤表指纹防固件升级误恢复、数据可疑时保守降级、时钟驱动状态机可测可移植。</p>]]></content>
    <category term="嵌入式"/>
    <category term="C语言"/>
    <category term="Flash存储"/>
    <category term="系统设计"/>
  </entry>
  <entry>
    <title>lwevt 核心实现：两次 include 生成事件类型，一次遍历完成广播</title>
    <link href="https://createskyblue.github.io/posts/lwevt-core-design/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/lwevt-core-design/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>lwevt 实现思想：X-macro 生成类型、广播遍历分发</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>lwevt 核心实现：两次 include 生成事件类型，一次遍历完成广播</h1>
<blockquote>
<p><strong>声明：</strong> lwevt 是一个开源项目，作者为 <a href="https://github.com/MaJerle/lwevt">Tilen MAJERLE</a>。本文是阅读该项目源码和文档后整理的学习笔记，用于理解 X-macro 类型生成与广播分发在嵌入式事件系统里的实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/MaJerle/lwevt" target="_blank" rel="noopener noreferrer">github.com/MaJerle/lwevt</a></p>
<p>这篇文章讲 lwevt 源码里的三个实现思想：类型系统交给编译器生成、分发退化成一次数组遍历、状态只有三个静态变量。</p>
<h2>核心思想</h2>
<p>lwevt 把整个事件系统压到最小：170 B 代码加 48 B RAM。三个落点：</p>
<ul>
<li>类型用 X-macro 生成：声明一次，枚举和 union 同时出来</li>
<li>分发用广播：所有监听器收所有事件，过滤下沉到监听器</li>
<li>状态用全局单例：一个静态句柄、一个静态监听表、一个计数，无堆无锁</li>
</ul>
<h2>X-macro：两次 include 生成类型系统</h2>
<p>X-macro 的核心技巧：宏在 include 之前重新定义，让同一个头文件在两次 include 时展开成不同内容。</p>
<p><code>lwevt_type.h</code> 先给两个宏提供空默认值，再 include 用户的 <code>lwevt_types.h</code>，最后 undef：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#ifndef</span><span style="color:#569CD6"> LWEVT_TYPE_BASIC</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> LWEVT_TYPE_BASIC(</span><span style="color:#9CDCFE">name</span><span style="color:#569CD6">)</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span>
<span class="line"><span style="color:#C586C0">#ifndef</span><span style="color:#569CD6"> LWEVT_TYPE_EXT</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> LWEVT_TYPE_EXT(</span><span style="color:#9CDCFE">name</span><span style="color:#569CD6">, </span><span style="color:#9CDCFE">data</span><span style="color:#569CD6">)</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "lwevt_types.h"</span><span style="color:#6A9955">   /* 用户的事件声明 */</span></span>
<span class="line"><span style="color:#C586C0">#undef</span><span style="color:#569CD6"> LWEVT_TYPE_BASIC</span></span>
<span class="line"><span style="color:#C586C0">#undef</span><span style="color:#569CD6"> LWEVT_TYPE_EXT</span></span></code></pre><p><code>lwevt.h</code> 第一次 include 前把宏定义成生成枚举成员，第二次定义成生成 union 成员：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 第一次：枚举 */</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> LWEVT_TYPE_BASIC(</span><span style="color:#9CDCFE">name</span><span style="color:#569CD6">)   name,</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> LWEVT_TYPE_EXT(</span><span style="color:#9CDCFE">name</span><span style="color:#569CD6">, </span><span style="color:#9CDCFE">data</span><span style="color:#569CD6">) name,</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "lwevt/lwevt_type.h"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 第二次：union */</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> LWEVT_TYPE_EXT(</span><span style="color:#9CDCFE">name</span><span style="color:#569CD6">, </span><span style="color:#9CDCFE">data</span><span style="color:#569CD6">) data;</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "lwevt/lwevt_type.h"</span></span></code></pre><p>用户在 <code>lwevt_types.h</code> 里只写一行声明：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">LWEVT_TYPE_EXT</span><span style="color:#D4D4D4">(LWEVT_TYPE_TEMP_READY, </span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> { </span><span style="color:#569CD6">int16_t</span><span style="color:#D4D4D4"> temp_x10; } temp)</span></span></code></pre><p>编译后同时得到枚举成员 <code>LWEVT_TYPE_TEMP_READY</code> 和 union 成员 <code>temp</code>，类型与数据绑定，改一处两端生效。</p>
<p><img src="/posts/lwevt-core-design/xmacro-expand.svg" alt="X-macro 展开过程"></p>
<h2>广播分发：一次遍历调用全部监听器</h2>
<p><code>lwevt_dispatch_ex</code> 只有两个动作：</p>
<ol>
<li><code>evt_handle-&gt;type = type</code></li>
<li><code>for (idx = 0; idx &lt; evt_fncs_cnt; ++idx) evt_fncs[idx](evt_handle)</code></li>
</ol>
<p>没有话题过滤，没有匹配逻辑。每个监听器收到每个事件，自己用 switch 或 if 判断 type。数据零拷贝：事件数据就在句柄的 union 里，监听器直接读写。</p>
<p><img src="/posts/lwevt-core-design/broadcast-loop.svg" alt="广播遍历"></p>
<h2>注册去重与全局单例</h2>
<ul>
<li><code>lwevt_register</code> 先遍历现有监听表查重，同一个函数指针直接返回 0，避免重复注册后每个事件触发两次</li>
<li>状态是三个 static：默认句柄 <code>evt</code>、监听表 <code>evt_fncs[8]</code>、计数 <code>evt_fncs_cnt</code>。<code>LWEVT_CFG_ENABLE_DEFAULT_HANDLE=0</code> 时去掉默认句柄，改用 <code>lwevt_dispatch_ex</code> 传自己的句柄，再省 12 B</li>
<li>没有退订接口：数组只追加不删除，这是把接口压到最小的取舍</li>
</ul>
<h2>设计取舍与边界</h2>
<ul>
<li>广播换简单：遍历没有匹配开销，代价是每个监听器都要处理无关事件</li>
<li>类型生成换手感：X-macro 让事件声明集中在一处，代价是读代码要先理解宏展开</li>
<li>单例换零成本：无堆无锁，代价是只能有一套监听表、无法退订</li>
<li>边界：dispatch 同步执行，在中断里调用时回调也在中断上下文运行</li>
</ul>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="C语言"/>
    <category term="Cortex-M"/>
    <category term="系统设计"/>
  </entry>
  <entry>
    <title>LwEVT 入门：注册回调，dispatch 广播事件</title>
    <link href="https://createskyblue.github.io/posts/lwevt-getting-started/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/lwevt-getting-started/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>LwEVT 广播分发器
 最简入门与示例代码</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>LwEVT 入门：注册回调，dispatch 广播事件</h1>
<blockquote>
<p><strong>声明：</strong> LwEVT 是一个开源项目，作者为 <a href="https://github.com/MaJerle/LwEVT">Tilen MAJERLE</a>。本文是阅读该项目源码和文档后整理的入门笔记。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 示例代码已在本机 GCC 与 ARM GCC 下编译运行验证。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/MaJerle/LwEVT" target="_blank" rel="noopener noreferrer">github.com/MaJerle/LwEVT</a></p>
<p>这篇文章帮你完成一件事：<strong>把 LwEVT 跑起来，实现&quot;按键按下发一个事件、温度采集完发一个带数据的事件&quot;</strong>。全程只用 5 个 API，不需要理解内部实现。</p>
<h2>LwEVT 的调用流程</h2>
<p>LwEVT 是事件分发器：事件（event）由类型 type 区分，监听器（listener）就是普通回调函数。核心动作只有两个：</p>
<ol>
<li><code>LwEVT_register()</code> 注册回调，可注册多个，最多 <code>LwEVT_CFG_MAX_EVT_LISTENERS</code> 个（默认 8）</li>
<li><code>LwEVT_dispatch()</code> 广播事件给所有已注册回调，逐个调用</li>
</ol>
<p><img src="/posts/lwevt-getting-started/lwevt-flow.svg" alt="LwEVT 分发流程"></p>
<h2>示例代码：按键与温度事件</h2>
<p>先建两个文件：<code>LwEVT_opts.h</code>（配置）和 <code>LwEVT_types.h</code>（事件类型），从仓库的 <code>LwEVT_opts_template.h</code>、<code>LwEVT_types_template.h</code> 复制改名即可。</p>
<p><code>LwEVT_types.h</code> 定义事件类型。<code>LwEVT_TYPE_BASIC</code> 定义不带数据的事件，<code>LwEVT_TYPE_EXT</code> 定义带数据的事件，数据声明成 C 结构体：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">LwEVT_TYPE_BASIC</span><span style="color:#D4D4D4">(LwEVT_TYPE_BTN_PRESSED)</span></span>
<span class="line"><span style="color:#DCDCAA">LwEVT_TYPE_BASIC</span><span style="color:#D4D4D4">(LwEVT_TYPE_BTN_RELEASED)</span></span>
<span class="line"><span style="color:#DCDCAA">LwEVT_TYPE_EXT</span><span style="color:#D4D4D4">(LwEVT_TYPE_TEMP_READY, </span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> { </span><span style="color:#569CD6">int16_t</span><span style="color:#D4D4D4"> temp_x10; } temp)</span></span></code></pre><p><code>main.c</code> 完整流程：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "LwEVT/LwEVT.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdint.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint32_t</span><span style="color:#D4D4D4"> btn_press_count;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 监听器 1：按键计数 + 温度判断 */</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> on_event</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">LwEVT_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">evt</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#C586C0">    switch</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">evt</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">type</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> LwEVT_TYPE_BTN_PRESSED:</span></span>
<span class="line"><span style="color:#D4D4D4">        btn_press_count++;</span></span>
<span class="line"><span style="color:#C586C0">        break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> LwEVT_TYPE_TEMP_READY:</span></span>
<span class="line"><span style="color:#6A9955">        /* 零拷贝：直接读句柄 union 里的数据 */</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (evt->msg.temp.temp_x10 > </span><span style="color:#B5CEA8">300</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#6A9955">            /* 超过 30.0 °C */</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#C586C0">        break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">    default</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#C586C0">        break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 监听器 2：只关心温度，独立记录 */</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> on_temp_log</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">LwEVT_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">evt</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">evt</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">type</span><span style="color:#D4D4D4"> == LwEVT_TYPE_TEMP_READY) {</span></span>
<span class="line"><span style="color:#6A9955">        /* 把温度写入日志缓冲区 */</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#4EC9B0">    LwEVT_t</span><span style="color:#D4D4D4"> *evt;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    LwEVT_init</span><span style="color:#D4D4D4">();</span><span style="color:#6A9955">                 /* 1. 初始化 */</span></span>
<span class="line"><span style="color:#DCDCAA">    LwEVT_register</span><span style="color:#D4D4D4">(on_event);</span><span style="color:#6A9955">     /* 2. 注册监听器 1 */</span></span>
<span class="line"><span style="color:#DCDCAA">    LwEVT_register</span><span style="color:#D4D4D4">(on_temp_log);</span><span style="color:#6A9955">  /* 3. 注册监听器 2 */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 4. 无数据事件：直接 dispatch */</span></span>
<span class="line"><span style="color:#DCDCAA">    LwEVT_dispatch</span><span style="color:#D4D4D4">(LwEVT_TYPE_BTN_PRESSED);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 5. 带数据事件：先拿句柄写数据，再 dispatch */</span></span>
<span class="line"><span style="color:#D4D4D4">    evt = </span><span style="color:#DCDCAA">LwEVT_get_handle</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#9CDCFE">    evt</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">msg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">temp</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">temp_x10</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">315</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">    LwEVT_dispatch</span><span style="color:#D4D4D4">(LwEVT_TYPE_TEMP_READY);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>运行输出（验证版加 printf）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>button pressed, count=1</span></span>
<span class="line"><span>temp=31.5 C</span></span>
<span class="line"><span>over 30.0 C</span></span>
<span class="line"><span>temp logged=315</span></span></code></pre><p>关键点：dispatch 是广播，所有已注册监听器都会收到每个事件（上例两个监听器各处理一次）；监听器自己判断关心哪种事件（用 switch 或 if）。注册顺序就是调用顺序，同一函数指针重复注册会被去重。</p>
<h2>LwEVT_opts.h 配置项</h2>
<ul>
<li><code>LwEVT_CFG_MAX_EVT_LISTENERS</code>：监听器上限，默认 8</li>
<li><code>LwEVT_CFG_ENABLE_DEFAULT_HANDLE</code>：默认句柄开关，默认 1；置 0 时改用 <code>LwEVT_dispatch_ex(&amp;evt, type)</code> 传自己的句柄</li>
</ul>
<h2>LwEVT 的特色</h2>
<ul>
<li>极小：库代码约 170 B（Cortex-M4，-Os），静态 RAM 48 B</li>
<li>X-macro 类型系统：事件类型和携带数据在编译期生成，改类型只动 <code>LwEVT_types.h</code></li>
<li>零拷贝：监听器直接读写句柄里的数据</li>
<li>API 只有 5 个函数，注册自动去重</li>
</ul>
<h2>适用边界</h2>
<ul>
<li>全局单例：整个程序只有一套监听表，无法创建多个独立的事件系统</li>
<li>无法退订：注册后不能移除回调</li>
<li>无队列：dispatch 同步执行全部回调，在中断里调用时回调也在中断上下文运行</li>
</ul>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="C语言"/>
    <category term="Cortex-M"/>
  </entry>
  <entry>
    <title>MCU-Malloc-Tracker 给每次 malloc 记账，崩溃后还能导出堆快照查泄漏</title>
    <link href="https://createskyblue.github.io/posts/mcu-malloc-tracker/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/mcu-malloc-tracker/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>MCU上每次malloc都被记进静态表，泄漏、热点、崩溃现场一查便知</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>MCU-Malloc-Tracker 给每次 malloc 记账，崩溃后还能导出堆快照查泄漏</h1>
<blockquote>
<p><strong>声明：</strong> MCU-Malloc-Tracker 是一个开源项目，作者为 Vanderhell（<a href="https://github.com/Vanderhell/MCU-Malloc-Tracker">项目仓库</a>）。本文是阅读该项目源码和文档后整理的学习笔记，用于理解 MCU 堆内存追踪的工程实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/Vanderhell/MCU-Malloc-Tracker" target="_blank" rel="noopener noreferrer">github.com/Vanderhell/MCU-Malloc-Tracker</a>（面向裸机 MCU 的 malloc/free 追踪库，查泄漏、找分配热点、导出崩溃快照）</p>
<p><img src="/posts/mcu-malloc-tracker/flow.svg" alt="追踪器整体流程"></p>
<p>malloc 是从堆里动态申请内存的标准接口，配套的 free 负责归还。MCU 上的动态内存很有限，用错一点，问题要跑几天才暴露。</p>
<h2>场景：设备随机崩溃，泄漏查不到</h2>
<p>最常见的故障链条：某模块反复 malloc 却忘记 free，堆被悄悄吃光，之后某次分配失败，或者写穿边界，系统随机复位。排查的难点在现场留不住：设备不能随便接仿真器，故障后自动复位，等连上调试器时堆的状态已经没了。</p>
<p>这个库的思路是平时就把堆的账记下来：谁在哪个文件第几行分配了多大一块，分配顺序如何。出了事翻账本，泄漏点、分配热点、崩溃时的堆现场全部可查。</p>
<h2>拦截：宏替换把 malloc 换成记账版</h2>
<p>要拦下每一次 malloc，用的是 C 预处理器宏替换：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> malloc(</span><span style="color:#9CDCFE">x</span><span style="color:#569CD6">) </span><span style="color:#DCDCAA">mt_malloc</span><span style="color:#569CD6">((x), __FILE__, __LINE__)</span></span></code></pre><p>代码里的 malloc(256) 在编译期变成 mt_malloc(256, &quot;driver.c&quot;, 42)，文件路径和行号由编译器自动填好，业务代码一行不用改。库内部先调真实分配器拿到指针，再写一条记录。</p>
<p>宏拦截最怕递归：mt_malloc 内部调用真实 malloc，真实分配器若再触发宏展开，就会无限嵌套。库用一个 g_inside 标志做递归保护，进入追踪逻辑时置位，置位状态下直接放行到真实分配器。</p>
<h2>账本：静态哈希表记下每次分配</h2>
<p>记账表是一张静态数组，容量编译期固定（默认 512 项，必须是 2 的幂）。定位槽位用指针哈希：idx = hash(ptr) &amp; (N-1)。用位与代替取模，MCU 上省掉除法开销，分配和释放都是平均 O(1)。每条记录包含指针、大小、文件哈希、行号、序号五样信息。</p>
<p>释放时不能把记录直接清空。哈希表靠连续探测找槽位，清空会打断探测链，后面的分配可能找不到。库把释放的槽位标记成墓碑（tombstone）：查找时跳过，下次插入优先复用，代价是探测距离缓慢变长。表满时采用丢记录策略，真实分配照常成功，只是不记账，损失由 table_drops 计数如实暴露。</p>
<h2>快照：确定性二进制带回 PC 解码</h2>
<p>MCU 上通常没有可靠时钟，库不用时间戳，改用单调递增的序号记录分配顺序。同一段分配序列必然产生同一个内部状态，快照逐字节可复现，方便对比和回归验证。</p>
<p>mt_snapshot_write() 把当前所有存活记录导出成 MTS1 二进制：36 字节头加每条 24 字节记录，记录按指针升序排序，CRC32/IEEE 覆盖整份数据。文件名不存字符串，存 FNV1a-32 哈希，快照体积小、跨编译稳定；PC 端解码时用 <code>--filemap</code> 把哈希映射回源码路径。仓库自带的 demo 在本地编译运行后，解码器输出的 CRC 校验通过。</p>
<h2>三条设计底线：确定性、零 malloc、诚实</h2>
<table>
<thead>
<tr>
<th>底线</th>
<th>落实方式</th>
</tr>
</thead>
<tbody><tr>
<td>确定性</td>
<td>序号代替时间戳，同一序列得到同一快照</td>
</tr>
<tr>
<td>零 malloc</td>
<td>全部静态表，追踪器自身从不申请内存</td>
</tr>
<tr>
<td>诚实</td>
<td>算不出的指标标 N/A，不编造数字</td>
</tr>
</tbody></table>
<p>三条底线互相支撑：追踪器不申请内存，才有固定可预测的开销，能在任何 MCU 上稳定跑；状态完全确定，快照才可比对。碎片率是最典型的诚实案例：没有平台堆遍历钩子时，库直接报告 N/A；想要真实值，自己实现两个平台钩子即可，库只负责展示。</p>
<h2>适用边界</h2>
<ul>
<li>活跃分配数超过表容量时丢记录，默认 512 项，table_drops 可查。</li>
<li>ISR 里调用 malloc 时，需要自己定义 MT_LOCK / MT_UNLOCK（例如关中断）。</li>
<li><strong>FILE</strong> 路径跨编译不稳定时，用 -fmacro-prefix-map 固定前缀，保证文件哈希能映射回源码。</li>
</ul>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="C语言"/>
    <category term="Hook"/>
    <category term="工程调试"/>
  </entry>
  <entry>
    <title>microbus 核心实现：分发快照让回调再入队也不失控</title>
    <link href="https://createskyblue.github.io/posts/microbus-core-design/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/microbus-core-design/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>microbus 实现思想：入口快照保证单次分发有界</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>microbus 核心实现：分发快照让回调再入队也不失控</h1>
<blockquote>
<p><strong>声明：</strong> microbus 是一个开源项目，作者为 <a href="https://github.com/Vanderhell/microbus">Peter Vanderhell</a>。本文是阅读该项目源码和文档后整理的学习笔记，用于理解嵌入式事件总线在裸机上的确定性实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/Vanderhell/microbus" target="_blank" rel="noopener noreferrer">github.com/Vanderhell/microbus</a></p>
<p>这篇文章讲 microbus 源码里的三个实现思想：状态归调用者、分发确定性优先、队列用快照保证有界。不重复入门用法，直接看机制。</p>
<h2>核心思想</h2>
<p>microbus 的全部设计围绕一个目标：在单上下文裸机上提供确定性的发布订阅。三个落点：</p>
<ul>
<li>状态归调用者：<code>mbus_t</code> 由调用者分配，库内没有全局变量，可多实例、可放栈上</li>
<li>分发确定性：遍历订阅表按固定顺序，分发期间锁定订阅表</li>
<li>队列有界性：dispatch 入口快照队列计数，单次分发绝不失控</li>
</ul>
<h2>订阅表与投递期锁定</h2>
<p>订阅表就是 <code>mbus_t</code> 里的 <code>subs[16]</code> 数组，每个槽是 <code>{topic, handler, ctx, active}</code>。分发时 <code>mbus_deliver</code> 从下标 0 升序扫到 <code>MBUS_MAX_SUBSCRIBERS</code>，话题匹配或命中 <code>MBUS_TOPIC_ANY</code> 就调用回调。</p>
<p>确定性从哪来：<code>mbus_deliver</code> 进出时维护 <code>delivery_depth</code> 计数。深度大于 0 期间，<code>subscribe</code>、<code>unsubscribe</code>、<code>unsubscribe_topic</code> 直接返回 <code>MBUS_ERR_BUSY</code>。效果是分发期间订阅表不可变，遍历顺序和结果都可预测。代价是回调里不能改订阅，只能等分发结束再改。</p>
<p><img src="/posts/microbus-core-design/subs-lock.svg" alt="订阅表与投递期锁定"></p>
<h2>环形队列的单调计数</h2>
<p>队列是 <code>queue[8]</code> 加 <code>q_head</code>、<code>q_tail</code>，两个 32 位计数器只增不减，用 <code>(head &amp; (DEPTH-1))</code> 取模定位槽位，所以深度必须是 2 的幂。满判定是 <code>head - tail &gt;= DEPTH</code>，空判定是 <code>head == tail</code>。</p>
<p>单调计数的好处：不需要单独的 count 字段；32 位计数要入队 2^32 次才回绕；单生产者单消费者下 head 和 tail 各自只被一方写，配合临界区宏就能构成 ISR 与主循环的通道。</p>
<h2>dispatch 入口快照</h2>
<p>队列存在的意义是解耦产生事件和处理事件。<code>mbus_dispatch</code> 的实现思想是快照：</p>
<ol>
<li>入口取 <code>eligible = 当前队列计数</code></li>
<li>循环最多处理 eligible 个：pop 到局部事件，分发，<code>eligible--</code></li>
<li>回调里再入队的事件本回合不处理，留到下次 dispatch</li>
</ol>
<p>没有快照的版本，回调再入队会让循环追着队尾跑，可能无限执行。快照把单次 dispatch 的执行时间变成有界：最多 eligible 次回调，回调里入队再多也只增长 head。</p>
<p><img src="/posts/microbus-core-design/dispatch-snapshot.svg" alt="dispatch 入口快照"></p>
<h2>ABI 校验与统计计数</h2>
<ul>
<li><code>mbus_impl_init</code> 校验 <code>bus_size == sizeof(*bus)</code>：库和消费者配置不一致时编译出不同大小的结构，运行期直接拒绝，避免踩坏调用者内存</li>
<li><code>publish_count</code>、<code>deliver_count</code>、<code>drop_count</code> 三个计数器，drop 记录队列满丢事件，丢事件率可观测</li>
<li><code>MBUS_ISR_SAFE_QUEUE</code> 开启时 push、pop、计数都用临界区宏包裹，构成 SPSC 模式</li>
</ul>
<h2>设计取舍与边界</h2>
<ul>
<li>拷贝换安全：payload 拷进事件对象，调用方缓冲可复用，代价是 40 B 事件对象加一次 memcpy</li>
<li>确定性换灵活：分发期锁定订阅表，回调内改订阅被拒绝</li>
<li>有界换吞吐：单次 dispatch 只处理入口快照，回调密集入队时事件延迟到下次</li>
<li>边界：默认单上下文，多线程访问要外部加锁；话题空间 uint8 只有 256 个</li>
</ul>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="C语言"/>
    <category term="Cortex-M"/>
    <category term="系统设计"/>
  </entry>
  <entry>
    <title>MicroBus 入门：订阅话题，publish 发布事件</title>
    <link href="https://createskyblue.github.io/posts/microbus-getting-started/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/microbus-getting-started/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>MicroBus事件总线
 最简入门与示例代码</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>MicroBus 入门：订阅话题，publish 发布事件</h1>
<blockquote>
<p><strong>声明：</strong> MicroBus 是一个开源项目，作者为 <a href="https://github.com/Vanderhell/MicroBus">Peter Vanderhell</a>。本文是阅读该项目源码和文档后整理的入门笔记。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 示例代码已在本机 GCC 与 ARM GCC 下编译运行验证。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/Vanderhell/MicroBus" target="_blank" rel="noopener noreferrer">github.com/Vanderhell/MicroBus</a></p>
<p>这篇文章帮你完成一件事：<strong>把 MicroBus 跑起来，实现&quot;传感器模块发布电压读数，多个模块按话题订阅接收&quot;</strong>。核心概念只有两个：话题（topic，事件的类别）和订阅（subscribe，声明自己关心哪个话题）。事件携带的数据叫 payload（载荷）。</p>
<h2>MicroBus 的调用流程</h2>
<ol>
<li><code>mbus_init()</code> 初始化总线</li>
<li><code>mbus_subscribe()</code> 按话题注册回调</li>
<li><code>mbus_publish()</code> 发布事件，同步调用话题匹配的回调</li>
</ol>
<p><img src="/posts/microbus-getting-started/microbus-flow.svg" alt="MicroBus 发布流程"></p>
<h2>示例代码：传感器读数广播</h2>
<p><code>mbus.h</code> 自带默认配置（16 个订阅槽、32 字节 payload、8 深度队列），直接包含就能用：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "mbus.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdint.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;string.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint16_t</span><span style="color:#D4D4D4"> g_last_mv;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> on_sensor</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#4EC9B0"> mbus_event_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#D4D4D4">    (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)ctx;</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">payload_len</span><span style="color:#D4D4D4"> != </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint16_t</span><span style="color:#D4D4D4">)) {</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#DCDCAA">    memcpy</span><span style="color:#D4D4D4">(&#x26;g_last_mv, </span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">payload</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(g_last_mv));</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> on_any</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#4EC9B0"> mbus_event_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#D4D4D4">    (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)event;</span></span>
<span class="line"><span style="color:#D4D4D4">    (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)ctx;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#4EC9B0">    mbus_t</span><span style="color:#D4D4D4"> bus;</span></span>
<span class="line"><span style="color:#569CD6">    uint16_t</span><span style="color:#D4D4D4"> mv = </span><span style="color:#B5CEA8">3300</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    mbus_init</span><span style="color:#D4D4D4">(&#x26;bus, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span><span style="color:#6A9955">            /* 1. 初始化，时钟可传 NULL */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 2. 订阅：on_sensor 只收 MBUS_TOPIC_SENSOR，on_any 收所有话题 */</span></span>
<span class="line"><span style="color:#DCDCAA">    mbus_subscribe</span><span style="color:#D4D4D4">(&#x26;bus, MBUS_TOPIC_SENSOR, on_sensor, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    mbus_subscribe</span><span style="color:#D4D4D4">(&#x26;bus, MBUS_TOPIC_ANY, on_any, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 3. 立即发布：同步调用回调 */</span></span>
<span class="line"><span style="color:#DCDCAA">    mbus_publish</span><span style="color:#D4D4D4">(&#x26;bus, MBUS_TOPIC_SENSOR, &#x26;mv, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(mv));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 4. 入队延后分发：先攒事件，主循环统一 dispatch */</span></span>
<span class="line"><span style="color:#DCDCAA">    mbus_queue</span><span style="color:#D4D4D4">(&#x26;bus, MBUS_TOPIC_SENSOR, &#x26;mv, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(mv));</span></span>
<span class="line"><span style="color:#DCDCAA">    mbus_queue_signal</span><span style="color:#D4D4D4">(&#x26;bus, MBUS_TOPIC_BOOT);</span></span>
<span class="line"><span style="color:#DCDCAA">    mbus_dispatch</span><span style="color:#D4D4D4">(&#x26;bus);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>运行输出（验证版加 printf）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>sensor: 3300 mV</span></span>
<span class="line"><span>any event received, count=1</span></span>
<span class="line"><span>sensor: 3300 mV</span></span>
<span class="line"><span>any event received, count=2</span></span>
<span class="line"><span>any event received, count=3</span></span></code></pre><p>publish 立即触发 on_sensor 和 on_any 各一次；队列里的两个事件在 dispatch 时各触发一次，<code>MBUS_TOPIC_BOOT</code> 不带数据所以只有 on_any 收到。</p>
<h2>mbus_config.h 配置项</h2>
<ul>
<li><code>MBUS_MAX_SUBSCRIBERS</code>：订阅槽数量，默认 16</li>
<li><code>MBUS_MAX_PAYLOAD</code>：payload 上限，默认 32 字节</li>
<li><code>MBUS_ENABLE_QUEUE</code>：队列开关，默认 1</li>
<li><code>MBUS_QUEUE_DEPTH</code>：队列深度，默认 8，必须是 2 的幂</li>
<li><code>MBUS_ISR_SAFE_QUEUE</code>：ISR 安全模式，默认 0；开启需提供临界区宏</li>
</ul>
<h2>MicroBus 的特色</h2>
<ul>
<li>话题过滤：订阅时指定话题，回调只收到匹配事件；<code>MBUS_TOPIC_ANY</code> 通配</li>
<li>payload 拷贝进事件对象，调用方的临时缓冲可以立刻复用</li>
<li>有界队列：ISR 里 <code>mbus_queue</code> 入队，主循环 <code>mbus_dispatch</code> 统一处理</li>
<li>确定性：分发期间修改订阅表返回 <code>MBUS_ERR_BUSY</code></li>
<li>调用者持有状态：<code>mbus_t</code> 放栈或静态，可多实例</li>
</ul>
<h2>适用边界</h2>
<ul>
<li>默认单上下文，多线程访问需要外部加锁</li>
<li>话题空间 256 个，payload_len 单字节上限 255</li>
<li><code>mbus_t</code> 约 544 B RAM（16 订阅槽 + 8 队列，Cortex-M4），放栈上要确认栈空间足够</li>
</ul>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="C语言"/>
    <category term="Cortex-M"/>
  </entry>
  <entry>
    <title>LwEVT轻量够用的广播分发器，microbus稍大但功能更全的事件总线</title>
    <link href="https://createskyblue.github.io/posts/microbus-vs-lwevt/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/microbus-vs-lwevt/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>嵌入式事件分发总线 
 microbus VS LwEVT</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>LwEVT轻量够用的广播分发器，microbus是稍大功能更全的事件总线</h1>
<blockquote>
<p><strong>声明：</strong> microbus 与 LwEVT 都是开源项目。microbus 的作者是 <a href="https://github.com/Vanderhell/microbus">Peter Vanderhell</a>，LwEVT 的作者是 <a href="https://github.com/MaJerle/LwEVT">Tilen MAJERLE</a>。本文是对两个项目源码做 ARM GCC 编译与资源实测后整理的对比笔记。<strong>本文作者不是这些项目的开发者，未参与任何代码贡献。</strong> 文中资源数据来自编译实测，工程细节来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>项目仓库：</p>
<ul>
<li>microbus：<a href="https://github.com/Vanderhell/microbus" target="_blank" rel="noopener noreferrer">github.com/Vanderhell/microbus</a>，话题事件总线</li>
<li>LwEVT：<a href="https://github.com/MaJerle/LwEVT" target="_blank" rel="noopener noreferrer">github.com/MaJerle/LwEVT</a>，极简事件分发器</li>
</ul>
<h2>结论</h2>
<p>两个库都无需堆分配，可直接集成进 Cortex-M 工程。LwEVT 用 170 B 代码和 48 B RAM 完成广播分发：注册回调后 dispatch 逐个调用，携带数据的类型由 X-macro 在编译期生成。microbus 用 972 B 代码和 544 B RAM 提供完整事件总线：话题过滤、通配订阅、payload 拷贝、有界队列、确定性分发、错误码与 ABI 校验。资源差距来自功能差距，两者都在各自定位内把代价花在该花的地方。选型看需求：只要求通知一组回调、偶尔带点数据，LwEVT 够用；需要话题路由、ISR 入队、多实例或错误诊断，选 microbus。</p>
<p>两库结构对比如下：microbus 把订阅表和环形队列放进调用者持有的 <code>mbus_t</code>，按话题过滤分发；LwEVT 用全局静态监听表做广播，监听器自行按 type 过滤。</p>
<p><img src="/posts/microbus-vs-lwevt/lwevt-vs-microbus.svg" alt="microbus 与 LwEVT 结构对比"></p>
<h2>资源占用对比（Cortex-M4 实测）</h2>
<p>工具链 arm-none-eabi-gcc 14.3.1，Cortex-M4，<code>-Os</code>，链接 <code>--gc-sections</code>；完整程序为库加对等功能测试程序加 crt0。两库均通过 <code>-Wall -Wextra -Werror</code> 零告警。</p>
<table>
<thead>
<tr>
<th>指标</th>
<th>microbus</th>
<th>LwEVT</th>
</tr>
</thead>
<tbody><tr>
<td>库代码（单个 .o，全 API）</td>
<td>972 B</td>
<td>170 B</td>
</tr>
<tr>
<td>库静态 RAM</td>
<td>0 B（状态由调用者持有）</td>
<td>48 B（全局单例）</td>
</tr>
<tr>
<td>事件对象</td>
<td>mbus_event_t 40 B</td>
<td>LwEVT_t 12 B</td>
</tr>
<tr>
<td>订阅/监听表</td>
<td>16 槽 × 12 B = 192 B</td>
<td>8 槽 × 4 B = 32 B</td>
</tr>
<tr>
<td>完整总线实例 RAM</td>
<td>mbus_t 544 B（订阅表 192 + 队列 320 + 杂项 32）</td>
<td>48 B</td>
</tr>
<tr>
<td>最小配置</td>
<td>关队列后 1072 B text</td>
<td>关默认句柄后 668 B text</td>
</tr>
<tr>
<td>最大函数体</td>
<td>mbus_dispatch 132 B</td>
<td>LwEVT_register 60 B</td>
</tr>
</tbody></table>
<p>关键解读：microbus 的 544 B RAM 由调用者决定占用位置，放栈或静态都行，关掉队列再省约 328 B；LwEVT 的 48 B 是编译进去就固定的全局量，关默认句柄可降到 36 B。</p>
<h2>microbus 的设计收益</h2>
<ul>
<li>实例化设计：<code>mbus_t</code> 由调用者持有，可多实例、放栈上，测试隔离容易</li>
<li>话题过滤：uint8 话题加 <code>MBUS_TOPIC_ANY</code> 通配订阅，回调只收到匹配事件</li>
<li>payload 拷贝进事件对象，调用方的临时缓冲可以立刻复用</li>
<li>有界队列：环形缓冲、深度为 2 的幂；<code>mbus_dispatch</code> 入口快照队列长度，回调里再入队不会让本次分发无限执行</li>
<li>确定性：投递期间修改订阅表返回 <code>MBUS_ERR_BUSY</code>，升序遍历保证顺序</li>
<li>错误契约：8 个错误码加 <code>mbus_err_str()</code>；<code>mbus_impl_init</code> 用 <code>sizeof(*bus)</code> 校验 ABI</li>
<li>可裁剪：关队列省约 328 B RAM，<code>MBUS_ISR_SAFE_QUEUE</code> 模式配临界区钩子支持 ISR 入队</li>
</ul>
<h2>LwEVT 的设计收益</h2>
<ul>
<li>X-macro 类型系统：在 <code>LwEVT_types.h</code> 声明 <code>LwEVT_TYPE_BASIC</code> / <code>LwEVT_TYPE_EXT</code>，编译期生成枚举和数据 union，事件类型与携带数据绑定</li>
<li>零拷贝：监听器直接读写句柄里的 union，不复制 payload</li>
<li>API 只有 5 个函数，注册自动去重</li>
<li>可关默认句柄再省 12 B</li>
<li>代价：全局单例、无法退订、无队列、错误只回 0/1、无并发保护</li>
</ul>
<h2>选型依据</h2>
<ul>
<li>场景 A：Flash 只剩几百字节，只做简单事件广播，选 LwEVT。170 B 代码加 48 B RAM，接近免费。</li>
<li>场景 B：需要话题过滤、ISR 入队延迟处理、多实例或错误诊断，选 microbus。972 B 代码，RAM 按需实例化。</li>
<li>判断句：能接受所有监听器收所有事件、自行按 type 过滤，LwEVT 够用；出现只关心某类话题、或中断里发事件主循环处理的需求，就换 microbus。</li>
<li>边界：LwEVT 无法退订是实际限制；microbus 的话题空间只有 256 个、payload_len 是单字节，都是容量上限。</li>
</ul>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="C语言"/>
    <category term="Cortex-M"/>
    <category term="选型"/>
  </entry>
  <entry>
    <title>microcbor 原理：头字节、sticky overflow 与迭代式 skip</title>
    <link href="https://createskyblue.github.io/posts/microcbor-internals/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/microcbor-internals/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>一个头字节同时带类型和长度，解码靠pending计数器避免递归</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>microcbor 原理：头字节、sticky overflow 与迭代式 skip</h1>
<blockquote>
<p><strong>声明：</strong> microcbor 是一个开源项目，作者为 <a href="https://github.com/Vanderhell">Vanderhell</a>。本文是阅读该项目源码和文档后整理的学习笔记，用于理解嵌入式场景下 CBOR 编解码的工程实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/Vanderhell/microcbor" target="_blank" rel="noopener noreferrer">github.com/Vanderhell/microcbor</a>（单文件 C99 实现，~500 行）</p>
<p>配套入门篇：<a href="https://createskyblue.github.io/blog/microcbor-quickstart">microcbor 入门：MCU 上用 CBOR 编码遥测消息</a></p>
<h2>CBOR 线格式基础</h2>
<p>CBOR 把每一项编码成「头 + 可选负载」。头字节的高 3 位是 major type（大类），低 5 位是 additional info（参数长度）。microcbor 所有编解码逻辑都是围绕这个头字节展开的。</p>
<p><img src="/posts/microcbor-internals/cbor-head-byte.svg" alt="CBOR 初始字节结构"></p>
<p>major type 与 microcbor 支持情况：</p>
<table>
<thead>
<tr>
<th>major</th>
<th>含义</th>
<th>microcbor 支持</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>unsigned int</td>
<td>✅ 32 位</td>
</tr>
<tr>
<td>1</td>
<td>negative int</td>
<td>✅ 32 位（编码为 -1-n）</td>
</tr>
<tr>
<td>2</td>
<td>byte string</td>
<td>✅ 定长</td>
</tr>
<tr>
<td>3</td>
<td>text string</td>
<td>✅ 定长 + UTF-8 校验</td>
</tr>
<tr>
<td>4</td>
<td>array</td>
<td>✅ 定长</td>
</tr>
<tr>
<td>5</td>
<td>map</td>
<td>✅ 定长</td>
</tr>
<tr>
<td>6</td>
<td>tag</td>
<td>❌ 直接报 invalid</td>
</tr>
<tr>
<td>7</td>
<td>simple/float</td>
<td>✅ bool/null/float32</td>
</tr>
</tbody></table>
<p>additional info 的宽度规则在 RFC 里是统一表：0–23 直接当值；24 表示后面跟 1 字节参数；25 跟 2 字节大端；26 跟 4 字节大端；27 跟 8 字节（microcbor 不处理）；31 表示不定长（microcbor 不处理）。</p>
<h2>编码器：一次写一个头</h2>
<p>编码端的核心是 <code>enc_head</code>，它根据参数大小选最短宽度，把 major type 和宽度编码写进 5 字节临时 buffer，返回实际占用的头长度：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> size_t</span><span style="color:#DCDCAA"> enc_head</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint8_t</span><span style="color:#9CDCFE"> major</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> value</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint8_t</span><span style="color:#9CDCFE"> out</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">5</span><span style="color:#D4D4D4">]) {</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (value &#x3C;= </span><span style="color:#B5CEA8">23</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#9CDCFE">        out</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">] = (</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">)(major | (</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">)value);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#B5CEA8"> 1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    } </span><span style="color:#C586C0">else</span><span style="color:#C586C0"> if</span><span style="color:#D4D4D4"> (value &#x3C;= </span><span style="color:#B5CEA8">0xFF</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#9CDCFE">        out</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">] = (</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">)(major | CBOR_INFO_1BYTE);</span><span style="color:#6A9955">  /* 24 */</span></span>
<span class="line"><span style="color:#9CDCFE">        out</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">] = (</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">)value;</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#B5CEA8"> 2</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    } </span><span style="color:#C586C0">else</span><span style="color:#C586C0"> if</span><span style="color:#D4D4D4"> (value &#x3C;= </span><span style="color:#B5CEA8">0xFFFF</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#9CDCFE">        out</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">] = (</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">)(major | CBOR_INFO_2BYTE);</span><span style="color:#6A9955">  /* 25 */</span></span>
<span class="line"><span style="color:#DCDCAA">        put_u16</span><span style="color:#D4D4D4">(out + </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">, (</span><span style="color:#569CD6">uint16_t</span><span style="color:#D4D4D4">)value);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#B5CEA8"> 3</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    } </span><span style="color:#C586C0">else</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#9CDCFE">        out</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">] = (</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">)(major | CBOR_INFO_4BYTE);</span><span style="color:#6A9955">  /* 26 */</span></span>
<span class="line"><span style="color:#DCDCAA">        put_u32</span><span style="color:#D4D4D4">(out + </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">, value);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#B5CEA8"> 5</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><code>mcbor_enc_uint</code> 直接调用 <code>enc_append_head_only(enc, CBOR_MAJOR_UINT, value)</code>；<code>mcbor_enc_int</code> 对负数做 <code>-1-n</code> 转换后改用 major 1；字符串/字节串先用 <code>enc_head(CBOR_MAJOR_TEXT, len, ...)</code> 写长度头，再 memcpy payload；array/map 只写头，不校验后续是否真有那么多项。</p>
<p>这种设计的结果是：编码器是纯顺序写，没有回溯修补长度的操作（因为只支持定长容器，调用方必须在写 <code>mcbor_enc_map(n)</code> 前就知道有 n 对）。代价是调用方必须对结构有把握；收益是 encoder 不需要维护容器栈，代码只有 ~130 行。</p>
<h2>宽度选择的边界</h2>
<p>几个值得注意的宽度判断：</p>
<ul>
<li><code>value &lt;= 23</code> 走 1 字节头（单字节 0x00–0x17 直接表示小整数，这是 CBOR 定义的）。23 这个分界来自 RFC 本身，microcbor 并未自定义。</li>
<li>有符号整数 <code>mcbor_enc_int</code> 对非负数转调 uint 编码（major 0），对负数走 major 1 并把值映射为 <code>-(value+1)</code> 的无符号表示。-1 编码成 <code>0x20</code>（major 1, info=0），是最短负整数。</li>
<li>float32 固定走 major 7 + info=26 + 4 字节 IEEE 754 bit pattern；库用 <code>memcpy(&amp;bits, &amp;f, 4)</code> 做 bit cast，避开 strict aliasing，并用编译期 <code>typedef char assert[(sizeof(float)==4 &amp;&amp; ...)?1:-1]</code> 硬约束 float 必须是 binary32。</li>
</ul>
<h2>sticky overflow：链式编码只查一次</h2>
<p>编码器结构体只维护 <code>buf / capacity / pos / overflow</code> 四个字段，其中 <code>overflow</code> 是 sticky bit：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#4EC9B0"> mcbor_err_t</span><span style="color:#DCDCAA"> enc_prepare_write</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#4EC9B0"> mcbor_enc_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">overflow</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> MCBOR_ERR_OVERFLOW;</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">pos</span><span style="color:#D4D4D4"> > </span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">capacity</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> MCBOR_ERR_INVALID;</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (len > </span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">capacity</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">pos</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4"> MCBOR_ERR_OVERFLOW;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> MCBOR_OK;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>一旦某次写入触发 overflow，<code>enc_append_item</code> 会把 <code>enc-&gt;overflow = true</code> 置位，后续所有写入在 <code>enc_prepare_write</code> 第一行就直接返回错误，不再推进 pos。这样调用方可以安全地连写一长串：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">mcbor_enc_map</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">mcbor_enc_str</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"dev"</span><span style="color:#D4D4D4">);   </span><span style="color:#DCDCAA">mcbor_enc_str</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"s-01"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">mcbor_enc_str</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"temp"</span><span style="color:#D4D4D4">);  </span><span style="color:#DCDCAA">mcbor_enc_float</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">23.4f</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#6A9955">/* ... */</span></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">mcbor_enc_overflow</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">enc</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">ovf</span><span style="color:#D4D4D4">) != MCBOR_OK || ovf) {</span></span>
<span class="line"><span style="color:#6A9955">    /* 整个消息放弃 */</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>不用每次都检查返回值，也不会因为中间某次溢出导致 pos 越界写内存。这是典型的&quot;记录错误、继续做空跑、收尾统一处理&quot;模式，在 telemetry 这种&quot;消息要么完整发出、要么整块丢弃&quot;的场景里比逐次 goto fail 更实用。</p>
<p>另一个防御性细节是 <code>ranges_overlap</code> 检查：如果调用方把输出 buffer 内的某段作为 payload 传给 <code>mcbor_enc_text/bytes</code>，且这段区间与即将写入的位置重叠（典型出现在&quot;边编码边用自己 buffer 里的字符串&quot;的错误用法），<code>enc_append_item</code> 直接返回 <code>MCBOR_ERR_INVALID</code>，避免 memcpy 自覆盖产生未定义行为。</p>
<h2>解码器：<code>dec_next_core</code> 一次读一个完整项</h2>
<p>解码入口是内部的 <code>dec_next_core</code>，它先读 1 字节 initial byte，拆出 major/info，再通过 <code>dec_argument</code> 按 info 宽度读取参数（整数 argument 或长度），然后按 major 分发：</p>
<ul>
<li>uint/negint：直接把 argument 填进 <code>val.val.uint_val / int_val</code>（negint 做 <code>-1-argument</code> 转换并检查 <code>argument &gt; INT32_MAX</code> 防越界）。</li>
<li>bytes/text：先 <code>dec_require_bytes(dec, argument)</code> 校验剩余字节够，text 额外走 <code>is_valid_utf8</code>，然后把 <code>val.val.bytes.ptr</code> 指向输入 buffer 里这段起始位置，pos 前移长度。这就是 <code>mcbor_dec_bytes_ref</code> 零拷贝的来源：它不复制数据，只返回指针。</li>
<li>array/map：只把 count 填到 <code>val.val.container</code>，不递归，也不校验后面有没有那么多项。</li>
<li>simple（major 7）：info 20/21/22 对应 false/null/true 单字节；info=26 读 4 字节 float bit pattern；其余返回 invalid。</li>
</ul>
<p>所有 typed convenience 函数（<code>mcbor_dec_uint/float/str/...</code>）都遵循同一个模板：先 <code>next = *dec</code> 局部拷贝，在副本上跑 <code>dec_next_core</code>，成功了才 <code>*dec = next</code> 提交读指针。失败时 dec 状态保持不变，调用方可以回退或报告错误。这是 commit-or-rollback 模式，代价是一个结构体拷贝（很小），换回来的是错误路径下不会把读指针推到半中间状态。</p>
<p><code>mcbor_dec_str</code> 和 <code>mcbor_dec_bytes_ref</code> 的差异体现了一个明确的责任边界：</p>
<ul>
<li>文本字符串用 <code>mcbor_dec_str</code>，库负责拷贝到调用方 buffer 并补 <code>\0</code>，保证拿到的是普通 C 字符串；</li>
<li>二进制 payload 用 <code>mcbor_dec_bytes_ref</code>，库只返回指针和长度，不拷贝，调用方保证解码期间输入 buffer 不被释放或覆写。</li>
</ul>
<h2>迭代式 skip：不用递归跳嵌套</h2>
<p><code>mcbor_dec_skip</code> 是整个库里唯一需要处理嵌套结构的地方，实现上完全没有递归，用一个 <code>pending</code> 计数器做广度展开：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#4EC9B0"> mcbor_err_t</span><span style="color:#DCDCAA"> dec_skip_core</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">mcbor_dec_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">dec</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">    size_t</span><span style="color:#D4D4D4"> pending = </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (pending != </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#4EC9B0">        mcbor_value_t</span><span style="color:#D4D4D4"> val;</span></span>
<span class="line"><span style="color:#569CD6">        size_t</span><span style="color:#D4D4D4"> child_count = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#4EC9B0">        mcbor_err_t</span><span style="color:#D4D4D4"> err = </span><span style="color:#DCDCAA">dec_next_core</span><span style="color:#D4D4D4">(dec, &#x26;val);</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (err != MCBOR_OK) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> err;</span></span>
<span class="line"><span style="color:#D4D4D4">        pending--;</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">type</span><span style="color:#D4D4D4"> == MCBOR_ARRAY) {</span></span>
<span class="line"><span style="color:#D4D4D4">            child_count = </span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">container</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">        } </span><span style="color:#C586C0">else</span><span style="color:#C586C0"> if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">type</span><span style="color:#D4D4D4"> == MCBOR_MAP) {</span></span>
<span class="line"><span style="color:#C586C0">            if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">container</span><span style="color:#D4D4D4"> > SIZE_MAX / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> MCBOR_ERR_RANGE;</span></span>
<span class="line"><span style="color:#D4D4D4">            child_count = </span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">container</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (child_count != </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">            size_t</span><span style="color:#D4D4D4"> remaining = </span><span style="color:#9CDCFE">dec</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">size</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">dec</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">pos</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">            if</span><span style="color:#D4D4D4"> (child_count > remaining) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> MCBOR_ERR_INVALID;</span></span>
<span class="line"><span style="color:#C586C0">            if</span><span style="color:#D4D4D4"> (child_count > SIZE_MAX - pending) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> MCBOR_ERR_RANGE;</span></span>
<span class="line"><span style="color:#D4D4D4">            pending += child_count;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> MCBOR_OK;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>工作方式：</p>
<ol>
<li>初始 <code>pending = 1</code>（要跳过当前这一项）。</li>
<li>每迭代一次读出一个完整项（<code>dec_next_core</code> 对 string/bytes 已经前移了 payload 指针），pending 减 1。</li>
<li>如果这一项是 array(n)，说明后面还紧跟着 n 个直接子项，把 n 加到 pending；如果是 map(n)，后面紧跟着 2n 个项（n 个 key + n 个 value），加 2n。</li>
<li>pending 回到 0 时，整个值（包括所有嵌套）都被&quot;读掉了&quot;，读指针正好停在下一项之前。</li>
</ol>
<p>两个防整数溢出的检查是必要的：<code>val.val.container * 2</code> 在 n 接近 SIZE_MAX/2 时会回绕，所以先判断 <code>&gt; SIZE_MAX/2</code>；<code>pending += child_count</code> 也可能溢出，所以判断 <code>&gt; SIZE_MAX - pending</code>。恶意 CBOR 报文可以把 n 设成 <code>0xFFFFFFFF</code>，递归实现会爆栈，这个迭代实现如果漏了检查就会 pending 回绕到 0 导致 skip 提前结束、后续解析错位。</p>
<p><code>mcbor_validate_one</code> 的实现就是 <code>mcbor_dec_init → mcbor_dec_skip → 检查 dec.pos == dec.size</code>，复用 skip 做完整校验，不另写校验路径。这是个很干净的设计：skip 只要保证不漏读、不越界、不无限循环，validate 自然正确。</p>
<h2>UTF-8 校验放在编码端</h2>
<p><code>mcbor_enc_text</code> 在写文本串之前先跑一次 <code>is_valid_utf8</code>，校验失败直接返回 <code>MCBOR_ERR_INVALID</code>；解码端 <code>dec_next_core</code> 对 major 3 也会再跑一次。两次校验看起来冗余，但责任不同：</p>
<ul>
<li>编码端校验是让&quot;我自己编码出的 CBOR 一定合法&quot;，发现调用方传入了非法 UTF-8（比如二进制数据误当字符串），在源头报错。</li>
<li>解码端校验是让&quot;从外部接收到的 CBOR 如果含非法 UTF-8 文本就拒绝&quot;，防御对端问题。</li>
</ul>
<p><code>is_valid_utf8</code> 的实现是手写的单遍状态机，覆盖 1–4 字节序列以及 0xE0/0xED/0xF0/0xF4 这几个 lead byte 的特殊范围（拒绝 overlong 编码、UTF-16 surrogate halves、超过 U+10FFFF 的码位）。嵌入式目标库选择手写校验具有明确原因：无外部依赖、代码量固定、可在裸机上跑。</p>
<h2>浮点位模式转换</h2>
<p><code>float_to_bits</code> / <code>bits_to_float</code> 用 <code>memcpy</code> 在 <code>uint32_t</code> 和 <code>float</code> 之间搬 4 字节，不做类型 punning。前面提到的编译期静态断言：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">typedef</span><span style="color:#569CD6"> char</span><span style="color:#9CDCFE"> mcbor_float32_must_be_ieee754_binary32</span><span style="color:#D4D4D4">[</span></span>
<span class="line"><span style="color:#D4D4D4">    (</span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">float</span><span style="color:#D4D4D4">) == </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4"> &#x26;&#x26; FLT_RADIX == </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> &#x26;&#x26;</span></span>
<span class="line"><span style="color:#D4D4D4">     FLT_MANT_DIG == </span><span style="color:#B5CEA8">24</span><span style="color:#D4D4D4"> &#x26;&#x26; FLT_MAX_EXP == </span><span style="color:#B5CEA8">128</span><span style="color:#D4D4D4">) ? </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4"> : -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">];</span></span></code></pre><p>它通过 <code>&lt;float.h&gt;</code> 的常量判断宿主平台的 float 为 binary32：基数 2、尾数 24 位（含隐含 1）、最大指数 128，这三个条件合起来等价于 IEEE 754 single precision。在不符合这个约束的平台（极少数嵌入式编译器有非 IEEE 浮点模式）上会直接编译报错，避免静默生成错误的 bit pattern。float16/float64 不在子集里，省掉了 half&lt;-&gt;single 转换表和 64 位端序读写。</p>
<h2>设计取舍：砍掉的特性与原因</h2>
<p>结合 DESIGN.md 的说明和代码，几个裁剪决策是连贯的：</p>
<ul>
<li><strong>不定长</strong>：需要在数据流里打 &quot;break&quot; 标记，decoder 要维护一个&quot;读到哪个容器的 break&quot;栈，显著增大代码。遥测消息长度在编码时一定已知。</li>
<li><strong>64 位整数</strong>：8 字节参数会让所有 <code>uint32_t argument</code> 局部变量升级成 64 位，在 Cortex-M0 这类 32 位核上产生软件模拟运算；传感器值、时间戳（到 2106 年的秒数都在 2^32 内）极少需要 64 位。</li>
<li><strong>Tag</strong>：给值加语义注解（比如&quot;这是个日期&quot;、&quot;这是个正 bignum&quot;），但通信双方一般有预定义 schema，Tag 只是在 wire 上多加 1–2 字节。</li>
<li><strong>半精度/双精度</strong>：float16 需要转换表，float64 加大 payload 并把所有 bit cast 升级成 64 位；遥测里 float32 足够。</li>
</ul>
<p>这些裁剪共同把实现压到单文件 ~500 行，且保证每条 API 的栈用量都是常数（skip 用迭代、encode 无递归、decode 无递归），对极小 RAM 的 MCU 友好。</p>
<h2>适用边界回顾</h2>
<p>理解内部实现之后，几个使用边界就有明确原因：</p>
<ul>
<li>必须提前知道 map/array 元素数，encoder 不回填长度。</li>
<li>解码字符串默认拷贝，解码字节串零拷贝；想零拷贝处理文本需用 <code>mcbor_dec_next</code> 拿 <code>val.val.bytes.ptr</code>，返回的是未补 NUL 的原始切片。</li>
<li>输入 buffer 必须在解码期间保持有效，<code>bytes_ref</code> 返回的是内部指针。</li>
<li>编码输出非 canonical/deterministic CBOR：map key 顺序、整数宽度按调用方传入原样写入，不适合需要哈希签名的场景。</li>
<li>float 端序是按 <code>memcpy</code> 出来的 bit pattern 走大端写出，要求平台是 IEEE 754 binary32（断言保证）。</li>
</ul>
<p>入门篇里那个 demo 跑通之后，回头看这几个机制，就能判断什么时候该用 microcbor、什么时候需要换 Nanopb/cn-cbor 这类更完整的实现。</p>]]></content>
    <category term="嵌入式"/>
    <category term="C语言"/>
    <category term="通信协议"/>
    <category term="物联网"/>
    <category term="编码"/>
  </entry>
  <entry>
    <title>microcbor 入门：MCU 上用 CBOR 编码遥测消息</title>
    <link href="https://createskyblue.github.io/posts/microcbor-quickstart/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/microcbor-quickstart/</id>
    <published>2026-08-22T00:00:00.000Z</published>
    <updated>2026-08-22T00:00:00.000Z</updated>
    <summary>JSON在MCU上太重，microcbor用32位整数子集给遥测减负</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>microcbor 入门：MCU 上用 CBOR 编码遥测消息</h1>
<blockquote>
<p><strong>声明：</strong> microcbor 是一个开源项目，作者为 <a href="https://github.com/Vanderhell">Vanderhell</a>。本文是阅读该项目源码和文档后整理的学习笔记，用于理解嵌入式场景下 CBOR 编解码的工程实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/Vanderhell/microcbor" target="_blank" rel="noopener noreferrer">github.com/Vanderhell/microcbor</a>（C99、零依赖、零堆分配的 CBOR 编解码库，面向 MCU 遥测场景裁剪了 RFC 8949 子集）</p>
<h2>CBOR 在 MCU 遥测场景的优势</h2>
<p>MQTT、CoAP、BLE GATT、LoRa 这几类链路传输的典型载荷是键值型遥测消息：几个传感器数值加一个设备 ID。JSON 在 PC 端是默认选项，搬到 Cortex-M0/M3 这类小 MCU 上会遇到几个具体问题：</p>
<ul>
<li>每个字符串键、字符串值两端都要加引号；冒号、逗号、花括号是纯开销字节。</li>
<li>浮点数转成 ASCII 文本要走 <code>sprintf</code>，在无 FPU 的芯片上是最耗时的一段；数字 <code>&quot;23.4&quot;</code> 占 4 个 ASCII 字节，CBOR 里是 4 字节定长二进制 float32。</li>
<li>解析器要处理任意嵌套和字符串转义，代码体积和栈占用都偏大。</li>
</ul>
<p>CBOR（RFC 8949）是二进制 JSON 替代品，主要思路是用一个头字节同时携带「类型 + 长度信息」，去掉所有结构标点，整数和浮点数走二进制定长编码。同一条消息通常比 JSON 短 30%–50%，在 LoRa 51 字节 payload 限制下这个差距直接决定能不能一帧发完。</p>
<p><img src="/posts/microcbor-quickstart/cbor-vs-json.svg" alt="JSON 与 CBOR 字节占用对比"></p>
<h2>microcbor 的定位</h2>
<p>microcbor 面向遥测场景裁剪了 RFC 8949 子集，只实现：</p>
<ul>
<li>uint/int 32 位整数、bool、null、float32；</li>
<li>UTF-8 文本串、字节串；</li>
<li>定长 array 和 map；</li>
<li>未知字段的迭代式 skip（用于前向兼容）。</li>
</ul>
<p>砍掉的部分也是刻意的：不支持 64 位整数、不定长编码、Tag、半精度/双精度浮点。换来的是两个 <code>.c/.h</code> 就能集成、没有任何 malloc、编码端有 sticky overflow 标志可以连写后统一检查。</p>
<h2>集成方式</h2>
<p>代码只有一个实现文件和两个头文件：</p>
<ul>
<li><code>include/mcbor.h</code>：公共 API</li>
<li><code>include/mcbor_config.h</code>：一个开关 <code>MCBOR_ENABLE_FLOAT32</code>（默认 1，关闭后 float API 返回 <code>MCBOR_ERR_UNSUPPORTED</code>）</li>
<li><code>src/mcbor.c</code>：实现</li>
</ul>
<p>把这三个文件拷进工程，<code>#include &quot;mcbor.h&quot;</code> 即可，没有第三方依赖，C99 可编译。也可以直接用 CMake 里的 <code>add_subdirectory</code> 链接。</p>
<h2>入门 Demo：编码一条传感器消息</h2>
<p>下面这段代码编码一条 <code>{dev:&quot;s-01&quot;, temp:23.4, hum:55, ok:true}</code>，验证完整性后再解回来。完整可编译文件见仓库 README 的 Quick Start，下面给出跑通的版本。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdio.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;string.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "mcbor.h"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#9CDCFE"> buf</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">128</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#4EC9B0">    mcbor_enc_t</span><span style="color:#D4D4D4"> enc;</span></span>
<span class="line"><span style="color:#569CD6">    size_t</span><span style="color:#D4D4D4"> used = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 编码：4 个键值对的 map */</span></span>
<span class="line"><span style="color:#DCDCAA">    mcbor_enc_init</span><span style="color:#D4D4D4">(&#x26;enc, buf, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(buf));</span></span>
<span class="line"><span style="color:#DCDCAA">    mcbor_enc_map</span><span style="color:#D4D4D4">(&#x26;enc, </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    mcbor_enc_str</span><span style="color:#D4D4D4">(&#x26;enc, </span><span style="color:#CE9178">"dev"</span><span style="color:#D4D4D4">);   </span><span style="color:#DCDCAA">mcbor_enc_str</span><span style="color:#D4D4D4">(&#x26;enc, </span><span style="color:#CE9178">"s-01"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    mcbor_enc_str</span><span style="color:#D4D4D4">(&#x26;enc, </span><span style="color:#CE9178">"temp"</span><span style="color:#D4D4D4">);  </span><span style="color:#DCDCAA">mcbor_enc_float</span><span style="color:#D4D4D4">(&#x26;enc, </span><span style="color:#B5CEA8">23.4f</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    mcbor_enc_str</span><span style="color:#D4D4D4">(&#x26;enc, </span><span style="color:#CE9178">"hum"</span><span style="color:#D4D4D4">);   </span><span style="color:#DCDCAA">mcbor_enc_uint</span><span style="color:#D4D4D4">(&#x26;enc, </span><span style="color:#B5CEA8">55</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    mcbor_enc_str</span><span style="color:#D4D4D4">(&#x26;enc, </span><span style="color:#CE9178">"ok"</span><span style="color:#D4D4D4">);    </span><span style="color:#DCDCAA">mcbor_enc_bool</span><span style="color:#D4D4D4">(&#x26;enc, </span><span style="color:#569CD6">true</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    mcbor_enc_size</span><span style="color:#D4D4D4">(&#x26;enc, &#x26;used);</span></span>
<span class="line"><span style="color:#6A9955">    /* 编码后 used=30 字节，hex:</span></span>
<span class="line"><span style="color:#6A9955">       a4 63646576 64732d3031 6474656d70 fa41bb3333</span></span>
<span class="line"><span style="color:#6A9955">       6368756d 1837 626f6b f5 */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 一次性验证：顶层 1 个完整项、无 trailing 字节 */</span></span>
<span class="line"><span style="color:#DCDCAA">    mcbor_validate_one</span><span style="color:#D4D4D4">(buf, used);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 解码：按 key 查表，未知 key 用 skip 跳过 */</span></span>
<span class="line"><span style="color:#4EC9B0">    mcbor_dec_t</span><span style="color:#D4D4D4"> dec;</span></span>
<span class="line"><span style="color:#DCDCAA">    mcbor_dec_init</span><span style="color:#D4D4D4">(&#x26;dec, buf, used);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    size_t</span><span style="color:#D4D4D4"> n;</span></span>
<span class="line"><span style="color:#DCDCAA">    mcbor_dec_map</span><span style="color:#D4D4D4">(&#x26;dec, &#x26;n);</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; n; i++) {</span></span>
<span class="line"><span style="color:#569CD6">        char</span><span style="color:#9CDCFE"> key</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">]; </span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4"> klen;</span></span>
<span class="line"><span style="color:#DCDCAA">        mcbor_dec_str</span><span style="color:#D4D4D4">(&#x26;dec, key, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(key), &#x26;klen);</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">strcmp</span><span style="color:#D4D4D4">(key, </span><span style="color:#CE9178">"temp"</span><span style="color:#D4D4D4">) == </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">            float</span><span style="color:#D4D4D4"> v; </span><span style="color:#DCDCAA">mcbor_dec_float</span><span style="color:#D4D4D4">(&#x26;dec, &#x26;v);</span></span>
<span class="line"><span style="color:#DCDCAA">            printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"temp=</span><span style="color:#9CDCFE">%.1f</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, v);</span></span>
<span class="line"><span style="color:#D4D4D4">        } </span><span style="color:#C586C0">else</span><span style="color:#C586C0"> if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">strcmp</span><span style="color:#D4D4D4">(key, </span><span style="color:#CE9178">"hum"</span><span style="color:#D4D4D4">) == </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">            uint32_t</span><span style="color:#D4D4D4"> v; </span><span style="color:#DCDCAA">mcbor_dec_uint</span><span style="color:#D4D4D4">(&#x26;dec, &#x26;v);</span></span>
<span class="line"><span style="color:#DCDCAA">            printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"hum=</span><span style="color:#9CDCFE">%u</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, v);</span></span>
<span class="line"><span style="color:#D4D4D4">        } </span><span style="color:#C586C0">else</span><span style="color:#C586C0"> if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">strcmp</span><span style="color:#D4D4D4">(key, </span><span style="color:#CE9178">"ok"</span><span style="color:#D4D4D4">) == </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">            bool</span><span style="color:#D4D4D4"> v; </span><span style="color:#DCDCAA">mcbor_dec_bool</span><span style="color:#D4D4D4">(&#x26;dec, &#x26;v);</span></span>
<span class="line"><span style="color:#DCDCAA">            printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"ok=</span><span style="color:#9CDCFE">%d</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, v);</span></span>
<span class="line"><span style="color:#D4D4D4">        } </span><span style="color:#C586C0">else</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#DCDCAA">            mcbor_dec_skip</span><span style="color:#D4D4D4">(&#x26;dec);</span><span style="color:#6A9955">   /* 不识别的 key 直接跳过 value */</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>编译运行（仓库自带测试文件也用同一条命令验证过，43/43 测试通过）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">gcc</span><span style="color:#569CD6"> -std=c99</span><span style="color:#569CD6"> -Iinclude</span><span style="color:#CE9178"> src/mcbor.c</span><span style="color:#CE9178"> demo.c</span><span style="color:#569CD6"> -lm</span><span style="color:#569CD6"> -o</span><span style="color:#CE9178"> demo</span></span>
<span class="line"><span style="color:#DCDCAA">./demo</span></span></code></pre><p>几个阅读这段代码时需要注意的点：</p>
<ul>
<li><code>mcbor_enc_map(n)</code> 只写容器头，不校验后面是否真的写了 n 对。编码器状态机不跟踪结构完整性，交给调用方或 <code>mcbor_validate_one</code> 收尾校验。</li>
<li>每个 <code>mcbor_enc_*</code> 返回 <code>mcbor_err_t</code>，buf 写满时会置 <code>enc-&gt;overflow = true</code>（sticky，置位后所有后续写入直接返回 overflow），可以在链尾用 <code>mcbor_enc_overflow</code> 一次检查。</li>
<li><code>mcbor_dec_str</code> 把文本拷进调用方提供的 buffer 并补 NUL 终止符；<code>mcbor_dec_bytes_ref</code> 则返回指向输入 buffer 的零拷贝指针，二进制 payload 走这个接口。</li>
<li>解码未知字段一定要用 <code>mcbor_dec_skip</code>，它内部是迭代式计数跳过，嵌套 array/map 也不会递归消耗栈。</li>
</ul>
<h2>API 速查</h2>
<p>编码端（写入调用方提供的 <code>uint8_t buf[]</code>）：</p>
<table>
<thead>
<tr>
<th>函数</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td><code>mcbor_enc_init</code></td>
<td>绑定 buffer，重置 pos/overflow</td>
</tr>
<tr>
<td><code>mcbor_enc_uint / mcbor_enc_int</code></td>
<td>32 位无符号 / 有符号整数，自动选最短宽度</td>
</tr>
<tr>
<td><code>mcbor_enc_bool / mcbor_enc_null / mcbor_enc_float</code></td>
<td>简单值</td>
</tr>
<tr>
<td><code>mcbor_enc_str / mcbor_enc_text / mcbor_enc_bytes</code></td>
<td>以 NUL 结尾串 / 指定长度文本 / 字节串</td>
</tr>
<tr>
<td><code>mcbor_enc_array(n) / mcbor_enc_map(n)</code></td>
<td>定长容器头</td>
</tr>
<tr>
<td><code>mcbor_enc_size / mcbor_enc_overflow</code></td>
<td>链尾查询已写字节 / 是否溢出</td>
</tr>
</tbody></table>
<p>解码端（顺序读，指针前移）：</p>
<table>
<thead>
<tr>
<th>函数</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td><code>mcbor_dec_init</code></td>
<td>绑定输入 buffer</td>
</tr>
<tr>
<td><code>mcbor_dec_next</code></td>
<td>通用取下一项，返回 <code>mcbor_value_t</code> tagged union</td>
</tr>
<tr>
<td><code>mcbor_dec_uint / mcbor_dec_int / mcbor_dec_bool / mcbor_dec_float</code></td>
<td>按类型取，类型不匹配返回 <code>MCBOR_ERR_TYPE</code></td>
</tr>
<tr>
<td><code>mcbor_dec_str</code></td>
<td>拷贝到调用方 buffer 并补 NUL</td>
</tr>
<tr>
<td><code>mcbor_dec_bytes_ref</code></td>
<td>零拷贝拿字节串指针</td>
</tr>
<tr>
<td><code>mcbor_dec_array / mcbor_dec_map</code></td>
<td>读容器头，返回元素个数</td>
</tr>
<tr>
<td><code>mcbor_dec_skip</code></td>
<td>跳过当前项（含嵌套容器），用于前向兼容</td>
</tr>
<tr>
<td><code>mcbor_dec_remaining / mcbor_dec_done</code></td>
<td>剩余字节 / 是否读完</td>
</tr>
</tbody></table>
<p>校验：</p>
<table>
<thead>
<tr>
<th>函数</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td><code>mcbor_validate_one</code></td>
<td>校验 buffer 包含且仅包含一个完整受支持的 CBOR 项，无 trailing 字节</td>
</tr>
<tr>
<td><code>mcbor_err_str</code></td>
<td>错误码转字符串，日志用</td>
</tr>
</tbody></table>
<h2>适用边界</h2>
<p>适合：传感器遥测、配置项导出、日志结构化字段、BLE GATT  characteristic 载荷、MQTT/CoAP 短消息。</p>
<p>不适合：</p>
<ul>
<li>载荷里需要 64 位整数、大整数、半精度/双精度浮点、Tag（比如日期 Tag 1）。</li>
<li>需要流式编码（encoder 只写定长容器头，必须提前知道元素个数）。</li>
<li>接收端需要 canonical CBOR（确定性编码）：库不排序 map key，不做最短浮点表示，NaN payload 原样出。</li>
</ul>
<p>跑通 demo 之后，下一篇 <a href="https://createskyblue.github.io/blog/microcbor-internals">microcbor 原理：头字节编码、sticky overflow 与迭代式 skip</a> 展开内部实现，重点看 <code>enc_head</code> 宽度选择、<code>dec_skip_core</code> 的迭代计数、以及 UTF-8 校验放在编码端还是解码端这个取舍。</p>]]></content>
    <category term="嵌入式"/>
    <category term="C语言"/>
    <category term="通信协议"/>
    <category term="物联网"/>
    <category term="编码"/>
  </entry>
  <entry>
    <title>边缘AI 姿态识别 振动故障诊断（异常检测、震动分类、传感器融合、回归标定）</title>
    <link href="https://createskyblue.github.io/posts/esp32s3-imu-gesture-recognition/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp32s3-imu-gesture-recognition/</id>
    <published>2026-08-18T00:00:00.000Z</published>
    <updated>2026-08-18T00:00:00.000Z</updated>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>边缘AI 姿态识别 振动故障诊断（异常检测、震动分类、传感器融合、回归标定）</h1>
<h2>项目背景</h2>
<p>项目原型来自本人负责的低功耗安防量产设备，安装在城市街道，设备一次性电池长效供电，依靠 IMU 震动事件唤醒 MCU，端侧完成风险研判后按需 4G 上报。</p>
<p>受量产项目涉密限制，搭建简化 DEMO 复现整套技术链路。设备以 50Hz 采样窗口采集 2 秒加速度时序数据，支持样本标注本地存储与网页导出；PC 端完成算法训练后，将推理逻辑以纯 C 形式部署到终端，实现本地实时分类。</p>
<p>整套技术可迁移至振动异常检测、故障诊断、传感器标定等工业场景。</p>
<h2>核心工作</h2>
<ol>
<li><strong>数据采集</strong>：设定50Hz/2s采集窗口，完成IMU运动姿态时序样本采集与标注，本地SD卡存储、网页后台导出数据。</li>
<li><strong>AutoML算法选型</strong>：基于时序数据自动遍历预处理、特征与分类算法组合，确定最优推理流水线。演示版采用轻量化简易特征；量产方案搭载全套时域、频域复合特征工程，适配复杂工业场景。</li>
<li><strong>算法复刻</strong>：AutoML 输出绑定特定芯片的黑盒静态库，无法跨平台运行。基于其输出的算法技术路线，借助 AI 工具逐级重建预处理、特征提取、推理全流程逻辑，经输入比对校验，复刻得到输出对齐的干净模型。</li>
<li><strong>MCU推理优化</strong>：实现纯C无依赖算法部署，全程静态内存设计，无动态内存分配；模型体积仅3.1KB，极低资源占用，实现毫秒级端侧离线推理。</li>
<li><strong>LVGL交互界面</strong>：基于LVGL搭建设备交互界面，实时展示三轴时序数据波形，配置姿态分类、实时推理功能按键，实现数据采集、算法推理的可视化闭环调试。</li>
</ol>
<h2>设备效果</h2>
<table>
<thead>
<tr>
<th>数据采集</th>
<th>实时推理</th>
</tr>
</thead>
<tbody><tr>
<td><img src="/posts/esp32s3-imu-gesture-recognition/img/%E6%95%B0%E6%8D%AE%E9%87%87%E9%9B%86%E5%AE%9E%E6%8B%8D.webp" alt="设备采集加速度数据"></td>
<td><img src="/posts/esp32s3-imu-gesture-recognition/img/%E6%8E%A8%E7%90%86%E5%AE%9E%E6%8B%8D.webp" alt="设备端实时推理"></td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>A 类手势</th>
<th>B 类手势</th>
<th>C 类手势</th>
</tr>
</thead>
<tbody><tr>
<td><img src="/posts/esp32s3-imu-gesture-recognition/img/%E6%8E%A8%E7%90%86A.webp" alt="A 类手势推理结果"></td>
<td><img src="/posts/esp32s3-imu-gesture-recognition/img/%E6%8E%A8%E7%90%86B.webp" alt="B 类手势推理结果"></td>
<td><img src="/posts/esp32s3-imu-gesture-recognition/img/%E6%8E%A8%E7%90%86C.webp" alt="C 类手势推理结果"></td>
</tr>
</tbody></table>
<video controls loop playsinline width="100%" style="border-radius: 12px;">
  <source src="/posts/esp32s3-imu-gesture-recognition/1911524140.mp4" type="video/mp4">
  您的浏览器不支持视频播放。
</video>

<h2>技术路线</h2>
<p><img src="/posts/esp32s3-imu-gesture-recognition/img/architecture.svg" alt="ESP32-S3 手势识别端侧推理链路"></p>
<h2>边缘AI 使用场景</h2>
<p>这套&quot;时序采集 + 特征工程 + 轻量化模型 + 纯 C 端侧部署&quot;的骨架，是一套<strong>端侧信号处理模板</strong>，分类、回归都能覆盖，换传感器、换场景都能快速复用：</p>
<ul>
<li><strong>异常振动检测</strong>：电机、风机、泵体等旋转设备，识别轴承磨损、不平衡、松动等故障类型。</li>
<li><strong>多传感器融合诊断</strong>：融合温度、电流、振动、声纹等多路传感器，在 MCU 本地预测设备异常并分类故障原因。</li>
<li><strong>电弧故障检测</strong>：从电流波形中提取特征，在断路器端侧实时识别串联/并联电弧。</li>
<li><strong>运动姿态识别</strong>：基于腕部/腰部 IMU 识别走路、跑步、跌倒、骑行等动作，应用于健康穿戴设备。</li>
<li><strong>传感器回归标定</strong>：输入单路或多路传感器原始值，回归估算标定后的物理量，例如把气体传感器原始读数换算为浓度单位。</li>
</ul>
<p><a href="https://github.com/createskyblue/esp32s3-imu-gesture-recognition">GitHub Demo仓库</a> · <a href="https://www.bilibili.com/video/BV1oSbX6DEbN">演示视频</a></p>]]></content>
    <category term="嵌入式"/>
    <category term="ESP32"/>
    <category term="边缘AI"/>
    <category term="异常检测"/>
    <category term="震动检测"/>
    <category term="姿态识别"/>
    <category term="EdgeAI"/>
    <category term="TFLite"/>
    <category term="TFLM"/>
  </entry>
  <entry>
    <title>嵌入式选型指南</title>
    <link href="https://createskyblue.github.io/posts/embedded_selection_guide/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/embedded_selection_guide/</id>
    <published>2026-08-13T00:00:00.000Z</published>
    <updated>2026-08-13T00:00:00.000Z</updated>
    <summary>嵌入式选型指南</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>嵌入式选型指南</h1>
<ul>
<li>通讯</li>
<li><ul>
<li>nanomodbus</li>
</ul>
</li>
<li><ul>
<li>帧解析器 TinyFrame</li>
</ul>
</li>
<li>基础设施</li>
<li><ul>
<li>环形缓冲区 LwRB</li>
</ul>
</li>
<li><ul>
<li>键值对 时序数据库 loxdb(稍微重一点 11K Flash; 8K RAM) &gt; flashDB(多数情况足够，节省37% Flash 资源，8.5K Flash; 1K RAM)</li>
</ul>
</li>
<li><ul>
<li>轻量AB分区配置数据库 flashStore</li>
</ul>
</li>
<li><ul>
<li>事件库 microbus(更先进，Flash 2K, RAM 872B) &gt; LwEVT (精简，大多数情况下够用，Flash 410B, RAM 52B)</li>
</ul>
</li>
<li>JSON解析</li>
<li><ul>
<li>cjson</li>
</ul>
</li>
<li><ul>
<li>LwJSON</li>
</ul>
</li>
<li>内存分配器 LwMEM</li>
<li>LwSHELL</li>
<li>LwPRINTF</li>
<li>图形显示</li>
<li><ul>
<li>单色屏 U8g2</li>
</ul>
</li>
<li><ul>
<li>彩屏 LVGL</li>
</ul>
</li>
<li>边缘AI</li>
<li><ul>
<li>TFLits</li>
</ul>
</li>
<li><ul>
<li>NanoEdgeAI</li>
</ul>
</li>
</ul>]]></content>
    <category term="嵌入式"/>
    <category term="软件"/>
    <category term="选型"/>
  </entry>
  <entry>
    <title>MCU 私有协议帧解析：FIFO 解耦与逐字节状态机</title>
    <link href="https://createskyblue.github.io/posts/byte-stream-fsm-frame-parser/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/byte-stream-fsm-frame-parser/</id>
    <published>2026-08-12T00:00:00.000Z</published>
    <updated>2026-08-12T00:00:00.000Z</updated>
    <summary>半帧、粘包、断包：一个状态机把字节流拼成完整消息</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>MCU 私有协议帧解析：FIFO 解耦与逐字节状态机</h1>
<p><strong>背景</strong>：MCU 通信固件收发「帧头 + 长度 + 变长数据 + 校验」私有协议帧，串口、BLE、433MHz 都按字节流到达，半帧、粘包、断包是常态。</p>
<p><strong>方案</strong>：接收中断只入队不解析，中断外用逐字节状态机拆帧，失败整帧丢弃重新同步。纯 C、定长缓冲、无动态分配，裸机中断环境可用。</p>
<h2>字节流没有消息边界</h2>
<p>接收中断一次只拿到一个字节，通道不会告诉你帧从哪开始、到哪结束，直接当消息读全乱。解析器要自己建边界：认帧头、按长度收齐数据、核校验。字节流如何可靠到达上层，见 <a href="/posts/uart-dma-idle-fifo">串口接收为什么会丢数据：从轮询到循环缓冲的改造思路</a>，本篇只讲拆帧。</p>
<h2>反例：凑够一帧再整体解析</h2>
<p>按固定长度阻塞读一帧再解析，能消化半帧、粘包，但有两个硬伤：只适配定长帧（变长帧的长度在数据里，阻塞读做不到）；中间丢一个字节就整体错位，按垃圾长度永远等不齐，通道永久卡死。这不是纸上推演：某仪器厂的低功耗分析仪上位机就是固定 64 字节盲切，不找帧头、不做校验，串口丢 1 字节后帧解析永久错位，数据乱跳而软件毫无察觉，复现过程见 <a href="http://localhost:5173/posts/fault-injection-uart-parser">故障注入复现上位机丢字节失步</a>。逐字节流式匹配则不同：丢字节只错一帧，下一帧按帧头重新对齐。</p>
<h2>FIFO 解耦与逐字节状态机</h2>
<p>中断里拆帧耗时不可控，中间隔一层字节流队列：中断只入队，解析器在中断外逐字节出队拆帧，解析慢不会丢字节。队列写满时的溢出策略由调用方决定：丢新字节保旧数据，或整段清空重新同步，本文实现取前者。</p>
<p><img src="/posts/byte-stream-fsm-frame-parser/%E6%9E%B6%E6%9E%84.svg" alt="分层架构：数据源、字节流队列、帧解析器、帧队列、指令解析器"></p>
<p>状态机一次处理一个字节，接口是 <code>uint8_t data, uint8_t* frame_buf, uint32_t buf_size</code>：帧头匹配后按长度字段收齐数据、核校验，全部通过才返回一帧。长度字段约定为用户数据字节数，不含帧头、长度与校验本身。</p>
<p>解析器本身平台无关、零依赖：喂进一个字节，拼进缓冲区，拼满返回一帧，仅此而已。FIFO 在哪、中断怎么接、帧缓冲区用完怎么处理，全是调用方的事，组件一概不管，换个平台原样编译。</p>
<p><img src="/posts/byte-stream-fsm-frame-parser/%E7%8A%B6%E6%80%81%E6%9C%BA.svg" alt="帧解析状态机：帧头、长度、变长数据、校验、返回一帧"></p>
<p>帧解析与消费分两种组织方式：</p>
<ul>
<li>同步：解析出一帧就退出，等这帧被消费完再回来继续解析。帧队列容量实际为 1，实现简单，吞吐偏低</li>
<li>异步：一进一出两个队列，字节流队列进、帧队列出，解析持续进行，消费者异步取帧，吞吐更高</li>
</ul>
<p>两种组织方式都要防吃 CPU：异步的解析任务不能被上游数据喂着跑个不停；同步看似出一帧就退出，但垃圾数据永远拼不出一帧，退出条件永不满足，照样空转。这也是 NASA/JPL 嵌入式 C 规范（Power of Ten）的要求：所有循环必须有静态可确定的退出上界，不允许出现依赖外部数据的死循环。落到这里就是给每次调用设退出条件：限定解析的字节数或帧数，或限定单次占用时间；有 RTOS 就给解析任务分配时间片，到点让出。</p>
<p>帧头不匹配、长度超界、校验不符，任一步失败都丢弃已收内容、回到匹配帧头重新同步，脏字节不进完整帧。重新同步时不必从空白状态重来：已匹配帧头前缀与后续字节重叠时，回退到最长可续匹配位置即可，多字节帧头选数据区低概率组合可进一步降低误判。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="通信协议"/>
    <category term="FIFO"/>
    <category term="中断"/>
  </entry>
  <entry>
    <title>一包要 25 个转换，ADC 规则组只有 16：双 ADC 并行采样</title>
    <link href="https://createskyblue.github.io/posts/dual-adc-parallel-sampling/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/dual-adc-parallel-sampling/</id>
    <published>2026-08-11T00:00:00.000Z</published>
    <updated>2026-08-11T00:00:00.000Z</updated>
    <summary>一帧要 1 电压加 24 电流共 25 个转换，规则组上限 16 放不下，单 ADC 得切规则组，双 ADC 并行绕开</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>一包要 25 个转换，ADC 规则组只有 16：双 ADC 并行采样</h1>
<p>某测量仪器的高速帧，一个包里装 25 个采样：1 个电压 + 24 个电流。要采满这一包，ADC 得做 25 次转换。但 ADC 规则组一次最多放 16 个，25 个转换怎么凑出来，就是采样结构设计的核心取舍。结论是双 ADC 双 DMA 并行，绕开规则组上限。</p>
<h2>一包要 25 个转换</h2>
<p>高速帧是 64 字节，载荷里 25 个采样：sample[0] 是电压（每帧 1 次），sample[1..24] 是电流（每帧 24 次），帧率约 400 帧/秒。</p>
<ul>
<li>电压更新率：1 × 400 = 400 次/秒（慢信号，够用）</li>
<li>电流采样率：24 × 400 = 9600 次/秒（快信号，抓瞬态）</li>
</ul>
<p>两路信号一个包传输，靠帧内位置区分，9 字节通道头再标注 24 个电流各自属于哪个档位。</p>
<h2>ADC 规则组一次最多 16 个</h2>
<p>常见 MCU 的 ADC 规则组（regular group）决定一次扫描按顺序转哪些通道，上限有限。这块芯片的规则组最多 16 个转换（SQ1~SQ16）。</p>
<p>25 个转换一次扫不完。要么拆成多个规则组，要么反复触发，要么用多个 ADC。怎么凑这 25 个，决定了采样时序和电流流的连续性。</p>
<h2>单 ADC 得来回切规则组，会有时序和死区</h2>
<p>如果只用同一个 ADC，要兼顾电压和电流，就得来回切换规则组：切到电压组转 1 次，切到电流组转若干次，再切回电压组。每次切换都要重配 ADC 的转换序列，重配期间 ADC 不干活，电流流里就出现死区。</p>
<p>更麻烦的是 25 超出 16：一个规则组塞不下全部转换，得拆成两个组（16 + 9），中间还要切换。切换的时序抖动和死区，对要抓瞬态的电流测量很不友好。</p>
<h2>双 ADC 让每个规则组都够小</h2>
<p>用两个 ADC 各管一路，问题就没了：每个 ADC 只管自己的信号，规则组只需要 1 个通道。电流 ADC 连续采电流（24 次/帧），电压 ADC 采电压（1 次/帧），两路并行，不用切规则组，电流流没有死区。</p>
<p><img src="/posts/dual-adc-parallel-sampling/dual-adc-parallel.svg" alt="采样结构对比"></p>
<p>两个 ADC 对应两个 DMA 控制器，各自把采样搬进帧缓冲，电压写 sample[0]，电流写 sample[1..24]，合成一帧。</p>
<p>考虑过其他可能：反复触发单个 ADC 也能凑 25 个，但要精确控制电压和电流的穿插时序，和规则组切换一样容易出死区；外部 mux 切输入也要卡时序。双 ADC 的方案最干净：固件里能找到两个 ADC 基址和两个 DMA 控制器的引用，两路各扫各的，互不干扰。</p>
<p>比起单 ADC 来回切规则组，双 ADC 用两个转换器的代价，换来了电流的完整带宽和干净的时序。</p>
<h2>推断边界</h2>
<p>双 ADC 双 DMA 是最可能的结构，但来自反编译代码的片段推断。固件用 HAL 抽象层藏了大量细节，具体寄存器映射、两个 ADC 是否真正并行（还是共享时钟），未 100% 确认。这套结论不来自厂商文档，可能有错。</p>]]></content>
    <category term="嵌入式"/>
    <category term="数据采集"/>
    <category term="系统设计"/>
    <category term="工程调试"/>
  </entry>
  <entry>
    <title>故障注入复现上位机丢字节失步</title>
    <link href="https://createskyblue.github.io/posts/fault-injection-uart-parser/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/fault-injection-uart-parser/</id>
    <published>2026-08-11T00:00:00.000Z</published>
    <updated>2026-08-11T00:00:00.000Z</updated>
    <summary>注入按钮复现上位机丢字节失步：字节级故障注入、失步锁定</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>故障注入复现上位机丢字节失步</h1>
<p>一台低功耗分析仪的上位机（Windows 闭源软件）有个隐患：串口链路只要丢 1 个字节，数据解析就永久错位。反馈给厂家后，他们的技术人员没意识到问题，认为代码本就会丢弃错误包，不至于失步。口说无凭，我设计了一个实验：在厂家自己的软件里加一个「模拟丢字节」按钮，当着他们的面按下去，让失步自己发生。缺陷是怎么查出来的、为什么会永久错位，见 <a href="http://localhost:5173/posts/serial-stream-state-machine">错位、丢尾、永久失步：三态状态机把串口字节流拼成完整帧</a>，本文不重复，只讲这个实验怎么设计、结果怎么样。</p>
<h2>一、实验前要知道的三个事实</h2>
<p>这款上位机的接收逻辑有三个特点，实验设计全建立在这上面：</p>
<ul>
<li>软件每次固定读 64 字节，从不检查帧头，错位了也没有自己找回来的机制；</li>
<li>末尾不足 64 字节的零碎数据会被直接扔掉。这看似保护措施，其实防不住真正的风险：软件读之前先确认缓冲区够 64 字节，读取永远成功，数据流中间丢字节造成的错位照样发生，扔残段只会再白丢一笔数据。这个丢弃还会挪动对齐位置，直接影响实验效果（见第三节）；</li>
<li>解析出错时软件既不报警也不提示，界面只是波形卡住不动，看起来像是偶发卡死。</li>
</ul>
<p>前两点决定了失步一旦发生就不会自己恢复，第三点决定了必须靠外部手段让失步「看得见」。</p>
<p><img src="/posts/fault-injection-uart-parser/align.svg" alt="丢字节前后解析窗口对齐对比"></p>
<h2>二、实验设计：一个「模拟丢 1 字节」按钮</h2>
<p>在厂家的软件里加了三个小改动：</p>
<ol>
<li>界面上多一个按钮和一行状态文字；</li>
<li>按钮按下后，抢在软件读取之前，从串口缓冲里提前取走 1 个字节。软件照样读满 64 字节，但读到的内容整体错开了一位，从此每一帧都是错位的，效果和真实链路丢字节完全一样；</li>
<li>每收到一帧数据检查一次帧头，对齐正常显示绿色，错位显示红色。</li>
</ol>
<p>按钮只是触发器，缺陷不是我们注入的，这一点必须说死。有人会问：USB 有校验和重传，丢也是整包丢，一帧正好一个包，怎么会错位？问得对，整包丢失确实不造成错位。但要分清契约和现象：虚拟串口对应用层就是字节流管道，驱动从不承诺按 64 的倍数交付，&quot;缓冲区总是整包倍数&quot;只是当前链路上观察到的行为，不是任何一层的保证。何况字节流脱离整帧网格有两条现实路径：设备侧，复位瞬间吐杂讯、固件发送缓冲溢出截断，丢的就不是整帧；主机侧更讽刺，这个软件收到设备报错会在接收线程里直接弹对话框，弹窗没人点，接收循环堵死，几秒后驱动缓冲溢出，溢出丢多少字节就不受控制了。也就是说它不需要任何外力，自己就能把自己逼到错位。而原厂代码没有从错位中恢复的任何能力：数据主路径从不检查帧头，认不出的帧静默丢弃，既发现不了也找不回来。公允地说：正常链路、正常固件下它确实不会出错，对齐是传输层免费提供的；但低概率不等于零，长时间挂机总会撞上扰动，而它对异常零防御、零感知。所以就算完全不碰这个软件，同样的失步也会发生，区别只是等多久。实验做的只是把&quot;等一次随机扰动&quot;变成&quot;按一下按钮&quot;。</p>
<h2>三、第一次失败：失步竟然自己恢复了</h2>
<p>第一版实验有个意外：丢了 1 字节后，软件有时过一会儿自己恢复了。查清楚原因：这台设备一帧正好 64 字节，经 USB 送达时缓冲区通常按 64 的倍数累积，读完没有剩余。被拿走 1 字节后，缓冲区总数永远差 1 才凑整，每次读完都会剩 63 字节零头被扔掉，而扔 63 等于读取位置多跳 63 位，正好把之前错的 1 位凑回来，对齐恢复。</p>
<p>修正办法：丢字节之后，让软件不再扔末尾数据，偏移就永远补不回来，失步确定持续。实验要证明的是「软件无法自愈」，就不能留下这条偶然自愈的路。</p>
<p>这里要主动交代：禁用末尾丢弃确实动了原程序的行为，但动的不是它「找回对齐」的能力，因为它从来没有这种能力。末尾丢弃从不检查帧头，恢复与否只看扔掉的字节数凑不凑巧，是运气不是机制。就算偶尔凑巧恢复，恢复之前数据已经错了很久，用户毫无察觉；真实使用中丢字节反复发生，靠巧合对齐等于把正确性交给运气。禁用它只是把随机性拿掉，让失步确定地摆在台面上。</p>
<h2>四、实验结果：按下按钮之后</h2>
<p>点击按钮：状态文字立刻变红「帧失步」，此后一直不恢复；波形卡住不动，其他操作也没有反应；而软件自己全程没有任何报错。必须断开连接再重连才恢复正常。整个改动只有 4 处，软件原有逻辑一行没动。</p>
<p><img src="/posts/fault-injection-uart-parser/demo-shot.webp" alt="现场复现：点击「模拟丢1字节」后，状态变红「帧失步! MAGIC=132 cmd(错)=60」"></p>
<h2>五、一条铁律：不能碰原程序的行为</h2>
<p>改动有一条铁律：不能改变软件原来的解析逻辑，否则厂家可以说「这是你改坏的」。全程唯一的例外是第三节交代过的尾部丢弃守卫，它禁掉的是巧合自愈，不是任何有意的恢复机制。实践中踩过一个坑：注入时改错了程序内部的引用，导致软件启动握手直接失败，后来手工修正才解决。全程没有厂家源码，也没有他们的开发环境，用的是 Windows 自带工具加开源库完成全部改动。</p>
<h2>六、这个实验的价值</h2>
<p>两个关键取舍：故障要像自然发生的错误，不能像人为破坏；观测手段不能碰业务逻辑。这样演示才站得住。</p>
<p>丢字节是传输链路的事故，失步是软件自己的缺陷，这个实验把两件事分开了，厂家无法再用「链路问题」搪塞。反编译中还看到另一个模块里有一模一样的接收写法：凑满 64 字节盲读、不找帧头，说明这不是某个函数的疏忽，而是习惯性的设计模式。这套「加观测点 + 加故障点」的方法对任何闭源上位机都通用：把软件自己察觉不到的缺陷，变成对方无法否认的证据。</p>]]></content>
    <category term="故障诊断"/>
    <category term="通信协议"/>
    <category term="工程调试"/>
    <category term="Windows"/>
    <category term="可观测性"/>
  </entry>
  <entry>
    <title>帧凑满一包 USB 是巧思，换档尖峰却没人处理</title>
    <link href="https://createskyblue.github.io/posts/gear-switch-transient/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/gear-switch-transient/</id>
    <published>2026-08-11T00:00:00.000Z</published>
    <updated>2026-08-11T00:00:00.000Z</updated>
    <summary>自动换档靠 MOS 开关，米勒电容在切换瞬间注入电荷，上位机不丢弃尖峰就混进数据</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>帧凑满一包 USB 是巧思，换档尖峰却没人处理</h1>
<p>某测量仪器的高速帧设计有个小巧思：帧长刚好凑满一个 USB 包。同一台仪器的自动换档却有个没处理干净的环节。换档瞬间 MOSFET 的米勒电容会往测量点注入电荷，产生尖峰，上位机又不丢弃这些采样。</p>
<h2>一帧刚好凑满一个 USB 包</h2>
<p>高速采样帧是 64 字节，正好等于 USB 全速单包上限。一帧就是一个 USB 包，不用拆包、不用重组。</p>
<p><img src="/posts/gear-switch-transient/frame-usb-packet.svg" alt="帧 = USB 包"></p>
<p>载荷 60 字节安排得严丝合缝：9 字节通道头 + 25 个采样（各 2 字节）+ 1 字节标志 = 60，一字节不剩。更巧的是 9 字节通道头 = 24 个电流采样 × 3 位通道号 = 72 位，位打包后恰好 9 字节。每个采样点独立标注所属档位。</p>
<p>三个约束互相咬合：USB 包大小、采样数量、通道头位宽，选出的数字恰好都凑满。这是值得抄的设计。</p>
<h2>换档瞬间的电荷注入</h2>
<p>自动量程靠 MOS 开关切换分流电阻和增益。换档是硬件动作：一个 MOS 管导通、另一个关断，测量回路瞬间换挡。</p>
<p>MOS 管栅极和漏极之间有寄生电容，也就是米勒电容。切换瞬间，栅极驱动电压快速跳变，通过米勒电容把电荷灌进漏极，也就是测量节点。对 µA、nA 级测量，这一点电荷就是很大的相对误差。开关本身的导通关断过渡还会让测量节点抖几个采样周期。</p>
<p>换档瞬间产生的是物理必然的尖峰，不是测量噪声。</p>
<p>实测抓帧能看到换档瞬间的完整过程：过渡帧里一部分采样还标旧档、一部分已经标新档（通道号混档）；换档后固件把电流采样置 0，持续几个采样周期，作为稳定期标记。稳定期结束，采样恢复正常。固件用零填充标记数据无效，没有停止采样，也没有重新编程 ADC。</p>
<p><img src="/posts/gear-switch-transient/switch-transient.svg" alt="换档瞬态"></p>
<h2>上位机只读档位，不处理过渡</h2>
<p>上位机逐采样读通道号，按通道号套对应的校准系数，把当前档位显示出来。通道号一变，显示跟着变。</p>
<p>固件给了零填充作为稳定期标记，上位机却不认。过渡帧、零采样都被当成正常数据照常处理。帧里唯一的标志字节只用于电压校准模式选择，跟换档无关。上位机既不知道换档发生的时间，也不在乎过渡采样脏不脏。</p>
<h2>尖峰混进数据的后果</h2>
<p>低功耗测量里，换档尖峰可能被当成真实的电流瞬态。设备唤醒脉冲本来就该是尖峰，真假混在一起，结论就错了。</p>
<p>平均和滤波救不了尖峰：尖峰是有偏的，会把均值抬高，让功耗估算偏大。触发和告警可能被尖峰误触发，出现假告警。</p>
<p>换档越频繁，污染越多。读数在量程边界附近跳动时最容易换档，那也正是数据最需要准确的时候。</p>
<h2>处理办法</h2>
<p>换档瞬态是物理必然，靠软件消化：</p>
<ul>
<li>固件侧：换档后置零标记稳定期（实测该仪器如此），上位机识别零点后丢弃。</li>
<li>上位机侧：看到通道号变化，跳过过渡帧或过渡采样。</li>
<li>显示侧：对尖峰做合理性校验，明显偏离相邻采样就剔除。</li>
</ul>
<p>帧设计能凑满一包 USB 是巧思，换档瞬态同样值得花心思。测量仪器最怕的，是假数据。</p>
<h2>推断边界</h2>
<p>以上换档行为来自实测抓帧，可信度较高。电压与电流各自独立采样的双 ADC 结构来自反编译代码的片段推断，具体寄存器映射和通道号未 100% 确认（固件用 HAL 抽象层藏了大量细节）。整套机制是逆向分析结果，不来自厂商文档，可能有错。</p>]]></content>
    <category term="嵌入式"/>
    <category term="信号处理"/>
    <category term="低功耗"/>
    <category term="工程调试"/>
  </entry>
  <entry>
    <title>升档降档共用一条阈值，中间再没留死区</title>
    <link href="https://createskyblue.github.io/posts/auto-ranging-hysteresis/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/auto-ranging-hysteresis/</id>
    <published>2026-08-10T00:00:00.000Z</published>
    <updated>2026-08-10T00:00:00.000Z</updated>
    <summary>自动量程在阈值附近来回横跳</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>升档降档共用一条阈值，中间再没留死区</h1>
<p>自动量程就是迟滞触发：读数太高升档，太低降档。升档和降档如果画在同一条线上，读数在阈值附近晃一下，两个档位就会来回横跳。显示难看，噪声被放大，换档瞬态还可能把电路推自激。</p>
<p>办法也只有这一条：升档线和降档线分开，中间留下死区。</p>
<ul>
<li>升档线靠近满量程，比如 95%。当前档快饱和了再换。</li>
<li>降档线靠近见底，比如 10%。当前档灵敏度过剩了再换。</li>
<li>死区必须大于噪声带。噪声是 ±2%，死区就得远大于 4%，否则一次尖峰照样来回踩线。</li>
</ul>
<p><img src="/posts/auto-ranging-hysteresis/hysteresis-deadzone.svg" alt="滞回死区"></p>
<p>量程如果是十倍程，滞回还会再被拉开一截。旧档 95% 升上去，同一物理量在新档只剩 9.5%；要降回来，还得跌到新档 10% 以下，相当于旧档的 1%。物理量要变两个数量级才会反向换档。</p>
<p>换档后再空几个采样不评估，挡住电路建立期的毛刺就够了。参数不用精调，先让死区盖住噪声，横跳自己会停。</p>]]></content>
    <category term="嵌入式"/>
    <category term="系统设计"/>
    <category term="数据采集"/>
    <category term="工程调试"/>
  </entry>
  <entry>
    <title>封装了 EMK850+ 功耗分析仪 MCP，AI可自主调用仪器</title>
    <link href="https://createskyblue.github.io/posts/emk850-mcp-server/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/emk850-mcp-server/</id>
    <published>2026-08-10T00:00:00.000Z</published>
    <updated>2026-08-10T00:00:00.000Z</updated>
    <summary>原厂只有 Windows 上位机：逆向串口协议，把功耗分析仪做成 MCP 服务器接入 AI 自动化</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>把 EMK850+ 功耗分析仪改造成 AI 可调度的自动化测量节点</h1>
<h2>项目背景</h2>
<p>低功耗调试离不开 µA 级电流测量。英加 EMK850+ 只有 Windows 上位机，没有对外接口，功耗数据进不了脚本，批量测试、回归测量、AI 辅助优化全部做不通。</p>
<p>本项目独立逆向其串口协议，用 Python 实现命令行工具与 MCP/REST 服务器，把测量与供电控制变成 HTTP 接口，脚本、CI、AI 客户端可直接调度，形成采集、控制、优化闭环。</p>
<p>「闭源仪器协议逆向 + MCP 服务化」这套做法，可复用到任何只有上位机的台式仪器。</p>
<h2>核心工作</h2>
<ol>
<li><strong>串口协议逆向</strong>：原厂上位机是 .NET 程序，用 ILSpy 反编译直接拿到完整通信协议，帧结构、命令表、换算公式整理成协议文档。</li>
<li><strong>AI 辅助实现</strong>：定义协议规范与验收标准，驱动、CLI 与服务器代码借助 AI 工具生成，帧行为逐项与真机实测比对校验。</li>
<li><strong>流式帧同步</strong>：USB CDC 流式传输不保证按包交付，解析采用逐字节流式帧同步状态机，拆包、粘包、错位下稳定出帧。</li>
<li><strong>MCP/REST 服务化</strong>：单进程同时提供 REST 与 MCP（Streamable HTTP）接口，覆盖测量、采样、供电控制，Claude、Cursor 通过工具发现自动接入。</li>
<li><strong>断电复位唤醒</strong>：可编程电源断电重启，唤醒深度休眠后调试器断连的 MCU。</li>
</ol>
<h2>设备效果</h2>
<p><img src="/posts/emk850-mcp-server/emk850_photo.webp" alt="EMK850+ 低功耗分析仪"></p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">python</span><span style="color:#CE9178"> emk850_analyzer.py</span><span style="color:#CE9178"> power</span><span style="color:#CE9178"> COM19</span></span></code></pre><pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>电压: 4.202 V   电流: 6.940 uA   功耗: 29.20 uW</span></span></code></pre><h2>项目边界</h2>
<ul>
<li>协议为独立分析归纳，与厂商无关，固件升级可能失配</li>
<li>断电通过设 0mV 间接实现，实测测量端仍有约 2.6V，是否真正断电需万用表确认</li>
<li>串口独占，不能与原厂上位机同时使用；空载清零需断开所有负载</li>
</ul>
<p>GitHub 仓库：<a href="https://github.com/createskyblue/Yingjia_EMK850_low-power_analyzer_MCP" target="_blank" rel="noopener noreferrer">Yingjia_EMK850_low-power_analyzer_MCP</a> · <a href="https://github.com/createskyblue/Yingjia_EMK850_low-power_analyzer_MCP/blob/main/docs/EMK850_PROTOCOL.md" target="_blank" rel="noopener noreferrer">协议文档 EMK850_PROTOCOL.md</a></p>]]></content>
    <category term="嵌入式"/>
    <category term="低功耗"/>
    <category term="数据采集"/>
    <category term="通信协议"/>
    <category term="MCP"/>
    <category term="Python"/>
  </entry>
  <entry>
    <title>固件加密形同虚设，解密密钥就写死在解密程序里</title>
    <link href="https://createskyblue.github.io/posts/firmware-encryption-key-flaw/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/firmware-encryption-key-flaw/</id>
    <published>2026-08-10T00:00:00.000Z</published>
    <updated>2026-08-10T00:00:00.000Z</updated>
    <summary>固件包加密了，解密口令却写死在上位机里，拿到程序就能还原明文</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>固件加密形同虚设，解密密钥就写死在解密程序里</h1>
<p>嵌入式行业中，厂商常通过加密保护固件知识产权与设备运行安全，但大量方案从架构设计阶段就陷入同一个误区：把解密密钥与核心逻辑部署在用户完全可控的上位机环境里。</p>
<p>近期分析某测量仪器的固件升级链路时，验证了这一设计的必然失效：服务器下发的固件包全程加密，表面防护逻辑完备，实则解密密钥、盐值、算法参数全部硬编码在 PC 端上位机程序内。抓取加密固件包后，直接复用程序内置的解密流程，即可完整导出明文下位机固件。全程未涉及算法破解与暴力攻击，只是复现了厂商烧录流程的前置步骤。</p>
<h2>失效根源：信任边界错位</h2>
<p>这套方案从架构上注定无法实现真正的固件保护，问题集中在三点：</p>
<ol>
<li>方案采用对称加密，加解密共用同一个密钥。烧录动作由上位机触发执行，密钥必须内置到客户端程序里。</li>
<li>上位机运行在用户完全掌控的通用计算环境中，无论密钥以硬编码、资源隐藏还是动态派生方式存储，都能靠反编译、内存抓取、字符串检索等常规手段提取。</li>
<li>固件头部的初始化向量、盐值都以明文存储，完整性只靠哈希校验，没有数字签名机制。既挡不住固件提取，也防不了恶意篡改。</li>
</ol>
<p>此类设计中的&quot;加密&quot;，本质只是一层文件级混淆，只能拦住无意翻看临时目录的普通用户，对有明确目标的逆向分析者不构成有效防护。</p>
<p><img src="/posts/firmware-encryption-key-flaw/trust-boundary.svg" alt="信任边界"></p>
<h2>正确防护：把信任根锚定在设备端</h2>
<p>真正可靠的固件保护，核心原则只有一条：密钥与解密逻辑始终不脱离受保护的硬件环境。上位机只做数据传输通道，不参与任何解密与校验。在此基础上，按下位机硬件能力选型加密方案。</p>
<h3>硬件支持加密模块：优先硬件级加解密</h3>
<p>若下位机芯片内置 AES、DES 等硬件加密外设，或带 HSM/SE 安全模块，优先用硬件加速方案：</p>
<ul>
<li>服务端用对称算法完成固件加密，密钥只存在于厂商内部可信环境，绝不下发上位机。</li>
<li>解密由芯片硬件模块完成，密钥存在芯片专用安全区域或一次性可编程区域，外部无法读取。</li>
<li>硬件级加密既保障加解密性能，也提高密钥泄露的门槛。</li>
</ul>
<h3>硬件无专用模块：用轻量软件加密</h3>
<p>若下位机资源有限、无硬件加密能力，可选 ChaCha20 这类轻量高效的软件加密：</p>
<ul>
<li>服务端用 ChaCha20 加密固件，密钥同样只保存在服务端。</li>
<li>设备端用软件实现解密，密钥固化在芯片 Flash 内部，开启读保护与代码访问限制，阻止外部读取。</li>
<li>该方案不需要额外硬件支持，资源占用低，能在绝大多数通用 MCU 上落地。</li>
</ul>
<p><img src="/posts/firmware-encryption-key-flaw/key-location.svg" alt="密钥存放边界"></p>
<h3>进阶安全加固</h3>
<p>在解密逻辑下移的基础上，按硬件条件叠加防护：</p>
<ul>
<li>固件增加数字签名校验，设备端烧录前先验签再解密，防恶意固件注入。</li>
<li>开启芯片安全启动链，逐级校验固件完整性，构建从 Bootloader 到应用层的信任链。</li>
<li>启用芯片读保护、代码保护，防止通过调试接口直接读取固件。</li>
</ul>
<h2>结语</h2>
<p>固件加密的安全上限，从来不由加密算法的复杂度决定，由密钥的存放边界决定。</p>
<p>把解密密钥交到用户可控的上位机手里，再复杂的算法也只是形同虚设。把信任根牢牢锚定在设备端，哪怕是轻量加密方案，也能构建有效的防护屏障。</p>]]></content>
    <category term="嵌入式"/>
    <category term="系统设计"/>
    <category term="Crypto API"/>
    <category term="工程调试"/>
  </entry>
  <entry>
    <title>错位、丢尾、永久失步：三态状态机把串口字节流拼成完整帧</title>
    <link href="https://createskyblue.github.io/posts/serial-stream-state-machine/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/serial-stream-state-machine/</id>
    <published>2026-08-10T00:00:00.000Z</published>
    <updated>2026-08-10T00:00:00.000Z</updated>
    <summary>低功耗分析仪上位机卡死溯源：64 字节整块读的缺陷与流式状态机替代方案</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>错位、丢尾、永久失步：三态状态机把串口字节流拼成完整帧</h1>
<p>EMK850+ 是一台低功耗分析仪：经 USB 虚拟串口（115200）连电脑，持续回传电流/电压采样流，nA 到 mA 自动换挡。低功耗测试的常态是让被测设备长时间挂机，看完整的功耗曲线，采样数据只能经配套上位机读取。</p>
<p>但这个上位机撑不住挂机：连续运行接近 1 小时就卡死。我反编译了它的接收代码定位缺陷，再重写了一个流式解析器替换。</p>
<ul>
<li><strong>问题</strong>：上位机按 64 字节整块读取，流中间丢一个字节，之后每帧永久错位；另一处阻塞读没有超时，USB 断流时线程挂死。</li>
<li><strong>我的工作</strong>：反编译厂商上位机（.NET 4.7.2，ilspycmd）取证；搭两级线缆衰减链路复现故障；用 Python 重写解析器，三态状态机逐字节同步。</li>
<li><strong>结果</strong>：校验失败每次只丢一个字节，自动回到帧头扫描；实测约 400 帧/秒持续采样，无失步、无挂死。</li>
</ul>
<h2>串口字节流没有帧边界</h2>
<p>串口物理层按字节连续传输，协议层把若干字节聚成帧。帧边界只能由帧头特征和长度字段给出：本协议帧头是 <code>0x33</code>，第 3 字节 <code>len</code> 声明载荷长度。接收端拿到的是不断流出的字节序列，线上没有&quot;这条消息结束&quot;的信号。</p>
<p>按固定长度切块因此是一种强假设：缓冲区起点恰好是帧起点。这个假设不由协议保证。接收端读得快、事件在帧边界触发，假设成立；读慢一拍、事件在帧中间触发，假设失效。</p>
<h2>反编译取证：上位机接收的两类缺陷</h2>
<p>反编译 EMK850+ 上位机（.NET 4.7.2，ilspycmd），主接收函数 <code>MainWindow.SysReceiveData</code> 按 64 字节整块读取：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">while</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">serialPort</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">BytesToRead</span><span style="color:#D4D4D4"> >= </span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#9CDCFE">    serialPort</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">Read</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">array</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#4EC9B0">    Protocol</span><span style="color:#9CDCFE"> prot</span><span style="color:#D4D4D4"> = (</span><span style="color:#4EC9B0">Protocol</span><span style="color:#D4D4D4">)</span><span style="color:#9CDCFE">Utils</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">BytesToStruct</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">array</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">typeof</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">Protocol</span><span style="color:#D4D4D4">));</span></span>
<span class="line"><span style="color:#DCDCAA">    HandleProtocol</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">prot</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">array</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">serialPort</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">BytesToRead</span><span style="color:#D4D4D4"> > </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4"> &#x26;&#x26; </span><span style="color:#9CDCFE">serialPort</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">BytesToRead</span><span style="color:#D4D4D4"> &#x3C; </span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#9CDCFE">    serialPort</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">Read</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">array</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">serialPort</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">BytesToRead</span><span style="color:#D4D4D4">);   </span><span style="color:#6A9955">// 尾包直接丢弃</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>主路径上每次 <code>Read(array, 0, 64)</code> 都能凑满 64 字节，读取本身从不失败。真正的脆弱点是：这 64 字节未必等于完整一帧。字节流中间一旦出错，读到的 64 字节就变成跨帧的错位窗口，内容全是错的。</p>
<p>接收逻辑的缺陷分两类。第一类是错位：整块读取有三个具体缺陷点。</p>
<ol>
<li>无帧头扫描、无重新同步。主接收路径拿到 64 字节直接按 <code>cmd</code> 分发，不校验 <code>0x33</code>；采样数据分支（<code>cmd</code> 33/132）同样不看帧头。个别配置响应分支里虽有 <code>buffer[0]==51</code> 的判断，失败仅静默丢帧，不构成重新同步。对齐一错，后面每帧都错。</li>
<li>尾包无条件丢弃。每个接收事件结束时缓冲被清空，不足 64 字节的残段直接丢掉（源码日志原文就是&quot;抛掉 N 字节数据&quot;），既丢数据又加重失步。</li>
<li>结构体盲解。<code>Utils.BytesToStruct</code> 直接 <code>Marshal.Copy</code> 64 字节；错位后 <code>cmd</code> 是垃圾，按它分发即静默误判。</li>
</ol>
<p>第二类是挂死：无超时的阻塞读。<code>calWindow</code> 直接 <code>Read(array, 0, 64)</code> 不检查 <code>BytesToRead</code>，且全代码未设 <code>ReadTimeout</code>（.NET 默认无限超时）。USB 流中断、凑不满 64 字节时，该事件线程永久挂起。错位导致数据损坏，阻塞导致挂死，两类缺陷独立。</p>
<h2>一次错位引发的永久失步</h2>
<p>每次 <code>Read(array, 0, 64)</code> 都能凑满 64 字节。而且正常情况下对齐是白捡的：设备一帧正好 64 字节，经 USB 送达时一帧一个包；USB 自带校验和重传，坏包不会交付半个，丢也是整包丢，而整包丢失不破坏对齐，只是数据少一帧。但这只是观察到的行为，不是契约：虚拟串口对应用层就是一根字节流管道，操作系统和驱动从不承诺按包交付，经过驱动缓冲、集线器、隔离器之后，<code>BytesToRead</code> 出现任何字节数都合法，换个电脑、换个驱动版本行为就可能变。把解析正确性押在传输层的实现细节上，本身就是隐患。真正会坏的是字节流脱离 64 网格的情况：设备复位瞬态往流里吐杂讯、固件发送被截断，字节数不再是 64 的整数倍，流的对齐从此永久偏移。</p>
<p><img src="/posts/serial-stream-state-machine/desync-timeline.svg" alt="错位时间线"></p>
<p>读窗口固定是 64 字节，帧变短也不会让窗口调整位置。字节丢在某个帧的中间时，这一帧变短，它后面的所有帧整体前移。上位机仍按原来的 64 字节位置读，于是每个读窗口都跨在两帧之间：<code>0x33</code> 落在错误位置，<code>cmd</code> 读错，整帧报废。块读没有任何手段把偏移找回来，错位持续到重连。</p>
<p>错位一旦发生，代码里没有任何路径把同步找回来：不扫描帧头，不清空缓冲从下一字节继续。</p>
<p>实测复现：我搭的测试链路是 电脑 → USB 延长线 → USB 隔离线 → 分析仪，两级线缆叠加让 USB 信号衰减、链路更容易出错。设备经该链路连续挂机，官方上位机接近 1 小时卡死。该设备是 STM32 USB CDC 虚拟串口，对连续流没有硬件流控，链路劣化让设备反复复位：复位杂讯让字节流脱离 64 网格造成错位，断流撞上无超时的阻塞读造成挂起，错位无法自愈，挂起不可恢复，最终都表现为卡死。</p>
<p>要公允地说一句：正常链路、正常固件下，这套块读不会出错，它的对齐是传输层免费提供的。缺陷不在&quot;会出错&quot;，在&quot;出错时零防御、零感知&quot;：概率低，但现场挂机总会撞上，撞上就是永久失步，软件自己还毫无察觉。</p>
<h2>三态状态机：等帧头、收头部、收载荷</h2>
<p>流式解析的核心：任何时刻只维护少量状态，逐字节推进，校验失败只丢弃当前这一个字节，然后继续。</p>
<p><img src="/posts/serial-stream-state-machine/stream-state-machine.svg" alt="三态状态机"></p>
<p>三个状态（括号里是代码里的状态名）：</p>
<ul>
<li>等帧头（<code>WAIT_MAGIC</code>）：逐字节扫描，遇到 <code>0x33</code> 进入收头部。其他字节直接丢弃。</li>
<li>收头部（<code>HEADER</code>）：收 <code>cmd / len / seq</code> 三字节，校验 <code>len &lt;= 60</code>。通过进入收载荷；失败丢弃当前这一字节，回等帧头继续。</li>
<li>收载荷（<code>PAYLOAD</code>）：收满整帧 64 字节（<code>len</code> 之外自动补零），出帧并回等帧头。帧级不设累加和校验，校验只做在收头部的 <code>len &lt;= 60</code>。</li>
</ul>
<p>三条纪律：</p>
<ol>
<li>每次只丢一个字节，绝不整帧丢弃。</li>
<li>校验失败不清空缓冲，从下一字节继续扫描。</li>
<li>收完整帧回到等帧头，处理流中下一帧。</li>
</ol>
<p>状态机解决错位，超时解决挂起。串口读必须带超时：项目实现用 <code>serial.Serial(port, 115200, timeout=0.2)</code>，读不到就超时返回，USB 断流时只是没有数据，不会永久阻塞。</p>
<p>项目实现见 <code>tools/emk850_proto.py</code> 两段式解析：<code>FrameDecoder</code> 逐字节状态机出 64 字节帧，<code>ProtocolParser</code> 按 <code>cmd</code> 分发。实测高速数据流约 400 帧/秒持续采样，无失步。</p>
<h2>流式解析的适用边界</h2>
<p>整块读取在某些条件够用：设备固定节奏发满定长帧、接收端跟得上、错位后重连成本可接受。这类场景块读代码简单，是务实选择。</p>
<p>需要流式解析的场景：复位瞬态吐出杂讯、半帧、多帧交错，或数据速率高、接收端可能瞬时滞后。这类场景任何一次错位都会让块读永久失步。</p>
<p>本协议判断完整帧的要素是帧头字节 + 长度字段；累加和只用于配置大块数据的重组，帧级不校验。换其他协议，把等帧头状态的匹配字节换成对应帧头即可，三态结构不变。</p>]]></content>
    <category term="嵌入式"/>
    <category term="通信协议"/>
    <category term="嵌入式调试"/>
    <category term="工程调试"/>
  </entry>
  <entry>
    <title>直流电机 PWM 载波频率优化：扫频寻找最低能耗点</title>
    <link href="https://createskyblue.github.io/posts/pwm-carrier-frequency-power/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/pwm-carrier-frequency-power/</id>
    <published>2026-08-09T00:00:00.000Z</published>
    <updated>2026-08-09T00:00:00.000Z</updated>
    <summary>直流电机 PWM 载波频率优化：扫频寻找最低能耗点</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>直流电机 PWM 载波频率优化：通过扫频实验寻找最低输入能耗点</h1>
<h2>背景</h2>
<p>电池供电的直流电机执行器经霍尔脉冲反馈位置/转速，并借零点霍尔信号回零；控制器以&quot;位置外环 + 速度 PI 内环 + 速度前馈&quot;完成定行程运动。鉴于续航与噪声约束，PWM 载波频率需权衡开关损耗、绕组纹波及噪声，因此对载波频率做了系统性扫频寻优。</p>
<h2>工作内容与成果</h2>
<ul>
<li><strong>搭建 AI 驱动的自动化测试环境</strong>：逆向低功耗分析仪的通信协议，将其封装为 MCP（Model Context Protocol）工具，打通固件烧录与串口指令下发链路，使 AI 能够自主完成固件烧录、运动控制指令发送和能耗数据采集。人工仅负责初始环境搭建与早期联调监督，之后由 AI 无人值守自动完成全频带扫频测试。</li>
<li><strong>固定行程测量能耗</strong>：由低功耗分析仪同步采集固定行程内的输入电压与输入电流，对瞬时输入功率积分得到行程输入能耗；每频点重复 10 次，被测机全程常唤醒以消除低功耗睡眠唤醒干扰。</li>
<li><strong>扫频寻求最优参数</strong>：实测固定行程输入能耗随载波频率呈 U 型曲线，定位本机最低能耗点，其测量中心值较原设定频点降低约 <strong>11.5%</strong>；高频端能耗明显抬升，与频率相关损耗增加的趋势一致。</li>
</ul>
<h2>实测扫频结果</h2>
<p><img src="/posts/pwm-carrier-frequency-power/ucurve.svg" alt="直流电机 PWM 载波频率 vs 固定行程能耗"></p>
<h2>损耗机理</h2>
<p>在电流连续、平均电流近似不变，且 PWM 周期远小于电机电气时间常数 <code>τe = L/R</code> 的近似下，电流纹波满足 <code>ΔI ∝ 1/f</code>，由纹波引入的额外绕组铜损可写成 <code>P_ripple ≈ k₂/f²</code>。当单次开关能量近似不变时，驱动器开关损耗可写成 <code>P_sw ≈ k₁f</code>。加上与载波频率近似无关的基线损耗 <code>P₀</code>，总损耗可近似表示为 <code>P_total ≈ P₀ + k₁f + k₂/f²</code>；该模型用于解释 U 型趋势，不代表本次扫频完成了各项损耗的独立测量。本文以扫频实测结果为准，不将损耗分解为独立项进行数学拟合。</p>
<p><img src="/posts/pwm-carrier-frequency-power/loss-model.svg" alt="开关损耗与纹波损耗叠加的 U 型总损耗（理想化）"></p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="低功耗"/>
    <category term="电机"/>
  </entry>
  <entry>
    <title>ESP32-C3 httpd 并发请求第二个必被 ECONNRESET，前端串行队列根治</title>
    <link href="https://createskyblue.github.io/posts/esp32c3-httpd-single-connection/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp32c3-httpd-single-connection/</id>
    <published>2026-08-08T00:00:00.000Z</published>
    <updated>2026-08-08T00:00:00.000Z</updated>
    <summary>并发 HTTP 第二个必被 ECONNRESET、刷新后状态不同步：请求队列串行化</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>ESP32-C3 httpd 并发请求第二个必被 ECONNRESET，前端串行队列根治</h1>
<p>一块 ESP32-C3 电源控制板，Web 前端通过 HTTP API 控制电源，WebSocket 推实时采样曲线。打开输出后刷新页面，输出按钮显示「已关闭」，实际输出保持开启。后端接口返回的状态正确，问题出在页面加载时前端同时发出多个 HTTP 请求：C3 的 httpd 只处理第一个，其余连接全部被重置。本文记录复现过程、原因和前端串行化修复。</p>
<h2>现象：刷新后输出按钮显示关闭，输出实际开启</h2>
<p>打开输出后刷新网页。输出按钮显示「已关闭」，电源输出实际保持开启。按钮状态没有同步到真实状态。</p>
<h2>复现：并发 HTTP 请求第二个必被 ECONNRESET</h2>
<p>先排除后端。用 curl 直查 <code>/api/getSystemStatus</code>，返回 <code>is_output_on: 1</code>，与实际状态一致。后端是实时查询 PSU，返回值正确。</p>
<p>再看前端。页面加载时同时执行三个接口请求（get_memory_preset、getSystemStatus、get_output_status），并建立 WebSocket 连接。用 node 脚本模拟这个行为：</p>
<ul>
<li>同时发两个请求，第一个 200，第二个报 <code>read ECONNRESET</code>；连测两次结果一致。</li>
<li>同时发三个快速接口（get_config、debug.json、network.json），也只成功第一个。</li>
<li>把请求改成串行，三个全部成功（getSystemStatus 1559ms、get_output_status 1904ms、get_memory_preset 920ms）。</li>
</ul>
<p>结论：这台 C3 的 httpd 同时只处理一个请求，并发到达的其余连接被重置。</p>
<p><img src="/posts/esp32c3-httpd-single-connection/img/request-serialization.svg" alt="并发与串行请求对比"></p>
<h2>原因：单任务 httpd 加 PSU 慢查询占住处理循环</h2>
<p>esp_http_server 是单任务模型。httpd 配置 <code>max_open_sockets=7</code>（C3 上限），<code>lru_purge_enable=true</code>。处理一个请求期间，并发到达的连接得不到处理，被 httpd 关闭，客户端收到 ECONNRESET。快速接口并发也失败，重置来自 httpd 单任务处理能力本身，与处理器快慢无关。</p>
<p>PSU 串口查询把问题放大。AST2/AST4/AST5 每条查询要约 1.5s（仪器把应答拆成多个小块、间隔约 300ms 吐出）。慢查询占住 httpd 任务时，并发到达的连接更明显被重置。</p>
<p>前端 <code>.catch</code> 把失败吞掉，界面停在初始值。<code>getSystemStatus</code> 失败后 <code>isOutput</code> 保持初始 <code>false</code>，按钮显示「已关闭」。</p>
<h2>修复：前端请求队列串行化</h2>
<p>让所有 HTTP 请求进同一个队列，前一个完成（成功或失败）再发下一个，任何时刻只有一个请求在飞。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">var</span><span style="color:#9CDCFE"> apiQueue</span><span style="color:#D4D4D4">=</span><span style="color:#4EC9B0">Promise</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">resolve</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#569CD6">function</span><span style="color:#DCDCAA"> api</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">path</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">opts</span><span style="color:#D4D4D4">){</span></span>
<span class="line"><span style="color:#569CD6">  var</span><span style="color:#DCDCAA"> run</span><span style="color:#D4D4D4">=</span><span style="color:#569CD6">function</span><span style="color:#D4D4D4">(){</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#4EC9B0"> Promise</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">race</span><span style="color:#D4D4D4">([</span></span>
<span class="line"><span style="color:#DCDCAA">      fetch</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">path</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">Object</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">assign</span><span style="color:#D4D4D4">({</span><span style="color:#9CDCFE">headers:</span><span style="color:#D4D4D4">{</span><span style="color:#CE9178">'Content-Type'</span><span style="color:#9CDCFE">:</span><span style="color:#CE9178">'application/json'</span><span style="color:#D4D4D4">}}, </span><span style="color:#9CDCFE">opts</span><span style="color:#D4D4D4">||{}))</span></span>
<span class="line"><span style="color:#D4D4D4">        .</span><span style="color:#DCDCAA">then</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">function</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">r</span><span style="color:#D4D4D4">){</span><span style="color:#C586C0">return</span><span style="color:#9CDCFE"> r</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">json</span><span style="color:#D4D4D4">()}),</span></span>
<span class="line"><span style="color:#569CD6">      new</span><span style="color:#4EC9B0"> Promise</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">function</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">_</span><span style="color:#D4D4D4">,</span><span style="color:#9CDCFE">rej</span><span style="color:#D4D4D4">){</span><span style="color:#DCDCAA">setTimeout</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">function</span><span style="color:#D4D4D4">(){</span><span style="color:#DCDCAA">rej</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">new</span><span style="color:#DCDCAA"> Error</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">'timeout'</span><span style="color:#D4D4D4">))},</span><span style="color:#B5CEA8">10000</span><span style="color:#D4D4D4">)})</span></span>
<span class="line"><span style="color:#D4D4D4">    ]).</span><span style="color:#DCDCAA">catch</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">function</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">e</span><span style="color:#D4D4D4">){</span><span style="color:#DCDCAA">showMsg</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">'请求失败: '</span><span style="color:#D4D4D4">+</span><span style="color:#9CDCFE">path</span><span style="color:#D4D4D4">,</span><span style="color:#CE9178">'err'</span><span style="color:#D4D4D4">); </span><span style="color:#C586C0">throw</span><span style="color:#9CDCFE"> e</span><span style="color:#D4D4D4">});</span></span>
<span class="line"><span style="color:#D4D4D4">  };</span></span>
<span class="line"><span style="color:#569CD6">  var</span><span style="color:#9CDCFE"> p</span><span style="color:#D4D4D4">=</span><span style="color:#9CDCFE">apiQueue</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">then</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">run</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">run</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#9CDCFE">  apiQueue</span><span style="color:#D4D4D4">=</span><span style="color:#9CDCFE">p</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">catch</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">function</span><span style="color:#D4D4D4">(){});</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#9CDCFE"> p</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>要点：</p>
<ol>
<li><code>apiQueue</code> 保存前一条请求的完成状态，新的 <code>api()</code> 用 <code>apiQueue.then(run, run)</code> 排队，队列空闲后才执行。</li>
<li><code>apiQueue=p.catch(...)</code> 吞掉错误，队列链不断，失败的请求不会卡住后续请求。</li>
<li>10s 超时兜底：PSU 查询挂死时队列不会被永久卡住。</li>
</ol>
<p>WebSocket 握手也是 HTTP 连接。页面加载时先走完状态请求，队列清空后再 <code>connectWS()</code>，避免握手和 API 抢连接被重置：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">syncStatus</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">loadPreset</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#9CDCFE">apiQueue</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">then</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">connectWS</span><span style="color:#D4D4D4">);</span></span></code></pre><p>加载顺序上先查 <code>getSystemStatus</code>，输出按钮在首个请求完成（约 1.6s）后即反映真实状态。串行加载三个查询全部成功，状态同步恢复。</p>
<h2>边界：嵌入式前端的并发策略</h2>
<p>串行化修复了这台 C3 的并发重置。前端不能假设设备有浏览器一样的并行请求能力，嵌入式 httpd 场景按这几条控制：</p>
<ol>
<li>前端请求串行发送，任何时刻只允许一个请求在飞。慢查询占住 httpd 时并发请求全被重置。</li>
<li>合并接口、减少往返。多个状态能一次查询就一次返回，减少慢查询次数。</li>
<li>慢读取在后端做缓存或批量，让 httpd 处理器尽快返回，缩短占住单任务的时间。</li>
<li>实时数据走 WebSocket 批量推送（配合降采样），避免高频 HTTP 轮询挤占连接池。</li>
</ol>]]></content>
    <category term="嵌入式"/>
    <category term="ESP32"/>
    <category term="ESP-IDF"/>
    <category term="前端"/>
    <category term="性能优化"/>
  </entry>
  <entry>
    <title>软件 I2C 采样：中断只发事件、任务做读取，2kHz 跑不起来的 CPU 边界</title>
    <link href="https://createskyblue.github.io/posts/esp32c3-soft-i2c-sampling-architecture/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp32c3-soft-i2c-sampling-architecture/</id>
    <published>2026-08-08T00:00:00.000Z</published>
    <updated>2026-08-08T00:00:00.000Z</updated>
    <summary>位总线 I2C 读取移出中断：事件通知加高优先级任务，GPIO 寄存器直写提速读取</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>软件 I2C 采样：中断只发事件、任务做读取，2kHz 跑不起来的 CPU 边界</h1>
<p>任务背景：为电源控制板搭建 INA228 采样管线，在 ESP32-C3（160MHz）上用位总线 I2C 连续采样电压电流。目标是把采样率从 1kHz 提升到 2kHz。本文说明采样管线为何采用「中断只发事件、高优先级任务做读取」的架构，以及 2kHz 受三条约束限制、最终稳定在 1kHz 的边界。</p>
<h2>采样管线：中断→事件→高优先级任务→软件 I2C</h2>
<p><img src="/posts/esp32c3-soft-i2c-sampling-architecture/img/architecture.svg" alt="INA228 采样管线架构"></p>
<p>esp_timer 以 1kHz 触发采样，回调里只做一件事：<code>xTaskNotifyGive</code> 向读取任务发事件。真正的位总线 I2C 读取、ADC 换挡、样本入队，全部在一个高优先级实时采样任务里完成：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> sampling_timer_cb</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">arg</span><span style="color:#D4D4D4">) {</span><span style="color:#6A9955">           /* esp_timer 任务上下文 */</span></span>
<span class="line"><span style="color:#DCDCAA">    xTaskNotifyGive</span><span style="color:#D4D4D4">(s_reader_task_handle);</span><span style="color:#6A9955">            /* 只发事件，不做 I2C */</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> ina228_reader_task</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">parameter</span><span style="color:#D4D4D4">) {</span><span style="color:#6A9955">     /* 读取任务 */</span></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        ulTaskNotifyTake</span><span style="color:#D4D4D4">(pdTRUE, portMAX_DELAY);</span></span>
<span class="line"><span style="color:#4EC9B0">        ina228_sample_t</span><span style="color:#D4D4D4"> s;</span></span>
<span class="line"><span style="color:#DCDCAA">        ina228_read_sample_fast</span><span style="color:#D4D4D4">(&#x26;s);</span><span style="color:#6A9955">                  /* 慢的位总线读取 */</span></span>
<span class="line"><span style="color:#DCDCAA">        xQueueSendToBack</span><span style="color:#D4D4D4">(par20_sensor_queue, &#x26;s, </span><span style="color:#B5CEA8">0u</span><span style="color:#D4D4D4">);</span><span style="color:#6A9955"> /* 样本入队 */</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>为什么不直接让 esp_timer 回调读 I2C？这个候选方案在 2kHz 下被看门狗否决：回调每次要跑 ~1ms，esp_timer 任务连续被长回调占住，IDLE 任务饿死，任务看门狗复位设备。esp_timer 任务还托管显示、WiFi 等其它定时器，把 I2C 读取塞进去等于用最忙的一条线程干最慢的活。</p>
<p>读取任务取高优先级保证采样实时性：事件通知一到就立即执行位总线读取，不被其它业务线程拖慢。下游一个 LTTB 降采样消费者每 200ms 取 10 个代表点（50 点/秒），只推电压和电流，经 WebSocket 给前端图表。</p>
<h2>软件 I2C 而非硬件 I2C：400kHz 上限</h2>
<p>不用 C3 的硬件 I2C 控制器，原因是硬件 I2C 上限 400kHz；此外这块板上原生 I2C 控制器曾经把状态机卡死在临界区（代码注释有记录），所以沿用原固件的位总线方案。</p>
<p>位总线理论上可以更快：原 SoftWire 声称 3MHz。实际能跑多快，取决于 GPIO 操作的开销，而不是协议本身。</p>
<h2>GPIO 寄存器直写：读取从 1ms 降到 240µs</h2>
<p>驱动函数 <code>gpio_set_level()</code> / <code>gpio_get_level()</code> 带校验开销，实测把位总线压到 ~100-200kHz，一次 6 寄存器全读要 ~1ms。</p>
<p>改成直接写 GPIO 寄存器：<code>out_w1ts</code>/<code>out_w1tc</code> 置位/清位，<code>in</code> 读输入。同样的位翻转逻辑，单次读取降到 ~240µs。</p>
<p>实测 SCL 频率：按 I2C 协议位数换算，快速读取（216 个 SCL 时钟）240µs ≈ 0.9MHz，全量读取（360 时钟）340µs ≈ 1.06MHz。位总线实际跑在 Fast Mode Plus（1MHz）档位，超过硬件 I2C 的 400kHz 上限约 2.5 倍。</p>
<p>顺带两个小优化：ADC 范围缓存（不每次读 CONFIG 寄存器），energy/charge 每 10 次采样才读一次（LCD 和 WebSocket 都不用这两个字段）。</p>
<h2>2kHz 的三重约束与 1kHz 的 CPU 边界</h2>
<p>要上 2kHz，位总线读取必须同时满足三条约束：</p>
<ol>
<li><strong>读取不能占用托管周期回调的任务</strong>：esp_timer 任务承载显示、网络等所有周期回调。若把 ~1ms 的位总线读取放进回调，2kHz（500µs 周期）下回调占满 esp_timer 任务，其它定时器被饿死，IDLE 无法喂看门狗 → 设备复位。慢读取必须移出 esp_timer 任务。</li>
<li><strong>读取耗时必须小于采样周期</strong>：读取交回独立任务后，未优化的位总线一次全读 ~1ms，仍大于 500µs 周期。任务连续运转、无法让出 CPU → 看门狗。读取必须压到周期以内。</li>
<li><strong>读取 CPU 占用不能挤占网络业务</strong>：位总线优化后读取 ~240µs，2kHz 下读取任务占 ~50% CPU。持久 WebSocket 连接下网页加载被拖到 2.7 秒。</li>
</ol>
<p>1kHz（周期放宽到 1000µs，读取占 ~24% CPU）同时满足三条，网页恢复正常（~0.4 秒）。这是 C3（160MHz，已是上限）+ 位总线 I2C 的实用边界。要真正上 2kHz，只能换硬件 I2C 控制器，把读取从任务里挪进硬件。</p>
<h2>架构边界与移植结论</h2>
<p>采样管线的正确形态：中断只发事件，慢的位总线读取交回一个高优先级任务。位总线提速靠 GPIO 寄存器直写，而不是带校验的驱动函数。2kHz 对这台 C3 不成立，1kHz 是 CPU 占用允许的极限；要更高采样率，需要换掉位总线方案本身。</p>]]></content>
    <category term="嵌入式"/>
    <category term="ESP32"/>
    <category term="中断"/>
    <category term="传感器"/>
    <category term="性能优化"/>
  </entry>
  <entry>
    <title>墨水屏高刷的秘密：Caster 给每个像素配独立计时器</title>
    <link href="https://createskyblue.github.io/posts/eink-caster-high-refresh/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/eink-caster-high-refresh/</id>
    <published>2026-08-06T00:00:00.000Z</published>
    <updated>2026-08-06T00:00:00.000Z</updated>
    <summary>如何用 FPGA 实现墨水屏高刷</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>墨水屏高刷的秘密：Caster 给每个像素配独立计时器</h1>
<blockquote>
<p><strong>声明：</strong> Modos-Labs 的 Glider 与 Caster 是开源项目，作者为 Wenting Zhang（GitHub: Modos-Labs）。本文是阅读该项目源码和文档后整理的学习笔记，用于理解墨水屏高刷的工程实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p><img src="/posts/eink-caster-high-refresh/cover.svg" alt="封面：每个像素一个独立计时器"></p>
<p>项目仓库：<a href="https://github.com/Modos-Labs" target="_blank" rel="noopener noreferrer">github.com/Modos-Labs</a>（Glider 开源墨水屏显示器 / Caster Verilog EPD 控制器）</p>
<h2>微胶囊电泳与双稳保持</h2>
<p><img src="/posts/eink-caster-high-refresh/microcapsule.svg" alt="微胶囊：电场驱动黑白双色颗粒"></p>
<p>墨水屏每个像素由无数微胶囊组成，胶囊内悬浮着带相反电荷的黑白颜料颗粒，浸泡在透明油中。给像素施加正电压，一种颜色的颗粒被吸向观察面、另一种沉底；反压则相反。寻址使用 2D TFT 阵列加电场，行列扫描方式与 TN/IPS 液晶同源。</p>
<p>与液晶的本质差别在保持机制：液晶易失，撤掉电场后分子松弛回原点，必须每帧持续刷新并耗电；墨水屏双稳，驱动完成撤场后图像自行保持，零场零功耗。Kindle 关机仍显封面即源于此。</p>
<h2>控制器按波形表逐帧驱动</h2>
<p>真实驱动并非恒定直流电压。为得到干净灰度，控制器从波形表（waveform LUT）读出一串时序脉冲，逐帧发给每个像素，每像素跑自己的状态机直到抵达目标灰阶。</p>
<p>颗粒在油中电泳迁移需要时间，单次黑白翻转约 9 个驱动帧、物理上约 150ms。这一段是流体迁移下限，控制器无法突破。控制器还要做 DC 平衡、整屏闪刷清残影、防过驱欠驱。因此刷新是控制器主动逐帧编排的过程，并非面板自主完成。</p>
<h2>全局计数器造成的帧率排队</h2>
<p><img src="/posts/eink-caster-high-refresh/arch-compare.svg" alt="架构对比：全局时间线 vs 每像素时间线"></p>
<p>普通 EPD 控制器（EPDC）用一条全局计数器查波形表，整屏共用一条时间线。上一次整屏刷新未走完，新画面不受理。一次刷新约 100ms，把图像可受理速率钉在约 10Hz。这 100ms 里颗粒早已停下，延迟纯属架构排队。</p>
<p>用户感知的卡顿多来自这层排队，并非 150ms 物理迁移。</p>
<h2>每像素局部计数器与早取消</h2>
<p><img src="/posts/eink-caster-high-refresh/early-cancel.svg" alt="早取消：驱动半途反悔按已走比例算返程"></p>
<p>Caster 删掉全局计数器，给每个像素在自己的显存里塞一个 6-bit 局部计数器，各自独立记帧序、各自查波形。</p>
<p>早取消（early cancellation）：像素驱动到一半目标变了，不必先走完全程再掉头。控制器按已走比例重算返程帧数（csr_b2wframe - pixel_framecnt + 1），省下的正是排队时间。</p>
<p>混合灰阶：变化时用 fast-binary 以 85Hz 跟手，静止约 1 秒（AUTOLUT_QUIET_FRAMES=60）后自动用 LUT 重绘 16 级干净灰阶。</p>
<p>FPGA 在此处的本职是并行与确定性低延迟：每像素独立状态机为纯组合逻辑，4 像素/时钟，抖动为零；视频流直通处理低于 20µs，因此走 DVI/DP，不走 USB/SPI。每像素每帧读改写 16bit 状态，1080p 约需 540MB/s 带宽，板上因此挂 DDR3-800。</p>
<p>硬件并不夸张：仅用 Spartan-6 LX16 的 1704 个触发器。物理 150ms 没有突破，高刷消掉的是卡顿感：输入零排队、每像素随时掉头、停下才精绘。看书场景下这套优化无收益，当显示器才需要。</p>]]></content>
    <category term="嵌入式"/>
    <category term="系统设计"/>
    <category term="性能优化"/>
  </entry>
  <entry>
    <title>u8g2-js：打通单色屏开发 Web 仿真的最后一公里</title>
    <link href="https://createskyblue.github.io/posts/epaper-sensor-trend/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/epaper-sensor-trend/</id>
    <published>2026-08-06T00:00:00.000Z</published>
    <updated>2026-08-06T00:00:00.000Z</updated>
    <summary>U8g2-Js 开发单色屏反复烧录太慢，我造浏览器仿真给 AI 闭环</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>U8g2-Js 为开发单色屏，我给 AI 造了浏览器仿真环境</h1>
<iframe id="epaper-demo-frame" data-auto-height="true" scrolling="no" src="https://createskyblue.github.io/epaper-sensor-trend-demo/" title="墨水屏传感器趋势交互演示" style="width: 100%; height: 560px; border: 1px solid #dbe2ec; border-radius: 8px; background: #f5f7fb; overflow: hidden;"></iframe>
<script>
(() => {
  const frame = document.getElementById('epaper-demo-frame');
  const measure = () => {
    let h = 0;
    try {
      const doc = frame.contentDocument || frame.contentWindow.document;
      const app = doc.querySelector('#app');
      // demo 的 body 用 min-height:100vh，直接量 body 会触发高度反馈循环；改量内容容器 #app 再补 body 上下 padding(24*2)
      const pad = 48;
      h = app ? (app.getBoundingClientRect().height + pad)
              : Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
    } catch (e) { return; }
    if (h > 0) frame.style.height = `${Math.ceil(h) + 8}px`;
  };
  const schedule = () => requestAnimationFrame(() => requestAnimationFrame(measure));
  frame.addEventListener('load', () => {
    schedule();
    try {
      const doc = frame.contentDocument;
      const app = doc && doc.querySelector('#app');
      if (app && window.ResizeObserver) new ResizeObserver(schedule).observe(app);
    } catch (e) {}
  });
  window.addEventListener('resize', schedule);
})();
</script>

<p><a href="https://createskyblue.github.io/epaper-sensor-trend-demo/">打开交互 Demo</a></p>
<h2>单色屏开发卡在 AI 看不见屏幕</h2>
<p>OLED、单色点阵屏（12864 那类灰白屏）、墨水屏这类单色屏资源有限，跑不动 LVGL 一类重型 GUI。单色屏图形库的事实标准是 U8G2：支持几十种屏幕控制器、字体库庞大、内存占用极小。</p>
<p>但 U8G2 开发很慢：改一次显示效果，要重新编译、烧录、上板看，一轮几十秒到几分钟。AI 写的 U8G2 代码经常能编译过、屏幕不对，字体、坐标、布局来回调都对不上。AI 看不见屏幕，写完只能等人烧录反馈，迭代闭环断在它看不见的地方。</p>
<p>为了让 AI 能独立把单色屏界面做对，我把它需要的环境直接搬进浏览器：一份像素级还原 U8G2 的纯 JS 仿真，AI 在这里生成代码、实时预览、截图自检，确认效果对了，再把同一份代码挪到嵌入式真机。</p>
<h2>浏览器里的像素级仿真闭环</h2>
<p>把 U8G2 完整移植成纯 JS 后，AI 在浏览器里就能跑通整个闭环：</p>
<ol>
<li>开发 U8G2 显示界面（JavaScript，浏览器原样运行）</li>
<li>视觉验证：AI 自己运行、截图、识图，把 UI 闭环</li>
<li>自动化测试：AI 在浏览器上跑测试，提前排除错误隐患</li>
<li>用户确认：效果符合预期即通过</li>
<li>部署：同一份代码上真机（与原生 U8G2 C 库 API 一致）</li>
</ol>
<p>显示逻辑在浏览器里提前验证，问题不用等到上板才发现。</p>
<p><strong>U8G2JS 项目：</strong> <a href="https://github.com/createskyblue/u8g2-js" target="_blank" rel="noopener noreferrer">github.com/createskyblue/u8g2-js</a></p>
<p><strong>Demo 仓库：</strong> <a href="https://createskyblue.github.io/u8g2-js/" target="_blank" rel="noopener noreferrer">createskyblue.github.io/u8g2-js/</a></p>
<h2>用仿真环境做出的墨水屏传感器趋势界面</h2>
<p>仿真环境最大的价值，是能低成本反复试错。下面这套单色墨水屏传感器趋势界面，就是 AI 在浏览器里迭代出来的：</p>
<ul>
<li>CO₂ 曲线趋势：渐变填充分两步，先在独立灰度通道按曲线生成深浅渐变，再用误差扩散把灰度量化成黑白点阵叠加到屏幕，整体墨量近似守恒，观感平滑，全程整数运算，符合嵌入式无浮点约束</li>
<li>PM2.5 三粒径趋势：多曲线同屏，靠抖动区分层次</li>
<li>温湿度双曲线：双轴对照，信息密度压进单色屏</li>
</ul>
<p><img src="/posts/epaper-sensor-trend/co2-mode.webp" alt="CO₂ 趋势界面"></p>
<p><img src="/posts/epaper-sensor-trend/pm25-mode.webp" alt="PM2.5 三粒径趋势界面"></p>
<p><img src="/posts/epaper-sensor-trend/environment-mode.webp" alt="温湿度双曲线界面"></p>
<h2>仿真器逐字节一致的实现</h2>
<p>浏览器里的效果必须和真机一模一样，否则仿真毫无意义。这部分是仿真器的核心。</p>
<h3>逐行移植 C 渲染引擎</h3>
<p>移植没有走 WASM 或自动转译，直接对照 C 源码逐行手写 JS：同一套算法、同一种控制流，连字体格式和帧缓冲的字节布局都保持一致。浏览器端能直接读源码、改算法，AI 也容易理解和修改。</p>
<h3>与原版 C 库逐字节对拍</h3>
<p>手工移植最怕大概一致。做法是把原版 U8G2 C 库用 gcc 编译出来，跑 18 个场景（文本、图形、旋转、裁剪、XOR、按钮、Unicode、全量中文），每一帧都与 JS 输出逐字节比对，0 字节差异。验证脚本可复现，改完核心代码重跑一遍即可确认没有偏差。</p>
<h3>像素级还原细节</h3>
<ul>
<li>帧缓冲、字体 RLE 位流、draw_color 0/1/2、旋转，输出与真机逐字节一致</li>
<li>字体运行时加载：字体就是字节数组，运行时代码里直接 load / fromBase64 载入任意 U8G2 字体</li>
<li>内置全量中文字库：宋体 12/16/24px，约 2.8 万字形，AI 写的中文直接渲染</li>
<li>零依赖开箱即用：纯 ESM，浏览器 file:// 双击 demo 即可运行，Node 可无头测试</li>
<li>API 与真机一致：Arduino U8g2lib 风格 camelCase 加 C 风格别名，仿真代码可直接平移到硬件</li>
</ul>
<h2>快速开始</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> { </span><span style="color:#9CDCFE">U8g2</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">U8g2Font</span><span style="color:#D4D4D4"> } </span><span style="color:#C586C0">from</span><span style="color:#CE9178"> './src/index.js'</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">const</span><span style="color:#4FC1FF"> u8g2</span><span style="color:#D4D4D4"> = </span><span style="color:#569CD6">new</span><span style="color:#DCDCAA"> U8g2</span><span style="color:#D4D4D4">({ </span><span style="color:#9CDCFE">display:</span><span style="color:#CE9178"> 'ssd1306_128x64_noname_f'</span><span style="color:#D4D4D4"> });</span></span>
<span class="line"><span style="color:#9CDCFE">u8g2</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">setFont</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">U8g2Font</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">fromBase64</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">'AP//AA...'</span><span style="color:#D4D4D4">));</span></span>
<span class="line"><span style="color:#9CDCFE">u8g2</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">drawStr</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">10</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">'Hello'</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#9CDCFE">u8g2</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">sendBuffer</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#9CDCFE">u8g2</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">attachCanvas</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">document</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">getElementById</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">'screen'</span><span style="color:#D4D4D4">));</span></span></code></pre><h2>本地运行</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">python</span><span style="color:#569CD6"> -m</span><span style="color:#CE9178"> http.server</span><span style="color:#B5CEA8"> 8765</span><span style="color:#569CD6"> --bind</span><span style="color:#B5CEA8"> 127.0.0.1</span></span></code></pre><p>然后访问 <code>http://127.0.0.1:8765/</code>。</p>]]></content>
    <category term="嵌入式"/>
    <category term="前端"/>
    <category term="嵌入式GUI"/>
    <category term="AI辅助开发"/>
    <category term="JavaScript"/>
    <category term="Canvas"/>
    <category term="数据可视化"/>
    <category term="传感器"/>
  </entry>
  <entry>
    <title>低端 Arduino 用 74HCU04 振荡器把 LC 阻抗测量转为频率计数</title>
    <link href="https://createskyblue.github.io/posts/franklin-oscillator-lc-meter/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/franklin-oscillator-lc-meter/</id>
    <published>2026-08-06T00:00:00.000Z</published>
    <updated>2026-08-06T00:00:00.000Z</updated>
    <summary>电子圈笑 AVR 是玩具。它却把 VNA 的阻抗测量做成频率计数</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>低端 Arduino 用 74HCU04 振荡器把 LC 阻抗测量转为频率计数</h1>
<blockquote>
<p><strong>声明：</strong> Turning Impedance into Frequency: An LC Experiment 是一个开源项目，作者为 nobcha（<a href="https://hackaday.io/project/204883-turning-impedance-into-frequency-an-lc-experiment">Hackaday 项目页</a>）。本文是阅读该项目源码与文档后整理的学习笔记，用于理解「阻抗转频率」式 LC 测量的工程实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>性能很弱的 MCU 也能完成 L/C 测量。这个项目给出的做法是：根本不用 MCU 去做采样或信号生成。待测电感或电容被放进 LC 谐振槽，由 74HCU04 反相器构成 Franklin 振荡器，输出频率由 f = 1/(2π√(LC)) 决定。MCU 只负责数这个频率。阻抗到频率的映射全部发生在模拟物理层面，代码里没有任何 ADC 或相位解调。</p>
<h2>谐振频率映射阻抗的物理机制</h2>
<p>传统阻抗测量（例如 VNA）需要注入扫频信号、同步采样电压与电流、再解算复阻抗。这个项目的做法绕开了整条链路：把待测件接入谐振槽，让振荡器自发工作在由槽路参数决定的频率上。</p>
<p>关键点在分工：74HCU04 加 LC 槽是真正的模拟前端，承担阻抗到频率的全部转换；ATmega328P 不参与波形生成，也不做任何模拟采集。复杂度下沉到电路，MCU 退化为一个计数器。</p>
<p><img src="/posts/franklin-oscillator-lc-meter/architecture.svg" alt="信号链：74HCU04 振荡器把阻抗映射为频率，Arduino 只数频率"></p>
<h2>MCU 闸门计数与共振方程</h2>
<p>ATmega328P 通过 FreqCount 库，在 1 秒闸门内对 Pin 5 的上升沿计数，直接得到整数频率。固件做两件事：</p>
<ul>
<li>校准：DPDT 开关把 1000 pF 参考电容切入槽路，测两次频率 F1（无 Cref）与 F2（有 Cref），由 C0 = Cref / (ratio² − 1) 和 L0 = 1 / ((2π·F1)² · C0) 解出槽路等效参数。</li>
<li>测量：把待测件接入槽路后测频率 f，由 L = 1 / ((2π·f)² · C0) − L0 反推未知量。</li>
</ul>
<p>全文检索不到 analogRead、analogWrite、tone 或任何 ADC、同步采样调用。MCU 的全部负担是数脉冲加几行浮点除法。</p>
<h2>复阻抗塌缩为标量频率的测量边界</h2>
<p>振荡器只输出一个频率 f，它只反映槽路的净电抗。这一简化带来四个硬性边界：</p>
<p><img src="/posts/franklin-oscillator-lc-meter/impedance_collapse.svg" alt="复阻抗塌缩为标量频率：VNA 给出矢量，振荡器只给一个标量 f"></p>
<ul>
<li>一次只能解一个未知量（L 或 C），且必须已知槽路拓扑。</li>
<li>测不到电阻、ESR、Q 值、D 因子与相位，标量频率无法分离实部与虚部。</li>
<li>待测件必须物理接入谐振槽，属于侵入式测量，不能在板在线测量。</li>
<li>单频单点，无法刻画组件随频率变化的特性。</li>
</ul>
<h2>适用边界与设计取舍</h2>
<p>优势是硬件极简、电路透明，下限可到约 0.1 μH 小电感，成本远低于 VNA。</p>
<p>取舍同样明确：作者以结构清晰为目标，不追求绝对精度；每次上电或温度显著变化需重新校准，量程被固定 1–2 MHz 的槽路锁死，且 f 对 L 的映射是非线性的。</p>
<p>选型建议：只关心单个 L/C 的数值、且希望用最廉价 MCU 完成时，这套方案合适。需要复阻抗矢量、Q 值或在板测量时，仍应使用 VNA。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="信号处理"/>
    <category term="VNA"/>
  </entry>
  <entry>
    <title>花 20 元用 Patent Disclosure Skill 生成一份高质量专利交底书</title>
    <link href="https://createskyblue.github.io/posts/patent-disclosure-skill-review/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/patent-disclosure-skill-review/</id>
    <published>2026-08-06T00:00:00.000Z</published>
    <updated>2026-08-06T00:00:00.000Z</updated>
    <summary>Patent Disclosure Skill 辅助生成专利技术交底书</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>花 20 元用 Patent Disclosure Skill 生成一份高质量专利交底书</h1>
<p>Patent Disclosure Skill 是一个辅助编写专利技术交底书的开源 Skill，适合需要把已有技术材料整理成正式交底书的人。</p>
<h2>实际使用结果</h2>
<p>这次我花了 20 块钱，烧出了一份非常高质量的专利交底书。最终成品的完整度和表达质量都超出了我的预期，我非常满意。</p>
<p>整个使用体验很直接，最后得到的结果也很扎实。只说结论：这玩意非常好用，我很推荐。</p>
<h2>开源项目地址</h2>
<p>GitHub：<a href="https://github.com/handsomestWei/patent-disclosure-skill">https://github.com/handsomestWei/patent-disclosure-skill</a></p>]]></content>
    <category term="AI"/>
    <category term="AI辅助开发"/>
    <category term="技术分享"/>
  </entry>
  <entry>
    <title>在线串口工具 支持关键词 染色 提取 三折叠</title>
    <link href="https://createskyblue.github.io/posts/web-serial-monitor/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/web-serial-monitor/</id>
    <published>2026-08-03T00:00:00.000Z</published>
    <updated>2026-08-03T00:00:00.000Z</updated>
    <summary>在线串口 / 蓝牙 / WebSocket 调试工具</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>在线串口 / 蓝牙 / WebSocket 调试工具</h1>
<p>免安装、浏览器即用的极简通信调试平台。串口、蓝牙、WebSocket 三合一，实时收发 + 关键词染色 + 数据提取。</p>
<ul>
<li>GitHub：<a href="https://github.com/createskyblue/web-serial-monitor">https://github.com/createskyblue/web-serial-monitor</a></li>
<li>在线使用：<a href="https://createskyblue.github.io/web-serial-monitor">https://createskyblue.github.io/web-serial-monitor</a></li>
</ul>
<p><img src="/posts/web-serial-monitor/cover.webp" alt="界面预览"></p>
<table>
<thead>
<tr>
<th>快捷发送</th>
<th>染色和提取</th>
</tr>
</thead>
<tbody><tr>
<td><img src="/posts/web-serial-monitor/img/%E5%BF%AB%E6%8D%B7%E5%8F%91%E9%80%81.webp" alt="快捷发送"></td>
<td><img src="/posts/web-serial-monitor/img/%E6%9F%93%E8%89%B2%E5%92%8C%E6%8F%90%E5%8F%96.webp" alt="染色和提取"></td>
</tr>
</tbody></table>
<h2>🚀 王炸特性</h2>
<h3>三种通信模式</h3>
<ul>
<li><strong>串口</strong> — Web Serial API，免驱动，Chrome/Edge 直接开用</li>
<li><strong>WebSocket</strong> — 网络透传/远程串口（需本地部署，见下方说明）</li>
<li><strong>蓝牙 BLE</strong> — Web Bluetooth API，UUID16 / 完整 UUID，TX/RX 双特征</li>
</ul>
<h3>关键词染色 + 提取</h3>
<ul>
<li>填「起始 + 结束」→ <strong>区间模式</strong>：染色该区间 + 提取区间内容</li>
<li>只填「起始」→ <strong>关键词模式</strong>：高亮关键词 + 提取出现时间</li>
<li>Text / HEX 双模式匹配，提取结果<strong>自动锁存</strong>，不被新数据挤丢</li>
</ul>
<h3>专业调试细节</h3>
<ul>
<li><strong>显示</strong>：文本 / HEX / 同窗（文本|HEX 左右对照），Tab 一键切换</li>
<li><strong>三折叠</strong>：左侧栏、右侧栏、发送区都可折叠，整个应用能缩成一个小窗口，不占地方</li>
<li><strong>流控</strong>：DTR / RTS 手动控制，连接前可预置初始状态</li>
<li><strong>智能自动滚动</strong>：检测到你在底部才跟随，上翻历史不被拉扯</li>
<li><strong>大数据量优化</strong>：历史日志按块管理，默认只渲染最近区块，上滚动态加载更早历史、回到底部自动卸载，数据量再大内存与渲染也保持有界、不卡顿</li>
<li><strong>收发</strong>：定时发送、文件发送（RAW / YModem）、快捷发送列表</li>
<li><strong>管理</strong>：缓冲区保护、导出 TXT/BIN、一键复制、配置导入导出、清屏</li>
</ul>
<h3>⌨️ 快捷键</h3>
<ul>
<li><code>Tab</code> — 切换显示模式（文本 / HEX / 同窗）</li>
<li><code>p</code> — 连接 / 断开</li>
<li><code>c</code> — 清屏</li>
<li><code>Ctrl+Enter</code> / <code>Cmd+Enter</code> — 发送</li>
<li><code>[</code> / <code>]</code> — 折叠/展开左右侧栏</li>
<li><code>&#39;</code> — 折叠/展开发送区</li>
</ul>
<h2>🛠 快速开始</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">npm</span><span style="color:#CE9178"> install</span></span>
<span class="line"><span style="color:#DCDCAA">npm</span><span style="color:#CE9178"> run</span><span style="color:#CE9178"> dev</span><span style="color:#6A9955">      # 本地开发</span></span>
<span class="line"><span style="color:#DCDCAA">npm</span><span style="color:#CE9178"> run</span><span style="color:#CE9178"> build</span><span style="color:#6A9955">    # 生产构建，部署 dist/ 目录即可</span></span></code></pre><h2>⚠️ 注意</h2>
<ul>
<li>串口 / 蓝牙要求 <strong>HTTPS 或 localhost</strong>（浏览器安全限制）</li>
<li><strong>WebSocket 模式需本地部署</strong>：页面在 HTTPS 下只能连安全的 wss://；要连普通 ws:// 本地/局域网服务，请把本工具跑在 <strong>localhost</strong> 上（<code>npm run dev</code> 或 <code>npm run build</code> 后本地托管）</li>
<li>建议使用 <strong>Chrome / Edge</strong></li>
</ul>]]></content>
    <category term="前端"/>
    <category term="WebSocket"/>
    <category term="Web Bluetooth"/>
    <category term="工程调试"/>
    <category term="React"/>
  </entry>
  <entry>
    <title>Stm32L4 开关GPIO中断误触发原理</title>
    <link href="https://createskyblue.github.io/posts/stm32-exti-two-gates/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/stm32-exti-two-gates/</id>
    <published>2026-08-02T00:00:00.000Z</published>
    <updated>2026-08-02T00:00:00.000Z</updated>
    <summary>Stm32L4 开关GPIO中断误触发原理</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Stm32L4 开关GPIO中断误触发的底层原理</h1>
<h2>背景</h2>
<p>STM32L4低功耗开发中，临时关闭再重启GPIO中断时，常会出现无外部信号却自动触发中断的问题，会导致设备误唤醒、计数异常、低功耗逻辑出错。</p>
<h2>IO中断传递链路</h2>
<p>STM32L4中断传递链路：<code>GPIO电平变化 → EXTI_IMR源头过滤 → 置位EXTI_PR挂起标志 → NVIC_ISER总开关放行 → CPU响应中断</code></p>
<p>其中<code>PR标志</code>置位后不会自动清零，会持续保存中断记录</p>
<p><img src="/posts/stm32-exti-two-gates/gpio-exti-flow.svg" alt="GPIO/EXTI 中断流程与事件路径"></p>
<h2>误触发原因</h2>
<ol>
<li>HAL库关闭GPIO中断仅关闭<code>NVIC_ISER</code>内核总开关，只屏蔽中断上报，不会停止EXTI信号检测、不会清空<code>PR</code>挂起标志</li>
<li>中断关闭期间，引脚电平抖动、电源瞬变产生的信号会留存<code>PR</code>残留标志</li>
<li>重启中断开关后，残留标志会立即触发一次无效中断</li>
</ol>
<h2>解决方案</h2>
<ol>
<li>重启NVIC中断前手动清空<code>EXTI_PR挂起位</code>，可消除历史残留，适配所有STM32芯片，兼容性最优；</li>
<li>直接关闭<code>EXTI_IMR</code>屏蔽位，依托STM32L4芯片<code>IMR</code>前置、<code>PR</code>后置的硬件特性，从源头拦截信号，避免产生挂起残留。</li>
</ol>
<blockquote>
<p>部分STM32芯片链路为<code>PR</code>前置、<code>IMR</code>后置，仅关闭<code>IMR</code>无法清除已有残留，仍会出现误触发。因此代码跨芯片移植时，优先采用清PR挂起位的通用方案</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="STM32"/>
    <category term="STM32L496"/>
    <category term="中断"/>
    <category term="低功耗"/>
  </entry>
  <entry>
    <title>PY32F002A 试用 ChaCha20 流密码加密 FlashStore 存储数据</title>
    <link href="https://createskyblue.github.io/posts/air001-chacha20-flashstore/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/air001-chacha20-flashstore/</id>
    <published>2026-07-31T00:00:00.000Z</published>
    <updated>2026-07-31T00:00:00.000Z</updated>
    <summary>ChaCha20 加密 FlashStore，Cortex-M0+ 纯软件免 AES 硬件</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>PY32F002A 试用 ChaCha20 流密码加密 FlashStore 存储数据</h1>
<h2>ChaCha20 是什么</h2>
<p>ChaCha20 是一种流密码（stream cipher），RFC 8439 标准。它是 TLS 1.3、QUIC、WireGuard 等新一代协议的默认加密算法之一，Google 在移动端全面用它替代 AES。和 AES 不同，它不需要硬件加速器，纯软件就能在 MCU 上高效运行。</p>
<p>核心特性：</p>
<ul>
<li><strong>对称加密</strong>：加密和解密是同一个操作（XOR 密钥流），调用同一个函数</li>
<li><strong>无对齐要求</strong>：数据不需要 4 字节对齐，任意长度都能处理</li>
<li><strong>资源友好</strong>：Cortex-M0+ 上 <code>-Os</code> 编译仅占约 <strong>700 字节 Flash</strong>，约 <strong>200 字节栈</strong></li>
<li><strong>输入参数</strong>：32 字节密钥 + 12 字节 nonce（同一密钥下 nonce 不能重复）</li>
</ul>
<h2>在 PY32F002A 上的用法</h2>
<p>结合之前移植的 FlashStore A/B 分区存储，把 ChaCha20 作为加密层叠加在上面：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>保存: 明文 → ChaCha20 加密 → FlashStore_Save → 写入 Flash</span></span>
<span class="line"><span>加载: FlashStore_Load → 密文 → ChaCha20 解密 → 明文</span></span></code></pre><p>核心代码三行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">chacha20_crypt</span><span style="color:#D4D4D4">(data, size, key, nonce);</span><span style="color:#6A9955">   // 加密（或解密，同一函数）</span></span>
<span class="line"><span style="color:#DCDCAA">FlashStore_Save</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">cfg</span><span style="color:#D4D4D4">, data, size);</span><span style="color:#6A9955">        // 存到闪存</span></span>
<span class="line"><span style="color:#DCDCAA">FlashStore_Load</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">cfg</span><span style="color:#D4D4D4">, data, size);</span><span style="color:#6A9955">        // 读回</span></span>
<span class="line"><span style="color:#DCDCAA">chacha20_crypt</span><span style="color:#D4D4D4">(data, size, key, nonce);</span><span style="color:#6A9955">   // 解密</span></span></code></pre><h2>实测：加密一句名言</h2>
<p>用 &quot;Stay hungry, stay foolish.&quot; 做测试，32 字节密钥 + 12 字节 nonce。</p>
<p>明文：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>53 74 61 79 20 68 75 6e 67 72 79 2c 20 73 74 61  │Stay hungry, sta</span></span>
<span class="line"><span>79 20 66 6f 6f 6c 69 73 68 2e 00 00 00 00 00 00  │y foolish.......</span></span></code></pre><p>加密后 — 完全不可读：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>3a 28 1d a0 11 62 cd 14 4a 03 07 03 93 67 63 73  │:(...b..J....gcs</span></span>
<span class="line"><span>69 d1 59 2f a1 dd 95 06 2d 14 b5 28 29 ee 9f 0a  │i.Y/....-..()...</span></span></code></pre><p>FlashStore 存盘再读回，解密还原 — 一字不差。</p>
<h2>资源占用</h2>
<p>PY32F002A（Cortex-M0+，48MHz，<code>-Os</code>）：</p>
<table>
<thead>
<tr>
<th>模块</th>
<th>Flash</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>flash_store</td>
<td>594 B</td>
<td>A/B 分区核心</td>
</tr>
<tr>
<td>flash_store_port</td>
<td>230 B</td>
<td>HAL 适配</td>
</tr>
<tr>
<td>chacha20</td>
<td>~700 B</td>
<td>加密/解密</td>
</tr>
<tr>
<td><strong>合计</strong></td>
<td><strong>~1.5 KB</strong></td>
<td>含加密的可靠闪存存储</td>
</tr>
</tbody></table>
<p>如果只需要存储不需要加密，去掉 ChaCha20 即可，约 800 字节搞定。</p>
<h2>完整源码</h2>
<p>只有 100 行，零依赖，复制到任何 MCU 项目即可使用。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "chacha20.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;string.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> QR(</span><span style="color:#9CDCFE">a</span><span style="color:#569CD6">, </span><span style="color:#9CDCFE">b</span><span style="color:#569CD6">, </span><span style="color:#9CDCFE">c</span><span style="color:#569CD6">, </span><span style="color:#9CDCFE">d</span><span style="color:#569CD6">) </span><span style="color:#C586C0">do</span><span style="color:#569CD6"> {          </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    (a) </span><span style="color:#D4D4D4">+=</span><span style="color:#569CD6"> (b); (d) </span><span style="color:#D4D4D4">^=</span><span style="color:#569CD6"> (a);           </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    (d) </span><span style="color:#D4D4D4">=</span><span style="color:#569CD6"> ((d) </span><span style="color:#D4D4D4">&#x3C;&#x3C;</span><span style="color:#B5CEA8"> 16</span><span style="color:#569CD6">) </span><span style="color:#D4D4D4">|</span><span style="color:#569CD6"> ((d) </span><span style="color:#D4D4D4">>></span><span style="color:#B5CEA8"> 16</span><span style="color:#569CD6">);  </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    (c) </span><span style="color:#D4D4D4">+=</span><span style="color:#569CD6"> (d); (b) </span><span style="color:#D4D4D4">^=</span><span style="color:#569CD6"> (c);           </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    (b) </span><span style="color:#D4D4D4">=</span><span style="color:#569CD6"> ((b) </span><span style="color:#D4D4D4">&#x3C;&#x3C;</span><span style="color:#B5CEA8"> 12</span><span style="color:#569CD6">) </span><span style="color:#D4D4D4">|</span><span style="color:#569CD6"> ((b) </span><span style="color:#D4D4D4">>></span><span style="color:#B5CEA8"> 20</span><span style="color:#569CD6">);  </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    (a) </span><span style="color:#D4D4D4">+=</span><span style="color:#569CD6"> (b); (d) </span><span style="color:#D4D4D4">^=</span><span style="color:#569CD6"> (a);           </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    (d) </span><span style="color:#D4D4D4">=</span><span style="color:#569CD6"> ((d) </span><span style="color:#D4D4D4">&#x3C;&#x3C;</span><span style="color:#B5CEA8"> 8</span><span style="color:#569CD6">)  </span><span style="color:#D4D4D4">|</span><span style="color:#569CD6"> ((d) </span><span style="color:#D4D4D4">>></span><span style="color:#B5CEA8"> 24</span><span style="color:#569CD6">);  </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    (c) </span><span style="color:#D4D4D4">+=</span><span style="color:#569CD6"> (d); (b) </span><span style="color:#D4D4D4">^=</span><span style="color:#569CD6"> (c);           </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    (b) </span><span style="color:#D4D4D4">=</span><span style="color:#569CD6"> ((b) </span><span style="color:#D4D4D4">&#x3C;&#x3C;</span><span style="color:#B5CEA8"> 7</span><span style="color:#569CD6">)  </span><span style="color:#D4D4D4">|</span><span style="color:#569CD6"> ((b) </span><span style="color:#D4D4D4">>></span><span style="color:#B5CEA8"> 25</span><span style="color:#569CD6">);  </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">} </span><span style="color:#C586C0">while</span><span style="color:#569CD6"> (</span><span style="color:#B5CEA8">0</span><span style="color:#569CD6">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint32_t</span><span style="color:#DCDCAA"> ld32</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">p</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> v;</span></span>
<span class="line"><span style="color:#DCDCAA">    memcpy</span><span style="color:#D4D4D4">(&#x26;v, p, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(v));</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> v;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> st32</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">p</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> v</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">    memcpy</span><span style="color:#D4D4D4">(p, &#x26;v, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(v));</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> chacha20_block</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint8_t</span><span style="color:#9CDCFE"> out</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">], </span><span style="color:#569CD6">const</span><span style="color:#569CD6"> uint32_t</span><span style="color:#9CDCFE"> init</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">]) {</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#9CDCFE"> x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#DCDCAA">    memcpy</span><span style="color:#D4D4D4">(x, init, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(x));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; </span><span style="color:#B5CEA8">10</span><span style="color:#D4D4D4">; i++) {</span></span>
<span class="line"><span style="color:#DCDCAA">        QR</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[ </span><span style="color:#B5CEA8">8</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">12</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#DCDCAA">        QR</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">5</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[ </span><span style="color:#B5CEA8">9</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">13</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#DCDCAA">        QR</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">6</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">10</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">14</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#DCDCAA">        QR</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">7</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">11</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">15</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#DCDCAA">        QR</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">5</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">10</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">15</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#DCDCAA">        QR</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">6</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">11</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">12</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#DCDCAA">        QR</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">7</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[ </span><span style="color:#B5CEA8">8</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">13</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#DCDCAA">        QR</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[ </span><span style="color:#B5CEA8">9</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">14</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; </span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">; i++)</span></span>
<span class="line"><span style="color:#DCDCAA">        st32</span><span style="color:#D4D4D4">(out + i * </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">[i] + </span><span style="color:#9CDCFE">init</span><span style="color:#D4D4D4">[i]);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> chacha20_crypt</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> size</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                    const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#9CDCFE"> key</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">32</span><span style="color:#D4D4D4">], </span><span style="color:#569CD6">const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#9CDCFE"> nonce</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">12</span><span style="color:#D4D4D4">]) {</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (size == </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#9CDCFE"> state</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#9CDCFE">    state</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">0x61707865u</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">    state</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">0x3320646eu</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">    state</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">0x79622d32u</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">    state</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">0x6b206574u</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; </span><span style="color:#B5CEA8">8</span><span style="color:#D4D4D4">; i++)</span></span>
<span class="line"><span style="color:#9CDCFE">        state</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4"> + i] = </span><span style="color:#DCDCAA">ld32</span><span style="color:#D4D4D4">(key + i * </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">    state</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">12</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; </span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">; i++)</span></span>
<span class="line"><span style="color:#9CDCFE">        state</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">13</span><span style="color:#D4D4D4"> + i] = </span><span style="color:#DCDCAA">ld32</span><span style="color:#D4D4D4">(nonce + i * </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#9CDCFE"> keystream</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#569CD6">    size_t</span><span style="color:#D4D4D4"> offset = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (offset &#x3C; size) {</span></span>
<span class="line"><span style="color:#DCDCAA">        chacha20_block</span><span style="color:#D4D4D4">(keystream, state);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">        size_t</span><span style="color:#D4D4D4"> chunk = size - offset;</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (chunk > </span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">) chunk = </span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">        for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; chunk; i++)</span></span>
<span class="line"><span style="color:#9CDCFE">            data</span><span style="color:#D4D4D4">[offset + i] ^= </span><span style="color:#9CDCFE">keystream</span><span style="color:#D4D4D4">[i];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">        offset += chunk;</span></span>
<span class="line"><span style="color:#9CDCFE">        state</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">12</span><span style="color:#D4D4D4">]++;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>头文件：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#ifndef</span><span style="color:#569CD6"> CHACHA20_H</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> CHACHA20_H</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stddef.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdint.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> chacha20_crypt</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> size</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                    const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#9CDCFE"> key</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">32</span><span style="color:#D4D4D4">], </span><span style="color:#569CD6">const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#9CDCFE"> nonce</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">12</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#endif</span></span></code></pre>]]></content>
    <category term="嵌入式"/>
    <category term="PY32F002A"/>
    <category term="Flash存储"/>
    <category term="ChaCha20"/>
    <category term="Cortex-M0+"/>
  </entry>
  <entry>
    <title>FlashStore: A/B 双页 Flash 存储，面向小型 MCU。零依赖，28 bytes RAM。</title>
    <link href="https://createskyblue.github.io/posts/flashstore-ab-partition/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/flashstore-ab-partition/</id>
    <published>2026-07-31T00:00:00.000Z</published>
    <updated>2026-07-31T00:00:00.000Z</updated>
    <summary>FlashStore: 极简 A/B 分区配置存储方案</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>FlashStore A/B Partition</h1>
<p>A/B 双页 Flash 存储，面向小型 MCU。零依赖，无状态。</p>
<p><a href="https://github.com/createskyblue/flashstore-ab-partition">https://github.com/createskyblue/flashstore-ab-partition</a></p>
<p><strong>选哪个？</strong></p>
<table>
<thead>
<tr>
<th></th>
<th>FlashStore</th>
<th>FlashDB</th>
<th>LittleFS</th>
</tr>
</thead>
<tbody><tr>
<td>定位</td>
<td>单块配置可靠存储</td>
<td>嵌入式键值数据库</td>
<td>嵌入式文件系统</td>
</tr>
<tr>
<td>数据模型</td>
<td>整块二进制</td>
<td>键值对</td>
<td>文件</td>
</tr>
<tr>
<td>读写方式</td>
<td>Save/Load 整块</td>
<td>Set/Get 按 key</td>
<td>Open/Read/Write</td>
</tr>
<tr>
<td>磨损均衡</td>
<td>❌</td>
<td>✅</td>
<td>✅</td>
</tr>
<tr>
<td>掉电保护</td>
<td>✅ 双页冗余</td>
<td>✅ 事务日志</td>
<td>✅</td>
</tr>
<tr>
<td>适用</td>
<td>配置参数，低频更新</td>
<td>多配置项独立读写</td>
<td>文件、日志、大块数据</td>
</tr>
</tbody></table>
<h2>资源占用（Cortex-M0 -Os 实测）</h2>
<table>
<thead>
<tr>
<th>模块</th>
<th>Flash</th>
<th>静态 RAM</th>
<th>栈</th>
</tr>
</thead>
<tbody><tr>
<td>flash_store</td>
<td>500 bytes</td>
<td>0</td>
<td>~20 bytes</td>
</tr>
<tr>
<td>chacha20（可选）</td>
<td>700 bytes</td>
<td>0</td>
<td>~200 bytes</td>
</tr>
<tr>
<td>xxtea（可选）</td>
<td>737 bytes</td>
<td>0</td>
<td>~0</td>
</tr>
</tbody></table>
<blockquote>
<p>flash_store 明细：Load 158B / load_page 108B / save_page 100B / Save 66B / CRC32 52B / MaxDataSize 16B。按需链接，不用就不占。</p>
</blockquote>
<h2>5 分钟接入</h2>
<ol>
<li>复制 <code>include/flash_store.h</code>、<code>src/flash_store.c</code>。需要加密再加 <code>chacha20</code> 或 <code>xxtea</code></li>
<li>实现 3 个回调：</li>
</ol>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">bool</span><span style="color:#DCDCAA"> my_read</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> addr</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">out</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">    memcpy</span><span style="color:#D4D4D4">(out, (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *)(</span><span style="color:#569CD6">uintptr_t</span><span style="color:#D4D4D4">)addr, len);</span><span style="color:#6A9955">  // 或 HAL 读</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#569CD6"> true</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#569CD6">bool</span><span style="color:#DCDCAA"> my_erase</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> addr</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">    HAL_FLASH_ErasePage</span><span style="color:#D4D4D4">(addr);</span><span style="color:#6A9955">                  // 整页擦除</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#569CD6"> true</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#569CD6">bool</span><span style="color:#DCDCAA"> my_program</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> addr</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#6A9955">    // 按 word 编程，或 memcpy（支持字节寻址的 flash）</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#569CD6"> true</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><ol start="3">
<li>初始化并读写：</li>
</ol>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">FlashStore_IO io = {</span></span>
<span class="line"><span style="color:#D4D4D4">    .read = my_read, .erase = my_erase, .program = my_program,</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#D4D4D4">FlashStore_Config cfg = {</span></span>
<span class="line"><span style="color:#D4D4D4">    .io = &#x26;io, .page_a_address = </span><span style="color:#B5CEA8">0x0800FE00</span><span style="color:#D4D4D4">, .page_b_address = </span><span style="color:#B5CEA8">0x0800FE80</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">    .page_size = </span><span style="color:#B5CEA8">128</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#DCDCAA">FlashStore_Save</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">cfg</span><span style="color:#D4D4D4">, data, len);</span><span style="color:#6A9955">   // 写</span></span>
<span class="line"><span style="color:#DCDCAA">FlashStore_Load</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">cfg</span><span style="color:#D4D4D4">, data, len);</span><span style="color:#6A9955">   // 读</span></span></code></pre><blockquote>
<p>需要加密？推荐 <a href="/posts/flashstore-ab-partition/src/chacha20.c" target="_blank" rel="noopener noreferrer">ChaCha20</a>——RFC 8439 标准，流密码不分块不要求对齐。实测 Cortex-M0 -Os：700B Flash + ~200B 栈，0 静态 RAM。极致省资源用 <a href="/posts/flashstore-ab-partition/src/xxtea.c" target="_blank" rel="noopener noreferrer">XXTEA</a>，仅混淆级别，要求 4 字节对齐且 ≥8 字节。加密在外部做：<code>encrypt → FlashStore_Save</code> / <code>FlashStore_Load → decrypt</code>。</p>
</blockquote>
<h2>为什么不会丢数据</h2>
<p><strong>A 写完才写 B。</strong> 任意时刻断电，至少一份完整数据在 flash 上。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Save:  A 先 → B 后     Load: 读两页 → CRC 验 → 返回好的 → 顺手修坏的</span></span></code></pre><table>
<thead>
<tr>
<th>断电在…</th>
<th>A</th>
<th>B</th>
<th>Load 结果</th>
</tr>
</thead>
<tbody><tr>
<td>空闲</td>
<td>v2</td>
<td>v2</td>
<td>返回 v2</td>
</tr>
<tr>
<td>写 A 中途</td>
<td>坏</td>
<td>v1</td>
<td>返回 v1，修 A</td>
</tr>
<tr>
<td>A 完成，B 前</td>
<td>v2 (CRC=c2)</td>
<td>v1 (CRC=c1)</td>
<td>CRC 不同→A 新→返回 v2，修 B</td>
</tr>
<tr>
<td>写 B 中途</td>
<td>v2</td>
<td>坏</td>
<td>返回 v2，下次修 B</td>
</tr>
<tr>
<td>两页都坏</td>
<td>坏</td>
<td>坏</td>
<td><code>ERROR_NO_VALID_DATA</code></td>
</tr>
</tbody></table>
<blockquote>
<p><code>WARN_REPAIR_FAILED</code>？修复时 erase/program 失败，正常不会发生——收到应检查硬件。</p>
</blockquote>
<h2>Header 结构</h2>
<p>每页 12 字节 header + payload：</p>
<table>
<thead>
<tr>
<th>偏移</th>
<th>字段</th>
<th>含义</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>magic (4B)</td>
<td><code>0x46534142</code></td>
</tr>
<tr>
<td>4</td>
<td>length (4B)</td>
<td>payload 大小</td>
</tr>
<tr>
<td>8</td>
<td>crc32 (4B)</td>
<td>payload CRC32</td>
</tr>
</tbody></table>
<h2>状态码</h2>
<table>
<thead>
<tr>
<th>返回值</th>
<th>含义</th>
</tr>
</thead>
<tbody><tr>
<td><code>OK</code></td>
<td>正常</td>
</tr>
<tr>
<td><code>ERROR_ARGUMENT</code></td>
<td>参数错误</td>
</tr>
<tr>
<td><code>ERROR_WRITE</code></td>
<td>擦除或编程失败</td>
</tr>
<tr>
<td><code>ERROR_NO_VALID_DATA</code></td>
<td>两页都坏了</td>
</tr>
<tr>
<td><code>WARN_REPAIR_FAILED</code></td>
<td>数据正确但冗余修复失败</td>
</tr>
</tbody></table>
<h2>测试</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">cmake</span><span style="color:#569CD6"> -S</span><span style="color:#CE9178"> .</span><span style="color:#569CD6"> -B</span><span style="color:#CE9178"> build</span><span style="color:#D4D4D4"> &#x26;&#x26; </span><span style="color:#DCDCAA">cmake</span><span style="color:#569CD6"> --build</span><span style="color:#CE9178"> build</span><span style="color:#D4D4D4"> &#x26;&#x26; </span><span style="color:#DCDCAA">./build/flash_store_tests.exe</span></span></code></pre><p>10 个测试覆盖：正常读写、A 坏回退 B、B 坏从 A 修、A 写失败保 B、CRC 裁决新旧、双页全坏、MaxDataSize、Save/Load 参数校验、ConfigCheck、修复失败告警。</p>
<h2>License</h2>
<p><a href="LICENSE">MIT</a></p>]]></content>
    <category term="嵌入式"/>
    <category term="flash"/>
    <category term="flashdb"/>
    <category term="littlefs"/>
  </entry>
  <entry>
    <title>设备挂机异常后，用 pyOCD 与 Cortex-Debug 保留 MCU 现场</title>
    <link href="https://createskyblue.github.io/posts/pyocd-preserve-mcu-fault-site/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/pyocd-preserve-mcu-fault-site/</id>
    <published>2026-07-30T00:00:00.000Z</published>
    <updated>2026-07-30T00:00:00.000Z</updated>
    <summary>MCU程序卡死后，怎样不重启就看到它停在哪个函数</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>设备挂机异常后，用 pyOCD 与 Cortex-Debug 保留 MCU 现场</h1>
<p>设备已经异常时，先保持供电。不要复位、重新烧录，也不要启动平时的 Launch 下载调试。下面这份配置会把 MCU 原地暂停，让 VS Code 显示它卡住时所在的代码、调用过程和变量。</p>
<h2>Cortex-Debug 现场连接配置</h2>
<p>先把下面配置加入 <code>.vscode/launch.json</code>。<code>MCU 现场检查</code> 是 <code>name</code> 字段创建的自定义名称，添加后才会出现在 VS Code 的“运行和调试”列表中。</p>
<pre><code>{
  "name": "MCU 现场检查",
  "type": "cortex-debug",
  "request": "attach",
  "servertype": "pyocd",
  "targetId": "TARGET_ID",
  "executable": "${workspaceFolder}/build/firmware.elf",
  "serverArgs": [
    "-O",
    "connect_mode=halt"
  ],
  "overrideGDBServerStartedRegex": "GDB server listening on port"
}</code></pre><p><code>targetId</code> 和 <code>executable</code> 直接沿用已经可以正常调试的工程配置。原配置中已有 <code>cmsisPack</code> 或 <code>svdFile</code>，把对应行原样复制进来。</p>
<ul>
<li><code>name</code> 创建稍后要选择的自定义调试入口。</li>
<li><code>request: &quot;attach&quot;</code> 连接现有程序，不执行 Launch 下载流程。</li>
<li><code>connect_mode=halt</code> 让 pyOCD 连接目标时暂停 MCU，减少现场继续变化。</li>
</ul>
<p>不要复制 Reset、Run to main、GDB <code>load</code> 或烧录任务。这些动作会改变需要检查的现场。</p>
<p>终端已经显示 <code>GDB server listening on port</code>，VS Code 仍报启动超时时，保留配置里的 <code>overrideGDBServerStartedRegex</code>。它让 Cortex-Debug 认出 pyOCD 已经启动，增加等待时间没有用。</p>
<h2>设备异常后的四个动作</h2>
<ol>
<li>保持设备当前供电，不按复位键，不重新烧录固件。</li>
<li>连接调试器的 SWDIO、SWCLK 和 GND。SWD 是 Cortex-M 芯片常用的调试接口。</li>
<li>打开 VS Code，在“运行和调试”中选择已经添加的 <code>MCU 现场检查</code>。</li>
<li>启动调试，看到源码停住后立即截图或记录，暂时不要按“继续运行”。</li>
</ol>
<p>终端出现 <code>GDB server listening on port</code> 表示 pyOCD 已经开始监听。VS Code 随后应停在当前代码位置，不会重新运行到 <code>main</code>。</p>
<h2>ELF 与调用栈的对应关系</h2>
<p><code>executable</code> 必须指向设备当前固件同一次构建产生的 ELF 文件。ELF 在现场连接中只负责把地址翻译成函数、源码和变量，不负责下载固件。</p>
<p>调试器暂停后，按这个顺序记录：</p>
<ol>
<li>查看当前源码行，确认 CPU 停在什么函数。</li>
<li>展开 <code>Call Stack</code>，记录程序经过哪些函数到达当前位置。</li>
<li>查看 <code>Variables</code> 和已有的 <code>Watch</code>，记录与异常状态直接相关的值。</li>
</ol>
<p>本地 ELF 与设备固件不对应时，函数名、源码行和变量都可能显示错误。先保存信息，再决定是否单步或继续运行。</p>
<h2>现场连接的失效边界</h2>
<p>以下情况已经无法保留原始现场：</p>
<ul>
<li>设备被断电、复位或重新烧录。</li>
<li>看门狗在连接调试器前已经复位 MCU。</li>
<li>程序继续运行并覆盖了故障时的栈和变量。</li>
</ul>
<p>这套方法保留的是调试器连接那一刻的状态，无法回到更早的故障瞬间。正式挂机前，先在正常设备上启动一次 <code>MCU 现场检查</code>，确认芯片会原地暂停且没有重新进入启动流程，再把这项配置留在工程中。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="工程调试"/>
    <category term="故障诊断"/>
    <category term="VSCode"/>
    <category term="pyocd"/>
  </entry>
  <entry>
    <title>用 pyOCD 与 Cortex-Debug 在挂机异常后连接 MCU 查看现场</title>
    <link href="https://createskyblue.github.io/posts/pyocd-cortex-debug-late-attach/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/pyocd-cortex-debug-late-attach/</id>
    <published>2026-07-28T00:00:00.000Z</published>
    <updated>2026-07-28T00:00:00.000Z</updated>
    <summary>pyOCD配合Cortex-Debug在挂机异常后查看MCU现场</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用 pyOCD 与 Cortex-Debug 在挂机异常后连接 MCU 查看现场</h1>
<p>几十台产品需要长期挂机，异常可能数小时或数天后出现。每台一直挂调试器不现实，启动调试还可能复位目标。设备平时独立运行，出现停转、卡死或状态异常后，再通过 SWD 暂停 MCU 查看现场。</p>
<p><img src="/posts/pyocd-cortex-debug-late-attach/PixPin_2026-07-28_11-56-24.webp" alt="Vscode_Cortex_Debug断点调试"></p>
<h2>Reset 与现场边界</h2>
<p>常规 Launch 调试会让程序从复位入口重新开始：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Reset -> Reset_Handler -> SystemInit -> main</span></span></code></pre><p>现场连接需要保留当前执行位置：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>异常位置 -> SWD 连接 -> Debug Halt -> 读取 CPU 状态</span></span></code></pre><p>一旦执行 Reset，<code>PC</code>、<code>SP</code>、调用栈和外设状态都会发生变化，故障现场也随之改变。</p>
<p>目标中的固件与本地 ELF 必须来自同一次构建。<code>executable</code> 只用于加载 ELF 符号，把地址映射到函数、源码和变量；现场调试不要执行 GDB 的 <code>load</code> 命令。<code>load</code> 会进入 Flash 编程流程，可能执行擦除、写入或复位，直接改变现场。ELF 与设备固件不一致时，调用栈和变量显示可能失真。</p>
<p>这套方法保存的是“调试器暂停 MCU 那一刻”的状态。若看门狗已经复位设备，或程序继续运行并覆盖了故障栈，原始现场无法靠延迟连接恢复。</p>
<h2>Cortex-Debug Attach 配置</h2>
<p>Cortex-Debug 应使用 <code>request: &quot;attach&quot;</code>。它跳过 Launch 模式默认的下载和启动流程，不会主动把设备重新运行到 <code>main</code>。自定义 GDB 命令、启动脚本和 GDB Server 仍可能触发 Reset，使用前需要逐项检查。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "name"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"MCU Attach (pyOCD)"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"cortex-debug"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "request"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"attach"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "cwd"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"${workspaceFolder}"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "servertype"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"pyocd"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "targetId"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"TARGET_ID"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "executable"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"./build/firmware.elf"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "cmsisPack"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"./pack/Vendor.Device.pack"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "svdFile"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"./svd/device.svd"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "serverArgs"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#CE9178">    "-O"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#CE9178">    "connect_mode=attach"</span></span>
<span class="line"><span style="color:#D4D4D4">  ],</span></span>
<span class="line"><span style="color:#9CDCFE">  "overrideGDBServerStartedRegex"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"GDB server listening on port"</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>当前版本的 Cortex-Debug 无法识别新版 pyOCD 的启动输出，需要用 <code>overrideGDBServerStartedRegex</code> 指定成功日志。否则 pyOCD 已监听端口，Cortex-Debug 仍可能停在等待启动。</p>
<p>异常出现后，在 VS Code 中启动这项配置。<code>connect_mode=attach</code> 只控制 pyOCD 初始化阶段，此时 pyOCD 不主动暂停 CPU。</p>
<p>本机 pyOCD 0.44.1 会在 GDB 接入时调用 <code>halt()</code>，所以此版本冻结点位于 GDB 连接阶段；其他版本请以启动日志为准。</p>
<h2>Attach 与 Halt 的冻结时机</h2>
<p>状态仍在快速变化时，等待 GDB 完成连接可能太晚。把连接模式改成 <code>halt</code>，pyOCD 会在 GDB Server 初始化期间暂停内核。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#CE9178">"serverArgs"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#CE9178">  "-O"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#CE9178">  "connect_mode=halt"</span></span>
<span class="line"><span style="color:#D4D4D4">],</span></span>
<span class="line"><span style="color:#CE9178">"overrideGDBServerStartedRegex"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"GDB server listening on port"</span></span></code></pre><p>暂停由 Cortex-M 调试硬件完成，CPU 无需运行到某个暂停函数。概念链路如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>SWD -> DAP -> DHCSR.C_HALT -> CPU 停止</span></span></code></pre><p>两种模式都配合 Cortex-Debug 的 <code>request: &quot;attach&quot;</code> 使用：</p>
<table>
<thead>
<tr>
<th>pyOCD 模式</th>
<th>暂停时机</th>
<th>使用场景</th>
</tr>
</thead>
<tbody><tr>
<td><code>attach</code></td>
<td>GDB 客户端接入时</td>
<td>状态稳定</td>
</tr>
<tr>
<td><code>halt</code></td>
<td>pyOCD 连接目标时</td>
<td>状态变化快</td>
</tr>
</tbody></table>
<h2>独立 GDB Server 提前冻结</h2>
<p>需要先冻结、后打开 VS Code 时，可以运行一次性脚本。它用 <code>halt</code> 启动 pyOCD GDB Server，冻结 MCU 后等待 GDB 接入。</p>
<pre><code>@echo off
pyocd gdbserver --pack .\pack\Vendor.Device.pack -t TARGET_ID -O connect_mode=halt
pause</code></pre><p>VS Code 使用外部 GDB Server，并继续采用 <code>request: &quot;attach&quot;</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "name"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"MCU External Attach"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"cortex-debug"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "request"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"attach"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "servertype"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"external"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "gdbTarget"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"localhost:3333"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "executable"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"./build/firmware.elf"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "svdFile"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"./svd/device.svd"</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>脚本只启动一次。参数错误或 GDB Server 退出后，<code>pause</code> 会保留错误信息，避免快速重复启动 pyOCD。</p>
<p>几十台设备可以继续独立挂机，调试探针只接到发生异常的设备上。<code>halt</code> 负责尽早冻结，Cortex-Debug 负责把寄存器、源码和变量组织成可读现场。</p>]]></content>
    <category term="嵌入式"/>
    <category term="断点调试"/>
    <category term="故障诊断"/>
    <category term="VSCode"/>
    <category term="pyocd"/>
    <category term="gdb"/>
    <category term="Cortex"/>
    <category term="halt"/>
    <category term="attach"/>
  </entry>
  <entry>
    <title>用 PowerShell 为 Windows 文件和文件夹添加复制正斜杠完整路径菜单</title>
    <link href="https://createskyblue.github.io/posts/windows-copy-full-path-context-menu/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/windows-copy-full-path-context-menu/</id>
    <published>2026-07-28T00:00:00.000Z</published>
    <updated>2026-07-28T00:00:00.000Z</updated>
    <summary>Windows右键复制完整路径，文件和文件夹统一输出正斜杠格式</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用 PowerShell 为 Windows 文件和文件夹添加复制正斜杠完整路径菜单</h1>
<p>Windows 可以通过注册表扩展资源管理器右键菜单。本例给文件和文件夹加入“复制完整路径（带文件名）”，复制结果统一使用正斜杠：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>C:/Users/example/Desktop/demo.txt</span></span></code></pre><p>实现只修改当前用户配置，不需要管理员权限。安装脚本会创建一个本地辅助脚本，再为文件和文件夹分别注册菜单命令。</p>
<h2>目标行为与运行条件</h2>
<p>目标包含四项可验证行为：</p>
<ul>
<li>文件右键菜单显示“复制完整路径（带文件名）”。</li>
<li>文件夹右键菜单显示同一个命令。</li>
<li>菜单执行期间不显示 PowerShell 控制台窗口。</li>
<li>剪贴板使用 <code>/</code> 作为路径分隔符，并保留中文、空格和 <code>&amp;</code> 等字符。</li>
</ul>
<p>运行环境为 Windows 10 或 Windows 11，使用系统自带的 Windows PowerShell。Windows 11 可能把自定义菜单放在“显示更多选项”中。</p>
<h2>文件与文件夹的注册表入口</h2>
<p>资源管理器为文件和文件夹使用不同的类注册入口。只写文件入口时，文件夹不会显示菜单。</p>
<table>
<thead>
<tr>
<th>对象</th>
<th>当前用户注册表路径</th>
</tr>
</thead>
<tbody><tr>
<td>所有文件</td>
<td><code>HKCU\Software\Classes\*\shell\CopyFullPathWithFilename</code></td>
</tr>
<tr>
<td>所有文件夹</td>
<td><code>HKCU\Software\Classes\Directory\shell\CopyFullPathWithFilename</code></td>
</tr>
</tbody></table>
<p>每个菜单键保存显示名称和图标，子键 <code>command</code> 的默认值保存执行命令。<code>%1</code> 由资源管理器替换成当前选中的完整路径。</p>
<p>脚本使用 <code>Microsoft.Win32.Registry</code> 写入默认值。这个 API 可以直接处理空名称的注册表默认值，也避开了 PowerShell 注册表驱动对默认值的差异。</p>
<h2><code>-Command</code> 参数解析故障</h2>
<p>最初的命令尝试在 <code>-Command</code> 后声明参数：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>powershell.exe -Command "param([string]$p) Set-Clipboard -Value $p" "%1"</span></span></code></pre><p>从资源管理器启动时，<code>-Command</code> 后续内容可能继续进入命令文本解析。文件路径没有稳定绑定到 <code>param()</code>。路径含 <code>&amp;</code> 时，PowerShell 会把它识别成调用运算符并产生解析错误；用户只能看到短暂黑框，剪贴板没有变化。</p>
<p>解决方法是把复制逻辑放进独立 <code>.ps1</code> 文件，再通过 <code>-File</code> 传递位置参数：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>powershell.exe -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "辅助脚本路径" "%1"</span></span></code></pre><p><code>-File</code> 会把最后一个参数交给脚本的 <code>$Path</code>。路径中的空格、中文和 <code>&amp;</code> 继续保持为一个参数。<code>-WindowStyle Hidden</code> 隐藏控制台窗口，<code>-ExecutionPolicy Bypass</code> 只作用于本次辅助进程。</p>
<h2><code>-File</code> 辅助脚本与正斜杠转换</h2>
<p>辅助脚本安装到当前用户的本地应用数据目录：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>%LOCALAPPDATA%/CopyFullPathWithFilename/copy-full-path.ps1</span></span></code></pre><p>脚本只接收一个路径，替换分隔符后写入剪贴板：</p>
<pre><code>param(
    [Parameter(Mandatory = $true, Position = 0)]
    [string]$Path
)

Set-Clipboard -Value $Path.Replace('\', '/')</code></pre><p>替换只影响剪贴板文本，不会修改磁盘上的文件名或目录结构。Windows API 仍把原始反斜杠路径传给脚本。</p>
<h2>一键安装与端到端验证</h2>
<p>附件中的安装脚本是自包含版本。它创建辅助脚本，并重复写入两类菜单键。重复运行会更新同一组键，不会产生重复菜单。</p>
<ul>
<li><a href="/posts/windows-copy-full-path-context-menu/attachments/install-copy-full-path-menu.ps1" target="_blank" rel="noopener noreferrer">下载安装脚本</a></li>
<li><a href="/posts/windows-copy-full-path-context-menu/attachments/verify-copy-full-path-menu.ps1" target="_blank" rel="noopener noreferrer">下载验证脚本</a></li>
<li><a href="/posts/windows-copy-full-path-context-menu/attachments/uninstall-copy-full-path-menu.ps1" target="_blank" rel="noopener noreferrer">下载卸载脚本</a></li>
</ul>
<p>在附件所在目录执行安装：</p>
<pre><code>powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\install-copy-full-path-menu.ps1</code></pre><p>关闭已打开的右键菜单，重新右键任意文件或文件夹。粘贴后应得到类似结果：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>C:/测试目录/path with spaces &#x26; symbols.txt</span></span></code></pre><p>验证脚本会检查两个注册表入口，调用实际辅助脚本，再比较剪贴板结果：</p>
<pre><code>powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\verify-copy-full-path-menu.ps1</code></pre><p>成功输出如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>PASS: file and directory menus are registered; clipboard uses forward slashes.</span></span></code></pre><p>验证过程会暂时写入剪贴板。剪贴板原本含文本时，脚本会在结束前恢复原文本；原本没有文本时，测试路径会保留在剪贴板中。</p>
<h2>卸载范围与复刻检查</h2>
<p>卸载脚本只删除本功能创建的两个菜单键，以及 <code>%LOCALAPPDATA%/CopyFullPathWithFilename</code> 辅助目录：</p>
<pre><code>powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\uninstall-copy-full-path-menu.ps1</code></pre><p>其他 Agent 复刻时需要核对以下边界：</p>
<ol>
<li>使用 <code>HKCU</code>，避免要求管理员权限。</li>
<li>同时注册 <code>*</code> 和 <code>Directory</code>，覆盖文件与文件夹。</li>
<li>使用 <code>-File</code> 传递 <code>%1</code>，避免路径进入命令文本解析。</li>
<li>在辅助脚本内转换 <code>/</code>，不要改写资源管理器传入值。</li>
<li>用含中文、空格和 <code>&amp;</code> 的路径执行端到端验证。</li>
</ol>
<p>这套结构把菜单注册、路径参数绑定和剪贴板处理分成三个清晰边界。安装器负责部署，注册表负责触发，辅助脚本负责文本格式。</p>]]></content>
    <category term="Windows"/>
    <category term="系统编程"/>
    <category term="开发效率"/>
    <category term="测试"/>
  </entry>
  <entry>
    <title>用 A/B 双页冗余保存 MCU 固定配置</title>
    <link href="https://createskyblue.github.io/posts/ab-partition-flash-store/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ab-partition-flash-store/</id>
    <published>2026-07-27T00:00:00.000Z</published>
    <updated>2026-07-27T00:00:00.000Z</updated>
    <summary>A/B 双页冗余用 CRC32 校验固定结构体配置</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用 A/B 双页冗余保存 MCU 固定配置</h1>
<p>Flash 擦除和写入需要时间。设备在这个过程中掉电、复位或受到干扰，正在更新的页可能只写入了一部分，单页存储随后无法提供完整配置。</p>
<p>长期使用中还可能出现偶发位翻转。CRC32 可以发现数据已经损坏，但单页存储没有第二份数据可供恢复。A/B 双页存储把同一份结构体写入两个独立擦除页：CRC32 负责识别损坏页，另一页负责提供可用副本。</p>
<p>本文示例来自实际工程中的 <code>FlashStore</code> 模块，现已整理成独立的 HAL 无关 C 库：<a href="https://github.com/createskyblue/flashstore-ab-partition" target="_blank" rel="noopener noreferrer">FlashStore A/B Partition 开源仓库</a>。</p>
<h2>双页冗余的职责</h2>
<p>每个页都保存魔数、数据长度、CRC32 和 payload。A 页是优先读取的主副本，B 页用于回退。</p>
<p>这套实现没有代次号，也不会在两页之间轮流选择最新记录。两页保存同一份数据，目标是在一次擦除或写入失败后仍保留一份可校验副本。</p>
<h2>先 A 后 B 的更新顺序</h2>
<p>保存时先擦除并写入 A 页。A 页更新失败，流程立即停止，B 页中的旧值保持不变；A 页写入成功后再更新 B 页。</p>
<p>读取时先检查 A 页的魔数、长度和 CRC32。A 页无效时再检查 B 页。两个副本都无效，接口返回 <code>FLASH_STORE_ERROR_NO_VALID_DATA</code>，由应用决定加载默认值或进入故障处理。</p>
<h2>HAL 无关接口</h2>
<p>核心库只要求平台提供读取、擦除和整页写入三个回调。调用方再配置两个页地址、页大小和一个静态工作区：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">FlashStore_Config config = {</span></span>
<span class="line"><span style="color:#D4D4D4">    .io = {</span></span>
<span class="line"><span style="color:#D4D4D4">        .read = board_flash_read,</span></span>
<span class="line"><span style="color:#D4D4D4">        .erase = board_flash_erase,</span></span>
<span class="line"><span style="color:#D4D4D4">        .program = board_flash_program,</span></span>
<span class="line"><span style="color:#D4D4D4">    },</span></span>
<span class="line"><span style="color:#D4D4D4">    .page_a_address = PAGE_A_ADDRESS,</span></span>
<span class="line"><span style="color:#D4D4D4">    .page_b_address = PAGE_B_ADDRESS,</span></span>
<span class="line"><span style="color:#D4D4D4">    .page_size = </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(workspace),</span></span>
<span class="line"><span style="color:#D4D4D4">    .workspace = workspace,</span></span>
<span class="line"><span style="color:#D4D4D4">    .workspace_size = </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(workspace),</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">FlashStore_Init</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">store</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">config</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">FlashStore_Save</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">store</span><span style="color:#D4D4D4">, key, (</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> *)&#x26;</span><span style="color:#9CDCFE">settings</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(settings));</span></span>
<span class="line"><span style="color:#DCDCAA">FlashStore_Load</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">store</span><span style="color:#D4D4D4">, key, (</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *)&#x26;</span><span style="color:#9CDCFE">settings</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(settings));</span></span></code></pre><p>仓库中的 <code>examples/air001_example.c</code> 给出了 Air001 HAL 的回调实现。移植到其他 MCU 时，只需替换这三个回调，并按芯片手册处理页大小、写入粒度和对齐要求。</p>
<h2>使用边界</h2>
<ul>
<li>两个页必须从链接脚本的固件区排除，避免代码覆盖配置页。</li>
<li>payload 上限是页大小减 12 字节的页头。</li>
<li>结构体字段变化需要应用层维护版本兼容。</li>
<li>静态工作区会在调用期间被改写，接口不支持并发调用。</li>
<li><code>key</code> 只提供可逆混淆，不具备密码学安全性。</li>
<li>双页冗余只覆盖单页损坏；两个副本同时损坏时仍需加载默认值或从外部恢复。</li>
</ul>
<p>该方案适合固定结构体、低频写入和代码空间敏感的 MCU。动态 KV、频繁更新、磨损均衡或时序记录仍应使用 FlashDB 等完整存储组件。两类方案的资源和维护成本对比见 <a href="/posts/ab-partition-vs-flashdb">FlashDB 占用约 7KB Flash 时，固定配置可考虑 A/B 双页存储</a>。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="Flash存储"/>
    <category term="A/B分区"/>
    <category term="C语言"/>
  </entry>
  <entry>
    <title>FlashDB 占用约 7KB Flash 时，固定配置可考虑 A/B 双页存储</title>
    <link href="https://createskyblue.github.io/posts/ab-partition-vs-flashdb/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ab-partition-vs-flashdb/</id>
    <published>2026-07-27T00:00:00.000Z</published>
    <updated>2026-07-27T00:00:00.000Z</updated>
    <summary>FlashDB KV 与 A/B 双页存储：按数据模型和 Flash 预算选型</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>FlashDB 占用约 7KB Flash 时，固定配置可考虑 A/B 双页存储</h1>
<p>FlashDB 是面向嵌入式设备的数据库组件。KVDB 用键值对保存配置，适合 key 会增加、更新较频繁、需要遍历或删除数据的场景；它同时提供 GC、磨损均衡和掉电恢复等能力。</p>
<p>A/B 双页存储使用两个独立 Flash 擦除页保存同一份固定结构体。写入中断或主副本校验失败时，程序可以读取另一页，适合数据少、结构稳定、更新频率低的设备配置。</p>
<h2>FlashDB 的 7KB 代码占用</h2>
<p>以下数据来自 Cortex-M0+ 在 <code>-Os -flto</code> 下的编译结果，仅开启 KVDB 与 FAL。这个数字用于说明当前工程的资源量级，不代表所有芯片和工具链都会得到相同结果。</p>
<table>
<thead>
<tr>
<th>模块</th>
<th>主要代价来源</th>
<th>约 Flash 占用</th>
</tr>
</thead>
<tbody><tr>
<td>fdb_kvdb.c</td>
<td>扇区管理、追加写、GC 回收、迭代器</td>
<td>~3KB</td>
</tr>
<tr>
<td>fdb_utils.c</td>
<td>CRC32、状态字操作</td>
<td>~1.5KB</td>
</tr>
<tr>
<td>fdb.c</td>
<td>数据库公共接口</td>
<td>~0.5KB</td>
</tr>
<tr>
<td>FAL 层</td>
<td>分区表、设备管理、Flash 操作封装</td>
<td>~2KB</td>
</tr>
<tr>
<td>合计</td>
<td></td>
<td><strong>~7KB</strong></td>
</tr>
</tbody></table>
<p>这 7KB 购买的是完整 KV 存储能力。项目仅保存几十字节固定配置时，其中很多能力用不上；配置持续增长或需要频繁维护时，这些能力可以降低应用层复杂度。</p>
<h2>A/B 双页与 FlashDB 的选型差异</h2>
<table>
<thead>
<tr>
<th>选型维度</th>
<th>A/B 双页存储</th>
<th>FlashDB KVDB</th>
</tr>
</thead>
<tbody><tr>
<td>数据模型</td>
<td>固定结构体或固定二进制块</td>
<td>key-value，条目可独立增删</td>
</tr>
<tr>
<td>写入方式</td>
<td>整块擦除、整块更新</td>
<td>追加写入，由 GC 回收空间</td>
</tr>
<tr>
<td>掉电恢复</td>
<td>两份副本校验与回退</td>
<td>数据库状态管理与恢复机制</td>
</tr>
<tr>
<td>磨损管理</td>
<td>应用自行控制写入频率</td>
<td>提供 GC 和磨损均衡能力</td>
</tr>
<tr>
<td>版本迁移</td>
<td>应用处理结构体版本</td>
<td>可按 key 补充或更新默认值</td>
</tr>
<tr>
<td>外部管理</td>
<td>需要自定义字段映射</td>
<td>key 适合查询、修改和导出</td>
</tr>
<tr>
<td>资源代价</td>
<td>两个擦除页和少量代码</td>
<td>当前最小配置约 7KB 代码</td>
</tr>
</tbody></table>
<p>数据字节数只能作为一个判断维度。固定结构体即使接近 1KB，也可能适合 A/B；几十字节配置如果每个字段都要独立管理、频繁更新，FlashDB 仍有价值。</p>
<h2>选型边界</h2>
<p>选择 A/B 双页存储，需要同时满足这些条件：</p>
<ul>
<li>数据结构固定，保存时可以整体更新。</li>
<li>写入频率低，两个页的擦写寿命可以接受。</li>
<li>Flash 代码空间紧张，愿意在应用层维护版本迁移。</li>
<li>不需要 KV 遍历、按 key 删除、TSDB 或通用管理接口。</li>
</ul>
<p>选择 FlashDB，通常是因为配置已经形成动态 KV 集合，写入和升级需求持续增加。此时 7KB 左右的代码代价换来了统一的数据管理、空间回收和恢复机制，项目不必继续扩展自写存储层。</p>
<p>实际选择可以压缩成一句话：<strong>固定、低频、整块更新优先 A/B；动态、频繁、按 key 管理优先 FlashDB。</strong></p>
<p>A/B 双页实现、Air001 接入方式和掉电边界见 <a href="/posts/ab-partition-flash-store">用 A/B 双页冗余保存 MCU 固定配置</a>。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="FlashDB"/>
    <category term="Flash存储"/>
    <category term="嵌入式"/>
    <category term="Cortex-M0+"/>
    <category term="系统设计"/>
    <category term="A/B分区"/>
  </entry>
  <entry>
    <title>传感器驱动框架设计：从 Linux IIO、RT-Thread 到 ESP32-S3 的统一设备表</title>
    <link href="https://createskyblue.github.io/posts/esp32s3-sensor-driver-decoupling/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp32s3-sensor-driver-decoupling/</id>
    <published>2026-07-27T00:00:00.000Z</published>
    <updated>2026-07-27T00:00:00.000Z</updated>
    <summary>传感器各封独立库，换型号要大改代码，用统一接口屏蔽差异</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>传感器驱动框架设计：从 Linux IIO、RT-Thread 到 ESP32-S3 的统一设备表</h1>
<p>嵌入式系统里，每个传感器厂商都会提供一套自己的库函数。两款温湿度传感器，用法可能长这样：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* AHT30 */</span></span>
<span class="line"><span style="color:#DCDCAA">aht30_init_i2c</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">handle</span><span style="color:#D4D4D4">, I2C_NUM_0, </span><span style="color:#B5CEA8">0x38</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">aht30_get_data</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">handle</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">temp</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">humi</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 换成 SHT41 后 */</span></span>
<span class="line"><span style="color:#DCDCAA">sht41_init</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">dev</span><span style="color:#D4D4D4">, I2C_PORT_0);</span></span>
<span class="line"><span style="color:#DCDCAA">sht41_measure</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">dev</span><span style="color:#D4D4D4">, SHT41_PRECISION_HIGH, &#x26;</span><span style="color:#9CDCFE">result</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#569CD6">float</span><span style="color:#D4D4D4"> temp = result.temperature;</span></span></code></pre><p>这两个传感器做同一件事，调用方式却完全不同。换传感器时，每一处使用了旧库的代码都要跟着改。接入的传感器越多，这个问题越严重。</p>
<p>解决方向只有一个：找到各类传感器的共同点，定义统一的操作接口，把型号差异隔在驱动内部。</p>
<h2>分类是统一接口的前提</h2>
<p>不同型号的温度传感器，做的事情本质相同：初始化硬件，读回一个温度值。差别只在于寄存器地址不同、I2C 地址不同、换算公式不同。这些都是实现细节，不是接口本身。</p>
<p>把传感器按类型分类（温度、湿度、气压、加速度……），每类定义统一的操作契约：所有这类传感器都要实现同样的 init / read 方法，返回同样形状的数据。上层代码只认类型，不认型号。</p>
<p>这就是传感器驱动框架的核心思路。RT-Thread 和 Linux 都用这个思路，但各自的规模和侧重有所不同。</p>
<h2>Linux：按传感器类别接入对应子系统</h2>
<p>Linux 根据设备的数据模型和主要使用场景提供不同子系统，同一种物理传感器可能因用途不同进入不同框架：</p>
<table>
<thead>
<tr>
<th>子系统</th>
<th>侧重</th>
<th>典型设备</th>
</tr>
</thead>
<tbody><tr>
<td>hwmon</td>
<td>板级监控</td>
<td>VRM 温度、电源轨电压、风扇转速</td>
</tr>
<tr>
<td>IIO</td>
<td>工业测量和连续采样</td>
<td>气压、湿度、加速度计、ADC</td>
</tr>
<tr>
<td>input</td>
<td>用户交互事件</td>
<td>触摸屏、按键、某些姿态传感器</td>
</tr>
</tbody></table>
<p>驱动注册进哪个子系统，决定了它向上层暴露什么接口。以 IIO 为例，驱动只描述通道类型和读原始值的方法，子系统负责生成统一的对外入口：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 驱动侧：描述通道 + 提供 read_raw + 注册 */</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> const</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> iio_chan_spec channels</span><span style="color:#569CD6">[]</span><span style="color:#D4D4D4"> = {</span></span>
<span class="line"><span style="color:#D4D4D4">    { .type = IIO_TEMP,</span></span>
<span class="line"><span style="color:#D4D4D4">      .info_mask_separate = </span><span style="color:#DCDCAA">BIT</span><span style="color:#D4D4D4">(IIO_CHAN_INFO_PROCESSED) },</span></span>
<span class="line"><span style="color:#D4D4D4">    { .type = IIO_HUMIDITYRELATIVE,</span></span>
<span class="line"><span style="color:#D4D4D4">      .info_mask_separate = </span><span style="color:#DCDCAA">BIT</span><span style="color:#D4D4D4">(IIO_CHAN_INFO_PROCESSED) },</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> const</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> iio_info sensor_info = {</span></span>
<span class="line"><span style="color:#D4D4D4">    .read_raw = sensor_read_raw,</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">indio_dev->info         = &#x26;sensor_info;</span></span>
<span class="line"><span style="color:#D4D4D4">indio_dev->channels     = channels;</span></span>
<span class="line"><span style="color:#D4D4D4">indio_dev->num_channels = </span><span style="color:#DCDCAA">ARRAY_SIZE</span><span style="color:#D4D4D4">(channels);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">return</span><span style="color:#DCDCAA"> devm_iio_device_register</span><span style="color:#D4D4D4">(&#x26;client-></span><span style="color:#9CDCFE">dev</span><span style="color:#D4D4D4">, indio_dev);</span></span></code></pre><p>注册完成后，子系统自动开出三条消费路径，适配不同场景：</p>
<table>
<thead>
<tr>
<th>谁来读</th>
<th>走哪条路</th>
<th>拿到什么</th>
</tr>
</thead>
<tbody><tr>
<td>状态页、shell 脚本</td>
<td>sysfs 属性文件</td>
<td>单次当前值</td>
</tr>
<tr>
<td>波形采集、批量记录</td>
<td><code>/dev/iio:deviceN</code> 缓冲</td>
<td>带时间戳的连续样本流</td>
</tr>
<tr>
<td>内核控制逻辑</td>
<td>IIO consumer 接口</td>
<td>换算后的数值</td>
</tr>
</tbody></table>
<p>前两条路只读文件，第三条在内核模块里直接拿通道句柄：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 内核 consumer：只认通道类型，不认具体芯片 */</span></span>
<span class="line"><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> iio_channel *chan = </span><span style="color:#DCDCAA">devm_iio_channel_get</span><span style="color:#D4D4D4">(dev, </span><span style="color:#CE9178">"humidity"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> val;</span></span>
<span class="line"><span style="color:#DCDCAA">iio_read_channel_processed</span><span style="color:#D4D4D4">(chan, &#x26;</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">);</span></span></code></pre><p>实际工程中，consumer 通常通过设备树或 ACPI 描述 channel 名称，与具体驱动解耦；以上示例只展示 consumer 如何通过统一接口获取数据，不代表字符串可以随意定义。</p>
<p><strong>结果：驱动按类别注册一次，三类消费者各取所需，谁也不需要知道芯片挂在哪条总线上。</strong></p>
<h2>RT-Thread：类型枚举与统一设备访问</h2>
<p>RT-Thread 不按传感器类别拆分子系统，而是用一套统一的设备模型 + 类型枚举覆盖所有传感器。</p>
<p>每个驱动填一张操作表并声明自己的类型，然后注册进框架：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 驱动侧：填操作表 + 声明类型 + 注册 */</span></span>
<span class="line"><span style="color:#D4D4D4">sensor->info.type = RT_SENSOR_CLASS_HUMI;</span><span style="color:#6A9955">   /* 湿度传感器 */</span></span>
<span class="line"><span style="color:#D4D4D4">sensor->ops       = &#x26;my_sensor_ops;</span><span style="color:#6A9955">         /* fetch_data / control */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">rt_hw_sensor_register</span><span style="color:#D4D4D4">(sensor, </span><span style="color:#CE9178">"humi"</span><span style="color:#D4D4D4">, RT_DEVICE_FLAG_RDONLY, RT_NULL);</span></span></code></pre><p><code>rt_sensor_ops</code> 提供统一的数据采集和控制入口，主要包括 <code>fetch_data</code>、<code>control</code> 等接口，不同版本的实现细节可能略有差异。无论是 I2C 传感器、SPI 传感器还是 UART 传感器，驱动都填这同一张表。</p>
<p>应用侧的调用顺序固定为 find / open / control / read，全程不出现芯片型号：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 应用侧：统一打开、配置、读取 */</span></span>
<span class="line"><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> rt_sensor_data data;</span></span>
<span class="line"><span style="color:#4EC9B0">rt_device_t</span><span style="color:#D4D4D4"> dev = </span><span style="color:#DCDCAA">rt_device_find</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"humi_sht41"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">rt_device_open</span><span style="color:#D4D4D4">(dev, RT_DEVICE_FLAG_RDONLY);</span></span>
<span class="line"><span style="color:#DCDCAA">rt_device_control</span><span style="color:#D4D4D4">(dev, RT_SENSOR_CTRL_SET_ODR, (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *)</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">);</span><span style="color:#6A9955">  /* 1 Hz */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">rt_device_read</span><span style="color:#D4D4D4">(dev, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) == </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">    rt_kprintf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"humi: </span><span style="color:#9CDCFE">%d</span><span style="color:#CE9178">.</span><span style="color:#9CDCFE">%d</span><span style="color:#9CDCFE"> %%</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">humi</span><span style="color:#D4D4D4"> / </span><span style="color:#B5CEA8">10</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">humi</span><span style="color:#D4D4D4"> % </span><span style="color:#B5CEA8">10</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><code>data.data</code> 是一个联合体，成员由 <code>sensor-&gt;info.type</code> 决定：温度传感器访问 <code>data.data.temp</code>，湿度传感器访问 <code>data.data.humi</code>，加速度传感器访问 <code>data.data.acce.x/y/z</code>。类型不同，接口形状统一。</p>
<p><strong>在接口稳定的前提下，上层业务代码无需感知具体芯片型号的变化。</strong></p>
<h2>两个框架共有的四层</h2>
<p>把 RT-Thread 和 Linux 缩小，骨架是一样的：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>硬件驱动 → 统一接口 → 设备表 → 采样调度 → 统一样本 → 消费者</span></span></code></pre><p>每一层挡住一种变化：</p>
<table>
<thead>
<tr>
<th>层</th>
<th>负责什么</th>
<th>挡住哪种变化</th>
</tr>
</thead>
<tbody><tr>
<td>统一接口</td>
<td>init / read / control 操作约定</td>
<td>芯片型号和总线协议换了</td>
</tr>
<tr>
<td>设备表</td>
<td>系统当前有哪些传感器</td>
<td>传感器数量变了</td>
</tr>
<tr>
<td>采样调度</td>
<td>何时读、按什么顺序读</td>
<td>采样节奏变了</td>
</tr>
<tr>
<td>统一样本</td>
<td>数值、单位、有效状态、时间</td>
<td>上层用途变了</td>
</tr>
</tbody></table>
<p>前三层管的是&quot;怎么读&quot;，第四层管的是&quot;怎么传&quot;。RT-Thread 用 <code>rt_sensor_data</code>，IIO 用带 scale 的通道值，落到一个最小实现上，一条样本需要携带的信息就这几项：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">typedef</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">    float</span><span style="color:#D4D4D4">    value;</span><span style="color:#6A9955">         /* 已换算成物理量的数值 */</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#D4D4D4">  valid;</span><span style="color:#6A9955">         /* 本次采样是否可信 */</span></span>
<span class="line"><span style="color:#569CD6">    int64_t</span><span style="color:#D4D4D4">  timestamp_ms;</span><span style="color:#6A9955">  /* 采样时刻 */</span></span>
<span class="line"><span style="color:#D4D4D4">} </span><span style="color:#4EC9B0">sensor_sample_t</span><span style="color:#D4D4D4">;</span></span></code></pre><p><code>valid</code> 和 <code>timestamp_ms</code> 容易被省掉，但它们决定上层能不能正确处理数据：没有 <code>valid</code>，读失败和读到 0 无法区分；没有时间戳，曲线和超时判断都无从下手。</p>
<p>少一层就会漏一种耦合：没有统一接口，换型号就要改上层；没有设备表，调度代码里会写死传感器名单；没有统一样本，每个消费者都要自己解析各传感器的格式。</p>
<p><strong>结果：框架的价值来自这四层职责固定，不来自接口数量多。</strong></p>
<h2>ESP32-S3：三种总线接入同一张驱动表</h2>
<p>ESP32-S3 上不需要完整的 RTOS 设备体系，只需要实现这四层的最小版本。</p>
<p>以同时接入三种总线的传感器为例：Modbus RTU、UART 私有协议、I2C。统一接口只有三个成员，<code>read</code> 直接填上一节那个 <code>sensor_sample_t</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">typedef</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> sensor_device {</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *name;</span></span>
<span class="line"><span style="color:#DCDCAA">    esp_err_t</span><span style="color:#D4D4D4"> (*init)(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> sensor_device *dev);</span></span>
<span class="line"><span style="color:#DCDCAA">    esp_err_t</span><span style="color:#D4D4D4"> (*read)(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> sensor_device *dev,</span></span>
<span class="line"><span style="color:#4EC9B0">                      sensor_sample_t</span><span style="color:#D4D4D4"> *sample);</span></span>
<span class="line"><span style="color:#D4D4D4">} </span><span style="color:#4EC9B0">sensor_device_t</span><span style="color:#D4D4D4">;</span></span></code></pre><p>三个驱动各自导出一个实例，Modbus 请求帧、UART 帧解析、I2C 时序都关在各自的 <code>.c</code> 文件里。驱动表是一个数组，加传感器只加一行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>传感器A驱动  ─ Modbus RTU ─┐</span></span>
<span class="line"><span>传感器B驱动  ─ UART ───────┼─→ 驱动表 ─→ 采样任务</span></span>
<span class="line"><span>传感器C驱动  ─ I2C ────────┘</span></span></code></pre><pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#4EC9B0"> sensor_device_t</span><span style="color:#D4D4D4"> *g_sensors</span><span style="color:#569CD6">[]</span><span style="color:#D4D4D4"> = {</span></span>
<span class="line"><span style="color:#D4D4D4">    &#x26;sensor_a_device,</span></span>
<span class="line"><span style="color:#D4D4D4">    &#x26;sensor_b_device,</span></span>
<span class="line"><span style="color:#D4D4D4">    &#x26;sensor_c_device,</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><p>采样任务只遍历这张表，不判断当前设备用哪种总线：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 顺序初始化 */</span></span>
<span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; SENSOR_COUNT; i++)</span></span>
<span class="line"><span style="color:#9CDCFE">    g_sensors</span><span style="color:#D4D4D4">[i]-></span><span style="color:#DCDCAA">init</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">g_sensors</span><span style="color:#D4D4D4">[i]);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 顺序轮询 + 聚合 */</span></span>
<span class="line"><span style="color:#4EC9B0">sensor_sample_t</span><span style="color:#9CDCFE"> samples</span><span style="color:#D4D4D4">[SENSOR_COUNT];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">while</span><span style="color:#D4D4D4"> (</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; SENSOR_COUNT; i++)</span></span>
<span class="line"><span style="color:#9CDCFE">        g_sensors</span><span style="color:#D4D4D4">[i]-></span><span style="color:#DCDCAA">read</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">g_sensors</span><span style="color:#D4D4D4">[i], &#x26;</span><span style="color:#9CDCFE">samples</span><span style="color:#D4D4D4">[i]);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    assemble_reading</span><span style="color:#D4D4D4">(samples, &#x26;reading);</span><span style="color:#6A9955">   /* 各驱动样本 → 统一环境读数 */</span></span>
<span class="line"><span style="color:#DCDCAA">    sensor_queue_push</span><span style="color:#D4D4D4">(&#x26;reading);</span></span>
<span class="line"><span style="color:#DCDCAA">    vTaskDelay</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">pdMS_TO_TICKS</span><span style="color:#D4D4D4">(POLL_MS));</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><strong>结果：新增传感器写一个驱动文件并在驱动表加一行，轮询任务不改。</strong></p>
<h2>统一数据的消费者与驱动边界</h2>
<p>聚合层输出统一的环境读数，下游有多个消费者，都只认这一种结构：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>统一环境读数 ─┬─→ 数据队列   （取连续样本）</span></span>
<span class="line"><span>              ├─→ 最新值缓存 （取当前状态）</span></span>
<span class="line"><span>              └─→ 历史环形缓冲（取时间段曲线）</span></span></code></pre><p>Web 页面从这三者里选需要的那一份。它不知道哪个传感器走 Modbus，也不知道哪个挂在 I2C 上。</p>
<p>这条边界就是框架和业务的分界：驱动负责把数据从硬件读出来，上层负责决定数据拿来做什么。</p>
<p><strong>结果：加一个新消费者、换一种存储策略，都不会回头修改传感器驱动。</strong></p>]]></content>
    <category term="嵌入式"/>
    <category term="ESP32-S3"/>
    <category term="ESP-IDF"/>
    <category term="传感器"/>
    <category term="RT-Thread"/>
    <category term="Linux"/>
  </entry>
  <entry>
    <title>用 SPI 的占空比编码驱动 WS2812 灯珠</title>
    <link href="https://createskyblue.github.io/posts/stm32-hal-spi-ws2812/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/stm32-hal-spi-ws2812/</id>
    <published>2026-07-27T00:00:00.000Z</published>
    <updated>2026-07-27T00:00:00.000Z</updated>
    <summary>SPI占空比编码驱动WS2812</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用 SPI 的占空比编码驱动 WS2812 灯珠</h1>
<p>WS2812 只有一根数据线，对逻辑 0 和逻辑 1 的识别依赖高电平持续时间。直接用 GPIO 翻转需要严格控制每个脉冲，代码执行时间和中断都可能扰动波形。</p>
<p>这个驱动借用了 SPI 的稳定时钟：先把每个 WS2812 数据位展开成一个 SPI 字节，再让 SPI 和 DMA 连续输出。完整代码见 <a href="https://github.com/createskyblue/STM32_HAL_SPI_WS2812">STM32 HAL SPI WS2812 驱动仓库</a>。</p>
<h2>WS2812 单线脉宽约束</h2>
<p>WS2812 接收的是固定周期内不同高电平宽度的脉冲。逻辑 0 的高电平较短，逻辑 1 的高电平较长。SPI 本身不会生成 WS2812 协议，但 MOSI 引脚可以按固定速率连续输出 0 和 1，这给了我们一个转换入口。</p>
<p>驱动规定一个 WS2812 数据位占用 8 个 SPI 位。只要 SPI 使用 MSB 优先发送，就能用一个字节中连续高电平位的数量控制脉宽。</p>
<h2>SPI 字节展开脉宽编码</h2>
<p>驱动只定义了两个编码值：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> BIT0_PULSE </span><span style="color:#B5CEA8">0x80</span><span style="color:#6A9955">  // 1000 0000</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> BIT1_PULSE </span><span style="color:#B5CEA8">0xC0</span><span style="color:#6A9955">  // 1100 0000</span></span></code></pre><p><code>0x80</code> 发送时先输出 1 个高电平位，再输出 7 个低电平位。<code>0xC0</code> 先输出 2 个高电平位，再输出 6 个低电平位。两种编码的总周期相同，高电平宽度不同，正好对应 WS2812 对逻辑 0 和逻辑 1 的判定方式。</p>
<p>这样处理后，CPU 不再逐个翻转 GPIO。SPI 外设负责稳定输出每一个小时间片，软件只需要准备字节序列。</p>
<h2>GRB 数据缓冲区</h2>
<p>每颗 WS2812 需要 24 位颜色数据，发送顺序是 G、R、B。驱动逐位读取三个颜色分量，并把原始 1 位转换成一个 SPI 字节：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *p = ws2812->led_buffer + num * </span><span style="color:#B5CEA8">24</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; </span><span style="color:#B5CEA8">8</span><span style="color:#D4D4D4">; i++) {</span></span>
<span class="line"><span style="color:#D4D4D4">    *p++ = (g &#x26; </span><span style="color:#B5CEA8">0x80</span><span style="color:#D4D4D4">) ? BIT1_PULSE : BIT0_PULSE;</span></span>
<span class="line"><span style="color:#D4D4D4">    g &#x3C;&#x3C;= </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>R 和 B 分量使用相同的展开过程。一颗灯最终占用 24 字节缓冲区，多颗灯的数据直接首尾相连。缓冲区消耗增加了 8 倍，换来了简单、连续且可交给外设发送的波形数据。</p>
<h2>DMA 连续发送</h2>
<p>颜色全部编码完成后，更新函数只做一次 DMA 发送：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> WS2812_Update</span><span style="color:#D4D4D4">(WS2812_HandleTypeDef *</span><span style="color:#9CDCFE">ws2812</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">    HAL_SPI_Transmit_DMA</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#9CDCFE">        ws2812</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">spi_handle</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">        ws2812</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">led_buffer</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">        WS2812_LED_NUM * </span><span style="color:#B5CEA8">24</span></span>
<span class="line"><span style="color:#D4D4D4">    );</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>DMA 按顺序搬运整个缓冲区，SPI 持续输出 MOSI 波形。发送期间不需要 CPU 逐位等待，长灯带也沿用同一套编码方式，只需扩大灯珠数量和缓冲区。</p>
<h2>SPI 配置与波形校准</h2>
<p><code>0x80</code> 和 <code>0xC0</code> 只决定高电平占整个编码字节的比例，实际脉宽仍由 SPI 时钟决定。移植时需要检查以下条件：</p>
<ul>
<li>MOSI 连接 WS2812 数据输入，SPI 使用 MSB 优先。</li>
<li>SPI 时钟配合 8 位展开后，逻辑 0、逻辑 1 的高低电平落在当前灯珠数据手册范围内。</li>
<li>DMA 发送完成后保留足够的低电平复位时间，再开始下一帧。</li>
<li>调整 SPI 分频或编码值后，用示波器确认 MOSI 波形。</li>
</ul>
<p>这段驱动的核心很小：用 SPI 字节中的高电平位数表达 WS2812 脉宽，再用 DMA 发送整帧。它把严格时序转换成普通的缓冲区编码问题，适合已经具备 SPI 和 DMA 外设的 MCU 工程。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="STM32"/>
    <category term="DMA"/>
    <category term="SPI"/>
  </entry>
  <entry>
    <title>串口、TCP 等流式传输 会出现粘包、拆包、等传输错误；需要用状态机解析字节流，输出完整消息帧</title>
    <link href="https://createskyblue.github.io/posts/streaming-frame-parser/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/streaming-frame-parser/</id>
    <published>2026-07-27T00:00:00.000Z</published>
    <updated>2026-07-27T00:00:00.000Z</updated>
    <summary>串口、TCP 流式传输
粘包、拆包、传输错误
状态机解析字节流
状态机输出完整消息帧</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>串口、TCP 等流式传输 会出现粘包、拆包、等传输错误；需要用状态机解析字节流，输出完整消息帧</h1>
<p>串口、TCP 这类流式接口没有消息边界，读取字节和完整消息不存在一一对应：一次读取可能只拿到部分帧数据，剩余部分在下一次读取，还可能夹杂下一帧开头，也就是常见的粘包、拆包问题。</p>
<p>解决方案采用逐字节驱动的组帧状态机：先处于等待帧头状态，匹配到帧头后读取长度字段，再按长度接收负载数据。半包时保存状态等待后续数据；数据收齐后执行帧完整性校验。</p>
<p>容易踩坑：负载数据碰巧出现帧头字节、传输损坏长度字段，都会造成帧头误识别，状态机按错误长度接收，破坏后续报文。可在帧头 + 长度后增加头部 CRC32 校验：头部校验通过才接收负载；负载数据再独立做 CRC 校验。提前拦截头部误匹配，避免污染后续帧。</p>
<p>校验成功向上交付完整帧；校验失败、长度越界、超时则丢弃残帧，回到搜寻帧头状态。缓存残留字节继续解析下一帧。依靠状态机持续处理字节流并容错，即可把无边界字节流可靠解析为完整消息。</p>
<p><img src="/posts/streaming-frame-parser/streaming-frame-parser-diagram-fixed.svg" alt="图 1 组帧状态机将连续字节流解析为完整消息帧"></p>]]></content>
    <category term="嵌入式"/>
    <category term="通信协议"/>
    <category term="TinyFrame"/>
    <category term="C语言"/>
    <category term="缓存"/>
  </entry>
  <entry>
    <title>PY32F030 VREFINT 读到 4095 时检查 5 V 信号接入的 GPIO</title>
    <link href="https://createskyblue.github.io/posts/py32f030-vrefint-gpio-overvoltage/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/py32f030-vrefint-gpio-overvoltage/</id>
    <published>2026-07-26T00:00:00.000Z</published>
    <updated>2026-07-26T00:00:00.000Z</updated>
    <summary>外部电路上电后，MCU 内部基准读数突然满量程</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>PY32F030 VREFINT 读到 4095 时检查 5 V 信号接入的 GPIO</h1>
<blockquote>
<p><strong>结论：</strong> 本板开启外部 DCDC 后，5 V 电源域的输出信号进入工作在 3.3 V 电源域的 MCU，相关 GPIO 出现高于 VDD 的电压，VREFINT 随即饱和到 <code>4095</code>。关闭 DCDC 后，GPIO 电压恢复，VREFINT 也立即恢复正常。</p>
</blockquote>
<h2>供电结构与异常现象</h2>
<p>业务操作需要开启外部 DCDC，由 DCDC 输出 5 V，为其他电路供电。MCU 仍由 3.3 V 供电，但 5 V 电源域中的部分输出信号会接入 MCU GPIO。</p>
<p>DCDC 关闭时，VREFINT 原始值约为 <code>1470</code>，反推 VDD 约为 <code>3.3 V</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>[VREF] raw=1476 vdd_mv=3330</span></span></code></pre><p>DCDC 开启后，连续两次转换都到满量程：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>[VREF] raw=4095 first_raw=4095 vdd_mv=1200</span></span></code></pre><h2>DCDC 开关的单变量验证</h2>
<p>每次只改变一个硬件状态：</p>
<table>
<thead>
<tr>
<th>操作</th>
<th align="right">VREFINT 原始值</th>
</tr>
</thead>
<tbody><tr>
<td>GPIO 时钟和输出初始化</td>
<td align="right">约 <code>1470</code></td>
</tr>
<tr>
<td>DCDC 使能脚配置为输出</td>
<td align="right">约 <code>1470</code></td>
</tr>
<tr>
<td>DCDC 开启</td>
<td align="right"><code>4095</code></td>
</tr>
<tr>
<td>DCDC 关闭</td>
<td align="right">立即恢复到约 <code>1470</code></td>
</tr>
</tbody></table>
<p>DCDC 使能脚完成 GPIO 初始化时，VREFINT 仍然正常。异常只在 DCDC 实际输出 5 V 后出现。ADC 重置和 VREFINT 通道重建也没有改变这一对应关系，因此排查方向需要从 ADC 配置转向 5 V 电源域与 MCU 之间的信号连接。</p>
<h2>5 V 信号跨电源域的触发链路</h2>
<p>示波器确认了以下触发链路：</p>
<ol>
<li>开启外部 DCDC，5 V 电源域开始工作。</li>
<li>该电源域中的部分电路向 MCU 输出信号。</li>
<li>相关 GPIO 电压高于 MCU 的 3.3 V VDD。</li>
<li>VREFINT 固定为 <code>4095</code>。</li>
</ol>
<p>关闭 DCDC 后，GPIO 过压消失，VREFINT 无需重建 ADC 或重启 MCU 便立即恢复。现有证据可以确认 GPIO 过压与 VREFINT 异常同步出现，但没有直接证明具体的片内耦合路径。</p>
<h2>ADC 结果的失效边界</h2>
<p>异常日志中的 <code>1200 mV</code> 是由满量程读数反推得到的错误结果。VREFINT 已经读到 <code>4095</code> 时，基于该值计算的 VDD、电池电压或阈值判断都不能继续使用。</p>
<h2>跨电源域信号的排查顺序</h2>
<p>再次遇到 VREFINT 满量程时：</p>
<ol>
<li>测量 MCU 的真实 VDD。</li>
<li>检查外部电源域与 MCU 之间的全部信号线。</li>
<li>测量相关 GPIO，找出电压高于 MCU VDD 的引脚。</li>
<li>关闭对应外设供电，观察 GPIO 电压与 VREFINT 是否同时恢复。</li>
</ol>
<p>确认触发关系后，再根据接口类型处理电平兼容问题。具体电路改法取决于信号方向、速度和驱动方式，本文不展开。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="ADC"/>
    <category term="故障诊断"/>
    <category term="工程调试"/>
  </entry>
  <entry>
    <title>KCP 在 UDP 上做可靠传输：RTO 不翻倍、选择性重传与快速重传</title>
    <link href="https://createskyblue.github.io/posts/kcp-protocol/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/kcp-protocol/</id>
    <published>2026-07-22T00:00:00.000Z</published>
    <updated>2026-07-22T00:00:00.000Z</updated>
    <summary>TCP 丢包延迟翻倍，KCP 用带宽换延迟做可靠传输</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>KCP 在 UDP 上做可靠传输：RTO 不翻倍、选择性重传与快速重传</h1>
<blockquote>
<p><strong>声明：</strong> KCP 是一个开源项目，作者为 <a href="https://github.com/skywind3000/kcp">林伟 (skywind3000)</a>。本文是阅读该项目源码和文档后整理的学习笔记，用于理解可靠传输协议的工程实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>项目仓库：<a href="https://github.com/skywind3000/kcp" target="_blank" rel="noopener noreferrer">github.com/skywind3000/kcp</a>（UDP 之上的快速可靠 ARQ 协议）</p>
<p>TCP 在公网丢包时延迟表现不理想。超时重传让 RTO 翻倍，丢包后触发全部重传，延迟 ACK 又抬高 RTT 估算。这些机制为最大化吞吐量设计，代价是丢包时延迟显著上升。</p>
<p>游戏、实时视频、VPN 隧道对延迟敏感，能容忍少量丢包和带宽冗余，但不能接受几百毫秒的卡顿。KCP 为这类场景设计，是一套快速可靠 ARQ 协议：比 TCP 多消耗 10% 至 20% 带宽，换取平均延迟下降 30% 至 40%、最大延迟下降约三倍。</p>
<p>协议只有 <code>ikcp.h</code> 与 <code>ikcp.c</code> 两个文件，不含任何系统调用。底层收发、时钟源、定时调度全部由使用者通过回调提供。</p>
<h2>协议分层：KCP 只做 ARQ 算法层</h2>
<p>KCP 是一套纯 ARQ 算法层，不含完整网络库的收发与调度职责，连时钟也由外部传入。</p>
<p>创建 KCP 对象后，使用者提供三样东西：</p>
<ul>
<li><strong>输出回调</strong>：KCP 组装好协议包，通过 <code>output</code> 回调交给使用者发送。底层是 UDP、串口还是其他通道，KCP 不关心。</li>
<li><strong>时钟</strong>：<code>ikcp_update(kcp, current)</code> 需要外部传入当前毫秒时间戳。内部所有超时判断都基于这个时间。</li>
<li><strong>定时调度</strong>：使用者周期调用 <code>ikcp_update</code>，或用 <code>ikcp_check</code> 获取下次调度的精确时间点。KCP 内部不启动任何定时器。</li>
</ul>
<p><strong>完整帧输入</strong>：<code>ikcp_input</code> 每次调用必须传入一个完整的 KCP 段：24 字节头部加上 <code>len</code> 字段指定的数据体，缺一不可。源码中 <code>size &lt; len</code> 直接返回 -2，内部没有对不完整段的缓冲。KCP 设计时假设底层是 UDP 这类数据报传输，一个 <code>send</code> 对应一个完整段；如果底层是串口、TCP 隧道等流式通道，调用方必须自己做帧定界，收齐一个完整段再交给 <code>ikcp_input</code>，否则会被当作协议错误。</p>
<p>收到数据调 <code>ikcp_input</code> 喂入完整段，发送调 <code>ikcp_send</code>，接收调 <code>ikcp_recv</code>。五个 API 构成完整交互面。</p>
<p>这种拆开的设计让 KCP 可嵌入任意协议栈：下层加 FEC 纠删码，上层加流加密，握手层做非对称密钥交换。每个协议单元独立可替换。</p>
<h2>段格式与四条内部队列</h2>
<p>KCP 只有一种包格式，数据和控制消息共用同一个 24 字节头部：</p>
<svg viewBox="0 0 690 96" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;background:#f8fafc;border-radius:8px;">
  <rect x="10" y="20" width="110" height="48" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="65" y="46" text-anchor="middle" fill="#1e293b" font-size="15" font-family="monospace">conv</text>
  <text x="65" y="78" text-anchor="middle" fill="#475569" font-size="12">4 B</text>

  <rect x="120" y="20" width="28" height="48" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="134" y="46" text-anchor="middle" fill="#1e293b" font-size="13" font-family="monospace">cmd</text>
  <text x="134" y="78" text-anchor="middle" fill="#475569" font-size="12">1 B</text>

  <rect x="148" y="20" width="28" height="48" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="162" y="46" text-anchor="middle" fill="#1e293b" font-size="13" font-family="monospace">frg</text>
  <text x="162" y="78" text-anchor="middle" fill="#475569" font-size="12">1 B</text>

  <rect x="176" y="20" width="56" height="48" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="204" y="46" text-anchor="middle" fill="#1e293b" font-size="13" font-family="monospace">wnd</text>
  <text x="204" y="78" text-anchor="middle" fill="#475569" font-size="12">2 B</text>

  <rect x="232" y="20" width="110" height="48" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="287" y="46" text-anchor="middle" fill="#1e293b" font-size="14" font-family="monospace">ts</text>
  <text x="287" y="78" text-anchor="middle" fill="#475569" font-size="12">4 B</text>

  <rect x="342" y="20" width="110" height="48" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="397" y="46" text-anchor="middle" fill="#1e293b" font-size="14" font-family="monospace">sn</text>
  <text x="397" y="78" text-anchor="middle" fill="#475569" font-size="12">4 B</text>

  <rect x="452" y="20" width="110" height="48" fill="#dbeafe" stroke="#60a5fa" stroke-width="1.5"/>
  <text x="507" y="46" text-anchor="middle" fill="#1e293b" font-size="14" font-family="monospace">una</text>
  <text x="507" y="78" text-anchor="middle" fill="#475569" font-size="12">4 B</text>

  <rect x="562" y="20" width="110" height="48" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="617" y="46" text-anchor="middle" fill="#1e293b" font-size="14" font-family="monospace">len</text>
  <text x="617" y="78" text-anchor="middle" fill="#475569" font-size="12">4 B</text>

<p>  <text x="345" y="14" text-anchor="middle" fill="#64748b" font-size="12">← 24 字节固定头部 →</text>
  <text x="617" y="14" text-anchor="middle" fill="#64748b" font-size="12">DATA（可选）</text>
</svg></p>
<ul>
<li><strong>conv</strong>：会话编号，两端必须一致，用于区分不同连接</li>
<li><strong>cmd</strong>：命令类型，PUSH(81) 数据推送、ACK(82) 确认、WASK(83) 窗口探测、WINS(84) 窗口通知</li>
<li><strong>frg</strong>：分片编号，从 <code>count-1</code> 递减到 0，接收端据此合并分片</li>
<li><strong>wnd</strong>：接收窗口剩余大小，用于流量控制</li>
<li><strong>ts</strong>：时间戳，用于 RTT 计算</li>
<li><strong>sn</strong>：包序号</li>
<li><strong>una</strong>：未确认序号，此编号之前的包全部已收到</li>
<li><strong>len</strong>：后续数据体字节数</li>
</ul>
<p>注意 <code>una</code> 字段：KCP 的每个包（包括数据包）都携带 UNA 信息。TCP 只在 ACK 包中确认，KCP 让每个发出的包同时承担 ACK 职责。即使反向没有独立 ACK 包，确认信息也能顺带到达。</p>
<p>KCP 内部维护四条队列，收发两条路径各自独立：</p>
<svg viewBox="0 0 690 180" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;background:#f8fafc;border-radius:8px;">
  <text x="20" y="24" fill="#334155" font-size="13" font-weight="bold">发送路径</text>
  <rect x="20" y="36" width="86" height="36" rx="5" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="63" y="59" text-anchor="middle" fill="#1e293b" font-size="13">ikcp_send</text>
  <line x1="106" y1="54" x2="128" y2="54" stroke="#475569" stroke-width="2" marker-end="url(#a)"/>
  <rect x="130" y="36" width="96" height="36" rx="5" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="178" y="59" text-anchor="middle" fill="#1e293b" font-size="13">snd_queue</text>
  <line x1="226" y1="54" x2="248" y2="54" stroke="#475569" stroke-width="2" marker-end="url(#a)"/>
  <rect x="250" y="36" width="150" height="36" rx="5" fill="#dbeafe" stroke="#60a5fa" stroke-width="1.5"/>
  <text x="325" y="53" text-anchor="middle" fill="#1e293b" font-size="12">ikcp_flush</text>
  <text x="325" y="66" text-anchor="middle" fill="#475569" font-size="11">分配序号/时间戳</text>
  <line x1="400" y1="54" x2="422" y2="54" stroke="#475569" stroke-width="2" marker-end="url(#a)"/>
  <rect x="424" y="36" width="96" height="36" rx="5" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="472" y="59" text-anchor="middle" fill="#1e293b" font-size="13">snd_buf</text>
  <line x1="520" y1="54" x2="542" y2="54" stroke="#475569" stroke-width="2" marker-end="url(#a)"/>
  <rect x="544" y="36" width="110" height="36" rx="5" fill="#fef9c3" stroke="#eab308" stroke-width="1.5"/>
  <text x="599" y="59" text-anchor="middle" fill="#1e293b" font-size="13">网络/UDP</text>

<p>  <text x="20" y="118" fill="#334155" font-size="13" font-weight="bold">接收路径</text>
  <rect x="20" y="130" width="96" height="36" rx="5" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="68" y="153" text-anchor="middle" fill="#1e293b" font-size="13">ikcp_input</text>
  <line x1="116" y1="148" x2="138" y2="148" stroke="#475569" stroke-width="2" marker-end="url(#a)"/>
  <rect x="140" y="130" width="96" height="36" rx="5" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="188" y="153" text-anchor="middle" fill="#1e293b" font-size="13">rcv_buf</text>
  <line x1="236" y1="148" x2="258" y2="148" stroke="#475569" stroke-width="2" marker-end="url(#a)"/>
  <rect x="260" y="130" width="96" height="36" rx="5" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1.5"/>
  <text x="308" y="153" text-anchor="middle" fill="#1e293b" font-size="13">rcv_queue</text>
  <line x1="356" y1="148" x2="378" y2="148" stroke="#475569" stroke-width="2" marker-end="url(#a)"/>
  <rect x="380" y="130" width="96" height="36" rx="5" fill="#dcfce7" stroke="#22c55e" stroke-width="1.5"/>
  <text x="428" y="153" text-anchor="middle" fill="#1e293b" font-size="13">ikcp_recv</text></p>
  <defs>
    <marker id="a" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
      <path d="M0,0 L6,3 L0,6 Z" fill="#475569"/>
    </marker>
  </defs>
</svg>

<p><code>ikcp_send</code> 把数据写入 <code>snd_queue</code>，<code>ikcp_flush</code> 将其搬到 <code>snd_buf</code>，分配序号与时间戳，交给 <code>output</code> 发送。<code>snd_buf</code> 中已发送的包等待对方确认，确认后才移除。</p>
<p>接收端 <code>ikcp_input</code> 把收到的包插入 <code>rcv_buf</code>（按序号排序的接收缓冲），再按序搬到 <code>rcv_queue</code>。<code>rcv_queue</code> 中的数据连续可读，<code>ikcp_recv</code> 从这里取走。</p>
<h2>RTO 不翻倍与选择性重传</h2>
<p>TCP 在超时重传时把 RTO 翻倍（RTO × 2），连续丢三次包变成 RTO × 8。丢包越多，等待时间指数增长。</p>
<p>KCP 在快速模式下，RTO 每次只乘 1.5。RTO 增长曲线被压低，丢包后恢复更快。配合 <code>rx_minrto</code> 可设到 30ms（TCP 默认最小 RTO 为 200ms），进一步压低等待下限。</p>
<p>重传策略上，TCP 采用累积确认：收到 ACK=N 只表示 N 之前的包全收到。如果包 2 丢失、包 3/4/5 已到达，TCP 仍只确认到包 1，发送方必须从包 2 开始全部重传（Go-Back-N）。</p>
<p>KCP 同时使用 UNA 和独立 ACK。收到包 3 时，除了 UNA 告知&quot;包 1 之前全收到&quot;，还会为包 3 单独生成 ACK。发送方收到 ACK 1、3、4、5 后，知道包 2 被跳过，只重传包 2。</p>
<svg viewBox="0 0 690 200" xmlns="http://www.w3.org/2000/svg" style="max-width:100%;height:auto;background:#f8fafc;border-radius:8px;">
  <rect x="10" y="14" width="320" height="170" rx="8" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5"/>
  <text x="170" y="36" text-anchor="middle" fill="#334155" font-size="13" font-weight="bold">TCP 累积确认（Go-Back-N）</text>
  <rect x="30" y="50" width="40" height="30" rx="4" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="50" y="70" text-anchor="middle" fill="#1e293b" font-size="13">1</text>
  <rect x="78" y="50" width="40" height="30" rx="4" fill="#fee2e2" stroke="#ef4444"/>
  <text x="98" y="70" text-anchor="middle" fill="#1e293b" font-size="13">2✕</text>
  <rect x="126" y="50" width="40" height="30" rx="4" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="146" y="70" text-anchor="middle" fill="#1e293b" font-size="13">3</text>
  <rect x="174" y="50" width="40" height="30" rx="4" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="194" y="70" text-anchor="middle" fill="#1e293b" font-size="13">4</text>
  <rect x="222" y="50" width="40" height="30" rx="4" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="242" y="70" text-anchor="middle" fill="#1e293b" font-size="13">5</text>
  <text x="170" y="102" text-anchor="middle" fill="#475569" font-size="13">ACK = 1（只确认到包 1）</text>
  <line x1="170" y1="112" x2="170" y2="132" stroke="#475569" stroke-width="2" marker-end="url(#b)"/>
  <rect x="70" y="138" width="200" height="32" rx="5" fill="#fee2e2" stroke="#ef4444" stroke-width="1.5"/>
  <text x="170" y="159" text-anchor="middle" fill="#1e293b" font-size="13">重传 2、3、4、5（全部）</text>

  <rect x="350" y="14" width="320" height="170" rx="8" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5"/>
  <text x="510" y="36" text-anchor="middle" fill="#334155" font-size="13" font-weight="bold">KCP UNA + 独立 ACK</text>
  <rect x="370" y="50" width="40" height="30" rx="4" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="390" y="70" text-anchor="middle" fill="#1e293b" font-size="13">1</text>
  <rect x="418" y="50" width="40" height="30" rx="4" fill="#fee2e2" stroke="#ef4444"/>
  <text x="438" y="70" text-anchor="middle" fill="#1e293b" font-size="13">2✕</text>
  <rect x="466" y="50" width="40" height="30" rx="4" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="486" y="70" text-anchor="middle" fill="#1e293b" font-size="13">3</text>
  <rect x="514" y="50" width="40" height="30" rx="4" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="534" y="70" text-anchor="middle" fill="#1e293b" font-size="13">4</text>
  <rect x="562" y="50" width="40" height="30" rx="4" fill="#e2e8f0" stroke="#94a3b8"/>
  <text x="582" y="70" text-anchor="middle" fill="#1e293b" font-size="13">5</text>
  <text x="510" y="102" text-anchor="middle" fill="#475569" font-size="13">ACK 1、3、4、5（跳过 2）</text>
  <line x1="438" y1="112" x2="438" y2="132" stroke="#475569" stroke-width="2" marker-end="url(#b)"/>
  <rect x="378" y="138" width="120" height="32" rx="5" fill="#dcfce7" stroke="#22c55e" stroke-width="1.5"/>
  <text x="438" y="159" text-anchor="middle" fill="#1e293b" font-size="13">只重传 2</text>

  <defs>
    <marker id="b" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
      <path d="M0,0 L6,3 L0,6 Z" fill="#475569"/>
    </marker>
  </defs>
</svg>

<p>两种确认的组合让 KCP 不必在&quot;全部重传&quot;与&quot;每条都独立 ACK 开销过大&quot;之间二选一。</p>
<h2>快速重传：不等超时的 fastack 计数</h2>
<p>选择性重传避免了全部重传，但仍要等到超时。KCP 的快速重传机制在超时前即可触发重传。</p>
<p>每个已发送的段（<code>IKCPSEG</code>）内部维护一个 <code>fastack</code> 计数器。当收到的 ACK 跳过了某个包，包 2 的 <code>fastack</code> 随之累加：收到 ACK 3 时加 1，收到 ACK 4 时再加 1。<code>fastack</code> 达到阈值（<code>fastresend</code> 参数，通常设为 2）时，不等超时，立即重传。</p>
<p>源码中 <code>ikcp_parse_fastack</code> 负责更新计数器，<code>ikcp_flush</code> 遍历 <code>snd_buf</code> 时检查 <code>segment-&gt;fastack &gt;= resent</code>，满足条件就标记 <code>needsend = 1</code>。</p>
<p>快速重传配合 RTO × 1.5 和低 <code>rx_minrto</code>，形成 KCP 降低延迟的三层机制：丢包时先靠快速重传抢时间，抢不到再靠压低后的超时兜底。</p>
<h2>可关闭的拥塞控制与可插拔 CC 接口</h2>
<p>KCP 内置的流控算法与 TCP 类似：慢启动阶段 <code>cwnd</code> 指数增长，拥塞避免阶段线性增长，丢包时 <code>ssthresh</code> 减半、<code>cwnd</code> 回退。</p>
<p>但 KCP 允许跳过拥塞控制。调用 <code>ikcp_nodelay(kcp, 1, 10, 2, 1)</code> 时，最后一个参数 <code>nc=1</code> 关闭流控。此时的发送窗口仅由 <code>snd_wnd</code> 和远端 <code>rmt_wnd</code> 决定，不再受丢包退让和慢启动约束。代价是牺牲公平性，网络上其他 TCP 流可能在竞争中吃亏。</p>
<p>KCP V2 引入了可插拔的拥塞控制接口 <code>IKCPOPS</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> IKCPOPS {</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *name;</span></span>
<span class="line"><span style="color:#569CD6">    int</span><span style="color:#D4D4D4">  (*init)(ikcpcb *kcp);</span></span>
<span class="line"><span style="color:#569CD6">    void</span><span style="color:#D4D4D4"> (*release)(ikcpcb *kcp);</span></span>
<span class="line"><span style="color:#569CD6">    void</span><span style="color:#D4D4D4"> (*on_ack)(ikcpcb *kcp, IUINT32 acked_segs, IUINT32 acked_bytes, IUINT32 prior_in_flight);</span></span>
<span class="line"><span style="color:#569CD6">    void</span><span style="color:#D4D4D4"> (*on_fast_retransmit)(ikcpcb *kcp, IUINT32 fast_retrans, IUINT32 inflight, IUINT32 prior_cwnd);</span></span>
<span class="line"><span style="color:#569CD6">    void</span><span style="color:#D4D4D4"> (*on_timeout)(ikcpcb *kcp, IUINT32 prior_cwnd);</span></span>
<span class="line"><span style="color:#6A9955">    // ...</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><p>通过 <code>ikcp_setcc</code> 可以替换整套流控算法，不需要修改协议本身。内置算法与自定义算法可在同一份 KCP 代码上切换。</p>
<h2>适用边界与上层职责</h2>
<p>KCP 已被多个用户规模上亿的项目采用：米哈游《原神》用 KCP 降低游戏消息传输耗时，SpatialOS 用它加速大型多人游戏服务端，网易 CC 和 BOBO 用它加速视频推流，kcptun 用它做 TCP over UDP 隧道加速。</p>
<p>KCP 适合的场景特征：延迟敏感、能容忍适度带宽增加、数据包较小且频繁发送。不适合的场景：需要严格公平共享带宽、传输大文件追求最高吞吐量。</p>
<p>工程上要明确两层职责边界：KCP 默认不加密，加密由上层处理；KCP 不负责握手和连接管理，会话建立和 <code>conv</code> 分配由上层协议完成。</p>
<hr>
<p>源码只有两个文件，阅读入口清晰：</p>
<ul>
<li><code>ikcp.h</code>：数据结构定义（<code>IKCPSEG</code>、<code>IKCPCB</code>、<code>IKCPOPS</code>）和全部 API 声明</li>
<li><code>ikcp.c</code>：<code>ikcp_input</code> 解析输入包并驱动 ACK/重传逻辑，<code>ikcp_flush</code> 组装输出包并执行发送，<code>ikcp_update</code> 按间隔触发 flush</li>
</ul>
<p>本文核对的源码基线：<a href="https://github.com/skywind3000/kcp/commit/b1a7a2101dcbb96017681a500d6b82bbe5a88766">KCP commit b1a7a21</a></p>]]></content>
    <category term="通信协议"/>
    <category term="KCP"/>
    <category term="C语言"/>
    <category term="性能优化"/>
  </entry>
  <entry>
    <title>光感突变遥测设备的低功耗采样与射频时序取舍</title>
    <link href="https://createskyblue.github.io/posts/air001-light-sensing-mutation-switch-433rf/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/air001-light-sensing-mutation-switch-433rf/</id>
    <published>2026-07-19T00:00:00.000Z</published>
    <updated>2026-07-19T00:00:00.000Z</updated>
    <summary>光感遥测设备：周期唤醒采样、射频脉宽修正</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>光感突变遥测设备的低功耗采样与射频时序取舍</h1>
<p>该设备用于监测环境光突变，并通过 433MHz 上报遥测事件。检测到光线快速变暗时，立即发送光线下降遥测信号；计时到 30 分钟时发送结束信号；期间检测到光线上升时，发送光线上升遥测信号。项目使用 PY32F002A，实测平均电流 12µA。</p>
<p>工程重点只有两个：平时怎样以足够低的功耗识别光照变化，事件发生时怎样稳定发出接收端能识别的脉冲。具体外设配置会随平台变化，本文只保留影响设计选择的部分。</p>
<h2>STOP 模式无法让 ADC 持续值守</h2>
<p>最直观的做法是让 ADC 模拟看门狗持续监测，越过阈值后再唤醒 CPU。问题是深度睡眠会关闭 ADC 及其时钟，模拟看门狗也随之停止；保留 ADC 工作只能使用功耗更高的睡眠模式。</p>
<p>本项目检测的是秒级光照变化，没有必要为毫秒级响应长期供电。因此 MCU 平时进入 STOP 模式，由低功耗定时器每秒唤醒一次，完成 ADC 采样后立即回睡。信号变化速度决定采样策略，具体 MCU 型号不改变这项判断。</p>
<h2>秒级采样下的光突变判定</h2>
<p>每次唤醒后连续采样并取平均，用于压低随机噪声；最近三个结果组成滑动窗口，窗口首尾差同时满足幅度和方向条件时才累计确认。连续两次命中后发送对应方向的遥测信号，单个尖刺不会直接触发。</p>
<p>判断采用相对变化量，因此不要求把传感器电压换算成绝对照度。发送光线下降遥测信号后先暂停一次判断，再以新光照重建基准，避免后续波动立刻被误判为光线上升。30 分钟结束信号由独立超时计时负责。</p>
<p>这套思路适合变化缓慢、允许秒级延迟的场景。需要快速响应时，应重新评估采样周期和睡眠模式。</p>
<h2>HAL GPIO 操作让射频脉宽偏长</h2>
<p>最初的发码逻辑先调用 <code>HAL_GPIO_WritePin</code> 翻转电平，再延时 325µs 或 950µs。4MHz 主频下，GPIO 调用本身约耗时 40µs，这段时间被算进了脉冲，325µs 短脉冲实测接近 365µs。</p>
<p>修正分为两步：先缩短 GPIO 翻转路径，本项目直接写置位和复位寄存器；再按定时器的目标时刻切换每个边沿。后一条更重要，因为每个边沿都对齐预定时间，某次执行开销只影响当前等待，不会沿整帧累积。</p>
<p>可迁移的结论很简单：软件生成脉冲时，延时函数只控制调用后的等待时间，GPIO 操作和循环开销同样属于脉宽。最终应以示波器看到的边沿为准。</p>
<p><img src="/posts/air001-light-sensing-mutation-switch-433rf/img/%E5%AE%9A%E6%97%B6%E5%99%A8%E8%BE%B9%E6%B2%BF%E5%AF%B9%E9%BD%90.svg" alt="相对延时会累积 GPIO 开销，绝对时间对齐可阻止误差传递"></p>
<h2>外设断电与 GPIO 漏电控制</h2>
<p>未使用的 IO 若保留数字输入功能，悬空电平或外部干扰会使输入缓冲器中的施密特触发器反复翻转，产生额外功耗。待机时应把这些 IO 配置为模拟输入，或用芯片提供的方式关闭数字输入通道，同时关闭内部上下拉。</p>
<p>射频模块等非必要外设直接断电。外设断电后，连接到该外设的 IO 也要关闭数字输入和上下拉，避免信号脚反向供电或形成漏电路径；相邻的未使用 IO 同样按模拟输入处理。需要工作时再恢复外设供电和引脚功能，结束后重新关闭。</p>
<p>低功耗测试不能只测 MCU 的睡眠电流，还要检查外设供电、指示灯和所有 IO 状态。事件很少时，长期漏电通常比一次短暂发射更影响电池寿命。</p>
<h2>低功耗实测结果</h2>
<p><img src="/posts/air001-light-sensing-mutation-switch-433rf/img/%E5%8A%9F%E7%8E%87%E5%88%86%E6%9E%90%E4%BB%AA%E6%B3%A2%E5%BD%A2%E5%9B%BE.webp" alt="STOP 模式下每秒一次的唤醒电流尖峰"></p>
<p><img src="/posts/air001-light-sensing-mutation-switch-433rf/img/%E5%8A%9F%E7%8E%87%E5%88%86%E6%9E%90%E4%BB%AA%E6%95%B0%E6%8D%AE%E9%A1%B5%E9%9D%A2.webp" alt="功率分析仪统计页面"></p>
<p>功率分析仪测得平均电流 12µA，STOP 模式基线最低 8µA，每秒采样的唤醒峰值约 1.7mA。结果说明周期唤醒适合本项目的秒级信号，射频发射虽有较高瞬时电流，但事件频率低，不主导长期平均功耗。</p>]]></content>
    <category term="PY32F002A"/>
    <category term="低功耗"/>
    <category term="ADC"/>
    <category term="433MHz"/>
    <category term="射频"/>
    <category term="传感器"/>
  </entry>
  <entry>
    <title>FIFO批量取出后无法原位放回，先进入暂存区等待下一次消费</title>
    <link href="https://createskyblue.github.io/posts/batch-ack-fifo-cache/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/batch-ack-fifo-cache/</id>
    <published>2026-07-19T00:00:00.000Z</published>
    <updated>2026-07-19T00:00:00.000Z</updated>
    <summary>FIFO批量取出后写回会乱序，用暂存区等下次消费</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>FIFO批量取出后无法原位放回，先进入暂存区等待下一次消费</h1>
<h2>痛点：从队列取出后，这批数据还不能丢</h2>
<p>发送链路里常见这样一条路径：协议栈把待发数据写入 FIFO；发送模块按批取出，交给串口、总线或对端；发送完成或收到对端确认之前，这批数据可能还要重发。</p>
<p>问题来了：<code>read</code> / <code>pop</code> 取出后，读指针已经前进，数据离开 FIFO。发送还没确认成功时，数据不能丢；但队列还在继续收新数据。这批「正在路上」的数据该放哪？</p>
<p><img src="/posts/batch-ack-fifo-cache/fifo-consumer-staging-area.svg" alt="FIFO 写回乱序与消费者暂存区"></p>
<h2>写回 FIFO 只能追加到队尾</h2>
<p>普通 FIFO 只有队尾追加，没有「插回原位」。</p>
<p>举例：队列里是 A、B、C、D。取出 A、B 发送后，队列只剩 C、D。发送还没确认时，协议栈又写入 E、F，队列变成 C、D、E、F。</p>
<p>此时把 A、B 写回，结果是 C、D、E、F、A、B。原本更早的数据跑到了新数据后面，顺序已经错了。所以「取出后若失败就塞回队列」这条路走不通。</p>
<h2>确认前这批数据必须保留</h2>
<p>正在发送的那批，丢不得。</p>
<p>发送成功或对端确认之前，消费者手里必须一直握着这批数据。确认之后才能丢弃。否则超时重发时会发现：数据已经不在了。</p>
<p>MQTT 的发布确认（PUBACK）是同一类事：发送方先在本地暂存，收到对端确认后再删除。核心就一句：发出不等于完成；确认前要能重来。</p>
<h2>消费者暂存区接管当前批次</h2>
<p>确认前数据要能重来，又不能写回 FIFO，只能放在队列外面。</p>
<p>消费者在取出时立刻把当前批次放进自己的暂存区，再交给发送路径。FIFO 照常接收 E、F，和暂存区互不干扰。</p>
<p>下一次消费（发送成功、收到确认）发生后，才清空暂存区，再从 FIFO 取下一批。在此之前暂存区保持不变。释放只决定暂存区何时清空，不回退读指针，也不把数据送回队列。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="通信协议"/>
    <category term="FIFO"/>
    <category term="缓存"/>
  </entry>
  <entry>
    <title>基于序号确认与超时重传的双向透传</title>
    <link href="https://createskyblue.github.io/posts/sync-request-response-fifo-exchange/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/sync-request-response-fifo-exchange/</id>
    <published>2026-07-19T00:00:00.000Z</published>
    <updated>2026-07-19T00:00:00.000Z</updated>
    <summary>半双工链路下的可靠 FIFO 交换设计</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>请求应答链路双向透传设计</h1>
<p>SPI、NFC 的数据块交换通常由一端发起，对端只能应答。对端有数据时也不能主动发送，只能等下一轮请求。</p>
<p>CH58X 的 NFC-A 实现见 <a href="/posts/ch58x-nfc-p2p-data-exchange">两个 CH58X 芯片通过 NFC-A 实现 FIFO 数据透传</a>。</p>
<p>协议处理直接写进传输层时，协议变化会牵动 SPI 或 NFC 代码。把协议解析放到上层，SPI 和 NFC 只负责数据透传。</p>
<p>两端各放一组发送 FIFO 和接收 FIFO：请求带走主机的数据，应答带回从机的数据。原有问答时序不变，上层得到一条双向字节通道。</p>
<p><img src="/posts/sync-request-response-fifo-exchange/fifo-exchange-sequence.svg" alt="请求应答链路搬运双方 FIFO 数据并捎带确认"></p>
<h2>半双工：只有主机能主动发</h2>
<p>SPI 和 NFC 是主从结构。主机发起每一轮通信，从机只能应答，不能主动发送。从机积攒了数据，也必须等主机下一次轮询才能在应答里带出去。</p>
<h2>一轮搬运两边的 FIFO</h2>
<p>主机从自己的发送 FIFO 取一批数据发给从机，从机收到后把自己的发送 FIFO 数据打包进应答回给主机。一轮请求-应答，两边的 FIFO 各搬一次。</p>
<h2>丢包导致交换中断</h2>
<p>但数据包可能在传输中丢失。主机发了从机没收到，或者从机回了主机没收到。不管哪种情况，交换都断了。主机不知道从机收没收到，两边各少一批数据。</p>
<h2>用序号确认每批数据</h2>
<p>解决办法是给每批数据加一个递增的序号。主机从 FIFO 取出数据后不直接丢弃，先放进暂存区，连同序号一起发给从机。</p>
<p>从机收到后做两件事：把收到的序号原样放进应答作为确认号，同时把自己的发送 FIFO 数据打包带回去。</p>
<p>FIFO 出队后的保存位置和回写风险，见 <a href="/posts/batch-ack-fifo-cache">FIFO 出队数据应进入消费者暂存区</a>。</p>
<h2>超时重发，序号不变</h2>
<p>主机发出后等一段时间，没收到从机应答，就把暂存区里同一批数据、同一个序号再发一次。一直重发，直到从机在应答里把刚才收到的序号原样带了回来。这说明从机确实收到了。主机释放暂存区，从 FIFO 取下一批，序号递增。</p>
<h2>重传的数据不入队</h2>
<p>从机收到数据时看一眼序号：这个序号见过没有？见过就是重传，应答里照常回确认号，但数据丢弃，不入队。没见过才是新数据，正常入队。</p>
<p>两端各维护自己的序号，独立递增，互不干扰。</p>
<h2>没数据也要发空请求</h2>
<p>主机没有数据时仍按约定节奏发空请求。序号照常带，数据为空。从机借此机会在应答中送出自己积攒的数据。主机在下一轮请求里带上对从机这批数据的确认号。</p>
<h2>确认号只表示数据入队</h2>
<p>接收 FIFO 满时不更新确认号，发送方继续保留当前批次。确认号只说明数据已经进入接收 FIFO，不代表业务命令已经执行。透传层管轮询、分批、序号、确认号、重试和 FIFO 搬运；业务层只管消息含义。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="通信协议"/>
    <category term="FIFO"/>
    <category term="ACK"/>
    <category term="缓存"/>
  </entry>
  <entry>
    <title>7种降采样算法一站式交互对比，附在线演示</title>
    <link href="https://createskyblue.github.io/posts/downsampling-demo/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/downsampling-demo/</id>
    <published>2026-07-18T00:00:00.000Z</published>
    <updated>2026-07-18T00:00:00.000Z</updated>
    <summary>7种降采样算法一站式对比，含低通滤波抗混叠演示</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>7种降采样算法一站式交互对比，附在线演示</h1>
<p>做了一个纯前端的降采样算法交互演示页面，把七种常见算法放在一起、用同一份数据实时对比，方便直观感受每种算法的取舍。</p>
<p><a href="https://createskyblue.github.io/downsampling-demo/" target="_blank" rel="noopener noreferrer">🌐 在线体验</a> · <a href="https://github.com/createskyblue/downsampling-demo" target="_blank" rel="noopener noreferrer">📂 GitHub 仓库</a></p>
<iframe id="downsampling-frame" data-auto-height="true" scrolling="no" src="/posts/downsampling-demo/downsampling-demo.html" title="7 种降采样算法交互对比演示" style="width: 100%; height: 1600px; border: 1px solid #dbe2ec; border-radius: 8px; background: #f3f4f8; overflow: hidden;"></iframe>
<script>
(() => {
  const frame = document.getElementById('downsampling-frame');
  if (!frame) return;
  const measure = () => {
    let h = 0;
    try {
      const doc = frame.contentDocument || frame.contentWindow.document;
      // 只认 .wrap：body 带 min-height:100vh，scrollHeight 会被 iframe 自身高度带偏，导致高度只增不减
      const wrap = doc.querySelector('.wrap');
      h = wrap
        ? wrap.getBoundingClientRect().height
        : Math.max(doc.documentElement.scrollHeight, doc.body.scrollHeight);
    } catch (e) { return; }
    if (h > 0) frame.style.height = `${Math.ceil(h) + 24}px`;
  };
  const schedule = () => requestAnimationFrame(() => requestAnimationFrame(measure));
  frame.addEventListener('load', () => {
    schedule();
    try {
      const doc = frame.contentDocument;
      if (doc && doc.documentElement && window.ResizeObserver) {
        new ResizeObserver(schedule).observe(doc.documentElement);
      }
    } catch (e) {}
  });
  window.addEventListener('resize', schedule);
})();
</script>

<h2>七种算法覆盖</h2>
<p>页面内置七种降采样算法，从最简单的等间隔到自适应精度，覆盖了实际工程中常见的策略：</p>
<table>
<thead>
<tr>
<th>算法</th>
<th>一句话</th>
</tr>
</thead>
<tbody><tr>
<td><strong>直接抽样</strong></td>
<td>等间隔取点，O(1) 决策，最快但也最容易丢特征</td>
</tr>
<tr>
<td><strong>均值降采样</strong></td>
<td>分桶取平均，自带抗混叠效果，但会削平尖峰</td>
</tr>
<tr>
<td><strong>中位数降采样</strong></td>
<td>分桶取中位数，对异常尖峰免疫</td>
</tr>
<tr>
<td><strong>Min-Max</strong></td>
<td>每桶保留最大+最小值，包络带可视化</td>
</tr>
<tr>
<td><strong>LTTB</strong></td>
<td>最大三角形三桶法，视觉保真度最优</td>
</tr>
<tr>
<td><strong>分段线性</strong></td>
<td>分段做线性回归逼近，适合趋势分析</td>
</tr>
<tr>
<td><strong>RDP</strong></td>
<td>Ramer–Douglas–Peucker，按容差自适应：平坦区域少留点，波动区域多留点</td>
</tr>
</tbody></table>
<p>除了 RDP 是按精度（容差 ε）控制输出，其余六种都是固定目标点数。两种范式放在一起对比，降采样的取舍一目了然。</p>
<h2>低通滤波抗混叠</h2>
<p>页面提供了一个可选的低通滤波器（滑动窗口平均），在降采样之前对原始信号做预处理。</p>
<p>开/关滤波、调窗口大小都是实时生效，可以直观看到：哪些算法本身就抗混叠（均值），哪些算法需要前置滤波配合（直接抽样），以及 Min-Max 和 LTTB 在滤波前后的包络变化。</p>
<h2>五种内置信号</h2>
<p>预置了五组不同特征的测试信号，比单一正弦波更能暴露算法的差异：</p>
<ul>
<li><strong>复合正弦波 + 噪声</strong>：多频叠加，考验抗混叠和噪声抑制</li>
<li><strong>阶跃信号</strong>：测试对突变边缘的保留能力</li>
<li><strong>尖峰脉冲</strong>：对比均值和 Median 对离群点的处理</li>
<li><strong>模拟心电</strong>：类真实场景，LTTB 和 Min-Max 表现有参考价值</li>
<li><strong>随机游走</strong>：无周期结构，只靠统计特征降采样</li>
</ul>
<h2>技术实现</h2>
<p>单 HTML 文件，零构建、零依赖。Canvas 绑制双图对比，左侧控制面板右侧实时渲染，自适应 DPR 保证 Retina 屏清晰。</p>
<p><a href="https://createskyblue.github.io/downsampling-demo/" target="_blank" rel="noopener noreferrer">🌐 在线体验</a> · <a href="https://github.com/createskyblue/downsampling-demo" target="_blank" rel="noopener noreferrer">📂 GitHub 仓库</a></p>]]></content>
    <category term="降采样"/>
    <category term="信号处理"/>
  </entry>
  <entry>
    <title>Canvas 将 Min-Max 心率采样绘成连续包络波形</title>
    <link href="https://createskyblue.github.io/posts/heart-rate-minmax-canvas-envelope/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/heart-rate-minmax-canvas-envelope/</id>
    <published>2026-07-17T00:00:00.000Z</published>
    <updated>2026-07-17T00:00:00.000Z</updated>
    <summary>Canvas Min-Max 波形渲染：每桶包络连接极值</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Canvas 将 Min-Max 心率采样绘成连续包络波形</h1>
<p><img src="/posts/heart-rate-minmax-canvas-envelope/PixPin_2026-07-17_23-03-25.webp" alt="实时心率与同步采样 Min-Max 包络波形"></p>
<p>心率记录需要同时返回 BPM 和一秒采样波形。设备端每秒处理 500 个 ADC 样本，将它们分成 10 个桶，每个桶输出一个 8-bit 最小值和最大值。这样一条记录会携带 20 个波形字节，主机端再把它显示在实时心率曲线下方。</p>
<p>这类数据不能直接按 20 个普通采样点连线。20 个字节实际代表 10 组范围，每组范围覆盖一段原始样本。最终图表采用“一个桶一个 x 坐标”的包络绘制，再叠加一条中心线，既保留极值，也避免把 Min-Max 数据误读成带有明确先后顺序的波形点。</p>
<h2>Min-Max 桶语义</h2>
<p>协议中的波形部分由 10 组连续的 <code>[min,max]</code> 组成。每组覆盖 50 个新样本，桶边界保留一个重叠样本，用于保持边沿信息。<code>min</code> 和 <code>max</code> 只描述这段样本的范围，协议没有携带极值出现的原始索引。</p>
<p>主机端为每个桶建立一条记录：</p>
<table>
<thead>
<tr>
<th>字段</th>
<th>含义</th>
</tr>
</thead>
<tbody><tr>
<td><code>timestamp</code></td>
<td>桶中心对应的时间</td>
</tr>
<tr>
<td><code>min</code></td>
<td>该桶的 8-bit 最小值</td>
</tr>
<tr>
<td><code>max</code></td>
<td>该桶的 8-bit 最大值</td>
</tr>
<tr>
<td><code>value</code></td>
<td><code>(min + max) / 2</code>，用于中心线</td>
</tr>
</tbody></table>
<p>如果把每组 <code>[min,max]</code> 拆成两个有序时间点，页面必须猜测极值先后。上升段、下降段和重复量化值都会让这条猜测产生凹刺或不自然的平台。包络绘制直接使用协议提供的范围信息，避免引入这层猜测。</p>
<h2>桶中心坐标与 15 秒窗口</h2>
<p>记录时间戳表示这一秒波形的结束位置。第 <code>pairIndex</code> 个桶的中心时间计算为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">const</span><span style="color:#4FC1FF"> bucketTimestamp</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">recordTimestamp</span><span style="color:#D4D4D4"> - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4"> +</span></span>
<span class="line"><span style="color:#D4D4D4">    (</span><span style="color:#9CDCFE">pairIndex</span><span style="color:#D4D4D4"> + </span><span style="color:#B5CEA8">0.5</span><span style="color:#D4D4D4">) / </span><span style="color:#B5CEA8">10</span><span style="color:#D4D4D4">;</span></span></code></pre><p>10 个桶均匀落在 <code>[recordTimestamp - 1, recordTimestamp]</code> 内。每个桶只有一个 x 坐标，<code>min</code> 和 <code>max</code> 共同描述这个位置的上下边界。</p>
<p>调试器只保留最近 15 秒，也就是 150 个桶。单条读取、批量读取和连续读取都进入同一个 <code>appendHeartRateSignalRecord()</code> 数据入口。记录用 <code>id:timestamp</code> 去重，Canvas 重绘通过一个待处理的 <code>requestAnimationFrame</code> 合并，避免一次批量响应触发多次完整绘制。</p>
<h2>包络路径与中心线</h2>
<p>Canvas 先把所有桶的 <code>min</code> 和 <code>max</code> 映射到动态 8-bit 纵轴。包络路径分两段建立：第一段从左到右连接每个桶的 <code>max</code>，第二段从右到左连接每个桶的 <code>min</code>，最后闭合路径并填充淡色区域。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">beginPath</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">let</span><span style="color:#9CDCFE"> index</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">start</span><span style="color:#D4D4D4">; </span><span style="color:#9CDCFE">index</span><span style="color:#D4D4D4"> &#x3C; </span><span style="color:#9CDCFE">end</span><span style="color:#D4D4D4">; </span><span style="color:#9CDCFE">index</span><span style="color:#D4D4D4">++) {</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> bucket</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">buckets</span><span style="color:#D4D4D4">[</span><span style="color:#9CDCFE">index</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> x</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">xForTimestamp</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">bucket</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">timestamp</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> maxY</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">yForValue</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">bucket</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">max</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">min</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">max</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">area</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">index</span><span style="color:#D4D4D4"> === </span><span style="color:#9CDCFE">start</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#9CDCFE">    ctx</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">moveTo</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">maxY</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">  } </span><span style="color:#C586C0">else</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#9CDCFE">    ctx</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">lineTo</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">maxY</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">let</span><span style="color:#9CDCFE"> index</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">end</span><span style="color:#D4D4D4"> - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">; </span><span style="color:#9CDCFE">index</span><span style="color:#D4D4D4"> >= </span><span style="color:#9CDCFE">start</span><span style="color:#D4D4D4">; </span><span style="color:#9CDCFE">index</span><span style="color:#D4D4D4">--) {</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> bucket</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">buckets</span><span style="color:#D4D4D4">[</span><span style="color:#9CDCFE">index</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> x</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">xForTimestamp</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">bucket</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">timestamp</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> minY</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">yForValue</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">bucket</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">min</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">min</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">max</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">area</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#9CDCFE">  ctx</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">lineTo</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">minY</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">closePath</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">fill</span><span style="color:#D4D4D4">();</span></span></code></pre><p>包络上下边界使用较浅的青绿色描边，中心线使用桶的中点：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">const</span><span style="color:#4FC1FF"> value</span><span style="color:#D4D4D4"> = (</span><span style="color:#9CDCFE">bucket</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">min</span><span style="color:#D4D4D4"> + </span><span style="color:#9CDCFE">bucket</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">max</span><span style="color:#D4D4D4">) / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">const</span><span style="color:#4FC1FF"> y</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">yForValue</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">value</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">min</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">max</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">area</span><span style="color:#D4D4D4">);</span></span></code></pre><p>包络路径覆盖相邻桶之间的整个区间，所以画面不会出现独立柱子之间的空白。中心线提供清晰的趋势轮廓，淡色区域继续保留每个桶的极值范围。截图中的上下边界、中心线和右侧 0 到 255 刻度共同表达了同一组采样数据。</p>
<h2>8-bit 量化的视觉表现</h2>
<p>设备端把 12-bit ADC 极值量化为 8-bit：有效 ADC 值执行 <code>sample &gt;&gt; 4</code>，超出 12-bit 范围的值饱和到 <code>255</code>。量化会让相邻桶出现相同的最小值或最大值，包络边界可能形成短平台，中心线也会保留阶梯状变化。</p>
<p>这种平台属于量化后的数据特征。它与多个采样点被错误放到同一 x 坐标的情况不同。包络图把一个桶的范围画在一个时间位置，中心线只使用一个中点值，因此图形结构和数据结构保持一致。</p>
<h2>上位机数据流与显示分工</h2>
<p>实时心率区域和同步采样区域使用两套历史数据：</p>
<ul>
<li>上方红色曲线保存 BPM、MAD、1 分钟均值和有效性状态。</li>
<li>下方青绿色图形保存最近 15 秒的 Min-Max 桶。</li>
<li>心率记录解析器从记录偏移 9 处读取 20 个波形字节。</li>
<li>单条记录和批量记录共享同一套桶转换与绘制逻辑。</li>
<li>原始 ADC 调试指令继续保留，便于对照未经 Min-Max 处理的采样数据。</li>
</ul>
<p>这样的分工让 BPM 趋势和采样形状各自使用合适的纵轴。BPM 图固定业务范围，采样图使用 8-bit 动态范围，两个图不会因为量纲不同互相压缩。</p>
<h2>数据边界与验证结果</h2>
<p>Min-Max 包络能保证每个桶的极值进入画面，却不能恢复极值在 50 个原始样本中的准确先后。如果产品需要一条严格对应原始时间顺序的折线，协议还需要传输极值索引或保留有序采样点。当前协议只提供范围信息，包络加中心线是与协议语义一致的显示方式。</p>
<p>这次实现完成了三层校验：协议记录长度和 20 字节波形解析通过静态测试；调试器内联 JavaScript 通过语法检查；实机页面确认实时心率和同步波形上下排列，波形窗口稳定显示最近 15 秒。最终图形保持了极值信息，也让时间轴、包络范围和趋势线各自承担清晰的显示职责。</p>]]></content>
    <category term="嵌入式"/>
    <category term="BLE"/>
    <category term="Canvas"/>
    <category term="波形渲染"/>
    <category term="降采样"/>
    <category term="数据可视化"/>
  </entry>
  <entry>
    <title>Cortex-Debug 1.12.1 无法识别 pyOCD 0.44.1 启动日志的修复</title>
    <link href="https://createskyblue.github.io/posts/cortex-debug-pyocd-startup-timeout-regex/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/cortex-debug-pyocd-startup-timeout-regex/</id>
    <published>2026-07-15T00:00:00.000Z</published>
    <updated>2026-07-15T00:00:00.000Z</updated>
    <summary>Cortex-Debug启动pyOCD后仍然超时</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Cortex-Debug 1.12.1 无法识别 pyOCD 0.44.1 启动日志的修复</h1>
<p>在 Air001 项目中，Cortex-Debug 已经启动 pyOCD，终端也显示 GDB server 正在监听端口，VS Code 仍在等待，最后报错：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Failed to launch PyOCD GDB Server: Timeout.</span></span></code></pre><p>这次现场使用的是 <strong>pyOCD</strong>。OpenOCD 是另一个 GDB server，两者不能混用名称。报错文字和 <code>servertype: &quot;pyocd&quot;</code> 都能确认实际对象。</p>
<p>修复只需覆盖 Cortex-Debug 用来判断 server 已就绪的正则表达式：</p>
<pre><code>"overrideGDBServerStartedRegex": "GDB server listening on port"</code></pre><h2>故障现象</h2>
<p>环境为 Cortex-Debug 1.12.1、pyOCD 0.44.1 和 Air001。执行 F5 后，pyOCD 输出：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>GDB server listening on port 3333 (core 0)</span></span></code></pre><p>这行日志说明 pyOCD 已经运行，3333 端口也已进入监听状态。此时出现 Timeout，排查重点应放在 Cortex-Debug 的“就绪判断”，无需继续折腾探针驱动或增加等待时间。</p>
<h2>启动日志与内置正则</h2>
<p>Cortex-Debug 启动 GDB server 后，会等待一行符合内置正则的日志。1.12.1 中 pyOCD 的内置正则是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">GDB server started </span><span style="color:#CE9178">(</span><span style="color:#D4D4D4">at|on</span><span style="color:#CE9178">)</span><span style="color:#D4D4D4"> port</span></span></code></pre><p>pyOCD 0.44.1 的实际文字是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">GDB server listening on port</span></span></code></pre><p><code>started at/on</code> 与 <code>listening on</code> 无法匹配。进程虽然已经启动，Cortex-Debug 收不到“已就绪”信号，只能一直等待到超时。</p>
<h2>最小修复配置</h2>
<p>下面是可以直接放进 <code>.vscode/launch.json</code> 的最小示例：</p>
<pre><code>{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Air001: Cortex-Debug with pyOCD",
      "type": "cortex-debug",
      "request": "launch",
      "cwd": "${workspaceFolder}",
      "executable": "${workspaceFolder}/build/Project.elf",
      "servertype": "pyocd",
      "targetId": "Air001",

      // 本次修复的核心配置
      "overrideGDBServerStartedRegex": "GDB server listening on port",

      "runToEntryPoint": "main"
    }
  ]
}</code></pre><p>如果 <code>pyocd</code> 没有加入 PATH，可再通过 Cortex-Debug 的 <code>serverpath</code> 或全局 <code>cortex-debug.pyocdPath</code> 指定可执行文件。路径配置与本次超时根因属于两件事。</p>
<h2>修复生效机制</h2>
<p><code>overrideGDBServerStartedRegex</code> 不负责启动 pyOCD，也不会修改 GDB 端口。它只替换 Cortex-Debug 的就绪匹配规则。</p>
<p>修改前的流程停在第二步：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>启动 pyOCD → 等待旧日志文字 → 超时</span></span></code></pre><p>修改后的流程能够继续：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>启动 pyOCD → 匹配 listening on port → GDB 连接 3333 端口</span></span></code></pre><p>因此，这项修复不依赖 PowerShell、后台进程或固定时长的 sleep。pyOCD 真正启动失败时不会输出匹配文字，Cortex-Debug 仍会正常报错。</p>
<h2>验证结果与适用边界</h2>
<p>加入覆盖正则后，Cortex-Debug 能越过 GDB server 启动阶段，不再报告 <code>Failed to launch PyOCD GDB Server: Timeout</code>。</p>
<p>这项修复只解决 Cortex-Debug 1.12.1 与 pyOCD 0.44.1 的启动日志不兼容。项目最终按调试需求采用 VS Code C/C++ 扩展的 <code>cppdbg</code>，入口断点和 GDB 启动时序记录在另一篇文章中。</p>
<p>如果日志明确写的是 OpenOCD，应先查看 OpenOCD 的实际启动文字，再为 <code>overrideGDBServerStartedRegex</code> 编写对应规则。不要直接复制本文的 pyOCD 正则。</p>]]></content>
    <category term="嵌入式"/>
    <category term="工程调试"/>
    <category term="故障诊断"/>
    <category term="VSCode"/>
    <category term="Air001"/>
  </entry>
  <entry>
    <title>VS Code 连接 pyOCD 后未停在 main 的排查与修复</title>
    <link href="https://createskyblue.github.io/posts/vscode-cppdbg-pyocd-main-breakpoint/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/vscode-cppdbg-pyocd-main-breakpoint/</id>
    <published>2026-07-15T00:00:00.000Z</published>
    <updated>2026-07-15T00:00:00.000Z</updated>
    <summary>VS Code远程调试启动后未停在main</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>VS Code 连接 pyOCD 后未停在 main 的排查与修复</h1>
<p>在 Air001 项目中，F5 本应依次完成固件烧录、启动 pyOCD GDB server、加载 ELF 符号、复位芯片，并停在 <code>main</code> 的第一条可执行语句。工程使用 VS Code C/C++ 扩展提供的 <code>cppdbg</code> 和 <code>arm-none-eabi-gdb</code>。</p>
<p>实际调试先后出现两个现象：手工添加 <code>main</code> 断点时报“没有符号表”；符号错误消失后，启动位置又落在 <code>HAL_GetTick()</code> 或主循环。本文说明这两个时序问题，并给出经过硬件验证的最小配置。</p>
<p><strong>修复结论：</strong> 在 <code>postRemoteConnectCommands</code> 中先执行 <code>monitor reset halt</code>，再执行 <code>-exec-continue</code>。前一条命令复位并暂停芯片，后一条命令让芯片继续运行到已经创建好的 <code>main</code> 断点。</p>
<h2>调试目标与故障现象</h2>
<p>调试环境为 Air001、pyOCD 0.44.1、Debug ELF（<code>-O0 -g3</code>）和 VS Code <code>cppdbg</code>。预期链路可以压缩成一行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>烧录 ELF → 启动 GDB server → 加载符号 → reset halt → 运行到 main</span></span></code></pre><p>第一个故障来自 <code>setupCommands</code>。其中的 <code>-break-insert main</code> 早于 ELF 符号加载，GDB 无法解析 <code>main</code>。</p>
<p>第二个故障发生在远程连接阶段。符号和断点都已存在，芯片却没有继续运行到入口断点，VS Code 显示了连接瞬间的随机 PC。</p>
<h2>main 入口与第 277 行</h2>
<p>文章中的“第 277 行”只对应当时的 <code>Src/main.c</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#B5CEA8">275</span><span style="color:#569CD6">  int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#B5CEA8">276</span><span style="color:#D4D4D4">  {</span></span>
<span class="line"><span style="color:#B5CEA8">277</span><span style="color:#DCDCAA">    HAL_Init</span><span style="color:#D4D4D4">();</span></span></code></pre><p>第 275 行是函数声明，第 276 行是左花括号，第 277 行是第一条可执行 C 语句。编译器会在函数入口生成保存寄存器、调整栈指针等序言指令。GDB 的源码级 <code>break main</code> 通常绑定到序言之后第一条带有源码行号的指令，因此停在第 277 行。</p>
<p>停在第 277 行时，<code>HAL_Init()</code> 尚未执行。这个行号会随源码增删发生变化，真正的判断标准是“停在 <code>main</code> 的第一条可执行语句”。如果需要观察函数序言，应使用指令级断点，例如 <code>break *main</code>。</p>
<h2>分层排查与启动时序根因</h2>
<p>排查按三层进行：</p>
<ol>
<li><code>nm</code> 和 <code>objdump</code> 确认 ELF 中存在 <code>main</code>，DWARF 行号可以映射到第 277 行。</li>
<li>原生 GDB 执行 <code>monitor reset halt</code>、<code>tbreak main</code> 和 <code>continue</code>，硬件稳定停在第 277 行。</li>
<li>微软 <code>OpenDebugAD7/cppdbg</code> 使用相同 ELF 和探针时，首个停止位置落在其他函数。</li>
</ol>
<p>前两层正常，问题范围由此收敛到 <code>cppdbg</code> 的启动阶段。MI engine 日志给出了关键顺序：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>-break-insert -f main</span></span>
<span class="line"><span>-target-select remote localhost:3333</span></span>
<span class="line"><span>*stopped,frame={func="HAL_GetTick",line="315"}</span></span>
<span class="line"><span>-interpreter-exec console "monitor reset halt"</span></span></code></pre><p><code>target-select remote</code> 连接正在运行的目标后，pyOCD 会暂停内核并返回当前 PC。本次 PC 位于 <code>HAL_GetTick()</code> 第 315 行。<code>cppdbg</code> 将它记录为启动阶段的初始停止事件，随后没有执行原计划放在 <code>launchCompleteCommand</code> 中的继续命令。</p>
<p><code>monitor reset halt</code> 完成复位后，内核仍处于暂停状态。入口断点已经创建，但 CPU 没有运行到该地址，所以 VS Code 最终显示了远程连接产生的初始停止位置。</p>
<p>符号表报错属于另一个时序点：<code>setupCommands</code> 早于默认的 <code>-file-exec-and-symbols</code>。删除其中依赖符号的 <code>-break-insert main</code> 即可，<code>program</code> 字段会让 <code>cppdbg</code> 在后续阶段加载 ELF。</p>
<h2>-exec-continue 的作用与修复条件</h2>
<p><code>-exec-continue</code> 是 GDB/MI 命令，等价于 GDB 控制台中的 <code>continue</code> 或 <code>c</code>。MI 是调试器提供给 IDE 使用的机器接口，命令前的 <code>-</code> 表示这是 MI 命令。</p>
<p>这条命令只负责让当前暂停的目标恢复执行。各步骤的职责如下：</p>
<ul>
<li><code>program</code> 加载 ELF 符号。</li>
<li><code>cppdbg</code> 创建 <code>main</code> 入口断点。</li>
<li><code>monitor reset halt</code> 复位并暂停芯片。</li>
<li><code>-exec-continue</code> 让芯片从暂停位置继续运行。</li>
</ul>
<p>断点只是一个触发条件。只有程序计数器 PC 运行到断点地址，GDB 才会再次收到停止事件。本案的有效时序为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>reset halt → -exec-continue → PC 到达 main → 入口断点命中</span></span></code></pre><p>因此，<code>-exec-continue</code> 能修复本案的前提是 ELF 已加载、<code>main</code> 断点已创建、远程目标已连接。它需要放在 <code>postRemoteConnectCommands</code> 中，紧跟 <code>monitor reset halt</code>。放在 <code>setupCommands</code> 中会再次遇到符号尚未加载和目标尚未连接的问题。</p>
<h2>最小 VS Code 配置</h2>
<p><code>tasks.json</code> 直接调用 pyOCD 烧录固件。烧录进程结束后会释放探针。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "version"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"2.0.0"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "tasks"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">    {</span></span>
<span class="line"><span style="color:#9CDCFE">      "label"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"Flash Air001"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"process"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "command"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"pyocd"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "args"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#CE9178">        "load"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#CE9178">        "${workspaceFolder}/build/Project.elf"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#CE9178">        "-t"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#CE9178">        "Air001"</span></span>
<span class="line"><span style="color:#D4D4D4">      ]</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">  ]</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><code>launch.json</code> 由 <code>cppdbg</code> 启动 pyOCD GDB server。示例假设 <code>pyocd</code> 已加入 VS Code 进程的 PATH。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "version"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"0.2.0"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "configurations"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">    {</span></span>
<span class="line"><span style="color:#9CDCFE">      "name"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"Air001: GDB Flash &#x26; Debug"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"cppdbg"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "request"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"launch"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "preLaunchTask"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"Flash Air001"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "program"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"${workspaceFolder}/build/Project.elf"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "cwd"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"${workspaceFolder}"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "MIMode"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"gdb"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "miDebuggerPath"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"arm-none-eabi-gdb"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "miDebuggerServerAddress"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"localhost:3333"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "debugServerPath"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"pyocd"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "debugServerArgs"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"gdbserver --port 3333 --target Air001"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "serverStarted"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"GDB server listening on port 3333"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "filterStderr"</span><span style="color:#D4D4D4">: </span><span style="color:#569CD6">true</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "stopAtEntry"</span><span style="color:#D4D4D4">: </span><span style="color:#569CD6">true</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "launchCompleteCommand"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"exec-continue"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "postRemoteConnectCommands"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">        {</span></span>
<span class="line"><span style="color:#9CDCFE">          "text"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"-interpreter-exec console </span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">monitor reset halt</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">          "ignoreFailures"</span><span style="color:#D4D4D4">: </span><span style="color:#569CD6">false</span></span>
<span class="line"><span style="color:#D4D4D4">        },</span></span>
<span class="line"><span style="color:#D4D4D4">        {</span></span>
<span class="line"><span style="color:#9CDCFE">          "text"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"-exec-continue"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">          "ignoreFailures"</span><span style="color:#D4D4D4">: </span><span style="color:#569CD6">false</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#D4D4D4">      ]</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">  ]</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>pyOCD 未加入 PATH 时，将 <code>debugServerPath</code> 改为本机 <code>pyocd.exe</code> 的完整路径。</p>
<h2>验证结果与适用边界</h2>
<p>重新构建并烧录后，使用与 VS Code 相同的 <code>OpenDebugAD7/cppdbg</code> 配置启动调试，首帧为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "function"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"main()"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "line"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">277</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "pc"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"0x08002B30"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "file"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"Src/main.c"</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>硬件验证和实际 F5 启动均确认调试器会停在 <code>main</code> 的第一条可执行语句。本文只验证启动阶段的烧录、server 启动、符号加载、复位和入口停靠。普通断点、观察点、重启和多次连续会话仍需单独测试。</p>
<p>这次排查的核心方法是先用原生 GDB 证明底层链路可用，再检查 IDE 调试适配器的首个 <code>*stopped</code> 事件。远程调试停在随机位置时，应先确认 CPU 是否真的执行过“继续运行”这一步。</p>]]></content>
    <category term="嵌入式"/>
    <category term="工程调试"/>
    <category term="故障诊断"/>
    <category term="VSCode"/>
    <category term="Air001"/>
  </entry>
  <entry>
    <title>在 PY32 上实现单向直流电机的级联 P-PI 定位控制</title>
    <link href="https://createskyblue.github.io/posts/dc-motor-p-pi-controller/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/dc-motor-p-pi-controller/</id>
    <published>2026-07-14T00:00:00.000Z</published>
    <updated>2026-07-14T00:00:00.000Z</updated>
    <summary>单向直流电机用位置环、速度环与减速约束控制过冲，检测堵转后增加PWM补偿</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>在 PY32 上实现单向直流电机的级联 P-PI 定位控制</h1>
<p>单向直流电机只支持正向驱动，发生过冲后无法通过反转校正。控制器通过霍尔脉冲计算位置；提高运行速度会同步增加断开 PWM 后的滑行距离，因此控制目标需要同时覆盖定位误差、停车距离与堵转恢复。</p>
<p>本文记录一套在 PY32 上实现的级联 P-PI 控制：位置 P 环生成目标速度，速度 PI 环调节 PWM，减速约束根据剩余距离限制速度，堵转检测提供受限的 PWM 增量补偿。</p>
<h2>位置环与速度环的级联控制</h2>
<p>直接用位置误差调节 PWM，难以同时处理机械惯性和负载变化。控制结构在位置与 PWM 之间引入目标速度，将定位和调速分成两个职责明确的环节。</p>
<p>“级联”表示前一级控制器为后一级提供目标。各环节的职责如下：</p>
<table>
<thead>
<tr>
<th>环节</th>
<th>职责</th>
</tr>
</thead>
<tbody><tr>
<td>位置 P 环</td>
<td>根据剩余距离生成目标速度，接近目标时主动降速</td>
</tr>
<tr>
<td>速度 P 环节</td>
<td>根据当前速度偏差快速修正 PWM</td>
</tr>
<tr>
<td>速度 I 环节</td>
<td>累计持续存在的偏差，补偿负载变化</td>
</tr>
<tr>
<td>速度前馈</td>
<td>根据目标速度预先给出基础 PWM</td>
</tr>
</tbody></table>
<p>位置环可以简化为：</p>
<p><code>目标速度 = 位置误差 × 位置系数</code></p>
<p>位置误差越大，目标速度越高；巡航上限用于限制最高速度。速度环可以写成：</p>
<p><code>PWM = 前馈输出 + 当前速度偏差修正 + 持续偏差修正</code></p>
<p>前馈提供目标速度对应的基础输出，P 环节负责即时纠偏，I 环节负责补偿长期偏差。该结构减少了积分从零开始建立输出的时间，也让位置控制和速度控制保持清晰边界。</p>
<h2>基于停车距离的减速约束</h2>
<p>电机断开驱动后仍会继续滑行。控制器需要根据剩余距离限制目标速度，避免减速开始过晚。该约束来自经典的匀减速运动关系：</p>
<p><code>末速度² = 初速度² - 2 × 减速度 × 行驶距离</code></p>
<p>将目标位置的末速度设为零，并把行驶距离换成剩余距离，可得到：</p>
<p><code>初速度 = √(2 × 减速度 × 剩余距离)</code></p>
<p>控制器把这个初速度作为当前位置允许的最高速度，再加入安全系数，让减速提前开始：</p>
<p><code>允许速度 = √(2 × 减速度 × 剩余距离 ÷ 安全系数)</code></p>
<p>该关系可以直接理解为：</p>
<ul>
<li>剩余距离越短，允许速度越低。</li>
<li>速度增加一倍，停车通常需要约四倍距离。</li>
</ul>
<p>这段推导假设电机的减速度大致稳定。实际减速度会受供电、负载和机械摩擦影响，因此减速度需要通过实测取得，安全系数用于覆盖测量延迟和工况变化。进入减速阶段后，控制器还会加快 PI 积分泄放，避免历史补偿延迟 PWM 下降。</p>
<h2>堵转检测与 PWM 增量补偿</h2>
<p>控制器周期性检查位置脉冲。当 PWM 已经输出、电机速度接近零、位置没有变化时，控制器逐步增加 PWM 补偿，尝试克服起动阻力或短时卡滞。</p>
<p>该机制用于有限度地恢复运动。完整的堵转保护还需要超时停机、故障记录以及温度或电流保护。</p>
<h2>样机测试结果与实现边界</h2>
<p>现有样机测试记录显示，提高 PWM 上限后，运行速度约为原来的 2.5 倍；多组远近目标测试中的最大过冲为 2 个位置脉冲。这些数据说明当前样机在已测范围内保持了较小过冲，不代表所有负载和工况都能得到相同结果。</p>
<p>代码审查还发现两处需要修正：</p>
<ul>
<li>停车速度公式需要计算平方根。当前快速近似只迭代两次，输入较大时结果会偏高数倍，可能导致减速开始过晚。应改用 <code>sqrtf</code> 或经过误差验证的平方根算法，再重新测试。</li>
<li>程序经过零点做速度校准时，只读取一个时刻的速度。单次读数可能受霍尔脉冲时机影响，建议在稳定运行区间取多次平均值。</li>
</ul>
<h2>AI 自动化测试与参数调优</h2>
<p>控制参数需要通过真实电机反复验证。常规流程包括修改代码、重新编译、烧录、运行、采集数据和分析结果。为减少重复人工操作，我将 AI 接入可操作的硬件环境，向它提供电源控制、烧录器、串口和断点调试能力。</p>
<p>一轮自动测试包含以下步骤：</p>
<p><code>修改程序 → 编译并烧录 → 上电运行 → 串口触发测试并读取日志 → 必要时断点调试 → 对比结果并进入下一轮</code></p>
<p>每轮测试后，AI 根据速度、停止位置、过冲量和堵转表现确定下一次修改。修改内容包括控制参数和影响结果的程序逻辑。经过多轮迭代，最终得到当前使用的参数组合。</p>
<p>该闭环得到的是当前硬件和测试范围内的结果。电源、负载或机械结构发生变化后，仍需重新验证。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="电机控制"/>
    <category term="控制论"/>
    <category term="霍尔信号"/>
    <category term="AI辅助开发"/>
  </entry>
  <entry>
    <title>I2C 从机开发：事务流程、数据组织与耗时应答</title>
    <link href="https://createskyblue.github.io/posts/i2c-slave/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/i2c-slave/</id>
    <published>2026-07-13T00:00:00.000Z</published>
    <updated>2026-07-13T00:00:00.000Z</updated>
    <summary>I2C从机采用寄存器或命令模型，耗时任务通过状态应答</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>I2C 从机开发：事务流程、数据组织与耗时应答</h1>
<p>I2C 从机开发先掌握普通事务，再处理变长响应和耗时操作。</p>
<h2>先记住四条规则</h2>
<ul>
<li>从机不能主动发起通信。</li>
<li>主机决定读写方向、读取字节数和结束时机。</li>
<li>写数据阶段由从机逐字节应答；读数据阶段由主机逐字节应答，最后一字节回 NACK。</li>
<li>ACK 只表示地址或字节已接收，不代表业务完成。</li>
</ul>
<p>从机开发先关注三种事务：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>写：      START → 地址+W → 请求 → STOP</span></span>
<span class="line"><span>读：      START → 地址+R → 响应 → STOP</span></span>
<span class="line"><span>写后读：  START → 地址+W → 请求 → RESTART → 地址+R → 响应 → STOP</span></span></code></pre><h2>从请求到响应</h2>
<p>I2C 只定义传输，不定义字节含义。从机软件可按一条链路组织：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>RX 回调 → 请求缓冲 → 业务执行 → 响应快照 → TX 回调</span></span></code></pre><p>回调负责搬运数据和记录事件，耗时业务放在回调外。TX 始终读取一份稳定快照。写后读可能紧接 RESTART，来不及完成的请求先返回 <code>BUSY</code>。</p>
<h2>两种数据模型</h2>
<p>I2C 从机主要使用寄存器模型和命令模型。命令通常采用二进制编码；文本命令属于自定义的应用层编码。</p>
<table>
<thead>
<tr>
<th></th>
<th>寄存器</th>
<th>二进制命令</th>
<th>文本命令（少见）</th>
</tr>
</thead>
<tbody><tr>
<td><strong>模型</strong></td>
<td>寄存器模型</td>
<td>命令模型</td>
<td>命令模型</td>
</tr>
<tr>
<td><strong>开头</strong></td>
<td>寄存器地址</td>
<td>操作码</td>
<td>命令词</td>
</tr>
<tr>
<td><strong>长度</strong></td>
<td>由寄存器定义</td>
<td>定长或长度字段</td>
<td>写入可分隔，读取须约定</td>
</tr>
<tr>
<td><strong>特点</strong></td>
<td>适合配置和状态</td>
<td>紧凑、效率高</td>
<td>可读、便于调试</td>
</tr>
</tbody></table>
<p>寄存器可由软件虚拟，也可包含命令寄存器。设备可混合使用寄存器和二进制命令。文本命令多用于 MCU 间自定义通信、调试或复用 UART 命令行。移植到 UART 或 SPI 时，帧边界和读写方向仍需适配。</p>
<h2>特殊情况一：变长读取</h2>
<p>I2C 读阶段没有从机发出的结束标记。多数主机 API 要求调用前给定字节数，通用方案是两次应用层读取：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>读固定响应头 → 取得长度 N → 校验 N → 再读 N 字节</span></span></code></pre><p>寄存器模型可提供长度寄存器和数据区，命令模型可返回响应头和载荷。实现时还要保证：</p>
<ul>
<li>两次读取使用同一份响应快照；</li>
<li>N 不超过协议上限和主机缓冲区；</li>
<li>第二次读取完成前不复用旧缓冲。</li>
</ul>
<p>文本结束符只能标记内容结束，不能替主机结束 I2C 读取。</p>
<p>少数控制器支持读到长度后在同一事务中继续收取，具体取决于硬件和驱动。</p>
<h2>特殊情况二：耗时操作</h2>
<p>Flash 擦写、采样或电机动作不能立即完成时，可从以下方式中选择：</p>
<table>
<thead>
<tr>
<th>方式</th>
<th>含义</th>
<th>适用场景</th>
<th>限制</th>
</tr>
</thead>
<tbody><tr>
<td>时钟拉伸</td>
<td>从机拉低 SCL 暂停时钟</td>
<td>很短且上限明确</td>
<td>占用总线，可能超时</td>
</tr>
<tr>
<td>状态轮询</td>
<td>主机反复读取状态</td>
<td>通用默认方案</td>
<td>增加轮询事务</td>
</tr>
<tr>
<td>地址 NACK</td>
<td>从机忙时拒绝地址访问</td>
<td>主从明确约定</td>
<td>无法区分忙、掉电或缺失</td>
</tr>
<tr>
<td>GPIO 通知</td>
<td>完成后改变 GPIO 电平</td>
<td>时间长或不确定</td>
<td>多占一个引脚</td>
</tr>
</tbody></table>
<p>状态通过寄存器、命令或响应头返回 <code>BUSY/DONE/ERROR</code>。耗时很长时可增加 GPIO 通知。</p>
<h2>STM32 实现提醒</h2>
<ul>
<li>主机侧 <code>HAL_I2C_Mem_*</code> 对应寄存器访问，<code>HAL_I2C_Master_*</code> 收发原始数据；<code>MemAddress</code> 仍会在线路上传输。</li>
<li>两个独立的阻塞式发送、接收通常产生 STOP。需要 RESTART 时使用对应系列的 Sequential/Seq 接口。</li>
<li><code>HAL_I2C_SlaveRxCpltCallback</code> 通常只表示预设长度收满，不等于检测到 STOP 或 RESTART。</li>
<li>用逻辑分析仪验证写、读、写后读和异常中断。</li>
</ul>]]></content>
    <category term="嵌入式"/>
    <category term="通信协议"/>
    <category term="I2C"/>
  </entry>
  <entry>
    <title>AI取代执行后，人的价值在哪里</title>
    <link href="https://createskyblue.github.io/posts/ai-code-human-value/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ai-code-human-value/</id>
    <published>2026-07-11T00:00:00.000Z</published>
    <updated>2026-07-11T00:00:00.000Z</updated>
    <summary>AI取代执行后，人的价值在哪里</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>AI写代码后 人的价值在哪里</h1>
<p><img src="/posts/ai-code-human-value/cover.webp" alt="AI取代执行后，人的价值在哪里"></p>
<p>最近我使用 WCH 的 CH585 开发一个低功耗蓝牙项目，需要完成 ADC 采样、TFLM 异常检测和蓝牙定时传输</p>
<p>借助 Codex 配合 GPT-5.5，我在几天内完成了 Demo 开发</p>
<p>CH585 的开发接近标准库风格，没有 CubeMX 这样的图形化工具</p>
<p>人给出顶层架构设计后，先用 AI 完善项目的执行文档。AI 可以阅读指定路径下的官方示例、查询数据手册，再按设计补齐实现</p>
<p>有人说：“看起来你什么也不用干，都是机器在干，应该和你关系不大”</p>
<p>我的回复是：如果 AI 有一天能够独立理解目标、规划系统、验证结果、处理意外，并对最终交付负责，这个项目确实和我关系不大。目前 AI 还没有形成这样的闭环</p>
<p>上面提到几天完成 Demo 开发，听起来很顺利，实际执行很坎坷</p>
<p>AI 是概率机器。面对信息缺失、约束冲突和稀缺场景，总有概率覆盖不到的地方，它就会犯错</p>
<p>概率覆盖不到的地方，需要人的知识和经验</p>
<p>人的工作价值体现于决定目标、边界和取舍，给出顶层架构设计</p>
<p>同时，如果使用 AI，就必须对 AI 的结果负责</p>
<p>AI 提高了执行速度，也放大了工程判断的差距</p>
<p>用 AI 可以推进得更快，也需要人对结果负责，才能把项目做</p>
<p>人的思想才是灵魂所在，AI 是执行工具。</p>]]></content>
    <category term="AI"/>
    <category term="AI辅助开发"/>
    <category term="嵌入式"/>
    <category term="系统设计"/>
    <category term="开发效率"/>
  </entry>
  <entry>
    <title>蓝牙Mesh基于广播泛洪组网，单包仅7字节可用，配网到通信须严格按序配置</title>
    <link href="https://createskyblue.github.io/posts/ble-mesh-overview-ch585/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ble-mesh-overview-ch585/</id>
    <published>2026-07-11T00:00:00.000Z</published>
    <updated>2026-07-11T00:00:00.000Z</updated>
    <summary>深入理解蓝牙 Mesh：流程、约束与实践，基于 WCH CH585 SDK</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>蓝牙Mesh基于广播泛洪组网，单包仅7字节可用，配网到通信须严格按序配置</h1>
<h2>蓝牙 Mesh 与传统 BLE 的本质区别</h2>
<p>传统 BLE 是点对点连接：手机连耳机、手环连手机，一对一，连接建立后才能通信。蓝牙 Mesh 换了一种思路：它<strong>不建立连接</strong>，直接基于广播 (flooding) 构建多对多网络。每个节点在 3 个广播信道上发送和接收消息，听到消息后如果自己是目标就处理，不是就转发（如果开启了 Relay），消息像涟漪一样扩散到整个网络。</p>
<p>这个设计带来了几个直接影响：</p>
<ul>
<li><strong>没有连接，没有主从</strong>。任何节点可以和任何节点通信，只要它们在同一个网络里。</li>
<li><strong>范围可扩展</strong>。两个节点距离太远？中间节点会帮你转发。</li>
<li><strong>天然支持组控</strong>。发一条消息到一个组播地址，订阅了该地址的所有节点同时响应。</li>
<li><strong>代价是带宽极低</strong>。广播信道速率有限，加上 flooding 带来的消息冗余，实际可用吞吐量很低。</li>
</ul>
<h2>网络中的四种角色</h2>
<p>蓝牙 Mesh 定义了四种可选功能角色，组合使用来适应不同场景：</p>
<table>
<thead>
<tr>
<th>角色</th>
<th>功能</th>
<th>典型场景</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Relay（中继）</strong></td>
<td>接收并转发消息，扩展网络覆盖</td>
<td>所有需要多跳的节点，如灯网中的每个灯泡</td>
</tr>
<tr>
<td><strong>Proxy（代理）</strong></td>
<td>通过 GATT 连接让非 Mesh 设备（如手机）接入网络</td>
<td>网关、带手机 App 控制的设备</td>
</tr>
<tr>
<td><strong>Friend（朋友）</strong></td>
<td>为低功耗节点缓存消息，等它醒来再转发</td>
<td>常供电的灯或网关</td>
</tr>
<tr>
<td><strong>Low Power（低功耗）</strong></td>
<td>大部分时间休眠，定时唤醒向 Friend 要消息</td>
<td>电池供电的传感器、开关</td>
</tr>
</tbody></table>
<p><strong>约束</strong>：Friend 和 Low Power 是互斥的，一个节点不能同时充当两者。另外，Relay 功能会显著增加功耗，电池设备通常不开 Relay。</p>
<p>在 CH585 SDK 中，这些功能通过编译宏控制（注意 LPN/Friend 的注册要先于 Relay 初始化）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// app.c 中的功能初始化</span></span>
<span class="line"><span style="color:#C586C0">#if</span><span style="color:#569CD6">(CONFIG_BLE_MESH_FRIEND)</span></span>
<span class="line"><span style="color:#DCDCAA">    friend_init_register</span><span style="color:#D4D4D4">(bt_mesh_friend_init, friend_state);</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span>
<span class="line"><span style="color:#C586C0">#if</span><span style="color:#569CD6">(CONFIG_BLE_MESH_LOW_POWER)</span></span>
<span class="line"><span style="color:#DCDCAA">    lpn_init_register</span><span style="color:#D4D4D4">(bt_mesh_lpn_init, lpn_state);</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span>
<span class="line"><span style="color:#6A9955">// ... 中间还有 cfg_set、comp_register 等步骤 ...</span></span>
<span class="line"><span style="color:#C586C0">#if</span><span style="color:#569CD6">(CONFIG_BLE_MESH_RELAY)</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_relay_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span></code></pre><p>对应的 SDK 示例工程覆盖了所有角色组合：<code>adv_vendor</code>（基础节点）、<code>adv_vendor_friend</code>（Friend）、<code>adv_vendor_low_power</code>（LPN）、<code>adv_proxy</code>（Proxy）。</p>
<h2>四大阶段：从入网到通信</h2>
<p>一个蓝牙 Mesh 节点的完整生命周期分为四个阶段。</p>
<h3>第一阶段：初始化</h3>
<p>初始化的顺序是固定的，不能随意调换。以 CH585 SDK 的 <code>blemesh_on_sync()</code> 为例（为可读性做了精简，保留核心调用和顺序；实际使用时务必检查返回值，参考 SDK 原版 <code>app.c</code> 中的完整错误处理）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> blemesh_on_sync</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#4EC9B0">    mem_info_t</span><span style="color:#D4D4D4"> info;</span></span>
<span class="line"><span style="color:#9CDCFE">    info</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">base_addr</span><span style="color:#D4D4D4"> = MESH_MEM;</span></span>
<span class="line"><span style="color:#9CDCFE">    info</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">mem_len</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">ARRAY_SIZE</span><span style="color:#D4D4D4">(MESH_MEM);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 1. Friend/LPN 功能注册（如有），位于 cfg_set 之前</span></span>
<span class="line"><span style="color:#C586C0">#if</span><span style="color:#569CD6">(CONFIG_BLE_MESH_FRIEND)</span></span>
<span class="line"><span style="color:#DCDCAA">    friend_init_register</span><span style="color:#D4D4D4">(bt_mesh_friend_init, friend_state);</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span>
<span class="line"><span style="color:#C586C0">#if</span><span style="color:#569CD6">(CONFIG_BLE_MESH_LOW_POWER)</span></span>
<span class="line"><span style="color:#DCDCAA">    lpn_init_register</span><span style="color:#D4D4D4">(bt_mesh_lpn_init, lpn_state);</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 2. 设置 Mesh 全局参数（内存池、Flash、RF 参数）</span></span>
<span class="line"><span style="color:#DCDCAA">    GetMACAddress</span><span style="color:#D4D4D4">(MACAddr);</span></span>
<span class="line"><span style="color:#DCDCAA">    tmos_memcpy</span><span style="color:#D4D4D4">(dev_uuid, MACAddr, </span><span style="color:#B5CEA8">6</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_cfg_set</span><span style="color:#D4D4D4">(&#x26;app_mesh_cfg, &#x26;app_dev, MACAddr, &#x26;info);</span></span>
<span class="line"><span style="color:#DCDCAA">    hal_rf_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 3. 注册 Composition Data——告诉协议栈这个设备有什么能力</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_comp_register</span><span style="color:#D4D4D4">(&#x26;app_comp);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 4. 按需初始化各功能模块</span></span>
<span class="line"><span style="color:#C586C0">#if</span><span style="color:#569CD6">(CONFIG_BLE_MESH_RELAY)</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_relay_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span>
<span class="line"><span style="color:#C586C0">#if</span><span style="color:#569CD6">(CONFIG_BLE_MESH_PROXY </span><span style="color:#D4D4D4">||</span><span style="color:#569CD6"> CONFIG_BLE_MESH_PB_GATT)</span></span>
<span class="line"><span style="color:#6A9955">    // ... proxy beacon / gatts notify 等注册 ...</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_proxy_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_prov_init</span><span style="color:#D4D4D4">(&#x26;app_prov);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 5. 初始化协议栈各层</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_mod_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_net_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_trans_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_beacon_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_adv_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 6. 从 Flash 恢复配网信息</span></span>
<span class="line"><span style="color:#C586C0">#if</span><span style="color:#569CD6">(CONFIG_BLE_MESH_SETTINGS)</span></span>
<span class="line"><span style="color:#DCDCAA">    bt_mesh_settings_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">    settings_load</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 7. 判断是否已配网</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">bt_mesh_is_provisioned</span><span style="color:#D4D4D4">()) {</span></span>
<span class="line"><span style="color:#DCDCAA">        APP_DBG</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"Mesh network restored from flash"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">    } </span><span style="color:#C586C0">else</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#DCDCAA">        prov_enable</span><span style="color:#D4D4D4">();</span><span style="color:#6A9955">  // 发送未配网 Beacon，等待 Provisioner 发现</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><strong>关键点</strong>：<code>bt_mesh_cfg_set</code> 中传入的 <code>ble_mesh_cfg_t</code> 结构体决定了整个协议栈的运行时资源，包括消息缓存数量、分段参数、Friend/LPN 队列大小等。这些参数配小了会丢消息，配大了浪费 RAM。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// app_mesh_config.c 中的资源配置</span></span>
<span class="line"><span style="color:#569CD6">const</span><span style="color:#4EC9B0"> ble_mesh_cfg_t</span><span style="color:#D4D4D4"> app_mesh_cfg = {</span></span>
<span class="line"><span style="color:#D4D4D4">    .</span><span style="color:#9CDCFE">common_cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">adv_buf_count</span><span style="color:#D4D4D4"> = CONFIG_MESH_ADV_BUF_COUNT_DEF,</span></span>
<span class="line"><span style="color:#D4D4D4">    .</span><span style="color:#9CDCFE">net_cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">msgcache_count</span><span style="color:#D4D4D4"> = CONFIG_MESH_MSG_CACHE_DEF,</span><span style="color:#6A9955">  // 消息去重缓存</span></span>
<span class="line"><span style="color:#D4D4D4">    .</span><span style="color:#9CDCFE">net_cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">subnet_count</span><span style="color:#D4D4D4"> = CONFIG_MESH_SUBNET_COUNT_DEF,</span><span style="color:#6A9955">  // 子网数量</span></span>
<span class="line"><span style="color:#D4D4D4">    .</span><span style="color:#9CDCFE">net_cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">appkey_count</span><span style="color:#D4D4D4"> = CONFIG_MESH_APPKEY_COUNT_DEF,</span><span style="color:#6A9955">  // AppKey 数量</span></span>
<span class="line"><span style="color:#D4D4D4">    .</span><span style="color:#9CDCFE">net_cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">rxseg_count</span><span style="color:#D4D4D4"> = CONFIG_MESH_RX_SEG_COUNT_DEF,</span><span style="color:#6A9955">   // 接收分段数</span></span>
<span class="line"><span style="color:#D4D4D4">    .</span><span style="color:#9CDCFE">net_cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">rxsdu_max</span><span style="color:#D4D4D4"> = CONFIG_MESH_RX_SDU_DEF,</span><span style="color:#6A9955">           // 最大接收 SDU</span></span>
<span class="line"><span style="color:#D4D4D4">    .</span><span style="color:#9CDCFE">friend_cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">frndlpn_count</span><span style="color:#D4D4D4"> = CONFIG_MESH_FRIEND_LPN_COUNT_DEF,</span><span style="color:#6A9955"> // Friend 能服务几个 LPN</span></span>
<span class="line"><span style="color:#6A9955">    // ...</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><h3>第二阶段：配网 (Provisioning)</h3>
<p>配网是新设备加入 Mesh 网络的唯一入口，由 Provisioner（配网器）发起。协议支持两种承载方式：</p>
<ul>
<li><strong>PB-ADV</strong>：通过广播信道配网，是纯 Mesh 设备的默认方式</li>
<li><strong>PB-GATT</strong>：通过 GATT 连接配网，手机 App 配网时使用</li>
</ul>
<p>配网流程：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>未配网设备发 Beacon → Provisioner 扫描并邀请 → 建立 provisioning link</span></span>
<span class="line"><span>→ ECDH 交换公钥 → OOB 认证（可选：数字比对、输入密码等）</span></span>
<span class="line"><span>→ Provisioner 分发 NetKey + Unicast Address + IV Index</span></span>
<span class="line"><span>→ prov_complete 回调触发</span></span></code></pre><p>在代码中体现为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> const</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> bt_mesh_prov app_prov = {</span></span>
<span class="line"><span style="color:#D4D4D4">    .uuid = dev_uuid,</span><span style="color:#6A9955">          // 设备 UUID，用于被 Provisioner 识别</span></span>
<span class="line"><span style="color:#D4D4D4">    .link_open = link_open,</span><span style="color:#6A9955">     // 链路打开回调</span></span>
<span class="line"><span style="color:#D4D4D4">    .link_close = link_close,</span><span style="color:#6A9955">   // 链路关闭回调</span></span>
<span class="line"><span style="color:#D4D4D4">    .complete = prov_complete,</span><span style="color:#6A9955">  // 配网完成回调——此时设备正式入网</span></span>
<span class="line"><span style="color:#D4D4D4">    .reset = prov_reset,</span><span style="color:#6A9955">        // 节点被重置回调</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><p><strong>配网完成后，设备拿到了三个关键凭证</strong>：</p>
<ul>
<li>NetKey：网络层加密密钥，整个子网共用</li>
<li>Unicast Address：设备的唯一地址（0x0001 ~ 0x7FFF）</li>
<li>IV Index：用于防重放攻击的序列号基础</li>
</ul>
<h3>第三阶段：配置 (Configuration)</h3>
<p>配网只是拿到了&quot;入场券&quot;，节点还不能通信。Provisioner 需要通过 Configuration Model 进行三步配置，<strong>顺序严格不可变</strong>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>① NetKey Add     → 确认网络密钥</span></span>
<span class="line"><span>② AppKey Add     → 分发应用层密钥</span></span>
<span class="line"><span>③ Model-App Bind → 将 AppKey 绑定到具体 Model</span></span>
<span class="line"><span>④ Subscription Add → 给 Model 设置订阅地址（可选但实际必做）</span></span></code></pre><p>在 <code>app.c</code> 的 <code>cfg_srv_rsp_handler</code> 中可以清楚看到这个顺序逻辑：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> cfg_srv_rsp_handler</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#4EC9B0"> cfg_srv_status_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">cfgHdr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">status</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#6A9955">        // 操作执行失败</span></span>
<span class="line"><span style="color:#DCDCAA">        APP_DBG</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"warning opcode 0x</span><span style="color:#9CDCFE">%02x</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">cfgHdr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">opcode</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">cfgHdr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">opcode</span><span style="color:#D4D4D4"> == OP_APP_KEY_ADD) {</span></span>
<span class="line"><span style="color:#DCDCAA">        APP_DBG</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"App Key Added"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">        tmos_start_task</span><span style="color:#D4D4D4">(App_TaskID, APP_DELETE_LOCAL_NODE_EVT, APP_WAIT_ADD_APPKEY_DELAY);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    else</span><span style="color:#C586C0"> if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">cfgHdr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">opcode</span><span style="color:#D4D4D4"> == OP_MOD_APP_BIND) {</span></span>
<span class="line"><span style="color:#DCDCAA">        APP_DBG</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"Vendor Model Binded"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">        tmos_start_task</span><span style="color:#D4D4D4">(App_TaskID, APP_DELETE_LOCAL_NODE_EVT, APP_WAIT_ADD_APPKEY_DELAY);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    else</span><span style="color:#C586C0"> if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">val</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">cfgHdr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">opcode</span><span style="color:#D4D4D4"> == OP_MOD_SUB_ADD) {</span></span>
<span class="line"><span style="color:#DCDCAA">        APP_DBG</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"Vendor Model Subscription Set"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">        tmos_stop_task</span><span style="color:#D4D4D4">(App_TaskID, APP_DELETE_LOCAL_NODE_EVT);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>第四阶段：发布/订阅通信</h3>
<p>配置完成后，节点通过<strong>发布 (Publish) / 订阅 (Subscribe)</strong> 模型进行通信：</p>
<ul>
<li><strong>Server 模型</strong>向某个地址<strong>发布</strong>状态数据</li>
<li><strong>Client 模型****订阅</strong>该地址来接收数据</li>
<li>地址可以是单播、组播或虚拟地址</li>
</ul>
<p>在 CH585 SDK 中发送一条消息：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> int</span><span style="color:#DCDCAA"> vendor_model_srv_send</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint16_t</span><span style="color:#9CDCFE"> addr</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">pData</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint16_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> send_param param = {</span></span>
<span class="line"><span style="color:#D4D4D4">        .app_idx = </span><span style="color:#9CDCFE">vnd_models</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">].</span><span style="color:#9CDCFE">keys</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">],</span><span style="color:#6A9955">  // 使用绑定的 AppKey 加密</span></span>
<span class="line"><span style="color:#D4D4D4">        .addr = addr,</span><span style="color:#6A9955">                       // 目标地址（组播或单播）</span></span>
<span class="line"><span style="color:#D4D4D4">        .trans_cnt = </span><span style="color:#B5CEA8">0x01</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955">                  // 协议层重传次数</span></span>
<span class="line"><span style="color:#D4D4D4">        .period = </span><span style="color:#DCDCAA">K_MSEC</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">400</span><span style="color:#D4D4D4">),</span><span style="color:#6A9955">              // 应用层发送间隔</span></span>
<span class="line"><span style="color:#D4D4D4">        .rand = (</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">),</span><span style="color:#6A9955">                        // 随机延迟</span></span>
<span class="line"><span style="color:#D4D4D4">        .tid = </span><span style="color:#DCDCAA">vendor_srv_tid_get</span><span style="color:#D4D4D4">(),</span><span style="color:#6A9955">        // 事务 ID，用于去重</span></span>
<span class="line"><span style="color:#D4D4D4">        .send_ttl = BLE_MESH_TTL_DEFAULT,</span><span style="color:#6A9955">   // 跳数限制</span></span>
<span class="line"><span style="color:#D4D4D4">    };</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#DCDCAA"> vendor_message_srv_send_trans</span><span style="color:#D4D4D4">(&#x26;param, pData, len);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><strong><code>period</code> 不能太小</strong>：协议规定重传间隔不应小于 <code>200 + 50 × TTL</code> ms。如果你的 TTL 是 7，那最小间隔就是 550ms。设太小会导致消息拥塞。</p>
<h2>协议约束速查</h2>
<p>这些是实际开发中最常碰到的硬限制：</p>
<table>
<thead>
<tr>
<th>约束项</th>
<th>数值</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>未分段单包用户数据</td>
<td><strong>7 字节</strong></td>
<td>SDK 标注 <code>BLE_MESH_SDU_UNSEG_MAX = 11</code>，减去层开销后剩 7</td>
</tr>
<tr>
<td>分段最大有效载荷</td>
<td>32 × 12 = 384 字节</td>
<td>SDK 标注用户可用约 376 字节（减 8 字节头）</td>
</tr>
<tr>
<td>最大 TTL</td>
<td>127</td>
<td>实际默认 3~7</td>
</tr>
<tr>
<td>单播地址范围</td>
<td>0x0001 ~ 0x7FFF</td>
<td>最多 32767 个节点</td>
</tr>
<tr>
<td>组播地址范围</td>
<td>0xC000 ~ 0xFEFF</td>
<td>用户可配置的组播地址</td>
</tr>
<tr>
<td>固定地址</td>
<td>0xFFFC ~ 0xFFFF</td>
<td>all-proxies (0xFFFC)、all-friends (0xFFFD)、all-relays (0xFFFE)、all-nodes (0xFFFF)</td>
</tr>
<tr>
<td>虚拟地址范围</td>
<td>0x8000 ~ 0xBFFF</td>
<td>基于 Label UUID 生成的哈希地址</td>
</tr>
<tr>
<td>广播信道</td>
<td>37、38、39</td>
<td>与 BLE 广播共用</td>
</tr>
<tr>
<td>单个 Model 可绑定的 AppKey 数</td>
<td><code>CONFIG_MESH_MOD_KEY_COUNT_DEF</code></td>
<td>SDK 可配置</td>
</tr>
<tr>
<td>单个 Model 可订阅的组地址数</td>
<td><code>CONFIG_MESH_MOD_GROUP_COUNT_DEF</code></td>
<td>SDK 可配置</td>
</tr>
</tbody></table>
<p><strong>7 字节的限制意味着什么</strong>？控制开关灯只需要 1 个字节，没问题。但如果想透传一段自定义数据，几乎一定会触发分段传输。分段传输涉及 Segment Acknowledgment，延迟和丢包风险都会增加。</p>
<h2>常见开发坑点</h2>
<p><strong>1. 配置顺序不能乱</strong></p>
<p>NetKey → AppKey → Bind → Subscription，跳步或乱序都会收到 <code>STATUS_INVALID_APPKEY</code> 或 <code>STATUS_CANNOT_BIND</code> 错误。这不是 SDK 的设计选择，是蓝牙 Mesh 协议规范强制要求的。</p>
<p><strong>2. 消息发送间隔不能太小</strong></p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">.period = </span><span style="color:#DCDCAA">K_MSEC</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">400</span><span style="color:#D4D4D4">),</span><span style="color:#6A9955">  // 不小于 (200 + 50 * TTL) ms</span></span></code></pre><p>如果周期太快，协议栈来不及处理重传确认，会返回 <code>EBUSY</code>。需要高速上报的场景，蓝牙 Mesh 不适合，考虑用 BLE 直连。</p>
<p><strong>3. Friend 和 LPN 互斥</strong></p>
<p>一个节点不能同时启用 Friend 和 Low Power 功能。这是协议的定义：Friend 是&quot;帮忙的人&quot;，LPN 是&quot;被帮忙的人&quot;，一个节点不能同时是两者。</p>
<p><strong>4. 配网信息要持久化</strong></p>
<p>配网完成后，NetKey、AppKey、Unicast Address、IV Index、Sequence Number 等都需要写入 Flash。<code>settings_load()</code> 在每次启动时恢复这些信息。如果存储失败或丢失，节点只能重置后重新配网。</p>
<p><strong>5. 中继风暴</strong></p>
<p>在一个密集网络中，如果所有节点都开启 Relay 且 TTL 设得很大，一条消息会被反复中继，造成广播风暴。实践中：</p>
<ul>
<li>TTL 不要超过网络直径 + 2</li>
<li>不是所有节点都需要开 Relay</li>
<li>利用 <code>msgcache_count</code> 配置消息缓存来过滤已处理过的消息</li>
</ul>
<p><strong>6. Model 回调中不要做耗时操作</strong></p>
<p>Model 的消息处理回调是在协议栈的上下文中执行的，做耗时操作（如 Flash 写入、长时间计算）会阻塞协议栈处理后续消息。应该把耗时任务通过事件或队列异步处理。</p>
<h2>总结</h2>
<p>蓝牙 Mesh 用广播代替连接、用 flooding 代替路由，换来了多对多通信和网络自愈能力，代价是带宽和功耗。理解它的流程（初始化 → 配网 → 配置 → 发布/订阅）和约束（7 字节小包、严格配置顺序、角色互斥），是写好 Mesh 应用的前提。</p>
<p>CH585 的 MESH_LIB 把这些协议细节封装得很好，你主要需要关注三件事：<code>app_mesh_config.c</code> 里的资源配置、<code>app.c</code> 里的配网回调处理、以及 Model 的消息收发逻辑。对照 SDK 提供的十几个示例工程，基本可以覆盖大多数应用场景。</p>]]></content>
    <category term="嵌入式"/>
    <category term="BLE"/>
    <category term="CH58x"/>
    <category term="通信协议"/>
    <category term="物联网"/>
  </entry>
  <entry>
    <title>CH58X BLE 配对流程详解</title>
    <link href="https://createskyblue.github.io/posts/ch58x-ble-pairing-flow/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ch58x-ble-pairing-flow/</id>
    <published>2026-07-11T00:00:00.000Z</published>
    <updated>2026-07-11T00:00:00.000Z</updated>
    <summary>CH58X BLE 外设配对流程：从广播到加密</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>CH58X BLE 配对流程详解</h1>
<p>本文基于 CH584 MCU 的 BLE 心率传感器项目，逐层拆解 BLE 外设的配对流程。文中的代码注释和配置均来自项目实际源码。</p>
<h2>系统架构</h2>
<p>整个 BLE 协议栈运行在沁恒 CH58x 系列的 TMOS 任务调度系统之上。调用链路如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Main.c 主循环</span></span>
<span class="line"><span>  └─ ble_peripheral_process() → TMOS_SystemProcess()</span></span>
<span class="line"><span>       └─ 事件分发 ble_peripheral_process_event()</span></span>
<span class="line"><span>            └─ GAPRole_PeripheralStartDevice() → 启动设备</span></span>
<span class="line"><span>                 └─ GAP → GATT → SM（安全管理器）</span></span>
<span class="line"><span>                      └─ CH58x BLE Controller（硬件链路层）</span></span></code></pre><p><code>Main.c</code> 在主循环中交替执行 <code>ble_peripheral_process()</code> 和应用调度器。协议栈事件通过 TMOS 消息机制异步回调到应用层。</p>
<h2>编译期 BLE 配置</h2>
<p>项目在 <code>src/app_project_config.h</code> 中覆盖了 SDK 的默认 BLE 参数：</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>值</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td><code>BLE_MEMHEAP_SIZE</code></td>
<td>10240 (10 KB)</td>
<td>协议栈内存池</td>
</tr>
<tr>
<td><code>BLE_BUFF_MAX_LEN</code></td>
<td>251 (ATT_MTU = 247)</td>
<td>最大应用数据包长度</td>
</tr>
<tr>
<td><code>BLE_BUFF_NUM</code></td>
<td>8</td>
<td>收发缓冲区数量</td>
</tr>
<tr>
<td><code>BLE_TX_NUM_EVENT</code></td>
<td>6</td>
<td>单连接事件最大发包数</td>
</tr>
<tr>
<td><code>BLE_TX_POWER</code></td>
<td><code>LL_TX_POWEER_MINUS_10_DBM</code></td>
<td>发射功率 -10 dBm</td>
</tr>
</tbody></table>
<p>SDK 层 <code>CONFIG.h</code> 还控制了绑定存储的硬件参数：</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>值</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td><code>BLE_SNV</code></td>
<td>TRUE</td>
<td>启用 SNV 绑定存储</td>
</tr>
<tr>
<td><code>BLE_SNV_ADDR</code></td>
<td><code>0x77000 - FLASH_ROM_MAX_SIZE</code></td>
<td>存储起始地址</td>
</tr>
<tr>
<td><code>BLE_SNV_BLOCK</code></td>
<td>256 bytes</td>
<td>块大小</td>
</tr>
<tr>
<td><code>BLE_SNV_NUM</code></td>
<td>1</td>
<td>块数量</td>
</tr>
<tr>
<td><code>PERIPHERAL_MAX_CONNECTION</code></td>
<td>1</td>
<td>最大同时连接数</td>
</tr>
</tbody></table>
<h2>配对初始化</h2>
<p>入口函数 <code>ble_peripheral_init()</code> 位于 <code>src/ble_peripheral.c:103</code>，分三步完成初始化。</p>
<h3>第一步：底层初始化</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">CH58x_BLEInit</span><span style="color:#D4D4D4">();</span><span style="color:#6A9955">          // BLE 协议栈库初始化</span></span>
<span class="line"><span style="color:#DCDCAA">HAL_Init</span><span style="color:#D4D4D4">();</span><span style="color:#6A9955">               // 硬件抽象层初始化</span></span>
<span class="line"><span style="color:#DCDCAA">GAPRole_PeripheralInit</span><span style="color:#D4D4D4">();</span><span style="color:#6A9955"> // 配置为 Peripheral 角色</span></span></code></pre><p><code>CH58x_BLEInit()</code> 内部配置 SNV 的 Flash 读写回调，为绑定数据持久化准备硬件通路。</p>
<h3>第二步：广播与连接参数</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> BLE_PERIPHERAL_ADV_INTERVAL      </span><span style="color:#B5CEA8">160</span><span style="color:#6A9955">  // 160 × 0.625 ms = 100 ms</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> BLE_PERIPHERAL_MIN_CONN_INTERVAL </span><span style="color:#B5CEA8">6</span><span style="color:#6A9955">    // 6 × 1.25 ms = 7.5 ms</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> BLE_PERIPHERAL_MAX_CONN_INTERVAL </span><span style="color:#B5CEA8">6</span><span style="color:#6A9955">    // 同上，固定连接间隔</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> BLE_PERIPHERAL_SLAVE_LATENCY     </span><span style="color:#B5CEA8">0</span><span style="color:#6A9955">    // 无延迟</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> BLE_PERIPHERAL_CONN_TIMEOUT      </span><span style="color:#B5CEA8">100</span><span style="color:#6A9955">  // 100 × 10 ms = 1 s</span></span></code></pre><p>广播间隔 100 ms，连接间隔固定 7.5 ms，适合低延迟数据传输。</p>
<h3>第三步：安全参数——核心决策</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4"> passkey  = </span><span style="color:#B5CEA8">0u</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">  pair_mode = GAPBOND_PAIRING_MODE_WAIT_FOR_REQ;</span><span style="color:#6A9955">  // 等主机发起</span></span>
<span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">  mitm      = </span><span style="color:#569CD6">FALSE</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">                               // 无 MITM 保护</span></span>
<span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">  bonding   = </span><span style="color:#569CD6">FALSE</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">                               // 不保存绑定</span></span>
<span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">  io_cap    = GAPBOND_IO_CAP_NO_INPUT_NO_OUTPUT;</span><span style="color:#6A9955">  // 无输入输出</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">GAPBondMgr_SetParameter</span><span style="color:#D4D4D4">(GAPBOND_PERI_DEFAULT_PASSCODE, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(passkey), &#x26;</span><span style="color:#9CDCFE">passkey</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">GAPBondMgr_SetParameter</span><span style="color:#D4D4D4">(GAPBOND_PERI_PAIRING_MODE, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(pair_mode), &#x26;</span><span style="color:#9CDCFE">pair_mode</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">GAPBondMgr_SetParameter</span><span style="color:#D4D4D4">(GAPBOND_PERI_MITM_PROTECTION, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(mitm), &#x26;</span><span style="color:#9CDCFE">mitm</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">GAPBondMgr_SetParameter</span><span style="color:#D4D4D4">(GAPBOND_PERI_IO_CAPABILITIES, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(io_cap), &#x26;</span><span style="color:#9CDCFE">io_cap</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">GAPBondMgr_SetParameter</span><span style="color:#D4D4D4">(GAPBOND_PERI_BONDING_ENABLED, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(bonding), &#x26;</span><span style="color:#9CDCFE">bonding</span><span style="color:#D4D4D4">);</span></span></code></pre><p><strong>Bonding 关闭</strong>意味着每次断开后不会保存对端设备的密钥信息，重连时需要重新完成配对流程。</p>
<h2>配对方式：Just Works</h2>
<p>当前配置组合 (NoInputNoOutput + MITM=FALSE) 决定了采用 BLE 的 <strong>Just Works</strong> 配对：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Central（手机）                        Peripheral（CH58x）</span></span>
<span class="line"><span>     │                                       │</span></span>
<span class="line"><span>     │──── Pairing Request ─────────────────→│  主机发起配对</span></span>
<span class="line"><span>     │←─── Pairing Response ────────────────│  IO=NoInputNoOutput, Bonding=0</span></span>
<span class="line"><span>     │                                       │</span></span>
<span class="line"><span>     │  ← 双方用 TK=0 计算确认值和 STK →    │  无用户交互</span></span>
<span class="line"><span>     │                                       │</span></span>
<span class="line"><span>     │←── Encryption Start ─────────────────→│  链路加密建立</span></span></code></pre><ul>
<li>临时密钥 TK 固定为 0，无用户交互</li>
<li>无 MITM 保护，链路上的中间人攻击无法被检测</li>
<li>不存储 LTK / IRK / CSRK，设备断电或断开后密钥全部丢失</li>
</ul>
<h2>广播与扫描响应</h2>
<p>设备启动后发出两类广播数据：</p>
<p><strong>广播包</strong>（31 字节上限）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Flags: LE General Discoverable + BR/EDR Not Supported</span></span>
<span class="line"><span>Service UUID: 0xFFF0（自定义服务）</span></span></code></pre><p><strong>扫描响应</strong>（在主机主动扫描时返回）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>设备名称: "CH584F_HR_PROTO"（15 字符完整名称）</span></span>
<span class="line"><span>连接间隔范围: 最小 6，最大 6（固定 7.5 ms）</span></span>
<span class="line"><span>TX Power: 0（未显式指定功率值）</span></span></code></pre><p>广播过滤策略使用默认值 <code>GAP_FILTER_POLICY_ALL</code>，未启用白名单过滤。</p>
<h2>连接与断开事件流</h2>
<p>应用层通过 <code>ble_peripheral_state_changed()</code> 回调处理状态迁移。该函数在 <code>ble_peripheral.c:330</code> 实现：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>状态机转换：</span></span>
<span class="line"><span></span></span>
<span class="line"><span>   [空闲/广播中]</span></span>
<span class="line"><span>        │ GAP_LINK_ESTABLISHED_EVENT</span></span>
<span class="line"><span>        ▼</span></span>
<span class="line"><span>   [已连接]</span></span>
<span class="line"><span>        ├─ 记录 conn_handle</span></span>
<span class="line"><span>        ├─ 80 TU 后发起连接参数更新请求</span></span>
<span class="line"><span>        └─ 激活 TinyFrame 协议层</span></span>
<span class="line"><span>        │</span></span>
<span class="line"><span>        │ GAP_LINK_TERMINATED_EVENT</span></span>
<span class="line"><span>        ▼</span></span>
<span class="line"><span>   [等待/广播中]</span></span>
<span class="line"><span>        ├─ 清除连接句柄</span></span>
<span class="line"><span>        ├─ 清除协议层连接状态</span></span>
<span class="line"><span>        └─ 若之前广播已开启 → 自动恢复广播</span></span></code></pre><p>连接参数更新请求始终使用相同的 min/max 间隔（6），本质上是在确认而非协商。断开后广播恢复逻辑由 <code>ble_peripheral_set_advertising()</code> 的状态变量 <code>g_ble_peripheral_advertising_enabled</code> 控制——如果应用层此前开启了广播，断开后自动延续。</p>
<h2>绑定回调：当前全部为空</h2>
<p>协议栈通过 <code>gapBondCBs_t</code> 结构体向应用层通知配对事件：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#4EC9B0"> gapBondCBs_t</span><span style="color:#D4D4D4"> g_ble_peripheral_bond_callbacks = {</span></span>
<span class="line"><span style="color:#569CD6">    NULL</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955">  // passcodeCB   — 动态密码请求</span></span>
<span class="line"><span style="color:#569CD6">    NULL</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955">  // pairStateCB  — 配对状态变化</span></span>
<span class="line"><span style="color:#569CD6">    NULL</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955">  // oobCB        — OOB 安全配对</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><p>三个回调全部为 NULL 的影响：</p>
<ul>
<li><strong>passcodeCB = NULL</strong>：使用静态 Passkey=0，无法动态生成或向用户展示密码</li>
<li><strong>pairStateCB = NULL</strong>：不感知配对进度，出现失败时无日志也无重试</li>
<li><strong>oobCB = NULL</strong>：OOB 安全配对不支持</li>
</ul>
<h2>SNV 绑定存储：硬件已就绪，尚未启用</h2>
<p><code>BLE_SNV = TRUE</code> 已在 <code>CONFIG.h</code> 中开启，Flash 读写回调已在 <code>CH58x_BLEInit()</code> 中注册。但由于 <code>bonding = FALSE</code>，协议栈不会实际写入任何绑定数据。</p>
<p>如果将来启用 bonding，每个绑定设备将占用 6 条 NV 记录，NV ID 计算公式为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">nvID = (bondIdx * </span><span style="color:#B5CEA8">6</span><span style="color:#D4D4D4"> + offset) + </span><span style="color:#B5CEA8">0x0200</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">offset </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">: </span><span style="color:#4EC9B0">gapBondRec_t</span><span style="color:#D4D4D4">    — 设备地址、地址类型、状态标志</span></span>
<span class="line"><span style="color:#D4D4D4">offset </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">: </span><span style="color:#4EC9B0">gapBondLTK_t</span><span style="color:#D4D4D4">    — 本地 LTK（</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4"> bytes）、ediv、rand、keySize</span></span>
<span class="line"><span style="color:#D4D4D4">offset </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">: </span><span style="color:#4EC9B0">gapBondLTK_t</span><span style="color:#D4D4D4">    — 对端 LTK</span></span>
<span class="line"><span style="color:#D4D4D4">offset </span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">: </span><span style="color:#DCDCAA">IRK</span><span style="color:#D4D4D4"> (</span><span style="color:#B5CEA8">16</span><span style="color:#9CDCFE"> bytes</span><span style="color:#D4D4D4">)  — 身份解析密钥</span></span>
<span class="line"><span style="color:#D4D4D4">offset </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">: </span><span style="color:#DCDCAA">CSRK</span><span style="color:#D4D4D4"> (</span><span style="color:#B5CEA8">16</span><span style="color:#9CDCFE"> bytes</span><span style="color:#D4D4D4">) — 签名解析密钥</span></span>
<span class="line"><span style="color:#D4D4D4">offset </span><span style="color:#B5CEA8">5</span><span style="color:#D4D4D4">: SignCounter      — 签名计数器（</span><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4">）</span></span></code></pre><p>存储区域位于 Flash 末尾（<code>0x77000 - FLASH_ROM_MAX_SIZE</code>），单块 256 bytes。如果设备需要绑定多台主机，需要调整 <code>BLE_SNV_NUM</code> 扩大存储空间。</p>
<h2>启用安全配对的方法</h2>
<p>如果需要提升安全性，按以下顺序修改：</p>
<p><strong>1. 启用 Bonding</strong></p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> bonding = </span><span style="color:#569CD6">TRUE</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">GAPBondMgr_SetParameter</span><span style="color:#D4D4D4">(GAPBOND_PERI_BONDING_ENABLED, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(bonding), &#x26;</span><span style="color:#9CDCFE">bonding</span><span style="color:#D4D4D4">);</span></span></code></pre><p><strong>2. 实现绑定回调</strong></p>
<p>至少实现 <code>pairStateCB</code>，在 <code>GAPBOND_PAIRING_STATE_BOND_SAVED</code> 状态时记录绑定完成。如果需要动态密码，则实现 <code>passcodeCB</code>。</p>
<p><strong>3. 为 GATT 特征添加加密权限</strong></p>
<p>当前 BLE UART 服务的读写权限不加加密限制（<code>ble_uart_service_16bit.c</code>）。需要修改两处：</p>
<p>RX 特征值（第 138 行），将 <code>GATT_PERMIT_WRITE</code> 替换为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">GATT_PERMIT_ENCRYPT_WRITE,</span></span></code></pre><p>TX 特征 CCCD（第 124 行），将 <code>GATT_PERMIT_READ | GATT_PERMIT_WRITE</code> 替换为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">GATT_PERMIT_ENCRYPT_READ | GATT_PERMIT_ENCRYPT_WRITE,</span></span></code></pre><p>加密后，主机必须先完成配对建立加密链路，才能读写这些特征。<code>GATT_PERMIT_ENCRYPT_READ</code> 和 <code>GATT_PERMIT_ENCRYPT_WRITE</code> 定义在 <code>CH58xBLE_LIB.h</code> 中，分别为 <code>0x40</code> 和 <code>0x80</code>。</p>
<p><strong>4. 按需启用 MITM</strong></p>
<p>如果设备有显示或输入能力，可以启用 MITM 保护：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> mitm = </span><span style="color:#569CD6">TRUE</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> io_cap = GAPBOND_IO_CAP_DISPLAY_YES_NO;</span></span></code></pre><p>注意：启用 MITM 后配对需要用户交互（确认数字或输入密码），对无屏设备不适用。</p>
<h2>总结</h2>
<p>当前固件的 BLE 配对策略可以归纳为几条关键事实：</p>
<ul>
<li>配对模式为 Just Works，无认证、无 MITM 保护</li>
<li>Bonding 关闭，断开后不记忆设备，每次重连都需要重新配对</li>
<li>SNV Flash 存储已配置但未使用</li>
<li>广播在断开后自动恢复，遵循应用层的开关状态</li>
<li>连接间隔固定 7.5 ms，延迟极低，适合心率数据实时传输</li>
<li>升级到安全配对只需修改 bonding 标志、实现回调、添加加密权限三步</li>
</ul>]]></content>
    <category term="BLE"/>
    <category term="CH58x"/>
    <category term="嵌入式"/>
    <category term="MCU"/>
  </entry>
  <entry>
    <title>两个 CH58X 芯片通过 NFC-A 实现 FIFO 数据透传</title>
    <link href="https://createskyblue.github.io/posts/ch58x-nfc-p2p-data-exchange/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ch58x-nfc-p2p-data-exchange/</id>
    <published>2026-07-11T00:00:00.000Z</published>
    <updated>2026-07-11T00:00:00.000Z</updated>
    <summary>CH58X 间 NFC 透传：轮询帧双向捎带 FIFO 数据</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>两个 CH58X 芯片通过 NFC-A 实现 FIFO 数据透传</h1>
<p>CH58X 系列（CH585/CH584）的 NFC-A 外设同时支持 PCD（Proximity Coupling Device，读卡器）和 PICC（Proximity Integrated Circuit Card，卡模拟）两种角色。FIFO（First In First Out，先进先出队列）是数据透传的核心——双方各维护一个发送队列，上层应用往里塞数据，通信层在每次交互时取出并发送。SDK 没有提供开箱即用的&quot;透传模式&quot;，但底层的 PCD（读卡器）/PICC（卡模拟）API 允许在 ISO 14443-3A 传输层之上构建任意数据交换。一块芯片做读卡器发起命令，另一块做卡响应，双方通过自定义命令码通信。</p>
<h2>核心思路</h2>
<p>PCD（读卡器）主动发起通信，PICC（卡模拟）被动响应。ISO 14443-3A 的标准选择流程——REQA（Request，寻卡指令）→ ANTICOLL（Anti-Collision，防冲突）→ SELECT（选中）——完成后，PICC（卡模拟）进入 ACTIVE 状态。在此状态下，<code>data_handler</code> 回调可以识别自定义命令码，读写任意数据缓冲区。每次数据交换由 PCD（读卡器）发起一帧，PICC（卡模拟）回复一帧。</p>
<h2>两端如何收发数据</h2>
<p><strong>PCD（读卡器）端</strong>——主动发、等回复：</p>
<p>PCD（读卡器）是通信的发起方。先把要发送的数据填入 <code>g_nfca_pcd_send_buf</code>，调用 <code>nfca_pcd_communicate(data_bits_num, recv_mode, offset)</code>——前两个参数指定要发送的比特数和接收模式，第三个参数 <code>offset</code> 是首比特在首字节中的偏移（0-7），通常填 0。芯片会自动把数据调制成 13.56 MHz 射频信号发射出去，发完后自动切换到接收模式，等待 PICC（卡模拟）回复。轮询 <code>nfca_pcd_get_communicate_status</code> 直到状态变为 DONE，回复数据就从 <code>g_nfca_pcd_recv_buf</code> 里读，长度用 <code>nfca_pcd_get_recv_data_len</code> 获取。</p>
<p>整个过程是：<strong>填发送缓冲 → 调 communicate → 等状态结束 → 读接收缓冲</strong>。</p>
<p><strong>PICC（卡模拟）端</strong>——被动收、填回复：</p>
<p>PICC（卡模拟）靠读卡器的射频场供电，不能主动发数据。SDK 通过回调机制处理通信：注册一个 <code>nfca_picc_cb_t</code> 结构体，其中 <code>data_handler</code> 是核心回调，在中断里被调用。每当读卡器发来一帧数据，<code>g_picc_data_buf</code> 里就是收到的内容，<code>bits_num</code> 是比特数。你在这个回调里解析命令、准备响应：把回复数据写入同一个 <code>g_picc_data_buf</code>，校验位写入 <code>g_picc_parity_buf</code>，最后返回回复的比特数，芯片自动完成后续的射频回复。</p>
<p>整个过程是：<strong>data_handler 被触发 → 读 g_picc_data_buf → 写回复到同一缓冲 → 返回比特数</strong>。</p>
<h2>通信流程</h2>
<ol>
<li>PCD（读卡器）发 REQA（寻卡，0x26）/WUPA（唤醒，0x52）→ PICC（卡模拟）回 ATQA（寻卡应答）→ PICC（卡模拟）进入 READY 状态</li>
<li>PCD（读卡器）执行 ANTICOLL（防冲突，0x93）+ SELECT（选中）→ PICC（卡模拟）回 UID（唯一标识符）+ SAK（选中确认）→ PICC（卡模拟）进入 <strong>ACTIVE</strong> 状态</li>
<li>ACTIVE 状态下，PCD（读卡器）可发送任意自定义命令码，PICC（卡模拟）的 <code>data_handler</code> 处理并回复</li>
</ol>
<p>PICC（卡模拟）端可以参考 SDK 中 T2T（Type 2 Tag，NFC Forum 定义的标签标准之一）示例（<code>wch_nfca_picc_t2t.c</code>）的 <code>NFCA_PICC_T2T_STATE_ACTIVE</code> 分支，把 READ/WRITE 等标准命令替换为自己的协议即可。</p>
<h2>FIFO 数据如何搭上轮询帧</h2>
<p>PICC（卡模拟）不能主动发送，PCD（读卡器）必须持续发起交互。两端各维护一个发送 FIFO：PCD 的请求帧带走一批数据，PICC 的响应帧带回另一批数据，一轮完成一次双向搬运。PCD 没有数据时仍发送空请求，否则 PICC 没有回复机会。</p>
<p>ACTIVE 状态下可用 <code>0xF0</code> 标识数据交换帧。请求帧由命令码、序号/ACK、数据长度、数据和 CRC_A 组成；响应帧省略命令码。序号和 ACK 可各占 4 bit，用一个字节同时标记当前批次和确认上一批次。</p>
<p>PCD 在主循环中反复调用 <code>nfca_pcd_communicate</code>，PICC 则在 <code>data_handler</code> 中取出收到的数据并填入响应。通信层只负责轮询、分批、确认和 FIFO 搬运，不解析上层业务协议。</p>
<p>这种请求—应答链路如何变成双向字节通道，见 <a href="/posts/sync-request-response-fifo-exchange">请求应答链路双向透传设计</a>。</p>
<h2>断连与恢复</h2>
<p>PICC（卡模拟）离开射频场后，PCD（读卡器）的 <code>nfca_pcd_communicate</code> 会超时返回错误。PCD（读卡器）侧需要在主循环中检测这个状态：超时后停止对当前卡的轮询，回到 REQA 重新寻卡。</p>
<p>PICC（卡模拟）侧离场后回调 <code>offline</code> 被触发，状态回到 IDLE。重新进入射频场时回调 <code>online</code> 被触发，之后必须重新走完整的选卡流程（REQA → ANTICOLL → SELECT）才能再次进入 ACTIVE 状态接收数据帧。</p>
<p>这意味着上层协议要能接受通信中断：数据 FIFO 在断连期间可能积压，恢复后需从断点继续，而不应假设通道永远在线。</p>
<h2>传输中途断开的数据保护</h2>
<p>负责 NFC 发送的消费者从 FIFO 取出一批数据后，先保存在自己的暂存区。收到匹配的 ACK 才清空暂存区并取下一批；发送超时或断连时保留原批次，重新选卡后继续发送。FIFO 可以继续接收新数据，也不需要回退读指针。</p>
<p>数据不能直接写回 FIFO 的原因，见 <a href="/posts/batch-ack-fifo-cache">FIFO 出队数据应进入消费者暂存区</a>。</p>
<h2>限制</h2>
<ul>
<li>PICC（卡模拟）只能响应，无法主动发起传输，双向通信必须由 PCD（读卡器）端轮询</li>
<li>单帧大小受 SDK 默认缓冲限制——PCD（读卡器）发送缓冲默认 32 字节（<code>NFCA_PCD_DATA_BUF_SIZE</code>），PICC（卡模拟）数据缓冲默认 20 字节（<code>PICC_DATA_BUF_LEN</code>）。如需更大帧可自行增大这些宏，但需确保 RAM 足够</li>
<li>速率固定 106 kbps（ISO 14443A 标准），不支持更高波特率</li>
<li>PICC（卡模拟）的 <code>data_handler</code> 在中断中运行，不可长时间阻塞；ATQA 回复必须在约 90 μs 内发出</li>
</ul>]]></content>
    <category term="CH58x"/>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="通信协议"/>
    <category term="射频"/>
  </entry>
  <entry>
    <title>FlashDB 与 LittleFS 如何在 MCU Flash 上管理数据</title>
    <link href="https://createskyblue.github.io/posts/flashdb-littlefs-storage-principles/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/flashdb-littlefs-storage-principles/</id>
    <published>2026-07-11T00:00:00.000Z</published>
    <updated>2026-07-11T00:00:00.000Z</updated>
    <summary>FlashDB 与 LittleFS 如何在 MCU Flash 上管理数据</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>FlashDB 与 LittleFS 如何在 MCU Flash 上管理数据</h1>
<p><strong>FlashDB</strong> 是面向 MCU Flash 的数据管理组件，<strong>LittleFS（Little File System）</strong> 是为微控制器设计的文件系统。两者经常一起出现在 MCU 的 Flash 存储方案里。</p>
<p>它们解决的问题不同：</p>
<ul>
<li><strong>FlashDB</strong>：面向 KV 数据和日志的数据管理层</li>
<li><strong>LittleFS</strong>：文件系统</li>
</ul>
<p>FlashDB 与 LittleFS 通常使用不同分区并列工作：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>                    应用</span></span>
<span class="line"><span>              +------+------+</span></span>
<span class="line"><span>              |             |</span></span>
<span class="line"><span>          FlashDB        LittleFS</span></span>
<span class="line"><span>          KV / TSDB      文件与目录</span></span>
<span class="line"><span>              |             |</span></span>
<span class="line"><span>             FAL        块设备接口</span></span>
<span class="line"><span>              |             |</span></span>
<span class="line"><span>          数据库分区       文件分区</span></span>
<span class="line"><span>              +------ Flash ------+</span></span></code></pre><p>FlashDB 也支持 POSIX 或 C 标准库文件模式。采用文件模式时，FlashDB 经文件接口访问底层文件系统；移植层提供相应文件接口适配后，该文件系统可以是 LittleFS。两者存在并列分区和文件模式两种组合，不能固定画成唯一的上下层关系。</p>
<hr>
<h2>一、LittleFS 的底层原理</h2>
<p>LittleFS 是为 MCU NOR Flash 设计的文件系统。</p>
<p>它主要解决三个问题：</p>
<ol>
<li>Flash 擦写寿命有限</li>
<li>掉电保护</li>
<li>在有限 RAM 下管理小块更新</li>
</ol>
<h3>1. Flash 的限制</h3>
<p>普通 Flash：</p>
<p>读取：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>addr -> data</span></span></code></pre><p>很简单。</p>
<p>但是写入：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>1 -> 0</span></span></code></pre><p>在满足芯片 program size、对齐和编程次数限制时可以直接写。</p>
<p>但是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>0 -> 1</span></span></code></pre><p>必须：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>擦除整个 sector</span></span></code></pre><p>例如：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Flash:</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Sector 0</span></span>
<span class="line"><span>+----------------+</span></span>
<span class="line"><span>|1111111111111111|</span></span>
<span class="line"><span>+----------------+</span></span>
<span class="line"><span></span></span>
<span class="line"><span>写入:</span></span>
<span class="line"><span></span></span>
<span class="line"><span>11111111 -> 11110000</span></span>
<span class="line"><span></span></span>
<span class="line"><span>OK</span></span>
<span class="line"><span></span></span>
<span class="line"><span></span></span>
<span class="line"><span>00001111 -> 11111111</span></span>
<span class="line"><span></span></span>
<span class="line"><span>必须 erase</span></span></code></pre><p>erase 操作：</p>
<ul>
<li>慢</li>
<li>有次数限制</li>
</ul>
<p>具体擦除寿命由 Flash 型号和数据手册决定。存储层需要避免少量 sector 承担全部高频更新。</p>
<hr>
<h3>LittleFS 如何保存文件</h3>
<p>LittleFS 保存一个文件时，需要记住两类内容：</p>
<ul>
<li>文件说明：文件名、文件大小，以及文件内容存放在哪里。</li>
<li>文件内容：用户真正写入的数据。</li>
</ul>
<p>很小的文件可以直接和文件说明放在一起。文件较大时，文件说明只记录内容的位置，真正的数据会放在其他数据块中。</p>
<p>源码把存放文件说明的成对空间称为 <code>metadata pair</code>，把连接大文件数据块的结构称为 <code>CTZ skip-list</code>。理解后面的过程只需记住：前者负责指出文件在哪里，后者负责保存较大的文件内容。</p>
<p>下面用单个数据块表示较大文件的一次更新。它只展示提交顺序，不代表 LittleFS 每次修改都会复制整个文件：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>修改前：</span></span>
<span class="line"><span></span></span>
<span class="line"><span>文件说明中记录的位置</span></span>
<span class="line"><span>    |</span></span>
<span class="line"><span>    v</span></span>
<span class="line"><span>+-----------+</span></span>
<span class="line"><span>| block 10  |</span></span>
<span class="line"><span>|   AAAA    |</span></span>
<span class="line"><span>|  有效数据  |</span></span>
<span class="line"><span>+-----------+</span></span>
<span class="line"><span></span></span>
<span class="line"><span></span></span>
<span class="line"><span>写入新数据：</span></span>
<span class="line"><span></span></span>
<span class="line"><span>旧引用 -------------> block 10（旧数据仍然有效）</span></span>
<span class="line"><span></span></span>
<span class="line"><span>                       block 20</span></span>
<span class="line"><span>                     +-----------+</span></span>
<span class="line"><span>                     |   BBBB    |</span></span>
<span class="line"><span>                     |  正在写入  |</span></span>
<span class="line"><span>                     +-----------+</span></span>
<span class="line"><span></span></span>
<span class="line"><span></span></span>
<span class="line"><span>提交完成：</span></span>
<span class="line"><span></span></span>
<span class="line"><span>更新后的文件说明</span></span>
<span class="line"><span>    |</span></span>
<span class="line"><span>    v</span></span>
<span class="line"><span>+-----------+</span></span>
<span class="line"><span>| block 20  |</span></span>
<span class="line"><span>|   BBBB    |</span></span>
<span class="line"><span>|  新数据    |</span></span>
<span class="line"><span>+-----------+</span></span>
<span class="line"><span></span></span>
<span class="line"><span>block 10 不再被文件引用，之后可重新分配</span></span></code></pre><p>写入期间，旧文件状态仍可读取。文件内容及相关属性会在 <code>sync</code> 或 <code>close</code> 时提交到文件系统：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>写入新的数据块 → 在 metadata pair 中追加一条带 CRC 的提交，记录新的文件位置 → 旧数据块失去引用 → 以后重新使用旧空间</span></span></code></pre><p>这种“先写新内容，再追加提交新的文件位置”的做法叫作 copy-on-write，也就是写时复制。较大文件的 CTZ skip-list 本身含有块之间的引用，但 LittleFS 不会原地改写一张统一的指针表：新数据块写好后，新的 CTZ 位置会随 metadata commit 一起追加，并由 CRC 判断提交是否完整。小文件以内联形式保存在 metadata pair 中，更新时同样通过 metadata commit 提交。它的好处是新内容确认完成前，旧内容仍然有效。即使写入失败或途中断电，系统仍能使用旧文件；只有新内容完整写入且提交有效后，文件位置才会切换过去。</p>
<hr>
<h3>2. Metadata Pair（LittleFS 核心）</h3>
<p>成对保存的直接好处是：更新过程中始终保留一个已经写完整的版本。平时追加新内容时，旧内容仍然存在；需要擦除、整理其中一块时，另一块仍保留可用内容。即使此时断电，重新启动后也能回到最近一次完整提交。</p>
<p>一个 metadata pair 由两个 block 地址组成。这两个 block 不要求物理连续。每个 metadata block 自身是一段可追加的提交日志，一次提交由若干 metadata entry 和末尾的 CRC 组成。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>metadata pair = {block 5, block 21}</span></span>
<span class="line"><span></span></span>
<span class="line"><span>当前活动 block：</span></span>
<span class="line"><span></span></span>
<span class="line"><span>revision | commit A + CRC | commit B + CRC | 未写空间</span></span></code></pre><p>空间足够时，更新会直接追加到当前 metadata log，不需要在两个 block 之间来回切换。当前 block 需要压缩时，LittleFS 擦除 pair 中作为目标的另一个 block，把仍然有效的最新条目和递增后的 revision count 写进去，并用 commit CRC 完成提交。原活动 block 会保留为旧版本，等以后角色再次轮换时才被擦除。</p>
<p>挂载时，LittleFS 使用 revision count 比较两个 block，并通过 CRC 排除掉电时未完成的 commit。metadata pair 的掉电安全来自旧提交仍然存在、commit CRC 检错和压缩时的双 block 冗余，不能只概括成“先写 block 6，再切换指针”。</p>
<hr>
<h3>3. 均衡磨损</h3>
<p>Flash 的每一小块空间都有擦除寿命。如果配置文件始终占用同一个位置，每次修改都让这一块承担擦除，其他位置却很少使用，这一块会最先老化。</p>
<p>LittleFS 使用一小段 lookahead buffer 记录当前扫描范围内哪些块可以使用。需要新空间时，它从当前范围继续向后寻找，走到 Flash 末尾后再从头开始。挂载时，LittleFS 会根据已经通过 CRC 检查的提交计算一个 seed，再用它选择扫描起点；随着有效提交发生变化，起点也可能变化，因此不会固定从 block 0 开始。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>只使用固定位置：</span></span>
<span class="line"><span></span></span>
<span class="line"><span>block 10：擦除、擦除、擦除、擦除……</span></span>
<span class="line"><span>block 11：几乎不用</span></span>
<span class="line"><span>block 12：几乎不用</span></span>
<span class="line"><span></span></span>
<span class="line"><span></span></span>
<span class="line"><span>LittleFS 分散使用：</span></span>
<span class="line"><span></span></span>
<span class="line"><span>第一次使用 block 10</span></span>
<span class="line"><span>下一次使用 block 11</span></span>
<span class="line"><span>继续使用 block 12</span></span>
<span class="line"><span>走到末尾后再从前面寻找空闲位置</span></span></code></pre><p>目录等信息如果长期占用同一块，LittleFS 也会在达到一定使用次数后把它换到其他位置。</p>
<p>这种做法只能分散“允许换位置”的数据。一个文件写入后长期不变，LittleFS 不会为了平衡寿命主动搬动它。因此这里的“均衡”表示尽量分散擦除次数，不表示每一块的使用次数完全相同。</p>
<hr>
<h3>4. 垃圾回收</h3>
<p>垃圾回收要解决的问题很直接：新内容写到新位置后，Flash 中会留下旧版本。旧版本不能立刻擦除，因为新版本可能还没有写完；确认新版本完整后，旧空间才可以重新使用。</p>
<p>LittleFS 需要处理两类旧内容。</p>
<p>第一类是目录、文件名和文件位置等信息。每次修改都会在后面追加一个新版本。空间逐渐写满后，LittleFS 把仍然有效的最新内容整理到成对的另一块中：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>整理前：</span></span>
<span class="line"><span></span></span>
<span class="line"><span>A旧 | B有效 | A新 | C有效 | 剩余空间很少</span></span>
<span class="line"><span></span></span>
<span class="line"><span>整理后：</span></span>
<span class="line"><span></span></span>
<span class="line"><span>A新 | B有效 | C有效 | 空闲空间</span></span></code></pre><p>新的一块写完并确认有效后，原来那块暂时保留为旧版本，以后轮换到它时再擦除使用。这样即使整理途中掉电，旧版本仍能用于恢复。</p>
<p>第二类是文件内容。文件修改时，新内容写到新位置，文件记录随后改为指向新内容。旧位置失去引用，下一次寻找可用空间时就可以被识别出来：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>修改前：文件记录 -> block 10</span></span>
<span class="line"><span></span></span>
<span class="line"><span>修改后：文件记录 -> block 20</span></span>
<span class="line"><span>                    block 10 已无文件引用</span></span>
<span class="line"><span></span></span>
<span class="line"><span>以后需要空间：擦除 block 10，再写入其他内容</span></span></code></pre><p>LittleFS 不会把所有有效数据集中搬到一片连续区域。它主要整理写满的目录信息，并在寻找空闲空间时重新利用已经没有文件引用的块。</p>
<hr>
<h2>二、FlashDB 原理</h2>
<p>FlashDB 提供面向 Flash 的数据库接口，不提供目录、路径和普通文件语义。它包含两种数据库：</p>
<ul>
<li>KVDB</li>
<li>TSDB</li>
</ul>
<hr>
<h3>1. KVDB</h3>
<p>Key Value Database</p>
<p>例如：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>wifi_ssid = "home"</span></span>
<span class="line"><span></span></span>
<span class="line"><span>motor_speed = 100</span></span>
<span class="line"><span></span></span>
<span class="line"><span>device_id = 123456</span></span></code></pre><p>KV 不需要绑定固定物理地址：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>地址:</span></span>
<span class="line"><span></span></span>
<span class="line"><span>0x1000 wifi_ssid</span></span>
<span class="line"><span>0x1100 motor_speed</span></span></code></pre><p>更新后的记录会写入新的空闲位置：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Flash:</span></span>
<span class="line"><span></span></span>
<span class="line"><span>Record1:</span></span>
<span class="line"><span>key=wifi_ssid</span></span>
<span class="line"><span>value=home</span></span>
<span class="line"><span></span></span>
<span class="line"><span></span></span>
<span class="line"><span>Record2:</span></span>
<span class="line"><span>key=motor_speed</span></span>
<span class="line"><span>value=100</span></span>
<span class="line"><span></span></span>
<span class="line"><span></span></span>
<span class="line"><span>Record3:</span></span>
<span class="line"><span>key=wifi_ssid</span></span>
<span class="line"><span>value=office</span></span></code></pre><p>KVDB 可选用 KV cache 直接定位记录。未启用缓存或缓存未命中时，<code>find_kv</code> 会遍历 sector 中的记录，寻找名称匹配、CRC 正确且状态为 <code>FDB_KV_WRITE</code> 的记录。读取逻辑不能简化成每次扫描并取“最后一条同名记录”。</p>
<hr>
<h3>2. FlashDB 的核心：追加写与单向状态机</h3>
<p>FlashDB 会先把数据库分区切成多个 sector，再在每个 sector 中顺序存放 KV：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>数据库分区</span></span>
<span class="line"><span></span></span>
<span class="line"><span>+------------+------------+------------+</span></span>
<span class="line"><span>| sector 0   | sector 1   | sector 2   |</span></span>
<span class="line"><span>+------------+------------+------------+</span></span>
<span class="line"><span></span></span>
<span class="line"><span></span></span>
<span class="line"><span>一个 sector 内部</span></span>
<span class="line"><span></span></span>
<span class="line"><span>+-------------+----------+----------+----------+</span></span>
<span class="line"><span>| sector 说明 | KV 记录1 | KV 记录2 | 空闲空间 |</span></span>
<span class="line"><span>+-------------+----------+----------+----------+</span></span>
<span class="line"><span></span></span>
<span class="line"><span></span></span>
<span class="line"><span>一条 KV 记录</span></span>
<span class="line"><span></span></span>
<span class="line"><span>+------+-------------------+------+-------+</span></span>
<span class="line"><span>| 状态 | 长度、校验值等说明 | key  | value |</span></span>
<span class="line"><span>+------+-------------------+------+-------+</span></span></code></pre><p>sector 开头的说明记录这一块当前是否正在使用、是否含有旧数据。每条 KV 开头也有自己的状态字段，后面才是 key 和 value。因此，“单向状态机”并非一个单独的控制器，它就保存在 Flash 中每条 KV 记录的开头，设备重启后仍然可以读到。</p>
<p>状态变化利用 NOR Flash 可从 <code>1</code> 写成 <code>0</code> 的特点。每向前推进一个状态，只需把更多位写成 <code>0</code>，不用先擦除整个 sector；已经推进的状态无法原路改回，所以称为“单向”。KV 的主体数据仍采用追加写，旧记录只改变状态，新值写入后面的空闲位置。</p>
<p>修改一个已有 KV 时，源码中的顺序是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>旧 KV：WRITE → PRE_DELETE</span></span>
<span class="line"><span>新 KV：PRE_WRITE → 写入 header/name/value/CRC → WRITE</span></span>
<span class="line"><span>旧 KV：PRE_DELETE → DELETED</span></span></code></pre><p>如果在更新中途掉电，初始化恢复会处理这些中间状态：CRC 正确的 <code>PRE_DELETE</code> 旧记录会被搬移恢复，停留在 <code>PRE_WRITE</code> 的未完成新记录会被标记为错误。这个状态机才是 KV 更新掉电恢复的关键。</p>
<hr>
<h3>3. 为什么不用直接改？</h3>
<p>因为 Flash：</p>
<p>修改：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>00001111</span></span></code></pre><p>想变：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>11111111</span></span></code></pre><p>需要：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>erase</span></span></code></pre><p>追加新记录时：</p>
<p>只需要：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>写新位置</span></span></code></pre><hr>
<h3>4. FlashDB 的垃圾回收</h3>
<p>比如：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Flash:</span></span>
<span class="line"><span></span></span>
<span class="line"><span>A=1</span></span>
<span class="line"><span>B=2</span></span>
<span class="line"><span>A=3</span></span>
<span class="line"><span>C=4</span></span>
<span class="line"><span>A=5</span></span></code></pre><p>状态仍为 <code>WRITE</code> 的有效记录：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>A=5</span></span>
<span class="line"><span>B=2</span></span>
<span class="line"><span>C=4</span></span></code></pre><p>已经删除或写入失败的记录：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>A=1</span></span>
<span class="line"><span>A=3</span></span></code></pre><p>KV 更新多次以后，同一个 sector 里会混合有效记录、已删除记录和写入失败的记录。完整空 sector 所剩不多时，KVDB 开始回收：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>1. 给准备回收的 sector 做标记</span></span>
<span class="line"><span></span></span>
<span class="line"><span>2. 逐条检查里面的记录</span></span>
<span class="line"><span></span></span>
<span class="line"><span>3. 把确认写完整、仍未删除的 KV 搬到其他可写位置</span></span>
<span class="line"><span></span></span>
<span class="line"><span>4. 擦除原 sector，使它重新变成空闲空间</span></span></code></pre><p>第一步的标记用于应对掉电。如果设备在搬运途中断电，下次启动时能发现回收尚未完成，并继续执行。FlashDB 会搬走一个 sector 里的有效 KV；LittleFS 则分别整理目录信息和寻找已经没有文件使用的旧块，两者做法不同。</p>
<hr>
<h3>5. TSDB（时间序列数据库）</h3>
<p>TSDB 用来保存不断产生、按时间排列的数据。例如传感器每隔一段时间产生一条温度记录：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>time      temp</span></span>
<span class="line"><span></span></span>
<span class="line"><span>10:01     25</span></span>
<span class="line"><span></span></span>
<span class="line"><span>10:02     26</span></span>
<span class="line"><span></span></span>
<span class="line"><span>10:03     27</span></span></code></pre><p>每条记录由两部分组成：</p>
<ul>
<li>索引：记录状态、时间戳，以及实际数据的长度和位置。</li>
<li>实际数据：温度、湿度、错误信息等用户内容。</li>
</ul>
<p>FlashDB 在一个 sector 的两端同时写入。索引从前向后增加，实际数据从后向前增加，中间是尚未使用的空间：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>低地址                                                    高地址</span></span>
<span class="line"><span></span></span>
<span class="line"><span>+------------+--------+--------+----------+--------+--------+</span></span>
<span class="line"><span>| sector说明 | 索引1  | 索引2  | 空闲空间 | 数据2  | 数据1  |</span></span>
<span class="line"><span>+------------+--------+--------+----------+--------+--------+</span></span>
<span class="line"><span>                         →          ←</span></span>
<span class="line"><span>                    索引向后增长   数据向前增长</span></span></code></pre><p>这样安排有两个好处：实际数据可以保持不同长度，不必给每条记录预留同样大的空间；索引长度固定，第 N 条索引的位置可以直接用“起始地址 + N × 索引长度”算出，也就可以在索引区使用二分查找。</p>
<p>追加一条记录时，FlashDB 按下面的顺序写入：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>1. 在新索引中标记“正在写”</span></span>
<span class="line"><span>2. 写入时间戳、数据长度和数据位置</span></span>
<span class="line"><span>3. 把实际数据写到 sector 尾部</span></span>
<span class="line"><span>4. 将索引状态改成“写入完成”</span></span></code></pre><p>最后一步相当于正式提交。设备在前三步中途断电，这条记录仍带有“正在写”标记，不会变成“写入完成”状态。重新启动后，FlashDB 能据此识别它没有完成。当前版本没有给每条时间序列记录单独保存 CRC，掉电时主要依靠这个写入顺序区分完整记录和半条记录。</p>
<p>TSDB 要求新记录的时间戳大于上一条记录，这为二分查找提供了前提。实际代码不会对整个数据库直接二分：它先逐个检查 sector 的起止时间，找到可能包含目标时间的 sector；随后在这个 sector 的定长索引区中二分查找起点；找到后再按固定步长顺序读取索引，直到超出查询时间范围，并根据索引中的地址读取实际数据。它支持顺序查询、倒序查询和指定时间范围查询。</p>
<p>一个 sector 写满后，TSDB 会继续写下一个 sector。默认开启循环覆盖：所有 sector 都用完后，擦除最旧的 sector，继续保存新数据。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>sector 0 → sector 1 → sector 2 → 回到 sector 0</span></span>
<span class="line"><span>                                      ↓</span></span>
<span class="line"><span>                              擦除最旧 sector 后继续写</span></span></code></pre><p>关闭循环覆盖后，所有 sector 写满就停止追加并返回空间不足，旧记录会一直保留。</p>
<p>这种结构适合：</p>
<ul>
<li>IoT</li>
<li>传感器</li>
<li>日志</li>
</ul>
<hr>
<h2>三、FlashDB 和 LittleFS 的区别</h2>
<table>
<thead>
<tr>
<th></th>
<th>LittleFS</th>
<th>FlashDB</th>
</tr>
</thead>
<tbody><tr>
<td>类型</td>
<td>文件系统</td>
<td>数据库</td>
</tr>
<tr>
<td>对象</td>
<td>文件</td>
<td>Key/Record</td>
</tr>
<tr>
<td>访问</td>
<td>open/read/write</td>
<td>get/set</td>
</tr>
<tr>
<td>目录</td>
<td>有</td>
<td>无</td>
</tr>
<tr>
<td>随机文件</td>
<td>支持</td>
<td>不适合</td>
</tr>
<tr>
<td>掉电保护</td>
<td>有</td>
<td>有</td>
</tr>
<tr>
<td>磨损均衡</td>
<td>尽量把可移动数据分散到不同空闲块</td>
<td>KVDB 通过垃圾回收搬走有效记录，再轮流擦除和复用 sector；TSDB 开启循环覆盖后按顺序擦除最旧 sector；文件模式的物理擦写由底层文件系统负责分散</td>
</tr>
<tr>
<td>日志</td>
<td>metadata pair 使用日志提交</td>
<td>KVDB 日志式布局，TSDB 顺序追加</td>
</tr>
<tr>
<td>适合</td>
<td>配置文件、资源</td>
<td>参数、日志、状态</td>
</tr>
</tbody></table>
<p>两者分散磨损的方式不同。LittleFS 用 <code>block_cycles</code> 控制 metadata 达到一定使用次数后主动换块，并通过空闲块分配分散动态数据；FlashDB 不记录每个 sector 的擦除次数，KVDB 的分散效果来自垃圾回收和 sector 轮转，TSDB 则来自开启循环覆盖后的顺序复用。</p>
<hr>
<h2>四、实际 MCU 项目怎么选？</h2>
<p>例如 ESP32：</p>
<h3>存配置：</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>wifi账号</span></span>
<span class="line"><span>设备参数</span></span>
<span class="line"><span>校准值</span></span>
<span class="line"><span></span></span>
<span class="line"><span>↓</span></span>
<span class="line"><span></span></span>
<span class="line"><span>FlashDB KVDB</span></span></code></pre><hr>
<h3>存升级文件：</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>web页面</span></span>
<span class="line"><span>图片</span></span>
<span class="line"><span>模型文件</span></span>
<span class="line"><span></span></span>
<span class="line"><span>↓</span></span>
<span class="line"><span></span></span>
<span class="line"><span>LittleFS</span></span></code></pre><hr>
<h3>存运行日志：</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>温度历史</span></span>
<span class="line"><span>错误记录</span></span>
<span class="line"><span></span></span>
<span class="line"><span>↓</span></span>
<span class="line"><span></span></span>
<span class="line"><span>FlashDB TSDB</span></span></code></pre><hr>
<p>实际工程常见组合：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>             APP</span></span>
<span class="line"><span></span></span>
<span class="line"><span>              |</span></span>
<span class="line"><span>      +-------+-------+</span></span>
<span class="line"><span>      |               |</span></span>
<span class="line"><span>   FlashDB         LittleFS</span></span>
<span class="line"><span>      |               |</span></span>
<span class="line"><span>  参数/日志        文件资源</span></span>
<span class="line"><span></span></span>
<span class="line"><span>              |</span></span>
<span class="line"><span>          SPI NOR Flash</span></span></code></pre><hr>
<h2>五、源码阅读入口</h2>
<ol>
<li><strong>LittleFS源码里的 block allocator + metadata pair</strong></li>
<li><strong>FlashDB源码里的 sector manager + KV cache + GC</strong></li>
</ol>
<p>这两组模块分别覆盖空间分配、元数据提交、记录检索和垃圾回收，是理解两套存储机制的主要入口。</p>
<p>本文核对的源码基线：</p>
<ul>
<li><a href="https://github.com/armink/FlashDB/commit/8236571f6e29273a16bba62061bf0405e4186878">FlashDB commit 8236571</a></li>
<li><a href="https://github.com/littlefs-project/littlefs/commit/6cb4e86540eca0d9ba62500a298385c9d863c8be">LittleFS commit 6cb4e86</a></li>
</ul>
<hr>
<h2>六、FlashDB 和 LittleFS 如何处理数据错误</h2>
<p>掉电中断和数据存放多年后发生位翻转，是两类不同的错误。前者发生在写入过程中，后者可能发生在一条记录或一个文件早已写完之后。</p>
<table>
<thead>
<tr>
<th>存储方式</th>
<th>写入中途断电</th>
<th>写完后发生位翻转</th>
</tr>
</thead>
<tbody><tr>
<td>FlashDB KVDB</td>
<td>根据记录状态和 CRC32 排除未写完的 KV，并在启动时恢复未完成的更新或垃圾回收</td>
<td>CRC32 覆盖 key、value 和长度信息，读取时通常能发现内容变化</td>
</tr>
<tr>
<td>FlashDB TSDB</td>
<td>根据记录状态区分准备写入和已经写完的记录</td>
<td>用户数据默认没有 CRC，需要在每条 payload 中自行保存 CRC</td>
</tr>
<tr>
<td>LittleFS</td>
<td>metadata pair 保留旧提交，并用 commit CRC 判断新提交是否完整</td>
<td>能检查元数据提交；普通文件内容默认没有逐块 CRC，内容位翻转可能无法发现</td>
</tr>
</tbody></table>
<p>这些机制主要负责发现错误并避开不完整的数据，无法凭一份损坏的数据还原原值。需要长期冷存储时，可以在 TSDB payload 或 LittleFS 文件格式中加入 CRC；重要数据还应保存副本，检测到一份损坏后才能用另一份修复。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="Flash存储"/>
    <category term="FlashDB"/>
    <category term="LittleFS"/>
    <category term="文件系统"/>
  </entry>
  <entry>
    <title>CH585 上强行同时跑 RT-Thread 和 BLE：能跑，但官方不推荐</title>
    <link href="https://createskyblue.github.io/posts/rtthread-ble-ch585/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/rtthread-ble-ch585/</id>
    <published>2026-07-11T00:00:00.000Z</published>
    <updated>2026-07-11T00:00:00.000Z</updated>
    <summary>CH585 上强行同时跑 RT-Thread 和 BLE：能跑，但官方不推荐</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>CH585 上强行同时跑 RT-Thread 和 BLE：能跑，但官方不推荐</h1>
<blockquote>
<p><strong>先说结论：沁恒官方不推荐在 CH58x 系列上同时使用 RTOS 和 BLE。</strong> BLE 协议栈是闭源库，深度绑定 TMOS 调度系统，无法剥离。RT-Thread 移植 readme 第 8 条明确写了「不建议使用蓝牙」，EVT 包里也找不到任何一个 RTOS + BLE 的例程。低功耗模式更是彻底不兼容（官方论坛明确回应「FreeRTOS + BLE low power consumption cannot coexist」）。</p>
<p>本文记录的是社区摸索出来的 workaround——在 RT-Thread 里创建一个低优先级任务循环调用 <code>TMOS_SystemProcess()</code>，配合一系列补丁让两个调度器勉强共存。技术上社区有成功的移植案例（如 MX-WCH-RTOS-SDK），但这不是官方支持的使用方式，生产环境需自行评估风险。</p>
</blockquote>
<hr>
<h2>坑一：SysTick 被 BLE 初始化覆盖</h2>
<p><strong>现象</strong>：BLE 初始化完成后，RT-Thread 的 <code>rt_tick</code> 不再增长，系统调度停止；或者反过来，BLE 连接立即断开。</p>
<p><strong>原因</strong>：RT-Thread 启动时会配置 SysTick 作为系统心跳，通常 1ms 一次中断。<code>CH58X_BLEInit()</code> 内部同样会调用 <code>__SysTick_Config(SysTick_LOAD_RELOAD_Msk)</code> 配置 SysTick，虽然紧接着就关了 SysTick 中断（<code>PFIC_DisableIRQ(SysTick_IRQn)</code>），但 SysTick 的配置寄存器已经被覆盖。TMOS 的时间基准来自 RTC（<code>SYSTEM_TIME_MICROSEN 625us</code>），不依赖 SysTick——但 BLE 初始化覆盖 SysTick 配置这个副作用仍然会让 RT-Thread 的系统心跳失效。</p>
<p><strong>解决</strong>：在 <code>CH58X_BLEInit()</code> 内部找到 <code>__SysTick_Config()</code> 调用并注释掉，只保留 RT-Thread 的 SysTick 配置。BLE 初始化完成后，RT-Thread 需要重新配置一次 SysTick 以确保心跳参数正确。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// 在 CH58X_BLEInit() 内部：</span></span>
<span class="line"><span style="color:#6A9955">// SysTick_Config(...);   // ← 注释掉，交给 RT-Thread 管理</span></span></code></pre><hr>
<h2>坑二：LLE 中断内部做环境切换，RT-Thread 任务调度破坏上下文</h2>
<p><strong>现象</strong>：蓝牙广播和连接都正常，运行几秒到几分钟后系统死机或数据错乱，无固定规律。</p>
<p><strong>原因</strong>：CH585 的蓝牙链路层中断 <code>LLE_IRQHandler</code> 在中断处理过程中，协议栈会在中断内部保存当前上下文，切换到另一个运行环境等待 BLE 硬件事件完成。如果这个过程中 SysTick 中断恰好触发了一次 RT-Thread 任务调度，调度器会尝试保存&quot;当前任务&quot;的上下文，但此时 CPU 正处于 BLE 协议栈的另一个运行环境中，上下文被错误保存，导致数据损坏甚至死机。</p>
<p><strong>解决</strong>：在 <code>ble_task_scheduler.S</code> 的 <code>LLE_IRQHandler</code> 入口加临界区保护，退出时恢复。</p>
<pre><code>/* ↓ 示意代码，基于 EVT ble_task_scheduler.S 原始代码修改 ↓ */
LLE_IRQHandler:
    addi    sp, sp, -32*4            /* 分配 32 个寄存器保存空间 */

    sw      x1,  1 * 4(sp)           /* 保存 ra  */
    sw      x4,  2 * 4(sp)           /* 保存 tp  */
    sw      x5,  3 * 4(sp)
    /* ... x6~x30 按偏移依次保存 ... */
    sw      x31, 29 * 4(sp)

    /* ★ 关键：停掉 RT-Thread 调度，防止在 BLE 内部环境切换时切入 */
    call    rt_hw_interrupt_disable

    la      a1, g_LLE_IRQLibHandlerLocation
    lw      a0, 0(a1)
    jalr    x1, 0(a0)                /* 通过函数指针间接调用 BLE 库处理函数 */

    /* ★ 关键：恢复 RT-Thread 调度 */
    call    rt_hw_interrupt_enable

    lw      x1,  1 * 4(sp)
    lw      x4,  2 * 4(sp)
    /* ... x6~x30 按偏移依次恢复 ... */
    lw      x31, 29 * 4(sp)

    addi    sp, sp, 32*4
    mret</code></pre><p>以上为示意代码，基于 EVT <code>BLE/LIB/ble_task_scheduler.S</code> 的原始代码修改，插入的 <code>rt_hw_interrupt_disable</code> / <code>rt_hw_interrupt_enable</code> 来自 RT-Thread 移植 readme 第 12 条的明确要求。实际修改时务必以你的 EVT 版本中的原始汇编为准。</p>
<hr>
<h2>坑三：TMOS 事件执行太久，BLE 连接超时</h2>
<p><strong>现象</strong>：在 GATT 写回调或 TMOS 定时事件中加了 Flash 写入、外设等待等操作后，BLE 连接不规则断开。</p>
<p><strong>原因</strong>：TMOS 是单线程事件循环，所有 BLE 事件都在同一个循环中依次处理。BLE 对时序极其敏感，每个连接间隔内主从设备必须按约定时间窗口交换数据。如果某个 TMOS 事件回调执行了 20ms 的 Flash 擦写，连接间隔只有 30ms，对端设备连续几次收不到响应就会判定超时断开。</p>
<p><strong>解决</strong>：单次 TMOS 事件执行时间控制在连接间隔的一半以内。耗时操作拆分成多个小事件，通过事件链逐步执行。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// ❌ 错误：在 GATT 回调中直接做耗时操作</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> gatt_write_callback</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint16_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    flash_erase_page</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0x1000</span><span style="color:#D4D4D4">);</span><span style="color:#6A9955">        // 耗时 20ms+</span></span>
<span class="line"><span style="color:#DCDCAA">    flash_write</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0x1000</span><span style="color:#D4D4D4">, data, len);</span><span style="color:#6A9955">  // 直接阻塞 TMOS 循环</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// ✅ 正确：只做数据缓存，通过事件链异步处理</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> gatt_write_callback</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint16_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    memcpy</span><span style="color:#D4D4D4">(g_rx_buffer, data, len);</span></span>
<span class="line"><span style="color:#DCDCAA">    tmos_set_event</span><span style="color:#D4D4D4">(task_id, EVENT_FLASH_WRITE);</span><span style="color:#6A9955">  // 延迟到下一个 TMOS 周期</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> flash_write_event_handler</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    flash_write_page</span><span style="color:#D4D4D4">(g_current_page++, g_rx_buffer + offset, </span><span style="color:#B5CEA8">256</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (!finished) {</span></span>
<span class="line"><span style="color:#DCDCAA">        tmos_set_event</span><span style="color:#D4D4D4">(task_id, EVENT_FLASH_WRITE);</span><span style="color:#6A9955">  // 继续下一批</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><hr>
<h2>坑四：在中断里直接调用 BLE 函数</h2>
<p><strong>现象</strong>：在 GPIO 中断或定时器中断中调用 <code>GATT_Notification()</code> 等 BLE 发送函数，系统死机或数据发不出去。</p>
<p><strong>原因</strong>：TMOS 不可重入，BLE 函数内部依赖 TMOS 事件机制，必须运行在任务上下文中。</p>
<p><strong>解决</strong>：中断只置标志，在 RT-Thread 任务或 TMOS 事件回调中执行 BLE 操作。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// GPIO 中断</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> GPIO_IRQHandler</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">GPIO_ReadITFlagPort</span><span style="color:#D4D4D4">() &#x26; GPIO_Pin_5) {</span></span>
<span class="line"><span style="color:#D4D4D4">        g_send_flag = </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">                           // 只置标志</span></span>
<span class="line"><span style="color:#DCDCAA">        rt_sem_release_from_isr</span><span style="color:#D4D4D4">(g_ble_sem);</span><span style="color:#6A9955">         // 通知任务</span></span>
<span class="line"><span style="color:#DCDCAA">        GPIO_ClearITFlagBit</span><span style="color:#D4D4D4">(GPIO_Pin_5);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// RT-Thread 任务</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> ble_send_task</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">param</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        rt_sem_take</span><span style="color:#D4D4D4">(g_ble_sem, RT_WAITING_FOREVER);</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (g_send_flag) {</span></span>
<span class="line"><span style="color:#D4D4D4">            g_send_flag = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">            attHandleValueNoti_t</span><span style="color:#D4D4D4"> noti;</span></span>
<span class="line"><span style="color:#9CDCFE">            noti</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">handle</span><span style="color:#D4D4D4"> = g_char_handle;</span></span>
<span class="line"><span style="color:#9CDCFE">            noti</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">len</span><span style="color:#D4D4D4"> = len;</span></span>
<span class="line"><span style="color:#DCDCAA">            tmos_memcpy</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">noti</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">value</span><span style="color:#D4D4D4">, data, len);</span></span>
<span class="line"><span style="color:#DCDCAA">            GATT_Notification</span><span style="color:#D4D4D4">(conn_handle, &#x26;noti, </span><span style="color:#569CD6">FALSE</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><hr>
<h2>坑五：任务优先级排错</h2>
<p><strong>现象</strong>：BLE 数据卡顿，或 TMOS 任务长期得不到执行。</p>
<p><strong>推荐配置</strong>：</p>
<table>
<thead>
<tr>
<th>任务</th>
<th>优先级</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>TMOS 循环任务</td>
<td>仅比 IDLE 高一级</td>
<td>由高优先级任务抢占，空闲时立即处理 BLE</td>
</tr>
<tr>
<td>业务逻辑任务</td>
<td>中（高于 TMOS）</td>
<td>传感器采集、数据处理等</td>
</tr>
<tr>
<td>IDLE</td>
<td>最低</td>
<td>RT-Thread 空闲任务</td>
</tr>
</tbody></table>
<p>RT-Thread Nano 中 IDLE 任务优先级通常为 31（最低）。TMOS 任务设置为优先级 2<del>3，业务任务设置为 4</del>6——即 TMOS 低于业务、远高于 IDLE。&quot;仅比 IDLE 高一级&quot;这个说法容易让人误解为设置为 30，实际上 readme 的意思是 TMOS 任务处于低优先级区间、不抢占业务即可。</p>
<p>这里容易有一个误解：TMOS 本身会主动让出 CPU（<code>TMOS_SystemProcess()</code> 处理完事件队列就返回，没有事件时几乎立即返回），并不会永久占据 CPU。优先级不建议过高的真正原因是 BLE 的硬实时保障在 LLE 中断层——连接间隔的严格时序由硬件中断保证，不依赖 TMOS 任务的优先级。TMOS 任务只负责消化中断产生的事件（GATT 回调、连接参数更新、广播刷新等），这些事件只要在下一个连接间隔到来之前处理完即可。因此 TMOS 任务可以设为低优先级，让传感器采集、电机控制等对延迟敏感的业务任务优先拿到 CPU，业务跑完后再由 TMOS 在空闲时间里收尾 BLE 事件。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#4EC9B0">rt_thread_t</span><span style="color:#D4D4D4"> tmos_thread = </span><span style="color:#DCDCAA">rt_thread_create</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#CE9178">    "tmos"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">    tmos_task_entry,</span></span>
<span class="line"><span style="color:#D4D4D4">    RT_NULL,</span></span>
<span class="line"><span style="color:#B5CEA8">    1024</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955">    // 栈大小，建议 ≥ 1024 字节</span></span>
<span class="line"><span style="color:#B5CEA8">    3</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955">       // 优先级，低于用户业务任务</span></span>
<span class="line"><span style="color:#B5CEA8">    5</span><span style="color:#6A9955">        // 时间片</span></span>
<span class="line"><span style="color:#D4D4D4">);</span></span></code></pre><hr>
<h2>坑六：自定义连接参数太紧凑</h2>
<p><strong>现象</strong>：RT-Thread 任务频繁被 BLE 中断打断，业务逻辑执行效率低。</p>
<p>EVT 官方 Peripheral 例程的默认连接参数本身已经比较宽松（MAX 间隔 125ms）。但很多开发者会为了低延迟把连接间隔改小，在 RTOS 场景下这会进一步挤压业务任务的 CPU 时间。</p>
<p><strong>解决</strong>：如果需要自定义连接参数，保持宽松的配置。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// EVT Peripheral 默认值（参考 peripheral.c）</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> DEFAULT_DESIRED_MIN_CONN_INTERVAL    </span><span style="color:#B5CEA8">6</span><span style="color:#6A9955">     // 7.5ms</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> DEFAULT_DESIRED_MAX_CONN_INTERVAL    </span><span style="color:#B5CEA8">100</span><span style="color:#6A9955">   // 125ms（本身已较宽松）</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> DEFAULT_DESIRED_SLAVE_LATENCY        </span><span style="color:#B5CEA8">0</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> DEFAULT_DESIRED_CONN_TIMEOUT         </span><span style="color:#B5CEA8">100</span><span style="color:#6A9955">   // 1s</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// RTOS 场景建议值（如果默认值仍不够宽松）</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> DEFAULT_DESIRED_MIN_CONN_INTERVAL    </span><span style="color:#B5CEA8">12</span><span style="color:#6A9955">    // 15ms</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> DEFAULT_DESIRED_MAX_CONN_INTERVAL    </span><span style="color:#B5CEA8">100</span><span style="color:#6A9955">   // 125ms</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> DEFAULT_DESIRED_SLAVE_LATENCY        </span><span style="color:#B5CEA8">4</span><span style="color:#6A9955">     // 可跳过 4 个间隔</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> DEFAULT_DESIRED_CONN_TIMEOUT         </span><span style="color:#B5CEA8">500</span><span style="color:#6A9955">   // 5s 超时</span></span></code></pre><p>关键调整是增大 <code>SLAVE_LATENCY</code>（允许从机跳过若干连接间隔），在 MAX 间隔 125ms + 4 次延迟的最长窗口下，RT-Thread 有约 625ms 完整时间片执行业务任务。不要随意减小 <code>MAX_CONN_INTERVAL</code>——越小 BLE 中断越频繁，留给 RT-Thread 的时间越少。</p>
<hr>
<h2>整体架构</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>RT-Thread 调度器</span></span>
<span class="line"><span>│</span></span>
<span class="line"><span>├── 传感器采集任务（优先级 4）</span></span>
<span class="line"><span>├── 数据处理任务（优先级 5）</span></span>
<span class="line"><span>├── TMOS 循环任务（优先级 2，仅比 IDLE 高一级）</span></span>
<span class="line"><span>│     └── while(1) { TMOS_SystemProcess(); }</span></span>
<span class="line"><span>│           └── TMOS 内部：GATT 回调、连接管理、广播刷新</span></span>
<span class="line"><span>├── IDLE 任务</span></span>
<span class="line"><span>│</span></span>
<span class="line"><span>└── 中断层</span></span>
<span class="line"><span>      ├── LLE_IRQHandler（+ rt_hw_interrupt_disable/enable）</span></span>
<span class="line"><span>      └── SysTick_IRQ（RT-Thread 心跳）</span></span></code></pre><hr>
<h2>调试顺序</h2>
<ol>
<li><strong>裸机验证</strong>：关掉 RT-Thread，确认裸机 BLE 例程正常工作</li>
<li><strong>单独验证</strong>：关掉 BLE，确认 RT-Thread 多任务调度正常</li>
<li><strong>合入检查</strong>：在 SysTick 中断和 TMOS 处理中各加一个 GPIO 翻转，用逻辑分析仪观察时序</li>
<li><strong>栈检查</strong>：用 <code>list_thread</code> 检查 TMOS 任务栈使用量，确保没有溢出</li>
<li><strong>临界区验证</strong>：在 <code>LLE_IRQHandler</code> 的 <code>rt_hw_interrupt_disable/enable</code> 前后加 GPIO 翻转，确认保护区间覆盖了 BLE 库函数调用</li>
</ol>
<hr>
<h2>其他 CH585 特有约束</h2>
<p>以下来自 RT-Thread 移植 readme，除第 8、11、12 条外，也是在使用 BLE 时必须注意的：</p>
<ul>
<li><strong>关闭全局中断再初始化 BLE</strong>（readme 第 8 条）：初始化蓝牙时需要先关闭全局中断，等初始化完成后再开启。蓝牙使用免表中断，<code>StartUP.S</code> 已将 <code>BB_IRQLibHandler</code> 和 <code>LLE_IRQLibHandler</code> 放入中断向量表</li>
<li><strong>禁止裸开关中断</strong>（readme 第 12 条）：不可使用 <code>__enable_irq()</code> / <code>__disable_irq()</code>，必须使用 RT-Thread 的 <code>rt_hw_interrupt_disable()</code> / <code>rt_hw_interrupt_enable()</code></li>
<li><strong>mscratch 寄存器</strong>（readme 第 7 条）：已被中断系统占用，用户代码不可使用</li>
<li><strong>中断函数修饰</strong>（readme 第 4 条）：统一入口中断（<code>unified_interrupt_entry</code>）不需要 <code>__attribute__((interrupt(...)))</code> 修饰；非统一入口中断需要</li>
<li><strong>统一入口中断无需手动调 enter/leave</strong>（readme 第 10 条）：<code>unified_interrupt_entry</code> 中已调用 <code>rt_interrupt_enter</code> 和 <code>rt_interrupt_leave</code>，用户中断函数内不要重复调用</li>
<li><strong>中断栈</strong>（readme 第 5 条）：所有统一入口中断会切换到独立中断栈（<code>_eusrstack</code>），务必在 LD 脚本中预留足够 RAM，否则中断中栈溢出直接 HardFault</li>
<li><strong>中断函数放高速 RAM</strong>（readme 第 2 条）：外部中断函数建议用 <code>__attribute__((section(&quot;.highcode&quot;)))</code> 修饰</li>
<li><strong>BLE_LibInit 调用顺序</strong>：<code>TMOS_SystemProcess()</code> 必须在 <code>CH58x_BLEInit()</code>（内部调用 <code>BLE_LibInit()</code>）成功之后才能调用，否则会访问未初始化的库内部数据结构</li>
</ul>
<hr>
<h2>参考资料</h2>
<ul>
<li><a href="https://github.com/smartmx/MX-WCH-RTOS-SDK">MX-WCH-RTOS-SDK</a> — 社区维护的 CH585/CH582 RT-Thread Nano + BLE 移植参考</li>
<li><a href="https://www.wch.cn/bbs/thread-155362-1.html">WCH 论坛：RT-Thread 上使用 BLE 的一些疑惑</a></li>
<li><a href="https://wch.cn/bbs/thread-92075-1.html">WCH 论坛：rtt 工程加了蓝牙后，断连</a></li>
<li><a href="https://wch.cn/bbs/thread-157867-1.html">WCH 论坛：CH585M FreeRTOS tickless BLE</a></li>
<li>CH585 EVT <code>EXAM/FreeRTOS/readme.txt</code> — 官方 FreeRTOS 移植说明（RT-Thread 同理可参考）</li>
</ul>
<hr>
<p>CH585 上 RT-Thread + BLE 的核心做法：TMOS 作为 BLE 的微调度器跑在 RT-Thread 的低优先级任务里，LLE 中断中以临界区保护阻止 RT-Thread 在 BLE 内部环境切换时切入，TMOS 事件执行时间控制在连接间隔的一半以内。</p>
<p>再次强调：<strong>这是社区摸索出来的 workaround，不是官方推荐方案。</strong> 低功耗模式完全不可用——BLE 需要 TMOS 运行，RTOS 的 tickless 低功耗与 TMOS 的 RTC 时间基准直接冲突，官方已明确回应无法共存。如果你的产品需要 BLE + 低功耗，纯 TMOS 是唯一选择。只有在确实需要 RTOS 的多任务能力、可以放弃低功耗、并且愿意承担踩坑成本时，才考虑本文的方案。</p>
<blockquote>
<p><strong>关于代码示例</strong>：文中的汇编和 C 代码为示意代码，基于 EVT 原始文件修改，未经过编译验证。实际移植时务必以你的 SDK 版本中的实际代码为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="BLE"/>
    <category term="CH58x"/>
    <category term="RT-Thread"/>
    <category term="RTOS"/>
    <category term="中断"/>
  </entry>
  <entry>
    <title>ESP32-S3 通用模板：WiFi 配网、OTA、文件管理开箱即用</title>
    <link href="https://createskyblue.github.io/posts/esp32s3-idf-template/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp32s3-idf-template/</id>
    <published>2026-07-08T00:00:00.000Z</published>
    <updated>2026-07-08T00:00:00.000Z</updated>
    <summary>ESP32-S3 通用模板：WiFi 配网、OTA 升级、SD 卡日志、文件管理一站配齐</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>ESP32-S3 通用模板：WiFi 配网、OTA、文件管理开箱即用</h1>
<p>基于 ESP-IDF v6.0.1 的 ESP32-S3 基础设施模板，提供 WiFi 配网、LittleFS、SD 卡、OTA 升级等开箱即用的功能，适合作为新项目的起点。</p>
<p><a href="https://github.com/createskyblue/esp32s3_idf_template" target="_blank" rel="noopener noreferrer">GitHub 仓库</a></p>
<h2>功能一览</h2>
<table>
<thead>
<tr>
<th>功能</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td><strong>WiFi AP+STA</strong></td>
<td>同时运行 AP 热点（<code>ESP32S3-Template</code>）和 STA 客户端</td>
</tr>
<tr>
<td><strong>Web 配网</strong></td>
<td>网页端输入 SSID/密码，配置持久化到 LittleFS，自动重连</td>
</tr>
<tr>
<td><strong>Captive Portal</strong></td>
<td>DNS 劫持，手机连上 AP 后自动弹出配网页面</td>
</tr>
<tr>
<td><strong>LittleFS</strong></td>
<td>1 MB 内部闪存文件系统，存放网页和配置文件</td>
</tr>
<tr>
<td><strong>SD 卡</strong></td>
<td>SPI 模式，FAT 文件系统，支持热插拔（可选，失败不阻塞启动）</td>
</tr>
<tr>
<td><strong>文件管理器</strong></td>
<td>Web 界面浏览/上传/下载/删除/新建文件夹，支持内部 Flash 和 SD 卡双存储</td>
</tr>
<tr>
<td><strong>OTA 升级</strong></td>
<td>支持固件 + 文件系统远程升级，也支持网页直接上传刷写</td>
</tr>
<tr>
<td><strong>SD 日志</strong></td>
<td>所有 ESP_LOG 输出自动双写（串口 + SD 卡），定时滚动</td>
</tr>
<tr>
<td><strong>SNTP 授时</strong></td>
<td>STA 连接成功后自动同步北京时间</td>
</tr>
<tr>
<td><strong>LED 指示</strong></td>
<td>绿灯心跳（500ms 翻转），蓝灯 WiFi/数据活动指示</td>
</tr>
<tr>
<td><strong>调试接口</strong></td>
<td><code>/debug.json</code> 查看堆内存、PSRAM、任务列表、运行时间</td>
</tr>
</tbody></table>
<h2>界面预览</h2>
<p><img src="/posts/esp32s3-idf-template/img/%E9%A6%96%E9%A1%B5.webp" alt="首页仪表盘"></p>
<p><img src="/posts/esp32s3-idf-template/img/%E6%96%87%E4%BB%B6%E7%AE%A1%E7%90%86%E5%99%A8.webp" alt="文件管理器"></p>
<h2>架构</h2>
<p>项目采用 <strong>平台 + 业务</strong> 分层架构：</p>
<ul>
<li><code>main/web_platform.c</code> — 基础设施胶水层，负责 WiFi / OTA / 配网 / 仪表盘编排</li>
<li><code>main/hello_web.c</code> — 业务端点模板，从这里开始写自定义 HTTP handler</li>
<li><code>components/</code> — 可复用组件（<code>wifi_manager</code>、<code>ota_manager</code>、<code>file_manager</code>、<code>led_task</code>、<code>sd_card</code>、<code>sd_logger</code>）</li>
</ul>
<p>新项目只需在 <code>hello_web.c</code> 中仿照模板添加 handler，基础设施已全部就绪。</p>
<h2>快速开始</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955"># 克隆模板</span></span>
<span class="line"><span style="color:#DCDCAA">git</span><span style="color:#CE9178"> clone</span><span style="color:#CE9178"> https://github.com/createskyblue/esp32s3_idf_template.git</span><span style="color:#CE9178"> my_project</span></span>
<span class="line"><span style="color:#DCDCAA">cd</span><span style="color:#CE9178"> my_project</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 编译烧录</span></span>
<span class="line"><span style="color:#DCDCAA">idf.py</span><span style="color:#CE9178"> set-target</span><span style="color:#CE9178"> esp32s3</span></span>
<span class="line"><span style="color:#DCDCAA">idf.py</span><span style="color:#CE9178"> build</span></span>
<span class="line"><span style="color:#DCDCAA">idf.py</span><span style="color:#569CD6"> -p</span><span style="color:#CE9178"> COMx</span><span style="color:#CE9178"> flash</span><span style="color:#CE9178"> monitor</span></span></code></pre><p>首次上电：手机搜到热点 <code>ESP32S3-Template</code>（密码 <code>template1234</code>），连接后浏览器打开任意网址自动弹出配网页面。</p>]]></content>
    <category term="嵌入式"/>
    <category term="ESP32-S3"/>
    <category term="ESP-IDF"/>
    <category term="物联网"/>
    <category term="OTA"/>
  </entry>
  <entry>
    <title>PY32F002A SOP8 封装下 SWD 引脚复用的防砖指南</title>
    <link href="https://createskyblue.github.io/posts/py32-sop8-swd-remap/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/py32-sop8-swd-remap/</id>
    <published>2026-07-08T00:00:00.000Z</published>
    <updated>2026-07-08T00:00:00.000Z</updated>
    <summary>SOP8 只有 8 个引脚，SWD、NRST、GPIO 抢着用。怎么开发时不砖、量产时省引脚</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>PY32F002A SOP8 封装下 SWD 引脚复用的防砖指南</h1>
<p>SOP8 只有 8 个引脚。VCC 和 VSS 吃掉两个，剩下 6 个。如果你需要两路通信接口、两路传感器输入、一路控制输出，6 个引脚刚好用完。然后你发现 PA13（SWDIO）、PA14（SWCLK）和 PF2（NRST）全都被迫让位给了业务功能。接着你烧不进去程序了。</p>
<p>这篇文章梳理 SOP8 封装的 SWD 引脚复用策略、恢复机制和防砖实践，不涉及具体产品设计，只讲通用方法。</p>
<h2>SOP8 的引脚布局</h2>
<p>PY32F002AL15S（SOP8）的引脚分配：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>     VCC  1    8  VSS</span></span>
<span class="line"><span>PA4/PA10  2    7  PA1</span></span>
<span class="line"><span>     PA3  3    6  PA2/PF2-NRST</span></span>
<span class="line"><span>PA14-SWC  4    5  PA13-SWD</span></span>
<span class="line"><span>    /PB3</span></span></code></pre><p>注意三个共享焊盘：</p>
<ul>
<li>Pin 2：PA4 和 PA10 二选一（通过 SYSCFG 寄存器选择）</li>
<li>Pin 4：PA14（SWCLK）和 PB3 二选一</li>
<li>Pin 6：PA2 和 PF2-NRST 二选一（PF2/NRST 通过 Option Bytes 切换）</li>
</ul>
<p>每个被占用的调试引脚意味着什么：</p>
<table>
<thead>
<tr>
<th>引脚</th>
<th>被占用的后果</th>
</tr>
</thead>
<tbody><tr>
<td>PA13 (SWDIO)</td>
<td>调试器无法与芯片通信</td>
</tr>
<tr>
<td>PA14 (SWCLK)</td>
<td>调试器没有时钟</td>
</tr>
<tr>
<td>PF2 (NRST)</td>
<td>Connect Under Reset 不可用</td>
</tr>
</tbody></table>
<p>三者全占 = 常规 SWD 调试通路全部切断。</p>
<h2>SWD 引脚的生存窗口</h2>
<p>ARM Cortex-M0+ 上电后，PA13 和 PA14 的默认状态是 AF0（SWDIO + SWCLK）。这个状态一直保持到用户固件执行 GPIO 初始化把它们切走。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>上电 -> SystemInit -> main -> GPIO_Init(PA13, ...) -> SWD 消失</span></span>
<span class="line"><span>                              ^</span></span>
<span class="line"><span>                     窗口在这里关闭，微秒级</span></span></code></pre><p>在 24MHz 的 PY32F002A 上，从复位释放到 GPIO 初始化执行完，通常不到 1 毫秒。除非你主动把窗口拉大，否则调试器不够时间连上来。</p>
<h2>三层防线</h2>
<h3>第一层：启动延迟</h3>
<p>在 <code>main()</code> 开头加一段 delay，再切 SWD 引脚。Milton 在<a href="https://www.cnblogs.com/milton/p/17277985.html">他的 PY32 博文</a>里推荐 2 秒：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    HAL_Init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">    SystemClock_Config</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#ifdef</span><span style="color:#569CD6"> DEBUG_BUILD</span></span>
<span class="line"><span style="color:#DCDCAA">    HAL_Delay</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">2000</span><span style="color:#D4D4D4">);</span><span style="color:#6A9955">  /* 调试版本给调试器 2 秒连接窗口 */</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 下面的 GPIO 初始化会重映射 PA13/PA14 */</span></span>
<span class="line"><span style="color:#DCDCAA">    Remap_SWD_Pins_To_GPIO</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* ... 业务逻辑 ... */</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>用 <code>#ifdef DEBUG_BUILD</code> 包住，量产时一关宏就去掉了。</p>
<p><strong>这层防线的漏洞：</strong> 如果固件在 delay 之前就 crash 了（时钟配置写错、HardFault），窗口直接消失。这只是买时间，不是真正的恢复机制。</p>
<h3>第二层：Connect Under Reset</h3>
<p>这是真正的防线。原理如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>NRST = LOW  -> Core 处于复位状态 -> SWD 引脚 = AF0（默认）</span></span>
<span class="line"><span>            -> 调试器通过 SWD 连接成功</span></span>
<span class="line"><span>            -> 调试器在 Reset Handler 入口设硬件断点</span></span>
<span class="line"><span>NRST = HIGH -> Core 释放复位 -> 立即停在断点 -> 用户代码还没跑</span></span></code></pre><p>调试器在芯片还处于复位状态时就接管了 SWD 总线，然后在第一条用户指令之前设下断点。<strong>用户代码连 <code>main</code> 都没进去。</strong></p>
<p>前提是 <strong>NRST 引脚必须可用</strong>。几种调试器的操作方式：</p>
<table>
<thead>
<tr>
<th>调试器</th>
<th>操作</th>
</tr>
</thead>
<tbody><tr>
<td>J-Link</td>
<td><code>connect</code> -&gt; 选器件 -&gt; 提示 &quot;Connect under reset?&quot; -&gt; Yes</td>
</tr>
<tr>
<td>DAP-Link / PY32-Link</td>
<td>接线时把 NRST 接上，工具自动使用</td>
</tr>
<tr>
<td>OpenOCD</td>
<td><code>reset_config srst_only srst_nogate</code></td>
</tr>
<tr>
<td>pyocd</td>
<td><code>--connect=under-reset</code></td>
</tr>
</tbody></table>
<p>如果 J-Link 连不上，先检查 NRST 线接了没有。这是最常见的漏接。</p>
<h3>第三层：Option Bytes 与 SOP8 的限制</h3>
<p>PY32F002A 内建了一个系统内存 bootloader，通过 USART1 烧录。拉高 BOOT0（PF4）上电就能进入。</p>
<p><strong>但 SOP8 封装没有引出 PF4/BOOT0 引脚。</strong> 这条路在 SOP8 上不存在。这也是 SOP8 的终极风险：如果 SWD 死了、NRST 也没了、BOOT0 又不存在，芯片就失去了所有外部恢复手段。</p>
<p>20 引脚及以上的封装（TSSOP20、QFN20）有 BOOT0，可以通过 USART ISP 救砖。选 SOP8 就意味着你必须靠前两层防线保护自己。</p>
<h2>开发用大封装，量产切 SOP8</h2>
<p>PY32F002A、PY32F003、PY32F030 共用同一套 HAL 驱动和同一个硅片。TSSOP20 封装引出了全部 18 个 IO，SWD、NRST、BOOT0 一个不缺。开发阶段用 TSSOP20，量产阶段切 SOP8，是 PY32 系列的标准玩法。</p>
<p>封装的切换只是表象，真正的约束是<strong>外设子集要对齐</strong>。你在 TSSOP20 上开发时，不能用 F003 多出来的 DMA、TIM3、USART2。这些在 F002A 上根本没有。只要你只使用 F002A 也有的外设子集，从 TSSOP20 切到 SOP8 就只是改 Makefile 里 <code>MCU_TYPE</code> 和 linker script 的事。</p>
<p>引脚分配上的过渡策略：</p>
<ol>
<li><strong>TSSOP20 开发阶段：</strong> 把将来要挪到 PA13/PA14/PF2 的功能，先挂在 TSSOP20 多出来的空闲引脚上。比如最终要占用 PA13 做 GPIO 输出，开发阶段先用 PA8 代替，等切 SOP8 时再把代码里的 <code>PA8</code> 改成 <code>PA13</code>。</li>
<li><strong>SOP8 移植阶段：</strong> 硬件换成 SOP8，功能引脚挪到目标位置。此时固件保留启动 delay 和安全模式，NRST 保持为复位引脚。验证所有外设功能正常。</li>
<li><strong>量产锁死：</strong> 最后一步才烧录关闭 NRST 的 Option Bytes。这一步不可逆（SOP8 没有 BOOT0 救不了），所以排在所有验证通过之后。</li>
</ol>
<p>整个过程里，SWD 调试能力只在最后一步之前始终可用。开发调试用 TSSOP20 全功能 SWD，移植验证用安全模式保底，量产烧录一次性关闭 NRST。</p>
<p>启动延迟不能带到量产固件里，但可以在切 SWD 之前加一个条件判断：某个 GPIO 被外部拉低时，跳过 SWD 重映射，保持调试通道畅通。</p>
<p>选择检测引脚的原则：</p>
<ul>
<li>正常运行时空闲为高电平（或被外部上拉）</li>
<li>外部拉低不会触发总线协议误判</li>
<li>最好是已有引脚，不额外占用</li>
</ul>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> int</span><span style="color:#DCDCAA"> should_enter_safe_mode</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#6A9955">    /*</span></span>
<span class="line"><span style="color:#6A9955">     * 选择某个正常运行时为高电平的引脚做安全模式检测。</span></span>
<span class="line"><span style="color:#6A9955">     * 例如 I2C SCL 空闲时为高，外部拉低不会触发 Start 信号</span></span>
<span class="line"><span style="color:#6A9955">     * （因为 Start 条件是 SCL 高电平期间 SDA 下降沿）。</span></span>
<span class="line"><span style="color:#6A9955">     */</span></span>
<span class="line"><span style="color:#D4D4D4">    GPIO_InitTypeDef gpio = {</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#6A9955">    /* 重要：必须先使能 GPIO 端口时钟，否则 HAL_GPIO_Init 寄存器写入可能无效 */</span></span>
<span class="line"><span style="color:#DCDCAA">    __HAL_RCC_SAFE_MODE_PORT_CLK_ENABLE</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#9CDCFE">    gpio</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">Pin</span><span style="color:#D4D4D4"> = SAFE_MODE_PIN;</span><span style="color:#6A9955">      /* 由项目配置决定 */</span></span>
<span class="line"><span style="color:#9CDCFE">    gpio</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">Mode</span><span style="color:#D4D4D4"> = GPIO_MODE_INPUT;</span></span>
<span class="line"><span style="color:#9CDCFE">    gpio</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">Pull</span><span style="color:#D4D4D4"> = GPIO_PULLUP;</span></span>
<span class="line"><span style="color:#DCDCAA">    HAL_GPIO_Init</span><span style="color:#D4D4D4">(SAFE_MODE_PORT, &#x26;gpio);</span></span>
<span class="line"><span style="color:#DCDCAA">    HAL_Delay</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">HAL_GPIO_ReadPin</span><span style="color:#D4D4D4">(SAFE_MODE_PORT, SAFE_MODE_PIN) == GPIO_PIN_RESET);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    HAL_Init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">    SystemClock_Config</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (!</span><span style="color:#DCDCAA">should_enter_safe_mode</span><span style="color:#D4D4D4">()) {</span></span>
<span class="line"><span style="color:#6A9955">        /* 正常启动：重映射 SWD 引脚到业务功能 */</span></span>
<span class="line"><span style="color:#DCDCAA">        Remap_SWD_Pins_To_GPIO</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#6A9955">    /* 安全模式下 SWD 引脚保持 AF0，调试器可正常连接 */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 恢复安全模式检测引脚的业务功能（如 I2C AF） */</span></span>
<span class="line"><span style="color:#DCDCAA">    Restore_SafeModePin_AF</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* ... 业务逻辑 ... */</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>需要更新固件时，把检测引脚临时短接到 GND 再上电就行。</p>
<p><strong>为什么选 I2C SCL 类型的引脚做检测：</strong> SCL 空闲时为高电平，外部拉低不会被从机误判，因为 I2C 的 Start 条件要求 SCL 高电平期间 SDA 出现下降沿。单拉低 SCL 不会触发任何 I2C 行为。类似的，SPI 的 SCK（CPOL=0 时空闲为低，CPOL=1 时空闲为高）也可以根据实际配置选择。</p>
<h2>NRST 什么时候可以放开</h2>
<p>PF2-NRST 通过 Option Bytes 控制。Option Bytes 的地址是 <code>0x1FFF 0E80</code>，bit 14 控制 NRST_MODE：</p>
<table>
<thead>
<tr>
<th>NRST_MODE</th>
<th>行为</th>
</tr>
</thead>
<tbody><tr>
<td>0（默认）</td>
<td>PF2 是 NRST 复位引脚</td>
</tr>
<tr>
<td>1</td>
<td>PF2 是普通 GPIO（NRST 功能关闭）</td>
</tr>
</tbody></table>
<p>把 PF2 改成 GPIO 的代码：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> APP_FlashSetOptionBytes</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#D4D4D4">    FLASH_OBProgramInitTypeDef OBInitCfg;</span></span>
<span class="line"><span style="color:#DCDCAA">    LL_FLASH_Unlock</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">    LL_FLASH_OB_Unlock</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">    OBInitCfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">OptionType</span><span style="color:#D4D4D4"> = OPTIONBYTE_USER;</span></span>
<span class="line"><span style="color:#9CDCFE">    OBInitCfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">USERType</span><span style="color:#D4D4D4"> = OB_USER_BOR_EN | OB_USER_BOR_LEV |</span></span>
<span class="line"><span style="color:#D4D4D4">                         OB_USER_IWDG_SW | OB_USER_WWDG_SW |</span></span>
<span class="line"><span style="color:#D4D4D4">                         OB_USER_NRST_MODE | OB_USER_nBOOT1;</span></span>
<span class="line"><span style="color:#9CDCFE">    OBInitCfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">USERConfig</span><span style="color:#D4D4D4"> = OB_BOR_DISABLE | OB_BOR_LEVEL_3p1_3p2 |</span></span>
<span class="line"><span style="color:#D4D4D4">                           OB_IWDG_SW | OB_WWDG_SW |</span></span>
<span class="line"><span style="color:#D4D4D4">                           OB_RESET_MODE_GPIO |</span><span style="color:#6A9955">   /* PF2 变 GPIO */</span></span>
<span class="line"><span style="color:#D4D4D4">                           OB_BOOT1_SYSTEM;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    LL_FLASH_OBProgram</span><span style="color:#D4D4D4">(&#x26;OBInitCfg);</span></span>
<span class="line"><span style="color:#DCDCAA">    LL_FLASH_Lock</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">    LL_FLASH_OB_Lock</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">    LL_FLASH_OB_Launch</span><span style="color:#D4D4D4">();</span><span style="color:#6A9955">  /* 触发软复位，OB 生效。此调用不返回 */</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><strong>关键防护：</strong> 调用前检查 OB 是否已经被改过，避免无限复位：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">READ_BIT</span><span style="color:#D4D4D4">(FLASH-></span><span style="color:#9CDCFE">OPTR</span><span style="color:#D4D4D4">, FLASH_OPTR_NRST_MODE) == OB_RESET_MODE_RESET) {</span></span>
<span class="line"><span style="color:#DCDCAA">    APP_FlashSetOptionBytes</span><span style="color:#D4D4D4">();</span><span style="color:#6A9955">  /* 只执行一次 */</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><strong>NRST 改 GPIO 的时机：</strong> 放在上一节三步流程的最后一步。SOP8 硬件全部验证通过，量产烧录时才执行。在此之前 NRST 始终保持为复位引脚。</p>
<h2>总结</h2>
<p>SOP8 封装下占用 PA13/PA14/PF2 可以做，但要有纪律：</p>
<ol>
<li><strong>开发时不动 SWD 和 NRST。</strong> 用引脚更多的封装调试，引脚管够。</li>
<li><strong>量产固件留安全模式。</strong> 一个 GPIO 拉低就让 SWD 保持打开，永远有一条路回去。</li>
<li><strong>NRST 是最后一道防线。</strong> 没了 NRST 就等于放弃了 Connect Under Reset，最可靠的 SWD 恢复手段。把它留到最后再关。</li>
<li><strong>SOP8 没有 BOOT0，ISP 救不了你。</strong> 防线全在前两层，软件上必须做足。</li>
</ol>
<blockquote>
<p>补充说明：PA10 和 PA4 在 SOP8 的 Pin 2 上是二选一关系。如果你要用硬件 I2C1，注意 PA4 不支持 I2C SDA 复用（只有 SPI1_NSS、USART1_CK），必须选 PA10（AF6 = I2C1_SDA）。类似的，Pin 4 在 PA14 和 PB3 之间二选一，如果你的外设需要独立于 SWCLK 的引脚，选 PB3（AF1 = TIM1_CH2）。选引脚前对照数据手册的复用功能映射表，避免选了不支持的组合。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="PY32F002A"/>
    <category term="SWD"/>
    <category term="SOP8"/>
    <category term="工程调试"/>
    <category term="Cortex-M0+"/>
  </entry>
  <entry>
    <title>在 PY32F002A 上实测 TinyFrame 的 Flash 和 RAM 静态消耗</title>
    <link href="https://createskyblue.github.io/posts/tinyframe-footprint-py32f002a/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/tinyframe-footprint-py32f002a/</id>
    <published>2026-07-08T00:00:00.000Z</published>
    <updated>2026-07-08T00:00:00.000Z</updated>
    <summary>TinyFrame 在 PY32F002A 上编译的 Flash 和 RAM 增量与可调开关</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>在 PY32F002A 上实测 TinyFrame 的 Flash 和 RAM 静态消耗</h1>
<p>选一个通信库之前，通常想知道它到底占多少 Flash 和 RAM。文档里写的&quot;轻量级&quot;不解决实际问题——3KB RAM 的芯片上能不能跑，CRC16 查找表有多大，缓冲区参数调到多少合适，这些需要实际编译一次才知道。</p>
<p>这次用 IOsetting/py32f0-template 模板，把 TinyFrame 加到 PY32F002A 工程里，用 ARM GCC 编译，测出静态增量。Demo 实现了三个 UART 查询-响应指令：PING、STATUS 和 ECHO。</p>
<h2>测试方法</h2>
<p>在空白模板上先编译一次作为基线，再加入 TinyFrame 编译第二次，差值就是库本身的静态开销。</p>
<p>环境：</p>
<ul>
<li>模板：<a href="https://github.com/IOsetting/py32f0-template">IOsetting/py32f0-template</a></li>
<li>芯片：PY32F002A（Cortex-M0+, 20KB Flash, 3KB RAM）</li>
<li>编译器：<code>arm-none-eabi-gcc 13.2.1</code>，<code>-Os -ffunction-sections -fdata-sections -Wl,--gc-sections</code></li>
<li>HAL 库：PY32F0xx HAL Driver（UART + GPIO + printf）</li>
<li>TinyFrame：<a href="https://github.com/MightyPork/TinyFrame">MightyPork/TinyFrame</a></li>
</ul>
<p>基线工程只做 LED 闪烁 + UART printf。加入 TinyFrame 后，Demo 在 UART 上接收 TinyFrame 帧，按 TYPE 字段分发到三个监听器并返回响应。两次编译除了 TinyFrame 相关代码外，HAL 和 BSP 部分完全一致。</p>
<h2>TinyFrame 配置</h2>
<p>这份配置是针对 3KB RAM 裁剪过的，也是本次测量的实际参数：</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>值</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td><code>TF_MAX_PAYLOAD_RX</code></td>
<td>128</td>
<td>最大接收 payload，也是最大的单块 RAM 消耗</td>
</tr>
<tr>
<td><code>TF_SENDBUF_LEN</code></td>
<td>64</td>
<td>发送缓冲区</td>
</tr>
<tr>
<td><code>TF_MAX_TYPE_LST</code></td>
<td>5</td>
<td>Type 监听器槽位数</td>
</tr>
<tr>
<td><code>TF_MAX_ID_LST</code></td>
<td>3</td>
<td>ID 监听器槽位数</td>
</tr>
<tr>
<td><code>TF_MAX_GEN_LST</code></td>
<td>1</td>
<td>通用监听器槽位数</td>
</tr>
<tr>
<td><code>TF_ID_BYTES</code> / <code>TF_TYPE_BYTES</code></td>
<td>1</td>
<td>ID 和类型字段宽度</td>
</tr>
<tr>
<td><code>TF_LEN_BYTES</code></td>
<td>2</td>
<td>长度字段宽度</td>
</tr>
<tr>
<td><code>TF_CKSUM_TYPE</code></td>
<td><code>TF_CKSUM_CRC16</code></td>
<td>校验类型</td>
</tr>
<tr>
<td><code>TF_USE_SOF_BYTE</code></td>
<td>1</td>
<td>启用帧首字节</td>
</tr>
<tr>
<td><code>TF_USE_MUTEX</code></td>
<td>0</td>
<td>单线程，不加锁</td>
</tr>
</tbody></table>
<p>Demo 注册了三个 Type 监听器和一个 Generic 监听器，初始化时调用 <code>TF_InitStatic()</code> 静态分配。UART 接收字节通过 <code>TF_Accept()</code> 喂入解析器，<code>TF_WriteImpl()</code> 用 <code>HAL_UART_Transmit()</code> 阻塞发送。</p>
<h2>测量结果</h2>
<p>两次编译的 <code>arm-none-eabi-size</code> 对比：</p>
<table>
<thead>
<tr>
<th></th>
<th>Flash (text+data)</th>
<th>RAM (data+bss)</th>
</tr>
</thead>
<tbody><tr>
<td>基线（HAL + UART + printf）</td>
<td>9,988 B</td>
<td>1,496 B</td>
</tr>
<tr>
<td>加入 TinyFrame</td>
<td>12,836 B</td>
<td>1,856 B</td>
</tr>
<tr>
<td><strong>TinyFrame 净增量</strong></td>
<td><strong>+2,848 B</strong></td>
<td><strong>+360 B</strong></td>
</tr>
</tbody></table>
<p>Flash 多出约 2.8KB，RAM 多出 360B。对于 20KB/3KB 的 PY32F002A，加上 HAL 基线后总共用掉 63% Flash 和 60% RAM，还有空间给业务逻辑。</p>
<h2>增量拆解</h2>
<p>从 linker map 里把 TinyFrame 相关的符号挑出来：</p>
<table>
<thead>
<tr>
<th>项目</th>
<th>大小</th>
<th>存储</th>
</tr>
</thead>
<tbody><tr>
<td><code>tf</code> 实例（含 128B RX buf + 64B TX buf + 监听器槽位）</td>
<td>352 B</td>
<td>RAM (.bss)</td>
</tr>
<tr>
<td>CRC16 查找表 <code>crc16_table</code></td>
<td>512 B</td>
<td>Flash (.rodata)</td>
</tr>
<tr>
<td><code>TF_HandleReceivedMessage</code> 解析核心</td>
<td>336 B</td>
<td>Flash (.text)</td>
</tr>
<tr>
<td>其余 TF 函数（Init, Send, Query, Listener 管理, Tick...）</td>
<td>~1,680 B</td>
<td>Flash (.text)</td>
</tr>
</tbody></table>
<p>最大的单块 Flash 开销是 CRC16 的 512 字节查找表——占 TinyFrame 增量的 18%。RAM 开销的大头是 <code>tf</code> 实例本身，其中 RX 缓冲区和 TX 缓冲区合计 192 字节。</p>
<h2>可调开关</h2>
<p>如果 Flash 或 RAM 更紧张，可以从这几个方向压：</p>
<p><strong>校验类型。</strong> CRC16 → CRC8 省掉 500B Flash（512B 查找表换成 256B），换 XOR 或 None 再省约 20B。代价是检错能力下降，在 UART 等本身有一定误码率的链路上不建议关掉。</p>
<p><strong>缓冲区大小。</strong> <code>TF_MAX_PAYLOAD_RX</code> 从 128 降到 64，直接省 64B RAM。<code>TF_SENDBUF_LEN</code> 从 64 降到 32，再省 32B。这两个参数要和实际业务帧大小匹配——如果最大指令 payload 只有 20 字节，128 的缓冲区就是浪费。</p>
<p><strong>监听器槽位。</strong> <code>TF_MAX_TYPE_LST</code>、<code>TF_MAX_ID_LST</code>、<code>TF_MAX_GEN_LST</code> 每个槽位在 <code>tf</code> 实例中占十几个字节（指针 + 标志位）。按实际需要的最大并发监听数设置，多余的槽位是纯浪费。</p>
<p><strong>字段宽度。</strong> <code>TF_LEN_BYTES</code> 从 2 降到 1（如果 payload 不超过 255 字节），省少量 Flash 和几个字节 RAM。<code>TF_ID_BYTES</code> 同理。</p>
<p><strong>去掉 SOF。</strong> <code>TF_USE_SOF_BYTE</code> 设为 0，省掉帧首字节检查和相关的几行代码。在本身有帧边界机制的链路上（如 BLE Notify 每次一个完整包），SOF 可以省略。</p>
<p>压缩到极限后的估算（CRC8 + RX 64 + TX 32 + 最简监听器）：</p>
<table>
<thead>
<tr>
<th>配置</th>
<th>Flash 增量</th>
<th>RAM 增量</th>
</tr>
</thead>
<tbody><tr>
<td>本文配置（CRC16, RX128, TX64）</td>
<td>~2,848 B</td>
<td>360 B</td>
</tr>
<tr>
<td>极限压榨（CRC8, RX64, TX32）</td>
<td>~2,100 B</td>
<td>264 B</td>
</tr>
</tbody></table>
<h2>结论</h2>
<p>TinyFrame 在 Cortex-M0+ 上的静态增量约 <strong>2.8KB Flash + 360B RAM</strong>（CRC16 配置），压到极限约 <strong>2.1KB Flash + 264B RAM</strong>（CRC8 配置）。对于 20KB/3KB 的 PY32F002A 这种级别的芯片，加上 HAL 后还剩约 7KB Flash 和 1.1KB RAM 给业务层，完全够用。</p>
<p>这个测量方法的要点是：在真实 MCU 工程里编译，拿基线差分，不看 <code>.o</code> 文件大小或文档里的估算。选了芯片和工具链之后跑一次，数字比任何&quot;轻量级&quot;的描述都可靠。</p>]]></content>
    <category term="嵌入式"/>
    <category term="TinyFrame"/>
    <category term="PY32F002A"/>
    <category term="Cortex-M"/>
  </entry>
  <entry>
    <title>MCU 定时器脉宽捕获：双边沿读引脚与 PWM 输入模式</title>
    <link href="https://createskyblue.github.io/posts/mcu-timer-pulse-width-capture/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/mcu-timer-pulse-width-capture/</id>
    <published>2026-07-07T00:00:00.000Z</published>
    <updated>2026-07-07T00:00:00.000Z</updated>
    <summary>定时器双边沿捕获测量脉宽</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>MCU 定时器脉宽捕获：双边沿读引脚与 PWM 输入模式</h1>
<p>使用定时器测量外部信号的脉宽是嵌入式开发中的常见需求。以下场景都会遇到：</p>
<ul>
<li>超声波测距，需要测量 Echo 引脚的高电平宽度</li>
<li>红外遥控解码，需要区分引导码、数据 0 和数据 1 的脉冲长度</li>
<li>PPM 信号解析，多路 PWM 的上升沿间隔编码了通道值</li>
<li>旋转编码器速度测量，两个脉冲之间的时间间隔对应转速</li>
</ul>
<p>这篇文章整理三种基于定时器捕获通道的脉宽测量方法，并讨论一个问题：硬件能自动过滤不合格的脉宽吗？哪些必须在软件里做？</p>
<h2>PY32F002A 的定时器资源</h2>
<p>先看一眼手里的硬件。PY32F002A（TSSOP-20 封装）有 2 个定时器：</p>
<table>
<thead>
<tr>
<th>定时器</th>
<th>类型</th>
<th>捕获通道</th>
<th>能测脉宽?</th>
</tr>
</thead>
<tbody><tr>
<td>TIM1</td>
<td>高级定时器</td>
<td>4 个（CH1~CH4）</td>
<td>✅</td>
</tr>
<tr>
<td>TIM16</td>
<td>通用定时器</td>
<td>0 个——没有 CCMR、CCER、CCR</td>
<td>❌ 只能做定时中断</td>
</tr>
</tbody></table>
<p><strong>只有 TIM1 能做输入捕获。</strong> 不过它有 4 个通道，每个通道是完全独立的硬件：独立的 CCR 寄存器、独立的输入滤波器（ICxF）、独立的预分频器（ICxPSC）、独立的中断标志。唯一共享的是一颗 16 位计数器 CNT。</p>
<h2>硬件滤波：能做的和不能做的</h2>
<p>每个捕获通道都有一个 4 位数字滤波器（ICxF）和一个 2 位事件预分频器（ICxPSC），在信号进入边沿检测之前工作。</p>
<h3>数字滤波器去毛刺</h3>
<p>滤波器用 fDTS（死区时间采样时钟，等于 CK_INT 或其 2/4 分频）对输入信号连续采样 N 次。只有 N 次采样结果一致，信号电平才被传递到边沿检测电路。</p>
<table>
<thead>
<tr>
<th>ICxF</th>
<th>fDTS</th>
<th>N</th>
<th>48MHz 下的最小脉冲</th>
</tr>
</thead>
<tbody><tr>
<td>0x0</td>
<td>无滤波</td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>0x3</td>
<td>fCK_INT</td>
<td>8</td>
<td>167 ns</td>
</tr>
<tr>
<td>0x7</td>
<td>fCK_INT/4</td>
<td>8</td>
<td>667 ns</td>
</tr>
<tr>
<td>0xB</td>
<td>fCK_INT/16</td>
<td>8</td>
<td>2.67 μs</td>
</tr>
<tr>
<td>0xF</td>
<td>fCK_INT/32</td>
<td>8</td>
<td>5.33 μs</td>
</tr>
</tbody></table>
<p>宽度小于上述值的毛刺会被硬件自动吞掉，不会触发捕获。这是<strong>纯硬件行为</strong>，你的 ISR 根本感知不到这些毛刺。</p>
<h3>事件预分频器跳脉冲</h3>
<p>ICxPSC 让捕获通道每 N 个边沿才响应一次：</p>
<table>
<thead>
<tr>
<th>ICPSC</th>
<th>效果</th>
</tr>
</thead>
<tbody><tr>
<td>DIV1</td>
<td>每个边沿都捕获</td>
</tr>
<tr>
<td>DIV2</td>
<td>每 2 个边沿捕获 1 次</td>
</tr>
<tr>
<td>DIV4</td>
<td>每 4 个边沿捕获 1 次</td>
</tr>
<tr>
<td>DIV8</td>
<td>每 8 个边沿捕获 1 次</td>
</tr>
</tbody></table>
<p>预分频器适合周期性信号——比如你只关心每第 4 次上升沿的时间戳，中间 3 次直接忽略。</p>
<h3>硬件不能做的：阈值判定</h3>
<p>ICxF 滤毛刺，ICxPSC 跳脉冲。但没有寄存器可以设&quot;脉宽小于 100 个 tick 就自动丢弃&quot;。<strong>脉冲是否合格，必须在 ISR 里用软件判断。</strong></p>
<p>好在这不难：中断里读到 CCR 值，跟前一次捕获值做差，大于最小阈值、小于最大阈值就算合格，不满足的直接丢弃。</p>
<h2>三种捕获触发模式</h2>
<p>每个捕获通道可以独立选择边沿触发方式：</p>
<table>
<thead>
<tr>
<th>模式</th>
<th>HAL 宏</th>
<th>行为</th>
</tr>
</thead>
<tbody><tr>
<td>上升沿</td>
<td><code>TIM_ICPOLARITY_RISING</code></td>
<td>只在上升沿锁存 CNT → CCR，触发中断</td>
</tr>
<tr>
<td>下降沿</td>
<td><code>TIM_ICPOLARITY_FALLING</code></td>
<td>只在下降沿锁存 CNT → CCR，触发中断</td>
</tr>
<tr>
<td>双边沿</td>
<td><code>TIM_ICPOLARITY_BOTHEDGE</code></td>
<td>两个边沿都锁存 CNT → CCR，都触发中断</td>
</tr>
</tbody></table>
<p>触发条件满足时，硬件自动把当前 CNT 值锁存到对应的 CCR 寄存器里，置中断标志。整个过程不需要软件参与——你在 ISR 里读 CCR 拿到的就是边沿到达那一刻的计数器快照。</p>
<p>下面三种方法本质上是在这三种触发模式上做组合。</p>
<h2>方法一：单通道双边沿 + 读引脚判方向</h2>
<p>一个通道配成双边沿捕获，上升沿和下降沿都会触发中断。中断里读对应 GPIO 的输入电平：高说明刚才是上升沿，低说明刚才是下降沿。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// 配置：CH1 双边沿捕获 + 硬件滤波</span></span>
<span class="line"><span style="color:#D4D4D4">TIM_IC_InitTypeDef </span><span style="color:#9CDCFE">sIC</span><span style="color:#D4D4D4"> = {</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#9CDCFE">sIC</span><span style="color:#D4D4D4">.ICPolarity  = TIM_ICPOLARITY_BOTHEDGE;</span><span style="color:#6A9955">   // 双边沿都触发</span></span>
<span class="line"><span style="color:#9CDCFE">sIC</span><span style="color:#D4D4D4">.ICSelection = TIM_ICSELECTION_DIRECTTI;</span><span style="color:#6A9955">   // 直接连 TI1</span></span>
<span class="line"><span style="color:#9CDCFE">sIC</span><span style="color:#D4D4D4">.ICPrescaler = TIM_ICPSC_DIV1;</span><span style="color:#6A9955">             // 每个边沿都捕获</span></span>
<span class="line"><span style="color:#9CDCFE">sIC</span><span style="color:#D4D4D4">.ICFilter    = </span><span style="color:#B5CEA8">0x0F</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">                       // 最强硬件滤波</span></span>
<span class="line"><span style="color:#DCDCAA">HAL_TIM_IC_ConfigChannel</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">htim1</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">sIC</span><span style="color:#D4D4D4">, TIM_CHANNEL_1);</span></span>
<span class="line"><span style="color:#DCDCAA">HAL_TIM_IC_Start_IT</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">htim1</span><span style="color:#D4D4D4">, TIM_CHANNEL_1);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// ISR</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> HAL_TIM_IC_CaptureCallback</span><span style="color:#D4D4D4">(TIM_HandleTypeDef *</span><span style="color:#9CDCFE">htim</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    static</span><span style="color:#569CD6"> uint32_t</span><span style="color:#D4D4D4"> last = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">htim</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">Channel</span><span style="color:#D4D4D4"> == HAL_TIM_ACTIVE_CHANNEL_1) {</span></span>
<span class="line"><span style="color:#569CD6">        uint32_t</span><span style="color:#D4D4D4"> now = </span><span style="color:#DCDCAA">HAL_TIM_ReadCapturedValue</span><span style="color:#D4D4D4">(htim, TIM_CHANNEL_1);</span></span>
<span class="line"><span style="color:#569CD6">        uint32_t</span><span style="color:#D4D4D4"> diff = (now >= last) ? (now - last) : (</span><span style="color:#B5CEA8">0xFFFF</span><span style="color:#D4D4D4"> - last + now);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">HAL_GPIO_ReadPin</span><span style="color:#D4D4D4">(GPIOA, GPIO_PIN_8) == GPIO_PIN_SET) {</span></span>
<span class="line"><span style="color:#6A9955">            // 引脚高 → 刚才上升沿 → diff = 低电平宽度</span></span>
<span class="line"><span style="color:#C586C0">            if</span><span style="color:#D4D4D4"> (diff > MIN_TICKS &#x26;&#x26; diff &#x3C; MAX_TICKS) {</span></span>
<span class="line"><span style="color:#D4D4D4">                g_pulse_ticks = diff;</span><span style="color:#6A9955">   // 合格</span></span>
<span class="line"><span style="color:#D4D4D4">            }</span></span>
<span class="line"><span style="color:#6A9955">            // 不合格的静默丢弃</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#6A9955">        // 下降沿只记录时间戳，不计算宽度</span></span>
<span class="line"><span style="color:#D4D4D4">        last = now;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>时序：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>信号 ──┐        ┌──────────┐        ┌──────</span></span>
<span class="line"><span>       └────────┘          └────────┘</span></span>
<span class="line"><span>        ↓        ↑          ↓        ↑</span></span>
<span class="line"><span>    下降沿     上升沿      下降沿    上升沿</span></span>
<span class="line"><span>    CCR=100   CCR=400    CCR=650  CCR=900</span></span>
<span class="line"><span>    PIN=0     PIN=1      PIN=0    PIN=1</span></span>
<span class="line"><span>    只记录    低脉宽      只记录    低脉宽</span></span>
<span class="line"><span>              =400-100             =900-650</span></span>
<span class="line"><span>              =300                =250</span></span></code></pre><p><strong>优点</strong>：一个通道搞定一路信号。TIM1 有 4 个通道，够测 4 路。</p>
<p><strong>代价</strong>：ISR 里要读一次 GPIO 来判断方向。对于高频脉冲，GPIO 读操作有 1~2 个 APB 周期的延迟，但绝大多数应用场景完全够用。</p>
<h2>方法二：PWM 输入模式——两个通道绑一个信号</h2>
<p>PWM 输入模式用两个捕获通道（CH1+CH2 或 CH3+CH4）绑到同一个输入引脚。CH1 只抓上升沿，CH2 只抓下降沿，两个通道各自独立触发，互不干扰。ISR 里<strong>不需要读 GPIO</strong>——哪个通道触发，就知道是哪个边沿。</p>
<p>同一个信号物理上只接一根引脚。CH2 通过 CCMR 的 INDIRECT 模式&quot;内部布线&quot;连到同一路 TI1 输入。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// CH1: 上升沿, 直接连 TI1</span></span>
<span class="line"><span style="color:#D4D4D4">TIM_IC_InitTypeDef </span><span style="color:#9CDCFE">sIC1</span><span style="color:#D4D4D4"> = {</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#9CDCFE">sIC1</span><span style="color:#D4D4D4">.ICPolarity  = TIM_ICPOLARITY_RISING;</span></span>
<span class="line"><span style="color:#9CDCFE">sIC1</span><span style="color:#D4D4D4">.ICSelection = TIM_ICSELECTION_DIRECTTI;</span></span>
<span class="line"><span style="color:#9CDCFE">sIC1</span><span style="color:#D4D4D4">.ICPrescaler = TIM_ICPSC_DIV1;</span></span>
<span class="line"><span style="color:#9CDCFE">sIC1</span><span style="color:#D4D4D4">.ICFilter    = </span><span style="color:#B5CEA8">0x0F</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">HAL_TIM_IC_ConfigChannel</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">htim1</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">sIC1</span><span style="color:#D4D4D4">, TIM_CHANNEL_1);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// CH2: 下降沿, 间接连到同一个 TI1（不需要额外引脚）</span></span>
<span class="line"><span style="color:#D4D4D4">TIM_IC_InitTypeDef </span><span style="color:#9CDCFE">sIC2</span><span style="color:#D4D4D4"> = {</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#9CDCFE">sIC2</span><span style="color:#D4D4D4">.ICPolarity  = TIM_ICPOLARITY_FALLING;</span></span>
<span class="line"><span style="color:#9CDCFE">sIC2</span><span style="color:#D4D4D4">.ICSelection = TIM_ICSELECTION_INDIRECTTI;</span><span style="color:#6A9955">  // 内部连 TI1</span></span>
<span class="line"><span style="color:#9CDCFE">sIC2</span><span style="color:#D4D4D4">.ICPrescaler = TIM_ICPSC_DIV1;</span></span>
<span class="line"><span style="color:#9CDCFE">sIC2</span><span style="color:#D4D4D4">.ICFilter    = </span><span style="color:#B5CEA8">0x0F</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">HAL_TIM_IC_ConfigChannel</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">htim1</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">sIC2</span><span style="color:#D4D4D4">, TIM_CHANNEL_2);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">HAL_TIM_IC_Start_IT</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">htim1</span><span style="color:#D4D4D4">, TIM_CHANNEL_1);</span></span>
<span class="line"><span style="color:#DCDCAA">HAL_TIM_IC_Start_IT</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">htim1</span><span style="color:#D4D4D4">, TIM_CHANNEL_2);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// ISR</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> HAL_TIM_IC_CaptureCallback</span><span style="color:#D4D4D4">(TIM_HandleTypeDef *</span><span style="color:#9CDCFE">htim</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">htim</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">Channel</span><span style="color:#D4D4D4"> == HAL_TIM_ACTIVE_CHANNEL_1) {</span></span>
<span class="line"><span style="color:#6A9955">        // CH1 中断 → 一定是上升沿 → 读 CCR1（周期值）</span></span>
<span class="line"><span style="color:#569CD6">        uint32_t</span><span style="color:#D4D4D4"> period = </span><span style="color:#DCDCAA">HAL_TIM_ReadCapturedValue</span><span style="color:#D4D4D4">(htim, TIM_CHANNEL_1);</span></span>
<span class="line"><span style="color:#D4D4D4">        g_period_ticks = period;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">htim</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">Channel</span><span style="color:#D4D4D4"> == HAL_TIM_ACTIVE_CHANNEL_2) {</span></span>
<span class="line"><span style="color:#6A9955">        // CH2 中断 → 一定是下降沿 → 读 CCR2（高电平宽度）</span></span>
<span class="line"><span style="color:#569CD6">        uint32_t</span><span style="color:#D4D4D4"> high = </span><span style="color:#DCDCAA">HAL_TIM_ReadCapturedValue</span><span style="color:#D4D4D4">(htim, TIM_CHANNEL_2);</span></span>
<span class="line"><span style="color:#D4D4D4">        g_high_ticks = high;</span></span>
<span class="line"><span style="color:#D4D4D4">        g_low_ticks = g_period_ticks - high;</span><span style="color:#6A9955">   // 低脉宽 = 周期 - 高脉宽</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><strong>优点</strong>：ISR 不需要读 GPIO，不需要记上次值，边沿方向由通道号本身保证。代码干净。</p>
<p><strong>代价</strong>：一路信号吃掉两个捕获通道。TIM1 最多测两路（CH1+CH2 一路，CH3+CH4 一路）。CH3+CH4 在 CCMR2 中<strong>没有 INDIRECT 模式</strong>——CH4 不能间接映射到 TI3，第二路只能退回方法一。</p>
<h2>方法三：纯 GPIO 双边沿中断</h2>
<p>前两种方法都需要占用定时器捕获通道。如果引脚不满足 TIM1 通道映射、或者捕获通道已挪作他用，可以用 GPIO 双边沿中断加软件状态机来测脉宽。</p>
<p>PY32F002A 的 EXTI 原生支持双边沿触发：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">GPIO_InitStruct.Mode = GPIO_MODE_IT_RISING_FALLING;</span></span></code></pre><p>配置之后，上升沿和下降沿都会进同一个 EXTI 中断，<strong>不需要像 51 单片机那样在 ISR 里来回切换触发边沿</strong>。ISR 里读一次引脚电平即可判断方向，再用一个自由运行的定时器（比如 SysTick 或只做时基的 TIM16）拿时间戳：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// 两路信号各用一个小的状态机</span></span>
<span class="line"><span style="color:#569CD6">typedef</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> last_tick;</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#D4D4D4">  state;</span><span style="color:#6A9955">       // 0=等下降沿, 1=等上升沿</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> low_width;</span></span>
<span class="line"><span style="color:#D4D4D4">} </span><span style="color:#4EC9B0">pulse_detect_t</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">pulse_detect_t</span><span style="color:#D4D4D4"> g_ch1, g_ch2;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> EXTI_IRQHandler</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> now = </span><span style="color:#9CDCFE">TIM16</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">CNT</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">  // 自由运行的时间基准</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">__HAL_GPIO_EXTI_GET_IT</span><span style="color:#D4D4D4">(GPIO_PIN_2) != RESET) {</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">HAL_GPIO_ReadPin</span><span style="color:#D4D4D4">(GPIOA, GPIO_PIN_2) == GPIO_PIN_SET) {</span></span>
<span class="line"><span style="color:#6A9955">            // 上升沿 → 低电平结束</span></span>
<span class="line"><span style="color:#569CD6">            uint32_t</span><span style="color:#D4D4D4"> diff = (now >= </span><span style="color:#9CDCFE">g_ch1</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">last_tick</span><span style="color:#D4D4D4">) ? (now - </span><span style="color:#9CDCFE">g_ch1</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">last_tick</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">                                                     : (</span><span style="color:#B5CEA8">0xFFFF</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">g_ch1</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">last_tick</span><span style="color:#D4D4D4"> + now);</span></span>
<span class="line"><span style="color:#C586C0">            if</span><span style="color:#D4D4D4"> (diff > MIN_TICKS &#x26;&#x26; diff &#x3C; MAX_TICKS) {</span></span>
<span class="line"><span style="color:#9CDCFE">                g_ch1</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">low_width</span><span style="color:#D4D4D4"> = diff;</span><span style="color:#6A9955">  // 合格</span></span>
<span class="line"><span style="color:#D4D4D4">            }</span></span>
<span class="line"><span style="color:#D4D4D4">        } </span><span style="color:#C586C0">else</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#6A9955">            // 下降沿 → 低电平开始，只记录起点</span></span>
<span class="line"><span style="color:#9CDCFE">            g_ch1</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">last_tick</span><span style="color:#D4D4D4"> = now;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#DCDCAA">        __HAL_GPIO_EXTI_CLEAR_IT</span><span style="color:#D4D4D4">(GPIO_PIN_2);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#6A9955">    // 通道2 同理...</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>精度能接受吗</h3>
<p>软件中断的延迟不是随机乱跳——Cortex-M0+ 的中断响应是确定性的 12~16 个周期。跑 48MHz 时约 0.3μs。真正引入抖动的是被其他中断或临界区短暂挡住。</p>
<p>以 250μs 最小脉宽为例：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>中断延迟：~0.3μs</span></span>
<span class="line"><span>SysTick 挡住（典型）：~2μs</span></span>
<span class="line"><span>最坏情况 jitter：~2μs</span></span>
<span class="line"><span>相对误差：2 / 250 = 0.8%</span></span></code></pre><p>对于电机霍尔、红外遥控、超声波测距这类场景完全够用。但如果脉宽是几十微秒级别，或者系统里有长时间关中断的代码（Flash 擦写、I2C 阻塞等待），误差就会明显增大。</p>
<p><strong>延伸阅读</strong>：<a href="/posts/software-dual-edge-pulse-filter/" target="_blank" rel="noopener noreferrer">在 51 单片机上用中断捕获 12 路微秒级脉宽，软件双边沿会碰到实时性边界</a> 的情况不同——51 主频低、路数多、脉宽窄，软件方案才会成为瓶颈。</p>
<h2>三种方法对比</h2>
<table>
<thead>
<tr>
<th></th>
<th>方法一<br>双边沿+读引脚</th>
<th>方法二<br>PWM输入模式</th>
<th>方法三<br>GPIO中断</th>
</tr>
</thead>
<tbody><tr>
<td>通道/信号数</td>
<td>1 个</td>
<td>2 个</td>
<td>0（不用定时器）</td>
</tr>
<tr>
<td>TIM1 最大信号数</td>
<td>4 路</td>
<td>2 路</td>
<td>不限</td>
</tr>
<tr>
<td>ISR 读 GPIO</td>
<td>需要</td>
<td>不需要</td>
<td>需要</td>
</tr>
<tr>
<td>ISR 复杂度</td>
<td>中等</td>
<td>低</td>
<td>低</td>
</tr>
<tr>
<td>精度</td>
<td>硬件时间戳</td>
<td>硬件时间戳</td>
<td>软件时间戳，~2μs jitter</td>
</tr>
<tr>
<td>适用场景</td>
<td>多路低频脉冲</td>
<td>1~2 路高频 PWM</td>
<td>脉宽 ≥100μs，无长时间关中断</td>
</tr>
<tr>
<td>额外要求</td>
<td>引脚映射到 TIM1 CHx</td>
<td>引脚映射到 TIM1 CHx</td>
<td>EXTI 可用即可</td>
</tr>
</tbody></table>
<h2>软件阈值判定的最终形态</h2>
<p>不论用方法一还是方法二，硬件只能去毛刺，不能判断脉宽是否合法。ISR 里代码形态是相同的：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// 拿到 diff（两次捕获的 tick 差值）</span></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (diff &#x3C; MIN_VALID_TICKS)  </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">  // 太窄，丢弃</span></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (diff > MAX_VALID_TICKS)  </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">  // 太宽，丢弃</span></span>
<span class="line"><span style="color:#D4D4D4">g_valid_count++;</span><span style="color:#6A9955">                        // 合格</span></span>
<span class="line"><span style="color:#6A9955">// 然后该干啥干啥</span></span></code></pre><p>MIN 和 MAX 的取值取决于你的信号协议。红外遥控 NEC 协议逻辑 0 是 560μs，逻辑 1 是 1690μs；超声波测距 HC-SR04 高电平约 150μs~25ms。把这些物理时间换算成定时器 tick 数作为阈值，不合规的直接 <code>return</code> 丢弃。</p>
<p><strong>延伸阅读</strong>：<a href="/posts/hall-pulse-width-validation/" target="_blank" rel="noopener noreferrer">一种基于脉宽判定的电机霍尔反馈抗干扰方法</a> 在电机霍尔信号上实际应用了脉宽阈值校验——用定时器捕获霍尔边沿，通过脉宽范围过滤干扰脉冲。</p>
<h2>小结</h2>
<p>定时器捕获通道自带两级硬件预处理：数字滤波器去毛刺，预分频器跳脉冲。这两项是纯硬件行为，不需要软件参与。</p>
<p>但&quot;判定脉宽范围&quot;必须软件做——没有硬件阈值比较器。</p>
<p>怎么选：</p>
<ul>
<li><strong>引脚能映射到 TIM1 CHx</strong> → 优先用方法一或方法二，硬件时间戳精度最高</li>
<li><strong>引脚不满足 TIM1 映射、但脉宽 ≥ 100μs</strong> → 方法三的软件中断完全可用，误差在 1~2% 以内。STM32 系 EXTI 原生支持双边沿，不需要像 51 那样来回切换触发模式</li>
<li><strong>脉宽几十微秒且关中断频繁</strong> → 硬件定时器捕获是底线</li>
</ul>
<p>具体到 PY32F002A：只有 TIM1 能做捕获，TSSOP-20 能引出的 TIM1 通道取决于封装。查数据手册的引脚复用表确认 CH1~CH4 对应的 GPIO 即可。TIM1 通道不够用时，TIM16 配成自由运行计数器给方法三当时间基准。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="定时器"/>
    <category term="脉宽判定"/>
    <category term="STM32"/>
    <category term="中断"/>
  </entry>
  <entry>
    <title>Windows 熄屏后自动亮起时先检查空闲输入是否被重置</title>
    <link href="https://createskyblue.github.io/posts/windows-display-idle-input/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/windows-display-idle-input/</id>
    <published>2026-07-07T00:00:00.000Z</published>
    <updated>2026-07-07T00:00:00.000Z</updated>
    <summary>Windows 1分钟熄屏后自动亮起，从空闲输入计时查起</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Windows 熄屏后自动亮起时先检查空闲输入是否被重置</h1>
<p>Windows 的“关闭显示器”和“系统睡眠”是两条不同路径。一次死机重启后，机器可以正常手动睡眠，但把显示器自动关闭时间设成 1 分钟后，屏幕会在熄灭后很快亮起。这个现象容易被误判成电源计划失效，实际排查入口应该先落到空闲输入计时。</p>
<h2>现象</h2>
<p>故障表现很窄：</p>
<ul>
<li>手动睡眠可以进入低功耗状态，按电源键可以正常恢复。</li>
<li>把显示器自动关闭时间设为 1 分钟后，屏幕会按时熄灭，又很快重新亮起。</li>
<li>机器没有表现出整机睡眠后被异常唤醒的特征。</li>
</ul>
<p>这组现象说明，问题不一定在“睡眠唤醒”。显示器熄灭只需要满足用户空闲条件；只要 Windows 认为有新的鼠标、键盘、触控板或虚拟 HID 输入，显示器就会重新点亮。</p>
<h2>先排除电源唤醒路径</h2>
<p>第一步先看系统有没有明确的电源阻塞或唤醒来源。几个结果比较关键：</p>
<ul>
<li><code>powercfg /requests</code> 没有显示器、系统、执行、Away Mode 等保持唤醒请求。</li>
<li><code>powercfg /waketimers</code> 没有活动唤醒计时器。</li>
<li><code>powercfg /devicequery wake_armed</code> 没有列出允许唤醒系统的设备。</li>
<li>电源事件记录里，手动睡眠后的恢复来源是电源按钮。</li>
</ul>
<p>这些证据把问题范围收窄到显示器空闲路径。系统并没有被某个计划任务或设备从睡眠里唤醒，屏幕重新亮起更可能来自用户输入状态被刷新。</p>
<h2>用空闲计时验证假输入</h2>
<p>随后直接监控 Windows 的最后输入时间。正常情况下，如果没有碰鼠标、键盘和触控板，空闲秒数应该持续递增。实际观察到的情况是，空闲秒数接近显示器关闭阈值时会突然归零。</p>
<p>这个结果很关键。它说明系统收到了某种输入事件，或者某个驱动/服务让 Windows 认为用户刚刚操作过。于是显示器刚进入关闭状态，又立刻被“新输入”点亮。</p>
<p>这里的重点是先确定判断对象：从“电源计划坏了”切换到“谁在重置空闲输入计时”。排查方向一变，后续证据就容易对齐。</p>
<h2>可疑对象集中在虚拟输入和虚拟显示</h2>
<p>设备和进程列表里出现了几类强相关对象：</p>
<ul>
<li>远控或串流类服务。</li>
<li>模拟器相关远程服务。</li>
<li>虚拟显示适配器。</li>
<li>虚拟 HID 鼠标、键盘和供应商定义设备。</li>
</ul>
<p>这类组件为了远程控制、投屏、模拟触控或保持会话，常会安装虚拟输入设备和虚拟显示设备。它们不一定会阻止系统睡眠，也不一定出现在 <code>powercfg /requests</code> 里，但仍可能刷新用户输入状态。</p>
<p>本次机器上最终通过停用相关虚拟输入/显示服务解决。停用后重新测试 1 分钟自动熄屏，屏幕不再马上亮起。</p>
<h2>修复边界</h2>
<p>这次修复采用的是隔离式处理：</p>
<ul>
<li>只停用确认相关的虚拟输入/显示服务。</li>
<li>不修改电源计划的核心参数。</li>
<li>不禁用物理鼠标、键盘、触摸板和显卡。</li>
<li>不卸载驱动，先保留回滚空间。</li>
</ul>
<p>如果还需要使用远控、串流或模拟器功能，可以逐个恢复服务并重新测试。哪个服务恢复后让空闲计时再次归零，哪个服务就是后续处理对象。后续处理可以选择更新软件、关闭它的远程输入能力、禁用虚拟显示组件，或保留服务但只在需要时手动启动。</p>
<h2>排查经验</h2>
<p>这类问题要把“睡眠”和“熄屏”分开看。手动睡眠正常，只能说明 S3 睡眠链路和电源按钮恢复链路可用；它不能证明显示器空闲计时没有被重置。</p>
<p>比较稳的排查顺序是：</p>
<ol>
<li>先看 <code>powercfg /requests</code>、唤醒定时器和唤醒设备，排除明确的电源阻塞。</li>
<li>再看事件日志，确认实际唤醒来源是否指向电源按钮、设备或计划任务。</li>
<li>如果电源路径没有异常，监控最后输入时间。</li>
<li>如果空闲时间无人操作时归零，优先检查虚拟 HID、远控、串流、模拟器、手写板、蓝牙键盘和触摸板服务。</li>
<li>只做可逆隔离，每次停一组相关服务，测试通过后再决定长期策略。</li>
</ol>
<p>这次真正有用的分界点，是确认“屏幕自动亮起”来自空闲输入被刷新。只要这个判断成立，后续就应该停止反复调整电源计划，转向输入设备、虚拟驱动和后台服务。</p>]]></content>
    <category term="Windows"/>
    <category term="工程调试"/>
    <category term="故障诊断"/>
    <category term="远程输入"/>
  </entry>
  <entry>
    <title>从 STM32 Model Zoo 看边缘 AI 的 14 个落地场景</title>
    <link href="https://createskyblue.github.io/posts/stm32-edge-ai-scenarios/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/stm32-edge-ai-scenarios/</id>
    <published>2026-07-06T00:00:00.000Z</published>
    <updated>2026-07-06T00:00:00.000Z</updated>
    <summary>43 个优化模型覆盖 14 类边缘 AI 场景，从视觉、音频到工业传感</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>从 STM32 Model Zoo 看边缘 AI 的 14 个落地场景</h1>
<blockquote>
<p><strong>声明：</strong> <a href="https://github.com/STMicroelectronics/stm32ai-modelzoo">STM32 AI Model Zoo</a> 是 STMicroelectronics 维护的开源项目。本文是阅读该项目源码、文档和模型清单后整理的学习笔记，用于理解边缘 AI 在微控制器上的工程落地方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有模型数据均来自项目文档与 manifest，不代表本文作者的设计决策。</p>
</blockquote>
<p>边缘 AI 跑到了 MCU 上。Cortex-M4、Cortex-M7、Cortex-M85 —— 几十 MHz 主频、几百 KB SRAM —— 这些单片机现在能直接运行量化后的神经网络。</p>
<p>STMicroelectronics 开源的 <a href="https://github.com/STMicroelectronics/stm32ai-modelzoo">stm32ai-modelzoo</a> 是观察这个趋势的绝佳窗口。仓库收录了 <strong>14 个应用领域、43 个预训练模型</strong>，全部经过 INT8 量化，提供 TensorFlow Lite 和 ONNX QDQ 两种格式，可以直接部署到 STM32H7、STM32MP2、STM32N6 等目标平台。配套仓库 <a href="https://github.com/STMicroelectronics/stm32ai-modelzoo-services">stm32ai-modelzoo-services</a> 包含完整的训练脚本和模型架构源码。</p>
<hr>
<h2>一、视觉类（8 类）</h2>
<p>视觉是边缘 AI 最密集的方向，从简单分类到像素级理解，Model Zoo 提供了完整的任务链。</p>
<h3>图像分类</h3>
<p>最基础的 CV 任务：输入一张图，输出类别标签。Model Zoo 提供了 29 个模型变体 —— 是数量最多的分类。TensorFlow 侧有 MobileNetV1/V2、EfficientNetV2、ResNet8/20/32 等经典架构；PyTorch 侧覆盖 20 种轻量网络，包括 ShuffleNetV2、RegNet、HarDNet、PeleeNet，以及 ST 自研的 ST-ResNet 系列（pico / nano / micro / milli / tiny 五个尺寸）。</p>
<p><img src="/posts/stm32-edge-ai-scenarios/img/output_application_ic.webp" alt="图像分类"></p>
<p>预训练数据集覆盖 ImageNet、Food101、CIFAR-10/100、TF Flowers、Plant Leaf Diseases 等。输入分辨率从 128×128 到 384×384 不等。</p>
<h3>目标检测</h3>
<p>框出物体位置并分类。16 个模型变体，YOLO 家族是主力：Tiny-YOLOv2、YOLOv8-n、YOLOv11-n、最新的 YOLO26-n，以及 ST 自研的 ST-YOLO-LC、ST-YOLO-X 系列。SSD/SSDLite + MobileNet 组合也在列。</p>
<p><img src="/posts/stm32-edge-ai-scenarios/img/output_application_od.webp" alt="目标检测"></p>
<h3>人脸检测</h3>
<p>YuNet 和 BlazeFace Front 两个模型，输入分辨率 128×128 和 320×320。输出人脸框和关键点（眼睛、鼻子、嘴角），通常作为人脸识别 pipeline 的第一级。</p>
<h3>姿态估计</h3>
<p>检测人体 17 个关键点。包含 MoveNet（ST 改进版）、YOLOv8-n Pose、YOLOv11-n Pose，以及手部关键点和头部关键点专用模型。</p>
<p><img src="/posts/stm32-edge-ai-scenarios/img/output_application_pe.webp" alt="姿态估计"></p>
<h3>语义分割</h3>
<p>给每个像素打类别标签。基于 DeepLabV3 + MobileNetV2 backbone，256×256 和 320×320 两种分辨率。在 NPU 加速的 STM32N6 上可以实时运行。</p>
<p><img src="/posts/stm32-edge-ai-scenarios/img/output_application_semseg.webp" alt="语义分割"></p>
<h3>实例分割</h3>
<p>比语义分割更进一步 —— 区分同一类别的不同实例。YOLOv8-n Seg 和 YOLOv11-n Seg 两个模型，需要 NPU 加速。</p>
<h3>深度估计</h3>
<p>从单张 2D 图像推断每个像素的距离。FastDepth 模型，基于 NYU Depth v2 数据集训练。</p>
<h3>神经风格迁移</h3>
<p>把照片转换成指定艺术风格。Xinet-Picasso-Muse 模型，输入 160×160，在 COCO 上训练。Model Zoo 里最&quot;好玩&quot;的模型，也可用于相机实时滤镜和 AR 特效。</p>
<hr>
<h2>二、音频类（2 类）</h2>
<h3>音频事件检测</h3>
<p>识别声音中的特定事件：玻璃破碎、婴儿哭声、警报声等。MiniResNetV1/V2 和 YAMNet（多种嵌入尺寸），输入是梅尔频谱图。</p>
<p><img src="/posts/stm32-edge-ai-scenarios/img/output_application_aed.webp" alt="音频事件检测"></p>
<h3>语音增强</h3>
<p>从嘈杂环境中提取干净人声。STFT-TCNN 模型，在 Valentini 数据集上训练。输入带噪语音的 STFT 频谱，输出降噪后的语音。</p>
<hr>
<h2>三、传感类（4 类）</h2>
<p>视觉和音频之外，Model Zoo 还覆盖了基于传感器信号的四个场景。</p>
<h3>人体活动识别</h3>
<p>通过加速度计数据判断行走、跑步、静坐等状态。ST-GMP 和 ST-IGN 两个模型，输入 48×3 的时序数据（48 个时间步、3 轴加速度）。模型极小，Cortex-M4 可跑。</p>
<p><img src="/posts/stm32-edge-ai-scenarios/img/output_application_har.webp" alt="人体活动识别"></p>
<h3>手势识别</h3>
<p>用 ToF 测距传感器（VL53L5CX / VL53L8CX）识别 8 种手势。ST-CNN2D HandPosture 模型。ToF 只拿到低分辨率深度图，天然隐私友好，适合公共场所。</p>
<h3>电弧故障检测</h3>
<p>检测电路中的电弧 —— 电气火灾的主要成因之一。ST-Conv（时域 CNN）和 ST-Dense（频域全连接）两个模型，输入电流采样序列。</p>
<p><img src="/posts/stm32-edge-ai-scenarios/img/output_application_afd.webp" alt="电弧故障检测"></p>
<h3>人员重识别</h3>
<p>判断两张照片中是否为同一个人。MobileNetV2 和 OSNet 两个模型，256×128 输入分辨率。多摄像头跟踪系统的关键组件。</p>
<hr>
<h2>四、模型架构完全开源</h2>
<p>模型架构定义代码在 <a href="https://github.com/STMicroelectronics/stm32ai-modelzoo-services">stm32ai-modelzoo-services</a> 中完整开源。以图像分类为例：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>image_classification/</span></span>
<span class="line"><span>├── tf/src/models/</span></span>
<span class="line"><span>│   ├── mobilenetv1.py        # MobileNetV1</span></span>
<span class="line"><span>│   ├── mobilenetv2.py        # 倒置残差 + 线性瓶颈</span></span>
<span class="line"><span>│   ├── efficientnetv2.py     # EfficientNetV2 B0~S</span></span>
<span class="line"><span>│   ├── resnet.py             # ResNet8/20/32</span></span>
<span class="line"><span>│   ├── squeezenetv11.py      # Fire 模块</span></span>
<span class="line"><span>│   └── ...</span></span>
<span class="line"><span>├── pt/src/models/</span></span>
<span class="line"><span>│   ├── mobilenetv1/          # PyTorch 版</span></span>
<span class="line"><span>│   ├── mobilenetv3/</span></span>
<span class="line"><span>│   ├── edgevit/              # EdgeViT</span></span>
<span class="line"><span>│   ├── stresnet/             # ST 自研 ResNet 系列</span></span>
<span class="line"><span>│   └── ...</span></span></code></pre><p>每个 <code>.py</code> 文件是完整的模型类定义，可以逐行阅读、修改宽度因子、换激活函数、加注意力模块。通过 <code>stm32ai_main.py</code> 加 YAML 配置文件驱动全流程：训练 → 评估 → 量化 → 部署。</p>
<p>主仓库提供的是量化后的推理文件：TFLite INT8（全整数量化）和 ONNX QDQ（支持混合精度 w4/w8/a8）。</p>
<hr>
<h2>五、为什么在 MCU 上跑 AI</h2>
<p>云端 GPU 算力远超 MCU，但在以下四个维度上本地推理有不可替代的优势：</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>云端推理</th>
<th>MCU 本地推理</th>
</tr>
</thead>
<tbody><tr>
<td>延迟</td>
<td>50-500ms（含网络往返）</td>
<td>1-50ms</td>
</tr>
<tr>
<td>隐私</td>
<td>数据上传服务器</td>
<td>数据不出设备</td>
</tr>
<tr>
<td>功耗</td>
<td>WiFi/4G 持续工作</td>
<td>毫瓦级</td>
</tr>
<tr>
<td>离线</td>
<td>断网不可用</td>
<td>始终在线</td>
</tr>
</tbody></table>
<p>一个典型场景：智能门铃的人形检测。如果每帧图像上传云端，延迟高、云服务持续计费、有隐私顾虑。在 STM32 上本地跑一个 300KB 的 SSD-MobileNet 模型，50ms 出结果，功耗不到 100mW。</p>
<hr>
<h2>六、总结</h2>
<p>STM32 AI Model Zoo 覆盖了边缘 AI 的主流应用方向。从 43 个预训练模型出发，配合 modelzoo-services 的训练工具链，可以做到：选模型 → 改模型（微调或从头训练）→ 量化导出 → 上板运行。</p>
<p>如果你在做需要本地智能的嵌入式产品 —— 无论是产线视觉质检、智能门铃、可穿戴运动追踪，还是工业电弧检测 —— 这个仓库是一个务实的技术起点。</p>
<hr>
<blockquote>
<p><strong>相关链接：</strong></p>
<ul>
<li><a href="https://github.com/STMicroelectronics/stm32ai-modelzoo">stm32ai-modelzoo（预训练模型）</a></li>
<li><a href="https://github.com/STMicroelectronics/stm32ai-modelzoo-services">stm32ai-modelzoo-services（训练脚本 + 模型架构源码）</a></li>
</ul>
</blockquote>
<p><em>本文基于 STM32 AI Model Zoo v4.1（2026 年 4 月发布）撰写。</em></p>]]></content>
    <category term="边缘AI"/>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="STM32"/>
    <category term="模型量化"/>
    <category term="AI"/>
  </entry>
  <entry>
    <title>WCH CH585M 上的边缘AI、TFLite Micro 移植全过程</title>
    <link href="https://createskyblue.github.io/posts/ch585m-tflite-micro-sin-demo/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ch585m-tflite-micro-sin-demo/</id>
    <published>2026-07-05T00:00:00.000Z</published>
    <updated>2026-07-05T00:00:00.000Z</updated>
    <summary>边缘AI 移植 TFLite 到任意 MCU</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>WCH CH585M 上的边缘AI、TFLite Micro 移植全过程</h1>
<blockquote>
<p><strong>把本文链接丢给 AI 代理即可复现全部步骤。</strong> 全文唯一需要人类操作的地方：打开一个 GitHub 网页，点击 Create codespace。其余全部由 AI 代理通过 <code>gh</code> 命令自动完成。</p>
</blockquote>
<p>想象一下：一个几块钱的 MCU，不需要网络、不需要 Linux、不需要 MPU，就能本地运行神经网络——关键词唤醒、手势识别、振动异常检测、传感器数据拟合。设备端推理意味着<strong>零延迟、离线可用、数据不出设备、功耗低到可以用电池跑几年</strong>。</p>
<p>这就是 TensorFlow Lite Micro（TFLM）要做的事——目标是在只有几十 KB RAM、几百 KB Flash 的芯片上跑 AI 推理。</p>
<p>WCH CH585M 是一颗只要几块钱的 RISC-V 芯片——448KB Flash、128KB RAM，带 BLE。如果能在它上面跑 TFLM，就意味着一大批低成本 RISC-V 设备都有了边缘 AI 的可能性。</p>
<p>这篇文章记录把 TFLite Micro 真正烧进 CH585M 的过程：用 MRS IDE 编译，串口输出推理结果，最终在真实硬件上跑通 sin 函数拟合 demo。</p>
<blockquote>
<p>本文用的 sin 模型是用 TensorFlow 训练的一个三层 MLP（1→10→5→1），输入 x 输出 sin(x)。模型的训练过程见<a href="/posts/../%E9%A1%B9%E7%9B%AE/esp32s3-esp-nn-sin-demo" target="_blank" rel="noopener noreferrer">《ESP32-S3 上使用 ESP-NN 加速 TFLite Micro sin 推理》</a>。完整工程代码在 <a href="https://github.com/createskyblue/CH585M_TFLite_sin">GitHub</a>。</p>
</blockquote>
<h2>硬件与工具链</h2>
<table>
<thead>
<tr>
<th>项目</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>MCU</td>
<td>WCH CH585M，RISC-V RV32IMC，448KB Flash，128KB RAM</td>
</tr>
<tr>
<td>调试器</td>
<td>WCH-LinkE（板载）</td>
</tr>
<tr>
<td>串口</td>
<td>UART0 — PA14 (TX) / PA15 (RX) @ 115200</td>
</tr>
<tr>
<td>IDE</td>
<td>MRS2（MounRiver Studio 2，基于 Eclipse CDT）</td>
</tr>
<tr>
<td>工具链</td>
<td>RISC-V Embedded GCC 12.2.0，prefix: <code>riscv-wch-elf-</code></td>
</tr>
<tr>
<td>C 库</td>
<td>newlib-nano</td>
</tr>
</tbody></table>
<h2>第一步：获取 TFLite Micro 源码</h2>
<p>TFLM 自带官方脚本 <code>create_tflm_tree.py</code>，自动生成所需的完整目录树。<strong>已在 GitHub Codespace 上实测通过。</strong></p>
<h3>人类操作（唯一需要你动手的地方）</h3>
<p>打开 <a href="https://github.com/tensorflow/tflite-micro">tensorflow/tflite-micro</a>，点击 <code>Code → Codespaces → Create codespace on main</code>。等 Codespace 启动（约 1-2 分钟），然后把会话交给 AI 代理。如果你使用的 AI 代理能控制浏览器，这一步 AI 可以代劳。</p>
<h3>AI 代理操作</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955"># 列出当前 Codespace</span></span>
<span class="line"><span style="color:#DCDCAA">gh</span><span style="color:#CE9178"> codespace</span><span style="color:#CE9178"> list</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># SSH 进去运行官方脚本</span></span>
<span class="line"><span style="color:#DCDCAA">gh</span><span style="color:#CE9178"> codespace</span><span style="color:#CE9178"> ssh</span><span style="color:#569CD6"> -c</span><span style="color:#D4D4D4"> &#x3C;</span><span style="color:#CE9178">codespace-nam</span><span style="color:#D4D4D4">e> </span><span style="color:#569CD6">--</span><span style="color:#CE9178"> "</span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#CE9178">  pip install Pillow &#x26;&#x26; </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#CE9178">  python3 tensorflow/lite/micro/tools/project_generation/create_tflm_tree.py /tmp/tflm-output &#x26;&#x26; </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#CE9178">  find /tmp/tflm-output -name '*.cc' | wc -l"</span></span>
<span class="line"><span style="color:#6A9955"># 输出应为 202</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 拉取生成的文件到工程目录</span></span>
<span class="line"><span style="color:#DCDCAA">gh</span><span style="color:#CE9178"> codespace</span><span style="color:#CE9178"> cp</span><span style="color:#CE9178"> remote:/tmp/tflm-output/tensorflow</span><span style="color:#CE9178"> .</span><span style="color:#569CD6"> -c</span><span style="color:#D4D4D4"> &#x3C;</span><span style="color:#CE9178">codespace-nam</span><span style="color:#D4D4D4">e></span></span>
<span class="line"><span style="color:#DCDCAA">gh</span><span style="color:#CE9178"> codespace</span><span style="color:#CE9178"> cp</span><span style="color:#CE9178"> remote:/tmp/tflm-output/third_party</span><span style="color:#CE9178"> .</span><span style="color:#569CD6"> -c</span><span style="color:#D4D4D4"> &#x3C;</span><span style="color:#CE9178">codespace-nam</span><span style="color:#D4D4D4">e></span></span></code></pre><p>脚本生成的文件（忽略 <code>signal/</code>，它是信号处理库，与推理无关）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>工程目录/</span></span>
<span class="line"><span>├── tensorflow/     # TFLM 核心源码（202 个 .cc + 271 个 .h）</span></span>
<span class="line"><span>└── third_party/    # flatbuffers, gemmlowp, ruy（纯头文件）</span></span></code></pre><blockquote>
<p>如果本机有 Linux/WSL，直接 clone 仓库后跑 <code>python3 create_tflm_tree.py /tmp/tflm-output</code> 即可，不需要 Codespace。</p>
</blockquote>
<h2>第二步：集成到工程</h2>
<p>拿到 <code>tensorflow/</code> 和 <code>third_party/</code> 后，需要做 4 件事。</p>
<h3>删除冲突文件（9 个）</h3>
<p>脚本生成的默认平台实现与我们要自己写的冲突：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>tensorflow/lite/micro/debug_log.cc       ← 与 tflite_port/debug_log.cc 冲突</span></span>
<span class="line"><span>tensorflow/lite/micro/micro_time.cc      ← 与 tflite_port/micro_time.cc 冲突</span></span>
<span class="line"><span>tensorflow/lite/micro/system_setup.cc    ← 与 tflite_port/system_setup.cc 冲突</span></span>
<span class="line"><span>tensorflow/lite/micro/test_helpers.cc    ← 测试辅助，不需要</span></span>
<span class="line"><span>tensorflow/lite/micro/test_helper_custom_ops.cc</span></span>
<span class="line"><span>tensorflow/lite/micro/fake_micro_context.cc</span></span>
<span class="line"><span>tensorflow/lite/micro/mock_micro_graph.cc</span></span>
<span class="line"><span>tensorflow/lite/micro/recording_micro_allocator.cc</span></span>
<span class="line"><span>tensorflow/lite/micro/hexdump.cc</span></span></code></pre><h3>新增 include 路径（2 个）</h3>
<p>脚本生成的 third_party 目录结构比手动复制多了一层：</p>
<table>
<thead>
<tr>
<th>路径</th>
<th>原因</th>
</tr>
</thead>
<tbody><tr>
<td><code>third_party/flatbuffers/include</code></td>
<td>flatbuffers 在 <code>include/</code> 子目录下</td>
</tr>
<tr>
<td><code>third_party/ruy</code></td>
<td>ruy 路径多了一层 <code>ruy/</code></td>
</tr>
</tbody></table>
<h3>新增编译宏（4 个）</h3>
<p>newlib-nano 缺少部分 C++ 标准数学函数：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>TF_LITE_STATIC_MEMORY               # 禁用动态内存</span></span>
<span class="line"><span>TF_LITE_USE_GLOBAL_CMATH_FUNCTIONS  # 缺少 std::round / std::expm1</span></span>
<span class="line"><span>TF_LITE_USE_GLOBAL_MAX              # 缺少 std::fmax</span></span>
<span class="line"><span>TF_LITE_USE_GLOBAL_MIN              # 缺少 std::fmin</span></span></code></pre><h3>Makefile 用通配符自动发现文件</h3>
<p>不再手动列举文件名，用 <code>$(wildcard ...)</code> 自动匹配：</p>
<pre><code>TFLM_SRCS := $(filter-out \
    %/debug_log.cc %/micro_time.cc %/system_setup.cc \
    %/test_helpers.cc %/test_helper_custom_ops.cc \
    %/fake_micro_context.cc %/mock_micro_graph.cc \
    %/recording_micro_allocator.cc %/hexdump.cc, \
    $(wildcard tensorflow/lite/micro/*.cc \
               tensorflow/lite/micro/arena_allocator/*.cc \
               tensorflow/lite/micro/memory_planner/*.cc \
               tensorflow/lite/micro/kernels/*.cc \
               tensorflow/lite/micro/tflite_bridge/*.cc \
               tensorflow/lite/core/api/*.cc \
               tensorflow/lite/core/c/*.cc \
               tensorflow/lite/kernels/*.cc \
               tensorflow/lite/kernels/internal/*.cc \
               tensorflow/lite/kernels/internal/reference/*.cc \
               tensorflow/compiler/mlir/lite/core/api/*.cc \
               tensorflow/compiler/mlir/lite/schema/*.cc))</code></pre><p><strong>不用担心文件太多</strong>——链接器 <code>--gc-sections</code> 会自动丢弃未使用的代码段。编译了全部 120+ 个算子内核后，最终固件 text 段只有 72KB。</p>
<h2>第三步：写三个平台抽象函数</h2>
<p>TFLite Micro 把平台相关部分收敛到三个函数。这是移植中<strong>唯一需要手写的 C++ 代码</strong>。</p>
<h3>DebugLog</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// tflite_port/debug_log.cc</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/debug_log.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;cstdio></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">extern</span><span style="color:#CE9178"> "C"</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> DebugLog</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4">* format, va_list args) {</span></span>
<span class="line"><span style="color:#DCDCAA">  vfprintf</span><span style="color:#D4D4D4">(stderr, format, args);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">extern</span><span style="color:#CE9178"> "C"</span><span style="color:#569CD6"> int</span><span style="color:#DCDCAA"> DebugVsnprintf</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">char</span><span style="color:#D4D4D4">* buffer, </span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4"> buf_size,</span></span>
<span class="line"><span style="color:#569CD6">                               const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4">* format, va_list vlist) {</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#DCDCAA"> vsnprintf</span><span style="color:#D4D4D4">(buffer, buf_size, format, vlist);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>TFLM 所有日志走 <code>DebugLog()</code>。printf 已被 CH58x HAL 层重定向到 UART（<code>_write()</code> 在 <code>CH58x_sys.c</code> 中实现）。</p>
<h3>InitializeTarget</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// tflite_port/system_setup.cc</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/system_setup.h"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">namespace</span><span style="color:#4EC9B0"> tflite</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> InitializeTarget</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#6A9955">  // 时钟和 UART 已在 main() 中初始化</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>GetCurrentTimeTicks</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// tflite_port/micro_time.cc</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/micro_time.h"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">extern</span><span style="color:#CE9178"> "C"</span><span style="color:#D4D4D4"> { </span><span style="color:#569CD6">extern</span><span style="color:#569CD6"> uint32_t</span><span style="color:#D4D4D4"> SystemCoreClock; }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">namespace</span><span style="color:#4EC9B0"> tflite</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">uint32_t</span><span style="color:#DCDCAA"> ticks_per_second</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#D4D4D4"> (SystemCoreClock / </span><span style="color:#B5CEA8">1000</span><span style="color:#D4D4D4">) ? (SystemCoreClock / </span><span style="color:#B5CEA8">1000</span><span style="color:#D4D4D4">) : </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#569CD6">uint32_t</span><span style="color:#DCDCAA"> GetCurrentTimeTicks</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#569CD6">  volatile</span><span style="color:#569CD6"> uint32_t</span><span style="color:#D4D4D4"> dummy = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#D4D4D4"> dummy;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>不用引用 <code>CH58x_common.h</code>，用 <code>extern &quot;C&quot;</code> 声明 <code>SystemCoreClock</code> 即可，避免引入不必要 HAL 依赖。</p>
<h2>第四步：用 MRS IDE 创建工程</h2>
<ol>
<li>打开 MRS IDE，创建一个 CH585M 空白工程</li>
<li>告诉 AI 代理工程路径</li>
<li>AI 代理将 <code>tensorflow/</code>、<code>third_party/</code>、<code>tflite_port/</code>、<code>src/main.cc</code>、<code>model_data.h</code> 放入工程</li>
</ol>
<p>MRS IDE 使用 Eclipse CDT 的 <code>.cproject</code> 管理编译选项。AI 代理需在 IDE 中配置以下项（或直接编辑 <code>.cproject</code> 文件）：</p>
<h3>C++ 编译选项</h3>
<table>
<thead>
<tr>
<th>配置项</th>
<th>值</th>
</tr>
</thead>
<tbody><tr>
<td>C++ 标准</td>
<td><code>gnu++17</code></td>
</tr>
<tr>
<td>异常</td>
<td><code>-fno-exceptions</code></td>
</tr>
<tr>
<td>RTTI</td>
<td><code>-fno-rtti</code></td>
</tr>
<tr>
<td>静态对象线程安全</td>
<td><code>-fno-threadsafe-statics</code></td>
</tr>
</tbody></table>
<h3>预处理器宏</h3>
<table>
<thead>
<tr>
<th>位置</th>
<th>宏</th>
<th>原因</th>
</tr>
</thead>
<tbody><tr>
<td>C++</td>
<td><code>TF_LITE_STATIC_MEMORY</code></td>
<td>禁用动态内存</td>
</tr>
<tr>
<td>C++</td>
<td><code>TF_LITE_USE_GLOBAL_CMATH_FUNCTIONS</code></td>
<td>newlib-nano 缺少 std::round</td>
</tr>
<tr>
<td>C++</td>
<td><code>TF_LITE_USE_GLOBAL_MAX</code></td>
<td>newlib-nano 缺少 std::fmax</td>
</tr>
<tr>
<td>C++</td>
<td><code>TF_LITE_USE_GLOBAL_MIN</code></td>
<td>newlib-nano 缺少 std::fmin</td>
</tr>
<tr>
<td>C</td>
<td><code>DEBUG=0</code></td>
<td>printf 输出到 UART0</td>
</tr>
</tbody></table>
<p><code>DEBUG=0</code> 决定 printf 走哪个串口。<code>CH58x_sys.c</code> 中的 <code>_write()</code> 用 <code>#if DEBUG == Debug_UART0</code> 选择 UART0（PA14/PA15），这是板子上唯一引出的串口。</p>
<h3>include 路径</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>${workspace_loc:/${ProjName}}</span></span>
<span class="line"><span>${workspace_loc:/${ProjName}/StdPeriphDriver/inc}</span></span>
<span class="line"><span>${workspace_loc:/${ProjName}/RVMSIS}</span></span>
<span class="line"><span>${workspace_loc:/${ProjName}/third_party}</span></span>
<span class="line"><span>${workspace_loc:/${ProjName}/third_party/gemmlowp}</span></span>
<span class="line"><span>${workspace_loc:/${ProjName}/third_party/flatbuffers/include}</span></span>
<span class="line"><span>${workspace_loc:/${ProjName}/third_party/ruy}</span></span></code></pre><h3>链接器</h3>
<ul>
<li>链接库：<code>-lISP585 -lstdc++</code></li>
<li>链接脚本：<code>Ld/Link.ld</code></li>
<li><strong>关键</strong>：C 和 C++ 链接器都勾选 &quot;Use float with nano printf&quot;（<code>-u _printf_float</code>）。newlib-nano 默认禁用 <code>%f</code> 格式化，不开启的话串口只输出空白。</li>
</ul>
<h2>第三方依赖</h2>
<p>三组外部库全部由 <code>create_tflm_tree.py</code> 自动生成，只需添加 include 路径，不编译任何源码：</p>
<table>
<thead>
<tr>
<th>库</th>
<th>用途</th>
<th>路径</th>
</tr>
</thead>
<tbody><tr>
<td>FlatBuffers</td>
<td>解析 <code>.tflite</code> 模型文件</td>
<td><code>third_party/flatbuffers/include/</code></td>
</tr>
<tr>
<td>gemmlowp</td>
<td>定点运算模板</td>
<td><code>third_party/gemmlowp/</code></td>
</tr>
<tr>
<td>ruy</td>
<td>profiling（<code>ScopeLabel</code> 空类）</td>
<td><code>third_party/ruy/</code></td>
</tr>
</tbody></table>
<p>ruy 的 <code>instrumentation.h</code> 自带 <code>#ifdef RUY_PROFILER</code> 分支，未定义该宏时回退到空类，开箱即用。</p>
<h2>Demo 主程序</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "CH58x_common.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/micro_interpreter.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/micro_mutable_op_resolver.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/system_setup.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/schema/schema_generated.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "model_data.h"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">namespace</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">using</span><span style="color:#4EC9B0"> OpResolver</span><span style="color:#D4D4D4"> = </span><span style="color:#4EC9B0">tflite</span><span style="color:#D4D4D4">::</span><span style="color:#4EC9B0">MicroMutableOpResolver</span><span style="color:#D4D4D4">&#x3C;</span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">>;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">TfLiteStatus</span><span style="color:#DCDCAA"> RegisterOps</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">OpResolver</span><span style="color:#569CD6">&#x26;</span><span style="color:#9CDCFE"> op_resolver</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">  TF_LITE_ENSURE_STATUS</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">op_resolver</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AddFullyConnected</span><span style="color:#D4D4D4">());</span></span>
<span class="line"><span style="color:#DCDCAA">  TF_LITE_ENSURE_STATUS</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">op_resolver</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AddTanh</span><span style="color:#D4D4D4">());</span></span>
<span class="line"><span style="color:#DCDCAA">  TF_LITE_ENSURE_STATUS</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">op_resolver</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AddReshape</span><span style="color:#D4D4D4">());</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#D4D4D4"> kTfLiteOk;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">constexpr</span><span style="color:#569CD6"> int</span><span style="color:#D4D4D4"> kTensorArenaSize = </span><span style="color:#B5CEA8">8</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">1024</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint8_t</span><span style="color:#9CDCFE"> tensor_arena</span><span style="color:#D4D4D4">[kTensorArenaSize] </span><span style="color:#DCDCAA">__attribute__</span><span style="color:#D4D4D4">((</span><span style="color:#DCDCAA">aligned</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">)));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> RunInference</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4EC9B0"> tflite</span><span style="color:#D4D4D4">::Model* model = ::</span><span style="color:#4EC9B0">tflite</span><span style="color:#D4D4D4">::</span><span style="color:#DCDCAA">GetModel</span><span style="color:#D4D4D4">(g_model_data);</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">model</span><span style="color:#D4D4D4">-></span><span style="color:#DCDCAA">version</span><span style="color:#D4D4D4">() != TFLITE_SCHEMA_VERSION) {</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"Bad model version</span><span style="color:#D7BA7D">\r\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">  OpResolver op_resolver;</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">RegisterOps</span><span style="color:#D4D4D4">(op_resolver) != kTfLiteOk) {</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"Op register fail</span><span style="color:#D7BA7D">\r\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">  tflite</span><span style="color:#D4D4D4">::MicroInterpreter </span><span style="color:#DCDCAA">interpreter</span><span style="color:#D4D4D4">(model, op_resolver,</span></span>
<span class="line"><span style="color:#D4D4D4">                                       tensor_arena, kTensorArenaSize);</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AllocateTensors</span><span style="color:#D4D4D4">() != kTfLiteOk) {</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"Alloc fail</span><span style="color:#D7BA7D">\r\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">  TfLiteTensor* input  = </span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">input</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">  TfLiteTensor* output = </span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">output</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">  printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\r\n</span><span style="color:#CE9178">=== TFLite sin demo ===</span><span style="color:#D7BA7D">\r\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">  float</span><span style="color:#D4D4D4"> test_x[] = {</span><span style="color:#B5CEA8">0.0f</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0.5f</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">1.0f</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">1.5f</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">2.0f</span><span style="color:#D4D4D4">, -</span><span style="color:#B5CEA8">1.0f</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">3.14f</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#C586C0">  for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; </span><span style="color:#B5CEA8">7</span><span style="color:#D4D4D4">; i++) {</span></span>
<span class="line"><span style="color:#9CDCFE">    input</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">f</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">] = </span><span style="color:#9CDCFE">test_x</span><span style="color:#D4D4D4">[i];</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">Invoke</span><span style="color:#D4D4D4">() != kTfLiteOk) {</span></span>
<span class="line"><span style="color:#DCDCAA">      printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"Invoke fail</span><span style="color:#D7BA7D">\r\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">      return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#569CD6">    float</span><span style="color:#D4D4D4"> y = </span><span style="color:#9CDCFE">output</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">f</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"x=</span><span style="color:#9CDCFE">%.2f</span><span style="color:#CE9178"> -> y=</span><span style="color:#9CDCFE">%.6f</span><span style="color:#D7BA7D">\r\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">test_x</span><span style="color:#D4D4D4">[i], y);</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">  printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"=== Done ===</span><span style="color:#D7BA7D">\r\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">}</span><span style="color:#6A9955">  // namespace</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">extern</span><span style="color:#CE9178"> "C"</span><span style="color:#569CD6"> int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#DCDCAA">  HSECFG_Capacitance</span><span style="color:#D4D4D4">(HSECap_18p);</span></span>
<span class="line"><span style="color:#DCDCAA">  SetSysClock</span><span style="color:#D4D4D4">(SYSCLK_FREQ);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">  GPIOA_SetBits</span><span style="color:#D4D4D4">(GPIO_Pin_14);</span></span>
<span class="line"><span style="color:#DCDCAA">  GPIOPinRemap</span><span style="color:#D4D4D4">(ENABLE, RB_PIN_UART0);</span></span>
<span class="line"><span style="color:#DCDCAA">  GPIOA_ModeCfg</span><span style="color:#D4D4D4">(GPIO_Pin_15, GPIO_ModeIN_PU);</span></span>
<span class="line"><span style="color:#DCDCAA">  GPIOA_ModeCfg</span><span style="color:#D4D4D4">(GPIO_Pin_14, GPIO_ModeOut_PP_5mA);</span></span>
<span class="line"><span style="color:#DCDCAA">  UART0_DefInit</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">  UART0_BaudRateCfg</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">115200</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">  DelayMs</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">1000</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">  printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"CH585M TFLite boot</span><span style="color:#D7BA7D">\r\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">  tflite</span><span style="color:#D4D4D4">::</span><span style="color:#DCDCAA">InitializeTarget</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">  RunInference</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">  while</span><span style="color:#D4D4D4"> (</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) {}</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>要点：</p>
<ul>
<li><code>MicroMutableOpResolver&lt;3&gt;</code> 模板参数在编译期确定数组大小，零堆分配</li>
<li><code>tensor_arena[8192]</code> 用 <code>__attribute__((aligned(16)))</code> 对齐</li>
<li><code>GetModel(g_model_data)</code> 不拷贝数据，只是指针 cast</li>
<li>UART0 映射到 PA14/PA15 需要通过 <code>GPIOPinRemap</code> — CH585M 的 UART0 默认不在这些引脚上</li>
</ul>
<h2>编译结果</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>   text	   data	    bss	    dec	    hex</span></span>
<span class="line"><span>  71916	    844	   8240	  81000	  13c68</span></span></code></pre><table>
<thead>
<tr>
<th>区域</th>
<th>大小</th>
<th>使用率</th>
</tr>
</thead>
<tbody><tr>
<td>Flash (448KB)</td>
<td>~72KB</td>
<td>16%</td>
</tr>
<tr>
<td>RAM (128KB)</td>
<td>~9KB</td>
<td>7%</td>
</tr>
</tbody></table>
<p>120+ 个算子内核全部编译，<code>--gc-sections</code> 自动丢弃未用代码。8KB bss 段主要是 <code>tensor_arena</code>，实际峰值使用约 3KB。</p>
<h2>踩坑记录</h2>
<ol>
<li><strong><code>flatbuffers/flatbuffers.h</code> 找不到</strong> — 脚本的 flatbuffers 多一层 <code>include/</code> 子目录。加 <code>-Ithird_party/flatbuffers/include</code>。</li>
<li><strong><code>ruy/profiler/instrumentation.h</code> 找不到</strong> — 脚本的 ruy 路径是 <code>third_party/ruy/ruy/...</code>。加 <code>-Ithird_party/ruy</code>。</li>
<li><strong><code>&#39;fmax&#39;/&#39;fmin&#39; is not a member of &#39;std&#39;</code></strong> — newlib-nano 不提供这两个函数。加宏 <code>TF_LITE_USE_GLOBAL_MAX</code> 和 <code>TF_LITE_USE_GLOBAL_MIN</code>。</li>
<li><strong><code>&#39;round&#39; is not a member of &#39;std&#39;</code></strong> — 同理，加宏 <code>TF_LITE_USE_GLOBAL_CMATH_FUNCTIONS</code>。</li>
<li><strong>串口无输出</strong> — <code>_write()</code> 通过 <code>DEBUG</code> 宏选择串口。<code>DEBUG=0</code> = UART0，<code>DEBUG=1</code> = UART1。</li>
<li><strong>串口浮点数空白</strong> — newlib-nano 默认禁用 <code>%f</code>。C 和 C++ 链接器都勾选 &quot;Use float with nano printf&quot;（<code>-u _printf_float</code>），增加约 12KB code size。</li>
<li><strong><code>FLASH_EEPROM_CMD</code> undefined</strong> — CH58x HAL 依赖 WCH ISP 库，链接加 <code>-lISP585</code>。</li>
</ol>
<h2>串口输出</h2>
<p><img src="/posts/ch585m-tflite-micro-sin-demo/PixPin_2026-07-05_07-43-20.webp" alt="串口输出截图"></p>
<p>sin(0) ≈ 0, sin(1) ≈ 0.84, sin(3.14) ≈ 0，7 个测试点全部正确。</p>
<h2>移植成本</h2>
<p>TFLite Micro 的架构决定了移植工作量极小：</p>
<ol>
<li><strong>文件获取</strong>：<code>create_tflm_tree.py</code> 一把生成，AI 代理通过 <code>gh codespace</code> 全自动完成</li>
<li><strong>集成</strong>：删 9 个冲突文件，加 2 个 include 路径、4 个编译宏、<code>-u _printf_float</code></li>
<li><strong>手写代码</strong>：只有 3 个平台抽象函数（共约 30 行）+ main.cc</li>
<li><strong>算子</strong>：不需要手动精简，<code>--gc-sections</code> 自动丢弃未用代码</li>
<li><strong>内存模型</strong>：静态 arena + GreedyMemoryPlanner，零动态分配</li>
</ol>
<p>换模型只需改 <code>RegisterOps()</code> 添加对应算子注册，其余不变。</p>]]></content>
    <category term="TFLite Micro"/>
    <category term="TFLM"/>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="边缘AI"/>
    <category term="CH58x"/>
  </entry>
  <entry>
    <title>已有 .tflite 模型转 int8 量化</title>
    <link href="https://createskyblue.github.io/posts/tflite-int8-quantization-tf221/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/tflite-int8-quantization-tf221/</id>
    <published>2026-07-05T00:00:00.000Z</published>
    <updated>2026-07-05T00:00:00.000Z</updated>
    <summary>提取 .tflite 权重 → Keras 重建 → 导出 int8</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>已有 .tflite 模型转 int8 量化</h1>
<p>有一个现成的 <code>sine_calcu.tflite</code> 浮点模型（2380 字节，<a href="/posts/tflite-int8-quantization-tf221/sine_calcu_float.tflite" target="_blank" rel="noopener noreferrer">下载</a>），需要量化为 int8 给 STM32 用。打开终端，装好 TF 2.21，写下一行熟悉的代码：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">converter = tf.lite.TFLiteConverter.from_frozen_buffer(model_data, ...)</span></span></code></pre><p>报错：<code>AttributeError: type object &#39;TFLiteConverterV2&#39; has no attribute &#39;from_frozen_buffer&#39;</code>。</p>
<p>查了一圈发现 TF 2.16+ 重构了 TFLiteConverter 的 API。新的构造函数签名是 <code>TFLiteConverter(funcs, trackable_obj=None)</code>，需要 concrete functions。但手里只有一个 <code>.tflite</code> 文件——没有 SavedModel，没有 Keras 模型，也没有原始训练代码。concrete function 从哪来？</p>
<p>这篇文章记录了这个问题的解法：用 flatbuffers 解析 <code>.tflite</code> 模型的内部结构，提取所有权重张量，在 Keras 中重建一个完全相同的模型，再走标准的 Keras → int8 量化流程。</p>
<h2>第一步：用 flatbuffers 解析模型结构</h2>
<p>TensorFlow 自带 TFLite 的 flatbuffers schema（<code>tensorflow.lite.python.schema_py_generated</code>），可以直接解析 <code>.tflite</code> 文件的内部结构：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">from</span><span style="color:#D4D4D4"> tensorflow.lite.python </span><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> schema_py_generated </span><span style="color:#C586C0">as</span><span style="color:#D4D4D4"> _schema</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">with</span><span style="color:#DCDCAA"> open</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"sine_calcu.tflite"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"rb"</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">as</span><span style="color:#D4D4D4"> f:</span></span>
<span class="line"><span style="color:#D4D4D4">    model_data = f.read()</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">model = _schema.Model.GetRootAs(model_data, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">sg = model.Subgraphs(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> i </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> range</span><span style="color:#D4D4D4">(sg.OperatorsLength()):</span></span>
<span class="line"><span style="color:#D4D4D4">    op = sg.Operators(i)</span></span>
<span class="line"><span style="color:#D4D4D4">    oc = model.OperatorCodes(op.OpcodeIndex())</span></span>
<span class="line"><span style="color:#D4D4D4">    inputs = [op.Inputs(j) </span><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> j </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> range</span><span style="color:#D4D4D4">(op.InputsLength())]</span></span>
<span class="line"><span style="color:#D4D4D4">    outputs = [op.Outputs(j) </span><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> j </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> range</span><span style="color:#D4D4D4">(op.OutputsLength())]</span></span>
<span class="line"><span style="color:#DCDCAA">    print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"Op [</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">i</span><span style="color:#569CD6">}</span><span style="color:#CE9178">]: builtin_code=</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">oc.BuiltinCode()</span><span style="color:#569CD6">}</span><span style="color:#CE9178">, "</span></span>
<span class="line"><span style="color:#569CD6">          f</span><span style="color:#CE9178">"inputs=</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">inputs</span><span style="color:#569CD6">}</span><span style="color:#CE9178">, outputs=</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">outputs</span><span style="color:#569CD6">}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span></code></pre><p>输出：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Op [0]: builtin_code=9, inputs=[0, 5, 6], outputs=[7]    ← FULLY_CONNECTED</span></span>
<span class="line"><span>Op [1]: builtin_code=28, inputs=[7], outputs=[8]         ← TANH</span></span>
<span class="line"><span>Op [2]: builtin_code=9, inputs=[8, 4, 2], outputs=[9]    ← FULLY_CONNECTED</span></span>
<span class="line"><span>Op [3]: builtin_code=28, inputs=[9], outputs=[10]        ← TANH</span></span>
<span class="line"><span>Op [4]: builtin_code=9, inputs=[10, 3, 1], outputs=[11]  ← FULLY_CONNECTED</span></span></code></pre><p>模型结构一目了然：<code>Dense(1→10) → Tanh → Dense(10→5) → Tanh → Dense(5→1)</code>，一个三层 MLP。每个 tensor 的名称和 shape 也都能从 flatbuffer 中读到。</p>
<h2>第二步：提取权重张量</h2>
<p>所有权重都存储在 flatbuffer 的 buffer 区。根据 tensor 的名称和索引关系，定位到 6 个权重张量：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">tensor_data = {}</span></span>
<span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> i </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> range</span><span style="color:#D4D4D4">(sg.TensorsLength()):</span></span>
<span class="line"><span style="color:#D4D4D4">    t = sg.Tensors(i)</span></span>
<span class="line"><span style="color:#D4D4D4">    name = t.Name().decode() </span><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> t.Name() </span><span style="color:#C586C0">else</span><span style="color:#569CD6"> f</span><span style="color:#CE9178">"tensor_</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">i</span><span style="color:#569CD6">}</span><span style="color:#CE9178">"</span></span>
<span class="line"><span style="color:#D4D4D4">    buf = model.Buffers(t.Buffer())</span></span>
<span class="line"><span style="color:#D4D4D4">    data_len = buf.DataLength()</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> data_len > </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#D4D4D4">        shape = [t.Shape(j) </span><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> j </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> range</span><span style="color:#D4D4D4">(t.ShapeLength())]</span></span>
<span class="line"><span style="color:#D4D4D4">        raw_bytes = buf.DataAsNumpy()</span></span>
<span class="line"><span style="color:#D4D4D4">        arr = np.frombuffer(raw_bytes, </span><span style="color:#9CDCFE">dtype</span><span style="color:#D4D4D4">=np.float32).reshape(shape).copy()</span></span>
<span class="line"><span style="color:#D4D4D4">        tensor_data[name] = arr</span></span></code></pre><p>提取结果：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>sequential_1/dense_1/MatMul   : [10, 1]   ← Layer 1 kernel</span></span>
<span class="line"><span>sequential_1/dense_1/BiasAdd  : [10]      ← Layer 1 bias</span></span>
<span class="line"><span>arith.constant3               : [5, 10]   ← Layer 2 kernel</span></span>
<span class="line"><span>arith.constant1               : [5]       ← Layer 2 bias</span></span>
<span class="line"><span>arith.constant2               : [1, 5]    ← Layer 3 kernel</span></span>
<span class="line"><span>arith.constant                : [1]       ← Layer 3 bias</span></span></code></pre><p>注意 TFLite 中 FC 层的权重 shape 是 <code>[out_features, in_features]</code>，Keras 的 Dense 是 <code>[in_features, out_features]</code>，赋值时需要 transpose。</p>
<h2>第三步：在 Keras 中重建模型</h2>
<p>用提取的权重构建一个完全相同的 Keras 模型：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">model_keras = tf.keras.Sequential([</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">10</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">activation</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'tanh'</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">input_shape</span><span style="color:#D4D4D4">=(</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">,)),</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">5</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">activation</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'tanh'</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">])</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># Build</span></span>
<span class="line"><span style="color:#D4D4D4">_ = model_keras(np.array([[</span><span style="color:#B5CEA8">0.0</span><span style="color:#D4D4D4">]], </span><span style="color:#9CDCFE">dtype</span><span style="color:#D4D4D4">=np.float32))</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># Set weights — 注意 transpose</span></span>
<span class="line"><span style="color:#D4D4D4">model_keras.layers[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">].set_weights([w1.T, b1])</span></span>
<span class="line"><span style="color:#D4D4D4">model_keras.layers[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">].set_weights([w2.T, b2])</span></span>
<span class="line"><span style="color:#D4D4D4">model_keras.layers[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">].set_weights([w3.T, b3])</span></span></code></pre><p>验证 float 精度——与原 <code>.tflite</code> 模型对比：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>x=-2.00  keras=+0.643296  tflite=+0.643297  OK</span></span>
<span class="line"><span>x=-1.00  keras=+0.243617  tflite=+0.243617  OK</span></span>
<span class="line"><span>x=+0.00  keras=+0.000132  tflite=+0.000133  OK</span></span>
<span class="line"><span>x=+0.50  keras=-0.001474  tflite=-0.001474  OK</span></span>
<span class="line"><span>x=+1.00  keras=+0.014659  tflite=+0.014659  OK</span></span>
<span class="line"><span>x=+2.00  keras=+0.038101  tflite=+0.038101  OK</span></span></code></pre><p>六个测试点误差在 1e-6 级别，权重提取和重建完全正确。</p>
<h2>第四步：Full Int8 量化</h2>
<p>现在有了 Keras 模型，就可以走标准的 TF 量化流程：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">def</span><span style="color:#DCDCAA"> representative_dataset</span><span style="color:#D4D4D4">():</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> _ </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> range</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">500</span><span style="color:#D4D4D4">):</span></span>
<span class="line"><span style="color:#D4D4D4">        x = np.random.uniform(-np.pi, np.pi, </span><span style="color:#9CDCFE">size</span><span style="color:#D4D4D4">=(</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">)).astype(np.float32)</span></span>
<span class="line"><span style="color:#C586C0">        yield</span><span style="color:#D4D4D4"> [x]</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">converter = tf.lite.TFLiteConverter.from_keras_model(model_keras)</span></span>
<span class="line"><span style="color:#D4D4D4">converter.optimizations = [tf.lite.Optimize.DEFAULT]</span></span>
<span class="line"><span style="color:#D4D4D4">converter.representative_dataset = representative_dataset</span></span>
<span class="line"><span style="color:#D4D4D4">converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]</span></span>
<span class="line"><span style="color:#D4D4D4">converter.inference_input_type = tf.int8</span></span>
<span class="line"><span style="color:#D4D4D4">converter.inference_output_type = tf.int8</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">tflite_quant_model = converter.convert()</span></span></code></pre><p>这里的关键参数：</p>
<ul>
<li><code>supported_ops = TFLITE_BUILTINS_INT8</code> — 要求全部算子都用 int8 内核</li>
<li><code>inference_input_type = tf.int8</code> — 输入直接使用 int8，跳过 float 输入端的隐式 Quantize</li>
<li><code>inference_output_type = tf.int8</code> — 输出也是 int8</li>
<li><code>representative_dataset</code> — 校准数据集，用于计算激活值的量化参数</li>
</ul>
<h2>量化结果</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Float model: 2380 bytes</span></span>
<span class="line"><span>Int8  model: 2992 bytes</span></span></code></pre><p>int8 模型尺寸不降反升——这个模型太小了（总共才 6 个权重张量，加起来不到 300 个 float），新增的 Quantize/Dequantize 算子和量化元数据开销超过了权重本身的压缩收益。对于真实规模的模型（几 MB 以上），int8 通常会缩小 3-4 倍。</p>
<p>量化参数：</p>
<table>
<thead>
<tr>
<th></th>
<th>Scale</th>
<th>Zero Point</th>
</tr>
</thead>
<tbody><tr>
<td>Input</td>
<td>0.02451</td>
<td>0</td>
</tr>
<tr>
<td>Output</td>
<td>0.00341</td>
<td>-126</td>
</tr>
</tbody></table>
<p>精度验证——用 int8 推理并与原 float 模型对比：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>x=+0.00  int8=+0.00683  float_ref=+0.00013  error=0.0067</span></span>
<span class="line"><span>x=+0.50  int8=+0.00683  float_ref=-0.00147  error=0.0083</span></span>
<span class="line"><span>x=+1.00  int8=+0.01024  float_ref=+0.01466  error=0.0044</span></span>
<span class="line"><span>x=+1.50  int8=+0.04096  float_ref=+0.02848  error=0.0125</span></span>
<span class="line"><span>x=-1.00  int8=+0.24919  float_ref=+0.24362  error=0.0056</span></span>
<span class="line"><span>x=+3.14  int8=+0.06145  float_ref=+0.05494  error=0.0065</span></span></code></pre><p>误差在 int8 量化的正常范围（~0.01 级别）。量化后模型的函数语义保持不变。</p>
<p>量化前后的模型文件：</p>
<ul>
<li><a href="/posts/tflite-int8-quantization-tf221/sine_calcu_float.tflite" target="_blank" rel="noopener noreferrer">sine_calcu_float.tflite</a>（2380 bytes，float32）</li>
<li><a href="/posts/tflite-int8-quantization-tf221/sine_calcu_int8.tflite" target="_blank" rel="noopener noreferrer">sine_calcu_int8.tflite</a>（2992 bytes，全 int8 量化）</li>
</ul>
<h2>这个方法能泛化吗</h2>
<p>这个流程的核心思路是 <strong>&quot;从 .tflite flatbuffer 反向工程出模型结构和权重&quot;</strong>，它适用于以下条件：</p>
<ul>
<li><strong>已知算子类型</strong>：模型只用了 TFLite 的标准内置算子（FullyConnected、Tanh、Conv2D 等），没有自定义算子</li>
<li><strong>已知激活函数</strong>：flatbuffer 中 Tanh/ReLU/Logistic 等激活函数的代码是公开的</li>
<li><strong>模型规模适中</strong>：权重张量数量在几十个以内，手动匹配 tensor 名称到 Keras 层的映射是可行的</li>
</ul>
<p>对于更复杂的模型（含有分支、跳跃连接、自定义算子），手动重建会变得困难。那种场景下，更好的做法是找到原始训练代码，或者用 TF 1.x 的 <code>tf.compat.v1.lite.TFLiteConverter.from_frozen_graph</code> 降级处理。</p>
<p>这次量化工作的本质是把一个不透明的二进制模型文件变成了可操作的 Keras 对象。flatbuffers 在这里扮演了&quot;解包器&quot;的角色——即便 TFLite 的序列化格式是 Protobuf 或其他格式，思路一样：解析序列化格式，提取数据，重建计算图。</p>]]></content>
    <category term="TFLite"/>
    <category term="TFLM"/>
    <category term="模型量化"/>
    <category term="嵌入式"/>
    <category term="边缘AI"/>
    <category term="Python"/>
  </entry>
  <entry>
    <title>TFLite Micro 的 GCC 模拟移植</title>
    <link href="https://createskyblue.github.io/posts/tflite-mcu-gcc-port/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/tflite-mcu-gcc-port/</id>
    <published>2026-07-05T00:00:00.000Z</published>
    <updated>2026-07-05T00:00:00.000Z</updated>
    <summary>脱离 OS 和 malloc，用 GCC 把 TFLite Micro 跑起来</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>TFLite Micro 的 GCC 模拟移植</h1>
<p>在单片机上跑 AI 推理是一个越来越常见的需求——设备端处理意味着低延迟、无需网络、数据隐私可控。TensorFlow Lite Micro (TFLM) 就是为此设计的：它是 TensorFlow Lite 的微控制器分支，可以在只有几十 KB RAM 的芯片上运行神经网络。</p>
<p>但 TFLM 的官方构建依赖 Bazel 和 Makefile，目标平台是 ARM Cortex-M 配合嵌入式工具链。如果我想在 Windows 上用 GCC 先跑通一遍，理解它的内部结构，需要做什么？</p>
<p>这篇文章记录了一次&quot;模拟移植&quot;过程：用 MinGW GCC 编译器，在 Windows 上把 TFLite Micro 编译成一个独立可执行文件，用静态内存池替代 malloc，用嵌入 C 数组替代文件系统，最终跑通 hello_world 模型的 sin(x) 推理。</p>
<h2>移植目标与约束</h2>
<p>真实 MCU 环境有几个硬约束：</p>
<table>
<thead>
<tr>
<th>约束</th>
<th>MCU 实际情况</th>
<th>模拟方式</th>
</tr>
</thead>
<tbody><tr>
<td>无操作系统</td>
<td>裸机或 RTOS，无 POSIX</td>
<td>只用 <code>&lt;cstdio&gt;</code>、<code>&lt;cmath&gt;</code>、<code>&lt;ctime&gt;</code></td>
</tr>
<tr>
<td>无动态内存</td>
<td>没有 malloc/free，用静态 buffer</td>
<td><code>uint8_t tensor_arena[3000]</code> 静态数组</td>
</tr>
<tr>
<td>无文件系统</td>
<td>模型不能从文件加载</td>
<td>模型转 C 字节数组嵌入代码</td>
</tr>
<tr>
<td>无异常/RTTI</td>
<td>C++ 异常和 RTTI 开销太大</td>
<td><code>-fno-exceptions -fno-rtti</code></td>
</tr>
<tr>
<td>代码体积敏感</td>
<td>Flash 通常只有几百 KB</td>
<td><code>-ffunction-sections -Wl,--gc-sections</code></td>
</tr>
</tbody></table>
<p>编译器用 MinGW-w64 自带的 GCC 8.1.0，项目目录结构如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>port/mcu/</span></span>
<span class="line"><span>├── platform/              # 平台抽象层（3个文件）</span></span>
<span class="line"><span>│   ├── mcu_debug_log.cc   # 日志输出 → vfprintf(stderr)</span></span>
<span class="line"><span>│   ├── mcu_system_setup.cc # 系统初始化 → 空函数</span></span>
<span class="line"><span>│   └── mcu_micro_time.cc  # 计时器 → clock()</span></span>
<span class="line"><span>├── demo/</span></span>
<span class="line"><span>│   ├── mcu_demo.cc        # 主程序</span></span>
<span class="line"><span>│   └── model_data.h       # hello_world 模型嵌入</span></span>
<span class="line"><span>├── mcu_config.h           # 编译配置</span></span>
<span class="line"><span>├── Makefile / build.ps1   # 构建脚本</span></span>
<span class="line"><span>└── build/</span></span>
<span class="line"><span>    └── mcu_demo.exe       # 最终产物 290KB</span></span></code></pre><h2>平台抽象层：三个函数打通 TFLM 与裸机</h2>
<p>TFLite Micro 的设计把平台相关的部分抽象成了几个函数，移植时只需要实现它们。核心有三个：</p>
<p><strong>DebugLog</strong> — TFLM 内部所有日志输出都走这个函数。在真实 MCU 上它应该写 UART；在模拟环境里直接用 <code>vfprintf(stderr, ...)</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">extern</span><span style="color:#CE9178"> "C"</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> DebugLog</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4">* format, va_list args) {</span></span>
<span class="line"><span style="color:#DCDCAA">  vfprintf</span><span style="color:#D4D4D4">(stderr, format, args);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><strong>InitializeTarget</strong> — 芯片上电后的初始化入口（配时钟、配 GPIO 等）。模拟环境不需要做任何事：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> InitializeTarget</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#6A9955">  // 真实 MCU：配置系统时钟、GPIO、UART 等</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><strong>micro_time</strong> — 提供 tick 计数器，用于性能分析。模拟环境用 <code>clock()</code>，真实 MCU 用硬件定时器（SysTick）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">uint32_t</span><span style="color:#DCDCAA"> ticks_per_second</span><span style="color:#D4D4D4">() { </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> CLOCKS_PER_SEC; }</span></span>
<span class="line"><span style="color:#569CD6">uint32_t</span><span style="color:#DCDCAA"> GetCurrentTimeTicks</span><span style="color:#D4D4D4">() { </span><span style="color:#C586C0">return</span><span style="color:#569CD6"> static_cast</span><span style="color:#D4D4D4">&#x3C;</span><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4">>(</span><span style="color:#DCDCAA">clock</span><span style="color:#D4D4D4">()); }</span></span></code></pre><p>这三个文件替换了 TFLM 源码中的默认实现，其余 TFLM 代码不需要任何修改。</p>
<h2>依赖管理：flatbuffers、gemmlowp、ruy</h2>
<p>TFLM 有三层外部依赖：</p>
<p><strong>FlatBuffers</strong> — 模型解析。TFLite 模型（<code>.tflite</code> 文件）是 FlatBuffers 格式的序列化数据。TFLM 用 <code>schema_generated.h</code>（由 FlatBuffers 编译器生成）来解析模型结构。需要下载 FlatBuffers 库（约 3MB 的头文件），提供 <code>flatbuffers/flatbuffers.h</code> 的 include 路径。</p>
<p><strong>gemmlowp</strong> — 定点运算。量化推理（int8）需要定点乘法、饱和运算等。TFLM 的 <code>common.h</code> 大量使用 <code>gemmlowp::FixedPoint</code> 模板类和配套的算术函数。这个库很小，只有头文件。</p>
<p><strong>ruy</strong> — 矩阵乘法优化库。TFLM 的参考内核只引用了它的 profiler 头文件（<code>ruy/profiler/instrumentation.h</code>），实际不调用任何 ruy 函数。写一个空桩即可：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">namespace</span><span style="color:#4EC9B0"> ruy</span><span style="color:#D4D4D4"> { </span><span style="color:#569CD6">namespace</span><span style="color:#4EC9B0"> profiler</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">inline</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> ScopeLabel</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> char*</span><span style="color:#D4D4D4">) {}</span></span>
<span class="line"><span style="color:#D4D4D4">}}</span></span></code></pre><h2>静态内存：Arena 分配器</h2>
<p>TFLM 不使用 malloc。推理所需的所有内存——输入输出 tensor、中间计算结果、算子参数——全部从一个静态字节数组中分配：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">constexpr</span><span style="color:#569CD6"> int</span><span style="color:#D4D4D4"> kTensorArenaSize = </span><span style="color:#B5CEA8">3000</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#9CDCFE"> tensor_arena</span><span style="color:#D4D4D4">[kTensorArenaSize];</span><span style="color:#6A9955">  // 静态数组，放在 .bss 段</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">tflite</span><span style="color:#D4D4D4">::</span><span style="color:#4EC9B0">MicroInterpreter</span><span style="color:#DCDCAA"> interpreter</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">model</span><span style="color:#D4D4D4">, </span><span style="color:#4EC9B0">op_resolver</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#4EC9B0">                                     tensor_arena</span><span style="color:#D4D4D4">, </span><span style="color:#4EC9B0">kTensorArenaSize</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AllocateTensors</span><span style="color:#D4D4D4">();</span><span style="color:#6A9955">  // 从 arena 中分配所有 tensor</span></span></code></pre><p>Arena 内部做了几层抽象：</p>
<ul>
<li><strong>MemoryPlanner</strong> — 规划 buffer 的生命周期，决定哪些 tensor 可以复用同一块内存。默认使用 GreedyMemoryPlanner，离线计算最优复用方案。</li>
<li><strong>BufferAllocator</strong> — 执行实际的内存分配。有三种：<code>SingleArenaBufferAllocator</code>（可复用临时 buffer）、<code>PersistentArenaBufferAllocator</code>（持久分配的权重/参数）、<code>NonPersistentArenaBufferAllocator</code>（仅单次推理有效的 scratch buffer）。</li>
</ul>
<p>整个分配过程发生在 <code>AllocateTensors()</code> 中，推理时不再有任何内存操作。</p>
<h2>模型嵌入：.tflite 转 C 数组</h2>
<p>没有文件系统，模型文件需要以 C 字节数组的形式嵌入代码。用 PowerShell 把 hello_world 模型（3164 字节）转换：</p>
<pre><code>$bytes = [System.IO.File]::ReadAllBytes("hello_world_float.tflite")
# 输出 unsigned char g_model_data[] = {0x1C, 0x00, ...};</code></pre><p>然后在代码中直接使用：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "model_data.h"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">const</span><span style="color:#4EC9B0"> tflite</span><span style="color:#D4D4D4">::Model* model = ::</span><span style="color:#4EC9B0">tflite</span><span style="color:#D4D4D4">::</span><span style="color:#DCDCAA">GetModel</span><span style="color:#D4D4D4">(g_model_data);</span></span></code></pre><p><code>GetModel()</code> 不拷贝数据——它只是把指针 cast 成 FlatBuffers 的 Model 结构体，零开销。</p>
<h2>算子注册：只注册需要的</h2>
<p>TFLM 的 <code>MicroMutableOpResolver</code> 采用模板参数指定最大算子数量，所有注册信息放在栈上，不触发堆分配：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">using</span><span style="color:#4EC9B0"> DemoOpResolver</span><span style="color:#D4D4D4"> = </span><span style="color:#4EC9B0">tflite</span><span style="color:#D4D4D4">::</span><span style="color:#4EC9B0">MicroMutableOpResolver</span><span style="color:#D4D4D4">&#x3C;</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">>;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">TfLiteStatus</span><span style="color:#DCDCAA"> RegisterOps</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">DemoOpResolver</span><span style="color:#569CD6">&#x26;</span><span style="color:#9CDCFE"> op_resolver</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">  TF_LITE_ENSURE_STATUS</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">op_resolver</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AddFullyConnected</span><span style="color:#D4D4D4">());</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#D4D4D4"> kTfLiteOk;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>hello_world 模型只有一个 FullyConnected 算子（后面接 ReLU 激活，但 ReLU 被融合到 FullyConnected 的激活参数中，不需要单独注册）。链接器会把没用到的算子代码全部 strip 掉。</p>
<h2>编译链接：41 个目标文件</h2>
<p>最终要编译的源文件分布在几个模块中：</p>
<table>
<thead>
<tr>
<th>模块</th>
<th>文件数</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>Core</td>
<td>16</td>
<td>micro_interpreter、micro_allocator、micro_op_resolver 等</td>
</tr>
<tr>
<td>Arena</td>
<td>3</td>
<td>single/persistent/non_persistent 三种 allocator</td>
</tr>
<tr>
<td>MemoryPlanner</td>
<td>3</td>
<td>greedy、linear planner 及 shim</td>
</tr>
<tr>
<td>Kernels</td>
<td>7</td>
<td>fully_connected、reshape、kernel_util、micro_tensor_utils、lite kernel_util</td>
</tr>
<tr>
<td>API</td>
<td>5</td>
<td>flatbuffer_conversions、schema_utils、common、error_reporter、array</td>
</tr>
<tr>
<td>Internal</td>
<td>4</td>
<td>quantization_util、portable_tensor_utils、common、tensor_ctypes</td>
</tr>
<tr>
<td>Platform</td>
<td>3</td>
<td>我们的平台抽象层</td>
</tr>
<tr>
<td>Demo</td>
<td>1</td>
<td>主程序</td>
</tr>
</tbody></table>
<p>编译命令的核心参数：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">g++</span><span style="color:#569CD6"> -std=c++17</span><span style="color:#D7BA7D"> \</span></span>
<span class="line"><span style="color:#569CD6">    -fno-exceptions</span><span style="color:#569CD6"> -fno-rtti</span><span style="color:#D7BA7D"> \</span></span>
<span class="line"><span style="color:#569CD6">    -ffunction-sections</span><span style="color:#569CD6"> -fdata-sections</span><span style="color:#D7BA7D"> \</span></span>
<span class="line"><span style="color:#569CD6">    -DTF_LITE_USE_CTIME</span><span style="color:#D7BA7D"> \</span></span>
<span class="line"><span style="color:#569CD6">    -O2</span><span style="color:#D7BA7D"> \</span></span>
<span class="line"><span style="color:#D4D4D4">    [41个.cc文件] </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#DCDCAA">    -Wl,--gc-sections</span><span style="color:#569CD6"> -lm</span><span style="color:#D7BA7D"> \</span></span>
<span class="line"><span style="color:#569CD6">    -o</span><span style="color:#CE9178"> mcu_demo.exe</span></span></code></pre><p>链接后得到 290KB 的可执行文件。如果设置 <code>-Os</code> 并 strip 符号表，还能更小。</p>
<h2>完整 Demo 代码</h2>
<p>把前面各节的内容串起来，就是一个完整的推理程序。这个 demo 直接编译即可运行，不依赖任何外部文件（模型已嵌入），不触发堆分配（全部栈上或静态区）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// mcu_demo.cc — TFLite Micro MCU Port Demo</span></span>
<span class="line"><span style="color:#6A9955">// 模型: y = sin(x), 算子: FullyConnected + ReLU</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;cstdio></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;cmath></span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/core/c/common.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/micro_interpreter.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/micro_log.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/micro_mutable_op_resolver.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/micro_profiler.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/system_setup.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/schema/schema_generated.h"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "model_data.h"</span><span style="color:#6A9955">  // g_model_data[] 字节数组</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">namespace</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">using</span><span style="color:#4EC9B0"> DemoOpResolver</span><span style="color:#D4D4D4"> = </span><span style="color:#4EC9B0">tflite</span><span style="color:#D4D4D4">::</span><span style="color:#4EC9B0">MicroMutableOpResolver</span><span style="color:#D4D4D4">&#x3C;</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">>;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">TfLiteStatus</span><span style="color:#DCDCAA"> RegisterOps</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">DemoOpResolver</span><span style="color:#569CD6">&#x26;</span><span style="color:#9CDCFE"> op_resolver</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">  TF_LITE_ENSURE_STATUS</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">op_resolver</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AddFullyConnected</span><span style="color:#D4D4D4">());</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#D4D4D4"> kTfLiteOk;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// Test 1: 浮点模型推理, 验证 sin(x) 拟合精度</span></span>
<span class="line"><span style="color:#4EC9B0">TfLiteStatus</span><span style="color:#DCDCAA"> RunFloatModelInference</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#DCDCAA">  MicroPrintf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">=== Test 1: Float Model Inference ==="</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4EC9B0"> tflite</span><span style="color:#D4D4D4">::Model* model = ::</span><span style="color:#4EC9B0">tflite</span><span style="color:#D4D4D4">::</span><span style="color:#DCDCAA">GetModel</span><span style="color:#D4D4D4">(g_model_data);</span></span>
<span class="line"><span style="color:#DCDCAA">  TFLITE_CHECK_EQ</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">model</span><span style="color:#D4D4D4">-></span><span style="color:#DCDCAA">version</span><span style="color:#D4D4D4">(), TFLITE_SCHEMA_VERSION);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">  DemoOpResolver op_resolver;</span></span>
<span class="line"><span style="color:#DCDCAA">  TF_LITE_ENSURE_STATUS</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">RegisterOps</span><span style="color:#D4D4D4">(op_resolver));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">  // MCU 关键: 静态 arena, 不调 malloc</span></span>
<span class="line"><span style="color:#569CD6">  constexpr</span><span style="color:#569CD6"> int</span><span style="color:#D4D4D4"> kTensorArenaSize = </span><span style="color:#B5CEA8">3000</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">  static</span><span style="color:#569CD6"> uint8_t</span><span style="color:#9CDCFE"> tensor_arena</span><span style="color:#D4D4D4">[kTensorArenaSize];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">  tflite</span><span style="color:#D4D4D4">::MicroInterpreter </span><span style="color:#DCDCAA">interpreter</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#D4D4D4">      model, op_resolver, tensor_arena, kTensorArenaSize);</span></span>
<span class="line"><span style="color:#DCDCAA">  TF_LITE_ENSURE_STATUS</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AllocateTensors</span><span style="color:#D4D4D4">());</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">  TfLiteTensor* input  = </span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">input</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">  TfLiteTensor* output = </span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">output</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">  constexpr</span><span style="color:#569CD6"> int</span><span style="color:#D4D4D4"> kNumTests = </span><span style="color:#B5CEA8">7</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#569CD6"> float</span><span style="color:#9CDCFE"> test_inputs</span><span style="color:#D4D4D4">[kNumTests] =</span></span>
<span class="line"><span style="color:#D4D4D4">      {</span><span style="color:#B5CEA8">0.0f</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0.5f</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">1.0f</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">1.5f</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">2.0f</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">3.0f</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">5.0f</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#569CD6"> float</span><span style="color:#D4D4D4"> epsilon = </span><span style="color:#B5CEA8">0.10f</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">  int</span><span style="color:#D4D4D4"> passed = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">  for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; kNumTests; ++i) {</span></span>
<span class="line"><span style="color:#9CDCFE">    input</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">f</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">] = </span><span style="color:#9CDCFE">test_inputs</span><span style="color:#D4D4D4">[i];</span></span>
<span class="line"><span style="color:#DCDCAA">    TF_LITE_ENSURE_STATUS</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">Invoke</span><span style="color:#D4D4D4">());</span></span>
<span class="line"><span style="color:#569CD6">    float</span><span style="color:#D4D4D4"> y_pred = </span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">output</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">)-></span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">f</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#569CD6">    float</span><span style="color:#D4D4D4"> y_true = </span><span style="color:#DCDCAA">sinf</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">test_inputs</span><span style="color:#D4D4D4">[i]);</span></span>
<span class="line"><span style="color:#569CD6">    float</span><span style="color:#D4D4D4"> error  = </span><span style="color:#DCDCAA">fabsf</span><span style="color:#D4D4D4">(y_true - y_pred);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (error &#x3C; epsilon) ++passed;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    MicroPrintf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"  sin(</span><span style="color:#9CDCFE">%5.2f</span><span style="color:#CE9178">) = </span><span style="color:#9CDCFE">%+7.4f</span><span style="color:#CE9178">  |  pred = </span><span style="color:#9CDCFE">%+7.4f</span><span style="color:#CE9178">  |  err = </span><span style="color:#9CDCFE">%6.4f</span><span style="color:#9CDCFE">  %s</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">                test_inputs</span><span style="color:#D4D4D4">[i], y_true, y_pred, error,</span></span>
<span class="line"><span style="color:#D4D4D4">                error &#x3C; epsilon ? </span><span style="color:#CE9178">"OK"</span><span style="color:#D4D4D4"> : </span><span style="color:#CE9178">"FAIL"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#D4D4D4"> (passed == kNumTests) ? kTfLiteOk : kTfLiteError;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// Test 2: 1000 次连续推理, 验证 arena 内存复用稳定性</span></span>
<span class="line"><span style="color:#4EC9B0">TfLiteStatus</span><span style="color:#DCDCAA"> RunMultipleInvocations</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4EC9B0"> tflite</span><span style="color:#D4D4D4">::Model* model = ::</span><span style="color:#4EC9B0">tflite</span><span style="color:#D4D4D4">::</span><span style="color:#DCDCAA">GetModel</span><span style="color:#D4D4D4">(g_model_data);</span></span>
<span class="line"><span style="color:#D4D4D4">  DemoOpResolver op_resolver;</span></span>
<span class="line"><span style="color:#DCDCAA">  TF_LITE_ENSURE_STATUS</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">RegisterOps</span><span style="color:#D4D4D4">(op_resolver));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">  constexpr</span><span style="color:#569CD6"> int</span><span style="color:#D4D4D4"> kTensorArenaSize = </span><span style="color:#B5CEA8">3000</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">  static</span><span style="color:#569CD6"> uint8_t</span><span style="color:#9CDCFE"> tensor_arena</span><span style="color:#D4D4D4">[kTensorArenaSize];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">  tflite</span><span style="color:#D4D4D4">::MicroInterpreter </span><span style="color:#DCDCAA">interpreter</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#D4D4D4">      model, op_resolver, tensor_arena, kTensorArenaSize);</span></span>
<span class="line"><span style="color:#DCDCAA">  TF_LITE_ENSURE_STATUS</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AllocateTensors</span><span style="color:#D4D4D4">());</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">  constexpr</span><span style="color:#569CD6"> int</span><span style="color:#D4D4D4"> kIters = </span><span style="color:#B5CEA8">1000</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">  volatile</span><span style="color:#569CD6"> float</span><span style="color:#D4D4D4"> sum = </span><span style="color:#B5CEA8">0.0f</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955"> // volatile 防编译器优化</span></span>
<span class="line"><span style="color:#C586C0">  for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; kIters; ++i) {</span></span>
<span class="line"><span style="color:#9CDCFE">    interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">input</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">)-></span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">f</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">] = </span><span style="color:#569CD6">static_cast</span><span style="color:#D4D4D4">&#x3C;</span><span style="color:#569CD6">float</span><span style="color:#D4D4D4">>(i) * </span><span style="color:#B5CEA8">0.01f</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">    TF_LITE_ENSURE_STATUS</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">Invoke</span><span style="color:#D4D4D4">());</span></span>
<span class="line"><span style="color:#D4D4D4">    sum += </span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">output</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">)-></span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">f</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"><span style="color:#DCDCAA">  MicroPrintf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"  Ran </span><span style="color:#9CDCFE">%d</span><span style="color:#CE9178"> inferences, checksum: </span><span style="color:#9CDCFE">%f</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, kIters,</span></span>
<span class="line"><span style="color:#569CD6">              static_cast</span><span style="color:#D4D4D4">&#x3C;</span><span style="color:#569CD6">float</span><span style="color:#D4D4D4">>(sum));</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#D4D4D4"> kTfLiteOk;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">}</span><span style="color:#6A9955">  // namespace</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#4EC9B0">  tflite</span><span style="color:#D4D4D4">::</span><span style="color:#DCDCAA">InitializeTarget</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">  MicroPrintf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"========================================"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">  MicroPrintf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">" TFLite Micro — MCU Port Demo (GCC)"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">  MicroPrintf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">" Model: hello_world (y = sin(x))"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">  MicroPrintf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"========================================"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">RunFloatModelInference</span><span style="color:#D4D4D4">() != kTfLiteOk) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">RunMultipleInvocations</span><span style="color:#D4D4D4">() != kTfLiteOk) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">  MicroPrintf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">========================================"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">  MicroPrintf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">" ALL TESTS PASSED"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">  MicroPrintf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"========================================"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>配套的平台抽象层三个文件：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// mcu_debug_log.cc — 日志输出</span></span>
<span class="line"><span style="color:#569CD6">extern</span><span style="color:#CE9178"> "C"</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> DebugLog</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4">* format, va_list args) {</span></span>
<span class="line"><span style="color:#DCDCAA">  vfprintf</span><span style="color:#D4D4D4">(stderr, format, args);</span><span style="color:#6A9955">  // 真实 MCU 写 UART</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#569CD6">extern</span><span style="color:#CE9178"> "C"</span><span style="color:#569CD6"> int</span><span style="color:#DCDCAA"> DebugVsnprintf</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">char</span><span style="color:#D4D4D4">* buf, </span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4"> n, </span><span style="color:#569CD6">const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4">* fmt,</span></span>
<span class="line"><span style="color:#D4D4D4">                               va_list vl) {</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#DCDCAA"> vsnprintf</span><span style="color:#D4D4D4">(buf, n, fmt, vl);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// mcu_system_setup.cc — 系统初始化</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/system_setup.h"</span></span>
<span class="line"><span style="color:#569CD6">namespace</span><span style="color:#4EC9B0"> tflite</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> InitializeTarget</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#6A9955">  // 真实 MCU: 配时钟、GPIO、UART</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#D4D4D4">}</span><span style="color:#6A9955">  // namespace tflite</span></span></code></pre><pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// mcu_micro_time.cc — 计时器</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/micro_time.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;ctime></span></span>
<span class="line"><span style="color:#569CD6">namespace</span><span style="color:#4EC9B0"> tflite</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">uint32_t</span><span style="color:#DCDCAA"> ticks_per_second</span><span style="color:#D4D4D4">() { </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> CLOCKS_PER_SEC; }</span></span>
<span class="line"><span style="color:#569CD6">uint32_t</span><span style="color:#DCDCAA"> GetCurrentTimeTicks</span><span style="color:#D4D4D4">() { </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4">)</span><span style="color:#DCDCAA">clock</span><span style="color:#D4D4D4">(); }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span><span style="color:#6A9955">  // namespace tflite</span></span></code></pre><h2>推理验证</h2>
<p>Demo 跑了三个测试：</p>
<p><strong>Test 1 — 浮点模型推理</strong>：输入 7 个值（0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 5.0），对比模型输出和标准 sin(x)，误差阈值 0.10。全部通过。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>sin( 0.00) = +0.0000  |  predicted = +0.0264  |  error = 0.0264  OK</span></span>
<span class="line"><span>sin( 0.50) = +0.4794  |  predicted = +0.4540  |  error = 0.0254  OK</span></span>
<span class="line"><span>sin( 1.00) = +0.8415  |  predicted = +0.8630  |  error = 0.0216  OK</span></span>
<span class="line"><span>sin( 1.50) = +0.9975  |  predicted = +0.9816  |  error = 0.0158  OK</span></span>
<span class="line"><span>sin( 2.00) = +0.9093  |  predicted = +0.8872  |  error = 0.0221  OK</span></span>
<span class="line"><span>sin( 3.00) = +0.1411  |  predicted = +0.1276  |  error = 0.0135  OK</span></span>
<span class="line"><span>sin( 5.00) = -0.9589  |  predicted = -0.9565  |  error = 0.0024  OK</span></span>
<span class="line"><span>Result: 7 / 7 tests passed</span></span></code></pre><p><strong>Test 2 — 连续推理稳定性</strong>：循环调用 <code>Invoke()</code> 1000 次，验证 arena 内存复用没有累积问题。全部正常完成。</p>
<p><strong>Test 3 — 性能分析</strong>：MicroProfiler 记录了每个算子的 tick 数，模拟了真实 MCU 上用硬件定时器做性能 profiling 的流程。</p>
<h2>从模拟到真实 MCU</h2>
<p>GCC 模拟验证了整体链路跑得通。部署到真实芯片还有几步要走：</p>
<ul>
<li><strong>算子优化</strong>：参考内核（<code>reference/</code> 目录）是纯 C++ 的可移植实现。真实部署会用 CMSIS-NN（ARM）、Xtensa（ESP32-S3）、或 Ethos-U（Arm NPU）的优化版本替换。例如 CMSIS-NN 的 FullyConnected 使用 SIMD 指令，比参考实现快 4-5 倍。</li>
<li><strong>日志通道</strong>：<code>DebugLog</code> 改成 UART DMA 输出，避免 printf 阻塞推理时序。</li>
<li><strong>内存规划</strong>：用 <code>RecordingMicroInterpreter</code> 跑一次推理，记录实际的 arena 使用峰值，把 <code>kTensorArenaSize</code> 调到刚好够用。</li>
<li><strong>模型量化</strong>：浮点模型在 Cortex-M4 上没有 FPU 的情况下很慢。用 TFLite Converter 把模型量化成 int8，推理速度能提升一个数量级，同时模型体积减半。</li>
</ul>
<p>这次模拟移植的核心收获：TFLite Micro 的架构设计让移植到新平台非常轻量——只需要实现 3 个平台函数、提供 3 个第三方库的头文件路径、配好编译器 flag。其余代码全部是平台无关的 C++。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="边缘AI"/>
    <category term="TFLite Micro"/>
    <category term="TFLM"/>
    <category term="构建系统"/>
    <category term="Windows"/>
  </entry>
  <entry>
    <title>6000 点加速度数据 → 100 维特征 → 5KB int8 模型，完整部署到 MCU</title>
    <link href="https://createskyblue.github.io/posts/tiny-ml-vibration-tflm/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/tiny-ml-vibration-tflm/</id>
    <published>2026-07-05T00:00:00.000Z</published>
    <updated>2026-07-05T00:00:00.000Z</updated>
    <summary>用 TFLite Micro 在 MCU 上跑震动异常分类：从训练到部署</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>6000 点加速度数据 → 100 维特征 → 5KB int8 模型，完整部署到 MCU</h1>
<p>目标硬件是一块 Cortex-M 级别的 MCU，只有 64KB Flash 和 64KB RAM。要在上面跑一个程序：输入加速度传感器采集的 6000 个连续采样点，输出&quot;这是震动A、震动B、还是震动C&quot;。</p>
<p>这篇文章走一遍完整过程：怎么把原始震动数据变成 MCU 能理解的数字，怎么训练一个足够小的模型，怎么把它压缩到 int8 塞进 MCU，以及怎么用 C++ 写出和 Python 训练时精度一致的推理代码。</p>
<p>先看一眼整条链路在做什么：</p>
<p><img src="/posts/tiny-ml-vibration-tflm/pipeline.svg" alt="完整管线"></p>
<p>六个步骤，①②③ 在 Python 端完成（训练 + 导出），④⑤ 在 C++ 端完成（MCU 推理）。下面逐个展开。</p>
<h2>特征提取：6000 点 → 100 维</h2>
<p>MCU 拿到的是一串 6000 个 <code>float</code>：<code>[0.003, -0.012, 0.008, ...]</code>。直接把这 6000 个数扔给神经网络行不通 — 输入太大了，MCU 算不动。</p>
<p>解决方案是做<strong>特征提取</strong>：把 6000 个原始采样点压缩成 100 个有意义的数字。这些数字要能描述震动信号的核心特性 — 低频有多强、波动大不大、有没有突然的尖峰。可以把它理解成一种<strong>有损压缩</strong>：扔掉不重要的细节，保留能区分三类震动的关键信息。</p>
<p><img src="/posts/tiny-ml-vibration-tflm/feature-flow.svg" alt="特征提取流程"></p>
<h3>FFT 频域特征（64 维）</h3>
<p>FFT 做的事情：把一段随时间变化的波形，拆成&quot;哪些频率成分组成了这段波&quot;。不同震动的频率分布不一样，这是 FFT 能区分它们的原因。</p>
<p>做法是对整条 6000 点的信号跑一次 FFT，取 64 个频率 bin 的幅度，用 <code>log1p(x) = ln(1 + x)</code> 对幅度做对数压缩。对数压缩的目的很简单：低频分量的幅度可能是高频的几百倍，直接送给模型，大数会把小数淹掉。取个对数，差距从几百倍缩到几倍。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">fft = np.abs(np.fft.rfft(sig))        </span><span style="color:#6A9955"># FFT → 取幅度</span></span>
<span class="line"><span style="color:#D4D4D4">feats[:</span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">] = np.log1p(fft[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">:</span><span style="color:#B5CEA8">65</span><span style="color:#D4D4D4">])       </span><span style="color:#6A9955"># 跳过 DC（0Hz），取 64 个 bin + 对数压缩</span></span></code></pre><h3>时域分段特征（36 维）</h3>
<p>频域告诉模型&quot;哪个频率在震&quot;，时域告诉模型&quot;震动有多剧烈、怎么变的&quot;。</p>
<p>把 6000 个点切成 4 段，每段 1500 点。对每一段算 9 个统计量：</p>
<table>
<thead>
<tr>
<th>特征</th>
<th>含义</th>
<th align="center">只用加减乘除？</th>
</tr>
</thead>
<tbody><tr>
<td>均值</td>
<td>这段信号的平均值</td>
<td align="center">✓</td>
</tr>
<tr>
<td>标准差</td>
<td>波动幅度</td>
<td align="center">✓</td>
</tr>
<tr>
<td>最小值 / 最大值</td>
<td>振幅范围</td>
<td align="center">✓</td>
</tr>
<tr>
<td>RMS</td>
<td>有效值，衡量能量大小</td>
<td align="center">✓</td>
</tr>
<tr>
<td>峰值</td>
<td>绝对值的最大值</td>
<td align="center">✓</td>
</tr>
<tr>
<td>过零率</td>
<td>穿越零点的次数 ÷ 段长</td>
<td align="center">✓</td>
</tr>
<tr>
<td>能量</td>
<td>平方和 ÷ 段长</td>
<td align="center">✓</td>
</tr>
<tr>
<td>非零比</td>
<td>非零采样点占比</td>
<td align="center">✓</td>
</tr>
</tbody></table>
<p><strong>9 个特征 × 4 段 = 36 维。</strong> 加上前面 64 维 FFT，总共 100 维特征向量。</p>
<p>选这些特征的硬性条件只有一个：<strong>MCU 上只用加减乘除就能算。</strong> 没有 <code>percentile()</code>、没有 <code>sort()</code>、没有 <code>log()</code>。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> seg </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> range</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">):</span></span>
<span class="line"><span style="color:#D4D4D4">    s = sig[seg*</span><span style="color:#B5CEA8">1500</span><span style="color:#D4D4D4">:(seg+</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">)*</span><span style="color:#B5CEA8">1500</span><span style="color:#D4D4D4">]</span></span>
<span class="line"><span style="color:#D4D4D4">    b = </span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4"> + seg * </span><span style="color:#B5CEA8">9</span></span>
<span class="line"><span style="color:#D4D4D4">    feats[b+</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">] = np.mean(s)                              </span><span style="color:#6A9955"># 均值</span></span>
<span class="line"><span style="color:#D4D4D4">    feats[b+</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">] = np.std(s)                               </span><span style="color:#6A9955"># 标准差</span></span>
<span class="line"><span style="color:#D4D4D4">    feats[b+</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">] = np.min(s)                               </span><span style="color:#6A9955"># 最小值</span></span>
<span class="line"><span style="color:#D4D4D4">    feats[b+</span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">] = np.max(s)                               </span><span style="color:#6A9955"># 最大值</span></span>
<span class="line"><span style="color:#D4D4D4">    feats[b+</span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">] = np.sqrt(np.mean(s**</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">))                  </span><span style="color:#6A9955"># RMS</span></span>
<span class="line"><span style="color:#D4D4D4">    feats[b+</span><span style="color:#B5CEA8">5</span><span style="color:#D4D4D4">] = np.max(np.abs(s))                       </span><span style="color:#6A9955"># 峰值</span></span>
<span class="line"><span style="color:#D4D4D4">    feats[b+</span><span style="color:#B5CEA8">6</span><span style="color:#D4D4D4">] = np.sum(np.abs(np.diff(np.signbit(s)))) / </span><span style="color:#DCDCAA">len</span><span style="color:#D4D4D4">(s)  </span><span style="color:#6A9955"># 过零率</span></span>
<span class="line"><span style="color:#D4D4D4">    feats[b+</span><span style="color:#B5CEA8">7</span><span style="color:#D4D4D4">] = np.sum(s**</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">) / </span><span style="color:#DCDCAA">len</span><span style="color:#D4D4D4">(s)                   </span><span style="color:#6A9955"># 能量</span></span>
<span class="line"><span style="color:#D4D4D4">    feats[b+</span><span style="color:#B5CEA8">8</span><span style="color:#D4D4D4">] = np.count_nonzero(s) / </span><span style="color:#DCDCAA">len</span><span style="color:#D4D4D4">(s)            </span><span style="color:#6A9955"># 非零比</span></span></code></pre><h2>特征标准化</h2>
<p>100 维特征算出来了，但有一个问题：不同特征的数值范围差别很大。</p>
<p>比如&quot;均值&quot;可能一直在 ±0.01 之间晃，但&quot;能量&quot;可能是几千的量级。直接把这些数喂给模型，数值大的特征会主导计算结果，数值小的等于没参与。</p>
<p>解决方法是做一次<strong>标准化（StandardScaler）</strong>：对每个特征维度，减掉均值、除以标准差。处理完后，每个维度都变成零均值、单位方差的分布。相当于把厘米、千克、秒全部统一成同一个尺度。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">scaler = StandardScaler()</span></span>
<span class="line"><span style="color:#D4D4D4">X_train_s = scaler.fit_transform(X_train)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 保存 mean 和 scale — C++ 端要用完全一样的值</span></span>
<span class="line"><span style="color:#D4D4D4">s_mean = scaler.mean_</span></span>
<span class="line"><span style="color:#D4D4D4">s_scale = scaler.scale_</span></span>
<span class="line"><span style="color:#D4D4D4">s_scale[s_scale &#x3C; </span><span style="color:#B5CEA8">1e-8</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">1.0</span><span style="color:#6A9955">   # 防止除零</span></span></code></pre><p>保存 <code>s_mean</code> 和 <code>s_scale</code> 是关键动作。训练时用的 normalization 参数，推理时也必须用同一组。</p>
<h2>模型结构</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>100 维输入 → Dense(16, ReLU) → Dense(8, ReLU) → Dense(3, Softmax)</span></span></code></pre><p>三层逐级压缩：100 → 16 → 8 → 3，每层只保留最关键的信息。16 和 8 是&quot;刚好够用且省空间&quot;的平衡点 — 翻倍则 Flash 放不下，减半则表达力不够。</p>
<table>
<thead>
<tr>
<th>指标</th>
<th align="left">数值</th>
</tr>
</thead>
<tbody><tr>
<td>float32 权重</td>
<td align="left">~7.0 KB</td>
</tr>
<tr>
<td>int8 权重</td>
<td align="left">~1.8 KB</td>
</tr>
<tr>
<td>TFLite 模型</td>
<td align="left"><strong>5,024 字节</strong></td>
</tr>
</tbody></table>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">model = tf.keras.Sequential([</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.InputLayer(</span><span style="color:#9CDCFE">shape</span><span style="color:#D4D4D4">=(</span><span style="color:#B5CEA8">100</span><span style="color:#D4D4D4">,)),</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">activation</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'relu'</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">8</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">activation</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'relu'</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">activation</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'softmax'</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">])</span></span>
<span class="line"><span style="color:#D4D4D4">model.compile(</span><span style="color:#9CDCFE">optimizer</span><span style="color:#D4D4D4">=tf.keras.optimizers.Adam(</span><span style="color:#B5CEA8">0.001</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#9CDCFE">              loss</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'sparse_categorical_crossentropy'</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">              metrics</span><span style="color:#D4D4D4">=[</span><span style="color:#CE9178">'accuracy'</span><span style="color:#D4D4D4">])</span></span>
<span class="line"><span style="color:#D4D4D4">model.fit(X_train_s, y_train, </span><span style="color:#9CDCFE">batch_size</span><span style="color:#D4D4D4">=</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">epochs</span><span style="color:#D4D4D4">=</span><span style="color:#B5CEA8">60</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">          validation_split</span><span style="color:#D4D4D4">=</span><span style="color:#B5CEA8">0.2</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">          callbacks</span><span style="color:#D4D4D4">=[tf.keras.callbacks.EarlyStopping(</span></span>
<span class="line"><span style="color:#9CDCFE">              monitor</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'val_loss'</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">patience</span><span style="color:#D4D4D4">=</span><span style="color:#B5CEA8">12</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">restore_best_weights</span><span style="color:#D4D4D4">=</span><span style="color:#569CD6">True</span><span style="color:#D4D4D4">)])</span></span></code></pre><h2>量化：把 float32 压成 int8</h2>
<p>训练用的是 float32 — 每个权重占 4 字节。要在 MCU 上跑推理，需要把 float32 换成 int8，减少存储空间和计算开销。</p>
<p>量化和 <code>map()</code> 是一个道理——把数值从一个范围线性映射到另一个范围：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">map</span><span style="color:#D4D4D4">(value, fromLow, fromHigh, toLow, toHigh)</span></span></code></pre><p>量化就是把 float32 的 <code>[r_min, r_max]</code> 映射到 int8 的 <code>[-128, 127]</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>q = round((r - r_min) / (r_max - r_min) * 255) - 128</span></span></code></pre><p>TFLite 的写法等价，用 scale（S）和 zero point（Z）来表达同一件事：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>q = round(r / S) + Z</span></span></code></pre><ul>
<li><code>S = (r_max - r_min) / 255</code> — 一个 int8 刻度代表多少 float32</li>
<li><code>Z</code> — float32 的 0 对应到 int8 的哪个值</li>
</ul>
<p>举个例子：某层激活值在 <code>[-1.0, 1.0]</code> 之间，则 <code>S = 2.0/255 ≈ 0.0078</code>，<code>Z = 0</code>。<code>r = 0.5</code> 映射后 <code>q = 64</code>。</p>
<p>TFLite 转换器会自动算每层最优的 S 和 Z — 提供几十到几百个真实样本作为校准数据即可：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">def</span><span style="color:#DCDCAA"> representative_dataset</span><span style="color:#D4D4D4">():</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> i </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> range</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, </span><span style="color:#DCDCAA">min</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">len</span><span style="color:#D4D4D4">(X_train_s), </span><span style="color:#B5CEA8">300</span><span style="color:#D4D4D4">)):</span></span>
<span class="line"><span style="color:#C586C0">        yield</span><span style="color:#D4D4D4"> [X_train_s[i:i+</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]]</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">converter = tf.lite.TFLiteConverter.from_keras_model(model)</span></span>
<span class="line"><span style="color:#D4D4D4">converter.optimizations = [tf.lite.Optimize.DEFAULT]</span></span>
<span class="line"><span style="color:#D4D4D4">converter.representative_dataset = representative_dataset</span></span>
<span class="line"><span style="color:#D4D4D4">converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]</span></span>
<span class="line"><span style="color:#D4D4D4">converter.inference_input_type = tf.int8</span></span>
<span class="line"><span style="color:#D4D4D4">converter.inference_output_type = tf.int8</span></span>
<span class="line"><span style="color:#D4D4D4">tflite_model = converter.convert()</span></span></code></pre><p>量化前后的准确率对比：</p>
<table>
<thead>
<tr>
<th>阶段</th>
<th align="left">准确率</th>
</tr>
</thead>
<tbody><tr>
<td>float32 训练模型</td>
<td align="left">80.9%</td>
</tr>
<tr>
<td>int8 TFLite（同测试集）</td>
<td align="left">79.3%</td>
</tr>
<tr>
<td>量化损失</td>
<td align="left">−1.6%</td>
</tr>
</tbody></table>
<p>损失不到 2%，但模型的存储空间从 7KB 降到了 1.8KB，计算从 float32 乘法变成了 int8 乘法（MCU 上快很多）。</p>
<h2>用 C 实现特征提取</h2>
<p>python 端的工作完成后，产出三个文件：</p>
<ul>
<li><code>vibration_model_v2.tflite</code> — 量化后的模型（5024 字节）</li>
<li><code>vibration_model_v2_data.h</code> — 模型转为 C 数组，直接 <code>#include</code></li>
<li><code>scaler_params_v2.h</code> — Scaler 的均值、标准差和量化参数</li>
</ul>
<p>C 端的特征提取流水线：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>CSV 一行 → 特征提取(DFT+时域) → StandardScaler → 量化到 int8 → 送入推理 runtime → 取最大类别</span></span></code></pre><h3>手写 DFT 的精度细节</h3>
<p>python 端用的是 numpy 的高性能 FFT，MCU 上只能手写朴素 DFT。注意一个容易被跳过的细节 — <strong>角度必须用 double 算</strong>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">double</span><span style="color:#D4D4D4"> omega = -</span><span style="color:#B5CEA8">2.0</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">3.141592653589793</span><span style="color:#D4D4D4"> * k / len;</span></span>
<span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; len; i++) {</span></span>
<span class="line"><span style="color:#569CD6">    double</span><span style="color:#D4D4D4"> angle = omega * i;</span><span style="color:#6A9955">          // ← 关键：double，不是 float</span></span>
<span class="line"><span style="color:#D4D4D4">    real += </span><span style="color:#9CDCFE">sig</span><span style="color:#D4D4D4">[i] * </span><span style="color:#DCDCAA">cos</span><span style="color:#D4D4D4">(angle);</span></span>
<span class="line"><span style="color:#D4D4D4">    imag += </span><span style="color:#9CDCFE">sig</span><span style="color:#D4D4D4">[i] * </span><span style="color:#DCDCAA">sin</span><span style="color:#D4D4D4">(angle);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#9CDCFE">feats</span><span style="color:#D4D4D4">[k - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">] = (</span><span style="color:#569CD6">float</span><span style="color:#D4D4D4">)</span><span style="color:#DCDCAA">log1p</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">sqrt</span><span style="color:#D4D4D4">(real * real + imag * imag));</span></span></code></pre><p>为什么？6000 点的 DFT，<code>omega * i</code> 循环到最后一轮时 i=5999。如果用 float32 计算角度，累加误差在 10⁻³ 量级，cos/sin 之后误差被放大，最终的幅度谱和 python 端对不上。用 double 算角度，结果再截回 float，两边就对齐了。</p>
<p>时域特征没这个问题 — 1500 点的段内累加，float32 精度绰绰有余。</p>
<h3>标准化 + 量化 + 推理</h3>
<p>这步在 C++ 端复现 python 训练时的 Scaler + 量化逻辑：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; </span><span style="color:#B5CEA8">100</span><span style="color:#D4D4D4">; i++) {</span></span>
<span class="line"><span style="color:#569CD6">    float</span><span style="color:#D4D4D4"> s = (</span><span style="color:#9CDCFE">feats</span><span style="color:#D4D4D4">[i] - </span><span style="color:#9CDCFE">kScalerMeanV2</span><span style="color:#D4D4D4">[i]) / </span><span style="color:#9CDCFE">kScalerScaleV2</span><span style="color:#D4D4D4">[i];</span><span style="color:#6A9955">  // Scaler</span></span>
<span class="line"><span style="color:#569CD6">    float</span><span style="color:#D4D4D4"> q = s / kInputScale + kInputZeroPoint;</span><span style="color:#6A9955">                   // 量化</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (q > </span><span style="color:#B5CEA8">127.0f</span><span style="color:#D4D4D4">) q = </span><span style="color:#B5CEA8">127.0f</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">       // clip 到 int8 范围</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (q &#x3C; -</span><span style="color:#B5CEA8">128.0f</span><span style="color:#D4D4D4">) q = -</span><span style="color:#B5CEA8">128.0f</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">    input</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">int8</span><span style="color:#D4D4D4">[i] = (</span><span style="color:#569CD6">int8_t</span><span style="color:#D4D4D4">)</span><span style="color:#DCDCAA">lroundf</span><span style="color:#D4D4D4">(q);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">Invoke</span><span style="color:#D4D4D4">();</span></span></code></pre><p>TFLM 的初始化也很轻量 — 模型只有全连接和 Softmax，两个 Op 即可：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#4EC9B0">tflite</span><span style="color:#D4D4D4">::MicroMutableOpResolver&#x3C;</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">> resolver;</span></span>
<span class="line"><span style="color:#9CDCFE">resolver</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AddFullyConnected</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#9CDCFE">resolver</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AddSoftmax</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint8_t</span><span style="color:#9CDCFE"> arena</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">1024</span><span style="color:#D4D4D4">];</span><span style="color:#6A9955">   // Tensor Arena</span></span>
<span class="line"><span style="color:#4EC9B0">tflite</span><span style="color:#D4D4D4">::</span><span style="color:#4EC9B0">MicroInterpreter</span><span style="color:#DCDCAA"> interpreter</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">model</span><span style="color:#D4D4D4">, </span><span style="color:#4EC9B0">resolver</span><span style="color:#D4D4D4">, </span><span style="color:#4EC9B0">arena</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">16</span><span style="color:#569CD6"> *</span><span style="color:#B5CEA8"> 1024</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AllocateTensors</span><span style="color:#D4D4D4">();</span></span></code></pre><h3>Python 与 C 端结果对比</h3>
<p>同一批测试文件、同一组 Scaler 参数、同一个 TFLite 模型：</p>
<table>
<thead>
<tr>
<th>类别</th>
<th align="center">python int8</th>
<th align="center">C++ int8</th>
<th align="center">差异</th>
</tr>
</thead>
<tbody><tr>
<td>震动A</td>
<td align="center">94.3%</td>
<td align="center">92.4%</td>
<td align="center">±1.9%</td>
</tr>
<tr>
<td>震动B</td>
<td align="center">92.0%</td>
<td align="center">93.9%</td>
<td align="center">±1.9%</td>
</tr>
<tr>
<td>震动C</td>
<td align="center">53.4%</td>
<td align="center">55.7%</td>
<td align="center">±2.3%</td>
</tr>
</tbody></table>
<p>1-3% 的差异来自 numpy 的优化 FFT 实现 vs 朴素 DFT，以及 float64 和 double 中间精度的微小区别。两端结果高度一致，整条链路验证通过。</p>
<p>震动C 准确率偏低（~54%），原因是该类样本和震动B 的时域特征高度重叠，仅靠 100 维线性特征难以完全分开。这个方向上的改进空间在特征工程，不在模型结构。</p>
<h2>资源占用</h2>
<table>
<thead>
<tr>
<th>资源</th>
<th align="left">用量</th>
<th align="center">占 64KB 总量</th>
</tr>
</thead>
<tbody><tr>
<td>Flash（模型权重）</td>
<td align="left">5,024 B</td>
<td align="center">7.7%</td>
</tr>
<tr>
<td>Flash（含代码）</td>
<td align="left">~10 KB</td>
<td align="center">16%</td>
</tr>
<tr>
<td>RAM（Tensor Arena）</td>
<td align="left">~1,800 B</td>
<td align="center">2.7%</td>
</tr>
<tr>
<td>RAM（特征+工作缓冲）</td>
<td align="left">~3,000 B</td>
<td align="center">4.6%</td>
</tr>
</tbody></table>
<p>Flash 用了 16%，RAM 用了不到 10%。在 64KB/64KB 的 MCU 上还有充裕空间跑传感器驱动和应用层代码。</p>
<p>适用范围：</p>
<ul>
<li>时序信号的三分类，输入几千点以内</li>
<li>特征维度 100-200，模型参数 2000-5000</li>
<li>对实时性要求不极端 — 朴素 DFT 在 MCU 上的耗时与采样点数和 bin 数量成正比，64 bin × 6000 点的计算量在秒级分类场景中足够用</li>
</ul>
<p>如果需要更低延迟，可以对输入做降采样（如 1024 点）来减少 DFT 计算量。移植到实际 Cortex-M 开发板后，用 CMSIS-NN 的 int8 矩阵乘法内核替换 TFLM 的 Reference Fallback，可以进一步提升推理速度。</p>
<h2>注意事项</h2>
<p><strong>Scaler 参数必须一字不差。</strong> python 训练时的 <code>mean</code> 和 <code>scale</code> 要原样导出到 C 头文件。一个特征维度对不上，量化后的 int8 值直接漂到 −128 或 127 饱和，整条推理报废。</p>
<p><strong>DFT 角度用 double 算。</strong> 长序列下 float32 的角度累加误差会通过 cos/sin 放大，导致幅度谱和 python 端偏差不可忽略。用 double 算角度，结果截回 float。</p>
<p><strong>校准数据覆盖所有类别。</strong> 量化校准时 <code>representative_dataset</code> 覆盖三类震动，否则某些层的激活范围估计不准，clip 损失大。</p>
<p><strong>训练前洗数据。</strong> 传感器偶发野值（读数突然飙到天文数字）会污染 Scaler 的统计。过滤掉绝对值异常的样本（比如超过 1000 的读数），用 float64 做特征提取中间计算。</p>
<p><strong>防除零。</strong> Scaler 拟合后，接近 0 的 <code>scale_</code> 替换为 1.0，避免 C 头文件出现正负无穷。</p>
<h2>在 STM32L496 上用 X-CUBE-AI 部署</h2>
<p>前面的 C++ 推理代码先在云端虚拟机上跑通了一遍，确认整条链路（特征提取 → Scaler → 量化 → 推理）和 Python 端结果一致。实际落到 STM32L496 工程时，没有手动移植 TFLM，而是使用 STM32CubeMX 集成的 X-CUBE-AI，把同一个 <code>vibration_model_v2.tflite</code> 转成 ST 官方的 MCU runtime 工程代码。</p>
<p>CubeMX 里选择 TFLite 模型，runtime 选择 <code>STM32Cube.AI MCU runtime</code>。模型输入仍然是 <code>int8[100]</code>，输出是 <code>int8[3]</code>，核心网络结构没有变化。</p>
<p><img src="/posts/tiny-ml-vibration-tflm/x-cube-ai-config.webp" alt="X-CUBE-AI 配置"></p>
<p>X-CUBE-AI 生成后的计算图和训练时一致：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>100 维输入 → Dense(16) → Dense(8) → Dense(3) → Softmax</span></span></code></pre><p><img src="/posts/tiny-ml-vibration-tflm/x-cube-ai-network.webp" alt="X-CUBE-AI 网络结构"></p>
<p>这条部署路径的职责划分比较清楚：</p>
<ul>
<li>特征工程仍然由固件侧 C 代码完成：<code>6000 点原始采样 → 64 维 DFT + 36 维时域特征 → StandardScaler → int8</code>。</li>
<li>模型推理交给 X-CUBE-AI runtime：全连接层、ReLU、Softmax 和量化参数由生成代码处理。</li>
<li>验证时同时保留一个手写 <code>tiny_infer</code> 路径，只用于对照每层 int8 计算，不作为最终 runtime。</li>
</ul>
<p>真正容易出错的位置在模型输入之前。最初 C 端特征提取把时域特征数组偏移加了两次，导致 <code>feats[64..99]</code> 没有写入正确数据，栈上还发生了越界写。X-CUBE-AI 拿到错误的 100 维输入，预测结果自然和 Python 参考对不上。</p>
<p>修正后，把三类样本分别烧到 STM32L496 上运行，串口里看到的预测类别和预期类别一致：</p>
<p><img src="/posts/tiny-ml-vibration-tflm/stm32-serial-log.webp" alt="STM32 串口验证输出"></p>
<p>最终验证结果：</p>
<table>
<thead>
<tr>
<th>测试样本</th>
<th>预期类别</th>
<th>MCU 预测</th>
</tr>
</thead>
<tbody><tr>
<td>vib_A</td>
<td>vib_A</td>
<td>vib_A</td>
</tr>
<tr>
<td>vib_B</td>
<td>vib_B</td>
<td>vib_B</td>
</tr>
<tr>
<td>vib_C</td>
<td>vib_C</td>
<td>vib_C</td>
</tr>
</tbody></table>
<p>这说明模型部署到 STM32 后，实际分类结果和桌面端验证结果一致。真正的部署检查重点是：固件端送进模型的 100 个 int8 特征必须和训练/验证端保持同一套特征工程和量化流程。</p>
<p>如果想看手动移植 TFLite Micro 的做法，可以看这篇更小的正弦模型示例：<a href="/posts/ch585m-tflite-micro-sin-demo">CH585M 上手动移植 TFLite Micro 跑 sin demo</a>。这篇振动分类工程选择 X-CUBE-AI，是为了直接使用 ST 官方生成的 MCU runtime；手动 TFLM 和 X-CUBE-AI 两条路线的目标相同，都是把同一个量化模型稳定地放到 MCU 上运行。</p>
<p>如果手上有 STM32L496 开发板，也可以直接下载这份编译产物烧录验证：<a href="/posts/tiny-ml-vibration-tflm/stm32l496-vibration-classifier.hex" target="_blank" rel="noopener noreferrer">HEX 固件</a>，或者用 <a href="/posts/tiny-ml-vibration-tflm/stm32l496-vibration-classifier.bin" target="_blank" rel="noopener noreferrer">BIN 固件</a> 从 <code>0x08000000</code> 写入。烧录后打开串口窗口看日志即可，参数是 <code>115200 8N1</code>。</p>
<h2>总结与改进方向</h2>
<p>这条路线的优点是可解释、可复现，训练、特征工程、量化和 MCU 端输入都能逐项对齐；缺点也很明显，6000 点特征提取在 MCU 上跑得比较慢，最终分类效果也只能说够用。</p>
<p>如果想要更好的效果，可以先用 ST 的 NanoEdge AI 跑一轮基准测试。它会给出一个黑盒模型，也会把模型路线流水线暴露出来：中间用了哪些特征提取、有没有 FFT、有没有裁剪、最后用了什么模型。先让 NanoEdge AI 帮你把特征流水线试错一遍，再把这条流水线的核心思路移植成自己的代码，这样就能从黑盒工具里提炼出一个更可控的边缘 AI 模型。</p>]]></content>
    <category term="嵌入式"/>
    <category term="边缘AI"/>
    <category term="TFLite Micro"/>
    <category term="TFLM"/>
    <category term="模型量化"/>
    <category term="振动分析"/>
    <category term="信号处理"/>
  </entry>
  <entry>
    <title>博客首页卡片瀑布流：让各列高度均匀分布</title>
    <link href="https://createskyblue.github.io/posts/masonry-waterfall-layout/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/masonry-waterfall-layout/</id>
    <published>2026-07-04T00:00:00.000Z</published>
    <updated>2026-07-04T00:00:00.000Z</updated>
    <summary>卡片瀑布流最短列优先贪心算法</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<p><img src="/posts/masonry-waterfall-layout/PixPin_2026-07-04_20-30-53.webp" alt="各列底部高度均匀"></p>
<h2>一个直观的问题</h2>
<p>想象你在整理书架。你有一堆书要放进五格书架，每本书厚度不同。如果你只是从左到右、一格一格轮流放——第一本放第一格，第二本放第二格……绕回来继续——那么很可能某一格塞满了厚书，另一格全是薄册子，五格书架看起来歪歪扭扭。</p>
<p>博客首页的卡片瀑布流面对的是完全一样的问题。每张卡片高度不同：有封面截图的卡片比较&quot;厚&quot;（300–500px），纯文字标题卡比较&quot;薄&quot;（150–200px）。原来的做法就是轮流往各列塞卡片，完全不看每张卡片高还是矮。运气好时各列差不多，运气差时某一列比其他列长出一大截，视觉上很难看。</p>
<p><strong>一句话：轮流分配 + 高度参差不齐 = 底部必然不平。</strong></p>
<h2>直觉上的解法：哪列最矮就往哪列放</h2>
<p>回到书架的例子，聪明一点的整理方式是：拿起一本书，扫一眼哪一格最空，就把书放进去。再拿下一本，继续找最空的那一格。这样不管书厚书薄，最终五格的高度一定差不多。</p>
<p>卡片瀑布流用的就是同一套思路。每张新卡片到来时，先看看当前哪一列总高度最小，然后塞进去。这个策略在算法领域有一个名字，叫「最短列优先」。它解决的核心矛盾是：<strong>卡片高度差异很大，而轮流分配对这种差异完全视而不见。</strong></p>
<h2>真正的难点：怎么知道每张卡片有多高</h2>
<p>思路说清楚了只剩一个障碍——书是拿在手里的，厚度一眼能看到。但网页上的卡片高度在它真正渲染出来之前是未知的。封面截图加载出来多大？标题换了几行？这些都得到浏览器里实际跑一遍才知道。</p>
<p>所以我们采取的办法是：<strong>不猜，直接量。</strong> 每一张卡片在真正进入瀑布流之前，先被放到一个隐藏的测量区里渲染一遍。测量区宽度和真实列完全一致，卡片在里面怎么折行、图片撑多高，跟最终效果一模一样。</p>
<p>最初的设计是等封面图加载完再量——图片没加载时浏览器只给了一个 150px 的最小高度兜底，量出来的数字比真实值小一半以上，会严重误导最短列优先的判断。但这个串行等待带来了一个隐蔽问题：每张有封面图的卡片都要等图片加载，哪怕本地服务器图片秒开，几十张卡片累积的异步等待也会产生可感知的卡顿。排在队列末尾的卡片，要等前面所有卡片的图片全部加载完才能轮到自己，底部最后几张卡像是&quot;卡住了&quot;。</p>
<p>后来的改进是：<strong>不等待，先量再修正。</strong> 卡片渲染后立即测量当前高度（图片未加载时至少有 min-height 兜底），直接放入瀑布流。同时监听实际卡片中图片的加载完成事件——如果图片加载后实际高度变化超过阈值，就更新高度记录并重排瀑布流。这样卡片流出的速度不再被图片加载阻塞，而最终各列高度仍然收敛到精确值。</p>
<h2>逐张处理，一张没出来下一张不许开始</h2>
<p>每一张卡片严格按原始顺序排队进入测量区：渲染 → 量高度（不等图片） → 找当前最矮的列放进去 → 从透明渐入 → 图片后台加载完后必要时修正高度并重排。整个过程串行执行，当前卡片没有完成放置步骤之前，下一张卡片绝不开始。</p>
<p>这样保证了两个关键性质：</p>
<p>一是<strong>列高度优先基于实测数据做决策</strong>。每放入一张卡片时，先用量到的当前高度（图片可能还在后台加载），最短列优先算法拿到的是当前最准确的输入。图片加载完成后如果高度有显著变化，自动修正记录并触发重排。在本地服务器上绝大多数图片在卡片放置时已经加载完，修正触发极少。</p>
<p>二是<strong>卡片按原始顺序依次出现</strong>。无论文字卡还是图片卡，都不需要等封面图加载完就能先进入瀑布流，所以每张卡片流出的间隔均匀。顺序不会乱——不会出现&quot;文字卡全部堆在上面、图片卡全部压在底下&quot;的情况。</p>
<h2>每张卡片独立渐入</h2>
<p>渐入动画不是做在整个网格上，而是做在每一张卡片上。卡片在测量区完成测量后，被移入瀑布流的某一列，此时它是完全透明的。等浏览器渲染完第一帧之后，再把透明度从 0 过渡到 1，播放 0.5 秒的淡入动画。</p>
<p>这里有一个容易踩的坑：如果刚把卡片插入 DOM 就立刻把透明度设为 1，浏览器还没来得及画&quot;透明&quot;的第一帧，transition 根本不会触发——它不知道从哪开始过渡。解决办法是在设置透明度之前插入一个 <code>requestAnimationFrame</code>，强制浏览器先画一帧初始状态。</p>
<h2>处理并发和分页</h2>
<p>卡片逐个处理的逻辑用一个互斥锁保护起来，确保同一时间只会有一个处理循环在跑。如果处理过程中有新卡片到达（比如用户滚动了页面触发了分页），当前循环结束后会自动重入再跑一轮。</p>
<p>分页加载也被限流了：只要还有卡片在排队等待逐个处理，哨兵就不会触发下一页。等当前批次全部处理完再判断是否真的需要加载更多。这防止了卡片逐个加载时页面高度不够、哨兵一直可见、一次性把所有分页全部拉下来的问题。</p>
<p>手机上还有一个额外的坑：两列布局下，两列的实际高度可能差很多。原来整个瀑布流只有一个哨兵放在网格底部，它的位置由最高的那一列决定。如果左列全是文字卡（矮），右列全是截图卡（高），哨兵在右列最下方——用户滚到左列底部时看到大片空白，但哨兵还没进入视口，分页不会触发。解决方案是给每一列底部各放一个哨兵，用 IntersectionObserver 同时观察所有列的哨兵，任一列进入视口就触发下一页。这样无论各列高度差多少，用户滚到最短列的底部时也能正常加载。</p>
<h2>效果</h2>
<p>所有卡片按顺序逐张浮现，0.5 秒淡入，不会被封面图加载阻塞。各列高度基于 DOM 实测值做最短列优先分配，底部均匀；图片加载完成后有修正机制兜底。分页加载通过每列独立哨兵触发，手机端两列高度不均时也能正常翻页。整个流程的额外性能开销极小——每张卡片只在隐藏测量区多渲染了一次，对几十张卡片的首页来说完全无感。</p>]]></content>
    <category term="前端"/>
    <category term="Vue"/>
    <category term="CSS Grid"/>
    <category term="瀑布流"/>
    <category term="布局算法"/>
    <category term="性能优化"/>
  </entry>
  <entry>
    <title>CH58x 心率项目 AI 移植功耗异常：GPIO 底层差异定位与自动化测试方法</title>
    <link href="https://createskyblue.github.io/posts/wch-ch58x-stm32-gpio-low-power/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/wch-ch58x-stm32-gpio-low-power/</id>
    <published>2026-07-04T00:00:00.000Z</published>
    <updated>2026-07-04T00:00:00.000Z</updated>
    <summary>CH58x 与 STM32 GPIO 低功耗模式差异：AI 移植漏关数字通路，功耗超标十倍</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>CH58x 心率项目 AI 移植功耗异常：GPIO 底层差异定位与自动化测试方法</h1>
<h2>现象</h2>
<p>将 STM32 平台成熟心率采集项目交由 AI 迁移至 CH58x 蓝牙芯片，整体架构与开发规范由人工划定，AI 负责底层库函数适配与代码落地。</p>
<p>烧录后实测休眠电流 400μA+，同硬件上 WCH 官方低功耗示例仅 35μA，差距超过十倍。</p>
<h2>根因：GPIO 低功耗模式底层差异</h2>
<p>AI 迁移时直接套用 STM32 低功耗配置经验，将模拟引脚设为浮空输入，认为等效 STM32 的 Analog 模式。两类芯片此处硬件逻辑完全不同。</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>STM32 Analog 模式</th>
<th>CH58x 浮空输入（AI 写法）</th>
<th>CH58x 官方低功耗方案</th>
</tr>
</thead>
<tbody><tr>
<td>数字输入缓冲</td>
<td>硬件直接关闭</td>
<td>保持开启</td>
<td>普通 IO 上拉锁电平，ADC 引脚单独关闭数字通路</td>
</tr>
<tr>
<td>电平状态</td>
<td>无浮动，零跳变功耗</td>
<td>随机浮动，反复翻转耗电</td>
<td>上拉固定高电平，消除动态翻转损耗</td>
</tr>
<tr>
<td>漏电量级</td>
<td>μA 级</td>
<td>数百 μA</td>
<td>接近官方 Demo 基线</td>
</tr>
<tr>
<td>适用场景</td>
<td>ADC、模拟引脚</td>
<td>仅临时数字高阻</td>
<td>全端口上拉做基线，模拟引脚单独处理</td>
</tr>
</tbody></table>
<p>CH58x 不存在独立的模拟 GPIO 模式。普通数字 IO 靠输入上拉固定电平来省电，ADC 等模拟引脚需要两步配置：先设浮空输入，再调用 <code>GPIOxDigitalCfg(DISABLE, pin)</code> 关闭数字输入通路。两步缺一不可。</p>
<p>AI 只做了第一步浮空配置，完全遗漏第二步关闭数字通路，模拟引脚持续漏电，直接拉垮整机休眠功耗。</p>
<h2>关键修复代码</h2>
<p>全端口输入上拉（官方 Demo 基线操作，AI 未执行）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">GPIOA_ModeCfg</span><span style="color:#D4D4D4">(GPIO_Pin_All, GPIO_ModeIN_PU);</span></span>
<span class="line"><span style="color:#DCDCAA">GPIOB_ModeCfg</span><span style="color:#D4D4D4">(GPIO_Pin_All, GPIO_ModeIN_PU);</span></span></code></pre><p>心率 ADC 引脚专属配置：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">GPIOA_ModeCfg</span><span style="color:#D4D4D4">(ADC_PIN, GPIO_ModeIN_Floating);</span></span>
<span class="line"><span style="color:#DCDCAA">GPIOADigitalCfg</span><span style="color:#D4D4D4">(DISABLE, ADC_PIN);</span></span></code></pre><h2>如何把异常缩小到 ADC 引脚</h2>
<p>硬件侧排查采用了拆分链路的方法。心率 ADC 通过电阻连接到外部模拟电路，休眠电流异常时，先移除这类桥接电阻，把 MCU 引脚与后级模拟网络暂时隔开。</p>
<p>这个动作有两个价值：</p>
<ul>
<li>若功耗明显下降，说明漏电路径和这条模拟输入链路有关</li>
<li>若功耗不变，优先回到电源、时钟、无线或其他 IO 继续排查</li>
</ul>
<p>实际现象是移除桥接电阻后功耗下降，异常范围收敛到 ADC 输入附近。随后直接测量 MCU 侧 ADC 引脚，发现休眠状态下仍接近 VCC。这个电平不符合“模拟前端已断电、ADC 输入应高阻”的预期，说明 MCU 侧仍在影响该节点。</p>
<p>这一步把问题从“整机低功耗异常”缩小成“ADC 引脚睡眠状态不对”。后续再回到 CH58x GPIO 配置，就能看到两个关键点：</p>
<table>
<thead>
<tr>
<th>检查项</th>
<th>期望状态</th>
<th>异常含义</th>
</tr>
</thead>
<tbody><tr>
<td>ADC 引脚模式</td>
<td>浮空输入</td>
<td>不能保留普通输入上拉</td>
</tr>
<tr>
<td>数字输入通路</td>
<td>关闭</td>
<td>不能只配置 GPIO 模式</td>
</tr>
<tr>
<td>外部模拟连接</td>
<td>可拆分验证</td>
<td>用桥接电阻定位漏电路径</td>
</tr>
</tbody></table>
<p>最终处理按板级连接关系建立睡眠清单，覆盖所有连接外部模拟网络的 ADC 引脚。进入睡眠前统一执行浮空输入和关闭数字输入通路，避免普通 IO 的低功耗基线配置反过来把模拟节点拉到 VCC。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> APP_BOARD_SLEEP_ISOLATED_ADC_PINS </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    (GPIO_Pin_2 </span><span style="color:#D4D4D4">|</span><span style="color:#569CD6"> GPIO_Pin_3 </span><span style="color:#D4D4D4">|</span><span style="color:#569CD6"> GPIO_Pin_4 </span><span style="color:#D4D4D4">|</span><span style="color:#569CD6"> GPIO_Pin_5 </span><span style="color:#D4D4D4">|</span><span style="color:#569CD6"> GPIO_Pin_12 </span><span style="color:#D4D4D4">|</span><span style="color:#569CD6"> GPIO_Pin_13)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">GPIOA_ModeCfg</span><span style="color:#D4D4D4">(APP_BOARD_SLEEP_ISOLATED_ADC_PINS, GPIO_ModeIN_Floating);</span></span>
<span class="line"><span style="color:#DCDCAA">GPIOADigitalCfg</span><span style="color:#D4D4D4">(DISABLE, APP_BOARD_SLEEP_ISOLATED_ADC_PINS);</span></span></code></pre><p>这类问题的排查顺序应先落到物理节点：拆开外部链路、测 MCU 引脚电平、确认电流变化，再回到寄存器和库函数。只看 ADC 通道号或只看当前启用通道，很容易漏掉板上真实接到模拟电路的其他引脚。</p>
<h2>封装的三个 skill</h2>
<p>人工逐行对比效率太低，这里采用的方法是：人搭建测试环境、封装调用接口、设定实验规则，AI 自主完成对照实验。</p>
<table>
<thead>
<tr>
<th>Skill</th>
<th>实现方式</th>
<th>解决的问题</th>
</tr>
</thead>
<tbody><tr>
<td>PAR20-4H 可编程电源</td>
<td><code>ESP32_PAR20_4H</code> 提供 HTTP 接口，skill 调用接口开关电源</td>
<td>低功耗下调试器挂死时，AI 可自主完成断电、延迟、上电复位</td>
</tr>
<tr>
<td>低功耗分析仪</td>
<td>Python 直接读取串口打印数据</td>
<td>AI 直接记录休眠电流，无需人工值守读数</td>
</tr>
<tr>
<td>OpenOCD 烧录</td>
<td>封装为统一 skill 接口</td>
<td>烧录、验证、读取启动日志</td>
</tr>
</tbody></table>
<h2>单轮测试完整流程</h2>
<p>AI 按固定顺序调用三个 skill，完成一次完整功耗测试：</p>
<ol>
<li>调用电源 HTTP 接口 → 断电、延迟、上电，复位板子</li>
<li>调用 OpenOCD skill → 烧录固件，读取日志确认启动正常</li>
<li>延迟等待系统进入休眠</li>
<li>运行串口读取脚本 → 采集低功耗分析仪输出并记录</li>
<li>生成本轮实验文档：改动点、功耗数据、结论</li>
</ol>
<h2>实验规则与文档留痕</h2>
<p>人工设定三条硬约束：</p>
<ul>
<li>只能用二分法，单次仅修改一组模块</li>
<li>编译失败或功耗异常自动回退上一稳定版本</li>
<li>每轮必须输出实验文档，记录所有改动与数据</li>
</ul>
<p>实验目标：定位官方 Demo 与 AI 移植固件之间造成 400μA 功耗差的最小代码差异。</p>
<p>设定完成后 AI 自主运行，夜间跑完所有对比组。次日查看日志，差异点已锁定至 GPIO 模拟引脚配置。</p>
<p>每轮强制输出实验文档有两个作用：</p>
<ul>
<li>人工审核时可追溯每一步改动，无需猜测中间过程</li>
<li>AI 上下文长度有限，跑满后重开新智能体时直接读取文档即可接续工作，无需从头重复实验</li>
</ul>
<h2>方法沉淀</h2>
<p>这次排查的核心价值不在于&quot;CH58x 要上拉&quot;这个具体结论——查手册早晚能找到。</p>
<p>真正可复用的是这套工作模式：将物理世界的测试能力封装为标准化接口，设定实验框架和约束条件，交由 AI 自主执行对照实验。人负责设计验证方案、封装工具、审核结论，AI 负责重复劳动和数据采集。</p>
<p>跨平台移植场景下，AI 处理语法和业务逻辑迁移效率很高，但不同厂商 MCU 底层硬件差异容易被忽略。提前划定底层外设配置规范，搭配自动化测试闭环，才能同时兼顾开发效率与硬件指标。</p>]]></content>
    <category term="嵌入式"/>
    <category term="BLE"/>
    <category term="低功耗"/>
    <category term="CH58x"/>
    <category term="AI辅助开发"/>
  </entry>
  <entry>
    <title>51 单片机在 2026 年不应该还活着</title>
    <link href="https://createskyblue.github.io/posts/51-mcu-engineering-margin/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/51-mcu-engineering-margin/</id>
    <published>2026-07-03T00:00:00.000Z</published>
    <updated>2026-07-03T00:00:00.000Z</updated>
    <summary>51单片机在2026年不应该还活着</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>51 单片机在 2026 年不应该还活着</h1>
<p>站在 2026 年，51 不该再是新项目的默认选择。成本敏感区间已经堆满了 RISC-V 和 ARM Cortex-M0/M0+，价格同一档，外设更新、工具链更新、C 支持完整、调试链路完整。继续用 51 的常见理由是便宜、熟悉、库存稳定，放在今天这些理由都要重新核算。</p>
<h2>省下来的不一定是成本</h2>
<p>选型时最容易看到的是 BOM：芯片单价、外围器件、库存替换，摆在表格里 51 显得还有优势。进入工程以后，成本会从别的地方回来：</p>
<ul>
<li>编译器和 IDE 落后，自动化构建、静态检查、现代调试流程都难接（STC32 系列是唯一例外，见后文）。</li>
<li>除 STC32 自研工具链外，C99 支持不完整，代码组织和跨平台复用要降级。</li>
<li>内存架构割裂，<code>code</code>、<code>data</code>、<code>idata</code>、<code>xdata</code> 会进业务代码，新工具链也一样。</li>
<li>主频和寻址模型受限，稍微复杂的实时逻辑要手工抠时序。</li>
<li>外设弱，定时器、输入捕获、DMA、硬件滤波经常不够用。</li>
<li>生态资料分散，AI 生成代码时容易混进不同厂商和编译器的旧写法。</li>
</ul>
<p>这些成本不在芯片单价里，会出现在调试时间、返工次数、现场问题和后续维护里。</p>
<h2>低成本要靠大出货兑现</h2>
<p>选 51 省 BOM 的前提是产品能稳定量产：省下的几毛钱要乘上出货量才变成利润。为了省这几毛钱选余量很小的平台，系统稳定性、现场问题、售后返修、版本补丁会把放量节奏拖住。产品出不去量，低单价优势兑现不了，市场窗口也过去了。低成本芯片本来是为大出货服务的，底层平台让产品不稳定，就同时丢掉成本优势和销售规模。</p>
<h2>内存架构会污染 C 代码</h2>
<p>8051/8052 把程序空间、片内数据空间、间接寻址空间、外部数据空间分成不同区域，代码里于是出现 <code>code</code>、<code>data</code>、<code>idata</code>、<code>xdata</code> 限定。典型片内 RAM 只有几百字节，低 128 字节可直接寻址，高 128 字节只能间接寻址，SFR 另占一套直接地址空间，后扩的大 RAM 落到 <code>xdata</code>。</p>
<p>这些空间不只是名字不同，访问方式、速度、指针模型都不同。同一份数据放在不同空间，生成的指令和执行成本可能完全不同，实时路径上差几个机器周期就踩穿时序边界。于是本该由编译器消化的细节直接进了 C 代码设计：</p>
<ul>
<li>指针类型和访问空间绑定，普通指针、远指针、通用指针成本不同。</li>
<li>片内直接访问空间很小，大缓冲区被迫放进间接寻址或扩展空间。</li>
<li>ISR、查表、环形缓冲、通信收发的时序都会受影响。</li>
<li>通用指针为兼容多个空间，带来额外代码体积和运行开销。</li>
<li>迁出或回迁 51，都要处理大量内存限定。</li>
</ul>
<p>现代 MCU 上，业务代码只需要关心 RAM、Flash、栈、堆和链接脚本边界。51 把更底层的空间概念暴露进日常编码，团队要持续付认知成本，也要承担访问差异带来的时序风险。</p>
<h2>AI 会放大生态碎片</h2>
<p>现在写代码大量依赖 AI 辅助。ARM、RISC-V 的 SDK、示例工程、论坛讨论和 CI 配置更多，训练数据集中；51 的资料分散得多，厂商寄存器命名、头文件、编译器扩展、中断声明、内存限定写法各不相同，2026 年又多了一套 STC 自研的 C11 方言。即使把某个型号的手册、示例库和头文件全部喂进去，也容易犯这几类错：</p>
<ul>
<li>把 Keil C51、SDCC、厂商 IDE 的语法混用。</li>
<li>把 <code>sfr</code>、<code>sbit</code>、<code>interrupt</code>、<code>using</code>、<code>xdata</code>、<code>code</code> 的约束写错。</li>
<li>用现代 C 的默认内存模型去理解 51 的指针和存储空间。</li>
<li>生成看起来能编译的寄存器访问，实际 SFR 名称、位定义、初始化顺序不匹配。</li>
<li>忽略 ISR、访问空间和循环等待带来的周期成本。</li>
</ul>
<p>资料喂得越全，AI 越贴近某个具体型号；只要跨型号、跨编译器、跨旧代码风格，混写风险就还在。</p>
<h2>对 STC 常见反驳的回应</h2>
<p>拿 STC 新型号反驳，通常列的是：1T 架构、高主频、大容量 <code>xdata</code>、USB、DMA、PWM、CAN、I2S、比较器、运放、在线下载、在线仿真、低单价。这些参数都成立，STC 确实在 8051 兼容线上做了很多增强。但本文讨论的是&quot;2026 年新项目要不要默认选 51 兼容路线&quot;，这和&quot;某一颗 STC 能不能跑某个小任务&quot;是两件事。</p>
<p>STC 自己的产品线分层已经给出答案：STC8G/STC8H 面向极低成本和简单控制，STC32G/Ai8051U 进入和 Cortex-M0/M0+ 同价位竞争的区间。极低成本小任务可以留特例，新项目默认选型要回到现代 MCU 的完整比较。厂商补主频和外设，补不了这些系统性问题：</p>
<ul>
<li>代码仍要面对 C51/C251/SDCC/自研 C11 等多套方言和历史包袱。</li>
<li>内存空间概念仍然影响代码组织。</li>
<li>旧项目、旧示例、论坛代码、厂商库、AI 生成代码之间容易混写。</li>
<li>生态主流已转向 ARM 和 RISC-V，新人、工具、CI、静态分析和调试经验都集中在那边。</li>
</ul>
<p>工具链这条线 STC 自己也在补。2026 年 8 月 STC 发布了 <a href="https://stcide.stcaimcu.com/">STC IDE</a>：打开网页就能写 C、点一下编译出 HEX，内置 SWD 差分下载、硬件断点、单步、运行到光标和变量监视，编译全部在本机浏览器里完成，PWA 缓存后断网可用，完全开源免费，官方持续维护。它面向 STC32 系列（STC32G12K128、STC32G8K64、AI8051U、STC32G144K246），语法基于 C11 文法并支持部分 C23，官方给的数据是 30 个例程整体代码尺寸比 Keil 小约 15%。对长期被 Keil 授权费和 Windows-only 卡住的人来说，这是实打实的进展。</p>
<p>把本文的批评逐条对上去，它补掉的是其中一部分：</p>
<ul>
<li>覆盖范围是 STC32 这一小撮，STC8 系列还在计划里，其他厂商的 51 完全不在射程内。</li>
<li><code>data</code>、<code>edata</code>、<code>xdata</code>、<code>code</code> 存储类型一个没少，官方编译器自己就得支持这套限定，说明内存架构割裂不是工具问题，是架构问题。</li>
<li>编译器 2026 年 8 月仍在修正确性 bug：中断函数提前 return 跑飞、指针 <code>++</code>/<code>--</code> 步进错误、结构体初始化漏首成员、局部指针数组传参读到错值。新工具链的成熟曲线还没走完。</li>
<li>不支持 Keil <code>.uvproj</code> 工程解析，没有命令行入口，CI 和第三方静态分析接不进来。</li>
<li>C11 只覆盖常用子集，<code>_Generic</code>、可变参数宏未实现，无块级作用域，<code>reentrant</code> 未实现，同时还得兼容 Keil <code>_at_</code>。</li>
</ul>
<p>所以它解决的是&quot;51 开发体验差&quot;，没解决的是&quot;51 兼容路线本身贵&quot;。它面向的目标芯片本身也说明了问题：STC32 是 32 位总线加兼容 251 指令集，并非 Intel 251 的 8 位总线。</p>
<p><code>xdata</code> 变大、LARGE 模式、C251 的 NEAR/FAR/HUGE、库函数封装都是缓解手段，降低了手工分配内存空间的频率，但没把 51 生态变成平坦 C 环境。只要代码还要关心编译模式、内存限定、指针模型、厂商头文件和库版本，认知成本就还在。C251 + HUGE 能让业务代码少碰这些限定，代价是把自己锁进特定芯片系列、特定编译器、特定厂商资料闭环；团队能不能稳定复现，CI 能不能接入，静态分析能不能跑，第三方库能不能直接用，都要重新评估。</p>
<p>AI 辅助也是同一件事。STC 的说法是喂全手册、头文件、库说明和典型示例后准确率可接受，跨型号跨编译器需要人工审核，这句话本身就在报成本。论坛活跃、中文资料多、教学案例多，对入门和存量维护有价值，但新产品工程还要看另一组指标：自动化构建、可移植性、招聘、跨平台库、长期维护、供应商切换、调试工具通用性。</p>
<p>还有个现实问题：一颗&quot;现代 51&quot;已经需要 1T、高主频、大 RAM、DMA、USB、CAN、I2S、自研编译器、SWD 硬件调试和专用 IDE 来证明自己够现代，它和同价位现代 MCU 比的就不再是&quot;51 简单便宜&quot;，比的是工具链、C 支持完整性、调试链路、生态集中度和长期维护成本。按这个标准，Cortex-M0/M0+ 和 RISC-V 通常更干净。要让 51 好用，先得让它不再像 51。</p>
<h2>余量、可观测性与技术债</h2>
<p>很多 51 项目的核心问题来自余量太少。算力勉强够，定时器少一点，中断能力弱一点，IO 复用绕一点，调试工具差一点，每一项都能靠软件补：没有双边沿捕获就用中断切边沿，没有足够捕获通道就把多路信号塞进同一个 ISR，没有硬件滤波就在中断里判脉宽，没有 DMA 就靠 CPU 搬数据，没有调试能力就靠日志、示波器和猜测拼现场。简单场景能跑，系统规模、实时性、干扰、低功耗、输入路数一起上来，软件补硬件余量就成了风险来源。</p>
<p>钱学森的工程控制论给了个实用视角：工程对象要放进反馈回路看，扰动进来以后系统要能被观测、被修正、被拉回目标状态。CPU 主频、定时器、输入捕获、中断响应、RAM、调试链路都是控制系统的裕度。裕度足，干扰只造成局部偏差；裕度太薄，小毛刺、低功耗切换、温漂、供应批次和现场安装差异，都会被软件补丁和延迟反馈放大。</p>
<p>可观测性尤其要命。现代 MCU 有 SWD、trace、硬件捕获、DMA 和统一内存模型，出问题还能定位；只能靠示波器、日志、猜测和反复试机，反馈链路就变长，调参容易振荡：修一个现场问题压出另一个边界，加一个过滤漏掉另一个脉冲，补一个中断状态挤掉别的实时任务。可控性同理，资源够就可以选硬件捕获、硬件滤波、DMA 搬运、独立 ISR；资源不够只能把多个实时职责塞进同一条中断路径，控制输入变少，回旋空间也变小。</p>
<p>所以一颗便宜芯片如果降低了可观测性、可控性和稳定裕度，它的低单价只是一次开环计算，闭环成本会在调试、返修、延期和维护里补回来，还会持续计息：每次改需求先问还能不能塞下，每次加功能先问中断扛不扛得住，每次现场异常先怀疑时序边界又被踩到，每次换人都要重讲一遍工具链限制。早期省下的一点硬件余量，后面用软件复杂度、测试成本、沟通成本和现场成本还。</p>
<h2>新项目别默认选 51</h2>
<p>存量 51 产品不必立刻重做，认证、库存、产线、代码和售后约束都在，继续维护很正常。新项目不该再把 51 放在默认位置，默认选型从现代 RISC-V、Cortex-M0/M0+ 或同级别现代 MCU 开始评估，再按供货、成本、外设、功耗、工具链和团队经验收敛。需要稳定输入捕获、低功耗唤醒、复杂通信、现场可调试性、长期维护的项目，51 的低单价抵不过后面的工程成本。余量买在前面是成本，余量省在前面，后面就是风险和滚起来的技术债。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="单片机"/>
    <category term="STC"/>
    <category term="系统设计"/>
    <category term="工程调试"/>
    <category term="AI"/>
    <category term="控制论"/>
  </entry>
  <entry>
    <title>RETAP 手持天线控制终端项目复盘</title>
    <link href="https://createskyblue.github.io/posts/antenna-control-terminal/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/antenna-control-terminal/</id>
    <published>2026-07-03T00:00:00.000Z</published>
    <updated>2026-07-03T00:00:00.000Z</updated>
    <summary>RETAP手持终端：尾号扫描树、动态菜单</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>RETAP 手持天线控制终端</h1>
<p><strong>项目背景</strong>：基站远程电调天线（RET）通过 Iuant/RETAP 标准接口与协议维护。本项目实现一台手持终端，把 RETAP 协议栈与现场维护动作下沉到设备本地，让技师在塔上或机房用屏幕和按键即可完成天线扫描、校准、调倾角、读取信息、下发配置与固件，无需连接基站主设备或电脑。</p>
<p><strong>职责</strong>：独立设计并实现硬件、MCU 固件与 RETAP 协议栈对接。</p>
<p><strong>核心技术</strong></p>
<ul>
<li><strong>十进制尾号扫描树</strong>：针对总线挂接多台、地址全未知且不可预置清单的设备，设计基于序列号尾号逐位细分的自动发现机制：将尾号建模为逐位展开的树形结构，先以最高位粗探、唯一命中即分配短地址并移出集合；多台冲突则补下一位继续收窄，无响应分支直接剪枝。以最小探测代价将模糊设备集合精确切分为单台并完成寻址，避免对空分支徒耗总线帧。</li>
<li><strong>RETAP 协议分层</strong>：拆成传输层（帧封装、校验、超时）与应用层（扫描、地址分配、校准、倾角、信息等）；UI 只调用应用层，新增能力只扩应用层，传输层守超时与校验。</li>
<li><strong>运行时数据驱动菜单</strong>：菜单不写死，由设备描述与文件列表动态生成；四类控件（列表、跳转、动作、数值条），渲染与执行分离，确认键按节点类型分流。</li>
<li><strong>USB 双模式</strong>：检测到接入即进入 Mass Storage，电脑把终端当 U 盘读写存储卡；退出后回控制模式，文件维护不依赖烧录器。</li>
<li><strong>RTOS 多任务</strong>：按键、LCD、LED/蜂鸣、电池采样、USB 检测、协议操作分任务并发；单色屏无灰度，用棋盘格抽稀加反色弹窗做状态分层。</li>
<li><strong>多语言</strong>：中英文共用同一套菜单层级与动作，仅替换文本资源。</li>
</ul>
<p><strong>界面总览</strong></p>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
  <tbody>
    <tr>
      <td colspan="2" style="padding: 12px; border: 1px solid #e2e8f0; background: #f8fafc; color: #334155; line-height: 1.7;">
        <strong>脱敏说明：</strong>本文只保留软件交互、仿真界面和公开标准对接层面的内容。文章不包含客户信息、真实设备型号、私有字段、具体协议帧内容、具体固件文件和源代码实现细节。
      </td>
    </tr>
    <tr>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/antenna-list.webp" alt="天线列表" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">天线列表</div>
      </td>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/main-menu.webp" alt="主菜单" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">主菜单</div>
      </td>
    </tr>
    <tr>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/calibration-confirm.webp" alt="校准确认" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">校准确认</div>
      </td>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/calibration-success.webp" alt="校准成功" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">校准成功</div>
      </td>
    </tr>
    <tr>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/tilt-setting.webp" alt="设置倾角" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">设置倾角</div>
      </td>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/tilt-input-value.webp" alt="倾角输入数值" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">倾角输入数值</div>
      </td>
    </tr>
    <tr>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/tilt-confirm-dialog.webp" alt="倾角确认弹窗" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">倾角确认弹窗</div>
      </td>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/tilt-success-sim.webp" alt="倾角设置成功" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">倾角设置成功</div>
      </td>
    </tr>
    <tr>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/antenna-info-top.webp" alt="天线信息顶部" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">天线信息顶部</div>
      </td>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/antenna-info-bottom.webp" alt="天线信息底部" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">天线信息底部</div>
      </td>
    </tr>
    <tr>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/config-file-list.webp" alt="配置文件列表" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">配置文件列表</div>
      </td>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/firmware-file-list.webp" alt="固件更新列表" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">固件更新列表</div>
      </td>
    </tr>
    <tr>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/other-settings.webp" alt="其他设置" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">其他设置</div>
      </td>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/output-voltage.webp" alt="输出电压设置" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">输出电压设置</div>
      </td>
    </tr>
    <tr>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/language-setting.webp" alt="语言设置" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">语言设置</div>
      </td>
      <td style="width: 50%; padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/main-menu-en-sim.webp" alt="英文主菜单" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">英文主菜单</div>
      </td>
    </tr>
    <tr>
      <td colspan="2" style="padding: 10px; border: 1px solid #e2e8f0; vertical-align: top; background: #f8fafc;">
        <img src="/posts/antenna-control-terminal/img/sim/antenna-info-en.webp" alt="英文天线信息界面" style="width: 100%; height: auto; border: 1px solid #e2e8f0; border-radius: 6px;">
        <div style="margin-top: 8px; color: #334155; font-size: 14px; text-align: center;">英文天线信息界面</div>
      </td>
    </tr>
  </tbody>
</table>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="通信协议"/>
    <category term="系统设计"/>
    <category term="RTOS"/>
    <category term="单片机"/>
  </entry>
  <entry>
    <title>在 51 单片机上用中断捕获 12 路微秒级脉宽，软件双边沿会碰到实时性边界</title>
    <link href="https://createskyblue.github.io/posts/software-dual-edge-pulse-filter/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/software-dual-edge-pulse-filter/</id>
    <published>2026-07-03T00:00:00.000Z</published>
    <updated>2026-07-03T00:00:00.000Z</updated>
    <summary>12路微秒级脉宽软件捕获，实时性不足会误计数</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>在 51 单片机上用中断捕获 12 路微秒级脉宽，软件双边沿会碰到实时性边界</h1>
<p>这是一个中途接手的项目。原方案基于 51 单片机，6 个电机各有 2 路霍尔反馈信号，一共 12 路外部脉冲，最初直接用 GPIO 下降沿中断捕获；后来现场发现输入容易受到干扰，我在这个基础上引入了软件脉宽过滤。继续排查计数抖动问题时，才发现这个过滤方案本身也受限于既有硬件资源和中断实时性。</p>
<p>脉宽过滤本身没有复杂：下降沿来了先记时间，上升沿来了再算宽度；宽度够，就提交一次有效脉冲。麻烦出在要捕获的是微秒级脉宽，留给 ISR 响应和软件切边沿的时间预算很短。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>高电平 ─────┐      ┌───── 高电平</span></span>
<span class="line"><span>             │      │</span></span>
<span class="line"><span>低电平       └──────┘</span></span>
<span class="line"><span>             A      B</span></span>
<span class="line"><span>           下降沿   上升沿</span></span></code></pre><p>真正的问题在实现方式。读者如果习惯了资源完整的 MCU，可能会默认 GPIO 可以直接开双边沿触发；但部分 51 单片机的中断能力比较受限，只能配置单边沿，或者不同 IO 的边沿能力受限。硬件没有给每一路输入独立的双边沿捕获、脉冲计数或去抖资源时，软件只能在对应 GPIO 中断 ISR 里模拟：A 点进中断，记录时间，把这个 IO 切到等 B 点；B 点再进中断，计算脉宽，再切回等 A 点。</p>
<h2>12 路输入挤在几个中断里</h2>
<p>如果只有一路信号，这个软件状态机已经依赖 ISR 响应速度。脉宽长到毫秒级时，软件通常还有余量；脉宽压到微秒级时，余量会迅速消失。</p>
<p>12 路 IO 不会都在同一个 port 上。更常见的情况是：多个 port 各有自己的中断入口，每个 port 内的多个 IO 共用同一个中断服务函数。每一路又都要在这个 ISR 里模拟双边沿，实时性压力会放大：</p>
<ul>
<li>ISR 先读一次中断标志寄存器。</li>
<li>软件逐位判断是哪一路 IO 触发。</li>
<li>每一路都有自己的“等下降沿 / 等上升沿”状态。</li>
<li>每一路都要在 ISR 里切换触发模式。</li>
</ul>
<p>这相当于在低主频 51 单片机上，分散在几个 GPIO 中断里，用软件模拟 12 个小型输入捕获外设。</p>
<h2>漏边沿发生在哪里</h2>
<p>理想时序是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>下降沿 A</span></span>
<span class="line"><span>MCU 进入 ISR</span></span>
<span class="line"><span>软件切到等上升沿</span></span>
<span class="line"><span>上升沿 B</span></span>
<span class="line"><span>MCU 再进 ISR</span></span>
<span class="line"><span>软件验证脉宽</span></span></code></pre><p>实时性不够时，时序可能变成：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>下降沿 A</span></span>
<span class="line"><span>上升沿 B</span></span>
<span class="line"><span>MCU 才进入 ISR</span></span></code></pre><p>这时同一个 IO 的中断标志只有一个 bit。它只能表示“这个脚触发过”，不能表示“这个脚连续触发了两次”。软件已经拿不到 A 和 B 的完整历史，这个真实脉冲就可能被当成毛刺丢掉。</p>
<p>多个 IO 共用同一个 port 的中断入口不一定有错。风险来自“多个 IO 共用 ISR + 12 路软件双边沿 + 低主频 + 单 bit 标志”。只要其中一路边沿间隔接近或短于 ISR 最坏响应时间，软件模拟的双边沿过滤就没有足够时间完成状态切换。脉宽越短，这个风险越明显。</p>
<h2>受控脉冲实验</h2>
<p>为了验证软件边沿捕获的实时性，可以用受控信号源产生固定数量、固定脉宽的脉冲序列，再检查 MCU 侧捕获数量是否完整。</p>
<p>这次测试使用 10 个脉冲作为一组手动触发序列：9 个合法脉冲宽度为 260us，1 个非法脉冲宽度为 240us。软件过滤阈值设为 <code>&gt;= 250us</code> 有效。触发前后保持低电平。</p>
<p><img src="/posts/software-dual-edge-pulse-filter/img/signal-generator-260us-240us.webp" alt="信号发生器多脉冲输出设置"></p>
<p>少量触发时，计数结果基本符合预期，说明边沿切换和脉宽判断在低压力条件下可以工作。重复触发多次后，打印日志出现了两路计数分叉：</p>
<p><img src="/posts/software-dual-edge-pulse-filter/img/pulse-capture-log.webp" alt="重复触发后的 FB / FBZ 打印日志"></p>
<p>当 <code>FBZ = 55</code> 时，按当前显示滞后关系计算，理论 FB 有效计数为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>(55 + 1) * 9 - 1 = 503</span></span></code></pre><p>实测结果为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>LM FB = 512</span></span>
<span class="line"><span>RM FB = 503</span></span>
<span class="line"><span>FBZ   = 55</span></span></code></pre><p>RM 与理论值一致，LM 多计 9。这个结果说明至少有一路在长时间重复触发后发生了误计数。</p>
<p>这组实验暴露的是阈值附近的实时性余量不足。合法脉宽 260us、非法脉宽 240us，都只距离 250us 阈值 10us。多个 IO 共用同一个中断服务函数，并在 ISR 内软件切换边沿模式时，中断响应延迟、处理顺序差异、边沿模式切换延迟都会改变测得脉宽。部分 240us 非法脉冲可能被测成有效脉冲，最终表现为多计数。</p>
<p>两路结果不同也很有参考价值。共享 ISR 下，不同 IO 接收同一组测试脉冲，实际软件捕获条件并不完全一致。排在 ISR 后面的分支、和其他 IO 同时触发的分支，会承受额外的软件处理延迟。</p>
<h2>提主频以后还要看 ISR 开销</h2>
<p>第一反应通常是提高 CPU 主频。主频从 24MHz 提到 40MHz 后，指令执行变快，中断响应也会变短，理论上可以给软件双边沿捕获争取更多余量。</p>
<p>但这次测试又暴露了另一个细节：时间戳换算本身也在 ISR 路径里。24MHz、12T 模式下，Timer11 原始计数频率为 2MHz，1 tick 等于 0.5us，换算成微秒只需要除以 2。40MHz、12T 模式下，Timer11 原始计数频率为 3.333MHz，1 tick 等于 0.3us，换算成微秒会变成 <code>raw * 3 / 10</code>。</p>
<p>放在 51 单片机上，32 位除法和取模很重。CPU 主频提高带来的收益，可能被 ISR 内的实时换算吃掉一部分。软件捕获路径里，最贵的地方不该放在边沿到来的瞬间。</p>
<p>后续把 ISR 改成直接使用 Timer11 原始 tick：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>rise_tick = T11_raw</span></span>
<span class="line"><span>width_tick = now_raw - rise_tick</span></span></code></pre><p>阈值在编译期换算成 tick 后再比较：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>40MHz / 12T: 1 tick = 0.3us</span></span>
<span class="line"><span>250us = 833.33 tick</span></span>
<span class="line"><span>向上取整后，阈值取 834 tick</span></span></code></pre><p>这样 ISR 热路径只剩下读计数器、<code>uint16_t</code> 减法、<code>uint16_t</code> 比较和计数更新。微秒单位只用于调试打印，不再参与实时判定。</p>
<p>再次测试后，260us 合法脉冲被测到 261<del>264us，240us 非法脉冲被测到 241</del>244us，并稳定进入 reject 统计。这个结果把方向指得很清楚：信号源精度可以信任，单纯提高主频仍然不够，实时路径里的昂贵换算必须移出去，阈值比较要回到原始 tick 域。</p>
<h2>硬件捕获和软件模拟的差别</h2>
<p>有些 MCU 的定时器或专用输入捕获外设可以直接做这些事：</p>
<ul>
<li>硬件双边沿捕获。</li>
<li>硬件脉冲计数。</li>
<li>输入滤波或去抖。</li>
<li>捕获时刻锁存。</li>
</ul>
<p>这些能力的价值在于：边沿先由硬件抓住，软件晚一点处理也不会立刻丢历史。</p>
<p>用 GPIO 中断软件模拟时，边沿历史只剩一个标志 bit。软件来晚了，硬件不会帮你补记录。</p>
<h2>工程结论</h2>
<p>这个问题不适合只靠调脉宽阈值解决。阈值只能决定什么宽度算有效，不能恢复已经错过的边沿。</p>
<p>如果系统没有足够的定时器、输入捕获通道或带滤波能力的专用 IO，那么软件模拟双边沿必须接受一个边界：真实脉冲宽度要明显大于 ISR 最坏响应时间。微秒级脉宽已经接近这个边界，不能只按普通 GPIO 中断来理解。</p>
<p>如果脉宽确实这么短，工程上只有几条路：提高 CPU 主频，换取更短的中断响应时间；减少每个中断服务函数里软件模拟的输入路数；或者把关键输入迁到硬件双边沿捕获、定时器输入捕获、硬件计数和硬件滤波资源上。</p>
<p>当 12 路信号都依赖 51 的 GPIO 中断软件模拟双边沿，漏边沿就不再是偶然 bug，它是硬件资源不足和软件实时性边界叠加后的自然结果。</p>
<h2>一点个人观点</h2>
<p>站在 2026 年看，51 单片机不该继续作为新项目的默认选择。成本敏感场景已经有大量 RISC-V MCU，也有成熟的 ARM Cortex-M0/M0+ MCU：价格低，外设新，工具链新，C 语言支持也正常。</p>
<p>51 的问题不只是主频低。很多型号 C99 支持不完整，编译器、调试器、工程体系都停在旧时代，外设能力也很弱。最后省下来的物料成本，很容易变成中断实时性、工具链兼容、维护成本和现场调试成本。</p>
<p>工程要给自己留余量。算力余量、外设余量、定时器余量、调试余量，看起来都不直接出现在 BOM 上，但被省掉以后，不确定性不会消失，只会转成风险。风险再拖下去，就会变成技术债，持续计息，后面每一次改需求、查现场、补兼容都要还。</p>]]></content>
    <category term="嵌入式"/>
    <category term="中断"/>
    <category term="MCU"/>
    <category term="脉宽判定"/>
    <category term="定时器"/>
  </entry>
  <entry>
    <title>Bootloader 从外挂 Flash 选择固件并引导启动</title>
    <link href="https://createskyblue.github.io/posts/stm32-bootloader-exflash/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/stm32-bootloader-exflash/</id>
    <published>2026-07-03T00:00:00.000Z</published>
    <updated>2026-07-03T00:00:00.000Z</updated>
    <summary>Bootloader引导外挂Flash固件启动，用启动选项选版本</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Bootloader 从外挂 Flash 选择固件并引导启动</h1>
<p>这个 Bootloader 的核心思路是把外挂 W25Q Flash 做成固件镜像池和启动决策区。Bootloader 复位后先读取外部 Flash 中的启动选项，再选择目标固件、完成校验、提交到内部 APP 运行区，最后跳转到 APP 向量表启动。</p>
<p>这样做的直接收益是：OTA 链路可以分包、断点续写或分多次会话完成；内部 Flash 只在固件头、固件本体、启动选项都通过校验后才被擦写。Bootloader 不需要在接收过程中反复动内部 Flash，APP 区也不会因为半包数据变成不可启动状态。</p>
<h2>升级目标</h2>
<p>这个项目解决的是小容量 MCU 做 OTA 升级时的一个基本矛盾：内部 Flash 空间有限，升级链路又不适合把数据直接写进运行区。</p>
<p>内部 Flash 被划成两块：</p>
<ul>
<li>Bootloader 固定放在起始位置，负责初始化外部 Flash、读取启动选项、选择目标固件、校验固件、擦写 APP 区、跳转 APP。</li>
<li>用户 APP 放在后面的运行区。Bootloader 只在确认外部 Flash 中的目标固件有效后才覆盖这一段。</li>
</ul>
<p>外挂 Flash 承担三类状态：</p>
<ul>
<li>启动选项：记录本次要从外部 Flash 的哪个固件槽位引导启动。</li>
<li>固件槽位：保存用户、DFU、工厂、升级、备份、临时等不同用途的固件入口。</li>
<li>完整性信息：固件头和 CRC32 放在外部 Flash 中，Bootloader 从这里判断能否提交到内部 Flash。</li>
</ul>
<p>内部 Flash 因此只保留“当前可运行版本”。外部 Flash 保留“候选版本”和“启动意图”。</p>
<h2>外挂 Flash 保存可启动固件</h2>
<p>升级包先进入外挂 Flash。Bootloader 重启后先把这份数据视为候选固件，再按固定顺序判断它能否成为本次启动目标：</p>
<ul>
<li>先读固件头，检查头部自身的 CRC32。</li>
<li>再检查固件大小，确认不会超过内部 APP 区。</li>
<li>接着按分块方式读取外部 Flash，计算固件本体 CRC32。</li>
<li>只有固件自带 CRC32 和本地计算结果一致，才允许提交到内部 APP 运行区。</li>
<li>如果内部 Flash 里已有同样 CRC32 的 APP，则跳过重复写入。</li>
</ul>
<p>这套流程把“固件已写入外部 Flash”和“本次启动采用该固件”分开。外部 Flash 中可以长期保存多份固件镜像，Bootloader 每次复位后根据启动选项选择其中一份，再用完整性校验决定能否引导启动。</p>
<p>这里的启动路径是：外部 Flash 保存固件镜像和启动意图，Bootloader 在复位后选择目标镜像，校验后写入内部 APP 运行区，再交给 APP 向量表启动。外挂 Flash 提供可重读、可校验的固件来源，真正的控制权交接发生在内部 APP 运行区。</p>
<h2>启动选项做成主备记录</h2>
<p>外挂 Flash 里不只保存固件，也保存启动选项。项目里有主记录和备份记录，两份记录都有 CRC32。</p>
<p>Bootloader 启动后先读两份启动选项：</p>
<ul>
<li>主记录有效时使用主记录；备份记录损坏时用主记录覆盖备份记录。</li>
<li>主记录损坏但备份记录有效时使用备份记录，并回写主记录。</li>
<li>两份都无效时不执行升级决策，最后尝试跳转已有 APP。</li>
</ul>
<p>启动选项里最重要的是启动标志。它把外部 Flash 中的固件槽位分成几个启动目标：</p>
<ul>
<li>用户固件：正常运行路径。</li>
<li>DFU 固件：进入设备固件升级能力。</li>
<li>工厂固件：保留出厂或恢复路径。</li>
<li>升级固件：把外部 Flash 中的新固件提交到内部 APP 区并作为本次启动目标。</li>
<li>临时固件：用于一次性验证或特殊启动。</li>
</ul>
<p>每次启动选项被修改后，Bootloader 会重新计算 CRC32，再写回主备两份记录。这个动作让启动意图本身也具备掉电后的可恢复能力。</p>
<h2>固件槽位保留回退余地</h2>
<p>外挂 Flash 的价值不只在“比内部 Flash 大”。它让 Bootloader 可以把不同生命周期的可启动固件分开放。</p>
<p>用户固件是日常运行目标，DFU 固件是升级能力本身，工厂固件提供恢复入口，升级固件作为待提交版本，备份固件用于保留旧版本，临时固件用于不改变默认启动策略的试运行。</p>
<p>这些槽位让 Bootloader 的职责从“把一个 bin 写进 APP 区”扩展成“根据启动选项选择一个外部 Flash 固件槽位，再决定是否引导启动”。提交成功后，启动选项可以回到默认用户启动模式；需要保持工厂模式时，也可以让工厂启动标志继续保留。</p>
<p>这里的重点是责任分层：</p>
<ul>
<li>外部通信负责把固件写入外挂 Flash 的指定槽位。</li>
<li>Bootloader 负责读启动选项、验证槽位里的固件、提交到内部 Flash 并完成引导。</li>
<li>APP 只负责运行自身逻辑，不参与擦写自己的运行区。</li>
</ul>
<p>这种分层减少了在线升级时最危险的耦合：业务 APP 不需要在自身运行期间覆盖自身所在的内部 Flash。</p>
<h2>写内部 Flash 前先收口风险</h2>
<p>内部 Flash 擦写是这个流程里风险最高的动作，所以它被放在最后。</p>
<p>项目中的写入路径有几个保护点：</p>
<ul>
<li>写入前先完成头部校验、大小校验和固件本体校验。</li>
<li>擦除内部 APP 区前先解锁 Flash，写完后及时加锁。</li>
<li>外部 Flash 到内部 Flash 的复制使用固定大小缓冲区分块完成，避免一次性占用过多 RAM。</li>
<li>跳转 APP 前关闭 SysTick、清中断挂起状态、反初始化 HAL，并设置 APP 的主栈指针。</li>
</ul>
<p>最后一个细节很重要。Bootloader 跳到 APP 属于固件控制权交接。跳转前如果外设、中断、SysTick 状态没有收干净，APP 会带着 Bootloader 的运行现场启动，后续问题会很难定位。</p>
<h2>适用边界</h2>
<p>这类外挂 Flash Bootloader 适合几种场景：</p>
<ul>
<li>MCU 内部 Flash 无法同时容纳两个完整 APP。</li>
<li>升级链路可能分包、断连或跨多次会话完成。</li>
<li>设备需要保留工厂固件、DFU 固件、备份固件等恢复路径。</li>
<li>Bootloader 需要在启动阶段从多份外部 Flash 固件中选择目标版本。</li>
</ul>
<p>它也有明确代价：</p>
<ul>
<li>系统多依赖一颗外部 Flash，硬件和驱动初始化必须可靠。</li>
<li>外部 Flash 的分区表、启动选项和上位机写入规则必须保持一致。</li>
<li>如果启动选项和固件槽位的状态机没有定义清楚，升级成功、回退、临时启动会互相影响。</li>
<li>Bootloader 必须足够小、足够稳定，后续升级机制都依赖它。</li>
</ul>
<p>这个项目的核心设计可以概括成一句话：外挂 Flash 保存可校验的固件镜像和启动选项，Bootloader 在启动阶段选择目标镜像并引导启动。内部 Flash 只承接最终运行结果，外挂 Flash 承接 OTA 传输过程、版本选择和恢复路径。</p>]]></content>
    <category term="嵌入式"/>
    <category term="STM32"/>
    <category term="Bootloader"/>
    <category term="OTA"/>
    <category term="升级"/>
    <category term="Flash存储"/>
  </entry>
  <entry>
    <title>串口接收为什么会丢数据：从轮询到循环缓冲的改造思路</title>
    <link href="https://createskyblue.github.io/posts/uart-dma-idle-fifo/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/uart-dma-idle-fifo/</id>
    <published>2026-07-03T00:00:00.000Z</published>
    <updated>2026-07-03T00:00:00.000Z</updated>
    <summary>从轮询到循环缓冲，梳理串口接收稳定性的改造思路</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>串口接收为什么会丢数据：从轮询到循环缓冲的改造思路</h1>
<p>串口接收看似只是“收到字节再读出来”，实际工程里会遇到短命令、连续数据流、主循环忙碌、协议解析耗时不稳定等情况。接收路径设计不好，就容易出现丢字节、乱序、短包延迟和偶发错包。</p>
<p>串口接收是嵌入式系统的基本输入路径。本文记录一种从简单轮询接收到 <code>DMA circular + IDLE + HT/TC + FIFO</code> 的演进思路，重点是把硬件接收、DMA 搬运、中断触发、软件缓冲和用户层解析拆开，让每一层职责清楚、边界可测。</p>
<h2>阶段1：轮询寄存器</h2>
<h3>【快递站类比】</h3>
<p>最开始的快递站很原始。老板每隔几分钟跑到门口看一眼，有快递就自己拿进来，没有快递就回去继续干别的活。</p>
<p>这个办法在快递很少时能用。一旦老板在里面算账、接电话，门口来了好几车快递没人接，快递员等不住就走了，包裹也就丢了。</p>
<h3>【技术解耦与实现】</h3>
<p>对应到串口接收，轮询就是主循环反复检查接收标志，发现有数据后读取数据寄存器。它的优点是直观，适合 bring-up、低速调试和最小可用验证。</p>
<p>问题在于 CPU 的主循环不可能长期只服务串口。只要轮询间隔变长，外设接收寄存器就可能来不及被读取，后续字节进入时触发溢出。轮询阶段可以用回环测试做最小验证：PC 持续发送随机字节，设备原样回显，PC 端逐字节比较。如果低波特率回环测试都不稳定，先检查串口配置、引脚复用和基础读写路径。</p>
<h2>阶段2：字节中断</h2>
<h3>【快递站类比】</h3>
<p>后来快递站在门口装了门铃。快递员到了不用等老板巡逻，按一下门铃，老板出来签收。</p>
<p>这比老板反复跑门口省力很多。但到了双11，门铃一直响，老板每隔几秒就被叫出去一次，里面的分拣、记账、发货全都做不动。</p>
<h3>【技术解耦与实现】</h3>
<p>字节中断把“等待数据”的成本降下来。串口收到一个字节后触发中断，CPU 在中断服务函数里读出数据寄存器。</p>
<p>它的限制来自中断频率。波特率升高后，每个字节都触发一次中断，CPU 会被大量小中断压住。中断里只应该做最小动作：快速取走字节、放入轻量缓冲、退出。协议解析、打印、大块复制都不应该压到这个路径里。</p>
<p>这一阶段继续用回环测试压测，但要逐步提高波特率，并让主循环同时做轻量任务。通过标准不能靠肉眼看到有回显，必须看 PC 端校验：长度一致、内容一致、顺序一致、设备不中断运行。</p>
<h2>阶段3：普通 DMA</h2>
<h3>【快递站类比】</h3>
<p>接着，快递站在门口放了一个大箩筐。快递员不用按门铃，也不用等老板，直接把包裹放进箩筐。</p>
<p>箩筐满了，老板再一次性搬进去。这样老板不用每个包裹都跑一趟。但老板把箩筐搬进屋、倒空、再放回门口的这段时间，新来的快递又没地方放。</p>
<h3>【技术解耦与实现】</h3>
<p>普通 DMA 让外设把接收到的字节直接搬到内存。CPU 不再为每个字节进入中断，只需要等一块 DMA buffer 写满后再处理。</p>
<p>这个阶段的结构通常是一块固定 RX buffer：启动一次外设到内存的 DMA 接收，DMA 写满后触发完成中断，CPU 处理这段内存，然后重新启动下一次 DMA。它解决了“每字节中断”的问题，但带来了重启空档。DMA 完成后，如果 CPU 处理和重新启动接收不够快，中间进入的字节仍然可能丢失。</p>
<p>回环测试要覆盖随机长度数据块，尤其是短包、长包和接近 buffer 大小的数据块。这里常见问题有两个：只处理满 buffer，短包一直等不到处理；DMA 完成后重启太慢，连续输入时出现空档。</p>
<h2>阶段4：双缓冲区</h2>
<h3>【快递站类比】</h3>
<p>快递站又放了两个箩筐。A 箩筐满了，老板搬 A；快递员马上改往 B 箩筐里放包裹。B 满了，再切回 A。</p>
<p>这解决了“搬箩筐时门口没地方放”的问题。但如果快递员只放了两个包裹就走了，箩筐永远装不满，老板可能一直等着“满了再搬”，屋里的人也迟迟拿不到这两个包裹。</p>
<h3>【技术解耦与实现】</h3>
<p>双缓冲区把接收和处理拆开：DMA 当前写 buffer A，A 满后切到 buffer B，CPU 处理 A；B 满后再切回 A。这个模型对固定长度数据块很清楚，可以减少普通 DMA 重启期间的接收空档。</p>
<p>它仍然没有解决变长数据。串口经常是字节流，数据不一定刚好填满 buffer。一段命令发完后，如果 buffer 没满，系统也需要及时处理这段数据。另一方面，如果上层处理长期慢于输入速度，两块 buffer 最终都会被占住，后续数据仍会被覆盖或丢弃。</p>
<p>这一阶段的回环测试要覆盖两类输入：刚好填满 buffer 的连续块，用来验证 A/B 切换；随机长度短包，用来验证未填满 buffer 能否被及时提交。出现整块丢失、A/B 顺序颠倒或短包长时间不回显，都说明双缓冲状态管理还有问题。</p>
<h2>阶段5：空闲中断（IDLE）</h2>
<h3>【快递站类比】</h3>
<p>快递站又装了一个红外线感应器。快递员往箩筐里放完东西，走了，门口安静下来，感应器就通知老板。</p>
<p>这样不管箩筐有没有满，只要快递员这一波已经放完，老板就能马上把已有包裹搬进去，不用傻等箩筐装满。</p>
<h3>【技术解耦与实现】</h3>
<p>IDLE 中断用于识别串口线上出现空闲。对变长数据来说，它提供了一个很重要的处理时机：这段输入暂时结束了，可以把 DMA 工作区里已经收到的数据搬走。</p>
<p>它不负责解析协议，也不判断这一段是否完整业务帧。它只解决一个底层问题：DMA buffer 里有一段新增字节，现在可以提交给后面的软件缓冲。实现上，驱动通过 DMA 当前写入位置和上次已处理位置，计算新增范围，再把这段数据搬到后续队列。</p>
<blockquote>
<p><strong>实现细节：</strong> 常见 HAL 写法会用 DMA 剩余计数换算写指针，例如 <code>buffer_size - dma_remaining_count</code>。这个代码只是在定位写位置，核心设计仍然是“IDLE 触发搬运，后续缓冲承接数据”。</p>
</blockquote>
<p>IDLE 的限制也很明确：它依赖“空闲”。如果上位机连续发送，没有足够长的停顿，IDLE 就不会及时触发。持续流输入需要新的搬运节点。</p>
<h2>阶段6：循环 DMA + HT/TC：兼顾连续流和短包</h2>
<h3>【快递站类比】</h3>
<p>最后，快递站把两个箩筐换成了一条循环传送带。快递员不停往传送带上放包裹，传送带走到一半，系统提醒老板搬一段；走到末尾，再提醒老板搬一段。</p>
<p>如果快递员放一会儿就走，红外线感应器也会提醒老板来搬。这样一来，快递断断续续也能及时处理，快递连续不断也能按传送带位置分段搬走，传送带不需要停下来换箩筐。</p>
<h3>【技术解耦与实现】</h3>
<p>循环 DMA 让 DMA 写到缓冲区末尾后自动回到开头继续写。它省掉了显式 A/B 双缓冲状态，也减少了普通 DMA 反复重启带来的空档。</p>
<p>仅靠 IDLE 还不够，因为持续流没有空闲。因此循环 DMA 通常配合两个固定节点：半传输中断 HT 和传输完成中断 TC。HT 表示 DMA 写到缓冲区一半，TC 表示写到末尾。IDLE、HT、TC 都进入同一个搬运逻辑：计算从上次位置到当前位置之间新增了哪些字节，把它们交给后面的软件层。</p>
<blockquote>
<p><strong>实现细节：</strong> 在 HAL 工程里，通常会启用 <code>DMA_IT_HT</code> 和 <code>DMA_IT_TC</code>，再让 DMA IRQ 和 USART IDLE IRQ 走同一个“计算新增范围并搬运”的函数。函数名不重要，职责必须统一。</p>
</blockquote>
<p>HT/TC 等价于把一段循环内存切成稳定观察点。物理上只有一块环形 DMA buffer，逻辑上获得了类似乒乓缓冲的效果：前半段写满搬一次，后半段写满再搬一次。配合 IDLE 后，短包、变长包和连续流都能触发搬运。</p>
<p>这一阶段最需要连续随机流压测。PC 端不要一帧一停，要持续发送随机数据，让串口线上尽量少出现空闲。通过标准是回显序列完全一致，正常容量内丢弃计数不增长，超过设计能力时必须能看到明确的溢出统计。</p>
<h2>阶段7：软件 FIFO：解耦接收和解析</h2>
<h3>【快递站类比】</h3>
<p>老板把传送带上的包裹搬进来后，没有马上逐个拆包处理。他把包裹先放进后面的大仓库。</p>
<p>业务部门有空了再去仓库慢慢分拣。哪怕业务部门临时去开会，前面快递站也可以先把包裹囤住，不会因为后面处理慢就让门口丢件。</p>
<h3>【技术解耦与实现】</h3>
<p>FIFO 的职责是把实时接收和业务处理解耦。DMA buffer 是硬件接收工作区，容量小，且持续被 DMA 写；FIFO 是软件队列，保存已经脱离 DMA 工作区、等待上层消费的字节流。</p>
<p>这条边界很重要：</p>
<ul>
<li>DMA buffer 负责临时接住硬件输入。</li>
<li>中断处理负责按 IDLE、HT、TC 节点把新增字节搬走。</li>
<li>FIFO 负责保存有序字节流，并吸收短时间突发。</li>
<li>用户层负责协议解析、校验、组包、丢帧恢复等语义。</li>
</ul>
<p>驱动层不应该解析协议，不应该替用户清理业务缓存，也不应该假装 FIFO 永远够用。FIFO 满时必须累计丢弃字节数和队列溢出次数。静默覆盖是灾难：上层只会看到偶发错包、乱序或解析失败，真正的压力来源消失了。溢出计数把“用户消费速度跟不上”暴露出来，这是防御性编程的一部分。</p>
<p>回环测试在这一阶段要验证保序和可观测性。PC 端持续发送随机流，设备从 FIFO 取一小段再回显。只要容量没有越界，回显就应该长度一致、内容一致、顺序一致。容量越界时，可以丢，但必须有溢出计数，测试报告也要明确标记为压力超过设计范围。</p>
<h2>阶段8：直接读取循环缓冲：省内存但边界更紧</h2>
<h3>【快递站类比】</h3>
<p>如果快递站租金很贵，老板可能想把后面的大仓库拆掉。这个时候不能只拆仓库，还要把门口的循环传送带加长，让它本身能临时堆更多包裹。</p>
<p>这样一来，传送带既负责接快递，也兼任临时仓库。业务部门不再去仓库拿包裹，直接从传送带上拿。省掉了“老板把包裹从传送带搬到仓库”这一步。</p>
<p>这样省地方，但要求业务部门动作很快，还要时刻知道自己拿到哪里了。一旦拿慢了，传送带转一圈，新的包裹就会盖掉旧包裹，之前没拿走的东西就丢了。</p>
<h3>【技术解耦与实现】</h3>
<p>走到循环 DMA 后，还可以取消独立软件 RX FIFO，但前提是把循环 DMA buffer 配得足够大。循环 DMA buffer 本身就是环形内存：DMA 写指针持续前进，走到末尾再回到开头。只要驱动再维护一个软件读指针，就可以把这段 DMA buffer 直接封装成 FIFO。</p>
<p>这个设计少一次从 DMA buffer 到外部 FIFO 的搬运，也省掉一块独立 FIFO RAM，适合资源很紧、接收处理足够快、接口可以统一封装的工程。代价是 DMA buffer 同时承担两个职责：硬件接收工作区和用户可读队列。读写边界必须清楚。</p>
<p>最低要求包括：</p>
<ul>
<li>DMA buffer 要按最坏消费延迟加长，不能沿用很小的临时接收缓冲。</li>
<li>可读长度必须由 DMA 写位置和软件读位置计算。</li>
<li>读取函数必须处理回绕，不能假设数据连续。</li>
<li>写位置追上读位置时，必须判定覆盖风险。</li>
<li>溢出统计必须保留，不能把覆盖伪装成随机错字节。</li>
</ul>
<p>外部 FIFO 和加长 DMA buffer 后直接读取没有绝对高低。外部 FIFO 的职责边界更清楚，可观测性更好；DMA 直读更省搬运和 RAM，但读写指针、覆盖判断、缓冲区长度估算和压力测试都要更严谨。</p>
<h2>发送侧延伸：TX FIFO</h2>
<h3>【快递站类比】</h3>
<p>收快递解决后，发快递也会遇到类似问题。业务部门都想把包裹交给前台寄出，但门口只有一辆车，一次只能发一批。</p>
<p>更稳的做法是把待发包裹先排进出库队列。车空了就装下一批，装完再发下一批。业务部门只负责交货，前台负责按顺序发出。</p>
<h3>【技术解耦与实现】</h3>
<p>发送侧通常会有 busy 标志，并在发送完成回调里清除。简单回显可以直接判断空闲后启动 DMA 发送，但多个模块同时打印或发送协议帧时，调用方会被迫关心“现在能不能发”。</p>
<p>TX FIFO 可以把发送入口统一起来：外部模块只写入待发送数据，串口发送驱动在空闲时从 TX FIFO 取一段启动 DMA，发送完成中断清 busy 并继续取下一段。这样发送侧也形成同样边界：用户只负责投递，驱动负责按串口单通道特性串行发出。</p>
<p>回环测试可以同时验证 RX FIFO 和 TX FIFO。PC 端看到的仍然应该是原始随机流，顺序不能乱，内容不能变，两个 DMA 通道的状态不能互相破坏。</p>
<h2>回环测试与可观测性</h2>
<p>每一层优化都应该接受同一个外部判据：PC 端持续发送随机数据，设备原样回显，PC 端负责校验。这样测试目标不会随着实现变化被稀释。</p>
<p>推荐测试分成几组：</p>
<ul>
<li>固定长度短包：验证基础收发和中断入口。</li>
<li>随机长度包：覆盖变长数据和 DMA 批量搬运。</li>
<li>连续随机流：减少空闲时间，验证 HT/TC 能否补上单纯 IDLE 的缺口。</li>
<li>逐步提高波特率：观察系统从稳定到接近极限的过程。</li>
<li>加入主循环负载：验证用户处理变慢时 FIFO 是否能吸收短时间突发。</li>
</ul>
<p>通过标准要明确：</p>
<ul>
<li>PC 端发送字节数和回显字节数一致。</li>
<li>回显内容和发送内容逐字节一致。</li>
<li>字节顺序不乱，不重复，不跳号。</li>
<li>设备不中断运行，发送完成回调能持续推进。</li>
<li>正常压力下队列溢出计数不增长。</li>
<li>超过设计容量时，溢出计数增长，测试报告明确标记为压力越界。</li>
</ul>
<p>随机流压测会打破“刚好按包发送”的理想条件。真实系统里，上位机可能连续发送，也可能短包密集出现，还可能在任意位置停顿。随机长度、随机内容、逐步提高波特率，能更早暴露边界错误。</p>
<h2>适用边界</h2>
<p><code>DMA circular + IDLE + HT/TC + FIFO</code> 适合做通用串口接收底座，特别适合变长数据、突发输入和上层处理时间不稳定的场景。</p>
<p>它仍有容量上限：</p>
<ul>
<li>FIFO 满时仍然会丢数据，工程需要统计并暴露这个事件。</li>
<li>驱动层只搬运字节，不替协议层判断帧边界和恢复策略。</li>
<li>DMA 缓冲区太小会增加 HT/TC 中断频率；如果取消独立软件 FIFO，DMA 缓冲区还要承担仓库职责，长度必须按最坏消费延迟估算。</li>
<li>用户层长期消费不足时，任何缓冲都会被填满，最终必须靠流控、限速或协议重试解决。</li>
</ul>
<p>这套设计包含 DMA 搬运，也包含清楚的接收链路分层：硬件用 DMA 快速落内存，中断用 IDLE/HT/TC 找搬运时机，FIFO 保存已经脱离 DMA 工作区的数据，用户层按自己的协议节奏处理。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="Cortex-M"/>
    <category term="中断"/>
    <category term="FIFO"/>
    <category term="DMA"/>
  </entry>
  <entry>
    <title>CH58x 睡眠中 PB22/PB23 按键唤醒需要打开 RB_PIN_INTX</title>
    <link href="https://createskyblue.github.io/posts/ch58x-sleep-gpio-remap/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ch58x-sleep-gpio-remap/</id>
    <published>2026-07-02T00:00:00.000Z</published>
    <updated>2026-07-02T00:00:00.000Z</updated>
    <summary>RTC能醒按键不醒，从GPIO中断映射查起</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>CH58x 睡眠中 PB22/PB23 按键唤醒需要打开 RB_PIN_INTX</h1>
<p>本文回答一个很具体的问题：CH58x/CH584F 进入睡眠后，RTC 到点能唤醒，PB22/PB23 上的 Boot 或 Reset 按键却没有反应，排查入口应该放在哪里。</p>
<p>结论先放在前面：如果按键接在 PB22/PB23，睡眠唤醒依赖 GPIO 中断线时，需要先打开 <code>RB_PIN_INTX</code>，把 <code>INT24/INT25</code> 映射到 PB22/PB23。</p>
<h2>现象</h2>
<p>设备已经进入低功耗睡眠，串口没有持续打印，RTC 到点后可以醒来并继续运行。这个现象说明睡眠路径、RTC 唤醒源、RAM 保持这几项基本有效。</p>
<p>异常点在按键：睡眠期间按 Boot/PB22 或 Reset/PB23 没有唤醒效果。代码里已经配置了 GPIO 输入、下降沿中断、GPIO 唤醒源，看起来像是 GPIO 唤醒没有生效。</p>
<p>这里最容易误判的一点是：运行时能读到 PB22/PB23 电平变化，不代表它们已经接到了睡眠唤醒需要的中断线。</p>
<h2>原因</h2>
<p>CH58x 这类芯片的 GPIO 中断能力需要看中断线分配。PB22/PB23 和 PB8/PB9 共用 <code>INT24/INT25</code> 这组中断线，默认位置在 PB8/PB9。</p>
<p>SDK 头文件里有两个关键信息：</p>
<ul>
<li><code>GPIOB_ITModeCfg()</code> 支持 PB0-PB15、PB22-PB23，并且 PB8/PB9 与 PB22/PB23 通过 <code>RB_PIN_INTX</code> 选择。</li>
<li><code>RB_PIN_INTX</code> 的含义是：<code>0</code> 时 <code>INT24/25</code> 在 PB8/PB9，<code>1</code> 时 <code>INT24/25</code> 在 PB22/PB23。</li>
</ul>
<p>所以只写：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">GPIOB_ITModeCfg</span><span style="color:#D4D4D4">(GPIO_Pin_22, GPIO_ITMode_FallEdge);</span></span>
<span class="line"><span style="color:#DCDCAA">GPIOB_ITModeCfg</span><span style="color:#D4D4D4">(GPIO_Pin_23, GPIO_ITMode_FallEdge);</span></span></code></pre><p>还不够。没有打开重映射时，睡眠唤醒事件仍然落在 PB8/PB9 这一侧。PB22/PB23 可以作为普通输入读电平，却不能按预期触发睡眠唤醒。</p>
<h2>后果</h2>
<p>这个故障会给调试带来一个假象：RTC 能醒，所以低功耗入口看起来没问题；GPIO 运行时能读，所以按键硬件看起来也没问题。</p>
<p>实际缺口在“GPIO 引脚”和“唤醒中断线”之间。只查按键电路、上拉、下降沿、唤醒源开关，可能会绕过真正的配置项。</p>
<h2>修复</h2>
<p>按键初始化时，在配置 PB22/PB23 中断前打开 <code>RB_PIN_INTX</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">GPIOPinRemap</span><span style="color:#D4D4D4">(ENABLE, RB_PIN_INTX);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">GPIOB_ModeCfg</span><span style="color:#D4D4D4">(APP_BOARD_BUTTON_PIN, GPIO_ModeIN_PU);</span></span>
<span class="line"><span style="color:#DCDCAA">GPIOB_ITModeCfg</span><span style="color:#D4D4D4">(APP_BOARD_BUTTON_PIN, GPIO_ITMode_FallEdge);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">GPIOB_ModeCfg</span><span style="color:#D4D4D4">(APP_BOARD_RESET_PIN, GPIO_ModeIN_PU);</span></span>
<span class="line"><span style="color:#DCDCAA">GPIOB_ITModeCfg</span><span style="color:#D4D4D4">(APP_BOARD_RESET_PIN, GPIO_ITMode_FallEdge);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">PWR_PeriphWakeUpCfg</span><span style="color:#D4D4D4">(ENABLE,</span></span>
<span class="line"><span style="color:#D4D4D4">                    RB_SLP_GPIO_WAKE | RB_GPIO_EDGE_WAKE | RB_SLP_RTC_WAKE,</span></span>
<span class="line"><span style="color:#D4D4D4">                    Long_Delay);</span></span></code></pre><p>如果设备只要求 RAM 保持，睡眠入口可以保留 RAM 域，醒来后恢复时钟和必要外设：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> APP_BOARD_SLEEP_RETENTION_MASK (RB_PWR_RAM32K </span><span style="color:#D4D4D4">|</span><span style="color:#569CD6"> RB_PWR_RAM96K)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">LowPower_Sleep</span><span style="color:#D4D4D4">(APP_BOARD_SLEEP_RETENTION_MASK);</span></span></code></pre><p>在这次板级验证中，<code>LowPower_Sleep()</code> 这条路径可以同时保留 RTC 和 GPIO 唤醒。<code>LowPower_Sleep_WFE()</code> 曾经只证明 RTC 能醒，没有证明 PB22/PB23 GPIO 唤醒可用。</p>
<h2>预防</h2>
<p>低功耗按键设计时，不要只按 GPIO 编号分配引脚。需要同时确认这几项：</p>
<ul>
<li>目标引脚是否支持 GPIO 中断。</li>
<li>该中断线是否和别的引脚复用。</li>
<li>复用中断线是否需要 remap 或 alternate 配置。</li>
<li>睡眠入口是否启用了 <code>RB_SLP_GPIO_WAKE</code> 和正确的边沿唤醒。</li>
<li>验证时要分别测 RTC 唤醒、GPIO 唤醒、醒来后 RAM 状态。</li>
</ul>
<p>对 PB22/PB23 这类复用中断脚，运行时读电平只能证明输入功能有效，不能证明睡眠唤醒链路完整。RTC 能醒、按键不醒时，优先查中断线映射。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="低功耗"/>
    <category term="MCU"/>
    <category term="中断"/>
    <category term="寄存器映射"/>
    <category term="工程调试"/>
  </entry>
  <entry>
    <title>异常振动监测终端系统架构：多任务协作与双通道遥测</title>
    <link href="https://createskyblue.github.io/posts/vibration-monitor-system-architecture/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/vibration-monitor-system-architecture/</id>
    <published>2026-07-02T00:00:00.000Z</published>
    <updated>2026-07-02T00:00:00.000Z</updated>
    <summary>4G 物联网终端，震动检测，边缘 AI 实践</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>异常振动监测终端系统架构：多任务协作与双通道遥测</h1>
<blockquote>
<p>本文基于真实项目脱敏整理。文中函数签名和接口名称为说明架构而设计，与实际业务代码不完全对应，请关注设计思路而非具体 API 命名。</p>
</blockquote>
<p>我负责了一款工业振动异常监测终端的系统架构设计。设备运行 RT-Thread，搭载 4G LTE Cat.1 模组和三轴 MEMS 加速度计，内置 ST 边缘 AI 库。核心业务是：采集振动数据 → AI 本地推理识别异常模式 → 将状态和原始波形上报云端。本文从顶层视角复盘系统的任务划分、调用链和数据通路设计。</p>
<h2>一、硬件与软件底座</h2>
<table>
<thead>
<tr>
<th>层级</th>
<th>选型</th>
</tr>
</thead>
<tbody><tr>
<td>主控</td>
<td>STM32 L4 系列（超低功耗）</td>
</tr>
<tr>
<td>操作系统</td>
<td>RT-Thread</td>
</tr>
<tr>
<td>通信</td>
<td>4G LTE Cat.1 模组</td>
</tr>
<tr>
<td>传感器</td>
<td>三轴 MEMS 加速度计</td>
</tr>
<tr>
<td>AI 引擎</td>
<td>ST 边缘 AI 库（ST 官方边缘推理库）</td>
</tr>
<tr>
<td>定位</td>
<td>GPS + 基站辅助定位</td>
</tr>
</tbody></table>
<h2>二、系统任务架构</h2>
<p>系统按职责划分为四个任务，优先级从高到低：</p>
<table>
<thead>
<tr>
<th>任务</th>
<th>优先级</th>
<th>职责</th>
</tr>
</thead>
<tbody><tr>
<td><strong>数据采集任务</strong></td>
<td>高</td>
<td>响应传感器中断，驱动振动检测状态机，执行 AI 推理，生产上报数据</td>
</tr>
<tr>
<td><strong>配置管理任务</strong></td>
<td>中</td>
<td>处理远程参数下发，持久化到 Flash，保证掉电不丢失</td>
</tr>
<tr>
<td><strong>4G 通讯任务</strong></td>
<td>中低</td>
<td>独占 4G 模组，对上提供异步消息 API，统管 MQTT 和 TCP 两条通道</td>
</tr>
<tr>
<td><strong>低功耗管理任务</strong></td>
<td>最低</td>
<td>系统空闲时自动休眠，事件驱动唤醒</td>
</tr>
</tbody></table>
<p>任务间通过事件和函数调用协作，不直接共享数据：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>传感器中断</span></span>
<span class="line"><span>    │</span></span>
<span class="line"><span>    ▼</span></span>
<span class="line"><span>数据采集任务 ──(事件)──→ 配置管理任务（参数变更时）</span></span>
<span class="line"><span>    │</span></span>
<span class="line"><span>    │ (调用通讯 API)</span></span>
<span class="line"><span>    │</span></span>
<span class="line"><span>    ▼</span></span>
<span class="line"><span>4G 通讯任务 ──→ MQTT / TCP ──→ 云端</span></span></code></pre><p>数据采集任务生产上报数据后，调用通讯层 API 将数据交给通讯任务；通讯任务通过 4G 模组发往云端。业务代码只跟函数打交道，不感知模组的存在。</p>
<p>除了 4G 远程通道，设备还提供 NFC 本地配置接口。NFC 芯片与 MCU 通过 FIFO 交换数据，我封装了一个轻量级同步协议，支持读卡器近距离读写设备参数——无需 4G 联网、无需设备上电，在生产调试和现场维护场景下尤其实用。</p>
<h2>三、数据采集与 AI 推理链路</h2>
<p>设备日常处于深度休眠状态，CPU 降频运行，4G 模组断电。振动检测链路分四个阶段：</p>
<ol>
<li><p><strong>稀疏唤醒</strong>。加速度计配置了运动阈值，振动超过阈值时产生硬件中断，将 MCU 从休眠中唤醒。此时只记录唤醒时间戳，不启动采集，立即重新入睡。这个阶段功耗极低，仅靠中断计数感知环境振动密度。</p>
</li>
<li><p><strong>频率判定与模组预上电</strong>。当短时间内唤醒次数超过预设频率阈值，说明振动事件具有持续性——不是偶发的环境扰动，而是真正需要关注的异常振动模式。此时设备正式唤醒：提高 CPU 主频，传感器切换到高采样率模式，同时 4G 模组开始上电入网。4G 模组从冷启动到驻网成功大约需要十几秒，恰好与振动持续采集窗口重叠——等到 AI 推理完成、确认需要上报时，模组已经就绪，无需额外等待。</p>
</li>
<li><p><strong>AI 推理</strong>。以高采样率持续采集三轴加速度原始数据，喂入 ST 边缘 AI 库 模型进行本地推理。推理是计算密集型任务，短暂的 CPU 升频窗口刚好覆盖计算需求，输出分类结果和置信度后立即降回低功耗频率。</p>
</li>
<li><p><strong>上报决策</strong>。如果推理结果需要关注且上传功能已使能，走双通道上报流程：先通过 TCP 将原始波形数据发送到 AI 分析服务，服务端返回一个上报 ID；再将这个 ID 打包进 MQTT 报文，通过 MQTT 通道发送。云端根据上报 ID 将两次上报关联。上报完成后 4G 模组断电，系统恢复深度休眠。</p>
</li>
</ol>
<p>这条链路的设计原则是<strong>按需唤醒，用完即退</strong>——传感器阈值做第一道筛选，频率判定做第二道筛选，AI 推理做最终裁决。三层递进保证 CPU 高频和 4G 模组只在真正需要时才工作，最大限度压缩高功耗窗口。</p>
<h2>四、双通道遥测：控制面与数据面分离</h2>
<p>设备需要上报两类数据，特性截然不同：</p>
<table>
<thead>
<tr>
<th></th>
<th>MQTT 通道</th>
<th>TCP 通道</th>
</tr>
</thead>
<tbody><tr>
<td><strong>数据类型</strong></td>
<td>设备状态、报警、位置、电量</td>
<td>AI 原始加速度波形</td>
</tr>
<tr>
<td><strong>数据量</strong></td>
<td>小（KB 级 JSON）</td>
<td>大（几百 KB 二进制）</td>
</tr>
<tr>
<td><strong>频次</strong></td>
<td>周期性 + 事件触发</td>
<td>仅 AI 推理触发</td>
</tr>
<tr>
<td><strong>方向</strong></td>
<td>双向（含下行命令）</td>
<td>单向上传 + 简短响应</td>
</tr>
<tr>
<td><strong>传输</strong></td>
<td>MQTT over TLS</td>
<td>TCP Raw Socket</td>
</tr>
</tbody></table>
<p><strong>MQTT 通道</strong>承载常规遥测和远程控制。上行报文包含设备标识、信号强度、GPS 位置、电池电量、报警等级等信息；下行支持两类指令：运行参数热更新和 OTA 固件升级。</p>
<p><strong>TCP 通道</strong>专用于 AI 原始波形数据。采用自定义二进制帧协议：固定头 → JSON 元数据（采样率、AI 分类结果、置信度等）→ 原始三轴加速度数组 → CRC32 校验。比走 MQTT broker 中转更高效，也避免大数据冲击信令通道。</p>
<p>两路数据通过服务端返回的上报 ID 在业务层关联——MQTT 报文携带这个 ID，云端据此把设备状态和 AI 分析结果串成闭环。</p>
<h2>五、通讯层 API 封装</h2>
<p>通讯任务对上暴露一组函数式 API，业务任务不感知 4G 模组的存在：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// MQTT 发布</span></span>
<span class="line"><span style="color:#DCDCAA">comm_mqtt_pub</span><span style="color:#D4D4D4">(topic, payload, len);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// TCP 直连（AI 波形上报）</span></span>
<span class="line"><span style="color:#DCDCAA">comm_tcp_connect</span><span style="color:#D4D4D4">(addr, port);</span></span>
<span class="line"><span style="color:#DCDCAA">comm_tcp_send</span><span style="color:#D4D4D4">(chunk, len, is_last);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// 信息查询</span></span>
<span class="line"><span style="color:#DCDCAA">comm_gps_query</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">comm_signal_query</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// 回调注册</span></span>
<span class="line"><span style="color:#DCDCAA">comm_register_recv</span><span style="color:#D4D4D4">(topic, callback_fn);</span></span>
<span class="line"><span style="color:#DCDCAA">comm_register_sub</span><span style="color:#D4D4D4">(topic, callback_fn);</span></span></code></pre><p>调用方只需调用函数，函数内部自动完成 AT 命令执行和重试。所有与 4G 模组的交互细节——AT 时序、超时重试、URC 处理——全部封装在通讯任务内部，业务代码零锁、零 AT 命令。</p>
<h2>六、配置与云端协同</h2>
<p>设备支持两种配置途径——远程 MQTT 和本地 NFC：</p>
<ul>
<li><strong>NFC 本地配置</strong>：通过 NFC 芯片的 FIFO 与读卡器双向交换数据，封装了轻量级同步协议。无需 4G 联网即可读写设备参数，适用于生产调试和现场维护。</li>
<li><strong>远程参数热更新</strong>：采集周期、报警阈值、使能开关等通过 MQTT 实时下发，设备收到后即时生效并持久化到 Flash。</li>
<li><strong>OTA 固件升级</strong>：MQTT 通知升级 → 断开 MQTT → HTTP 下载固件包 → CRC 校验 → 写入 Flash → 自动重启。</li>
<li><strong>时间同步</strong>：利用 4G 网络时间自动校准设备 RTC，保证事件时间戳准确。</li>
<li><strong>数据关联</strong>：MQTT 状态报文和 TCP 波形报文通过上报 ID 关联，云端统一展示。</li>
</ul>
<h2>七、设计总结</h2>
<p>这套架构的核心思路是<strong>按职责拆分任务，按数据特性分流通道</strong>。数据采集、AI 推理、通讯传输、配置管理各司其职，任务间通过事件和 API 调用解耦。双通道设计将控制面和数据面分离，MQTT 保证实时双向通信，TCP 保证大数据吞吐效率。</p>
<p>面向资源受限的嵌入式设备做系统设计，核心是把每个模块的边界划清楚，让调用链简单、数据流向可控。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="物联网"/>
    <category term="RTOS"/>
    <category term="RT-Thread"/>
    <category term="异常检测"/>
    <category term="边缘AI"/>
    <category term="系统设计"/>
  </entry>
  <entry>
    <title>量产 Modbus 数据重组网关项目复盘</title>
    <link href="https://createskyblue.github.io/posts/n320a-modbus-gateway/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/n320a-modbus-gateway/</id>
    <published>2026-06-30T00:00:00.000Z</published>
    <updated>2026-06-30T00:00:00.000Z</updated>
    <summary>量产 Modbus 网关：多型号仪表排成一张寄存器表，换型号不改 PLC 程序</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>量产 Modbus 数据重组网关项目复盘</h1>
<p><strong>项目背景</strong>：本网关基于 STM32F103 + RT-Thread 实现。PLC 通过 RS-485 接多台 Modbus 仪表，同厂家不同型号的同一种业务数据（如当前温度）寄存器地址也各不相同。设备一多，PLC 侧要为每型号写一套地址映射，现场换一台设备就可能要改 PLC 程序。本网关把这些分散寄存器收敛成一张统一寄存器表，PLC 只按固定偏移读写，不关心底层接了什么型号，换型号不改 PLC 程序。</p>
<p><strong>职责</strong>：独立设计并实现网关固件与寄存器映射架构。</p>
<p><strong>核心技术</strong></p>
<ul>
<li><strong>业务语义连续表</strong>：PLC 要分多次通讯读不同型号零散寄存器、效率低<ul>
<li>解决：按业务语义排连续地址分区，PLC 一次连续读拿齐同语义数据。</li>
</ul>
</li>
<li><strong>虚拟地址解耦</strong>：设备型号差异让上位机强耦合从机，换型号要改程序<ul>
<li>解决：映射表把虚拟地址翻译回&quot;通道+真实寄存器&quot;，差异封装在网关，新增型号只加配置。</li>
</ul>
</li>
<li><strong>缓存解耦</strong>：RS-485 半双工慢，上位机直轮询被阻塞、实时性崩<ul>
<li>解决：后台轮询刷缓存，PLC 读缓存零等待，总线往返不阻塞上位机。</li>
</ul>
</li>
<li><strong>读写冲突隔离</strong>：轮询旧值冲掉刚写入的值，写操作悄悄失效<ul>
<li>解决：位图拦截误写、保护待同步寄存器，写后回读真实值清标记。</li>
</ul>
</li>
<li><strong>总线带宽调度</strong>：多设备抢总线，报警高优数据来不及回、零散读浪费带宽<ul>
<li>解决：行动点数分优先级、相邻地址合并单包、单次 ≤124 寄存器守上限。</li>
</ul>
</li>
<li><strong>配置掉电保护</strong>：配置掉电损坏=网关变砖，保存中途断电文件半截<ul>
<li>解决：延迟保存 + A/B 双分区交叉修复 + 拨码兜底，任意时刻至少一份完整配置。</li>
</ul>
</li>
<li><strong>RT-Thread RTOS 多任务架构</strong>：基于 RT-Thread 把采集/缓存刷新/上位机交互分任务并发，半双工等待不阻塞；消息队列/信号量同步数据。</li>
</ul>
<h2>同厂家多型号寄存器地址不一致，PLC 程序失控</h2>
<p>同样是&quot;当前温度&quot;，A 型号可能在 0x2000，B 型号在 0x2004。PLC 每接一种新型号都要加一套地址映射逻辑，差异散落在 PLC 程序里，换设备即改程序、不可复用。</p>
<p>解决方向是把差异收敛到网关：对下适配不同型号地址差异，对上输出统一寄存器表。PLC 读的是网关整理好的地址空间，不是物理设备的原始寄存器。</p>
<h2>业务语义连续表：把零散寄存器排成可整块读的统一地址空间</h2>
<p><img src="/posts/n320a-modbus-gateway/modbus-address-reorg.webp" alt="地址碎片整理成连续表">
<em>把被打散的数据，按业务语义重新排成连续分区。</em></p>
<h2>虚拟地址解耦：映射表把设备差异封装在网关内</h2>
<p>虚拟地址如何落到真实设备？网关维护两张静态配置表：<strong>采集任务表</strong>描述每台下位机的物理寄存器起始地址、长度与读写权限，<strong>映射规则表</strong>描述这些物理寄存器投递到虚拟空间的哪个分区和通道偏移。</p>
<p>上位机只访问统一虚拟地址，网关按表翻译回&quot;具体通道 + 真实寄存器&quot;，设备型号差异彻底封装在网关内——换型号只改配置、不动 PLC 程序。</p>
<p><img src="/posts/n320a-modbus-gateway/modbus-virtual-mapping.webp" alt="虚拟地址映射异构设备">
<em>用虚拟地址屏蔽设备差异，对上统一成一张寄存器表。</em></p>
<h2>慢速半双工总线阻塞上位机：后台轮询缓存换零等待读取</h2>
<p>若 PLC 每次读取都透传到 RS-485，系统很慢：多设备共享半双工总线，报文往返时延大。</p>
<p>做法：后台按采集任务表持续轮询下位机刷进缓存，PLC 直接读缓存。</p>
<p><img src="/posts/n320a-modbus-gateway/modbus-polling-cache.webp" alt="慢速总线变成快速读取">
<em>后台轮询负责采集，缓存负责对上快速返回。</em></p>
<p>为榨干总线性能，做三项核心处理：</p>
<ol>
<li><strong>优先级与行动点数调度</strong>：高优先级（温度、报警）和普通优先级分别建轮询任务；通过&quot;行动点数&quot;分配总线使用权，高优先级执行一定次数后把点数让给普通任务，实现带宽精细化分配。</li>
<li><strong>相邻地址自动粘黏合并</strong>：轮询时状态机自动检测相邻且物理地址连续的规则，合并成一次批量读取；单次合并严格 ≤124 寄存器，大幅减少总线碎片化往返。</li>
<li><strong>精准失效标记</strong>：通信失败时不保留旧值，改按出错任务块规则向缓存填对应异常状态值，让上位机准确知道哪块数据当前不可信。</li>
</ol>
<h2>写缓存被旧值冲掉：位图隔离读写冲突并写后回读校验</h2>
<p>PLC 刚把新设定值写进网关，若后台轮询刚好读回下位机旧值，就会把缓存里的新值冲掉——读写路径在争抢同一份数据。</p>
<p>设计引入位图机制分开两个概念：</p>
<ul>
<li><strong>读写权限拦截</strong>：初始化时按配置表标记所有可写区域，直接拦截对只读寄存器的误写。</li>
<li><strong>待同步状态保护</strong>：刚被上位机写过但未同步到下位机的寄存器，位图置位标记；后台轮询拿回的旧值无法覆盖被标记的寄存器。</li>
<li><strong>写后回读校验</strong>：网关向下位机推送写缓存时，写入成功立即发起回读，用真实值更新本地缓存并清除标记，确保数据一致性。</li>
</ul>
<h2>配置掉电损坏：延迟保存 + A/B 双分区 + 拨码兜底</h2>
<p>配置保存最怕频繁写废 Flash，以及断电导致配置损坏：</p>
<ol>
<li><strong>延迟保存与软重启</strong>：配置变更后不立即落盘，经事件机制累积写操作；超时无新操作才触发落盘并执行软重启，避免运行时热切换参数的边界问题。</li>
<li><strong>双分区交叉修复</strong>：Flash 分 A/B 两独立分区；读取时若 A 校验损坏，自动读 B 并尝试修复 A；写入时总是先擦写已知损坏分区，再擦写另一分区，确保任意时刻断电至少一份完整配置。</li>
<li><strong>硬件拨码兜底</strong>：启动时优先读硬件拨码开关初始化基础从机地址和波特率，即使 Flash 配置彻底损坏，设备仍能被上位机访问并重新配置。</li>
</ol>
<h2>设计取舍与可复用边界</h2>
<ol>
<li><strong>静态配置</strong>：映射规则编译时静态定义，不支持动态下发；牺牲远程加设备灵活性，换确定性与防误操作。</li>
<li><strong>内存换速度</strong>：全量数据缓存到 RAM，占用内存换 PLC 侧零等待。</li>
<li><strong>复杂度集中</strong>：适配逻辑集中在网关单点，PLC 程序标准化；现场设备远多于网关，更划算。</li>
<li><strong>设备适配成本</strong>：新增型号只需加一套物理地址与映射偏移描述配置，架构不动；成本从&quot;改 PLC 程序&quot;降为&quot;加一行配置&quot;。</li>
</ol>
<p>现场效果：PLC 面对固定格式寄存器表；下位机换型号不改 PLC 程序；调好的 PLC 程序可复用到不同现场。</p>]]></content>
    <category term="嵌入式"/>
    <category term="STM32"/>
    <category term="RT-Thread"/>
    <category term="Modbus"/>
    <category term="寄存器映射"/>
    <category term="系统设计"/>
  </entry>
  <entry>
    <title>资源受限 MCU 长周期故障，用内存 Dump 定位并用故障注入验证</title>
    <link href="https://createskyblue.github.io/posts/memory-dump-context-debugging/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/memory-dump-context-debugging/</id>
    <published>2026-06-29T00:00:00.000Z</published>
    <updated>2026-06-29T00:00:00.000Z</updated>
    <summary>资源受限MCU长周期故障，内存Dump定位+故障注入验证</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>资源受限 MCU 长周期故障，用内存 Dump 定位并用故障注入验证</h1>
<p><img src="/posts/memory-dump-context-debugging/dump-compare-demo.webp" alt="十六进制内存 Dump 对比假数据演示"></p>
<p>低端 MCU 上，长周期、低概率、重启即恢复的状态类故障很难查：不能断点，也铺不开日志。</p>
<p>方法是在关键位置把上下文按原始字节 dump 成十六进制，带上触发点（休眠前、唤醒后、异常现场）。字段解析和差异高亮放到 PC 侧做；对比正常快照与异常快照，变化的字段就是证据。截图是纯假数据，只演示 dump 可被解析，不对应真实业务。</p>
<p>修复后别再靠长时间挂机验证：把异常 dump 回灌测试路径，看能否稳定复现原故障，以及修复后能否从同一状态恢复。验证的是恢复能力。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="低功耗"/>
    <category term="故障诊断"/>
    <category term="工程调试"/>
    <category term="可观测性"/>
  </entry>
  <entry>
    <title>信号处理实验室用拖拽流程图完成浏览器端滤波和波形分析</title>
    <link href="https://createskyblue.github.io/posts/signal-analysis-lab/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/signal-analysis-lab/</id>
    <published>2026-06-29T00:00:00.000Z</published>
    <updated>2026-06-29T00:00:00.000Z</updated>
    <summary>信号处理实验室拖拽流程图做浏览器端滤波和波形分析</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>信号处理实验室用拖拽流程图完成浏览器端滤波和波形分析</h1>
<p><img src="/posts/signal-analysis-lab/img/cover.webp" alt="信号处理实验室界面"></p>
<p>信号处理实验室是一个运行在浏览器里的可视化信号处理工具。它把 CSV 输入、信号发生器、滤波器、窗口统计、特征提取、FFT 频谱、示波器和 CSV 输出做成可拖拽节点，用户通过连线组织处理流程，再在右侧查看波形结果。</p>
<p>项目入口：</p>
<ul>
<li>GitHub：<a href="https://github.com/createskyblue/signal-analysis-lab">https://github.com/createskyblue/signal-analysis-lab</a></li>
<li>在线体验：<a href="https://createskyblue.github.io/signal-analysis-lab/">https://createskyblue.github.io/signal-analysis-lab/</a></li>
</ul>
<p>在线页面打开后，可以点击左下角的「演示数据」生成含噪声的正弦波测试流程图，用它快速观察滤波、比较、分流和示波器显示效果。</p>
<h2>交付内容</h2>
<p>这个项目交付的是一个浏览器端信号处理工作台，不需要后端服务即可完成常见实验流程：</p>
<ul>
<li>从 CSV 文件导入采样序列，设置采样率，并把结果导出为纯净 CSV。</li>
<li>使用信号发生器生成默认正弦波，作为测试信号或流程参考。</li>
<li>把多个处理节点连接成流程图，按连接关系逐级计算。</li>
<li>在右侧示波器中查看多路波形，每个图表都有独立光标读数。</li>
<li>保存、导入和导出 JSON 项目文件，浏览器本地也会自动保存当前项目。</li>
<li>切换中文和英文界面，便于把同一套工具交给不同使用者。</li>
</ul>
<p>项目定位为轻量、可共享、可快速验证的实验界面，不承担专业 DSP 软件的完整建模和分析能力。它适合做滤波链路试验、传感器波形观察、简单特征提取、流程演示和自定义算法验证。</p>
<h2>流程图是主要交互模型</h2>
<p>工具的核心交互是节点和连线。左侧栏提供节点库，中间画布用于组织流程，右侧面板显示输出波形。</p>
<p>常用操作包括：</p>
<ul>
<li>从左侧拖拽节点到画布。</li>
<li>从输出端口拖到输入端口完成连线。</li>
<li>点击或右键删除连线。</li>
<li>选中节点后按 <code>Delete</code> 删除节点。</li>
<li>在画布空白处框选多个节点，并拖动一组节点。</li>
<li>双击节点标题重命名。</li>
<li>使用 <code>Ctrl+C</code> / <code>Ctrl+V</code> 复制粘贴节点。</li>
<li>使用 <code>Ctrl+Z</code> / <code>Ctrl+Y</code> 撤销和重做。</li>
<li>用鼠标中键平移画布，按空格复位视图。</li>
<li>按 <code>F5</code> 或点击运行按钮执行流程。</li>
</ul>
<p>这种模型的好处是处理链路会直接呈现在界面上。相比把一串滤波函数写在脚本里，流程图更适合调参、讲解和定位某一路中间结果。</p>
<h2>节点覆盖常见信号处理步骤</h2>
<p>项目里的节点按用途分组，覆盖了从输入、滤波、统计、特征提取到输出的常见步骤。</p>
<p>输入输出节点包括 CSV 输入、信号发生器、CSV 输出和示波器。CSV 输出节点可以单独导出，也可以在打开「自动导出」后随运行流程导出。导出的 CSV 正文不带表头，每行一个采样点，节点名称、时间戳和采样率会写入文件名。</p>
<p>滤波器节点包括 RC 低通、高通、带通、指数平均滤波、FIR 线性相位、Hampel 滤波和卡尔曼滤波。Hampel 节点会同时输出滤波结果和局部 MAD，便于观察阈值调节对异常点处理的影响。</p>
<p>运算和变换节点包括加法器、减法器、乘法器、加权合成器、反相器、绝对值、对数乘法器、归一化、限位器、移相器、迟滞比较器、去直流偏置和微分器。它们承担流程中的基础组合逻辑。</p>
<p>窗口统计节点包括滑动平均、中值窗口、最大值窗口、最小值窗口、极值窗口、滑动标准差、因果窗口熵和矩形波检测器。它们适合观察局部趋势、尖峰、波动强度和边沿规律。</p>
<p>特征提取和流程控制节点包括峰谷检测器、间隔到 BPM、分流器、开关、多路选择器和比较选择器。峰谷检测器可以配合迟滞比较器做触发驱动的分段极值搜索；比较选择器可以根据比较值逐点选择最大或最小值对应的信号。</p>
<p>项目还提供自定义节点。用户可以写 JavaScript 处理输入信号，单输入单输出时可沿用 <code>return signal.map(...)</code> 的简单写法；多输入时读取 <code>inputs.in</code>、<code>inputs.in2</code> 等端口数组，多输出时返回 <code>{ out: arr1, out2: arr2 }</code>。</p>
<h2>波形显示服务于调参</h2>
<p>右侧图表面板承担调参过程中的观察入口。</p>
<ul>
<li>示波器节点可以设置颜色，也可以关闭「启用」，关闭后该路不会出现在右侧波形显示中。</li>
<li>每张图都有独立 X 轴游标读数，普通波形显示采样点，FFT 频谱显示频率 Hz。</li>
<li>鼠标滚轮可以缩放图表。</li>
<li>点击图表标题或按钮可以折叠显示，隐藏后保留标题。</li>
<li>点击图表会把对应示波器节点定位到画布中心，并短暂显示十字准心。</li>
<li>拖拽图表标题可以调整多个图表的显示顺序。</li>
</ul>
<p>这些细节让流程图和波形图之间能互相定位。节点多起来以后，单纯看画布很难知道哪条曲线来自哪个输出；单纯看波形也很难快速回到对应节点。图表定位把这两个视图连在一起。</p>
<h2>自定义节点和 AI 上下文</h2>
<p>自定义节点让工具可以扩展到内置节点没有覆盖的处理逻辑。为了降低编写成本，界面提供「复制上下文」按钮，可以把当前项目流程图和自定义节点 API 说明复制到剪贴板。</p>
<p>这份上下文不包含信号数据，主要用于让外部 AI 工具理解节点拓扑、端口命名和可用 API，然后辅助编写自定义节点代码。对临时实验来说，这比从零描述整个流程图更直接。</p>
<h2>WebSocket 实时数据流</h2>
<p>项目支持通过 WebSocket 从 ESP32 等设备接收实时传感器数据。典型流程是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>开始节点 -> 自定义节点(WebSocket 客户端) -> 滤波器链 -> 示波器</span></span></code></pre><p>这里有一个浏览器安全限制：在线版是 HTTPS 页面，浏览器会拒绝连接明文 <code>ws://</code> 设备。因此实时连接 ESP32 这类本地设备时，需要把项目克隆到本地并使用开发服务器运行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">npm</span><span style="color:#CE9178"> install</span></span>
<span class="line"><span style="color:#DCDCAA">npm</span><span style="color:#CE9178"> run</span><span style="color:#CE9178"> dev</span></span></code></pre><p>本地页面使用 <code>http://localhost:3000</code> 调试时，才能连接明文 WebSocket 设备。更详细的实时数据流用法见项目文档 <code>doc/websocket-streaming.md</code>。</p>
<h2>使用边界</h2>
<p>这个项目更适合快速实验和交互式验证，不适合直接承诺严肃生产环境里的算法正确性。</p>
<p>几个边界需要明确：</p>
<ul>
<li>带通滤波由低通和高通级联得到，两个截止频率应保持足够间隔。</li>
<li>采样率、窗口长度、截止频率、滤波阶数等参数仍需要按实际信号含义设置。</li>
<li>CSV 导入输出适合离线样本和中小规模数据，定位为浏览器端实验工具。</li>
<li>WebSocket 实时数据受浏览器安全策略限制，在线 HTTPS 页面不能直接连明文 <code>ws://</code>。</li>
<li>自定义节点执行的是用户提供的 JavaScript，应把它当作本地实验扩展能力使用。</li>
</ul>
<p>它的价值在于把信号处理链路从代码脚本变成可观察、可保存、可共享的流程图。对于传感器波形、滤波器调参、异常点处理和简单频域分析，这种界面能明显缩短试错路径。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="前端"/>
    <category term="Canvas"/>
    <category term="信号处理"/>
    <category term="数据可视化"/>
    <category term="信号处理实验室"/>
    <category term="Vite"/>
  </entry>
  <entry>
    <title>使用VNA检查PCB射频通路并完成天线PI匹配</title>
    <link href="https://createskyblue.github.io/posts/%E4%BD%BF%E7%94%A8VNA%E8%BF%9B%E8%A1%8C%E9%98%BB%E6%8A%97%E5%8C%B9%E9%85%8D/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/%E4%BD%BF%E7%94%A8VNA%E8%BF%9B%E8%A1%8C%E9%98%BB%E6%8A%97%E5%8C%B9%E9%85%8D/</id>
    <published>2026-06-29T00:00:00.000Z</published>
    <updated>2026-06-29T00:00:00.000Z</updated>
    <summary>VNA板上校准、50Ω通路检查与PI天线匹配</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>使用VNA检查PCB射频通路并完成天线PI匹配</h1>
<p><img src="/posts/使用VNA进行阻抗匹配/cover.webp" alt="VNA匹配后的490MHz实测结果"></p>
<p>这篇文章只解决一个问题：VNA线已经焊到射频芯片旁边，PCB也预留了PI焊盘，下一步该接什么、焊什么、看什么。</p>
<p>先选任务。只选一行，后面按对应章节操作。</p>
<table>
<thead>
<tr>
<th align="left">你的目标</th>
<th align="left">天线接口接什么</th>
<th align="left">做到哪里结束</th>
</tr>
</thead>
<tbody><tr>
<td align="left">检查PCB走线和天线接口</td>
<td align="left"><code>50 Ω</code>假负载</td>
<td align="left">确认板上通路正常；需要窄带补偿时再调PI</td>
</tr>
<tr>
<td align="left">匹配固定外接天线或板载天线</td>
<td align="left">最终使用的天线</td>
<td align="left">调整PI元件并检查整个工作频段</td>
</tr>
</tbody></table>
<p>固定外接天线和板载天线使用同一套匹配步骤。两者只在“实际天线怎么接入”这里不同。</p>
<p>如果使用已经带射频匹配的无线模块，通常从模块的<code>ANT</code>端开始测。使用裸射频芯片时，先照官方参考电路完成芯片侧匹配，再从官方标出的<code>50 Ω</code>端口往天线方向测。</p>
<p>本文实测范围为<code>470MHz</code>至<code>510MHz</code>，重点频点为<code>490MHz</code>。</p>
<h2>1. VNA接线与扫频设置</h2>
<p>VNA不能承受无线芯片的发射功率，测量前先把芯片与这段射频通路断开。</p>
<ol>
<li>关闭PCB电源，拆下射频芯片或无线模块，也可以断开其RF引脚与测量点之间的连接</li>
<li>把VNA射频线的芯线焊到原来的<code>ANT</code>信号焊盘</li>
<li>把射频线的屏蔽层焊到信号焊盘旁边的GND</li>
<li>在VNA上设置<code>470MHz</code>至<code>510MHz</code>扫频范围，并把标记放在<code>490MHz</code></li>
<li>焊元件前断开VNA；校准完成后不再移动射频线和焊点</li>
</ol>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-09-42.webp" alt="射频线接入点与三个匹配焊盘的位置"></p>
<p>图中<code>馈线#4</code>接信号，<code>屏蔽层#5</code>接GND。射频线尽量短，屏蔽层的接地点尽量靠近信号焊盘。</p>
<h2>2. 在第一个PI焊盘完成三次校准</h2>
<p>射频线、焊点和转接头都会带来误差。先让VNA记住三个已知状态，后面的读数就从<code>焊盘#1</code>开始计算。</p>
<p>校准时，<code>焊盘#2</code>和<code>焊盘#3</code>一直保持空白，只操作靠近芯片的并联焊盘<code>#1</code>：</p>
<ol>
<li><code>焊盘#1</code>什么都不焊，在VNA上执行<code>OPEN</code>，也就是开路校准</li>
<li><code>焊盘#1</code>用<code>0 Ω（0R）</code>接地，在VNA上执行<code>SHORT</code>，完成后取下电阻</li>
<li><code>焊盘#1</code>用<code>50 Ω（50R）</code>接地，在VNA上执行<code>LOAD</code></li>
<li>保存校准配置，取下<code>50R</code>，让三个PI焊盘重新回到空白状态</li>
</ol>
<p>VNA菜单常把这三步写成<code>OPEN / SHORT / LOAD</code>，缩写为<code>OSL</code>。记住“空着、接地、接50R”就够了。</p>
<p>普通贴片<code>0R</code>和<code>50R</code>只能完成近似板级校准，适合排查PCB和调试匹配。需要更高精度时，应使用经过表征的板级校准件。</p>
<p>移动射频线、更换转接头、返修焊点或改变扫频范围后，重新做这三步。</p>
<h2>3. 检查PCB通路时接50Ω假负载</h2>
<p>这项任务只检查芯片焊盘到外部天线接口之间的PCB通路，先不接天线。</p>
<ol>
<li>在串联位置<code>焊盘#2</code>焊<code>0R</code>，两个并联位置<code>焊盘#1</code>和<code>焊盘#3</code>保持空白</li>
<li>在外部天线接口上接一个质量可靠的<code>50 Ω</code>假负载</li>
<li>查看<code>490MHz</code>的阻抗和<code>S11</code>，再扫一遍<code>470MHz</code>至<code>510MHz</code>；<code>S11</code>表示反射大小，dB数值越负，反射越小</li>
<li>史密斯图接近圆心、阻抗接近<code>50 + j0 Ω</code>时，板上通路通过检查；这里的目标是实部接近<code>50</code>、电抗接近<code>0</code></li>
<li>偏差明显时，先检查走线尺寸、参考地、接地过孔、天线接口和焊接</li>
</ol>
<p>假负载给出了一个已知的<code>50 Ω</code>终点。此时出现的额外偏差主要来自PCB走线、焊盘、天线接口和接地结构。</p>
<p>当前PCB无法改版，并且只要求较窄频段时，可以保持假负载连接，继续使用第5节的方法调整PI。这只能补偿当前板子在目标频段的端口阻抗，无法改变微带线本身的特性阻抗。</p>
<p>通过检查后，取下假负载，再接符合规格的外部天线进行整机验证。外部天线型号、馈线或安装方式发生变化时，需要重新验证。</p>
<h2>4. 匹配整套天线时接最终天线</h2>
<p>这项任务测的是最终产品，天线、馈线、PCB和外壳都要保持实际使用状态。</p>
<ul>
<li>固定外接天线：接上量产使用的天线和馈线，装好最终外壳，并固定馈线走向</li>
<li>板载天线：保留板载辐射体、PCB地、净空区、电池和最终外壳</li>
<li>可能更换的外接天线：先执行第3节，只把PCB端口做好并逐款验证天线</li>
</ul>
<p>接好实际天线后执行下面的步骤：</p>
<ol>
<li><code>焊盘#2</code>焊<code>0R</code>，<code>焊盘#1</code>和<code>焊盘#3</code>保持空白</li>
<li>把天线和馈线摆到产品中的实际位置</li>
<li>在<code>490MHz</code>读取初始复阻抗，例如<code>10.50 + j44.00 Ω</code>；<code>j</code>后面的数是电抗，正号表示偏感性，负号表示偏容性</li>
<li>记录读数和史密斯图位置</li>
<li>保持天线、馈线和外壳不动，进入第5节调整PI元件</li>
</ol>
<p><img src="/posts/使用VNA进行阻抗匹配/dipole-fpc-antenna.webp" alt="外接使用的偶极子FPC天线"></p>
<p>图中样品标有<code>LoRa M433</code>。用于<code>490MHz</code>前，应先确认天线规格覆盖<code>470MHz</code>至<code>510MHz</code>。天线规格不覆盖目标频率时，PI只能改善端口反射，无法保证辐射效率。</p>
<h2>5. 按史密斯图调整PI元件</h2>
<p>PI网络有三个焊盘，不代表三个位置都要焊元件。初始状态只在中间串联位置焊<code>0R</code>。</p>
<table>
<thead>
<tr>
<th align="left">焊盘</th>
<th align="left">电路位置</th>
<th align="left">初始状态</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><code>焊盘#1</code></td>
<td align="left">靠近芯片，并联到地</td>
<td align="left">空白</td>
</tr>
<tr>
<td align="left"><code>焊盘#2</code></td>
<td align="left">信号线上串联</td>
<td align="left"><code>0R</code></td>
</tr>
<tr>
<td align="left"><code>焊盘#3</code></td>
<td align="left">靠近天线，并联到地</td>
<td align="left">空白</td>
</tr>
</tbody></table>
<p>调试时一次只改一个元件：</p>
<ol>
<li>把VNA测得的初始复阻抗填入<a href="https://onlinesmithchart.com/">在线史密斯圆图工具</a></li>
<li>按PCB上的实际顺序添加元件；本文先调整<code>焊盘#2</code>的串联元件，再调整<code>焊盘#1</code>的并联元件</li>
<li>只焊第一个元件并重新测量，不要一次焊完两个计算值</li>
<li>把新读数重新填入工具，再选择第二个元件</li>
<li>每换一次元件都复测目标频点和整个工作频段</li>
</ol>
<p>下面是本文<code>490MHz</code>的实测过程。元件值只适用于这块PCB、这根天线和当时的安装状态，不能直接照抄到其他产品。</p>
<p>初始读数为<code>Z=10.50 + j44.00 Ω</code>。先把它填入工具的<code>BLACK BOX</code>。</p>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-48-17.webp" alt="将VNA测得的初始阻抗填入BLACK BOX"></p>
<p>理想模型给出的起始组合为：<code>焊盘#2</code>串联<code>15pF</code>电容，<code>焊盘#1</code>并联<code>12pF</code>电容。</p>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-49-16.webp" alt="匹配网络仿真：串联电容对应焊盘2，并联电容对应焊盘1"></p>
<p>两个元件焊好后，VNA实测约为<code>48 - j24 Ω</code>，没有落到理想模型的位置。把这个实测点作为新标记加入工具。</p>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-50-03.webp" alt="将两个匹配元件焊接后的实测阻抗添加为MK0标记"></p>
<p>焊盘、过孔、走线和器件封装都会产生寄生参数。工具中的<code>ESL</code>在这里用于拟合整段实物的综合影响，不能直接当成电容本体的标称ESL。</p>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-50-35.webp" alt="用有效寄生参数近似拟合实际测量点"></p>
<p>拟合后，并联电容从<code>12pF</code>改为<code>10pF</code>，模型重新回到圆心附近。</p>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-51-35.webp" alt="调整并联电容数值后的仿真结果"></p>
<h2>6. 用整段频率和最终外壳验收</h2>
<p>本文最终在<code>490MHz</code>得到以下读数：</p>
<table>
<thead>
<tr>
<th align="left">VNA读数</th>
<th align="left">结果</th>
<th align="left">直接含义</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><code>S11</code></td>
<td align="left"><code>-22.15 dB</code></td>
<td align="left">目标频点的反射较小</td>
</tr>
<tr>
<td align="left">复阻抗</td>
<td align="left"><code>57.44 - j3.89 Ω</code></td>
<td align="left">接近<code>50 + j0 Ω</code></td>
</tr>
<tr>
<td align="left">驻波比</td>
<td align="left"><code>1.169</code></td>
<td align="left">目标频点匹配良好</td>
</tr>
</tbody></table>
<p>单点读数通过后，再完成四项检查：</p>
<ol>
<li>扫描<code>470MHz</code>至<code>510MHz</code>，记录最差的<code>S11</code>或最大的驻波比</li>
<li>装上最终外壳、电池和其他结构件后复测</li>
<li>固定外接天线时，按量产馈线走向和安装姿态复测</li>
<li>使用辐射效率、增益或通信距离测试确认整机性能</li>
</ol>
<p>良好的<code>S11</code>只说明端口反射较小。天线效率和通信距离仍需单独测试。</p>
<h2>新手常见问题</h2>
<h3>外接天线为什么常先接50Ω假负载？</h3>
<p>假负载是已知的<code>50 Ω</code>终点，可以先把PCB走线和天线接口单独检查出来。天线可能更换时，板上PI不会被某一根样品天线的偏差带走。</p>
<h3>固定外接天线可以直接参与匹配吗？</h3>
<p>可以。量产天线、馈线和安装方式固定时，直接接上最终天线调整PI，得到整套产品的匹配值。任何一项发生变化后都要重新验证。</p>
<h3>板载天线为什么从第一次测量就要保留？</h3>
<p>板载辐射体、PCB地、净空区和外壳共同决定输入阻抗。拆掉板载天线或换成假负载后，VNA测到的是另一套负载。</p>
<h3>PI网络能把错误的微带线改成50Ω吗？</h3>
<p>不能。微带线的特性阻抗由线宽、铜厚、介质厚度、介电常数和参考地决定。PI可以让VNA在目标频段看到接近<code>50 Ω</code>的输入阻抗，这属于窄带端口补偿。下一版PCB仍应修正走线和叠层。</p>
<h3>拆下射频芯片后，VNA测到什么？</h3>
<p>VNA测到<code>焊盘#1</code>往天线方向的无源网络，包括PI元件、走线、天线接口、馈线和天线。这个读数不包含已经拆下的射频芯片。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="VNA"/>
    <category term="天线匹配"/>
    <category term="史密斯圆图"/>
    <category term="射频"/>
    <category term="嵌入式"/>
  </entry>
  <entry>
    <title>STM32 DMA 串口长时间运行后响应失效要检查 UART 错误状态</title>
    <link href="https://createskyblue.github.io/posts/stm32-uart-ore-long-run/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/stm32-uart-ore-long-run/</id>
    <published>2026-06-28T00:00:00.000Z</published>
    <updated>2026-06-28T00:00:00.000Z</updated>
    <summary>串口长时间运行后突然失效</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>STM32 DMA 串口长时间运行后响应失效要检查 UART 错误状态</h1>
<p>接手维护一个低功耗项目后，遇到串口长时间运行后失效的问题。MCU 向外部通信模组发起查询，模组按请求返回数据，但 MCU 侧收不到响应。接收链路已经用了 DMA、中断、乒乓缓冲区和外部 FIFO，仍然会在连续运行数天后偶发失效。</p>
<p>串口长时间失效不一定只由 ORE 触发，也可能来自 FE、NE、PE 等其他 UART 错误状态。这个案例最后定位到的是 ORE 接收溢出：固件没有清掉这个错误标志，后续 DMA/HAL 接收链路被错误状态卡住。</p>
<h2>现象</h2>
<p>故障表现很窄：</p>
<ul>
<li>外部通信模组不会主动上报，只会响应 MCU 的查询。</li>
<li>MCU 发出查询后，业务层收不到模组响应。</li>
<li>接收侧已有 DMA 中断、乒乓缓冲和外部 FIFO。</li>
<li>失效后 UART 接收链路不再按预期推进。</li>
<li>复位后通信恢复。</li>
<li>整机连续挂机几天后更容易出现。</li>
</ul>
<h2>测试方法</h2>
<p>为了缩短复现时间，测试时把这个通信模块单独拎出来做压力测试，并缩短低功耗休眠间隔。单位时间内执行更多次“进入低功耗、唤醒、查询模组、接收响应”的循环，让原本几天才暴露的问题更快出现。</p>
<p>问题暴露后，保存两个时间点的状态：</p>
<ul>
<li>正常通信时的 UART 相关寄存器。</li>
<li>失效后的 UART 相关寄存器。</li>
<li>正常通信时的 HAL UART 结构体字段。</li>
<li>失效后的 HAL UART 结构体字段。</li>
</ul>
<p>这些 dump 再交给一个临时 HTML 解析工具做字段级对比。对比结果直接指向 UART 错误状态：失效现场出现了 ORE。</p>
<p>后续验证不再每次从零开始挂机。下一轮测试会把故障时 dump 出来的内存状态重新加载回去，让系统直接回到问题现场，再观察修复后的接收链路能不能恢复。</p>
<h2>原因</h2>
<p>ORE 是 Overrun Error，含义是接收溢出。典型过程如下：</p>
<ol>
<li>UART 收到一个字节，硬件把数据放入接收数据寄存器，并置位 RXNE。</li>
<li>CPU 或 DMA 还没有及时搬走 RDR 或 DR 里的数据。</li>
<li>下一个字节已经到达。</li>
<li>接收寄存器没有空位，硬件置位 ORE。</li>
</ol>
<p>接手时，已有代码里有一段注释为“玄学操作”的处理：先读 SR 寄存器，再读 DR 寄存器。这个动作并不玄学，它正是部分 STM32 系列用于清除 ORE 的规定序列。问题在于这段代码放在一个不够可靠的位置，只覆盖了部分正常路径；特殊时序下没有走到这段处理，ORE 就没有按预期清掉。</p>
<p>在这个项目里，外部模组的响应只发生在 MCU 查询之后，不存在模组长时间主动灌数据。ORE 更可能来自接收链路的短暂不可服务区间：</p>
<ul>
<li>MCU 进入或退出低功耗时，某段时间没有处理 UART 接收。</li>
<li>全局中断关闭时间过长。</li>
<li>UART 中断优先级较低，被其他任务或中断长期压住。</li>
<li>DMA 接收在切换、重启或错误处理期间存在空档。</li>
<li>乒乓缓冲和外部 FIFO 只保护已经搬运出来的数据，不能清除 UART 外设错误位。</li>
<li>模组响应时机和 MCU 低功耗恢复、调度延迟或接收重启时序偶然重合。</li>
<li>调试期间打断点，CPU 停住但 UART 外设仍在接收。</li>
</ul>
<p>这些条件单次测试未必出现。运行时间足够长后，查询响应、低功耗切换、任务调度、中断屏蔽和 DMA 接收状态总会碰到一次不利组合。</p>
<h2>后果</h2>
<p>ORE 置位后，至少已经丢过一个字节。对 AT 指令、Modbus、帧头帧尾协议或二进制数据流来说，当前帧可能已经不完整。</p>
<p>ORE 也不能当成普通告警忽略。很多 STM32 工程里，ORE 未清除会让 UART 停在错误状态；HAL 可能记录 <code>HAL_UART_ERROR_ORE</code>，进入错误回调，或者停掉原来的 DMA/中断接收流程。最终现象就是 MCU 继续发查询，但响应数据进不了正常接收路径。</p>
<h2>修复</h2>
<p>修复遵循最小职责原则：UART/DMA 层只负责清外设错误、恢复接收链路。已经进入外部 FIFO 或协议缓存的数据，继续交给后面的协议层处理。</p>
<p>这次修复沿用已有的清除机制，但把 ORE 清除动作放到可靠位置。旧 STM32 系列常见方式是读 SR 后读 DR；新系列可能需要写 ICR 的 ORECF 位，或者按参考手册要求组合读取 ISR/RDR。检测到 UART 错误状态后，按目标芯片要求清外设错误、恢复 UART/DMA 接收、记录错误事件，并向上层暴露状态。</p>
<p>HAL 工程里还要确认 DMA 是否仍在运行，必要时按乒乓缓冲状态重新启动。协议是否丢帧、重同步、继续等待下一包，应由协议层根据校验、帧边界和业务语义决定。</p>
<p>清除 ORE 是恢复接收链路的止血动作，不等于根因闭环。如果时间允许，还应该继续追查触发 ORE 的具体时序：是哪一次低功耗切换、DMA 重启、调度延迟或中断屏蔽，让接收寄存器没有及时被服务。</p>
<h2>预防</h2>
<p>后续同类项目至少补这些检查：</p>
<ul>
<li>在 UART 错误中断或 HAL 错误回调里处理 ORE、FE、NE、PE 等错误。</li>
<li>查询外部模组前确认 UART/DMA 接收链路处于可接收状态。</li>
<li>把 UART 错误状态纳入响应式串口通信的默认排查项。</li>
</ul>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="低功耗"/>
    <category term="MCU"/>
    <category term="中断"/>
    <category term="故障诊断"/>
  </entry>
  <entry>
    <title>一次性电池 GPS 定位设备的按需供电与分频上报</title>
    <link href="https://createskyblue.github.io/posts/low-power-gps-tracker/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/low-power-gps-tracker/</id>
    <published>2026-06-27T00:00:00.000Z</published>
    <updated>2026-06-27T00:00:00.000Z</updated>
    <summary>一次性电池 GPS 定位：按需断电、定位上报分频</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>一次性电池 GPS 定位设备的按需供电与分频上报</h1>
<p>该设备使用一次性电池，按小时级周期获取位置。定位结果先保存在本地；记录数量、位移或设备状态达到条件时，才启动 4G 通过 MQTT 上报。GPS、4G 在空闲期间完全断电，整机休眠电流实测约 6 uA。</p>
<p>工程重点只有两个：怎样把 GPS 和 4G 的高功耗限制在定位和上报阶段，怎样在小时级定位下兼顾数据上报和位移判断。具体模块配置会随平台变化，本文只保留影响设计取舍的部分。</p>
<h2>GPS 与 4G 在线时间决定唤醒能耗</h2>
<p>GPS 冷启动取得有效位置约需半分钟，4G 搜网、连接和上报还需十几秒。小时级定位的平均功耗主要取决于这两类高功耗动作的次数和持续时间。</p>
<p>定位周期到达后，RTC（实时时钟）唤醒主控，设备启动 GPS 并保存位置。达到上报条件时才启动 4G；定位或上报结束后，相关模块立即断电，主控重新进入休眠。</p>
<h2>GPS 与 4G 休眠时需要彻底断电</h2>
<p>主控 MCU 管理 GPS 和 4G 的独立供电开关。休眠期间两类模块完全断电，只保留主控和 RTC 低功耗运行。</p>
<p>硬件若无法隔离高耗电供电域，软件待机很难消除外设的静态消耗。</p>
<h2>数百微安 GPS 备电不适合小时级定位</h2>
<p>GPS 备电模式可以保留辅助状态并缩短下次定位时间，但需要持续消耗数百微安。</p>
<p>设备采用小时级定位，没有秒级启动要求，因此项目没有启用 GPS 备电模式。</p>
<p>GPS 完全断电后仍可正常冷启动。需要定位时直接上电，接受约半分钟的冷启动耗时，并用定位超时避免弱信号环境持续耗电。</p>
<h2>定位与上报同频会增加 4G 唤醒</h2>
<p>每次定位都启动 4G，会让搜网、连接和 MQTT 交互抵消 GPS 侧的节能收益。</p>
<p>设备先在本地累计小时级定位结果，记录数量、位移或设备状态达到条件后再集中上报。</p>
<p>每次联网还会同步定位间隔、距离阈值和上报数量。这些参数写入本地 KV（键值对）数据库，设备断网或重启后仍可沿用；每个配置项独立存储，后续增加参数无需调整既有字段布局。</p>
<h2>相邻定位比较会漏掉跨周期位移</h2>
<p>设备每小时依次得到 A、B、C 三个位置，用 <code>d(A,B)</code> 表示 A、B 两点距离，把位移上报阈值记为 <code>D</code>。只比较相邻定位点时，可能出现：</p>
<ul>
<li><code>d(A,B) &lt; D</code></li>
<li><code>d(B,C) &lt; D</code></li>
<li><code>d(A,C) &gt;= D</code></li>
</ul>
<p>程序只检查前两项，因此不会上报；C 相对 A 的位移已经达到阈值。连续移动被采样点分成两段，阈值事件也随之被拆开。</p>
<p>这个现象和“采样频率不足会漏检”的直觉一致，但不宜直接称为奈奎斯特采样定理。奈奎斯特定理讨论带限信号重建；这里的 <code>D/2</code> 结论来自三角不等式：<code>d(A,B) + d(B,C) &gt;= d(A,C) &gt;= D</code>，所以前两段至少有一段不小于 <code>D/2</code>。</p>
<p><img src="/posts/low-power-gps-tracker/gps-window-timing.svg" alt="GPS 相邻定位比较漏掉跨周期位移示意图"></p>
<p>工程上有两种处理方式：</p>
<ul>
<li><strong>双周期比较：</strong> 保留阈值 <code>D</code>。得到 C 后，同时比较 C 与 B、C 与 A；当前项目采用双周期比较，不增加 GPS 唤醒次数，也不改变原有距离阈值的含义。</li>
<li><strong>相邻阈值减半：</strong> 仍只比较相邻点，把触发阈值改为 <code>D/2</code>。它能覆盖被拆成两段的位移，但会提高 GPS 漂移、定位噪声和往返移动造成误触发的概率。</li>
</ul>
<p><code>D/2</code> 只对两段拆分成立。移动被拆成 <code>N</code> 段时，相邻比较要降到 <code>D/N</code> 才有同类保证，或直接保留更长的历史记录；继续降低阈值会进一步放大误触发风险。</p>
<h2>6 uA 休眠结果与适用边界</h2>
<p>GPS、4G 完全断电后，整机休眠电流实测约 6 uA。这一数值只代表休眠基线，整机续航仍需结合电池有效容量、定位周期、信号环境、冷启动和联网时间计算。</p>
<p>这套设计适合资产定位、周期巡检和低频移动监测等小时级业务。连续轨迹、秒级定位或长时间在线需要重新评估电池、模块和通信架构。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="低功耗"/>
    <category term="一次性电池"/>
    <category term="GPS"/>
    <category term="数据上报"/>
  </entry>
  <entry>
    <title>MCU 边缘 AI 多气体传感器融合 异常分类</title>
    <link href="https://createskyblue.github.io/posts/mcu-gas-sensor-nanoedgeai/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/mcu-gas-sensor-nanoedgeai/</id>
    <published>2026-06-27T00:00:00.000Z</published>
    <updated>2026-06-27T00:00:00.000Z</updated>
    <summary>多气体传感器融合、异常检测、NanoEdge AI、AutoML、MCU 边缘推理</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用 NanoEdge AI 做 MCU 多气体异常分类</h1>
<p>多气体传感器融合可以用于异常气味识别，例如腐败气味、烧焦气味或其他污染源。基本流程是采集同一时刻的多路传感器数据，整理成带标签的数据集，再用 NanoEdge AI 生成可在 MCU 上运行的检测或分类库。</p>
<p><img src="/posts/mcu-gas-sensor-nanoedgeai/PixPin_2026-06-27_18-20-11.webp" alt="电化学气体传感器"></p>
<h2>采集数据样本</h2>
<p>异常分类先看数据。样本需要覆盖正常环境和异常环境。正常环境建议来自多个位置、不同通风条件、不同温湿度和不同背景气味，避免模型只记住某个房间的底噪。</p>
<p>异常环境要贴近真实触发场景。腐败气味、烧焦气味、溶剂气味等目标来源，都应在接近实际扩散状态下采集。每条样本记录同一时刻的多路传感器值，字段顺序、单位、采样频率保持一致。</p>
<p>正常样本和异常样本数量尽量接近。样本严重不均衡时，模型容易偏向数量更多的类别。标签边界也要谨慎处理，边界模糊样本可以单独留出，避免直接混进主训练集。</p>
<h2>AutoML</h2>
<p>数据准备好后，可以导入 NanoEdge AI 做 AutoML 搜索。多气体异常识别常用两条路线：异常检测和分类。</p>
<p>异常检测适合异常样本少、现场环境差异大的场景。工具主要学习正常环境数据，设备运行时可以先做一段正常环境学习，后续输入偏离正常分布时输出异常结果。这条路线更适合需要抗环境干扰的设备。</p>
<p>分类适合正常数据和异常数据都比较明确的场景。正常环境、烧焦、腐败、溶剂等样本分别作为标签导入，生成库后在 MCU 侧直接输出类别结果。</p>
<p>选择可以按数据条件判断：异常样本少、需要现场学习，选异常检测；正常和异常数据都足够、需要输出具体类别，选分类。</p>
<p><img src="/posts/mcu-gas-sensor-nanoedgeai/neai-benchmark-plots.webp" alt="NanoEdge AI Studio benchmark 结果曲线"></p>
<h2>Nano Edge Ai 黑箱</h2>
<p>NanoEdge AI 的优点是落地快。数据集先在本地评估，但生成库所需参数需要再上传到 ST 服务器云编译。最后下载 C 库，链接进 MCU 工程，运行时把传感器数组传进去，读取检测或分类结果。</p>
<p>缺点也清楚：库是黑箱，模型结构、特征选择、训练过程和内部参数都不能完全控制。它更适合工程落地，不适合追求模型细节可解释的研究流程。</p>
<p>尽管NanoEdgeAI生成的模型可以在其他兼容arm芯片上使用，甚至把把本地评估程序exe挪到其他地方使用，但是这是违反用户许可协议的，不能商用</p>
<h2>自训练</h2>
<p>NanoEdge AI 的优点是落地快，缺点是黑箱程度高。它适合快速做出 MCU 端可运行的边缘 AI 功能；如果后期产品对可解释性、模型结构和部署格式有更强要求，就需要考虑自训练模型，再通过 TFLite Micro 或手写 C 推理部署。
你可以把标注过的数据集丢给Agent，再给一台服务器让它自己做模型，自动评估、转换成 C 代码，部署到 MCU。代码智能体可以辅助完成数据处理、训练和转换。</p>
<p>这条路线控制权更高，但是我测试过，最后出来的模型没有NanoEdgeAI跑出来的强</p>
<h2>参考资料</h2>
<ul>
<li><a href="https://www.st.com/en/development-tools/nanoedgeaistudio.html" target="_blank" rel="noopener noreferrer">NanoEdgeAIStudio | Software - STMicroelectronics</a></li>
<li><a href="https://wiki.st.com/stm32mcu/wiki/AI%3ANanoEdge_AI_Studio" target="_blank" rel="noopener noreferrer">AI:NanoEdge AI Studio - stm32mcu - ST wiki</a></li>
</ul>
<blockquote>
<p>本文由 AI 辅助整理，请以实际测试结果和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="边缘AI"/>
    <category term="NanoEdge AI"/>
    <category term="TFLite"/>
    <category term="AutoML"/>
    <category term="异常检测"/>
  </entry>
  <entry>
    <title>利用 IO 中断实现低功耗下使用 ADC 按键</title>
    <link href="https://createskyblue.github.io/posts/adc-key-low-power-wakeup/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/adc-key-low-power-wakeup/</id>
    <published>2026-06-26T00:00:00.000Z</published>
    <updated>2026-06-26T00:00:00.000Z</updated>
    <summary>低功耗 ADC 按键扫描</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>利用 IO 中断实现低功耗下使用 ADC 按键</h1>
<h2>1. 概述</h2>
<p>IO 不够，几个按键挤到一根 ADC 线上。产品还要低功耗。休眠时 ADC 关着，这根线扫不了。</p>
<p>模拟看门狗能盯电压，ADC 子系统得挂着，电流下不来，这条丢掉。剩下 IO 中断：休眠只留一个 GPIO 看按键节点。键一按，电压掉到能打下降沿的位置，中断把人叫醒。起来再开 ADC，扫一眼是哪颗键。</p>
<p>一般 MCU 的 ADC 脚同时挂着数字输入。同一根线可以一边开着 ADC，一边配下降沿中断，不用再拆脚。</p>
<h2>2. 原理图</h2>
<p><img src="/posts/adc-key-low-power-wakeup/ADC_KEY_IRQ.webp" alt="ADC 按键原理图"></p>
<p><code>2.4k</code> 上拉到 <code>3.3V</code>，五颗键各自串电阻落地：<code>0Ω</code>、<code>180Ω</code>、<code>390Ω</code>、<code>620Ω</code>、<code>820Ω</code>。按下之后分压大约 <code>0V</code>、<code>0.23V</code>、<code>0.46V</code>、<code>0.68V</code>、<code>0.84V</code>。</p>
<p>这几档都低到能当低电平。随便按哪颗，ADC 节点都从 <code>3.3V</code> 掉下去，打出下降沿，IO 中断就能从休眠里把人拉起来。</p>
<p>醒来后 ADC 认的是这几个分压点隔得开。休眠认的是任意一颗键都能打出下降沿。键少、分压又压得够低，一根脚就够了。</p>
<h2>3. 工作流程</h2>
<p><img src="/posts/adc-key-low-power-wakeup/adc-key-wakeup-flow.svg" alt="低功耗休眠，IO 中断唤醒，再扫描 ADC 按键"></p>
<p>休眠阶段 ADC 关掉，GPIO 盯着按键节点。下降沿中断进来之后再开 ADC，采一次电压，按区间认键。消个抖就行。</p>
<h2>4. 独立中断信号</h2>
<p>分压档位一多，相邻电压挤太近，ADC 容易认错键。换成双回路轻触开关（DPST）。</p>
<p><img src="/posts/adc-key-low-power-wakeup/adc-key-dual-pole.webp" alt="双回路轻触开关：一路分压认键，一路并成公共中断"></p>
<p>一颗键里两路独立常开触点。刀 A 进原来的 ADC 分压网络，电阻按认键来排，档位可以拉开。刀 B 全部并到一根中断线上，按下直接落地。休眠盯这根公共中断线，谁按都打下降沿；醒来再去 ADC 上看是哪颗。</p>
<p>多占一根 GPIO，分压能铺开整段量程，相邻档不容易糊在一起。</p>]]></content>
    <category term="嵌入式"/>
    <category term="ADC"/>
    <category term="低功耗"/>
    <category term="MCU"/>
    <category term="按键"/>
  </entry>
  <entry>
    <title>[转载]FDM打印防收缩槽：一条线消除散热孔槽冷却纹</title>
    <link href="https://createskyblue.github.io/posts/fdm-cooling-shrinkage-groove/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/fdm-cooling-shrinkage-groove/</id>
    <published>2026-06-26T00:00:00.000Z</published>
    <updated>2026-06-26T00:00:00.000Z</updated>
    <summary>FDM散热孔冷却纹，用防收缩槽释放应力</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>FDM打印防收缩槽：一条线消除散热孔槽冷却纹</h1>
<blockquote>
<p><strong>声明：</strong> 本文记录的防收缩槽技巧来自小红书用户「聆息听韵」（小红书号：26507740295）的帖子分享。本文是学习整理笔记，用于理解 FDM 打印中冷却收缩纹的成因与抑制方法。<strong>本文作者不是该技巧的原创者，未参与原帖的实验过程。</strong> 文中工程分析来自对打印现象的推理，不代表原作者的设计思路。</p>
</blockquote>
<p><img src="/posts/fdm-cooling-shrinkage-groove/sketch-diagram.webp" alt="封面"></p>
<h2>问题：FDM 打印冷却纹</h2>
<p>FDM 打印中，熔融塑料从喷嘴挤出后迅速冷却固化。当模型存在<strong>长条形散热孔槽</strong>时，孔槽上下边缘的冷却速度不一致——上层已固化收缩，下层还在挤出，层间积累的收缩应力会在孔槽根部释放，形成一道道横向冷却收缩纹。</p>
<p>这种纹路在功能性结构件上尤其扎眼：散热齿、通风槽、卡扣开孔等位置的表面质量直接决定了成品观感。打磨可以缓解，但窄槽内部几乎无法处理。</p>
<h2>偶然的对照实验</h2>
<p>聆息听韵在打印一个&quot;过度工程的充电站&quot;底座时，意外做了一次完美的 A/B 对照：</p>
<ul>
<li><strong>左侧散热孔</strong>：建模时在孔槽根部预挖了一条细小的弧形<strong>防收缩槽</strong></li>
<li><strong>右侧散热孔</strong>：忘了画这条槽，保持原始直角结构</li>
</ul>
<p>同一块底板、同一卷耗材、同一次打印——唯一的变量就是那条不起眼的线。</p>
<p><img src="/posts/fdm-cooling-shrinkage-groove/cover.webp" alt="实物对比：木桌上同时展示开槽侧与未开槽侧，左侧冷却纹几乎不可见，右侧纹路清晰"></p>
<p><img src="/posts/fdm-cooling-shrinkage-groove/comparison-print.webp" alt="实物对比：红圈标出防收缩槽区域，左侧开槽表面平整，右侧未开槽冷却纹明显"></p>
<p>结果一目了然：<strong>有防收缩槽的一侧几乎看不到冷却纹，表面平整；没画槽的一侧，散热齿根部布满明显的收缩纹路。</strong></p>
<h2>为什么一条线能解决问题</h2>
<p>从原理上看，防收缩槽的作用是<strong>切断连续收缩应力路径</strong>：</p>
<ul>
<li>散热孔槽的上下边缘在冷却时各自收缩，在直角根部交汇处形成应力集中</li>
<li>预挖的弧形槽将连续的直角拐点打断，收缩应力在槽的弧形表面分散释放，不再积累到一条线上</li>
<li>弧形槽自身的曲率让收缩变形均匀分布，视觉上不可见</li>
</ul>
<p><img src="/posts/fdm-cooling-shrinkage-groove/sketch-diagram.webp" alt="手绘原理图：上方为原始直角结构（走线箭头指向应力集中点），下方为增加防收缩槽的改进结构"></p>
<p>原作者在方格纸上手绘了两种结构的对比：上图为原始直角孔槽，挤出路径箭头直指应力集中拐点；下图在孔槽起始位置挖出弧形防收缩槽，将应力路径切断。</p>
<p>本质上，这和注塑模具的&quot;防缩痕槽&quot;、金属焊接的&quot;应力释放槽&quot;是同一个工程直觉：<strong>不要让收缩应力在一条线上集中</strong>。</p>
<h2>实操要点</h2>
<p>从原帖的实践可以总结几条经验：</p>
<ul>
<li><strong>预挖而非后处理</strong>：防收缩槽是建模时画在模型上的，不是打印后雕刻。在 CAD 中孔槽根部加一段弧形切除即可</li>
<li><strong>槽不需要大</strong>：从对比图看，一条宽度不到 1mm 的弧形线就能生效，不会影响结构强度</li>
<li><strong>散热孔槽最容易中招</strong>：长条形开孔是冷却纹重灾区，圆孔由于应力分布均匀反而问题不大</li>
<li><strong>别忘了对称结构</strong>：左右对称的孔槽要一起画，原作者&quot;只画左边没画右边&quot;恰好验证了效果</li>
</ul>
<h2>总结</h2>
<p>这个技巧的价值在于<strong>成本极低、效果显著</strong>。建模时多画一条线的时间可以忽略不计，但打印成品的表面质量提升肉眼可见。对于经常打印功能性结构件的玩家，值得放进自己的建模习惯里。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="3D打印"/>
    <category term="FDM"/>
    <category term="冷却纹"/>
    <category term="防收缩槽"/>
    <category term="打印技巧"/>
    <category term="表面质量"/>
  </entry>
  <entry>
    <title>MCU 进入低功耗前后要防范 IO 和外设断电触发中断</title>
    <link href="https://createskyblue.github.io/posts/mcu-low-power-io-interrupt-timing/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/mcu-low-power-io-interrupt-timing/</id>
    <published>2026-06-26T00:00:00.000Z</published>
    <updated>2026-06-26T00:00:00.000Z</updated>
    <summary>低功耗休眠时被非预期IO中断唤醒？</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>MCU 进入低功耗前后要防范 IO 和外设断电触发中断</h1>
<p><img src="/posts/mcu-low-power-io-interrupt-timing/img/io-low-power-timing.webp" alt="MCU 低功耗前后 IO 与中断处理时序"></p>
<p>低功耗切换包含 IO 和中断时序，不只是一条 sleep 指令。很多 MCU 在进入休眠前会重新配置 IO：上拉、下拉、输入、推挽、开漏、高阻态都可能发生切换。这个动作会改变引脚等效电路，实际电压上可能出现 ns 级毛刺。</p>
<p>还要特别注意一个容易忽略的坑：常见的 IO 模式切换函数通常具有非原子特征。一个 <code>gpio_init()</code> 或 <code>pinMode()</code> 风格的调用，内部可能会分多步修改方向寄存器、上下拉寄存器、输出类型寄存器、复用功能寄存器和中断配置寄存器。调用语句在 C 代码里看起来是一行，硬件实际经历的是一串寄存器读改写。</p>
<p>如果这个引脚同时挂在中断检测链路上，毛刺就可能被比较器、边沿检测或唤醒逻辑捕获。即使应用层没有主动产生外部事件，中断标志位也可能被置位，系统进入低功耗后立即被唤醒，或者在唤醒后读到一次假的中断来源。</p>
<p>同样的风险也会出现在外部器件断电时。比如用中断捕获编码器、霍尔、光电开关或其他脉冲传感器，低功耗前为了省电关闭传感器电源。电源下掉时，传感器输出脚可能从驱动态变成高阻、被上拉拖动、被下拉释放，或者在掉电过程中产生一段不确定电平。MCU 看到的是 IO 电平变化，中断逻辑不会自动知道这是“外设断电”，仍然可能把它当成一次有效脉冲。</p>
<h2>风险位置</h2>
<p>问题通常出现在两个阶段：</p>
<ul>
<li>进入低功耗前：固件把普通运行态 IO 改成低漏电状态，例如关闭上拉、改输入、改开漏或切到模拟态。</li>
<li>从低功耗唤醒后：固件把休眠态 IO 恢复成业务运行态，期间也可能再次产生毛刺。</li>
<li>IO 切换函数执行中：驱动库分步写寄存器，中间态可能短暂经过一个会触发中断检测的电气组合。</li>
<li>外部传感器断电或上电时：编码器等脉冲源输出脚失去驱动或重新驱动，输入电平变化可能被计成一次脉冲。</li>
</ul>
<p>这类毛刺时间很短，示波器不一定容易在常规采样下稳定复现。但中断检测链路本来就用于捕获边沿，它可能比应用日志更早看见这个瞬态变化。</p>
<p>所以低功耗前后的 IO 处理要同时考虑两件事：</p>
<ul>
<li>IO 电气状态是否已经稳定。</li>
<li>中断使能和中断标志位是否处在可控状态。</li>
</ul>
<h2>IO 切换函数具有非原子特征</h2>
<p>不少驱动库把 IO 配置封装成一个函数，但这个封装只保证软件接口简单，不保证硬件状态一次性完成切换。一次模式切换可能包含这些中间步骤：</p>
<ul>
<li>先关输出再改输入方向。</li>
<li>先改上下拉再改复用功能。</li>
<li>先改输出类型再写默认输出电平。</li>
<li>先清某个寄存器位，再写入新的组合值。</li>
</ul>
<p>这些中间状态持续时间很短，通常只在几个总线周期或寄存器访问周期内存在。对普通业务逻辑来说，这段时间几乎不可见；对边沿中断、比较器输入、低功耗唤醒电路来说，它可能已经足够形成一次有效事件。</p>
<p>因此不能默认“调用 IO 配置函数期间不会触发中断”。如果某个引脚处于中断检测路径上，IO 配置函数本身就应该被看成一个可能产生毛刺和中间态的危险区间。</p>
<h2>外部脉冲源断电也会改变量测 IO</h2>
<p>低功耗设计里经常会关闭外部器件电源，例如编码器、霍尔传感器、红外对管、光电开关、模拟前端或小板上的辅助芯片。只要这些器件的输出接到 MCU 的中断脚，断电动作就会变成输入状态变化。</p>
<p>以编码器为例，运行时 MCU 用边沿中断计数。进入低功耗前如果直接关闭编码器供电，可能出现几类误触发：</p>
<ul>
<li>编码器输出从推挽驱动变成高阻，MCU 侧上拉或下拉把电平拉到另一个状态。</li>
<li>编码器电源下降过程中，输出级短暂进入不确定区间。</li>
<li>外部 RC、ESD 结构或漏电路径让输入脚缓慢跨过中断阈值。</li>
<li>MCU 仍然保持边沿中断使能，掉电过程中的一次变化被计入位置或速度。</li>
</ul>
<p>处理原则和 IO 模式切换一致：先关闭捕获中断，清掉历史标志，再关闭外部器件电源。等待电源和输入脚稳定后，如果这个输入不作为唤醒源，就保持中断关闭进入休眠；如果它必须作为唤醒源，就要确认外设断电后的默认电平不会正好处在触发条件上。</p>
<p>唤醒恢复也要按顺序来：先给外部器件上电，等待输出稳定，再清一次中断标志，最后开启编码器或脉冲捕获中断。不要在外设电源刚打开、输出还没稳定时就开始计数。</p>
<h2>进入低功耗前</h2>
<p>进入休眠前建议按固定顺序处理：</p>
<ol>
<li>关闭不需要作为唤醒源的引脚中断。</li>
<li>清空相关中断标志位。</li>
<li>关闭不需要工作的外部传感器或编码器电源。</li>
<li>设置 IO 模式，让引脚进入低功耗安全状态。</li>
<li>等待 IO、电源和外部 RC 网络稳定，至少跨过驱动库分步写寄存器、器件内部同步和外部器件掉电可能产生的瞬态时间。</li>
<li>进入低功耗或休眠。</li>
</ol>
<p>这个顺序的核心是先切断误触发进入中断控制流的路径，再修改 IO 电气状态。清标志位放在 IO 切换前，是为了把历史残留状态和这次低功耗切换区分开。IO 切换后的短暂等待，则给引脚、电平检测和外部电路一个收敛时间。</p>
<p>如果某个引脚必须作为唤醒源，仍然要先明确它的唤醒极性和有效电平，再决定是否在切换前后重新清标志。不能把“保持唤醒能力”和“允许切换毛刺触发”混在一起。</p>
<h2>唤醒后</h2>
<p>系统从低功耗恢复后，处理顺序应反过来保持谨慎：</p>
<ol>
<li>先完成系统唤醒和时钟恢复。</li>
<li>恢复外部器件电源，并等待输出稳定。</li>
<li>恢复或重新设置业务运行态 IO。</li>
<li>如果需要，清空 IO 切换和外设上电期间产生的中断标志位。</li>
<li>最后重新开启普通业务中断。</li>
</ol>
<p>唤醒后的第一件事不应是直接打开全部中断。此时 IO 可能还停在休眠态，时钟、外设和软件状态也未必完全恢复。先把硬件状态放回业务预期，再清掉恢复过程中产生的标志位，最后开放中断处理，能减少假事件进入业务逻辑。</p>
<h2>标志位要重新看待</h2>
<p>很多低功耗异常看起来像“莫名其妙被唤醒”，实际是标志位管理不清晰。中断标志位只说明检测链路看到了满足条件的电平或边沿，不一定说明外部世界真的发生了业务事件。</p>
<p>在低功耗时序里，标志位至少有三种来源：</p>
<ul>
<li>进入低功耗前遗留的历史事件。</li>
<li>IO 模式切换产生的瞬态毛刺。</li>
<li>真正来自外部输入的唤醒事件。</li>
</ul>
<p>固件需要通过关中断、清标志、切 IO、等待稳定、再进入休眠，把这三类来源拆开。唤醒后也要先恢复 IO，再处理标志位和中断使能，避免把恢复过程中的电气扰动当成业务输入。</p>
<h2>工程检查点</h2>
<p>实际项目里可以按下面几项检查：</p>
<ul>
<li>每个低功耗前会被重配的 IO，都列出运行态、休眠态和唤醒后的恢复态。</li>
<li>对关键 IO，检查驱动库切换函数内部是否会分步改方向、上下拉、输出类型和复用功能。</li>
<li>对编码器、霍尔、光电开关等外部脉冲源，检查断电和上电过程是否会改变 MCU 输入脚电平。</li>
<li>每个可中断 IO，都明确中断使能位和标志位在哪一步处理。</li>
<li>对不作为唤醒源的 IO，进入休眠前关闭中断并清标志。</li>
<li>对作为唤醒源的 IO，确认切换过程不会经过有效触发电平；如果无法保证，切换后重新清标志并留出稳定时间。</li>
<li>唤醒后先恢复 IO 和外设，再开放普通业务中断。</li>
</ul>
<p>单个 API 调用通常不能保证低功耗稳定性。IO 模式、中断检测、标志位和休眠状态共同组成一条时序链路。只要其中一步顺序不清楚，ns 级毛刺就可能被放大成一次系统级异常唤醒。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="低功耗"/>
    <category term="MCU"/>
    <category term="单片机"/>
    <category term="中断"/>
  </entry>
  <entry>
    <title>低功耗设备测量电池电压时要关断分压并控制 ADC 源阻抗</title>
    <link href="https://createskyblue.github.io/posts/power-voltage-low-power-sampling/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/power-voltage-low-power-sampling/</id>
    <published>2026-06-26T00:00:00.000Z</published>
    <updated>2026-06-26T00:00:00.000Z</updated>
    <summary>低功耗下电池电压测量</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>低功耗设备测量电池电压时要关断分压并控制 ADC 源阻抗</h1>
<p>电池供电设备经常需要知道自身电量。最直接的办法，是用两个电阻把电池电压分到 ADC 可接受的范围，再由 MCU 周期性采样。</p>
<p>这个做法在常供电设备里很自然，但在低功耗设备里会暴露一个硬约束：分压电阻只要接在电池上，就会一直耗电。ADC 可以关闭，MCU 可以休眠，分压链路本身却还在消耗电池。</p>
<p>这类电路的设计目标要同时满足三件事：</p>
<ul>
<li>休眠时分压电阻不长期吃电。</li>
<li>测量时 ADC 输入电压不超过 MCU 允许范围。</li>
<li>高阻分压不会让 ADC 采样结果失真。</li>
</ul>
<h2>原理图</h2>
<p><img src="/posts/power-voltage-low-power-sampling/img/power-voltage-low-power-sampling.webp" alt="低功耗电池电压采样原理图"></p>
<p>这张图的核心结构是一个可关断分压器。Q1 是 P 沟道 MOS，放在电池正端和分压电阻之间；Q2 是 N 沟道 MOS，用 MCU 的 <code>MCU_BAT_MEAS_EN</code> 控制 Q1 的栅极。R22、R19、R20 构成分压链，<code>MCU_ADC_BAT</code> 从 R19 和 R20 中间取样，C11 接在 ADC 节点上做滤波和保持。</p>
<p>默认状态下，R24 把 Q1 的栅极上拉到 <code>+VBAT</code>，Q1 的 <code>VGS</code> 接近 0，分压链被切断；R25 把 Q2 栅极下拉，避免 MCU 引脚上电悬空时误开启测量。需要测量时，MCU 拉高 <code>MCU_BAT_MEAS_EN</code>，Q2 导通，把 Q1 栅极拉向地，Q1 导通，电池电压才接入分压电阻。</p>
<h2>常接分压的问题</h2>
<p>假设只用普通电阻分压，比如上臂 2MΩ、下臂 1MΩ，总阻值是 3MΩ。电池为 4.2V 时，分压电流大约是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>I = 4.2V / 3MΩ = 1.4uA</span></span></code></pre><p>1.4uA 看起来很小，但低功耗设备的休眠电流可能也是微安级甚至更低。一个常接分压器会把系统休眠电流直接抬高，而且这部分电流和采样频率无关：一天只测一次，它也会 24 小时流过。</p>
<p>继续把电阻做大可以降低电流，但会引入另一个问题：ADC 输入源阻抗变高。很多 MCU ADC 都要求信号源阻抗不能太大，否则内部采样保持电容在采样时间内充不准，读数会偏低或受上一次通道影响。</p>
<p>所以低功耗电池采样不能只靠“把分压电阻做大”。它需要在不测量时断开分压链，在测量时再临时接入。</p>
<h2>用 PMOS 关断高边</h2>
<p>Q1 放在高边，作用是从源头切断分压电阻。用 P 沟道 MOS 做这个开关有一个好处：关断时分压链不再直接挂在电池正端，静态耗电主要剩下 MOS 漏电、R24 上拉支路相关的微小泄漏，以及后级输入泄漏。</p>
<p>Q1 的控制关系很直接：</p>
<ul>
<li>栅极被 R24 拉到 <code>+VBAT</code>：<code>VGS ≈ 0</code>，Q1 关闭。</li>
<li>栅极被拉低：<code>VGS</code> 为负，Q1 打开。</li>
</ul>
<p>这种结构适合电池电压可能高于 MCU 供电电压的场景。ADC 分压以后送入 MCU，但分压链的上端仍然是电池电压，不能把所有控制节点都默认看成 MCU 电压域。</p>
<h2>为什么还要一个 NMOS</h2>
<p>图里增加了 Q2，避免 MCU IO 直接连接 Q1 栅极。Q2 实际上是一个低边开漏下拉器：MCU 只控制 Q2 的栅极，Q2 再负责把 Q1 栅极拉低。</p>
<p>这一步主要是为了处理电压域差异。电池电压 <code>VBAT</code> 可能高于 MCU 的 <code>VDD</code>。Q1 的栅极在关闭状态会被 R24 拉到 <code>VBAT</code>，如果 MCU IO 直接接到这个节点，即使把 IO 配成开漏，IO 引脚仍然可能被外部拉到高于 MCU 供电的电压。</p>
<p>这会带来几个风险：</p>
<ul>
<li>MCU 引脚的 ESD 或钳位二极管可能被正向偏置，电流从电池通过 IO 灌入 MCU 供电域。</li>
<li>MCU 处于掉电或深睡时，IO 被高于 <code>VDD</code> 的电压反向供电，导致休眠电流异常或芯片状态不可控。</li>
<li>许多 MCU 的开漏 IO 仍然不允许引脚电压超过 <code>VDD</code>；除非手册明确支持 5V tolerant 或 over-voltage tolerant，否则不能按开漏等同于完全隔离来设计。</li>
</ul>
<p>Q2 把这两个电压域隔开。MCU 只看见 Q2 栅极，这是一个普通的 MCU 逻辑输入；Q1 栅极则由 Q2 的漏极去下拉，可以被 R24 拉到 <code>VBAT</code>。只要 Q2 的 <code>VDS</code> 额定值覆盖电池电压，MCU IO 就不用直接承受 <code>VBAT</code>。</p>
<h2>分压比例和 ADC 范围</h2>
<p>图中 R22、R19、R20 都是 1MΩ。ADC 取样点在 R19 和 R20 之间，因此下臂是 R20，上臂是 R22 + R19。</p>
<p>分压关系是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>V_ADC = VBAT * R20 / (R22 + R19 + R20)</span></span>
<span class="line"><span>      = VBAT / 3</span></span></code></pre><p>如果单节锂电最高按 4.2V 计算，ADC 节点大约是 1.4V。这个电压通常低于 3.3V 或 1.8V MCU 的 ADC 输入范围。若使用多节电池，或者系统存在充电、热插拔、升压输出回灌等情况，就要按最高可能的 <code>VBAT</code> 重新计算，不能只按标称电压判断。</p>
<p>分压电阻总值为 3MΩ，测量时电流约为 <code>VBAT / 3MΩ</code>。因为 Q1 只在采样前短时间打开，这个电流只出现在测量周期内，不再变成持续休眠电流。</p>
<h2>高阻分压会影响 ADC 采样</h2>
<p>这个电路为了省电使用了兆欧级电阻。代价是 ADC 节点的等效源阻抗很高。</p>
<p>从 <code>MCU_ADC_BAT</code> 节点往外看，上臂是 R22 + R19，也就是 2MΩ；下臂是 R20，也就是 1MΩ。等效电阻为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Rth = 2MΩ || 1MΩ ≈ 667kΩ</span></span></code></pre><p>667kΩ 对 ADC 来说已经很高。ADC 内部通常有一个采样开关和采样保持电容。采样瞬间，这个电容要从外部信号源取电荷。如果源阻抗太高，在 MCU 配置的采样时间内电容来不及充到真实电压，ADC 读数就会偏。</p>
<p>C11 的作用是给 ADC 节点提供一个本地电荷库。分压链慢慢把 C11 充到目标电压，ADC 采样瞬间主要从 C11 取电荷，对 667kΩ 高阻源瞬时供电能力的依赖会降低。</p>
<h2>为什么要等待 300 到 500ms</h2>
<p>C11 是 100nF。它和分压器的戴维南等效电阻形成 RC 充电过程。时间常数为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>τ = Rth * C</span></span>
<span class="line"><span>  ≈ 667kΩ * 100nF</span></span>
<span class="line"><span>  ≈ 66.7ms</span></span></code></pre><p>RC 电路不会在一个时间常数后立刻到达最终值。常用工程估算是等待 5 个时间常数，此时电压已经接近最终值：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>5τ ≈ 333ms</span></span></code></pre><p>所以固件流程应该是：</p>
<ol>
<li>拉高 <code>MCU_BAT_MEAS_EN</code>，打开 Q2 和 Q1。</li>
<li>等待 300 到 500ms，让 C11 和分压节点稳定。</li>
<li>配置 ADC 较长采样时间。</li>
<li>丢弃第一次 ADC 结果。</li>
<li>连续采几次取平均。</li>
<li>关闭 <code>MCU_BAT_MEAS_EN</code>，重新断开分压链。</li>
</ol>
<p>这里的等待时间来自分压电阻和电容决定的模拟稳定过程，和按键类软件消抖无关。C11 越大，抗 ADC 采样扰动和噪声能力越强，但等待时间越长；电阻越小，稳定越快，但测量期间电流越大。</p>
<h2>取值边界</h2>
<p>这个设计可以成立，但要把几个边界写进检查项：</p>
<ul>
<li>Q1 的 Source 应接 <code>+VBAT</code>，Drain 接分压链上端，封装引脚不能接反。</li>
<li>Q2 的 <code>VDS</code> 额定值要覆盖最高电池电压。</li>
<li>Q1 的 <code>VGS</code> 额定值要覆盖栅极被拉低时的最大负压。</li>
<li>ADC 节点最高电压要低于 MCU ADC 允许输入电压，并留出电阻误差和电池最高电压余量。</li>
<li>ADC 采样时间要按高源阻抗场景配置，不能沿用低阻信号的默认采样周期。</li>
<li>关闭测量后，ADC 引脚、C11 和 MCU 内部输入结构的泄漏也会影响残余电压，下一次采样仍应按完整稳定流程处理。</li>
</ul>
<h2>结论</h2>
<p>低功耗电池电压采样的核心矛盾，是分压器既要在测量时提供 ADC 可读电压，又不能在休眠时长期耗电。高边 PMOS 可以切断分压链，NMOS 用来把 MCU 控制信号和电池高压控制节点隔离，避免 MCU IO 直接暴露在高于自身供电的电压上。</p>
<p>分压电阻取到兆欧级以后，功耗降下来了，ADC 源阻抗也上去了。C11 可以改善 ADC 采样瞬间的电荷供给，但它和分压电阻共同决定了数百毫秒的稳定等待时间。这个电路最终要靠硬件开关和固件采样时序配合：打开分压、等待 RC 稳定、长采样、丢首样、多次平均、关闭分压。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="ADC"/>
    <category term="低功耗"/>
    <category term="MCU"/>
    <category term="数据采集"/>
  </entry>
  <entry>
    <title>嵌入式 Flash 配置应拆成独立键值对，而不是固耦合结构体</title>
    <link href="https://createskyblue.github.io/posts/flashdb-kv-vs-struct-config/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/flashdb-kv-vs-struct-config/</id>
    <published>2026-06-20T00:00:00.000Z</published>
    <updated>2026-06-20T00:00:00.000Z</updated>
    <summary>FlashDB KV替代结构体配置</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>嵌入式 Flash 配置应拆成独立键值对，而不是固耦合结构体</h1>
<p>嵌入式固件里，把配置固化成 Flash 常量是基本需求。Wi-Fi 账号、设备名、采样间隔、功能开关——这些东西掉电后必须还在。</p>
<p>MCU 项目最本能的写法是定义一个 C 结构体，把整个结构体当一块连续内存写进 Flash：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> device_config {</span></span>
<span class="line"><span style="color:#569CD6">    char</span><span style="color:#9CDCFE"> wifi_ssid</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">32</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#569CD6">    char</span><span style="color:#9CDCFE"> ble_name</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">32</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#569CD6">    uint16_t</span><span style="color:#D4D4D4"> sample_interval;</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#D4D4D4"> flags;</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><p>这个写法看着干净，但它把配置项之间锁死了。结构体方案有持久化能力，没有持久化弹性。</p>
<h2>结构体方案的三个问题</h2>
<p><strong>定位方式决定耦合程度</strong>。结构体的字段靠偏移量寻址——<code>sample_interval</code> 在 <code>ble_name</code> 后面 0 字节处，这是编译时写死的。所有字段挤在同一块连续内存里，定义结构体的那一刻，字段之间就已经锁死了。KV 的每个键值对靠名字寻址，键与键之间没有位置关系，天然独立。</p>
<p><strong>版本锁死</strong>。固件升级加一个字段，必须放在结构体末尾。插在中间，所有后续字段的偏移量全变了，老数据读出来是乱的。每个版本还要维护一套 if-else 判断旧格式、逐字段搬运到新布局。KV 加一个 <code>cfg/new_feature</code>，老键值对不受任何影响，新旧并存，互不干扰。</p>
<p><strong>不自描述</strong>。把 Flash dump 出来是一段二进制。没有结构体定义文件、没有对应的固件版本号，根本不知道里面有什么。KV 的 key 名本身就是元数据——<code>list</code> 一下就知道当前有哪些配置，浏览器端可以直接展示成表格。</p>
<h2>KV 是怎么解耦的</h2>
<p>KV 把配置从「一块连续的、布局锁死的二进制」变成「一组独立的、自描述的键值对」。</p>
<p>每个键值对独立存取。key 是名字，value 是数据。FlashDB 在底层负责扇区管理和磨损均衡，业务层只需要 <code>get</code> 和 <code>set</code>。</p>
<p>对比：</p>
<table>
<thead>
<tr>
<th></th>
<th>结构体方案</th>
<th>KV 方案</th>
</tr>
</thead>
<tbody><tr>
<td>字段寻址</td>
<td>编译时偏移量，位置锁死</td>
<td>运行时按名字查找，独立定位</td>
</tr>
<tr>
<td>加一个字段</td>
<td>必须放末尾，否则偏移量全乱</td>
<td>新增 key，不影响已有键值对</td>
</tr>
<tr>
<td>版本升级</td>
<td>按版本号 if-else 逐字段搬运</td>
<td>新旧 key 并存，无需迁移</td>
</tr>
<tr>
<td>外部查看</td>
<td>二进制 blob，无结构体定义不可读</td>
<td>key 名即元数据，可直接 list</td>
</tr>
</tbody></table>
<h2>什么时候用结构体就够了</h2>
<p>不是所有 Flash 数据都值得换 KV。</p>
<p>如果配置项极少（两三个字段）、格式永远不变、也不需要从外部查看或管理，结构体方案完全够用。一个 boot 标志位、一个校准值——KV 的代价（FlashDB 本身占用几 KB Flash 和少量 RAM）反而不划算。</p>
<p>一旦满足下面任意一条，就值得拆成 KV：</p>
<ul>
<li>配置项超过四五个，且还在增加。</li>
<li>固件会持续升级，配置格式不能锁死。</li>
<li>需要从芯片外部（BLE、串口、调试器）查看或修改配置。</li>
</ul>
<h2>不止是嵌入式 Flash 的问题</h2>
<p>这个对比不限于 MCU。Linux 上用 <code>fwrite(&amp;cfg, sizeof(cfg), 1, fp)</code> 存二进制结构体，跟 MCU 上往 Flash 写结构体的困境完全一样——偏移量锁死、版本迁移靠 if-else、dump 出来不可读。</p>
<p>JSON 文件本质上就是文本 KV。<code>{&quot;sample_interval&quot;: 500}</code> 跟 FlashDB 里 <code>cfg/sample_interval → 500</code> 是同一种思维方式：靠名字定位，不靠偏移量。好处也一致——加字段不用管顺序，老数据不受影响，任何文本编辑器都能打开看。</p>
<p>当然 JSON 有 JSON 的代价：每次启动要解析整个文件，文件大了解析慢。但结构上的优势是一样的。嵌入式 Flash 上选 KV，Linux 上选 JSON 或 TOML，背后是同一个原则：<strong>数据之间如果没有天然的先后依赖，就不要用位置关系把它们锁在一起。</strong></p>
<h2>结论</h2>
<p>结构体存配置先快后慢：第一版很快，每次改版都慢一步。KV 存配置先慢后快：接入 FlashDB 多花一点初始工作量，之后每次加配置、改字段、外部管理都更独立。</p>
<p>核心原则很简单：把耦合的东西拆成独立的键值对。Flash 上的配置如此，很多嵌入式设计问题也如此。</p>
<h2>FlashDB 写入机制简述</h2>
<p>理解 FlashDB 的写入方式，要先理解 Flash 硬件的两个基本约束：</p>
<ul>
<li><strong>擦除</strong>：以扇区为单位（通常几 KB），擦除后整个扇区所有位变为 1。</li>
<li><strong>写入</strong>：只能把 1 改成 0。想把 0 改回 1，必须擦除整个扇区。</li>
</ul>
<p>这意味着原位覆盖一个值是不可能的——旧值是 0 的位，你没法单独翻回 1。FlashDB 的处理方式是追加写：修改一个键，不碰旧记录，在扇区尾部的新位置写一条新记录，然后把旧记录的状态字节里的一个标记位从 1 写成 0，表示废弃。</p>
<p>每个扇区内部顺序追加，写满后触发 GC：把扇区中未被标记废弃的键值对拷贝到另一个干净扇区，再擦除整个旧扇区（全部位回到 1）。追加写和 GC 配合，磨损均衡自然达成——写入轮流落在不同扇区，不会盯着一个地址反复擦写。</p>
<p>每条 KV 记录带一个状态头，标记当前处于「准备写入」「已写入」「已删除」哪个阶段，外加 CRC 校验。如果在「准备写入」阶段复位，下次启动 CRC 对不上，这条记录直接丢弃，不影响已有数据。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="FlashDB"/>
    <category term="KVDB"/>
    <category term="嵌入式"/>
    <category term="Flash存储"/>
    <category term="键值对"/>
  </entry>
  <entry>
    <title>用零宽哨兵字符捕获手机软键盘的连续删除</title>
    <link href="https://createskyblue.github.io/posts/mobile-backspace-sentinel/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/mobile-backspace-sentinel/</id>
    <published>2026-06-20T00:00:00.000Z</published>
    <updated>2026-06-20T00:00:00.000Z</updated>
    <summary>零宽字符，实现移动网页端连续删除按钮检测</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用零宽哨兵字符捕获手机软键盘的连续删除</h1>
<p>手机浏览器里的软键盘删除键和桌面物理键盘不同。网页可以稳定收到文本输入变化，却不能稳定知道“用户正在按住软键盘上的删除键”。在远程输入工具里，这会影响一个具体动作：手机输入框已经空了，用户长按删除，电脑端也应该连续收到 Backspace。</p>
<p>问题的边界很清楚：</p>
<ul>
<li>软键盘由系统输入法接管，页面拿不到可靠的 <code>pointerdown</code> / <code>pointerup</code>。</li>
<li><code>keydown</code> 在移动端输入法里不稳定，有的浏览器不会按桌面键盘方式连续派发。</li>
<li><code>beforeinput</code> 能描述一次删除意图，但输入框为空时，很多输入法不会持续产生可观测的文本变化。</li>
<li>电脑端需要接收一串 Backspace，手机输入框里的内部字符不能进入业务文本。</li>
</ul>
<p>解决点不在“检测按住状态”，而在让每一次删除都变成输入框内可观测的变化。</p>
<h2>零宽哨兵字符</h2>
<p>输入框逻辑为空时，内部放一个不可见字符：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">const</span><span style="color:#4FC1FF"> BACKSPACE_SENTINEL</span><span style="color:#D4D4D4"> = </span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\u200B</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">;</span></span></code></pre><p><code>\u200B</code> 是零宽空格。它占据一个文本位置，但用户看不到。页面把它当成内部哨兵字符，不把它当作真实输入内容。</p>
<p>这样输入框在视觉上仍然是空的，浏览器和输入法看到的文本里还有一个最小字符。用户按删除键时，软键盘会删除这个字符，<code>input</code> 事件就有了可以观察的状态变化。</p>
<h2>删除变成可观测事件</h2>
<p>核心流程是：</p>
<ol>
<li>输入框逻辑内容为空时，保持 <code>\u200B</code> 存在。</li>
<li><code>beforeinput</code> 记录删除前的逻辑文本。</li>
<li><code>input</code> 事件里检查哨兵字符是否消失。</li>
<li>如果删除前逻辑文本为空、删除后哨兵消失，就向电脑同步一次 Backspace。</li>
<li>立即把哨兵字符补回输入框，并把光标放到末尾。</li>
</ol>
<p>只要手机输入法长按删除时会持续发起删除动作，每次动作都会删掉刚补回的哨兵字符。页面每次都能观察到“哨兵消失”，于是连续发送 Backspace。</p>
<h2>逻辑文本必须过滤哨兵</h2>
<p>哨兵字符进入了 <code>textarea.value</code>，所以所有业务路径都不能直接使用原始 <code>text.value</code>。</p>
<p>需要统一封装逻辑文本：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">function</span><span style="color:#DCDCAA"> getComposerText</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#9CDCFE"> text</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">value</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">split</span><span style="color:#D4D4D4">(</span><span style="color:#4FC1FF">BACKSPACE_SENTINEL</span><span style="color:#D4D4D4">).</span><span style="color:#DCDCAA">join</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">""</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>发送文本、自动发送倒计时、历史覆盖判断、历史回填、累计字数，都应使用 <code>getComposerText()</code>。这样用户输入的正文不会带上零宽字符，发送历史也不会出现不可见脏数据。</p>
<p>清空输入框时也要恢复到哨兵状态：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#9CDCFE">text</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">value</span><span style="color:#D4D4D4"> = </span><span style="color:#4FC1FF">BACKSPACE_SENTINEL</span><span style="color:#D4D4D4">;</span></span></code></pre><p>从用户角度看，输入框仍然为空；从浏览器角度看，它还有一个可以被删除的最小字符。</p>
<h2>输入事件分工</h2>
<p><code>beforeinput</code> 和 <code>input</code> 的职责不同。</p>
<p><code>beforeinput</code> 适合记录动作发生前的状态：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#9CDCFE">text</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">addEventListener</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"beforeinput"</span><span style="color:#D4D4D4">, (</span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">) </span><span style="color:#569CD6">=></span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#9CDCFE">  composerTextBeforeInput</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">getComposerText</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#D4D4D4">});</span></span></code></pre><p><code>input</code> 适合判断删除后的结果：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">function</span><span style="color:#DCDCAA"> restoreBackspaceSentinelAfterDelete</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (</span></span>
<span class="line"><span style="color:#9CDCFE">    event</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">inputType</span><span style="color:#D4D4D4"> === </span><span style="color:#CE9178">"deleteContentBackward"</span><span style="color:#D4D4D4"> &#x26;&#x26;</span></span>
<span class="line"><span style="color:#9CDCFE">    composerTextBeforeInput</span><span style="color:#D4D4D4"> === </span><span style="color:#CE9178">""</span><span style="color:#D4D4D4"> &#x26;&#x26;</span></span>
<span class="line"><span style="color:#D4D4D4">    !</span><span style="color:#9CDCFE">text</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">value</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">includes</span><span style="color:#D4D4D4">(</span><span style="color:#4FC1FF">BACKSPACE_SENTINEL</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">  ) {</span></span>
<span class="line"><span style="color:#DCDCAA">    syncBackspace</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"><span style="color:#DCDCAA">  ensureBackspaceSentinel</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>这里没有在 <code>beforeinput</code> 里阻止默认删除。让输入法真的删掉哨兵字符，页面才能在 <code>input</code> 后确认这次删除动作已经发生。随后补回哨兵，为下一次长按重复删除准备条件。</p>
<h2>与 Backspace 去重配合</h2>
<p>移动端浏览器可能同时触发 <code>beforeinput(deleteContentBackward)</code> 和 <code>keydown(Backspace)</code>。如果两边都同步 Backspace，一次删除会变成两次。</p>
<p>因此现代浏览器优先走 <code>beforeinput</code> / <code>input</code> 路径，<code>keydown</code> 只作为不支持 <code>beforeinput</code> 的兜底：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">const</span><span style="color:#4FC1FF"> canUseBeforeInput</span><span style="color:#D4D4D4"> = </span><span style="color:#CE9178">"onbeforeinput"</span><span style="color:#569CD6"> in</span><span style="color:#9CDCFE"> text</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">text</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">addEventListener</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"keydown"</span><span style="color:#D4D4D4">, (</span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">) </span><span style="color:#569CD6">=></span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">key</span><span style="color:#D4D4D4"> === </span><span style="color:#CE9178">"Backspace"</span><span style="color:#D4D4D4"> &#x26;&#x26; !</span><span style="color:#9CDCFE">canUseBeforeInput</span><span style="color:#D4D4D4"> &#x26;&#x26; </span><span style="color:#DCDCAA">getComposerText</span><span style="color:#D4D4D4">() === </span><span style="color:#CE9178">""</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">    syncBackspace</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"><span style="color:#D4D4D4">});</span></span></code></pre><p>这个边界很重要。哨兵字符解决“长按删除没有连续可观测变化”，事件去重解决“同一次删除被两条事件链重复同步”。</p>
<h2>适用范围与限制</h2>
<p>这个方法适合远程输入、虚拟键盘桥接、手机控制桌面输入等场景。它依赖普通文本框的删除行为，不需要输入法私有接口。</p>
<p>它也有边界：</p>
<ul>
<li>输入法如果完全不重复发起删除动作，页面仍然无法凭空知道用户在长按。</li>
<li>代码必须保证哨兵字符只存在于 UI 内部，不能进入提交、历史、统计和搜索。</li>
<li>光标需要放在哨兵字符后面，否则删除动作可能不作用在哨兵上。</li>
<li>组合输入、语音输入、历史回填等路径都要经过逻辑文本过滤。</li>
</ul>
<p>工程上的经验是：移动端软键盘的“按键状态”不可控，但文本变化可控。把不可观察的按住动作转化成可观察的字符删除，再把内部字符从业务数据里剥离，就能用浏览器标准事件完成连续删除同步。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="前端"/>
    <category term="移动端输入法"/>
    <category term="远程输入"/>
    <category term="JavaScript"/>
  </entry>
  <entry>
    <title>内容指纹 + 输出复用：博客增量构建缓存的设计</title>
    <link href="https://createskyblue.github.io/posts/build-cache-design/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/build-cache-design/</id>
    <published>2026-06-19T00:00:00.000Z</published>
    <updated>2026-06-19T00:00:00.000Z</updated>
    <summary>解决博客重复构建文章的问题</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>内容指纹 + 输出复用：博客增量构建缓存的设计</h1>
<p>博客有 67 篇文章，每篇要经过 Markdown 渲染、代码高亮、公式转换、图片 WebP 转码等步骤。每次全量构建即使只改了一篇文章，也要把所有步骤重跑一遍。</p>
<p>核心思路：<strong>如果源文件没变、输出已存在，就跳过</strong>。</p>
<h2>三层缓存判定</h2>
<h3>第一层：版本号</h3>
<p>缓存文件 <code>.blog-cache.json</code> 里带一个 <code>version</code> 字段。构建脚本版本升级时，意味内部逻辑可能变化（比如新增了 WebP 转换、路径重写规则调整），此时旧缓存全部作废，触发全量重建。</p>
<h3>第二层：目录指纹</h3>
<p>每篇文章是一个目录，包含 <code>index.md</code> 和图片等资源。计算指纹时，遍历目录下所有文件，取出文件名、字节大小、最后修改时间的毫秒值，拼成一个字符串再 SHA-256 截取 16 位：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>文件名:大小:修改时间ms | 文件名:大小:修改时间ms | ...</span></span>
<span class="line"><span>        ↓ SHA-256 → 16 hex</span></span></code></pre><p>文件增删、内容变化、重命名都会改变指纹。指纹相同意味着整篇文章的源输入与上一次构建完全一致。</p>
<h3>第三层：输出存在性</h3>
<p>即使指纹命中，还要确认 <code>public/posts/{文章}/index.html</code> 真实存在。防止因手动清理输出目录或部分构建失败导致的虚假命中。</p>
<p>同时，扫描完所有文章后，反向检查输出目录——如果某个输出目录找不到对应的源文章，说明文章已被删除，自动清理。</p>
<h2>什么被缓存</h2>
<p>指纹命中时，整条渲染链路全部跳过：</p>
<ul>
<li>Markdown 解析与 Shiki 代码高亮</li>
<li>KaTeX 公式渲染</li>
<li>sharp 图片 WebP 转码</li>
<li>封面压缩</li>
<li>HTML 页面生成</li>
<li>文件复制</li>
</ul>
<p>直接将缓存的文章元数据（标题、描述、标签、分类等）推入 <code>posts.json</code> 的构建列表。</p>
<h2>命中效果</h2>
<p>实测：67 篇文章首次全量构建后，第二次构建 67/68 命中缓存。修改一篇文章再构建，只有被改的文章触发重建，其余 66 篇全部跳过。构建时间从秒级降到毫秒级。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="构建系统"/>
    <category term="缓存"/>
    <category term="增量编译"/>
  </entry>
  <entry>
    <title>深入理解 DALI 协议：曼彻斯特编解码与 Input Capture 实现</title>
    <link href="https://createskyblue.github.io/posts/dali-manchester-codec/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/dali-manchester-codec/</id>
    <published>2026-06-19T00:00:00.000Z</published>
    <updated>2026-06-19T00:00:00.000Z</updated>
    <summary>DALI照明总线：曼彻斯特编码 实现</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>深入理解 DALI 协议：曼彻斯特编解码与 Input Capture 实现</h1>
<p><img src="/posts/dali-manchester-codec/img/manchester-timing.webp" alt="DALI 反相曼彻斯特时序"></p>
<h2>DALI 协议物理层</h2>
<p>DALI（Digital Addressable Lighting Interface）是 IEC 62386 标准定义的照明控制总线协议。物理层核心参数：</p>
<ul>
<li><strong>数据速率</strong>：1200 bps（±10% 容差）</li>
<li><strong>编码方式</strong>：曼彻斯特（Manchester / bi-phase）编码</li>
<li><strong>总线拓扑</strong>：两线差分，半双工，最大压降 2V</li>
<li><strong>位周期</strong>：833.33 µs（1/1200 s）</li>
<li><strong>半位周期 Te</strong>：416.67 µs</li>
</ul>
<p>DALI 帧分两类：</p>
<ul>
<li><strong>前向帧</strong>（控制器 → 设备）：1 起始位 + 16 数据位 + 2 停止位 = 19 位，总时长 38 Te ≈ 15.83 ms</li>
<li><strong>后向帧</strong>（设备 → 控制器）：1 起始位 + 8 数据位 + 2 停止位 = 11 位，总时长 22 Te ≈ 9.17 ms</li>
</ul>
<p>每个字节按 MSB first 顺序发送。前向帧的 16 个数据位中，先发地址字节（bit17–10），再发数据字节（bit9–2），最后是两个停止位（bit1–0，恒为 11）。</p>
<h2>曼彻斯特编码</h2>
<p>曼彻斯特编码的规则是：每位中心必须发生一次电平跳变，用跳变方向区分逻辑值。</p>
<ul>
<li><strong>逻辑 1</strong>：位中心出现上升沿（低 → 高）</li>
<li><strong>逻辑 0</strong>：位中心出现下降沿（高 → 低）</li>
</ul>
<p>当连续两个位的值相同时，位边界处额外多一次跳变；值不同时，位边界无跳变。这意味着相邻边沿的时间间隔只有两种可能：短脉冲 ≈ 1 Te（位边界），长脉冲 ≈ 2 Te（位中心）。这一特性是接收端解码的关键。</p>
<p>以连续三个位 <code>1, 1, 0</code> 的反相曼彻斯特波形为例。位 1 的两半依次为高电平、低电平，位中心是下降沿（长脉冲 ≈ 2Te）。两个连续的 1 之间在位边界处有额外跳变（短脉冲 ≈ 1Te），因为前后位的值相同。位 0 的两半依次为低电平、高电平，位中心是上升沿（长脉冲 ≈ 2Te）。位 1 和位 0 值不同，位边界无跳变。</p>
<p>DALI 总线通常经光耦或晶体管驱动，硬件上反相一次。因此 MCU 侧 GPIO 的实际波形是反相曼彻斯特：1 对应 <strong>高 → 低</strong>（下降沿），0 对应 <strong>低 → 高</strong>（上升沿）。总线空闲时 MCU 引脚保持低电平。</p>
<h2>TX 发送：定时器驱动的 GPIO 位拆裂</h2>
<p>硬件平台是 Air001（Cortex-M0+，48 MHz）。发送端将 TIM16 配置为 2400 Hz（Period = 20000，即 48 MHz / 20000），每 Te 触发一次中断。ISR 根据发送缓冲区 LSB 判断当前位值，按半位奇偶序号决定 GPIO 输出电平：编码 1 时前半输出 HIGH 后半输出 LOW，编码 0 时前半输出 LOW 后半输出 HIGH。每个完整位（两个半位）结束后，发送缓冲区右移一位，直到 38 个半位（前向帧）或 22 个半位（后向帧）全部发送。</p>
<p>发送前，帧数据按 MSB first 装入发送缓冲区：停止位在最低位方向，起始位在最高位方向，中间按地址字节、数据字节的顺序逐位填入。</p>
<h2>RX 接收：两种方案的对比</h2>
<p>接收端的核心挑战是从 GPIO 引脚上的曼彻斯特波形中恢复数据和时钟。同一块 MCU 上，可以走两条完全不同的路线。</p>
<h3>方案一：固定间隔采样</h3>
<p>思路很简单：先靠 EXTI 捕获起始位的两个边沿完成同步，然后启动一个定时器，按固定间隔（如 1.5 Te ≈ 625 µs）周期性采样引脚电平，直接移位存入接收缓冲。</p>
<p><strong>问题</strong>：因为相邻位值相同时位边界会有额外跳变，实际边沿间隔在 1 Te 和 2 Te 之间交替，固定 1.5 Te 采样点会随数据模式漂移，恰好落在电平翻转区时读到不确定值。此外，真实的 1200 bps 时钟有 ±10% 允许偏差，累积几个位后采样窗口就会偏移到错误位置。</p>
<h3>方案二：Input Capture 边沿时间差测量</h3>
<p>不依赖任何固定采样节拍。每收到一个 GPIO 边沿，从自由运行定时器中抓取时间戳。计算相邻边沿的时间差，直接分类：</p>
<ul>
<li><strong>≈ 1 Te（313–521 µs）</strong>：位边界跳变，不产生数据</li>
<li><strong>≈ 2 Te（625–1041 µs）</strong>：位中心跳变，记录一个数据位</li>
<li><strong>&gt; 2 ms</strong>：帧结束，触发解析</li>
</ul>
<p>边沿测量自动适应 ±10% 时钟偏差，不受数据模式影响，抗抖动能力由容差窗口宽度直接决定。</p>
<p><img src="/posts/dali-manchester-codec/img/sampling-vs-capture.webp" alt="固定采样与 Input Capture 接收策略对比"></p>
<h2>RX Input Capture 实现细节</h2>
<p>TIM17 被配置为自由运行计数器：48 MHz 时钟经 48 分频（Prescaler = 47），得到 1 MHz 计数频率，每 tick = 1 µs。Period 设为 0xFFFF，溢出后自然回绕。在主循环初始化阶段调用 <code>HAL_TIM_Base_Start</code> 以非中断模式启动，此后持续运行。边沿时间戳的读取通过 <code>__HAL_TIM_GET_COUNTER</code> 宏完成，uint16_t 自然溢出保证差值计算总是正确。</p>
<p>RX 引脚 PA1 配置为 EXTI 双沿触发。每个边沿进入中断后，从 TIM17 计数器读取当前时间戳，计算与上一个边沿的差值，驱动三级状态机。</p>
<p><strong>状态 0 — IDLE</strong>：空闲。收到第一个边沿，转入 START_EDGE1，记录时间戳和引脚电平。</p>
<p><strong>状态 1 — START_EDGE1</strong>：等待第二个边沿。若间隔 ≈ 1 Te，确认为有效起始位，记录其值（反相曼彻斯特下，引脚低电平取反得 1），转入 DECODING；否则当作噪声退回 IDLE。</p>
<p><strong>状态 2 — DECODING</strong>：按间隔分类处理。短脉冲（≈ 1 Te）仅更新上次电平和时间戳，不产生数据。长脉冲（≈ 2 Te）说明位中心跳变到达，此时数据位的值由跳变前的引脚电平决定（反相曼彻斯特下，高电平之后的跳变表示逻辑 1，低电平之后的跳变表示逻辑 0）。解码出的 bit 左移进入接收缓冲，递增位计数。间隔超出容差范围则认为帧异常，退回 IDLE。</p>
<p>状态转移规则：</p>
<ul>
<li>IDLE → START_EDGE1：第 1 个边沿触发</li>
<li>START_EDGE1 → DECODING：第 2 个边沿间隔 ≈ 1Te，起始位确认</li>
<li>START_EDGE1 → IDLE：第 2 个边沿间隔非 1Te，判为噪声丢弃</li>
<li>DECODING → IDLE：delta 超出 SHORT/LONG 容差窗口，帧异常重置</li>
<li>DECODING 自循环：每次边沿继续解码</li>
<li>DECODING 超时退出：最后边沿距今超过 2ms，主循环判定帧结束，解析后回到 IDLE</li>
</ul>
<p>每次进入 EXTI 中断都会刷新帧超时计时标记，供主循环判断帧结束。</p>
<p><img src="/posts/dali-manchester-codec/img/rx-state-machine.webp" alt="DALI RX Input Capture 状态机"></p>
<h2>帧格式验证与数据提取</h2>
<p>主循环中 <code>DALI_Update</code> 轮询检查：当状态为 DECODING 且最后边沿距今超过 2 ms，判定帧结束，进入解析。</p>
<p>收到的比特流含起始位 + 数据 + 停止位。验证起始位和停止位模式后提取数据：</p>
<ul>
<li><strong>前向帧</strong>（≥ 19 位）：<code>raw[1:0] == 11</code>（停止位），<code>raw[18] == 1</code>（起始位），中间 16 位为地址字节 + 数据字节</li>
<li><strong>后向帧</strong>（≥ 11 位）：<code>raw[1:0] == 11</code>，<code>raw[10] == 1</code>，中间 8 位为响应字节</li>
</ul>
<p>TX 端已经按 MSB first 发送，但接收缓冲的移位方向造成每个字节内位序颠倒，提取时用 <code>reverse_byte()</code> 恢复正确字节值。完整状态机在解析完成后重置，等待下一帧。</p>
<h2>总结</h2>
<table>
<thead>
<tr>
<th>维度</th>
<th>固定间隔采样</th>
<th>Input Capture 边沿测量</th>
</tr>
</thead>
<tbody><tr>
<td>时钟同步</td>
<td>依赖一次同步后开环运行</td>
<td>每个边沿自动重新同步</td>
</tr>
<tr>
<td>抗抖动</td>
<td>累积漂移</td>
<td>容差窗口吸收</td>
</tr>
<tr>
<td>复杂度</td>
<td>较低</td>
<td>中等（三级状态机）</td>
</tr>
<tr>
<td>适用场景</td>
<td>实验验证</td>
<td>真实 DALI 总线通信</td>
</tr>
</tbody></table>
<p>平台无关的关键结论是：对自时钟编码（Manchester、BMC 等），用边沿时间测量替代固定节拍采样，是可以用极少硬件代价（一个自由运行定时器 + 一个双沿 EXTI）换取解码鲁棒性的通用方案。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="DALI"/>
    <category term="Manchester"/>
    <category term="曼彻斯特编码"/>
    <category term="嵌入式"/>
    <category term="IEC 62386"/>
    <category term="Air001"/>
  </entry>
  <entry>
    <title>右锚可信趋势卡片图：轻量化实时监测图表的设计理念</title>
    <link href="https://createskyblue.github.io/posts/html-canvas-waveform-chart/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/html-canvas-waveform-chart/</id>
    <published>2026-06-19T00:00:00.000Z</published>
    <updated>2026-06-19T00:00:00.000Z</updated>
    <summary>分段着色折线图</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>右锚可信趋势卡片图：轻量化实时监测图表的设计理念</h1>
<p><img src="/posts/html-canvas-waveform-chart/waveform-chart.webp" alt="波形图最终效果"></p>
<p><strong>右锚可信趋势卡片图</strong>是一种面向实时监测的轻量化状态图表。它通过柔和折线、淡色可信区间、近期中位虚线、固定右侧刻度、当前点标记和简洁图例，弱化复杂分析感，突出&quot;现在是否正常、趋势是否稳定、结果是否可用&quot;。核心设计原则：</p>
<ul>
<li><strong>右锚</strong>：图表以右侧为当前时刻锚点，新数据出现在最右侧，旧数据依次左移。当前点用右侧空心圆点标记，与右侧刻度轴对齐，直接读取最新数值。刻度放在右侧而非传统左侧，避免打断数据从左到右的视觉流向。</li>
<li><strong>固定范围</strong>：纵轴始终固定在业务范围内（如心率 40–120 BPM），不随个别异常值自动缩放。同一数值始终落在同一高度，位置即数值，趋势判断稳定。</li>
<li><strong>等距铺满</strong>：只根据当前已有数据点从左到右铺满绘图区。5 个点就铺 5 个点，不预留未来空白。</li>
<li><strong>面积填充</strong>：比纯折线多一重&quot;量感&quot;，渐变从上半透明到底部透明，趋势变化一目了然，无需图例。</li>
<li><strong>逐段着色</strong>：数据质量持续变化，逐段着色同时展示有效段和无效段——红色可信，灰色受干扰，颜色随质量实时流动。</li>
<li><strong>中位线</strong>：叠加近期中位虚线作为参考基准，不被极端值拉偏。当前值在中位线以上还是以下，比绝对值更有参考意义。</li>
<li><strong>高分屏适配</strong>：自动适配不同分辨率，线条和文字始终保持清晰。</li>
</ul>
<h2>附件</h2>
<ul>
<li><a href="/posts/html-canvas-waveform-chart/%E5%A4%8D%E7%8E%B0%E6%8C%87%E5%8D%97.md" target="_blank" rel="noopener noreferrer">AI 复现指南</a> — 铁律、工作流清单、函数对照表、常见错误、完成检查</li>
<li><a href="/posts/html-canvas-waveform-chart/%E5%A4%8D%E7%8E%B0%E6%8C%87%E5%8D%97.html" target="_blank" rel="noopener noreferrer">完整可运行示例（原生 Canvas）</a> — 零依赖，含阈值滑动条、原始采样折线图、分段着色趋势图、中位线、图例</li>
<li><a href="/posts/html-canvas-waveform-chart/%E5%A4%8D%E7%8E%B0%E6%8C%87%E5%8D%97-echarts.html" target="_blank" rel="noopener noreferrer">完整可运行示例（ECharts）</a> — 基于 ECharts 5.5，同等效果，CDN 引入</li>
</ul>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="前端"/>
    <category term="Canvas"/>
    <category term="数据可视化"/>
    <category term="嵌入式"/>
    <category term="BLE"/>
  </entry>
  <entry>
    <title>FlashDB 嵌入式数据库从移植到上手：KVDB/TSDB 双模式基础操作指南</title>
    <link href="https://createskyblue.github.io/posts/flashdb-porting-guide/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/flashdb-porting-guide/</id>
    <published>2026-06-17T00:00:00.000Z</published>
    <updated>2026-06-17T00:00:00.000Z</updated>
    <summary>FlashDB 移植与基础操作，嵌入式 Flash 磨损平衡与掉电保护实战</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>FlashDB 嵌入式数据库从移植到上手：KVDB/TSDB 双模式基础操作指南</h1>
<h2>简介</h2>
<p><a href="https://github.com/armink/FlashDB">FlashDB</a> 是 armink 开源的一款超轻量级嵌入式数据库，专为嵌入式产品的数据存储场景设计。它不像传统数据库依赖文件系统，而是直接结合 Flash 存储特性，在保证极低资源占用的前提下，提供高性能、高可靠的数据存储。</p>
<p>FlashDB 提供两种数据库模式：</p>
<ul>
<li><strong>KVDB（键值数据库）</strong>：非关系型数据库，数据以 Key-Value 键值对存储，操作简洁、扩展性强。适用于产品参数存储、用户配置信息管理、小文件管理等场景。</li>
<li><strong>TSDB（时序数据库）</strong>：数据按时间顺序存储，支持大量数据的高速插入与查询。适用于传感器采集数据记录、设备运行日志存储、异常告警记录等场景。</li>
</ul>
<h2>核心特性</h2>
<p>FlashDB 在设计上针对嵌入式 Flash 的特点做了深度优化：</p>
<p><strong>资源占用极低</strong>。在 STM32F4 平台下，IAR 编译器编译后整个库仅占用约 7KB ROM，RAM 占用几乎为零。内部使用静态分配内存，不使用 malloc。</p>
<p><strong>磨损平衡</strong>。Flash 的擦写次数有限，FlashDB 通过扇区轮转写入机制让擦写操作均匀分布在整个分区上，避免热点扇区过早失效。</p>
<p><strong>掉电保护</strong>。写入机制类比 OTA 的 AB 分区：新数据写到扇区空白区域，旧数据原地不动。写完后用一次 Flash 编程脉冲把一个 bit 从 <code>1</code> 翻成 <code>0</code>，这一个 bit 就是提交信号。NOR Flash 的物理规则是擦除后全为 <code>1</code>，编程只能把 <code>1</code> 变 <code>0</code>，且单个 bit 的翻转是硬件保证的最小原子操作——要么打上去，要么没打，不存在&quot;翻了一半&quot;。于是只有两种结果：bit 还是 <code>1</code>（没提交，等效于没写过），bit 已经 <code>0</code>（提交成功），没有中间态。断电发生在翻转前——旧数据完好；发生在翻转后——新数据生效。</p>
<p><strong>多分区、多实例</strong>。一个系统中可以同时存在多个 KVDB 或 TSDB 实例，各自坐落在不同的 Flash 分区上。当数据量大时，可按业务细分到不同分区以降低检索时间。</p>
<p><strong>增量升级</strong>。启用 <code>FDB_KV_AUTO_UPDATE</code> 后，固件升级时 KVDB 能自动将新的默认 KV 集合合并到已有数据库中，无需手动迁移。</p>
<p><strong>两种 KV 类型</strong>。支持字符串（string）和二进制（blob）两种值类型，blob 可兼容任意结构体数据。</p>
<h2>快速上手：在 Linux 上运行 Demo</h2>
<p>FlashDB 项目自带一个 Linux 平台的 demo，不依赖硬件，可以直接在 PC 上体验。demo 位于 <code>demos/linux</code> 目录下。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955"># 克隆项目</span></span>
<span class="line"><span style="color:#DCDCAA">git</span><span style="color:#CE9178"> clone</span><span style="color:#CE9178"> https://github.com/armink/FlashDB.git</span></span>
<span class="line"><span style="color:#DCDCAA">cd</span><span style="color:#CE9178"> FlashDB/demos/linux</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 编译</span></span>
<span class="line"><span style="color:#DCDCAA">make</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 多次运行查看效果</span></span>
<span class="line"><span style="color:#DCDCAA">cd</span><span style="color:#CE9178"> out</span></span>
<span class="line"><span style="color:#DCDCAA">./FlashDBLinuxDemo</span></span>
<span class="line"><span style="color:#DCDCAA">./FlashDBLinuxDemo</span></span></code></pre><p>每次运行时，demo 会执行 KVDB 和 TSDB 的基本操作并输出日志。第二次运行时可以看到 KVDB 中之前写入的 <code>boot_count</code> 值已被持久化保留。</p>
<h2>配置文件说明</h2>
<p>使用 FlashDB 前，需要在项目中创建 <code>fdb_cfg.h</code> 配置文件。项目提供了模板文件 <code>inc/fdb_cfg_template.h</code>，把它复制并重命名后按需修改即可。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#ifndef</span><span style="color:#569CD6"> _FDB_CFG_H_</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> _FDB_CFG_H_</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 启用 KVDB 功能 */</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> FDB_USING_KVDB</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 可选：启用 KV 增量升级 */</span></span>
<span class="line"><span style="color:#6A9955">/* #define FDB_KV_AUTO_UPDATE */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 启用 TSDB 功能 */</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> FDB_USING_TSDB</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 使用 FAL 存储模式（直接操作 Flash） */</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> FDB_USING_FAL_MODE</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#ifdef</span><span style="color:#569CD6"> FDB_USING_FAL_MODE</span></span>
<span class="line"><span style="color:#6A9955">/* Flash 写粒度，单位 bit</span></span>
<span class="line"><span style="color:#6A9955"> * nor flash: 1,  stm32f2/f4: 8,  stm32f1: 32,  stm32l4: 64 */</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> FDB_WRITE_GRAN  </span><span style="color:#B5CEA8">8</span></span>
<span class="line"><span style="color:#C586C0">#endif</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 或者使用文件模式（二选一） */</span></span>
<span class="line"><span style="color:#6A9955">/* #define FDB_USING_FILE_LIBC_MODE   */</span></span>
<span class="line"><span style="color:#6A9955">/* #define FDB_USING_FILE_POSIX_MODE */</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 调试信息开关 */</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> FDB_DEBUG_ENABLE</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#endif</span><span style="color:#6A9955"> /* _FDB_CFG_H_ */</span></span></code></pre><p>关键配置项说明：</p>
<table>
<thead>
<tr>
<th>配置项</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td><code>FDB_USING_KVDB</code></td>
<td>编译 KVDB 模块，按需开启</td>
</tr>
<tr>
<td><code>FDB_USING_TSDB</code></td>
<td>编译 TSDB 模块，按需开启</td>
</tr>
<tr>
<td><code>FDB_USING_FAL_MODE</code></td>
<td>Flash 抽象层模式，直接操作 Flash，性能最优</td>
</tr>
<tr>
<td><code>FDB_USING_FILE_POSIX_MODE</code></td>
<td>POSIX 文件模式，通过 <code>open/read/write/close</code> 操作</td>
</tr>
<tr>
<td><code>FDB_USING_FILE_LIBC_MODE</code></td>
<td>C 标准库文件模式，通过 <code>fopen/fread/fwrite/fclose</code> 操作</td>
</tr>
<tr>
<td><code>FDB_WRITE_GRAN</code></td>
<td>Flash 写粒度，必须与所用 Flash 芯片一致</td>
</tr>
<tr>
<td><code>FDB_DEBUG_ENABLE</code></td>
<td>调试日志开关，移植阶段建议开启</td>
</tr>
<tr>
<td><code>FDB_KV_AUTO_UPDATE</code></td>
<td>KV 增量升级开关</td>
</tr>
<tr>
<td><code>FDB_BIG_ENDIAN</code></td>
<td>大端模式，默认小端</td>
</tr>
<tr>
<td><code>FDB_PRINT(...)</code></td>
<td>自定义打印宏，默认使用 <code>printf</code></td>
</tr>
</tbody></table>
<p>FAL 模式和文件模式只能二选一。FAL 模式直接操作 Flash，性能更好；文件模式则不受硬件限制，可以存储在 SD 卡、文件系统等任意介质上。</p>
<h2>KVDB 基础操作</h2>
<p>KVDB 的所有操作都围绕 Key-Value 展开。在代码中需要先定义一个 <code>fdb_kvdb_t</code> 类型的数据库实例并完成初始化。</p>
<h3>初始化 KVDB</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;flashdb.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 定义默认 KV 集合：首次初始化时自动写入 */</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> fdb_default_kv_node default_kv_set</span><span style="color:#569CD6">[]</span><span style="color:#D4D4D4"> = {</span></span>
<span class="line"><span style="color:#D4D4D4">    { .key = </span><span style="color:#CE9178">"device_id"</span><span style="color:#D4D4D4">,  .value = </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, .value_len = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4"> },</span></span>
<span class="line"><span style="color:#D4D4D4">    { .key = </span><span style="color:#CE9178">"boot_count"</span><span style="color:#D4D4D4">, .value = </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, .value_len = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4"> },</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> fdb_default_kv default_kv = {</span></span>
<span class="line"><span style="color:#D4D4D4">    .kvs = default_kv_set,</span></span>
<span class="line"><span style="color:#D4D4D4">    .num = </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(default_kv_set) / </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">default_kv_set</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]),</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 定义 KVDB 实例 */</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> fdb_kvdb kvdb;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> kvdb_init_example</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#4EC9B0">    fdb_err_t</span><span style="color:#D4D4D4"> result;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* FAL 模式："kv_db" 是分区表中的分区名 */</span></span>
<span class="line"><span style="color:#D4D4D4">    result = </span><span style="color:#DCDCAA">fdb_kvdb_init</span><span style="color:#D4D4D4">(&#x26;kvdb, </span><span style="color:#CE9178">"env"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"kv_db"</span><span style="color:#D4D4D4">, &#x26;default_kv, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (result != FDB_NO_ERR) {</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"KVDB init failed: </span><span style="color:#9CDCFE">%d</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, result);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"KVDB init ok</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>初始化参数说明：</p>
<ul>
<li><code>&amp;kvdb</code>：数据库实例指针</li>
<li><code>&quot;env&quot;</code>：数据库名称，用于日志标识</li>
<li><code>&quot;kv_db&quot;</code>：FAL 模式下为分区名，文件模式下为存储路径</li>
<li><code>&amp;default_kv</code>：默认 KV 集合，首次初始化时写入，不需要则传 NULL</li>
<li><code>NULL</code>：用户自定义数据指针</li>
</ul>
<h3>字符串类型 KV</h3>
<p>字符串类型操作最简单，直接使用 <code>char *</code> 指针即可。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> kvdb_string_ops</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> fdb_kvdb *</span><span style="color:#9CDCFE">db</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    char</span><span style="color:#D4D4D4"> *val;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 新增 / 修改 KV —— fdb_kv_set 自动判断 key 是否存在 */</span></span>
<span class="line"><span style="color:#DCDCAA">    fdb_kv_set</span><span style="color:#D4D4D4">(db, </span><span style="color:#CE9178">"wifi_ssid"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"MyWiFi_2.4G"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 读取 KV —— 返回值在内部 buffer，建议立即拷贝 */</span></span>
<span class="line"><span style="color:#D4D4D4">    val = </span><span style="color:#DCDCAA">fdb_kv_get</span><span style="color:#D4D4D4">(db, </span><span style="color:#CE9178">"wifi_ssid"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (val != </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"wifi_ssid = </span><span style="color:#9CDCFE">%s</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, val);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 修改已有 KV */</span></span>
<span class="line"><span style="color:#DCDCAA">    fdb_kv_set</span><span style="color:#D4D4D4">(db, </span><span style="color:#CE9178">"wifi_ssid"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"MyWiFi_5G"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 删除 KV */</span></span>
<span class="line"><span style="color:#DCDCAA">    fdb_kv_del</span><span style="color:#D4D4D4">(db, </span><span style="color:#CE9178">"wifi_ssid"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>注意：<code>fdb_kv_get</code> 返回的指针指向内部缓冲区，不可重入，建议立即用 <code>strcpy</code> 或 <code>strdup</code> 拷贝到用户内存中。</p>
<h3>Blob 类型 KV</h3>
<p>Blob 类型支持任意二进制数据，如结构体、数组等。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> device_config {</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> baud_rate;</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#D4D4D4">  slave_id;</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#D4D4D4">  parity;</span></span>
<span class="line"><span style="color:#569CD6">    uint16_t</span><span style="color:#D4D4D4"> timeout_ms;</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> kvdb_blob_ops</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> fdb_kvdb *</span><span style="color:#9CDCFE">db</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> fdb_blob blob;</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> device_config cfg_write, cfg_read;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 写入 blob */</span></span>
<span class="line"><span style="color:#9CDCFE">    cfg_write</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">baud_rate</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">115200</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">    cfg_write</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">slave_id</span><span style="color:#D4D4D4">  = </span><span style="color:#B5CEA8">0x01</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">    cfg_write</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">parity</span><span style="color:#D4D4D4">    = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">    cfg_write</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">timeout_ms</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">500</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">    fdb_kv_set_blob</span><span style="color:#D4D4D4">(db, </span><span style="color:#CE9178">"dev_cfg"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#DCDCAA">        fdb_blob_make</span><span style="color:#D4D4D4">(&#x26;blob, &#x26;cfg_write, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(cfg_write)));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 读取 blob */</span></span>
<span class="line"><span style="color:#DCDCAA">    memset</span><span style="color:#D4D4D4">(&#x26;cfg_read, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(cfg_read));</span></span>
<span class="line"><span style="color:#DCDCAA">    fdb_kv_get_blob</span><span style="color:#D4D4D4">(db, </span><span style="color:#CE9178">"dev_cfg"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#DCDCAA">        fdb_blob_make</span><span style="color:#D4D4D4">(&#x26;blob, &#x26;cfg_read, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(cfg_read)));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">blob</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">saved</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">len</span><span style="color:#D4D4D4"> > </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"baud_rate = </span><span style="color:#9CDCFE">%u</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">cfg_read</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">baud_rate</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><code>fdb_blob_make</code> 的三步操作合为一句：</p>
<ol>
<li>用 <code>value_buf</code> 和 <code>buf_len</code> 初始化 blob 对象</li>
<li>返回构造好的 <code>fdb_blob_t</code> 指针</li>
<li>直接传给 <code>fdb_kv_set_blob</code> 或 <code>fdb_kv_get_blob</code></li>
</ol>
<p>读取 blob 后通过 <code>blob.saved.len &gt; 0</code> 判断是否成功获取到数据。</p>
<h3>遍历所有 KV</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> kvdb_iterate_all</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> fdb_kvdb *</span><span style="color:#9CDCFE">db</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> fdb_kv_iterator itr;</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> fdb_blob blob;</span></span>
<span class="line"><span style="color:#569CD6">    char</span><span style="color:#9CDCFE"> value_buf</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">128</span><span style="color:#D4D4D4">];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    fdb_kv_iterator_init</span><span style="color:#D4D4D4">(db, &#x26;itr);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">fdb_kv_iterate</span><span style="color:#D4D4D4">(db, &#x26;itr)) {</span></span>
<span class="line"><span style="color:#4EC9B0">        fdb_kv_t</span><span style="color:#D4D4D4"> kv = &#x26;</span><span style="color:#9CDCFE">itr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">curr_kv</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">        /* 读取当前 KV 的值 */</span></span>
<span class="line"><span style="color:#DCDCAA">        fdb_kv_to_blob</span><span style="color:#D4D4D4">(kv, </span><span style="color:#DCDCAA">fdb_blob_make</span><span style="color:#D4D4D4">(&#x26;blob, value_buf, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(value_buf)));</span></span>
<span class="line"><span style="color:#DCDCAA">        fdb_blob_read</span><span style="color:#D4D4D4">((</span><span style="color:#4EC9B0">fdb_db_t</span><span style="color:#D4D4D4">)db, &#x26;blob);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"KV: name=</span><span style="color:#9CDCFE">%-20s</span><span style="color:#CE9178"> value_len=</span><span style="color:#9CDCFE">%u</span><span style="color:#CE9178"> status=</span><span style="color:#9CDCFE">%d</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">               kv</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">name</span><span style="color:#D4D4D4">, (</span><span style="color:#569CD6">unsigned</span><span style="color:#D4D4D4">)</span><span style="color:#9CDCFE">kv</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">value_len</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">kv</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">status</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"遍历完成：共 </span><span style="color:#9CDCFE">%u</span><span style="color:#CE9178"> 条 KV, 占用 </span><span style="color:#9CDCFE">%zu</span><span style="color:#CE9178"> 字节</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">           (</span><span style="color:#569CD6">unsigned</span><span style="color:#D4D4D4">)</span><span style="color:#9CDCFE">itr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">iterated_cnt</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">itr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">iterated_obj_bytes</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>重置为默认值</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 将 KVDB 中所有 KV 重置为初始化时传入的 default_kv */</span></span>
<span class="line"><span style="color:#DCDCAA">fdb_kv_set_default</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">kvdb</span><span style="color:#D4D4D4">);</span></span></code></pre><h3>打印数据库信息</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 输出当前 KVDB 中所有 KV 的概要信息 */</span></span>
<span class="line"><span style="color:#DCDCAA">fdb_kv_print</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">kvdb</span><span style="color:#D4D4D4">);</span></span></code></pre><h2>TSDB 基础操作</h2>
<p>TSDB 使用时序日志（TSL）作为存储单元，每条 TSL 包含时间戳和用户数据。</p>
<h3>初始化 TSDB</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> fdb_tsdb tsdb;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 获取当前时间戳的回调函数 */</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#4EC9B0"> fdb_time_t</span><span style="color:#DCDCAA"> get_current_time</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#DCDCAA"> time</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> tsdb_init_example</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#4EC9B0">    fdb_err_t</span><span style="color:#D4D4D4"> result;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    result = </span><span style="color:#DCDCAA">fdb_tsdb_init</span><span style="color:#D4D4D4">(&#x26;tsdb, </span><span style="color:#CE9178">"sensor_log"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"ts_db"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">                           get_current_time,</span><span style="color:#6A9955">   /* 获取时间戳函数 */</span></span>
<span class="line"><span style="color:#B5CEA8">                           128</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955">                /* 每条 TSL 最大长度 */</span></span>
<span class="line"><span style="color:#569CD6">                           NULL</span><span style="color:#D4D4D4">);</span><span style="color:#6A9955">              /* 用户数据 */</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (result != FDB_NO_ERR) {</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"TSDB init failed: </span><span style="color:#9CDCFE">%d</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, result);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"TSDB init ok</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>几个关键参数：</p>
<ul>
<li><code>get_current_time</code>：时间戳获取函数，嵌入式平台可对接 RTC</li>
<li><code>128</code>：每条 TSL 数据部分的最大长度，超出会返回错误</li>
<li>可选配置：通过 <code>fdb_tsdb_control</code> 设置扇区大小、翻转模式等</li>
</ul>
<h3>追加 TSL</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> sensor_data {</span></span>
<span class="line"><span style="color:#569CD6">    float</span><span style="color:#D4D4D4"> temperature;</span></span>
<span class="line"><span style="color:#569CD6">    float</span><span style="color:#D4D4D4"> humidity;</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> timestamp;</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> tsdb_append_example</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> fdb_tsdb *</span><span style="color:#9CDCFE">db</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> fdb_blob blob;</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> sensor_data data;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">    data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">temperature</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">26.5f</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">    data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">humidity</span><span style="color:#D4D4D4">    = </span><span style="color:#B5CEA8">65.2f</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">    data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">timestamp</span><span style="color:#D4D4D4">   = </span><span style="color:#DCDCAA">get_current_time</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    fdb_tsl_append</span><span style="color:#D4D4D4">(db, </span><span style="color:#DCDCAA">fdb_blob_make</span><span style="color:#D4D4D4">(&#x26;blob, &#x26;data, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(data)));</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"已追加一条传感器数据</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>追加操作会自动打上时间戳。如果需要使用指定时间戳（如从 RTC 读取历史数据回填），使用 <code>fdb_tsl_append_with_ts</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">fdb_tsl_append_with_ts</span><span style="color:#D4D4D4">(db, </span><span style="color:#DCDCAA">fdb_blob_make</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">blob</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(data)),</span></span>
<span class="line"><span style="color:#D4D4D4">                       custom_timestamp);</span></span></code></pre><h3>查询 TSL</h3>
<p>TSDB 的查询通过迭代器回调实现。回调函数返回 <code>true</code> 时停止迭代。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 回调函数：每条 TSL 调用一次 */</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> bool</span><span style="color:#DCDCAA"> query_cb</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">fdb_tsl_t</span><span style="color:#9CDCFE"> tsl</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">arg</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> fdb_blob blob;</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> sensor_data data;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    fdb_blob_read</span><span style="color:#D4D4D4">((</span><span style="color:#4EC9B0">fdb_db_t</span><span style="color:#D4D4D4">)arg,</span></span>
<span class="line"><span style="color:#DCDCAA">                  fdb_tsl_to_blob</span><span style="color:#D4D4D4">(tsl, </span><span style="color:#DCDCAA">fdb_blob_make</span><span style="color:#D4D4D4">(&#x26;blob, &#x26;data, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(data))));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"TSL: time=</span><span style="color:#9CDCFE">%d</span><span style="color:#CE9178">  temp=</span><span style="color:#9CDCFE">%.1f</span><span style="color:#CE9178">  humi=</span><span style="color:#9CDCFE">%.1f</span><span style="color:#CE9178">  status=</span><span style="color:#9CDCFE">%d</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">           (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4">)</span><span style="color:#9CDCFE">tsl</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">time</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">temperature</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">humidity</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">tsl</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">status</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#569CD6"> false</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">  /* 返回 false 继续迭代 */</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 遍历全部 TSL */</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> tsdb_query_all</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> fdb_tsdb *</span><span style="color:#9CDCFE">db</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"===== 全部 TSL =====</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    fdb_tsl_iter</span><span style="color:#D4D4D4">(db, query_cb, db);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 按时间范围遍历 */</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> tsdb_query_by_time</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> fdb_tsdb *</span><span style="color:#9CDCFE">db</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#4EC9B0">                        fdb_time_t</span><span style="color:#9CDCFE"> from</span><span style="color:#D4D4D4">, </span><span style="color:#4EC9B0">fdb_time_t</span><span style="color:#9CDCFE"> to</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"===== </span><span style="color:#9CDCFE">%d</span><span style="color:#CE9178"> ~ </span><span style="color:#9CDCFE">%d</span><span style="color:#CE9178"> 范围内的 TSL =====</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4">)from, (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4">)to);</span></span>
<span class="line"><span style="color:#DCDCAA">    fdb_tsl_iter_by_time</span><span style="color:#D4D4D4">(db, from, to, query_cb, db);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 同时查询满足条件的 TSL 数量 */</span></span>
<span class="line"><span style="color:#569CD6">    size_t</span><span style="color:#D4D4D4"> count = </span><span style="color:#DCDCAA">fdb_tsl_query_count</span><span style="color:#D4D4D4">(db, from, to, FDB_TSL_WRITE);</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"范围内 TSL 数量: </span><span style="color:#9CDCFE">%zu</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, count);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 反向遍历（从最新到最旧） */</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> tsdb_query_reverse</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> fdb_tsdb *</span><span style="color:#9CDCFE">db</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"===== 反向遍历 =====</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    fdb_tsl_iter_reverse</span><span style="color:#D4D4D4">(db, query_cb, db);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>设置 TSL 状态</h3>
<p>每条 TSL 都有状态标记，可用于数据管理。状态切换必须按顺序：
<code>FDB_TSL_WRITE → FDB_TSL_USER_STATUS1 → FDB_TSL_DELETED → FDB_TSL_USER_STATUS2</code></p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> bool</span><span style="color:#DCDCAA"> set_status_cb</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">fdb_tsl_t</span><span style="color:#9CDCFE"> tsl</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">arg</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> fdb_tsdb *db = (</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> fdb_tsdb *)arg;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 将已写入状态改为用户自定义状态1 */</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">tsl</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">status</span><span style="color:#D4D4D4"> == FDB_TSL_WRITE) {</span></span>
<span class="line"><span style="color:#DCDCAA">        fdb_tsl_set_status</span><span style="color:#D4D4D4">(db, tsl, FDB_TSL_USER_STATUS1);</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"TSL time=</span><span style="color:#9CDCFE">%d</span><span style="color:#CE9178"> 状态已更新</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4">)</span><span style="color:#9CDCFE">tsl</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">time</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#569CD6"> false</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> tsdb_set_status_example</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> fdb_tsdb *</span><span style="color:#9CDCFE">db</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    fdb_tsl_iter</span><span style="color:#D4D4D4">(db, set_status_cb, db);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>两个用户自定义状态（<code>USER_STATUS1</code> / <code>USER_STATUS2</code>）可用于实现&quot;已读/未读&quot;、&quot;已上报/未上报&quot;等业务逻辑。</p>
<h3>其他控制操作</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 设置 TSDB 翻卷模式：最旧数据可被覆盖（默认） */</span></span>
<span class="line"><span style="color:#DCDCAA">fdb_tsdb_control</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">tsdb</span><span style="color:#D4D4D4">, FDB_TSDB_CTRL_SET_ROLLOVER, (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *)</span><span style="color:#9CDCFE">true</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 查询上次存储时间 */</span></span>
<span class="line"><span style="color:#4EC9B0">fdb_time_t</span><span style="color:#D4D4D4"> last_time;</span></span>
<span class="line"><span style="color:#DCDCAA">fdb_tsdb_control</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">tsdb</span><span style="color:#D4D4D4">, FDB_TSDB_CTRL_GET_LAST_TIME, &#x26;</span><span style="color:#9CDCFE">last_time</span><span style="color:#D4D4D4">);</span></span></code></pre><h2>移植指南</h2>
<p>FlashDB 的移植工作集中在 FAL（Flash Abstraction Layer）一端。FAL 是 RT-Thread 开源的 Flash 抽象层软件包，也支持裸机运行，<strong>不依赖任何 RTOS</strong>。</p>
<p>移植的整体架构如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span> ┌─────────────────────────────────┐</span></span>
<span class="line"><span> │  KVDB / TSDB  (FlashDB)         │  ← 上层 API</span></span>
<span class="line"><span> ├─────────────────────────────────┤</span></span>
<span class="line"><span> │  FAL (Flash Abstraction Layer)  │  ← 分区 &#x26; 设备管理</span></span>
<span class="line"><span> ├─────────────────────────────────┤</span></span>
<span class="line"><span> │  Flash Driver (片内/片外)        │  ← 硬件驱动</span></span>
<span class="line"><span> └─────────────────────────────────┘</span></span></code></pre><h3>第一步：集成 FAL</h3>
<p>将 FAL 源码添加到工程。FAL 仓库地址：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>https://github.com/RT-Thread-packages/fal</span></span></code></pre><p>将 <code>fal/inc</code> 和 <code>fal/src</code> 目录加入工程头文件路径和源文件列表。</p>
<h3>第二步：实现 Flash 驱动操作函数</h3>
<p>根据目标平台实现四个操作函数。以下以 STM32F4 片内 Flash 为例：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;fal.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stm32f4xx_hal.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> int</span><span style="color:#DCDCAA"> stm32_onchip_init</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#6A9955">    /* 片内 Flash 通常无需额外初始化 */</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> int</span><span style="color:#DCDCAA"> stm32_onchip_read</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">long</span><span style="color:#9CDCFE"> offset</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">buf</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> size</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    size_t</span><span style="color:#D4D4D4"> i;</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> addr = STM32_FLASH_START_ADDR + offset;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; size; i++, addr++, buf++) {</span></span>
<span class="line"><span style="color:#D4D4D4">        *buf = *(</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *)addr;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> size;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> int</span><span style="color:#DCDCAA"> stm32_onchip_write</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">long</span><span style="color:#9CDCFE"> offset</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">buf</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> size</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    size_t</span><span style="color:#D4D4D4"> i;</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> addr = STM32_FLASH_START_ADDR + offset;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    HAL_FLASH_Unlock</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; size; i++, addr++, buf++) {</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">HAL_FLASH_Program</span><span style="color:#D4D4D4">(FLASH_TYPEPROGRAM_BYTE, addr, *buf) != HAL_OK) {</span></span>
<span class="line"><span style="color:#DCDCAA">            HAL_FLASH_Lock</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">            return</span><span style="color:#D4D4D4"> i;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#DCDCAA">    HAL_FLASH_Lock</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> size;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> int</span><span style="color:#DCDCAA"> stm32_onchip_erase</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">long</span><span style="color:#9CDCFE"> offset</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> size</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> err_sector;</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> start_addr = STM32_FLASH_START_ADDR + offset;</span></span>
<span class="line"><span style="color:#D4D4D4">    FLASH_EraseInitTypeDef erase_cfg;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">    erase_cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">TypeErase</span><span style="color:#D4D4D4">    = FLASH_TYPEERASE_SECTORS;</span></span>
<span class="line"><span style="color:#9CDCFE">    erase_cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">Sector</span><span style="color:#D4D4D4">       = </span><span style="color:#DCDCAA">stm32_get_sector</span><span style="color:#D4D4D4">(start_addr);</span></span>
<span class="line"><span style="color:#9CDCFE">    erase_cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">NbSectors</span><span style="color:#D4D4D4">    = size / FLASH_SECTOR_SIZE;</span></span>
<span class="line"><span style="color:#9CDCFE">    erase_cfg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">VoltageRange</span><span style="color:#D4D4D4"> = FLASH_VOLTAGE_RANGE_3;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    HAL_FLASH_Unlock</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">HAL_FLASHEx_Erase</span><span style="color:#D4D4D4">(&#x26;erase_cfg, &#x26;err_sector) != HAL_OK) {</span></span>
<span class="line"><span style="color:#DCDCAA">        HAL_FLASH_Lock</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4"> -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#DCDCAA">    HAL_FLASH_Lock</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> size;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 定义 Flash 设备对象 */</span></span>
<span class="line"><span style="color:#569CD6">const</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> fal_flash_dev stm32_onchip_flash = {</span></span>
<span class="line"><span style="color:#D4D4D4">    .name       = </span><span style="color:#CE9178">"stm32_onchip"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">    .addr       = </span><span style="color:#B5CEA8">0x08000000</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">    .len        = </span><span style="color:#B5CEA8">1024</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">1024</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955">      /* 1MB */</span></span>
<span class="line"><span style="color:#D4D4D4">    .blk_size   = </span><span style="color:#B5CEA8">128</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">1024</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955">       /* 最大扇区大小 128KB */</span></span>
<span class="line"><span style="color:#D4D4D4">    .ops        = { stm32_onchip_init,</span></span>
<span class="line"><span style="color:#D4D4D4">                    stm32_onchip_read,</span></span>
<span class="line"><span style="color:#D4D4D4">                    stm32_onchip_write,</span></span>
<span class="line"><span style="color:#D4D4D4">                    stm32_onchip_erase },</span></span>
<span class="line"><span style="color:#D4D4D4">    .write_gran = </span><span style="color:#B5CEA8">8</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955">                /* STM32F4 写粒度: 8 bit */</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><p>操作函数说明：</p>
<table>
<thead>
<tr>
<th>函数</th>
<th>功能</th>
<th>返回值</th>
</tr>
</thead>
<tbody><tr>
<td><code>init</code></td>
<td>可选初始化，不需要可置 NULL</td>
<td>≥0 成功</td>
</tr>
<tr>
<td><code>read</code></td>
<td>从 Flash 偏移处读取数据</td>
<td>实际读取字节数</td>
</tr>
<tr>
<td><code>write</code></td>
<td>向 Flash 偏移处写入数据</td>
<td>实际写入字节数</td>
</tr>
<tr>
<td><code>erase</code></td>
<td>擦除 Flash 指定区域</td>
<td>实际擦除字节数</td>
</tr>
</tbody></table>
<p><code>write_gran</code> 写粒度必须准确设置——它决定了 FlashDB 内部按什么粒度对齐写入数据。常见 Flash 写粒度：</p>
<table>
<thead>
<tr>
<th>Flash 类型</th>
<th>write_gran</th>
</tr>
</thead>
<tbody><tr>
<td>NOR Flash (SPI)</td>
<td>1 bit</td>
</tr>
<tr>
<td>STM32F2 / STM32F4</td>
<td>8 bit</td>
</tr>
<tr>
<td>STM32F1</td>
<td>32 bit</td>
</tr>
<tr>
<td>STM32L4</td>
<td>64 bit</td>
</tr>
<tr>
<td>STM32H5</td>
<td>128 bit</td>
</tr>
<tr>
<td>STM32H7</td>
<td>256 bit</td>
</tr>
</tbody></table>
<h3>第三步：创建 fal_cfg.h</h3>
<p>在工程目录下新建 <code>fal_cfg.h</code> 文件，定义 Flash 设备表和分区表：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#ifndef</span><span style="color:#569CD6"> _FAL_CFG_H_</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> _FAL_CFG_H_</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;fal.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 声明 Flash 设备 */</span></span>
<span class="line"><span style="color:#569CD6">extern</span><span style="color:#569CD6"> const</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> fal_flash_dev stm32_onchip_flash;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* ============ Flash 设备表 ============ */</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> FAL_FLASH_DEV_TABLE        </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">{                                  </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#D4D4D4">    &#x26;</span><span style="color:#569CD6">stm32_onchip_flash,           </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* ============ Flash 分区表 ============ */</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> FAL_PART_TABLE                                                          </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">{                                                                               </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    {FAL_PART_MAGIC_WORD, </span><span style="color:#CE9178">"boot"</span><span style="color:#569CD6">,    </span><span style="color:#CE9178">"stm32_onchip"</span><span style="color:#569CD6">,         </span><span style="color:#B5CEA8">0</span><span style="color:#569CD6">,  </span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">*</span><span style="color:#B5CEA8">1024</span><span style="color:#569CD6">,   </span><span style="color:#B5CEA8">0</span><span style="color:#569CD6">}, </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    {FAL_PART_MAGIC_WORD, </span><span style="color:#CE9178">"app"</span><span style="color:#569CD6">,     </span><span style="color:#CE9178">"stm32_onchip"</span><span style="color:#569CD6">,  </span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">*</span><span style="color:#B5CEA8">1024</span><span style="color:#569CD6">, </span><span style="color:#B5CEA8">256</span><span style="color:#D4D4D4">*</span><span style="color:#B5CEA8">1024</span><span style="color:#569CD6">,   </span><span style="color:#B5CEA8">0</span><span style="color:#569CD6">}, </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    {FAL_PART_MAGIC_WORD, </span><span style="color:#CE9178">"kv_db"</span><span style="color:#569CD6">,   </span><span style="color:#CE9178">"stm32_onchip"</span><span style="color:#569CD6">, </span><span style="color:#B5CEA8">320</span><span style="color:#D4D4D4">*</span><span style="color:#B5CEA8">1024</span><span style="color:#569CD6">, </span><span style="color:#B5CEA8">128</span><span style="color:#D4D4D4">*</span><span style="color:#B5CEA8">1024</span><span style="color:#569CD6">,   </span><span style="color:#B5CEA8">0</span><span style="color:#569CD6">}, </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">    {FAL_PART_MAGIC_WORD, </span><span style="color:#CE9178">"ts_db"</span><span style="color:#569CD6">,   </span><span style="color:#CE9178">"stm32_onchip"</span><span style="color:#569CD6">, </span><span style="color:#B5CEA8">448</span><span style="color:#D4D4D4">*</span><span style="color:#B5CEA8">1024</span><span style="color:#569CD6">, </span><span style="color:#B5CEA8">512</span><span style="color:#D4D4D4">*</span><span style="color:#B5CEA8">1024</span><span style="color:#569CD6">,   </span><span style="color:#B5CEA8">0</span><span style="color:#569CD6">}, </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#569CD6">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#endif</span><span style="color:#6A9955"> /* _FAL_CFG_H_ */</span></span></code></pre><p>分区表定义了 4 个分区：</p>
<table>
<thead>
<tr>
<th>分区名</th>
<th>设备</th>
<th>偏移</th>
<th>大小</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td>boot</td>
<td>stm32_onchip</td>
<td>0</td>
<td>64KB</td>
<td>Bootloader</td>
</tr>
<tr>
<td>app</td>
<td>stm32_onchip</td>
<td>64KB</td>
<td>256KB</td>
<td>应用程序</td>
</tr>
<tr>
<td>kv_db</td>
<td>stm32_onchip</td>
<td>320KB</td>
<td>128KB</td>
<td>KVDB 数据库</td>
</tr>
<tr>
<td>ts_db</td>
<td>stm32_onchip</td>
<td>448KB</td>
<td>512KB</td>
<td>TSDB 数据库</td>
</tr>
</tbody></table>
<p>注意：</p>
<ul>
<li>分区名不可重复</li>
<li>关联的 Flash 设备必须在设备表中已定义且名称一致</li>
<li>分区起始地址和大小不能超出 Flash 设备的地址范围</li>
<li>每个分区记录末尾保留 <code>0</code> 作为保留字段</li>
</ul>
<h3>第四步：应用层初始化</h3>
<p>在 <code>main</code> 函数中依次初始化 FAL 和 FlashDB：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;flashdb.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;fal.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> fdb_kvdb kvdb;</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> fdb_tsdb tsdb;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#4EC9B0">    fdb_err_t</span><span style="color:#D4D4D4"> result;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 1. 初始化 FAL */</span></span>
<span class="line"><span style="color:#DCDCAA">    fal_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 2. 初始化 KVDB —— 分区名对应 fal_cfg.h 中的 "kv_db" */</span></span>
<span class="line"><span style="color:#D4D4D4">    result = </span><span style="color:#DCDCAA">fdb_kvdb_init</span><span style="color:#D4D4D4">(&#x26;kvdb, </span><span style="color:#CE9178">"env"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"kv_db"</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (result != FDB_NO_ERR) {</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"KVDB init failed: </span><span style="color:#9CDCFE">%d</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, result);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 3. 初始化 TSDB —— 分区名对应 fal_cfg.h 中的 "ts_db" */</span></span>
<span class="line"><span style="color:#D4D4D4">    result = </span><span style="color:#DCDCAA">fdb_tsdb_init</span><span style="color:#D4D4D4">(&#x26;tsdb, </span><span style="color:#CE9178">"sensor_log"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"ts_db"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">                           get_current_time, </span><span style="color:#B5CEA8">128</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (result != FDB_NO_ERR) {</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"TSDB init failed: </span><span style="color:#9CDCFE">%d</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, result);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 4. 正常使用 */</span></span>
<span class="line"><span style="color:#DCDCAA">    fdb_kv_set</span><span style="color:#D4D4D4">(&#x26;kvdb, </span><span style="color:#CE9178">"fw_version"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"1.0.0"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"fw_version = </span><span style="color:#9CDCFE">%s</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, </span><span style="color:#DCDCAA">fdb_kv_get</span><span style="color:#D4D4D4">(&#x26;kvdb, </span><span style="color:#CE9178">"fw_version"</span><span style="color:#D4D4D4">));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#6A9955">        /* 业务循环 */</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>文件模式移植（无需 Flash 硬件）</h3>
<p>如果开发阶段没有 Flash 硬件，或需要将数据库存储在 SD 卡/文件系统中，可以使用文件模式：</p>
<p>在 <code>fdb_cfg.h</code> 中选择文件模式：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 使用 POSIX 文件模式（Linux / ESP-IDF 等） */</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> FDB_USING_FILE_POSIX_MODE</span></span>
<span class="line"><span style="color:#6A9955">/* 注意：同时关闭 FAL 模式 */</span></span>
<span class="line"><span style="color:#6A9955">/* #define FDB_USING_FAL_MODE */</span></span></code></pre><p>初始化时通过 control 接口设置文件模式参数：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/* 设置文件模式 */</span></span>
<span class="line"><span style="color:#DCDCAA">fdb_kvdb_control</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">kvdb</span><span style="color:#D4D4D4">, FDB_KVDB_CTRL_SET_FILE_MODE, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#6A9955">/* 设置扇区大小（字节） */</span></span>
<span class="line"><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4"> sec_size = </span><span style="color:#B5CEA8">4096</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">fdb_kvdb_control</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">kvdb</span><span style="color:#D4D4D4">, FDB_KVDB_CTRL_SET_SEC_SIZE, &#x26;</span><span style="color:#9CDCFE">sec_size</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#6A9955">/* 设置数据库最大大小 */</span></span>
<span class="line"><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4"> max_size = </span><span style="color:#B5CEA8">256</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">1024</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">  /* 256KB */</span></span>
<span class="line"><span style="color:#DCDCAA">fdb_kvdb_control</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">kvdb</span><span style="color:#D4D4D4">, FDB_KVDB_CTRL_SET_MAX_SIZE, &#x26;</span><span style="color:#9CDCFE">max_size</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 路径参数传入文件目录 */</span></span>
<span class="line"><span style="color:#DCDCAA">fdb_kvdb_init</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">kvdb</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"env"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"/data/flashdb_kv"</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span></code></pre><p>C 标准库模式 (<code>FDB_USING_FILE_LIBC_MODE</code>) 与 POSIX 模式类似，区别在于底层使用 <code>fopen/fread/fwrite/fclose</code> 接口。</p>
<h3>加锁保护</h3>
<p>在多线程或中断环境中需要保护 Flash 操作，通过 control 接口注册加锁/解锁函数：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> db_lock</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">fdb_db_t</span><span style="color:#9CDCFE"> db</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    mutex_lock</span><span style="color:#D4D4D4">(&#x26;flash_mutex);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> db_unlock</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">fdb_db_t</span><span style="color:#9CDCFE"> db</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    mutex_unlock</span><span style="color:#D4D4D4">(&#x26;flash_mutex);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 初始化前注册 */</span></span>
<span class="line"><span style="color:#DCDCAA">fdb_kvdb_control</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">kvdb</span><span style="color:#D4D4D4">, FDB_KVDB_CTRL_SET_LOCK, (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *)</span><span style="color:#9CDCFE">db_lock</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">fdb_kvdb_control</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">kvdb</span><span style="color:#D4D4D4">, FDB_KVDB_CTRL_SET_UNLOCK, (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *)</span><span style="color:#9CDCFE">db_unlock</span><span style="color:#D4D4D4">);</span></span></code></pre><h3>常见移植问题</h3>
<ol>
<li><p><strong>分区找不到</strong>：检查 <code>fdb_kvdb_init</code> 传入的分区名与 <code>fal_cfg.h</code> 中的分区名是否完全一致，以及 <code>fal_init()</code> 是否在数据库初始化之前调用。</p>
</li>
<li><p><strong>写入后读取数据不匹配</strong>：检查 <code>FDB_WRITE_GRAN</code> 写粒度是否与 Flash 芯片一致。nor flash 通常为 1，STM32 系列因型号不同而异。</p>
</li>
<li><p><strong>扇区大小对齐问题</strong>：KVDB/TSDB 的扇区大小必须是 Flash 块大小的整数倍。如果数据库保存的数据较大，可通过 <code>FDB_KVDB_CTRL_SET_SEC_SIZE</code> 调大扇区。</p>
</li>
<li><p><strong>编译提示找不到 <code>fal_cfg.h</code></strong>：确认文件已创建在工程目录下且头文件路径已加入编译器 include path。</p>
</li>
</ol>
<h2>API 速查表</h2>
<h3>KVDB API</h3>
<table>
<thead>
<tr>
<th>函数</th>
<th>功能</th>
</tr>
</thead>
<tbody><tr>
<td><code>fdb_kvdb_init(db, name, path, default_kv, user_data)</code></td>
<td>初始化 KVDB</td>
</tr>
<tr>
<td><code>fdb_kvdb_deinit(db)</code></td>
<td>反初始化</td>
</tr>
<tr>
<td><code>fdb_kvdb_control(db, cmd, arg)</code></td>
<td>控制操作（设置扇区大小、锁等）</td>
</tr>
<tr>
<td><code>fdb_kv_set(db, key, value)</code></td>
<td>写入字符串类型 KV</td>
</tr>
<tr>
<td><code>fdb_kv_get(db, key)</code></td>
<td>读取字符串类型 KV（返回内部 buffer 指针）</td>
</tr>
<tr>
<td><code>fdb_kv_set_blob(db, key, blob)</code></td>
<td>写入 blob 类型 KV</td>
</tr>
<tr>
<td><code>fdb_kv_get_blob(db, key, blob)</code></td>
<td>读取 blob 类型 KV</td>
</tr>
<tr>
<td><code>fdb_kv_get_obj(db, key, kv)</code></td>
<td>获取 KV 对象（不读数据）</td>
</tr>
<tr>
<td><code>fdb_kv_del(db, key)</code></td>
<td>删除指定 KV</td>
</tr>
<tr>
<td><code>fdb_kv_set_default(db)</code></td>
<td>重置为默认 KV 集合</td>
</tr>
<tr>
<td><code>fdb_kv_print(db)</code></td>
<td>打印 KVDB 信息</td>
</tr>
<tr>
<td><code>fdb_kv_iterator_init(db, itr)</code></td>
<td>初始化 KV 迭代器</td>
</tr>
<tr>
<td><code>fdb_kv_iterate(db, itr)</code></td>
<td>遍历下一个 KV，返回 false 表示结束</td>
</tr>
</tbody></table>
<h3>TSDB API</h3>
<table>
<thead>
<tr>
<th>函数</th>
<th>功能</th>
</tr>
</thead>
<tbody><tr>
<td><code>fdb_tsdb_init(db, name, path, get_time, max_len, user_data)</code></td>
<td>初始化 TSDB</td>
</tr>
<tr>
<td><code>fdb_tsdb_deinit(db)</code></td>
<td>反初始化</td>
</tr>
<tr>
<td><code>fdb_tsdb_control(db, cmd, arg)</code></td>
<td>控制操作（翻卷、扇区大小等）</td>
</tr>
<tr>
<td><code>fdb_tsl_append(db, blob)</code></td>
<td>追加 TSL（自动时间戳）</td>
</tr>
<tr>
<td><code>fdb_tsl_append_with_ts(db, blob, timestamp)</code></td>
<td>追加 TSL（指定时间戳）</td>
</tr>
<tr>
<td><code>fdb_tsl_iter(db, cb, arg)</code></td>
<td>遍历全部 TSL</td>
</tr>
<tr>
<td><code>fdb_tsl_iter_reverse(db, cb, arg)</code></td>
<td>反向遍历全部 TSL</td>
</tr>
<tr>
<td><code>fdb_tsl_iter_by_time(db, from, to, cb, arg)</code></td>
<td>按时间范围遍历</td>
</tr>
<tr>
<td><code>fdb_tsl_query_count(db, from, to, status)</code></td>
<td>查询符合条件 TSL 数量</td>
</tr>
<tr>
<td><code>fdb_tsl_set_status(db, tsl, status)</code></td>
<td>设置 TSL 状态</td>
</tr>
<tr>
<td><code>fdb_tsl_clean(db)</code></td>
<td>清空 TSDB</td>
</tr>
</tbody></table>
<h3>Blob 工具 API</h3>
<table>
<thead>
<tr>
<th>函数</th>
<th>功能</th>
</tr>
</thead>
<tbody><tr>
<td><code>fdb_blob_make(blob, buf, len)</code></td>
<td>构造 blob 对象</td>
</tr>
<tr>
<td><code>fdb_blob_read(db, blob)</code></td>
<td>读取 blob 数据到用户 buffer</td>
</tr>
<tr>
<td><code>fdb_kv_to_blob(kv, blob)</code></td>
<td>KV 对象转 blob</td>
</tr>
<tr>
<td><code>fdb_tsl_to_blob(tsl, blob)</code></td>
<td>TSL 对象转 blob</td>
</tr>
</tbody></table>
<h3>KVDB 控制命令</h3>
<table>
<thead>
<tr>
<th>命令</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td><code>FDB_KVDB_CTRL_SET_SEC_SIZE</code></td>
<td>设置扇区大小（初始化前）</td>
</tr>
<tr>
<td><code>FDB_KVDB_CTRL_GET_SEC_SIZE</code></td>
<td>获取扇区大小</td>
</tr>
<tr>
<td><code>FDB_KVDB_CTRL_SET_LOCK</code></td>
<td>设置加锁函数</td>
</tr>
<tr>
<td><code>FDB_KVDB_CTRL_SET_UNLOCK</code></td>
<td>设置解锁函数</td>
</tr>
<tr>
<td><code>FDB_KVDB_CTRL_SET_FILE_MODE</code></td>
<td>启用文件模式（初始化前）</td>
</tr>
<tr>
<td><code>FDB_KVDB_CTRL_SET_MAX_SIZE</code></td>
<td>设置数据库最大大小（文件模式，初始化前）</td>
</tr>
<tr>
<td><code>FDB_KVDB_CTRL_SET_NOT_FORMAT</code></td>
<td>禁自动格式化（初始化前）</td>
</tr>
</tbody></table>
<h3>TSDB 控制命令</h3>
<table>
<thead>
<tr>
<th>命令</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td><code>FDB_TSDB_CTRL_SET_SEC_SIZE</code></td>
<td>设置扇区大小（初始化前）</td>
</tr>
<tr>
<td><code>FDB_TSDB_CTRL_SET_ROLLOVER</code></td>
<td>设置翻卷模式（初始化后）</td>
</tr>
<tr>
<td><code>FDB_TSDB_CTRL_GET_ROLLOVER</code></td>
<td>获取翻卷模式</td>
</tr>
<tr>
<td><code>FDB_TSDB_CTRL_GET_LAST_TIME</code></td>
<td>获取最后一次存储时间</td>
</tr>
<tr>
<td><code>FDB_TSDB_CTRL_SET_FILE_MODE</code></td>
<td>启用文件模式（初始化前）</td>
</tr>
<tr>
<td><code>FDB_TSDB_CTRL_SET_MAX_SIZE</code></td>
<td>设置数据库最大大小（文件模式，初始化前）</td>
</tr>
</tbody></table>
<h2>与 LittleFS 的设计对比</h2>
<p>FlashDB 和 LittleFS 同为嵌入式 Flash 存储方案，都遵循 Copy-on-Write + 原子提交的设计原则，但抽象层级和适用场景完全不同。</p>
<p><strong>共同的设计哲学</strong>：</p>
<p>两者都&quot;不信任 Flash&quot;——不用原地修改，每次写入走新位置；两者都靠 Flash 的单向翻转特性做原子提交——写完后改一个标志位或版本号，改完即提交，断在中间就当没写；两者都不依赖 OS，静态分配内存，裸机和 RTOS 都能跑。</p>
<p><strong>提交机制的对比</strong>：</p>
<p>FlashDB 用<strong>单 bit 翻转</strong>做提交。写入一条 KV 后，把记录头部一个 bit 从 <code>1</code> 翻成 <code>0</code>，这条记录就生效了。bit 是 Flash 编程的最小原子单位，断电时只有翻之前（回退旧数据）和翻之后（新数据生效）两种状态。</p>
<p>LittleFS 用<strong>双 metadata block 交替</strong>做提交。因为它管理的是整个文件系统的目录树——一次操作可能牵扯多个 inode、目录项、文件大小更新。一个 bit 表达不了这么多改动的&quot;一致性提交&quot;，于是它用两个 metadata block 轮换写入，每次写新的、让 revision count 递增，上电时比较两个 block，取 revision 更大且 CRC 正确的那个。</p>
<p>本质是同一个 AB 切换思想，FlashDB 缩到了一个 bit，LittleFS 扩到了两个 block。不是谁更聪明，是各自要保护的数据复杂度不同。</p>
<p><strong>抽象层级</strong>：FlashDB 是<strong>记录导向</strong>的数据库——接口是 <code>set(&quot;key&quot;, value)</code>，它理解数据的语义，能做遍历、状态过滤、增量升级。LittleFS 是<strong>流导向</strong>的文件系统——接口是 <code>fopen/fwrite</code>，只管理字节序列和目录结构，不关心文件里存了什么。</p>
<p><strong>选择依据</strong>：</p>
<table>
<thead>
<tr>
<th>场景</th>
<th>推荐</th>
</tr>
</thead>
<tbody><tr>
<td>存产品参数、用户配置</td>
<td>FlashDB KVDB</td>
</tr>
<tr>
<td>记录传感器日志、运行日志</td>
<td>FlashDB TSDB</td>
</tr>
<tr>
<td>存固件 OTA 包、资源文件、任意格式文件</td>
<td>LittleFS</td>
</tr>
<tr>
<td>需要 PC 端熟悉的目录/文件操作模式</td>
<td>LittleFS</td>
</tr>
<tr>
<td>资源极度受限（几 KB RAM）</td>
<td>FlashDB</td>
</tr>
</tbody></table>
<p>也可以一起用：FlashDB 的文件模式底层跑在 LittleFS 之上，LittleFS 管 Flash 分区，FlashDB 把数据库当做文件存在上面。</p>
<h2>总结</h2>
<p>FlashDB 为嵌入式设备提供了一套开箱即用的数据库方案。KVDB 解决键值存储需求，TSDB 解决时序数据记录需求，二者共享同一套底层 Flash 管理机制。移植过程的核心在 FAL 层——只要实现 <code>init/read/write/erase</code> 四个 Flash 操作函数并配置好分区表，即可在裸机或 RTOS 环境下运行。对于没有 Flash 硬件的开发阶段，文件模式可以无缝切换到 PC 端进行调试。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="FlashDB"/>
    <category term="嵌入式"/>
    <category term="数据库"/>
    <category term="KVDB"/>
    <category term="TSDB"/>
    <category term="Flash存储"/>
    <category term="FAL"/>
  </entry>
  <entry>
    <title>一个面向 BLE 芯片的 FlashDB 持久化配置管理台</title>
    <link href="https://createskyblue.github.io/posts/flashdb-tinyframe-ble-debugger/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/flashdb-tinyframe-ble-debugger/</id>
    <published>2026-06-17T00:00:00.000Z</published>
    <updated>2026-06-17T00:00:00.000Z</updated>
    <summary>一个面向 BLE 芯片的 FlashDB 持久化Web配置控制台</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>一个面向 BLE 芯片的 FlashDB 持久化配置管理台</h1>
<div style="margin: 18px 0; padding: 18px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
  <div style="font-weight: 700; color: #1e293b; margin-bottom: 14px;">交付架构</div>
  <div style="display: grid; grid-template-columns: repeat(5, minmax(128px, 1fr)); gap: 10px; align-items: stretch; font-size: 13px; line-height: 1.45; color: #334155;">
    <div style="padding: 12px; background: #ffffff; border: 1px solid #cbd5e1; border-radius: 8px;">
      <div style="font-weight: 700; color: #1e293b;">Web 管理台</div>
      <div>表格化管理配置项，显示串口和协议日志</div>
    </div>
    <div style="display: grid; place-items: center; color: #64748b; font-weight: 700;">Web Bluetooth</div>
    <div style="padding: 12px; background: #dbeafe; border: 1px solid #bfdbfe; border-radius: 8px;">
      <div style="font-weight: 700; color: #1d4ed8;">TinyFrame</div>
      <div>提供统一帧格式和请求响应通道</div>
    </div>
    <div style="padding: 12px; background: #dcfce7; border: 1px solid #bbf7d0; border-radius: 8px;">
      <div style="font-weight: 700; color: #166534;">KV 应用层</div>
      <div>校验配置名、数据长度和操作类型</div>
    </div>
    <div style="padding: 12px; background: #fef9c3; border: 1px solid #fde68a; border-radius: 8px;">
      <div style="font-weight: 700; color: #854d0e;">FlashDB</div>
      <div>在芯片 Data-Flash 中保存配置数据</div>
    </div>
  </div>
</div>

<p>这个交付物给 BLE 芯片增加了一套持久化配置管理能力。设备端使用 FlashDB 保存配置数据；BLE 通道使用 TinyFrame 提供稳定的访问接口；浏览器端通过 Web Bluetooth 做成管理台，可以查看、添加、修改和删除配置项。</p>
<p>界面效果如下。上方是配置管理表格，下方是固定的串口与协议日志面板。</p>
<p><img src="/posts/flashdb-tinyframe-ble-debugger/flashdb-debugger.webp" alt="FlashDB 配置管理台"></p>
<h2>交付内容</h2>
<p>这次交付包含三部分。</p>
<p>第一部分是设备端持久化存储。FlashDB 被移植到 CH58x 工程中，使用 CH583 Data-Flash 的前 16KB 作为业务配置区。WCH BLE 协议栈默认使用的 SNV 区域仍保留在 Data-Flash 尾部，避免应用配置和协议栈内部数据互相覆盖。</p>
<p>第二部分是 BLE 访问接口。配置读写通过一个独立的 KV 应用层统一处理，再由 TinyFrame 通过 BLE UART 传输。这样浏览器端看到的是稳定的配置访问接口，设备端仍保留清晰的存储边界。</p>
<p>第三部分是 Web 管理台。它通过 Web Bluetooth 连接设备，通过 Web Serial 观察串口日志，用表格管理设备里的配置项。</p>
<h2>KV 是什么</h2>
<p>KV 是 key-value 的缩写，也就是“键值对”。</p>
<p>可以把它理解成一个小型配置表。每一行都有一个名字和一份数据：</p>
<ul>
<li>key：配置名，例如 <code>cfg/demo</code>、<code>cfg/Byte</code>。</li>
<li>value：配置值，可以是一段字符串，也可以是一段字节数据。</li>
</ul>
<p>设备程序可以用 key 查到对应 value。比如一个设备可以把采样间隔、开关状态、校准参数、功能标志位都放进这个表里。断电后这些配置仍然保存在 Flash 中，下次启动时继续读取。</p>
<p>FlashDB 提供的就是这种嵌入式 KV 数据库能力。它把底层 Flash 的擦写限制封装起来，让业务代码按 key-value 的方式保存配置。</p>
<h2>总体架构</h2>
<p>这套管理能力分成五层。</p>
<p>Web 管理台运行在浏览器里。它负责展示配置表格，并把用户操作转成读、写、列出配置名、删除配置这几类请求。</p>
<p>Web Bluetooth 负责连接 BLE 设备。浏览器把 TinyFrame 字节流写入 BLE UART 特征值，也从 Notify 中接收设备返回的数据。</p>
<p>TinyFrame 负责帧格式。BLE UART 只是字节流，TinyFrame 提供帧头、长度、类型、校验和请求响应边界，避免上层直接处理分包和粘包。</p>
<p>KV 应用层负责业务校验。它检查 key 是否为空、长度是否超限、字符是否允许、value 是否超长，再把合法请求交给 FlashDB。</p>
<p>FlashDB 负责持久化。它最终把配置写入芯片 Data-Flash，并支持读取、写入、遍历和删除。</p>
<h2>设备端能力</h2>
<p>设备端提供一个统一的 FlashDB KV 命令。命令类型只有一个，具体动作放在 payload 内部区分。</p>
<p>当前支持四类动作：</p>
<ul>
<li>读取：根据 key 读取 value。</li>
<li>写入：把 key 和 value 保存到 FlashDB。</li>
<li>列表：返回当前已保存的一批 key。</li>
<li>删除：删除指定 key。</li>
</ul>
<p>key 的长度限制为 31 字节，只允许字母、数字、下划线、短横线、点和斜杠。value 单次最大 128 字节。这个限制让 BLE 传输、FlashDB 存储和前端显示都保持在比较容易控制的范围内。</p>
<p>当前 value 类型只用于前端显示。FlashDB 内部保存的是原始字节，同一个 key 只有一份 value。后续如果需要设备端记住字符串、字节数组等真实类型，可以把类型字段一起封装进 value 再保存。</p>
<h2>Web 管理台</h2>
<p>Web 管理台把设备里的配置项显示成表格。每一行对应一个 key，列包括类型、数值和操作按钮。</p>
<p>已经提供的操作包括：</p>
<ul>
<li>添加行：在前端新增一条待写入配置。</li>
<li>刷新列表：从设备读取当前保存的 key 列表。</li>
<li>刷新：读取单行 key 的 value。</li>
<li>写入：把单行 key 和 value 写回设备。</li>
<li>删除：删除单行 key。</li>
</ul>
<p>底部固定的“串口与日志”面板用于观察运行过程。面板不会随着上方功能页滚动消失，并且可以拖拽调整高度。日志里同时显示 TinyFrame 的发送、接收、Notify 分片和设备串口输出，便于定位问题发生在前端、BLE 传输还是设备端。</p>
<h2>适用范围</h2>
<p>这套管理台适合固件开发和内部调试阶段使用。</p>
<p>它解决的是“BLE 芯片里有持久化配置后，开发者如何通过蓝牙直接管理这些配置”。它不替代正式产品 App。正式产品还需要权限控制、配置版本迁移、误删保护、用户交互和更完整的异常处理。</p>
<p>当前交付已经覆盖配置管理的基本闭环：设备能保存，浏览器能连接，前端能列出配置，单行能读取、写入和删除，日志能同步观察通信过程。</p>
<p>后续可以继续补两项能力：一是把 value 类型持久化，让设备端返回真实类型；二是给 key 列表增加分页，避免配置项很多时单次 BLE 响应装不下。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="FlashDB"/>
    <category term="TinyFrame"/>
    <category term="BLE"/>
    <category term="嵌入式"/>
  </entry>
  <entry>
    <title>Audio-Triangulation 用三麦克风做低成本声学相机</title>
    <link href="https://createskyblue.github.io/posts/audio-triangulation-tdoa/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/audio-triangulation-tdoa/</id>
    <published>2026-06-16T00:00:00.000Z</published>
    <updated>2026-06-16T00:00:00.000Z</updated>
    <summary>三麦克风TDOA声源定位</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Audio-Triangulation 用三麦克风做低成本声学相机</h1>
<p>原始项目仓库：<a href="https://github.com/Sam-Belliveau/Audio-Triangulation">https://github.com/Sam-Belliveau/Audio-Triangulation</a></p>
<p>Audio-Triangulation 是一个基于 Raspberry Pi Pico 的声音定位项目。项目介绍页把它称为 20 美元级声学相机：三个麦克风监听声音，Pico 在片上计算声音到达时间差，再把声源可能位置画成 VGA 热图。</p>
<p><img src="/posts/audio-triangulation-tdoa/final_board.webp" alt="三麦克风、低通滤波、Pico 和 VGA 的硬件结构"></p>
<p>这篇项目最值得学习的地方，是它把“声音从哪里来”这个问题拆成了三个层次：先用 TDOA 得到距离差，再用互相关估计时间差，最后用网格评分避开难解的非线性方程。</p>
<h2>被动声源定位拿不到绝对距离</h2>
<p>声音定位容易让人先想到“画几个圆相交”。如果知道声音从什么时候发出，就可以用到达时间乘以声速，得到声源到每个麦克风的绝对距离。二维里是圆，三维里是球面，几个圆或球面的交点就是声源位置。</p>
<p>被动监听场景没有这个条件。人说话、物体敲击、环境里突然响了一声，系统通常不知道发声时刻。麦克风只能比较同一段声音先到谁、后到谁。</p>
<p>这时能得到的是相对信息：</p>
<ul>
<li>声音到 A 和 B 的时间差。</li>
<li>时间差乘以声速得到距离差。</li>
<li>距离差只能说明声源到两个麦克风的距离相差多少。</li>
</ul>
<p>距离差不能直接变成两个确定半径的圆。假设声源到 A 的距离是 10cm，B 的距离可能是 15cm；假设 A 是 50cm，B 就可能是 55cm。距离差相同，绝对距离可以有很多组。</p>
<p>所以被动声音定位不能按“已知半径的圆相交”来理解。它的核心约束是“到两个麦克风的距离差固定”。</p>
<h2>TDOA 对应的是双曲线</h2>
<p>平面上到两个定点距离差固定的点，会形成双曲线。两个麦克风就是双曲线的两个焦点。声音到达 A、B 的时间差越大，说明声源越偏向其中一侧；时间差接近 0，说明声源大致在两者的垂直平分线附近。</p>
<p>三个麦克风可以形成三对约束：</p>
<ul>
<li>A-B 约束一条双曲线。</li>
<li>A-C 约束一条双曲线。</li>
<li>B-C 约束一条双曲线。</li>
</ul>
<p>理想情况下，三组双曲线共同指向同一个声源位置。Audio-Triangulation 的数学核心就是这个：它使用 TDOA，把未知发声时刻的问题转化为相对时间差问题。</p>
<p>另一个现实限制是：时间差是离散采样得到的。项目采样率为 50kHz，一个采样点是 20 微秒。声速按 343m/s 计算，一个采样点对应约 6.86mm 的传播距离。延迟估计只能落在整数采样点上，因此声源位置也会被量化成有限的可区分区域。</p>
<h2>远处声源会让双曲线变密</h2>
<p>麦克风阵列越小，远处声源越难定位。原因是远处声源移动一小段距离时，到三个麦克风的距离差变化很小，几组双曲线会挤在一起。</p>
<p>这张图的价值在于提醒：定位精度同时受算法、采样率、麦克风间距、声源距离和空间几何限制。</p>
<p>提升分辨率有两条常见路径：</p>
<ul>
<li>提高采样率，让时间差量化更细。</li>
<li>增大麦克风间距，让同一方向变化产生更大的时间差。</li>
</ul>
<p>两条路都有代价。提高采样率会增加数据处理压力；增大间距会让设备变大，也会放大布线、同步和环境误差。这个项目选择 50kHz 和三麦克风面包板阵列，是一个低成本实时演示的平衡点。</p>
<h2>互相关从波形里找时间差</h2>
<p>有了 TDOA 思路，还需要从三路麦克风波形里估计“到底差了几个采样点”。Audio-Triangulation 用互相关完成这一步。</p>
<p>互相关的直觉是滑动对齐。拿 A、B 两段波形，把 B 向前或向后移动若干采样点，每移动一次就逐点相乘并求和。某个偏移下得分最高，说明两段波形在这个偏移下最相似，这个偏移就是时间差估计。</p>
<div style="margin: 18px 0; padding: 16px; border: 1px solid #dbe3ef; border-radius: 8px; background: #f8fafc; color: #1e293b;">
  <div style="font-weight: 700; margin-bottom: 8px;">互相关可以理解成“固定 A，把延后的 B 往回滑”</div>
  <div style="font-size: 13px; color: #475569; line-height: 1.65; margin-bottom: 12px;">
    下面假设 B 比 A 晚到 2 个采样点。A 的脉冲固定在第 3-5 格，B 原始脉冲在第 5-7 格；只有把 B 左移 2 格，两个脉冲才完全重合。
  </div>
  <div style="display: grid; gap: 14px;">
    <div style="display: grid; grid-template-columns: 96px 1fr 96px; gap: 10px; align-items: center;">
      <div style="font-size: 13px; color: #475569;">B 不移动</div>
      <div style="display: grid; gap: 4px;">
        <div style="display: grid; grid-template-columns: 22px repeat(9, 1fr); gap: 4px;">
          <div style="font-size: 12px; color: #2563eb;">A</div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #60a5fa; border-radius: 4px;"></div>
          <div style="height: 20px; background: #60a5fa; border-radius: 4px;"></div>
          <div style="height: 20px; background: #60a5fa; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
        </div>
        <div style="display: grid; grid-template-columns: 22px repeat(9, 1fr); gap: 4px;">
          <div style="font-size: 12px; color: #16a34a;">B</div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #22c55e; border-radius: 4px;"></div>
          <div style="height: 20px; background: #22c55e; border-radius: 4px;"></div>
          <div style="height: 20px; background: #22c55e; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
        </div>
      </div>
      <div style="font-size: 13px; color: #64748b;">只重合 1 格</div>
    </div>
    <div style="display: grid; grid-template-columns: 96px 1fr 96px; gap: 10px; align-items: center;">
      <div style="font-size: 13px; color: #475569;">B 左移 1 格</div>
      <div style="display: grid; gap: 4px;">
        <div style="display: grid; grid-template-columns: 22px repeat(9, 1fr); gap: 4px;">
          <div style="font-size: 12px; color: #2563eb;">A</div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #60a5fa; border-radius: 4px;"></div>
          <div style="height: 20px; background: #60a5fa; border-radius: 4px;"></div>
          <div style="height: 20px; background: #60a5fa; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
        </div>
        <div style="display: grid; grid-template-columns: 22px repeat(9, 1fr); gap: 4px;">
          <div style="font-size: 12px; color: #16a34a;">B</div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #22c55e; border-radius: 4px;"></div>
          <div style="height: 20px; background: #22c55e; border-radius: 4px;"></div>
          <div style="height: 20px; background: #22c55e; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
        </div>
      </div>
      <div style="font-size: 13px; color: #64748b;">重合 2 格</div>
    </div>
    <div style="display: grid; grid-template-columns: 96px 1fr 96px; gap: 10px; align-items: center;">
      <div style="font-size: 13px; color: #475569;">B 左移 2 格</div>
      <div style="display: grid; gap: 4px; background: #fef9c3; border-radius: 6px; padding: 6px;">
        <div style="display: grid; grid-template-columns: 22px repeat(9, 1fr); gap: 4px;">
          <div style="font-size: 12px; color: #2563eb;">A</div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #60a5fa; border-radius: 4px;"></div>
          <div style="height: 20px; background: #60a5fa; border-radius: 4px;"></div>
          <div style="height: 20px; background: #60a5fa; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
        </div>
        <div style="display: grid; grid-template-columns: 22px repeat(9, 1fr); gap: 4px;">
          <div style="font-size: 12px; color: #16a34a;">B</div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #22c55e; border-radius: 4px;"></div>
          <div style="height: 20px; background: #22c55e; border-radius: 4px;"></div>
          <div style="height: 20px; background: #22c55e; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
          <div style="height: 20px; background: #e2e8f0; border-radius: 4px;"></div>
        </div>
      </div>
      <div style="font-size: 13px; color: #15803d; font-weight: 700;">完全重合</div>
    </div>
  </div>
  <div style="margin-top: 12px; font-size: 13px; color: #475569; line-height: 1.65;">
    互相关就是把这些“左移几格”的情况全部试一遍。左移 2 格时 A、B 的脉冲完全重合，逐点相乘后的总和最大，所以时间差就是 2 个采样点。
  </div>
</div>

<p>项目源码会遍历 <code>-MAX_SHIFT_SAMPLES</code> 到 <code>+MAX_SHIFT_SAMPLES</code> 的整数偏移，分别计算 A-B、A-C、B-C 三对互相关。每一对都会得到一条“偏移量到相似度”的曲线，而不仅仅是一个延迟数字。</p>
<p>这很重要，因为后面的热图评分会直接使用整条互相关曲线。某个候选位置预测出的延迟，如果刚好落在互相关峰值附近，它的得分就高。</p>
<h2>为什么没有用 FFT</h2>
<p>介绍页提到他们考虑过 FFT 相关方法。理论上，卷积和相关可以通过频域乘法加速，复杂度从平方级降到 <code>N log N</code>。</p>
<p>但这个项目的缓冲区规模是 1024 点，实际收益不明显。更麻烦的是，FFT 方法会把滤波相位、三路采样时序和频域处理细节引进来，调试难度上升。</p>
<p>最终项目选择直接在时域做互相关。这个选择看起来朴素，但适合 RP2040：整数累加容易实现，计算范围受物理最大延迟限制，结果也更容易和波形显示对应起来。</p>
<h2>位置求解反过来做</h2>
<p>理论上可以写出双曲线方程组，然后求交点。但实际系统里会遇到几个麻烦：</p>
<ul>
<li>双曲线有两支，可能出现多个候选位置。</li>
<li>测量误差会让几条曲线无法精确相交。</li>
<li>牛顿法一类解析求解依赖初值，可能不收敛。</li>
<li>嵌入式系统需要可预测的运行时间。</li>
</ul>
<p>Audio-Triangulation 的做法很工程化：避开直接求交点，反过来枚举候选位置。</p>
<p>初始化 VGA 热图时，它会遍历显示区域里的每个点。对每个候选点，先计算它到三个麦克风的距离，再换算成 A-B、A-C、B-C 应该出现的样本延迟，并把这些延迟索引存起来。</p>
<p>实时显示时，它拿当前互相关结果给每个候选点打分：</p>
<p><code>候选点得分 = A-B 在理论延迟处的互相关值 + A-C 在理论延迟处的互相关值 + B-C 在理论延迟处的互相关值</code></p>
<div style="margin: 18px 0; padding: 16px; border: 1px solid #dbe3ef; border-radius: 8px; background: #f8fafc; color: #1e293b;">
  <div style="font-weight: 700; margin-bottom: 12px;">位置求解被改成“每个候选点查三张延迟得分表”</div>
  <div style="display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr); gap: 14px; align-items: stretch;">
    <div style="padding: 12px; border: 1px solid #cbd5e1; border-radius: 8px; background: #ffffff;">
      <div style="font-size: 12px; color: #475569; margin-bottom: 8px;">候选位置网格：逐格计算 P 点理论延迟</div>
      <div style="display: grid; grid-template-columns: repeat(5, minmax(30px, 1fr)); gap: 6px;">
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #dcfce7; border: 2px solid #22c55e; border-radius: 50%; display: grid; place-items: center; font-weight: 700;">C</div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #fef9c3; border: 2px solid #eab308; border-radius: 7px; display: grid; place-items: center; font-weight: 700;">P</div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #fff7ed; border: 1px dashed #f59e0b; border-radius: 6px; display: grid; place-items: center; font-size: 12px; color: #92400e;">算分</div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #fee2e2; border: 2px solid #ef4444; border-radius: 50%; display: grid; place-items: center; font-weight: 700;">A</div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #dbeafe; border: 2px solid #3b82f6; border-radius: 50%; display: grid; place-items: center; font-weight: 700;">B</div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
        <div style="aspect-ratio: 1; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;"></div>
      </div>
    </div>
    <div style="display: grid; gap: 10px; align-content: center;">
      <div style="padding: 10px; border-radius: 8px; background: #ffffff; border: 1px solid #e2e8f0;">
        <div style="font-weight: 700; color: #334155;">预计算：P 到三对麦克风的理论延迟</div>
        <div style="margin-top: 8px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; font-size: 13px; text-align: center;">
          <div style="padding: 8px; background: #fee2e2; border-radius: 6px;">A-B<br><strong>+2</strong></div>
          <div style="padding: 8px; background: #dcfce7; border-radius: 6px;">A-C<br><strong>-1</strong></div>
          <div style="padding: 8px; background: #dbeafe; border-radius: 6px;">B-C<br><strong>-3</strong></div>
        </div>
      </div>
      <div style="padding: 10px; border-radius: 8px; background: #ffffff; border: 1px solid #e2e8f0;">
        <div style="font-weight: 700; color: #334155;">实时：到互相关曲线里取对应分数</div>
        <div style="margin-top: 8px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; font-size: 13px; text-align: center;">
          <div style="padding: 8px; background: #fff7ed; border-radius: 6px;">R<sub>AB</sub>[+2]<br><strong>91</strong></div>
          <div style="padding: 8px; background: #f0fdf4; border-radius: 6px;">R<sub>AC</sub>[-1]<br><strong>84</strong></div>
          <div style="padding: 8px; background: #eff6ff; border-radius: 6px;">R<sub>BC</sub>[-3]<br><strong>88</strong></div>
        </div>
      </div>
      <div style="padding: 10px; border-radius: 8px; background: #fef9c3; border: 1px solid #fde68a; font-weight: 700; text-align: center;">
        P 点总分 = 91 + 84 + 88 = 263
      </div>
    </div>
  </div>
  <div style="margin-top: 12px; font-size: 13px; color: #475569; line-height: 1.65;">
    热图里的每个格子都按同样方式算一次。总分最高的区域，表示这个位置预测出的三组时间差最接近当前麦克风实际测到的互相关峰值。
  </div>
</div>

<p>分数越高，说明这个点预测出的三组延迟越接近当前波形测到的延迟。热图显示的就是这个匹配度。</p>
<p><img src="/posts/audio-triangulation-tdoa/control_flow_diagram.webp" alt="Pico 上的采样、触发、互相关和显示流程"></p>
<p>这个设计的好处是稳定。每个网格点独立评分，计算量由网格大小决定，不需要初值，不存在收敛失败。代价是分辨率受网格大小限制，但对实时热图来说，这个取舍很合理。</p>
<h2>硬件链路服务于实时性</h2>
<p>硬件部分使用三个 MAX4466 麦克风，摆在面包板上形成三角阵列。每路麦克风输出经过二阶 20kHz 低通滤波，再接到 Pico 的 ADC 0、1、2 通道。</p>
<p>采样侧使用 ADC round-robin 和 DMA。DMA 把三路 8 位样本持续搬到一个小数组里，计算循环按 50kHz 节奏读出，转换成 16 位样本并推入三路滚动缓冲区。</p>
<p>声音事件由能量变化触发。系统维护滚动缓冲区的新旧两段能量，检测到明显声音事件后，再取出完整帧做互相关和热图更新。这能减少安静时的无效计算。</p>
<p>VGA 输出也由 Pico 完成。项目用 PIO 和 DMA 生成 VGA 信号，让同一块 RP2040 同时负责采样、定位和显示。</p>
<p><img src="/posts/audio-triangulation-tdoa/cover.webp" alt="VGA 上的波形、延迟和热图界面"></p>
<h2>这篇项目的真正启发</h2>
<p>Audio-Triangulation 的精彩点在于它把数学问题压成了适合 MCU 的流程：</p>
<ul>
<li>被动监听拿不到发声时刻，所以使用 TDOA。</li>
<li>TDOA 产生的是双曲线约束，所以不能套用绝对距离的圆相交。</li>
<li>互相关把波形对齐问题变成整数偏移评分。</li>
<li>网格评分把双曲线求交变成候选点查表。</li>
<li>预计算让实时阶段只做数组取值、加法和颜色映射。</li>
</ul>
<p>这个项目也很适合提醒自己：信号处理要同时看公式、采样、阵列和环境。采样率决定时间分辨率，阵列尺寸决定空间分辨率，环境噪声和混响决定互相关峰值是否可靠。算法、硬件和可视化必须一起设计。</p>
<h2>边界和扩展</h2>
<p>当前实现主要把声源映射到一个预设平面。声源高度变化较大时，二维热图会把三维位置投影到该平面，结果会带系统偏差。</p>
<p>如果要做完整三维定位，可以增加搜索维度，或者使用非共面的麦克风阵列。网格评分方法本身可以扩展，但内存、刷新率、显示方式和标定难度都会上升。</p>
<p>实际工程还需要处理麦克风增益差异、几何尺寸误差、混响、多声源和环境噪声。VGA 热图表达的是候选位置匹配度，不能直接当成带置信区间的精确坐标。</p>
<h2>结论</h2>
<p>Audio-Triangulation 把 TDOA 声源定位做成了一个完整的嵌入式演示。三路麦克风提供相对时间差，互相关从波形里估计延迟，网格评分把延迟变成位置热图，VGA 让整个过程可以直接观察。</p>
<p>它的核心是一组非常清楚的工程转化：绝对距离不可得，就用距离差；双曲线难求解，就用网格评分；实时算力有限，就把候选点延迟提前算好。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="声音定位"/>
    <category term="TDOA"/>
    <category term="RP2040"/>
    <category term="信号处理"/>
  </entry>
  <entry>
    <title>BLE 调试工具读取 ADC 波形时要同时处理确认和前端性能</title>
    <link href="https://createskyblue.github.io/posts/ble-adc-waveform-tinyframe/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ble-adc-waveform-tinyframe/</id>
    <published>2026-06-16T00:00:00.000Z</published>
    <updated>2026-06-16T00:00:00.000Z</updated>
    <summary>BLE透传+TinyFrame协议包装+Web上位机实现命令控制和波形传输</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>BLE 调试工具读取 ADC 波形时要同时处理确认和前端性能</h1>
<p>一个 BLE 波形采集项目需要在浏览器调试工具里查看 ADC 原始波形。这个功能看起来只是“从 MCU 取一批样本，再画到前端 canvas 上”，实际要处理两个边界：BLE 大帧分片可能中断，样本被读出后也不能提前从设备 FIFO 删除。</p>
<p><img src="/posts/ble-adc-waveform-tinyframe/PixPin_2026-06-16_23-16-25.gif" alt="ADC 波形自动读取运行效果"></p>
<div style="margin: 18px 0; padding: 16px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
  <div style="font-weight: 700; color: #1e293b; margin-bottom: 12px;">排查过程按目标逐层推进</div>
  <div style="display: grid; grid-template-columns: repeat(6, minmax(108px, 1fr)); gap: 8px; align-items: stretch; font-size: 13px; line-height: 1.45; color: #334155;">
    <div style="padding: 10px; background: #fee2e2; border-radius: 6px; border: 1px solid #fecaca;">读取即删除<br><strong>断链会丢样本</strong></div>
    <div style="padding: 10px; background: #fef9c3; border-radius: 6px; border: 1px solid #fde68a;">拆成 GET / ACK<br><strong>先保住数据</strong></div>
    <div style="padding: 10px; background: #ffedd5; border-radius: 6px; border: 1px solid #fed7aa;">两条异步链路<br><strong>前端状态变复杂</strong></div>
    <div style="padding: 10px; background: #dcfce7; border-radius: 6px; border: 1px solid #bbf7d0;">下次 GET 顺带 ACK<br><strong>收敛交互</strong></div>
    <div style="padding: 10px; background: #dbeafe; border-radius: 6px; border: 1px solid #bfdbfe;">长时间运行变慢<br><strong>浏览器成瓶颈</strong></div>
    <div style="padding: 10px; background: #e0f2fe; border-radius: 6px; border: 1px solid #bae6fd;">限制日志 + 间隔补偿<br><strong>稳住消费节奏</strong></div>
  </div>
</div>

<h2>采样模块先独立出来</h2>
<p>ADC 采样最初和本地计算任务绑在一起。本地算法为了测速或省电被关闭时，ADC 采样也会停掉，前端自然读不到波形。</p>
<p>这次先把 ADC 采样拆成单独模块：定时器固定触发 ADC0 采样，原始 <code>uint16_t</code> 样本写入 FIFO。BLE 调试工具只消费这个 FIFO，不依赖本地计算任务是否启用。</p>
<p>拆分后，ADC 采样、上层处理任务、BLE 调试指令变成三个层次。采样层只保证数据进入 FIFO，应用层再决定如何读取和确认。</p>
<h2>大 TinyFrame 要排队发送</h2>
<p>一次读取 120 个 ADC 样本时，TinyFrame 响应已经明显大于单个 BLE ATT Notify 的有效载荷。MTU 仍是 23 时，一个应用响应会被拆成多个 20 字节左右的 Notify 分片。</p>
<p>最初直接在 TinyFrame 发送出口连续发送所有分片。小包可以通过，大包会出现浏览器 CRC 错误和等待超时。日志看起来像 TinyFrame 校验失败，实际是后半帧没有稳定发完。</p>
<p>修正方式是在协议层增加 TinyFrame Notify 发送队列。TinyFrame 生成完整帧后先进入发送缓冲，主循环和连接事件回调按 MTU 分片推进；协议栈忙时保留 offset，下次继续发送。浏览器仍然按一帧应用响应解析，底层分片由队列保证补齐。</p>
<h2>读取后立即删除会丢样本</h2>
<p>发送队列只能保证设备侧尽量把完整帧发出去，不能证明浏览器已经解析成功。ADC FIFO 如果在组响应时直接推进读指针，后续只要发生断链、超时、前端刷新或 ACK 丢失，这批样本就已经从设备侧消失。</p>
<p>这对波形调试也不能接受。FIFO 里保存的是采样时间序列，批量读取只是一次传输尝试，不应该等同于删除。</p>
<p>因此 ADC 读取改成待确认语义：读取指令只把当前批次复制到待确认缓存，并返回 <code>batch_id</code>。前端收到完整 TinyFrame、完成 CRC 校验、解析出样本并画到波形图后，再发送 ACK。MCU 只有收到匹配的 <code>batch_id</code>，才推进 FIFO 读指针删除这批样本。</p>
<h2>FIFO 满时也不能覆盖未确认数据</h2>
<p>旧实现为了保持实时波形，FIFO 满时会覆盖最旧样本。这个策略适合“只看最新趋势”的场景，但不适合用户明确要求不可丢包的采集链路。</p>
<p>现在 FIFO 满时保留旧数据，丢弃新来的样本并累计 overflow 计数。这样至少不会把已经进入待确认链路的数据覆盖掉。前端如果消费速度不足，应通过 FIFO 剩余数量和 overflow 计数暴露出来，避免静默删除旧样本。</p>
<h2>前端也要参与确认</h2>
<p>前端自动读取不能只循环发 GET。它必须等待上一批 GET 响应完成，再发送对应 ACK；ACK 返回成功后，才允许进入下一轮读取。</p>
<p>这个流程把责任边界分清楚：</p>
<ul>
<li>MCU 负责采样、缓存、返回待确认批次。</li>
<li>BLE 发送队列负责把 TinyFrame 分片发完整。</li>
<li>浏览器负责校验、绘图，并对已消费批次发送 ACK。</li>
<li>MCU 收到 ACK 后才删除 FIFO 中对应样本。</li>
</ul>
<h2>GET 和 ACK 分开后状态会变复杂</h2>
<p>把读取和确认拆成两个指令后，设备侧的数据安全边界清楚了，但前端状态机变复杂了。一次完整消费要经历 GET、接收大 TinyFrame、解析、绘图、发送 ACK、等待 ACK 响应这几个步骤。任何一步被浏览器后台节流、BLE 写入失败、页面连点或旧响应延迟打断，前端都可能和设备侧状态错开。</p>
<p>实际调试时可以复现这个问题：手动连续点击几次读取 ADC，再打开自动读取，FIFO 剩余数量会长期降不下来。原因并不在 FIFO 本身。前端同时启动了多个 ADC 读取流程，等待响应对象会被后一次操作覆盖；旧 GET 或旧 ACK 响应回来后，又可能被新的等待者接收。页面刷新后状态清空，所以现象会临时恢复。</p>
<p>这个阶段暴露出另一个设计边界：ACK 单独成帧虽然语义直接，但它让浏览器必须维护两条相关的异步链路。GET 和 ACK 的先后顺序、帧 ID、超时清理、断线恢复都要处理。对一个持续采集工具来说，这个复杂度会直接影响稳定性。</p>
<div style="margin: 18px 0; padding: 16px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px;">
  <div style="font-weight: 700; color: #1e293b; margin-bottom: 10px;">GET 和 ACK 分成两条链路时，前端要同时维护两个等待状态</div>
  <svg viewBox="0 0 760 260" role="img" aria-label="GET 和 ACK 分离时序图" style="width: 100%; height: auto; display: block;">
    <defs>
      <marker id="arrow-split" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
        <path d="M0,0 L8,4 L0,8 Z" fill="#64748b"></path>
      </marker>
    </defs>
    <rect x="0" y="0" width="760" height="260" fill="#f8fafc" rx="8"></rect>
    <text x="110" y="34" text-anchor="middle" fill="#1e293b" font-size="14" font-weight="700">浏览器前端</text>
    <text x="380" y="34" text-anchor="middle" fill="#1e293b" font-size="14" font-weight="700">BLE / TinyFrame</text>
    <text x="650" y="34" text-anchor="middle" fill="#1e293b" font-size="14" font-weight="700">MCU FIFO</text>
    <line x1="110" y1="48" x2="110" y2="230" stroke="#cbd5e1" stroke-width="2"></line>
    <line x1="380" y1="48" x2="380" y2="230" stroke="#cbd5e1" stroke-width="2"></line>
    <line x1="650" y1="48" x2="650" y2="230" stroke="#cbd5e1" stroke-width="2"></line>
    <line x1="120" y1="76" x2="640" y2="76" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-split)"></line>
    <text x="380" y="68" text-anchor="middle" fill="#334155" font-size="13">GET：读取一批样本</text>
    <line x1="640" y1="116" x2="120" y2="116" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-split)"></line>
    <text x="380" y="108" text-anchor="middle" fill="#334155" font-size="13">返回 batch_id + samples</text>
    <rect x="38" y="132" width="144" height="40" rx="6" fill="#dbeafe" stroke="#bfdbfe"></rect>
    <text x="110" y="157" text-anchor="middle" fill="#1e40af" font-size="13">绘图后等待 ACK</text>
    <line x1="120" y1="190" x2="640" y2="190" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-split)"></line>
    <text x="380" y="182" text-anchor="middle" fill="#334155" font-size="13">ACK：确认删除上一批</text>
    <line x1="640" y1="224" x2="120" y2="224" stroke="#64748b" stroke-width="2" marker-end="url(#arrow-split)"></line>
    <text x="380" y="216" text-anchor="middle" fill="#334155" font-size="13">ACK 响应</text>
  </svg>
</div>

<h2>下一次读取顺带确认上一批</h2>
<p>后续把 ADC 读取流程收敛成单条主链路。前端收到一批 ADC 样本并成功绘图后，先记录这批 <code>batch_id</code>。下一次发送 GET 时，把上一次已经消费的 <code>batch_id</code> 一起带给设备。</p>
<p>设备收到新的 GET 后，先尝试确认上一批，再返回当前待确认批次或新批次。这样前端的主循环只需要维护“发起读取、等待对应帧 ID 的响应、绘图、记录下一次要确认的 batch_id”。确认动作跟下一次读取绑定在一起，减少一次 BLE 往返，也减少一个容易和 GET 交叉的异步状态。</p>
<p>这个方案还要做成可恢复语义。浏览器可能没有收到上一轮响应，但设备可能已经处理过其中的确认动作。下一次 GET 再带旧 <code>batch_id</code> 时，设备不能让链路停在错误状态，而应继续返回当前待确认批次，让前端重新对齐到设备侧状态。</p>
<p>前端侧也同步收紧状态机：</p>
<ul>
<li>ADC 读取过程加互斥，手动连点时忽略重复触发。</li>
<li>ADC 响应按 TinyFrame 帧 ID 匹配，过期响应直接丢弃。</li>
<li>发送失败时立即清理 pending 状态，避免必须刷新页面。</li>
<li>页面进入后台时暂停自动读取，回到前台后再补一轮读取。</li>
</ul>
<div style="margin: 18px 0; padding: 16px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px;">
  <div style="font-weight: 700; color: #1e293b; margin-bottom: 10px;">合并后只有一条主链路：下一次 GET 顺带确认上一批</div>
  <svg viewBox="0 0 760 250" role="img" aria-label="GET 携带上一批确认的时序图" style="width: 100%; height: auto; display: block;">
    <defs>
      <marker id="arrow-merge" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
        <path d="M0,0 L8,4 L0,8 Z" fill="#0f766e"></path>
      </marker>
    </defs>
    <rect x="0" y="0" width="760" height="250" fill="#f8fafc" rx="8"></rect>
    <text x="110" y="34" text-anchor="middle" fill="#1e293b" font-size="14" font-weight="700">浏览器前端</text>
    <text x="380" y="34" text-anchor="middle" fill="#1e293b" font-size="14" font-weight="700">BLE / TinyFrame</text>
    <text x="650" y="34" text-anchor="middle" fill="#1e293b" font-size="14" font-weight="700">MCU FIFO</text>
    <line x1="110" y1="48" x2="110" y2="225" stroke="#cbd5e1" stroke-width="2"></line>
    <line x1="380" y1="48" x2="380" y2="225" stroke="#cbd5e1" stroke-width="2"></line>
    <line x1="650" y1="48" x2="650" y2="225" stroke="#cbd5e1" stroke-width="2"></line>
    <rect x="38" y="62" width="144" height="40" rx="6" fill="#dcfce7" stroke="#bbf7d0"></rect>
    <text x="110" y="86" text-anchor="middle" fill="#166534" font-size="13">记录上一批 batch_id</text>
    <line x1="120" y1="122" x2="640" y2="122" stroke="#0f766e" stroke-width="2.5" marker-end="url(#arrow-merge)"></line>
    <text x="380" y="114" text-anchor="middle" fill="#0f766e" font-size="13">GET：携带上一批 ACK + 请求下一批</text>
    <rect x="578" y="140" width="144" height="42" rx="6" fill="#fef9c3" stroke="#fde68a"></rect>
    <text x="650" y="157" text-anchor="middle" fill="#854d0e" font-size="13">先删除已确认批次</text>
    <text x="650" y="175" text-anchor="middle" fill="#854d0e" font-size="13">再准备当前批次</text>
    <line x1="640" y1="205" x2="120" y2="205" stroke="#0f766e" stroke-width="2.5" marker-end="url(#arrow-merge)"></line>
    <text x="380" y="197" text-anchor="middle" fill="#0f766e" font-size="13">返回新的 batch_id + samples</text>
  </svg>
</div>

<p>这张状态转移图来自手动连续读取 ADC 的三次前端截图。第一次读取拿到一个待确认批次；第二次读取会带上上一批 <code>batch_id</code>，设备先确认上一批，再返回下一批；前端继续记录新的待确认批次。</p>
<p><img src="/posts/ble-adc-waveform-tinyframe/ack-state-transition.webp" alt="ACK 状态转移"></p>
<h2>长时间运行后前端也会拖慢采集</h2>
<p>合并 ACK 后，短时间自动读取可以把 FIFO 剩余数量从高位追到 0。但继续长时间运行后，又出现了新的现象：页面没有切到后台，FIFO 仍会慢慢升高。这个现象说明 BLE 链路和设备侧一开始是够用的，后面变慢更可能发生在浏览器前端。</p>
<p>排查后发现，调试页面本身也在消耗采集能力。每批 ADC 数据都会产生 TX、Notify、RX、ADC 摘要等多条日志。日志区不断追加 DOM 节点，每次写日志还会滚动到底部。运行时间越久，日志节点越多，浏览器主线程处理日志和布局的开销越大，自动读取的实际间隔就会被拉长。采样端还在持续产出数据，前端消费速度下降后，FIFO 剩余数量自然又会涨回去。</p>
<p>这次处理对象转到前端调试工具的运行成本。第一项改动是限制日志数量：日志只保留固定行数，超过后删除旧节点；ADC 自动读取时减少 TX、Notify、RX 细节输出，只保留低频摘要。这样日志仍能观察状态，也不会无限增长拖慢页面。</p>
<p>第二项改动是给自动读取做间隔补偿。原来用固定定时器触发读取，当前一轮读取、绘图或日志处理变慢时，后续节奏会跟着漂移。现在改成上一轮完成后再安排下一轮，并根据这一轮实际耗时扣减等待时间。目标间隔是 200 ms，如果这一轮已经花了 80 ms，下一轮就等待约 120 ms；如果这一轮已经超时，就尽快进入下一轮。这样可以减少前端主线程抖动对采集节奏的影响。</p>
<p>这个阶段的收获是，通信链路调通以后，瓶颈不一定还在设备或 BLE。浏览器调试工具也属于采集链路的一部分，日志、绘图、DOM 更新都会吃掉时间预算。持续采集类工具要把“可观察性”和“采集性能”分开设计，调试信息不能无限制地占用主线程。</p>
<div style="margin: 18px 0; padding: 16px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
  <div style="font-weight: 700; color: #1e293b; margin-bottom: 12px;">前端变慢的因果链和对应修复</div>
  <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 14px; font-size: 13px; line-height: 1.5; color: #334155;">
    <div style="padding: 14px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px;">
      <div style="font-weight: 700; color: #9a3412; margin-bottom: 8px;">退化链路</div>
      <div>日志持续增长 → DOM 节点变多 → 主线程处理变慢 → 自动读取间隔被拉长 → FIFO 剩余重新升高</div>
    </div>
    <div style="padding: 14px; background: #ecfdf5; border: 1px solid #bbf7d0; border-radius: 8px;">
      <div style="font-weight: 700; color: #166534; margin-bottom: 8px;">修复链路</div>
      <div>限制日志数量 → 降低高频日志 → 自调度读取 → 按实际耗时补偿间隔 → 稳住消费节奏</div>
    </div>
  </div>
</div>

<h2>结论</h2>
<p>BLE 链路带宽足够时，仍然可能因为发送节奏和删除时机造成数据丢失。ADC 波形读取不能在批量读取后立即删除，必须先把读取和删除拆开。</p>
<p>拆开以后还要继续收敛前端状态机。最终结构是：ADC FIFO 保存原始样本，TinyFrame 返回待确认批次，Notify 队列保证大帧分片发送，前端解析成功后记录 <code>batch_id</code>，下一次 GET 顺带确认上一批，MCU 再删除样本。这样才能把“浏览器已经消费”和“设备允许删除”绑定在一起，同时避免 GET 和 ACK 两条异步链路互相干扰。</p>
<p>解决不丢包后，还要继续观察长时间运行。前端日志无限增长和固定定时器漂移也会让消费速度下降。最终处理围绕同一个目标逐层收敛：先保证数据不提前删除，再减少确认链路状态，再降低浏览器前端运行成本。工程调试经常就是这个过程，每解决一层问题，下一层瓶颈才会暴露出来。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="BLE"/>
    <category term="TinyFrame"/>
    <category term="ADC"/>
  </entry>
  <entry>
    <title>Web Bluetooth 测 BLE 吞吐时会卡在默认 MTU</title>
    <link href="https://createskyblue.github.io/posts/ble-throughput-conn-interval-mtu/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ble-throughput-conn-interval-mtu/</id>
    <published>2026-06-16T00:00:00.000Z</published>
    <updated>2026-06-16T00:00:00.000Z</updated>
    <summary>浏览器Web BLE默认MTU 20影响传输速度</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Web Bluetooth 测 BLE 吞吐时会卡在默认 MTU</h1>
<p>一次性电池供电的 BLE 监测设备只在同步数据时短暂连接手机。连接时间越短，蓝牙打开时间越短，平均功耗越低。因此在浏览器调试工具里做了 BLE 吞吐测试，分别测浏览器到 MCU 的上传速度，以及 MCU 通过 Notify 发给浏览器的下载速度。</p>
<p><img src="/posts/ble-throughput-conn-interval-mtu/img/webui%E8%B0%83%E8%AF%95%E7%95%8C%E9%9D%A2_%E5%B8%A6%E6%97%A5%E5%BF%97.webp" alt="Web Bluetooth 调试界面和测速日志"></p>
<p>最开始的结果很低：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>上传速率：4096 B / 5350.5 ms = 765.5 B/s</span></span>
<span class="line"><span>下载速率：4096 B / 6998.7 ms = 585.3 B/s</span></span></code></pre><p>这个速度容易让人怀疑 MCU 忙、协议封包慢、串口日志太多，或者浏览器 JavaScript 处理不过来。后面的排查证明，真正踩到的是 Web Bluetooth 调试环境的边界：浏览器侧没有标准接口显式请求大 MTU，Windows 和 Chrome 没主动协商时，连接会停在默认 MTU 23。</p>
<h2>现象是 MTU 一直只有 23</h2>
<p>设备端打开 Notify 后读取到的 MTU 是 23。ATT Notify 的有效载荷需要扣掉 3 字节 ATT 头，因此每个 Notify 只能放 20 字节数据。</p>
<p>尝试在 MCU 侧主动发起 MTU Exchange，请求 247 字节，结果返回 <code>0x02</code>。在 WCH 的状态码里，<code>0x02</code> 表示 <code>INVALIDPARAMETER</code>。结合 WCH 库说明可以确认：<code>GATT_ExchangeMTU()</code> 是 GATT client 用来发起 MTU Exchange 的接口。当前设备是 peripheral/server，主动调用这条路不成立。</p>
<p>Web Bluetooth 也没有给网页暴露类似 Android <code>requestMtu()</code> 的接口。网页可以连接设备、读写特征、订阅 Notify，但不能直接设置 ATT MTU。浏览器是否请求大 MTU，取决于系统蓝牙栈和浏览器实现。这个项目在 Windows Chrome 下没有协商到大 MTU，所以设备侧只能按 20 字节分包。</p>
<h2>应用任务影响很小</h2>
<p>设备端同时运行 ADC 采样、心率计算、BLE 协议处理和串口日志。为了确认是否有 CPU 干扰，临时关闭心率计算任务，只保留 BLE 主循环。</p>
<p>关闭后速度只提升了约 100 B/s。这个结果说明心率计算对这次低速影响很小，也避免继续在算法任务和主循环调度上排查。</p>
<h2>连接间隔决定 20B 小包的包率</h2>
<p>MTU 无法放大后，剩下能调的主要变量是连接间隔。连接日志显示初始 <code>connInterval</code> 是 20。BLE 连接间隔单位是 1.25ms，因此实际间隔是 25ms。</p>
<p>下载 4096 字节需要 205 个 20 字节 Notify。实际耗时接近 7 秒，折算每秒约 29 个 Notify。这个包率符合 25ms 连接间隔下每次连接事件只发出少量包的表现。</p>
<p>后续把数据同步阶段的连接间隔请求改成 6，也就是 7.5ms，并在连接建立后尽早发起参数更新。再次测速后结果明显改善：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>上传速率：4096 B / 810.0 ms = 5056.8 B/s</span></span>
<span class="line"><span>下载速率：4096 B / 1544.3 ms = 2652.3 B/s</span></span></code></pre><p><img src="/posts/ble-throughput-conn-interval-mtu/img/webui%E6%B5%8B%E9%80%9F%E7%BB%93%E6%9E%9C%E6%88%AA%E5%9B%BE.webp" alt="Web Bluetooth 测速结果"></p>
<p>这里没有解决 MTU，解决的是默认 MTU 下的小包发送频率。</p>
<h2>下载已经接近当前理论上限</h2>
<p>调参后的下载速度是 2652.3 B/s。这个数值已经接近当前条件下的理论上限。</p>
<p>当前 Web Bluetooth 调试环境里，MTU 仍是 23，Notify 有效载荷是 20 字节。连接间隔调到 6 后，每次连接事件间隔是 7.5ms。如果每个连接事件稳定发出一个 20 字节 Notify，理论吞吐大约是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>20 B / 0.0075 s = 2666.7 B/s</span></span></code></pre><p>实测 <code>2652.3 B/s</code> 和这个值非常接近。这个结果说明下载路径已经基本吃满了“默认 MTU + 7.5ms 连接间隔 + 每连接事件一包”的边界。</p>
<p>继续提升下载速度，需要改变底层条件：手机侧协商更大的 MTU，或者同一个连接事件里发出更多 Notify。继续只改应用层封包，收益会很有限。</p>
<h2>这个坑的误导性</h2>
<p>Web Bluetooth 调试 BLE 吞吐时，低速现象会把排查方向带偏：</p>
<ol>
<li>看起来像浏览器 JavaScript 慢。</li>
<li>看起来像 MCU 心率算法抢 CPU。</li>
<li>看起来像 TinyFrame 或自定义协议开销太大。</li>
<li>看起来像 Notify 发送函数写得不够快。</li>
</ol>
<p>实际证据集中在三个量上：</p>
<ol>
<li><code>ATT_GetMTU()</code> 一直是 23。</li>
<li>MCU 作为 peripheral 不能主动完成 MTU Exchange。</li>
<li>连接间隔从 25ms 改到 7.5ms 后，下载速度接近 20B 小包理论上限。</li>
</ol>
<p>因此，浏览器调试工具测出来的速度不能直接代表正式手机 App 的上限。它更适合验证协议、状态机和基础链路，不适合判断原生 App 能达到的最高吞吐。</p>
<h2>仍然没有解决的遗憾</h2>
<p>这次解决了默认 MTU 下的低包率，没有解决 Web Bluetooth 无法显式请求大 MTU的问题。</p>
<p>正式产品如果使用 Android 或 iOS 原生 App，应在手机侧主动请求更大的 MTU。大 MTU 能减少分包次数，降低协议开销和连接时长。浏览器调试工具做不到这一点，因此它需要按 20 字节包设计，并把测速结果理解为 Web Bluetooth 调试环境的结果。</p>
<p>产品实现上可以采用分阶段策略：</p>
<ol>
<li>平时断开蓝牙或使用低功耗连接参数。</li>
<li>数据同步开始后请求短连接间隔。</li>
<li>浏览器调试阶段按 20 字节分包，确认状态机和数据可靠性。</li>
<li>正式 App 阶段请求大 MTU，再重新评估吞吐和功耗。</li>
<li>数据同步结束后恢复低功耗状态，或者直接断开并进入休眠。</li>
</ol>
<h2>结论</h2>
<p>Web Bluetooth 测 BLE 吞吐时，浏览器侧不能显式请求大 MTU 是一个重要限制。Windows Chrome 没主动协商时，设备会停在 MTU 23，每个 Notify 只有 20 字节有效载荷。</p>
<p>在这个限制下，短连接间隔可以把 20 字节小包的发送频率拉起来。本次下载测速从约 0.6 KB/s 提升到 2.59 KB/s，并且已经接近当前 20 字节包和 7.5ms 连接间隔下的理论上限。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="BLE"/>
    <category term="Web Bluetooth"/>
    <category term="连接参数"/>
    <category term="MTU"/>
  </entry>
  <entry>
    <title>CmBacktrace 可以把 Cortex-M 的 HardFault 现场打印出来</title>
    <link href="https://createskyblue.github.io/posts/cmbacktrace-cortexm-fault-debug/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/cmbacktrace-cortexm-fault-debug/</id>
    <published>2026-06-16T00:00:00.000Z</published>
    <updated>2026-06-16T00:00:00.000Z</updated>
    <summary>CmBacktrace还原Cortex-M崩溃调用栈</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>CmBacktrace 可以把 Cortex-M 的 HardFault 现场打印出来</h1>
<p>CmBacktrace 是一个面向 ARM Cortex-M MCU 的错误追踪库。它主要解决一个常见调试痛点：设备发生 HardFault、BusFault、UsageFault 或断言失败后，现场信息很快丢失，只靠“程序死了”很难判断是哪一层函数触发了异常。</p>
<p>它的工作方式很直接：故障发生时，库从异常现场读取寄存器、栈指针和调用栈地址，把故障原因、固件信息、栈内容和调用链地址打印出来。拿到这些地址后，再配合 <code>addr2line</code> 和带调试信息的可执行文件，就可以把地址还原成函数名和源码行号。</p>
<h2>它能解决什么</h2>
<p>在 Cortex-M 项目里，HardFault 经常来自这些情况：</p>
<ul>
<li>空指针或野指针访问。</li>
<li>未对齐访问。</li>
<li>除零。</li>
<li>访问非法地址。</li>
<li>栈溢出或任务栈配置过小。</li>
<li>中断或任务里触发断言。</li>
</ul>
<p>没有工具时，常见做法是接上调试器、停在异常入口、手动看寄存器和汇编调用关系。问题偶发、现场设备不能接仿真器、故障后自动复位时，这种方法很难稳定复现。</p>
<p>CmBacktrace 的价值在于把异常现场变成可保存、可复制、可离线分析的日志。串口能打印就可以看到诊断信息；如果项目有 Flash 日志，也可以把故障信息保存下来，设备重启后再读取。</p>
<h2>输出信息包含哪些内容</h2>
<p>CmBacktrace 的输出通常围绕几类信息展开：</p>
<ul>
<li>固件名称、硬件版本、软件版本。</li>
<li>故障类型和原因分析。</li>
<li>异常现场寄存器。</li>
<li>主栈或线程栈信息。</li>
<li>调用栈地址。</li>
<li>可选的栈内容 dump。</li>
</ul>
<p>这些信息里，调用栈地址最适合定位源码。日志里只能看到地址时，还需要把地址交给 <code>addr2line</code> 转换。转换依赖编译产物中的符号信息，所以发布调试固件时要保留可用于定位的 ELF、OUT 或 AXF 文件。</p>
<h2>先理解堆和栈</h2>
<p>理解 CmBacktrace 前，需要先分清堆和栈。</p>
<p>栈是函数调用使用的一块连续内存。函数被调用时，CPU 和编译器会把返回地址、部分寄存器、局部变量、临时变量等内容放到栈里。这个函数运行结束后，它占用的栈空间会被释放，程序回到调用它的位置继续执行。</p>
<p>堆是动态内存分配使用的区域。<code>malloc()</code>、<code>calloc()</code>、<code>new</code> 这类接口通常从堆里申请空间，释放时再调用 <code>free()</code> 或 <code>delete</code>。堆上的数据生命周期由程序控制，适合存放运行期间才知道大小、需要跨函数继续使用的数据。</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; margin: 18px 0;">
  <div style="padding: 14px 16px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
    <div style="font-weight: 700; color: #1e293b; margin-bottom: 8px;">栈 stack</div>
    <div style="color: #334155; font-size: 14px; line-height: 1.8;">
      跟随函数调用自动增长和回收。保存返回地址、寄存器、参数、局部变量。空间通常提前固定，溢出后容易破坏返回路径。
    </div>
  </div>
  <div style="padding: 14px 16px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
    <div style="font-weight: 700; color: #1e293b; margin-bottom: 8px;">堆 heap</div>
    <div style="color: #334155; font-size: 14px; line-height: 1.8;">
      由动态内存分配器管理。对象生命周期由申请和释放决定。常见风险是泄漏、碎片、越界写和释放后继续使用。
    </div>
  </div>
</div>

<p>嵌入式项目里，栈空间通常在链接脚本或启动文件里提前固定好。裸机程序一般有主栈；RTOS 程序通常每个任务都有自己的任务栈。栈空间太小，函数调用层级太深，或者局部数组太大，都可能把栈撑爆。栈溢出后，轻则变量被踩坏，重则返回地址被破坏，程序跳到错误地址并触发 HardFault。</p>
<p>递归会持续消耗栈空间。每递归调用一次，都会产生一层新的函数栈帧；这一层会保存自己的参数、局部变量和返回地址。递归深度越大，栈帧叠得越多。递归没有正确退出条件，或者输入数据让递归层数超过预期，就会很快耗尽栈空间。</p>
<div style="margin: 18px 0; padding: 16px; background: #ffffff; border: 1px solid #e2e8f0; border-radius: 8px;">
  <div style="font-weight: 700; color: #1e293b; margin-bottom: 12px;">递归调用时的栈帧叠加</div>
  <div style="display: grid; gap: 6px; max-width: 520px;">
    <div style="padding: 8px 10px; background: #dbeafe; border: 1px solid #bfdbfe; border-radius: 6px; color: #1e293b;">第 4 层递归：参数、局部变量、返回地址</div>
    <div style="padding: 8px 10px; background: #dbeafe; border: 1px solid #bfdbfe; border-radius: 6px; color: #1e293b;">第 3 层递归：参数、局部变量、返回地址</div>
    <div style="padding: 8px 10px; background: #dbeafe; border: 1px solid #bfdbfe; border-radius: 6px; color: #1e293b;">第 2 层递归：参数、局部变量、返回地址</div>
    <div style="padding: 8px 10px; background: #dcfce7; border: 1px solid #bbf7d0; border-radius: 6px; color: #1e293b;">第 1 层调用：参数、局部变量、返回地址</div>
    <div style="padding: 8px 10px; background: #f1f5f9; border: 1px dashed #94a3b8; border-radius: 6px; color: #334155;">栈底：任务栈或主栈的固定边界</div>
  </div>
  <div style="margin-top: 10px; color: #475569; font-size: 14px; line-height: 1.8;">
    每多一层递归，都会新增一层栈帧。栈帧继续向固定边界外增长时，返回地址和相邻内存就可能被覆盖。
  </div>
</div>

<p>调用栈表示函数逐层调用形成的栈帧链。CmBacktrace 打印调用栈，本质上是在崩溃后从 SP 指向的栈空间里寻找返回地址，尽量还原故障发生前的函数调用路径。</p>
<h2>它的实现原理</h2>
<p>CmBacktrace 的原理可以分成三层：先确定代码段和栈范围，再从异常现场取出硬件保存的寄存器，最后在栈里寻找可能的返回地址。</p>
<p>初始化时，<code>cm_backtrace_init()</code> 会记录固件名、硬件版本和软件版本，同时从编译器或链接脚本导出的符号里读取主栈范围和代码段范围。Keil、IAR、GCC 的符号写法不同，所以库里分别处理了 <code>STACK</code>、<code>CSTACK</code>、<code>_sstack</code>、<code>_estack</code>、代码段起止地址这些信息。后面判断某个数值是否落在代码区，就依赖这个代码段范围。</p>
<p>Cortex-M 进入异常时，硬件会自动把一组寄存器压到当前栈里，包括 <code>R0</code>、<code>R1</code>、<code>R2</code>、<code>R3</code>、<code>R12</code>、<code>LR</code>、<code>PC</code> 和 <code>xPSR</code>。CmBacktrace 的 HardFault 汇编入口负责把异常时的 LR 和 SP 传给 <code>cm_backtrace_fault()</code>。库再按固定顺序从这段自动压栈帧里读出寄存器。</p>
<p>其中 <code>PC</code> 是异常发生时正在执行或即将执行的指令地址，通常是最直接的故障点。<code>LR</code> 是当前函数返回后要去的位置，可以帮助补出上一层调用关系。Cortex-M 使用 Thumb 指令集，函数地址最低位常带有 Thumb 状态标志，所以库在分析 LR/PC 时会做减 1 或减一个字长的修正，让地址落回真实指令附近。</p>
<p>调用栈回溯没有完整运行时元数据可用时，只能从栈里做启发式扫描。CmBacktrace 会从当前 SP 向栈顶遍历，把栈里的 32 位数当成潜在返回地址检查：地址要落在代码段范围内，Thumb 地址形态要合理，前一条指令还要像 <code>BL</code> 或 <code>BLX</code> 调用指令。通过这些条件过滤后，剩下的地址就组成打印出来的调用栈。</p>
<p>这也解释了调用栈偶尔不完整的原因。编译优化、尾调用、内联、栈损坏、栈溢出、手写汇编和异常发生位置都会影响栈上的返回地址形态。CmBacktrace 能显著提高定位效率，但它打印的是“基于异常现场和栈内容推导出来的调用链”，需要结合源码和 fault 类型一起判断。</p>
<h2>崩溃日志里的地址是什么意思</h2>
<p>崩溃日志里常见的地址可以分成三类。</p>
<p>第一类是寄存器地址。<code>PC</code> 指向异常现场的执行位置，优先拿它定位故障行。<code>LR</code> 指向返回地址，通常能说明当前函数由谁调用。<code>R0</code> 到 <code>R3</code> 常用于传参，遇到空指针、非法地址访问时，这些寄存器里可能能看到可疑指针。</p>
<p>第二类是 fault 状态寄存器。<code>HFSR</code>、<code>CFSR</code> 拆分出的 <code>MFSR</code>、<code>BFSR</code>、<code>UFSR</code>，以及 <code>MMAR</code>、<code>BFAR</code> 这类 fault address register，用来说明故障类别和访问地址。例如精确总线错误、非法状态、除零、未对齐访问等，会在不同 bit 上留下原因。</p>
<p>第三类是调用栈地址。CmBacktrace 打印的 <code>call stack</code> 是一串代码区地址，代表库推导出的调用路径。地址越靠前，通常越接近故障现场；后面的地址表示更上层的调用者。这些地址需要用 <code>addr2line</code> 继续解析成源码行号。</p>
<h2>基本移植路径</h2>
<p>移植时先把库文件加入工程：</p>
<ul>
<li><code>cm_backtrace/cm_backtrace.c</code></li>
<li><code>cm_backtrace/cm_backtrace.h</code></li>
<li><code>cm_backtrace/cmb_def.h</code></li>
<li>项目自己的 <code>cmb_cfg.h</code></li>
<li>对应编译器的 <code>fault_handler</code> 汇编文件，可选但推荐新项目优先使用</li>
</ul>
<p><code>cmb_cfg.h</code> 是移植的核心。最少要配置日志输出、平台类型和 CPU 类型：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> cmb_println(...) </span><span style="color:#DCDCAA">printf</span><span style="color:#569CD6">(__VA_ARGS__); </span><span style="color:#DCDCAA">printf</span><span style="color:#569CD6">(</span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\r\n</span><span style="color:#CE9178">"</span><span style="color:#569CD6">)</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> CMB_USING_BARE_METAL_PLATFORM</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> CMB_CPU_PLATFORM_TYPE CMB_CPU_ARM_CORTEX_M3</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> CMB_USING_DUMP_STACK_INFO</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> CMB_PRINT_LANGUAGE CMB_PRINT_LANGUAGE_CHINESE_UTF8</span></span></code></pre><p>如果项目运行 RTOS，则改成 <code>CMB_USING_OS_PLATFORM</code>，并配置 <code>CMB_OS_PLATFORM_TYPE</code>。仓库里提供了 RT-Thread、UCOS、FreeRTOS 的 demo，可以先找接近的工程参考。</p>
<p>初始化一般放在 BSP 和串口可用之后：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">cm_backtrace_init</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"firmware_name"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"hardware_v1.0"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"software_v0.1"</span><span style="color:#D4D4D4">);</span></span></code></pre><p>这三个字符串会出现在故障日志里，适合写入固件名、硬件版本和软件版本。量产设备排查问题时，这些信息能避免拿错固件和硬件版本。</p>
<h2>HardFault 入口怎么接</h2>
<p>CmBacktrace 提供两种接入方式。</p>
<p>第一种是使用库里的 <code>cmb_fault.S</code>。它会定义 <code>HardFault_Handler</code>，准备好 LR 和 SP 后调用 <code>cm_backtrace_fault()</code>。这种方式对新项目更稳，因为异常入口的寄存器取值由汇编完成，手写错误的概率低。</p>
<p>使用这种方式时，项目里原有的 <code>HardFault_Handler</code> 要移除或注释掉，否则会出现重复定义。</p>
<p>第二种是在已有的 HardFault 入口里手动调用：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> cm_backtrace_fault</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> fault_handler_lr</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> fault_handler_sp</span><span style="color:#D4D4D4">);</span></span></code></pre><p>这种方式需要自己保证传入的 LR 和 SP 是异常现场对应的值。传错以后，库仍然会运行，但调用栈和故障分析可能失真。</p>
<h2>断言也可以接入</h2>
<p>除了硬件 fault，CmBacktrace 也能记录断言失败：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">cm_backtrace_assert</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">cmb_get_sp</span><span style="color:#D4D4D4">());</span></span></code></pre><p>断言里获取 SP 的位置要尽量靠近断言入口。README 里特别提醒，如果在断言钩子的子函数里再取 SP，函数嵌套已经改变了栈现场，可能需要人为修正偏移。实际工程里更推荐在断言入口处直接调用，减少人为修正。</p>
<h2>用 addr2line 还原源码位置</h2>
<p>CmBacktrace 打印出的调用栈通常是一串地址。定位源码时，把这些地址交给 <code>addr2line</code>：</p>
<pre><code>addr2line -e firmware.elf -a -f 08000a60 08000141 0800313f</code></pre><p>常用参数含义：</p>
<ul>
<li><code>-e</code> 指定带符号信息的可执行文件。</li>
<li><code>-a</code> 输出地址。</li>
<li><code>-f</code> 输出函数名。</li>
</ul>
<p><code>addr2line</code> 属于 GNU Binutils。它做的事情是读取可执行文件里的符号表和 DWARF 调试信息，把一个机器地址映射回函数名、源文件路径和行号。符号表能告诉它地址落在哪个函数范围内，调试行号表能告诉它这个地址对应哪一行源码。</p>
<p>如果编译器生成的是 <code>.axf</code> 或 <code>.out</code>，也可以用对应文件作为 <code>-e</code> 的输入。关键是这个文件必须和设备正在运行的固件一致，并且保留调试符号。固件重新链接后，即使源码没改，函数地址也可能变化；拿错构建产物会得到错误的函数名和行号。</p>
<h2>学习这个库时要抓住的边界</h2>
<p>CmBacktrace 负责采集和打印异常现场。fault 修复、系统级日志、看门狗和崩溃后恢复策略仍然属于应用工程职责。工程里仍然要考虑故障日志保存、复位策略、版本匹配和现场数据回传。</p>
<p>它对栈信息很敏感。主栈名称、链接脚本里的栈符号、RTOS 任务栈获取方式都会影响调用栈结果。初始化时如果提示无法获取主栈，要先检查 <code>cmb_cfg.h</code> 里的栈配置和启动文件、链接脚本是否匹配。</p>
<p>FreeRTOS 场景还需要注意仓库文档里的提示：部分能力依赖任务栈地址获取，可能需要按 demo 修改 FreeRTOS 源码或适配相关接口。</p>
<h2>结论</h2>
<p>CmBacktrace 适合作为 Cortex-M 项目的故障诊断基础设施。它把 HardFault 和断言现场整理成日志，再通过 <code>addr2line</code> 把调用栈地址还原到源码位置。</p>
<p>学习它时重点看三件事：<code>cmb_cfg.h</code> 怎么匹配平台，HardFault 入口怎么把真实 LR/SP 交给库，构建产物怎么保留符号供 <code>addr2line</code> 使用。把这三件事打通后，很多偶发死机就可以从“只能复现调试”变成“日志可回收分析”。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="CmBacktrace"/>
    <category term="Cortex-M"/>
    <category term="HardFault"/>
    <category term="嵌入式调试"/>
  </entry>
  <entry>
    <title>eFlesh 用磁场指纹把 3D 打印结构变成触觉皮肤</title>
    <link href="https://createskyblue.github.io/posts/eflesh-magnetic-touch-sensing/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/eflesh-magnetic-touch-sensing/</id>
    <published>2026-06-16T00:00:00.000Z</published>
    <updated>2026-06-16T00:00:00.000Z</updated>
    <summary>eFlesh磁触觉 机器学习</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>eFlesh 用磁场指纹把 3D 打印结构变成触觉皮肤</h1>
<p>原始项目仓库：<a href="https://github.com/notvenky/eFlesh">https://github.com/notvenky/eFlesh</a></p>
<p>eFlesh 是一个磁触觉传感项目。它把柔性 3D 打印结构、磁铁、磁力计阵列和机器学习放到同一条链路里，让机器人表面能够感知接触位置、法向力、剪切力和滑移状态。</p>
<p>这类项目有一个很现实的难点：接触状态很难直接测量。手指碰到物体后，外力先让柔性结构变形，再改变磁铁和磁力计之间的位置关系，最后才表现为磁场读数变化。eFlesh 的思路是保留这条复杂链路，然后用数据把磁场读数映射回接触状态。</p>
<p><img src="/posts/eflesh-magnetic-touch-sensing/robot-hand-eflesh.webp" alt="eFlesh 覆盖在机器人手上的示意"></p>
<h2>磁场指纹来自结构变形</h2>
<p>eFlesh 的传感单元里有柔性结构、磁铁和磁力计。结构受压或受剪切时，磁铁相对磁力计的位置会变化，磁力计读到的三轴磁场也会跟着变化。</p>
<p>项目里的 Arduino 采集程序会扫描 I2C 地址，读取多个 MLX90393 磁力计。每个磁力计输出 X、Y、Z 三轴数据，多个传感器合起来形成一组多维磁场读数。</p>
<p>这组读数可以理解成一次接触留下的“磁场指纹”。接触位置、按压力、剪切方向不同，柔性结构的变形路径也不同，最后得到的磁场模式也不同。模型学习的目标就是把这些磁场模式翻译成位置、力或滑移判断。</p>
<p><img src="/posts/eflesh-magnetic-touch-sensing/sensor-structure.webp" alt="eFlesh 传感结构和磁力计板"></p>
<h2>3D 打印结构决定信号质量</h2>
<p>磁触觉传感不能只看电子部分。外部接触力能不能形成稳定、可分辨的磁场变化，很大程度取决于机械结构。</p>
<p>eFlesh 使用 cut-cell microstructures 生成可打印的柔性微结构，并在结构里设计磁铁口袋和磁力计板槽位。这样传感器形状可以贴合机器人手、指尖或夹爪，结构刚度也可以通过微结构参数调整。</p>
<p>这个设计的价值在于可定制性。机器人触觉皮肤很难依靠一种固定形状覆盖所有任务。eFlesh 把传感结构做成可打印、可改形状的对象，让同一套磁感知方法可以迁移到不同表面。</p>
<h2>采集链路保持简单</h2>
<p>硬件采集端的职责很清楚：稳定读传感器，稳定输出数据。Arduino 程序负责发现传感器、配置参数、进入连续读取模式，然后通过串口输出二进制读数。</p>
<p>这条链路里有几个容易被忽略的点：</p>
<ul>
<li>传感器顺序要稳定，否则模型输入列的含义会乱。</li>
<li>无接触状态要做 baseline，后续读数通常要减去基线。</li>
<li>传感器数据和外部状态数据要带时间戳，训练时才能对齐。</li>
<li>MCU 侧先保证采样稳定，复杂映射放到 PC 侧训练。</li>
</ul>
<p>这种分工适合原型阶段。MCU 负责采样，PC 负责记录、可视化和训练。模型稳定以后，再考虑边缘推理或 MCU 部署。</p>
<h2>训练数据来自同步采集</h2>
<p>这类数据集通常不会靠人工逐条标注。人工可以告诉系统“正在做哪类实验”，但每一帧的接触位置、力值和磁场读数需要通过设备同步记录。</p>
<p>仓库里能确认的表征训练数据由两类文件组成：<code>sensor_post_baselines.csv</code> 保存减去 baseline 后的磁力计读数，<code>states.csv</code> 保存同一实验过程里的状态或力值。训练脚本读取这两个文件，用时间戳为每条传感器读数寻找最近的状态记录。</p>
<p>这说明数据采集过程至少包含两路信息：</p>
<ul>
<li>磁力计阵列连续输出传感器读数。</li>
<li>外部实验系统记录位置、法向力、剪切力或动作标签。</li>
<li>两路数据都保留时间戳。</li>
<li>训练前先做 baseline 处理，再按时间戳配对。</li>
</ul>
<p>仓库没有给出完整的表征实验采集脚本，所以不能把某一种机械运动流程写死。可以确认的是，训练阶段依赖已经配对好的时间序列数据，标签来源应当是实验装置、机器人流程或力学测量设备，人工只负责实验组织和样本类别确认。</p>
<h2>MLP 把磁场读数回归到位置或力</h2>
<p><code>characterization/train.py</code> 里使用 PyTorch MLP 做表征训练。它支持三种模式：空间位置、法向力和剪切力。</p>
<p>训练流程很直接：</p>
<ul>
<li>读取 <code>states.csv</code> 和 <code>sensor_post_baselines.csv</code>。</li>
<li>通过最近时间戳把传感器读数和状态值配对。</li>
<li>按随机 80/20 切分训练集和验证集。</li>
<li>只用训练集统计均值和方差，再归一化输入和标签。</li>
<li>使用两层隐藏层、隐藏宽度 128 的 MLP 训练。</li>
<li>用 Adam 和 MSE loss 回归位置或力值。</li>
<li>保存模型权重和归一化参数到 <code>artifacts</code> 目录。</li>
</ul>
<p>这个训练方式没有依赖复杂网络。磁场到位置或力的关系是非线性的，但输入维度不高，目标也明确。小 MLP 足够先跑通从读数到物理量的闭环。</p>
<p><img src="/posts/eflesh-magnetic-touch-sensing/shear-force-fit.webp" alt="剪切力数据和模型预测曲线"></p>
<h2>滑移检测展示了触觉的实用性</h2>
<p>滑移检测是 eFlesh 很直观的应用。机器人夹住物体后，如果物体开始被拉走，触觉信号会先发生变化。视觉系统未必能稳定看到这种细小相对运动，触觉传感器可以直接感知接触状态变化。</p>
<p><code>slip_detection</code> 目录里包含数据采集、训练和机器人控制代码。控制脚本以 100Hz 采样触觉序列，先用前 50 帧计算 baseline，然后在后续序列中减去 baseline，提取一段触觉变化作为分类特征。</p>
<p>滑移训练脚本读取 <code>seq_*.pt</code> 序列和标签文件。它会对序列做 Savitzky-Golay 滤波，取传感器通道减去 baseline 后的变化量，再训练一个轻量分类器判断是否发生拉拽或滑移。</p>
<p><img src="/posts/eflesh-magnetic-touch-sensing/slip-ui.webp" alt="滑移检测演示界面"></p>
<p>这个例子说明触觉传感的价值不止是测力。它可以给机器人提供接触反馈：碰到了哪里、力有没有变化、物体是否开始滑动。抓取、交接、插拔、擦拭这类任务都依赖这种反馈。</p>
<h2>学习这个项目时要看边界</h2>
<p>eFlesh 的优点是系统链路完整：结构设计、传感器采集、baseline、数据集、训练脚本和演示应用都能对应起来。读这个仓库时，可以同时看到触觉硬件和学习算法怎么配合。</p>
<p>复现时需要注意几个边界：</p>
<ul>
<li>磁铁一致性会影响读数。</li>
<li>磁力计位置误差会改变输入分布。</li>
<li>TPU 打印结构的刚度和回弹会影响响应。</li>
<li>换形状或换传感器布局后，原模型可能需要重新采集数据。</li>
<li>环境磁干扰会让 baseline 和预测稳定性变差。</li>
</ul>
<p>因此，eFlesh 更适合作为“磁触觉系统怎么搭起来”的学习案例。它展示了机械、电路、采集和训练如何配合，也提醒触觉模型的质量高度依赖采集覆盖范围和实验条件。</p>
<h2>结论</h2>
<p>eFlesh 的主线可以概括为：可打印柔性结构把接触变成磁铁位移，磁力计阵列把位移变成多维磁场读数，训练脚本再把这些磁场指纹映射到位置、力或滑移状态。</p>
<p>这篇项目最值得看的部分是训练数据链路。数据来自传感器流和实验状态流的同步记录，再通过时间戳、baseline 和归一化进入模型训练。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="eFlesh"/>
    <category term="触觉传感"/>
    <category term="机器人"/>
    <category term="机器学习"/>
  </entry>
  <entry>
    <title>nanoModbus 更适合放在嵌入式网关的协议边界</title>
    <link href="https://createskyblue.github.io/posts/nanoModbus/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/nanoModbus/</id>
    <published>2026-06-16T00:00:00.000Z</published>
    <updated>2026-06-16T00:00:00.000Z</updated>
    <summary>nanoModbus应用</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>nanoModbus 更适合放在嵌入式网关的协议边界</h1>
<p>在 DALI 转 Modbus 网关里，nanoModbus 的职责是处理 Modbus RTU 从站协议，并把业务逻辑留给应用层。</p>
<p>项目硬件是 AIR001，DALI 侧负责照明总线的发送、接收和解析，RS485 侧对外暴露 Modbus RTU。从上位机看，它就是一组 holding register；从固件看，Modbus 写寄存器会触发 DALI 发送，DALI 接收结果会回填到寄存器里。</p>
<p><img src="/posts/nanoModbus/img/dali-gateway-pcb.webp" alt="DALI 转 Modbus 网关硬件"></p>
<h2>先把 Modbus 当成入口层</h2>
<p>nanoModbus 只需要平台读写函数和回调表。串口、DMA、队列、业务状态都留在应用层，不塞进库里。</p>
<p>这个项目里，串口接收使用 DMA 环形缓冲和 idle 中断，再转存到一个字节队列。nanoModbus 的 <code>read</code> 回调只从这个队列里按超时时间取字节，<code>write</code> 回调只负责把响应发回 UART。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> int32_t</span><span style="color:#DCDCAA"> nanoModbus_read_serial</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">* </span><span style="color:#9CDCFE">buf</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                      uint16_t</span><span style="color:#9CDCFE"> count</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                      int32_t</span><span style="color:#9CDCFE"> byte_timeout_ms</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                      void</span><span style="color:#D4D4D4">* </span><span style="color:#9CDCFE">arg</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">  uint32_t</span><span style="color:#D4D4D4"> tick_start = </span><span style="color:#DCDCAA">HAL_GetTick</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#569CD6">  uint16_t</span><span style="color:#D4D4D4"> realReadCount = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">  do</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#D4D4D4"> rx_data;</span></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (realReadCount &#x3C; count &#x26;&#x26;</span></span>
<span class="line"><span style="color:#DCDCAA">           USART_read</span><span style="color:#D4D4D4">(&#x26;USART1_DMA_Context, &#x26;rx_data)) {</span></span>
<span class="line"><span style="color:#9CDCFE">      buf</span><span style="color:#D4D4D4">[realReadCount++] = rx_data;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">  } </span><span style="color:#C586C0">while</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">HAL_GetTick</span><span style="color:#D4D4D4">() - tick_start &#x3C; byte_timeout_ms &#x26;&#x26;</span></span>
<span class="line"><span style="color:#D4D4D4">           realReadCount &#x3C; count);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#D4D4D4"> realReadCount;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> int32_t</span><span style="color:#DCDCAA"> nanoModbus_write_serial</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4">* </span><span style="color:#9CDCFE">buf</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                       uint16_t</span><span style="color:#9CDCFE"> count</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                       int32_t</span><span style="color:#9CDCFE"> byte_timeout_ms</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                       void</span><span style="color:#D4D4D4">* </span><span style="color:#9CDCFE">arg</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#DCDCAA"> USART_write</span><span style="color:#D4D4D4">(&#x26;USART1_DMA_Context, (</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">*)buf, count);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>这样做的好处是边界清楚：nanoModbus 不关心 UART 是轮询、中断还是 DMA；应用层也不需要解析 Modbus 帧、CRC 和异常响应。</p>
<h2>寄存器表要表达业务动作</h2>
<p>DALI 网关只开放 holding register。部分寄存器是状态，例如运行时间、接收长度、接收数据；部分寄存器是配置，例如控制器模式、是否监听自身发送；还有一个寄存器是动作触发：写入发送使能后，把前面两个消息字节发到 DALI 总线。</p>
<p>这个设计比另起一套私有命令更容易测试。Modbus Poll 直接读写寄存器，就能验证网关的主要行为。</p>
<p><img src="/posts/nanoModbus/img/modbus-poll-registers.webp" alt="Modbus Poll 中的寄存器视图"></p>
<p>寄存器地址不要散落在业务代码里。项目里用枚举固定寄存器顺序，读写回调统一检查范围和权限：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">typedef</span><span style="color:#569CD6"> enum</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#D4D4D4">  MODBUS_HOLDING_REG_IS_MILLIS_0 = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">  MODBUS_HOLDING_REG_IS_MILLIS_1,</span></span>
<span class="line"><span style="color:#D4D4D4">  MODBUS_HOLDING_REG_IS_CONTROLER,</span></span>
<span class="line"><span style="color:#D4D4D4">  MODBUS_HOLDING_REG_TX_MSG_BOX_0,</span></span>
<span class="line"><span style="color:#D4D4D4">  MODBUS_HOLDING_REG_TX_MSG_BOX_1,</span></span>
<span class="line"><span style="color:#D4D4D4">  MODBUS_HOLDING_REG_TX_EN,</span></span>
<span class="line"><span style="color:#D4D4D4">  MODBUS_HOLDING_REG_RX_LEN,</span></span>
<span class="line"><span style="color:#D4D4D4">  MODBUS_HOLDING_REG_RX_MSG_BOX_0,</span></span>
<span class="line"><span style="color:#D4D4D4">  MODBUS_HOLDING_REG_RX_MSG_BOX_1,</span></span>
<span class="line"><span style="color:#D4D4D4">  MODBUS_HOLDING_REG_DEMO_SENDING_RESTRICTIONS,</span></span>
<span class="line"><span style="color:#D4D4D4">  MODBUS_HOLDING_REG_DEMO_IS_MONITOR_SELF_TX,</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><p>读回调适合做轻量的状态整理。这个项目在读取时刷新运行时间，并在上位机读取接收消息后释放 IRQ 提示信号：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">nmbs_error </span><span style="color:#DCDCAA">handler_read_holding_registers</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint16_t</span><span style="color:#9CDCFE"> address</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                          uint16_t</span><span style="color:#9CDCFE"> quantity</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                          uint16_t</span><span style="color:#D4D4D4">* </span><span style="color:#9CDCFE">registers_out</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                          uint8_t</span><span style="color:#9CDCFE"> unit_id</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                          void</span><span style="color:#D4D4D4">* </span><span style="color:#9CDCFE">arg</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (address + quantity > HOLDING_REGISTERS_COUNT)</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> NMBS_EXCEPTION_ILLEGAL_DATA_ADDRESS;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (address &#x3C;= MODBUS_HOLDING_REG_RX_MSG_BOX_0 &#x26;&#x26;</span></span>
<span class="line"><span style="color:#D4D4D4">      address + quantity >= MODBUS_HOLDING_REG_RX_MSG_BOX_1) {</span></span>
<span class="line"><span style="color:#DCDCAA">    HAL_GPIO_WritePin</span><span style="color:#D4D4D4">(RX_IRQ_GPIO_Port, RX_IRQ_Pin, GPIO_PIN_SET);</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">  *((</span><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4">*)modbus_holding_registers) = </span><span style="color:#DCDCAA">HAL_GetTick</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">  memcpy</span><span style="color:#D4D4D4">(registers_out,</span></span>
<span class="line"><span style="color:#D4D4D4">         modbus_holding_registers + address,</span></span>
<span class="line"><span style="color:#569CD6">         sizeof</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint16_t</span><span style="color:#D4D4D4">) * quantity);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#D4D4D4"> NMBS_ERROR_NONE;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>写回调要区分“写状态”和“触发动作”。接收结果、演示限制计数这类寄存器由固件维护，上位机写入应该返回非法地址。发送使能寄存器不能简单保存值，写入时要读出消息寄存器并启动 DALI 发送。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">nmbs_error </span><span style="color:#DCDCAA">handler_write_single_register</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint16_t</span><span style="color:#9CDCFE"> address</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                         uint16_t</span><span style="color:#9CDCFE"> value</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                         uint8_t</span><span style="color:#9CDCFE"> unit_id</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                         void</span><span style="color:#D4D4D4">* </span><span style="color:#9CDCFE">arg</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (address >= HOLDING_REGISTERS_COUNT)</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> NMBS_EXCEPTION_ILLEGAL_DATA_ADDRESS;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">  switch</span><span style="color:#D4D4D4"> (address) {</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> MODBUS_HOLDING_REG_RX_LEN:</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> MODBUS_HOLDING_REG_RX_MSG_BOX_0:</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> MODBUS_HOLDING_REG_RX_MSG_BOX_1:</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> MODBUS_HOLDING_REG_DEMO_SENDING_RESTRICTIONS:</span></span>
<span class="line"><span style="color:#C586C0">      return</span><span style="color:#D4D4D4"> NMBS_EXCEPTION_ILLEGAL_DATA_ADDRESS;</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">  switch</span><span style="color:#D4D4D4"> (address) {</span></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> MODBUS_HOLDING_REG_IS_CONTROLER:</span></span>
<span class="line"><span style="color:#9CDCFE">      dali_ctx</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">tx_isControler</span><span style="color:#D4D4D4"> = value;</span></span>
<span class="line"><span style="color:#C586C0">      break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> MODBUS_HOLDING_REG_DEMO_IS_MONITOR_SELF_TX:</span></span>
<span class="line"><span style="color:#9CDCFE">      dali_ctx</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">rx_isMonitorSelfTx</span><span style="color:#D4D4D4"> = value;</span></span>
<span class="line"><span style="color:#C586C0">      break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    case</span><span style="color:#D4D4D4"> MODBUS_HOLDING_REG_TX_EN: {</span></span>
<span class="line"><span style="color:#569CD6">      uint8_t</span><span style="color:#9CDCFE"> sendData</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">] = {</span></span>
<span class="line"><span style="color:#9CDCFE">        modbus_holding_registers</span><span style="color:#D4D4D4">[MODBUS_HOLDING_REG_TX_MSG_BOX_0] &#x26; </span><span style="color:#B5CEA8">0xFF</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">        modbus_holding_registers</span><span style="color:#D4D4D4">[MODBUS_HOLDING_REG_TX_MSG_BOX_1] &#x26; </span><span style="color:#B5CEA8">0xFF</span></span>
<span class="line"><span style="color:#D4D4D4">      };</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">      DALI_start_tx_interrupt</span><span style="color:#D4D4D4">(&#x26;dali_ctx, sendData);</span></span>
<span class="line"><span style="color:#C586C0">      return</span><span style="color:#D4D4D4"> NMBS_ERROR_NONE;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">  modbus_holding_registers</span><span style="color:#D4D4D4">[address] = value;</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#D4D4D4"> NMBS_ERROR_NONE;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>触发型寄存器如果被当成普通状态保存，后面维护时很容易误判设备状态。例如 <code>TX_EN</code> 的意义是“这次写入触发发送”，不能表示“设备当前处于发送使能状态”。这类寄存器最好在写回调里立即消费掉。</p>
<h2>DALI 侧只通过状态和事件接入</h2>
<p>DALI 接收完成后，不需要直接参与 Modbus 协议处理。它只把解析结果写进 holding register，并拉低 IRQ 引脚通知外部设备有新数据。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> rx_interrupt_callback</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4">* </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint8_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#9CDCFE">  modbus_holding_registers</span><span style="color:#D4D4D4">[MODBUS_HOLDING_REG_RX_LEN] = len;</span></span>
<span class="line"><span style="color:#9CDCFE">  modbus_holding_registers</span><span style="color:#D4D4D4">[MODBUS_HOLDING_REG_RX_MSG_BOX_0] = </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#9CDCFE">  modbus_holding_registers</span><span style="color:#D4D4D4">[MODBUS_HOLDING_REG_RX_MSG_BOX_1] = </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#DCDCAA">  HAL_GPIO_WritePin</span><span style="color:#D4D4D4">(RX_IRQ_GPIO_Port, RX_IRQ_Pin, GPIO_PIN_RESET);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>这个边界让系统更容易排查。DALI 定时器、中断解析、曼彻斯特编码可以单独调；Modbus Poll 读写寄存器也可以单独调。两边通过一张小寄存器表连接。</p>
<p><img src="/posts/nanoModbus/img/dali-waveform.png" alt="DALI 总线波形"></p>
<h2>主循环里不要空跑协议栈</h2>
<p>工程里还有一个细节：只有串口队列不为空时才调用 <code>nmbs_server_poll()</code>。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (!</span><span style="color:#DCDCAA">isQueueEmpty</span><span style="color:#D4D4D4">(&#x26;USART1_DMA_Context.queue) &#x26;&#x26;</span></span>
<span class="line"><span style="color:#DCDCAA">    nmbs_server_poll</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">nmbs</span><span style="color:#D4D4D4">) == NMBS_ERROR_TRANSPORT) {</span></span>
<span class="line"><span style="color:#DCDCAA">  Error_Handler</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">DALI_Update</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">dali_ctx</span><span style="color:#D4D4D4">);</span></span></code></pre><p>这样做可以避免主循环长期卡在无意义的 Modbus 读取等待里。DALI 侧还需要持续处理接收超时和帧解析，协议入口不能抢走整个主循环。</p>
<p>超时也要按串口接收方式理解。<code>byte_timeout_ms</code> 是 nanoModbus 等待下一个字节的边界，不代表 DALI 业务超时。DMA idle 中断已经负责把收到的数据搬到队列里，read 回调只要在这个边界内尽量取满即可。</p>
<h2>这类项目的移植结论</h2>
<p>nanoModbus 适合承担协议边界，不适合承担业务模型。嵌入式网关里最稳的用法是：</p>
<ul>
<li>串口收发封装成平台 <code>read</code> 和 <code>write</code>。</li>
<li>业务能力整理成 holding register。</li>
<li>只读寄存器在写回调里明确拒绝。</li>
<li>触发型寄存器在写回调里立即消费。</li>
<li>异步总线事件只更新寄存器和通知引脚。</li>
<li>主循环避免因为 Modbus 等待阻塞其他实时任务。</li>
</ul>
<p>这样移植后，nanoModbus 负责协议正确性，应用代码负责设备行为。两边边界清楚，后续换串口驱动、扩寄存器、接入新的现场总线时都更容易维护。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="nanoModbus"/>
    <category term="Modbus"/>
    <category term="DALI"/>
    <category term="嵌入式"/>
  </entry>
  <entry>
    <title>图片非线性扰动工具可以用参数码加扰和还原图片</title>
    <link href="https://createskyblue.github.io/posts/nonlinear-image-distortion-tool/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/nonlinear-image-distortion-tool/</id>
    <published>2026-06-16T00:00:00.000Z</published>
    <updated>2026-06-16T00:00:00.000Z</updated>
    <summary>非线性图像扰动工具本地处理图片</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>图片非线性扰动工具可以用参数码加扰和还原图片</h1>
<p>图片非线性扰动工具是一个在浏览器本地运行的图片处理工具。它可以把截图、图纸、资料图处理成带非线性形变的版本，并生成对应参数码。之后拿到同一张处理后图片和同一个参数码，就可以尝试还原。</p>
<p><img src="/posts/nonlinear-image-distortion-tool/cover.webp" alt="图片非线性扰动工具界面"></p>
<h2>工具入口</h2>
<ul>
<li>GitHub：<a href="https://github.com/createskyblue/Nonlinear-Image-Distortion-Tool">https://github.com/createskyblue/Nonlinear-Image-Distortion-Tool</a></li>
<li>在线体验：<a href="https://createskyblue.github.io/Nonlinear-Image-Distortion-Tool/">https://createskyblue.github.io/Nonlinear-Image-Distortion-Tool/</a></li>
</ul>
<h2>核心功能</h2>
<p>工具的主流程很直接：导入图片，选择加扰或还原，调整密钥、偏移强度、网格尺度和旋转扰动，然后处理并下载结果。</p>
<p>它支持几种常用输入方式：</p>
<ul>
<li>选择本地图片文件。</li>
<li>手机或电脑直接拍照。</li>
<li>拖拽图片到页面。</li>
<li>从剪贴板粘贴截图。</li>
</ul>
<p>处理完成后可以下载 PNG，也可以打开自动复制结果，把处理后的图片直接放进剪贴板，方便粘贴到聊天工具、文档或工单里。</p>
<h2>参数码用于复现同一组扰动</h2>
<p>加扰结果会生成 <code>NO3:</code> 开头的参数码。参数码记录了密钥、偏移强度、网格尺度、旋转扰动等关键参数。后续还原时，只靠记住密钥不够，还需要使用同一个参数码。</p>
<p>这个设计适合做轻量来源确认。给不同分享对象或不同讨论场景使用不同参数码，后续如果看到疑似派生图片，可以用对应参数码尝试还原。正确参数通常能得到明显更好的还原效果。</p>
<p>工具也兼容旧版 <code>NO2:</code> 参数码，已有旧图不需要重新生成。</p>
<h2>支持等比例缩放后的还原</h2>
<p>图片经过放大或缩小后，仍然可以放回工具里用同一参数码尝试还原。这一点对截图传播很重要，因为图片在聊天软件、网页预览或文档里经常会被等比例缩放。</p>
<p>还原目标是辅助恢复可识别结构，不承诺逐像素无损。压缩、截图、缩放和局部编辑都会带来损失。这个工具让正确参数和错误参数之间形成可观察差异，用来辅助判断来源。</p>
<h2>适用场景</h2>
<p>这个工具适合处理需要“能看大概内容，但不希望直接拿走原图细节”的图片。</p>
<p>典型场景包括：</p>
<ul>
<li>对外展示 CAD 导出图、结构图或设计稿预览。</li>
<li>分享内部截图时降低敏感文字和细节的可读性。</li>
<li>给不同沟通对象生成不同扰动版本，辅助后续来源判断。</li>
<li>在不上传图片到服务器的前提下完成本地处理。</li>
</ul>
<p>它不适合作为司法级鉴定工具，也不应该替代正式的保密流程。它的定位是轻量的视觉脱敏和来源辅助。</p>
<h2>为什么不用传统水印</h2>
<p>这个项目早期考虑过暗水印和明水印，但最终选择把主要能力放在非线性扰动上。</p>
<p>暗水印容易受到缩放、压缩、截图和局部干扰影响，提取失败后很难继续判断。明水印在视觉上更明显，但也更容易被裁剪、擦除或用 AI 修补。</p>
<p>非线性扰动的优势是直接改变图片几何结构。别人仍然可以看到图的大概布局，但想要准确恢复原图，需要知道对应参数码。</p>
<h2>使用方式</h2>
<p>打开在线页面后，按下面顺序使用即可：</p>
<ol>
<li>导入图片或粘贴截图。</li>
<li>选择“加扰”模式。</li>
<li>调整密钥、偏移强度、网格尺度和旋转扰动。</li>
<li>保存生成的参数码。</li>
<li>下载或复制处理后的图片。</li>
</ol>
<p>需要还原时，切换到“还原”模式，粘贴参数码，再导入处理后的图片重新处理。</p>
<p><img src="/posts/nonlinear-image-distortion-tool/img/english-ui.webp" alt="英文界面"></p>
<h2>小结</h2>
<p>图片非线性扰动工具解决的是一个很具体的需求：图片要能被展示和讨论，但不希望原始细节被轻易复用；同时又希望以后能用参数码尝试还原或确认来源。</p>
<p>它的特点是本地运行、输入方式灵活、参数码可复现、对等比例缩放有一定容忍度。对设计图、资料截图和预览图来说，这比单纯打水印更贴近实际传播场景。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="图像工具"/>
    <category term="非线性扰动"/>
    <category term="隐私保护"/>
    <category term="React"/>
  </entry>
  <entry>
    <title>TinyFrame 可以给串口和 BLE 透传加一层可靠消息帧</title>
    <link href="https://createskyblue.github.io/posts/tinyframe-protocol-frame/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/tinyframe-protocol-frame/</id>
    <published>2026-06-16T00:00:00.000Z</published>
    <updated>2026-06-16T00:00:00.000Z</updated>
    <summary>TinyFrame把串口字节流封成可校验消息帧</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>TinyFrame 可以给串口和 BLE 透传加一层可靠消息帧</h1>
<p>TinyFrame 是一个 C 语言消息帧库，适合放在 UART、BLE 透传、USB CDC、socket 这类字节流链路上。底层链路只负责收发字节，TinyFrame 负责把字节组织成一条条有边界、有类型、有校验的消息。</p>
<p>这类库的价值在于减少重复造轮子。嵌入式项目里经常会遇到很多指令：设置时间、读取时间、读取传感器记录、删除记录、测速、查询版本。直接在透传数据里手写解析逻辑，很容易把包边界、长度检查、校验、指令分发和业务逻辑混在一起。TinyFrame 把这些通用动作收进协议层，业务代码只需要按消息类型处理指令。</p>
<h2>适用场景</h2>
<p>TinyFrame 适合这些场景：</p>
<ul>
<li>串口或 BLE 透传本身只提供字节收发，没有消息边界。</li>
<li>指令类型比较多，希望用 <code>type</code> 字段分发业务命令。</li>
<li>需要长度字段和校验，避免半包、粘包、乱包直接进入业务逻辑。</li>
<li>需要请求和响应关联，可以用帧 ID 等待某次请求的回复。</li>
<li>MCU 资源有限，希望协议层用静态缓冲区和少量 C 文件完成。</li>
</ul>
<p>TinyFrame 的定位是消息帧库，文件传输调度仍由应用层负责。TinyFrame 可以发送长 payload，也提供 multipart 发送接口，但接收端仍然需要用 <code>TF_MAX_PAYLOAD_RX</code> 缓冲完整一帧后再校验。超出接收缓冲区的大数据，应在应用层拆成多条消息传输。</p>
<h2>帧里有什么</h2>
<p>TinyFrame 的一帧通常包含这些部分：</p>
<ul>
<li>SOF：帧开始字节，可配置。</li>
<li>ID：帧 ID，用来关联请求和响应。</li>
<li>LEN：payload 长度。</li>
<li>TYPE：消息类型，常用来当作指令号。</li>
<li>HEAD_CKSUM：头部校验。</li>
<li>DATA：业务数据。</li>
<li>DATA_CKSUM：payload 校验。</li>
</ul>
<p>这些字段的字节数可以在 <code>TF_Config.h</code> 里配置。例如 ID、长度、类型字段可以选择 1、2 或 4 字节，校验可以选择无校验、XOR、CRC8、CRC16、CRC32 或自定义校验。两个通信端必须使用同一套配置。</p>
<h2>优点</h2>
<p>TinyFrame 的优点主要体现在工程边界清楚：</p>
<ul>
<li>协议层负责帧格式、校验、解析状态机和监听器。</li>
<li>业务层只关心某个 <code>type</code> 对应哪条指令。</li>
<li>发送接口只依赖 <code>TF_WriteImpl()</code>，可以接 UART、BLE notify/write、USB 或 TCP。</li>
<li>接收接口只需要把收到的字节交给 <code>TF_Accept()</code>。</li>
<li>静态初始化可避免 <code>malloc()</code>，适合 MCU 工程。</li>
<li>多实例可用，但同一工程里的实例要使用同样的字段配置。</li>
</ul>
<p>对指令较多的嵌入式项目来说，<code>type</code> 字段很适合作为命令号。新增一条业务指令时，通常只需要定义新的 <code>type</code>，注册对应监听器，然后在回调里处理 payload。</p>
<h2>移植步骤</h2>
<p>移植时只需要把通用库和工程适配层分清楚。</p>
<p>第一步，把这些文件放进工程：</p>
<ul>
<li><code>TinyFrame.c</code></li>
<li><code>TinyFrame.h</code></li>
<li><code>TF_Config.h</code></li>
</ul>
<p><code>TF_Config.example.h</code> 可以复制成 <code>TF_Config.h</code> 后再按项目调整。常见配置包括字段长度、校验类型、最大接收 payload、发送缓冲区长度、监听器数量和是否启用互斥锁。</p>
<p>第二步，实现发送出口：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> TF_WriteImpl</span><span style="color:#D4D4D4">(TinyFrame *</span><span style="color:#9CDCFE">tf</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">buff</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    ble_or_uart_write</span><span style="color:#D4D4D4">(buff, len);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>TinyFrame 不直接操作硬件。所有 <code>TF_Send()</code>、<code>TF_Respond()</code> 最终都会调用 <code>TF_WriteImpl()</code>，所以这个函数就是协议层到物理链路的出口。</p>
<p>第三步，初始化实例并注册监听器：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#D4D4D4"> TinyFrame tf;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> app_proto_init</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    TF_InitStatic</span><span style="color:#D4D4D4">(&#x26;tf, TF_SLAVE);</span></span>
<span class="line"><span style="color:#DCDCAA">    TF_AddTypeListener</span><span style="color:#D4D4D4">(&#x26;tf, </span><span style="color:#B5CEA8">0x01</span><span style="color:#D4D4D4">, on_set_time);</span></span>
<span class="line"><span style="color:#DCDCAA">    TF_AddTypeListener</span><span style="color:#D4D4D4">(&#x26;tf, </span><span style="color:#B5CEA8">0x02</span><span style="color:#D4D4D4">, on_get_time);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>如果项目允许动态内存，也可以使用 <code>TF_Init()</code>。在 MCU 工程里更常用 <code>TF_InitStatic()</code>，实例由应用自己分配。</p>
<p>第四步，把收到的数据喂给解析器：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> on_link_rx</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint32_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    TF_Accept</span><span style="color:#D4D4D4">(&#x26;tf, data, len);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>UART 中断、BLE 写特征回调、USB CDC 接收回调都可以走这条路径。TinyFrame 会在内部累积字段、校验长度和校验和，解析出完整消息后再调用监听器。</p>
<p>第五步，如果使用请求超时或解析超时，周期性调用：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> systick_handler</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    TF_Tick</span><span style="color:#D4D4D4">(&#x26;tf);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><code>TF_Tick()</code> 的时间基准由调用周期决定。它用于解析器超时，也用于 ID 监听器超时。</p>
<h2>简单发送和响应</h2>
<p>发送一条普通消息可以使用 <code>TF_Send()</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> payload</span><span style="color:#569CD6">[]</span><span style="color:#D4D4D4"> = { </span><span style="color:#B5CEA8">0x01</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0x02</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0x03</span><span style="color:#D4D4D4"> };</span></span>
<span class="line"><span style="color:#D4D4D4">TF_Msg msg;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">TF_ClearMsg</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">msg</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">msg.type = </span><span style="color:#B5CEA8">0x10</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">msg.data = payload;</span></span>
<span class="line"><span style="color:#D4D4D4">msg.len = </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(payload);</span></span>
<span class="line"><span style="color:#DCDCAA">TF_Send</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">tf</span><span style="color:#D4D4D4">, &#x26;</span><span style="color:#9CDCFE">msg</span><span style="color:#D4D4D4">);</span></span></code></pre><p>接收某个类型的消息时，监听器拿到的是 <code>TF_Msg</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#D4D4D4"> TF_Result </span><span style="color:#DCDCAA">on_get_time</span><span style="color:#D4D4D4">(TinyFrame *</span><span style="color:#9CDCFE">tf</span><span style="color:#D4D4D4">, TF_Msg *</span><span style="color:#9CDCFE">msg</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#9CDCFE"> rsp</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">7</span><span style="color:#D4D4D4">];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    fill_time_payload</span><span style="color:#D4D4D4">(rsp);</span></span>
<span class="line"><span style="color:#9CDCFE">    msg</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4"> = rsp;</span></span>
<span class="line"><span style="color:#9CDCFE">    msg</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">len</span><span style="color:#D4D4D4"> = </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(rsp);</span></span>
<span class="line"><span style="color:#DCDCAA">    TF_Respond</span><span style="color:#D4D4D4">(tf, msg);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> TF_STAY;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><code>TF_Respond()</code> 会复用收到消息的帧 ID，方便请求方把响应和原请求对应起来。监听器返回 <code>TF_STAY</code> 表示继续保留，后续同类型消息还会进入这个回调。</p>
<h2>使用时要注意</h2>
<p>配置要先定下来。字段长度、校验类型、SOF 开关、大小端和最大 payload 都会影响帧格式，通信双方必须一致。</p>
<p>接收缓冲区要按最大单帧 payload 预留。TinyFrame 解析 payload 后才会交给业务回调，因此 <code>TF_MAX_PAYLOAD_RX</code> 太小会导致大帧被丢弃。大文件、大波形、大批量记录应拆成多条业务消息，不要依赖单帧硬扛。</p>
<p>发送 multipart 只解决发送端不想一次性准备完整 payload 的问题。它不会降低接收端对完整单帧缓冲区的要求。</p>
<p>如果在多线程或中断加主循环环境中共用同一个 TinyFrame 实例，要开启 <code>TF_USE_MUTEX</code> 并实现 <code>TF_ClaimTx()</code>、<code>TF_ReleaseTx()</code>，或者在应用层保证发送接口不会并发进入。</p>
<h2>结论</h2>
<p>TinyFrame 适合给“透传链路”补齐协议层。它不替代 UART、BLE 或 TCP，也不替代业务协议设计；它负责把字节流变成可校验、可分发、可响应的消息帧。</p>
<p>在指令数量较多的 MCU 项目里，可以把 TinyFrame 放在链路层和业务层之间：链路层收发字节，TinyFrame 解析和封包，业务层按 <code>type</code> 处理命令。这样新增指令时，主要改业务回调，不需要反复重写包解析状态机。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="TinyFrame"/>
    <category term="通信协议"/>
    <category term="嵌入式"/>
    <category term="BLE"/>
  </entry>
  <entry>
    <title>弹窗动画停在半像素位置会让文章图片顶线消失</title>
    <link href="https://createskyblue.github.io/posts/article-modal-subpixel-frame-debugging/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/article-modal-subpixel-frame-debugging/</id>
    <published>2026-06-13T00:00:00.000Z</published>
    <updated>2026-06-13T00:00:00.000Z</updated>
    <summary>弹窗边框出现细线，从布局和渲染精度排查</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>弹窗动画停在半像素位置会让文章图片顶线消失</h1>
<p>文章弹窗里的图片出现过一个渲染异常：图片顶部的 1px 线框会消失，看起来像图片内容向上压住了外部线框。鼠标框选图片后，线框又恢复正常。</p>
<p>直接改图片样式很难稳定复现和修复。异常状态和恢复后的 computed style 对比后，图片自身规则没有实质差异。真正触发风险的是弹窗动画把内容层长期留在半像素 transform 合成层里，1px 顶线在取整时被吃掉。</p>
<h2>有效线索</h2>
<p>异常状态下，文章图片继承的 CSS 变量里有这样的入场位移：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">--modal-enter-x: -192.5px;</span></span>
<span class="line"><span style="color:#D4D4D4">--modal-enter-y: -786.5px;</span></span></code></pre><p>这两个 <code>.5px</code> 值来自弹窗入场动画。原结构把弹窗外层固定在视口中心：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D7BA7D">.modal-outer</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#9CDCFE">  position</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">fixed</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">  top</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">50%</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">  left</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">50%</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">  transform</span><span style="color:#D4D4D4">: </span><span style="color:#DCDCAA">translate</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">-50%</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">-50%</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>入场动画也直接作用在同一个外层元素上。视口尺寸、卡片点击位置和弹窗尺寸组合后，最终坐标容易落在半像素。浏览器在 transform 合成层中绘制图片、圆角和 1px 线框时，会出现取整差异。鼠标框选触发重绘，线框临时恢复，所以看起来像“选一下就好了”。</p>
<h2>修复方式</h2>
<p>修复点放在弹窗层级，避免继续堆叠图片规则。</p>
<ol>
<li><code>modal-outer</code> 改成全屏 fixed 容器，用 flex 居中，不再使用 <code>top: 50%</code> 和 <code>translate(-50%, -50%)</code>。</li>
<li>新增 <code>modal-shell</code> 包住目录栏和文章主体，入场/离场动画只作用在这个内部壳层。</li>
<li>给 Vue <code>&lt;Transition&gt;</code> 显式声明 enter/leave 时长，避免外层没有 transition 后过渡类无法按预期清理。</li>
<li>动画结束态让 <code>modal-shell</code> 回到 <code>transform: none</code>，文章内容不再长期处在 transform 合成层里。</li>
<li>图片仍保留 <code>display: block</code>，线框改用 <code>box-shadow: 0 0 0 1px black</code>，降低 inline 布局和边框绘制带来的额外变量。</li>
</ol>
<p>核心结构变成：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#808080">&#x3C;</span><span style="color:#569CD6">div</span><span style="color:#9CDCFE"> class</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"modal-outer"</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">  &#x3C;</span><span style="color:#569CD6">div</span><span style="color:#9CDCFE"> class</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"modal-shell"</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">    &#x3C;</span><span style="color:#569CD6">aside</span><span style="color:#9CDCFE"> class</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"chapter-panel"</span><span style="color:#808080">>&#x3C;/</span><span style="color:#569CD6">aside</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">    &#x3C;</span><span style="color:#569CD6">div</span><span style="color:#9CDCFE"> class</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"modal-wrapper"</span><span style="color:#808080">>&#x3C;/</span><span style="color:#569CD6">div</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">  &#x3C;/</span><span style="color:#569CD6">div</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">div</span><span style="color:#808080">></span></span></code></pre><p>关键样式变成：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D7BA7D">.modal-outer</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#9CDCFE">  position</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">fixed</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">  inset</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">  display</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">flex</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">  align-items</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">center</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">  justify-content</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">center</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">  pointer-events</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">none</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D7BA7D">.modal-shell</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#9CDCFE">  display</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">flex</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">  transform</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">none</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">  pointer-events</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">auto</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D7BA7D">.modal-enter-from</span><span style="color:#D7BA7D"> .modal-shell</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#9CDCFE">  transform</span><span style="color:#D4D4D4">: </span><span style="color:#DCDCAA">translate</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#DCDCAA">    var</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">--modal-enter-x</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0px</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#DCDCAA">    var</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">--modal-enter-y</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0px</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">  ) </span><span style="color:#DCDCAA">scale</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0.004</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D7BA7D">.modal-enter-to</span><span style="color:#D7BA7D"> .modal-shell</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D7BA7D">.modal-leave-from</span><span style="color:#D7BA7D"> .modal-shell</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#9CDCFE">  transform</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">none</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h2>验证方式</h2>
<p>这类问题不能只看 computed style。异常和正常状态的 computed style 可能完全一样，差异存在于合成层、动画类和实际像素坐标。</p>
<p>验证时检查三个点：</p>
<ol>
<li>动画结束后 <code>.modal-outer</code> 的 <code>transform</code> 为 <code>none</code>。</li>
<li>动画结束后 <code>.modal-shell</code> 的 <code>transform</code> 为 <code>none</code>。</li>
<li><code>.article-body img</code> 保持 <code>display: block</code>，线框规则为 <code>box-shadow: 0 0 0 1px black</code>。</li>
</ol>
<p>对应的回归测试可以直接检查源码约束，防止后续又把 transform 居中加回外层弹窗。</p>
<h2>结论</h2>
<p>图片线框丢失不一定是图片样式问题。弹窗、浮层、缩放动画、半像素居中会改变子元素的绘制上下文。遇到“鼠标框选后恢复”的 UI 异常，应优先检查是否存在长期保留的 transform、filter、backdrop-filter 或动画类。</p>
<p>这次修复的原则是：外层负责固定定位和居中，内层负责短暂动画，动画结束后内容层回到普通布局。这样能保留打开动画，同时减少图片、文字和 1px 线框的合成层取整风险。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="CSS"/>
    <category term="Vue"/>
    <category term="渲染调试"/>
  </entry>
  <entry>
    <title>手机不在场也不丢数：BLE 设备先落盘后同步，连接失败下次补传</title>
    <link href="https://createskyblue.github.io/posts/ble-low-power-data-upload/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ble-low-power-data-upload/</id>
    <published>2026-06-13T00:00:00.000Z</published>
    <updated>2026-06-13T00:00:00.000Z</updated>
    <summary>电池 BLE 非实时数据同步：定时唤醒、先存后传、失败补传</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>手机不在场也不丢数：BLE 设备先落盘后同步，连接失败下次补传</h1>
<p>一次性电池供电的监测设备采集的多是非实时数据：环境状态、睡眠记录、健康记录、资产状态、周期告警。这类数据不要求手机立即收到，但要求设备长期运行且数据最终能同步出去。设备不能长期开广播等连接，否则平均功耗上升，直接吃掉电池寿命。可行策略是：平时深度休眠，到点采集并先落盘，只在约定时间短暂开蓝牙；手机按约定时间找设备，连上后同步。核心是&quot;数据先落地，蓝牙后同步&quot;，连接失败不丢数，下次补。</p>
<p><img src="/posts/ble-low-power-data-upload/ble-low-power-data-upload-flow.webp" alt="一次性电池蓝牙数据交换方案"></p>
<h2>适用前提</h2>
<ol>
<li>一次性电池供电，目标是长时间运行。</li>
<li>数据不要求秒级实时上传。</li>
<li>手机不需要随时控制设备。</li>
<li>设备有本地存储，且双方能在上次连接时约定下次同步时间。</li>
</ol>
<p>要求手机随时连接、实时查看或实时控制的产品不适用，蓝牙在线时间必须提高，功耗预算要重新算。同步也不该依赖人工按键：设备常放在床垫、贴片、柜内等不便接触的位置，用户不该为每次同步去操作设备。按键只保留作调试、首次绑定和售后入口。</p>
<h2>数据交换流程</h2>
<p>每次连接成功后双方记录下一次同步时间，然后进入下一轮：</p>
<ol>
<li>休眠：关蓝牙和传感器，只留唤醒计时。</li>
<li>采集：到点唤醒，完成本轮记录。</li>
<li>保存：写入序号、时间、上传状态。</li>
<li>等待同步：到约定时间后短暂开蓝牙。</li>
<li>同步：手机提前扫描并连接，读取本轮和历史未同步数据。</li>
<li>确认：手机确认收到后，设备标记为已同步。</li>
<li>记录下一次同步时间，关蓝牙，回到休眠。</li>
</ol>
<p>蓝牙活动被限制在第 4 到 6 步的短窗口内，其余时间不广播、不保持连接。</p>
<h2>连接失败处理</h2>
<p>手机可能不在附近，也可能被系统后台限制没能及时扫描到。设备端不能假设本次一定连上，所以先保存再同步：本次没连上就关蓝牙继续休眠，数据留在本地，下次连接成功时补齐。连接失败只推迟数据到达手机的时间，不造成丢失。</p>
<p>手机端要保存设备返回的下次同步时间并提前扫描，连上后同步所有未完成数据并回确认，避免历史记录长期滞留在设备端。</p>
<h2>实施要点</h2>
<p>设备侧：</p>
<ul>
<li>本地数据队列，每条带序号、时间、同步状态。</li>
<li>同步确认机制，手机确认后才更新已同步状态。</li>
<li>同步时间管理，每次连接后双方更新时间。</li>
</ul>
<p>手机侧：</p>
<ul>
<li>记录下次同步时间，提前进入扫描。</li>
<li>连接后补齐所有未同步数据。</li>
</ul>
<h2>结论</h2>
<p>用同步延迟换电池寿命，靠本地保存和确认机制保证数据最终可达。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="BLE"/>
    <category term="低功耗"/>
    <category term="一次性电池"/>
    <category term="蓝牙"/>
  </entry>
  <entry>
    <title>BLE 连接中重置硬件 RTC 会导致链路断开</title>
    <link href="https://createskyblue.github.io/posts/ble-rtc-offset-note/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ble-rtc-offset-note/</id>
    <published>2026-06-13T00:00:00.000Z</published>
    <updated>2026-06-13T00:00:00.000Z</updated>
    <summary>WCH蓝牙芯片设置RTC时间导致蓝牙断连问题排查</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>BLE 连接中重置硬件 RTC 会导致链路断开</h1>
<p>背景是一个一次性电池供电的 BLE 健康监测设备。设备平时休眠，定时唤醒采集数据，再通过手机连接上传。调试时增加了两个 BLE 指令：设置 RTC 时间和读取 RTC 时间。</p>
<p>问题出现在 <code>SET_RTC</code>：手机通过 BLE 写入当前时间，固件解析成功，也返回了状态帧，但浏览器马上显示设备断开。串口日志能看到指令已经进入处理函数，<code>Notify</code> 也返回成功，断开点集中在调用 <code>RTC_InitTime()</code> 之后。</p>
<p>原因是这类芯片的硬件 RTC 不只表示业务时间。BLE 协议栈、低功耗唤醒、系统定时器也依赖同一套 32k 计数时基。连接过程中直接重置硬件 RTC，会让底层时间基准跳变，BLE 链路可能被打断。</p>
<p>最后的处理方式是把业务时间和硬件 RTC 分开：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>业务时间 = 硬件 RTC 单调计数换算时间 + 业务时间偏移</span></span></code></pre><p><code>SET_RTC</code> 不再调用 <code>RTC_InitTime()</code>，只记录手机时间和当前硬件 RTC 时间之间的偏移。<code>GET_RTC</code> 返回当前硬件 RTC 时间加上这个偏移。这样设备仍然能对外表现为已经同步 RTC，同时不破坏 BLE 和低功耗系统依赖的底层时基。</p>
<p>真正需要写硬件 RTC 的场景，应放在 BLE 协议栈停止后处理，例如断开连接、停止广播，再设置硬件 RTC，必要时重新初始化 BLE 或重启设备。对于持续运行的低功耗 BLE 设备，业务时间偏移是更稳的设计边界。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="BLE"/>
    <category term="RTC"/>
    <category term="低功耗"/>
    <category term="嵌入式"/>
  </entry>
  <entry>
    <title>用 AI 把 ESP32 心率算法迁移到 WCH 蓝牙芯片</title>
    <link href="https://createskyblue.github.io/posts/heart-rate-migration-debugging/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/heart-rate-migration-debugging/</id>
    <published>2026-06-13T00:00:00.000Z</published>
    <updated>2026-06-13T00:00:00.000Z</updated>
    <summary>用 AI 把 ESP32 心率算法迁移到 WCH 蓝牙芯片</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用 AI 把 ESP32 心率算法迁移到 WCH 蓝牙芯片</h1>
<p><img src="/posts/heart-rate-migration-debugging/PixPin_2026-06-13_00-13-40.webp" alt="WCH 蓝牙芯片心率算法迁移调试记录"></p>
<h2>迁移目标</h2>
<p>原来的心率算法已经在 ESP32 项目里稳定运行。本次目标是迁移到 WCH 蓝牙芯片，并让新硬件保持稳定的心率输出。</p>
<p>这是第一次使用 WCH 家的蓝牙芯片。迁移时将原项目工程、WCH SDK 和官方示例一起交给 GPT-5.5 辅助理解，再配合人工调试和实机验证。</p>
<h2>初版问题</h2>
<p>初版移植后，程序能运行，但结果不能直接接受。</p>
<p>主要有两个问题：一是计算速度太慢，一次心率计算大约需要 4500ms；二是实机读数会跳，和预期结果不一致。</p>
<p>这两个问题如果只看表面，很容易误判成算法本身不稳定。实际处理时，先把它们拆开验证。</p>
<h2>同一份波形做两路验证</h2>
<p><img src="/posts/heart-rate-migration-debugging/PixPin_2026-06-13_00-33-22.webp" alt="仿真工具中的心率波形与参考结果"></p>
<p>这一步用到的是 <a href="https://github.com/createskyblue/signal-analysis-lab">signal-analysis-lab</a>。这个工具最初就是为了解决调参太慢的问题：用可拖拽节点先搭出信号处理流程，把滤波、窗口和参数效果在仿真里看清楚，再把稳定下来的方案固化成代码。</p>
<p>排查时先从仿真软件里导出两样东西：一份原始波形数据，一份对应的参考答案。原始波形用于重复输入，参考答案用于判断结果是否正确。</p>
<p>后续验证分成两路。第一路是本地静态测试：AI 用 GCC 编译算法测试程序，把同一份原始波形喂进去，再把输出结果和参考答案放进质量评估公式。这样可以先确认代码改动有没有破坏算法结果。</p>
<p>第二路是实机验证：把同一份原始波形导入外部信号发生器，让信号发生器输出给 WCH 芯片的 ADC。</p>
<p><img src="/posts/heart-rate-migration-debugging/PixPin_2026-06-13_00-35-13.webp" alt="外部信号发生器输出已知波形给 ADC"></p>
<p>设备端会通过串口打印计算出的心率结果，AI 再读取串口输出，进入同一套质量评估。</p>
<p>这里没有要求实机结果和仿真参考答案逐点完全一致。仿真导出、信号重放和 ADC 采样之间会有量化误差，也可能因为窗口边界和因果处理带来轻微偏移。</p>
<p>所以判断方式是质量评估：综合看平均偏差、最大偏差、连续结果的跳变幅度，以及是否出现明显离谱值。只要偏差在合理范围内、趋势稳定、没有异常跳变，就说明迁移结果是可信的。</p>
<p>这个对照很关键。本地静态测试负责确认算法改动是否正确，信号发生器和串口结果负责确认实机采样和运行过程是否正确。两边输入相同，评价标准也一致，结果就不能靠感觉解释。</p>
<p>环境搭好以后，这个流程可以持续复用。AI 可以完成本地编译、静态测试、固件编译、烧录、串口采集和结果对比，人工主要负责判断异常现象是否符合产品预期。</p>
<h2>优化慢计算</h2>
<p>先处理计算太慢的问题。排查后发现，初版里有一些重复计算，同一段历史数据被反复统计。后面把这部分改成滚动累加和缓存更新。</p>
<p>这个改法没有改变算法结果，只是减少重复劳动。改完后继续用同一份原始数据验证，确认输出一致。计算耗时从约 4500ms 降到约 400-500ms，新硬件才有足够余量继续采样和输出。</p>
<h2>修复共用缓冲区</h2>
<p>速度问题解决后，继续处理读数跳变。这个问题来自 AI 初版移植没有严格保留 ESP32 原工程里的缓冲设计。</p>
<p>问题很直接：采样和算法处理共用了同一块数据区。采样还在写入，算法同时读取这块数据，读数就会被干扰。</p>
<p>后面按原项目思路把两边隔开。采样只负责写入，算法只处理已经完整交付的数据块。</p>
<h2>处理结果</h2>
<p>修改后，实机结果和仿真趋势重新对齐，心率输出恢复稳定。</p>
<p>这次一个下午完成了迁移、性能优化和稳定性修复。AI 确实缩短了阅读工程、理解 SDK 和修改代码的时间。</p>
<p>最终证明结果可靠的依据，是同一份波形、本地静态测试、信号发生器输入、串口结果采集、质量评估公式和每一步修改后的对照验证。</p>
<h2>复盘结论</h2>
<p>这次最大的收获，是 AI 开始能参与更多工程自动化工作。</p>
<p>前一层是用 AI 造工具。signal-analysis-lab 先把调参、滤波和波形验证这些脏活累活放到可视化仿真里完成，等方案稳定后再固化成代码。</p>
<p>后一层是用 AI 做迁移。固件移植后的效果判断，依赖移植版本持续向仿真软件里的结果对齐。</p>
<p>AI 可以让项目推进更快，也可以让一些原本重复、琐碎、容易出错的验证工作变成流程。工程判断仍然重要，但它可以更多放在目标、边界和结果验收上。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="算法迁移"/>
    <category term="性能优化"/>
    <category term="数据采集"/>
  </entry>
  <entry>
    <title>移动端实时图表应先安排绘图区再调整样式</title>
    <link href="https://createskyblue.github.io/posts/mobile-chart-ui-design/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/mobile-chart-ui-design/</id>
    <published>2026-06-10T00:00:00.000Z</published>
    <updated>2026-06-10T00:00:00.000Z</updated>
    <summary>手机实时图表刻度和端点拥挤，先划分绘图区再调样式</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>移动端实时图表应先安排绘图区再调整样式</h1>
<p>移动端实时图表最先暴露的问题通常是空间分配。屏幕宽度变小以后，曲线、刻度、端点、标题、辅助文字和卡片边距都会挤在一起。先决定每个元素占据哪块区域，再讨论颜色、圆角和线宽。</p>
<p>实时图表还多一个要求：用户的视线会持续落在最新端。右侧区域承担了端点、刻度和状态读取三件事，必须预留空间。</p>
<h2>卡片少用边框</h2>
<p>移动端卡片一旦占满屏幕宽度，边框会特别显眼。多个卡片上下排列时，边框会把页面切成很多小盒子，图表本身反而被削弱。</p>
<p>更稳的做法是用底色和间距分层：</p>
<ul>
<li>页面背景用浅灰。</li>
<li>卡片保持白底。</li>
<li>卡片之间留出明确间距。</li>
<li>卡片边框去掉或降到几乎不可见。</li>
</ul>
<p>这样会让页面更安静。容器退到后面，图表线条、数字和端点成为主要信息。</p>
<h2>右端空心圆是视觉锚点</h2>
<p>实时曲线的最右侧适合放一个空心圆。它用于标出“这里是当前端点”。</p>
<p>空心圆给用户的感受很直接：</p>
<ul>
<li>画面仍在更新。</li>
<li>曲线的时间方向明确。</li>
<li>最新点被强调，但不会像按钮一样跳出来。</li>
</ul>
<p>实心圆在手机上容易变成一个重色块。空心圆只保留轮廓，视觉重量更轻。描边宽度应当接近曲线线宽，过粗会压住曲线，过细会丢失存在感。</p>
<h2>Y 轴刻度放到右侧</h2>
<p>实时图表的最新点在右侧。用户看曲线时，视线通常会从左向右移动，最后停在右端。把 Y 轴刻度放到右侧，可以让刻度靠近视线终点。</p>
<p>这个处理有三个好处：</p>
<ul>
<li>读最新点高度时，视线移动更短。</li>
<li>左侧留给曲线进入画面，不被刻度占掉。</li>
<li>右端空心圆和刻度形成同一个读取区域。</li>
</ul>
<p>右侧刻度不能直接压在曲线上。右侧应当留一条固定 gutter，曲线画到 gutter 左边，刻度文字放在 gutter 里面。线条和文字分开以后，图表会干净很多。</p>
<h2>图表四边都要留空</h2>
<p>图表填满画布会显得紧。移动端高度有限，更需要给四边留空。</p>
<p>顶部留白用于最高刻度。最高刻度贴到顶部时，文字会被裁切，线条也会顶住边缘。</p>
<p>底部留白用于最低刻度和端点轮廓。圆点贴到底部时，会像被切掉一截。</p>
<p>右侧留白用于刻度文字和最新端点。曲线画到文字下面，会立刻显得脏。</p>
<p>左侧也需要留一点空气。曲线从边缘冲出来，会让图表像贴在卡片边上。</p>
<h2>辅助文字不要做成徽章</h2>
<p>图表右上角常放时间范围、筛选、状态之类的辅助信息。它们应当是弱信息。最常见的问题是把每一项都做成圆角徽章，看起来像一排按钮。</p>
<p>更好的处理：</p>
<ul>
<li>用普通文本。</li>
<li>降低字号和颜色。</li>
<li>信息项之间用细分隔线。</li>
<li>不给每一项单独加背景。</li>
</ul>
<p>辅助信息的视觉等级要低于曲线和端点。它们只负责解释图表，不负责吸引注意力。</p>
<h2>移动端边距不能省</h2>
<p>手机屏幕窄，很多设计会下意识减少左右边距。结果卡片贴满屏幕，图表也跟着变窄，整个页面会显得廉价。</p>
<p>卡片需要留出侧边距。这个边距不用大，但要让用户看到页面背景。背景露出来以后，卡片才像一个独立区域，图表也不会压迫屏幕边缘。</p>
<p>图表高度也要稳定。移动端高度可以比桌面低，但比例不能被浏览器拉伸。文字如果出现纵向压扁，通常说明画布实际绘制尺寸和显示尺寸不一致。</p>
<h2>结论</h2>
<p>移动端实时图表的设计顺序应当是：</p>
<ul>
<li>先确定卡片边距。</li>
<li>再确定绘图区和右侧刻度区。</li>
<li>再确定上下留白。</li>
<li>最后调整颜色、线宽和端点。</li>
</ul>
<p>无边框、右侧刻度、空心端点、右侧 gutter、弱化辅助文字，这几项比复杂配色更重要。图表先干净，样式才有意义。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="移动端UI"/>
    <category term="图表设计"/>
    <category term="视觉设计"/>
  </entry>
  <entry>
    <title>ESP32 AP 模式下通过 DNS 劫持实现强制门户跳转</title>
    <link href="https://createskyblue.github.io/posts/esp32-dns-hijack-captive-portal/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp32-dns-hijack-captive-portal/</id>
    <published>2026-06-09T00:00:00.000Z</published>
    <updated>2026-06-09T00:00:00.000Z</updated>
    <summary>实现连接ESP32热点后自动弹出网页</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>ESP32 AP 模式下通过 DNS 劫持实现强制门户跳转</h1>
<p>ESP32 开启热点时，需要一个机制让连接上来的设备自动打开控制面板网页，而不是让用户手动输入 IP 地址。这个机制在酒店/机场 WiFi 中很常见——连上热点后自动弹出一个登录页面，这就是强制门户（Captive Portal）。</p>
<p>实现强制门户需要三个环节配合：DHCP 通告 DNS、DNS 劫持、HTTP 302 跳转。</p>
<h2>整体流程</h2>
<ol>
<li>设备连接 ESP32 热点，通过 DHCP 获取 IP，同时被告知 DNS 服务器地址为 <code>192.168.4.1</code></li>
<li>操作系统在后台发送强制门户检测请求（如 <code>http://captive.apple.com/hotspot-detect.html</code>）</li>
<li>DNS 查询被 ESP32 上的 DNS 服务拦截，全部返回 ESP32 自身 IP</li>
<li>HTTP 请求到达 ESP32 的 Web 服务器，未知路径返回 302 重定向到 <code>/</code></li>
<li>操作系统收到非预期响应，识别为强制门户，弹出浏览器窗口</li>
</ol>
<h2>DHCP 通告 DNS 服务器</h2>
<p>首先让 DHCP 在分配 IP 时告诉客户端：DNS 服务器就是 ESP32 自己。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#4EC9B0">esp_netif_dns_info_t</span><span style="color:#D4D4D4"> dns_info = {</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#D4D4D4">dns_info.ip.u_addr.ip4.addr = </span><span style="color:#DCDCAA">esp_ip4addr_aton</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"192.168.4.1"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">dns_info.ip.type = ESP_IPADDR_TYPE_V4;</span></span>
<span class="line"><span style="color:#DCDCAA">esp_netif_dhcps_option</span><span style="color:#D4D4D4">(s_wifi_ap_netif, ESP_NETIF_OP_SET,</span></span>
<span class="line"><span style="color:#D4D4D4">                       ESP_NETIF_DOMAIN_NAME_SERVER,</span></span>
<span class="line"><span style="color:#D4D4D4">                       &#x26;</span><span style="color:#9CDCFE">dns_info</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(dns_info));</span></span></code></pre><p><code>esp_netif_dhcps_option</code> 直接修改 ESP-IDF 内置 DHCP 服务器的配置，不需要额外引入 DHCP 库。</p>
<h2>DNS 劫持服务</h2>
<p>核心是一个 FreeRTOS 任务，创建 UDP socket 监听 53 端口，对所有 DNS 查询返回一个 A 记录，IP 指向 ESP32 自身的 AP 地址 <code>192.168.4.1</code>。</p>
<p>完整代码如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> DNS_PORT </span><span style="color:#B5CEA8">53</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> DNS_MAX_QUERY_LEN </span><span style="color:#B5CEA8">512</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> dns_server_task</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">arg</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#D4D4D4">    (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)arg;</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> sockaddr_in addr = {</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#9CDCFE">    addr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">sin_family</span><span style="color:#D4D4D4"> = AF_INET;</span></span>
<span class="line"><span style="color:#9CDCFE">    addr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">sin_addr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">s_addr</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">htonl</span><span style="color:#D4D4D4">(INADDR_ANY);</span></span>
<span class="line"><span style="color:#9CDCFE">    addr</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">sin_port</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">htons</span><span style="color:#D4D4D4">(DNS_PORT);</span></span>
<span class="line"><span style="color:#569CD6">    int</span><span style="color:#D4D4D4"> sock = </span><span style="color:#DCDCAA">socket</span><span style="color:#D4D4D4">(AF_INET, SOCK_DGRAM, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (sock &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        ESP_LOGE</span><span style="color:#D4D4D4">(TAG, </span><span style="color:#CE9178">"dns: socket create failed"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">        vTaskDelete</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">bind</span><span style="color:#D4D4D4">(sock, (</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> sockaddr *)&#x26;addr, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(addr)) &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        ESP_LOGE</span><span style="color:#D4D4D4">(TAG, </span><span style="color:#CE9178">"dns: bind failed"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">        close</span><span style="color:#D4D4D4">(sock);</span></span>
<span class="line"><span style="color:#DCDCAA">        vTaskDelete</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#DCDCAA">    ESP_LOGI</span><span style="color:#D4D4D4">(TAG, </span><span style="color:#CE9178">"dns hijack server started on port </span><span style="color:#9CDCFE">%u</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, DNS_PORT);</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#9CDCFE"> buf</span><span style="color:#D4D4D4">[DNS_MAX_QUERY_LEN];</span></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">        struct</span><span style="color:#D4D4D4"> sockaddr_in from = {</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#4EC9B0">        socklen_t</span><span style="color:#D4D4D4"> fromlen = </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(from);</span></span>
<span class="line"><span style="color:#569CD6">        int</span><span style="color:#D4D4D4"> len = </span><span style="color:#DCDCAA">recvfrom</span><span style="color:#D4D4D4">(sock, buf, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(buf), </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">                           (</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> sockaddr *)&#x26;from, &#x26;fromlen);</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (len &#x3C; </span><span style="color:#B5CEA8">12</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">continue</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">        uint8_t</span><span style="color:#9CDCFE"> response</span><span style="color:#D4D4D4">[DNS_MAX_QUERY_LEN];</span></span>
<span class="line"><span style="color:#DCDCAA">        memcpy</span><span style="color:#D4D4D4">(response, buf, (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4">)len);</span></span>
<span class="line"><span style="color:#9CDCFE">        response</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">] |= </span><span style="color:#B5CEA8">0x80</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">        response</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">] |= </span><span style="color:#B5CEA8">0x80</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">        response</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">6</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">0x00</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">        response</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">7</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">0x01</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">        size_t</span><span style="color:#D4D4D4"> answer_off = (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4">)len;</span></span>
<span class="line"><span style="color:#569CD6">        uint8_t</span><span style="color:#D4D4D4"> answer</span><span style="color:#569CD6">[]</span><span style="color:#D4D4D4"> = {</span></span>
<span class="line"><span style="color:#B5CEA8">            0xC0</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0x0C</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#B5CEA8">            0x00</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0x01</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#B5CEA8">            0x00</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0x01</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#B5CEA8">            0x00</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0x00</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0x00</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">60</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#B5CEA8">            0x00</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0x04</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#B5CEA8">            0x00</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0x00</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0x00</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0x00</span></span>
<span class="line"><span style="color:#D4D4D4">        };</span></span>
<span class="line"><span style="color:#DCDCAA">        memcpy</span><span style="color:#D4D4D4">(response + answer_off, answer, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(answer));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">        esp_netif_ip_info_t</span><span style="color:#D4D4D4"> ip_info;</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (s_wifi_ap_netif &#x26;&#x26; </span><span style="color:#DCDCAA">esp_netif_get_ip_info</span><span style="color:#D4D4D4">(s_wifi_ap_netif, &#x26;ip_info) == ESP_OK) {</span></span>
<span class="line"><span style="color:#569CD6">            uint32_t</span><span style="color:#D4D4D4"> ip = </span><span style="color:#9CDCFE">ip_info</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">ip</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">addr</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">            memcpy</span><span style="color:#D4D4D4">(response + answer_off + </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(answer) - </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">, &#x26;ip, </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">        } </span><span style="color:#C586C0">else</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#9CDCFE">            response</span><span style="color:#D4D4D4">[answer_off + </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(answer) - </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">192</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">            response</span><span style="color:#D4D4D4">[answer_off + </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(answer) - </span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">168</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">            response</span><span style="color:#D4D4D4">[answer_off + </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(answer) - </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">            response</span><span style="color:#D4D4D4">[answer_off + </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(answer) - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">] = </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">        sendto</span><span style="color:#D4D4D4">(sock, response, answer_off + </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(answer), </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">               (</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> sockaddr *)&#x26;from, fromlen);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> dns_server_start</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    xTaskCreate</span><span style="color:#D4D4D4">(dns_server_task, </span><span style="color:#CE9178">"dns_server"</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">3072</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">5</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>代码要点</h3>
<p><strong>最小 DNS 报文验证。</strong> <code>if (len &lt; 12) continue</code> 跳过不完整的请求。DNS 头部固定 12 字节，小于这个长度一定是无效包。</p>
<p><strong>复用查询包构造响应。</strong> <code>memcpy(response, buf, len)</code> 把原始查询报文复制到响应缓冲区。这样做的好处是自动保留了 Transaction ID（前 2 字节）和 Question 段，客户端才能匹配请求和响应。</p>
<p><strong>修改标志位。</strong> <code>response[2] |= 0x80</code> 将 QR 位置 1，表示这是响应而非查询。<code>response[3] |= 0x80</code> 设置 RA（Recursion Available），告知客户端服务器支持递归查询。</p>
<p><strong>设置回答计数。</strong> <code>response[6]=0x00, response[7]=0x01</code> 表示 Answer RRs = 1。后续追加一个 A 记录回答。</p>
<p><strong>构造 DNS 回答段。</strong> 16 字节的固定回答模板：</p>
<table>
<thead>
<tr>
<th>偏移</th>
<th>字节</th>
<th>含义</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td><code>0xC0 0x0C</code></td>
<td>域名指针，指向查询报文偏移 12 处的域名</td>
</tr>
<tr>
<td>2</td>
<td><code>0x00 0x01</code></td>
<td>Type = A (Host Address)</td>
</tr>
<tr>
<td>4</td>
<td><code>0x00 0x01</code></td>
<td>Class = IN (Internet)</td>
</tr>
<tr>
<td>6</td>
<td><code>0x00 0x00 0x00 60</code></td>
<td>TTL = 60 秒</td>
</tr>
<tr>
<td>10</td>
<td><code>0x00 0x04</code></td>
<td>RD Length = 4 字节</td>
</tr>
<tr>
<td>12</td>
<td>4 字节 IP</td>
<td>回答的 IPv4 地址</td>
</tr>
</tbody></table>
<p>使用 <code>0xC0 0x0C</code> 域名指针而不是重复拷贝域名，节省了响应报文长度。<code>0xC0</code> 表示这是一个压缩指针，<code>0x0C</code> 指向 DNS 报文偏移 12（即紧接头部之后的 Question 域名段）。</p>
<p><strong>填充 ESP32 AP IP。</strong> 优先从 <code>s_wifi_ap_netif</code> 获取真实的 AP 地址，fallback 硬编码 <code>192.168.4.1</code>（ESP-IDF 默认 AP IP）。</p>
<h2>HTTP 302 跳转</h2>
<p>DNS 劫持把请求送到了 ESP32，但 Web 服务器需要对未知路径做出响应。返回 404 不会触发强制门户检测——系统可能认为这只是网络不通。返回 302 重定向到 <code>/</code> 则被系统识别为强制门户，同时浏览器自动加载首页。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">FILE *file = </span><span style="color:#DCDCAA">fopen</span><span style="color:#D4D4D4">(path, </span><span style="color:#CE9178">"r"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (file == </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">    httpd_resp_set_status</span><span style="color:#D4D4D4">(req, </span><span style="color:#CE9178">"302 Found"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    httpd_resp_set_hdr</span><span style="color:#D4D4D4">(req, </span><span style="color:#CE9178">"Location"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"/"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    httpd_resp_send</span><span style="color:#D4D4D4">(req, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> ESP_OK;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><code>littlefs_static_handler</code> 原本对不存在文件返回 404，改为 302 后所有未知路径（包括各操作系统的强制门户检测 URL）都会被重定向到 <code>/</code>。</p>
<h2>各平台强制门户检测 URL</h2>
<table>
<thead>
<tr>
<th>平台</th>
<th>检测 URL</th>
</tr>
</thead>
<tbody><tr>
<td>iOS / macOS</td>
<td><code>http://captive.apple.com/hotspot-detect.html</code></td>
</tr>
<tr>
<td>Android</td>
<td><code>http://connectivitycheck.gstatic.com/generate_204</code></td>
</tr>
<tr>
<td>Windows</td>
<td><code>http://www.msftconnecttest.com/connecttest.txt</code></td>
</tr>
</tbody></table>
<p>这些 URL 的域名经 DNS 劫持后全部解析到 ESP32，HTTP 请求被 302 重定向，触发各自系统的强制门户弹窗。</p>
<h2>启动方式</h2>
<p>在 <code>wifi_init()</code> 中，<code>esp_wifi_start()</code> 之后调用 <code>dns_server_start()</code> 即可。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">ESP_ERROR_CHECK</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">esp_wifi_start</span><span style="color:#D4D4D4">());</span></span>
<span class="line"><span style="color:#DCDCAA">dns_server_start</span><span style="color:#D4D4D4">();</span></span></code></pre><p>不需要单独管理 DNS 任务的生命周期——WiFi 初始化后启动，随 ESP32 一直运行。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="ESP32"/>
    <category term="DNS劫持"/>
    <category term="强制门户"/>
    <category term="Captive Portal"/>
    <category term="FreeRTOS"/>
  </entry>
  <entry>
    <title>系统状态不可见时不要先急着调整控制参数</title>
    <link href="https://createskyblue.github.io/posts/observability-before-control/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/observability-before-control/</id>
    <published>2026-06-09T00:00:00.000Z</published>
    <updated>2026-06-09T00:00:00.000Z</updated>
    <summary>排查概率性问题全靠猜？钱学森说要先搞定可观测性</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>系统状态不可见时不要先急着调整控制参数</h1>
<p>工程调试里经常会直接进入调参、改策略、换实现。但系统状态不可见时，这些控制动作缺少依据，结果很容易变成反复试错。</p>
<p>控制之前有一个更基础的问题：系统是否能被观测。</p>
<p>一条更实际的链路是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>观测</span></span>
<span class="line"><span>↓</span></span>
<span class="line"><span>理解</span></span>
<span class="line"><span>↓</span></span>
<span class="line"><span>控制</span></span>
<span class="line"><span>↓</span></span>
<span class="line"><span>反馈</span></span>
<span class="line"><span>↓</span></span>
<span class="line"><span>优化</span></span></code></pre><p>如果系统状态不可见，后续控制和优化都会退化成猜测。参数可以调整，代码可以修改，策略可以切换，但这些动作没有稳定依据。</p>
<h2>调试能力经常被误判</h2>
<p>很多团队会把问题归因到人的经验差异：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>新人不会调试</span></span>
<span class="line"><span>老工程师会调试</span></span></code></pre><p>这个判断只描述了表象。真实问题经常是系统缺少可观测性：</p>
<ul>
<li>没有关键路径日志</li>
<li>没有运行时统计信息</li>
<li>没有监控指标</li>
<li>没有状态记录</li>
<li>没有关键变量暴露</li>
</ul>
<p>在这种条件下，新人在猜，老工程师也在猜。区别通常在于，老工程师更早停止猜测，转而补齐观测点。</p>
<p>有效调试的第一步应当转向确认系统内部发生了什么，随后再决定是否修改参数。</p>
<h2>缺少观测会把问题变成玄学</h2>
<p>当系统缺少观测点时，常见流程会变成：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>出现异常</span></span>
<span class="line"><span>↓</span></span>
<span class="line"><span>无法定位</span></span>
<span class="line"><span>↓</span></span>
<span class="line"><span>修改参数</span></span>
<span class="line"><span>↓</span></span>
<span class="line"><span>再次尝试</span></span>
<span class="line"><span>↓</span></span>
<span class="line"><span>继续失败</span></span></code></pre><p>多循环几次之后，团队会开始依赖经验、直觉和运气。问题看起来像玄学，本质是关键状态没有暴露出来。</p>
<p>工程系统通常存在因果链路，只是因果链路被隐藏了。只看最终结果，无法区分错误发生在输入、处理、缓存、调度、传输还是输出阶段。</p>
<h2>嵌入式系统中的数据丢失问题</h2>
<p>嵌入式系统里的数据丢失是典型案例。</p>
<p>如果只能看到最终结果：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>数据少了</span></span></code></pre><p>那就无法判断问题来自哪里：</p>
<ul>
<li>ADC 采样异常</li>
<li>DMA 搬运中断</li>
<li>Ring Buffer 覆盖或读写错位</li>
<li>RTOS 任务调度延迟</li>
<li>网络发送队列阻塞</li>
<li>接收端解析或落盘失败</li>
</ul>
<p>这些问题的表现都可能是“数据丢失”，但修复路径完全不同。</p>
<p>此时继续优化代码通常没有意义。更有效的做法是沿数据链路增加观测点：</p>
<ul>
<li>记录 ADC 采样计数</li>
<li>记录 DMA 半满和全满中断次数</li>
<li>记录 Ring Buffer 写入、读取、覆盖次数</li>
<li>记录任务调度延迟和队列深度</li>
<li>记录网络发送成功、失败、重试次数</li>
<li>记录接收端确认和丢包统计</li>
</ul>
<p>当每一段链路都有计数和状态，问题会从“数据为什么没了”变成“哪一段计数开始不一致”。后者才是可定位、可复现、可修复的问题。</p>
<h2>优化前先让系统透明</h2>
<p>不要急着优化一个看不清楚的系统。</p>
<p>工程优先级应该先转向可观测性建设：</p>
<ul>
<li>增加日志</li>
<li>增加指标</li>
<li>增加监控</li>
<li>增加状态暴露</li>
<li>增加统计信息</li>
<li>增加关键路径事件记录</li>
</ul>
<p>这些工作看起来不直接修复问题，但能把系统从黑盒变成灰盒，甚至接近白盒。系统越透明，控制动作越容易形成闭环。</p>
<p>很多时候，可观测性提升带来的收益大于继续调整参数。因为参数优化只影响某个假设下的结果，而观测能力会提高后续所有问题的定位效率。</p>
<h2>AI 更擅长利用观测，人类工程师更擅长设计观测</h2>
<p>AI 可以帮助分析已经暴露出来的信息，例如日志、指标、调用链、配置、代码路径和错误堆栈。信息足够完整时，AI 能快速归纳模式，提出候选原因，生成排查步骤。</p>
<p>但 AI 很难凭空判断系统应该暴露什么：</p>
<ul>
<li>哪些指标最关键</li>
<li>哪些状态必须记录</li>
<li>哪些变量是真正的问题来源</li>
<li>哪些链路需要端到端计数</li>
<li>哪些异常需要保留现场</li>
</ul>
<p>这些判断依赖领域经验、系统结构理解，以及对关键变量的选择能力。</p>
<p>因此，AI 更擅长利用观测结果。人类工程师的核心价值之一，是为系统设计观测方式，让问题在发生时留下足够的信息。</p>
<h2>结论</h2>
<p>控制论在工程实践里的重要启发是：看不见的问题，无法被有效控制。</p>
<p>系统优化之前，先提高可观测性。让输入、状态、链路、队列、计数和异常现场变得可见，控制和反馈才有依据。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="控制论"/>
    <category term="可观测性"/>
    <category term="工程调试"/>
    <category term="系统设计"/>
  </entry>
  <entry>
    <title>画布框选节点变慢时可以用四叉树先筛掉无关区域</title>
    <link href="https://createskyblue.github.io/posts/quadtree-spatial-index/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/quadtree-spatial-index/</id>
    <published>2026-06-08T00:00:00.000Z</published>
    <updated>2026-06-08T00:00:00.000Z</updated>
    <summary>四叉树降低画布框选全量遍历</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>画布框选节点变慢时可以用四叉树先筛掉无关区域</h1>
<h2>从画布框选说起</h2>
<p>画布应用常见一个交互：用户拖拽鼠标拉出矩形选框，把框住的节点全部选中。最直接的实现是遍历所有节点，逐个判断矩形是否相交：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">function</span><span style="color:#DCDCAA"> getNodesInSelection</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">selectRect</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#9CDCFE"> nodes</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">filter</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">node</span><span style="color:#569CD6"> =></span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#4FC1FF"> r</span><span style="color:#D4D4D4"> = { </span><span style="color:#9CDCFE">x:</span><span style="color:#9CDCFE"> node</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">y:</span><span style="color:#9CDCFE"> node</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">y</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">w:</span><span style="color:#9CDCFE"> node</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">el</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">offsetWidth</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">h:</span><span style="color:#9CDCFE"> node</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">el</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">offsetHeight</span><span style="color:#D4D4D4"> };</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> !(</span><span style="color:#9CDCFE">r</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4"> + </span><span style="color:#9CDCFE">r</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">w</span><span style="color:#D4D4D4"> &#x3C; </span><span style="color:#9CDCFE">selectRect</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4"> || </span><span style="color:#9CDCFE">r</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4"> > </span><span style="color:#9CDCFE">selectRect</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">x</span><span style="color:#D4D4D4"> + </span><span style="color:#9CDCFE">selectRect</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">w</span></span>
<span class="line"><span style="color:#D4D4D4">          || </span><span style="color:#9CDCFE">r</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">y</span><span style="color:#D4D4D4"> + </span><span style="color:#9CDCFE">r</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">h</span><span style="color:#D4D4D4"> &#x3C; </span><span style="color:#9CDCFE">selectRect</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">y</span><span style="color:#D4D4D4"> || </span><span style="color:#9CDCFE">r</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">y</span><span style="color:#D4D4D4"> > </span><span style="color:#9CDCFE">selectRect</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">y</span><span style="color:#D4D4D4"> + </span><span style="color:#9CDCFE">selectRect</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">h</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">  });</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>每次 mousemove 都跑一遍。节点数量只有几十到上百个时，这种全量遍历通常够用。</p>
<p>节点数量继续增长后，问题会变成：选框只覆盖画布一小块区域，代码却仍然检查所有节点。</p>
<p>O(n) 遍历的痛点不在于单个判断慢，而在于<strong>绝大部分判断是浪费的</strong>。鼠标框选了画布左上角 3 个节点，代码仍然老老实实地和右下角 497 个节点逐一比对。</p>
<h2>空间索引的思路：把位置当索引用</h2>
<p>数据库用 B-tree 索引避免全表扫描，空间索引也用于减少全量扫描。它的索引键是 (x, y) 坐标。</p>
<p>核心策略只有一句话：<strong>先把&quot;哪些节点可能在选框里&quot;算出来，只对候选节点做精确判断。</strong></p>
<p>这就是&quot;粗筛 → 精筛&quot;两阶段机制：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>原始数据 ──[粗筛]──→ 候选集 ──[精筛]──→ 结果</span></span>
<span class="line"><span> (n个)   空间索引    (k~m个)  AABB判断   (k个)</span></span></code></pre><p>粗筛用空间结构快速淘汰整片不相关的区域，留下少量候选。精筛用精确的矩形相交判断收尾。精筛的复杂度是 O(m)，m 远小于 n。</p>
<h2>四叉树的结构</h2>
<p>四叉树（Quadtree）是最直观的空间索引之一。把画布递归四等分，直到每个格子里节点数小于某个阈值（通常 4~8 个）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>画布 800×600，共 18 个节点</span></span>
<span class="line"><span></span></span>
<span class="line"><span>┌──────────┬──────────┐</span></span>
<span class="line"><span>│ ··       │   ·      │</span></span>
<span class="line"><span>│   NW  ·  │  NE   ·  │</span></span>
<span class="line"><span>│          │     ··   │</span></span>
<span class="line"><span>├──────────┴──────────┤</span></span>
<span class="line"><span>│    ·    │      ·    │</span></span>
<span class="line"><span>│  SW  ·  │   SE ··   │</span></span>
<span class="line"><span>│  ·      │     ·  ·  │</span></span>
<span class="line"><span>└─────────┴───────────┘</span></span>
<span class="line"><span></span></span>
<span class="line"><span>四叉树:</span></span>
<span class="line"><span>              root (800×600)</span></span>
<span class="line"><span>      ┌────────┼────────┬────────┐</span></span>
<span class="line"><span>     NW      NE       SW       SE</span></span>
<span class="line"><span>  (400×300) (400×300)(400×300)(400×300)</span></span>
<span class="line"><span>  [5个节点] [4个节点] [3个节点] [6个节点]</span></span></code></pre><p>每个节点存 4 样东西：</p>
<ul>
<li>四个子象限的引用（NW / NE / SW / SE）</li>
<li>自己的空间边界</li>
<li>落在这个区域内的对象列表（仅叶子节点存储）</li>
</ul>
<p>当某个象限里节点超过阈值，就继续分裂。直到每个叶子格子里节点数 ≤ 阈值。</p>
<h2>查询过程</h2>
<p>框选一个矩形后，从根开始递归：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>query(rect, node):</span></span>
<span class="line"><span>  if node.bounds 与 rect 完全不相交 → return []  // 整块跳过</span></span>
<span class="line"><span>  if node 是叶子:</span></span>
<span class="line"><span>    return node.items.filter(item → AABB相交(item, rect))</span></span>
<span class="line"><span>  // 否则递归查四个子节点</span></span>
<span class="line"><span>  result = []</span></span>
<span class="line"><span>  for child in [node.NW, node.NE, node.SW, node.SE]:</span></span>
<span class="line"><span>    result.push(...query(rect, child))</span></span>
<span class="line"><span>  return result</span></span></code></pre><p>关键在第一条分支。当某个象限的边界和选框完全不重叠时，这个象限及其所有子节点<strong>一次性全部跳过</strong>，回到用户之前的话——&quot;只需要搜索涉及区域里面的节点就可以了&quot;。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>选框在 NE 角:</span></span>
<span class="line"><span>┌──────────┬──────────┐</span></span>
<span class="line"><span>│  ✗跳过   │  ✓进入   │</span></span>
<span class="line"><span>│   NW     │  NE ··   │</span></span>
<span class="line"><span>│          ├──────────┤</span></span>
<span class="line"><span>│          │     ✗    │</span></span>
<span class="line"><span>│          │     SE   │</span></span>
<span class="line"><span>│          │   (跳过)  │</span></span>
<span class="line"><span>└──────────┴──────────┘</span></span>
<span class="line"><span></span></span>
<span class="line"><span>SW 完全在选框外 → 整棵 SW 子树淘汰，0 次 AABB 判断</span></span>
<span class="line"><span>NE 与选框重叠 → 进入 NE，对其中 4 个节点做 AABB</span></span></code></pre><h2>复杂度分析</h2>
<table>
<thead>
<tr>
<th>操作</th>
<th>全量遍历</th>
<th>四叉树</th>
</tr>
</thead>
<tbody><tr>
<td>建树</td>
<td>O(1)</td>
<td>O(n log n)</td>
</tr>
<tr>
<td>单次查询</td>
<td>O(n)</td>
<td>O(log n + k)</td>
</tr>
<tr>
<td>插入/移动</td>
<td>O(1)</td>
<td>O(log n)</td>
</tr>
</tbody></table>
<p>查询复杂度中，log n 来自树的层数（逐层定位到相关叶子），k 是结果集中的节点数（精筛阶段无法避免）。只要 k ≪ n，加速就是数量级的。</p>
<p>具体到数字：500 个节点，选框选中 3 个。</p>
<ul>
<li>全量遍历：判断 500 次</li>
<li>四叉树（深度 4 层，选中的叶子格子里 8 个节点）：访问约 12~20 个树节点 + 8 次 AABB → 约 20 次判断</li>
</ul>
<h2>什么时候不该用</h2>
<p>四叉树有自己的成本：</p>
<p><strong>构建成本</strong>。每次插入、删除、移动节点都要更新树。如果节点频繁拖拽（信号处理实验室正是这样），移动操作要重新定位节点所在的格子。</p>
<p><strong>JS 对象分配开销</strong>。四叉树在 JavaScript 中靠递归和对象属性访问实现。当节点数在 200 以下时，一次 for 循环 + 矩形判断可能比递归查询四叉树更快——函数调用和属性查找的开销反而成了瓶颈。</p>
<p><strong>交叉点大约在 500~1000 个对象</strong>。低于这个数，粗暴遍历就是最优解。这就是信号处理实验室没有引入四叉树的理由：流程图的节点数几乎不可能超过 200。</p>
<h2>不止于四叉树</h2>
<p>四叉树只是空间索引家族的一员。相似的思路还有：</p>
<ul>
<li><strong>R-tree</strong>：为磁盘存储优化，数据库的空间索引首选。每个节点存一个最小包围矩形，节点之间可以有重叠。</li>
<li><strong>网格索引</strong>：把空间切成等大的格子。实现比四叉树更简单，但浪费内存在空旷区域。</li>
<li><strong>KD-tree</strong>：每次只沿一个维度切分，交替使用 x 和 y 轴。适合最近邻搜索。</li>
</ul>
<p>它们共享同一个设计哲学：<strong>用空间换时间，用结构换遍历</strong>。预先花一点代价把对象按位置组织好，查询时就能跳过绝大部分无关对象。</p>
<h2>总结</h2>
<p>分级筛选的思路不限于空间索引。数据库的 B-tree、搜索引擎的倒排索引、布隆过滤器，本质上都是&quot;粗筛 → 精筛&quot;——先用廉价操作淘汰掉大部分数据，再用精确操作收尾。</p>
<p>下次写全量遍历的时候可以问自己一句：我要处理的对象里，有多少是真正有关的？</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="四叉树"/>
    <category term="空间索引"/>
    <category term="碰撞检测"/>
    <category term="算法优化"/>
    <category term="信号处理实验室"/>
  </entry>
  <entry>
    <title>网页波形降采样要同时保留极值和边沿</title>
    <link href="https://createskyblue.github.io/posts/waveform-minmax-downsampling/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/waveform-minmax-downsampling/</id>
    <published>2026-06-07T00:00:00.000Z</published>
    <updated>2026-06-07T00:00:00.000Z</updated>
    <summary>示波器波形图MinMax降采样</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>网页波形降采样要同时保留极值和边沿</h1>
<h2>问题从哪里来</h2>
<p>我在做一个信号处理实验室的 Web 工具，需要用 Canvas 在浏览器里画波形图。右侧面板每个示波器窗口大概 400 像素宽，但信号数据动辄几万到几十万个采样点。</p>
<p>一个像素要代表几百个数据点——这时候，怎么画？</p>
<p><img src="/posts/waveform-minmax-downsampling/PixPin_2026-06-07_13-52-51.webp" alt="信号处理实验室的波形渲染：左侧显示了多种滤波后的信号波形，右侧示波器区域展示了 min-max 降采样后的实际效果"></p>
<h2>最朴素的做法：每隔 N 个点取一个</h2>
<p>不行的。想象一段信号：99.9% 的采样点都在 0 附近，只有 1 个尖峰冲到 10V。每隔 N 点采样，有 99.9% 概率正好跳过那个尖峰。屏幕上看起来就是一条平平的线，用户完全不知道那里有个 10V 的脉冲。</p>
<p>这个问题不是边缘情况——是高频信号的普遍现象。</p>
<h2>一个像素 = 一个竖线，线涵盖 min 到 max</h2>
<p>标准做法叫 min-max 降采样：</p>
<ol>
<li>把数据按像素宽度切成一段一段</li>
<li>每一段找最小值和最大值</li>
<li>在这一列的像素位置上，从 min 画一条竖线到 max</li>
</ol>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">let</span><span style="color:#9CDCFE"> px</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; </span><span style="color:#9CDCFE">px</span><span style="color:#D4D4D4"> &#x3C; </span><span style="color:#9CDCFE">canvasWidth</span><span style="color:#D4D4D4">; </span><span style="color:#9CDCFE">px</span><span style="color:#D4D4D4">++) {</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> chunkStart</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">Math</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">floor</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">iStart</span><span style="color:#D4D4D4"> + (</span><span style="color:#9CDCFE">px</span><span style="color:#D4D4D4"> / </span><span style="color:#9CDCFE">canvasWidth</span><span style="color:#D4D4D4">) * (</span><span style="color:#9CDCFE">iEnd</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">iStart</span><span style="color:#D4D4D4">));</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> chunkEnd</span><span style="color:#D4D4D4">   = </span><span style="color:#9CDCFE">Math</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">floor</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">iStart</span><span style="color:#D4D4D4"> + ((</span><span style="color:#9CDCFE">px</span><span style="color:#D4D4D4"> + </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) / </span><span style="color:#9CDCFE">canvasWidth</span><span style="color:#D4D4D4">) * (</span><span style="color:#9CDCFE">iEnd</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">iStart</span><span style="color:#D4D4D4">));</span></span>
<span class="line"><span style="color:#569CD6">  let</span><span style="color:#9CDCFE"> chunkMin</span><span style="color:#D4D4D4"> = </span><span style="color:#569CD6">Infinity</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">chunkMax</span><span style="color:#D4D4D4"> = -</span><span style="color:#569CD6">Infinity</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">  for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">let</span><span style="color:#9CDCFE"> j</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">chunkStart</span><span style="color:#D4D4D4">; </span><span style="color:#9CDCFE">j</span><span style="color:#D4D4D4"> &#x3C; </span><span style="color:#9CDCFE">chunkEnd</span><span style="color:#D4D4D4">; </span><span style="color:#9CDCFE">j</span><span style="color:#D4D4D4">++) {</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#4FC1FF"> v</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">[</span><span style="color:#9CDCFE">j</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">v</span><span style="color:#D4D4D4"> &#x3C; </span><span style="color:#9CDCFE">chunkMin</span><span style="color:#D4D4D4">) </span><span style="color:#9CDCFE">chunkMin</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">v</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">v</span><span style="color:#D4D4D4"> > </span><span style="color:#9CDCFE">chunkMax</span><span style="color:#D4D4D4">) </span><span style="color:#9CDCFE">chunkMax</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">v</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> yMin</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">height</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">pad</span><span style="color:#D4D4D4"> - ((</span><span style="color:#9CDCFE">chunkMax</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">globalMin</span><span style="color:#D4D4D4">) / </span><span style="color:#9CDCFE">range</span><span style="color:#D4D4D4">) * (</span><span style="color:#9CDCFE">height</span><span style="color:#D4D4D4"> - </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> * </span><span style="color:#9CDCFE">pad</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> yMax</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">height</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">pad</span><span style="color:#D4D4D4"> - ((</span><span style="color:#9CDCFE">chunkMin</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">globalMin</span><span style="color:#D4D4D4">) / </span><span style="color:#9CDCFE">range</span><span style="color:#D4D4D4">) * (</span><span style="color:#9CDCFE">height</span><span style="color:#D4D4D4"> - </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> * </span><span style="color:#9CDCFE">pad</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#9CDCFE">  ctx</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">fillRect</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">px</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">yMin</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">Math</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">max</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">yMax</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">yMin</span><span style="color:#D4D4D4">));</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>这样，只要尖峰落在这个像素列对应的数据段里，它就一定会出现在画布上——因为 <code>chunkMax</code> 一定 &gt;= 尖峰值。无论缩多小，尖峰永远不会丢。</p>
<h2>第二个坑：方波的上升沿、下降沿去哪了</h2>
<p>min-max 解决尖峰之后，很快发现了另一个问题：画矩形波的时候，眼看高电平和低电平都能显示，但上升沿和下降沿经常消失。</p>
<p>原因很简单：上升沿通常只有 1~2 个采样点，恰好处于两个相邻像素列的分界线上。左侧像素列的 chunk 结束于分界线前一格，右侧像素列的 chunk 从分界线开始——上升沿那 1 个点正好在缝隙里。</p>
<p>修起来非常廉价——让相邻像素列的 chunk 重叠 1 个采样点：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">const</span><span style="color:#4FC1FF"> chunkStart</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">px</span><span style="color:#D4D4D4"> === </span><span style="color:#B5CEA8">0</span></span>
<span class="line"><span style="color:#D4D4D4">  ? </span><span style="color:#9CDCFE">Math</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">floor</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">iStart</span><span style="color:#D4D4D4"> + (</span><span style="color:#9CDCFE">px</span><span style="color:#D4D4D4"> / </span><span style="color:#9CDCFE">dw</span><span style="color:#D4D4D4">) * (</span><span style="color:#9CDCFE">iEnd</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">iStart</span><span style="color:#D4D4D4">))</span></span>
<span class="line"><span style="color:#D4D4D4">  : </span><span style="color:#9CDCFE">Math</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">floor</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">iStart</span><span style="color:#D4D4D4"> + (</span><span style="color:#9CDCFE">px</span><span style="color:#D4D4D4"> / </span><span style="color:#9CDCFE">dw</span><span style="color:#D4D4D4">) * (</span><span style="color:#9CDCFE">iEnd</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">iStart</span><span style="color:#D4D4D4">)) - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;  </span><span style="color:#6A9955">// 向前重叠 1 个采样</span></span></code></pre><pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>之前: [0..9] [10..19] [20..29]   ← 9-10、19-20 之间的边沿可能掉进缝隙</span></span>
<span class="line"><span>现在: [0..9] [9..19]  [19..29]   ← 每个边界点被左右两个像素列共同覆盖</span></span></code></pre><p>增加的运算量可以忽略不计——多看了不到 1% 的数据点。</p>
<h2>放大时的策略：别画竖线了，画连线</h2>
<p>min-max 竖线在缩小时表现完美，但放大到接近原始采样率时，一个像素列只覆盖 1~2 个甚至 0 个采样点。这时候画竖线看起来会有颗粒感。</p>
<p>做一个简单的分支：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">const</span><span style="color:#4FC1FF"> pointsPerPixel</span><span style="color:#D4D4D4"> = (</span><span style="color:#9CDCFE">iEnd</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">iStart</span><span style="color:#D4D4D4">) / </span><span style="color:#9CDCFE">canvasWidth</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">pointsPerPixel</span><span style="color:#D4D4D4"> &#x3C;= </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#6A9955">  // 放大：逐像素线性插值，平滑连线</span></span>
<span class="line"><span style="color:#C586C0">  for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">let</span><span style="color:#9CDCFE"> px</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; </span><span style="color:#9CDCFE">px</span><span style="color:#D4D4D4"> &#x3C; </span><span style="color:#9CDCFE">dw</span><span style="color:#D4D4D4">; </span><span style="color:#9CDCFE">px</span><span style="color:#D4D4D4">++) {</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#4FC1FF"> dataIdx</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">iStart</span><span style="color:#D4D4D4"> + (</span><span style="color:#9CDCFE">px</span><span style="color:#D4D4D4"> / </span><span style="color:#9CDCFE">dw</span><span style="color:#D4D4D4">) * (</span><span style="color:#9CDCFE">iEnd</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">iStart</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#4FC1FF"> idx</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">Math</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">floor</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">dataIdx</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#4FC1FF"> frac</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">dataIdx</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">idx</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#4FC1FF"> v</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">[</span><span style="color:#9CDCFE">idx</span><span style="color:#D4D4D4">] * (</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4"> - </span><span style="color:#9CDCFE">frac</span><span style="color:#D4D4D4">) + </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">[</span><span style="color:#9CDCFE">idx</span><span style="color:#D4D4D4"> + </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">] * </span><span style="color:#9CDCFE">frac</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#6A9955">    // ... 连线</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"><span style="color:#D4D4D4">} </span><span style="color:#C586C0">else</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#6A9955">  // 缩小：min-max 竖线</span></span>
<span class="line"><span style="color:#6A9955">  // ...</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><code>pointsPerPixel</code> 是每个像素对应的数据点数。等于 1 就是刚好 1:1；小于 1 说明是放大状态，采样点比像素还稀疏，用线性插值能给出最流畅的显示；大于 1 进入缩小状态，走 min-max 保极值。</p>
<h2>这套方案的特点</h2>
<ul>
<li><strong>实现简单</strong>：核心逻辑不到 50 行，没有额外依赖</li>
<li><strong>性能稳定</strong>：渲染开销完全由像素宽度决定，与数据量无关。百万点数据在 400px 宽的 canvas 上，每秒刷几十帧无压力</li>
<li><strong>不会丢极值</strong>：缩到多小都保证尖峰可见</li>
<li><strong>不会丢边沿</strong>：1-sample overlap 确保边界点的连续性</li>
<li><strong>放大也精细</strong>：自适应切换到线性插值，不破坏近距离观察的体验</li>
</ul>
<p>这个方案既可以独立用于 Canvas 2D 直绘，也可以迁移到 WebGL 做纹理采样，或做成图片服务端的降采样管道。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="波形渲染"/>
    <category term="降采样"/>
    <category term="Canvas"/>
    <category term="JavaScript"/>
    <category term="信号处理"/>
  </entry>
  <entry>
    <title>Vue SPA 博客改为静态 HTML 输出解决爬虫无法索引文章内容</title>
    <link href="https://createskyblue.github.io/posts/blog-json-to-html-seo-upgrade/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/blog-json-to-html-seo-upgrade/</id>
    <published>2026-06-03T00:00:00.000Z</published>
    <updated>2026-06-03T00:00:00.000Z</updated>
    <summary>静态博客生成HTML详情页，让搜索引擎能读正文</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Vue SPA 博客改为静态 HTML 输出解决爬虫无法索引文章内容</h1>
<h2>问题背景</h2>
<p>基于 Vue 3 + Vite 的 SPA 博客，文章内容在构建时渲染为 HTML 字符串，存储为 JSON 文件（<code>public/data/posts/{slug}.json</code>）。运行时通过 <code>fetch()</code> 加载 JSON，再用 <code>v-html</code> 注入到弹窗组件中。</p>
<p>这套方案对搜索引擎爬虫和 AI 索引不友好：</p>
<ul>
<li>Googlebot 执行 JS 的能力有限</li>
<li>AI 爬虫（如 ChatGPT）直接读 HTML 源码，拿不到 JSON 里的内容</li>
<li>用户分享链接时，接收方打开后只能看到空白首页（需要等 SPA 加载完再解析 URL 参数）</li>
</ul>
<p>GitHub Pages 是纯静态托管，没有后端服务器，SSR 走不通。最终方案：构建时直接生成独立的 HTML 文件，每篇文章一个目录。</p>
<h2>目录结构</h2>
<p>改造前：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>public/data/posts/esp32s3-esp-nn-sin-demo.json   ← { "content": "&#x3C;html>..." }</span></span>
<span class="line"><span>public/blog/嵌入式 &#x26; Linux/esp32s3-esp-nn-sin-demo/img.jpg</span></span></code></pre><p>改造后：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>public/posts/esp32s3-esp-nn-sin-demo/</span></span>
<span class="line"><span>  index.html          ← 完整 HTML 页面（meta 标签 + 正文 + 样式引用）</span></span>
<span class="line"><span>  img.jpg             ← 图片和 HTML 同目录</span></span></code></pre><p><code>posts.json</code> 索引文件保留，首页卡片列表仍依赖它加载文章元数据。slug 字段从 <code>嵌入式 &amp; Linux/esp32s3-esp-nn-sin-demo</code> 改为 <code>esp32s3-esp-nn-sin-demo</code>，URL 不再包含分类路径。</p>
<h2>构建流程</h2>
<p><code>scripts/generate-data.js</code> 负责整个流程：</p>
<ol>
<li>扫描 <code>src/content/blog/</code> 下的 Markdown 文件</li>
<li>解析 frontmatter，提取数学公式，用 <code>marked</code> + Shiki 渲染为 HTML</li>
<li>修复图片路径，复制图片到 <code>public/posts/{postDir}/</code></li>
<li>调用 <code>post-html-template.js</code> 生成完整 HTML 页面</li>
<li>生成 <code>posts.json</code> 索引和 <code>sitemap.xml</code></li>
</ol>
<p>公开文章和加密文章都输出为 HTML。加密文章的 HTML 内嵌 <code>&lt;script type=&quot;application/json&quot; id=&quot;encrypted-data&quot;&gt;</code> 存储密文，页面自带解密 UI 和内联的 Web Crypto 解密脚本。</p>
<p><code>package.json</code> 构建脚本：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>node scripts/generate-data.js &#x26;&#x26; vite build &#x26;&#x26; node scripts/generate-data.js --post-build</span></span></code></pre><p>第三步 <code>--post-build</code> 把 <code>public/posts/</code> 重新复制到 <code>dist/posts/</code>，覆盖 Vite 构建时改写过的版本。</p>
<h2>关键设计决策</h2>
<h3>扁平 URL 结构</h3>
<p>所有文章放在 <code>posts/</code> 根目录下，不按分类建子目录。分类仅用于首页筛选和侧边栏展示，不进入 URL。好处是改分类名不会导致已分享的链接失效。</p>
<h3>图片用绝对路径</h3>
<p>HTML 中图片使用 <code>/posts/{postDir}/img.jpg</code> 而非相对路径 <code>img.jpg</code>。原因是 SPA 弹窗通过 <code>fetch()</code> 获取 HTML 后用 <code>innerHTML</code> 注入首页 DOM，浏览器会以当前页面 URL 为基准解析相对路径，导致图片地址错误。</p>
<p>CSS 样式和 KaTeX CSS 用相对路径 <code>../post-styles.css</code>，因为它们在 <code>&lt;head&gt;</code> 中引用，直接由浏览器加载，不存在 DOM 注入的上下文问题。</p>
<h3>Vite dev 服务器中间件</h3>
<p>Vite dev 服务器把 <code>/posts/xxx/</code> 当作 SPA 路由，返回 <code>index.html</code> 而不是实际的文章 HTML 文件。通过 <code>configureServer</code> 钩子添加中间件拦截请求：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">function</span><span style="color:#DCDCAA"> postsMiddleware</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#C586C0">  return</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#9CDCFE">    name:</span><span style="color:#CE9178"> 'posts-middleware'</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#DCDCAA">    configureServer</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">server</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#9CDCFE">      server</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">middlewares</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">use</span><span style="color:#D4D4D4">((</span><span style="color:#9CDCFE">req</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">res</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">next</span><span style="color:#D4D4D4">) </span><span style="color:#569CD6">=></span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">req</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">url</span><span style="color:#D4D4D4">?.</span><span style="color:#DCDCAA">startsWith</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">'/posts/'</span><span style="color:#D4D4D4">) &#x26;&#x26; !</span><span style="color:#9CDCFE">req</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">url</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">includes</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">'.'</span><span style="color:#D4D4D4">)) {</span></span>
<span class="line"><span style="color:#569CD6">          const</span><span style="color:#4FC1FF"> urlPath</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">decodeURIComponent</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">req</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">url</span><span style="color:#D4D4D4">).</span><span style="color:#DCDCAA">replace</span><span style="color:#D4D4D4">(</span><span style="color:#D16969">/</span><span style="color:#D7BA7D">\/</span><span style="color:#DCDCAA">$</span><span style="color:#D16969">/</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">''</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#569CD6">          const</span><span style="color:#4FC1FF"> filePath</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">resolve</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">__dirname</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">'public'</span><span style="color:#D4D4D4"> + </span><span style="color:#9CDCFE">urlPath</span><span style="color:#D4D4D4"> + </span><span style="color:#CE9178">'/index.html'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#C586C0">          if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">fs</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">existsSync</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">filePath</span><span style="color:#D4D4D4">)) {</span></span>
<span class="line"><span style="color:#9CDCFE">            res</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">setHeader</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">'Content-Type'</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">'text/html'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#9CDCFE">            res</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">end</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">fs</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">readFileSync</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">filePath</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">'utf-8'</span><span style="color:#D4D4D4">))</span></span>
<span class="line"><span style="color:#C586C0">            return</span></span>
<span class="line"><span style="color:#D4D4D4">          }</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#DCDCAA">        next</span><span style="color:#D4D4D4">()</span></span>
<span class="line"><span style="color:#D4D4D4">      })</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>直接访问文章 URL 的处理</h3>
<p>文章 HTML 内嵌一段 JS：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">window</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">top</span><span style="color:#D4D4D4"> === </span><span style="color:#9CDCFE">window</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#9CDCFE">  window</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">location</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">replace</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">'/?article={slug}'</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>浏览器直接访问文章 URL 时，先加载 HTML 页面，脚本检测到是顶层窗口（非 SPA 弹窗的 fetch），自动跳转到首页并携带 <code>?article=</code> 参数。首页的 <code>checkUrlForArticle()</code> 读取该参数，自动打开对应文章弹窗，URL 替换为 <code>/posts/{slug}</code>。</p>
<h2>踩过的坑</h2>
<h3>Vite 构建改写 public 下 HTML 的路径</h3>
<p><code>vite build</code> 会处理 <code>public/</code> 目录下的 HTML 文件，把 <code>src=&quot;img.jpg&quot;</code> 解析为 <code>src=&quot;/posts/嵌入式 &amp; Linux/xxx/img.jpg&quot;</code> 绝对路径。解决办法是构建脚本在 Vite 完成后执行 <code>--post-build</code>，用未被改写的版本覆盖 <code>dist/posts/</code>。</p>
<h3>dev 服务器缓存旧数据</h3>
<p><code>npm run dev</code> 启动的 <code>generate-data.js --watch</code> 进程使用 Node.js 模块缓存，修改构建脚本代码后不会自动生效。需要杀掉旧进程重新启动 dev 服务器。Vite 本身也有进程级缓存，即使文件已更新，正在运行的 Vite 进程仍返回旧内容。</p>
<h3>SPA 弹窗中相对图片路径解析错误</h3>
<p>最初图片用相对路径 <code>img.jpg</code>，在独立访问时正常，但在 SPA �弹窗中加载失败。原因是 <code>innerHTML</code> 注入后浏览器以当前页面 URL（<code>/</code> 或 <code>/posts/xxx</code>）为基准解析相对路径，而不是以文章 HTML 所在目录为基准。改为绝对路径 <code>/posts/{postDir}/img.jpg</code> 后解决。</p>
<h2>最终效果</h2>
<ul>
<li>每篇文章是独立 HTML 页面，包含完整的 meta 标签、Open Graph、Schema.org 结构化数据</li>
<li><code>sitemap.xml</code> 列出所有文章 URL，搜索引擎可直接发现和索引</li>
<li>首页 SPA 体验不变，点击卡片弹窗显示文章内容</li>
<li>直接访问文章 URL 自动跳转首页并打开弹窗</li>
<li>加密文章同样输出 HTML，密文和解密 UI 内嵌在页面中</li>
</ul>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="Vue"/>
    <category term="Vite"/>
    <category term="SEO"/>
    <category term="博客"/>
    <category term="静态站点生成"/>
  </entry>
  <entry>
    <title>ESP-IDF VS Code 插件缓存 JTAG 序列号导致第二块开发板 OpenOCD 启动失败的排查与解决</title>
    <link href="https://createskyblue.github.io/posts/esp-idf-vscode-openocd-second-board/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp-idf-vscode-openocd-second-board/</id>
    <published>2026-06-03T00:00:00.000Z</published>
    <updated>2026-06-03T00:00:00.000Z</updated>
    <summary>ESP-IDF换板后OpenOCD启动失败排查</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>ESP-IDF VS Code 插件缓存 JTAG 序列号导致第二块开发板 OpenOCD 启动失败的排查与解决</h1>
<h2>问题现象</h2>
<p>使用 ESP-IDF VS Code 插件调试 ESP32-S3 时，第一块开发板 OpenOCD 正常启动，换第二块板后报错：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Info : No device matches the serial string</span></span>
<span class="line"><span>Error: esp_usb_jtag: could not find or open device!</span></span></code></pre><p>Windows 设备管理器显示设备正常，VID/PID 正确（303A:1001），WinUSB 驱动已安装。</p>
<h2>根因分析</h2>
<p>ESP-IDF VS Code 插件会将第一块开发板的 USB JTAG 序列号静默保存到 VS Code 的 workspace SQLite 数据库（<code>state.vscdb</code>）中。换板后，插件仍使用旧序列号启动 OpenOCD，导致新板的序列号不匹配而无法连接。</p>
<p><strong>关键判断依据</strong>：命令行 <code>idf.py openocd</code> 或 <code>openocd -f board/esp32s3-builtin.cfg</code> 可正常启动，仅 VS Code 图形化启动失败。</p>
<h2>解决方案</h2>
<h3>方法一：清除缓存的 JTAG 序列号（推荐）</h3>
<p>这是 Espressif 官方提供的方法，来自 <a href="https://github.com/espressif/vscode-esp-idf-extension/issues/1788">Issue #1788</a> 的维护者回复：</p>
<ol>
<li>在 VS Code 中打开 ESP-IDF 项目</li>
<li>点击左侧活动栏的 <strong>ESP-IDF 插件图标</strong>（Espressif logo），打开插件命令面板</li>
<li>在命令列表中找到 <strong><code>OpenOCD Adaptor (serial &amp; location)</code></strong>，点击启用它</li>
<li>启用后，底部<strong>状态栏</strong>左侧会出现一个新按钮，显示当前缓存的 JTAG 序列号和 USB 端口位置</li>
<li><strong>点击该状态栏按钮</strong>即可清除缓存的序列号，插件将自动连接到下一个可用的 JTAG 设备</li>
</ol>
<p>清除后重新启动 OpenOCD 即可正常连接第二块开发板。</p>
<h3>方法二：命令行启动 OpenOCD 绕过插件</h3>
<p>如果状态栏方法不适用，可以命令行启动 OpenOCD，VS Code 仅连接已有的 GDB Server：</p>
<pre><code>idf.py openocd</code></pre><p>然后在 VS Code 的 <code>launch.json</code> 中配置：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#CE9178">"miDebuggerServerAddress"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"localhost:3333"</span></span></code></pre><h2>验证步骤</h2>
<ol>
<li>插上第一块板，VS Code 中启动 OpenOCD，确认正常</li>
<li>换第二块板，直接在 VS Code 中启动 OpenOCD —— 此时应报错 <code>No device matches the serial string</code></li>
<li>按上述方法一清除序列号缓存，重新启动 OpenOCD，确认第二块板正常连接</li>
</ol>
<h2>参考</h2>
<ul>
<li><a href="https://github.com/espressif/vscode-esp-idf-extension/issues/1788">ESP-IDF VSCode Extension Issue #1788</a> - Virtual JTAG silently saved in workspace database（维护者回复中给出了状态栏清除方法）</li>
<li><a href="https://docs.espressif.com/projects/vscode-esp-idf-extension/en/latest/debugproject.html">ESP-IDF VSCode Debug Documentation</a></li>
</ul>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="ESP32"/>
    <category term="ESP-IDF"/>
    <category term="VSCode"/>
    <category term="OpenOCD"/>
    <category term="USB-JTAG"/>
  </entry>
  <entry>
    <title>用ESP-NN在ESP32-S3上部署一个学过sin的神经网络</title>
    <link href="https://createskyblue.github.io/posts/esp32s3-esp-nn-sin-demo/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp32s3-esp-nn-sin-demo/</id>
    <published>2026-06-02T00:00:00.000Z</published>
    <updated>2026-06-02T00:00:00.000Z</updated>
    <summary>ESP32-S3运行ESP-NN量化正弦模型</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用ESP-NN在ESP32-S3上部署一个学过sin的神经网络</h1>
<h2>全景流程</h2>
<div style="display: grid; grid-template-columns: 1fr auto 1fr; gap: 12px; align-items: center; font-size: 14px;">

<div style="padding: 16px; background: #f0f9ff; border-radius: 8px; border: 1px solid #bae6fd;">

<p><strong>PC (Python)</strong></p>
<p>① <code>train.py</code> — 训练 float 模型</p>
<p>② <code>quantize.py</code> — float32 → int8 量化</p>
<p>③ <code>convert_to_c.py</code> — .tflite → C 数组 <code>model.h</code></p>
</div>

<div style="text-align: center; font-size: 24px; color: #0ea5e9;">→<br/>拷贝<br/>model.h<br/>→</div>

<div style="padding: 16px; background: #f0fdf4; border-radius: 8px; border: 1px solid #bbf7d0;">

<p><strong>ESP32-S3</strong></p>
<p>④ <code>app_main.cpp</code> — 加载模型 → 推理 → 打印结果</p>
</div>

</div>

<h2>环境</h2>
<ul>
<li>Python 3.8+，用uv管理虚拟环境</li>
<li>ESP-IDF v5.0+</li>
<li>ESP32-S3开发板（ESP32/ESP32-C3也兼容，S3加速比最高）</li>
</ul>
<h2>训练</h2>
<h3>数据</h3>
<p>1000个<code>[0, 2π]</code>均匀分布的随机点，对应<code>sin(x)</code>值。选sin的原因：非线性（Dense必须弯曲才能拟合）、有界（输出[-1,1]，量化时范围可控）、周期性（训练数据天然覆盖完整定义域）。</p>
<h3>网络</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">model = tf.keras.Sequential([</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">activation</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'relu'</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">input_shape</span><span style="color:#D4D4D4">=(</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">,)),</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">activation</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'relu'</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">])</span></span>
<span class="line"><span style="color:#D4D4D4">model.compile(</span><span style="color:#9CDCFE">optimizer</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'adam'</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">loss</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'mse'</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">metrics</span><span style="color:#D4D4D4">=[</span><span style="color:#CE9178">'mae'</span><span style="color:#D4D4D4">])</span></span></code></pre><p>三层全连接，305个参数。300轮训练，batch_size=64，20%验证集。</p>
<h3>训练代码（train.py）</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> math</span></span>
<span class="line"><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> numpy </span><span style="color:#C586C0">as</span><span style="color:#D4D4D4"> np</span></span>
<span class="line"><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> tensorflow </span><span style="color:#C586C0">as</span><span style="color:#D4D4D4"> tf</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 1. 生成训练数据：1000个 [0, 2π] 的随机点</span></span>
<span class="line"><span style="color:#D4D4D4">x_values = np.random.uniform(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> * math.pi, </span><span style="color:#B5CEA8">1000</span><span style="color:#D4D4D4">).astype(np.float32)</span></span>
<span class="line"><span style="color:#D4D4D4">np.random.shuffle(x_values)</span></span>
<span class="line"><span style="color:#D4D4D4">y_values = np.sin(x_values).astype(np.float32)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 2. 构建模型</span></span>
<span class="line"><span style="color:#D4D4D4">model = tf.keras.Sequential([</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">activation</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'relu'</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">input_shape</span><span style="color:#D4D4D4">=(</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">,)),</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">activation</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'relu'</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.layers.Dense(</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">])</span></span>
<span class="line"><span style="color:#D4D4D4">model.compile(</span><span style="color:#9CDCFE">optimizer</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'adam'</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">loss</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'mse'</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">metrics</span><span style="color:#D4D4D4">=[</span><span style="color:#CE9178">'mae'</span><span style="color:#D4D4D4">])</span></span>
<span class="line"><span style="color:#D4D4D4">model.summary()</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 3. 训练 300 轮</span></span>
<span class="line"><span style="color:#D4D4D4">model.fit(x_values, y_values, </span><span style="color:#9CDCFE">epochs</span><span style="color:#D4D4D4">=</span><span style="color:#B5CEA8">300</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">batch_size</span><span style="color:#D4D4D4">=</span><span style="color:#B5CEA8">64</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">validation_split</span><span style="color:#D4D4D4">=</span><span style="color:#B5CEA8">0.2</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 4. 验证</span></span>
<span class="line"><span style="color:#D4D4D4">test_x = np.array([</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, math.pi / </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">, math.pi / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">, math.pi, </span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4"> * math.pi / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> * math.pi], </span><span style="color:#9CDCFE">dtype</span><span style="color:#D4D4D4">=np.float32)</span></span>
<span class="line"><span style="color:#D4D4D4">test_y = model.predict(test_x, </span><span style="color:#9CDCFE">verbose</span><span style="color:#D4D4D4">=</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">).flatten()</span></span>
<span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> x, y_pred </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> zip</span><span style="color:#D4D4D4">(test_x, test_y):</span></span>
<span class="line"><span style="color:#D4D4D4">    y_true = math.sin(x)</span></span>
<span class="line"><span style="color:#DCDCAA">    print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"  sin(</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">x</span><span style="color:#569CD6">:.4f}</span><span style="color:#CE9178">) = </span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">y_true</span><span style="color:#569CD6">:+.4f}</span><span style="color:#CE9178">  |  预测 = </span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">y_pred</span><span style="color:#569CD6">:+.4f}</span><span style="color:#CE9178">  |  误差 = </span><span style="color:#569CD6">{</span><span style="color:#DCDCAA">abs</span><span style="color:#D4D4D4">(y_pred - y_true)</span><span style="color:#569CD6">:.4f}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 5. 保存</span></span>
<span class="line"><span style="color:#D4D4D4">model.save(</span><span style="color:#CE9178">"sin_model_float.keras"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">converter = tf.lite.TFLiteConverter.from_keras_model(model)</span></span>
<span class="line"><span style="color:#C586C0">with</span><span style="color:#DCDCAA"> open</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"sin_model_float.tflite"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"wb"</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">as</span><span style="color:#D4D4D4"> f:</span></span>
<span class="line"><span style="color:#D4D4D4">    f.write(converter.convert())</span></span></code></pre><pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">pip</span><span style="color:#CE9178"> install</span><span style="color:#CE9178"> uv</span></span>
<span class="line"><span style="color:#DCDCAA">uv</span><span style="color:#CE9178"> venv</span><span style="color:#CE9178"> .venv</span><span style="color:#D4D4D4"> &#x26;&#x26; </span><span style="color:#DCDCAA">uv</span><span style="color:#CE9178"> pip</span><span style="color:#CE9178"> install</span><span style="color:#CE9178"> tensorflow</span><span style="color:#CE9178"> numpy</span></span>
<span class="line"><span style="color:#DCDCAA">python</span><span style="color:#CE9178"> train.py</span></span></code></pre><h3>训练结果</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>sin(0.00) = +0.000  |  预测 = +0.001  |  误差 0.001</span></span>
<span class="line"><span>sin(π/2)  = +1.000  |  预测 = +0.991  |  误差 0.009</span></span>
<span class="line"><span>sin(π)    = -0.000  |  预测 = -0.013  |  误差 0.013</span></span></code></pre><p>产物：<code>sin_model_float.keras</code>（可继续训练）、<code>sin_model_float.tflite</code>（float TFLite，约3KB）。</p>
<h2>量化</h2>
<h3>为什么需要量化</h3>
<p>ESP32-S3的FPU只有单精度，且没有向量浮点加速。int8整数运算配合ESP-NN的汇编优化（利用ESP32-S3的PIE向量指令，一条指令处理4个int8），推理速度比float快一个数量级。模型体积也缩小4倍。</p>
<h3>量化原理</h3>
<p>量化建立float和int8之间的线性映射：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>float_value = (int8_value - zero_point) × scale</span></span></code></pre><p><code>scale</code>和<code>zero_point</code>通过校准数据集统计每层数值范围后自动计算。量化脚本中的<code>representative_dataset</code>用于<strong>观察</strong>每层激活值的分布，与训练无关。</p>
<h3>量化代码（quantize.py）</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> math</span></span>
<span class="line"><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> numpy </span><span style="color:#C586C0">as</span><span style="color:#D4D4D4"> np</span></span>
<span class="line"><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> tensorflow </span><span style="color:#C586C0">as</span><span style="color:#D4D4D4"> tf</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 1. 加载 float 模型</span></span>
<span class="line"><span style="color:#D4D4D4">converter = tf.lite.TFLiteConverter.from_keras_model(</span></span>
<span class="line"><span style="color:#D4D4D4">    tf.keras.models.load_model(</span><span style="color:#CE9178">"sin_model_float.keras"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 2. 配置 int8 全量化</span></span>
<span class="line"><span style="color:#D4D4D4">converter.optimizations = [tf.lite.Optimize.DEFAULT]</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 校准数据集：100个随机样本，用来统计每层数值范围</span></span>
<span class="line"><span style="color:#569CD6">def</span><span style="color:#DCDCAA"> representative_dataset</span><span style="color:#D4D4D4">():</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> x </span><span style="color:#C586C0">in</span><span style="color:#D4D4D4"> np.random.uniform(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> * math.pi, </span><span style="color:#B5CEA8">100</span><span style="color:#D4D4D4">).astype(np.float32):</span></span>
<span class="line"><span style="color:#C586C0">        yield</span><span style="color:#D4D4D4"> [x]</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">converter.representative_dataset = representative_dataset</span></span>
<span class="line"><span style="color:#D4D4D4">converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]</span></span>
<span class="line"><span style="color:#D4D4D4">converter.inference_input_type = tf.int8</span></span>
<span class="line"><span style="color:#D4D4D4">converter.inference_output_type = tf.int8</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 3. 转换并保存</span></span>
<span class="line"><span style="color:#D4D4D4">tflite_quant_model = converter.convert()</span></span>
<span class="line"><span style="color:#C586C0">with</span><span style="color:#DCDCAA"> open</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"sin_model_int8.tflite"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"wb"</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">as</span><span style="color:#D4D4D4"> f:</span></span>
<span class="line"><span style="color:#D4D4D4">    f.write(tflite_quant_model)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955"># 4. 验证</span></span>
<span class="line"><span style="color:#D4D4D4">interpreter = tf.lite.Interpreter(</span><span style="color:#9CDCFE">model_content</span><span style="color:#D4D4D4">=tflite_quant_model)</span></span>
<span class="line"><span style="color:#D4D4D4">interpreter.allocate_tensors()</span></span>
<span class="line"><span style="color:#D4D4D4">input_details = interpreter.get_input_details()</span></span>
<span class="line"><span style="color:#D4D4D4">output_details = interpreter.get_output_details()</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"输入量化参数: scale=</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">input_details[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][</span><span style="color:#CE9178">'quantization'</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]</span><span style="color:#569CD6">:.6f}</span><span style="color:#CE9178">, "</span></span>
<span class="line"><span style="color:#569CD6">      f</span><span style="color:#CE9178">"zero_point=</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">input_details[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][</span><span style="color:#CE9178">'quantization'</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]</span><span style="color:#569CD6">}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#DCDCAA">print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"输出量化参数: scale=</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">output_details[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][</span><span style="color:#CE9178">'quantization'</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]</span><span style="color:#569CD6">:.6f}</span><span style="color:#CE9178">, "</span></span>
<span class="line"><span style="color:#569CD6">      f</span><span style="color:#CE9178">"zero_point=</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">output_details[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][</span><span style="color:#CE9178">'quantization'</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]</span><span style="color:#569CD6">}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">test_points = [</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, math.pi / </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">, math.pi / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">, math.pi, </span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4"> * math.pi / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">]</span></span>
<span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> x </span><span style="color:#C586C0">in</span><span style="color:#D4D4D4"> test_points:</span></span>
<span class="line"><span style="color:#D4D4D4">    input_scale = input_details[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][</span><span style="color:#CE9178">'quantization'</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]</span></span>
<span class="line"><span style="color:#D4D4D4">    input_zp = input_details[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][</span><span style="color:#CE9178">'quantization'</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]</span></span>
<span class="line"><span style="color:#D4D4D4">    x_quant = </span><span style="color:#4EC9B0">int</span><span style="color:#D4D4D4">(x / input_scale + input_zp)</span></span>
<span class="line"><span style="color:#D4D4D4">    x_quant = np.clip(x_quant, -</span><span style="color:#B5CEA8">128</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">127</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    interpreter.set_tensor(input_details[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][</span><span style="color:#CE9178">'index'</span><span style="color:#D4D4D4">], np.array([[x_quant]], </span><span style="color:#9CDCFE">dtype</span><span style="color:#D4D4D4">=np.int8))</span></span>
<span class="line"><span style="color:#D4D4D4">    interpreter.invoke()</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    y_quant = interpreter.get_tensor(output_details[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][</span><span style="color:#CE9178">'index'</span><span style="color:#D4D4D4">]).flatten()[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]</span></span>
<span class="line"><span style="color:#D4D4D4">    output_scale = output_details[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][</span><span style="color:#CE9178">'quantization'</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]</span></span>
<span class="line"><span style="color:#D4D4D4">    output_zp = output_details[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][</span><span style="color:#CE9178">'quantization'</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]</span></span>
<span class="line"><span style="color:#D4D4D4">    y_pred = </span><span style="color:#4EC9B0">float</span><span style="color:#D4D4D4">((</span><span style="color:#4EC9B0">int</span><span style="color:#D4D4D4">(y_quant) - output_zp) * output_scale)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    y_true = math.sin(x)</span></span>
<span class="line"><span style="color:#DCDCAA">    print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"  sin(</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">x</span><span style="color:#569CD6">:.4f}</span><span style="color:#CE9178">) = </span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">y_true</span><span style="color:#569CD6">:+.4f}</span><span style="color:#CE9178">  |  int8 预测 = </span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">y_pred</span><span style="color:#569CD6">:+.4f}</span><span style="color:#CE9178">  |  误差 = </span><span style="color:#569CD6">{</span><span style="color:#DCDCAA">abs</span><span style="color:#D4D4D4">(y_pred - y_true)</span><span style="color:#569CD6">:.4f}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span></code></pre><h3>量化结果</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>输入: scale=0.024349, zero_point=-128</span></span>
<span class="line"><span>输出: scale=0.008386, zero_point=7</span></span>
<span class="line"><span></span></span>
<span class="line"><span>sin(0.00) = +0.000  |  int8 预测 = +0.025  |  误差 0.025</span></span>
<span class="line"><span>sin(π/4)  = +0.707  |  int8 预测 = +0.713  |  误差 0.006</span></span>
<span class="line"><span>sin(π/2)  = +1.000  |  int8 预测 = +1.006  |  误差 0.006</span></span>
<span class="line"><span>sin(3π/2) = -1.000  |  int8 预测 = -1.115  |  误差 0.115</span></span></code></pre><p>边界处误差较大，可通过量化感知训练（QAT）改善。产物：<code>sin_model_int8.tflite</code>（约3.4KB）。</p>
<h2>转C数组</h2>
<p>.tflite文件无法直接被C代码读取，转为<code>const unsigned char</code>数组嵌入固件：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> sys</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">def</span><span style="color:#DCDCAA"> convert_to_c_array</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">tflite_path</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">output_path</span><span style="color:#D4D4D4">):</span></span>
<span class="line"><span style="color:#C586C0">    with</span><span style="color:#DCDCAA"> open</span><span style="color:#D4D4D4">(tflite_path, </span><span style="color:#CE9178">"rb"</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">as</span><span style="color:#D4D4D4"> f:</span></span>
<span class="line"><span style="color:#D4D4D4">        model_data = f.read()</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    with</span><span style="color:#DCDCAA"> open</span><span style="color:#D4D4D4">(output_path, </span><span style="color:#CE9178">"w"</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">as</span><span style="color:#D4D4D4"> f:</span></span>
<span class="line"><span style="color:#D4D4D4">        f.write(</span><span style="color:#CE9178">'// Auto-generated by convert_to_c.py</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">        f.write(</span><span style="color:#CE9178">'// Do not edit manually</span><span style="color:#D7BA7D">\n\n</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">        f.write(</span><span style="color:#CE9178">'#ifndef MODEL_H_</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">        f.write(</span><span style="color:#CE9178">'#define MODEL_H_</span><span style="color:#D7BA7D">\n\n</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">        f.write(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">'constexpr unsigned int g_model_len = </span><span style="color:#569CD6">{</span><span style="color:#DCDCAA">len</span><span style="color:#D4D4D4">(model_data)</span><span style="color:#569CD6">}</span><span style="color:#CE9178">;</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">        f.write(</span><span style="color:#CE9178">'alignas(16) const unsigned char g_model[] = {</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">        for</span><span style="color:#D4D4D4"> i, byte </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> enumerate</span><span style="color:#D4D4D4">(model_data):</span></span>
<span class="line"><span style="color:#C586C0">            if</span><span style="color:#D4D4D4"> i % </span><span style="color:#B5CEA8">12</span><span style="color:#D4D4D4"> == </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#D4D4D4">                f.write(</span><span style="color:#CE9178">'  '</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">            f.write(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">'0x</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">byte</span><span style="color:#569CD6">:02x}</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#C586C0">            if</span><span style="color:#D4D4D4"> i &#x3C; </span><span style="color:#DCDCAA">len</span><span style="color:#D4D4D4">(model_data) - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#D4D4D4">                f.write(</span><span style="color:#CE9178">', '</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#C586C0">            if</span><span style="color:#D4D4D4"> i % </span><span style="color:#B5CEA8">12</span><span style="color:#D4D4D4"> == </span><span style="color:#B5CEA8">11</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#D4D4D4">                f.write(</span><span style="color:#CE9178">'</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">        f.write(</span><span style="color:#CE9178">'</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">};</span><span style="color:#D7BA7D">\n\n</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">        f.write(</span><span style="color:#CE9178">'#endif  // MODEL_H_</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#9CDCFE"> __name__</span><span style="color:#D4D4D4"> == </span><span style="color:#CE9178">"__main__"</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#D4D4D4">    input_file = sys.argv[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">] </span><span style="color:#C586C0">if</span><span style="color:#DCDCAA"> len</span><span style="color:#D4D4D4">(sys.argv) > </span><span style="color:#B5CEA8">1</span><span style="color:#C586C0"> else</span><span style="color:#CE9178"> "sin_model_int8.tflite"</span></span>
<span class="line"><span style="color:#D4D4D4">    output_file = sys.argv[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">] </span><span style="color:#C586C0">if</span><span style="color:#DCDCAA"> len</span><span style="color:#D4D4D4">(sys.argv) > </span><span style="color:#B5CEA8">2</span><span style="color:#C586C0"> else</span><span style="color:#CE9178"> "../esp32s3/main/model.h"</span></span>
<span class="line"><span style="color:#D4D4D4">    convert_to_c_array(input_file, output_file)</span></span></code></pre><p><code>alignas(16)</code>是ESP32-S3的DMA和向量指令的对齐要求。</p>
<h2>ESP32-S3部署</h2>
<h3>项目结构</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>esp32s3/</span></span>
<span class="line"><span>├── CMakeLists.txt</span></span>
<span class="line"><span>├── components/</span></span>
<span class="line"><span>│   └── esp-tflite-micro/   # TFLite Micro 组件（通过 -DESP_NN 调用 ESP-NN）</span></span>
<span class="line"><span>├── managed_components/</span></span>
<span class="line"><span>│   └── espressif__esp-nn/  # ESP-NN（idf.py build 自动下载）</span></span>
<span class="line"><span>└── main/</span></span>
<span class="line"><span>    ├── CMakeLists.txt</span></span>
<span class="line"><span>    ├── idf_component.yml</span></span>
<span class="line"><span>    ├── app_main.cpp</span></span>
<span class="line"><span>    └── model.h             # convert_to_c.py 生成</span></span></code></pre><p><code>main/idf_component.yml</code>只声明<code>esp-nn</code>依赖，<code>esp-tflite-micro</code>通过本地<code>components</code>目录引入：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">dependencies</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#569CD6">  espressif/esp-nn</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#569CD6">    version</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">">=1.1.1"</span></span></code></pre><p><code>main/CMakeLists.txt</code>必须在<code>PRIV_REQUIRES</code>中声明<code>esp-tflite-micro</code>，否则编译器找不到头文件：</p>
<pre><code>idf_component_register(
    SRCS "app_main.cpp"
    INCLUDE_DIRS "."
    PRIV_REQUIRES spi_flash esp-tflite-micro
)</code></pre><p>ESP-NN通过<code>esp-tflite-micro</code>的CMakeLists.txt中的<code>-DESP_NN</code>宏自动启用。</p>
<h3>推理代码</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">/*</span></span>
<span class="line"><span style="color:#6A9955"> * sin_demo - 在 ESP32-S3 上运行 sin(x) 推理</span></span>
<span class="line"><span style="color:#6A9955"> *</span></span>
<span class="line"><span style="color:#6A9955"> * 流程:</span></span>
<span class="line"><span style="color:#6A9955"> *   1. 加载 int8 量化模型</span></span>
<span class="line"><span style="color:#6A9955"> *   2. 循环输入 x 值</span></span>
<span class="line"><span style="color:#6A9955"> *   3. 执行推理（ESP-NN 自动加速）</span></span>
<span class="line"><span style="color:#6A9955"> *   4. 反量化输出，打印结果</span></span>
<span class="line"><span style="color:#6A9955"> */</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;cstdio></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;cinttypes></span><span style="color:#6A9955">  // PRId32 / PRIu32 宏</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;cmath></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;freertos/FreeRTOS.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;freertos/task.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/micro_interpreter.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/micro/micro_mutable_op_resolver.h"</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "tensorflow/lite/schema/schema_generated.h"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "model.h"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// TFLite Micro 需要的内存池</span></span>
<span class="line"><span style="color:#569CD6">constexpr</span><span style="color:#569CD6"> int</span><span style="color:#D4D4D4"> kTensorArenaSize = </span><span style="color:#B5CEA8">2048</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">alignas</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">) </span><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint8_t</span><span style="color:#9CDCFE"> tensor_arena</span><span style="color:#D4D4D4">[kTensorArenaSize];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// 模型需要的算子数量（只有 1 个全连接层类型）</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#4EC9B0"> tflite</span><span style="color:#D4D4D4">::MicroMutableOpResolver&#x3C;</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">> resolver;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">extern</span><span style="color:#CE9178"> "C"</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> app_main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">========================================</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"  sin_demo: ESP-NN + TFLite Micro</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"========================================</span><span style="color:#D7BA7D">\n\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 1. 加载模型</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#4EC9B0"> tflite</span><span style="color:#D4D4D4">::Model *model = </span><span style="color:#4EC9B0">tflite</span><span style="color:#D4D4D4">::</span><span style="color:#DCDCAA">GetModel</span><span style="color:#D4D4D4">(g_model);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">model</span><span style="color:#D4D4D4">-></span><span style="color:#DCDCAA">version</span><span style="color:#D4D4D4">() != TFLITE_SCHEMA_VERSION) {</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"ERROR: 模型版本不匹配! 期望 </span><span style="color:#9CDCFE">%d</span><span style="color:#CE9178">, 实际 %"</span><span style="color:#D4D4D4"> PRIu32 </span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">               TFLITE_SCHEMA_VERSION, </span><span style="color:#9CDCFE">model</span><span style="color:#D4D4D4">-></span><span style="color:#DCDCAA">version</span><span style="color:#D4D4D4">());</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"[OK] 模型已加载 (</span><span style="color:#9CDCFE">%d</span><span style="color:#CE9178"> bytes)</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, g_model_len);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">resolver</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AddFullyConnected</span><span style="color:#D4D4D4">() != kTfLiteOk) {</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"ERROR: 注册 FullyConnected 算子失败</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 2. 创建解释器</span></span>
<span class="line"><span style="color:#569CD6">    static</span><span style="color:#4EC9B0"> tflite</span><span style="color:#D4D4D4">::</span><span style="color:#4EC9B0">MicroInterpreter</span><span style="color:#DCDCAA"> interpreter</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#4EC9B0">        model</span><span style="color:#D4D4D4">, </span><span style="color:#4EC9B0">resolver</span><span style="color:#D4D4D4">, </span><span style="color:#4EC9B0">tensor_arena</span><span style="color:#D4D4D4">, </span><span style="color:#4EC9B0">kTensorArenaSize</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">AllocateTensors</span><span style="color:#D4D4D4">() != kTfLiteOk) {</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"ERROR: 分配张量内存失败</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"[OK] 解释器已初始化 (arena: </span><span style="color:#9CDCFE">%d</span><span style="color:#CE9178"> bytes)</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, kTensorArenaSize);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    TfLiteTensor *input = </span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">input</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">    TfLiteTensor *output = </span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">output</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // ESP32-S3 上 int32_t 是 long int，必须用 PRId32 而非 %d</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"[OK] 输入量化参数:  scale=</span><span style="color:#9CDCFE">%.6f</span><span style="color:#CE9178">  zero_point=%"</span><span style="color:#D4D4D4"> PRId32 </span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">           (</span><span style="color:#569CD6">double</span><span style="color:#D4D4D4">)</span><span style="color:#9CDCFE">input</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">params</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">scale</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">input</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">params</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">zero_point</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"[OK] 输出量化参数:  scale=</span><span style="color:#9CDCFE">%.6f</span><span style="color:#CE9178">  zero_point=%"</span><span style="color:#D4D4D4"> PRId32 </span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\n\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">           (</span><span style="color:#569CD6">double</span><span style="color:#D4D4D4">)</span><span style="color:#9CDCFE">output</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">params</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">scale</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">output</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">params</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">zero_point</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 3. 循环推理</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"  </span><span style="color:#9CDCFE">%-12s</span><span style="color:#9CDCFE">  %-12s</span><span style="color:#9CDCFE">  %-12s</span><span style="color:#9CDCFE">  %-12s</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"x"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"sin(x)真实值"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"推理结果"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"误差"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"  </span><span style="color:#9CDCFE">%-12s</span><span style="color:#9CDCFE">  %-12s</span><span style="color:#9CDCFE">  %-12s</span><span style="color:#9CDCFE">  %-12s</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"--------"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"----------"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"--------"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"--------"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C;= </span><span style="color:#B5CEA8">20</span><span style="color:#D4D4D4">; i++) {</span></span>
<span class="line"><span style="color:#569CD6">        float</span><span style="color:#D4D4D4"> x = (</span><span style="color:#569CD6">float</span><span style="color:#D4D4D4">)i / </span><span style="color:#B5CEA8">20.0f</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">2.0f</span><span style="color:#D4D4D4"> * M_PI;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">        // float → int8</span></span>
<span class="line"><span style="color:#569CD6">        int8_t</span><span style="color:#D4D4D4"> x_quant = (</span><span style="color:#569CD6">int8_t</span><span style="color:#D4D4D4">)(x / </span><span style="color:#9CDCFE">input</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">params</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">scale</span><span style="color:#D4D4D4"> + </span><span style="color:#9CDCFE">input</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">params</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">zero_point</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#9CDCFE">        input</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">int8</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">] = x_quant;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">        // 推理（ESP-NN 自动加速）</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">interpreter</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">Invoke</span><span style="color:#D4D4D4">() != kTfLiteOk) {</span></span>
<span class="line"><span style="color:#DCDCAA">            printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"ERROR: 推理失败</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">            return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">        // int8 → float</span></span>
<span class="line"><span style="color:#569CD6">        int8_t</span><span style="color:#D4D4D4"> y_quant = </span><span style="color:#9CDCFE">output</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">int8</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#569CD6">        float</span><span style="color:#D4D4D4"> y_pred = (y_quant - </span><span style="color:#9CDCFE">output</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">params</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">zero_point</span><span style="color:#D4D4D4">) * </span><span style="color:#9CDCFE">output</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">params</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">scale</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">        float</span><span style="color:#D4D4D4"> y_true = </span><span style="color:#DCDCAA">sinf</span><span style="color:#D4D4D4">(x);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"  </span><span style="color:#9CDCFE">%-12.4f</span><span style="color:#9CDCFE">  %-12.4f</span><span style="color:#9CDCFE">  %-12.4f</span><span style="color:#9CDCFE">  %-12.4f</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">               x, y_true, y_pred, </span><span style="color:#DCDCAA">fabsf</span><span style="color:#D4D4D4">(y_pred - y_true));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">        vTaskDelay</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">pdMS_TO_TICKS</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">500</span><span style="color:#D4D4D4">));</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">推理完成!</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>编译烧录</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">idf.py</span><span style="color:#CE9178"> set-target</span><span style="color:#CE9178"> esp32s3</span></span>
<span class="line"><span style="color:#DCDCAA">idf.py</span><span style="color:#CE9178"> build</span></span>
<span class="line"><span style="color:#DCDCAA">idf.py</span><span style="color:#569CD6"> --port</span><span style="color:#CE9178"> COM3</span><span style="color:#CE9178"> flash</span><span style="color:#CE9178"> monitor</span></span></code></pre><h3>输出</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>========================================</span></span>
<span class="line"><span>  sin_demo: ESP-NN + TFLite Micro</span></span>
<span class="line"><span>========================================</span></span>
<span class="line"><span></span></span>
<span class="line"><span>[OK] 模型已加载 (3456 bytes)</span></span>
<span class="line"><span>[OK] 解释器已初始化 (arena: 2048 bytes)</span></span>
<span class="line"><span>[OK] 输入量化参数:  scale=0.024349  zero_point=-128</span></span>
<span class="line"><span>[OK] 输出量化参数:  scale=0.008386  zero_point=7</span></span>
<span class="line"><span></span></span>
<span class="line"><span>  x             sin(x)真实值  推理结果    误差</span></span>
<span class="line"><span>  --------      ----------    --------    --------</span></span>
<span class="line"><span>  0.0000        0.0000        0.0252      0.0252</span></span>
<span class="line"><span>  0.3142        0.3090        0.3438      0.0348</span></span>
<span class="line"><span>  0.6283        0.5878        0.5786      0.0092</span></span>
<span class="line"><span>  0.9425        0.8090        0.8134      0.0044</span></span>
<span class="line"><span>  1.2566        0.9511        0.9560      0.0049</span></span>
<span class="line"><span>  1.5708        1.0000        1.0063      0.0063</span></span>
<span class="line"><span>  1.8850        0.9511        0.9308      0.0202</span></span>
<span class="line"><span>  2.1991        0.8090        0.7967      0.0124</span></span>
<span class="line"><span>  2.5133        0.5878        0.5115      0.0762</span></span>
<span class="line"><span>  2.8274        0.3090        0.2432      0.0658</span></span>
<span class="line"><span>  3.1416       -0.0000       -0.0168      0.0168</span></span>
<span class="line"><span>  3.4558       -0.3090       -0.2600      0.0491</span></span>
<span class="line"><span>  3.7699       -0.5878       -0.5619      0.0259</span></span>
<span class="line"><span>  4.0841       -0.8090       -0.7967      0.0124</span></span>
<span class="line"><span>  4.3982       -0.9511       -0.9728      0.0217</span></span>
<span class="line"><span>  4.7124       -1.0000       -1.1153      0.1153</span></span>
<span class="line"><span>  5.0265       -0.9511       -0.9224      0.0286</span></span>
<span class="line"><span>  5.3407       -0.8090       -0.7212      0.0878</span></span>
<span class="line"><span>  5.6549       -0.5878       -0.5115      0.0762</span></span>
<span class="line"><span>  5.9690       -0.3090       -0.3187      0.0096</span></span>
<span class="line"><span>  6.2832        0.0000        0.0419      0.0419</span></span>
<span class="line"><span></span></span>
<span class="line"><span>推理完成!</span></span></code></pre><h2>ESP-NN加速机制</h2>
<p>ESP-NN通过<code>-DESP_NN</code>宏替换TFLite Micro的默认算子实现。本例涉及的是<code>FullyConnected</code>算子。</p>
<p>ESP32-S3的加速比最高，因其有PIE向量指令，一条指令同时处理4个int8数据。ESP-NN的汇编内核直接利用了这个特性。</p>
<p>性能数据来源：<a href="https://github.com/espressif/esp-nn">ESP-NN GitHub</a> 和 <a href="https://components.espressif.com/components/espressif/esp-nn">ESP Component Registry</a> 中的Person Detection示例基准测试。</p>
<h2>后续</h2>
<ul>
<li>换更大的模型（如CNN做MNIST），体会卷积层的量化</li>
<li>用<a href="https://github.com/espressif/esp-dl">ESP-DL</a>替代TFLite Micro，支持双核调度和静态内存规划</li>
<li>接传感器，把<code>input-&gt;data.int8</code>换成真实的传感器数据</li>
</ul>
<p>链路不变：训练 → 量化 → 转C → 推理。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="ESP32-S3"/>
    <category term="ESP-NN"/>
    <category term="TFLite Micro"/>
    <category term="模型量化"/>
    <category term="边缘AI"/>
  </entry>
  <entry>
    <title>A* 在栅格地图上搜索低代价路径的工程解释</title>
    <link href="https://createskyblue.github.io/posts/astar-grid-path-planning-svg/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/astar-grid-path-planning-svg/</id>
    <published>2026-05-31T00:00:00.000Z</published>
    <updated>2026-05-31T00:00:00.000Z</updated>
    <summary>A*栅格路径规划可视化</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>A* 在栅格地图上搜索低代价路径的工程解释</h1>
<p>A* 是机器人路径规划里最常见的搜索算法之一。它解决的问题很具体：给定一张栅格地图、一个起点、一个终点和一组障碍物，搜索一条从起点到终点的低代价可行路径。</p>
<p>在室内导航项目里，A* 通常不直接运行在高精度传感器地图上。SLAM 可能维护 2cm 分辨率地图，但规划常用 5cm、10cm 或更粗的栅格。原因很直接：格子越多，搜索空间越大，open set、closed set、父节点数组和代价数组都会占用更多 RAM。</p>
<h2>A* 要解决的问题</h2>
<p>先把环境离散成网格。每个格子有状态：</p>
<ul>
<li>可通行</li>
<li>障碍</li>
<li>未知</li>
<li>高代价区域</li>
</ul>
<p>然后给定起点 <code>S</code> 和终点 <code>G</code>，A* 在网格上扩展节点，直到找到一条通向目标的路径。</p>
<p>下面的 SVG 使用固定 <code>10x10</code> 网格运行 8 连通 A* 后绘制。示例参数如下：</p>
<ul>
<li>起点：<code>(1, 8)</code>。</li>
<li>目标：<code>(8, 1)</code>。</li>
<li>障碍：<code>(4,1)..(4,6)</code>，<code>(6,4)</code>，<code>(7,4)</code>，<code>(8,4)</code>。</li>
<li>移动代价：正交 <code>10</code>，对角 <code>14</code>。</li>
<li>约束：对角移动禁止穿角。</li>
<li>启发函数：Octile 距离。</li>
</ul>
<div style="margin:16px 0; padding:14px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px;">
<svg viewBox="0 0 420 260" width="100%" role="img" aria-label="A* grid map example">
  <rect x="0" y="0" width="420" height="260" fill="#f8fafc"/>
  <g transform="translate(20,20)">
    <rect x="0" y="0" width="220" height="220" fill="#ffffff" stroke="#cbd5e1"/>
    <g stroke="#e2e8f0" stroke-width="1">
      <path d="M22 0v220 M44 0v220 M66 0v220 M88 0v220 M110 0v220 M132 0v220 M154 0v220 M176 0v220 M198 0v220"/>
      <path d="M0 22h220 M0 44h220 M0 66h220 M0 88h220 M0 110h220 M0 132h220 M0 154h220 M0 176h220 M0 198h220"/>
    </g>
    <g fill="#94a3b8">
      <rect x="88" y="22" width="22" height="22"/>
      <rect x="88" y="44" width="22" height="22"/>
      <rect x="88" y="66" width="22" height="22"/>
      <rect x="88" y="88" width="22" height="22"/>
      <rect x="88" y="110" width="22" height="22"/>
      <rect x="88" y="132" width="22" height="22"/>
      <rect x="132" y="88" width="22" height="22"/>
      <rect x="154" y="88" width="22" height="22"/>
      <rect x="176" y="88" width="22" height="22"/>
    </g>
    <rect x="22" y="176" width="22" height="22" fill="#dcfce7" stroke="#22c55e"/>
    <text x="33" y="191" text-anchor="middle" font-size="12" fill="#166534" font-weight="700">S</text>
    <rect x="176" y="22" width="22" height="22" fill="#dbeafe" stroke="#2563eb"/>
    <text x="187" y="37" text-anchor="middle" font-size="12" fill="#1d4ed8" font-weight="700">G</text>
    <polyline points="33,187 55,165 77,165 99,165 121,165 121,143 121,121 121,99 121,77 143,55 165,33 187,33" fill="none" stroke="#ef4444" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
  </g>
  <g transform="translate(270,46)" font-size="14" fill="#334155">
    <rect x="0" y="0" width="20" height="20" fill="#dcfce7" stroke="#22c55e"/><text x="32" y="15">起点</text>
    <rect x="0" y="36" width="20" height="20" fill="#dbeafe" stroke="#2563eb"/><text x="32" y="51">目标</text>
    <rect x="0" y="72" width="20" height="20" fill="#94a3b8"/><text x="32" y="87">障碍</text>
    <line x1="0" y1="118" x2="22" y2="118" stroke="#ef4444" stroke-width="4" stroke-linecap="round"/><text x="32" y="123">搜索出的路径</text>
  </g>
</svg>
</div>

<p>这个图里，灰色格子是障碍。红线来自 A* 回溯出的父节点链：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>(1,8) -> (2,7) -> (3,7) -> (4,7) -> (5,7) -> (5,6)</span></span>
<span class="line"><span>      -> (5,5) -> (5,4) -> (5,3) -> (6,2) -> (7,1) -> (8,1)</span></span></code></pre><p>这条路径没有进入障碍格，也没有通过对角移动穿过障碍角点。</p>
<h2>三个代价：g、h、f</h2>
<p>A* 的核心公式只有一个：</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>f</mi><mo stretchy="false">(</mo><mi>n</mi><mo stretchy="false">)</mo><mo>=</mo><mi>g</mi><mo stretchy="false">(</mo><mi>n</mi><mo stretchy="false">)</mo><mo>+</mo><mi>h</mi><mo stretchy="false">(</mo><mi>n</mi><mo stretchy="false">)</mo></mrow><annotation encoding="application/x-tex">f(n) = g(n) + h(n)</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.1076em;">f</span><span class="mopen">(</span><span class="mord mathnormal">n</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">g</span><span class="mopen">(</span><span class="mord mathnormal">n</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">h</span><span class="mopen">(</span><span class="mord mathnormal">n</span><span class="mclose">)</span></span></span></span></span></div>
<p>含义如下：</p>
<table>
<thead>
<tr>
<th>符号</th>
<th>含义</th>
<th>工程解释</th>
</tr>
</thead>
<tbody><tr>
<td><code>g(n)</code></td>
<td>从起点走到当前格子的已知代价</td>
<td>已经花掉的成本</td>
</tr>
<tr>
<td><code>h(n)</code></td>
<td>从当前格子到目标的估计代价</td>
<td>还要花多少成本</td>
</tr>
<tr>
<td><code>f(n)</code></td>
<td>总估计代价</td>
<td>用来决定下一步扩展谁</td>
</tr>
</tbody></table>
<p>A* 每次从 open set 里取出 <code>f</code> 最小的格子扩展。<code>g</code> 让搜索不乱跑，<code>h</code> 让搜索朝目标靠近。</p>
<div style="margin:16px 0; padding:14px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px;">
<svg viewBox="0 0 620 170" width="100%" role="img" aria-label="A star cost formula visualization">
  <rect x="0" y="0" width="620" height="170" fill="#f8fafc"/>
  <g transform="translate(24,34)">
    <rect x="0" y="0" width="150" height="90" rx="8" fill="#ffffff" stroke="#dcfce7"/>
    <text x="75" y="34" text-anchor="middle" font-size="24" fill="#166534" font-weight="700">g(n)</text>
    <text x="75" y="62" text-anchor="middle" font-size="13" fill="#475569">起点到当前格</text>
  </g>
  <text x="205" y="88" text-anchor="middle" font-size="30" fill="#64748b">+</text>
  <g transform="translate(234,34)">
    <rect x="0" y="0" width="150" height="90" rx="8" fill="#ffffff" stroke="#dbeafe"/>
    <text x="75" y="34" text-anchor="middle" font-size="24" fill="#1d4ed8" font-weight="700">h(n)</text>
    <text x="75" y="62" text-anchor="middle" font-size="13" fill="#475569">当前格到目标估计</text>
  </g>
  <text x="415" y="88" text-anchor="middle" font-size="30" fill="#64748b">=</text>
  <g transform="translate(444,34)">
    <rect x="0" y="0" width="150" height="90" rx="8" fill="#ffffff" stroke="#fef08a"/>
    <text x="75" y="34" text-anchor="middle" font-size="24" fill="#854d0e" font-weight="700">f(n)</text>
    <text x="75" y="62" text-anchor="middle" font-size="13" fill="#475569">扩展优先级</text>
  </g>
</svg>
</div>

<p><code>h(n)</code> 叫启发函数。它不能随便写。常见选择：</p>
<ul>
<li>4 连通网格：曼哈顿距离。</li>
<li>8 连通网格：Octile 距离。</li>
<li>任意方向连续空间：欧氏距离。</li>
</ul>
<p>在 8 连通栅格里，正交移动代价常设为 <code>10</code>，对角移动代价常设为 <code>14</code>，因为 <code>14</code> 近似 <code>10 * sqrt(2)</code>。这样可以用整数代价避免浮点。</p>
<h2>open set 和 closed set</h2>
<p>A* 维护两类集合。</p>
<ul>
<li>open set：已经发现，但还没有扩展的格子。</li>
<li>closed set：已经扩展过，不需要重复处理的格子。</li>
</ul>
<p>每一轮循环：</p>
<ol>
<li>从 open set 取出 <code>f</code> 最小的格子。</li>
<li>把它放入 closed set。</li>
<li>检查它的相邻格子。</li>
<li>如果相邻格子可通行，计算新的 <code>g</code>、<code>h</code>、<code>f</code>。</li>
<li>如果这条路更便宜，更新该格子的父节点。</li>
<li>到达目标后，从目标格沿父节点回溯出路径。</li>
</ol>
<div style="margin:16px 0; padding:14px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px;">
<svg viewBox="0 0 520 250" width="100%" role="img" aria-label="A star open and closed set">
  <rect x="0" y="0" width="520" height="250" fill="#f8fafc"/>
  <g transform="translate(28,18)">
    <rect x="0" y="0" width="220" height="220" fill="#ffffff" stroke="#cbd5e1"/>
    <g stroke="#e2e8f0" stroke-width="1">
      <path d="M22 0v220 M44 0v220 M66 0v220 M88 0v220 M110 0v220 M132 0v220 M154 0v220 M176 0v220 M198 0v220"/>
      <path d="M0 22h220 M0 44h220 M0 66h220 M0 88h220 M0 110h220 M0 132h220 M0 154h220 M0 176h220 M0 198h220"/>
    </g>
    <g fill="#94a3b8">
      <rect x="88" y="44" width="22" height="22"/>
      <rect x="88" y="66" width="22" height="22"/>
      <rect x="88" y="88" width="22" height="22"/>
      <rect x="88" y="110" width="22" height="22"/>
      <rect x="110" y="110" width="22" height="22"/>
      <rect x="132" y="110" width="22" height="22"/>
    </g>
    <g fill="#bfdbfe">
      <rect x="22" y="154" width="22" height="22"/>
      <rect x="44" y="154" width="22" height="22"/>
      <rect x="66" y="132" width="22" height="22"/>
      <rect x="66" y="110" width="22" height="22"/>
      <rect x="66" y="88" width="22" height="22"/>
      <rect x="88" y="132" width="22" height="22"/>
    </g>
    <g fill="#fef9c3">
      <rect x="44" y="132" width="22" height="22"/>
      <rect x="44" y="176" width="22" height="22"/>
      <rect x="66" y="154" width="22" height="22"/>
      <rect x="110" y="132" width="22" height="22"/>
      <rect x="66" y="66" width="22" height="22"/>
    </g>
    <rect x="22" y="176" width="22" height="22" fill="#dcfce7" stroke="#22c55e"/>
    <text x="33" y="191" text-anchor="middle" font-size="12" fill="#166534" font-weight="700">S</text>
    <rect x="176" y="22" width="22" height="22" fill="#dbeafe" stroke="#2563eb"/>
    <text x="187" y="37" text-anchor="middle" font-size="12" fill="#1d4ed8" font-weight="700">G</text>
  </g>
  <g transform="translate(300,58)" font-size="14" fill="#334155">
    <rect x="0" y="0" width="20" height="20" fill="#bfdbfe"/><text x="32" y="15">closed set：已经扩展</text>
    <rect x="0" y="40" width="20" height="20" fill="#fef9c3"/><text x="32" y="55">open set：候选边界</text>
    <rect x="0" y="80" width="20" height="20" fill="#94a3b8"/><text x="32" y="95">障碍格</text>
    <rect x="0" y="120" width="20" height="20" fill="#ffffff" stroke="#cbd5e1"/><text x="32" y="135">尚未访问</text>
  </g>
</svg>
</div>

<p>open set 通常用最小堆实现。每次取 <code>f</code> 最小节点的复杂度是 <code>O(log N)</code>，比每轮扫描整个数组更适合大地图。</p>
<h2>4 连通和 8 连通</h2>
<p>4 连通只能上下左右移动。8 连通允许对角移动。</p>
<table>
<thead>
<tr>
<th>连通方式</th>
<th>可移动方向</th>
<th>适合场景</th>
</tr>
</thead>
<tbody><tr>
<td>4 连通</td>
<td>上、下、左、右</td>
<td>机器人不能斜穿格子，路径更保守</td>
</tr>
<tr>
<td>8 连通</td>
<td>上、下、左、右、四个对角</td>
<td>路径更短，更接近真实移动</td>
</tr>
</tbody></table>
<p>8 连通要处理一个额外问题：对角线穿角。如果两个正交相邻格子都是障碍，机器人不能从它们之间斜穿过去。工程实现通常会禁止这种对角移动。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>障碍  可走</span></span>
<span class="line"><span>可走  当前</span></span></code></pre><p>从“当前”斜走到左上角时，左侧和上侧至少要有足够通道，否则会擦过障碍角。</p>
<h2>障碍膨胀和代价地图</h2>
<p>真实机器人有尺寸。即使某个格子没有障碍，贴着障碍边走也可能碰撞。因此规划前通常会做障碍膨胀。</p>
<p>障碍膨胀的做法是：把障碍周围若干格标成高代价区域。离障碍越近，代价越高。</p>
<div style="margin:16px 0; padding:14px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px;">
<svg viewBox="0 0 520 250" width="100%" role="img" aria-label="Obstacle inflation cost map">
  <rect x="0" y="0" width="520" height="250" fill="#f8fafc"/>
  <g transform="translate(28,18)">
    <rect x="0" y="0" width="220" height="220" fill="#ffffff" stroke="#cbd5e1"/>
    <g stroke="#e2e8f0" stroke-width="1">
      <path d="M22 0v220 M44 0v220 M66 0v220 M88 0v220 M110 0v220 M132 0v220 M154 0v220 M176 0v220 M198 0v220"/>
      <path d="M0 22h220 M0 44h220 M0 66h220 M0 88h220 M0 110h220 M0 132h220 M0 154h220 M0 176h220 M0 198h220"/>
    </g>
    <g fill="#fef9c3">
      <rect x="66" y="66" width="22" height="22"/>
      <rect x="88" y="66" width="22" height="22"/>
      <rect x="110" y="66" width="22" height="22"/>
      <rect x="66" y="88" width="22" height="22"/>
      <rect x="110" y="88" width="22" height="22"/>
      <rect x="66" y="110" width="22" height="22"/>
      <rect x="88" y="110" width="22" height="22"/>
      <rect x="110" y="110" width="22" height="22"/>
    </g>
    <g fill="#fee2e2">
      <rect x="88" y="88" width="22" height="22"/>
    </g>
    <rect x="88" y="88" width="22" height="22" fill="#64748b"/>
    <g fill="#dcfce7">
      <rect x="22" y="176" width="22" height="22"/>
      <rect x="44" y="176" width="22" height="22"/>
      <rect x="66" y="176" width="22" height="22"/>
      <rect x="88" y="176" width="22" height="22"/>
      <rect x="110" y="176" width="22" height="22"/>
      <rect x="132" y="154" width="22" height="22"/>
      <rect x="154" y="132" width="22" height="22"/>
    </g>
  </g>
  <g transform="translate(300,54)" font-size="14" fill="#334155">
    <rect x="0" y="0" width="20" height="20" fill="#64748b"/><text x="32" y="15">障碍：不可通行</text>
    <rect x="0" y="40" width="20" height="20" fill="#fee2e2"/><text x="32" y="55">硬膨胀：极高代价</text>
    <rect x="0" y="80" width="20" height="20" fill="#fef9c3"/><text x="32" y="95">软膨胀：增加代价</text>
    <rect x="0" y="120" width="20" height="20" fill="#dcfce7"/><text x="32" y="135">规划路径更倾向远离障碍</text>
  </g>
</svg>
</div>

<p>代价地图会进入 <code>g(n)</code>。路径如果靠近障碍，虽然几何距离短，但总代价可能更高。这样 A* 会倾向于选择更安全的路线。</p>
<h2>A* 的基本伪代码</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">open_set.</span><span style="color:#DCDCAA">push</span><span style="color:#D4D4D4">(start);</span></span>
<span class="line"><span style="color:#9CDCFE">g</span><span style="color:#D4D4D4">[start] = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">parent</span><span style="color:#D4D4D4">[start] = none;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">while</span><span style="color:#D4D4D4"> (!open_set.</span><span style="color:#DCDCAA">empty</span><span style="color:#D4D4D4">()) {</span></span>
<span class="line"><span style="color:#D4D4D4">    current = </span><span style="color:#9CDCFE">open_set</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">pop_min_f</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (current == goal) {</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#DCDCAA"> rebuild_path</span><span style="color:#D4D4D4">(parent, goal);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">    closed</span><span style="color:#D4D4D4">[current] = </span><span style="color:#569CD6">true</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> each neighbor of current {</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (neighbor is obstacle || </span><span style="color:#9CDCFE">closed</span><span style="color:#D4D4D4">[neighbor]) {</span></span>
<span class="line"><span style="color:#C586C0">            continue</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">        tentative_g = </span><span style="color:#9CDCFE">g</span><span style="color:#D4D4D4">[current] + </span><span style="color:#DCDCAA">move_cost</span><span style="color:#D4D4D4">(current, neighbor) + </span><span style="color:#DCDCAA">cell_cost</span><span style="color:#D4D4D4">(neighbor);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (neighbor not in open_set || tentative_g &#x3C; </span><span style="color:#9CDCFE">g</span><span style="color:#D4D4D4">[neighbor]) {</span></span>
<span class="line"><span style="color:#9CDCFE">            parent</span><span style="color:#D4D4D4">[neighbor] = current;</span></span>
<span class="line"><span style="color:#9CDCFE">            g</span><span style="color:#D4D4D4">[neighbor] = tentative_g;</span></span>
<span class="line"><span style="color:#9CDCFE">            h</span><span style="color:#D4D4D4">[neighbor] = </span><span style="color:#DCDCAA">heuristic</span><span style="color:#D4D4D4">(neighbor, goal);</span></span>
<span class="line"><span style="color:#9CDCFE">            f</span><span style="color:#D4D4D4">[neighbor] = </span><span style="color:#9CDCFE">g</span><span style="color:#D4D4D4">[neighbor] + </span><span style="color:#9CDCFE">h</span><span style="color:#D4D4D4">[neighbor];</span></span>
<span class="line"><span style="color:#9CDCFE">            open_set</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">push_or_update</span><span style="color:#D4D4D4">(neighbor, </span><span style="color:#9CDCFE">f</span><span style="color:#D4D4D4">[neighbor]);</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">return</span><span style="color:#D4D4D4"> no_path;</span></span></code></pre><p>工程实现里还会额外处理：</p>
<ul>
<li>起点或终点落在障碍上。</li>
<li>目标附近容差，不要求精确到某个格子。</li>
<li>open set 容量上限。</li>
<li>路径数组长度上限。</li>
<li>旧路径亲和度，减少重规划抖动。</li>
<li>未知区是否允许通行。</li>
</ul>
<h2>在嵌入式导航里的取舍</h2>
<p>嵌入式项目里，A* 的关键约束是内存和时间预算。</p>
<p>以 40 x 40 规划图为例，只有 1600 个格子。可以为每个格子准备：</p>
<ul>
<li><code>g_cost</code></li>
<li><code>parent</code></li>
<li><code>state</code></li>
<li><code>heap_index</code></li>
<li><code>cell_cost</code></li>
</ul>
<p>这些数组可以静态分配，避免动态内存。</p>
<p>如果直接在 200 x 200 地图上搜索，节点数量变成 40000。RAM、open set 和最坏搜索时间都会明显上升。NoeticMaze 这类 MCU 项目通常会保留一张高精度 SLAM 图，再降采样出低分辨率规划图。</p>
<h2>A* 的边界</h2>
<p>A* 只负责在当前代价地图上搜索路径。</p>
<p>它不负责：</p>
<ul>
<li>感知障碍。</li>
<li>估计机器人位置。</li>
<li>控制电机。</li>
<li>修正地图漂移。</li>
<li>预测动态障碍物。</li>
</ul>
<p>它依赖上游模块提供正确地图，依赖下游控制器跟踪路径。地图错了，A* 会在错误地图上认真搜索；控制器跟踪不好，路径再合理也无法稳定执行。</p>
<h2>总结</h2>
<p>A* 的核心可以压缩成一句话：每次扩展 <code>f(n)=g(n)+h(n)</code> 最小的候选格子，并用父节点记录最便宜的来路。</p>
<p>在机器人导航里，真正需要工程化处理的是栅格分辨率、代价地图、障碍膨胀、open set 数据结构、路径平滑和重规划稳定性。算法本身不复杂，复杂的是把它放进有限 RAM、有限 CPU 和真实底盘误差里运行。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="A*"/>
    <category term="路径规划"/>
    <category term="栅格地图"/>
    <category term="机器人导航"/>
    <category term="算法"/>
  </entry>
  <entry>
    <title>嵌入式 GUI 脏区域局部刷新机制：LVGL 与 ARM-2D 源码级实现对比</title>
    <link href="https://createskyblue.github.io/posts/dirty-region-lvgl-arm2d/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/dirty-region-lvgl-arm2d/</id>
    <published>2026-05-31T00:00:00.000Z</published>
    <updated>2026-05-31T00:00:00.000Z</updated>
    <summary>LVGL脏区域+ARM-2D提高帧率，减少刷新数据量</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>嵌入式 GUI 脏区域局部刷新机制：LVGL 与 ARM-2D 源码级实现对比</h1>
<p>屏幕上只有一个小按钮变了，为什么要整屏重画？脏区域（Dirty Region）机制记录哪些区域内容失效，刷新时只重绘这些区域，减少 CPU 绘制和 LCD 传输开销。</p>
<h2>两种策略：脏矩形 vs 脏矩阵</h2>
<p><strong>脏矩形</strong>：记录每个变化区域的精确矩形坐标。区域精确，但合并算法复杂。LVGL 采用此方案。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">typedef</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">    int</span><span style="color:#D4D4D4"> x, y, w, h;</span></span>
<span class="line"><span style="color:#D4D4D4">} </span><span style="color:#4EC9B0">dirty_rect_t</span><span style="color:#D4D4D4">;</span></span></code></pre><p><strong>脏矩阵</strong>：把屏幕切成网格，用二维数组记录哪些块脏了。逻辑简单、内存可控，适合低端 MCU。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> TILE_W  </span><span style="color:#B5CEA8">16</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> TILE_H  </span><span style="color:#B5CEA8">16</span></span>
<span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#9CDCFE"> dirty_matrix</span><span style="color:#D4D4D4">[ROWS][COLS];</span><span style="color:#6A9955">  // 0=干净, 1=脏</span></span></code></pre><p>下面是一个 320×240 屏幕按 16×16 分块的脏矩阵可视化。按钮区域 (x=30, y=40, w=80, h=48) 发生变化，对应网格被标脏：</p>
<div style="margin:20px 0;text-align:center;">
<svg viewBox="0 0 440 350" style="max-width:500px;width:100%;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;">
  <text x="220" y="18" text-anchor="middle" font-size="11" fill="#64748b">320×240 屏幕 · 16×16 分块 · 20×15 网格</text>
  <g transform="translate(30,30)">
    <!-- grid bg -->
    <rect x="0" y="0" width="380" height="300" fill="#f0fdf4" stroke="#cbd5e1" stroke-width="0.5"/>
    <!-- grid lines -->
    <g stroke="#d1d5db" stroke-width="0.3" opacity="0.6">
      <line x1="0" y1="20" x2="380" y2="20"/><line x1="0" y1="40" x2="380" y2="40"/><line x1="0" y1="60" x2="380" y2="60"/><line x1="0" y1="80" x2="380" y2="80"/><line x1="0" y1="100" x2="380" y2="100"/><line x1="0" y1="120" x2="380" y2="120"/><line x1="0" y1="140" x2="380" y2="140"/><line x1="0" y1="160" x2="380" y2="160"/><line x1="0" y1="180" x2="380" y2="180"/><line x1="0" y1="200" x2="380" y2="200"/><line x1="0" y1="220" x2="380" y2="220"/><line x1="0" y1="240" x2="380" y2="240"/><line x1="0" y1="260" x2="380" y2="260"/><line x1="0" y1="280" x2="380" y2="280"/>
      <line x1="20" y1="0" x2="20" y2="300"/><line x1="40" y1="0" x2="40" y2="300"/><line x1="60" y1="0" x2="60" y2="300"/><line x1="80" y1="0" x2="80" y2="300"/><line x1="100" y1="0" x2="100" y2="300"/><line x1="120" y1="0" x2="120" y2="300"/><line x1="140" y1="0" x2="140" y2="300"/><line x1="160" y1="0" x2="160" y2="300"/><line x1="180" y1="0" x2="180" y2="300"/><line x1="200" y1="0" x2="200" y2="300"/><line x1="220" y1="0" x2="220" y2="300"/><line x1="240" y1="0" x2="240" y2="300"/><line x1="260" y1="0" x2="260" y2="300"/><line x1="280" y1="0" x2="280" y2="300"/><line x1="300" y1="0" x2="300" y2="300"/><line x1="320" y1="0" x2="320" y2="300"/><line x1="340" y1="0" x2="340" y2="300"/><line x1="360" y1="0" x2="360" y2="300"/>
    </g>
    <!-- dirty tiles: row 2-5, col 1-6 -->
    <rect x="20" y="40" width="120" height="80" fill="#fecaca" stroke="#ef4444" stroke-width="1.5" rx="1"/>
    <!-- button outline -->
    <rect x="30" y="40" width="80" height="48" rx="3" fill="none" stroke="#2563eb" stroke-width="1.5" stroke-dasharray="4,2"/>
    <text x="70" y="68" text-anchor="middle" font-size="9" fill="#2563eb" font-weight="600">按钮</text>
    <!-- col numbers -->
    <text x="10" y="316" text-anchor="middle" font-size="7" fill="#94a3b8">0</text>
    <text x="30" y="316" text-anchor="middle" font-size="7" fill="#ef4444" font-weight="700">1</text>
    <text x="50" y="316" text-anchor="middle" font-size="7" fill="#ef4444" font-weight="700">2</text>
    <text x="70" y="316" text-anchor="middle" font-size="7" fill="#ef4444" font-weight="700">3</text>
    <text x="90" y="316" text-anchor="middle" font-size="7" fill="#ef4444" font-weight="700">4</text>
    <text x="110" y="316" text-anchor="middle" font-size="7" fill="#ef4444" font-weight="700">5</text>
    <text x="130" y="316" text-anchor="middle" font-size="7" fill="#ef4444" font-weight="700">6</text>
    <text x="150" y="316" text-anchor="middle" font-size="7" fill="#94a3b8">7</text>
    <text x="370" y="316" text-anchor="middle" font-size="7" fill="#94a3b8">19</text>
    <!-- row numbers -->
    <text x="-6" y="14" text-anchor="end" font-size="7" fill="#94a3b8">0</text>
    <text x="-6" y="34" text-anchor="end" font-size="7" fill="#94a3b8">1</text>
    <text x="-6" y="54" text-anchor="end" font-size="7" fill="#ef4444" font-weight="700">2</text>
    <text x="-6" y="74" text-anchor="end" font-size="7" fill="#ef4444" font-weight="700">3</text>
    <text x="-6" y="94" text-anchor="end" font-size="7" fill="#ef4444" font-weight="700">4</text>
    <text x="-6" y="114" text-anchor="end" font-size="7" fill="#ef4444" font-weight="700">5</text>
    <text x="-6" y="134" text-anchor="end" font-size="7" fill="#94a3b8">6</text>
    <text x="-6" y="294" text-anchor="end" font-size="7" fill="#94a3b8">14</text>
    <!-- dimension labels -->
    <text x="80" y="335" text-anchor="middle" font-size="8" fill="#64748b">← col 1~6 (x: 16~112px) →</text>
    <text x="-22" y="82" text-anchor="middle" font-size="8" fill="#64748b" transform="rotate(-90,-22,82)">← row 2~5 →</text>
  </g>
</svg>
<div style="font-size:12px;color:#64748b;margin-top:6px;">
  <span style="display:inline-block;width:12px;height:12px;background:#fecaca;border:1px solid #ef4444;vertical-align:middle;margin-right:4px;"></span>脏网格 24/300 = 8%
  <span style="margin-left:16px;display:inline-block;width:12px;height:12px;background:#f0fdf4;border:1px solid #86efac;vertical-align:middle;margin-right:4px;"></span>干净网格
  <span style="margin-left:16px;border:1.5px dashed #2563eb;display:inline-block;width:12px;height:12px;vertical-align:middle;margin-right:4px;"></span>按钮实际区域
</div>
</div>

<p>标脏计算：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>col_start = 30 / 16 = 1        col_end = (30+80-1) / 16 = 6</span></span>
<span class="line"><span>row_start = 40 / 16 = 2        row_end = (40+48-1) / 16 = 5</span></span>
<span class="line"><span>→ 标记 row 2~5, col 1~6 共 4×6 = 24 个网格块</span></span></code></pre><table>
<thead>
<tr>
<th>特性</th>
<th>脏矩形</th>
<th>脏矩阵</th>
</tr>
</thead>
<tbody><tr>
<td>精度</td>
<td>像素级</td>
<td>块级</td>
</tr>
<tr>
<td>内存</td>
<td>取决于矩形数量</td>
<td>固定</td>
</tr>
<tr>
<td>合并</td>
<td>复杂</td>
<td>天然连续</td>
</tr>
<tr>
<td>代表</td>
<td>LVGL、Qt</td>
<td>部分低端嵌入式 GUI</td>
</tr>
</tbody></table>
<p>全量刷新 vs 局部刷新的 SPI 传输量对比（240×240 RGB565 屏幕）：</p>
<div style="margin:16px 0;">
<svg viewBox="0 0 520 180" style="max-width:560px;width:100%;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;">
  <text x="260" y="20" text-anchor="middle" font-size="11" fill="#64748b">SPI 传输量对比（240×240 RGB565）</text>
  <!-- full refresh bar -->
  <text x="10" y="58" font-size="10" fill="#334155">全量刷新</text>
  <rect x="100" y="44" width="360" height="24" fill="#fca5a5" stroke="#ef4444" stroke-width="1" rx="4"/>
  <text x="280" y="60" text-anchor="middle" font-size="9" fill="#991b1b" font-weight="600">115,200 bytes (240×240×2)</text>
  <!-- partial refresh bar -->
  <text x="10" y="100" font-size="10" fill="#334155">局部刷新</text>
  <rect x="100" y="86" width="60" height="24" fill="#86efac" stroke="#16a34a" stroke-width="1" rx="4"/>
  <text x="170" y="102" text-anchor="start" font-size="9" fill="#166534" font-weight="600">6,400 bytes (80×40×2)</text>
  <!-- ratio -->
  <text x="260" y="140" text-anchor="middle" font-size="11" fill="#1d4ed8" font-weight="700">局部刷新仅需 5.6% 的数据量，差 18 倍</text>
  <text x="260" y="158" text-anchor="middle" font-size="9" fill="#64748b">SPI 40MHz 下：全量 ~23ms vs 局部 ~1.3ms（不含绘制开销）</text>
</svg>
</div>

<h2>LVGL 的脏区域实现</h2>
<h3>核心数据结构</h3>
<p>脏区域管理在 <code>lv_display_private.h</code> 中定义：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> LV_INV_BUF_SIZE </span><span style="color:#B5CEA8">32</span><span style="color:#6A9955">  // 最多记录 32 个脏区域</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">struct </span><span style="color:#4EC9B0">_lv_display_t</span><span style="color:#569CD6"> {</span></span>
<span class="line"><span style="color:#4EC9B0">    lv_area_t</span><span style="color:#9CDCFE">  inv_areas</span><span style="color:#D4D4D4">[LV_INV_BUF_SIZE];</span><span style="color:#6A9955">      // 32 个矩形区域</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#9CDCFE">    inv_area_joined</span><span style="color:#D4D4D4">[LV_INV_BUF_SIZE];</span><span style="color:#6A9955"> // 合并标记</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4">   inv_p;</span><span style="color:#6A9955">                            // 当前脏区域计数</span></span>
<span class="line"><span style="color:#4EC9B0">    lv_display_render_mode_t</span><span style="color:#D4D4D4"> render_mode;</span><span style="color:#6A9955">        // PARTIAL / DIRECT / FULL</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4">   tile_cnt : </span><span style="color:#B5CEA8">8</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">                     // 并行渲染 tile 数量</span></span>
<span class="line"><span style="color:#4EC9B0">    lv_draw_buf_t</span><span style="color:#D4D4D4"> *buf_1;</span><span style="color:#6A9955">    // 缓冲区 1</span></span>
<span class="line"><span style="color:#4EC9B0">    lv_draw_buf_t</span><span style="color:#D4D4D4"> *buf_2;</span><span style="color:#6A9955">    // 缓冲区 2（双缓冲）</span></span>
<span class="line"><span style="color:#4EC9B0">    lv_draw_buf_t</span><span style="color:#D4D4D4"> *buf_act;</span><span style="color:#6A9955">  // 当前活动缓冲区</span></span>
<span class="line"><span style="color:#4EC9B0">    lv_display_flush_cb_t</span><span style="color:#D4D4D4"> flush_cb;</span><span style="color:#6A9955">  // 写入 LCD 的回调</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><p><code>lv_area_t</code> 用四个坐标定义矩形：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">typedef</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">    int32_t</span><span style="color:#D4D4D4"> x1, y1;</span><span style="color:#6A9955">  // 左上角</span></span>
<span class="line"><span style="color:#569CD6">    int32_t</span><span style="color:#D4D4D4"> x2, y2;</span><span style="color:#6A9955">  // 右下角</span></span>
<span class="line"><span style="color:#D4D4D4">} </span><span style="color:#4EC9B0">lv_area_t</span><span style="color:#D4D4D4">;</span></span></code></pre><h3>标脏流程</h3>
<p>控件状态变化时，调用链为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>lv_obj_invalidate(obj)</span></span>
<span class="line"><span>  → obj_invalidate_area_internal(disp, obj, &#x26;obj_coords)</span></span>
<span class="line"><span>    → invalidate_area_core(obj, &#x26;area_tmp)</span></span>
<span class="line"><span>      → lv_inv_area(disp, area_p)</span></span></code></pre><p><code>lv_inv_area()</code> 核心逻辑（<code>src/core/lv_refr.c</code>）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#4EC9B0">lv_result_t</span><span style="color:#DCDCAA"> lv_inv_area</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">lv_display_t</span><span style="color:#D4D4D4"> * </span><span style="color:#9CDCFE">disp</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">const</span><span style="color:#4EC9B0"> lv_area_t</span><span style="color:#D4D4D4"> * </span><span style="color:#9CDCFE">area_p</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#6A9955">    // 1. 裁剪到屏幕范围</span></span>
<span class="line"><span style="color:#4EC9B0">    lv_area_t</span><span style="color:#D4D4D4"> com_area;</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4">(!</span><span style="color:#DCDCAA">lv_area_intersect</span><span style="color:#D4D4D4">(&#x26;com_area, area_p, &#x26;scr_area))</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4"> LV_RESULT_INVALID;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 2. 全屏模式：直接标记整个屏幕</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">disp</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">render_mode</span><span style="color:#D4D4D4"> == LV_DISPLAY_RENDER_MODE_FULL) {</span></span>
<span class="line"><span style="color:#9CDCFE">        disp</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">inv_areas</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">] = scr_area;</span></span>
<span class="line"><span style="color:#9CDCFE">        disp</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">inv_p</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4"> LV_RESULT_OK;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 3. 去重：如果新区域已被包含，跳过</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4">(i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; </span><span style="color:#9CDCFE">disp</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">inv_p</span><span style="color:#D4D4D4">; i++) {</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">lv_area_is_in</span><span style="color:#D4D4D4">(&#x26;com_area, &#x26;</span><span style="color:#9CDCFE">disp</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">inv_areas</span><span style="color:#D4D4D4">[i], </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">))</span></span>
<span class="line"><span style="color:#C586C0">            return</span><span style="color:#D4D4D4"> LV_RESULT_OK;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 4. 缓冲区满则回退到整屏刷新</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">disp</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">inv_p</span><span style="color:#D4D4D4"> >= LV_INV_BUF_SIZE) {</span></span>
<span class="line"><span style="color:#9CDCFE">        disp</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">inv_p</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">        tmp_area_p = &#x26;scr_area;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#DCDCAA">    lv_area_copy</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">disp</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">inv_areas</span><span style="color:#D4D4D4">[</span><span style="color:#9CDCFE">disp</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">inv_p</span><span style="color:#D4D4D4">], tmp_area_p);</span></span>
<span class="line"><span style="color:#9CDCFE">    disp</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">inv_p</span><span style="color:#D4D4D4">++;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>区域合并</h3>
<p>刷新前，<code>lv_refr_join_area()</code> 合并重叠或相邻的脏区域：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> lv_refr_join_area</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4">(join_in = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; join_in &#x3C; </span><span style="color:#9CDCFE">disp_refr</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">inv_p</span><span style="color:#D4D4D4">; join_in++) {</span></span>
<span class="line"><span style="color:#C586C0">        for</span><span style="color:#D4D4D4">(join_from = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; join_from &#x3C; </span><span style="color:#9CDCFE">disp_refr</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">inv_p</span><span style="color:#D4D4D4">; join_from++) {</span></span>
<span class="line"><span style="color:#C586C0">            if</span><span style="color:#D4D4D4">(!</span><span style="color:#DCDCAA">lv_area_is_on</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">inv_areas</span><span style="color:#D4D4D4">[join_in], &#x26;</span><span style="color:#9CDCFE">inv_areas</span><span style="color:#D4D4D4">[join_from]))</span></span>
<span class="line"><span style="color:#C586C0">                continue</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">            lv_area_join</span><span style="color:#D4D4D4">(&#x26;joined_area, &#x26;</span><span style="color:#9CDCFE">inv_areas</span><span style="color:#D4D4D4">[join_in], &#x26;</span><span style="color:#9CDCFE">inv_areas</span><span style="color:#D4D4D4">[join_from]);</span></span>
<span class="line"><span style="color:#6A9955">            // 只有合并后面积更小时才合并</span></span>
<span class="line"><span style="color:#C586C0">            if</span><span style="color:#D4D4D4">(</span><span style="color:#DCDCAA">lv_area_get_size</span><span style="color:#D4D4D4">(&#x26;joined_area) &#x3C;</span></span>
<span class="line"><span style="color:#DCDCAA">               lv_area_get_size</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">inv_areas</span><span style="color:#D4D4D4">[join_in]) +</span></span>
<span class="line"><span style="color:#DCDCAA">               lv_area_get_size</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">inv_areas</span><span style="color:#D4D4D4">[join_from])) {</span></span>
<span class="line"><span style="color:#DCDCAA">                lv_area_copy</span><span style="color:#D4D4D4">(&#x26;</span><span style="color:#9CDCFE">inv_areas</span><span style="color:#D4D4D4">[join_in], &#x26;joined_area);</span></span>
<span class="line"><span style="color:#9CDCFE">                inv_area_joined</span><span style="color:#D4D4D4">[join_from] = </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">            }</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>刷新流程</h3>
<p>定时器驱动 <code>lv_display_refr_timer()</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>lv_obj_update_layout()    → 更新布局</span></span>
<span class="line"><span>lv_refr_join_area()       → 合并脏区域</span></span>
<span class="line"><span>refr_sync_areas()         → 同步双缓冲区</span></span>
<span class="line"><span>refr_invalid_areas()      → 逐区域渲染</span></span>
<span class="line"><span>清空 inv_areas[]           → 完成</span></span></code></pre><p>在 PARTIAL 模式下，<code>refr_invalid_areas()</code> 将脏区域切分为多个 row band：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">int32_t</span><span style="color:#D4D4D4"> max_row = </span><span style="color:#DCDCAA">get_max_row</span><span style="color:#D4D4D4">(disp, w, h);</span></span>
<span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4">(row = inv_a.y1; row &#x3C;= inv_a.y2; row += max_row) {</span></span>
<span class="line"><span style="color:#D4D4D4">    sub_area = {</span><span style="color:#9CDCFE">inv_a</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">x1</span><span style="color:#D4D4D4">, row, </span><span style="color:#9CDCFE">inv_a</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">x2</span><span style="color:#D4D4D4">, row+max_row-</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#DCDCAA">    refr_area</span><span style="color:#D4D4D4">(&#x26;sub_area, y_off);</span></span>
<span class="line"><span style="color:#DCDCAA">    draw_buf_flush</span><span style="color:#D4D4D4">(disp);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><div style="margin:20px 0;text-align:center;">
<svg viewBox="0 0 520 300" style="max-width:560px;width:100%;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;">
  <text x="260" y="18" text-anchor="middle" font-size="11" fill="#64748b">LVGL PARTIAL 模式：脏区域 → row band 切分</text>
  <!-- left: dirty area -->
  <g transform="translate(30,40)">
    <text x="60" y="-4" text-anchor="middle" font-size="10" fill="#334155" font-weight="600">脏区域</text>
    <rect x="0" y="0" width="120" height="200" fill="#e2e8f0" stroke="#94a3b8" stroke-width="1" rx="2"/>
    <rect x="10" y="20" width="100" height="160" fill="#fecaca" stroke="#ef4444" stroke-width="1.5" rx="1"/>
    <text x="60" y="105" text-anchor="middle" font-size="9" fill="#991b1b">inv_a</text>
    <text x="60" y="118" text-anchor="middle" font-size="8" fill="#991b1b">(x1,y1)~(x2,y2)</text>
    <text x="60" y="215" text-anchor="middle" font-size="8" fill="#94a3b8">整个屏幕</text>
  </g>
  <!-- arrow -->
  <g transform="translate(175,120)">
    <text x="25" y="-2" text-anchor="middle" font-size="9" fill="#64748b">切分</text>
    <line x1="0" y1="10" x2="40" y2="10" stroke="#64748b" stroke-width="1.5" marker-end="url(#arrowhead)"/>
    <defs><marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#64748b"/></marker></defs>
  </g>
  <!-- right: row bands -->
  <g transform="translate(240,40)">
    <text x="120" y="-4" text-anchor="middle" font-size="10" fill="#334155" font-weight="600">Row Band 切分结果</text>
    <!-- band 0 -->
    <rect x="0" y="20" width="240" height="36" fill="#bfdbfe" stroke="#2563eb" stroke-width="1" rx="2"/>
    <text x="120" y="42" text-anchor="middle" font-size="9" fill="#1e40af">Band 0: row 0~max_row-1</text>
    <text x="250" y="42" text-anchor="start" font-size="8" fill="#64748b">→ refr + flush</text>
    <!-- band 1 -->
    <rect x="0" y="60" width="240" height="36" fill="#93c5fd" stroke="#2563eb" stroke-width="1" rx="2"/>
    <text x="120" y="82" text-anchor="middle" font-size="9" fill="#1e40af">Band 1: row max_row~2*max_row-1</text>
    <text x="250" y="82" text-anchor="start" font-size="8" fill="#64748b">→ refr + flush</text>
    <!-- band 2 -->
    <rect x="0" y="100" width="240" height="36" fill="#bfdbfe" stroke="#2563eb" stroke-width="1" rx="2"/>
    <text x="120" y="122" text-anchor="middle" font-size="9" fill="#1e40af">Band 2: row 2*max_row~3*max_row-1</text>
    <text x="250" y="122" text-anchor="start" font-size="8" fill="#64748b">→ refr + flush</text>
    <!-- dots -->
    <text x="120" y="150" text-anchor="middle" font-size="14" fill="#94a3b8">···</text>
    <!-- last band -->
    <rect x="0" y="155" width="240" height="28" fill="#dbeafe" stroke="#2563eb" stroke-width="1" rx="2" stroke-dasharray="3,2"/>
    <text x="120" y="173" text-anchor="middle" font-size="9" fill="#1e40af">Band N: row N*max_row~y2 (剩余)</text>
    <text x="250" y="173" text-anchor="start" font-size="8" fill="#64748b">→ refr + flush</text>
    <!-- annotation -->
    <text x="120" y="205" text-anchor="middle" font-size="9" fill="#059669">每个 band 独立渲染 + flush 到 LCD</text>
    <text x="120" y="220" text-anchor="middle" font-size="8" fill="#64748b">max_row = draw_buf_size / stride</text>
  </g>
</svg>
</div>

<h3>三种渲染模式</h3>
<table>
<thead>
<tr>
<th>模式</th>
<th>Buffer</th>
<th>脏区域处理</th>
<th>RAM</th>
<th>适用平台</th>
</tr>
</thead>
<tbody><tr>
<td>PARTIAL</td>
<td>小 buffer</td>
<td>切分为 row band 逐块渲染</td>
<td>极低</td>
<td>STM32/ESP32</td>
</tr>
<tr>
<td>DIRECT</td>
<td>完整 framebuffer</td>
<td>直接渲染脏区域</td>
<td>高</td>
<td>Linux/高配嵌入式</td>
</tr>
<tr>
<td>FULL</td>
<td>完整 framebuffer</td>
<td>忽略脏区域，全屏刷新</td>
<td>最高</td>
<td>电子墨水屏</td>
</tr>
</tbody></table>
<h2>ARM-2D 的脏区域实现</h2>
<p>ARM-2D（ARM-software/arm-2d）是 ARM 官方的 2D 图形加速库，专为 Cortex-M 优化。脏区域管理放在 Helper 层，每个 Scene 独立管理。</p>
<h3>核心数据结构</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">typedef</span><span style="color:#569CD6"> struct</span><span style="color:#4EC9B0"> arm_2d_region_t</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#4EC9B0">    arm_2d_location_t</span><span style="color:#D4D4D4"> tLocation;</span><span style="color:#6A9955">  // {int16_t iX, iY}</span></span>
<span class="line"><span style="color:#4EC9B0">    arm_2d_size_t</span><span style="color:#D4D4D4">     tSize;</span><span style="color:#6A9955">      // {int16_t iWidth, iHeight}</span></span>
<span class="line"><span style="color:#D4D4D4">} </span><span style="color:#4EC9B0">arm_2d_region_t</span><span style="color:#D4D4D4">;</span></span></code></pre><p><code>arm_2d_tile_t</code> 同时描述区域和像素缓冲区：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">struct</span><span style="color:#4EC9B0"> arm_2d_tile_t</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">        uint8_t</span><span style="color:#D4D4D4"> bIsRoot           : </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">        uint8_t</span><span style="color:#D4D4D4"> bVirtualScreen    : </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span><span style="color:#6A9955">  // 用于脏区域计算</span></span>
<span class="line"><span style="color:#569CD6">        uint8_t</span><span style="color:#D4D4D4"> u3ExtensionID     : </span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    } tInfo;</span></span>
<span class="line"><span style="color:#4EC9B0">    arm_2d_region_t</span><span style="color:#D4D4D4"> tRegion;</span></span>
<span class="line"><span style="color:#569CD6">    union</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#4EC9B0">        arm_2d_tile_t</span><span style="color:#D4D4D4"> *ptParent;</span><span style="color:#6A9955">   // 父 tile</span></span>
<span class="line"><span style="color:#569CD6">        uint16_t</span><span style="color:#D4D4D4">      *phwBuffer;</span><span style="color:#6A9955">  // 像素缓冲区</span></span>
<span class="line"><span style="color:#D4D4D4">    };</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><h3>配置参数</h3>
<p>在 <code>arm_2d_disp_adapter_0.h</code> 中配置：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> __DISP0_CFG_OPTIMIZE_DIRTY_REGIONS__     </span><span style="color:#B5CEA8">1</span><span style="color:#6A9955">   // 启用优化</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> __DISP0_CFG_DIRTY_REGION_POOL_SIZE__      </span><span style="color:#B5CEA8">8</span><span style="color:#6A9955">   // 脏区域池大小</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> __DISP0_CFG_PFB_BLOCK_WIDTH__             </span><span style="color:#B5CEA8">320</span><span style="color:#6A9955"> // PFB 块宽度</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> __DISP0_CFG_PFB_BLOCK_HEIGHT__            </span><span style="color:#B5CEA8">24</span><span style="color:#6A9955">  // PFB 块高度</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> __DISP0_CFG_PFB_HEAP_SIZE__               </span><span style="color:#B5CEA8">1</span><span style="color:#6A9955">   // PFB 块数量</span></span></code></pre><p>PFB（Partial Frame Buffer）大小 = 320 × 24 × 2 = 15 KB，相比全屏 320 × 240 × 2 = 153 KB，节省 90% 内存。</p>
<h3>脏区域池</h3>
<p>ARM-2D 使用对象池模式管理脏区域，避免动态内存分配：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#4EC9B0">arm_2d_region_list_item_t</span><span style="color:#D4D4D4"> *</span></span>
<span class="line"><span style="color:#DCDCAA">__arm_2d_helper_dirty_region_pool_new</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">arm_2d_helper_pfb_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ptThis</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> __arm_2d_helper_dirty_region_pool_free</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#4EC9B0">    arm_2d_helper_pfb_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ptThis</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#4EC9B0">    arm_2d_region_list_item_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ptRegion</span><span style="color:#D4D4D4">);</span></span></code></pre><h3>更新流程</h3>
<p>场景级更新，每个控件调用：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">arm_2d_helper_dirty_region_update_dirty_regions</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#D4D4D4">    &#x26;ptScene-></span><span style="color:#9CDCFE">tDirtyRegionHelper</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">    ptTargetTile,</span></span>
<span class="line"><span style="color:#D4D4D4">    ptVisibleArea,</span><span style="color:#6A9955">   // 裁剪区域</span></span>
<span class="line"><span style="color:#D4D4D4">    ptNewRegion);</span><span style="color:#6A9955">    // 新脏区域</span></span></code></pre><p>内部实现（<code>arm_2d_helper_pfb.c</code>）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> __arm_2d_helper_dirty_region_update_dirty_regions2</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#4EC9B0">    arm_2d_helper_dirty_region_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ptThis</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#4EC9B0"> arm_2d_tile_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ptTargetTile</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#4EC9B0"> arm_2d_region_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ptVisibleArea</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#4EC9B0"> arm_2d_region_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ptNewRegion</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">    __arm_2d_helper_dirty_region_item_update</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#D4D4D4">        &#x26;</span><span style="color:#9CDCFE">this</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">tDefaultItem</span><span style="color:#D4D4D4">, ptTargetTile, ptVisibleArea, ptNewRegion);</span></span>
<span class="line"><span style="color:#DCDCAA">    __arm_2d_helper_dirty_region_update_dirty_regions</span><span style="color:#D4D4D4">(ptThis, ptTargetTile);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>加权列表优化</h3>
<p>ARM-2D 用加权列表决定脏区域处理优先级：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> __arm_2d_helper_add_item_to_weighted_dirty_region_list</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#4EC9B0">    arm_2d_helper_pfb_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ptThis</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#4EC9B0">    arm_2d_region_list_item_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ptItem</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">arm_2d_region_list_item_t</span><span style="color:#D4D4D4"> *</span></span>
<span class="line"><span style="color:#DCDCAA">__arm_2d_helper_remove_item_from_weighted_dirty_region_list</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#4EC9B0">    arm_2d_helper_pfb_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">ptThis</span><span style="color:#D4D4D4">);</span></span></code></pre><p>合并策略使用包围盒优化：当两个区域的包围盒面积小于两者面积之和时，合并为一个刷新区域。</p>
<h3>刷新流程</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>arm_2d_helper_dirty_region_on_frame_start()  → 帧开始</span></span>
<span class="line"><span>控件更新脏区域                                → 池分配 + 加权排序</span></span>
<span class="line"><span>disp_adapter0_task()                          → 驱动 PFB 刷新</span></span>
<span class="line"><span>PFB 块绘制（Helium/MVE SIMD 加速）            → 像素生成</span></span>
<span class="line"><span>flush_cb() → LCD（支持异步 DMA）              → 传输</span></span>
<span class="line"><span>脏区域池释放                                   → 完成</span></span></code></pre><h2>LVGL vs ARM-2D 对比</h2>
<table>
<thead>
<tr>
<th>特性</th>
<th>LVGL</th>
<th>ARM-2D</th>
</tr>
</thead>
<tbody><tr>
<td>脏区域存储</td>
<td>固定数组 inv_areas[32]</td>
<td>对象池 + 链表</td>
</tr>
<tr>
<td>合并策略</td>
<td>O(n²) 遍历合并</td>
<td>加权列表 + 包围盒</td>
</tr>
<tr>
<td>管理粒度</td>
<td>全局（per display）</td>
<td>场景级（per scene）</td>
</tr>
<tr>
<td>内存分配</td>
<td>静态数组</td>
<td>对象池（避免 malloc）</td>
</tr>
<tr>
<td>硬件加速</td>
<td>软件渲染为主</td>
<td>Helium/MVE SIMD</td>
</tr>
<tr>
<td>适用平台</td>
<td>通用（MCU 到 Linux）</td>
<td>Cortex-M 专用</td>
</tr>
</tbody></table>
<h2>有无 Framebuffer 的架构差异</h2>
<p><strong>有完整 framebuffer</strong>：GUI 绘制到 framebuffer，脏矩阵决定哪些区域 flush 到 LCD。适合 Linux、RTOS + 外部 SRAM。</p>
<p><strong>小 buffer + 脏区域</strong>：只有小块 draw buffer，脏区域决定哪些 tile 需要现画。每次刷新重新遍历控件树绘制。适合 STM32/ESP32、SPI LCD。</p>
<p>无 framebuffer 时，GUI 框架保存的是界面描述 + 当前状态（控件树），不是整张像素图。刷新时需要从背景开始，按层级重画该区域内所有相关控件。</p>
<h2>工程实践要点</h2>
<p><strong>旧位置也要标脏</strong>：控件移动时，旧位置和新位置都需要标脏。很多残影问题就是因为忘记标脏旧位置。</p>
<p><strong>脏区域要适当扩大</strong>：阴影、边框、抗锯齿的实际影响区域可能比控件本身大。LVGL 中 <code>invalidate_area_core()</code> 会自动扩大 5 像素。</p>
<p><strong>不要每次标脏就立即刷新</strong>：多次标脏后统一刷新，效率更高。</p>
<p><strong>缓冲区满时回退整屏刷新</strong>：LVGL 的 <code>LV_INV_BUF_SIZE = 32</code>，超过时 <code>inv_p</code> 重置为 0 并标脏整个屏幕。</p>
<h2>常用 API 速查</h2>
<p><strong>LVGL：</strong></p>
<table>
<thead>
<tr>
<th>函数</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td><code>lv_obj_invalidate(obj)</code></td>
<td>标脏整个控件</td>
</tr>
<tr>
<td><code>lv_obj_invalidate_area(obj, area)</code></td>
<td>标脏指定区域</td>
</tr>
<tr>
<td><code>lv_inv_area(disp, area)</code></td>
<td>底层标脏</td>
</tr>
<tr>
<td><code>lv_refr_join_area()</code></td>
<td>合并重叠区域</td>
</tr>
<tr>
<td><code>lv_display_refr_timer()</code></td>
<td>定时刷新入口</td>
</tr>
</tbody></table>
<p><strong>ARM-2D：</strong></p>
<table>
<thead>
<tr>
<th>函数</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td><code>arm_2d_helper_dirty_region_update_dirty_regions()</code></td>
<td>更新脏区域</td>
</tr>
<tr>
<td><code>arm_2d_helper_dirty_region_on_frame_start()</code></td>
<td>帧开始初始化</td>
</tr>
<tr>
<td><code>arm_2d_helper_pfb_enable_dirty_region_optimization()</code></td>
<td>启用优化</td>
</tr>
<tr>
<td><code>disp_adapter0_task()</code></td>
<td>显示适配器任务</td>
</tr>
</tbody></table>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式GUI"/>
    <category term="LVGL"/>
    <category term="ARM-2D"/>
    <category term="脏区域"/>
    <category term="局部刷新"/>
    <category term="MCU"/>
  </entry>
  <entry>
    <title>高斯分布（正态分布）的性质与工程应用</title>
    <link href="https://createskyblue.github.io/posts/gaussian-distribution/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/gaussian-distribution/</id>
    <published>2026-05-31T00:00:00.000Z</published>
    <updated>2026-05-31T00:00:00.000Z</updated>
    <summary>高斯分布 正态分布 滤波</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>高斯分布（正态分布）的性质与工程应用</h1>
<p>高斯分布（Gaussian Distribution），又称正态分布（Normal Distribution），是统计学中最核心的概率分布。传感器噪声、测量误差、自然现象的波动等大量随机变量都近似服从正态分布。</p>
<h2>直观理解</h2>
<p>正态分布描述的是&quot;大多数数据集中在某个值附近，偏离越远的数据越少&quot;这种规律。图形上呈钟形曲线：</p>
<div style="padding: 20px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin: 16px 0;">
  <div style="display: flex; align-items: flex-end; justify-content: center; gap: 2px; height: 140px;">
    <div style="width: 8px; height: 2px; background: #cbd5e1; border-radius: 1px;"></div>
    <div style="width: 8px; height: 3px; background: #cbd5e1; border-radius: 1px;"></div>
    <div style="width: 8px; height: 5px; background: #cbd5e1; border-radius: 1px;"></div>
    <div style="width: 8px; height: 8px; background: #93c5fd; border-radius: 1px;"></div>
    <div style="width: 8px; height: 14px; background: #93c5fd; border-radius: 1px;"></div>
    <div style="width: 8px; height: 22px; background: #60a5fa; border-radius: 1px;"></div>
    <div style="width: 8px; height: 34px; background: #60a5fa; border-radius: 1px;"></div>
    <div style="width: 8px; height: 48px; background: #3b82f6; border-radius: 1px;"></div>
    <div style="width: 8px; height: 64px; background: #3b82f6; border-radius: 1px;"></div>
    <div style="width: 8px; height: 80px; background: #2563eb; border-radius: 1px;"></div>
    <div style="width: 8px; height: 94px; background: #2563eb; border-radius: 1px;"></div>
    <div style="width: 8px; height: 106px; background: #1d4ed8; border-radius: 1px;"></div>
    <div style="width: 8px; height: 116px; background: #1d4ed8; border-radius: 1px;"></div>
    <div style="width: 8px; height: 122px; background: #1e40af; border-radius: 1px;"></div>
    <div style="width: 8px; height: 126px; background: #1e40af; border-radius: 1px;"></div>
    <div style="width: 8px; height: 128px; background: #1e3a8a; border-radius: 1px;"></div>
    <div style="width: 8px; height: 126px; background: #1e40af; border-radius: 1px;"></div>
    <div style="width: 8px; height: 122px; background: #1e40af; border-radius: 1px;"></div>
    <div style="width: 8px; height: 116px; background: #1d4ed8; border-radius: 1px;"></div>
    <div style="width: 8px; height: 106px; background: #1d4ed8; border-radius: 1px;"></div>
    <div style="width: 8px; height: 94px; background: #2563eb; border-radius: 1px;"></div>
    <div style="width: 8px; height: 80px; background: #2563eb; border-radius: 1px;"></div>
    <div style="width: 8px; height: 64px; background: #3b82f6; border-radius: 1px;"></div>
    <div style="width: 8px; height: 48px; background: #3b82f6; border-radius: 1px;"></div>
    <div style="width: 8px; height: 34px; background: #60a5fa; border-radius: 1px;"></div>
    <div style="width: 8px; height: 22px; background: #60a5fa; border-radius: 1px;"></div>
    <div style="width: 8px; height: 14px; background: #93c5fd; border-radius: 1px;"></div>
    <div style="width: 8px; height: 8px; background: #93c5fd; border-radius: 1px;"></div>
    <div style="width: 8px; height: 5px; background: #cbd5e1; border-radius: 1px;"></div>
    <div style="width: 8px; height: 3px; background: #cbd5e1; border-radius: 1px;"></div>
    <div style="width: 8px; height: 2px; background: #cbd5e1; border-radius: 1px;"></div>
  </div>
  <div style="text-align: center; margin-top: 8px; color: #64748b; font-size: 13px;">
    <span style="color: #94a3b8;">← 偏离中心越远，出现概率越低</span>
    <span style="margin: 0 24px; color: #1e40af; font-weight: bold;">μ（中心）</span>
    <span style="color: #94a3b8;">偏离中心越远，出现概率越低 →</span>
  </div>
</div>

<p>现实例子：</p>
<ul>
<li>同一批传感器在同一温度下的读数，会围绕某个值上下波动</li>
<li>人的身高围绕平均身高分布，极高和极矮的人很少</li>
<li>测量误差通常服从均值为 0 的正态分布</li>
</ul>
<h2>概率密度函数（PDF）</h2>
<p>正态分布的概率密度函数：</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>f</mi><mo stretchy="false">(</mo><mi>x</mi><mo stretchy="false">)</mo><mo>=</mo><mfrac><mn>1</mn><mrow><mi>σ</mi><msqrt><mrow><mn>2</mn><mi>π</mi></mrow></msqrt></mrow></mfrac><mtext> </mtext><msup><mi>e</mi><mrow><mo>−</mo><mfrac><mrow><mo stretchy="false">(</mo><mi>x</mi><mo>−</mo><mi>μ</mi><msup><mo stretchy="false">)</mo><mn>2</mn></msup></mrow><mrow><mn>2</mn><msup><mi>σ</mi><mn>2</mn></msup></mrow></mfrac></mrow></msup></mrow><annotation encoding="application/x-tex">f(x) = \frac{1}{\sigma\sqrt{2\pi}} \, e^{-\frac{(x-\mu)^2}{2\sigma^2}}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.1076em;">f</span><span class="mopen">(</span><span class="mord mathnormal">x</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:2.2591em;vertical-align:-0.93em;"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.3214em;"><span style="top:-2.2028em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span><span class="mord sqrt"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.9072em;"><span class="svg-align" style="top:-3em;"><span class="pstrut" style="height:3em;"></span><span class="mord" style="padding-left:0.833em;"><span class="mord">2</span><span class="mord mathnormal" style="margin-right:0.0359em;">π</span></span></span><span style="top:-2.8672em;"><span class="pstrut" style="height:3em;"></span><span class="hide-tail" style="min-width:0.853em;height:1.08em;"><svg xmlns="http://www.w3.org/2000/svg" width="400em" height="1.08em" viewBox="0 0 400000 1080" preserveAspectRatio="xMinYMin slice"><path d="M95,702
c-2.7,0,-7.17,-2.7,-13.5,-8c-5.8,-5.3,-9.5,-10,-9.5,-14
c0,-2,0.3,-3.3,1,-4c1.3,-2.7,23.83,-20.7,67.5,-54
c44.2,-33.3,65.8,-50.3,66.5,-51c1.3,-1.3,3,-2,5,-2c4.7,0,8.7,3.3,12,10
s173,378,173,378c0.7,0,35.3,-71,104,-213c68.7,-142,137.5,-285,206.5,-429
c69,-144,104.5,-217.7,106.5,-221
l0 -0
c5.3,-9.3,12,-14,20,-14
H400000v40H845.2724
s-225.272,467,-225.272,467s-235,486,-235,486c-2.7,4.7,-9,7,-19,7
c-6,0,-10,-1,-12,-3s-194,-422,-194,-422s-65,47,-65,47z
M834 80h400000v40h-400000z"/></svg></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.1328em;"><span></span></span></span></span></span></span></span><span style="top:-3.23em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line" style="border-bottom-width:0.04em;"></span></span><span style="top:-3.677em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord">1</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.93em;"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord"><span class="mord mathnormal">e</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:1.3291em;"><span style="top:-3.4534em;margin-right:0.05em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">−</span><span class="mord mtight"><span class="mopen nulldelimiter sizing reset-size3 size6"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.251em;"><span style="top:-2.5062em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size3 size1 mtight"><span class="mord mtight"><span class="mord mtight">2</span><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0359em;">σ</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.9384em;"><span style="top:-2.9384em;margin-right:0.1em;"><span class="pstrut" style="height:2.6444em;"></span><span class="mord mtight">2</span></span></span></span></span></span></span></span></span></span><span style="top:-3.2255em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line mtight" style="border-bottom-width:0.049em;"></span></span><span style="top:-3.5021em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size3 size1 mtight"><span class="mord mtight"><span class="mopen mtight">(</span><span class="mord mathnormal mtight">x</span><span class="mbin mtight">−</span><span class="mord mathnormal mtight">μ</span><span class="mclose mtight"><span class="mclose mtight">)</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:1.0484em;"><span style="top:-3.0484em;margin-right:0.1em;"><span class="pstrut" style="height:2.6444em;"></span><span class="mord mtight">2</span></span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.4938em;"><span></span></span></span></span></span><span class="mclose nulldelimiter sizing reset-size3 size6"></span></span></span></span></span></span></span></span></span></span></span></span></span></span></div>
<p>其中：</p>
<ul>
<li><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>μ</mi></mrow><annotation encoding="application/x-tex">\mu</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">μ</span></span></span></span></span>（mu）：均值，决定分布的中心位置</li>
<li><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span>（sigma）：标准差，决定分布的&quot;胖瘦&quot;——<span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 越大，曲线越扁平，数据越分散</li>
<li><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>π</mi><mo>≈</mo><mn>3.14159</mn></mrow><annotation encoding="application/x-tex">\pi \approx 3.14159</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4831em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">π</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">≈</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">3.14159</span></span></span></span></span>，<span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>e</mi><mo>≈</mo><mn>2.71828</mn></mrow><annotation encoding="application/x-tex">e \approx 2.71828</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4831em;"></span><span class="mord mathnormal">e</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">≈</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">2.71828</span></span></span></span></span></li>
</ul>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0;">
  <div style="padding: 16px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
    <div style="color: #475569; font-size: 13px; font-weight: bold; margin-bottom: 12px;">μ 不同，σ 相同</div>
    <div style="position: relative; height: 100px; overflow: hidden;">
      <div style="position: absolute; left: 25%; bottom: 0; width: 60px; height: 80px; background: rgba(59,130,246,0.15); border-top: 2px solid #3b82f6; border-radius: 50% 50% 0 0;"></div>
      <div style="position: absolute; left: 55%; bottom: 0; width: 60px; height: 80px; background: rgba(239,68,68,0.15); border-top: 2px solid #ef4444; border-radius: 50% 50% 0 0;"></div>
      <div style="position: absolute; bottom: -4px; left: 25%; transform: translateX(-50%); font-size: 11px; color: #3b82f6;">μ₁</div>
      <div style="position: absolute; bottom: -4px; left: 55%; transform: translateX(-50%); font-size: 11px; color: #ef4444;">μ₂</div>
    </div>
    <div style="font-size: 12px; color: #64748b; margin-top: 8px;">曲线形状相同，位置不同</div>
  </div>
  <div style="padding: 16px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;">
    <div style="color: #475569; font-size: 13px; font-weight: bold; margin-bottom: 12px;">μ 相同，σ 不同</div>
    <div style="position: relative; height: 100px; overflow: hidden;">
      <div style="position: absolute; left: 50%; bottom: 0; width: 40px; height: 90px; background: rgba(59,130,246,0.15); border-top: 2px solid #3b82f6; border-radius: 50% 50% 0 0; transform: translateX(-50%);"></div>
      <div style="position: absolute; left: 50%; bottom: 0; width: 80px; height: 50px; background: rgba(239,68,68,0.15); border-top: 2px solid #ef4444; border-radius: 50% 50% 0 0; transform: translateX(-50%);"></div>
      <div style="position: absolute; bottom: -4px; left: 50%; transform: translateX(-50%); font-size: 11px; color: #475569;">μ</div>
      <div style="position: absolute; top: 4px; right: 8px; font-size: 11px;">
        <span style="color: #3b82f6;">— 小σ（瘦高）</span><br>
        <span style="color: #ef4444;">— 大σ（矮胖）</span>
      </div>
    </div>
    <div style="font-size: 12px; color: #64748b; margin-top: 8px;">σ 小→数据集中，σ 大→数据分散</div>
  </div>
</div>

<h2>68-95-99.7 法则</h2>
<p>正态分布有一个重要性质：数据落在均值附近不同范围内的概率是固定的。</p>
<div style="padding: 20px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin: 16px 0;">
  <div style="display: flex; align-items: center; justify-content: center; gap: 0; margin-bottom: 16px;">
    <div style="width: 40px; height: 20px; background: #e2e8f0;"></div>
    <div style="width: 60px; height: 40px; background: #dbeafe;"></div>
    <div style="width: 80px; height: 60px; background: #bfdbfe;"></div>
    <div style="width: 100px; height: 80px; background: #93c5fd;"></div>
    <div style="width: 80px; height: 60px; background: #bfdbfe;"></div>
    <div style="width: 60px; height: 40px; background: #dbeafe;"></div>
    <div style="width: 40px; height: 20px; background: #e2e8f0;"></div>
  </div>
  <table style="width: 100%; border-collapse: collapse; font-size: 14px;">
    <tr style="border-bottom: 1px solid #e2e8f0;">
      <td style="padding: 8px 12px; color: #64748b; font-weight: bold;">范围</td>
      <td style="padding: 8px 12px; text-align: center; color: #1e40af; font-weight: bold;">概率</td>
      <td style="padding: 8px 12px; color: #475569;">含义</td>
    </tr>
    <tr style="border-bottom: 1px solid #e2e8f0;">
      <td style="padding: 8px 12px; color: #475569;"><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>μ</mi><mo>±</mo><mn>1</mn><mi>σ</mi></mrow><annotation encoding="application/x-tex">\mu \pm 1\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.7778em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">μ</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">±</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">1</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span></td>
      <td style="padding: 8px 12px; text-align: center; color: #1e40af; font-weight: bold;">68.27%</td>
      <td style="padding: 8px 12px; color: #64748b;">约 2/3 的数据落在这个范围</td>
    </tr>
    <tr style="border-bottom: 1px solid #e2e8f0;">
      <td style="padding: 8px 12px; color: #475569;"><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>μ</mi><mo>±</mo><mn>2</mn><mi>σ</mi></mrow><annotation encoding="application/x-tex">\mu \pm 2\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.7778em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">μ</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">±</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">2</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span></td>
      <td style="padding: 8px 12px; text-align: center; color: #1e40af; font-weight: bold;">95.45%</td>
      <td style="padding: 8px 12px; color: #64748b;">约 19/20 的数据落在这个范围</td>
    </tr>
    <tr>
      <td style="padding: 8px 12px; color: #475569;"><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>μ</mi><mo>±</mo><mn>3</mn><mi>σ</mi></mrow><annotation encoding="application/x-tex">\mu \pm 3\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.7778em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">μ</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">±</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">3</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span></td>
      <td style="padding: 8px 12px; text-align: center; color: #1e40af; font-weight: bold;">99.73%</td>
      <td style="padding: 8px 12px; color: #64748b;">只有 0.27% 的数据会落在外面</td>
    </tr>
  </table>
</div>

<p>这就是 <strong><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>3</mn><mi>σ</mi></mrow><annotation encoding="application/x-tex">3\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">3</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 准则</strong>的来源：如果一个数据点偏离均值超过 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>3</mn><mi>σ</mi></mrow><annotation encoding="application/x-tex">3\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">3</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span>，它来自正态分布的概率只有 0.27%，可以合理怀疑它是异常值。</p>
<h2>标准正态分布</h2>
<p>当 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>μ</mi><mo>=</mo><mn>0</mn></mrow><annotation encoding="application/x-tex">\mu = 0</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">μ</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">0</span></span></span></span></span>、<span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi><mo>=</mo><mn>1</mn></mrow><annotation encoding="application/x-tex">\sigma = 1</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">1</span></span></span></span></span> 时，称为标准正态分布。任意正态分布都可以通过 Z-score 标准化：</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>Z</mi><mo>=</mo><mfrac><mrow><mi>x</mi><mo>−</mo><mi>μ</mi></mrow><mi>σ</mi></mfrac></mrow><annotation encoding="application/x-tex">Z = \frac{x - \mu}{\sigma}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord mathnormal" style="margin-right:0.0715em;">Z</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1.9463em;vertical-align:-0.686em;"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.2603em;"><span style="top:-2.314em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span><span style="top:-3.23em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line" style="border-bottom-width:0.04em;"></span></span><span style="top:-3.677em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">x</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mord mathnormal">μ</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.686em;"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span></span></span></span></span></div>
<p>Z-score 表示&quot;这个数据点偏离均值几个标准差&quot;。例如：</p>
<ul>
<li><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>Z</mi><mo>=</mo><mn>0</mn></mrow><annotation encoding="application/x-tex">Z = 0</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord mathnormal" style="margin-right:0.0715em;">Z</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">0</span></span></span></span></span>：正好在均值上</li>
<li><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>Z</mi><mo>=</mo><mn>1</mn></mrow><annotation encoding="application/x-tex">Z = 1</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord mathnormal" style="margin-right:0.0715em;">Z</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">1</span></span></span></span></span>：比均值高 1 个标准差</li>
<li><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>Z</mi><mo>=</mo><mo>−</mo><mn>2</mn></mrow><annotation encoding="application/x-tex">Z = -2</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord mathnormal" style="margin-right:0.0715em;">Z</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.7278em;vertical-align:-0.0833em;"></span><span class="mord">−</span><span class="mord">2</span></span></span></span></span>：比均值低 2 个标准差</li>
</ul>
<h2>工程应用</h2>
<h3>传感器数据建模</h3>
<p>传感器读数通常可以建模为：</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><msub><mi>x</mi><mtext>measured</mtext></msub><mo>=</mo><msub><mi>x</mi><mtext>true</mtext></msub><mo>+</mo><mi>ϵ</mi><mo separator="true">,</mo><mspace width="1em"/><mi>ϵ</mi><mo>∼</mo><mi mathvariant="script">N</mi><mo stretchy="false">(</mo><mn>0</mn><mo separator="true">,</mo><msup><mi>σ</mi><mn>2</mn></msup><mo stretchy="false">)</mo></mrow><annotation encoding="application/x-tex">x_{\text{measured}} = x_{\text{true}} + \epsilon, \quad \epsilon \sim \mathcal{N}(0, \sigma^2)</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal">x</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3361em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord text mtight"><span class="mord mtight">measured</span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.7333em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal">x</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.2806em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord text mtight"><span class="mord mtight">true</span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">ϵ</span><span class="mpunct">,</span><span class="mspace" style="margin-right:1em;"></span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal">ϵ</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">∼</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1.1141em;vertical-align:-0.25em;"></span><span class="mord mathcal" style="margin-right:0.1474em;">N</span><span class="mopen">(</span><span class="mord">0</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8641em;"><span style="top:-3.113em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">2</span></span></span></span></span></span></span></span><span class="mclose">)</span></span></span></span></span></div>
<p>其中 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>ϵ</mi></mrow><annotation encoding="application/x-tex">\epsilon</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal">ϵ</span></span></span></span></span> 是噪声，服从均值为 0、方差为 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msup><mi>σ</mi><mn>2</mn></msup></mrow><annotation encoding="application/x-tex">\sigma^2</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8141em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8141em;"><span style="top:-3.063em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">2</span></span></span></span></span></span></span></span></span></span></span></span> 的正态分布。知道 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 就能量化测量的可信度。</p>
<h3>异常检测（3σ 准则）</h3>
<p>基于 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>3</mn><mi>σ</mi></mrow><annotation encoding="application/x-tex">3\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">3</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 准则的异常检测：</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mtext>如果 </mtext><mi mathvariant="normal">∣</mi><mi>x</mi><mo>−</mo><mi>μ</mi><mi mathvariant="normal">∣</mi><mo>&gt;</mo><mn>3</mn><mi>σ</mi><mtext>，则判定为异常</mtext></mrow><annotation encoding="application/x-tex">\text{如果 } |x - \mu| &gt; 3\sigma \text{，则判定为异常}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord text"><span class="mord cjk_fallback">如果</span><span class="mord"> </span></span><span class="mord">∣</span><span class="mord mathnormal">x</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">μ</span><span class="mord">∣</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">&gt;</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord">3</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span><span class="mord text"><span class="mord cjk_fallback">，则判定为异常</span></span></span></span></span></span></div>
<div style="padding: 16px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; margin: 16px 0;">
  <div style="color: #92400e; font-weight: bold; margin-bottom: 8px;">与 Hampel Filter 的关系</div>
  <div style="color: #78350f; font-size: 14px;">
    Hampel Filter 用 MAD（中值绝对偏差）替代标准差 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span>，用中值替代均值 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>μ</mi></mrow><annotation encoding="application/x-tex">\mu</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">μ</span></span></span></span></span>。好处是：单个异常值不会像均值/标准差那样拉偏统计量，检测更鲁棒。<span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>3</mn><mi>σ</mi></mrow><annotation encoding="application/x-tex">3\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">3</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 准则中的 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 在 Hampel Filter 中对应 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>t</mi><mo>⋅</mo><mi>σ</mi><mo>⋅</mo><mtext>MAD</mtext></mrow><annotation encoding="application/x-tex">t \cdot \sigma \cdot \text{MAD}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6151em;"></span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">⋅</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.4445em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">⋅</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord text"><span class="mord">MAD</span></span></span></span></span></span>。
  </div>
</div>

<h3>工程中的典型场景</h3>
<table>
<thead>
<tr>
<th>场景</th>
<th><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>μ</mi></mrow><annotation encoding="application/x-tex">\mu</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">μ</span></span></span></span></span> 的含义</th>
<th><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 的含义</th>
</tr>
</thead>
<tbody><tr>
<td>传感器校准</td>
<td>真实值或标称值</td>
<td>测量噪声幅度</td>
</tr>
<tr>
<td>质量控制</td>
<td>目标尺寸</td>
<td>加工误差范围</td>
</tr>
<tr>
<td>信号滤波</td>
<td>信号期望值</td>
<td>噪声强度</td>
</tr>
</tbody></table>
<h2>C 语言实现</h2>
<h3>计算概率密度（PDF）</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;math.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/*</span></span>
<span class="line"><span style="color:#6A9955"> * 正态分布概率密度函数</span></span>
<span class="line"><span style="color:#6A9955"> * @param x:     输入值</span></span>
<span class="line"><span style="color:#6A9955"> * @param mu:    均值 μ</span></span>
<span class="line"><span style="color:#6A9955"> * @param sigma: 标准差 σ</span></span>
<span class="line"><span style="color:#6A9955"> * @return:      概率密度 f(x)</span></span>
<span class="line"><span style="color:#6A9955"> */</span></span>
<span class="line"><span style="color:#569CD6">double</span><span style="color:#DCDCAA"> gaussian_pdf</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">double</span><span style="color:#9CDCFE"> x</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">double</span><span style="color:#9CDCFE"> mu</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">double</span><span style="color:#9CDCFE"> sigma</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    double</span><span style="color:#D4D4D4"> z = (x - mu) / sigma;</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#DCDCAA"> exp</span><span style="color:#D4D4D4">(-</span><span style="color:#B5CEA8">0.5</span><span style="color:#D4D4D4"> * z * z) / (sigma * </span><span style="color:#DCDCAA">sqrt</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">2.0</span><span style="color:#D4D4D4"> * M_PI));</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>计算累积分布函数（CDF）</h3>
<p>标准正态分布的 CDF 没有解析解，使用 Abramowitz &amp; Stegun 近似（误差 &lt; 7.5×10⁻⁸）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;math.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/*</span></span>
<span class="line"><span style="color:#6A9955"> * 标准正态分布累积分布函数 Φ(z) 的近似</span></span>
<span class="line"><span style="color:#6A9955"> * Abramowitz &#x26; Stegun 公式 26.2.17</span></span>
<span class="line"><span style="color:#6A9955"> * @param z: 标准化后的值 Z = (x - μ) / σ</span></span>
<span class="line"><span style="color:#6A9955"> * @return:  P(X ≤ z)</span></span>
<span class="line"><span style="color:#6A9955"> */</span></span>
<span class="line"><span style="color:#569CD6">double</span><span style="color:#DCDCAA"> standard_normal_cdf</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">double</span><span style="color:#9CDCFE"> z</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (z &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">return</span><span style="color:#B5CEA8"> 1.0</span><span style="color:#D4D4D4"> - </span><span style="color:#DCDCAA">standard_normal_cdf</span><span style="color:#D4D4D4">(-z);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> double</span><span style="color:#D4D4D4"> b0 = </span><span style="color:#B5CEA8">0.2316419</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> double</span><span style="color:#D4D4D4"> b1 = </span><span style="color:#B5CEA8">0.319381530</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> double</span><span style="color:#D4D4D4"> b2 = -</span><span style="color:#B5CEA8">0.356563782</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> double</span><span style="color:#D4D4D4"> b3 = </span><span style="color:#B5CEA8">1.781477937</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> double</span><span style="color:#D4D4D4"> b4 = -</span><span style="color:#B5CEA8">1.821255978</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> double</span><span style="color:#D4D4D4"> b5 = </span><span style="color:#B5CEA8">1.330274429</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    double</span><span style="color:#D4D4D4"> t = </span><span style="color:#B5CEA8">1.0</span><span style="color:#D4D4D4"> / (</span><span style="color:#B5CEA8">1.0</span><span style="color:#D4D4D4"> + b0 * z);</span></span>
<span class="line"><span style="color:#569CD6">    double</span><span style="color:#D4D4D4"> phi = </span><span style="color:#DCDCAA">exp</span><span style="color:#D4D4D4">(-</span><span style="color:#B5CEA8">0.5</span><span style="color:#D4D4D4"> * z * z) / </span><span style="color:#DCDCAA">sqrt</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">2.0</span><span style="color:#D4D4D4"> * M_PI);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 1.0</span><span style="color:#D4D4D4"> - phi * (b1*t + b2*t*t + b3*t*t*t + b4*t*t*t*t + b5*t*t*t*t*t);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/*</span></span>
<span class="line"><span style="color:#6A9955"> * 一般正态分布的 CDF</span></span>
<span class="line"><span style="color:#6A9955"> * @param x:     输入值</span></span>
<span class="line"><span style="color:#6A9955"> * @param mu:    均值 μ</span></span>
<span class="line"><span style="color:#6A9955"> * @param sigma: 标准差 σ</span></span>
<span class="line"><span style="color:#6A9955"> * @return:      P(X ≤ x)</span></span>
<span class="line"><span style="color:#6A9955"> */</span></span>
<span class="line"><span style="color:#569CD6">double</span><span style="color:#DCDCAA"> normal_cdf</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">double</span><span style="color:#9CDCFE"> x</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">double</span><span style="color:#9CDCFE"> mu</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">double</span><span style="color:#9CDCFE"> sigma</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#DCDCAA"> standard_normal_cdf</span><span style="color:#D4D4D4">((x - mu) / sigma);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>异常检测示例</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdio.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;math.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#6A9955">    /* 传感器读数 */</span></span>
<span class="line"><span style="color:#569CD6">    double</span><span style="color:#D4D4D4"> data</span><span style="color:#569CD6">[]</span><span style="color:#D4D4D4"> = {</span><span style="color:#B5CEA8">100.2</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">99.8</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">100.5</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">99.7</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">150.0</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">100.1</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">99.9</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">100.3</span><span style="color:#D4D4D4">, -</span><span style="color:#B5CEA8">80.0</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">100.0</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#569CD6">    int</span><span style="color:#D4D4D4"> n = </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(data) / </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 计算均值 */</span></span>
<span class="line"><span style="color:#569CD6">    double</span><span style="color:#D4D4D4"> mu = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; n; i++) mu += </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">[i];</span></span>
<span class="line"><span style="color:#D4D4D4">    mu /= n;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 计算标准差 */</span></span>
<span class="line"><span style="color:#569CD6">    double</span><span style="color:#D4D4D4"> sigma = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; n; i++) sigma += (</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">[i] - mu) * (</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">[i] - mu);</span></span>
<span class="line"><span style="color:#D4D4D4">    sigma = </span><span style="color:#DCDCAA">sqrt</span><span style="color:#D4D4D4">(sigma / n);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"均值 μ = </span><span style="color:#9CDCFE">%.2f</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, mu);</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"标准差 σ = </span><span style="color:#9CDCFE">%.2f</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, sigma);</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\n</span><span style="color:#9CDCFE">%-12s</span><span style="color:#9CDCFE"> %-8s</span><span style="color:#9CDCFE"> %-10s</span><span style="color:#9CDCFE"> %s</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"数据"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"Z-score"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"P(|X-μ|&#x3C;z)"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"判定"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"--------------------------------------------</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; n; i++) {</span></span>
<span class="line"><span style="color:#569CD6">        double</span><span style="color:#D4D4D4"> z = </span><span style="color:#DCDCAA">fabs</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">[i] - mu) / sigma;</span></span>
<span class="line"><span style="color:#569CD6">        double</span><span style="color:#D4D4D4"> p = </span><span style="color:#B5CEA8">2.0</span><span style="color:#D4D4D4"> * </span><span style="color:#DCDCAA">standard_normal_cdf</span><span style="color:#D4D4D4">(z) - </span><span style="color:#B5CEA8">1.0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">        const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *result = (z > </span><span style="color:#B5CEA8">3.0</span><span style="color:#D4D4D4">) ? </span><span style="color:#CE9178">"异常"</span><span style="color:#D4D4D4"> : </span><span style="color:#CE9178">"正常"</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"</span><span style="color:#9CDCFE">%-12.2f</span><span style="color:#9CDCFE"> %-8.2f</span><span style="color:#9CDCFE"> %-10.4f</span><span style="color:#9CDCFE"> %s</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">data</span><span style="color:#D4D4D4">[i], z, p, result);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>输出：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>均值 μ = 87.05</span></span>
<span class="line"><span>标准差 σ = 57.64</span></span>
<span class="line"><span></span></span>
<span class="line"><span>数据         Z-score  P(|X-μ|&#x3C;z)  判定</span></span>
<span class="line"><span>--------------------------------------------</span></span>
<span class="line"><span>100.20       0.23     0.1805      正常</span></span>
<span class="line"><span>99.80        0.22     0.1751      正常</span></span>
<span class="line"><span>100.50       0.23     0.1845      正常</span></span>
<span class="line"><span>99.70        0.22     0.1737      正常</span></span>
<span class="line"><span>150.00       1.09     0.7252      正常</span></span>
<span class="line"><span>100.10       0.23     0.1791      正常</span></span>
<span class="line"><span>99.90        0.22     0.1764      正常</span></span>
<span class="line"><span>100.30       0.23     0.1818      正常</span></span>
<span class="line"><span>-80.00       2.90     0.9962      正常</span></span>
<span class="line"><span>100.00       0.22     0.1778      正常</span></span></code></pre><blockquote>
<p>注意：此例中数据量太小（n=10），均值和标准差被异常值（150 和 -80）严重拉偏，导致 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>3</mn><mi>σ</mi></mrow><annotation encoding="application/x-tex">3\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">3</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 准则失效——即使 -80 偏离均值 2.9σ，仍未触发 3σ 阈值。这正是 Hampel Filter 使用中值和 MAD 的原因——对异常值更鲁棒。实际应用中需要足够的样本量（通常 n &gt; 30）才能获得可靠的 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>μ</mi></mrow><annotation encoding="application/x-tex">\mu</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">μ</span></span></span></span></span> 和 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 估计。</p>
</blockquote>
<h2>局限性</h2>
<ul>
<li><strong>正态假设不一定成立</strong>：有些数据是偏态分布、多峰分布或重尾分布</li>
<li><strong>对异常值敏感</strong>：均值和标准差容易被极端值拉偏（Hampel Filter 用中值和 MAD 解决了这个问题）</li>
<li><strong>需要足够样本</strong>：小样本下 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>μ</mi></mrow><annotation encoding="application/x-tex">\mu</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">μ</span></span></span></span></span> 和 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 的估计不可靠</li>
</ul>
<h2>总结</h2>
<table>
<thead>
<tr>
<th>概念</th>
<th>公式/值</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td>均值 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>μ</mi></mrow><annotation encoding="application/x-tex">\mu</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">μ</span></span></span></span></span></td>
<td><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mfrac><mn>1</mn><mi>n</mi></mfrac><mo>∑</mo><msub><mi>x</mi><mi>i</mi></msub></mrow><annotation encoding="application/x-tex">\frac{1}{n}\sum x_i</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.1901em;vertical-align:-0.345em;"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.8451em;"><span style="top:-2.655em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">n</span></span></span></span><span style="top:-3.23em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line" style="border-bottom-width:0.04em;"></span></span><span style="top:-3.394em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.345em;"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mop op-symbol small-op" style="position:relative;top:0em;">∑</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord"><span class="mord mathnormal">x</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span></span></td>
<td>数据的中心位置</td>
</tr>
<tr>
<td>标准差 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span></td>
<td><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msqrt><mrow><mfrac><mn>1</mn><mi>n</mi></mfrac><mo>∑</mo><mo stretchy="false">(</mo><msub><mi>x</mi><mi>i</mi></msub><mo>−</mo><mi>μ</mi><msup><mo stretchy="false">)</mo><mn>2</mn></msup></mrow></msqrt></mrow><annotation encoding="application/x-tex">\sqrt{\frac{1}{n}\sum(x_i-\mu)^2}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.84em;vertical-align:-0.6049em;"></span><span class="mord sqrt"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.2351em;"><span class="svg-align" style="top:-3.8em;"><span class="pstrut" style="height:3.8em;"></span><span class="mord" style="padding-left:1em;"><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.8451em;"><span style="top:-2.655em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">n</span></span></span></span><span style="top:-3.23em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line" style="border-bottom-width:0.04em;"></span></span><span style="top:-3.394em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">1</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.345em;"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mop op-symbol small-op" style="position:relative;top:0em;">∑</span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal">x</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mord mathnormal">μ</span><span class="mclose"><span class="mclose">)</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.7401em;"><span style="top:-2.989em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight">2</span></span></span></span></span></span></span></span></span></span><span style="top:-3.1951em;"><span class="pstrut" style="height:3.8em;"></span><span class="hide-tail" style="min-width:1.02em;height:1.88em;"><svg xmlns="http://www.w3.org/2000/svg" width="400em" height="1.88em" viewBox="0 0 400000 1944" preserveAspectRatio="xMinYMin slice"><path d="M983 90
l0 -0
c4,-6.7,10,-10,18,-10 H400000v40
H1013.1s-83.4,268,-264.1,840c-180.7,572,-277,876.3,-289,913c-4.7,4.7,-12.7,7,-24,7
s-12,0,-12,0c-1.3,-3.3,-3.7,-11.7,-7,-25c-35.3,-125.3,-106.7,-373.3,-214,-744
c-10,12,-21,25,-33,39s-32,39,-32,39c-6,-5.3,-15,-14,-27,-26s25,-30,25,-30
c26.7,-32.7,52,-63,76,-91s52,-60,52,-60s208,722,208,722
c56,-175.3,126.3,-397.3,211,-666c84.7,-268.7,153.8,-488.2,207.5,-658.5
c53.7,-170.3,84.5,-266.8,92.5,-289.5z
M1001 80h400000v40h-400000z"/></svg></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.6049em;"><span></span></span></span></span></span></span></span></span></span></td>
<td>数据的分散程度</td>
</tr>
<tr>
<td>Z-score</td>
<td><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mfrac><mrow><mi>x</mi><mo>−</mo><mi>μ</mi></mrow><mi>σ</mi></mfrac></mrow><annotation encoding="application/x-tex">\frac{x-\mu}{\sigma}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.1994em;vertical-align:-0.345em;"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.8544em;"><span style="top:-2.655em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight" style="margin-right:0.0359em;">σ</span></span></span></span><span style="top:-3.23em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line" style="border-bottom-width:0.04em;"></span></span><span style="top:-3.4461em;"><span class="pstrut" style="height:3em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mathnormal mtight">x</span><span class="mbin mtight">−</span><span class="mord mathnormal mtight">μ</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.345em;"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span></span></span></span></span></td>
<td>标准化，衡量偏离程度</td>
</tr>
<tr>
<td><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>3</mn><mi>σ</mi></mrow><annotation encoding="application/x-tex">3\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">3</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 准则</td>
<td><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi mathvariant="normal">∥</mi><mi>Z</mi><mi mathvariant="normal">∥</mi><mo>&gt;</mo><mn>3</mn></mrow><annotation encoding="application/x-tex">\|Z\| &gt; 3</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord">∥</span><span class="mord mathnormal" style="margin-right:0.0715em;">Z</span><span class="mord">∥</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">&gt;</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">3</span></span></span></span></span> → 异常</td>
<td>异常检测的简单判据</td>
</tr>
</tbody></table>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="正态分布"/>
    <category term="高斯分布"/>
    <category term="统计学"/>
    <category term="概率密度"/>
    <category term="传感器"/>
  </entry>
  <entry>
    <title>Hampel Filter 算法原理与 C 语言实现</title>
    <link href="https://createskyblue.github.io/posts/hampel-filter-algorithm/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/hampel-filter-algorithm/</id>
    <published>2026-05-31T00:00:00.000Z</published>
    <updated>2026-05-31T00:00:00.000Z</updated>
    <summary>Hampel滤波器用中位数识别尖刺，只替换离群点</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Hampel Filter 算法原理与 C 语言实现</h1>
<p>Hampel Filter 是一种基于中值绝对偏差（Median Absolute Deviation, MAD）的鲁棒离群点检测算法。与均值滤波、高斯滤波不同，Hampel Filter 对脉冲噪声和尖刺异常值具有天然免疫力，适用于传感器信号预处理场景。</p>
<h2>算法原理</h2>
<h3>滑动窗口与中值</h3>
<p>对于输入信号 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>x</mi><mo stretchy="false">[</mo><mi>n</mi><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">x[n]</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">x</span><span class="mopen">[</span><span class="mord mathnormal">n</span><span class="mclose">]</span></span></span></span></span>，取以当前样本为中心、半径为 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>k</mi></mrow><annotation encoding="application/x-tex">k</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal" style="margin-right:0.0315em;">k</span></span></span></span></span> 的滑动窗口：</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>W</mi><mo>=</mo><mo stretchy="false">{</mo><mi>x</mi><mo stretchy="false">[</mo><mi>n</mi><mo>−</mo><mi>k</mi><mo stretchy="false">]</mo><mo separator="true">,</mo><mo>…</mo><mo separator="true">,</mo><mi>x</mi><mo stretchy="false">[</mo><mi>n</mi><mo stretchy="false">]</mo><mo separator="true">,</mo><mo>…</mo><mo separator="true">,</mo><mi>x</mi><mo stretchy="false">[</mo><mi>n</mi><mo>+</mo><mi>k</mi><mo stretchy="false">]</mo><mo stretchy="false">}</mo></mrow><annotation encoding="application/x-tex">W = \{ x[n-k], \ldots, x[n], \ldots, x[n+k] \}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord mathnormal" style="margin-right:0.1389em;">W</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mopen">{</span><span class="mord mathnormal">x</span><span class="mopen">[</span><span class="mord mathnormal">n</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.0315em;">k</span><span class="mclose">]</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="minner">…</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal">x</span><span class="mopen">[</span><span class="mord mathnormal">n</span><span class="mclose">]</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="minner">…</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal">x</span><span class="mopen">[</span><span class="mord mathnormal">n</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.0315em;">k</span><span class="mclose">]}</span></span></span></span></span></div>
<p>窗口长度为 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>2</mn><mi>k</mi><mo>+</mo><mn>1</mn></mrow><annotation encoding="application/x-tex">2k+1</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.7778em;vertical-align:-0.0833em;"></span><span class="mord">2</span><span class="mord mathnormal" style="margin-right:0.0315em;">k</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">1</span></span></span></span></span>。计算窗口内所有样本的中值：</p>
<div style="display: flex; align-items: flex-end; gap: 6px; padding: 20px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; overflow-x: auto; margin: 16px 0;">
  <div style="text-align: center;">
    <div style="font-size: 11px; color: #94a3b8; margin-bottom: 2px;">x[0]</div>
    <div style="width: 44px; height: 30px; background: #e2e8f0; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: #64748b; font-size: 12px;">100</div>
  </div>
  <div style="text-align: center; border-top: 2px solid #3b82f6; border-left: 2px solid #3b82f6; border-right: 2px solid #3b82f6; border-radius: 4px 4px 0 0; padding-top: 2px; margin-top: 18px;">
    <div style="font-size: 11px; color: #3b82f6; margin-bottom: 2px;">x[1]</div>
    <div style="width: 44px; height: 32px; background: #dbeafe; border: 1px solid #93c5fd; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: #1e40af; font-size: 12px;">102</div>
  </div>
  <div style="text-align: center; border-top: 2px solid #3b82f6; border-left: 2px solid #3b82f6; border-right: 2px solid #3b82f6; border-radius: 4px 4px 0 0; padding-top: 2px; margin-top: 18px;">
    <div style="font-size: 11px; color: #3b82f6; margin-bottom: 2px;">x[2]</div>
    <div style="width: 44px; height: 28px; background: #dbeafe; border: 1px solid #93c5fd; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: #1e40af; font-size: 12px;">98</div>
  </div>
  <div style="text-align: center; border: 2px solid #f59e0b; border-radius: 4px; padding-top: 2px; margin-top: 18px; background: #fffbeb;">
    <div style="font-size: 11px; color: #b45309; margin-bottom: 2px;">x[3] ←</div>
    <div style="width: 44px; height: 31px; background: #fef3c7; border: 1px solid #fbbf24; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: #92400e; font-size: 12px; font-weight: bold;">101</div>
  </div>
  <div style="text-align: center; border-top: 2px solid #3b82f6; border-left: 2px solid #3b82f6; border-right: 2px solid #3b82f6; border-radius: 4px 4px 0 0; padding-top: 2px; margin-top: 18px;">
    <div style="font-size: 11px; color: #3b82f6; margin-bottom: 2px;">x[4]</div>
    <div style="width: 44px; height: 80px; background: #fee2e2; border: 2px solid #ef4444; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: #b91c1c; font-size: 12px;">500</div>
  </div>
  <div style="text-align: center; border-top: 2px solid #3b82f6; border-left: 2px solid #3b82f6; border-right: 2px solid #3b82f6; border-radius: 4px 4px 0 0; padding-top: 2px; margin-top: 18px;">
    <div style="font-size: 11px; color: #3b82f6; margin-bottom: 2px;">x[5]</div>
    <div style="width: 44px; height: 29px; background: #dbeafe; border: 1px solid #93c5fd; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: #1e40af; font-size: 12px;">99</div>
  </div>
  <div style="text-align: center; border-top: 2px solid #3b82f6; border-left: 2px solid #3b82f6; border-right: 2px solid #3b82f6; border-radius: 4px 4px 0 0; padding-top: 2px; margin-top: 18px;">
    <div style="font-size: 11px; color: #3b82f6; margin-bottom: 2px;">x[6]</div>
    <div style="width: 44px; height: 33px; background: #dbeafe; border: 1px solid #93c5fd; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: #1e40af; font-size: 12px;">103</div>
  </div>
  <div style="text-align: center;">
    <div style="font-size: 11px; color: #94a3b8; margin-bottom: 2px;">x[7]</div>
    <div style="width: 44px; height: 30px; background: #e2e8f0; border-radius: 4px; display: flex; align-items: center; justify-content: center; color: #64748b; font-size: 12px;">100</div>
  </div>
</div>

<div style="display: flex; gap: 16px; font-size: 13px; color: #64748b; margin: -8px 0 16px 4px;">
  <span>⬜ 灰色：窗口外</span>
  <span style="color: #3b82f6;">🟦 蓝色：窗口内</span>
  <span style="color: #f59e0b;">🟧 橙色：当前样本</span>
  <span style="color: #ef4444;">🟥 红色：异常值</span>
</div>

<p>窗口 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo stretchy="false">{</mo><mn>102</mn><mo separator="true">,</mo><mn>98</mn><mo separator="true">,</mo><mn>101</mn><mo separator="true">,</mo><mn>500</mn><mo separator="true">,</mo><mn>99</mn><mo stretchy="false">}</mo></mrow><annotation encoding="application/x-tex">\{102, 98, 101, 500, 99\}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mopen">{</span><span class="mord">102</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">98</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">101</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">500</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">99</span><span class="mclose">}</span></span></span></span></span> 排序后 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo stretchy="false">{</mo><mn>98</mn><mo separator="true">,</mo><mn>99</mn><mo separator="true">,</mo><mn>101</mn><mo separator="true">,</mo><mn>102</mn><mo separator="true">,</mo><mn>500</mn><mo stretchy="false">}</mo></mrow><annotation encoding="application/x-tex">\{98, 99, 101, 102, 500\}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mopen">{</span><span class="mord">98</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">99</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">101</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">102</span><span class="mpunct">,</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord">500</span><span class="mclose">}</span></span></span></span></span>，中值 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mover accent="true"><mi>x</mi><mo>~</mo></mover><mo>=</mo><mn>101</mn></mrow><annotation encoding="application/x-tex">\tilde{x} = 101</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6679em;"></span><span class="mord accent"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.6679em;"><span style="top:-3em;"><span class="pstrut" style="height:3em;"></span><span class="mord mathnormal">x</span></span><span style="top:-3.35em;"><span class="pstrut" style="height:3em;"></span><span class="accent-body" style="left:-0.2222em;"><span class="mord">~</span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">101</span></span></span></span></span>。</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mover accent="true"><mi>x</mi><mo>~</mo></mover><mo>=</mo><mtext>median</mtext><mo stretchy="false">(</mo><mi>W</mi><mo stretchy="false">)</mo></mrow><annotation encoding="application/x-tex">\tilde{x} = \text{median}(W)</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6679em;"></span><span class="mord accent"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.6679em;"><span style="top:-3em;"><span class="pstrut" style="height:3em;"></span><span class="mord mathnormal">x</span></span><span style="top:-3.35em;"><span class="pstrut" style="height:3em;"></span><span class="accent-body" style="left:-0.2222em;"><span class="mord">~</span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord text"><span class="mord">median</span></span><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.1389em;">W</span><span class="mclose">)</span></span></span></span></span></div>
<h3>中值绝对偏差（MAD）</h3>
<p>MAD 定义为窗口内各点与中值偏差的绝对值的中值：</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mtext>MAD</mtext><mo>=</mo><mtext>median</mtext><mo stretchy="false">(</mo><mi mathvariant="normal">∣</mi><msub><mi>x</mi><mi>i</mi></msub><mo>−</mo><mover accent="true"><mi>x</mi><mo>~</mo></mover><mi mathvariant="normal">∣</mi><mo stretchy="false">)</mo><mo separator="true">,</mo><mspace width="1em"/><msub><mi>x</mi><mi>i</mi></msub><mo>∈</mo><mi>W</mi></mrow><annotation encoding="application/x-tex">\text{MAD} = \text{median}(|x_i - \tilde{x}|), \quad x_i \in W</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord text"><span class="mord">MAD</span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord text"><span class="mord">median</span></span><span class="mopen">(</span><span class="mord">∣</span><span class="mord"><span class="mord mathnormal">x</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord accent"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.6679em;"><span style="top:-3em;"><span class="pstrut" style="height:3em;"></span><span class="mord mathnormal">x</span></span><span style="top:-3.35em;"><span class="pstrut" style="height:3em;"></span><span class="accent-body" style="left:-0.2222em;"><span class="mord">~</span></span></span></span></span></span></span><span class="mord">∣</span><span class="mclose">)</span><span class="mpunct">,</span><span class="mspace" style="margin-right:1em;"></span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord"><span class="mord mathnormal">x</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">∈</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord mathnormal" style="margin-right:0.1389em;">W</span></span></span></span></span></div>
<p>MAD 比标准差更鲁棒——单个异常值不会像对方差那样拉偏整个统计量。</p>
<div style="padding: 20px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; margin: 16px 0; font-family: monospace; font-size: 14px;">
  <div style="color: #475569; margin-bottom: 12px; font-weight: bold;">MAD 计算过程（以上述窗口为例）</div>
  <table style="width: 100%; border-collapse: collapse; color: #334155;">
    <tr style="border-bottom: 1px solid #e2e8f0;">
      <td style="padding: 8px 12px; color: #94a3b8; white-space: nowrap;">① 窗口数据</td>
      <td style="padding: 8px 12px; text-align: center;">102</td><td style="padding: 8px 12px; text-align: center;">98</td><td style="padding: 8px 12px; text-align: center;">101</td><td style="padding: 8px 12px; text-align: center; color: #dc2626; font-weight: bold;">500</td><td style="padding: 8px 12px; text-align: center;">99</td>
    </tr>
    <tr style="border-bottom: 1px solid #e2e8f0;">
      <td style="padding: 8px 12px; color: #94a3b8; white-space: nowrap;">② 中值</td>
      <td colspan="5" style="padding: 8px 12px; color: #b45309; font-weight: bold;">x̃ = 101</td>
    </tr>
    <tr style="border-bottom: 1px solid #e2e8f0;">
      <td style="padding: 8px 12px; color: #94a3b8; white-space: nowrap;">③ 绝对偏差</td>
      <td style="padding: 8px 12px; text-align: center;">1</td><td style="padding: 8px 12px; text-align: center;">3</td><td style="padding: 8px 12px; text-align: center;">0</td><td style="padding: 8px 12px; text-align: center; color: #dc2626; font-weight: bold;">399</td><td style="padding: 8px 12px; text-align: center;">2</td>
    </tr>
    <tr style="border-bottom: 1px solid #e2e8f0;">
      <td style="padding: 8px 12px; color: #94a3b8; white-space: nowrap;">④ 排序偏差</td>
      <td style="padding: 8px 12px; text-align: center;">0</td><td style="padding: 8px 12px; text-align: center;">1</td><td style="padding: 8px 12px; text-align: center; color: #b45309; font-weight: bold;">2</td><td style="padding: 8px 12px; text-align: center;">3</td><td style="padding: 8px 12px; text-align: center; color: #dc2626; font-weight: bold;">399</td>
    </tr>
    <tr>
      <td style="padding: 8px 12px; color: #94a3b8; white-space: nowrap;">⑤ MAD</td>
      <td colspan="5" style="padding: 8px 12px; color: #059669; font-weight: bold;">MAD = 2</td>
    </tr>
  </table>
  <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #e2e8f0; color: #64748b; font-size: 13px;">
    阈值 = 3 × 1.4826 × 2 = <span style="color: #059669; font-weight: bold;">8.9</span>　|　|x[4] - x̃| = |500 - 101| = <span style="color: #dc2626; font-weight: bold;">399</span>　→　399 > 8.9，判定为离群点
  </div>
</div>

<h3>异常判定与替换</h3>
<p>将 MAD 乘以缩放系数 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span>（通常取 1.4826，使 MAD 在高斯分布下与标准差一致），设定阈值 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>t</mi></mrow><annotation encoding="application/x-tex">t</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6151em;"></span><span class="mord mathnormal">t</span></span></span></span></span>（通常取 3）。<span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 在此语境下是 MAD 的缩放因子，作用是把 MAD 校正到与标准差等价的尺度，使得阈值 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>t</mi></mrow><annotation encoding="application/x-tex">t</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6151em;"></span><span class="mord mathnormal">t</span></span></span></span></span> 可以直接沿用统计学中 &quot;<span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>3</mn><mi>σ</mi></mrow><annotation encoding="application/x-tex">3\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">3</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 准则&quot;的直觉——<span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>t</mi><mo>=</mo><mn>3</mn></mrow><annotation encoding="application/x-tex">t=3</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6151em;"></span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">3</span></span></span></span></span> 对应约 99.7% 的高斯置信区间：</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><msub><mi>σ</mi><mtext>MAD</mtext></msub><mo>=</mo><mi>σ</mi><mo>⋅</mo><mtext>MAD</mtext></mrow><annotation encoding="application/x-tex">\sigma_{\text{MAD}} = \sigma \cdot \text{MAD}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3283em;"><span style="top:-2.55em;margin-left:-0.0359em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord text mtight"><span class="mord mtight">MAD</span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.4445em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">⋅</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.6833em;"></span><span class="mord text"><span class="mord">MAD</span></span></span></span></span></span></div>
<p>判定规则：</p>
<ul>
<li>若 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi mathvariant="normal">∣</mi><mi>x</mi><mo stretchy="false">[</mo><mi>n</mi><mo stretchy="false">]</mo><mo>−</mo><mover accent="true"><mi>x</mi><mo>~</mo></mover><mi mathvariant="normal">∣</mi><mo>&gt;</mo><mi>t</mi><mo>⋅</mo><msub><mi>σ</mi><mtext>MAD</mtext></msub></mrow><annotation encoding="application/x-tex">|x[n] - \tilde{x}| &gt; t \cdot \sigma_{\text{MAD}}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord">∣</span><span class="mord mathnormal">x</span><span class="mopen">[</span><span class="mord mathnormal">n</span><span class="mclose">]</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord accent"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.6679em;"><span style="top:-3em;"><span class="pstrut" style="height:3em;"></span><span class="mord mathnormal">x</span></span><span style="top:-3.35em;"><span class="pstrut" style="height:3em;"></span><span class="accent-body" style="left:-0.2222em;"><span class="mord">~</span></span></span></span></span></span></span><span class="mord">∣</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">&gt;</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6151em;"></span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">⋅</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3283em;"><span style="top:-2.55em;margin-left:-0.0359em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord text mtight"><span class="mord mtight">MAD</span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span></span>，则将 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>x</mi><mo stretchy="false">[</mo><mi>n</mi><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">x[n]</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">x</span><span class="mopen">[</span><span class="mord mathnormal">n</span><span class="mclose">]</span></span></span></span></span> 视为离群点，替换为中值 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mover accent="true"><mi>x</mi><mo>~</mo></mover></mrow><annotation encoding="application/x-tex">\tilde{x}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6679em;"></span><span class="mord accent"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.6679em;"><span style="top:-3em;"><span class="pstrut" style="height:3em;"></span><span class="mord mathnormal">x</span></span><span style="top:-3.35em;"><span class="pstrut" style="height:3em;"></span><span class="accent-body" style="left:-0.2222em;"><span class="mord">~</span></span></span></span></span></span></span></span></span></span></span></li>
<li>否则保留原始值 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>x</mi><mo stretchy="false">[</mo><mi>n</mi><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">x[n]</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">x</span><span class="mopen">[</span><span class="mord mathnormal">n</span><span class="mclose">]</span></span></span></span></span></li>
</ul>
<h2>C 语言实现</h2>
<p>以下实现适用于嵌入式环境，无动态内存分配，使用固定大小缓冲区。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdint.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;string.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdlib.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/* 比较函数，用于 qsort */</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> int</span><span style="color:#DCDCAA"> cmp_int16</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">a</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">const</span><span style="color:#569CD6"> void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">b</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    int16_t</span><span style="color:#D4D4D4"> va = *(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> int16_t</span><span style="color:#D4D4D4"> *)a;</span></span>
<span class="line"><span style="color:#569CD6">    int16_t</span><span style="color:#D4D4D4"> vb = *(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> int16_t</span><span style="color:#D4D4D4"> *)b;</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> (va > vb) - (va &#x3C; vb);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">/*</span></span>
<span class="line"><span style="color:#6A9955"> * Hampel Filter - 基于中值绝对偏差的离群点检测与替换</span></span>
<span class="line"><span style="color:#6A9955"> *</span></span>
<span class="line"><span style="color:#6A9955"> * @param input:   输入信号数组</span></span>
<span class="line"><span style="color:#6A9955"> * @param output:  输出信号数组（可与 input 相同，原地处理）</span></span>
<span class="line"><span style="color:#6A9955"> * @param len:     信号长度</span></span>
<span class="line"><span style="color:#6A9955"> * @param k:       窗口半径（窗口长度 = 2k+1）</span></span>
<span class="line"><span style="color:#6A9955"> * @param t:       阈值系数（推荐 3.0）</span></span>
<span class="line"><span style="color:#6A9955"> * @param sigma:   MAD 缩放系数（推荐 1.4826）</span></span>
<span class="line"><span style="color:#6A9955"> */</span></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> hampel_filter</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> int16_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">input</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">int16_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">output</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                   uint32_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">uint16_t</span><span style="color:#9CDCFE"> k</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                   float</span><span style="color:#9CDCFE"> t</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">float</span><span style="color:#9CDCFE"> sigma</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> uint16_t</span><span style="color:#D4D4D4"> win_size = </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> * k + </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    /* 临时缓冲区，存放窗口数据的副本 */</span></span>
<span class="line"><span style="color:#569CD6">    int16_t</span><span style="color:#9CDCFE"> window</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4"> + </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">];</span><span style="color:#6A9955"> /* 最大支持 k=16，按需调整 */</span></span>
<span class="line"><span style="color:#569CD6">    int16_t</span><span style="color:#9CDCFE"> abs_dev</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4"> + </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; len; i++) {</span></span>
<span class="line"><span style="color:#6A9955">        /* 计算窗口边界，处理边界情况 */</span></span>
<span class="line"><span style="color:#569CD6">        uint32_t</span><span style="color:#D4D4D4"> start = (i >= k) ? i - k : </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">        uint32_t</span><span style="color:#D4D4D4"> end = (i + k &#x3C; len) ? i + k : len - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">        uint16_t</span><span style="color:#D4D4D4"> actual_size = (</span><span style="color:#569CD6">uint16_t</span><span style="color:#D4D4D4">)(end - start + </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">        /* 复制窗口数据 */</span></span>
<span class="line"><span style="color:#C586C0">        for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">uint16_t</span><span style="color:#D4D4D4"> j = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; j &#x3C; actual_size; j++) {</span></span>
<span class="line"><span style="color:#9CDCFE">            window</span><span style="color:#D4D4D4">[j] = </span><span style="color:#9CDCFE">input</span><span style="color:#D4D4D4">[start + j];</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">        /* 计算中值 */</span></span>
<span class="line"><span style="color:#DCDCAA">        qsort</span><span style="color:#D4D4D4">(window, actual_size, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">int16_t</span><span style="color:#D4D4D4">), cmp_int16);</span></span>
<span class="line"><span style="color:#569CD6">        int16_t</span><span style="color:#D4D4D4"> median = </span><span style="color:#9CDCFE">window</span><span style="color:#D4D4D4">[actual_size / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">        /* 计算各点与中值的绝对偏差 */</span></span>
<span class="line"><span style="color:#C586C0">        for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">uint16_t</span><span style="color:#D4D4D4"> j = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; j &#x3C; actual_size; j++) {</span></span>
<span class="line"><span style="color:#9CDCFE">            abs_dev</span><span style="color:#D4D4D4">[j] = (</span><span style="color:#9CDCFE">window</span><span style="color:#D4D4D4">[j] > median) ?</span></span>
<span class="line"><span style="color:#D4D4D4">                         (</span><span style="color:#9CDCFE">window</span><span style="color:#D4D4D4">[j] - median) :</span></span>
<span class="line"><span style="color:#D4D4D4">                         (median - </span><span style="color:#9CDCFE">window</span><span style="color:#D4D4D4">[j]);</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">        /* 计算 MAD */</span></span>
<span class="line"><span style="color:#DCDCAA">        qsort</span><span style="color:#D4D4D4">(abs_dev, actual_size, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">int16_t</span><span style="color:#D4D4D4">), cmp_int16);</span></span>
<span class="line"><span style="color:#569CD6">        int16_t</span><span style="color:#D4D4D4"> mad = </span><span style="color:#9CDCFE">abs_dev</span><span style="color:#D4D4D4">[actual_size / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">        /* 异常判定 */</span></span>
<span class="line"><span style="color:#569CD6">        float</span><span style="color:#D4D4D4"> threshold = t * sigma * (</span><span style="color:#569CD6">float</span><span style="color:#D4D4D4">)mad;</span></span>
<span class="line"><span style="color:#569CD6">        int16_t</span><span style="color:#D4D4D4"> deviation = (</span><span style="color:#9CDCFE">input</span><span style="color:#D4D4D4">[i] > median) ?</span></span>
<span class="line"><span style="color:#D4D4D4">                            (</span><span style="color:#9CDCFE">input</span><span style="color:#D4D4D4">[i] - median) :</span></span>
<span class="line"><span style="color:#D4D4D4">                            (median - </span><span style="color:#9CDCFE">input</span><span style="color:#D4D4D4">[i]);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> ((</span><span style="color:#569CD6">float</span><span style="color:#D4D4D4">)deviation > threshold) {</span></span>
<span class="line"><span style="color:#9CDCFE">            output</span><span style="color:#D4D4D4">[i] = median;</span><span style="color:#6A9955">  /* 离群点，替换为中值 */</span></span>
<span class="line"><span style="color:#D4D4D4">        } </span><span style="color:#C586C0">else</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#9CDCFE">            output</span><span style="color:#D4D4D4">[i] = </span><span style="color:#9CDCFE">input</span><span style="color:#D4D4D4">[i];</span><span style="color:#6A9955"> /* 正常点，保留原值 */</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h2>参数选择</h2>
<table>
<thead>
<tr>
<th>参数</th>
<th>推荐值</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>k</mi></mrow><annotation encoding="application/x-tex">k</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal" style="margin-right:0.0315em;">k</span></span></span></span></span></td>
<td>3~5</td>
<td>窗口半径。越大越平滑，但响应越慢</td>
</tr>
<tr>
<td><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>t</mi></mrow><annotation encoding="application/x-tex">t</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6151em;"></span><span class="mord mathnormal">t</span></span></span></span></span></td>
<td>3.0</td>
<td>阈值系数。越大越宽松，只剔除极端异常</td>
</tr>
<tr>
<td><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span></td>
<td>1.4826</td>
<td>使 MAD 等效于高斯分布下的标准差</td>
</tr>
</tbody></table>
<span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi><mo>=</mo><mn>1.4826</mn></mrow><annotation encoding="application/x-tex">\sigma = 1.4826</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">1.4826</span></span></span></span></span> 的来源：对于标准正态分布，MAD 的期望值约为 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>0.6745</mn><mi>σ</mi></mrow><annotation encoding="application/x-tex">0.6745\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">0.6745</span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span>，取倒数 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mn>1</mn><mi mathvariant="normal">/</mi><mn>0.6745</mn><mo>≈</mo><mn>1.4826</mn></mrow><annotation encoding="application/x-tex">1/0.6745 \approx 1.4826</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord">1/0.6745</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">≈</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">1.4826</span></span></span></span></span> 作为缩放系数，使得 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mi>σ</mi><mtext>MAD</mtext></msub></mrow><annotation encoding="application/x-tex">\sigma_{\text{MAD}}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3283em;"><span style="top:-2.55em;margin-left:-0.0359em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord text mtight"><span class="mord mtight">MAD</span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span></span> 与标准差 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>σ</mi></mrow><annotation encoding="application/x-tex">\sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 在高斯假设下等价。</p>
<h2>使用示例</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdio.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    int16_t</span><span style="color:#D4D4D4"> signal</span><span style="color:#569CD6">[]</span><span style="color:#D4D4D4"> = {</span><span style="color:#B5CEA8">100</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">102</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">98</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">101</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">500</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">99</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">103</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">100</span><span style="color:#D4D4D4">, -</span><span style="color:#B5CEA8">200</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">98</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> len = </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(signal) / </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">signal</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#569CD6">    int16_t</span><span style="color:#9CDCFE"> filtered</span><span style="color:#D4D4D4">[len];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    hampel_filter</span><span style="color:#D4D4D4">(signal, filtered, len,</span></span>
<span class="line"><span style="color:#6A9955">                  /*k=*/</span><span style="color:#B5CEA8">3</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955"> /*t=*/</span><span style="color:#B5CEA8">3.0f</span><span style="color:#D4D4D4">,</span><span style="color:#6A9955"> /*sigma=*/</span><span style="color:#B5CEA8">1.4826f</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"原始信号</span><span style="color:#D7BA7D">\t</span><span style="color:#CE9178">滤波后</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; len; i++) {</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"</span><span style="color:#9CDCFE">%d</span><span style="color:#D7BA7D">\t\t</span><span style="color:#9CDCFE">%d</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">signal</span><span style="color:#D4D4D4">[i], </span><span style="color:#9CDCFE">filtered</span><span style="color:#D4D4D4">[i]);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>输出：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>原始信号        滤波后</span></span>
<span class="line"><span>100             100</span></span>
<span class="line"><span>102             102</span></span>
<span class="line"><span>98              98</span></span>
<span class="line"><span>101             101</span></span>
<span class="line"><span>500             101      ← 尖刺被替换为中值</span></span>
<span class="line"><span>99              99</span></span>
<span class="line"><span>103             103</span></span>
<span class="line"><span>100             100</span></span>
<span class="line"><span>-200            99       ← 尖刺被替换为中值</span></span>
<span class="line"><span>98              98</span></span></code></pre><div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0;">
  <div style="padding: 16px; background: #fff1f2; border: 1px solid #fecdd3; border-radius: 8px;">
    <div style="color: #e11d48; font-size: 13px; font-weight: bold; margin-bottom: 12px;">原始信号（含尖刺）</div>
    <div style="display: flex; align-items: flex-end; gap: 3px; height: 120px;">
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:24px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">100</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:24px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">102</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:20px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">98</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:22px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">101</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:100px; background:#fee2e2; border:2px solid #ef4444; border-radius:2px;"></div><span style="font-size:10px; color:#dc2626; font-weight:bold; margin-top:4px;">500</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:20px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">99</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:26px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">103</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:24px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">100</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:2px; background:#fee2e2; border:2px solid #ef4444; border-radius:2px;"></div><span style="font-size:10px; color:#dc2626; font-weight:bold; margin-top:4px;">-200</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:20px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">98</span></div>
    </div>
  </div>
  <div style="padding: 16px; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px;">
    <div style="color: #16a34a; font-size: 13px; font-weight: bold; margin-bottom: 12px;">滤波后（尖刺被替换）</div>
    <div style="display: flex; align-items: flex-end; gap: 3px; height: 120px;">
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:24px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">100</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:24px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">102</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:20px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">98</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:22px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">101</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:22px; background:#dcfce7; border:2px solid #22c55e; border-radius:2px;"></div><span style="font-size:10px; color: #16a34a; font-weight:bold; margin-top:4px;">101</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:20px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">99</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:26px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">103</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:24px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">100</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:20px; background:#dcfce7; border:2px solid #22c55e; border-radius:2px;"></div><span style="font-size:10px; color: #16a34a; font-weight:bold; margin-top:4px;">99</span></div>
      <div style="flex:1; display:flex; flex-direction:column; align-items:center;"><div style="width:100%; height:20px; background:#cbd5e1; border-radius:2px;"></div><span style="font-size:10px; color:#64748b; margin-top:4px;">98</span></div>
    </div>
  </div>
</div>

<div style="display: flex; gap: 16px; font-size: 13px; color: #64748b; margin: -8px 0 16px 4px;">
  <span>🟥 红色边框：被检测为离群点</span>
  <span style="color: #16a34a;">🟩 绿色边框：替换后的中值</span>
</div>

<h2>嵌入式应用注意事项</h2>
<ol>
<li><p><strong>qsort 开销</strong>：每次窗口移动执行两次排序。若窗口较小（<span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>k</mi><mo>≤</mo><mn>5</mn></mrow><annotation encoding="application/x-tex">k \leq 5</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8304em;vertical-align:-0.136em;"></span><span class="mord mathnormal" style="margin-right:0.0315em;">k</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">≤</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">5</span></span></span></span></span>，窗口 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mo>≤</mo><mn>11</mn></mrow><annotation encoding="application/x-tex">\leq 11</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.7719em;vertical-align:-0.136em;"></span><span class="mrel">≤</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.6444em;"></span><span class="mord">11</span></span></span></span></span>），排序开销可接受。对于实时性要求高的场景，可用插入排序替代。</p>
</li>
<li><p><strong>整数溢出</strong>：MAD 计算中的绝对偏差使用 <code>int16_t</code>，若信号动态范围接近 <code>INT16_MAX</code>，需改用 <code>int32_t</code>。</p>
</li>
<li><p><strong>边界处理</strong>：代码中边界处窗口自动缩小。若需要严格等长窗口，可在信号两端做镜像填充或重复边界值。</p>
</li>
<li><p><strong>浮点依赖</strong>：阈值比较使用 <code>float</code>。若 MCU 无 FPU，可将 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>t</mi><mo>⋅</mo><mi>σ</mi></mrow><annotation encoding="application/x-tex">t \cdot \sigma</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6151em;"></span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">⋅</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal" style="margin-right:0.0359em;">σ</span></span></span></span></span> 合并为常数，乘法改用定点运算。</p>
</li>
</ol>
<h2>适用场景</h2>
<ul>
<li>传感器数据预处理（温度、压力、心率等突变尖刺）</li>
<li>工业控制系统中的信号清洗</li>
<li>嵌入式系统中替代中值滤波器，增加自适应阈值能力</li>
<li>任何需要保留信号边缘同时剔除脉冲干扰的场景</li>
</ul>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="Hampel Filter"/>
    <category term="中值滤波"/>
    <category term="异常检测"/>
    <category term="嵌入式"/>
    <category term="C语言"/>
  </entry>
  <entry>
    <title>NoeticMaze 在 STM32 上实现激光雷达 SLAM 与自主导航的工程拆解</title>
    <link href="https://createskyblue.github.io/posts/noeticmaze-stm32-lidar-slam-navigation/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/noeticmaze-stm32-lidar-slam-navigation/</id>
    <published>2026-05-31T00:00:00.000Z</published>
    <updated>2026-05-31T00:00:00.000Z</updated>
    <summary>STM32激光雷达SLAM</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>NoeticMaze 在 STM32 上实现激光雷达 SLAM 与自主导航的工程拆解</h1>
<blockquote>
<p><strong>声明：</strong> NoeticMaze 是一个开源项目，作者为 <a href="https://github.com/dokdo2013/noeticmaze">dokdo2013</a>。本文是阅读该项目源码和文档后整理的学习笔记，用于理解嵌入式 SLAM 的工程实现方式。<strong>本文作者不是该项目的开发者，未参与该项目的任何代码贡献。</strong> 文中所有工程细节均来自对开源代码的分析，不代表本文作者的设计决策。</p>
</blockquote>
<p>NoeticMaze 是一个基于 STM32F446RE 的室内自主导航项目。它把激光雷达建图、ICP 定位、A* 路径规划、路径跟踪、动态重规划和蓝牙遥测放进了一个资源受限的 MCU 工程。</p>
<p>这篇文章按照技术报告的完整度展开，不把项目压缩成一句“STM32 跑 SLAM”。真正有价值的部分在工程边界：地图怎么存，点云怎么传，ICP 怎么收敛，A* 怎么在小内存里搜索，FreeRTOS 任务怎么隔离实时控制和重计算。</p>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:10px; margin:18px 0; padding:12px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px;">
  <div style="padding:12px; background:#ffffff; border:1px solid #dbeafe; border-radius:8px;">
    <div style="font-weight:700; color:#1e293b;">1. 感知</div>
    <div style="font-size:14px; color:#475569; margin-top:6px;">雷达 DMA 组帧，IMU 和编码器给运动预测。</div>
  </div>
  <div style="padding:12px; background:#ffffff; border:1px solid #dcfce7; border-radius:8px;">
    <div style="font-weight:700; color:#1e293b;">2. 定位与建图</div>
    <div style="font-size:14px; color:#475569; margin-top:6px;">ICP 输出位姿，Bresenham 写入占据栅格。</div>
  </div>
  <div style="padding:12px; background:#ffffff; border:1px solid #fef9c3; border-radius:8px;">
    <div style="font-weight:700; color:#1e293b;">3. 规划</div>
    <div style="font-size:14px; color:#475569; margin-top:6px;">2cm 地图降采样到 10cm，A* 搜索低代价路径。</div>
  </div>
  <div style="padding:12px; background:#ffffff; border:1px solid #fee2e2; border-radius:8px;">
    <div style="font-weight:700; color:#1e293b;">4. 控制</div>
    <div style="font-size:14px; color:#475569; margin-top:6px;">MotionTask 100Hz 跟踪路径并输出左右轮 PWM。</div>
  </div>
</div>

<h2>核心结论</h2>
<p>NoeticMaze 在 STM32F446RE 上实现了基于激光雷达的局部室内建图与自主导航闭环。系统不依赖 ROS，不依赖上位机做核心算法，也不使用预先导入的地图。</p>
<p>完整导航能力包括：</p>
<ul>
<li>激光雷达 360 度扫描：UART DMA 接收，逐帧解析为极坐标点云。</li>
<li>ICP 实时定位：点到线 ICP 匹配前后激光扫描，估计机器人位移。</li>
<li>占据栅格建图：2cm 分辨率栅格，Bresenham 光线追踪更新地图。</li>
<li>A* 路径规划：在 10cm 降采样规划图上搜索低代价可行路径。</li>
<li>预瞄点航向跟踪：100Hz 运动控制差速底盘沿路径行驶。</li>
<li>动态重规划：目标变化、路径阻断或位姿偏移时重新规划。</li>
<li>蓝牙遥测：向上位机发送位姿、地图增量、路径和调试事件。</li>
</ul>
<p>它的适用范围也很明确：固定 4m x 4m 局部地图、小型室内或迷宫场景、相对静态环境。它没有回环检测，不适合长距离、大范围、动态人群环境。</p>
<h2>几个需要先厘清的技术边界</h2>
<p>理解 NoeticMaze 前，需要先厘清几个技术边界。它们直接影响后面对定位、建图、路径规划和避障机制的判断。</p>
<p>第一，地图是在线构建的。机器人开机后从零开始扫描环境，通过 ICP 估计位姿，再用 Bresenham 光线追踪把扫描结果写入占据栅格。用户是在实时构建的地图上点选目标。</p>
<p>第二，ICP 采用帧到帧匹配。系统维护参考扫描帧，用当前扫描和参考帧做点到线匹配，估计相对运动，再把定位结果用于建图。</p>
<p>第三，A* 结果应描述为低代价可行路径。项目在 10cm 规划图上做 8 连通搜索，并叠加障碍膨胀、未知区策略、旧路径亲和度和路径平滑。</p>
<p>第四，当前雷达任务没有真正按车体运动状态做跳帧。代码中有运动学跳帧相关宏和设计意图，但实际数据流是每完成一圈 360 度扫描后投递给 SLAM；队列满时复用当前缓冲。</p>
<p>第五，路径规划结果通过全局路径快照和事件标志通知运动线程。系统里存在 <code>RespQueueHandle</code>，但最终路径发布机制是全局路径公告板。</p>
<h2>硬件平台与资源约束</h2>
<p>NoeticMaze 使用 NUCLEO-F446RE，核心 MCU 是 STM32F446RE。</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>规格</th>
</tr>
</thead>
<tbody><tr>
<td>内核</td>
<td>ARM Cortex-M4F，带单精度 FPU</td>
</tr>
<tr>
<td>主频</td>
<td>180MHz</td>
</tr>
<tr>
<td>Flash</td>
<td>512KB</td>
</tr>
<tr>
<td>SRAM</td>
<td>128KB</td>
</tr>
<tr>
<td>FPU</td>
<td>fpv4-sp-d16，支持单精度浮点</td>
</tr>
<tr>
<td>RTOS</td>
<td>FreeRTOS v10.3.1，CMSIS-RTOS v2 API</td>
</tr>
<tr>
<td>FreeRTOS 堆</td>
<td>30720 Bytes，约 30KB</td>
</tr>
<tr>
<td>开发板</td>
<td>NUCLEO-F446RE</td>
</tr>
</tbody></table>
<p>外围传感器和通信接口如下。</p>
<table>
<thead>
<tr>
<th>模块</th>
<th>型号或规格</th>
<th>接口</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td>激光雷达</td>
<td>460800 波特率，84 字节/帧，40 点/帧</td>
<td>USART1 + DMA</td>
<td>360 度环境距离扫描</td>
</tr>
<tr>
<td>IMU</td>
<td>MPU6500，3 轴陀螺仪 + 3 轴加速度计</td>
<td>SPI2</td>
<td>Yaw 和角速度</td>
</tr>
<tr>
<td>编码器</td>
<td>MC520P30_12V，30:1 减速比，4 倍频 1560 ticks/圈</td>
<td>TIM3/TIM4 正交解码</td>
<td>轮速与里程计</td>
</tr>
<tr>
<td>蓝牙</td>
<td>透传模块</td>
<td>USART3 + DMA，912600 波特率</td>
<td>上位机遥测和目标点指令</td>
</tr>
</tbody></table>
<p>底盘参数如下。</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>值</th>
</tr>
</thead>
<tbody><tr>
<td>驱动方式</td>
<td>双轮差速驱动</td>
</tr>
<tr>
<td>轮径</td>
<td>70mm</td>
</tr>
<tr>
<td>轮距</td>
<td>180mm</td>
</tr>
<tr>
<td>最大线速度</td>
<td>0.8m/s</td>
</tr>
<tr>
<td>巡航线速度</td>
<td>0.35m/s</td>
</tr>
<tr>
<td>最大角速度</td>
<td>60deg/s</td>
</tr>
<tr>
<td>电机 PWM 频率</td>
<td>45kHz</td>
</tr>
</tbody></table>
<p>128KB SRAM 是最紧的资源。系统同时需要维护地图、雷达帧、点云、A* 队列、任务栈、蓝牙缓冲和各种状态快照。</p>
<p>关键内存压力包括：</p>
<ul>
<li>2cm 分辨率的 200 x 200 占据栅格地图：2bit/cell，约 10KB。</li>
<li>10cm 分辨率的 40 x 40 规划地图：4bit/cell，800B/缓冲，乒乓双缓冲共 1600B。</li>
<li>3 个激光雷达帧缓冲：<code>LidarMap_t</code> 每个约 736B，共约 2.2KB。</li>
<li>FreeRTOS 任务栈：多个任务合计约 20KB。</li>
<li>ICP 使用的 360 点当前帧和参考帧点云。</li>
<li>A* 优先队列：2500 个节点，每个 4B，共约 10KB。</li>
</ul>
<p>这些数字解释了项目为什么大量使用静态内存、打包存储、零拷贝队列和降采样规划图。桌面端常见的宽松数据结构，在这个平台上很快会把 RAM 用完。</p>
<h2>总体数据流</h2>
<p>系统可以按以下链路理解：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>激光雷达 USART1 + DMA</span></span>
<span class="line"><span>  -> LidarRouteTask 解析并组装 360 度扫描</span></span>
<span class="line"><span>  -> LidarQueueHandle 投递 LidarMap_t 指针</span></span>
<span class="line"><span>  -> AlgorithmBrain 执行 ICP、建图、降采样、重规划判断</span></span>
<span class="line"><span>  -> ReqQueueHandle 向 PlannerTask 请求路径</span></span>
<span class="line"><span>  -> PlannerTask 在 10cm 地图上运行 A* 和路径平滑</span></span>
<span class="line"><span>  -> 全局路径公告板 + PathEventHandle 发布路径</span></span>
<span class="line"><span>  -> MotionTask 100Hz 跟踪路径并控制电机</span></span>
<span class="line"><span>  -> TaskPrint 通过蓝牙发送地图、路径、位姿和调试事件</span></span></code></pre><p>高频运动控制和低频规划分开。A* 可以按需运行，MotionTask 必须稳定周期运行。雷达、SLAM、Planner 和蓝牙也通过队列和事件解耦，避免单个模块卡住整个控制链。</p>
<h2>激光雷达数据采集与预处理</h2>
<p>激光雷达数据通过 USART1 + DMA 循环接收。UART 空闲中断触发解析逻辑，<code>LidarRouteTask</code> 将原始帧组装为完整的 360 度扫描。</p>
<p>协议结构要点：</p>
<ul>
<li>单帧 84 字节。</li>
<li>每帧包含 40 个测距点。</li>
<li>帧头高半字节匹配 <code>0xA?</code>、<code>0x5?</code>，低半字节组合为校验值。</li>
<li>相邻点角度间隔约 0.705 度。</li>
<li>单帧覆盖约 27.5 到 28.2 度。</li>
<li>完成 360 度扫描需要约 13 到 14 帧原始数据。</li>
</ul>
<p>代码采用动态插值解算器。解析时根据相邻两帧起始角度差计算精确步长，补偿雷达电机转速波动。</p>
<p>距离过滤也分层处理。雷达模块有效距离宏为 <code>30mm ~ 10000mm</code>，SLAM 转换点云时使用 <code>30mm ~ 8000mm</code> 过滤，无效点标记为 0。</p>
<p>雷达坐标系还有方向适配问题。该雷达角度方向是顺时针为正，而常见 SLAM 坐标系按逆时针为正。极坐标转笛卡尔坐标时需要做角度方向转换，并叠加雷达安装角补偿。</p>
<h3>零拷贝雷达缓冲池</h3>
<p>系统维护 3 个静态 <code>LidarMap_t</code> 缓冲区，通过 FreeRTOS 队列实现借还机制。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>空闲池 -> 雷达任务填充 -> 就绪队列 -> SLAM 任务处理 -> 归还空闲池</span></span></code></pre><p>队列里传的是指针，不复制整帧数据。当前结构体每个约 736B，三块缓冲区可以接受；如果每帧复制，会浪费 CPU 和 SRAM。</p>
<h3>运动学跳帧的当前状态</h3>
<p>代码中定义了直行跳帧、转弯全投递、角速度阈值等宏，设计意图是转弯时全量处理，直行或静止时降低投递频率。</p>
<p>当前 <code>StartLidarRouteTask()</code> 没有读取角速度，也没有使用这些宏丢弃直行帧。实际行为是每完成一圈 360 度扫描后，队列可用就投递给 SLAM；队列满时复用当前缓冲。</p>
<p>因此，文章里不能把“直行时每 10 帧只处理 1 帧”写成已生效优化，只能写成预留设计。</p>
<h2>IMU 与轮式里程计</h2>
<p>MPU6500 使用 DMP 做姿态解算。DMP 在传感器内部输出四元数，STM32 通过 SPI2 读取结果，减少 MCU 上的姿态计算负担。</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>值</th>
</tr>
</thead>
<tbody><tr>
<td>IMU</td>
<td>MPU6500</td>
</tr>
<tr>
<td>通信</td>
<td>SPI2</td>
</tr>
<tr>
<td>DMP 输出</td>
<td>6 轴低功耗四元数</td>
</tr>
<tr>
<td>采样率</td>
<td>200Hz</td>
</tr>
<tr>
<td>主要输出</td>
<td>Yaw、角速度</td>
</tr>
</tbody></table>
<p>差速底盘的里程计来自左右轮编码器。系统根据左右轮位移计算线位移和角位移，给 ICP 提供初始猜测。轮式里程计会因为打滑、地面不平和积分误差逐步漂移，但短时间运动预测很有价值。</p>
<p>ICP 如果从零初值开始匹配，容易收敛慢或落到错误局部最优。里程计和 IMU 预测让 ICP 从一个接近真实运动的位置开始迭代，减少计算量，也提高匹配稳定性。</p>
<h2>ICP 定位：帧到帧点到线匹配</h2>
<p>室内机器人需要持续回答“我在哪里”。编码器可以推算位移，但误差会积累；室内没有 GPS；磁力计在电机和结构件附近也不可靠。因此 NoeticMaze 用激光雷达扫描之间的几何关系修正位姿。</p>
<p>ICP 的直观含义是：当前扫描得到一圈环境轮廓，参考帧保存上一关键帧的环境轮廓。将当前轮廓平移、旋转，直到它和参考轮廓尽量重合。最终平移和旋转量就是机器人相对参考帧的运动。</p>
<p>NoeticMaze 使用帧到帧模式。</p>
<div style="display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:12px; margin:16px 0;">
  <div style="padding:14px; background:#f8fafc; border:1px solid #dbeafe; border-radius:8px;">
    <div style="font-weight:700; color:#1e293b;">ICP 输入</div>
    <div style="font-size:14px; color:#475569; margin-top:6px;">当前 360 度扫描、参考帧点云、编码器和 IMU 给出的初始位姿预测。</div>
  </div>
  <div style="padding:14px; background:#f8fafc; border:1px solid #dcfce7; border-radius:8px;">
    <div style="font-weight:700; color:#1e293b;">ICP 输出</div>
    <div style="font-size:14px; color:#475569; margin-top:6px;">机器人本帧位姿增量，以及用于修正里程计漂移的 map-odom 变换。</div>
  </div>
  <div style="padding:14px; background:#f8fafc; border:1px solid #fef9c3; border-radius:8px;">
    <div style="font-weight:700; color:#1e293b;">建图输入</div>
    <div style="font-size:14px; color:#475569; margin-top:6px;">ICP 位姿和当前雷达命中点，用 Bresenham 标记空闲格和占据格。</div>
  </div>
</div>

<table>
<thead>
<tr>
<th>模式</th>
<th>匹配对象</th>
<th>NoeticMaze 是否使用</th>
</tr>
</thead>
<tbody><tr>
<td>帧到帧</td>
<td>当前扫描 vs 最近参考扫描</td>
<td>是</td>
</tr>
<tr>
<td>帧到地图</td>
<td>当前扫描 vs 已建地图</td>
<td>否</td>
</tr>
<tr>
<td>预存地图定位</td>
<td>当前扫描 vs 预先导入地图</td>
<td>否</td>
</tr>
</tbody></table>
<p>地图由定位结果驱动建出来。ICP 负责估计机器人移动了多少，建图模块用这个位姿把激光扫描写入栅格。</p>
<h3>参考帧维护</h3>
<p>ICP 需要参考帧。系统将第一次有效激光扫描作为初始参考帧，之后根据运动阈值更新。</p>
<ul>
<li>初始时：第一次扫描直接成为参考帧。</li>
<li>正常运行：机器人平移超过 10cm 或旋转超过 5.7 度时，当前帧替换参考帧。</li>
<li>静止时：如果编码器检测位移小于 2mm 且旋转小于 0.14 度，触发 ZUPT 死区，跳过 ICP。</li>
</ul>
<p>参考帧存在全局数组 <code>ref_scan[360]</code> 中，同时预计算每个点附近的表面法向量 <code>ref_normals[360]</code>，供点到线误差计算使用。</p>
<h3>ICP 完整流程</h3>
<p>一帧扫描到来后，定位流程可以拆成以下步骤。</p>
<ol>
<li><p>极坐标转笛卡尔坐标<br>雷达每个角度槽有一个距离值。系统将距离和角度转换为 XY 点，同时处理顺时针角度方向和安装角补偿。</p>
</li>
<li><p>运动畸变补偿<br>一圈扫描跨越一段时间。机器人扫描过程中仍在运动，因此不同角度点处于不同采样时刻。系统用线速度和角速度把每个点补偿到扫描结束时刻。</p>
</li>
<li><p>里程计初始猜测<br>编码器和 IMU 给出本帧位姿预测，作为 ICP 初始估计。</p>
</li>
<li><p>迭代匹配<br>对当前帧每个有效点，按当前估计位姿变换到参考坐标系，在参考帧角度窗口内搜索最近线段，并计算点到线误差。</p>
</li>
<li><p>构造最小二乘问题<br>每个匹配点贡献一行方程，未知量是 <code>dx</code>、<code>dy</code>、<code>dtheta</code>。</p>
</li>
<li><p>求解 3 x 3 线性方程组<br>使用高斯消元、部分主元和岭回归，得到本轮位姿增量。</p>
</li>
<li><p>更新位姿并判断收敛<br>若增量足够小则退出，否则继续迭代，最多迭代 10 次。</p>
</li>
</ol>
<h3>点到线误差的数学形式</h3>
<p>点到线 ICP 每次迭代求解的是最小二乘问题。对第 <code>i</code> 个匹配点：</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><msubsup><mi>p</mi><mi>i</mi><mo mathvariant="normal" lspace="0em" rspace="0em">′</mo></msubsup><mo>=</mo><mi>R</mi><mo stretchy="false">(</mo><mi>θ</mi><mo stretchy="false">)</mo><mo>⋅</mo><msub><mi>p</mi><mi>i</mi></msub><mo>+</mo><mi>t</mi></mrow><annotation encoding="application/x-tex">p&#x27;_i = R(\theta) \cdot p_i + t</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.0489em;vertical-align:-0.247em;"></span><span class="mord"><span class="mord mathnormal">p</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.8019em;"><span style="top:-2.453em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span><span style="top:-3.113em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">′</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.247em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mopen">(</span><span class="mord mathnormal" style="margin-right:0.0278em;">θ</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">⋅</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.7778em;vertical-align:-0.1944em;"></span><span class="mord"><span class="mord mathnormal">p</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.6151em;"></span><span class="mord mathnormal">t</span></span></span></span></span></div>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><msub><mi>e</mi><mi>i</mi></msub><mo>=</mo><mo stretchy="false">(</mo><msub><mtext>ref</mtext><mi>i</mi></msub><mo>−</mo><msubsup><mi>p</mi><mi>i</mi><mo mathvariant="normal" lspace="0em" rspace="0em">′</mo></msubsup><mo stretchy="false">)</mo><mo>⋅</mo><msub><mi>n</mi><mi>i</mi></msub></mrow><annotation encoding="application/x-tex">e_i = (\text{ref}_i - p&#x27;_i) \cdot n_i</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal">e</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mopen">(</span><span class="mord"><span class="mord text"><span class="mord">ref</span></span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:1.0519em;vertical-align:-0.25em;"></span><span class="mord"><span class="mord mathnormal">p</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.8019em;"><span style="top:-2.453em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span><span style="top:-3.113em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">′</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.247em;"><span></span></span></span></span></span></span><span class="mclose">)</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">⋅</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal">n</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span></span></div>
<p>其中：</p>
<ul>
<li><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mi>p</mi><mi>i</mi></msub></mrow><annotation encoding="application/x-tex">p_i</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.625em;vertical-align:-0.1944em;"></span><span class="mord"><span class="mord mathnormal">p</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span></span> 是当前帧点。</li>
<li><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msubsup><mi>p</mi><mi>i</mi><mo mathvariant="normal" lspace="0em" rspace="0em">′</mo></msubsup></mrow><annotation encoding="application/x-tex">p&#x27;_i</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.0106em;vertical-align:-0.2587em;"></span><span class="mord"><span class="mord mathnormal">p</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.7519em;"><span style="top:-2.4413em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span><span style="top:-3.063em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">′</span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2587em;"><span></span></span></span></span></span></span></span></span></span></span> 是当前估计位姿变换后的点。</li>
<li><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mtext>ref</mtext><mi>i</mi></msub></mrow><annotation encoding="application/x-tex">\text{ref}_i</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8444em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord text"><span class="mord">ref</span></span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span></span> 是参考帧中匹配的点。</li>
<li><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mi>n</mi><mi>i</mi></msub></mrow><annotation encoding="application/x-tex">n_i</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal">n</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span></span> 是参考点附近表面的法向量。</li>
<li><span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><msub><mi>e</mi><mi>i</mi></msub></mrow><annotation encoding="application/x-tex">e_i</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal">e</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.3117em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">i</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span></span> 是当前点到参考线的垂直距离。</li>
</ul>
<p>线性化后得到关于 <span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>d</mi><mi>x</mi></mrow><annotation encoding="application/x-tex">dx</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal">d</span><span class="mord mathnormal">x</span></span></span></span></span>、<span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>d</mi><mi>y</mi></mrow><annotation encoding="application/x-tex">dy</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord mathnormal">d</span><span class="mord mathnormal" style="margin-right:0.0359em;">y</span></span></span></span></span>、<span class="math-inline"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>d</mi><mi>θ</mi></mrow><annotation encoding="application/x-tex">d\theta</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal">d</span><span class="mord mathnormal" style="margin-right:0.0278em;">θ</span></span></span></span></span> 的超定方程组。有效匹配对通常有数百个，但未知量只有 3 个。</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mo stretchy="false">(</mo><msup><mi>A</mi><mi>T</mi></msup><mi>A</mi><mo>+</mo><mi>λ</mi><mi>I</mi><mo stretchy="false">)</mo><mtext> </mtext><mi>x</mi><mo>=</mo><msup><mi>A</mi><mi>T</mi></msup><mi>b</mi></mrow><annotation encoding="application/x-tex">(A^T A + \lambda I)\, x = A^T b</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:1.1413em;vertical-align:-0.25em;"></span><span class="mopen">(</span><span class="mord"><span class="mord mathnormal">A</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8913em;"><span style="top:-3.113em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.1389em;">T</span></span></span></span></span></span></span></span><span class="mord mathnormal">A</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">+</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">λ</span><span class="mord mathnormal" style="margin-right:0.0785em;">I</span><span class="mclose">)</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal">x</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.8913em;"></span><span class="mord"><span class="mord mathnormal">A</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8913em;"><span style="top:-3.113em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.1389em;">T</span></span></span></span></span></span></span></span><span class="mord mathnormal">b</span></span></span></span></span></div>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>x</mi><mo>=</mo><mrow><mo fence="true">[</mo><mtable rowspacing="0.16em" columnalign="center" columnspacing="1em"><mtr><mtd><mstyle scriptlevel="0" displaystyle="false"><mrow><mi>d</mi><mi>x</mi></mrow></mstyle></mtd></mtr><mtr><mtd><mstyle scriptlevel="0" displaystyle="false"><mrow><mi>d</mi><mi>y</mi></mrow></mstyle></mtd></mtr><mtr><mtd><mstyle scriptlevel="0" displaystyle="false"><mrow><mi>d</mi><mi>θ</mi></mrow></mstyle></mtd></mtr></mtable><mo fence="true">]</mo></mrow><mo separator="true">,</mo><mspace width="1em"/><mi>λ</mi><mo>=</mo><msup><mn>10</mn><mrow><mo>−</mo><mn>4</mn></mrow></msup></mrow><annotation encoding="application/x-tex">x = \begin{bmatrix} dx \\ dy \\ d\theta \end{bmatrix}, \quad \lambda = 10^{-4}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.4306em;"></span><span class="mord mathnormal">x</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:3.6em;vertical-align:-1.55em;"></span><span class="minner"><span class="mopen"><span class="delimsizing mult"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.05em;"><span style="top:-4.05em;"><span class="pstrut" style="height:5.6em;"></span><span style="width:0.667em;height:3.6em;"><svg xmlns="http://www.w3.org/2000/svg" width="0.667em" height="3.6em" viewBox="0 0 667 3600"><path d="M403 1759 V84 H666 V0 H319 V1759 v0 v1759 v84 h347 v-84
H403z M403 1759 V0 H319 V1759 v0 v1759 v84 h84z"/></svg></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:1.55em;"><span></span></span></span></span></span></span><span class="mord"><span class="mtable"><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.05em;"><span style="top:-4.21em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">d</span><span class="mord mathnormal">x</span></span></span><span style="top:-3.01em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">d</span><span class="mord mathnormal" style="margin-right:0.0359em;">y</span></span></span><span style="top:-1.81em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord mathnormal">d</span><span class="mord mathnormal" style="margin-right:0.0278em;">θ</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:1.55em;"><span></span></span></span></span></span></span></span><span class="mclose"><span class="delimsizing mult"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:2.05em;"><span style="top:-4.05em;"><span class="pstrut" style="height:5.6em;"></span><span style="width:0.667em;height:3.6em;"><svg xmlns="http://www.w3.org/2000/svg" width="0.667em" height="3.6em" viewBox="0 0 667 3600"><path d="M347 1759 V0 H0 V84 H263 V1759 v0 v1759 H0 v84 H347z
M347 1759 V0 H263 V1759 v0 v1759 h84z"/></svg></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:1.55em;"><span></span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mpunct">,</span><span class="mspace" style="margin-right:1em;"></span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal">λ</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:0.8641em;"></span><span class="mord">1</span><span class="mord"><span class="mord">0</span><span class="msupsub"><span class="vlist-t"><span class="vlist-r"><span class="vlist" style="height:0.8641em;"><span style="top:-3.113em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mtight"><span class="mord mtight">−</span><span class="mord mtight">4</span></span></span></span></span></span></span></span></span></span></span></span></span></div>
<p>岭回归项用于防止矩阵病态或接近奇异。</p>
<h3>为什么使用点到线 ICP</h3>
<p>激光雷达扫到的是离散点，但室内墙面、走廊和家具边缘常常形成连续线段。点到点 ICP 只关心离散最近点，当前扫描和参考扫描角度稍有错位时，最近点未必是真正对应点。</p>
<p>点到线 ICP 把相邻参考点形成的局部表面当作线段，计算当前点到表面的垂直距离。对墙面和走廊这类结构，误差方向更稳定。</p>
<p>法向量通过相邻点计算：</p>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><mi>t</mi><mo>=</mo><mtext>point</mtext><mo stretchy="false">[</mo><mtext>next</mtext><mo stretchy="false">]</mo><mo>−</mo><mtext>point</mtext><mo stretchy="false">[</mo><mtext>prev</mtext><mo stretchy="false">]</mo></mrow><annotation encoding="application/x-tex">t = \text{point}[\text{next}] - \text{point}[\text{prev}]</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.6151em;"></span><span class="mord mathnormal">t</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord text"><span class="mord">point</span></span><span class="mopen">[</span><span class="mord text"><span class="mord">next</span></span><span class="mclose">]</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord text"><span class="mord">point</span></span><span class="mopen">[</span><span class="mord text"><span class="mord">prev</span></span><span class="mclose">]</span></span></span></span></span></div>
<div class="math-block"><span class="katex-display"><span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><semantics><mrow><msub><mi>n</mi><mi>x</mi></msub><mo>=</mo><mo>−</mo><mfrac><msub><mi>t</mi><mi>y</mi></msub><mrow><mi mathvariant="normal">∥</mi><mi>t</mi><mi mathvariant="normal">∥</mi></mrow></mfrac><mo separator="true">,</mo><mspace width="1em"/><msub><mi>n</mi><mi>y</mi></msub><mo>=</mo><mfrac><msub><mi>t</mi><mi>x</mi></msub><mrow><mi mathvariant="normal">∥</mi><mi>t</mi><mi mathvariant="normal">∥</mi></mrow></mfrac></mrow><annotation encoding="application/x-tex">n_x = -\frac{t_y}{\|t\|}, \quad n_y = \frac{t_x}{\|t\|}</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.5806em;vertical-align:-0.15em;"></span><span class="mord"><span class="mord mathnormal">n</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">x</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:2.2281em;vertical-align:-0.936em;"></span><span class="mord">−</span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.2921em;"><span style="top:-2.314em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord">∥</span><span class="mord mathnormal">t</span><span class="mord">∥</span></span></span><span style="top:-3.23em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line" style="border-bottom-width:0.04em;"></span></span><span style="top:-3.677em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord"><span class="mord mathnormal">t</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0359em;">y</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2861em;"><span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.936em;"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span><span class="mpunct">,</span><span class="mspace" style="margin-right:1em;"></span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord"><span class="mord mathnormal">n</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight" style="margin-right:0.0359em;">y</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.2861em;"><span></span></span></span></span></span></span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">=</span><span class="mspace" style="margin-right:0.2778em;"></span></span><span class="base"><span class="strut" style="height:2.2281em;vertical-align:-0.936em;"></span><span class="mord"><span class="mopen nulldelimiter"></span><span class="mfrac"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:1.2921em;"><span style="top:-2.314em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord">∥</span><span class="mord mathnormal">t</span><span class="mord">∥</span></span></span><span style="top:-3.23em;"><span class="pstrut" style="height:3em;"></span><span class="frac-line" style="border-bottom-width:0.04em;"></span></span><span style="top:-3.677em;"><span class="pstrut" style="height:3em;"></span><span class="mord"><span class="mord"><span class="mord mathnormal">t</span><span class="msupsub"><span class="vlist-t vlist-t2"><span class="vlist-r"><span class="vlist" style="height:0.1514em;"><span style="top:-2.55em;margin-left:0em;margin-right:0.05em;"><span class="pstrut" style="height:2.7em;"></span><span class="sizing reset-size6 size3 mtight"><span class="mord mathnormal mtight">x</span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.15em;"><span></span></span></span></span></span></span></span></span></span><span class="vlist-s">​</span></span><span class="vlist-r"><span class="vlist" style="height:0.936em;"><span></span></span></span></span></span><span class="mclose nulldelimiter"></span></span></span></span></span></span></div>
<p>环形边界用模运算处理：0 度点的左邻居是 359 度，359 度点的右邻居是 0 度。</p>
<p>法向量计算还包含保护条件：</p>
<ul>
<li>当前点无效则跳过。</li>
<li>邻居点无效则跳过。</li>
<li>邻居点距离太远则跳过，避免墙角或不同物体误连。</li>
<li>切线向量太短则跳过，避免法向方向不稳定。</li>
</ul>
<h3>ICP 参数表</h3>
<table>
<thead>
<tr>
<th>参数</th>
<th>值</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>扫描点数</td>
<td>360</td>
<td>每度 1 个槽位</td>
</tr>
<tr>
<td>最大迭代次数</td>
<td>10</td>
<td>平衡精度和 CPU 开销</td>
</tr>
<tr>
<td>匹配距离阈值</td>
<td>0.20m</td>
<td>超过距离的匹配对丢弃</td>
</tr>
<tr>
<td>搜索窗口</td>
<td>±30 度</td>
<td>在参考帧中搜索候选点</td>
</tr>
<tr>
<td>求解器</td>
<td>高斯消元 + 部分主元 + 岭回归</td>
<td>模拟稳定的最小二乘求解</td>
</tr>
<tr>
<td>关键帧更新</td>
<td>位移 &gt; 10cm 或旋转 &gt; 5.7 度</td>
<td>更新参考帧</td>
</tr>
<tr>
<td>ZUPT 死区</td>
<td>位移 &lt; 2mm 且旋转 &lt; 0.14 度</td>
<td>静止时跳过 ICP</td>
</tr>
<tr>
<td>增量限制</td>
<td>平移 ±0.4m，旋转 ±0.3rad</td>
<td>防止单次迭代发散</td>
</tr>
<tr>
<td>最小匹配数</td>
<td>10</td>
<td>匹配不足时判定失败</td>
</tr>
</tbody></table>
<h3>运动畸变补偿</h3>
<p>激光雷达完成一圈扫描需要时间。机器人在这一段时间内可能前进或转弯，导致早期采集的点和后期采集的点不在同一车体坐标系。</p>
<p>补偿思路是把每个点变换到扫描结束时刻。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> each point i in scan:</span></span>
<span class="line"><span style="color:#D4D4D4">    dt = i * (scan_time / </span><span style="color:#B5CEA8">360</span><span style="color:#D4D4D4">) - scan_time</span></span>
<span class="line"><span style="color:#D4D4D4">    dtheta = omega * dt</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">fabs</span><span style="color:#D4D4D4">(omega) > </span><span style="color:#B5CEA8">1e-4</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#D4D4D4">        dx = (v / omega) * </span><span style="color:#DCDCAA">sin</span><span style="color:#D4D4D4">(dtheta);</span></span>
<span class="line"><span style="color:#D4D4D4">        dy = (v / omega) * (</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4"> - </span><span style="color:#DCDCAA">cos</span><span style="color:#D4D4D4">(dtheta));</span></span>
<span class="line"><span style="color:#D4D4D4">    } </span><span style="color:#C586C0">else</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#D4D4D4">        dx = v * dt;</span></span>
<span class="line"><span style="color:#D4D4D4">        dy = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">    point</span><span style="color:#D4D4D4">[i] = </span><span style="color:#DCDCAA">rotate</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">point</span><span style="color:#D4D4D4">[i], dtheta) + [dx, dy];</span></span></code></pre><p><code>dt</code> 为负值是合理的：早期点距离扫描结束时刻更远，需要补偿更多运动。代码将 <code>scan_time / 360</code> 提到循环外，用乘法替代重复除法，减少 360 次循环中的开销。</p>
<h3>map-odom 变换</h3>
<p>ICP 输出的是相对更可信的地图坐标位姿，里程计输出的是高频但会漂移的 odom 位姿。系统维护 <code>map &lt;- odom</code> 变换，将二者连接起来。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">tf_theta = icp_theta - odom_theta;</span></span>
<span class="line"><span style="color:#D4D4D4">tf_x = icp_x - (</span><span style="color:#DCDCAA">cos</span><span style="color:#D4D4D4">(tf_theta) * odom_x - </span><span style="color:#DCDCAA">sin</span><span style="color:#D4D4D4">(tf_theta) * odom_y);</span></span>
<span class="line"><span style="color:#D4D4D4">tf_y = icp_y - (</span><span style="color:#DCDCAA">sin</span><span style="color:#D4D4D4">(tf_theta) * odom_x + </span><span style="color:#DCDCAA">cos</span><span style="color:#D4D4D4">(tf_theta) * odom_y);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">global_x = tf_x + </span><span style="color:#DCDCAA">cos</span><span style="color:#D4D4D4">(tf_theta) * odom_x - </span><span style="color:#DCDCAA">sin</span><span style="color:#D4D4D4">(tf_theta) * odom_y;</span></span>
<span class="line"><span style="color:#D4D4D4">global_y = tf_y + </span><span style="color:#DCDCAA">sin</span><span style="color:#D4D4D4">(tf_theta) * odom_x + </span><span style="color:#DCDCAA">cos</span><span style="color:#D4D4D4">(tf_theta) * odom_y;</span></span>
<span class="line"><span style="color:#D4D4D4">global_theta = tf_theta + odom_theta;</span></span></code></pre><p>这样 100Hz 运动线程可以使用高频里程计，同时通过 ICP 结果修正长期漂移。</p>
<h2>占据栅格建图</h2>
<p>定位回答“机器人在哪”，建图回答“环境里有什么”。NoeticMaze 使用占据栅格表示局部地图。</p>
<p>SLAM 高精图参数：</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>值</th>
</tr>
</thead>
<tbody><tr>
<td>分辨率</td>
<td>2cm</td>
</tr>
<tr>
<td>尺寸</td>
<td>200 x 200</td>
</tr>
<tr>
<td>覆盖范围</td>
<td>4m x 4m</td>
</tr>
<tr>
<td>存储</td>
<td>2bit/cell，共 10000 字节</td>
</tr>
<tr>
<td>状态</td>
<td>未知、空闲、占据</td>
</tr>
</tbody></table>
<p>每次有激光点命中障碍，系统从机器人所在栅格到命中点栅格做 Bresenham 光线追踪：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> each lidar hit point:</span></span>
<span class="line"><span style="color:#D4D4D4">    ray = </span><span style="color:#DCDCAA">bresenham_line</span><span style="color:#D4D4D4">(robot_pos, hit_point);</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> each cell in ray excluding endpoint:</span></span>
<span class="line"><span style="color:#D4D4D4">        mark cell as FREE;</span></span>
<span class="line"><span style="color:#D4D4D4">    mark endpoint as OCCUPIED;</span></span></code></pre><p>Bresenham 只需要整数加减和比较，很适合 MCU。沿途格子标为空闲，终点格子标为占据，这正是占据栅格建图的核心。</p>
<h3>2bit 打包存储</h3>
<p>每个格子只需要 2 bit 表示 3 种状态：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>00 = 未知</span></span>
<span class="line"><span>01 = 空闲</span></span>
<span class="line"><span>10 = 占据</span></span></code></pre><p>一个字节存 4 个格子：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>bit[7:6] = cell3</span></span>
<span class="line"><span>bit[5:4] = cell2</span></span>
<span class="line"><span>bit[3:2] = cell1</span></span>
<span class="line"><span>bit[1:0] = cell0</span></span></code></pre><p>40000 个格子如果用 1 字节表示需要 40KB；2bit 打包后约 10KB。对 128KB SRAM 来说，这个优化非常关键。</p>
<h3>地图写保护和增量传输</h3>
<p>项目通过 <code>map_core_write.h</code> 的宏定义控制写权限，确保只有 SLAM 线程写地图，降低并发写导致数据损坏的风险。</p>
<p>蓝牙发送地图时只发送变化的格子增量。报告中提到最多记录 512 个 diff，这能显著降低蓝牙带宽压力。</p>
<h2>双地图结构：2cm 建图，10cm 规划</h2>
<p>SLAM 高精图适合建图和显示，但不适合直接跑 A*。200 x 200 网格有 40000 个节点，搜索空间过大。</p>
<div style="display:grid; grid-template-columns:minmax(0,1fr) auto minmax(0,1fr); gap:10px; align-items:center; margin:16px 0; padding:12px; background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px;">
  <div style="padding:14px; background:#ffffff; border:1px solid #dbeafe; border-radius:8px;">
    <div style="font-weight:700; color:#1e293b;">SLAM 高精图</div>
    <div style="font-size:14px; color:#475569; margin-top:6px;">2cm，200 x 200，2bit/cell。用于精细建图、显示和地图增量。</div>
  </div>
  <div style="font-size:22px; color:#64748b; text-align:center;">→</div>
  <div style="padding:14px; background:#ffffff; border:1px solid #dcfce7; border-radius:8px;">
    <div style="font-weight:700; color:#1e293b;">Planner 规划图</div>
    <div style="font-size:14px; color:#475569; margin-top:6px;">10cm，40 x 40，4bit/cell。用于 A*、碰撞检测和重规划。</div>
  </div>
</div>

<p>系统将 2cm 图降采样为 10cm 规划图。</p>
<table>
<thead>
<tr>
<th>地图</th>
<th>分辨率</th>
<th>尺寸</th>
<th>覆盖范围</th>
<th>存储</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td>SLAM 高精图</td>
<td>2cm</td>
<td>200 x 200</td>
<td>4m x 4m</td>
<td>2bit/cell，约 10KB</td>
<td>建图、显示、增量传输</td>
</tr>
<tr>
<td>Planner 规划图</td>
<td>10cm</td>
<td>40 x 40</td>
<td>4m x 4m</td>
<td>4bit/cell，800B x 2</td>
<td>A*、碰撞检测、重规划</td>
</tr>
</tbody></table>
<p>一个 10cm 规划格对应 5 x 5 个 2cm 细格。降采样采用保守规则：</p>
<ul>
<li>25 个细格中只要有一个占据，规划格标为占据。</li>
<li>否则只要有一个空闲，规划格标为空闲。</li>
<li>全部未知则保持未知。</li>
</ul>
<p>这个规则可能牺牲一点通行空间，但能降低漏检障碍的风险。</p>
<h2>A* 路径规划</h2>
<p>PlannerTask 在 10cm、40 x 40 的规划图上运行 A*。使用 8 连通网格，允许上下左右和 4 个对角方向移动。</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>值</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>网格连通</td>
<td>8 连通</td>
<td>正交 + 对角</td>
</tr>
<tr>
<td>正交代价</td>
<td>10</td>
<td>整数缩放，避免浮点</td>
</tr>
<tr>
<td>对角代价</td>
<td>14</td>
<td>约等于 10 x sqrt(2)</td>
</tr>
<tr>
<td>启发函数</td>
<td>Octile 距离</td>
<td>适合 8 连通网格</td>
</tr>
<tr>
<td>目标容差</td>
<td>1.5 格</td>
<td>不要求精确到目标格</td>
</tr>
<tr>
<td>优先队列</td>
<td>最小堆，容量 2500</td>
<td>打包 f 值和索引</td>
</tr>
<tr>
<td>最大路径长度</td>
<td>400 点</td>
<td>限制路径缓存大小</td>
</tr>
</tbody></table>
<p>A* 的目标是在当前代价模型下找可行且代价较低的路径。代价模型中包含障碍膨胀、未知区策略和路径亲和度。</p>
<h3>障碍膨胀</h3>
<p>机器人有物理尺寸，路径不能贴着障碍物走。规划图对障碍周围区域设置多级惩罚。</p>
<table>
<thead>
<tr>
<th>级别</th>
<th>膨胀半径</th>
<th>惩罚值</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td>L1</td>
<td>0.15m</td>
<td>1000</td>
<td>接近物理碰撞边界，A* 极力避免</td>
</tr>
<tr>
<td>L2</td>
<td>0.22m</td>
<td>150</td>
<td>安全缓冲区</td>
</tr>
<tr>
<td>L3</td>
<td>0.28m</td>
<td>30</td>
<td>轻微惩罚，鼓励远离障碍</td>
</tr>
</tbody></table>
<p>物理碰撞半径约 0.10m。膨胀半径让路径在可通行区域中留出安全余量。</p>
<h3>路径亲和度</h3>
<p>重规划时，如果新路径和旧路径完全无关，机器人可能在局部代价变化中来回跳。NoeticMaze 对旧路径附近区域降低代价，让新路径倾向于延续旧路径。</p>
<p>报告中整理的策略是：旧路径附近约 0.3m 半径内代价获得折扣。这样能降低重规划振荡，尤其在未知区域和 U 型结构附近更有用。</p>
<h3>路径平滑</h3>
<p>A* 输出的是栅格折线，直接跟踪会造成频繁转向。系统做两步平滑：</p>
<ol>
<li><p>双向拉线<br>从路径首尾尝试用直线连接更远路径点，用 Bresenham 射线检测是否穿墙。若直线安全，就替代中间折线。</p>
</li>
<li><p>安全 Bezier 圆角<br>在转折处用 Bezier 曲线做圆角。生成圆角时持续做碰撞检测，若碰撞则缩小圆角半径。</p>
</li>
</ol>
<p>平滑后的路径更适合预瞄点跟踪，也能减少差速底盘频繁急转。</p>
<h2>路径跟踪与运动控制</h2>
<p>MotionTask 以 100Hz 运行。它读取全局路径公告板中的路径，选择前方预瞄点，生成线速度和角速度，再换算为左右轮速度。</p>
<p>核心思想如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">lookahead_point = </span><span style="color:#DCDCAA">find_lookahead</span><span style="color:#D4D4D4">(path, robot_pos, L = </span><span style="color:#F44747">0.18m</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">heading_error = </span><span style="color:#DCDCAA">angle_to</span><span style="color:#D4D4D4">(lookahead_point) - robot_yaw;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">yaw_rate_cmd = Kp * heading_error;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">fabs</span><span style="color:#D4D4D4">(heading_error) > </span><span style="color:#F44747">10deg</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#D4D4D4">    linear_speed *= speed_scale;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">fabs</span><span style="color:#D4D4D4">(heading_error) > </span><span style="color:#F44747">60deg</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#D4D4D4">    linear_speed = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">v_left  = linear_speed - yaw_rate_cmd * wheel_track / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">v_right = linear_speed + yaw_rate_cmd * wheel_track / </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">;</span></span></code></pre><p>这套方法使用前方预瞄点，但没有使用经典 Pure Pursuit 的曲率公式。因此更准确的描述是“预瞄点航向控制”。</p>
<p>底层电机控制采用轮速 PID。</p>
<table>
<thead>
<tr>
<th>控制环</th>
<th>参数</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>轮速 PID</td>
<td>Kp=950, Ki=250, Kd=0</td>
<td>使用 CMSIS-DSP <code>arm_pid_f32()</code>，100Hz 更新</td>
</tr>
<tr>
<td>角速度控制</td>
<td>Kp=0.0035, Kd=0.0005</td>
<td>生成角速度修正</td>
</tr>
<tr>
<td>加速度限制</td>
<td>1.60m/s^2</td>
<td>防止电机突然加减速</td>
</tr>
</tbody></table>
<p>到达目标附近后系统减速停车。若启用自动返航，到达目标后会将初始位置 <code>(1.05, 3.15)</code> 设为新目标并规划返航路径。</p>
<h2>动态避障与重规划</h2>
<p>NoeticMaze 的避障机制是持续建图和重规划。系统检测到新障碍后先写入地图，再根据当前地图重新规划路径。</p>
<p>重规划触发条件如下。</p>
<table>
<thead>
<tr>
<th>#</th>
<th>触发条件</th>
<th>阈值</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>目标变化</td>
<td>移动超过 1 个规划格</td>
<td>用户通过蓝牙下发新目标</td>
</tr>
<tr>
<td>2</td>
<td>无有效路径</td>
<td>路径长度为 0</td>
<td>当前没有可跟踪路径</td>
</tr>
<tr>
<td>3</td>
<td>起点漂移</td>
<td>偏离超过 5 个规划格</td>
<td>机器人位置和旧路径起点偏差过大</td>
</tr>
<tr>
<td>4</td>
<td>路径阻断</td>
<td>连续 4 帧以上检测到路径穿障</td>
<td>新障碍挡住当前路径</td>
</tr>
<tr>
<td>5</td>
<td>防抖间隔</td>
<td>两次规划间隔至少 300ms</td>
<td>避免高频重规划振荡</td>
</tr>
</tbody></table>
<p>避障链路如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>激光雷达持续扫描</span></span>
<span class="line"><span>  -> SLAM 更新占据栅格</span></span>
<span class="line"><span>  -> 检查当前路径是否穿过新障碍</span></span>
<span class="line"><span>  -> 触发重规划请求</span></span>
<span class="line"><span>  -> PlannerTask 基于新地图运行 A*</span></span>
<span class="line"><span>  -> MotionTask 切换到新路径</span></span></code></pre><p>这套机制适合静态或缓慢变化的室内环境。快速移动的人或物体可能被写入地图，导致临时虚假障碍或重规划抖动。</p>
<h2>FreeRTOS 任务拓扑</h2>
<p>NoeticMaze 由多个 FreeRTOS 任务协同运行。任务频率和优先级体现了实时系统设计：运动控制优先，规划按需，蓝牙遥测低优先级。</p>
<table>
<thead>
<tr>
<th>任务</th>
<th>优先级</th>
<th>频率</th>
<th>栈大小</th>
<th>职责</th>
</tr>
</thead>
<tbody><tr>
<td><code>MotionTask</code></td>
<td>最高</td>
<td>100Hz</td>
<td>1KB</td>
<td>电机 PID + 路径跟踪</td>
</tr>
<tr>
<td><code>ImuTask</code></td>
<td>高</td>
<td>200Hz</td>
<td>2KB</td>
<td>MPU6500 DMP 读取和 Yaw 解算</td>
</tr>
<tr>
<td><code>LidarRouteTask</code></td>
<td>较高</td>
<td>事件驱动</td>
<td>1KB</td>
<td>DMA 数据解码和 360 度帧组装</td>
</tr>
<tr>
<td><code>AlgorithmBrain</code></td>
<td>中</td>
<td>跟随激光帧</td>
<td>4KB</td>
<td>ICP、建图、降采样、重规划判断</td>
</tr>
<tr>
<td><code>PlannerTask</code></td>
<td>中低</td>
<td>按需</td>
<td>4KB</td>
<td>A* 和路径平滑</td>
</tr>
<tr>
<td><code>TaskPrint</code></td>
<td>低</td>
<td>事件/周期</td>
<td>4KB</td>
<td>蓝牙遥测发送和指令接收</td>
</tr>
<tr>
<td><code>defaultTask</code></td>
<td>低</td>
<td>空闲</td>
<td>0.5KB</td>
<td>默认空闲任务</td>
</tr>
</tbody></table>
<p>任务间通信机制如下。</p>
<table>
<thead>
<tr>
<th>机制</th>
<th>名称</th>
<th>用途</th>
</tr>
</thead>
<tbody><tr>
<td>消息队列</td>
<td><code>LidarQueueHandle</code>，深度 3</td>
<td>激光帧指针传递，零拷贝</td>
</tr>
<tr>
<td>消息队列</td>
<td><code>ReqQueueHandle</code>，深度 3</td>
<td>SLAM 向 Planner 发重规划请求</td>
</tr>
<tr>
<td>消息队列</td>
<td><code>RespQueueHandle</code>，深度 2</td>
<td>当前版本保留创建，规划结果不靠它返回</td>
</tr>
<tr>
<td>事件标志</td>
<td><code>PathEventHandle</code></td>
<td>广播路径更新</td>
</tr>
<tr>
<td>互斥锁</td>
<td><code>MapDataMutexHandle</code></td>
<td>保护地图数据访问</td>
</tr>
<tr>
<td>互斥锁</td>
<td><code>PrintfMutexHandle</code></td>
<td>保护调试输出</td>
</tr>
</tbody></table>
<p>系统数据流可以简化为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>LidarRouteTask</span></span>
<span class="line"><span>  -> LidarQueueHandle</span></span>
<span class="line"><span>  -> AlgorithmBrain</span></span>
<span class="line"><span>     -> ICP 位姿</span></span>
<span class="line"><span>     -> 2cm 栅格更新</span></span>
<span class="line"><span>     -> 10cm 规划图更新</span></span>
<span class="line"><span>     -> ReqQueueHandle</span></span>
<span class="line"><span>  -> PlannerTask</span></span>
<span class="line"><span>     -> A* 路径</span></span>
<span class="line"><span>     -> 平滑路径</span></span>
<span class="line"><span>     -> 全局路径公告板</span></span>
<span class="line"><span>     -> PathEventHandle</span></span>
<span class="line"><span>  -> MotionTask</span></span>
<span class="line"><span>     -> 预瞄点跟踪</span></span>
<span class="line"><span>     -> 左右轮速度</span></span>
<span class="line"><span>     -> PWM</span></span></code></pre><p>这个结构避免了把所有逻辑塞进一个大循环。高频控制不等待 A*，SLAM 不等待蓝牙，上位机遥测也不能阻塞运动控制。</p>
<h2>用户交互流程</h2>
<p>从用户角度看，流程分为 4 个阶段。</p>
<h3>阶段 1：开机初始化</h3>
<ul>
<li>IMU 初始化并等待 DMP 输出稳定。</li>
<li>雷达启动，USART1 DMA 开始接收。</li>
<li>编码器和里程计归零。</li>
<li>初始位置设置在固定地图坐标范围内，例如 <code>(1.05, 3.15)</code>。</li>
<li>FreeRTOS 任务开始调度。</li>
</ul>
<h3>阶段 2：自主探索建图</h3>
<ul>
<li>雷达持续输出 360 度扫描。</li>
<li>ICP 做帧到帧匹配并估计位姿。</li>
<li>Bresenham 光线追踪更新 2cm 占据栅格。</li>
<li>蓝牙上位机接收机器人位姿、地图增量和调试事件。</li>
<li>用户看到实时构建的局部地图。</li>
</ul>
<h3>阶段 3：用户下发目标点</h3>
<p>用户在上位机地图上点击目标位置，上位机通过蓝牙发送目标点。报告中整理的目标点包是 <code>GoalPacket (0x07)</code>，包含 <code>goal_x</code> 和 <code>goal_y</code>。</p>
<p>STM32 通过 USART3 DMA 接收并解析包，调用目标点设置逻辑。SLAM 线程检测到目标变化后，向 PlannerTask 发起规划请求。</p>
<h3>阶段 4：规划和自动行驶</h3>
<ul>
<li>SLAM 将 2cm 地图降采样为 10cm 规划图。</li>
<li>PlannerTask 使用 A* 搜索路径。</li>
<li>路径通过全局公告板发布。</li>
<li>MotionTask 使用预瞄点航向控制跟踪路径。</li>
<li>若路径被新障碍阻断，系统触发重规划。</li>
<li>到达目标后停车，必要时自动返航。</li>
</ul>
<p>这里的用户输入是目标点，系统自行完成定位、建图、路径搜索和轨迹跟踪。</p>
<h2>项目亮点</h2>
<p>NoeticMaze 的亮点集中在工程实现。</p>
<p>第一，完整链路在 MCU 端闭环。激光雷达、IMU、编码器、SLAM、A*、路径跟踪和蓝牙遥测都在 STM32 上运行。</p>
<p>第二，内存设计有明确约束。2bit 地图压缩、静态缓冲池、零拷贝队列、规划图降采样，都是为 128KB SRAM 服务的。</p>
<p>第三，算法选型贴合场景。室内墙面环境适合点到线 ICP，局部地图适合占据栅格，固定小范围导航适合 40 x 40 规划图。</p>
<p>第四，实时调度边界清晰。高频控制和低频规划分开，重计算不会直接压在运动控制周期上。</p>
<p>第五，通用库依赖少。项目没有使用 Cartographer、GMapping 这类 ROS SLAM 库，ICP、地图、规划和调度逻辑都针对 MCU 资源约束自研。</p>
<h2>已知边界</h2>
<p>这套系统的边界同样需要写清楚。</p>
<p>没有回环检测。机器人回到之前到过的位置时，系统不会识别并全局校正地图。长时间运行后，位姿和地图会累积漂移。</p>
<p>地图范围固定为 4m x 4m。它适合迷宫、小房间、教学和竞赛场景，不适合整层楼或大范围导航。</p>
<p>环境假设偏静态。移动的人或物体可能被写入地图，造成临时障碍和重规划抖动。</p>
<p>定位精度依赖硬件标定。轮径、轮距、编码器、IMU 零偏、雷达安装角和底盘机械误差都会影响结果。</p>
<p>A* 在未知区域和 U 型结构附近可能振荡。路径亲和度、最小重规划间隔和路径穿障连续帧判断可以缓解，但不能从根本上消除所有局部规划问题。</p>
<h2>结论</h2>
<p>NoeticMaze 展示了一种典型的嵌入式导航解法：放弃通用框架，把问题限制在明确范围内，并在这个范围内完成闭环。</p>
<p>在 128KB RAM 的 STM32F446RE 上，完整导航系统能跑起来，依赖一组连续的工程取舍：雷达零拷贝、点到线 ICP、2bit 占据栅格、2cm 到 10cm 的双地图、带代价约束的 A*、预瞄点航向控制，以及 FreeRTOS 下的任务隔离。</p>
<p>这也是 NoeticMaze 最值得学习的地方。它是一个约束明确、链路完整、适合学习嵌入式 SLAM 和室内导航的工程样本。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="STM32"/>
    <category term="SLAM"/>
    <category term="激光雷达"/>
    <category term="FreeRTOS"/>
    <category term="A*"/>
  </entry>
  <entry>
    <title>Claude Code在Windows下无法执行ESP-IDF编译的问题分析与绕过方案</title>
    <link href="https://createskyblue.github.io/posts/claude-code-esp-idf-build/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/claude-code-esp-idf-build/</id>
    <published>2026-05-26T00:00:00.000Z</published>
    <updated>2026-05-26T00:00:00.000Z</updated>
    <summary>Claude Code自动编译ESP-IDF</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Claude Code在Windows下无法执行ESP-IDF编译的问题分析与绕过方案</h1>
<h2>问题现象</h2>
<p>在Windows上使用Claude Code执行<code>eim run &quot;idf.py build&quot;</code>编译ESP-IDF项目时，eim完成环境初始化后输出以下警告并终止：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>MSys/Mingw is no longer supported. Please follow the getting started guide</span></span>
<span class="line"><span>of the documentation in order to set up a suitable environment, or continue</span></span>
<span class="line"><span>at your own risk.</span></span></code></pre><p><code>idf.py build</code>从未执行。同样的命令在用户自己的PowerShell终端中可以正常完成编译。</p>
<h2>根因分析</h2>
<p>Claude Code在Windows上的命令执行工具链如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Claude Code → Bash tool → Git Bash (MSYS2/MinGW) → 目标命令</span></span></code></pre><p>Git Bash启动时会设置两个环境变量：</p>
<ul>
<li><code>MSYSTEM=MSYS</code>（或<code>MINGW64</code>等）</li>
<li><code>MSYS2_PATH_TYPE=inherit</code></li>
</ul>
<p>这两个变量会被子进程继承。<code>eim</code>（ESP-IDF的安装管理器）在检测到<code>MSYSTEM</code>变量后，判定当前处于MSys/Mingw环境，拒绝继续执行。ESP-IDF从v5.x起不再支持MSYS2作为构建环境，要求使用原生Windows命令行或PowerShell。</p>
<p>即使通过Claude Code的PowerShell工具或<code>powershell.exe -Command</code>调用，<code>MSYSTEM</code>变量仍然从父进程继承，eim依然拒绝执行。</p>
<h2>绕过方案</h2>
<p>核心思路：绕过eim，直接用<code>pwsh.exe -NoProfile</code>启动干净的PowerShell环境，手动设置ESP-IDF所需的环境变量，然后调用<code>idf.py</code>。</p>
<h3>完整编译命令</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#9CDCFE">MSYS2_PATH_TYPE</span><span style="color:#D4D4D4">= </span><span style="color:#9CDCFE">MSYSTEM</span><span style="color:#D4D4D4">= </span><span style="color:#DCDCAA">pwsh.exe</span><span style="color:#569CD6"> -NoProfile</span><span style="color:#569CD6"> -Command</span><span style="color:#CE9178"> "</span></span>
<span class="line"><span style="color:#D7BA7D">\$</span><span style="color:#CE9178">env:IDF_PATH = 'C:\esp\v6.0.1\esp-idf'</span></span>
<span class="line"><span style="color:#D7BA7D">\$</span><span style="color:#CE9178">env:IDF_TOOLS_PATH = 'C:\Espressif\tools'</span></span>
<span class="line"><span style="color:#D7BA7D">\$</span><span style="color:#CE9178">env:IDF_PYTHON_ENV_PATH = 'C:\Espressif\tools\python\v6.0.1\venv'</span></span>
<span class="line"><span style="color:#D7BA7D">\$</span><span style="color:#CE9178">env:ESP_IDF_VERSION = '6.0.1'</span></span>
<span class="line"><span style="color:#D7BA7D">\$</span><span style="color:#CE9178">env:MSYSTEM = </span><span style="color:#D7BA7D">\$</span><span style="color:#CE9178">null</span></span>
<span class="line"><span style="color:#D7BA7D">\$</span><span style="color:#CE9178">env:MSYS2_PATH_TYPE = </span><span style="color:#D7BA7D">\$</span><span style="color:#CE9178">null</span></span>
<span class="line"><span style="color:#CE9178">&#x26; 'C:\Espressif\tools\python\v6.0.1\venv\Scripts\python.exe' </span><span style="color:#D7BA7D">\</span></span>
<span class="line"><span style="color:#CE9178">  'C:\esp\v6.0.1\esp-idf\tools\idf.py' build"</span></span></code></pre><p>Bash工具调用示例（timeout设为10分钟）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>timeout: 600000</span></span>
<span class="line"><span>command: （上面的完整命令）</span></span></code></pre><h3>关键细节</h3>
<ol>
<li><p><strong>清除MSYS变量</strong>：<code>MSYS2_PATH_TYPE=</code>和<code>MSYSTEM=</code>放在命令最前面，配合pwsh内<code>\$env:MSYSTEM = \$null</code>双重确保清除。</p>
</li>
<li><p><strong><code>ESP_IDF_VERSION</code>必须设置</strong>：<code>idf_component_manager</code>在初始化时调用<code>Version.coerce(os.getenv(&#39;ESP_IDF_VERSION&#39;))</code>，该变量为空会直接抛出<code>TypeError</code>。这是绕过eim后容易遗漏的一个变量。</p>
</li>
<li><p><strong><code>pwsh.exe -NoProfile</code></strong>：跳过用户profile脚本，避免其他工具链的PATH污染。</p>
</li>
<li><p><strong>直接调用Python解释器</strong>：不经过<code>idf.py</code>的bat/ps1包装器，减少一层环境依赖。</p>
</li>
</ol>
<h2>settings.json配置</h2>
<p>在<code>~/.claude/settings.json</code>中添加以下配置，让Claude Code优先使用PowerShell并启用PowerShell工具：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "defaultShell"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"powershell"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "env"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">    "CLAUDE_CODE_USE_POWERSHELL_TOOL"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"1"</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>根据Claude Code官方文档，<code>defaultShell</code>设置为<code>&quot;powershell&quot;</code>后，交互式<code>!</code>命令会走PowerShell。<code>CLAUDE_CODE_USE_POWERSHELL_TOOL=1</code>启用PowerShell工具。但即便如此，Bash工具仍然使用Git Bash，编译ESP-IDF时仍需要上面的绕过方案。</p>
<h2>适配不同IDF版本</h2>
<p>上面的路径基于ESP-IDF v6.0.1。如果版本不同，需要调整以下路径：</p>
<table>
<thead>
<tr>
<th>变量</th>
<th>路径模式</th>
</tr>
</thead>
<tbody><tr>
<td><code>IDF_PATH</code></td>
<td><code>C:\esp\v{版本}\esp-idf</code></td>
</tr>
<tr>
<td><code>IDF_PYTHON_ENV_PATH</code></td>
<td><code>C:\Espressif\tools\python\v{版本}\venv</code></td>
</tr>
<tr>
<td><code>ESP_IDF_VERSION</code></td>
<td><code>{版本}</code></td>
</tr>
</tbody></table>
<p>可通过<code>eim list</code>查看已安装的IDF版本。</p>
<h2>已知非致命警告</h2>
<ul>
<li><code>ESP_ROM_ELF_DIR not defined</code>：只影响gdbinit文件生成，不影响编译和烧录。</li>
<li><code>Running as elevated user</code>：eim的提示性警告，可忽略。</li>
</ul>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="Claude Code"/>
    <category term="ESP-IDF"/>
    <category term="Windows"/>
    <category term="MSYS"/>
  </entry>
  <entry>
    <title>ESP-IDF 离线安装：绕过在线安装工具的正确方式</title>
    <link href="https://createskyblue.github.io/posts/esp-idf-offline-install/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp-idf-offline-install/</id>
    <published>2026-05-26T00:00:00.000Z</published>
    <updated>2026-05-26T00:00:00.000Z</updated>
    <summary>ESP-IDF离线安装</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>ESP-IDF 离线安装：绕过在线安装工具的正确方式</h1>
<p>ESP-IDF 的在线安装工具（ESP-IDF Windows Installer）体验很差：下载速度慢、经常卡在某个步骤不动、网络超时后需要从头再来。对于国内网络环境，这个问题更加严重。</p>
<p>离线安装是更可靠的选择。提前下载好完整的安装包，用 GUI 安装器完成安装，全程不依赖网络。</p>
<h2>在线安装工具的问题</h2>
<p>在线安装工具需要下载多个组件：Python 环境、工具链、ESP-IDF 框架本体、Git 仓库。这些内容加起来好几个 GB，全程在线下载。常见问题：</p>
<ul>
<li>下载速度极慢，经常只有几十 KB/s</li>
<li>中途断网或超时，需要重新开始</li>
<li>安装过程中卡在 &quot;Downloading tools&quot; 或 &quot;Installing Python environment&quot; 步骤</li>
<li>报网络相关错误但无法定位原因</li>
</ul>
<p>这些问题在国内网络环境下几乎是常态。</p>
<h2>离线安装的优势</h2>
<p>离线安装将完整环境打包为一个归档文件，安装过程不需要网络：</p>
<ul>
<li>安装包可以提前用下载工具（IDM、aria2 等）下载，支持断点续传</li>
<li>安装过程纯本地操作，不受网络影响</li>
<li>可以在多台电脑上复用同一个安装包</li>
<li>安装速度快，几分钟内完成</li>
</ul>
<h2>下载离线安装包</h2>
<p>访问乐鑫官方下载页面：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>https://dl.espressif.com/dl/eim/?utm_source=chatgpt.com&#x26;tab=offline</span></span></code></pre><p>在页面中选择 <strong>离线安装程序（Offline Installer）</strong>。</p>
<p>点击&quot;下载&quot;按钮后，浏览器会弹窗提示&quot;是否允许下载多个文件&quot;——<strong>点击&quot;允许&quot;</strong>。浏览器会分别下载以下三个文件：</p>
<table>
<thead>
<tr>
<th>文件</th>
<th>说明</th>
<th>大小</th>
</tr>
</thead>
<tbody><tr>
<td><code>eim-windows-x64.exe</code></td>
<td>离线安装器（GUI）</td>
<td>几十 MB</td>
</tr>
<tr>
<td><code>eim-cli-windows-x64.exe</code></td>
<td>命令行安装工具</td>
<td>几十 MB</td>
</tr>
<tr>
<td><code>archive_v6.0.1_windows-x64.zst</code></td>
<td>离线安装包（核心）</td>
<td><strong>几个 GB</strong></td>
</tr>
</tbody></table>
<p>三个文件都下载完成后，把三个文件放在同一个目录下。</p>
<blockquote>
<p>注意：离线安装包的版本号（如 v6.0.1）会随时间更新，下载时以页面实际提供的版本为准。</p>
</blockquote>
<h2>执行安装</h2>
<p><strong>强烈建议使用 GUI 安装器（<code>eim-windows-x64.exe</code>）进行安装。</strong></p>
<p>双击运行 <code>eim-windows-x64.exe</code>，在安装界面中选择本地离线归档文件（<code>.zst</code>）。安装界面中会出现一个 <strong>MCP（Model Context Protocol）</strong> 勾选框——<strong>务必勾选它</strong>（原因见下方专章）。</p>
<p>安装过程会自动完成以下操作：</p>
<ol>
<li>解压离线归档文件</li>
<li>配置 Python 虚拟环境</li>
<li>安装工具链（xtensa-esp-elf 等交叉编译器）</li>
<li>安装 ESP-IDF 框架到默认路径</li>
<li>安装 esp-eim-mcp 服务器（勾选 MCP 后）</li>
</ol>
<p>如果坚持使用命令行方式，运行：</p>
<pre><code>.\eim-cli-windows-x64.exe install --use-local-archive .\archive_v6.0.1_windows-x64.zst</code></pre><p>命令行方式没有 MCP 勾选选项，不推荐。</p>
<h2>⚠️ 首次安装必须勾选 MCP，否则只能删目录重装</h2>
<p>ESP-IDF v6.0.0 及以上版本自带 <code>esp-eim-mcp</code>，这是一个 MCP（Model Context Protocol）服务器，可以让 AI 编程工具（如 Claude Code、Cursor 等）直接操作 ESP-IDF 项目。</p>
<p>这个功能<strong>只能在首次安装时勾选</strong>，安装完成后无法单独补装。坑点在于：</p>
<ul>
<li><strong>离线安装器重装</strong>：再次运行离线安装器时，它检测到已有安装，直接跳过配置步骤，没有机会重新勾选 MCP</li>
<li><strong>在线安装器重装</strong>：在线安装器检测到已安装的 ESP-IDF 后拒绝运行，你必须手动删除整个 <code>C:\Espressif</code> 目录，才能重新走一遍安装流程</li>
</ul>
<p>也就是说，首次安装忘勾 MCP → 删掉整个 ESP 目录 → 重新安装几个 GB → 才能补上这个勾选。非常睿智。</p>
<p><strong>建议：首次安装时仔细检查 MCP 勾选框，确认已勾选再点安装。</strong></p>
<h2>MCP 在 Claude Code 中的使用问题</h2>
<p>ESP-IDF v6.0+ 自带的 <code>esp-eim-mcp</code> 在终端中可以正常使用，但在 Claude Code 中会出现执行超时的问题，具体原因待排查。替代方案将在另一篇博客中详细说明。</p>
<h2>验证安装</h2>
<p>安装完成后，运行 ESP-IDF 提供的初始化脚本来验证：</p>
<pre><code># 进入 ESP-IDF 安装目录，执行 export 脚本
cd C:\Espressif\frameworks\esp-idf-v6.0.1
.\export.ps1</code></pre><p>然后尝试查看版本：</p>
<pre><code>idf.py --version</code></pre><p>如果正确输出版本号，说明安装成功。</p>
<h2>常见问题</h2>
<h3>浏览器没有弹窗提示下载多个文件</h3>
<p>部分浏览器默认阻止多文件下载。检查浏览器地址栏是否有被拦截的提示，手动允许即可。Chrome 通常在地址栏右侧显示拦截图标。</p>
<h3>下载速度慢</h3>
<p>离线安装包有几个 GB，建议使用下载工具。直接用浏览器下载也可以，但不要用浏览器自带的&quot;另存为&quot;中断后重下——用支持断点续传的工具更稳妥。</p>
<h3>安装命令报错找不到归档文件</h3>
<p>确认 <code>eim-cli-windows-x64.exe</code> 和 <code>.zst</code> 归档文件在同一个目录下。或者使用绝对路径指定归档文件位置：</p>
<pre><code>.\eim-cli-windows-x64.exe install --use-local-archive "D:\downloads\archive_v6.0.1_windows-x64.zst"</code></pre><h3>安装完成后找不到 idf.py</h3>
<p>ESP-IDF 的工具链需要通过 <code>export.ps1</code> 脚本激活环境变量。每次打开新的终端窗口都需要先执行一次 <code>export.ps1</code>，或者在 PowerShell profile 中添加自动执行。</p>
<h3>忘记勾选 MCP 想补装</h3>
<p>没有补装途径。必须删除 <code>C:\Espressif</code> 整个目录，然后重新运行安装器，在安装界面中勾选 MCP。重新安装需要再次解压几个 GB 的离线包。</p>
<h3>命令行安装和 GUI 安装的区别</h3>
<p>GUI 安装器（<code>eim-windows-x64.exe</code>）有 MCP 勾选框，推荐优先使用。命令行安装器（<code>eim-cli-windows-x64.exe</code>）没有 MCP 选项，安装后无法补装。</p>
<h2>总结</h2>
<p>ESP-IDF 在线安装工具在国内网络环境下问题多，离线安装是更可靠的方式。整个流程：下载三个文件 → 放到同一目录 → 用 GUI 安装器安装 → 记得勾选 MCP。</p>
<p>几个关键提醒：</p>
<ul>
<li>用 GUI 安装器，不要用命令行安装器</li>
<li>首次安装时勾选 MCP，否则只能删目录重装</li>
<li><code>esp-eim-mcp</code> 在 Claude Code 中存在超时问题，替代方案见后续博客</li>
</ul>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="ESP-IDF"/>
    <category term="ESP32"/>
    <category term="离线安装"/>
    <category term="嵌入式开发"/>
  </entry>
  <entry>
    <title>ESP32-S3 Web 上传 SD 卡慢：1KB 小块写入瓶颈与 PSRAM 大块缓冲解决</title>
    <link href="https://createskyblue.github.io/posts/esp32s3-web-upload-sd-psram-buffer/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/esp32s3-web-upload-sd-psram-buffer/</id>
    <published>2026-05-26T00:00:00.000Z</published>
    <updated>2026-05-26T00:00:00.000Z</updated>
    <summary>PSRAM优化ESP32在线SD卡文件管理器速度慢</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>ESP32-S3 Web 文件上传 SD 卡慢：1KB 小块的命令开销与 64KB PSRAM 缓冲解决</h1>
<h2>背景</h2>
<p>项目是一个心率数据记录器，使用 ESP32-S3 通过 SDMMC 接口将 ADC 采集数据写入 SD 卡。设备需要脱离电脑独立运行，因此在固件里实现了一个 Web 文件管理器，用于在浏览器中临时收发文件：浏览目录、下载记录数据、上传配置、删除旧数据。</p>
<p><img src="/posts/esp32s3-web-upload-sd-psram-buffer/PixPin_2026-05-26_22-30-21.webp" alt="ESP32-S3 Web 文件浏览器界面"></p>
<p>文件管理器通过统一 API <code>POST /api/fs</code> 分发所有操作。上传走流式协议：浏览器发 <code>application/octet-stream</code> 请求，第一行是 JSON 元数据（包含 <code>action</code>、<code>fs</code>、<code>path</code>、<code>size</code>），以 <code>\n</code> 分隔，后续是原始文件字节。服务端从 HTTP socket 逐块接收数据，<code>fwrite</code> 到临时文件，完成后 <code>rename</code> 到目标路径。</p>
<p>浏览器端用 XHR 的 <code>upload.onprogress</code> 报告上传进度。</p>
<h2>问题</h2>
<p>上传 64KB 文件到 SD 卡，耗时约 1 秒。等效速度 ~64 KB/s。</p>
<p>SD 卡标称速率 20 Mbps（2.5 MB/s）。利用率只有 2.5%。上传到内部 LittleFS 存储明显更快。</p>
<h2>瓶颈定位</h2>
<h3>原始代码</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> FILE_MGR_CHUNK_SIZE  </span><span style="color:#B5CEA8">1024u</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#4EC9B0"> esp_err_t</span><span style="color:#DCDCAA"> stream_upload_body</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">httpd_req_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">req</span><span style="color:#D4D4D4">, FILE *</span><span style="color:#9CDCFE">file</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                    size_t</span><span style="color:#9CDCFE"> bytes_remaining</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    char</span><span style="color:#9CDCFE"> buffer</span><span style="color:#D4D4D4">[FILE_MGR_CHUNK_SIZE];</span></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (bytes_remaining > </span><span style="color:#B5CEA8">0u</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">        size_t</span><span style="color:#D4D4D4"> requested = bytes_remaining &#x3C; </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(buffer)</span></span>
<span class="line"><span style="color:#D4D4D4">            ? bytes_remaining : </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(buffer);</span></span>
<span class="line"><span style="color:#569CD6">        int</span><span style="color:#D4D4D4"> received = </span><span style="color:#DCDCAA">recv_retry_timeout</span><span style="color:#D4D4D4">(req, buffer, requested);</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (received &#x3C;= </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4"> ||</span></span>
<span class="line"><span style="color:#DCDCAA">            fwrite</span><span style="color:#D4D4D4">(buffer, </span><span style="color:#B5CEA8">1u</span><span style="color:#D4D4D4">, (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4">)received, file) != (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4">)received) {</span></span>
<span class="line"><span style="color:#C586C0">            return</span><span style="color:#D4D4D4"> ESP_FAIL;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#D4D4D4">        bytes_remaining -= (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4">)received;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> ESP_OK;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>栈上 1KB 缓冲区，每次循环：<code>recv(1KB)</code> + <code>fwrite(1KB→SD)</code>。</p>
<p>64KB 文件循环 64 次。</p>
<h3>单次 SD 写入的开销</h3>
<p>SD 卡每次写入都包含固定的命令开销：SDIO 命令握手、FAT 表读取和更新、NAND 块写入。这些开销与写入数据量无关，1 字节和 512 字节的命令往返时间差距不大。1KB 小块写入将这些固定开销放大了数十倍。</p>
<h3>排除 WiFi</h3>
<p>ESP32-S3 WiFi 的实际吞吐远高于 64 KB/s。瓶颈不在网络层。</p>
<h3>为什么内部存储更快</h3>
<p>内部 LittleFS 走 SPI Flash，单次写入延迟远低于 SD 卡的 SDIO 命令往返。同样的 1KB 循环，LittleFS 每次写入的固定开销小得多，所以总耗时明显更短。</p>
<h3>对比 OTA 上传</h3>
<p>同一项目中的 OTA 固件上传使用 4KB 缓冲区：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> OTA_UPLOAD_BUF_SIZE  </span><span style="color:#B5CEA8">4096u</span></span></code></pre><p>写入次数减少到 1/4，速度明显更快。</p>
<h2>解决：64KB PSRAM 大块缓冲</h2>
<p>ESP32-S3-WROOM-1-N8R8 模块带 8MB 八线 PSRAM。项目中的 SD 数据记录模块已使用 128KB PSRAM 作为数据缓冲队列，剩余可用空间约 7.9MB。</p>
<p>将上传缓冲区从栈上 1KB 改为 PSRAM 上的 64KB 动态分配：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> "esp_heap_caps.h"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> FILE_MGR_UPLOAD_BUF_SIZE </span><span style="color:#B5CEA8">65536u</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#4EC9B0"> esp_err_t</span><span style="color:#DCDCAA"> stream_upload_body</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">httpd_req_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">req</span><span style="color:#D4D4D4">, FILE *</span><span style="color:#9CDCFE">file</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                                    size_t</span><span style="color:#9CDCFE"> bytes_remaining</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    char</span><span style="color:#D4D4D4"> *buffer = </span><span style="color:#DCDCAA">heap_caps_malloc</span><span style="color:#D4D4D4">(FILE_MGR_UPLOAD_BUF_SIZE,</span></span>
<span class="line"><span style="color:#D4D4D4">                                    MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (buffer == </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#D4D4D4">        buffer = </span><span style="color:#DCDCAA">heap_caps_malloc</span><span style="color:#D4D4D4">(FILE_MGR_UPLOAD_BUF_SIZE,</span></span>
<span class="line"><span style="color:#D4D4D4">                                  MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (buffer == </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4"> ESP_ERR_NO_MEM;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#4EC9B0">    esp_err_t</span><span style="color:#D4D4D4"> err = ESP_OK;</span></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (bytes_remaining > </span><span style="color:#B5CEA8">0u</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">        size_t</span><span style="color:#D4D4D4"> requested = bytes_remaining &#x3C; FILE_MGR_UPLOAD_BUF_SIZE</span></span>
<span class="line"><span style="color:#D4D4D4">            ? bytes_remaining : FILE_MGR_UPLOAD_BUF_SIZE;</span></span>
<span class="line"><span style="color:#569CD6">        int</span><span style="color:#D4D4D4"> received = </span><span style="color:#DCDCAA">recv_retry_timeout</span><span style="color:#D4D4D4">(req, buffer, requested);</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (received &#x3C;= </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4"> ||</span></span>
<span class="line"><span style="color:#DCDCAA">            fwrite</span><span style="color:#D4D4D4">(buffer, </span><span style="color:#B5CEA8">1u</span><span style="color:#D4D4D4">, (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4">)received, file) != (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4">)received) {</span></span>
<span class="line"><span style="color:#D4D4D4">            err = ESP_FAIL;</span></span>
<span class="line"><span style="color:#C586C0">            break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"><span style="color:#D4D4D4">        bytes_remaining -= (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4">)received;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#DCDCAA">    free</span><span style="color:#D4D4D4">(buffer);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> err;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><code>MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT</code> 指定从 PSRAM 分配。如果 PSRAM 不可用，回退到内部 RAM。上传完成后立即 <code>free</code>，不长期占用。</p>
<h3>改动前后对比</h3>
<table>
<thead>
<tr>
<th>指标</th>
<th>1KB 栈缓冲</th>
<th>64KB PSRAM 缓冲</th>
</tr>
</thead>
<tbody><tr>
<td>SD 写入次数 / 64KB</td>
<td>64 次</td>
<td>1 次</td>
</tr>
<tr>
<td>内部 RAM 占用</td>
<td>1KB（栈）</td>
<td>0</td>
</tr>
<tr>
<td>PSRAM 占用</td>
<td>0</td>
<td>64KB（临时）</td>
</tr>
</tbody></table>
<p>瓶颈从 SD 卡的小块写入开销转移到 WiFi 吞吐本身。</p>
<h2>结论</h2>
<p>嵌入式 I/O 性能的关键指标不是设备标称速率，而是每次操作的固定开销。SD 卡的每次写入都有不可省略的命令往返，1KB 小块写入将这些开销放大到不可接受的程度。</p>
<p>PSRAM 是 ESP32-S3 上容量充裕但容易被忽略的资源。8MB 外部 RAM 适合存放上传缓冲、采集数据帧等大块临时数据，避免占用紧张的内部 RAM。</p>
<p>性能分析的核心步骤：量化每个环节的耗时，排除非瓶颈层（WiFi），对比同类操作（OTA 上传）确认差距，最后用最小改动验证假设。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="ESP32-S3"/>
    <category term="PSRAM"/>
    <category term="文件上传"/>
    <category term="性能优化"/>
    <category term="SD 卡"/>
  </entry>
  <entry>
    <title>如何让 Claude Code 每轮对话结束播放提示音通知你</title>
    <link href="https://createskyblue.github.io/posts/claude-code-sound-hook/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/claude-code-sound-hook/</id>
    <published>2026-05-22T00:00:00.000Z</published>
    <updated>2026-05-22T00:00:00.000Z</updated>
    <summary>加个声音钩子，Claude完工自动通知你</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>如何让 Claude Code 每轮对话结束播放提示音通知你</h1>
<p>Claude Code 处理长任务时，开发者切到浏览器或终端做别的事，完成后没有声音提示，容易错过回复时机。同样，Claude 向你提问或请求权限审批时如果没注意到，也会拖慢整个流程。通过配置 <code>Stop</code>、<code>Notification</code>、<code>PermissionRequest</code> 三个 Hook，可以让 Claude 在不同场景下自动播放不同提示音。</p>
<h2>Claude Code 的 Hook 机制</h2>
<p>Hook 是 Claude Code 的生命周期钩子系统，在特定事件发生时自动执行命令。与声音通知相关的事件：</p>
<table>
<thead>
<tr>
<th>事件</th>
<th>触发时机</th>
<th>音效建议</th>
</tr>
</thead>
<tbody><tr>
<td><code>Stop</code></td>
<td>Claude 完成一轮回复</td>
<td><code>tada.wav</code> — 任务完成</td>
</tr>
<tr>
<td><code>Notification</code></td>
<td>Claude 发出通知（包括提问）</td>
<td><code>notify.wav</code> — 需要你注意</td>
</tr>
<tr>
<td><code>PermissionRequest</code></td>
<td>Claude 请求权限审批</td>
<td><code>chimes.wav</code> — 需要立即审批</td>
</tr>
</tbody></table>
<p>三个事件用不同音效，听声音就知道该做什么。</p>
<h2>配置方法</h2>
<p>编辑用户级配置文件 <code>~/.claude/settings.json</code>，添加 <code>hooks</code> 字段：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "hooks"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">    "Stop"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">      {</span></span>
<span class="line"><span style="color:#9CDCFE">        "hooks"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">          {</span></span>
<span class="line"><span style="color:#9CDCFE">            "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"command"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "command"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"(New-Object Media.SoundPlayer 'C:</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">Windows</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">Media</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">tada.wav').PlaySync()"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "shell"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"powershell"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "timeout"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">10</span></span>
<span class="line"><span style="color:#D4D4D4">          }</span></span>
<span class="line"><span style="color:#D4D4D4">        ]</span></span>
<span class="line"><span style="color:#D4D4D4">      }</span></span>
<span class="line"><span style="color:#D4D4D4">    ],</span></span>
<span class="line"><span style="color:#9CDCFE">    "Notification"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">      {</span></span>
<span class="line"><span style="color:#9CDCFE">        "hooks"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">          {</span></span>
<span class="line"><span style="color:#9CDCFE">            "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"command"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "command"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"(New-Object Media.SoundPlayer 'C:</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">Windows</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">Media</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">notify.wav').PlaySync()"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "shell"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"powershell"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "timeout"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">10</span></span>
<span class="line"><span style="color:#D4D4D4">          }</span></span>
<span class="line"><span style="color:#D4D4D4">        ]</span></span>
<span class="line"><span style="color:#D4D4D4">      }</span></span>
<span class="line"><span style="color:#D4D4D4">    ],</span></span>
<span class="line"><span style="color:#9CDCFE">    "PermissionRequest"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">      {</span></span>
<span class="line"><span style="color:#9CDCFE">        "hooks"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">          {</span></span>
<span class="line"><span style="color:#9CDCFE">            "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"command"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "command"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"(New-Object Media.SoundPlayer 'C:</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">Windows</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">Media</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">chimes.wav').PlaySync()"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "shell"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"powershell"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "timeout"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">10</span></span>
<span class="line"><span style="color:#D4D4D4">          }</span></span>
<span class="line"><span style="color:#D4D4D4">        ]</span></span>
<span class="line"><span style="color:#D4D4D4">      }</span></span>
<span class="line"><span style="color:#D4D4D4">    ]</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>保存后立即生效，无需重启。</p>
<h2>字段说明</h2>
<table>
<thead>
<tr>
<th>字段</th>
<th>值</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td><code>type</code></td>
<td><code>&quot;command&quot;</code></td>
<td>执行 shell 命令</td>
</tr>
<tr>
<td><code>command</code></td>
<td>PowerShell 播放音频的命令</td>
<td>播放指定 <code>.wav</code> 文件</td>
</tr>
<tr>
<td><code>shell</code></td>
<td><code>&quot;powershell&quot;</code></td>
<td>显式指定 PowerShell，避免被 Git Bash 接管</td>
</tr>
<tr>
<td><code>timeout</code></td>
<td><code>10</code></td>
<td>超时秒数，防止音频卡死阻塞会话</td>
</tr>
</tbody></table>
<p><code>command</code> 中的路径使用 <code>\\</code> 双反斜杠，因为 JSON 字符串中 <code>\</code> 是转义字符。</p>
<h2>换成自定义音效</h2>
<p>Windows 系统自带音效位于 <code>C:\Windows\Media\</code>，可选 <code>tada.wav</code>、<code>notify.wav</code>、<code>chimes.wav</code>、<code>ding.wav</code> 等。</p>
<p>如果想用自定义音效：</p>
<ol>
<li>准备一个 <code>.wav</code> 文件，放到任意路径</li>
<li>将 <code>command</code> 中的路径替换为自定义文件路径：</li>
</ol>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#CE9178">"command"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"(New-Object Media.SoundPlayer 'C:</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">Users</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">你的用户名</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">sounds</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">custom.wav').PlaySync()"</span></span></code></pre><h2>macOS / Linux 方案</h2>
<p>非 Windows 系统需要替换 <code>command</code>，并去掉 <code>shell</code> 字段（默认使用 bash）：</p>
<p>macOS：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"command"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "command"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"afplay /System/Library/Sounds/Glass.aiff"</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>Linux（需安装 <code>paplay</code> 或 <code>aplay</code>）：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"command"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "command"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"paplay /usr/share/sounds/freedesktop/stereo/complete.oga"</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h2>禁用与管理</h2>
<ul>
<li>临时禁用：在 Claude Code 中输入 <code>/hooks</code>，找到对应 Hook 禁用</li>
<li>永久删除：从 <code>settings.json</code> 中移除 <code>hooks</code> 字段</li>
<li>音频太吵：将 <code>timeout</code> 设为 <code>1</code>，超时后自动中断播放</li>
</ul>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="Claude Code"/>
    <category term="Hook"/>
    <category term="开发效率"/>
    <category term="Windows"/>
  </entry>
  <entry>
    <title>使用 Web Audio API 实现浏览器插件的成功提示音效</title>
    <link href="https://createskyblue.github.io/posts/web-audio-success-sound/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/web-audio-success-sound/</id>
    <published>2026-05-21T00:00:00.000Z</published>
    <updated>2026-05-21T00:00:00.000Z</updated>
    <summary>Web Audio合成清脆的电子提示音</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>使用 Web Audio API 实现浏览器插件的成功提示音效</h1>
<p>NousSave 是一款 AI 对话管理浏览器插件，支持 ChatGPT、Claude、DeepSeek、Gemini 等平台的对话导出和快捷操作。插件在完成导出、复制等操作后会播放一段简短的成功提示音，实现方式是 Web Audio API 实时合成，无外部音频文件依赖。</p>
<h2>核心实现</h2>
<p>音效通过 <code>AudioContext</code> + <code>OscillatorNode</code> + <code>GainNode</code> 三件套实现：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">function</span><span style="color:#DCDCAA"> playSuccessSoundEffect</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">options</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">options</span><span style="color:#D4D4D4">?.</span><span style="color:#9CDCFE">muted</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> AudioCtx</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">window</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">AudioContext</span><span style="color:#D4D4D4"> || </span><span style="color:#9CDCFE">window</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">webkitAudioContext</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (!</span><span style="color:#9CDCFE">AudioCtx</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">return</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> ctx</span><span style="color:#D4D4D4"> = </span><span style="color:#569CD6">new</span><span style="color:#DCDCAA"> AudioCtx</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">  if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">state</span><span style="color:#D4D4D4"> === </span><span style="color:#CE9178">'suspended'</span><span style="color:#D4D4D4">) </span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">resume</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> frequency</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">Number</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">options</span><span style="color:#D4D4D4">?.</span><span style="color:#9CDCFE">frequency</span><span style="color:#D4D4D4">) || </span><span style="color:#B5CEA8">1200</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> volume</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">Number</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">options</span><span style="color:#D4D4D4">?.</span><span style="color:#9CDCFE">volume</span><span style="color:#D4D4D4">) || </span><span style="color:#B5CEA8">0.08</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> durationSec</span><span style="color:#D4D4D4"> = (</span><span style="color:#DCDCAA">Number</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">options</span><span style="color:#D4D4D4">?.</span><span style="color:#9CDCFE">durationMs</span><span style="color:#D4D4D4">) || </span><span style="color:#B5CEA8">300</span><span style="color:#D4D4D4">) / </span><span style="color:#B5CEA8">1000</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> type</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">options</span><span style="color:#D4D4D4">?.</span><span style="color:#9CDCFE">type</span><span style="color:#D4D4D4"> || </span><span style="color:#CE9178">'square'</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> oscillator</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">createOscillator</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#569CD6">  const</span><span style="color:#4FC1FF"> gainNode</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">createGain</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">  oscillator</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">connect</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">gainNode</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#9CDCFE">  gainNode</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">connect</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">destination</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">  oscillator</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">frequency</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">value</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">frequency</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">  oscillator</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">type</span><span style="color:#D4D4D4"> = </span><span style="color:#9CDCFE">type</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">  gainNode</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">gain</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">setValueAtTime</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">volume</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">currentTime</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#9CDCFE">  gainNode</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">gain</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">exponentialRampToValueAtTime</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#9CDCFE">    Math</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">max</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0.001</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">volume</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">0.125</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#9CDCFE">    ctx</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">currentTime</span><span style="color:#D4D4D4"> + </span><span style="color:#9CDCFE">durationSec</span></span>
<span class="line"><span style="color:#D4D4D4">  );</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">  oscillator</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">onended</span><span style="color:#D4D4D4"> = () </span><span style="color:#569CD6">=></span><span style="color:#9CDCFE"> ctx</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">close</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#9CDCFE">  oscillator</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">start</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">currentTime</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#9CDCFE">  oscillator</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">stop</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">ctx</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">currentTime</span><span style="color:#D4D4D4"> + </span><span style="color:#9CDCFE">durationSec</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>调用方式：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">playSuccessSoundEffect</span><span style="color:#D4D4D4">({</span></span>
<span class="line"><span style="color:#9CDCFE">  frequency:</span><span style="color:#B5CEA8"> 1200</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  volume:</span><span style="color:#B5CEA8"> 0.08</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  durationMs:</span><span style="color:#B5CEA8"> 300</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  type:</span><span style="color:#CE9178"> 'square'</span></span>
<span class="line"><span style="color:#D4D4D4">});</span></span></code></pre><h2>参数解析</h2>
<table>
<thead>
<tr>
<th>参数</th>
<th>默认值</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td><code>frequency</code></td>
<td>1200 Hz</td>
<td>音调频率</td>
</tr>
<tr>
<td><code>volume</code></td>
<td>0.08</td>
<td>响度（0~1）</td>
</tr>
<tr>
<td><code>durationMs</code></td>
<td>300 ms</td>
<td>持续时长</td>
</tr>
<tr>
<td><code>type</code></td>
<td><code>square</code></td>
<td>波形类型</td>
</tr>
</tbody></table>
<h3>frequency = 1200 Hz</h3>
<p>人耳对 1 kHz ~ 4 kHz 频段最敏感（等响曲线的谷底区域）。1200 Hz 踩在这个区间的起点，听感清脆但不刺耳。低于 800 Hz 会发闷，高于 2000 Hz 容易变成噪音。</p>
<h3>type = &#39;square&#39;</h3>
<p>方波由基频和无穷多个奇次谐波（3f、5f、7f...）叠加而成。相比正弦波的&quot;纯净&quot;，方波带有天然的泛音结构，听感上是经典的 8-bit 电子音色。Web Audio API 提供四种波形：</p>
<ul>
<li><code>sine</code> — 纯音，无谐波，像医用检测音</li>
<li><code>square</code> — 奇次谐波丰富，复古电子感</li>
<li><code>sawtooth</code> — 所有谐波都有，偏尖锐</li>
<li><code>triangle</code> — 奇次谐波但衰减快，比方波柔和</li>
</ul>
<p>方波在这个场景下的优势：辨识度高、存在感强、但不吵。</p>
<h3>volume = 0.08</h3>
<p>提示音的职责是&quot;告知状态&quot;，不是&quot;引起注意&quot;。0.08 的音量配合 300ms 的时长，刚好在潜意识层面完成信息传递，不会打断用户当前操作。</p>
<h3>durationMs = 300 ms</h3>
<p>人类对短促声音的感知阈值约 50ms，300ms 足够形成完整的&quot;音节感&quot;。超过 500ms 就从&quot;提示&quot;变成了&quot;通知&quot;，性质不同。</p>
<h3>指数衰减</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#9CDCFE">gainNode</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">gain</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">exponentialRampToValueAtTime</span><span style="color:#D4D4D4">(</span></span>
<span class="line"><span style="color:#9CDCFE">  Math</span><span style="color:#D4D4D4">.</span><span style="color:#DCDCAA">max</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">0.001</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">volume</span><span style="color:#D4D4D4"> * </span><span style="color:#B5CEA8">0.125</span><span style="color:#D4D4D4">),</span></span>
<span class="line"><span style="color:#9CDCFE">  ctx</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">currentTime</span><span style="color:#D4D4D4"> + </span><span style="color:#9CDCFE">durationSec</span></span>
<span class="line"><span style="color:#D4D4D4">);</span></span></code></pre><p>音量从 0.08 线性起始，在 300ms 内指数衰减到 0.01（0.08 × 0.125）。<code>exponentialRampToValueAtTime</code> 模拟自然界声音的衰减曲线——声波能量按指数规律消散，所以听感自然，没有硬切的突兀感。</p>
<p><code>Math.max(0.001, ...)</code> 是因为 <code>exponentialRampToValueAtTime</code> 的目标值不能为 0，否则会抛异常。</p>
<h2>信号链路</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>OscillatorNode (1200Hz square)</span></span>
<span class="line"><span>       │</span></span>
<span class="line"><span>       ▼</span></span>
<span class="line"><span>  GainNode (0.08 → 0.01 exponential)</span></span>
<span class="line"><span>       │</span></span>
<span class="line"><span>       ▼</span></span>
<span class="line"><span>  AudioContext.destination (扬声器)</span></span></code></pre><p>OscillatorNode 产生原始波形，GainNode 控制音量包络，destination 是最终输出。整条链路在 <code>oscillator.stop()</code> 后通过 <code>onended</code> 回调关闭 AudioContext，释放系统资源。</p>
<h2>复用</h2>
<p>将上面的 <code>playSuccessSoundEffect</code> 函数复制到项目中即可使用。可选参数全部有默认值，零配置调用：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">playSuccessSoundEffect</span><span style="color:#D4D4D4">(); </span><span style="color:#6A9955">// 使用全部默认参数</span></span></code></pre><p>需要不同音效时传入参数覆盖：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// 低沉的提示音</span></span>
<span class="line"><span style="color:#DCDCAA">playSuccessSoundEffect</span><span style="color:#D4D4D4">({ </span><span style="color:#9CDCFE">frequency:</span><span style="color:#B5CEA8"> 600</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">type:</span><span style="color:#CE9178"> 'triangle'</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">durationMs:</span><span style="color:#B5CEA8"> 200</span><span style="color:#D4D4D4"> });</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">// 清脆的确认音</span></span>
<span class="line"><span style="color:#DCDCAA">playSuccessSoundEffect</span><span style="color:#D4D4D4">({ </span><span style="color:#9CDCFE">frequency:</span><span style="color:#B5CEA8"> 1800</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">type:</span><span style="color:#CE9178"> 'sine'</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">durationMs:</span><span style="color:#B5CEA8"> 150</span><span style="color:#D4D4D4"> });</span></span></code></pre><h2>注意事项</h2>
<ul>
<li>浏览器要求用户交互后才能创建 AudioContext（autoplay policy），首次调用需在点击事件中触发</li>
<li><code>ctx.close()</code> 释放资源，避免长时间运行的页面累积 AudioContext 实例</li>
<li>Safari 使用 <code>webkitAudioContext</code>，已做兼容处理</li>
<li><code>exponentialRampToValueAtTime</code> 的目标值必须大于 0</li>
</ul>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="Web Audio API"/>
    <category term="浏览器插件"/>
    <category term="音效"/>
    <category term="前端"/>
  </entry>
  <entry>
    <title>自制 STM32L496 板移植 Zephyr 要先搭好最小工程</title>
    <link href="https://createskyblue.github.io/posts/zephyr-stm32l496-rtos-skeleton/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/zephyr-stm32l496-rtos-skeleton/</id>
    <published>2026-05-20T00:00:00.000Z</published>
    <updated>2026-05-20T00:00:00.000Z</updated>
    <summary>Zephyr在STM32L496上搭RTOS最小工程</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>自制 STM32L496 板移植 Zephyr 要先搭好最小工程</h1>
<p>这次 demo 的目标很小：在一块自制 STM32L496RGT6 最小系统板上，把 Zephyr 从环境安装、工程创建、设备树适配、编译、烧录、串口验证完整跑通。</p>
<p>完整仓库：</p>
<p><a href="https://github.com/createskyblue/stm32l496-zephyr-rtos-skeleton">https://github.com/createskyblue/stm32l496-zephyr-rtos-skeleton</a></p>
<h2>为什么要做这个 demo</h2>
<p>STM32 裸机点灯只需要 GPIO 初始化和一个循环。Zephyr 的收益出现在工程结构上：硬件信息集中到 devicetree，功能裁剪集中到 Kconfig，应用逻辑拆成线程、消息队列、定时器和工作队列。</p>
<p>这个 demo 保留最小硬件目标，同时加入几个能直接观察的 RTOS 对象：</p>
<ul>
<li>LED 线程：500 ms 周期翻转 PC13。</li>
<li>ADC 线程：周期采样 PA2 / ADC1_IN7。</li>
<li>I2C 扫描：由 <code>k_timer</code> 周期触发，再交给 <code>k_work</code> 执行。</li>
<li>消息队列：LED、ADC、I2C 把事件投递到 <code>k_msgq</code>。</li>
<li>logger 线程：统一从消息队列取事件，通过 UART 打印。</li>
</ul>
<p>运行时结构：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>LED线程 ─┐</span></span>
<span class="line"><span>ADC线程 ─┼─> k_msgq ─> logger线程 ─> USART1</span></span>
<span class="line"><span>I2C工作 ─┘</span></span>
<span class="line"><span>   ↑</span></span>
<span class="line"><span>k_timer</span></span></code></pre><p>这个结构的好处很直接：新增传感器、通信任务或状态机时，可以新增线程或 work item，再把结果投递给统一事件通道，主入口保持简单。</p>
<h2>第一次使用过程</h2>
<p>Windows 原生环境先准备基础工具：</p>
<pre><code>winget install Kitware.CMake Ninja-build.Ninja Python.Python.3.12 Git.Git oss-winget.dtc 7zip.7zip</code></pre><p>Zephyr 工作用独立目录：</p>
<pre><code>mkdir C:\zephyr
cd C:\zephyr
py -3.12 -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install west</code></pre><p>第一次容易直接执行完整 <code>west update</code>。实际体验是下载量和等待时间都很大。这个 demo 只面向 STM32L496 最小 bring-up，最后采用了较小的依赖范围：Zephyr 主仓库、<code>hal_stm32</code>、CMSIS 相关模块，以及 Zephyr SDK 的 <code>arm-zephyr-eabi</code> 和 host tools。</p>
<p>安装完成后需要记住一点：<code>west</code> 在虚拟环境里。普通 PowerShell 直接输入 <code>west</code> 可能找不到命令，需要先激活 venv，或直接使用：</p>
<pre><code>C:\zephyr\.venv\Scripts\west.exe</code></pre><h2>硬件确认</h2>
<p>自制板不能直接套官方 Nucleo 或 Discovery 的 board 文件烧录。先从网表确认最小连接：</p>
<table>
<thead>
<tr>
<th>功能</th>
<th>配置</th>
</tr>
</thead>
<tbody><tr>
<td>MCU</td>
<td>STM32L496RGT6</td>
</tr>
<tr>
<td>LED</td>
<td>PC13，低电平点亮</td>
</tr>
<tr>
<td>UART console</td>
<td>USART1，PA9/PA10，115200 8N1</td>
</tr>
<tr>
<td>ADC</td>
<td>PA2 / ADC1_IN7</td>
</tr>
<tr>
<td>I2C</td>
<td>PB8/PB9</td>
</tr>
<tr>
<td>下载</td>
<td>ST-LINK，SWD</td>
</tr>
<tr>
<td>Flash</td>
<td>1 MB</td>
</tr>
<tr>
<td>RAM</td>
<td>320 KB total</td>
</tr>
</tbody></table>
<p>首次 board 配置使用 HSI + PLL，暂时不依赖外部 HSE/LSE。这样可以减少首次 bring-up 的排查变量。</p>
<h2>设备树怎么写</h2>
<p>自定义 board 放在应用仓库内部：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>boards/arm/my_stm32l496_board/</span></span></code></pre><p>应用 <code>CMakeLists.txt</code> 设置：</p>
<pre><code>set(BOARD_ROOT ${CMAKE_CURRENT_SOURCE_DIR})</code></pre><p>这样仓库移动到其它目录后，自定义 board 仍然能被 Zephyr 找到。</p>
<p>设备树里描述 LED、ADC 和 I2C：</p>
<pre><code>/ {
	chosen {
		zephyr,console = &usart1;
		zephyr,shell-uart = &usart1;
		zephyr,sram = &sram0;
		zephyr,flash = &flash0;
	};

	leds {
		compatible = "gpio-leds";
		user_led: led_0 {
			gpios = <&gpioc 13 GPIO_ACTIVE_LOW>;
		};
	};

	zephyr,user {
		io-channels = <&adc1 7>;
	};

	aliases {
		led0 = &user_led;
	};
};

&usart1 {
	pinctrl-0 = <&usart1_tx_pa9 &usart1_rx_pa10>;
	current-speed = <115200>;
	status = "okay";
};

&adc1 {
	pinctrl-0 = <&adc1_in7_pa2>;
	st,adc-clock-source = "SYNC";
	st,adc-prescaler = <4>;
	status = "okay";
};

&i2c1 {
	pinctrl-0 = <&i2c1_scl_pb8 &i2c1_sda_pb9>;
	clock-frequency = <I2C_BITRATE_STANDARD>;
	status = "okay";
};</code></pre><p><code>prj.conf</code> 打开最小驱动：</p>
<pre><code>CONFIG_GPIO=y
CONFIG_SERIAL=y
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y
CONFIG_ADC=y
CONFIG_I2C=y
CONFIG_THREAD_NAME=y</code></pre><h2>应用代码的最小形状</h2>
<p>应用入口只负责初始化和启动模块：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#DCDCAA">	app_led_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">	app_adc_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">	app_i2c_scan_init</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">	app_logger_start</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">	app_led_start</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">	app_adc_start</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#DCDCAA">	app_i2c_scan_start</span><span style="color:#D4D4D4">();</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">	return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>模块之间不直接打印，统一投递事件：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> app_event {</span></span>
<span class="line"><span style="color:#569CD6">	enum</span><span style="color:#D4D4D4"> app_event_type type;</span></span>
<span class="line"><span style="color:#569CD6">	uint32_t</span><span style="color:#D4D4D4"> source;</span></span>
<span class="line"><span style="color:#569CD6">	int32_t</span><span style="color:#D4D4D4"> value0;</span></span>
<span class="line"><span style="color:#569CD6">	int32_t</span><span style="color:#D4D4D4"> value1;</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">K_MSGQ_DEFINE</span><span style="color:#D4D4D4">(app_msgq, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> app_event), </span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> app_event_publish</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> app_event *</span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#D4D4D4">	(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span><span style="color:#DCDCAA">k_msgq_put</span><span style="color:#D4D4D4">(&#x26;app_msgq, event, K_NO_WAIT);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>LED 线程只处理 LED：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> led_thread</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">arg0</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">arg1</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">arg2</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">	uint32_t</span><span style="color:#D4D4D4"> counter = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">	while</span><span style="color:#D4D4D4"> (</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">		gpio_pin_toggle_dt</span><span style="color:#D4D4D4">(&#x26;led);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">		const</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> app_event event = {</span></span>
<span class="line"><span style="color:#D4D4D4">			.type = APP_EVENT_LED,</span></span>
<span class="line"><span style="color:#D4D4D4">			.value0 = counter++,</span></span>
<span class="line"><span style="color:#D4D4D4">		};</span></span>
<span class="line"><span style="color:#DCDCAA">		app_event_publish</span><span style="color:#D4D4D4">(&#x26;event);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">		k_msleep</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">500</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">	}</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>logger 线程只处理输出：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> logger_thread</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">arg0</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">arg1</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">arg2</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#C586C0">	while</span><span style="color:#D4D4D4"> (</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">		struct</span><span style="color:#D4D4D4"> app_event event;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">		app_event_wait</span><span style="color:#D4D4D4">(&#x26;event);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">		if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">type</span><span style="color:#D4D4D4"> == APP_EVENT_LED) {</span></span>
<span class="line"><span style="color:#DCDCAA">			printk</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"[logger] blink counter = </span><span style="color:#9CDCFE">%u</span><span style="color:#D7BA7D">\r\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">event</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">value0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">		}</span></span>
<span class="line"><span style="color:#D4D4D4">	}</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>I2C 扫描由 timer 触发 work：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> void</span><span style="color:#DCDCAA"> i2c_scan_timer_handler</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> k_timer *</span><span style="color:#9CDCFE">timer</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#D4D4D4">	(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span><span style="color:#DCDCAA">k_work_submit</span><span style="color:#D4D4D4">(&#x26;i2c_scan_work);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>这几段代码能说明 Zephyr 的应用形态：外设驱动负责硬件操作，线程负责周期任务，消息队列负责模块解耦，work queue 负责延后执行较慢操作。</p>
<h2>构建、烧录和串口验证</h2>
<p>构建：</p>
<pre><code>cd C:\zephyr
.\.venv\Scripts\Activate.ps1
$env:ZEPHYR_BASE = "C:\zephyr\zephyrproject\zephyr"

cd C:\zephyr\stm32l496-demo
west build -p always -b my_stm32l496_board .</code></pre><p>烧录：</p>
<pre><code>west flash</code></pre><p>实测 ST-LINK 输出：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Device name : STM32L496xx/STM32L4A6xx</span></span>
<span class="line"><span>Flash size  : 1 MBytes</span></span>
<span class="line"><span>Application is running</span></span></code></pre><p>串口输出在 ST-LINK Virtual COM Port 上验证：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>[logger] blink counter = 237</span></span>
<span class="line"><span>[logger] ADC PA2/ADC1_IN7 raw = 202, 162 mV</span></span>
<span class="line"><span>[logger] I2C scan done, found 0 device(s)</span></span></code></pre><h2>VS Code CMake Tools 踩坑</h2>
<p>直接用 VS Code CMake Tools 配置 Zephyr 工程时，需要显式指定 board：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>-DBOARD=my_stm32l496_board</span></span></code></pre><p>缺少该参数会报：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>BOARD is not being defined</span></span></code></pre><p>CMake 还需要使用 Zephyr venv 里的 Python。否则 Zephyr 脚本可能找不到依赖：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>ModuleNotFoundError: No module named 'jsonschema'</span></span></code></pre><p>可用配置：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "cmake.configureArgs"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#CE9178">    "-DBOARD=my_stm32l496_board"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#CE9178">    "-DZEPHYR_TOOLCHAIN_VARIANT=zephyr"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#CE9178">    "-DPython3_EXECUTABLE=C:/zephyr/.venv/Scripts/python.exe"</span></span>
<span class="line"><span style="color:#D4D4D4">  ],</span></span>
<span class="line"><span style="color:#9CDCFE">  "cmake.buildDirectory"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"${workspaceFolder}/build"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "cmake.generator"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"Ninja"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "cmake.configureEnvironment"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">    "ZEPHYR_BASE"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"C:/zephyr/zephyrproject/zephyr"</span></span>
<span class="line"><span style="color:#D4D4D4">  }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>配置变更后执行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>CMake: Delete Cache and Reconfigure</span></span></code></pre><h2>结论</h2>
<ul>
<li>自制 STM32 板先确认网表，再写自定义 board。</li>
<li>devicetree 负责硬件描述，<code>prj.conf</code> 负责功能裁剪。</li>
<li>最小 RTOS demo 应包含线程、消息队列、定时器和工作队列。</li>
<li>首次 bring-up 按 build、flash、serial 的顺序排查。</li>
<li>VS Code 需要显式配置 board 和 Zephyr Python。</li>
</ul>
<p>完整示例仓库：</p>
<p><a href="https://github.com/createskyblue/stm32l496-zephyr-rtos-skeleton">https://github.com/createskyblue/stm32l496-zephyr-rtos-skeleton</a></p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="Zephyr"/>
    <category term="STM32"/>
    <category term="STM32L496"/>
    <category term="RTOS"/>
    <category term="嵌入式"/>
  </entry>
  <entry>
    <title>瞬态冲击信号更适合用小波查看发生位置</title>
    <link href="https://createskyblue.github.io/posts/fft-vs-wavelet/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/fft-vs-wavelet/</id>
    <published>2026-05-17T00:00:00.000Z</published>
    <updated>2026-05-17T00:00:00.000Z</updated>
    <summary>FFT看频谱，小波看时间变化</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>瞬态冲击信号更适合用小波查看发生位置</h1>
<p><img src="/posts/fft-vs-wavelet/fft-vs-wavelet.webp" alt="FFT 与小波变换对比示意图"></p>
<h2>一句话区分</h2>
<p>FFT 看&quot;整体由哪些频率组成&quot;，小波看&quot;什么时候出现了什么频率/尺度的特征&quot;。</p>
<h2>FFT（快速傅里叶变换）</h2>
<p>把整段信号拆成不同频率的正弦波叠加，回答的问题是：<strong>信号里有哪些频率成分？</strong></p>
<ul>
<li>频率分析直观，计算成熟，工程实现广泛</li>
<li>假设信号是稳态、周期性的，只输出频谱，不保留时间信息</li>
<li>对突变、冲击、短时异常不敏感：一个瞬间脉冲会被&quot;摊平&quot;到全频段</li>
</ul>
<p>典型场景：电源谐波分析、稳态周期振动、音频频谱、通信频偏/频段占用分析。</p>
<h2>小波变换</h2>
<p>用一族可缩放、可平移的小波函数沿时间轴扫描信号，回答的问题是：<strong>什么时候出现了什么频率/尺度的特征？</strong></p>
<ul>
<li>同时给出时间和频率信息，时频图可定位事件</li>
<li>擅长捕捉突变、冲击、毛刺；支持多尺度，能同时看趋势和细节</li>
<li>代价：参数选择多（母小波、尺度、层数），结果比 FFT 难直接解释，计算量更高</li>
</ul>
<p>典型场景：轴承冲击故障检测、电流/振动瞬态异常、变速过程分析、去噪与特征提取。</p>
<h2>选型判据</h2>
<table>
<thead>
<tr>
<th>关注点</th>
<th>选择</th>
</tr>
</thead>
<tbody><tr>
<td>信号是否稳态、只关心频率组成</td>
<td>FFT</td>
</tr>
<tr>
<td>异常是否随时间变化、有无冲击/毛刺</td>
<td>小波变换</td>
</tr>
<tr>
<td>转速恒定的电机谐波</td>
<td>FFT</td>
</tr>
<tr>
<td>启停、变速、冲击故障</td>
<td>小波变换</td>
</tr>
</tbody></table>
<p>工程里的常见组合：先用 FFT 看整体频率结构判断主成分，再用小波在时频面上定位异常事件发生的时刻和持续时间。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="FFT"/>
    <category term="小波变换"/>
    <category term="信号处理"/>
    <category term="振动分析"/>
    <category term="故障诊断"/>
  </entry>
  <entry>
    <title>按键消抖可以用滑动窗口或积分法过滤抖动</title>
    <link href="https://createskyblue.github.io/posts/key-debounce-shift-register/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/key-debounce-shift-register/</id>
    <published>2026-05-17T00:00:00.000Z</published>
    <updated>2026-05-17T00:00:00.000Z</updated>
    <summary>按键消抖 积分阈值 or 寄存器滑动窗口</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>按键消抖可以用滑动窗口或积分法过滤抖动</h1>
<p><img src="/posts/key-debounce-shift-register/principle.webp" alt="32位移位寄存器滑动窗口法原理图"></p>
<p>机械按键在按下和松开瞬间有几毫秒到十几毫秒的触点抖动，直接读 GPIO 会把一次按压识别成多次。</p>
<p>常见软件做法是检测到电平变化时记一个时间戳，挂一个软件定时器，到时再回来检查电平是否仍然稳定。逻辑没问题，但需要维护&quot;上一次电平&quot;、&quot;定时器句柄&quot;、&quot;等待中&quot;等额外状态。</p>
<p>下面这种方法只用一个 <code>uint32_t</code> 变量加一个固定节拍的扫描。</p>
<h2>原理</h2>
<p>每 1ms 采样一次按键电平，左移进 32 位历史寄存器：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">history = (history &#x3C;&#x3C; </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) | sample;</span></span></code></pre><p><code>history</code> 保存最近 32 次（32ms）采样。判定规则：</p>
<ul>
<li><code>history == 0xFFFFFFFF</code>：连续 32 次为 1，稳定按下，<code>state = 1</code>。</li>
<li><code>history == 0x00000000</code>：连续 32 次为 0，稳定松开，<code>state = 0</code>。</li>
<li>其他：窗口里还有抖动位，<code>state</code> 保持不变。</li>
</ul>
<p>抖动只要还落在 32ms 窗口里，<code>history</code> 就不会是全 1 或全 0，状态被冻结。</p>
<h2>实现</h2>
<p>按键低电平有效，1ms 定时器中断驱动扫描：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdint.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint32_t</span><span style="color:#D4D4D4"> key_history = </span><span style="color:#B5CEA8">0xFFFFFFFFu</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4">  key_state   = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> key_scan_1ms</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    uint32_t</span><span style="color:#D4D4D4"> sample = (</span><span style="color:#DCDCAA">read_key_gpio</span><span style="color:#D4D4D4">() == </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) ? </span><span style="color:#B5CEA8">1u</span><span style="color:#D4D4D4"> : </span><span style="color:#B5CEA8">0u</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    key_history = (key_history &#x3C;&#x3C; </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">) | sample;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (key_history == </span><span style="color:#B5CEA8">0xFFFFFFFFu</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#D4D4D4">        key_state = </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    } </span><span style="color:#C586C0">else</span><span style="color:#C586C0"> if</span><span style="color:#D4D4D4"> (key_history == </span><span style="color:#B5CEA8">0x00000000u</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#D4D4D4">        key_state = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>需要&quot;按下瞬间&quot;事件时加一个上次状态：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> key_state_prev = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">uint8_t</span><span style="color:#DCDCAA"> key_pressed_edge</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#D4D4D4"> edge = (key_state_prev == </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) &#x26;&#x26; (key_state == </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">    key_state_prev = key_state;</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> edge;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h2>参数</h2>
<ul>
<li>采样周期 1ms × 窗口 32 位 = 32ms 稳定判定时间。换 <code>uint16_t</code> 得 16ms 窗口。</li>
<li><code>key_history</code> 初值设为 <code>0xFFFFFFFF</code> 对应&quot;松开&quot;，避免上电瞬间被误判成按下。</li>
<li>多按键就把 <code>history</code> 和 <code>state</code> 做成数组，扫描里循环一次。</li>
</ul>
<h2>另一种思路：积分消抖法</h2>
<p>滑动窗口法严格要求 32 次采样完全一致，任何一次反向毛刺都会重置窗口。工程现场如果存在偶发尖刺干扰，按键事件会被无限推迟。</p>
<p>积分消抖法换一种判定方式：用一个计数器累计趋势，类似软件实现的 RC 滤波。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> CNT_MAX  </span><span style="color:#B5CEA8">10</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> cnt = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">static</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> key_state = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> key_scan_1ms</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#D4D4D4"> sample = (</span><span style="color:#DCDCAA">read_key_gpio</span><span style="color:#D4D4D4">() == </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) ? </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4"> : </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (sample) {</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (cnt &#x3C; CNT_MAX) cnt++;</span></span>
<span class="line"><span style="color:#D4D4D4">    } </span><span style="color:#C586C0">else</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (cnt > </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) cnt--;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (cnt == CNT_MAX) {</span></span>
<span class="line"><span style="color:#D4D4D4">        key_state = </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    } </span><span style="color:#C586C0">else</span><span style="color:#C586C0"> if</span><span style="color:#D4D4D4"> (cnt == </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#D4D4D4">        key_state = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><ul>
<li><code>sample = 1</code>：计数器加一，触顶后 <code>state = 1</code>。</li>
<li><code>sample = 0</code>：计数器减一，触底后 <code>state = 0</code>。</li>
<li>中间区域保持原状态，偶发反向毛刺只把计数器拉低一格，不会击穿判定。</li>
</ul>
<p>判定门限可调（<code>CNT_MAX</code>）、抗毛刺、只需要一个计数变量。</p>
<h2>两种方案对比</h2>
<p><img src="/posts/key-debounce-shift-register/compare.webp" alt="32位滑动窗口法与积分消抖法对比图"></p>
<table>
<thead>
<tr>
<th>对比项</th>
<th>32位滑动窗口法</th>
<th>积分消抖法</th>
</tr>
</thead>
<tbody><tr>
<td>判定语义</td>
<td>最近 N 次采样必须完全一致</td>
<td>趋势累积到阈值</td>
</tr>
<tr>
<td>抗偶发毛刺</td>
<td>弱，一次反向采样即重置窗口</td>
<td>强，单次毛刺只让计数回退一格</td>
</tr>
<tr>
<td>阈值调节</td>
<td>只能改窗口位宽，粒度粗</td>
<td><code>CNT_MAX</code> 任意整数，粒度细</td>
</tr>
<tr>
<td>状态信息</td>
<td>保留最近 N 次原始采样序列</td>
<td>只剩一个计数值</td>
</tr>
<tr>
<td>RAM 占用</td>
<td>每键 4 字节（uint32_t）+ 状态</td>
<td>每键 1 字节计数 + 状态</td>
</tr>
</tbody></table>
<h2>本质：都是脉宽判定</h2>
<p>两种方法做的是同一件事 —— 判定输入电平的稳定脉宽是否超过阈值。</p>
<ul>
<li>滑动窗口法：要求脉宽 ≥ N×Ts，且中途不允许反向脉冲，等价于一个零容忍的脉宽阈值判定。</li>
<li>积分消抖法：要求&quot;按下&quot;采样数减&quot;松开&quot;采样数的净累积 ≥ <code>CNT_MAX</code>，等价于带容忍度的脉宽阈值判定，允许窄毛刺嵌在长脉冲里。</li>
</ul>
<p>抖动是窄脉冲，按键事件是宽脉冲，所有软件消抖归根到底都是在时域上设一个宽度门限。差别只在门限&quot;硬不硬&quot;。</p>
<p>结论：</p>
<ul>
<li>默认用积分消抖法。<code>CNT_MAX</code> 可以根据实测抖动调到合适值，抗毛刺，RAM 占用低。</li>
<li>选 32 位滑动窗口法的两种情况：一是需要排查接触不良或硬件抖动，<code>history</code> 的二进制位直接就是采样波形；二是上层要求&quot;必须连续 N 次稳定才认&quot;的严格语义，例如安全相关输入。</li>
</ul>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="按键消抖"/>
    <category term="嵌入式"/>
    <category term="滑动窗口"/>
    <category term="积分消抖"/>
    <category term="单片机"/>
  </entry>
  <entry>
    <title>用栈水位线与结构体哨兵检测内存损坏</title>
    <link href="https://createskyblue.github.io/posts/stack-watermark-and-struct-guards/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/stack-watermark-and-struct-guards/</id>
    <published>2026-05-17T00:00:00.000Z</published>
    <updated>2026-05-17T00:00:00.000Z</updated>
    <summary>栈水位线+结构体哨兵检测内存损坏</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用栈水位线与结构体哨兵检测内存损坏</h1>
<h2>问题</h2>
<p>偶发复位、看门狗、跑飞、字段值莫名变化，调试器接上现场已经丢。背后的根因通常是：</p>
<ul>
<li>栈溢出</li>
<li>数组越界写</li>
<li><code>memcpy</code> / <code>memset</code> 长度算错</li>
<li>野指针 / 错误偏移</li>
<li>协议解析相信对端长度字段</li>
</ul>
<p>在主机和现代芯片上，ASan、MPU、编译器栈保护、KASLR 都能定位这类问题。换到下列环境就用不上：</p>
<ul>
<li>裸机 / 轻量 RTOS</li>
<li>低端单片机（8 位 / 16 位）</li>
<li>内核早期启动、自实现分配器</li>
<li>任何 sanitizer、stack-protector 都开不起来的工程</li>
</ul>
<p>下文给两种低成本检测方法，运行时开销可控，编译器无要求。共同思路：往内存里写一个业务代码不会自然写入的标记字节，事后回来看它有没有被改过。下文统一用 <code>0xA5</code> 与 <code>0x5A</code>，挑值理由见文末。</p>
<h2>一、栈水位线</h2>
<p><img src="/posts/stack-watermark-and-struct-guards/stack-watermark.webp" alt="栈水位线两种实现对比"></p>
<p>要回答的问题是：线上跑这么久，栈历史最深用到过哪。</p>
<h3>反例：周期采样 SP</h3>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> timer_tick_isr</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">    uintptr_t</span><span style="color:#D4D4D4"> sp_now = </span><span style="color:#DCDCAA">read_sp</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (sp_now &#x3C; sp_min) sp_min = sp_now;</span><span style="color:#6A9955">  // 栈向低地址增长</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>采样式测量。两次 tick 之间的瞬时栈峰值采不到。线上偶发复位排查无效。</p>
<h3>正解：上电填 <code>0xA5</code>，按需反扫</h3>
<p>启动早期把栈区全部填为 <code>0xA5</code>。栈字节被压过又弹出后，值不再是 <code>0xA5</code>。任意时刻扫描栈区，第一个非 <code>0xA5</code> 字节的位置就是历史最大栈深度。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">// 以栈向低地址增长为例（Cortex-M、x86 等）</span></span>
<span class="line"><span style="color:#569CD6">extern</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> __stack_base;</span><span style="color:#6A9955">   // 栈区低地址端</span></span>
<span class="line"><span style="color:#569CD6">extern</span><span style="color:#569CD6"> uint8_t</span><span style="color:#D4D4D4"> __stack_top;</span><span style="color:#6A9955">    // 栈区高地址端，SP 初值</span></span>
<span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> STACK_MAGIC </span><span style="color:#B5CEA8">0xA5</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> stack_paint</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">uint8_t</span><span style="color:#D4D4D4"> *p = &#x26;__stack_base; p &#x3C; &#x26;__stack_top; ++p)</span></span>
<span class="line"><span style="color:#D4D4D4">        *p = STACK_MAGIC;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">size_t</span><span style="color:#DCDCAA"> stack_used_bytes</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#D4D4D4"> *p = &#x26;__stack_base;</span></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (p &#x3C; &#x26;__stack_top &#x26;&#x26; *p == STACK_MAGIC) ++p;</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4">)(&#x26;__stack_top - p);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p><code>stack_paint</code> 必须在任何业务函数压栈之前调用，常见放法是 <code>main</code> 入口第一行，或链接脚本指定的启动钩子。栈向高地址增长（8051 等）只需把 <code>stack_used_bytes</code> 的扫描方向反过来。</p>
<h3>局限</h3>
<ul>
<li><code>stack_paint</code> 自身用到的栈在调用结束后无法回填，存在数字节误差</li>
<li>真实业务恰好写入 <code>0xA5</code> 会让水位线被低估对应字节数，对 KB 级判定无影响</li>
<li>要求栈方向已知、栈区物理边界明确（多数 RTOS 任务栈和裸机栈都提供）</li>
<li>属于诊断手段；不阻止溢出，只在溢出后留下痕迹</li>
</ul>
<h2>二、结构体 / 缓冲区哨兵</h2>
<p><img src="/posts/stack-watermark-and-struct-guards/struct-guards.webp" alt="结构体哨兵布局"></p>
<p>要回答的问题是：这块缓冲区 / 这个结构体有没有被别人踩过。</p>
<p>做法是给关键对象前后各加一段哨兵字节，定期检查：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">typedef</span><span style="color:#569CD6"> struct</span><span style="color:#D4D4D4"> {</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#9CDCFE">      guard_head</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">];</span><span style="color:#6A9955">   // A5 A5 A5 A5</span></span>
<span class="line"><span style="color:#4EC9B0">    sensor_pkt_t</span><span style="color:#D4D4D4"> obj;</span></span>
<span class="line"><span style="color:#569CD6">    uint8_t</span><span style="color:#9CDCFE">      guard_tail</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">];</span><span style="color:#6A9955">   // 5A 5A 5A 5A</span></span>
<span class="line"><span style="color:#D4D4D4">} </span><span style="color:#4EC9B0">safe_sensor_pkt_t</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">void</span><span style="color:#DCDCAA"> guard_init</span><span style="color:#D4D4D4">(</span><span style="color:#4EC9B0">safe_sensor_pkt_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">p</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">; ++i) {</span></span>
<span class="line"><span style="color:#9CDCFE">        p</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">guard_head</span><span style="color:#D4D4D4">[i] = </span><span style="color:#B5CEA8">0xA5</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#9CDCFE">        p</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">guard_tail</span><span style="color:#D4D4D4">[i] = </span><span style="color:#B5CEA8">0x5A</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">bool</span><span style="color:#DCDCAA"> guard_check</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#4EC9B0"> safe_sensor_pkt_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">p</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; </span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">; ++i) {</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">p</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">guard_head</span><span style="color:#D4D4D4">[i] != </span><span style="color:#B5CEA8">0xA5</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">return</span><span style="color:#569CD6"> false</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (</span><span style="color:#9CDCFE">p</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">guard_tail</span><span style="color:#D4D4D4">[i] != </span><span style="color:#B5CEA8">0x5A</span><span style="color:#D4D4D4">) </span><span style="color:#C586C0">return</span><span style="color:#569CD6"> false</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#569CD6"> true</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>head 用 <code>0xA5</code>、tail 用 <code>0x5A</code>，破坏发生时立即区分前向越界与后向溢出。</p>
<h3>检查时机</h3>
<ul>
<li>协议帧处理完成后</li>
<li>涉及 <code>memcpy</code> / <code>memset</code> 的关键路径之后</li>
<li>低频任务（100 ms / 1 s）</li>
<li>避免放进高频 ISR</li>
</ul>
<h3>检测覆盖</h3>
<ul>
<li>命中：4 字节内的越界、长度算错、错误偏移、野指针落入哨兵区</li>
<li>漏检：跨越哨兵区的远距离写入；<code>obj</code> 内部字段的合法逻辑错误</li>
<li>哨兵宽度 4 字节是底线，关键对象用 8 字节更稳</li>
</ul>
<h2>三、组合套餐</h2>
<table>
<thead>
<tr>
<th>层级</th>
<th>手段</th>
<th>检查时机</th>
<th>RAM 成本</th>
</tr>
</thead>
<tbody><tr>
<td>栈</td>
<td>上电 paint，按需反扫</td>
<td>故障时 / 低频任务</td>
<td>占用栈区，无额外 RAM</td>
</tr>
<tr>
<td>关键对象</td>
<td>head / tail 哨兵</td>
<td>协议入口、低频任务</td>
<td>每对象 8 B</td>
</tr>
<tr>
<td>Flash 配置</td>
<td>CRC + 魔术头</td>
<td>上电、写入后</td>
<td>数字节</td>
</tr>
<tr>
<td>复位原因</td>
<td>保留 1 字节 + WDT</td>
<td>上电早期读取</td>
<td>1 B</td>
</tr>
</tbody></table>
<p>复位原因码做法：保留 1 字节非初始化 RAM，检测到哨兵破坏时写入原因码并触发软件复位，上电早期通过串口或 LED 输出。代价 1 字节 RAM，能把&quot;看门狗复位&quot;区分成&quot;哨兵 A 被踩&quot;、&quot;栈撞顶&quot;等可定位信号。</p>
<h2>四、注意</h2>
<ul>
<li><code>0xA5 / 0x5A</code> 用作诊断标记，不能当安全机制；保护逻辑仍要靠代码层的长度检查、指针校验、边界判断</li>
<li>哨兵字节自身也可能被覆盖；再对哨兵加 CRC 校验并不提供额外保护</li>
<li>多任务 RTOS 下每个任务栈独立 paint，结果按任务汇报</li>
<li><code>stack_paint</code> 与 <code>guard_init</code> 必须在对应对象生命周期开始时执行；调用顺序错乱会让首次检查直接判定为已被破坏</li>
</ul>
<h2>附：魔法数字选值</h2>
<p><img src="/posts/stack-watermark-and-struct-guards/magic-numbers.webp" alt="魔法数字选值依据"></p>
<p>挑值的三个硬性条件：</p>
<ul>
<li>比特分布均匀，1 与 0 各占 4 位；单 bit 卡死立即改变比例</li>
<li>业务代码极少自然写入，远离 <code>0x00</code> 和 <code>0xFF</code></li>
<li>在 hex dump 中一眼可识别</li>
</ul>
<p>满足条件的常用值：</p>
<table>
<thead>
<tr>
<th>值</th>
<th>二进制</th>
<th>备注</th>
</tr>
</thead>
<tbody><tr>
<td><code>0xA5</code></td>
<td><code>1010 0101</code></td>
<td>高低交错</td>
</tr>
<tr>
<td><code>0x5A</code></td>
<td><code>0101 1010</code></td>
<td>与 <code>0xA5</code> 互补</td>
</tr>
<tr>
<td><code>0xAA</code></td>
<td><code>1010 1010</code></td>
<td>严格交替</td>
</tr>
</tbody></table>
<p><code>0xA5</code> 与 <code>0x5A</code> 互为镜像，用作 head / tail 哨兵能区分越界方向。<code>0x00</code> 与 <code>0xFF</code> 不可用：前者与未初始化 RAM 难以区分，后者与擦除态 Flash 和上电默认值难以区分。<code>0x55</code> 与 <code>0xA5</code> 等价，可作备用。</p>
<h2>总结</h2>
<ul>
<li>栈水位线靠&quot;启动填 <code>0xA5</code> + 反扫&quot;，绕过 ISR 采样盲区</li>
<li>结构体哨兵用 head=<code>0xA5</code>、tail=<code>0x5A</code> 区分越界方向</li>
<li>全部依赖魔法数字在内存里留下痕迹，事后取证</li>
<li>适用于任何缺少 sanitizer / MPU / 栈保护的工程；零编译器依赖，RAM 成本可控</li>
</ul>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
  </entry>
  <entry>
    <title>NanoVNA2 高频扫频乱线 排查 ADF4350 锁定状态</title>
    <link href="https://createskyblue.github.io/posts/NanoVNA2_%E7%BB%B4%E4%BF%AE/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/NanoVNA2_%E7%BB%B4%E4%BF%AE/</id>
    <published>2026-05-10T00:00:00.000Z</published>
    <updated>2026-05-10T00:00:00.000Z</updated>
    <summary>NanoVNA2高频乱跳，发现ADF4350失锁</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>NanoVNA2 高频扫频乱线 排查 ADF4350 锁定状态</h1>
<p>这次维修的起点是一个很具体的需求：给 LoRa 模块做天线阻抗匹配，目标频段是 430MHz 到 500MHz。NanoVNA2 很久没有开机，再次使用时，扫频结果只能正常维持大约 1 秒。开机后前 1 秒曲线正常，随后屏幕开始乱跳，显示大量杂乱线条。</p>
<p>这个故障有两个明显特征：</p>
<ul>
<li>低频段可用，高频段异常。</li>
<li>异常出现在启动后一小段时间内。</li>
</ul>
<p>后续排查借助 GPT 做故障树分析。先从频率范围切入，再根据原理图定位到高频段的频率合成器。</p>
<p><img src="/posts/NanoVNA2_维修/cover.webp" alt="拆开的 NanoVNA2 主板"></p>
<h2>频率范围测试</h2>
<p>第一步是缩小异常条件。通过切换不同扫频范围，可以看到 140MHz 以下基本正常；频率越高于 140MHz，扫频越不稳定，屏幕越容易出现杂乱线条。</p>
<p>这个现象很关键。NanoVNA2 在不同频段使用不同的频率源。结合原理图分析后，140MHz 以下主要由 SI5351 产生频率；超过 140MHz 后，高频部分由 ADF4350 参与产生。</p>
<p><img src="/posts/NanoVNA2_维修/img/NanoVNA_%E5%8E%9F%E7%90%86%E5%9B%BE_v2_2.webp" alt="NanoVNA2 原理图总览"></p>
<p>因此故障范围从整机显示、MCU、校准数据，收敛到高频频率合成链路。430MHz 到 500MHz 正好落在需要 ADF4350 正常工作的范围内。</p>
<h2>锁定状态指示</h2>
<p>拆机后先观察 ADF4350 的锁定状态。通道一的 TX synthesizer 附近有一颗指示灯，位号为 LED601，对应 ADF4350 的 LD 引脚。LD 用于指示 PLL 锁定状态。</p>
<p>实测现象如下：</p>
<ul>
<li>开机后约 1 秒内，LED601 常亮。</li>
<li>约 1 秒后，LED601 开始闪烁。</li>
<li>随后 LED601 熄灭，扫频曲线同时变成杂乱线条。</li>
</ul>
<p>这说明异常和 ADF4350 的 PLL 锁定状态高度相关。扫频还能短暂正常，说明 SPI 配置、电源上电、参考时钟至少在启动初期能工作；后续失锁指向合成器环路、VCO 调谐或芯片状态失效。</p>
<p><img src="/posts/NanoVNA2_维修/img/2041894575.webp" alt="ADF4350 局部放大">
<img src="/posts/NanoVNA2_维修/img/ADF4350%E5%B1%80%E9%83%A8%E5%8E%9F%E7%90%86%E5%9B%BE.webp" alt="ADF4350 局部原理图"></p>
<h2>Vtune 波形对比</h2>
<p>下一步检查 ADF4350 的 Vtune 引脚。Vtune 是 PLL 环路滤波后的 VCO 调谐电压。扫频过程中，它应该随目标频点变化，呈现阶梯状扫描波形。</p>
<p>通道一的 Vtune 实测结果：</p>
<ul>
<li>启动后约 1 秒内，有阶梯状扫描波形。</li>
<li>失锁后，Vtune 变成噪声状波形。</li>
<li>Vtune 乱掉后，屏幕扫频曲线同步乱掉。</li>
</ul>
<p>NanoVNA2 板上有两颗 ADF4350。一颗用于通道一发射链路，另一颗用于通道二相关链路。对通道二做同样测量时，Vtune 一直保持正常阶梯状扫描，LD 指示灯也保持常亮。</p>
<p>这个对比把问题进一步压到通道一 ADF4350 及其外围环路。供电、MCU、参考时钟、整机软件的嫌疑降低，因为另一颗 ADF4350 在同一台机器上持续正常。</p>
<h2>环路滤波器排查</h2>
<p>长期放置后的设备，先怀疑外围无源件是合理路径。Vtune 连接到 ADF4350 的电荷泵环路滤波网络，相关电阻、电容异常可能导致 PLL 无法稳定锁定。</p>
<p>围绕 Vtune 和 CPout 检查并替换了一圈阻容件。重点包括环路滤波电容、串联电阻、下拉电阻，以及靠近 Vtune 的小电容。替换后故障没有恢复，通道一仍然在启动后约 1 秒失锁。</p>
<p>这一步排除了最容易处理的外围失效。剩下的可疑点主要是：</p>
<ul>
<li>通道一 ADF4350 本体异常。</li>
<li>焊点、封装底部或局部连接存在接触问题。</li>
<li>热风返修过程中可能改变了某些隐性接触状态。</li>
</ul>
<p><img src="/posts/NanoVNA2_维修/img/ADF4350%E5%B1%80%E9%83%A8%E5%8E%9FPCB%E5%AE%9E%E6%8B%8D%E5%AF%B9%E7%85%A7.webp" alt="ADF4350 外围 PCB 与原理图位置对照"></p>
<h2>对调两颗 ADF4350</h2>
<p>板上正好有两颗 ADF4350。为了确认故障是否跟随芯片移动，直接使用热风枪把两颗 ADF4350 对调。</p>
<p>这个操作本来预期会得到两个结果之一：</p>
<ul>
<li>如果故障跟随芯片移动，说明原通道一 ADF4350 损坏。</li>
<li>如果故障仍留在原通道一，说明外围电路、焊盘或布局附近存在问题。</li>
</ul>
<p>实际结果更简单：对调后，两路都恢复正常。140MHz 以上扫频恢复稳定，LED 锁定状态正常，目标的 430MHz 到 500MHz 频段也可以继续用于 LoRa 天线匹配。</p>
<p><img src="/posts/NanoVNA2_维修/img/1673303399.webp" alt="通道一 ADF4350 附近实拍"></p>
<h2>结论</h2>
<p>这次故障的有效排查路径是：</p>
<ol>
<li>先用扫频范围确认故障频段，发现 140MHz 以下正常、140MHz 以上异常。</li>
<li>结合原理图确认频率源切换关系，定位到 ADF4350。</li>
<li>观察 LD 指示灯，确认通道一 PLL 会在启动后失锁。</li>
<li>用示波器测 Vtune，对比通道一和通道二的调谐电压。</li>
<li>检查并替换 Vtune/CPout 环路阻容，确认外围滤波网络嫌疑降低。</li>
<li>对调两颗 ADF4350，最终恢复正常。</li>
</ol>
<p>最终原因没有被拆到单点。可能是某颗 ADF4350 的焊接状态、引脚接触、局部污染或热风返修后恢复的隐性连接问题。维修记录的实用结论是：NanoVNA2 在高频段出现开机短暂正常、随后扫频乱线时，可以优先检查 ADF4350 的 LD 状态和 Vtune 波形；如果板上两颗 ADF4350 表现不同，对调芯片是一个直接的验证手段。</p>
<p>能修好就先修好。射频小板有时就是这么朴素。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="NanoVNA2"/>
    <category term="ADF4350"/>
    <category term="SI5351"/>
    <category term="PLL"/>
    <category term="射频维修"/>
    <category term="LoRa"/>
  </entry>
  <entry>
    <title>加速度计波形上传到云端后出现尖峰，设备端逐点检查却无异常</title>
    <link href="https://createskyblue.github.io/posts/stm32l4-hezhou-4g-telemetry-crc32-spike/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/stm32l4-hezhou-4g-telemetry-crc32-spike/</id>
    <published>2026-05-10T00:00:00.000Z</published>
    <updated>2026-05-10T00:00:00.000Z</updated>
    <summary>加速度计遥测数据经 4G 模块上传后出现极端值的排查记录</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>一次遥测波形上报后出现极端尖峰的故障排查记录</h1>
<p>设备使用 STM32L4 系列 MCU，外接合宙 4G 模块上传传感器数据。业务中有一段几百 KB 的遥测数据，供后台前端预览时间序列波形。</p>
<p>故障现象是前端曲线偶发极端尖峰，峰值明显超出传感器量程，破坏整段波形可读性。可能来源有三个：传感器采集异常、MCU 输入缓冲区被改写、遥测上报链路数据损坏。</p>
<h2>数据路径</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>传感器采集 -> 输入缓冲区 -> 边缘 AI 推理 -> 遥测报文打包 -> 合宙 4G 模块 -> 云后台 -> 前端波形预览</span></span></code></pre><p>报文本身带 CRC32，后端收到后计算并记录校验结果。这段波形只用于问题分析和体验改进，所以即使校验失败，后端仍会保存报文并生成报告，损坏数据因此会继续进入前端预览。</p>
<h2>排除采集与内存损坏</h2>
<p>在固件里加两个校验点：边缘 AI 推理前和数据上报后，各对同一份输入缓冲区算一次 CRC32。同时加断言，检查采样值量程、缓冲区长度和写指针。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4"> crc_before = </span><span style="color:#DCDCAA">crc32_calc</span><span style="color:#D4D4D4">(input_buf, input_len);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">for</span><span style="color:#D4D4D4"> (</span><span style="color:#569CD6">size_t</span><span style="color:#D4D4D4"> i = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">; i &#x3C; sample_count; i++) {</span></span>
<span class="line"><span style="color:#DCDCAA">    ASSERT</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">sample</span><span style="color:#D4D4D4">[i] >= SENSOR_MIN);</span></span>
<span class="line"><span style="color:#DCDCAA">    ASSERT</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">sample</span><span style="color:#D4D4D4">[i] &#x3C;= SENSOR_MAX);</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">run_edge_ai_inference</span><span style="color:#D4D4D4">(input_buf, input_len);</span></span>
<span class="line"><span style="color:#DCDCAA">upload_telemetry_packet</span><span style="color:#D4D4D4">(input_buf, input_len);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">uint32_t</span><span style="color:#D4D4D4"> crc_after = </span><span style="color:#DCDCAA">crc32_calc</span><span style="color:#D4D4D4">(input_buf, input_len);</span></span>
<span class="line"><span style="color:#DCDCAA">ASSERT</span><span style="color:#D4D4D4">(crc_before == crc_after);</span></span></code></pre><p>实测两次 CRC32 一致，断言未触发。输入缓冲区在推理前后没有被改写，传感器输出也没有超量程值。范围收敛到报文打包、串口传输、4G 模块发送、云端接收和后端解析。</p>
<h2>后端 CRC32 日志</h2>
<p>检查出现尖峰的报告，对应的遥测上报都存在 CRC32 校验错误。三条证据放在一起：</p>
<ul>
<li>MCU 输入缓冲区 CRC32 正常，断言正常。</li>
<li>后端收到的报文 CRC32 错误。</li>
<li>CRC32 错误的报告更容易出现极端尖峰。</li>
</ul>
<p>错误字节被后端按原格式解析，解释成异常大的采样值，这就是前端尖峰的来源。波形渲染和边缘 AI 推理的嫌疑排除。</p>
<h2>板级差异</h2>
<p>老 PCB 复现，新 PCB 未复现同类 CRC32 错误。两个版本 STM32L4 到 4G 模块的主要连接没有改动，原理图差异在其他边缘线路。</p>
<p>新 PCB 稳定不能证明老 PCB 某个器件坏了，它给出的约束是：固件和后端协议基本可用，问题与旧硬件环境下的大块数据上报强相关。后续方向是硬件版本差异、供电完整性、4G 模块发射时的电源与地噪声。</p>
<h2>结论</h2>
<p>MCU 侧输入缓冲区前后 CRC32 一致、断言未报错，采集和内存安全的嫌疑排除；后端日志显示尖峰报告存在遥测 CRC32 错误，大块数据上报链路确实存在损坏；老 PCB 复现、新 PCB 不复现，把方向指向硬件版本差异与供电完整性。</p>
<p>遥测数据用于排查故障时，允许 CRC32 错误的数据进入报告可以接受，但后端必须记录校验结果、前端必须标记完整性状态。否则损坏点会被画成正常曲线，通信问题伪装成传感器异常。</p>]]></content>
    <category term="STM32L4"/>
    <category term="合宙4G模块"/>
    <category term="CRC32"/>
    <category term="遥测数据"/>
    <category term="边缘AI"/>
    <category term="数据上报"/>
  </entry>
  <entry>
    <title>长录音分类可以先切片再把频谱拼成特征图</title>
    <link href="https://createskyblue.github.io/posts/%E9%95%BF%E5%BA%8F%E5%88%97%E9%9F%B3%E9%A2%91%E5%88%86%E7%B1%BB%E4%BB%BB%E5%8A%A1%E6%80%9D%E8%B7%AF/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/%E9%95%BF%E5%BA%8F%E5%88%97%E9%9F%B3%E9%A2%91%E5%88%86%E7%B1%BB%E4%BB%BB%E5%8A%A1%E6%80%9D%E8%B7%AF/</id>
    <published>2026-05-08T00:00:00.000Z</published>
    <updated>2026-05-08T00:00:00.000Z</updated>
    <summary>长录音先分段提频谱再聚合分类</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>长录音分类可以先切片再把频谱拼成特征图</h1>
<p>长时间声音分类常见于设备状态识别、工业异常检测、环境声音监测和边缘 AI 部署。输入可能是一段几十秒、几分钟甚至持续不断的录音。直接把整段波形送进模型，会带来三个问题：数据量大、有效信息稀疏、模型很难稳定学习长期变化规律。</p>
<p>比较可控的做法是先做特征构建：把长录音切成短时间窗口，每个窗口做 FFT，再对频谱结果做筛选、过滤、合并和降维。每个窗口输出一个紧凑的特征向量，随后按时间顺序堆叠，形成一张二维特征图。模型输入从原始长波形，变成已经压缩过的“时间 x 频率/特征”数据。</p>
<p><img src="/posts/长序列音频分类任务思路/cover.webp" alt="长序列声音分类任务思路"></p>
<p>这套流程的本质，是把长时间声音分类问题转换成二维特征图分类问题。短时间窗口负责提取局部频率结构，时间堆叠负责保留长期变化模式，模型最终学习声音特征随时间变化的规律。</p>
<h2>为什么不能直接处理整段长录音</h2>
<p>声音采样率通常不低。以 16 kHz 单声道为例，1 分钟录音就有 960000 个采样点；如果使用 48 kHz，数据量还会增加 3 倍。对嵌入式设备或边缘网关来说，直接处理这种长度的原始序列会占用大量 RAM、算力和带宽。</p>
<p>长录音里也经常存在大量无效片段。设备可能大部分时间处于稳定运行状态，异常只出现在少数时间点；目标声音可能只持续几百毫秒。整段输入会把模型注意力分散到大量背景信息上，训练和推理都不经济。</p>
<p>模型结构也会受到限制。CNN 更擅长处理局部结构，RNN 和 Transformer 可以建模时序关系，但序列过长后计算和存储开销很快上升。工程上通常需要先把输入压缩成可控长度，再交给模型。</p>
<h2>基本处理流程总览</h2>
<p>完整流程可以拆成六步：</p>
<ol>
<li>连续采集声音，按固定时间窗切成很多短片段。</li>
<li>对每个短片段做 FFT，得到频域能量分布。</li>
<li>对频谱做筛选、过滤、合并和降维，得到紧凑特征向量。</li>
<li>按时间顺序保存每个窗口的特征向量。</li>
<li>把一串特征向量堆叠成二维特征图。</li>
<li>将二维特征图输入分类模型，输出设备状态或声音类别。</li>
</ol>
<p>可以把每个短片段理解为一帧声音数据。单帧提供局部频率信息，多帧堆叠提供时间演化信息。</p>
<h2>第一步：短时间窗分段</h2>
<p>连续录音先进入一个滑动窗口。窗口长度通常按任务特性选择，例如 20 ms、50 ms、100 ms 或 1 s。窗口太短，低频分辨率差；窗口太长，时间定位变粗，还会增加单帧计算量。</p>
<p>工业声音分类里，窗口长度要和目标现象的时间尺度匹配。电机轴承冲击、气阀泄漏、继电器吸合、风机叶片异常，这些声音的持续时间和频率结构不同，窗口设置也不同。</p>
<p>常见参数包括：</p>
<ul>
<li><code>window_size</code>：每帧采样点数量。</li>
<li><code>hop_size</code>：相邻窗口的步进长度。</li>
<li><code>overlap</code>：窗口重叠比例。</li>
<li><code>sample_rate</code>：原始采样率。</li>
</ul>
<p>重叠窗口可以减少边界损失。比如窗口 100 ms、步进 50 ms，就有 50% 重叠。这样目标声音落在窗口边缘时，下一帧仍然可以捕捉到较完整的频率结构。</p>
<h2>第二步：FFT 提取频域信息</h2>
<p>短片段仍然是时域波形。波形能看到振幅随时间变化，但很多设备声音的差异主要体现在频率结构上。FFT 把一帧波形转换到频域，得到不同频率 bin 的幅值或能量。</p>
<p>典型处理链路如下：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>原始采样帧 -> 去直流 -> 加窗 -> FFT -> 幅值谱/功率谱 -> 对数压缩</span></span></code></pre><p>加窗通常使用 Hann、Hamming 等窗函数，用来减轻频谱泄漏。FFT 输出复数结果，工程上常取幅值、功率或对数能量。对数压缩可以降低极大幅值对特征的支配，让弱但稳定的频率成分更容易被模型利用。</p>
<p>FFT 之后，每一帧可以表示为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>[bin_0, bin_1, bin_2, ..., bin_N]</span></span></code></pre><p>这些 bin 对应从低频到高频的频率成分。采样率和 FFT 点数决定频率分辨率。例如 16 kHz 采样、1024 点 FFT，频率间隔约为 15.625 Hz。</p>
<h2>第三步：筛选、过滤、合并、降维</h2>
<p>原始 FFT 频率 bin 数量可能很多。直接保留全部 bin 会增加数据量，也会把噪声和无关频段带入模型。特征构建的关键工作，就是把原始高维频谱压缩成任务相关的特征向量。</p>
<p>筛选负责保留有效频带。比如设备状态识别可能只关心 100 Hz 到 8 kHz，低于 100 Hz 的结构振动、供电纹波或环境低频噪声可以先排除；高于传感器有效带宽的频段也没有必要保留。</p>
<p>过滤负责抑制噪声。可以使用带通滤波、谱减、移动平均、中值滤波、噪声门限或背景谱扣除。对工业场景来说，稳定背景噪声经常存在，过滤步骤可以减少模型对现场环境的依赖。</p>
<p>合并负责降低频率维度。相邻频率 bin 往往高度相关，可以按线性频带、对数频带或任务定义的频段做聚合。聚合方式可以是求和、均值、最大值、能量积分或统计量组合。</p>
<p>降维负责得到固定长度特征。常见做法包括：</p>
<ul>
<li>频带能量：每个频段输出一个能量值。</li>
<li>对数频带能量：对宽动态范围信号更稳定。</li>
<li>MFCC 或类 Mel 特征：适合语音和部分环境声音任务。</li>
<li>频谱质心、带宽、滚降点：描述频谱形状。</li>
<li>PCA 或轻量自编码器：在离线训练后部署压缩映射。</li>
</ul>
<p>处理完成后，每一帧从高维频谱变成一个短向量：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>原始频谱: [bin_0, bin_1, ..., bin_511]</span></span>
<span class="line"><span>压缩特征: [f_0, f_1, ..., f_31]</span></span></code></pre><p>这一层的意义很直接：减少数据量，降低模型负担，去掉和任务无关的频段，提高输入特征的稳定性。</p>
<h2>第四步：沿时间方向堆叠</h2>
<p>每个窗口都会输出一个压缩后的特征向量。录音持续进行时，系统不断产生新的向量：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>t1 -> [f_0, f_1, ..., f_31]</span></span>
<span class="line"><span>t2 -> [f_0, f_1, ..., f_31]</span></span>
<span class="line"><span>t3 -> [f_0, f_1, ..., f_31]</span></span>
<span class="line"><span>...</span></span>
<span class="line"><span>tT -> [f_0, f_1, ..., f_31]</span></span></code></pre><p>这些向量按时间顺序排列后，可以保留声音随时间变化的轨迹。设备异常往往不只表现为某一帧能量升高，还会表现为某些频段持续增强、周期性出现、突然消失或随转速变化移动。</p>
<p>工程实现时通常使用环形缓冲区保存最近一段时间的特征。例如每 50 ms 产生一帧，保存最近 4 s，就需要保存 80 帧。如果每帧 32 维，输入矩阵大小就是 <code>80 x 32</code>。这比保存 4 s 原始波形轻得多。</p>
<h2>第五步：形成二维特征图</h2>
<p>把时间序列特征按行或列排列，就得到二维特征图。横轴通常是时间，纵轴是频率或压缩后的特征维度。数据形态类似简化声谱图：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>          时间 -></span></span>
<span class="line"><span>特征0  [  .  .  .  .  .  ]</span></span>
<span class="line"><span>特征1  [  .  .  .  .  .  ]</span></span>
<span class="line"><span>特征2  [  .  .  .  .  .  ]</span></span>
<span class="line"><span>...</span></span>
<span class="line"><span>特征31 [  .  .  .  .  .  ]</span></span></code></pre><p>这张图不保留完整原始 FFT 结果。它已经经过频段筛选、噪声过滤、频带合并和降维，包含的是更贴近任务目标的特征。对设备状态分类来说，它可以表达某些频带的长期能量变化；对异常检测来说，它可以表达短时冲击和持续异常的组合模式。</p>
<p>二维特征图还便于做归一化和裁剪。可以按帧归一化、按频带归一化，也可以保留运行时统计量做在线标准化。输入尺寸固定后，模型部署也更简单。</p>
<h2>第六步：送入模型分类</h2>
<p>二维特征图可以输入不同类型的模型：</p>
<ul>
<li>CNN：把二维特征图当作单通道图像，学习局部时间-频率模式。</li>
<li>RNN/GRU/LSTM：按时间步读取每一帧特征，学习时序变化。</li>
<li>Transformer：用注意力机制建模较长时间范围内的依赖关系。</li>
<li>TCN 或一维卷积：沿时间方向做轻量时序建模。</li>
<li>传统模型：使用统计池化后输入 SVM、随机森林或 GBDT。</li>
</ul>
<p>边缘部署时，模型不一定要很大。很多设备状态任务可以先用 <code>时间窗口 + 频带能量 + 轻量 CNN/TCN</code> 起步。输入稳定、维度可控，往往比直接堆大模型更容易落地。</p>
<p>分类输出可以是：</p>
<ul>
<li>正常 / 异常。</li>
<li>不同故障类型。</li>
<li>目标声音是否出现。</li>
<li>设备运行档位或工况。</li>
<li>告警等级。</li>
</ul>
<p>如果任务是异常检测，也可以让模型输出异常分数，再由阈值和持续时间规则决定是否报警。</p>
<h2>这种方法的优势</h2>
<p>第一，输入数据量可控。原始长波形被压缩成二维特征图，RAM 和算力压力明显降低。</p>
<p>第二，特征更贴近任务。筛选和合并频段后，模型看到的是有用频带的变化，整段录音里的采样点已经在特征阶段被压缩。</p>
<p>第三，长期模式仍然保留。时间方向堆叠后，模型可以学习持续增强、周期性冲击、频带漂移等动态特征。</p>
<p>第四，部署链路清晰。FFT、滤波、频带合并和归一化都可以用确定性的 DSP 代码实现，模型只负责分类，便于调试和性能评估。</p>
<p>第五，容易做在线推理。新音频帧到来后，只需要更新一行特征和环形缓冲区，然后按固定周期触发模型推理。</p>
<h2>在嵌入式和边缘 AI 中的意义</h2>
<p>嵌入式设备通常受限于 MCU/DSP/NPU 算力、内存容量、功耗和实时性。长录音原始输入会把压力集中到模型侧，导致模型尺寸、输入长度和缓存需求全部上升。</p>
<p>把 FFT 和特征压缩放在模型前面，可以把一部分问题交给可解释、可优化的信号处理模块。FFT 可以使用 CMSIS-DSP、KissFFT、厂商 DSP 库或硬件加速单元；频带聚合和归一化可以用定点数实现；模型输入尺寸固定后，也更适合量化部署。</p>
<p>工业现场还需要可诊断性。原始波形分类很难解释错误原因；二维特征图可以直接观察某些频带在某段时间的变化。调试人员可以把特征图、分类结果和现场工况对齐，判断是传感器问题、噪声问题、阈值问题还是模型问题。</p>
<p>该流程适合以下任务：</p>
<ul>
<li>电机、泵、风机、压缩机等旋转设备状态识别。</li>
<li>轴承、齿轮、阀门等部件异常声音检测。</li>
<li>产线撞击、摩擦、泄漏、松动等事件识别。</li>
<li>长时间环境声音监测。</li>
<li>低功耗设备上的目标声音唤醒或告警。</li>
</ul>
<h2>总结</h2>
<p>长序列声音分类可以先转换成特征图分类。处理链路是：短时间窗分段、FFT、频谱筛选、噪声过滤、频带合并、降维、时间堆叠、模型分类。</p>
<p>短窗口提取局部频率结构，时间堆叠保留长期变化规律，二维特征图把长录音压缩成固定尺寸输入。模型学习的是声音特征随时间变化的模式，输出正常、异常、目标声音或设备状态类别。</p>
<p>对嵌入式和边缘 AI 项目来说，这条路线的价值在于输入可控、计算可拆分、特征可解释、部署路径清晰。先把长录音整理成高质量二维特征图，再选择合适模型，通常比直接处理整段波形更容易做出稳定系统。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="声音分类"/>
    <category term="FFT"/>
    <category term="特征工程"/>
    <category term="边缘AI"/>
    <category term="异常检测"/>
  </entry>
  <entry>
    <title>用户态程序可以通过 AF_ALG 调用 Linux 内核加密</title>
    <link href="https://createskyblue.github.io/posts/af-alg-crypto-api/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/af-alg-crypto-api/</id>
    <published>2026-05-05T00:00:00.000Z</published>
    <updated>2026-05-05T00:00:00.000Z</updated>
    <summary>AF_ALG通过socket调用内核加密</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用户态程序可以通过 AF_ALG 调用 Linux 内核加密</h1>
<p><code>AF_ALG</code> 是 Linux 暴露给用户态的内核加密接口。应用程序可以通过 socket 调用内核 crypto API，使用哈希、对称加密、AEAD 等算法。</p>
<p>它不是 OpenSSL 这类用户态密码库的替代品。它更适合需要复用内核算法实现、硬件加速驱动或统一内核 crypto 框架的场景。</p>
<h2>基本模型</h2>
<p><code>AF_ALG</code> 通常分为两层 socket：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>父 socket：选择算法，设置 key 等公共参数</span></span>
<span class="line"><span>子 socket：执行一次具体的加密、解密或哈希操作</span></span></code></pre><p>常见流程：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> alg_socket = </span><span style="color:#DCDCAA">socket</span><span style="color:#D4D4D4">(AF_ALG, SOCK_SEQPACKET, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">bind</span><span style="color:#D4D4D4">(alg_socket, ...);</span></span>
<span class="line"><span style="color:#DCDCAA">setsockopt</span><span style="color:#D4D4D4">(alg_socket, SOL_ALG, ALG_SET_KEY, key, key_len);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> op_socket = </span><span style="color:#DCDCAA">accept</span><span style="color:#D4D4D4">(alg_socket, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">send</span><span style="color:#D4D4D4">(op_socket, input, input_len, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">read</span><span style="color:#D4D4D4">(op_socket, output, output_len);</span></span></code></pre><p>父 socket 定义算法上下文，<code>accept</code> 创建具体操作实例。多个操作可以从同一个父 socket 创建多个子 socket。</p>
<h2>选择算法</h2>
<p>算法通过 <code>struct sockaddr_alg</code> 指定：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;linux/if_alg.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> sockaddr_alg alg_addr = {</span></span>
<span class="line"><span style="color:#D4D4D4">    .salg_family = AF_ALG,</span></span>
<span class="line"><span style="color:#D4D4D4">    .salg_type = </span><span style="color:#CE9178">"skcipher"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">    .salg_name = </span><span style="color:#CE9178">"cbc(aes)"</span></span>
<span class="line"><span style="color:#D4D4D4">};</span></span></code></pre><p>字段含义：</p>
<ul>
<li><code>salg_family</code>：固定为 <code>AF_ALG</code>。</li>
<li><code>salg_type</code>：算法类型，例如 <code>hash</code>、<code>skcipher</code>、<code>aead</code>、<code>rng</code>。</li>
<li><code>salg_name</code>：内核已经注册的算法名称，例如 <code>sha256</code>、<code>cbc(aes)</code>。</li>
</ul>
<p>算法名不能随意自定义。可以通过 <code>/proc/crypto</code> 查看当前内核支持的算法：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">cat</span><span style="color:#CE9178"> /proc/crypto</span></span></code></pre><p>如果算法名不存在，<code>bind</code> 会失败。</p>
<h2>AES-CBC 加密示例</h2>
<p>下面示例演示 <code>skcipher</code> 类型的 AES-CBC 用法。代码只展示核心路径，生产代码需要补充完整错误处理和 padding 处理。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> _GNU_SOURCE</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;errno.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;fcntl.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;linux/if_alg.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdio.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;string.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;sys/socket.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;unistd.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> encrypt_aes_cbc</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">const</span><span style="color:#569CD6"> unsigned</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">key</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> key_len</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                    const</span><span style="color:#569CD6"> unsigned</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">iv_data</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                    const</span><span style="color:#569CD6"> unsigned</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">input</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> input_len</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">                    unsigned</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">output</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> output_len</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">    int</span><span style="color:#D4D4D4"> alg_socket = -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#569CD6">    int</span><span style="color:#D4D4D4"> op_socket = -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> sockaddr_alg alg_addr = {</span></span>
<span class="line"><span style="color:#D4D4D4">        .salg_family = AF_ALG,</span></span>
<span class="line"><span style="color:#D4D4D4">        .salg_type = </span><span style="color:#CE9178">"skcipher"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">        .salg_name = </span><span style="color:#CE9178">"cbc(aes)"</span></span>
<span class="line"><span style="color:#D4D4D4">    };</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    alg_socket = </span><span style="color:#DCDCAA">socket</span><span style="color:#D4D4D4">(AF_ALG, SOCK_SEQPACKET, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (alg_socket &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4"> -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">bind</span><span style="color:#D4D4D4">(alg_socket, (</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> sockaddr *)&#x26;alg_addr, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(alg_addr)) &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        close</span><span style="color:#D4D4D4">(alg_socket);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4"> -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">setsockopt</span><span style="color:#D4D4D4">(alg_socket, SOL_ALG, ALG_SET_KEY, key, key_len) &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        close</span><span style="color:#D4D4D4">(alg_socket);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4"> -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    op_socket = </span><span style="color:#DCDCAA">accept</span><span style="color:#D4D4D4">(alg_socket, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (op_socket &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        close</span><span style="color:#D4D4D4">(alg_socket);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4"> -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    unsigned</span><span style="color:#569CD6"> char</span><span style="color:#9CDCFE"> control_buf</span><span style="color:#D4D4D4">[</span><span style="color:#DCDCAA">CMSG_SPACE</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">) + </span><span style="color:#DCDCAA">CMSG_SPACE</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> af_alg_iv) + </span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">)];</span></span>
<span class="line"><span style="color:#DCDCAA">    memset</span><span style="color:#D4D4D4">(control_buf, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(control_buf));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> msghdr msg = {</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">};</span></span>
<span class="line"><span style="color:#9CDCFE">    msg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">msg_control</span><span style="color:#D4D4D4"> = control_buf;</span></span>
<span class="line"><span style="color:#9CDCFE">    msg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">msg_controllen</span><span style="color:#D4D4D4"> = </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(control_buf);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> cmsghdr *cmsg = </span><span style="color:#DCDCAA">CMSG_FIRSTHDR</span><span style="color:#D4D4D4">(&#x26;msg);</span></span>
<span class="line"><span style="color:#9CDCFE">    cmsg</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">cmsg_level</span><span style="color:#D4D4D4"> = SOL_ALG;</span></span>
<span class="line"><span style="color:#9CDCFE">    cmsg</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">cmsg_type</span><span style="color:#D4D4D4"> = ALG_SET_OP;</span></span>
<span class="line"><span style="color:#9CDCFE">    cmsg</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">cmsg_len</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">CMSG_LEN</span><span style="color:#D4D4D4">(</span><span style="color:#B5CEA8">4</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#D4D4D4">    *((</span><span style="color:#569CD6">unsigned</span><span style="color:#569CD6"> int</span><span style="color:#D4D4D4"> *)</span><span style="color:#DCDCAA">CMSG_DATA</span><span style="color:#D4D4D4">(cmsg)) = ALG_OP_ENCRYPT;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">    cmsg = </span><span style="color:#DCDCAA">CMSG_NXTHDR</span><span style="color:#D4D4D4">(&#x26;msg, cmsg);</span></span>
<span class="line"><span style="color:#9CDCFE">    cmsg</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">cmsg_level</span><span style="color:#D4D4D4"> = SOL_ALG;</span></span>
<span class="line"><span style="color:#9CDCFE">    cmsg</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">cmsg_type</span><span style="color:#D4D4D4"> = ALG_SET_IV;</span></span>
<span class="line"><span style="color:#9CDCFE">    cmsg</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">cmsg_len</span><span style="color:#D4D4D4"> = </span><span style="color:#DCDCAA">CMSG_LEN</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> af_alg_iv) + </span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> af_alg_iv *iv = (</span><span style="color:#569CD6">struct</span><span style="color:#D4D4D4"> af_alg_iv *)</span><span style="color:#DCDCAA">CMSG_DATA</span><span style="color:#D4D4D4">(cmsg);</span></span>
<span class="line"><span style="color:#9CDCFE">    iv</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">ivlen</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">    memcpy</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">iv</span><span style="color:#D4D4D4">-></span><span style="color:#9CDCFE">iv</span><span style="color:#D4D4D4">, iv_data, </span><span style="color:#B5CEA8">16</span><span style="color:#D4D4D4">);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    struct</span><span style="color:#D4D4D4"> iovec iov = {</span></span>
<span class="line"><span style="color:#D4D4D4">        .iov_base = (</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4"> *)input,</span></span>
<span class="line"><span style="color:#D4D4D4">        .iov_len = input_len</span></span>
<span class="line"><span style="color:#D4D4D4">    };</span></span>
<span class="line"><span style="color:#9CDCFE">    msg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">msg_iov</span><span style="color:#D4D4D4"> = &#x26;iov;</span></span>
<span class="line"><span style="color:#9CDCFE">    msg</span><span style="color:#D4D4D4">.</span><span style="color:#9CDCFE">msg_iovlen</span><span style="color:#D4D4D4"> = </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">sendmsg</span><span style="color:#D4D4D4">(op_socket, &#x26;msg, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        close</span><span style="color:#D4D4D4">(op_socket);</span></span>
<span class="line"><span style="color:#DCDCAA">        close</span><span style="color:#D4D4D4">(alg_socket);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4"> -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    ssize_t</span><span style="color:#D4D4D4"> nread = </span><span style="color:#DCDCAA">read</span><span style="color:#D4D4D4">(op_socket, output, output_len);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    close</span><span style="color:#D4D4D4">(op_socket);</span></span>
<span class="line"><span style="color:#DCDCAA">    close</span><span style="color:#D4D4D4">(alg_socket);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#D4D4D4"> nread &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4"> ? -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4"> : </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>CBC 模式要求输入长度是块大小的整数倍。AES 块大小是 16 字节。实际业务中需要明确 padding 方案，例如 PKCS#7，并在加密前补齐数据。</p>
<h2>setsockopt 与控制消息</h2>
<p><code>AF_ALG</code> 有两类常见参数传递方式。</p>
<p>第一类是父 socket 上的 <code>setsockopt</code>，例如设置 key：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">setsockopt</span><span style="color:#D4D4D4">(alg_socket, SOL_ALG, ALG_SET_KEY, key, key_len);</span></span></code></pre><p>第二类是发送数据时通过 <code>sendmsg</code> 附带控制消息，例如设置操作方向和 IV：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">ALG_SET_OP</span></span>
<span class="line"><span style="color:#D4D4D4">ALG_SET_IV</span></span></code></pre><p>这种设计使父 socket 可以保存公共配置，而每次操作可以使用不同 IV 或不同操作方向。</p>
<h2>适用场景</h2>
<p><code>AF_ALG</code> 适合以下场景：</p>
<ul>
<li>需要使用内核 crypto framework 中的算法实现。</li>
<li>需要复用内核支持的硬件加速驱动。</li>
<li>数据已经在文件描述符或内核缓冲路径中，适合和 <code>sendmsg</code>、<code>splice</code> 等接口组合。</li>
<li>系统组件希望减少用户态密码库依赖。</li>
</ul>
<p>普通应用层协议开发通常优先使用成熟用户态库，例如 OpenSSL、BoringSSL、libsodium。它们的文档、错误处理、协议封装和跨平台能力更完整。</p>
<h2>注意事项</h2>
<p><code>AF_ALG</code> 是 Linux 专有接口，不具备跨平台能力。不同内核版本、配置和模块加载状态会影响可用算法列表。</p>
<p>密码算法本身只是一部分。实际系统还需要正确处理随机数、IV 唯一性、认证标签、padding、密钥生命周期和错误路径。错误的参数组合会让加密结果不可用，严重时会破坏安全属性。</p>
<h2>总结</h2>
<p><code>AF_ALG</code> 通过 socket 把用户态程序连接到 Linux 内核 crypto API。正常用法是先用父 socket 选择算法和 key，再用 <code>accept</code> 得到操作 socket，通过 <code>sendmsg</code>、<code>read</code> 或 <code>recv</code> 完成一次具体运算。它适合 Linux 系统编程和内核加密能力复用，不适合追求跨平台的通用业务代码。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="Linux"/>
    <category term="AF_ALG"/>
    <category term="Crypto API"/>
    <category term="系统编程"/>
  </entry>
  <entry>
    <title>一种基于脉宽判定的电机霍尔反馈抗干扰方法</title>
    <link href="https://createskyblue.github.io/posts/hall-pulse-width-validation/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/hall-pulse-width-validation/</id>
    <published>2026-05-05T00:00:00.000Z</published>
    <updated>2026-05-05T00:00:00.000Z</updated>
    <summary>霍尔脉宽校验过滤电机霍尔计数干扰</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>一种基于脉宽判定的电机霍尔反馈抗干扰方法</h1>
<p>电机霍尔反馈线被干扰后，最直接的问题是计数乱跳。中断只看到边沿，不知道这个边沿来自真实霍尔信号，还是来自线束、电源、地弹噪声带来的窄毛刺。</p>
<p>处理规则：边沿触发后先判断目标电平持续时间。脉宽合格再计数，脉宽太窄直接丢弃。</p>
<h2>适用场景</h2>
<p>定时器输入滤波适合通道少、引脚固定的场景。多电机项目里，一个电机可能有多路霍尔输入，几台电机加起来很快就超过定时器通道数量。还有一些 IO 映射不到定时器输入，定时器本身也可能已经用于 PWM、编码器或周期任务。</p>
<p>这种情况下，可以把霍尔输入当普通 IO 处理，使用上下边沿中断，再配一个独立的微秒级时间基准。</p>
<h2>实现思路</h2>
<p>每一路霍尔输入只需要保存两类信息：当前状态，以及进入目标电平的时间。</p>
<p>以低电平有效为例：下降沿记录时间，上升沿计算低电平持续时间。脉宽达标，计数加一；脉宽太窄，直接丢弃。</p>
<p><img src="/posts/hall-pulse-width-validation/img/hall-pulse-width-timing.webp" alt="霍尔输入低电平脉宽判定时序图"></p>
<p>不要只比较当前边沿和上一次有效边沿的间隔。两个窄干扰脉冲之间可能隔得很久，边沿间隔看起来合格，单个脉冲宽度仍然非法。要检查脉冲本身的宽度。</p>
<p>多电机、多霍尔输入时，每一路独立判断。不同电机、不同输入可以使用不同的目标电平和阈值。</p>
<h2>阈值设置原则</h2>
<p>阈值按最高转速来定。电机转得越快，真实霍尔脉冲越窄。</p>
<p>调试时先让电机跑到允许的最高转速，用示波器看霍尔反馈。把余晖开到较长时间或最大余晖，找真实霍尔脉冲里最短的低电平宽度。阈值要低于这个宽度，并留出余量，避免转速波动、负载变化和测量误差造成误判。</p>
<p>阈值定完后，用示波器脉宽触发复查。触发条件设在阈值附近，观察是否还能抓到真实霍尔脉冲。还能抓到，阈值可能偏大；只抓到窄毛刺，阈值基本可用。</p>
<p>软件里可以保留一个拒绝计数，只做调试诊断，不参与控制。</p>
<h2>和硬件滤波配合</h2>
<p>软件判定只是计数入口的保护。上拉、下拉、线束屏蔽、接口保护、RC 处理、施密特输入仍然要按硬件问题处理。</p>
<p>硬件先减少干扰，软件再过滤非法计数。多电机、多路霍尔反馈、定时器资源紧张时，这个结构比较好扩展。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="电机控制"/>
    <category term="霍尔信号"/>
    <category term="脉宽判定"/>
    <category term="定时器"/>
  </entry>
  <entry>
    <title>fork 后父子进程可以用 pipe 传递字节流</title>
    <link href="https://createskyblue.github.io/posts/pipe-ipc-stream/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/pipe-ipc-stream/</id>
    <published>2026-05-05T00:00:00.000Z</published>
    <updated>2026-05-05T00:00:00.000Z</updated>
    <summary>fork后进程要交换数据，用pipe建立单向字节流</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>fork 后父子进程可以用 pipe 传递字节流</h1>
<p><code>pipe</code> 是 Unix/Linux 中最基础的进程间通信机制之一。它创建一条由内核维护的单向字节流通道，一个文件描述符用于读取，另一个文件描述符用于写入。</p>
<p>它常用于父子进程通信、命令行管道、日志转发、数据流处理，以及和 <code>splice</code>、<code>tee</code>、<code>vmsplice</code> 等零拷贝接口组合使用。</p>
<h2>函数签名</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;unistd.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> pipe</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">int</span><span style="color:#9CDCFE"> pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">]);</span></span></code></pre><p>调用成功返回 <code>0</code>，失败返回 <code>-1</code> 并设置 <code>errno</code>。</p>
<p><code>pipefd</code> 数组包含两个文件描述符：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]</span><span style="color:#6A9955">  // 读端</span></span>
<span class="line"><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]</span><span style="color:#6A9955">  // 写端</span></span></code></pre><p>数据流向固定为：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>write(pipefd[1]) -> kernel pipe buffer -> read(pipefd[0])</span></span></code></pre><p>pipe 是单向通道。如果两个进程需要双向通信，通常需要创建两个 pipe。</p>
<h2>基本读写示例</h2>
<p>同一个进程中也可以使用 pipe。下面示例先写入字符串，再从读端取出数据：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdio.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;string.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;unistd.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">    int</span><span style="color:#9CDCFE"> pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">pipe</span><span style="color:#D4D4D4">(pipefd) &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        perror</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"pipe"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#B5CEA8"> 1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *msg = </span><span style="color:#CE9178">"hello pipe"</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">    write</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">], msg, </span><span style="color:#DCDCAA">strlen</span><span style="color:#D4D4D4">(msg));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    char</span><span style="color:#9CDCFE"> buf</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">128</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#569CD6">    ssize_t</span><span style="color:#D4D4D4"> n = </span><span style="color:#DCDCAA">read</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">], buf, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(buf) - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (n > </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#9CDCFE">        buf</span><span style="color:#D4D4D4">[n] = </span><span style="color:#CE9178">'</span><span style="color:#D7BA7D">\0</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">        printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"read: </span><span style="color:#9CDCFE">%s</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, buf);</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    close</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#DCDCAA">    close</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>这段代码没有体现进程间通信，但展示了 pipe 的基本数据方向：写端进入，读端取出。</p>
<h2>父子进程通信</h2>
<p>pipe 最常见的用法是配合 <code>fork</code>。父进程先创建 pipe，然后 fork 子进程。<code>fork()</code> 会复制当前进程，形成父进程和子进程两条执行路径。调用返回后，两边都会继续从 <code>fork()</code> 后面的代码往下执行，但返回值不同：子进程中返回 <code>0</code>，父进程中返回子进程的 PID，失败时返回 <code>-1</code>。</p>
<p>fork 后，父子进程都持有同一组 pipe 文件描述符，因此可以通过关闭不用的一端来建立单向通信。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;stdio.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;string.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;sys/wait.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;unistd.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">void</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#6A9955">    // pipefd[0] 是读端，pipefd[1] 是写端。</span></span>
<span class="line"><span style="color:#569CD6">    int</span><span style="color:#9CDCFE"> pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 创建一条匿名管道。失败时返回 -1，并设置 errno。</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">pipe</span><span style="color:#D4D4D4">(pipefd) &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#DCDCAA">        perror</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"pipe"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#B5CEA8"> 1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // fork 会创建一个子进程。</span></span>
<span class="line"><span style="color:#6A9955">    // 从这一行之后，父进程和子进程都会继续执行后面的代码。</span></span>
<span class="line"><span style="color:#569CD6">    pid_t</span><span style="color:#D4D4D4"> pid = </span><span style="color:#DCDCAA">fork</span><span style="color:#D4D4D4">();</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (pid &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#6A9955">        // pid &#x3C; 0 表示 fork 失败，没有创建出子进程。</span></span>
<span class="line"><span style="color:#DCDCAA">        perror</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"fork"</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#B5CEA8"> 1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (pid == </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#6A9955">        // pid == 0 表示当前代码运行在子进程中。</span></span>
<span class="line"><span style="color:#6A9955">        // 子进程只负责读取，所以关闭不用的写端。</span></span>
<span class="line"><span style="color:#DCDCAA">        close</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">        char</span><span style="color:#9CDCFE"> buf</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">128</span><span style="color:#D4D4D4">];</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">        // 从 pipe 的读端读取父进程写入的数据。</span></span>
<span class="line"><span style="color:#6A9955">        // 这里最多读取 sizeof(buf) - 1 字节，留 1 字节给字符串结尾 '\0'。</span></span>
<span class="line"><span style="color:#569CD6">        ssize_t</span><span style="color:#D4D4D4"> n = </span><span style="color:#DCDCAA">read</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">], buf, </span><span style="color:#569CD6">sizeof</span><span style="color:#D4D4D4">(buf) - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (n > </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#6A9955">            // read 读取的是原始字节，不会自动补字符串结尾。</span></span>
<span class="line"><span style="color:#6A9955">            // 如果要按 C 字符串打印，需要手动添加 '\0'。</span></span>
<span class="line"><span style="color:#9CDCFE">            buf</span><span style="color:#D4D4D4">[n] = </span><span style="color:#CE9178">'</span><span style="color:#D7BA7D">\0</span><span style="color:#CE9178">'</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#DCDCAA">            printf</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"child read: </span><span style="color:#9CDCFE">%s</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">, buf);</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">        // 子进程读完后关闭读端并退出。</span></span>
<span class="line"><span style="color:#DCDCAA">        close</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 运行到这里的是父进程。</span></span>
<span class="line"><span style="color:#6A9955">    // 父进程只负责写入，所以关闭不用的读端。</span></span>
<span class="line"><span style="color:#DCDCAA">    close</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    const</span><span style="color:#569CD6"> char</span><span style="color:#D4D4D4"> *msg = </span><span style="color:#CE9178">"hello from parent"</span><span style="color:#D4D4D4">;</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 向 pipe 的写端写入数据，子进程会从读端读到这些字节。</span></span>
<span class="line"><span style="color:#DCDCAA">    write</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">], msg, </span><span style="color:#DCDCAA">strlen</span><span style="color:#D4D4D4">(msg));</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 写完后关闭写端。这样子进程继续 read 时可以正确感知 EOF。</span></span>
<span class="line"><span style="color:#DCDCAA">    close</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    // 等待子进程退出，避免产生僵尸进程。</span></span>
<span class="line"><span style="color:#DCDCAA">    wait</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>这里父进程只保留写端，子进程只保留读端。关闭不用的一端很重要，否则读端可能无法正确收到 EOF。<code>wait(NULL)</code> 用于等待子进程结束，避免子进程退出后短暂变成僵尸进程。</p>
<h2>close 只关闭当前进程的 fd</h2>
<p><code>fork()</code> 之后，父进程和子进程里的 <code>pipefd[0]</code>、<code>pipefd[1]</code> 数字通常是一样的。但这个数字不是系统全局唯一的管道编号，而是各自进程文件描述符表里的索引。</p>
<p>可以把 fork 后的关系理解成：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>内核 pipe 对象</span></span>
<span class="line"><span>  ↑</span></span>
<span class="line"><span>父进程 fd 3 -> 读端引用</span></span>
<span class="line"><span>父进程 fd 4 -> 写端引用</span></span>
<span class="line"><span>子进程 fd 3 -> 读端引用</span></span>
<span class="line"><span>子进程 fd 4 -> 写端引用</span></span></code></pre><p>当子进程执行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">close</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]);</span></span></code></pre><p>关闭的是子进程 fd 表里的写端引用，不会关闭父进程 fd 表里的 <code>pipefd[1]</code>。关闭后可以理解成：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>内核 pipe 对象</span></span>
<span class="line"><span>  ↑</span></span>
<span class="line"><span>父进程 fd 3 -> 读端引用</span></span>
<span class="line"><span>父进程 fd 4 -> 写端引用</span></span>
<span class="line"><span>子进程 fd 3 -> 读端引用</span></span>
<span class="line"><span>子进程 fd 4 已关闭</span></span></code></pre><p>此时父进程仍然可以通过自己的 <code>pipefd[1]</code> 写入数据。只有当所有进程里指向写端的 fd 都关闭后，读端继续读取才会得到 EOF。反过来，只有当所有进程里指向读端的 fd 都关闭后，写端继续写入才会触发 <code>SIGPIPE</code> 或返回 <code>EPIPE</code>。</p>
<p>因此示例中的 <code>close</code> 不是关闭对方的端口，而是关闭当前进程不需要的引用。这样可以让内核准确判断还有没有读者或写者。</p>
<h2>pipe 是字节流</h2>
<p>pipe 传输的是字节流，不是消息队列。它不保留每一次 <code>write</code> 的业务边界。</p>
<p>例如写端连续写入：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">write</span><span style="color:#D4D4D4">(fd, </span><span style="color:#CE9178">"hello"</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">5</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">write</span><span style="color:#D4D4D4">(fd, </span><span style="color:#CE9178">"world"</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">5</span><span style="color:#D4D4D4">);</span></span></code></pre><p>读端可能一次读到：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>helloworld</span></span></code></pre><p>也可能分多次读到。应用层如果需要消息边界，应自行定义协议，例如固定长度头部、换行分隔、TLV 格式等。</p>
<p>有一个特殊规则：当单次写入长度不超过 <code>PIPE_BUF</code> 时，Linux 保证这次写入相对于其他写入是原子的。这个保证只解决多个写者并发写入时的数据交错问题，不等于读端一定按同样长度读取。</p>
<h2>阻塞与 EOF</h2>
<p>默认情况下，pipe 是阻塞的。</p>
<p>读端行为：</p>
<ul>
<li>pipe 中有数据：<code>read</code> 返回实际读取的字节数。</li>
<li>pipe 中没有数据，但仍有写端打开：<code>read</code> 阻塞等待。</li>
<li>所有写端都已关闭：<code>read</code> 返回 <code>0</code>，表示 EOF。</li>
</ul>
<p>写端行为：</p>
<ul>
<li>pipe 缓冲区有空间：<code>write</code> 写入数据并返回。</li>
<li>pipe 缓冲区已满：<code>write</code> 阻塞等待读端消费。</li>
<li>所有读端都已关闭：<code>write</code> 触发 <code>SIGPIPE</code>，或在忽略信号时返回 <code>-1</code> 并设置 <code>errno = EPIPE</code>。</li>
</ul>
<p>这些规则决定了 pipe 程序必须认真关闭不再使用的文件描述符。</p>
<h2>非阻塞 pipe</h2>
<p>可以使用 <code>pipe2</code> 创建带标志的 pipe：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> _GNU_SOURCE</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;fcntl.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;unistd.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#9CDCFE"> pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#DCDCAA">pipe2</span><span style="color:#D4D4D4">(pipefd, O_NONBLOCK | O_CLOEXEC);</span></span></code></pre><p>常用标志：</p>
<ul>
<li><code>O_NONBLOCK</code>：读写操作不阻塞，没有数据或没有空间时返回错误。</li>
<li><code>O_CLOEXEC</code>：执行 <code>exec</code> 后自动关闭文件描述符，避免泄漏到新程序。</li>
</ul>
<p>非阻塞 pipe 常与 <code>poll</code>、<code>epoll</code> 或事件循环组合使用。</p>
<h2>命名管道 FIFO</h2>
<p>普通 <code>pipe</code> 通常用于有亲缘关系的进程，因为文件描述符需要通过 <code>fork</code> 继承。没有亲缘关系的进程可以使用 FIFO，也叫命名管道。</p>
<p>创建 FIFO：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;sys/stat.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">mkfifo</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"/tmp/myfifo"</span><span style="color:#D4D4D4">, </span><span style="color:#B5CEA8">0600</span><span style="color:#D4D4D4">);</span></span></code></pre><p>之后不同进程可以像打开普通文件一样打开它：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">int</span><span style="color:#D4D4D4"> fd = </span><span style="color:#DCDCAA">open</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"/tmp/myfifo"</span><span style="color:#D4D4D4">, O_RDONLY);</span></span></code></pre><p>FIFO 仍然是字节流，读写语义和 pipe 接近，但它在文件系统中有名字，适合无亲缘关系进程之间建立简单通信通道。</p>
<h2>与 splice 的关系</h2>
<p><code>splice</code> 经常需要 pipe 作为中转结构：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">int</span><span style="color:#9CDCFE"> pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#DCDCAA">pipe</span><span style="color:#D4D4D4">(pipefd);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">splice</span><span style="color:#D4D4D4">(file_fd, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">], </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, len, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">splice</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipefd</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">], </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, socket_fd, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, len, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span></code></pre><p>这时 pipe 不一定是为了两个进程通信，而是作为内核中的数据搬运缓冲区。数据路径是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>file_fd -> pipe buffer -> socket_fd</span></span></code></pre><p>用户态不需要分配大块数据缓冲区，也不直接读取文件内容。</p>
<h2>注意事项</h2>
<p>pipe 的容量有限，不能把它当作无限队列使用。写端速度长期高于读端时，最终会阻塞或在非阻塞模式下返回 <code>EAGAIN</code>。</p>
<p>多个进程同时写同一个 pipe 时，应关注写入大小和协议边界。超过 <code>PIPE_BUF</code> 的写入可能和其他写者的数据交错。</p>
<p>长期运行的程序应统一管理文件描述符生命周期。忘记关闭写端会导致读端一直等不到 EOF；忘记关闭读端会让写端错误处理变得不明确。</p>
<h2>总结</h2>
<p><code>pipe</code> 是一条由内核维护的单向字节流。它可以连接父子进程，也可以作为 <code>splice</code> 的内核中转缓冲区。正确使用 pipe 的关键是理解读端、写端、阻塞、EOF 和文件描述符关闭规则。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="Linux"/>
    <category term="pipe"/>
    <category term="IPC"/>
    <category term="系统编程"/>
  </entry>
  <entry>
    <title>Linux splice 可以在内核态搬运文件和 socket 数据</title>
    <link href="https://createskyblue.github.io/posts/splice-zero-copy/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/splice-zero-copy/</id>
    <published>2026-05-05T00:00:00.000Z</published>
    <updated>2026-05-05T00:00:00.000Z</updated>
    <summary>splice让文件到socket少一次拷贝</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Linux splice 可以在内核态搬运文件和 socket 数据</h1>
<p><code>splice</code> 是 Linux 提供的零拷贝数据搬运接口。它可以在文件描述符之间移动数据，并尽量避免把数据复制到用户态缓冲区。典型用途是文件、管道、socket 之间的数据转发。</p>
<p>普通读写路径通常是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>kernel -> user buffer -> kernel</span></span></code></pre><p><code>splice</code> 的目标是减少中间这次用户态拷贝：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>kernel -> pipe buffer -> kernel</span></span></code></pre><h2>函数签名</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> _GNU_SOURCE</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;fcntl.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">ssize_t</span><span style="color:#DCDCAA"> splice</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">int</span><span style="color:#9CDCFE"> fd_in</span><span style="color:#D4D4D4">, </span><span style="color:#4EC9B0">loff_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">off_in</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">               int</span><span style="color:#9CDCFE"> fd_out</span><span style="color:#D4D4D4">, </span><span style="color:#4EC9B0">loff_t</span><span style="color:#D4D4D4"> *</span><span style="color:#9CDCFE">off_out</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#569CD6">               size_t</span><span style="color:#9CDCFE"> len</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">unsigned</span><span style="color:#569CD6"> int</span><span style="color:#9CDCFE"> flags</span><span style="color:#D4D4D4">);</span></span></code></pre><p>参数含义：</p>
<ul>
<li><code>fd_in</code>：输入文件描述符。</li>
<li><code>off_in</code>：输入偏移。为 <code>NULL</code> 时使用并更新文件描述符当前偏移；非 <code>NULL</code> 时从指定偏移读取并更新该变量。</li>
<li><code>fd_out</code>：输出文件描述符。</li>
<li><code>off_out</code>：输出偏移，规则同 <code>off_in</code>。</li>
<li><code>len</code>：最多移动的字节数。</li>
<li><code>flags</code>：行为标志，可以为 <code>0</code>。</li>
</ul>
<p>返回值是实际移动的字节数。返回 <code>0</code> 通常表示输入已经到达 EOF。返回 <code>-1</code> 表示失败，并设置 <code>errno</code>。</p>
<h2>pipe 是核心中转结构</h2>
<p><code>splice</code> 的一个重要限制是：输入或输出的一端通常需要是 pipe。因此常见写法是两段式：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#569CD6">int</span><span style="color:#9CDCFE"> pipe_fds</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#DCDCAA">pipe</span><span style="color:#D4D4D4">(pipe_fds);</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">splice</span><span style="color:#D4D4D4">(file_fd, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">pipe_fds</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">], </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, len, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#DCDCAA">splice</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipe_fds</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">], </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, socket_fd, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, len, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span></code></pre><p>第一步把数据从文件移动到 pipe，第二步把数据从 pipe 移动到 socket。用户态代码不直接接触数据内容，只负责安排数据路径。</p>
<h2>文件到 socket 的示例</h2>
<p>下面的示例把本地文件内容发送到已连接的 socket：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">#define</span><span style="color:#569CD6"> _GNU_SOURCE</span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;fcntl.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;unistd.h></span></span>
<span class="line"><span style="color:#C586C0">#include</span><span style="color:#CE9178"> &#x3C;sys/socket.h></span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">int</span><span style="color:#DCDCAA"> send_file_by_splice</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">int</span><span style="color:#9CDCFE"> file_fd</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">int</span><span style="color:#9CDCFE"> socket_fd</span><span style="color:#D4D4D4">, </span><span style="color:#569CD6">size_t</span><span style="color:#9CDCFE"> total_len</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#569CD6">    int</span><span style="color:#9CDCFE"> pipe_fds</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">];</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> (</span><span style="color:#DCDCAA">pipe</span><span style="color:#D4D4D4">(pipe_fds) &#x3C; </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">        return</span><span style="color:#D4D4D4"> -</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">    size_t</span><span style="color:#D4D4D4"> sent = </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#C586C0">    while</span><span style="color:#D4D4D4"> (sent &#x3C; total_len) {</span></span>
<span class="line"><span style="color:#569CD6">        size_t</span><span style="color:#D4D4D4"> chunk = total_len - sent;</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (chunk > </span><span style="color:#B5CEA8">65536</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#D4D4D4">            chunk = </span><span style="color:#B5CEA8">65536</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">        ssize_t</span><span style="color:#D4D4D4"> moved = </span><span style="color:#DCDCAA">splice</span><span style="color:#D4D4D4">(file_fd, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">pipe_fds</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">], </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, chunk, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (moved &#x3C;= </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">            break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">        ssize_t</span><span style="color:#D4D4D4"> written = </span><span style="color:#DCDCAA">splice</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipe_fds</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">], </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, socket_fd, </span><span style="color:#569CD6">NULL</span><span style="color:#D4D4D4">, moved, </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">);</span></span>
<span class="line"><span style="color:#C586C0">        if</span><span style="color:#D4D4D4"> (written &#x3C;= </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">) {</span></span>
<span class="line"><span style="color:#C586C0">            break</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">        }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#D4D4D4">        sent += written;</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    close</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipe_fds</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#DCDCAA">    close</span><span style="color:#D4D4D4">(</span><span style="color:#9CDCFE">pipe_fds</span><span style="color:#D4D4D4">[</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]);</span></span>
<span class="line"><span style="color:#C586C0">    return</span><span style="color:#B5CEA8"> 0</span><span style="color:#D4D4D4">;</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>这个函数适合用于代理、网关、静态文件服务等数据转发场景。它减少用户态内存拷贝，但仍然需要处理短读、短写、阻塞、错误返回等情况。</p>
<h2>常用 flags</h2>
<p><code>splice</code> 的 <code>flags</code> 可以组合以下选项：</p>
<ul>
<li><code>SPLICE_F_MOVE</code>：提示内核可以移动页而不是复制页。现代内核中更多是优化提示。</li>
<li><code>SPLICE_F_NONBLOCK</code>：以非阻塞方式操作，但文件描述符本身也应设置非阻塞。</li>
<li><code>SPLICE_F_MORE</code>：提示后续还有更多数据，类似 <code>MSG_MORE</code>。</li>
<li><code>SPLICE_F_GIFT</code>：主要用于 <code>vmsplice</code>，表示用户页可以交给内核管理。</li>
</ul>
<p>普通阻塞式代码可以先使用 <code>0</code>，把正确性处理清楚后再考虑 flags。</p>
<h2>注意事项</h2>
<p><code>splice</code> 不等于所有场景都更快。它减少数据拷贝，但会引入 pipe 管理、系统调用次数、阻塞语义和内核路径差异。小数据量、需要用户态解析或修改内容的场景，普通 <code>read</code>/<code>write</code> 更直接。</p>
<p>使用 <code>splice</code> 时应检查每一次返回值。实际移动字节数可能小于请求的 <code>len</code>，网络 socket 尤其需要循环处理。错误路径也要关闭 pipe 两端，避免文件描述符泄漏。</p>
<h2>总结</h2>
<p><code>splice</code> 的核心价值是让数据在内核对象之间流动，减少用户态缓冲区参与。典型模式是 <code>fd -&gt; pipe -&gt; fd</code>。它适合大块转发和代理类场景，不适合需要频繁检查或修改数据内容的业务逻辑。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="Linux"/>
    <category term="splice"/>
    <category term="零拷贝"/>
    <category term="系统编程"/>
  </entry>
  <entry>
    <title>用 Chroma 跑通文本向量检索的最小流程</title>
    <link href="https://createskyblue.github.io/posts/chroma-vector-database-demo/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/chroma-vector-database-demo/</id>
    <published>2026-05-02T00:00:00.000Z</published>
    <updated>2026-05-02T00:00:00.000Z</updated>
    <summary>Chroma向量数据库做相似文本检索</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用 Chroma 跑通文本向量检索的最小流程</h1>
<p>向量数据库用于存储向量并执行相似度检索。文本、图片、音频等数据可以先通过 embedding 模型转换成向量，再写入向量数据库。</p>
<p>Chroma 是一个开源向量数据库。本文只演示最小使用流程：</p>
<ol>
<li>创建 Chroma client</li>
<li>创建 collection</li>
<li>写入文档</li>
<li>使用自然语言查询相似文档</li>
<li>查看返回结果</li>
</ol>
<h2>Chroma 是什么</h2>
<p>Chroma 可以保存以下数据：</p>
<ul>
<li><code>ids</code>：文档唯一编号</li>
<li><code>documents</code>：原始文本</li>
<li><code>metadatas</code>：附加信息</li>
<li><code>embeddings</code>：文本对应的向量</li>
</ul>
<p>在最小 Demo 中，可以不手动传入 <code>embeddings</code>。Chroma 会使用默认 embedding 模型，把 <code>documents</code> 和 <code>query_texts</code> 自动转换成向量。</p>
<p>例如查询：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Chroma 怎么把数据保存到本地？</span></span></code></pre><p>可以匹配到：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>PersistentClient 可以把 Chroma 数据保存到本地目录。</span></span></code></pre><p>这类查询不是精确字符串匹配，而是相似度检索。</p>
<h2>环境</h2>
<p>使用 <code>uv</code> 创建虚拟环境：</p>
<pre><code>uv venv
.\.venv\Scripts\Activate.ps1
uv pip install -r requirements.txt</code></pre><p><code>requirements.txt</code> 里只需要：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>chromadb>=1.0.0</span></span></code></pre><p>运行 Demo：</p>
<pre><code>uv run python demo.py</code></pre><p>第一次运行时，Chroma 会下载默认 embedding 模型。下载完成后会使用本地缓存。</p>
<h2>最小 Demo</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#C586C0">import</span><span style="color:#D4D4D4"> chromadb</span></span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span style="color:#569CD6">def</span><span style="color:#DCDCAA"> main</span><span style="color:#D4D4D4">() -> </span><span style="color:#569CD6">None</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#6A9955">    # 1. 创建 Chroma 客户端。</span></span>
<span class="line"><span style="color:#6A9955">    # Client() 是内存模式：适合 demo 和快速测试，程序结束后数据会消失。</span></span>
<span class="line"><span style="color:#6A9955">    # 如果你想保存到本地磁盘，可以改成：</span></span>
<span class="line"><span style="color:#6A9955">    # client = chromadb.PersistentClient(path="chroma_data")</span></span>
<span class="line"><span style="color:#D4D4D4">    client = chromadb.Client()</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    # 2. 创建或获取一个 collection。</span></span>
<span class="line"><span style="color:#6A9955">    # collection 可以理解成一组文档的集合，类似数据库里的“表”。</span></span>
<span class="line"><span style="color:#D4D4D4">    collection = client.get_or_create_collection(</span><span style="color:#9CDCFE">name</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"simple_chroma_demo"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    # 3. 准备几条示例文档。</span></span>
<span class="line"><span style="color:#D4D4D4">    ids = [</span><span style="color:#CE9178">"doc1"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"doc2"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"doc3"</span><span style="color:#D4D4D4">]</span></span>
<span class="line"><span style="color:#D4D4D4">    documents = [</span></span>
<span class="line"><span style="color:#CE9178">        "Chroma 是一个向量数据库，可以用来做相似度搜索。"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#CE9178">        "PersistentClient 可以把 Chroma 数据保存到本地目录。"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#CE9178">        "RAG 会先从知识库检索相关内容，再交给大语言模型回答。"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">    ]</span></span>
<span class="line"><span style="color:#D4D4D4">    metadatas = [</span></span>
<span class="line"><span style="color:#D4D4D4">        {</span><span style="color:#CE9178">"topic"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"chroma"</span><span style="color:#D4D4D4">},</span></span>
<span class="line"><span style="color:#D4D4D4">        {</span><span style="color:#CE9178">"topic"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"persistence"</span><span style="color:#D4D4D4">},</span></span>
<span class="line"><span style="color:#D4D4D4">        {</span><span style="color:#CE9178">"topic"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"rag"</span><span style="color:#D4D4D4">},</span></span>
<span class="line"><span style="color:#D4D4D4">    ]</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    # 4. 写入文档。</span></span>
<span class="line"><span style="color:#6A9955">    # 这里没有手动传 embeddings，所以 Chroma 会使用默认 embedding 模型</span></span>
<span class="line"><span style="color:#6A9955">    # 自动把 documents 转成向量，然后保存起来。</span></span>
<span class="line"><span style="color:#D4D4D4">    collection.upsert(</span></span>
<span class="line"><span style="color:#9CDCFE">        ids</span><span style="color:#D4D4D4">=ids,</span></span>
<span class="line"><span style="color:#9CDCFE">        documents</span><span style="color:#D4D4D4">=documents,</span></span>
<span class="line"><span style="color:#9CDCFE">        metadatas</span><span style="color:#D4D4D4">=metadatas,</span></span>
<span class="line"><span style="color:#D4D4D4">    )</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">    # 5. 用自然语言查询。</span></span>
<span class="line"><span style="color:#D4D4D4">    query = </span><span style="color:#CE9178">"Chroma 怎么把数据保存到本地？"</span></span>
<span class="line"><span style="color:#D4D4D4">    results = collection.query(</span></span>
<span class="line"><span style="color:#9CDCFE">        query_texts</span><span style="color:#D4D4D4">=[query],</span></span>
<span class="line"><span style="color:#9CDCFE">        n_results</span><span style="color:#D4D4D4">=</span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#D4D4D4">    )</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">    print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"问题：</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">query</span><span style="color:#569CD6">}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#DCDCAA">    print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"当前 collection 文档数量：</span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">collection.count()</span><span style="color:#569CD6">}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">    for</span><span style="color:#D4D4D4"> index, document </span><span style="color:#C586C0">in</span><span style="color:#DCDCAA"> enumerate</span><span style="color:#D4D4D4">(results[</span><span style="color:#CE9178">"documents"</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">], </span><span style="color:#9CDCFE">start</span><span style="color:#D4D4D4">=</span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">):</span></span>
<span class="line"><span style="color:#D4D4D4">        doc_id = results[</span><span style="color:#CE9178">"ids"</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][index - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]</span></span>
<span class="line"><span style="color:#D4D4D4">        metadata = results[</span><span style="color:#CE9178">"metadatas"</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][index - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]</span></span>
<span class="line"><span style="color:#D4D4D4">        distance = results[</span><span style="color:#CE9178">"distances"</span><span style="color:#D4D4D4">][</span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">][index - </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">]</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">        print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">结果 </span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">index</span><span style="color:#569CD6">}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#DCDCAA">        print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"id: </span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">doc_id</span><span style="color:#569CD6">}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#DCDCAA">        print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"metadata: </span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">metadata</span><span style="color:#569CD6">}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#DCDCAA">        print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"distance: </span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">distance</span><span style="color:#569CD6">:.4f}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"><span style="color:#DCDCAA">        print</span><span style="color:#D4D4D4">(</span><span style="color:#569CD6">f</span><span style="color:#CE9178">"document: </span><span style="color:#569CD6">{</span><span style="color:#D4D4D4">document</span><span style="color:#569CD6">}</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">)</span></span>
<span class="line"></span>
<span class="line"></span>
<span class="line"><span style="color:#C586C0">if</span><span style="color:#9CDCFE"> __name__</span><span style="color:#D4D4D4"> == </span><span style="color:#CE9178">"__main__"</span><span style="color:#D4D4D4">:</span></span>
<span class="line"><span style="color:#D4D4D4">    main()</span></span></code></pre><h2>运行结果</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>问题：Chroma 怎么把数据保存到本地？</span></span>
<span class="line"><span>当前 collection 文档数量：3</span></span>
<span class="line"><span></span></span>
<span class="line"><span>结果 1</span></span>
<span class="line"><span>id: doc2</span></span>
<span class="line"><span>metadata: {'topic': 'persistence'}</span></span>
<span class="line"><span>distance: 0.3286</span></span>
<span class="line"><span>document: PersistentClient 可以把 Chroma 数据保存到本地目录。</span></span>
<span class="line"><span></span></span>
<span class="line"><span>结果 2</span></span>
<span class="line"><span>id: doc1</span></span>
<span class="line"><span>metadata: {'topic': 'chroma'}</span></span>
<span class="line"><span>distance: 0.4719</span></span>
<span class="line"><span>document: Chroma 是一个向量数据库，可以用来做相似度搜索。</span></span></code></pre><p>第一个结果匹配到 <code>PersistentClient</code> 相关文档，说明查询文本和目标文档在向量空间中距离更近。</p>
<h2>API 说明</h2>
<p><code>chromadb.Client()</code>：创建一个内存模式的 Chroma 客户端，适合快速测试。</p>
<p><code>get_or_create_collection()</code>：创建或获取一个 collection。collection 可以理解成一组文档集合。</p>
<p><code>upsert()</code>：写入或更新文档。这里传入了 <code>ids</code>、<code>documents</code> 和 <code>metadatas</code>。</p>
<p><code>query()</code>：查询相似内容。这里使用 <code>query_texts</code>，Chroma 会自动把查询文本转换成向量。</p>
<p><code>distance</code>：距离越小，通常表示越相似。不同距离算法下数值含义会有差异，但在这个 Demo 里可以先理解为“越小越接近”。</p>
<h2>内存模式</h2>
<p>Demo 里使用的是：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">client = chromadb.Client()</span></span></code></pre><p>这是内存模式，适合临时测试。程序结束后，数据不会长期保存。</p>
<h2>持久化模式</h2>
<p>需要把数据保存到本地目录时，改用 <code>PersistentClient</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">client = chromadb.PersistentClient(</span><span style="color:#9CDCFE">path</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"chroma_data"</span><span style="color:#D4D4D4">)</span></span></code></pre><p>数据会写入 <code>chroma_data</code> 目录。下次程序启动后可以继续读取。</p>
<h2>总结</h2>
<ul>
<li>文档可以直接写入 Chroma</li>
<li>Chroma 可以自动使用默认 embedding 模型生成向量</li>
<li>查询时也可以直接传自然语言</li>
<li>返回结果是按相似度排序的</li>
<li>如果要长期保存数据，需要使用 <code>PersistentClient</code></li>
</ul>]]></content>
    <category term="Chroma"/>
    <category term="向量数据库"/>
    <category term="Python"/>
    <category term="AI"/>
    <category term="RAG"/>
  </entry>
  <entry>
    <title>把手机浏览器变成电脑的远程输入终端，语音、触控板、常用按键都行</title>
    <link href="https://createskyblue.github.io/posts/remote-input-board/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/remote-input-board/</id>
    <published>2026-04-29T00:00:00.000Z</published>
    <updated>2026-04-29T00:00:00.000Z</updated>
    <summary>电脑长文输入与躺平操控两难，手机页面语音触控常用按键一站式解决</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>把手机浏览器变成电脑的远程输入终端，语音、触控板、常用按键都行</h1>
<table>
  <tr>
    <td align="center"><strong>🗣️ 语音输入</strong><br><sub>豆包输入法 → 电脑</sub></td>
    <td align="center"><strong>🖱️ 触控板</strong><br><sub>手机当鼠标用</sub></td>
    <td align="center"><strong>📋 历史记录</strong><br><sub>点击回填，自动滚回顶部</sub></td>
  </tr>
  <tr>
    <td><img src="/posts/remote-input-board/img/输入界面带键盘.webp" alt="语音输入主界面" width="240"></td>
    <td><img src="/posts/remote-input-board/img/触摸板.webp" alt="触控板模式" width="240"></td>
    <td><img src="/posts/remote-input-board/img/输入界面带历史记录.webp" alt="历史记录" width="240"></td>
  </tr>
</table>

<p>电脑上写长文，打字慢；手机上豆包输入法的语音识别很准，但识别出来的字还在手机上。躺在床上 AI Coding 切候选项，还得爬起来摸键盘。</p>
<p><strong>Remote Input Board</strong> 把这件事倒过来：电脑上跑一个小服务，手机浏览器打开网页，手机就变成电脑的远程输入终端。语音输入直接打到光标位置，触控板当鼠标用，ESC、发送、清空这些常用按键也能在手机上操作。</p>
<p>项目地址：<a href="https://github.com/createskyblue/remote-input-board">https://github.com/createskyblue/remote-input-board</a></p>
<h2>能做什么</h2>
<p><strong>文本输入</strong></p>
<ul>
<li>🗣️ 手机用豆包输入法语音识别，电脑光标处直接出字</li>
<li>⚡ 停止输入后自动发送，延时可调</li>
<li>↩️ 有内容按回车立即发送，空内容按回车 = 电脑回车键</li>
<li>⌫ 输入框为空时按退格 = 电脑退格键</li>
<li>📝 最近 30 条发送历史，点一下回填到输入框，覆盖前会确认</li>
<li>🔢 累计发送字数统计，浏览器 localStorage 持久化</li>
</ul>
<p><strong>触控板</strong></p>
<ul>
<li>🖱️ 单指滑动 = 移动鼠标，单指单击 = 左键，双指单击 = 右键</li>
<li>📜 双指滑动 = 滚动（横放上下滚，竖放左右滚）</li>
<li>✊ 双击按住 = 拖拽，松开结束</li>
<li>🌐 鼠标移动和滚动走 WebSocket，比 HTTP 轮询延迟低得多</li>
</ul>
<p><strong>常用按键</strong></p>
<ul>
<li>⎋ ESC、清空、发送按钮放在输入框下方，AI Coding 退出状态、重发文本和清理输入都很顺手</li>
</ul>
<p><strong>关键行为</strong></p>
<ul>
<li>🧹 所有文字通过 Win32 <code>SendInput</code> Unicode 键盘事件逐字输入，不碰系统剪贴板，不会覆盖你复制的重要内容</li>
</ul>
<h2>怎么用</h2>
<p>电脑端一行启动：</p>
<pre><code>uv run python -m py_remote_input</code></pre><p>终端打印局域网地址，手机连同一个 Wi-Fi 打开即可。发送前在电脑上点一下目标输入位置，把光标放好。</p>
<p>改端口：</p>
<pre><code>$env:PORT='3219'; uv run python -m py_remote_input</code></pre><p>打包成单个 exe（不需要 Python 环境）：</p>
<pre><code>.\.venv\Scripts\python.exe scripts\build_exe.py</code></pre><p>输出 <code>dist\RemoteInputBoard.exe</code>，双击即用。</p>
<p>发送过的文本都写进 <code>logs/input-history.log</code>，一行一条 JSON，崩溃也不丢。</p>
<h2>架构</h2>
<p>整个项目只有两个运行时通道：</p>
<table>
<thead>
<tr>
<th>通道</th>
<th>用途</th>
<th>协议</th>
</tr>
</thead>
<tbody><tr>
<td>HTTP API</td>
<td>文本发送、按键、鼠标点击等离散操作</td>
<td>HTTP POST JSON</td>
</tr>
<tr>
<td>WebSocket</td>
<td>鼠标移动、滚动等高频连续操作</td>
<td>WebSocket 文本帧 JSON</td>
</tr>
</tbody></table>
<p>鼠标移动如果走 HTTP，每次 <code>pointermove</code> 发一个 POST 请求，延迟和开销都很大。走 WebSocket 后，前端用 <code>requestAnimationFrame</code> 攒帧合并增量，后端收到直接调 <code>SendInput</code>，体感跟手。</p>
<p>核心模块：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>py_remote_input/</span></span>
<span class="line"><span>  __main__.py     # 入口</span></span>
<span class="line"><span>  server.py       # HTTP + WebSocket 服务，ThreadingHTTPServer</span></span>
<span class="line"><span>  web.py          # HTTP 路由和请求分发</span></span>
<span class="line"><span>  websocket.py    # WebSocket 帧编码/解码（无第三方依赖）</span></span>
<span class="line"><span>  typer.py        # Win32 SendInput 封装：文本、按键、鼠标移动/点击/滚动</span></span>
<span class="line"><span>  logger.py       # 结构化日志（同时输出 stdout 和文件）</span></span>
<span class="line"><span>  templates/</span></span>
<span class="line"><span>    index.html    # 单页前端（原生 JS，无框架）</span></span></code></pre><p><code>typer.py</code> 是整个电脑端的核心。文字输入走 <code>KEYEVENTF_UNICODE</code>，每个 UTF-16 码元生成一对 keydown/keyup 事件；回车和退格映射到虚拟键码；鼠标移动、点击、滚动各自拼装 <code>MOUSEINPUT</code> 结构体，统一通过 <code>SendInput</code> 批量注入。</p>
<p><code>server.py</code> 用标准库 <code>ThreadingHTTPServer</code>，不依赖 Flask 或任何 Web 框架。WebSocket 升级握手、帧解析、掩码处理全在 <code>websocket.py</code> 里手写实现，代码不到 80 行。</p>
<p>前端是单个 HTML 文件，所有状态（自动发送开关、延时、历史记录、累计字数）存在 <code>localStorage</code>，不依赖服务端 session。</p>
<h2>触控板手势状态机</h2>
<p>触控板的 tap / double-tap / drag 判定不依赖浏览器 click 事件，完全在 <code>pointerdown/move/up</code> 里处理：</p>
<ul>
<li><strong>单指按下</strong>：记录起始时间戳和位置，启动 tap 监测</li>
<li><strong>移动超过阈值（8px）</strong>：判定为滑动，取消 tap，开始移动鼠标</li>
<li><strong>抬起且无移动且间隔 &lt; 220ms</strong>：判定为 tap，区分单指（左键）和双指（右键）</li>
<li><strong>快速双击（间隔 &lt; 180ms）</strong>：进入拖拽候选 — 第二次按下后如有移动则锁定拖拽（持续按下左键），无移动则触发双击</li>
<li><strong>双指滑动</strong>：根据两指初始横纵关系判定滚动轴向，累积增量走 <code>requestAnimationFrame</code> 批量发送</li>
</ul>
<p>所有定时和状态管理都在前端完成，服务端只接收 <code>mouseMove</code>、<code>mouseScroll</code>、<code>mouseClick</code>、<code>mouseButton</code> 四种标准化消息。</p>
<h2>小结</h2>
<p>这个工具的出发点一直没变：把手机上更好的输入能力借给电脑用。语音输入是起点，触控板和常用按键是后来发现同样顺手加上的。代码量不大，但恰好覆盖了&quot;不想碰键盘&quot;的几个高频场景。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="豆包输入法"/>
    <category term="语音输入"/>
    <category term="触控板"/>
    <category term="远程输入"/>
    <category term="Python"/>
    <category term="Windows"/>
    <category term="WebSocket"/>
  </entry>
  <entry>
    <title>PCB卡扣 / PCB压紧扣 PCB Retaining Clip</title>
    <link href="https://createskyblue.github.io/posts/PCB-Retaining-Clip/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/PCB-Retaining-Clip/</id>
    <published>2026-03-15T00:00:00.000Z</published>
    <updated>2026-03-15T00:00:00.000Z</updated>
    <summary>PCB卡扣 / PCB压紧扣 PCB Retaining Clip</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>PCB卡扣 / PCB压紧扣 PCB Retaining Clip</h1>
<p>最近在小红书上刷到博主「广西3D打印之神」分享的一个实用的3D打印PCB外壳卡扣设计。写一个帖子来收藏一下，防止找不到</p>
<table>
<thead>
<tr>
<th>「广西3D打印之神」参考设计</th>
<th>我自己的数值</th>
</tr>
</thead>
<tbody><tr>
<td><img src="/posts/PCB-Retaining-Clip/%E5%8D%A1%E6%89%A3%E8%AE%BE%E8%AE%A1.webp" alt="卡扣设计参数数值"></td>
<td><img src="/posts/PCB-Retaining-Clip/PixPin_2026-09-13_23-46-04.webp" alt="个人数值参数参考"></td>
</tr>
</tbody></table>
<h2>模型下载</h2>
<p>实践用的卡扣模型（3MF）：<a href="/posts/PCB-Retaining-Clip/%E5%8D%A1%E6%89%A3.3mf" target="_blank" rel="noopener noreferrer">下载卡扣.3mf</a></p>
<p>可导入 Fusion 360、Blender 等建模软件参考或直接 3D 打印。</p>
<h2>设计参数</h2>
<table>
<thead>
<tr>
<th>参数</th>
<th>参考数值</th>
<th>个人数值</th>
<th>备注</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>1.2-1.6</td>
<td>1.2</td>
<td>-</td>
</tr>
<tr>
<td>B</td>
<td>0.6-1</td>
<td>0.6</td>
<td>-</td>
</tr>
<tr>
<td>C</td>
<td>1.6-2</td>
<td>1.6</td>
<td>-</td>
</tr>
<tr>
<td>D（卡扣量）</td>
<td>0.65</td>
<td>0.65</td>
<td>-</td>
</tr>
<tr>
<td>E</td>
<td>0.25</td>
<td>0.25</td>
<td>-</td>
</tr>
<tr>
<td>F</td>
<td>0.4</td>
<td>0.4</td>
<td>-</td>
</tr>
<tr>
<td>G</td>
<td>0.15</td>
<td>0</td>
<td>可以直接为0，嘉立创出来板框会小一圈</td>
</tr>
<tr>
<td>H</td>
<td>-</td>
<td>5</td>
<td>我习惯PCB悬空5mm</td>
</tr>
<tr>
<td>I</td>
<td>-</td>
<td>7</td>
<td>-</td>
</tr>
<tr>
<td>PCB厚度</td>
<td>-</td>
<td>1.6</td>
<td>-</td>
</tr>
</tbody></table>
<h2>个人实践OK</h2>
<p><img src="/posts/PCB-Retaining-Clip/PixPin_2026-09-13_23-31-31.webp" alt="">
<img src="/posts/PCB-Retaining-Clip/PixPin_2026-09-13_23-30-03.webp" alt=""></p>]]></content>
    <category term="3D建模"/>
    <category term="卡扣"/>
    <category term="fusion360"/>
    <category term="3D打印"/>
  </entry>
  <entry>
    <title>专为 NanoEdgeAI 设计的 CSV 时间序列标注工具 | A CSV time-series annotation tool tailored for NanoEdge AI Studio</title>
    <link href="https://createskyblue.github.io/posts/csv-time-series-annotator/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/csv-time-series-annotator/</id>
    <published>2026-03-02T00:00:00.000Z</published>
    <updated>2026-03-02T00:00:00.000Z</updated>
    <summary>CSV时间序列标注工具</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>NanoEdgeAI CSV时间序列标注工具</h1>
<h2>项目简介</h2>
<p>NanoEdgeAI CSV时间序列标注工具是一个基于Web的数据标注平台，专为时间序列数据的可视化与标注而设计。项目提供本地运行和快速部署能力，提升数据标注效率。</p>
<p>本工具主要面向数据科学家、机器学习工程师以及需要对时间序列数据进行结构化标注的研究团队。</p>
<p>你可以在 <a href="https://createskyblue.github.io/csv-time-series-annotator/">https://createskyblue.github.io/csv-time-series-annotator/</a> 直接使用此工具，无需安装任何依赖。
<img src="/posts/csv-time-series-annotator/img/PixPin_2026-01-07_06-26-10.webp" alt="应用演示"></p>
<h2>主要功能</h2>
<ul>
<li><strong>多文件标注</strong>：支持同时上传多个CSV文件进行标注，可在一个项目中管理多个数据文件</li>
<li><strong>文件导航</strong>：支持通过W/S键在不同文件间切换，不管样本是否完成标注</li>
<li><strong>文件管理系统</strong>：新增文件树系统，可添加和删除标注项目中的文件</li>
<li><strong>灵活数据导入与解析</strong>：支持CSV格式的数据导入，使用Papa Parse进行高效解析，每个CSV行被视为一个独立的时间序列样本</li>
<li><strong>时间轴系数设置</strong>：允许用户设置时间轴单位系数，调整X轴显示比例</li>
<li><strong>数据维度设置</strong>：支持设置数据维度数量，将数据按维度交错方式解析（D1_S1, D2_S1...序列），便于处理多维时间序列数据</li>
<li><strong>时间序列可视化</strong>：基于uPlot轻量级图表库实现数据可视化</li>
<li><strong>快速标签选择</strong>：支持数字键1-9快速选择前9个标签按钮</li>
<li><strong>标签管理</strong>：灵活的标签配置系统，支持自定义标签</li>
<li><strong>跳转功能</strong>：在样本导航区域提供文本框，允许用户手动输入数字快速跳转到指定样本</li>
<li><strong>项目命名</strong>：允许用户为项目命名，便于管理</li>
<li><strong>按钮按下反馈</strong>：标注按钮在鼠标按下或键盘快捷键按下时提供视觉反馈</li>
<li><strong>数据导出</strong>：一键导出标注结果到分类的CSV文件，按原始文件名分别导出</li>
<li><strong>修复CSV导出问题</strong>：修复导出CSV时自动创建新标题行的问题</li>
<li><strong>项目备份与恢复</strong>：支持项目状态的导入导出功能</li>
</ul>
<h2>技术架构</h2>
<ul>
<li><strong>前端框架</strong>：React v19.2.3</li>
<li><strong>语言</strong>：TypeScript ~5.8.2</li>
<li><strong>构建工具</strong>：Vite ^6.2.0</li>
<li><strong>图表库</strong>：uPlot ^1.6.32</li>
<li><strong>CSV解析</strong>：Papa Parse ^5.5.3</li>
</ul>
<h2>快速开始</h2>
<h3>环境准备</h3>
<p>确保您的系统已安装Node.js（版本需兼容npm）</p>
<h3>安装与运行</h3>
<ol>
<li><p><strong>克隆或下载项目到本地目录</strong></p>
</li>
<li><p><strong>安装依赖</strong>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">npm</span><span style="color:#CE9178"> install</span></span></code></pre></li>
<li><p><strong>启动本地服务</strong>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">npm</span><span style="color:#CE9178"> run</span><span style="color:#CE9178"> dev</span></span></code></pre></li>
</ol>
<h2>使用说明</h2>
<h3>数据导入</h3>
<ul>
<li>点击侧边栏的&quot;上传 CSV 数据源&quot;按钮，选择CSV文件</li>
<li>CSV的每一行将被视为一个独立的时间序列样本</li>
</ul>
<h3>数据维度设置</h3>
<ul>
<li>在&quot;全局配置&quot;区域，可以设置&quot;数据维度(D)&quot;的值</li>
<li>该功能用于处理多维时间序列数据，将数据按维度交错方式解析（D1_S1, D2_S1...序列）</li>
<li>设置后，图表将显示多条数据序列线，每条线代表一个维度的数据</li>
<li>数据维度值会在右上角显示为&quot;{维度数} 维模式&quot;标签</li>
</ul>
<h3>标签配置</h3>
<ul>
<li>在&quot;标签配置&quot;区域编辑标签，每行一个标签</li>
<li>修改后将即时更新下方的打标按钮</li>
</ul>
<h3>数据标注</h3>
<ul>
<li>使用键盘上的 A/D 键或点击箭头按钮切换样本</li>
<li>点击标签按钮为当前样本打标签</li>
<li>已标注的样本会显示在状态指示区域</li>
</ul>
<h3>数据导出</h3>
<ul>
<li>完成标注后，点击&quot;一键导出标注结果&quot;按钮</li>
<li>系统会按标签分类导出CSV文件</li>
</ul>
<h3>项目管理</h3>
<ul>
<li>使用&quot;导出项目&quot;功能保存当前标注进度</li>
<li>使用&quot;导入项目&quot;功能恢复之前的标注进度</li>
</ul>
<h2>构建与部署</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">npm</span><span style="color:#CE9178"> run</span><span style="color:#CE9178"> build</span></span></code></pre><h2>项目结构</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>.</span></span>
<span class="line"><span>├── App.tsx           # 主应用组件</span></span>
<span class="line"><span>├── uplot-wrapper.tsx # 图表组件封装</span></span>
<span class="line"><span>├── types.ts          # 类型定义</span></span>
<span class="line"><span>├── index.html        # 应用入口HTML</span></span>
<span class="line"><span>├── index.tsx         # React渲染入口</span></span>
<span class="line"><span>├── vite.config.ts    # Vite配置</span></span>
<span class="line"><span>├── lib/              # 第三方库</span></span>
<span class="line"><span>├── img/              # 图片资源</span></span>
<span class="line"><span>└── README.md</span></span></code></pre>]]></content>
    <category term="csv"/>
    <category term="time-series"/>
    <category term="annotation"/>
    <category term="nanoedgeai"/>
  </entry>
  <entry>
    <title>物联网 iot 低功耗 电池寿命计算器 物联网设备电池寿命估算工具 - 根据电池容量、待机电流和周期性负载计算设备续航时间</title>
    <link href="https://createskyblue.github.io/posts/embedded-battery-lifetime-calculator/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/embedded-battery-lifetime-calculator/</id>
    <published>2026-03-02T00:00:00.000Z</published>
    <updated>2026-03-02T00:00:00.000Z</updated>
    <summary>电池寿命计算器 - 计算电池物联网设备续航时间</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>电池寿命计算器</h1>
<p>一个用于估算电池续航时间的网页工具，适用于物联网设备、低功耗嵌入式系统等场景。</p>
<div style="margin: 16px 0;">
  <iframe src="https://createskyblue.github.io/embedded-battery-lifetime-calculator/" title="嵌入式设备电池寿命计算器" loading="lazy" data-fallback-image="#battery-demo-fallback" onload="this.style.display='block';document.querySelector(this.dataset.fallbackImage).style.display='none'" onerror="this.style.display='none';document.querySelector(this.dataset.fallbackImage).style.display='block'" style="width: 100%; height: 760px; border: 1px solid #dbe2ec; border-radius: 8px; background: #f5f7fb;"></iframe>
  <img id="battery-demo-fallback" src="/posts/embedded-battery-lifetime-calculator/battery-lifetime-cover.png" alt="电池寿命计算器封面图" style="display: none; width: 100%; height: auto; border: 1px solid #dbe2ec; border-radius: 8px;">
</div>

<p>源码：<a href="https://github.com/createskyblue/embedded-battery-lifetime-calculator" target="_blank" rel="noopener noreferrer">github.com/createskyblue/embedded-battery-lifetime-calculator</a></p>
<h2>链接</h2>
<ul>
<li><strong>GitHub 仓库</strong>: <a href="https://github.com/createskyblue/embedded-battery-lifetime-calculator">https://github.com/createskyblue/embedded-battery-lifetime-calculator</a></li>
<li><strong>在线演示</strong>: <a href="https://createskyblue.github.io/embedded-battery-lifetime-calculator/">https://createskyblue.github.io/embedded-battery-lifetime-calculator/</a></li>
</ul>
<h2>功能特点</h2>
<ul>
<li><strong>全局参数设置</strong>：配置电池容量(mAh)和待机电流(mA)</li>
<li><strong>多负载管理</strong>：添加多个周期性运行的负载组件</li>
<li><strong>智能计算</strong>：根据占空比自动计算平均电流消耗</li>
<li><strong>启用/禁用</strong>：通过复选框选择参与计算的负载项</li>
<li><strong>数据导入导出</strong>：支持项目配置的保存和加载</li>
</ul>
<h2>使用说明</h2>
<h3>1. 配置基础参数</h3>
<ul>
<li><strong>项目名称</strong>：为您的项目命名</li>
<li><strong>电池容量</strong>：输入电池总容量（毫安时）</li>
<li><strong>待机电流</strong>：设备空闲时的基础功耗（毫安）</li>
</ul>
<h3>2. 添加负载项</h3>
<p>点击&quot;添加项目&quot;按钮，配置每个负载的参数：</p>
<table>
<thead>
<tr>
<th>字段</th>
<th>说明</th>
<th>格式</th>
</tr>
</thead>
<tbody><tr>
<td>名称</td>
<td>负载名称，如&quot;电机&quot;、&quot;射频&quot;</td>
<td>任意文本</td>
</tr>
<tr>
<td>电流</td>
<td>该负载工作时的电流</td>
<td>mA</td>
</tr>
<tr>
<td>单次运行时间</td>
<td>每次运行持续时间</td>
<td>hh:mm:ss.ms</td>
</tr>
<tr>
<td>运行间隔</td>
<td>两次运行之间的时间间隔</td>
<td>dd:hh:mm:ss</td>
</tr>
</tbody></table>
<h3>3. 启用/禁用负载</h3>
<ul>
<li>勾选第一列的复选框启用该负载参与计算</li>
<li>表头全选框可一键启用/禁用所有项目</li>
<li>禁用的项目以灰色显示，不计入结果</li>
</ul>
<h3>4. 查看结果</h3>
<p>页面底部实时显示电池预计续航时间，格式为：X年X天X时X分X秒</p>
<h3>5. 保存与加载</h3>
<ul>
<li><strong>导出项目</strong>：将当前配置保存为 JSON 文件</li>
<li><strong>导入项目</strong>：加载之前保存的配置文件</li>
</ul>
<h2>计算原理</h2>
<p>电池寿命 = 电池容量 ÷ 总平均电流</p>
<p>其中总平均电流 = 待机电流 + Σ(各负载工作电流 × 占空比)</p>
<p>占空比 = 单次运行时间 ÷ 运行间隔</p>]]></content>
    <category term="iot"/>
    <category term="low-power"/>
  </entry>
  <entry>
    <title>LLM智能体工具调用原理</title>
    <link href="https://createskyblue.github.io/posts/LLM%E8%B0%83%E7%94%A8%E5%8E%9F%E7%90%86/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/LLM%E8%B0%83%E7%94%A8%E5%8E%9F%E7%90%86/</id>
    <published>2026-02-26T00:00:00.000Z</published>
    <updated>2026-02-26T00:00:00.000Z</updated>
    <summary>LLM智能体工具调用原理</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>LLM智能体工具调用原理</h1>
<p><img src="/posts/LLM调用原理/PixPin_2026-02-26_22-39-10.webp" alt="alt text"></p>
<h2>测试环境</h2>
<ul>
<li>用户提示词：<code>读取 被加数.txt 和 加数.txt 把加法结果写入到 答案.txt</code></li>
<li>测试脚本：<a href="https://github.com/createskyblue/nanocode_openai_compat">https://github.com/createskyblue/nanocode_openai_compat</a></li>
<li>使用<code>openai</code>库</li>
</ul>
<p>被加数.txt文件内容</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>2</span></span></code></pre><p>加数.txt文件内容</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>1</span></span></code></pre><h2>工具列表示例（截取部分）</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">[</span></span>
<span class="line"><span style="color:#D4D4D4">  {</span></span>
<span class="line"><span style="color:#9CDCFE">    "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"function"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">    "function"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">      "name"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"edit"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "description"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"Replace old with new in file (old must be unique unless all=true)"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "parameters"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">        "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"object"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">        "properties"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">          "path"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">            "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"string"</span></span>
<span class="line"><span style="color:#D4D4D4">          },</span></span>
<span class="line"><span style="color:#9CDCFE">          "old"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">            "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"string"</span></span>
<span class="line"><span style="color:#D4D4D4">          },</span></span>
<span class="line"><span style="color:#9CDCFE">          "new"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">            "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"string"</span></span>
<span class="line"><span style="color:#D4D4D4">          },</span></span>
<span class="line"><span style="color:#9CDCFE">          "all"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">            "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"boolean"</span></span>
<span class="line"><span style="color:#D4D4D4">          }</span></span>
<span class="line"><span style="color:#D4D4D4">        },</span></span>
<span class="line"><span style="color:#9CDCFE">        "required"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#CE9178">          "path"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#CE9178">          "old"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#CE9178">          "new"</span></span>
<span class="line"><span style="color:#D4D4D4">        ]</span></span>
<span class="line"><span style="color:#D4D4D4">      }</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">  },</span></span>
<span class="line"><span style="color:#D4D4D4">]</span></span></code></pre><h2>LLM节点调用（OpenAI兼容接口）</h2>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955"># 调用API</span></span>
<span class="line"><span style="color:#D4D4D4">response = </span><span style="color:#569CD6">self</span><span style="color:#D4D4D4">.client.chat.completions.create(</span></span>
<span class="line"><span style="color:#9CDCFE">    model</span><span style="color:#D4D4D4">=</span><span style="color:#569CD6">self</span><span style="color:#D4D4D4">.model,</span></span>
<span class="line"><span style="color:#9CDCFE">    messages</span><span style="color:#D4D4D4">=[{</span><span style="color:#CE9178">"role"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"system"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"content"</span><span style="color:#D4D4D4">: </span><span style="color:#569CD6">self</span><span style="color:#D4D4D4">.system_prompt}]</span></span>
<span class="line"><span style="color:#D4D4D4">    + </span><span style="color:#569CD6">self</span><span style="color:#D4D4D4">.messages,</span></span>
<span class="line"><span style="color:#9CDCFE">    tools</span><span style="color:#D4D4D4">=</span><span style="color:#569CD6">self</span><span style="color:#D4D4D4">.tools,</span></span>
<span class="line"><span style="color:#9CDCFE">    tool_choice</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"auto"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">    stream</span><span style="color:#D4D4D4">=stream,</span></span>
<span class="line"><span style="color:#D4D4D4">)</span></span></code></pre><h2>第一轮</h2>
<p><img src="/posts/LLM调用原理/PixPin_2026-02-26_18-45-51.webp" alt="alt text"></p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "id"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"chatcmpl-3111d54f-45eb-9bea-8a4e-1813bac62f9b"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "object"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"chat.completion"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "created"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">1772106543</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "model"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"qwen3.5-plus"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "choices"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">    {</span></span>
<span class="line"><span style="color:#9CDCFE">      "index"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "message"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">        "role"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"assistant"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">        "content"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">""</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">        "tool_calls"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">          {</span></span>
<span class="line"><span style="color:#9CDCFE">            "id"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"call_7adf7a6935924e84b8f9726e"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"function"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "function"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">              "name"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"read"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">              "arguments"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"{</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">path</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">: </span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">被加数.txt</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">}"</span></span>
<span class="line"><span style="color:#D4D4D4">            }</span></span>
<span class="line"><span style="color:#D4D4D4">          },</span></span>
<span class="line"><span style="color:#D4D4D4">          {</span></span>
<span class="line"><span style="color:#9CDCFE">            "id"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"call_669d0d02ad5f48c2a2073986"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"function"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "function"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">              "name"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"read"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">              "arguments"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"{</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">path</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">: </span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">加数.txt</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">}"</span></span>
<span class="line"><span style="color:#D4D4D4">            }</span></span>
<span class="line"><span style="color:#D4D4D4">          }</span></span>
<span class="line"><span style="color:#D4D4D4">        ]</span></span>
<span class="line"><span style="color:#D4D4D4">      },</span></span>
<span class="line"><span style="color:#9CDCFE">      "finish_reason"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"tool_calls"</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">  ]</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h2>第二轮</h2>
<p><img src="/posts/LLM调用原理/PixPin_2026-02-26_18-45-58.webp" alt="alt text"></p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "id"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"chatcmpl-93aedd25-7a56-9827-9837-d37142ab349b"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "object"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"chat.completion"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "created"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">1772106544</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "model"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"qwen3.5-plus"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "choices"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">    {</span></span>
<span class="line"><span style="color:#9CDCFE">      "index"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "message"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">        "role"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"assistant"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">        "content"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"被加数是 2，加数是 1，结果是 3。我将把结果写入 答案.txt。</span><span style="color:#D7BA7D">\n</span><span style="color:#CE9178">&#x3C;/think></span><span style="color:#D7BA7D">\n\n</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">        "tool_calls"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">          {</span></span>
<span class="line"><span style="color:#9CDCFE">            "id"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"call_d97a09ce53c5441888da2331"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "type"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"function"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">            "function"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">              "name"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"write"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">              "arguments"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"{</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">path</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">: </span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">答案.txt</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">, </span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">content</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">: </span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">3</span><span style="color:#D7BA7D">\"</span><span style="color:#CE9178">}"</span></span>
<span class="line"><span style="color:#D4D4D4">            }</span></span>
<span class="line"><span style="color:#D4D4D4">          }</span></span>
<span class="line"><span style="color:#D4D4D4">        ]</span></span>
<span class="line"><span style="color:#D4D4D4">      },</span></span>
<span class="line"><span style="color:#9CDCFE">      "finish_reason"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"tool_calls"</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">  ]</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><h3>第三轮</h3>
<p><img src="/posts/LLM调用原理/PixPin_2026-02-26_20-00-15.webp" alt="alt text"></p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#9CDCFE">  "id"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"chatcmpl-d3c05ff1-b30f-9d5b-a51e-fe59faf44437"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "object"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"chat.completion"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "created"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">1772106546</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "model"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"qwen3.5-plus"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "choices"</span><span style="color:#D4D4D4">: [</span></span>
<span class="line"><span style="color:#D4D4D4">    {</span></span>
<span class="line"><span style="color:#9CDCFE">      "index"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">      "message"</span><span style="color:#D4D4D4">: {</span></span>
<span class="line"><span style="color:#9CDCFE">        "role"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"assistant"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">        "content"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"已完成。被加数 2 加上加数 1 等于 3，结果已写入 答案.txt。"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">        "tool_calls"</span><span style="color:#D4D4D4">: []</span></span>
<span class="line"><span style="color:#D4D4D4">      },</span></span>
<span class="line"><span style="color:#9CDCFE">      "finish_reason"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"stop"</span></span>
<span class="line"><span style="color:#D4D4D4">    }</span></span>
<span class="line"><span style="color:#D4D4D4">  ]</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre>]]></content>
    <category term="LLM"/>
    <category term="AGENT"/>
  </entry>
  <entry>
    <title>关于</title>
    <link href="https://createskyblue.github.io/posts/about/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/about/</id>
    <published>2026-02-24T00:00:00.000Z</published>
    <updated>2026-02-24T00:00:00.000Z</updated>
    <summary>关于 &amp; 联系我</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>关于</h1>
<p>欢迎来到我的博客！</p>
<h2>联系方式</h2>
<ul>
<li><strong>GitHub</strong>: <a href="https://github.com/createskyblue">https://github.com/createskyblue</a></li>
<li><strong>邮箱</strong>: <a href="mailto:createskyblue@outlook.com">createskyblue@outlook.com</a></li>
</ul>
<p>如果你有任何问题或建议，欢迎通过以上方式与我联系！</p>
<h2>关于博客技术栈</h2>
<p>Vue3 + Vibe Coding魔法</p>]]></content>
    <category term="about"/>
    <category term="contact"/>
  </entry>
  <entry>
    <title>ESP32-C3 + INA228：给 PAR20-4H 线性电源加上网页控制台与微安级无线采样</title>
    <link href="https://createskyblue.github.io/posts/par20-4h-ina228-wireless-sampler/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/par20-4h-ina228-wireless-sampler/</id>
    <published>2025-07-20T00:00:00.000Z</published>
    <updated>2025-07-20T00:00:00.000Z</updated>
    <summary>PAR20-4H 控制板加 INA228 无线采样：网页控制台、微安精度回显</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<blockquote>
<p>版本 V1.0 · 日期 2025-07-20 · <a href="mailto:createskyblue@outlook.com">createskyblue@outlook.com</a></p>
</blockquote>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/cover.webp" alt="PAR20-4H 电源控制板 + INA228 无线采样器 硬件照片"></p>
<h2>简介</h2>
<p>这是一个基于 ESP32-C3 开发的控制板，用于控制 PAR20-4H 电源，并通过外置的 INA228 芯片实现电压、电流的精准采集与电能统计功能。</p>
<ul>
<li>可搭配PAR20系列可调电源使用：通过6芯电话线连接电源背面通讯接口。</li>
<li>亦可单独作为INA228无线采样板进行电能采样、功率统计。<ul>
<li>可通过网页访问和控制PAR20系列线性电源</li>
<li>设置“输出电压”和“输出电流”参数</li>
<li>基于INA228的“电压”、“电流”（自动换挡）、“功率”、“累计电能”回显</li>
<li>提供4组设置“工作区”，可以一键调用和保存设置</li>
<li>每组“工作区”拥有“普通精度”和“微安精度”2种不同的电流设置</li>
<li>“CV恒压”和“CC恒流”显示</li>
<li>实时“电压”、“电流”、“功率”图表</li>
<li>图表区间统计分析功能<ul>
<li>“电压”：平均值、最小值、最大值</li>
<li>“电流”：平均值、最小值、最大值</li>
<li>“功率”：平均值、最小值、最大值</li>
<li>“电量”：uAh、mWh</li>
<li>“时间统计”：选中区间经过时间、选中区间的频率、采样点数量</li>
</ul>
</li>
<li>图表数据“导入”和“导出” JSON格式</li>
</ul>
</li>
<li>JustFloat Over UDP：可以通过 “Vofa+”上位机连接设备的UDP端口7777使用“JustFloat协议”解码报文，并绑定到图表控件显示<ul>
<li>*上报数据包频率1kHz，实际数据采样率66.6Hz</li>
</ul>
</li>
</ul>
<p>可定制开发、二次开发，<a href="mailto:createskyblue@outlook.com">createskyblue@outlook.com</a></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image2.webp" alt="Web控制面板界面截图"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image3.webp" alt="使用第三方“Vofa+”上位机解析“JustFloat Over UDP”报文并绑定到图表输出"></p>
<h2>控制板硬件介绍</h2>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image4.webp" alt="硬件框架图"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image5.webp" alt="PCB图"></p>
<h2>控制板功能点介绍</h2>
<h3>WEB控制面板</h3>
<h4>WIFI配网</h4>
<ol>
<li>第一次烧录固件后，设备会打开名为“TEXIO(KENWOOD) PAR20-4H”默认密码为“PAR20-4H”的Wifi 2.4G热点，连接后会自动跳转到控制面板网页。</li>
<li>如果没有跳转请手动打开<a href="http://192.168.4.1">http://192.168.4.1</a></li>
<li>点击控制网页右上角的“小齿轮”图标，打开设置面板</li>
<li>在&quot;STA设置&quot;设置无线路由器热点信息（注意，因安全原因面板不会显示已经设置成功的密码），参考“设备设置示意图”</li>
<li>保存设置，然后手动重启设备</li>
</ol>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image6.webp" alt="网页面板-设备设置-AP设置 界面截图"></p>
<h4>INA228校准设置</h4>
<p>1.完成章节“WIFI配网”的前3个步骤</p>
<p>2.打开“INA228设置”标签页</p>
<p>3.根据实际情况设置“采样电阻”和“最大电流”</p>
<p>4.保存设置后手动重启设备使校准设置生效</p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image7.webp" alt="网页面板-设备设置-INA228设置 界面截图"></p>
<h4>设置可调电源电压</h4>
<ul>
<li>●方法一<ul>
<li>点击“蓝色电压卡片”的&quot;/&quot;分割符右侧数字输入区（红色方框处）</li>
<li>输入浮点数设置值</li>
</ul>
</li>
<li>●方法二：<ul>
<li>鼠标悬停在“蓝色电压卡片”的&quot;/&quot;分割符右侧数字输入区（红色方框处）</li>
<li>滚动鼠标滚轮微调设置值</li>
</ul>
</li>
</ul>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image8.webp" alt="网页面板-电压卡片 界面截图"></p>
<h4>设置可调电流</h4>
<ul>
<li>●方法一<ul>
<li>点击“绿色电流卡片”的&quot;/&quot;分割符右侧数字输入区（红色方框处）</li>
<li>输入浮点数设置值</li>
</ul>
</li>
<li>●方法二：<ul>
<li>鼠标悬停在“绿色电流卡片”的&quot;/&quot;分割符右侧数字输入区（红色方框处）</li>
<li>滚动鼠标滚轮微调设置值</li>
</ul>
</li>
</ul>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image9.webp" alt="网页面板-电流卡片 界面截图"></p>
<h4>手动拉取PAR20-4H电源配置 以及 同步网页面板状态</h4>
<p>点击“功率卡片”，会自动执行面板同步，稍等片刻</p>
<p>如果同步失败</p>
<ul>
<li>检查电路焊接，尤其是RS232芯片、ESP32芯片和5303-6P6C插座</li>
<li>检查通讯用的电话线是否为6芯电话线</li>
<li>检查6芯电话线是否正确连接到PAR20-4H电源背面的“IN接口”</li>
</ul>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image10.webp" alt="网页面板-功率卡片 界面截图"></p>
<h4>记忆工作区</h4>
<p>默认有3个记忆区分别对应物理面板的“1”、“2”、“3”记忆区</p>
<p>另外特别地“当前工作区”代表物理面板的“VARIABLE”工作区</p>
<p>网页面板界面的每一个“工作区色块”会显示如下内容</p>
<ul>
<li>工作区名称（记忆2）</li>
<li>输出电压设置（5V）</li>
<li>输出电流设置(2A/1A)<ul>
<li>其中2A代表普通精度输出的电流设置</li>
<li>其中1A代表微安精度输出的电流设置</li>
</ul>
</li>
</ul>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image11.webp" alt="网页面板-记忆工作区 界面截图"></p>
<p>*当前选中了“记忆2”</p>
<h5>切换工作区</h5>
<p>点击任意的“记忆工作区”，“电压设置值”和“电流设置值”会自动切换到对应的“记忆工作区”</p>
<h5>修改工作区</h5>
<p>选中工作区后再设置电流和电压，会自动记录到当前工作区中</p>
<h5>持久化/保存 工作区</h5>
<p>PAR20-4H的原版日文扫描版手册中未提供显式的保存工作区指令。不过经过实践发现，只要开机的时间足够久PAR20-4H会自动持久化和保存当前的工作区设置。</p>
<p>如果用户希望手动显示操作保存，可以按照以下步骤执行</p>
<ol>
<li>确保“物理面板”已解锁，参考“解锁物理面板”</li>
<li>选择并按下物理面板对应的记忆工作区，比如“1”、“2”、“3”和“VARIABLE”工作区</li>
<li>按下物理面板的“MEMORY”键，“MEMORY”指示灯亮起</li>
<li>如果用户希望保存比如“1”、“2”、“3”这三个工作区，则需要再次选择并按下物理面板中对应的工作区，比如比如“1”、“2”、“3”，“MEMORY”指示灯熄灭，完成并结束流程</li>
<li>如果用户希望保存“VARIABLE”工作区，则只需要再按下一次“MEMORY”键，“MEMORY”指示灯熄灭，完成并结束流程</li>
</ol>
<h3>操作按钮</h3>
<p>为防止用户操作与自动刷新产生状态回退，通过比较操作时间戳实现最后写入优先（LWW）策略，确保前端状态与预期一致。</p>
<p>WEB操作区的均已进行“基于时间戳的竞态条件处理”。网页面板显示状态为实际物理面板状态。</p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image12.webp" alt="网页面板-操作按钮组 界面截图"></p>
<h5>解锁物理面板按钮</h5>
<p>当使用网页面板访问和操作PAR20-4H时，PAR20-4H的物理操作面板会被自动锁定防止误操作，届时物理面板“KEY LOCK / LOCAL”指示灯亮起，物理面板无法操作，需要执行下面任意步骤解锁物理面板</p>
<ul>
<li>方法一：用“顶针”按下物理面板“KEY LOCK / LOCAL”下的复位孔，如果物理面板“KEY LOCK / LOCAL”指示灯熄灭代表解锁成功</li>
<li>方法二：点击Web面板的“物理面板解锁按钮”，如果物理面板“KEY LOCK / LOCAL”指示灯熄灭代表解锁成功</li>
</ul>
<h5>输出保护按钮</h5>
<p>当“输出保护”激活时，“输出保护”按钮呈现橘黄色高亮，同时文本提示“输出保护已锁定”，届时切换“记忆工作区”时会导致“电源输出”被自动关闭。</p>
<p>如果用户希望切换“记忆工作区”时，电源继续保持输出，需要关闭、“输出保护”按钮。</p>
<h5>微安精度按钮</h5>
<p>当“微安精度”按钮呈现绿色高亮，同时文本提示“微安精度已激活”时，代表微安精度已开启，当前“电流输出设置”会切换到当前“记忆工作组”的“微安电流设置”。</p>
<p>微安模式和普通电流模式的区别</p>
<ul>
<li>微安模式精度达到100ua级别，但是可调范围是0~1A</li>
<li>普通精度模式精度是1ma级别，可调范围是0~4A</li>
</ul>
<h5>实时刷新按钮</h5>
<p>是否定时自动刷新电流电压功率等数据的显示，以及是否启用图表自动更新</p>
<h5>OVP过压保护</h5>
<ul>
<li>OVP是过压保护的缩写，如果PAR20-4H触发过压保护，会有响应的提示；</li>
<li>当过压保护触发时，需要先解锁控制面板，然后按下物理面板的“A”或者”V”设置键，关闭OVP过压警告。</li>
<li>如果需要设置OVP的阈值，需要在解锁状态下按下物理面板的“OVP”按键</li>
</ul>
<h5>CV恒压 &amp; CC恒流 工作模式</h5>
<ul>
<li>通过INA228回读的电压和电流值，以及当前的输出电压和电流设置值，自动判断当前电源是否处于CV恒压或者CC恒流模式。</li>
<li>尽管PAR20-4H提供了查询API进行查询，但是该API需要1秒种的响应时间，同时会影响物理面板的显示速率，因此没有使用这种原生的方法查询CV和CC模式，而是借助INA228进行CC/CV模式判断。</li>
</ul>
<h5>输出按钮</h5>
<p>开启或关闭电源输出</p>
<p>当“输出”按钮呈现绿色高亮，同时文本提示“输出已激活”时，代表电源输出已开启。</p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image13.webp" alt="网页面板界面截图"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image14.webp" alt="网页面板界面截图"></p>
<h4>曲线图表功能</h4>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image15.webp" alt="网页面板-曲线图表 界面截图"></p>
<h5>图表区间统计</h5>
<ul>
<li>图表区间统计分析功能<ul>
<li>“电压”：平均值、最小值、最大值</li>
<li>“电流”：平均值、最小值、最大值</li>
<li>“功率”：平均值、最小值、最大值</li>
<li>“电量”：uAh、mWh</li>
<li>“时间统计”：选中区间经过时间、选中区间的频率、采样点数量</li>
</ul>
</li>
</ul>
<p>如何使用</p>
<ol>
<li>确保当前的图表必须已经有实际的数据可供进行区间分析</li>
<li>选择并使用“网页面板-右上角工具栏-横向选择”工具</li>
<li>从左到右框选统计区域</li>
<li>图标上方会显示区间分析统计结果</li>
</ol>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image16.webp" alt="网页面板-曲线图表-右上角工具栏-横向选择 界面截图"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image17.webp" alt="网页面板-曲线图表-选中区域分析统计结果 界面截图"></p>
<h5>其他图表设置</h5>
<ul>
<li>刷新间隔</li>
<li>数据点个数</li>
<li>清空数据点</li>
<li>导出数据为JSON</li>
<li>导入JSON数据</li>
</ul>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image18.webp" alt="网页面板-其他图表设置 界面截图"></p>
<h2>附录</h2>
<h3>PAR-H系列线性电源介绍</h3>
<p>PAR-H系列是搭载了微计算机的多功能、高可靠性电压远程感应端子附带的高分辨率CV/CC电源。从系统组装时的GP-IB或主控控制开始，对应各种远程控制，作为自动化测量系统电源或模拟试验用电源，能够广泛满足各种需求的多功能电源。 </p>
<p>此外，PAR-H L型还具备电子负载功能，可以轻松进行电池的充放电试验等。</p>
<ul>
<li>利用电压远程感应端子，可以将因线材的阻抗引起的电压降降到非常小的程度。</li>
<li>输出电压和电流通过7段绿色LED同时以数字形式显示电压和电流，因此很容易确认输出值或设定值。</li>
<li>对于使用频率较高的电压和电流的各个设定值，最多可以预设3点（V·A对），从而实现快速的输出切换。</li>
<li>注重操作性，条件设定只需一键开关，所有设定值都可以通过旋转编码器进行一键控制和简单操作。此外，输出电压和电流分别可以以1mV、1mA单位（0.1mA量程时为0.1mA单位）进行设定，是一种高精度方式。</li>
<li>通过键锁定功能，可以强制固定所有的设定值，因此在实验中即使稍微离开座位也不用担心被他人误改设定值。</li>
<li>通过保护功能，在输出为ON时，如果切换预设键或可变键，则输出会被遮断，防止预期外的电压和电流输出。</li>
<li>通过节能设计，机器内部的温度上升被极力抑制，因此不需要风扇，没有噪音和振动，也没有吸入灰尘的担忧。</li>
<li>各种设定值可以通过备份功能进行记忆，因此无需每次打开电源开关时重新设定。</li>
<li>由于具备OVP（过电压保护）、OCP（过电流保护）、OHP（过热保护）功能，即使本机无法控制输出，也能尽量减少对负载的损害。</li>
</ul>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image19.webp" alt="PAR-H系列线性电源 产品照片"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image20.webp" alt="PAR-H系列线性电源 产品照片"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image21.webp" alt="PAR-H系列线性电源 产品照片"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image22.webp" alt="PAR-H系列线性电源 产品照片"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image23.webp" alt="PAR-H系列线性电源 产品照片"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image24.webp" alt="PAR-H系列线性电源 产品照片"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image25.webp" alt="PAR-H系列线性电源 产品照片"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image26.webp" alt="PAR-H系列线性电源 产品照片"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image27.webp" alt="PAR-H系列线性电源 产品照片"></p>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image28.webp" alt="PAR-H系列线性电源 产品照片"></p>
<h3>INA228介绍</h3>
<p><img src="/posts/par20-4h-ina228-wireless-sampler/img/image29.webp" alt="INA228 介绍 示意图"></p>]]></content>
    <category term="嵌入式"/>
    <category term="ESP32"/>
    <category term="物联网"/>
    <category term="传感器"/>
    <category term="数据采集"/>
  </entry>
  <entry>
    <title>STM32 虚拟串口固定发 64 字节，电脑端能正好收到 64 字节吗</title>
    <link href="https://createskyblue.github.io/posts/stm32-cdc-64byte-packet-boundary/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/stm32-cdc-64byte-packet-boundary/</id>
    <published>2026-09-21T00:00:00.000Z</published>
    <updated>2026-09-21T00:00:00.000Z</updated>
    <author><name>匿名</name></author>
    <content type="html"><![CDATA[<h1>STM32 虚拟串口固定发 64 字节，电脑端能正好收到 64 字节吗</h1>
<p><strong>结论</strong>：不能保证。CDC 是字节流，不是消息流，它不保证一次给你 64 字节，可能粘包，也可能分包。但它反过来也不会随机吞掉其中某几个字节。</p>
<p>三层各一句话：</p>
<ul>
<li><strong>USB 总线</strong>：全速批量一包最大 64 字节，带 CRC 校验和重传。STM32 连续发的 64 字节，若出错也是整 64 字节一起丢，不会只少几个字节，因为坏包不会被部分交付。</li>
<li><strong>驱动</strong>：CDC 把 USB 管道伪装成传统串口，传统串口没有包的概念，驱动把字节拼进内核缓冲区、抹平包边界，攒满一批或收短包（含 ZLP）才上抛，一次给多少由驱动决定，可能粘连也可能拆开。</li>
<li><strong>应用</strong>：串口 API 语义是&quot;返回当前已到的若干字节&quot;，没有&quot;按包读&quot;的接口，换驱动、加 HUB、过隔离器，行为都可能变。</li>
</ul>
<p>所以：USB 不会随机吃掉某几个字节，除非你的软件自己没处理好；但 CDC 也不保证原样一次性给你 64 字节。指望它每次精确 64 字节是靠不住的，对齐只是现象，不是契约。</p>]]></content>
  </entry>
</feed>
