<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Mint Lab</title><description>狛荷屋</description><link>https://www.mintlab.top/</link><language>zh-CN</language><item><title>KVM虚拟化入门： Debian系统上用物理 DVD 安装 KVM 虚拟机攻略</title><link>https://www.mintlab.top/posts/tries/r730_kvm/</link><guid isPermaLink="true">https://www.mintlab.top/posts/tries/r730_kvm/</guid><description>本文讲述了在Debian系统上安装KVM，从物理DVD安装Fedora和Arch Linux虚拟机，配置桥接网络、VNC图形控制台，以及Btrfs+drm_panic等进阶技巧。</description><pubDate>Tue, 19 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;环境&lt;/strong&gt;：Dell PowerEdge R730 / Debian 13 / KVM+libvirt / 纯 SSH 终端（无图形界面）&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;目标&lt;/strong&gt;：从服务器物理光驱安装 Fedora 44 和 Arch Linux 虚拟机&lt;/p&gt;
&lt;p&gt;参考文章： &lt;a href=&quot;https://geek-blogs.com/blog/linux-kvm-debian/&quot;&gt;Linux KVM 虚拟化实战：基于 Debian 系统的完整指南&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 前置条件&lt;/h2&gt;
&lt;h3&gt;1.1 硬件虚拟化支持检查&lt;/h3&gt;
&lt;p&gt;KVM 依赖 CPU 的硬件虚拟化扩展，需确保 CPU 支持并启用该功能：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;检查 CPU 支持：&lt;/strong&gt;
执行以下命令，若输出包含 &lt;code&gt;vmx&lt;/code&gt;（Intel）或 &lt;code&gt;svm&lt;/code&gt;（AMD），则支持虚拟化：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;grep -E --color=auto &apos;vmx|svm&apos; /proc/cpuinfo
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;若无输出，可能是 CPU 不支持，或需在 BIOS/UEFI 中启用（常见选项：Intel Virtualization Technology 或 AMD SVM）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;验证内核模块加载：&lt;/strong&gt;
若硬件支持，KVM 内核模块会自动加载（或按需加载）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;lsmod | grep kvm
# 输出示例（Intel）：
# kvm_intel             311296  0
# kvm                   942080  1 kvm_intel
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;1.2 Debian 系统要求&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;系统版本&lt;/strong&gt;：推荐 Debian 11（Bullseye）或 12（Bookworm，最新稳定版），内核版本 ≥ 5.10（KVM 功能更完善）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;资源建议&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;CPU：至少 2 核心（推荐 4 核心以上，支持多线程）。&lt;/li&gt;
&lt;li&gt;内存：至少 4GB（每台虚拟机建议分配 2GB+，根据需求调整）。&lt;/li&gt;
&lt;li&gt;存储：至少 20GB 空闲空间（推荐 SSD 以提升虚拟机磁盘性能）。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 在 Debian 上安装 KVM&lt;/h2&gt;
&lt;h3&gt;2.1 安装核心组件&lt;/h3&gt;
&lt;p&gt;Debian 官方仓库提供 KVM 相关包，直接通过 apt 安装：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 更新软件源
sudo apt update

# 安装 KVM 核心组件、管理工具及网络支持
sudo apt install -y qemu-kvm libvirt-daemon-system libvirt-clients bridge-utils virt-manager virtinst
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;组件说明：&lt;/strong&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qemu-kvm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;提供硬件模拟功能，是 KVM 的用户态组件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;libvirt-daemon-system&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;libvirt 守护进程（管理虚拟机的核心服务）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;libvirt-clients&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;libvirt 命令行工具（如 &lt;code&gt;virsh&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bridge-utils&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;网络桥接配置工具&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;virt-manager&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;图形化虚拟机管理工具（可选，适合桌面环境）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;virtinst&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;命令行虚拟机创建工具（如 &lt;code&gt;virt-install&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;2.2 配置用户权限&lt;/h3&gt;
&lt;p&gt;默认情况下，仅 root 用户可管理 KVM 虚拟机。为避免直接使用 root，需将普通用户添加到 &lt;code&gt;kvm&lt;/code&gt; 和 &lt;code&gt;libvirt&lt;/code&gt; 组（前者控制设备访问，后者控制 libvirt 服务）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 将当前用户加入 kvm 和 libvirt 组
sudo usermod -aG kvm $USER
sudo usermod -aG libvirt $USER

# 重新登录使权限生效（或执行 `newgrp kvm; newgrp libvirt` 临时生效）
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.3 验证安装&lt;/h3&gt;
&lt;p&gt;检查 libvirt 服务状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl status libvirtd
# 预期输出：Active: active (running)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;验证 KVM 模块加载：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo virsh capabilities | grep -A 10 &quot;cpu&quot;
# 输出应包含 CPU 特性（如 vmx/svm），表明 KVM 可用
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;列出默认网络：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;virsh net-list --all
# 预期输出：默认 NAT 网络 &quot;default&quot; 处于活跃状态
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 环境检查&lt;/h2&gt;
&lt;p&gt;动手前先确认 KVM 服务正常：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# CPU 虚拟化支持
grep -E &quot;(vmx|svm)&quot; /proc/cpuinfo | head -1

# KVM 模块
lsmod | grep kvm

# 设备节点
ls -la /dev/kvm

# libvirtd 状态
systemctl status libvirtd

# 用户组（当前用户需在 libvirt 组）
groups
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4. 光驱设备定位&lt;/h2&gt;
&lt;p&gt;插入 DVD 后，用以下命令定位设备：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;lsblk -o NAME,TYPE,SIZE,MODEL,LABEL | grep -E &quot;(sr|cdrom)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出示例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sr0    rom    3.6G HL-DT-ST DVD-ROM DTA0N   Fedora-S-dvd-x86_64-44
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结论：设备文件是 &lt;strong&gt;&lt;code&gt;/dev/sr0&lt;/code&gt;&lt;/strong&gt;，符号链接 &lt;strong&gt;&lt;code&gt;/dev/cdrom&lt;/code&gt; → &lt;code&gt;sr0&lt;/code&gt;&lt;/strong&gt;，光盘是 Fedora 44 安装 DVD。&lt;/p&gt;
&lt;p&gt;确认设备信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat /proc/sys/dev/cdrom/info
# drive name: sr0
# Can read DVD: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;验证光盘内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mount /dev/sr0 /mnt -o ro
ls /mnt/
# boot  EFI  Fedora-Legal-README.txt  images  LICENSE  media.repo  Packages  repodata
sudo umount /mnt
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5. 失败的尝试与排错&lt;/h2&gt;
&lt;p&gt;以下是我踩过的三个坑，帮你绕过。&lt;/p&gt;
&lt;h3&gt;尝试一：&lt;code&gt;--cdrom&lt;/code&gt; 直接启动&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo virt-install \
  --name fedora-vm \
  --ram 2048 \
  --vcpus 2 \
  --disk path=/var/lib/libvirt/images/fedora-vm.qcow2,size=30 \
  --cdrom /dev/sr0 \
  --os-variant fedora-unknown \
  --network network=default \
  --graphics none \
  --console pty,target_type=serial
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;结果&lt;/strong&gt;：VM 启动后一片空白，无法看到安装界面。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：Fedora 安装器（Anaconda）默认使用&lt;strong&gt;图形界面&lt;/strong&gt;。&lt;code&gt;--graphics none&lt;/code&gt; 禁用了图形输出后，串口控制台没有显示任何内容。WARNING 提示：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;CDROM 介质默认情况下不输出信息到文本控制台&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;尝试二：&lt;code&gt;--location&lt;/code&gt; + &lt;code&gt;inst.text&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;升级方案——用 &lt;code&gt;--location&lt;/code&gt; 从光盘加载内核，加 &lt;code&gt;inst.text&lt;/code&gt; 强制文本模式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mount /dev/sr0 /mnt -o ro

sudo virt-install \
  --name fedora-vm \
  --ram 2048 \
  --vcpus 2 \
  --disk path=/var/lib/libvirt/images/fedora-vm.qcow2,size=30 \
  --location /mnt \
  --os-variant fedora-unknown \
  --network network=default \
  --graphics none \
  --extra-args &quot;inst.text console=ttyS0,115200&quot; \
  --console pty,target_type=serial
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;结果&lt;/strong&gt;：内核启动成功，dracut 加载 initrd，但随后进入超时循环：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ 170.526] dracut-initqueue[1091]: Warning: dracut-initqueue: timeout
[ 170.546] dracut-initqueue[1091]: Warning: It seems that the boot has failed.
[ 170.552] dracut-initqueue[1091]: Warning: missing inst.stage2 or inst.repo
                                       boot parameters on the kernel cmdline.
[ 170.555] dracut-initqueue[1091]: Warning: Please verify that you have
                                       specified inst.stage2 or inst.repo.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;原因分析&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--location /mnt&lt;/code&gt; 只会从光盘加载 &lt;strong&gt;vmlinuz（内核）&lt;/strong&gt; 和 &lt;strong&gt;initrd.img&lt;/strong&gt; 两个文件，然后启动安装环境。但安装环境启动后，它不知道从哪里获取剩余的安装数据（RPM 软件包仓库），因为光盘并没有作为块设备传给虚拟机——只传了挂载点里的两个引导文件。&lt;/p&gt;
&lt;p&gt;Anaconda 需要 &lt;code&gt;inst.stage2&lt;/code&gt; 或 &lt;code&gt;inst.repo&lt;/code&gt; 参数来告知第二阶段安装源的位置。没有这个参数，dracut-initqueue 就在循环等待 &lt;code&gt;/dev/root&lt;/code&gt; 出现，最终超时。&lt;/p&gt;
&lt;h3&gt;尝试三：混合使用 &lt;code&gt;--cdrom&lt;/code&gt; + &lt;code&gt;--extra-args&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;天真地想用 &lt;code&gt;--cdrom&lt;/code&gt; 传光盘设备，同时用 &lt;code&gt;--extra-args&lt;/code&gt; 传文本模式参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo virt-install \
  --name fedora-vm \
  --cdrom /dev/sr0 \
  --graphics none \
  --extra-args &quot;inst.text console=ttyS0,115200&quot; \
  ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;结果&lt;/strong&gt;：直接报错 ❌&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR  Kernel arguments are only supported with location or kernel installs.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;原因&lt;/strong&gt;：&lt;code&gt;virt-install&lt;/code&gt; 规定——内核参数 &lt;code&gt;--extra-args&lt;/code&gt; 只能与 &lt;code&gt;--location&lt;/code&gt;（PXE/URL/本地目录）搭配使用，不能和 &lt;code&gt;--cdrom&lt;/code&gt;（模拟光驱启动）混用。因为 &lt;code&gt;--cdrom&lt;/code&gt; 模式下 libvirt 直接让 BIOS/UEFI 从光盘引导，不经过 libvirt 的内核注入流程。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 终极方案&lt;/h2&gt;
&lt;p&gt;综合以上教训，正确的方案是&lt;strong&gt;双管齐下&lt;/strong&gt;：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;--location /mnt&lt;/code&gt; 负责引导 + &lt;code&gt;--disk /dev/sr0,device=cdrom&lt;/code&gt; 传光盘设备 + &lt;code&gt;inst.repo=cdrom&lt;/code&gt; 指定安装源&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;# 1. 挂载 DVD
sudo mount /dev/sr0 /mnt -o ro

# 2. 清理之前的试错残留（如有）
sudo virsh destroy fedora-vm 2&amp;gt;/dev/null
sudo virsh undefine fedora-vm --remove-all-storage 2&amp;gt;/dev/null

# 3. 终极命令
sudo virt-install \
  --name fedora-vm \
  --ram 2048 \
  --vcpus 2 \
  --disk path=/var/lib/libvirt/images/fedora-vm.qcow2,size=30 \
  --disk /dev/sr0,device=cdrom,readonly=on \
  --location /mnt \
  --os-variant fedora-unknown \
  --network network=default \
  --graphics none \
  --extra-args &quot;inst.text console=ttyS0,115200 inst.repo=cdrom&quot; \
  --console pty,target_type=serial
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;参数详解&lt;/h3&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;code&gt;--disk /dev/sr0,device=cdrom,readonly=on&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;把物理光驱以 CDROM 设备传给 VM&lt;/td&gt;
&lt;td&gt;Anaconda 需要直接访问光盘上的 RPM 仓库&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--location /mnt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;从挂载的光盘中提取 vmlinuz + initrd&lt;/td&gt;
&lt;td&gt;用于启动安装环境（允许传 &lt;code&gt;--extra-args&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--graphics none&lt;/code&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;code&gt;--extra-args &quot;inst.text ...&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;强制文本模式安装&lt;/td&gt;
&lt;td&gt;跳过 Anaconda 的图形/RDP 提示&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--extra-args &quot;... inst.repo=cdrom&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指定安装源为光盘&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;关键参数&lt;/strong&gt;——解决 &lt;code&gt;dracut-initqueue timeout&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--extra-args &quot;console=ttyS0,115200&quot;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;串口终端输出&lt;/td&gt;
&lt;td&gt;安装信息输出到 &lt;code&gt;virsh console&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--console pty,target_type=serial&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;安装后自动连接串口&lt;/td&gt;
&lt;td&gt;无需手动 &lt;code&gt;virsh console&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;执行流程图&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;物理光驱 /dev/sr0
        │
        ├──► 挂载到 /mnt
        │       │
        │       └──► --location /mnt ──► 提取 vmlinuz + initrd ──► 启动安装内核
        │                                       │
        │                                       ├──► inst.text ──► 文本模式安装
        │                                       │
        │                                       └──► inst.repo=cdrom ──► 从光盘读取 RPM
        │
        └──► --disk /dev/sr0,device=cdrom ──► VM 内出现 /dev/sr0 ──► Anaconda 挂载读取
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;7. Fedora 文本安装流程&lt;/h2&gt;
&lt;p&gt;安装启动后，Anaconda 会提示选择模式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1) Use graphical mode via Remote Desktop Protocol
2) Use text mode

输入 2，然后输入 c 确认
&lt;/code&gt;&lt;/pre&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;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Language&lt;/td&gt;
&lt;td&gt;已默认简体中文&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Timezone&lt;/td&gt;
&lt;td&gt;已默认 Asia/Shanghai&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Install Source&lt;/td&gt;
&lt;td&gt;应自动识别为 Local media&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Software&lt;/td&gt;
&lt;td&gt;选择 Fedora Server Edition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disk&lt;/td&gt;
&lt;td&gt;选择自动分区方案&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;7&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Root Password&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;必须设置&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;User&lt;/td&gt;
&lt;td&gt;创建管理员用户&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;b&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Begin&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;hr /&gt;
&lt;h2&gt;8. 安装后管理&lt;/h2&gt;
&lt;h3&gt;连接到虚拟机&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 方式 1：串口控制台
sudo virsh console fedora-vm
# 退出：按 Ctrl + ]

# 方式 2：SSH（推荐，安装好 guest-agent 后）
sudo virsh domifaddr fedora-vm   # 查看 IP
ssh mint@&amp;lt;vm-ip&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;在 VM 内安装 Guest Agent&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo dnf install -y qemu-guest-agent
sudo systemctl enable --now qemu-guest-agent
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;日常管理速查&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# VM 生命周期
sudo virsh list --all           # 查看所有 VM
sudo virsh start fedora-vm      # 启动
sudo virsh shutdown fedora-vm   # 正常关机
sudo virsh destroy fedora-vm    # 强制关机
sudo virsh autostart fedora-vm  # 开机自启

# 修改硬件
sudo virsh edit fedora-vm       # 编辑全部配置
sudo virsh setvcpus fedora-vm 4 --config
sudo virsh setmaxmem fedora-vm 4096 --config

# 快照
sudo virsh snapshot-create-as fedora-vm --name clean-install
sudo virsh snapshot-revert fedora-vm clean-install

# 网络
sudo virsh net-dhcp-leases default   # 查看 VM 的 IP

# 克隆
sudo virt-clone --original fedora-vm --name fedora-vm2 \
  --file /var/lib/libvirt/images/fedora-vm2.qcow2

# 删除
sudo virsh destroy fedora-vm
sudo virsh undefine fedora-vm --remove-all-storage
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;9. 网络配置：让外部设备访问虚拟机&lt;/h2&gt;
&lt;p&gt;安装完成后，你会发现一个尴尬的问题：&lt;code&gt;virsh console&lt;/code&gt; 可以连上 VM，但从笔记本 SSH 过去却 &lt;code&gt;100% packet loss&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;9.1 为什么默认网络外部无法访问&lt;/h3&gt;
&lt;p&gt;KVM 默认使用 &lt;strong&gt;NAT 虚拟网络&lt;/strong&gt;（&lt;code&gt;virbr0&lt;/code&gt;，网段 &lt;code&gt;192.168.122.0/24&lt;/code&gt;）。它的工作原理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────────────────────────────────────┐
│                    宿主机 (R730)                      │
│                                                     │
│   物理网卡 eno1            虚拟网桥 virbr0            │
│   192.168.43.101  ──NAT──►  192.168.122.1            │
│       │                         │                    │
│       │                         ├── VM: .225         │
│       │                         ├── VM: .xxx         │
│       │                         └── ...              │
│       │                                              │
└───────┼──────────────────────────────────────────────┘
        │
   ┌────┴────┐
   │ 路由器   │
   │ 192.168.43.1
   └────┬────┘
        │
   ┌────┴────┐
   │ 你的笔记本 │
   │ 192.168.43.x
   └─────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键问题：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;笔记本发给 &lt;code&gt;192.168.122.225&lt;/code&gt; 的包到达路由器后，路由器查路由表：&lt;code&gt;192.168.122.0/24&lt;/code&gt; 在哪？&lt;strong&gt;不知道，丢弃。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;只有宿主机自己知道这个子网（&lt;code&gt;virbr0&lt;/code&gt; 在宿主机内部）。&lt;/li&gt;
&lt;li&gt;这就是 &lt;code&gt;ping&lt;/code&gt; 不通、&lt;code&gt;ssh&lt;/code&gt; 不了的根因。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;9.2 三种暴露方案对比&lt;/h3&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;VM IP&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;strong&gt;桥接网络&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⭐⭐&lt;/td&gt;
&lt;td&gt;独立 IP，和宿主机同网段&lt;/td&gt;
&lt;td&gt;VM 长期运行，需要对外提供服务&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;SSH 隧道&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⭐&lt;/td&gt;
&lt;td&gt;无需暴露 VM IP&lt;/td&gt;
&lt;td&gt;临时访问，无需改网络&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;端口转发&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;⭐⭐&lt;/td&gt;
&lt;td&gt;无需暴露 VM IP&lt;/td&gt;
&lt;td&gt;只暴露特定端口（如 Web、SSH）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;下文重点讲解&lt;strong&gt;桥接网络&lt;/strong&gt;（推荐方案）的完整原理与配置。&lt;/p&gt;
&lt;h3&gt;9.3 桥接网络原理&lt;/h3&gt;
&lt;p&gt;桥接的本质：&lt;strong&gt;让虚拟机&quot;冒充&quot;一台真机直接接入物理交换机&lt;/strong&gt;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;配置前（NAT）                          配置后（桥接 br0）
┌──────────┐                           ┌──────────────────────┐
│ 宿主机     │                          │      宿主机           │
│ eno1      │                          │                      │
│ .101      │                          │  eno1 ──┐            │
│           │                          │  (无IP)  │           │
│ virbr0 NAT│                          │         br0          │
│ ├─ VM .225│                          │       .101 (宿主机)   │
│           │                          │       .250 (VM)      │
└──────────┘                           └─────────┬────────────┘
                                                 │
VM 对外不可见                                 ┌────┴────┐
                                            │ 路由器   │
                                            └────┬────┘
                                                 │
                                            ┌────┴────┐
                                            │ 笔记本   │
                                            └─────────┘
                                      ssh mint@192.168.43.250 ✅
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;数据流向变化：&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;阶段&lt;/th&gt;
&lt;th&gt;NAT 模式&lt;/th&gt;
&lt;th&gt;桥接模式&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VM 发包&lt;/td&gt;
&lt;td&gt;→ &lt;code&gt;virbr0&lt;/code&gt; → 宿主机 NAT → &lt;code&gt;eno1&lt;/code&gt; → 物理网络&lt;/td&gt;
&lt;td&gt;→ &lt;code&gt;br0&lt;/code&gt; → &lt;code&gt;eno1&lt;/code&gt; → 物理网络（直达）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;外部回包&lt;/td&gt;
&lt;td&gt;路由器发给宿主机 .101，宿主机再做 NAT 转回 VM&lt;/td&gt;
&lt;td&gt;路由器直接发给 VM 的 IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;性能&lt;/td&gt;
&lt;td&gt;多一层 NAT 转换&lt;/td&gt;
&lt;td&gt;零拷贝，接近物理网卡性能&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;9.4 宿主机桥接配置&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;⚠️ 警告&lt;/strong&gt;：以下操作会导致 SSH &lt;strong&gt;短暂断开&lt;/strong&gt;（约 5~10 秒），因为主网卡的 IP 要迁移到桥接口上。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;第一步：查看当前网络布局&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 查看默认路由和主网卡
ip route show default
# default via 192.168.43.1 dev eno1 ...
#                       ↑主网卡名

# 查看当前连接
nmcli con show --active
# NAME         UUID    TYPE      DEVICE
# Wired-1      xxxx    ethernet  eno1
# ...
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;第二步：创建网桥（用 NetworkManager）&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 1. 创建桥接接口 br0
sudo nmcli con add type bridge ifname br0

# 2. 把主网卡 eno1 作为 br0 的从属
sudo nmcli con add type bridge-slave ifname eno1 master br0

# 原理：nmcli 在 /etc/NetworkManager/system-connections/ 下创建两个配置文件：
#   - br0.nmconnection        → 桥接设备定义
#   - br0-slave-eno1.nmconnection → eno1 从属关系
# NetworkManager 重启后自动生效，持久化无需额外操作。
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;此时发生了什么：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;执行前:
  eno1 ── IP 192.168.43.101 ── 默认网关 192.168.43.1

执行中（nmcli con up br0）:
  1. eno1 被加入 br0（变为从属，IP 被剥离）
  2. br0 发起 DHCP 请求，获取 IP  ← SSH 断开发生在这里
  3. 默认路由从 eno1 转移到 br0

执行后:
  eno1 ── 无 IP（master: br0）
  br0  ── IP 192.168.43.xxx（新 DHCP 分配的 IP）
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;注意&lt;/strong&gt;：新 IP 可能和原来不同！本次实操中，&lt;code&gt;eno1&lt;/code&gt; 原来的 IP 是 &lt;code&gt;192.168.43.69&lt;/code&gt;，桥接后 &lt;code&gt;br0&lt;/code&gt; 拿到的是 &lt;code&gt;192.168.43.101&lt;/code&gt;。如果你有 DHCP 保留 / 静态 IP 需求，应预先设置。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;第三步：验证桥接&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 确认 br0 已创建并获取 IP
ip addr show br0 | grep inet
# inet 192.168.43.101/24 ...

# 确认 eno1 已从属到 br0
ip link show eno1
# ... master br0 ...

# 确认默认路由走 br0
ip route show default
# default via 192.168.43.1 dev br0 ...

# 确认 NetworkManager 连接状态
nmcli con show --active | grep br0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;9.5 创建 libvirt 桥接网络&lt;/h3&gt;
&lt;p&gt;宿主机网络桥 OK 了，但 libvirt 还不知道这个桥的存在。需要为 libvirt 定义网络：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 编写 libvirt 网络定义 XML
cat &amp;gt; /tmp/bridge-net.xml &amp;lt;&amp;lt; &apos;EOF&apos;
&amp;lt;network&amp;gt;
  &amp;lt;name&amp;gt;host-bridge&amp;lt;/name&amp;gt;          &amp;lt;!-- libvirt 内的网络名称 --&amp;gt;
  &amp;lt;forward mode=&quot;bridge&quot;/&amp;gt;          &amp;lt;!-- 桥接模式：二层转发 --&amp;gt;
  &amp;lt;bridge name=&quot;br0&quot;/&amp;gt;              &amp;lt;!-- 绑定到宿主机 br0 网桥 --&amp;gt;
&amp;lt;/network&amp;gt;
EOF

# 定义（注册到 libvirt）
sudo virsh net-define /tmp/bridge-net.xml

# 启动
sudo virsh net-start host-bridge

# 设为开机自启
sudo virsh net-autostart host-bridge

# 确认
sudo virsh net-list --all
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;XML 参数详解：&lt;/strong&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;forward mode=&quot;bridge&quot;/&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;二层桥接模式。和 NAT 模式 (&lt;code&gt;mode=&apos;nat&apos;&lt;/code&gt;) 不同，不做 IP 转换，VM 数据帧直接透传到 &lt;code&gt;br0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;bridge name=&quot;br0&quot;/&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指定宿主机上已有的 Linux bridge 设备名&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;forward mode=&quot;bridge&quot;/&amp;gt;&lt;/code&gt; vs &lt;code&gt;&amp;lt;forward mode=&quot;nat&quot;/&amp;gt;&lt;/code&gt;&lt;/strong&gt;：前者工作在数据链路层（二层），后者工作在网络层（三层）。桥接模式下的 VM 和宿主机在同一个广播域中。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;9.6 将虚拟机接入桥接网络&lt;/h3&gt;
&lt;h4&gt;热切换（VM 运行时在线切换）&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 1. 查看当前接口
sudo virsh domiflist fedora-vm
# 接口     类型      源          MAC
# vnet4    network   default    52:54:00:cf:37:f1

# 2. 断开默认 NAT 接口（--mac 用于精确定位要删除的接口）
sudo virsh detach-interface fedora-vm network --mac 52:54:00:cf:37:f1

# 3. 接入桥接网络
sudo virsh attach-interface fedora-vm bridge br0 \
  --model virtio \   # 准虚拟化网卡（最佳性能）
  --config \         # 持久化到 XML 配置
  --live             # 立即生效（--config 只存配置，--live 才在线生效；两者同时用=永久在线切换）
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;--live&lt;/code&gt; vs &lt;code&gt;--config&lt;/code&gt;&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;--live&lt;/code&gt;：立即生效，但重启后丢失&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--config&lt;/code&gt;：写入 XML，下次启动才生效&lt;/li&gt;
&lt;li&gt;两者共用 = 立即生效且持久化&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h4&gt;在 VM 内刷新 DHCP&lt;/h4&gt;
&lt;p&gt;网卡切换后，VM 需要重新获取 IP：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 进入 VM 控制台
sudo virsh console fedora-vm

# 在 VM 内执行
sudo dhclient enp1s0
# 或
sudo nmcli con down enp1s0 &amp;amp;&amp;amp; sudo nmcli con up enp1s0
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;验证&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 宿主机上查看 VM 的网络接口
sudo virsh domiflist fedora-vm
# 接口     类型     源    型号      MAC
# vnet5    bridge   br0   virtio   52:54:00:54:7e:bc
#                       ↑ 已改为 br0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;9.7 查看虚拟机 IP 的多种方式&lt;/h3&gt;
&lt;p&gt;这是日常管理中最常用的操作，按推荐度排列：&lt;/p&gt;
&lt;h4&gt;方式一：&lt;code&gt;virsh domifaddr&lt;/code&gt;（推荐，最全面）&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 通过 Guest Agent 查询（需 VM 内安装 qemu-guest-agent）
sudo virsh domifaddr fedora-vm --source agent
# 名称      MAC 地址            协议    地址
# lo        00:00:00:00:00:00   ipv4    127.0.0.1/8
# enp1s0    52:54:00:54:7e:bc   ipv4    192.168.43.250/24

# 通过 libvirt DHCP 租约查询（仅 NAT 网络有效）
sudo virsh domifaddr fedora-vm --source lease
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;--source&lt;/code&gt; 参数的含义：&lt;/strong&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;code&gt;agent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;所有网络&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;查询 VM 内的 qemu-guest-agent，返回真实 IP。最准确。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lease&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;仅 NAT&lt;/td&gt;
&lt;td&gt;查询 libvirt 的 dnsmasq DHCP 租约表。桥接模式下不由 libvirt 管理 DHCP，故查不到。&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;arp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;所有网络&lt;/td&gt;
&lt;td&gt;查看宿主机的 ARP 表（同方式三）。&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;方式二：&lt;code&gt;virsh net-dhcp-leases&lt;/code&gt;（仅 NAT 网络）&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;sudo virsh net-dhcp-leases default
# 到期时间               MAC 地址             协议  IP 地址               主机名
# 2026-05-19 03:40:42   52:54:00:cf:37:f1   ipv4  192.168.122.225/24   -
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;仅适用于 &lt;code&gt;default&lt;/code&gt; NAT 网络。桥接到 &lt;code&gt;br0&lt;/code&gt; 后，DHCP 由路由器负责，libvirt 不知道租约信息。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;方式三：宿主机的 ARP 表&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;ip neigh show dev br0
# 192.168.43.250 lladdr 52:54:00:54:7e:bc REACHABLE

# 或用 arp 命令
arp -n -i br0
# Address          HWtype  HWaddress           Flags  Iface
# 192.168.43.250   ether   52:54:00:54:7e:bc   C      br0
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;原理：宿主机和 VM 在同一个二层域（br0），宿主机通信时自然会缓存 VM 的 MAC-IP 映射。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;方式四：路由器后台&lt;/h4&gt;
&lt;p&gt;直接登录路由器管理页面（如 &lt;code&gt;192.168.43.1&lt;/code&gt;），在 DHCP 客户端列表中找到 VM（可根据主机名 &lt;code&gt;fedora-vm&lt;/code&gt; 或 MAC 地址识别）。&lt;/p&gt;
&lt;h4&gt;方式五：登入 VM 内部查看&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;sudo virsh console fedora-vm
# 登录后执行
ip addr show enp1s0
# 或
hostname -I
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;IP 查看速查表&lt;/h4&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;日常快速查 IP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo virsh domifaddr fedora-vm --source agent&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAT 网络查 IP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo virsh net-dhcp-leases default&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;桥接网络查 IP&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ip neigh show dev br0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Guest Agent 未装&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sudo virsh console fedora-vm&lt;/code&gt; 进去看&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;9.8 SSH 隧道方案（备选）&lt;/h3&gt;
&lt;p&gt;如果不想改网络，可以用宿主机做跳板：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 笔记本上执行（一次性）
ssh -J mint@&amp;lt;R730的IP&amp;gt; mint@192.168.122.225
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;配置 &lt;code&gt;~/.ssh/config&lt;/code&gt; 后更简单：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Host r730
    HostName &amp;lt;R730的IP&amp;gt;
    User mint

Host fedora-vm
    HostName 192.168.122.225
    User mint
    ProxyJump r730
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后直接 &lt;code&gt;ssh fedora-vm&lt;/code&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;原理&lt;/strong&gt;：SSH 客户端先连 R730，在 R730 上开启一个端口转发通道，再通过该通道连接 VM。&lt;code&gt;192.168.122.225&lt;/code&gt; 对笔记本仍然不可达，但 R730 可达——它充当了&lt;strong&gt;代理&lt;/strong&gt;角色。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;9.9 端口转发方案（备选）&lt;/h3&gt;
&lt;p&gt;只暴露特定端口，用 iptables 做 DNAT：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 在 R730 上执行：把 R730:2222 转发到 VM:22
sudo iptables -t nat -A PREROUTING -p tcp --dport 2222 \
  -j DNAT --to-destination 192.168.122.225:22
sudo iptables -A FORWARD -p tcp -d 192.168.122.225 --dport 22 -j ACCEPT
sudo iptables -t nat -A POSTROUTING -j MASQUERADE

# 持久化（Debian）
sudo apt install iptables-persistent
sudo netfilter-persistent save
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;笔记本连接：&lt;code&gt;ssh -p 2222 mint@192.168.43.101&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;数据流路径&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;笔记本 → R730:2222 → iptables DNAT → VM:22
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;适用&lt;/strong&gt;：只需暴露 Web（80/443）或 SSH 等少数端口。不适用多端口或 UDP 密集型服务。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;10. Fedora 安装小结&lt;/h2&gt;
&lt;p&gt;Fedora 安装的核心在于&lt;strong&gt;同时使用 &lt;code&gt;--location&lt;/code&gt;（提取内核）和 &lt;code&gt;--disk device=cdrom&lt;/code&gt;（传光盘设备）&lt;/strong&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--disk /dev/sr0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;把物理光驱作为块设备传给 VM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--location /mnt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;提取 vmlinuz + initrd 引导&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inst.repo=cdrom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;告诉 Anaconda 从光盘读取 RPM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;详细公式见文末第 13 节&quot;全文总结&quot;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;11. Arch Linux 安装实战&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：在上述 Fedora 安装完成后，笔者又用同样的物理光驱安装了 Arch Linux（2026.05.01 版 ISO），踩了完全不同的坑。本节记录整个过程。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/4cea1a892a094b67afa10c53ddd41a69.BuLMqd9t.png&quot; alt=&quot;Arch&quot; title=&quot;archlinux喵&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;11.1 Arch Linux 的光驱引导&lt;/h3&gt;
&lt;p&gt;Arch Linux 的 ISO 结构与 Fedora 完全不同——它没有 &lt;code&gt;virt-install --location&lt;/code&gt; 能识别的安装树。&lt;code&gt;--location&lt;/code&gt; 检查 &lt;code&gt;/mnt&lt;/code&gt; 后直接报错：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ERROR  验证安装位置出错：在 URL &apos;/mnt&apos; 上找不到可安装的发行版
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;失败原因&lt;/h4&gt;
&lt;p&gt;Fedora 光盘内有标准的 &lt;code&gt;images/pxeboot/vmlinuz + initrd.img&lt;/code&gt; 布局，&lt;code&gt;--location&lt;/code&gt; 能自动发现。Arch 的内核和 initrd 则藏在更深的路径下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/mnt/arch/boot/x86_64/vmlinuz-linux
/mnt/arch/boot/x86_64/initramfs-linux.img
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;必须先探查光盘布局：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mount /dev/sr0 /mnt -o ro
ls -laR /mnt/arch/boot/
# 输出：
# /mnt/arch/boot/x86_64/vmlinuz-linux
# /mnt/arch/boot/x86_64/initramfs-linux.img
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时查看引导器配置，获取必需的内核参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat /mnt/loader/entries/01-archiso-linux.conf
# title    Arch Linux install medium (x86_64, UEFI)
# linux    /arch/boot/x86_64/vmlinuz-linux
# initrd   /arch/boot/x86_64/initramfs-linux.img
# options  archisobasedir=arch archisosearchuuid=2026-05-01-06-05-08-00
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;关键参数 &lt;code&gt;archisobasedir=arch&lt;/code&gt;&lt;/strong&gt; 和 &lt;strong&gt;&lt;code&gt;archisosearchuuid=&amp;lt;UUID&amp;gt;&lt;/code&gt;&lt;/strong&gt; 是 Arch ISO 引导时必须的。没有它们，initramfs 找不到光盘上的根文件系统。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;11.2 正确的启动命令&lt;/h3&gt;
&lt;p&gt;Arch 需要用 &lt;code&gt;--boot&lt;/code&gt; 显式指定内核和 initrd 路径，不能依赖 &lt;code&gt;--location&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 1. 挂载 DVD
sudo mount /dev/sr0 /mnt -o ro

# 2. 清理残留
sudo virsh destroy arch-vm 2&amp;gt;/dev/null
sudo virsh undefine arch-vm --remove-all-storage 2&amp;gt;/dev/null

# 3. 启动安装
sudo virt-install \
  --name arch-vm \
  --ram 2048 \
  --vcpus 2 \
  --disk path=/var/lib/libvirt/images/arch-vm.qcow2,size=30 \
  --disk /dev/sr0,device=cdrom,readonly=on \
  --boot kernel=/mnt/arch/boot/x86_64/vmlinuz-linux,\
         initrd=/mnt/arch/boot/x86_64/initramfs-linux.img,\
         kernel_args=&quot;console=ttyS0,115200 archisobasedir=arch archisosearchuuid=2026-05-01-06-05-08-00&quot; \
  --os-variant archlinux \
  --network network=default \
  --graphics none \
  --console pty,target_type=serial
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;参数对比：Fedora vs Arch&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;参数&lt;/th&gt;
&lt;th&gt;Fedora (Anaconda)&lt;/th&gt;
&lt;th&gt;Arch Linux (archiso)&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&gt;--location /mnt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--boot kernel=...,initrd=...,kernel_args=...&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内核参数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;inst.text inst.repo=cdrom&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;archisobasedir=arch archisosearchuuid=&amp;lt;UUID&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;安装源&lt;/td&gt;
&lt;td&gt;自动搜索光盘上的 RPM 仓库&lt;/td&gt;
&lt;td&gt;archiso 会自动挂载 &lt;code&gt;/dev/sr0&lt;/code&gt; 到 &lt;code&gt;/run/archiso/bootmnt&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;安装程序&lt;/td&gt;
&lt;td&gt;Anaconda（图形/文本向导）&lt;/td&gt;
&lt;td&gt;纯命令行（手动 &lt;code&gt;pacstrap&lt;/code&gt;）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;成功启动后，Arch Live 环境会直接进入 root shell：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Arch Linux 7.0.3-arch1-1 (ttyS0)
archiso login: root （无密码直接登录）
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;11.3 镜像源加速&lt;/h3&gt;
&lt;p&gt;默认镜像源在国内只有几百 B/s，安装前务必切换到国内镜像：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo &quot;Server = https://mirrors.ustc.edu.cn/archlinux/\$repo/os/\$arch&quot; &amp;gt; /etc/pacman.d/mirrorlist
echo &quot;Server = https://mirrors.tuna.tsinghua.edu.cn/archlinux/\$repo/os/\$arch&quot; &amp;gt;&amp;gt; /etc/pacman.d/mirrorlist
echo &quot;Server = https://mirrors.aliyun.com/archlinux/\$repo/os/\$arch&quot; &amp;gt;&amp;gt; /etc/pacman.d/mirrorlist

# 开启并行下载
sed -i &apos;s/^#ParallelDownloads/ParallelDownloads/&apos; /etc/pacman.conf
pacman -Syy
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;效果：下载速度从 &lt;strong&gt;12 KiB/s → 3 MiB/s&lt;/strong&gt;，提升约 250 倍。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;11.4 分区与 Btrfs 文件系统&lt;/h3&gt;
&lt;p&gt;由于 &lt;code&gt;zfs-dkms&lt;/code&gt; 已从 Arch 官方仓库移除，且第三方 archzfs 源在国内不可用，改用 &lt;strong&gt;Btrfs&lt;/strong&gt;——它和 ZFS 一样支持 CoW（写时复制）、快照、透明压缩等特性，且原生内置于内核。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 查看磁盘
lsblk
# vda  30G  ← 唯一磁盘

# MBR 分区（BIOS 模式）
parted /dev/vda mklabel msdos
parted /dev/vda mkpart primary ext4 1MiB 513MiB    # /boot
parted /dev/vda set 1 boot on
parted /dev/vda mkpart primary 513MiB 100%           # btrfs

# 格式化
mkfs.ext4 /dev/vda1                                 # boot 分区
mkfs.btrfs -f /dev/vda2                             # btrfs 根分区

# 创建 btrfs 子卷（方便快照管理）
mount /dev/vda2 /mnt
btrfs subvolume create /mnt/@                       # 根子卷
btrfs subvolume create /mnt/@home                   # home 子卷
umount /mnt

# 挂载
mount -o compress=zstd,subvol=@ /dev/vda2 /mnt
mkdir -p /mnt/{boot,home}
mount -o compress=zstd,subvol=@home /dev/vda2 /mnt/home
mount /dev/vda1 /mnt/boot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;分区布局&lt;/strong&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&gt;/dev/vda1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;512 MiB&lt;/td&gt;
&lt;td&gt;ext4&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/boot&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/dev/vda2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;29.5 GiB&lt;/td&gt;
&lt;td&gt;btrfs (子卷 @)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/&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;btrfs (子卷 @home)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/home&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;11.5 安装基础系统&lt;/h3&gt;
&lt;p&gt;安装 &lt;code&gt;linux-zen&lt;/code&gt; 内核以支持 &lt;strong&gt;drm_panic&lt;/strong&gt; 功能（内核崩溃时显示 QR 码），同时安装中文字体：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pacstrap /mnt base base-devel \
  linux-zen linux-zen-headers \
  linux-firmware btrfs-progs \
  grub networkmanager \
  vim sudo git \
  noto-fonts-cjk
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;为什么选 &lt;code&gt;linux-zen&lt;/code&gt;&lt;/strong&gt;：主线内核的 &lt;code&gt;drm_panic&lt;/code&gt; 功能需要 &lt;code&gt;CONFIG_DRM_PANIC=y&lt;/code&gt; 编译选项。Zen 内核开箱即支持，且针对桌面/服务器混合负载做了优化。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/c8c005f2c94cf8fb46ab287fad7cf993.KDXr7Cmc.png&quot; alt=&quot;Arch Linux Vnc&quot; title=&quot;在VNC里看VNC&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;11.6 系统配置&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 1. fstab
genfstab -U /mnt &amp;gt;&amp;gt; /mnt/etc/fstab

# 2. chroot
arch-chroot /mnt

# 3. 时区与本地化
ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
hwclock --systohc
echo &apos;en_US.UTF-8 UTF-8&apos; &amp;gt; /etc/locale.gen
echo &apos;zh_CN.UTF-8 UTF-8&apos; &amp;gt;&amp;gt; /etc/locale.gen
locale-gen
echo &apos;LANG=en_US.UTF-8&apos; &amp;gt; /etc/locale.conf
echo &apos;arch-vm&apos; &amp;gt; /etc/hostname

# 4. 设置密码
echo &apos;root:arch123&apos; | chpasswd
useradd -m -G wheel -s /bin/bash mint
echo &apos;mint:mint123&apos; | chpasswd
echo &apos;%wheel ALL=(ALL:ALL) ALL&apos; &amp;gt;&amp;gt; /etc/sudoers.d/wheel

# 5. 复制镜像源配置（新系统也用国内源）
cp /etc/pacman.d/mirrorlist /etc/pacman.conf .
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;11.7 initramfs 与 GRUB&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 在 mkinitcpio 中添加 btrfs hook
sed -i &apos;s/^HOOKS=.*/HOOKS=(base udev autodetect modconf block btrfs filesystems keyboard fsck)/&apos; /etc/mkinitcpio.conf
mkinitcpio -P

# 安装 GRUB（BIOS 模式）
grub-install --target=i386-pc /dev/vda

# 配置串口输出 + drm_panic
sed -i &apos;s|^GRUB_CMDLINE_LINUX_DEFAULT=&quot;|GRUB_CMDLINE_LINUX_DEFAULT=&quot;console=ttyS0,115200 drm.panic_register=1 |&apos; /etc/default/grub
sed -i &apos;s|^GRUB_TERMINAL_INPUT=|GRUB_TERMINAL_INPUT=console serial|&apos; /etc/default/grub
sed -i &apos;s|^#GRUB_TERMINAL_OUTPUT=|GRUB_TERMINAL_OUTPUT=console serial|&apos; /etc/default/grub
echo &apos;GRUB_SERIAL_COMMAND=&quot;serial --speed=115200 --unit=0 --word=8 --parity=no --stop=1&quot;&apos; &amp;gt;&amp;gt; /etc/default/grub
grub-mkconfig -o /boot/grub/grub.cfg

# 启用 NetworkManager
systemctl enable NetworkManager

# 退出 chroot，卸载，关机
exit
umount -R /mnt
poweroff
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;11.8 安装后清理&lt;/h3&gt;
&lt;p&gt;VM 关机后，从 libvirt XML 中移除 DVD 内核引导配置，改为纯磁盘启动：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 导出现有 XML，删除 kernel/initrd/cmdline 行
sudo virsh dumpxml arch-vm &amp;gt; /tmp/arch-vm.xml
sed -i &apos;/&amp;lt;kernel&amp;gt;.*vmlinuz-linux&amp;lt;\/kernel&amp;gt;/d&apos; /tmp/arch-vm.xml
sed -i &apos;/&amp;lt;initrd&amp;gt;.*initramfs-linux.img&amp;lt;\/initrd&amp;gt;/d&apos; /tmp/arch-vm.xml
sed -i &apos;/&amp;lt;cmdline&amp;gt;.*archisobasedir.*&amp;lt;\/cmdline&amp;gt;/d&apos; /tmp/arch-vm.xml

# 重新定义并启动
sudo virsh define /tmp/arch-vm.xml
sudo virsh start arch-vm
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;用户密码&lt;/strong&gt;：&lt;code&gt;root&lt;/code&gt; / &lt;code&gt;arch123&lt;/code&gt;，&lt;code&gt;mint&lt;/code&gt; / &lt;code&gt;mint123&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;12. VNC 图形控制台配置&lt;/h2&gt;
&lt;p&gt;虽然服务器无图形界面，但为 VM 配置 VNC 后，可以从笔记本用 VNC 客户端直连查看 VM 画面（GRUB 菜单、tty 等）。对于 &lt;code&gt;virt-manager&lt;/code&gt; 远程管理也必不可少。&lt;/p&gt;
&lt;h3&gt;12.1 原理&lt;/h3&gt;
&lt;p&gt;libvirt 的 &lt;code&gt;&amp;lt;graphics&amp;gt;&lt;/code&gt; 元素告诉 QEMU 启动一个内置的 VNC 服务器。&lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; 元素定义虚拟显卡型号。两者配合后，VM 就有了图形输出通道。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;笔记本 VNC 客户端 ──TCP 5901──► QEMU (libvirt) ──► VM 虚拟显卡
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;12.2 为已有 VM 添加 VNC&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;注意&lt;/strong&gt;：操作需要 destroy → define → start，VM 会短暂中断（约 10 秒）。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;# 1. 导出 VM 的 XML
sudo virsh dumpxml &amp;lt;vm-name&amp;gt; | sudo tee /tmp/&amp;lt;vm-name&amp;gt;-vnc.xml &amp;gt; /dev/null

# 2. 在 &amp;lt;devices&amp;gt; 起始标签后插入 graphics + video
sudo sed -i &apos;s|&amp;lt;devices&amp;gt;|&amp;lt;devices&amp;gt;\n    &amp;lt;graphics type=&quot;vnc&quot; port=&quot;-1&quot; autoport=&quot;yes&quot; listen=&quot;0.0.0.0&quot;&amp;gt;\n      &amp;lt;listen type=&quot;address&quot; address=&quot;0.0.0.0&quot;/&amp;gt;\n    &amp;lt;/graphics&amp;gt;\n    &amp;lt;video&amp;gt;\n      &amp;lt;model type=&quot;virtio&quot; heads=&quot;1&quot; primary=&quot;yes&quot;/&amp;gt;\n    &amp;lt;/video&amp;gt;|&apos; /tmp/&amp;lt;vm-name&amp;gt;-vnc.xml

# 3. 销毁 → 重新定义 → 启动
sudo virsh destroy &amp;lt;vm-name&amp;gt;
sudo virsh define /tmp/&amp;lt;vm-name&amp;gt;-vnc.xml
sudo virsh start &amp;lt;vm-name&amp;gt;

# 4. 查看分配的 VNC 端口
sudo virsh vncdisplay &amp;lt;vm-name&amp;gt;
# 输出示例：:1  →  端口 5901
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;&amp;lt;graphics&amp;gt;&lt;/code&gt; 参数说明&lt;/strong&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;code&gt;type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vnc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;使用 VNC 协议&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;port&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;自动分配端口&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;autoport&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;自动选择可用端口&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;listen&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0.0.0.0&lt;/code&gt;&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;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; 参数说明&lt;/strong&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;code&gt;type&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;virtio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;准虚拟化显卡（性能最佳）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;heads&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;单显示器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;primary&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;yes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;主显示设备&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;12.3 实际执行结果&lt;/h3&gt;
&lt;p&gt;笔者两台 VM 配置后的端口分配：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;虚拟机&lt;/th&gt;
&lt;th&gt;VNC 端口&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&gt;arch-vm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.43.101:5901&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fedora-vm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;:2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;192.168.43.101:5902&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;12.4 从笔记本连接&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;VNC 客户端直连&lt;/strong&gt;（最直接）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Linux 笔记本
vncviewer 192.168.43.101:5901

# Windows/Mac 可用 TigerVNC、RealVNC 等客户端
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;virt-manager 远程连接&lt;/strong&gt;（在笔记本上安装 virt-manager）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 笔记本终端执行（X11 转发）
ssh -X mint@192.168.43.101 virt-manager
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这会打开图形化的虚拟机管理窗口，可以查看所有 VM 的状态和画面。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;13. 总结&lt;/h2&gt;
&lt;h3&gt;核心教训&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Fedora/Anaconda 的图形安装器与无图形服务器不兼容&lt;/strong&gt;，必须用 &lt;code&gt;inst.text&lt;/code&gt; 强制文本模式&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;--cdrom&lt;/code&gt; 模式下不能传 &lt;code&gt;--extra-args&lt;/code&gt;&lt;/strong&gt;，必须改用 &lt;code&gt;--location&lt;/code&gt; 方式引导&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;--location&lt;/code&gt; 只传引导文件，不传光盘设备&lt;/strong&gt;，必须同时用 &lt;code&gt;--disk /dev/sr0,device=cdrom&lt;/code&gt; 把光盘作为块设备传给 VM&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;inst.repo=cdrom&lt;/code&gt; 是解决 &lt;code&gt;dracut-initqueue timeout&lt;/code&gt; 的关键参数&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Arch Linux 不能用 &lt;code&gt;--location&lt;/code&gt;&lt;/strong&gt;，必须用 &lt;code&gt;--boot&lt;/code&gt; 显式指定内核路径和 &lt;code&gt;archisobasedir&lt;/code&gt; 参数&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Arch 官方仓库已移除 &lt;code&gt;zfs-dkms&lt;/code&gt;&lt;/strong&gt;，国内 archzfs 镜像不可用，改用 &lt;strong&gt;Btrfs&lt;/strong&gt;（子卷 + zstd 压缩）是更务实的选择&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;drm_panic&lt;/code&gt; QR 码内核崩溃诊断&lt;/strong&gt; 需要 &lt;code&gt;linux-zen&lt;/code&gt; 内核 + &lt;code&gt;drm.panic_register=1&lt;/code&gt; 内核参数&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VNC 图形控制台&lt;/strong&gt; 通过修改 libvirt XML 添加 &lt;code&gt;&amp;lt;graphics&amp;gt;&lt;/code&gt; + &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; 实现，不依赖宿主机图形环境&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;通用公式&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Fedora/RHEL/CentOS/Rocky 系列&lt;/strong&gt;（Anaconda 安装器）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mount /dev/sr0 /mnt -o ro

sudo virt-install \
  --name &amp;lt;vm-name&amp;gt; \
  --ram &amp;lt;内存&amp;gt; --vcpus &amp;lt;CPU数&amp;gt; \
  --disk path=&amp;lt;磁盘路径&amp;gt;,size=&amp;lt;大小&amp;gt; \
  --disk /dev/sr0,device=cdrom,readonly=on \
  --location /mnt \
  --os-variant &amp;lt;发行版&amp;gt; \
  --network network=default \
  --graphics none \
  --extra-args &quot;inst.text console=ttyS0,115200 inst.repo=cdrom&quot; \
  --console pty,target_type=serial
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Arch Linux 系列&lt;/strong&gt;（archiso，用 &lt;code&gt;--boot&lt;/code&gt; 显式指定内核）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mount /dev/sr0 /mnt -o ro
# 先获取 UUID: cat /mnt/boot/*.uuid

sudo virt-install \
  --name &amp;lt;vm-name&amp;gt; \
  --ram &amp;lt;内存&amp;gt; --vcpus &amp;lt;CPU数&amp;gt; \
  --disk path=&amp;lt;磁盘路径&amp;gt;,size=&amp;lt;大小&amp;gt; \
  --disk /dev/sr0,device=cdrom,readonly=on \
  --boot kernel=/mnt/arch/boot/x86_64/vmlinuz-linux,\
         initrd=/mnt/arch/boot/x86_64/initramfs-linux.img,\
         kernel_args=&quot;console=ttyS0,115200 archisobasedir=arch archisosearchuuid=&amp;lt;UUID&amp;gt;&quot; \
  --os-variant archlinux \
  --network network=default \
  --graphics none \
  --console pty,target_type=serial
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;本文记录于 2026 年 5 月 19 日，环境 Debian 13 + libvirt 11.3.0 + QEMU 10.0.8&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>折腾家里云： 入手一台戴尔R730服务器，设置IDRAC直通并配置ssh隧道</title><link>https://www.mintlab.top/posts/tries/r730-setup/</link><guid isPermaLink="true">https://www.mintlab.top/posts/tries/r730-setup/</guid><description>学校批了一间办公室，不要电费，空调随便开。哎呀，路过科技市场，脚一滑，不知道怎么的就上2楼了，不知道怎么了就配了台服务器回来。本文主要讲解R730服务器的一些特点，以及设置IDRAC直通，和利用已有云服务器配置ssh隧道的过程。</description><pubDate>Sun, 17 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;学校批了一间办公室，不要电费，空调随便开。哎呀，路过科技市场，脚一滑，不知道怎么的就上2楼了，不知道怎么了就配了台服务器回来。本文主要讲解R730服务器的一些特点，以及设置IDRAC直通，和利用已有云服务器配置ssh隧道的过程。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/IMG_20260513_220837_4986c12f.BlehSDup.jpg&quot; alt=&quot;&quot; title=&quot;她真漂亮&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/mmexport1778673059822_809d422b.CwCugW2r.jpg&quot; alt=&quot;&quot; title=&quot;小彩屏，嘿嘿，小彩屏&quot; /&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;待机功耗56瓦，风扇确实安静，白天根本听不出来&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&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;戴尔 R730 3.5英寸 8盘位准系统&lt;/td&gt;
&lt;td&gt;含 H730 阵列卡、提升卡、8个硬盘架（装满）&lt;/td&gt;
&lt;td&gt;750&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;单路 Intel Xeon E5-2696v4&lt;/td&gt;
&lt;td&gt;280&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内存&lt;/td&gt;
&lt;td&gt;16GB DDR4 ECC 一根&lt;/td&gt;
&lt;td&gt;320&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;硬盘&lt;/td&gt;
&lt;td&gt;512GB 机械硬盘（赠送）&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;合计&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1350&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;我是直接在科技市场线下买的，价格可能偏贵，如果在淘宝可能会更便宜。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;设置为默认命令行启动&lt;/h2&gt;
&lt;p&gt;:::note
我的服务器安装的主系统是&lt;code&gt;Debian 13 (Trixie)&lt;/code&gt;, 桌面环境是&lt;code&gt;Xfce&lt;/code&gt;，通过以下设置可使系统默认以纯命令行启动，并且可以自由启动和退出图形化界面。
:::&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/a98e266faa3341a43fce105a9241071b.LKQGjHQz.jpg&quot; alt=&quot;Debian 13 Live系统&quot; title=&quot;在服务器上办公(双关)&quot; /&gt;&lt;/p&gt;
&lt;p&gt;通过 systemd 的 &lt;code&gt;multi-user.target&lt;/code&gt; (纯命令行) 和 &lt;code&gt;graphical.target&lt;/code&gt; (带桌面环境) 来控制默认启动模式。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl set-default multi-user.target
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行以下命令确认设置成功，若输出为 &lt;code&gt;multi-user.target&lt;/code&gt; 即成功：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;systemctl get-default
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;手动启动和退出桌面环境&lt;/h3&gt;
&lt;p&gt;使用 &lt;code&gt;startx&lt;/code&gt; 命令启动桌面环境，如果提示未找到命令，需先安装 xinit 包。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;startx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如需退出桌面环境，直接在桌面环境中&lt;strong&gt;点击注销&lt;/strong&gt;即可，注意注销后桌面环境中未保存的工作将会丢失。&lt;/p&gt;
&lt;h2&gt;idrac 配置为直通模式&lt;/h2&gt;
&lt;p&gt;:::note&lt;/p&gt;
&lt;p&gt;戴尔服务器有一个独立于主系统的管理系统idrac，默认是独立的网络，在主系统内访问不到idrac，这样设计的好处是可以在服务器出现故障，或是主系统未启动时依然可以访问idrac，还可以远程重装系统。&lt;/p&gt;
&lt;p&gt;我没有多的设备对idrac进行独立穿透了，所以我选择&lt;strong&gt;将idrac设置为直通模式&lt;/strong&gt;，通过主系统访问并转发到云服务器，但这样&lt;strong&gt;会使idrac的访问必须依赖主系统的运行。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;重启服务器&lt;/li&gt;
&lt;li&gt;在bios启动显示以下界面时根据左上角的提示按&lt;code&gt;F2&lt;/code&gt;进入&lt;code&gt;System Setup&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/6f0b2e01fd3cc9abc4879f2047bc09fa.B5yK_3XZ.jpg&quot; alt=&quot;bios实拍1&quot; title=&quot;bios启动界面&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;选择并进入 &lt;code&gt;iDRAC Settings&lt;/code&gt; 菜单&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/f228c61e2d7e1181c268abd461c85165.BKZ15mFH.jpg&quot; alt=&quot;bios实拍2&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;找到并进入 &lt;code&gt;Communications Permissions&lt;/code&gt; (通信权限)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/ac47afa935ca75a8dd978c4913141e9e.DT5rZs_b.jpg&quot; alt=&quot;bios实拍3&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在 &lt;code&gt;OS to iDRAC Pass-through&lt;/code&gt;设置项，选择 &lt;code&gt;USB NIC&lt;/code&gt; 模式&lt;/li&gt;
&lt;li&gt;在下方的&lt;code&gt;OS IP&lt;/code&gt;中输入自定义的ip，这里设置为&lt;code&gt;169.254.0.1&lt;/code&gt;，后面要用&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/e405e6a7f97710ec0141b5d6ecbb26a8.UwDrRtpB.jpg&quot; alt=&quot;bios实拍4&quot; title=&quot;直通设置&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;保存退出并继续引导，启动主系统。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;测试端口是否开放&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nc -zv 169.254.0.1 443

# 参考输出
# Connection to 169.254.0.1 443 port [tcp/https] succeeded!
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;给R730配置一个连接到云服务器的ssh密钥&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;这一段相信大家都会做，不然怎么连上服务器，难道真用桌面吗(x)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;ssh-keygen -t rsa -b 4096 -C &quot;在公钥中的描述文字&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将&lt;code&gt;xxx.pub&lt;/code&gt;文件的内容追加到云服务器的&lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt;，如果云服务器设置了允许密码登录(不推荐)，则还可以这样上传(会提示输入密码)：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh-copy-id user@hostname
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;配置 ssh 隧道&lt;/h2&gt;
&lt;p&gt;:::note&lt;/p&gt;
&lt;p&gt;需要一个&lt;strong&gt;带有固定公网ip的云服务器作为中转&lt;/strong&gt;，R730先通过ssh连接到云服务器，将自己&lt;strong&gt;本地的ssh端口转发到云服务器&lt;/strong&gt;的本地回环端口上(远程转发)，远程登录时先使用ssh登录云服务器作为跳板，&lt;strong&gt;再将云服务器端口转发到本地&lt;/strong&gt;(本地转发)，通过本地ssh连接R730。&lt;/p&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h3&gt;ssh 转发格式&lt;/h3&gt;
&lt;h4&gt;&lt;code&gt;-L&lt;/code&gt; 本地转发 (Local)&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;
ssh -L [绑定地址:]本地端口:目标主机:目标端口  user@ssh服务器

&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;绑定地址&lt;/strong&gt;（可选）：默认 &lt;code&gt;127.0.0.1&lt;/code&gt;，表示只允许本机连接该本地端口。若写 &lt;code&gt;0.0.0.0&lt;/code&gt; 或 &lt;code&gt;*&lt;/code&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;strong&gt;SSH 服务端的角度解析的（即服务端去连接它）&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;&lt;code&gt;-R&lt;/code&gt; 远程转发 (Remote)&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;
ssh -R [绑定地址:]远程端口:目标主机:目标端口  user@ssh服务器

&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;绑定地址&lt;/strong&gt;（可选）：默认 &lt;code&gt;127.0.0.1&lt;/code&gt;，表示只在 SSH 服务端本地监听该远程端口。若想允许其他机器访问服务端的这个端口，通常需要服务端配置 &lt;code&gt;GatewayPorts yes&lt;/code&gt;，或显式指定 &lt;code&gt;0.0.0.0&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;远程端口&lt;/strong&gt;：在 SSH 服务端机器上监听的端口。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;目标主机:目标端口&lt;/strong&gt;：最终要访问的服务地址，这个地址是从&lt;strong&gt;SSH 客户端（本地）的角度解析的（即客户端去连接它）&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;安装并配置autossh&lt;/h3&gt;
&lt;p&gt;为了连接稳定，使用&lt;code&gt;autossh&lt;/code&gt;包代替ssh，拥有自动保活控制。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
 sudo apt update 
 sudo apt install autossh 

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;新建以下systemd服务文件在&lt;code&gt;/etc/systemd/system/autossh-tunnel.service&lt;/code&gt;，&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//file: /etc/systemd/system/autossh-tunnel.service

[Unit]
Description=AutoSSH reverse tunnel for R730
After=network.target

[Service]
User=&amp;lt;执行服务的本机用户&amp;gt;
Environment=&quot;AUTOSSH_GATETIME=0&quot;
ExecStart=/usr/bin/autossh -M 0 -N -o &quot;ServerAliveInterval 60&quot; -o &quot;ServerAliveCountMax 3&quot;  -o &quot;ExitOnForwardFailure yes&quot; \
          -R 7322:localhost:7322 \    # ssh端口
          -R 7320:169.254.0.1:443 \   # idrac网页控制端口
          -R 5900:169.254.0.1:5900 \  # vnc 端口
          -R 5901:169.254.0.1:5901 \  # vnc ws控制端口
          &amp;lt;云服务器登录用户&amp;gt;@&amp;lt;云服务器ip&amp;gt;:&amp;lt;云服务器ssh端口&amp;gt;
Restart=always
RestartSec=60

[Install]
WantedBy=multi-user.target

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写入文件后保存退出并启动服务设置开机自启。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
sudo systemctl daemon-reload                      # 重载服务文件
sudo systemctl enable autossh-tunnel.service      # 设置开机自启
sudo systemctl start autossh-tunnel.service       # 启动服务
sudo systemctl status autossh-tunnel.service      # 查看服务状态

&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;参数解释&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;/usr/bin/autossh&lt;/code&gt;
&lt;code&gt;autossh&lt;/code&gt; 的可执行文件路径。该工具会在 SSH 连接断开时自动重连。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;-M 0&lt;/code&gt;
设置监控端口为 0，即禁用 &lt;code&gt;autossh&lt;/code&gt; 自带的连接检测机制（原本它会通过一个额外端口发送测试数据）。当使用 &lt;code&gt;-M 0&lt;/code&gt; 时，&lt;code&gt;autossh&lt;/code&gt; 完全依赖 &lt;code&gt;SSH&lt;/code&gt; 自身的保活选项来判断连接是否存活。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;-N&lt;/code&gt;
告诉 SSH 不执行远程命令（不分配 shell）。通常用于纯端口转发场景，只建立隧道而不登录到远程主机。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;-o &quot;ServerAliveInterval 60&quot;&lt;/code&gt;
设置 SSH 的 &lt;code&gt;ServerAliveInterval&lt;/code&gt; 选项：&lt;strong&gt;每隔 60 秒&lt;/strong&gt;向服务器发送一个保活消息（空包），用于检测连接是否仍在工作。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;-o &quot;ServerAliveCountMax 3&quot;&lt;/code&gt;
设置 SSH 的 &lt;code&gt;ServerAliveCountMax&lt;/code&gt; 选项：最多&lt;strong&gt;连续 3 次&lt;/strong&gt;收不到保活消息的响应（即最长 60×3=180 秒无响应），就认为连接已断开，主动关闭连接。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;-o &quot;ExitOnForwardFailure yes&quot;&lt;/code&gt; &lt;strong&gt;（非常重要）&lt;/strong&gt;
设置 SSH 的 &lt;code&gt;ExitOnForwardFailure&lt;/code&gt; 选项：如果&lt;strong&gt;端口转发失败&lt;/strong&gt;（例如本地或远程端口被占用、无法绑定等），SSH 立即退出并报错，等待下一次重连。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::note&lt;/p&gt;
&lt;p&gt;如果R730 ssh连接意外断开(如断网)，在云服务器ssh处短时间不会断连，需要等待约180秒自动清理，这会导致&lt;strong&gt;远端端口暂时无法被释放&lt;/strong&gt;，R730重新连接ssh时虽然连接成功，但是建立端口转发会失败，如果加入&lt;code&gt;&quot;ExitOnForwardFailure yes&lt;/code&gt;这个选项，则会让&lt;code&gt;autossh&lt;/code&gt;检测到端口转发失败后自动重试。&lt;/p&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h3&gt;云服务器ssh配置&lt;/h3&gt;
&lt;p&gt;通过&lt;code&gt;sudo vim /etc/ssh/sshd_config&lt;/code&gt;修改云服务器sshd配置文件，新加入一行。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//file: /etc/ssh/sshd_config

# 在绑定时删除已有的套接字文件，避免端口已占用报错
StreamLocalBindUnlink yes

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;本机ssh_config配置&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;//file: ~/.ssh/config

Host cloud
    HostName &amp;lt;云服务器公网ip&amp;gt;
    User &amp;lt;云服务器登录用户&amp;gt;
    Port &amp;lt;云服务器ssh端口&amp;gt;
    IdentityFile /home/mint/.ssh/id_rsa           # 要使用的密钥的本地路径
    LocalForward 7322 localhost:7322
    LocalForward 7320 localhost:7320
    LocalForward 5900 localhost:5900
    LocalForward 5901 localhost:5901
Host R730
    HostName localhost
    Port 7322
    User &amp;lt;R730登录用户&amp;gt;
    ProxyJump cloud
    IdentityFile /home/mint/.ssh/R730_linux_LAPTOP # 要使用的密钥的本地路径

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;下次使用登录可直接使用：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ssh R730
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;使用Server Box作为移动端监控&lt;/h3&gt;
&lt;p&gt;一个非常好用且高颜值的移动端监控软件：&lt;/p&gt;
&lt;p&gt;::github{repo=&quot;lollipopkit/flutter_server_box&quot;}&lt;/p&gt;
&lt;p&gt;先连接云服务器，在连接R730时在下方配置&lt;code&gt;跳板服务器&lt;/code&gt;为连接好的云服务器，主机直接填&lt;code&gt;localhost&lt;/code&gt;即可：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/9b922a8a855df086f8c930a683fc92bf.BMo_kiPG.jpg&quot; alt=&quot;Server Box截图1&quot; title=&quot;配置跳板服务器&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/4cbc22f84639ce09397d9cc00a16ba30.Cv_hduQX.jpg&quot; alt=&quot;Server Box截图2&quot; title=&quot;连接成功&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;配置IDRAC远程连接&lt;/h2&gt;
&lt;p&gt;:::note
idrac的远程访问界面有Host检查，必须为本机地址才会放行，远程连接会显示400错误码。需要先登录idrac然后设置禁止Host检查。
:::&lt;/p&gt;
&lt;p&gt;登录到R730中，使用ssh连接IDRAC系统：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 默认用户名是 root，密码是你为 iDRAC 网页界面设置的登录密码, 默认是calvin
ssh root@169.254.0.1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关闭Host检查：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;racadm set idrac.webserver.HostHeaderCheck 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;重启web服务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;racadm racreset soft
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后稍等重启完成后，新建一个终端使用&lt;code&gt;ssh cloud&lt;/code&gt;建立端口转发，然后打开浏览器访问&lt;code&gt;https://localhost:7320&lt;/code&gt;，注意是&lt;strong&gt;https&lt;/strong&gt;，可能会提示不安全的连接，这是IDRAC内部的证书导致的不影响访问。&lt;/p&gt;
&lt;h3&gt;检查VNC配置&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;请确认IDRAC许可必须为&lt;code&gt;Enterprise&lt;/code&gt;才可以使用VNC，登录，默认用户名&lt;code&gt;root&lt;/code&gt;，密码&lt;code&gt;calvin&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/13704a4c83596dcfa7c3369c106f4740.IflAKkoU.png&quot; alt=&quot;Idrac截图1&quot; title=&quot;IDRAC登录界面&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;检查VNC服务器设置是否打开，控制端口设置为&lt;code&gt;5901&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/2471da5ad6188f59ca7e2ba35b06e7a5.EDR-5meA.png&quot; alt=&quot;Idrac截图2&quot; title=&quot;VNC服务器设置&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在虚拟控制台选项中设置远程端口为&lt;code&gt;5900&lt;/code&gt;，插件类型为&lt;code&gt;HTML5&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/07358d35338420df5c5ba6cf6cf827df.EkE8i1r1.png&quot; alt=&quot;Idrac截图3&quot; title=&quot;虚拟控制台设置&quot; /&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;点击&lt;code&gt;启动虚拟控制台&lt;/code&gt;即可&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/6cb357b0b9e6c026954c2cf1422a778e.Dac3iFvs.jpg&quot; alt=&quot;Idrac截图4&quot; title=&quot;VNC连接成功&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>博客前端性能大优化： 首图快一点，再快一点</title><link>https://www.mintlab.top/posts/tries/blog_enhance/</link><guid isPermaLink="true">https://www.mintlab.top/posts/tries/blog_enhance/</guid><description>Lighthouse 评分从 34 一路拉到桌面端 95，图片传输减少 80%，验证码脚本推后 1.7 秒，记录了每个优化项的踩坑过程</description><pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;起因是看月度总结那篇文章时发现，直接打开文章链接居然加载不出评论区，排查问题途中顺手做了个全站性能调优。Lighthouse 评分从 34 一路拉到桌面端 95、移动端 76。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;起因：评论区加载不出来&lt;/h2&gt;
&lt;p&gt;写完 &lt;a href=&quot;/posts/talk/2026-04/&quot;&gt;2026年4月总结&lt;/a&gt; 之后，把链接发给了朋友，他说页面打不开评论区。我点开试了一下——从首页点进去正常，但是&lt;strong&gt;直接复制链接到新标签页打开&lt;/strong&gt;，评论区空荡荡，控制台没有任何请求。&lt;/p&gt;
&lt;p&gt;神了，Swup 页面切换就正常，直接 SSR 渲染就不行。&lt;/p&gt;
&lt;h3&gt;排查：set:html + 模板字面量踩坑&lt;/h3&gt;
&lt;p&gt;F12 看了半天，发现评论区有个巨大的内联脚本（快一千行），用了 Astro 的 &lt;code&gt;set:html&lt;/code&gt; 语法把整段 JS 当字符串塞进去：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ❌ 旧写法
&amp;lt;script is:inline type=&quot;module&quot; set:html={
  `(function() {
    const articleKey = ${JSON.stringify(article)};
    // ...
  })();
`}&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个写法我以前翻过一次车：Astro 的 &lt;code&gt;set:html&lt;/code&gt; 里嵌套 JS 模板字面量 &lt;code&gt;${}&lt;/code&gt; 会破坏解析器。跟项目里正常的 &lt;code&gt;CommentForm.astro&lt;/code&gt; 对比，它用的是 &lt;code&gt;define:vars&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;最后干脆放弃变量注入，直接让脚本从 DOM 的 &lt;code&gt;data-article&lt;/code&gt; 属性读取参数，彻底绕开 Astro 的模板魔法：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ✅ 最终方案：从 DOM 读取，零依赖
var section = document.querySelector(&apos;section[data-comment-section-id]&apos;);
var articleKey = section.dataset.article;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;照着改了，还是不行。继续往深了挖 —— 日志显示脚本确实跑起来了，卡在了 &lt;code&gt;loadMarkdownIt()&lt;/code&gt; 里面。&lt;/p&gt;
&lt;h3&gt;真凶：CDN 脚本加载竞态&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;comment-utils.js&lt;/code&gt; 里的 &lt;code&gt;loadMarkdownIt()&lt;/code&gt; 通过动态创建 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 标签从 CDN 加载 markdown-it 和 highlight.js：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;U.loadMarkdownIt = () =&amp;gt; {
  if (window.__md) return Promise.resolve(window.__md);
  // 动态创建 script 标签加载 CDN
  // 等两个脚本都 onload 了才 resolve Promise
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;问题来了：&lt;strong&gt;Swup 预加载（prefetch）时已经把 CDN 标签塞进 &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; 了，但 &lt;code&gt;onload&lt;/code&gt; 还没触发&lt;/strong&gt;。直接打开新标签页时，标签已存在，&lt;code&gt;window.__md&lt;/code&gt; 却没定义，&lt;code&gt;Promise&lt;/code&gt; 永远不 resolve，评论区卡死。&lt;/p&gt;
&lt;p&gt;根本原因是这个函数的 else 分支缺失 —— 它只处理了&quot;没有标签就创建&quot;的情况，没有处理&quot;标签已存在但未 onload&quot;的情况。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;graph LR
    A[页面加载] --&amp;gt; B{CDN标签已存在?}
    B --&amp;gt;|Swup导航| C[window.__md已缓存]
    C --&amp;gt; D[✅ 直接返回]
    B --&amp;gt;|直接打开| E[标签存在但未onload]
    E --&amp;gt; F[❌ Promise卡死]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;修复：超时 + fallback&lt;/h3&gt;
&lt;p&gt;给 &lt;code&gt;loadMarkdownIt()&lt;/code&gt; 加了三个容错机制：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;10 秒超时兜底&lt;/strong&gt; —— CDN 加载不上也 resolve，评论正常显示（只是没有语法高亮）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;else 分支补全&lt;/strong&gt; —— 标签已存在则直接检查 &lt;code&gt;window.markdownit&lt;/code&gt; 并 resolve&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;resolved&lt;/code&gt; 防重入&lt;/strong&gt; —— 防止 onload/onerror/超时多次 resolve&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;var timeoutId = setTimeout(function() {
  if (resolved) return;
  resolved = true;
  resolve(null); // 降级，继续加载评论
}, 10000);

// 补全的分支：标签已存在但 __md 未定义
if (window.markdownit) {
  tryInit(); tryInit(); // 两次触发 loaded &amp;gt;= 2
} else {
  resolved = true; resolve(null);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;顺手把 &lt;code&gt;CommentSection&lt;/code&gt; 改为懒加载 —— &lt;code&gt;IntersectionObserver&lt;/code&gt; 在评论区距视口 400px 时才开始请求数据。文章正文渲染完全不受影响了。&lt;/p&gt;
&lt;h2&gt;Lighthouse 来了个下马威&lt;/h2&gt;
&lt;p&gt;评论修好之后跑了一次 Lighthouse 看看整体情况。这一跑不要紧...&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;优化前&lt;/strong&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;Performance&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;34&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;🔴&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FCP&lt;/td&gt;
&lt;td&gt;1,633ms&lt;/td&gt;
&lt;td&gt;🟢&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LCP&lt;/td&gt;
&lt;td&gt;4,043ms&lt;/td&gt;
&lt;td&gt;🔴&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TBT&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,356ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;🔴&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total Size&lt;/td&gt;
&lt;td&gt;4,148KB&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;pre&gt;&lt;code&gt;729ms  ← AliyunCaptcha.js  (验证码 SDK)
354ms  ← Layout 内联脚本 bundle
297ms  ← AliyunCaptcha.js 再次
218ms  ← feilin006 (验证码字体渲染)
217ms  ← feilin006 再次
203ms  ← feilin006 再次
197ms  ← cx.031 (验证码动态脚本)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;阿里云验证码相关长任务合计 1,760ms&lt;/strong&gt;，占整个 TBT 的 130%。&lt;/p&gt;
&lt;h2&gt;逐个击破&lt;/h2&gt;
&lt;h3&gt;1. 图片过大 → sharp 缩放&lt;/h3&gt;
&lt;p&gt;Lighthouse 报告了 &quot;Properly size images&quot; 警告，预计能省 &lt;strong&gt;2,567KB&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;罪魁祸首是文章封面图：原图 5760×3240，实际在卡片上只需要 405×228。Astro 内置了 &lt;code&gt;Image&lt;/code&gt; 组件，但 &lt;code&gt;ImageWrapper&lt;/code&gt; 没传 &lt;code&gt;width&lt;/code&gt;，等于原画质输出。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ❌ 旧：没传宽高，原画质
&amp;lt;Image src={img} /&amp;gt;

// ✅ 新：指定宽度 + 2x 视网膜
&amp;lt;Image src={img} width={1600} /&amp;gt;  // 800px 显示 × 2x 密度
&lt;/code&gt;&lt;/pre&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;1600&lt;/td&gt;
&lt;td&gt;内容区 800px × 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;卡片封面&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;td&gt;侧栏 ~200px × 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;头像&lt;/td&gt;
&lt;td&gt;192&lt;/td&gt;
&lt;td&gt;96px × 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Banner&lt;/td&gt;
&lt;td&gt;2400&lt;/td&gt;
&lt;td&gt;全宽 1200px × 2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;code&gt;astro.config.mjs&lt;/code&gt; 开启 sharp 图片处理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;image: {
  service: { entrypoint: &quot;astro/assets/services/sharp&quot; },
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写了一个 rehype 插件，给 markdown 里的 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 自动加上 &lt;code&gt;loading=&quot;lazy&quot; decoding=&quot;async&quot;&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// rehype-image-lazy.mjs
export default function rehypeImageLazy() {
  return (tree) =&amp;gt; {
    visit(tree, &quot;element&quot;, (node) =&amp;gt; {
      if (node.tagName !== &quot;img&quot;) return;
      node.properties.loading = node.properties.loading || &quot;lazy&quot;;
      node.properties.decoding = node.properties.decoding || &quot;async&quot;;
    });
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 封面图 LCP 优化&lt;/h3&gt;
&lt;p&gt;对于首屏封面图，加上最高优先级：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;ImageWrapper
  loading=&quot;eager&quot;
  decoding=&quot;sync&quot;
  fetchpriority=&quot;high&quot;
  width={1600}
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;浏览器在发现图片 URL 第一时间就全速下载，LCP 从 4,043ms 压到了 1,452ms。&lt;/p&gt;
&lt;h3&gt;3. 验证码 SDK 阻塞主线程 → 懒加载&lt;/h3&gt;
&lt;p&gt;验证码 1.7 秒阻塞是最大痛点。之前 &lt;code&gt;CommentForm.astro&lt;/code&gt; 页面一加载就初始化：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;setTimeout(initCaptcha, 50);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;改成 IntersectionObserver，不滚到评论区就不加载：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;var captchaObserver = new IntersectionObserver(function(entries) {
  if (entries[0].isIntersecting) {
    captchaObserver.disconnect();
    initCaptcha(); // 现在才开始下载 AliyunCaptcha.js
  }
}, { rootMargin: &apos;500px&apos; });
captchaObserver.observe(form);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;4. KaTeX CSS 按需加载&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Layout.astro&lt;/code&gt; 全局引入了 KaTeX CSS（约 60KB），实际上只有少数文章（比如 markdown 语法测试那篇）用到数学公式。把它移到了 &lt;code&gt;Markdown.astro&lt;/code&gt; —— 只有文章页才加载。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Layout.astro  ❌ 移除
// import &quot;katex/dist/katex.css&quot;;

// Markdown.astro  ✅ 仅文章页
import &quot;katex/dist/katex.css&quot;;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;5. 说说页面也修了&lt;/h3&gt;
&lt;p&gt;修复过程中顺手发现说说页（&lt;code&gt;/moments/&lt;/code&gt;）有同样的问题。&lt;code&gt;moments.js&lt;/code&gt; 也是 IIFE 入口就拿了 &lt;code&gt;var U = window.__commentUtils&lt;/code&gt;，而 &lt;code&gt;comment-utils.js&lt;/code&gt; 在 &lt;code&gt;&amp;lt;slot /&amp;gt;&lt;/code&gt; 之后同步加载，但 &lt;code&gt;moments.js&lt;/code&gt; 通过动态插入 script 标签加载，时序不确定。&lt;/p&gt;
&lt;p&gt;最简单的方案是保证 &lt;code&gt;comment-utils.js&lt;/code&gt; 的加载顺序在所有其它脚本之前 —— 保持同步 &lt;code&gt;&amp;lt;script is:inline src=&quot;/comment-utils.js&quot;&amp;gt;&lt;/code&gt; 不变。虽然 Lighthouse 会报一个小警告，但实际只有 5KB，在生产环境 HTTP/2 下影响几乎为零。&lt;/p&gt;
&lt;h3&gt;6. 不删的 CSS&lt;/h3&gt;
&lt;p&gt;还有一部分 178KB &quot;Unused CSS&quot; —— &lt;code&gt;@tailwindcss/typography&lt;/code&gt; 的 prose 变体、expressive-code 代码块样式、photoswipe 灯箱、overlayscrollbars 滚动条。这些都是给特定内容类型准备的（代码块、移动端图片缩放），不能随便删，也没有懒加载 CSS 的简单方案，保留了。&lt;/p&gt;
&lt;h2&gt;HTTP/2 加速：阿里云 ESA&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;我的博客托管在阿里云 ESA（全站加速），开启了 HTTP/2。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;ESA 控制台 → 站点 → 边缘规则 → 添加规则：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;规则名称：启用 HTTP/2
目标类型：所有请求
执行动作：HTTP/2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;HTTP/2 相比 HTTP/1.1 的核心优势：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;多路复用&lt;/strong&gt; —— 一个连接并行传输所有资源，不再需要&quot;CSS Sprites&quot;和域名分片&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;头部压缩 (HPACK)&lt;/strong&gt; —— &lt;code&gt;Cookie&lt;/code&gt; 等头部信息只传差异部分&lt;/li&gt;
&lt;li&gt;对静态资源多的博客来说，HTTP/2 的多路复用优势非常明显&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;静态资源缓存&lt;/h3&gt;
&lt;p&gt;顺手把 &lt;code&gt;_astro/&lt;/code&gt; 目录的缓存拉到 365 天：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;规则：URL 路径 通配符 */_astro/*
执行：缓存 TTL = 31536000s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Astro 构建的静态文件带内容哈希（如 &lt;code&gt;C6BFPrR2_1wNbLv.webp&lt;/code&gt;），内容变了哈希就变，放心缓存。&lt;/p&gt;
&lt;h2&gt;最终效果&lt;/h2&gt;
&lt;p&gt;部署后跑了两轮 Lighthouse：&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;Performance&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;76&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FCP&lt;/td&gt;
&lt;td&gt;1,633ms&lt;/td&gt;
&lt;td&gt;582ms&lt;/td&gt;
&lt;td&gt;2,229ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LCP&lt;/td&gt;
&lt;td&gt;4,043ms&lt;/td&gt;
&lt;td&gt;1,452ms&lt;/td&gt;
&lt;td&gt;5,604ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TBT&lt;/td&gt;
&lt;td&gt;1,356ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total KB&lt;/td&gt;
&lt;td&gt;4,148KB&lt;/td&gt;
&lt;td&gt;797KB&lt;/td&gt;
&lt;td&gt;←&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed Index&lt;/td&gt;
&lt;td&gt;7,720ms&lt;/td&gt;
&lt;td&gt;1,109ms&lt;/td&gt;
&lt;td&gt;2,829ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;TBT 归零是最爽的，验证码懒加载把主线程 1.7 秒的阻塞全消掉了。&lt;/p&gt;
&lt;h2&gt;为什么移动端 76&lt;/h2&gt;
&lt;p&gt;Lighthouse 的移动端模拟用 &lt;strong&gt;4 倍 CPU 降速 + Slow 4G (1.6Mbps)&lt;/strong&gt;，相当于 2015 年的低端安卓。对一篇 20+ 张插图的长文来说，76 分非常健康。同类内容型博客（Medium、Dev.to）一般也就 60-80 分。&lt;/p&gt;
&lt;p&gt;桌面 95 分说明代码本身已经充分优化。&lt;/p&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;首图快才是真的快。压缩图片大小谁都会，关键是调节加载顺序。封面图高优先级全速下载，正文插图懒加载不抢带宽，评论区脚本等人滚到了再初始化。&lt;/p&gt;
&lt;p&gt;一个很反直觉的经验：改 &lt;code&gt;defer&lt;/code&gt; 的确能在 Lighthouse 上好看几分，但在生产环境里这点收益远小于它带来的维护成本。&lt;code&gt;comment-utils.js&lt;/code&gt; 直接同步加载只有 5KB，HTTP/2 多路复用下几乎没有感知延迟，但加上 &lt;code&gt;defer&lt;/code&gt; 后所有引用它的脚本都要改成惰性访问，反而引入了新的边界条件和 bug。性能优化还是要以用户体验为准，不要为了评分而优化。&lt;/p&gt;
&lt;p&gt;以后再写新文章，这些优化会自动生效 —— ImageWrapper 会输出合理尺寸的图片，rehype 插件给插图加 lazy loading，评论区在滚动到附近时才请求。一劳永逸，好耶！&lt;/p&gt;
</content:encoded></item><item><title>普源DHO804软件升级： 谁不想拥有带示波器功能的安卓平板呢？</title><link>https://www.mintlab.top/posts/tries/tries_rigol_dho804/</link><guid isPermaLink="true">https://www.mintlab.top/posts/tries/tries_rigol_dho804/</guid><description>本文讲解了如何通过无线网桥给DHO804连上WIFI， 用adb进行软件升级到924，并且允许恢复到原来的状态，不影响保修，升级后支持250M带宽，自带root权限，可安装桌面和应用，自定义全面屏手势，然后下载云游戏，原神启动（x）</description><pubDate>Mon, 11 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;本文讲解了如何通过无线网桥给DHO804连上WIFI， 用adb进行软件升级到924，并且允许恢复到原来的状态，不影响保修，升级后支持250M带宽，自带root权限，可安装桌面和应用，自定义全面屏手势，然后下载云游戏，原神启动（x）&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;购买&lt;/h2&gt;
&lt;p&gt;DHO804有4个通道，70MHz带宽，1.25G采样率，25M存储深度，PD供电功耗约36W。&lt;/p&gt;
&lt;p&gt;通过咸鱼经销商购买，价格为1800元整，是全新原盒未开封，可以凭序列号官网质保，比旗舰店便宜约400块，而且顺丰包邮，还是非常划算的。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/4fa560013de9e8acef8d5d1cfb05e5e3.HxtvnACX.jpg&quot; alt=&quot;DHO804外包装&quot; title=&quot;漂亮得很那&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/a596eedc30839458ecbed6d9c8542924.DFNCxvD1.jpg&quot; alt=&quot;DHO804所有标配配件&quot; title=&quot;所有的标配配件&quot; /&gt;&lt;/p&gt;
&lt;p&gt;实验室终于有一台带校准证书的仪器了😭&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/e041ff3a115b4bbd3420386aedccd960.B4ODVKIn.jpg&quot; alt=&quot;正弦波测试&quot; title=&quot;她真好看&quot; /&gt;&lt;/p&gt;
&lt;p&gt;这外壳设计我真喜欢，还有ui和万能旋钮，用起来非常舒服，设置界面的一些参数可以通过万能旋钮来调节，真正做到了触摸和实体按键相配合。&lt;/p&gt;
&lt;h2&gt;升级&lt;/h2&gt;
&lt;p&gt;我是通过29块找咸鱼帮我远程升级的，折磨了卖家一下午（&lt;/p&gt;
&lt;p&gt;他还怪好嘞，远程操控完之后，也没删文件，把他的工具和升级固件都留给我了，看一遍就学会了😋&lt;/p&gt;
&lt;p&gt;我会把升级固件和adb软件放到后文中供大家自行下载。&lt;/p&gt;
&lt;h3&gt;准备工作：为示波器联网&lt;/h3&gt;
&lt;p&gt;DHO804背面有一个有线网口，不支持WIFI连接，&lt;strong&gt;如果你有路由器的话，可以通过一条网线直接为它联网，然后跳过这一节&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;下文主要讲解怎么为DHO804连接WIFI，我在学校外面租的房，没有办宽带，只能开个热点联网了。&lt;/p&gt;
&lt;p&gt;首先需要购买一个无线网桥，某宝参考价格180元（我超，贵死了，可以选择不带12v电源适配器的款，这样会便宜一些。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/4da0cbf8ad2ef84eb415b3930c7c5a8d.BDp0VyaN.jpg&quot; alt=&quot;&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/3a70e5fae0f7571884b1fae06045ea42.BHNYfeVm.jpg&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;网桥有一个12v dc供电插口，会接出一根网线和一根USB供电线，直接将网线插入示波器网口，将USB线插入示波器前面板的USB接口就可以供电了。&lt;/p&gt;
&lt;p&gt;参考卖家给的说明书，先连接网桥自己的WIFI，配置好网络信息，保存重启之后就可以自动连接你配置的WIFI，并将其转换为有线网啦。&lt;/p&gt;
&lt;p&gt;在辅助功能中看到CONNECTED字样即为联网成功，记录一下示波器的IP地址，后面adb连接会用到。&lt;/p&gt;
&lt;h3&gt;使用adb远程连接到示波器&lt;/h3&gt;
&lt;blockquote&gt;
&lt;ul&gt;
&lt;li&gt;以下是必要的资源（用于将804升级到924）&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://file.mintlab.top/dho804/adb.tar.gz&quot;&gt;adb调试软件(exe)+924固件&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;首先确保你的电脑和示波器在同一局域网下，可以通过ping验证网络连通性。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ping &amp;lt;示波器的IP地址&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;确认网络连通后，输入以下命令进行连接（注意端口是 &lt;strong&gt;55555&lt;/strong&gt;）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;adb connect &amp;lt;示波器的IP地址&amp;gt;:55555
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后输入 adb devices，看到 device 状态即为成功。&lt;/p&gt;
&lt;h3&gt;备份你自己的固件和校准数据&lt;/h3&gt;
&lt;p&gt;示波器破解前的第一件事，就是备份原始 &lt;code&gt;vendor.bin&lt;/code&gt; 这包含了示波器的固件以及独一无二的校准数据。备份后如果需要售后则可以用备份的文件来还原。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;adb pull /rigol/data/vendor.bin vendor_old.bin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;执行后，vendor.bin文件就会被复制到当前工作目录，命名为&lt;code&gt;vendor_old.bin&lt;/code&gt;，请妥善保管。&lt;/p&gt;
&lt;p&gt;刷入924的&lt;code&gt;vendor.bin&lt;/code&gt;，然后重启示波器，运行一下自校准功能。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;adb push &amp;lt;资源包中vendor.bin文件的路径&amp;gt; /rigol/data/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;检查示波器设置中的版本，显示924即为成功，&lt;strong&gt;如果你只是想升级示波器，到这里就完全结束了&lt;/strong&gt;，还想自定义安装软件的请看下文。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/b52413a74766c16d362184b0203cb159.B5qJEIw6.jpg&quot; alt=&quot;魔改dho804&quot; title=&quot;DHO924(确信&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;安装自定义软件&lt;/h2&gt;
&lt;p&gt;注意到DHO804的安卓版本非常老（Android 7.1 / SDK 25），可能会出现一些应用安装不上的情况，请优先考虑是否是系统版本过低。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;以下是非必要资源（我自己自定义安装的一些软件）&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;火狐浏览器 &lt;a href=&quot;https://archive.mozilla.org/pub/mobile/releases/68.11.0/android-aarch64/multi/fennec-68.11.0.multi.android-aarch64.apk&quot;&gt;Firefox 68 APK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;文件浏览器 &lt;a href=&quot;https://file.mintlab.top/dho804/Solid_Explorer_v2.8.37_b200274-arm6.apk&quot;&gt;Solid_Explorer_v2.8.37_b200274-arm6.apk&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;全面屏手势 &lt;a href=&quot;https://file.mintlab.top/dho804/me.hisn.mygesture_6.41p.apk&quot;&gt;me.hisn.mygesture_6.41p.apk&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;启动器桌面 &lt;a href=&quot;https://file.mintlab.top/dho804/Olauncher-L-v6.3.15.apk&quot;&gt;Olauncher-L-v6.3.15.apk&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;bilibili &lt;a href=&quot;https://file.mintlab.top/dho804/iBiliPlayer-bili.apk&quot;&gt;iBiliPlayer-bili.apk&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;网易云游戏 &lt;a href=&quot;https://file.mintlab.top/dho804/official_com.netease.android.cloudgame-2.8.24-2520-netease_new-1029.apk&quot;&gt;official_com.netease.android.cloudgame-2.8.24-2520-netease_new-1029.apk&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;键盘 &lt;a href=&quot;https://file.mintlab.top/dho804/Simple%20Keyboard_6.3_APKPure.apk&quot;&gt;Simple Keyboard_6.3_APKPure.apk&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/7c4a7fef98ba55ffebead81cbcebf234.BrcsNUXn.jpg&quot; alt=&quot;系统自带文件管理器 w-50%&quot; title=&quot;这示波器有力气&quot; /&gt;&lt;/p&gt;
&lt;p&gt;可以使用以下命令来安装应用&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;adb install &amp;lt;本地apk路径&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;解决键盘问题：安装Simple Keyboard&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;adb install &amp;lt;下载好的Simple Keyboard本地apk路径&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# 设置 Simple Keyboard 为默认输入法
adb shell ime enable rkr.simplekeyboard.inputmethod/.latin.LatinIME
adb shell ime set rkr.simplekeyboard.inputmethod/.latin.LatinIME

# 确保输入法已被启用（应该会返回你刚装的输入法）
adb shell ime list -s
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;解决导航问题：mygesture&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;adb install &amp;lt;下载好的mygesture本地apk路径&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# 通过 ADB 授权无障碍服务
adb shell settings put secure enabled_accessibility_services com.whiz.quickgest/com.whiz.quickgest.GestureService
adb shell settings put secure accessibility_enabled 1

# 启动手势应用
adb shell monkey -p com.omarea.gesture -c android.intent.category.LAUNCHER 1
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;安装桌面启动器：Olauncher-L&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/eccdc626917114beb0aa644da34eaf90.CUckxcSS.jpg&quot; alt=&quot;为dho804安装桌面&quot; title=&quot;可爱捏，可以当桌搭用&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;adb install &amp;lt;下载好的Olauncher-L本地apk路径&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# 安装成功后，先启动一次新桌面
adb shell monkey -p app.olauncher -c android.intent.category.LAUNCHER 1

# 设置为默认桌面
adb shell pm set-home-activity app.olauncher
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后续的bilibili,云游戏可自行安装，直接使用&lt;code&gt;adb install &amp;lt;apk路径&amp;gt;&lt;/code&gt;即可&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/d052e1ef560a026cbb3136f6f32530a2.DfCZa6wN.jpg&quot; alt=&quot;在dho804上启动云游戏&quot; title=&quot;zmd启动！&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;dho804示波器仪器包推荐（&lt;/h2&gt;
&lt;p&gt;我发现随便买的这个收纳包异常的合适，还是硬壳的。某宝参考价90元。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/68b05de2f6bf32c07624056a162ad386.CKXfaX4P.jpg&quot; alt=&quot;dho804自配仪器包1&quot; title=&quot;某宝仪器包&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/7a93e44818813ee6e638caa035628dff.CerL7QUu.jpg&quot; alt=&quot;dho804自配仪器包2&quot; /&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/1d095c234127d96b4404b0a234dde2a9.D3s2uDtg.jpg&quot; alt=&quot;某宝订单&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>2026年4月总结： 忙忙忙忙，参加各种比赛，技术栈增长飞快的一个月</title><link>https://www.mintlab.top/posts/talk/2026-04/</link><guid isPermaLink="true">https://www.mintlab.top/posts/talk/2026-04/</guid><description>几乎无休的一个月，因为打比赛，在一个月内学了前端后端嵌入式还有3d建模，小挑战杯，计算机设计大赛都进省赛了，还购置了新设备，好耶！本篇几乎全是关于计设比赛的碎碎念，不涉及任何技术上的细节，是三月下和整个四月经历的回忆，篇幅较长。</description><pubDate>Thu, 07 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;几乎无休的一个月，因为打比赛，在一个月内学了前端后端嵌入式还有3D建模，非常充实的一个月，小挑战杯，计算机设计大赛都进省赛了，还购置了新设备，好耶！本篇几乎全是关于计设比赛的碎碎念，不涉及任何技术上的细节，是三月下和整个四月经历的回忆，篇幅较长&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;另外博客因为这篇文章更新了图片标题和横向排版的功能，这篇博客让我受益匪浅：&lt;a href=&quot;https://ikamusume7.org/posts/frontend/some_small_code_changes2/&quot;&gt;对Fuwari进行一些小的改动（二）改&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;关于计算机设计大赛的经历&lt;/h2&gt;
&lt;h3&gt;起初：思考比赛的意义&lt;/h3&gt;
&lt;p&gt;一开始是一个同年级的朋友问我有计算机设计大赛要不要参加，我想可算有一个注重技术的比赛了，而且我当时从来没有使用过git的协作功能，就想加入他们队伍协作做一点东西(毕竟才大一，也只学了C语言，我就当放松了，顺带学习github协作。&lt;/p&gt;
&lt;p&gt;我说那随意了，反正大一啥都不会能做出点啥呢，你们想选题吧，谁曾想，选题选了一个毫米波雷达检测老人跌倒的项目, 还说有边缘计算什么东西的，我都惊了。&lt;/p&gt;
&lt;p&gt;真的震撼我一整年，现在的大学生都怎么了？如果这是创业大赛，你吹一吹也就算了，这tm是计算机设计竞赛，简直是ai连接大脑，豆包代替思考，怎么能这么敢想，c语言还没学明白呢，太好高骛远了。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/d6513ec7aa72ebc2a72d44d339c36572.BSFNP6Tl.jpg&quot; alt=&quot;微信截图1&quot; title=&quot;《这些东西只要写在文档和PPT上就行》&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/521ea25f73b99c8a2850929525de54c9.DppgWPGO.jpg&quot; alt=&quot;微信截图2&quot; title=&quot;《技术学都来不及的》&quot; /&gt;&lt;/p&gt;
&lt;p&gt;唉，其实我一直在想，比赛真的是为了得奖吗，难道不是提供了一个大家一起组队协作，锻炼技术与经验的一个平台吗？之前参加了小挑战杯，其实真的有些失望了，创业大赛变成了PPT吹牛大赛。现在计算机设计大赛也是这样吗，ai生成一个高大上的选题和高深的技术组合，网上下载一些视频和开源项目，拼接、编造成自己的成果，那这样的比赛获奖了又有什么意义呢？&lt;/p&gt;
&lt;p&gt;后来我就放弃了和他们组队，没有别的意思，对事不对人，只是大家打比赛的目标不同罢了，我不愿意一个多月只是费劲巴拉的当搬运工，还学不到东西。&lt;/p&gt;
&lt;p&gt;我认为打比赛得什么奖不重要， 重要的是你学会了什么， 最后留下来了什么。借鉴技术不是拿别人的技术拍个演示视频， 技术的应用不会比技术研发简单。&lt;/p&gt;
&lt;h3&gt;机缘巧合：获得一个志同道合的队友&lt;/h3&gt;
&lt;p&gt;后面我就在想，我小挑的技术部分一个人完成压力太大了，既然计设是一个技术性比赛，能不能用小挑的项目去参加计设，拉一些真正搞技术的人来帮我分担压力，也能让多一些人拿奖。&lt;/p&gt;
&lt;p&gt;然后我就在一堂水课上看到坐我斜前方的哥们在拿电脑写代码，我去C++，还会用md看文档，这波会自己学也是领先90%的大学生了，后面我们就认识了，&lt;a href=&quot;https://www.cyanbutterfly.top/&quot;&gt;青蝶半染&lt;/a&gt; -- 哇全能键盘手（双关&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/6de16de17f0492afe7472b90500f3871.D0eJE5on.jpg&quot; alt=&quot;微信截图3&quot; title=&quot;心有灵犀这一块&quot; /&gt;&lt;/p&gt;
&lt;p&gt;我俩的比赛目标非常一致，就是学东西，接下来进度简直飞快，git、前端、Vue框架说学就学。很快我们就进入协作开发环节了--他负责前端，我负责后端和嵌入式，一天十几个commit，每天都pr，太燃了（&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/7c49946470c2593449317e24bc7f3423.AY_0NXcv.png&quot; alt=&quot;github贡献&quot; title=&quot;github刷绿咧&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;嵌入式学习：在未知的探索中收获喜悦&lt;/h3&gt;
&lt;p&gt;我们做的是一个智慧养鸟系统，物联网赛道，需要用到硬件上传图像和传感器数据，并且主动接收一些指令如上粮，开灯等，这套系统是我们完全从零开发的，技术细节可以参考：&lt;a href=&quot;/archive/?category=Lark%E4%BA%91%E9%9B%80%E7%89%A9%E8%81%94%E7%BD%91%E5%85%A8%E6%A0%88&quot;&gt;分类：Lark云雀物联网全栈
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;选型是ESP32，我也借此机会学习ESP32的开发，在这之前都是用arduino库去写，我想试一试ESP-IDF官方的开发库，另外学习FreeRTOS， 提前装好了环境，参考：&lt;a href=&quot;/posts/esp-idf%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E5%8F%8Aarduino%E7%A7%BB%E6%A4%8D/&quot;&gt;vscode esp-idf开发环境安装及arduino移植&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/ff205acef2f0d39f248e17f36465e598.d9E8d-yb.jpg&quot; alt=&quot;ESP32视频传输测试&quot; title=&quot;视频传输测试成功&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/4f60b27e571a057bc9e4982a1493b588.CzKeErbA.jpg&quot; alt=&quot;实时传输画面&quot; title=&quot;实时传输画面&quot; /&gt;&lt;/p&gt;
&lt;p&gt;当时离提交校赛只有不到三周了，状态还只是刚买好模块装好环境，基本是上课查文档，下课搞开发，晚上去想明天的方向和计划，有过焦虑和迷茫，每天都是走一步看一步，一个一个功能的学。熬到凌晨三四点，大半夜的看着自己实现的功能傻笑。&lt;/p&gt;
&lt;p&gt;就这样，几乎每天都有新进展，从移植摄像头驱动，设计网络请求回调函数，设计ws传输和命令，到分片接收响应，这些全是我自己亲手写出来的，零ai coding，我只在晚上和ai讨论各种实现的可行性。&lt;/p&gt;
&lt;h3&gt;信心打击：实地考察征求鸟厂意见&lt;/h3&gt;
&lt;p&gt;在交稿的前两天，我们紧赶慢赶才完成了系统的功能设计，尤其是必要的远程日志ota功能。说是设备，其实目前只有摄像头和温湿度传感器这两个，去实地部署测试了，去的原因一是我们参加的是物联网创业赛道，需要切实可行的创业相关调研；二是我也想问问能不能真的帮上什么忙。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/206d95545cd8df84396dbee6c80f8e8d.BboWhMX7.jpg&quot; alt=&quot;鸟厂调研实拍图&quot; title=&quot;鸟厂调研实拍&quot; /&gt;&lt;/p&gt;
&lt;p&gt;老板其实是非常不看好的，指着挂在墙上的4G摄像头说，这东西才两百块，还不用联网，你们的能做什么？就是摄像功能也没有它画质好。我为了这次实地部署能够顺利，考虑了很多种情况，鸟厂有可能没有电源，没有WIFI，于是我带上了自己的插排和路由器(移动热点)，还好准备做得足，最后硬说想要试试才放上。&lt;/p&gt;
&lt;p&gt;唉，是啊，我也在想到底怎么才能帮上忙，但是在半个月内这是我能做出最最好的状态了，技术方面几乎是都是最好的架构。&lt;/p&gt;
&lt;p&gt;我回家看着床头缺少的插板，电脑想要传文件发现连路由器都没了😭。我一直在想是不是一开始就做的太仓促了，没有调研好要做什么，但是所谓的自动喂水喂食，现成的物理结构完全就能实现，自动上粮和铲💩我们可能还设计不来那种机械结构&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/20e6c56aa33b024e701aa74287cd1130.D5Cn9Hqg.jpg&quot; alt=&quot;自动上粮和铲屎结构想象图&quot; title=&quot;自动上粮和铲屎结构畅想&quot; /&gt;&lt;/p&gt;
&lt;p&gt;那天晚上也和朋友聊了很多，网抑云到好晚，你说这比赛打得没意义叭，对我个人来说学了超级超级多，从数据库到嵌入式，还发了四篇博客，有意义叭，我又觉得没帮上实际的忙。&lt;/p&gt;
&lt;p&gt;最后我们决定先通过校赛，从&quot;对现实有用&quot;为基础进行设计，从老板提出的缺少批量上粮的系统开始设计，因为学校不给报销，我们打算如果可能的话，在省赛时做出自动上粮的3D结构模型。&lt;/p&gt;
&lt;h3&gt;藏龙卧虎！稳稳拿下校赛答辩&lt;/h3&gt;
&lt;p&gt;接下来就是校赛答辩了，因为过于顺利，没拍什么照片，看了现场很多都是大二大三的学长学姐，交流一下发现我们的项目技术还是非常有竞争力的。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/4156046c5f1a0f2b504656cb04a71d00.CCwmqyAz.jpg&quot; alt=&quot;答辩现场实拍&quot; title=&quot;偷看其他队伍答辩&quot; /&gt;&lt;/p&gt;
&lt;p&gt;计设校赛老师都非常善，还是相当尊重技术的，说是一个队8分钟路演时间，实际上没有计时器，你不管讲多久都不会打断。而且不要求脱稿，ppt直接拿电脑上去念。&lt;/p&gt;
&lt;p&gt;我们演示系统的时候老师都直接围上来看，说上一个做物联网的接的是华为云平台，你们这是全链路自己搓的啊，我看这大一的也是藏龙卧虎啊。&lt;/p&gt;
&lt;p&gt;打的是物联网赛道，团队没一个是物联网专业的，全靠自学和热爱走到现在（哭。后面答辩环节老师也是什么都没问，基本都是在指导我们省赛怎么打，该怎么扩展交互和传感器。最后也是稳稳进入省赛。&lt;/p&gt;
&lt;h3&gt;柳暗花明：挣扎着学习3D建模&lt;/h3&gt;
&lt;p&gt;为了扩展更多功能，我们设计了喂食称重功能和批量喂食的螺旋管道，这些结构部分找不到完全合适的，只得自己建模打印。还好找好哥们借了一台A1 mini，开始学习建模。&lt;/p&gt;
&lt;p&gt;从SU开始折腾，破面一直补不上，有时候真的想过自己是否真的不适合学习，仅仅是一个简单的三通管就折腾到三五点后半夜。
&amp;lt;!--
&lt;img src=&quot;https://www.mintlab.top/_astro/ca01ef4926436b0ec452d70ff711a897.Drtnknli.jpg&quot; alt=&quot;和大佬的聊天1&quot; title=&quot;重画吧（无慈悲&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/5f6b3e38f3155cf60c734b6780063e72.GG61l3oJ.jpg&quot; alt=&quot;和大佬的聊天2&quot; /&gt; --&amp;gt;&lt;/p&gt;
&lt;p&gt;后来换到了Rhino，从管道，曲面，螺纹一点点建模，又熬了三天把上粮结构设计出来了，也算3D建模入门了叭，我菜死了。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/9345ee8bd8bbe9e5b55e3838c9253ebe.DUYekqIN.jpg&quot; alt=&quot;螺旋上粮减速电机结构实拍图&quot; title=&quot;螺旋上粮减速电机结构&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/947f7c4eb3d4245191b2ca54a5a35f9e.CpAcXjWs.jpg&quot; alt=&quot;螺旋上粮减速电机结构建模图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;中途因为鸟粮的谷子在塑料管里转的摩擦是在实在是太大了，输送距离越长越明显，我就看着电机功率一点一点往上涨，到出料口电机直接不动了，把管子剪短了也没用，最后换了一个3v20转的电机，调到12v跑，强而有力，甚至能把卡住的谷子磨碎。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/2063e7398e7c406c3a604221db7ce11e.Dlazq4I3.jpg&quot; alt=&quot;上粮结构整体实拍&quot; title=&quot;上粮结构整体实拍&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/630b3671c052d0f851d33379d5bedb5e.CJrK0dfH.jpg&quot; alt=&quot;自动喂食器建模图&quot; title=&quot;自动喂食器建模&quot; /&gt;&lt;/p&gt;
&lt;p&gt;省赛这次我们新加了一个ESP32C3芯片用来外挂新加的传感器，包括光强，紫外线，重量，TVOC，CO2，颗粒物和电压电流这些，带上买的电机和各种耗材，测试的时候还烧了俩ESP32，马上快没钱打比赛了(哭了&lt;/p&gt;
&lt;h3&gt;突发意外：为准备演示视频组装成品&lt;/h3&gt;
&lt;p&gt;队友非常靠谱，我俩作息异常的一致，凌晨还在对接传输格式，省赛改进的部分我把前端和后端开发都交给他了，我全部时间都在研究结构和嵌入式，，软硬件都准备完毕，可以拼在一起拍演示视频了。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/9790bfc76ef46fd3d412ca1d292d8e42.wb3KASYG.jpg&quot; alt=&quot;云雀前端系统图1&quot; title=&quot;总览页面&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/11592c5f38c3eb94bd69004e2d8cf3d6.Dw8HlULk.jpg&quot; alt=&quot;云雀前端系统图2&quot; title=&quot;空气质量统计页面&quot; /&gt;&lt;/p&gt;
&lt;p&gt;推算工期没有想到这两个主控，八九个传感器，还有电机，灯和风扇的接线难度，太复杂了。当时软硬件测试完成只剩不到24个小时就要提交到省赛了，我准备用这一坨先简单拍一个演示视频兜底，然后赶快理线，通宵给设备建模个壳子。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/8263fa12eeca37f6fb3ceeef8aabc85b.BFwfmuN4.jpg&quot; alt=&quot;一堆模块实拍图&quot; title=&quot;搞半天还要自己拼&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/268105569f180ec953b32fdf4ca1b68c.Bhu1uodX.jpg&quot; alt=&quot;烙铁炸了&quot; title=&quot;炸飞老铁&quot; /&gt;&lt;/p&gt;
&lt;p&gt;没想到遇到不可抗力了，电烙铁突然在手里爆炸了。拆开看了看，也没有发现什么短路问题，这个黄花电烙铁大概陪了我6年，购买记录都找不到了，比我的电脑还要老。中间烙铁头也换过不下六七个了，大到铜接地带，小到0402贴片都焊过，还压过热熔螺母，切过塑料盒，以及烫过我自己（x），真的算是陪我走过了整个电子入门阶段，离别的好突然😭&lt;/p&gt;
&lt;p&gt;​我知道我该换电烙铁了，一直没有契机，主要是觉得现在这个也够用，但也没必要这样搞我叭（&lt;/p&gt;
&lt;p&gt;唉，还是命重要，提前拍的演示视频也可以凑合，是该休息一下了，等新的烙铁到了再做，到答辩现场再展示完整的也不急。&lt;/p&gt;
&lt;h2&gt;小结：虽然累，但是值得&lt;/h2&gt;
&lt;p&gt;这段难得的经历真的让我学会了很多，一次次地面对未知与挫折，关关难过关关过，问题解决之后有那种打游戏难有的开心。有过熬到五点的日子，也有一觉睡到下午五点补觉的日子。还有和我一起打比赛的队友，尤其是学姐们，帮我分担了好多文档类的工作，感谢你们一路相伴。以后我会试着将我的经历更多地分享出来。&lt;/p&gt;
&lt;h2&gt;后记：计设省赛的最终结果&lt;/h2&gt;
&lt;p&gt;5月17号等我好消息喵，无论结果如何，这些难忘的经历，已经是我最好的收获了。&lt;/p&gt;
</content:encoded></item><item><title>博客后端并发优化实录：从 SQLite 48 req/s 到 PostgreSQL 323 req/s</title><link>https://www.mintlab.top/posts/tries/backend-optimization/</link><guid isPermaLink="true">https://www.mintlab.top/posts/tries/backend-optimization/</guid><description>狛荷屋主站后端基于 FastAPI + SQLAlchemy 构建，之前的项目使用 SQLite 作为数据库、单 Worker 运行。主站评论功能引入递归 CTE 查询，考虑到后期的可扩展性, 构建时选用了PostgreSQL。本文记录了从 SQLite 迁移到 PostgreSQL，并发优化的完整过程。</description><pubDate>Sun, 19 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;狛荷屋主站后端基于 FastAPI + SQLAlchemy 构建，之前的项目使用 SQLite 作为数据库、单 Worker 运行。主站评论功能引入递归 CTE 查询，考虑到后期的可扩展性, 构建时选用了PostgreSQL。本文记录了从 SQLite 迁移到 PostgreSQL，并发优化的完整过程。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;起点：SQLite + 单 Worker&lt;/h2&gt;
&lt;p&gt;最初的架构非常简单：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;数据库&lt;/strong&gt;：SQLite（文件型数据库）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;运行方式&lt;/strong&gt;：&lt;code&gt;uvicorn main:app&lt;/code&gt;，单 Worker 单进程&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;压测结果&lt;/strong&gt;：&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;wrk -t2 -c32 -d10s
Requests/sec: 48
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SQLite 在并发写入时有全局锁，且不支持异步连接池，成为最大瓶颈。&lt;/p&gt;
&lt;h2&gt;第一步：迁移到 PostgreSQL&lt;/h2&gt;
&lt;p&gt;将数据库从 SQLite 替换为 PostgreSQL，使用 &lt;code&gt;asyncpg&lt;/code&gt; 驱动 + SQLAlchemy 异步引擎：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;engine = create_async_engine(
    &quot;postgresql+psycopg://user:pass@localhost:5432/blog&quot;,
    pool_size=10,
    max_overflow=20,
    pool_timeout=30,
    pool_pre_ping=True,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;迁移后，单 Worker 压测：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wrk -t2 -c32 -d10s
Requests/sec: ~255
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;提升约 5.3 倍&lt;/strong&gt;。PostgreSQL 原生支持并发连接和异步查询，递归 CTE 的执行效率也远优于 SQLite。&lt;/p&gt;
&lt;h2&gt;第二步：部署 Gunicorn 多 Worker&lt;/h2&gt;
&lt;p&gt;生产环境使用 Gunicorn 启动 4 个 Uvicorn Worker：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;4 个 Worker = 4 个独立 Python 进程，每个进程有自己的事件循环，可以充分利用多核 CPU。&lt;/p&gt;
&lt;h2&gt;第三步：发现高并发瓶颈&lt;/h2&gt;
&lt;p&gt;部署后用 wrk 进行压力测试，发现问题：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;wrk -t4 -c2048 -d10s
  Latency     1.49s   302.09ms   2.00s
  Socket errors: timeout 481
Requests/sec: 285.63
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;低并发时响应约 10ms，高并发时飙升到 200ms+，还出现大量超时。于是开始排查。&lt;/p&gt;
&lt;h2&gt;瓶颈分析&lt;/h2&gt;
&lt;h3&gt;瓶颈一：日志线程池串行化（最致命）&lt;/h3&gt;
&lt;p&gt;原始的日志函数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;log_executor = ThreadPoolExecutor(max_workers=1)  # 只有 1 个线程！

async def async_log(logger_obj, level, message):
    def _log():
        getattr(logger_obj, level)(message)
    loop = asyncio.get_event_loop()
    await loop.run_in_executor(log_executor, _log)  # await = 等日志写完
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每个请求在中间件中调用 &lt;strong&gt;2 次&lt;/strong&gt; &lt;code&gt;await async_log&lt;/code&gt;。&lt;code&gt;await&lt;/code&gt; 意味着协程会挂起并排队等待日志写完。线程池只有 1 个线程时，2048 个请求的日志调用排成长队，所有协程都卡在 &lt;code&gt;await&lt;/code&gt; 上。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这是延迟飙升的最主要原因。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;瓶颈二：数据库连接池不足&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;pool_size=10, max_overflow=20  # 最多 30 个并发 DB 连接
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;当 2048 个请求同时到达，大量请求在等待连接池释放，&lt;code&gt;pool_timeout=30&lt;/code&gt; 导致超时前长时间挂起。&lt;/p&gt;
&lt;h3&gt;瓶颈三：请求日志中间件开销&lt;/h3&gt;
&lt;p&gt;中间件对&lt;strong&gt;每个请求&lt;/strong&gt;都执行：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;读取并解析请求 body&lt;/li&gt;
&lt;li&gt;序列化完整 headers + body 为 JSON&lt;/li&gt;
&lt;li&gt;2 次阻塞式日志写入&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;高并发下叠加效果显著。&lt;/p&gt;
&lt;h2&gt;优化措施&lt;/h2&gt;
&lt;h3&gt;优化一：日志改为 Fire-and-Forget&lt;/h3&gt;
&lt;p&gt;核心思路：日志提交到线程池后&lt;strong&gt;不等待完成&lt;/strong&gt;，请求立即继续处理。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;log_executor = ThreadPoolExecutor(max_workers=4)  # 1 → 4

def async_log(logger_obj, level, message):
    def _log():
        getattr(logger_obj, level)(message)
    try:
        loop = asyncio.get_running_loop()
        loop.run_in_executor(log_executor, _log)  # 不 await！
    except RuntimeError:
        _log()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键区别：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;之前&lt;/strong&gt;：&lt;code&gt;await run_in_executor&lt;/code&gt; → 协程挂起等日志写完 → 高并发下排队&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;之后&lt;/strong&gt;：&lt;code&gt;run_in_executor&lt;/code&gt; 不 &lt;code&gt;await&lt;/code&gt; → 日志任务扔进线程池，请求立刻返回&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;优化二：增大数据库连接池&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;engine = create_async_engine(
    DATABASE_URL,
    pool_size=20,       # 10 → 20
    max_overflow=40,    # 20 → 40
    pool_timeout=10,    # 30 → 10，避免长时间挂起
    pool_pre_ping=True,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;4 个 Worker 进程各自有连接池，总计可用 $4 \times (20 + 40) = 240$ 个数据库连接。&lt;/p&gt;
&lt;h3&gt;优化三：中间件日志不阻塞&lt;/h3&gt;
&lt;p&gt;所有 &lt;code&gt;await async_log(...)&lt;/code&gt; 改为 &lt;code&gt;async_log(...)&lt;/code&gt;，中间件中的日志写入不再阻塞请求处理。&lt;/p&gt;
&lt;h2&gt;优化结果&lt;/h2&gt;
&lt;h3&gt;本地测试（单 Worker）&lt;/h3&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;16&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;62ms&lt;/td&gt;
&lt;td&gt;255 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;125ms&lt;/td&gt;
&lt;td&gt;255 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;406ms&lt;/td&gt;
&lt;td&gt;250 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;吞吐量稳定在 ~255 req/s，延迟随并发线性增长符合 Little&apos;s Law：&lt;/p&gt;
&lt;p&gt;$$Latency = \frac{Concurrency}{Throughput}$$&lt;/p&gt;
&lt;h3&gt;生产环境（4 Workers）&lt;/h3&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;16&lt;/td&gt;
&lt;td&gt;96ms&lt;/td&gt;
&lt;td&gt;159 req/s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;103ms&lt;/td&gt;
&lt;td&gt;296 req/s&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;296ms&lt;/td&gt;
&lt;td&gt;323 req/s&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;804ms&lt;/td&gt;
&lt;td&gt;278 req/s&lt;/td&gt;
&lt;td&gt;84&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;其中生产环境的延迟包含约 34ms 的网络 RTT（客户端 → CDN → 服务器），实际服务器处理时间约 $96 - 34 \approx 62ms$。&lt;/p&gt;
&lt;h3&gt;性能提升总结&lt;/h3&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;延迟（32并发）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQLite + 单 Worker&lt;/td&gt;
&lt;td&gt;48 req/s&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL + 单 Worker&lt;/td&gt;
&lt;td&gt;255 req/s&lt;/td&gt;
&lt;td&gt;125ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PostgreSQL + 4 Workers + 优化&lt;/td&gt;
&lt;td&gt;323 req/s&lt;/td&gt;
&lt;td&gt;103ms（含网络）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;SQLite → PostgreSQL + 全部优化后，吞吐量提升约 6.7 倍。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;为什么吞吐没有 4x？&lt;/h2&gt;
&lt;p&gt;4 个 Worker 理论上应该有 4 倍吞吐，但实际只提升了约 27%。原因是：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;瓶颈已经从 Python 转移到了 PostgreSQL。&lt;/strong&gt; 4 个 Worker 共享同一个数据库实例，评论查询使用递归 CTE，数据库 CPU 成为新的天花板。进一步优化需要在 DB 层下功夫（缓存、物化视图等）。&lt;/p&gt;
&lt;h2&gt;关于合理的压测方式&lt;/h2&gt;
&lt;p&gt;在排查过程中还踩了一个坑：一开始用 2048 并发压测单 Worker，看到大量超时就以为有问题——其实这完全在预期范围内。&lt;/p&gt;
&lt;p&gt;合理的压测并发量应匹配 Worker 数量：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Worker 数&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;1&lt;/td&gt;
&lt;td&gt;16-32&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;32-100&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;超过处理能力的并发只会产生排队，不会暴露真实性能问题。&lt;/p&gt;
&lt;h2&gt;总结&lt;/h2&gt;
&lt;p&gt;这次优化最大的收获是：&lt;strong&gt;性能瓶颈往往不在你以为的地方。&lt;/strong&gt; 直觉上会以为&quot;数据库慢&quot;、&quot;Python 慢&quot;，但实际上最致命的瓶颈是一个 &lt;code&gt;max_workers=1&lt;/code&gt; 的日志线程池配合 &lt;code&gt;await&lt;/code&gt;——一行 &lt;code&gt;await&lt;/code&gt; 就让所有高并发请求排成了单行队列。&lt;/p&gt;
&lt;p&gt;对于博客这种读多写少的场景，当前性能已经完全足够。如果后续需要进一步提升，方向是在 DB 层做缓存（Redis 或内存 TTL 缓存），可以将评论查询延迟从 ~60ms 降到 &amp;lt;5ms。&lt;/p&gt;
</content:encoded></item><item><title>重新学习SQL: 完成博客系统的 SQL递归CTE 评论查询设计</title><link>https://www.mintlab.top/posts/learn/sql-cte/</link><guid isPermaLink="true">https://www.mintlab.top/posts/learn/sql-cte/</guid><description>一直想给自己的博客添加一个评论功能, 真正写起来才发现评论树的层级嵌套查询并不好做, 在业务层做需要发起多次数据库查询, 在SQL中使用递归CTE可以很好的解决这一点, 本文主要注重于SQL语句的设计, 真得学了叭</description><pubDate>Wed, 15 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;一直想给自己的博客添加一个评论功能, 真正写起来才发现评论树的层级嵌套查询并不好做, 在业务层做需要发起多次数据库查询, 而在SQL中使用递归CTE可以很好的解决这一点, 本文主要注重于SQL语句的设计, 真得学了叭&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;参考&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Bilibili视频: &lt;a href=&quot;https://www.bilibili.com/video/BV18T411m7DX/&quot;&gt;SQL语言之 CTE(Common Table Expression)公用表达式&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;实际需求&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/9a9fb392bd99012110768b4b2d22f88f.BM2jEoiH.png&quot; alt=&quot;实际评论图&quot; /&gt;&lt;/p&gt;
&lt;p&gt;实际评论是可以回复盖楼的, 这就意味着在评论内还有评论, 要实现一个评论树的结构&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用户A: 文章不错! (#1)
├─ 贴主: 谢谢(#2)
│   ├─ 用户A: 不客气（#3）
│   │   └─ 用户B: 膜（#4）
│   └─ 用户C: 这文章怎么写的? (#5)
└─ 用户C: 你写得也不差(#6)
用户D: 膜膜(#7)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;期望返回结构&lt;/h3&gt;
&lt;p&gt;前端构建需要根据评论的深度层级(&lt;code&gt;depth&lt;/code&gt;)来调整缩进, 最好有父级id(&lt;code&gt;parent_id&lt;/code&gt;)让我知道是回复了谁, 通过评论路径(&lt;code&gt;path&lt;/code&gt;)我就可以摘出整个评论回复链&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;parent_id&lt;/th&gt;
&lt;th&gt;content&lt;/th&gt;
&lt;th&gt;depth&lt;/th&gt;
&lt;th&gt;path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;文章不错!&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;贴主&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;谢谢&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1 -&amp;gt; 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;不客气&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1 -&amp;gt; 2 -&amp;gt; 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;膜&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;1 -&amp;gt; 2 -&amp;gt; 3 -&amp;gt; 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;这文章怎么写的?&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1 -&amp;gt; 2 -&amp;gt; 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;你写得也不差&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1 -&amp;gt; 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;膜膜&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;CTE入门&lt;/h2&gt;
&lt;h3&gt;为什么要使用CTE?&lt;/h3&gt;
&lt;p&gt;来看下面这个例子, 查询所有换过部门的员工, 思路是在 &lt;code&gt;job_history&lt;/code&gt; 部门更换记录表中获取员工编号, 然后通过编号筛选换过部门的员工。此处的&lt;code&gt;(select distinct empno from job_history)&lt;/code&gt;就是一个子表达式, 如果这个子查询再复杂些, 或者需要在别的地方复用, 则需要再写一遍, 大大提高了代码的复杂度&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;select * from employees where empno in (select distinct empno from job_history);

&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;什么是CTE?&lt;/h3&gt;
&lt;p&gt;CTE全称&lt;strong&gt;Common Table Expression&lt;/strong&gt;, 是一个命名的临时结果集,它存在于单个语句的范围内,并且可以在该语句中多次引用，而且CTE还可以相互引用。&lt;/p&gt;
&lt;p&gt;并且CTE是在所有主流数据库中都通用的表达式(很多数据库都拥有自己的方言, 那这个是不是可以算作数据库中的普通话x)&lt;/p&gt;
&lt;p&gt;CTE的基本语法如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WITH cte_name (column list) AS query

SELECT * FROM cte_name;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中&lt;code&gt;cte_name&lt;/code&gt;是cte临时表名, &lt;code&gt;(column list)&lt;/code&gt;是cte表中的列名, &lt;code&gt;query&lt;/code&gt;部分应该用&lt;code&gt;( )&lt;/code&gt;包含整个查询语句,&lt;/p&gt;
&lt;p&gt;让我们改造一下上文的SQL语句, 查询所有换过部门的员工, 使用CTE实现:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WITH emp_with_history AS (
    SELECT DISTINCT empno
    FROM job_history
)
SELECT *
FROM employees
WHERE empno IN (SELECT empno FROM emp_with_history);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你会发现原来的子查询被前置了, 先查询作为临时结果集, 后面的查询再从结果集中二次筛选. 虽然看起来比原式稍复杂, 但是有更好的扩展性, 你可以进行多个子查询, 全部都存成CTE, 然后在后文的查询中自由组合使用, 可读性更好.&lt;/p&gt;
&lt;h3&gt;CTE递归初识&lt;/h3&gt;
&lt;p&gt;CTE像是一个函数, 所得到的结果可以反复复用, 甚至可以自己使用自己, 即为&quot;函数递归&quot;, CTE中的递归需要将&lt;code&gt;WITH&lt;/code&gt;变成&lt;code&gt;WITH RECURSIVE&lt;/code&gt;, 来看一个例子&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WITH RECURSIVE cte_table(n) AS (
    SELECT 1

    UNION ALL

    SELECT n+1 FROM cte_table WHERE n &amp;lt; 10
)

SELECT * FROM cte_table;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出是一个包含n列的表，有10行，值1到10&lt;/p&gt;
&lt;p&gt;整个语句用&lt;code&gt;UNION ALL&lt;/code&gt;分成两部分, 上半部分只执行一次, 我们暂且称它为锚点查询, 下半部分会对每个锚点数据反复执行, 直到&lt;code&gt;WHERE&lt;/code&gt;中的条件不再符合, 最后将每个子查询和对应原始数据拼接在一起形成最终的结果表.&lt;/p&gt;
&lt;p&gt;回到例子中, 上半部分&lt;code&gt;SELECT 1&lt;/code&gt;直接与&lt;code&gt;cte_table(n)&lt;/code&gt;中的n列对应, 代表直接将一个&lt;code&gt;1&lt;/code&gt;放入&lt;code&gt;n列&lt;/code&gt;, 下半部分对锚点数据1执行, 将&lt;code&gt;n列的数据+1&lt;/code&gt;, 并和锚点数据1拼接在一起, 直到&lt;code&gt;n列不小于10&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;| n   |
| --- |
| 1   |
| 2   |
| 3   |
| 4   |
| 5   |
| 6   |
| 7   |
| 8   |
| 9   |
| 10  |
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;更进一步? 递归CTE生成斐波那契数&lt;/h3&gt;
&lt;p&gt;试一试生成斐波那契数的前10项&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WITH RECURSIVE fib(n, fib_n, next_fib) AS (
    -- 初始值：第1项为0，第2项为1
    SELECT 1, 0, 1
    UNION ALL
    -- 递归：下一项 = 当前项 + 前一项
    SELECT n + 1, next_fib, fib_n + next_fib
    FROM fib
    WHERE n &amp;lt; 10   -- 生成前10项
)
SELECT n   AS 序号,
       fib_n AS 斐波那契数
FROM fib;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;| 序号 | 斐波那契数 |
| ---- | ---------- |
| 1    | 0          |
| 2    | 1          |
| 3    | 1          |
| 4    | 2          |
| 5    | 3          |
| 6    | 5          |
| 7    | 8          |
| 8    | 13         |
| 9    | 21         |
| 10   | 34         |
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;好了, 你已经掌握了CTE的精髓--递归&lt;/p&gt;
&lt;h2&gt;简化版: 测试核心功能&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;parent_id&lt;/th&gt;
&lt;th&gt;content&lt;/th&gt;
&lt;th&gt;time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;123&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;文章不错!&lt;/td&gt;
&lt;td&gt;2026-04-15T08:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;121&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;谢谢&lt;/td&gt;
&lt;td&gt;2026-04-15T08:12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;在数据库中, 评论数据是按照上述格式存储的&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;WITH RECURSIVE comment_tree(com_id, user_id, content, depth, path) AS (
    SELECT c.id, c.user_id, c.content, 1, c.id from comments AS c
    WHERE c.parent_id IS NULL

    UNION ALL
    SELECT c.id, c.user_id, c.content, ct.depth+1, concat(ct.path, &apos; -&amp;gt; &apos;, c.id)
    FROM comments AS c
    JOIN comment_tree AS ct ON ct.com_id = c.parent_id
)
SELECT * FROM comment_tree
ORDER BY path;

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;该sql返回的内容和上文期望结果相同&lt;/p&gt;
&lt;p&gt;在sql中, 使用&lt;code&gt;from comments AS c&lt;/code&gt;给&lt;code&gt;comments&lt;/code&gt;表起了一个别名&lt;code&gt;c&lt;/code&gt;, 接下来需要访问cte表和评论表, 就用&lt;code&gt;表名.列名&lt;/code&gt;来区分, 每次计算子节点时都&lt;code&gt;ct.depth+1&lt;/code&gt;, 通过&lt;code&gt;concat(ct.path, &apos; -&amp;gt; &apos;, c.id)&lt;/code&gt;拼接出类似于&lt;code&gt;1 -&amp;gt; 2 -&amp;gt; 3&lt;/code&gt;的回复链, 后续通过&lt;code&gt;ORDER BY path;&lt;/code&gt;自动实现按照评论回复顺序排序&lt;/p&gt;
&lt;p&gt;下半部分的&lt;code&gt;JOIN comment_tree AS ct ON ct.com_id = c.parent_id&lt;/code&gt;其实就是连接条件, 等价于&lt;code&gt;FROM comments c, comment_tree ct WHERE ct.com_id = c.parent_id&lt;/code&gt;, 但是JOIN更加可读&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- 使用 JOIN
SELECT ...
FROM comments c
JOIN comment_tree ct ON ct.com_id = c.parent_id

-- 使用 WHERE
SELECT ...
FROM comments c, comment_tree ct
WHERE ct.com_id = c.parent_id
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;实际生产环境设计&lt;/h2&gt;
&lt;p&gt;在实际博客系统中, 不只是获取用户id, 还需要根据user_id, 在users表中查询对应的用户信息并加入评论返回中, 还需要支持置顶评论, 根评论按最新排序, 子评论按时间先后排序, 评论分页功能&lt;/p&gt;
&lt;p&gt;并且简化版的sql中还存在一个漏洞, 直接使用path字符串进行排序, 当评论id达到两位数时, 按照符逐个比较排序会发生10排在2前面, 因为先比较的是&lt;code&gt;&quot;10&quot;中的第一个字符&quot;1&quot;&lt;/code&gt;与&lt;code&gt;&quot;2&quot;&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;数据库表示例&lt;/h3&gt;
&lt;h4&gt;comments 表&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;parent_id&lt;/th&gt;
&lt;th&gt;root_id&lt;/th&gt;
&lt;th&gt;article&lt;/th&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;content&lt;/th&gt;
&lt;th&gt;time&lt;/th&gt;
&lt;th&gt;pinned&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;Root A (pinned)&lt;/td&gt;
&lt;td&gt;2024-01-01 10:00:00&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;Child A1&lt;/td&gt;
&lt;td&gt;2024-01-01 10:05:00&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;td&gt;Child A2&lt;/td&gt;
&lt;td&gt;2024-01-01 10:10:00&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;Root B&lt;/td&gt;
&lt;td&gt;2024-01-02 09:00:00&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;Child B1&lt;/td&gt;
&lt;td&gt;2024-01-02 09:15:00&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;Grandchild A1-1&lt;/td&gt;
&lt;td&gt;2024-01-01 10:20:00&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;users 表&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;username&lt;/th&gt;
&lt;th&gt;avatar&lt;/th&gt;
&lt;th&gt;home_page&lt;/th&gt;
&lt;th&gt;role&lt;/th&gt;
&lt;th&gt;email&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;alice&lt;/td&gt;
&lt;td&gt;a.jpg&lt;/td&gt;
&lt;td&gt;alice.com&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;a@x.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;bob&lt;/td&gt;
&lt;td&gt;b.jpg&lt;/td&gt;
&lt;td&gt;bob.com&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;b@x.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;td&gt;charlie&lt;/td&gt;
&lt;td&gt;c.jpg&lt;/td&gt;
&lt;td&gt;charlie.com&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;c@x.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;dave&lt;/td&gt;
&lt;td&gt;d.jpg&lt;/td&gt;
&lt;td&gt;dave.com&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;d@x.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;eve&lt;/td&gt;
&lt;td&gt;e.jpg&lt;/td&gt;
&lt;td&gt;eve.com&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;e@x.com&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;查询SQL&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;WITH RECURSIVE comment_tree AS (
  SELECT id, id AS com_id, article, user_id, content, browser, os, parent_id, root_id, time, pinned,
         1 AS depth,
         lpad(id::text, 10, &apos;0&apos;) AS path,
         time AS root_time
  FROM comments
  WHERE {root_filter}

  UNION ALL

  SELECT c.id, c.id AS com_id, c.article, c.user_id, c.content, c.browser, c.os,
         c.parent_id, c.root_id, c.time, c.pinned,
         ct.depth + 1 AS depth,
         concat(ct.path, &apos; -&amp;gt; &apos;, lpad(c.id::text, 10, &apos;0&apos;)) AS path,
         ct.root_time
  FROM comments AS c
  JOIN comment_tree AS ct ON ct.com_id = c.parent_id
  WHERE c.article = :article
)
SELECT ct.id, ct.article, ct.user_id, ct.content, ct.browser, ct.os,
       ct.parent_id, ct.root_id, ct.time, ct.depth, ct.path, ct.pinned,
       u.username, u.avater, u.home_page, u.role, u.email
FROM comment_tree ct
LEFT JOIN users u ON ct.user_id = u.id
ORDER BY ct.pinned DESC, ct.root_time DESC, ct.path ASC
OFFSET :skip LIMIT :limit
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;该SQL相较于简化版有着以下明显改进&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&gt;WHERE c.parent_id IS NULL&lt;/code&gt;&amp;lt;br&amp;gt;（固定取所有根评论）&lt;/td&gt;
&lt;td&gt;&lt;code&gt;WHERE {root_filter}&lt;/code&gt;（占位符，可动态指定根节点，&amp;lt;br&amp;gt;比如 &lt;code&gt;root_id = 某值&lt;/code&gt; 或 &lt;code&gt;parent_id IS NULL AND article = :article）&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;WHERE 子句中添加了 &lt;code&gt;c.article = :article&lt;/code&gt;&amp;lt;br&amp;gt;确保子评论与根评论属于同一篇文章&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;路径构建&lt;/td&gt;
&lt;td&gt;&lt;code&gt;concat(ct.path, &apos; -&amp;gt; &apos;, c.id)&lt;/code&gt;&amp;lt;br&amp;gt;路径如 1 -&amp;gt; 2 -&amp;gt; 3&lt;/td&gt;
&lt;td&gt;用 &lt;code&gt;lpad(..., 10, &apos;0&apos;)&lt;/code&gt; 将 ID 补零成固定宽度（如 0000000001）&amp;lt;br&amp;gt;保证按字符串排序时能按数字顺序排列 &lt;strong&gt;（避免 10 排在 2 前面）&lt;/strong&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;置顶评论（pinned）优先&amp;lt;br&amp;gt;然后按根评论时间倒序&amp;lt;br&amp;gt;同根下的评论按 path 字典序（即树的深度优先顺序）&amp;lt;br&amp;gt;支持分页（OFFSET / LIMIT）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;最终查询结果&lt;/h3&gt;
&lt;p&gt;（&lt;code&gt;{root_filter} = parent_id IS NULL AND article = :article&lt;/code&gt;，&lt;code&gt;article=/abc&lt;/code&gt;，&lt;code&gt;skip=0&lt;/code&gt;，&lt;code&gt;limit=100&lt;/code&gt;，排序 &lt;code&gt;pinned DESC, root_time DESC, path ASC&lt;/code&gt;）&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;id&lt;/th&gt;
&lt;th&gt;article&lt;/th&gt;
&lt;th&gt;user_id&lt;/th&gt;
&lt;th&gt;content&lt;/th&gt;
&lt;th&gt;parent_id&lt;/th&gt;
&lt;th&gt;root_id&lt;/th&gt;
&lt;th&gt;time&lt;/th&gt;
&lt;th&gt;depth&lt;/th&gt;
&lt;th&gt;path&lt;/th&gt;
&lt;th&gt;pinned&lt;/th&gt;
&lt;th&gt;username&lt;/th&gt;
&lt;th&gt;avatar&lt;/th&gt;
&lt;th&gt;home_page&lt;/th&gt;
&lt;th&gt;role&lt;/th&gt;
&lt;th&gt;email&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;Root A (pinned)&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2024-01-01 10:00:00&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0000000001&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;alice&lt;/td&gt;
&lt;td&gt;a.jpg&lt;/td&gt;
&lt;td&gt;alice.com&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;a@x.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;102&lt;/td&gt;
&lt;td&gt;Child A1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2024-01-01 10:05:00&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0000000001 -&amp;gt; 0000000002&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;bob&lt;/td&gt;
&lt;td&gt;b.jpg&lt;/td&gt;
&lt;td&gt;bob.com&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;b@x.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;Grandchild A1-1&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2024-01-01 10:20:00&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0000000001 -&amp;gt; 0000000002 -&amp;gt; 0000000006&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;eve&lt;/td&gt;
&lt;td&gt;e.jpg&lt;/td&gt;
&lt;td&gt;eve.com&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;e@x.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;103&lt;/td&gt;
&lt;td&gt;Child A2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2024-01-01 10:10:00&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0000000001 -&amp;gt; 0000000003&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;charlie&lt;/td&gt;
&lt;td&gt;c.jpg&lt;/td&gt;
&lt;td&gt;charlie.com&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;c@x.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;104&lt;/td&gt;
&lt;td&gt;Root B&lt;/td&gt;
&lt;td&gt;NULL&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2024-01-02 09:00:00&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0000000004&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;dave&lt;/td&gt;
&lt;td&gt;d.jpg&lt;/td&gt;
&lt;td&gt;dave.com&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;d@x.com&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;/abc&lt;/td&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;Child B1&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2024-01-02 09:15:00&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0000000004 -&amp;gt; 0000000005&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;alice&lt;/td&gt;
&lt;td&gt;a.jpg&lt;/td&gt;
&lt;td&gt;alice.com&lt;/td&gt;
&lt;td&gt;user&lt;/td&gt;
&lt;td&gt;a@x.com&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
</content:encoded></item><item><title>FastAPI单worker后端并发优化: 从持续瘫痪到高并发保持服务</title><link>https://www.mintlab.top/posts/lark-solution/single-process-improve/</link><guid isPermaLink="true">https://www.mintlab.top/posts/lark-solution/single-process-improve/</guid><description>突发奇想给自己的单线程后端压测一下, 没想到30并发就会卡死超时, 重启也未恢复, 本文介绍了完整的排查流程和优化方案, 实现了在3000并发下有超过1/3的请求得到服务, 超出能力的请求全部拒绝, 仅有3个超时.</description><pubDate>Fri, 10 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;突发奇想给自己的后端压测一下, 没想到30并发就会卡死超时, 重启也未恢复, 本文介绍了完整的排查流程和优化方案, 实现了在3000并发下有超过1/3的请求得到服务, 超出能力的请求全部拒绝, 仅有3个超时.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;问题&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;今天下午压测后端了&amp;lt;br&amp;gt;
咱的后端连30并发都扛不住&amp;lt;br&amp;gt;
那很坏了&amp;lt;br&amp;gt;
测试了512并发，两分钟就能让我们后端瘫痪半小时&amp;lt;br&amp;gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;使用了&lt;code&gt;systemctl --user restart fastapi.service&lt;/code&gt;, &lt;code&gt;systemctl --user stop fastapi.service&lt;/code&gt;和&lt;code&gt;systemctl --user start fastapi.service&lt;/code&gt;都不能解决问题, 虽然成功重启, 但是服务访问仍然超时&lt;/p&gt;
&lt;h2&gt;排查&lt;/h2&gt;
&lt;h3&gt;查看数据库连接池&lt;/h3&gt;
&lt;p&gt;SQLite在创建连接时会先进入连接池, 如果连接池已满, 则后续的连接请求会被阻塞, 直到有连接被释放或默认15秒超时后报错退出&lt;/p&gt;
&lt;p&gt;但我们未显式设置连接池数量, 默认为128, 对于我们30并发就阻塞卡死的情况, 显然不合理, 并且我们SQLite是WAL模式, 写不阻塞读, 所以排除数据库的问题&lt;/p&gt;
&lt;h3&gt;TCP连接池满?&lt;/h3&gt;
&lt;p&gt;我们使用&lt;code&gt;ss -tan state close-wait -H | wc -l&lt;/code&gt;命令查询了系统未关闭的TCP连接数量,&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mint@MintServer-WH:~$ ss -tan state close-wait -H | wc -l
468
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果非常惊人, 有接近500个连接未被关闭, 这非常符合我们刚刚进行的512并发测试, 并且重启服务这些连接也未被关闭.&lt;/p&gt;
&lt;p&gt;反复刷新可以发现这些连接在缓慢减少, 说明后端正在缓慢处理这些堆积的请求, 可是这些请求对应的客户端早都超时退出了, 服务器正在做无用功.&lt;/p&gt;
&lt;p&gt;大约半小时过去了, 我们还在寻找解决方案时, 后端服务突然恢复了, 重新查看发现未关闭的连接数果然下降到了1, 这也证实了我们的想法&lt;/p&gt;
&lt;h2&gt;修复思路&lt;/h2&gt;
&lt;h3&gt;清除大量未关闭的连接&lt;/h3&gt;
&lt;p&gt;从网上查到了这行命令&lt;code&gt;lsof -i:&amp;lt;端口号&amp;gt;&lt;/code&gt;, 可以查询正在使用端口的进程和状态, 运行显示&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mint@MintServer-WH:~$ lsof -i:8080
COMMAND      PID USER   FD   TYPE   DEVICE SIZE/OFF NODE NAME
nginx    3967538 mint   13u  IPv4 50128379      0t0  TCP localhost:47238-&amp;gt;localhost:http-alt (ESTABLISHED)
gunicorn 4062077 mint    3u  IPv4 48464702      0t0  TCP localhost:http-alt (LISTEN)
gunicorn 4062083 mint    3u  IPv4 48464702      0t0  TCP localhost:http-alt (LISTEN)
gunicorn 4062083 mint   28u  IPv4 50129369      0t0  TCP localhost:http-alt-&amp;gt;localhost:47238 (ESTABLISHED)
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;ttp-alt 是 /etc/services 中定义的 8080 端口 的服务别名，实际就是端口 8080。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;我们是一个典型的 Nginx 反向代理 → Gunicorn 应用服务器 的本地回环通信架构：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Gunicorn&lt;/strong&gt; 在 &lt;code&gt;127.0.0.1:8080&lt;/code&gt; 上监听 HTTP 请求（两个 LISTEN 是因为 master + worker 共享同一个监听套接字）。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Nginx&lt;/strong&gt; 作为客户端主动向 &lt;code&gt;localhost:8080&lt;/code&gt; 发起连接（从自己的临时端口 &lt;code&gt;47238&lt;/code&gt; 连过去），用来转发外部 Web 请求。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;输出中的 &lt;code&gt;ESTABLISHED&lt;/code&gt; 连接正是 Nginx 与 Gunicorn 之间当前活跃的一条请求通道。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;之前使用&lt;code&gt;systemctl --user restart fastapi.service&lt;/code&gt;属于优雅关闭, 保留了进程的TCP连接池, 以便重启后能够继续处理未完成的请求, 这就是我们重启也无效的原因 -- 大量的请求还未被关闭, 服务上线后依然在按顺序处理这些过时的请求, 新的请求只能在后面排队, 等排到新请求也超时了.&lt;/p&gt;
&lt;p&gt;使用&lt;code&gt;kill -9 &amp;lt;进程PID&amp;gt;&lt;/code&gt;强制关闭进程, 这里注意要关闭Gunicorn的主进程, 根据上图也就是&lt;code&gt;4062077&lt;/code&gt;, 强制关闭后重新检查未关闭连接数量&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mint@MintServer-WH:~$ ss -tan state close-wait -H | wc -l
1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;恢复正常, 使用&lt;code&gt;systemctl --user start fastapi.service&lt;/code&gt;启动服务, 后端服务正常运行!&lt;/p&gt;
&lt;h3&gt;优化单线程逻辑&lt;/h3&gt;
&lt;p&gt;之前的操作只是从瘫痪中临时恢复, 如果再来一次高并发, 依然会陷入相同的境地, 我可不想每次都上来杀进程:(&lt;/p&gt;
&lt;p&gt;解决方案是在FastAPI http中间件里添加信号量, 试了试asyncio但是我好像不太会用(, 所以就自己写了一个&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;该设计限制了单worker的最大处理数, 在多worker时依然有效, 因为不同worker内存独立, gunicorn会将负载均衡分配到每一个worker上&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;# 请求限制信号量实现
class RequestLimiter:
    def __init__(self, max_concurrent: int):
        self._max = max_concurrent
        self._current = 0
        self._lock = asyncio.Lock()

    async def acquire(self) -&amp;gt; bool:
        async with self._lock:
            if self._current &amp;gt;= self._max:
                return False
            self._current += 1
            return True

    async def release(self) -&amp;gt; None:
        async with self._lock:
            if self._current &amp;gt; 0:
                self._current -= 1
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;request_limiter = RequestLimiter(5)

@app.middleware(&quot;http&quot;)
async def log_requests(request: Request, call_next):
    if not await request_limiter.acquire():
        return JSONResponse({&quot;detail&quot;: &quot;server busy&quot;}, status_code=503)
    try:
        # 处理请求
        response = await call_next(request)    # T1. 先处理业务, 获取响应
        return response                        # T2. 由于在try块中, python会先记住要返回的值
    finally:
        await request_limiter.release()        # T3. finally执行, 释放信号量
                                               # T4. 函数真正返回
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;ESA访问频次限制&lt;/h3&gt;
&lt;p&gt;在阿里云ESA访问频次限制中, 限制了&lt;strong&gt;同一ip&lt;/strong&gt;在&lt;strong&gt;10秒内请求超过60次&lt;/strong&gt;即触发拦截, 返回403&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/ddac8426e7696df9cfdb55ce30dea213.Cl7s5CsH.png&quot; alt=&quot;403_zako&quot; /&gt;&lt;/p&gt;
&lt;p&gt;来感受恶意()&lt;/p&gt;
&lt;h2&gt;最终效果&lt;/h2&gt;
&lt;p&gt;1024并发-ESA防护
&lt;img src=&quot;https://www.mintlab.top/_astro/163f42aabec96cbce194b1040ab11fcb.oeWuy_0a.png&quot; alt=&quot;1024并发-ESA防护&quot; /&gt;&lt;/p&gt;
&lt;p&gt;3000并发-无ESA防护
&lt;img src=&quot;https://www.mintlab.top/_astro/271cf07f4bd22ef86bf27c40c262659a.BNosliO1.png&quot; alt=&quot;3000并发-无ESA防护&quot; /&gt;
1273+1724=2997&lt;/p&gt;
&lt;p&gt;3000并发情况下，有超过1/3的请求完成服务，1724个拒绝，仅有3个超时, 完美解决!&lt;/p&gt;
&lt;p&gt;谁说单线程不能打，单线程能服务1000多个请求，改成32线程，并发上3万（x）&lt;/p&gt;
&lt;h2&gt;多worker优化&lt;/h2&gt;
&lt;p&gt;保持上述设计的同时可以在nginx限制全局并发&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Nginx 配置示例
http {
    # 定义一个共享内存区域，用于存储连接数状态
    limit_conn_zone $binary_remote_addr zone=perip:10m;
    limit_conn_zone $server_name zone=perserver:10m;

    server {
        location / {
            # 每个IP的并发连接数限制
            limit_conn perip 10;
            # 所有IP对当前虚拟主机的总并发连接数限制
            limit_conn perserver 50;

            # 当请求超过限制时，返回503状态码
            limit_conn_status 503;

            proxy_pass http://backend;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>我的第一个边缘计算项目：用阿里云 ESA 搭建设备视监面板</title><link>https://www.mintlab.top/posts/tries/monitor-kv-serverless/</link><guid isPermaLink="true">https://www.mintlab.top/posts/tries/monitor-kv-serverless/</guid><description>第一次接触边缘计算，用阿里云 ESA 的边缘函数 + EdgeKV 做了一个设备监控面板。没有服务器，没有数据库，一个 JS 文件就是全部后端。这篇文章记录整个技术方案的实现细节。</description><pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;前言&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;第一次接触边缘计算，用阿里云 ESA 的边缘函数 + EdgeKV 做了一个设备监控面板。没有服务器，没有数据库，一个 JS 文件就是全部后端。这篇文章记录整个技术方案的实现细节。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;网站在这里: &lt;a href=&quot;https://mintlab.top/monitor&quot;&gt;Mint Now--视监面板&lt;/a&gt; 欢迎来玩!&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/7f88f5bf-3440-4717-bc96-a36a90b1bb52.BqskJYqQ.png&quot; alt=&quot;视监面板截图&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;什么是边缘函数&lt;/h1&gt;
&lt;p&gt;传统的后端服务跑在一台固定的服务器上，用户的请求需要绕到那台机器再返回。边缘函数不同——代码被部署到全球各地的 CDN 节点上，用户的请求由离他最近的节点直接处理，响应速度天然就快。&lt;/p&gt;
&lt;p&gt;阿里云 ESA（Edge Security Acceleration）提供的边缘函数基于标准的 Web Worker API，写法和 Cloudflare Workers 类似：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;export default {
  async fetch(request, env, ctx) {
    return handleRequest(request);
  }
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;整个 &lt;code&gt;monitor.js&lt;/code&gt; 就是一个标准的 &lt;code&gt;fetch&lt;/code&gt; handler。接收 &lt;code&gt;Request&lt;/code&gt; 对象，返回 &lt;code&gt;Response&lt;/code&gt; 对象，中间没有 Express、没有 Koa，连 Node.js 的 &lt;code&gt;http&lt;/code&gt; 模块都不存在——这是一个纯粹的 Serverless 环境。&lt;/p&gt;
&lt;p&gt;路由分发也是手写的 &lt;code&gt;if/else&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function handleRequest(request) {
  const url = new URL(request.url);
  const path = url.pathname;

  if (path === &quot;/api/monitor/report&quot; &amp;amp;&amp;amp; request.method === &quot;POST&quot;) {
    return handleReport(request);
  }
  if (path === &quot;/api/monitor/devices&quot; &amp;amp;&amp;amp; request.method === &quot;GET&quot;) {
    return handleGetDevices();
  }
  // ...更多路由
  return errorResponse(&quot;Not Found&quot;, 404);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;没有框架抽象，逻辑一目了然。&lt;/p&gt;
&lt;h1&gt;EdgeKV：边缘节点上的键值存储&lt;/h1&gt;
&lt;p&gt;边缘函数是无状态的，每次请求之间不共享内存。要持久化数据，ESA 提供了 &lt;strong&gt;EdgeKV&lt;/strong&gt;——一个分布式键值存储，和边缘函数跑在同一层网络上，读写延迟极低。&lt;/p&gt;
&lt;p&gt;使用方式很直接：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const kv = new EdgeKV({ namespace: &quot;monitor-kv&quot; });

// 写
await kv.put(&quot;device:my-pc&quot;, JSON.stringify(deviceData));

// 读
const value = await kv.get(&quot;device:my-pc&quot;);

// 删
await kv.delete(&quot;device:my-pc&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在这个项目里，KV 存储承担了所有的数据职责：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key 模式&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&gt;device:&amp;lt;id&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;单个设备的完整状态&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;device:list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;所有设备 ID 的数组&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;device:list:data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;所有设备数据的缓存快照&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uptime:&amp;lt;id&amp;gt;:&amp;lt;date&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;按天聚合的设备在线时长（分钟）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;viewer:&amp;lt;id&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;单个观看者的会话数据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;viewer:list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;所有观看者 ID 的数组&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;viewer:list:data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;所有观看者数据的缓存快照&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;你会注意到 &lt;code&gt;device:list:data&lt;/code&gt; 和 &lt;code&gt;viewer:list:data&lt;/code&gt; 这两个&quot;缓存列表&quot;。这是被 EdgeKV 的调用次数限制逼出来的设计——如果每个设备存一条 KV，获取设备列表时要逐个 &lt;code&gt;kv.get()&lt;/code&gt;，5 台设备就是 5 次 KV 调用，超了配额直接报错。所以在设备上报时，顺手把整个设备列表冗余地写一份到 &lt;code&gt;device:list:data&lt;/code&gt;，读列表时一次 &lt;code&gt;kv.get()&lt;/code&gt; 全部拿回来。&lt;/p&gt;
&lt;h1&gt;没有 TTL？手动管理过期&lt;/h1&gt;
&lt;p&gt;这是踩的最大的坑。很多 KV 存储（比如 Cloudflare KV）支持设置 TTL，key 到期自动删除。但 ESA 的 EdgeKV &lt;strong&gt;没有原生的 TTL 支持&lt;/strong&gt;。如果你写入一条数据，不主动删，它就永远在那里。&lt;/p&gt;
&lt;p&gt;对于设备数据这种 7 天过期的场景，不可能靠定时任务去扫——边缘函数没有 cron，只有请求来了才会执行。&lt;/p&gt;
&lt;p&gt;最终方案是 &lt;strong&gt;包装层模拟 TTL&lt;/strong&gt;。写入时，把实际数据和过期时间戳包在一起：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;function makeExpirablePayload(value, ttlSeconds) {
  return JSON.stringify({
    value,
    expireAt: Date.now() + ttlSeconds * 1000,
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;KV 里存的不是裸数据，而是 &lt;code&gt;{ value: &amp;lt;实际数据&amp;gt;, expireAt: &amp;lt;过期时间戳&amp;gt; }&lt;/code&gt; 这样的信封。&lt;/p&gt;
&lt;p&gt;读取时，通过配套的 &lt;code&gt;getExpirableValue&lt;/code&gt; 函数拆信封：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function getExpirableValue(kv, key, { deleteIfExpired = true } = {}) {
  const raw = await kv.get(key);
  if (raw === undefined || raw === null) return null;

  try {
    const parsed = JSON.parse(raw);
    if (parsed &amp;amp;&amp;amp; typeof parsed.expireAt === &quot;number&quot;
        &amp;amp;&amp;amp; Object.prototype.hasOwnProperty.call(parsed, &quot;value&quot;)) {
      if (Date.now() &amp;gt; parsed.expireAt) {
        // 过期了，顺手删掉
        if (deleteIfExpired) {
          kv.delete(key).catch(() =&amp;gt; {});
        }
        return null;
      }
      return parsed.value;
    }
  } catch (e) {
    // 不是包装格式，返回原始值
  }
  return raw;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键在于 &lt;code&gt;deleteIfExpired&lt;/code&gt;——读到过期数据时，&lt;strong&gt;顺手异步删除&lt;/strong&gt;。这是一种&quot;惰性清理&quot;策略：不专门花时间扫过期数据，而是在正常业务读取时发现过期就清。代价是过期后到下一次被读到之间，数据仍然占着空间；好处是零额外开销。&lt;/p&gt;
&lt;p&gt;在拉取设备列表和观看者列表时也做了同样的处理：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for (const device of devices) {
  if (device._expireAt &amp;amp;&amp;amp; now &amp;gt; device._expireAt) {
    await removeDeviceFromLists(kv, device.id).catch(() =&amp;gt; {});
    continue; // 跳过过期设备
  }
  // ...正常处理
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每次读列表都是一次&quot;顺便清扫&quot;。&lt;/p&gt;
&lt;h1&gt;实时观看者计数&lt;/h1&gt;
&lt;p&gt;这是整个项目里最有意思的部分。需求很简单：页面上显示&quot;当前有 N 人正在观看&quot;。&lt;/p&gt;
&lt;h2&gt;心跳机制&lt;/h2&gt;
&lt;p&gt;浏览器端每隔 30 秒发一次心跳：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /api/monitor/viewers
{ &quot;id&quot;: &quot;viewer-abc123&quot;, &quot;page&quot;: &quot;home&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;边缘函数收到后，写入一条带 35 秒 TTL 的观看者数据：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const viewerData = {
  id: body.id,
  page: body.page || &quot;&quot;,
  source: body.source || &quot;&quot;,
  lastSeen: now,
  _expireAt: now + CONFIG.VIEWER_TTL_SECONDS * 1000, // 35秒后过期
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;TTL 设为 35 秒（比心跳间隔 30 秒多 5 秒容错）。如果用户关闭页面、断网、切走，心跳停止，35 秒后这条数据自动被视为过期。&lt;/p&gt;
&lt;h2&gt;查询时过滤&lt;/h2&gt;
&lt;p&gt;拉取观看人数时，从缓存列表里读出所有观看者，&lt;strong&gt;过滤掉已过期的&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;for (const viewer of viewers) {
  if (viewer._expireAt &amp;amp;&amp;amp; now &amp;gt; viewer._expireAt) {
    continue; // 跳过过期的观看者
  }
  validViewers.push(viewer);
  if (!page || viewer.page === page) {
    activeViewers.push(viewer);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果发现有过期条目被清除了，顺手把清理后的列表写回 KV，保持缓存干净：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (validViewers.length !== viewers.length) {
  await kv.put(CONFIG.KV_VIEWER_DATA_LIST_KEY, JSON.stringify(validViewers));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同时在心跳写入时也会清理过期条目，避免缓存列表无限膨胀影响实时性：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;async function updateViewerDataList(kv, viewerData) {
  let viewerList = listJson ? JSON.parse(listJson) : [];
  // 清理已过期的观看者，保证实时性
  const now = Date.now();
  viewerList = viewerList.filter(
    (item) =&amp;gt; !(item._expireAt &amp;amp;&amp;amp; now &amp;gt; item._expireAt)
  );
  // 然后更新当前观看者...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;按页面过滤&lt;/h2&gt;
&lt;p&gt;支持查询特定页面的观看人数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET /api/monitor/viewers?page=home
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;success&quot;: true,
  &quot;data&quot;: {
    &quot;count&quot;: 3,
    &quot;viewers&quot;: [...]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;整个方案没有 WebSocket，没有长连接，纯粹靠轮询心跳 + 短 TTL 模拟出了实时效果。在 Serverless 环境下这是最务实的方案——你没有长驻进程来维持 WebSocket 连接。&lt;/p&gt;
&lt;h1&gt;设备状态的三态判定&lt;/h1&gt;
&lt;p&gt;设备也是靠心跳判断在线状态的，但需要比观看者更细的粒度。定义了三种状态：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const CONFIG = {
  OFFLINE_THRESHOLD: 30 * 60 * 1000, // 30分钟无心跳 = 离线
  AWAY_THRESHOLD:    10 * 60 * 1000, // 10分钟无心跳 = 离开
};

function determineStatus(lastSeen) {
  const diff = Date.now() - lastSeen;
  if (diff &amp;gt; CONFIG.OFFLINE_THRESHOLD) return &quot;offline&quot;;
  if (diff &amp;gt; CONFIG.AWAY_THRESHOLD)    return &quot;away&quot;;
  return &quot;online&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;online&lt;/strong&gt;：10 分钟内有心跳&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;away&lt;/strong&gt;：10~30 分钟无心跳（可能睡眠或暂时离开）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;offline&lt;/strong&gt;：超过 30 分钟无心跳&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;状态不是存死的，而是每次查询时根据 &lt;code&gt;lastSeen&lt;/code&gt; 实时计算。这样即使没有新的上报，查询接口也能反映最新状态。&lt;/p&gt;
&lt;h1&gt;上报客户端&lt;/h1&gt;
&lt;p&gt;配套写了一个 Python 客户端 &lt;code&gt;report_client.py&lt;/code&gt;，跑在被监控的设备上，支持自动采集系统信息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python report_client.py --id my-pc --interval 60
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它会每 60 秒采集一次 CPU、内存、磁盘、电量等信息，通过 &lt;code&gt;psutil&lt;/code&gt; 获取，然后 POST 到边缘函数。还能自动检测前台运行的应用窗口（Linux 下通过 &lt;code&gt;xdotool&lt;/code&gt;），方便在面板上展示&quot;这台机器正在干什么&quot;。&lt;/p&gt;
&lt;h1&gt;Serverless 的优势&lt;/h1&gt;
&lt;p&gt;做完这个项目，对 Serverless 的体感比之前清晰很多：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;零运维&lt;/strong&gt;。没有服务器要管，不用装 Nginx，不用配 systemd，不用半夜爬起来重启进程。代码推上去就跑。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;按需付费&lt;/strong&gt;。没有请求就不产生费用。对于个人项目这种流量极低的场景，成本几乎为零。传统方案怎么也得一台 VPS，哪怕空跑也按月收费。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;天然全球加速&lt;/strong&gt;。代码部署在 CDN 边缘节点上，不管用户从哪里访问，响应都是就近的。不需要额外买 CDN、配回源、调缓存策略。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;自动扩缩&lt;/strong&gt;。突然来了 1000 个并发？平台自动处理。不需要提前预估容量、配负载均衡。&lt;/p&gt;
&lt;p&gt;当然也有代价：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;调试困难&lt;/strong&gt;。没有本地运行环境（本项目靠 mock 的 &lt;code&gt;EdgeKV&lt;/code&gt; 做本地测试）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;存储限制&lt;/strong&gt;。EdgeKV 只是简单的键值对，复杂查询做不了&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没有原生 TTL&lt;/strong&gt;。这篇文章花了大量篇幅讲手动过期管理，就是因为平台不提供&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;冷启动&lt;/strong&gt;。虽然边缘函数的冷启动比传统 FaaS 快得多，但偶尔还是能感知到&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;小结&lt;/h1&gt;
&lt;p&gt;一个 731 行的 JS 文件，一个 Python 上报脚本，零服务器，实现了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;多设备状态监控（在线/离开/离线三态）&lt;/li&gt;
&lt;li&gt;实时观看者计数（心跳 + 短 TTL 过期）&lt;/li&gt;
&lt;li&gt;7 天在线时长统计&lt;/li&gt;
&lt;li&gt;设备前台应用检测&lt;/li&gt;
&lt;li&gt;完整的 CRUD API&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;作为第一次接触边缘函数的项目，整体体验是积极的。最大的收获不是代码本身，而是理解了在&quot;没有服务器&quot;的限制下，如何用最朴素的方式（轮询、包装 TTL、冗余缓存）解决实际问题。&lt;/p&gt;
</content:encoded></item><item><title>在 Linux Mint 上折腾 DRM Panic：从企鹅到二维码的完整调试记录</title><link>https://www.mintlab.top/posts/tries/linux-drm-panic/</link><guid isPermaLink="true">https://www.mintlab.top/posts/tries/linux-drm-panic/</guid><description>在b站上刷到 Linux 内核在2024年增加了一个有趣的功能：DRM Panic。当内核发生Kernel Panic时， 会出现一个类似于windows的蓝屏界面(划掉, 紫屏。我在物理机上尝试了最终还是没看到二维码, 搞半天原来要重新编译内核(晕</description><pubDate>Tue, 07 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;:::note[前言]&lt;/p&gt;
&lt;p&gt;在b站上刷到 Linux 内核在2024年增加了一个有趣的功能：DRM Panic。当内核发生Kernel Panic时， 会出现一个类似于windows的蓝屏界面(划掉, 紫屏。我在物理机上尝试了最终还是没看到二维码, 搞半天原来要重新编译内核(晕&lt;/p&gt;
&lt;p&gt;于是，我开启了一段长达数小时的调试之旅。本文将完整记录整个过程——从触发内核恐慌、解决各种卡死问题，到最终发现“二维码缺失”的根本原因。希望能给同样好奇的朋友一些参考。&lt;/p&gt;
&lt;p&gt;我的设备：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;RedmiBook 14 II，&lt;/li&gt;
&lt;li&gt;CPU Intel i7-1065G7（Ice Lake）&lt;/li&gt;
&lt;li&gt;双显卡 Intel + NVIDIA MX350&lt;/li&gt;
&lt;li&gt;系统 Linux Mint 22.2 Zara&lt;/li&gt;
&lt;li&gt;内核 6.14.0-29-generic&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;:::&lt;/p&gt;
&lt;h1&gt;一、初次尝试：直接触发，只有卡死&lt;/h1&gt;
&lt;p&gt;按照网上的教程，最简单触发内核恐慌的命令是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo c | sudo tee /proc/sysrq-trigger
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果屏幕直接卡死在最后一帧，没有任何新画面，键盘无响应，只能长按电源键强制重启。查看日志确认内核确实 panic 了，但画面没出来。&lt;/p&gt;
&lt;p&gt;原因分析&lt;/p&gt;
&lt;p&gt;· 默认内核启动参数中有 quiet splash，隐藏了大部分输出。
· 图形界面（Cinnamon）的 Xorg 服务器持有 DRM master 锁，内核恐慌时无法抢占显示输出。&lt;/p&gt;
&lt;h1&gt;二、初现曙光：切换到 tty 文本控制台&lt;/h1&gt;
&lt;p&gt;我尝试按 Ctrl+Alt+F3 切换到纯文本 tty，登录后再次触发崩溃。这次屏幕上终于出现了黑底白字的内核恐慌调用栈——但依然没有企鹅和二维码。&lt;/p&gt;
&lt;p&gt;这说明：传统文本模式可以工作，但 DRM Panic 的图形界面没有激活。&lt;/p&gt;
&lt;p&gt;检查内核配置&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;grep -E &quot;CONFIG_DRM_PANIC&quot; /boot/config-$(uname -r)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出显示：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CONFIG_DRM_PANIC=y
CONFIG_DRM_PANIC_FOREGROUND_COLOR=0xffffff
CONFIG_DRM_PANIC_BACKGROUND_COLOR=0x5e2750
CONFIG_DRM_PANIC_SCREEN=&quot;user&quot;
# CONFIG_DRM_PANIC_SCREEN_QR_CODE is not set
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;好消息：内核已编译 DRM_PANIC 支持。坏消息：当前模式是 user（只显示文本），二维码功能被禁用。&lt;/p&gt;
&lt;h1&gt;三、尝试强制启用图形化 Panic&lt;/h1&gt;
&lt;h2&gt;1. 修改 GRUB 内核参数&lt;/h2&gt;
&lt;p&gt;编辑 /etc/default/grub，添加：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GRUB_CMDLINE_LINUX_DEFAULT=&quot;quiet splash drm.panic=1 drm.panic_force=1 vt.handoff=0&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行 sudo update-grub 并重启。检查 /proc/cmdline 确认参数已生效。&lt;/p&gt;
&lt;h2&gt;2. 问题依旧：图形界面下仍然卡死&lt;/h2&gt;
&lt;p&gt;即使在桌面环境直接触发，依然卡在最后一帧。于是我尝试禁用 NVIDIA 显卡（使用 prime-select intel 并黑名单 nvidia 模块），结果依旧。&lt;/p&gt;
&lt;h2&gt;3. 添加 nomodeset 后“成功”了？&lt;/h2&gt;
&lt;p&gt;在 GRUB 参数中加入 nomodeset（禁用内核模式设置）后，触发崩溃时终于出现了紫色的文本 panic 界面，顶部有 ASCII 企鹅字符！但是——没有二维码。&lt;/p&gt;
&lt;p&gt;这正是文章开头那张截图的效果。&lt;/p&gt;
&lt;h1&gt;四、定位二维码缺失的真相&lt;/h1&gt;
&lt;p&gt;检查运行时控制接口&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cat /sys/module/drm/parameters/panic_screen
# 输出：user
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;尝试在线切换为二维码模式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo -n qr_code | sudo tee /sys/module/drm/parameters/panic_screen
# 报错：Invalid argument
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这说明内核根本不认识 qr_code 这个值。&lt;/p&gt;
&lt;p&gt;终极检查：内核编译选项&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;grep &quot;CONFIG_DRM_PANIC_SCREEN&quot; /boot/config-$(uname -r)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CONFIG_DRM_PANIC_SCREEN=&quot;user&quot;
# CONFIG_DRM_PANIC_SCREEN_QR_CODE is not set
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真相大白：我的 Linux Mint 内核在编译时只开启了文本模式，二维码功能对应的 CONFIG_DRM_PANIC_SCREEN_QR_CODE 被明确关闭了。因此无论怎么配置，都不可能显示二维码。&lt;/p&gt;
&lt;h1&gt;五、为什么会这样？发行版内核策略&lt;/h1&gt;
&lt;p&gt;· 二维码功能依赖 Rust 编写的底层库，编译时需要完整的 Rust 工具链。
· Ubuntu/Debian 系（包括 Mint）为了稳定和减少依赖，默认不开启该选项。
· 而 Arch Linux、Fedora 等激进发行版则默认启用了二维码。&lt;/p&gt;
&lt;h1&gt;六、硬件兼容性问题补充&lt;/h1&gt;
&lt;p&gt;在排查过程中，我发现即使不加 nomodeset，DRM Panic 也无法在图形界面下正常工作。查阅内核邮件列表发现，该功能的开发者 Jocelyn Falempe 曾报告：&lt;/p&gt;
&lt;p&gt;“I still have an issue with Alderlake, and it doesn&apos;t work when in gnome desktop.”&lt;/p&gt;
&lt;p&gt;我的 Ice Lake 平台与 Alderlake 架构相近，同样受到影响。这说明除了内核配置，部分 Intel 显卡在图形界面下的抢占问题也是未解决的 bug。&lt;/p&gt;
&lt;h1&gt;七、最终结论与建议&lt;/h1&gt;
&lt;h2&gt;1. 为什么我没看到二维码？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;直接原因：当前内核编译时未启用 CONFIG_DRM_PANIC_SCREEN_QR_CODE。&lt;/li&gt;
&lt;li&gt;根本原因：Linux Mint 发行版内核策略保守，未包含该功能。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 为什么连紫色企鹅界面都看不到（不加 nomodeset）？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;硬件/驱动兼容性问题，Intel Ice Lake + i915 驱动在图形界面下无法被 DRM Panic 抢占。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;3. 如何完整体验 DRM Panic？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;最简单：使用虚拟机安装 Fedora 40+ 或 Arch Linux，直接触发。&lt;/li&gt;
&lt;li&gt;物理机：要么更换发行版，要么自己重新编译内核（开启 CONFIG_DRM_PANIC_SCREEN_QR_CODE=y），并接受可能存在的抢占问题。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;4. 给同样折腾的朋友的建议&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;先检查 &lt;code&gt;/proc/cmdline&lt;/code&gt; 确认参数生效。&lt;/li&gt;
&lt;li&gt;用 &lt;code&gt;lsmod | grep nvidia&lt;/code&gt; 排除双显卡干扰。&lt;/li&gt;
&lt;li&gt;查看内核 config 确认 CONFIG_DRM_PANIC_SCREEN_QR_CODE 是否开启。&lt;/li&gt;
&lt;li&gt;如果只是为了截图，虚拟机是最省心的途径。&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;八、彩蛋：开发者自己的话&lt;/h1&gt;
&lt;blockquote&gt;
&lt;p&gt;“我仍然在 Alderlake 平台上遇到问题，在 GNOME 桌面下它无法工作。” —— Jocelyn Falempe&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;连功能开发者都在某些 Intel 笔记本上搞不定，我的 RedmiBook 失败也就不那么意外了。这证明问题不是我的配置错误，而是功能本身在部分硬件上尚不完善。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/fb383f9c7097266f2f4a788963ad2c5b.CZyDCvjQ.png&quot; alt=&quot;名场面&quot; /&gt;
为什么说Rust是编程界的原神.jpg&lt;/p&gt;
&lt;h1&gt;后记&lt;/h1&gt;
&lt;p&gt;虽然最终没有在物理机上看到完整的二维码，但整个调试过程让我对 Linux 内核参数、DRM 子系统、GRUB 配置以及内核编译选项有了更深的理解。而且，至少我成功看到了那只紫色的 ASCII 企鹅——也算是不虚此行。&lt;/p&gt;
&lt;h1&gt;后后记：在ArchLinux虚拟机上看到二维码&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/c8c005f2c94cf8fb46ab287fad7cf993.KDXr7Cmc.png&quot; alt=&quot;Arch Linux Vnc&quot; title=&quot;在VNC里看VNC&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>初识Latex: 从安装到撰写比赛计划书</title><link>https://www.mintlab.top/posts/learn/latex_setup/</link><guid isPermaLink="true">https://www.mintlab.top/posts/learn/latex_setup/</guid><description>最近打了商业精英挑战赛, 需要经常写计划书协作, 那么问题来了, linux写word并不太方便, libreoffice还是太拉了, 并且每次协作都需要重新排格式, 涉及到的公式还非常难排, 本文介绍了基于linux系统latex的安装和在计划书撰写方面的尝试    </description><pubDate>Fri, 03 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;最近打了商业精英挑战赛, 需要经常写计划书协作, 那么问题来了, linux写word并不太方便, libreoffice还是太拉了, 并且每次协作都需要重新排格式, 涉及到的公式还非常难排, 本文介绍了基于linux系统latex的安装和在计划书撰写方面的尝试&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;参考文章&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.cnblogs.com/eslzzyl/p/17358405.html&quot;&gt;TeX Live 2025 安装教程（Windows/WSL/Linux）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://rosetears.cn/archives/73/&quot;&gt;【LaTeX】Vs code下载与配置&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://zhuanlan.zhihu.com/p/133984428&quot;&gt;TeX Live宏包集合和自定义安装&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我的设备:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;系统 Linux Mint 22.2 Zara&lt;/li&gt;
&lt;li&gt;内核 6.14.0-29-generic&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;安装&lt;/h2&gt;
&lt;p&gt;本文主要&lt;strong&gt;针对Linux系统&lt;/strong&gt;安装TeX Live作介绍, 其他系统可查看上方参考文章&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;首先我推荐在 Linux 平台安装 TeX Live（Mac 不在本文的讨论之列）。出于各种原因，Linux 下程序的编译速度可以达到 Windows 下的数倍。根据我的经验，对于 LaTeX 项目，Linux 可以获得至少 5 倍的编译加速。&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;下载安装镜像&lt;/h3&gt;
&lt;p&gt;到某个国内的 CTAN 镜像站下载最新版的 TeX Live 镜像，比如 &lt;a href=&quot;https://mirrors.tuna.tsinghua.edu.cn/CTAN/systems/texlive/Images/&quot;&gt;清华大学开源软件镜像站&lt;/a&gt;。&lt;/p&gt;
&lt;p&gt;目前最新的 TeX Live 版本为 2026，该文件夹下包含 3 个 .iso 文件，这 3 个文件仅有文件名不同，内容完全相同，你可以任选一个下载。该文件夹下还包含有 md5 摘要文件，你也可以下载下来以备校验。&lt;/p&gt;
&lt;p&gt;iso可以放在任意位置, 后文将使用挂载镜像的安装方式, 默认安装到&lt;code&gt;/usr/local/texlive/2026&lt;/code&gt;&lt;/p&gt;
&lt;h3&gt;校验安装镜像&lt;/h3&gt;
&lt;p&gt;这一步是可选的，主要是检查下载过程中镜像有没有出现损坏，以及镜像是否被第三方篡改过。实际上现在的网络条件很好了，一般不会出问题的。如果从正规的镜像站下载，也不必担心篡改的问题。&lt;/p&gt;
&lt;p&gt;在 Shell（如 bash）中切换到镜像所在的文件夹，然后执行&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;md5sum texlive2026.iso
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;md5 计算需要完整读取整个镜像文件，因此计算会持续一段时间，取决于你机器的运算速度和硬盘读取速度，请耐心等待。&lt;/p&gt;
&lt;p&gt;你应该可以得到 TeX Live 2026 镜像文件的 md5 值 &lt;code&gt;f5872cb2dec838670f91ed5c62493553&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;挂载安装镜像&lt;/h3&gt;
&lt;p&gt;进入 Shell 并执行&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo mkdir /mnt/texlive
sudo mount 你的镜像存放路径/texlive2026.iso /mnt/texlive
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;运行安装程序&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo /mnt/texlive/install-tl
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你应该能够看到如下内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;======================&amp;gt; TeX Live installation procedure &amp;lt;=====================
 
======&amp;gt;   Letters/digits in &amp;lt;angle brackets&amp;gt; indicate   &amp;lt;=======
======&amp;gt;   menu items for actions or customizations      &amp;lt;=======
= help&amp;gt;   https://tug.org/texlive/doc/install-tl.html   &amp;lt;=======
 
 Detected platform: GNU/Linux on x86_64
 
 &amp;lt;B&amp;gt; set binary platforms: 1 out of 15
 
 &amp;lt;S&amp;gt; set installation scheme: scheme-full
 
 &amp;lt;C&amp;gt; set installation collections:
     40 collections out of 41, disk space required: 8779 MB (free: 970330 MB)
 
 &amp;lt;D&amp;gt; set directories:
   TEXDIR (the main TeX directory):
     /usr/local/texlive/2025
   TEXMFLOCAL (directory for site-wide local files):
     /usr/local/texlive/texmf-local
   TEXMFSYSVAR (directory for variable and automatically generated data):
     /usr/local/texlive/2025/texmf-var
   TEXMFSYSCONFIG (directory for local config):
     /usr/local/texlive/2025/texmf-config
   TEXMFVAR (personal directory for variable and automatically generated data):
     ~/.texlive2025/texmf-var
   TEXMFCONFIG (personal directory for local config):
     ~/.texlive2025/texmf-config
   TEXMFHOME (directory for user-specific files):
     ~/texmf
 
 &amp;lt;O&amp;gt; options:
   [ ] use letter size instead of A4 by default
   [X] allow execution of restricted list of programs via \write18
   [X] create all format files
   [X] install macro/font doc tree
   [X] install macro/font source tree
   [ ] create symlinks to standard directories
   [X] after install, set CTAN as source for package updates
 
 &amp;lt;V&amp;gt; set up for portable installation
 
Actions:
 &amp;lt;I&amp;gt; start installation to hard disk
 &amp;lt;P&amp;gt; save installation profile to &apos;texlive.profile&apos; and exit
 &amp;lt;Q&amp;gt; quit
 
Enter command:
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;调整安装配置&lt;/h3&gt;
&lt;p&gt;这一节是可选的。实际上 TeX Live 中有许多组件是很少用的，为了节省磁盘空间，我们精简一些组件。如果你的硬盘足够大，不在乎空间占用，可以直接进入下一步。&lt;/p&gt;
&lt;p&gt;这一节参考了 &lt;a href=&quot;https://zhuanlan.zhihu.com/p/133984428&quot;&gt;TeX Live宏包集合和自定义安装&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;TeX Live 的打包策略为&lt;strong&gt;体系（scheme）&lt;/strong&gt;——&lt;strong&gt;集合（collection）&lt;/strong&gt;——&lt;strong&gt;软件包/宏包&lt;/strong&gt;三层。其中可定制安装的是集合层次。TeX Live 2025 包括了 41 个集合。针对这些集合的解释可以参考上面的链接，这里就不赘述了。&lt;/p&gt;
&lt;p&gt;Linux 的安装路径不建议改，否则之后出问题查文档可能会雪上加霜。Windows 随意，但路径不能带有非 ASCII 字符，而且不要太深，否则容易出问题。&lt;/p&gt;
&lt;p&gt;在安装程序中输入 C 并回车，进入安装组件定制页面。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;===============================================================================
Select collections:
 
 a [X] Essential programs and files      w [X] Italian                         
 b [X] BibTeX additional styles          x [X] Japanese                        
 c [X] TeX auxiliary programs            y [X] Korean                          
 d [X] ConTeXt and packages              z [X] Other languages                 
 e [X] Additional fonts                  A [X] Polish                          
 f [X] Recommended fonts                 B [X] Portuguese                      
 g [X] Graphics and font utilities       C [X] Spanish                         
 h [X] Additional formats                D [X] LaTeX fundamental packages      
 i [X] Games typesetting                 E [X] LaTeX additional packages       
 j [X] Humanities packages               F [X] LaTeX recommended packages      
 k [X] Arabic                            G [X] LuaTeX packages                 
 l [X] Chinese                           H [X] MetaPost and Metafont packages  
 m [X] Chinese/Japanese/Korean (base)    I [X] Music packages                  
 n [X] Cyrillic                          J [X] Graphics, pictures, diagrams    
 o [X] Czech/Slovak                      K [X] Plain (La)TeX packages          
 p [X] US and UK English                 L [X] PSTricks                        
 s [X] Other European languages          M [X] Publisher styles, theses, etc.  
 t [X] French                            N [ ] Windows-only support programs   
 u [X] German                            O [X] XeTeX and packages              
 v [X] Greek                            
 P [X] Mathematics, natural sciences, computer science packages
 S [X] TeXworks editor; TL includes only the Windows binary
 
Actions: (disk space required: 8315 MB)
 &amp;lt;-&amp;gt; deselect all
 &amp;lt;+&amp;gt; select all
 &amp;lt;R&amp;gt; return to main menu
 &amp;lt;Q&amp;gt; quit
 
Enter letter(s) to (de)select collection(s):
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;输入集合对应的字母可以选中/取消选中。支持批量输入。可以不装的组件有：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;d [ ] ConTeXt and packages
e [ ] Additional fonts		// 各种字体，空间大头
g [ ] Graphics and font utilities
i [ ] Games typesetting		// 排版游戏用的
j [ ] Humanities packages	// 人文科学类宏包，按需安装即可
E [ ] LaTeX additional packages		// 空间大头
H [ ] MetaPost and Metafont packages
I [ ] Music packages	// 排版乐谱用的
K [ ] Plain (La)TeX packages
L [ ] PSTricks		// 老旧技术
M [ ] Publisher styles, theses, etc.	// 各种学术、毕业论文模板，需要学术写作可以装一下
N [ ] Windows-only support programs		// linux不支持，默认取消勾选
S [ ] TeXworks editor; TL includes only the Windows binary
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;各种语言集合上面省略了，根据需要选择即可。如果不需要外语支持，语言类集合中仅选择 &lt;code&gt;Chinese&lt;/code&gt; 和 &lt;code&gt;Chinese/Japanese/Korean (base)&lt;/code&gt;、&lt;code&gt;US&lt;/code&gt; and &lt;code&gt;UK English&lt;/code&gt; 即可。&lt;/p&gt;
&lt;p&gt;注意，如果需要中文支持，那么 &lt;code&gt;Chinese&lt;/code&gt; 和 &lt;code&gt;Chinese/Japanese/Korean (base)&lt;/code&gt;（这三种语言的首字母形成了有名的缩写：&lt;code&gt;CJK&lt;/code&gt;）都是必不可少的。同理 Japanese 和 Korean 集合也都依赖 CJK base。&lt;/p&gt;
&lt;p&gt;TeXworks 是一个简单的 LaTeX 编辑器，我们将会使用 VSCode 来编辑 LaTeX ，因此这个组件可以不装。另外只有 Windows 平台提供有编译好的二进制文件，Linux 平台估计要手动编译。&lt;/p&gt;
&lt;p&gt;即使有些宏包在上面精简了，后期仍然可以使用 &lt;code&gt;tlmgr&lt;/code&gt; 工具来单独安装它们。因此不用太担心。&lt;/p&gt;
&lt;p&gt;我把可以不装的包的序号写在下面，可以直接复制粘贴然后回车：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;deghijkstuvwxyznoABCEHIKLMNS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我这里经过一番精简后，原本需要的 &lt;code&gt;8779 MB&lt;/code&gt; 空间精简到了 &lt;code&gt;2713 MB&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这样精简之后，编译有些模板时可能会报错说找不到宏包。此时可以使用&lt;code&gt;tlmgr&lt;/code&gt; 从 CTAN 单独安装它们。&lt;/p&gt;
&lt;p&gt;确认无误后，输入 &lt;code&gt;R&lt;/code&gt; 并回车，回到主界面。&lt;/p&gt;
&lt;h2&gt;使用&lt;/h2&gt;
&lt;p&gt;参考链接：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;官方文档: &lt;a href=&quot;http://tug.ctan.org/info/install-latex-guide-zh-cn/install-latex-guide-zh-cn.pdf&quot;&gt;一份(不太)简短的 LaTeX 2ε 介绍.pdf&lt;/a&gt;（推荐，非常详细）&lt;/li&gt;
&lt;li&gt;本站镜像: &lt;a href=&quot;https://file.mintlab.top/mirror/lshort-zh-cn.pdf&quot;&gt;一份(不太)简短的 LaTeX 2ε 介绍.pdf&lt;/a&gt;（网络不佳时可用）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;从一个最小骨架开始&lt;/h3&gt;
&lt;p&gt;任何 LaTeX 文档都由两部分组成：&lt;strong&gt;导言区&lt;/strong&gt;（preamble，&lt;code&gt;\documentclass&lt;/code&gt; 到 &lt;code&gt;\begin{document}&lt;/code&gt; 之间）和&lt;strong&gt;正文区&lt;/strong&gt;（body，&lt;code&gt;\begin{document}&lt;/code&gt; 到 &lt;code&gt;\end{document}&lt;/code&gt; 之间）。导言区负责全局设置、加载宏包；正文区负责实际内容。&lt;/p&gt;
&lt;p&gt;最精简的文档长这样——已经可以编译了：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;\documentclass[oneside]{ctexart}
\title{Hello, \LaTeX!}
\author{Mint}
\date{\today}

\begin{document}
\maketitle
Hello, \LaTeX{}! 这是我的第一篇 \LaTeX{} 文档。
\end{document}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里选择了 &lt;code&gt;ctexart&lt;/code&gt; 文档类（CTeX 提供的 article 中文适配版），选项 &lt;code&gt;oneside&lt;/code&gt; 表示单面打印——如果写计划书、课程报告，单面就够用；写论文需要双面打印时换成 &lt;code&gt;twoside&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;导言区：加载你需要的宏包&lt;/h3&gt;
&lt;p&gt;LaTeX 的核心理念是&quot;按需加载&quot;——用到什么功能，就 &lt;code&gt;\usepackage&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;zhlipsum&lt;/code&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;code&gt;graphicx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;插入图片&lt;/td&gt;
&lt;td&gt;支持 png/jpg/pdf，&lt;code&gt;\includegraphics&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;booktabs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;学术风格三线表&lt;/td&gt;
&lt;td&gt;提供 &lt;code&gt;\toprule&lt;/code&gt; &lt;code&gt;\midrule&lt;/code&gt; &lt;code&gt;\bottomrule&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;caption&lt;/code&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;code&gt;fancyhdr&lt;/code&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;code&gt;hyperref&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;超链接与 PDF 书签&lt;/td&gt;
&lt;td&gt;目录、引用自动变成可点击链接&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre&gt;&lt;code&gt;\usepackage{zhlipsum}
\usepackage{graphicx}
\usepackage{booktabs}
\usepackage{caption}
\usepackage{fancyhdr}
\usepackage[bookmarksnumbered=true, colorlinks=true, linkcolor=black]{hyperref}

% 单栏文档（默认就是单栏，显式声明更清晰）
\onecolumn

% 表格标题与上方内容的间距
\captionsetup[table]{skip=10pt}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;小提示&lt;/strong&gt;：&lt;code&gt;hyperref&lt;/code&gt; 通常放在导言区最后加载，避免与其他宏包冲突。&lt;code&gt;colorlinks=true&lt;/code&gt; 让链接有颜色但不加框；&lt;code&gt;linkcolor=black&lt;/code&gt; 让目录和交叉引用的链接变成黑色，打印友好。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;页眉页脚：用 fancyhdr 自定义&lt;/h3&gt;
&lt;p&gt;默认的 LaTeX 页眉只有章节名和页码，用 &lt;code&gt;fancyhdr&lt;/code&gt; 可以自由定制。下面定义了一个 &lt;code&gt;myfancy&lt;/code&gt; 页面风格——页眉左中右分别显示章节名、文章标题、小节名，页脚居中显示页码：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;\fancypagestyle{myfancy}{%
    \pagestyle{fancy}
    \lhead{\leftmark}        % 页眉左：当前章节（section）名
    \chead{\thetitle}        % 页眉中：文章标题
    \rhead{\rightmark}       % 页眉右：当前小节（subsection）名
    \lfoot{}                 % 页脚左：留空
    \cfoot{\thepage}         % 页脚中：页码
    \rfoot{}                 % 页脚右：留空
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;\leftmark&lt;/code&gt; 和 &lt;code&gt;\rightmark&lt;/code&gt; 是 LaTeX 自动维护的标记——章节标题会自动填入。但是 &lt;code&gt;\chead{\thetitle}&lt;/code&gt; 需要我们自己定义 &lt;code&gt;\thetitle&lt;/code&gt; 变量，详见下文&quot;标题页&quot;一节。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fancyhdr&lt;/code&gt; 对页眉高度有要求。如果编译时警告&quot;headheight is too small&quot;，加上这两行即可：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;\setlength{\headheight}{12.64723pt}
\addtolength{\topmargin}{-0.64723pt}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;正式文档通常让标题页和目录页&lt;strong&gt;不显示页眉&lt;/strong&gt;，所以在导言区最后把默认页面风格设为 &lt;code&gt;empty&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;\pagestyle{empty}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;等到正文开始再切换到 &lt;code&gt;\pagestyle{myfancy}&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;正文区：从标题页到致谢&lt;/h3&gt;
&lt;p&gt;下面按顺序讲解正文区的每一个部分。&lt;/p&gt;
&lt;h4&gt;1. 标题页&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;\begin{document}
\makeatletter
\let\thetitle\@title   % 把内部变量 \@title 的值保存到自定义命令 \thetitle
\makeatother

\maketitle
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;\@title&lt;/code&gt; 是 LaTeX 内部存储标题的变量，正常情况不能在导言区外直接访问。&lt;code&gt;\makeatletter&lt;/code&gt; / &lt;code&gt;\makeatother&lt;/code&gt; 之间的代码可以&quot;借用&quot;内部命令，把标题值捕获到 &lt;code&gt;\thetitle&lt;/code&gt;，供后续页眉使用。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;\maketitle&lt;/code&gt; 生成标题区域，包含标题、作者、日期。&lt;/p&gt;
&lt;h4&gt;2. 摘要与目录（前导页）&lt;/h4&gt;
&lt;p&gt;正式文档的前几页通常用罗马数字页码，与正文的阿拉伯数字页码区分：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;\section*{摘要}
\pagenumbering{Roman}
\addcontentsline{toc}{section}{摘要}
Hello, \LaTeX{}! 这是我的第一篇 \LaTeX{} 文档。

\zhlipsum[1-3]    % 占位文字，写正式内容时删掉

\newpage
\thispagestyle{empty}
\tableofcontents    % 生成目录
\listoffigures      % 生成插图清单（可选）
\listoftables       % 生成表格清单（可选）
&lt;/code&gt;&lt;/pre&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;code&gt;\section*{摘要}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;带 &lt;code&gt;*&lt;/code&gt; 的章节不会自动编号，也不会进入目录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\addcontentsline{toc}{section}{摘要}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;手动把&quot;摘要&quot;加入目录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\pagenumbering{Roman}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;页码切换为罗马数字&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\tableofcontents&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;自动从章节标题生成目录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\thispagestyle{empty}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;当前页（目录页）不显示页眉页脚&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;提示&lt;/strong&gt;：&lt;code&gt;\zhlipsum[1-3]&lt;/code&gt; 生成第 1~3 段中文占位文字，方便预览排版效果。写正式内容时删掉即可。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;3. 进入正文&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;\cleardoublepage          % 确保正文从奇数页开始
\pagestyle{myfancy}       % 切换到自定义页眉页脚
\setcounter{page}{1}      % 页码重置为 1
\pagenumbering{arabic}    % 切换为阿拉伯数字
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;\cleardoublepage&lt;/code&gt; 对于双面打印很重要——它保证新的一章始终从右页（奇数页）开始。如果只写电子版文档，用 &lt;code&gt;\newpage&lt;/code&gt; 也可以。&lt;/p&gt;
&lt;h4&gt;4. 章节层级&lt;/h4&gt;
&lt;p&gt;LaTeX 的章节命令从高到低依次为：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;\section{一级标题}
  \subsection{二级标题}
    \subsubsection{三级标题}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;编号自动递增，目录也自动更新。写计划书时，一般用 &lt;code&gt;\section&lt;/code&gt; 划分大板块（市场分析、财务预测等），&lt;code&gt;\subsection&lt;/code&gt; 细分具体内容。&lt;/p&gt;
&lt;h4&gt;5. 插入图片&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;\begin{figure}[htbp]
    \centering
    \includegraphics[width=0.8\textwidth]{img/ba.png}
    \caption{示例图片}\label{fig:example}
\end{figure}

如图~\ref{fig:example}所示，这是一个示例图片。
&lt;/code&gt;&lt;/pre&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\begin{figure}[htbp]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;浮动体环境，&lt;code&gt;[htbp]&lt;/code&gt; 控制图片放置优先级：here→top→bottom→单独一页&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\centering&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;图片居中&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\includegraphics[width=0.8\textwidth]{...}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;插入图片，宽度为页面文字宽度的 80%，高度等比例缩放&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\caption{...}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;图片标题，自动编号&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\label{fig:example}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;给图片贴标签，供交叉引用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\ref{fig:example}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;引用标签，编译后自动替换为对应编号&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;不可断行空格，防止&quot;如图&quot;和&quot;1&quot;分处两行&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;浮动体&lt;/strong&gt;是 LaTeX 的一大特色——图片和表格不会死板地卡在代码位置，而是浮动到排版最优的地方。用 &lt;code&gt;[htbp]&lt;/code&gt; 可以调整浮动偏好；如果坚持&quot;图片就要在这里&quot;，可以用 &lt;code&gt;[H]&lt;/code&gt;（需加载 &lt;code&gt;float&lt;/code&gt; 宏包）。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4&gt;6. 插入表格&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;\begin{table}[htbp]
    \centering
    \caption{示例表格}\label{tab:example}
    \begin{tabular}{ccc}
        \toprule
        列1 &amp;amp; 列2 &amp;amp; 列3 \\
        \midrule
        数据1 &amp;amp; 数据2 &amp;amp; 数据3 \\
        \bottomrule
    \end{tabular}
\end{table}
&lt;/code&gt;&lt;/pre&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;code&gt;\begin{tabular}{ccc}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;三列均居中（c=center），换成 &lt;code&gt;l&lt;/code&gt; 左对齐、&lt;code&gt;r&lt;/code&gt; 右对齐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\toprule&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;表格顶部粗线（booktabs 宏包提供）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\midrule&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;表头与数据之间的细线&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\bottomrule&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;表格底部粗线&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;amp;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;列分隔符&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;\\&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;行分隔符&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;三线表&lt;/strong&gt;是学术写作的标准格式，简洁干净。&lt;code&gt;booktabs&lt;/code&gt; 宏包提供的就是这种风格，比 LaTeX 默认的 &lt;code&gt;\hline&lt;/code&gt; 好看得多。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;交叉引用表格的语法与图片完全一致：&lt;code&gt;\label{tab:xxx}&lt;/code&gt; 贴标签，&lt;code&gt;\ref{tab:xxx}&lt;/code&gt; 引用。建议用前缀区分类型——&lt;code&gt;fig:&lt;/code&gt; 表示图，&lt;code&gt;tab:&lt;/code&gt; 表示表，&lt;code&gt;eq:&lt;/code&gt; 表示公式。&lt;/p&gt;
&lt;h4&gt;7. 致谢&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;\newpage
\thispagestyle{empty}
\section*{致谢}
\addcontentsline{toc}{section}{致谢}
感谢所有支持我的人！

\end{document}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;和摘要一样，&lt;code&gt;\section*&lt;/code&gt; 确保不自动编号，&lt;code&gt;\addcontentsline&lt;/code&gt; 手动加入目录。&lt;/p&gt;
&lt;h3&gt;完整模板&lt;/h3&gt;
&lt;p&gt;把以上所有片段组合起来，就是一份可以直接编译的计划书模板：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;\documentclass[oneside]{ctexart}
\title{Hello, \LaTeX!}
\author{Mint}
\date{\today}

\usepackage{zhlipsum}
\usepackage{graphicx}
\usepackage{booktabs}
\usepackage{caption}
\usepackage{fancyhdr}
\usepackage[bookmarksnumbered=true, colorlinks=true, linkcolor=black]{hyperref}

\onecolumn
\captionsetup[table]{skip=10pt}

\fancypagestyle{myfancy}{%
    \pagestyle{fancy}
    \lhead{\leftmark}
    \chead{\thetitle}
    \rhead{\rightmark}
    \lfoot{}
    \cfoot{\thepage}
    \rfoot{}
}
\setlength{\headheight}{12.64723pt}
\addtolength{\topmargin}{-0.64723pt}
\pagestyle{empty}

\begin{document}
\makeatletter
\let\thetitle\@title
\makeatother

\maketitle

\section*{摘要}
\pagenumbering{Roman}
\addcontentsline{toc}{section}{摘要}
Hello, \LaTeX{}! 这是我的第一篇 \LaTeX{} 文档。

\zhlipsum[1-3]

\newpage
\thispagestyle{empty}
\tableofcontents
\listoffigures
\listoftables

\cleardoublepage
\pagestyle{myfancy}
\setcounter{page}{1}
\pagenumbering{arabic}

\section{第一章}
\subsection{概述}
\subsubsection{如何xxx}

\begin{figure}[htbp]
    \centering
    \includegraphics[width=0.8\textwidth]{img/ba.png}
    \caption{示例图片}\label{fig:example}
\end{figure}

如图~\ref{fig:example}所示，这是一个示例图片。

\zhlipsum[1-4]

\subsubsection{然后xxx}
\zhlipsum[1-4]

\begin{table}[htbp]
    \centering
    \caption{示例表格}\label{tab:example}
    \begin{tabular}{ccc}
        \toprule
        列1 &amp;amp; 列2 &amp;amp; 列3 \\
        \midrule
        数据1 &amp;amp; 数据2 &amp;amp; 数据3 \\
        \bottomrule
    \end{tabular}
\end{table}
如表~\ref{tab:example}所示，这是一个示例表格。

\zhlipsum[1-8]

\newpage
\thispagestyle{empty}
\section*{致谢}
\addcontentsline{toc}{section}{致谢}
感谢所有支持我的人！

\end{document}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;使用提示&lt;/strong&gt;：复制模板后，把 &lt;code&gt;\zhlipsum[...]&lt;/code&gt; 替换成你的实际内容，调整图片路径，修改章节标题——一份格式规范的计划书就出来了。后续协作时，团队成员只需要关注正文内容，格式由 LaTeX 自动搞定，再也不用反复手动排版了。&lt;/p&gt;
&lt;/blockquote&gt;
</content:encoded></item><item><title>ESP32 并发 TLS 握手导致间歇性连接失败的排查与优化</title><link>https://www.mintlab.top/posts/lark-solution/tls-connection-reuse-analysis/</link><guid isPermaLink="true">https://www.mintlab.top/posts/lark-solution/tls-connection-reuse-analysis/</guid><description>ESP32 上多个 HTTPS 任务并发 TLS 握手时，偶发 `PK verify failed` 签名验证失败。本文从内部 SRAM 峰值争抢的角度定位根因，并通过 HTTP 连接持久化复用将 TLS 握手频率降低 99.9%，彻底消除问题。</description><pubDate>Sun, 29 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;环境&lt;/strong&gt;: ESP-IDF v5.5 / ESP32 (4MB PSRAM) / mbedTLS&lt;br /&gt;
&lt;strong&gt;现象&lt;/strong&gt;: HTTPS POST 传感器数据时随机出现 &lt;code&gt;PK verify failed with error 0x4290&lt;/code&gt;，但同一请求有时成功有时失败&lt;br /&gt;
&lt;strong&gt;根因&lt;/strong&gt;: 多任务并发 TLS 握手争抢内部 SRAM，RSA 签名验证因内存不足而失败&lt;br /&gt;
&lt;strong&gt;修复&lt;/strong&gt;: HTTP 连接持久化复用 + 请求重试&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;一、问题现象&lt;/h1&gt;
&lt;p&gt;设备运行期间，传感器数据每 60 秒上报一次，日志中周期性出现以下错误：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;E esp-x509-crt-bundle: PK verify failed with error 0x4290
E esp-x509-crt-bundle: Certificate matched but signature verification failed
E esp-x509-crt-bundle: Failed to verify certificate
E esp-tls-mbedtls: mbedtls_ssl_handshake returned -0x3000
E esp-tls: Failed to open new connection
E HTTP_CLIENT: Connection failed, sock &amp;lt; 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;奇怪的是，&lt;strong&gt;同一个接口、同样的数据、同一台服务器&lt;/strong&gt;——上一分钟还成功返回 200，下一分钟就 TLS 握手失败。跟服务器证书本身没有关系（证书有效期内，其他时段正常）。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二、系统架构与并发模型&lt;/h1&gt;
&lt;p&gt;设备上同时运行着多个需要 HTTPS 的任务：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────┐     ┌──────────────────────┐     ┌──────────────────┐
│  remote_log (rlog)  │     │ sensor_data_transmit │     │   websocket_task │
│  每 ~1s POST 日志    │     │  每 60s POST 传感器   │     │   持久 WSS 连接   │
│  独立 HTTP client   │     │  全局 HTTP client     │     │                  │
└────────┬────────────┘     └────────┬─────────────┘     └──────────────────┘
         │                           │
         ▼                           ▼
   ┌───────────┐              ┌───────────┐
   │ TLS 握手   │              │ TLS 握手  │
   │ (mbedTLS) │              │ (mbedTLS) │
   └─────┬─────┘              └─────┬─────┘
         │                          │
         ▼                          ▼
   ┌────────────────────────────────────────┐
   │        ESP32 内部 SRAM (~307 KB)        │
   │     空闲仅 35~70 KB (11%~22%)           │
   └────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键数据来自健康监控日志：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[内存] 堆=3318188/4501391B(73%) 最低=3279536B
       内部=35691/307087B(11%) | PSRAM=3285836/4194304B(78%)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;虽然总堆空间（含 PSRAM）还剩 73%，但&lt;strong&gt;内部 SRAM 只剩 11%（~35KB）&lt;/strong&gt;。而 mbedTLS 的密钥运算必须使用内部 SRAM（不能用 PSRAM），一次 TLS 握手的 RSA 签名验证临时需要 20~40KB 内部 SRAM。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;三、根因分析&lt;/h1&gt;
&lt;h3&gt;3.1 旧代码：每次 flush 都新建 TLS 连接&lt;/h3&gt;
&lt;p&gt;修改前的 &lt;code&gt;remote_log_http_post()&lt;/code&gt; 实现：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ❌ 旧代码：每次调用都 init → TLS握手 → perform → close → cleanup
static esp_err_t remote_log_http_post(const char *data, int len)
{
    esp_http_client_config_t cfg = {
        .url = s_upload_url,
        .method = HTTP_METHOD_POST,
        .crt_bundle_attach = esp_crt_bundle_attach,
        .keep_alive_enable = true,   // 虽然开了 keep-alive，但...
    };

    // 每次都创建新 client
    esp_http_client_handle_t client = esp_http_client_init(&amp;amp;cfg);
    
    esp_http_client_set_header(client, &quot;Content-Type&quot;, &quot;text/plain&quot;);
    esp_http_client_set_header(client, &quot;Authorization&quot;, s_secret);
    esp_http_client_set_post_field(client, data, len);
    
    // perform 内部会做完整 TLS 握手（TCP → TLS → HTTP）
    esp_err_t err = esp_http_client_perform(client);

    // 用完直接销毁
    esp_http_client_close(client);
    esp_http_client_cleanup(client);  // ← 连接和 TLS 上下文全部释放
    return err;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;问题&lt;/strong&gt;：虽然配置了 &lt;code&gt;keep_alive_enable = true&lt;/code&gt;，但每次调用结束都 &lt;code&gt;cleanup&lt;/code&gt; 销毁了 client 对象，&lt;strong&gt;下次调用只能重新创建、重新握手&lt;/strong&gt;。&lt;code&gt;keep_alive&lt;/code&gt; 形同虚设。&lt;/p&gt;
&lt;p&gt;由于 flush 任务每秒运行一次，这意味着&lt;strong&gt;每秒都会进行一次完整的 TLS 握手&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;3.2 时间线碰撞&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;时间轴 ──────────────────────────────────────►

rlog:   [TLS握手][TLS握手][TLS握手]...[TLS握手][TLS握手]...
               每 ~1-2 秒一次             ↑
                                         │ 恰好重叠
sensor:                                  │
         ────────── 60s ────────── [TLS握手] ← 失败!
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;每分钟总有一次传感器 POST 的 TLS 握手和 rlog 的握手&lt;strong&gt;时间重叠&lt;/strong&gt;。两个并发握手同时向内部 SRAM 申请临时缓冲区：&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;一次 TLS 握手 RSA 验签&lt;/td&gt;
&lt;td&gt;~20-40 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内部 SRAM 空闲&lt;/td&gt;
&lt;td&gt;~35-70 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;两次并发握手总需求&lt;/td&gt;
&lt;td&gt;~40-80 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;当内部 SRAM 处于低位（35KB）时，两个并发握手总需求超过可用空间，后发起的那个就会在 RSA 签名验证阶段因 &lt;code&gt;malloc&lt;/code&gt; 失败而报错 &lt;code&gt;0x4290&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;3.3 为什么&quot;有时成功有时失败&quot;?&lt;/h2&gt;
&lt;p&gt;这是&lt;strong&gt;概率性&lt;/strong&gt;的，取决于两个条件：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;时间是否重叠&lt;/strong&gt;：rlog 每 ~1s 一次握手，sensor 每 60s 一次。两者碰撞概率大约 30-50%（取决于握手耗时 ~0.5-1s）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内部 SRAM 当时的空闲量&lt;/strong&gt;：其他任务（WiFi、WebSocket、Camera）的内存占用是波动的，空闲量在 35-70KB 间浮动&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;两个条件同时满足（重叠 + 内存低位）时失败，否则成功。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;四、修复方案&lt;/h1&gt;
&lt;h2&gt;4.1 核心修复：HTTP 连接持久化复用&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;// ✅ 新代码：持久化 client，复用 TLS 连接
static esp_http_client_handle_t s_persistent_client = NULL;

static esp_http_client_handle_t rlog_get_client(void)
{
    if (s_persistent_client != NULL) {
        return s_persistent_client;  // 直接返回已有 client
    }

    // 仅首次（或重建时）创建
    esp_http_client_config_t cfg = {
        .url = s_upload_url,
        .method = HTTP_METHOD_POST,
        .timeout_ms = RLOG_HTTP_TIMEOUT_MS,
        .crt_bundle_attach = esp_crt_bundle_attach,
        .buffer_size = 1024,
        .keep_alive_enable = true,
    };

    s_persistent_client = esp_http_client_init(&amp;amp;cfg);
    if (s_persistent_client != NULL) {
        esp_http_client_set_header(s_persistent_client, &quot;Content-Type&quot;, &quot;text/plain&quot;);
        esp_http_client_set_header(s_persistent_client, &quot;Authorization&quot;, s_secret);
    }
    return s_persistent_client;
}

static esp_err_t remote_log_http_post(const char *data, int len)
{
    esp_http_client_handle_t client = rlog_get_client();
    if (client == NULL) return ESP_FAIL;

    esp_http_client_set_post_field(client, data, len);

    // perform 在 keep-alive 连接上直接发 HTTP 请求，无需 TLS 握手
    esp_err_t err = esp_http_client_perform(client);

    if (err != ESP_OK) {
        // 连接断开时销毁，下次调用会自动重建
        ESP_LOGW(TAG, &quot;持久连接失败, 将重建: %s&quot;, esp_err_to_name(err));
        rlog_destroy_client();
    }
    // 注意：成功时不 close/cleanup，连接保持
    return err;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4.2 关键区别对比&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;&lt;strong&gt;client 生命周期&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;每次 flush 创建并销毁&lt;/td&gt;
&lt;td&gt;首次创建，长期持有&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TLS 握手频率&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;每秒 1 次&lt;/td&gt;
&lt;td&gt;仅首次 + 断线重连&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;内部 SRAM 峰值&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;每秒出现 20-40KB 尖峰&lt;/td&gt;
&lt;td&gt;稳态仅 ~5KB（TLS 上下文）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;与 sensor 任务碰撞概率&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~30-50%&lt;/td&gt;
&lt;td&gt;≈0%（几乎无握手）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;Certificate validated&lt;/code&gt; 日志&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;每 2 秒一条（刷屏）&lt;/td&gt;
&lt;td&gt;仅启动时 1 条&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;网络开销&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;每次 TCP+TLS 三次握手&lt;/td&gt;
&lt;td&gt;HTTP keep-alive 复用&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;4.3 &lt;code&gt;esp_http_client_perform&lt;/code&gt; 的内部行为&lt;/h2&gt;
&lt;p&gt;理解这个修复的关键在于 &lt;code&gt;esp_http_client_perform()&lt;/code&gt; 的内部逻辑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;esp_http_client_perform(client)
    │
    ├── 连接未建立？
    │   ├── DNS 解析
    │   ├── TCP connect (三次握手)
    │   ├── TLS handshake ← 内存密集操作!
    │   │   ├── ClientHello → ServerHello
    │   │   ├── 服务器证书验证 (x509 bundle)
    │   │   ├── RSA/ECDSA 签名验证 ← 需要 20-40KB 内部 SRAM
    │   │   └── 密钥交换 → Finished
    │   └── 连接就绪
    │
    ├── 连接已建立且 keep-alive？
    │   └── 直接跳到 HTTP 请求 ← 零额外内存开销!
    │
    ├── 发送 HTTP 请求
    ├── 接收 HTTP 响应
    └── 返回结果
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;旧代码每次都走左边分支（完整握手），新代码只有首次走左边，后续全部走右边（直接发请求）。&lt;/p&gt;
&lt;h2&gt;4.4 辅助修复：传感器 POST 增加重试&lt;/h2&gt;
&lt;p&gt;即使持久化连接大幅降低了碰撞概率，极端情况下仍可能遇到（比如持久连接断线重建时恰好碰上 sensor POST）。因此增加重试兜底：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#define SENSOR_POST_MAX_RETRY   3
#define SENSOR_POST_RETRY_MS    3000  // 重试间隔 3 秒

// 带重试的 HTTPS POST
int ret_code = ESP_FAIL;
for (int attempt = 0; attempt &amp;lt; SENSOR_POST_MAX_RETRY; attempt++) {
    ret_code = WifiSecurityRequest(&quot;https://lark.mintlab.top&quot;,
                                   &quot;/api/sensors&quot;, 443,
                                   WS_CLINENT_METHOD_POST, post_data, NULL);
    if (ret_code == ESP_OK) break;
    
    ESP_LOGW(TAG, &quot;传感器上报失败 (%d/%d), %dms 后重试...&quot;,
             attempt + 1, SENSOR_POST_MAX_RETRY, SENSOR_POST_RETRY_MS);
    vTaskDelay(pdMS_TO_TICKS(SENSOR_POST_RETRY_MS));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;3 秒的重试间隔不是随意选的——它足够让另一个 TLS 握手完成并释放内部 SRAM。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;五、实测验证&lt;/h1&gt;
&lt;p&gt;部署修复后的固件 (v0.7.1)，通过 OTA 升级并观察日志。&lt;/p&gt;
&lt;h2&gt;5.1 启动阶段：仍有碰撞，但重试兜住了&lt;/h2&gt;
&lt;p&gt;OTA 重启后，前 5 秒内 OTA 检查、rlog 首次 flush、sensor 首次 POST &lt;strong&gt;三个任务几乎同时发起 TLS 握手&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(5050) ota:     Checking for firmware update    → TLS 握手
(5070) sensor:  POST /api/sensors               → TLS 握手
(5070) rlog:    首次 flush                       → TLS 握手（持久 client 首次创建）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;sensor 的前两次握手失败（&lt;code&gt;0x4290&lt;/code&gt; 和 &lt;code&gt;0x7F00&lt;/code&gt;），第三次重试成功——此时 OTA 和 rlog 已完成握手释放了内部 SRAM：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(5240)  task: 传感器上报失败 (1/3), 3000ms 后重试...
(8320)  task: 传感器上报失败 (2/3), 3000ms 后重试...
(12190) http_client: POST .../api/sensors -&amp;gt; 200  ✅ 第三次成功
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;重试机制达到预期效果&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;为进一步消除启动碰撞，在 sensor 任务入口增加 5 秒延迟，错开与 OTA/rlog 的首次握手窗口：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;void sensor_data_transmit_task(void *pvParameter)
{
    // 启动后延迟 5 秒，错开 OTA/rlog 首次 TLS 握手的内存高峰
    vTaskDelay(pdMS_TO_TICKS(5000));
    // ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5.2 稳态运行：问题彻底消除&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;修复前 (v0.7.0)&lt;/th&gt;
&lt;th&gt;修复后 (v0.7.1)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Certificate validated&lt;/code&gt; 频率&lt;/td&gt;
&lt;td&gt;每 ~2 秒一条（刷屏）&lt;/td&gt;
&lt;td&gt;启动后消失&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sensor POST 成功率&lt;/td&gt;
&lt;td&gt;~60-70%（每分钟偶发失败）&lt;/td&gt;
&lt;td&gt;100%（稳态零重试）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rlog &lt;code&gt;consec_fail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;偶发 1+&lt;/td&gt;
&lt;td&gt;稳定 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rlog stats 间隔&lt;/td&gt;
&lt;td&gt;~20 秒（退避中）&lt;/td&gt;
&lt;td&gt;~10 秒（正常，无退避）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;关键证据——稳态下 &lt;code&gt;Certificate validated&lt;/code&gt; 日志&lt;strong&gt;完全消失&lt;/strong&gt;，证明 rlog 持久连接复用生效，不再每秒做 TLS 握手：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;00:29:28  remote_log: [stats] sent=6746 B, consec_fail=0
00:29:38  remote_log: [stats] sent=6879 B, consec_fail=0   ← +133B, 无握手
00:29:48  remote_log: [stats] sent=7012 B, consec_fail=0   ← +133B, 无握手
00:30:22  http_client: POST .../api/sensors -&amp;gt; 200          ← 一次成功，无重试
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;5.3 实际数据对比&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;TLS 握手次数&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;修复前: ~1次/秒 × 3600秒/小时 = 3600 次/小时
修复后: 1次/启动 + 偶发重建 ≈ 1-5 次/小时
减少: 99.9%
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;内部 SRAM&lt;/strong&gt;（来自 sys_mon 实测）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;修复前: 内部=35691/307087B (11%)  ← TLS 握手后的谷值
修复后: 内部=38731/307079B (12%)  ← 稳态值，无尖峰波动
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;稳态下内部 SRAM 不再出现周期性尖峰，彻底消除了并发碰撞的条件。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;六、经验总结&lt;/h1&gt;
&lt;h2&gt;6.1 &lt;code&gt;keep_alive_enable = true&lt;/code&gt; 不是万能的&lt;/h2&gt;
&lt;p&gt;ESP-IDF 的 &lt;code&gt;keep_alive_enable&lt;/code&gt; 只是在 HTTP 层面告诉服务器&quot;不要关闭连接&quot;，但如果你在客户端代码里每次都 &lt;code&gt;cleanup&lt;/code&gt; 销毁了 client 对象，keep-alive 就毫无意义——对象都没了，哪来的连接可以复用？&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;正确用法&lt;/strong&gt;: 创建一次 client，多次调用 &lt;code&gt;perform&lt;/code&gt;，只在不再需要时 &lt;code&gt;cleanup&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;6.2 ESP32 的&quot;两种内存&quot;陷阱&lt;/h2&gt;
&lt;p&gt;ESP32 有 PSRAM 时总堆空间看起来很大（4.5MB），但 mbedTLS 加密运算只能用&lt;strong&gt;内部 SRAM&lt;/strong&gt;（~307KB），而系统本身（WiFi、freeRTOS、任务栈等）已占用大部分。实际可用于 TLS 握手的空间非常紧张。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;esp_get_free_heap_size()&lt;/code&gt; 返回的数字包含 PSRAM，容易给人&quot;内存充裕&quot;的错觉。排查 TLS 问题时要看 &lt;code&gt;heap_caps_get_free_size(MALLOC_CAP_INTERNAL)&lt;/code&gt;。&lt;/p&gt;
&lt;h2&gt;6.3 间歇性 TLS 失败的排查思路&lt;/h2&gt;
&lt;p&gt;当 TLS 握手&quot;有时成功有时失败&quot;时：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;不是证书问题&lt;/strong&gt;——证书问题是 100% 失败，不会间歇&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;code&gt;PK verify failed&lt;/code&gt;，说明 RSA 运算分配不到足够内存&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检查并发&lt;/strong&gt;——找出所有同时做 TLS 的任务，计算峰值内存需求&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;6.4 连接复用是嵌入式 HTTPS 的最佳实践&lt;/h2&gt;
&lt;p&gt;在资源受限的嵌入式设备上，每个 TLS 连接的建立成本很高：&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;1-3 秒（含 TCP + TLS 握手）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内部 SRAM 峰值&lt;/td&gt;
&lt;td&gt;20-40 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU&lt;/td&gt;
&lt;td&gt;RSA-2048 验签约 0.5-1 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;对于周期性请求同一服务器的场景，&lt;strong&gt;连接复用&lt;/strong&gt;应该是默认选择，而不是每次新建。&lt;/p&gt;
</content:encoded></item><item><title>SQLite 性能优化实战：从 70ms 到 1ms 的日志写入之旅</title><link>https://www.mintlab.top/posts/lark-solution/sqlite-wal%E4%BC%98%E5%8C%96%E6%80%BB%E7%BB%93/</link><guid isPermaLink="true">https://www.mintlab.top/posts/lark-solution/sqlite-wal%E4%BC%98%E5%8C%96%E6%80%BB%E7%BB%93/</guid><description>在 IoT 项目 Lark 中，ESP32 设备每秒向后端上报日志，FastAPI + SQLite 的写入延迟高达 70ms，优化后降至 1ms 以内。本文详解 SQLite 的 journal_mode、synchronous 机制原理，以及 ORM 层的批量写入优化。</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;背景&lt;/h1&gt;
&lt;p&gt;Lark 是一个 ESP32 物联网监控平台，嵌入式端通过 HTTP POST 每秒批量上报 &lt;code&gt;ESP_LOG&lt;/code&gt; 日志到 FastAPI 后端，存入 SQLite。&lt;/p&gt;
&lt;p&gt;在优化前，日志上报接口的处理时间波动很大：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST /api/logs - 200 OK - 处理时间: 74.46ms
POST /api/logs - 200 OK - 处理时间: 28.54ms
POST /api/logs - 200 OK - 处理时间: 17.43ms
POST /api/logs - 200 OK - 处理时间: 71.78ms
POST /api/logs - 200 OK - 处理时间: 78.86ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;平均约 40ms，峰值接近 80ms。对于一个简单的日志写入操作来说，这个速度显然不正常。&lt;/p&gt;
&lt;h1&gt;一、SQLite Journal Mode 详解&lt;/h1&gt;
&lt;p&gt;SQLite 通过 &lt;strong&gt;日志(Journal)&lt;/strong&gt; 机制保证事务的原子性——要么完全提交，要么完全回滚。&lt;code&gt;journal_mode&lt;/code&gt; 控制的就是这个日志的工作方式。&lt;/p&gt;
&lt;h2&gt;1.1 DELETE 模式（默认）&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;PRAGMA journal_mode = DELETE;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是 SQLite 的默认模式，工作流程：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;写入事务开始
  ├─ 1. 创建 rollback journal 文件（.db-journal）
  ├─ 2. 将要修改的原始页拷贝到 journal 文件
  ├─ 3. fsync journal 文件 → 确保落盘
  ├─ 4. 修改数据库文件中的页
  ├─ 5. fsync 数据库文件 → 确保落盘
  └─ 6. 删除 journal 文件  → 事务完成标志
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;关键痛点&lt;/strong&gt;：每次 commit 需要 &lt;strong&gt;2 次 fsync&lt;/strong&gt;（步骤 3 和 5）。&lt;code&gt;fsync&lt;/code&gt; 是告诉操作系统&quot;把内存缓冲区的数据真正写入物理磁盘&quot;，这是整个流程中最慢的操作。在 HDD 上一次 fsync 约 10-30ms，SSD 上约 1-10ms。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;读写互斥&lt;/strong&gt;：写事务持有排他锁期间，所有读操作被阻塞。&lt;/p&gt;
&lt;h2&gt;1.2 WAL 模式（Write-Ahead Logging）&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;PRAGMA journal_mode = WAL;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;WAL 彻底反转了写入逻辑：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                      ┌──────────────┐
                      │  数据库文件    │  ← 只读（checkpoint 时才写入）
                      │  .db         │
                      └──────────────┘
                             ▲
                             │ checkpoint（批量回写）
                             │
┌──────────────┐      ┌──────────────┐
│  共享内存索引  │ ←──→ │  WAL 日志文件  │ ← 所有写入先到这里
│  .db-shm     │      │  .db-wal     │
└──────────────┘      └──────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;WAL 的写入流程&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;写入事务开始
  ├─ 1. 将修改后的页 追加(append) 到 WAL 文件末尾
  ├─ 2. fsync WAL 文件（取决于 synchronous 设置）
  └─ 3. 完成 ← 没有第二次 fsync，没有删除操作
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;核心优势&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;特性&lt;/th&gt;
&lt;th&gt;DELETE&lt;/th&gt;
&lt;th&gt;WAL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;写入操作&lt;/td&gt;
&lt;td&gt;拷贝原始页 → 修改 → 删除 journal&lt;/td&gt;
&lt;td&gt;追加新页到 WAL 尾部&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fsync 次数&lt;/td&gt;
&lt;td&gt;2 次&lt;/td&gt;
&lt;td&gt;0~1 次&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;文件操作&lt;/td&gt;
&lt;td&gt;每次创建/删除 journal&lt;/td&gt;
&lt;td&gt;追加写入（顺序 I/O）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;为什么 WAL 更快？&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;顺序写入&lt;/strong&gt; vs 随机写入：WAL 只做 append，磁盘顺序 I/O 比随机 I/O 快 10-100 倍&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;单次 fsync&lt;/strong&gt; vs 两次：减少一半的磁盘同步等待&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;无文件创建/删除&lt;/strong&gt;：省去了 journal 文件的 create/unlink 系统调用&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;WAL 的 Checkpoint 机制&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;WAL 文件不会无限增长。当 WAL 达到 &lt;code&gt;wal_autocheckpoint&lt;/code&gt;（默认 1000 页 ≈ 4MB）时，SQLite 自动将 WAL 中的修改回写到数据库主文件。这个过程叫 &lt;strong&gt;checkpoint&lt;/strong&gt;，通常在读操作时顺带完成，不影响写入性能。&lt;/p&gt;
&lt;h2&gt;1.3 其他模式（简要）&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;strong&gt;TRUNCATE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;类似 DELETE，但截断 journal 而非删除（省一次 unlink 系统调用）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;PERSIST&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;journal 文件创建后不删除，header 置零表示无效（省 create + unlink）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MEMORY&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;journal 存在内存中，断电即丢（不安全，仅测试用）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;OFF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;不创建 journal（无事务保护，极端场景才用）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;对于服务端应用，&lt;strong&gt;WAL 是绝大多数场景的最优选择&lt;/strong&gt;。&lt;/p&gt;
&lt;h1&gt;二、Synchronous 级别详解&lt;/h1&gt;
&lt;p&gt;&lt;code&gt;synchronous&lt;/code&gt; 控制 SQLite 调用 &lt;code&gt;fsync&lt;/code&gt; 的频率，直接影响&quot;数据安全性 vs 写入速度&quot;的权衡。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PRAGMA synchronous = FULL | NORMAL | OFF;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;各级别对比&lt;/h2&gt;
&lt;h3&gt;FULL（默认值 = 2）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;每次 commit:
  → fsync journal/WAL 文件  ✓
  → fsync 数据库文件         ✓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;最安全&lt;/strong&gt;：即使操作系统崩溃或突然断电，数据库也不会损坏，不会丢失已提交的事务。代价是每次写入都要等磁盘完成物理写入。&lt;/p&gt;
&lt;h3&gt;NORMAL（值 = 1）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;每次 commit:
  → fsync WAL 文件          ✓（关键时刻才做）
  → fsync 数据库文件         ✗（交给 OS 决定何时刷盘）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;在 WAL 模式下的安全性分析&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;进程崩溃（segfault / kill）&lt;/strong&gt;：不丢数据 ✓ —— OS 缓冲区会自动刷盘&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;操作系统崩溃（内核 panic）&lt;/strong&gt;：可能丢失最后一个事务 —— OS 缓冲区中未刷盘的数据丢失&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;断电&lt;/strong&gt;：可能丢失最后一个事务 —— 同上&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据库损坏&lt;/strong&gt;：不会 ✓ —— WAL 的校验和机制保护完整性&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;关键理解&lt;/strong&gt;：NORMAL 在 WAL 模式下，丢的只是&quot;最后一个未刷盘的事务&quot;，数据库完整性始终有保障。对于 IoT 日志场景，丢失一条日志完全可接受。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;OFF（值 = 0）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;每次 commit:
  → fsync journal/WAL 文件  ✗
  → fsync 数据库文件         ✗
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;最快但最危险&lt;/strong&gt;：完全不调用 fsync，任何非正常关机都可能导致&lt;strong&gt;数据库损坏&lt;/strong&gt;。生产环境不推荐。&lt;/p&gt;
&lt;h2&gt;直觉理解&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;       安全性                     速度
 ◄─────────────────────────────────────────►
 OFF          NORMAL              FULL
 │             │                    │
 不 fsync      关键时刻 fsync       每次都 fsync
 断电可损坏    断电丢最后1个事务     绝对不丢
 ~0.1ms       ~1ms                 ~10-70ms
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;三、ORM 层优化：bulk_insert_mappings&lt;/h1&gt;
&lt;p&gt;SQLAlchemy 的标准 &lt;code&gt;add_all()&lt;/code&gt; 会为每个对象维护 identity map（身份映射），跟踪对象状态变化。这对日志这种&quot;写入后不再修改&quot;的场景是纯浪费。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 优化前：add_all — 创建 ORM 对象 + identity map 跟踪
entries = []
for item in parsed:
    entries.append(M_DeviceLog(
        device_id=device_id,
        timestamp=timestamp,
        level=item[&quot;level&quot;],
        ...
    ))
db.add_all(entries)  # 每个对象进入 Session 跟踪
db.commit()

# 优化后：bulk_insert_mappings — 直接插入字典，跳过 ORM 跟踪
mappings = [
    {
        &quot;device_id&quot;: device_id,
        &quot;timestamp&quot;: timestamp,
        &quot;level&quot;: item[&quot;level&quot;],
        ...
    }
    for item in parsed
]
db.bulk_insert_mappings(M_DeviceLog, mappings)  # 直接生成 INSERT 语句
db.commit()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;bulk_insert_mappings&lt;/code&gt; 跳过了：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;对象实例化（不创建 Python ORM 对象）&lt;/li&gt;
&lt;li&gt;Identity map 注册（不跟踪对象状态）&lt;/li&gt;
&lt;li&gt;属性事件触发（不触发 attribute event）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于批量插入场景，性能提升约 2-5 倍。&lt;/p&gt;
&lt;h1&gt;四、实际优化效果&lt;/h1&gt;
&lt;h2&gt;项目配置&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# Db.py — SQLAlchemy 引擎配置
from sqlalchemy import create_engine, event

engine = create_engine(
    &quot;sqlite:///../database/lark.db&quot;,
    connect_args={&quot;check_same_thread&quot;: False}
)

@event.listens_for(engine, &quot;connect&quot;)
def _set_sqlite_pragma(dbapi_conn, connection_record):
    cursor = dbapi_conn.cursor()
    cursor.execute(&quot;PRAGMA journal_mode=WAL&quot;)
    cursor.execute(&quot;PRAGMA synchronous=NORMAL&quot;)
    cursor.close()
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;基准测试结果&lt;/h2&gt;
&lt;p&gt;测试环境：Linux, SQLite 3.x, SSD&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;单条日志写入（50 次取样）&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;优化前 (DELETE + FULL)&lt;/th&gt;
&lt;th&gt;优化后 (WAL + NORMAL)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;最小值&lt;/td&gt;
&lt;td&gt;~17ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.15ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;平均值&lt;/td&gt;
&lt;td&gt;~40ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.95ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;最大值&lt;/td&gt;
&lt;td&gt;~79ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;18.93ms&lt;/strong&gt;（首次冷启动）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;6 条日志批量写入（50 次取样）&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;优化后 (WAL + NORMAL + bulk_insert)&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;strong&gt;0.21ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;平均值&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.27ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;最大值&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.52ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;平均写入延迟从 &lt;strong&gt;40ms 降至不到 1ms&lt;/strong&gt;，提升约 &lt;strong&gt;40 倍&lt;/strong&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;数据库文件变化&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;优化前:
  lark.db           360K    ← 数据库主文件
  lark.db-journal   (临时)  ← 每次写入创建/删除

优化后:
  lark.db           360K    ← 数据库主文件（checkpoint 时更新）
  lark.db-shm        32K    ← 共享内存索引（WAL 读取加速）
  lark.db-wal       4.0M    ← WAL 日志（自动 checkpoint 回收）
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;五、适用场景总结&lt;/h1&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;IoT 日志 / 传感器数据&lt;/td&gt;
&lt;td&gt;WAL + NORMAL&lt;/td&gt;
&lt;td&gt;高频写入，偶尔丢一条可接受&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户账号 / 配置数据&lt;/td&gt;
&lt;td&gt;WAL + FULL&lt;/td&gt;
&lt;td&gt;低频写入，数据不能丢&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;金融交易 / 订单&lt;/td&gt;
&lt;td&gt;WAL + FULL（或用 PostgreSQL）&lt;/td&gt;
&lt;td&gt;必须绝对安全&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;临时缓存 / 测试&lt;/td&gt;
&lt;td&gt;WAL + OFF 或 MEMORY&lt;/td&gt;
&lt;td&gt;速度优先，数据不重要&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h1&gt;六、注意事项&lt;/h1&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;WAL 模式是持久的&lt;/strong&gt;：设置一次后写入数据库文件头，下次打开自动生效。但 &lt;code&gt;synchronous&lt;/code&gt; 需要每次连接时设置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;网络文件系统不兼容&lt;/strong&gt;：WAL 依赖共享内存（mmap），NFS/SMB 上不能用 WAL 模式。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WAL 文件增长&lt;/strong&gt;：高频写入时 &lt;code&gt;.db-wal&lt;/code&gt; 可能增长，但 &lt;code&gt;wal_autocheckpoint&lt;/code&gt;（默认 1000 页 ≈ 4MB）会自动回收。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;备份注意&lt;/strong&gt;：备份 SQLite 数据库时，必须同时复制 &lt;code&gt;.db&lt;/code&gt;、&lt;code&gt;.db-shm&lt;/code&gt;、&lt;code&gt;.db-wal&lt;/code&gt; 三个文件，或使用 &lt;code&gt;VACUUM INTO&lt;/code&gt; 命令。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;只读场景无收益&lt;/strong&gt;：WAL 主要优化写入性能，纯读场景两种模式差异不大。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Lark 项目的完整优化还包括日志解析改造：将 ESP32 批量文本拆分为单条存储，自动解析日志等级(E/W/I/D/V)、组件标签(tag)、设备 tick，支持按等级/标签/时间范围检索。这属于业务层改造，不在本文讨论范围。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>ESP32 日志远程传输系统：从设计到实现</title><link>https://www.mintlab.top/posts/lark-solution/remote-log-design/</link><guid isPermaLink="true">https://www.mintlab.top/posts/lark-solution/remote-log-design/</guid><description>嵌入式开发中，调试通常依赖串口输出。但设备部署到现场后，串口不再可用，设备出现异常时开发者只能盲猜。本文以一个 ESP32 物联网项目为例，完整记录远程日志系统的设计演进——从最初的 WebSocket 方案到最终的 HTTP POST 方案，以及如何在 WiFi 连接之前就开始捕获日志。</description><pubDate>Sat, 28 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;基于 ESP-IDF v5.x + FreeRTOS，运行于 ESP32 平台。&lt;/em&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;一、核心需求&lt;/h1&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;零侵入&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;不修改业务代码中的任何 &lt;code&gt;ESP_LOGx&lt;/code&gt; 调用，对现有代码完全透明&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;不阻塞&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;日志上传绝不能影响主业务（摄像头帧传输、传感器采集）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;不丢关键日志&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;WiFi 连接前的启动日志（最有诊断价值）也要尽量捕获&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;网络容错&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;断网、超时时优雅降级，不崩溃、不阻塞、不狂刷请求&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;最终实现的架构一句话概括：&lt;strong&gt;vprintf 钩子拦截所有日志 → 写入 16KB 环形缓冲区 → 独立任务每秒通过 HTTP POST 批量上传&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;下面从方案选型开始，逐步展开每个设计决策背后的思考。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;二、方案演进：从 WebSocket 到 HTTP POST&lt;/h1&gt;
&lt;h2&gt;2.1 初始方案：WebSocket 传输&lt;/h2&gt;
&lt;p&gt;最初的设计是新开一个独立的 WebSocket 连接专门传输日志：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ESP_LOGx ──► vprintf 钩子
               ├── 串口输出
               └── 环形缓冲区
                      │
                flush 任务 (每 1s)
                      │
                独立 WS 连接 ──► /api/stream/device/log
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;为什么必须「独立」WS，而不是复用现有连接？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;项目中已有一个 WS 连接用于传输摄像头视频帧，&lt;code&gt;buffer_size = 64KB&lt;/code&gt;，发送使用 &lt;code&gt;portMAX_DELAY&lt;/code&gt;（无限阻塞等待缓冲区可用）。如果日志和视频帧共用同一连接：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;视频帧很大，占满发送缓冲区时，日志发送被阻塞——违反「不阻塞」约束&lt;/li&gt;
&lt;li&gt;日志写入反过来也会抢占缓冲区，干扰帧率&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以即便用 WS，也必须是独立连接。但分析到这一步之后，WS 方案本身的问题也浮出水面了：&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;多一个 WS client 常驻内存 ~10KB RAM，ESP32 本就紧张&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;WS 的长连接优势在高频通信（&amp;gt;10次/秒）时才明显，&lt;strong&gt;1 秒 1 次完全用不上&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;后端复杂度&lt;/td&gt;
&lt;td&gt;需要维护 WS 会话状态，不方便水平扩展&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;2.2 最终方案：HTTP POST 传输&lt;/h2&gt;
&lt;p&gt;1 秒 1 次的频率，HTTP POST 完全足够。两种方案的正面对比：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;WebSocket&lt;/th&gt;
&lt;th&gt;HTTP POST&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;连接模型&lt;/td&gt;
&lt;td&gt;1 次握手，长连接常驻&lt;/td&gt;
&lt;td&gt;keep-alive 复用，或按需新建&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;后端复杂度&lt;/td&gt;
&lt;td&gt;需要 WS 会话管理&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;无状态，天然负载均衡&lt;/strong&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;strong&gt;每次请求独立，失败重试即可&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ESP32 内存&lt;/td&gt;
&lt;td&gt;~10KB 常驻&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;按需分配，用完即释放&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;代码复用&lt;/td&gt;
&lt;td&gt;需额外 WS client 封装&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;项目已有 HTTP POST 基础设施&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;最后一点尤其关键：项目中已经封装了 &lt;code&gt;WifiSecurityRequest()&lt;/code&gt; 用于传感器数据 POST 上传。日志上传可以复用完全相同的后端模式——一个普通的 REST API。&lt;/p&gt;
&lt;p&gt;不过这里有个&lt;strong&gt;线程安全的坑&lt;/strong&gt;：&lt;code&gt;WifiSecurityRequest()&lt;/code&gt; 内部使用一个全局单例 &lt;code&gt;WifiSecurityClient&lt;/code&gt; 句柄，传感器任务已经在用它。如果日志 flush 任务也调用它，两个 FreeRTOS 任务并发访问同一个 &lt;code&gt;esp_http_client_handle_t&lt;/code&gt;，必然崩溃。所以&lt;strong&gt;日志模块必须创建自己独立的 &lt;code&gt;esp_http_client&lt;/code&gt;&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;最终架构确定如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ESP_LOGx ──► vprintf 钩子 (阶段一: WiFi 之前安装)
               ├── vprintf() → 串口照常输出
               └── 环形缓冲区 16KB (非阻塞写入, 满则丢弃新日志)
                      │
                flush 任务 (阶段二: 网络就绪后启动, 每 1s)
                      │
                独立 esp_http_client ──► POST /api/logs
                      (线程安全, 不与全局 HTTP client 冲突)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;三、两阶段初始化：不漏掉 WiFi 前的日志&lt;/h1&gt;
&lt;h2&gt;3.1 矛盾：日志最有价值的时候，网络还没通&lt;/h2&gt;
&lt;p&gt;设备上电后的启动流程是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NVS init → WiFi 连接 → SNTP 时间同步 → OTA 检测 → WS 连接 → 业务运行
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;WiFi 连接阶段产生的日志（扫描、认证、DHCP）往往是排查网络问题的关键信息。但此时网络还没通，根本无法上传。如果等网络就绪后再安装日志钩子，这些最有价值的启动日志就全丢了。&lt;/p&gt;
&lt;h2&gt;3.2 解法：先攒着，等能发了一口气刷出去&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;code&gt;remote_log_early_init()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;NVS 之后、WiFi &lt;strong&gt;之前&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;创建 16KB 环形缓冲区 + 安装 vprintf 钩子&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;remote_log_start()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;主 WS 连接成功&lt;strong&gt;之后&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;启动 flush 任务，开始 HTTP POST 上传&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;从阶段一开始，所有 &lt;code&gt;ESP_LOGx&lt;/code&gt; 输出就会同时写入缓冲区。WiFi 连接、SNTP、OTA 检测期间产生的日志全部在缓冲区里攒着。等阶段二启动后，flush 任务第一个周期就把积攒的日志一口气发出去。&lt;/p&gt;
&lt;p&gt;在 &lt;code&gt;app_main()&lt;/code&gt; 中的实际调用位置：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// NVS 初始化 + 读取 secret ...

// ── 阶段一：WiFi 之前 ──
remote_log_early_init();    // 钩子就绪，日志开始积攒

WifistaInit(&quot;SSID&quot;, &quot;password&quot;);
while (!Wifi_isConnected) { vTaskDelay(500 / portTICK_PERIOD_MS); }
obtain_time();              // SNTP
// OTA 检测 ...
WifiSecurityClientInit();
WebsocketStart(...);
while (!WebsocketIsConnected()) { vTaskDelay(500 / portTICK_PERIOD_MS); }

// ── 阶段二：网络就绪 ──
remote_log_start(&quot;https://example.com&quot;, &quot;/api/logs&quot;, 443, secret);
// 积攒的启动日志在下一秒自动刷出
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.3 缓冲区大小怎么定？&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;WiFi driver init + scan + connect + DHCP&lt;/td&gt;
&lt;td&gt;20-30 行&lt;/td&gt;
&lt;td&gt;2-4 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SNTP 时间同步&lt;/td&gt;
&lt;td&gt;5 行&lt;/td&gt;
&lt;td&gt;500 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OTA 状态检测&lt;/td&gt;
&lt;td&gt;5 行&lt;/td&gt;
&lt;td&gt;500 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TLS client init + WS 握手&lt;/td&gt;
&lt;td&gt;10 行&lt;/td&gt;
&lt;td&gt;1-2 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;合计&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~50 行&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~5-7 KB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;选择 &lt;strong&gt;16KB&lt;/strong&gt; 缓冲区，安全余量超过 50%。即使极端情况溢出，溢出策略是&lt;strong&gt;丢弃新日志、保留旧日志&lt;/strong&gt;——最早的启动日志（最有价值）一定不会丢。&lt;/p&gt;
&lt;p&gt;一旦 flush 任务启动并成功 POST，缓冲区空间被释放，后续日志就正常流转了。丢弃只可能发生在启动阶段的极端情况下。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;四、环形缓冲区：整个系统的心脏&lt;/h1&gt;
&lt;p&gt;环形缓冲区（Ring Buffer）是生产者-消费者模型的经典解法。在这个系统里，它承担着一个关键角色：&lt;strong&gt;解耦日志产生的速度和日志上传的速度&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;4.1 为什么不直接发？&lt;/h2&gt;
&lt;p&gt;假设没有缓冲区，vprintf 钩子里直接调 HTTP POST 会怎样？&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;ESP_LOGI&lt;/code&gt; 在任意任务、任意上下文中被调用&lt;/li&gt;
&lt;li&gt;HTTP POST 涉及 TLS 加密、TCP 发送，耗时 50-500ms&lt;/li&gt;
&lt;li&gt;调用 &lt;code&gt;ESP_LOGI&lt;/code&gt; 的业务任务被阻塞数百毫秒&lt;/li&gt;
&lt;li&gt;摄像头帧率暴跌，传感器读取超时，看门狗触发重启&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;日志系统绝对不能阻塞产生日志的那个任务。&lt;/strong&gt; 缓冲区的意义就在于：写入是瞬时的（微秒级），发送是异步的（另一个任务慢慢来）。&lt;/p&gt;
&lt;h2&gt;4.2 FreeRTOS RingBuffer 的工作原理&lt;/h2&gt;
&lt;p&gt;ESP-IDF 提供了 &lt;code&gt;freertos/ringbuf.h&lt;/code&gt;，我们使用 &lt;code&gt;RINGBUF_TYPE_BYTEBUF&lt;/code&gt; 类型（字节流缓冲区）。可以把它想象成一个首尾相连的数组：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;        写指针 (head)
           ↓
┌──────────────────────────────────┐
│ ████████░░░░░░░░░░░░░░███████████│
└──────────────────────────────────┘
                                ↑
                           读指针 (tail)

█ = 已写入待读取的数据
░ = 空闲空间
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;写入&lt;/strong&gt; (&lt;code&gt;xRingbufferSend&lt;/code&gt;)：从 head 开始写，写完 head 前进。如果追上了 tail（满了），根据超时参数决定等待还是失败返回。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;读取&lt;/strong&gt; (&lt;code&gt;xRingbufferReceiveUpTo&lt;/code&gt;)：从 tail 开始读，读完 tail 前进，释放空间给写入。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;环形&lt;/strong&gt;：head 到达数组末尾时自动绕回到开头，所以叫「环形」。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;关键特性：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;BYTEBUF 类型&lt;/strong&gt;：数据按字节流连续存储，没有 per-item 的 header 开销，空间利用率最高&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;线程安全&lt;/strong&gt;：内部使用信号量保护，多任务并发读写是安全的&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;0-tick 超时&lt;/strong&gt;：写入指定超时为 0 ticks 时，写不进去立即返回 &lt;code&gt;pdFALSE&lt;/code&gt;，绝不阻塞&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4.3 写入端：vprintf 钩子中的非阻塞写入&lt;/h3&gt;
&lt;p&gt;每当任意任务调用 &lt;code&gt;ESP_LOGx&lt;/code&gt;，钩子函数被触发，将格式化后的日志文本以 &lt;strong&gt;0-tick 超时&lt;/strong&gt;写入缓冲区：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// 格式化日志文本
char line_buf[256];
int len = vsnprintf(line_buf, sizeof(line_buf), fmt, args);

// 0-tick 写入：写不进去就丢，绝不阻塞
BaseType_t ok = xRingbufferSend(s_ringbuf, line_buf, len, 0);
if (ok == pdTRUE) {
    s_total_bytes_buffered += len;
} else {
    s_total_bytes_dropped += len;  // 统计丢弃量
    s_drop_count++;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;0&lt;/code&gt; 是超时 ticks 数。如果缓冲区满了，&lt;code&gt;xRingbufferSend&lt;/code&gt; 立即返回 &lt;code&gt;pdFALSE&lt;/code&gt;，不会等待哪怕一个 tick。日志被丢弃，但调用 &lt;code&gt;ESP_LOGI&lt;/code&gt; 的业务任务完全无感——这就是「不阻塞」的保证。&lt;/p&gt;
&lt;h2&gt;4.4 读取端：flush 任务的批量收割&lt;/h2&gt;
&lt;p&gt;flush 任务每秒执行一次，从缓冲区中批量读取数据，拼成一个大块一次性 POST 出去：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;char flush_buf[4096];
int total_len = 0;

// 批量读取，最多读满 4KB
while (total_len &amp;lt; 4095) {
    size_t remain = 4095 - total_len;
    void *item = xRingbufferReceiveUpTo(s_ringbuf, &amp;amp;item_size, 0, remain);
    if (item == NULL) break;
    memcpy(flush_buf + total_len, item, item_size);
    total_len += item_size;
    vRingbufferReturnItem(s_ringbuf, item);  // 归还空间
}

// 批量 POST
if (total_len &amp;gt; 0) {
    remote_log_http_post(flush_buf, total_len);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意 &lt;code&gt;xRingbufferReceiveUpTo&lt;/code&gt; 的语义：一次最多读取 &lt;code&gt;remain&lt;/code&gt; 字节，但可能返回的比请求的少（取决于缓冲区中连续可用的数据量）。所以用 &lt;code&gt;while&lt;/code&gt; 循环多次读取，拼成一个大块一次性发出——&lt;strong&gt;减少 HTTP 请求次数，降低 TLS 握手开销&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;读取完成后调用 &lt;code&gt;vRingbufferReturnItem()&lt;/code&gt; 归还空间，写入端才能继续写入新数据。&lt;/p&gt;
&lt;h2&gt;4.5 为什么「满了丢新的」而不是「覆盖旧的」？&lt;/h2&gt;
&lt;p&gt;FreeRTOS RingBuffer 的 &lt;code&gt;BYTEBUF&lt;/code&gt; 类型在满的时候不会自动覆盖旧数据，而是让新写入失败。这恰好符合我们的需求：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;启动阶段&lt;/strong&gt;：钩子已安装但网络未通，日志只进不出，缓冲区逐渐填满&lt;/li&gt;
&lt;li&gt;如果&lt;strong&gt;覆盖旧的&lt;/strong&gt;：最先产生的 WiFi init 日志被后面的日志挤掉——恰恰丢了最有价值的部分&lt;/li&gt;
&lt;li&gt;如果&lt;strong&gt;丢弃新的&lt;/strong&gt;：WiFi init 日志稳稳保留，被丢弃的是网络就绪前最后几条——价值相对较低&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一旦 flush 任务启动并成功 POST，缓冲区空间被释放，后续日志就正常流转了。丢弃只发生在启动阶段的极端情况下。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;五、vprintf 钩子：零侵入的秘密武器&lt;/h1&gt;
&lt;h2&gt;5.1 ESP-IDF 的日志拦截机制&lt;/h2&gt;
&lt;p&gt;ESP-IDF 的 &lt;code&gt;ESP_LOGx&lt;/code&gt; 宏最终都会调用一个 &lt;code&gt;vprintf&lt;/code&gt; 风格的函数来输出格式化文本。ESP-IDF 提供了一个 API 来替换这个输出函数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;vprintf_like_t esp_log_set_vprintf(vprintf_like_t func);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;调用它可以替换日志系统内部的输出函数，返回值是被替换掉的旧函数。这就是我们的入口——安装一个自定义的 &lt;code&gt;vprintf&lt;/code&gt;，在里面同时做两件事：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static int remote_log_vprintf(const char *fmt, va_list args)
{
    // 1. 调用原始 vprintf → 串口照常输出
    va_list args_copy;
    va_copy(args_copy, args);
    int ret = s_original_vprintf(fmt, args_copy);
    va_end(args_copy);

    // 2. 格式化后写入环形缓冲区 → 等待上传
    char line_buf[256];
    int len = vsnprintf(line_buf, sizeof(line_buf), fmt, args);
    xRingbufferSend(s_ringbuf, line_buf, len, 0);

    return ret;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;整个项目中&lt;strong&gt;没有任何一行 &lt;code&gt;ESP_LOGx&lt;/code&gt; 需要修改&lt;/strong&gt;——钩子在底层透明地拦截了所有输出。&lt;/p&gt;
&lt;h2&gt;5.2 va_list 的陷阱：必须 va_copy&lt;/h2&gt;
&lt;p&gt;注意代码中的 &lt;code&gt;va_copy&lt;/code&gt;。&lt;code&gt;va_list&lt;/code&gt; 在 C 标准中是一个&lt;strong&gt;不透明类型&lt;/strong&gt;，一次 &lt;code&gt;vprintf&lt;/code&gt; / &lt;code&gt;vsnprintf&lt;/code&gt; 调用可能会消耗（推进）它的内部状态。如果不 copy 就直接传给两个函数，第二个函数拿到的是被第一个函数「消耗过」的 &lt;code&gt;va_list&lt;/code&gt;——&lt;strong&gt;未定义行为&lt;/strong&gt;，轻则日志乱码，重则栈损坏崩溃。&lt;/p&gt;
&lt;p&gt;正确做法：先 &lt;code&gt;va_copy&lt;/code&gt; 出一份副本给串口输出，原始的 &lt;code&gt;args&lt;/code&gt; 留给缓冲区写入（或者反过来，顺序不重要，关键是不能让两个函数共用同一个 &lt;code&gt;va_list&lt;/code&gt;）。&lt;/p&gt;
&lt;h2&gt;5.3 防递归：钩子里绝对不能调 ESP_LOGx&lt;/h2&gt;
&lt;p&gt;这是最容易踩的坑。如果钩子函数内部调用了 &lt;code&gt;ESP_LOGI&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ESP_LOGI → remote_log_vprintf → ESP_LOGI → remote_log_vprintf → ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;无限递归，栈溢出，设备当场重启。&lt;/p&gt;
&lt;p&gt;解法是用一个 &lt;code&gt;_Thread_local&lt;/code&gt; 标志位做递归守卫：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static _Thread_local bool s_in_hook = false;

static int remote_log_vprintf(const char *fmt, va_list args)
{
    // 先执行原始串口输出（这一步始终执行，确保串口日志不丢）
    int ret = s_original_vprintf(fmt, args_copy);

    // 递归守卫：正在钩子内则跳过缓冲区写入
    if (s_in_hook) return ret;
    s_in_hook = true;
    // ... 写入缓冲区 ...
    s_in_hook = false;
    return ret;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;为什么用 &lt;code&gt;_Thread_local&lt;/code&gt; 而不是普通 &lt;code&gt;static bool&lt;/code&gt;？&lt;/strong&gt; 因为 FreeRTOS 中多个任务可能同时调用 &lt;code&gt;ESP_LOGx&lt;/code&gt;。如果用全局 &lt;code&gt;static&lt;/code&gt;，任务 A 设置了标志，任务 B 的日志也会被误跳过。&lt;code&gt;_Thread_local&lt;/code&gt; 让每个任务拥有独立的标志，互不干扰。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;flush 任务里的 &lt;code&gt;ESP_LOGI&lt;/code&gt;（统计日志）安全吗？&lt;/strong&gt; 安全。flush 任务在读完缓冲区之后才调用 &lt;code&gt;ESP_LOGI&lt;/code&gt;，触发的钩子会把统计日志写入缓冲区，但不会递归——因为 &lt;code&gt;ESP_LOGI → 钩子 → 写缓冲区&lt;/code&gt; 这条路径不涉及再次调用 &lt;code&gt;ESP_LOGI&lt;/code&gt;。这条统计日志会在下一轮 flush 时被发出去。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h1&gt;六、网络容错：超时、失败与指数退避&lt;/h1&gt;
&lt;p&gt;网络不是永远可靠的。设备运行在现场，WiFi 可能中断、服务器可能重启、TLS 握手可能超时。日志系统必须在这些情况下优雅降级，而不是崩溃或狂刷请求。&lt;/p&gt;
&lt;h2&gt;6.1 HTTP POST 的独立 client&lt;/h2&gt;
&lt;p&gt;每次 flush 都创建独立的 &lt;code&gt;esp_http_client&lt;/code&gt;，请求完成后立即关闭释放：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static esp_err_t remote_log_http_post(const char *data, int len)
{
    esp_http_client_config_t cfg = {
        .url            = s_upload_url,
        .method         = HTTP_METHOD_POST,
        .timeout_ms     = 5000,                    // 5 秒超时
        .crt_bundle_attach = esp_crt_bundle_attach, // TLS 证书
        .keep_alive_enable = true,                  // TCP keep-alive 复用
    };

    esp_http_client_handle_t client = esp_http_client_init(&amp;amp;cfg);
    esp_http_client_set_header(client, &quot;Content-Type&quot;, &quot;text/plain&quot;);
    esp_http_client_set_header(client, &quot;Authorization&quot;, s_secret);
    esp_http_client_set_post_field(client, data, len);

    esp_err_t err = esp_http_client_perform(client);

    if (err == ESP_OK) {
        int status = esp_http_client_get_status_code(client);
        if (status != 200 &amp;amp;&amp;amp; status != 201 &amp;amp;&amp;amp; status != 204) {
            err = ESP_FAIL;  // HTTP 层面的失败
        }
    }

    esp_http_client_close(client);
    esp_http_client_cleanup(client);
    return err;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;关键设计点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;5 秒超时&lt;/strong&gt;：&lt;code&gt;timeout_ms = 5000&lt;/code&gt;，避免网络异常时无限等待。如果服务器 5 秒内没有响应，请求失败，flush 任务继续下一轮&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;keep-alive&lt;/strong&gt;：开启 TCP keep-alive，底层连接可以被复用，减少每次 POST 的 TCP 握手 + TLS 握手开销&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;独立句柄&lt;/strong&gt;：不与全局 &lt;code&gt;WifiSecurityClient&lt;/code&gt; 共享，两个任务互不影响&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;6.2 发送失败时的数据回写&lt;/h2&gt;
&lt;p&gt;如果 HTTP POST 失败了，已经从缓冲区读出来的数据怎么办？直接丢弃太可惜。我们尝试将数据&lt;strong&gt;写回缓冲区&lt;/strong&gt;，等下一轮重试：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;esp_err_t err = remote_log_http_post(flush_buf, total_len);
if (err == ESP_OK) {
    s_total_bytes_sent += total_len;
    s_consecutive_fail = 0;          // 重置连续失败计数
} else {
    s_total_bytes_send_fail += total_len;
    s_consecutive_fail++;
    // 写回缓冲区，下次重试（0-tick，满则丢弃）
    xRingbufferSend(s_ringbuf, flush_buf, total_len, 0);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写回也是 0-tick 非阻塞的——如果此时新日志已经把缓冲区填满了，回写失败，这批数据就丢弃。这是可接受的降级：&lt;strong&gt;优先保证系统不阻塞&lt;/strong&gt;。&lt;/p&gt;
&lt;h2&gt;6.3 指数退避：别在网络故障时狂刷 HTTP&lt;/h2&gt;
&lt;p&gt;如果服务器挂了，每秒一次 POST 就变成了每秒一次无意义的 TLS 握手 + 超时等待，白白消耗 CPU 和网络资源。解法是&lt;strong&gt;指数退避&lt;/strong&gt;（Exponential Backoff）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;uint32_t delay_ms = RLOG_FLUSH_INTERVAL_MS;  // 基础 1 秒
if (s_consecutive_fail &amp;gt; 0) {
    // 连续失败次数越多，等待越久：1s → 2s → 4s → 8s → 16s → 封顶 30s
    delay_ms = RLOG_FLUSH_INTERVAL_MS * (1u &amp;lt;&amp;lt; (s_consecutive_fail &amp;gt; 4 ? 4 : s_consecutive_fail));
    if (delay_ms &amp;gt; RLOG_MAX_BACKOFF_MS) delay_ms = RLOG_MAX_BACKOFF_MS;
}
vTaskDelay(pdMS_TO_TICKS(delay_ms));
&lt;/code&gt;&lt;/pre&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1 秒（正常）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;2 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;4 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;8 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;16 秒&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5+&lt;/td&gt;
&lt;td&gt;30 秒（封顶）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一旦某次 POST &lt;strong&gt;成功&lt;/strong&gt;，&lt;code&gt;s_consecutive_fail&lt;/code&gt; 立即归零，间隔恢复到 1 秒。这样：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;短暂的网络波动（1-2 次失败）：几秒后自动恢复&lt;/li&gt;
&lt;li&gt;持续的网络断连：请求频率降到每 30 秒一次，不浪费资源&lt;/li&gt;
&lt;li&gt;网络恢复后：第一次成功即回到正常频率&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h1&gt;七、运行时诊断：定期统计&lt;/h1&gt;
&lt;p&gt;系统稳定运行后，怎么知道它健不健康？flush 任务每 10 轮（约 10 秒）打印一次诊断统计：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ESP_LOGI(TAG, &quot;[stats] flushed=%d B, pending=%u/%u bytes (%.0f%%), &quot;
              &quot;sent=%u B, drop=%u B (%u times), send_fail=%u B, consec_fail=%u&quot;,
         total_len,
         pre_flush_used + total_len, RLOG_RINGBUF_SIZE,
         (float)(pre_flush_used + total_len) / RLOG_RINGBUF_SIZE * 100.0f,
         s_total_bytes_sent,
         s_total_bytes_dropped, s_drop_count,
         s_total_bytes_send_fail,
         s_consecutive_fail);
&lt;/code&gt;&lt;/pre&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;code&gt;flushed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;本轮读出的字节数&lt;/td&gt;
&lt;td&gt;正常运行时 200-1000 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pending&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;flush 前缓冲区已用量&lt;/td&gt;
&lt;td&gt;正常 &amp;lt;10%，启动阶段可达 30-40%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sent&lt;/code&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;code&gt;drop&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;累计被丢弃字节数&lt;/td&gt;
&lt;td&gt;正常运行时应为 0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;send_fail&lt;/code&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;code&gt;consec_fail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;当前连续失败次数&lt;/td&gt;
&lt;td&gt;0 为健康，&amp;gt;3 说明网络持续异常&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这条统计日志本身也会被钩子捕获、写入缓冲区、在下一轮 flush 时上传——形成一个优雅的自监控闭环。它不会触发递归问题，因为 &lt;code&gt;ESP_LOGI → 钩子 → 写缓冲区&lt;/code&gt; 这条路径不涉及再次调用 &lt;code&gt;ESP_LOGI&lt;/code&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;八、优雅关闭：remote_log_deinit&lt;/h1&gt;
&lt;p&gt;当设备需要停止远程日志（例如进入深度休眠前）时，关闭顺序很重要：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;void remote_log_deinit(void)
{
    // 1. 先恢复原始 vprintf（停止拦截新日志）
    if (s_original_vprintf) {
        esp_log_set_vprintf(s_original_vprintf);
        s_original_vprintf = NULL;
    }
    s_hook_installed = false;

    // 2. 停止 flush 任务（等待当前轮次完成）
    s_upload_started = false;
    if (s_flush_task_handle) {
        vTaskDelay(pdMS_TO_TICKS(RLOG_FLUSH_INTERVAL_MS + 500));
        s_flush_task_handle = NULL;
    }

    // 3. 释放缓冲区
    if (s_ringbuf) {
        vRingbufferDelete(s_ringbuf);
        s_ringbuf = NULL;
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;顺序不能反：如果先删缓冲区，钩子还在拦截日志，写入一个已释放的缓冲区——野指针访问，立即崩溃。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;九、设计要点总结&lt;/h1&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;strong&gt;生产-消费解耦&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;环形缓冲区隔离日志产生速度和上传速度&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;两阶段初始化&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;先攒后发，不丢启动日志&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;零侵入&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;vprintf 钩子透明拦截，业务代码无需修改&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;绝不阻塞&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;写缓冲区 0-tick 超时，满了就丢&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;线程安全&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;独立 HTTP client + &lt;code&gt;_Thread_local&lt;/code&gt; 递归守卫&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;网络容错&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;失败回写 + 指数退避，优雅降级&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;自监控&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;定期统计日志自我上报&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;资源友好&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;16KB 缓冲区 + 按需创建 HTTP client，内存可控&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;整个模块约 200 行 C 代码，对外暴露 4 个 API（&lt;code&gt;early_init&lt;/code&gt; / &lt;code&gt;start&lt;/code&gt; / &lt;code&gt;is_connected&lt;/code&gt; / &lt;code&gt;deinit&lt;/code&gt;），即插即用。对于任何需要远程查看设备日志的 ESP32 项目，这套方案都可以直接移植。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;基于 ESP-IDF v5.x + FreeRTOS，运行于 ESP32 平台。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>基于 asyncio.Future 实现 WebSocket 请求-响应模式：异步获取 IoT 设备版本号</title><link>https://www.mintlab.top/posts/lark-solution/async-device-version/</link><guid isPermaLink="true">https://www.mintlab.top/posts/lark-solution/async-device-version/</guid><description>在 FastAPI + WebSocket 架构中，HTTP 接口和 WebSocket 消息循环是两个独立的协程。当 HTTP 接口需要向远端设备&quot;提问&quot;并等待回答时，如何优雅地桥接二者？本文以**获取 ESP32 设备固件版本号**为例，记录一种基于 `asyncio.Future` 的请求-响应实现方案。</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;1. 问题背景&lt;/h1&gt;
&lt;p&gt;项目中后端通过 WebSocket 长连接与 ESP32-CAM 设备通信，前端通过 HTTP/WebSocket 与后端交互。已有的设备控制指令（重启、OTA）都是&quot;发射后不管&quot;（fire-and-forget）——发完指令就返回，无需等待设备的具体响应内容：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 重启：发完即返回 &quot;ok&quot;
await device.websocket.send_json(CMD_RESTART)
return CommonOut(msg=&quot;Restart command sent.&quot;, data={&quot;key&quot;: &quot;restart&quot;, &quot;values&quot;: &quot;ok&quot;})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但&lt;strong&gt;获取版本号&lt;/strong&gt;不同，它是一个 &lt;strong&gt;请求-响应（Request-Response）&lt;/strong&gt; 模式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP 客户端 ──GET /devices/1/version──► 后端 ──ws.send──► ESP32
                                         ▲                    │
                                         │    ws.receive      │
HTTP 客户端 ◄──返回版本号────────────── 后端 ◄─────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;后端必须&lt;strong&gt;等待设备回复&lt;/strong&gt;后，才能把版本号返回给前端。&lt;/p&gt;
&lt;h1&gt;2. 为什么不能用简单方案？&lt;/h1&gt;
&lt;h2&gt;方案一：在 HTTP 处理函数中直接 &lt;code&gt;receive()&lt;/code&gt;？&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# ❌ 错误方案
async def get_device_version(id):
    await device.websocket.send_json(CMD_VERSION)
    response = await device.websocket.receive_json()  # 阻塞等待
    return response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;问题&lt;/strong&gt;：WebSocket 连接上的 &lt;code&gt;receive()&lt;/code&gt; 只能有一个消费者。设备的消息循环（&lt;code&gt;Stream.py&lt;/code&gt;）已经在持续 &lt;code&gt;await websocket.receive()&lt;/code&gt; 了。如果 HTTP 处理函数也去 &lt;code&gt;receive()&lt;/code&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;两个协程竞争同一个 WebSocket 的读取权 → &lt;strong&gt;未定义行为&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;设备可能先发一帧视频流二进制数据，HTTP 侧收到的不是版本号响应 → &lt;strong&gt;消息错乱&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;消息循环那边丢失了这条消息 → &lt;strong&gt;其他功能被破坏&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;方案二：轮询共享变量？&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# ❌ 低效方案
device.latest_version = None
await device.websocket.send_json(CMD_VERSION)
for _ in range(50):
    if device.latest_version is not None:
        return device.latest_version
    await asyncio.sleep(0.1)
return &quot;timeout&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;问题&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;轮询间隔固定，要么浪费 CPU 要么响应慢&lt;/li&gt;
&lt;li&gt;多个请求同时查版本号时，无法区分响应归属&lt;/li&gt;
&lt;li&gt;需要额外的锁/清理逻辑，实现丑陋且脆弱&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;方案三：回调函数？&lt;/h2&gt;
&lt;p&gt;回调函数是异步编程中最经典的模式——&quot;事情做完了叫我&quot;。我们先看看如果硬用回调会怎样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ⚠️ 尝试用回调实现
class Esp32:
    def __init__(self):
        self.callbacks = {}  # key -&amp;gt; callback function

    def register_callback(self, key: str, callback):
        &quot;&quot;&quot;注册：等收到这个 key 的响应时，调用 callback&quot;&quot;&quot;
        self.callbacks[key] = callback

# 消息循环中触发回调
async def _on_device_text(device, raw):
    json_data = json.loads(raw)
    key = json_data.get(&quot;key&quot;)
    if key in device.callbacks:
        device.callbacks.pop(key)(json_data)  # 调用回调
        return
    # ...正常转发...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看起来还不错？现在问题来了——HTTP 处理函数怎么写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ❌ 回调的死胡同
@router.get(&quot;/{id}/version&quot;)
async def get_device_version(id):
    result = None

    def on_version(data):
        nonlocal result
        result = data   # 回调把结果写入 result

    device.register_callback(&quot;version&quot;, on_version)
    await device.websocket.send_json(CMD_VERSION)

    # 但是...现在 result 还是 None！
    # 回调还没被调用（设备还没响应）
    # 而 HTTP 函数必须现在就 return 一个值
    return CommonOut(data=result)  # ← None，错误！
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;核心矛盾&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;时间线：
  T0  HTTP handler: 注册回调, 发送指令
  T1  HTTP handler: 函数执行到末尾, 必须 return  ← 此时设备还没响应！
  T2  WS 消息循环: 收到设备响应, 触发回调
  T3  回调执行: result = data  ← 但 HTTP 响应早就在 T1 返回了，来不及了
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;回调函数能在&quot;将来某个时刻&quot;处理数据，但 &lt;strong&gt;HTTP 处理函数需要在当前执行流中返回结果&lt;/strong&gt;。回调和 HTTP handler 的生命周期是割裂的。&lt;/p&gt;
&lt;p&gt;那加个 &lt;code&gt;await asyncio.sleep()&lt;/code&gt; 等等？那就退化成方案二的轮询了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;真正的解法&lt;/strong&gt;：我们需要一种方式让 HTTP handler &lt;strong&gt;挂起（暂停执行），直到回调被触发后再继续&lt;/strong&gt;。这恰好就是 &lt;code&gt;asyncio.Future&lt;/code&gt; 做的事——它把回调模式和 await 挂起机制粘合在一起：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ✅ 回调 + Future = 最终方案
async def get_device_version(id):
    future = loop.create_future()                       # 创建占位符
    device.register_callback(&quot;version&quot;, future.set_result)  # 回调 = 填充 Future
    await device.websocket.send_json(CMD_VERSION)
    result = await future   # 挂起，直到回调执行 set_result()
    return CommonOut(data=result)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;future.set_result&lt;/code&gt; 本身就是一个函数引用——它就是那个&quot;回调&quot;。只不过我们不直接用回调返回数据，而是用它来 &lt;strong&gt;填充 Future&lt;/strong&gt;。HTTP handler 那边 &lt;code&gt;await future&lt;/code&gt; 就能在回调执行后被唤醒继续执行。&lt;/p&gt;
&lt;p&gt;这正是我们最终方案的本质——&lt;strong&gt;把回调的&quot;通知&quot;能力和 Future 的&quot;等待&quot;能力结合起来&lt;/strong&gt;。&lt;/p&gt;
&lt;h1&gt;3. 最终方案：&lt;code&gt;asyncio.Future&lt;/code&gt; 桥接两个协程&lt;/h1&gt;
&lt;h2&gt;3.1 核心思想&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;asyncio.Future&lt;/code&gt; 是 asyncio 中最底层的可等待对象。它本质上是一个 &lt;strong&gt;&quot;还没有结果的占位符&quot;&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;创建时：pending 状态（空盒子）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;set_result(value)&lt;/code&gt;：filled 状态（盒子里放了东西）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;await future&lt;/code&gt;：如果还是空的就挂起等待；如果已经有值就立即返回&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;利用这个特性，我们可以在协程 A 中创建 Future 并等待，在协程 B 中填入结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;协程A (HTTP handler)          共享的 Future           协程B (WS 消息循环)
    │                              │                       │
    ├─ create future ────────────► │                       │
    ├─ send command to device      │                       │
    ├─ await future (挂起) ◄───── pending                  │
    │                              │     收到设备响应 ────── │
    │                              │ ◄── set_result(data)   │
    ├─ await 返回, 拿到 data ◄── filled                    │
    └─ return response             │                       │
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.2 实现代码&lt;/h2&gt;
&lt;h3&gt;Esp32 类中的两个方法&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;class Esp32:
    def __init__(self, device):
        # ...
        self.pending_responses: dict[str, asyncio.Future] = {}
        #    key: 指令的 key 字段 (如 &quot;version&quot;)
        #    value: 等待该响应的 Future

    async def send_and_wait(self, command: dict, key: str, timeout: float = 5.0) -&amp;gt; dict | None:
        &quot;&quot;&quot;发送指令并等待设备返回匹配 key 的响应。超时返回 None。&quot;&quot;&quot;
        loop = asyncio.get_event_loop()
        future = loop.create_future()          # ① 创建空 Future
        self.pending_responses[key] = future   # ② 注册，供消息循环查找
        try:
            await self.websocket.send_json(command)  # ③ 发送指令
            return await asyncio.wait_for(future, timeout=timeout)  # ④ 挂起等待
        except asyncio.TimeoutError:
            return None                        # ⑤ 超时 → 返回 None
        finally:
            self.pending_responses.pop(key, None)  # ⑥ 清理

    def resolve_pending(self, key: str, data: dict) -&amp;gt; bool:
        &quot;&quot;&quot;由消息循环调用：尝试匹配并填充一个挂起的 Future。&quot;&quot;&quot;
        future = self.pending_responses.get(key)
        if future and not future.done():
            future.set_result(data)   # 唤醒 send_and_wait 中的 await
            return True
        return False
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;消息循环中的拦截逻辑&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# Stream.py
async def _on_device_text(device: Device.Esp32, raw: str) -&amp;gt; None:
    json_data = json.loads(raw)

    # 优先检查：是否有 HTTP 请求在等这条响应？
    resp_key = json_data.get(&quot;key&quot;)
    if resp_key and device.resolve_pending(resp_key, json_data):
        return  # 已消费，不转发给前端观看者

    # 常规流程：状态处理 + 转发给订阅者
    await _handle_device_status_response(device, json_data)
    await _forward_text(device, json_data)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;HTTP 接口&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;@router.get(&quot;/{id}/version&quot;, summary=&quot;查询设备固件版本&quot;)
async def get_device_version(id, db):
    # ...校验省略...
    device = esp32IdDict.get(id)

    result = await device.send_and_wait(CMD_VERSION, &quot;version&quot;, timeout=5.0)
    if result is None:
        return JSONResponse(status_code=504, ...)  # 超时

    return CommonOut(data={&quot;key&quot;: &quot;version&quot;, &quot;values&quot;: result.get(&quot;values&quot;, &quot;&quot;)})
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;3.3 逐步执行流程&lt;/h2&gt;
&lt;p&gt;以 &lt;code&gt;GET /devices/1/version&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;T0&lt;/td&gt;
&lt;td&gt;HTTP handler&lt;/td&gt;
&lt;td&gt;创建 &lt;code&gt;Future&lt;/code&gt;，存入 &lt;code&gt;pending_responses[&quot;version&quot;]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T1&lt;/td&gt;
&lt;td&gt;HTTP handler&lt;/td&gt;
&lt;td&gt;&lt;code&gt;send_json(CMD_VERSION)&lt;/code&gt; → 指令发往 ESP32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T2&lt;/td&gt;
&lt;td&gt;HTTP handler&lt;/td&gt;
&lt;td&gt;&lt;code&gt;await wait_for(future, 5.0)&lt;/code&gt; → &lt;strong&gt;挂起&lt;/strong&gt;，让出控制权&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T3&lt;/td&gt;
&lt;td&gt;WS 消息循环&lt;/td&gt;
&lt;td&gt;&lt;code&gt;await websocket.receive()&lt;/code&gt; → 收到设备响应&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T4&lt;/td&gt;
&lt;td&gt;WS 消息循环&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolve_pending(&quot;version&quot;, data)&lt;/code&gt; → &lt;code&gt;future.set_result(data)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T5&lt;/td&gt;
&lt;td&gt;HTTP handler&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;被唤醒&lt;/strong&gt;，&lt;code&gt;await&lt;/code&gt; 返回 &lt;code&gt;data&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;T6&lt;/td&gt;
&lt;td&gt;HTTP handler&lt;/td&gt;
&lt;td&gt;包装成 JSON 返回给前端&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;如果 T3 在 T2 之后 5 秒仍未发生 → &lt;code&gt;TimeoutError&lt;/code&gt; → 返回 504。&lt;/p&gt;
&lt;h1&gt;4. 与回调函数的深度类比&lt;/h1&gt;
&lt;h2&gt;4.1 什么是回调函数&lt;/h2&gt;
&lt;p&gt;回调的思想很简单：&lt;strong&gt;&quot;我现在没法处理，但你帮我做完后，调用我给你的这个函数&quot;&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;生活类比：你去餐厅点餐，前台给你一个取餐呼叫器。你不用站在柜台前等，呼叫器响了你再去取。这里&quot;呼叫器响了&quot;就是回调被触发的时刻。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 最朴素的回调模式
def 点餐(菜名, 做好了之后):
    厨房.开始做(菜名)
    厨房.做好时调用(做好了之后)  # 注册回调

def 吃饭(菜):
    print(f&quot;开吃: {菜}&quot;)

点餐(&quot;红烧肉&quot;, 吃饭)   # 传入回调函数
# 这里程序继续往下跑，不会等在这里
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4.2 用回调实现设备版本查询——完整示例&lt;/h2&gt;
&lt;p&gt;为了更好理解 Future 解决了什么问题，我们先用纯回调硬写一遍：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ========= 纯回调实现（在 FastAPI 中行不通的版本）=========

# 1. 设备对象维护回调注册表
class Esp32:
    def __init__(self):
        self.callbacks: dict[str, callable] = {}

    def on_response(self, key, callback):
        self.callbacks[key] = callback

# 2. 消息循环：收到消息后查找并触发回调
async def _on_device_text(device, raw):
    json_data = json.loads(raw)
    key = json_data.get(&quot;key&quot;)
    cb = device.callbacks.pop(key, None)
    if cb:
        cb(json_data)  # 触发回调！
        return
    # ...正常转发...

# 3. HTTP 接口中使用
@router.get(&quot;/{id}/version&quot;)
async def get_device_version(id):
    device = esp32IdDict[id]

    # 问题在这里 ↓
    received = {}
    def my_callback(data):
        received[&quot;result&quot;] = data   # 回调把数据存到外部字典

    device.on_response(&quot;version&quot;, my_callback)
    await device.websocket.send_json(CMD_VERSION)

    # ❌ 此刻 received 是空的！因为设备还没响应
    # ❌ 但 HTTP 函数必须返回值
    return received.get(&quot;result&quot;)  # → None
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;画成时序图看得更清楚：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;HTTP handler 执行流         回调函数           WS 消息循环
     │                        │                    │
  T0 ├── on_response(cb) ────►│                    │
  T1 ├── send_json(CMD) ──────┼───────────────────►│
  T2 ├── return received ──►  │                    │  ← HTTP 响应已发出（None）!
     │   （函数结束了）          │                    │
  T3 │                        │    receive(data) ──┤
  T4 │  （已经不存在了）   ◄── cb(data)             │  ← 回调终于执行了
     │                        │                    │     但 HTTP 响应已经返回了
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;根本矛盾&lt;/strong&gt;：回调是&quot;事后&quot;执行的，但 HTTP handler 的 &lt;code&gt;return&lt;/code&gt; 是&quot;当下&quot;就要的。这两个时间点对不上。&lt;/p&gt;
&lt;h2&gt;4.3 回调在 Node.js/Express 中为什么能用？&lt;/h2&gt;
&lt;p&gt;你可能会问：JavaScript 的 Express 框架不就是用回调的吗？&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Node.js Express — 回调是可以的
app.get(&apos;/version&apos;, (req, res) =&amp;gt; {
    device.send({ key: &apos;version&apos; });
    device.once(&apos;message:version&apos;, (data) =&amp;gt; {
        res.json(data);   // ← 在回调里发送 HTTP 响应
    });
    // 注意：这里没有 return 响应内容
    // Express 不要求函数返回值，它靠 res.json() 来发响应
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;区别在于 &lt;strong&gt;Express 的 HTTP 响应是通过 &lt;code&gt;res.send()/res.json()&lt;/code&gt; 主动发送的&lt;/strong&gt;，不依赖函数返回值。所以回调里可以随时调 &lt;code&gt;res.json()&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;但 &lt;strong&gt;FastAPI 的路由处理函数必须通过 &lt;code&gt;return&lt;/code&gt; 返回响应&lt;/strong&gt;。函数 return 了就结束了，你在回调里没法 &quot;return 到一个已经结束的函数&quot;。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# FastAPI：响应 = 函数返回值
@router.get(&quot;/version&quot;)
async def handler():
    return CommonOut(data=...)  # ← 只能在这里决定返回什么

# Express：响应 = 手动调用 res
app.get(&apos;/version&apos;, (req, res) =&amp;gt; {
    // 可以在任何时候、任何回调里调用 res.json()
    setTimeout(() =&amp;gt; res.json({...}), 1000);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;4.4 Future = 回调的&quot;时空传送门&quot;&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;asyncio.Future&lt;/code&gt; 解决了这个时间差问题。它的作用就像一个&lt;strong&gt;信封&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;HTTP handler 创建一个空信封（Future），然后&lt;strong&gt;在信封前等着&lt;/strong&gt;（&lt;code&gt;await future&lt;/code&gt;）—— 此时函数 &lt;strong&gt;没有 return&lt;/strong&gt;，它挂起了&lt;/li&gt;
&lt;li&gt;回调往信封里塞了数据（&lt;code&gt;set_result(data)&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;HTTP handler 拆开信封拿到数据，&lt;strong&gt;继续执行到 return&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;# 回调                              Future
# ────                              ──────

def on_version(data):               future = loop.create_future()
    # data 在这里，但没法                
    # 传回 HTTP handler              pending[&quot;version&quot;] = future
    ???                              await ws.send_json(CMD_VERSION)
                                     
                                     data = await future  # 暂停在这里
                                     # ↑ 等价于：注册了一个回调说
                                     # &quot;有人 set_result 时唤醒我&quot;

# 消息循环触发回调：                    # 消息循环填充 Future：
cb(json_data)                        future.set_result(json_data)
# ↓                                  # ↓
# 回调执行了，但 HTTP handler          # await 被唤醒，data 有值了
# 已经 return 了                      # HTTP handler 继续执行到 return
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;await future&lt;/code&gt; 做了两件事：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;暂停当前函数&lt;/strong&gt;（不是 return，是 suspend，函数还活着）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;等 &lt;code&gt;set_result()&lt;/code&gt; 后恢复执行&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这就把回调的&quot;通知能力&quot;和 HTTP handler 的&quot;需要 return 值&quot;完美衔接了。&lt;/p&gt;
&lt;h2&gt;4.5 对照表：回调 vs Future vs Promise&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;asyncio.Future&lt;/th&gt;
&lt;th&gt;JS Promise&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;注册&quot;事后操作&quot;&lt;/td&gt;
&lt;td&gt;传入回调函数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pending[key] = future&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.then(callback)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;触发&quot;事后操作&quot;&lt;/td&gt;
&lt;td&gt;直接调用 &lt;code&gt;cb(data)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;future.set_result(data)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;resolve(data)&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;&lt;code&gt;await future&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;await promise&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;错误传递&lt;/td&gt;
&lt;td&gt;手动传 &lt;code&gt;err&lt;/code&gt; 参数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;future.set_exception(e)&lt;/code&gt; + try/except&lt;/td&gt;
&lt;td&gt;&lt;code&gt;reject(e)&lt;/code&gt; + &lt;code&gt;.catch()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;超时控制&lt;/td&gt;
&lt;td&gt;手动 &lt;code&gt;setTimeout&lt;/code&gt; + 清理&lt;/td&gt;
&lt;td&gt;&lt;code&gt;asyncio.wait_for(future, timeout)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Promise.race([p, timeout])&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;&lt;code&gt;asyncio.gather(f1, f2)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Promise.all([p1, p2])&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;✅ &lt;code&gt;await&lt;/code&gt; 挂起&lt;/td&gt;
&lt;td&gt;✅ &lt;code&gt;await&lt;/code&gt; 挂起&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;本质&lt;/td&gt;
&lt;td&gt;&quot;做完了叫我&quot;&lt;/td&gt;
&lt;td&gt;&quot;给我一个承诺，我等它兑现&quot;&lt;/td&gt;
&lt;td&gt;&quot;给我一个承诺，我等它兑现&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;4.6 一句话总结关系&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;回调:    &quot;嘿，做完了调用这个函数&quot;    → 通知能力 ✓，挂起等待 ✗
Future:  &quot;嘿，做完了往这个盒子里放结果，我在盒子前 await 着&quot;  → 通知能力 ✓，挂起等待 ✓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;asyncio.Future&lt;/code&gt; 不是替代了回调，而是包装了回调。&lt;/strong&gt; &lt;code&gt;set_result()&lt;/code&gt; 就是回调被触发的那个动作，只不过触发后的效果不是&quot;跑一个函数&quot;，而是 &quot;唤醒一个正在 await 的协程&quot;。&lt;/p&gt;
&lt;p&gt;底层实现上，&lt;code&gt;await future&lt;/code&gt; 其实就是向事件循环注册了一个回调：&quot;当这个 future 完成时，恢复执行我这个协程&quot;。async/await 只是把这个注册过程藏在了语法糖背后，让你写出来的代码 &lt;strong&gt;看起来像同步的，跑起来是异步的&lt;/strong&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果你了解 JavaScript 的 &lt;code&gt;Promise&lt;/code&gt;，&lt;code&gt;asyncio.Future&lt;/code&gt; 就是 Python 中的等价物。&lt;code&gt;set_result()&lt;/code&gt; 对应 &lt;code&gt;resolve()&lt;/code&gt;，&lt;code&gt;await future&lt;/code&gt; 对应 &lt;code&gt;await promise&lt;/code&gt;。历史上 Python 也是受 JS Promise 启发才在 asyncio 中引入了 Future + async/await 语法。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1&gt;5. 为什么必须使用异步方案？&lt;/h1&gt;
&lt;p&gt;回到我们的场景——&lt;strong&gt;FastAPI + 单线程事件循环 + WebSocket 长连接&lt;/strong&gt;：&lt;/p&gt;
&lt;h2&gt;5.1 WebSocket 消息流是独占的&lt;/h2&gt;
&lt;p&gt;设备的 WebSocket 连接上跑着一个持续的消息循环：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;while True:
    data = await websocket.receive()  # 独占读取
    if &quot;text&quot; in data:
        await _on_device_text(device, data[&quot;text&quot;])
    if &quot;bytes&quot; in data:
        await _on_device_bytes(device, websocket, data[&quot;bytes&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你不可能从另一个协程去 &lt;code&gt;receive()&lt;/code&gt; 同一个 WebSocket——这会导致&lt;strong&gt;竞态条件&lt;/strong&gt;。所以响应 &lt;strong&gt;只能从消息循环里获取&lt;/strong&gt;，然后通过某种机制传给等待方。&lt;/p&gt;
&lt;h2&gt;5.2 HTTP 处理函数需要&quot;等&quot;&lt;/h2&gt;
&lt;p&gt;FastAPI 的路由处理函数必须返回一个值。对于版本号查询，你不能&quot;先返回再补数据&quot;——HTTP 是请求-响应模型。所以处理函数 &lt;strong&gt;必须挂起等待&lt;/strong&gt;，直到拿到结果或超时。&lt;/p&gt;
&lt;h2&gt;5.3 不能阻塞事件循环&lt;/h2&gt;
&lt;p&gt;如果用 &lt;code&gt;time.sleep()&lt;/code&gt; 或忙等待来&quot;等&quot;，整个事件循环都会冻结——&lt;strong&gt;所有其他请求、所有 WebSocket 通信全部停摆&lt;/strong&gt;。必须用 &lt;code&gt;await&lt;/code&gt; 让出控制权，让事件循环在等待期间继续处理其他任务。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;asyncio.Future&lt;/code&gt; + &lt;code&gt;await&lt;/code&gt; 完美满足这三个约束：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;不触碰 WebSocket 的读取权 ✓&lt;/li&gt;
&lt;li&gt;让 HTTP 处理函数挂起等待 ✓&lt;/li&gt;
&lt;li&gt;不阻塞事件循环 ✓&lt;/li&gt;
&lt;/ul&gt;
&lt;h1&gt;6. 扩展思考&lt;/h1&gt;
&lt;h2&gt;6.1 并发安全&lt;/h2&gt;
&lt;p&gt;如果两个前端同时查同一设备的版本号怎么办？当前实现中 &lt;code&gt;pending_responses&lt;/code&gt; 用 &lt;code&gt;key&lt;/code&gt; 作为字典键，后注册的会覆盖先注册的 Future。&lt;/p&gt;
&lt;p&gt;如需支持并发，可以改为用唯一请求 ID：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;import uuid

async def send_and_wait(self, command, key, timeout=5.0):
    request_id = f&quot;{key}:{uuid.uuid4().hex[:8]}&quot;
    command[&quot;_req_id&quot;] = request_id  # 需要设备原样返回此字段
    future = loop.create_future()
    self.pending_responses[request_id] = future
    # ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但对于嵌入式设备场景（ESP32 资源有限，协议简单），当前按 &lt;code&gt;key&lt;/code&gt; 匹配的方案已经够用。&lt;/p&gt;
&lt;h2&gt;6.2 方案适用范围&lt;/h2&gt;
&lt;p&gt;这个模式适用于任何需要&quot;通过一个共享通道发送请求、从同一通道接收响应&quot;的场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IoT 设备控制（本文场景）&lt;/li&gt;
&lt;li&gt;消息队列的 RPC 模式（通过 MQ 发请求、等回复）&lt;/li&gt;
&lt;li&gt;多路复用的 TCP 连接上的请求-响应匹配&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;核心思路始终是：&lt;strong&gt;创建 Future → 注册到共享字典 → 发送请求 → 等待 Future → 另一端填充结果&lt;/strong&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;基于 FastAPI + asyncio + WebSocket + ESP32-CAM 的实际项目经验整理。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>嵌入式 WebSocket 命令处理的扁平化设计：映射表 + 分发表实战</title><link>https://www.mintlab.top/posts/lark-solution/ws_process_design/</link><guid isPermaLink="true">https://www.mintlab.top/posts/lark-solution/ws_process_design/</guid><description>在嵌入式物联网项目中，设备端常常需要通过 WebSocket 接收来自服务端的 JSON 指令，完成查询、设置等操作。随着功能膨胀，传统的 `if-else` / `switch-case` 写法会迅速变得臃肿且难以维护。本文以一个 ESP32 项目的真实代码为例，介绍如何用 **映射表 + 分发表** 将命令处理逻辑彻底扁平化。</description><pubDate>Thu, 26 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;1. 问题：嵌套地狱&lt;/h1&gt;
&lt;p&gt;一个典型的 WebSocket 文本帧协议长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;code&quot;: 0,
  &quot;item&quot;: &quot;camera&quot;,
  &quot;key&quot;: &quot;frame_size&quot;,
  &quot;values&quot;: &quot;FRAMESIZE_VGA&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;code&lt;/code&gt;：&lt;code&gt;0&lt;/code&gt; 查询，&lt;code&gt;1&lt;/code&gt; 设定&lt;/li&gt;
&lt;li&gt;&lt;code&gt;item&lt;/code&gt;：模块名（&lt;code&gt;status&lt;/code&gt; / &lt;code&gt;camera&lt;/code&gt; / &lt;code&gt;device&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;key&lt;/code&gt;：参数名&lt;/li&gt;
&lt;li&gt;&lt;code&gt;values&lt;/code&gt;：参数值&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;最朴素的实现方式是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (code == 0) {
    if (strcmp(item, &quot;status&quot;) == 0) {
        if (strcmp(key, &quot;status&quot;) == 0) { /* ... */ }
    } else if (strcmp(item, &quot;camera&quot;) == 0) {
        if (strcmp(key, &quot;frame_size&quot;) == 0) { /* ... */ }
        else if (strcmp(key, &quot;jpeg_quality&quot;) == 0) { /* ... */ }
        // ...
    }
} else if (code == 1) {
    // 再来一遍……
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;三层嵌套，每增加一个 item 或 key 就要在两处（查询/设定）各加一段。&lt;/strong&gt; 模块一多，这个函数轻松突破 300 行，可读性和可维护性直线下降。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;2. 解法概览：两级扁平化&lt;/h1&gt;
&lt;p&gt;整体思路是将「嵌套分支」拆成两级独立的 &lt;strong&gt;表驱动（Table-Driven）&lt;/strong&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;strong&gt;映射表&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;枚举值 ↔ 字符串的双向转换&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FrameSizeEntry_t[]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;分发表&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;item&lt;/code&gt; → 对应的查询/设定处理函数&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ItemDispatch_t[]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;入口函数 &lt;code&gt;ws_text_handler&lt;/code&gt; 只做三件事：&lt;strong&gt;解析 JSON → 查分发表 → 调用处理函数&lt;/strong&gt;，不含任何业务逻辑。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;┌─────────────────────┐
│  ws_text_handler()  │  入口：JSON 解析 + 字段校验
└────────┬────────────┘
         │ 遍历 dispatch_table[]
         ▼
┌────────────────────────────────────────┐
│  dispatch_table[i].query / .set        │  按 code 选择查询或设定
│  ┌──────────┬──────────┬────────────┐  │
│  │ status   │ camera   │ device     │  │  每个 item 一行
│  └──────────┴──────────┴────────────┘  │
└────────┬───────────────────────────────┘
         │ 在具体 handler 中按 key 处理
         ▼
   query_camera() / set_camera() / ...
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h1&gt;3. 映射表：枚举 ↔ 字符串的零成本转换&lt;/h1&gt;
&lt;p&gt;嵌入式开发中经常需要在「人类可读的字符串」和「C 枚举值」之间转换。传统做法是写一大堆 &lt;code&gt;switch-case&lt;/code&gt;，每增加一个枚举值就要改两处（&lt;code&gt;to_str&lt;/code&gt; 和 &lt;code&gt;from_str&lt;/code&gt;）。&lt;/p&gt;
&lt;h2&gt;定义&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;typedef struct {
    const char    *name;
    framesize_t    fs;
} FrameSizeEntry_t;

static const FrameSizeEntry_t fs_table[] = {
    { &quot;FRAMESIZE_128X128&quot;, FRAMESIZE_128X128 },
    { &quot;FRAMESIZE_240X240&quot;, FRAMESIZE_240X240 },
    { &quot;FRAMESIZE_VGA&quot;,     FRAMESIZE_VGA     },
    { &quot;FRAMESIZE_SVGA&quot;,    FRAMESIZE_SVGA    },
    { &quot;FRAMESIZE_HD&quot;,      FRAMESIZE_HD      },
    { &quot;FRAMESIZE_FHD&quot;,     FRAMESIZE_FHD     },
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;正反查找&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;// 枚举 → 字符串
static const char *framesize_to_str(framesize_t fs) {
    for (size_t i = 0; i &amp;lt; FS_TABLE_SIZE; i++)
        if (fs_table[i].fs == fs) return fs_table[i].name;
    return &quot;unknown&quot;;
}

// 字符串 → 枚举
static framesize_t str_to_framesize(const char *str) {
    for (size_t i = 0; i &amp;lt; FS_TABLE_SIZE; i++)
        if (strcasecmp(str, fs_table[i].name) == 0) return fs_table[i].fs;
    return FRAMESIZE_INVALID;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;优势：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;单一数据源&lt;/strong&gt;：新增分辨率只需在 &lt;code&gt;fs_table[]&lt;/code&gt; 加一行，正反查找自动生效。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;编译期可见&lt;/strong&gt;：表是 &lt;code&gt;const&lt;/code&gt; 数组，存储在 &lt;code&gt;.rodata&lt;/code&gt;，不占 RAM。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;大小写容错&lt;/strong&gt;：查找使用 &lt;code&gt;strcasecmp&lt;/code&gt;，协议鲁棒性更好。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h1&gt;4. 分发表：item × code 的扁平路由&lt;/h1&gt;
&lt;p&gt;这是整个设计的核心。我们定义一个统一的处理函数签名，然后用结构体数组把 &lt;code&gt;item&lt;/code&gt; 名称和对应的查询、设定函数关联起来。&lt;/p&gt;
&lt;h2&gt;处理函数签名&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;typedef void (*query_handler_t)(const char *key, cJSON *values_item);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所有查询和设定 handler 都遵守同一签名（入参为 &lt;code&gt;key&lt;/code&gt; 和 &lt;code&gt;values&lt;/code&gt;），确保可以用函数指针统一调用。&lt;/p&gt;
&lt;h3&gt;分发表定义&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;typedef struct {
    const char      *item;     // 模块名称
    query_handler_t  query;    // code == 0 时调用
    query_handler_t  set;      // code == 1 时调用
} ItemDispatch_t;

static const ItemDispatch_t dispatch_table[] = {
    { &quot;status&quot;, query_status, (query_handler_t)set_status },
    { &quot;camera&quot;, query_camera, (query_handler_t)set_camera },
    { &quot;device&quot;, query_device, NULL },  // device 的 set 需要特殊处理
};
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;入口函数中的调度逻辑&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;// 1. 遍历分发表，O(n) 查找 item
const ItemDispatch_t *entry = NULL;
for (size_t i = 0; i &amp;lt; DISPATCH_TABLE_SIZE; i++) {
    if (strcasecmp(item, dispatch_table[i].item) == 0) {
        entry = &amp;amp;dispatch_table[i];
        break;
    }
}

// 2. 未找到 → 统一错误响应
if (!entry) {
    ws_reply(0, &quot;不支持的item&quot;, key, &quot;&quot;);
    cJSON_Delete(json);
    return;
}

// 3. 按 code 分发
if (code == 0) {
    entry-&amp;gt;query(key, values_item);
} else if (code == 1) {
    // device 模块的 set 需要额外传入 json（支持重启前释放资源）
    if (strcasecmp(item, &quot;device&quot;) == 0) {
        if (set_device(key, values_item, json)) return;
    } else if (entry-&amp;gt;set) {
        entry-&amp;gt;set(key, values_item);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;入口函数体只有约 50 行，且不含任何具体业务分支。&lt;/strong&gt; 所有业务逻辑都下沉到各个 handler 中。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;5. 业务 Handler 内部：key 级别的平铺&lt;/h1&gt;
&lt;p&gt;每个 handler 内部再按 &lt;code&gt;key&lt;/code&gt; 做一层 &lt;code&gt;if-else&lt;/code&gt;，但此时每个函数只关心自己模块的字段，职责单一：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static void query_camera(const char *key, cJSON *values_item) {
    sensor_t *s = esp_camera_sensor_get();
    if (!s) { ws_reply(0, &quot;相机未初始化&quot;, key, &quot;&quot;); return; }

    char vbuf[32];
    if (strcasecmp(key, &quot;frame_size&quot;) == 0) {
        ws_reply(1, &quot;OK.&quot;, key, framesize_to_str(s-&amp;gt;status.framesize));
    } else if (strcasecmp(key, &quot;jpeg_quality&quot;) == 0) {
        snprintf(vbuf, sizeof(vbuf), &quot;%d&quot;, s-&amp;gt;status.quality);
        ws_reply(1, &quot;OK.&quot;, key, vbuf);
    } else {
        ws_reply(0, &quot;未知的camera键&quot;, key, &quot;&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;每个 handler &lt;strong&gt;不超过 30 行&lt;/strong&gt;，context 清晰&lt;/li&gt;
&lt;li&gt;错误处理统一走 &lt;code&gt;ws_reply&lt;/code&gt; 返回标准格式&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h1&gt;6. 统一响应：ws_reply&lt;/h1&gt;
&lt;p&gt;所有对外输出都通过一个函数完成，保证协议格式一致：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;static void ws_reply(int ok, const char *msg, const char *key, const char *values) {
    char buf[256];
    snprintf(buf, sizeof(buf),
             &quot;{\&quot;code\&quot;:%d,\&quot;msg\&quot;:\&quot;%s\&quot;,\&quot;key\&quot;:\&quot;%s\&quot;,\&quot;values\&quot;:\&quot;%s\&quot;}&quot;,
             ok ? 1 : 0, msg, key ? key : &quot;&quot;, values ? values : &quot;&quot;);
    WebsocketSendText(buf, strlen(buf));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这带来的好处：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;修改响应格式只改一处&lt;/li&gt;
&lt;li&gt;不会出现某个分支忘了返回 &lt;code&gt;code&lt;/code&gt; 字段的问题&lt;/li&gt;
&lt;li&gt;调用处代码极其简洁&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h1&gt;7. 特殊分支的优雅处理&lt;/h1&gt;
&lt;p&gt;并非所有模块都能完美套入统一签名。&lt;code&gt;device&lt;/code&gt; 模块的 &lt;code&gt;restart&lt;/code&gt; 指令需要在重启前手动释放 JSON 对象，函数签名多了一个 &lt;code&gt;cJSON *json&lt;/code&gt; 参数。&lt;/p&gt;
&lt;p&gt;处理方式是在分发表中将 &lt;code&gt;device.set&lt;/code&gt; 设为 &lt;code&gt;NULL&lt;/code&gt;，在入口函数中做一次显式判断：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if (strcasecmp(item, &quot;device&quot;) == 0) {
    if (set_device(key, values_item, json))
        return;  // json 已在 set_device 内部释放（重启场景）
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;set_device&lt;/code&gt; 返回 &lt;code&gt;bool&lt;/code&gt; 来告知调用方是否已接管 &lt;code&gt;json&lt;/code&gt; 的生命周期——这是一个&lt;strong&gt;最小特例化&lt;/strong&gt;的策略：不为了追求完美统一而引入复杂的抽象，而是在必要时用最小的代码量处理例外。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;8. 扩展性分析&lt;/h1&gt;
&lt;h2&gt;新增一个 item 模块（如 &lt;code&gt;sensor&lt;/code&gt;）&lt;/h2&gt;
&lt;p&gt;只需 3 步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;编写 &lt;code&gt;query_sensor()&lt;/code&gt; 和 &lt;code&gt;set_sensor()&lt;/code&gt; 两个函数&lt;/li&gt;
&lt;li&gt;在分发表加一行：&lt;pre&gt;&lt;code&gt;{ &quot;sensor&quot;, query_sensor, (query_handler_t)set_sensor },
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;完成。入口函数 &lt;strong&gt;零改动&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;新增一个 key（如给 camera 加 &lt;code&gt;brightness&lt;/code&gt;）&lt;/h2&gt;
&lt;p&gt;只需在 &lt;code&gt;query_camera&lt;/code&gt; / &lt;code&gt;set_camera&lt;/code&gt; 中各加一个 &lt;code&gt;else if&lt;/code&gt; 分支。其他模块和入口函数完全不受影响。&lt;/p&gt;
&lt;h2&gt;新增一个枚举映射（如 &lt;code&gt;FRAMESIZE_QVGA&lt;/code&gt;）&lt;/h2&gt;
&lt;p&gt;在 &lt;code&gt;fs_table[]&lt;/code&gt; 加一行即可，&lt;code&gt;to_str&lt;/code&gt; 和 &lt;code&gt;from_str&lt;/code&gt; 自动适配。&lt;/p&gt;
&lt;hr /&gt;
&lt;h1&gt;9. 设计要点总结&lt;/h1&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;strong&gt;表驱动替代分支&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;映射表消除 switch-case，分发表消除 if-else 嵌套&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;单一职责&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;入口函数只做路由，业务逻辑下沉到各 handler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;统一接口&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;所有 handler 遵循 &lt;code&gt;(key, values_item)&lt;/code&gt; 签名&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;统一出口&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ws_reply()&lt;/code&gt; 保证响应格式一致&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;最小特例化&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;仅 &lt;code&gt;device.set&lt;/code&gt; 做特殊处理，不过度抽象&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;编译期安全&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;映射表和分发表均为 &lt;code&gt;const&lt;/code&gt;，存 &lt;code&gt;.rodata&lt;/code&gt; 段&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;零外部依赖&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;仅依赖 cJSON + ESP-IDF 标准 API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h1&gt;10. 适用场景&lt;/h1&gt;
&lt;p&gt;这套模式适用于任何「&lt;strong&gt;协议字段 → 处理函数&lt;/strong&gt;」的映射场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;MQTT 主题路由&lt;/li&gt;
&lt;li&gt;HTTP REST 端点分发&lt;/li&gt;
&lt;li&gt;串口 AT 指令解析&lt;/li&gt;
&lt;li&gt;BLE 特征值读写回调&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;核心思想不变：&lt;strong&gt;用数据（表）代替控制流（分支），用函数指针代替内联逻辑。&lt;/strong&gt; 代码量可能差不多，但可读性和可维护性天差地别。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;基于 ESP-IDF v5.x + cJSON，运行于 ESP32 平台。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>linux systemd 服务管理器的使用</title><link>https://www.mintlab.top/posts/linux_systemd%E7%AE%A1%E7%90%86%E5%99%A8/</link><guid isPermaLink="true">https://www.mintlab.top/posts/linux_systemd%E7%AE%A1%E7%90%86%E5%99%A8/</guid><description>打计设搭建了一个物联网平台, 后端用的就是fastapi+systemd服务管理, 本文记录了我学习的参考文章和简要命令, 方便速查</description><pubDate>Tue, 17 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;参考了以下文章, 写得超好!&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.axiaoxin.com/post/systemd-config-guide/&quot;&gt;systemd 服务配置完全指南：从入门到精通&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.arloor.com/posts/systemd_user_guide/&quot;&gt;systemd用户模式和user journal使用指南&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;简要命令&lt;/h2&gt;
&lt;h3&gt;重载 systemd 配置&lt;/h3&gt;
&lt;p&gt;重载所有 unit 文件（常用）&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl daemon-reload
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;启动服务并设置开机启动&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl start myapp.service
sudo systemctl enable myapp.service
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;查看服务状态与日志&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl status myapp
sudo journalctl -fu myapp
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;列出所有服务&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;systemctl list-units --type=service
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;systemd 用户模式 (好用!)&lt;/h2&gt;
&lt;p&gt;简单说，systemd 不只是给 root 用的。每个登录用户都可以跑自己的 systemd --user 实例，用来管理用户级别的后台服务和定时任务——不需要 sudo。&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;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.config/systemd/user/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;用户自定义的服务(推荐!)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~/.local/share/systemd/user/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;用户安装的服务&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/usr/lib/systemd/user/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;系统提供的用户服务模板&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;所有操作都带 --user 参数，和系统级的 systemctl 用法几乎一样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;systemctl --user start my-app       # 启动
systemctl --user stop my-app        # 停止
systemctl --user restart my-app     # 重启
systemctl --user status my-app      # 查看状态
systemctl --user enable my-app      # 开机自启
systemctl --user daemon-reload      # 重新加载配置
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;有一个前提：必须启用 linger，否则用户注销后所有用户服务都会被杀掉：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo loginctl enable-linger $USER
loginctl show-user $USER | grep Linger   # 确认输出 Linger=yes
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;systemd文件配置示例&lt;/h2&gt;
&lt;p&gt;以这个博客的fastapi服务文件为例&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//file: /home/mint/.config/systemd/user/blog_backend.service

[Unit]
Description=Blog Fastapi Service
After=network.target
 
[Service]
WorkingDirectory=/home/mint/web/blog_backend/app
Environment=&quot;PATH=/home/mint/web/blog_backend/.venv/bin:/usr/local/bin:/usr/bin&quot;
Environment=&quot;FASTAPI_DEPLOY=DEPLOY&quot;           # 用于区分测试和部署环境
Environment=&quot;SMTP_USER=******&quot;                # 以下均是通过环境变量引入敏感数据
Environment=&quot;SMTP_PASSWORD=******&quot;
Environment=&quot;SECRET_KEY=******&quot;
Environment=&quot;DB_USER=******&quot;
Environment=&quot;DB_PASSWORD=******&quot;
Environment=&quot;DB_NAME=******&quot;
Environment=&quot;DB_HOST=******&quot;
Environment=&quot;DB_SCHEMA=******&quot;
ExecStart=/home/mint/web/blog_backend/.venv/bin/gunicorn \
          -w 4 \
          -k uvicorn.workers.UvicornWorker \
          --bind localhost:8051 \
          --log-file=- \
          main:app
StandardOutput=journal                        # 将控制台输出重定向到journal
Restart=always                                # 自动重启
RestartSec=10  
LimitNOFILE=4096  
 
[Install]
WantedBy=default.target
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;后记&lt;/h2&gt;
&lt;p&gt;还有定时器功能等等, 还没用上, 可先参考上方列出来的文章&lt;/p&gt;
</content:encoded></item><item><title>vscode esp-idf开发环境安装及arduino移植</title><link>https://www.mintlab.top/posts/esp-idf%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E5%8F%8Aarduino%E7%A7%BB%E6%A4%8D/</link><guid isPermaLink="true">https://www.mintlab.top/posts/esp-idf%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E5%8F%8Aarduino%E7%A7%BB%E6%A4%8D/</guid><description>基于linux esp-idf v5.1.4和arduino3.0.0配置开发环境</description><pubDate>Sun, 08 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/0421a36068c99d7841069b68ec0df68d.DHQ1XMmo.png&quot; alt=&quot;&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/2cb7ffa41e914743f2868825236fe149.DzJl50dD.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;p&gt;:::note
之所以写这篇博客, 是因为乐鑫做的实在是太垃圾了.&lt;/p&gt;
&lt;p&gt;手动安装VScode插件识别不到, 默认安装版本太高arduino不适配, Vscode启动安装管理器安装居然要在vscode上按一下回车确认我的github用户名???&lt;/p&gt;
&lt;p&gt;我瞪了半天安装管理器, 没发现vsc命令窗口上有个超小的输入框确认, 没确认就不开始下载, 等了半天自己居然崩溃了?!&lt;/p&gt;
&lt;p&gt;我tm整整装了四遍, 等进度条的时候写下了这篇博客
:::&lt;/p&gt;
&lt;h1&gt;esp-idf环境安装&lt;/h1&gt;
&lt;p&gt;:::note
手动安装或使用&lt;code&gt;source export.sh&lt;/code&gt;会导致idf插件无法识别虚拟环境&lt;/p&gt;
&lt;p&gt;两种方案原生无法共存, 所以哪个都行, 后文有解决方案.
:::&lt;/p&gt;
&lt;h2&gt;方案A: 通过VSCode插件安装(通过官方管理器)&lt;/h2&gt;
&lt;p&gt;参考&lt;a href=&quot;https://docs.espressif.com/projects/vscode-esp-idf-extension/zh_CN/latest/installation.html&quot;&gt;安装 ESP-IDF 和相关工具&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;如果你&lt;strong&gt;不使用VSCode插件&lt;/strong&gt;, 参考&lt;a href=&quot;https://docs.espressif.com/projects/esp-idf/zh_CN/latest/esp32h2/get-started/linux-setup.html#eim-cli&quot;&gt;在 Linux 上安装 ESP-IDF 及工具链&lt;/a&gt;直接下载管理器安装&lt;/p&gt;
&lt;p&gt;vscode安装扩展&lt;code&gt;ESP-IDF&lt;/code&gt;, 然后跟随欢迎页面提示安装,.&lt;/p&gt;
&lt;p&gt;或者在vsc命令面板输入&lt;code&gt;ESP-IDF: Open ESP-IDF Install Manager&lt;/code&gt;安装.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/54e058ae09dd86c6e2f00596ad5658c6.wR-UGukU.png&quot; alt=&quot;欢迎页面&quot; /&gt;&lt;/p&gt;
&lt;p&gt;:::important
正如我开头提到的, 确认完下载后, 答应我一定要看VScode顶上的命令框好吗, 没确认就不开始下载, 我哭死
:::&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/2e2834810d4d3a6bde6ac54171cea436.st88KmxW.png&quot; alt=&quot;&quot; /&gt;
&lt;img src=&quot;https://www.mintlab.top/_astro/d27cb40e408a71069b6992c09bd2b134.54XqKZjz.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;方案B: 手动安装&lt;/h2&gt;
&lt;p&gt;参考&lt;a href=&quot;https://docs.espressif.com/projects/esp-idf/zh_CN/latest/esp32h2/get-started/linux-macos-setup-legacy.html&quot;&gt;Linux 和 macOS 平台工具链的标准设置（已过时）&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;以下是基于Ubuntu 和 Debian的安装方案&lt;/p&gt;
&lt;h3&gt;1.安装依赖(已安装的会自动跳过)&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo apt-get install git wget flex bison gperf python3 python3-pip python3-venv cmake ninja-build ccache libffi-dev libssl-dev dfu-util libusb-1.0-0
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 从Github克隆esp-idf&lt;/h3&gt;
&lt;p&gt;::github{repo=&quot;espressif/esp-idf&quot;}&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir -p ~/esp
cd ~/esp
git clone -b v5.1.4 --single-branch --recursive https://github.com/espressif/esp-idf.git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::important
&lt;code&gt;--recursive&lt;/code&gt;必须要加, 因为这会下载所有的子仓库&lt;/p&gt;
&lt;p&gt;&lt;code&gt;-b v5.1.4&lt;/code&gt;必须要加, 截止2026.3 esp-idf已经更新到v6.1, 但arduino组件仅支持v5.x版本的esp-idf
:::&lt;/p&gt;
&lt;h3&gt;3. 安装所需芯片的工具包&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cd ~/esp/esp-idf
./install.sh all  # 安装所有芯片

./install.sh esp32,esp32s2 # 安装指定的几个芯片
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;:::note
工具包默认安装到&lt;code&gt;~/.espressif&lt;/code&gt;定制安装目录参见前文&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://docs.espressif.com/projects/esp-idf/zh_CN/latest/esp32h2/get-started/linux-macos-setup-legacy.html&quot;&gt;Linux 和 macOS 平台工具链的标准设置（已过时）&lt;/a&gt;
:::&lt;/p&gt;
&lt;h3&gt;4. 环境配置&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;vim ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在&lt;code&gt;.bashrc&lt;/code&gt;中加入以下内容, 保存后别忘了&lt;code&gt;source ~./bashrc&lt;/code&gt;使配置生效&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;
export IDF_PATH=&quot;$HOME/esp/esp-idf/:$PATH&quot;
# 设置一个别名, 
alias get_idf=&apos;. $IDF_PATH/export.sh&apos;

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;请在需要运行 ESP-IDF 的终端窗口运行以下命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;. $HOME/esp/esp-idf/export.sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者直接运行&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;get_idf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;看到下图即说明安装成功
&lt;img src=&quot;https://www.mintlab.top/_astro/1d443a13e0ba9acea619d129a4421fb5.Dd-fNWWu.png&quot; alt=&quot;get_idf结果&quot; /&gt;&lt;/p&gt;
&lt;h2&gt;vscode ESP-IDF插件编译 和 idf.py手动编译兼容&lt;/h2&gt;
&lt;p&gt;以v5.5.3版本为例&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;首先, 你一定执行过&lt;code&gt;./install.sh&lt;/code&gt;和&lt;code&gt;$HOME/esp/esp-idf/export.sh&lt;/code&gt;,出现了上图的界面, 证明&lt;code&gt;idf.py&lt;/code&gt;已经可以使用.&lt;/li&gt;
&lt;li&gt;但是ESP-IDF插件的编译虚拟环境没有使用&lt;code&gt;export.sh&lt;/code&gt;, 而是使用了&lt;code&gt;source $HOME/.espressif/tools/activate_idf_v5.5.3.sh&lt;/code&gt;, 其内部设置的虚拟环境和export.sh的虚拟环境冲突, 如果设置了export.sh, 则vsc插件的环境就会消失&lt;/li&gt;
&lt;li&gt;所以我们要做的是将&lt;code&gt;activate_idf_v5.5.3.sh&lt;/code&gt;内的虚拟环境改为&lt;code&gt;export.sh&lt;/code&gt;中的虚拟环境&lt;/li&gt;
&lt;li&gt;&lt;code&gt;export.sh&lt;/code&gt;中的虚拟环境位置在&lt;code&gt;$HOME/.espressif/python_env/idf5.5_py3.12_env&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;//file: /home/mint/.espressif/tools/activate_idf_v5.5.3.sh
l-files activate_idf_v5.5.3.sh &apos;activate_idf_v5.5.3 (副本).sh&apos;
9c9
+ IDF_PYTHON_ENV_PATH:/home/mint/.espressif/python_env/idf5.5_py3.12_env
- IDF_PYTHON_ENV_PATH:/home/mint/.espressif/tools/python/v5.5.3/venv
97c97
+     export PATH=&quot;/home/mint/.espressif/tools/cmake/3.30.2/bin:/home/mint/.espressif/tools/esp-clang/esp-19.1.2_20250312/esp-clang/bin:/home/mint/.espressif/tools/esp-rom-elfs/20241011/:/home/mint/.espressif/tools/esp32ulp-elf/2.38_20240113/esp32ulp-elf/bin:/home/mint/.espressif/tools/esp32ulp-elf/2.38_20240113/esp32ulp-elf/esp32ulp-elf/bin:/home/mint/.espressif/tools/ninja/1.12.1/:/home/mint/.espressif/tools/openocd-esp32/v0.12.0-esp32-20251215/openocd-esp32/bin:/home/mint/.espressif/tools/riscv32-esp-elf-gdb/16.3_20250913/riscv32-esp-elf-gdb/bin:/home/mint/.espressif/tools/riscv32-esp-elf/esp-14.2.0_20251107/riscv32-esp-elf/bin:/home/mint/.espressif/tools/riscv32-esp-elf/esp-14.2.0_20251107/riscv32-esp-elf/riscv32-esp-elf/bin:/home/mint/.espressif/tools/xtensa-esp-elf-gdb/16.3_20250913/xtensa-esp-elf-gdb/bin:/home/mint/.espressif/tools/xtensa-esp-elf/esp-14.2.0_20251107/xtensa-esp-elf/bin:/home/mint/.espressif/tools/xtensa-esp-elf/esp-14.2.0_20251107/xtensa-esp-elf/xtensa-esp-elf/bin:/home/mint/.espressif/python_env/idf5.5_py3.12_env:/home/mint/.espressif/v5.5.3/esp-idf/components/esptool_py/esptool:$PATH&quot;
-     export PATH=&quot;/home/mint/.espressif/tools/cmake/3.30.2/bin:/home/mint/.espressif/tools/esp-clang/esp-19.1.2_20250312/esp-clang/bin:/home/mint/.espressif/tools/esp-rom-elfs/20241011/:/home/mint/.espressif/tools/esp32ulp-elf/2.38_20240113/esp32ulp-elf/bin:/home/mint/.espressif/tools/esp32ulp-elf/2.38_20240113/esp32ulp-elf/esp32ulp-elf/bin:/home/mint/.espressif/tools/ninja/1.12.1/:/home/mint/.espressif/tools/openocd-esp32/v0.12.0-esp32-20251215/openocd-esp32/bin:/home/mint/.espressif/tools/riscv32-esp-elf-gdb/16.3_20250913/riscv32-esp-elf-gdb/bin:/home/mint/.espressif/tools/riscv32-esp-elf/esp-14.2.0_20251107/riscv32-esp-elf/bin:/home/mint/.espressif/tools/riscv32-esp-elf/esp-14.2.0_20251107/riscv32-esp-elf/riscv32-esp-elf/bin:/home/mint/.espressif/tools/xtensa-esp-elf-gdb/16.3_20250913/xtensa-esp-elf-gdb/bin:/home/mint/.espressif/tools/xtensa-esp-elf/esp-14.2.0_20251107/xtensa-esp-elf/bin:/home/mint/.espressif/tools/xtensa-esp-elf/esp-14.2.0_20251107/xtensa-esp-elf/xtensa-esp-elf/bin:/home/mint/.espressif/tools/python/v5.5.3/venv/bin:$PATH&quot;
103c103
+     venv_path=&quot;/home/mint/.espressif/python_env/idf5.5_py3.12_env&quot;
-     venv_path=&quot;$1&quot;
138c138
+ alias idf.py=&quot;/home/mint/.espressif/v5.5.3/esp-idf/tools/idf.py&quot;
- alias idf.py=&quot;/home/mint/.espressif/tools/python/v5.5.3/venv/bin/python /home/mint/.espressif/v5.5.3/esp-idf/tools/idf.py&quot;
140c140
+ alias esptool.py=&quot;/home/mint/.espressif/v5.5.3/esp-idf/components/esptool_py/esptool/esptool.py&quot;
- alias esptool.py=&quot;/home/mint/.espressif/tools/python/v5.5.3/venv/bin/python /home/mint/.espressif/v5.5.3/esp-idf/components/esptool_py/esptool/esptool.py&quot;
142c142
+ alias espefuse.py=&quot;/home/mint/.espressif/v5.5.3/esp-idf/components/esptool_py/esptool/espefuse.py&quot;
- alias espefuse.py=&quot;/home/mint/.espressif/tools/python/v5.5.3/venv/bin/python /home/mint/.espressif/v5.5.3/esp-idf/components/esptool_py/esptool/espefuse.py&quot;
144c144
+ alias espsecure.py=&quot;/home/mint/.espressif/v5.5.3/esp-idf/components/esptool_py/esptool/espsecure.py&quot;
- alias espsecure.py=&quot;/home/mint/.espressif/tools/python/v5.5.3/venv/bin/python /home/mint/.espressif/v5.5.3/esp-idf/components/esptool_py/esptool/espsecure.py&quot;
146c146
+ alias otatool.py=&quot;/home/mint/.espressif/v5.5.3/esp-idf/components/app_update/otatool.py&quot;
- alias otatool.py=&quot;/home/mint/.espressif/tools/python/v5.5.3/venv/bin/python /home/mint/.espressif/v5.5.3/esp-idf/components/app_update/otatool.py&quot;
148c148
+ alias parttool.py=&quot;/home/mint/.espressif/v5.5.3/esp-idf/components/partition_table/parttool.py&quot;
- alias parttool.py=&quot;/home/mint/.espressif/tools/python/v5.5.3/venv/bin/python /home/mint/.espressif/v5.5.3/esp-idf/components/partition_table/parttool.py&quot;
156c156
+ venv_default=&quot;/home/mint/.espressif/python_env/idf5.5_py3.12_env&quot;
- venv_default=&quot;/home/mint/.espressif/tools/python/v5.5.3/venv&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;arduino组件添加&lt;/h1&gt;
&lt;p&gt;参考&lt;a href=&quot;https://docs.espressif.com/projects/arduino-esp32/en/latest/esp-idf_component.html&quot;&gt;Arduino as an ESP-IDF component&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;要使用 IDF 组件管理器将 Arduino 组件添加到项目中，请在项目目录中运行以下命令：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;idf.py add-dependency &quot;espressif/arduino-esp32^3.3.7&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者，可以使用 Arduino 组件从模板创建一个新项目：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;idf.py create-project-from-example &quot;espressif/arduino-esp32^3.3.7:hello_world&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;配置&lt;/h2&gt;
&lt;p&gt;根据以下两个选项之一，在 &lt;code&gt;idf.py menuconfig&lt;/code&gt; 中设置相应的设置。&lt;/p&gt;
&lt;p&gt;前往该部分&lt;code&gt;Arduino Configuration ---&amp;gt;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;功能使用说明&lt;code&gt;app_main()&lt;/code&gt;- &lt;strong&gt;关闭&lt;/strong&gt;&lt;code&gt;Autostart Arduino setup and loop on boot&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;使用方法&lt;code&gt;setup()&lt;/code&gt;和&lt;code&gt;loop()&lt;/code&gt;功能 - &lt;strong&gt;打开&lt;/strong&gt;&lt;code&gt;Autostart Arduino setup and loop on boot&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;由于 Arduino 库使用了 C++ 特性，您需要将一些文件扩展名从 .c .c++替换为 .c++ .cpp：&lt;/p&gt;
&lt;p&gt;在主文件夹中，将文件main.c重命名为main.cpp。&lt;/p&gt;
&lt;p&gt;在主文件夹中打开CMakeLists.txt文件，并按照以下说明将main.c更改为main.cpp 。&lt;/p&gt;
&lt;h3&gt;方案一：使用 Arduino 的 setup() 和 loop() 函数&lt;/h3&gt;
&lt;p&gt;你的 main.cpp 文件应该像其他草图文件一样格式化。别忘了包含Arduino.h.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//file: main.cpp
#include &quot;Arduino.h&quot;

void setup(){
  Serial.begin(115200);
  while(!Serial){
    ; // wait for serial port to connect
  }
}

void loop(){
    Serial.println(&quot;loop&quot;);
    delay(1000);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;选项 2. 使用 ESP-IDF appmain()&lt;/h3&gt;
&lt;p&gt;你需要在 &lt;code&gt;main.cpp&lt;/code&gt; 中实现&lt;code&gt;app_main()&lt;/code&gt;并调用&lt;code&gt;initArduino();&lt;/code&gt;它。&lt;/p&gt;
&lt;p&gt;请注意，在这种情况下不会调用 &lt;code&gt;setup()&lt;/code&gt; 和 &lt;code&gt;loop()&lt;/code&gt; 函数。此外，这app_main()是一个单次执行的普通函数，因此如果您需要像 Arduino 那样的无限循环，请将其放在其他地方。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;//file: main.cpp
#include &quot;Arduino.h&quot;

extern &quot;C&quot; void app_main()
{
  initArduino();

  // Arduino-like setup()
  Serial.begin(115200);
  while(!Serial){
    ; // wait for serial port to connect
  }

  // Arduino-like loop()
  while(true){
    Serial.println(&quot;loop&quot;);
  }

  // WARNING: if program reaches end of function app_main() the MCU will restart.
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;最终结果&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/54afcd195a6d3ac4155dc74e7eab8a78.DbBPx-Nd.png&quot; alt=&quot;&quot; /&gt;&lt;/p&gt;
</content:encoded></item><item><title>stc8g makefile和用量分析脚本</title><link>https://www.mintlab.top/posts/stc8g%E7%BC%96%E8%AF%91%E8%84%9A%E6%9C%AC/stc8g_makefile/</link><guid isPermaLink="true">https://www.mintlab.top/posts/stc8g%E7%BC%96%E8%AF%91%E8%84%9A%E6%9C%AC/stc8g_makefile/</guid><description>基于makefile和awk脚本的代码flash用量分析,输出占用量前TOP20的函数</description><pubDate>Sat, 03 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;makefile&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;
#!/usr/bin/make -f
# Makefile for SDCC project with automatic library discovery
CC = sdcc
CFLAGS = --model-medium --opt-code-size

# 定义库名称（只需要在这里添加）
LIBRARIES = User System

# 单片机型号
MCU = STC8G1K08A
# 串口设备
SERIAL_PORT = /dev/ttyUSB0

# 生成包含目录路径
INCLUDES = $(foreach lib, $(LIBRARIES), -I $(lib)/Inc)

# 查找所有源文件
SRCS = $(foreach lib, $(LIBRARIES), $(wildcard $(lib)/Src/*.c))
# 获取所有.c文件的基本名称（用于检查重名）
BASENAMES = $(foreach src, $(SRCS), $(notdir $(src)))

# 检查是否有重复的源文件名
DUPLICATES = $(filter $(words $(BASENAMES)),$(words $(sort $(BASENAMES))))
ifneq ($(DUPLICATES),$(words $(BASENAMES)))
    $(warning 警告: 发现重复的源文件名！)
    $(warning 重复文件: $(foreach base,$(sort $(foreach b,$(BASENAMES),$(if $(filter 2,$(words $(filter $(b),$(BASENAMES)))),$(b)))),$(base)))
endif

# 生成对象文件列表（保持目录结构）
OBJS = $(patsubst %.c,%.rel,$(SRCS))

# 项目名称
TARGET = pwm

# 构建目录
BUILD_DIR = build

# 将对象文件放在build目录中
OBJS_BUILD = $(patsubst %.rel,$(BUILD_DIR)/%.rel,$(OBJS))

# 默认目标：构建并生成报告
all: check_libs $(BUILD_DIR) $(BUILD_DIR)/report
	@echo &quot;构建完成: $(BUILD_DIR)/$(TARGET).hex&quot;

# 检查库目录是否存在
check_libs:
	@for lib in $(LIBRARIES); do \
		if [ ! -d &quot;$$lib/Inc&quot; ] || [ ! -d &quot;$$lib/Src&quot; ]; then \
			echo &quot;错误: 库 $$lib 结构不正确！需要 $$lib/Inc 和 $$lib/Src 目录&quot;; \
			exit 1; \
		fi; \
	done

# 创建构建目录
$(BUILD_DIR):
	@mkdir -p $(BUILD_DIR)

# 编译规则：所有.c文件 -&amp;gt; build/目录下的.rel文件
$(BUILD_DIR)/%.rel: %.c
	@echo &quot;编译: $&amp;lt;&quot;
	@mkdir -p $(dir $@)
	@$(CC) -c $(CFLAGS) $(INCLUDES) $&amp;lt; -o $@ 2&amp;gt;&amp;amp;1 | head -20

# 链接
$(BUILD_DIR)/$(TARGET).ihx: $(OBJS_BUILD)
	@echo &quot;链接: $(words $(OBJS_BUILD)) 个对象文件...&quot;
	@cd $(BUILD_DIR) &amp;amp;&amp;amp; $(CC) $(CFLAGS) $(patsubst $(BUILD_DIR)/%,%,$(OBJS_BUILD)) -o $(TARGET).ihx

# 生成HEX
$(BUILD_DIR)/$(TARGET).hex: $(BUILD_DIR)/$(TARGET).ihx
	@echo &quot;生成HEX文件...&quot;
	@cd $(BUILD_DIR) &amp;amp;&amp;amp; packihx $(TARGET).ihx &amp;gt; $(TARGET).hex

+ # report: 调用外部脚本解析 map 并打印报告
+ $(BUILD_DIR)/report: $(BUILD_DIR)/$(TARGET).hex
+	@echo &quot;生成资源使用报告...&quot;
+	@sh scripts/map_report.sh $(BUILD_DIR)/$(TARGET).map || true

# 下载到单片机
flash: $(BUILD_DIR)/$(TARGET).hex
	@echo &quot;下载到 $(MCU) via $(SERIAL_PORT)...&quot;
	stcgal -p $(SERIAL_PORT) -t 22168 -o program_eeprom_split=12288 -a $(BUILD_DIR)/$(TARGET).hex

# 编译但不链接（调试用）
compile: $(OBJS_BUILD)
	@echo &quot;编译完成，生成 $(words $(OBJS_BUILD)) 个.rel文件&quot;

# 显示项目信息
info:
	@echo &quot;========================================&quot;
	@echo &quot;项目: $(TARGET)&quot;
	@echo &quot;编译器: $(CC)&quot;
	@echo &quot;库列表: $(LIBRARIES)&quot;
	@echo &quot;包含目录: $(INCLUDES)&quot;
	@echo &quot;源文件 ($(words $(SRCS)) 个):&quot;
	@for src in $(sort $(SRCS)); do echo &quot;  $$src&quot;; done
	@echo &quot;对象文件 ($(words $(OBJS_BUILD)) 个):&quot;
	@for obj in $(sort $(OBJS_BUILD)); do echo &quot;  $$obj&quot;; done
	@echo &quot;构建目录: $(BUILD_DIR)&quot;
	@echo &quot;========================================&quot;

# 清理
clean:
	@echo &quot;清理构建文件...&quot;
	@rm -rf $(BUILD_DIR)
	@rm -f *.ihx *.hex *.rel *.lk *.map *.mem *.rst *.asm *.lst *.sym *.cdb

# 帮助信息
help:
	@echo &quot;可用命令:&quot;
	@echo &quot;  make           - 构建整个项目&quot;
	@echo &quot;  make clean     - 清理所有生成文件&quot;
	@echo &quot;  make flash     - 下载到单片机&quot;
	@echo &quot;  make info      - 显示项目信息&quot;
	@echo &quot;  make compile   - 只编译不链接&quot;
	@echo &quot;  make help      - 显示此帮助信息&quot;

.PHONY: all clean flash info help check_libs compile tidy

&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;资源分析脚本&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;
#!/bin/sh
# Enhanced map report for SDCC projects
# Usage: map_report.sh &amp;lt;map-file&amp;gt; [ROM_TOTAL_BYTES] [RAM_TOTAL_BYTES]
# If ROM_TOTAL_BYTES / RAM_TOTAL_BYTES are not provided, script tries to
#  - read a companion .mem file (same base name) for ROM total
#  - otherwise falls back to defaults: ROM=8k+4k (12288), RAM=1k+256 (1280)

MAPFILE=&quot;$1&quot;
ROM_OVERRIDE=&quot;$2&quot;
RAM_OVERRIDE=&quot;$3&quot;

if [ -z &quot;$MAPFILE&quot; ] || [ ! -f &quot;$MAPFILE&quot; ]; then
    echo &quot;map 报告: 未提供 map 文件 或 文件不存在: $MAPFILE&quot;
    exit 0
fi

hex2dec() {
    [ -z &quot;$1&quot; ] &amp;amp;&amp;amp; echo 0 &amp;amp;&amp;amp; return
    # accept 0x... or bare hex (without 0x)
    case &quot;$1&quot; in
        0x*|0X*) printf &quot;%d&quot; &quot;$1&quot; ;;
        *) printf &quot;%d&quot; &quot;0x$1&quot; ;;
    esac
}

# Try to extract code/const/idata/pdata/xdata sizes from map file.
# We look for sections by name and take the &quot;Size&quot; column where possible.
CSEG_START_HEX=$(awk &apos;/^[[:space:]]*CSEG[[:space:]]+[0-9A-Fa-f]/ {print $2; exit}&apos; &quot;$MAPFILE&quot; | sed &apos;s/0x//g&apos;)
CSEG_HEX=$(awk &apos;/^[[:space:]]*CSEG[[:space:]]+[0-9A-Fa-f]/ {print $3; exit}&apos; &quot;$MAPFILE&quot; | sed &apos;s/0x//g&apos;)
CONST_HEX=$(awk &apos;/^[[:space:]]*CONST[[:space:]]+[0-9A-Fa-f]/ {print $3; exit}&apos; &quot;$MAPFILE&quot; | sed &apos;s/0x//g&apos;)
DSEG_HEX=$(awk &apos;/^[[:space:]]*DSEG[[:space:]]+[0-9A-Fa-f]/ {print $3; exit}&apos; &quot;$MAPFILE&quot; | sed &apos;s/0x//g&apos;)
PSEG_HEX=$(awk &apos;/^[[:space:]]*PSEG[[:space:]]+[0-9A-Fa-f]/ {print $3; exit}&apos; &quot;$MAPFILE&quot; | sed &apos;s/0x//g&apos;)
XSEG_HEX=$(awk &apos;/^[[:space:]]*XSEG[[:space:]]+[0-9A-Fa-f]/ {print $3; exit}&apos; &quot;$MAPFILE&quot; | sed &apos;s/0x//g&apos;)

CSEG=$(hex2dec &quot;$CSEG_HEX&quot;)
CONST=$(hex2dec &quot;$CONST_HEX&quot;)
DSEG=$(hex2dec &quot;$DSEG_HEX&quot;)
PSEG=$(hex2dec &quot;$PSEG_HEX&quot;)
XSEG=$(hex2dec &quot;$XSEG_HEX&quot;)

USED_ROM=$((CSEG + CONST))
USED_RAM=$((DSEG + PSEG + XSEG))

# compute CSEG end for module delta calc
CSEG_START=$(hex2dec &quot;$CSEG_START_HEX&quot;)
CSEG_END=$((CSEG_START + CSEG))

# Determine total ROM (flash) size: prefer override arg, then companion .mem file, then default
DEFAULT_ROM=$((8*1024 + 4*1024))
DEFAULT_RAM=$((1*1024 + 256))

ROM_TOTAL=&quot;&quot;
if [ -n &quot;$ROM_OVERRIDE&quot; ]; then
    ROM_TOTAL=&quot;$ROM_OVERRIDE&quot;
else
    MEMFILE=&quot;${MAPFILE%.map}.mem&quot;
    if [ -f &quot;$MEMFILE&quot; ]; then
        # Look for line like: ROM/EPROM/FLASH  0x0000   0x1e73    7796    65536
        ROM_TOTAL=$(awk &apos;/ROM\/EPROM\/FLASH/ {print $(NF-1); exit}&apos; &quot;$MEMFILE&quot;)
    fi
fi
if [ -z &quot;$ROM_TOTAL&quot; ]; then
    ROM_TOTAL=$DEFAULT_ROM
fi

RAM_TOTAL=&quot;&quot;
if [ -n &quot;$RAM_OVERRIDE&quot; ]; then
    RAM_TOTAL=&quot;$RAM_OVERRIDE&quot;
else
    # try to read some RAM hints from .mem (not always present)
    MEMFILE=&quot;${MAPFILE%.map}.mem&quot;
    if [ -f &quot;$MEMFILE&quot; ]; then
        # try to find a line like: PAGED EXT. RAM   0x0001   0x00ba     186      256
        RAM_TOTAL=$(awk &apos;/PAGED EXT. RAM|EXTERNAL RAM|Internal RAM layout:/ {line=NR} END{ if (line) { for(i=1;i&amp;lt;=NR;i++){} } }&apos; &quot;$MEMFILE&quot;)
        # above attempt is conservative; we won&apos;t rely on it — fallback to default if empty
        RAM_TOTAL=&quot;&quot;
    fi
fi
if [ -z &quot;$RAM_TOTAL&quot; ]; then
    RAM_TOTAL=$DEFAULT_RAM
fi

# Print summary with percentages
percent() {
    # percent &amp;lt;used&amp;gt; &amp;lt;total&amp;gt;
    awk &quot;BEGIN{ if ($2==0) printf \&quot;N/A\&quot;; else printf \&quot;%.1f\&quot;, ($1/$2)*100 }&quot;
}

echo &quot;Map 报告: $MAPFILE&quot;
echo &quot;----------------------------------------&quot;
echo &quot;ROM 总量: $ROM_TOTAL bytes&quot;
echo &quot;ROM 已用: $USED_ROM bytes (code=$CSEG const=$CONST)&quot;
echo &quot;ROM 使用率: $(percent $USED_ROM $ROM_TOTAL)%&quot;
echo &quot;&quot;
echo &quot;RAM 总量: $RAM_TOTAL bytes&quot;
echo &quot;RAM 已用: $USED_RAM bytes (idata=$DSEG pdata=$PSEG xdata=$XSEG)&quot;
echo &quot;RAM 使用率: $(percent $USED_RAM $RAM_TOTAL)%&quot;
echo &quot;----------------------------------------&quot;


# Top modules by C: entries (module name after C:)
# We&apos;ll convert the hex sizes to decimal using shell helper then aggregate with awk.
TMPFILE=&quot;/tmp/map_report.$$&quot;
rm -f &quot;$TMPFILE&quot;
# Build a sorted list of symbol addresses and their module
awk &apos;/^ *C:/ { addr=$2; sym=$3; mod=$NF; if (addr!=&quot;&quot; &amp;amp;&amp;amp; sym!=&quot;&quot;) print addr, sym, mod }&apos; &quot;$MAPFILE&quot; | sed &apos;s/\.rel//; s/\.o//; s/\.obj//&apos; | \
while read HEX SYM MOD; do
    [ -z &quot;$HEX&quot; -o -z &quot;$SYM&quot; ] &amp;amp;&amp;amp; continue
    DEC=$(hex2dec &quot;$HEX&quot;)
    printf &quot;%d %s %s\n&quot; &quot;$DEC&quot; &quot;$SYM&quot; &quot;$MOD&quot; &amp;gt;&amp;gt; &quot;$TMPFILE&quot;
done

if [ -f &quot;$TMPFILE&quot; ]; then
    SORTED=&quot;/tmp/map_report_sorted.$$&quot;
    sort -n &quot;$TMPFILE&quot; &amp;gt; &quot;$SORTED&quot;

    # compute deltas between consecutive symbols and attribute delta to the symbol (function)
    # SORTED format: &amp;lt;addr&amp;gt; &amp;lt;symbol&amp;gt; &amp;lt;module&amp;gt;
    SYMBOL_SIZES=&quot;/tmp/map_report_sym_sizes.$$&quot;
    awk -v cseg_end=&quot;$CSEG_END&quot; &apos;
    { addr[NR]=$1; sym[NR]=$2; mod[NR]=$3 }
    END {
        for(i=1;i&amp;lt;=NR;i++) {
            if (i&amp;lt;NR) delta = addr[i+1]-addr[i]; else delta = cseg_end - addr[i];
            if (delta&amp;lt;0) delta=0;
            printf &quot;%d %s %s\n&quot;, delta, sym[i], mod[i];
        }
    }&apos; &quot;$SORTED&quot; &amp;gt; &quot;$SYMBOL_SIZES&quot;

    echo &quot;Top 20 函数（按占用字节）:&quot;
    # Print header with tabs, then nicely aligned rows: Rank, Bytes, Function, Module
    printf &quot;%-4s\t%8s\t%-30s\t%s\n&quot; &quot;#&quot; &quot;Bytes&quot; &quot;Function&quot; &quot;Module&quot;
    sort -nr &quot;$SYMBOL_SIZES&quot; | head -n 20 | awk &apos;BEGIN{rank=0} {rank++; printf &quot;%-4d\t%8d\t%-30s\t%s\n&quot;, rank, $1, $2, $3}&apos;

    rm -f &quot;$SYMBOL_SIZES&quot;

    rm -f &quot;$TMPFILE&quot; &quot;$SORTED&quot;
fi

exit 0

&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>汇编代码初始化数组</title><link>https://www.mintlab.top/posts/stc8g%E6%B1%87%E7%BC%96%E7%AC%94%E8%AE%B0/asm_array/</link><guid isPermaLink="true">https://www.mintlab.top/posts/stc8g%E6%B1%87%E7%BC%96%E7%AC%94%E8%AE%B0/asm_array/</guid><description>汇编代码初始化数组</description><pubDate>Tue, 30 Dec 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;初始化数组（存储在RAM中）&lt;/h2&gt;
&lt;h3&gt;方法1：使用DS在RAM中预留空间&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;ORG 30H            ; 从30H开始分配RAM变量

; 未初始化的数组（预留空间）
BUFFER:      DS    16      ; 预留16字节的缓冲区
TEMP_ARRAY:  DS    8       ; 预留8字节的温度数组
RESULTS:     DS    32      ; 预留32字节的结果数组

; 混合定义
DATA_AREA:
VAR1:        DS    1       ; 单个字节变量
ARRAY1:      DS    10      ; 10字节数组
VAR2:        DS    1       ; 另一个变量
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;方法2：运行时初始化数组&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 初始化RAM数组的函数
INIT_ARRAYS:
    ; 初始化BUFFER数组为0
    MOV R0, #BUFFER       ; 数组起始地址
    MOV R2, #16           ; 数组长度
    CLR A                 ; A = 0
INIT_LOOP1:
    MOV @R0, A            ; 清零数组元素
    INC R0
    DJNZ R2, INIT_LOOP1
    
    ; 初始化TEMP_ARRAY为特定值
    MOV R0, #TEMP_ARRAY
    MOV @R0, #20          ; TEMP_ARRAY[0] = 20
    INC R0
    MOV @R0, #25          ; TEMP_ARRAY[1] = 25
    INC R0
    MOV @R0, #30          ; TEMP_ARRAY[2] = 30
    ; ... 继续初始化
    
    RET
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;访问常数变量和数组&lt;/h2&gt;
&lt;h3&gt;1. 访问ROM中的常数（使用MOVC）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 读取SINE_TABLE中的第3个元素
READ_SINE_VALUE:
    MOV DPTR, #SINE_TABLE   ; 设置表基址
    MOV A, #2               ; 索引2（第3个元素）
    MOVC A, @A+DPTR         ; A = SINE_TABLE[2] = 25
    RET

; 读取字符串中的字符
READ_MESSAGE_CHAR:
    MOV DPTR, #MESSAGE      ; 字符串地址
    MOV R2, #0              ; 字符索引
READ_LOOP:
    MOV A, R2               ; 索引到A
    MOVC A, @A+DPTR         ; 读取字符
    JZ END_OF_STRING        ; 如果是0，字符串结束
    ; 处理字符...
    INC R2                  ; 下一个字符
    SJMP READ_LOOP
END_OF_STRING:
    RET
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 访问RAM中的数组&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 通过索引访问数组元素
ACCESS_ARRAY_ELEMENT:
    ; 参数：R7 = 数组索引
    ; 返回：A = 数组元素
    
    MOV A, R7              ; 索引到A
    ADD A, #BUFFER         ; 计算实际地址
    MOV R0, A              ; R0指向数组元素
    MOV A, @R0             ; 读取数组元素
    RET

; 遍历数组（求数组元素之和）
SUM_ARRAY:
    MOV R0, #BUFFER        ; 数组起始地址
    MOV R2, #16            ; 数组长度
    CLR A                  ; 累加器清零
    CLR B                  ; 进位清零
SUM_LOOP:
    ADD A, @R0             ; 累加数组元素
    JNC NO_CARRY
    INC B                  ; 处理进位
NO_CARRY:
    INC R0                 ; 下一个元素
    DJNZ R2, SUM_LOOP
    
    ; 结果：A=低8位，B=高8位（进位）
    RET
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3. 多维数组访问&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 二维数组（3x4矩阵）
MATRIX_ROWS   EQU    3
MATRIX_COLS   EQU    4
MATRIX_SIZE   EQU    MATRIX_ROWS * MATRIX_COLS

MATRIX: DS MATRIX_SIZE     ; 3x4矩阵

; 访问MATRIX[row][col]
; 参数：R6 = 行号(0-2), R7 = 列号(0-3)
; 返回：A = MATRIX[row][col]
ACCESS_MATRIX:
    MOV A, R6              ; 行号
    MOV B, #MATRIX_COLS    ; 每行4列
    MUL AB                 ; A = 行号 * 4
    ADD A, R7              ; + 列号
    ADD A, #MATRIX         ; + 矩阵基址
    MOV R0, A              ; R0指向矩阵元素
    MOV A, @R0             ; 读取元素
    RET
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;结构体类型的数据组织&lt;/h2&gt;
&lt;h3&gt;1. 定义数据结构&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 定义学生记录结构
STUDENT_SIZE EQU 4
STUDENT_AGE   EQU 0     ; 偏移0：年龄
STUDENT_GRADE EQU 1     ; 偏移1：成绩
STUDENT_ID_H  EQU 2     ; 偏移2：ID高字节
STUDENT_ID_L  EQU 3     ; 偏移3：ID低字节

; 学生数组（3个学生）
STUDENTS: DS STUDENT_SIZE * 3

; 常量学生数据（ROM中的模板）
STUDENT_TEMPLATE:
    DB 18, 85, 0, 1      ; 学生1：18岁，成绩85，ID=0001
    DB 19, 90, 0, 2      ; 学生2：19岁，成绩90，ID=0002
    DB 20, 78, 0, 3      ; 学生3：20岁，成绩78，ID=0003
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 访问结构体数组&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 获取第n个学生的成绩
; 参数：R7 = 学生索引(0-2)
; 返回：A = 成绩
GET_STUDENT_GRADE:
    MOV A, R7              ; 学生索引
    MOV B, #STUDENT_SIZE   ; 每个学生大小
    MUL AB                 ; A = 索引 * 4
    ADD A, #STUDENTS       ; + 基址
    ADD A, #STUDENT_GRADE  ; + 成绩偏移
    MOV R0, A              ; R0指向成绩字段
    MOV A, @R0             ; 读取成绩
    RET

; 初始化学生数组
INIT_STUDENTS:
    MOV DPTR, #STUDENT_TEMPLATE
    MOV R0, #STUDENTS      ; RAM目标地址
    MOV R2, #3             ; 3个学生
INIT_STU_LOOP1:
    MOV R3, #STUDENT_SIZE  ; 每个学生4字节
INIT_STU_LOOP2:
    CLR A
    MOVC A, @A+DPTR        ; 从ROM读取
    MOV @R0, A             ; 写入RAM
    INC DPTR
    INC R0
    DJNZ R3, INIT_STU_LOOP2
    DJNZ R2, INIT_STU_LOOP1
    RET
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;高级技巧：使用宏和标号&lt;/h2&gt;
&lt;h3&gt;1. 定义带类型的数组访问宏&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 定义数组访问宏
ARRAY_GET MACRO ARRAY_NAME, INDEX, DEST
    MOV A, INDEX
    ADD A, #ARRAY_NAME
    MOV R0, A
    MOV DEST, @R0
    ENDM

ARRAY_SET MACRO ARRAY_NAME, INDEX, SRC
    MOV A, INDEX
    ADD A, #ARRAY_NAME
    MOV R0, A
    MOV @R0, SRC
    ENDM

; 使用宏
    MOV R1, #2            ; 索引2
    ARRAY_GET BUFFER, R1, A    ; A = BUFFER[2]
    
    MOV R1, #3
    MOV R2, #0FFH
    ARRAY_SET BUFFER, R1, R2   ; BUFFER[3] = 0FFH
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2. 使用标号计算数组大小&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 自动计算数组大小
CONST_ARRAY_START:
    DB 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
CONST_ARRAY_END:

CONST_ARRAY_SIZE EQU CONST_ARRAY_END - CONST_ARRAY_START

; 编译时会计算：CONST_ARRAY_SIZE = 10
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;完整示例程序&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;; 定义常数
ORG 1000H
PI:          DB    22, 7          ; π ≈ 22/7
DAYS_IN_MONTH:
    DB 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31

; 定义RAM变量
ORG 30H
TEMPERATURES: DS   24      ; 24小时温度数据
DAILY_MAX:    DS   1       ; 日最高温度
DAILY_MIN:    DS   1       ; 日最低温度

; 主程序
ORG 0000H
    MOV SP, #60H
    
    ; 初始化温度数组为20°C
    CALL INIT_TEMP_ARRAY
    
    ; 读取下午2点的温度（索引14）
    MOV R7, #14
    CALL GET_TEMPERATURE   ; A = TEMPERATURES[14]
    
    ; 查找最高温度
    CALL FIND_MAX_TEMP     ; A = 最高温度
    MOV DAILY_MAX, A
    
    ; 使用常数表：获取7月的天数
    MOV DPTR, #DAYS_IN_MONTH
    MOV A, #6              ; 7月是第7个月，索引6
    MOVC A, @A+DPTR        ; A = 31
    
    SJMP $

; 初始化温度数组
INIT_TEMP_ARRAY:
    MOV R0, #TEMPERATURES
    MOV R2, #24
    MOV R3, #20            ; 初始温度20°C
INIT_TEMP_LOOP:
    MOV @R0, R3
    INC R0
    DJNZ R2, INIT_TEMP_LOOP
    RET

; 获取温度值
GET_TEMPERATURE:      ; R7 = 小时(0-23)
    MOV A, R7
    ADD A, #TEMPERATURES
    MOV R0, A
    MOV A, @R0
    RET

; 查找最高温度
FIND_MAX_TEMP:
    MOV R0, #TEMPERATURES
    MOV R2, #24
    CLR A                ; A = 0（当前最大值）
FIND_MAX_LOOP:
    MOV B, @R0
    CJNE A, B, COMPARE
COMPARE:
    JNC NOT_HIGHER       ; 如果A &amp;gt;= B，跳过
    MOV A, B             ; 更新最大值
NOT_HIGHER:
    INC R0
    DJNZ R2, FIND_MAX_LOOP
    RET
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;重要提示：&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;ROM常数&lt;/strong&gt;：使用&lt;code&gt;DB&lt;/code&gt;/&lt;code&gt;DW&lt;/code&gt;定义，用&lt;code&gt;MOVC&lt;/code&gt;指令访问&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RAM数组&lt;/strong&gt;：使用&lt;code&gt;DS&lt;/code&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;性能考虑&lt;/strong&gt;：频繁访问的数组放在直接寻址区域（00H-7FH）&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>自建服务器搭建git远程仓库</title><link>https://www.mintlab.top/posts/git_origin_repo/</link><guid isPermaLink="true">https://www.mintlab.top/posts/git_origin_repo/</guid><description>在阿里云服务器搭建git远程仓库</description><pubDate>Tue, 25 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;服务器操作&lt;/h1&gt;
&lt;pre&gt;&lt;code&gt;sudo yum install git

# 创建git用户
sudo adduser git
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;添加远程访问者的ssh密钥&lt;/h2&gt;
&lt;p&gt;在本地&lt;code&gt;~/.ssh/id_rsa.pub&lt;/code&gt;中复制自己的ssh公钥到服务器&lt;code&gt;/home/git/.ssh/authorized_keys&lt;/code&gt;中&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 本地执行，复制输出的密钥
cat ~/.ssh/id_rsa.pub

# 服务器执行，将复制来的密钥添加到文件中，一个密钥一行
vim /home/git/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;# 也可以自行生成一个新的密钥
ssh-keygen -t rsa -b 4096 -C &quot;助记词(一般是邮箱)&quot;

# 将新生成的密钥加入ssh钥匙串
ssh-add &quot;~/.ssh/id_rsa(新生成的私钥路径)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;创建git仓库&lt;/h2&gt;
&lt;p&gt;在&lt;code&gt;~/&lt;/code&gt; &lt;code&gt;/home/git&lt;/code&gt;家目录下执行&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir gitrepo

# 创建一个裸仓库(没有工作区，只用于文件共享)
git init --bare myrepo.git
&lt;/code&gt;&lt;/pre&gt;
&lt;h1&gt;本地操作&lt;/h1&gt;
&lt;h2&gt;A. 从新仓库开始(克隆)&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;git clone git@xxx.com:/home/git/gitrepo/myrepo.git
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;B. 为已有仓库添加新的远程仓库&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 添加远程仓库
git remote add my_origin git@xxx.com:/home/git/gitrepo/myrepo.git

# 显示所有远程仓库配置
git remote -v

# 展示远程仓库状态
git remote show my_origin

# 推送到远程仓库，好耶！
git push my_origin
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>Linux系统备份方案</title><link>https://www.mintlab.top/posts/backup_stratagy/</link><guid isPermaLink="true">https://www.mintlab.top/posts/backup_stratagy/</guid><description>基于两地三介质的linux可靠备份系统</description><pubDate>Fri, 21 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Linux系统备份方案&lt;/h1&gt;
&lt;h2&gt;备份策略概述&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;架构&lt;/strong&gt;：两地三介质&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;冗余级别&lt;/strong&gt;：HDD RAID1&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;数据分类&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;系统核心文件：6份备份&lt;/li&gt;
&lt;li&gt;用户文档：3份备份&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;备份类型&lt;/strong&gt;
&lt;ul&gt;
&lt;li&gt;完整压缩：clonezilla&lt;/li&gt;
&lt;li&gt;增量：timeshift&lt;/li&gt;
&lt;li&gt;反向增量：rdiff-backup&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&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;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;HDD&lt;/td&gt;
&lt;td&gt;完整 压缩&lt;/td&gt;
&lt;td&gt;每月&lt;/td&gt;
&lt;td&gt;3个月&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;td&gt;6个月&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;系统镜像&lt;/td&gt;
&lt;td&gt;BD&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;HDD&lt;/td&gt;
&lt;td&gt;增量&lt;/td&gt;
&lt;td&gt;每天&lt;/td&gt;
&lt;td&gt;14天&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;系统快照&lt;/td&gt;
&lt;td&gt;BD&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;每周&lt;/td&gt;
&lt;td&gt;4周&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户文档&lt;/td&gt;
&lt;td&gt;HDD&lt;/td&gt;
&lt;td&gt;反向增量&lt;/td&gt;
&lt;td&gt;每天&lt;/td&gt;
&lt;td&gt;60天&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户文档&lt;/td&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;反向增量&lt;/td&gt;
&lt;td&gt;每天&lt;/td&gt;
&lt;td&gt;60天&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;用户文档&lt;/td&gt;
&lt;td&gt;DVD&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;h2&gt;恢复测试计划&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;周期&lt;/strong&gt;：每3个月（在所有新的备份工作完成后）&lt;/p&gt;
&lt;h3&gt;测试项目&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;系统完整镜像恢复测试&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;将新的系统完整镜像恢复到另一台电脑&lt;/li&gt;
&lt;li&gt;测试是否启动成功&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;系统快照回退测试&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;从HDD和BD分别随机选择一个系统快照版本&lt;/li&gt;
&lt;li&gt;使用live系统进入，将系统回退到此快照版本&lt;/li&gt;
&lt;li&gt;测试是否启动成功&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;介质读取测试&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;随机抽选一张BD和一张DVD&lt;/li&gt;
&lt;li&gt;测试读取是否成功&lt;/li&gt;
&lt;li&gt;验证文件内容是否正确&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>sdcc汇编伪指令</title><link>https://www.mintlab.top/posts/stc8g%E6%B1%87%E7%BC%96%E7%AC%94%E8%AE%B0/stc8g_asm_guide/</link><guid isPermaLink="true">https://www.mintlab.top/posts/stc8g%E6%B1%87%E7%BC%96%E7%AC%94%E8%AE%B0/stc8g_asm_guide/</guid><description>基于stc8g编写的8051架构sdcc编译器汇编伪指令</description><pubDate>Fri, 21 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h3&gt;汇编助记符中的符号含义&lt;/h3&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;strong&gt;Rn&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;工作寄存器 R0~R7&lt;/td&gt;
&lt;td&gt;n 可取 0~7，表示当前寄存器组中的寄存器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ri&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;间接寻址寄存器 R0 或 R1&lt;/td&gt;
&lt;td&gt;i 可取 0 或 1，用于间接寻址&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;@Ri&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;以 Ri 中的值为地址，访问该地址的数据&lt;/td&gt;
&lt;td&gt;间接寻址方式，Ri 中存放的是内存地址&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;#data&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;立即数&lt;/td&gt;
&lt;td&gt;直接跟在指令后的常数，如 &lt;code&gt;#30H&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;direct&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;直接地址&lt;/td&gt;
&lt;td&gt;8位内部 RAM 地址或 SFR 地址，如 &lt;code&gt;30H&lt;/code&gt; 或 &lt;code&gt;P1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h1&gt;SDCC伪指令详细说明及示例&lt;/h1&gt;
&lt;h2&gt;&lt;strong&gt;一、段定义伪指令（.area）&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;1. 基本格式&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;.area 段名 (属性1,属性2,...) [重定位类型]
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;2. 常用段类型&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 代码段（程序存储器）
.area CSEG (CODE)       ; 常规代码段
.area HOME (CODE)       ; 起始段（0x0000-0x00FF）
.area GSINIT (CODE)     ; 全局变量初始化代码
.area GSFINAL (CODE)    ; 初始化后的代码

; 数据段
.area DSEG (DATA)       ; 内部可直接寻址数据（0x30-0x7F）
.area ISEG (DATA)       ; 内部间接寻址数据（0x80-0xFF）
.area OSEG (DATA)       ; 覆盖数据段
.area XSEG (XDATA)      ; 外部数据存储器
.area ESEG (EDATA)      ; 扩展数据存储器（STC8G特有）

; 其他
.area RSEG (ABS)        ; 绝对定位段
.area ASEG (ABS)        ; 绝对段（已过时）
.area BSEG (BIT)        ; 位寻址区（0x20-0x2F）
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;3. 段属性&lt;/strong&gt;&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;CODE&lt;/code&gt; - 程序代码&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DATA&lt;/code&gt; - 内部RAM数据&lt;/li&gt;
&lt;li&gt;&lt;code&gt;XDATA&lt;/code&gt; - 外部RAM数据&lt;/li&gt;
&lt;li&gt;&lt;code&gt;EDATA&lt;/code&gt; - 扩展RAM数据（STC8G）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ABS&lt;/code&gt; - 绝对地址&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BSS&lt;/code&gt; - 未初始化数据&lt;/li&gt;
&lt;li&gt;&lt;code&gt;BIT&lt;/code&gt; - 位数据&lt;/li&gt;
&lt;li&gt;&lt;code&gt;OVR&lt;/code&gt; - 可覆盖段&lt;/li&gt;
&lt;li&gt;&lt;code&gt;REL&lt;/code&gt; - 可重定位段&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CON&lt;/code&gt; - 常量段&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;&lt;strong&gt;4. 示例&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 定义绝对地址中断向量表
.area VEC (ABS)
.org 0x0000
    ljmp   _main          ; 复位向量
.org 0x0003
    ljmp   _ext0_isr      ; 外部中断0
.org 0x000B
    ljmp   _timer0_isr    ; 定时器0中断

; 代码段
.area CSEG (CODE)
_main:
    mov    SP, #0x80      ; 设置堆栈指针

; 内部数据段
.area DSEG (DATA)
counter:
    .byte 0x00           ; 单字节变量
buffer:
    .ds   16             ; 16字节缓冲区

; 外部数据段
.area XSEG (XDATA)
large_buffer:
    .ds   1024           ; 1KB外部缓冲区

; 位寻址区
.area BSEG (BIT)
flag1:   .dbits 1        ; 定义1个位
flags:   .dbits 8        ; 定义8个位（1字节）
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;二、数据定义伪指令&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;1. 字节数据&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 定义字节（8位）
.byte value1, value2, ...    ; 定义字节序列
.db   value1, value2, ...    ; 同.byte

; 示例
data1:  .byte 0x12           ; 单个字节
table:  .byte 0, 1, 2, 3, 4  ; 数组
str:    .byte &apos;H&apos;,&apos;e&apos;,&apos;l&apos;,&apos;l&apos;,&apos;o&apos;,0  ; 字符串
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;2. 字数据&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 定义字（16位）
.word value1, value2, ...    ; 定义字序列（小端序）
.dw   value1, value2, ...    ; 同.word

; 示例
vectors: .word 0x0000, 0x1000, 0x2000  ; 地址表
const:   .dw   0x1234, 0x5678          ; 16位常量
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;3. 双字数据&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 定义双字（32位）
.long value1, value2, ...    ; 32位数据
.dl   value1, value2, ...    ; 同.long

; 示例
long_val: .long 0x12345678   ; 32位值
float_val: .long 0x4048F5C3  ; 浮点数（3.14的IEEE754表示）
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;4. 浮点数&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 定义浮点数
.float value1, value2, ...   ; 单精度浮点数

; 示例
pi:     .float 3.1415926
temp:   .float 25.5
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;5. 字符串&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; ASCII字符串
.ascii &quot;string&quot;              ; 无结束符
.asciz &quot;string&quot;             ; 以\0结尾

; 示例
msg1:   .ascii &quot;Error&quot;       ; 长度5
msg2:   .asciz &quot;Hello&quot;       ; 长度6（含\0）
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;6. 保留空间&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;.ds size                     ; 保留size字节空间
.blkb size                   ; 同.ds（块）

; 示例
buffer: .ds 100              ; 100字节缓冲区
stack:  .blkb 32             ; 32字节堆栈空间
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;7. 位数据&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 位定义（仅用于BSEG段）
.dbits size                  ; 定义size个位

; 示例
.area BSEG (BIT)
flags:  .dbits 8             ; 8个标志位
status: .dbits 1             ; 单状态位
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;三、符号定义伪指令&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;1. 常量定义&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 等值定义
.equ symbol, value           ; 定义符号常量
.set  symbol, value          ; 同.equ（可重复定义）
=      symbol = value        ; 另一种形式

; 示例
.equ BUFFER_SIZE, 256        ; 定义常量
.set  COUNTER_MAX, 1000
TIMEOUT = 5000               ; 使用等号
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;2. 全局符号声明&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;.globl symbol                ; 声明全局符号
.global symbol               ; 同.globl
.gblel symbol                ; 声明全局外部符号

; 示例
.globl _main                 ; C主函数
.globl _isr_timer0           ; 中断服务函数
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;3. 外部符号声明&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;.extern symbol               ; 声明外部符号
.external symbol             ; 同.extern

; 示例
.extern _printf              ; 外部C函数
.extern buffer               ; 外部变量
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;四、条件汇编伪指令&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;1. 基本条件&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 条件块
.if condition                ; 开始条件
.else                        ; 否则分支
.endif                       ; 结束条件

; 示例
.if DEBUG == 1
    mov   A, #0x55           ; 调试代码
.else
    mov   A, #0xAA           ; 发布代码
.endif
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;2. 符号条件&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 符号检查
.ifdef symbol                ; 如果符号已定义
.ifndef symbol               ; 如果符号未定义

; 示例
.ifdef USE_UART
    call  uart_init          ; 如果定义了USE_UART
.endif

.ifndef OPTIMIZE
    nop                      ; 如果没有优化
.endif
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;3. 比较条件&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 比较表达式
.ifeq expression             ; 如果等于
.ifne expression             ; 如果不等于
.ifgt expression             ; 如果大于
.ifge expression             ; 如果大于等于
.iflt expression             ; 如果小于
.ifle expression             ; 如果小于等于

; 示例
.ifeq VERSION-2
    ljmp  legacy_code        ; VERSION==2时
.else
    ljmp  new_code           ; 其他情况
.endif
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;五、宏定义与使用&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;1. 基本宏定义&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 宏定义
.macro macroname [param1, param2, ...]  ; 定义宏
    ; 宏体
.endm                                   ; 结束宏

; 示例：延时宏
.macro DELAY cycles
    mov   R7, #(\cycles &amp;amp; 0xFF)         ; 使用参数
    djnz  R7, $
.endm

; 使用宏
DELAY 100     ; 扩展为：mov R7, #100; djnz R7, $
DELAY 255     ; 扩展为：mov R7, #255; djnz R7, $
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;2. 带参数的宏&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 带多个参数的宏
.macro MOV16 src, dst
    mov   A, \src            ; 参数前加反斜杠
    mov   \dst, A
    mov   A, \src+1
    mov   \dst+1, A
.endm

; 使用
MOV16 #0x1234, R6    ; 将16位值传送到R6:R7
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;3. 局部标签宏&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 带局部标签的宏
.macro DELAY_MS ms
    mov   R5, #\ms
1$:                         ; 局部标签（数字+$）
    mov   R6, #200
2$:
    mov   R7, #250
    djnz  R7, $
    djnz  R6, 2$
    djnz  R5, 1$
.endm
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;4. 条件宏&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 条件判断宏
.macro BIT_SET reg, bit
    .if \bit &amp;lt; 8
        setb \reg.\bit      ; 使用位操作符
    .else
        .error &quot;Bit out of range&quot;
    .endif
.endm

; 使用
BIT_SET P1, 0        ; setb P1.0
BIT_SET ACC, 3       ; setb ACC.3
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;5. 嵌套宏&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 宏嵌套
.macro PUSH_REG reg
    push  \reg
.endm

.macro PUSH_REGS regs
    .irp reg, \regs        ; 迭代参数列表
        PUSH_REG \reg
    .endm
.endm

; 使用
PUSH_REGS ACC, B, PSW    ; 压栈多个寄存器
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;6. 宏函数示例&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 计算平方的宏函数
.macro SQUARE x, result
    mov   A, #\x
    mov   B, A
    mul   AB
    mov   \result, A       ; 低8位
    mov   \result+1, B     ; 高8位
.endm

; 使用
SQUARE 5, square_result

; 带返回值的宏
.macro ADD16 a, b
    mov   A, \a
    add   A, \b
    mov   R0, A
    mov   A, \a+1
    addc  A, \b+1
    mov   R1, A
    ; 结果在R0:R1中
.endm
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;六、包含文件与模块化&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;1. 包含文件&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;.include &quot;filename.inc&quot;    ; 包含汇编文件
.include &amp;lt;stddef.h&amp;gt;       ; 包含C头文件（需要-i指定路径）

; 示例
.include &quot;stc8g.h&quot;
.include &quot;macros.inc&quot;
.include &quot;config.inc&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;2. 模块化组织&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 主文件 main.asm
.module main
.globl _main

.include &quot;uart.inc&quot;
.include &quot;timer.inc&quot;

_main:
    call  uart_init
    call  timer_init
    ; ...

; 子模块 uart.inc
.module uart
.globl uart_init, uart_send

uart_init:
    ; ...
    ret

uart_send:
    ; ...
    ret
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;七、地址控制伪指令&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;1. 地址定位&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;.org address               ; 设置当前位置
.radix base                ; 设置数字基数（2,8,10,16）

; 示例
.radix 16                  ; 设置为16进制
.org   1000H               ; 从1000H开始
    .byte 1,2,3,4
.org   2000H               ; 跳转到2000H
    .word 1234H
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;2. 对齐&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;.even                      ; 对齐到偶数地址
.odd                       ; 对齐到奇数地址
.align power               ; 对齐到2^power边界

; 示例
.align 4                   ; 对齐到16字节边界
data: .ds 10

.even                      ; 确保下面代码在偶地址
code: mov A, #0
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;八、列表控制伪指令&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;1. 列表文件控制&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;.list                      ; 开启列表
.nolist                    ; 关闭列表
.lst                       ; 生成列表文件
.nolst                     ; 不生成列表文件

; 示例
.nolist                    ; 不列出包含文件内容
.include &quot;macros.inc&quot;
.list                      ; 恢复列表
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;2. 其他控制&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;.title &quot;标题&quot;              ; 设置标题
.sbttl &quot;子标题&quot;            ; 设置子标题
.page                      ; 分页
.width columns             ; 设置列宽
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;九、标签的 : 和 :: 区别&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;1. 单冒号 :（局部标签）&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 局部标签（默认）
label1:                    ; 局部标签，文件内可见
    nop

; 示例
loop:                      ; 局部循环标签
    djnz R7, loop          ; 可访问
    
func1:
    call subfunc
    ret
    
subfunc:                   ; 另一个局部标签
    nop
    ret
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;2. 双冒号 ::（全局标签）&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 全局标签
label2::                   ; 全局标签，外部可访问

; 示例
.globl _main               ; 需要配合.globl声明
_main::                    ; C入口函数（全局）
    mov SP, #0x80

; 中断服务函数（全局）
_ext0_isr::
    push PSW
    ; ... 中断处理
    pop PSW
    reti

; 在另一个文件中可这样引用
.extern _main              ; 声明外部全局符号
.extern _ext0_isr
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;3. 标签作用域规则&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 文件: module1.asm
.local_label:              ; 仅本文件可见
    nop
    
global_label::             ; 全局可见（自动.globl）
    nop

.globl exported_label      ; 需要显式声明
exported_label:            ; 全局可见（需.globl）
    nop

; 文件: module2.asm
.extern global_label       ; 引用全局标签
.extern exported_label

call global_label          ; 可调用
call exported_label        ; 可调用
; call local_label        ; 错误！不可访问
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;4. 数字标签（局部）&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;; 数字标签（自动局部）
1:                         ; 数字标签，可重复定义
    nop
    jb   P1.0, 1b          ; 向后跳转到最近的1:
    
2:                         ; 另一个数字标签
    nop
    jnb  P1.1, 2f          ; 向前跳转到下一个2:

; 示例：循环中使用
    mov  R7, #10
1$:                        ; 带$的数字标签（推荐）
    djnz R7, 1$            ; 向后跳转

; 嵌套循环
    mov  R6, #5
2$:
    mov  R7, #10
1$:
    djnz R7, 1$
    djnz R6, 2$
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;十、完整示例&lt;/strong&gt;&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;; ============================================
; STC8G 汇编程序示例
; ============================================

; 包含头文件
.include &quot;stc8g.inc&quot;

; 常量定义
.equ BUFFER_SIZE,  256
.equ LED_PIN,      P1_0
.equ BUTTON_PIN,   P3_2
.set  TIMEOUT,     1000

; 宏定义
.macro DELAY_US us
    .if \us &amp;gt; 0
        mov  R7, #((\us * 2) &amp;amp; 0xFF)
    1$: djnz R7, 1$
    .endif
.endm

.macro LED_ON
    setb LED_PIN
.endm

.macro LED_OFF
    clr  LED_PIN
.endm

.macro TOGGLE_LED
    cpl  LED_PIN
.endm

; 位定义
.area BSEG (BIT)
button_flag:   .dbits 1    ; 按键标志
tx_busy:       .dbits 1    ; 发送忙标志
rx_ready:      .dbits 1    ; 接收就绪标志

; 数据段
.area DSEG (DATA)
counter:      .byte 0
buffer:       .ds   BUFFER_SIZE
tx_index:     .byte 0
rx_index:     .byte 0

; 绝对地址段（中断向量）
.area VEC (ABS)
.org 0x0000
    ljmp   _main           ; 复位向量

.org 0x0003
    ljmp   ext0_isr        ; INT0中断

.org 0x000B
    ljmp   timer0_isr      ; Timer0中断

.org 0x0023
    ljmp   uart_isr        ; UART中断

; 代码段
.area CSEG (CODE)

; ============================================
; 主程序
; ============================================
.globl _main
_main::
    ; 初始化堆栈
    mov   SP, #0x80
    
    ; 初始化端口
    mov   P1M0, #0x01      ; P1.0推挽输出
    mov   P1M1, #0x00
    
    ; 初始化定时器
    call  timer0_init
    
    ; 初始化串口
    call  uart_init
    
    ; 使能中断
    setb  EA
    setb  EX0              ; 使能INT0
    setb  ET0              ; 使能Timer0
    setb  ES               ; 使能串口中断
    
    ; 主循环
main_loop:
    ; 检查按键
    jnb   button_flag, no_button
    clr   button_flag
    
    ; 按键处理
    TOGGLE_LED
    call  send_message
    
no_button:
    ; 检查接收数据
    jnb   rx_ready, main_loop
    clr   rx_ready
    
    ; 处理接收数据
    call  process_data
    
    sjmp  main_loop

; ============================================
; 延时函数（毫秒）
; 使用宏实现
; ============================================
.macro DELAY_MS ms
    push  ACC
    push  PSW
    mov   A, #\ms
    call  delay_ms_func
    pop   PSW
    pop   ACC
.endm

; 实际的延时函数
delay_ms_func:
    mov   R6, A
1$: mov   R7, #200
2$: mov   R8, #250
3$: djnz  R8, 3$
    djnz  R7, 2$
    djnz  R6, 1$
    ret

; ============================================
; 中断服务函数
; ============================================
ext0_isr::
    push  PSW
    push  ACC
    
    ; 防抖延时
    DELAY_US 100
    
    ; 设置按键标志
    setb  button_flag
    
    pop   ACC
    pop   PSW
    reti

timer0_isr::
    push  PSW
    push  ACC
    
    ; 定时器处理
    inc   counter
    
    ; 1秒闪烁
    mov   A, counter
    anl   A, #0x80
    jz    timer_done
    TOGGLE_LED
    
timer_done:
    pop   ACC
    pop   PSW
    reti

uart_isr::
    push  PSW
    push  ACC
    
    ; 串口中断处理
    jnb   RI, uart_tx_check
    clr   RI
    
    ; 接收数据
    mov   A, SBUF
    call  store_rx_data
    
uart_tx_check:
    jnb   TI, uart_done
    clr   TI
    setb  tx_busy          ; 发送完成
    
uart_done:
    pop   ACC
    pop   PSW
    reti

; ============================================
; 子函数
; ============================================
; 初始化定时器0
timer0_init::
    mov   TMOD, #0x01      ; 定时器0模式1
    mov   TH0, #0xFC       ; 1ms定时
    mov   TL0, #0x66
    setb  TR0              ; 启动定时器
    ret

; 初始化串口
uart_init::
    mov   SCON, #0x50      ; 模式1，允许接收
    mov   PCON, #0x80      ; 波特率加倍
    mov   TH1, #0xFD       ; 9600 bps @11.0592MHz
    mov   TL1, #0xFD
    setb  TR1              ; 启动定时器1
    ret

; 存储接收数据
store_rx_data:
    push  PSW
    mov   PSW, #0x10       ; 使用第2组寄存器
    
    mov   R0, rx_index
    mov   @R0, A           ; 存储数据
    inc   rx_index
    
    ; 设置接收就绪标志
    setb  rx_ready
    
    pop   PSW
    ret

; 发送消息
send_message::
    mov   DPTR, #welcome_msg
    call  send_string
    ret

; 发送字符串
send_string:
    push  ACC
1$: clr   A
    movc  A, @A+DPTR
    jz    send_done
    call  send_char
    inc   DPTR
    sjmp  1$
send_done:
    pop   ACC
    ret

; 发送单个字符
send_char:
    jnb   tx_busy, $       ; 等待发送完成
    clr   tx_busy
    mov   SBUF, A
    ret

; 处理数据
process_data::
    ; 处理接收缓冲区数据
    ret

; ============================================
; 常量数据
; ============================================
welcome_msg:
    .asciz &quot;STC8G Ready!\r\n&quot;

; ============================================
; 程序结束
; ============================================
    .end
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;十一、编译与链接&lt;/strong&gt;&lt;/h2&gt;
&lt;h3&gt;&lt;strong&gt;1. 编译命令&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 编译汇编文件
sdcc -c -mmcs51 --model-small example.asm

# 编译C文件（含内联汇编）
sdcc -c -mmcs51 --model-small main.c

# 链接目标文件
sdcc -mmcs51 --model-small example.rel main.rel

# 生成Intel HEX文件
packihx example.ihx &amp;gt; example.hex
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;strong&gt;2. Makefile示例&lt;/strong&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;TARGET = example
SRCS = main.asm uart.asm timer.asm
OBJS = $(SRCS:.asm=.rel)

CC = sdcc
CFLAGS = -mmcs51 --model-small --opt-code-size

all: $(TARGET).hex

$(TARGET).hex: $(OBJS)
	$(CC) $(CFLAGS) $(OBJS) -o $(TARGET).ihx
	packihx $(TARGET).ihx &amp;gt; $(TARGET).hex

%.rel: %.asm
	$(CC) -c $(CFLAGS) $&amp;lt;

clean:
	rm -f *.rel *.asm *.lst *.sym *.ihx *.hex *.map
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&lt;strong&gt;十二、注意事项&lt;/strong&gt;&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;大小写敏感&lt;/strong&gt;：SDCC汇编器是大小写敏感的&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;下划线规则&lt;/strong&gt;：C函数在汇编中前加下划线（如 &lt;code&gt;_main&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;参数传递&lt;/strong&gt;：通过寄存器R5-R7传递参数&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;返回值&lt;/strong&gt;：8位在A中，16位在DPL/DPH中&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;内存模型&lt;/strong&gt;：使用 &lt;code&gt;--model-small&lt;/code&gt;、&lt;code&gt;--model-medium&lt;/code&gt;、&lt;code&gt;--model-large&lt;/code&gt; 指定&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;优化选项&lt;/strong&gt;：使用 &lt;code&gt;--opt-code-size&lt;/code&gt; 或 &lt;code&gt;--opt-code-speed&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;调试信息&lt;/strong&gt;：使用 &lt;code&gt;--debug&lt;/code&gt; 生成调试信息&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item><item><title>rdiff-backup 反向增量备份</title><link>https://www.mintlab.top/posts/rdiff-backup/</link><guid isPermaLink="true">https://www.mintlab.top/posts/rdiff-backup/</guid><description>基于Linux命令行rdiff-backup命令的文件备份</description><pubDate>Thu, 20 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;📋 rdiff-backup 2.x 新版语法结构&lt;/h2&gt;
&lt;h3&gt;基本语法格式&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;rdiff-backup [全局选项...] &amp;lt;动作&amp;gt; [子选项...] [路径...]
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;🔧 常用动作详解&lt;/h2&gt;
&lt;h3&gt;备份 (backup)&lt;/h3&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;code&gt;rdiff-backup backup &amp;lt;源目录&amp;gt; &amp;lt;目标目录&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;基本备份&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rdiff-backup backup /home/user /backup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--exclude &amp;lt;模式&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;排除文件/目录&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--exclude &quot;/home/user/tmp&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--include &amp;lt;模式&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;包含文件/目录&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--include &quot;/home/user/important&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--print-statistics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;显示备份统计&lt;/td&gt;
&lt;td&gt;&lt;code&gt;backup --print-statistics&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--compression&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;启用压缩(默认)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;backup --compression&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--no-compression&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;禁用压缩&lt;/td&gt;
&lt;td&gt;&lt;code&gt;backup --no-compression&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;完整备份示例：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rdiff-backup --api-version 201 backup \
    --exclude &quot;/home/mint/Documents/xwechat_files&quot; \
    --print-statistics \
    &quot;/home/mint/Documents&quot; \
    &quot;/media/mint/H/Documents_backup&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;列出备份点 (list increments)&lt;/h3&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;code&gt;rdiff-backup list increments &amp;lt;备份目录&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;列出所有备份点&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list increments /backup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;显示备份点大小&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list increments --size /backup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--no-size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;不显示大小(默认)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list increments --no-size /backup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--parsable-output&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;机器可读格式&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list increments --parsable-output /backup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;使用示例：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 列出所有备份点
rdiff-backup --api-version 201 list increments &quot;/media/mint/H/Documents_backup&quot;

# 显示备份点大小
rdiff-backup --api-version 201 list increments --size &quot;/media/mint/H/Documents_backup&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;恢复文件 (restore)&lt;/h3&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;code&gt;rdiff-backup restore --at &amp;lt;时间&amp;gt; &amp;lt;源目录&amp;gt; &amp;lt;目标目录&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;恢复到指定时间&lt;/td&gt;
&lt;td&gt;&lt;code&gt;restore --at 3D /backup /restore&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--at now&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;最新状态&lt;/td&gt;
&lt;td&gt;&lt;code&gt;restore --at now /backup /restore&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--at 3D&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3天前&lt;/td&gt;
&lt;td&gt;&lt;code&gt;restore --at 3D /backup /restore&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--at 2W&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2周前&lt;/td&gt;
&lt;td&gt;&lt;code&gt;restore --at 2W /backup /restore&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--at 1M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1个月前&lt;/td&gt;
&lt;td&gt;&lt;code&gt;restore --at 1M /backup /restore&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--at 2024-11-20T10:00:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;精确时间&lt;/td&gt;
&lt;td&gt;&lt;code&gt;restore --at &quot;2024-11-20T10:00:00&quot; /backup /restore&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--at 5B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;第5个备份点&lt;/td&gt;
&lt;td&gt;&lt;code&gt;restore --at 5B /backup /restore&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;恢复示例：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 恢复到3天前的状态
rdiff-backup --api-version 201 restore \
    --at 3D \
    &quot;/media/mint/H/Documents_backup&quot; \
    &quot;/home/mint/Documents_restored&quot;

# 恢复到特定日期
rdiff-backup --api-version 201 restore \
    --at &quot;2024-11-20T10:00:00&quot; \
    &quot;/media/mint/H/Documents_backup&quot; \
    &quot;/home/mint/Documents_old&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;查看文件列表 (list files)&lt;/h3&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;code&gt;rdiff-backup list files --at &amp;lt;时间&amp;gt; &amp;lt;备份目录&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查看特定时间点的文件&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list files --at 1D /backup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;list files --changed-since &amp;lt;时间&amp;gt; &amp;lt;备份目录&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;查看自某时间后变化的文件&lt;/td&gt;
&lt;td&gt;&lt;code&gt;list files --changed-since 2D /backup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;使用示例：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 查看3天前的文件列表
rdiff-backup --api-version 201 list files \
    --at 3D \
    &quot;/media/mint/H/Documents_backup&quot;

# 查看最近2天内变化的文件
rdiff-backup --api-version 201 list files \
    --changed-since 2D \
    &quot;/media/mint/H/Documents_backup&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;清理旧备份 (remove increments)&lt;/h3&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;code&gt;rdiff-backup remove increments --older-than &amp;lt;时间&amp;gt; &amp;lt;备份目录&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;删除旧备份点&lt;/td&gt;
&lt;td&gt;&lt;code&gt;remove increments --older-than 30D /backup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--force&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;强制删除多个会话&lt;/td&gt;
&lt;td&gt;&lt;code&gt;remove increments --older-than 30D --force /backup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;显示删除的备份大小&lt;/td&gt;
&lt;td&gt;&lt;code&gt;remove increments --older-than 30D --size /backup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;清理示例：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 删除30天前的备份点
rdiff-backup --api-version 201 remove increments \
    --older-than 30D \
    &quot;/media/mint/H/Documents_backup&quot;

# 强制删除60天前的所有备份点
rdiff-backup --api-version 201 remove increments \
    --older-than 60D \
    --force \
    &quot;/media/mint/H/Documents_backup&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&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;&lt;code&gt;--api-version 201&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;使用新版API&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--api-version 201&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--force&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;强制操作(如覆盖目录)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--force&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--verbosity &amp;lt;0-9&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;设置详细级别&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--verbosity 5&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--terminal-verbosity &amp;lt;0-9&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;终端输出详细级别&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--terminal-verbosity 3&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--null-separator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;使用null分隔符&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--null-separator&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;📁 文件选择选项&lt;/h2&gt;
&lt;h3&gt;排除/包含模式&lt;/h3&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;code&gt;--exclude &amp;lt;模式&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;排除匹配的文件&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--exclude &quot;*.tmp&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--include &amp;lt;模式&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;包含匹配的文件&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--include &quot;*.doc&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--exclude-filelist &amp;lt;文件&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;从文件读取排除列表&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--exclude-filelist excludes.txt&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--include-filelist &amp;lt;文件&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;从文件读取包含列表&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--include-filelist includes.txt&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;特殊文件处理&lt;/h3&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;code&gt;--include-special-files&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;包含设备文件等特殊文件&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--include-special-files&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--exclude-special-files&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;排除特殊文件&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--exclude-special-files&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--max-file-size &amp;lt;大小&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;限制备份文件最大尺寸&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--max-file-size 1G&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&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;&lt;code&gt;now&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;当前时间&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--at now&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;3D&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3天前&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--at 3D&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2W&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2周前&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--at 2W&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1M&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1个月前&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--at 1M&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5B&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;第5个备份点&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--at 5B&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2024-11-20T10:00:00&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;精确时间&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--at &quot;2024-11-20T10:00:00&quot;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1234567890&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;时间戳&lt;/td&gt;
&lt;td&gt;&lt;code&gt;--at 1234567890&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;🛠️ 实用脚本模板&lt;/h2&gt;
&lt;h3&gt;完整备份脚本&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash
# complete_backup.sh

BACKUP_DIR=&quot;/media/mint/H/Documents_backup&quot;
SOURCE_DIR=&quot;/home/mint/Documents&quot;

echo &quot;开始备份: $(date)&quot;

# 执行备份
rdiff-backup --api-version 201 \
    backup \
    --exclude &quot;/home/mint/Documents/xwechat_files&quot; \
    --exclude &quot;**/cache&quot; \
    --exclude &quot;**/tmp&quot; \
    --print-statistics \
    &quot;$SOURCE_DIR&quot; &quot;$BACKUP_DIR&quot;

if [ $? -eq 0 ]; then
    echo &quot;备份成功完成&quot;
    
    # 显示备份点信息
    echo &quot;=== 备份点列表 ===&quot;
    rdiff-backup --api-version 201 list increments &quot;$BACKUP_DIR&quot;
    
    echo &quot;=== 备份点大小 ===&quot;
    rdiff-backup --api-version 201 list increments --size &quot;$BACKUP_DIR&quot;
    
    # 清理60天前的备份
    rdiff-backup --api-version 201 remove increments \
        --older-than 60D \
        &quot;$BACKUP_DIR&quot;
else
    echo &quot;备份失败!&quot;
    exit 1
fi

echo &quot;备份结束: $(date)&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;恢复脚本&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash
# restore_backup.sh

BACKUP_DIR=&quot;/media/mint/H/Documents_backup&quot;

echo &quot;可用的备份点:&quot;
rdiff-backup --api-version 201 list increments &quot;$BACKUP_DIR&quot;

read -p &quot;输入要恢复的时间点 (如 3D, 1W, 或时间戳): &quot; restore_time

RESTORE_DIR=&quot;/home/mint/Documents_restored_$(date +%Y%m%d_%H%M%S)&quot;

echo &quot;正在恢复到: $RESTORE_DIR&quot;
rdiff-backup --api-version 201 restore \
    --at &quot;$restore_time&quot; \
    &quot;$BACKUP_DIR&quot; &quot;$RESTORE_DIR&quot;

echo &quot;恢复完成到: $RESTORE_DIR&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;🔍 故障排除&lt;/h2&gt;
&lt;h3&gt;常见问题解决&lt;/h3&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;&quot;invalid choice&quot; 错误&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;code&gt;--force&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;/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;h3&gt;验证备份完整性&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 验证备份仓库
rdiff-backup --api-version 201 verify &quot;/media/mint/H/Documents_backup&quot;

# 比较当前目录与备份
rdiff-backup --api-version 201 compare \
    --at 1D \
    &quot;/home/mint/Documents&quot; \
    &quot;/media/mint/H/Documents_backup&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这些命令基于 rdiff-backup 2.x 的新语法，避免了弃用警告，并提供了完整的备份、恢复和管理功能。&lt;/p&gt;
</content:encoded></item><item><title>LinuxDir2html文件目录树创建方法</title><link>https://www.mintlab.top/posts/linuxdir2html/</link><guid isPermaLink="true">https://www.mintlab.top/posts/linuxdir2html/</guid><description>基于linux命令行LinuxDir2html文件目录树创建方法</description><pubDate>Wed, 19 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;LinuxDir2html Github项目地址：
::github{repo=&quot;homeisfar/LinuxDir2HTML&quot;}&lt;/p&gt;
&lt;h2&gt;用法&lt;/h2&gt;
&lt;p&gt;该程序需要两个必需参数：要建立索引的目录和不带扩展名的输出文件名。例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;linuxdir2html ~/Pictures output
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将索引内容/home/username/Pictures保存到output.html当前工作目录中。&lt;/p&gt;
&lt;h3&gt;额外选项&lt;/h3&gt;
&lt;p&gt;有五个可选标志：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;-hidden&lt;/code&gt; 包含隐藏文件和目录。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-links&lt;/code&gt; 使 HTML 链接直接指向文件。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-symlink&lt;/code&gt; 跟踪符号链接。此标志很危险（可能导致循环引用）。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-silent&lt;/code&gt; 抑制输出。适用于脚本编写。仍会打印错误信息。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-verbose&lt;/code&gt; 额外的终端输出。不影响结果。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;只有在确定需要时才应启用符号链接标志。&lt;/p&gt;
&lt;p&gt;应用户要求，v1.4.0 版本新增了堆叠&lt;code&gt;--startswith&lt;/code&gt;和&lt;code&gt;--child&lt;/code&gt;参数功能[注意：自 v1.6.1 版本起，这些功能已被视为弃用，未来版本将移除。]。例如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;linuxdir2html --startswith &apos;dev&apos; --child &apos;Pictures&apos; ~ ~/output
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;将从用户主目录中选择以“dev”开头的目录以及名为“Pictures”的目录。此&lt;code&gt;--startswith&lt;/code&gt;过滤器仅影响根搜索目录，所有子目录和文件都将被索引。隐藏标志可与 startswith 标志一起使用。&lt;/p&gt;
</content:encoded></item><item><title>在linux下刻录光盘</title><link>https://www.mintlab.top/posts/linux_dvd/</link><guid isPermaLink="true">https://www.mintlab.top/posts/linux_dvd/</guid><description>基于Linux命令行的DVD、BD光盘刻录和校验</description><pubDate>Sat, 15 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Linux 下光盘刻录&lt;/h1&gt;
&lt;h2&gt;制作iso镜像文件&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 基本参数
genisoimage -V &quot;&amp;lt;盘名称&amp;gt;&quot; -J -R -udf -allow-limited-size -o &quot;&amp;lt;输出iso文件位置&amp;gt;&quot; &amp;lt;要打包的目录&amp;gt;

# 基本示例
genisoimage -V &quot;BD-1-2025-11-11&quot; -J -R -udf -allow-limited-size -o &quot;~/iso/BD-1-2025-11-11.iso&quot; ~/Documents
&lt;/code&gt;&lt;/pre&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;-V&lt;/td&gt;
&lt;td&gt;&quot;BD-1-2025-11-11&quot; 设置光盘卷标，在系统中显示为光盘名称&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-J&lt;/td&gt;
&lt;td&gt;生成 Joliet 目录记录，支持Windows长文件名&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-R&lt;/td&gt;
&lt;td&gt;使用 Rock Ridge 协议，保留Unix/Linux文件权限和符号链接&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-udf&lt;/td&gt;
&lt;td&gt;启用 UDF 文件系统，支持大于4GB的文件和更好的兼容性&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-allow-limited-size&lt;/td&gt;
&lt;td&gt;允许创建超过ISO 9660标准限制的镜像&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-o &quot;~/iso/BD-1-2025-11-11.iso&quot;&lt;/td&gt;
&lt;td&gt;指定输出ISO文件的路径和名称&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~/Documents&lt;/td&gt;
&lt;td&gt;要打包的源目录路径&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3&gt;🎯 其他常用可选参数&lt;/h3&gt;
&lt;h4&gt;文件系统相关&lt;/h4&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;-joliet-long&lt;/td&gt;
&lt;td&gt;允许Joliet文件名达到103字符（默认64）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-ucs-level 3&lt;/td&gt;
&lt;td&gt;使用UCS级别3，支持更多Unicode字符&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-iso-level 4&lt;/td&gt;
&lt;td&gt;使用ISO 9660版本4，支持更多特性&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-no-iso-translate&lt;/td&gt;
&lt;td&gt;禁止字符转换，保持原文件名&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;优化和性能&lt;/h4&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;-sort&lt;/td&gt;
&lt;td&gt;按文件名排序文件在光盘上的位置&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-r&lt;/td&gt;
&lt;td&gt;按反向顺序排序&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-cache-inodes&lt;/td&gt;
&lt;td&gt;缓存inode信息（Linux系统）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-no-cache-inodes&lt;/td&gt;
&lt;td&gt;不缓存inode信息（其他系统）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;文件过滤和排除&lt;/h4&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;-exclude-list file.txt&lt;/td&gt;
&lt;td&gt;从指定文件读取要排除的文件列表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-exclude &quot;*.tmp&quot;&lt;/td&gt;
&lt;td&gt;排除所有.tmp文件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-path-list file.txt&lt;/td&gt;
&lt;td&gt;只包含列表中的文件和目录&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;引导和特殊用途&lt;/h4&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;-b boot.img&lt;/td&gt;
&lt;td&gt;指定引导镜像文件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-c boot.catalog&lt;/td&gt;
&lt;td&gt;指定引导目录文件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-no-emul-boot&lt;/td&gt;
&lt;td&gt;非模拟模式启动&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-boot-load-size 4&lt;/td&gt;
&lt;td&gt;设置引导扇区加载大小&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-hide file.txt&lt;/td&gt;
&lt;td&gt;在ISO 9660中隐藏文件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-hide-joliet file.txt&lt;/td&gt;
&lt;td&gt;在Joliet中隐藏文件&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;💡 实用命令示例&lt;/h4&gt;
&lt;h5&gt;完整的推荐命令&lt;/h5&gt;
&lt;pre&gt;&lt;code&gt;genisoimage -V &quot;BACKUP_2025&quot; -J -joliet-long -R -udf -iso-level 4 \
  -allow-limited-size -cache-inodes -o &quot;/backup/backup.iso&quot; \
  -exclude &quot;*.tmp&quot; -exclude &quot;*.log&quot; /usr/total_backup
&lt;/code&gt;&lt;/pre&gt;
&lt;h5&gt;创建可启动备份&lt;/h5&gt;
&lt;pre&gt;&lt;code&gt;genisoimage -V &quot;RESCUE_DISK&quot; -J -R -udf -b boot/grub/stage2_eltorito \
  -no-emul-boot -boot-load-size 4 -boot-info-table \
  -allow-limited-size -o &quot;/backup/rescue.iso&quot; /rescue_files
&lt;/code&gt;&lt;/pre&gt;
&lt;h5&gt;带文件过滤的备份&lt;/h5&gt;
&lt;pre&gt;&lt;code&gt;# 创建排除列表文件
echo &quot;*.tmp&quot; &amp;gt; exclude-list.txt
echo &quot;*.log&quot; &amp;gt;&amp;gt; exclude-list.txt
echo &quot;temp/&quot; &amp;gt;&amp;gt; exclude-list.txt

genisoimage -V &quot;CLEAN_BACKUP&quot; -J -R -udf -allow-limited-size \
  -exclude-list exclude-list.txt -o &quot;/backup/clean.iso&quot; /usr/total_backup
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;启动刻录&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;growisofs -dvd-compat -speed=&amp;lt;速度&amp;gt; -Z &amp;lt;光驱位置&amp;gt;=&quot;&amp;lt;iso文件位置&amp;gt;&quot;

growisofs -dvd-compat -speed=2 -Z /dev/sr0=&quot;~/iso/BD-1-2025-11-11.iso&quot;
&lt;/code&gt;&lt;/pre&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;strong&gt;基本操作&lt;/strong&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;-Z&lt;/td&gt;
&lt;td&gt;开始刻录到空白光盘&lt;/td&gt;
&lt;td&gt;-Z /dev/sr0=file.iso&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-M&lt;/td&gt;
&lt;td&gt;追加刻录（多会话）&lt;/td&gt;
&lt;td&gt;-M /dev/sr0=file2.iso&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-dvd-compat&lt;/td&gt;
&lt;td&gt;封闭光盘提高兼容性&lt;/td&gt;
&lt;td&gt;-dvd-compat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;速度控制&lt;/strong&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;-speed=N&lt;/td&gt;
&lt;td&gt;设置刻录速度&lt;/td&gt;
&lt;td&gt;-speed=2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-s&lt;/td&gt;
&lt;td&gt;模拟刻录（测试）&lt;/td&gt;
&lt;td&gt;-s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;缓存设置&lt;/strong&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;-use-the-force-luke=bufsize:SIZE&lt;/td&gt;
&lt;td&gt;设置缓冲区大小&lt;/td&gt;
&lt;td&gt;bufsize:32m&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-overburn&lt;/td&gt;
&lt;td&gt;允许超刻（超过标称容量）&lt;/td&gt;
&lt;td&gt;-overburn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;光盘管理&lt;/strong&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;-dvd-compat&lt;/td&gt;
&lt;td&gt;完成后封闭光盘&lt;/td&gt;
&lt;td&gt;-dvd-compat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-dao&lt;/td&gt;
&lt;td&gt;使用 Disk-At-Once 模式&lt;/td&gt;
&lt;td&gt;-dao&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-sa&lt;/td&gt;
&lt;td&gt;使用 Session-At-Once 模式&lt;/td&gt;
&lt;td&gt;-sa&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;文件系统&lt;/strong&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;-udf&lt;/td&gt;
&lt;td&gt;使用 UDF 文件系统&lt;/td&gt;
&lt;td&gt;-udf&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-iso-level&lt;/td&gt;
&lt;td&gt;设置 ISO 级别&lt;/td&gt;
&lt;td&gt;-iso-level 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-joliet&lt;/td&gt;
&lt;td&gt;启用 Joliet 扩展&lt;/td&gt;
&lt;td&gt;-joliet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-rock&lt;/td&gt;
&lt;td&gt;启用 Rock Ridge 扩展&lt;/td&gt;
&lt;td&gt;-rock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;调试信息&lt;/strong&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;-v&lt;/td&gt;
&lt;td&gt;详细输出&lt;/td&gt;
&lt;td&gt;-v&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-dry-run&lt;/td&gt;
&lt;td&gt;干运行（不实际刻录）&lt;/td&gt;
&lt;td&gt;-dry-run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;特殊选项&lt;/strong&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;-use-the-force-luke=notray&lt;/td&gt;
&lt;td&gt;不弹出光盘托盘&lt;/td&gt;
&lt;td&gt;notray&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-use-the-force-luke=tty&lt;/td&gt;
&lt;td&gt;强制 TTY 输出&lt;/td&gt;
&lt;td&gt;tty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-use-the-force-luke=4gms&lt;/td&gt;
&lt;td&gt;使用 4GB 边界对齐&lt;/td&gt;
&lt;td&gt;4gms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;验证光盘&lt;/h2&gt;
&lt;h3&gt;文件完整性验证&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;cmp -n $(stat -c%s image.iso) image.iso /dev/sr0
&lt;/code&gt;&lt;/pre&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;-n $(stat -c%s image.iso)&lt;/td&gt;
&lt;td&gt;限制比较的字节数为原始 ISO 文件的大小&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;image.iso&lt;/td&gt;
&lt;td&gt;原始 ISO 镜像文件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/dev/sr0&lt;/td&gt;
&lt;td&gt;刻录后的光盘设备&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;工作原理：&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;stat -c%s image.iso 获取原始 ISO 文件的确切字节大小&lt;/li&gt;
&lt;li&gt;cmp 读取光盘上前 N 个字节（N=ISO文件大小）与原始文件逐字节比较&lt;/li&gt;
&lt;li&gt;如果完全一致，命令不输出任何内容（返回码 0）&lt;/li&gt;
&lt;li&gt;如果发现差异，会报告第一个不匹配的位置&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;文件校验&lt;/h3&gt;
&lt;h4&gt;步骤1：计算原始文件哈希&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 计算MD5（快速）
md5sum BD-1-2025-11-11.iso
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;步骤2：验证光盘内容&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;# 获取ISO文件大小
iso_size=$(stat -c%s &quot;BD-1-2025-11-11.iso&quot;)

# 验证MD5
dd if=/dev/sr0 bs=1M 2&amp;gt;/dev/null | head -c $iso_size | md5sum
&lt;/code&gt;&lt;/pre&gt;
&lt;h4&gt;步骤3：自动化验证脚本&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash
ISO_FILE=&quot;BD-1-2025-11-11.iso&quot;
CD_DEVICE=&quot;/dev/sr0&quot;

echo &quot;计算原始文件MD5...&quot;
original_md5=$(md5sum &quot;$ISO_FILE&quot; | awk &apos;{print $1}&apos;)

echo &quot;计算光盘内容MD5...&quot;
iso_size=$(stat -c%s &quot;$ISO_FILE&quot;)
cd_md5=$(dd if=&quot;$CD_DEVICE&quot; bs=1M 2&amp;gt;/dev/null | head -c $iso_size | md5sum | awk &apos;{print $1}&apos;)

if [ &quot;$original_md5&quot; = &quot;$cd_md5&quot; ]; then
    echo &quot;✅ MD5验证成功：光盘刻录完整&quot;
else
    echo &quot;❌ MD5验证失败：文件不匹配&quot;
    echo &quot;原始文件: $original_md5&quot;
    echo &quot;光盘内容: $cd_md5&quot;
fi
&lt;/code&gt;&lt;/pre&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;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;(无参数)&lt;/td&gt;
&lt;td&gt;计算文件MD5值&lt;/td&gt;
&lt;td&gt;md5sum file.iso&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-b&lt;/td&gt;
&lt;td&gt;二进制模式读取文件&lt;/td&gt;
&lt;td&gt;md5sum -b file.iso&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-t&lt;/td&gt;
&lt;td&gt;文本模式读取文件（默认）&lt;/td&gt;
&lt;td&gt;md5sum -t file.txt&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;-c&lt;/td&gt;
&lt;td&gt;从文件检查MD5值&lt;/td&gt;
&lt;td&gt;md5sum -c checksum.md5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;--status&lt;/td&gt;
&lt;td&gt;静默模式，只返回状态码&lt;/td&gt;
&lt;td&gt;md5sum -c --status checksum.md5&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;--tag&lt;/td&gt;
&lt;td&gt;输出BSD风格格式&lt;/td&gt;
&lt;td&gt;md5sum --tag file.iso&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-z&lt;/td&gt;
&lt;td&gt;用NUL分隔输出行&lt;/td&gt;
&lt;td&gt;md5sum -z *.iso&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4&gt;💡 实际应用示例&lt;/h4&gt;
&lt;h5&gt;创建MD5校验文件&lt;/h5&gt;
&lt;pre&gt;&lt;code&gt;# 为多个文件创建校验文件
md5sum *.iso &amp;gt; checksum.md5

# 检查内容
cat checksum.md5
# 输出：
# d41d8cd98f00b204e9800998ecf8427e  file1.iso
# e4d909c290d0fb1ca068ffaddf22cbd0  file2.iso
&lt;/code&gt;&lt;/pre&gt;
&lt;h5&gt;批量验证文件&lt;/h5&gt;
&lt;pre&gt;&lt;code&gt;# 验证checksum.md5中所有的文件
md5sum -c checksum.md5

# 输出：
# file1.iso: 确定
# file2.iso: 确定
&lt;/code&gt;&lt;/pre&gt;
&lt;h5&gt;网络下载验证&lt;/h5&gt;
&lt;pre&gt;&lt;code&gt;# 下载文件和他的MD5校验文件
wget http://example.com/file.iso
wget http://example.com/file.iso.md5

# 验证下载的文件
md5sum -c file.iso.md5
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>linux备份命令</title><link>https://www.mintlab.top/posts/linux_backup/</link><guid isPermaLink="true">https://www.mintlab.top/posts/linux_backup/</guid><description>基于Linux命令行rsync命令的备份</description><pubDate>Tue, 11 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;linux备份命令&lt;/h1&gt;
&lt;h2&gt;rsync 同步&lt;/h2&gt;
&lt;h3&gt;常用命令形式&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;本地同步&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rsync -av /path/to/source/ /path/to/destination/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;从本地同步到远程&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rsync -avz /local/path/ username@remote_host:/remote/path/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;从远程同步到本地&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rsync -avz username@remote_host:/remote/path/ /local/path/
&lt;/code&gt;&lt;/pre&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;-a&lt;/td&gt;
&lt;td&gt;--archive&lt;/td&gt;
&lt;td&gt;归档模式，保留所有文件属性（相当于 -rlptgoD）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-v&lt;/td&gt;
&lt;td&gt;--verbose&lt;/td&gt;
&lt;td&gt;显示详细传输信息&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-z&lt;/td&gt;
&lt;td&gt;--compress&lt;/td&gt;
&lt;td&gt;传输时压缩数据&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-r&lt;/td&gt;
&lt;td&gt;--recursive&lt;/td&gt;
&lt;td&gt;递归复制目录&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-l&lt;/td&gt;
&lt;td&gt;--links&lt;/td&gt;
&lt;td&gt;保留符号链接&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-p&lt;/td&gt;
&lt;td&gt;--perms&lt;/td&gt;
&lt;td&gt;保留文件权限&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-t&lt;/td&gt;
&lt;td&gt;--times&lt;/td&gt;
&lt;td&gt;保留文件修改时间&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-g&lt;/td&gt;
&lt;td&gt;--group&lt;/td&gt;
&lt;td&gt;保留文件所属组&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-o&lt;/td&gt;
&lt;td&gt;--owner&lt;/td&gt;
&lt;td&gt;保留文件所有者&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-D&lt;/td&gt;
&lt;td&gt;--devices&lt;/td&gt;
&lt;td&gt;保留设备文件（仅限超级用户）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-h&lt;/td&gt;
&lt;td&gt;--human-readable&lt;/td&gt;
&lt;td&gt;以人类可读格式输出数字&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;--progress&lt;/td&gt;
&lt;td&gt;显示传输进度&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;--delete&lt;/td&gt;
&lt;td&gt;删除目标中源没有的文件&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;--exclude=PATTERN&lt;/td&gt;
&lt;td&gt;排除匹配 PATTERN 的文件&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;--include=PATTERN&lt;/td&gt;
&lt;td&gt;包含匹配 PATTERN 的文件&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;-n&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;h3&gt;本地备份&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo rsync -avzh --log-file=/usr/doc_backup/rsync.log /home/mint/Documents /usr/doc_backup/ 
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>python pip虚拟环境构建和使用</title><link>https://www.mintlab.top/posts/python_pip/</link><guid isPermaLink="true">https://www.mintlab.top/posts/python_pip/</guid><description>基于Linux命令行的python pip虚拟环境构建和使用</description><pubDate>Sat, 08 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;python pip虚拟环境构建和使用&lt;/h1&gt;
&lt;h2&gt;pip虚拟环境&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;sudo apt install python3-venv

# 创建新的虚拟环境
python3 -m venv .venv

# 激活虚拟环境
source .env/bin/activate

# 执行相关安装
pip install ...
pip3 install ...

# 退出虚拟环境
deactivate
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Django运行没有端口权限解决&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;sudo -E ../.env/bin/python3 manage.py runserver localhost:80
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>Django 使用小记</title><link>https://www.mintlab.top/posts/django_usage_note/</link><guid isPermaLink="true">https://www.mintlab.top/posts/django_usage_note/</guid><description>基于命令行的Django项目创建与调试</description><pubDate>Wed, 05 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;Django 使用小记&lt;/h1&gt;
&lt;h2&gt;命令行使用&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 新建工程文件
django-admin startproject &amp;lt;projectName&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;调试&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;# 启动调试服务
manage.py runserver localhost:80

# Linux下缺少端口权限，使用该命令
sudo -E ../.env/bin/python3 manage.py runserver localhost:80
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;url路由&lt;/h2&gt;
&lt;h3&gt;分级解析&lt;/h3&gt;
&lt;p&gt;在工程目录urls.py&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;rom django.urls import include, path
from sales import views

urlpatterns = [
    path(&apos;admin/&apos;, admin.site.urls),
    path(&apos;sales/&apos;, include(&quot;sales.urls&quot;)),
]

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在sales/urls.py&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;from django.urls import path
from sales.views import listorders

urlpatterns = [
    path(&apos;orders/&apos;, listorders),
]
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item><item><title>FMO外壳安装说明（电池版）</title><link>https://www.mintlab.top/posts/fmo_battries_tips/</link><guid isPermaLink="true">https://www.mintlab.top/posts/fmo_battries_tips/</guid><description>BI6LDD基于FMO制作的配套外壳安装步骤说明</description><pubDate>Tue, 04 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;h1&gt;购买无电池普通版本 的台友请看这里：&lt;/h1&gt;
&lt;p&gt;外壳安装仅需拆掉FMO原装透明亚克力前后保护板，然后参考   步骤&lt;strong&gt;4、5、6、13&lt;/strong&gt;进行安装即可。附带螺丝有4短4长一共8颗螺丝短螺丝用于固定前面板，长螺丝用于固定后盖。&lt;/p&gt;
&lt;p&gt;祝各位台友玩的愉快 73！&lt;/p&gt;
&lt;p&gt;:::note[提示]
本页面只是提供其外壳使用说明的展示服务，最终解释权归BI6LDD所有&lt;/p&gt;
&lt;p&gt;使用说明的PDF版本点此下载：&lt;a href=&quot;http://file.mintlab.top/blog/fmo%E5%A4%96%E5%A3%B3%E5%AE%89%E8%A3%85%E5%9B%BE%E8%A7%A3.pdf&quot;&gt;fmo外壳安装图解.pdf&lt;/a&gt;
:::&lt;/p&gt;
&lt;p&gt;:::caution[安装注意]
FMO原装的塑料螺柱非常非常脆弱！请拆卸和安装时候务必非常小心！所有螺丝拧上后请稍稍用力拧紧即可，大力拧紧螺丝会造成螺丝滑丝无法安装和使用！
:::&lt;/p&gt;
&lt;h1&gt;1&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/1.BBaC1-RR.png&quot; alt=&quot;1&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;2&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/2.FbYiMtWm.png&quot; alt=&quot;2&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;3&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/3.C2OxyJ--.png&quot; alt=&quot;3&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;4&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/4.QH59RwHT.png&quot; alt=&quot;4&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;5&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/5.CfbS-fu1.png&quot; alt=&quot;5&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;6&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/6.DklcsgU3.png&quot; alt=&quot;6&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;7&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/7.BUCoMQdf.png&quot; alt=&quot;7&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;8&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/8.B--nOwNA.png&quot; alt=&quot;8&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;9&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/9.BgUUsHck.png&quot; alt=&quot;9&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;10&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/10.DZA9d05j.png&quot; alt=&quot;10&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;11&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/11.Cx-x1lTI.png&quot; alt=&quot;11&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;12&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/12.vv34pczo.png&quot; alt=&quot;12&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;13&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/13.CKGEBNkS.png&quot; alt=&quot;13&quot; /&gt;&lt;/p&gt;
&lt;h1&gt;14&lt;/h1&gt;
&lt;p&gt;&lt;img src=&quot;https://www.mintlab.top/_astro/14.BCCtXpd0.png&quot; alt=&quot;14&quot; /&gt;&lt;/p&gt;
</content:encoded></item></channel></rss>