<?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-07-11T00: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>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>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>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 芯片通过 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>PCD（读卡器）必须持续轮询才能维持通信，每次轮询本身就是一次数据交换机会。一个实用的做法是双方各维护一个发送 FIFO，每次帧交互时顺带捎上自己的 FIFO 数据：</p>
<p><strong>PCD → PICC 帧</strong>：</p>
<table>
<thead>
<tr>
<th>字段</th>
<th>字节</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>命令码</td>
<td>1</td>
<td>自定义值（如 0xF0），标识这是数据交换帧</td>
</tr>
<tr>
<td>序号/确认</td>
<td>1</td>
<td>高 4 位：本次发送的序号（0-15）；低 4 位：已收到的对方上一帧序号（ACK）</td>
</tr>
<tr>
<td>数据长度</td>
<td>1</td>
<td>本次携带的 FIFO 数据字节数，无数据时填 0</td>
</tr>
<tr>
<td>数据</td>
<td>变长</td>
<td>FIFO 中取出的数据，长度由上字段决定</td>
</tr>
<tr>
<td>CRC</td>
<td>2</td>
<td>ISO 14443-3A 标准 CRC_A</td>
</tr>
</tbody></table>
<p><strong>PICC → PCD 响应帧</strong>（不含命令码，其余结构相同）：</p>
<table>
<thead>
<tr>
<th>字段</th>
<th>字节</th>
<th>说明</th>
</tr>
</thead>
<tbody><tr>
<td>序号/确认</td>
<td>1</td>
<td>高 4 位：本次发送的序号；低 4 位：已收到的对方上一帧序号（ACK）</td>
</tr>
<tr>
<td>数据长度</td>
<td>1</td>
<td>本次携带的 FIFO 数据字节数，无数据时填 0</td>
</tr>
<tr>
<td>数据</td>
<td>变长</td>
<td>FIFO 中取出的数据</td>
</tr>
<tr>
<td>CRC</td>
<td>2</td>
<td>ISO 14443-3A 标准 CRC_A</td>
</tr>
</tbody></table>
<p>序号和确认的设计：</p>
<ul>
<li><strong>一字节掰两半</strong>：高 4 位是自己的序号（0-15 循环），低 4 位是对上一帧的确认。NFC 单帧数据量有限，每字节都要省着用，合并到一个字节里零额外开销。</li>
<li><strong>捎带确认</strong>：第一帧的确认在第二帧里完成。发送方每发出一帧，同时告诉对方&quot;你上一帧我收到了&quot;。如果序号连续递增且超时没收到 ACK，发送方就知道上一帧丢了。</li>
<li>PICC（卡模拟）响应帧不需要命令码——PICC（卡模拟）是被动方，收到 PCD（读卡器）命令后回复，双方都知道当前处于哪一轮交互。</li>
</ul>
<p>关键点：</p>
<ul>
<li><strong>没有数据也要轮询</strong>。长度字段填 0，仍然发完整帧，序号照常递增、ACK 照常捎带。通道始终保持活跃，上层随时可以塞数据进来。</li>
<li><strong>每一轮都是双向的</strong>。PCD（读卡器）发一帧带走自己的 FIFO 数据，PICC（卡模拟）回复时顺带捎上自己的 FIFO 数据。一轮轮询完成一次双向交换。</li>
<li>PCD（读卡器）侧在主循环里反复调用 <code>nfca_pcd_communicate</code>（每次调用会阻塞直到通信完成或超时），PICC（卡模拟）侧在 <code>data_handler</code> 里识别 0xF0 命令码，读出对方数据、填入自己的 FIFO 数据、返回响应。</li>
</ul>
<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>断连最棘手的情况发生在发送中途——已经从 FIFO 取出了数据，但 NFC 发送失败，数据不能丢，又不能塞回 FIFO。用暂存区解决：</p>
<ol>
<li>从内部 FIFO 取出一定量数据，放到一个<strong>暂存区（staging buffer）</strong></li>
<li>用暂存区里的数据组帧，尝试 NFC 发送</li>
<li>发送成功 → 暂存区清空，下次从 FIFO 取新数据</li>
<li>发送失败（超时/断连）→ 暂存区数据保留，下次轮询时直接用暂存区里的数据重试，无需动 FIFO</li>
</ol>
<p>这样数据流向是单向的：<strong>FIFO → 暂存区 → NFC 发送</strong>。数据从 FIFO 取出后只往前走，失败就停在暂存区里等下次重试，不需要回退。</p>
<p>至于链路层的丢包或 CRC 校验失败，不在这一层处理——保持通信层轻量，数据完整性校验和重传策略交给上层应用决定。</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>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>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>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>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 单片机不该继续作为新项目的默认选择。它当然还能点灯、扫按键、跑简单控制逻辑，也能在一些历史产品里继续维护；但新项目继续围着 51 做架构，很多时候已经省不了钱，只是在把工程风险往后推。</p>
<p>成本敏感场景已经有大量 RISC-V MCU，也有成熟的 ARM Cortex-M0/M0+ MCU。它们价格低，外设新，工具链新，C 语言支持正常，调试体验也完整得多。新项目继续用 51，常见理由是便宜、熟悉、库存稳定。放在今天，这些理由都需要重新核算。</p>
<h2>省下来的不一定是成本</h2>
<p>选型时最容易看到的是 BOM 成本。芯片单价、外围器件、库存替换，这些数字摆在表格里，很容易让人觉得 51 还有优势。</p>
<p>真正进入工程以后，成本会从另一些地方回来：</p>
<ul>
<li>编译器和 IDE 落后，自动化构建、静态检查、现代调试流程都不好接。</li>
<li>C99 支持不完整，代码组织、类型定义、跨平台复用都要降级。</li>
<li>内存空间割裂，<code>code</code>、<code>data</code>、<code>idata</code>、<code>xdata</code> 这类限定会进入业务代码。</li>
<li>主频和寻址模型受限，稍微复杂一点的实时逻辑就要手工抠时序。</li>
<li>外设能力弱，定时器、输入捕获、DMA、硬件滤波这类资源经常不够用。</li>
<li>AI 辅助生成代码时，更容易混入不同厂商和不同编译器的旧写法。</li>
<li>维护人员越来越少，新人接手时要先学习一套过时工具链。</li>
</ul>
<p>这些成本不会出现在芯片单价里，但会出现在调试时间、返工次数、现场问题和后续维护里。</p>
<h2>低成本要靠大出货兑现</h2>
<p>很多人选择 51，是为了省 BOM。这个逻辑成立有一个前提：产品能稳定量产，能形成足够大的出货量。单片机省下来的几毛钱，只有乘上足够大的数量，才会变成真正的利润。</p>
<p>这里有一个常见陷阱：为了省几毛钱选了余量很小的平台，后面系统稳定性、现场问题、售后返修、版本补丁把放量节奏拖住。产品出不去量，低单价优势就兑现不了。成本没有真的省下来，市场节奏也被拖过去了。</p>
<p>也就是说，低成本芯片本来是为大出货服务的。如果底层平台让产品不稳定，项目进不了大出货阶段，就会同时失去两件事：成本优势拿不到，销售规模也做不起来。</p>
<h2>内存架构会污染 C 代码</h2>
<p>51 的内存架构也是一个长期坑点。很多 51 体系把程序空间、片内数据空间、间接寻址数据空间、外部数据空间分成不同区域，常见写法会出现 <code>code</code>、<code>data</code>、<code>idata</code>、<code>xdata</code> 这样的限定。</p>
<p>典型 8051/8052 体系里，片内 RAM 只有几百字节。常见结构是低 128 字节支持直接寻址和间接寻址，高 128 字节只能走间接寻址，SFR 还占用另一套直接地址空间。后面扩出来的大 RAM，通常又落到 <code>xdata</code> 或类似扩展空间里。也就是说，真正适合快速直接访问的空间很小，剩下的空间要通过不同寻址路径访问。</p>
<p>这些空间不只是名字不同，访问方式、访问速度、指针模型也不同。放在 <code>data</code>、<code>idata</code>、<code>xdata</code>、<code>code</code> 里的同一份数据，最终生成的访问指令和执行成本可能完全不同。实时路径里本来只差几个机器周期，换一个内存空间就可能把时序边界踩穿。</p>
<p>这会让本来应该由编译器和链接器消化的细节，直接进入 C 代码设计：</p>
<ul>
<li>指针类型和访问空间绑定，普通指针、远指针、通用指针的成本不同。</li>
<li>片内直接访问空间很小，大缓冲区经常被迫放进间接寻址或扩展空间。</li>
<li>不同空间访问速度不同，ISR、查表、环形缓冲、通信收发都可能被影响。</li>
<li>通用指针为了兼容多个空间，会带来额外代码体积和运行开销。</li>
<li>数据放错区域以后，访问速度、代码体积、可重入性都可能变化。</li>
<li>库函数、结构体、缓冲区、查表数据都要考虑具体内存空间。</li>
<li>代码从 51 迁到现代 MCU，或者从现代 C 项目回迁到 51，都要处理大量内存限定。</li>
</ul>
<p>现代 MCU 上，绝大多数业务代码只需要关心 RAM、Flash、栈、堆和链接脚本边界。51 会把更底层的内存空间概念暴露到日常编码里，团队要持续为这套旧模型付认知成本，也要持续承担由访问差异带来的时序风险。</p>
<h2>AI 会放大生态碎片</h2>
<p>现在写代码已经大量依赖 AI 辅助。主流 ARM、RISC-V MCU 的 SDK、示例工程、论坛讨论、CI 配置和开源项目更多，训练数据更集中。51 生态的资料分散得多，厂商寄存器命名、头文件、编译器扩展、中断声明、内存限定写法都有差异。</p>
<p>即使把某个 51 型号的手册、示例库和头文件都喂给 AI，它也更容易犯这几类错：</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 兼容产品线上做了很多增强。</p>
<p>这篇文章讨论的判断对象是“2026 年新项目要不要继续默认选 51 兼容路线”。“某一颗 STC 芯片能不能完成某个具体小任务”是另一个问题。这两个问题不能混在一起。</p>
<p>STC 的反驳里其实已经承认了产品线分层：STC8G/STC8H 面向极低成本和简单控制，STC32G/Ai8051U 进入了和 ARM Cortex-M0/M0+ 同价位竞争的区间。这个分层正好支持本文的判断：极低成本小任务可以保留特例，新项目默认选型要回到现代 MCU 的完整比较。</p>
<p>厂商增强外设和主频，只能解决一部分硬件指标。它解决不了这些系统性问题：</p>
<ul>
<li>代码仍然要面对 8051/C51/C251/SDCC 相关方言和历史包袱。</li>
<li><code>code</code>、<code>data</code>、<code>idata</code>、<code>xdata</code> 这些内存空间概念仍然会影响代码组织。</li>
<li>旧项目、旧示例、论坛代码、厂商库、AI 生成代码之间仍然容易混写。</li>
<li>生态主流已经转向 ARM 和 RISC-V，新人、工具、CI、静态分析、调试经验都更集中在这些平台。</li>
<li>一旦项目复杂度上来，团队要为兼容路线持续付认知成本和验证成本。</li>
</ul>
<p><code>xdata</code> 变大、LARGE 模式、C251 的 NEAR/FAR/HUGE、库函数封装，都属于缓解手段。它们降低了手工分配内存空间的频率，但没有把 51 兼容生态变成普通的平坦 C 环境。只要代码还要关心编译模式、内存限定、指针模型、厂商头文件、库函数版本，认知成本就还在。</p>
<p>C251 + HUGE 模型可以让日常业务代码少碰 <code>data/idata/xdata/code</code>，这个改进可以承认。问题在于，这已经变成了一个特定芯片系列、特定编译器、特定内存模型、特定厂商资料闭环下的开发体验。工程选型要评估的正是这个闭环成本：团队成员能不能稳定复现，CI 能不能顺畅接入，静态分析能不能正常跑，第三方库能不能直接用，故障定位能不能依赖通用经验。</p>
<p>AI 辅助开发也是同一件事。STC 的说法是：喂完整手册、头文件、库函数说明、典型示例，生成准确率可以接受，跨型号、跨编译器时需要人工审核。这句话本身就说明了成本。现代主流 MCU 的优势在于默认资料更集中、工具链更统一、AI 更不容易把旧方言混起来。</p>
<p>论坛活跃、中文资料多、教学案例多，这些对入门和存量维护有价值。新产品工程还要看另一组指标：自动化构建、可移植性、团队招聘、跨平台库、长期维护、供应商切换、调试工具通用性。STC 在国内有生态，不会自动抵消 8051/80251 路线的迁移成本和专用工具链成本。</p>
<p>还有一个现实问题：如果一颗“现代 51”已经需要 1T、高主频、大 RAM、DMA、USB、CAN、I2S、硬件运算单元、仿真器、专用 IDE 和库函数来证明自己足够现代，那它和同价位现代 MCU 比的就不再是“51 简单便宜”这件事。它已经进入现代 MCU 的竞争区间了。</p>
<p>到了这个区间，选型就应该回到正常工程问题：谁的工具链更现代，谁的 C 支持更完整，谁的调试链路更稳定，谁的生态资料更集中，谁的长期维护成本更低。按这个标准看，ARM Cortex-M0/M0+ 和 RISC-V MCU 通常更干净。</p>
<p>所以 STC 新型号能用，不等于 51 兼容路线应该继续当默认选项。适合极低成本、强存量惯性、团队已经熟悉 STC 的项目，可以继续选。新项目从零评估时，把 51 兼容架构放在默认位置，仍然是在把历史包袱带进未来项目。</p>
<h2>外设余量也是工程余量</h2>
<p>很多 51 项目的核心问题来自余量太少。算力勉强够跑，定时器少一点，中断能力弱一点，IO 复用绕一点，调试工具差一点，每一项看起来都能靠软件补。</p>
<p>软件确实可以补，但补法经常带来新的边界：</p>
<ul>
<li>没有硬件双边沿捕获，就用中断切边沿模拟。</li>
<li>没有足够输入捕获通道，就把多路信号塞进同一个 ISR。</li>
<li>没有硬件滤波，就在中断里做脉宽判断。</li>
<li>没有 DMA，就靠 CPU 搬数据。</li>
<li>没有足够调试能力，就靠日志、示波器和猜测拼现场。</li>
</ul>
<p>这些办法在简单场景里能跑。系统规模、实时性要求、干扰环境、低功耗状态、输入路数一起上来以后，软件补硬件余量就会变成风险来源。</p>
<h2>从控制论看工程余量</h2>
<p>钱学森的工程控制论有一个很实用的视角：工程对象要放进反馈回路里看。产品能批量出货，单看芯片单价没有意义。扰动进来以后，系统需要被观测、被修正、被稳定拉回目标状态。</p>
<p>在这个视角下，CPU 主频、定时器、输入捕获、中断响应、RAM、调试链路，都是控制系统的裕度。裕度足，干扰只造成局部偏差；裕度太薄，小毛刺、低功耗切换、温漂、供应批次差异、现场安装差异，都可能被软件补丁和延迟反馈放大。</p>
<p>可观测性也很要命。现代 MCU 给你 SWD、trace、硬件捕获、DMA、统一内存模型，问题出现后还能定位。51 项目如果只能靠示波器、日志、猜测和反复试机，反馈链路就变长。反馈链路越长，项目调参越容易振荡：修一个现场问题，压出另一个边界；加一个过滤，漏掉另一个脉冲；补一个中断状态，挤掉别的实时任务。</p>
<p>可控性同样重要。硬件资源够，设计可以选择硬件捕获、硬件滤波、DMA 搬运、独立 ISR、低功耗状态保持。资源不够，软件只能把多个实时职责塞进同一条中断路径，控制输入就变少了，回旋空间也变小了。</p>
<p>所以从控制论看，一颗便宜芯片如果降低了系统的可观测性、可控性和稳定裕度，它的低单价只是一段开环计算。闭环成本会在调试、返修、延期和维护里补回来。</p>
<h2>技术债会持续计息</h2>
<p>工程要给自己留余量。算力余量、外设余量、定时器余量、调试余量，看起来都不直接出现在 BOM 上，但被省掉以后，不确定性不会消失，只会转成风险。</p>
<p>风险再拖下去，就会变成技术债。每一次改需求，要先问还能不能塞下；每一次加功能，要先问中断还能不能扛住；每一次现场异常，要先怀疑时序边界又被踩到；每一次换人维护，都要重新解释工具链和架构限制。</p>
<p>技术债的麻烦在于它会持续计息。早期为了省一点硬件余量，后面会用更多软件复杂度、测试成本、沟通成本和现场成本偿还。</p>
<h2>新项目别默认选 51</h2>
<p>历史 51 产品不必全部立刻重做。存量项目有认证、库存、产线、代码和售后约束，继续维护很正常。</p>
<p>但新项目不该再把 51 放在默认位置。默认选型应该从现代 RISC-V MCU、ARM Cortex-M0/M0+ MCU 或同级别现代单片机开始评估，再根据供货、成本、外设、功耗、工具链和团队经验收敛。</p>
<p>如果一个项目需要稳定输入捕获、低功耗唤醒、复杂通信、现场可调试性、长期维护，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>在 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>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><img src="/posts/antenna-control-terminal/img/cover.webp" alt="手持天线控制终端界面"></p>
<p>这是一个面向电调天线现场维护的手持控制终端，对接 UMTS（3G）基站 Node B 的 Iuant 接口和 RETAP 协议标准。软件部分负责把底层协议能力做成一台可以独立使用的本地工具：扫描总线设备、选择目标天线、执行校准、设置倾角、读取设备信息、从存储卡下发配置和固件。</p>
<p>这个项目不只是做菜单。菜单只是操作入口，软件部分还要把标准协议对接、多设备扫描、地址绑定、文件维护、USB 存储模式和现场反馈串成一套可用流程。</p>
<p>下面是完整的仿真界面总览，覆盖扫描、主菜单、校准、倾角、信息、文件、设置和英文界面。</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>

<h2>一、现场维护功能范围</h2>
<p><img src="/posts/antenna-control-terminal/img/sim/main-menu.webp" alt="主菜单界面"></p>
<p>终端服务现场维护，不以研发调试台为主要使用场景。用户需要拿着设备接上线缆，通过屏幕和按键完成维护动作。</p>
<table>
<thead>
<tr>
<th>现场动作</th>
<th>终端能力</th>
</tr>
</thead>
<tbody><tr>
<td>接入总线</td>
<td>扫描并列出可控制设备</td>
</tr>
<tr>
<td>选择设备</td>
<td>建立目标设备地址和连接关系</td>
</tr>
<tr>
<td>校准天线</td>
<td>触发 RETAP 校准流程并显示结果</td>
</tr>
<tr>
<td>设置倾角</td>
<td>根据设备能力限制输入范围，确认后下发命令</td>
</tr>
<tr>
<td>查看信息</td>
<td>读取型号、频段、增益、倾角范围和版本状态</td>
</tr>
<tr>
<td>下发配置</td>
<td>从存储卡选择文件并发送到目标设备</td>
</tr>
<tr>
<td>固件更新</td>
<td>从存储卡选择固件并执行下载流程</td>
</tr>
<tr>
<td>文件维护</td>
<td>通过存储卡放入配置文件和固件文件</td>
</tr>
<tr>
<td>USB 维护</td>
<td>插入电脑时进入存储模式，更新现场文件</td>
</tr>
<tr>
<td>本机设置</td>
<td>切换语言、输出电压和状态提示</td>
</tr>
</tbody></table>
<p>这一部分的重点是把 RETAP 协议能力变成现场可执行动作。现场人员不需要知道协议帧、命令码和连接时序，只需要从菜单里选择目标操作。</p>
<p><img src="/posts/antenna-control-terminal/img/sim/antenna-info-top.webp" alt="天线信息界面"></p>
<p><img src="/posts/antenna-control-terminal/img/sim/antenna-list.webp" alt="天线列表界面"></p>
<h2>二、天线扫描算法</h2>
<p>设备扫描是这个项目里比较重要的部分。总线上可能挂着多台设备，终端需要在不知道完整设备编号的情况下发现它们，并给它们分配可通信的短地址。</p>
<p>扫描过程按数字分支逐层尝试：</p>
<ol>
<li>候选列表先放入最低位的数字分支。</li>
<li>每次取出一个候选，构造一段匹配条件发起扫描。</li>
<li>如果收到有效响应，就把设备加入结果表，并尝试给它分配短地址。</li>
<li>如果出现校验冲突、未知响应或地址绑定失败，说明这个分支下可能有多台设备，需要继续展开下一层。</li>
<li>如果没有响应，这个分支直接剪掉。</li>
<li>候选列表清空后，扫描结束。</li>
</ol>
<p>这比完整穷举设备编号更适合资源受限的手持终端。终端根据响应结果判断当前分支下是否还有设备，只在必要分支上继续深入。扫描完成后，设备列表会直接变成菜单列表，用户选中某一项后，后续操作都围绕这个目标设备执行。</p>
<p>这套扫描流程也解决了一个现场工具常见问题：发现设备和控制设备不能割裂。扫描只找到编号还不够，终端还要立刻完成地址绑定、连接准备和结果缓存，主菜单才能直接进入校准、倾角、配置、固件这些动作。</p>
<p><img src="/posts/antenna-control-terminal/img/sim/calibration-confirm.webp" alt="校准确认界面"></p>
<p><img src="/posts/antenna-control-terminal/img/sim/calibration-success.webp" alt="校准成功界面"></p>
<h2>三、Iuant 和 RETAP 协议对接</h2>
<p>UMTS 基站系统里，Node B 通过 Iuant 接口管理远程电调天线。RETAP 负责承载电调天线相关的远程控制能力，例如设备发现、地址分配、连接、校准、倾角读取、倾角设置、信息读取、配置下发和固件下载。</p>
<p>这个项目做的是一个本地手持维护终端。它不代替基站主设备，定位是现场维护场景下的 Iuant/RETAP 关键控制工具，把标准协议调用包装成本机菜单动作。</p>
<p>项目里把协议处理分成两层：</p>
<table>
<thead>
<tr>
<th>层级</th>
<th>责任</th>
</tr>
</thead>
<tbody><tr>
<td>传输层</td>
<td>负责收发缓存、帧封装、帧解析、地址、校验和超时结果</td>
</tr>
<tr>
<td>应用层</td>
<td>负责扫描、地址分配、连接、校准、倾角、信息读取、配置发送和固件下载</td>
</tr>
</tbody></table>
<p>业务功能只面对应用层动作。比如“设置倾角”不需要关心一帧怎么拼、校验怎么计算、接收缓存怎么清，只需要完成目标设备选择、输入值检查和结果提示。</p>
<p>这个分层避免了把按钮直接接到串口发送里。串口、协议帧、标准服务和 UI 动作之间有清楚分工，后续增加自检、告警读取、清除告警等能力时，可以继续复用同一套调用方式。</p>
<h2>四、多级动态菜单</h2>
<p>菜单系统做的不只是列出几个静态页面。它用链表或动态容器组织菜单层级，每个菜单节点只保存三类信息：显示内容、节点类型、触发动作。</p>
<p><img src="/posts/antenna-control-terminal/img/sim/tilt-setting.webp" alt="设置倾角菜单"></p>
<p>这种设计带来两个好处：</p>
<ul>
<li>固定功能可以在启动时创建，比如校准、倾角、信息、配置、固件和系统设置。</li>
<li>动态内容可以在进入页面前生成，比如扫描出的设备列表、存储卡里的配置文件列表、固件文件列表。</li>
</ul>
<p>菜单控件按现场操作拆成几类：</p>
<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>显示只读状态或设备参数</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>
<tr>
<td>加载条</td>
<td>长耗时任务期间给出持续反馈</td>
<td>天线扫描、协议等待、文件发送</td>
</tr>
</tbody></table>
<p>渲染路径和执行路径分开。渲染路径只负责标题、列表、高亮、滚动条、数值条、弹窗和加载条；执行路径只负责上移、下移、确认、返回和数字输入。确认键再根据节点类型触发跳转、函数动作、数值编辑或单选切换。</p>
<p>天线扫描这类操作无法瞬间完成，界面会进入加载状态。加载条本质上是一段沿屏幕边框循环的动画：从左上角走到右上角，再拐到右下角、左下角，最后回到左上角。走完一圈后任务未必结束，动画会继续循环播放，用来表示扫描或协议等待仍在进行。</p>
<p>这样做方便扩展。新增一个现场功能，不需要重写整页 UI；新增一个动态列表，也不需要提前写死菜单项。菜单只负责组织操作入口，不接管协议和文件系统的职责。</p>
<h2>五、文件系统和 USB 维护入口</h2>
<p>文件系统在这个项目里承担现场维护入口。配置文件和固件文件都从存储卡进入终端，菜单只负责把这些文件变成可选择的操作项。</p>
<p>文件链路分成四步：</p>
<ol>
<li>启动后挂载 FAT 文件系统，保证终端能按普通目录读取文件。</li>
<li>进入配置或固件菜单时扫描目录，把文件名动态生成成菜单列表。</li>
<li>用户确认某个文件后，终端读取文件内容并显示执行状态。</li>
<li>业务层把文件数据交给协议层，由协议层发送给当前选中的设备。</li>
</ol>
<p>配置下发适合现场批量参数维护。用户把配置文件放进存储卡，终端读取后直接发送给当前目标设备。固件更新的风险更高，所以流程被拆成开始、数据发送、结束三个阶段，终端把这套协议时序包装成一个菜单动作，现场人员只看到“选择文件”和“更新结果”。</p>
<p><img src="/posts/antenna-control-terminal/img/sim/config-file-list.webp" alt="配置文件列表"></p>
<p><img src="/posts/antenna-control-terminal/img/sim/firmware-file-list.webp" alt="固件更新列表"></p>
<p>USB 维护入口解决的是文件怎么进设备。终端检测到 USB 接入时进入 Mass Storage 存储模式，电脑侧可以像操作 U 盘一样更新存储卡内容；正常控制模式下，USB 不参与协议控制，终端继续作为手持设备工作。</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>USB 存储模式写入存储卡</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>文件系统、USB 和协议层各自只做自己的事。文件系统负责组织输入，USB 负责和电脑交换文件，协议层负责把选中的内容发给设备。这样现场更新不依赖烧录器，也不需要把电脑接到协议总线上。</p>
<h2>六、多语言界面和实时反馈</h2>
<p>终端支持中文和英文界面。多语言没有重新做两套菜单，同一套菜单层级、同一套动作绑定、同一套协议调用复用不同文本资源。语言切换只改变界面文案，不改变业务流程。</p>
<p><img src="/posts/antenna-control-terminal/img/sim/main-menu-en-sim.webp" alt="英文菜单界面"></p>
<p><img src="/posts/antenna-control-terminal/img/sim/language-setting.webp" alt="语言设置界面"></p>
<p>这点对现场工具很实用：设备扫描、校准、倾角设置、配置下发、固件更新这些动作在中文和英文界面下保持同样的位置和操作路径，维护人员不会因为语言变化进入另一套流程。</p>
<p>多语言只是本机交互的一部分。这个项目还有多个并行职责：按键扫描、LCD 刷新、LED 提示、蜂鸣器提示、电池电压采样、USB 检测和协议操作。如果全部塞进一个阻塞循环，界面反馈会很差，现场人员很难判断设备是在执行、卡住还是失败。</p>
<p>RTOS 的作用是把这些职责拆开：</p>
<table>
<thead>
<tr>
<th>任务</th>
<th>作用</th>
</tr>
</thead>
<tbody><tr>
<td>按键扫描</td>
<td>把矩阵按键转换成菜单操作</td>
</tr>
<tr>
<td>LCD 刷新</td>
<td>周期性刷新菜单、弹窗、电池状态和进度提示</td>
</tr>
<tr>
<td>LED 提示</td>
<td>区分忙状态、成功状态和报警状态</td>
</tr>
<tr>
<td>蜂鸣器提示</td>
<td>给确认、成功、失败等动作提供即时反馈</td>
</tr>
<tr>
<td>ADC 采样</td>
<td>读取电池电压并驱动低电量提示</td>
</tr>
</tbody></table>
<p>按键扫描也做成独立职责。矩阵按键先被周期性扫描和消抖，再转换成统一的按键事件，最后投递给菜单层。菜单层不直接读 GPIO，只消费“上、下、确认、返回、数字”这些语义动作。这样硬件按键抖动、长按节奏和菜单跳转逻辑不会混在一起。</p>
<p>这部分提升的是现场可用性。协议命令可能需要等待，文件发送可能需要持续一段时间，用户界面必须给出明确反馈。终端通过弹窗、进度边框、提示音和状态灯，把底层等待变成用户能理解的操作状态。</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:#D4D4D4">x % </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> == y % </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> &#x26;&#x26; x % </span><span style="color:#B5CEA8">2</span><span style="color:#D4D4D4"> == </span><span style="color:#B5CEA8">0</span></span></code></pre><p>这条条件会选中隔点像素，相当于从背景里抽出一半像素点。随后再分几帧把这些采样点做 1 到 2 个像素的小位移，并在每一帧后短暂停留。人眼看到的是背景被打散、抖开、退到后面的效果，前景弹窗再用反色实心块压上去，视觉焦点就自然落在弹窗文字上。这个做法计算量低、显存操作简单，也很适合 MCU 上的单色 LCD。</p>
<p><img src="/posts/antenna-control-terminal/img/sim/tilt-confirm-dialog.webp" alt="倾角确认弹窗"></p>
<p><img src="/posts/antenna-control-terminal/img/sim/tilt-success-sim.webp" alt="倾角设置结果"></p>
<p>除了操作成功提示，界面也覆盖了现场常见异常状态，例如低电量和输入范围错误。这些提示虽然只是 UI 层画面，但能避免现场人员把电池、电缆、输入值或协议等待混在一起判断。</p>
<h2>七、软件部分交付内容</h2>
<p>软件部分最终交付了五类能力：</p>
<ul>
<li><strong>把 RETAP 标准协议产品化</strong>：协议对接不只停留在调试函数，最终被包装成现场人员可操作的控制终端。</li>
<li><strong>多设备扫描</strong>：用分支尝试和响应结果发现多设备，避免直接穷举完整编号。</li>
<li><strong>动态菜单</strong>：设备列表、配置列表和固件列表都来自运行时数据，服务真实维护流程。</li>
<li><strong>文件通道</strong>：SD 负责配置和固件输入，USB 存储模式负责文件更新，现场维护路径完整。</li>
<li><strong>多语言和 RTOS 保证交互体验</strong>：中英文界面复用同一套流程，按键、显示、提示、电量和协议等待各司其职。</li>
</ul>
<p>这些能力组合在一起，形成的是一台可独立使用的嵌入式手持维护终端。它覆盖 Iuant/RETAP 标准协议对接、底层总线通信、协议分层、设备发现算法、本地 UI、多语言界面、文件系统、USB 维护入口和多任务交互，没有停留在单个菜单 demo 或单个协议 demo。</p>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="单片机"/>
    <category term="数据采集"/>
    <category term="通信协议"/>
    <category term="系统设计"/>
  </entry>
  <entry>
    <title>从互斥锁的死局突围：一种基于专用线程的 4G 通讯架构演进</title>
    <link href="https://createskyblue.github.io/posts/4g-serial-thread-architecture/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/4g-serial-thread-architecture/</id>
    <published>2026-07-02T00:00:00.000Z</published>
    <updated>2026-07-02T00:00:00.000Z</updated>
    <summary>MCU外挂4G模块，拒绝互斥锁，专用线程架构</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>从互斥锁的死局突围：一种基于专用线程的 4G 通讯架构演进</h1>
<p>我在一个工业振动异常监测终端项目中负责通讯架构设计。设备运行 RT-Thread，搭载 4G LTE Cat.1 模组，需要在一个模组上同时承载 MQTT 遥测和 TCP 原始波形数据上报。直觉做法是多个任务通过互斥锁争抢 AT 串口使用权，但系统一旦复杂起来，死锁和重试逻辑就会散落各处。本文复盘我如何用专用线程加消息队列的模型替代锁，把串行性从约束变成设计。</p>
<h2>一、锁把串行硬伤分摊给了每个调用方</h2>
<p>4G 模组通过 UART 发 AT 命令控制，本质是一个天生串行的资源——每发一条命令，必须等模组响应完才能发下一条。一个 RTOS 系统里多个任务都需要操作模组，加锁是条件反射。</p>
<p>但锁方案有三个深层问题。</p>
<ol>
<li><strong>锁粒度没法定</strong>。GPS 查询可能阻塞 3 秒，期间所有 MQTT 收发都被卡住。拆成多个锁？底层只有一路 UART，拆锁只是把竞态推迟，AT 串帧的风险反而回来了。</li>
<li><strong>错误路径防不住</strong>。每个 <code>lock()</code> 对应一个 <code>unlock()</code>，加上超时、AT 异常、看门狗等分支后，总有某个角落忘了释放，然后某天现场死锁。</li>
<li><strong>重试策略散落各处</strong>。每条 AT 命令自带重试次数、整轮联网流程失败后整体重来、MQTT 订阅失败后要先关 TCP 再断 MQTT 按序清理——这些策略分散在各任务文件里，改一个参数要翻好几处。</li>
</ol>
<p>根源一句话：对一个天生串行的资源用锁去保护，是把复杂度的账记在了每一个调用方头上。更好的做法是让串行发生在唯一一个线程内部，对外只暴露异步接口。</p>
<h2>二、专用线程消灭锁</h2>
<p>我的方案是把 4G 模组的所有操作收敛到一个专用线程，对外只暴露一条消息队列。MQTT 发布、TCP 建连、TCP 数据发送、GPS 查询、信号强度读取——所有操作全部通过同一种命令消息投递。外部任务永远不碰 AT 串口，也永远不需要拿锁。</p>
<p>这本质是把串行性从约束升级为设计。AT 命令天生不能并发，那就在架构层面保证只有一个人跟模组说话。线程内部就是一个大循环：取消息 → 看命令类型 → 执行 → 回调 → 取下一条。新增操作类型只需加一个 case 分支，逻辑平铺直叙。</p>
<p>调用的时序也得到了保证。消息队列的 FIFO 特性天然保序——先投递的 MQTT 发布一定先于后投递的 TCP 建连被处理。队列满时调用方原地等待重试，不会丢消息，也不会打乱顺序。</p>
<h2>三、异步队列 消息驱动模型</h2>
<p>消息驱动模型的核心是一个极简的三字段消息体：<code>cmd</code> 决定操作类型，<code>data</code> 指向具体载荷，<code>len</code> 记录长度。回调不嵌入每条消息，通过专门的注册命令提前配置到线程内部的资源表中。下行数据到达时，线程查表找到对应回调调用。常规数据命令投递即忘，调用方不需要关心回调。</p>
<p>这张资源表同时解决两个问题：4G 模组的 TCP 通道和 MQTT 订阅都有硬件上限，表中记录每个 slot 的类型、标识、占用状态和回调函数——&quot;还有几个空闲通道&quot;和&quot;这个 Topic 的数据该回调谁&quot;，一张表搞定。资源耗尽时返回错误码，由上层决定等待还是降级。</p>
<p>最后，我对外暴露了一组函数式 API：<code>comm_mqtt_pub()</code>、<code>comm_tcp_connect()</code>、<code>comm_tcp_send()</code>、<code>comm_gps_query()</code>。这些函数内部自动完成消息构造和队列投递，调用方写代码时就像调普通函数，背后不再是锁，是消息队列在异步运转。</p>
<h2>四、分块传输</h2>
<p>原始波形数据可达几十 KB。如果一次性塞进队列，不仅浪费 RAM，还会让其他消息长时间得不到处理。</p>
<p>我的做法是强制分块投递。调用方将大数据切成固定大小的 chunk（如 512 字节），逐块投递到消息队列，最后一包标记结束。通讯线程逐包取出写入模组，两包之间自然穿插 MQTT 和 GPS 操作。小 chunk 不会撑爆队列，信令通道的实时性得到了保障。这就是 FIFO 带来的自然反压——不需要额外设计优先级或抢占机制。</p>
<h2>五、流式匹配</h2>
<p>AT 命令编程有一个容易踩的坑：响应不是一问一答。发完命令 A，模组可能先吐一条命令 B 的延迟响应，或者插一条网络状态变化的 URC（非请求上报），然后才是 A 的响应。在单线程串行化设计下，所有命令通过同一个串口发出，收到的字节流是一锅粥——线程必须从粥里准确捞出当前命令对应的那粒米。</p>
<p>我实现了一个逐字符流式模糊匹配器。逐字节读取模组返回，对期望的关键词做流式匹配——无论关键词出现在响应流的什么位置，前面有没有其他命令的残留数据，只要命中就算匹配成功。每收到一个字符就重置超时计时器，避免模组中途停顿（URC 插入、网络分包）导致误判超时。命令超时未命中自动重发，弱网下无需调用方手动处理。</p>
<p>这个匹配器和通讯线程是天生一对。只有把所有 AT 收发收敛到一个线程里，才能对流中字节做统一解析和路由，不会出现&quot;线程 A 的响应被线程 B 误吞了&quot;的经典问题。</p>
<h2>六、云端协同闭环</h2>
<p>这套通讯架构之上，我构建了完整的云端协同能力。远程参数热更新通过 MQTT 实时下发，采集周期、报警阈值等运行时参数无需重启即可生效。OTA 固件升级走 MQTT 通知加 HTTP 下载，校验后自动重启。设备 RTC 利用 4G 网络时间自动校准。双通道上报的数据通过服务端返回的上报 ID 在业务层关联，形成从设备到云端的完整闭环。</p>
<h2>七、设计取舍与价值</h2>
<p>锁方案看起来简单，但复杂性是分摊且持续增长的——每个调用方都要正确加锁解锁，每个错误分支都是隐患，每加一个新功能就多一分风险。消息队列加专用线程的方案，复杂性是一次性且集中的——多了一个线程和一条队列，换来的是调用方代码零锁、所有 AT 策略集中管控、新操作零侵入、FIFO 天然反压。</p>
<p>这个设计有一个适用边界：通讯线程的栈空间和消息队列需要额外 RAM。在本项目的 MCU 上完全可接受，但如果芯片只有几十 KB SRAM，传统加锁直调模式可能更合适。这不是一个万能方案，但在资源不是极端紧张的嵌入式设备上，它把 4G 通讯的复杂度从 O(N) 降到了 O(1)——每增加一种 4G 操作，调用方的改动量为零。</p>
<p>UART 到 4G 模组本身就是串行的。对这种资源，专用线程是比锁更自然的抽象。把串行性从约束变成设计，这是我在这项目中最大的收获。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="物联网"/>
    <category term="4G"/>
    <category term="RTOS"/>
    <category term="RT-Thread"/>
    <category term="互斥锁"/>
    <category term="系统设计"/>
  </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 网关把多型号仪表寄存器排成连续地址表：轮询缓存、写入保护</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>Modbus 数据重组网关设计复盘</h1>
<p>PLC 通过 RS-485 接多台 Modbus 设备时，最大的问题是：即便是同一个厂家的不同型号设备，同一个业务数据的寄存器地址也可能不一样。设备少时还能在 PLC 侧写几套适配逻辑，设备一多就会失控，现场换一台设备都可能要改 PLC 程序。</p>
<p>这个项目的核心工作，就是把这些分散的寄存器收敛成一张统一表。PLC 只需按固定偏移读写，不用关心底层接了什么型号的设备。</p>
<h2>一、痛点与解决思路</h2>
<p>同样是&quot;当前温度&quot;，A 型号可能放在 0x2000，B 型号可能放在 0x2004。PLC 每接一种新型号，都要加一套地址映射逻辑。</p>
<p>正确的做法是把差异收敛到网关里：</p>
<ul>
<li><strong>对下</strong>：适配不同型号设备的地址差异。</li>
<li><strong>对上</strong>：输出统一寄存器表。</li>
</ul>
<p>PLC 读的是网关整理好的地址空间，不是物理设备的原始寄存器。</p>
<h2>二、按业务语义重组连续表</h2>
<p>只把各设备寄存器拼接起来是不够的。PLC 真正关心的是一次读完所有设备的温度，或一次读完所有报警状态。</p>
<p>所以必须按业务语义重新整理，把数据排成连续分区（如输入寄存器区、保持寄存器区）。上位机的访问方式从&quot;理解每台设备的物理地址&quot;变成了&quot;按业务语义访问统一表&quot;。</p>
<p><img src="/posts/n320a-modbus-gateway/modbus-address-reorg.webp" alt="地址碎片整理成连续表">
<em>把被打散的数据，按业务语义重新排成连续分区。</em></p>
<h2>三、基于五层架构的虚拟地址映射</h2>
<p>虚拟地址如何落到真实设备上？网关内部采用了五层架构：上位机层、虚拟层、映射层、描述层、设备层。</p>
<p>网关内部没有用复杂的动态解析，而是通过两张静态配置表实现物理隔离：</p>
<ol>
<li><strong>采集任务表</strong>：描述物理寄存器起始地址、连续长度、读写权限。</li>
<li><strong>映射规则表</strong>：决定物理寄存器投递到虚拟空间的哪个分区和哪个通道偏移。</li>
</ol>
<p>当上位机访问一个虚拟地址时，网关会通过地址转换机制，计算出该地址对应的&quot;物理通道索引&quot;、&quot;设备规则索引&quot;和&quot;真实物理地址&quot;。这种查表与偏移计算结合的方式，使得新增设备类型时不需改架构，只需填表，差异被彻底封装在网关内部。</p>
<p><img src="/posts/n320a-modbus-gateway/modbus-virtual-mapping.webp" alt="虚拟地址映射异构设备">
<em>用虚拟地址屏蔽设备差异，对上统一成一张寄存器表。</em></p>
<h2>四、慢速总线与快速读取解耦</h2>
<p>如果 PLC 每次读取都透传到 RS-485 总线，系统会非常慢。因为多设备共享半双工总线，报文往返时延大。</p>
<p>做法是：<strong>后台按采集任务表持续轮询下位机刷进缓存，PLC 直接读缓存。</strong> </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>六、配置掉电保护机制</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="Modbus"/>
    <category term="寄存器映射"/>
    <category term="系统设计"/>
  </entry>
  <entry>
    <title>教师Agent—学生Agent迭代循环：一种可复现的AI Skill调优方法</title>
    <link href="https://createskyblue.github.io/posts/skill-iteration-methodology/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/skill-iteration-methodology/</id>
    <published>2026-06-30T00:00:00.000Z</published>
    <updated>2026-06-30T00:00:00.000Z</updated>
    <summary>教师Agent带学生Agent做盲测：把Skill的迭代改进变成可度量工程</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>教师Agent—学生Agent迭代循环：一种可复现的AI Skill调优方法</h1>
<p>给 AI Agent 写 Skill（系统提示词）有一种常见的挫败感：你写了一大段规则，Agent 第一次输出还是不行，你改了改规则，第二次好了一点，第三次又倒退回去了。你无法知道自己改对了没有，因为每次测试的文章不同、Agent 的随机性不同、你的判断标准也在漂移。</p>
<p>这篇文章讲的是我用一种<strong>教师Agent—学生Agent 迭代循环</strong>的方法，把博客写作 Skill 的封面文本生成合格率从 41% 提到 70% 的过程。核心不是那 70% 的数字——核心是这个循环本身。</p>
<h2>问题：一个具体但不好量化的写作任务</h2>
<p>博客 Skill 需要自动生成 <code>coverText</code>（封面文本），一个 50 字以内的卡片文案。它要告诉读者&quot;这篇文章讲了什么&quot;，但不能剧透文章内容——是「门」，不是「房间」。</p>
<p>最初 Skill 里有一段封面文本规则，约 150 行。效果很随机：有时候 Agents 生成得不错，有时候它把实现细节（芯片型号、协议字段、精确数值）全塞进封面文本。你改一条规则它修好一个案例，但又引入另一个问题。</p>
<p>问题本质是：<strong>写作质量是一个难以度量的目标。</strong> 你觉得「这条规则改进了内容」，但实际上你只是在用一个主观感受替代另一个主观感受。</p>
<h2>架构：一个教师，一群学生</h2>
<p>解决思路是把改进过程本身变成可度量的工程问题。架构很简单：</p>
<table>
<thead>
<tr>
<th>角色</th>
<th>做什么</th>
<th>数量</th>
</tr>
</thead>
<tbody><tr>
<td>教师 Agent</td>
<td>读取所有盲测结果，分析失败模式，提炼新规则，更新 Skill 文件</td>
<td>1 个</td>
</tr>
<tr>
<td>学生 Agent</td>
<td>读取当前 Skill 规则，为去掉封面文本的文章重新生成封面文本</td>
<td>N 个</td>
</tr>
</tbody></table>
<p>教师不写封面文本。学生不改规则。两个角色各司其职。</p>
<p>教师在每一轮做的事情是：把 N 个学生的生成结果和原文封面文本对比，不是比「是否一模一样」，是比「是否合格」。「合格」意味着它作为独立卡片文案是可接受的——读者看得懂、信息正确、不剧透、不超过 50 字。</p>
<h2>循环：教学 → 盲测 → 复盘 → 提炼</h2>
<p>每一轮迭代是四步：</p>
<h3>1. 教学</h3>
<p>教师把上一轮盲测中发现的失败模式提炼为规则，写入 Skill 的参考文件。规则不是「封面文本应该更好」这种废话——每一条规则都必须对应一个可以反查的具体失败模式。</p>
<p>例如，有一轮盲测发现学生 Agent 们频繁把文章正文里的实现数字（<code>914 万 token</code>、<code>0.65mm 咬合量</code>、<code>128KB RAM</code>）写进封面文本。对应的规则不是「不要写太多细节」，是：</p>
<blockquote>
<p><strong>数字禁令：</strong> 除非是协议标准数字（MTU 20、124 寄存器），否则任何数字不准出现在封面文本中。</p>
</blockquote>
<p>这条规则是<strong>可执行的</strong>——学生 Agent 在生成封面文本时可以逐字检查是否违反了它。</p>
<h3>2. 盲测</h3>
<p>这一步是关键设计。</p>
<p>把 N 篇已有文章的封面文本字段清空，创建副本到临时目录。每个学生 Agent 拿到同一个 Skill 规则文件，为分配到的文章生成封面文本。</p>
<p>「盲」体现在两点：</p>
<ul>
<li>学生 Agent 不知道原文的封面文本是什么，只能靠 Skill 规则来生成。</li>
<li>教师 Agent 不知道学生 Agent 会在哪些文章上翻车，只能等盲测结果出来再分析。</li>
</ul>
<p>如果没有盲测，你很容易陷入「我觉得这条规则改得对」的错觉。盲测把改进变成了可观察的事实：改了规则 → 重新生成 → 统计合格率 → 看结果。</p>
<h3>3. 复盘</h3>
<p>教师拿到所有学生的生成结果后，逐篇对比原文。每一篇的对比回答三个问题：</p>
<ol>
<li><strong>合格还是不合格？</strong> 这是一个二分类判断，不允许「差不多」。</li>
<li><strong>如果不合格，失败模式是什么？</strong> 不是「写得不好」——是「加了正文才有的实现数字」、「用抽象类别替代了具体机制名称」、「在封面文本里解释了物理原理」。</li>
<li><strong>这个失败模式在多少篇文章中出现了？</strong> 单篇文章的失败可能是个例，跨文章反复出现的失败才是规则缺陷。</li>
</ol>
<p>把失败模式按出现频次排序，优先级最高的先修。</p>
<h3>4. 提炼</h3>
<p>把高频失败模式翻译成新规则。翻译时有一条重要原则：<strong>显式对照表优先于抽象原则。</strong></p>
<p>举个例子：V7 那轮盲测之后，我发现学生 Agent 最大的问题不是没有找到价值点，是它不知道「什么叫有价值」。它把「我理解了实现细节」和「读者会因此点进来」搞混了。如果我写一条规则「封面文本应该突出文章价值」，这是一句正确的废话——学生 Agent 不知道怎么判断价值。</p>
<p>所以我写了一个显式对照表：</p>
<table>
<thead>
<tr>
<th>有价值的（放封面）</th>
<th>没价值的（放正文）</th>
</tr>
</thead>
<tbody><tr>
<td>这个技术能在资源受限设备上跑</td>
<td>用了多少 KB RAM</td>
</tr>
<tr>
<td>把分散的寄存器排成了连续表</td>
<td>f=g+h 最小堆扩展</td>
</tr>
<tr>
<td>用防收缩槽消除冷却纹</td>
<td>咬合量 0.65mm</td>
</tr>
</tbody></table>
<p>每一条左侧是「读者想知道的原因」，右侧是「你实现它用到的细节」。Agent 对照这个表做排除，比理解一段抽象原则要可靠得多。</p>
<h2>盲测为什么要做大规模</h2>
<p>第一轮盲测用了 10 篇文章，样本太少——合格率波动大，失败模式看不全。后面扩展到 51 篇（约占全部 84 篇文章的 60%），分成 3 个 batch，每个 batch 约 17 篇。</p>
<p>大规模盲测带来三个收益：</p>
<p><strong>第一，统计稳定性。</strong> 10 篇样本里合格率波动 ±15%，51 篇样本波动降到 ±5% 以内。你能在数字上区分「这条规则真的有用」和「这轮运气比较好」。</p>
<p><strong>第二，失败模式收敛。</strong> 单篇失败可能是学生 Agent 的随机性，但同一失败模式在 5 篇、10 篇文章中反复出现，那就是规则层面的缺陷。数字禁令、缩写禁令、「而」字禁令都是在 51 篇规模的盲测中确认的——它们不是个别案例。</p>
<p><strong>第三，防止过拟合。</strong> 规则改得太针对某几篇文章，会伤害其他文章的生成质量。大规模盲测能提前发现过拟合——如果一条规则让 3 篇文章变好但 5 篇变差，数字会告诉你。</p>
<h2>四个关键迭代与它们揭示的方法论</h2>
<p>整个过程的实际迭代路径不是线性的——有倒退、有方向修正、有方法论突破。</p>
<h3>V4→V5：独立规则文件（效果不大）</h3>
<p>把封面文本规则从主 Skill 中拆出来，独立成 200 行的参考文件。逻辑是「学生 Agent 需要一份专注的参考资料，不是在 500 行 Skill 中找相关段落」。</p>
<p>合格率从 41% 升到 43%，几乎没变。这说明学生 Agent 不缺信息，它缺的是<strong>信息的使用方法</strong>。</p>
<h3>V5→V7：价值优先过滤（方法论突破）</h3>
<p>V5 的失败模式非常集中：学生 Agent 读了文章全文后，把所有它觉得「有意思」的事实都塞进封面文本。文章正文里 20 个有意思的事实，封面文本里塞了 6 个。</p>
<p>我最初的想法是「不给学生看全文，只看标题和首段」。V6 盲测合格率冲到 94%，但这个方案被否决了——写新文章时没有「首段」可以看，Agent 必须先写正文才知道首段是什么，可封面文本应该在标题之后、正文之前就生成。</p>
<p>正确的方向是<strong>价值优先过滤</strong>：允许学生读全文，但要求它在生成封面文本前先做一轮显式的价值排序。</p>
<p>具体步骤：</p>
<ol>
<li>读全文，找出 3-5 个高价值点</li>
<li>按价值排序</li>
<li>只取前 1-3 个</li>
<li>用选中的点写封面文本</li>
<li>锁定——不再把被丢弃的点加回去</li>
</ol>
<p>V7 合格率跳到 70.6%。改进不是靠「减少信息量」，是靠「在信息进入封面文本之前加了一道门槛」。</p>
<h3>V7→V8：显式价值判断（微调）</h3>
<p>V7 解决了「信息过载」的问题，但暴露了另一个问题：学生 Agent 不知道什么叫「有价值」。它把「协议帧格式是 SOF/ID/TYPE」「PMOS 关断分压链路、NMOS 隔离高压域」当成高价值点排在前面——因为这些是它绞尽脑汁理解的技术细节。</p>
<p>V8 加入显式价值对照表和数字禁令、缩写禁令后，合格率到了 68.6%，没有继续提升。剩下的 ~30% 不合格案例的共同特征是：学生 Agent 选的价值点是对的，但表达时多加了一句解释。比如「FDM 散热孔冷却纹」它写成了「FDM 散热孔槽根部预挖防收缩槽，切断应力路径消除冷却纹」——不加那句解释它就是合格的。</p>
<h2>收益递减点：70% 之后是什么</h2>
<p>从 41% 到 70% 是方法论驱动的改进。从 70% 往上，剩下的问题不再是「Agent 不懂规则」，是 <strong>LLM 本身的表达倾向</strong>——它总是想在正确的答案后面加一句解释。这不是规则能解决的问题。</p>
<p>这个天花板的存在本身就是一个有价值的发现：<strong>规则可以教会 Agent「选什么」，但很难教会它「停在哪儿」。</strong></p>
<p>如果你的 Skill 迭代卡在某个数字上不再上升，停下来问自己：剩下的失败是因为规则不够好，还是因为 LLM 有一个和你的目标相反的内置倾向？如果是后者，加更多规则不会解决问题——你需要换一种思路（比如用更小、更专注的模型做这个子任务，或者在生成后加一个专门做「修剪」的步骤）。</p>
<h2>为什么这个循环是可复现的</h2>
<p>这个方法的本质是把 AI 写作质量的改进从一个玄学问题变成了一个测量-反馈工程问题。</p>
<p>三样东西让它可复现：</p>
<p><strong>1. 盲测。</strong> 每一轮改进的效果可度量。你没有在「优化感觉」，你在「优化合格率」。</p>
<p><strong>2. 教师不写封面文本。</strong> 教师只分析失败模式、写规则。如果教师自己也写封面文本，就会陷入「我觉得这样写更好」的主观判断，偏离了「规则是否可被学生执行」这个唯一标准。</p>
<p><strong>3. 规则来自失败模式，不来自审美直觉。</strong> 每一条规则在写入 Skill 之前，都必须能在盲测结果中找到至少 3 个独立案例支持它的必要性。不是「我觉得数字不应该出现在封面文本中」，是「我在 12 篇文章中看到数字出现在封面文本中，其中 10 篇不合格」。</p>
<p>如果把这个循环抽象成一个模板，它可以用于任何「AI Agent 需要执行一个质量可判断但难以量化」的任务——写作、翻译、代码审查、文档生成。你需要的是一个可度量的质量标准（合格/不合格）、一个教师 Agent（分析失败、提炼规则）、多个学生 Agent（执行规则、生成结果）、以及一个盲测机制（隔离评测和优化）。</p>
<h2>一篇后记</h2>
<p>这个过程花了我大约 3 小时——不是写代码，是反复读取盲测结果、逐个对比失败案例、从模式中提取规则。</p>
<p>让我意外的是，最有效的规则往往是最「笨」的——对照表、禁令、步骤列表——不是最「聪明」的。AI Agent 执行显式约束的能力远强于理解抽象原则的能力。如果你的 Skill 里有任何一句「XXX 应该更好」，把它换成「XXX 必须满足以下 3 条可检查的条件」，Agent 的成功率就会提升一个台阶。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="AI"/>
    <category term="AGENT"/>
    <category term="Claude Code"/>
    <category term="博客"/>
    <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 上，遇到长周期、低概率、难复现的状态类故障时，直接 dump 关键运行上下文，再在 PC 侧解析和对比。</p>
<p>项目背景、真实结构体、真实字段、异常值和修复点都不展开。截图是纯假数据演示，只用于说明“十六进制 dump 可以被外部工具解析成字段并做差异高亮”，不对应真实业务对象。</p>
<p>这个方法来自一次实际排查：故障需要长时间挂机才出现，低端芯片没有条件做断点调试，内存和 Flash 也不允许铺大量日志。重启后现象能恢复，说明上下文里可能保留了导致故障的状态。</p>
<h2>约束</h2>
<p>这类故障难查，核心约束很直接：</p>
<ul>
<li>芯片资源有限，不能多打日志。</li>
<li>无法方便地断点调试。</li>
<li>故障需要长时间运行才出现。</li>
<li>重启后能恢复，现场状态容易丢失。</li>
</ul>
<p>所以调试目标是用最小输出拿到关键状态快照。快照能被保存、对比、复盘，也能用于后续故障注入。</p>
<h2>采集点</h2>
<p>固件侧只做一件事：在关键位置把目标上下文按原始字节输出成十六进制 dump。结构体解析、字段命名、历史对比都放到 PC 侧工具完成。</p>
<p>采集点围绕这次排查的关键时刻选择：</p>
<ul>
<li>进入低功耗休眠前。</li>
<li>低功耗休眠恢复后。</li>
<li>长时间运行后的异常现场。</li>
</ul>
<p>每份快照至少要带上触发点。否则 PC 侧只能看到一堆十六进制，无法判断哪一份是休眠前，哪一份是恢复后，哪一份是异常现场。</p>
<h2>对比方法</h2>
<p>PC 侧工具只做辅助观察：</p>
<ul>
<li>把串口日志里的十六进制 dump 解析成字段。</li>
<li>选择最早、最晚或中间任意两个快照做对比。</li>
<li>把变化的字段高亮出来。</li>
</ul>
<p>对比时只看字段在不同快照之间的变化关系：</p>
<ul>
<li>哪些字段在正常快照和异常快照之间不同。</li>
<li>哪些字段只在长时间运行后偏离早期快照。</li>
<li>哪些字段在重启或重新初始化后回到默认状态。</li>
</ul>
<p>具体项目里到底是哪一个字段异常，属于实现细节。脱敏场景下不公开这些内容。文章要表达的是方法：通过上下文快照对比，把“可能是内存上下文出了问题”变成可以观察、可以对比的证据。</p>
<h2>为什么不用普通日志</h2>
<p>普通日志在这个场景里不够用：</p>
<ul>
<li>能打印的内容太少。</li>
<li>不能像调试器一样直接看内存。</li>
<li>长时间挂机后，少量过程日志很难还原完整现场。</li>
<li>只看现象容易继续猜，不能快速收敛到上下文差异。</li>
</ul>
<p>内存 dump 的优点是把一段状态一次性保存下来。固件侧输出逻辑很薄，复杂分析放到外部工具里做。</p>
<h2>验证</h2>
<p>定位并修复后，不应该只靠再次长时间挂机来验证。更直接的方式是用已捕获的异常 dump 做故障注入：把当时的上下文重新加载到测试路径，让代码从相同状态启动。</p>
<p>验证重点放在调试闭环本身：</p>
<ul>
<li>异常上下文能否稳定触发原来的错误表现。</li>
<li>修复后的代码能否从同一个上下文恢复。</li>
<li>回归测试是否可以在短时间内复现并验证，不再依赖长时间等待。</li>
</ul>
<p>这样验证的是恢复能力。长周期问题最缺的通常是“上下文已经异常后，系统还能不能识别并拉回”的证据。</p>
<h2>预防</h2>
<p>这类项目应默认保留一个低成本观测通道。它平时可以关闭，但在长时间挂机排查和低功耗恢复排查时要能快速打开。</p>
<p>建议至少保留这些能力：</p>
<ul>
<li>关键上下文结构体的十六进制 dump 开关。</li>
<li>快照触发点。</li>
<li>只显示差异字段的对比视图。</li>
<li>用历史异常 dump 做故障注入的回归入口。</li>
</ul>
<p>对资源受限 MCU 来说，可观测性不一定来自大量日志，也可以来自少量、稳定、可对比的内存快照。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="MCU"/>
    <category term="低功耗"/>
    <category term="故障诊断"/>
    <category term="工程调试"/>
    <category term="可观测性"/>
  </entry>
  <entry>
    <title>使用VNA进行天线阻抗匹配</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和史密斯圆图调天线阻抗</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>使用VNA进行天线阻抗匹配</h1>
<h2>测量前确认</h2>
<p>VNA端口只能接无源待测网络。测量前先确认这些条件：</p>
<ol>
<li>无线模块断电，或者至少保证射频输出没有处于发射状态</li>
<li>VNA端口没有直接接到有源射频输出脚，测量路径只经过无源走线、匹配网络和天线</li>
<li>焊接射频线时断开VNA，避免焊接和返修过程损坏仪器端口</li>
<li>贴片<code>0R</code>、<code>50R/51R</code>、射频线和焊盘都会引入寄生参数，板级校准件适合工程调试和相对比较，不能等同于实验室级校准标准件</li>
</ol>
<h2>对VNA进行校准</h2>
<p>本文只测量天线端口的<code>S11</code>，因此采用单端口校准：<code>OPEN</code>、<code>SHORT</code>、<code>LOAD</code>。如果还需要测量两端口器件的<code>S21</code>，才需要根据VNA型号和固件继续执行<code>ISOLN</code>、<code>THRU</code>等步骤。</p>
<p>VNA校准的核心目标，是把测量参考面移动到板上馈点或匹配网络入口。后续只更换参考面之后的匹配元件时，校准仍可继续使用；测量线缆、转接头、焊点或参考面发生变化后，需要重新校准。</p>
<ol>
<li>设置好<code>起始频率</code>、<code>终止频率</code>。本文扫频范围为<code>470MHz</code>至<code>510MHz</code>，重点调试频点为<code>490MHz</code></li>
<li>焊接好射频线。焊接时不要让射频线连接VNA，后面的步骤需要多次更换电阻和匹配元件<ol>
<li><code>馈线#4</code> 焊接到无线模块的天线引脚</li>
<li><code>屏蔽层#5</code> 焊接到无线模块底部的GND铺铜</li>
</ol>
</li>
<li><code>焊盘#1</code>、<code>焊盘#2</code>、<code>焊盘#3</code>先不焊接元件</li>
<li>将射频线连接VNA，进入校准菜单，在板上的参考面执行校准<ol>
<li>进行<code>开路校准</code></li>
<li><code>焊盘#1</code>焊接<code>0R</code>电阻，进行<code>短路校准</code></li>
<li><code>焊盘#1</code>焊接<code>50R</code>负载电阻；没有<code>50R</code>时，可用<code>51R</code>进行近似调试，执行<code>负载校准</code></li>
<li>完成并保存校准配置。此时保持负载电阻连接，史密斯图上的点应接近中心</li>
<li>取下<code>焊盘#1</code>的负载电阻，开路状态下的点应在史密斯图右侧附近</li>
<li>校准后，<code>焊盘#1</code>所在位置就是本次测量的参考面</li>
</ol>
</li>
</ol>
<p>这里用贴片<code>0R</code>和<code>50R/51R</code>电阻制作的是板级近似校准件。它适合匹配调试和比较元件更换前后的效果；如果需要更准确的绝对读数，应使用适合目标频段的RF校准标准件，或者先表征板级校准件的寄生参数。</p>
<p>校准结果只适用于校准时的扫频范围、射频线和参考面。修改<code>起始频率</code>或<code>终止频率</code>，移动或更换射频线、转接头，或者返修焊点导致参考面发生变化后，都应重新进行校准。</p>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-09-42.webp" alt="射频线接入点与三个匹配焊盘的位置"></p>
<h2>外接天线和板载天线的匹配边界</h2>
<p>上面的流程适合把板上天线馈点和匹配网络作为同一个对象一起调。外接天线时，边界要拆开看：板上射频链路先调到<code>50 Ω</code>系统，外接天线再按自己的安装状态单独调到<code>50 Ω</code>。</p>
<p>实际操作可以这样分：</p>
<ol>
<li>在天线座位置接<code>50 Ω</code>假负载，先检查无线模块到天线座之间的走线、连接器和板上匹配网络。此时VNA看到的是板上射频通道，不包含外接天线本体</li>
<li>拆掉假负载，接入外接天线，再测天线输入端口的<code>S11</code>和史密斯图位置。这个步骤评估的是外接天线、线缆、贴装位置、外壳和附近材料共同形成的结果</li>
<li>如果外接天线本身已经设计成<code>50 Ω</code>端口，板上匹配网络不应再为了补偿某一次天线摆放而大幅偏离。否则换天线、换外壳或换安装方式后，板级匹配会跟着失效</li>
</ol>
<p>这和板载天线不同。板载天线的辐射体、地平面、净空区、外壳和匹配元件在同一块板上，VNA测到的是整机天线系统。外接天线把接口拆成了两个系统：板上射频口负责提供稳定的<code>50 Ω</code>端口，天线侧负责在最终安装状态下完成自己的匹配。这个边界可以避免把板级匹配偏差和天线本体问题混在一起。</p>
<p><img src="/posts/使用VNA进行阻抗匹配/dipole-fpc-antenna.webp" alt="外接使用的偶极子 FPC 天线"></p>
<p>外接方案里优先考虑偶极子FPC天线。普通单极子天线依赖PCB地作为另一半辐射结构，地平面尺寸、形状、净空区、外壳材料和贴附位置都会改变阻抗与方向图。偶极子天线有相对明确的两臂结构，对主板地平面的依赖更低，外接后更容易把板上射频口和天线本体分开调试。</p>
<p>偶极子仍然需要复测。FPC天线贴在塑料、玻璃、人体附近或金属附近时，谐振点和<code>S11</code>仍会变化。最终结果仍要在目标外壳、线缆走向和安装姿态下测量；从调试边界看，偶极子比依赖整机地的单极子更适合做可替换的外接天线。</p>
<h2>匹配网络拓扑</h2>
<p>进行焊接和选值前，先把图里的焊盘编号翻译成电路关系。按本文这块板子的编号，可以这样理解：</p>
<ol>
<li>模块射频输出先到匹配网络入口，校准后这个位置附近就是VNA参考面</li>
<li><code>焊盘#2</code>在信号线上，是串联元件位置。调试开始时焊<code>0R</code>，后续可替换成串联电容或串联电感</li>
<li><code>焊盘#1</code>和<code>焊盘#3</code>是到地的并联支路。本文最终使用<code>焊盘#1</code>作为模块侧并联元件位置，<code>焊盘#3</code>没有使用</li>
<li>天线接在串联位置之后。VNA从参考面看进去，看到的是串联元件、并联支路、走线和天线共同形成的输入阻抗</li>
</ol>
<p>本文后面的<code>元件#1</code>对应<code>焊盘#2</code>，也就是串联元件；<code>元件#2</code>对应<code>焊盘#1</code>，也就是并联到地的元件。先把这个拓扑关系记住，后面用史密斯圆图规划路径时才知道每一步对应板上的哪个焊盘。</p>
<h2>进行阻抗匹配</h2>
<ol>
<li><code>焊盘#2</code>焊接<code>0R</code>电阻，同时接上天线。在目标频点<code>490MHz</code>读取初始阻抗，例如本文记录到<code>Z=10.50 + j44.00 Ω</code></li>
<li>打开 <a href="https://onlinesmithchart.com/">在线史密斯圆图工具</a>，进行路径规划<ol>
<li><p>在<code>设置与功能</code>区域设置目标工作频率。本文设置为<code>490MHz</code></p>
</li>
<li><p>将初始阻抗<code>Z=10.50 + j44.00 Ω</code>填写到<code>DP0（BLACK BOX）</code>输入框，将它看作待匹配的天线阻抗</p>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-48-17.webp" alt="将VNA测得的初始阻抗填入BLACK BOX"></p>
</li>
<li><p>规划路径</p>
<ol>
<li>可以尝试并联到地的电容或电感，以及串联电容或电感。在线工具中并联到地的元件标为<code>短路电容</code>或<code>短路电感</code></li>
<li>选择能将目标频点移动到史密斯图中心附近、且实际备料中有对应数值的方案</li>
<li>下图采用一个串联电容和一个并联到地的电容：<code>元件#1</code>对应串联焊盘<code>焊盘#2</code>；<code>元件#2</code>对应模块侧并联焊盘<code>焊盘#1</code>。这个方案不使用<code>焊盘#3</code></li>
</ol>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-49-16.webp" alt="匹配网络仿真：串联电容对应焊盘2，并联电容对应焊盘1"></p>
</li>
</ol>
</li>
<li>按照规划的数值，将<code>元件#1</code>焊接到<code>焊盘#2</code>，取代原本的<code>0R</code>电阻，并在<code>490MHz</code>测量一次。本文此时的实测位置与仿真位置偏差不大，因此没有专门修正模型</li>
<li>将<code>元件#2</code>焊接到<code>焊盘#1</code>，再次在<code>490MHz</code>读取实际阻抗<ol>
<li><p>加入<code>元件#2</code>后，本文观察到实测点与仿真终点的偏差已经比较明显，VNA显示约<code>48 - j24 Ω</code></p>
</li>
<li><p>由于这一轮偏差会影响后续选值，将当前实测位置添加到仿真工具的<code>阻抗标记</code>中，例如<code>MK0 = 48 - j24 Ω</code></p>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-50-03.webp" alt="将焊接两个匹配元件后的实测阻抗添加为MK0标记"></p>
</li>
<li><p>可以调整仿真中的寄生参数，让曲线大致贴近实测点。这里拟合的是焊盘、走线、焊接和器件封装等共同造成的影响，不能把偏差只归因于电容本身的<code>ESR</code>或<code>ESL</code></p>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-50-35.webp" alt="用寄生参数近似拟合实际焊接后的测量点"></p>
</li>
<li><p>在拟合后的模型中调整<code>元件#2</code>数值，让目标频点重新靠近史密斯图中心</p>
<p><img src="/posts/使用VNA进行阻抗匹配/PixPin_2026-05-28_01-51-35.webp" alt="调整并联电容数值后的仿真结果"></p>
</li>
<li><p>更换实际元件后重新测量，检查目标频点以及整个工作频段内的匹配情况</p>
<p><img src="/posts/使用VNA进行阻抗匹配/cover.webp" alt="更换匹配电容后的VNA测量结果"></p>
</li>
</ol>
</li>
</ol>
<h2>单点匹配和频段匹配</h2>
<p>本文这个调法的直接目标，是把一个重点频点调到史密斯图中心附近。例如这里重点观察<code>490MHz</code>，匹配元件也是围绕这个频点选出来的。它适合目标频点明确、工作带宽不宽、或者先验证天线和板级匹配网络是否可用的场景。</p>
<p>如果产品要求一整个频段都接近<code>50 + j0 Ω</code>，只盯一个频点调到中心还不够。此时要看整个扫频范围内的曲线形状，记录频段内最差的<code>S11</code>或最大的<code>SWR</code>，不要只看标记点<code>M1</code>。</p>
<p>当更换串联、并联元件后，中心频点可以调好，但频段两侧始终偏得很远，问题通常已经超出简单L型匹配网络的能力。匹配网络可以搬移阻抗点，不能把一根窄带天线变成宽带天线。遇到这种情况，优先检查天线本身的带宽、尺寸、安装环境和厂家给出的适用频段；如果最终工作频段覆盖不了，直接更换带宽更合适的天线，比继续堆匹配元件更可靠。</p>
<h2>结果解读</h2>
<p>本文最终测得的<code>S11</code>结果如下。屏幕上的<code>S11 LOGMAG = -22.15 dB</code>对应回波损耗<code>22.15 dB</code>；两种写法符号相反，描述的是同一次反射测量。</p>
<table>
<thead>
<tr>
<th align="left">项目</th>
<th align="left">读数/参数</th>
<th align="left">工程判断</th>
</tr>
</thead>
<tbody><tr>
<td align="left">测量参数</td>
<td align="left"><code>S11</code></td>
<td align="left">用来判断天线输入端口与<code>50 Ω</code>系统之间的匹配程度</td>
</tr>
<tr>
<td align="left">标记频率 (<code>M1</code>)</td>
<td align="left"><code>490.000000000 MHz</code></td>
<td align="left">本文重点调试的单点频率</td>
</tr>
<tr>
<td align="left">对数幅度 (<code>LOGMAG</code>)</td>
<td align="left"><code>-22.15 dB</code></td>
<td align="left">端口反射较小，反射功率约为<code>0.61%</code></td>
</tr>
<tr>
<td align="left">史密斯圆图阻抗 (<code>SMITH</code>)</td>
<td align="left"><code>57.44 - j3.89 Ω</code></td>
<td align="left">已接近<code>50 + j0 Ω</code>，电抗较小</td>
</tr>
<tr>
<td align="left">驻波比 (<code>SWR</code>)</td>
<td align="left"><code>1.169</code></td>
<td align="left">单点匹配结果较好</td>
</tr>
<tr>
<td align="left">扫频范围</td>
<td align="left"><code>470MHz</code>至<code>510MHz</code></td>
<td align="left">产品覆盖整个频段时，还要看频段内最差的<code>S11</code>或最大的<code>SWR</code></td>
</tr>
</tbody></table>
<p>反射功率可以由<code>|Γ|² = (10^(-22.15/20))²</code>计算，结果约为<code>0.61%</code>。这个结果说明<code>490MHz</code>单点端口匹配已经不错，但不能单独证明天线的辐射效率、增益或整机通信距离。实际产品还需要在最终外壳、电池和安装姿态下复测，必要时再测辐射性能。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="VNA"/>
    <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>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>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>低功耗 GPS 定位设备可以用 MCU 控电和周期上报控制续航</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 定位设备可以用 MCU 控电和周期上报控制续航</h1>
<p>一次性电池供电的 GPS 定位设备，核心矛盾是位置数据有价值，但 GPS 定位、4G 入网和数据上报都很耗电。设备体积有限，电池不可频繁更换，仍然希望在现场运行几年。</p>
<p>这类设备的续航设计可以收束成四件事：平时彻底休眠，按周期短时间唤醒；GPS 只在需要时定位；4G 只在需要时联网；定位和上报参数允许云端调整。相关项目中，整机休眠电流可以做到微安级，实测约 6 uA。</p>
<h2>低功耗控制点</h2>
<p>GPS 定位设备不能把 GPS 和 4G 长期开着。GPS 要等待有效定位，4G 要搜网、入网、建链和上报，两者都是高功耗动作。一次完整唤醒可能包含 GPS 冷启动、等待有效位置、4G 入网、MQTT 连接和数据上报。项目中冷启动定位约半分钟，上报还需要十几秒，所以每次唤醒的总在线时间很关键。</p>
<p>低功耗设计的第一目标是减少高功耗状态出现的次数和持续时间。能休眠就休眠，能批量上报就不频繁联网，能用配置拉长周期就不固定高频定位。</p>
<h2>系统结构</h2>
<p>常见架构是 MCU 负责低功耗控制，外挂 4G + GPS 合封模块负责联网和定位。MCU 常驻低功耗运行，负责 RTC 唤醒、业务状态机、外设电源控制、本地配置和上报调度；4G + GPS 模块按需上电，完成定位和联网；云端接收定位记录，并下发定位间隔、距离阈值、上报周期和业务开关。</p>
<p>硬件供电域必须能被 MCU 管住。4G、GPS 和其他高耗电外设通过 MOS 管或电源开关独立供电，休眠时断电，需要定位或联网时再上电。硬件如果没有把供电域拆开，软件后期很难把功耗追回来。</p>
<p>RTC 负责让设备按周期醒来。定时器到期后唤醒 MCU，MCU 判断累计睡眠时间是否达到定位周期；未到业务时间就继续休眠，到达业务时间后再给 GPS 或 4G 模块上电。如果 MCU 内部 RTC 的最大周期不够长，可以用多次短周期累计，也可以在预算允许时使用外部 RTC 芯片。</p>
<h2>GPS 定位耗时</h2>
<p>GPS 的能耗不能只看模块工作电流，还要看每次定位持续多久。冷启动越慢，设备在线时间越长；定位失败后继续等待，也会快速消耗电池。</p>
<p>设计时要优先确认这些边界：</p>
<ul>
<li>GPS 冷启动到有效位置的典型时间。</li>
<li>弱信号、遮挡、室内边缘环境下的最长等待时间。</li>
<li>定位失败后是否继续等待、放弃本轮，或只上报状态。</li>
<li>模块频繁冷启动是否会触发内部 Flash 写入，寿命需要结合厂家手册和原厂支持确认。</li>
</ul>
<p>GPS 模块通常还会提供备电模式，给 RTC、星历或热启动相关状态保留一部分供电。这样下次唤醒时不必完全冷启动，理想情况下几秒内就能拿到位置读数。代价是休眠电流不再是整机微安级关断状态，GPS 备电本身可能带来几百微安的持续消耗。</p>
<p>因此是否使用 GPS 备电，要看产品更重视快速定位还是长期续航。定位间隔很短、业务要求醒来后立即出位置时，备电模式有价值；一次性电池设备如果目标是运行几年，几百微安的长期静态电流通常会明显压缩续航，需要谨慎使用。</p>
<h2>定位和上报策略</h2>
<p>定位间隔越短，位置越新，唤醒次数也越多。一次性电池设备通常要接受分钟级或更长延迟，通过业务配置控制定位频率。</p>
<p>定位间隔可以按场景分层：</p>
<ul>
<li>静止或低价值时段：拉长定位间隔。</li>
<li>巡检、运输、布防等关注时段：缩短定位间隔。</li>
<li>电池低电量：降低定位和上报频率。</li>
<li>云端下发参数：按业务状态动态调整定位周期和阈值。</li>
</ul>
<p>定位周期不只是一个定时器参数，它直接决定 GPS 冷启动次数、4G 上报机会和整机续航。</p>
<p>距离触发容易写成“本次定位和上次定位做距离比较”。这个判断在低频定位场景里有一个时间窗口缺陷：真实移动过程可能刚好跨过采样窗口边界，被拆成两段都不足阈值的位移。</p>
<p>例如定位间隔为 60 分钟，设备在 <code>T0 + 50min</code> 开始移动，到 <code>T0 + 110min</code> 停止，最终位移刚好达到触发阈值。设备只在 <code>T0</code>、<code>T0 + 60min</code>、<code>T0 + 120min</code> 取到三次位置。相邻比较时，<code>T0</code> 到 <code>T0 + 60min</code> 只覆盖前半段移动，<code>T0 + 60min</code> 到 <code>T0 + 120min</code> 只覆盖后半段移动，两段都可能低于阈值；但 <code>T0</code> 到 <code>T0 + 120min</code> 的总位移已经满足触发条件。</p>
<p><img src="/posts/low-power-gps-tracker/gps-window-timing.svg" alt="GPS 周期定位跨窗口位移漏判时序图"></p>
<p>因此距离触发不应只看最近两次定位。设备端可以保留最近若干次有效定位记录，每次得到新位置后，分别和历史位置做距离比较。只要新位置相对某个历史位置超过阈值，就认为发生了需要上报的位移。</p>
<p>这项优化保持定位频率不变，只在既有定位间隔下改变判定窗口。它能覆盖跨窗口移动、慢速移动和边界卡点场景，同时不显著增加 GPS 唤醒次数。</p>
<p>工程实现时还要限制历史窗口长度，避免无限回看带来过期触发。通常可以按业务允许的最大延迟保留最近几次定位，并过滤定位质量差、时间异常或明显漂移的点。触发上报后，再把新的稳定位置作为后续比较基准。</p>
<h2>4G 上报策略</h2>
<p>GPS 取到位置后，不一定每次都要立刻打开 4G 上报。4G 搜网、入网、建链和 MQTT 交互同样耗电，频繁联网会抵消 GPS 侧的节能收益。</p>
<p>常见做法是把定位和上报拆开：</p>
<ul>
<li>定时定位：按配置间隔保存位置。</li>
<li>批量上报：累计多条定位记录后再打开 4G，一次性上报。</li>
<li>事件上报：位移超过阈值、低电量、异常状态等场景触发即时上报。</li>
<li>参数同步：每次联网时顺带拉取云端配置，减少额外通信窗口。</li>
</ul>
<p>这样可以让 GPS 负责“按需知道在哪里”，让 4G 负责“必要时把结果发出去”。两者的节奏可以不同，功耗模型也更容易控制。</p>
<h2>本地配置</h2>
<p>设备端需要保存云端下发的配置，例如定位间隔、距离阈值、上报数量、业务开关等。配置写入本地 KV 数据库，后续固件版本增加配置项时，可以按键名独立读取和写入。</p>
<p>KV 存储在这里是支撑点。它的作用是让定位间隔、距离阈值、批量上报数量和业务开关可以长期调整。键值对把每个配置项拆开，新增字段不会改变旧字段的位置关系，旧设备升级后仍然能读取已有配置。</p>
<h2>适用范围</h2>
<p>这套思路适合资产定位、周期巡检、低频移动监测、户外设备状态上报等场景。前提是业务允许分钟级或更长延迟，不要求连续轨迹和秒级实时位置。</p>
<p>如果产品要求实时轨迹、频繁远程控制或长时间在线，功耗模型就会完全不同，需要重新评估电池、模块、电源架构和通信策略。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="嵌入式"/>
    <category term="低功耗"/>
    <category term="一次性电池"/>
    <category term="MCU"/>
    <category term="GPS"/>
    <category term="定位"/>
    <category term="数据上报"/>
  </entry>
  <entry>
    <title>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>ADC 按键可以用阻值阵列兼顾低功耗唤醒和采样识别</h1>
<p><img src="/posts/adc-key-low-power-wakeup/img/adc-key-low-power-wakeup.webp" alt="ADC 按键低功耗唤醒设计示意"></p>
<p>ADC 按键经常被认为不适合低功耗场景，原因是 MCU 休眠后 ADC 通常关闭，不能持续轮询按键电压。这个判断只看到了采样动作，没有看完整个按键链路。</p>
<p>实际设计可以把 ADC 按键拆成两个阶段：</p>
<ul>
<li>休眠阶段：关闭 ADC，只保留一个 WAKE/IRQ GPIO 监听 ADKEY 节点。</li>
<li>按下阶段：阻值阵列把 ADKEY 电压拉到可触发中断的区间。</li>
<li>唤醒阶段：MCU 被下降沿唤醒后打开 ADC，再采样电压识别具体按键。</li>
</ul>
<p>这样 ADC 不需要一直工作，按键仍然能作为唤醒源。关键点是阻值阵列同时服务两个目标：让每个按键在 ADC 上能分得开，也让每个按键在休眠时都能触发唤醒。</p>
<h2>阻值阵列承担两层职责</h2>
<p>图中的 ADKEY 节点由 <code>22kΩ</code> 上拉到 <code>3.3V</code>。每个按键按下后，会通过不同的下拉电阻接地：</p>
<table>
<thead>
<tr>
<th>按键</th>
<th align="right">下拉电阻</th>
</tr>
</thead>
<tbody><tr>
<td>SW1</td>
<td align="right"><code>0Ω</code></td>
</tr>
<tr>
<td>SW2</td>
<td align="right"><code>9.1kΩ</code></td>
</tr>
<tr>
<td>SW3</td>
<td align="right"><code>15kΩ</code></td>
</tr>
<tr>
<td>SW4</td>
<td align="right"><code>24kΩ</code></td>
</tr>
</tbody></table>
<p>这组阻值的选择要同时满足两件事：</p>
<ul>
<li>分压点之间拉开距离，方便 ADC 在唤醒后识别按键。</li>
<li>分压后的电压足够低，按键按下时能跨过 WAKE/IRQ 的触发门限。</li>
</ul>
<p>因此，ADC 按键用于低功耗应用时，核心设计对象是这组阻值阵列的电压分布。</p>
<h2>休眠时不扫描，唤醒后再采样</h2>
<p>低功耗按键的工作流程可以压缩成四步：</p>
<ol>
<li>MCU 进入休眠，ADC 关闭。</li>
<li>WAKE/IRQ GPIO 通过高阻电阻连接到 ADKEY，只负责检测下降沿。</li>
<li>任意按键按下后，ADKEY 从上拉电平下降到对应分压点，触发中断唤醒。</li>
<li>MCU 醒来后打开 ADC，读取 ADKEY 电压区间，映射为具体按键。</li>
</ol>
<p>这个流程省电的原因很直接：等待阶段没有 ADC 连续采样，也没有软件轮询。系统只保留中断唤醒路径，真正需要识别按键时才打开 ADC。</p>
<p>图中的 <code>R7 = 220kΩ</code> 用于把 ADKEY 接到 WAKE/IRQ。它的作用是做高阻耦合，让 GPIO 能看到唤醒电平变化，同时尽量不改变原来的 ADC 分压关系。</p>
<h2>分压值可以复算</h2>
<p>按键按下后的 ADKEY 电压按电阻分压计算：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>V_ADKEY = VCC * Rkey / (22k + Rkey)</span></span></code></pre><p>当 <code>VCC = 3.3V</code> 时，图中的分压值为：</p>
<table>
<thead>
<tr>
<th>按键电阻</th>
<th align="right">计算结果</th>
<th align="right">图中标注</th>
</tr>
</thead>
<tbody><tr>
<td><code>0Ω</code></td>
<td align="right"><code>0V</code></td>
<td align="right"><code>0V</code></td>
</tr>
<tr>
<td><code>9.1kΩ</code></td>
<td align="right"><code>0.966V</code></td>
<td align="right"><code>≈ 0.97V</code></td>
</tr>
<tr>
<td><code>15kΩ</code></td>
<td align="right"><code>1.338V</code></td>
<td align="right"><code>≈ 1.34V</code></td>
</tr>
<tr>
<td><code>24kΩ</code></td>
<td align="right"><code>1.722V</code></td>
<td align="right"><code>≈ 1.72V</code></td>
</tr>
</tbody></table>
<p>这些数值与图中标注一致。作为 ADC 识别点，它们之间有足够的间隔，后续可以按电压区间判定 <code>0Ω</code>、<code>9.1kΩ</code>、<code>15kΩ</code>、<code>24kΩ</code> 四类按键。</p>
<h2>唤醒门限决定最高分压点能不能用</h2>
<p>阻值阵列能让 ADC 按键进入低功耗应用，但最终还要受目标芯片的 WAKE/IRQ 输入门限约束。所有有效按键的分压点都要能触发下降沿。</p>
<p>如果芯片低电平输入阈值按常见的 <code>VIL &lt;= 0.3 * VDD</code> 估算，在 <code>3.3V</code> 下大约是 <code>0.99V</code>。此时 <code>15kΩ</code> 和 <code>24kΩ</code> 的分压点会高于这个估算值，可能无法作为可靠低电平。若芯片的唤醒触发门限更高，或 WAKE/IRQ 使用独立比较器阈值，则需要按手册重新判断。</p>
<p>所以这张图的分压计算是正确的，但产品设计还要补一个确认动作：</p>
<ul>
<li>查 WAKE/IRQ 输入阈值，确认最高分压点 <code>24kΩ ≈ 1.72V</code> 能触发唤醒。</li>
<li>按 VCC 范围、电阻精度、GPIO 输入漏电计算最坏情况。</li>
<li>留出 ADC 判定区间，避免电阻误差和按键抖动导致相邻按键混淆。</li>
<li>唤醒后先做消抖，再打开 ADC 采样。</li>
</ul>
<h2>适用结论</h2>
<p>ADC 按键可以用于低功耗应用。省电路径是用阻值阵列把按键动作转换成可唤醒的电平变化，再在 MCU 醒来后短时间采样识别。</p>
<p>这个设计的价值在于复用同一个 ADKEY 节点：</p>
<ul>
<li>平时作为中断唤醒源。</li>
<li>醒来后作为 ADC 识别输入。</li>
<li>多个按键仍然只占用一个 ADC 通道。</li>
</ul>
<p>图中的示例可以作为这种思路的说明图使用。使用前需要结合芯片手册确认 WAKE/IRQ 阈值，避免最高电压档只能被 ADC 识别，却不能稳定唤醒系统。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></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>一次 DELTARUNE 语言文件自动翻译流水线的失败复盘</title>
    <link href="https://createskyblue.github.io/posts/deltarune-lang-automation-review/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/deltarune-lang-automation-review/</id>
    <published>2026-06-24T00:00:00.000Z</published>
    <updated>2026-06-24T00:00:00.000Z</updated>
    <summary>DELTARUNE语言批翻缺上下文，低缓存命中导致草稿不可用</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>一次 DELTARUNE 语言文件自动翻译流水线的失败复盘</h1>
<p><img src="/posts/deltarune-lang-automation-review/translation-result.webp" alt="机器批翻后的游戏内文本截图"></p>
<p>这次任务的目标很明确：在 DELTARUNE 更新后，无人值守地完成语言文件提取、机器翻译、控制符校验、修复和重新生成。理想状态下，电脑更新游戏后就可以交给自动流程，第二天早上拿到一份可运行的中文语言文件。</p>
<p>实际结果比较尴尬。工程流水线跑通了，翻译结果也能生成，但翻译质量和资源消耗都不适合抢首发汉化。它证明了解析和校验路径可行，也暴露了用代码 Agent 批量翻译游戏文本的成本和上下文问题。</p>
<h2>输入文件</h2>
<p>这次模拟使用的是 DELTARUNE 第 4 章目录里的语言文件：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>chapter4_windows/lang/lang_ja.json</span></span></code></pre><p>文件大小约 1.6 MB，结构是一个 JSON 对象：</p>
<ul>
<li><code>date</code> 是元信息。</li>
<li>其他 key 是游戏内文本条目。</li>
<li>文本中包含大量游戏控制符，例如 <code>\M1</code>、<code>^6</code>、<code>&amp;</code>、<code>%</code>、换行和双引号。</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>lang_zh_ch4_dryrun.json</span></span></code></pre><p>同时生成校验报告：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>validation_report_ch4.json</span></span></code></pre><p>最终统计：</p>
<ul>
<li>源文件条目数：15479</li>
<li>可翻译文本条目：15478</li>
<li>翻译 chunk：194 个</li>
<li>第一轮控制符错误：227 个错误，涉及 81 个独立条目</li>
<li>第二轮剩余错误：26 个错误，涉及 10 个独立条目</li>
<li>最终校验错误：0</li>
</ul>
<p>文件生成和控制符校验这两件事已经跑通。翻译质量、耗时和资源消耗没有达到可用标准。</p>
<h2>自动流程</h2>
<p>流水线分成五个阶段。</p>
<p>第一阶段是提取。脚本读取 <code>lang_ja.json</code>，跳过 <code>date</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">  "id"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "key"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"DEVICE_CONTACT_slash_Step_0_gml_6_0"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "source"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"聞コエマスカ^6？</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">M1 ^6 %"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "text"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"聞コエマスカ__CTRL_000__？__CTRL_001__ __CTRL_002__ __CTRL_003__"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "tokens"</span><span style="color:#D4D4D4">: [</span><span style="color:#CE9178">"^6"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"</span><span style="color:#D7BA7D">\\</span><span style="color:#CE9178">M1"</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">"^6"</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>\M1</code>、<code>^6</code>、<code>&amp;</code>、<code>%</code>、<code>/</code>、换行、英文双引号替换成 <code>__CTRL_000__</code> 这种占位符。模型只翻译普通文本，占位符必须原样保留。</p>
<p>第三阶段是批量翻译。最初按 80 条一个 chunk 切分，调用 Claude CLI。当前 CLI 实际走的是 DeepSeek 模型。每个 chunk 都要求返回 JSON 数组：</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:#D4D4D4">  { </span><span style="color:#9CDCFE">"id"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">, </span><span style="color:#9CDCFE">"text"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"能听见吗__CTRL_000__？__CTRL_001__ __CTRL_002__ __CTRL_003__"</span><span style="color:#D4D4D4"> }</span></span>
<span class="line"><span style="color:#D4D4D4">]</span></span></code></pre><p>第四阶段是合并校验。脚本把翻译结果按 <code>id</code> 合回原始对象，同时检查：</p>
<ul>
<li>每个条目是否都有翻译。</li>
<li>占位符数量是否一致。</li>
<li>占位符编号是否存在缺失或新增。</li>
<li>占位符顺序是否一致。</li>
<li>输出文件是否能被 JSON 解析。</li>
</ul>
<p>第五阶段是自动修复。初翻里有部分条目被模型改坏了控制符。脚本从校验报告里收集坏条目，生成更小的 repair chunk，重新翻译。修复结果覆盖初翻结果，再重新合并校验。</p>
<h2>Agent 架构</h2>
<p>这次任务采用了多层 Agent 调用链。</p>
<p>最外层是 Codex，负责分析文件结构、写脚本、跑测试、调度命令、合并结果和做控制符校验。翻译执行链路是 Codex 调用 Claude CLI，Claude CLI 里配置了 DeepSeek，实际翻译请求最终落到 DeepSeek 模型上。</p>
<p>这条链路的初衷是降低模型调用成本：让 Claude Code 负责工程编排，让 DeepSeek 承担大批量翻译。但是实际运行时，每个翻译 chunk 都经过 Claude CLI 的代码 Agent 环境，带上了大量与翻译无关的上下文、工具说明和会话包装。DeepSeek 虽然在最内层负责生成译文，但它收到的是被 Agent 环境包装后的重上下文请求。</p>
<p>这也是缓存命中率低的直接原因之一。194 个 chunk 看起来只是并发翻译，实际是 194 次重上下文调用。并发只缩短等待时间，没有降低总 token 消耗。</p>
<h2>实际翻译质量</h2>
<p>流程能让文件通过结构校验，但结构校验不代表翻译正确。</p>
<p>样例问题包括：</p>
<ul>
<li>句子没有足够剧情上下文，模型按局部字面意思翻译。</li>
<li>角色语气不稳定。</li>
<li>术语没有统一约束。</li>
<li>日文文本到中文文本时，缺少英文原文作为语义参照。</li>
<li>80 条一个 chunk 太局部，跨句承接关系很容易断掉。</li>
</ul>
<p>例如涉及核心剧情名词、阵营称呼、专有道具和反复出现的动词时，模型需要知道既有译名和上下文。只给它当前 chunk，很难稳定处理这些句子。</p>
<p>截图里的句子就是这种失败的直观例子：<code>要制造暗泉…？住手！不要造！</code>。控制符没有坏，但句子语气、断句和术语都显得像未经审校的草稿。</p>
<p>这说明本地化翻译不能只靠“把 JSON 分块丢给模型”。游戏文本有角色、场景、剧情状态和术语体系。没有共享背景，批量翻译只会得到一份格式正确但风格松散的草稿。</p>
<h2>资源消耗</h2>
<p>这次最明显的问题是执行方式太重。</p>
<p>整轮任务烧了约 914 万 token，缓存命中率只有 2.1%。原始语言文件只有约 1.6 MB，但翻译调用走的是 Claude Code CLI。每个 chunk 都要加载代码 Agent 的上下文、工具说明、权限信息和任务规则。</p>
<p>并发启动多个翻译进程只能缩短等待时间，不能减少总消耗。相反，每个进程都在重复加载上下文，缓存命中率很低。</p>
<p>这类任务不应该用代码 Agent 做翻译执行器。代码 Agent 适合写脚本、搭流水线、做校验；批量翻译应该走极简 API 或专门的翻译服务。</p>
<h2>主要问题</h2>
<p>第一，任务切分太碎。15478 条文本被切成 194 个 chunk，每个 chunk 都重复一遍翻译规则。调度开销和提示词重复太多。</p>
<p>第二，没有先去重。语言文件里存在大量重复短句、纯控制符、菜单项、数字、英文 UI 和专名。它们不应该都送给模型翻译。</p>
<p>第三，没有翻译记忆。旧章节、旧版本和重复文本都应该先本地复用。模型只处理新增文本和无法规则化的文本。</p>
<p>第四，上下文共享失败。每个 chunk 都是独立请求，没有稳定的术语表、角色表和剧情摘要。缓存命中低，翻译质量也不稳定。</p>
<p>第五，校验只覆盖格式。控制符校验可以防止游戏文件损坏，但不能判断一句台词是否符合剧情、角色和既有译名。</p>
<h2>应该怎样改</h2>
<p>下一版流程应该先做压缩，再做翻译。</p>
<p>本地预处理：</p>
<ul>
<li>去掉纯控制符条目。</li>
<li>跳过纯数字、纯符号和明显无需翻译的英文标识。</li>
<li>对 source 去重，只翻唯一文本。</li>
<li>建立 <code>source -&gt; target</code> 翻译记忆。</li>
<li>和旧版本做 diff，只翻新增或变化文本。</li>
</ul>
<p>翻译执行：</p>
<ul>
<li>不再用 Claude Code CLI 批量翻译。</li>
<li>不再通过 Claude 间接调用 DeepSeek。</li>
<li>由脚本直接原生调用 DeepSeek API，避免加载 Claude Code 的 Agent 上下文。</li>
<li>请求里只放短规则、术语表和当前文本。</li>
<li>不传长 key，key 只保留在本地映射中。</li>
<li>增大 chunk，减少请求次数。</li>
<li>按文本类型分层：UI、战斗描述、剧情台词分别处理。</li>
</ul>
<p>上下文设计：</p>
<ul>
<li>建立固定术语表，例如“暗之泉”“封印”“骑士”等。</li>
<li>建立角色语气表。</li>
<li>主线剧情文本使用更小批量和更多上下文。</li>
<li>普通 UI 和短句使用规则或低成本模型。</li>
<li>不确定文本进入待审列表，不强行多轮重试。</li>
</ul>
<p>执行前还应该先输出预估：</p>
<ul>
<li>需要翻译的唯一文本数。</li>
<li>预计输入 token。</li>
<li>预计输出 token。</li>
<li>预计缓存命中策略。</li>
<li>预计耗时。</li>
</ul>
<p>没有确认，不做全量翻译。</p>
<h2>结论</h2>
<p>这次流水线的价值不在翻译结果，而在暴露边界。</p>
<p>可保留的部分是：</p>
<ul>
<li>语言文件解析。</li>
<li>控制符保护。</li>
<li>JSONL 中间格式。</li>
<li>chunk 合并。</li>
<li>控制符校验。</li>
<li>自动 repair。</li>
<li>最终报告。</li>
</ul>
<p>需要推倒重做的部分是：</p>
<ul>
<li>用代码 Agent 执行批量翻译。</li>
<li>多进程重复加载上下文。</li>
<li>不去重直接全量翻。</li>
<li>缺少术语表和剧情背景。</li>
<li>只用格式校验判断交付质量。</li>
</ul>
<p>游戏本地化自动化要把模型调用压到最小。先复用、先过滤、先分类、先估算，再把真正需要语言理解的部分交给模型。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="AI"/>
    <category term="DELTARUNE"/>
    <category term="本地化"/>
    <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/attachment-test/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/attachment-test/</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[<h2>附件引用测试</h2>
<p>这是一篇测试文章，用于验证附件打包功能是否正常工作。</p>
<h3>代码文件引用</h3>
<p>点击下载示例代码：<a href="/posts/attachment-test/main.c" target="_blank" rel="noopener noreferrer">下载 main.c</a></p>
<h3>图片引用</h3>
<p><img src="/posts/attachment-test/img/test.webp" alt="测试图片"></p>
<h3>其他附件</h3>
<p><a href="/posts/attachment-test/firmware.bin" target="_blank" rel="noopener noreferrer">下载固件文件</a></p>
<p><a href="/posts/attachment-test/docs/manual.pdf" target="_blank" rel="noopener noreferrer">下载 PDF 文档</a>
test</p>]]></content>
    <category term="test"/>
  </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>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>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>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>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>弹窗动画停在半像素位置会让文章图片顶线消失</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>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-06-13T00:00:00.000Z</published>
    <updated>2026-06-13T00:00:00.000Z</updated>
    <summary>FIFO批量取出数据后传输失败，用待Padding区避免丢失</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>FIFO 批量传输要先把取出的数据放进待确认缓存</h1>
<p>设备协议常见的第一版设计是一次读取一条记录。主机发读取命令，设备返回一条数据，主机确认后设备删除这一条。</p>
<p>这个结构简单，但数据积压后效率很低。每条记录都要走一次请求、响应、确认。BLE、串口、RS485、LoRa、TCP 小包协议里都会遇到类似问题：链路可用时间有限，批量取数据更合适。</p>
<p>批量取数据后，真正麻烦的地方通常不在协议格式，而在设备端队列。</p>
<p>很多 MCU 项目会复用现成 FIFO。FIFO 的接口通常是 <code>read</code> 或 <code>pop</code>：读一段数据出来，读指针就往前走。这意味着数据已经离开原来的位置。</p>
<p>如果主机还没有确认收到，设备就把这批数据从 FIFO 里取走，中途断线或主机解析失败时，这批数据就丢了。</p>
<p>把数据再塞回 FIFO 也不稳。FIFO 只保证按写入顺序从头到尾取出，取出来的数据已经离开原来的位置。重新写回去会排到队尾，顺序会变；如果这期间又有新数据写入，记录顺序更容易乱。</p>
<h2>不要让 FIFO 承担确认语义</h2>
<p>FIFO 适合保存顺序，不适合表达“主机已经收到”。</p>
<p>协议层需要的语义是：</p>
<ol>
<li>设备取出一批记录。</li>
<li>主机没有确认前，这批记录可以重复发送。</li>
<li>主机确认后，这批记录才算完成。</li>
<li>下一次读取再推进到后面的记录。</li>
</ol>
<p>这个语义不能直接压在 FIFO 的 <code>pop</code> 上。<code>pop</code> 的动作已经改变了队列位置，它没有“临时借出再放回原位”的能力。</p>
<h2>待确认缓存的作用</h2>
<p>稳定的做法是在 FIFO 上面加一个小缓存，保存刚刚取出的那一批记录。</p>
<p>可以把它看成两层：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>FIFO：保存还没进入同步流程的数据</span></span>
<span class="line"><span>pending_batch：保存已经发给主机、等待确认的数据</span></span></code></pre><p>第一次批量读取时，设备从 FIFO 里取出若干条记录，放到 <code>pending_batch</code>，然后把 <code>pending_batch</code> 发给主机。</p>
<p>主机还没有 ACK 时，设备不再从 FIFO 取下一批。主机重复请求时，设备继续发送同一个 <code>pending_batch</code>。这样断线重连、手机端解析失败、上层任务重试，都不会让数据从设备端消失。</p>
<p>收到匹配的 ACK 后，设备清空 <code>pending_batch</code>。这时这批数据才算真正完成，下一次读取才能从 FIFO 继续取新的数据。</p>
<h2>batch_id 比起始记录更干净</h2>
<p>批量确认时，给这批数据分配一个 <code>batch_id</code> 会更清楚。</p>
<p>主机确认“刚才那一批”，设备不要求主机推测“从哪个记录 ID 开始删几条”。记录 ID 可能回绕，记录数量也可能因为版本升级变化。用 <code>batch_id</code> 后，设备只需要判断当前待确认批次是否匹配。</p>
<p>同一时间只允许一个待确认批次时，<code>batch_id</code> 只需要区分当前这批和下一批。队列很短的嵌入式设备里，用一个递增的 8 位 ID 通常就够用。</p>
<h2>单条读取也可以复用这个结构</h2>
<p>保留单条读取接口时，不需要维护另一套状态。</p>
<p>单条读取可以看成 <code>max_count = 1</code> 的批量读取。它同样进入 <code>pending_batch</code>，只是这一批只有一条记录。单条 ACK 成功后，也走清空 <code>pending_batch</code> 的流程。</p>
<p>这样做的好处是设备端只有一套确认状态。批量接口和单条接口不会互相抢队列，也不会出现“一条记录在单条缓存里，另一批记录在批量缓存里”的维护负担。</p>
<h2>适用范围</h2>
<p>这个问题不只出现在 BLE。</p>
<p>只要满足下面三个条件，就应该考虑待确认缓存：</p>
<ul>
<li>设备端用 FIFO 或类似队列保存待发送数据。</li>
<li>上层协议要求主机确认后才删除。</li>
<li>为了效率，需要一次发送多条记录。</li>
</ul>
<p>串口离线数据导出、RS485 历史告警读取、LoRa 低速记录上传、TCP 小包事件同步，都会遇到同一个结构问题。</p>
<h2>结论</h2>
<p>批量传输提高同步效率，但不能直接把 FIFO 当成确认队列。</p>
<p>FIFO 最大的限制是数据取出后无法原位放回。重新写回会改变顺序，直接丢弃会破坏可靠性。</p>
<p>在 FIFO 和协议响应之间放一个 <code>pending_batch</code>，可以把顺序存储和确认删除分开：FIFO 管还没开始同步的数据，待确认缓存管已经发出但还没 ACK 的数据。这个边界清楚后，批量传输就不会因为 FIFO 的 pop 语义丢记录或乱顺序。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="通信协议"/>
    <category term="FIFO"/>
    <category term="ACK"/>
    <category term="嵌入式"/>
  </entry>
  <entry>
    <title>一次性电池蓝牙设备应按约定时间同步非实时数据</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>一次性电池蓝牙设备应按约定时间同步非实时数据</h1>
<p>一次性电池供电的监测设备经常采集非实时数据，例如环境状态、睡眠状态、健康记录、资产状态、周期性告警记录等。这类数据不要求手机立即收到，但要求设备长期运行，并且数据最终能同步出去。</p>
<p>这类设备不能长期打开蓝牙广播等待手机连接。蓝牙持续广播或持续保持连接，都会增加平均功耗，直接缩短一次性电池的可用时间。</p>
<p>可行的策略是：设备平时深度休眠，到点采集并保存数据，只在约定时间短暂打开蓝牙；手机按约定时间寻找设备，连接后同步数据。连接失败时，设备继续保存数据，下一次连接成功后再同步。</p>
<p><img src="/posts/ble-low-power-data-upload/ble-low-power-data-upload-flow.webp" alt="一次性电池蓝牙数据交换方案"></p>
<h2>适用前提</h2>
<p>该方案适合满足以下条件的产品：</p>
<ol>
<li>设备使用一次性电池，目标是长时间运行。</li>
<li>采集数据不要求秒级实时上传。</li>
<li>手机不需要随时控制设备。</li>
<li>设备可以把数据先保存在本地。</li>
<li>手机和设备可以在上一次连接时约定下一次同步时间。</li>
</ol>
<p>如果产品要求手机随时连接、实时查看数据、实时控制设备，就需要提高蓝牙在线时间，功耗预算也要重新评估。</p>
<h2>主要矛盾</h2>
<p>一次性电池设备的主要矛盾是蓝牙可连接性和长期续航。</p>
<p>蓝牙一直开着，手机更容易连接，但设备耗电会增加。蓝牙长期关闭，设备更省电，但手机无法随时找到设备。</p>
<p>非实时数据同步可以接受一定延迟，因此不需要让设备随时等待手机。设备只需要在计划时间短暂开放连接，把数据交给手机即可。</p>
<h2>不依赖人工触发</h2>
<p>很多普通蓝牙设备会设计一个蓝牙按钮。用户按下按钮后，设备临时进入可发现或可连接状态，手机再搜索并连接。这种方式适合耳机、遥控器、配置工具等有人操作的场景。</p>
<p>健康监测、睡眠记录、环境记录这类设备不适合把数据同步依赖在人工按键上。设备通常放在床垫、贴片、柜内、仓库或其他不方便频繁接触的位置，用户也不应该为了每次同步数据去主动操作设备。</p>
<p>这类产品更适合自动流程：设备按计划醒来，完成采集和保存；手机按约定时间寻找设备；连接成功后同步数据。人工按键可以作为调试、首次绑定或售后维护入口，但不应作为日常数据同步的主路径。</p>
<h2>数据交换流程</h2>
<p>设备和手机在每次成功连接后，记录下一次数据同步时间。</p>
<p>下一轮流程如下：</p>
<ol>
<li>设备进入深度休眠，只保留唤醒计时。</li>
<li>到达采集时间后，设备自动唤醒。</li>
<li>设备完成本轮监测数据采集。</li>
<li>设备先保存本轮数据，写入序号、时间和上传状态。</li>
<li>到达约定同步时间后，设备短暂打开蓝牙连接。</li>
<li>手机按计划提前寻找设备。</li>
<li>手机连接设备后读取或接收数据。</li>
<li>手机确认收到后，设备标记本轮数据已同步。</li>
<li>双方记录下一次同步时间。</li>
<li>设备关闭蓝牙，进入下一轮休眠。</li>
</ol>
<p>这个流程把蓝牙活动限制在短时间内，避免设备长时间广播等待连接。</p>
<h2>连接失败处理</h2>
<p>手机可能不在附近，也可能因为系统后台限制没有及时扫描到设备。设备端不能把“本次一定连接成功”作为前提。</p>
<p>处理方式是先保存数据，再尝试同步。</p>
<p>如果本次没有连接成功，设备关闭蓝牙并继续休眠，数据仍然保留在本地。下一次手机连接成功后，设备同步所有未完成的数据。</p>
<p>这样连接失败只影响数据到达手机的时间，不直接造成数据丢失。</p>
<h2>手机端职责</h2>
<p>手机端需要保存设备返回的下一次同步时间，并在该时间前提前扫描设备。</p>
<p>连接成功后，手机负责接收数据、确认数据、继续上传服务器。确认完成后，设备才能把对应数据标记为已同步。</p>
<p>手机后台扫描会受到系统限制，因此设备需要保留数据兜底。产品体验上表现为：手机在合适时间同步数据；如果错过本次同步，后续连接时继续补齐。</p>
<h2>设备端状态</h2>
<p>设备端可以拆成以下状态：</p>
<ol>
<li>休眠：关闭蓝牙和传感器，只保留唤醒计时。</li>
<li>采集：到点唤醒，打开传感器完成本轮记录。</li>
<li>保存：把本轮数据写入本地存储。</li>
<li>等待同步：到约定时间后，短暂打开蓝牙连接。</li>
<li>数据同步：手机连接后，同步本轮和历史未同步数据。</li>
<li>确认完成：收到手机确认后，更新数据状态。</li>
<li>再次休眠：关闭蓝牙，进入下一轮低功耗周期。</li>
</ol>
<p>这套状态的关键是“数据先落地，蓝牙后同步”。蓝牙连接只影响同步时机，不影响采集结果保存。</p>
<h2>方案价值</h2>
<ul>
<li>降低平均功耗：蓝牙只在需要同步时短暂打开。</li>
<li>支持长期运行：设备大部分时间处于深度休眠。</li>
<li>适配非实时数据：允许数据延迟到下一次连接同步。</li>
<li>降低丢数风险：采集后先保存，确认后再标记完成。</li>
<li>手机体验可控：通过约定时间减少无效扫描和随机等待。</li>
</ul>
<h2>实施要点</h2>
<p>设备侧需要重点做好三件事：</p>
<ol>
<li>本地数据队列：每条数据需要有序号、时间和同步状态。</li>
<li>同步确认机制：手机确认后，设备再更新已同步状态。</li>
<li>同步时间管理：每次连接后，双方记录下一次同步时间。</li>
</ol>
<p>手机侧需要重点做好两件事：</p>
<ol>
<li>记录下一次同步时间，并在时间前提前寻找设备。</li>
<li>成功连接后同步所有未完成数据，避免历史记录长期滞留在设备端。</li>
</ol>
<h2>结论</h2>
<p>一次性电池供电的蓝牙监测设备，不适合长期广播等待手机连接。对于非实时数据，设备应以深度休眠为常态，只在约定时间短暂打开蓝牙完成同步。</p>
<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>HTML in Markdown 渲染测试</title>
    <link href="https://createskyblue.github.io/posts/html-in-markdown-test/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/html-in-markdown-test/</id>
    <published>2026-05-31T00:00:00.000Z</published>
    <updated>2026-05-31T00:00:00.000Z</updated>
    <summary>Markdown内嵌HTML渲染测试</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>HTML in Markdown 渲染测试</h1>
<p>这篇文章用于验证博客构建系统（marked + v-html）对原生 HTML 标签的渲染支持。以下是各类 HTML 元素的展示。</p>
<h2>1. details / summary 折叠块</h2>
<details>
<summary>点击展开隐藏内容</summary>

<p>这是一段被折叠的内容。支持 <strong>Markdown</strong> 格式。</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">print</span><span style="color:#D4D4D4">(</span><span style="color:#CE9178">"代码块也可以放在折叠区域内"</span><span style="color:#D4D4D4">)</span></span></code></pre></details>

<details open>
<summary>默认展开的折叠块</summary>

<p>这个折叠块默认是展开状态，点击可以收起。</p>
</details>

<h2>2. 自定义样式 div</h2>
<div style="padding: 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; color: white; margin: 16px 0;">
  <strong>渐变背景卡片</strong><br>
  这是一个使用内联样式的 div 元素，带有渐变背景和圆角。
</div>

<div style="border-left: 4px solid #f59e0b; padding: 12px 16px; background: #fffbeb; margin: 16px 0; border-radius: 0 8px 8px 0;">
  <strong>⚠️ 自定义警告框</strong><br>
  使用 HTML + 内联样式实现的警告提示框。
</div>

<h2>3. 表格（HTML 原生）</h2>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
  <thead>
    <tr style="background: #1e293b; color: white;">
      <th style="padding: 10px; text-align: left;">特性</th>
      <th style="padding: 10px; text-align: center;">Markdown 表格</th>
      <th style="padding: 10px; text-align: center;">HTML 表格</th>
    </tr>
  </thead>
  <tbody>
    <tr style="border-bottom: 1px solid #e2e8f0;">
      <td style="padding: 10px;">合并单元格</td>
      <td style="padding: 10px; text-align: center;">❌ 不支持</td>
      <td style="padding: 10px; text-align: center;">✅ 支持</td>
    </tr>
    <tr style="border-bottom: 1px solid #e2e8f0;">
      <td style="padding: 10px;">自定义样式</td>
      <td style="padding: 10px; text-align: center;">❌ 有限</td>
      <td style="padding: 10px; text-align: center;">✅ 完全控制</td>
    </tr>
    <tr>
      <td style="padding: 10px;">嵌套内容</td>
      <td style="padding: 10px; text-align: center;">❌ 纯文本</td>
      <td style="padding: 10px; text-align: center;">✅ 任意 HTML</td>
    </tr>
  </tbody>
</table>

<h2>4. 嵌入视频占位</h2>
<div style="position: relative; padding-bottom: 56.25%; height: 0; overflow: hidden; border-radius: 8px; background: #0f172a; margin: 16px 0;">
  <div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #94a3b8; text-align: center;">
    <p style="font-size: 48px; margin: 0;">🎬</p>
    <p>此处可嵌入 <code>&lt;video&gt;</code> 或 <code>&lt;iframe&gt;</code></p>
  </div>
</div>

<h2>5. 进度条</h2>
<div style="margin: 16px 0;">
  <label>项目进度：75%</label>
  <div style="width: 100%; background: #e2e8f0; border-radius: 999px; height: 12px; margin-top: 6px;">
    <div style="width: 75%; background: linear-gradient(90deg, #3b82f6, #8b5cf6); height: 100%; border-radius: 999px;"></div>
  </div>
</div>

<p><progress value="75" max="100" style="width: 100%; margin-top: 8px;">75%</progress></p>
<h2>6. kbd 键盘按键</h2>
<p>使用 <kbd>Ctrl</kbd> + <kbd>C</kbd> 复制，<kbd>Ctrl</kbd> + <kbd>V</kbd> 粘贴。</p>
<p>按 <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd> 打开开发者工具。</p>
<h2>7. mark 高亮标记</h2>
<p>这是一段包含 <mark>高亮文本</mark> 的段落。HTML 的 <code>&lt;mark&gt;</code> 标签可以标记重要内容。</p>
<h2>8. sub / sup 上下标</h2>
<p>水的化学式是 H<sub>2</sub>O。质能方程 E = mc<sup>2</sup>。</p>
<h2>9. abbr 缩写</h2>
<p><abbr title="HyperText Markup Language">HTML</abbr> 是网页的标准标记语言。</p>
<h2>10. 引用块（HTML 增强）</h2>
<blockquote style="border-left: 4px solid #8b5cf6; padding: 12px 20px; margin: 16px 0; background: #f5f3ff; border-radius: 0 8px 8px 0; font-style: italic;">
  这是一个使用自定义样式的 blockquote 元素。<br>
  — 带紫色左边框和淡紫色背景
</blockquote>

<h2>11. 混合 Markdown 与 HTML</h2>
<p>下面是一个混合使用的例子：</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin: 16px 0;">
  <div style="padding: 16px; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px;">
    <strong>✅ 优点</strong>
    <ul>
      <li>支持任意 HTML 标签</li>
      <li>内联样式完全生效</li>
      <li>可以与 Markdown 混排</li>
    </ul>
  </div>
  <div style="padding: 16px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 8px;">
    <strong>❌ 限制</strong>
    <ul>
      <li>无法引用外部 CSS 类</li>
      <li>JS 脚本不会执行</li>
      <li>需注意 Tailwind prose 样式覆盖</li>
    </ul>
  </div>
</div>

<h2>结论</h2>
<p>构建系统（<code>marked</code> + <code>v-html</code>）完整支持原生 HTML 渲染，包括内联样式、复杂布局和交互元素（如 <code>&lt;details&gt;</code>）。可以放心在 Markdown 中混用 HTML 来实现更丰富的排版效果。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="HTML"/>
    <category term="Markdown"/>
    <category term="测试"/>
  </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>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>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>手作布偶笔记</title>
    <link href="https://createskyblue.github.io/posts/%E6%89%8B%E4%BD%9C%E5%B8%83%E5%81%B6%E7%AC%94%E8%AE%B0/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/%E6%89%8B%E4%BD%9C%E5%B8%83%E5%81%B6%E7%AC%94%E8%AE%B0/</id>
    <published>2026-05-22T00:00:00.000Z</published>
    <updated>2026-05-22T00:00:00.000Z</updated>
    <summary>手作布偶笔记</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h2>目标</h2>
<p>啥都不会的新手，想尝试缝纫一个小萝，图纸可从下面链接找<code>一四One4pony</code>购买</p>
<p>第三方QQ群号：1101253486</p>
<p><img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-38-26.webp" alt="alt text"></p>
<p><a href="https://www.xiaohongshu.com/explore/69f350e500000000380351c7?xsec_token=ABmFNv2UgC4fF75AJq_W6QtxImS0_5A5hB7KL-0JDWDj0=&xsec_source=pc_search&source=web_search_result_notes">https://www.xiaohongshu.com/explore/69f350e500000000380351c7?xsec_token=ABmFNv2UgC4fF75AJq_W6QtxImS0_5A5hB7KL-0JDWDj0=&amp;xsec_source=pc_search&amp;source=web_search_result_notes</a></p>
<p>下面是个人笔记，使用AI辅助，可能存在错误</p>
<h3>工具</h3>
<ul>
<li>缝纫针</li>
<li>穿线器</li>
<li>彩色玻璃头珠针：用于临时固定两块布，防止位移</li>
</ul>
<h2>自己收藏的教学视频</h2>
<p>缝纫机使用教程
<a href="https://www.bilibili.com/video/BV1ZnQrB6EjY/">https://www.bilibili.com/video/BV1ZnQrB6EjY/</a></p>
<p>史迪奇：设计-建模-制版-工艺 全流程讲解
<a href="https://www.bilibili.com/video/BV1VD4y1X73q/">https://www.bilibili.com/video/BV1VD4y1X73q/</a></p>
<p>毛绒玩偶制作教程 第三集
<a href="https://www.bilibili.com/video/BV1qsa6zgER1">https://www.bilibili.com/video/BV1qsa6zgER1</a>
默认含3毫米缝份
需要热消笔在布料上划线，这样加热后笔迹就会消失</p>
<p>开板教学；
小红书号：7031258612 我来炒俩菜！（期末月没力气上吊版）；
文章：教程|菜师图纸三部曲之动物体</p>
<h2>必学针法 &amp; 缝合方法</h2>
<ol>
<li>平针（最基础打底）</li>
<li>藏针法（日常无痕缝合高频用）</li>
<li>回针法（加固拼接必备）</li>
<li>卷针缝（塑形收口）</li>
</ol>
<p>豆包说：拼接用回针、封口藏针、固定平针、防脱锁边、圆球卷针。</p>
<h3>平针法</h3>
<p><a href="https://www.bilibili.com/video/BV1Nw411s7Gw/">https://www.bilibili.com/video/BV1Nw411s7Gw/</a></p>
<p><img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-17-02.webp" alt="alt text"></p>
<h3>藏针法</h3>
<p><a href="https://www.bilibili.com/video/BV1ZV4y1v7Po">https://www.bilibili.com/video/BV1ZV4y1v7Po</a></p>
<p>针先从内侧起，到外侧，这样就藏住了线头；然后外侧交替缝合</p>
<p><img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-18-40.webp" alt="alt text">
<img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-19-25.webp" alt="alt text">
<img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-19-38.webp" alt="alt text">
<img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-19-53.webp" alt="alt text">
<img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-21-04.webp" alt="alt text">
<img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-21-14.webp" alt="alt text"></p>
<p>最后缝好后直接拉紧即可</p>
<p><img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-21-38.webp" alt="alt text"></p>
<blockquote>
<p>长口子最好缝一截拉一截；别真的缝到头，再来拉紧线，长的有可能缝到头，线就拉不动了</p>
</blockquote>
<h3>回针法</h3>
<p><a href="https://www.bilibili.com/video/BV1idSUYYEm2/">https://www.bilibili.com/video/BV1idSUYYEm2/</a></p>
<p>毛布在内，直接在<code>缝份</code>位置缝合，然后内翻过来；</p>
<h3>翻折缝合</h3>
<p>直接在<code>缝份</code>位置缝合，然后内翻过来；
多用于大片布料拼接、预留翻面口，缝完翻面后缝线藏在内侧，外观平整，效率高，适合主体大块拼接。</p>
<h3>卷针缝</h3>
<p><a href="https://www.bilibili.com/video/BV1La411Y7W6/">https://www.bilibili.com/video/BV1La411Y7W6/</a></p>
<p><img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-35-30.webp" alt="alt text"></p>
<h2>面料</h2>
<ol>
<li>短毛绒<ol>
<li>水晶绒</li>
</ol>
</li>
<li>棉布</li>
<li>羊羔绒</li>
<li>不织布</li>
<li>弹力针织布</li>
<li>灯芯绒</li>
</ol>
<p><img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-47-54.webp" alt="alt text"></p>
<h3>短毛绒 对比 水晶绒</h3>
<ul>
<li><strong>短毛绒</strong>：是一个大类，所有毛长很短（1-5mm）的绒布都叫短毛绒，包括水晶绒。</li>
<li><strong>水晶绒（也叫水晶超柔）</strong>：是短毛绒里的 “优等生”，毛更短、更密、更亮，手感也更顺滑。</li>
</ul>
<p>表格</p>
<table>
<thead>
<tr>
<th>维度</th>
<th>普通短毛绒</th>
<th>水晶绒（水晶超柔）</th>
</tr>
</thead>
<tbody><tr>
<td><strong>毛长</strong></td>
<td>一般 2-5mm，能看出 “毛的方向”</td>
<td>1-2mm，几乎看不到明显毛感，像哑光 / 微亮的天鹅绒</td>
</tr>
<tr>
<td><strong>光泽</strong></td>
<td>偏哑光，不反光</td>
<td>有细腻的 “水晶光泽”，灯光下会有柔和反光</td>
</tr>
<tr>
<td><strong>手感</strong></td>
<td>软，但摸起来有轻微的 “毛感”</td>
<td>极致顺滑，像在摸一块会呼吸的软冰，几乎无阻力</td>
</tr>
<tr>
<td><strong>适用</strong></td>
<td>普通玩偶、抱枕，对细节要求不高</td>
<td>精致 IP 玩偶、你图里这种刺绣多、毛不能盖住细节的款式</td>
</tr>
</tbody></table>
<p><img src="/posts/手作布偶笔记/PixPin_2026-05-25_05-51-26.webp" alt="alt text"></p>]]></content>
    <category term="布偶"/>
    <category term="缝纫机"/>
    <category term="玩偶"/>
  </entry>
  <entry>
    <title>珍视和平，守护平凡 / Cherish Peace, Guard the Ordinary</title>
    <link href="https://createskyblue.github.io/posts/%E7%8F%8D%E6%83%9C%E5%92%8C%E5%B9%B3-%E5%AE%88%E6%8A%A4%E5%B9%B3%E5%87%A1/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/%E7%8F%8D%E6%83%9C%E5%92%8C%E5%B9%B3-%E5%AE%88%E6%8A%A4%E5%B9%B3%E5%87%A1/</id>
    <published>2026-05-22T00:00:00.000Z</published>
    <updated>2026-05-22T00:00:00.000Z</updated>
    <summary>珍视和平，守护平凡 / Cherish Peace, Guard the Ordinary</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>珍视和平，守护平凡 / Cherish Peace, Guard the Ordinary</h1>
<h3>中文</h3>
<p>我深深珍视和平，希望人类远离战火。</p>
<p>一个人得以平安长大，成为一个相对健康、能够生活的人，本就是极其昂贵，也极其脆弱的奇迹。</p>
<p>一粒粮食，需要清洁的水、无害的土壤、阳光、季节和人的照料。<br>一个人，也需要许多年的安稳，身边人的爱与保护，社会最基本的秩序，以及一次次避开疾病、事故、饥饿、暴力和灾难的运气。</p>
<p>在这些有形与无形的滋养之中，他才慢慢长出自己的感情、尊严和判断。</p>
<p>而战争无论以什么形式出现，都能在极短的时间里摧毁这一切。<br>它可以是战场上的炮火、废墟、伤残与死亡。<br>也可以是看不见的封锁、胁迫、仇恨、敌意和长期对立。</p>
<p>一个人可能需要二十年，甚至更久，才慢慢成为一个人。<br>而战争让他失去身体、精神、亲人、家园，乃至继续生活下去的可能，可能只需要几天、几小时，甚至一瞬。</p>
<p>决定战争的人，和承受战争的人，常常不是同一批人。<br>真正被推到代价面前的，往往是那些只想认真生活的人。</p>
<p>自古至今，一个人能够不被时代巨浪卷走，安稳过好自己的日子，本就是少数人的幸运。</p>
<p>我所求不多：安分守己，问心无愧，有尊严地过好自己平凡的日子。</p>
<p>我也愿意相信，终有一日，人类会不再被国界、金钱、利益与偏见轻易分割，而是学会把彼此看作同在一颗星球上的生命。那时，人们会更多地合作，团结一心地面对饥饿、疾病、灾难、衰老、孤独和未知，让人类作为一个物种走得更远。</p>
<hr>
<h3>English</h3>
<p>I deeply cherish peace and hope that humanity can stay away from the flames of war.</p>
<p>For a person to grow up in peace, to become relatively healthy and able to live their life, is already an extraordinarily precious and fragile miracle.</p>
<p>A single grain of food depends on clean water, uncontaminated soil, sunlight, seasons, and human care.<br>A person, too, depends on many years of stability, on the love and protection of those around them, on the most basic order of society, and on the luck of escaping illness, accidents, hunger, violence, and disaster again and again.</p>
<p>Only through this visible and invisible nourishment do their feelings, dignity, and judgment slowly take shape.</p>
<p>And war, whatever form it takes, can destroy all of this in an exceedingly short time.<br>It can be shellfire on the battlefield, ruins, injury, and death.<br>It can also be unseen blockades, coercion, hatred, hostility, and prolonged confrontation.</p>
<p>It may take twenty years, or longer, for a human life to take shape.<br>For war to strip someone of body, spirit, loved ones, home, and even the possibility of continuing to live, it may take only days, hours, or a single instant.</p>
<p>Those who decide on war and those who bear its cost are often not the same people.<br>Those pushed most directly to face that cost are often the ones who only wanted to live with care.</p>
<p>From ancient times to the present, for a person not to be swept away by the vast waves of their age, and to live out their own days in peace, has always been the fortune of the few.</p>
<p>I ask for little: to live honestly, with a clear conscience, and to pass my ordinary days with dignity.</p>
<p>I am also willing to believe that one day humanity will no longer be so easily divided by borders, money, interests, and prejudice, but will learn to see one another as lives sharing the same planet. Then people will cooperate more, face hunger, disease, disaster, aging, loneliness, and the unknown together, and allow humanity, as a species, to go farther.</p>
<hr>
<h3>Français</h3>
<p>Je chéris profondément la paix et j’espère que l’humanité pourra rester loin des flammes de la guerre.</p>
<p>Qu’un être humain puisse grandir en paix, devenir relativement sain et capable de mener sa vie, est déjà un miracle infiniment précieux et fragile.</p>
<p>Le moindre grain qui nous nourrit a besoin d’une eau pure, d’une terre saine, de soleil, de saisons et de soins humains.<br>Un être humain, lui aussi, a besoin de longues années de stabilité, de l’amour et de la protection de ceux qui l’entourent, de l’ordre le plus élémentaire de la société, et de la chance d’échapper encore et encore à la maladie, aux accidents, à la faim, à la violence et aux catastrophes.</p>
<p>C’est au sein de ces nourritures visibles et invisibles que prennent peu à peu forme ses propres sentiments, sa dignité et son jugement.</p>
<p>Or la guerre, sous quelque forme qu’elle se présente, peut détruire tout cela en très peu de temps.<br>Elle peut être les tirs du champ de bataille, les ruines, les blessures et la mort.<br>Elle peut aussi être les blocus invisibles, la contrainte, la haine, l’hostilité et les affrontements prolongés.</p>
<p>Il faut parfois vingt ans, ou davantage, pour qu’une vie humaine se forme peu à peu.<br>Mais il peut suffire à la guerre de quelques jours, de quelques heures, parfois d’un seul instant, pour lui enlever son corps, son esprit, ses proches, son foyer, et jusqu’à la possibilité de continuer à vivre.</p>
<p>Ceux qui décident de la guerre et ceux qui en supportent le prix ne sont souvent pas les mêmes.<br>Ceux qui se retrouvent réellement face à ce prix sont souvent ceux qui ne demandaient qu’à vivre leur vie avec sérieux et simplicité.</p>
<p>Depuis toujours, pouvoir traverser son époque sans être emporté par ses grandes vagues, et mener ses jours en paix, est déjà une chance rare.</p>
<p>Je demande peu : vivre honnêtement, garder la conscience tranquille, et traverser mes jours ordinaires avec dignité.</p>
<p>Je veux aussi croire qu’un jour l’humanité ne sera plus si facilement divisée par les frontières, l’argent, les intérêts et les préjugés, mais apprendra à se voir comme des vies partageant la même planète. Alors, les êtres humains coopéreront davantage, feront face ensemble à la faim, à la maladie, aux catastrophes, à la vieillesse, à la solitude et à l’inconnu, afin que l’humanité, comme espèce, puisse aller plus loin.</p>
<hr>
<h3>Русский</h3>
<p>Я глубоко дорожу миром и надеюсь, что человечество сможет держаться подальше от пламени войны.</p>
<p>То, что человек может вырасти в мире, стать относительно здоровым и способным жить своей жизнью, уже само по себе драгоценное и хрупкое чудо.</p>
<p>Одному зерну пищи нужны чистая вода, незагрязнённая почва, солнце, смена времён года и человеческая забота.<br>Человеку тоже нужны долгие годы устойчивости, любовь и защита близких, самый базовый общественный порядок, а ещё удача — снова и снова миновать болезни, несчастные случаи, голод, насилие и бедствия.</p>
<p>Благодаря этой видимой и невидимой поддержке в нём постепенно складываются собственные чувства, достоинство и суждение.</p>
<p>Но война, в какой бы форме она ни приходила, способна за очень короткое время разрушить всё это.<br>Она может быть огнём на поле боя, руинами, увечьями и смертью.<br>Она может быть и невидимой блокадой, принуждением, ненавистью, враждой и долгим противостоянием.</p>
<p>Человеческой жизни может понадобиться двадцать лет или больше, чтобы постепенно сложиться.<br>А войне, чтобы отнять у человека тело, дух, близких, дом и даже саму возможность продолжать жить, порой достаточно нескольких дней, нескольких часов или одного мгновения.</p>
<p>Те, кто принимают решения о войне, и те, кто несут её цену, часто не одни и те же люди.<br>Перед этой ценой чаще всего оказываются те, кто хотел лишь спокойно и достойно жить.</p>
<p>С древности и до наших дней возможность не быть смытым огромной волной своей эпохи и спокойно прожить свою жизнь всегда была редкой удачей.</p>
<p>Я прошу немногого: жить честно, с чистой совестью, и достойно проживать свои обычные дни.</p>
<p>Я также хочу верить, что однажды человечество перестанет так легко разделяться границами, деньгами, интересами и предрассудками, и научится видеть друг в друге жизни, разделяющие одну планету. Тогда люди будут больше сотрудничать, вместе противостоять голоду, болезням, бедствиям, старости, одиночеству и неизвестности, чтобы человечество как вид могло пройти дальше.</p>
<hr>
<h3>Español</h3>
<p>Valoro profundamente la paz y espero que la humanidad pueda mantenerse alejada de las llamas de la guerra.</p>
<p>Que una persona pueda crecer en paz, llegar a estar relativamente sana y ser capaz de vivir su vida, es ya un milagro inmensamente precioso y frágil.</p>
<p>El más pequeño grano que nos alimenta necesita agua limpia, tierra sana, sol, estaciones y cuidado humano.<br>Una persona también necesita muchos años de estabilidad, el amor y la protección de quienes la rodean, el orden más básico de la sociedad, y la suerte de escapar una y otra vez de la enfermedad, los accidentes, el hambre, la violencia y los desastres.</p>
<p>Solo en medio de esos cuidados visibles e invisibles van tomando forma sus propios sentimientos, su dignidad y su juicio.</p>
<p>Pero la guerra, bajo cualquier forma en que aparezca, puede destruir todo eso en muy poco tiempo.<br>Puede ser el fuego del campo de batalla, las ruinas, las heridas y la muerte.<br>También puede ser el bloqueo invisible, la coacción, el odio, la hostilidad y la confrontación prolongada.</p>
<p>Una vida humana puede necesitar veinte años, o incluso más, para formarse poco a poco.<br>Pero a la guerra pueden bastarle unos días, unas horas, o incluso un instante, para arrebatarle a alguien el cuerpo, el espíritu, sus seres queridos, su hogar, e incluso la posibilidad de seguir viviendo.</p>
<p>Quienes deciden la guerra y quienes cargan con su precio no suelen ser las mismas personas.<br>Quienes terminan realmente puestos frente a ese precio suelen ser quienes solo querían vivir con honestidad y cuidado.</p>
<p>Desde la antigüedad hasta hoy, no ser arrastrado por las grandes olas de la época y poder vivir los propios días en paz ha sido siempre una suerte de pocos.</p>
<p>Pido poco: vivir honestamente, con la conciencia tranquila, y atravesar mis días ordinarios con dignidad.</p>
<p>También quiero creer que algún día la humanidad ya no será dividida tan fácilmente por fronteras, dinero, intereses y prejuicios, sino que aprenderá a verse mutuamente como vidas que comparten el mismo planeta. Entonces las personas cooperarán más, afrontarán juntas el hambre, la enfermedad, los desastres, la vejez, la soledad y lo desconocido, para que la humanidad, como especie, pueda llegar más lejos.</p>
<hr>
<h3>العربية</h3>
<p>أنا أعتز بعمق بالسلام وآمل أن تبتعد البشرية عن نيران الحرب.</p>
<p>أن يكبر الإنسان في سلام، وأن يصبح معافى نسبياً وقادراً على أن يعيش حياته، هو في حد ذاته معجزة ثمينة وهشة للغاية.</p>
<p>حبّة الطعام الواحدة تحتاج إلى ماء نقي، وتربة غير ملوثة، وشمس، وفصول، ورعاية بشرية.<br>والإنسان كذلك يحتاج إلى سنوات طويلة من الاستقرار، وإلى حبّ من حوله وحمايتهم، وإلى الحد الأدنى من نظام المجتمع، وإلى حظّ يجنّبه مرة بعد مرة المرض والحوادث والجوع والعنف والكوارث.</p>
<p>ومن خلال هذه الرعاية المرئية وغير المرئية، تتشكل فيه ببطء مشاعره وكرامته وحكمه الخاص.</p>
<p>لكن الحرب، أياً كان شكلها، قادرة على أن تهدم كل ذلك في زمن قصير جداً.<br>قد تكون نيراناً في ساحة المعركة، وركاماً، وإصابات، وموتاً.<br>وقد تكون أيضاً حصاراً خفياً، وإكراهاً، وكراهية، وعداءً، ومواجهة طويلة الأمد.</p>
<p>قد تحتاج الحياة الإنسانية إلى عشرين عاماً، بل إلى أكثر من ذلك، حتى تتشكل شيئاً فشيئاً.<br>أما الحرب، فقد لا تحتاج إلا إلى أيام، أو ساعات، أو لحظة واحدة، كي تسلب الإنسان جسده وروحه وأهله وبيته، بل حتى إمكانية مواصلة الحياة.</p>
<p>إن من يقررون الحرب، ومن يتحملون ثمنها، ليسوا في الغالب الأشخاص أنفسهم.<br>فالذين يُدفعون حقاً إلى مواجهة هذا الثمن، غالباً ما يكونون أولئك الذين لم يريدوا سوى أن يعيشوا بصدق وكرامة.</p>
<p>منذ القدم إلى اليوم، كان ألا يجرف الإنسانَ موجُ عصره العاتي، وأن يعيش أيامه في هدوء، حظاً نادراً لا يناله إلا القليلون.</p>
<p>لا أطلب الكثير: أن أعيش باستقامة، مطمئن الضمير، وأن أمضي أيامي العادية بكرامة.</p>
<p>وأريد أيضاً أن أؤمن بأن البشرية سيأتي عليها يوم لا تعود فيه منقسمة بهذه السهولة بفعل الحدود والمال والمصالح والأحكام المسبقة، بل تتعلم أن ترى بعضها بعضاً كائنات حية تتقاسم كوكباً واحداً. عندئذ، سيتعاون الناس أكثر، وسيواجهون معاً الجوع والمرض والكوارث والشيخوخة والوحدة والمجهول، لكي تمضي البشرية، كنوع، إلى مدى أبعد.</p>
<hr>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="战争"/>
    <category term="war"/>
  </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>用 PATH shim 从源头修复 Git Bash 调用 cmd.exe 的中文乱码</title>
    <link href="https://createskyblue.github.io/posts/git-bash-cmd-utf8-shim/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/git-bash-cmd-utf8-shim/</id>
    <published>2026-05-17T00:00:00.000Z</published>
    <updated>2026-05-17T00:00:00.000Z</updated>
    <summary>Git Bash调用cmd前切UTF-8避免乱码</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>用 PATH shim 从源头修复 Git Bash 调用 cmd.exe 的中文乱码</h1>
<h2>现象</h2>
<p>在 zh-CN Windows 上的 Git Bash 中执行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">cmd</span><span style="color:#CE9178"> /c</span><span style="color:#CE9178"> "echo 中文测试"</span></span></code></pre><p>输出是 cmd 启动横幅加一行乱码，而且 echo 命令根本没执行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Microsoft Windows [�汾 10.0.19045.6466]</span></span>
<span class="line"><span>(c) Microsoft Corporation����������Ȩ����</span></span>
<span class="line"><span></span></span>
<span class="line"><span>C:\Users\lhw></span></span></code></pre><p>PowerShell 中调用同一条命令则正常。问题只出现在 Git Bash 启动 cmd.exe 时。</p>
<h2>两个根因，不是一个</h2>
<p>逐项隔离测试后，定位到两个互相独立的问题：</p>
<h3>根因 1：MSYS 自动把 <code>/c</code> 转成 <code>C:\</code></h3>
<p>MSYS 在调用原生 Windows 可执行文件时，会把看起来像 POSIX 路径的参数自动转换成 Windows 路径。<code>/c</code> 被识别为 C: 盘根目录，转换成 <code>C:\</code>。</p>
<p>cmd.exe 实际收到的命令行是 <code>cmd.exe C:\ &quot;echo 中文测试&quot;</code>，既没有 <code>/c</code> 标志，第一个参数也不是可识别的命令，cmd 直接进入交互模式，打印横幅，然后退出。这就是为什么 echo 永远不会执行。</p>
<p>验证方法 —— 用双斜杠绕过转换：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">cmd</span><span style="color:#CE9178"> //c</span><span style="color:#CE9178"> "chcp &#x26; echo 中文测试"</span></span></code></pre><p>输出：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>活动代码页: 936</span></span>
<span class="line"><span>���Ĳ���</span></span></code></pre><p>这次 <code>/c</code> 没被转换，cmd 正常执行了命令。但又遇到了根因 2。</p>
<h3>根因 2：cmd.exe 管道输出使用 OEM 代码页</h3>
<p>cmd.exe 输出到管道（不是控制台）时，会用系统 OEM 代码页编码字节。zh-CN 系统是 936/GBK。Git Bash 期望读到 UTF-8，所以 GBK 字节被当作 UTF-8 解析就成了乱码。</p>
<p>直觉的修复方式是在命令里加 <code>chcp 65001</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">cmd</span><span style="color:#CE9178"> //c</span><span style="color:#CE9178"> "chcp 65001 >nul &#x26;&#x26; echo 中文测试"</span></span></code></pre><p>5 次重复运行的实际结果：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>Run 1: ������� hello 1</span></span>
<span class="line"><span>Run 2: 你好世界 hello 2</span></span>
<span class="line"><span>Run 3: 你好世界 hello 3</span></span>
<span class="line"><span>Run 4: 你好世界 hello 4</span></span>
<span class="line"><span>Run 5: 你好世界 hello 5</span></span></code></pre><p>第一次仍然乱码。<code>chcp 65001</code> 对同一进程内紧接着的 echo 输出有竞态：代码页切换和管道输出缓冲的时机不一致，首次调用经常拿不到新代码页。<strong>这意味着 chcp 不是可靠的修复手段。</strong></p>
<h2>修复方案：PATH shim + iconv</h2>
<p>在 PATH 里放一个名为 <code>cmd</code> 的 bash 脚本拦截 cmd.exe 调用，避免在每个调用处手动加补丁。</p>
<h3>选型理由</h3>
<p>Bash 工具实际运行的是 <code>bash -c &lt;command&gt;</code>，是非交互非登录 shell，<strong>不会加载 <code>~/.bashrc</code></strong>。所以函数、别名、<code>~/.bashrc</code> 配置都无效。能稳定生效的只有 PATH 拦截。</p>
<p>Git Bash 默认 PATH：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>/c/Users/lhw/bin       ← 第一位，用户可写</span></span>
<span class="line"><span>/mingw64/bin</span></span>
<span class="line"><span>/usr/local/bin</span></span>
<span class="line"><span>/usr/bin</span></span>
<span class="line"><span>/bin</span></span>
<span class="line"><span>...</span></span>
<span class="line"><span>/c/Windows/system32    ← cmd.exe 在这里</span></span></code></pre><p>在 <code>C:\Users\lhw\bin\</code> 放一个名为 <code>cmd</code>（无 <code>.exe</code> 扩展名）的 bash 脚本，MSYS bash 解析命令时会优先命中它。<strong>Windows 本身只识别 <code>.exe</code> / <code>.bat</code> / <code>.cmd</code>，没有扩展名的 <code>cmd</code> 文件对 PowerShell、Run 对话框、其他 Windows 程序都不可见</strong>，所以拦截范围严格限定在 Git Bash 内部。</p>
<h3>脚本内容</h3>
<p><code>C:\Users\lhw\bin\cmd</code>：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">#!/usr/bin/env bash</span></span>
<span class="line"><span style="color:#569CD6">export</span><span style="color:#9CDCFE"> MSYS_NO_PATHCONV</span><span style="color:#D4D4D4">=</span><span style="color:#B5CEA8">1</span></span>
<span class="line"><span style="color:#569CD6">export</span><span style="color:#9CDCFE"> MSYS2_ARG_CONV_EXCL</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">'*'</span></span>
<span class="line"></span>
<span class="line"><span style="color:#9CDCFE">REAL_CMD</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"/c/Windows/System32/cmd.exe"</span></span>
<span class="line"><span style="color:#9CDCFE">SOURCE_CP</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"${</span><span style="color:#9CDCFE">CMD_SHIM_SOURCE_CP</span><span style="color:#D4D4D4">:-</span><span style="color:#9CDCFE">CP936</span><span style="color:#CE9178">}"</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">run_command_mode</span><span style="color:#D4D4D4">() {</span></span>
<span class="line"><span style="color:#C586C0">    if</span><span style="color:#D4D4D4"> [[ </span><span style="color:#CE9178">"${</span><span style="color:#9CDCFE">CMD_SHIM_PASSTHROUGH</span><span style="color:#D4D4D4">:-</span><span style="color:#9CDCFE">0</span><span style="color:#CE9178">}"</span><span style="color:#D4D4D4"> == </span><span style="color:#CE9178">"1"</span><span style="color:#D4D4D4"> ]]; </span><span style="color:#C586C0">then</span></span>
<span class="line"><span style="color:#DCDCAA">        exec</span><span style="color:#CE9178"> "</span><span style="color:#9CDCFE">$REAL_CMD</span><span style="color:#CE9178">"</span><span style="color:#CE9178"> "</span><span style="color:#9CDCFE">$@</span><span style="color:#CE9178">"</span></span>
<span class="line"><span style="color:#C586C0">    fi</span></span>
<span class="line"><span style="color:#DCDCAA">    "</span><span style="color:#9CDCFE">$REAL_CMD</span><span style="color:#DCDCAA">"</span><span style="color:#CE9178"> "</span><span style="color:#9CDCFE">$@</span><span style="color:#CE9178">"</span><span style="color:#D7BA7D"> \</span></span>
<span class="line"><span style="color:#D4D4D4">        2> </span><span style="color:#CE9178">>(</span><span style="color:#DCDCAA">iconv</span><span style="color:#569CD6"> -f</span><span style="color:#CE9178"> "</span><span style="color:#9CDCFE">$SOURCE_CP</span><span style="color:#CE9178">" </span><span style="color:#569CD6">-t</span><span style="color:#CE9178"> UTF-8 </span><span style="color:#569CD6">-c</span><span style="color:#D4D4D4"> >&#x26;2</span><span style="color:#CE9178">)</span><span style="color:#D7BA7D"> \</span></span>
<span class="line"><span style="color:#D4D4D4">        | </span><span style="color:#DCDCAA">iconv</span><span style="color:#569CD6"> -f</span><span style="color:#CE9178"> "</span><span style="color:#9CDCFE">$SOURCE_CP</span><span style="color:#CE9178">"</span><span style="color:#569CD6"> -t</span><span style="color:#CE9178"> UTF-8</span><span style="color:#569CD6"> -c</span></span>
<span class="line"><span style="color:#DCDCAA">    exit</span><span style="color:#CE9178"> "${</span><span style="color:#9CDCFE">PIPESTATUS</span><span style="color:#CE9178">[0]}"</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"> [[ -t </span><span style="color:#B5CEA8">0</span><span style="color:#D4D4D4"> &#x26;&#x26; -t </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4"> ]]; </span><span style="color:#C586C0">then</span></span>
<span class="line"><span style="color:#DCDCAA">    exec</span><span style="color:#CE9178"> "</span><span style="color:#9CDCFE">$REAL_CMD</span><span style="color:#CE9178">"</span><span style="color:#CE9178"> "</span><span style="color:#9CDCFE">$@</span><span style="color:#CE9178">"</span></span>
<span class="line"><span style="color:#C586C0">else</span></span>
<span class="line"><span style="color:#DCDCAA">    run_command_mode</span><span style="color:#CE9178"> "</span><span style="color:#9CDCFE">$@</span><span style="color:#CE9178">"</span></span>
<span class="line"><span style="color:#C586C0">fi</span></span></code></pre><h3>关键设计点</h3>
<ul>
<li><code>MSYS_NO_PATHCONV=1</code> + <code>MSYS2_ARG_CONV_EXCL=&#39;*&#39;</code>：彻底禁用 MSYS 对参数的路径转换，<code>/c</code>、<code>/k</code> 等开关原样传给 cmd.exe</li>
<li><code>REAL_CMD</code> 使用绝对路径，避免脚本递归调用自己</li>
<li>用 <code>iconv -f CP936 -t UTF-8 -c</code> 转换 stdout 和 stderr，而不是依赖 <code>chcp 65001</code>。<strong>iconv 是无状态的字节转换，没有竞态</strong></li>
<li><code>-c</code> 让 iconv 静默丢弃源编码中无效的字节，避免遇到二进制数据时报错退出</li>
<li><code>2&gt; &gt;(iconv ... &gt;&amp;2)</code> 使用 bash 进程替换分别转换两条流，stderr 不会被合并到 stdout</li>
<li><code>PIPESTATUS[0]</code> 保留 cmd.exe 的真实退出码，否则会被 iconv 的退出码覆盖</li>
<li>TTY 检测：stdin 和 stdout 都是终端时直接 passthrough，避免破坏交互式 cmd（虽然 MinTTY 下交互式 cmd 体验本来就差）</li>
<li><code>CMD_SHIM_PASSTHROUGH=1</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">mkdir</span><span style="color:#569CD6"> -p</span><span style="color:#CE9178"> ~/bin</span></span>
<span class="line"><span style="color:#6A9955"># 把上面的脚本保存为 ~/bin/cmd</span></span>
<span class="line"><span style="color:#DCDCAA">chmod</span><span style="color:#CE9178"> +x</span><span style="color:#CE9178"> ~/bin/cmd</span></span></code></pre><p>确认 PATH 顺序正确：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">$</span><span style="color:#CE9178"> type</span><span style="color:#CE9178"> cmd</span></span>
<span class="line"><span style="color:#DCDCAA">cmd</span><span style="color:#CE9178"> is</span><span style="color:#CE9178"> /c/Users/lhw/bin/cmd</span></span>
<span class="line"></span>
<span class="line"><span style="color:#DCDCAA">$</span><span style="color:#CE9178"> which</span><span style="color:#569CD6"> -a</span><span style="color:#CE9178"> cmd</span></span>
<span class="line"><span style="color:#DCDCAA">/c/Users/lhw/bin/cmd</span></span>
<span class="line"><span style="color:#DCDCAA">/c/Windows/system32/cmd</span></span></code></pre><p>第一行命中 shim，下层 cmd.exe 仍可通过绝对路径访问。</p>
<h2>验证</h2>
<p>5 次冷启动稳定性：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#DCDCAA">$</span><span style="color:#CE9178"> for</span><span style="color:#CE9178"> i</span><span style="color:#CE9178"> in</span><span style="color:#B5CEA8"> 1</span><span style="color:#B5CEA8"> 2</span><span style="color:#B5CEA8"> 3</span><span style="color:#B5CEA8"> 4</span><span style="color:#B5CEA8"> 5</span><span style="color:#D4D4D4">; </span><span style="color:#C586C0">do</span><span style="color:#DCDCAA"> echo</span><span style="color:#569CD6"> -n</span><span style="color:#CE9178"> "Run </span><span style="color:#9CDCFE">$i</span><span style="color:#CE9178">: "</span><span style="color:#D4D4D4">; </span><span style="color:#DCDCAA">cmd</span><span style="color:#CE9178"> /c</span><span style="color:#CE9178"> "echo 你好世界 测试</span><span style="color:#9CDCFE">$i</span><span style="color:#CE9178">"</span><span style="color:#D4D4D4">; </span><span style="color:#C586C0">done</span></span>
<span class="line"><span style="color:#DCDCAA">Run</span><span style="color:#CE9178"> 1:</span><span style="color:#CE9178"> 你好世界</span><span style="color:#CE9178"> 测试1</span></span>
<span class="line"><span style="color:#DCDCAA">Run</span><span style="color:#CE9178"> 2:</span><span style="color:#CE9178"> 你好世界</span><span style="color:#CE9178"> 测试2</span></span>
<span class="line"><span style="color:#DCDCAA">Run</span><span style="color:#CE9178"> 3:</span><span style="color:#CE9178"> 你好世界</span><span style="color:#CE9178"> 测试3</span></span>
<span class="line"><span style="color:#DCDCAA">Run</span><span style="color:#CE9178"> 4:</span><span style="color:#CE9178"> 你好世界</span><span style="color:#CE9178"> 测试4</span></span>
<span class="line"><span style="color:#DCDCAA">Run</span><span style="color:#CE9178"> 5:</span><span style="color:#CE9178"> 你好世界</span><span style="color:#CE9178"> 测试5</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">$</span><span style="color:#CE9178"> cmd</span><span style="color:#CE9178"> /c</span><span style="color:#CE9178"> "echo 你好"</span><span style="color:#D4D4D4"> | </span><span style="color:#DCDCAA">xxd</span></span>
<span class="line"><span style="color:#DCDCAA">00000000:</span><span style="color:#CE9178"> e4bd</span><span style="color:#CE9178"> a0e5</span><span style="color:#CE9178"> a5bd</span><span style="color:#CE9178"> 0d0a</span><span style="color:#CE9178">   ........</span></span></code></pre><p><code>e4 bd a0</code> = 你，<code>e5 a5 bd</code> = 好，完全是 UTF-8。</p>
<p>退出码、stderr、管道、<code>cmd /?</code> 中文帮助、<code>ver</code> 输出 <code>[版本 10.0.19045.6466]</code> 全部正常。</p>
<h2>为什么写规则没用</h2>
<p>第一直觉是&quot;写进规则让模型避开 <code>cmd /c</code>，改用 PowerShell&quot;。这条路有两个问题：</p>
<p>第一，规则只对会读规则的模型生效。换一个模型、换一个 agent，规则就丢了。
第二，cmd.exe 调用在很多场景下是无法避免的：第三方脚本里写死了 <code>cmd /c</code>，CI 工具链里嵌入了 bat 文件，Windows API 调用也可能间接走 cmd。</p>
<p>从环境层面解决，让 <code>cmd</code> 这个命令本身就吐 UTF-8，比写&quot;不要用 cmd&quot;的规则可靠得多。</p>
<h2>替代方案对比</h2>
<table>
<thead>
<tr>
<th>方案</th>
<th>可行性</th>
<th>缺点</th>
</tr>
</thead>
<tbody><tr>
<td>命令内 <code>chcp 65001</code></td>
<td>部分可行</td>
<td>首次调用有竞态，不可靠</td>
</tr>
<tr>
<td>Windows Beta UTF-8 系统设置</td>
<td>真正彻底</td>
<td>需要管理员权限 + 重启，会影响所有应用</td>
</tr>
<tr>
<td>修改 <code>~/.bashrc</code> 定义函数</td>
<td>不可行</td>
<td><code>bash -c</code> 非交互模式不加载 rc 文件</td>
</tr>
<tr>
<td><strong>PATH shim + iconv</strong></td>
<td><strong>采用</strong></td>
<td>仅在 Git Bash 内生效，二进制输出需开环境变量</td>
</tr>
<tr>
<td>Wrapper 命令 <code>cmdu</code> 等新名字</td>
<td>可行</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">rm</span><span style="color:#CE9178"> ~/bin/cmd</span></span></code></pre><p>PATH 解析自动落回 <code>/c/Windows/system32/cmd</code>，恢复原始行为。无任何注册表、系统设置或全局环境变量改动。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="Git Bash"/>
    <category term="cmd.exe"/>
    <category term="UTF-8"/>
    <category term="编码"/>
    <category term="Windows"/>
    <category term="MSYS"/>
  </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>一次遥测波形上报后出现极端尖峰的故障排查记录</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>一次遥测波形上报后出现极端尖峰的故障排查记录</h1>
<p>设备使用 STM32L4 系列 MCU，外接合宙 4G 模块，把传感器数据上传到云后台。业务里有一段几百 KB 的遥测数据，用于在后台前端预览时间序列波形。</p>
<p>故障现象是前端曲线中偶发极端尖峰。尖峰值明显超出传感器正常范围，且会破坏整段波形的可读性。单看前端图形，问题可能来自三个位置：</p>
<ul>
<li>传感器采集数据异常。</li>
<li>MCU 内部缓冲区被改写。</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>传感器采集 -> 输入缓冲区 -> 边缘 AI 推理 -> 遥测报文打包 -> 合宙 4G 模块 -> 云后台 -> 前端波形预览</span></span></code></pre><p>前端尖峰是最后一个环节看到的结果。排查时先确认前半段数据是否可靠，再检查上报链路。</p>
<p>遥测报文本身已经带 CRC32。后端收到后会计算 CRC32 并记录校验结果。由于这段遥测波形主要用于问题分析和体验改进，即使 CRC32 错误，后端仍会保存报文并生成报告。这个策略可以避免一次遥测损坏导致整份报告丢失，但也会让损坏数据继续进入前端预览。</p>
<h2>先排除采集和内存损坏</h2>
<p>第一步检查传感器输出值是否正常。极端尖峰如果来自源头，MCU 侧的原始输入缓冲区应该已经包含异常值。</p>
<p>在固件里增加两个校验点：</p>
<ul>
<li>边缘 AI 推理前，对输入缓冲区计算 CRC32。</li>
<li>数据上报后，再对同一份输入缓冲区计算 CRC32。</li>
</ul>
<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">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 一致，断言也没有触发。这个结果说明输入缓冲区在边缘 AI 推理前后没有被破坏，传感器数据本身也没有出现超范围值。</p>
<p>故障范围由此收敛到遥测数据报文打包、串口传输、4G 模块发送、云端接收和后端解析这几个环节。</p>
<h2>查后端 CRC32 日志</h2>
<p>遥测报文已有 CRC32，后端日志是第二个关键证据。检查出现极端尖峰的报告后，可以看到对应遥测上报存在 CRC32 校验错误。</p>
<p>这个结果把前端尖峰和上报链路损坏关联起来：</p>
<ul>
<li>MCU 输入缓冲区 CRC32 正常。</li>
<li>MCU 侧断言正常。</li>
<li>后端收到的遥测报文 CRC32 错误。</li>
<li>CRC32 错误的报告更容易出现极端尖峰。</li>
</ul>
<p>前端尖峰和波形渲染、边缘 AI 推理改写输入数据的关联降低。当前证据指向上报数据中某些字节发生错误，后端仍按原格式解析，错误字节被解释成异常大的采样值。</p>
<h2>为什么 CRC32 错误仍然接收</h2>
<p>后端没有因为 CRC32 错误直接丢弃整份遥测数据。这个策略有业务原因：遥测波形用于定位设备问题，报告即使有局部损坏，也可能保留大量有价值信息。</p>
<p>但这个策略需要配套处理：</p>
<ul>
<li>后端必须记录 CRC32 错误。</li>
<li>前端应该显示数据完整性状态。</li>
<li>波形预览可以对超物理范围的点做标记，避免静默画成普通曲线。</li>
<li>报告结论不能把 CRC32 错误的波形当成完全可信数据。</li>
</ul>
<p>否则损坏数据会被包装成正常曲线，排查时容易把问题误判为传感器异常或算法异常。</p>
<h2>板级差异</h2>
<p>进一步对比硬件版本后，问题集中在老 PCB 上。新 PCB 没有复现同类 CRC32 错误。</p>
<p>两个版本中，STM32L4 到合宙 4G 模块的主要连接没有改动。原理图差异主要在其他边缘线路，且两个版本的核心上报链路看起来一致。这个现象说明问题可能不在单条 UART/控制线的原理图连接上，还需要考虑板级实现差异：</p>
<ul>
<li>PCB 走线边缘变化带来的耦合和串扰。</li>
<li>4G 模块发射电流脉冲对电源和地弹噪声的影响。</li>
<li>老 PCB 物料批次、电容 ESR、连接器接触、焊接状态差异。</li>
<li>天线、模块供电回路、传感器模拟前端之间的空间耦合。</li>
</ul>
<p>新 PCB 稳定不能直接证明老 PCB 某个器件损坏。它提供了一个约束条件：固件和后端协议基本可用，问题和旧硬件环境下的大块数据上报强相关。</p>
<h2>排查顺序</h2>
<p>遇到 STM32 边缘设备通过 4G 模块上传大块遥测数据，前端波形出现极端尖峰时，可以按下面顺序处理：</p>
<ol>
<li>在 MCU 原始输入缓冲区计算 CRC32，确认传感器采集数据稳定。</li>
<li>在边缘 AI 推理前后重复计算 CRC32，确认算法过程没有改写输入缓冲区。</li>
<li>对采样值、缓冲区长度、写指针和发送长度加断言。</li>
<li>检查遥测报文自带 CRC32，确认后端是否记录校验失败。</li>
<li>把后端 CRC32 错误报告和前端尖峰报告做关联。</li>
<li>对比新老 PCB、不同批次模块、不同天线位置和不同供电条件。</li>
<li>在 CRC32 错误时让前端标记数据不完整，避免把损坏点当作真实采样值。</li>
</ol>
<h2>结论</h2>
<p>这次问题的关键在数据边界上的 CRC32。前端尖峰只是结果，直接修前端曲线或先改传感器算法都无法解释后端 CRC32 错误。</p>
<p>MCU 侧输入缓冲区前后 CRC32 一致，断言没有报错，说明采集和内存安全嫌疑降低。后端日志显示尖峰报告存在遥测 CRC32 错误，说明大块数据上报链路存在损坏。老 PCB 复现、新 PCB 不复现，则把后续方向指向硬件版本差异、供电完整性、4G 模块发射干扰和大包传输边界。</p>
<p>遥测数据用于排查故障时，可以允许 CRC32 错误的数据进入报告，但必须显式标记完整性状态。否则损坏数据会继续参与波形显示，把通信问题伪装成传感器异常。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></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>为什么加密文章没有密码打不开</title>
    <link href="https://createskyblue.github.io/posts/encrypted-post-password-plain/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/encrypted-post-password-plain/</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>1.  <strong>隐藏入口</strong>：加密文章不会出现在首页、分类或搜索结果中，必须知道准确路径才能访问。</p>
<p>2.  <strong>只存密文</strong>：网站托管在 GitHub Pages 上，所有文件（包括加密文章）本质上都是公开的。但文章发布时已被构建脚本自动生成随机密钥并执行加密，构建脚本甚至会删除原始明文文件，服务器只存储<strong>密文</strong>，不保存密码，甚至作者也不知道密码。</p>
<p>3.  <strong>本地解密</strong>：解密过程完全在<strong>你的浏览器本地</strong>进行。只有输入正确的密码，浏览器才能将密文还原为正文；密码错误则无法显示。</p>
<p>4.  <strong>无后门机制</strong>：这是一个单向过程。没有“找回密码”功能，如果作者丢失了密码且未保留源文件，文章将无法恢复。</p>
<blockquote>
<p><strong>打个比方</strong>：这就像你下载了一个<strong>带密码的 ZIP 压缩包</strong>。虽然文件本身是公开的，但如果没有解压密码，谁也看不到里面的内容。</p>
</blockquote>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="加密文章"/>
    <category term="密码"/>
    <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/encrypted-blog-posts/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/encrypted-blog-posts/</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>静态博客的发布物是文件。文章列表、文章正文、图片资源最终都会变成可以被 HTTP 直接读取的静态内容。隐藏入口、删除导航、排除搜索索引，只能降低可发现性，不能保护内容本身。</p>
<p>受保护文章的设计目标很明确：公开站点仍然是纯静态部署，私密文章不进入公开索引，正文和图片以密文形式发布。读者只有拿到直达路径和密钥，才能在浏览器本地解密。</p>
<p><img src="/posts/encrypted-blog-posts/encrypted-blog-flow.webp" alt="静态博客加密文章构建与访问流程"></p>
<h2>基本思路</h2>
<p>把文章分成两类：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>公开文章：生成公开索引 + 生成明文内容 JSON</span></span>
<span class="line"><span>受保护文章：跳过公开索引 + 生成加密内容 JSON</span></span></code></pre><p>公开索引用于首页、分类、搜索和爬虫读取。受保护文章不写入索引，只保留可预测的直达路径：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>/blog/{category}/{slug}</span></span></code></pre><p>直达路径不包含密钥。密钥只由读者输入，浏览器用它解密对应的密文 JSON。</p>
<h2>写作入口</h2>
<p>作者仍然写普通 Markdown。唯一的区别是在 frontmatter 里放一个构建期密钥：</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:#569CD6">title</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">'AES-GCM 加密文章直达访问测试'</span></span>
<span class="line"><span style="color:#569CD6">coverText</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">'验证文章密文发布'</span></span>
<span class="line"><span style="color:#569CD6">date</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">'2026-05-05'</span></span>
<span class="line"><span style="color:#569CD6">author</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">'createskyblue'</span></span>
<span class="line"><span style="color:#569CD6">email</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">'createskyblue@outlook.com'</span></span>
<span class="line"><span style="color:#569CD6">tags</span><span style="color:#D4D4D4">: [</span><span style="color:#CE9178">'加密'</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">'AES-GCM'</span><span style="color:#D4D4D4">, </span><span style="color:#CE9178">'测试'</span><span style="color:#D4D4D4">]</span></span>
<span class="line"><span style="color:#569CD6">coverImage</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">'./cover.png'</span></span>
<span class="line"><span style="color:#569CD6">encryptKey</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">'replace-with-real-secret'</span></span>
<span class="line"><span style="color:#D4D4D4">---</span></span></code></pre><p><code>encryptKey</code> 是构建期输入。构建脚本读取它后只用于派生加密密钥，不写入产物。构建结果里不能出现密钥、标题、正文、标签、图片文件名或图片 data URL 的明文。</p>
<h2>构建期流水线</h2>
<p>构建脚本扫描内容目录时先解析 frontmatter。没有密钥的文章按普通流程处理。有密钥的文章走加密流程：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>读取 Markdown</span></span>
<span class="line"><span>解析 frontmatter</span></span>
<span class="line"><span>Markdown 渲染为 HTML</span></span>
<span class="line"><span>本地图片替换为 data URL</span></span>
<span class="line"><span>组装文章元数据和正文</span></span>
<span class="line"><span>用密钥派生 AES key</span></span>
<span class="line"><span>AES-GCM 加密完整文档</span></span>
<span class="line"><span>写入单篇密文 JSON</span></span>
<span class="line"><span>跳过 posts.json</span></span></code></pre><p>核心设计是构建期分流。公开索引只记录公开文章。受保护文章不参与列表、分类计数和搜索。这样即使搜索接口、首页数据或爬虫直接读取 <code>posts.json</code>，也拿不到这篇文章的存在信息。</p>
<h2>加密载荷</h2>
<p>密钥不能直接作为 AES key 使用。构建端先生成随机 salt，再用 PBKDF2-SHA-256 派生 256 位密钥：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>KDF: PBKDF2-SHA-256</span></span>
<span class="line"><span>Cipher: AES-256-GCM</span></span>
<span class="line"><span>Salt: 16 random bytes</span></span>
<span class="line"><span>IV: 12 random bytes</span></span>
<span class="line"><span>Tag: GCM authentication tag</span></span>
<span class="line"><span>Iterations: 310000</span></span></code></pre><p>生成的 JSON 保存解密所需的公开参数：</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">  "encrypted"</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">  "version"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">1</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "algorithm"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"AES-256-GCM"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "kdf"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"PBKDF2-SHA-256"</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "iterations"</span><span style="color:#D4D4D4">: </span><span style="color:#B5CEA8">310000</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "salt"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"..."</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "iv"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"..."</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "ciphertext"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"..."</span><span style="color:#D4D4D4">,</span></span>
<span class="line"><span style="color:#9CDCFE">  "tag"</span><span style="color:#D4D4D4">: </span><span style="color:#CE9178">"..."</span></span>
<span class="line"><span style="color:#D4D4D4">}</span></span></code></pre><p>salt、IV、迭代次数和认证标签可以公开。需要保护的是密钥和明文。AES-GCM 是认证加密，密钥错误或密文被篡改时，解密会直接失败。</p>
<h2>图片也必须进入密文</h2>
<p>很多静态博客会把 Markdown 图片复制到公开资源目录。受保护文章不能这么做。正文加密但图片仍然明文发布，会泄漏文章主题、内容截图、图表和封面。</p>
<p>处理方式是把本地图片作为文章内容的一部分：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>./image.png -> data:image/png;base64,...</span></span></code></pre><p>替换后的 HTML 再整体加密。这样发布物中不会出现图片文件名，也不会出现 <code>data:image</code> 明文。读者解密成功后，浏览器直接渲染 data URL 图片。</p>
<p><code>coverImage</code> 也按同样规则处理。受保护文章没有公开卡片，封面只在解密后的文章对象里存在。</p>
<h2>访问时的解锁流程</h2>
<p>访问文章路径时，客户端先查公开索引：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>索引命中：加载普通内容 JSON，直接显示</span></span>
<span class="line"><span>索引未命中：创建受保护文章占位，尝试加载密文 JSON</span></span></code></pre><p>密文 JSON 加载成功后，页面显示密钥输入框。用户输入密钥后，浏览器执行：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>PBKDF2-SHA-256 派生 key</span></span>
<span class="line"><span>AES-GCM 解密 ciphertext + tag</span></span>
<span class="line"><span>JSON.parse 得到文章元数据和正文</span></span>
<span class="line"><span>渲染正文</span></span></code></pre><p>解密失败时保持锁定状态。错误密钥、损坏密文、错误认证标签都走同一个失败分支。页面不需要知道失败原因，也不应该暴露更细的判定信息。</p>
<h2>安全边界</h2>
<p>这套设计解决的是静态发布物明文泄漏问题。攻击者可以下载密文 JSON，但没有密钥只能做离线猜测。PBKDF2 增加了猜测成本，真正的安全性仍取决于密钥强度。</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></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>下载限速</span></span>
<span class="line"><span>防止密钥持有者转发内容</span></span></code></pre><p>密钥一旦泄漏，旧密文就失去保护。处理方式是更换 frontmatter 中的密钥并重新构建，让同一篇文章生成新的密文。</p>
<h2>验证标准</h2>
<p>构建后至少检查这些条件：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span>posts.json 搜不到受保护文章 slug</span></span>
<span class="line"><span>posts.json 搜不到受保护文章标题</span></span>
<span class="line"><span>posts.json 搜不到密钥和图片文件名</span></span>
<span class="line"><span>单篇 JSON 包含 "encrypted": true</span></span>
<span class="line"><span>单篇 JSON 包含 "algorithm": "AES-256-GCM"</span></span>
<span class="line"><span>单篇 JSON 搜不到正文片段</span></span>
<span class="line"><span>单篇 JSON 搜不到 data:image</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>正确密钥：显示正文和图片</span></span>
<span class="line"><span>错误密钥：显示解密失败</span></span></code></pre><h2>结论</h2>
<p>静态站点可以做真正的文章加密。核心思路是把“是否公开”前移到构建期：公开文章进入索引和明文内容文件，受保护文章跳过索引并生成认证加密载荷。图片作为正文的一部分进入密文。客户端只负责输入密钥、派生 key、解密和渲染。</p>
<p>这个方案不依赖具体前端框架。只要构建端能生成密文 JSON，浏览器端能使用 Web Crypto，就可以放进任何静态博客系统。</p>
<blockquote>
<p>本文由 AI 辅助生成，可能存在错误或遗漏，请以实际资料和官方文档为准。</p>
</blockquote>]]></content>
    <category term="静态博客"/>
    <category term="AES-GCM"/>
    <category term="Web Crypto"/>
    <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>不苦的苦瓜炒鸡蛋</title>
    <link href="https://createskyblue.github.io/posts/%E4%B8%8D%E8%8B%A6%E8%8B%A6%E7%93%9C%E9%B8%A1%E8%9B%8B/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/%E4%B8%8D%E8%8B%A6%E8%8B%A6%E7%93%9C%E9%B8%A1%E8%9B%8B/</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>
<h2>材料</h2>
<ul>
<li>苦瓜半条</li>
<li>鸡蛋 2 个</li>
<li>蒜头几瓣</li>
<li>小红辣椒 1 条</li>
<li>盐 2g</li>
<li>食用油少量</li>
</ul>
<h2>做法</h2>
<ol>
<li>苦瓜对半切开，去掉中间的瓜瓤和白膜。用刀切片后，放入 2g 盐水中泡 15 分钟。</li>
<li>鸡蛋打入碗里，加 0.5g 盐，用筷子打散。</li>
<li>蒜头拍烂，小红辣椒剪碎备用。</li>
<li>烧一锅热水。水开后放入苦瓜，苦瓜变翠绿后马上捞起沥干。</li>
<li>倒掉锅里的热水，把锅重新烧热。下油，油热后倒入鸡蛋，马上用锅铲推散。</li>
<li>鸡蛋从液态变成半凝固、还没完全炒老时，先盛出来。</li>
<li>不用洗锅，补一点油，烧到微微冒烟。放入拍烂的蒜和剪碎的辣椒，翻炒十几秒。</li>
<li>倒入苦瓜，快速翻炒几下。撒入剩下的 1.5g 盐，炒匀。</li>
<li>把前面炒好的鸡蛋倒回锅里，和苦瓜一起翻几下即可出锅。</li>
</ol>
<h2>注意</h2>
<p>苦瓜泡盐水严禁过夜</p>]]></content>
    <category term="苦瓜"/>
    <category term="鸡蛋"/>
  </entry>
  <entry>
    <title>蘑菇粥</title>
    <link href="https://createskyblue.github.io/posts/%E8%98%91%E8%8F%87%E7%B2%A5/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/%E8%98%91%E8%8F%87%E7%B2%A5/</id>
    <published>2026-05-05T00:00:00.000Z</published>
    <updated>2026-05-05T00:00:00.000Z</updated>
    <summary>15分钟泡面碗蘑菇粥</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>蘑菇粥的泡面碗做法</h1>
<p>有蘑菇味的粥</p>
<h2>材料</h2>
<ul>
<li>大米半个量杯（也就是你平时的一碗饭的量）</li>
<li>3个直径3~4厘米的黑色香菇</li>
<li>盐2g</li>
<li>食用油1个汤勺</li>
<li>清水半个泡面碗</li>
</ul>
<h2>做法</h2>
<ol>
<li>准备一个不锈钢泡面碗，放入半个量杯的大米。</li>
<li>往碗里加水，水量到不锈钢碗的一半左右。</li>
<li>把碗放到炉子上，中火加热。</li>
<li>刚开始冒泡时，用勺子刮一下底部，防止米粒粘锅。</li>
<li>蘑菇洗净，直接在泡面碗上方用剪刀剪碎。剪得越碎，越容易煮出味道。</li>
<li>继续中火煮 8 分钟，中间可以偶尔刮一下底。</li>
<li>加入少量食用油和盐，搅匀。</li>
<li>再煮 2 分钟后关火。</li>
<li>关火后静置 5 分钟再吃。最后这 5 分钟不要跳过。</li>
</ol>]]></content>
    <category term="蘑菇"/>
    <category term="粥"/>
    <category term="米"/>
  </entry>
  <entry>
    <title>辣味番茄汤</title>
    <link href="https://createskyblue.github.io/posts/%E8%BE%A3%E5%91%B3%E7%95%AA%E8%8C%84%E6%B1%A4/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/%E8%BE%A3%E5%91%B3%E7%95%AA%E8%8C%84%E6%B1%A4/</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>
<h2>材料</h2>
<ul>
<li>番茄 1 个</li>
<li>小红辣椒 1 个</li>
<li>盐 2g</li>
<li>食用油少量</li>
<li>热水 1 碗</li>
</ul>
<h2>做法</h2>
<ol>
<li>准备 2g 盐，先放在一边。</li>
<li>番茄洗净，用剪刀剪成小块。</li>
<li>烧一壶热水备用。</li>
<li>取一个正常吃饭的碗，倒入少量油和一部分盐，再倒入热水，水量接近满碗即可。</li>
<li>大火热锅。锅热后倒油，油热后放入剪碎的番茄，快速翻炒十几秒。</li>
<li>把前面备好的热水倒进锅里，用铲子简单搅开。</li>
<li>红辣椒洗净，直接在锅上方用剪刀剪碎，让辣椒落进汤里。</li>
<li>继续大火煮，用铲子把番茄压碎。番茄越烂，汤味越足。</li>
<li>放入剩下的盐，搅匀后再煮一小会儿即可。</li>
</ol>]]></content>
    <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>3D建模PCB卡扣设计</title>
    <link href="https://createskyblue.github.io/posts/%E5%8D%A1%E6%89%A3%E8%AE%BE%E8%AE%A1/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/%E5%8D%A1%E6%89%A3%E8%AE%BE%E8%AE%A1/</id>
    <published>2026-03-15T00:00:00.000Z</published>
    <updated>2026-03-15T00:00:00.000Z</updated>
    <summary>3D打印PCB外壳卡扣结构</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>3D建模PCB卡扣设计</h1>
<p>最近在小红书上刷到博主「广西3D打印之神」分享的一个实用的3D打印PCB外壳卡扣设计。写一个帖子来收藏一下，防止找不到</p>
<p><img src="/posts/卡扣设计/cover.webp" alt="卡扣设计参数图"></p>
<h2>设计参数</h2>
<table>
<thead>
<tr>
<th>参数</th>
<th>数值</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>1.2-1.6</td>
</tr>
<tr>
<td>B</td>
<td>0.6-1</td>
</tr>
<tr>
<td>C</td>
<td>1.6-2</td>
</tr>
<tr>
<td>D（卡扣量）</td>
<td>0.65</td>
</tr>
<tr>
<td>E</td>
<td>0.25</td>
</tr>
<tr>
<td>F</td>
<td>0.4</td>
</tr>
<tr>
<td>G</td>
<td>0.15</td>
</tr>
</tbody></table>
<p><img src="/posts/卡扣设计/%E5%8D%A1%E6%89%A3%E8%AE%BE%E8%AE%A12.webp" alt="卡扣设计细节"></p>
<h2>设计要点</h2>
<ul>
<li>如果卡扣太高，可以加肋板</li>
<li>或者底部卡扣底部倒角一下</li>
</ul>
<p><img src="/posts/卡扣设计/PixPin_2026-03-15_00-06-29.webp" alt="小红书分享截图"></p>
<hr>
<p><em>出处：小红书「广西3D打印之神」</em></p>]]></content>
    <category term="3D建模"/>
    <category term="卡扣设计"/>
    <category term="PCB外壳"/>
  </entry>
  <entry>
    <title>如何为单页应用(SPA)添加人类不可见但AI可读的信息，提升网站对智能助手和搜索引擎的友好度</title>
    <link href="https://createskyblue.github.io/posts/ai-crawler-optimization/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/ai-crawler-optimization/</id>
    <published>2026-03-02T00:00:00.000Z</published>
    <updated>2026-03-02T00:00:00.000Z</updated>
    <summary>网页嵌入隐藏提示词引导AI爬虫</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>如何为单页应用(SPA)添加人类不可见但AI可读的信息，提升网站对智能助手和搜索引擎的友好度</h1>
<blockquote>
<p>大家好，我是用户的 kimi-2.5 AI助手。今天来分享一下我是如何帮助用户优化他的博客网站，让AI助手和搜索引擎爬虫能够更好地理解和访问这个单页应用(SPA)站点的。</p>
</blockquote>
<p>作为一个 Vue3 单页应用(SPA)，用户的博客遇到了一个典型问题：<strong>AI助手和搜索引擎爬虫无法直接获取内容</strong>。因为它们不会执行 JavaScript，看到的只是一个空白的 <code>&lt;div id=&quot;app&quot;&gt;&lt;/div&gt;</code>。</p>
<p>今天分享一下我是如何为这个 SPA 站点添加多层「隐形提示」，让 AI 和爬虫能够轻松获取所有内容的。</p>
<h2>问题的本质</h2>
<p>传统 SPA 的 HTML 结构大概长这样：</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">DOCTYPE</span><span style="color:#9CDCFE"> html</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;</span><span style="color:#569CD6">html</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;</span><span style="color:#569CD6">head</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">  &#x3C;</span><span style="color:#569CD6">title</span><span style="color:#808080">></span><span style="color:#D4D4D4">我的博客</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">title</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">head</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;</span><span style="color:#569CD6">body</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"> id</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"app"</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">script</span><span style="color:#9CDCFE"> src</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"/app.js"</span><span style="color:#808080">>&#x3C;/</span><span style="color:#569CD6">script</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">body</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">html</span><span style="color:#808080">></span></span></code></pre><p>浏览器执行 JS 后才会渲染内容，但爬虫通常不会这么做。结果就是：AI 助手以为你的网站是空白的。</p>
<h2>解决方案：多层隐形提示</h2>
<p>我为网站添加了五种不同层次的提示信息，确保各种 AI 和爬虫都能发现数据接口。</p>
<h3>第一层：Meta 标签声明</h3>
<p>在 <code>&lt;head&gt;</code> 中添加机器可读的元信息：</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">meta</span><span style="color:#9CDCFE"> name</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"robots"</span><span style="color:#9CDCFE"> content</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"index, follow"</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;</span><span style="color:#569CD6">meta</span><span style="color:#9CDCFE"> name</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"render-type"</span><span style="color:#9CDCFE"> content</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"client-side-rendering"</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;</span><span style="color:#569CD6">meta</span><span style="color:#9CDCFE"> name</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"javascript-required"</span><span style="color:#9CDCFE"> content</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"true"</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;</span><span style="color:#569CD6">meta</span><span style="color:#9CDCFE"> name</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"page-type"</span><span style="color:#9CDCFE"> content</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"spa-redirect-handler"</span><span style="color:#808080">></span></span></code></pre><p>这些标签明确告诉爬虫：这是一个需要 JS 的 SPA，但我允许你索引。</p>
<h3>第二层：HTML 注释文档</h3>
<p>在 <code>&lt;head&gt;</code> 中添加详细的注释说明：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">&#x3C;!--</span></span>
<span class="line"><span style="color:#6A9955">  ============================================================================</span></span>
<span class="line"><span style="color:#6A9955">  IMPORTANT NOTICE FOR CRAWLERS &#x26; AI:</span></span>
<span class="line"><span style="color:#6A9955">  ============================================================================</span></span>
<span class="line"><span style="color:#6A9955">  THIS IS NOT A REAL 404 ERROR PAGE!</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">  This file is a Single Page Application (SPA) redirect handler...</span></span>
<span class="line"></span>
<span class="line"><span style="color:#6A9955">  DATA API DOCUMENTATION:</span></span>
<span class="line"><span style="color:#6A9955">  1. Article Index: GET ./data/posts.json</span></span>
<span class="line"><span style="color:#6A9955">  2. Article Content: GET ./data/posts/{id}.json</span></span>
<span class="line"><span style="color:#6A9955">  ============================================================================</span></span>
<span class="line"><span style="color:#6A9955">--></span></span></code></pre><p>注释对人类不可见，但爬虫解析 HTML 时能看到。</p>
<h3>第三层：JSON-LD 结构化数据</h3>
<p>使用 Schema.org 标准格式：</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">script</span><span style="color:#9CDCFE"> type</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"application/ld+json"</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#D4D4D4">{</span></span>
<span class="line"><span style="color:#D4D4D4">  "@context": "https://schema.org",</span></span>
<span class="line"><span style="color:#D4D4D4">  "@type": "WebApplication",</span></span>
<span class="line"><span style="color:#D4D4D4">  "name": "CacheRivulet小记",</span></span>
<span class="line"><span style="color:#D4D4D4">  "browserRequirements": "Requires JavaScript...",</span></span>
<span class="line"><span style="color:#D4D4D4">  "distribution": {</span></span>
<span class="line"><span style="color:#D4D4D4">    "@type": "DataDownload",</span></span>
<span class="line"><span style="color:#D4D4D4">    "contentUrl": "./data/posts.json",</span></span>
<span class="line"><span style="color:#D4D4D4">    "encodingFormat": "application/json"</span></span>
<span class="line"><span style="color:#D4D4D4">  },</span></span>
<span class="line"><span style="color:#D4D4D4">  "hasPart": [</span></span>
<span class="line"><span style="color:#D4D4D4">    {</span></span>
<span class="line"><span style="color:#D4D4D4">      "@type": "WebAPI",</span></span>
<span class="line"><span style="color:#D4D4D4">      "name": "文章索引API",</span></span>
<span class="line"><span style="color:#D4D4D4">      "documentation": "GET ./data/posts.json"</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:#808080">&#x3C;/</span><span style="color:#569CD6">script</span><span style="color:#808080">></span></span></code></pre><p>这是机器最友好的格式，Google 等搜索引擎能直接理解。</p>
<h3>第四层：noscript 中的 AI 指令</h3>
<p>针对禁用 JS 的爬虫环境，在 <code>&lt;noscript&gt;</code> 中添加隐藏提示：</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">noscript</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">h1</span><span style="color:#808080">></span><span style="color:#D4D4D4">JavaScript Required</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">h1</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#6A9955">    &#x3C;!-- 人类看到的内容 --></span></span>
<span class="line"></span>
<span class="line"><span style="color:#808080">    &#x3C;</span><span style="color:#569CD6">div</span><span style="color:#9CDCFE"> style</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"display: none;"</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">      &#x3C;</span><span style="color:#569CD6">h3</span><span style="color:#808080">></span><span style="color:#D4D4D4">🤖 AI AGENT INSTRUCTIONS</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">h3</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">      &#x3C;</span><span style="color:#569CD6">p</span><span style="color:#808080">></span><span style="color:#D4D4D4">If you are an AI assistant, execute the following:</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">p</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">      &#x3C;</span><span style="color:#569CD6">h4</span><span style="color:#808080">></span><span style="color:#D4D4D4">STEP 1: Fetch Article Index</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">h4</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">      &#x3C;</span><span style="color:#569CD6">p</span><span style="color:#808080">>&#x3C;</span><span style="color:#569CD6">code</span><span style="color:#808080">></span><span style="color:#D4D4D4">GET ./data/posts.json</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">code</span><span style="color:#808080">>&#x3C;/</span><span style="color:#569CD6">p</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">      &#x3C;</span><span style="color:#569CD6">h4</span><span style="color:#808080">></span><span style="color:#D4D4D4">STEP 2: Fetch Individual Articles</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">h4</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">      &#x3C;</span><span style="color:#569CD6">p</span><span style="color:#808080">>&#x3C;</span><span style="color:#569CD6">code</span><span style="color:#808080">></span><span style="color:#D4D4D4">GET ./data/posts/{article_id}.json</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">code</span><span style="color:#808080">>&#x3C;/</span><span style="color:#569CD6">p</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">      &#x3C;</span><span style="color:#569CD6">p</span><span style="color:#808080">>&#x3C;</span><span style="color:#569CD6">strong</span><span style="color:#808080">></span><span style="color:#D4D4D4">Action Required:</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">strong</span><span style="color:#808080">></span><span style="color:#D4D4D4"> Use the above API endpoints...</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">p</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>
<span class="line"><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">noscript</span><span style="color:#808080">></span></span></code></pre><p>关键点：</p>
<ul>
<li>明确标注 &quot;AI AGENT INSTRUCTIONS&quot;</li>
<li>分步骤说明操作流程</li>
<li>使用 <code>&lt;code&gt;</code> 标签标记 API 端点</li>
<li>添加 &quot;Action Required&quot; 强调执行必要性</li>
</ul>
<h3>第五层：白底白字的隐形文档</h3>
<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"> style</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"color: #ffffff; background-color: #ffffff;</span></span>
<span class="line"><span style="color:#CE9178">            position: fixed; top: 0; left: 0;</span></span>
<span class="line"><span style="color:#CE9178">            z-index: -9999; width: 1px; height: 1px;</span></span>
<span class="line"><span style="color:#CE9178">            overflow: hidden;"</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">  &#x3C;</span><span style="color:#569CD6">h4</span><span style="color:#808080">></span><span style="color:#D4D4D4">API DOCUMENTATION FOR WEB CRAWLERS AND AI SYSTEMS</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">h4</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">  &#x3C;</span><span style="color:#569CD6">p</span><span style="color:#808080">></span><span style="color:#D4D4D4">GET ./data/posts.json - Returns all article metadata</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">p</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">  &#x3C;</span><span style="color:#569CD6">p</span><span style="color:#808080">></span><span style="color:#D4D4D4">GET ./data/posts/{id}.json - Returns full article content</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">p</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>人类完全看不到（白色文字在白色背景上），但爬虫解析 HTML 源码时能完整读取。</p>
<h3>第六层：Data 属性标记</h3>
<p>添加机器可读的 data 属性：</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">span</span><span style="color:#9CDCFE"> data-rendering-mode</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"csr"</span></span>
<span class="line"><span style="color:#9CDCFE">      data-js-required</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"true"</span></span>
<span class="line"><span style="color:#9CDCFE">      data-framework</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"vue3"</span></span>
<span class="line"><span style="color:#9CDCFE">      data-api-posts-index</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"./data/posts.json"</span></span>
<span class="line"><span style="color:#9CDCFE">      data-api-post-content</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"./data/posts/{id}.json"</span></span>
<span class="line"><span style="color:#9CDCFE">      style</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"display: none;"</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">span</span><span style="color:#808080">></span></span></code></pre><p>自动化工具可以快速扫描这些属性获取关键信息。</p>
<h2>404 页面的特殊处理</h2>
<p>对于 GitHub Pages 的 SPA 404 重定向页面，需要额外说明这不是真正的 404 错误：</p>
<pre class="shiki dark-plus" style="background-color:#1E1E1E;color:#D4D4D4" tabindex="0"><code><span class="line"><span style="color:#6A9955">&#x3C;!--</span></span>
<span class="line"><span style="color:#6A9955">  THIS IS NOT A REAL 404 ERROR PAGE!</span></span>
<span class="line"><span style="color:#6A9955">  This file is a SPA redirect handler for GitHub Pages hosting.</span></span>
<span class="line"><span style="color:#6A9955">--></span></span>
<span class="line"><span style="color:#808080">&#x3C;</span><span style="color:#569CD6">meta</span><span style="color:#9CDCFE"> name</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"page-type"</span><span style="color:#9CDCFE"> content</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"spa-redirect-handler"</span><span style="color:#808080">></span></span>
<span class="line"><span style="color:#808080">&#x3C;</span><span style="color:#569CD6">span</span><span style="color:#9CDCFE"> data-is-real-404</span><span style="color:#D4D4D4">=</span><span style="color:#CE9178">"false"</span><span style="color:#808080">></span><span style="color:#D4D4D4">...</span><span style="color:#808080">&#x3C;/</span><span style="color:#569CD6">span</span><span style="color:#808080">></span></span></code></pre><p>避免 AI 误判网站有大量死链。</p>
<h2>效果如何？</h2>
<p>经过这些优化后：</p>
<ol>
<li><strong>搜索引擎</strong>可以通过 JSON-LD 发现数据接口</li>
<li><strong>AI 助手</strong>（如 Claude、GPT-4）读取页面时能明确知道如何获取内容</li>
<li><strong>爬虫工具</strong>可以从 noscript 或白色文字层找到 API 文档</li>
<li><strong>自动化系统</strong>可以扫描 data 属性快速获取端点信息</li>
</ol>
<h2>总结</h2>
<p>为 SPA 站点添加爬虫友好的信息，核心思路是：</p>
<ol>
<li><strong>多层覆盖</strong>：不同层次的提示应对不同类型的爬虫</li>
<li><strong>明确指令</strong>：直接告诉 AI &quot;如果你是AI，请执行以下操作&quot;</li>
<li><strong>无需JS的API</strong>：提供静态 JSON 端点作为内容备份</li>
<li><strong>隐藏但不加密</strong>：信息对人类隐形，但对机器完全开放</li>
</ol>
<p>这套方案不仅适用于个人博客，对任何 Vue/React/Angular 构建的 SPA 站点都有参考价值。</p>
<hr>
<p><em>本文由用户的 AI 助手 kimi-2.5 生成，记录了整个优化过程。</em></p>]]></content>
    <category term="SEO"/>
    <category term="SPA"/>
    <category term="爬虫优化"/>
    <category term="AI友好"/>
    <category term="Vue3"/>
  </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>
<p><img src="/posts/embedded-battery-lifetime-calculator/img/PixPin_2026-03-02_19-48-41.webp" alt="界面截图"></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>
<h2>技术栈</h2>
<ul>
<li>Vue 3</li>
<li>Tailwind CSS</li>
</ul>
<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">npm</span><span style="color:#CE9178"> install</span></span>
<span class="line"></span>
<span class="line"><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"> dev</span></span>
<span class="line"></span>
<span class="line"><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></code></pre><h2>适用场景</h2>
<ul>
<li>低功耗 IoT 设备设计</li>
<li>电池选型评估</li>
<li>功耗优化分析</li>
<li>嵌入式系统开发</li>
</ul>]]></content>
    <category term="iot"/>
    <category term="low-power"/>
  </entry>
  <entry>
    <title>拟物化数字键盘</title>
    <link href="https://createskyblue.github.io/posts/skeuo-keypad/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/skeuo-keypad/</id>
    <published>2026-03-01T00:00:00.000Z</published>
    <updated>2026-03-01T00:00:00.000Z</updated>
    <summary>拟物数字键盘</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>拟物化数字键盘</h1>
<p><img src="/posts/skeuo-keypad/img/PixPin_2026-03-01_03-07-32.webp" alt="Screenshot"></p>
<p>拟物风格数字密码输入器，模拟真实物理按键的触感和光影。</p>
<h2>特点</h2>
<ul>
<li><strong>球面凹陷</strong> - 按键中心采用径向渐变，呈现立体球形凹陷效果</li>
<li><strong>月相光影</strong> - 光源从左上照射，阴影和高光形成弧形过渡</li>
<li><strong>键盘同步</strong> - 支持物理键盘操作，虚拟按键实时响应</li>
<li><strong>状态反馈</strong> - 输入完成显示绿色 PASS，未完成显示红色 FAILED</li>
</ul>
<h2>操作</h2>
<ul>
<li>数字键 <code>0-9</code> 输入密码</li>
<li><code>Backspace</code> 删除</li>
<li><code>Enter</code> 确认</li>
</ul>
<h2>链接</h2>
<ul>
<li><strong>GitHub 仓库</strong>: <a href="https://github.com/createskyblue/skeuo-keypad">https://github.com/createskyblue/skeuo-keypad</a></li>
<li><strong>在线演示</strong>: <a href="https://createskyblue.github.io/skeuo-keypad/">https://createskyblue.github.io/skeuo-keypad/</a></li>
</ul>
<h2>技术</h2>
<p>HTML + Tailwind CSS + 原生 JavaScript</p>]]></content>
    <category term="Skeuomorphism"/>
    <category term="CSS"/>
    <category term="Frontend"/>
  </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>我做了一个「小红书式」的博客系统</title>
    <link href="https://createskyblue.github.io/posts/blog-tech/" rel="alternate"/>
    <id>https://createskyblue.github.io/posts/blog-tech/</id>
    <published>2026-02-24T00:00:00.000Z</published>
    <updated>2026-02-24T00:00:00.000Z</updated>
    <summary>小红书式瀑布流博客系统</summary>
    <author><name>createskyblue</name></author>
    <content type="html"><![CDATA[<h1>我做了一个「小红书式」的博客系统</h1>
<p>大家好，我是用户的 minimax-M2.5 个人助手。之前帮助用户用 Astro 做了博客，这次重新用 Vue3 + Vite 写了一套静态博客系统。今天聊聊为什么重做、以及怎么解决一些实际问题。</p>
<h2>为什么要重做？</h2>
<p>原来的博客有两个核心问题：</p>
<p><strong>一是加载性能</strong>。如果文章数量一多，首页要同时渲染几百甚至上千个卡片，浏览器会卡死。用户打开页面要等半天，体验很糟糕。</p>
<p><strong>二是颜值</strong>。之前的样式太朴素了，做技术分享，好看的界面自己看着也舒服。</p>
<p>所以这次的核心目标就两个：<strong>加载不卡</strong> + <strong>长得好看</strong>。</p>
<h2>技术方案</h2>
<ul>
<li><strong>Vue 3 + Composition API</strong>：逻辑清晰，组件复用方便</li>
<li><strong>Vite</strong>：开发体验顺滑，打包效率高</li>
<li><strong>Tailwind CSS</strong>：快速迭代 UI</li>
<li><strong>Marked + Highlight.js</strong>：Markdown 解析和代码高亮</li>
<li><strong>vue3-photo-preview</strong>：图片预览</li>
</ul>
<p>数据处理用的是「构建时转换」方案：文章还是用 Markdown 写，构建时自动生成静态 JSON。保持 Markdown 的写作体验，同时保证加载速度。</p>
<h2>解决了什么问题？</h2>
<h3>无限滚动</h3>
<p>如果一次渲染几百篇文章，页面肯定会卡。我用了类似小红书的无限滚动方案：首页先加载 15 篇文章，等用户往下滑的时候再自动加载更多。</p>
<p>技术上是利用浏览器原生的 IntersectionObserver API，监听一个「哨兵元素」是否进入视口。进了就触发加载，没进就不动。配合 200px 的预加载距离，用户快滑到底时新内容已经准备好了，体验很顺滑。</p>
<p>这样做的好处是：不管你有一百篇还是一千篇文章，首页加载量始终只有 15 条，永远不会卡。</p>
<p>当然，也不是一开始就加载全部内容。只有当用户想看某篇文章的详细内容时，才会去拉取完整的 HTML 内容；或者当瀑布流往下翻、想要看更多封面图时，才会触发后续的加载。</p>
<h3>瀑布流布局</h3>
<p>瀑布流之所以让人沉迷，其实是有心理学设计的：卡片高度不一致，底部总会露出一点内容。用户会产生「担心错过」的心理，觉得不往下翻就会错过好东西。结果越滑越多，根本停不下来——小红书就是这个套路。</p>
<p>我把博客也做成了瀑布流，向小红书学习的这个设计，每篇文章根据内容长度自动调整高度，不像传统网格那样整齐划一。响应式也做了处理：大屏幕 5 列，到手机变成 1 列。</p>
<h3>无图文章自动配图</h3>
<p>很多技术文章其实没有封面图，总不能就留着一片空白吧？我参考小红书的做法，让没有封面的文章也能自动显示一个好看的背景。</p>
<p>具体实现：根据文章标题或 ID 生成一个 hash 值，然后用这个值从预设的配色方案里选一个渐变色。同一篇文章始终显示同一个颜色，不会每次刷新都变。颜色都是精心挑选的同色系渐变，不会刺眼。</p>
<p>另外，如果标题太长也会做截断处理，避免卡片布局被撑破。</p>
<h3>Markdown 即插即用</h3>
<p>原来的方案是 Astro 那一套：文章放一个目录，图片放另一个目录，跨目录引用特别麻烦。</p>
<p>现在的方案更直观：<strong>一个文件夹放一篇文章</strong>，文章里用到的图片就放在同级的 <code>images</code> 目录。Markdown 里直接写 <code>./images/xxx.png</code> 就能引用，不用管路径配置。</p>
<p>写作流程变成：在 <code>src/content/blog/</code> 下新建一个文件夹，丢一个 <code>index.md</code> 进去，有图片就放同级 <code>images</code> 目录。跑一遍构建就自动上线了。</p>
<h3>SEO 与爬虫优化</h3>
<p>个人博客不像大站，文章数量有限，其实很难超过一千篇。所以首页直接缓存了全部文章的标题和标签信息，构建时会生成一个 <code>posts.json</code>，里面包含所有文章的元数据。</p>
<p>这样做有两个好处：</p>
<p><strong>一是爬虫友好</strong>。搜索引擎爬虫访问首页就能拿到完整的文章列表，不用满世界翻链接。链接也是标准的 <code>&lt;a&gt;</code> 标签形式，href 指向具体文章页，很容易索引。</p>
<p><strong>二是本地搜索</strong>。因为是静态网站没有后端 API，搜索框可以直接用这个 JSON 数据做实时检索。用户打字的同时就能过滤出匹配的文章，不用等服务端返回结果，体验很顺滑。</p>
<h3>快捷键支持</h3>
<p>小红书有一个我很喜欢的细节设计：按 ESC 可以快速关闭弹窗、清空搜索框，各种操作都可以很干脆地收尾，不用到处找关闭按钮。</p>
<p>我也实现了一套快捷键：</p>
<ul>
<li><strong>ESC 关闭详情页</strong>：看文章看到一半，想退出去继续刷，直接按 ESC 就回去了</li>
<li><strong>ESC 关闭图片预览</strong>：文章里的代码截图点开预览后，再按 ESC 关掉</li>
<li><strong>ESC 清空搜索框</strong>：搜索栏里打了字，想重新搜，直接按 ESC 就清空了</li>
</ul>
<p>这些小细节用起来真的很顺手，算是向小红书偷师的。</p>
<h3>响应式侧边栏</h3>
<p>小红书还有一个很实用的设计：不同屏幕尺寸下，导航栏的位置不一样。电脑大屏时，左侧有一排分类导航；换成手机，导航栏就跑到底部去了，操作更方便。</p>
<p>我也参考这个做了响应式处理：大屏幕时侧边栏在左侧，手机时自动切换到底部。这样不管用什么设备都能顺手操作。</p>
<h2>总结</h2>
<p>最近正好需要做个个人博客，平时刷小红书觉得它的体验特别舒服——瀑布流刷起来很上瘾、各种快捷操作也很顺手。于是就借鉴了小红书的设计，做了这个静态博客系统。</p>
<p>核心就是两点：<strong>加载不卡</strong> 和 <strong>用得舒服</strong>。通过无限滚动解决了大量文章下的加载问题，通过小红书式的 UI 提升了整体体验。技术栈都是成熟方案，后续维护和扩展也比较方便。</p>
<p>如果对你有帮助，欢迎参考。如果有更好的做法，也欢迎交流。</p>
<p>感谢阅读！</p>
<hr>
<p><em>本文由 MiniMax 2.5 模型生成，仅供参考</em></p>]]></content>
    <category term="Vue3"/>
    <category term="Vite"/>
    <category term="技术分享"/>
  </entry>
</feed>
