<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0"><channel><title>浮云落笔</title><link>https://www.flor.cn</link><atom:link href="https://www.flor.cn/rss.xml" rel="self" type="application/rss+xml"/><description>浮云落笔皆成记,一盏流年似旧时</description><generator>Halo v2.26.1</generator><language>zh-cn</language><image><url>https://www.flor.cn/upload/logo.png</url><title>浮云落笔</title><link>https://www.flor.cn</link></image><lastBuildDate>Fri, 9 Oct 2026 11:24:57 GMT</lastBuildDate><item><title><![CDATA[STM32 DMA 数据搬运实验记录：让 CPU 卸下搬运工的活]]></title><link>https://www.flor.cn/archives/dma-usart-experiment-4</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=STM32%20DMA%20%E6%95%B0%E6%8D%AE%E6%90%AC%E8%BF%90%E5%AE%9E%E9%AA%8C%E8%AE%B0%E5%BD%95%EF%BC%9A%E8%AE%A9%20CPU%20%E5%8D%B8%E4%B8%8B%E6%90%AC%E8%BF%90%E5%B7%A5%E7%9A%84%E6%B4%BB&amp;url=/archives/dma-usart-experiment-4" width="1" height="1" alt="" style="opacity:0;">
<blockquote>
 <p>平台：STM32F103ZET6 @ 72MHz · 工程名 usart_dma · USART1（PA9=TX / PA10=RX）@ 115200 前置知识：USART 中断接收 + 环形缓冲区 + 协议帧（模块 2） 实验日期：2026-10-04 · 两个实验全部验收通过</p>
</blockquote>
<h2 style="" id="%E4%B8%80%E3%80%81%E5%AE%9E%E9%AA%8C%E8%83%8C%E6%99%AF%EF%BC%9Acpu-%E5%BD%93%E6%90%AC%E8%BF%90%E5%B7%A5%E7%9A%84%E8%BE%9B%E9%85%B8">一、实验背景：CPU 当搬运工的辛酸</h2>
<p>在学习 DMA 之前，我的串口架构是这样的：每来一个字节，硬件敲门（RXNE 中断）→ CPU 放下手头的活 → 进中断 → 从 DR 寄存器搬一个字节进缓冲区 → 返回。115200 波特率连续收数据 = <strong>每秒 11520 次中断</strong>，CPU 的日程表被"搬字节"切碎。</p>
<p>发送侧更惨：<code>HAL_UART_Transmit</code> 是阻塞式的——发 420 字节，CPU 在函数里干等约 37ms，一步不能离开。</p>
<p><strong>DMA（Direct Memory Access，直接内存访问）= 芯片里一个独立的数据搬运处理器</strong>。它挂在 AHB 总线（主干道）上，能自己访问内存和外设。CPU 是经理，DMA 是搬运工：<strong>经理填一张搬运单（源地址、目的地址、数量），搬运工自己搬，搬完敲门汇报</strong>。期间经理想干嘛干嘛。</p>
<h2 style="" id="%E4%BA%8C%E3%80%81dma-%E5%8E%9F%E7%90%86%E8%A6%81%E7%82%B9">二、DMA 原理要点</h2>
<h3 style="" id="%E6%90%AC%E8%BF%90%E5%8D%95%E5%85%AD%E8%A6%81%E7%B4%A0">搬运单六要素</h3>
<p>配置一次 DMA 传输 = 填这张单子：</p>
<table>
 <thead>
  <tr>
   <th>字段</th>
   <th>含义</th>
   <th>串口发送的例子</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>源地址</td>
   <td>从哪搬</td>
   <td>内存里那个字符串数组</td>
  </tr>
  <tr>
   <td>目的地址</td>
   <td>搬到哪</td>
   <td>USART1 的 DR 寄存器（固定不变）</td>
  </tr>
  <tr>
   <td>数量</td>
   <td>搬多少个</td>
   <td>字符串长度</td>
  </tr>
  <tr>
   <td>数据宽度</td>
   <td>每件多大</td>
   <td>Byte（半字/字用于 16/32 位数据）</td>
  </tr>
  <tr>
   <td>方向</td>
   <td>P2M / M2P / M2M</td>
   <td>M2P（内存→外设）</td>
  </tr>
  <tr>
   <td>增量</td>
   <td>每搬一件地址走不走</td>
   <td>内存地址 +1（数组递增），外设地址不动（永远是 DR）</td>
  </tr>
 </tbody>
</table>
<h3 style="" id="%E8%AF%B7%E6%B1%82%EF%BC%88request%EF%BC%89%EF%BC%9A%E6%90%AC%E8%BF%90%E7%9A%84%E8%8A%82%E5%A5%8F%E6%8E%A7%E5%88%B6%E5%99%A8">请求（Request）：搬运的节奏控制器</h3>
<p>DMA 不是一口气把数据砸进外设，而是<strong>盯着外设的"空了"信号</strong>——串口每发完一字节，TXE 标志变空 → 硬件向 DMA 发一个请求 → DMA 搬一字节进 DR。<strong>传输速率被外设节流</strong>（115200 的节奏），DMA 只是随叫随到。</p>
<h3 style="" id="f1-%E7%9A%84%E9%80%9A%E9%81%93%E6%98%A0%E5%B0%84%E6%98%AF%E5%9B%BA%E5%AE%9A%E6%AD%BB%E7%9A%84">F1 的通道映射是固定死的</h3>
<p>DMA1 有 7 个通道、DMA2 有 5 个，每个通道同一时刻只能服务一个外设，<strong>对应关系查参考手册的映射表，不能自由组合</strong>：USART1_TX 固定走 DMA1 通道 4、USART1_RX 固定走 DMA1 通道 5。（F4 以后的 Stream/Request 才可以任意组合。）</p>
<h3 style="" id="%E5%85%AD%E9%97%AE%E9%80%9F%E7%AD%94">六问速答</h3>
<p>挂哪条总线：<strong>AHB</strong>（第一个住 AHB 楼层的外设，这是它能"直接访问内存"的结构原因）；门控开关：RCC_AHBENR 的 DMA1EN；两种模式：Normal（搬一批停）和 Circular（循环，流式场景）。</p>
<h2 style="" id="%E4%B8%89%E3%80%81%E5%AE%9E%E9%AA%8C-4.1%EF%BC%9A%E9%98%BB%E5%A1%9E-vs-dma-%E5%BD%93%E9%9D%A2%E5%AF%B9%E5%86%B3">三、实验 4.1：阻塞 vs DMA 当面对决</h2>
<h3 style="" id="%E5%AE%9E%E9%AA%8C%E8%AE%BE%E8%AE%A1%EF%BC%88%E5%90%AB%E4%B8%80%E6%AC%A1%E9%87%8D%E8%A6%81%E7%9A%84%E8%AE%BE%E8%AE%A1%E4%BF%AE%E6%AD%A3%EF%BC%89">实验设计（含一次重要的设计修正）</h3>
<p>同一个 420 字节的字符串，两种发法，<strong>用 CPU 计数器当裁判</strong>。</p>
<p>最初的设计是：阻塞发送后打印计数（=0），DMA 发送期间 CPU 数数（=几十万）。但我自己发现了这个设计的漏洞：<strong>阻塞版的"计数=0"其实是"压根没人数"，不是"想数但数不动"，没有说服力</strong>。</p>
<p>关键洞察：<strong>你恰恰没法在阻塞期间加计数</strong>——CPU 被关在 <code>HAL_UART_Transmit</code> 函数里出不来，一行计数代码都执行不了，"加不进计数"本身就是"阻塞"的定义。</p>
<p>所以要严谨地展示"CPU 被占死"，得请一个<strong>不受阻塞影响的观察者</strong>——中断。现成的是 SysTick（每 1ms 中断一次，主循环卡死它照跑），用它计时，主循环计数器量工作量，两个维度一起打印：</p>
<pre><code class="language-c">char msg[512];
int n = 0;
for (int i = 0; i &lt; 10; i++)                                   // 填 10 行 ≈ 420 字节
    n += sprintf(&amp;msg[n], "DMA搬运演示第%d行-0123456789ABCDEFG\r\n", i);
while (1)
{
    // ① 阻塞发送：SysTick 计时（中断观察者，主循环卡死它照跑）
    uint32_t t0 = HAL_GetTick();
    cpu_count = 0;                                // 想数数？看看数不数得起来
    HAL_UART_Transmit(&amp;huart1, (uint8_t *)msg, n, 100);
    // 此刻 cpu_count 必然还是 0——CPU 在函数里出不来
    printf("&gt;&gt;&gt; 阻塞发送 %d 字节：耗时 %lu ms，CPU 计数 = %lu（想数也数不了）\r\n\r\n",
           n, (unsigned long)(HAL_GetTick() - t0), (unsigned long)cpu_count);

    HAL_Delay(1000);

    // ② DMA 发送：CPU 扔下地址就走，期间数数
    uint32_t t1 = HAL_GetTick();
    cpu_count = 0;
    HAL_UART_Transmit_DMA(&amp;huart1, (uint8_t *)msg, n);   // 立即返回！
    while (huart1.gState != HAL_UART_STATE_READY)        // DMA 还在搬
        cpu_count++;                                     // CPU 闲着数圈
    printf("&gt;&gt;&gt; DMA 发送 %d 字节：耗时 %lu ms，CPU 计数 = %lu（全程自由）\r\n\r\n",
           n, (unsigned long)(HAL_GetTick() - t1), (unsigned long)cpu_count);

    HAL_Delay(1000);
}
</code></pre>
<h3 style="" id="cubemx-%E9%85%8D%E7%BD%AE">CubeMX 配置</h3>
<p>USART1 → DMA Settings → Add → <strong>USART1_TX</strong>：Channel=DMA1_Channel4（自动）、Direction=Memory to Peripheral、Mode=<strong>Normal</strong>、Memory Increment ✅、Peripheral Increment ❌、Data Width=Byte。NVIC 勾 <strong>DMA1 channel 4</strong> 和 <strong>USART1</strong> 两个中断。</p>
<h3 style="" id="%E5%AE%9E%E6%B5%8B%E6%95%B0%E6%8D%AE%E4%B8%8E%E7%BB%93%E8%AE%BA">实测数据与结论</h3>
<pre><code>&gt;&gt;&gt; 阻塞发送 420 字节：耗时 38 ms，CPU 计数 = 0（想数也数不了）
&gt;&gt;&gt; DMA 发送 420 字节：耗时 38 ms，CPU 计数 = 101437（全程自由）
</code></pre>
<p><strong>两行对比的完整解读</strong>：耗时几乎一样（都被 115200 波特率定死——<strong>DMA 买不回速度</strong>）；差别全在 CPU 计数——<strong>DMA 买回的是 CPU 那 38ms 的自由时间</strong>。一个维度证"速度没变"，一个维度证"占用天差地别"。</p>
<h3 style="" id="%E6%96%B9%E6%B3%95%E8%AE%BA%E6%B2%89%E6%B7%80">方法论沉淀</h3>
<p><strong>要证明"某段代码被卡死"，不能用它自己计数（它数不了），要用不受它影响的观察者（中断/定时器）</strong>。以后调试"主循环是不是被某行代码拖死了"，同一招：SysTick 打点前后对比。</p>
<h2 style="" id="%E5%9B%9B%E3%80%81%E5%AE%9E%E9%AA%8C-4.2%EF%BC%9Adma-%E6%8E%A5%E6%94%B6-%2B-idle-%E7%A9%BA%E9%97%B2%E6%96%AD%E5%B8%A7">四、实验 4.2：DMA 接收 + IDLE 空闲断帧</h2>
<h3 style="" id="%E9%97%AE%E9%A2%98%EF%BC%9Adma-%E5%8F%AA%E7%AE%A1%E5%9F%8B%E5%A4%B4%E6%90%AC%EF%BC%8C%E6%80%8E%E4%B9%88%E7%9F%A5%E9%81%93%22%E4%B8%80%E5%B8%A7%E7%BB%93%E6%9D%9F%E4%BA%86%22%EF%BC%9F">问题：DMA 只管埋头搬，怎么知道"一帧结束了"？</h3>
<p>DMA 接收时字节悄无声息地进缓冲区，CPU 睡着了——谁来叫醒它？答案在 UART 硬件里：<strong>IDLE 空闲检测</strong>。</p>
<h3 style="" id="idle%EF%BC%9A%E7%A1%AC%E4%BB%B6%E8%87%AA%E5%B8%A6%E7%9A%84%22%E6%96%AD%E5%8F%A5%E5%99%A8%22">IDLE：硬件自带的"断句器"</h3>
<p>串口有个天然规律：<strong>一帧数据一口气发完</strong>（字节间隔 &lt; 1 字符时间），<strong>帧与帧之间有静默</strong>。UART 硬件的 IDLE 标志：<strong>收到数据后总线静默满一个字符时间（115200 下约 87µs），IDLE 置位</strong>——这是硬件替你数的"句号"。</p>
<pre><code>AA 55 02 01 01 02 ──────静默 87µs────── 下一帧...
└───────────────┘ └── IDLE 中断! ──┘
 DMA 逐字节默默搬
                  ↑ CPU 只在这里醒一次
</code></pre>
<p><strong>整个接收过程：6 个字节 = 0 次中断；帧结束 = 1 次 IDLE 中断</strong>——中断数量从"字节数"降到"帧数"，这就是 DMA 高速接收的全部秘密。</p>
<h3 style="" id="%E4%B8%8D%E5%AE%9A%E9%95%BF%E7%9A%84%E9%AD%94%E6%B3%95">不定长的魔法</h3>
<p>IDLE 中断里 CPU 醒来后的第一问：这帧多长？<strong>DMA 的计数器就是答案</strong>：</p>
<blockquote>
 <p>本次到达字节数 Size = 缓冲区大小 − DMA 剩余计数（CNDTR）</p>
</blockquote>
<p>发 6 字节算 6，发 60 字节算 60——<strong>不用事先约定长度，来多少算多少</strong>，这就是"不定长接收"。HAL 已把这整套打包成 <code>HAL_UARTEx_ReceiveToIdle_DMA</code>，事件回调直接给算好的 Size。</p>
<h3 style="" id="%E6%9E%B6%E6%9E%84%EF%BC%9A%E4%B8%80%E5%B8%A7%E6%95%B0%E6%8D%AE%E7%9A%84%E5%AE%8C%E6%95%B4%E6%97%85%E7%A8%8B">架构：一帧数据的完整旅程</h3>
<pre><code>PC 串口助手(HEX)
   │  AA 55 02 01 01 02
   ▼
PA10(RX) ──► USART1 DR ──► [DMA1_CH5 硬件搬运] ──► rxDmaBuf[0..5]
   │              每字节：RXNE→硬件DMA请求→搬一字节→CNDTR减一（0中断）
   ├────────── 87µs ──────────┐
   ▼                          ▼
静默满 → IDLE 置位 ──► IDLE 中断（每帧仅 1 次）
              │ Size = 64 − CNDTR = 6
              ▼
   HAL_UARTEx_RxEventCallback(huart, Size)
              │
              ▼
   frame_parse(rxDmaBuf, Size)   ← 协议层，与模块2.3一字未改
              │ 五状态机推进
              ▼
   frame_exec：LED 点亮 + ACK 经 DMA 回发
              │
              ▼
   重新武装 ReceiveToIdle_DMA ──► 待命等下一帧
</code></pre>
<h3 style="" id="%E6%A0%B8%E5%BF%83%E4%BB%A3%E7%A0%81">核心代码</h3>
<pre><code class="language-c">// 启动（USER CODE 2）：
HAL_UARTEx_ReceiveToIdle_DMA(&amp;huart1, rxDmaBuf, RXBUF_SIZE);

// IDLE 事件回调（USER CODE 4）：
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if (huart-&gt;Instance == USART1 &amp;&amp;
        huart-&gt;RxEventType == HAL_UART_RXEVENT_IDLE) {
        frame_parse(rxDmaBuf, Size);                        // Size=本次字节数(不定长)
        HAL_UARTEx_ReceiveToIdle_DMA(huart, rxDmaBuf, RXBUF_SIZE);  // 重新武装
    }
}
</code></pre>
<p>（<code>frame_parse</code>/<code>frame_exec</code> 是模块 2.3 的五状态机解析器<strong>原封不动</strong>搬过来，仅 ACK 回发从阻塞改成 <code>HAL_UART_Transmit_DMA</code>。）</p>
<h3 style="" id="%E9%AA%8C%E6%94%B6%EF%BC%9A%E4%BA%94%E8%BF%9E%E6%B5%8B%E8%AF%95%EF%BC%88%E4%B8%B2%E5%8F%A3%E5%8A%A9%E6%89%8B-hex-%E6%A8%A1%E5%BC%8F%EF%BC%89">验收：五连测试（串口助手 HEX 模式）</h3>
<table>
 <thead>
  <tr>
   <th>测试</th>
   <th>发送</th>
   <th>预期</th>
   <th>结果</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>开灯</td>
   <td><code>AA 55 02 01 01 02</code></td>
   <td>LED 亮 + 回传同帧</td>
   <td>✅</td>
  </tr>
  <tr>
   <td>关灯</td>
   <td><code>AA 55 02 01 00 03</code></td>
   <td>LED 灭 + 回传同帧</td>
   <td>✅</td>
  </tr>
  <tr>
   <td>坏 CRC</td>
   <td><code>AA 55 02 01 01 99</code></td>
   <td>无动作无回传</td>
   <td>✅</td>
  </tr>
  <tr>
   <td>垃圾+好帧</td>
   <td><code>12 34 56 AA 55 02 01 01 02</code></td>
   <td>重新同步，灯亮+回帧</td>
   <td>✅</td>
  </tr>
  <tr>
   <td>双 AA</td>
   <td><code>AA AA 55 02 01 01 02</code></td>
   <td>第二个 AA 识别为真帧头</td>
   <td>✅</td>
  </tr>
 </tbody>
</table>
<p><strong>最有价值的发现：协议层一行没改，五连全过</strong>——搬运层从"每字节中断"换装成"DMA 批量"，解析器毫无感觉。<strong>分层解耦的活证据：换一层，其他层无感</strong>。</p>
<h3 style="" id="%E4%B8%8E%E6%A8%A1%E5%9D%97-2-%E7%9A%84%E8%B4%A6%E6%9C%AC%E5%AF%B9%E6%AF%94%EF%BC%88%E6%AF%8F%E5%B8%A7-6-%E5%AD%97%E8%8A%82%EF%BC%89">与模块 2 的账本对比（每帧 6 字节）</h3>
<table>
 <thead>
  <tr>
   <th></th>
   <th>2.2/2.3（RXNE 每字节中断）</th>
   <th>4.2（DMA+IDLE）</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>RX 中断次数</td>
   <td>6</td>
   <td><strong>1</strong></td>
  </tr>
  <tr>
   <td>CPU 搬字节数</td>
   <td>6</td>
   <td><strong>0</strong></td>
  </tr>
  <tr>
   <td>TX 回 ACK</td>
   <td>阻塞（CPU 等）</td>
   <td>DMA（CPU 走人）</td>
  </tr>
  <tr>
   <td>满速连续流</td>
   <td>11520 中断/秒</td>
   <td>11520÷帧长 次/秒</td>
  </tr>
 </tbody>
</table>
<h2 style="" id="%E4%BA%94%E3%80%81%E8%B8%A9%E5%9D%91%E5%AE%9E%E5%BD%95%EF%BC%9Amx_dma_init-%E7%BC%BA%E5%A4%B1%EF%BC%88%E6%9C%AC%E6%AC%A1%E6%9C%80%E5%80%BC%E9%92%B1%E7%9A%84%E6%95%99%E8%AE%AD%EF%BC%89">五、踩坑实录：MX_DMA_Init 缺失（本次最值钱的教训）</h2>
<h3 style="" id="%E7%8E%B0%E8%B1%A1">现象</h3>
<p>烧录后串口只打了一轮"阻塞发送…计数=0"+10 行数据，然后<strong>永久冻结</strong>。用调试器读 CPU 计数变量：<strong>单调增长到 840 万、从不归零</strong>——程序卡死在 DMA 等待循环里。</p>
<h3 style="" id="%E8%AF%8A%E6%96%AD%E9%93%BE%EF%BC%88gdb-%E7%AC%A6%E5%8F%B7%E7%BA%A7%E8%B0%83%E8%AF%95%EF%BC%8C%E5%85%A8%E7%A8%8B%E8%BF%9C%E7%A8%8B%EF%BC%89">诊断链（gdb 符号级调试，全程远程）</h3>
<table>
 <thead>
  <tr>
   <th>步骤</th>
   <th>读数</th>
   <th>推理</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><code>p huart1.gState</code></td>
   <td>0x21 = BUSY_TX</td>
   <td>卡在 DMA 发送等待 ✓</td>
  </tr>
  <tr>
   <td><code>p huart1.TxXferCount</code></td>
   <td>420 = 全量</td>
   <td>一个字节都没发出去</td>
  </tr>
  <tr>
   <td>DMA1 所有寄存器</td>
   <td><strong>全 0</strong></td>
   <td>DMA 没通电——配置写进没通电的寄存器=石沉大海</td>
  </tr>
  <tr>
   <td>NVIC ISER0</td>
   <td>0（CH4 中断没使能）</td>
   <td>dma.c 里的使能代码<strong>从没执行过</strong></td>
  </tr>
  <tr>
   <td>NVIC ISER1</td>
   <td>bit5=1（USART1 使能了）</td>
   <td>usart.c 的 MspInit 跑了</td>
  </tr>
  <tr>
   <td>查 main.c 初始化序列</td>
   <td>只有 GPIO→USART1</td>
   <td><strong>根本没有 <code>MX_DMA_Init()</code> 调用！</strong></td>
  </tr>
 </tbody>
</table>
<h3 style="" id="%E6%A0%B9%E5%9B%A0">根因</h3>
<p><strong>CubeMX 给已有工程加 DMA 后重新生成，偶尔不往 main.c 插 <code>MX_DMA_Init()</code> 调用</strong>——dma.c 里时钟使能、中断使能都生成得好好的，但没人调用它们，DMA 房间没通电。症状极具迷惑性：编译零错误、<code>HAL_UART_Transmit_DMA</code> 返回 OK、然后永远等不到完成。</p>
<h3 style="" id="%E4%BF%AE%E5%A4%8D">修复</h3>
<p>main.c 补两处（<code>MX_DMA_Init</code> 必须在 USART 之前——MspInit 要写 DMA 寄存器）：</p>
<pre><code class="language-c">#include "dma.h"
...
MX_GPIO_Init();
MX_DMA_Init();              // DMA 时钟+中断使能
MX_USART1_UART_Init();
</code></pre>
<p>修复后复测：<code>gState</code> = 0x20（READY，正常循环）、<code>cpu_count</code> = 101437（DMA 期间 CPU 自由计数）——复活。</p>
<h3 style="" id="%E9%98%B2%E5%A4%8D%E5%8F%91">防复发</h3>
<p><strong>以后每次 CubeMX 重新生成，git diff 里专门盯一眼 <code>MX_DMA_Init()</code> 还在不在</strong>——被吃掉就补回来。</p>
<h3 style="" id="%E6%AC%A1%E7%BA%A7%E7%9F%A5%E8%AF%86%E7%82%B9">次级知识点</h3>
<p>HAL 的 DMA 发送完成走<strong>两步</strong>：DMA 中断（数据搬完进 DR）→ <strong>USART TC 中断</strong>（最后一位移出移位寄存器）→ gState=READY。所以 <strong>USART1 的 NVIC 也必须勾</strong>，只勾 DMA 通道中断不够。</p>
<h2 style="" id="%E5%85%AD%E3%80%81%E8%B0%83%E8%AF%95%E6%89%8B%E6%B3%95%E6%B2%89%E6%B7%80%EF%BC%88%E6%9C%AC%E6%A8%A1%E5%9D%97%E6%96%B0%E5%A2%9E%EF%BC%89">六、调试手法沉淀（本模块新增）</h2>
<ul>
 <li><strong>gdb 符号级读句柄</strong>：<code>p/x huart1.gState</code>、<code>p/x huart1.TxXferCount</code>——比猜寄存器位准得多</li>
 <li><strong>NVIC ISER 读中断使能实况</strong>：ISER0/ISER1 对照 IRQ 号（DMA1_CH4=14、USART1=37）</li>
 <li><strong>"没通电"的识别</strong>：外设寄存器全零 + 配置写入无效 = 时钟没开</li>
 <li><strong>证明卡死用独立观察者</strong>：SysTick/定时器打点，别让被卡死的代码自己数</li>
</ul>
<h2 style="" id="%E4%B8%83%E3%80%81%E4%B8%80%E5%8F%A5%E8%AF%9D%E7%9F%A5%E8%AF%86%E7%82%B9%E6%B8%85%E5%8D%95">七、一句话知识点清单</h2>
<ul>
 <li>DMA=独立搬运工：CPU 填单（源/目的/数量/宽度/方向/增量），DMA 搬完敲门</li>
 <li>DMA1/DMA2 挂 AHB，门控 RCC_AHBENR——第一个 AHB 楼层外设</li>
 <li>F1 通道映射固定：USART1_TX=CH4、RX=CH5，查表不可选</li>
 <li>请求节流：速率被外设定死——<strong>DMA 买的是 CPU 自由时间，不是速度</strong></li>
 <li>IDLE=硬件断句器：静默 1 字符时间置位，帧边界的物理本质</li>
 <li>不定长 Size = 缓冲区大小 − CNDTR</li>
 <li><code>HAL_UARTEx_ReceiveToIdle_DMA</code> + <code>RxEventCallback</code>；Normal 模式 IDLE 后必须 re-arm</li>
 <li>DMA TX 完成两步走：DMA 中断 + USART TC 中断——两个 NVIC 都要勾</li>
 <li><code>MX_DMA_Init</code> 必须在用 DMA 的外设之前调用</li>
 <li>CubeMX 偶发不插 MX_DMA_Init——每次生成 git diff 盯梢</li>
 <li>分层解耦：搬运层升级，协议层免疫（2.3 解析器零改动上 DMA）</li>
 <li>证明卡死要用不受影响的观察者（SysTick），不能让卡死者自己数</li>
</ul>
<hr>
<p><em>模块 4 实验记录 · 2026-10-04 · STM32F103ZET6 最小系统板 · 工程名 usart_dma</em></p>]]></description><guid isPermaLink="false">/archives/dma-usart-experiment-4</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Sun, 4 Oct 2026 03:00:29 GMT</pubDate></item><item><title><![CDATA[STM32 定时器测量：输入捕获、编码器接口与高级定时器实验全记录]]></title><link>https://www.flor.cn/archives/stm32-timer-3b-input-capture-encoder-advanced</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=STM32%20%E5%AE%9A%E6%97%B6%E5%99%A8%E6%B5%8B%E9%87%8F%EF%BC%9A%E8%BE%93%E5%85%A5%E6%8D%95%E8%8E%B7%E3%80%81%E7%BC%96%E7%A0%81%E5%99%A8%E6%8E%A5%E5%8F%A3%E4%B8%8E%E9%AB%98%E7%BA%A7%E5%AE%9A%E6%97%B6%E5%99%A8%E5%AE%9E%E9%AA%8C%E5%85%A8%E8%AE%B0%E5%BD%95&amp;url=/archives/stm32-timer-3b-input-capture-encoder-advanced" width="1" height="1" alt="" style="opacity:0;">
<blockquote>
 <p>平台：STM32F103ZET6 @ 72MHz · 工程名 pwm_measure（一个工程跑完三个实验）· 串口 115200（UTF-8）
  <br>
  前置知识：模块 3A（时基、输出比较、PWM 呼吸灯）</p>
</blockquote>
<h2 style="" id="%E5%BC%95%E8%A8%80%EF%BC%9A%E4%BB%8E%E2%80%9C%E4%BA%A7%E7%94%9F%E4%BF%A1%E5%8F%B7%E2%80%9D%E5%88%B0%E2%80%9C%E6%B5%8B%E9%87%8F%E4%BF%A1%E5%8F%B7%E2%80%9D">引言：从“产生信号”到“测量信号”</h2>
<p>模块 3A 让定时器<strong>输出</strong>信号（PWM 点灯），模块 3B 把方向反过来——用定时器<strong>测量</strong>外部信号：测一个 PWM 波形的周期和占空比、数旋钮转了多少格、给电机驱动补上安全机制。三个实验共用同一个工程 <code>pwm_measure</code>，一个实验叠一个实验，最后三套功能在同一块板上和平共处——这也是真实项目的形态。</p>
<hr>
<h2 style="" id="%E4%B8%80%E3%80%813b.1-%E8%BE%93%E5%85%A5%E6%8D%95%E8%8E%B7%EF%BC%9A%E9%80%A0%E4%B8%80%E5%8F%B0-pwm-%E6%B5%8B%E9%87%8F%E4%BB%AA">一、3B.1 输入捕获：造一台 PWM 测量仪</h2>
<h3 style="" id="1.1-%E5%AE%9E%E9%AA%8C%E7%9B%AE%E6%A0%87">1.1 实验目标</h3>
<p>测一个未知信号的<strong>周期</strong>和<strong>占空比</strong>，串口每秒打印结果——这就是一台“PWM 测量仪”。它是很多应用的地基：测转速（编码器脉冲）、超声波回波计时、红外遥控解码、频率计。</p>
<h3 style="" id="1.2-%E5%8E%9F%E7%90%86%EF%BC%9A%E7%BB%99%E5%AE%9A%E6%97%B6%E5%99%A8%E8%A3%85%E2%80%9C%E7%A1%AC%E4%BB%B6%E5%BF%AB%E9%97%A8%E2%80%9D">1.2 原理：给定时器装“硬件快门”</h3>
<p>朴素思路是“中断+软件时间戳”，但 CPU 响应中断有微秒级抖动（可能被别的事耽误），测出来的时刻不准。</p>
<p><strong>输入捕获（IC）= 硬件快门</strong>：捕获通道盯着引脚，设定的边沿（上升/下降）一到达，<strong>硬件瞬间把当前 CNT 的值拍进 CCR</strong>——零延迟、零抖动，然后才置标志、发中断。你在回调里取走的不是“现在几点”，而是“边沿到达那一刻的照片”。</p>
<p>测量公式：</p>
<ul>
 <li><strong>周期</strong> = 两次上升沿快照之差</li>
 <li><strong>高电平时长</strong> = 上升沿快照 − 下降沿快照</li>
 <li><strong>占空比</strong> = 高电平时长 ÷ 周期</li>
</ul>
<p><strong>F1 的硬件限制</strong>：捕获极性只有上升/下降两档，<strong>没有双沿捕获</strong>（双沿是 F2/F4 之后新系列才加的，CubeMX 里找不到 Both Edges 不是操作问题，是芯片能力）。想要双沿效果 → <strong>双通道分工</strong>：CH2 守上升沿测周期、CH3 守下降沿测脉宽——这本来就是仪器级的标准做法。</p>
<p><strong>16 位回绕</strong>：CNT 数到 65535 会归零。只要信号周期小于定时器溢出周期，用 <strong><code>(uint16_t)(now - last)</code></strong> 的 16 位减法就能自动处理回绕（数学上 mod 65536）。</p>
<h3 style="" id="1.3-%E5%AE%9E%E9%AA%8C%E8%AE%BE%E8%AE%A1%EF%BC%9A%E4%BF%A1%E5%8F%B7%E6%BA%90-%2B-%E6%B5%8B%E9%87%8F%E4%BB%AA%EF%BC%8C%E8%87%AA%E5%B7%B1%E6%B5%8B%E8%87%AA%E5%B7%B1">1.3 实验设计：信号源 + 测量仪，自己测自己</h3>
<pre><code>TIM3_CH3 ──PB0──┬──→ PA1 (TIM2_CH2, 上升沿捕获) → 测周期
                │ 两根杜邦线
                └──→ PA2 (TIM2_CH3, 下降沿捕获) → 测高电平时长
</code></pre>
<p>三个设计决策：</p>
<ol>
 <li><strong>信号源用已知参数</strong>：TIM3 输出 1Hz、50% PWM（PSC=7199、ARR=9999、Pulse=5000）。测量仪读数必须精确等于已知答案——这叫<strong>自校准</strong>：用已知校准未知，是仪器思维的第一课</li>
 <li><strong>测量仪必须用另一个定时器</strong>：TIM3 的 CNT 正被自己的 PWM 用着，相位完全同步——上升沿永远发生在 CNT 同一位置，周期会测成 0（混叠）。信号源和测量仪必须<strong>时钟独立</strong></li>
 <li><strong>分辨率与量程的取舍</strong>：测量仪 TIM2 用 PSC=7199（每格 0.1ms）+ ARR=65535（量程 6.55 秒）——覆盖 1 秒信号绰绰有余，回绕减法也成立</li>
</ol>
<h3 style="" id="1.4-cubemx-%E9%85%8D%E7%BD%AE">1.4 CubeMX 配置</h3>
<table>
 <thead>
  <tr>
   <th>项</th>
   <th>配置</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>TIM3（信号源）</td>
   <td>Clock Source=Internal；Channel3=<strong>PWM Generation CH3</strong>；PSC=<strong>7199</strong>、ARR=<strong>9999</strong>、Pulse=<strong>5000</strong></td>
  </tr>
  <tr>
   <td>TIM2（测量仪）</td>
   <td>Channel2=IC Direct（<strong>Rising Edge</strong>）；Channel3=IC Direct（<strong>Falling Edge</strong>）；PSC=<strong>7199</strong>、Counter Period=<strong>65535</strong></td>
  </tr>
  <tr>
   <td>USART1</td>
   <td>Asynchronous，115200（打印结果用）</td>
  </tr>
  <tr>
   <td>NVIC</td>
   <td><strong>TIM2 global interrupt</strong> 打勾（TIM3 不勾——PWM 零中断）</td>
  </tr>
  <tr>
   <td>硬件</td>
   <td>杜邦线两根：PB0→PA1、PB0→PA2</td>
  </tr>
 </tbody>
</table>
<h3 style="" id="1.5-%E6%A0%B8%E5%BF%83%E4%BB%A3%E7%A0%81">1.5 核心代码</h3>
<p>启动（USER CODE 2）：</p>
<pre><code class="language-c">HAL_TIM_PWM_Start(&amp;htim3, TIM_CHANNEL_3); // 信号源：PB0 输出 1Hz 50% PWM
HAL_TIM_IC_Start_IT(&amp;htim2, TIM_CHANNEL_2); // CH2 上升沿：测周期
HAL_TIM_IC_Start_IT(&amp;htim2, TIM_CHANNEL_3); // CH3 下降沿：测高电平时长
</code></pre>
<p>捕获回调（USER CODE 4）：</p>
<pre><code class="language-c">void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim){
    if(htim-&gt;Instance != TIM2) return;
    if(htim-&gt;Channel == HAL_TIM_ACTIVE_CHANNEL_2){ // 上升沿 → 周期
        uint16_t now = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_2);
        period = (uint16_t)(now - cap_rise); // 两次上升沿之差(16位减法自动处理回绕)
        cap_rise = now;
        ready = 1; // 一整周期凑齐
    }else if(htim-&gt;Channel == HAL_TIM_ACTIVE_CHANNEL_3){ // 下降沿 → 高电平时长
        uint16_t now = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_3);
        width = (uint16_t)(now - cap_rise); // 上升→下降之差
    }
}
</code></pre>
<p>主循环每秒打印 <code>周期/高/占空比</code> 三件套（printf 重定向用模块 2 的 <code>__io_putchar</code> 老配方，记得 Includes 区加 <code>#include &lt;stdio.h&gt;</code>）。</p>
<h3 style="" id="1.6-%E7%8E%B0%E8%B1%A1%E4%B8%8E%E9%AA%8C%E8%AF%81">1.6 现象与验证</h3>
<ul>
 <li>串口每秒一行：<code>周期=1000 ms 高=500 ms 占空比=50%</code>——和信号源已知值精确吻合，自校准通过</li>
 <li>周期在 999/1000 之间偶尔跳变——<strong>末位量化误差</strong>：信号边沿恰好落在 0.1ms 刻度线附近，捕获有时差一拍。0.1ms 分辨率测 1 秒信号理论精度 ±0.01%，真实仪器的末位跳变就是这么来的</li>
 <li><strong>实时跟踪验收</strong>：用调试器现场把信号源的 CCR3 从 5000 改成 2500（一条 <code>mww</code> 命令，不重新烧录），测量仪下一秒立刻显示 <code>高=250 ms 占空比=25%</code>——仪器追上了活的信号变化</li>
</ul>
<h3 style="" id="1.7-%E8%B8%A9%E5%9D%91%E5%AE%9E%E5%BD%95%EF%BC%88%E8%BF%99%E8%8A%82%E8%B8%A9%E5%BE%97%E6%9C%80%E5%AF%86%E9%9B%86%EF%BC%89">1.7 踩坑实录（这节踩得最密集）</h3>
<ol>
 <li><strong>F1 无双沿捕获</strong>：CubeMX 里 Polarity 只有 Rising/Falling——换双通道分工</li>
 <li><strong>主循环代码放错区被吃</strong>：代码写在 <code>USER CODE END WHILE</code> 和 <code>BEGIN 3</code> 之间的<strong>缝隙</strong>（模板自留地），CubeMX 重新生成时被还原——加 USART1 重新生成后 printf 循环凭空消失。正确位置：<code>BEGIN 3</code> 之后</li>
 <li><strong>TIM2 PSC 填成 72</strong>：tick 变成约 1µs，1 秒信号跨 15 次 16 位回绕，减法只救一次——周期打印成 326ms 的乱数（实测变量 period=3261，铁证）</li>
 <li><strong>CH3 极性忘改 Falling</strong>：两个通道都守上升沿，CH2/CH3 在同一个上升沿同时触发，CH2 先算周期更新 cap_rise，CH3 随后算 width=now−cap_rise=<strong>0</strong>——占空比永远 0%（实测 width=0）</li>
 <li><strong>USART1 没配</strong>：<code>huart1</code> 未声明，编译直接报错</li>
 <li><strong>串口中文乱码</strong>：固件 printf 是 UTF-8、串口助手默认 GBK——数字和 % 是 ASCII 两边一样所以能认出，中文全花。解法：助手切 UTF-8</li>
</ol>
<p><strong>排障方法论</strong>：变量不对时用调试器直接读运行中的变量（<code>nm</code> 查地址 + <code>mdw</code> 读值）——period=3261、width=0 两个值一出来，两个配置 bug 当场定位，比瞎猜快一百倍。</p>
<hr>
<h2 style="" id="%E4%BA%8C%E3%80%813b.2-%E7%BC%96%E7%A0%81%E5%99%A8%E6%8E%A5%E5%8F%A3%EF%BC%9A%E8%AE%A9%E7%A1%AC%E4%BB%B6%E8%87%AA%E5%B7%B1%E6%95%B0%E6%97%8B%E9%92%AE">二、3B.2 编码器接口：让硬件自己数旋钮</h2>
<h3 style="" id="2.1-%E5%AE%9E%E9%AA%8C%E7%9B%AE%E6%A0%87">2.1 实验目标</h3>
<p>读取旋转编码器的<strong>位置</strong>（累计计数）、<strong>方向</strong>（顺/逆时针）、<strong>速度</strong>（单位时间计数变化），串口实时打印。</p>
<h3 style="" id="2.2-%E5%8E%9F%E7%90%86%EF%BC%9Aab-%E6%AD%A3%E4%BA%A4-%2B-%E7%A1%AC%E4%BB%B6%E8%A7%A3%E7%A0%81%E5%99%A8">2.2 原理：AB 正交 + 硬件解码器</h3>
<p>旋转编码器（EC11）转起来输出两路方波，玄机在<strong>相位差 90°</strong>：</p>
<pre><code>顺时针（A 超前 B 90°）：
A: ─┐ ┌─┐ ┌─
    └─┘ └─┘ └
B: ──┐ ┌─┐
     └─┘ └
逆时针（B 超前 A 90°）
</code></pre>
<ul>
 <li><strong>方向</strong>藏在“谁超前谁”里</li>
 <li><strong>位置</strong>藏在累计计数里（每圈 N 个脉冲，EC11 常见 20 个）</li>
 <li><strong>速度</strong> = 单位时间的计数变化</li>
</ul>
<p><strong>软件解码的痛</strong>：A、B 各接一个 EXTI 中断数边沿判相位——每圈几百次中断、机械触点抖动产生假边沿、判相位容易错。</p>
<p><strong>硬件编码器接口</strong>：定时器内置的<strong>正交解码器</strong>——A 接 CH1、B 接 CH2，硬件实时盯着两相位关系，<strong>每个有效边沿自动让 CNT +1 或 −1</strong>（顺时针加、逆时针减，方向判断零成本）。你随时读 CNT 就知道位置，<strong>零中断、零 CPU</strong>。这是“把数数交给硬件”的第三次登场（时基→PWM→编码器）。</p>
<p>三个关键机制：</p>
<ul>
 <li><strong>四倍频（x4）</strong>：Encoder Mode 选 "TI1 and TI2" = A、B 的<strong>每个边沿</strong>都计数 → 分辨率 ×4（20 脉冲/圈 = 80 计数/圈），转一格恰好 ±4——“四倍频”名字即来于此</li>
 <li><strong>PSC 失效</strong>：编码器模式下 CNT 的心跳来自<strong>外部边沿</strong>而不是内部时钟——预分频器形同虚设（不用设）</li>
 <li><strong>16 位双向回绕</strong>：顺转加到 65535 回绕到 0，逆转减到 0 回绕到 65535。读位置差用 <strong><code>(int16_t)(now - last)</code></strong>——有符号 16 位减法自动处理双向回绕，还白送一个符号（正=顺、负=逆）</li>
</ul>
<h3 style="" id="2.3-%E5%AE%9E%E9%AA%8C%E8%AE%BE%E8%AE%A1">2.3 实验设计</h3>
<p>TIM4 编码器模式 + 主循环每 200ms 采样一次 CNT 算速度。<strong>引脚重映射实战</strong>：TIM4 编码器默认脚是 PB6/PB7，但配置时把引脚点到了 <strong>PD12/PD13</strong>（TIM4 重映射后的位置）——同一对通道的两个住址，CubeMX 自动生成 <code>__HAL_AFIO_REMAP_TIM4_ENABLE()</code>，和 PB6/PB7 完全等效。这无意中把 F1 的“引脚搬家”（AFIO 重映射）功能用了一遍。</p>
<p>编码器模块五脚（GND/VCC/A/B/C）：<strong>VCC→3.3V</strong>（别接 5V，模块上拉电平要和 STM32 一致）、GND→GND、A→PD12、B→PD13、<strong>C 是按键脚</strong>（按下导通，本实验不接，留给 3B.3 当刹车按钮）。</p>
<h3 style="" id="2.4-cubemx-%E9%85%8D%E7%BD%AE">2.4 CubeMX 配置</h3>
<table>
 <thead>
  <tr>
   <th>项</th>
   <th>配置</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>TIM4</td>
   <td>Combined Channels → <strong>Encoder Mode</strong></td>
  </tr>
  <tr>
   <td>Encoder Mode</td>
   <td><strong>Encoder Mode TI1 and TI2</strong>（x4 四倍频）</td>
  </tr>
  <tr>
   <td>Input Filter（两通道）</td>
   <td><strong>15</strong>（机械触点抖动大，硬件滤波）</td>
  </tr>
  <tr>
   <td>Counter Period</td>
   <td><strong>65535</strong>（满量程，16 位回绕成立）</td>
  </tr>
  <tr>
   <td>引脚 GPIO</td>
   <td>PD12/PD13 设 <strong>Pull-up</strong>（编码器是开关输出，必须上拉；带板模块有板载上拉则双上拉无害）</td>
  </tr>
 </tbody>
</table>
<h3 style="" id="2.5-%E6%A0%B8%E5%BF%83%E4%BB%A3%E7%A0%81">2.5 核心代码</h3>
<p>启动只要一行（无需中断）：</p>
<pre><code class="language-c">HAL_TIM_Encoder_Start(&amp;htim4, TIM_CHANNEL_ALL); // 启动编码器接口: 硬件开始自动计数
</code></pre>
<p>主循环每 200ms 采样：</p>
<pre><code class="language-c">int16_t now = (int16_t)__HAL_TIM_GET_COUNTER(&amp;htim4); // 读当前位置
int16_t speed = (int16_t)(now - enc_last); // 差值: 正=顺 负=逆 0=静止
enc_last = now;
printf("位置=%d 速度=%d %s\r\n", now, speed,
        speed&gt;0 ? "顺时针" : (speed&lt;0 ? "逆时针" : "静止"));
</code></pre>
<h3 style="" id="2.6-%E7%8E%B0%E8%B1%A1%E4%B8%8E%E9%AA%8C%E8%AF%81">2.6 现象与验证</h3>
<p>串口每 200ms 一行，转动旋钮时：</p>
<pre><code>位置=-25 速度=1 顺时针
位置=-16 速度=8 顺时针
位置=-8 速度=0 静止
位置=-26 速度=-18 逆时针
</code></pre>
<p>调试器协同采样（云端定时读 CNT，用户同时转旋钮）：<code>+7 → −13 → +23 → −12 → +21 → −22 → +8 → +17 → +7</code>——<strong>双向计数实锤</strong>，数值在正负之间摆动对应来回旋转，停下归零。A/B 接反不会坏任何东西，只是方向反过来——对调两根线即可，编码器接线的日常。</p>
<h3 style="" id="2.7-%E8%B8%A9%E5%9D%91%E5%AE%9E%E5%BD%95">2.7 踩坑实录</h3>
<ol>
 <li><strong>编码器没接就查代码</strong>：CNT 六次采样全 0，排查半天——其实是编码器根本还没接线。先确认物理连接再查软件</li>
 <li><strong>引脚住址困惑</strong>：资料说 TIM4 是 PD12/13，教程说 PB6/7——两个都对，一个是重映射后的住址。跟着固件实际配置接（看 CubeMX 引脚分配或生成代码里的 REMAP 宏）</li>
 <li><strong>打印刷屏</strong>：主循环 <code>HAL_Delay(10)</code> 没按设计改成 200——每秒 100 行串口刷成瀑布。采样周期决定打印节奏，200ms 才能看清</li>
</ol>
<hr>
<h2 style="" id="%E4%B8%89%E3%80%813b.3-%E9%AB%98%E7%BA%A7%E5%AE%9A%E6%97%B6%E5%99%A8-tim1%EF%BC%9A%E4%BA%92%E8%A1%A5%E3%80%81%E6%AD%BB%E5%8C%BA%E4%B8%8E%E5%88%B9%E8%BD%A6">三、3B.3 高级定时器 TIM1：互补、死区与刹车</h2>
<h3 style="" id="3.1-%E5%AE%9E%E9%AA%8C%E7%9B%AE%E6%A0%87">3.1 实验目标</h3>
<p>输出一对<strong>互补 PWM</strong>（驱动半桥上下管），插入<strong>死区时间</strong>（防直通），接上<strong>刹车输入</strong>（硬件急停）——电机控制的看家本领三件套。</p>
<h3 style="" id="3.2-%E5%8E%9F%E7%90%86%EF%BC%9A%E4%B8%BA%E5%8D%8A%E6%A1%A5%E8%80%8C%E7%94%9F%E7%9A%84%E5%AE%89%E5%85%A8%E9%93%BE">3.2 原理：为半桥而生的安全链</h3>
<p>半桥是电机驱动的最小单元：</p>
<pre><code>     ──┬── 电源
 [上管] ← TIM1_CH1 (PE9)
       ├──── 中点 → 接电机
 [下管] ← TIM1_CH1N(PE8)
     ──┴── GND
</code></pre>
<p>上下管<strong>绝不能同时导通</strong>——同时导通=电源对地短路（直通），MOS 管瞬间烧毁。互补 PWM 保证逻辑上不同时通，<strong>但 MOS 管关断比开通慢</strong>——切换瞬间上管没关干净、下管已经开通，微秒级直通照样炸管。</p>
<p>三个硬件安全机制：</p>
<ul>
 <li><strong>互补输出</strong>：每个通道两根引脚（CHx/CHxN），输出极性相反，正好驱动上下两管</li>
 <li><strong>死区时间（DTG）</strong>：每次切换插入一段“双管全关”的空档，<strong>硬件按 tDTS（72MHz，13.9ns 步进）自动插入</strong>，不受 PSC 影响，软件零参与</li>
 <li><strong>刹车（BKIN）</strong>：一个引脚触发时<strong>硬件直接清 MOE 总闸</strong>（主输出使能），所有输出瞬间进安全态，不等软件反应——过流保护、急停按钮的最后一道保险丝</li>
</ul>
<h3 style="" id="3.3-%E5%AE%9E%E9%AA%8C%E8%AE%BE%E8%AE%A1%EF%BC%9A%E7%AC%AC%E4%B8%89%E6%AC%A1%E2%80%9C%E8%87%AA%E5%B7%B1%E6%B5%8B%E8%87%AA%E5%B7%B1%E2%80%9D">3.3 实验设计：第三次“自己测自己”</h3>
<pre><code>TIM1_CH1(PE9) ──┬──→ PA1 (捕获仪CH2, 上升沿→周期)
                └──→ PA2 (捕获仪CH3, 下降沿→脉宽)
TIM1_CH1N(PE8) ──→ 空着（调试器采样电平验证互补性）
编码器 C 键 ──→ PE15 (BKIN 刹车)
</code></pre>
<p><strong>实验参数 vs 工程参数的取舍</strong>（重要思维）：真实电机用 20kHz PWM + 几百 ns 死区，但捕获仪最快 1µs 分辨率测不到——所以故意<strong>放慢 PWM（100Hz）、加大死区（14µs）</strong>让仪器能看见。实验参数为可测性服务，工程参数为效率服务，别混为一谈。</p>
<p>参数计算：</p>
<ul>
 <li>TIM1：PSC=71（1µs 粒度）、ARR=9999（10ms 周期=100Hz）、Pulse=5000（50%）、<strong>DTG=255（0xFF 编码 ≈ 14µs 死区）</strong></li>
 <li>捕获仪 TIM2：PSC 从 7199 改成 <strong>71</strong>（分辨率从 0.1ms 提到 1µs）</li>
 <li><strong>预期读数</strong>：周期=10000µs、高=<strong>4986µs</strong>——理想 5000 被死区啃掉 14 → <strong>死区被直接测出来</strong>；占空比 49%（死区吃掉 1% 占空比）</li>
</ul>
<p><strong>引脚重映射</strong>：TIM1 默认脚在板上全被占（CH1N=PA7 是 W25Q16、CH2=PA9 是串口、CH3=PA10 是串口、CH4=PA11 是 USB、BKIN=PA6 是 W25Q16）——<strong>全重映射到 PE 口</strong>：CH1=PE9、CH1N=PE8、BKIN=PE15（动手前先确认板上排针有引出）。</p>
<h3 style="" id="3.4-cubemx-%E9%85%8D%E7%BD%AE">3.4 CubeMX 配置</h3>
<table>
 <thead>
  <tr>
   <th>项</th>
   <th>配置</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>TIM1 → Channel1</td>
   <td><strong>PWM Generation CH1 CH1N</strong>（互补模式）</td>
  </tr>
  <tr>
   <td>引脚（点击分配）</td>
   <td>PE9→TIM1_CH1、PE8→TIM1_CH1N、<strong>PE15→TIM1_BKIN</strong>（先分配 BKIN，刹车选项才出现）</td>
  </tr>
  <tr>
   <td>TIM1 时基</td>
   <td>PSC=<strong>71</strong>、ARR=<strong>9999</strong>、Pulse=<strong>5000</strong></td>
  </tr>
  <tr>
   <td>Break and Dead Time 分组</td>
   <td><strong>Dead Time=255</strong>、BRK Polarity=<strong>Low</strong>（按下=低=刹车）、Automatic Output State=<strong>Enable</strong>（松开自动恢复）、OSSI=<strong>Enable</strong>（刹车时引脚驱动到确定电平）</td>
  </tr>
  <tr>
   <td>PE15 GPIO</td>
   <td><strong>Pull-up</strong>（关键！见踩坑）</td>
  </tr>
  <tr>
   <td>TIM2</td>
   <td>Prescaler 7199→<strong>71</strong>（捕获仪提分辨率）</td>
  </tr>
  <tr>
   <td>接线</td>
   <td>PE9→PA1、PE9→PA2（从 PB0 挪线）、编码器 C 键→PE15</td>
  </tr>
 </tbody>
</table>
<p>刹车选项含义速查：<strong>BRK State</strong> 刹车电路电源开关；<strong>BRK Polarity</strong> 什么电平触发（按键按下接地→Low）；<strong>Automatic Output State</strong> 刹车消失后谁合闸（Enable=硬件自动，真实故障场景用 Disable 等软件确认）；<strong>OSSI/OSSR</strong> 输出关闭时引脚浮空还是驱动到确定电平（电机场景必开，防栅极浮空误触发）；<strong>Lock</strong> 给安全配置上锁防篡改。</p>
<h3 style="" id="3.5-%E6%A0%B8%E5%BF%83%E4%BB%A3%E7%A0%81">3.5 核心代码</h3>
<p>互补输出要开<strong>两个</strong>开关（USER CODE 2）：</p>
<pre><code class="language-c">HAL_TIM_PWM_Start(&amp;htim1, TIM_CHANNEL_1); // CH1 主输出(顺带开MOE总闸)
HAL_TIMEx_PWMN_Start(&amp;htim1, TIM_CHANNEL_1); // CH1N 互补输出(注意是Ex扩展函数)
</code></pre>
<p>printf 换微秒版（tick 已是 1µs，不用再除）：</p>
<pre><code class="language-c">printf("周期=%u us 高=%u us 占空比=%u%%\r\n",
        period, width, width*100/period);
</code></pre>
<h3 style="" id="3.6-%E7%8E%B0%E8%B1%A1%E4%B8%8E%E9%AA%8C%E8%AF%81">3.6 现象与验证</h3>
<ul>
 <li>串口：<code>周期=10000 us 高=4986 us 占空比=49%</code>——<strong>5000−4986=14µs 就是死区实测</strong>，与 DTG=255 的理论值精确命中</li>
 <li><strong>互补性铁证</strong>（调试器十次采样 PE8/PE9 电平）：<code>PE9=1,PE8=0 → PE8=1,PE9=0 → …</code> <strong>10/10 严格单高</strong>——两脚永不同时为高，互补+死区成立</li>
 <li><strong>刹车实测</strong>：按住编码器旋钮（C 键把 PE15 拉低）→ PWM 立即停止、打印冻结；松开 → AOE 自动合闸、打印恢复滚动</li>
</ul>
<h3 style="" id="3.7-%E8%B8%A9%E5%9D%91%E5%AE%9E%E5%BD%95%EF%BC%9Abkin-%E6%82%AC%E7%A9%BA%E5%BC%95%E5%8F%91%E7%9A%84%E2%80%9C%E5%85%A8%E9%93%BE%E8%B7%AF%E5%81%87%E6%95%85%E9%9A%9C%E2%80%9D%EF%BC%88%E6%9C%AC%E6%A8%A1%E5%9D%97%E6%9C%80%E7%B2%BE%E5%BD%A9%E7%9A%84%E4%B8%80%E6%A1%88%EF%BC%89">3.7 踩坑实录：BKIN 悬空引发的“全链路假故障”（本模块最精彩的一案）</h3>
<p><strong>现象</strong>：烧录后测量值全 0、串口没有测量数据、输出引脚电平乱跳。</p>
<p><strong>排查链</strong>（每一步都有寄存器证据）：</p>
<ol>
 <li>读测量变量：period=0、width=0 → 捕获仪没收到边沿 → PE9 上没有波形</li>
 <li>读 TIM1_BDTR=0x54FF：死区/刹车/自动恢复配置全对，但 <strong>MOE=0——总闸被拉掉了</strong></li>
 <li>读 PE15 电平：<strong>低</strong> → 刹车处于触发状态（配的是低有效）</li>
 <li>因果链闭合：PE15 低 → 刹车 → 硬件清 MOE → 输出禁 → 无波形 → 测量 0。PE15 电平乱跳还导致刹车反复触发/AOE 反复合闸，MOE 振荡，输出时有时无</li>
 <li>查生成代码：PE15 是 <code>GPIO_MODE_INPUT + GPIO_NOPULL</code>——<strong>悬空</strong>！</li>
 <li>修复（配上拉）后仍读低 → 读 CRH（模式=上拉输入 ✓）+ ODR（拉的方向=上 ✓）+ IDR（实际电平=低 ✗）三连——内部上拉生效但读低 = <strong>外部有东西硬拉地</strong></li>
 <li><strong>判决性实验</strong>：拔掉 PE15 上的线 → 立刻变高、MOE=1、测量值 10000/4986 全部出现——实锤是那根线接着地（模块端插错孔）</li>
</ol>
<p><strong>教训</strong>：刹车电路对电平极度敏感，<strong>BKIN 必须接确定电平（上拉），绝不悬空</strong>——这本身就是刹车威力的最好反面教材。排查手法沉淀：CRH/ODR/IDR 三连读（配置生效了吗+拉对方向了吗+实际电平是多少），内部上拉打不过外部直连地，拔线是最终的二分判决。</p>
<p>另一个坑：<strong>frp 调试偶发“全零假象”</strong>——openocd 连接后芯片停在复位态没放行，所有寄存器读复位值 0、RAM 是陈旧值，看起来像全面故障。解法：<code>monitor reset halt</code> + <code>monitor resume</code> 序列。<strong>读数全零时先怀疑目标没在跑，再怀疑代码。</strong></p>
<hr>
<h2 style="" id="%E7%BB%93%E8%AF%AD%EF%BC%9A%E4%B8%89%E4%B8%AA%E5%AE%9E%E9%AA%8C%E4%B8%B2%E6%88%90%E7%9A%84%E4%B8%80%E6%9D%A1%E7%BA%BF">结语：三个实验串成的一条线</h2>
<p>三个实验其实是同一条思路的三次递进：</p>
<ul>
 <li><strong>3B.1</strong>：硬件快门记时刻 → 测周期测脉宽（测量时间）</li>
 <li><strong>3B.2</strong>：硬件解码器数边沿 → 测位置测方向（测量旋转）</li>
 <li><strong>3B.3</strong>：硬件死区刹车保安全 → 互补输出驱动半桥（输出+保护）</li>
</ul>
<p>加上 3A 的时基/PWM，F103 的定时器全家桶至此没有死角。调试手法也攒出一套：<code>nm</code> 查变量地址 + <code>mdw</code> 读运行中的值、<code>compare-sections</code> 鉴别固件身份、<code>mww</code> 不重烧录现场改参数、协同采样（人转旋钮+云端读寄存器）——没有示波器的环境下，调试器就是你的眼睛。</p>]]></description><guid isPermaLink="false">/archives/stm32-timer-3b-input-capture-encoder-advanced</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Fri, 2 Oct 2026 13:27:39 GMT</pubDate></item><item><title><![CDATA[STM32 定时器实验全解：三阶段完整代码与思路（时基 → 输出比较 → PWM 呼吸灯）]]></title><link>https://www.flor.cn/archives/stm32-timer-3stage-timbase-oc-pwm</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=STM32%20%E5%AE%9A%E6%97%B6%E5%99%A8%E5%AE%9E%E9%AA%8C%E5%85%A8%E8%A7%A3%EF%BC%9A%E4%B8%89%E9%98%B6%E6%AE%B5%E5%AE%8C%E6%95%B4%E4%BB%A3%E7%A0%81%E4%B8%8E%E6%80%9D%E8%B7%AF%EF%BC%88%E6%97%B6%E5%9F%BA%20%E2%86%92%20%E8%BE%93%E5%87%BA%E6%AF%94%E8%BE%83%20%E2%86%92%20PWM%20%E5%91%BC%E5%90%B8%E7%81%AF%EF%BC%89&amp;url=/archives/stm32-timer-3stage-timbase-oc-pwm" width="1" height="1" alt="" style="opacity:0;">
<blockquote>
 <p>平台：STM32F103ZET6 @ 72MHz · LED=PB5（低电平点亮）· 工程名 tim_led 三个阶段是同一条思路的递进：<strong>每一步都把上一课软件干的活移交给硬件</strong></p>
</blockquote>
<h2 style="" id="%E9%98%B6%E6%AE%B5%E4%B8%80%EF%BC%9A%E6%97%B6%E5%9F%BA%E2%80%94%E2%80%941ms-%E5%AE%9A%E6%97%B6%EF%BC%8Cled-1hz-%E9%97%AA%E7%83%81%EF%BC%88%E8%BD%AF%E4%BB%B6%E8%AE%A1%E6%95%B0%E7%89%88%EF%BC%89">阶段一：时基——1ms 定时，LED 1Hz 闪烁（软件计数版）</h2>
<h3 style="" id="%E6%80%9D%E8%B7%AF">思路</h3>
<p>先解决"HAL_Delay 会阻塞 CPU"的问题：让定时器硬件自己数数，数满 1ms 敲一次门（中断），CPU 平时自由，只在被敲门时干一小点活。LED 要 1Hz 闪（亮 500ms 灭 500ms），但定时器只会每 1ms 响一次铃——<strong>"500ms"这个大时间尺度由软件在回调里数数实现</strong>：攒 500 个 1ms 就翻转一次引脚。</p>
<h3 style="" id="cubemx-%E9%85%8D%E7%BD%AE">CubeMX 配置</h3>
<table>
 <thead>
  <tr>
   <th>项</th>
   <th>值</th>
   <th>算法</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>TIM2 Clock Source</td>
   <td>Internal Clock</td>
   <td>用内部 72MHz</td>
  </tr>
  <tr>
   <td>Prescaler (PSC)</td>
   <td>71</td>
   <td>72M ÷ (71+1) = 1MHz，每格 <strong>1µs</strong></td>
  </tr>
  <tr>
   <td>Counter Period (ARR)</td>
   <td>999</td>
   <td>1000 格 × 1µs = <strong>1ms</strong> 溢出一次</td>
  </tr>
  <tr>
   <td>NVIC</td>
   <td>TIM2 global interrupt 打勾</td>
   <td>放行中断线</td>
  </tr>
 </tbody>
</table>
<p>注意 +1 陷阱：PSC/ARR 都从 0 数起，所以 71 是"72 档"、999 是"1000 格"。TIM2 挂 APB1（名义 36M），但定时器走 ×2 后门实际吃 72MHz。</p>
<h3 style="" id="%E5%AE%8C%E6%95%B4%E4%BB%A3%E7%A0%81">完整代码</h3>
<pre><code class="language-c">/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start_IT(&amp;htim2);   // 拧发条：CEN=1 开始数数，UIE=1 数满允许敲门
/* USER CODE END 2 */
</code></pre>
<pre><code class="language-c">/* USER CODE BEGIN 4 */
// TIM2 每 1ms 进来一次（中断上下文：快进快出，只做加减和翻转）
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim-&gt;Instance == TIM2) {
        static uint16_t ms = 0;              // static：跨中断保留进度
        if (++ms &gt;= 500) {                   // 攒满 500 个 1ms
            ms = 0;
            HAL_GPIO_TogglePin(led1_GPIO_Port, led1_Pin);  // 翻转 → 1Hz 闪烁
        }
    }
}
/* USER CODE END 4 */
</code></pre>
<h3 style="" id="%E9%80%90%E8%A1%8C%E8%AE%B2%E8%A7%A3">逐行讲解</h3>
<ul>
 <li><strong><code>HAL_TIM_Base_Start_IT(&amp;htim2)</code></strong>：CubeMX 的 <code>MX_TIM2_Init</code> 只负责"装填参数"（PSC、ARR 写进寄存器），此时计数器是静止的（CEN=0）。这一行干两件事：<strong>CEN=1</strong>（开始数）+ <strong>UIE=1</strong>（允许 Update 事件触发中断）。不调它，配置再对也是死表。</li>
 <li><strong><code>if (htim-&gt;Instance == TIM2)</code></strong>：<code>HAL_TIM_PeriodElapsedCallback</code> 是全局弱符号回调——将来你开 TIM3、TIM4，它们的 Update 事件也进这同一个函数。不判断是谁，就会串台。</li>
 <li><strong><code>static uint16_t ms = 0</code></strong>：本阶段的灵魂。普通局部变量每次函数返回就消失，<code>static</code> 让它<strong>活过函数返回</strong>——每次中断进来，ms 都是"上次的值"。没有 static，ms 永远从 0 数起，永远到不了 500，灯永远不闪。</li>
 <li><strong><code>if (++ms &gt;= 500)</code></strong>：前缀自增（先加再比）。500 个 1ms = 500ms。</li>
 <li><strong><code>HAL_GPIO_TogglePin</code></strong>：翻转 PB5。亮 500ms、灭 500ms，1Hz。</li>
 <li><strong>中断纪律</strong>：回调里只做加减和写引脚——中断上下文必须快进快出（拖久了下一次 1ms 到点就堆积）。</li>
</ul>
<h3 style="" id="%E7%8E%B0%E8%B1%A1%E4%B8%8E%E6%9C%AC%E8%B4%A8">现象与本质</h3>
<p>LED 1Hz 闪烁。本质：<strong>硬件管"1ms 这个小周期"，软件数"500 次这个大尺度"</strong>。缺点也明显：每秒 1000 次中断，只为数一个数。</p>
<hr>
<h2 style="" id="%E9%98%B6%E6%AE%B5%E4%BA%8C%EF%BC%9A%E8%BE%93%E5%87%BA%E6%AF%94%E8%BE%83%E2%80%94%E2%80%94%E5%8F%8C%E9%97%A8%E6%A7%9B%E5%AE%9A%E7%82%B9%E4%BA%AE%E7%81%AD%EF%BC%88%E9%9B%B6%E8%AE%A1%E6%95%B0%E7%89%88%EF%BC%89">阶段二：输出比较——双门槛定点亮灭（零计数版）</h2>
<h3 style="" id="%E6%80%9D%E8%B7%AF">思路</h3>
<p>阶段一里"500ms"靠软件数——能不能让硬件直接在 500ms 处响铃？<strong>输出比较（OC）就是给 CNT 再加一道门槛 CCR</strong>：CNT 数到 CCR 的瞬间，Compare 事件爆发。于是周期里有两道硬件门槛：</p>
<ul>
 <li><strong>Update 事件</strong>（CNT 溢出归零，t=0s）→ 点亮</li>
 <li><strong>Compare 事件</strong>（CNT 数到 CCR1，t=0.5s）→ 熄灭</li>
</ul>
<p>回调里<strong>一个计数器都不需要</strong>——两道门槛把时间点定死，软件只负责"听到铃声做动作"。这就是 PWM 的手工版。</p>
<h3 style="" id="cubemx-%E9%85%8D%E7%BD%AE">CubeMX 配置</h3>
<table>
 <thead>
  <tr>
   <th>项</th>
   <th>值</th>
   <th>算法</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>TIM2 Channel1</td>
   <td><strong>Output Compare No Output</strong></td>
   <td>只比较不占引脚（TIM2_CH1=PA0 是 WKUP 按键脚，不能碰）</td>
  </tr>
  <tr>
   <td>Prescaler (PSC)</td>
   <td>7199</td>
   <td>72M ÷ 7200 = 10kHz，每格 <strong>0.1ms</strong></td>
  </tr>
  <tr>
   <td>Counter Period (ARR)</td>
   <td>9999</td>
   <td>10000 格 = <strong>1s 周期</strong></td>
  </tr>
  <tr>
   <td>Pulse（CCR1 的界面名）</td>
   <td>5000</td>
   <td>第 5000 格 = <strong>500ms</strong> 处响铃</td>
  </tr>
 </tbody>
</table>
<p>为什么 PSC 从 71 改成 7199？ARR 是 16 位（最大 65535），1µs 粒度最多数 65.5ms——<strong>凑 1 秒周期必须先降频</strong>。这就是 (PSC, ARR) 的因式分解自由度：先定粒度，再凑周期。</p>
<h3 style="" id="%E5%AE%8C%E6%95%B4%E4%BB%A3%E7%A0%81">完整代码</h3>
<pre><code class="language-c">/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start_IT(&amp;htim2);              // 数数 + Update中断（周期起点事件）
HAL_TIM_OC_Start_IT(&amp;htim2, TIM_CHANNEL_1);  // 再开CH1比较中断（CCR门槛事件）
/* USER CODE END 2 */
</code></pre>
<pre><code class="language-c">/* USER CODE BEGIN 4 */
// Update事件：CNT溢出归零的瞬间 = 周期起点 → 点亮
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim-&gt;Instance == TIM2)
        HAL_GPIO_WritePin(led1_GPIO_Port, led1_Pin, GPIO_PIN_RESET);  // 低电平=亮
}

// Compare事件：CNT数到CCR1的瞬间 → 熄灭
void HAL_TIM_OC_DelayElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim-&gt;Instance == TIM2 &amp;&amp; htim-&gt;Channel == HAL_TIM_ACTIVE_CHANNEL_1)
        HAL_GPIO_WritePin(led1_GPIO_Port, led1_Pin, GPIO_PIN_SET);    // 高电平=灭
}
/* USER CODE END 4 */
</code></pre>
<h3 style="" id="%E9%80%90%E8%A1%8C%E8%AE%B2%E8%A7%A3">逐行讲解</h3>
<ul>
 <li><strong>两声令</strong>：<code>HAL_TIM_Base_Start_IT</code> 开 Update 中断许可（UIE=1）；<code>HAL_TIM_OC_Start_IT</code> 开 CH1 的 Compare 中断许可（CC1IE=1）+ 通道使能。两个 Start 各管各的许可灯，缺一个就少一种铃声。</li>
 <li><strong>一个入口，两个回调</strong>：两种事件走<strong>同一个</strong> <code>TIM2_IRQHandler</code>，HAL 在 <code>HAL_TIM_IRQHandler</code> 里查标志分家——UIF 走 <code>PeriodElapsedCallback</code>，CC1IF 走 <code>OC_DelayElapsedCallback</code>。</li>
 <li><strong>OC 回调多判一个 <code>htim-&gt;Channel</code></strong>：4 个通道共用一个 OC 回调，<code>Channel</code> 告诉你是哪个通道在响（这里必须确认是 CH1）。</li>
 <li><strong>两个回调名字都是弱符号</strong>：名字一字不差才构成覆盖。笔误一个字母 = 函数成孤儿 = 编译零警告、现象纹丝不动（真实踩过：<code>HLA_TIM_PeriodElapsedClallback</code>）。防御：从 HAL 源码抄、用 VSCode 补全敲、写完 grep 核对。</li>
 <li><strong>写引脚用 SET/RESET 而不是 Toggle</strong>：亮灭动作分别绑定在两道门槛上，语义清晰——这正是 PWM"起点置高、比较处置低"的雏形。</li>
</ul>
<h3 style="" id="%E7%8E%B0%E8%B1%A1%E4%B8%8E%E6%9C%AC%E8%B4%A8">现象与本质</h3>
<p>现象和阶段一一样是 1Hz 闪烁，但内核换了引擎：<strong>中断从 1000 次/秒降到 2 次/秒</strong>（CPU 占用差 500 倍），定时精度从"受中断延迟影响"变成"硬件门槛精准到格"。</p>
<hr>
<h2 style="" id="%E9%98%B6%E6%AE%B5%E4%B8%89%EF%BC%9Apwm%E2%80%94%E2%80%94%E7%A1%AC%E4%BB%B6%E6%96%B9%E6%B3%A2-%2B-%E5%91%BC%E5%90%B8%E7%81%AF%EF%BC%88%E6%9C%80%E7%BB%88%E7%89%88%EF%BC%89">阶段三：PWM——硬件方波 + 呼吸灯（最终版）</h2>
<h3 style="" id="%E6%80%9D%E8%B7%AF">思路</h3>
<p>两个硬件事实决定了方案：</p>
<ol>
 <li><strong>硬件 PWM 的规则</strong>：通道配成 PWM 模式后，硬件自动执行"<strong>CNT &lt; CCR → 引脚高，CNT ≥ CCR → 引脚低</strong>"——零中断零 CPU。用 TIM3_CH3（PB0）做硬件 PWM 演示（PA6/PA7 被板载 Flash 占用，选脚先查板载外设）。</li>
 <li><strong>LED 在 PB5——不属于任何定时器通道</strong>，硬件 PWM 够不着它 → 呼吸灯必须<strong>软件 PWM</strong>。</li>
</ol>
<p>软件 PWM 的架构是<strong>载波与包络分层</strong>：</p>
<ul>
 <li><strong>载波层</strong>：阶段二的双门槛机器，把周期从 1s 缩到 10ms（100Hz）——这个频率不能慢，慢了眼睛看见闪烁</li>
 <li><strong>包络层</strong>：Update 回调每个载波周期推一下占空比 duty（±1，在 0↔100 折返），用 <code>__HAL_TIM_SET_COMPARE</code> 动态写进 CCR1</li>
 <li>眼睛+LED 是低通滤波器：100Hz 快速亮灭只留<strong>平均亮度</strong>，正比占空比 → duty 扫 0↔100，灯就呼吸</li>
</ul>
<h3 style="" id="cubemx-%E9%85%8D%E7%BD%AE">CubeMX 配置</h3>
<table>
 <thead>
  <tr>
   <th>项</th>
   <th>值</th>
   <th>算法</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>TIM2 Prescaler</td>
   <td>7199（不变）</td>
   <td>每格 0.1ms</td>
  </tr>
  <tr>
   <td>TIM2 Counter Period</td>
   <td><strong>99</strong></td>
   <td>100 格 × 0.1ms = <strong>10ms = 100Hz 载波</strong></td>
  </tr>
  <tr>
   <td>TIM2 Pulse</td>
   <td>0</td>
   <td>代码每周期重写，初值无所谓</td>
  </tr>
  <tr>
   <td>TIM3 Clock Source</td>
   <td>Internal Clock</td>
   <td></td>
  </tr>
  <tr>
   <td>TIM3 Channel3</td>
   <td><strong>PWM Generation CH3</strong></td>
   <td>引脚自动分配 PB0</td>
  </tr>
  <tr>
   <td>TIM3 PSC / ARR / Pulse</td>
   <td>7199 / 9999 / 5000</td>
   <td><strong>1Hz、50% 占空比</strong>（人眼级慢动作，方便调试器采样验证）</td>
  </tr>
  <tr>
   <td>TIM3 NVIC</td>
   <td><strong>不勾</strong></td>
   <td>硬件 PWM 零中断——和 TIM2 软件版的本质区别</td>
  </tr>
 </tbody>
</table>
<p>关键刻度对齐：<strong>duty 是 0~100 的百分数，直接写进 CCR1，所以 ARR 必须=99</strong>——在 ARR=99 的世界里 100/100=100%，在 ARR=9999 的世界里 100/10000 只有 1%（真实踩过：PSC/ARR 改反，呼吸在 1% 亮度里扫，肉眼等于没亮）。<strong>占空比是比值，duty 的尺子必须和 ARR 同刻度</strong>。</p>
<h3 style="" id="%E5%AE%8C%E6%95%B4%E4%BB%A3%E7%A0%81">完整代码</h3>
<pre><code class="language-c">/* USER CODE BEGIN PV */
uint8_t duty = 0;     // 当前占空比 0~100（呼吸的"肺活量"）
int8_t  dir  = 1;     // 呼吸方向：+1 渐亮，-1 渐暗
/* USER CODE END PV */
</code></pre>
<pre><code class="language-c">/* USER CODE BEGIN 2 */
HAL_TIM_Base_Start_IT(&amp;htim2);              // TIM2：呼吸灯的软件PWM机器（阶段二原样）
HAL_TIM_OC_Start_IT(&amp;htim2, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&amp;htim3, TIM_CHANNEL_3);   // TIM3：PB0 硬件PWM，零中断
/* USER CODE END 2 */
</code></pre>
<pre><code class="language-c">/* USER CODE BEGIN 4 */
// Update事件 = 每个PWM周期(10ms)的起点：推进呼吸坡度，装填本周期占空比
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim){
  if(htim-&gt;Instance == TIM2){
    duty += dir;                                        // 坡度每周期 ±1
    if(duty &gt;= 100){ duty = 100; dir = -1; }            // 亮到头，折返渐暗
    if(duty &lt;= 0)  { duty = 0;   dir = +1; }             // 暗到头，折返渐亮
    __HAL_TIM_SET_COMPARE(&amp;htim2, TIM_CHANNEL_1, duty); // 门槛=本周期占空比
    if(duty == 0)
      HAL_GPIO_WritePin(led1_GPIO_Port,led1_Pin,GPIO_PIN_SET);   // 0%：直接灭
    else
      HAL_GPIO_WritePin(led1_GPIO_Port,led1_Pin,GPIO_PIN_RESET); // &gt;0%：先亮，等Compare来灭
  }
}

// Compare事件 = CNT数到duty处：熄灭（与阶段二一字不差）
void HAL_TIM_OC_DelayElapsedCallback(TIM_HandleTypeDef *htim){
  if(htim-&gt;Instance == TIM2 &amp;&amp; htim-&gt;Channel == HAL_TIM_ACTIVE_CHANNEL_1){
    HAL_GPIO_WritePin(led1_GPIO_Port,led1_Pin,GPIO_PIN_SET);
  }
}
/* USER CODE END 4 */
</code></pre>
<h3 style="" id="%E9%80%90%E8%A1%8C%E8%AE%B2%E8%A7%A3">逐行讲解</h3>
<ul>
 <li><strong>PV 区两个变量</strong>：<code>duty</code> 是当前占空比（呼吸的"肺活量"），<code>dir</code> 是方向（+1 渐亮 / −1 渐暗）。两个变量合起来就是一个微型呼吸状态机。</li>
 <li><strong><code>HAL_TIM_PWM_Start(&amp;htim3, TIM_CHANNEL_3)</code></strong>：硬件 PWM 的启动只要一行，连 NVIC 都不用配——波形完全由硬件按"CNT&lt;CCR3 输出高"的规则自己画，CPU 零参与。</li>
 <li><strong><code>duty += dir</code> + 两个折返 if</strong>：三角波坡度。duty 到 100 掉头向下、到 0 掉头向上，0↔100 来回扫。呼吸节奏 = 200 步 × 10ms = <strong>2 秒一个呼吸</strong>。</li>
 <li><strong><code>__HAL_TIM_SET_COMPARE(&amp;htim2, TIM_CHANNEL_1, duty)</code></strong>：呼吸的全部秘密——CCR 不是死的，<strong>运行中随时可改</strong>。这个宏把新 duty 写进 CCR1，本周期后半段 Compare 事件就按新门槛触发。</li>
 <li><strong><code>if (duty == 0)</code> 特判</strong>：duty=0 时 CCR1=0，Compare 事件和 Update 在 CNT=0 <strong>同一瞬间</strong>爆发，而 HAL 先处理 Compare 后处理 Update——不特判，0% 会变成常亮（阶段二调试时那个"常亮 bug"的缩小版）。所以 0% 时 Update 回调里直接写灭，不指望 Compare。</li>
 <li><strong><code>else</code> 分支先亮</strong>：&gt;0% 时周期起点先点亮，熄灭的活儿留给 Compare 事件在"数到 duty"处干——亮多久完全由 duty 说了算。</li>
 <li><strong>Compare 回调</strong>：与阶段二一字未改——这就是分层的红利，载波机器不用动，只换包络逻辑。</li>
</ul>
<h3 style="" id="%E7%8E%B0%E8%B1%A1%E4%B8%8E%E6%9C%AC%E8%B4%A8">现象与本质</h3>
<ul>
 <li><strong>PB5 呼吸灯</strong>：约 2 秒一个渐亮渐暗循环，亮度平滑变化</li>
 <li><strong>PB0 硬件 PWM</strong>：1Hz、50% 方波（调试器实时采样引脚电平验证：6 次采样 0/1 完美交替，PSC/ARR/CCR 寄存器读数 7199/9999/5000 与计算一致）</li>
</ul>
<hr>
<h2 style="" id="%E4%B8%89%E9%98%B6%E6%AE%B5%E6%BC%94%E8%BF%9B%E6%80%BB%E8%A7%88">三阶段演进总览</h2>
<table>
 <thead>
  <tr>
   <th></th>
   <th>阶段一（时基）</th>
   <th>阶段二（输出比较）</th>
   <th>阶段三（PWM 呼吸灯）</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>事件源</td>
   <td>Update × 1</td>
   <td>Update + Compare</td>
   <td>同阶段二 + 动态改 CCR</td>
  </tr>
  <tr>
   <td>回调里干的事</td>
   <td>数 500 个 ms</td>
   <td>直接写引脚，零计数</td>
   <td>推坡度 + 写引脚</td>
  </tr>
  <tr>
   <td>中断频率</td>
   <td>1000 次/秒</td>
   <td>2 次/秒</td>
   <td>200 次/秒（载波）+ 0（硬件 PWM）</td>
  </tr>
  <tr>
   <td>定时精度</td>
   <td>受中断延迟影响</td>
   <td>硬件门槛，精准到格</td>
   <td>同左，占空比运行中可调</td>
  </tr>
  <tr>
   <td>本质</td>
   <td>硬件数周期，软件数精细</td>
   <td>硬件记两个时间点</td>
   <td>载波/包络分层，CPU 只管节奏</td>
  </tr>
 </tbody>
</table>
<p><strong>一句话总结整个模块</strong>：定时器的学习史就是一部"移交史"——把"等"从 CPU 移交给硬件（阶段一），把"数"移交给门槛（阶段二），把"开关引脚"移交给比较器（阶段三的硬件 PWM）；CPU 最后只剩一件体面的活：决定呼吸的节奏。</p>]]></description><guid isPermaLink="false">/archives/stm32-timer-3stage-timbase-oc-pwm</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Thu, 24 Sep 2026 06:44:05 GMT</pubDate></item><item><title><![CDATA[串口协议帧设计]]></title><link>https://www.flor.cn/archives/uart-frame-protocol</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=%E4%B8%B2%E5%8F%A3%E5%8D%8F%E8%AE%AE%E5%B8%A7%E8%AE%BE%E8%AE%A1&amp;url=/archives/uart-frame-protocol" width="1" height="1" alt="" style="opacity:0;">
<blockquote>
 <p>平台：STM32F103ZET6 @ 72MHz · USART1（PA9=TX / PA10=RX）· 115200-8-N-1 · LED=PB5 低电平点亮 前置知识：USART 中断接收 + 环形缓冲区</p>
</blockquote>
<h2 style="" id="%E4%B8%80%E3%80%81%E8%A6%81%E8%A7%A3%E5%86%B3%E7%9A%84%E9%97%AE%E9%A2%98">一、要解决的问题</h2>
<p>printf 重定向解决了"怎么发"，中断接收 + 环形缓冲区解决了"怎么收"。但那两课搬运的都只是<strong>字节</strong>，而真实项目需要的是<strong>命令</strong>——PC 发一条指令，板子解析、执行、回确认。</p>
<p>核心矛盾在这里：<strong>串口是一条无边界字节流</strong>。PC 连续发来 12 个字节，串口本身不会告诉你这是"两条 6 字节命令"，还是"一条 12 字节命令"，还是"一堆垃圾 + 一条命令"。噪声、丢字节、粘包、劈包，在串口世界里都是家常便饭。</p>
<p>所以这个课题分两半：</p>
<ol>
 <li><strong>设计帧格式</strong>——给字节流加上结构和边界</li>
 <li><strong>写解析器</strong>——从字节流里可靠地"切"出完整帧，且坏帧不能影响好帧</li>
</ol>
<h2 style="" id="%E4%BA%8C%E3%80%81%E5%B8%A7%E6%A0%BC%E5%BC%8F%E8%AE%BE%E8%AE%A1">二、帧格式设计</h2>
<pre><code>┌──────┬──────┬──────┬─────────────┬──────────┐
│ 0xAA │ 0x55 │ len  │  data[len]  │  XOR CRC │
└──────┴──────┴──────┴─────────────┴──────────┘
 帧头1   帧头2   长度      数据域         校验
</code></pre>
<table>
 <thead>
  <tr>
   <th>字段</th>
   <th>长度</th>
   <th>作用</th>
   <th>设计理由</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>帧头 0xAA 0x55</td>
   <td>2 字节</td>
   <td>标记帧的起点</td>
   <td>双字节帧头，随机数据误命中概率降到 1/65536</td>
  </tr>
  <tr>
   <td>len</td>
   <td>1 字节</td>
   <td>数据域长度</td>
   <td><strong>断帧依据</strong>：收满 len 个就知道数据域结束了</td>
  </tr>
  <tr>
   <td>数据域</td>
   <td>len 字节</td>
   <td>命令码 + 参数</td>
   <td>业务载荷，本例 <code>[CMD][参数...]</code></td>
  </tr>
  <tr>
   <td>CRC</td>
   <td>1 字节</td>
   <td>异或校验</td>
   <td>检错（不是纠错），坏帧整帧丢弃</td>
  </tr>
 </tbody>
</table>
<p><strong>开灯帧逐字节拆解</strong>：<code>AA 55 02 01 01 02</code></p>
<ul>
 <li><code>AA 55</code>：帧头</li>
 <li><code>02</code>：数据域长度为 2</li>
 <li><code>01 01</code>：CMD=0x01（LED 控制），参数=1（亮）</li>
 <li><code>02</code>：校验 = <code>02 ^ 01 ^ 01</code>（<strong>长度字节也参与异或</strong>，这是收发双方的约定）</li>
</ul>
<h3 style="" id="%E4%B8%A4%E7%A7%8D%E6%96%AD%E5%B8%A7%E6%96%B9%E6%A1%88%E5%AF%B9%E6%AF%94">两种断帧方案对比</h3>
<table>
 <thead>
  <tr>
   <th>方案</th>
   <th>原理</th>
   <th>优缺点</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>长度断帧</strong>（本例）</td>
   <td>帧头后跟长度，收满即断</td>
   <td>不依赖发送方时序习惯，帧劈开也能拼回；代价是多一个长度字段</td>
  </tr>
  <tr>
   <td>IDLE 空闲断帧</td>
   <td>总线空闲（<code>HAL_UARTEx_ReceiveToIdle_IT</code>）即一帧结束</td>
   <td>省掉长度字段；但依赖"发送方一口气发完一帧"，劈包后无依据</td>
  </tr>
 </tbody>
</table>
<h2 style="" id="%E4%B8%89%E3%80%81%E8%BF%90%E8%A1%8C%E6%80%9D%E8%B7%AF%EF%BC%9A%E4%B8%80%E5%8F%B0%E4%B8%A4%E7%BA%A7%E8%8A%82%E5%A5%8F%E7%9A%84%E6%B5%81%E6%B0%B4%E7%BA%BF">三、运行思路：一台两级节奏的流水线</h2>
<p>整台机器是<strong>两条流水线 + 一个中转仓库</strong>：</p>
<pre><code>PC串口助手(HEX) ──AA 55 02 01 01 02──► CH340 ──► PA10(RX引脚)
                                              │ 每到 1 字节: RXNE→NVIC→中断
                                              ▼
           ┌── 中断上下文【生产者·快进快出】──────────┐
           │  RxCpltCallback:                          │
           │    rxbuf[rxHead]=字节; rxHead++;           │
           │    接力挂单(下一个字节继续中断收)            │
           └──────────────┬─────────────────────────────┘
                          ▼
                 〖环形缓冲区 rxbuf[128]〗←时间解耦的中转仓库
                          │
           ┌── 主循环【消费者·10ms 醒一次】─────────────┐
           │  rb_read() 批量取 → frame_parse()          │
           │  状态机: HDR1→HDR2→LEN→DATA→CRC            │
           └──────────────┬─────────────────────────────┘
                          ▼ CRC 通过(唯一合法入口)
                      frame_exec()
                     ┌─────┴──────┐
                     ▼            ▼
               PB5 拉低→LED亮   组ACK帧→HAL_UART_Transmit
                                   →PA9(TX)→CH340→PC显示
</code></pre>
<p>分层的本质是<strong>三种节奏的解耦</strong>：</p>
<ul>
 <li><strong>字节节奏（86.8µs/字节）</strong>：115200 波特率 8N1，每字节 10 位。这个节奏由硬件决定，用<strong>中断</strong>接——轮询要么丢字节要么烧 CPU</li>
 <li><strong>帧节奏（10ms）</strong>：主循环每 10ms 醒一次，把仓库里现有字节一次性搬空喂给解析器。这个值就是<strong>命令响应延迟上限</strong></li>
 <li><strong>中间的环形缓冲区</strong>：两种节奏差两个数量级，仓库负责削峰填谷。head 只有中断写、tail 只有主循环写——每个变量单一写者，加 volatile 就够，不用关中断</li>
</ul>
<p>为什么解析不放中断里做？<strong>中断必须快进快出</strong>——解析器最坏要跑完一整帧状态机，还会调用阻塞发送回 ACK，这些都不能在中断上下文里干。<strong>收字节的归中断，动脑子的归主循环。</strong></p>
<h2 style="" id="%E5%9B%9B%E3%80%81%E7%8A%B6%E6%80%81%E6%9C%BA%E8%A7%A3%E6%9E%90%E5%99%A8%EF%BC%9A%E4%BB%A3%E7%A0%81%E4%B8%8E%E9%80%90%E6%AE%B5%E8%AF%B4%E6%98%8E">四、状态机解析器：代码与逐段说明</h2>
<p>五个状态就是帧格式五个区段的镜像：</p>
<pre><code>  状态     等什么      成功去哪     失败去哪
  S_HDR1   0xAA        S_HDR2      原地不动（垃圾字节直接无视）
  S_HDR2   0x55        S_LEN       见AA留下（可能是真帧头），其他回S_HDR1
  S_LEN    1~32        S_DATA      非法长度回S_HDR1（这道闸防住 data[] 溢出）
  S_DATA   收满len个   S_CRC       无失败分支（来者不拒）
  S_CRC    校验字节    通过→执行   无论过不过都回S_HDR1（丢弃式恢复）
</code></pre>
<h3 style="" id="%E5%B8%A7%E6%89%A7%E8%A1%8C%EF%BC%9A%E5%90%88%E6%B3%95%E5%B8%A7%E7%9A%84%E5%94%AF%E4%B8%80%E5%87%BA%E5%8F%A3">帧执行：合法帧的唯一出口</h3>
<pre><code class="language-c">#define FRAME_HDR1 0xAA   // 帧头第1字节：在无边界的字节流里"认出"帧的起跑线
#define FRAME_HDR2 0x55   // 帧头第2字节：双字节帧头，大幅降低随机数据误同步的概率
#define FRAME_MAX  32     // 数据域最大长度：决定 data[] 大小和长度字节的合法上限

// 谁能调用我：只有 frame_parse 的 S_CRC 分支（五关全过才轮到我）
// 参数：p=数据域首地址  len=数据域长度
// 职责：解释数据域含义并执行 + 组确认帧回传（闭环）
void frame_exec(uint8_t *p, uint8_t len)
{
    // 数据域约定格式：[命令码][参数...]
    // len&gt;=2：至少要有"命令码+参数"两个字节，命令才有意义
    if (len &gt;= 2 &amp;&amp; p[0] == 0x01) {              // CMD=0x01：LED 控制
        // 板子 LED 共阳接法（3.3V→LED→510Ω→PB5），低电平才亮
        // 参数非0 → 写 RESET(低电平) 点亮；参数0 → 写 SET(高电平) 熄灭
        HAL_GPIO_WritePin(led1_GPIO_Port, led1_Pin,
                          p[1] ? GPIO_PIN_RESET : GPIO_PIN_SET);

        // ---- 组确认帧（ACK）：把收到的帧原样回传 ----
        // 发送方收到一模一样的帧 = "命令已落地"的闭环确认
        uint8_t ack[4 + FRAME_MAX];              // 栈上缓冲：4+32=36字节，装得下最长合法帧
        ack[0] = FRAME_HDR1;                     // 帧头1
        ack[1] = FRAME_HDR2;                     // 帧头2
        ack[2] = len;                            // 长度
        uint8_t crc = len;                       // 校验初值=长度字节（与发送方的约定）
        for (uint8_t i = 0; i &lt; len; i++) {
            ack[3 + i] = p[i];                   // 数据域原样拷贝
            crc ^= p[i];                         // 边拷边异或，一趟干两件事
        }
        ack[3 + len] = crc;                      // 校验字节收尾

        // 阻塞式发送：这是主循环上下文（不是中断），等得起
        // 6字节@115200 约0.5ms发完，100ms 超时只是保险丝
        HAL_UART_Transmit(&amp;huart1, ack, 4 + len, 100);
    }
    // 其他命令码：静默忽略（帧是合法的，只是本机不认识这个命令）
}
</code></pre>
<h3 style="" id="%E7%8A%B6%E6%80%81%E6%9C%BA%E4%B8%BB%E4%BD%93">状态机主体</h3>
<pre><code class="language-c">void frame_parse(uint8_t *buf, uint16_t n)
{
    typedef enum { S_HDR1, S_HDR2, S_LEN, S_DATA, S_CRC } PState;
    static PState st = S_HDR1;      // static：跨调用保留 → 帧劈成两半也能拼回
    static uint8_t data[FRAME_MAX]; // static：半成品帧的暂存区（每次清零就拼不回了）
    static uint8_t idx = 0;         // 已收数据字节数
    static uint8_t len = 0;         // 本帧数据域长度
    static uint8_t crc = 0;         // 边收边算的校验值

    for (uint16_t i = 0; i &lt; n; i++) {         // 这批字节逐个喂进状态机
        uint8_t b = buf[i];
        switch (st) {

        case S_HDR1:                            // 状态1：扫帧头第1字节
            if (b == FRAME_HDR1) st = S_HDR2;   // 见AA → 前进一步
            // 不是AA：原地不动（重新同步的第1道闸）
            break;

        case S_HDR2:                            // 状态2：等帧头第2字节
            if (b == FRAME_HDR2)      st = S_LEN;  // AA 55 → 帧头成立
            else if (b == FRAME_HDR1) st = S_HDR2; // AA AA：这个AA可能是真帧头，
                                                   // 留在原地再等一个55
            else                      st = S_HDR1; // 前面那个AA是假帧头，回炉重扫
            break;

        case S_LEN:                             // 状态3：长度字节
            if (b &gt; 0 &amp;&amp; b &lt;= FRAME_MAX) {      // 合法区间 1~32（data[] 装得下的范围）
                len = b;                       // 记住本帧数据域长度
                idx = 0;                       // 新帧新起点：计数器清零
                crc = b;                       // 校验初值=长度字节（约定）
                st = S_DATA;
            }
            else st = S_HDR1;                   // 0或&gt;32 → 非法，丢弃重新同步
            break;

        case S_DATA:                            // 状态4：收数据域
            data[idx++] = b;                    // 存入半成品区，计数+1
            crc ^= b;                           // 边收边异或，收完校验值也算完
            if (idx == len) st = S_CRC;          // 收满 → 去校验关
            break;

        case S_CRC:                             // 状态5：对校验字节
            if (crc == b) frame_exec(data, len);// 算出的==送来的 → 合法帧，执行！
            st = S_HDR1;                        // 无论过不过都回起点：坏帧不回ACK，
            break;                              // 发送方超时重发即可（丢弃式恢复）
        }
    }
}
</code></pre>
<h3 style="" id="%E4%B8%BB%E5%BE%AA%E7%8E%AF%EF%BC%88%E6%B6%88%E8%B4%B9%E8%80%85%E4%BE%A7%EF%BC%89">主循环（消费者侧）</h3>
<pre><code class="language-c">uint8_t tmp[RXBUF_SIZE];                    // 主循环的提货筐：一次最多搬128字节
while (1)
{
    uint16_t n = rb_read(tmp, sizeof(tmp));  // 消费者：把环形缓冲区现有字节一次搬空
    if (n) frame_parse(tmp, n);             // 有货就整批喂给状态机
    HAL_Delay(10);                          // 10ms轮询节奏 = 命令响应延迟上限
}
</code></pre>
<h3 style="" id="%E4%B8%89%E4%B8%AA%E5%85%B3%E9%94%AE%E6%9C%BA%E5%88%B6">三个关键机制</h3>
<ol>
 <li><strong>static 跨调用记忆</strong>：一帧 6 字节可能这批来 3 个、下批来 3 个（发送方分两次发、或主循环轮询切在中间）。状态机的进度存在 static 变量里，函数返回也不丢——帧劈开也能拼回。这是它和"一次性处理完整帧"思路的本质区别</li>
 <li><strong>AA AA 55 分支</strong>：收到 <code>AA AA 55</code> 时，第一个 AA 之后等来的是又一个 AA——它可能是真帧头。所以留在 S_HDR2 原地再等一个 55，而不是回 S_HDR1。没有这个分支，<code>AA AA 55 ...</code> 开头的帧会丢</li>
 <li><strong>丢弃式错误恢复</strong>：CRC 不过，整帧丢弃、回 S_HDR1、不回任何东西。实现最简，且坏帧不回 ACK 本身就是信号——发送方超时没等到确认，重发即可</li>
</ol>
<h2 style="" id="%E4%BA%94%E3%80%81%E9%AA%8C%E6%94%B6%E4%B8%8E%E8%B8%A9%E5%9D%91">五、验收与踩坑</h2>
<p>验收五连（串口助手 HEX 模式）：①开灯 <code>AA 55 02 01 01 02</code>→灯亮+回同帧；②关灯 <code>AA 55 02 01 00 03</code>→灯灭+回同帧；③坏CRC <code>AA 55 02 01 01 99</code>→彻底沉默；④垃圾前缀 <code>12 34 56</code>+好帧→重新同步成功；⑤双AA <code>AA AA 55 02 01 01 02</code>→第二个AA当真帧头。①②验常规通路，③④⑤专考状态机的防御分支。</p>
<p>实战踩过两个坑，都值得记一辈子：</p>
<ul>
 <li><strong><code>HAL_Delay</code> 单位是毫秒</strong>：把 10ms 写成 <code>HAL_Delay(10000)</code>，编译零警告、逻辑全对，但命令响应延迟变成 0~10 秒随机。这类 bug 最阴险：<strong>不报错，只让系统慢得莫名其妙</strong></li>
 <li><strong>长度判断 off-by-one</strong>：<code>b &lt; FRAME_MAX</code> 会把 len=32 的满帧也拒掉（<code>data[32]</code> 下标 0~31，len=32 完全合法），正确写法 <code>b &lt;= FRAME_MAX</code>。教训：<strong>边界值必须专门测</strong>——len=0、1、31、32、33 各来一发</li>
</ul>
<p>另一个强烈推荐的手法：<strong>主机侧仿真测试</strong>——上真机前把解析器原样搬到 PC，HAL 调用换成记录桩（<code>HAL_GPIO_WritePin</code> 写个 led 变量、<code>HAL_UART_Transmit</code> 把字节拷进内存），本机 gcc 毫秒级跑完全部测试向量。不碰硬件、可回归、出错定位在逻辑层而不是"板子抽风"。</p>
<h2 style="" id="%E5%85%AD%E3%80%81%E8%A6%81%E7%82%B9%E9%80%9F%E8%AE%B0">六、要点速记</h2>
<ul>
 <li>串口是无边界字节流，协议帧 = 自己造边界（帧头+长度+校验）</li>
 <li>双字节帧头把误同步概率降到 1/65536；长度断帧不依赖发送方时序</li>
 <li>状态机五状态 = 帧格式五区段的镜像；<code>static</code> 让帧劈开也能拼回</li>
 <li><code>AA AA 55</code>：S_HDR2 见 AA 留原地；长度闸门（1~32）防数组溢出</li>
 <li>丢弃式恢复：坏帧不回 ACK，靠发送方超时重发；ACK 原样回传 = 闭环确认</li>
 <li>收字节的归中断（快进快出），动脑子的归主循环；环形缓冲区是两种节奏之间的仓库</li>
 <li>10ms 轮询 = 命令响应延迟上限；<code>HAL_Delay</code> 单位是毫秒</li>
 <li>上真机前，主机侧仿真先跑一遍测试向量</li>
</ul>]]></description><guid isPermaLink="false">/archives/uart-frame-protocol</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Mon, 21 Sep 2026 03:35:28 GMT</pubDate></item><item><title><![CDATA[数制与逻辑门速查]]></title><link>https://www.flor.cn/archives/digital-logic-quickref</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=%E6%95%B0%E5%88%B6%E4%B8%8E%E9%80%BB%E8%BE%91%E9%97%A8%E9%80%9F%E6%9F%A5&amp;url=/archives/digital-logic-quickref" width="1" height="1" alt="" style="opacity:0;">
<blockquote>
 <p>内容来源：数电教材第一章 + STC8A8K64D4 手册，全部图表重绘 用途：数制与逻辑门速查卡片</p>
</blockquote>
<h2 style="" id="%E4%B8%80%E3%80%81%E6%95%B0%E5%88%B6%E5%AF%B9%E7%85%A7%E8%A1%A8">一、数制对照表</h2>
<p><img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fwww.flor.cn%2Fupload%2Fdata%2Fphoto%2Fshuzhi-table.png&amp;size=m" style="max-width:100%"></p>
<p>三个要点：</p>
<ul>
 <li><strong>十六进制是二进制的速记法</strong>：16 = 2⁴，一位十六进制恰好对应四位二进制——8/16/32 位二进制整齐地写成 2/4/8 位十六进制，书写程序十分简便</li>
 <li><strong>两个关键行</strong>：十进制 8 → 八进制 10（逢八进位，没有 8 这个数字）；十进制 10 → 十六进制 A（从 10 开始用字母 A~F）</li>
 <li><strong>最大值公式</strong>：n 位 b 进制的最大值 = bⁿ − 1。1111B = 15、7777O = 4095、FFFFH = 65535——嵌入式里 0xFF（8位满量程）、0xFFF（12位ADC满量程）、0xFFFF（16位上限）全是这一族</li>
</ul>
<h2 style="" id="%E4%BA%8C%E3%80%81%E5%A5%87%E5%81%B6%E6%A0%A1%E9%AA%8C%E7%A0%81">二、奇偶校验码</h2>
<p>数据在存取、运算和传送过程中难免出错（1 错成 0、0 错成 1）。奇偶校验码 = 信息位 + 1 位校验位，让整个码字中 1 的个数凑成奇数（奇校验）或偶数（偶校验）。</p>
<ul>
 <li><strong>能抓</strong>：任何 1 位翻转（1 的个数 ±1，奇偶性破坏立刻露馅）</li>
 <li><strong>抓不住</strong>：同时翻 2 位（个数 ±2 或不变，奇偶性完好——天生盲区）</li>
</ul>
<p>嵌入式对应：串口配置里的 <code>UART_PARITY_EVEN / ODD / NONE</code> 就是它。代价是每字节多 1 位（12.5% 开销），且只能发现不能纠正，所以正经链路升级用 CRC。</p>
<h2 style="" id="%E4%B8%89%E3%80%81%E4%B8%89%E7%A7%8D%E5%9F%BA%E6%9C%AC%E9%80%BB%E8%BE%91%E8%BF%90%E7%AE%97">三、三种基本逻辑运算</h2>
<p><img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fwww.flor.cn%2Fupload%2Fdata%2Fphoto%2Flogic-basic-gates.png&amp;size=m" style="max-width:100%"></p>
<p>真值表不用死记：与 = 乘法（0 乘啥都 0）、或 = 加法（但 1+1=1）、非 = 取反。</p>
<p>对应 C 语言的位操作：</p>
<ul>
 <li>置位：<code>P1 |= 0x10</code>（或）</li>
 <li>清位：<code>P1 &amp;= ~0x20</code>（与 + 非）</li>
 <li>取反：<code>P1 = ~P1</code>（非）</li>
</ul>
<h2 style="" id="%E5%9B%9B%E3%80%81%E7%BB%84%E5%90%88%E9%97%A8%EF%BC%9A%E4%B8%8E%E9%9D%9E%E3%80%81%E6%88%96%E9%9D%9E%E3%80%81%E4%B8%8E%E6%88%96%E9%9D%9E">四、组合门：与非、或非、与或非</h2>
<p><img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fwww.flor.cn%2Fupload%2Fdata%2Fphoto%2Flogic-composite-gates.png&amp;size=m" style="max-width:100%"></p>
<ul>
 <li><strong>与非门是万能门</strong>：只用与非门一种就能拼出全部逻辑（非 = 与非(A,A)；或 = 与非(A′,B′)）。74LS00（四与非门）曾是地球上产量最大的芯片，NAND Flash 之名即源于此</li>
 <li><strong>德摩根律</strong>：(A·B)′ = A′ + B′——与非门输出端的小圆圈挪到输入端就变成或门。圆圈可以在输入/输出之间"漂移"，这是看懂和化简逻辑电路图的核心技巧</li>
 <li><strong>与或非</strong>：Y = (A·B + C·D)′，16 行真值表仅 7 行为 0（AB=11 或 CD=11 时）。典型用途：地址译码、比较器（74HC51）</li>
</ul>
<h2 style="" id="%E4%BA%94%E3%80%81%E5%BC%82%E6%88%96%E4%B8%8E%E5%90%8C%E6%88%96">五、异或与同或</h2>
<p><img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=https%3A%2F%2Fwww.flor.cn%2Fupload%2Fdata%2Fphoto%2Fxor-xnor.png&amp;size=m" style="max-width:100%"></p>
<p>异或（不同为 1）是比较器，同或（相同为 1）是相等判断器，两者互为取反。</p>
<p>异或的四个神技（嵌入式天天用）：</p>
<ol>
 <li><strong>翻转位</strong>：<code>P1 ^= 0x01</code>——按一下翻一次</li>
 <li><strong>求奇偶</strong>：把数据位全部异或 = 偶校验位（奇偶校验本质上就是异或数奇偶）</li>
 <li><strong>免临时变量交换</strong>：<code>a^=b; b^=a; a^=b;</code>（因为 A⊕A=0、A⊕0=A）</li>
 <li><strong>半加器</strong>：A⊕B = 不带进位的和，A·B = 进位——CPU 加法器的地基</li>
</ol>
<p>状态监测经典用法：<code>changed = new ^ old</code>——一行异或，变了的位全亮出来。</p>
<h2 style="" id="%E5%85%AD%E3%80%81%E4%B8%80%E9%A1%B5%E6%80%BB%E8%A7%88">六、一页总览</h2>
<table>
 <thead>
  <tr>
   <th>运算</th>
   <th>公式</th>
   <th>口诀</th>
   <th>C 语言</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>与</td>
   <td>Y = A·B</td>
   <td>全 1 才 1</td>
   <td><code>a &amp; b</code></td>
  </tr>
  <tr>
   <td>或</td>
   <td>Y = A+B</td>
   <td>有 1 就 1</td>
   <td><code>a \| b</code></td>
  </tr>
  <tr>
   <td>非</td>
   <td>Y = A′</td>
   <td>0 变 1</td>
   <td><code>~a</code></td>
  </tr>
  <tr>
   <td>与非</td>
   <td>Y = (A·B)′</td>
   <td>有 0 出 1，全 1 才 0</td>
   <td><code>!(a &amp; b)</code></td>
  </tr>
  <tr>
   <td>或非</td>
   <td>Y = (A+B)′</td>
   <td>全 0 出 1，有 1 就 0</td>
   <td><code>!(a \| b)</code></td>
  </tr>
  <tr>
   <td>与或非</td>
   <td>Y = (A·B+C·D)′</td>
   <td>任一组全 1 则 0</td>
   <td><code>!((a&amp;&amp;b)\|\|(c&amp;&amp;d))</code></td>
  </tr>
  <tr>
   <td>异或</td>
   <td>Y = A⊕B</td>
   <td>不同为 1</td>
   <td><code>a ^ b</code></td>
  </tr>
  <tr>
   <td>同或</td>
   <td>Y = A⊙B</td>
   <td>相同为 1</td>
   <td><code>!(a ^ b)</code></td>
  </tr>
 </tbody>
</table>
<p><strong>一句话收尾：与或非是地基，与非是万能砖，异或是比较器——八种运算，一张表带走。</strong></p>]]></description><guid isPermaLink="false">/archives/digital-logic-quickref</guid><dc:creator>flor</dc:creator><category>数电</category><pubDate>Sat, 19 Sep 2026 09:48:35 GMT</pubDate></item><item><title><![CDATA[环形缓冲区详解]]></title><link>https://www.flor.cn/archives/embedded-ring-buffer</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=%E7%8E%AF%E5%BD%A2%E7%BC%93%E5%86%B2%E5%8C%BA%E8%AF%A6%E8%A7%A3&amp;url=/archives/embedded-ring-buffer" width="1" height="1" alt="" style="opacity:0;">
<blockquote>
 <p style="">适用场景：串口/传感器/键盘等"数据碎片化到达"场景的裸机开发 阅读基础：了解 C 语言、中断的基本概念</p>
</blockquote>
<h2 style="" id="0.-%E5%BC%95%E5%AD%90%EF%BC%9A%E4%B8%A4%E4%B8%AA%E4%B8%96%E7%95%8C%E7%9A%84%E8%8A%82%E5%A5%8F%E5%B7%AE">0. 引子：两个世界的节奏差</h2>
<p style="">串口中断接收有一个根本矛盾：</p>
<ul>
 <li>
  <p style=""><strong>中断世界</strong>：字节什么时候来，由对方（发送方）决定——随时可能来，也可能一秒钟来一百个</p>
 </li>
 <li>
  <p style=""><strong>主循环世界</strong>：什么时候有空处理，由程序逻辑决定——可能正在刷屏、算滤波、睡觉</p>
 </li>
</ul>
<p style="">115200 波特率下每个字节间隔约 87µs，72MHz 的 CPU 在这 87µs 里能执行约 6000 条指令。如果让 CPU 死盯串口（轮询），这 6000 条指令全浪费；如果主循环去干别的，字节来了没人管，下一个字节一到就把上一个挤丢（硬件溢出 ORE）。</p>
<p style=""><strong>环形缓冲区就是两个世界的"时差消化器"</strong>：中断是快递员（只管把包裹塞进快递柜，几微秒走人），主循环是收件人（有空时一次性取走所有包裹慢慢拆），缓冲区是快递柜，丢包计数器是登记簿</p>
<h2 style="" id="1.-%E4%B8%BA%E4%BB%80%E4%B9%88%E6%98%AF%22%E7%8E%AF%E5%BD%A2%22">1. 为什么是"环形"</h2>
<p style="">普通数组当缓冲区有两条死路：</p>
<ul>
 <li>
  <p style="">写到头就停 → 空间浪费一半</p>
 </li>
 <li>
  <p style="">写到头搬回开头 → 每次搬移 O(n)，CPU 干无用功</p>
 </li>
</ul>
<p style="">环形的答案：<strong>两个下标各自前进、互不等待，数组用完就绕回开头</strong>——一条首尾相接的传送带：</p>
<pre><code>        ┌──▶ [0][1][2][3][4][5][6][7] ──┐
        │      ↑tail          head↑     │
        └────────── 绕回开头 ─────────────┘
</code></pre>
<ul>
 <li>
  <p style=""><code>rxHead</code>（写下标）：数据往哪存，只有生产者（中断）动它</p>
 </li>
 <li>
  <p style=""><code>rxTail</code>（读下标）：数据从哪取，只有消费者（主循环）动它</p>
 </li>
 <li>
  <p style="">存一个字节 head 前进一步，取一个字节 tail 前进一步，追上对方就停</p>
 </li>
</ul>
<h2 style="" id="2.-%E4%B8%89%E4%BB%B6%E5%A5%97%E4%B8%8E%E4%B8%89%E4%B8%AA%E7%B2%BE%E5%A6%99%E8%AE%BE%E8%AE%A1">2. 三件套与三个精妙设计</h2>
<h3 style="" id="2.0-%E6%95%B0%E6%8D%AE%E4%B8%8E%E4%B8%8B%E6%A0%87%E5%AE%9A%E4%B9%89">2.0 数据与下标定义</h3>
<pre><code class="language-c">#define RXBUF_SIZE 128                     // 2 的幂：可用 &amp; (RXBUF_SIZE-1) 代替 % 取模（单周期）
uint8_t  rxbuf[RXBUF_SIZE];                // 存储区
volatile uint16_t rxHead = 0;              // 写下标：只有中断动它
volatile uint16_t rxTail = 0;              // 读下标：只有主循环动它
volatile uint32_t rxErr = 0;               // 丢包账本：满了没挤进来的字节数
</code></pre>
<h3 style="" id="2.1-%E8%AE%BE%E8%AE%A1%E4%B8%80%EF%BC%9A%E7%89%BA%E7%89%B2%E4%B8%80%E6%A0%BC%E2%80%94%E2%80%94%E5%8C%BA%E5%88%86%22%E6%BB%A1%22%E5%92%8C%22%E7%A9%BA%22">2.1 设计一：牺牲一格——区分"满"和"空"</h3>
<p style="">最坑的问题：<code>head == tail</code> 到底是"空"还是"满"？两种状态长得一模一样！</p>
<p style="">解法：<strong>宁可少用一个格子</strong>——留一格空作为"满"的标志：</p>
<ul>
 <li>
  <p style="">空：<code>rxHead == rxTail</code>（两者重合）</p>
 </li>
 <li>
  <p style="">满：<code>(rxHead + 1) % RXBUF_SIZE == rxTail</code>（还差一格重合）</p>
 </li>
</ul>
<p style="">128 格的缓冲区有效容量是 127。用一格容量换一个无歧义的状态判断，值。</p>
<h3 style="" id="2.2-%E8%AE%BE%E8%AE%A1%E4%BA%8C%EF%BC%9Acount-%E7%94%A8%E5%85%AC%E5%BC%8F%E7%AE%97%E2%80%94%E2%80%94%E6%B4%BE%E7%94%9F%E9%87%8F%E4%B8%8D%E5%8F%A6%E5%AD%98">2.2 设计二：count 用公式算——派生量不另存</h3>
<pre><code class="language-c">// 当前存量字节数
uint16_t rb_count(void)
{
    return (rxHead - rxTail + RXBUF_SIZE) % RXBUF_SIZE;
}
</code></pre>
<p style="">两个细节：</p>
<p style=""><strong>为什么要 </strong><code>+RXBUF_SIZE</code><strong> 再取模？</strong> 环形回绕后 head 可能数值上小于 tail（比如 head=5、tail=120），直接相减得 -115；加上 128 再取模得 13，才是真实存量。C 语言负数取模还是负数，这一步是环形计算的标配。</p>
<p style=""><strong>为什么不用 count 变量存着、每次加减？</strong> 因为 count 会被两边（生产/消费）同时维护——两个写者 = 竞态 = 会漂移的死账本。<strong>能从现有状态推导出来的量，永远不要另存一份</strong>（单一事实来源原则）。</p>
<h3 style="" id="2.3-%E8%AE%BE%E8%AE%A1%E4%B8%89%EF%BC%9Avolatile-%2B-%E5%8D%95%E4%B8%80%E5%86%99%E8%80%85%E2%80%94%E2%80%94%E4%B8%8D%E5%8A%A0%E9%94%81%E7%9A%84%E7%A7%98%E5%AF%86">2.3 设计三：volatile + 单一写者——不加锁的秘密</h3>
<div class="halo-table-wrapper" data-table-layout="auto" style="box-sizing: border-box; overflow-x: auto; overflow-y: hidden; width: 100%; max-width: 100%; min-width: 0;">
 <table data-table-layout="auto" style="display: table; width: 100%; min-width: 100%; table-layout: auto;">
  <tbody>
   <tr>
    <th colspan="1" rowspan="1">
     <p style="">变量</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">谁写</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">谁读</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">为什么安全</p>
    </th>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">rxHead</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">只有中断写</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">主循环读</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">一个写者，永远不打架</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">rxTail</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">只有主循环写</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">中断读（判满时比较）</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">同上</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">rxErr</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">只有中断写</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">主循环读</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">同上</p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<p style="">两层保护各管一件事：</p>
<ul>
 <li>
  <p style=""><strong>volatile 管"看得见"</strong>：禁止编译器把读优化成寄存器缓存。没有它，主循环可能永远读不到中断更新后的值——嵌入式三大玄学 bug 之一（症状：加 printf 就好了、开 -O0 就好了）</p>
 </li>
 <li>
  <p style=""><strong>归属权管"不打架"</strong>：每个下标只有一个写者，不存在写冲突</p>
 </li>
</ul>
<p style="">这就是这套代码<strong>全程不用关中断</strong>的原因。</p>
<h2 style="" id="3.-%E5%AE%8C%E6%95%B4%E6%9E%B6%E6%9E%84%E4%B8%8E%E4%BB%A3%E7%A0%81">3. 完整架构与代码</h2>
<h3 style="" id="3.1-%E6%95%B0%E6%8D%AE%E6%B5%81%E5%85%A8%E6%99%AF">3.1 数据流全景</h3>
<pre><code>【生产者：中断世界】               【缓冲区】              【消费者：主循环世界】
电脑串口发字节                     ┌────────────┐        while(1)
  → RX 引脚 → 移位寄存器 → DR      │  rxbuf[128] │          ├─ rb_read(tmp,128) 批量取
  → RXNE=1 → 中断                  │  rxHead 写下标│◄─中断写  ├─ 逐字节处理（回显/解析）
  → HAL 读 DR → 回调               │  rxTail 读下标│◄─主循环写 ├─ 报账（buf/err）
    ├─ 有空位：存 + head++        │  rxErr 总账本 │          └─ HAL_Delay(10)
    ├─ 满了：rxErr++（丢新）      └────────────┘
    └─ 挂下一单 Receive_IT
  → 中断退出（全程几微秒）
</code></pre>
<h3 style="" id="3.2-%E4%B8%AD%E6%96%AD%E4%BE%A7%EF%BC%9A%E5%9B%9E%E8%B0%83%E4%B8%89%E4%BB%B6%E4%BA%8B%EF%BC%88%E5%AD%98%E3%80%81%E8%AE%B0%E3%80%81%E6%8C%82%EF%BC%89">3.2 中断侧：回调三件事（存、记、挂）</h3>
<pre><code class="language-c">uint8_t rxbyte;   // HAL 的落地字节

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart-&gt;Instance == USART1) {
        uint16_t next = (rxHead + 1) % RXBUF_SIZE;
        if (next != rxTail) {                      // 没满：写入
            rxbuf[rxHead] = rxbyte;
            rxHead = next;
        } else {                                   // 满了：进不来（丢新策略），记一笔账
            rxErr++;
        }
        HAL_UART_Receive_IT(huart, &amp;rxbyte, 1);    // 挂下一单（不断链）
    }
}
</code></pre>
<p style="">三个细节：</p>
<ul>
 <li>
  <p style=""><strong>挂单不能忘</strong>：<code>HAL_UART_Receive_IT</code> 是"一单制"——收完这单就结束，回调里不续挂，链路就断（症状：只响应第一个字节，之后任你怎么发都装死）</p>
 </li>
 <li>
  <p style=""><strong>中断快进快出</strong>：三件事加起来几微秒，这是中断的铁律</p>
 </li>
 <li>
  <p style=""><strong>rxErr 记账</strong>：丢可以（策略决定），但必须数出来——无声丢数据是最恶劣的 bug</p>
 </li>
</ul>
<h3 style="" id="3.3-%E6%B6%88%E8%B4%B9%E8%80%85%E4%BE%A7%EF%BC%9A%E6%89%B9%E9%87%8F%E8%AF%BB-%2B-%E6%8E%92%E7%A9%BA%E5%BC%8F%E6%B6%88%E8%B4%B9">3.3 消费者侧：批量读 + 排空式消费</h3>
<pre><code class="language-c">// 批量读：最多取 n 个字节放进 buf，返回实际取到的个数（0 = 空）
uint16_t rb_read(uint8_t *buf, uint16_t n)
{
    uint16_t avail = (rxHead - rxTail + RXBUF_SIZE) % RXBUF_SIZE;
    if (n &gt; avail) n = avail;              // 要 100 只有 30 就只给 30，不越界
    for (uint16_t i = 0; i &lt; n; i++) {
        buf[i] = rxbuf[rxTail];
        rxTail = (rxTail + 1) % RXBUF_SIZE;
    }
    return n;
}

// 主循环
uint8_t tmp[RXBUF_SIZE];
while (1)
{
    uint16_t n = rb_read(tmp, sizeof(tmp));    // 排空式：有多少取多少
    for (uint16_t i = 0; i &lt; n; i++) {
        // 逐字节处理（回显 / 喂协议解析器 / 执行命令）
    }
    HAL_Delay(10);                            // 轮询周期 = 响应延迟上限
}
</code></pre>
<p style=""><strong>为什么必须批量读</strong>：如果主循环每次只取 1 个字节就去睡觉，积压 8 个字节要 8 个周期才消费完（睡 2 秒的话要 16 秒）。批量取让"消费速率"和"到达速率"彻底解耦——睡觉时中断照样收货，醒来一口气处理完。</p>
<h2 style="" id="4.-%E4%B8%A2%E5%8C%85%E7%AD%96%E7%95%A5%EF%BC%9A%E4%B8%A2%E6%96%B0-vs-%E4%B8%A2%E6%97%A7">4. 丢包策略：丢新 vs 丢旧</h2>
<p style="">缓冲区满了怎么办？两种相反的策略：</p>
<div class="halo-table-wrapper" data-table-layout="auto" style="box-sizing: border-box; overflow-x: auto; overflow-y: hidden; width: 100%; max-width: 100%; min-width: 0;">
 <table data-table-layout="auto" style="display: table; width: 100%; min-width: 100%; table-layout: auto;">
  <tbody>
   <tr>
    <th colspan="1" rowspan="1">
     <p style=""></p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">丢新（本文主线）</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">丢旧（进阶变体）</p>
    </th>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">行为</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">新字节不写入</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">挤掉最老字节，新字节一定写入</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">实现代价</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style=""><strong>零</strong>（写方只动 head）</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">rxTail 变成两边都写 → 读方更新下标必须关中断</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">适用场景</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">协议帧（帧断了两种都救不了，正解是容量 ≥ 最大帧长）</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">命令流/遥控器（连按 50 下，要的是第 50 下的值）</p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<p style="">丢旧的实现与代价：</p>
<pre><code class="language-c">// 进阶变体：丢旧不丢新
void rb_put_old(uint8_t c)                    // 中断里调用
{
    uint16_t next = (rxHead + 1) % RXBUF_SIZE;
    if (next == rxTail) {                     // 满了
        rxTail = (rxTail + 1) % RXBUF_SIZE;   // 中断里挤掉最老的（中断改 rxTail 不会被主循环插队，安全）
        rxErr++;
    }
    rxbuf[rxHead] = c;                        // 无论如何都写入
    rxHead = next;
}

int rb_get_old(void)                          // 主循环里调用
{
    if (rxTail == rxHead) return -1;
    uint8_t c = rxbuf[rxTail];
    __disable_irq();                          // ★ 丢旧策略的代价：读方更新 rxTail 要关中断
    rxTail = (rxTail + 1) % RXBUF_SIZE;       //   否则会被中断的"挤掉"动作覆盖掉
    __enable_irq();
    return c;
}
</code></pre>
<p style=""><strong>选型口诀：命令流要最新 → 丢旧；协议帧 → 加大缓冲区，靠 err 发现不够用。</strong></p>
<h2 style="" id="5.-%E9%94%99%E8%AF%AF%E8%B4%A6%E6%9C%AC%EF%BC%9A%E5%8F%AF%E8%A7%82%E6%B5%8B%E6%80%A7">5. 错误账本：可观测性</h2>
<p style="">丢包有两种来源，都要记账：</p>
<div class="halo-table-wrapper" data-table-layout="auto" style="box-sizing: border-box; overflow-x: auto; overflow-y: hidden; width: 100%; max-width: 100%; min-width: 0;">
 <table data-table-layout="auto" style="display: table; width: 100%; min-width: 100%; table-layout: auto;">
  <tbody>
   <tr>
    <th colspan="1" rowspan="1">
     <p style="">记账位置</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">丢的原因</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">性质</p>
    </th>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">RxCpltCallback 里 rxErr++</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">缓冲区满，装不下</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style=""><strong>软件丢</strong>（自己的策略）</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">ErrorCallback 里 rxErr++</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">ORE 溢出 / 帧错误 / 噪声</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style=""><strong>硬件丢</strong>（字节没进 DR 或已损坏）</p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<pre><code class="language-c">// 硬件错误回调：溢出等错误发生时 HAL 会终止接收，这里记账 + 重新挂单自愈
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
    if (huart-&gt;Instance == USART1) {
        rxErr++;
        HAL_UART_Receive_IT(huart, &amp;rxbyte, 1);
    }
}
</code></pre>
<p style="">没有 ErrorCallback 的后果：一次溢出后 HAL 静默终止接收，串口从此"装死"，直到重新上电。</p>
<p style=""><strong>一个数字看链路健康度</strong>：err=0 一切正常；err 涨了，要么缓冲区不够大，要么中断里干活太慢。</p>
<h2 style="" id="6.-%E5%90%8C%E4%B8%80%E4%B8%AA%E5%8E%9F%E5%AD%90%E6%93%8D%E4%BD%9C%E7%9A%84%E4%B8%89%E5%BC%A0%E9%9D%A2%E5%AD%94">6. 同一个原子操作的三张面孔</h2>
<p style="">"取一个字节"的两行核心（取数 + 下标前移），包装成三种接口：</p>
<pre><code class="language-c">// 面孔一：取一个，空返回 -1（轮询式消费）
int rb_get(void) { /* 判空 → 取 → tail++ → 返回 */ }

// 面孔二：取一批，返回实际个数（排空式消费）
uint16_t rb_read(uint8_t *buf, uint16_t n) { /* ... */ }

// 面孔三：取一个，空就等（阻塞式，scanf/getchar 的入口）
int __io_getchar(void)
{
    while (rxTail == rxHead) { }         // 空：原地等（中断会把字节放进来）
    uint8_t c = rxbuf[rxTail];
    rxTail = (rxTail + 1) % RXBUF_SIZE;
    return c;
}
</code></pre>
<p style="">基础原子学扎实，三种包装随手就来。</p>
<h2 style="" id="7.-%E5%AE%9E%E9%AA%8C%E9%AA%8C%E8%AF%81%EF%BC%88%E7%8E%B0%E8%B1%A1%E5%8D%B3%E8%AF%81%E6%8D%AE%EF%BC%89">7. 实验验证（现象即证据）</h2>
<div class="halo-table-wrapper" data-table-layout="auto" style="box-sizing: border-box; overflow-x: auto; overflow-y: hidden; width: 100%; max-width: 100%; min-width: 0;">
 <table data-table-layout="auto" style="display: table; width: 100%; min-width: 100%; table-layout: auto;">
  <tbody>
   <tr>
    <th colspan="1" rowspan="1">
     <p style="">实验</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">操作</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">现象</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">证明了什么</p>
    </th>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">连珠炮回显</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">主循环睡觉窗口里发 8 字符</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">醒来 8 字符一坨回显（0.7ms 内到齐）</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">睡觉时中断照样收货（生产者不停工）</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">过载</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">一次粘贴 200 字符</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">回显前 127 个，err=73</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">丢新策略 + 牺牲一格（128-1=127）+ 记账可见</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">LED 奇偶</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">发 N 字符看 LED</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">奇数净翻转、偶数不动</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">回调按字节触发（不是按"消息"）</p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<h2 style="" id="8.-%E4%B8%89%E4%B8%AA%E5%8F%8D%E9%9D%A2%E6%A1%88%E4%BE%8B%EF%BC%88%E8%AF%BB%E5%88%AB%E4%BA%BA%E4%BB%A3%E7%A0%81%E6%97%B6%E7%9A%84%E6%89%BE%E8%8C%AC%E9%A2%98%EF%BC%89">8. 三个反面案例（读别人代码时的找茬题）</h2>
<p style="">真实项目的环形缓冲区常见三个坑，读代码时值得带着怀疑去找：</p>
<p style=""><strong>① 命名撒谎</strong>：函数叫 <code>isEmpty</code>，返回 1 却表示"有数据"——所有调用处都得写 <code>== 0</code> 才敢用。命名的诚实度就是代码的可维护性。</p>
<p style=""><strong>② count 死账本</strong>：写方和读方都维护 count 变量（两个写者 = 竞态 = 漂移），结果查询函数干脆不用它、用公式重算。<strong>派生量别另存</strong>——本文 2.2 节从第一天就做对了。</p>
<p style=""><strong>③ 腾位不用</strong>：丢旧实现里满了挤掉一个老字节后直接 return——当前字节也没写进去。持续过载时效果是"丢一个旧、丢一个新、再收一个"约丢一半，新旧交错。读代码时自己推演一遍边界行为，是最好的练习。</p>
<h2 style="" id="9.-%E6%89%A9%E5%B1%95%E8%A7%86%E9%87%8E">9. 扩展视野</h2>
<ul>
 <li>
  <p style=""><strong>2 的幂优化</strong>：<code>% RXBUF_SIZE</code> 换成 <code>&amp; (RXBUF_SIZE-1)</code>——与指令单周期，除法指令要多周期。取模对任何大小都能用，2 的幂的真正好处是这个位运算技巧</p>
 </li>
 <li>
  <p style=""><strong>多实例化</strong>：把下标打包成结构体、缓冲区由 init 传入（数据与控制器分离），一套代码实例化 N 个队列（多串口各收发一个）</p>
 </li>
 <li>
  <p style=""><strong>发送也进队列</strong>：send 只塞 TX 缓冲区，TXE 中断慢慢搬——非阻塞发送</p>
 </li>
 <li>
  <p style=""><strong>DMA + 环形缓冲</strong>：DMA 往缓冲区搬字节，CPU 只管处理——性能天花板</p>
 </li>
 <li>
  <p style=""><strong>现实世界</strong>：操作系统内核、网络协议栈、键盘缓冲、音频缓冲——全是这一套</p>
 </li>
</ul>
<h2 style="" id="10.-%E6%80%BB%E7%BB%93">10. 总结</h2>
<div class="halo-table-wrapper" data-table-layout="auto" style="box-sizing: border-box; overflow-x: auto; overflow-y: hidden; width: 100%; max-width: 100%; min-width: 0;">
 <table data-table-layout="auto" style="display: table; width: 100%; min-width: 100%; table-layout: auto;">
  <tbody>
   <tr>
    <th colspan="1" rowspan="1">
     <p style="">要点</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">内容</p>
    </th>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">解决的问题</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">中断世界与主循环世界的节奏差</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">核心结构</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">数组 + 两个下标（head 写 / tail 读）+ 取模回绕</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">满空区分</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">牺牲一格：(head+1) % SIZE == tail 为满</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">并发安全</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">volatile（看得见）+ 单一写者（不打架）= 不用关中断</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">count</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">公式推导，不另存（单一事实来源）</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">丢包</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">策略明确（丢新/丢旧）+ err 记账（可观测）</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">消费姿势</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">批量读 + 排空式</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">架构位置</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">中断生产 → 缓冲存储 → 主循环消费</p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<p style=""><strong>一句话收尾：环形缓冲区是嵌入式世界的万能骨架——中断是快递员，缓冲区是快递柜，主循环是收件人，err 是登记簿。</strong></p>]]></description><guid isPermaLink="false">/archives/embedded-ring-buffer</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Sat, 19 Sep 2026 05:59:49 GMT</pubDate></item><item><title><![CDATA[2.4 编译产物与F5调试全流程]]></title><link>https://www.flor.cn/archives/build-output-size-elf-hex-bin-2-4</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=2.4%20%E7%BC%96%E8%AF%91%E4%BA%A7%E7%89%A9%E4%B8%8EF5%E8%B0%83%E8%AF%95%E5%85%A8%E6%B5%81%E7%A8%8B&amp;url=/archives/build-output-size-elf-hex-bin-2-4" width="1" height="1" alt="" style="opacity:0;">
<h2 style="" id="%E4%B8%80%E3%80%81%E7%BC%96%E8%AF%91%E6%88%90%E5%8A%9F%E7%9A%84%E6%A0%87%E5%BF%97%EF%BC%9Asize-%E6%B1%87%E6%80%BB">一、编译成功的标志：size 汇总</h2>
<p style=""><code>make</code> 输出的最后一行是 size 汇总，看到它就代表编译、链接全部通过：</p>
<pre><code>text    data     bss     dec     hex filename
   4736      12    1644    6392    18f8 build/uart.elf
</code></pre>
<p style="">三个关键数的读法：</p>
<div class="halo-table-wrapper" data-table-layout="auto" style="box-sizing: border-box; overflow-x: auto; overflow-y: hidden; width: 100%; max-width: 100%; min-width: 0;">
 <table data-table-layout="auto" style="display: table; width: 100%; min-width: 100%; table-layout: auto;">
  <tbody>
   <tr>
    <th colspan="1" rowspan="1">
     <p style="">字段</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">含义</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">占用</p>
    </th>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">text</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">代码 + 常量</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">占 Flash</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">data</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">已初始化变量</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">占 Flash，启动时拷进 SRAM</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">bss</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">清零变量</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">只占 SRAM</p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<p style="">这三个数就是程序对 512KB Flash / 64KB SRAM 的占用量</p>
<h2 style="" id="%E4%BA%8C%E3%80%81elf-%2F-hex-%2F-bin-%2F-map%EF%BC%9A%E4%B8%80%E6%AC%A1%E7%BC%96%E8%AF%91%E5%85%A8%E5%87%BA">二、elf / hex / bin / map：一次编译全出</h2>
<p style="">CubeMX Makefile 的 <code>all</code> 目标是 elf + hex + bin 三件套一起出，编译完去 <code>uart/build/</code> 目录就能看到（连同 map 共四样）：</p>
<div class="halo-table-wrapper" data-table-layout="auto" style="box-sizing: border-box; overflow-x: auto; overflow-y: hidden; width: 100%; max-width: 100%; min-width: 0;">
 <table data-table-layout="auto" style="display: table; width: 100%; min-width: 100%; table-layout: auto;">
  <tbody>
   <tr>
    <th colspan="1" rowspan="1">
     <p style="">文件</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">是什么</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">谁用它</p>
    </th>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">uart.elf</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">带调试符号的完整档案</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">gdb（F5 调试、flash-remote.sh 烧录都吃这个）</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">uart.hex</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">Intel HEX 文本格式，带地址信息</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">FlyMcu 等串口烧录工具</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">uart.bin</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">裸二进制镜像</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">部分烧录器 / OTA</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">uart.map</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">每个符号放在哪个地址的地图</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">查内存布局</p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<h2 style="" id="%E4%B8%89%E3%80%81%E8%B0%83%E8%AF%95%EF%BC%9Af5-%E5%85%A8%E6%B5%81%E7%A8%8B">三、调试：F5 全流程</h2>
<h3 style="" id="%E5%85%88%E7%BC%96%E8%AF%91%EF%BC%8C%E5%86%8D%E8%B0%83%E8%AF%95">先编译，再调试</h3>
<p style=""><code>Ctrl+Shift+B</code> 编译。云端 <code>launch.json</code> 没配 <code>preLaunchTask</code>，F5 不会自动编译——改了代码直接 F5、现象没变，99% 是忘了编译（烧的还是旧 elf）。</p>
<h3 style="" id="%E5%90%AF%E5%8A%A8%E4%B8%8E%E8%87%AA%E5%8A%A8%E6%B5%81%E7%A8%8B">启动与自动流程</h3>
<ul>
 <li>
  <p style="">F5 → 第一次会让你选调试配置，选 <strong>Remote openocd (frp)</strong>（之后记住选择不再问）</p>
 </li>
 <li>
  <p style="">自动流程：连隧道 → 复位 → 烧录 → 停在 main 入口</p>
 </li>
</ul>
<h3 style="" id="%E5%B8%B8%E7%94%A8%E6%93%8D%E4%BD%9C">常用操作</h3>
<div class="halo-table-wrapper" data-table-layout="auto" style="box-sizing: border-box; overflow-x: auto; overflow-y: hidden; width: 100%; max-width: 100%; min-width: 0;">
 <table data-table-layout="auto" style="display: table; width: 100%; min-width: 100%; table-layout: auto;">
  <tbody>
   <tr>
    <th colspan="1" rowspan="1">
     <p style="">操作</p>
    </th>
    <th colspan="1" rowspan="1">
     <p style="">方式</p>
    </th>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">断点</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">点行号左侧出现红点；F9 切换</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">继续</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">F5</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">单步跳过</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">F10</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">单步进入</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">F11</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">跳出</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">Shift+F11</p>
    </td>
   </tr>
   <tr>
    <td colspan="1" rowspan="1">
     <p style="">结束调试</p>
    </td>
    <td colspan="1" rowspan="1">
     <p style="">Shift+F5（干净退出，释放 openocd 连接）</p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<p style="">左侧三个面板：</p>
<ul>
 <li>
  <p style=""><strong>VARIABLES</strong> — 局部/全局变量实时值</p>
 </li>
 <li>
  <p style=""><strong>WATCH</strong> — 右键变量 → Add to Watch</p>
 </li>
 <li>
  <p style=""><strong>CALL STACK</strong> — 调用堆栈</p>
 </li>
</ul>
<p style="">DEBUG CONSOLE 里可以直接输表达式读寄存器，比如看 GPIOA 输出寄存器：</p>
<pre><code>p/x *(volatile unsigned int*)0x4001080C
</code></pre>
<p style="">外设寄存器图形面板（CORTEX PERIPHERALS）目前 <code>launch.json</code> 还没配 <code>svdFile</code>，可能为空——用控制台表达式读完全够用，想图形化加一行配置即可。</p>
<h3 style="" id="%E4%B8%A4%E4%B8%AA%E6%AD%A3%E5%B8%B8%E7%8E%B0%E8%B1%A1">两个正常现象</h3>
<ol>
 <li>
  <p style=""><strong>断点暂停 = 芯片被 halt</strong>，串口停发 abc；F5 继续后恢复</p>
 </li>
 <li>
  <p style=""><strong>调试会话独占 openocd</strong>——F5 期间跑 <code>flash-remote.sh</code> 会连不上，先 Shift+F5 退出</p>
 </li>
</ol>
<p style=""></p>]]></description><guid isPermaLink="false">/archives/build-output-size-elf-hex-bin-2-4</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Mon, 14 Sep 2026 09:24:27 GMT</pubDate></item><item><title><![CDATA[2.3 USART初始化配置与HAL发送函数]]></title><link>https://www.flor.cn/archives/usart-init-and-transmit-2-3</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=2.3%20USART%E5%88%9D%E5%A7%8B%E5%8C%96%E9%85%8D%E7%BD%AE%E4%B8%8EHAL%E5%8F%91%E9%80%81%E5%87%BD%E6%95%B0&amp;url=/archives/usart-init-and-transmit-2-3" width="1" height="1" alt="" style="opacity:0;">
<h2 style="" id="%E4%B8%80%E3%80%81cubemx-%E5%8F%82%E6%95%B0%E9%85%8D%E7%BD%AE%E8%AF%A6%E8%A7%A3">一、CubeMX 参数配置详解</h2>
<p>在 CubeMX 的 USART1 配置页中，核心参数如下：</p>
<table>
 <thead>
  <tr>
   <th>参数</th>
   <th>值</th>
   <th>含义</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>Baud Rate</td>
   <td>115200</td>
   <td>波特率</td>
  </tr>
  <tr>
   <td>Word Length</td>
   <td>8 Bits (including Parity)</td>
   <td>8 数据位</td>
  </tr>
  <tr>
   <td>Parity</td>
   <td>None</td>
   <td>无校验</td>
  </tr>
  <tr>
   <td>Stop Bits</td>
   <td>1</td>
   <td>1 停止位</td>
  </tr>
  <tr>
   <td>Data Direction</td>
   <td>Receive and Transmit</td>
   <td>收发双向</td>
  </tr>
  <tr>
   <td>Over Sampling</td>
   <td>16 samples</td>
   <td>16 倍过采样</td>
  </tr>
 </tbody>
</table>
<p><strong>逐项原理说明：</strong></p>
<ul>
 <li><strong>Baud Rate（波特率）</strong>：每秒传输的比特数。115200 是嵌入式最常用速率，由 BRR 寄存器分频得到：<code>BRR = f_PCLK / 波特率</code>（USART1 挂 APB2 = 72MHz，72000000/115200 = 625，无误差）。</li>
 <li><strong>Word Length（字长）</strong>：一个数据帧的数据位长度。注意 HAL 的 8B 是"含校验位"的——若开启奇偶校验，实际有效数据只有 7 位；无校验时才是完整 8 位。</li>
 <li><strong>Parity（校验）</strong>：None / Even（偶校验）/ Odd（奇校验）。无校验最常用；开启后校验位占用数据帧一位。</li>
 <li><strong>Stop Bits（停止位）</strong>：帧尾电平保持时间，1 位最常用（0.5/1/1.5/2 可选）。</li>
 <li><strong>Data Direction（模式）</strong>：TX_RX 同时使能发送与接收（对应 CR1 寄存器的 TE、RE 位）。</li>
 <li><strong>Over Sampling（过采样）</strong>：16 倍过采样对每位采样 16 次取多数，抗干扰强，是默认推荐；8 倍过采样可支撑更高波特率但容错差。</li>
</ul>
<p>一帧完整格式：<strong>起始位(1) + 数据位(8) + 校验位(0) + 停止位(1)</strong>，即 115200-8-N-1。</p>
<h2 style="" id="%E4%BA%8C%E3%80%81cubemx-%E7%94%9F%E6%88%90%E7%9A%84%E5%88%9D%E5%A7%8B%E5%8C%96%E4%BB%A3%E7%A0%81">二、CubeMX 生成的初始化代码</h2>
<p><code>usart.c</code> 中的核心结构体与初始化函数：</p>
<pre><code class="language-c">UART_HandleTypeDef huart1;

void MX_USART1_UART_Init(void)
{
  huart1.Instance           = USART1;
  huart1.Init.BaudRate      = 115200;
  huart1.Init.WordLength    = UART_WORDLENGTH_8B;
  huart1.Init.StopBits      = UART_STOPBITS_1;
  huart1.Init.Parity        = UART_PARITY_NONE;
  huart1.Init.Mode          = UART_MODE_TX_RX;
  huart1.Init.HwFlowCtl     = UART_HWCONTROL_NONE;
  huart1.Init.OverSampling  = UART_OVERSAMPLING_16;
  HAL_UART_Init(&amp;huart1);
}
</code></pre>
<p><strong>结构体字段与硬件的对应关系：</strong></p>
<table>
 <thead>
  <tr>
   <th>字段</th>
   <th>写入的寄存器/位</th>
   <th>作用</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>Instance</td>
   <td>—</td>
   <td>指向 USART1 外设基地址</td>
  </tr>
  <tr>
   <td>BaudRate</td>
   <td>BRR</td>
   <td>波特率分频值</td>
  </tr>
  <tr>
   <td>WordLength</td>
   <td>CR1.M</td>
   <td>数据位长度</td>
  </tr>
  <tr>
   <td>StopBits</td>
   <td>CR2.STOP</td>
   <td>停止位</td>
  </tr>
  <tr>
   <td>Parity</td>
   <td>CR1.PCE / PS</td>
   <td>校验使能与类型</td>
  </tr>
  <tr>
   <td>Mode</td>
   <td>CR1.TE / RE</td>
   <td>发送/接收使能</td>
  </tr>
  <tr>
   <td>HwFlowCtl</td>
   <td>CR3.RTSE / CTSE</td>
   <td>硬件流控（RTS/CTS）</td>
  </tr>
  <tr>
   <td>OverSampling</td>
   <td>CR1.OVER8</td>
   <td>8/16 倍过采样</td>
  </tr>
 </tbody>
</table>
<p><code>HAL_UART_Init()</code> 内部依次完成：参数校验 → 调用 <code>HAL_UART_MspInit()</code>（CubeMX 生成在 <code>usart.c</code> 底部：使能 USART1 与 GPIOA 时钟、配置 PA9/PA10 复用推挽）→ 按 Init 结构体写入 CR1/CR2/CR3/BRR → 使能外设。</p>
<p><strong>为什么代码里看不到 RCC 和 GPIO 配置？</strong> 因为 CubeMX 把"芯片级初始化"（时钟使能、引脚复用）拆进了 <code>HAL_UART_MspInit()</code>，与"协议参数配置"（<code>MX_USART1_UART_Init()</code>）分离——前者管硬件资源，后者管通信格式，各司其职。</p>
<h2 style="" id="%E4%B8%89%E3%80%81hal_uart_transmit-%E5%8F%91%E9%80%81%E5%87%BD%E6%95%B0">三、HAL_UART_Transmit 发送函数</h2>
<pre><code class="language-c">HAL_StatusTypeDef HAL_UART_Transmit(
    UART_HandleTypeDef *huart,  // 串口句柄
    uint8_t *pData,             // 数据指针
    uint16_t Size,              // 数据长度
    uint32_t Timeout            // 超时(ms)
);
</code></pre>
<p><strong>工作流程（阻塞式发送）：</strong></p>
<ol>
 <li>逐字节把数据写入 DR（数据寄存器）</li>
 <li>等待 TXE（发送数据寄存器空）标志，再写下一字节</li>
 <li>全部写完后等待 TC（发送完成）标志</li>
 <li>期间用 Timeout 计数，超时则放弃并返回 <code>HAL_TIMEOUT</code></li>
</ol>
<p><strong>返回值：</strong></p>
<table>
 <thead>
  <tr>
   <th>返回值</th>
   <th>含义</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>HAL_OK</td>
   <td>发送成功</td>
  </tr>
  <tr>
   <td>HAL_ERROR</td>
   <td>参数错误</td>
  </tr>
  <tr>
   <td>HAL_BUSY</td>
   <td>串口被占用（如上次发送未完成）</td>
  </tr>
  <tr>
   <td>HAL_TIMEOUT</td>
   <td>超时放弃</td>
  </tr>
 </tbody>
</table>
<p><strong>使用示例：</strong></p>
<pre><code class="language-c">uint8_t msg[] = "Hello USART1\r\n";
HAL_UART_Transmit(&amp;huart1, msg, sizeof(msg) - 1, 100);  // 100ms 超时
</code></pre>
<p><strong>要点：</strong></p>
<ul>
 <li>阻塞式发送——CPU 停在函数里等硬件搬完，期间不能干别的；大数据量场景应改用中断（<code>HAL_UART_Transmit_IT</code>）或 DMA 版本。</li>
 <li><code>sizeof(msg) - 1</code> 减掉字符串末尾的 <code>\0</code>，串口不需要发它。</li>
 <li>发送速率由波特率决定：115200bps 下每字节约 87μs，发 100 字节约 8.7ms。</li>
</ul>]]></description><guid isPermaLink="false">/archives/usart-init-and-transmit-2-3</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Fri, 11 Sep 2026 05:18:36 GMT</pubDate></item><item><title><![CDATA[2.2 USART硬件架构与挂载使能总链路]]></title><link>https://www.flor.cn/archives/usart-hardware-architecture-2-2</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=2.2%20USART%E7%A1%AC%E4%BB%B6%E6%9E%B6%E6%9E%84%E4%B8%8E%E6%8C%82%E8%BD%BD%E4%BD%BF%E8%83%BD%E6%80%BB%E9%93%BE%E8%B7%AF&amp;url=/archives/usart-hardware-architecture-2-2" width="1" height="1" alt="" style="opacity:0;">
<h2 style="" id="%E4%B8%80%E3%80%81%E5%85%AD%E4%B8%AA%E6%A0%87%E5%87%86%E9%97%AE%E9%A2%98">一、六个标准问题</h2>
<h3 style="" id="%E2%91%A0-%E6%8C%82%E5%93%AA%E6%9D%A1%E6%80%BB%E7%BA%BF">① 挂哪条总线</h3>
<p>USART1 挂 <strong>APB2</strong>（72MHz）。</p>
<p>回顾：F103 有三个 USART（大容量 ZET6 有 USART1/2/3 + UART4/5），总线分配不同：</p>
<table>
 <thead>
  <tr>
   <th>外设</th>
   <th>总线</th>
   <th>时钟频率</th>
   <th>本板用途</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>USART1</strong></td>
   <td><strong>APB2</strong></td>
   <td><strong>72MHz</strong></td>
   <td><strong>CH340K 串口（PA9/PA10）</strong></td>
  </tr>
  <tr>
   <td>USART2</td>
   <td>APB1</td>
   <td>36MHz</td>
   <td>扩展（PA2/PA3）</td>
  </tr>
  <tr>
   <td>USART3</td>
   <td>APB1</td>
   <td>36MHz</td>
   <td>扩展（PB10/PB11）</td>
  </tr>
  <tr>
   <td>UART4/5</td>
   <td>APB1</td>
   <td>36MHz</td>
   <td>扩展</td>
  </tr>
 </tbody>
</table>
<p>USART1 是唯一挂在 APB2 的串口，时钟直接 72MHz，不需要倍频补偿，波特率计算最精确。这也是为什么调试串口<strong>首选 USART1</strong>。</p>
<h3 style="" id="%E2%91%A1-%E6%97%B6%E9%92%9F%E6%9D%A5%E6%BA%90%E4%B8%8E%E7%89%B9%E6%AE%8A%E5%80%8D%E9%A2%91">② 时钟来源与特殊倍频</h3>
<pre><code>HSE 8MHz → PLL×9 → SYSCLK 72MHz
                              → HCLK 72MHz
                                  → PCLK2 72MHz（APB2 不分频）
                                      → USART1 72MHz
</code></pre>
<p>USART1 时钟 = PCLK2 = <strong>72MHz</strong>，无倍频、无分频，直接用。</p>
<blockquote>
 <p>对比：USART2/3/4/5 挂 APB1，PCLK1=36MHz，波特率计算时基准时钟是 36MHz。</p>
</blockquote>
<h3 style="" id="%E2%91%A2-%E9%97%A8%E6%8E%A7%E5%BC%80%E5%85%B3">③ 门控开关</h3>
<p>USART1 的时钟使能位在 <strong>RCC_APB2ENR</strong> 的 <strong>bit14（USART1EN）</strong>：</p>
<pre><code>RCC_APB2ENR |= RCC_APB2ENR_USART1EN;  // 开 USART1 时钟
</code></pre>
<p>同时，USART1 用 PA9/PA10，GPIOA 也要开：</p>
<pre><code>RCC_APB2ENR |= RCC_APB2ENR_IOPAEN;    // 开 GPIOA 时钟
</code></pre>
<p><strong>配电箱比喻</strong>：</p>
<pre><code>总闸 SYSCLK 72MHz
  └─ 分闸 APB2ENR（72MHz 母线）
       ├─ IOPAEN  → GPIOA 房间（PA9/PA10 引脚）
       └─ USART1EN → USART1 房间（波特率发生器/收发器）
</code></pre>
<p>两个开关都要开，缺一不可：GPIO 负责引脚电平物理输出，USART 负责协议帧时序。</p>
<h3 style="" id="%E2%91%A3-cubemx-%E4%B8%AD%E5%AF%B9%E5%BA%94%E9%85%8D%E7%BD%AE">④ CubeMX 中对应配置</h3>
<p>CubeMX 中配置 USART1 非常简单，大部分参数 CubeMX 自动处理：</p>
<table>
 <thead>
  <tr>
   <th>配置项</th>
   <th>CubeMX 位置</th>
   <th>是否可见</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>USART1 时钟使能</td>
   <td>勾选 USART1 → 自动开</td>
   <td>隐藏，自动生成</td>
  </tr>
  <tr>
   <td>GPIOA 时钟使能</td>
   <td>选 PA9/PA10 → 自动开</td>
   <td>隐藏，自动生成</td>
  </tr>
  <tr>
   <td>PA9 = USART1_TX</td>
   <td>Connectivity → USART1</td>
   <td>可见</td>
  </tr>
  <tr>
   <td>PA10 = USART1_RX</td>
   <td>Connectivity → USART1</td>
   <td>可见</td>
  </tr>
  <tr>
   <td>波特率/字长/停止位/校验</td>
   <td>Parameter Settings</td>
   <td>可见</td>
  </tr>
 </tbody>
</table>
<blockquote>
 <p>注意：GPIOA 的时钟使能（IOPAEN）在 CubeMX 界面无可见开关，选了 PA9/PA10 后自动推断生成。这和你学 GPIO 时 IOPBEN 不可见是同样的道理。</p>
</blockquote>
<h3 style="" id="%E2%91%A4-%E5%AF%84%E5%AD%98%E5%99%A8%E5%8F%82%E6%95%B0%EF%BC%88usart-%E6%A0%B8%E5%BF%83%E5%AF%84%E5%AD%98%E5%99%A8%EF%BC%89">⑤ 寄存器参数（USART 核心寄存器）</h3>
<p>USART 外设核心寄存器就几个，理解这 5 个就够：</p>
<table>
 <thead>
  <tr>
   <th>寄存器</th>
   <th>作用</th>
   <th>关键位</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td><strong>USART_SR</strong>（状态寄存器）</td>
   <td>查状态</td>
   <td>TXE（发送缓冲空）、TC（发送完成）、RXNE（接收缓冲非空）</td>
  </tr>
  <tr>
   <td><strong>USART_DR</strong>（数据寄存器）</td>
   <td>读写数据</td>
   <td>写→发送、读→接收，只有低8位有效</td>
  </tr>
  <tr>
   <td><strong>USART_BRR</strong>（波特率寄存器）</td>
   <td>设波特率</td>
   <td>DIV_Mantissa + DIV_Fraction = 分频值</td>
  </tr>
  <tr>
   <td><strong>USART_CR1</strong>（控制寄存器1）</td>
   <td>总开关</td>
   <td>UE（USART使能）、TE（发送使能）、RE（接收使能）、RXNEIE（接收中断使能）</td>
  </tr>
  <tr>
   <td><strong>USART_CR2</strong>（控制寄存器2）</td>
   <td>帧参数</td>
   <td>STOP[1:0]（停止位）、CLKEN（同步时钟）</td>
  </tr>
 </tbody>
</table>
<p><strong>波特率计算公式</strong>（异步模式）：</p>
<pre><code>波特率 = fPCLK / (16 × DIV)
</code></pre>
<p>其中 <code>fPCLK</code> = 72MHz（USART1），<code>DIV</code> = BRR 里的分频值（整数+小数，共12位整数+4位小数）。</p>
<p>以 <strong>115200</strong> 为例：</p>
<pre><code>DIV = 72000000 / (16 × 115200) = 39.0625
整数部分 = 39 = 0x27
小数部分 = 0.0625 × 16 = 1 = 0x1
BRR = 0x271
</code></pre>
<p>CubeMX 填 115200 后自动算好写进 BRR，不用手算。但原理要知道。</p>
<h3 style="" id="%E2%91%A5-%E7%A1%AC%E4%BB%B6%E8%A1%8C%E4%B8%BA%EF%BC%88%E6%95%B0%E6%8D%AE%E4%BB%8E%E5%8F%91%E9%80%81%E5%88%B0%E6%8E%A5%E6%94%B6%E7%9A%84%E5%AE%8C%E6%95%B4%E9%93%BE%E8%B7%AF%EF%BC%89">⑥ 硬件行为（数据从发送到接收的完整链路）</h3>
<p><strong>发送链路</strong>（STM32 → 电脑）：</p>
<pre><code>写 DR 寄存器
  → 移位寄存器逐位输出
    → TX 引脚(PA9) 按协议帧输出 TTL 电平
      → CH340K 收到 TTL 信号，转成 USB 协议
        → USB 线传到电脑
          → 电脑端 CH340 驱动转为虚拟 COM 口
            → 串口助手软件显示数据
</code></pre>
<p><strong>接收链路</strong>（电脑 → STM32）：</p>
<pre><code>串口助手发送数据
  → 电脑 USB → CH340K
    → CH340K 转 TTL → PA10(RX) 引脚
      → USART1 接收移位寄存器逐位采样
        → 收满一帧 → 数据写入 DR
          → 置 RXNE 标志 → 可读 DR 获取数据
</code></pre>
<h2 style="" id="%E4%BA%8C%E3%80%81ch340k-%E8%BF%9E%E6%8E%A5%E7%94%B5%E8%B7%AF%EF%BC%88tx%2Frx-%E4%BA%A4%E5%8F%89%EF%BC%89">二、CH340K 连接电路（TX/RX 交叉）</h2>
<p>这是<strong>最容易接错</strong>的地方，务必记住：<strong>TX→RX，RX→TX，交叉连接</strong>。</p>
<pre><code>STM32                CH340K
PA9  (USART1_TX) ──→ RXD
PA10 (USART1_RX) ←── TXD
GND ─────────────── GND    （共地！必须接）
</code></pre>
<blockquote>
 <p>为什么必须交叉？TX 是输出，RX 是输入。如果 TX→TX，两个输出对冲，谁也收不到数据。就像打电话，你的嘴（TX）要对对方的耳朵（RX），不可能嘴对嘴。</p>
</blockquote>
<p>你开发板上 CH340K 和 STM32 已经在 PCB 上固定连好了（PA9→CH340 RX、PA10→CH340 TX、共地），用一根 USB 线接电脑即可，不用飞线。</p>
<h2 style="" id="%E4%B8%89%E3%80%81%E5%AE%8C%E6%95%B4%E6%8C%82%E8%BD%BD%E4%BD%BF%E8%83%BD%E6%80%BB%E9%93%BE%E8%B7%AF%E5%9B%BE">三、完整挂载使能总链路图</h2>
<p>把六层串起来，这是整个 USART1 的全景：</p>
<pre><code>Cortex-M3 内核
  │
  ▼ SYSCLK = 72MHz（HSE 8MHz × PLL9）
  │
  ▼ HCLK = 72MHz
  │
  ▼ PCLK2 = 72MHz（APB2，不分频）
  │
  ├── RCC_APB2ENR.IOPAEN ───→ GPIOA 使能
  │                                ├── PA9  → 复用推挽输出（USART1_TX）
  │                                └── PA10 → 浮空/上拉输入（USART1_RX）
  │
  └── RCC_APB2ENR.USART1EN ─→ USART1 使能
                                   ├── BRR  = 0x271（115200bps）
                                   ├── CR1  = UE|TE|RE（使能+收发）
                                   ├── SR.TXE → 发送缓冲空，可写 DR
                                   ├── SR.RXNE → 接收缓冲有数据，可读 DR
                                   └── DR  → 读写数据
                                        │
                                        ▼
                                   PA9/PA10 引脚电平变化
                                        │
                                        ▼
                                   CH340K（TTL↔USB）
                                        │
                                        ▼
                                   电脑 USB → 虚拟 COM → 串口助手
</code></pre>
<h2 style="" id="%E5%9B%9B%E3%80%81pa9%2Fpa10-%E7%9A%84-gpio-%E6%A8%A1%E5%BC%8F">四、PA9/PA10 的 GPIO 模式</h2>
<p>USART 用的是<strong>复用功能（AF）</strong>，GPIO 模式和普通 IO 不同：</p>
<table>
 <thead>
  <tr>
   <th>引脚</th>
   <th>功能</th>
   <th>GPIO 模式</th>
   <th>说明</th>
  </tr>
 </thead>
 <tbody>
  <tr>
   <td>PA9</td>
   <td>USART1_TX</td>
   <td><strong>复用推挽输出</strong></td>
   <td>数据由 USART 外设驱动输出，不由 GPIO_ODR 驱动</td>
  </tr>
  <tr>
   <td>PA10</td>
   <td>USART1_RX</td>
   <td><strong>浮空输入/上拉输入</strong></td>
   <td>接收由 USART 外设采样，GPIO 只需配置为输入</td>
  </tr>
 </tbody>
</table>
<blockquote>
 <p>关键理解：一旦开启 USART 的 TE（发送使能），PA9 的输出控制权就<strong>从 GPIO 模块移交给了 USART 外设</strong>，此时写 GPIO_ODR 无效，数据要写 USART_DR 才会从 PA9 发出。这就是"复用"的含义——引脚被外设"借用"了。</p>
</blockquote>
<p>CubeMX 中你只需选 PA9 = USART1_TX、PA10 = USART1_RX，GPIO 模式自动配好，不用手动设。</p>]]></description><guid isPermaLink="false">/archives/usart-hardware-architecture-2-2</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Tue, 8 Sep 2026 08:04:52 GMT</pubDate></item><item><title><![CDATA[RCC外设依赖]]></title><link>https://www.flor.cn/archives/1787237355790</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=RCC%E5%A4%96%E8%AE%BE%E4%BE%9D%E8%B5%96&amp;url=/archives/1787237355790" width="1" height="1" alt="" style="opacity:0;">
<p style="">好问题，这正是理解 STM32F1 架构的关键点。RCC 的位置其实有"两个身份"，很多人混淆就在这里。</p>
<hr>
<h2 style="" id="%E4%B8%80%E3%80%81rcc-%E5%88%B0%E5%BA%95%E5%9C%A8%E5%93%AA%E4%B8%80%E5%B1%82%EF%BC%9F%E2%80%94%E2%80%94%E5%8F%8C%E9%87%8D%E8%BA%AB%E4%BB%BD">一、RCC 到底在哪一层？——双重身份</h2>
<p style=""><strong>物理上：RCC 是挂在 AHB 总线上的一颗"外设"</strong></p>
<ul>
 <li>
  <p style="">地址 <code>0x40021000</code>，有自己的寄存器组（CR、CFGR、AHBENR、APB1ENR、APB2ENR...）</p>
 </li>
 <li>
  <p style="">和 DMA、SDIO、CRC 一样是 AHB 总线上的成员</p>
 </li>
</ul>
<p style=""><strong>逻辑上：RCC 是整个芯片的"时钟分发中心"（中枢）</strong></p>
<ul>
 <li>
  <p style="">它决定 SYSCLK 来源（HSI/HSE/PLL）、各总线分频（HCLK/PCLK1/PCLK2）</p>
 </li>
 <li>
  <p style="">它掌管<strong>每个外设的时钟使能</strong>（门控开关）——不开就点不亮</p>
 </li>
</ul>
<p style=""><strong>特殊性</strong>：RCC 是<strong>唯一不需要别人给它开时钟的外设</strong>。上电后芯片默认用 HSI 8MHz 直接运行，RCC 自己就能工作，然后它再去"点亮"其他所有人。所以它既是"外设"，又是所有外设依赖链的<strong>第一环</strong>。</p>
<pre><code>             Cortex-M3
                │
        SYSCLK（系统时钟）
                │
     ┌──────────┴──────────┐
     │   AHB 总线 (72MHz)   │
     └──┬──────┬─────┬─────┘
        │      │     │
     ┌──▼──┐ ┌▼───┐ ┌▼────────────┐
     │ DMA │ │SDIO│ │ RCC ◄──── 时钟中枢 │
     │ CRC │ │Flash│ │(自己先跑起来，再发   │
     └─────┘ └────┘ │ 时钟给所有外设)    │
                    └──────┬───────────┘
              ┌────────────┼────────────┐
        ┌─────▼─────┐ ┌────▼─────┐
        │ APB2 总线  │ │ APB1 总线 │
        │ (72MHz)   │ │ (36MHz)  │
        └───────────┘ └──────────┘
</code></pre>
<hr>
<h2 style="" id="%E4%BA%8C%E3%80%81f1-%E6%97%B6%E9%92%9F%E6%A0%91%EF%BC%9Arcc-%E8%BE%93%E5%87%BA%E5%93%AA%E4%BA%9B%E6%97%B6%E9%92%9F">二、F1 时钟树：RCC 输出哪些时钟</h2>
<pre><code>HSE 8MHz ──────┐
               ├─→ PLL(×9) ──→ SYSCLK 72MHz ──→ HCLK 72MHz
HSI 8MHz ──────┘                 │                 │
                          ┌──────┴──────┐   ┌──────┴──────┐
                          │ APB1 分频 /2 │   │ APB2 分频 /1 │
                          ▼             ▼   ▼             ▼
                     PCLK1 = 36MHz  PCLK2 = 72MHz
LSE 32.768KHz ──→ RTC（独立于总线）
LSI 40KHz ──────→ IWDG（独立于总线）
PLL ─────────────→ USB 48MHz（专门输出）
</code></pre>
<hr>
<h2 style="" id="%E4%B8%89%E3%80%81%E5%90%84%E5%A4%96%E8%AE%BE%E6%8C%82%E5%9C%A8%E5%93%AA%E4%B8%AA%E6%80%BB%E7%BA%BF%EF%BC%88f1-%E7%89%B9%E4%BE%8B%E6%B3%A8%E6%84%8F%EF%BC%81%EF%BC%89">三、各外设挂在哪个总线（F1 特例注意！）</h2>
<p style=""><strong>APB2（高速 72MHz）</strong>——注意 <strong>F1 的 GPIO 在 APB2</strong>（F4 在 AHB1，别混）：</p>
<div style="overflow-x: auto; overflow-y: hidden;">
 <table style="width: 200px">
  <colgroup>
   <col style="width: 100px">
   <col style="width: 100px">
  </colgroup>
  <tbody>
   <tr style="height: 60px;">
    <th colspan="1" rowspan="1" colwidth="100">
     <p style="">外设</p>
    </th>
    <th colspan="1" rowspan="1" colwidth="100">
     <p style="">时钟使能函数</p>
    </th>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">GPIOA~GPIOG</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_GPIOx_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">AFIO</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_AFIO_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">USART1</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_USART1_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">SPI1</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_SPI1_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">TIM1 / TIM8</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_TIM1/8_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">ADC1/2/3</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_ADCx_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">EXTI</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">（由 AFIO 管理，使能 AFIO 即可）</p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<p style=""><strong>APB1（低速 36MHz）</strong>：</p>
<div style="overflow-x: auto; overflow-y: hidden;">
 <table style="width: 200px">
  <colgroup>
   <col style="width: 100px">
   <col style="width: 100px">
  </colgroup>
  <tbody>
   <tr style="height: 60px;">
    <th colspan="1" rowspan="1" colwidth="100">
     <p style="">外设</p>
    </th>
    <th colspan="1" rowspan="1" colwidth="100">
     <p style="">时钟使能函数</p>
    </th>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">USART2/3</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_USART2/3_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">SPI2/3</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_SPI2/3_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">I2C1/2</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_I2Cx_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">TIM2~TIM7</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_TIMx_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">CAN1</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_CAN1_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">USB</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_USB_CLK_ENABLE()</code>（还要 PLL 出 48MHz）</p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">DAC</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_DAC_CLK_ENABLE()</code></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">PWR / BKP</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_PWR_CLK_ENABLE()</code>（RTC 解锁用）</p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">WWDG</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>__HAL_RCC_WWDG_CLK_ENABLE()</code></p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<p style=""><strong>AHB（72MHz）</strong>：DMA1/2、SDIO、CRC、Flash 接口</p>
<p style=""><strong>独立时钟源（不经过总线）</strong>：IWDG←LSI、RTC←LSE/LSI</p>
<hr>
<h2 style="" id="%E5%9B%9B%E3%80%81%E5%90%84%E5%A4%96%E8%AE%BE%E5%AE%8C%E6%95%B4%E4%BE%9D%E8%B5%96%E9%93%BE%EF%BC%88%E9%87%8D%E7%82%B9%EF%BC%89">四、各外设完整依赖链（重点）</h2>
<p style="">每个外设不是"开自己的时钟就行"，它的<strong>完整依赖链</strong>如下（★=必须，☆=可选）：</p>
<h3 style="" id="1.-gpio%EF%BC%88%E6%9C%80%E5%9F%BA%E7%A1%80%EF%BC%89">1. GPIO（最基础）</h3>
<pre><code>★ RCC 使能 GPIOx 时钟 → ★ 配置 MODER/OTYPER/PUPDR → (无 AFIO/NVIC)
</code></pre>
<p style=""><strong>只依赖 RCC</strong>，这就是为什么它排在阶段 1 最前。</p>
<h3 style="" id="2.-afio%EF%BC%88%E5%A4%8D%E7%94%A8%E6%8E%A5%E7%BA%BF%E5%91%98%EF%BC%89">2. AFIO（复用接线员）</h3>
<pre><code>★ RCC 使能 AFIO 时钟 → ★ 配置复用重映射
</code></pre>
<p style="">依赖 RCC；它本身是 GPIO"切换成外设功能"的桥梁。</p>
<h3 style="" id="3.-exti-%E5%A4%96%E9%83%A8%E4%B8%AD%E6%96%AD">3. EXTI 外部中断</h3>
<pre><code>★ RCC 使能 AFIO（EXTI 源选择在 AFIO 里）
★ GPIO 配输入模式
★ 配置 EXTI 线路 + 边沿
★ NVIC 使能 + 优先级
</code></pre>
<p style=""><strong>依赖：RCC + GPIO + AFIO + NVIC</strong>（四层全占）。</p>
<h3 style="" id="4.-usart1%EF%BC%88%E9%98%B6%E6%AE%B5-2%EF%BC%89">4. USART1（阶段 2）</h3>
<pre><code>★ RCC 使能 USART1（APB2）
★ RCC 使能 GPIOA
★ PA9/PA10 配置复用（AFIO）
★ 配置波特率/数据位
☆ NVIC（中断接收）
☆ DMA（DMA1_CH4=TX、CH5=RX）
</code></pre>
<p style=""><strong>依赖：RCC + GPIO + AFIO +（NVIC/DMA）</strong>。</p>
<h3 style="" id="5.-tim%EF%BC%88%E9%98%B6%E6%AE%B5-3%EF%BC%89">5. TIM（阶段 3）</h3>
<pre><code>★ RCC 使能 TIMx
★ 配置 PSC/ARR/计数模式
☆ GPIO 复用（PWM 输出、输入捕获、编码器才需要）
☆ NVIC（定时中断）
</code></pre>
<p style="">基础定时<strong>只依赖 RCC</strong>；输出功能再加 GPIO。</p>
<h3 style="" id="6.-adc%EF%BC%88%E9%98%B6%E6%AE%B5-7%EF%BC%89">6. ADC（阶段 7）</h3>
<pre><code>★ RCC 使能 ADCx（APB2）
★ RCC 使能 GPIO（模拟输入模式！）
★ ADC 时钟分频（APB2 72MHz → ADC ≤14MHz，如 /6=12MHz）
★ 校准 → 配置通道/采样时间
☆ DMA（ADC1→DMA1_CH1，多通道必备）
</code></pre>
<p style=""><strong>注意</strong>：模拟输入引脚不能配推挽/复用，要配 <strong>Analog 模式</strong>。</p>
<h3 style="" id="7.-spi%EF%BC%88%E9%98%B6%E6%AE%B5-5%EF%BC%89">7. SPI（阶段 5）</h3>
<pre><code>★ RCC 使能 SPIx
★ RCC 使能 GPIO（PA5~7 或 PB13~15）
★ 复用功能配置
★ 配置速率/CPOL/CPHA
★ CS 用普通 GPIO 软件控制（F1 硬件 NSS 常不用）
</code></pre>
<h3 style="" id="8.-i2c%EF%BC%88%E9%98%B6%E6%AE%B5-6%EF%BC%89">8. I2C（阶段 6）</h3>
<pre><code>★ RCC 使能 I2Cx（APB1）
★ RCC 使能 GPIOB（PB6/7）
★ 复用**开漏**输出（I2C 特殊性！）
★ 上拉（内部或外部）
</code></pre>
<h3 style="" id="9.-dma%EF%BC%88%E9%98%B6%E6%AE%B5-4%EF%BC%89">9. DMA（阶段 4）</h3>
<pre><code>★ RCC 使能 DMA1/2（AHB）
★ 配置通道/方向/源/目的/模式
☆ NVIC（传输完成中断）
</code></pre>
<p style=""><strong>DMA 自身只依赖 RCC</strong>，但它的意义是服务 USART/ADC/SPI，所以放在它们前面学。</p>
<h3 style="" id="10.-can-%2F-usb%EF%BC%88%E9%98%B6%E6%AE%B5-11%2F12%EF%BC%8C%E5%8D%8F%E8%AE%AE%E7%BA%A7%EF%BC%89">10. CAN / USB（阶段 11/12，协议级）</h3>
<pre><code>CAN：★ RCC(CAN1) ★ GPIO(PA11/12 复用) ★ 波特率/滤波器 ★ 外部收发器 TJA1050
USB：★ RCC(USB) ★ PLL 输出 48MHz ★ GPIO(PA11/12 复用) ★ NVIC ★ USB 协议库
</code></pre>
<p style=""><strong>USB 是唯一额外要求"PLL 同时输出 48MHz"的外设</strong>，时钟配置最特殊。</p>
<h3 style="" id="11.-rtc-%2F-iwdg%EF%BC%88%E9%98%B6%E6%AE%B5-9%2F10%EF%BC%8C%E5%A4%87%E4%BB%BD%E5%9F%9F%E4%B8%8E%E7%8B%AC%E7%AB%8B%E6%BA%90%EF%BC%89">11. RTC / IWDG（阶段 9/10，备份域与独立源）</h3>
<pre><code>RTC：★ RCC(PWR+BKP) → ★ 解锁备份域写保护(DBP) → ★ LSE 32.768KHz → ★ 配置分频
IWDG：★ 直接用 LSI，不开任何总线时钟（写保护解除即可）
</code></pre>
<hr>
<h2 style="" id="%E4%BA%94%E3%80%81%E4%B8%80%E5%BC%A0%E5%9B%BE%E7%9C%8B%E6%87%82%22%E8%B0%81%E4%BE%9D%E8%B5%96%E8%B0%81%22">五、一张图看懂"谁依赖谁"</h2>
<pre><code>              RCC（时钟中枢，第一环）
              │
     ┌────────┼──────────────────┐
     ▼        ▼                  ▼
   GPIO    AFIO ←──GPIO的复用功能──┐
     │        │                  │
     ▼        ▼                  │
   EXTI ←──NVIC←──所有中断的入口──┤
     │                           │
     ▼                           ▼
  USART/TIM/ADC/SPI/I2C/CAN/USB   DMA（服务它们的数据搬运）
     │                           │
     └──────────┬────────────────┘
                ▼
        综合项目（全部串联）
</code></pre>
<p style=""><strong>关键结论</strong>：</p>
<ol>
 <li>
  <p style=""><strong>RCC 在 AHB 上，但它是时钟中枢</strong>——所有外设的第一依赖</p>
 </li>
 <li>
  <p style=""><strong>GPIO + AFIO + NVIC 是三大公共前置</strong>——几乎所有通信/中断外设都要先过这三关</p>
 </li>
 <li>
  <p style=""><strong>DMA 只依赖 RCC，但服务于外设</strong>——所以它早学（阶段 4），因为 USART/ADC/SPI 后面都要用</p>
 </li>
 <li>
  <p style=""><strong>IWDG/RTC 是"独立王国"</strong>——不依赖总线时钟，学的时候不用管 RCC</p>
 </li>
</ol>
<p style="">这也正好解释了目录顺序为什么是 <strong>RCC→GPIO→AFIO→SysTick→EXTI→NVIC</strong>，先把公共前置全部打通，后面的外设实验就只剩"该外设自己的参数配置"了。</p>]]></description><guid isPermaLink="false">/archives/1787237355790</guid><dc:creator>flor</dc:creator><pubDate>Thu, 20 Aug 2026 14:49:50 GMT</pubDate></item><item><title><![CDATA[pwm]]></title><link>https://www.flor.cn/archives/1786503239668</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=pwm&amp;url=/archives/1786503239668" width="1" height="1" alt="" style="opacity:0;">
<h2 style="" id="%E6%9C%AC%E8%AF%BE%E7%9B%AE%E6%A0%87%EF%BC%882.2-pwm-%E5%91%BC%E5%90%B8%E7%81%AF%EF%BC%89">本课目标（2.2 PWM 呼吸灯）</h2>
<ul>
 <li>
  <p style="">理解 PWM：占空比 = CCR ÷ ARR，频率 = 时钟 ÷ (PSC+1) ÷ (ARR+1)</p>
 </li>
 <li>
  <p style="">用定时器通道输出 PWM，用改变 CCR 的办法让 LED 平滑地“亮→暗→亮”</p>
 </li>
 <li>
  <p style="">验收标准：LED 亮度连续变化、不闪烁（仿真里用逻辑分析仪看 PA1 波形更直观）</p>
 </li>
</ul>
<h2 style="" id="%E6%A6%82%E5%BF%B5%EF%BC%9A%E5%91%BC%E5%90%B8%E7%81%AF%E6%98%AF%E6%80%8E%E4%B9%88%E2%80%9C%E9%AA%97%E2%80%9D%E8%BF%87%E7%9C%BC%E7%9D%9B%E7%9A%84">概念：呼吸灯是怎么“骗”过眼睛的</h2>
<p style="">LED 其实是在<strong>快速开关</strong>，只是开关太快人眼看不出来。亮的时间占一个周期的比例叫<strong>占空比</strong>：占空比 0% = 全灭，100% = 全亮，50% = 半亮。呼吸灯就是让占空比从 0 慢慢加到 100%，再慢慢减回 0%。</p>
<ul>
 <li>
  <p style=""><code>ARR</code>（Period）决定周期：一个周期数多少个计数</p>
 </li>
 <li>
  <p style=""><code>CCR</code>（Pulse）决定占空比：一个周期里高电平多少个计数</p>
 </li>
 <li>
  <p style="">在 8MHz、PSC=79、ARR=999 下，PWM 频率 = 8M÷80÷1000 = <strong>100Hz</strong>，人眼完全看不出闪</p>
 </li>
</ul>
<div style="overflow-x: auto; overflow-y: hidden;">
 <table style="width: 200px">
  <colgroup>
   <col style="width: 100px">
   <col style="width: 100px">
  </colgroup>
  <tbody>
   <tr style="height: 60px;">
    <th colspan="1" rowspan="1" colwidth="100">
     <p style="">频率</p>
    </th>
    <th colspan="1" rowspan="1" colwidth="100">
     <p style="">周期</p>
    </th>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">1kHz</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">1ms</p>
    </td>
   </tr>
   <tr style="height: 59px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><strong>10kHz</strong></p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><strong>100µs</strong></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">100kHz</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">10µs</p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">1MHz</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">1µs</p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">1000Hz = 1kHz</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">1ms</p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<p style=""></p>]]></description><guid isPermaLink="false">/archives/1786503239668</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Wed, 12 Aug 2026 02:54:06 GMT</pubDate></item><item><title><![CDATA[定时器中断]]></title><link>https://www.flor.cn/archives/1786500625933</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=%E5%AE%9A%E6%97%B6%E5%99%A8%E4%B8%AD%E6%96%AD&amp;url=/archives/1786500625933" width="1" height="1" alt="" style="opacity:0;">
<p style=""></p>
<div style="overflow-x: auto; overflow-y: hidden;">
 <table style="width: 889px">
  <colgroup>
   <col style="width: 100px">
   <col style="width: 437px">
   <col style="width: 352px">
  </colgroup>
  <tbody>
   <tr style="height: 60px;">
    <th colspan="1" rowspan="1" colwidth="100">
     <p style="">函数</p>
    </th>
    <th colspan="1" rowspan="1" colwidth="437">
     <p style="">拆解</p>
    </th>
    <th colspan="1" rowspan="1" colwidth="352">
     <p style="">意思</p>
    </th>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>HAL_TIM_Base_Start_IT</code></p>
    </td>
    <td colspan="1" rowspan="1" colwidth="437">
     <p style="">Base=基础、Start=启动、IT=Interrupt(中断)</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="352">
     <p style=""><strong>启动定时器基础计时功能（中断方式）</strong></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>TIM2_IRQHandler</code></p>
    </td>
    <td colspan="1" rowspan="1" colwidth="437">
     <p style="">TIM2 + IRQ(中断请求) + Handler(处理函数)</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="352">
     <p style=""><strong>定时器2的中断服务函数（硬件入口）</strong></p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style=""><code>HAL_TIM_PeriodElapsedCallback</code></p>
    </td>
    <td colspan="1" rowspan="1" colwidth="437">
     <p style="">Period=周期、Elapsed=到点/流逝、Callback=回调</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="352">
     <p style=""><strong>一个周期到了的回调（你的代码）</strong></p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<p style=""><strong>② 三段式记忆（和 EXTI 完全同构）：</strong></p>
<pre><code>定时器版：  TIM2_IRQHandler → HAL_TIM_IRQHandler → HAL_TIM_PeriodElapsedCallback
按键版：    EXTI15_10_IRQHandler → HAL_GPIO_EXTI_IRQHandler → HAL_GPIO_EXTI_Callback
结构：      硬件入口 → HAL 中转 → 你的回调</code></pre>
<p style="">记住一个模板，两个外设都通：</p>
<blockquote>
 <p style=""><code>xxx_IRQHandler</code><strong> 是入口（Cube 生成），</strong><code>HAL_xxx_IRQHandler</code><strong> 是公共处理（清标志），</strong><code>HAL_xxx_Callback</code><strong> 是你干活的地方。</strong></p>
</blockquote>
<p style=""><strong>③ 几个容易混的点：</strong></p>
<ul>
 <li>
  <p style=""><strong>Base</strong>：表示"基础计时"，只数数不用 PWM 通道；以后学 PWM 是 <code>HAL_TIM_PWM_Start</code>；</p>
 </li>
 <li>
  <p style=""><strong>IT</strong>：Interrupt 的缩写，代表"中断方式"。还有不带 IT 的版本：</p>
  <ul>
   <li>
    <p style=""><code>HAL_TIM_Base_Start()</code> = 只启动计数，不产生中断（轮询用）；</p>
   </li>
   <li>
    <p style=""><code>HAL_TIM_Base_Start_IT()</code> = 启动计数 + 开中断；</p>
   </li>
  </ul>
 </li>
 <li>
  <p style=""><strong>PeriodElapsed</strong> = "周期流逝/到点"——定时器数满一圈那一刻。以后串口还有 <code>HAL_UART_RxCpltCallback</code>（接收完成），都是"事件 + Callback"。</p>
 </li>
</ul>
<p style=""><strong>④ 一句话口诀：</strong></p>
<blockquote>
 <p style="">启动用 <strong>Start_IT</strong>，入口找 <strong>IRQHandler</strong>，干活进 <strong>PeriodElapsedCallback</strong>；
  <br>
  定时器 = TIM + 事件名 + Callback，和按键中断同一套骨架。</p>
</blockquote>
<p style=""></p>]]></description><guid isPermaLink="false">/archives/1786500625933</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Wed, 12 Aug 2026 02:10:38 GMT</pubDate></item><item><title><![CDATA[外设中断]]></title><link>https://www.flor.cn/archives/1786182141281</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=%E5%A4%96%E8%AE%BE%E4%B8%AD%E6%96%AD&amp;url=/archives/1786182141281" width="1" height="1" alt="" style="opacity:0;">
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fdata%2Fphoto%2Fimage-ohDm.png&amp;size=m" width="2255px">
</figure>
<p style=""></p>]]></description><guid isPermaLink="false">/archives/1786182141281</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Sat, 8 Aug 2026 09:42:28 GMT</pubDate></item><item><title><![CDATA[时钟树]]></title><link>https://www.flor.cn/archives/1786181649223</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=%E6%97%B6%E9%92%9F%E6%A0%91&amp;url=/archives/1786181649223" width="1" height="1" alt="" style="opacity:0;">
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fdata%2Fphoto%2Fimage-OCAF.png&amp;size=m" width="1053px">
</figure>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fdata%2Fphoto%2Fimage-IIVm.png&amp;size=m" width="1625px">
</figure>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fdata%2Fphoto%2Fimage-VZAe.png&amp;size=m" width="2255px">
</figure>
<p style=""></p>]]></description><guid isPermaLink="false">/archives/1786181649223</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Sat, 8 Aug 2026 09:38:37 GMT</pubDate></item><item><title><![CDATA[io模式]]></title><link>https://www.flor.cn/archives/1786180656285</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=io%E6%A8%A1%E5%BC%8F&amp;url=/archives/1786180656285" width="1" height="1" alt="" style="opacity:0;">
<div style="overflow-x: auto; overflow-y: hidden;">
 <table style="width: 769px">
  <colgroup>
   <col style="width: 100px">
   <col style="width: 100px">
   <col style="width: 100px">
   <col style="width: 212px">
   <col style="width: 257px">
  </colgroup>
  <tbody>
   <tr style="height: 60px;">
    <th colspan="1" rowspan="1" colwidth="100">
     <p style="">#</p>
    </th>
    <th colspan="1" rowspan="1" colwidth="100">
     <p style="">MODE</p>
    </th>
    <th colspan="1" rowspan="1" colwidth="100">
     <p style="">CNF</p>
    </th>
    <th colspan="1" rowspan="1" colwidth="212">
     <p style="">模式名称</p>
    </th>
    <th colspan="1" rowspan="1" colwidth="257">
     <p style="">典型用途</p>
    </th>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">1</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">00</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">00</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="212">
     <p style="">模拟输入 Analog</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="257">
     <p style="">ADC 采集、RTC</p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">2</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">00</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">01</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="212">
     <p style="">浮空输入 Floating</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="257">
     <p style="">临时悬空（少用，易受干扰）</p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">3</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">00</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">10</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="212">
     <p style="">上拉输入 Pull-Up</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="257">
     <p style="">按键（默认高电平）</p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">4</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">00</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">11</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="212">
     <p style="">下拉输入 Pull-Down</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="257">
     <p style="">按键（默认低电平）</p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">5</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">10/11</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">00</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="212">
     <p style="">推挽输出 Push-Pull</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="257">
     <p style="">LED、普通驱动（本板 LED）</p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">6</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">10/11</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">01</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="212">
     <p style="">开漏输出 Open-Drain</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="257">
     <p style="">I2C、线与</p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">7</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">10/11</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">10</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="212">
     <p style="">复用推挽 AF Push-Pull</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="257">
     <p style="">USART_TX、SPI_MOSI</p>
    </td>
   </tr>
   <tr style="height: 60px;">
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">8</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">10/11</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="100">
     <p style="">11</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="212">
     <p style="">复用开漏 AF Open-Drain</p>
    </td>
    <td colspan="1" rowspan="1" colwidth="257">
     <p style="">I2C_SCL/SDA</p>
    </td>
   </tr>
  </tbody>
 </table>
</div>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fimage-xwRL.png&amp;size=m" width="2255px">
</figure>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fimage-DBId.png&amp;size=m" width="2255px">
</figure>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fimage-LYFU.png&amp;size=m" width="2255px">
</figure>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fimage-fbeD.png&amp;size=m">
</figure>
<p style=""></p>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fdata%2Fphoto%2Fimage.png&amp;size=m" width="2255px">
</figure>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fdata%2Fphoto%2Fimage-RErv.png&amp;size=m" width="2255px">
</figure>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fdata%2Fphoto%2Fimage-GHHI.png&amp;size=m" width="2255px">
</figure>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fdata%2Fphoto%2Fimage-fhQU.png&amp;size=m" width="2255px">
</figure>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fdata%2Fphoto%2Fimage-yzdZ.png&amp;size=m" width="2255px">
</figure>
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fdata%2Fphoto%2Fimage-BYUC.png&amp;size=m" width="2255px">
</figure>
<p style=""></p>]]></description><guid isPermaLink="false">/archives/1786180656285</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Sat, 8 Aug 2026 09:17:45 GMT</pubDate></item><item><title><![CDATA[外设io组]]></title><link>https://www.flor.cn/archives/1786180562626</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=%E5%A4%96%E8%AE%BEio%E7%BB%84&amp;url=/archives/1786180562626" width="1" height="1" alt="" style="opacity:0;">
<figure style="align-items: start; display: flex; flex-direction: column" data-content-type="image">
 <img src="https://www.flor.cn/apis/api.storage.halo.run/v1alpha1/thumbnails/-/via-uri?uri=%2Fupload%2Fimage.png&amp;size=m" width="2194px">
</figure>
<p style=""></p>]]></description><guid isPermaLink="false">/archives/1786180562626</guid><dc:creator>flor</dc:creator><category>stm32</category><pubDate>Sat, 8 Aug 2026 09:16:48 GMT</pubDate></item><item><title><![CDATA[矩阵按钮控制数码显示]]></title><link>https://www.flor.cn/archives/1780497801022</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=%E7%9F%A9%E9%98%B5%E6%8C%89%E9%92%AE%E6%8E%A7%E5%88%B6%E6%95%B0%E7%A0%81%E6%98%BE%E7%A4%BA&amp;url=/archives/1780497801022" width="1" height="1" alt="" style="opacity:0;">
<div class="markdown-edited">
 <h1 id="矩阵按键--数码管倒计时系统--使用手册">矩阵按键 + 数码管倒计时系统 — 使用手册</h1>
 <blockquote>
  <p>芯片: STC8A8K64D4 @ 24MHz | 函数库: ESD_STC8 V3.0</p>
 </blockquote>
 <hr>
 <h2 id="一全局变量">一、全局变量</h2>
 <table>
  <thead>
   <tr>
    <th>变量名</th>
    <th>类型</th>
    <th>说明</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td><code>gear</code></td>
    <td>uchar</td>
    <td>挡位，范围 1~9，默认 1</td>
   </tr>
   <tr>
    <td><code>time_set</code></td>
    <td>uchar</td>
    <td>倒计时预设秒数，范围 1~99，默认 30</td>
   </tr>
   <tr>
    <td><code>time_disp</code></td>
    <td>uchar</td>
    <td>数码管当前显示的值（设置态 = time_set，运行态 = time_left）</td>
   </tr>
   <tr>
    <td><code>running</code></td>
    <td>uchar</td>
    <td>系统状态：0=设置模式，1=倒计时运行中</td>
   </tr>
   <tr>
    <td><code>time_left</code></td>
    <td>uchar</td>
    <td>倒计时剩余秒数，运行态时逐秒减 1</td>
   </tr>
   <tr>
    <td><code>ms_cnt</code></td>
    <td>uint</td>
    <td>毫秒累加器，每 1ms 加 1，满 1000 进 1 秒</td>
   </tr>
  </tbody>
 </table>
 <p><strong>变量关系图：</strong></p>
 <pre><code>设置态 (running=0)              运行态 (running=1)
┌─────────────────┐            ┌─────────────────┐
│ time_set = 30   │──K5启动→  │ time_left = 30   │
│                 │            │   ↓ 每秒减1     │
│ time_disp = 30  │            │ time_left = 29   │
│                 │            │   ↓              │
│ 按键可调        │            │ time_disp = 29   │
│ gear 1~9       │            │   ↓              │
│ time_set 1~99  │            │ ...到0           │
│                 │←─到0自动回─│ running变回0     │
└─────────────────┘            └─────────────────┘
</code></pre>
 <hr>
 <h2 id="二自定义函数">二、自定义函数</h2>
 <h3 id="函数1scan_keys">函数1：<code>Scan_Keys()</code></h3>
 <pre><code>原型:    uchar Scan_Keys(void)
参数:    无
返回:    0 = 无按键
        1 = K1 按下（挡位+）
        2 = K2 按下（挡位-）
        3 = K3 按下（时间+）
        4 = K4 按下（时间-）
        5 = K5 按下（启动）
</code></pre>
 <p><strong>原理：矩阵扫描法，2 行 × 3 列，分两轮检测</strong></p>
 <ul>
  <li><strong>第①轮</strong>：P16=0（选中行1）→ 读 P14 查 K1，读 P15 查 K2</li>
  <li><strong>第②轮</strong>：P17=0（选中行2）→ 读 P14 查 K3，读 P15 查 K4，读 P35 查 K5</li>
 </ul>
 <p>每次只激活一行，被激活行的"强 0"才能把列线拉低；另一行输出"强 1"，列线不受影响。</p>
 <p><strong>坐标系理解：</strong></p>
 <ul>
  <li>Y 轴（行扫描）：P16 / P17 轮流输出强 0</li>
  <li>X 轴（列检测）：P14 / P15 / P35 默认弱 1，被拉低 = 按键按下</li>
 </ul>
 <pre><code>第1步：Y=P16=0 → 查 X 轴 → 找到第1行的按键（K1 或 K2）
第2步：Y=P17=0 → 查 X 轴 → 找到第2行的按键（K3 或 K4 或 K5）
</code></pre>
 <blockquote>
  <p><strong>注意</strong>：如果同时按下多个键，函数只返回后检测到的那一个。</p>
 </blockquote>
 <hr>
 <h3 id="函数2tm0_isr">函数2：<code>TM0_isr()</code></h3>
 <pre><code>原型:    void TM0_isr(void)
调用者:  isr.c 中的 TM0_I() interrupt 1（每 1ms 自动调用）
参数:    无
返回:    无
</code></pre>
 <h4 id="功能-a数码管动态扫描333hz-无闪烁刷新">功能 A：数码管动态扫描（333Hz 无闪烁刷新）</h4>
 <p><strong>流程：</strong></p>
 <ol>
  <li><strong>消隐</strong>：关闭所有位选（P36=P37=P33=0），关闭所有段选（P2.0~P2.7 全写 1 → 全灭）</li>
  <li><strong>轮流亮一位</strong>：
   <ul>
    <li><code>pos=0</code>：送 gear 的段码到 P2 → 开 P36（第 1 位亮）</li>
    <li><code>pos=1</code>：送 time_disp 十位的段码 → 开 P37（第 2 位亮）</li>
    <li><code>pos=2</code>：送 time_disp 个位的段码 → 开 P33（第 3 位亮）</li>
   </ul></li>
  <li><strong>pos 循环</strong>：0 → 1 → 2 → 0 ...</li>
 </ol>
 <p><strong>时间轴：</strong></p>
 <pre><code>t=0ms   消隐 → 亮第1位（挡位）      pos=1
t=1ms   消隐 → 亮第2位（秒十位）    pos=2
t=2ms   消隐 → 亮第3位（秒个位）    pos=0
t=3ms   循环 ...

每位亮 1ms，3 位一轮 = 3ms，刷新率 333Hz
</code></pre>
 <h4 id="功能-b倒计时仅-running1-时执行">功能 B：倒计时（仅 running==1 时执行）</h4>
 <ol>
  <li><code>ms_cnt</code> 每 1ms 加 1</li>
  <li><code>ms_cnt</code> 满 1000 → 经过了 1 秒</li>
  <li><code>time_left</code> 减 1</li>
  <li><code>time_left</code> 到 0 → running 置 0，LED 灭（<code>Out_IO(10,1)</code>）</li>
 </ol>
 <hr>
 <h2 id="三用到的库函数esd_stc8-v30">三、用到的库函数（ESD_STC8 V3.0）</h2>
 <table>
  <thead>
   <tr>
    <th>库函数</th>
    <th>说明</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td><code>GPIO_init_allpin(mode)</code></td>
    <td>初始化所有 IO，mode: 0=准双向 1=推挽 2=高阻 3=开漏</td>
   </tr>
   <tr>
    <td><code>GPIO_init_8pin(port, mode)</code></td>
    <td>初始化整组 IO，port: 0=P0 2=P2...</td>
   </tr>
   <tr>
    <td><code>GPIO_init_pin(pin, mode)</code></td>
    <td>初始化单个 IO，pin: 36=P3.6 14=P1.4...</td>
   </tr>
   <tr>
    <td><code>Out_IO(pin, val)</code></td>
    <td>设置 IO 输出，val: 0=低电平 1=高电平</td>
   </tr>
   <tr>
    <td><code>Get_IO(pin)</code></td>
    <td>读取 IO 输入，返回 bit（0 或 1）</td>
   </tr>
   <tr>
    <td><code>PIT_init_ms(n, t)</code></td>
    <td>启动定时器中断，n: 0<del>4，t: 1</del>32ms（24MHz）</td>
   </tr>
   <tr>
    <td><code>Delay_X_mS(x)</code></td>
    <td>毫秒延时，x: 1~1000</td>
   </tr>
   <tr>
    <td><code>Delay_X_uS(x)</code></td>
    <td>微秒延时，x: 1~1000</td>
   </tr>
  </tbody>
 </table>
 <p><strong>引脚编号规则</strong>：两位数字，十位 = 端口号，个位 = 引脚号</p>
 <blockquote>
  <p><code>14</code> = P1^4 / <code>16</code> = P1^6 / <code>20</code> = P2^0 / <code>27</code> = P2^7 <code>33</code> = P3^3 / <code>35</code> = P3^5 / <code>36</code> = P3^6 / <code>37</code> = P3^7</p>
 </blockquote>
 <hr>
 <h2 id="四段码表">四、段码表</h2>
 <p>P2 口位：<code>.7=DP .6=G .5=F .4=E .3=D .2=C .1=B .0=A</code></p>
 <p>共阳规则：<strong>0 = 亮，1 = 灭</strong></p>
 <table>
  <thead>
   <tr>
    <th align="center">数字</th>
    <th align="center">十六进制</th>
    <th>亮的段</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td align="center">0</td>
    <td align="center"><code>0xC0</code></td>
    <td>A B C D E F</td>
   </tr>
   <tr>
    <td align="center">1</td>
    <td align="center"><code>0xF9</code></td>
    <td>B C</td>
   </tr>
   <tr>
    <td align="center">2</td>
    <td align="center"><code>0xA4</code></td>
    <td>A B D E G</td>
   </tr>
   <tr>
    <td align="center">3</td>
    <td align="center"><code>0xB0</code></td>
    <td>A B C D G</td>
   </tr>
   <tr>
    <td align="center">4</td>
    <td align="center"><code>0x99</code></td>
    <td>B C F G</td>
   </tr>
   <tr>
    <td align="center">5</td>
    <td align="center"><code>0x92</code></td>
    <td>A C D F G</td>
   </tr>
   <tr>
    <td align="center">6</td>
    <td align="center"><code>0x82</code></td>
    <td>A C D E F G</td>
   </tr>
   <tr>
    <td align="center">7</td>
    <td align="center"><code>0xF8</code></td>
    <td>A B C</td>
   </tr>
   <tr>
    <td align="center">8</td>
    <td align="center"><code>0x80</code></td>
    <td>A B C D E F G（全部）</td>
   </tr>
   <tr>
    <td align="center">9</td>
    <td align="center"><code>0x90</code></td>
    <td>A B C D F G</td>
   </tr>
  </tbody>
 </table>
 <hr>
 <h2 id="五硬件接线速查表">五、硬件接线速查表</h2>
 <h3 id="按键矩阵2-行--3-列">按键矩阵（2 行 × 3 列）</h3>
 <table>
  <thead>
   <tr>
    <th></th>
    <th align="center">P14（列1）</th>
    <th align="center">P15（列2）</th>
    <th align="center">P35（列3）</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td><strong>P16（行1）</strong></td>
    <td align="center">K1 挡位+</td>
    <td align="center">K2 挡位-</td>
    <td align="center">（空）</td>
   </tr>
   <tr>
    <td><strong>P17（行2）</strong></td>
    <td align="center">K3 时间+</td>
    <td align="center">K4 时间-</td>
    <td align="center">K5 启动</td>
   </tr>
  </tbody>
 </table>
 <p><strong>省线技巧</strong>：K1、K2 朝向 P16 那端焊在一起，共用一根飞线到 P16；K3、K4、K5 朝向 P17 那端焊在一起，共用一根飞线到 P17。</p>
 <p><strong>电阻</strong>：不需要外部电阻（代码用内部上拉）。</p>
 <hr>
 <h3 id="数码管-5641bs共阳极12-脚">数码管 5641BS（共阳极，12 脚）</h3>
 <p><strong>段码</strong>（各串 100Ω 电阻）：</p>
 <table>
  <thead>
   <tr>
    <th align="center">P2 脚</th>
    <th align="center">数码管脚</th>
    <th align="center">段名</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td align="center">P2.0</td>
    <td align="center">脚11</td>
    <td align="center">A</td>
   </tr>
   <tr>
    <td align="center">P2.1</td>
    <td align="center">脚7</td>
    <td align="center">B</td>
   </tr>
   <tr>
    <td align="center">P2.2</td>
    <td align="center">脚4</td>
    <td align="center">C</td>
   </tr>
   <tr>
    <td align="center">P2.3</td>
    <td align="center">脚2</td>
    <td align="center">D</td>
   </tr>
   <tr>
    <td align="center">P2.4</td>
    <td align="center">脚1</td>
    <td align="center">E</td>
   </tr>
   <tr>
    <td align="center">P2.5</td>
    <td align="center">脚10</td>
    <td align="center">F</td>
   </tr>
   <tr>
    <td align="center">P2.6</td>
    <td align="center">脚5</td>
    <td align="center">G</td>
   </tr>
   <tr>
    <td align="center">P2.7</td>
    <td align="center">脚3</td>
    <td align="center">DP</td>
   </tr>
  </tbody>
 </table>
 <p><strong>位选</strong>：</p>
 <table>
  <thead>
   <tr>
    <th align="center">单片机脚</th>
    <th align="center">数码管脚</th>
    <th>说明</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td align="center">P3.6</td>
    <td align="center">脚12</td>
    <td>第1位 = 挡位显示</td>
   </tr>
   <tr>
    <td align="center">P3.7</td>
    <td align="center">脚9</td>
    <td>第2位 = 秒十位</td>
   </tr>
   <tr>
    <td align="center">P3.3</td>
    <td align="center">脚8</td>
    <td>第3位 = 秒个位</td>
   </tr>
   <tr>
    <td align="center">—</td>
    <td align="center">脚6</td>
    <td>悬空（第4位不用）</td>
   </tr>
  </tbody>
 </table>
 <hr>
 <h3 id="led-指示灯">LED 指示灯</h3>
 <pre><code>VCC(5V) → [1KΩ电阻] → LED正极（长脚） → LED负极（短脚） → P1.0
</code></pre>
 <ul>
  <li>P1.0 = 高电平（5V）：LED 两端无压差 → <strong>灭</strong></li>
  <li>P1.0 = 低电平（0V）：电流从 VCC 流向 P1.0 → <strong>亮</strong></li>
 </ul>
 <hr>
 <h2 id="六程序执行流程">六、程序执行流程</h2>
 <h3 id="主循环后台">主循环（后台）</h3>
 <pre><code>main()
  │
  ├→ GPIO 配置：全部准双向 → P2/位选/行扫/LED 改推挽
  ├→ LED 初始灭：Out_IO(10,1)
  ├→ 定时器启动：PIT_init_ms(0,1)  每 1ms 进中断
  │
  └→ while(1):
       │
       ├→ ① time_disp = running ? time_left : time_set
       │     更新数码管数据源
       │
       ├→ ② key = Scan_Keys()  扫描按键
       │     if (有键) {
       │       Delay_15ms 防抖
       │       再次确认
       │       if (!locked) {
       │         locked = 1
       │         if (!running) {   // 设置态才处理
       │           key=1 → gear++
       │           key=2 → gear--
       │           key=3 → time_set++
       │           key=4 → time_set--
       │           key=5 → 启动倒计时
       │         }
       │       }
       │     } else {
       │       locked = 0   // 松手解锁
       │     }
       │
       └──→ 循环
</code></pre>
 <h3 id="定时器中断前台每-1ms">定时器中断（前台，每 1ms）</h3>
 <pre><code>TM0_isr()
  │
  ├→ 消隐：关位选 + 关段选
  │
  ├→ 轮流亮位：
  │   pos=0: Seg[gear]       → P2口 → P36=1
  │   pos=1: Seg[time_disp十位] → P2口 → P37=1
  │   pos=2: Seg[time_disp个位] → P2口 → P33=1
  │
  └→ 倒计时（running==1 时）：
       ms_cnt++
       ms_cnt 到 1000 → time_left--
       time_left 到 0 → running=0, LED灭
</code></pre>
 <blockquote>
  <p><strong>主循环和中断通过全局变量 <code>gear</code> / <code>time_disp</code> / <code>running</code> / <code>time_left</code> 通信，彼此独立运行。</strong></p>
 </blockquote>
 <hr>
 <h2 id="七按键防抖机制">七、按键防抖机制</h2>
 <p><strong>问题</strong>：机械按键按下/松开的瞬间，金属弹片反复弹跳，产生一串极短的通→断→通→断脉冲（持续约 5~15ms）。</p>
 <p><strong>时间轴：</strong></p>
 <pre><code>按下瞬间  ├── 抖动区（~15ms）──┤  稳定按住
电平: 1   │ 1 0 1 0 1 0 1 0 1  │  0 0 0 0 0
          ↑                    ↑
     第1次读数              第2次读数
     (可能是1)              (稳定是0)
     
     两次一致 → 确认有效 ✓
</code></pre>
 <h3 id="防长按locked-标志位">防长按（locked 标志位）</h3>
 <table>
  <thead>
   <tr>
    <th align="center">循环</th>
    <th align="center">locked</th>
    <th>动作</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td align="center">第1次</td>
    <td align="center">0</td>
    <td>执行功能 → locked=1</td>
   </tr>
   <tr>
    <td align="center">第2次</td>
    <td align="center">1</td>
    <td>跳过（按住不放）</td>
   </tr>
   <tr>
    <td align="center">第N次</td>
    <td align="center">1</td>
    <td>跳过</td>
   </tr>
   <tr>
    <td align="center">松手</td>
    <td align="center">—</td>
    <td>key=0 → locked=0</td>
   </tr>
   <tr>
    <td align="center">再按</td>
    <td align="center">0</td>
    <td>可以再次执行</td>
   </tr>
  </tbody>
 </table>
 <blockquote>
  <p><strong>效果</strong>：按一次只触发一次，按住不放不会连续触发。</p>
 </blockquote>
 <hr>
 <h2 id="八常见问题排查">八、常见问题排查</h2>
 <p><strong>Q：数码管不亮？</strong></p>
 <ol>
  <li>检查共阳脚是否接对（脚 12、9、8）</li>
  <li>段码电阻是否每根都接了</li>
  <li>万用表测 P36/P37/P33 有无轮流出现高电平</li>
  <li>确认 5641BS 是共阳型（不是共阴）</li>
 </ol>
 <p><strong>Q：数码管显示乱码？</strong></p>
 <p>段码脚位和代码不匹配。用 5V → 100Ω 逐个碰段脚，确认 A/B/C/D/E/F/G/DP 对应哪个脚，调整 P2 接线。</p>
 <p><strong>Q：按一下数字跳了好几次？</strong></p>
 <p>按键接触不良或抖动没消干净。</p>
 <ol>
  <li>杜邦线插紧</li>
  <li><code>Delay_X_mS(15)</code> 可以改大到 20~30</li>
 </ol>
 <p><strong>Q：按了按键没反应？</strong></p>
 <ol>
  <li>检查按键矩阵接线是否正确（特别注意共用的行线）</li>
  <li>万用表测按下去是否导通</li>
  <li>检查 <code>running</code> 是否为 0（运行态按键被屏蔽）</li>
 </ol>
 <p><strong>Q：倒计时不准？</strong></p>
 <p>确认 <code>config.h</code> 中的 <code>MAIN_Fosc</code> 和 ISP 下载软件中设置的主频一致。</p>
 <p><strong>Q：编译有 MULTIPLE CALL 警告？</strong></p>
 <p>正常，不影响运行。<code>Out_IO</code> 在主循环和 ISR 中都被调用，Keil 提醒可能重入，但 <code>Out_IO</code> 内部无静态变量，实际不会出错。</p>
 <p><strong>Q：LED 不亮？</strong></p>
 <ol>
  <li>检查正负极是否接反（长脚 → 电阻 → VCC，短脚 → P1.0）</li>
  <li>检查 running=1 时 P1.0 是否为低电平</li>
 </ol>
</div>
<p style=""></p>]]></description><guid isPermaLink="false">/archives/1780497801022</guid><dc:creator>flor</dc:creator><category>8051</category><pubDate>Wed, 3 Jun 2026 14:43:25 GMT</pubDate></item><item><title><![CDATA[串口屏多档位舵机控制]]></title><link>https://www.flor.cn/archives/1779945886904</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=%E4%B8%B2%E5%8F%A3%E5%B1%8F%E5%A4%9A%E6%A1%A3%E4%BD%8D%E8%88%B5%E6%9C%BA%E6%8E%A7%E5%88%B6&amp;url=/archives/1779945886904" width="1" height="1" alt="" style="opacity:0;">
<div class="markdown-edited">
 <h1 id="串口屏多档位舵机控制--从-main-函数开始的完整代码解读">串口屏多档位舵机控制 — 从 main 函数开始的完整代码解读</h1>
 <blockquote>
  <p>日期：2026-05-28
   <br>
   项目：串口屏 + 舵机，触摸按钮控制四档往复扫动
   <br>
   MCU：STC8A8K64D4 @ 24MHz | 屏幕：迪文 DGUS T5L | 舵机：SG90（外部 5V 供电）
   <br>
   工程路径：<code>功能实现/</code></p>
 </blockquote>
 <hr>
 <p>当 CPU 上电复位后，STARTUP.A51 最先执行——它清零内部 RAM、初始化堆栈指针，最后 <code>LJMP ?C_START</code> 跳转到 C 运行环境，进入 <code>main()</code>。</p>
 <p>这就是我们故事的起点。</p>
 <hr>
 <h2 id="目录">目录</h2>
 <ol>
  <li><a href="https://www.flor.cn#1-%E5%B7%A5%E7%A8%8B%E6%A6%82%E8%A7%88">工程概览</a></li>
  <li><a href="https://www.flor.cn#2-configh--main-%E7%9C%8B%E5%88%B0%E7%9A%84%E7%AC%AC%E4%B8%80%E4%BB%BD%E8%93%9D%E5%9B%BE">config.h — main() 看到的第一份蓝图</a></li>
  <li><a href="https://www.flor.cn#3-main-%E5%88%9D%E5%A7%8B%E5%8C%96--%E5%85%AD%E6%AD%A5%E8%AE%A9%E7%A1%AC%E4%BB%B6%E5%B0%B1%E7%BB%AA">main() 初始化 — 六步让硬件就绪</a></li>
  <li><a href="https://www.flor.cn#4-uart2-%E6%95%B0%E6%8D%AE%E6%8E%A5%E6%94%B6--%E4%B8%AD%E6%96%AD%E4%B8%8E%E4%B8%BB%E5%BE%AA%E7%8E%AF%E7%9A%84%E9%85%8D%E5%90%88">UART2 数据接收 — 中断与主循环的配合</a></li>
  <li><a href="https://www.flor.cn#5-%E5%B8%A7%E8%A7%A3%E6%9E%90%E7%8A%B6%E6%80%81%E6%9C%BA--%E4%BB%8E%E5%AD%97%E8%8A%82%E6%B5%81%E4%B8%AD%E6%8F%90%E5%8F%96%E5%AE%8C%E6%95%B4%E5%B8%A7">帧解析状态机 — 从字节流中提取完整帧</a></li>
  <li><a href="https://www.flor.cn#6-%E8%88%B5%E6%9C%BA%E6%89%AB%E5%8A%A8%E5%BE%AA%E7%8E%AF--%E5%BE%80%E5%A4%8D%E8%BF%90%E5%8A%A8%E7%9A%84%E8%BD%AF%E4%BB%B6%E5%AE%9E%E7%8E%B0">舵机扫动循环 — 往复运动的软件实现</a></li>
  <li><a href="https://www.flor.cn#7-dgusc--%E5%B1%8F%E5%B9%95%E9%80%9A%E4%BF%A1%E5%9B%9B%E5%87%BD%E6%95%B0">dgus.c — 屏幕通信四函数</a></li>
  <li><a href="https://www.flor.cn#8-%E5%AE%8C%E6%95%B4%E6%89%A7%E8%A1%8C%E6%B5%81--%E4%BB%8E%E8%A7%A6%E6%91%B8%E5%88%B0%E8%88%B5%E6%9C%BA%E5%93%8D%E5%BA%94">完整执行流 — 从触摸到舵机响应</a></li>
  <li><a href="https://www.flor.cn#9-%E9%99%84%E5%BD%95">附录</a></li>
 </ol>
 <hr>
 <h2 id="1-工程概览">1. 工程概览</h2>
 <pre><code>功能实现/
├── main.c          ★ 业务主程序：初始化 + 帧解析 + 舵机扫动         (3435 B)
├── dgus.c          ★ DGUS 通信模块：档位切换 + 写变量 + LED 控制   (1347 B)
├── dgus.h          模块接口声明                                    (265 B)
├── config.h        全局配置：主频、外设头文件、类型宏               (2550 B)
├── isr.c           中断向量表（21 个中断入口，UART2 中断转发）      (9793 B)
├── isr.h           中断处理函数 extern 声明                         (1208 B)
├── inisr.asm       扩展中断向量跳转表                              (2217 B)
├── STARTUP.A51     Keil 启动文件                                   (6376 B)
├── STC8.uvproj     Keil 工程描述 (XML, 含编译选项和文件分组)        (17793 B)
│
└── ESD-库/         底层驱动库（.obj 预编译）
    ├── GPIO.h/obj  GPIO 初始化和引脚操作
    ├── UART.h/obj  串口初始化和字节发送
    ├── PWM.h/obj   PWM 初始化和占空比更新
    └── ...         其他模块（delay, ADC, EEPROM 等）
</code></pre>
 <p><strong>编译配置</strong>（来自 STC8.uvproj）：</p>
 <table>
  <thead>
   <tr>
    <th>项</th>
    <th>值</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td>器件</td>
    <td>STC8A8K64D4 Series</td>
   </tr>
   <tr>
    <td>主频</td>
    <td>35MHz（编译设置）/ 24MHz（代码中实际使用）</td>
   </tr>
   <tr>
    <td>存储模型</td>
    <td>Large（变量默认在 XDATA）</td>
   </tr>
   <tr>
    <td>优化级别</td>
    <td>8（最高，侧重速度）</td>
   </tr>
   <tr>
    <td>链接选项</td>
    <td>REMOVEUNUSED（移除未用代码段）</td>
   </tr>
  </tbody>
 </table>
 <p><strong>Keil 工程文件分组</strong>：</p>
 <ul>
  <li><strong>User 组</strong>：STARTUP.A51, main.c, isr.c, inisr.asm — 用户代码</li>
  <li><strong>ESD 组</strong>：GPIO, delay, PWM, UART 等 12 个库模块 — 外设驱动</li>
  <li><strong>dgus 组</strong>：dgus.c, dgus.h — 屏幕通信</li>
 </ul>
 <hr>
 <h2 id="2-configh--main-看到的第一份蓝图">2. config.h — main() 看到的第一份蓝图</h2>
 <p><code>main.c</code> 的第一行：</p>
 <pre><code class="language-c">#include "config.h"
</code></pre>
 <p>编译时 config.h 先展开，铺好整个工程的基础设施。</p>
 <h3 id="21-主频选择">2.1 主频选择</h3>
 <pre><code class="language-c">#define MAIN_Fosc  24000000L    // 主频 24MHz
</code></pre>
 <p>STC8A8K64D4 最高可跑 35MHz，这里稳定选用 24MHz。这个值被 PWM、UART、delay 等所有外设库引用——改这一个数字，所有需要时钟计算的地方自动适配。</p>
 <p>附带几个便捷宏：</p>
 <pre><code class="language-c">#define Main_Fosc2   (MAIN_Fosc / 2)      // 12MHz
#define Main_Fosc_KHZ (MAIN_Fosc / 1000)   // 24000 KHz
#define Main_Fosc_MHZ (MAIN_Fosc / 1000000) // 24 MHz
</code></pre>
 <h3 id="22-类型别名">2.2 类型别名</h3>
 <pre><code class="language-c">#define uchar  unsigned char       // 8 位,  范围 0~255
#define uint   unsigned int        // 16 位, 范围 0~65535
#define u16    unsigned int
#define u32    unsigned long       // 32 位, 范围 0~4294967295
</code></pre>
 <p>8051 中 <code>int</code> 是 16 位的。这些别名让变量声明更紧凑。</p>
 <h3 id="23-外设模块引用">2.3 外设模块引用</h3>
 <pre><code class="language-c">#include "GPIO.h"    // GPIO_init_allpin, GPIO_init_pin, Out_IO, Get_IO
#include "PWM.h"     // PWM_init, PWM_change
#include "UART.h"    // UART_init, UART_Send_byte, UART_Send_string
#include "delay.h"   // delay_ms, delay_us
#include "ADC.h"
#include "EEPROM.h"
// ... 其他模块
</code></pre>
 <p>每个 <code>.h</code> 文件声明了对应 <code>.obj</code> 预编译库中的函数接口。链接时 Keil 找到这些符号，绑在一起生成最终的 <code>.hex</code> 固件。</p>
 <hr>
 <h2 id="3-main-初始化--六步让硬件就绪">3. main() 初始化 — 六步让硬件就绪</h2>
 <pre><code class="language-c">void main(void) {
    // ========== 第 1 步：GPIO ==========
    GPIO_init_allpin(0);       // 所有 IO 设为准双向模式
    GPIO_init_pin(20, 1);      // P20 单独设为推挽输出

    // ========== 第 2 步：串口 ==========
    UART_init(1, 115200, 1, 0);  // UART1: 调试口, 不开启接收中断
    UART_init(2, 115200, 1, 1);  // UART2: DGUS 屏通信口, 开启接收中断

    // ========== 第 3 步：PWM ==========
    PWM_init(20, 50, 250);     // P20, 50Hz, 初始占空比 250

    // ========== 第 4 步：中断总开关 ==========
    EA = 1;

    // ========== 第 5、6 步：主循环 ==========
    while(1) {
        // 帧解析 + 舵机扫动
    }
}
</code></pre>
 <p>下面逐行解释每一步"做了什么"和"为什么这么做"。</p>
 <h3 id="第-1-步gpio_init_allpin0--gpio_init_pin20-1">第 1 步：GPIO_init_allpin(0) + GPIO_init_pin(20, 1)</h3>
 <pre><code>GPIO_init_allpin(0)  →  所有引脚设为准双向模式
</code></pre>
 <p>准双向模式是 8051 的经典 IO 模式——输出高电平时靠内部弱上拉电阻（约 50KΩ），驱动电流不到 1mA；输出低电平时主动拉低，驱动能力较强。适合大多数数字 IO 场景。</p>
 <pre><code>GPIO_init_pin(20, 1)  →  P20 单独设为推挽输出 (模式=1)
</code></pre>
 <p><strong>为什么要单独改 P20？</strong> 因为 P20 要输出 PWM 信号驱动舵机。准双向模式的弱上拉会让 PWM 波形的上升沿变得缓慢（RC 充电效应），导致舵机抖动或无法准确响应。推挽模式用一对 MOS 管主动推拉电平，波形干净利落。</p>
 <p><code>GPIO_init_pin()</code> 的参数：20 表示 P2.0 引脚，1 表示推挽输出模式。</p>
 <h3 id="第-2-步uart_init-两个串口">第 2 步：UART_init() 两个串口</h3>
 <pre><code>UART_init(串口号, 波特率, 定时器, 是否开启中断)
         (1,     115200,   1,      0)     UART1: P30/P31, 调试用, 无中断
         (2,     115200,   1,      1)     UART2: P10/P11, DG通信用, 开中断
</code></pre>
 <p><strong>UART1（调试口）</strong>：连接 PC 的 USB 转串口模块。在主循环中收到 DGUS 帧后原样转发到 UART1，PC 端串口助手可以看到数据内容，用于调试。不需要接收数据，所以不开启接收中断。</p>
 <p><strong>UART2（DGUS 屏通信口）</strong>：连接串口屏。需要<strong>实时接收</strong>屏幕发来的触摸返回帧，所以第四个参数为 1——开启接收中断，每收到一个字节就触发 <code>UART2_I() interrupt 8</code>。</p>
 <h3 id="第-3-步pwm_init20-50-250">第 3 步：PWM_init(20, 50, 250)</h3>
 <pre><code>PWM_init(引脚, 频率Hz, 初始占空比)
        (P20,    50,      250)
</code></pre>
 <p>SG90 舵机需要 50Hz PWM（周期 20ms）。STC8 的 PWM 模块在 50Hz 下，占空比取值范围 0-10000 对应 0%-100%：</p>
 <table>
  <thead>
   <tr>
    <th>占空比 duty</th>
    <th>脉宽</th>
    <th>占空比%</th>
    <th>舵机角度</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td>250</td>
    <td>0.5ms</td>
    <td>2.5%</td>
    <td>0°</td>
   </tr>
   <tr>
    <td>500</td>
    <td>1.0ms</td>
    <td>5.0%</td>
    <td>45°</td>
   </tr>
   <tr>
    <td>750</td>
    <td>1.5ms</td>
    <td>7.5%</td>
    <td>90°</td>
   </tr>
   <tr>
    <td>1000</td>
    <td>2.0ms</td>
    <td>10.0%</td>
    <td>135°</td>
   </tr>
   <tr>
    <td>1250</td>
    <td>2.5ms</td>
    <td>12.5%</td>
    <td>180°</td>
   </tr>
  </tbody>
 </table>
 <p>这是线性关系：每 1° 对应 duty 变化 <code>(1250-250)/180 = 1000/180 ≈ 5.556</code>。</p>
 <p>初始占空比设为 250，舵机上电即停在 0°，等待指令。</p>
 <h3 id="第-4-步ea--1">第 4 步：EA = 1</h3>
 <pre><code>EA = 1;   // 总中断使能
</code></pre>
 <p><strong>必须放在初始化的最后</strong>。如果反了——先开中断再初始化 UART——可能在 UART 初始化中途就触发中断，此时 UART 寄存器还是乱的，后果不可控。</p>
 <hr>
 <h2 id="4-uart2-数据接收--中断与主循环的配合">4. UART2 数据接收 — 中断与主循环的配合</h2>
 <h3 id="41-中断层isrc--mainc">4.1 中断层：isr.c → main.c</h3>
 <p>串口屏发送的数据以字节为单位到达 STC 的 P10 (RXD2) 引脚。硬件自动将字节存入 S2BUF 寄存器，并置起 RI 中断标志。</p>
 <p>在 isr.c 中，UART2 中断入口：</p>
 <pre><code class="language-c">void UART2_I() interrupt 8 {          // 中断号 8 = UART2
    if (S2CON &amp; 0x01) {               // 检查 RI（接收完成标志）
        UART2_isr();                  // 调用 main.c 中的处理函数
        S2CON &amp;= ~0x01;               // 清除 RI 标志
    }
}
</code></pre>
 <p><strong>为什么分两层？</strong> Keil C51 要求 <code>interrupt N</code> 函数有特殊的序言/尾声代码（保存恢复寄存器、使用指定寄存器组）。如果在 main.c 里写 <code>interrupt 8</code>，会影响整个文件的编译方式。分层设计让 isr.c 专管硬件中断入口，main.c 写普通 C 函数，互不干扰。</p>
 <h3 id="42-消费层uart2_isr">4.2 消费层：UART2_isr()</h3>
 <pre><code class="language-c">void UART2_isr(void) {
    UART_DATA[next] = S2BUF;   // 从硬件寄存器读字节，写入环形缓冲
    next++;                     // 生产指针后移
}
</code></pre>
 <p>就两行，极致精简。<code>next</code> 是 <code>uchar</code> 类型（0~255），加到 256 时自动溢出回绕为 0，天然实现了 256 字节的环形缓冲——不需要 <code>next % 256</code>。</p>
 <h3 id="43-环形缓冲的生产-消费模型">4.3 环形缓冲的生产-消费模型</h3>
 <pre><code>               ┌──────────────────────────┐
               │  UART_DATA[256]          │
  UART2 ISR ──→│  next 写入 (生产者)       │──→ 主循环
               │  current 读取 (消费者)    │    读取
               └──────────────────────────┘
</code></pre>
 <p>这是一个<strong>单生产者单消费者</strong>的环形缓冲：</p>
 <ul>
  <li><strong>只有</strong> ISR 写 <code>next</code>，<strong>只有</strong> 主循环读 <code>current</code>——没有竞争</li>
  <li>不需要关中断、不需要互斥锁</li>
  <li><code>next != current</code> 意味着有未处理数据</li>
 </ul>
 <pre><code class="language-c">if(next != current) {    // 有数据？
    // 消费一个字节……
    current++;            // 消费后指针后移
}
</code></pre>
 <hr>
 <h2 id="5-帧解析状态机--从字节流中提取完整帧">5. 帧解析状态机 — 从字节流中提取完整帧</h2>
 <p>串口传输的是连续的字节流，没有天然的"帧边界"。状态机要做的是：在这片字节流中找到帧头、确定帧长度、收齐完整帧。</p>
 <h3 id="51-dgus-帧格式">5.1 DGUS 帧格式</h3>
 <p>屏幕收发的数据帧统一遵循这个结构：</p>
 <pre><code>5A A5 [长度N] [数据1] [数据2] ... [数据N]
│  │     │       └──────── N 字节数据 ────────┘
│  │     └── 数据部分长度（不含帧头和长度自身）
│  └── 帧头第二字节（固定 0xA5）
└── 帧头第一字节（固定 0x5A）
</code></pre>
 <p>完整帧总字节数 = <strong>N + 3</strong>（帧头 2 字节 + 长度 1 字节 + 数据 N 字节）。</p>
 <h3 id="52-四状态转换">5.2 四状态转换</h3>
 <pre><code>           找到5A?            确认A5?            取长度N            收N字节?
              │                  │                  │                  │
   ┌──────────▼──────┐  ┌────────▼────────┐  ┌─────▼──────┐  ┌───────▼────────┐
   │  case 0         │  │  case 1         │  │  case 2    │  │  case 3        │
   │  寻找帧头 0x5A  │──│  确认帧头 0xA5   │──│  读长度字节  │──│  逐字节收数据   │
   │                 │  │                 │  │  ja = N+1  │  │  ja-- 直到 ja=1 │
   └─────────────────┘  └─────────────────┘  └────────────┘  └───────┬────────┘
                                                                      │ ja==1
                                                          ┌───────────▼──────────┐
                                                          │ 帧收齐！              │
                                                          │ UART_1(1, frame)  回显│
                                                          │ gears(frame)      切档│
                                                          │ led01(frame)      亮灯│
                                                          │ flag=0, ia=0     复位 │
                                                          └──────────────────────┘
</code></pre>
 <h4 id="状态-0寻找-0x5a">状态 0：寻找 0x5A</h4>
 <pre><code class="language-c">case 0: {
    if(UART_DATA[current] == 0x5a) {
        uart2_data[ia] = UART_DATA[current];  // ia=0，写入帧头第一个字节
        ia++;                                  // ia=1
        UART_FLAG = 1;                         // → 状态 1
    }
    // 不是 0x5A → 丢弃该字节，保持状态 0 继续找
} break;
</code></pre>
 <h4 id="状态-1确认-0xa5">状态 1：确认 0xA5</h4>
 <pre><code class="language-c">case 1: {
    if(UART_DATA[current] == 0xa5) {
        uart2_data[ia] = UART_DATA[current];  // ia=1，写入帧头第二个字节
        ia++;                                  // ia=2
        UART_FLAG = 2;                         // → 状态 2
    }
    // 不是 0xA5 → 保持状态 1，等下一个字节（不会误判，因为 DGUS 帧 5A 后必跟 A5）
} break;
</code></pre>
 <h4 id="状态-2读取长度">状态 2：读取长度</h4>
 <pre><code class="language-c">case 2: {
    if(ia == 2) {                              // 加守卫：只在第一次进入状态 2 时执行
        uart2_data[ia] = UART_DATA[current];   // ia=2，写入长度字节
        ia++;                                   // ia=3
        ja = uart2_data[2] + 1;                // 倒数计数器 = N+1
        UART_FLAG = 3;                          // → 状态 3
    }
} break;
</code></pre>
 <p><code>ia == 2</code> 是一个守卫条件——状态 2 可能被执行多次（如果状态 1 之后立刻又收到字节）。<code>ia</code> 只在首次置为 2 然后递增为 3，后续进入状态 2 时 <code>ia</code> 已经是 3，条件不成立，不会重复读取。</p>
 <p><strong><code>ja = uart2_data[2] + 1</code></strong>：<code>uart2_data[2]</code> 是帧长度 N。N 字节数据加 1 作为倒数计数器初始值——在状态 3 中每收一字节 <code>ja--</code>，当 <code>ja == 1</code> 时意味着收到了 N 字节数据，帧完整。</p>
 <h4 id="状态-3逐字节收数据">状态 3：逐字节收数据</h4>
 <pre><code class="language-c">case 3: {
    ja--;                                      // 倒数计数器递减
    uart2_data[ia] = UART_DATA[current];       // 写入当前字节
    ia++;                                      // 缓冲指针后移

    if(ja == 1) {                              // 倒数到 1 → 完整帧收齐
        ia = 0;                                // 帧缓冲指针复位
        UART_1(1, uart2_data);                 // ┐
        gears(uart2_data);                     // ├ 三合一处理
        led01(uart2_data);                     // ┘
        UART_FLAG = 0;                         // 回到状态 0
    }
} break;
</code></pre>
 <p>帧收齐后调用三个函数，然后 <code>UART_FLAG = 0</code> 回到原点，准备寻找下一个帧。</p>
 <p><code>ia = 0</code> 复位帧缓冲写入指针，下一个帧的数据从头开始覆盖 <code>uart2_data[]</code>。</p>
 <hr>
 <h2 id="6-舵机扫动循环--往复运动的软件实现">6. 舵机扫动循环 — 往复运动的软件实现</h2>
 <p>UART 帧处理只在 <code>if(next != current)</code> 里执行。而下面的舵机控制<strong>每个循环周期都执行</strong>：</p>
 <pre><code class="language-c">while(1) {
    // ... 帧解析 ...

    sweep_timer++;
    if(sweep_timer &gt;= 9000) {
        sweep_timer = 0;
        servo_sweep();
    }
}
</code></pre>
 <h3 id="61-软件定时器的原理">6.1 软件定时器的原理</h3>
 <p>24MHz 下，主循环空转一圈大约几十微秒。<code>sweep_timer</code> 每圈加 1，累积到 9000 大约耗时 300~500ms，触发一次舵机步进。</p>
 <p>这是一个<strong>软件定时器</strong>——不占用硬件定时器资源的节拍发生器。改变阈值即可调节扫动速度：阈值越小越快，越大越慢。</p>
 <h3 id="62-四档参数">6.2 四档参数</h3>
 <pre><code class="language-c">uchar gear_mode = 0;                    // 当前档位 (0=停机, 1/2/3=扫动)
int  max_angle[4] = {0, 60, 120, 180}; // 每档最大扫动角度
</code></pre>
 <table>
  <thead>
   <tr>
    <th>档位</th>
    <th>最大角度</th>
    <th>扫动范围</th>
    <th>效果</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td>0</td>
    <td>0°</td>
    <td>不动</td>
    <td>停机</td>
   </tr>
   <tr>
    <td>1</td>
    <td>60°</td>
    <td>0° ↔ 60°</td>
    <td>小幅往复</td>
   </tr>
   <tr>
    <td>2</td>
    <td>120°</td>
    <td>0° ↔ 120°</td>
    <td>中幅往复</td>
   </tr>
   <tr>
    <td>3</td>
    <td>180°</td>
    <td>0° ↔ 180°</td>
    <td>大幅往复</td>
   </tr>
  </tbody>
 </table>
 <h3 id="63-servo_sweep--扫动算法">6.3 servo_sweep() — 扫动算法</h3>
 <pre><code class="language-c">void servo_sweep(void) {
    uint step = 1;                       // 每次步进 1°
    uint target = max_angle[gear_mode];  // 查表获取当前档目标

    if(gear_mode == 0) {                 // 停机档
        cur_angle = 0;
        PWM_change(20, 250);             // 舵机归 0°
        return;                          // 直接返回，不扫动
    }

    if(sweep_up) {                       // 正向：角度增大
        cur_angle += step;
        if(cur_angle &gt;= target) {        // 到达上限
            cur_angle = target;
            sweep_up = 0;                // 翻转方向
        }
    } else {                             // 反向：角度减小
        if(cur_angle &lt;= step) {          // 到达下限
            cur_angle = 0;
            sweep_up = 1;                // 翻转方向
        } else {
            cur_angle -= step;
        }
    }

    PWM_change(20, angle_to_duty(cur_angle));  // 更新舵机位置
}
</code></pre>
 <p><strong>逻辑流程</strong>：</p>
 <ol>
  <li>查 <code>max_angle[gear_mode]</code> 获取当前档位目标角度</li>
  <li>0 档直接停在 0°，返回</li>
  <li>非 0 档时，根据 <code>sweep_up</code> 标志决定方向：正向则 <code>cur_angle += 1</code>，反向则 <code>cur_angle -= 1</code></li>
  <li>到达边界（≥target 或 ≤0）时翻转 <code>sweep_up</code> 标志</li>
  <li>调用 <code>PWM_change()</code> 更新舵机位置</li>
 </ol>
 <p><strong><code>step = 1</code></strong> 意味着每次调用只改变 1°。60° 的目标需要 60 次调用（约 18~30 秒）才能扫完。如果改成 <code>step = 3</code>，速度就是原来的 3 倍。<code>step</code> 和 <code>sweep_timer</code> 阈值配合，可以独立调节速度和平滑度。</p>
 <h3 id="64-angle_to_duty--角度到占空比">6.4 angle_to_duty() — 角度到占空比</h3>
 <pre><code class="language-c">uint angle_to_duty(uint angle) {
    return (uint)(250 + ((unsigned long)angle * 1000 / 180));
}
</code></pre>
 <p>推导过程：SG90 在 50Hz PWM 下，0° 对应 duty = 250，180° 对应 duty = 1250。线性关系：</p>
 <pre><code>duty = 250 + angle × (1250 - 250) / 180
     = 250 + angle × 1000 / 180
</code></pre>
 <p><strong><code>(unsigned long)</code> 强制转换是关键</strong>：<code>angle * 1000</code> 在 <code>angle = 180</code> 时等于 180000，超过 <code>uint</code> 的表示范围 65535。先提升到 32 位（<code>unsigned long</code>）计算，再截断回 16 位，避免溢出。</p>
 <hr>
 <h2 id="7-dgusc--屏幕通信四函数">7. dgus.c — 屏幕通信四函数</h2>
 <p>dgus.c 是独立模块，封装了所有与串口屏交互的功能。main.c 通过 <code>#include "dgus.h"</code> 引用。</p>
 <h3 id="71-gears--档位切换">7.1 gears() — 档位切换</h3>
 <p>整个系统最核心的业务函数：</p>
 <pre><code class="language-c">uchar gears(uchar* pp) {
    uchar i;
    i = pp[2];                          // 帧长度 N

    if(pp[i+2] == 0x05) {              // 帧尾字节 = 0x05 → 升档
        if(gear_mode &lt; 3) {
            gear_mode++;                // 1→2, 2→3
        }
        dgus_write_vp(0x2000, gear_mode);  // 同步屏幕显示
    }
    else if(pp[i+2] == 0x04) {         // 帧尾字节 = 0x04 → 降档
        if(gear_mode &gt; 0) {
            gear_mode--;                // 3→2, 2→1, 1→0
        }
        if(gear_mode &lt;= 0) gear_mode = 0;   // 防下溢
        dgus_write_vp(0x2000, gear_mode);
    }
    return 0;
}
</code></pre>
 <p><strong>帧尾取字节技巧</strong>：</p>
 <pre><code>pp[2] = N (帧长度)
pp[N+2] = 帧头(2字节) + 长度(1字节) 之后的第 N 字节 = 帧的最后一字节

例如 N=6:
  索引: 0   1   2   3   4   5   6   7   8
  内容: 5A  A5  06  83  20  01  01  00  05
  pp[2]=6, pp[6+2]=pp[8]=0x05 = 键值低字节（升档）
</code></pre>
 <p>这种解析方式不关心 DGUS 帧内部字段（命令、变量地址、键值位置）的具体偏移量，直接读最后一字节做判断。如果 DGUS Tool 中改了变量地址或键值参数，C 代码不用改。</p>
 <p><strong>边界保护</strong>：</p>
 <ul>
  <li>升档 <code>if(gear_mode &lt; 3)</code> → 最多 3 档</li>
  <li>降档 <code>if(gear_mode &gt; 0)</code> + 兜底 <code>if(gear_mode &lt;= 0) gear_mode = 0</code></li>
 </ul>
 <p><strong>同步屏幕</strong>：<code>dgus_write_vp(0x2000, gear_mode)</code> 向屏幕的变量地址 <code>0x2000</code> 写入当前档位值。屏幕的"变量图标"控件根据这个值自动切换显示的图标。</p>
 <h3 id="72-dgus_write_vp--向屏幕写变量">7.2 dgus_write_vp() — 向屏幕写变量</h3>
 <pre><code class="language-c">void dgus_write_vp(uint addr, uint val) {
    uchar cmd[8];
    cmd[0] = 0x5A;                     // 帧头 1
    cmd[1] = 0xA5;                     // 帧头 2
    cmd[2] = 0x05;                     // 数据长度：5 字节
    cmd[3] = 0x82;                     // 命令：写变量寄存器
    cmd[4] = (addr &gt;&gt; 8) &amp; 0xFF;       // 变量地址高字节
    cmd[5] = addr &amp; 0xFF;              // 变量地址低字节
    cmd[6] = (val &gt;&gt; 8) &amp; 0xFF;        // 数据高字节
    cmd[7] = val &amp; 0xFF;               // 数据低字节
    UART_1(2, cmd);                    // UART2 发送
}
</code></pre>
 <p>按照 DGUS "写变量寄存器" 帧格式拼装 8 字节命令：</p>
 <pre><code>5A A5 05 82 [addr_H] [addr_L] [val_H] [val_L]
</code></pre>
 <p><code>(addr &gt;&gt; 8) &amp; 0xFF</code> 显式加了 <code>&amp; 0xFF</code> 掩码——某些 8051 编译器对 <code>uint</code> 移位的高位处理在不同优化级别下不一致，显式掩码确保行为确定。</p>
 <h3 id="73-led01--辅助-led">7.3 led01() — 辅助 LED</h3>
 <pre><code class="language-c">uchar led01(uchar* pp) {
    uchar i;
    i = pp[2];                         // 帧长度
    if(pp[i+2] == 0x00) {
        Out_IO(01, 0);                 // P01 低电平 → LED 灭
    } else if(pp[i+2] == 0x01) {
        Out_IO(01, 1);                 // P01 高电平 → LED 亮
    }
    return 0;
}
</code></pre>
 <p>同样用帧尾字节技巧，根据最后一字节的值控制 P01 引脚。<code>Out_IO(01, 0)</code> 中第一个参数 <code>01</code> 是 IO 组号——<code>01</code> = P0 口第 1 位（即 P01），仅验证状态机功能。</p>
 <h3 id="74-uart_1--通用帧发送">7.4 UART_1() — 通用帧发送</h3>
 <pre><code class="language-c">void UART_1(unsigned char pin, unsigned char *pt) {
    uint i;
    uint len = pt[2] + 3;              // N + 3 = 完整帧总字节数
    for(i = 0; i &lt; len; i++) {
        UART_Send_byte(pin, *pt++);
    }
}
</code></pre>
 <p><code>len</code> 用 <code>uint</code> 而非 <code>uchar</code>：如果 N = 254，则 <code>254 + 3 = 257</code>。<code>uchar</code> 只能表示 0~255，257 会溢出截断为 1。<code>uint</code> 能正确处理这情况。</p>
 <p><code>pin</code> 参数指定串口号（1 = UART1 调试，2 = UART2 屏幕）。</p>
 <hr>
 <h2 id="8-完整执行流--从触摸到舵机响应">8. 完整执行流 — 从触摸到舵机响应</h2>
 <h3 id="81-上电启动">8.1 上电启动</h3>
 <pre><code> 上电复位
   │
   ▼
 STARTUP.A51  →  清零内部 RAM (IDATA区域)  →  初始化堆栈 SP  →  LJMP main()
   │
   ▼
 main() 初始化:
   GPIO:   全 IO 准双向  →  P20 推挽
   UART:   UART1(调试) + UART2(DGUS,中断)
   PWM:    P20, 50Hz, duty=250 (0°)
   EA=1    ← 中断总开 (放在最后!)
   │
   ▼
 while(1) 主循环开始
</code></pre>
 <h3 id="82-触摸--按钮从-0-档升到-1-档">8.2 触摸 [+] 按钮，从 0 档升到 1 档</h3>
 <pre><code> 手指触摸屏幕 [+] 按钮
   │
   ▼
 DGUS 通过 UART2 TX 逐字节发送触摸返回帧:
   5A A5 06 83 20 01 01 00 05   (9 字节, 最后一字节=0x05)
            └────┬────┘ └──┬──┘
            变量地址0x2001  键值0x0005
   │
   ▼ (逐字节到达 STC P10)
 UART2 硬件 → S2BUF → RI=1 → 触发中断
   │
   ▼
 isr.c: UART2_I() interrupt 8
   └── UART2_isr()  →  UART_DATA[next++] = S2BUF
   │
   ▼ (中断返回)
 main() while(1) 帧解析:
   next != current → 有数据!
   case 0: 找到 0x5A ✓ → flag=1
   case 1: 确认 0xA5 ✓ → flag=2
   case 2: ia==2, 长度=06 ✓ → ja=7, flag=3
   case 3: 逐字节接收 83→20→01→01→00→05
           ja: 7→6→5→4→3→2→1 → ja==1! 帧收齐
           ├─ UART_1(1, frame)   → PC 串口助手看到完整帧
           ├─ gears(frame)       → 帧尾=0x05
           │   gear_mode: 0→1
           │   └─ dgus_write_vp(0x2000, 1) → 屏幕显示 1 档图标
           └─ led01(frame)       → P01=1, LED 亮
   │
   ▼
 舵机扫动开始 (gear_mode=1):
   sweep_timer: 1→2→...→8999→0 → servo_sweep():
     target = max_angle[1] = 60°
     cur_angle: 0→1→2→...→60→59→...→0→1→...
     PWM_change(20, angle_to_duty(cur_angle))
   │
   ▼
 SG90 舵机在 0° ↔ 60° 之间来回摆动
</code></pre>
 <h3 id="83-连续触摸切到-3-档再降到-0-档">8.3 连续触摸，切到 3 档再降到 0 档</h3>
 <pre><code> 触摸 [+]       → gears(): 帧尾=0x05 → gear_mode: 1→2
    target = max_angle[2] = 120°  → 扫动范围扩大到 0°↔120°

 触摸 [+]       → gears(): 帧尾=0x05 → gear_mode: 2→3
    target = max_angle[3] = 180°  → 扫动范围扩大到 0°↔180°

 触摸 [-]       → gears(): 帧尾=0x04 → gear_mode: 3→2
    target = 120°  → 范围缩小

 触摸 [-]       → gear_mode: 2→1 → target=60°

 触摸 [-]       → gear_mode: 1→0
    servo_sweep(): gear_mode==0 → cur_angle=0, PWM_change(20,250)
    舵机停转
</code></pre>
 <h3 id="84-主循环的并发时序">8.4 主循环的并发时序</h3>
 <pre><code>while(1) 每圈做的事情:

  ┌─ 检查 UART → 如果有数据，执行状态机一步(一字节)
  │     ↓ (无数据时跳过)
  ├─ sweep_timer++
  └─ if(sweep_timer &gt;= 9000) → servo_sweep()

  两个任务在同一循环中交替，互不阻塞:
  即使舵机在扫动中，收到触摸帧也能立即响应
  即使正在处理帧，舵机扫动计时也不会停止
</code></pre>
 <hr>
 <h2 id="9-附录">9. 附录</h2>
 <h3 id="a-硬件接线">A. 硬件接线</h3>
 <pre><code>DGUS T5L 串口屏            STC8A8K64D4             SG90 舵机
┌──────────┐             ┌──────────────┐        ┌──────────┐
│ TXD2     │────→────────│ P10 (RXD2)   │        │          │
│ RXD2     │←────────────│ P11 (TXD2)   │        │          │
│ GND      │──────┬──────│ GND          │──┬─────│ 棕 GND   │
└──────────┘      │      └──────────────┘  │     └──────────┘
                  │                        │
                  │             P20 ───────┼──→ 橙 SIG
                  │                        │
                  │             外部 5V ───┼──→ 红 VCC
                  │                        │
                  └────────────────────────┘ (共地)
</code></pre>
 <ul>
  <li>串口参数：UART2, 115200 bps, 8N1</li>
  <li>舵机供电：必须外部独立 5V（堵转电流可达 700mA+，STC 板载 LDO 带不动）</li>
 </ul>
 <h3 id="b-dgus-屏幕控件配置">B. DGUS 屏幕控件配置</h3>
 <table>
  <thead>
   <tr>
    <th>控件</th>
    <th>变量地址 (VP)</th>
    <th>参数</th>
    <th>功能</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td>变量图标</td>
    <td><code>0x2000</code></td>
    <td>4 张图标 (值 0/1/2/3)</td>
    <td>显示当前档位</td>
   </tr>
   <tr>
    <td>按键返回</td>
    <td><code>0x2001</code></td>
    <td>[-] 键值 <code>0x0004</code></td>
    <td>发送降档帧</td>
   </tr>
   <tr>
    <td>按键返回</td>
    <td><code>0x2001</code></td>
    <td>[+] 键值 <code>0x0005</code></td>
    <td>发送升档帧</td>
   </tr>
  </tbody>
 </table>
 <h3 id="c-dgus-协议帧格式">C. DGUS 协议帧格式</h3>
 <p><strong>触摸返回帧（屏 → MCU）</strong>：</p>
 <pre><code>5A A5 06 83 VP_H VP_L 01 KEY_H KEY_L
</code></pre>
 <p><strong>写变量帧（MCU → 屏）</strong>：</p>
 <pre><code>5A A5 05 82 VP_H VP_L VAL_H VAL_L
</code></pre>
 <p>两种帧的帧头（5A A5）、长度字段、命令字段位置完全一致，状态机能统一解析。</p>
 <h3 id="d-关键全局变量">D. 关键全局变量</h3>
 <table>
  <thead>
   <tr>
    <th>变量</th>
    <th>类型</th>
    <th>位置</th>
    <th>用途</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td><code>gear_mode</code></td>
    <td>uchar</td>
    <td>main.c 定义, dgus.h extern</td>
    <td>当前档位，gears() 写, servo_sweep() 读</td>
   </tr>
   <tr>
    <td><code>max_angle[4]</code></td>
    <td>int[]</td>
    <td>main.c</td>
    <td>各档目标角度</td>
   </tr>
   <tr>
    <td><code>cur_angle</code></td>
    <td>uint</td>
    <td>main.c</td>
    <td>舵机当前角度 (0~180)</td>
   </tr>
   <tr>
    <td><code>sweep_up</code></td>
    <td>bit</td>
    <td>main.c</td>
    <td>扫动方向标志</td>
   </tr>
   <tr>
    <td><code>sweep_timer</code></td>
    <td>uint</td>
    <td>main.c</td>
    <td>软件节拍计数器</td>
   </tr>
   <tr>
    <td><code>UART_DATA[256]</code></td>
    <td>uchar[]</td>
    <td>main.c</td>
    <td>环形接收缓冲</td>
   </tr>
   <tr>
    <td><code>UART_FLAG</code></td>
    <td>uchar</td>
    <td>main.c</td>
    <td>帧解析状态 (0~3)</td>
   </tr>
  </tbody>
 </table>
 <h3 id="e-中断系统架构">E. 中断系统架构</h3>
 <pre><code>inisr.asm ──→ P0~P9 INT 扩展中断 → 跳转到 isr.c 的 GPIO_I() (interrupt 25)

isr.c ──→ 21 个中断入口函数
  ├─ UART2_I() interrupt 8   →  UART2_isr() (main.c)   ★ 核心
  ├─ UART1_I() interrupt 4   →  只清标志 (调试口不发不收)
  ├─ PCA_I()  interrupt 7    →  模式分发 (预留, 业务函数未启用)
  └─ 其他 18 个               →  清标志 / 空壳 (保持中断向量表完整)
</code></pre>
 <h3 id="f-编译和烧录">F. 编译和烧录</h3>
 <ul>
  <li>编译输出：<code>HEX/STC8_HEX.hex</code> (18554 bytes)</li>
  <li>烧录工具：STC-ISP (通过 USB 转串口连接 STC 的 UART1)</li>
  <li>包含模块：GPIO, UART, PWM, delay 等 12 个底层库 + User 代码</li>
 </ul>
 <hr>
 <blockquote>
  <p>工程路径：
   <br>
   Windows Keil：<code>Desktop/stc8a8k64d4/Project/功能实现/</code>
   <br>
   Linux 备份：<code>/home/ctyun/.openclaw/workspace/</code></p>
 </blockquote>
</div>
<p style=""></p>]]></description><guid isPermaLink="false">/archives/1779945886904</guid><dc:creator>flor</dc:creator><category>8051</category><pubDate>Thu, 28 May 2026 05:24:55 GMT</pubDate></item><item><title><![CDATA[多档位舵机控制]]></title><link>https://www.flor.cn/archives/1778923964775</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=%E5%A4%9A%E6%A1%A3%E4%BD%8D%E8%88%B5%E6%9C%BA%E6%8E%A7%E5%88%B6&amp;url=/archives/1778923964775" width="1" height="1" alt="" style="opacity:0;">
<div class="markdown-edited">
 <p>明白了。帧只管切档，舵机自己连续往复。</p>
 <hr>
 <h2 id="新代码">新代码</h2>
 <h3 id="全局变量替换之前的-gear_mode">全局变量（替换之前的 gear_mode）</h3>
 <pre><code class="language-c">uchar gear_mode = 0;                    // 当前档位 0/1/2/3
int  max_angle[4] = {0, 60, 120, 180}; // 每档最大角度
bit  sweep_up = 1;                      // 1=上升 0=下降
uint cur_angle = 0;                     // 当前角度(0~180)
</code></pre>
 <h3 id="angle_to_duty不变"><code>angle_to_duty</code>（不变）</h3>
 <pre><code class="language-c">uint angle_to_duty(uint angle) {
    return (uint)(250 + ((unsigned long)angle * 1000 / 180));
}
</code></pre>
 <h3 id="servo_sweep--走一步新函数"><code>servo_sweep()</code> — 走一步（新函数）</h3>
 <pre><code class="language-c">void servo_sweep(void) {
    uint step = 3;                     // 步长3°, 越大越快
    uint target = max_angle[gear_mode];

    if(gear_mode == 0) {
        cur_angle = 0;
        PWM_change(20, 250);           // 停住0°
        return;
    }

    if(sweep_up) {
        cur_angle += step;
        if(cur_angle &gt;= target) {
            cur_angle = target;
            sweep_up = 0;              // 到顶, 转头
        }
    } else {
        if(cur_angle &lt;= step) {
            cur_angle = 0;
            sweep_up = 1;              // 到底, 转头
        } else {
            cur_angle -= step;
        }
    }

    PWM_change(20, angle_to_duty(cur_angle));
}
</code></pre>
 <h3 id="main-里-while1-改成这样">main() 里 while(1) 改成这样</h3>
 <pre><code class="language-c">void main(void) {
    // ===== 初始化 (不变) =====
    GPIO_init_allpin(0);
    GPIO_init_pin(27, 1);
    UART_init(1, 115200, 1, 1);
    UART_init(2, 115200, 1, 0);
    PWM_init(20, 50, 250);    // 初始0°
    EA = 1;

    uint sweep_timer = 0;     // 往复计时器

    while(1) {
        // ===== ① 串口帧处理 (不变) =====
        if(next != current) {
            switch(UART_FLAG) {
                case 0: {
                    if(UART_DATA[current] == 0x5a) {
                        uart2_data[ia] = UART_DATA[current];
                        ia++;
                        UART_FLAG = 1;
                    }
                } break;
                case 1: {
                    if(UART_DATA[current] == 0xa5) {
                        uart2_data[ia] = UART_DATA[current];
                        ia++;
                        UART_FLAG = 2;
                    }
                } break;
                case 2: {
                    if(ia == 2) {
                        uart2_data[ia] = UART_DATA[current];
                        ia++;
                        ja = uart2_data[2] + 1;
                        UART_FLAG = 3;
                    }
                } break;
                case 3: {
                    ja--;
                    uart2_data[ia] = UART_DATA[current];
                    ia++;
                    if(ja == 1) {
                        ia = 0;

                        // ★ 每收一帧 → 切一档, 不复位方向
                        gear_mode++;
                        if(gear_mode &gt;= 4) gear_mode = 0;

                        UART_1(1, uart2_data);
                        led01(uart2_data);
                        UART_FLAG = 0;
                    }
                } break;
            }
            current++;
        }

        // ===== ② 舵机自动往复 (只此一处新增) =====
        sweep_timer++;
        if(sweep_timer &gt;= 600) {       // 调这个数改速度
            sweep_timer = 0;
            servo_sweep();             // 每600次循环走1步
        }
    }
}
</code></pre>
 <hr>
 <h2 id="-改动总结">🎯 改动总结</h2>
 <pre><code>原来的 pwm_p20():  收帧→切档+走一步  (删掉不用了)
新的 servo_sweep(): 只做往复运动, 自己循环跑

main() 里:
  收帧 → gear_mode++  (只切档)
  while(1)末尾 → 自动调 servo_sweep() (舵机一直动)
</code></pre>
 <hr>
 <h2 id="-调速">🔧 调速</h2>
 <pre><code class="language-c">if(sweep_timer &gt;= 600)    // 600 = 慢
if(sweep_timer &gt;= 200)    // 200 = 快
if(sweep_timer &gt;= 50)     // 50  = 飞快
</code></pre>
 <p>你觉得太快或太慢就改这个数。</p>
 <hr>
 <h2 id="-预期效果">✅ 预期效果</h2>
 <pre><code>上电 → 0°静止
发1帧 → 切1档 → 舵机自己 0↔60° 来回摆
发1帧 → 切2档 → 舵机自己 0↔120° 来回摆
发1帧 → 切3档 → 舵机自己 0↔180° 来回摆
发1帧 → 切0档 → 舵机回0° 停住
</code></pre>
</div>
<p style=""></p>]]></description><guid isPermaLink="false">/archives/1778923964775</guid><dc:creator>flor</dc:creator><category>默认分类</category><pubDate>Sat, 16 May 2026 09:32:49 GMT</pubDate></item><item><title><![CDATA[dgus数据解析]]></title><link>https://www.flor.cn/archives/1778843841354</link><description><![CDATA[<img src="https://www.flor.cn/plugins/feed/assets/telemetry.gif?title=dgus%E6%95%B0%E6%8D%AE%E8%A7%A3%E6%9E%90&amp;url=/archives/1778843841354" width="1" height="1" alt="" style="opacity:0;">
<div class="markdown-edited">
 <h2 id="-不同控件的帧格式">📦 不同控件的帧格式</h2>
 <p>DGUS 帧结构是 <strong>固定头 + 可变数据</strong>，控件不同，数据长度也不同：</p>
 <pre><code>5A A5 [长度] [命令] [地址2字节] [数据区...变长]
</code></pre>
 <hr>
 <h2 id="-三类常用控件对比">🔢 三类常用控件对比</h2>
 <table>
  <thead>
   <tr>
    <th>控件</th>
    <th>命令</th>
    <th>方向</th>
    <th>帧格式</th>
    <th>数据量</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td><strong>按键返回</strong></td>
    <td>83</td>
    <td>屏→STC</td>
    <td><code>5A A5 06 83 VP VP 01 KEY KEY</code></td>
    <td>固定1字</td>
   </tr>
   <tr>
    <td><strong>变量图标</strong></td>
    <td>82</td>
    <td>STC→屏</td>
    <td><code>5A A5 05 82 VP VP VAL VAL</code></td>
    <td>固定1字</td>
   </tr>
   <tr>
    <td><strong>写多变量</strong></td>
    <td>82</td>
    <td>STC→屏</td>
    <td><code>5A A5 07 82 VP VP 02 V1 V1 V2 V2</code></td>
    <td>N字可变</td>
   </tr>
  </tbody>
 </table>
 <p><strong>数据量 = buf[2] - 1</strong>（总长减去命令字节1）</p>
 <p>举例：</p>
 <pre><code>按键返回: buf[2]=06 → 数据量=5 → 地址(2) + 字数(1) + 数据(2) = 5 ✓
写单变量: buf[2]=05 → 数据量=4 → 地址(2) + 数据(2) = 4 ✓
写双变量: buf[2]=07 → 数据量=6 → 地址(2) + 数据(2) + 数据(2) = 6 ✓
</code></pre>
 <hr>
 <h2 id="-怎么知道某个控件占多少字节">📐 怎么知道某个控件占多少字节？</h2>
 <p><strong>看 DGUS Tool 里的控件属性面板</strong>：</p>
 <pre><code>DGUS Tool → 选中控件 → 右侧属性栏：

  ┌─────────────────────┐
  │ 变量地址:  0x2001    │  ← 地址
  │ 变量类型:  unsigned int │  ← 1字
  │ 键值:      0x0002    │  ← 返回的数据内容
  └─────────────────────┘
</code></pre>
 <table>
  <thead>
   <tr>
    <th>变量类型</th>
    <th>字节数</th>
    <th>说明</th>
   </tr>
  </thead>
  <tbody>
   <tr>
    <td><code>unsigned char</code></td>
    <td>1字节 (半字)</td>
    <td>少见</td>
   </tr>
   <tr>
    <td><code>unsigned int</code></td>
    <td>2字节 (1字)</td>
    <td>最常用</td>
   </tr>
   <tr>
    <td><code>unsigned long</code></td>
    <td>4字节 (2字)</td>
    <td>大数值</td>
   </tr>
   <tr>
    <td><code>string</code></td>
    <td>N字节</td>
    <td>文本</td>
   </tr>
  </tbody>
 </table>
 <hr>
 <h2 id="-不用死记规律很简单">🧠 不用死记，规律很简单</h2>
 <pre><code>buf[2] = 从命令到帧尾的总字节数

总长度 = buf[2]  = 1(命令) + 2(地址) + 字数(1,仅83) + N×2(数据)

83 固定公式: buf[2] = 1 + 2 + 1 + (字数 × 2)
                       命令 地址 字计数  数据

82 固定公式: buf[2] = 1 + 2 + (字数 × 2)
                       命令 地址  数据
</code></pre>
 <p>你配置 DGUS Tool 时，变量类型选的就是字数，工具会自动按这个协议发帧——你只需要在 STC 端按上面公式解析即可。</p>
</div>
<p style=""></p>]]></description><guid isPermaLink="false">/archives/1778843841354</guid><dc:creator>flor</dc:creator><category>默认分类</category><pubDate>Fri, 15 May 2026 11:17:32 GMT</pubDate></item></channel></rss>