<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>赵华的个人博客</title>
    <description>Java / C / C# / MySQL 学习笔记，以及我在嵌入式、Web 与科学计算上的一些折腾记录。</description>
    <link>https://www.sakura.work.gd//</link>
    <atom:link href="https://www.sakura.work.gd/rss.xml" rel="self" type="application/rss+xml" />
    <language>zh-CN</language>
    <copyright>CC BY-NC-SA 4.0 · 赵华</copyright>
    <pubDate>Wed, 21 Oct 2026 12:05:37 +0000</pubDate>
    <lastBuildDate>Wed, 21 Oct 2026 12:05:37 +0000</lastBuildDate>
    <generator>Jekyll v3.10.0</generator>
    
    
    
    
      <item>
        <title>Python 在定时任务里输出 emoji，UnicodeEncodeError 从哪来</title>
        <description>&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;md-workflow-manager&lt;/code&gt; 是我给自己写的一个 Markdown 流程工具：界面上点几下，它就把一批笔记整理好、提交到集群、再把结果收回来生成报告。因为经常要跑 overnight，我给它加了一个无人值守入口——Windows 任务计划程序每天早上调用一个批处理，把控制台输出重定向到 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;logs/run.log&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;奇怪的事情从第三天开始：任务计划程序里显示“上次运行结果：0x1”，日志却只有半行时间戳，后面什么都没有。手动双击同一个批处理，窗口里能跑完；换成 Git Bash 执行同一条命令，又失败。我一开始怀疑工作目录、虚拟环境、Python 路径，挨个打印排查，最后把 stderr 单独接到一个文件，才看到真正的报错：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;UnicodeEncodeError: &apos;gbk&apos; codec can&apos;t encode character &apos;\u2705&apos; in position 0: illegal multibyte sequence
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;\u2705&lt;/code&gt; 是 ✅。进度输出里有一行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print(&quot;✅ 仿真任务已提交&quot;)&lt;/code&gt;，就是它把整个任务干掉了。中文一个字都没错，错的是一个表情符号。&lt;/p&gt;

&lt;h2 id=&quot;复现同一段代码换个终端就崩&quot;&gt;复现：同一段代码，换个终端就崩&lt;/h2&gt;

&lt;p&gt;我把问题压缩成一个三行的脚本：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# probe.py
&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;开始&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;✅ 完成&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;后续步骤&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;在 cmd 窗口里直接 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;python probe.py&lt;/code&gt;，三行都正常；加上重定向 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;python probe.py &amp;gt; out.txt 2&amp;gt;&amp;amp;1&lt;/code&gt;，立刻抛 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UnicodeEncodeError&lt;/code&gt;；在 Git Bash 里直接运行，同样崩。为了确认不是猜测，我在入口加了一行诊断：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;sys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;locale&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;print&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;stdout:&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stdout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;encoding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;&quot;stderr:&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stderr&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;encoding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
      &lt;span class=&quot;s&quot;&gt;&quot;locale:&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;locale&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;getpreferredencoding&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;失败环境里三个值都是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cp936&lt;/code&gt;，而 cmd 前台是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;utf-8&lt;/code&gt;。同一份代码、同一个解释器，差别只在“输出接到哪里”。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;运行方式&lt;/th&gt;
      &lt;th&gt;stdout 接到哪&lt;/th&gt;
      &lt;th&gt;实际编码&lt;/th&gt;
      &lt;th&gt;结果&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;cmd / PowerShell 前台&lt;/td&gt;
      &lt;td&gt;Windows 控制台&lt;/td&gt;
      &lt;td&gt;UTF-8&lt;/td&gt;
      &lt;td&gt;正常&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;Git Bash（mintty）&lt;/td&gt;
      &lt;td&gt;伪终端，被当作管道&lt;/td&gt;
      &lt;td&gt;cp936&lt;/td&gt;
      &lt;td&gt;崩溃&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;&amp;gt; run.log 2&amp;gt;&amp;amp;1&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;普通文件&lt;/td&gt;
      &lt;td&gt;cp936&lt;/td&gt;
      &lt;td&gt;崩溃&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;计划任务 + 重定向&lt;/td&gt;
      &lt;td&gt;普通文件&lt;/td&gt;
      &lt;td&gt;cp936&lt;/td&gt;
      &lt;td&gt;崩溃&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;重定向下设置 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PYTHONUTF8=1&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;普通文件&lt;/td&gt;
      &lt;td&gt;UTF-8&lt;/td&gt;
      &lt;td&gt;正常&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;这张表是整件事的关键：不是“Windows 上的 Python 不支持 emoji”，而是&lt;strong&gt;输出目标变了，Python 选用的编码也跟着变了&lt;/strong&gt;。&lt;/p&gt;

&lt;h2 id=&quot;根因控制台是-utf-8管道不是&quot;&gt;根因：控制台是 UTF-8，管道不是&lt;/h2&gt;

&lt;p&gt;要讲清楚得区分三层编码，它们经常被混为一谈：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;源码编码&lt;/strong&gt;：Python 3 的源文件默认按 UTF-8 解析（PEP 3120），&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;✅&lt;/code&gt; 在内存里是一个 Unicode 码点，没有问题。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;字符串编码&lt;/strong&gt;：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print&lt;/code&gt; 时，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys.stdout&lt;/code&gt; 这个 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;TextIOWrapper&lt;/code&gt; 要把码点编码成字节，用哪种编码由它启动时决定。&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;落盘/传输编码&lt;/strong&gt;：文件、管道、子进程各自还有自己的约定。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;从 Python 3.6 起，PEP 528 让 Windows 控制台的 stdio 走宽字符 API，编码固定为 UTF-8，所以前台 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print(&quot;✅&quot;)&lt;/code&gt; 看起来天经地义。但这条特殊通道只对&lt;strong&gt;真正的 Windows 控制台句柄&lt;/strong&gt;生效。一旦 stdout 被重定向到文件或管道，Python 就退回普通文本流，编码取 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;locale.getpreferredencoding(False)&lt;/code&gt;，在中文 Windows 上就是 ANSI 代码页 cp936。&lt;/p&gt;

&lt;p&gt;Git Bash 的 mintty 是一个伪终端，不是 Windows 控制台，所以 Python 同样走管道分支；计划任务里的重定向更是标准的文件输出。两个“看起来不一样”的环境，其实踩的是同一个分支。&lt;/p&gt;

&lt;p&gt;这也解释了为什么中文没出事：常用汉字在 GBK 里都有对应码位，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print(&quot;仿真完成&quot;)&lt;/code&gt; 能正常编码。✅、✓、部分箭头和 emoji 不在 cp936 的字符集里，编码器找不到映射，就抛 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UnicodeEncodeError&lt;/code&gt;。所以“把 emoji 换成 OK”能暂时过关，但下次日志里出现一个用户输入的特殊符号，问题还会回来。&lt;/p&gt;

&lt;p&gt;更麻烦的是错误信息本身也会丢。异常抛出后，解释器要把 traceback 写到 stderr，而 traceback 会带上出错的那行源码，里面正好有 ✅；stderr 也是 cp936，于是写日志时再抛一次，最后只剩 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;lost sys.stderr&lt;/code&gt; 之类的残片。stdout 在重定向下又是块缓冲，崩溃时缓冲区未必来得及 flush。日志看起来“空的”，不是没报错，而是报错也没能完整写出来。&lt;/p&gt;

&lt;p&gt;同一个默认编码还影响另外两个地方，只是症状不同：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open(path)&lt;/code&gt; 不写 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;encoding&lt;/code&gt; 时，读写都按 cp936。读一个 UTF-8 的配置文件，可能直接 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UnicodeDecodeError&lt;/code&gt;，也可能读出乱码；写出去的日志，别的工具按 UTF-8 打开又是乱码。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subprocess.run(..., text=True)&lt;/code&gt; 不写 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;encoding&lt;/code&gt; 时，父进程也用 cp936 解码子进程输出。子进程如果是 Python 且输出 UTF-8，父进程这边就会解码失败；反过来，父进程按 UTF-8 解一个 GBK 工具的输出，也会得到乱码。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;顺带说一句：这跟“中文路径”没关系。Windows 的文件 API 本身是 Unicode 的，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open(r&quot;D:\报告\输出.md&quot;)&lt;/code&gt; 能不能打开，取决于路径存在与否，不取决于控制台代码页。路径是路径，内容是内容，混在一起排查很容易跑偏。&lt;/p&gt;

&lt;h2 id=&quot;先把问题缩小到出口&quot;&gt;先把问题缩小到出口&lt;/h2&gt;

&lt;p&gt;出问题后我不再先怀疑业务代码，而是按固定顺序确认三件事。&lt;/p&gt;

&lt;p&gt;第一，在&lt;strong&gt;失败的那个环境&lt;/strong&gt;里打印 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys.stdout.encoding&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys.stderr.encoding&lt;/code&gt;。同一个脚本在 cmd 里是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;utf-8&lt;/code&gt;，在计划任务里是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cp936&lt;/code&gt;，这一条就能把“代码逻辑问题”排除掉大半。不要在能跑的环境里验证编码，那只能证明能跑的环境没问题。&lt;/p&gt;

&lt;p&gt;第二，用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;repr()&lt;/code&gt; 看清到底是哪个字符编不出去。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UnicodeEncodeError&lt;/code&gt; 的报错里会给出码点，比如 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;\u2705&lt;/code&gt;；把它和日志模板对照，通常就是某个 emoji 或特殊符号。直接看终端可能只显示一个方框，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;repr&lt;/code&gt; 不会骗人。&lt;/p&gt;

&lt;p&gt;第三，分别验证三条出口：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print&lt;/code&gt; 到重定向文件、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open()&lt;/code&gt; 读写一个含中文的文件、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subprocess&lt;/code&gt; 捕获一个输出 UTF-8 的子进程。三条里坏一条，就说明是边界编码问题，而不是某段业务逻辑。&lt;/p&gt;

&lt;p&gt;这个顺序的好处是每一步都能独立证伪，不会在“是不是路径有中文”“是不是 venv 没激活”这些旁支上浪费一整天。&lt;/p&gt;

&lt;h2 id=&quot;修复入口统一边界显式&quot;&gt;修复：入口统一，边界显式&lt;/h2&gt;

&lt;p&gt;我的修法分三层，原则只有一句：&lt;strong&gt;不要让任何一处依赖“当前系统的默认编码”&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;第一层是进程入口，把 stdio 固定成 UTF-8，并允许替换无法编码的字符：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;sys&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;ensure_utf8_stdio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stream&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stdout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stderr&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stream&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# pythonw 下可能没有控制台流
&lt;/span&gt;            &lt;span class=&quot;k&quot;&gt;continue&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;stream&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;reconfigure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;encoding&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;utf-8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;replace&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;AttributeError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;ValueError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;pass&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;__name__&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;__main__&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;ensure_utf8_stdio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;reconfigure&lt;/code&gt; 是 Python 3.7 起提供的，3.8/3.9 都能用；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;errors=&quot;replace&quot;&lt;/code&gt; 的意思是遇到实在编不出的字符就写一个替代符，而不是让整批任务退出。日志里少一个表情可以接受，少一次仿真结果不行。放在入口而不是散落在各个 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;print&lt;/code&gt; 前面，是为了保证之后新增的任何输出都不会再触发同类崩溃。&lt;/p&gt;

&lt;p&gt;第二层是日志，明确用 UTF-8 写文件，不再依赖 stdout：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;logging&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;handler&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;logging&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;FileHandler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;log_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;encoding&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;utf-8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;handler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;setFormatter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;logging&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Formatter&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;%(asctime)s %(levelname)s %(message)s&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;logging&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;basicConfig&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;level&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;logging&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;INFO&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;handlers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;handler&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;log&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;logging&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;getLogger&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;md-flow&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;log&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;info&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;✅ 仿真任务已提交&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这里显式建 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FileHandler&lt;/code&gt;，是因为 Python 3.8 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;basicConfig&lt;/code&gt; 还没有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;encoding&lt;/code&gt; 参数（3.9 才加），直接传会被忽略或报错。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FileHandler&lt;/code&gt; 自己带 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;encoding&lt;/code&gt; 已经很多年了，指定之后，无论外部 locale 是什么，文件都是 UTF-8。&lt;/p&gt;

&lt;p&gt;第三层是所有跨进程、跨文件的边界，读写都写死编码：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;os&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;subprocess&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;sys&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pathlib&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;config&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;cfg_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;read_text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;encoding&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;utf-8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;run_child&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;args&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;list&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CompletedProcess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;env&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;**&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;environ&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;PYTHONUTF8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;executable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-X&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;utf8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;args&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;capture_output&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;encoding&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;utf-8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;replace&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;env&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;n&quot;&gt;git_log&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;git&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;log&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;-1&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;--pretty=%s&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;capture_output&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;encoding&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;utf-8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;replace&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;check&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;False&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;stdout&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strip&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;给 Python 子进程同时设 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PYTHONUTF8=1&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-X utf8&lt;/code&gt;，是为了让它自己的 stdio 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open()&lt;/code&gt; 默认也走 UTF-8，父子两端对“字节代表什么”有同一个约定。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-X utf8&lt;/code&gt; 保证这一次启动立即生效，环境变量则会继续传给子进程再拉起的孙进程，两个都写上是为了整条调用链不出现例外。对于 git 这类非 Python 子进程，控制不了它内部，但可以明确父进程按 UTF-8 解码，并用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;errors=&quot;replace&quot;&lt;/code&gt; 兜底，避免一个坏字节让整段日志解析失败。&lt;/p&gt;

&lt;p&gt;最后是计划任务的启动脚本。批处理本身保持纯 ASCII，避免另一个编码坑：&lt;/p&gt;

&lt;div class=&quot;language-bat highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;@echo &lt;span class=&quot;na&quot;&gt;off&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;set&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;PYTHONUTF8&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;set&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;PYTHONIOENCODING&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;utf&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;8&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;cd&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;/d &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;vm&quot;&gt;%~dp0&lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;&lt;/span&gt;
&lt;span class=&quot;s2&quot;&gt;&quot;.venv\Scripts\python.exe&quot;&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;main&lt;/span&gt;.py &lt;span class=&quot;o&quot;&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;logs&lt;/span&gt;\run.log &lt;span class=&quot;m&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;m&quot;&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PYTHONUTF8=1&lt;/code&gt; 开启 PEP 540 的 UTF-8 模式，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;PYTHONIOENCODING&lt;/code&gt; 再明确一次 stdio 编码。两个变量都在 Python 启动前设置，因为解释器启动时就把 stdio 编码定下来了，进程内再改环境变量对当前流无效。批处理里不要写中文注释：如果文件存成 UTF-8 而 cmd 按 GBK 解析，命令本身就可能被拆坏。&lt;/p&gt;

&lt;p&gt;改完之后，同一个任务在 cmd 前台、Git Bash、计划任务三处都跑通，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;run.log&lt;/code&gt; 用 VS Code 打开，中文和 ✅ 都正常。我又故意在日志里加了几种符号做回归，没有再出现退出码 0x1。&lt;/p&gt;

&lt;h2 id=&quot;为什么-chcp-65001-不是主方案&quot;&gt;为什么 chcp 65001 不是主方案&lt;/h2&gt;

&lt;p&gt;网上最常见的建议是“先 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chcp 65001&lt;/code&gt;”。我试过，结论是它解决不了这个场景：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;计划任务根本没有交互式控制台，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chcp&lt;/code&gt; 改的是当前控制台的输出代码页，不是进程的 ANSI 代码页；&lt;/li&gt;
  &lt;li&gt;重定向到文件时，Python 取的是 ANSI 代码页（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;GetACP&lt;/code&gt;），&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chcp&lt;/code&gt; 不会改变它；&lt;/li&gt;
  &lt;li&gt;Python 在启动时缓存 stdio 的编码，进程跑起来之后再 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chcp&lt;/code&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys.stdout.encoding&lt;/code&gt; 不会跟着变；&lt;/li&gt;
  &lt;li&gt;前台控制台在 Python 3.6+ 本来就按 UTF-8 走，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chcp&lt;/code&gt; 至多是重复劳动；&lt;/li&gt;
  &lt;li&gt;系统设置里的“Beta: 使用 Unicode UTF-8 提供全球语言支持”影响面更大，可能让依赖 GBK 的老程序出问题，不适合为一个小工具去改。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;所以 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;chcp&lt;/code&gt; 最多是交互式调试时的辅助，真正稳定的做法还是把编码写进代码和启动环境。&lt;/p&gt;

&lt;h2 id=&quot;小结&quot;&gt;小结&lt;/h2&gt;

&lt;p&gt;这次踩坑的表面是“Python 打印 emoji 崩了”，本质是&lt;strong&gt;输出目标从控制台变成管道后，stdio 的编码从 UTF-8 退回 ANSI 代码页&lt;/strong&gt;。中文能过、emoji 不能过，只是因为 GBK 的字符集边界刚好卡在那里；日志为空，则是 traceback 自己也写不出去。&lt;/p&gt;

&lt;p&gt;我现在给所有带无人值守入口的 Python 工具固定三件事：入口 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;reconfigure&lt;/code&gt; 把 stdout/stderr 设为 UTF-8 且 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;errors=&quot;replace&quot;&lt;/code&gt;；日志用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FileHandler(encoding=&quot;utf-8&quot;)&lt;/code&gt;；所有 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;read_text&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;subprocess&lt;/code&gt; 显式写编码，并让子进程也用 UTF-8。做完这三步，编码问题就不再取决于今天从哪个终端启动、有没有控制台、系统装的是不是中文版 Windows。&lt;/p&gt;

</description>
        <pubDate>Tue, 20 Oct 2026 22:02:00 +0000</pubDate>
        <link>https://www.sakura.work.gd/2026/10/20/python-unicodeencodeerror-gbk-stdout.html</link>
        <guid isPermaLink="true">https://www.sakura.work.gd/2026/10/20/python-unicodeencodeerror-gbk-stdout.html</guid>
        
        <category>Python</category>
        
        <category>UnicodeEncodeError</category>
        
        <category>Windows</category>
        
        <category>字符编码</category>
        
        <category>定时任务</category>
        
        
        <category>踩坑修复</category>
        
      </item>
    
    
    
      <item>
        <title>STM32 的 I2C 卡死在 BUSY：从机按住 SDA 的成因与恢复时序</title>
        <description>&lt;p&gt;去年给实验室的环境监测节点加了一块 0.96 寸 OLED，和温湿度传感器共用 I2C1。台面上调试一切正常，放到窗边连续跑了几天后，早上来发现串口没输出了，程序停在 OLED 刷新的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_I2C_Master_Transmit&lt;/code&gt; 里面。看门狗复位之后又能跑，但过几个小时还会再来一次。换过传感器、换过杜邦线，问题依旧，最后用逻辑分析仪抓波形才发现：每次卡死时 SCL 是高电平，SDA 被从机一直拉低，总线根本发不出起始条件。&lt;/p&gt;

&lt;p&gt;这篇文章把这件事拆开讲：BUSY 标志到底在等什么，从机为什么会按住 SDA 不放，以及怎么用软件把总线救回来。&lt;/p&gt;

&lt;h2 id=&quot;busy-位到底在等什么&quot;&gt;BUSY 位到底在等什么&lt;/h2&gt;

&lt;p&gt;STM32 的 HAL 库在阻塞式收发里会先检查 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;I2C_FLAG_BUSY&lt;/code&gt;。这个标志不是”外设正在忙”，而是&lt;strong&gt;总线电平状态&lt;/strong&gt;的映射：只要 SCL 或 SDA 有一根被拉低，硬件就认为总线被占用，BUSY 置位。&lt;/p&gt;

&lt;p&gt;问题在于，很多示例代码是这么写的：&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;HAL_I2C_Master_Transmit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hi2c1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;addr&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HAL_MAX_DELAY&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_MAX_DELAY&lt;/code&gt; 意味着永不超时。一旦总线被从机按住，这行代码就在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;while&lt;/code&gt; 里等 BUSY 清零，等不到就永远不返回。主循环停了，喂狗停了，最后只能靠看门狗复位。所以第一个结论很直接：&lt;strong&gt;所有 I2C 阻塞调用都必须给超时&lt;/strong&gt;，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_MAX_DELAY&lt;/code&gt; 在 I2C 上不是一个可以偷懒的选项。&lt;/p&gt;

&lt;p&gt;给了超时之后，函数会返回 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_BUSY&lt;/code&gt; 或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_TIMEOUT&lt;/code&gt;。但返回错误只是把”卡死”变成了”报错”，总线仍然是死的，下一次调用照样失败。要真正恢复，得先搞清楚从机为什么不肯松手。&lt;/p&gt;

&lt;p&gt;还有一种常见写法是用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_I2C_IsDeviceReady&lt;/code&gt; 在初始化时扫描地址，确认设备在线。这个函数同样依赖总线空闲，总线被按住时它只会一直返回 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_BUSY&lt;/code&gt;，既不能证明从机坏了，也不能把总线救活。把它当成上电自检可以，当成修复手段不行。&lt;/p&gt;

&lt;h2 id=&quot;从机为什么会把-sda-按住不放&quot;&gt;从机为什么会把 SDA 按住不放&lt;/h2&gt;

&lt;p&gt;I2C 的两根线都是开漏结构：器件只能把线拉低，拉高靠上拉电阻。也就是说，总线上任何一个器件拉低，整根线就是低电平，谁也抢不回来。&lt;/p&gt;

&lt;p&gt;一次完整的字节传输是 8 个数据位加 1 个应答位。从机在输出数据时，会在 SCL 低电平期间把下一位放到 SDA 上；如果这一位是 0，它就把 SDA 拉低，然后等主机给出第 9 个时钟来读应答。&lt;strong&gt;如果主机在这个节骨眼上复位了&lt;/strong&gt;，SCL 停在某个状态不再翻转，从机就会一直保持 SDA 为低，等那个永远不来的时钟。&lt;/p&gt;

&lt;p&gt;这时候主机重新启动，想要发出起始条件：SCL 为高时让 SDA 由高变低。可 SDA 已经被从机拉低，主机根本无法制造这个下降沿，硬件看到 SDA 低就置 BUSY，于是卡住。除了 MCU 复位，下面几种情况也会造成同样的局面：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;从机上电比 MCU 晚，MCU 已经开始扫描总线；&lt;/li&gt;
  &lt;li&gt;热插拔传感器，插头瞬间的毛刺被从机当成了时钟；&lt;/li&gt;
  &lt;li&gt;从机自身死机或供电跌落，输出级停在低电平；&lt;/li&gt;
  &lt;li&gt;多主机系统中 arbitration 丢失后状态机没有正确复位。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;所以”换一根线就好了”往往是错觉，真正变化的是复位时机和上电顺序。&lt;/p&gt;

&lt;p&gt;另外要区分两种”按住”：按住 SDA 和按住 SCL。前者是从机在等时钟，补时钟有效；后者是从机在做 clock stretching，或者 SCL 被短路，此时主机连一个高电平都发不出去，9 个时钟也无从谈起。所以恢复函数执行前先看一眼两根线的电平，能省掉很多无效尝试。&lt;/p&gt;

&lt;h2 id=&quot;先用-9-个时钟把总线救回来&quot;&gt;先用 9 个时钟把总线救回来&lt;/h2&gt;

&lt;p&gt;从机等的其实很简单：把当前这个字节剩下的时钟补完，它就会释放 SDA。一个字节最多 9 个时钟（8 位数据加 1 位应答），所以恢复的标准做法是：&lt;strong&gt;把 I2C 外设关掉，用 GPIO 手动在 SCL 上打最多 9 个脉冲，SDA 保持释放，最后补一个 STOP&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;手动打脉冲之前要先 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_I2C_DeInit&lt;/code&gt;，否则外设还占着引脚。恢复完成后调用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_I2C_Init&lt;/code&gt;，HAL 会通过 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MspInit&lt;/code&gt; 把引脚切回复用开漏模式。&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;cp&quot;&gt;#define I2C_SCL_PORT  GPIOB
#define I2C_SCL_PIN   GPIO_PIN_6
#define I2C_SDA_PORT  GPIOB
#define I2C_SDA_PIN   GPIO_PIN_7
&lt;/span&gt;
&lt;span class=&quot;cm&quot;&gt;/* 返回 1 表示恢复成功，0 表示 SDA 仍被拉低，多半是硬件问题 */&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;i2c_bus_recover&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_HandleTypeDef&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hi2c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;GPIO_InitTypeDef&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ok&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;HAL_I2C_DeInit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hi2c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;                 &lt;span class=&quot;cm&quot;&gt;/* 外设让出引脚 */&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Mode&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_MODE_OUTPUT_OD&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;     &lt;span class=&quot;cm&quot;&gt;/* 开漏，和总线电气一致 */&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Pull&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_PULLUP&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Speed&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_SPEED_FREQ_HIGH&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Pin&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I2C_SCL_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;HAL_GPIO_Init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_SCL_PORT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;cm&quot;&gt;/* SDA 先设为输入，确保不会和从机对拉 */&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Pin&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I2C_SDA_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Mode&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_MODE_INPUT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;HAL_GPIO_Init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_SDA_PORT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;ok&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HAL_GPIO_ReadPin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_SDA_PORT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I2C_SDA_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_PIN_SET&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ok&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;9&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;HAL_GPIO_WritePin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_SCL_PORT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I2C_SCL_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_PIN_RESET&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;i2c_delay_us&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;HAL_GPIO_WritePin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_SCL_PORT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I2C_SCL_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_PIN_SET&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;i2c_delay_us&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HAL_GPIO_ReadPin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_SDA_PORT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I2C_SDA_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_PIN_SET&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;ok&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;break&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
            &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
        &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ok&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;cm&quot;&gt;/* SDA 已释放，补一个 STOP：SCL 高时 SDA 由低变高 */&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Pin&lt;/span&gt;  &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I2C_SDA_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Mode&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_MODE_OUTPUT_OD&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;HAL_GPIO_Init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_SDA_PORT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;gpio&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;

        &lt;span class=&quot;n&quot;&gt;HAL_GPIO_WritePin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_SCL_PORT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I2C_SCL_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_PIN_RESET&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;HAL_GPIO_WritePin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_SDA_PORT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I2C_SDA_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_PIN_RESET&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;i2c_delay_us&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;HAL_GPIO_WritePin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_SCL_PORT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I2C_SCL_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_PIN_SET&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;i2c_delay_us&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;HAL_GPIO_WritePin&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;I2C_SDA_PORT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;I2C_SDA_PIN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;GPIO_PIN_SET&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;i2c_delay_us&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;5&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;HAL_I2C_Init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hi2c&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;                   &lt;span class=&quot;cm&quot;&gt;/* MspInit 会把引脚切回复用模式 */&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ok&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;i2c_delay_us&lt;/code&gt; 可以用 DWT 计数器或者定时器实现。核心不是精确的 5 微秒，而是让 SCL 的低电平和高电平都足够宽，慢一点没关系，快到来不及被从机采样才会出问题。&lt;/p&gt;

&lt;p&gt;调用方应该把”超时、恢复、重试一次”包在一起，并且限制重试次数：&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;HAL_StatusTypeDef&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;i2c_write_retry&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;uint16_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;addr&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;uint16_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;HAL_StatusTypeDef&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;st&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;st&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HAL_I2C_Master_Transmit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hi2c1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;addr&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;st&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HAL_OK&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;st&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;!&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i2c_bus_recover&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hi2c1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HAL_ERROR&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;                 &lt;span class=&quot;cm&quot;&gt;/* 硬件层面没救回来，交给上层处理 */&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HAL_I2C_Master_Transmit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hi2c1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;addr&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这里有一个容易忽略的细节：恢复函数只负责把总线拉回空闲，不负责判断”为什么出错”。如果 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;i2c_bus_recover&lt;/code&gt; 返回 0，9 个时钟打完 SDA 还是低，那就不要再无限重试了，先查上拉电阻、查短路、查从机供电，再考虑给从机断电重启。&lt;/p&gt;

&lt;p&gt;我现在习惯在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main&lt;/code&gt; 里、第一次访问任何 I2C 设备之前先调用一次恢复函数。因为上一次复位很可能就发生在传输中途，从机还停在半个字节里；如果等第一次读写失败再恢复，等于把故障推迟到业务代码里。代价只是启动时多花几十微秒。&lt;/p&gt;

&lt;p&gt;如果板子空间允许，我还会留一路软件 I2C 作为兜底：GPIO 模拟的时序完全可控，遇到从机 clock stretching 也能自己决定等多久。它不是要替代硬件 I2C，而是在调试阶段用来对比——同一套读写逻辑，硬件外设失败、软件时序成功，基本就能把问题锁定在总线状态而不是从机寄存器。&lt;/p&gt;

&lt;h2 id=&quot;恢复是补救预防才是正事&quot;&gt;恢复是补救，预防才是正事&lt;/h2&gt;

&lt;p&gt;把恢复函数加进去以后，那个节点确实不再整机卡死了，但偶尔还是会丢一帧显示数据。后来我按下面的顺序逐项检查，才把根因压下去。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;现象&lt;/th&gt;
      &lt;th&gt;更可能的原因&lt;/th&gt;
      &lt;th&gt;怎么确认&lt;/th&gt;
      &lt;th&gt;处理&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;一上电就 BUSY，SDA 始终为低&lt;/td&gt;
      &lt;td&gt;从机供电晚于 MCU，或上次传输被打断&lt;/td&gt;
      &lt;td&gt;断电测 SDA 对地电阻，上电看电平&lt;/td&gt;
      &lt;td&gt;调整上电顺序；从机加复位脚；启动时先做一次总线恢复&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;偶发 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_BUSY&lt;/code&gt;，重新初始化就好&lt;/td&gt;
      &lt;td&gt;干扰、时钟被拉长、总线电容过大&lt;/td&gt;
      &lt;td&gt;逻辑分析仪抓 SCL/SDA 波形&lt;/td&gt;
      &lt;td&gt;加超时与恢复；缩短走线；检查上拉&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;SCL 一直被拉低&lt;/td&gt;
      &lt;td&gt;从机 clock stretching 卡住或短路&lt;/td&gt;
      &lt;td&gt;断开从机看 SCL 是否释放&lt;/td&gt;
      &lt;td&gt;查从机状态；必要时断电重启从机&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;多设备时偶发丢数据&lt;/td&gt;
      &lt;td&gt;上拉偏弱或总线电容过大&lt;/td&gt;
      &lt;td&gt;看上升沿是否变缓&lt;/td&gt;
      &lt;td&gt;减小上拉阻值、降低速率、减少挂载设备&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;上拉电阻也不是越小越好。常见模块板上已经带了 4.7k 或 10k，多个模块并在一起，等效阻值会变小，低电平时的灌电流变大，反而可能把低电平抬高。我一般按总线和速率这样选：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;上拉阻值&lt;/th&gt;
      &lt;th&gt;适用情况&lt;/th&gt;
      &lt;th&gt;代价&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;10k&lt;/td&gt;
      &lt;td&gt;100kHz、短线、单从机&lt;/td&gt;
      &lt;td&gt;上升沿慢，400kHz 下波形容易变差&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;4.7k&lt;/td&gt;
      &lt;td&gt;100–400kHz 的常见折中&lt;/td&gt;
      &lt;td&gt;模块自带时并联会偏小，需要算总阻值&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;2.2k&lt;/td&gt;
      &lt;td&gt;400kHz、总线电容较小&lt;/td&gt;
      &lt;td&gt;灌电流大，对从机低电平驱动能力要求高&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;上电顺序这一点值得单独说：如果传感器和 MCU 共用 3.3V，但传感器板上有大电容，MCU 可能先启动并开始扫描，此时传感器还没准备好，SDA 被它拉低的概率就很高。我的做法是给传感器单独留一个 GPIO 控制的 MOSFET 或者直接用一个复位脚，等电源稳定后再释放。&lt;/p&gt;

&lt;p&gt;除此之外，还有几件成本很低但很值的事：在 RTOS 里给 I2C 总线加互斥锁，避免两个任务同时发起传输；给从机留一个复位引脚，软件恢复失败时直接硬件复位；保留看门狗，但不要把看门狗当成 I2C 的常规恢复手段。看门狗复位整机，代价远大于恢复一条总线。&lt;/p&gt;

&lt;h2 id=&quot;小结&quot;&gt;小结&lt;/h2&gt;

&lt;p&gt;I2C 卡死的本质不是”STM32 的 I2C 有 bug”，而是开漏总线上的状态机没有走完：从机在等时钟，主机在等 SDA 释放，双方互等。理解这一点之后，处理思路就很清楚了——给所有调用加超时，出错后用 9 个时钟加手动 STOP 把从机从当前字节里”推”出来，再重新初始化外设。恢复代码最好在系统启动时也主动跑一次，因为上一次复位很可能就是在传输中途发生的。这样即使现场再出现异常，最坏也只是丢一次读数，而不是整机停摆。&lt;/p&gt;

</description>
        <pubDate>Mon, 19 Oct 2026 22:02:00 +0000</pubDate>
        <link>https://www.sakura.work.gd/2026/10/19/stm32-i2c-busy-sda-recovery.html</link>
        <guid isPermaLink="true">https://www.sakura.work.gd/2026/10/19/stm32-i2c-busy-sda-recovery.html</guid>
        
        <category>STM32</category>
        
        <category>I2C</category>
        
        <category>总线死锁</category>
        
        <category>嵌入式</category>
        
        <category>HAL库</category>
        
        
        <category>硬件设计</category>
        
      </item>
    
    
    
      <item>
        <title>用 PyInstaller 把 Python 脚本打包成单文件 exe：图标、路径与中文目录的完整清单</title>
        <description>&lt;p&gt;上个月把实验室用的一个小工具发给同学，功能很简单：读几个 CSV、按规则改名、再导出一份汇总。为了不让他装 Python 和一堆依赖，我用 PyInstaller 打了单文件 exe。在我电脑上双击就开，发过去却一闪而过；后来换到一台带中文用户名的机器，又出现读不到资源、写配置失败的问题。这篇文章把那次打包从头到尾的坑整理成一份清单，重点不是命令本身，而是命令背后那些只在别人机器上才暴露的路径和编码问题。&lt;/p&gt;

&lt;p&gt;先说一个容易误解的前提：PyInstaller 并不是把 Python 编译成机器码，而是把解释器、字节码和依赖收集起来，运行时先解压再启动。理解这一点，后面那些临时目录、首次启动慢、杀毒软件误报的现象就都能解释通，而不是每次遇到都当成玄学。&lt;/p&gt;

&lt;h2 id=&quot;单文件还是单目录&quot;&gt;单文件还是单目录&lt;/h2&gt;

&lt;p&gt;PyInstaller 最常被问的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-F&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-D&lt;/code&gt; 怎么选。两者不是“新旧”关系，而是分发方式和运行方式的交换：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;方式&lt;/th&gt;
      &lt;th&gt;产物&lt;/th&gt;
      &lt;th&gt;启动与维护&lt;/th&gt;
      &lt;th&gt;适合&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-F&lt;/code&gt; 单文件&lt;/td&gt;
      &lt;td&gt;一个 exe&lt;/td&gt;
      &lt;td&gt;每次启动先把内容解压到临时目录，冷启动会慢一点&lt;/td&gt;
      &lt;td&gt;发给别人、工具体积不大&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-D&lt;/code&gt; 单目录&lt;/td&gt;
      &lt;td&gt;exe + 依赖目录&lt;/td&gt;
      &lt;td&gt;启动快，文件多，升级时整目录替换&lt;/td&gt;
      &lt;td&gt;内部长期使用、依赖较多&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;我选 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-F&lt;/code&gt;，理由很实际：同学只需要接收一个文件，不用理解“要把整个文件夹一起拷走”。代价是每次启动都要解压一遍，对一个一天开几次的工具来说可以接受。如果工具要常驻托盘、依赖几十兆的资源，我会改用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-D&lt;/code&gt;，避免每次启动都做一次解压。&lt;/p&gt;

&lt;h2 id=&quot;构建命令和几个不能省的参数&quot;&gt;构建命令和几个不能省的参数&lt;/h2&gt;

&lt;p&gt;我最开始只写了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pyinstaller -F main.py&lt;/code&gt;，能跑，但图标是默认的、控制台会弹、pyserial 的串口枚举也失败。后来固定成下面这条命令：&lt;/p&gt;

&lt;div class=&quot;language-powershell highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;pyinstaller&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;-F&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;-w&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;-n&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;LabTool&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;se&quot;&gt;`
&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;--icon&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;app.ico&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;se&quot;&gt;`
&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;--add-data&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;assets;assets&quot;&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;se&quot;&gt;`
&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;nt&quot;&gt;--hidden-import&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;serial.tools.list_ports&lt;/span&gt;&lt;span class=&quot;w&quot;&gt; &lt;/span&gt;&lt;span class=&quot;se&quot;&gt;`
&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;main.py&lt;/span&gt;&lt;span class=&quot;w&quot;&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;几个参数值得逐个说清楚：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-F&lt;/code&gt; 打单文件；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-w&lt;/code&gt; 去掉控制台窗口。调试阶段不要加 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-w&lt;/code&gt;，否则异常信息没地方看。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-n&lt;/code&gt; 指定产物名，避免默认拿入口文件名，和快捷方式、版本信息对不上。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--add-data &quot;源;目标&quot;&lt;/code&gt; 把资源打进去。Windows 用分号分隔，macOS/Linux 用冒号，这是跨平台构建时最容易漏的一处。&lt;/li&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--hidden-import&lt;/code&gt; 解决动态导入。像 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;serial.tools.list_ports.comports()&lt;/code&gt; 这种在运行时才 import 的模块，静态分析抓不到，不补就会在别人机器上报 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ModuleNotFoundError&lt;/code&gt;。&lt;/li&gt;
  &lt;li&gt;依赖里带数据文件的包，可以用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--collect-all 包名&lt;/code&gt; 一并收进去，但不要无脑全加，产物会明显变大。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我还会在构建前删掉 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;dist&lt;/code&gt;。PyInstaller 有缓存，改过资源或依赖后如果不清理，偶尔会打出旧内容，排查起来很浪费时间。把命令写进 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build.bat&lt;/code&gt;，比每次翻聊天记录可靠：&lt;/p&gt;

&lt;div class=&quot;language-bat highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;@echo &lt;span class=&quot;na&quot;&gt;off&lt;/span&gt;
&lt;span class=&quot;nb&quot;&gt;rmdir&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;/s /q &lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;build&lt;/span&gt; &lt;span class=&quot;kd&quot;&gt;dist&lt;/span&gt; &lt;span class=&quot;m&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;kr&quot;&gt;nul&lt;/span&gt;
&lt;span class=&quot;kd&quot;&gt;pyinstaller&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;-F -w -n &lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;LabTool&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;--icon &lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;app&lt;/span&gt;.ico &lt;span class=&quot;na&quot;&gt;--add-data &lt;/span&gt;&lt;span class=&quot;s2&quot;&gt;&quot;assets;assets&quot;&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;--hidden-import &lt;/span&gt;&lt;span class=&quot;kd&quot;&gt;serial&lt;/span&gt;.tools.list_ports &lt;span class=&quot;kd&quot;&gt;main&lt;/span&gt;.py
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;图标版本信息和虚拟环境&quot;&gt;图标、版本信息和虚拟环境&lt;/h2&gt;

&lt;p&gt;图标不只是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--icon&lt;/code&gt; 一行。Windows 上如果 ico 里只有一种尺寸，任务栏和文件列表会各取所需地拉伸，看起来发糊。我一般准备一个包含 16、32、48、256 四种尺寸的 ico，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--icon&lt;/code&gt; 指过去就行。版本信息则没有命令行参数，需要写一个 version file，再用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--version-file&lt;/code&gt; 传进去。它不是必需项，但发给别人后，右键属性里能看到产品名和版本号，对方报问题时至少能确认自己拿的是哪一版，而不是对着一个光秃秃的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;LabTool.exe&lt;/code&gt; 猜。&lt;/p&gt;

&lt;p&gt;还有虚拟环境这一条。我第一次打包用的是系统 Python，机器上装了几十个包，PyInstaller 把能扫到的都带了进去，产物一百多兆。后来养成习惯：每个要交付的工具单独建一个干净 venv，只装运行必需的依赖，在 venv 里构建。产物小了，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ModuleNotFoundError&lt;/code&gt; 也少了，因为扫描范围和实际运行环境终于一致了。&lt;/p&gt;

&lt;h2 id=&quot;资源路径__file__-在单文件里不是你以为的位置&quot;&gt;资源路径：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;__file__&lt;/code&gt; 在单文件里不是你以为的位置&lt;/h2&gt;

&lt;p&gt;单文件 exe 启动时会把内容解压到一个临时目录，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;__file__&lt;/code&gt; 指向的是临时目录里的文件，不是 exe 旁边。读只读资源要用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys._MEIPASS&lt;/code&gt;，写配置和日志则要落在 exe 所在目录或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;%APPDATA%&lt;/code&gt;，不能用临时目录：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;sys&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pathlib&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;resource_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rel&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;base&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;getattr&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;_MEIPASS&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;__file__&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;resolve&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;base&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rel&lt;/span&gt;

&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;writable_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;c1&quot;&gt;# 单文件模式下，exe 所在目录才是长期可写的位置
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;executable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;resolve&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parent&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;name&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这段代码解决的是两类完全不同的问题。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resource_path&lt;/code&gt; 用来读图标、模板、内置字典这类打包进 exe 的只读文件；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;writable_path&lt;/code&gt; 用来写日志、缓存、用户配置。很多人把两者都写成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Path(__file__).parent&lt;/code&gt;，开发时正常，打包后日志写进了临时目录，程序一退出就没了。另一个常见错误是往 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys._MEIPASS&lt;/code&gt; 里写文件，临时目录可能被系统清理，也可能没有写权限。&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys.executable&lt;/code&gt; 在打包后指向 exe 本身；在源码运行时指向 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;python.exe&lt;/code&gt;。如果一段代码要同时支持两种运行方式，可以用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;getattr(sys, &quot;frozen&quot;, False)&lt;/code&gt; 判断当前是不是打包产物，再决定路径策略。&lt;/p&gt;

&lt;h2 id=&quot;中文目录工作目录和子进程&quot;&gt;中文目录、工作目录和子进程&lt;/h2&gt;

&lt;p&gt;中文路径本身在 Python 3 里不是问题，真正出问题的是把路径交给子进程、C 库或控制台的时候。比如主程序调用一个随包附带的小工具：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;subprocess&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;sys&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pathlib&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;exe_dir&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sys&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;executable&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;resolve&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parent&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;result&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;subprocess&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;run&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exe_dir&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;helper.exe&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;--input&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;试卷.pdf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;cwd&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;exe_dir&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;capture_output&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;encoding&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;utf-8&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;errors&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;replace&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这里显式指定 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cwd&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;encoding&lt;/code&gt;，是因为双击 exe、从快捷方式启动、从另一个程序调起时，当前工作目录可能不同。依赖 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;os.getcwd()&lt;/code&gt; 去找同目录的文件，在别人机器上很容易失败。显式传 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cwd&lt;/code&gt; 后，相对路径的基准就固定了。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;encoding=&quot;utf-8&quot;&lt;/code&gt; 则是为了避免子进程输出走 Windows 控制台代码页，中文变成乱码后连报错都看不懂。&lt;/p&gt;

&lt;p&gt;文件读写同样建议显式写 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;encoding=&quot;utf-8&quot;&lt;/code&gt;；如果生成的 CSV 要给 Excel 直接双击打开，可以用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;utf-8-sig&lt;/code&gt; 带 BOM，避免表头中文乱码。&lt;/p&gt;

&lt;h2 id=&quot;打包后最常见的问题&quot;&gt;打包后最常见的问题&lt;/h2&gt;

&lt;p&gt;下面这张表基本覆盖了我这些年遇到的打包问题，按出现频率排：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;现象&lt;/th&gt;
      &lt;th&gt;大概率原因&lt;/th&gt;
      &lt;th&gt;处理&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;双击后一闪而过&lt;/td&gt;
      &lt;td&gt;异常没被捕获，且用了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-w&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;先打无 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-w&lt;/code&gt; 的调试版；把异常写进日志文件&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;找不到图标或模板&lt;/td&gt;
      &lt;td&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--add-data&lt;/code&gt; 没加，或路径用了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;__file__&lt;/code&gt;&lt;/td&gt;
      &lt;td&gt;用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resource_path&lt;/code&gt;，检查 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys._MEIPASS&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;换机器提示缺 DLL&lt;/td&gt;
      &lt;td&gt;目标机缺运行库或系统组件&lt;/td&gt;
      &lt;td&gt;在干净 Windows 虚拟机里测试，必要时安装对应运行库&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;中文路径下读写失败&lt;/td&gt;
      &lt;td&gt;子进程或文件编码不一致&lt;/td&gt;
      &lt;td&gt;显式 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;encoding&lt;/code&gt;，用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Path&lt;/code&gt; 拼接，不拼字符串&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;杀毒软件报毒&lt;/td&gt;
      &lt;td&gt;单文件自解压行为容易被误判&lt;/td&gt;
      &lt;td&gt;提交误报、给 exe 签名，或改用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-D&lt;/code&gt; 目录模式&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;配置重启后丢失&lt;/td&gt;
      &lt;td&gt;写到了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_MEIPASS&lt;/code&gt; 临时目录&lt;/td&gt;
      &lt;td&gt;改写到 exe 所在目录或 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;%APPDATA%&lt;/code&gt;&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;最后一条我踩过两次。第一次以为是配置没保存，第二次才发现文件确实写了，只是写进了临时目录；程序退出后目录被清掉，看起来就像“设置没生效”。&lt;/p&gt;

&lt;h2 id=&quot;上线前的最小检查&quot;&gt;上线前的最小检查&lt;/h2&gt;

&lt;p&gt;打包命令跑通不等于能交付。我现在至少会做这几件事：在一台没装 Python 的干净 Windows 虚拟机上双击运行；把 exe 放到带空格和中文的路径下再试一次；从快捷方式和从命令行各启动一次，确认工作目录不敏感；用一个只读目录模拟 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Program Files&lt;/code&gt;，确认需要写的文件都落在可写位置；最后保留一个带控制台的调试版本，方便对方出问题时抓日志。&lt;/p&gt;

&lt;p&gt;此外我会把构建环境一并记下来：Python 版本、PyInstaller 版本、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requirements.txt&lt;/code&gt;，和产物放在同一个交付目录。隔几个月同学说“新电脑上打不开”，没有这份记录，就只能靠猜是系统组件缺失还是依赖漂移。exe 看起来只是一个文件，背后仍然是一整套版本组合，记录它比事后复现省事得多。&lt;/p&gt;

&lt;h2 id=&quot;小结&quot;&gt;小结&lt;/h2&gt;

&lt;p&gt;单文件 exe 的价值是降低交付成本，但它把路径、编码、依赖这些平时被开发环境掩盖的问题一次性放大了。我的经验是：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-F&lt;/code&gt; 负责分发，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-D&lt;/code&gt; 负责长期维护；只读资源走 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys._MEIPASS&lt;/code&gt;，可写数据走 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;sys.executable&lt;/code&gt; 所在目录；子进程显式传 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;cwd&lt;/code&gt; 和 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;encoding&lt;/code&gt;；构建命令写进脚本，交付前一定在干净机器上试一次。把这些固定下来，打包就不再是“在我电脑上能跑”的玄学。&lt;/p&gt;

</description>
        <pubDate>Mon, 19 Oct 2026 03:14:00 +0000</pubDate>
        <link>https://www.sakura.work.gd/2026/10/19/pyinstaller-single-file-exe.html</link>
        <guid isPermaLink="true">https://www.sakura.work.gd/2026/10/19/pyinstaller-single-file-exe.html</guid>
        
        <category>PyInstaller</category>
        
        <category>Python</category>
        
        <category>exe打包</category>
        
        <category>桌面工具</category>
        
        <category>路径处理</category>
        
        
        <category>软件实战</category>
        
      </item>
    
    
    
      <item>
        <title>用 FastAPI 写带权限的文件上传接口：路径穿越、类型校验与大小限制</title>
        <description>&lt;p&gt;去年给教研组做课堂情绪识别教学平台时，试卷导入是我最没当回事的接口：老师登录后传一份 PDF 或几张答题卡照片，后端存起来，学生端只能看结果。第一版只有二十来行，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;UploadFile&lt;/code&gt; 拿到手，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open(upload.filename, &quot;wb&quot;)&lt;/code&gt; 一写就返回成功。&lt;/p&gt;

&lt;p&gt;后来我用 curl 做异常测试，把文件名改成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;../../config.py&lt;/code&gt;，才意识到这个接口能把文件写到上传目录之外；再换一个学生账号调用，居然也能成功。真正的问题不是一个笔误，而是我把“文件已经收到”当成了“上传已经完成”。之后我按鉴权、命名、限流、验真四件事重写了它，下面这套代码是项目里正在用的简化版。&lt;/p&gt;

&lt;h2 id=&quot;先把上传成功拆成四个条件&quot;&gt;先把“上传成功”拆成四个条件&lt;/h2&gt;

&lt;p&gt;我现在要求接口同时满足四个条件才返回成功：调用者是当前班级的教师；文件大小不超过上限；扩展名和文件内容都在白名单里；最终路径一定落在上传根目录内。&lt;/p&gt;

&lt;p&gt;这四个条件缺一不可，因为它们的失败方式完全不同。只做鉴权，老师传一个超大压缩包就能把磁盘写满；只做大小限制，学生依然能覆盖别人的试卷；只看扩展名，把脚本改成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.png&lt;/code&gt; 就能绕过；只检查路径，又挡不住伪装成图片的恶意内容。&lt;/p&gt;

&lt;p&gt;还要先接受一个现实：上传接口是系统里少数让外部字节直接落到本机磁盘的入口。普通 JSON 接口的输入会被反序列化成受控结构，上传接口收到的却是一段完全不信任的二进制。把它当普通接口写，问题只是暂时还没被发现。&lt;/p&gt;

&lt;h2 id=&quot;文件名只当展示落盘名自己生成&quot;&gt;文件名只当展示，落盘名自己生成&lt;/h2&gt;

&lt;p&gt;路径穿越的根源不是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;open&lt;/code&gt;，而是把调用方给的字符串当成了系统路径。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;upload.filename&lt;/code&gt; 来自 multipart 表单，可以写成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;../../../etc/passwd&lt;/code&gt;，也可以是 Windows 下的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;..\..\config.ini&lt;/code&gt;，甚至夹杂空字节。&lt;/p&gt;

&lt;p&gt;我的做法是：扩展名只用来做白名单判断，真正的落盘名用 UUID 重新生成，原始文件名只存数据库，展示时再转义。这样即使原始名字再离谱，也不会参与磁盘定位。&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;datetime&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;datetime&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;pathlib&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;uuid&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;uuid4&lt;/span&gt;

&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;fastapi&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;ALLOWED_SUFFIX&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;.pdf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.png&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.jpg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;.jpeg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;UPLOAD_ROOT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;data/uploads&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;resolve&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;make_target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;original_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;original_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lower&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ALLOWED_SUFFIX&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status_code&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;415&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;只接受 PDF/PNG/JPEG&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;month_dir&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UPLOAD_ROOT&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;datetime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;now&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;strftime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;%Y%m&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;month_dir&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mkdir&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;parents&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;exist_ok&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;n&quot;&gt;target&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;month_dir&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;uuid4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;().&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;hex&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;suffix&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;resolve&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;relative_to&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;UPLOAD_ROOT&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;ValueError&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# UUID 生成的名字正常不会越界，这里留作兜底
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status_code&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;400&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;非法路径&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这里有几个刻意的选择。用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;resolve()&lt;/code&gt; 而不是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;absolute()&lt;/code&gt;，因为它会展开 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;..&lt;/code&gt; 和符号链接，判断才有意义。用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;relative_to&lt;/code&gt; 而不是字符串 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;startswith&lt;/code&gt;，因为 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/data/uploads2&lt;/code&gt; 也以 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/data/uploads&lt;/code&gt; 开头，字符串比较会放过它。按月份分目录则是运维考虑：单目录堆几十万文件后，备份和排查都会变慢。&lt;/p&gt;

&lt;p&gt;我没有用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;secure_filename&lt;/code&gt; 处理完就直接落盘。它解决的是“名字是否适合展示”，不是“路径是否在根目录内”。把这两件事分开，逻辑才不会互相依赖。原始文件名进数据库之后，前端渲染时也要转义，否则一个带标签的文件名又能变成存储型 XSS。&lt;/p&gt;

&lt;h2 id=&quot;鉴权和大小限制别等文件收完才拒绝&quot;&gt;鉴权和大小限制：别等文件收完才拒绝&lt;/h2&gt;

&lt;p&gt;鉴权我写成依赖，挂在路由上。认证负责“你是谁”，授权负责“你能不能传”，两者都通过才进入业务函数：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;fastapi&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Depends&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;status&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;fastapi.security&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OAuth2PasswordBearer&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;oauth2_scheme&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;OAuth2PasswordBearer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;tokenUrl&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/api/auth/token&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;current_teacher&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Depends&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;oauth2_scheme&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;))&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;User&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;decode_and_load_user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;token&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# 项目里已有的 JWT 校验
&lt;/span&gt;    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HTTP_401_UNAUTHORIZED&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;登录已失效&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;role&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;teacher&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HTTP_403_FORBIDDEN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;只有教师可以上传试卷&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这里有个容易忽略的细节：FastAPI 处理带表单的路由时，会先解析 multipart 请求体，再执行依赖函数。也就是说，鉴权依赖能挡住业务逻辑和落盘，但字节已经进了临时文件。攻击者不带合法 token 反复发大文件，依赖挡得住写入，挡不住带宽和临时磁盘的消耗。&lt;/p&gt;

&lt;p&gt;所以大小限制不能只写在依赖里。我做了两层：反向代理限制请求体大小，接口内部再流式计数。代理层负责尽早切断连接，应用层保证绕过代理直接访问时也不会失控。也不能只信 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Content-Length&lt;/code&gt;，分块传输可以不带这个头，声明的长度也可能和实际不符。&lt;/p&gt;

&lt;p&gt;如果接口允许多文件，我会再限制一次数量，比如一次最多五张。多文件和单文件的风险不一样：每个文件都不大，但数量可以堆出问题。&lt;/p&gt;

&lt;h2 id=&quot;流式写入与内容校验&quot;&gt;流式写入与内容校验&lt;/h2&gt;

&lt;p&gt;真正写盘时，我不用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;shutil.copyfileobj(upload.file, out)&lt;/code&gt; 一把梭。它写起来舒服，但中途不知道已经收了多少字节，只能在结束后才发现超限。下面的写法边读边计数，一超限就抛错，并清掉半截文件：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;fastapi&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UploadFile&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;CHUNK_SIZE&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1024&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1024&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;MAX_BYTES&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;20&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1024&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1024&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;save_with_limit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;upload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;UploadFile&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;total&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;open&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;xb&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;  &lt;span class=&quot;c1&quot;&gt;# xb：目标已存在就失败，避免覆盖
&lt;/span&gt;            &lt;span class=&quot;k&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;upload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;read&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;CHUNK_SIZE&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;total&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;total&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MAX_BYTES&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status_code&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;413&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;文件超过 20 MB&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;out&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;write&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;BaseException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unlink&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;missing_ok&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;total&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;xb&lt;/code&gt; 而不是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;wb&lt;/code&gt;，是让“同名覆盖”变成显式错误。UUID 碰撞概率极低，但让文件系统兜底比让代码假设安全更可靠。捕获 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;BaseException&lt;/code&gt; 而不是只捕获 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Exception&lt;/code&gt;，是为了在客户端断开、任务被取消时也能删掉半截文件。同步写盘会短暂阻塞事件循环，内部工具可以接受；并发上传很高时，可以放进 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;anyio.to_thread.run_sync&lt;/code&gt;，或者把接口写成同步 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;def&lt;/code&gt; 交给线程池。&lt;/p&gt;

&lt;p&gt;写完还不算完，因为 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Content-Type&lt;/code&gt; 和扩展名都由客户端决定。一个 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.png&lt;/code&gt; 完全可以是 HTML 或一段脚本。我在落盘后读文件头，用魔数判断真实类型，失败就删掉刚写的文件：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;MAGIC&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;%PDF-&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;application/pdf&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\x89&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;PNG&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\r\n\x1a\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;image/png&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\xff\xd8\xff&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;image/jpeg&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;sniff_mime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;with&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;open&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;rb&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;head&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;read&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;16&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;magic&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mime&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;MAGIC&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;head&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;startswith&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;magic&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;mime&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unlink&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;missing_ok&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status_code&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;415&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;detail&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;文件内容与扩展名不符&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;魔数校验只是低成本的第一道门：它能挡住改扩展名的明显攻击，挡不住构造合法文件头再拼接恶意内容的文件。要更稳，可以再用 Pillow 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Image.verify()&lt;/code&gt; 校验图片结构，PDF 交给专门解析器。但不管用哪种，都不要把它当病毒扫描，上传目录也永远不要挂到静态文件目录下直接对外提供。&lt;/p&gt;

&lt;p&gt;还有个实现细节值得提醒：如果打算在写入前读文件头，读完必须 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;seek(0)&lt;/code&gt;，否则后续保存会从第十七个字节开始，文件看着上传成功，实际已经损坏。我早期为了省一次读盘就这么干过，排查了半天才发现问题出在校验函数。后来统一改成落盘后校验，虽然多读一次文件头，但状态少了一个，出问题更容易解释。&lt;/p&gt;

&lt;h2 id=&quot;下载接口同样要鉴权&quot;&gt;下载接口同样要鉴权&lt;/h2&gt;

&lt;p&gt;很多人上传做得严，下载却直接 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FileResponse(path)&lt;/code&gt;，路径从查询参数来，权限完全没有。知道 URL 就能下载别人的试卷，前面的鉴权等于白做。&lt;/p&gt;

&lt;p&gt;我的下载路由只接收数据库记录 id，路径从记录里取，不接收任何文件系统路径：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;o&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;router&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;/papers/{paper_id}/file&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;k&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;download_paper&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;paper_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;User&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Depends&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;current_user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;AsyncSession&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;Depends&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get_db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;paper&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;db&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Paper&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;paper_id&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;paper&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status_code&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;404&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;role&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;teacher&quot;&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;paper&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;class_id&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;user&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;class_ids&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;raise&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HTTPException&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;status_code&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;403&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;FileResponse&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;paper&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;storage_path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;media_type&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;paper&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;mime&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;filename&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;paper&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;original_name&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;headers&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;X-Content-Type-Options&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;nosniff&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;},&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;关键不在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;FileResponse&lt;/code&gt; 本身，而在两点：可下载的前提是数据库里存在这条记录，并且当前用户对记录所属班级有权限。前者避免任意路径读取，后者避免“URL 藏得深就是安全”的错觉。&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;filename&lt;/code&gt; 只影响响应头，不参与磁盘定位；&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;nosniff&lt;/code&gt; 则告诉浏览器不要猜类型。&lt;/p&gt;

&lt;p&gt;另外，上传目录放在应用包之外，数据库只存相对路径，运行时再和根目录拼接。这样换机器部署不用改一库绝对路径，清理任务也能在同一个根目录里先校验再删。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;环节&lt;/th&gt;
      &lt;th&gt;我最初的做法&lt;/th&gt;
      &lt;th&gt;实际后果&lt;/th&gt;
      &lt;th&gt;现在的做法&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;文件名&lt;/td&gt;
      &lt;td&gt;直接用上传文件名拼接&lt;/td&gt;
      &lt;td&gt;可写到任意目录&lt;/td&gt;
      &lt;td&gt;UUID 重命名，原名只入库&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;大小&lt;/td&gt;
      &lt;td&gt;文档里写“最大 20 MB”&lt;/td&gt;
      &lt;td&gt;磁盘和带宽失控&lt;/td&gt;
      &lt;td&gt;代理层与应用层双重限制&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;类型&lt;/td&gt;
      &lt;td&gt;只看 Content-Type&lt;/td&gt;
      &lt;td&gt;改扩展名即可绕过&lt;/td&gt;
      &lt;td&gt;后缀白名单加魔数校验&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;鉴权&lt;/td&gt;
      &lt;td&gt;登录即可上传&lt;/td&gt;
      &lt;td&gt;学生也能覆盖试卷&lt;/td&gt;
      &lt;td&gt;角色与班级双重检查&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;下载&lt;/td&gt;
      &lt;td&gt;查询参数传路径&lt;/td&gt;
      &lt;td&gt;知道 URL 就能读文件&lt;/td&gt;
      &lt;td&gt;按记录 id 查并校验归属&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;小结&quot;&gt;小结&lt;/h2&gt;

&lt;p&gt;这次改造之后，我给自己留了一份检查顺序：上传入口先问“谁能调”，再问“多大”，然后问“是不是它声称的类型”，最后问“落在哪”。它们分别对应鉴权依赖、双层大小限制、魔数或解析器校验、UUID 命名与根目录校验。&lt;/p&gt;

&lt;p&gt;最容易被低估的是这几个等号：鉴权不等于限流，扩展名不等于内容，URL 不等于权限。上传接口看起来只是收一个文件，实际上同时处理网络、磁盘、内容解析和访问控制四个边界。分开写、分开测，代码不会长多少，出问题时却能立刻知道该看哪一层。&lt;/p&gt;

</description>
        <pubDate>Sun, 18 Oct 2026 22:03:00 +0000</pubDate>
        <link>https://www.sakura.work.gd/2026/10/18/fastapi-authenticated-file-upload.html</link>
        <guid isPermaLink="true">https://www.sakura.work.gd/2026/10/18/fastapi-authenticated-file-upload.html</guid>
        
        <category>FastAPI</category>
        
        <category>文件上传</category>
        
        <category>接口安全</category>
        
        <category>Python</category>
        
        <category>权限控制</category>
        
        <category>流式处理</category>
        
        
        <category>软件实战</category>
        
      </item>
    
    
    
      <item>
        <title>用 tkinter 和 pyserial 给 STM32 写一个串口调试上位机</title>
        <description>&lt;p&gt;上个月给一块 STM32F103 的小板子调 MPU6050，需要连着看三路采样值。串口助手能把 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;printf&lt;/code&gt; 出来的东西显示出来，但它有三个我忍不了的地方：数据是纯文本，想算个滑动平均得先复制粘贴进 Excel；加了时间戳和通道号之后每行变得很长，滚动起来眼睛跟不上；偶发丢帧的时候只能靠一行行肉眼比对，根本判断不了是数据错了还是显示错了。&lt;/p&gt;

&lt;p&gt;于是一个晚上写了个不到 300 行的上位机。这篇文章把它拆开讲，重点只有三个地方：读串口为什么必须放子线程、帧怎么分、界面怎么刷新才不卡。&lt;/p&gt;

&lt;h2 id=&quot;目录结构&quot;&gt;目录结构&lt;/h2&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;serial-monitor/
├── main.py            # 入口：装配 worker 和窗口
├── serial_worker.py   # 读串口线程 + 分帧循环
├── protocol.py        # crc8 与结构体打包
└── requirements.txt
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;requirements.txt&lt;/code&gt; 只有一行 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;pyserial&lt;/code&gt;。tkinter 是标准库，不用装。&lt;/p&gt;

&lt;h2 id=&quot;为什么不用-qt-或网页&quot;&gt;为什么不用 Qt 或网页&lt;/h2&gt;

&lt;p&gt;这类小工具最怕的是发给同学装不上。PyQt 功能确实强，但要把 Qt 运行库一起打包，分发体积明显更大，而且 PyQt 的 GPL 授权在实验室以外的地方需要留意；换成网页方案（本地 Flask 加浏览器）跨平台最省事，可又得多维护一个进程和端口，插拔串口时页面还得自己处理重连。我的使用场景就是插上开发板看几路数据，界面只有下拉框、按钮和文本框，tkinter 够用，而且它在 Python 标准库里，别人拿到源码双击就能跑。选型的标准不是”哪个更专业”，而是”谁维护成本最低”。&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;方案&lt;/th&gt;
      &lt;th&gt;依赖&lt;/th&gt;
      &lt;th&gt;分发成本&lt;/th&gt;
      &lt;th&gt;适合场景&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;tkinter&lt;/td&gt;
      &lt;td&gt;Python 标准库&lt;/td&gt;
      &lt;td&gt;最小，PyInstaller 单文件即可&lt;/td&gt;
      &lt;td&gt;内部调试工具、界面简单&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;PyQt/PySide&lt;/td&gt;
      &lt;td&gt;第三方 Qt 运行库&lt;/td&gt;
      &lt;td&gt;体积更大，需注意许可证&lt;/td&gt;
      &lt;td&gt;复杂交互、长期维护的上位机&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;本地 Web 页面&lt;/td&gt;
      &lt;td&gt;浏览器 + Web 框架&lt;/td&gt;
      &lt;td&gt;多一个常驻服务&lt;/td&gt;
      &lt;td&gt;多人访问、远程查看&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;第一步把读串口放进子线程&quot;&gt;第一步：把读串口放进子线程&lt;/h2&gt;

&lt;p&gt;这是整个项目最容易写错的地方。tkinter 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mainloop()&lt;/code&gt; 是一个单线程事件循环，所有控件操作都必须在这个线程里完成。如果直接在按钮回调里写 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;while True: ser.readline()&lt;/code&gt;，mainloop 就再也拿不到 CPU，窗口几秒内变成”未响应”。&lt;/p&gt;

&lt;p&gt;反过来，在子线程里直接 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;text.insert()&lt;/code&gt; 同样不行。tkinter 内部没有对控件加锁，跨线程改控件在 Windows 上往往不报错，但会表现为偶发的界面不刷新、滚动条卡死，甚至进程直接退出。这类问题比崩溃更难查，因为它不复现。&lt;/p&gt;

&lt;p&gt;所以我给自己定的规矩是：&lt;strong&gt;子线程只碰 queue，主线程只碰控件&lt;/strong&gt;。&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# serial_worker.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;queue&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;threading&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;serial&lt;/span&gt;

&lt;span class=&quot;kn&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;protocol&lt;/span&gt; &lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;unpack_frame&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;SerialWorker&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;只负责 IO：解析好的帧丢进 rx，错误丢进 err&quot;&quot;&quot;&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;__init__&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;queue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Queue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;queue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Queue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rx&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rx&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;err&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;err&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_ser&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_stop&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;threading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Event&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_thread&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;open&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;port&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;baud&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;115200&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_ser&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;serial&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Serial&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;port&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;port&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;baudrate&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;baud&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;bytesize&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;parity&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;N&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;stopbits&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;0.05&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# 关键：不能是 None
&lt;/span&gt;        &lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_stop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clear&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_thread&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;threading&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Thread&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;target&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_loop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;daemon&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_thread&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_loop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bytearray&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_stop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;():&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_ser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;read&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;256&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;serial&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;SerialException&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;err&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;put&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;串口断开：&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;e&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;not&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;k&quot;&gt;continue&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;extend&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;chunk&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                &lt;span class=&quot;n&quot;&gt;frame&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;unpack_frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;   &lt;span class=&quot;c1&quot;&gt;# 不够一帧返回 None
&lt;/span&gt;                &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;frame&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;is&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
                    &lt;span class=&quot;k&quot;&gt;break&lt;/span&gt;
                &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;put&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;close&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_stop&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;set&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_thread&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_thread&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;timeout&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_ser&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;and&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_ser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;is_open&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_ser&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;close&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;timeout=0.05&lt;/code&gt; 是这段代码里唯一必须解释的参数。pyserial 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;read()&lt;/code&gt; 在 timeout 为 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;None&lt;/code&gt; 时会一直阻塞到读够字节数，这时 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_stop&lt;/code&gt; 设了也没用，关窗口的瞬间线程还卡在 read 上，进程退不掉，任务管理器里留一个僵尸。给个短超时，read 每 50ms 就返回一次空字节串，循环重新检查 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_stop&lt;/code&gt;，退出才是干净的。&lt;/p&gt;

&lt;h2 id=&quot;第二步帧怎么分&quot;&gt;第二步：帧怎么分&lt;/h2&gt;

&lt;p&gt;一开始我用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;printf(&quot;%d,%d\n&quot;, ch, val)&lt;/code&gt; 加换行分隔，图省事。问题出在两处：某天我想在帧里塞一个二进制状态字，分帧就全乱了；另外一旦因为干扰丢了一个字节，后面所有行都错位，而且错得很像”对的”，肉眼几乎发现不了。&lt;/p&gt;

&lt;p&gt;改成带包头、长度和校验的帧：&lt;/p&gt;

&lt;div class=&quot;language-plaintext highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;0xAA 0x55 | len(1) | payload(len) | crc8(1)
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;payload 固定 3 字节：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;channel(int8) + value(int16 小端，单位 0.01)&lt;/code&gt;。&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;c1&quot;&gt;# protocol.py
&lt;/span&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;struct&lt;/span&gt;

&lt;span class=&quot;n&quot;&gt;HEAD&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;b&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\xaa\x55&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;crc8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;CRC-8/ATM：poly=0x07, init=0x00&quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0x00&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;b&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;^=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;b&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;range&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;^&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0x07&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0xFF&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0x80&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0xFF&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;pack_frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;float&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;struct&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;pack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&amp;lt;Bh&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;round&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;100&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)))&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;HEAD&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)])&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;crc8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)])&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;unpack_frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bytearray&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;从缓冲区头部切一帧出来；不够一帧返回 None，脏字节直接丢&quot;&quot;&quot;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;True&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;find&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;HEAD&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;clear&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# 全是垃圾，清空重来
&lt;/span&gt;            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;del&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[:&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;      &lt;span class=&quot;c1&quot;&gt;# 丢掉包头之前的噪声
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;4&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# 连头部都还没收全
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;length&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;total&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;length&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;total&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;          &lt;span class=&quot;c1&quot;&gt;# 半包，等下一次 read 补齐
&lt;/span&gt;        &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;bytes&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;length&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;length&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;del&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[:&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;total&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;crc8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;!=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;continue&lt;/span&gt;             &lt;span class=&quot;c1&quot;&gt;# 校验错，继续往后找包头
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;struct&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;unpack&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&amp;lt;Bh&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;length&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这段里真正重要的是两个 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;return None&lt;/code&gt;：一个在”长度不够”时返回，一个在”包头还没来”时返回。而且 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;buf&lt;/code&gt; 必须留在函数外面（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_loop&lt;/code&gt; 里的那个 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;bytearray&lt;/code&gt;），因为一次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;read(256)&lt;/code&gt; 拿到的可能是 1.7 帧，也可能只有半帧。我最早的版本把 buf 写在函数内部，半包永远拼不起来——表现就是连上之后三分钟内必然丢一段数据。&lt;/p&gt;

&lt;p&gt;STM32 那边对应着发就行，HAL 库版本：&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;crc8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0x00&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;while&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;len&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;--&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;^=&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;data&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0x80&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;^&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0x07&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;crc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;report_sample&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;int8_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;int16_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;raw&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raw&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0xFF&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)((&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;raw&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0xFF&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;7&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0xAA&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;mh&quot;&gt;0x55&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;sizeof&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;};&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;memcpy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;sizeof&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;6&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;]&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;crc8&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;sizeof&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;payload&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;));&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;HAL_UART_Transmit&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;huart1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;sizeof&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;frame&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;),&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;10&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这里唯一容易错的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;frame[7]&lt;/code&gt; 的下标：包头 2 + 长度 1 + payload 3 + 校验 1。我第一版写成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uint8_t frame[5]&lt;/code&gt;，编译不报错，运行时把栈上的相邻变量踩掉，查了两个小时。&lt;/p&gt;

&lt;h2 id=&quot;第三步界面怎么刷新才不卡&quot;&gt;第三步：界面怎么刷新才不卡&lt;/h2&gt;

&lt;p&gt;布局本身没什么好讲的：一个 Combobox 选串口、一个按钮开关、一个 Text 显示。有讲究的是刷新策略。&lt;/p&gt;

&lt;p&gt;如果每收到一帧就 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;insert&lt;/code&gt; 一次，115200 下数据一密，mainloop 的时间几乎全花在 Text 重排上，窗口拖都拖不动。我的做法是每 30ms 从队列里批量取一次，一次最多 200 条：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;_poll&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;[]&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;_&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;range&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;200&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;try&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;rx&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get_nowait&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;except&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;queue&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Empty&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;k&quot;&gt;break&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;append&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;CH&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;  &lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;mf&quot;&gt;8.2&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;configure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;normal&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;insert&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;end&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;join&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;lines&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;se&quot;&gt;\n&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# 只保留最近 2000 行，否则重排会越来越慢
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;int&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;index&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;end-1c&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;).&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;split&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;.&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2000&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;delete&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;1.0&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;500.0&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;see&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;end&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;text&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;configure&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;state&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;disabled&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
    &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;after&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;30&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_poll&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;after(30)&lt;/code&gt; 是在 mainloop 里排一个定时回调，语义就是”30 毫秒后让主线程再跑一次这个函数”，它不占线程也不阻塞事件处理。这比 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;threading.Timer&lt;/code&gt; 好：后者又开一个线程，等于把最开始的问题绕回来了。&lt;/p&gt;

&lt;p&gt;另外 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Text&lt;/code&gt; 设成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;state=&apos;disabled&apos;&lt;/code&gt; 后用户不能编辑，但 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;insert&lt;/code&gt; 之前必须临时切回 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;normal&lt;/code&gt;，忘了就是”插不进去还不报错”。&lt;/p&gt;

&lt;h2 id=&quot;采样数据怎么落盘&quot;&gt;采样数据怎么落盘&lt;/h2&gt;

&lt;p&gt;界面解决的是”我看见”，分析解决的是”我回看”。只把数据留在 Text 控件里，关掉程序就没了，所以我在同一个工具里加了 CSV 记录：勾选”记录”后，每次从队列取出的帧顺手写一行，文件名按启动时间生成。&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;csv&lt;/span&gt;
&lt;span class=&quot;kn&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;nn&quot;&gt;time&lt;/span&gt;


&lt;span class=&quot;k&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;nc&quot;&gt;CsvRecorder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
    &lt;span class=&quot;s&quot;&gt;&quot;&quot;&quot;只在主线程调用：一次写一批，定时 flush&quot;&quot;&quot;&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;__init__&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;str&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_f&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;nb&quot;&gt;open&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;path&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;w&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;newline&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;encoding&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;=&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;utf-8-sig&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_w&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;csv&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;writer&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_w&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;writerow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;t_s&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;channel&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&apos;value&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_t0&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_last_flush&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;write&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rows&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;value&lt;/span&gt; &lt;span class=&quot;ow&quot;&gt;in&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;rows&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_w&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;writerow&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;([&lt;/span&gt;&lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_t0&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;3&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;sa&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;value&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;f&lt;/span&gt;&lt;span class=&quot;si&quot;&gt;}&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;])&lt;/span&gt;
        &lt;span class=&quot;c1&quot;&gt;# 每秒落一次盘，避免每行 flush 卡住界面
&lt;/span&gt;        &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;-&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_last_flush&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;gt;=&lt;/span&gt; &lt;span class=&quot;mf&quot;&gt;1.0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
            &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;flush&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
            &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_last_flush&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;time&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

    &lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;close&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;flush&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;_f&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;close&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;三个细节值得说清楚：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;encoding=&apos;utf-8-sig&apos;&lt;/code&gt; 不是为了好看。Windows 版 Excel 默认不认纯 UTF-8 的 CSV，表头里的中文会变成乱码；加 BOM 之后双击就能正确打开，这个和以前折腾 GBK 是同一类问题。&lt;/li&gt;
  &lt;li&gt;不在串口线程里写文件。磁盘偶尔卡一下，如果发生在读线程里，缓冲区就会堆起来，看起来像串口丢数据。写文件统一放到主线程的批量回调里，读线程只负责收字节。&lt;/li&gt;
  &lt;li&gt;用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;flush()&lt;/code&gt; 控制频率而不是每行都刷。每行 flush 等于每 30 毫秒强制一次磁盘写入，界面在机械硬盘上会明显发顿；每秒刷一次，最坏丢一秒数据，对调 PID 这类场景完全可以接受。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;关窗口时也要把记录器关掉，否则 Python 退出时可能还没来得及刷缓冲区：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;on_close&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;):&lt;/span&gt;
    &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;worker&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;close&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;recorder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt;
        &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;recorder&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;close&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;
    &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;root&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;destroy&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt;

&lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;root&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;protocol&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&apos;WM_DELETE_WINDOW&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;bp&quot;&gt;self&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;on_close&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WM_DELETE_WINDOW&lt;/code&gt; 是 tkinter 的关闭钩子，不注册它，点右上角的叉只会销毁窗口，后台线程和文件对象都还活着。&lt;/p&gt;

&lt;h2 id=&quot;打包成-exe&quot;&gt;打包成 exe&lt;/h2&gt;

&lt;div class=&quot;language-bash highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;pyinstaller &lt;span class=&quot;nt&quot;&gt;-F&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-w&lt;/span&gt; &lt;span class=&quot;nt&quot;&gt;-n&lt;/span&gt; SerialMonitor &lt;span class=&quot;nt&quot;&gt;--icon&lt;/span&gt; app.ico &lt;span class=&quot;se&quot;&gt;\&lt;/span&gt;
  &lt;span class=&quot;nt&quot;&gt;--hidden-import&lt;/span&gt; serial.tools.list_ports main.py
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-F&lt;/code&gt; 打单文件，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;-w&lt;/code&gt; 去掉黑色控制台窗口。加 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--hidden-import&lt;/code&gt; 是因为我用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;serial.tools.list_ports.comports()&lt;/code&gt; 枚举串口，PyInstaller 的静态分析抓不到这种间接 import，不打这个参数，生成的 exe 一运行就 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ModuleNotFoundError&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;单文件 exe 每次启动会先把自己解压到临时目录，所以启动比源码慢一点。这是个可以接受的交换：发给实验室同学一个文件就能用，不用教他们装 Python 和驱动。&lt;/p&gt;

&lt;h2 id=&quot;三种分帧方式的取舍&quot;&gt;三种分帧方式的取舍&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;分帧方式&lt;/th&gt;
      &lt;th&gt;优点&lt;/th&gt;
      &lt;th&gt;缺点&lt;/th&gt;
      &lt;th&gt;适合&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;换行分隔文本&lt;/td&gt;
      &lt;td&gt;一行 printf 就能用，最省事&lt;/td&gt;
      &lt;td&gt;二进制里出现 0x0A 就误判；解析要 atof&lt;/td&gt;
      &lt;td&gt;只打日志、不带结构化数据&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;定长帧&lt;/td&gt;
      &lt;td&gt;解析最快，不用搜包头&lt;/td&gt;
      &lt;td&gt;改一个字段两端都得改；丢一字节就永久错位&lt;/td&gt;
      &lt;td&gt;固定结构的高频采样&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;包头 + 长度 + CRC&lt;/td&gt;
      &lt;td&gt;能自恢复，能识别坏帧&lt;/td&gt;
      &lt;td&gt;多二十行代码，要处理半包&lt;/td&gt;
      &lt;td&gt;长时间运行、有干扰的现场&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;我最后选第三种，理由不是”更专业”，而是板子和电脑之间那根杜邦线经常被碰。第二种方案里，碰一下之后必须重启程序才能恢复，这个代价比多写二十行代码高得多。&lt;/p&gt;

&lt;h2 id=&quot;调试时最常遇到的几种情况&quot;&gt;调试时最常遇到的几种情况&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;现象&lt;/th&gt;
      &lt;th&gt;大概率原因&lt;/th&gt;
      &lt;th&gt;处理&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;打开串口报 PermissionError&lt;/td&gt;
      &lt;td&gt;串口助手或调试器还占着这个口&lt;/td&gt;
      &lt;td&gt;关掉占用它的程序&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;收到一堆乱码&lt;/td&gt;
      &lt;td&gt;两端波特率不一致，或 STM32 时钟树配错&lt;/td&gt;
      &lt;td&gt;都设 115200，核对 HSE 与 PLL&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;前几帧正常，后面全错位&lt;/td&gt;
      &lt;td&gt;半包没处理，把半帧当整帧切了&lt;/td&gt;
      &lt;td&gt;按长度字段判断是否收全&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;拔掉 USB 后程序卡死&lt;/td&gt;
      &lt;td&gt;子线程在 read 上抛异常没捕获&lt;/td&gt;
      &lt;td&gt;捕获 SerialException，向队列发错误&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;最后一条我真踩过：线被碰掉时 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_ser.read()&lt;/code&gt; 抛 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;SerialException&lt;/code&gt;，如果 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;_loop&lt;/code&gt; 里没有 try，线程会静默死掉，界面还显示”已连接”，然后一直等一个再也不会来的数据。&lt;/p&gt;

&lt;h2 id=&quot;小结&quot;&gt;小结&lt;/h2&gt;

&lt;p&gt;整个工具不到 300 行，但把几件事说清楚了：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;串口 IO 一定放子线程，线程和数据的边界画清：子线程只往 queue 里放，主线程只从 queue 里取；&lt;/li&gt;
  &lt;li&gt;分帧要有长度和校验，缓冲区放在循环外，半包要能等下一次 read；&lt;/li&gt;
  &lt;li&gt;界面刷新要合并，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;after()&lt;/code&gt; 批处理比”来一帧刷一次”稳。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;再往下能加的东西不少：把数据写成 CSV、用 matplotlib 画实时曲线、命令下发也走同一套帧格式。但先把这三件事做对，这个工具已经能天天用了。&lt;/p&gt;
</description>
        <pubDate>Sun, 18 Oct 2026 15:31:00 +0000</pubDate>
        <link>https://www.sakura.work.gd/2026/10/18/stm32-serial-monitor-python.html</link>
        <guid isPermaLink="true">https://www.sakura.work.gd/2026/10/18/stm32-serial-monitor-python.html</guid>
        
        <category>Python</category>
        
        <category>pyserial</category>
        
        <category>tkinter</category>
        
        <category>STM32</category>
        
        <category>串口通信</category>
        
        
        <category>项目</category>
        
      </item>
    
    
    
      <item>
        <title>STM32 的 ADC 采样什么时候必须上 DMA：三种配置的取舍</title>
        <description>&lt;p&gt;前阵子做一个采集板，要同时看三路模拟量：一路电位器、一路 NTC 热敏、一路电池分压。一开始图省事，用最朴素的写法——主循环里挨个通道 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_ADC_Start&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_ADC_PollForConversion&lt;/code&gt; 再读值。跑起来才发现两个问题：三路读数之间的时间间隔完全不可控，而且一旦主循环里有别的事情耽搁，采样点就稀了。&lt;/p&gt;

&lt;p&gt;这篇把 ADC 采样的三种配置讲清楚，以及为什么多通道场景基本绕不开 DMA。&lt;/p&gt;

&lt;h2 id=&quot;先看三种配置的差别&quot;&gt;先看三种配置的差别&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;配置&lt;/th&gt;
      &lt;th&gt;触发方式&lt;/th&gt;
      &lt;th&gt;CPU 占用&lt;/th&gt;
      &lt;th&gt;采样间隔&lt;/th&gt;
      &lt;th&gt;适用场景&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;单次转换 + 轮询&lt;/td&gt;
      &lt;td&gt;软件逐次启动&lt;/td&gt;
      &lt;td&gt;高（阻塞等待）&lt;/td&gt;
      &lt;td&gt;不确定，取决于主循环&lt;/td&gt;
      &lt;td&gt;上电自检、偶尔测一次&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;连续转换 + 轮询读&lt;/td&gt;
      &lt;td&gt;启动一次后自动循环&lt;/td&gt;
      &lt;td&gt;中&lt;/td&gt;
      &lt;td&gt;由 ADC 时钟决定，均匀&lt;/td&gt;
      &lt;td&gt;单通道、要求不高的场合&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;连续转换 + DMA&lt;/td&gt;
      &lt;td&gt;启动一次后自动搬运&lt;/td&gt;
      &lt;td&gt;极低&lt;/td&gt;
      &lt;td&gt;均匀，且与 CPU 无关&lt;/td&gt;
      &lt;td&gt;多通道扫描、波形采集&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;p&gt;关键差别不在”快”，而在&lt;strong&gt;确定性&lt;/strong&gt;。轮询写法里，两次采样之间隔多久取决于你那轮主循环跑了什么；DMA 写法里，采样节奏由 ADC 自己的时钟和 DMA 请求决定，主循环忙不忙都不影响。&lt;/p&gt;

&lt;h2 id=&quot;单通道连续转换就够了&quot;&gt;单通道：连续转换就够了&lt;/h2&gt;

&lt;p&gt;如果只采一路，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ContinuousConvMode = ENABLE&lt;/code&gt; 配一次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_ADC_Start()&lt;/code&gt;，之后随时读 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ADC1-&amp;gt;DR&lt;/code&gt; 就是最新值。这种写法很多教程在用，也确实是单通道最简单的方式。&lt;/p&gt;

&lt;p&gt;但它有个隐式前提：&lt;strong&gt;你读得比它转得快&lt;/strong&gt;。12 位 ADC 在 14MHz ADC 时钟、239.5 周期采样时间下，一次转换大约 18 微秒。如果主循环里有延时或者阻塞操作，两次读数之间实际跨了好几次转换，你读到的只是其中一个。对慢变量（比如温度）没关系，对波形就是灾难。&lt;/p&gt;

&lt;h2 id=&quot;多通道扫描模式必须配-dma&quot;&gt;多通道：扫描模式必须配 DMA&lt;/h2&gt;

&lt;p&gt;这是最容易踩的地方。开了 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;ScanConvMode = ENABLE&lt;/code&gt; 之后，ADC 会按规则组的顺序依次转换各个通道，但&lt;strong&gt;数据寄存器 DR 只有一个&lt;/strong&gt;。转换完通道 1 的数据会在通道 2 转换完成时被覆盖。&lt;/p&gt;

&lt;p&gt;也就是说，不开 DMA 的扫描模式，你在序列末尾读到的永远是最后一个通道的值，前面几个通道的数据全丢了。这不是配置错了，是硬件结构决定的。&lt;/p&gt;

&lt;p&gt;正确做法是让 DMA 在每次 EOC 时把 DR 搬走：&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;cp&quot;&gt;#define ADC_CH_NUM   3
#define ADC_BUF_LEN  (ADC_CH_NUM * 8)   &lt;/span&gt;&lt;span class=&quot;cm&quot;&gt;/* 每个通道 8 个点，共 24 个半字 */&lt;/span&gt;&lt;span class=&quot;cp&quot;&gt;
&lt;/span&gt;
&lt;span class=&quot;kt&quot;&gt;uint16_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;adc_buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ADC_BUF_LEN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;

&lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;adc_init&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;cm&quot;&gt;/* CubeMX 里对应的配置：
       ScanConvMode        = ENABLE
       ContinuousConvMode  = ENABLE
       DMAContinuousRequests = ENABLE
       NbrOfConversion     = 3
       规则组顺序：Rank1=IN0(电位器) Rank2=IN1(NTC) Rank3=IN2(电池)
    */&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;HAL_ADCEx_Calibration_Start&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hadc1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;          &lt;span class=&quot;cm&quot;&gt;/* F1/F4 需要先校准 */&lt;/span&gt;
    &lt;span class=&quot;n&quot;&gt;HAL_ADC_Start_DMA&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hadc1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;uint32_t&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;adc_buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ADC_BUF_LEN&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_ADC_Start_DMA&lt;/code&gt; 的第三个参数是&lt;strong&gt;传输次数&lt;/strong&gt;，不是字节数。这里缓冲区是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uint16_t&lt;/code&gt;，DMA 配置成半字（Half Word）宽度，所以 24 个元素就传 24 次。&lt;/p&gt;

&lt;h2 id=&quot;环形缓冲--半传输中断不要每次都等整块填满&quot;&gt;环形缓冲 + 半传输中断：不要每次都等整块填满&lt;/h2&gt;

&lt;p&gt;DMA 配成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;DMA_CIRCULAR&lt;/code&gt; 之后会一直循环填这块缓冲。如果只在”传输完成”中断里处理，你会一次拿到一整块，但处理期间新数据已经在覆盖缓冲区开头了。&lt;/p&gt;

&lt;p&gt;标准的做法是用&lt;strong&gt;半传输 + 传输完成&lt;/strong&gt;两个中断做双缓冲：&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;k&quot;&gt;static&lt;/span&gt; &lt;span class=&quot;k&quot;&gt;volatile&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;uint8_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;half_ready&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;

&lt;span class=&quot;cm&quot;&gt;/* 前半块填满 */&lt;/span&gt;
&lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;HAL_ADC_ConvHalfCpltCallback&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ADC_HandleTypeDef&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hadc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hadc&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Instance&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ADC1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;process_block&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;adc_buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ADC_BUF_LEN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;

&lt;span class=&quot;cm&quot;&gt;/* 后半块填满 */&lt;/span&gt;
&lt;span class=&quot;kt&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;HAL_ADC_ConvCpltCallback&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ADC_HandleTypeDef&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hadc&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;hadc&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;-&amp;gt;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;Instance&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;==&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ADC1&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;process_block&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;&amp;amp;&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;adc_buf&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;ADC_BUF_LEN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;],&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ADC_BUF_LEN&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;2&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;);&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;这样”填前半块”和”处理后半块”是并行的，只要 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;process_block&lt;/code&gt; 的耗时小于半块填满的时间，就永远不会丢数据。&lt;/p&gt;

&lt;p&gt;&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;process_block&lt;/code&gt; 里只做轻活——求平均、判阈值、塞进队列。&lt;strong&gt;不要在回调里做浮点运算和串口打印&lt;/strong&gt;，中断里拖太久，下一半块照样会被覆盖。&lt;/p&gt;

&lt;h2 id=&quot;四个我踩过的坑&quot;&gt;四个我踩过的坑&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;一、采样时间给太短，读数偏低。&lt;/strong&gt;
NTC 前面串了 10k 分压，源阻抗不低。采样时间用默认的 1.5 周期时，采样电容还没充满就转换了，读数系统性偏低。改成 239.5 周期后正常。经验值：源阻抗超过 10k，采样时间往大了给。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;二、忘了校准。&lt;/strong&gt;
F1/F4 系列上电后要跑一次 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HAL_ADCEx_Calibration_Start()&lt;/code&gt;，不做的话零点和增益都有偏差。这个坑很安静——数据”看起来能用”，只是不准。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;三、DMA 缓冲区和 ADC 对齐问题。&lt;/strong&gt;
用 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;uint16_t&lt;/code&gt; 缓冲时，DMA 的 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;MemDataAlignment&lt;/code&gt; 必须设成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HALFWORD&lt;/code&gt;。设成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;WORD&lt;/code&gt; 会出现两个通道数据拼在一个 32 位里，解析出来全是乱的。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;四、多通道数据交错，不是分块。&lt;/strong&gt;
DMA 搬过来的是 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;[CH0, CH1, CH2, CH0, CH1, CH2, ...]&lt;/code&gt; 这样按转换顺序交错的，不是先存满 CH0 再存 CH1。所以取某一路要按步长跳：&lt;/p&gt;

&lt;div class=&quot;language-c highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;cm&quot;&gt;/* 取第 ch 路在本次块内的平均 */&lt;/span&gt;
&lt;span class=&quot;kt&quot;&gt;uint16_t&lt;/span&gt; &lt;span class=&quot;nf&quot;&gt;channel_avg&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;k&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;uint16_t&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;*&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;block&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;block_len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;uint32_t&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;sum&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cnt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;int&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ch&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;&amp;lt;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;block_len&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;i&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;ADC_CH_NUM&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;sum&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;+=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;block&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;i&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;];&lt;/span&gt;
        &lt;span class=&quot;n&quot;&gt;cnt&lt;/span&gt;&lt;span class=&quot;o&quot;&gt;++&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;k&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cnt&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;?&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;kt&quot;&gt;uint16_t&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)(&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;sum&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;/&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;cnt&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;;&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;h2 id=&quot;什么时候不需要-dma&quot;&gt;什么时候不需要 DMA&lt;/h2&gt;

&lt;p&gt;也别为了”规范”硬上。下面这几种情况，轮询反而更省事：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;上电只测一次电池电压&lt;/li&gt;
  &lt;li&gt;采样频率低于 1Hz 的慢变量（比如环境温度）&lt;/li&gt;
  &lt;li&gt;内存极度紧张的小容量型号（DMA 缓冲区也是 RAM）&lt;/li&gt;
  &lt;li&gt;只需要一路、且主循环节奏稳定的场景&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;判断标准很简单：&lt;strong&gt;采样点之间的时间间隔对你有没有意义&lt;/strong&gt;。没意义就轮询，有意义就上 DMA。&lt;/p&gt;

&lt;h2 id=&quot;小结&quot;&gt;小结&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;单通道、要求不高：连续转换 + 随时读 DR&lt;/li&gt;
  &lt;li&gt;多通道：必须 DMA，否则只能拿到最后一个通道&lt;/li&gt;
  &lt;li&gt;要波形或要均匀采样：连续 + 环形 DMA + 半传输中断双缓冲&lt;/li&gt;
  &lt;li&gt;回调里只做轻活，重活丢给主循环&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ADC 本身不难，难的是”你以为它在按你想的节奏采”。把采样时序画出来，很多问题一眼就看得到。&lt;/p&gt;

</description>
        <pubDate>Sat, 17 Oct 2026 07:35:00 +0000</pubDate>
        <link>https://www.sakura.work.gd/2026/10/17/stm32-adc-dma.html</link>
        <guid isPermaLink="true">https://www.sakura.work.gd/2026/10/17/stm32-adc-dma.html</guid>
        
        <category>STM32</category>
        
        <category>ADC</category>
        
        <category>DMA</category>
        
        <category>嵌入式</category>
        
        <category>硬件设计</category>
        
        
        <category>硬件设计</category>
        
      </item>
    
    
    
      <item>
        <title>我的学习仓库地图：STM32、51 单片机、OpenMV、JavaWeb、MySQL</title>
        <description>&lt;p&gt;除了几个完整项目，我 GitHub 上还有一批”学习型仓库”。它们不像项目那样有完整的产品形态，但每个都按主题整理过，复习的时候比翻硬盘靠谱。这篇把它们串起来。&lt;/p&gt;

&lt;h2 id=&quot;嵌入式方向&quot;&gt;嵌入式方向&lt;/h2&gt;

&lt;h3 id=&quot;stm32-实验合集--30-个实验&quot;&gt;&lt;a href=&quot;https://github.com/sakurajh/Stm32&quot;&gt;STM32 实验合集&lt;/a&gt; —— 30 个实验&lt;/h3&gt;

&lt;p&gt;STM32F103，正点原子风格的工程结构（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;HARDWARE/&lt;/code&gt; + &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;USER/&lt;/code&gt;），从跑马灯一路到 UCOSII：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;阶段&lt;/th&gt;
      &lt;th&gt;实验&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;基础外设&lt;/td&gt;
      &lt;td&gt;跑马灯 · 按键 · 串口 · 外部中断 · 看门狗&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;定时器&lt;/td&gt;
      &lt;td&gt;定时器中断 · PWM · 输入捕获 · 电容触摸&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;显示交互&lt;/td&gt;
      &lt;td&gt;数码管 · USMART 调试组件&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;模拟量&lt;/td&gt;
      &lt;td&gt;ADC · DMA&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;总线通信&lt;/td&gt;
      &lt;td&gt;IIC · SPI · 红外遥控&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;传感器&lt;/td&gt;
      &lt;td&gt;DS18B20 · DHT11&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;USB&lt;/td&gt;
      &lt;td&gt;虚拟串口 · 读卡器 · 串口 IAP&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;RTOS&lt;/td&gt;
      &lt;td&gt;UCOSII 任务调度 · 信号量邮箱 · 消息队列&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h3 id=&quot;51-单片机实验&quot;&gt;&lt;a href=&quot;https://github.com/sakurajh/single-chip-microcomputer&quot;&gt;51 单片机实验&lt;/a&gt;&lt;/h3&gt;

&lt;p&gt;STC89C52 的一套练习：LED、数码管、LCD1602、矩阵键盘密码锁、DS1302 时钟、蜂鸣器放音乐、红外遥控、直流电机调速、AD/DA 转换…… 按日期组织，每个目录一个 Keil 工程。&lt;/p&gt;

&lt;h3 id=&quot;openmv-机器视觉&quot;&gt;&lt;a href=&quot;https://github.com/sakurajh/Openmv-Project&quot;&gt;OpenMV 机器视觉&lt;/a&gt;&lt;/h3&gt;

&lt;p&gt;摄像头视觉的几个经典任务：红色色块追踪、圆形 / 矩形识别、数字模板匹配、小车巡线、串口通信。里面还放了一个「自动计算 LAB 阈值」的脚本 —— 光照变了先跑它拿阈值，比手调试错快得多。&lt;/p&gt;

&lt;h3 id=&quot;stm32-keil-工程练习&quot;&gt;&lt;a href=&quot;https://github.com/sakurajh/Stm32-Project&quot;&gt;STM32 Keil 工程练习&lt;/a&gt;&lt;/h3&gt;

&lt;p&gt;工程模板与练习存档，主要是记 Keil 的配置方式。&lt;/p&gt;

&lt;h2 id=&quot;web-方向&quot;&gt;Web 方向&lt;/h2&gt;

&lt;h3 id=&quot;javaweb-学习仓库&quot;&gt;&lt;a href=&quot;https://github.com/sakurajh/Java-Web&quot;&gt;JavaWeb 学习仓库&lt;/a&gt;&lt;/h3&gt;

&lt;p&gt;按学习路线整理的完整仓库：&lt;/p&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;HTML / CSS / JavaScript → Vue → Ajax
→ MySQL（多表查询 / 事务 / 索引）
→ Maven → MyBatis → Spring IOC&amp;amp;DI → AOP → 事务管理
→ SpringBoot（quickstart / mybatis-crud）
→ tlias 综合案例
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;目录按「课程日期 + 知识主题」命名（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;01.&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;02.&lt;/code&gt; …），可以按编号顺序看。&lt;/p&gt;

&lt;h3 id=&quot;mysql-实验脚本&quot;&gt;&lt;a href=&quot;https://github.com/sakurajh/MySql&quot;&gt;MySQL 实验脚本&lt;/a&gt;&lt;/h3&gt;

&lt;p&gt;课程实验的 SQL 脚本，建表、查询、事务、索引都有，附带示例数据集。&lt;/p&gt;

&lt;h3 id=&quot;htmlcss-练习--java-基础练习&quot;&gt;&lt;a href=&quot;https://github.com/sakurajh/html&quot;&gt;HTML/CSS 练习&lt;/a&gt; · &lt;a href=&quot;https://github.com/sakurajh/java&quot;&gt;Java 基础练习&lt;/a&gt;&lt;/h3&gt;

&lt;p&gt;前者是前端入门的手写练习（选择器、盒模型、Flex 布局、页面仿写）；后者是 Java 语法、面向对象、集合与 Swing 的练习代码，文件名保留了学习时的顺序（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Demo1&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Test1&lt;/code&gt;…），方便按进度回顾。&lt;/p&gt;

&lt;h2 id=&quot;为什么保留这些练习仓库&quot;&gt;为什么保留这些”练习仓库”&lt;/h2&gt;

&lt;p&gt;有人会把课程代码清理掉，我倾向于留着，原因有三个：&lt;/p&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;可回溯&lt;/strong&gt;：某个知识点忘了，翻当时的练习比看教程快&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;可对比&lt;/strong&gt;：能看到自己从 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;Demo1.java&lt;/code&gt; 到能写完整项目的变化&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;可复用&lt;/strong&gt;：像 STM32 的工程结构、OpenMV 的调参脚本，新项目直接抄&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;全部仓库见：&lt;a href=&quot;https://github.com/sakurajh?tab=repositories&quot;&gt;github.com/sakurajh&lt;/a&gt;，也可以在我的&lt;a href=&quot;/projects.html&quot;&gt;项目页&lt;/a&gt;按分类筛。&lt;/p&gt;
</description>
        <pubDate>Sat, 17 Oct 2026 03:40:00 +0000</pubDate>
        <link>https://www.sakura.work.gd/2026/10/17/learning-notes-repos.html</link>
        <guid isPermaLink="true">https://www.sakura.work.gd/2026/10/17/learning-notes-repos.html</guid>
        
        <category>项目</category>
        
        <category>嵌入式</category>
        
        <category>学习笔记</category>
        
        
        <category>项目</category>
        
      </item>
    
    
    
      <item>
        <title>项目｜鸿蒙双端：仿微信与仿 YouTube 的 ArkTS 实践</title>
        <description>&lt;p&gt;学 HarmonyOS 最直接的方式，是&lt;strong&gt;照着成熟产品做一遍&lt;/strong&gt;。这两个课程项目分别复刻了微信和 YouTube 的结构与交互，跑在 HarmonyOS NEXT 上。&lt;/p&gt;

&lt;h2 id=&quot;之一仿微信客户端&quot;&gt;之一：仿微信客户端&lt;/h2&gt;

&lt;p&gt;一共 11 个页面：&lt;/p&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;页面&lt;/th&gt;
      &lt;th&gt;说明&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;登录 / 注册&lt;/td&gt;
      &lt;td&gt;账号界面与表单校验&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;主界面&lt;/td&gt;
      &lt;td&gt;微信经典四栏：微信 / 通讯录 / 发现 / 我&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;聊天&lt;/td&gt;
      &lt;td&gt;单聊会话列表与气泡&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;朋友圈&lt;/td&gt;
      &lt;td&gt;信息流与图片九宫格&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;二维码名片&lt;/td&gt;
      &lt;td&gt;个人二维码页&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;视频号&lt;/td&gt;
      &lt;td&gt;列表 + 播放页&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;搜索&lt;/td&gt;
      &lt;td&gt;全局搜索&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;之二仿-youtube-客户端&quot;&gt;之二：仿 YouTube 客户端&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;页面&lt;/th&gt;
      &lt;th&gt;说明&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;首页推荐&lt;/td&gt;
      &lt;td&gt;视频信息流&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;搜索 / 通知&lt;/td&gt;
      &lt;td&gt;关键词搜索、订阅消息&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;探索&lt;/td&gt;
      &lt;td&gt;分类 / 新发现 / 排行榜&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;播放页&lt;/td&gt;
      &lt;td&gt;播放界面与相关推荐&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;历史 / 播放列表&lt;/td&gt;
      &lt;td&gt;观看记录与收藏&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;登录 / 注册&lt;/td&gt;
      &lt;td&gt;账号体系&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;arkts-写下来的几个感受&quot;&gt;ArkTS 写下来的几个感受&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. 声明式 UI 上手很快。&lt;/strong&gt;
写过 Vue / React 的话，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@State&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Prop&lt;/code&gt;、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;build()&lt;/code&gt; 这套几乎无缝：状态变了界面自动更新，不用碰 DOM。&lt;/p&gt;

&lt;div class=&quot;language-ts highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;p&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;nd&quot;&gt;Entry&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;nd&quot;&gt;Component&lt;/span&gt;
&lt;span class=&quot;nx&quot;&gt;struct&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;Index&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;@&lt;/span&gt;&lt;span class=&quot;nd&quot;&gt;State&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;currentTab&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;kr&quot;&gt;number&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;mi&quot;&gt;0&lt;/span&gt;

  &lt;span class=&quot;nx&quot;&gt;build&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;nx&quot;&gt;Column&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
      &lt;span class=&quot;nx&quot;&gt;Tabs&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;({&lt;/span&gt; &lt;span class=&quot;na&quot;&gt;barPosition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;BarPosition&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;End&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;})&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;TabContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;HomeView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;tabBar&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;微信&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;TabContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;ContactsView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;tabBar&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;通讯录&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;TabContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;DiscoverView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;tabBar&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;发现&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
        &lt;span class=&quot;nx&quot;&gt;TabContent&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;{&lt;/span&gt; &lt;span class=&quot;nx&quot;&gt;MineView&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;()&lt;/span&gt; &lt;span class=&quot;p&quot;&gt;}.&lt;/span&gt;&lt;span class=&quot;nx&quot;&gt;tabBar&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;s1&quot;&gt;我&lt;/span&gt;&lt;span class=&quot;dl&quot;&gt;&apos;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
      &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
    &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
  &lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;p&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;2. 页面路由集中在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main_pages.json&lt;/code&gt;。&lt;/strong&gt;
所有页面必须先登记，好处是路由可枚举、不容易漏，坏处是加页面时容易忘。两个项目各 11 / 15 个页面，改多了会想念 Next.js 的文件路由。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. 组件比样式更值得花时间。&lt;/strong&gt;
同一个”列表项”，微信里是头像 + 昵称 + 摘要，YouTube 里是缩略图 + 标题 + 频道。把它们抽成 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;@Component&lt;/code&gt; 之后，页面代码会薄很多 —— 这也是 ArkUI 最舒服的地方。&lt;/p&gt;

&lt;h2 id=&quot;运行方式&quot;&gt;运行方式&lt;/h2&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;DevEco Studio 打开项目根目录
→ 配置 HarmonyOS NEXT 模拟器或真机
→ 运行 entry 模块
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;两个仓库都是&lt;strong&gt;课程设计性质的学习项目&lt;/strong&gt;，UI 参考原作，数据为本地模拟，与腾讯 / Google 无关：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/sakurajh/Wechat_Project&quot;&gt;sakurajh/Wechat_Project&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;https://github.com/sakurajh/Youtube_HarmonyOS&quot;&gt;sakurajh/Youtube_HarmonyOS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
        <pubDate>Sat, 17 Oct 2026 03:20:00 +0000</pubDate>
        <link>https://www.sakura.work.gd/2026/10/17/harmony-apps.html</link>
        <guid isPermaLink="true">https://www.sakura.work.gd/2026/10/17/harmony-apps.html</guid>
        
        <category>项目</category>
        
        <category>HarmonyOS</category>
        
        <category>ArkTS</category>
        
        
        <category>项目</category>
        
      </item>
    
    
    
      <item>
        <title>项目｜旅行摄影日志：一个复古胶片风格的网站</title>
        <description>&lt;p&gt;想给旅行照片找个地方放。现成的相册要么太”工具”，要么主题太花。于是自己写了一个 ——&lt;strong&gt;目标是让人点进来就想去旅行。&lt;/strong&gt;&lt;/p&gt;

&lt;h2 id=&quot;前台只是好看还不够&quot;&gt;前台：只是好看还不够&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;全屏放大的轮播，照片是主角&lt;/li&gt;
  &lt;li&gt;卡片跟随鼠标做 3D 倾斜，鼠标离开回弹&lt;/li&gt;
  &lt;li&gt;粒子背景 + 自定义光标&lt;/li&gt;
  &lt;li&gt;磁性按钮、快捷键操作、旅行统计（城市 / 里程 / 照片数）&lt;/li&gt;
  &lt;li&gt;复古胶片色调的整体视觉&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这些效果全部是&lt;strong&gt;原生 JavaScript + CSS&lt;/strong&gt; 实现的，没有引入任何框架或动画库 —— 页面加载只有一个 html、一个 css、几个 js。&lt;/p&gt;

&lt;h2 id=&quot;后台静态站也要能管内容&quot;&gt;后台：静态站也要能管内容&lt;/h2&gt;

&lt;p&gt;静态站点最大的问题是”改内容要动代码”。这里的解法是 &lt;strong&gt;Supabase&lt;/strong&gt;：&lt;/p&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;Auth&lt;/strong&gt;：邮箱注册 / 登录，管理后台有门禁&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Postgres&lt;/strong&gt;：旅行记录、目的地、页面配置&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;Storage&lt;/strong&gt;：照片批量上传，走对象存储而不是塞进仓库&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这样一来，网站本身还是纯静态（部署在 Vercel，全球 CDN），但内容可以随时在后台改。&lt;/p&gt;

&lt;h2 id=&quot;部署&quot;&gt;部署&lt;/h2&gt;

&lt;div class=&quot;language-text highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;双击 deploy.bat 看教程 → 推到 GitHub → Vercel 导入仓库 → 完成
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;也支持 GitHub Pages。配置文件分离得很干净：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;config.js&lt;/code&gt; 管站点信息，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;supabase.js&lt;/code&gt; 管客户端，&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;storage.js&lt;/code&gt; 管图片，换环境只需要改配置。&lt;/p&gt;

&lt;h2 id=&quot;一点设计上的取舍&quot;&gt;一点设计上的取舍&lt;/h2&gt;

&lt;p&gt;胶片风格的网站很容易做”脏”：颗粒、噪点、暖色调叠多了就糊。最后的做法是 —— &lt;strong&gt;把做旧效果限制在边框和叠层上，照片本身保持干净&lt;/strong&gt;，让”复古”是一种气质，而不是一层滤镜。&lt;/p&gt;

&lt;p&gt;项目地址：&lt;a href=&quot;https://github.com/sakurajh/travel-journal&quot;&gt;sakurajh/travel-journal&lt;/a&gt;&lt;/p&gt;
</description>
        <pubDate>Sat, 17 Oct 2026 03:00:00 +0000</pubDate>
        <link>https://www.sakura.work.gd/2026/10/17/travel-journal.html</link>
        <guid isPermaLink="true">https://www.sakura.work.gd/2026/10/17/travel-journal.html</guid>
        
        <category>项目</category>
        
        <category>JavaScript</category>
        
        <category>Supabase</category>
        
        <category>前端</category>
        
        
        <category>项目</category>
        
      </item>
    
    
    
      <item>
        <title>项目｜情绪识别教学系统：一次 FastAPI 全栈实践</title>
        <description>&lt;p&gt;这是我做过的功能最”全”的一个 Web 项目：从学生打开摄像头的那一刻，到教师看得懂的一份学情报告，整条链路都在里面。&lt;/p&gt;

&lt;h2 id=&quot;五个模块&quot;&gt;五个模块&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;① 实时情绪识别&lt;/strong&gt;
浏览器摄像头把视频流推给后端，OpenCV 做人脸检测与表情分析，情绪分布实时回传前端图表。教师端可以看到整堂课的情绪曲线。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② 在线考试&lt;/strong&gt;
教师可视化出题、发布考试；学生在线作答、提交、查看成绩。最麻烦的是&lt;strong&gt;试卷导入&lt;/strong&gt; —— 支持 Word / PDF，用 PyMuPDF 拆页、RapidOCR 识别、python-docx 解析，把纸质卷子变成题库。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ 学情画像&lt;/strong&gt;
按学生检索历史数据，AI 生成个性化学习报告，教师端还提供班级整体分析。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;④ AI 助手&lt;/strong&gt;
接入火山方舟的豆包模型（OpenAI 兼容接口），支持多轮对话答疑。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑤ 管理与权限&lt;/strong&gt;
学生 / 教师 / 管理员三角色，密码用 SHA-256 哈希存储，后台有用户管理与平台统计。&lt;/p&gt;

&lt;h2 id=&quot;技术栈&quot;&gt;技术栈&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;层次&lt;/th&gt;
      &lt;th&gt;选型&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;Web 框架&lt;/td&gt;
      &lt;td&gt;FastAPI + Jinja2&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;数据库&lt;/td&gt;
      &lt;td&gt;MySQL + SQLAlchemy ORM&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;计算机视觉&lt;/td&gt;
      &lt;td&gt;OpenCV（采集 / 人脸 / 情绪）&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;文档解析&lt;/td&gt;
      &lt;td&gt;PyMuPDF · python-docx · RapidOCR&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;大模型&lt;/td&gt;
      &lt;td&gt;火山方舟 · 豆包&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;实时通信&lt;/td&gt;
      &lt;td&gt;MJPEG 视频流 + fetch 轮询图表&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;一个必须记住的教训密钥不能写进代码&quot;&gt;一个必须记住的教训：密钥不能写进代码&lt;/h2&gt;

&lt;p&gt;项目早期，我把 API Key 和数据库账号密码直接写死在 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;main.py&lt;/code&gt; 里，仓库又是公开的 —— 等于把钥匙挂在门上。&lt;/p&gt;

&lt;p&gt;后来全部改成环境变量读取：&lt;/p&gt;

&lt;div class=&quot;language-python highlighter-rouge&quot;&gt;&lt;div class=&quot;highlight&quot;&gt;&lt;pre class=&quot;highlight&quot;&gt;&lt;code&gt;&lt;span class=&quot;n&quot;&gt;ARK_API_KEY&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;environ&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;ARK_API_KEY&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;DB_USER&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;environ&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;DB_USER&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;span class=&quot;n&quot;&gt;DB_PASS&lt;/span&gt; &lt;span class=&quot;o&quot;&gt;=&lt;/span&gt; &lt;span class=&quot;n&quot;&gt;os&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;environ&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;.&lt;/span&gt;&lt;span class=&quot;n&quot;&gt;get&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;s&quot;&gt;&quot;DB_PASS&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;s&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;p&quot;&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;/div&gt;

&lt;p&gt;并补了一份 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;.env.example&lt;/code&gt;。&lt;strong&gt;但要注意：改成环境变量只解决了”当前代码”，Git 历史里的旧提交依然能翻到密钥。&lt;/strong&gt; 所以真正的补救是去控制台把 Key 吊销换新、把数据库密码改掉。&lt;/p&gt;

&lt;blockquote&gt;
  &lt;p&gt;结论：任何出现在代码里的密钥，都要按”已经泄露”处理。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2 id=&quot;值得复用的几个做法&quot;&gt;值得复用的几个做法&lt;/h2&gt;

&lt;ol&gt;
  &lt;li&gt;&lt;strong&gt;试卷导入做成”尽力而为”&lt;/strong&gt;：OCR 失败不阻塞流程，允许人工修正识别结果&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;情绪数据只存聚合结果&lt;/strong&gt;：不落原始画面，避免隐私问题&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;AI 报告有缓存&lt;/strong&gt;：同一学生短时间内重复请求不再烧 token&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;项目地址：&lt;a href=&quot;https://github.com/sakurajh/python-emotion-system&quot;&gt;sakurajh/python-emotion-system&lt;/a&gt;&lt;/p&gt;
</description>
        <pubDate>Sat, 17 Oct 2026 02:40:00 +0000</pubDate>
        <link>https://www.sakura.work.gd/2026/10/17/emotion-teaching-system.html</link>
        <guid isPermaLink="true">https://www.sakura.work.gd/2026/10/17/emotion-teaching-system.html</guid>
        
        <category>项目</category>
        
        <category>FastAPI</category>
        
        <category>OpenCV</category>
        
        <category>大模型</category>
        
        
        <category>项目</category>
        
      </item>
    
    
    
      <item>
        <title>项目｜MD Workflow Manager：把分子动力学流程装进一个桌面软件</title>
        <description>&lt;p&gt;分子动力学（MD）模拟的流程是标准化的，但每一步都要手敲命令：&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;grompp&lt;/code&gt; 生成 tpr、&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;mdrun&lt;/code&gt; 跑轨迹、检查日志、再进下一步。一个体系动辄几个小时，中途参数写错就得重来。&lt;/p&gt;

&lt;p&gt;MD Workflow Manager 做的事情很直接：&lt;strong&gt;选一个 CHARMM-GUI 输出的文件夹，剩下的交给它。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;img src=&quot;/assets/img/proj-md-workflow.png&quot; alt=&quot;MD Workflow Manager 界面&quot; /&gt;&lt;/p&gt;

&lt;h2 id=&quot;核心能力&quot;&gt;核心能力&lt;/h2&gt;

&lt;ul&gt;
  &lt;li&gt;&lt;strong&gt;一键接入 CHARMM-GUI&lt;/strong&gt;：选中输出目录，自动识别力场、结构文件、各阶段的 mdp&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;全原子 / 粗粒化自动区分&lt;/strong&gt;：读 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;topol.top&lt;/code&gt; 里的力场判断（CHARMM36 → 全原子，Martini → 粗粒化），粗粒化自动换 20 fs 步长&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;四页签流程&lt;/strong&gt;：环境配置 → 输入检测 → 参数配置 → 运行日志&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;实时进度与预估&lt;/strong&gt;：当前阶段、阶段内进度、总体进度、剩余时间&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;可取消、可续跑&lt;/strong&gt;：失败后修好参数，从失败阶段继续&lt;/li&gt;
  &lt;li&gt;&lt;strong&gt;自动装 GROMACS&lt;/strong&gt;：检测不到 gmx 时，自动下载 micromamba、建 conda 环境、装 conda-forge 版 GROMACS，全程不需要管理员权限&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&quot;一个有意思的架构决定界面在-windows计算在-linux&quot;&gt;一个有意思的架构决定：界面在 Windows，计算在 Linux&lt;/h2&gt;

&lt;p&gt;GROMACS 在 Linux 上性能最好，但大多数人手上是 Windows。&lt;/p&gt;

&lt;p&gt;所以这个软件把两件事拆开了：&lt;strong&gt;GUI 跑在 Windows（打包成 exe），计算通过 WSL2 在 Linux 里执行&lt;/strong&gt;。中间层处理了路径转换（&lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;C:\xxx&lt;/code&gt; ↔ &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;/mnt/c/xxx&lt;/code&gt;）、命令包装、输出流解析。用户看到的是一个 Windows 软件，实际干活的是一台 Linux。&lt;/p&gt;

&lt;p&gt;这也带来一个隐藏福利：GPU 加速可用 —— WSL2 能直通显卡；如果没有可用 GPU 设备，会自动回退 CPU 重跑当前阶段并记录警告，不中断流程。&lt;/p&gt;

&lt;h2 id=&quot;工程细节&quot;&gt;工程细节&lt;/h2&gt;

&lt;table&gt;
  &lt;thead&gt;
    &lt;tr&gt;
      &lt;th&gt;问题&lt;/th&gt;
      &lt;th&gt;解法&lt;/th&gt;
    &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td&gt;CHARMM-GUI 下载的是压缩包&lt;/td&gt;
      &lt;td&gt;直接导入 tgz / tar.gz / zip，自动解压定位&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;想先熟悉软件再跑真模拟&lt;/td&gt;
      &lt;td&gt;dry-run 模式：不调用 gmx 也能走完整个流程&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;环境到底行不行？&lt;/td&gt;
      &lt;td&gt;「环境自测」用最小水体系真跑一遍 grompp + mdrun&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;断网 / 国内源慢&lt;/td&gt;
      &lt;td&gt;conda 通道可切清华镜像&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td&gt;想在服务器上用&lt;/td&gt;
      &lt;td&gt;提供 &lt;code class=&quot;language-plaintext highlighter-rouge&quot;&gt;--cli&lt;/code&gt; 控制台模式，可 nohup / tmux 后台跑&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;

&lt;h2 id=&quot;体会&quot;&gt;体会&lt;/h2&gt;

&lt;p&gt;写这个项目最大的收获不是 GROMACS 本身，而是&lt;strong&gt;怎么把一个”专家流程”翻译成”界面语言”&lt;/strong&gt;：哪些参数该暴露、哪些该藏起来、出错时用户到底需要看到什么。后面这一点尤其重要 —— 报错只写 “grompp failed” 是没用的，得告诉他第几个阶段、哪一行 mdp、缺了什么。&lt;/p&gt;

&lt;p&gt;项目地址：&lt;a href=&quot;https://github.com/sakurajh/md-workflow-manager&quot;&gt;sakurajh/md-workflow-manager&lt;/a&gt;&lt;/p&gt;
</description>
        <pubDate>Sat, 17 Oct 2026 02:20:00 +0000</pubDate>
        <link>https://www.sakura.work.gd/2026/10/17/md-workflow-manager.html</link>
        <guid isPermaLink="true">https://www.sakura.work.gd/2026/10/17/md-workflow-manager.html</guid>
        
        <category>项目</category>
        
        <category>Python</category>
        
        <category>科学计算</category>
        
        <category>GROMACS</category>
        
        
        <category>项目</category>
        
      </item>
    
    
  </channel>
</rss>
