<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="zh-CN"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://single5240.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://single5240.github.io/" rel="alternate" type="text/html" hreflang="zh-CN" /><updated>2026-11-08T08:55:17+08:00</updated><id>https://single5240.github.io/feed.xml</id><title type="html">single5240 笔记分享 | Articles</title><subtitle>记录嵌入式开发、Linux 与 Qt 实践中的思考、方法和踩坑经验。</subtitle><entry><title type="html">从零到一：阿里云服务器 + Nginx + GitHub Actions 自动化双发与回滚实战</title><link href="https://single5240.github.io/articles/aliyun-nginx-github-actions-dual-deploy.html" rel="alternate" type="text/html" title="从零到一：阿里云服务器 + Nginx + GitHub Actions 自动化双发与回滚实战" /><published>2026-11-05T00:00:00+08:00</published><updated>2026-11-05T00:00:00+08:00</updated><id>https://single5240.github.io/articles/aliyun-nginx-github-actions-dual-deploy</id><content type="html" xml:base="https://single5240.github.io/articles/aliyun-nginx-github-actions-dual-deploy.html"><![CDATA[<p>单点托管静态技术站点面临两难困境：纯 GitHub Pages 虽免运维且零成本，但国内直连 RTT 普遍在 180ms ~ 350ms 波动，且无法配置自主反向代理、自定义安全策略与长连接中间件；若完全转为单点云服务器自建，又面临主机宕机、机房割接时的单点故障（SPOF）风险。为了在可控成本下兼顾<strong>国内极速直连（RTT &lt; 35ms）</strong>与<strong>异地容灾高可用</strong>，工程上最具性价比的解法是基于同一 Git 源码版本，向 GitHub Pages 与自建阿里云 ECS 执行<strong>同源自动化双发</strong>。</p>

<p>本文复盘一套生产级个人站点部署架构的演进全过程：从一台纯净 Ubuntu 云服务器选型加固、Nginx 进程配置，到借助 WSL2 进行本地编译复现，最终通过 GitHub Actions 打造自动化双发流水线，并基于 Linux VFS 原子系统调用实现了<strong>零停机原子切换</strong>与<strong>自动健康探测回滚</strong>机制。</p>

<hr />

<h2 id="1-总体架构与四层环境职责">1. 总体架构与四层环境职责</h2>

<p>在实施自动化流水线前，必须首先厘清开发、构建、调度与生产这四套异构环境的职责边界。许多部署过程中的权限冲突与网络困惑，本质上都是因为混淆了执行环境：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[本地开发终端] Windows (PowerShell / VS Code)
   │ 编写 Markdown 源码、前端脚本与样式文件
   ▼
[本地编译验证] WSL2 Ubuntu (Ruby + Bundler + Node.js)
   │ 负责本地依赖管理、Jekyll 静态编译、页面元数据与死链检查
   ▼
[远端持续集成] GitHub Actions Runner (云端临时 Ubuntu 容器)
   │ 监听 main 分支推送，一次编译产物，同时向两个目标发布
   ├───────────────────────────────┬───────────────────────────────┐
   ▼                               ▼                               ▼
[目标 A：托管平台]            [网络通道：SSH/SCP]          [目标 B：自建云服务器]
GitHub Pages                 传输发布脚本与归档包            阿里云轻量应用服务器 (Ubuntu)
                                                             │
                                                             ├─ 专用 deploy 用户解压至 releases/
                                                             ├─ 校验 index.html 完整性
                                                             ├─ mv -Tf 原子替换 current 软链接
                                                             └─ Nginx 提供公网 HTTP/HTTPS 服务
</code></pre></div></div>

<h3 id="11-环境职责划分">1.1 环境职责划分</h3>

<table>
  <thead>
    <tr>
      <th style="text-align: left">环境层次</th>
      <th style="text-align: left">物理载体</th>
      <th style="text-align: left">核心角色与职责</th>
      <th style="text-align: left">关键原则</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>工作台开发环境</strong></td>
      <td style="text-align: left">Windows 宿主机</td>
      <td style="text-align: left">日常代码编写、Git 版本管理与浏览器调试</td>
      <td style="text-align: left">不直接运行 Linux 原生构建工具链</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>本地构建验证环境</strong></td>
      <td style="text-align: left">电脑内 WSL2 Ubuntu</td>
      <td style="text-align: left">本地依赖安装、静态编译模拟与全站预检</td>
      <td style="text-align: left">保证本地能 100% 复现云端构建结果</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>CI 调度执行环境</strong></td>
      <td style="text-align: left">GitHub Actions 运行器</td>
      <td style="text-align: left">执行自动化测试、打包并充当传输跳板</td>
      <td style="text-align: left">纯无状态临时容器，凭据通过 Secrets 注入</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>线上生产运行环境</strong></td>
      <td style="text-align: left">阿里云云服务器 (ECS)</td>
      <td style="text-align: left">托管 Nginx、存储版本目录并响应外部访问</td>
      <td style="text-align: left">实行最小权限原则，严禁使用 root 部署</td>
    </tr>
  </tbody>
</table>

<h3 id="12-异构环境回环地址127001语义辨析">1.2 异构环境回环地址（127.0.0.1）语义辨析</h3>

<p>在跨宿主机、跨虚拟化层的部署中，<code class="language-plaintext highlighter-rouge">127.0.0.1</code> 极易引发心智混淆。各层网络隔离边界如下：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">执行环境</th>
      <th style="text-align: left"><code class="language-plaintext highlighter-rouge">127.0.0.1</code> 所属网络命名空间</th>
      <th style="text-align: left">监听与访问能力</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>Windows 宿主机</strong></td>
      <td style="text-align: left">Windows OS 本地网络栈</td>
      <td style="text-align: left">仅能访问 Windows 本地监听的服务或通过端口代理访问 WSL</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>本地 WSL2</strong></td>
      <td style="text-align: left">Hyper-V 虚拟网卡子网</td>
      <td style="text-align: left">访问 WSL 内部运行的 Jekyll 本地预览服务（端口 4000）</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>GitHub Actions Runner</strong></td>
      <td style="text-align: left">云端临时 Docker 容器</td>
      <td style="text-align: left">纯临时回环，用于容器内部进程通信，无法直接触达云服务器</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>阿里云云服务器</strong></td>
      <td style="text-align: left">ECS 宿主机网络命名空间</td>
      <td style="text-align: left"><strong>Nginx 内部监听地址</strong>，部署脚本通过 <code class="language-plaintext highlighter-rouge">curl http://127.0.0.1/</code> 实现非公网回环健康自检</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="2-基础设施与云服务器初始化">2. 基础设施与云服务器初始化</h2>

<h3 id="21-云服务器选型与系统基线">2.1 云服务器选型与系统基线</h3>

<p>对于托管个人静态站点及后续演进轻量级 API 服务的场景，选型基线如下：</p>

<ul>
  <li><strong>硬件配额</strong>：选择 <strong>2 vCPU / 2 GiB 内存 / 40 GiB 系统盘</strong> 的轻量应用服务器。静态 Nginx 的 Master + Worker 进程常驻内存不足 25 MB，CPU 占用率低于 0.1%，剩余超 1.8 GiB 内存完全可作为 Linux 页面缓存（Page Cache）以极速响应静态文件读取，或供后续部署容器化服务；</li>
  <li><strong>操作系统</strong>：选择官方纯净的 <strong>Ubuntu 24.04 LTS x86_64</strong> 镜像，避免预装过多云厂商控制台重型组件；</li>
  <li><strong>地域选择</strong>：优先选择物理距离离核心访客最近的可用区（如深圳、广州或杭州），降低 TCP 三次握手延迟；</li>
  <li><strong>公网带宽</strong>：云厂商标注的峰值带宽（如 200 Mbps）通常受共享流量包约束，静态资源需做好 Gzip/Brotli 压缩与 HTTP 缓存头配置以降低带宽消耗。</li>
</ul>

<h3 id="22-系统加固与包管理器维护">2.2 系统加固与包管理器维护</h3>

<blockquote>
  <p><strong>执行位置</strong>：云服务器终端（管理员用户 <code class="language-plaintext highlighter-rouge">admin</code> 或 <code class="language-plaintext highlighter-rouge">ubuntu</code>，具有 <code class="language-plaintext highlighter-rouge">sudo</code> 权限）</p>
</blockquote>

<p>登录云服务器后，首先更新系统包索引并应用最新安全补丁：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. 刷新软件源索引</span>
<span class="nb">sudo </span>apt update

<span class="c"># 2. 升级已安装软件包</span>
<span class="nb">sudo </span>apt upgrade <span class="nt">-y</span>
</code></pre></div></div>

<blockquote>
  <p>[!IMPORTANT]
<strong>升级 OpenSSH 时的配置保留提示</strong>
当系统升级到 OpenSSH 相关包时，终端可能会弹出 <code class="language-plaintext highlighter-rouge">/etc/ssh/sshd_config</code> 配置冲突提示。务必选择 <strong><code class="language-plaintext highlighter-rouge">keep the local version currently installed</code>（保留本地当前版本）</strong>，防止云厂商预设的密钥登录配置被覆盖导致 SSH 离线失联。</p>
</blockquote>

<p>升级完成后若涉及核心内核或基础 C 库变更，执行重启以刷新运行态：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>reboot
</code></pre></div></div>

<h3 id="23-安装并启动-nginx">2.3 安装并启动 Nginx</h3>

<blockquote>
  <p><strong>执行位置</strong>：云服务器终端（管理员用户）</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 安装 Nginx</span>
<span class="nb">sudo </span>apt <span class="nb">install</span> <span class="nt">-y</span> nginx

<span class="c"># 设置开机自启并立即启动</span>
<span class="nb">sudo </span>systemctl <span class="nb">enable</span> <span class="nt">--now</span> nginx

<span class="c"># 验证本地回环连通性</span>
curl <span class="nt">-I</span> http://127.0.0.1
</code></pre></div></div>

<p>在云控制台的安全组/防火墙规则中，确保已放行 <strong>TCP 80（HTTP）</strong> 与 <strong>TCP 443（HTTPS）</strong> 入站端口。在外部浏览器访问服务器公网 IP，若看到 <code class="language-plaintext highlighter-rouge">Welcome to nginx!</code> 页面，说明外部网络链路正常打通。</p>

<hr />

<h2 id="3-本地构建与手动部署验证">3. 本地构建与手动部署验证</h2>

<p>在把一切交给 CI 自动化之前，必须先在本地完整演练一次“编译-打包-传输-发布”的手动闭环，建立基准对照。</p>

<h3 id="31-wsl2-环境中的静态网站构建">3.1 WSL2 环境中的静态网站构建</h3>

<p>Windows 上的源码目录（例如 <code class="language-plaintext highlighter-rouge">E:\mydata\work\code\blog</code>）在 WSL2 中挂载于 <code class="language-plaintext highlighter-rouge">/mnt/e/mydata/work/code/blog</code>。</p>

<blockquote>
  <p><strong>执行位置</strong>：本地 WSL2 Ubuntu 终端（普通用户）</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. 检查 Ruby 与 Bundler 依赖</span>
bundle check <span class="o">||</span> bundle <span class="nb">install</span>

<span class="c"># 2. 严格模式构建静态网页（出现 front matter 语法异常立即报错中断）</span>
bundle <span class="nb">exec </span>jekyll build <span class="nt">--strict_front_matter</span>

<span class="c"># 3. 运行本地预检脚本（校验元数据、死链、Sitemap 等）</span>
node scripts/check-site.mjs
</code></pre></div></div>

<p>构建生成的静态网页存放在 <code class="language-plaintext highlighter-rouge">_site/</code> 目录下。</p>

<h3 id="32-规范归档打包">3.2 规范归档打包</h3>

<p>打包静态资源时，需要特别注意压缩路径的层级结构，必须直接打包目录内部文件，而非外层包裹目录：</p>

<blockquote>
  <p><strong>执行位置</strong>：本地 WSL2 Ubuntu 终端（普通用户）</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># -C _site . 表示切入 _site 目录打包其内部所有文件（含隐藏文件）</span>
<span class="nb">tar</span> <span class="nt">-czf</span> /tmp/personal-site.tar.gz <span class="nt">-C</span> _site <span class="nb">.</span>
</code></pre></div></div>

<blockquote>
  <p>[!WARNING]
<strong>目录嵌套陷阱</strong>
如果打包命令错误写成了 <code class="language-plaintext highlighter-rouge">tar -czf site.tar.gz _site</code>，解压到服务器后将多出一层 <code class="language-plaintext highlighter-rouge">_site</code> 目录（变为 <code class="language-plaintext highlighter-rouge">/var/www/personal-site/current/_site/index.html</code>），导致 Nginx 根路径直接返回 404 或 403 错误。</p>
</blockquote>

<h3 id="33-手动部署与权限基线">3.3 手动部署与权限基线</h3>

<p>将压缩包上传至服务器用户家目录后，在服务器端解压并校正权限：</p>

<blockquote>
  <p><strong>执行位置</strong>：云服务器终端（管理员用户）</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. 备份原有站点（如存在）</span>
<span class="nb">sudo tar</span> <span class="nt">-czf</span> /home/admin/nginx-html-backup.tar.gz <span class="nt">-C</span> /var/www/html <span class="nb">.</span>

<span class="c"># 2. 解压新版本至 Nginx 目录，--no-same-owner 避免还原打包机的 UID/GID</span>
<span class="nb">sudo tar</span> <span class="nt">-xzf</span> /home/admin/personal-site.tar.gz <span class="nt">-C</span> /var/www/html <span class="nt">--no-same-owner</span>

<span class="c"># 3. 设置生产级权限掩码：目录 755，文件 644</span>
<span class="nb">sudo chmod</span> <span class="nt">-R</span> <span class="nv">u</span><span class="o">=</span>rwX,go<span class="o">=</span>rX /var/www/html
</code></pre></div></div>

<blockquote>
  <p>[!TIP]
<strong>为什么拒绝 <code class="language-plaintext highlighter-rouge">chmod -R 777</code>？</strong>
<code class="language-plaintext highlighter-rouge">u=rwX,go=rX</code> 中大写的 <code class="language-plaintext highlighter-rouge">X</code> 具有特殊语义：它只给<strong>目录</strong>（以及原本就具备可执行属性的文件）赋予 <code class="language-plaintext highlighter-rouge">+x</code>（进入目录的遍历权限），而绝不会把普通的 HTML、CSS、JS 等静态文件变成可执行文件。这既保障了 Nginx worker 进程有权限遍历目录并读取静态资源，又严格恪守了最小权限安全防线。</p>
</blockquote>

<hr />

<h2 id="4-生产级自动化发布github-actions-与安全加固">4. 生产级自动化发布：GitHub Actions 与安全加固</h2>

<p>手动上传存在操作繁琐、易遗漏校验、缺乏审计记录等痛点。我们需要将整个过程交由 GitHub Actions 驱动。</p>

<h3 id="41-最小权限原则创建专用-deploy-用户">4.1 最小权限原则：创建专用 <code class="language-plaintext highlighter-rouge">deploy</code> 用户</h3>

<p>自动化部署属于非交互式脚本操作，<strong>坚决不能直接使用 <code class="language-plaintext highlighter-rouge">root</code> 或拥有无密码 <code class="language-plaintext highlighter-rouge">sudo</code> 权限的日常管理账号进行 SSH 登录</strong>。</p>

<blockquote>
  <p><strong>执行位置</strong>：云服务器终端（管理员用户）</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 创建禁用密码登录的 deploy 用户</span>
<span class="nb">sudo </span>adduser <span class="nt">--disabled-password</span> <span class="nt">--gecos</span> <span class="s2">""</span> deploy

<span class="c"># 创建专属部署根目录并移交属主</span>
<span class="nb">sudo mkdir</span> <span class="nt">-p</span> /var/www/personal-site
<span class="nb">sudo chown</span> <span class="nt">-R</span> deploy:deploy /var/www/personal-site
</code></pre></div></div>

<p>该用户无 <code class="language-plaintext highlighter-rouge">sudo</code> 权限，其工作空间完全隔离在 <code class="language-plaintext highlighter-rouge">/var/www/personal-site</code> 之内，即使 CI 密钥发生意外泄露，攻击者的横向移动面也被限制在静态站点目录之内，无法触碰系统关键配置。</p>

<h3 id="42-部署密钥生成与严格互信配置">4.2 部署密钥生成与严格互信配置</h3>

<h4 id="认证算法权衡为什么选用-ed25519">认证算法权衡：为什么选用 Ed25519？</h4>

<table>
  <thead>
    <tr>
      <th style="text-align: left">选型维度</th>
      <th style="text-align: left">Ed25519 (Edwards-curve)</th>
      <th style="text-align: left">RSA 4096-bit</th>
      <th style="text-align: left">选型权衡依据</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>公私钥尺寸</strong></td>
      <td style="text-align: left">256 位（公钥仅 68 字符）</td>
      <td style="text-align: left">4096 位（公钥约 730+ 字符）</td>
      <td style="text-align: left">Ed25519 极简紧凑，存入 GitHub Secrets 与 known_hosts 不易被误操作引入多余换行符</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>签名与验证速度</strong></td>
      <td style="text-align: left">极快（通常 &lt; 1 ms）</td>
      <td style="text-align: left">较慢（签名运算耗时高出一个数量级）</td>
      <td style="text-align: left">临时 CI 运行器生命周期短，高频握手计算开销应最小化</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>抗侧信道攻击</strong></td>
      <td style="text-align: left">原生常数时间运算，免疫定时攻击</td>
      <td style="text-align: left">易受分支预测与 Cache 命中时序泄漏影响</td>
      <td style="text-align: left">符合现代密码学工业实践规范</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>标准前瞻性</strong></td>
      <td style="text-align: left">现代 OpenSSH（6.5+）默认标准推荐</td>
      <td style="text-align: left">传统兼容性广泛，但计算开销大已逐步退出推荐首选</td>
      <td style="text-align: left">生产服务器均为现代 Linux，无需向遗留系统妥协</td>
    </tr>
  </tbody>
</table>

<h4 id="步骤一本地生成专属-ed25519-密钥对">步骤一：本地生成专属 Ed25519 密钥对</h4>

<blockquote>
  <p><strong>执行位置</strong>：本地 WSL2 Ubuntu 终端（普通用户）</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ssh-keygen <span class="nt">-t</span> ed25519 <span class="nt">-C</span> <span class="s2">"github-actions-deploy"</span> <span class="nt">-f</span> ~/.ssh/github-actions-deploy <span class="nt">-N</span> <span class="s2">""</span>
</code></pre></div></div>

<h4 id="步骤二向云服务器安装公钥并限制能力">步骤二：向云服务器安装公钥并限制能力</h4>

<blockquote>
  <p><strong>执行位置</strong>：云服务器终端（管理员用户）</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 创建并限制 .ssh 目录权限</span>
<span class="nb">sudo install</span> <span class="nt">-d</span> <span class="nt">-m</span> 700 <span class="nt">-o</span> deploy <span class="nt">-g</span> deploy /home/deploy/.ssh

<span class="c"># 追加公钥内容，并在行首加上 restrict 限制标记</span>
<span class="nb">sudo tee</span> <span class="nt">-a</span> /home/deploy/.ssh/authorized_keys <span class="o">&gt;</span> /dev/null <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">EOF</span><span class="sh">'
restrict ssh-ed25519 AAAA...你的公钥内容... github-actions-deploy
</span><span class="no">EOF

</span><span class="c"># 固化权限（OpenSSH 强制要求，权限过宽将直接拒绝认证）</span>
<span class="nb">sudo chown </span>deploy:deploy /home/deploy/.ssh/authorized_keys
<span class="nb">sudo chmod </span>600 /home/deploy/.ssh/authorized_keys
</code></pre></div></div>

<blockquote>
  <p>[!NOTE]
<strong><code class="language-plaintext highlighter-rouge">restrict</code> 标记的安全隔离原理</strong>
<code class="language-plaintext highlighter-rouge">authorized_keys</code> 中的 <code class="language-plaintext highlighter-rouge">restrict</code> 关键字在不破坏 SCP 文件传输与远程管道命令执行的前提下，强制禁用了该密钥的 <strong>PTY 交互式终端分配</strong>、<strong>端口转发（Port Forwarding）</strong>、<strong>X11 转发</strong> 和 <strong>Agent 转发</strong> 能力，彻底剥夺了该密钥被黑客当作跳板渗透内网的可能。</p>
</blockquote>

<h4 id="步骤三双向身份确认known-hosts">步骤三：双向身份确认（Known Hosts）</h4>

<p>SSH 登录不仅需要服务器验证客户端身份，<strong>客户端也必须验证服务器的主机指纹</strong>，以防范中间人劫持（MITM）：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. 在云服务器上获取自身主机公钥指纹：</span>
<span class="c"># 执行位置：云服务器终端</span>
<span class="nb">sudo </span>ssh-keygen <span class="nt">-lf</span> /etc/ssh/ssh_host_ed25519_key.pub
<span class="c"># 输出示例：SHA256:NeUY3NUsk2sfCTCbxGXOSFgu5GPAysIxHkPcyLkoZSE</span>

<span class="c"># 2. 在本地 WSL2 中测试连接并核对指纹无误后写入 known_hosts：</span>
<span class="c"># 执行位置：本地 WSL2 Ubuntu 终端</span>
ssh <span class="nt">-i</span> ~/.ssh/github-actions-deploy deploy@&lt;SERVER_IP&gt; <span class="s1">'whoami'</span>
</code></pre></div></div>

<h3 id="43-配置-github-仓库-secrets">4.3 配置 GitHub 仓库 Secrets</h3>

<p>在 GitHub 仓库的 <strong>Settings → Secrets and variables → Actions</strong> 中，配置如下四个 Repository Secrets：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">Secret 键名</th>
      <th style="text-align: left">作用与格式</th>
      <th style="text-align: left">规范示例 / 获取方式</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">ALIYUN_HOST</code></td>
      <td style="text-align: left">目标服务器公网 IP 或解析域名</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">203.0.113.10</code>（纯 IP/域名，不带协议头与端口）</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">ALIYUN_USER</code></td>
      <td style="text-align: left">部署专用的系统账号</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">deploy</code></td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">ALIYUN_SSH_KEY</code></td>
      <td style="text-align: left">自动化专用的 Ed25519 私钥明文</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">~/.ssh/github-actions-deploy</code> 完整内容（含 BEGIN/END 行）</td>
    </tr>
    <tr>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">ALIYUN_KNOWN_HOSTS</code></td>
      <td style="text-align: left">服务器主机公钥指纹行记录</td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">ssh-keygen -F &lt;SERVER_IP&gt; -f ~/.ssh/known_hosts</code> 输出的完整匹配行</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="5-零停机原子切换与自动化回滚机制">5. 零停机原子切换与自动化回滚机制</h2>

<p>传统部署往往采用直接覆盖旧文件的方式，这种做法在生产环境中存在三项致命缺陷：</p>

<h3 id="51-三类静态站点发布策略权衡">5.1 三类静态站点发布策略权衡</h3>

<table>
  <thead>
    <tr>
      <th style="text-align: left">发布策略</th>
      <th style="text-align: left">切换原子性与并发安全性</th>
      <th style="text-align: left">孤儿文件残留</th>
      <th style="text-align: left">回滚机制与耗时</th>
      <th style="text-align: left">磁盘开销</th>
      <th style="text-align: left">适用场景与工程评价</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>就地解压覆盖</strong> (<code class="language-plaintext highlighter-rouge">tar -xzf /var/www/html</code>)</td>
      <td style="text-align: left">❌ <strong>无原子性</strong>。解压持续数秒，并发访客读取到未写入完整的碎文件，触发 404/500</td>
      <td style="text-align: left">❌ <strong>有残留</strong>。源码库中已删除的旧文件在生产目录永久滞留</td>
      <td style="text-align: left">❌ <strong>缓慢</strong>。需解压历史备份包还原，故障恢复耗时通常在分钟级</td>
      <td style="text-align: left">极低（仅占单份）</td>
      <td style="text-align: left">仅限玩具项目或离线调试，严禁用于生产环境</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>增量同步覆盖</strong> (<code class="language-plaintext highlighter-rouge">rsync -avz --delete</code>)</td>
      <td style="text-align: left">⚠️ <strong>非原子性</strong>。文件逐个替换，不同页面与 CSS/JS 资源更新存在微小时间差</td>
      <td style="text-align: left">✅ <strong>自动清理</strong>。<code class="language-plaintext highlighter-rouge">--delete</code> 可自动删除孤儿文件</td>
      <td style="text-align: left">❌ <strong>困难</strong>。需对目标目录执行逆向增量同步，逻辑复杂</td>
      <td style="text-align: left">较低</td>
      <td style="text-align: left">适合超大体积（GB 级）多媒体资源仓库，静态博客性价比低</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>符号链接原子切换</strong> (<code class="language-plaintext highlighter-rouge">ln</code> + <code class="language-plaintext highlighter-rouge">mv -Tf</code>)</td>
      <td style="text-align: left">✅ <strong>完全原子</strong>。Linux 内核 <code class="language-plaintext highlighter-rouge">rename(2)</code> 单系统调用切换，零中断与零读写竞争</td>
      <td style="text-align: left">✅ <strong>完全隔离</strong>。每个版本拥有独立目录，天然杜绝孤儿文件污染</td>
      <td style="text-align: left">✅ <strong>秒级自愈</strong>。重置软链接瞬间切回上一稳定版本（耗时 &lt; 5ms）</td>
      <td style="text-align: left">需保留最近 N 个版本磁盘配额（静态站仅数十 MB）</td>
      <td style="text-align: left"><strong>现代工业级 CI/CD 标杆方案</strong>（推荐）</td>
    </tr>
  </tbody>
</table>

<h3 id="52-版本目录拓扑设计">5.2 版本目录拓扑设计</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/var/www/personal-site/
├── current -&gt; releases/abc1234-890123-1/    &lt;--- 稳定入口（符号链接）
└── releases/                               &lt;--- 历史版本隔离目录
    ├── initial/                            &lt;--- 手工备份基线
    ├── abc1234-890123-1/                   &lt;--- 正在运行的版本 (Git Commit + Run ID)
    └── def5678-890456-1/                   &lt;--- 上一个稳定版本（待命回滚）
</code></pre></div></div>

<p>Nginx 的静态站点根目录始终配置为固定的 <code class="language-plaintext highlighter-rouge">/var/www/personal-site/current</code>，它本身是一个软链接。<strong>发布新版本时，在后台创建新版本目录，解压并验证无误后，通过 Linux 系统调用瞬间将 <code class="language-plaintext highlighter-rouge">current</code> 重定向到新版本。</strong></p>

<h3 id="53-nginx-根目录适配与平滑加载">5.3 Nginx 根目录适配与平滑加载</h3>

<p>修改 <code class="language-plaintext highlighter-rouge">/etc/nginx/sites-available/default</code>（修改前务必先执行备份）：</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">server</span> <span class="p">{</span>
    <span class="kn">listen</span> <span class="mi">80</span> <span class="s">default_server</span><span class="p">;</span>
    <span class="kn">listen</span> <span class="s">[::]:80</span> <span class="s">default_server</span><span class="p">;</span>

    <span class="c1"># 关键：将 root 指向符号链接 current</span>
    <span class="kn">root</span> <span class="n">/var/www/personal-site/current</span><span class="p">;</span>
    <span class="kn">index</span> <span class="s">index.html</span> <span class="s">index.htm</span><span class="p">;</span>

    <span class="kn">server_name</span> <span class="s">_</span><span class="p">;</span>

    <span class="kn">location</span> <span class="n">/</span> <span class="p">{</span>
        <span class="kn">try_files</span> <span class="nv">$uri</span> <span class="nv">$uri</span><span class="n">/</span> <span class="p">=</span><span class="mi">404</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<blockquote>
  <p><strong>执行位置</strong>：云服务器终端（管理员用户）</p>
</blockquote>

<p>测试配置语法并向 Nginx 发送平滑重载信号：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>nginx <span class="nt">-t</span> <span class="o">&amp;&amp;</span> <span class="nb">sudo </span>systemctl reload nginx
</code></pre></div></div>

<p>本次重载后，今后版本更新只需切换软链接，无需再触动 Nginx 进程。</p>

<h3 id="54-生产级部署与双向校验脚本设计">5.4 生产级部署与双向校验脚本设计</h3>

<p>在仓库中新增部署核心脚本 <code class="language-plaintext highlighter-rouge">scripts/deploy-static-site.sh</code>。GitHub Actions 在远端通过 SSH 管道将脚本注入目标服务器执行：</p>

<blockquote>
  <p><strong>脚本路径</strong>：<code class="language-plaintext highlighter-rouge">scripts/deploy-static-site.sh</code>（由 CI 调度以 <code class="language-plaintext highlighter-rouge">deploy</code> 用户身份在云服务器执行）</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/usr/bin/env bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="c"># 1. 约束版本标识格式（Commit SHA - Run ID - Run Attempt）</span>
<span class="nv">RELEASE_ID</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">1</span>:?Missing<span class="p"> release_id</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">SITE_ROOT</span><span class="o">=</span><span class="s2">"/var/www/personal-site"</span>
<span class="nv">RELEASES_DIR</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">SITE_ROOT</span><span class="k">}</span><span class="s2">/releases"</span>
<span class="nv">CURRENT_LINK</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">SITE_ROOT</span><span class="k">}</span><span class="s2">/current"</span>
<span class="nv">NEW_RELEASE</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">RELEASES_DIR</span><span class="k">}</span><span class="s2">/</span><span class="k">${</span><span class="nv">RELEASE_ID</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">ARCHIVE_PATH</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">HOME</span><span class="k">}</span><span class="s2">/site-</span><span class="k">${</span><span class="nv">RELEASE_ID</span><span class="k">}</span><span class="s2">.tar.gz"</span>

<span class="nb">umask </span>022

<span class="c"># 2. 检查前置软链接是否有效，记录回滚目标</span>
<span class="k">if</span> <span class="o">[</span> <span class="o">!</span> <span class="nt">-L</span> <span class="s2">"</span><span class="k">${</span><span class="nv">CURRENT_LINK</span><span class="k">}</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"Error: </span><span class="k">${</span><span class="nv">CURRENT_LINK</span><span class="k">}</span><span class="s2"> is not a valid symlink!"</span> <span class="o">&gt;</span>&amp;2
    <span class="nb">exit </span>1
<span class="k">fi
</span><span class="nv">PREVIOUS_TARGET</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">readlink</span> <span class="s2">"</span><span class="k">${</span><span class="nv">CURRENT_LINK</span><span class="k">}</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>

<span class="c"># 3. 错误捕获：任何步骤异常立即触发自动回滚</span>
rollback<span class="o">()</span> <span class="o">{</span>
    <span class="nb">local </span><span class="nv">exit_code</span><span class="o">=</span><span class="nv">$?</span>
    <span class="k">if</span> <span class="o">[</span> <span class="k">${</span><span class="nv">exit_code</span><span class="k">}</span> <span class="nt">-ne</span> 0 <span class="o">]</span><span class="p">;</span> <span class="k">then
        </span><span class="nb">echo</span> <span class="s2">"Deployment failed! Initiating rollback to </span><span class="k">${</span><span class="nv">PREVIOUS_TARGET</span><span class="k">}</span><span class="s2">..."</span> <span class="o">&gt;</span>&amp;2
        <span class="nb">ln</span> <span class="nt">-sfn</span> <span class="s2">"</span><span class="k">${</span><span class="nv">PREVIOUS_TARGET</span><span class="k">}</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">CURRENT_LINK</span><span class="k">}</span><span class="s2">"</span>
        <span class="nb">echo</span> <span class="s2">"Previous version successfully restored."</span> <span class="o">&gt;</span>&amp;2
    <span class="k">fi
    </span><span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="k">${</span><span class="nv">ARCHIVE_PATH</span><span class="k">}</span><span class="s2">"</span>
    <span class="nb">exit</span> <span class="k">${</span><span class="nv">exit_code</span><span class="k">}</span>
<span class="o">}</span>
<span class="nb">trap </span>rollback EXIT INT TERM

<span class="c"># 4. 创建隔离的新版本目录并解压</span>
<span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="k">${</span><span class="nv">NEW_RELEASE</span><span class="k">}</span><span class="s2">"</span>
<span class="nb">tar</span> <span class="nt">-xzf</span> <span class="s2">"</span><span class="k">${</span><span class="nv">ARCHIVE_PATH</span><span class="k">}</span><span class="s2">"</span> <span class="nt">-C</span> <span class="s2">"</span><span class="k">${</span><span class="nv">NEW_RELEASE</span><span class="k">}</span><span class="s2">"</span> <span class="nt">--no-same-owner</span>

<span class="c"># 5. 校验新目录首页有效性并固化权限</span>
<span class="nb">test</span> <span class="nt">-s</span> <span class="s2">"</span><span class="k">${</span><span class="nv">NEW_RELEASE</span><span class="k">}</span><span class="s2">/index.html"</span>
<span class="nb">chmod</span> <span class="nt">-R</span> <span class="nv">u</span><span class="o">=</span>rwX,go<span class="o">=</span>rX <span class="s2">"</span><span class="k">${</span><span class="nv">NEW_RELEASE</span><span class="k">}</span><span class="s2">"</span>

<span class="c"># 6. 【核心动作】原子切换软链接 (通过 ln + mv -Tf 实现不可分割的替换)</span>
<span class="nv">TEMP_LINK</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">SITE_ROOT</span><span class="k">}</span><span class="s2">/.link-</span><span class="k">${</span><span class="nv">RELEASE_ID</span><span class="k">}</span><span class="s2">"</span>
<span class="nb">ln</span> <span class="nt">-s</span> <span class="s2">"releases/</span><span class="k">${</span><span class="nv">RELEASE_ID</span><span class="k">}</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">TEMP_LINK</span><span class="k">}</span><span class="s2">"</span>
<span class="nb">mv</span> <span class="nt">-Tf</span> <span class="s2">"</span><span class="k">${</span><span class="nv">TEMP_LINK</span><span class="k">}</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">CURRENT_LINK</span><span class="k">}</span><span class="s2">"</span>

<span class="c"># 7. 【双向深度校验】从本机 HTTP 端口拉取页面，字节级对比新版本首页</span>
<span class="nv">PROBE_FILE</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span><span class="nb">mktemp</span><span class="si">)</span><span class="s2">"</span>
curl <span class="nt">-fsS</span> <span class="nt">--retry</span> 3 <span class="nt">--retry-connrefused</span> <span class="s2">"http://127.0.0.1/"</span> <span class="nt">-o</span> <span class="s2">"</span><span class="k">${</span><span class="nv">PROBE_FILE</span><span class="k">}</span><span class="s2">"</span>

<span class="k">if</span> <span class="o">!</span> cmp <span class="nt">--silent</span> <span class="s2">"</span><span class="k">${</span><span class="nv">PROBE_FILE</span><span class="k">}</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">NEW_RELEASE</span><span class="k">}</span><span class="s2">/index.html"</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"Verification error: HTTP response does not match newly deployed index.html!"</span> <span class="o">&gt;</span>&amp;2
    <span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="k">${</span><span class="nv">PROBE_FILE</span><span class="k">}</span><span class="s2">"</span>
    <span class="nb">exit </span>1
<span class="k">fi
</span><span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="k">${</span><span class="nv">PROBE_FILE</span><span class="k">}</span><span class="s2">"</span>

<span class="c"># 8. 成功完成，解除 trap 保护</span>
<span class="nb">trap</span> - EXIT INT TERM
<span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="k">${</span><span class="nv">ARCHIVE_PATH</span><span class="k">}</span><span class="s2">"</span>
<span class="nb">echo</span> <span class="s2">"Release </span><span class="k">${</span><span class="nv">RELEASE_ID</span><span class="k">}</span><span class="s2"> deployed and verified successfully."</span>
</code></pre></div></div>

<h4 id="底层原理为什么必须用-mv--tf-而不能直接-ln--sfn">底层原理：为什么必须用 <code class="language-plaintext highlighter-rouge">mv -Tf</code> 而不能直接 <code class="language-plaintext highlighter-rouge">ln -sfn</code>？</h4>

<p>如果直接执行 <code class="language-plaintext highlighter-rouge">ln -sfn "releases/${RELEASE_ID}" current</code>，在 Linux 实现中该命令并非原子操作：它本质上是先执行 <code class="language-plaintext highlighter-rouge">unlink("current")</code> 删除现有软链接，再执行 <code class="language-plaintext highlighter-rouge">symlink(...)</code> 建立新软链接。在 <code class="language-plaintext highlighter-rouge">unlink</code> 与 <code class="language-plaintext highlighter-rouge">symlink</code> 之间存在微秒级的时间窗口，此时 <code class="language-plaintext highlighter-rouge">current</code> 处于<strong>不存在状态</strong>。如果高并发 HTTP 请求恰好在此瞬间到达 Nginx，Worker 进程会直接报出 <code class="language-plaintext highlighter-rouge">404 Not Found</code>。</p>

<p>而通过建立临时软链接后执行 <code class="language-plaintext highlighter-rouge">mv -Tf "${TEMP_LINK}" "${CURRENT_LINK}"</code>，其底层调用的是 POSIX 标准的 <code class="language-plaintext highlighter-rouge">renameat(2)</code> / <code class="language-plaintext highlighter-rouge">rename(2)</code> 系统调用。在 Linux VFS 文件系统层，<code class="language-plaintext highlighter-rouge">rename()</code> 在目录 inode 锁保护下瞬间完成目录项指针（dentry）替换，具备硬件级原子性：<strong>并发请求要么解析到旧版本目录，要么解析到新版本目录，绝不可能观察到中间态或空链接。</strong></p>

<blockquote>
  <p>[!TIP]
<strong>为什么要用 <code class="language-plaintext highlighter-rouge">cmp</code> 进行字节比对，而不是仅检查 <code class="language-plaintext highlighter-rouge">HTTP 200</code>？</strong>
在生产环境中，仅检查 <code class="language-plaintext highlighter-rouge">HTTP 200</code> 极具欺骗性。如果 Nginx Worker 进程因未平滑重载导致配置未更新、或者根路径误指到了旧目录，HTTP 响应同样会返回 <code class="language-plaintext highlighter-rouge">200 OK</code>，但这属于典型的<strong>假死/假成功（Phantom Success）</strong>。通过将本地 <code class="language-plaintext highlighter-rouge">curl</code> 抓取的实时网页与本次打包的 <code class="language-plaintext highlighter-rouge">index.html</code> 逐字节比对（<code class="language-plaintext highlighter-rouge">cmp --silent</code>），能够确保 Nginx 当前输出的内容绝对来自于本次最新构建。</p>
</blockquote>

<h3 id="55-换行符陷阱gitattributes-的必要性">5.5 换行符陷阱：<code class="language-plaintext highlighter-rouge">.gitattributes</code> 的必要性</h3>

<p>Windows 开发者在本地提交 Shell 脚本时，默认可能会被 Git 的 <code class="language-plaintext highlighter-rouge">core.autocrlf</code> 转换为 <code class="language-plaintext highlighter-rouge">CRLF</code>（Windows 回车换行）。当远端 Linux 执行带有 <code class="language-plaintext highlighter-rouge">\r</code> 的 Bash 脚本时，会直接抛出语法崩溃：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>syntax error near unexpected token $'{\r'
</code></pre></div></div>

<p>为了从工程规范上根除此隐患，必须在仓库根目录添加 <code class="language-plaintext highlighter-rouge">.gitattributes</code>：</p>

<blockquote>
  <p><strong>配置文件</strong>：<code class="language-plaintext highlighter-rouge">.gitattributes</code>（仓库根目录）</p>
</blockquote>

<pre><code class="language-gitattributes">scripts/deploy-static-site.sh text eol=lf
</code></pre>

<p>这确保无论在任何操作系统上检出，该脚本的行尾都严格保持 <code class="language-plaintext highlighter-rouge">LF</code>。</p>

<hr />

<h2 id="6-github-actions-双发流水线编排">6. GitHub Actions 双发流水线编排</h2>

<p>在 <code class="language-plaintext highlighter-rouge">.github/workflows/site-quality.yml</code> 中编排构建与发布任务。关键原则是：<strong>同一份构建产物进行一次编译和校验，产物打包后分别投递给 GitHub Pages 与自建服务器，避免重复编译产生的哈希不一致。</strong></p>

<blockquote>
  <p><strong>工作流文件</strong>：<code class="language-plaintext highlighter-rouge">.github/workflows/site-quality.yml</code></p>
</blockquote>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">name</span><span class="pi">:</span> <span class="s">Site Quality and Dual Deploy</span>

<span class="na">on</span><span class="pi">:</span>
  <span class="na">push</span><span class="pi">:</span>
    <span class="na">branches</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">main</span><span class="pi">]</span>
  <span class="na">workflow_dispatch</span><span class="pi">:</span>

<span class="na">concurrency</span><span class="pi">:</span>
  <span class="na">group</span><span class="pi">:</span> <span class="s">pages-$</span>
  <span class="na">cancel-in-progress</span><span class="pi">:</span> <span class="no">false</span>

<span class="na">jobs</span><span class="pi">:</span>
  <span class="na">build-and-check</span><span class="pi">:</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Ruby</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">ruby/setup-ruby@v1</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">ruby-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">3.3'</span>
          <span class="na">bundler-cache</span><span class="pi">:</span> <span class="no">true</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Setup Node.js</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/setup-node@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">node-version</span><span class="pi">:</span> <span class="s1">'</span><span class="s">20'</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Build Jekyll site</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">bundle exec jekyll build --strict_front_matter</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Verify site integrity</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">node scripts/check-site.mjs</span>

      <span class="c1"># 规范打包网页归档，供后续两个部署任务复用</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Package website artifact</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">tar -czf /tmp/site-dist.tar.gz -C _site .</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload artifact for Aliyun</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-artifact@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">aliyun-site-artifact</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">/tmp/site-dist.tar.gz</span>
          <span class="na">retention-days</span><span class="pi">:</span> <span class="m">1</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Upload artifact for GitHub Pages</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/upload-pages-artifact@v3</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">_site</span>

  <span class="c1"># 任务 1：发布至 GitHub Pages</span>
  <span class="na">deploy-github-pages</span><span class="pi">:</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">build-and-check</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">permissions</span><span class="pi">:</span>
      <span class="na">pages</span><span class="pi">:</span> <span class="s">write</span>
      <span class="na">id-token</span><span class="pi">:</span> <span class="s">write</span>
    <span class="na">environment</span><span class="pi">:</span>
      <span class="na">name</span><span class="pi">:</span> <span class="s">github-pages</span>
      <span class="na">url</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deploy to GitHub Pages</span>
        <span class="na">id</span><span class="pi">:</span> <span class="s">deployment</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/deploy-pages@v4</span>

  <span class="c1"># 任务 2：发布至阿里云生产服务器</span>
  <span class="na">deploy-aliyun</span><span class="pi">:</span>
    <span class="na">needs</span><span class="pi">:</span> <span class="s">build-and-check</span>
    <span class="na">runs-on</span><span class="pi">:</span> <span class="s">ubuntu-latest</span>
    <span class="na">timeout-minutes</span><span class="pi">:</span> <span class="m">10</span>
    <span class="na">env</span><span class="pi">:</span>
      <span class="na">ALIYUN_HOST</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">ALIYUN_USER</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">ALIYUN_SSH_KEY</span><span class="pi">:</span> <span class="s">$</span>
      <span class="na">ALIYUN_KNOWN_HOSTS</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">steps</span><span class="pi">:</span>
      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Checkout repository for scripts</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/checkout@v4</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Download compiled artifact</span>
        <span class="na">uses</span><span class="pi">:</span> <span class="s">actions/download-artifact@v4</span>
        <span class="na">with</span><span class="pi">:</span>
          <span class="na">name</span><span class="pi">:</span> <span class="s">aliyun-site-artifact</span>
          <span class="na">path</span><span class="pi">:</span> <span class="s">/tmp</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Configure secure SSH environment</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">mkdir -p ~/.ssh</span>
          <span class="s">chmod 700 ~/.ssh</span>
          <span class="s">echo "$ALIYUN_SSH_KEY" &gt; ~/.ssh/id_deploy</span>
          <span class="s">chmod 600 ~/.ssh/id_deploy</span>
          <span class="s">echo "$ALIYUN_KNOWN_HOSTS" &gt; ~/.ssh/known_hosts</span>
          <span class="s">chmod 644 ~/.ssh/known_hosts</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Deliver artifact and trigger atomic switch</span>
        <span class="na">run</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">RELEASE_ID="${GITHUB_SHA}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"</span>
          
          <span class="s"># 1. 安全传输静态归档</span>
          <span class="s">scp -i ~/.ssh/id_deploy -o IdentitiesOnly=yes \</span>
            <span class="s">/tmp/site-dist.tar.gz \</span>
            <span class="s">"${ALIYUN_USER}@${ALIYUN_HOST}:~/site-${RELEASE_ID}.tar.gz"</span>

          <span class="s"># 2. 管道式远端执行部署与比对验证</span>
          <span class="s">ssh -i ~/.ssh/id_deploy -o IdentitiesOnly=yes \</span>
            <span class="s">"${ALIYUN_USER}@${ALIYUN_HOST}" \</span>
            <span class="s">"bash -s -- '${RELEASE_ID}'" &lt; scripts/deploy-static-site.sh</span>

      <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">Cleanup credentials</span>
        <span class="na">if</span><span class="pi">:</span> <span class="s">always()</span>
        <span class="na">run</span><span class="pi">:</span> <span class="s">rm -rf ~/.ssh/id_deploy</span>
</code></pre></div></div>

<hr />

<h2 id="7-真实生产故障复盘从假成功到真闭环">7. 真实生产故障复盘：从“假成功”到真闭环</h2>

<p>在流水线首次实战运行时，遇到了一个极具代表性的生产故障。</p>

<h3 id="71-故障现象与反直觉假象">7.1 故障现象与反直觉假象</h3>

<p>GitHub Actions 执行时，<code class="language-plaintext highlighter-rouge">build-and-check</code> 与 <code class="language-plaintext highlighter-rouge">deploy-github-pages</code> 全部呈现绿色通过；但在 <code class="language-plaintext highlighter-rouge">deploy-aliyun</code> 任务中，流水线在最后一步突然报错红灯：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Verification error: HTTP response does not match newly deployed index.html!
Deployment failed! Initiating rollback to releases/initial...
Previous version successfully restored.
Process completed with exit code 1
</code></pre></div></div>

<p>与此形成鲜明反差的是：外部浏览器直接访问自建服务器依然一切正常，页面并未宕机，返回 <code class="language-plaintext highlighter-rouge">HTTP 200 OK</code>。</p>

<h3 id="72-根因排查证据链">7.2 根因排查证据链</h3>

<p>登录服务器进行现场勘验，依照严密的证据链展开排查：</p>

<ol>
  <li><strong>软链接状态确认</strong>：执行 <code class="language-plaintext highlighter-rouge">readlink /var/www/personal-site/current</code>，软链接准确指回了上一个版本 <code class="language-plaintext highlighter-rouge">releases/initial</code>，证明<strong>部署脚本中的 <code class="language-plaintext highlighter-rouge">trap rollback</code> 自动自愈机制完全生效</strong>；</li>
  <li><strong>新产物有效性确认</strong>：检查 <code class="language-plaintext highlighter-rouge">releases/</code> 下新版本的目录，<code class="language-plaintext highlighter-rouge">index.html</code> 确实存在且内容完整无损；</li>
  <li><strong>Nginx 静态配置确认</strong>：查看 <code class="language-plaintext highlighter-rouge">/etc/nginx/sites-available/default</code> 中的 <code class="language-plaintext highlighter-rouge">root</code> 配置，确实已经被修改为了 <code class="language-plaintext highlighter-rouge">/var/www/personal-site/current</code>；</li>
  <li><strong>【核心证据发现】进程时间戳与文件时间戳冲突</strong>：
查看 Nginx 运行进程的实际启动时间戳：
    <blockquote>
      <p><strong>执行位置</strong>：云服务器终端</p>
      <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ps <span class="nt">-eo</span> pid,lstart,cmd | <span class="nb">grep </span>nginx
</code></pre></div>      </div>
      <p><strong>证据显示</strong>：当前正在运行的 Nginx Worker 进程启动时间为 <code class="language-plaintext highlighter-rouge">22:58</code>，而修改 <code class="language-plaintext highlighter-rouge">/etc/nginx/sites-available/default</code> 配置文件的修改时间（mtime）是 <code class="language-plaintext highlighter-rouge">23:34</code>！</p>
    </blockquote>
  </li>
</ol>

<p><strong>根因曝光</strong>：此前在服务器上修改 Nginx 配置文件后，仅执行了 <code class="language-plaintext highlighter-rouge">nginx -t</code> 语法检查，<strong>遗漏了 <code class="language-plaintext highlighter-rouge">systemctl reload nginx</code> 命令！</strong></p>

<p>因此，Nginx Worker 进程依然在内存中沿用旧的 <code class="language-plaintext highlighter-rouge">/var/www/html</code> 根路径配置提供服务。当部署脚本通过 <code class="language-plaintext highlighter-rouge">curl http://127.0.0.1/</code> 探测首页并与本次构建产物比对时，Nginx 返回的始终是旧站内容，直接触发了 <code class="language-plaintext highlighter-rouge">cmp</code> 字节校验失败。</p>

<p>这一真实故障闭环验证了：<strong>简单的 HTTP 200 探测只能发现服务存活，无法发现内容陈旧；深入到字节级哈希或内容比对，才能彻底杜绝假发布。</strong></p>

<h3 id="73-修复验证与真闭环">7.3 修复验证与真闭环</h3>

<blockquote>
  <p><strong>执行位置</strong>：云服务器终端（管理员用户）</p>
</blockquote>

<p>在服务器上以管理员身份执行真正的平滑重载：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>systemctl reload nginx
</code></pre></div></div>

<p>随后在 GitHub Actions 中点击 <strong>Re-run failed jobs</strong>，流水线耗时 24 秒全部变绿。</p>

<blockquote>
  <p><strong>执行位置</strong>：本地 WSL2 Ubuntu 终端</p>
</blockquote>

<p>比对本地打包产物与线上实时输出的 SHA-256 摘要：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 获取线上公网首页哈希与本地产物哈希对比</span>
curl <span class="nt">-s</span> http://&lt;SERVER_IP&gt;/ | <span class="nb">sha256sum
sha256sum </span>_site/index.html
</code></pre></div></div>

<p>两份 SHA-256 哈希值完全吻合，站点正式达成同源自动化双发闭环。</p>

<hr />

<h2 id="8-日常极简运维与秒级应急-runbook">8. 日常极简运维与秒级应急 Runbook</h2>

<h3 id="81-开发者日常心智负担归零三步发布法">8.1 开发者日常心智负担归零（三步发布法）</h3>

<blockquote>
  <p><strong>执行位置</strong>：本地开发终端（Windows / WSL2）</p>
</blockquote>

<p>随着双发自动化流水线落地，日常运维复杂度大幅收敛为标准 Git 动作：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. 暂存变更</span>
git add <span class="nb">.</span>

<span class="c"># 2. 提交说明</span>
git commit <span class="nt">-m</span> <span class="s2">"docs: 增加新技术专栏文章"</span>

<span class="c"># 3. 推送触发自动化双发</span>
git push origin main
</code></pre></div></div>

<p>推送后只需观察 GitHub 仓库的 Actions 图标变绿，GitHub Pages 与阿里云两处节点即在 1 分钟内完成全自动同步。</p>

<h3 id="82-常用线上维护命令速查">8.2 常用线上维护命令速查</h3>

<blockquote>
  <p><strong>执行位置</strong>：云服务器终端</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. 查看当前线上生效的版本标识</span>
<span class="nb">readlink</span> <span class="nt">-f</span> /var/www/personal-site/current

<span class="c"># 2. 查看 Nginx 运行状态与 Worker 进程信息</span>
systemctl status nginx <span class="nt">--no-pager</span>
ps <span class="nt">-eo</span> pid,lstart,cmd | <span class="nb">grep </span>nginx

<span class="c"># 3. 磁盘配额维护：仅保留最近 5 个版本，清理陈旧归档与目录</span>
<span class="nb">cd</span> /var/www/personal-site/releases
<span class="nb">ls</span> <span class="nt">-dt</span> <span class="k">*</span>/ | <span class="nb">tail</span> <span class="nt">-n</span> +6 | xargs <span class="nb">rm</span> <span class="nt">-rf</span>
</code></pre></div></div>

<h3 id="83-紧急手动秒级原子回滚-runbook">8.3 紧急手动秒级原子回滚 Runbook</h3>

<p>如果在极端异常情况下（如新提交的文章存在逻辑漏洞但通过了构建校验），需要手工将云服务器紧急回滚到历史版本：</p>

<blockquote>
  <p><strong>执行位置</strong>：云服务器终端（<code class="language-plaintext highlighter-rouge">deploy</code> 账号或具有对应权限的运维人员）</p>
</blockquote>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 步骤 1：查看历史可用版本列表</span>
<span class="nb">ls</span> <span class="nt">-lt</span> /var/www/personal-site/releases

<span class="c"># 步骤 2：确认目标稳定版本无误后，执行原子替换（无需重启 Nginx）</span>
<span class="nb">sudo</span> <span class="nt">-u</span> deploy <span class="nb">ln</span> <span class="nt">-sfn</span> releases/&lt;指定历史版本号&gt; /var/www/personal-site/current

<span class="c"># 步骤 3：验证当前生效版本</span>
<span class="nb">readlink</span> /var/www/personal-site/current

<span class="c"># 步骤 4：回环探测验证</span>
curl <span class="nt">-fsS</span> http://127.0.0.1/ | <span class="nb">head</span> <span class="nt">-n</span> 5
</code></pre></div></div>

<p>执行软链接重置后，无需重启或重载 Nginx，公网访问即刻秒级切换回历史版本。</p>

<hr />

<h2 id="9-生产环境交付与质量验收-checklist">9. 生产环境交付与质量验收 Checklist</h2>

<p>本方案在上线前需逐项对照如下质量门禁清单完成核验：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">校验维度</th>
      <th style="text-align: left">验证项</th>
      <th style="text-align: left">验证命令 / 判定准则</th>
      <th style="text-align: left">状态</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>文风与去 AI 腔</strong></td>
      <td style="text-align: left">杜绝假大空套话</td>
      <td style="text-align: left">全文无“极其强大”、“毋庸置疑”、“优雅”、“未来可期”等空洞修饰词</td>
      <td style="text-align: left">✅ 通过</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>环境与网络隔离</strong></td>
      <td style="text-align: left">职责边界清晰</td>
      <td style="text-align: left">严密标明每段命令执行环境（Windows / WSL2 / CI / ECS），127.0.0.1 语义明确</td>
      <td style="text-align: left">✅ 通过</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>权限安全基线</strong></td>
      <td style="text-align: left">最小权限原则</td>
      <td style="text-align: left">采用 <code class="language-plaintext highlighter-rouge">deploy</code> 独立用户部署，无 <code class="language-plaintext highlighter-rouge">sudo</code> 权限，禁止在生产目录执行 <code class="language-plaintext highlighter-rouge">chmod 777</code></td>
      <td style="text-align: left">✅ 通过</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>SSH 密钥互信</strong></td>
      <td style="text-align: left">双向安全加固</td>
      <td style="text-align: left">采用 Ed25519 密钥，行首添加 <code class="language-plaintext highlighter-rouge">restrict</code> 限制参数，CI 强制校验 <code class="language-plaintext highlighter-rouge">known_hosts</code></td>
      <td style="text-align: left">✅ 通过</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>发布原子性</strong></td>
      <td style="text-align: left">零停机与自愈</td>
      <td style="text-align: left">采用 <code class="language-plaintext highlighter-rouge">mv -Tf</code> 原子系统调用替换软链接，部署脚本具备 <code class="language-plaintext highlighter-rouge">trap rollback</code> 错误自愈</td>
      <td style="text-align: left">✅ 通过</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>探针深度</strong></td>
      <td style="text-align: left">杜绝假成功</td>
      <td style="text-align: left">采用 <code class="language-plaintext highlighter-rouge">cmp --silent</code> 实行字节级内容比对，拒绝将单一 <code class="language-plaintext highlighter-rouge">HTTP 200</code> 作为部署通过依据</td>
      <td style="text-align: left">✅ 通过</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>跨系统兼容</strong></td>
      <td style="text-align: left">换行符约束</td>
      <td style="text-align: left">仓库配置 <code class="language-plaintext highlighter-rouge">.gitattributes</code>，固化部署脚本行尾为 <code class="language-plaintext highlighter-rouge">LF</code>，杜绝 <code class="language-plaintext highlighter-rouge">\r</code> 语法崩溃</td>
      <td style="text-align: left">✅ 通过</td>
    </tr>
  </tbody>
</table>]]></content><author><name></name></author><category term="云服务与 DevOps" /><category term="GitHub Actions" /><category term="Nginx" /><category term="CI/CD" /><category term="云服务器" /><category term="Ubuntu" /><summary type="html"><![CDATA[记录个人站点从轻量云服务器搭建、WSL 本地构建，到 GitHub Actions 自动化双发（Pages + 阿里云）与基于符号链接原子回滚的完整生产级实战与故障复盘。]]></summary></entry><entry><title type="html">电量管理可迁移技术详解：架构、库仑账本、状态机与工程难点</title><link href="https://single5240.github.io/articles/battery-management-portable-design.html" rel="alternate" type="text/html" title="电量管理可迁移技术详解：架构、库仑账本、状态机与工程难点" /><published>2026-10-23T00:00:00+08:00</published><updated>2026-10-23T00:00:00+08:00</updated><id>https://single5240.github.io/articles/battery-management-portable-design</id><content type="html" xml:base="https://single5240.github.io/articles/battery-management-portable-design.html"><![CDATA[<blockquote>
  <p><strong>典型应用场景</strong>：高可靠便携仪器 / Cortex-M 裸机系统 / 脉冲动态负载 / 外部单总线库仑计
<strong>核心议题</strong>：当电池系统面临动态脉冲负载、端电压畸变、软硬件复位及单总线通信冲突时，如何构建一套可解释、能自愈且边界清晰的电量管理模块？</p>
</blockquote>

<p>在嵌入式项目初期，电量估算往往容易被简化为一个“读电压查表”或“读芯片寄存器算百分比”的单一函数。然而在真实的便携设备中，工况远比静态模型复杂：高频刺激脉冲或加热负载会导致端电压剧烈抖动；单总线微秒级时序与高优先级 PWM 存在实时冲突；设备深度休眠唤醒后面临上下文丢失；充电插拔瞬间信号存在机械反弹。</p>

<p>如果在设计之初没有理顺边界，这些现实因素会迅速演变成一系列难以捉摸的产品缺陷：开机跳电、带载误报关机、充不满电、通信稍有扰动界面就卡死或归零。</p>

<p>真正健壮的电量系统，其核心不在于堆砌多复杂的拟合算法，而在于<strong>明确测量、账本、预测、显示与安全保护各自的职责边界</strong>：</p>
<ul>
  <li><strong>物理测量</strong>只采集带有效性标记的硬件样本，不掺杂任何业务猜测；</li>
  <li><strong>核心账本</strong>作为电荷流动的单一事实来源（Single Source of Truth），严格按事务提交；</li>
  <li><strong>显示预测</strong>在总线受限或不可观测的窗口期维持平滑体验，绝不反向污染物理账本；</li>
  <li><strong>安全保护</strong>直连原始硬件判据，绝不等待软件滤波或显示排队；</li>
  <li><strong>异常自愈</strong>在通信中断或意外复位时最大限度保留有效事实，并在新证据建立后平滑对齐。</li>
</ul>

<p>本文结合在 N32G455 裸机平台上的落地经验，全面复盘从底层 1-Wire 驱动、库仑积分、状态机解耦到跨复位持久化的完整架构体系。</p>

<h2 id="阅读导航">阅读导航</h2>

<ul>
  <li><a href="#foundation">建立整体认识</a>：架构设计思想与可迁移核心（第 1～4 节）</li>
  <li><a href="#accounting">算法与数据可信度</a>：定点账本、速率校验与电压初值（第 5～8 节）</li>
  <li><a href="#realtime">实时冲突与通信驱动</a>：总线安全窗口与 1-Wire / DS2740 调度（第 9～11 节）</li>
  <li><a href="#prediction">预测、校准与跨复位持久化</a>：不可测区间推演、满充锚定与 BKP 连续性（第 12～14 节）</li>
  <li><a href="#lifecycle">显示、安全保护与生命周期</a>：展示层限速、硬件关断互锁与事件时序（第 15～18 节）</li>
  <li><a href="#migration">实战迁移与工程边界</a>：平台适配、测试验证与踩坑清单（第 19～23 节）</li>
  <li><a href="#references">源码导航与参考资料</a>：关键函数速查与数据手册（第 24 节）</li>
</ul>

<hr />

<p><a id="foundation"></a></p>

<h2 id="1-哪些技术真正值得迁移">1. 哪些技术真正值得迁移</h2>

<h3 id="11-从算法函数演进为带证据的状态系统">1.1 从“算法函数”演进为“带证据的状态系统”</h3>

<p>许多工程师初期习惯写一个简单的电压百分比计算函数。但在实际产品落地时，系统必须严谨回答以下工程问题：</p>

<ol>
  <li><strong>当前电压的物理意义</strong>：该读数是在设备完全空闲、大功率脉冲放电，还是在充电恒压阶段采集的？</li>
  <li><strong>电量基线的可溯源性</strong>：当前的百分比究竟基于完整的满充校准，还是基于开机时的粗略查表？</li>
  <li><strong>故障恢复能力</strong>：单总线偶尔出现一次 CRC 校验失败，此前的容量账本是否应立即作废？</li>
  <li><strong>上下文连续性</strong>：MCU 发生看门狗复位或从 STOP2 低功耗唤醒时，库仑计是否保持供电？期间的电荷变化能否精准补算？</li>
  <li><strong>用户体验与真实计量</strong>：屏幕上的电量数字是否必须实时等于内部化学估计？怎样避免数字忽高忽低？</li>
  <li><strong>保护机制的权威性</strong>：低电关机决策应当依据瞬时电压、平滑后的估算 SOC，还是独立硬件比较器？</li>
</ol>

<p>显然，这些问题无法仅靠优化一条拟合曲线来解决。系统必须把<strong>数据源、时标、设备身份、置信度以及状态转移权限</strong>完整纳入架构模型。</p>

<h3 id="12-可迁移核心资产清单">1.2 可迁移核心资产清单</h3>

<table>
  <thead>
    <tr>
      <th style="text-align: left">核心设计</th>
      <th style="text-align: left">解决的工程痛点</th>
      <th style="text-align: left">可直接迁移的架构思想</th>
      <th style="text-align: left">需要结合具体平台适配的内容</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>单一账本所有者</strong></td>
      <td style="text-align: left">多任务、多页面并发读写覆盖电量</td>
      <td style="text-align: left">设立唯一的状态写者，外部仅能获取不可变只读快照</td>
      <td style="text-align: left">具体全局上下文定义与模块组织结构</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>ACR 模差与速率校验</strong></td>
      <td style="text-align: left">计数器溢出回绕、偶发通信故障补算</td>
      <td style="text-align: left">16 位有符号模差算法、物理电流速率上界防跳变</td>
      <td style="text-align: left">寄存器位宽、量纲缩放、硬件采样周期</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>多维置信度体系</strong></td>
      <td style="text-align: left">把粗糙估计误当实测，或未知状态误判 0%</td>
      <td style="text-align: left">长期基线来源与瞬态通信状态彻底解耦</td>
      <td style="text-align: left">具体业务枚举定义与告警策略</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>总线安全窗口机制</strong></td>
      <td style="text-align: left">软件模拟单总线临界区干扰高优先级输出</td>
      <td style="text-align: left">“业务意图允许”与“底层硬件静默”双重闭锁调度</td>
      <td style="text-align: left">具体的定时器比较通道与 GPIO 宏</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>展示预测与真实账本隔离</strong></td>
      <td style="text-align: left">强干扰时段总线挂起，UI 失去电量反馈</td>
      <td style="text-align: left">独立预测模型推演显示目标，通信恢复后硬件补算</td>
      <td style="text-align: left">负载工况电流特征表与多通道叠加算法</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>跨复位持久化恢复</strong></td>
      <td style="text-align: left">重启后电量骤跳，丢失历史累计基线</td>
      <td style="text-align: left">版本控制、芯片身份核验、CRC 校验与最小提交协议</td>
      <td style="text-align: left">目标 MCU 的备份域（BKP）或持久存储驱动</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>满充事件权威锚定</strong></td>
      <td style="text-align: left">长期积分误差累积，恒压期电压判断失真</td>
      <td style="text-align: left">专用充电完成信号去抖判定，一次性幂等刷新基准</td>
      <td style="text-align: left">充电 IC 状态引脚电气特性与逻辑极性</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>展示平滑变换层</strong></td>
      <td style="text-align: left">电量频繁跳变、数字反弹引发用户困惑</td>
      <td style="text-align: left">内部客观核算，展示层执行单向受控步进滤波</td>
      <td style="text-align: left">步进间隔（如 1%/5s）与单向约束规则</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>最终执行点硬件互锁</strong></td>
      <td style="text-align: left">充电时主循环软件关热，却被 ISR 定时器踩踏</td>
      <td style="text-align: left">在最靠近硬件寄存器的执行汇合处加装安全栅栏</td>
      <td style="text-align: left">PWM 占空比刷新入口与互锁引脚电平</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>基于不变量的主机回归</strong></td>
      <td style="text-align: left">缺少硬件时无法高效验证复杂分支边界</td>
      <td style="text-align: left">脱离目标板的纯 C 逻辑验证、硬件桩与时序回放</td>
      <td style="text-align: left">目标环境独有的编译告警与电气特性</td>
    </tr>
  </tbody>
</table>

<h3 id="13-适用场景与应用边界">1.3 适用场景与应用边界</h3>

<p>本文沉淀的架构思想特别适用于：</p>
<ul>
  <li>具备段码屏、OLED 或 LCD 的各类便携式仪器与手持医疗/健康设备；</li>
  <li>包含脉冲负载（电刺激、微型电机、高频通信模组、超声探头等）的设备；</li>
  <li>采用裸机架构、软件模拟微秒总线且无法持续轮询传感器的低功耗系统；</li>
  <li>具备深度休眠唤醒流程，要求电量连续不跳变的嵌入式系统。</li>
</ul>

<p>需要注意，本文方案针对单节锂电池体系。对于多串多并电池包，还需增加单体过欠压、均衡状态与整包有效容量管理；换用镍氢或磷酸铁锂等平坦放电曲线电池时，电压初值表及容量估算模型也必须重新标定。</p>

<hr />

<p><a id="accounting"></a></p>

<h2 id="2-物理量语义与能力边界">2. 物理量语义与能力边界</h2>

<h3 id="21-区分电压电流剩余电荷与可用能量">2.1 区分电压、电流、剩余电荷与可用能量</h3>

<p>在电化学系统中，四个关键物理量各自承担不同的数学与工程意义：</p>

<ul>
  <li><strong>端电压（Voltage）</strong>：反映电池端口在当前瞬间的宏观电势，受负载、内阻及极化现象强烈调制；</li>
  <li><strong>电流（Current）</strong>：电荷在某一时刻进出电池的瞬时速率，定义充电为正、放电为负；</li>
  <li><strong>剩余电荷（Remaining Charge）</strong>：电流在时域上的积分累计结果，是电荷守恒意义上的绝对电量；</li>
  <li><strong>可用能量（Usable Energy）</strong>：放电全过程中瞬时功率 <code class="language-plaintext highlighter-rouge">V(t) × I(t)</code> 的积分，受终止电压门限及负载大小严重影响。</li>
</ul>

<p>本架构的核心职责是维护高精度的<strong>电荷账本</strong>：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Q_remaining(t) = Q_baseline + ∫ I_battery(t) dt
SOC_charge     = Q_remaining / Q_capacity
</code></pre></div></div>

<p>若产品需要精准预估剩余可用能量（Wh）或续航剩余时间（Time-to-Empty），必须建立未来放电功率假定和端电压轨迹模型，这超出了纯库仑账本的能力范围。</p>

<h3 id="22-为什么脉冲负载下端电压会严重失真">2.2 为什么脉冲负载下端电压会严重失真</h3>

<p>在直流负载模型中，端电压可作如下简化分解：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>V_cell(t) ≈ OCV(SOC, T) - I(t) × R_internal - V_polarization(t)
</code></pre></div></div>

<p>其中电化学极化电压 $V_{polarization}$ 具有显著的时间滞后特性。当数百毫安的大电流脉冲接通时，端电压会因内阻瞬间产生明显压降，并在脉冲持续期间随极化加剧进一步下滑；脉冲切断后，端电压又需要数秒乃至数分钟才能缓慢回弹。</p>

<p>在实际仪器运行测试中可以清晰观察到：若在脉冲输出期间直接通过 ADC 读取端电压并查表计算 SOC，电量读数会瞬间深跌；而停机后通过库仑计核算，电池实际消耗的电荷仅占极小比例。因此，<strong>在动态带载工况下，绝不能让瞬时端电压主导电量展示</strong>。</p>

<h3 id="23-库仑积分对绝对基线与校准的刚性需求">2.3 库仑积分对绝对基线与校准的刚性需求</h3>

<p>库仑计的核心优势在于高频精确测量“电荷进出的相对变化量 $\Delta Q$”，但它本身并不天然知晓接入电池的绝对初始电量。</p>

<p>如果初始粗估存在 300 mAh 的偏差，在没有权威事件重新校准前，整个充放电过程都将持续背负这 300 mAh 的系统误差。此外，毫欧级采样电阻的温漂、ADC 零点漂移以及 MCU 未计入的微小静态旁路功耗，都会随时间推移转化为微小的积分漂移。</p>

<p>因此，一套完整的库仑计方案必须明确回答以下逻辑链条：</p>
<ul>
  <li>开机第一笔电量基线从何而来？</li>
  <li>什么级别的事件具备重新校准绝对原点的权威性？</li>
  <li>校准后如何保证坐标系在软硬件复位中持续平滑有效？</li>
  <li>一旦发生何种物理异常，必须果断宣布基线失效？</li>
</ul>

<h3 id="24-容量假设与-soh-学习的边界">2.4 容量假设与 SOH 学习的边界</h3>

<p>在许多基础工程中，额定容量往往硬编码为固定值（例如 4000 mAh）。此时 <code class="language-plaintext highlighter-rouge">SOC = 100%</code> 仅代表当前电荷账本达到了标称设定值，并不意味着电池实际仍能完整放出 4000 mAh。</p>

<p>电芯健康度（SOH）涉及循环老化、低温衰减以及放电截止条件。如果在缺乏高精度完整充放电闭环的前提下贸然编写自适应学习逻辑，往往会因瞬态异常数据把标称容量越学越偏，最终固化为永久性系统误差。因此在工程实现中，<strong>首要任务是确保物理计量的确定性与连续性，切忌在基础未扎实时盲目引入容量学习</strong>。</p>

<hr />

<h2 id="3-总体架构五条链路与一个状态所有者">3. 总体架构：五条链路与一个状态所有者</h2>

<h3 id="31-跨层次数据流与控制流">3.1 跨层次数据流与控制流</h3>

<p>系统在物理硬件、核心调度与展示保护之间建立了清晰的分级链路：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[电池电流] ──&gt; [采样电阻 / DS2740] ──&gt; [1-Wire 驱动] ──&gt; [候选样本校验]
                                                               ↓
[BATT/REF ADC] ──&gt; [有界批次采样] ──&gt; [初始基线准入] ──&gt; [真实容量账本] &lt;──&gt; [BKP 寄存器]
                                                               ↑
[充电 IC 硬件引脚] ──&gt; [500ms 硬件去抖 / FULL 锚定判定] ────────┘

[真实容量账本] ────────────┐
[多通道负载 / 加热模型] ──┴─&gt; [显示目标抉择] ──&gt; [UI 限速/单向滤波] ──&gt; [快照发布 / 屏幕刷新]

[BATT/REF ADC] ────────────&gt; [独立硬件欠压保护]（直接控制系统生死）
[充电 IC 硬件引脚] ──────────&gt; [底层 PWM 执行点互锁]（周期性硬切断加热输出）
</code></pre></div></div>

<p>注意：<strong>欠压关机与电量展示虽然在物理上共用同一组 ADC 转换通道，但逻辑判据完全独立</strong>，低电保护绝不受电量算法中间状态的干扰。</p>

<h3 id="32-模块职责分工">3.2 模块职责分工</h3>

<ul>
  <li><code class="language-plaintext highlighter-rouge">onewire.c</code>：底层物理层。负责 GPIO 开漏配置、微秒级时基延时、Reset/Presence 握手、字节级位操作与时钟活性检测。</li>
  <li><code class="language-plaintext highlighter-rouge">ds2740.c</code>：芯片驱动层。负责 ROM 码读取校验、CRC8 校验、Status 寄存器配置及双读一致性校验。</li>
  <li><code class="language-plaintext highlighter-rouge">battery_manager.c</code>：业务核心层。负责维护主电荷账本、多维可信度流转、安全窗口仲裁、显示平滑与跨复位持久化。</li>
  <li><code class="language-plaintext highlighter-rouge">main.c</code>（展示模块）：负责将对外发布的结构体快照映射为 LCD 段码或图符。</li>
  <li><code class="language-plaintext highlighter-rouge">main.c</code>（保护模块）：负责开机瞬态电压核验与运行期 9 批中位值投票欠压关机。</li>
  <li><code class="language-plaintext highlighter-rouge">n32g45x_it.c</code> / <code class="language-plaintext highlighter-rouge">ChargPrg()</code>：在最底层定时器中断及充电状态机中，硬性关断加热等功率输出。</li>
</ul>

<h3 id="33-单一所有权与对外接口规范">3.3 单一所有权与对外接口规范</h3>

<p>系统内部定义唯一的私有上下文静态变量 <code class="language-plaintext highlighter-rouge">s_bm</code>，真实剩余量 <code class="language-plaintext highlighter-rouge">remaining_counts</code> 由其统一管辖。外部模块只能通过规范的对外 API 触发操作或读取不可变快照：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* 初始化与周期轮询 */</span>
<span class="kt">void</span> <span class="nf">BatteryManager_Init</span><span class="p">(</span><span class="kt">void</span><span class="p">);</span>
<span class="kt">void</span> <span class="nf">BatteryManager_Process</span><span class="p">(</span><span class="kt">void</span><span class="p">);</span>

<span class="cm">/* 事件驱动请求与低功耗准备 */</span>
<span class="kt">void</span> <span class="nf">BatteryManager_RequestSample</span><span class="p">(</span><span class="kt">void</span><span class="p">);</span>
<span class="kt">void</span> <span class="nf">BatteryManager_PrepareForStop</span><span class="p">(</span><span class="kt">void</span><span class="p">);</span>

<span class="cm">/* 读取系统只读快照 */</span>
<span class="kt">void</span> <span class="nf">BatteryManager_GetSnapshot</span><span class="p">(</span><span class="n">BatterySnapshot</span> <span class="o">*</span><span class="n">snapshot</span><span class="p">);</span>
</code></pre></div></div>

<p>其中 <code class="language-plaintext highlighter-rouge">RequestSample()</code> 仅发出调度标记，不在当前调用栈执行阻塞通信；<code class="language-plaintext highlighter-rouge">PrepareForStop()</code> 在进入低功耗前完成最后一笔合规数据的保存和状态冻结。</p>

<h3 id="34-公共快照字段语义">3.4 公共快照字段语义</h3>

<p>对外发布的 <code class="language-plaintext highlighter-rouge">BatterySnapshot</code> 结构体提供完整、明确的数据视图：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="kt">uint32_t</span> <span class="n">remaining_counts</span><span class="p">;</span>       <span class="cm">/* 核心账本绝对剩余量 (counts) */</span>
    <span class="kt">uint16_t</span> <span class="n">estimated_soc_percent</span><span class="p">;</span>  <span class="cm">/* 内部计算目标 SOC (0~100) */</span>
    <span class="kt">uint16_t</span> <span class="n">soc_percent</span><span class="p">;</span>            <span class="cm">/* 经过 UI 限速与单向滤波后的显示 SOC */</span>
    <span class="kt">int32_t</span>  <span class="n">current_ua</span><span class="p">;</span>             <span class="cm">/* 最近一次硬件测得的物理电流 (μA) */</span>
    <span class="kt">uint16_t</span> <span class="n">voltage_mv</span><span class="p">;</span>             <span class="cm">/* 最近有效滤波端电压 (mV)，0 代表无效 */</span>
    <span class="n">BatteryConfidence</span> <span class="n">confidence</span><span class="p">;</span>    <span class="cm">/* 当前对外部展示数据的置信度 */</span>
    <span class="n">BatteryState</span> <span class="n">state</span><span class="p">;</span>              <span class="cm">/* 核心状态机枚举 */</span>
    <span class="n">ChargeState</span> <span class="n">charge_state</span><span class="p">;</span>        <span class="cm">/* 经去抖后的业务充电状态 */</span>
    <span class="kt">uint16_t</span> <span class="n">fault_flags</span><span class="p">;</span>            <span class="cm">/* 硬件故障分类位图 */</span>
<span class="p">}</span> <span class="n">BatterySnapshot</span><span class="p">;</span>
</code></pre></div></div>

<hr />

<h2 id="4-状态模型解耦正交维度">4. 状态模型：解耦正交维度</h2>

<h3 id="41-五大正交维度的分离">4.1 五大正交维度的分离</h3>

<p>许多电量模块的 Bug 源于试图用一个统一的 <code class="language-plaintext highlighter-rouge">State</code> 枚举表达所有现象。事实上，系统在任意时刻均由五个互不冲突的正交维度决定：</p>

<ol>
  <li><strong>绝对基线是否建立</strong>：内部是否拥有经过有效事件确认的电荷原点；</li>
  <li><strong>基线的权威程度</strong>：该原点是通过粗估电压查表建立，还是通过充满电硬件锚定建立；</li>
  <li><strong>物理总线健康度</strong>：芯片在线、通信超时、CRC 损坏还是时序异常；</li>
  <li><strong>外部充电语义</strong>：空闲未充、充电恒流中、充满断开还是状态引脚悬空；</li>
  <li><strong>显示层展示模式</strong>：直随物理账本、跟随模型预测还是故障平滑冻结。</li>
</ol>

<p>将这五个维度拆开后，诸如“通信偶发超时但历史满充基线仍然完全可信”的场景便能得到清晰的边界解耦，避免一报错就引发全局变量清零。</p>

<h3 id="42-账本状态机流转">4.2 账本状态机流转</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                  [上电复位 / BKP 校验通过]
                             │
                             ▼
[未初始化 UNINITIALIZED] ──&gt; [正常跟踪 TRACKING] &lt;──┐
           │                         │              │
           │ (充满信号有效且经确认)   │ (重新校准)   │ (通信恢复且连续)
           ▼                         ▼              │
    [满充锚定 FULL_ANCHORED] ───────────────────────┤
                                     │              │
                           (连续 3 次通信严重故障)   │
                                     ▼              │
                             [计量故障 GAUGE_FAULT] ─┘
</code></pre></div></div>

<ul>
  <li><strong>UNINITIALIZED</strong>：系统开机且无有效持久化记录，等待电压稳定或充满事件；</li>
  <li><strong>TRACKING</strong>：已建立基准，依据每笔通过校验的物理增量驱动账本；</li>
  <li><strong>FULL_ANCHORED</strong>：满充事件已完成权威基线校准，正处于最高精度坐标系；</li>
  <li><strong>GAUGE_FAULT</strong>：总线连续失败达到门限。此时保留历史基准，可信度降级，等待总线自愈。</li>
</ul>

<h3 id="43-必须严格守护的八大系统不变量">4.3 必须严格守护的八大系统不变量</h3>

<p>在进行任何代码修改或平台移植时，必须保证以下八条核心不变量（Invariants）绝不破损：</p>

<ol>
  <li><strong>样本准入铁律</strong>：未通过物理速率校验与双读一致性的 ACR 数据，严禁推进核心账本和参考时间戳；</li>
  <li><strong>单向隔离原则</strong>：显示平滑滤波与负载预测消耗，绝不能反写进真实硬件电荷账本；</li>
  <li><strong>权威优先原则</strong>：一旦建立了有效连续账本，动态带载电压与充电端电压严禁强行覆盖账本；</li>
  <li><strong>故障语义分级</strong>：单总线偶发物理通信错误，绝不等同于计量芯片掉电；</li>
  <li><strong>未知不等于零</strong>：未建立初始基准时对外报 <code class="language-plaintext highlighter-rouge">NONE</code>，绝不能向 UI 伪造 0% 导致误关机；</li>
  <li><strong>校准条件严谨</strong>：满充校准依赖持续稳定的物理引脚电平与合法硬件 ACR，绝不依赖“UI 是否已显示 100%”；</li>
  <li><strong>安全关断最高优先级</strong>：低电关机与加热切断必须直连底层硬件判据，绝不等待显示层或主循环状态机慢慢排队；</li>
  <li><strong>跨复位自愈前提</strong>：只有在芯片身份（ROM）与复位标志（RNAOP）双重确认连续时，才允许基于历史 ACR 补算。</li>
</ol>

<hr />

<p><a id="realtime"></a></p>

<h2 id="5-库仑账本单位推导定点计算与模差处理">5. 库仑账本：单位推导、定点计算与模差处理</h2>

<h3 id="51-原始数据拒绝过早截断">5.1 原始数据拒绝过早截断</h3>

<p>DS2740 采样寄存器返回 16 位补码数据。在将物理寄存器换算为对外展示的百分比前，必须在内部保持高精度定点计数（counts）。</p>

<p>以 4000 mAh 电池为例，1% 的电量相当于 40 mAh。如果驱动层过早把每次采样转换为整数百分比，每次只有零点几毫安时的消耗都会在整除中被抹去，累积数小时后将造成严重的漏计。</p>

<h3 id="52-换算基准与参数推导">5.2 换算基准与参数推导</h3>

<p>在案例工程中，硬件采用 <strong>$R_{sns} = 20\text{ m}\Omega$</strong> 采样电阻，芯片配置为 DS2740U。</p>

<p>根据官方数据手册，各物理量单位换算如下：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ACR 分辨率 = 6.25 / R_sns(mΩ) = 6.25 / 20 = 0.3125 mAh / count
配置额定容量 = 4000 mAh
总容量对应 counts = 4000 / 0.3125 = 12800 counts

DS2740U 瞬时电流分辨率 = 1562.5 / R_sns(mΩ) = 1562.5 / 20 = 78.125 μA / LSB
</code></pre></div></div>

<p>在 C99 源码中，通过定点整数避免浮点开销：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#define BM_CAPACITY_COUNTS ((BM_CAPACITY_MAH * BM_RSNS_MOHM * 4) / 25)
</span>
<span class="cm">/* 瞬时电流定点换算 (μA) */</span>
<span class="kt">int32_t</span> <span class="n">current_ua</span> <span class="o">=</span> <span class="p">((</span><span class="kt">int32_t</span><span class="p">)</span><span class="n">raw_current</span> <span class="o">*</span> <span class="mi">15625</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="mi">10</span> <span class="o">*</span> <span class="n">BM_RSNS_MOHM</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="53-16-位有符号模差算法解析-acr-回绕">5.3 16 位有符号模差算法解析 ACR 回绕</h3>

<p>库仑计的累积电荷寄存器（ACR）会在 16 位边界（0x0000 与 0xFFFF 之间）发生自然的自然溢出回绕。如果直接用 32 位无符号减法，溢出时会算出数万 counts 的虚假跳变。</p>

<p>系统采用严格的 <strong>16 位有符号模差算法</strong>：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">int16_t</span> <span class="n">delta</span> <span class="o">=</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)(</span><span class="kt">uint16_t</span><span class="p">)(</span><span class="n">new_acr</span> <span class="o">-</span> <span class="n">old_acr</span><span class="p">);</span>
<span class="n">remaining</span> <span class="o">=</span> <span class="n">clamp</span><span class="p">(</span><span class="n">remaining</span> <span class="o">+</span> <span class="n">POLARITY</span> <span class="o">*</span> <span class="n">delta</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="n">CAPACITY_COUNTS</span><span class="p">);</span>
</code></pre></div></div>

<p>通过两个典型工况验证补码回绕的正确性：</p>

<ul>
  <li><strong>放电越界回绕</strong>：上次采样 <code class="language-plaintext highlighter-rouge">old = 0x0004</code>，本次读到 <code class="language-plaintext highlighter-rouge">new = 0xFFFE</code>。
<code class="language-plaintext highlighter-rouge">new - old = 0xFFFE - 0x0004 = 0xFFFA</code>（16位无符号）。
强制转为 <code class="language-plaintext highlighter-rouge">int16_t</code> 后精确得到 <strong>$-6\text{ counts}$</strong>，准确反映放出 6 counts 电荷。</li>
  <li><strong>充电越界回绕</strong>：上次采样 <code class="language-plaintext highlighter-rouge">old = 0xFFFC</code>，本次读到 <code class="language-plaintext highlighter-rouge">new = 0x0003</code>。
<code class="language-plaintext highlighter-rouge">new - old = 0x0003 - 0xFFFC = 0x0007</code>。
强制转为 <code class="language-plaintext highlighter-rouge">int16_t</code> 精确得到 <strong>$+7\text{ counts}$</strong>，准确反映充入 7 counts 电荷。</li>
</ul>

<p>该算法成立的前提是：<strong>两次采样之间真实的净电荷变动必须落在有符号半量程（$\pm 32767\text{ counts}$）之内</strong>。在 20 mΩ 下，32767 counts 对应超过 10240 mAh，远大于整包电池容量，因此在常规轮询周期下具备坚不可摧的数学可靠性。</p>

<hr />

<h2 id="6-事务级样本接收机制">6. 事务级样本接收机制</h2>

<h3 id="61-候选缓冲与两阶段提交">6.1 候选缓冲与两阶段提交</h3>

<p>总线通信不可避免地会受到电磁干扰或接触抖动。驱动设计严守“两阶段提交”模式：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[读取硬件寄存器] ──&gt; [局部 candidate 临时结构体]
                              │
                    [严格完整性与速率校验]
                              │
                    ┌─────────┴─────────┐
                 [合格]               [异常]
                    │                   │
                    ▼                   ▼
           [正式吸收入账]          [果断丢弃 candidate]
           [推进 last_good_tick]   [保留历史账本与参考点]
</code></pre></div></div>

<p>如果粗暴地直接将原始数据读入全局上下文再去判断合法性，一旦校验失败，系统将被迫处于半更新的污染状态。</p>

<h3 id="62-速率校验的时间窗口锚定">6.2 速率校验的时间窗口锚定</h3>

<p>物理世界中电池电流存在物理上限。系统通过校验单次增量是否超越物理极限，有效拦截异常干扰帧：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>允许最大增量 limit = I_max × (当前时间 - 上次有效样本时间) + 余量
</code></pre></div></div>

<p>这里的关键工程细节是：计算时间差必须使用 <strong><code class="language-plaintext highlighter-rouge">last_good_tick</code>（上次成功被系统接受的样本时间）</strong>，绝不能使用 <code class="language-plaintext highlighter-rouge">last_attempt_tick</code>（上次尝试通信的时间）。</p>

<p>如果使用尝试时间，当总线因外部干扰连续失败两次并在第 15 秒成功恢复时，时间差会被错误算成 5 秒，导致累计了 15 秒物理电荷的合规数据被当成“超速大跳变”错误拒绝，最终陷入“通信越重试、恢复越失败”的死锁。</p>

<hr />

<h2 id="7-adc-采样成对校验与有效性优先">7. ADC 采样：成对校验与有效性优先</h2>

<h3 id="71-成对比值滤波">7.1 成对比值滤波</h3>

<p>电压采集采用芯片内部精确基准 REF（1.20V / 2.048V 等）与分压后的电池端通道 BATT 联动测量：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>V_cell(mV) = (BATT_raw × V_ref_mv) / REF_raw
</code></pre></div></div>

<p>系统按批次采集 8～12 组样本。每采入一组立即计算比值，最终对这批比值进行排序，取中心均值滤波。</p>

<p><strong>为什么不能分别对 BATT 数组和 REF 数组取中位数后再相除？</strong>
在数学上，比值的中位数并不等价于中位数的比值：</p>

\[\text{Median}\left(\frac{A_i}{B_i}\right) \neq \frac{\text{Median}(A_i)}{\text{Median}(B_i)}\]

<p>当电源轨在采样瞬间出现纹波抖动时，单独排序可能会把第 1 次的尖峰 BATT 与第 8 次的低谷 REF 强行拼在一起，凭空捏造出一个从未真实存在过的离谱电压。</p>

<h3 id="72-测量失效与电池欠压的严格区分">7.2 测量失效与电池欠压的严格区分</h3>

<p>当 ADC 轮询超时、硬件分压偏置未开启或基准通道异常时，系统严格将数据标记为 <code class="language-plaintext highlighter-rouge">voltage_valid = 0</code>，向对外接口发布 <code class="language-plaintext highlighter-rouge">0 mV</code> 作为无效哨兵值。</p>

<p>上层业务逻辑绝不能因为“读数失效”就顺理成章地当成“0V 电池放空”执行紧急关机，否则任何底层的偶发采样时序抖动都会导致整机意外死机。</p>

<hr />

<h2 id="8-无基线初始化审慎使用开路电压">8. 无基线初始化：审慎使用开路电压</h2>

<h3 id="81-开路电压查表的苛刻准入条件">8.1 开路电压查表的苛刻准入条件</h3>

<p>当系统彻底断电冷启动、且 BKP 中没有任何历史记录时，系统只能借助静态电压估算初始 SOC。但该入口受到极其严格的互锁保护：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">bool</span> <span class="nf">BM_IdleVoltageAllowed</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">return</span> <span class="p">(</span><span class="o">!</span><span class="n">have_baseline</span><span class="p">)</span> <span class="o">&amp;&amp;</span>                     <span class="cm">/* 1. 仅限无基线状态 */</span>
           <span class="p">(</span><span class="n">voltage_valid</span><span class="p">)</span> <span class="o">&amp;&amp;</span>                      <span class="cm">/* 2. 电压采样有效 */</span>
           <span class="p">(</span><span class="n">raw_charge_state</span> <span class="o">==</span> <span class="n">CHARGE_IDLE</span><span class="p">)</span> <span class="o">&amp;&amp;</span>    <span class="cm">/* 3. 硬件未充电 */</span>
           <span class="p">(</span><span class="n">Pwr1</span> <span class="o">==</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="n">Pwr2</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&amp;&amp;</span>             <span class="cm">/* 4. 脉冲输出关闭 */</span>
           <span class="p">(</span><span class="n">TIM1_CH1_Disabled</span> <span class="o">&amp;&amp;</span> <span class="n">TIM8_CH1N_Disabled</span><span class="p">);</span> <span class="cm">/* 5. 硬件底层输出真正静默 */</span>
<span class="p">}</span>
</code></pre></div></div>

<p>只有当系统确认<strong>既无大功率放电、又无充电电流注入</strong>时，才允许调用 12 点分段线性插值表建立第一个粗略的 <code class="language-plaintext highlighter-rouge">TRACKING</code> 基准。</p>

<h3 id="82-12-点参考曲线与插值规范">8.2 12 点参考曲线与插值规范</h3>

<p>当前采用的 12 点典型常温开路电压（OCV）参考映射表如下：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">端电压 (mV)</th>
      <th style="text-align: center">3000</th>
      <th style="text-align: center">3400</th>
      <th style="text-align: center">3550</th>
      <th style="text-align: center">3660</th>
      <th style="text-align: center">3710</th>
      <th style="text-align: center">3750</th>
      <th style="text-align: center">3790</th>
      <th style="text-align: center">3840</th>
      <th style="text-align: center">3900</th>
      <th style="text-align: center">3970</th>
      <th style="text-align: center">4060</th>
      <th style="text-align: center">4180</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>初始 SOC (%)</strong></td>
      <td style="text-align: center">0</td>
      <td style="text-align: center">5</td>
      <td style="text-align: center">10</td>
      <td style="text-align: center">20</td>
      <td style="text-align: center">30</td>
      <td style="text-align: center">40</td>
      <td style="text-align: center">50</td>
      <td style="text-align: center">60</td>
      <td style="text-align: center">70</td>
      <td style="text-align: center">80</td>
      <td style="text-align: center">90</td>
      <td style="text-align: center">100</td>
    </tr>
  </tbody>
</table>

<p>相邻电压点之间采用标准的整数定点线性插值。需要明确，该曲线仅作为冷机上电时的粗略落脚点，一旦库仑计开始正常工作，该表即刻退出舞台，绝不允许在后续运行中周期性强行查表修正。</p>

<hr />

<p><a id="realtime"></a></p>

<h2 id="9-实时冲突总线安全窗口机制">9. 实时冲突：总线安全窗口机制</h2>

<h3 id="91-微秒级延时与高优先级脉冲输出的冲突">9.1 微秒级延时与高优先级脉冲输出的冲突</h3>

<p>软件模拟 1-Wire 协议要求严苛的微秒级时序：主机拉低 500 μs 复位、并在特定 15～60 μs 采样窗口内读取从机电平。在此期间，MCU 必须临时关闭全局中断（<code class="language-plaintext highlighter-rouge">__disable_irq()</code>）。</p>

<p>然而，工程中的高优先级任务（例如驱动高频功率开关管或脉冲变压器的 TIM 互补 PWM、以及负责实时安全监控的定时器中断）要求微秒级确定性响应。如果在高占空比输出过程中强行执行 1-Wire 通信，长达数百微秒的关中断会直接导致硬件输出波形失真；反之，若在 1-Wire 期间不关中断，中断抢占又会摧毁总线时序，导致通信频繁超时。</p>

<h3 id="92-软件意图与底层硬件双重闭锁">9.2 软件意图与底层硬件双重闭锁</h3>

<p>解决该冲突的核心方案是<strong>总线安全窗口机制（Safe Bus Window）</strong>：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">bool</span> <span class="nf">BM_GaugeBusSafe</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span>
<span class="p">{</span>
    <span class="cm">/* 软件业务层认为已停止 */</span>
    <span class="n">bool</span> <span class="n">soft_idle</span> <span class="o">=</span> <span class="p">(</span><span class="n">Pwr1</span> <span class="o">==</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="n">Pwr2</span> <span class="o">==</span> <span class="mi">0</span><span class="p">);</span>

    <span class="cm">/* 底层硬件定时器比较输出引脚已真正物理关闭 */</span>
    <span class="n">bool</span> <span class="n">hard_idle</span> <span class="o">=</span> <span class="p">((</span><span class="n">TIM1</span><span class="o">-&gt;</span><span class="n">CCER</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">TIM_CCER_CC1E</span> <span class="o">|</span> <span class="n">TIM_CCER_CC2E</span><span class="p">))</span> <span class="o">==</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
                     <span class="p">((</span><span class="n">TIM8</span><span class="o">-&gt;</span><span class="n">CCER</span> <span class="o">&amp;</span> <span class="p">(</span><span class="n">TIM_CCER_CC1NE</span> <span class="o">|</span> <span class="n">TIM_CCER_CC2NE</span><span class="p">))</span> <span class="o">==</span> <span class="mi">0</span><span class="p">);</span>

    <span class="k">return</span> <span class="n">soft_idle</span> <span class="o">&amp;&amp;</span> <span class="n">hard_idle</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>只有当上层意图和底层硬件输出同时处于静默状态时，才允许启动 1-Wire 事务。若窗口不满足，主循环平滑推迟总线采样，绝不强行打断实时输出。</p>

<h3 id="93-通信挂起期间硬件独立积分">9.3 通信挂起期间硬件独立积分</h3>

<p><strong>软件暂停通信并不等同于硬件暂停计量</strong>。</p>

<p>在脉冲输出期间，虽然 MCU 停止读取 DS2740，但库仑计芯片本身由电池持续供电，其内部独立的模拟积分器和计数器依然在以极高精度默默记录流经采样电阻的每一微库伦电荷。当脉冲输出结束、安全窗口重新开启时，MCU 仅需读取一次最新的 ACR 寄存器，即可通过 16 位模差一次性补全整个输出期间消耗的全部电荷。</p>

<hr />

<h2 id="10-1-wire-驱动时序临界区与容错退出">10. 1-Wire 驱动：时序、临界区与容错退出</h2>

<h3 id="101-独立硬件微秒时基">10.1 独立硬件微秒时基</h3>

<p>驱动摒弃了不可靠的 <code class="language-plaintext highlighter-rouge">for</code> 循环空指令延时，采用独立定时器 TIM2 构建确定性时基：</p>
<ul>
  <li>定时器主频 64 MHz，预分频系数设为 63，得到标准的 <strong>1 MHz 自由运行向上计数器</strong>（1 tick = 1 μs）；</li>
  <li>自动重装载寄存器设为 <code class="language-plaintext highlighter-rouge">0xFFFF</code>，支持自然的 16 位溢出回绕；</li>
  <li>超时计算全部采用模差：<code class="language-plaintext highlighter-rouge">(uint16_t)(TIM2-&gt;CNT - start_us) &lt; timeout_us</code>。</li>
</ul>

<h3 id="102-严格规范的临界区保护">10.2 严格规范的临界区保护</h3>

<p>在关键读写时序中，关中断必须遵循“保存现场—恢复现场”规范，严禁粗暴调用全局开中断：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* 正确写法：保存调用者的原始中断屏蔽状态 */</span>
<span class="kt">uint32_t</span> <span class="n">primask</span> <span class="o">=</span> <span class="n">__get_PRIMASK</span><span class="p">();</span>
<span class="n">__disable_irq</span><span class="p">();</span>

<span class="cm">/* 执行极微秒级总线位操作 */</span>

<span class="n">__set_PRIMASK</span><span class="p">(</span><span class="n">primask</span><span class="p">);</span> <span class="cm">/* 精确恢复原有状态，不踩踏上层嵌套 */</span>
</code></pre></div></div>

<h3 id="103-双重有限循环-guard-防御">10.3 双重有限循环 Guard 防御</h3>

<p>在等待总线电平翻转时，若硬件总线因外部短路持续锁死在低电平，单纯依赖时钟判决可能会因时钟异常陷入死循环。</p>

<p>系统在所有等待宏中加入了<strong>有限循环计次门限（Guard Count）</strong>：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#define OW_WAIT_LEVEL(level, max_us, guard) do { \
    uint16_t start = (uint16_t)TIM2-&gt;CNT; \
    uint32_t cnt = 0; \
    while (GPIO_ReadInputDataBit(...) != (level)) { \
        if ((uint16_t)(TIM2-&gt;CNT - start) &gt;= (max_us)) return OW_TIMEOUT; \
        if (++cnt &gt;= (guard)) return OW_BUS_ERROR; \
    } \
} while(0)
</span></code></pre></div></div>

<p>通过“时基超时”与“最大指令循环上限”双重防护，彻底杜绝外设硬件故障导致 CPU 主循环假死。</p>

<hr />

<h2 id="11-ds2740-驱动与主循环协作调度">11. DS2740 驱动与主循环协作调度</h2>

<h3 id="111-芯片复位侦测技巧rnaop-位利用">11.1 芯片复位侦测技巧（RNAOP 位利用）</h3>

<p>DS2740 具备一个专用状态寄存器（Status Register）。其中的 <code class="language-plaintext highlighter-rouge">RNAOP</code>（Read Net Address Opcode）位在上电时的硬件默认值为 <code class="language-plaintext highlighter-rouge">0</code>。</p>

<p>驱动在每次成功完成初始化后，主动将 <code class="language-plaintext highlighter-rouge">RNAOP</code> 置为 <code class="language-plaintext highlighter-rouge">1</code>。在后续的周期性巡检中，若发现该位意外变回了 <code class="language-plaintext highlighter-rouge">0</code>，即可作为<strong>库仑计自身发生过掉电复位</strong>的决定性铁证，从而果断废除旧坐标系，防止按错误的参考点累计。</p>

<h3 id="112-主循环非阻塞协作状态机">11.2 主循环非阻塞协作状态机</h3>

<p>为了将耗时较长的单总线事务平摊到各个调度周期中，<code class="language-plaintext highlighter-rouge">BatteryManager_Process()</code> 被设计为一个分步推进的状态机：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[IDLE 周期等待] ──&gt; [STEP_ADC 单组采集] ──&gt; [STEP_GAUGE 安全读芯片] ──&gt; [STEP_FUSE 融合发布]
       ▲                                                                       │
       └───────────────────────────────────────────────────────────────────────┘
</code></pre></div></div>

<p>每个主循环仅推进一个有限子步骤（例如仅采集一组 ADC，或仅执行一次受控读取），单步耗时控制在毫秒级以内，确保主循环对按键、UI 和保护判据保持毫秒级流畅响应。</p>

<hr />

<p><a id="prediction"></a></p>

<h2 id="12-显示预测在不可测区间提供平滑体验">12. 显示预测：在不可测区间提供平滑体验</h2>

<h3 id="121-引入独立显示预测的必要性">12.1 引入独立显示预测的必要性</h3>

<p>在脉冲电刺激输出期间，为了规避实时冲突，1-Wire 通信完全挂起。如果界面直接冻结不变，用户会误以为设备卡死；若直接拿带载电压查表，电量又会瞬间跳水。</p>

<p>系统的解决方案是构建<strong>轻量级展示预测层（Display Prediction Layer）</strong>：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>真实电荷账本 remaining ──(脉冲期间冻结等待补算)─────────────────&gt; 停机后一次性模差补齐
                                                                   │
负载工况模型推演 ───────&gt; [显示预测器 stim_remaining] ──&gt; [UI 目标] ──&gt; UI 限速展示
</code></pre></div></div>

<p>预测模型仅负责在总线挂起期间计算一个合理的“假想目标”，指导 UI 平滑走字，<strong>该数据绝不反写进底层的硬件真实账本</strong>。</p>

<h3 id="122-双通道负载电流模型与静态功耗扣除">12.2 双通道负载电流模型与静态功耗扣除</h3>

<p>单通道脉冲在不同档位下的平均等效电流基于实测物理曲线分段插值：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: left">刺激强度档位</th>
      <th style="text-align: center">10</th>
      <th style="text-align: center">20</th>
      <th style="text-align: center">30</th>
      <th style="text-align: center">40</th>
      <th style="text-align: center">50</th>
      <th style="text-align: center">60</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>单路参考总电流 (mA)</strong></td>
      <td style="text-align: center">95.3</td>
      <td style="text-align: center">138.7</td>
      <td style="text-align: center">216.6</td>
      <td style="text-align: center">264.4</td>
      <td style="text-align: center">303.5</td>
      <td style="text-align: center">342.8</td>
    </tr>
  </tbody>
</table>

<p>在推导双通道复合工况时，必须<strong>扣除整机基础静态功耗 $I_{base}$（约 27 mA）</strong>，防止基础功耗被重复叠加两次：</p>

\[I_{total} = I_{base} + \sum_{k=1}^{2} \left[ I_{single}(Pwr_k) - I_{base} \right] + I_{heat1} + I_{heat2}\]

<h3 id="123-微安毫秒定点积分与余数保留">12.3 微安·毫秒定点积分与余数保留</h3>

<p>预测模块在主循环中累积消耗。定义基本计算单位为：</p>

\[1\text{ count} = 0.3125\text{ mAh} = 11,250,000\text{ } (0.1\text{ mA} \times \text{ms})\]

<p>在每次循环迭代中，必须<strong>将不足 1 count 的微小余数精准保存在 <code class="language-plaintext highlighter-rouge">fraction</code> 变量中</strong>：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">uint64_t</span> <span class="n">consumed</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint64_t</span><span class="p">)</span><span class="n">model_current_01ma</span> <span class="o">*</span> <span class="n">elapsed_ms</span> <span class="o">+</span> <span class="n">fraction</span><span class="p">;</span>
<span class="kt">uint64_t</span> <span class="n">delta_counts</span> <span class="o">=</span> <span class="n">consumed</span> <span class="o">/</span> <span class="n">UNITS_PER_COUNT</span><span class="p">;</span>
<span class="n">fraction</span> <span class="o">=</span> <span class="n">consumed</span> <span class="o">%</span> <span class="n">UNITS_PER_COUNT</span><span class="p">;</span> <span class="cm">/* 余数无损滚入下一轮 */</span>
<span class="n">predicted_remaining</span> <span class="o">-=</span> <span class="n">delta_counts</span><span class="p">;</span>
</code></pre></div></div>

<p>如果直接丢弃除法余数，由于主循环调度周期极快（每次仅消耗微小的几分之一 count），截断误差会导致预测模型“跑不动”，即使输出持续数分钟，预测电量也纹丝不动。</p>

<h3 id="124-档位切换时先积旧档再切新档">12.4 档位切换时先积旧档、再切新档</h3>

<p>当检测到用户按键调整了脉冲档位时，计算顺序必须严格遵循因果依赖：</p>
<ol>
  <li><strong>第一步</strong>：使用<strong>旧档位电流</strong>与流逝时间 $\Delta t$，将上一阶段的实际电荷消耗积分入账；</li>
  <li><strong>第二步</strong>：将当前观察到的<strong>新档位</strong>存入上下文，作为下一阶段的计算基准。</li>
</ol>

<p>如果颠倒顺序，系统会错误地用崭新的高档位电流去反溯之前低档位区间的消耗，人为放大估算误差。</p>

<hr />

<h2 id="13-满充校准事件权威与幂等控制">13. 满充校准：事件权威与幂等控制</h2>

<h3 id="131-充电信号引脚的业务语义">13.1 充电信号引脚的业务语义</h3>

<p>系统通过硬件上拉检测充电芯片的两只漏极开路指示脚（CHARG 与 STDBY，低电平有效）：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">CHARG 引脚</th>
      <th style="text-align: center">STDBY 引脚</th>
      <th style="text-align: center">业务充电状态</th>
      <th style="text-align: left">说明</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center">高电平</td>
      <td style="text-align: center">高电平</td>
      <td style="text-align: center"><strong>IDLE</strong></td>
      <td style="text-align: left">外部未接充电器，或电源已切断</td>
    </tr>
    <tr>
      <td style="text-align: center">低电平</td>
      <td style="text-align: center">高电平</td>
      <td style="text-align: center"><strong>CHARGING</strong></td>
      <td style="text-align: left">正在恒流/恒压充电中</td>
    </tr>
    <tr>
      <td style="text-align: center">高电平</td>
      <td style="text-align: center">低电平</td>
      <td style="text-align: center"><strong>FULL</strong></td>
      <td style="text-align: left">充电截止，电池已充满</td>
    </tr>
    <tr>
      <td style="text-align: center">低电平</td>
      <td style="text-align: center">低电平</td>
      <td style="text-align: center"><strong>INVALID</strong></td>
      <td style="text-align: left">瞬态抖动或硬件异常组合</td>
    </tr>
  </tbody>
</table>

<h3 id="132-信号去抖与一次性幂等锚定">13.2 信号去抖与一次性幂等锚定</h3>

<p>充满校准绝不能仅凭瞬态脉冲直接触发，必须经过两级可靠性确认：</p>
<ol>
  <li><strong>时序去抖</strong>：<code class="language-plaintext highlighter-rouge">FULL</code> 状态在硬件引脚上<strong>连续稳定保持至少 500 ms</strong>，排除插入和拔出瞬间的机械抖动；</li>
  <li><strong>安全锚定</strong>：在总线安全窗口内取得最新合法 ACR 后，执行原子校准：
    <ul>
      <li>将账本 <code class="language-plaintext highlighter-rouge">remaining_counts</code> 重置为额定最大值（12800 counts）；</li>
      <li>将当前的 <code class="language-plaintext highlighter-rouge">last_acr</code> 设为全新基准参考点；</li>
      <li>将基线可信度提升为最高等级 <code class="language-plaintext highlighter-rouge">ANCHORED</code>，并持久化到 BKP；</li>
      <li>置位 <code class="language-plaintext highlighter-rouge">full_done</code> 标志，确保在设备插着线满电静置的数小时内，<strong>只执行一次基线重置</strong>，绝不周期性反复覆盖正常的静态待机微小损耗。</li>
    </ul>
  </li>
</ol>

<hr />

<h2 id="14-跨复位持久化守护上下文连续性">14. 跨复位持久化：守护上下文连续性</h2>

<h3 id="141-为什么持久化不能只存一个-soc-百分比">14.1 为什么持久化不能只存一个 SOC 百分比</h3>

<p>许多系统习惯在休眠前把电量存为 <code class="language-plaintext highlighter-rouge">85%</code>。但在唤醒恢复时，该数值无法告诉系统：</p>
<ul>
  <li>85% 究竟对应多少物理电荷？</li>
  <li>库仑计当时的内部累积寄存器停在哪个刻度？</li>
  <li>睡眠期间库仑计是否一直在正常走字？</li>
  <li>唤醒后的第一笔新电量应该相对哪个原点进行差分？</li>
</ul>

<p>如果只存百分比，系统在唤醒后将彻底失去连续跟踪能力，只能重新依赖端电压粗估查表，造成频繁的“睡醒电量突变”。</p>

<h3 id="142-bkp-v3-精确存储布局与校验">14.2 BKP V3 精确存储布局与校验</h3>

<p>系统使用 MCU 内部由备用电源维持的 <strong>备份数据寄存器（BKP_DAT1～DAT12）</strong>，总计 24 字节：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: center">寄存器</th>
      <th style="text-align: left">存储内容</th>
      <th style="text-align: left">核心设计意图</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: center"><strong>DAT1</strong></td>
      <td style="text-align: left">Magic Word (<code class="language-plaintext highlighter-rouge">0x2740</code>)</td>
      <td style="text-align: left">既是有效特征字，又是两阶段提交的最后 Commit 标记</td>
    </tr>
    <tr>
      <td style="text-align: center"><strong>DAT2</strong></td>
      <td style="text-align: left">Data Version (<code class="language-plaintext highlighter-rouge">0x0003</code>)</td>
      <td style="text-align: left">结构体版本控制，防止固件升级后结构体错位</td>
    </tr>
    <tr>
      <td style="text-align: center"><strong>DAT3</strong></td>
      <td style="text-align: left">Baseline Confidence</td>
      <td style="text-align: left">记录基线等级（<code class="language-plaintext highlighter-rouge">TRACKING</code> 或 <code class="language-plaintext highlighter-rouge">ANCHORED</code>）</td>
    </tr>
    <tr>
      <td style="text-align: center"><strong>DAT4</strong></td>
      <td style="text-align: left">Last Valid ACR</td>
      <td style="text-align: left">保存时的库仑计绝对硬件参考点</td>
    </tr>
    <tr>
      <td style="text-align: center"><strong>DAT5~6</strong></td>
      <td style="text-align: left">Remaining Counts</td>
      <td style="text-align: left">32 位剩余电荷绝对计数值（低 16 位在前）</td>
    </tr>
    <tr>
      <td style="text-align: center"><strong>DAT7</strong></td>
      <td style="text-align: left">Capacity Counts</td>
      <td style="text-align: left">保存时的电池额定总容量基准</td>
    </tr>
    <tr>
      <td style="text-align: center"><strong>DAT8~11</strong></td>
      <td style="text-align: left">64-bit DS2740 ROM</td>
      <td style="text-align: left">库仑计芯片的全球唯一 64 位硬件身份序列号</td>
    </tr>
    <tr>
      <td style="text-align: center"><strong>DAT12</strong></td>
      <td style="text-align: left">CRC-16 校验码</td>
      <td style="text-align: left">覆盖前 11 个字的数据完整性校验</td>
    </tr>
  </tbody>
</table>

<h3 id="143-最小提交协议commit-protocol">14.3 最小提交协议（Commit Protocol）</h3>

<p>在写入备份域时，系统采用严格的写入时序保证原子性：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">BKP_Write</span><span class="p">(</span><span class="n">DAT1</span><span class="p">,</span> <span class="mh">0x0000</span><span class="p">);</span> <span class="cm">/* 第一步：先清空 Magic，将旧记录显式作废 */</span>

<span class="cm">/* 第二步：按序写入版本号、物理参数、身份码和计算出的全新 CRC16 */</span>
<span class="n">BKP_Write</span><span class="p">(</span><span class="n">DAT2</span><span class="p">,</span> <span class="n">VERSION</span><span class="p">);</span>
<span class="p">...</span>
<span class="n">BKP_Write</span><span class="p">(</span><span class="n">DAT12</span><span class="p">,</span> <span class="n">crc16_value</span><span class="p">);</span>

<span class="n">BKP_Write</span><span class="p">(</span><span class="n">DAT1</span><span class="p">,</span> <span class="mh">0x2740</span><span class="p">);</span> <span class="cm">/* 第三步：最后一步回写 Magic，完成正式提交 */</span>
</code></pre></div></div>

<p>如果在写入中途 MCU 发生断电或意外复位，重启时由于 <code class="language-plaintext highlighter-rouge">DAT1 != 0x2740</code>，系统能准确识别出这是一条“未完成的半截废数据”，果断放弃恢复并重新初始化，绝不加载被破坏的数据。</p>

<h3 id="144-校验恢复流水线与补算">14.4 校验恢复流水线与补算</h3>

<p>在开机恢复时，<code class="language-plaintext highlighter-rouge">BM_TryRestore()</code> 顺序通过七重安全门禁：</p>
<ol>
  <li><code class="language-plaintext highlighter-rouge">DAT1</code> Magic 匹配且 CRC16 严格通过；</li>
  <li>存储版本号完全一致；</li>
  <li>容量配置与系统宏定义一致；</li>
  <li>现场读出的 64 位芯片 ROM 码与存储的一致（防止电池包或从板被热替换）；</li>
  <li>现场读出的芯片 <code class="language-plaintext highlighter-rouge">RNAOP</code> 标志位仍为 1（证明库仑计自身未发生过掉电复位）；</li>
  <li>存储的 ACR 与现场读到的 ACR 模差在合理容量边界内；</li>
  <li>恢复出的剩余电量落在 $0 \sim \text{Capacity}$ 合法区间。</li>
</ol>

<p>门禁全部通过后，系统通过模差算法补全休眠期间的全部电量变动：</p>

\[\text{Restored Remaining} = \text{Saved Remaining} + \text{Polarity} \times \left( \text{Current ACR} - \text{Saved ACR} \right)\]

<p>系统随后将当前 ACR 刷新为最新起点。整个恢复过程完全不需要读取电压，实现了平滑无缝的电量延续。</p>

<hr />

<p><a id="lifecycle"></a></p>

<h2 id="15-ui-展示层平滑滤波与单向约束">15. UI 展示层：平滑滤波与单向约束</h2>

<h3 id="151-三层数值解耦">15.1 三层数值解耦</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[物理账本 SOC]  ──&gt; 依据真实电荷积分，毫秒级反映微小进出
       │
[内部目标 SOC]  ──&gt; 融合刺激预测与故障保持后的计算目标
       │
[展示层 UI SOC] ──&gt; 面向用户的最终平滑视图，严格遵守步进与单向约束
</code></pre></div></div>

<p>三层解耦保证了：<strong>内部对物理世界了如指掌，而对外展现给用户的是一个稳定、合乎直觉的仪表盘</strong>。</p>

<h3 id="152-核心展示平滑规则">15.2 核心展示平滑规则</h3>

<ol>
  <li><strong>步进限速</strong>：UI 显示每 <strong>5000 ms 内最多只能变动 1 个百分点</strong>，消除任何因算法切换导致的数字跳动；</li>
  <li><strong>放电单向约束</strong>：在未接入充电器的正常使用期（<code class="language-plaintext highlighter-rouge">CHARGE_IDLE</code>），<strong>UI 电量绝对不允许向上反弹</strong>；</li>
  <li><strong>充电单向约束</strong>：在充电状态（<code class="language-plaintext highlighter-rouge">CHARGING</code> / <code class="language-plaintext highlighter-rouge">FULL</code>）下，<strong>UI 电量绝对不允许向下倒退</strong>；</li>
  <li><strong>异常保持</strong>：通信偶发故障或信号抖动时，UI 坚定冻结显示上一次的合法百分比，避免闪烁；</li>
  <li><strong>未知与真零分离</strong>：未建立有效基准时，屏幕隐藏百分比或显示破折号；只有真实电荷耗尽时才显示“0%”。</li>
</ol>

<hr />

<h2 id="16-安全保护与硬件互锁">16. 安全保护与硬件互锁</h2>

<h3 id="161-低电关机严禁依赖-ui-估算值">16.1 低电关机严禁依赖 UI 估算值</h3>

<p><strong>显示层为了用户体验添加的限速和单向滤波，在功能安全领域统称为“延迟与失真”</strong>。</p>

<p>如果将低电关机的判据绑定在“UI 变为 0%”上，当大电流突发负载将端电压拉低到保护板即将截断的临界点时，UI 可能还在以每 5 秒 1% 的速度慢吞吞递减。这将导致设备尚未提示用户保存就直接被硬件保护板猝死切断，甚至引发 Flash 写入崩溃。</p>

<p>因此，<strong>低电关机必须直连底层的独立硬件 ADC 判据</strong>：</p>
<ul>
  <li>采集真实的 BATT 端电压；</li>
  <li>采用 8 组中位值滤波形成批次；</li>
  <li>采用 9 批连续滑动投票机制（超过 5 批低于 3.309V 门限即刻触发紧急关机流程）；</li>
  <li>保护逻辑完全独立于任何电量估算代码。</li>
</ul>

<h3 id="162-经典-bug-复盘主循环单次关热为什么会被中断踩踏">16.2 经典 Bug 复盘：主循环单次关热为什么会被中断踩踏</h3>

<p>在包含加热功能的设备中，曾出现过一个极为隐蔽的软硬件竞态缺陷：
当用户插入充电器时，主循环虽然检测到了充电状态，并调用函数将加热引脚拉低（<code class="language-plaintext highlighter-rouge">HOTEN_OFF</code>）；但此时用户之前设定的温度档位全局变量仍停留在“中档”。</p>

<p>负责产生加热 PWM 调制信号的底层定时器中断（TIM5 ISR）每隔 100 毫秒执行一次：中断一触发，代码按当前的“中档”重新计算占空比，并再度将加热引脚拉高（<code class="language-plaintext highlighter-rouge">HOTEN_ON</code>）。结果导致主循环的单次关热指令被底层中断反复践踏，使得设备在充电时依然偷偷发热。</p>

<h3 id="163-最终执行点硬件互锁defensive-sink">16.3 最终执行点硬件互锁（Defensive Sink）</h3>

<p>正确的工程解法是在<strong>最终硬件输出汇合处建立不可逾越的安全互锁</strong>：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* TIM5 中断服务函数中，最靠近硬件输出寄存器的唯一出口 */</span>
<span class="kt">void</span> <span class="nf">TIM5_IRQHandler</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span>
<span class="p">{</span>
    <span class="cm">/* 1. 硬件级首要互锁：直接读取充电引脚的原始电平 */</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">GPIO_ReadInputDataBit</span><span class="p">(</span><span class="n">CHARG_PORT</span><span class="p">,</span> <span class="n">CHARG_PIN</span><span class="p">)</span> <span class="o">==</span> <span class="n">Bit_RESET</span> <span class="o">||</span>
        <span class="n">GPIO_ReadInputDataBit</span><span class="p">(</span><span class="n">STDBY_PORT</span><span class="p">,</span> <span class="n">STDBY_PIN</span><span class="p">)</span> <span class="o">==</span> <span class="n">Bit_RESET</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="cm">/* 只要插着线，底层无条件切断加热开关管，直接忽略任何上层软件档位 */</span>
        <span class="n">HOTEN_OFF</span><span class="p">();</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="cm">/* 2. 正常工况下，才允许根据上层档位配置 PWM 占空比 */</span>
    <span class="n">Apply_Heater_PWM</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<p>将安全互锁从脆弱的上层业务页面下沉到<strong>物理执行的最终汇合点</strong>，无论上层逻辑发生多严重的调度错乱、任务阻塞或变量残留，底层硬件安全都能得到绝对保证。</p>

<hr />

<h2 id="17-完整生命周期事件演进">17. 完整生命周期事件演进</h2>

<p>为了清晰呈现各模块的协同，下表梳理系统在关键生命周期节点中的行为：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>[冷机上电] ──&gt; 探测 1-Wire ──&gt; BKP 校验失败 ──&gt; 满足空闲条件 ──&gt; 电压查表建初值 ──&gt; 启动跟踪
     │
[正常运行] ──&gt; 周期 ADC 滤波 ──&gt; 安全窗口读 ACR ──&gt; 16位模差入账 ──&gt; UI 限速 1%/5s 刷新
     │
[启动脉冲] ──&gt; 硬件输出开启 ──&gt; 挂起 1-Wire ──&gt; 模型定点积分 ──&gt; UI 预测走字
     │
[停止脉冲] ──&gt; 硬件输出关闭 ──&gt; 窗口恢复 ──&gt; 读最新 ACR ──&gt; 一次性模差补齐 ──&gt; 清除预测态
     │
[插入充电] ──&gt; 底层 ISR 硬切断加热 ──&gt; 充电去抖 500ms ──&gt; UI 转为单向递增
     │
[满充锚定] ──&gt; STDBY 稳定 500ms ──&gt; 读取有效 ACR ──&gt; 重置 100% 基线 ──&gt; 写入 BKP ──&gt; 保持幂等
     │
[准备休眠] ──&gt; 调用 PrepareForStop ──&gt; 封存最后有效数据 ──&gt; 提交 BKP ──&gt; 进入 STOP2
     │
[唤醒重启] ──&gt; 读 BKP ──&gt; 七重门禁全过 ──&gt; 模差补算休眠损耗 ──&gt; 零跳变恢复运行
</code></pre></div></div>

<hr />

<p><a id="migration"></a></p>

<h2 id="18-跨平台移植与最小抽象层设计">18. 跨平台移植与最小抽象层设计</h2>

<p>当需要将本套电量架构移植到 STM32、GD32、ESP32 或搭载 RTOS 的新项目中时，切忌全盘复制代码。应当优先为核心模块实现以下六个最小硬件抽象层（HAL）接口：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* 最小硬件适配接口草案 */</span>
<span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="kt">uint32_t</span> <span class="p">(</span><span class="o">*</span><span class="n">get_now_ms</span><span class="p">)(</span><span class="kt">void</span><span class="p">);</span>                       <span class="cm">/* 单调递增毫秒时钟 */</span>
    <span class="n">bool</span>     <span class="p">(</span><span class="o">*</span><span class="n">is_hardware_safe</span><span class="p">)(</span><span class="kt">void</span><span class="p">);</span>                 <span class="cm">/* 硬件是否处于安全访问窗口 */</span>
    <span class="n">bool</span>     <span class="p">(</span><span class="o">*</span><span class="n">read_battery_mv</span><span class="p">)(</span><span class="kt">uint16_t</span> <span class="o">*</span><span class="n">voltage_mv</span><span class="p">);</span>  <span class="cm">/* 读取有效端电压 */</span>
    <span class="n">bool</span>     <span class="p">(</span><span class="o">*</span><span class="n">read_gauge_sample</span><span class="p">)(</span><span class="n">Ds2740Sample</span> <span class="o">*</span><span class="n">sample</span><span class="p">);</span><span class="cm">/* 读取底层硬件库仑计 */</span>
    <span class="n">ChargeRawState</span> <span class="p">(</span><span class="o">*</span><span class="n">read_charger_pins</span><span class="p">)(</span><span class="kt">void</span><span class="p">);</span>          <span class="cm">/* 读取充电引脚瞬时物理电平 */</span>
    <span class="n">bool</span>     <span class="p">(</span><span class="o">*</span><span class="n">bkp_save_record</span><span class="p">)(</span><span class="k">const</span> <span class="n">BatteryRecord</span> <span class="o">*</span><span class="n">r</span><span class="p">);</span><span class="cm">/* 持久化写入 */</span>
    <span class="n">bool</span>     <span class="p">(</span><span class="o">*</span><span class="n">bkp_load_record</span><span class="p">)(</span><span class="n">BatteryRecord</span> <span class="o">*</span><span class="n">r</span><span class="p">);</span>      <span class="cm">/* 持久化读取 */</span>
<span class="p">}</span> <span class="n">BatteryPlatformOps</span><span class="p">;</span>
</code></pre></div></div>

<p>通过将具体的时钟树配置、定时器寄存器操作以及 BKP 驱动收敛到底层接口中，<code class="language-plaintext highlighter-rouge">battery_manager.c</code> 中的状态机、模差计算、显示限速和预测逻辑可以实现 100% 纯逻辑无缝跨平台复用。</p>

<hr />

<h2 id="19-工程踩坑排查清单">19. 工程踩坑排查清单</h2>

<p>在调试便携电量管理系统时，以下是最容易踩中的技术隐患：</p>

<ol>
  <li><strong>电阻单位混淆</strong>：代码中的 <code class="language-plaintext highlighter-rouge">BM_RSNS_MOHM</code> 宏使用的是毫欧（如 <code class="language-plaintext highlighter-rouge">20</code>）。若误写成欧姆（<code class="language-plaintext highlighter-rouge">0.02</code>）或微欧（<code class="language-plaintext highlighter-rouge">20000</code>），将彻底摧毁整个定点换算体系；</li>
  <li><strong>假死在关中断区</strong>：1-Wire 等待循环务必加入 Guard Count，防止因上拉断开导致 MCU 永久死锁在临界区；</li>
  <li><strong>把尝试时间当有效时间</strong>：速率校验如果绑定了重试时间戳，通信故障恢复后必定引发虚假超速报警；</li>
  <li><strong>充满后 UI 滞后不是 Bug</strong>：满充锚定瞬时将内部账本重置为 100%，而 UI 需遵守 1%/5s 限速。从 85% 追赶到 100% 约需 75 秒，这是为了兼顾用户体验的刻意设计；</li>
  <li><strong>休眠期间 BKP 供电</strong>：MCU 备份域若依赖电池直供，必须确保在整机主 LDO 关断时，VBAT 引脚仍有可靠备电，否则无法实现跨睡眠连续恢复；</li>
  <li><strong>边充边用场景极性相反</strong>：若仪器支持插线运行，必须仔细核对采样电阻的位置是串在“纯电池支路”还是“系统总电源支路”，否则充电时开启大功率输出会被诊断逻辑误判为“极性接反故障”。</li>
</ol>

<hr />

<p><a id="references"></a></p>

<h2 id="20-核心源码锚点与参考资料">20. 核心源码锚点与参考资料</h2>

<h3 id="201-关键逻辑速查">20.1 关键逻辑速查</h3>

<table>
  <thead>
    <tr>
      <th style="text-align: left">核心功能逻辑</th>
      <th style="text-align: left">对应函数 / 结构体</th>
      <th style="text-align: left">核心设计关注点</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: left"><strong>状态机与生命周期</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">BatteryManager_Process</code> / <code class="language-plaintext highlighter-rouge">PrepareForStop</code></td>
      <td style="text-align: left">非阻塞分步推进与休眠前状态封存</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>ACR 模差与安全入账</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">BM_FuseSample</code></td>
      <td style="text-align: left">16 位有符号差分，防止溢出回绕</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>物理速率防跳变校验</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">BM_DeltaLimit</code> / <code class="language-plaintext highlighter-rouge">BM_DeltaValid</code></td>
      <td style="text-align: left">基于 <code class="language-plaintext highlighter-rouge">last_good_tick</code> 的物理电流上界判断</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>总线安全窗口判断</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">BM_GaugeBusSafe</code></td>
      <td style="text-align: left">软件意图与底层比较输出双重校验</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>跨复位持久化管理</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">BM_BkpSave</code> / <code class="language-plaintext highlighter-rouge">BM_TryRestore</code></td>
      <td style="text-align: left">24 字节 BKP 存储、CRC16 校验与提交协议</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>展示层限速与平滑</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">BM_UpdateDisplay</code></td>
      <td style="text-align: left">5000ms 步进、单向递增/递减与异常保持</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>开路电压准入与插值</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">BM_IdleVoltageAllowed</code> / <code class="language-plaintext highlighter-rouge">BM_VoltageToSoc</code></td>
      <td style="text-align: left">纯空闲严格准入与 12 点定点分段插值</td>
    </tr>
    <tr>
      <td style="text-align: left"><strong>输出端硬件关热互锁</strong></td>
      <td style="text-align: left"><code class="language-plaintext highlighter-rouge">TIM5_IRQHandler</code> (中断底层)</td>
      <td style="text-align: left">最底层硬件出口直接闭锁加热开关管</td>
    </tr>
  </tbody>
</table>

<h3 id="202-外部资料与规范">20.2 外部资料与规范</h3>

<ul>
  <li><a href="https://www.analog.com/media/en/technical-documentation/data-sheets/DS2740.pdf">Analog Devices DS2740 官方数据手册</a>：用于核对 16 位补码物理意义、RNAOP 与 SMOD 状态寄存器位定义；</li>
  <li>ARM Cortex-M4 编程手册：深入理解 <code class="language-plaintext highlighter-rouge">PRIMASK</code> 临界区保护规范与硬件单周期指令行为。</li>
</ul>]]></content><author><name></name></author><category term="嵌入式" /><category term="电量管理" /><category term="DS2740" /><category term="库仑计" /><category term="SOC" /><category term="N32G455" /><category term="裸机架构" /><summary type="html"><![CDATA[以 N32G455 与 DS2740 裸机工程为例，深入剖析库仑账本、事务级样本提交、安全窗口通信、显示预测、满充校准与跨复位恢复的系统设计与实战经验。]]></summary></entry><entry><title type="html">NOR Flash 掉电安全定长记录存储与 RTT 故障注入验证</title><link href="https://single5240.github.io/articles/nor-flash-powerfail-record-store.html" rel="alternate" type="text/html" title="NOR Flash 掉电安全定长记录存储与 RTT 故障注入验证" /><published>2026-10-16T00:00:00+08:00</published><updated>2026-10-16T00:00:00+08:00</updated><id>https://single5240.github.io/articles/nor-flash-powerfail-record-store</id><content type="html" xml:base="https://single5240.github.io/articles/nor-flash-powerfail-record-store.html"><![CDATA[<p>在嵌入式裸机与轻量系统中，使用 SPI NOR Flash（如 W25Q128）保存结构化测试记录或日志时，必须直面硬件物理特性的严苛约束：扇区擦除耗时长（45～400 ms）、页编程仅支持将位从 <code class="language-plaintext highlighter-rouge">1</code> 改为 <code class="language-plaintext highlighter-rouge">0</code>，以及写入随时可能被意外掉电打断。存储层因此采用定长槽、两阶段原子提交、原位墓碑标记与世代控制环架构。下面系统梳理存储格式、状态转移算法、自愈规则及实机故障注入验证方案。底层 SPI 非阻塞驱动见 <a href="/articles/w25q128-spi-flash-driver-guide.html">N32G455 W25Q128 SPI NOR Flash 驱动与非阻塞擦写实践</a>。</p>

<h2 id="设计目标与物理约束">设计目标与物理约束</h2>

<p>这套记录存储需要满足以下要求：</p>

<ol>
  <li><strong>掉电原子性</strong>：在记录写入、单条删除、整表清空或扇区整理的任意字节/指令边界断电，重启后不得出现半写脏数据被误判为有效记录、已有有效记录丢失或显示序号错乱。</li>
  <li><strong>硬实时与看门狗安全</strong>：主循环运行 AD7190 约 2.4 kHz 连续采样与 8 行条带 LCD 刷新，独立看门狗（IWDG）超时窗口约 1 s。任何保存、删除、清空确认或后台擦除都严禁在主循环中原地阻塞等待 Flash 内部耗时。</li>
  <li><strong>支持 O(1) 整表清空与单条原位删除</strong>：
    <ul>
      <li>用户执行“清空全部”时必须立即生效，不能在按键确认路径中同步擦除数十个 4 KiB 扇区；</li>
      <li>用户执行“单条删除”后，列表显示序号动态重排为连续的 <code class="language-plaintext highlighter-rouge">1..N</code>，但不能为了顺延序号而重写 Flash 中后续数百条记录。</li>
    </ul>
  </li>
  <li><strong>受控的稀疏扇区空间回收</strong>：单条删除积累到一定程度后，按需把稀疏扇区中仅存的有效记录安全迁移到追加扇区并擦除回收源扇区，且避免通用 KV 文件系统的高 RAM 开销与写放大。</li>
</ol>

<h2 id="分区规划与容量预算">分区规划与容量预算</h2>

<p>系统选用 W25Q128（16 MiB）顶部的 <strong>156 KiB（<code class="language-plaintext highlighter-rouge">0xFD9000</code>～<code class="language-plaintext highlighter-rouge">0xFFFFFF</code>）</strong> 作为记录专用分区，共包含 <strong>39 个 4 KiB 物理扇区</strong>：</p>

<table>
  <thead>
    <tr>
      <th>分区区域</th>
      <th>物理地址范围</th>
      <th style="text-align: right">扇区数</th>
      <th>核心用途</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>控制环扇区 0</td>
      <td><code class="language-plaintext highlighter-rouge">0xFD9000</code>～<code class="language-plaintext highlighter-rouge">0xFD9FFF</code></td>
      <td style="text-align: right">1</td>
      <td>世代控制记录（Generation）与迁移事务日志（<code class="language-plaintext highlighter-rouge">MIGRATE_BEGIN/COMMIT</code>）</td>
    </tr>
    <tr>
      <td>控制环扇区 1</td>
      <td><code class="language-plaintext highlighter-rouge">0xFDA000</code>～<code class="language-plaintext highlighter-rouge">0xFDAFFF</code></td>
      <td style="text-align: right">1</td>
      <td>同上，三扇区顺序轮转备用</td>
    </tr>
    <tr>
      <td>控制环扇区 2</td>
      <td><code class="language-plaintext highlighter-rouge">0xFDB000</code>～<code class="language-plaintext highlighter-rouge">0xFDBFFF</code></td>
      <td style="text-align: right">1</td>
      <td>同上，三扇区顺序轮转备用</td>
    </tr>
    <tr>
      <td>动态数据扇区池</td>
      <td><code class="language-plaintext highlighter-rouge">0xFDC000</code>～<code class="language-plaintext highlighter-rouge">0xFFFFFF</code></td>
      <td style="text-align: right">36</td>
      <td>扇区头、64B 数据记录槽、稀疏迁移目标及至少 2 个经校验全空扇区</td>
    </tr>
  </tbody>
</table>

<h3 id="容量与余量计算">容量与余量计算</h3>

<ul>
  <li><strong>扇区内部结构</strong>：每个 4 KiB（4,096 B）数据扇区划分为 64 个固定 64 字节槽位——槽 0 固定为 <strong>64B 数据扇区头</strong>，槽 1～63 为 <strong>63 个数据记录槽</strong>。</li>
  <li><strong>产品上限与物理预留</strong>：
    <ul>
      <li>当前产品可见记录上限设为 <strong>1,000 条</strong>，最多占用 <code class="language-plaintext highlighter-rouge">ceil(1000 / 63) = 16</code> 个数据扇区；</li>
      <li>物理分区一次规划支持 <strong>2,000 条</strong>（需 <code class="language-plaintext highlighter-rouge">ceil(2000 / 63) = 32</code> 个数据扇区）；</li>
      <li>36 个数据扇区共提供 <code class="language-plaintext highlighter-rouge">36 × 63 = 2,268</code> 个物理记录槽；扣除强制保留的 <strong>至少 2 个经整扇区验证为 <code class="language-plaintext highlighter-rouge">0xFF</code> 的全空扇区（<code class="language-plaintext highlighter-rouge">EMPTY</code>）</strong> 后，仍开放 <code class="language-plaintext highlighter-rouge">34 × 63 = 2,142</code> 个槽位，足以容纳 2,000 条有效记录及 142 个过渡/掉电脏槽。</li>
    </ul>
  </li>
</ul>

<h2 id="64-字节定长槽编码与两阶段提交">64 字节定长槽编码与两阶段提交</h2>

<p>为消除编译器结构体对齐与填充差异，所有记录槽固定为 <strong>64 字节</strong>，统一使用显式小端函数（<code class="language-plaintext highlighter-rouge">PutU16/PutU32</code>、<code class="language-plaintext highlighter-rouge">GetU16/GetU32</code>）序列化。</p>

<h3 id="数据记录槽布局format-v3">数据记录槽布局（Format V3）</h3>

<table>
  <thead>
    <tr>
      <th style="text-align: right">字节偏移</th>
      <th style="text-align: right">长度</th>
      <th>字段名称</th>
      <th>约束与含义</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0..3</code></td>
      <td style="text-align: right">4 B</td>
      <td><code class="language-plaintext highlighter-rouge">magic</code></td>
      <td>固定 4 字节 ASCII 魔数标识</td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">4</code></td>
      <td style="text-align: right">1 B</td>
      <td><code class="language-plaintext highlighter-rouge">version</code></td>
      <td>格式版本号（当前为 <code class="language-plaintext highlighter-rouge">3</code>）</td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">5</code></td>
      <td style="text-align: right">1 B</td>
      <td><code class="language-plaintext highlighter-rouge">recordType</code></td>
      <td><code class="language-plaintext highlighter-rouge">1</code> = 数据记录，<code class="language-plaintext highlighter-rouge">2</code> = 世代/迁移控制记录</td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">6..7</code></td>
      <td style="text-align: right">2 B</td>
      <td><code class="language-plaintext highlighter-rouge">recordLen</code></td>
      <td>固定为 <code class="language-plaintext highlighter-rouge">64</code></td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">8..11</code></td>
      <td style="text-align: right">4 B</td>
      <td><code class="language-plaintext highlighter-rouge">generation</code></td>
      <td>世代号（无符号 32 位回绕序号，清空全部时递增）</td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">12..15</code></td>
      <td style="text-align: right">4 B</td>
      <td><code class="language-plaintext highlighter-rouge">recordId</code></td>
      <td>当前世代内单调递增的内部稳定主键（<code class="language-plaintext highlighter-rouge">1..N</code>，删除后不重排）</td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">16..22</code></td>
      <td style="text-align: right">7 B</td>
      <td><code class="language-plaintext highlighter-rouge">completedAt</code></td>
      <td>完成绝对时间：<code class="language-plaintext highlighter-rouge">uint16_t</code> 年 + 月、日、时、分、秒</td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">23..27</code></td>
      <td style="text-align: right">5 B</td>
      <td><code class="language-plaintext highlighter-rouge">meta</code></td>
      <td>测试动作（压缩/拉伸/插拔/接点/破断/按键）、有效位掩码、单位、量程、公差判定</td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">28..43</code></td>
      <td style="text-align: right">16 B</td>
      <td><code class="language-plaintext highlighter-rouge">values</code></td>
      <td><code class="language-plaintext highlighter-rouge">result1</code>、<code class="language-plaintext highlighter-rouge">result2</code>、公差下限、公差上限（有符号 32 位 <code class="language-plaintext highlighter-rouge">mN</code>）</td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">44..49</code></td>
      <td style="text-align: right">6 B</td>
      <td><code class="language-plaintext highlighter-rouge">buttonExt</code></td>
      <td>按键测试扩展字段（<code class="language-plaintext highlighter-rouge">Fr</code>、结果组合选择、算法版本；非按键动作保持 <code class="language-plaintext highlighter-rouge">0xFF</code>）</td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">50..58</code></td>
      <td style="text-align: right">9 B</td>
      <td><code class="language-plaintext highlighter-rouge">reserved</code></td>
      <td>预留扩展，固定保持擦除态 <code class="language-plaintext highlighter-rouge">0xFF</code></td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">59..60</code></td>
      <td style="text-align: right">2 B</td>
      <td><code class="language-plaintext highlighter-rouge">lifecycle</code></td>
      <td><strong>原位生命周期码</strong>（小端，不参与正文 CRC，支持单向 <code class="language-plaintext highlighter-rouge">1 → 0</code> 编程）</td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">61..62</code></td>
      <td style="text-align: right">2 B</td>
      <td><code class="language-plaintext highlighter-rouge">crc16</code></td>
      <td><strong>CRC-16/MODBUS</strong>，仅覆盖不可变正文偏移 <code class="language-plaintext highlighter-rouge">0..58</code></td>
    </tr>
    <tr>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">63</code></td>
      <td style="text-align: right">1 B</td>
      <td><code class="language-plaintext highlighter-rouge">commitFlag</code></td>
      <td><strong>独立提交标志</strong>：首阶段保持 <code class="language-plaintext highlighter-rouge">0xFF</code>，校验通过后单独编程为 <code class="language-plaintext highlighter-rouge">0x00</code></td>
    </tr>
  </tbody>
</table>

<h3 id="两阶段提交two-phase-commit写流程">两阶段提交（Two-Phase Commit）写流程</h3>

<p>NOR Flash 页编程如果在写正文中途断电，会留下部分字节已写、部分字节仍为 <code class="language-plaintext highlighter-rouge">0xFF</code> 的半写槽。为此，所有数据记录、扇区头和控制记录均严格执行两阶段提交：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>阶段 1（写正文与 CRC）：
  ├─ 目标槽 64 字节必须处于全 0xFF 擦除态（ERASED）
  ├─ 序列化偏移 0..58 正文与 61..62 的 CRC-16
  ├─ 偏移 59..60 (lifecycle) 保持 0xFFFF，偏移 63 (commitFlag) 保持 0xFF
  ├─ 启动单页非阻塞编程并轮询 BUSY 清零
  └─ 整槽 64 字节读回，逐字节比对正文、CRC 及 commitFlag == 0xFF

阶段 2（单独写提交字节）：
  ├─ 仅对偏移 63 发起 1 字节页编程：0xFF → 0x00
  ├─ 轮询 BUSY 清零后读回确认 commitFlag == 0x00
  └─ 更新 RAM 中的可见记录数、下一内部 ID 与扇区有效计数
</code></pre></div></div>

<h4 id="定长槽-c99-结构体定义">定长槽 C99 结构体定义</h4>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#pragma pack(push, 1)
</span><span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="kt">uint32_t</span> <span class="n">magic</span><span class="p">;</span>          <span class="cm">/* 偏移 0..3: ASCII 魔数标识 (如 0x52454344 'RECD') */</span>
    <span class="kt">uint8_t</span>  <span class="n">version</span><span class="p">;</span>        <span class="cm">/* 偏移 4: 格式版本号 (当前为 3) */</span>
    <span class="kt">uint8_t</span>  <span class="n">recordType</span><span class="p">;</span>     <span class="cm">/* 偏移 5: 记录类型 (1=数据记录, 2=世代/迁移控制记录) */</span>
    <span class="kt">uint16_t</span> <span class="n">recordLen</span><span class="p">;</span>      <span class="cm">/* 偏移 6..7: 固定为 64 字节 */</span>
    <span class="kt">uint32_t</span> <span class="n">generation</span><span class="p">;</span>     <span class="cm">/* 偏移 8..11: 世代号 (整表逻辑清空时单调递增) */</span>
    <span class="kt">uint32_t</span> <span class="n">recordId</span><span class="p">;</span>       <span class="cm">/* 偏移 12..15: 世代内稳定自增主键 (1..N，删除不重排) */</span>
    <span class="kt">uint8_t</span>  <span class="n">payload</span><span class="p">[</span><span class="mi">43</span><span class="p">];</span>    <span class="cm">/* 偏移 16..58: 43 字节通用业务数据载荷 (时间戳、测量结果、状态等) */</span>
    <span class="kt">uint16_t</span> <span class="n">lifecycle</span><span class="p">;</span>      <span class="cm">/* 偏移 59..60: 原位生命周期码 (0xFFFF=有效, 0xA55A=已删除, 0x5AA5=已迁移) */</span>
    <span class="kt">uint16_t</span> <span class="n">crc16</span><span class="p">;</span>          <span class="cm">/* 偏移 61..62: CRC-16/MODBUS (仅覆盖偏移 0..58 正文) */</span>
    <span class="kt">uint8_t</span>  <span class="n">commitFlag</span><span class="p">;</span>     <span class="cm">/* 偏移 63: 独立提交标志 (0xFF=未提交/脏槽, 0x00=已正式提交生效) */</span>
<span class="p">}</span> <span class="n">RecordSlot64</span><span class="p">;</span>
<span class="cp">#pragma pack(pop)
</span></code></pre></div></div>

<h4 id="两阶段提交核心写入实现">两阶段提交核心写入实现</h4>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* 两阶段安全提交：先写正文与 CRC，校验无误后再单独将 commitFlag 编程为 0x00 */</span>
<span class="n">bool</span> <span class="nf">RecordStore_Write2PC</span><span class="p">(</span><span class="kt">uint32_t</span> <span class="n">flashAddr</span><span class="p">,</span> <span class="n">RecordSlot64</span> <span class="o">*</span><span class="n">slot</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">uint8_t</span> <span class="n">readback</span><span class="p">[</span><span class="mi">64</span><span class="p">];</span>

    <span class="cm">/* 阶段 1：写入偏移 0..62 的正文与 CRC，保持 commitFlag 为 0xFF */</span>
    <span class="n">slot</span><span class="o">-&gt;</span><span class="n">commitFlag</span> <span class="o">=</span> <span class="mh">0xFF</span><span class="p">;</span>
    <span class="n">slot</span><span class="o">-&gt;</span><span class="n">crc16</span> <span class="o">=</span> <span class="n">Crc16_Modbus</span><span class="p">((</span><span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="n">slot</span><span class="p">,</span> <span class="mi">59</span><span class="p">);</span>

    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">Flash_PageProgram</span><span class="p">(</span><span class="n">flashAddr</span><span class="p">,</span> <span class="p">(</span><span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="n">slot</span><span class="p">,</span> <span class="mi">63</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">Flash_WaitBusy</span><span class="p">();</span>

    <span class="cm">/* 读回校验阶段 1 数据是否完整写入 */</span>
    <span class="n">Flash_Read</span><span class="p">(</span><span class="n">flashAddr</span><span class="p">,</span> <span class="n">readback</span><span class="p">,</span> <span class="mi">64</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">memcmp</span><span class="p">(</span><span class="n">readback</span><span class="p">,</span> <span class="n">slot</span><span class="p">,</span> <span class="mi">63</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">0</span> <span class="o">||</span> <span class="n">readback</span><span class="p">[</span><span class="mi">63</span><span class="p">]</span> <span class="o">!=</span> <span class="mh">0xFF</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span> <span class="cm">/* 发生半写或断电，当前槽成为 DIRTY 脏槽，绝不提交 */</span>
    <span class="p">}</span>

    <span class="cm">/* 阶段 2：单独对偏移 63 发起 1 字节编程：0xFF -&gt; 0x00 */</span>
    <span class="kt">uint8_t</span> <span class="n">commitByte</span> <span class="o">=</span> <span class="mh">0x00</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">Flash_PageProgram</span><span class="p">(</span><span class="n">flashAddr</span> <span class="o">+</span> <span class="mi">63</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">commitByte</span><span class="p">,</span> <span class="mi">1</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">Flash_WaitBusy</span><span class="p">();</span>

    <span class="cm">/* 读回确认 commitFlag 是否成功变为 0x00 */</span>
    <span class="n">Flash_Read</span><span class="p">(</span><span class="n">flashAddr</span> <span class="o">+</span> <span class="mi">63</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">commitByte</span><span class="p">,</span> <span class="mi">1</span><span class="p">);</span>
    <span class="k">return</span> <span class="p">(</span><span class="n">commitByte</span> <span class="o">==</span> <span class="mh">0x00</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>启动扫描时，每个 64 字节槽被严格归为三类：</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">ERASED</code>（全 <code class="language-plaintext highlighter-rouge">0xFF</code>）</strong>：干净空槽，仅当前追加扇区尾部的连续 <code class="language-plaintext highlighter-rouge">ERASED</code> 槽可用于分配新记录；</li>
  <li><strong><code class="language-plaintext highlighter-rouge">VALID</code>（<code class="language-plaintext highlighter-rouge">commitFlag == 0x00</code> 且版本、长度、CRC-16 全部正确）</strong>：有效已提交记录；</li>
  <li><strong><code class="language-plaintext highlighter-rouge">DIRTY</code>（任何非全 <code class="language-plaintext highlighter-rouge">0xFF</code> 但未提交或 CRC 错误的槽）</strong>：掉电残槽，扫描时直接跳过、绝不原地覆写，留待整扇区回收时一并擦除。</li>
</ul>

<h3 id="交互式在线演示两阶段提交与掉电故障注入模拟">交互式在线演示：两阶段提交与掉电故障注入模拟</h3>

<p>下面的模拟器按 64 字节槽展示写入、提交、删除和逻辑清空。可以在阶段 1 中途模拟断电，再执行上电扫描，查看哪些槽会被识别为有效记录。它只演示恢复规则，实际 Flash 的时序和掉电行为仍需上板验证。</p>

<div class="article-interactive-widget" id="flash-demo-app" aria-label="NOR Flash 掉电安全存储交互演示">
  <div class="aiw-header">
    <span class="aiw-badge">INTERACTIVE WIDGET · 硬件物理层仿真</span>
    <h3 class="aiw-title">NOR Flash 掉电安全两阶段提交与世代控制环模拟器</h3>
    <p class="aiw-desc">仿真外部 SPI NOR Flash（W25Q128）物理约束：仅能 1→0 编程、64B 两阶段原子提交、原位墓碑生命周期码（0xFFFF ➔ 0x0000）与世代控制环逻辑秒级清空。</p>
  </div>
  <div class="aiw-body">
    <div class="aiw-stats" aria-label="Flash 存储引擎状态">
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">控制环世代 (Generation)</span>
        <span class="aiw-stat-value accent" id="flash-stat-gen">Gen 1</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">有效提交记录</span>
        <span class="aiw-stat-value" id="flash-stat-count">0 条有效记录</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">下一追加写槽位</span>
        <span class="aiw-stat-value warn" id="flash-stat-next">Slot 1</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">Flash 硬件状态</span>
        <span class="aiw-stat-value" id="flash-stat-status">就绪 (IDLE)</span>
      </div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span id="flash-sector-addr">数据扇区 0xFDC000 (4 KiB) 物理槽位视图</span>
        <span class="aiw-hint">点击任意槽位可在下方检视 64 字节二进制结构</span>
      </div>
      <div class="flash-slots-grid" id="flash-slots-grid"></div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span>两阶段写入、紧缩迁移与故障注入操作</span>
        <span class="aiw-hint">演示两阶段原子性、墓碑删除、稀疏扇区紧缩回收与掉电防护</span>
      </div>
      <div class="aiw-controls">
        <button type="button" class="aiw-btn primary" id="flash-btn-write">两阶段正常写入 (2PC Write)</button>
        <button type="button" class="aiw-btn danger" id="flash-btn-fail">故障注入: 阶段1中断电</button>
        <button type="button" class="aiw-btn warn" id="flash-btn-del">原位墓碑单条删除 (1→0)</button>
        <button type="button" class="aiw-btn" id="flash-btn-migrate">紧缩迁移回收 (Sector GC)</button>
        <button type="button" class="aiw-btn" id="flash-btn-clear">世代控制环逻辑清空 (Clear All)</button>
        <button type="button" class="aiw-btn" id="flash-btn-scan">模拟上电扫描 (Boot Scan)</button>
        <button type="button" class="aiw-btn" id="flash-btn-erase">整扇区擦除 4KB</button>
      </div>
    </div>

    <div class="flash-inspector">
      <div class="aiw-section-title">
        <span>当前选定槽 64 字节结构体物理字段剖析</span>
        <span class="aiw-hint">注意观察 commitFlag (偏移63) 与 lifecycle (偏移59..60) 的单向编程变化</span>
      </div>
      <table class="flash-fields-table">
        <thead>
          <tr>
            <th style="width: 130px;">字节偏移与字段</th>
            <th style="width: 140px;">物理存储值</th>
            <th>工程约束与防掉电语义</th>
          </tr>
        </thead>
        <tbody id="flash-inspector-body"></tbody>
      </table>
    </div>

    <div class="aiw-log-panel">
      <div class="aiw-section-title">
        <span>SPI Flash 硬件层事务与验证日志</span>
        <span class="aiw-hint">展示两阶段写入时序与安全校验</span>
      </div>
      <div class="aiw-log" id="flash-log" aria-live="polite"></div>
    </div>
  </div>
</div>

<h2 id="三扇区世代控制环与逻辑清空">三扇区世代控制环与逻辑清空</h2>

<h3 id="为什么清空全部不擦除数据扇区">为什么“清空全部”不擦除数据扇区</h3>

<p>若在用户确认清空时同步擦除全部数据扇区，耗时可达数秒并触发看门狗复位。本架构引入 32 位 <strong><code class="language-plaintext highlighter-rouge">generation</code>（世代号）</strong>：</p>

<ol>
  <li><strong>三扇区控制环轮转</strong>：3 个控制扇区（每个可顺序追加 64 条 64B 控制记录）保持“1 个当前追加 + 1 个全 <code class="language-plaintext highlighter-rouge">0xFF</code> 备用空扇区 + 1 个空或待后台补擦扇区”的稳态。</li>
  <li><strong>确认路径只追加 1 条世代记录</strong>：用户长按 5 秒并确认清空后，仅在控制环尾部按两阶段提交写入 1 条新 <code class="language-plaintext highlighter-rouge">generation</code> 记录（耗时 <code class="language-plaintext highlighter-rouge">&lt; 10 ms</code>）。提交成功的瞬间，RAM 中可见记录数立即归零、<code class="language-plaintext highlighter-rouge">nextRecordId</code> 重置为 <code class="language-plaintext highlighter-rouge">1</code>，旧世代记录因 <code class="language-plaintext highlighter-rouge">record.generation != currentGeneration</code> 自动在逻辑上不可见。</li>
  <li><strong>跨世代共用当前追加扇区</strong>：逻辑清空<strong>不会</strong>封闭当前正在追加的数据扇区。只要该数据扇区尾部仍有 <code class="language-plaintext highlighter-rouge">ERASED</code> 空槽，新世代记录会继续紧接着旧世代记录往后追加，直到写满 63 槽才启用下一个全空扇区。这样不必在每次“保存几条就清空”后立即擦除数据扇区。</li>
  <li><strong>控制环后台擦除</strong>：仅当活动控制扇区写满 64 条切换到备用空扇区后，后台状态机才对已退役的旧控制扇区发起非阻塞 4 KiB 擦除并逐页验证回填为全 <code class="language-plaintext highlighter-rouge">0xFF</code>（约每 64 次清空才触发 1 次控制扇区擦除）。</li>
</ol>

<h2 id="原位生命周期码与单条删除">原位生命周期码与单条删除</h2>

<h3 id="稳定主键与动态显示序号分离">稳定主键与动态显示序号分离</h3>

<p>每条记录拥有不可变的内部主键 <code class="language-plaintext highlighter-rouge">{generation, recordId}</code>，而 UI 列表展示的序号 <code class="language-plaintext highlighter-rouge">1..N</code> 是按当前可见记录顺序实时计算的索引：</p>

<ul>
  <li>删除请求通过 <code class="language-plaintext highlighter-rouge">{generation, recordId}</code> 精确定位目标槽，拒绝跨世代、不存在或已删除目标；</li>
  <li>删除中间某条记录后，其余记录的 <code class="language-plaintext highlighter-rouge">recordId</code> 保持不变，列表显示序号自然顺延为连续的 <code class="language-plaintext highlighter-rouge">1..(N-1)</code>，无需改写其他记录。</li>
</ul>

<h3 id="利用-nor-flash-特性的单向生命周期码">利用 NOR Flash 特性的单向生命周期码</h3>

<p>偏移 <code class="language-plaintext highlighter-rouge">59..60</code> 的 16 位 <code class="language-plaintext highlighter-rouge">lifecycle</code> 字段独立于正文 CRC 之外，利用 NOR Flash 允许将 <code class="language-plaintext highlighter-rouge">1</code> 编程为 <code class="language-plaintext highlighter-rouge">0</code> 的特性实现三次状态跃迁：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>0xFFFF (ACTIVE：正常可见记录)
  │
  ├── 用户单条删除 (1 → 0 页编程) ──► 0xA55A (DELETED：逻辑已删除，不可见、不迁移)
  │
  └── 稀疏迁移复制完成 (1 → 0)   ──► 0x5AA5 (MIGRATED：目标副本已提交，等待源扇区擦除)
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">0xA55A</code> 与 <code class="language-plaintext highlighter-rouge">0x5AA5</code> 均只能从 <code class="language-plaintext highlighter-rouge">0xFFFF</code> 单向写入，互不转换；</li>
  <li>单条删除只需对目标槽偏移 <code class="language-plaintext highlighter-rouge">59..60</code> 执行 2 字节页编程（<code class="language-plaintext highlighter-rouge">0xFFFF → 0xA55A</code>）并读回验证，耗时仅约 <strong>4 ms</strong>；</li>
  <li>一旦 <code class="language-plaintext highlighter-rouge">0xA55A</code> 读回确认成功，逻辑删除立即生效，后续即便稀疏整理因空间或异常延后，已删除记录也绝不会在重启后“复活”。</li>
</ul>

<h4 id="原位单条删除核心实现">原位单条删除核心实现</h4>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* 单条记录原位墓碑删除：利用 NOR Flash 1-&gt;0 特性将 lifecycle 编为 0xA55A */</span>
<span class="n">bool</span> <span class="nf">RecordStore_DeleteInPlace</span><span class="p">(</span><span class="kt">uint32_t</span> <span class="n">slotAddr</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">uint16_t</span> <span class="n">currentLife</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="n">Flash_Read</span><span class="p">(</span><span class="n">slotAddr</span> <span class="o">+</span> <span class="mi">59</span><span class="p">,</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">currentLife</span><span class="p">,</span> <span class="mi">2</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">currentLife</span> <span class="o">!=</span> <span class="mh">0xFFFF</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span> <span class="cm">/* 已被删除或非有效记录 */</span>
    <span class="p">}</span>

    <span class="kt">uint16_t</span> <span class="n">tombstone</span> <span class="o">=</span> <span class="mh">0xA55A</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">Flash_PageProgram</span><span class="p">(</span><span class="n">slotAddr</span> <span class="o">+</span> <span class="mi">59</span><span class="p">,</span> <span class="p">(</span><span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">tombstone</span><span class="p">,</span> <span class="mi">2</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">Flash_WaitBusy</span><span class="p">();</span>

    <span class="n">Flash_Read</span><span class="p">(</span><span class="n">slotAddr</span> <span class="o">+</span> <span class="mi">59</span><span class="p">,</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">currentLife</span><span class="p">,</span> <span class="mi">2</span><span class="p">);</span>
    <span class="k">return</span> <span class="p">(</span><span class="n">currentLife</span> <span class="o">==</span> <span class="mh">0xA55A</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="稀疏扇区非阻塞迁移与掉电续作">稀疏扇区非阻塞迁移与掉电续作</h2>

<h3 id="触发条件与目标扇区选择">触发条件与目标扇区选择</h3>

<p>每次单条删除提交成功后，检查目标记录所在的 4 KiB 数据扇区：</p>

<ul>
  <li>一个数据扇区共有 63 个数据槽，当其中属于当前世代的 <code class="language-plaintext highlighter-rouge">ACTIVE</code> 记录数 <strong><code class="language-plaintext highlighter-rouge">&gt; 21</code> 条</strong>时，不触发整理，直接结束；</li>
  <li>当 <code class="language-plaintext highlighter-rouge">ACTIVE</code> 记录数降至 <strong><code class="language-plaintext highlighter-rouge">≤ 21</code> 条（即逻辑空闲达到 2/3）</strong> 时，触发稀疏扇区非阻塞整理：
    <ol>
      <li><strong>优先复用当前追加扇区（<code class="language-plaintext highlighter-rouge">fit</code> 路径）</strong>：若源扇区不是当前追加扇区，且当前追加扇区尾部剩余连续 <code class="language-plaintext highlighter-rouge">ERASED</code> 空槽数 <code class="language-plaintext highlighter-rouge">&gt;= 源扇区 ACTIVE 记录数</code>，直接将有效记录迁移到当前追加扇区尾部；</li>
      <li><strong>按需启用全空扇区（<code class="language-plaintext highlighter-rouge">new</code> 路径）</strong>：若当前追加扇区尾部空间不足，或源扇区本身就是当前追加扇区，则从数据池中挑选 1 个经校验全为 <code class="language-plaintext highlighter-rouge">0xFF</code> 的 <code class="language-plaintext highlighter-rouge">EMPTY</code> 扇区（启用后仍必须至少保留 2 个 <code class="language-plaintext highlighter-rouge">EMPTY</code> 扇区），先两阶段提交新扇区头，再将其作为迁移目标。</li>
    </ol>
  </li>
</ul>

<h3 id="掉电安全的五步迁移协议">掉电安全的五步迁移协议</h3>

<p>为防止迁移中途断电导致记录丢失或出现双份重复记录，控制环引入 <code class="language-plaintext highlighter-rouge">MIGRATE_BEGIN</code> 与 <code class="language-plaintext highlighter-rouge">MIGRATE_COMMIT</code> 事务日志，按以下状态机分步推进：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 写 MIGRATE_BEGIN 控制记录（两阶段提交）
   └─ 持久化冻结 {事务序号, 源扇区索引/分配序号, 目标扇区索引/分配序号}

2. 逐槽扫描源扇区，对每条当前世代 ACTIVE 记录执行：
   ├─ (a) 在目标扇区尾部两阶段提交完整副本（保留原 generation 与 recordId）
   └─ (b) 目标副本读回确认后，将源记录 lifecycle 从 0xFFFF 编程为 0x5AA5 (MIGRATED)

3. 源扇区全部 ACTIVE 记录复制并标记完毕后：
   └─ 写 MIGRATE_COMMIT 控制记录（两阶段提交），宣告数据所有权正式移交目标扇区

4. 对源扇区发起非阻塞 4 KiB 擦除 (SectorEraseStart + PollOperation)

5. 按 256B 页逐页读回校验源扇区 4,096 字节全为 0xFF：
   └─ 校验通过后将源扇区重新登记为 EMPTY 空扇区，释放迁移事务锁
</code></pre></div></div>

<h3 id="四个持久化边界断电复位自愈规则">四个持久化边界断电/复位自愈规则</h3>

<table>
  <thead>
    <tr>
      <th>断电/复位发生边界</th>
      <th>上电扫描与自动续作行为</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>边界 1：<code class="language-plaintext highlighter-rouge">MIGRATE_BEGIN</code> 提交后，尚未复制完</strong></td>
      <td>识别未完成的 <code class="language-plaintext highlighter-rouge">MIGRATE_BEGIN</code>，逐槽比对源与目标，从未完成的那条记录继续复制</td>
    </tr>
    <tr>
      <td><strong>边界 2：目标副本已提交，源记录尚未标为 <code class="language-plaintext highlighter-rouge">0x5AA5</code></strong></td>
      <td>允许该迁移事务指定的源、目标扇区间存在一对正文完全一致的相同 <code class="language-plaintext highlighter-rouge">{generation, recordId}</code>，以目标副本为准并补标源记录为 <code class="language-plaintext highlighter-rouge">0x5AA5</code></td>
    </tr>
    <tr>
      <td><strong>边界 3：源记录已全部标为 <code class="language-plaintext highlighter-rouge">0x5AA5</code>，<code class="language-plaintext highlighter-rouge">MIGRATE_COMMIT</code> 未提交</strong></td>
      <td>核对目标副本数量与身份无误后，补写 <code class="language-plaintext highlighter-rouge">MIGRATE_COMMIT</code> 并继续擦除源扇区</td>
    </tr>
    <tr>
      <td><strong>边界 4：<code class="language-plaintext highlighter-rouge">MIGRATE_COMMIT</code> 已提交，源扇区尚未擦除或擦除中断</strong></td>
      <td>目标副本已生效；忽略源扇区残留内容，直接重新对源扇区执行 4 KiB 擦除与全 <code class="language-plaintext highlighter-rouge">0xFF</code> 逐页校验</td>
    </tr>
  </tbody>
</table>

<h4 id="上电扫描分类与容灾状态机">上电扫描分类与容灾状态机</h4>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">enum</span> <span class="p">{</span>
    <span class="n">SLOT_STATE_ERASED</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span> <span class="cm">/* 全 0xFF 空槽，可用于追加新记录 */</span>
    <span class="n">SLOT_STATE_VALID</span><span class="p">,</span>      <span class="cm">/* 正常提交的有效记录 */</span>
    <span class="n">SLOT_STATE_DELETED</span><span class="p">,</span>    <span class="cm">/* 逻辑墓碑已删除记录 (0xA55A / 0x5AA5) */</span>
    <span class="n">SLOT_STATE_DIRTY</span>       <span class="cm">/* 校验失败或掉电半写脏槽，直接跳过并在整扇区回收时擦除 */</span>
<span class="p">}</span> <span class="n">SlotState</span><span class="p">;</span>

<span class="n">SlotState</span> <span class="nf">RecordStore_ScanSlot</span><span class="p">(</span><span class="kt">uint32_t</span> <span class="n">slotAddr</span><span class="p">,</span> <span class="n">RecordSlot64</span> <span class="o">*</span><span class="n">outSlot</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">RecordSlot64</span> <span class="n">slot</span><span class="p">;</span>
    <span class="n">Flash_Read</span><span class="p">(</span><span class="n">slotAddr</span><span class="p">,</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">slot</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">RecordSlot64</span><span class="p">));</span>

    <span class="cm">/* 1. 检查是否为全 0xFF 干净擦除槽 */</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">IsAllErased</span><span class="p">((</span><span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">slot</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">RecordSlot64</span><span class="p">)))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">SLOT_STATE_ERASED</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="cm">/* 2. 检查两阶段提交标志 */</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">slot</span><span class="p">.</span><span class="n">commitFlag</span> <span class="o">!=</span> <span class="mh">0x00</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">SLOT_STATE_DIRTY</span><span class="p">;</span> <span class="cm">/* 断电半写脏槽，直接跳过 */</span>
    <span class="p">}</span>

    <span class="cm">/* 3. 校验魔数、版本与正文 CRC-16 */</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">slot</span><span class="p">.</span><span class="n">magic</span> <span class="o">!=</span> <span class="mh">0x52454344</span> <span class="o">||</span> <span class="n">slot</span><span class="p">.</span><span class="n">recordLen</span> <span class="o">!=</span> <span class="mi">64</span> <span class="o">||</span>
        <span class="n">slot</span><span class="p">.</span><span class="n">crc16</span> <span class="o">!=</span> <span class="n">Crc16_Modbus</span><span class="p">((</span><span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">slot</span><span class="p">,</span> <span class="mi">59</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">SLOT_STATE_DIRTY</span><span class="p">;</span> <span class="cm">/* CRC 损坏，判定为无效脏槽 */</span>
    <span class="p">}</span>

    <span class="cm">/* 4. 检查生命周期码 */</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">slot</span><span class="p">.</span><span class="n">lifecycle</span> <span class="o">==</span> <span class="mh">0xA55A</span> <span class="o">||</span> <span class="n">slot</span><span class="p">.</span><span class="n">lifecycle</span> <span class="o">==</span> <span class="mh">0x5AA5</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">SLOT_STATE_DELETED</span><span class="p">;</span> <span class="cm">/* 已被原位删除或已迁移 */</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">outSlot</span> <span class="o">!=</span> <span class="nb">NULL</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="o">*</span><span class="n">outSlot</span> <span class="o">=</span> <span class="n">slot</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">SLOT_STATE_VALID</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="基于-j-link-rtt-的板级故障注入与-hil-验收">基于 J-Link RTT 的板级故障注入与 HIL 验收</h2>

<p>为在不破坏量产固件安全性的前提下验证上述状态机，工程通过默认关闭的编译开关 <code class="language-plaintext highlighter-rouge">APP_RECORD_STORE_VALIDATION_ENABLE</code> 隔离测试状态机，在 J-Link RTT Channel 0 上提供非阻塞验证指令集。</p>

<h3 id="核心-rtt-验证指令与注入点">核心 RTT 验证指令与注入点</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>record_status                      # 查看记录服务只读状态 (generation/count/next/empty)
record_test codec                  # 运行 64B 槽固定向量与异常分支纯 RAM 自检
record_test cleanup                # 擦除并逐页校验 39 个扇区 (0xFD9000..0xFFFFFF)
record_test append &lt;N&gt;             # 非阻塞批量追加 N 条测试记录
record_test delete_pos first|middle|last  # 删除首/中/末条可见记录并自动验证重复删除拒绝
record_test full_probe             # 满容量时探测第 1001 条返回 FULL 且介质零改动
record_test list_check             # 正序/倒序遍历全部页面，校验连续显示序号 1..N
record_test migrate_probe fit|new  # 自动触发 22→21 条稀疏扇区迁移（复用追加扇区 / 启用新扇区）
record_test inject migrate_begin|migrate_copy|migrate_mark|migrate_commit # 4 个持久化边界复位注入
record_test inject erase_start|erase_timeout|erase_verify                 # 3 种擦除故障边界注入
</code></pre></div></div>

<h3 id="实机目标板n32g455--w25q128验证结果汇总">实机目标板（N32G455 + W25Q128）验证结果汇总</h3>

<p>在基于 Cortex-M4 与 SPI NOR Flash 的实机硬件测试板上，执行全套 RTT 故障注入与压力验证，关键实测数据如下：</p>

<table>
  <thead>
    <tr>
      <th>验证场景</th>
      <th>实测表现与介质复查结论</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>39 扇区清理与自动建代</strong></td>
      <td>39 个 4 KiB 扇区擦除并逐页校验 <code class="language-plaintext highlighter-rouge">0xFF</code> 耗时约 <code class="language-plaintext highlighter-rouge">6.1 s</code>，复位后自动建立 <code class="language-plaintext highlighter-rouge">generation=1</code>、<code class="language-plaintext highlighter-rouge">count=0</code>、<code class="language-plaintext highlighter-rouge">data_empty=36</code></td>
    </tr>
    <tr>
      <td><strong>1,000 条满容量追加与满盘拦截</strong></td>
      <td>批量两阶段提交 1,000 条耗时 <code class="language-plaintext highlighter-rouge">17.92 s</code>（平均 <code class="language-plaintext highlighter-rouge">17.9 ms/条</code>，单次页编程峰值 <code class="language-plaintext highlighter-rouge">4 ms</code>），启用数据扇区 <code class="language-plaintext highlighter-rouge">0..15</code>；随后 <code class="language-plaintext highlighter-rouge">full_probe</code> 同步返回 <code class="language-plaintext highlighter-rouge">FULL</code>，介质零改动</td>
    </tr>
    <tr>
      <td><strong>删除首/中/末条与补存回归</strong></td>
      <td>删除第 500 条后 <code class="language-plaintext highlighter-rouge">count=999</code>，正反序 200 页（共 400 页）连续序号检查通过；再补存 1 条分配 <code class="language-plaintext highlighter-rouge">recordId=1001</code>（不复用已删 ID 500），复位后状态完全一致</td>
    </tr>
    <tr>
      <td><strong>稀疏迁移阈值（22 条 vs 21 条）</strong></td>
      <td>追加 84 条后依次删除 ID <code class="language-plaintext highlighter-rouge">1..41</code>（源扇区剩 22 条）不触发迁移；删除第 42 条（剩 21 条）立即启动迁移，无论 <code class="language-plaintext highlighter-rouge">fit</code> 还是 <code class="language-plaintext highlighter-rouge">new</code> 路径均完整迁移 21 条并擦除回收源扇区</td>
    </tr>
    <tr>
      <td><strong>4 个持久化边界复位注入</strong></td>
      <td><code class="language-plaintext highlighter-rouge">migrate_begin</code>、<code class="language-plaintext highlighter-rouge">migrate_copy</code>、<code class="language-plaintext highlighter-rouge">migrate_mark</code>、<code class="language-plaintext highlighter-rouge">migrate_commit</code> 触发软件复位后，启动扫描均准确识别未完成事务并自动续作完成，最终 <code class="language-plaintext highlighter-rouge">count=42</code>、<code class="language-plaintext highlighter-rouge">next=85</code>、<code class="language-plaintext highlighter-rouge">corrupt=0</code>，无记录丢失或重复</td>
    </tr>
    <tr>
      <td><strong>3 种擦除故障注入</strong></td>
      <td><code class="language-plaintext highlighter-rouge">erase_start</code>、<code class="language-plaintext highlighter-rouge">erase_timeout</code>、<code class="language-plaintext highlighter-rouge">erase_verify</code> 注入后准确报告错误且拒绝将未验证扇区标空；状态机重扫后自动恢复为 <code class="language-plaintext highlighter-rouge">ready</code></td>
    </tr>
    <tr>
      <td><strong>硬实时并发与资源水位</strong></td>
      <td>全程无 IWDG 复位（<code class="language-plaintext highlighter-rouge">iwdg=0</code>）；AD7190 维持 <code class="language-plaintext highlighter-rouge">2,414～2,420 Hz</code> 连续采样（<code class="language-plaintext highlighter-rouge">dma_err=0, overrun=0</code>）；主循环最大单圈耗时约 <code class="language-plaintext highlighter-rouge">6～7 ms</code>，主栈涂漆（<code class="language-plaintext highlighter-rouge">0xA5A5A5A5</code>）最低剩余水位 <strong>584 B</strong></td>
    </tr>
  </tbody>
</table>

<blockquote>
  <p>验证收尾规范：全部破坏性测试结束后，再次执行 <code class="language-plaintext highlighter-rouge">record_test cleanup</code> 将 39 个扇区恢复为空白世代 1，随后将 <code class="language-plaintext highlighter-rouge">APP_RECORD_STORE_VALIDATION_ENABLE</code> 恢复为 <code class="language-plaintext highlighter-rouge">0</code> 重新编译量产固件，确保生产镜像中输入 <code class="language-plaintext highlighter-rouge">record_test</code> 仅返回 <code class="language-plaintext highlighter-rouge">no support now</code>。</p>
</blockquote>]]></content><author><name></name></author><category term="嵌入式" /><category term="NOR Flash" /><category term="W25Q128" /><category term="掉电安全" /><category term="RTT" /><category term="N32G455" /><summary type="html"><![CDATA[基于 W25Q128 SPI NOR Flash 的裸机掉电安全定长记录存储架构，详解 64B 槽两阶段提交、三扇区世代控制环、原位生命周期码单条删除、稀疏扇区非阻塞迁移与 J-Link RTT 故障注入验证。]]></summary></entry><entry><title type="html">N32G455 LCD 条带渲染与异步刷新实现分析</title><link href="https://single5240.github.io/articles/n32g455-lcd-strip-rendering.html" rel="alternate" type="text/html" title="N32G455 LCD 条带渲染与异步刷新实现分析" /><published>2026-10-15T00:00:00+08:00</published><updated>2026-10-15T00:00:00+08:00</updated><id>https://single5240.github.io/articles/n32g455-lcd-strip-rendering</id><content type="html" xml:base="https://single5240.github.io/articles/n32g455-lcd-strip-rendering.html"><![CDATA[<p>在 SRAM 极度受限的嵌入式系统（如仅 144 KB RAM）中驱动 240×320 彩屏时，整屏显存高达 153.6 KB，无法开辟整帧缓冲区。本文系统解析用一块 8 行缓冲区分批刷新 LCD 的架构链路，深入剖析条带画布裁剪、不可变帧快照防撕裂与 SPI DMA 异步收尾机制。控制器底层命令可参考 <a href="/articles/st7789-command-and-pixel-dma.html">ST7789 命令时序、窗口配置与像素 DMA 传输参考</a>，多外设 SPI 与 DMA 调度参考见 <a href="/articles/n32g455-spi-dma-guide.html">N32G455 SPI 与 DMA 配置参考</a>。</p>

<h2 id="阅读目标与相关文件">阅读目标与相关文件</h2>

<p>阅读时可以对照这几个问题：</p>

<ul>
  <li>UI 数据、页面状态和 LCD 驱动之间如何分层；</li>
  <li>为什么没有使用整屏帧缓冲，而是使用 8 行高的渲染条带；</li>
  <li>RGB565 像素如何通过全屏坐标裁剪写入条带缓冲；</li>
  <li>SPI1 为什么同时启用 TX DMA 和 RX DMA；</li>
  <li>DMA 完成为什么不等于 SPI 已经发送完毕；</li>
  <li>中断、主循环和 DMA 如何协同，又如何避免缓冲区被提前改写；</li>
  <li>屏幕方向切换、背光、超时恢复分别在什么时机执行；</li>
  <li>当前实现的性能、边界和仍需硬件确认的事项。</li>
</ul>

<p>核心显示链路分为四个解耦的子系统：</p>

<table>
  <thead>
    <tr>
      <th>子系统模块</th>
      <th>核心职责</th>
      <th>依赖与设计约束</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">StripCanvas</code></td>
      <td>240×8 局部条带像素缓冲（3,840 字节）、全屏到局部坐标动态裁剪</td>
      <td>零整屏显存分配，仅管理当前 8 行 RGB565 像素</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">FrameSnapshot</code></td>
      <td>逻辑帧不可变快照抓取、重绘请求合并（脏标记）</td>
      <td>解决 40 次主循环分批渲染期间数据跳变撕裂</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">LcdDriver</code></td>
      <td>窗口命令下发、SPI 全双工 DMA 异步传输与三态状态机</td>
      <td>极简中断服务程序，主循环等待 SPI BUSY 清零</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PageRenderer</code></td>
      <td>业务页面布局、1-bit 字库光栅化与基础图元绘制</td>
      <td>绘制逻辑使用全屏坐标，由 Canvas 自动裁剪</td>
    </tr>
  </tbody>
</table>

<h2 id="整体显示分层模型">整体显示分层模型</h2>

<p>显示链路自顶向下分为 6 个阶段：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>传感器与设置数据
  → AppRuntimeData（运行数据快照）
  → AppUiContext（页面状态和待显示值）
  → AppUiRenderer（一帧不可变快照）
  → AppUiCanvas（240 × 8 RGB565 条带缓冲）
  → AppUiDisplay（显示适配层）
  → LcdSt7789（窗口命令与 SPI1 DMA）
  → LCD GRAM（屏内显存）
</code></pre></div></div>

<p>每层只负责一类职责：</p>

<table>
  <thead>
    <tr>
      <th>层</th>
      <th>解决的问题</th>
      <th>不应该负责的事情</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AppRuntimeData</code></td>
      <td>收集力值、电量、方向</td>
      <td>不画像素，不操作 SPI</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AppUiContext</code></td>
      <td>当前页面、选中项、显示文本、UI 事件</td>
      <td>不感知 DMA 通道</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AppUiRenderer</code></td>
      <td>固定一整帧要使用的数据快照</td>
      <td>不直接控制 LCD 引脚</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AppUiCanvas</code></td>
      <td>保存当前 8 行的 RGB565 像素</td>
      <td>不保存完整 320 行画面</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">AppUiDisplay</code></td>
      <td>隔离 UI 与 ST7789 具体实现</td>
      <td>不实现页面布局</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">LcdSt7789</code></td>
      <td>配置 LCD 窗口，启动 SPI/DMA，处理完成与超时</td>
      <td>不理解“力值”“菜单”等业务含义</td>
    </tr>
  </tbody>
</table>

<p>最核心的设计边界是：<strong>LCD 驱动模块不保存整屏画面，它只把 UI 交给它的一块像素内存传送到屏幕。</strong> 页面怎么画、画什么内容，完全由 UI 层决定。</p>

<h2 id="硬件连接与信号含义">硬件连接与信号含义</h2>

<p>在典型的 4 线 SPI 彩屏接口中，包含以下硬件引脚定义：</p>

<table>
  <thead>
    <tr>
      <th>MCU 引脚</th>
      <th>驱动名称</th>
      <th>作用</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PA5</td>
      <td><code class="language-plaintext highlighter-rouge">LCD_SCL</code></td>
      <td>SPI1 SCK，串行时钟</td>
    </tr>
    <tr>
      <td>PA7</td>
      <td><code class="language-plaintext highlighter-rouge">LCD_SDA</code></td>
      <td>SPI1 MOSI，MCU 向屏幕发送命令和像素</td>
    </tr>
    <tr>
      <td>PA6</td>
      <td><code class="language-plaintext highlighter-rouge">LCD_DOUT</code></td>
      <td>SPI1 MISO，屏幕向 MCU 返回数据；初始化时读取 ID</td>
    </tr>
    <tr>
      <td>PC4</td>
      <td><code class="language-plaintext highlighter-rouge">LCD_RS</code></td>
      <td>命令/数据选择（D/C）：<code class="language-plaintext highlighter-rouge">0</code>=控制命令，<code class="language-plaintext highlighter-rouge">1</code>=参数或像素数据</td>
    </tr>
    <tr>
      <td>PC5</td>
      <td><code class="language-plaintext highlighter-rouge">LCD_CS</code></td>
      <td>屏幕片选，低电平有效：<code class="language-plaintext highlighter-rouge">0</code>=选中屏幕，<code class="language-plaintext highlighter-rouge">1</code>=事务结束</td>
    </tr>
    <tr>
      <td>PB0</td>
      <td><code class="language-plaintext highlighter-rouge">LCD_RST</code></td>
      <td>屏幕硬件复位，低电平有效</td>
    </tr>
    <tr>
      <td>PC2</td>
      <td><code class="language-plaintext highlighter-rouge">LCD_BL</code></td>
      <td>背光控制，当前板级定义为高电平点亮</td>
    </tr>
  </tbody>
</table>

<p>代码按 ST7789、SPI Mode 0、32 MHz 工作，这属于<strong>当前软件配置与现有实现</strong>，移植或更换屏幕批次时仍须结合屏幕规格书与逻辑分析仪实测确认。</p>

<h2 id="spi1-时钟与模式配置">SPI1 时钟与模式配置</h2>

<p>LCD 初始化通过 <code class="language-plaintext highlighter-rouge">LcdSt7789_ConfigGpio()</code> 完成 GPIO、SPI1、DMA1 时钟和 DMA 中断配置。</p>

<h3 id="时钟来源">时钟来源</h3>

<p>系统时钟与 SPI1 分频关系如下：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>SYSCLK = 128 MHz
HCLK   = 128 MHz
PCLK2  =  64 MHz（HCLK / 2）
SPI1   = PCLK2 / 2 = 32 MHz
</code></pre></div></div>

<h3 id="spi1-工作模式">SPI1 工作模式</h3>

<table>
  <thead>
    <tr>
      <th>配置项</th>
      <th>当前值</th>
      <th>含义</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>主从模式</td>
      <td>Master</td>
      <td>MCU 产生 SCK</td>
    </tr>
    <tr>
      <td>总线方向</td>
      <td>双线全双工</td>
      <td>MOSI 发送的同时，MISO 也会移入一个值</td>
    </tr>
    <tr>
      <td>初始数据宽度</td>
      <td>8 bit</td>
      <td>适合发送 LCD 命令和单字节参数</td>
    </tr>
    <tr>
      <td>CPOL / CPHA</td>
      <td>Low / First edge</td>
      <td>SPI Mode 0，空闲时 SCK 为低，第一边沿采样</td>
    </tr>
    <tr>
      <td>NSS</td>
      <td>Software</td>
      <td>CS 不交给 SPI 硬件，由 PC5 手动控制</td>
    </tr>
    <tr>
      <td>位序</td>
      <td>MSB first</td>
      <td>高位先发送</td>
    </tr>
    <tr>
      <td>波特率</td>
      <td>PCLK2 / 2</td>
      <td>当前 32 MHz</td>
    </tr>
  </tbody>
</table>

<p>手动控制 CS 和 RS 的原因是 LCD 协议不只是连续字节流。在一次刷新前，软件要依次完成“发命令、发参数、切换为像素数据、保持 CS 有效”等步骤。</p>

<h3 id="初始化-gpio-时先写安全电平">初始化 GPIO 时先写安全电平</h3>

<p><code class="language-plaintext highlighter-rouge">drv_lcd_st7789.c</code> 先写输出数据寄存器，再把引脚切成推挽输出模式：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CS</code> 先置高，避免误选中屏幕；</li>
  <li><code class="language-plaintext highlighter-rouge">RS</code> 先置高，保持普通数据态；</li>
  <li><code class="language-plaintext highlighter-rouge">RST</code> 先置低，屏幕保持复位；</li>
  <li><code class="language-plaintext highlighter-rouge">BL</code> 先关闭，避免屏幕显存未初始化时闪出随机内容；</li>
  <li><code class="language-plaintext highlighter-rouge">SCK</code> 先置低，匹配 SPI Mode 0 空闲电平。</li>
</ul>

<p>这样可以避免 GPIO 模式切换瞬间产生肉眼可见的背光闪烁或被屏幕误识别为命令的毛刺。</p>

<h2 id="lcd-上电初始化与背光时机">LCD 上电初始化与背光时机</h2>

<p><code class="language-plaintext highlighter-rouge">LcdSt7789_Init()</code> 的执行顺序如下：</p>

<ol>
  <li>配置 GPIO、SPI1、DMA1 和 NVIC；</li>
  <li>关闭背光（PC2 置低）；</li>
  <li><code class="language-plaintext highlighter-rouge">RST</code> 拉低 20 ms，再拉高并等待 120 ms；</li>
  <li>发送 <code class="language-plaintext highlighter-rouge">SWRESET (0x01)</code> 软件复位，等待 150 ms；</li>
  <li>发送 <code class="language-plaintext highlighter-rouge">SLPOUT (0x11)</code> 退出睡眠，等待 120 ms；</li>
  <li>发送 <code class="language-plaintext highlighter-rouge">RDDID (0x04)</code>，经过一个 dummy byte 后读取 3 个 ID 字节；</li>
  <li>发送 <code class="language-plaintext highlighter-rouge">COLMOD (0x3A) = 0x55</code>，选择 16 bit/pixel 的 RGB565 像素格式；</li>
  <li>发送 <code class="language-plaintext highlighter-rouge">MADCTL (0x36) = 0x00</code>，选择默认正向扫描；</li>
  <li>依次发送 <code class="language-plaintext highlighter-rouge">INVON (0x21)</code>、<code class="language-plaintext highlighter-rouge">NORON (0x13)</code> 和 <code class="language-plaintext highlighter-rouge">DISPON (0x29)</code>；</li>
  <li>等待 UI 把第一整帧（40 个条带）画完后再开启背光。</li>
</ol>

<p>需要注意两点：</p>

<ol>
  <li>当前 <code class="language-plaintext highlighter-rouge">RDDID</code> 只判断 SPI 传输是否超时，未将读回的 3 字节与固定 ID 强校验。因此 <code class="language-plaintext highlighter-rouge">LcdSt7789_Init()</code> 返回 <code class="language-plaintext highlighter-rouge">true</code> 代表“ID 读取事务完成”。</li>
  <li>初始化期间 LCD GRAM 的内容不保证是有效页面。如果复位后立即开背光，用户可能看到随机花屏。因此固件坚持在 UI 完成首帧全部 40 个条带（<code class="language-plaintext highlighter-rouge">nextY &gt;= 320</code>）后才调用 <code class="language-plaintext highlighter-rouge">AppUiDisplay_BacklightOn()</code> 打开背光（100% 档位为持续高电平，40%～88% 档位为 TIM4 软件 PWM）。</li>
</ol>

<h2 id="rgb565-格式与-lcd-地址窗口">RGB565 格式与 LCD 地址窗口</h2>

<h3 id="rgb565-16-位像素表示">RGB565 16 位像素表示</h3>

<p><code class="language-plaintext highlighter-rouge">COLMOD = 0x55</code> 将像素格式配置为 RGB565：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Bit:  15          11 10               5 4            0
     +--------------+------------------+--------------+
     | Red: 5 bits  | Green: 6 bits    | Blue: 5 bits |
     +--------------+------------------+--------------+
</code></pre></div></div>

<p>常见纯色值：红色 <code class="language-plaintext highlighter-rouge">0xF800</code>、绿色 <code class="language-plaintext highlighter-rouge">0x07E0</code>、蓝色 <code class="language-plaintext highlighter-rouge">0x001F</code>、白色 <code class="language-plaintext highlighter-rouge">0xFFFF</code>、黑色 <code class="language-plaintext highlighter-rouge">0x0000</code>。</p>

<p>UI 的像素缓冲类型是 <code class="language-plaintext highlighter-rouge">uint16_t</code>。发送像素前，SPI1 从 8 位模式切换到 16 位模式并保持 MSB first，每个半字按高位到低位发出，正好对应 RGB565 的线上顺序；像素传输完成后，驱动再把 SPI1 恢复为 8 位，以便发送下一组 LCD 命令。</p>

<h3 id="矩形窗口设置">矩形窗口设置</h3>

<p>屏幕内部有独立 GRAM。MCU 在发送像素之前，先用 <code class="language-plaintext highlighter-rouge">LcdSt7789_SetWindow()</code> 设定目标矩形窗口：</p>

<ol>
  <li><code class="language-plaintext highlighter-rouge">CASET (0x2A)</code>：设置起始列 <code class="language-plaintext highlighter-rouge">x0</code> 和结束列 <code class="language-plaintext highlighter-rouge">x1</code>；</li>
  <li><code class="language-plaintext highlighter-rouge">RASET (0x2B)</code>：设置起始行 <code class="language-plaintext highlighter-rouge">y0</code> 和结束行 <code class="language-plaintext highlighter-rouge">y1</code>；</li>
  <li><code class="language-plaintext highlighter-rouge">RAMWR (0x2C)</code>：通知屏幕后续数据是 GRAM 像素；</li>
  <li><code class="language-plaintext highlighter-rouge">RS</code> 拉高、<code class="language-plaintext highlighter-rouge">CS</code> 拉低；</li>
  <li>连续发送 <code class="language-plaintext highlighter-rouge">width × height</code> 个 RGB565 像素。</li>
</ol>

<p>结束坐标包含端点，计算关系为 <code class="language-plaintext highlighter-rouge">x1 = x + width - 1</code>、<code class="language-plaintext highlighter-rouge">y1 = y + height - 1</code>。条带缓冲按行优先排列，LCD 内部地址自动递增，将线性像素流依次填入矩形窗口。</p>

<h2 id="渲染条带strip-rendering与帧快照机制">渲染条带（Strip Rendering）与帧快照机制</h2>

<h3 id="为什么用-8-行条带代替整屏缓冲">为什么用 8 行条带代替整屏缓冲</h3>

<p>240 × 320 分辨率下，每像素 2 字节，完整 RGB565 帧缓冲需要：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>240 × 320 × 2 = 153,600 bytes (150 KiB)
</code></pre></div></div>

<p>这已超过 N32G455REL7 的 144 KiB 总 SRAM。因此固件只分配 8 行高度的条带缓冲：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#define UI_RENDER_STRIP_ROWS (8U)
</span><span class="kt">uint16_t</span> <span class="n">pixels</span><span class="p">[</span><span class="mi">240</span> <span class="o">*</span> <span class="mi">8</span><span class="p">];</span>
</code></pre></div></div>

<p>条带缓冲大小仅为 <code class="language-plaintext highlighter-rouge">240 × 8 × 2 = 3,840 bytes</code>，是整屏缓冲的 1/40；完整一帧被拆成 <code class="language-plaintext highlighter-rouge">320 / 8 = 40</code> 个条带，用分次绘制和 DMA 调度换取极低的 RAM 占用。</p>

<h3 id="单条带生成流程与坐标裁剪">单条带生成流程与坐标裁剪</h3>

<p><code class="language-plaintext highlighter-rouge">AppUi_RenderProcess()</code> 在每轮主循环中每次只推进一条带：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>if LCD still busy:
    return

if a new frame starts:
    capture an immutable frame snapshot

if all 320 rows are complete:
    end frame and turn on backlight
    return

prepare canvas for rows nextY .. nextY + 7
draw current page, clipped to those rows
start asynchronous DMA for that strip
nextY += stripHeight
</code></pre></div></div>

<p>为什么调用完整页面绘制函数却只改写 8 行？关键在 <code class="language-plaintext highlighter-rouge">AppUiCanvas_FillRect()</code> 和 <code class="language-plaintext highlighter-rouge">AppUi_DrawGlyphBits()</code> 的裁剪逻辑：</p>

<ul>
  <li>页面绘制代码继续使用 <code class="language-plaintext highlighter-rouge">(0..239, 0..319)</code> 的全屏坐标；</li>
  <li>基础绘图原语先判断图形矩形与当前条带 <code class="language-plaintext highlighter-rouge">[appUiCanvas.y, appUiCanvas.y + 7]</code> 是否相交；</li>
  <li>不相交则立即跳过；相交时把全局纵坐标转换为条带内偏移 <code class="language-plaintext highlighter-rouge">drawY - appUiCanvas.y</code>，只改写条带缓冲中的第 <code class="language-plaintext highlighter-rouge">0..7</code> 行。</li>
</ul>

<p>这种“用 CPU 换 RAM”的架构使业务页面无需为条带编写复杂的分段布局代码，代价是同一页面的布局函数在一帧内会执行 40 次。</p>

<h4 id="条带画布上下文与全屏矩形裁剪实现">条带画布上下文与全屏矩形裁剪实现</h4>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="kt">uint16_t</span> <span class="n">stripY</span><span class="p">;</span>          <span class="cm">/* 当前条带在全屏中的起始 Y 坐标 (0..312, 步进 8) */</span>
    <span class="kt">uint16_t</span> <span class="n">stripHeight</span><span class="p">;</span>     <span class="cm">/* 当前条带高度 (固定为 8) */</span>
    <span class="kt">uint16_t</span> <span class="n">pixels</span><span class="p">[</span><span class="mi">240</span> <span class="o">*</span> <span class="mi">8</span><span class="p">];</span> <span class="cm">/* 8 行 RGB565 像素缓冲 (3,840 字节) */</span>
<span class="p">}</span> <span class="n">StripCanvas</span><span class="p">;</span>

<span class="k">static</span> <span class="n">StripCanvas</span> <span class="n">s_canvas</span><span class="p">;</span>

<span class="cm">/* 全屏矩形填充在当前条带上的动态裁剪算法 */</span>
<span class="kt">void</span> <span class="nf">Canvas_FillRect</span><span class="p">(</span><span class="kt">int16_t</span> <span class="n">x</span><span class="p">,</span> <span class="kt">int16_t</span> <span class="n">y</span><span class="p">,</span> <span class="kt">int16_t</span> <span class="n">w</span><span class="p">,</span> <span class="kt">int16_t</span> <span class="n">h</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">color</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">int16_t</span> <span class="n">stripTop</span> <span class="o">=</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)</span><span class="n">s_canvas</span><span class="p">.</span><span class="n">stripY</span><span class="p">;</span>
    <span class="kt">int16_t</span> <span class="n">stripBottom</span> <span class="o">=</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)(</span><span class="n">stripTop</span> <span class="o">+</span> <span class="n">s_canvas</span><span class="p">.</span><span class="n">stripHeight</span><span class="p">);</span>

    <span class="cm">/* 1. 垂直方向快速剔除：若目标矩形完全在当前条带上方或下方，直接跳过 */</span>
    <span class="k">if</span> <span class="p">((</span><span class="n">y</span> <span class="o">+</span> <span class="n">h</span> <span class="o">&lt;=</span> <span class="n">stripTop</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">y</span> <span class="o">&gt;=</span> <span class="n">stripBottom</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="cm">/* 2. 计算在条带局部的有效扫描行范围 [localY0, localY1) */</span>
    <span class="kt">int16_t</span> <span class="n">localY0</span> <span class="o">=</span> <span class="p">(</span><span class="n">y</span> <span class="o">&lt;</span> <span class="n">stripTop</span><span class="p">)</span> <span class="o">?</span> <span class="mi">0</span> <span class="o">:</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)(</span><span class="n">y</span> <span class="o">-</span> <span class="n">stripTop</span><span class="p">);</span>
    <span class="kt">int16_t</span> <span class="n">localY1</span> <span class="o">=</span> <span class="p">(</span><span class="n">y</span> <span class="o">+</span> <span class="n">h</span> <span class="o">&gt;</span> <span class="n">stripBottom</span><span class="p">)</span> <span class="o">?</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)</span><span class="n">s_canvas</span><span class="p">.</span><span class="n">stripHeight</span> <span class="o">:</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)(</span><span class="n">y</span> <span class="o">+</span> <span class="n">h</span> <span class="o">-</span> <span class="n">stripTop</span><span class="p">);</span>

    <span class="cm">/* 3. 水平方向边界夹紧 [clipX0, clipX1) */</span>
    <span class="kt">int16_t</span> <span class="n">clipX0</span> <span class="o">=</span> <span class="p">(</span><span class="n">x</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">)</span> <span class="o">?</span> <span class="mi">0</span> <span class="o">:</span> <span class="n">x</span><span class="p">;</span>
    <span class="kt">int16_t</span> <span class="n">clipX1</span> <span class="o">=</span> <span class="p">(</span><span class="n">x</span> <span class="o">+</span> <span class="n">w</span> <span class="o">&gt;</span> <span class="mi">240</span><span class="p">)</span> <span class="o">?</span> <span class="mi">240</span> <span class="o">:</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)(</span><span class="n">x</span> <span class="o">+</span> <span class="n">w</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">clipX0</span> <span class="o">&gt;=</span> <span class="n">clipX1</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>

    <span class="cm">/* 4. 仅改写落在当前 8 行内的局部缓冲区 */</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">int16_t</span> <span class="n">ly</span> <span class="o">=</span> <span class="n">localY0</span><span class="p">;</span> <span class="n">ly</span> <span class="o">&lt;</span> <span class="n">localY1</span><span class="p">;</span> <span class="n">ly</span><span class="o">++</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">uint16_t</span> <span class="o">*</span><span class="n">row</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">s_canvas</span><span class="p">.</span><span class="n">pixels</span><span class="p">[</span><span class="n">ly</span> <span class="o">*</span> <span class="mi">240</span><span class="p">];</span>
        <span class="k">for</span> <span class="p">(</span><span class="kt">int16_t</span> <span class="n">lx</span> <span class="o">=</span> <span class="n">clipX0</span><span class="p">;</span> <span class="n">lx</span> <span class="o">&lt;</span> <span class="n">clipX1</span><span class="p">;</span> <span class="n">lx</span><span class="o">++</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="n">row</span><span class="p">[</span><span class="n">lx</span><span class="p">]</span> <span class="o">=</span> <span class="n">color</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="帧快照与重绘请求合并">帧快照与重绘请求合并</h3>

<p>一帧由 40 次主循环、40 次绘制和 40 次 DMA 组成。如果每条带都直接读取实时力值或菜单焦点，上半屏可能画旧值、下半屏画新值，造成同一帧内部自相矛盾。</p>

<p>因此一帧开始时，渲染器将当前页面、菜单焦点、已格式化力值、电池百分比、波形快照及预计算的曲线纵坐标、设置页文本统一复制到 <code class="language-plaintext highlighter-rouge">AppUiRenderer</code> 快照中，在 40 个条带全部发完前保持不变，从而保证<strong>逻辑上的帧一致性</strong>。</p>

<p>同时，<code class="language-plaintext highlighter-rouge">AppUi_RequestRedraw()</code> 不会堆积刷新队列，只将 <code class="language-plaintext highlighter-rouge">appUiRenderer.pending</code> 置 1：</p>

<ul>
  <li>同一时刻连续发生多次数据变化，只合并为“需要再画一帧”；</li>
  <li>当前帧传输期间若有新变化，<code class="language-plaintext highlighter-rouge">pending</code> 标志保留，待当前帧第 40 条带结束后抓取最新快照开始下一帧。</li>
</ul>

<h3 id="交互式在线演示2408-条带渲染与帧快照防撕裂模拟">交互式在线演示：240×8 条带渲染与帧快照防撕裂模拟</h3>

<p>下面的演示每次绘制 8 行，从第 0 条带（<code class="language-plaintext highlighter-rouge">y=0..7</code>）推进到第 39 条带（<code class="language-plaintext highlighter-rouge">y=312..319</code>）。关闭“帧快照”后，可以观察数据在一帧中途变化时，数字上下部分不一致的现象。整屏缓冲区需要 153.6 KB，条带缓冲区为 3.84 KB。</p>

<div class="article-interactive-widget" id="strip-demo-app" aria-label="LCD 条带渲染与帧快照防撕裂交互演示">
  <div class="aiw-header">
    <span class="aiw-badge">INTERACTIVE WIDGET · 渲染管线仿真</span>
    <h3 class="aiw-title">240×8 像素条带渲染与帧快照防撕裂模拟器</h3>
    <p class="aiw-desc">仿真极低内存 MCU（SRAM 144 KB）驱动 240×320 彩屏方案：8 行局部条带画布（仅 3.84 KB，节省 97.5% 显存）、全屏坐标动态裁剪与 <code>AppUiRenderer</code> 帧快照一致性保证。</p>
  </div>
  <div class="aiw-body">
    <div class="strip-memory-compare">
      <div class="strip-mem-card bad">
        <div class="strip-mem-top">
          <span class="aiw-stat-label">方案一：传统整屏全帧缓冲 (Full Framebuffer)</span>
          <span class="strip-mem-pill bad">溢出 106.7% SRAM</span>
        </div>
        <div class="strip-mem-val">153.6 KB <small>(150.0 KiB)</small></div>
        <div class="strip-mem-bar"><div class="strip-mem-fill bad" style="width: 100%;"></div></div>
        <small class="strip-mem-note bad">240×320×2 = 153,600 字节，直接超过 N32G455 144 KB 总 SRAM 上限，无法链接分配。</small>
      </div>

      <div class="strip-mem-card good">
        <div class="strip-mem-top">
          <span class="aiw-stat-label">方案二：8 行局部条带画布 (Strip Canvas)</span>
          <span class="strip-mem-pill good">仅占 2.67% SRAM</span>
        </div>
        <div class="strip-mem-val">3.84 KB <small>(3.75 KiB)</small></div>
        <div class="strip-mem-bar"><div class="strip-mem-fill good" style="width: 6%;"></div></div>
        <small class="strip-mem-note good">240×8×2 = 3,840 字节（整屏的 1/40），为 AD7190 采集队列与协议栈留出充足内存。</small>
      </div>
    </div>

    <div class="aiw-stats" aria-label="当前条带状态">
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">当前渲染条带 (Strip Index)</span>
        <span class="aiw-stat-value accent" id="strip-stat-strip">条带 0 / 39 (整帧就绪)</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">条带物理行坐标跨度</span>
        <span class="aiw-stat-value" id="strip-stat-y">y = 0 .. 7 px</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">逻辑画面完整性判定</span>
        <span class="aiw-stat-value accent" id="strip-stat-tear">画面原子一致 (无撕裂)</span>
      </div>
    </div>

    <!-- 运行寄存器与数据流诊断板 -->
    <div class="strip-reg-stats">
      <div class="strip-reg-item">
        <span class="strip-reg-label">传感器实时采样 (Runtime)</span>
        <span class="strip-reg-value" id="strip-stat-live">45.20 N</span>
      </div>
      <div class="strip-reg-item">
        <span class="strip-reg-label">渲染帧锁定量 (Snapshot)</span>
        <span class="strip-reg-value accent" id="strip-stat-snap">45.20 N (已锁定)</span>
      </div>
      <div class="strip-reg-item">
        <span class="strip-reg-label">换帧标记 (Pending Dirty)</span>
        <span class="strip-reg-value" id="strip-stat-pending">0 · CLEAN (就绪)</span>
      </div>
      <div class="strip-reg-item">
        <span class="strip-reg-label">接缝物理坐标诊断</span>
        <span class="strip-reg-value accent" id="strip-stat-seam">无接缝断层</span>
      </div>
    </div>

    <div class="strip-sim-container">
      <div class="strip-hardware-col">
        <div class="strip-bezel-header">
          <span class="strip-bezel-title">ST7789 240×320 硬件 GRAM</span>
          <span class="strip-dma-badge" id="strip-dma-badge">DMA1_CH2 IDLE</span>
        </div>

        <div class="strip-screen-bezel">
          <div class="strip-screen-viewport" id="strip-screen-viewport" title="点击屏幕任意行可定位并检视对应 8 行条带">
            <canvas id="strip-screen-canvas" width="240" height="320" class="strip-screen-canvas" aria-label="ST7789 屏幕画布，点击任意位置可定位对应条带"></canvas>
            <div class="strip-scan-line" id="strip-band" style="top: 0px;">
              <span class="strip-scan-pill flip-below" id="strip-scan-tag">STRIP 0 · ROW 0..7</span>
            </div>
            <div class="strip-tear-seam" id="strip-tear-seam" style="display: none; top: 104px;">
              <span class="strip-tear-badge" id="strip-tear-badge">⚡ 撕裂接缝 Row 104 (45.20N / 89.60N)</span>
            </div>
          </div>
        </div>

        <div class="strip-sram-box">
          <div class="strip-sram-header">
            <span class="strip-sram-title">MCU 片内条带缓冲 (SRAM `pixels[240×8]`)</span>
            <span class="strip-sram-tag">3.84 KB · localY 0..7</span>
          </div>
          <div class="strip-sram-viewport">
            <canvas id="strip-sram-canvas" width="240" height="8" class="strip-sram-canvas" aria-label="3.84 KB 条带局部显存放大视图"></canvas>
          </div>
          <div class="strip-clip-diag" id="strip-clip-info">
            <code>Canvas_FillRect</code> 裁剪：<code>stripY = 0..7</code> ➔ 命中【顶部状态栏】，写入 <code>localY[0..7]</code>
          </div>
          <div class="strip-sram-desc">
            <span>提示：可直接<strong>点击上方屏幕任意位置</strong>或下方 40 条带进度格，观察全屏坐标如何被裁剪进这 8 行 SRAM 缓冲区。</span>
          </div>
        </div>
      </div>

      <div class="strip-controls-col">
        <div class="strip-card-panel">
          <div class="aiw-section-title">
            <span>一键实验场景对比 (Quick Scenarios)</span>
            <span class="aiw-hint">直观对比有无快照时的刷屏中途跳变表现</span>
          </div>
          <div class="strip-preset-row">
            <button type="button" class="aiw-btn primary" id="strip-preset-safe">🛡️ 演示开启帧快照 (原子保护不割裂)</button>
            <button type="button" class="aiw-btn danger" id="strip-preset-tear">⚡ 演示无快照中途跳变 (力值上下割裂)</button>
            <button type="button" class="aiw-btn" id="strip-preset-inspect">🔍 检视 3.84KB SRAM 字模切片 (Strip 12)</button>
          </div>
        </div>

        <div class="strip-card-panel">
          <div class="aiw-section-title">
            <span>帧快照机制开关 (AppUiRenderer Snapshot)</span>
            <span class="aiw-hint">切换开关可实时重绘对比当前帧画面</span>
          </div>
          <label class="strip-toggle-label">
            <input type="checkbox" id="strip-chk-snapshot" checked="" />
            <span><strong>开启帧快照机制 (推荐)</strong>：在 <code>Strip 0</code> 启动时将待显示力值与曲线坐标复制到不可变快照 <code>AppUiRenderer</code>，40 个条带发完前保持不变；中途新采样仅置位 <code>pending = 1</code>，杜绝同一帧上下半截自相矛盾。</span>
          </label>
          <label class="strip-toggle-label" style="margin-top: 8px;">
            <input type="checkbox" id="strip-chk-auto-jump" />
            <span><strong>模拟拉力机高频采样流</strong>：每 320 ms 产生一次动态力值跳变，复现高速采样下无锁直读造成的横向撕裂。</span>
          </label>
        </div>

        <div class="strip-card-panel">
          <div class="aiw-section-title">
            <span>分批渲染与 DMA 调度控制台 (40 条带帧进度)</span>
            <span class="aiw-hint" id="strip-frame-counter">Frame #1 · 点击色块可跳转条带</span>
          </div>
          <div class="strip-progress-grid" id="strip-progress-grid" role="group" aria-label="40 个条带进度矩阵"></div>
          <div class="strip-pipeline-bar" id="strip-pipeline-bar" aria-label="单条带 4 阶段硬件时序">
            <span class="strip-stage is-active" id="strip-st-clip">① 局部裁剪绘制</span>
            <span class="strip-stage" id="strip-st-win">② 8-bit 窗口命令</span>
            <span class="strip-stage" id="strip-st-dma">③ 16-bit 全双工 DMA</span>
            <span class="strip-stage" id="strip-st-wait">④ 等 BUSY=0 拉高 CS</span>
          </div>
          <div class="strip-ctrl-row" style="margin-top: 10px;">
            <div class="strip-speed-wrap">
              <label for="strip-speed-select" class="strip-speed-label">步进速率：</label>
              <select id="strip-speed-select" class="aiw-select">
                <option value="120">慢速单步观察 (120ms / 条带)</option>
                <option value="45" selected="">标准步进 (45ms / 条带)</option>
                <option value="16">真实高速刷屏 (16ms / 条带)</option>
              </select>
            </div>
          </div>
          <div class="aiw-controls" style="margin-top: 10px;">
            <button type="button" class="aiw-btn primary" id="strip-btn-step">单步推进 1 条带 (8 行)</button>
            <button type="button" class="aiw-btn" id="strip-btn-run">连续刷屏</button>
            <button type="button" class="aiw-btn warn" id="strip-btn-jump">模拟传感器力值跳变 (45.20N ➔ 89.60N)</button>
            <button type="button" class="aiw-btn" id="strip-btn-reset">复位重置</button>
          </div>
        </div>

        <div class="aiw-log-panel">
          <div class="aiw-section-title">
            <span>渲染条带流水与 SPI1 DMA 状态机日志</span>
            <span class="aiw-hint">记录 Strip 0 快照锁定、CASET/RASET 窗口与撕裂位置</span>
          </div>
          <div class="aiw-log" id="strip-log" aria-live="polite"></div>
        </div>
      </div>
    </div>
  </div>
</div>

<h2 id="spi1-全双工-dma-与三态异步状态机">SPI1 全双工 DMA 与三态异步状态机</h2>

<h3 id="tx-dma-与-rx-dma-协同配置">TX DMA 与 RX DMA 协同配置</h3>

<p>一个 8 行条带包含 1,920 个像素（30,720 bits）。<code class="language-plaintext highlighter-rouge">LcdSt7789_DrawPixelsAsync()</code> 使用 DMA1_CH2 发送像素，同时启用 DMA1_CH1 接收丢弃：</p>

<table>
  <thead>
    <tr>
      <th>配置项</th>
      <th>RX 通道（DMA1_CH1）</th>
      <th>TX 通道（DMA1_CH2）</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>外设地址</td>
      <td><code class="language-plaintext highlighter-rouge">&amp;SPI1-&gt;DAT</code></td>
      <td><code class="language-plaintext highlighter-rouge">&amp;SPI1-&gt;DAT</code></td>
    </tr>
    <tr>
      <td>内存地址</td>
      <td>静态变量 <code class="language-plaintext highlighter-rouge">&amp;lcdAsyncRxDiscard</code></td>
      <td>条带像素缓冲 <code class="language-plaintext highlighter-rouge">pixels</code></td>
    </tr>
    <tr>
      <td>传输方向</td>
      <td>外设 → 内存</td>
      <td>内存 → 外设</td>
    </tr>
    <tr>
      <td>内存地址递增</td>
      <td>关闭（始终覆盖同一丢弃变量）</td>
      <td>开启（顺序扫描每个像素）</td>
    </tr>
    <tr>
      <td>数据宽度</td>
      <td>HalfWord（16 bit）</td>
      <td>HalfWord（16 bit）</td>
    </tr>
    <tr>
      <td>模式 / 优先级</td>
      <td>Normal / Medium</td>
      <td>Normal / Medium</td>
    </tr>
    <tr>
      <td>中断配置</td>
      <td>开启完成（TC）与错误（TE）中断</td>
      <td>不开启独立中断</td>
    </tr>
  </tbody>
</table>

<p>为什么只写屏幕还必须配置 RX DMA？因为 SPI 是全双工移位结构，MOSI 每发出 16 位像素，MISO 也会移入 16 位数据。若不取走接收数据，接收寄存器会产生未读数据并触发 <code class="language-plaintext highlighter-rouge">OVR</code>（Overrun）标志。用内存不递增的 RX DMA 将无用数据排空到静态变量 <code class="language-plaintext highlighter-rouge">lcdAsyncRxDiscard</code>，既无需分配 3,840 字节接收缓冲，又能以“最后一个接收半字取走”作为全双工传输完成的可靠基准。</p>

<h3 id="为什么-dma-完成不等于-spi-传输结束">为什么 DMA 完成不等于 SPI 传输结束</h3>

<p>必须区分三个硬件阶段：</p>

<ol>
  <li>TX DMA 把最后一个像素写入 <code class="language-plaintext highlighter-rouge">SPI1-&gt;DAT</code>；</li>
  <li>SPI 将该数据装入内部移位寄存器，RX DMA 收完倒数第二个字并在最后一个字移入后触发 RX TC 中断；</li>
  <li>SPI 硬件确保总线上最后一位真正移出并令 <code class="language-plaintext highlighter-rouge">SPI_I2S_BUSY_FLAG</code> 清零。</li>
</ol>

<p>如果收到 DMA 中断就立刻拉高 <code class="language-plaintext highlighter-rouge">CS</code>，最后一个像素可能仍在移位或刚离开移位器，导致屏幕尾像素截断。因此驱动采用三态状态机：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>LCD_ASYNC_IDLE（空闲，可接受新条带）
  │
  ├─ 配置窗口并启动 RX/TX DMA
  ▼
LCD_ASYNC_TRANSFER（DMA 搬运中）
  │
  ├─ RX DMA 完成中断置位 lcdAsyncDmaDone = 1
  ▼
LCD_ASYNC_WAIT_SPI（主循环等待 SPI BUSY 清零）
  │
  ├─ SPI BUSY == 0（或 200 ms 超时恢复）
  ▼
关闭 DMA 与中断 → 拉高 CS → SPI 恢复 8 bit → 回到 LCD_ASYNC_IDLE
</code></pre></div></div>

<h4 id="异步传输状态机与中断服务程序">异步传输状态机与中断服务程序</h4>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">enum</span> <span class="p">{</span>
    <span class="n">LCD_STATE_IDLE</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span>     <span class="cm">/* 空闲，可接受新条带 */</span>
    <span class="n">LCD_STATE_TRANSFERRING</span><span class="p">,</span> <span class="cm">/* DMA 搬运像素中 */</span>
    <span class="n">LCD_STATE_WAIT_SPI_DONE</span> <span class="cm">/* DMA 完成，等待 SPI 硬件移位寄存器排空 */</span>
<span class="p">}</span> <span class="n">LcdAsyncState</span><span class="p">;</span>

<span class="k">static</span> <span class="k">volatile</span> <span class="n">LcdAsyncState</span> <span class="n">s_lcdState</span> <span class="o">=</span> <span class="n">LCD_STATE_IDLE</span><span class="p">;</span>

<span class="cm">/* DMA 接收完成中断服务程序 (仅更新状态与清除中断挂起，绝不阻塞) */</span>
<span class="kt">void</span> <span class="nf">DMA1_Channel1_IRQHandler</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">DMA_GetFlagStatus</span><span class="p">(</span><span class="n">DMA1_FLAG_TC1</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="n">DMA_ClearFlag</span><span class="p">(</span><span class="n">DMA1_FLAG_TC1</span><span class="p">);</span>
        <span class="n">s_lcdState</span> <span class="o">=</span> <span class="n">LCD_STATE_WAIT_SPI_DONE</span><span class="p">;</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="cm">/* 主循环非阻塞推进函数 */</span>
<span class="kt">void</span> <span class="nf">Lcd_Process</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">s_lcdState</span> <span class="o">==</span> <span class="n">LCD_STATE_WAIT_SPI_DONE</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="cm">/* 严禁在 DMA ISR 中拉高 CS，必须等待 SPI 硬件 BUSY 清零 */</span>
        <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">SPI_IsBusy</span><span class="p">(</span><span class="n">SPI1</span><span class="p">))</span>
        <span class="p">{</span>
            <span class="n">LCD_CS_HIGH</span><span class="p">();</span>               <span class="cm">/* 拉高片选，结束像素事务 */</span>
            <span class="n">SPI_SetDataWidth8Bit</span><span class="p">(</span><span class="n">SPI1</span><span class="p">);</span>  <span class="cm">/* 恢复 8 位命令模式 */</span>
            <span class="n">s_lcdState</span> <span class="o">=</span> <span class="n">LCD_STATE_IDLE</span><span class="p">;</span> <span class="cm">/* 释放条带所有权，允许 UI 绘制下一条带 */</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="极简-isr-与主循环收尾">极简 ISR 与主循环收尾</h3>

<p><code class="language-plaintext highlighter-rouge">DMA1_Channel1_IRQHandler</code> 转发给 <code class="language-plaintext highlighter-rouge">LcdSt7789_DmaRxIrqHandler()</code>，中断服务程序只做三件事：</p>

<ul>
  <li>RX DMA 错误时置 <code class="language-plaintext highlighter-rouge">lcdAsyncDmaError = 1</code>；</li>
  <li>RX DMA 完成时置 <code class="language-plaintext highlighter-rouge">lcdAsyncDmaDone = 1</code>；</li>
  <li>清除 DMA1_CH1 全局中断挂起位。</li>
</ul>

<p>ISR 不等待 <code class="language-plaintext highlighter-rouge">SPI BUSY</code>、不切 SPI 位宽、不打日志，也不启动下一条带。LCD 中断的抢占优先级为 3，AD7190 采样中断为 1；这里只记录标志，收尾交给主循环中的 <code class="language-plaintext highlighter-rouge">LcdSt7789_Process()</code> 执行。</p>

<h2 id="单条带完整时序与主循环串联">单条带完整时序与主循环串联</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>AppUi_RenderProcess()
  1. 在 240×8 缓冲中裁剪绘制当前条带 (y .. y+7)
  2. 调用 LcdSt7789_DrawPixelsAsync(0, y, 240, 8, pixels)
       ├─ 8-bit 轮询发送 CASET + RASET + RAMWR
       ├─ RS=1, CS=0 保持，SPI1 切换为 16-bit
       ├─ 先使能 RX DMA (CH1)，再使能 TX DMA (CH2)
       └─ 状态置为 TRANSFER，立即返回 true
  3. UI 将 nextY += 8 并返回主循环，主循环继续处理采样/按键/看门狗
  ...
DMA1_Channel1_IRQHandler()
  4. 1,920 个像素搬运完毕，置 lcdAsyncDmaDone = 1 并清中断
  ...
下一轮主循环：
  5. AppRuntimeData_Process() 更新实时数据
  6. LcdSt7789_Process() 检测到 dmaDone，进入 WAIT_SPI
  7. 确认 SPI BUSY == 0，关 DMA、拉高 CS、恢复 8-bit、状态回 IDLE
  8. AppUi_Process() 见 IsBusy() == false，立即绘制并提交下一条带
</code></pre></div></div>

<p>需要注意的是：“释放条带缓冲”是所有权移交概念——在 <code class="language-plaintext highlighter-rouge">LcdSt7789_IsBusy()</code> 返回 <code class="language-plaintext highlighter-rouge">false</code> 之前，UI 绝不能修改或覆盖 <code class="language-plaintext highlighter-rouge">pixels</code> 数组。</p>

<h2 id="性能估算与屏幕方向切换">性能估算与屏幕方向切换</h2>

<h3 id="理论与实际帧率">理论与实际帧率</h3>

<p>在 SPI1 32 MHz、16 bit RGB565 下，理论纯像素速率为 <code class="language-plaintext highlighter-rouge">32,000,000 / 16 = 2,000,000 pixel/s</code>：</p>

<ul>
  <li>单个 <code class="language-plaintext highlighter-rouge">240 × 8</code> 条带（1,920 像素）纯传输时间约 <code class="language-plaintext highlighter-rouge">0.96 ms</code>；</li>
  <li>整屏 40 个条带（76,800 像素）纯传输时间约 <code class="language-plaintext highlighter-rouge">38.4 ms</code>（理论极限约 26 fps）。</li>
</ul>

<p>计入每条带的 <code class="language-plaintext highlighter-rouge">CASET/RASET/RAMWR</code> 命令开销、40 次布局裁剪计算、主循环收尾间隔以及高优先级 AD7190 采样的穿插后，实际刷新率面向测量仪表式的 <strong>10 Hz（100 ms 周期）内容更新</strong>设计，兼顾视觉响应与采样实时性。</p>

<h3 id="屏幕方向在帧边界切换">屏幕方向在帧边界切换</h3>

<p>屏幕正向（<code class="language-plaintext highlighter-rouge">MADCTL = 0x00</code>）与 180° 反向（<code class="language-plaintext highlighter-rouge">MADCTL = 0xC0</code>）只能在两帧之间切换。<code class="language-plaintext highlighter-rouge">AppUi_ProcessOrientation()</code> 仅在满足以下全部条件时才写 <code class="language-plaintext highlighter-rouge">MADCTL</code>：</p>

<ul>
  <li>目标方向有效且与当前方向不同；</li>
  <li>当前没有正在分条带生成的一帧；</li>
  <li><code class="language-plaintext highlighter-rouge">LcdSt7789_IsBusy() == false</code>。</li>
</ul>

<p>若在一帧中途修改 <code class="language-plaintext highlighter-rouge">MADCTL</code>，已发出的上半帧与后续条带会使用相反的扫描坐标系，导致画面上下割裂。方向切换成功后会立即触发一次整屏重绘。</p>

<h2 id="常见误区与排查速查">常见误区与排查速查</h2>

<h3 id="常见认识误区">常见认识误区</h3>

<ul>
  <li><strong>误区一：DMA 完成中断一到就可以立刻改写像素缓冲</strong>——不可以，必须等主循环 <code class="language-plaintext highlighter-rouge">LcdSt7789_Process()</code> 确认 <code class="language-plaintext highlighter-rouge">SPI BUSY</code> 清零并将状态切回 <code class="language-plaintext highlighter-rouge">IDLE</code>。</li>
  <li><strong>误区二：启用 RX DMA 说明正在从屏幕读回图像</strong>——不是，正常刷屏时 RX DMA 仅用于排空全双工移位器防止 <code class="language-plaintext highlighter-rouge">OVR</code>，并作为完成事件基准。</li>
  <li><strong>误区三：有帧快照就完全没有物理撕裂</strong>——帧快照保证同一帧内的业务数值与曲线自洽，但屏幕仍按 40 个条带从上往下写入 GRAM，未使用双帧缓冲或 TE 同步信号。</li>
</ul>

<h3 id="典型故障排查方向">典型故障排查方向</h3>

<table>
  <thead>
    <tr>
      <th>现象</th>
      <th>优先检查项</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>全黑但背光亮</td>
      <td><code class="language-plaintext highlighter-rouge">RST</code> 时序、<code class="language-plaintext highlighter-rouge">DISPON</code> 命令、SPI 模式、<code class="language-plaintext highlighter-rouge">CS/RS</code> 电平</td>
    </tr>
    <tr>
      <td>背光不亮</td>
      <td><code class="language-plaintext highlighter-rouge">PC2</code> 电平、首帧 40 个条带是否顺利走完、外围背光驱动电路</td>
    </tr>
    <tr>
      <td>颜色变互补色或红蓝互换</td>
      <td><code class="language-plaintext highlighter-rouge">INVON (0x21)</code> / <code class="language-plaintext highlighter-rouge">INVOFF (0x20)</code>、<code class="language-plaintext highlighter-rouge">MADCTL</code> 的 RGB/BGR 位，详见 <a href="/articles/lcd-rgb565-color-troubleshooting.html">嵌入式 RGB565 LCD 颜色反转、字节错序与花屏排查指南</a></td>
    </tr>
    <tr>
      <td>尾像素缺失或条带末尾异常</td>
      <td><code class="language-plaintext highlighter-rouge">CS</code> 是否在 <code class="language-plaintext highlighter-rouge">SPI BUSY</code> 清零前被提前拉高</td>
    </tr>
    <tr>
      <td>随机卡住约 200 ms 后恢复</td>
      <td><code class="language-plaintext highlighter-rouge">DMA1_CH1</code> 中断配置、请求重映射、主循环是否有超长阻塞</td>
    </tr>
    <tr>
      <td>横向彩条或条带边界错色</td>
      <td>异步 DMA 期间是否提前改写了 <code class="language-plaintext highlighter-rouge">pixels</code> 缓冲、8/16 位模式切换是否成对</td>
    </tr>
  </tbody>
</table>]]></content><author><name></name></author><category term="嵌入式" /><category term="N32G455" /><category term="ST7789" /><category term="LCD" /><category term="DMA" /><category term="裸机UI" /><summary type="html"><![CDATA[针对小内存 Cortex-M MCU，解析从数据变化到 240×8 局部条带画布、不可变帧快照防撕裂、SPI 全双工 DMA 与主循环非阻塞刷屏的完整链路。]]></summary></entry><entry><title type="html">嵌入式 1-bit 中英繁点阵字库子集化生成与集成</title><link href="https://single5240.github.io/articles/embedded-1bit-bitmap-font-pipeline.html" rel="alternate" type="text/html" title="嵌入式 1-bit 中英繁点阵字库子集化生成与集成" /><published>2026-10-15T00:00:00+08:00</published><updated>2026-10-15T00:00:00+08:00</updated><id>https://single5240.github.io/articles/embedded-1bit-bitmap-font-pipeline</id><content type="html" xml:base="https://single5240.github.io/articles/embedded-1bit-bitmap-font-pipeline.html"><![CDATA[<p>在小内存嵌入式 MCU（如片内 Flash 512 KB、RAM 144 KB）的多语言界面中，直接嵌入完整中文字库会迅速耗尽 Flash 空间。本文介绍一套按需提取 UI 字符子集的 1-bit 点阵字库生成与光栅化流水线，使用 16 px 原生点阵和 24 px/40 px 定制字形，五套字库仅占约 19.2 KB Flash，即可实现高清晰度多语言显示。页面条带渲染原理见 <a href="/articles/n32g455-lcd-strip-rendering.html">N32G455 LCD 条带渲染与异步刷新实现分析</a>。</p>

<h2 id="字号规划与-flash-体积预算">字号规划与 Flash 体积预算</h2>

<p>在 512 KB Flash 的 MCU 上，如果直接嵌入完整 GB2312 或 Big5 字库，仅单个字号就会消耗数百 KB 空间。因此工程按 UI 视觉层级定义了 5 套按需子集化的 1-bit 点阵字库：</p>

<table>
  <thead>
    <tr>
      <th>字库模块</th>
      <th>像素尺寸</th>
      <th>界面用途</th>
      <th>收录字符范围</th>
      <th style="text-align: right">字形数量</th>
      <th style="text-align: right">点阵体积</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">font_16</code></td>
      <td>汉字 <code class="language-plaintext highlighter-rouge">16×16</code>，ASCII <code class="language-plaintext highlighter-rouge">8×16</code> 等宽</td>
      <td>简中/英文菜单正文、底栏、状态标签</td>
      <td>可打印 ASCII + 简中 UI 汉字 + <code class="language-plaintext highlighter-rouge">±</code></td>
      <td style="text-align: right">278</td>
      <td style="text-align: right">7,360 B</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">font_16_tw</code></td>
      <td>汉字 <code class="language-plaintext highlighter-rouge">16×16</code></td>
      <td>台湾繁体菜单正文、底栏、状态标签</td>
      <td>当前繁体 UI 使用的全部汉字</td>
      <td style="text-align: right">182</td>
      <td style="text-align: right">5,824 B</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">font_24</code></td>
      <td>汉字 <code class="language-plaintext highlighter-rouge">24×24</code>，ASCII <code class="language-plaintext highlighter-rouge">12×24</code> 等宽</td>
      <td>简中页面标题</td>
      <td>简中菜单及常用小节标题字</td>
      <td style="text-align: right">47</td>
      <td style="text-align: right">2,952 B</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">font_24_tw</code></td>
      <td>汉字 <code class="language-plaintext highlighter-rouge">24×24</code></td>
      <td>台湾繁体页面标题</td>
      <td>当前繁体标题使用的全部汉字</td>
      <td style="text-align: right">29</td>
      <td style="text-align: right">2,088 B</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">font_num_40</code></td>
      <td><code class="language-plaintext highlighter-rouge">24×40</code> 等宽</td>
      <td>首页主读数大数字</td>
      <td>仅 <code class="language-plaintext highlighter-rouge">0123456789.-</code></td>
      <td style="text-align: right">12</td>
      <td style="text-align: right">1,440 B</td>
    </tr>
  </tbody>
</table>

<p>五套字库点阵数据合计仅 <strong>19,664 字节（约 19.2 KiB）</strong>，全部以 <code class="language-plaintext highlighter-rouge">const uint8_t</code> 存入片内 Flash，运行期零 RAM 占用。</p>

<h2 id="1-bit-点阵二进制格式">1-bit 点阵二进制格式</h2>

<p>所有字库统一采用 <strong>1 bit/pixel、行优先（Row-major）、高位在左（MSB leftmost）</strong> 的紧凑编码格式：</p>

<ul>
  <li>每个字节的最高位（<code class="language-plaintext highlighter-rouge">bit7</code>）对应该行最左侧像素，<code class="language-plaintext highlighter-rouge">bit0</code> 对应右侧第 8 个像素；</li>
  <li>单行像素宽度不足 8 的整数倍时，行末低位补 <code class="language-plaintext highlighter-rouge">0</code>；</li>
  <li>每行字节数计算公式：<code class="language-plaintext highlighter-rouge">stride = (width + 7) / 8</code>；</li>
  <li>单个字形总字节数：<code class="language-plaintext highlighter-rouge">size = stride × height</code>。</li>
</ul>

<p>典型字形尺寸计算示例：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>16×16 汉字：stride = (16 + 7) / 8 = 2 B/行  →  单个字形 = 2 × 16 = 32 B
 8×16 ASCII：stride = (8 + 7) / 8 = 1 B/行  →  单个字形 = 1 × 16 = 16 B
24×24 汉字：stride = (24 + 7) / 8 = 3 B/行  →  单个字形 = 3 × 24 = 72 B
24×40 数字：stride = (24 + 7) / 8 = 3 B/行  →  单个字形 = 3 × 40 = 120 B
</code></pre></div></div>

<h2 id="为什么-16-px-繁体汉字必须用原生点阵而非矢量缩放">为什么 16 px 繁体汉字必须用原生点阵而非矢量缩放</h2>

<p>在 16×16 的极低分辨率下，台湾繁体字（如“歸”“斷”“輸”“壓”“籤”）笔画极其密集。如果直接将桌面端 TrueType/OpenType 矢量字体缩放到 16 px 再做二值化阈值裁剪，相邻横画与撇捺会严重粘连成实心黑块，导致小屏幕完全无法辨认。</p>

<p>为此，字库生成流水线采用了<strong>按字号分源</strong>的策略：</p>

<table>
  <thead>
    <tr>
      <th>字库类别</th>
      <th>字形上游来源</th>
      <th>选择原因与质量控制</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>简体汉字 <code class="language-plaintext highlighter-rouge">16×16</code> / <code class="language-plaintext highlighter-rouge">24×24</code></td>
      <td>U8g2 收录的 <code class="language-plaintext highlighter-rouge">GB16ST</code> / <code class="language-plaintext highlighter-rouge">GB24ST</code> 点阵宋体</td>
      <td>经典手工修整点阵，横平竖直、笔画清晰</td>
    </tr>
    <tr>
      <td>拉丁字母与符号 <code class="language-plaintext highlighter-rouge">8×16</code> / <code class="language-plaintext highlighter-rouge">12×24</code></td>
      <td>Spleen 等宽点阵字库</td>
      <td>专为低分屏设计的像素等宽西文字体</td>
    </tr>
    <tr>
      <td>台湾繁体汉字 <code class="language-plaintext highlighter-rouge">16×16</code></td>
      <td><strong>GNU Unifont 18.0.01（T-source Chinese）原生点阵</strong></td>
      <td>直接提取原生 16×16 像素手工点阵，不做任何缩放、抗锯齿或二值化，避免缩放、二值化造成的繁体字笔画粘连</td>
    </tr>
    <tr>
      <td>台湾繁体标题汉字 <code class="language-plaintext highlighter-rouge">24×24</code></td>
      <td>Noto Serif TC Regular（23 px 栅格化）</td>
      <td>24 px 空间充足；先在带 8 px 外扩安全区的画布中完整绘制再裁入 <code class="language-plaintext highlighter-rouge">24×24</code> 字框，若任一有效像素越界则脚本直接报错中断</td>
    </tr>
    <tr>
      <td>主读数大数字 <code class="language-plaintext highlighter-rouge">24×40</code></td>
      <td>JetBrains Mono Bold 定制点阵</td>
      <td>等宽数字与小数点/负号，保证力值快速跳变时数字列绝不左右晃动</td>
    </tr>
  </tbody>
</table>

<p>此外，构建脚本对上游标准字库文件执行 <strong>SHA-256 哈希强校验</strong>，确保在不同开发环境或 CI 中重新生成都能得到逐字节完全一致的 C 源码数组。</p>

<div id="font-demo-app" class="font-demo-root">
  <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
    <h3 style="margin:0; font-size:16px;">1-bit 点阵字库生成与光栅化排障实验室</h3>
    <span style="font-size:12px; color:var(--muted);">纯前端交互演示</span>
  </div>

  <div class="font-tabs">
    <button class="font-tab-btn active" data-tab="editor">16×16 点阵画板与 C99 生成</button>
    <button class="font-tab-btn" data-tab="compare">矢量缩放粘连 vs 原生点阵</button>
    <button class="font-tab-btn" data-tab="bsearch">Unicode 升序二分查找模拟</button>
  </div>

  <!-- Tab 1: Editor -->
  <div id="font-panel-editor" class="font-tab-panel active">
    <div class="font-editor-grid">
      <div class="font-grid-wrap">
        <div id="font-grid-16" class="font-grid-16"></div>
        <div class="font-palette-bar">
          <button class="font-palette-btn" id="font-preset-gui">預設: 歸</button>
          <button class="font-palette-btn" id="font-preset-duan">預設: 斷</button>
          <button class="font-palette-btn" id="font-preset-he">预设: 荷</button>
          <button class="font-palette-btn" id="font-preset-a">ASCII: A</button>
          <button class="font-palette-btn" id="font-preset-0">ASCII: 0</button>
          <button class="font-palette-btn" id="font-btn-invert">反相</button>
          <button class="font-palette-btn" id="font-btn-clear" style="color:#ef4444;">清空</button>
        </div>
      </div>

      <div style="display:flex; flex-direction:column; gap:12px;">
        <div class="font-preview-wrap">
          <canvas id="font-preview-canvas" class="font-preview-canvas" width="32" height="32"></canvas>
          <div class="font-preview-info">
            <div>字形规格：<span id="font-dim-label">16 × 16 (汉字)</span></div>
            <div>内存占用：<span id="font-byte-count">32 字节 (stride=2 B/行)</span></div>
            <div style="font-size:11px; color:var(--muted); margin-top:2px;">编码标准：Row-major MSB leftmost (bit7 为行首最左像素)</div>
          </div>
        </div>

        <div class="font-c-export">
          <pre id="font-c-code">/* 正在生成 C99 const uint8_t 数组... */</pre>
        </div>
      </div>
    </div>
  </div>

  <!-- Tab 2: Compare -->
  <div id="font-panel-compare" class="font-tab-panel">
    <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px; flex-wrap:wrap; gap:8px;">
      <div style="display:flex; gap:6px;">
        <span style="font-size:13px; font-weight:600; line-height:26px;">测试繁体字：</span>
        <button class="font-palette-btn font-compare-preset-btn active" data-char="歸">歸 (18画)</button>
        <button class="font-palette-btn font-compare-preset-btn" data-char="斷">斷 (18画)</button>
      </div>
      <div style="display:flex; align-items:center; gap:8px; font-size:12px;">
        <label for="font-thresh-slider">二值化阈值 (Threshold): <strong id="font-thresh-val">128</strong></label>
        <input type="range" id="font-thresh-slider" min="40" max="220" value="128" style="width:110px;" />
      </div>
    </div>

    <div class="font-compare-grid">
      <div class="font-compare-card bad">
        <div class="font-compare-title">
          <span>矢量字体缩放到 16 px + 阈值二值化</span>
          <span class="font-compare-badge bad">粘连失真</span>
        </div>
        <canvas id="font-zoom-vector" class="font-zoom-canvas" width="128" height="128"></canvas>
        <div class="font-compare-desc">
          桌面级矢量字库在 16 px 极低网格下做灰度抗锯齿二值化裁剪时，繁体字密集横撇（如“歸”中间“𠂤”与“止”）像素间距不足 1 像素，强制二值化导致笔画粘连成黑色实心方块，完全失去可读性。
        </div>
      </div>

      <div class="font-compare-card good">
        <div class="font-compare-title">
          <span>GNU Unifont 原生 16×16 点阵</span>
          <span class="font-compare-badge good">像素级手工优化</span>
        </div>
        <canvas id="font-zoom-native" class="font-zoom-canvas" width="128" height="128"></canvas>
        <div class="font-compare-desc">
          上游源自手工修整的 16×16 原生点阵，在保证汉字骨架的同时对密集笔画做合理避让，严格保留单像素留白缝隙。不做任何插值缩放，在嵌入式小彩屏上边缘锐利、清晰辨识。
        </div>
      </div>
    </div>
  </div>

  <!-- Tab 3: Binary Search -->
  <div id="font-panel-bsearch" class="font-tab-panel">
    <div class="font-bsearch-box">
      <div class="font-bsearch-controls">
        <label style="font-size:13px; font-weight:600;">输入查找字符或 Unicode 码点：</label>
        <input type="text" id="font-bsearch-input" class="font-bsearch-input" value="荷" maxlength="4" />
        <button class="ecat-btn" id="font-bsearch-step-btn" style="background:var(--accent); color:var(--on-accent); border-color:var(--accent);">单步探测 (Step)</button>
        <button class="ecat-btn" id="font-bsearch-reset-btn">重置探测</button>
        <span style="font-size:12px; color:var(--muted); margin-left:auto;">
          表中收录 28 个典型字形（已升序排列），最多只需要 ⌈log₂(28)⌉ = 5 次比较
        </span>
      </div>

      <div class="font-bsearch-bar-wrap">
        <div id="font-bsearch-bar" class="font-bsearch-bar"></div>
      </div>

      <div id="font-bsearch-log" class="font-bsearch-log">
        准备就绪：输入目标字符后点击“单步探测”观察 low / mid / high 指针折半收敛过程。
      </div>
    </div>
  </div>
</div>

<h2 id="unicode-升序索引表与二分查找">Unicode 升序索引表与二分查找</h2>

<p>为了在 ARMCC 5 下避免 GBK/UTF-8 源文件编码混用问题，所有字库检索表统一按 <strong>Unicode 码点（<code class="language-plaintext highlighter-rouge">uint16_t codepoint</code>）升序排列</strong>：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="kt">uint16_t</span> <span class="n">codepoint</span><span class="p">;</span> <span class="cm">/* Unicode 码点，如 '荷' = 0x8377 */</span>
    <span class="kt">uint8_t</span>  <span class="n">width</span><span class="p">;</span>     <span class="cm">/* 字形像素宽度：ASCII 为 8/12，汉字为 16/24 */</span>
    <span class="kt">uint8_t</span>  <span class="n">size</span><span class="p">;</span>      <span class="cm">/* 字形点阵字节数 */</span>
    <span class="kt">uint16_t</span> <span class="n">offset</span><span class="p">;</span>    <span class="cm">/* 在 font_xx_bitmap[] 数组中的起始字节偏移 */</span>
<span class="p">}</span> <span class="n">font_16_glyph_t</span><span class="p">;</span>
</code></pre></div></div>

<p>查找单个字符时，<code class="language-plaintext highlighter-rouge">font_16_find(uint16_t codepoint)</code> 对升序表执行 <code class="language-plaintext highlighter-rouge">O(log N)</code> 二分查找（278 个字形最多比较 9 次即可命中）：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">const</span> <span class="n">font_16_glyph_t</span> <span class="o">*</span><span class="n">g</span> <span class="o">=</span> <span class="n">font_16_find</span><span class="p">(</span><span class="mh">0x8377U</span><span class="p">);</span> <span class="cm">/* 查找 '荷' */</span>
<span class="k">if</span> <span class="p">(</span><span class="n">g</span> <span class="o">!=</span> <span class="nb">NULL</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">bits</span> <span class="o">=</span> <span class="n">font_16_bitmap</span> <span class="o">+</span> <span class="n">g</span><span class="o">-&gt;</span><span class="n">offset</span><span class="p">;</span>
    <span class="cm">/* 使用 g-&gt;width, FONT_16_HEIGHT (16), g-&gt;size 进行光栅化 */</span>
<span class="p">}</span>
</code></pre></div></div>

<p>对于仅含 12 个字符（<code class="language-plaintext highlighter-rouge">0123456789.-</code>）的主读数字库 <code class="language-plaintext highlighter-rouge">font_num_40</code>，则直接通过 <code class="language-plaintext highlighter-rouge">font_num_40_index(char ch)</code> 映射为 <code class="language-plaintext highlighter-rouge">0..11</code> 的常数下标，实现 <code class="language-plaintext highlighter-rouge">O(1)</code> 零开销寻址。</p>

<h2 id="多语言运行时切换与缺字安全回退">多语言运行时切换与缺字安全回退</h2>

<p>固件支持<strong>简体中文（0）、英文（1）、台湾繁体中文（2）</strong>三语实时切换：</p>

<ol>
  <li><strong>ASCII 与 <code class="language-plaintext highlighter-rouge">±</code> 共享主字库</strong>：繁体字库 <code class="language-plaintext highlighter-rouge">font_16_tw</code> 和 <code class="language-plaintext highlighter-rouge">font_24_tw</code> 仅存储繁体汉字点阵；绘制繁体字符串遇到 ASCII 字母、数字或 <code class="language-plaintext highlighter-rouge">±</code> 时，自动回落到 <code class="language-plaintext highlighter-rouge">font_16</code> / <code class="language-plaintext highlighter-rouge">font_24</code> 查找，避免重复存储西文字形。</li>
  <li><strong>整句缺字检查与回退保护</strong>：返回繁体 UI 文案前，渲染层会先扫描该 UTF-8 字符串中的每个汉字是否均存在于对应繁体字库中；一旦发现任一字符未收录，整条文案自动回退为对应的简体中文字符串，避免屏幕上出现空白方框或乱码。</li>
  <li><strong>固定边界防溢出</strong>：英文标签使用紧凑缩写（如 <code class="language-plaintext highlighter-rouge">PEAK</code>、<code class="language-plaintext highlighter-rouge">TRACK</code>），与中繁文案共用同一套行高和列边界，切换语言时无需重算菜单布局坐标。</li>
</ol>

<h2 id="在-rgb565-条带画布中光栅化-1-bit-字形">在 RGB565 条带画布中光栅化 1-bit 字形</h2>

<p>在 <code class="language-plaintext highlighter-rouge">240×8</code> 的条带渲染架构下，字形绘制函数 <code class="language-plaintext highlighter-rouge">AppUi_DrawGlyphBits()</code> 只光栅化当前条带 <code class="language-plaintext highlighter-rouge">[stripY, stripY + 7]</code> 与字形外框 <code class="language-plaintext highlighter-rouge">[glyphY, glyphY + glyphH - 1]</code> 相交的那几行：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">void</span> <span class="nf">AppUi_DrawGlyphBits</span><span class="p">(</span><span class="kt">int16_t</span> <span class="n">x</span><span class="p">,</span> <span class="kt">int16_t</span> <span class="n">y</span><span class="p">,</span>
                                <span class="kt">uint8_t</span> <span class="n">width</span><span class="p">,</span> <span class="kt">uint8_t</span> <span class="n">height</span><span class="p">,</span>
                                <span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">bits</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">color</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">uint8_t</span> <span class="n">stride</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)((</span><span class="n">width</span> <span class="o">+</span> <span class="mi">7U</span><span class="p">)</span> <span class="o">&gt;&gt;</span> <span class="mi">3U</span><span class="p">);</span>
    <span class="kt">uint8_t</span> <span class="n">row</span><span class="p">;</span>
    <span class="kt">uint8_t</span> <span class="n">col</span><span class="p">;</span>

    <span class="k">for</span> <span class="p">(</span><span class="n">row</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span> <span class="n">row</span> <span class="o">&lt;</span> <span class="n">height</span><span class="p">;</span> <span class="n">row</span><span class="o">++</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">int16_t</span> <span class="n">drawY</span> <span class="o">=</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)(</span><span class="n">y</span> <span class="o">+</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)</span><span class="n">row</span><span class="p">);</span>

        <span class="cm">/* 仅处理落在当前 8 行条带内的扫描行 */</span>
        <span class="k">if</span> <span class="p">((</span><span class="n">drawY</span> <span class="o">&lt;</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)</span><span class="n">appUiCanvas</span><span class="p">.</span><span class="n">y</span><span class="p">)</span> <span class="o">||</span>
            <span class="p">(</span><span class="n">drawY</span> <span class="o">&gt;=</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)(</span><span class="n">appUiCanvas</span><span class="p">.</span><span class="n">y</span> <span class="o">+</span> <span class="n">appUiCanvas</span><span class="p">.</span><span class="n">height</span><span class="p">)))</span>
        <span class="p">{</span>
            <span class="k">continue</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">rowBits</span> <span class="o">=</span> <span class="n">bits</span> <span class="o">+</span> <span class="p">((</span><span class="kt">uint16_t</span><span class="p">)</span><span class="n">row</span> <span class="o">*</span> <span class="n">stride</span><span class="p">);</span>
        <span class="kt">uint16_t</span> <span class="o">*</span><span class="n">dstRow</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">appUiCanvas</span><span class="p">.</span><span class="n">pixels</span><span class="p">[(</span><span class="kt">uint16_t</span><span class="p">)(</span><span class="n">drawY</span> <span class="o">-</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)</span><span class="n">appUiCanvas</span><span class="p">.</span><span class="n">y</span><span class="p">)</span> <span class="o">*</span> <span class="mi">240U</span><span class="p">];</span>

        <span class="k">for</span> <span class="p">(</span><span class="n">col</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span> <span class="n">col</span> <span class="o">&lt;</span> <span class="n">width</span><span class="p">;</span> <span class="n">col</span><span class="o">++</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="kt">int16_t</span> <span class="n">drawX</span> <span class="o">=</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)(</span><span class="n">x</span> <span class="o">+</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)</span><span class="n">col</span><span class="p">);</span>
            <span class="k">if</span> <span class="p">((</span><span class="n">drawX</span> <span class="o">&gt;=</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="n">drawX</span> <span class="o">&lt;</span> <span class="mi">240</span><span class="p">))</span>
            <span class="p">{</span>
                <span class="k">if</span> <span class="p">((</span><span class="n">rowBits</span><span class="p">[</span><span class="n">col</span> <span class="o">&gt;&gt;</span> <span class="mi">3U</span><span class="p">]</span> <span class="o">&amp;</span> <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)(</span><span class="mh">0x80U</span> <span class="o">&gt;&gt;</span> <span class="p">(</span><span class="n">col</span> <span class="o">&amp;</span> <span class="mi">7U</span><span class="p">)))</span> <span class="o">!=</span> <span class="mi">0U</span><span class="p">)</span>
                <span class="p">{</span>
                    <span class="n">dstRow</span><span class="p">[</span><span class="n">drawX</span><span class="p">]</span> <span class="o">=</span> <span class="n">color</span><span class="p">;</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="unicode-二分查找与字形索引检索">Unicode 二分查找与字形索引检索</h2>

<p>子集化字库按 Unicode 码点升序存储。在嵌入式 C 端，使用二分查找在 <code class="language-plaintext highlighter-rouge">O(log N)</code> 时间内完成字形定位：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* Unicode 升序索引表二分查找字形偏移 */</span>
<span class="kt">int32_t</span> <span class="nf">Font_FindGlyphIndex</span><span class="p">(</span><span class="k">const</span> <span class="kt">uint16_t</span> <span class="o">*</span><span class="n">unicodeTable</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">count</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">codePoint</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">int32_t</span> <span class="n">low</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>
    <span class="kt">int32_t</span> <span class="n">high</span> <span class="o">=</span> <span class="p">(</span><span class="kt">int32_t</span><span class="p">)</span><span class="n">count</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>

    <span class="k">while</span> <span class="p">(</span><span class="n">low</span> <span class="o">&lt;=</span> <span class="n">high</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">int32_t</span> <span class="n">mid</span> <span class="o">=</span> <span class="n">low</span> <span class="o">+</span> <span class="p">((</span><span class="n">high</span> <span class="o">-</span> <span class="n">low</span><span class="p">)</span> <span class="o">&gt;&gt;</span> <span class="mi">1</span><span class="p">);</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">unicodeTable</span><span class="p">[</span><span class="n">mid</span><span class="p">]</span> <span class="o">==</span> <span class="n">codePoint</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">return</span> <span class="n">mid</span><span class="p">;</span> <span class="cm">/* 命中字形偏移索引 */</span>
        <span class="p">}</span>
        <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">unicodeTable</span><span class="p">[</span><span class="n">mid</span><span class="p">]</span> <span class="o">&lt;</span> <span class="n">codePoint</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="n">low</span> <span class="o">=</span> <span class="n">mid</span> <span class="o">+</span> <span class="mi">1</span><span class="p">;</span>
        <span class="p">}</span>
        <span class="k">else</span>
        <span class="p">{</span>
            <span class="n">high</span> <span class="o">=</span> <span class="n">mid</span> <span class="o">-</span> <span class="mi">1</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="o">-</span><span class="mi">1</span><span class="p">;</span> <span class="cm">/* 未收录字符，触发缺字回退 */</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="自动化字库提取与代码生成脚本">自动化字库提取与代码生成脚本</h2>

<p>通过标准 Python 脚本解析 BDF/TTF 字体，提取文案涉及的字符并生成 C99 数组：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># bdf_subset_generator.py (轻量生成流水线核心实现)
</span><span class="k">def</span> <span class="nf">bdf_glyph_to_c_bytes</span><span class="p">(</span><span class="n">hex_lines</span><span class="p">,</span> <span class="n">width</span><span class="p">,</span> <span class="n">height</span><span class="p">):</span>
    <span class="n">stride</span> <span class="o">=</span> <span class="p">(</span><span class="n">width</span> <span class="o">+</span> <span class="mi">7</span><span class="p">)</span> <span class="o">//</span> <span class="mi">8</span>
    <span class="n">c_bytes</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">line</span> <span class="ow">in</span> <span class="n">hex_lines</span><span class="p">:</span>
        <span class="n">row_val</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">line</span><span class="p">,</span> <span class="mi">16</span><span class="p">)</span>
        <span class="c1"># 按 stride 拆分字节，高位在左 (MSB leftmost)
</span>        <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">stride</span> <span class="o">-</span> <span class="mi">1</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="o">-</span><span class="mi">1</span><span class="p">):</span>
            <span class="n">c_bytes</span><span class="p">.</span><span class="n">append</span><span class="p">((</span><span class="n">row_val</span> <span class="o">&gt;&gt;</span> <span class="p">(</span><span class="n">i</span> <span class="o">*</span> <span class="mi">8</span><span class="p">))</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">c_bytes</span>

<span class="k">def</span> <span class="nf">generate_font_c_file</span><span class="p">(</span><span class="n">chars</span><span class="p">,</span> <span class="n">bdf_dict</span><span class="p">,</span> <span class="n">font_name</span><span class="p">,</span> <span class="n">width</span><span class="p">,</span> <span class="n">height</span><span class="p">):</span>
    <span class="c1"># 1. 按 Unicode 码点严格升序排序
</span>    <span class="n">sorted_chars</span> <span class="o">=</span> <span class="nb">sorted</span><span class="p">(</span><span class="nb">list</span><span class="p">(</span><span class="nb">set</span><span class="p">(</span><span class="n">chars</span><span class="p">)),</span> <span class="n">key</span><span class="o">=</span><span class="k">lambda</span> <span class="n">c</span><span class="p">:</span> <span class="nb">ord</span><span class="p">(</span><span class="n">c</span><span class="p">))</span>
    <span class="n">stride</span> <span class="o">=</span> <span class="p">(</span><span class="n">width</span> <span class="o">+</span> <span class="mi">7</span><span class="p">)</span> <span class="o">//</span> <span class="mi">8</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"/* 自动生成的 </span><span class="si">{</span><span class="n">font_name</span><span class="si">}</span><span class="s"> 1-bit 点阵字库 */"</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"const uint16_t </span><span class="si">{</span><span class="n">font_name</span><span class="si">}</span><span class="s">_Index[</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">sorted_chars</span><span class="p">)</span><span class="si">}</span><span class="s">] = {{"</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"    "</span> <span class="o">+</span> <span class="s">", "</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="sa">f</span><span class="s">"0x</span><span class="si">{</span><span class="nb">ord</span><span class="p">(</span><span class="n">c</span><span class="p">)</span><span class="si">:</span><span class="mi">04</span><span class="n">X</span><span class="si">}</span><span class="s">"</span> <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">sorted_chars</span><span class="p">))</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"};"</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"const uint8_t </span><span class="si">{</span><span class="n">font_name</span><span class="si">}</span><span class="s">_Bitmaps[</span><span class="si">{</span><span class="nb">len</span><span class="p">(</span><span class="n">sorted_chars</span><span class="p">)</span><span class="si">}</span><span class="s"> * </span><span class="si">{</span><span class="n">stride</span> <span class="o">*</span> <span class="n">height</span><span class="si">}</span><span class="s">] = {{"</span><span class="p">)</span>
    <span class="k">for</span> <span class="n">c</span> <span class="ow">in</span> <span class="n">sorted_chars</span><span class="p">:</span>
        <span class="n">bytes_list</span> <span class="o">=</span> <span class="n">bdf_glyph_to_c_bytes</span><span class="p">(</span><span class="n">bdf_dict</span><span class="p">[</span><span class="n">c</span><span class="p">],</span> <span class="n">width</span><span class="p">,</span> <span class="n">height</span><span class="p">)</span>
        <span class="n">hex_str</span> <span class="o">=</span> <span class="s">", "</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="sa">f</span><span class="s">"0x</span><span class="si">{</span><span class="n">b</span><span class="si">:</span><span class="mi">02</span><span class="n">X</span><span class="si">}</span><span class="s">"</span> <span class="k">for</span> <span class="n">b</span> <span class="ow">in</span> <span class="n">bytes_list</span><span class="p">)</span>
        <span class="k">print</span><span class="p">(</span><span class="sa">f</span><span class="s">"    /* '</span><span class="si">{</span><span class="n">c</span><span class="si">}</span><span class="s">' U+</span><span class="si">{</span><span class="nb">ord</span><span class="p">(</span><span class="n">c</span><span class="p">)</span><span class="si">:</span><span class="mi">04</span><span class="n">X</span><span class="si">}</span><span class="s"> */ </span><span class="si">{</span><span class="n">hex_str</span><span class="si">}</span><span class="s">,"</span><span class="p">)</span>
    <span class="k">print</span><span class="p">(</span><span class="s">"};"</span><span class="p">)</span>
</code></pre></div></div>

<p>生成后的工程核查要点：</p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />确认新增字符已全部收录在升序索引表中，且生成器无字符越界或缺字报警。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />检查字模预览图，确认极小字号繁体字无笔画重叠粘连，且西文与中文字符基线对齐一致。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />固件支持整句缺字回退保护机制，当目标语言字库缺失任一汉字时安全降级，避免屏幕显示乱码或空白块。</li>
</ul>]]></content><author><name></name></author><category term="嵌入式" /><category term="点阵字库" /><category term="多语言UI" /><category term="N32G455" /><category term="LCD" /><category term="Python脚本" /><summary type="html"><![CDATA[面向小内存 MCU 的多语言（简体中文、台湾繁体、英文）1-bit 点阵字库生成流水线，涵盖字号规划、原生点阵防粘连、Unicode 二分查找表与零缓冲条带光栅化。]]></summary></entry><entry><title type="html">Cortex-M4 裸机固件分频时间片调度与模块化重构方案</title><link href="https://single5240.github.io/articles/baremetal-firmware-scheduler-and-refactoring.html" rel="alternate" type="text/html" title="Cortex-M4 裸机固件分频时间片调度与模块化重构方案" /><published>2026-10-14T00:00:00+08:00</published><updated>2026-10-14T00:00:00+08:00</updated><id>https://single5240.github.io/articles/baremetal-firmware-scheduler-and-refactoring</id><content type="html" xml:base="https://single5240.github.io/articles/baremetal-firmware-scheduler-and-refactoring.html"><![CDATA[<p>在基于 Cortex-M4（如 128 MHz、零动态内存 <code class="language-plaintext highlighter-rouge">malloc/free</code>）的中小型嵌入式设备中，主循环与 UI 逻辑容易随着功能迭代膨胀为数千行的“巨石文件”，引发周期抖动、按键迟滞与显示阻塞。本文提炼一套通用的裸机分频时间片协作调度架构，通过四级周期分频、无锁静态环形事件总线、页面生命周期契约路由以及 Flash 表驱动菜单引擎，实现业务逻辑、高频测控与条带渲染的彻底解耦。底层状态机与外设驱动细节可结合阅读 <a href="/articles/c99-table-driven-state-machine.html">裸机 C99 表驱动有限状态机设计与复用指南</a> 与 <a href="/articles/w25q128-spi-flash-driver-guide.html">N32G455 W25Q128 SPI NOR Flash 驱动与非阻塞擦写实践</a>。</p>

<h2 id="运行环境与现有问题">运行环境与现有问题</h2>

<h3 id="硬件与资源基线">硬件与资源基线</h3>

<p>固件运行于裸机环境，SysTick 提供 1 ms 基础时基，主循环通过协作式大循环（Super-Loop）轮询调度各业务与驱动模块：</p>

<ul>
  <li><strong>Flash (ROM) / SRAM</strong>：典型配置如 512 KB Flash / 144 KB SRAM，全程禁止动态堆内存分配；</li>
  <li><strong>显示通路</strong>：240×320 彩屏，采用 8 行条带局部画布（3.84 KB RAM）结合 SPI DMA 异步传输；</li>
  <li><strong>硬实时传感器</strong>：高精度 ADC 运行于 SPI DMA + 外部引脚中断（高抢占优先级）。</li>
</ul>

<h3 id="常见架构痛点">常见架构痛点</h3>

<ol>
  <li><strong>UI 逻辑过载与巨石文件</strong>：单文件容易达到数千行，页面跳转、测量主页、设置子页、参数校准、字库光栅化与绘图流水线深度耦合，难以维护与独立测试。</li>
  <li><strong>主循环周期无序抖动（Cycle Jitter）</strong>：<code class="language-plaintext highlighter-rouge">main.c</code> 的 <code class="language-plaintext highlighter-rouge">while(1)</code> 平铺串行调用所有模块的 <code class="language-plaintext highlighter-rouge">Process()</code>，缺乏执行周期分频与时间片预算。慢速任务（如存储操作或复杂运算）瞬时拉长单圈耗时，导致 1 ms 按键消抖状态机产生迟滞。</li>
  <li><strong>外部 NOR Flash 物理擦写阻塞风险</strong>：SPI Flash 扇区擦除耗时数十至数百毫秒，若在主循环中同步等待 <code class="language-plaintext highlighter-rouge">BUSY</code>，会挤压传感器数据消费与看门狗喂狗窗口。</li>
  <li><strong>模块间隐式耦合与硬编码菜单</strong>：各业务模块通过跨层读写全局结构体交互；UI 菜单项的坐标、高亮、步进加减全部硬编码计算，新增参数项需要改动多处绘图代码。</li>
</ol>

<h3 id="重构设计目标">重构设计目标</h3>

<ul>
  <li><strong>行为兼容</strong>：保持既有测量逻辑、按键交互手感与存储二进制格式；</li>
  <li><strong>零动态内存</strong>：全程禁止 <code class="language-plaintext highlighter-rouge">malloc/free</code>，所有新增队列、路由表与配置表均驻留静态 SRAM 或 <code class="language-plaintext highlighter-rouge">const</code> Flash；</li>
  <li><strong>耗时目标</strong>：主循环常见单圈耗时控制在 <code class="language-plaintext highlighter-rouge">&lt; 2 ms</code>，减少按键响应抖动，具体耗时需上板测量；</li>
  <li><strong>职责拆分</strong>：将绘图、页面切换和业务处理拆开，通过接口交互。</li>
</ul>

<h2 id="模块划分">模块划分</h2>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+-------------------------------------------------------------------------+
|                    分频节拍调度器 (main.c / Sched_Run)                   |
|  [1 ms 硬实时]     [10 ms 测控解算]    [100 ms 低频状态]   [Idle 分片]  |
|  按键扫描/消抖     AD7190队列/峰值判定  电池ADC/方向/时钟   UI条带/Flash |
+--------+-------------------+-------------------+----------------+-------+
         |                   |                   |                |
         v                   v                   v                |
+------------------------------------------------------------+    |
|          静态无锁环形事件总线 (app_event / Ring Buffer)      |    |
|    生产者: 按键单击/长按、超载报警、记录保存完成、标定事件     |    |
+----------------------------+-------------------------------+    |
                             | AppEvent_Poll                      |
                             v                                    v
+-------------------------------------------------------------------------+
|                     UI 子系统解耦与表驱动渲染管线                         |
|  app_ui_router (顶层页面路由 + 顶栏/底栏公共区绘制)                      |
|    ├─► app_ui_page_main   (测量主页: 40px 读数 + 动态量程波形)           |
|    ├─► app_ui_page_menu   (通用表驱动菜单引擎 + Flash const 配置表)      |
|    ├─► app_ui_page_calib  (传感器六点标定向导)                           |
|    └─► app_ui_page_record (测试记录分页浏览与单条/整表删除)              |
|                             │                                           |
|                             ▼                                           |
|  app_ui_engine (240×8 条带裁剪画布 + 字库光栅化 + 基础图元)               |
+-------------------------------------------------------------------------+
</code></pre></div></div>

<h2 id="一按周期调度主循环任务">一、按周期调度主循环任务</h2>

<h3 id="四级任务时间片模型">四级任务时间片模型</h3>

<p>将平铺在 <code class="language-plaintext highlighter-rouge">main()</code> 中的约 20 个 <code class="language-plaintext highlighter-rouge">Process()</code> 按实时性敏感度划分为 4 个等级：</p>

<table>
  <thead>
    <tr>
      <th>等级</th>
      <th style="text-align: center">调度周期</th>
      <th>覆盖模块</th>
      <th>耗时预算与设计约束</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Level 0：硬实时输入</strong></td>
      <td style="text-align: center"><strong>1 ms</strong></td>
      <td><code class="language-plaintext highlighter-rouge">AppButtons_Process()</code>、<code class="language-plaintext highlighter-rouge">AppController_Process()</code></td>
      <td>单次耗时 <code class="language-plaintext highlighter-rouge">&lt; 50 μs</code>，确保按键消抖与长按计时无累积误差</td>
    </tr>
    <tr>
      <td><strong>Level 1：测控与算法</strong></td>
      <td style="text-align: center"><strong>10 ms</strong></td>
      <td><code class="language-plaintext highlighter-rouge">AppAd7190_Process()</code>、<code class="language-plaintext highlighter-rouge">AppTest_Process()</code>、<code class="language-plaintext highlighter-rouge">Buzzer_Process()</code></td>
      <td>消费传感器环形队列、力值滤波、峰值/破断判定与蜂鸣器节拍，单次 <code class="language-plaintext highlighter-rouge">&lt; 200 μs</code></td>
    </tr>
    <tr>
      <td><strong>Level 2：低频状态量</strong></td>
      <td style="text-align: center"><strong>100 ms</strong></td>
      <td><code class="language-plaintext highlighter-rouge">BatteryAdc_Process()</code>、<code class="language-plaintext highlighter-rouge">DirAdc_Process()</code>、<code class="language-plaintext highlighter-rouge">AppState_Process()</code></td>
      <td>电池电量、方向重力检测、页面 10 Hz 脏检查</td>
    </tr>
    <tr>
      <td><strong>Level 3：空闲分片任务</strong></td>
      <td style="text-align: center"><strong>自适应轮询</strong></td>
      <td><code class="language-plaintext highlighter-rouge">LcdSt7789_Process()</code>、<code class="language-plaintext highlighter-rouge">AppUi_Process()</code>、<code class="language-plaintext highlighter-rouge">AppRecordStore_Process()</code></td>
      <td>每圈最多推进 1 个 8 行 LCD 条带和 1 次 Flash <code class="language-plaintext highlighter-rouge">BUSY</code> 状态查询，严禁阻塞</td>
    </tr>
  </tbody>
</table>

<h3 id="用无符号差值判断周期">用无符号差值判断周期</h3>

<p>用 <code class="language-plaintext highlighter-rouge">uint32_t</code> 无符号差值判断周期，可以处理毫秒计数器约 49.7 天后的回绕。这里要求两次检查之间不跨过完整的计数周期；分频本身不能消除任务执行造成的延迟：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">void</span> <span class="nf">Sched_Run</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">uint32_t</span> <span class="n">currentTick</span> <span class="o">=</span> <span class="n">PlatformTime_GetMs</span><span class="p">();</span>

    <span class="cm">/* Level 0: 1 ms 高频输入检测 */</span>
    <span class="k">if</span> <span class="p">((</span><span class="kt">uint32_t</span><span class="p">)(</span><span class="n">currentTick</span> <span class="o">-</span> <span class="n">s_lastTick1ms</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="mi">1U</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">s_lastTick1ms</span> <span class="o">=</span> <span class="n">currentTick</span><span class="p">;</span>
        <span class="n">AppButtons_Process</span><span class="p">();</span>
        <span class="n">AppController_Process</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="cm">/* Level 1: 10 ms 测控解算与报警节拍 */</span>
    <span class="k">if</span> <span class="p">((</span><span class="kt">uint32_t</span><span class="p">)(</span><span class="n">currentTick</span> <span class="o">-</span> <span class="n">s_lastTick10ms</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="mi">10U</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">s_lastTick10ms</span> <span class="o">=</span> <span class="n">currentTick</span><span class="p">;</span>
        <span class="n">AppAd7190_Process</span><span class="p">();</span>
        <span class="n">AppTest_Process</span><span class="p">();</span>
        <span class="n">Buzzer_Process</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="cm">/* Level 2: 100 ms 慢速状态采集 */</span>
    <span class="k">if</span> <span class="p">((</span><span class="kt">uint32_t</span><span class="p">)(</span><span class="n">currentTick</span> <span class="o">-</span> <span class="n">s_lastTick100ms</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="mi">100U</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">s_lastTick100ms</span> <span class="o">=</span> <span class="n">currentTick</span><span class="p">;</span>
        <span class="n">BatteryAdc_Process</span><span class="p">();</span>
        <span class="n">DirAdc_Process</span><span class="p">();</span>
        <span class="n">AppState_Process</span><span class="p">();</span>
    <span class="p">}</span>

    <span class="cm">/* Level 3: 非阻塞背景分片推进（LCD 条带 DMA 收尾/提交 + Flash 状态轮询） */</span>
    <span class="n">LcdSt7789_Process</span><span class="p">();</span>
    <span class="n">AppUi_Process</span><span class="p">();</span>
    <span class="n">AppRecordStore_Process</span><span class="p">();</span>

    <span class="cm">/* 主循环唯一安全喂狗点 */</span>
    <span class="n">Iwdg_Feed</span><span class="p">();</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="交互式在线演示四级分频协作调度器与无锁环形总线">交互式在线演示：四级分频协作调度器与无锁环形总线</h3>

<p>下面的演示按 1 ms、10 ms、100 ms 推进任务，并在空闲时刷新条带。点击按键或超载按钮可以向队列投递事件，再单步观察任务执行和事件消费。演示用于说明调度顺序，不代表实板耗时。</p>

<div class="article-interactive-widget" id="sched-demo-app" aria-label="四级分频时间片调度器交互演示">
  <div class="aiw-header">
    <span class="aiw-badge">INTERACTIVE WIDGET · 裸机调度沙箱</span>
    <h3 class="aiw-title">四级分频时间片协作调度器与静态环形总线演示</h3>
    <p class="aiw-desc">仿真 <code>Sched_Run()</code> 调度模型：Level 0 (1ms) 消抖、Level 1 (10ms) 测控解算、Level 2 (100ms) 状态量、Level 3 空闲分片 LCD 条带渲染与 10 槽无锁环形事件解耦。</p>
  </div>
  <div class="aiw-body">
    <div class="aiw-stats" aria-label="调度器全局指标">
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">SysTick 时钟基准</span>
        <span class="aiw-stat-value accent" id="sched-stat-tick">0 ms</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">各级任务触发统计</span>
        <span class="aiw-stat-value" id="sched-stat-cycle">L0:0 | L1:0 | L2:0</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">LCD 8行条带分片渲染</span>
        <span class="aiw-stat-value warn" id="sched-stat-strip">条带 1/40 (行 0..7)</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">环形事件总线占用</span>
        <span class="aiw-stat-value" id="sched-stat-q">0 / 10 (空闲)</span>
      </div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span>四级任务分频执行通道 (Task Frequency Levels)</span>
        <span class="aiw-hint">绿色指示灯闪烁表示当前 Tick 满足周期判定并执行</span>
      </div>
      <div class="sched-levels-grid">
        <div class="sched-level-card" id="sched-card-l0">
          <div class="sched-level-top">
            <span class="sched-level-title">Level 0: 1 ms 硬实时</span>
            <span class="sched-level-indicator"></span>
          </div>
          <div class="sched-level-func">AppButtons_Process()</div>
          <div class="sched-level-meta">
            <span>按键消抖/长按</span>
            <span>预算 &lt; 50 μs</span>
          </div>
        </div>

        <div class="sched-level-card" id="sched-card-l1">
          <div class="sched-level-top">
            <span class="sched-level-title">Level 1: 10 ms 测控</span>
            <span class="sched-level-indicator"></span>
          </div>
          <div class="sched-level-func">AppAd7190_Process()</div>
          <div class="sched-level-meta">
            <span>力值滤波/破断判定</span>
            <span>预算 &lt; 200 μs</span>
          </div>
        </div>

        <div class="sched-level-card" id="sched-card-l2">
          <div class="sched-level-top">
            <span class="sched-level-title">Level 2: 100 ms 慢速</span>
            <span class="sched-level-indicator"></span>
          </div>
          <div class="sched-level-func">BatteryAdc_Process()</div>
          <div class="sched-level-meta">
            <span>电量/重力/10Hz脏检查</span>
            <span>预算 &lt; 300 μs</span>
          </div>
        </div>

        <div class="sched-level-card" id="sched-card-l3">
          <div class="sched-level-top">
            <span class="sched-level-title">Level 3: 空闲分片</span>
            <span class="sched-level-indicator"></span>
          </div>
          <div class="sched-level-func">AppUi_Process()</div>
          <div class="sched-level-meta">
            <span>8行条带/Flash查询</span>
            <span>非阻塞背景分片</span>
          </div>
        </div>
      </div>
    </div>

    <div class="sched-strip-box">
      <div class="aiw-section-title">
        <span>240×320 屏幕条带渐进刷新管线 (每圈推进 8 行，40 次完成整帧)</span>
        <span class="aiw-hint">消除整屏 153.6 KB 显存占用，仅用 3.84 KB 局部画布</span>
      </div>
      <div class="sched-strip-bar">
        <div class="sched-strip-fill" id="sched-strip-fill" style="width: 2.5%;"></div>
      </div>
    </div>

    <div class="sched-ring-box">
      <div class="aiw-section-title">
        <span>静态无锁环形事件总线 (app_event / Ring Buffer，容量: 10)</span>
        <span class="aiw-hint">Head: 生产者写入 · Tail: 消费者(UI)读取</span>
      </div>
      <div class="sched-ring-track" id="sched-ring-track"></div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span>事件产生 (模拟物理输入与中断)</span>
        <span class="aiw-hint">将异步事件写入环形缓冲区</span>
      </div>
      <div class="aiw-controls">
        <button type="button" class="aiw-btn primary" id="sched-btn-key-click">产生按键单击 (KEY_CLICK)</button>
        <button type="button" class="aiw-btn" id="sched-btn-key-long">产生按键长按 (KEY_LONG)</button>
        <button type="button" class="aiw-btn danger" id="sched-btn-alarm">产生超载急停 (OVERLOAD)</button>
        <button type="button" class="aiw-btn warn" id="sched-btn-burst">突发并发投递 (测试防溢出)</button>
      </div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span>时基步进控制 (SysTick 时钟推进)</span>
        <span class="aiw-hint">以无符号差值驱动 Sched_Run()</span>
      </div>
      <div class="aiw-controls">
        <button type="button" class="aiw-btn primary" id="sched-btn-step1">单步推进 1 ms</button>
        <button type="button" class="aiw-btn" id="sched-btn-step10">步进 10 ms (触发 L0+L1)</button>
        <button type="button" class="aiw-btn" id="sched-btn-step100">步进 100 ms (全等级协同)</button>
        <button type="button" class="aiw-btn" id="sched-btn-run">连续运行</button>
        <button type="button" class="aiw-btn" id="sched-btn-reset">重置时基</button>
      </div>
    </div>

    <div class="aiw-log-panel">
      <div class="aiw-section-title">
        <span>调度执行流与事件消费追踪 (Scheduler Trace)</span>
        <span class="aiw-hint">展示分频调度与环形总线解耦优势</span>
      </div>
      <div class="aiw-log" id="sched-log" aria-live="polite"></div>
    </div>
  </div>
</div>

<h2 id="二拆分-ui-文件和页面接口">二、拆分 UI 文件和页面接口</h2>

<h3 id="模块职责切分">模块职责切分</h3>

<p>将巨石 UI 模块按“渲染引擎 + 页面路由 + 独立业务页”拆分为分层子系统：</p>

<table>
  <thead>
    <tr>
      <th>模块名称</th>
      <th>核心职责</th>
      <th>外部依赖边界</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ui_engine</code></td>
      <td><code class="language-plaintext highlighter-rouge">240×8</code> 条带画布管理、1-bit 字库光栅化（16/24/40 px）、折线与矩形条带裁剪</td>
      <td>仅依赖底层 LCD 驱动与字库常量</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ui_router</code></td>
      <td>顶层页面切换状态机、按键事件分发、顶部公共状态栏与底部按键提示栏绘制</td>
      <td>依赖抽象 <code class="language-plaintext highlighter-rouge">UiPageInterface</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ui_page_main</code></td>
      <td>测量主页：大字体实时读数、主/次峰值、公差判定指示、动态量程波形曲线</td>
      <td>依赖运行时测量与波形数据</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ui_page_menu</code></td>
      <td>通用表驱动菜单渲染器与基本设定、系统设定等配置表</td>
      <td>依赖设置参数管理组件</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ui_page_calib</code></td>
      <td>传感器标定向导：密码校验、零点与多段载荷采集、保存回退提示</td>
      <td>依赖校准算法模块</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">ui_page_record</code></td>
      <td>测试记录清单：分页浏览、单条选择删除、长按 5 秒整表清空确认</td>
      <td>依赖历史存储引擎</td>
    </tr>
  </tbody>
</table>

<h3 id="页面生命周期契约uipageinterface">页面生命周期契约（<code class="language-plaintext highlighter-rouge">UiPageInterface</code>）</h3>

<p>为消除页面与路由器之间的双向循环依赖，所有业务页面统一实现如下函数指针表：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="kt">void</span> <span class="p">(</span><span class="o">*</span><span class="n">OnEnter</span><span class="p">)(</span><span class="kt">void</span><span class="p">);</span>                                      <span class="cm">/* 进入页面时初始化局部状态 */</span>
    <span class="n">bool</span> <span class="p">(</span><span class="o">*</span><span class="n">OnEvent</span><span class="p">)(</span><span class="k">const</span> <span class="n">AppEvent</span> <span class="o">*</span><span class="n">event</span><span class="p">);</span>                     <span class="cm">/* 处理按键/系统事件，返回 true 表示已消费 */</span>
    <span class="kt">void</span> <span class="p">(</span><span class="o">*</span><span class="n">OnDrawStrip</span><span class="p">)(</span><span class="kt">uint16_t</span> <span class="n">stripY</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">stripHeight</span><span class="p">);</span> <span class="cm">/* 绘制当前 8 行条带内的页面内容 */</span>
    <span class="kt">void</span> <span class="p">(</span><span class="o">*</span><span class="n">OnExit</span><span class="p">)(</span><span class="kt">void</span><span class="p">);</span>                                       <span class="cm">/* 离开页面时清理或触发保存 */</span>
<span class="p">}</span> <span class="n">UiPageInterface</span><span class="p">;</span>
</code></pre></div></div>

<p>顶层路由器调度页面切换与事件分发，并内置“跨页物理释放锁存（Wait-Release Latch）”，防止长按切页误触发新页面的长按操作：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="k">const</span> <span class="n">UiPageInterface</span> <span class="o">*</span><span class="n">s_currentPage</span> <span class="o">=</span> <span class="nb">NULL</span><span class="p">;</span>
<span class="k">static</span> <span class="n">bool</span> <span class="n">s_waitReleaseLatch</span> <span class="o">=</span> <span class="nb">false</span><span class="p">;</span>

<span class="kt">void</span> <span class="nf">UiRouter_Switch</span><span class="p">(</span><span class="k">const</span> <span class="n">UiPageInterface</span> <span class="o">*</span><span class="n">newPage</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">((</span><span class="n">newPage</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">newPage</span> <span class="o">==</span> <span class="n">s_currentPage</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">s_currentPage</span> <span class="o">!=</span> <span class="nb">NULL</span> <span class="o">&amp;&amp;</span> <span class="n">s_currentPage</span><span class="o">-&gt;</span><span class="n">OnExit</span> <span class="o">!=</span> <span class="nb">NULL</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">s_currentPage</span><span class="o">-&gt;</span><span class="n">OnExit</span><span class="p">();</span>
    <span class="p">}</span>
    <span class="n">s_currentPage</span> <span class="o">=</span> <span class="n">newPage</span><span class="p">;</span>
    <span class="cm">/* 跨页防误触锁存：切页后必须等待所有物理按键松开，才允许新页面响应下一次按键 */</span>
    <span class="n">s_waitReleaseLatch</span> <span class="o">=</span> <span class="nb">true</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">s_currentPage</span><span class="o">-&gt;</span><span class="n">OnEnter</span> <span class="o">!=</span> <span class="nb">NULL</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">s_currentPage</span><span class="o">-&gt;</span><span class="n">OnEnter</span><span class="p">();</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>当条带渲染管线遍历 40 个条带（<code class="language-plaintext highlighter-rouge">stripY</code> 从 <code class="language-plaintext highlighter-rouge">0</code> 到 <code class="language-plaintext highlighter-rouge">312</code>，步进 <code class="language-plaintext highlighter-rouge">8</code>）时，由 <code class="language-plaintext highlighter-rouge">ui_router</code> 统一编排：</p>

<ol>
  <li>绘制全局公共顶部状态栏（<code class="language-plaintext highlighter-rouge">y ∈ [0, 23]</code>）；</li>
  <li>调用当前活动页面的 <code class="language-plaintext highlighter-rouge">OnDrawStrip(stripY, 8)</code> 绘制中部业务区；</li>
  <li>绘制全局公共底部操作提示栏（<code class="language-plaintext highlighter-rouge">y ∈ [296, 319]</code>）。</li>
</ol>

<h2 id="三用只读表描述菜单">三、用只读表描述菜单</h2>

<p>用 <code class="language-plaintext highlighter-rouge">const MenuItemConfig</code> 表描述菜单项并放入 Flash，由通用渲染器计算坐标和反色框，减少逐项手写的重复代码：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">enum</span>
<span class="p">{</span>
    <span class="n">MENU_TYPE_SUBPAGE</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span>   <span class="cm">/* 子菜单跳转入口 */</span>
    <span class="n">MENU_TYPE_INT_RANGE</span><span class="p">,</span>     <span class="cm">/* 整数范围步进调节（如背光亮度、归零时间） */</span>
    <span class="n">MENU_TYPE_ENUM_CYCLE</span><span class="p">,</span>    <span class="cm">/* 枚举列表循环切换（如单位、测试模式、语言） */</span>
    <span class="n">MENU_TYPE_ACTION_CONFIRM</span> <span class="cm">/* 动作二次确认弹窗（如恢复默认设置） */</span>
<span class="p">}</span> <span class="n">MenuItemType</span><span class="p">;</span>

<span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="k">const</span> <span class="kt">char</span>   <span class="o">*</span><span class="n">label</span><span class="p">;</span>           <span class="cm">/* 菜单项标签（或三语词条 ID） */</span>
    <span class="n">MenuItemType</span>  <span class="n">type</span><span class="p">;</span>            <span class="cm">/* 控件类型 */</span>
    <span class="kt">void</span>         <span class="o">*</span><span class="n">boundValue</span><span class="p">;</span>      <span class="cm">/* 绑定的设置变量指针 */</span>
    <span class="kt">int32_t</span>       <span class="n">minValue</span><span class="p">;</span>        <span class="cm">/* 最小值 */</span>
    <span class="kt">int32_t</span>       <span class="n">maxValue</span><span class="p">;</span>        <span class="cm">/* 最大值 */</span>
    <span class="kt">int32_t</span>       <span class="n">stepValue</span><span class="p">;</span>       <span class="cm">/* 步进值 */</span>
    <span class="k">const</span> <span class="kt">char</span>  <span class="o">**</span><span class="n">enumDisplayList</span><span class="p">;</span> <span class="cm">/* 枚举显示文本表 */</span>
    <span class="kt">void</span>        <span class="p">(</span><span class="o">*</span><span class="n">onChanged</span><span class="p">)(</span><span class="kt">void</span><span class="p">);</span><span class="cm">/* 修改即时生效回调 */</span>
<span class="p">}</span> <span class="n">MenuItemConfig</span><span class="p">;</span>
</code></pre></div></div>

<p>新增或调整一个菜单项只需在 <code class="language-plaintext highlighter-rouge">const MenuItemConfig[]</code> 数组中添加一行声明；通用的菜单页面渲染器负责焦点移动、视口滚动、编辑态反色高亮、数值边界夹紧与退出保存：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">void</span> <span class="nf">Menu_DrawStrip</span><span class="p">(</span><span class="k">const</span> <span class="n">MenuItemConfig</span> <span class="o">*</span><span class="n">items</span><span class="p">,</span> <span class="kt">uint8_t</span> <span class="n">count</span><span class="p">,</span> <span class="kt">uint8_t</span> <span class="n">focusIndex</span><span class="p">,</span>
                    <span class="kt">uint16_t</span> <span class="n">stripY</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">stripHeight</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">for</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="n">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">count</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="kt">int16_t</span> <span class="n">itemY</span> <span class="o">=</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)(</span><span class="n">MENU_TOP_OFFSET</span> <span class="o">+</span> <span class="n">i</span> <span class="o">*</span> <span class="n">MENU_ITEM_HEIGHT</span><span class="p">);</span>
        <span class="cm">/* 条带裁剪：仅当菜单项垂直区间落在当前条带内时才执行绘制 */</span>
        <span class="k">if</span> <span class="p">((</span><span class="n">itemY</span> <span class="o">+</span> <span class="n">MENU_ITEM_HEIGHT</span> <span class="o">&lt;=</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)</span><span class="n">stripY</span><span class="p">)</span> <span class="o">||</span>
            <span class="p">(</span><span class="n">itemY</span> <span class="o">&gt;=</span> <span class="p">(</span><span class="kt">int16_t</span><span class="p">)(</span><span class="n">stripY</span> <span class="o">+</span> <span class="n">stripHeight</span><span class="p">)))</span>
        <span class="p">{</span>
            <span class="k">continue</span><span class="p">;</span>
        <span class="p">}</span>
        <span class="n">bool</span> <span class="n">isFocus</span> <span class="o">=</span> <span class="p">(</span><span class="n">i</span> <span class="o">==</span> <span class="n">focusIndex</span><span class="p">);</span>
        <span class="cm">/* 在条带画布中渲染菜单文本、反色高亮框与当前绑定数值 */</span>
        <span class="n">Canvas_DrawMenuItem</span><span class="p">(</span><span class="n">items</span><span class="p">[</span><span class="n">i</span><span class="p">].</span><span class="n">label</span><span class="p">,</span> <span class="n">itemY</span><span class="p">,</span> <span class="n">isFocus</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="四用静态队列传递事件">四、用静态队列传递事件</h2>

<p>为避免按键、采样报警、存储完成等模块直接跨层修改 UI 状态，在 SRAM 中开辟固定 16 槽位的环形事件队列：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">enum</span>
<span class="p">{</span>
    <span class="n">EVT_NONE</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span>
    <span class="n">EVT_KEY_CLICK</span><span class="p">,</span>       <span class="cm">/* 参数: KeyID */</span>
    <span class="n">EVT_KEY_LONG_PRESS</span><span class="p">,</span>  <span class="cm">/* 参数: KeyID */</span>
    <span class="n">EVT_KEY_RELEASE_ALL</span><span class="p">,</span> <span class="cm">/* 全部按键物理松开 */</span>
    <span class="n">EVT_DATA_ALARM</span><span class="p">,</span>      <span class="cm">/* 参数: 告警方向与数值 */</span>
    <span class="n">EVT_RECORD_SAVED</span><span class="p">,</span>    <span class="cm">/* 参数: 记录槽位或状态码 */</span>
    <span class="n">EVT_BATTERY_LOW</span><span class="p">,</span>     <span class="cm">/* 参数: 剩余电量百分比 */</span>
    <span class="n">EVT_CALIB_DONE</span>       <span class="cm">/* 参数: 标定结果状态码 */</span>
<span class="p">}</span> <span class="n">AppEventId</span><span class="p">;</span>

<span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="n">AppEventId</span> <span class="n">id</span><span class="p">;</span>
    <span class="kt">uint16_t</span>   <span class="n">param1</span><span class="p">;</span>
    <span class="kt">uint32_t</span>   <span class="n">param2</span><span class="p">;</span>
<span class="p">}</span> <span class="n">AppEvent</span><span class="p">;</span>
</code></pre></div></div>

<p>完整的环形队列收发逻辑如下，实现生产者与消费者的零动态内存解耦：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#define EVENT_QUEUE_CAPACITY (16U)
</span>
<span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="n">AppEvent</span> <span class="n">buffer</span><span class="p">[</span><span class="n">EVENT_QUEUE_CAPACITY</span><span class="p">];</span>
    <span class="kt">uint8_t</span>  <span class="n">head</span><span class="p">;</span>
    <span class="kt">uint8_t</span>  <span class="n">tail</span><span class="p">;</span>
    <span class="kt">uint8_t</span>  <span class="n">count</span><span class="p">;</span>
<span class="p">}</span> <span class="n">EventQueue</span><span class="p">;</span>

<span class="k">static</span> <span class="n">EventQueue</span> <span class="n">s_eventQueue</span><span class="p">;</span>

<span class="n">bool</span> <span class="nf">AppEvent_Post</span><span class="p">(</span><span class="n">AppEventId</span> <span class="n">id</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">param1</span><span class="p">,</span> <span class="kt">uint32_t</span> <span class="n">param2</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">s_eventQueue</span><span class="p">.</span><span class="n">count</span> <span class="o">&gt;=</span> <span class="n">EVENT_QUEUE_CAPACITY</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span> <span class="cm">/* 队列满，丢弃并报警 */</span>
    <span class="p">}</span>
    <span class="n">s_eventQueue</span><span class="p">.</span><span class="n">buffer</span><span class="p">[</span><span class="n">s_eventQueue</span><span class="p">.</span><span class="n">tail</span><span class="p">].</span><span class="n">id</span> <span class="o">=</span> <span class="n">id</span><span class="p">;</span>
    <span class="n">s_eventQueue</span><span class="p">.</span><span class="n">buffer</span><span class="p">[</span><span class="n">s_eventQueue</span><span class="p">.</span><span class="n">tail</span><span class="p">].</span><span class="n">param1</span> <span class="o">=</span> <span class="n">param1</span><span class="p">;</span>
    <span class="n">s_eventQueue</span><span class="p">.</span><span class="n">buffer</span><span class="p">[</span><span class="n">s_eventQueue</span><span class="p">.</span><span class="n">tail</span><span class="p">].</span><span class="n">param2</span> <span class="o">=</span> <span class="n">param2</span><span class="p">;</span>
    <span class="n">s_eventQueue</span><span class="p">.</span><span class="n">tail</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)((</span><span class="n">s_eventQueue</span><span class="p">.</span><span class="n">tail</span> <span class="o">+</span> <span class="mi">1U</span><span class="p">)</span> <span class="o">%</span> <span class="n">EVENT_QUEUE_CAPACITY</span><span class="p">);</span>
    <span class="n">s_eventQueue</span><span class="p">.</span><span class="n">count</span><span class="o">++</span><span class="p">;</span>
    <span class="k">return</span> <span class="nb">true</span><span class="p">;</span>
<span class="p">}</span>

<span class="n">bool</span> <span class="nf">AppEvent_Poll</span><span class="p">(</span><span class="n">AppEvent</span> <span class="o">*</span><span class="n">outEvent</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">((</span><span class="n">s_eventQueue</span><span class="p">.</span><span class="n">count</span> <span class="o">==</span> <span class="mi">0U</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">outEvent</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="o">*</span><span class="n">outEvent</span> <span class="o">=</span> <span class="n">s_eventQueue</span><span class="p">.</span><span class="n">buffer</span><span class="p">[</span><span class="n">s_eventQueue</span><span class="p">.</span><span class="n">head</span><span class="p">];</span>
    <span class="n">s_eventQueue</span><span class="p">.</span><span class="n">head</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)((</span><span class="n">s_eventQueue</span><span class="p">.</span><span class="n">head</span> <span class="o">+</span> <span class="mi">1U</span><span class="p">)</span> <span class="o">%</span> <span class="n">EVENT_QUEUE_CAPACITY</span><span class="p">);</span>
    <span class="n">s_eventQueue</span><span class="p">.</span><span class="n">count</span><span class="o">--</span><span class="p">;</span>
    <span class="k">return</span> <span class="nb">true</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<ul>
  <li><strong>发布方（Producers）</strong>：按键模块识别到短按/长按、测控模块识别到超载或完成快照时，仅调用 <code class="language-plaintext highlighter-rouge">AppEvent_Post()</code> 向队列压入事件；</li>
  <li><strong>消费方（Dispatcher）</strong>：<code class="language-plaintext highlighter-rouge">UiRouter_Process()</code> 每轮从队列头取出一个事件，优先交给当前活动页面的 <code class="language-plaintext highlighter-rouge">OnEvent()</code> 处理；若当前页面未消费，再由顶层路由器兜底处理全局事件（如超载提示或低电告警）。</li>
</ul>

<h2 id="风险防控与工程验收准则">风险防控与工程验收准则</h2>

<table>
  <thead>
    <tr>
      <th>风险点</th>
      <th>防控措施</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>函数拆分后栈局部变量增大导致栈溢出</td>
      <td>严禁在函数栈上分配超过 64 字节的局部数组；所有条带画布、帧快照与菜单表统一使用 <code class="language-plaintext highlighter-rouge">static</code> 或 <code class="language-plaintext highlighter-rouge">const</code></td>
    </tr>
    <tr>
      <td>长按按键切页后继续误触发新页面长按动作</td>
      <td>按键状态机在跨页跳转时执行“等待物理释放锁存（Wait-Release Latch）”，必须松手后才允许新页面响应下一次按下</td>
    </tr>
    <tr>
      <td>异步 Flash 擦写期间突发掉电</td>
      <td>记录存储层采用两阶段提交与世代控制环，未完成整条记录校验前绝不擦除上一代有效索引</td>
    </tr>
  </tbody>
</table>

<p>每次合入重构改动前，检查以下事项：</p>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>编译与内存审计</strong>：编译器全量构建达到 <code class="language-plaintext highlighter-rouge">0 Error</code>，核对 <code class="language-plaintext highlighter-rouge">.map</code> 文件中 <code class="language-plaintext highlighter-rouge">Code + RO-data</code> 与 <code class="language-plaintext highlighter-rouge">RW + ZI</code> 增量符合预算。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>时序确定性验证</strong>：在开启高频传感器连续采样（如 2 kHz+）、LCD 连续翻页刷新与外部 Flash 后台扇区擦除并发条件下，按键短按/长按响应无粘滞，看门狗无复位，采样队列无溢出（<code class="language-plaintext highlighter-rouge">overrun == 0</code>）。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>前端演示同步</strong>：涉及可见 UI 布局或交互规则变更时，同步更新网页交互模拟器并运行自检脚本。</li>
</ul>]]></content><author><name></name></author><category term="嵌入式" /><category term="Cortex-M4" /><category term="N32G455" /><category term="裸机架构" /><category term="调度器" /><category term="重构" /><summary type="html"><![CDATA[针对嵌入式裸机 Super-Loop 工程常见的巨石文件、周期抖动与 Flash 擦写阻塞问题，设计四级时间片调度器、静态事件总线、页面契约路由与表驱动菜单引擎。]]></summary></entry><entry><title type="html">N32G455 M24C02 I2C EEPROM 驱动与页边界处理</title><link href="https://single5240.github.io/articles/m24c02-eeprom-driver-guide.html" rel="alternate" type="text/html" title="N32G455 M24C02 I2C EEPROM 驱动与页边界处理" /><published>2026-10-13T00:00:00+08:00</published><updated>2026-10-13T00:00:00+08:00</updated><id>https://single5240.github.io/articles/m24c02-eeprom-driver-guide</id><content type="html" xml:base="https://single5240.github.io/articles/m24c02-eeprom-driver-guide.html"><![CDATA[<p>M24C02 驱动是小型嵌入式设备持久化参数的常用方案。写入时按 16 字节页边界拆分，并用 ACK 轮询等待内部编程完成；上层将 256 字节空间分为 A/B 两槽保存关键参数。下面系统梳理硬件配置、跨页拆分、ACK 轮询，以及上层 A/B 双槽掉电安全存储的完整代码实现。</p>

<h2 id="芯片特性与驱动概览">芯片特性与驱动概览</h2>

<p>M24C02 是 2 Kbit（<code class="language-plaintext highlighter-rouge">256 × 8 bit</code>）串行 I2C EEPROM，内部按 16 页 × 16 字节组织，支持标准模式 100 kHz 与快速模式 400 kHz。当前固件驱动配置如下：</p>

<table>
  <thead>
    <tr>
      <th>项目</th>
      <th>当前实现配置</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>模块实现</td>
      <td>纯 C99 驱动（可直接对接标准外设库或硬件 I2C 控制器）</td>
    </tr>
    <tr>
      <td>硬件总线</td>
      <td>N32G455 <code class="language-plaintext highlighter-rouge">I2C3</code> 主机，7 位寻址，轮询模式（无中断、无 DMA）</td>
    </tr>
    <tr>
      <td>通信速率</td>
      <td>100 kHz（<code class="language-plaintext highlighter-rouge">PCLK2 = 64 MHz</code>）</td>
    </tr>
    <tr>
      <td>容量与地址范围</td>
      <td>256 字节，字节地址 <code class="language-plaintext highlighter-rouge">0x00</code>～<code class="language-plaintext highlighter-rouge">0xFF</code></td>
    </tr>
    <tr>
      <td>页写边界</td>
      <td>16 字节/页（跨页写入由驱动自动拆分）</td>
    </tr>
    <tr>
      <td>默认 7 位地址</td>
      <td><code class="language-plaintext highlighter-rouge">0x50</code>（可通过编译宏 <code class="language-plaintext highlighter-rouge">M24C02_I2C_ADDRESS</code> 覆盖）</td>
    </tr>
    <tr>
      <td>单次标志与写周期超时</td>
      <td>10 ms（覆盖手册最大内部写周期 <code class="language-plaintext highlighter-rouge">tWR = 5 ms</code>）</td>
    </tr>
    <tr>
      <td>公开接口</td>
      <td><code class="language-plaintext highlighter-rouge">M24c02_Init()</code>、<code class="language-plaintext highlighter-rouge">M24c02_Read()</code>、<code class="language-plaintext highlighter-rouge">M24c02_Write()</code></td>
    </tr>
  </tbody>
</table>

<h2 id="硬件连接与器件地址编码">硬件连接与器件地址编码</h2>

<h3 id="引脚与开漏配置">引脚与开漏配置</h3>

<p>本板使用 <code class="language-plaintext highlighter-rouge">I2C3</code> 默认映射（<code class="language-plaintext highlighter-rouge">I2C3_RMP = 00</code>），无需调用重映射函数：</p>

<table>
  <thead>
    <tr>
      <th>MCU 引脚</th>
      <th>信号网络</th>
      <th>GPIO 模式</th>
      <th>说明</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>PC0</td>
      <td><code class="language-plaintext highlighter-rouge">I2C3_SCL</code></td>
      <td><code class="language-plaintext highlighter-rouge">GPIO_Mode_AF_OD</code>（复用开漏，50 MHz）</td>
      <td>必须有外部上拉电阻至 VCC</td>
    </tr>
    <tr>
      <td>PC1</td>
      <td><code class="language-plaintext highlighter-rouge">I2C3_SDA</code></td>
      <td><code class="language-plaintext highlighter-rouge">GPIO_Mode_AF_OD</code>（复用开漏，50 MHz）</td>
      <td>必须有外部上拉电阻至 VCC</td>
    </tr>
  </tbody>
</table>

<p>M24C02（SO8 封装）的 <code class="language-plaintext highlighter-rouge">E0/E1/E2</code> 为硬件地址脚，<code class="language-plaintext highlighter-rouge">WC</code> 为写保护脚（低电平允许写入，高电平禁止写入）。若 <code class="language-plaintext highlighter-rouge">WC</code> 被拉高，写操作会在内部写周期或数据阶段失败。</p>

<h3 id="器件选择字节device-select-byte">器件选择字节（Device Select Byte）</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Bit:   7   6   5   4   3   2   1   0
     +---+---+---+---+---+---+---+---+
     | 1 | 0 | 1 | 0 | E2| E1| E0|R/W|
     +---+---+---+---+---+---+---+---+
</code></pre></div></div>

<ul>
  <li>高 4 位固定为 <code class="language-plaintext highlighter-rouge">1010</code>；</li>
  <li><code class="language-plaintext highlighter-rouge">E2/E1/E0</code> 由硬件引脚电平决定，本板实测 7 位地址为 <code class="language-plaintext highlighter-rouge">0x50</code>（对应 <code class="language-plaintext highlighter-rouge">E2=0, E1=0, E0=0</code>）；</li>
  <li><code class="language-plaintext highlighter-rouge">R/W = 0</code> 为写（<code class="language-plaintext highlighter-rouge">0xA0</code>），<code class="language-plaintext highlighter-rouge">R/W = 1</code> 为读（<code class="language-plaintext highlighter-rouge">0xA1</code>）。</li>
</ul>

<p>驱动中将 7 位地址左移 1 位后传给标准外设库 <code class="language-plaintext highlighter-rouge">I2C_SendAddr7bit()</code>：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#define M24C02_ADDRESS_BYTE ((uint8_t)(M24C02_I2C_ADDRESS &lt;&lt; 1U))
</span></code></pre></div></div>

<h2 id="存储组织与-16-字节页回绕陷阱">存储组织与 16 字节页回绕陷阱</h2>

<p>M24C02 共有 16 页，每页 16 字节，页起始地址依次为 <code class="language-plaintext highlighter-rouge">0x00, 0x10, 0x20 ... 0xF0</code>。</p>

<p><strong>页写回绕规则（Page Wrap-around）</strong>：数据手册规定，在单次 <code class="language-plaintext highlighter-rouge">Page Write</code> 事务中，每写入一字节，芯片内部字地址仅低 4 位自动加 1，高 4 位页号保持不变。如果一次连续写入跨越了 16 字节页尾（例如从 <code class="language-plaintext highlighter-rouge">0x0E</code> 连续写 4 字节），第 3、4 字节<strong>不会</strong>进入下一页的 <code class="language-plaintext highlighter-rouge">0x10, 0x11</code>，而是回绕覆盖本页开头的 <code class="language-plaintext highlighter-rouge">0x00, 0x01</code>！</p>

<p>因此，<code class="language-plaintext highlighter-rouge">M24c02_Write()</code> 在驱动层实现了严格的页边界自动拆分：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>当 length &gt; 0：
  本页剩余空间 pageRemain = 16 - (address % 16)
  本次写入长度 chunkLen   = min(length, pageRemain)
  执行单页写入 M24c02_WritePage(address, data, chunkLen)
  执行 ACK 轮询等待内部写周期 tWR 完成（最长 10 ms）
  address += chunkLen
  data    += chunkLen
  length  -= chunkLen
</code></pre></div></div>

<p>例如从 <code class="language-plaintext highlighter-rouge">0x0E</code> 写 4 字节时，驱动自动拆成两笔独立事务：第一笔写 <code class="language-plaintext highlighter-rouge">0x0E..0x0F</code>（2 字节）并等待编程完成；第二笔再写 <code class="language-plaintext highlighter-rouge">0x10..0x11</code>（2 字节）并等待编程完成，避免页内回绕覆盖。</p>

<h3 id="交互式在线演示16-字节页回绕陷阱与-ab-双槽乒乓存储">交互式在线演示：16 字节页回绕陷阱与 A/B 双槽乒乓存储</h3>

<p>下面用 256 字节模拟 EEPROM。试着从 <code class="language-plaintext highlighter-rouge">0x0E</code> 写入 4 字节，对比分页与不分页的结果；再切换到 A/B 双槽，观察更新中断后会保留哪一份参数。点击单元格可以查看所属页和槽位。</p>

<div class="article-interactive-widget" id="eeprom-demo-app" aria-label="M24C02 EEPROM 页回绕与双槽存储交互演示">
  <div class="aiw-header">
    <span class="aiw-badge">INTERACTIVE WIDGET · 硬件物理层仿真</span>
    <h3 class="aiw-title">M24C02 EEPROM 16 字节页回绕陷阱与 A/B 双槽乒乓存储模拟器</h3>
    <p class="aiw-desc">仿真 256 字节（16 页 × 16B）物理特性：字地址低 4 位硬件回绕、驱动层 <code>min(length, 16 - (addr % 16))</code> 自动拆包、以及 128B+128B 双槽乒乓镜像与掉电安全回退。</p>
  </div>
  <div class="aiw-body">
    <div class="aiw-stats" aria-label="EEPROM 当前状态">
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">当前活动参数镜像</span>
        <span class="aiw-stat-value accent" id="eeprom-stat-slot">当前活动: Slot A (0x00..0x7F)</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">活动槽 CRC-16 状态</span>
        <span class="aiw-stat-value" id="eeprom-stat-crc">0x4A1B (合法)</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">选定字节地址与值</span>
        <span class="aiw-stat-value" id="eeprom-stat-addr">0x0E (值: 0xAA)</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">选定字节所属物理页</span>
        <span class="aiw-stat-value warn" id="eeprom-stat-page">Page 0 (页首: 0x00)</span>
      </div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span>256 字节物理内存映射矩阵 (16 页 × 16 字节)</span>
        <span class="aiw-hint">青边: Slot A (0x00~0x7F) · 橙边: Slot B (0x80~0xFF) · 红格: 回绕污染</span>
      </div>
      <div class="eeprom-slot-badges">
        <span class="eeprom-slot-badge slot-a active-indicator" id="eeprom-badge-slot-a">Slot A [当前生效镜像 · 128B]</span>
        <span class="eeprom-slot-badge slot-b" id="eeprom-badge-slot-b">Slot B [备用写入槽 · 128B]</span>
      </div>
      <div class="eeprom-matrix-wrap">
        <div class="eeprom-grid" id="eeprom-grid"></div>
      </div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span>实验一：16 字节页写边界回绕陷阱对比测试</span>
        <span class="aiw-hint">测试从 0x0E 连续写入 4 字节 [0x11, 0x22, 0x33, 0x44]</span>
      </div>
      <div class="aiw-controls">
        <button type="button" class="aiw-btn danger" id="eeprom-btn-raw-bug">未分页直接写（演示回绕现象）</button>
        <button type="button" class="aiw-btn primary" id="eeprom-btn-driver-safe">按页拆分写入（Page Chunking）</button>
      </div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span>实验二：A/B 双槽掉电安全乒乓参数存储实验</span>
        <span class="aiw-hint">演示交替写入非活动槽与断电 CRC 自动回滚</span>
      </div>
      <div class="aiw-controls">
        <button type="button" class="aiw-btn primary" id="eeprom-btn-pingpong">正常更新参数 (乒乓切换写入非活动槽)</button>
        <button type="button" class="aiw-btn warn" id="eeprom-btn-fail">故障注入: 写入中途断电 (模拟开机自动回退)</button>
        <button type="button" class="aiw-btn" id="eeprom-btn-reset">恢复出厂配置</button>
      </div>
    </div>

    <div class="aiw-log-panel">
      <div class="aiw-section-title">
        <span>I2C 事务时序与驱动安全追踪日志</span>
        <span class="aiw-hint">注意观察硬件回绕覆盖警报与 ACK 轮询时序</span>
      </div>
      <div class="aiw-log" id="eeprom-log" aria-live="polite"></div>
    </div>
  </div>
</div>

<h2 id="i2c-事务帧格式与-ack-轮询">I2C 事务帧格式与 ACK 轮询</h2>

<h3 id="随机读单字节">随机读单字节</h3>

<p>当前 <code class="language-plaintext highlighter-rouge">M24c02_Read()</code> 对每个字节采用确定性的随机读时序（在最后 1 字节接收前关闭 <code class="language-plaintext highlighter-rouge">ACK</code> 并产生 <code class="language-plaintext highlighter-rouge">STOP</code>）：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>START
  → 发送 0xA0（写器件地址），等待 ADDRF
  → 发送 8 位字地址 word_address，等待 BYTEF
RESTART
  → 关闭 ACK 使能
  → 发送 0xA1（读器件地址），等待 ADDRF
  → 读 STS1 + 读 STS2 清 ADDRF，立即置位 STOP
  → 等待 RXDATNE，读取 1 字节数据
  → 等待 I2C BUSY = 0，重新打开 ACK 使能
</code></pre></div></div>

<h3 id="页写与内部写周期-ack-轮询ack-polling">页写与内部写周期 ACK 轮询（ACK Polling）</h3>

<p>发出页写数据的最后一个字节并产生 <code class="language-plaintext highlighter-rouge">STOP</code> 后，M24C02 进入最长 5 ms 的内部非易失性擦写周期（<code class="language-plaintext highlighter-rouge">tWR</code>）。在此期间，芯片不会响应外部访问，对器件选择字节会返回 <code class="language-plaintext highlighter-rouge">NACK</code>。</p>

<p>驱动不采用固定 <code class="language-plaintext highlighter-rouge">Delay(5)</code> 盲等，而是通过 <code class="language-plaintext highlighter-rouge">M24c02_WaitWriteComplete()</code> 执行 <strong>ACK 轮询</strong>，一旦芯片提前写完即可立即返回：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>等待 I2C BUSY = 0
循环（以 PlatformTime_GetMs() 无符号差值计时，上限 10 ms）：
  发出 START
  发送 0xA0（写器件地址）
  轮询 STS1：
    - 若 ADDRF = 1（从机已回 ACK，内部写完成）：
        读 STS1/STS2 清 ADDRF → 发 STOP → 等 BUSY = 0 → 返回 OK
    - 若 ACKFAIL = 1（从机仍忙，回 NACK）：
        清 ACKFAIL 标志 → 发 STOP → 等 BUSY = 0 → 继续下一轮尝试
若超过 10 ms 仍未收到 ACK → 返回 M24C02_STATUS_TIMEOUT
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">M24c02_Init()</code> 同样复用该函数探测器件是否在线应答。</p>

<h2 id="n32g455-i2c3-外设配置与错误恢复">N32G455 I2C3 外设配置与错误恢复</h2>

<h3 id="时钟与寄存器特性">时钟与寄存器特性</h3>

<p><code class="language-plaintext highlighter-rouge">I2C3</code> 挂载于 APB2 总线（<code class="language-plaintext highlighter-rouge">PCLK2 = 64 MHz</code>）。调用标准外设库 <code class="language-plaintext highlighter-rouge">I2C_Init()</code> 配置 100 kHz 时：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CLKCTRL (CCR)</code> 计算值为 <code class="language-plaintext highlighter-rouge">64 MHz / (100 kHz × 2) = 320</code>，实际产生标准 100 kHz <code class="language-plaintext highlighter-rouge">SCL</code>；</li>
  <li>库函数将大于 36 MHz 的 <code class="language-plaintext highlighter-rouge">CTRL2.FREQ</code> 封顶记为 <code class="language-plaintext highlighter-rouge">36</code>，<code class="language-plaintext highlighter-rouge">TMRISE</code> 设为 <code class="language-plaintext highlighter-rouge">37</code>。实际波形上升时间受板级外部上拉电阻与走线电容支配，联调时应以示波器实测为准。</li>
</ul>

<h3 id="外设异常自恢复m24c02_recoverperipheral">外设异常自恢复（<code class="language-plaintext highlighter-rouge">M24c02_RecoverPeripheral</code>）</h3>

<p>I2C 总线在受到静电、毛刺或从机异常影响时，容易卡在 <code class="language-plaintext highlighter-rouge">BUSY</code> 或仲裁错误状态。驱动在任何读写或初始化超时/错误退出前，均自动调用 <code class="language-plaintext highlighter-rouge">M24c02_RecoverPeripheral()</code>：</p>

<ol>
  <li>置位 <code class="language-plaintext highlighter-rouge">STOP</code> 请求并重新打开 <code class="language-plaintext highlighter-rouge">ACK</code> 配置；</li>
  <li>清除 <code class="language-plaintext highlighter-rouge">TIMOUT</code>、<code class="language-plaintext highlighter-rouge">OVERRUN</code>、<code class="language-plaintext highlighter-rouge">ARLOST</code>、<code class="language-plaintext highlighter-rouge">BUSERR</code>、<code class="language-plaintext highlighter-rouge">ACKFAIL</code> 全部错误标志；</li>
  <li>置位并释放 <code class="language-plaintext highlighter-rouge">I2C3</code> 软件复位位（<code class="language-plaintext highlighter-rouge">SWRST</code>），最后执行 <code class="language-plaintext highlighter-rouge">I2C_DeInit(I2C3)</code> 并按 100 kHz 重新初始化寄存器。</li>
</ol>

<h2 id="公开接口与最小调用示例">公开接口与最小调用示例</h2>

<h3 id="接口定义与状态码">接口定义与状态码</h3>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">typedef</span> <span class="k">enum</span>
<span class="p">{</span>
    <span class="n">M24C02_STATUS_OK</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span>
    <span class="n">M24C02_STATUS_INVALID_PARAM</span><span class="p">,</span> <span class="cm">/* 空指针、长度为 0，或 address + length &gt; 256 */</span>
    <span class="n">M24C02_STATUS_TIMEOUT</span><span class="p">,</span>       <span class="cm">/* 10 ms 内标志未就绪或内部写周期未完成 */</span>
    <span class="n">M24C02_STATUS_NACK</span><span class="p">,</span>          <span class="cm">/* 地址或数据阶段收到 NACK */</span>
    <span class="n">M24C02_STATUS_BUS_ERROR</span>      <span class="cm">/* 总线错误、仲裁丢失、溢出等 */</span>
<span class="p">}</span> <span class="n">m24c02_status_t</span><span class="p">;</span>

<span class="n">m24c02_status_t</span> <span class="nf">M24c02_Init</span><span class="p">(</span><span class="kt">void</span><span class="p">);</span>
<span class="n">m24c02_status_t</span> <span class="nf">M24c02_Read</span><span class="p">(</span><span class="kt">uint16_t</span> <span class="n">address</span><span class="p">,</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">data</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">length</span><span class="p">);</span>
<span class="n">m24c02_status_t</span> <span class="nf">M24c02_Write</span><span class="p">(</span><span class="kt">uint16_t</span> <span class="n">address</span><span class="p">,</span> <span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">data</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">length</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="最小调用示例">最小调用示例</h3>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">"drv_m24c02.h"</span><span class="cp">
</span>
<span class="k">static</span> <span class="kt">void</span> <span class="nf">Eeprom_Demo</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span>
<span class="p">{</span>
    <span class="cm">/* 选取跨越 0x0F -&gt; 0x10 页边界的 4 字节测试跨页拆分 */</span>
    <span class="kt">uint8_t</span> <span class="n">tx</span><span class="p">[</span><span class="mi">4</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span> <span class="mh">0x10U</span><span class="p">,</span> <span class="mh">0x32U</span><span class="p">,</span> <span class="mh">0x54U</span><span class="p">,</span> <span class="mh">0x76U</span> <span class="p">};</span>
    <span class="kt">uint8_t</span> <span class="n">rx</span><span class="p">[</span><span class="mi">4</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span> <span class="mi">0U</span> <span class="p">};</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">M24c02_Init</span><span class="p">()</span> <span class="o">!=</span> <span class="n">M24C02_STATUS_OK</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">M24c02_Write</span><span class="p">(</span><span class="mh">0x0EU</span><span class="p">,</span> <span class="n">tx</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">tx</span><span class="p">))</span> <span class="o">!=</span> <span class="n">M24C02_STATUS_OK</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="p">(</span><span class="kt">void</span><span class="p">)</span><span class="n">M24c02_Read</span><span class="p">(</span><span class="mh">0x0EU</span><span class="p">,</span> <span class="n">rx</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">rx</span><span class="p">));</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="与上层-ab-双槽掉电安全参数存储的协同">与上层 A/B 双槽掉电安全参数存储的协同</h2>

<p>驱动层只负责可靠的字节数组读写，不感知具体业务数据结构。上层参数管理模块将 M24C02 的 256 字节空间对半划分为两个 128 字节的乒乓备份槽位：</p>

<table>
  <thead>
    <tr>
      <th>槽位</th>
      <th>地址范围</th>
      <th>职责说明</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>槽 A（Slot A）</td>
      <td><code class="language-plaintext highlighter-rouge">0x00</code>～<code class="language-plaintext highlighter-rouge">0x7F</code>（128 B）</td>
      <td>保存完整的魔数、版本号、递增序号、用户设置、标定曲线及 CRC-16/MODBUS</td>
    </tr>
    <tr>
      <td>槽 B（Slot B）</td>
      <td><code class="language-plaintext highlighter-rouge">0x80</code>～<code class="language-plaintext highlighter-rouge">0xFF</code>（128 B）</td>
      <td>与槽 A 结构完全相同，每次保存总是写入<strong>非活动槽</strong></td>
    </tr>
  </tbody>
</table>

<h4 id="ab-双槽结构体定义与完整-c99-实现">A/B 双槽结构体定义与完整 C99 实现</h4>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">&lt;string.h&gt;</span><span class="cp">
</span>
<span class="cp">#define PARAM_SLOT_SIZE (128U)
#define PARAM_MAGIC     (0x5041524DU) </span><span class="cm">/* 'PARM' ASCII 魔数 */</span><span class="cp">
</span>
<span class="cp">#pragma pack(push, 1)
</span><span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="kt">uint32_t</span> <span class="n">magic</span><span class="p">;</span>          <span class="cm">/* 4B: 魔数标识 */</span>
    <span class="kt">uint16_t</span> <span class="n">version</span><span class="p">;</span>        <span class="cm">/* 2B: 参数结构版本号 */</span>
    <span class="kt">uint32_t</span> <span class="n">sequence</span><span class="p">;</span>       <span class="cm">/* 4B: 单调递增序号，越大表示版本越新 */</span>
    <span class="kt">uint8_t</span>  <span class="n">payload</span><span class="p">[</span><span class="mi">116</span><span class="p">];</span>   <span class="cm">/* 116B: 业务配置与标定曲线参数 */</span>
    <span class="kt">uint16_t</span> <span class="n">crc16</span><span class="p">;</span>          <span class="cm">/* 2B: CRC-16/MODBUS 校验码 (覆盖偏移 0..125) */</span>
<span class="p">}</span> <span class="n">ParamSlot</span><span class="p">;</span>
<span class="cp">#pragma pack(pop)
</span>
<span class="cm">/* 上电扫描：校验 A/B 两槽，自动选择版本最新且无损的镜像恢复 */</span>
<span class="n">bool</span> <span class="nf">ParamStore_Load</span><span class="p">(</span><span class="n">ParamSlot</span> <span class="o">*</span><span class="n">outParams</span><span class="p">,</span> <span class="n">bool</span> <span class="o">*</span><span class="n">outActiveIsSlotA</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">ParamSlot</span> <span class="n">slotA</span><span class="p">,</span> <span class="n">slotB</span><span class="p">;</span>
    <span class="n">bool</span> <span class="n">validA</span> <span class="o">=</span> <span class="nb">false</span><span class="p">;</span>
    <span class="n">bool</span> <span class="n">validB</span> <span class="o">=</span> <span class="nb">false</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">M24c02_Read</span><span class="p">(</span><span class="mh">0x00</span><span class="p">,</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">slotA</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">ParamSlot</span><span class="p">))</span> <span class="o">==</span> <span class="n">M24C02_STATUS_OK</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">validA</span> <span class="o">=</span> <span class="p">(</span><span class="n">slotA</span><span class="p">.</span><span class="n">magic</span> <span class="o">==</span> <span class="n">PARAM_MAGIC</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
                 <span class="p">(</span><span class="n">slotA</span><span class="p">.</span><span class="n">crc16</span> <span class="o">==</span> <span class="n">Crc16_Modbus</span><span class="p">((</span><span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">slotA</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">ParamSlot</span><span class="p">)</span> <span class="o">-</span> <span class="mi">2</span><span class="p">));</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">M24c02_Read</span><span class="p">(</span><span class="mh">0x80</span><span class="p">,</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">slotB</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">ParamSlot</span><span class="p">))</span> <span class="o">==</span> <span class="n">M24C02_STATUS_OK</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">validB</span> <span class="o">=</span> <span class="p">(</span><span class="n">slotB</span><span class="p">.</span><span class="n">magic</span> <span class="o">==</span> <span class="n">PARAM_MAGIC</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
                 <span class="p">(</span><span class="n">slotB</span><span class="p">.</span><span class="n">crc16</span> <span class="o">==</span> <span class="n">Crc16_Modbus</span><span class="p">((</span><span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">slotB</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">ParamSlot</span><span class="p">)</span> <span class="o">-</span> <span class="mi">2</span><span class="p">));</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">validA</span> <span class="o">&amp;&amp;</span> <span class="n">validB</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="cm">/* 两个槽位均完整有效，选用序号较大（较新）的槽 */</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">slotA</span><span class="p">.</span><span class="n">sequence</span> <span class="o">&gt;=</span> <span class="n">slotB</span><span class="p">.</span><span class="n">sequence</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="o">*</span><span class="n">outParams</span> <span class="o">=</span> <span class="n">slotA</span><span class="p">;</span>
            <span class="k">if</span> <span class="p">(</span><span class="n">outActiveIsSlotA</span><span class="p">)</span> <span class="o">*</span><span class="n">outActiveIsSlotA</span> <span class="o">=</span> <span class="nb">true</span><span class="p">;</span>
        <span class="p">}</span>
        <span class="k">else</span>
        <span class="p">{</span>
            <span class="o">*</span><span class="n">outParams</span> <span class="o">=</span> <span class="n">slotB</span><span class="p">;</span>
            <span class="k">if</span> <span class="p">(</span><span class="n">outActiveIsSlotA</span><span class="p">)</span> <span class="o">*</span><span class="n">outActiveIsSlotA</span> <span class="o">=</span> <span class="nb">false</span><span class="p">;</span>
        <span class="p">}</span>
        <span class="k">return</span> <span class="nb">true</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">validA</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="o">*</span><span class="n">outParams</span> <span class="o">=</span> <span class="n">slotA</span><span class="p">;</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">outActiveIsSlotA</span><span class="p">)</span> <span class="o">*</span><span class="n">outActiveIsSlotA</span> <span class="o">=</span> <span class="nb">true</span><span class="p">;</span>
        <span class="k">return</span> <span class="nb">true</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="n">validB</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="o">*</span><span class="n">outParams</span> <span class="o">=</span> <span class="n">slotB</span><span class="p">;</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">outActiveIsSlotA</span><span class="p">)</span> <span class="o">*</span><span class="n">outActiveIsSlotA</span> <span class="o">=</span> <span class="nb">false</span><span class="p">;</span>
        <span class="k">return</span> <span class="nb">true</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nb">false</span><span class="p">;</span> <span class="cm">/* 两槽均损坏（初次上电或硬件故障），应写入出厂默认值 */</span>
<span class="p">}</span>

<span class="cm">/* 掉电安全写入：始终写入非活动槽，并整槽读回校验 */</span>
<span class="n">bool</span> <span class="nf">ParamStore_Save</span><span class="p">(</span><span class="k">const</span> <span class="n">ParamSlot</span> <span class="o">*</span><span class="n">inParams</span><span class="p">,</span> <span class="n">bool</span> <span class="n">currentIsSlotA</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">uint16_t</span> <span class="n">targetAddr</span> <span class="o">=</span> <span class="n">currentIsSlotA</span> <span class="o">?</span> <span class="mh">0x80</span> <span class="o">:</span> <span class="mh">0x00</span><span class="p">;</span> <span class="cm">/* 写入交替备用槽 */</span>
    <span class="n">ParamSlot</span> <span class="n">newSlot</span> <span class="o">=</span> <span class="o">*</span><span class="n">inParams</span><span class="p">;</span>
    <span class="n">newSlot</span><span class="p">.</span><span class="n">magic</span> <span class="o">=</span> <span class="n">PARAM_MAGIC</span><span class="p">;</span>
    <span class="n">newSlot</span><span class="p">.</span><span class="n">sequence</span><span class="o">++</span><span class="p">;</span>
    <span class="n">newSlot</span><span class="p">.</span><span class="n">crc16</span> <span class="o">=</span> <span class="n">Crc16_Modbus</span><span class="p">((</span><span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">newSlot</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">ParamSlot</span><span class="p">)</span> <span class="o">-</span> <span class="mi">2</span><span class="p">);</span>

    <span class="cm">/* 1. 跨 8 页写入目标槽 */</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">M24c02_Write</span><span class="p">(</span><span class="n">targetAddr</span><span class="p">,</span> <span class="p">(</span><span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">newSlot</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">ParamSlot</span><span class="p">))</span> <span class="o">!=</span> <span class="n">M24C02_STATUS_OK</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="cm">/* 2. 写后整槽读回逐字节校验，确保物理无损 */</span>
    <span class="n">ParamSlot</span> <span class="n">readback</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">M24c02_Read</span><span class="p">(</span><span class="n">targetAddr</span><span class="p">,</span> <span class="p">(</span><span class="kt">uint8_t</span> <span class="o">*</span><span class="p">)</span><span class="o">&amp;</span><span class="n">readback</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">ParamSlot</span><span class="p">))</span> <span class="o">!=</span> <span class="n">M24C02_STATUS_OK</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="p">(</span><span class="n">memcmp</span><span class="p">(</span><span class="o">&amp;</span><span class="n">newSlot</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">readback</span><span class="p">,</span> <span class="k">sizeof</span><span class="p">(</span><span class="n">ParamSlot</span><span class="p">))</span> <span class="o">==</span> <span class="mi">0</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>这样设计的工程价值在于：</p>

<ol>
  <li><strong>掉电原子性</strong>：即使在 <code class="language-plaintext highlighter-rouge">M24c02_Write()</code> 跨 8 页（128 B）写入中途发生断电，被破坏的也只是正在写入的新槽；下次上电时，<code class="language-plaintext highlighter-rouge">AppParam_Load()</code> 校验两个槽的 CRC-16 与递增序号，自动回退到完好无损的旧槽镜像。</li>
  <li><strong>写后读回校验</strong>：每次写入非活动槽后，上层立即调用 <code class="language-plaintext highlighter-rouge">M24c02_Read()</code> 整槽读回并逐字节比对，确认物理存储无误后才更新活动槽指针。</li>
  <li><strong>看门狗安全边界</strong>：写满 128 字节共 8 页，正常耗时约 <code class="language-plaintext highlighter-rouge">8 × 5 ms ≈ 40 ms</code>（极端超时上界 &lt; 100 ms），远小于约 1 s 的独立看门狗窗口。</li>
</ol>

<h2 id="移植检查清单">移植检查清单</h2>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>引脚与重映射</strong>：确认目标板 SCL/SDA 引脚与 <code class="language-plaintext highlighter-rouge">I2C3_RMP</code> 配置，GPIO 设为复用开漏（<code class="language-plaintext highlighter-rouge">GPIO_Mode_AF_OD</code>）。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>电气与写保护</strong>：确认外部上拉电阻已焊接、<code class="language-plaintext highlighter-rouge">WC</code> 脚拉低允许写入、<code class="language-plaintext highlighter-rouge">VCC</code> 与 MCU I2C 电平一致。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>跨页边界测试</strong>：向 <code class="language-plaintext highlighter-rouge">0x0F</code> 地址连续写入 2 字节以上，读回确认 <code class="language-plaintext highlighter-rouge">0x0F</code> 与 <code class="language-plaintext highlighter-rouge">0x10</code> 均正确，且本页首地址 <code class="language-plaintext highlighter-rouge">0x00</code> 未被回绕篡改。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>调用上下文约束</strong>：仅在初始化与主循环中调用，严禁在中断服务程序（ISR）中调用阻塞式 I2C 读写。</li>
</ul>]]></content><author><name></name></author><category term="嵌入式" /><category term="N32G455" /><category term="I2C" /><category term="EEPROM" /><category term="M24C02" /><category term="参数存储" /><summary type="html"><![CDATA[面向小型嵌入式系统的 M24C02（256B I2C EEPROM）驱动实现，详解硬件 I2C 寄存器配置、16 字节页写自动拆分、ACK 轮询与 A/B 双槽掉电安全参数存储设计。]]></summary></entry><entry><title type="html">N32G455 W25Q128 SPI NOR Flash 驱动与非阻塞擦写实践</title><link href="https://single5240.github.io/articles/w25q128-spi-flash-driver-guide.html" rel="alternate" type="text/html" title="N32G455 W25Q128 SPI NOR Flash 驱动与非阻塞擦写实践" /><published>2026-10-09T00:00:00+08:00</published><updated>2026-10-09T00:00:00+08:00</updated><id>https://single5240.github.io/articles/w25q128-spi-flash-driver-guide</id><content type="html" xml:base="https://single5240.github.io/articles/w25q128-spi-flash-driver-guide.html"><![CDATA[<p>W25Q128 驱动使用硬件 SPI 轮询收发。下面系统记录引脚映射、跨页拆分、写保护检查，以及将页编程和扇区擦除拆为启动、查询两步的非阻塞做法。多外设调度参考见 <a href="/articles/n32g455-spi-dma-guide.html">N32G455 SPI 与 DMA 配置参考</a>。</p>

<h2 id="芯片特性与驱动概览">芯片特性与驱动概览</h2>

<p>W25Q128 是 Winbond 128 Mbit（16 MiB）标准 SPI NOR Flash，期望 24 位 JEDEC ID 为 <code class="language-plaintext highlighter-rouge">0xEF4018</code>（<code class="language-plaintext highlighter-rouge">0xEF</code> 代表 Winbond，<code class="language-plaintext highlighter-rouge">0x40</code> 代表存储器类型，<code class="language-plaintext highlighter-rouge">0x18</code> 代表 128 Mbit 容量）。</p>

<table>
  <thead>
    <tr>
      <th>项目</th>
      <th>当前实现配置</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>模块实现</td>
      <td>纯 C99 驱动（可直接复用于标准 SPI 主机控制器）</td>
    </tr>
    <tr>
      <td>硬件总线</td>
      <td>N32G455 <code class="language-plaintext highlighter-rouge">SPI3</code> 主机，软件片选（<code class="language-plaintext highlighter-rouge">PA2</code>），Mode 0，8 bit，MSB first</td>
    </tr>
    <tr>
      <td>传输方式</td>
      <td>按字节全双工轮询，不占用 DMA 通道与中断</td>
    </tr>
    <tr>
      <td>串行时钟 <code class="language-plaintext highlighter-rouge">SCLK</code></td>
      <td><code class="language-plaintext highlighter-rouge">PCLK1 / 4 = 32 MHz / 4 = 8 MHz</code></td>
    </tr>
    <tr>
      <td>容量与组织</td>
      <td>总容量 <code class="language-plaintext highlighter-rouge">16 MiB</code>（<code class="language-plaintext highlighter-rouge">16 × 1024 × 1024 B</code>），页大小 <code class="language-plaintext highlighter-rouge">256 B</code>，扇区大小 <code class="language-plaintext highlighter-rouge">4 KiB</code></td>
    </tr>
    <tr>
      <td>页编程超时上限</td>
      <td>100 ms（手册典型值约 0.4 ms，最大值约 3 ms；实测 256 B 约 4～5 ms）</td>
    </tr>
    <tr>
      <td>4 KiB 扇区擦除超时上限</td>
      <td>1000 ms（手册典型值约 45 ms，最大值约 400 ms；实测约 86～88 ms）</td>
    </tr>
    <tr>
      <td>非阻塞事务模型</td>
      <td>下发单页编程或扇区擦除命令后立即返回，由主循环单次查询 <code class="language-plaintext highlighter-rouge">BUSY</code> 位推进</td>
    </tr>
  </tbody>
</table>

<h2 id="硬件连接与-spi3-重映射">硬件连接与 SPI3 重映射</h2>

<h3 id="引脚分配">引脚分配</h3>

<p>本板通过 <code class="language-plaintext highlighter-rouge">GPIO_RMP3_SPI3</code>（即 <code class="language-plaintext highlighter-rouge">AFIO_RMP_CFG3</code> 的 <code class="language-plaintext highlighter-rouge">SPI3_RMP = 11</code>）将 SPI3 重映射至 <code class="language-plaintext highlighter-rouge">PC3 / PA0 / PA1</code>，片选 <code class="language-plaintext highlighter-rouge">/CS</code> 由 <code class="language-plaintext highlighter-rouge">PA2</code> 软件控制：</p>

<table>
  <thead>
    <tr>
      <th>W25Q128 引脚</th>
      <th>MCU 引脚</th>
      <th>信号方向</th>
      <th>驱动 GPIO 配置</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/CS</code>（Pin 1）</td>
      <td>PA2</td>
      <td>MCU → Flash</td>
      <td>推挽输出，初始化先写高电平再切输出模式</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DO / IO1</code>（Pin 2）</td>
      <td>PA0</td>
      <td>Flash → MCU MISO</td>
      <td>浮空输入</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DI / IO0</code>（Pin 5）</td>
      <td>PA1</td>
      <td>MCU MOSI → Flash</td>
      <td>SPI3 复用推挽输出</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CLK</code>（Pin 6）</td>
      <td>PC3</td>
      <td>MCU → Flash</td>
      <td>SPI3 复用推挽输出</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">/WP</code>（Pin 3）、<code class="language-plaintext highlighter-rouge">/HOLD</code>（Pin 7）</td>
      <td>硬件上拉</td>
      <td>—</td>
      <td>标准 SPI 模式下外部上拉至 VCC</td>
    </tr>
  </tbody>
</table>

<p>初始化时钟与重映射代码如下：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">RCC_EnableAPB2PeriphClk</span><span class="p">(</span><span class="n">RCC_APB2_PERIPH_AFIO</span> <span class="o">|</span>
                        <span class="n">RCC_APB2_PERIPH_GPIOA</span> <span class="o">|</span>
                        <span class="n">RCC_APB2_PERIPH_GPIOC</span><span class="p">,</span> <span class="n">ENABLE</span><span class="p">);</span>
<span class="n">RCC_EnableAPB1PeriphClk</span><span class="p">(</span><span class="n">RCC_APB1_PERIPH_SPI3</span><span class="p">,</span> <span class="n">ENABLE</span><span class="p">);</span>
<span class="n">GPIO_ConfigPinRemap</span><span class="p">(</span><span class="n">GPIO_RMP3_SPI3</span><span class="p">,</span> <span class="n">ENABLE</span><span class="p">);</span>
</code></pre></div></div>

<h3 id="全双工单字节收发规则">全双工单字节收发规则</h3>

<p>SPI3 工作在双线全双工模式，即使只向 Flash 发送命令或地址，MISO 也会同步移入字节。每次写入 <code class="language-plaintext highlighter-rouge">SPI_DAT</code> 后必须等待 <code class="language-plaintext highlighter-rouge">RNE (RXNE)</code> 并读走 <code class="language-plaintext highlighter-rouge">SPI_DAT</code>，否则下一字节会触发 <code class="language-plaintext highlighter-rouge">OVR</code>（接收溢出）；整段命令或数据结束后，必须等待 <code class="language-plaintext highlighter-rouge">BUSY</code> 标志清零才能拉高 <code class="language-plaintext highlighter-rouge">PA2 (/CS)</code>。</p>

<h2 id="存储组织与-nor-flash-物理写入规则">存储组织与 NOR Flash 物理写入规则</h2>

<table>
  <thead>
    <tr>
      <th>存储单元</th>
      <th style="text-align: right">大小</th>
      <th style="text-align: right">总数量</th>
      <th>驱动支持方式</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>字节（Byte）</td>
      <td style="text-align: right">1 B</td>
      <td style="text-align: right">16,777,216</td>
      <td>线性字节读取与页内编程寻址（24 位地址高字节在前）</td>
    </tr>
    <tr>
      <td>页（Page）</td>
      <td style="text-align: right">256 B</td>
      <td style="text-align: right">65,536</td>
      <td>同步 <code class="language-plaintext highlighter-rouge">W25q128_Write()</code> 自动按页尾拆分；非阻塞 <code class="language-plaintext highlighter-rouge">W25q128_PageProgramStart()</code> 限单页内</td>
    </tr>
    <tr>
      <td>扇区（Sector）</td>
      <td style="text-align: right">4 KiB</td>
      <td style="text-align: right">4,096</td>
      <td><code class="language-plaintext highlighter-rouge">W25q128_SectorErase()</code> 与 <code class="language-plaintext highlighter-rouge">W25q128_SectorEraseStart()</code>（要求 4096 字节对齐）</td>
    </tr>
    <tr>
      <td>32K / 64K 块与全片</td>
      <td style="text-align: right">32K / 64K / 16M</td>
      <td style="text-align: right">—</td>
      <td>未实现（全片擦除耗时数秒，会突破 1 s 看门狗窗口）</td>
    </tr>
  </tbody>
</table>

<p>读写时需要注意三个限制：</p>

<ol>
  <li><strong>先擦后写（只允许 1 → 0）</strong>：扇区擦除后所有位变为 <code class="language-plaintext highlighter-rouge">1</code>（字节为 <code class="language-plaintext highlighter-rouge">0xFF</code>），页编程只能把 <code class="language-plaintext highlighter-rouge">1</code> 改写为 <code class="language-plaintext highlighter-rouge">0</code>；若要把 <code class="language-plaintext highlighter-rouge">0</code> 变回 <code class="language-plaintext highlighter-rouge">1</code>，必须先擦除整个 4 KiB 扇区。驱动层 <code class="language-plaintext highlighter-rouge">W25q128_Write()</code> 不会隐式替调用方擦除扇区。</li>
  <li><strong>256 字节页回绕边界</strong>：单次 <code class="language-plaintext highlighter-rouge">Page Program (0x02)</code> 若跨越 256 字节页尾（<code class="language-plaintext highlighter-rouge">address &amp; 0xFF</code> 溢出），芯片内部地址会回绕覆盖当前页首部。因此 <code class="language-plaintext highlighter-rouge">W25q128_Write()</code> 每次计算 <code class="language-plaintext highlighter-rouge">pageRemain = 256 - (address &amp; 255)</code>，在页边界处自动拆分为多笔页编程事务。</li>
  <li><strong>4 KiB 扇区对齐检查</strong>：扇区擦除传入的 <code class="language-plaintext highlighter-rouge">address</code> 若不满足 <code class="language-plaintext highlighter-rouge">(address &amp; 0xFFFU) == 0</code>，驱动直接返回 <code class="language-plaintext highlighter-rouge">W25Q128_STATUS_INVALID_PARAM</code>，防止误擦除相邻数据。</li>
</ol>

<h2 id="已实现命令与状态寄存器保护核查">已实现命令与状态寄存器保护核查</h2>

<h3 id="命令列表">命令列表</h3>

<table>
  <thead>
    <tr>
      <th>命令名称</th>
      <th style="text-align: right">命令码</th>
      <th>事务帧格式</th>
      <th>驱动函数</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Write Enable</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x06</code></td>
      <td><code class="language-plaintext highlighter-rouge">CS</code> 低 → <code class="language-plaintext highlighter-rouge">0x06</code> → <code class="language-plaintext highlighter-rouge">CS</code> 高</td>
      <td><code class="language-plaintext highlighter-rouge">W25q128_WriteEnable()</code></td>
    </tr>
    <tr>
      <td>Read Status Register-1</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x05</code></td>
      <td><code class="language-plaintext highlighter-rouge">0x05</code> + 读 1 字节状态</td>
      <td><code class="language-plaintext highlighter-rouge">W25q128_ReadStatus1()</code></td>
    </tr>
    <tr>
      <td>Read Status Register-2 / 3</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x35</code> / <code class="language-plaintext highlighter-rouge">0x15</code></td>
      <td>命令 + 读 1 字节状态</td>
      <td><code class="language-plaintext highlighter-rouge">W25q128_ReadStatusRegisters()</code></td>
    </tr>
    <tr>
      <td>Read Data</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x03</code></td>
      <td><code class="language-plaintext highlighter-rouge">0x03</code> + 24 位地址 + 连续读 N 字节</td>
      <td><code class="language-plaintext highlighter-rouge">W25q128_Read()</code></td>
    </tr>
    <tr>
      <td>Page Program</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x02</code></td>
      <td><code class="language-plaintext highlighter-rouge">0x02</code> + 24 位地址 + 1～256 字节数据</td>
      <td><code class="language-plaintext highlighter-rouge">W25q128_PageProgram()</code> / <code class="language-plaintext highlighter-rouge">PageProgramStart()</code></td>
    </tr>
    <tr>
      <td>Sector Erase (4 KiB)</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x20</code></td>
      <td><code class="language-plaintext highlighter-rouge">0x20</code> + 24 位对齐地址</td>
      <td><code class="language-plaintext highlighter-rouge">W25q128_SectorErase()</code> / <code class="language-plaintext highlighter-rouge">SectorEraseStart()</code></td>
    </tr>
    <tr>
      <td>Read JEDEC ID</td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x9F</code></td>
      <td><code class="language-plaintext highlighter-rouge">0x9F</code> + 读 3 字节 ID</td>
      <td><code class="language-plaintext highlighter-rouge">W25q128_ReadJedecId()</code></td>
    </tr>
  </tbody>
</table>

<h3 id="状态寄存器与写保护检查">状态寄存器与写保护检查</h3>

<p>状态寄存器 1（SR1，<code class="language-plaintext highlighter-rouge">0x05</code>）的核心位定义如下：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">位</th>
      <th>名称</th>
      <th>驱动掩码</th>
      <th>含义</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">Bit 0</td>
      <td><code class="language-plaintext highlighter-rouge">BUSY</code>（WIP）</td>
      <td><code class="language-plaintext highlighter-rouge">0x01</code></td>
      <td><code class="language-plaintext highlighter-rouge">1</code> = 内部编程或扇区擦除进行中；<code class="language-plaintext highlighter-rouge">0</code> = 空闲</td>
    </tr>
    <tr>
      <td style="text-align: right">Bit 1</td>
      <td><code class="language-plaintext highlighter-rouge">WEL</code></td>
      <td><code class="language-plaintext highlighter-rouge">0x02</code></td>
      <td><code class="language-plaintext highlighter-rouge">1</code> = 写使能锁存器已置位（每次编程/擦除完成后自动清零）</td>
    </tr>
    <tr>
      <td style="text-align: right">Bit 2..4</td>
      <td><code class="language-plaintext highlighter-rouge">BP0..BP2</code></td>
      <td>—</td>
      <td>块写保护范围配置位</td>
    </tr>
  </tbody>
</table>

<p>每次发送 <code class="language-plaintext highlighter-rouge">Write Enable (0x06)</code> 后，驱动都会立即读回 SR1 校验 <code class="language-plaintext highlighter-rouge">WEL == 1</code>；若 <code class="language-plaintext highlighter-rouge">WEL</code> 未置位，立即返回 <code class="language-plaintext highlighter-rouge">W25Q128_STATUS_WRITE_ENABLE_FAILED</code>。</p>

<p>此外，上层记录存储服务在挂载前会调用 <code class="language-plaintext highlighter-rouge">W25q128_ReadStatusRegisters()</code> 同时读取 SR1、SR2、SR3，保守要求：</p>

<ul>
  <li>SR1 的 <code class="language-plaintext highlighter-rouge">BP[2:0] == 0</code>；</li>
  <li>SR2 的 <code class="language-plaintext highlighter-rouge">CMP == 0</code>；</li>
  <li>SR3 的 <code class="language-plaintext highlighter-rouge">WPS == 0</code>。</li>
</ul>

<p>任一保护位非零时拒绝写入，且驱动绝不主动下发改写状态寄存器的指令，防止意外将 Flash 锁成只读状态。</p>

<h2 id="为什么需要非阻塞页编程与扇区擦除">为什么需要非阻塞页编程与扇区擦除</h2>

<p>在开启独立看门狗（窗口约 1 s）和 AD7190 高频连续采样后，一次 4 KiB 扇区擦除在物理上需要 <strong>45～400 ms</strong>（实测约 88 ms）。如果主循环直接调用阻塞式 <code class="language-plaintext highlighter-rouge">W25q128_SectorErase()</code> 原地死等 <code class="language-plaintext highlighter-rouge">BUSY == 0</code>，会导致：</p>

<ul>
  <li>主循环停摆近百毫秒，按键消抖掉帧、LCD 8 行条带刷新卡顿；</li>
  <li>AD7190 的 255 帧环形队列可能逼近高水位；</li>
  <li>连续擦除多个扇区时直接触发看门狗复位。</li>
</ul>

<p>一次擦写包含两个阶段：</p>

<ul>
  <li><strong>SPI 命令下发阶段</strong>：发送 <code class="language-plaintext highlighter-rouge">0x06</code> + <code class="language-plaintext highlighter-rouge">0x20</code> + 3 字节地址仅占几个微秒；即使是 256 字节满页编程，在 8 MHz SCLK 下线上数据传输也仅需 <strong>约 0.256 ms</strong>；</li>
  <li><strong>芯片内部电荷擦写阶段</strong>：拉高 <code class="language-plaintext highlighter-rouge">/CS</code> 后，Flash 内部高压泵独自工作数毫秒至数百毫秒，此时完全不需要占用 MCU。</li>
</ul>

<p>因此驱动提供了一组<strong>单事务非阻塞接口</strong>：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">w25q128_status_t</span> <span class="nf">W25q128_PageProgramStart</span><span class="p">(</span><span class="kt">uint32_t</span> <span class="n">address</span><span class="p">,</span> <span class="k">const</span> <span class="kt">uint8_t</span> <span class="o">*</span><span class="n">data</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">length</span><span class="p">);</span>
<span class="n">w25q128_status_t</span> <span class="nf">W25q128_SectorEraseStart</span><span class="p">(</span><span class="kt">uint32_t</span> <span class="n">address</span><span class="p">);</span>
<span class="n">w25q128_status_t</span> <span class="nf">W25q128_ReadBusy</span><span class="p">(</span><span class="n">bool</span> <span class="o">*</span><span class="n">busy</span><span class="p">);</span>
<span class="n">w25q128_status_t</span> <span class="nf">W25q128_PollOperation</span><span class="p">(</span><span class="n">bool</span> <span class="o">*</span><span class="n">completed</span><span class="p">);</span>
<span class="n">bool</span>             <span class="nf">W25q128_IsOperationActive</span><span class="p">(</span><span class="kt">void</span><span class="p">);</span>
</code></pre></div></div>

<p>非阻塞状态推进流程如下：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 调用 Start 接口（PageProgramStart 或 SectorEraseStart）：
   ├─ 校验地址、单页不跨界或 4 KiB 扇区对齐
   ├─ 若驱动已有活动事务，或单次读 SR1 发现硬件 BUSY=1 → 立即返回 W25Q128_STATUS_BUSY
   ├─ 发送 0x06 并确认 WEL = 1
   ├─ 记录活动事务上下文与起始时间戳（100 ms / 1000 ms 超时）
   └─ 发送 0x02+地址+数据（≤ 0.26 ms）或 0x20+地址，拉高 CS 后立即返回主循环

2. 主循环每轮调用 W25q128_PollOperation(&amp;completed)：
   ├─ 发送一次 0x05 读取 1 字节 SR1（耗时仅数微秒）
   ├─ 若 BUSY == 0：清除活动事务标志，置 *completed = true，返回 OK
   ├─ 若 BUSY == 1 且未超时：置 *completed = false，立即返回 OK 让主循环继续运行
   └─ 若 BUSY == 1 且超出截止时间：返回 TIMEOUT，但保持事务隔离直到物理 BUSY 真正清零
</code></pre></div></div>

<p>在非阻塞事务活动期间（<code class="language-plaintext highlighter-rouge">W25q128_IsOperationActive() == true</code>），任何普通 <code class="language-plaintext highlighter-rouge">Read</code>、重复 <code class="language-plaintext highlighter-rouge">Start</code> 或同步擦写都会被驱动拦截并返回 <code class="language-plaintext highlighter-rouge">W25Q128_STATUS_BUSY</code>，避免同一执行上下文中的事务交叉。</p>

<div id="w25q-demo-app" class="w25q-demo-root">
  <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
    <h3 style="margin:0; font-size:16px;">W25Q128 非阻塞状态机与阻塞轮询对比演示</h3>
    <span style="font-size:12px; color:var(--muted);">纯前端交互演示</span>
  </div>

  <!-- Chip Status Bar -->
  <div class="w25q-chip-bar">
    <div class="w25q-chip-state">
      <span id="w25q-led-busy" class="w25q-led"></span>
      <span id="w25q-chip-state-text">STANDBY (空闲就绪)</span>
    </div>
    <div style="display:flex; align-items:center; gap:16px;">
      <div class="w25q-chip-flags">
        <span class="w25q-flag"><span id="w25q-led-wel" class="w25q-led" style="margin-right:4px;"></span>WEL</span>
        <span class="w25q-flag">BUSY</span>
      </div>
      <span id="w25q-mode-badge" class="ecat-status-val">当前模式：非阻塞状态机</span>
      <button class="ecat-btn" id="w25q-mode-toggle">切换为：阻塞轮询 while(BUSY)</button>
    </div>
  </div>

  <!-- Meters Grid -->
  <div class="w25q-meter-grid">
    <div class="w25q-meter-card">
      <div class="w25q-meter-title">
        <span>主循环单圈耗时 (Jitter)</span>
        <span style="color:var(--muted);">基准: 0.15 ms</span>
      </div>
      <div id="w25q-jitter-val" class="w25q-meter-val">0.15 ms</div>
      <div class="w25q-meter-bar">
        <div id="w25q-jitter-bar" class="w25q-meter-fill" style="width:2%;"></div>
      </div>
      <div style="font-size:11px; color:var(--muted); line-height:1.4;">
        阻塞 88ms 期间主循环完全停摆，导致按键消抖掉帧、LCD 条带刷屏撕裂。
      </div>
    </div>

    <div class="w25q-meter-card">
      <div class="w25q-meter-title">
        <span>AD7190 2.4kHz 连续采样队列</span>
        <span style="color:var(--muted);">容量: 255 帧</span>
      </div>
      <div id="w25q-queue-val" class="w25q-meter-val">2 / 255 帧</div>
      <div class="w25q-meter-bar">
        <div id="w25q-queue-bar" class="w25q-meter-fill" style="width:1%;"></div>
      </div>
      <div style="font-size:11px; color:var(--muted); line-height:1.4;">
        硬件中断以 2400 sps 灌入数据。阻塞 88ms 累积 ~211 帧，逼近 255 满溢危险线。
      </div>
    </div>

    <div class="w25q-meter-card">
      <div class="w25q-meter-title">
        <span>独立看门狗 (IWDG 窗口)</span>
        <span style="color:var(--muted);">超时: 1000 ms</span>
      </div>
      <div id="w25q-iwdg-val" class="w25q-meter-val">1000 ms</div>
      <div class="w25q-meter-bar">
        <div id="w25q-iwdg-bar" class="w25q-meter-fill" style="width:100%;"></div>
      </div>
      <div style="font-size:11px; color:var(--muted); line-height:1.4;">
        主循环非阻塞轮询每圈正常喂狗；若连续阻塞擦除累积超时将引发单片机硬件复位。
      </div>
    </div>
  </div>

  <!-- Page Chunking Visualizer -->
  <div class="w25q-chunk-box">
    <div class="w25q-chunk-title">
      📄 跨页 256 字节边界自动拆分器 (以 500 字节写入地址 0x0010E0 为例)
    </div>
    <div class="w25q-chunk-track">
      <div id="w25q-seg-0" class="w25q-chunk-segment" style="flex:32;">Page 0: 32B (0x10E0..0x10FF)</div>
      <div id="w25q-seg-1" class="w25q-chunk-segment" style="flex:256;">Page 1: 满页 256B (0x1100..0x11FF)</div>
      <div id="w25q-seg-2" class="w25q-chunk-segment" style="flex:212;">Page 2: 剩余 212B (0x1200..0x12D3)</div>
    </div>
    <div style="font-size:11px; color:var(--muted); margin-top:4px;">
      单次 0x02 页编程若跨越 256 字节页尾会发生硬件回绕覆盖页首。驱动自动在页边界处拆分为多笔事务。
    </div>
  </div>

  <!-- Actions and Log -->
  <div class="w25q-actions-grid">
    <div class="w25q-ctrl-card">
      <div class="w25q-ctrl-title">发起 Flash 硬件操作</div>
      <div class="w25q-btn-row">
        <button class="ecat-btn" id="w25q-btn-erase" style="background:var(--accent); color:var(--on-accent); border-color:var(--accent);">
          🧹 4 KiB 扇区擦除 (~88 ms)
        </button>
        <button class="ecat-btn" id="w25q-btn-prog">
          ✏️ 256 B 页编程 (~0.8 ms)
        </button>
        <button class="ecat-btn" id="w25q-btn-multipage">
          📑 跨页 500 字节写入 (自动拆包)
        </button>
      </div>
      <div style="font-size:12px; color:var(--muted); margin-top:4px; line-height:1.5;">
        切换不同工作模式后点击上方操作按钮，对比观察左侧主循环耗时抖动与 AD7190 环形队列水位变化。
      </div>
    </div>

    <div class="w25q-ctrl-card">
      <div class="w25q-ctrl-title">驱动状态机与 SPI 事件日志</div>
      <div id="w25q-log" style="flex:1; min-height:110px; max-height:140px; overflow-y:auto; background:var(--code-bg); color:var(--code-ink); border:1px solid var(--line); border-radius:6px; padding:8px 10px; font-family:ui-monospace, Consolas, monospace; font-size:11px; line-height:1.5;"></div>
    </div>
  </div>
</div>

<h2 id="最小探测与读写自检示例">最小探测与读写自检示例</h2>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">"drv_w25q128.h"</span><span class="cp">
</span>
<span class="cm">/* 1. 启动阶段探测 JEDEC ID */</span>
<span class="k">static</span> <span class="n">w25q128_status_t</span> <span class="nf">Flash_Probe</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">uint32_t</span> <span class="n">jedecId</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span>
    <span class="n">w25q128_status_t</span> <span class="n">status</span><span class="p">;</span>

    <span class="n">status</span> <span class="o">=</span> <span class="n">W25q128_Init</span><span class="p">();</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">status</span> <span class="o">!=</span> <span class="n">W25Q128_STATUS_OK</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">status</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">status</span> <span class="o">=</span> <span class="n">W25q128_ReadJedecId</span><span class="p">(</span><span class="o">&amp;</span><span class="n">jedecId</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">status</span> <span class="o">!=</span> <span class="n">W25Q128_STATUS_OK</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">status</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="p">(</span><span class="n">jedecId</span> <span class="o">==</span> <span class="n">W25Q128_JEDEC_ID</span><span class="p">)</span> <span class="o">?</span> <span class="n">W25Q128_STATUS_OK</span>
                                         <span class="o">:</span> <span class="n">W25Q128_STATUS_ID_MISMATCH</span><span class="p">;</span>
<span class="p">}</span>

<span class="cm">/* 2. 主循环中推进非阻塞扇区擦除 */</span>
<span class="k">static</span> <span class="kt">void</span> <span class="nf">Flash_NonBlockingEraseExample</span><span class="p">(</span><span class="kt">uint32_t</span> <span class="n">sectorAddr</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">bool</span> <span class="n">completed</span> <span class="o">=</span> <span class="nb">false</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">W25q128_IsOperationActive</span><span class="p">())</span>
    <span class="p">{</span>
        <span class="p">(</span><span class="kt">void</span><span class="p">)</span><span class="n">W25q128_SectorEraseStart</span><span class="p">(</span><span class="n">sectorAddr</span><span class="p">);</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">W25q128_PollOperation</span><span class="p">(</span><span class="o">&amp;</span><span class="n">completed</span><span class="p">)</span> <span class="o">==</span> <span class="n">W25Q128_STATUS_OK</span> <span class="o">&amp;&amp;</span> <span class="n">completed</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="cm">/* 扇区擦除已在后台完成，可安全发起后续页编程或读取 */</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="常见故障排查表">常见故障排查表</h2>

<table>
  <thead>
    <tr>
      <th>故障现象</th>
      <th>优先排查项</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>没有 <code class="language-plaintext highlighter-rouge">SCLK</code> 波形</td>
      <td><code class="language-plaintext highlighter-rouge">SPI3</code> 与 <code class="language-plaintext highlighter-rouge">AFIO</code> 时钟是否使能、<code class="language-plaintext highlighter-rouge">GPIO_RMP3_SPI3</code> 重映射是否生效、<code class="language-plaintext highlighter-rouge">SPI_Enable</code> 是否调用</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MISO</code> 恒为 <code class="language-plaintext highlighter-rouge">0xFF</code> 或 <code class="language-plaintext highlighter-rouge">0x00</code></td>
      <td><code class="language-plaintext highlighter-rouge">PA2 (/CS)</code> 极性、芯片 3.3 V 供电、<code class="language-plaintext highlighter-rouge">PA0</code> 是否配为浮空输入、芯片是否处于 Power-down</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">JEDEC ID</code> 不是 <code class="language-plaintext highlighter-rouge">0xEF4018</code></td>
      <td>核对实装芯片丝印、检查 MOSI/MISO 是否接反、是否在读完 3 字节前提前拉高了 <code class="language-plaintext highlighter-rouge">/CS</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">WEL</code> 无法置位</td>
      <td>检查硬件 <code class="language-plaintext highlighter-rouge">/WP</code> 引脚电平、读取 SR1/SR2/SR3 核查块保护位、<code class="language-plaintext highlighter-rouge">0x06</code> 后是否正常拉高 <code class="language-plaintext highlighter-rouge">/CS</code></td>
    </tr>
    <tr>
      <td>写入后读回仍是 <code class="language-plaintext highlighter-rouge">0xFF</code></td>
      <td>确认是否真正发出了 <code class="language-plaintext highlighter-rouge">0x02</code>、写入期间是否等待 <code class="language-plaintext highlighter-rouge">BUSY</code> 清零</td>
    </tr>
    <tr>
      <td>写后部分 bit 无法从 <code class="language-plaintext highlighter-rouge">0</code> 变 <code class="language-plaintext highlighter-rouge">1</code></td>
      <td>违反 NOR Flash 先擦后写规则，写入前目标区域不是 <code class="language-plaintext highlighter-rouge">0xFF</code></td>
    </tr>
    <tr>
      <td>写后页首数据被意外改写</td>
      <td>单次 <code class="language-plaintext highlighter-rouge">0x02</code> 页编程跨越了 256 字节页边界，触发页内地址回绕</td>
    </tr>
  </tbody>
</table>]]></content><author><name></name></author><category term="嵌入式" /><category term="N32G455" /><category term="SPI" /><category term="W25Q128" /><category term="NOR Flash" /><category term="非阻塞驱动" /><summary type="html"><![CDATA[面向 Cortex-M 裸机系统的 W25Q128（16 MiB SPI NOR Flash）驱动实现，涵盖硬件 SPI 配置、256B 页边界拆分、状态寄存器写保护检查及非阻塞擦写轮询设计。]]></summary></entry><entry><title type="html">裸机 C99 表驱动有限状态机设计与复用指南</title><link href="https://single5240.github.io/articles/c99-table-driven-state-machine.html" rel="alternate" type="text/html" title="裸机 C99 表驱动有限状态机设计与复用指南" /><published>2026-10-08T00:00:00+08:00</published><updated>2026-10-08T00:00:00+08:00</updated><id>https://single5240.github.io/articles/c99-table-driven-state-machine</id><content type="html" xml:base="https://single5240.github.io/articles/c99-table-driven-state-machine.html"><![CDATA[<p>本设计是一套零动态内存依赖、不绑定特定 MCU 或 RTOS 的通用 C99 表驱动有限状态机（FSM）实现。在裸机或多任务工程中，通过状态转移表、静态 FIFO 事件队列和动作函数解耦复杂业务流程。</p>

<h2 id="模块特性与适用场景">模块特性与适用场景</h2>

<p>业务状态和事件增多后，分散的 <code class="language-plaintext highlighter-rouge">switch-case</code> 与标志位不便检查。这个组件把状态转移集中放在表中，事件按队列顺序处理：</p>

<ul>
  <li><strong>声明式状态转移表</strong>：以静态常量表集中描述“当前状态 + 触发事件 → 动作函数 + 下一状态”；</li>
  <li><strong>内置静态 FIFO 事件队列</strong>：队列数组由调用方显式提供，零堆内存分配；</li>
  <li><strong>空闲自环事件（Loop Event）</strong>：当外部事件队列为空时，自动执行当前状态绑定的周期循环事件；</li>
  <li><strong>有界非阻塞调度</strong>：每次调用 <code class="language-plaintext highlighter-rouge">StateMachine_Process()</code> 最多消费并执行一个事件，单圈耗时还取决于动作函数是否及时返回；</li>
  <li><strong>业务上下文解耦</strong>：通过 <code class="language-plaintext highlighter-rouge">void *userContext</code> 向动作函数传递业务数据，状态机内核不保存任何业务全局变量；</li>
  <li><strong>初始化期配置自检</strong>：在 <code class="language-plaintext highlighter-rouge">StateMachine_Init()</code> 阶段自动拦截越界状态、越界事件、重复转移项和缺失的循环转移。</li>
</ul>

<h2 id="运行模型与调度流程">运行模型与调度流程</h2>

<p>一条状态转移由四元组构成：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>{ 触发事件 event, 当前状态 currentState, 动作函数 action, 下一状态 nextState }
</code></pre></div></div>

<p>单次 <code class="language-plaintext highlighter-rouge">StateMachine_Process()</code> 的执行路径如下：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>调用 StateMachine_Process(machine)
  │
  ├─ 1. 检查实例、配置表与当前状态是否有效？
  │      └─ 否 ──► 返回 STATE_MACHINE_PROCESS_INVALID
  │
  ├─ 2. 检查 FIFO 事件队列是否非空？
  │      ├─ 非空 ──► 从队列头部弹出最早到达的外部事件 event
  │      └─ 为空 ──► 取出当前状态对应的循环事件 config-&gt;loopEvents[currentState]
  │
  ├─ 3. 按顺序扫描 config-&gt;transitions 转移表，匹配 (event, currentState)
  │      ├─ 未找到匹配项 ──► 状态保持不变，返回 STATE_MACHINE_PROCESS_IGNORED
  │      └─ 找到匹配项 ───► (a) 若 action != NULL，调用 action(machine, userContext)
  │                         (b) 将 currentState 更新为 nextState
  │                         (c) 返回 STATE_MACHINE_PROCESS_HANDLED
</code></pre></div></div>

<p>这里的执行顺序是：<strong>动作函数 <code class="language-plaintext highlighter-rouge">action()</code> 在 <code class="language-plaintext highlighter-rouge">currentState</code> 更新之前执行</strong>。因此动作函数内部读取 <code class="language-plaintext highlighter-rouge">StateMachine_GetState()</code> 得到的是转移前的源状态；动作函数中通过 <code class="language-plaintext highlighter-rouge">StateMachine_PostEvent()</code> 投递的新事件会进入 FIFO 队列尾部，留待后续的 <code class="language-plaintext highlighter-rouge">Process()</code> 调用处理，不会在当前调用栈中发生递归重入。</p>

<h2 id="交互式内核演示状态转移与事件调度">交互式内核演示：状态转移与事件调度</h2>

<p>向下面的 8 槽 FIFO 投递事件后，单步执行 <code class="language-plaintext highlighter-rouge">StateMachine_Process()</code>，可以看到匹配的转移表行和动作执行顺序。队列为空时，演示会执行当前状态的循环事件。</p>

<div class="article-interactive-widget" id="fsm-demo-app" aria-label="C99 表驱动状态机交互演示">
  <div class="aiw-header">
    <span class="aiw-badge">INTERACTIVE WIDGET · 纯前端模拟</span>
    <h3 class="aiw-title">C99 表驱动有限状态机内核交互模拟器</h3>
    <p class="aiw-desc">模拟 <code>StateMachine_Process()</code> 单步调度：FIFO 外部事件出队、无符号自环（Loop Event）保活、静态转移表匹配与动作函数分派。</p>
  </div>
  <div class="aiw-body">
    <div class="aiw-stats" aria-label="状态机运行时状态">
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">当前状态 (State)</span>
        <span class="aiw-stat-value accent" id="fsm-stat-state">IDLE (待机)</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">调度事件 (Event)</span>
        <span class="aiw-stat-value" id="fsm-stat-event">就绪</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">执行动作 (Action)</span>
        <span class="aiw-stat-value" id="fsm-stat-action">无</span>
      </div>
      <div class="aiw-stat-item">
        <span class="aiw-stat-label">FIFO 事件队列堆积</span>
        <span class="aiw-stat-value warn" id="fsm-stat-qcount">0 / 8 (空闲)</span>
      </div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span>状态拓扑节点 (State Nodes)</span>
        <span class="aiw-hint">高亮带光圈者为当前活跃状态</span>
      </div>
      <div class="fsm-states-grid">
        <div class="fsm-state-card is-active" data-state-id="0">
          <div class="fsm-active-tag">ACTIVE</div>
          <div class="fsm-state-name">STATE_IDLE [0]</div>
          <div class="fsm-state-loop">Loop: EVENT_IDLE_LOOP</div>
        </div>
        <div class="fsm-state-card" data-state-id="1">
          <div class="fsm-active-tag">ACTIVE</div>
          <div class="fsm-state-name">STATE_RUNNING [1]</div>
          <div class="fsm-state-loop">Loop: EVENT_RUNNING_LOOP</div>
        </div>
        <div class="fsm-state-card" data-state-id="2">
          <div class="fsm-active-tag">ACTIVE</div>
          <div class="fsm-state-name">STATE_PEAK_HOLD [2]</div>
          <div class="fsm-state-loop">Loop: EVENT_PEAK_LOOP</div>
        </div>
        <div class="fsm-state-card" data-state-id="3">
          <div class="fsm-active-tag">ACTIVE</div>
          <div class="fsm-state-name">STATE_ALARM [3]</div>
          <div class="fsm-state-loop">Loop: EVENT_ALARM_LOOP</div>
        </div>
      </div>
    </div>

    <div class="fsm-queue-box">
      <div class="aiw-section-title">
        <span>静态 FIFO 外部事件队列 (容量: 8，循环数组环形回绕)</span>
        <span class="aiw-hint">H: 写入头指针(Head) · T: 读取尾指针(Tail)</span>
      </div>
      <div class="fsm-queue-slots" id="fsm-queue-slots"></div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span>外部事件投递 (模拟 ISR 中断或按键事件)</span>
        <span class="aiw-hint">调用 StateMachine_PostEvent()</span>
      </div>
      <div class="aiw-controls">
        <button type="button" class="aiw-btn primary" data-fsm-event="EVENT_START">投递 START (启动)</button>
        <button type="button" class="aiw-btn" data-fsm-event="EVENT_STOP">投递 STOP (停止)</button>
        <button type="button" class="aiw-btn" data-fsm-event="EVENT_PEAK">投递 PEAK (捕捉峰值)</button>
        <button type="button" class="aiw-btn danger" data-fsm-event="EVENT_ALARM">投递 ALARM (超载急停)</button>
        <button type="button" class="aiw-btn warn" data-fsm-event="EVENT_RESET">投递 RESET (故障复位)</button>
      </div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span>主循环内核单步推进 (StateMachine_Process)</span>
        <span class="aiw-hint">每次调用最多消费 1 个事件</span>
      </div>
      <div class="aiw-controls">
        <button type="button" class="aiw-btn primary" id="fsm-btn-step">单步调度 Process()</button>
        <button type="button" class="aiw-btn" id="fsm-btn-auto">连续自动运行</button>
        <button type="button" class="aiw-btn" id="fsm-btn-reset">重置状态机</button>
      </div>
    </div>

    <div>
      <div class="aiw-section-title">
        <span>C99 转移表匹配状态 (config-&gt;transitions)</span>
        <span class="aiw-hint">绿色行表示当前 Process() 命中的转移项</span>
      </div>
      <div class="fsm-table-container">
        <table class="fsm-table">
          <thead>
            <tr>
              <th>触发事件 (event)</th>
              <th>源状态 (currentState)</th>
              <th>执行动作 (action)</th>
              <th>目标状态 (nextState)</th>
              <th>语义说明</th>
            </tr>
          </thead>
          <tbody>
            <tr><td>EVENT_START</td><td>STATE_IDLE</td><td>Demo_Start</td><td>STATE_RUNNING</td><td>启动测试，清零计数并进入运行态</td></tr>
            <tr><td>EVENT_STOP</td><td>STATE_RUNNING</td><td>Demo_Stop</td><td>STATE_IDLE</td><td>手动停止测试，退出运行并归零</td></tr>
            <tr><td>EVENT_PEAK</td><td>STATE_RUNNING</td><td>Demo_PeakHold</td><td>STATE_PEAK_HOLD</td><td>捕捉到峰值点，锁存当前最大力值</td></tr>
            <tr><td>EVENT_STOP</td><td>STATE_PEAK_HOLD</td><td>Demo_Stop</td><td>STATE_IDLE</td><td>从峰值保持退出，返回空闲待机</td></tr>
            <tr><td>EVENT_ALARM</td><td>STATE_IDLE</td><td>Demo_Alarm</td><td>STATE_ALARM</td><td>待机超载安全报警</td></tr>
            <tr><td>EVENT_ALARM</td><td>STATE_RUNNING</td><td>Demo_Alarm</td><td>STATE_ALARM</td><td>测试超载立即急停并报警</td></tr>
            <tr><td>EVENT_ALARM</td><td>STATE_PEAK_HOLD</td><td>Demo_Alarm</td><td>STATE_ALARM</td><td>保持期超载急停并报警</td></tr>
            <tr><td>EVENT_RESET</td><td>STATE_ALARM</td><td>Demo_Reset</td><td>STATE_IDLE</td><td>操作员清除报警，复位回到待机</td></tr>
            <tr><td>EVENT_IDLE_LOOP</td><td>STATE_IDLE</td><td>NULL</td><td>STATE_IDLE</td><td>空闲状态周期自环保活</td></tr>
            <tr><td>EVENT_RUNNING_LOOP</td><td>STATE_RUNNING</td><td>Demo_RunningLoop</td><td>STATE_RUNNING</td><td>测试采样中自环 (满4次自动报峰值)</td></tr>
            <tr><td>EVENT_PEAK_LOOP</td><td>STATE_PEAK_HOLD</td><td>Demo_PeakLoop</td><td>STATE_PEAK_HOLD</td><td>峰值保持状态维持当前读数</td></tr>
            <tr><td>EVENT_ALARM_LOOP</td><td>STATE_ALARM</td><td>Demo_AlarmLoop</td><td>STATE_ALARM</td><td>声光报警周期自环交替闪烁</td></tr>
          </tbody>
        </table>
      </div>
    </div>

    <div class="aiw-log-panel">
      <div class="aiw-section-title">
        <span>内核执行轨迹日志 (Execution Trace)</span>
        <span class="aiw-hint">动作执行发生在状态更新前</span>
      </div>
      <div class="aiw-log" id="fsm-log" aria-live="polite"></div>
    </div>
  </div>
</div>

<h2 id="完整源码实现">完整源码实现</h2>

<h3 id="头文件state_machineh">头文件：<code class="language-plaintext highlighter-rouge">state_machine.h</code></h3>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/**
 * @file state_machine.h
 * @brief 无动态内存依赖的通用表驱动状态机。
 */</span>
<span class="cp">#ifndef STATE_MACHINE_H
#define STATE_MACHINE_H
</span>
<span class="cp">#ifdef __cplusplus
</span><span class="k">extern</span> <span class="s">"C"</span> <span class="p">{</span>
<span class="cp">#endif
</span>
<span class="cp">#include</span> <span class="cpf">&lt;stdbool.h&gt;</span><span class="cp">
#include</span> <span class="cpf">&lt;stdint.h&gt;</span><span class="cp">
</span>
<span class="k">typedef</span> <span class="kt">uint16_t</span> <span class="n">StateMachineState</span><span class="p">;</span>
<span class="k">typedef</span> <span class="kt">uint16_t</span> <span class="n">StateMachineEvent</span><span class="p">;</span>

<span class="k">struct</span> <span class="n">StateMachine</span><span class="p">;</span>

<span class="k">typedef</span> <span class="kt">void</span> <span class="p">(</span><span class="o">*</span><span class="n">StateMachineAction</span><span class="p">)(</span><span class="k">struct</span> <span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span> <span class="kt">void</span> <span class="o">*</span><span class="n">userContext</span><span class="p">);</span>

<span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="n">StateMachineEvent</span> <span class="n">event</span><span class="p">;</span>
    <span class="n">StateMachineState</span> <span class="n">currentState</span><span class="p">;</span>
    <span class="n">StateMachineAction</span> <span class="n">action</span><span class="p">;</span>
    <span class="n">StateMachineState</span> <span class="n">nextState</span><span class="p">;</span>
<span class="p">}</span> <span class="n">StateMachineTransition</span><span class="p">;</span>

<span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="k">const</span> <span class="n">StateMachineTransition</span> <span class="o">*</span><span class="n">transitions</span><span class="p">;</span>
    <span class="k">const</span> <span class="n">StateMachineEvent</span> <span class="o">*</span><span class="n">loopEvents</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">transitionCount</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">stateCount</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">eventCount</span><span class="p">;</span>
<span class="p">}</span> <span class="n">StateMachineConfig</span><span class="p">;</span>

<span class="k">typedef</span> <span class="k">enum</span>
<span class="p">{</span>
    <span class="n">STATE_MACHINE_PROCESS_HANDLED</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span>
    <span class="n">STATE_MACHINE_PROCESS_IGNORED</span><span class="p">,</span>
    <span class="n">STATE_MACHINE_PROCESS_INVALID</span>
<span class="p">}</span> <span class="n">StateMachineProcessResult</span><span class="p">;</span>

<span class="k">typedef</span> <span class="k">struct</span> <span class="n">StateMachine</span>
<span class="p">{</span>
    <span class="k">const</span> <span class="n">StateMachineConfig</span> <span class="o">*</span><span class="n">config</span><span class="p">;</span>
    <span class="n">StateMachineEvent</span> <span class="o">*</span><span class="n">eventQueue</span><span class="p">;</span>
    <span class="kt">void</span> <span class="o">*</span><span class="n">userContext</span><span class="p">;</span>
    <span class="n">StateMachineState</span> <span class="n">currentState</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">queueCapacity</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">eventReadIndex</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">eventWriteIndex</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">eventCount</span><span class="p">;</span>
<span class="p">}</span> <span class="n">StateMachine</span><span class="p">;</span>

<span class="cm">/* 配置表和队列存储的生命周期必须长于 machine。接口默认在单执行上下文调用。 */</span>
<span class="n">bool</span> <span class="n">StateMachine_Init</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span>
                       <span class="k">const</span> <span class="n">StateMachineConfig</span> <span class="o">*</span><span class="n">config</span><span class="p">,</span>
                       <span class="n">StateMachineState</span> <span class="n">initialState</span><span class="p">,</span>
                       <span class="n">StateMachineEvent</span> <span class="o">*</span><span class="n">eventQueue</span><span class="p">,</span>
                       <span class="kt">uint16_t</span> <span class="n">queueCapacity</span><span class="p">,</span>
                       <span class="kt">void</span> <span class="o">*</span><span class="n">userContext</span><span class="p">);</span>
<span class="kt">void</span> <span class="n">StateMachine_Reset</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span> <span class="n">StateMachineState</span> <span class="n">initialState</span><span class="p">);</span>
<span class="n">StateMachineProcessResult</span> <span class="n">StateMachine_Process</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">);</span>
<span class="n">bool</span> <span class="n">StateMachine_PostEvent</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span> <span class="n">StateMachineEvent</span> <span class="n">event</span><span class="p">);</span>
<span class="kt">void</span> <span class="n">StateMachine_ClearEvents</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">);</span>
<span class="n">StateMachineState</span> <span class="n">StateMachine_GetState</span><span class="p">(</span><span class="k">const</span> <span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">);</span>

<span class="cp">#ifdef __cplusplus
</span><span class="p">}</span>
<span class="cp">#endif
</span>
<span class="cp">#endif </span><span class="cm">/* STATE_MACHINE_H */</span><span class="cp">
</span></code></pre></div></div>

<h3 id="源文件state_machinec">源文件：<code class="language-plaintext highlighter-rouge">state_machine.c</code></h3>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/**
 * @file state_machine.c
 * @brief 无动态内存依赖的通用表驱动状态机。
 */</span>
<span class="cp">#include</span> <span class="cpf">"state_machine.h"</span><span class="cp">
</span>
<span class="cp">#include</span> <span class="cpf">&lt;stddef.h&gt;</span><span class="cp">
</span>
<span class="k">static</span> <span class="n">bool</span> <span class="nf">StateMachine_IsConfigValid</span><span class="p">(</span><span class="k">const</span> <span class="n">StateMachineConfig</span> <span class="o">*</span><span class="n">config</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">uint16_t</span> <span class="n">index</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">otherIndex</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">state</span><span class="p">;</span>
    <span class="n">bool</span> <span class="n">loopTransitionFound</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">((</span><span class="n">config</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">transitions</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span>
        <span class="p">(</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">loopEvents</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">transitionCount</span> <span class="o">==</span> <span class="mi">0U</span><span class="p">)</span> <span class="o">||</span>
        <span class="p">(</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">stateCount</span> <span class="o">==</span> <span class="mi">0U</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">eventCount</span> <span class="o">==</span> <span class="mi">0U</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">for</span> <span class="p">(</span><span class="n">index</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span> <span class="n">index</span> <span class="o">&lt;</span> <span class="n">config</span><span class="o">-&gt;</span><span class="n">stateCount</span><span class="p">;</span> <span class="n">index</span><span class="o">++</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">if</span> <span class="p">(</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">loopEvents</span><span class="p">[</span><span class="n">index</span><span class="p">]</span> <span class="o">&gt;=</span> <span class="n">config</span><span class="o">-&gt;</span><span class="n">eventCount</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">for</span> <span class="p">(</span><span class="n">index</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span> <span class="n">index</span> <span class="o">&lt;</span> <span class="n">config</span><span class="o">-&gt;</span><span class="n">transitionCount</span><span class="p">;</span> <span class="n">index</span><span class="o">++</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">const</span> <span class="n">StateMachineTransition</span> <span class="o">*</span><span class="n">transition</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">transitions</span><span class="p">[</span><span class="n">index</span><span class="p">];</span>

        <span class="k">if</span> <span class="p">((</span><span class="n">transition</span><span class="o">-&gt;</span><span class="n">event</span> <span class="o">&gt;=</span> <span class="n">config</span><span class="o">-&gt;</span><span class="n">eventCount</span><span class="p">)</span> <span class="o">||</span>
            <span class="p">(</span><span class="n">transition</span><span class="o">-&gt;</span><span class="n">currentState</span> <span class="o">&gt;=</span> <span class="n">config</span><span class="o">-&gt;</span><span class="n">stateCount</span><span class="p">)</span> <span class="o">||</span>
            <span class="p">(</span><span class="n">transition</span><span class="o">-&gt;</span><span class="n">nextState</span> <span class="o">&gt;=</span> <span class="n">config</span><span class="o">-&gt;</span><span class="n">stateCount</span><span class="p">))</span>
        <span class="p">{</span>
            <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
        <span class="p">}</span>

        <span class="k">for</span> <span class="p">(</span><span class="n">otherIndex</span> <span class="o">=</span> <span class="p">(</span><span class="kt">uint16_t</span><span class="p">)(</span><span class="n">index</span> <span class="o">+</span> <span class="mi">1U</span><span class="p">);</span>
             <span class="n">otherIndex</span> <span class="o">&lt;</span> <span class="n">config</span><span class="o">-&gt;</span><span class="n">transitionCount</span><span class="p">;</span>
             <span class="n">otherIndex</span><span class="o">++</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">const</span> <span class="n">StateMachineTransition</span> <span class="o">*</span><span class="n">otherTransition</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">transitions</span><span class="p">[</span><span class="n">otherIndex</span><span class="p">];</span>

            <span class="k">if</span> <span class="p">((</span><span class="n">transition</span><span class="o">-&gt;</span><span class="n">event</span> <span class="o">==</span> <span class="n">otherTransition</span><span class="o">-&gt;</span><span class="n">event</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
                <span class="p">(</span><span class="n">transition</span><span class="o">-&gt;</span><span class="n">currentState</span> <span class="o">==</span> <span class="n">otherTransition</span><span class="o">-&gt;</span><span class="n">currentState</span><span class="p">))</span>
            <span class="p">{</span>
                <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">for</span> <span class="p">(</span><span class="n">state</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span> <span class="n">state</span> <span class="o">&lt;</span> <span class="n">config</span><span class="o">-&gt;</span><span class="n">stateCount</span><span class="p">;</span> <span class="n">state</span><span class="o">++</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">loopTransitionFound</span> <span class="o">=</span> <span class="nb">false</span><span class="p">;</span>
        <span class="k">for</span> <span class="p">(</span><span class="n">index</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span> <span class="n">index</span> <span class="o">&lt;</span> <span class="n">config</span><span class="o">-&gt;</span><span class="n">transitionCount</span><span class="p">;</span> <span class="n">index</span><span class="o">++</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">if</span> <span class="p">((</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">transitions</span><span class="p">[</span><span class="n">index</span><span class="p">].</span><span class="n">currentState</span> <span class="o">==</span> <span class="n">state</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
                <span class="p">(</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">transitions</span><span class="p">[</span><span class="n">index</span><span class="p">].</span><span class="n">event</span> <span class="o">==</span> <span class="n">config</span><span class="o">-&gt;</span><span class="n">loopEvents</span><span class="p">[</span><span class="n">state</span><span class="p">]))</span>
            <span class="p">{</span>
                <span class="n">loopTransitionFound</span> <span class="o">=</span> <span class="nb">true</span><span class="p">;</span>
                <span class="k">break</span><span class="p">;</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">loopTransitionFound</span><span class="p">)</span>
        <span class="p">{</span>
            <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="nb">true</span><span class="p">;</span>
<span class="p">}</span>

<span class="k">static</span> <span class="n">bool</span> <span class="nf">StateMachine_TakeEvent</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span> <span class="n">StateMachineEvent</span> <span class="o">*</span><span class="n">event</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">((</span><span class="n">machine</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">event</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventCount</span> <span class="o">==</span> <span class="mi">0U</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="o">*</span><span class="n">event</span> <span class="o">=</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventQueue</span><span class="p">[</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventReadIndex</span><span class="p">];</span>
    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventReadIndex</span><span class="o">++</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventReadIndex</span> <span class="o">&gt;=</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">queueCapacity</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventReadIndex</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventCount</span><span class="o">--</span><span class="p">;</span>
    <span class="k">return</span> <span class="nb">true</span><span class="p">;</span>
<span class="p">}</span>

<span class="n">bool</span> <span class="nf">StateMachine_Init</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span>
                       <span class="k">const</span> <span class="n">StateMachineConfig</span> <span class="o">*</span><span class="n">config</span><span class="p">,</span>
                       <span class="n">StateMachineState</span> <span class="n">initialState</span><span class="p">,</span>
                       <span class="n">StateMachineEvent</span> <span class="o">*</span><span class="n">eventQueue</span><span class="p">,</span>
                       <span class="kt">uint16_t</span> <span class="n">queueCapacity</span><span class="p">,</span>
                       <span class="kt">void</span> <span class="o">*</span><span class="n">userContext</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">((</span><span class="n">machine</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">eventQueue</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">queueCapacity</span> <span class="o">==</span> <span class="mi">0U</span><span class="p">)</span> <span class="o">||</span>
        <span class="o">!</span><span class="n">StateMachine_IsConfigValid</span><span class="p">(</span><span class="n">config</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">initialState</span> <span class="o">&gt;=</span> <span class="n">config</span><span class="o">-&gt;</span><span class="n">stateCount</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">config</span> <span class="o">=</span> <span class="n">config</span><span class="p">;</span>
    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventQueue</span> <span class="o">=</span> <span class="n">eventQueue</span><span class="p">;</span>
    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">userContext</span> <span class="o">=</span> <span class="n">userContext</span><span class="p">;</span>
    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">queueCapacity</span> <span class="o">=</span> <span class="n">queueCapacity</span><span class="p">;</span>
    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">currentState</span> <span class="o">=</span> <span class="n">initialState</span><span class="p">;</span>
    <span class="n">StateMachine_ClearEvents</span><span class="p">(</span><span class="n">machine</span><span class="p">);</span>
    <span class="k">return</span> <span class="nb">true</span><span class="p">;</span>
<span class="p">}</span>

<span class="kt">void</span> <span class="nf">StateMachine_Reset</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span> <span class="n">StateMachineState</span> <span class="n">initialState</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">((</span><span class="n">machine</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">config</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span>
        <span class="p">(</span><span class="n">initialState</span> <span class="o">&gt;=</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">stateCount</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">StateMachine_ClearEvents</span><span class="p">(</span><span class="n">machine</span><span class="p">);</span>
    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">currentState</span> <span class="o">=</span> <span class="n">initialState</span><span class="p">;</span>
<span class="p">}</span>

<span class="n">StateMachineProcessResult</span> <span class="nf">StateMachine_Process</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">StateMachineEvent</span> <span class="n">event</span><span class="p">;</span>
    <span class="kt">uint16_t</span> <span class="n">index</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">((</span><span class="n">machine</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">config</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span>
        <span class="p">(</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventQueue</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span>
        <span class="p">(</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">currentState</span> <span class="o">&gt;=</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">stateCount</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="n">STATE_MACHINE_PROCESS_INVALID</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">StateMachine_TakeEvent</span><span class="p">(</span><span class="n">machine</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">event</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="n">event</span> <span class="o">=</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">loopEvents</span><span class="p">[</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">currentState</span><span class="p">];</span>
    <span class="p">}</span>

    <span class="k">for</span> <span class="p">(</span><span class="n">index</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span> <span class="n">index</span> <span class="o">&lt;</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">transitionCount</span><span class="p">;</span> <span class="n">index</span><span class="o">++</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">const</span> <span class="n">StateMachineTransition</span> <span class="o">*</span><span class="n">transition</span> <span class="o">=</span> <span class="o">&amp;</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">transitions</span><span class="p">[</span><span class="n">index</span><span class="p">];</span>

        <span class="k">if</span> <span class="p">((</span><span class="n">transition</span><span class="o">-&gt;</span><span class="n">event</span> <span class="o">==</span> <span class="n">event</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
            <span class="p">(</span><span class="n">transition</span><span class="o">-&gt;</span><span class="n">currentState</span> <span class="o">==</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">currentState</span><span class="p">))</span>
        <span class="p">{</span>
            <span class="k">if</span> <span class="p">(</span><span class="n">transition</span><span class="o">-&gt;</span><span class="n">action</span> <span class="o">!=</span> <span class="nb">NULL</span><span class="p">)</span>
            <span class="p">{</span>
                <span class="n">transition</span><span class="o">-&gt;</span><span class="n">action</span><span class="p">(</span><span class="n">machine</span><span class="p">,</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">userContext</span><span class="p">);</span>
            <span class="p">}</span>
            <span class="n">machine</span><span class="o">-&gt;</span><span class="n">currentState</span> <span class="o">=</span> <span class="n">transition</span><span class="o">-&gt;</span><span class="n">nextState</span><span class="p">;</span>
            <span class="k">return</span> <span class="n">STATE_MACHINE_PROCESS_HANDLED</span><span class="p">;</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="n">STATE_MACHINE_PROCESS_IGNORED</span><span class="p">;</span>
<span class="p">}</span>

<span class="n">bool</span> <span class="nf">StateMachine_PostEvent</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span> <span class="n">StateMachineEvent</span> <span class="n">event</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">((</span><span class="n">machine</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">config</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span>
        <span class="p">(</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventQueue</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span> <span class="o">||</span> <span class="p">(</span><span class="n">event</span> <span class="o">&gt;=</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">config</span><span class="o">-&gt;</span><span class="n">eventCount</span><span class="p">)</span> <span class="o">||</span>
        <span class="p">(</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventCount</span> <span class="o">&gt;=</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">queueCapacity</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="nb">false</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventQueue</span><span class="p">[</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventWriteIndex</span><span class="p">]</span> <span class="o">=</span> <span class="n">event</span><span class="p">;</span>
    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventWriteIndex</span><span class="o">++</span><span class="p">;</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventWriteIndex</span> <span class="o">&gt;=</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">queueCapacity</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventWriteIndex</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span>
    <span class="p">}</span>
    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventCount</span><span class="o">++</span><span class="p">;</span>
    <span class="k">return</span> <span class="nb">true</span><span class="p">;</span>
<span class="p">}</span>

<span class="kt">void</span> <span class="nf">StateMachine_ClearEvents</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">machine</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventReadIndex</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span>
    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventWriteIndex</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span>
    <span class="n">machine</span><span class="o">-&gt;</span><span class="n">eventCount</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span>
<span class="p">}</span>

<span class="n">StateMachineState</span> <span class="nf">StateMachine_GetState</span><span class="p">(</span><span class="k">const</span> <span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">)</span>
<span class="p">{</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">machine</span> <span class="o">==</span> <span class="nb">NULL</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="mi">0U</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="n">machine</span><span class="o">-&gt;</span><span class="n">currentState</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="最小可运行示例">最小可运行示例</h2>

<p>下面演示一个包含 <code class="language-plaintext highlighter-rouge">IDLE</code> 和 <code class="language-plaintext highlighter-rouge">RUNNING</code> 两个状态的例子：收到 <code class="language-plaintext highlighter-rouge">START</code> 事件后进入 <code class="language-plaintext highlighter-rouge">RUNNING</code>，执行 3 次循环动作后自动投递 <code class="language-plaintext highlighter-rouge">STOP</code> 事件切回 <code class="language-plaintext highlighter-rouge">IDLE</code>。</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#include</span> <span class="cpf">"state_machine.h"</span><span class="cp">
#include</span> <span class="cpf">&lt;stdio.h&gt;</span><span class="cp">
</span>
<span class="cp">#define DEMO_QUEUE_SIZE   (4U)
#define ARRAY_SIZE(array) (sizeof(array) / sizeof((array)[0]))
</span>
<span class="k">typedef</span> <span class="k">enum</span>
<span class="p">{</span>
    <span class="n">DEMO_STATE_IDLE</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span>
    <span class="n">DEMO_STATE_RUNNING</span><span class="p">,</span>
    <span class="n">DEMO_STATE_COUNT</span>
<span class="p">}</span> <span class="n">DemoState</span><span class="p">;</span>

<span class="k">typedef</span> <span class="k">enum</span>
<span class="p">{</span>
    <span class="n">DEMO_EVENT_IDLE_LOOP</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span>
    <span class="n">DEMO_EVENT_RUNNING_LOOP</span><span class="p">,</span>
    <span class="n">DEMO_EVENT_START</span><span class="p">,</span>
    <span class="n">DEMO_EVENT_STOP</span><span class="p">,</span>
    <span class="n">DEMO_EVENT_COUNT</span>
<span class="p">}</span> <span class="n">DemoEvent</span><span class="p">;</span>

<span class="k">typedef</span> <span class="k">struct</span>
<span class="p">{</span>
    <span class="n">StateMachine</span> <span class="n">machine</span><span class="p">;</span>
    <span class="n">StateMachineEvent</span> <span class="n">queue</span><span class="p">[</span><span class="n">DEMO_QUEUE_SIZE</span><span class="p">];</span>
    <span class="kt">uint32_t</span> <span class="n">runningCount</span><span class="p">;</span>
<span class="p">}</span> <span class="n">DemoContext</span><span class="p">;</span>

<span class="k">static</span> <span class="kt">void</span> <span class="nf">Demo_Start</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span> <span class="kt">void</span> <span class="o">*</span><span class="n">userContext</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">DemoContext</span> <span class="o">*</span><span class="n">context</span> <span class="o">=</span> <span class="p">(</span><span class="n">DemoContext</span> <span class="o">*</span><span class="p">)</span><span class="n">userContext</span><span class="p">;</span>
    <span class="p">(</span><span class="kt">void</span><span class="p">)</span><span class="n">machine</span><span class="p">;</span>
    <span class="n">context</span><span class="o">-&gt;</span><span class="n">runningCount</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"start</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">static</span> <span class="kt">void</span> <span class="nf">Demo_RunningLoop</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span> <span class="kt">void</span> <span class="o">*</span><span class="n">userContext</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">DemoContext</span> <span class="o">*</span><span class="n">context</span> <span class="o">=</span> <span class="p">(</span><span class="n">DemoContext</span> <span class="o">*</span><span class="p">)</span><span class="n">userContext</span><span class="p">;</span>
    <span class="n">context</span><span class="o">-&gt;</span><span class="n">runningCount</span><span class="o">++</span><span class="p">;</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"running: %lu</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="p">(</span><span class="kt">unsigned</span> <span class="kt">long</span><span class="p">)</span><span class="n">context</span><span class="o">-&gt;</span><span class="n">runningCount</span><span class="p">);</span>
    <span class="k">if</span> <span class="p">(</span><span class="n">context</span><span class="o">-&gt;</span><span class="n">runningCount</span> <span class="o">&gt;=</span> <span class="mi">3U</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="p">(</span><span class="kt">void</span><span class="p">)</span><span class="n">StateMachine_PostEvent</span><span class="p">(</span><span class="n">machine</span><span class="p">,</span> <span class="n">DEMO_EVENT_STOP</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="k">static</span> <span class="kt">void</span> <span class="nf">Demo_Stop</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span> <span class="kt">void</span> <span class="o">*</span><span class="n">userContext</span><span class="p">)</span>
<span class="p">{</span>
    <span class="p">(</span><span class="kt">void</span><span class="p">)</span><span class="n">machine</span><span class="p">;</span>
    <span class="p">(</span><span class="kt">void</span><span class="p">)</span><span class="n">userContext</span><span class="p">;</span>
    <span class="n">printf</span><span class="p">(</span><span class="s">"stop</span><span class="se">\n</span><span class="s">"</span><span class="p">);</span>
<span class="p">}</span>

<span class="k">static</span> <span class="k">const</span> <span class="n">StateMachineEvent</span> <span class="n">demoLoopEvents</span><span class="p">[</span><span class="n">DEMO_STATE_COUNT</span><span class="p">]</span> <span class="o">=</span>
<span class="p">{</span>
    <span class="n">DEMO_EVENT_IDLE_LOOP</span><span class="p">,</span>
    <span class="n">DEMO_EVENT_RUNNING_LOOP</span>
<span class="p">};</span>

<span class="k">static</span> <span class="k">const</span> <span class="n">StateMachineTransition</span> <span class="n">demoTransitions</span><span class="p">[]</span> <span class="o">=</span>
<span class="p">{</span>
    <span class="p">{</span><span class="n">DEMO_EVENT_IDLE_LOOP</span><span class="p">,</span>    <span class="n">DEMO_STATE_IDLE</span><span class="p">,</span>    <span class="nb">NULL</span><span class="p">,</span>             <span class="n">DEMO_STATE_IDLE</span><span class="p">},</span>
    <span class="p">{</span><span class="n">DEMO_EVENT_RUNNING_LOOP</span><span class="p">,</span> <span class="n">DEMO_STATE_RUNNING</span><span class="p">,</span> <span class="n">Demo_RunningLoop</span><span class="p">,</span> <span class="n">DEMO_STATE_RUNNING</span><span class="p">},</span>
    <span class="p">{</span><span class="n">DEMO_EVENT_START</span><span class="p">,</span>        <span class="n">DEMO_STATE_IDLE</span><span class="p">,</span>    <span class="n">Demo_Start</span><span class="p">,</span>       <span class="n">DEMO_STATE_RUNNING</span><span class="p">},</span>
    <span class="p">{</span><span class="n">DEMO_EVENT_STOP</span><span class="p">,</span>         <span class="n">DEMO_STATE_RUNNING</span><span class="p">,</span> <span class="n">Demo_Stop</span><span class="p">,</span>        <span class="n">DEMO_STATE_IDLE</span><span class="p">}</span>
<span class="p">};</span>

<span class="k">static</span> <span class="k">const</span> <span class="n">StateMachineConfig</span> <span class="n">demoConfig</span> <span class="o">=</span>
<span class="p">{</span>
    <span class="n">demoTransitions</span><span class="p">,</span>
    <span class="n">demoLoopEvents</span><span class="p">,</span>
    <span class="p">(</span><span class="kt">uint16_t</span><span class="p">)</span><span class="n">ARRAY_SIZE</span><span class="p">(</span><span class="n">demoTransitions</span><span class="p">),</span>
    <span class="p">(</span><span class="kt">uint16_t</span><span class="p">)</span><span class="n">DEMO_STATE_COUNT</span><span class="p">,</span>
    <span class="p">(</span><span class="kt">uint16_t</span><span class="p">)</span><span class="n">DEMO_EVENT_COUNT</span>
<span class="p">};</span>

<span class="kt">int</span> <span class="nf">main</span><span class="p">(</span><span class="kt">void</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">DemoContext</span> <span class="n">context</span><span class="p">;</span>
    <span class="kt">uint32_t</span> <span class="n">index</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="n">StateMachine_Init</span><span class="p">(</span><span class="o">&amp;</span><span class="n">context</span><span class="p">.</span><span class="n">machine</span><span class="p">,</span>
                           <span class="o">&amp;</span><span class="n">demoConfig</span><span class="p">,</span>
                           <span class="n">DEMO_STATE_IDLE</span><span class="p">,</span>
                           <span class="n">context</span><span class="p">.</span><span class="n">queue</span><span class="p">,</span>
                           <span class="n">DEMO_QUEUE_SIZE</span><span class="p">,</span>
                           <span class="o">&amp;</span><span class="n">context</span><span class="p">))</span>
    <span class="p">{</span>
        <span class="k">return</span> <span class="mi">1</span><span class="p">;</span>
    <span class="p">}</span>

    <span class="p">(</span><span class="kt">void</span><span class="p">)</span><span class="n">StateMachine_PostEvent</span><span class="p">(</span><span class="o">&amp;</span><span class="n">context</span><span class="p">.</span><span class="n">machine</span><span class="p">,</span> <span class="n">DEMO_EVENT_START</span><span class="p">);</span>

    <span class="k">for</span> <span class="p">(</span><span class="n">index</span> <span class="o">=</span> <span class="mi">0U</span><span class="p">;</span> <span class="n">index</span> <span class="o">&lt;</span> <span class="mi">6U</span><span class="p">;</span> <span class="n">index</span><span class="o">++</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="p">(</span><span class="kt">void</span><span class="p">)</span><span class="n">StateMachine_Process</span><span class="p">(</span><span class="o">&amp;</span><span class="n">context</span><span class="p">.</span><span class="n">machine</span><span class="p">);</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<h2 id="状态表设计准则与工程实践">状态表设计准则与工程实践</h2>

<h3 id="1-每个状态必须绑定循环事件">1. 每个状态必须绑定循环事件</h3>

<p>即使某个状态在空闲时无事可做，也必须在 <code class="language-plaintext highlighter-rouge">loopEvents</code> 中指定一个循环事件，并在转移表中配置一条空动作自环：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="n">EVENT_IDLE_LOOP</span><span class="p">,</span> <span class="n">STATE_IDLE</span><span class="p">,</span> <span class="nb">NULL</span><span class="p">,</span> <span class="n">STATE_IDLE</span><span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">StateMachine_IsConfigValid()</code> 会在初始化时强制检查每个状态是否都存在对应的循环转移，防止遗漏状态分支。</p>

<h3 id="2-动作函数保持非阻塞与无符号超时计算">2. 动作函数保持非阻塞与无符号超时计算</h3>

<p>动作函数必须快速返回，任何耗时的外设等待（如 Flash 擦写、传感器稳定等待）都应拆分为循环事件中的非阻塞轮询，并使用无符号差值计算超时以处理 32 位计数器回绕：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">static</span> <span class="kt">void</span> <span class="nf">Action_WaitLoop</span><span class="p">(</span><span class="n">StateMachine</span> <span class="o">*</span><span class="n">machine</span><span class="p">,</span> <span class="kt">void</span> <span class="o">*</span><span class="n">userContext</span><span class="p">)</span>
<span class="p">{</span>
    <span class="n">MyContext</span> <span class="o">*</span><span class="n">ctx</span> <span class="o">=</span> <span class="p">(</span><span class="n">MyContext</span> <span class="o">*</span><span class="p">)</span><span class="n">userContext</span><span class="p">;</span>

    <span class="k">if</span> <span class="p">(</span><span class="n">Peripheral_IsDone</span><span class="p">())</span>
    <span class="p">{</span>
        <span class="p">(</span><span class="kt">void</span><span class="p">)</span><span class="n">StateMachine_PostEvent</span><span class="p">(</span><span class="n">machine</span><span class="p">,</span> <span class="n">EVENT_OP_DONE</span><span class="p">);</span>
    <span class="p">}</span>
    <span class="k">else</span> <span class="k">if</span> <span class="p">((</span><span class="kt">uint32_t</span><span class="p">)(</span><span class="n">PlatformTime_GetMs</span><span class="p">()</span> <span class="o">-</span> <span class="n">ctx</span><span class="o">-&gt;</span><span class="n">startTick</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">ctx</span><span class="o">-&gt;</span><span class="n">timeoutMs</span><span class="p">)</span>
    <span class="p">{</span>
        <span class="p">(</span><span class="kt">void</span><span class="p">)</span><span class="n">StateMachine_PostEvent</span><span class="p">(</span><span class="n">machine</span><span class="p">,</span> <span class="n">EVENT_OP_TIMEOUT</span><span class="p">);</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="3-中断与多线程边界">3. 中断与多线程边界</h3>

<p>本模块默认在<strong>单一执行上下文</strong>（如裸机主循环或单个 RTOS 任务）中调用，内部不加锁也不开关中断：</p>

<ul>
  <li><strong>裸机环境</strong>：ISR 中只置硬件标志位或写入专用的单生产者无锁环形缓冲，由主循环轮询标志后调用 <code class="language-plaintext highlighter-rouge">StateMachine_PostEvent()</code> 并推进 <code class="language-plaintext highlighter-rouge">StateMachine_Process()</code>。</li>
  <li><strong>RTOS 环境</strong>：由单个专属任务持有状态机实例，其他任务或中断通过 RTOS 消息队列向该任务投递事件。</li>
</ul>

<h2 id="单元测试与集成检查清单">单元测试与集成检查清单</h2>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />合法配置可正常初始化；空指针、零容量、越界状态/事件、重复 <code class="language-plaintext highlighter-rouge">(currentState, event)</code> 或缺失循环转移时 <code class="language-plaintext highlighter-rouge">StateMachine_Init()</code> 返回 <code class="language-plaintext highlighter-rouge">false</code>。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />FIFO 队列读写索引跨队尾回绕正确；队列满时拒绝新事件并返回 <code class="language-plaintext highlighter-rouge">false</code>，不覆盖已有未处理事件。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />每次 <code class="language-plaintext highlighter-rouge">StateMachine_Process()</code> 最多消费一个事件；队列非空时优先消费外部事件，队列为空时才执行当前状态的循环事件。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />故障、急停、关机等安全事件在所有可能触发的状态下均配置了转移条目。</li>
</ul>]]></content><author><name></name></author><category term="嵌入式" /><category term="C99" /><category term="状态机" /><category term="裸机架构" /><category term="嵌入式软件" /><summary type="html"><![CDATA[零动态内存、不依赖特定 MCU 或 RTOS 的 C99 通用表驱动有限状态机（FSM）实现，内置静态 FIFO 事件队列、状态自环事件与初始化配置合法性自检。]]></summary></entry><entry><title type="html">ST7789 命令时序、窗口配置与像素 DMA 传输参考</title><link href="https://single5240.github.io/articles/st7789-command-and-pixel-dma.html" rel="alternate" type="text/html" title="ST7789 命令时序、窗口配置与像素 DMA 传输参考" /><published>2026-10-07T00:00:00+08:00</published><updated>2026-10-07T00:00:00+08:00</updated><id>https://single5240.github.io/articles/st7789-command-and-pixel-dma</id><content type="html" xml:base="https://single5240.github.io/articles/st7789-command-and-pixel-dma.html"><![CDATA[<p>移植 ST7789 驱动时，需要先核对命令、RS/CS 时序和显示窗口，再接入像素 DMA。下面按这个顺序整理接口与传输流程。上层 UI 条带裁剪与帧快照机制见 <a href="/articles/n32g455-lcd-strip-rendering.html">N32G455 LCD 条带渲染与异步刷新实现分析</a>，花屏与反色问题见 <a href="/articles/lcd-rgb565-color-troubleshooting.html">嵌入式 RGB565 LCD 颜色反转、字节错序与花屏排查指南</a>。</p>

<h2 id="可迁移边界与最小驱动接口">可迁移边界与最小驱动接口</h2>

<p>将本驱动迁移到新板卡或同系列 MCU 时，各部分的复用边界如下：</p>

<table>
  <thead>
    <tr>
      <th>可直接带走</th>
      <th>必须按新板重绑</th>
      <th>不要一起带走</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>命令码、参数格式、初始化顺序与延时</td>
      <td>GPIO / SPI / DMA 通道 / 时钟分频</td>
      <td>上层 UI 业务页面、具体图元画布、字库</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">RS</code> / <code class="language-plaintext highlighter-rouge">CS</code> / <code class="language-plaintext highlighter-rouge">RST</code> 事务规则</td>
      <td>NVIC 与 <code class="language-plaintext highlighter-rouge">DmaRxIrqHandler</code> 中断入口</td>
      <td>业务页面布局</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CASET</code> / <code class="language-plaintext highlighter-rouge">RASET</code> / <code class="language-plaintext highlighter-rouge">RAMWR</code> 窗口写 GRAM</td>
      <td>背光有效极性与调光定时器</td>
      <td>无关传感器的 DMA 通道</td>
    </tr>
    <tr>
      <td>命令 8 bit、像素 16 bit RGB565 切换</td>
      <td>由实际 <code class="language-plaintext highlighter-rouge">PCLK</code> 与分频算出的 <code class="language-plaintext highlighter-rouge">SCK</code></td>
      <td>Mem2Mem DMA 模式</td>
    </tr>
    <tr>
      <td>TX DMA 发像素 + RX DMA 丢弃全双工回读</td>
      <td>与高优先级采样 DMA 的抢占关系</td>
      <td> </td>
    </tr>
    <tr>
      <td>DMA 完成后再等 <code class="language-plaintext highlighter-rouge">SPI BUSY = 0</code> 才拉高 <code class="language-plaintext highlighter-rouge">CS</code></td>
      <td> </td>
      <td> </td>
    </tr>
  </tbody>
</table>

<p>新工程建议保留以下最小驱动接口语义：</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">bool</span> <span class="nf">LcdXxx_Init</span><span class="p">(</span><span class="kt">void</span><span class="p">);</span>
<span class="kt">void</span> <span class="nf">LcdXxx_Process</span><span class="p">(</span><span class="kt">void</span><span class="p">);</span>          <span class="cm">/* 主循环推进异步收尾 */</span>
<span class="n">bool</span> <span class="nf">LcdXxx_IsBusy</span><span class="p">(</span><span class="kt">void</span><span class="p">);</span>
<span class="n">bool</span> <span class="nf">LcdXxx_DrawPixelsAsync</span><span class="p">(</span><span class="kt">uint16_t</span> <span class="n">x</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">y</span><span class="p">,</span>
                            <span class="kt">uint16_t</span> <span class="n">w</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">h</span><span class="p">,</span>
                            <span class="k">const</span> <span class="kt">uint16_t</span> <span class="o">*</span><span class="n">pixels</span><span class="p">);</span>
<span class="kt">void</span> <span class="nf">LcdXxx_DmaRxIrqHandler</span><span class="p">(</span><span class="kt">void</span><span class="p">);</span>
<span class="kt">void</span> <span class="nf">LcdXxx_BacklightOn</span><span class="p">(</span><span class="kt">void</span><span class="p">);</span>
<span class="n">bool</span> <span class="nf">LcdXxx_SetOrientation</span><span class="p">(</span><span class="kt">uint8_t</span> <span class="n">orientation</span><span class="p">);</span> <span class="cm">/* 可选 */</span>
<span class="kt">void</span> <span class="nf">LcdXxx_FillRect</span><span class="p">(</span><span class="kt">uint16_t</span> <span class="n">x</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">y</span><span class="p">,</span>
                     <span class="kt">uint16_t</span> <span class="n">w</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">h</span><span class="p">,</span>
                     <span class="kt">uint16_t</span> <span class="n">color</span><span class="p">);</span>            <span class="cm">/* 可选，阻塞填色/产测 */</span>
</code></pre></div></div>

<h2 id="电气接口与安全初始化电平">电气接口与安全初始化电平</h2>

<p>在典型的 4 线 SPI 硬件连接中，MCU 与 240×320 LCD 的连接关系如下：</p>

<table>
  <thead>
    <tr>
      <th>信号</th>
      <th>典型 MCU 引脚</th>
      <th>作用</th>
      <th>有效电平与约束</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">SCL</code></td>
      <td>PA5</td>
      <td>SPI1 SCK</td>
      <td>Mode 0：空闲低电平</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">SDA</code></td>
      <td>PA7</td>
      <td>SPI1 MOSI，命令/参数/像素</td>
      <td>MSB first</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DOUT</code></td>
      <td>PA6</td>
      <td>SPI1 MISO，读 ID</td>
      <td>写 GRAM 时回读数据无业务含义</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">RS</code>（D/C）</td>
      <td>PC4</td>
      <td>命令/数据选择</td>
      <td><code class="language-plaintext highlighter-rouge">0</code> = 命令，<code class="language-plaintext highlighter-rouge">1</code> = 参数或像素</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CS</code></td>
      <td>PC5</td>
      <td>片选</td>
      <td>低有效，软件 GPIO 控制（<code class="language-plaintext highlighter-rouge">SPI_NSS_SOFT</code>）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">RST</code></td>
      <td>PB0</td>
      <td>硬件复位</td>
      <td>低有效</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">BL</code></td>
      <td>PC2</td>
      <td>背光控制</td>
      <td>高电平点亮（或接入 PWM 调光）</td>
    </tr>
  </tbody>
</table>

<p>GPIO 初始化必须<strong>先写安全输出电平，再切换为推挽输出模式</strong>：</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">CS = 1</code>：避免上电瞬间误选中屏幕；</li>
  <li><code class="language-plaintext highlighter-rouge">RS = 1</code>：默认处于数据态；</li>
  <li><code class="language-plaintext highlighter-rouge">RST = 0</code>：保持复位态；</li>
  <li><code class="language-plaintext highlighter-rouge">BL = 0</code>：保持背光关闭，避免 GRAM 未初始化时闪出随机花屏；</li>
  <li><code class="language-plaintext highlighter-rouge">SCK = 0</code>：匹配 Mode 0 空闲低电平。</li>
</ul>

<p><code class="language-plaintext highlighter-rouge">CS</code> 必须由软件控制，因为一次完整刷屏要经历“多段短命令事务 + 一段长像素流事务”，不能交给硬件 NSS 自动翻转。</p>

<h2 id="命令阶段-spi-配置与命令表">命令阶段 SPI 配置与命令表</h2>

<h3 id="命令阶段-spi-参数">命令阶段 SPI 参数</h3>

<table>
  <thead>
    <tr>
      <th>配置项</th>
      <th>当前值</th>
      <th>移植处理原则</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>主从与方向</td>
      <td>Master，双线全双工</td>
      <td>保持；即使只写屏幕也必须排空 RX</td>
    </tr>
    <tr>
      <td>命令位宽</td>
      <td>8 bit</td>
      <td>命令码和单字节参数一律使用 8 bit</td>
    </tr>
    <tr>
      <td>CPOL / CPHA</td>
      <td>Low / First edge</td>
      <td>SPI Mode 0</td>
    </tr>
    <tr>
      <td>位序</td>
      <td>MSB first</td>
      <td>保持</td>
    </tr>
    <tr>
      <td>本板 SCK</td>
      <td><code class="language-plaintext highlighter-rouge">PCLK2 / 2 = 64 MHz / 2 = 32 MHz</code></td>
      <td>按新板总线时钟重算；超过屏模组上限则加大分频</td>
    </tr>
    <tr>
      <td>CRC</td>
      <td>关闭</td>
      <td>保持关闭</td>
    </tr>
  </tbody>
</table>

<h3 id="驱动实际使用的命令表">驱动实际使用的命令表</h3>

<table>
  <thead>
    <tr>
      <th>命令</th>
      <th style="text-align: right">命令码</th>
      <th>参数</th>
      <th>作用</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">SWRESET</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x01</code></td>
      <td>无</td>
      <td>软件复位；发送后等待 ≥ 150 ms</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">RDDID</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x04</code></td>
      <td>读：1 字节 dummy + 3 字节 ID</td>
      <td>探测总线与控制器应答</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">SLPOUT</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x11</code></td>
      <td>无</td>
      <td>退出休眠；发送后等待 ≥ 120 ms</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">NORON</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x13</code></td>
      <td>无</td>
      <td>进入普通显示模式</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">INVON</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x21</code></td>
      <td>无</td>
      <td>开启显示反相（本屏实测需 <code class="language-plaintext highlighter-rouge">INVON</code> 才显示正确颜色）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DISPON</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x29</code></td>
      <td>无</td>
      <td>打开显示输出（不等于打开物理背光）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">CASET</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x2A</code></td>
      <td>4 字节：<code class="language-plaintext highlighter-rouge">x0H x0L x1H x1L</code></td>
      <td>设置列地址窗口（含首尾端点）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">RASET</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x2B</code></td>
      <td>4 字节：<code class="language-plaintext highlighter-rouge">y0H y0L y1H y1L</code></td>
      <td>设置行地址窗口（含首尾端点）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">RAMWR</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x2C</code></td>
      <td>随后为 RGB565 像素流</td>
      <td>开始向 GRAM 连续写像素</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MADCTL</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x36</code></td>
      <td>1 字节扫描与颜色序</td>
      <td>正向 <code class="language-plaintext highlighter-rouge">0x00</code>，180° 反向 <code class="language-plaintext highlighter-rouge">0xC0</code>（<code class="language-plaintext highlighter-rouge">MY | MX</code>）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">COLMOD</code></td>
      <td style="text-align: right"><code class="language-plaintext highlighter-rouge">0x3A</code></td>
      <td><code class="language-plaintext highlighter-rouge">0x55</code></td>
      <td>选择 16 bit/pixel（RGB565）</td>
    </tr>
  </tbody>
</table>

<p>当 <code class="language-plaintext highlighter-rouge">COLMOD = 0x55</code> 时，MCU 侧一个像素对应 <code class="language-plaintext highlighter-rouge">uint16_t</code> RGB565（高 5 位红、中 6 位绿、低 5 位蓝）。若纯色测试中红、绿、蓝变成青、黄、紫，应优先检查 <code class="language-plaintext highlighter-rouge">INVON (0x21)</code> / <code class="language-plaintext highlighter-rouge">INVOFF (0x20)</code> 及 <code class="language-plaintext highlighter-rouge">MADCTL</code> 的 RGB/BGR 位，不要盲目修改 DMA 字节序。</p>

<h2 id="单条命令与上电初始化流程">单条命令与上电初始化流程</h2>

<h3 id="命令与参数轮询事务">命令与参数轮询事务</h3>

<p>命令阶段全程采用 8 bit CPU 轮询，不使用 DMA：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>写命令 WriteCommand(cmd):
  RS = 0
  CS = 0
  等待 TXE → 写 SPI-&gt;DAT → 等待 RXNE → 读走 SPI-&gt;DAT（丢弃）
  等待 SPI BUSY = 0
  CS = 1

写单字节参数 WriteData(data):
  RS = 1
  CS = 0
  等待 TXE → 写 SPI-&gt;DAT → 等待 RXNE → 读走 SPI-&gt;DAT（丢弃）
  等待 SPI BUSY = 0
  CS = 1
</code></pre></div></div>

<p>无论命令还是像素，<strong>在 <code class="language-plaintext highlighter-rouge">SPI BUSY</code> 清零之前绝不能拉高 <code class="language-plaintext highlighter-rouge">CS</code></strong>，否则移位寄存器中的最后一位可能被截断。</p>

<h3 id="读-idrddid事务">读 ID（RDDID）事务</h3>

<p>在同一个 <code class="language-plaintext highlighter-rouge">CS = 0</code> 周期内完成：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RS = 0, CS = 0
发送 0x04，读走 1 个同步字节
RS = 1
连续发送 4 次 0x00：第 1 次对应 dummy，后 3 次依次读回 ID1、ID2、ID3
等待 SPI BUSY = 0
CS = 1
</code></pre></div></div>

<h3 id="上电初始化序列">上电初始化序列</h3>

<p>初始化总耗时约 430 ms 以上，必须放在独立看门狗（IWDG）启动之前：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>1. 配置 GPIO / SPI1 / DMA1 / NVIC，保持背光关闭 (BL = 0)
2. RST = 0 保持 ≥ 20 ms
3. RST = 1 等待 ≥ 120 ms
4. 发送 SWRESET (0x01)，等待 ≥ 150 ms
5. 发送 SLPOUT  (0x11)，等待 ≥ 120 ms
6. 发送 RDDID   (0x04)，读取 3 字节 ID
7. 发送 COLMOD  (0x3A)，参数 0x55 (RGB565)
8. 发送 MADCTL  (0x36)，参数 0x00 (正向扫描)
9. 发送 INVON   (0x21)
10. 发送 NORON  (0x13)
11. 发送 DISPON (0x29)，等待 ≥ 20 ms
12. 保持背光关闭，待首帧全部条带像素写入 GRAM 后再调用 BacklightOn()
</code></pre></div></div>

<h2 id="窗口设置与两条像素传输路径">窗口设置与两条像素传输路径</h2>

<h3 id="窗口命令流程">窗口命令流程</h3>

<p>向 GRAM 写入任何像素前，先指定目标矩形 <code class="language-plaintext highlighter-rouge">[x0..x1, y0..y1]</code>（坐标含两端端点，例如 <code class="language-plaintext highlighter-rouge">240×8</code> 条带为 <code class="language-plaintext highlighter-rouge">x0=0, x1=239, y0=n, y1=n+7</code>）：</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CASET (0x2A) + x0H, x0L, x1H, x1L   // 8-bit 轮询
RASET (0x2B) + y0H, y0L, y1H, y1L   // 8-bit 轮询
RAMWR (0x2C)                        // 8-bit 轮询
RS = 1, CS = 0（保持低电平）
SPI1 切换为 16-bit HalfWord 模式
DMA 连续发送 width × height 个 uint16_t 像素
等待 RX DMA 完成 且 SPI BUSY = 0
CS = 1
SPI1 恢复为 8-bit 模式
</code></pre></div></div>

<p>若新模组内部 GRAM 大于可视区域（如 240×240 模组复用 240×320 GRAM），需按屏幕规格书在 <code class="language-plaintext highlighter-rouge">CASET/RASET</code> 中加上 <code class="language-plaintext highlighter-rouge">colstart/rowstart</code> 偏移。</p>

<div class="language-c highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cm">/* 矩形窗口配置：发送 CASET (0x2A) + RASET (0x2B) + RAMWR (0x2C) */</span>
<span class="kt">void</span> <span class="nf">LcdSt7789_SetWindow</span><span class="p">(</span><span class="kt">uint16_t</span> <span class="n">x0</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">y0</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">x1</span><span class="p">,</span> <span class="kt">uint16_t</span> <span class="n">y1</span><span class="p">)</span>
<span class="p">{</span>
    <span class="kt">uint8_t</span> <span class="n">colData</span><span class="p">[</span><span class="mi">4</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
        <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)(</span><span class="n">x0</span> <span class="o">&gt;&gt;</span> <span class="mi">8</span><span class="p">),</span> <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)(</span><span class="n">x0</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">),</span>
        <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)(</span><span class="n">x1</span> <span class="o">&gt;&gt;</span> <span class="mi">8</span><span class="p">),</span> <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)(</span><span class="n">x1</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span>
    <span class="p">};</span>
    <span class="kt">uint8_t</span> <span class="n">rowData</span><span class="p">[</span><span class="mi">4</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
        <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)(</span><span class="n">y0</span> <span class="o">&gt;&gt;</span> <span class="mi">8</span><span class="p">),</span> <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)(</span><span class="n">y0</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">),</span>
        <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)(</span><span class="n">y1</span> <span class="o">&gt;&gt;</span> <span class="mi">8</span><span class="p">),</span> <span class="p">(</span><span class="kt">uint8_t</span><span class="p">)(</span><span class="n">y1</span> <span class="o">&amp;</span> <span class="mh">0xFF</span><span class="p">)</span>
    <span class="p">};</span>

    <span class="n">Lcd_WriteCommand</span><span class="p">(</span><span class="mh">0x2AU</span><span class="p">);</span> <span class="cm">/* CASET: 列起始/终止地址 */</span>
    <span class="n">Lcd_WriteData</span><span class="p">(</span><span class="n">colData</span><span class="p">,</span> <span class="mi">4</span><span class="p">);</span>

    <span class="n">Lcd_WriteCommand</span><span class="p">(</span><span class="mh">0x2BU</span><span class="p">);</span> <span class="cm">/* RASET: 行起始/终止地址 */</span>
    <span class="n">Lcd_WriteData</span><span class="p">(</span><span class="n">rowData</span><span class="p">,</span> <span class="mi">4</span><span class="p">);</span>

    <span class="n">Lcd_WriteCommand</span><span class="p">(</span><span class="mh">0x2CU</span><span class="p">);</span> <span class="cm">/* RAMWR: 显存写入 */</span>
<span class="p">}</span>
</code></pre></div></div>

<h3 id="路径一阻塞填色fill--fillrect">路径一：阻塞填色（<code class="language-plaintext highlighter-rouge">Fill</code> / <code class="language-plaintext highlighter-rouge">FillRect</code>）</h3>

<p>适用于开机产测色块或初始化清屏：</p>

<ul>
  <li>数据源为单个 <code class="language-plaintext highlighter-rouge">uint16_t color</code> 变量，TX DMA <strong>内存地址不递增</strong>，反复发送同一半字；</li>
  <li>RX DMA 写入局部变量 <code class="language-plaintext highlighter-rouge">rxDiscard</code>，内存地址不递增；</li>
  <li>像素总数超过 65,535（如全屏 <code class="language-plaintext highlighter-rouge">240×320 = 76,800</code>）时，自动按 65,535 像素拆分为多次 DMA 传输；</li>
  <li>CPU 原地等待每次 DMA 与 <code class="language-plaintext highlighter-rouge">SPI BUSY</code> 结束后再返回。<strong>运行期主循环禁止使用该阻塞路径</strong>，以免阻塞传感器采样和看门狗喂狗。</li>
</ul>

<h3 id="路径二异步矩形像素传输drawpixelsasync">路径二：异步矩形像素传输（<code class="language-plaintext highlighter-rouge">DrawPixelsAsync</code>）</h3>

<p>适用于运行期 UI 条带刷屏：</p>

<ul>
  <li>前置条件：异步状态机处于 <code class="language-plaintext highlighter-rouge">IDLE</code>、<code class="language-plaintext highlighter-rouge">pixels != NULL</code>、矩形位于 <code class="language-plaintext highlighter-rouge">240×320</code> 内且 <code class="language-plaintext highlighter-rouge">width × height &lt;= 65535</code>（8 行条带为 1,920 像素）；</li>
  <li>完成 <code class="language-plaintext highlighter-rouge">SetWindow</code> 与 <code class="language-plaintext highlighter-rouge">RAMWR</code> 后，拉高 <code class="language-plaintext highlighter-rouge">RS</code>、拉低 <code class="language-plaintext highlighter-rouge">CS</code> 并切到 16 bit 模式；</li>
  <li>配置 RX DMA（CH1，内存不递增，写入静态变量 <code class="language-plaintext highlighter-rouge">lcdAsyncRxDiscard</code>）和 TX DMA（CH2，<strong>内存递增</strong>，指向 <code class="language-plaintext highlighter-rouge">pixels</code>）；</li>
  <li>状态切为 <code class="language-plaintext highlighter-rouge">TRANSFER</code>，开启 RX DMA 完成/错误中断，<strong>先使能 RX DMA、后使能 TX DMA</strong>；</li>
  <li>函数立即返回 <code class="language-plaintext highlighter-rouge">true</code>；在 <code class="language-plaintext highlighter-rouge">LcdSt7789_IsBusy()</code> 回到 <code class="language-plaintext highlighter-rouge">false</code> 前，调用方严禁修改 <code class="language-plaintext highlighter-rouge">pixels</code> 缓冲区。</li>
</ul>

<h2 id="dma-寄存器级配置与异步收尾状态机">DMA 寄存器级配置与异步收尾状态机</h2>

<h3 id="dma1-通道参数对照">DMA1 通道参数对照</h3>

<table>
  <thead>
    <tr>
      <th>字段</th>
      <th>RX 通道（DMA1_CH1）</th>
      <th>TX 通道（DMA1_CH2）</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PeriphAddr</code></td>
      <td><code class="language-plaintext highlighter-rouge">&amp;SPI1-&gt;DAT</code></td>
      <td><code class="language-plaintext highlighter-rouge">&amp;SPI1-&gt;DAT</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MemAddr</code></td>
      <td>静态 <code class="language-plaintext highlighter-rouge">&amp;lcdAsyncRxDiscard</code></td>
      <td>像素缓冲区 <code class="language-plaintext highlighter-rouge">pixels</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Direction</code></td>
      <td><code class="language-plaintext highlighter-rouge">DMA_DIR_PERIPH_SRC</code>（外设→内存）</td>
      <td><code class="language-plaintext highlighter-rouge">DMA_DIR_PERIPH_DST</code>（内存→外设）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">BufSize</code></td>
      <td>像素个数（HalfWord 项数，非字节数）</td>
      <td>像素个数（HalfWord 项数，非字节数）</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">PeriphInc</code></td>
      <td>关闭</td>
      <td>关闭</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">MemoryInc</code></td>
      <td>关闭</td>
      <td>异步像素开启；阻塞填色关闭</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">DataSize</code></td>
      <td>HalfWord / HalfWord</td>
      <td>HalfWord / HalfWord</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Mode</code> / <code class="language-plaintext highlighter-rouge">M2M</code></td>
      <td>Normal / 关闭 <code class="language-plaintext highlighter-rouge">M2M</code></td>
      <td>Normal / 关闭 <code class="language-plaintext highlighter-rouge">M2M</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">Priority</code></td>
      <td>Medium（低于 AD7190 采样 DMA）</td>
      <td>Medium</td>
    </tr>
    <tr>
      <td>请求重映射</td>
      <td><code class="language-plaintext highlighter-rouge">DMA1_REMAP_SPI1_RX</code></td>
      <td><code class="language-plaintext highlighter-rouge">DMA1_REMAP_SPI1_TX</code></td>
    </tr>
    <tr>
      <td>中断</td>
      <td>开启传输完成（TC）与错误（TE）</td>
      <td>关闭（以 RX 完成作为全双工基准）</td>
    </tr>
  </tbody>
</table>

<h3 id="异步状态机与主循环职责">异步状态机与主循环职责</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>IDLE
  ├─ DrawPixelsAsync() 启动 DMA ────────────► TRANSFER
TRANSFER
  ├─ RX DMA 完成中断置位 dmaDone ───────────► WAIT_SPI
  └─ RX DMA 错误或 200 ms 超时 ─────────────► AsyncFinish(false) ──► IDLE
WAIT_SPI
  ├─ 主循环检测到 SPI BUSY == 0 ────────────► AsyncFinish(true)  ──► IDLE
  └─ 200 ms 超时 ───────────────────────────► AsyncFinish(false) ──► IDLE
</code></pre></div></div>

<ul>
  <li><strong>ISR 职责边界</strong>：<code class="language-plaintext highlighter-rouge">LcdSt7789_DmaRxIrqHandler()</code> 只允许置位 <code class="language-plaintext highlighter-rouge">dmaDone</code> / <code class="language-plaintext highlighter-rouge">dmaError</code> 并清除本通道中断标志，严禁在中断内等待 <code class="language-plaintext highlighter-rouge">BUSY</code>、切位宽、拉高 <code class="language-plaintext highlighter-rouge">CS</code> 或打印日志。</li>
  <li><strong>主循环职责</strong>：<code class="language-plaintext highlighter-rouge">LcdSt7789_Process()</code> 应放在主循环中 UI 提交新条带之前调用。<code class="language-plaintext highlighter-rouge">AsyncFinish()</code> 依次执行：关闭 RX DMA 中断 → 关闭 SPI DMA 请求并停止 CH1/CH2 → 拉高 <code class="language-plaintext highlighter-rouge">CS</code> → SPI 恢复 8 bit → 状态切回 <code class="language-plaintext highlighter-rouge">IDLE</code>。</li>
</ul>

<h2 id="移植检查清单">移植检查清单</h2>

<ul class="task-list">
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>引脚与极性</strong>：核对原理图引脚、<code class="language-plaintext highlighter-rouge">RST</code>/<code class="language-plaintext highlighter-rouge">CS</code>/<code class="language-plaintext highlighter-rouge">RS</code>/<code class="language-plaintext highlighter-rouge">BL</code> 有效电平，GPIO 初始化先写安全电平再切输出模式。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>时钟与位宽</strong>：按实际 <code class="language-plaintext highlighter-rouge">PCLK</code> 计算 <code class="language-plaintext highlighter-rouge">SCK</code> 不超屏模组上限；命令 8 bit 与像素 16 bit 切换成对出现，异常退出路径同样恢复 8 bit。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>协议与窗口</strong>：初始化延时不短于规格书且不触发看门狗；<code class="language-plaintext highlighter-rouge">CASET</code>/<code class="language-plaintext highlighter-rouge">RASET</code> 包含首尾端点，必要时加上面板偏移；像素阶段 <code class="language-plaintext highlighter-rouge">CS</code> 保持低电平直到 <code class="language-plaintext highlighter-rouge">SPI BUSY = 0</code>。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>DMA 与并发</strong>：关闭 <code class="language-plaintext highlighter-rouge">Mem2Mem</code>；先使能 RX DMA 后使能 TX DMA；<code class="language-plaintext highlighter-rouge">IsBusy()</code> 为真时严禁覆盖源像素缓冲或混用阻塞接口；中断优先级低于硬实时采样通道。</li>
  <li class="task-list-item"><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" /><strong>上板验证</strong>：完成红、绿、蓝、白、黑纯色与 1 像素边框测试；逻辑分析仪确认命令期 <code class="language-plaintext highlighter-rouge">RS=0</code>、像素期 <code class="language-plaintext highlighter-rouge">RS=1</code> 且 <code class="language-plaintext highlighter-rouge">CS</code> 覆盖完整像素段；与高优先级采样长时间并行无 200 ms 超时。</li>
</ul>]]></content><author><name></name></author><category term="嵌入式" /><category term="ST7789" /><category term="N32G455" /><category term="SPI" /><category term="DMA" /><category term="驱动移植" /><summary type="html"><![CDATA[面向 MCU 驱动移植的 ST7789 4 线 SPI 控制协议参考，涵盖命令表、RS/CS 事务边界、上电序列、8/16 位切换与阻塞/异步像素 DMA 实现。]]></summary></entry></feed>