11242 字
56 分钟
新年要用新系统
2026-03-08
2026-08-29

这个blog两年没更新过了啊!靠着AI打气又拾起来了!

起因是节前Fusion 360警告我win10不受支持了,Gemini也建议我换个系统,并且疯狂安利Win11,安抚我说Win11已经不是几年前那种费拉不堪的状态了。

再信AI一次。

0 备份#

尤其是wsl2!

Terminal window
mkdir -p ~/wsl_backup/manifests
cd ~/wsl_backup/manifests
#######
# apt #
#######
# 导出所有手动安装的包(排除依赖包)
comm -23 <(apt-mark showmanual | sort -u) <(gzip -dc /var/log/installer/initial-status.gz | sed -n 's/^Package: //p' | sort -u) > apt-manual-packages.txt
# 如果上面命令报错或太复杂,用这个简单的版本(会包含一些基础包):
apt-mark showmanual > apt-manual-list.txt
###################
# python 和 conda #
###################
# 激活主环境,比如 base 或 pytorch
conda env export --no-builds > conda_environment.yml
pip freeze > requirements.txt
########
# rust #
########
cargo install --list > cargo-crates.txt
########
# node #
########
npm list -g --depth=0 > npm-global-packages.txt
##############
# shell conf #
##############
cd ~
tar -czvf ~/wsl_backup/dotfiles.tar.gz \
.zshrc \
.bashrc \
.profile \
.zsh_history \
.bash_history
.ssh \
.tmux.conf \
.fzf.zsh \
.gitconfig \
.vimrc \
.config/nvim \
.gnupg \
.condarc \
.pip \
.cargo/config.toml \

1 装系统#

装专业版即可,需要注意,分区别在跟Linux似的一块/用到底,2T盘可以先给C盘384G(384×1024=393216 MB384 \times 1024 = 393216 \text{ MB}),剩下的给D盘。使用的时候:

  • wsl2放D盘
  • 尽量用protable软件,全放D盘,那些死皮赖脸要待在C盘的软件再放C盘。

如此,重装的时候不会一锅端。

另外,装好了第一次见到Windows初始化配置的时候,微软强制登录Microsoft账号。此时Shift + F10,输入OOBE\BYPASSNRO自动重启,就可以选“我没有 Internet 连接”跳过账号登录。好处是可以完全自己决定user文件夹的名字。

进了系统先装驱动,尤其是显卡驱动,先装完驱动再装wsl2,但是主板带的什么MSI Center装完驱动可以卸了。

NVIDIA APP 选 NVIDIA studio 驱动,对跑CUDA的场景来说,Game Ready 驱动更新过于频繁。

2 清理微软的垃圾#

第一时间防火防盗防微软。

  1. 彻底禁止 Windows 自动更新驱动: Win + R 输入 gpedit.msc计算机配置 -> 管理模板 -> Windows 组件 -> Windows 更新 -> 管理从 Windows 更新提供的更新,右侧的 “Windows 更新不包括驱动程序”,选择 “已启用”。如此,让 Windows Update 只推安全补丁和功能更新,绝不会动显卡、网卡、声卡驱动。

  2. 保留 Defender,配置“码农豁免区” 打开 Windows 安全中心 -> 病毒和威胁防护 -> 管理设置,下拉找到 “排除项” -> 添加或删除排除项,酌情加入:

    • D:\WSL (放 WSL 镜像,防止扫描 vhdx 读写);
    • D:\DEV (放代码);
    • D:\MOEDL (放模型);
    • etc.
  3. 左下角“天气新闻”等等是 小组件” (Widgets)Win + R 输入 gpedit.msc计算机配置 -> 管理模板 -> Windows 组件 -> 小组件,右侧的 “允许小组件”,选择 “已禁用”

  4. 锁屏时“天气新闻”等待是 Windows 聚焦 (Windows Spotlight)

    • 右键桌面 -> “个性化” -> 选择 “锁屏界面”,在个 性化锁屏界面 中选择 “图片” (Picture)
    • 再往下看,如果有 “在锁屏界面上获取花絮、提示、技巧和更多内容”,选择 关闭
    • 如果有 “锁屏界面状态”,改为 无 (None)
  5. 开始菜单里的垃圾,右键桌面 -> “个性化” -> “开始”

    • 显示最近添加的应用 ->
    • 显示最常用的应用 ->
    • 在“开始”菜单中……显示最近打开的项目 ->
  6. 开始菜单的搜索 强行把本地搜索和 Bing 搜索混合

    • Win + R 输入 gpedit.msc用户配置 (User Configuration) -> 管理模板 (Administrative Templates) -> Windows 组件 (Windows Components) -> 文件资源管理器 (File Explorer),右侧找:“关闭文件资源管理器搜索框中的最近搜索条目显示” (Turn off display of recent search entries in the Windows Search box) ,选择 “已启用” (Enabled) 。重启资源管理器即可,taskkill /f /im explorer.exe; start explorer.exe注意:微软经常改这个策略的名字,比如也可能叫“关闭搜索框建议”。
    • 也可以执行 reg add "HKCU\Software\Policies\Microsoft\Windows\Explorer" /v "DisableSearchBoxSuggestions" /t REG_DWORD /d 1 /f 修改。
    • 关闭 “搜索要点” (Search Highlights)设置 -> 隐私和安全性 -> 搜索权限,往下找到 “更多设置”,关掉 “显示搜索要点” (Show search highlights)
  7. 任务栏的搜索框,任务栏空白处 右键 -> “任务栏设置”,第一项就是 “搜索”,选择 “隐藏”

  8. Steam/Epic党可以把XBox相关都端了:

    • 关闭 Xbox Game Bar:在 设置 -> 游戏 -> Xbox Game Bar,关闭 “允许控制器打开 Game Bar”
    • 开始菜单搜 “Xbox”,右键 -> 卸载
    • 唯一例外:游戏模式 (Game Mode) ,留着,在 设置 -> 游戏 -> 游戏模式 里,保持 开 (ON)。
  9. 关闭 OneDrive

    • 解除“文件夹备份”,任务栏右下角的那个“云朵”图标,右上角齿轮进入 设置,找到 “同步并备份” (Sync and backup) -> “管理备份” (Manage backup),把“文档”、“图片”、“桌面”全部关掉(Stop backup)。,此时系统会把这些文件夹的路径从 C:\Users\<username>\OneDrive\Desktop 恢复为 C:\Users\<username>\Desktop
    • 取消链接:设置 里 -> “账户” (Account),点击 “取消链接此电脑” (Unlink this PC)。
    • 卸载:设置 -> 应用 -> 安装的应用,搜 “OneDrive” -> 卸载
  10. 至于 基于虚拟化的安全性 (VBS)” / HVCI,模型和网上的说法都没定论。

    • Win 键搜索 “内核隔离” -> “内存完整性”,收益有限,关不关无所谓。
    • 网上流传的 bcdedit /set hypervisorlaunchtype off 不能设置, wsl2 要用。
  11. 找回Win10右键菜单:

    Terminal window
    reg add "HKCU\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32" /f /ve

    重启资源管理器即可。后悔的话:

    Terminal window
    reg delete "HKCU\Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}" /f

3 清理其他垃圾,装Windows下的常用工具#

  • 搜狗自带桌面壁纸和汪仔,记得删掉和关闭。
  • 取消WPS的云盘资源管理器图标、取消传输助手自启。
  • cherrystudio
  • 装no machine。
  • Snipaste用portable。
  • Motrix用portable。

装Git:

Terminal window
winget install --id Git.Git

装微信QQ:

Terminal window
winget install Tencent.WeChat
winget install Tencent.QQ

PotPlayer:

Terminal window
winget install Daum.PotPlayer

装powertoys:

Terminal window
winget install Microsoft.PowerToys

装Everything和Listary:

Terminal window
winget install voidtools.Everything
winget install -e --id Bopsoft.Listary

4 安装wsl2#

有显卡驱动了再装wsl2。

  1. 先装 WSL 本体,管理员 PowerShell:

    Terminal window
    wsl --install
  2. 装完建议重启,确认 WSL 版本:

    Terminal window
    wsl --version
  3. 在 D 盘准备一个专门目录 D:\WSL,安装一个临时 Ubuntu(会默认在 C 盘),启动一次 Ubuntu,完成用户名/密码初始化后退出。

    Terminal window
    wsl --install -d Ubuntu
  4. 把迁移到 D 盘:

    • 先关掉 WSL:
    Terminal window
    wsl --shutdown
    • 导出为 tar(临时文件建议也放 D 盘):
    Terminal window
    wsl --export Ubuntu D:\WSL\ubuntu.tar
    • 注销原来的 Ubuntu(这一步会删除 C 盘那个实例的注册与磁盘):
    Terminal window
    wsl --unregister Ubuntu
    • 导入到 D 盘(强制 WSL2):
    Terminal window
    wsl --import Ubuntu D:\WSL D:\WSL\ubuntu.tar --version 2

    现在 Ubuntu 的 ext4.vhdx 就在 D:\WSL\ 下面了。

  5. wsl2网络配置:建议按老方法,默认NAT,不要配自动代理、DNS隧道等配置。装个clash-verge,用自己写的订阅发布程序让Windows、wsl、PVE、rock5b、o6n等设备同步订阅配置,虽然heavy但是最稳妥:

    [wsl2]
    networkingMode=nat
    autoProxy=false
    dnsTunneling=false
    dnsProxy=false

    新wsl2支持mirror,直接走Windows的网卡,别的没什么问题,但是gemini-cli或codex可能会用不了:

    [wsl2]
    networkingMode=mirrored
    dnsTunneling=true
    autoProxy=true
    firewall=true

    此时wget下载chrome会自动拿到autoproxy配置走clash,但是apt不行,还得手动指定proxy

    Terminal window
    sudo tee /etc/apt/apt.conf.d/99proxy <<'EOF'
    Acquire::http::Proxy "http://127.0.0.1:7897";
    Acquire::https::Proxy "http://127.0.0.1:7897";
    EOF

5 初始化wsl2#

建议先改主机名:

Terminal window
sudo hostnamectl set-hostname new_name

20260829更新,用gpt-sol-5.6更新了个一键安装脚本 bootstrap-dev-v5.sh ,也可以分块执行。

脚本是幂等的,可以直接用:

Terminal window
curl -fsSL \
https://gist.githubusercontent.com/zlotus/c0516b62bd5117a7683c76f82cf8ea03/raw/bootstrap.sh \
-o /tmp/bootstrap.sh
GITHUB_TOKEN=xxx bash /tmp/bootstrap.sh

具体内容如下:

#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
# ============================================================
# Development Environment Bootstrap v5
# Target: Ubuntu / Debian, WSL2 or native Linux
# Arch: x86_64 / arm64(aarch64)
#
# Usage:
# bash bootstrap-dev.sh # run all phases
# bash bootstrap-dev.sh system rust # run selected phases
#
# Optional environment variables:
# BOOTSTRAP_PROXY=auto # default: auto-detect 127.0.0.1:7897
# BOOTSTRAP_PROXY=http://127.0.0.1:7897 # force proxy
# BOOTSTRAP_PROXY=off # disable proxy configuration
# INSTALL_LAZYVIM=1 # default: 1
# INSTALL_MINICONDA=1 # default: 1
# INSTALL_DOCKER=1 # default: 1
# INSTALL_AI_CLI=1 # default: 1
# INSTALL_DESKTOP_I18N=auto # auto: native GUI only; 1=force; 0=skip
# UPGRADE_SYSTEM=0 # default: 0; avoid surprise full upgrade
#
# GitHub API rate limit:
# export GITHUB_TOKEN=... # optional; never hard-code token here
# ============================================================
readonly SCRIPT_NAME="${0##*/}"
readonly GREEN='\033[0;32m'
readonly BLUE='\033[0;34m'
readonly YELLOW='\033[0;33m'
readonly RED='\033[0;31m'
readonly NC='\033[0m'
BOOTSTRAP_PROXY="${BOOTSTRAP_PROXY:-auto}"
INSTALL_LAZYVIM="${INSTALL_LAZYVIM:-1}"
INSTALL_MINICONDA="${INSTALL_MINICONDA:-1}"
INSTALL_DOCKER="${INSTALL_DOCKER:-1}"
INSTALL_AI_CLI="${INSTALL_AI_CLI:-1}"
INSTALL_DESKTOP_I18N="${INSTALL_DESKTOP_I18N:-auto}"
UPGRADE_SYSTEM="${UPGRADE_SYSTEM:-0}"
export CARGO_NET_RETRY="${CARGO_NET_RETRY:-5}"
export CARGO_HTTP_TIMEOUT="${CARGO_HTTP_TIMEOUT:-120}"
export RUSTUP_DOWNLOAD_TIMEOUT="${RUSTUP_DOWNLOAD_TIMEOUT:-120}"
log() { printf '%b[SETUP]%b %s\n' "$GREEN" "$NC" "$*"; }
info() { printf '%b[INFO ]%b %s\n' "$BLUE" "$NC" "$*"; }
warn() { printf '%b[WARN ]%b %s\n' "$YELLOW" "$NC" "$*" >&2; }
die() { printf '%b[ERROR]%b %s\n' "$RED" "$NC" "$*" >&2; exit 1; }
on_error() {
local exit_code=$?
printf '%b[ERROR]%b line=%s exit=%s command=%q\n' \
"$RED" "$NC" "$LINENO" "$exit_code" "$BASH_COMMAND" >&2
exit "$exit_code"
}
trap on_error ERR
TMP_DIR=""
SUDO_KEEPALIVE_PID=""
cleanup() {
[[ -n "$SUDO_KEEPALIVE_PID" ]] && kill "$SUDO_KEEPALIVE_PID" 2>/dev/null || true
[[ -n "$TMP_DIR" && -d "$TMP_DIR" ]] && rm -rf "$TMP_DIR"
}
trap cleanup EXIT
retry() {
local max_attempts="$1"; shift
local delay="$1"; shift
local attempt=1
until "$@"; do
if (( attempt >= max_attempts )); then
return 1
fi
warn "命令失败,第 ${attempt}/${max_attempts} 次;${delay}s 后重试: $*"
sleep "$delay"
((attempt++))
done
}
optional() {
local desc="$1"; shift
if ! "$@"; then
warn "可选步骤失败,继续执行: ${desc}"
return 0
fi
}
has() { command -v "$1" >/dev/null 2>&1; }
is_wsl() {
grep -qiE '(microsoft|wsl)' /proc/version 2>/dev/null || \
grep -qiE '(microsoft|wsl)' /proc/sys/kernel/osrelease 2>/dev/null
}
port_open() {
local host="$1" port="$2"
timeout 1 bash -c "</dev/tcp/${host}/${port}" >/dev/null 2>&1
}
has_graphical_desktop() {
# DISPLAY/WAYLAND_DISPLAY covers an active graphical session. The
# desktop-session files cover bootstrap runs from a TTY/SSH on a desktop
# machine where the GUI is installed but not active in this shell.
[[ -n "${DISPLAY:-}" || -n "${WAYLAND_DISPLAY:-}" ]] && return 0
[[ -d /usr/share/xsessions ]] && find /usr/share/xsessions -maxdepth 1 -type f -name '*.desktop' -print -quit 2>/dev/null | grep -q . && return 0
[[ -d /usr/share/wayland-sessions ]] && find /usr/share/wayland-sessions -maxdepth 1 -type f -name '*.desktop' -print -quit 2>/dev/null | grep -q . && return 0
return 1
}
should_install_desktop_i18n() {
case "$INSTALL_DESKTOP_I18N" in
1|yes|true|on) return 0 ;;
0|no|false|off) return 1 ;;
auto)
is_wsl && return 1
has_graphical_desktop
;;
*) die "INSTALL_DESKTOP_I18N 取值无效: $INSTALL_DESKTOP_I18N(应为 auto/1/0)" ;;
esac
}
apt_package_exists() {
apt-cache show "$1" >/dev/null 2>&1
}
apt_install_if_available() {
local -a available=()
local pkg
for pkg in "$@"; do
if apt_package_exists "$pkg"; then
available+=("$pkg")
else
info "软件源中没有 $pkg,跳过。"
fi
done
((${#available[@]})) && apt_install "${available[@]}"
}
require_normal_user() {
[[ ${EUID:-$(id -u)} -ne 0 ]] || die "请使用普通用户运行,脚本会在需要时调用 sudo。"
}
require_supported_os() {
[[ -r /etc/os-release ]] || die "无法识别 Linux 发行版。"
# shellcheck disable=SC1091
. /etc/os-release
case "${ID:-}" in
ubuntu|debian) ;;
*) die "当前仅支持 Debian / Ubuntu;检测到: ${ID:-unknown}" ;;
esac
}
detect_arch() {
readonly UNAME_ARCH="$(uname -m)"
case "$UNAME_ARCH" in
x86_64|amd64)
readonly ARCH="amd64"
readonly GH_ARCH="x86_64"
readonly FASTFETCH_ARCH="amd64"
readonly MINICONDA_ARCH="x86_64"
;;
aarch64|arm64)
readonly ARCH="arm64"
readonly GH_ARCH="arm64"
readonly FASTFETCH_ARCH="aarch64"
readonly MINICONDA_ARCH="aarch64"
;;
*) die "不支持的 CPU 架构: $UNAME_ARCH(当前仅支持 x86_64 / arm64)" ;;
esac
}
setup_sudo() {
sudo -v
(
while true; do
sudo -n true
sleep 50
done
) 2>/dev/null &
SUDO_KEEPALIVE_PID=$!
}
setup_proxy() {
local proxy=""
case "$BOOTSTRAP_PROXY" in
off|none|0)
info "代理配置已禁用。"
return
;;
auto)
if port_open 127.0.0.1 7897; then
proxy="http://127.0.0.1:7897"
info "检测到 127.0.0.1:7897,启用代理。"
else
info "未检测到 127.0.0.1:7897,按直连模式执行。"
return
fi
;;
http://*|https://*) proxy="$BOOTSTRAP_PROXY" ;;
*) die "BOOTSTRAP_PROXY 格式无效: $BOOTSTRAP_PROXY" ;;
esac
export http_proxy="$proxy" https_proxy="$proxy"
export HTTP_PROXY="$proxy" HTTPS_PROXY="$proxy"
# apt doesn't reliably inherit WSL/Windows proxy settings.
sudo tee /etc/apt/apt.conf.d/99bootstrap-proxy >/dev/null <<EOF_PROXY
Acquire::http::Proxy "${proxy}";
Acquire::https::Proxy "${proxy}";
EOF_PROXY
BOOTSTRAP_PROXY="$proxy"
}
apt_get() {
retry 5 4 sudo apt-get \
-o Acquire::Retries=5 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
"$@"
}
apt_install() {
retry 5 4 sudo DEBIAN_FRONTEND=noninteractive apt-get \
-o Acquire::Retries=5 \
-o Acquire::http::Timeout=30 \
-o Acquire::https::Timeout=30 \
install -y --no-install-recommends "$@"
}
curl_download() {
local output="$1" url="$2"
retry 5 3 curl -fL \
--retry 5 --retry-delay 2 --retry-all-errors \
--connect-timeout 20 --max-time 900 \
-o "$output" "$url"
}
# Replace only the block owned by this bootstrap; preserve user content elsewhere.
update_managed_block() {
local file="$1" begin="$2" end="$3" content="$4"
local tmp
tmp="$(mktemp)"
touch "$file"
awk -v begin="$begin" -v end="$end" '
$0 == begin { skip=1; next }
$0 == end { skip=0; next }
!skip { print }
' "$file" > "$tmp"
# Trim excessive trailing blank lines before appending our block.
sed -i -e :a -e '/^\n*$/{$d;N;ba}' "$tmp" 2>/dev/null || true
cat "$tmp" > "$file"
{
printf '\n%s\n' "$begin"
printf '%s\n' "$content"
printf '%s\n' "$end"
} >> "$file"
rm -f "$tmp"
}
github_api() {
local url="$1"
local -a args=(-fsSL --retry 3 --retry-delay 2 -H 'Accept: application/vnd.github+json')
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
args+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
elif has gh && gh auth status >/dev/null 2>&1; then
local token
token="$(gh auth token 2>/dev/null || true)"
[[ -n "$token" ]] && args+=(-H "Authorization: Bearer ${token}")
fi
curl "${args[@]}" "$url"
}
install_github_asset() {
local repo="$1" regex="$2" output="$3"
local api asset_url
api="$(github_api "https://api.github.com/repos/${repo}/releases/latest")"
asset_url="$(jq -r --arg re "$regex" '.assets[] | select(.name | test($re)) | .browser_download_url' <<<"$api" | head -n1)"
[[ -n "$asset_url" && "$asset_url" != null ]] || return 1
curl_download "$output" "$asset_url"
}
phase_system() {
log "[system] 更新软件索引并安装基础依赖"
apt_get update
if [[ "$UPGRADE_SYSTEM" == 1 ]]; then
retry 3 4 sudo DEBIAN_FRONTEND=noninteractive apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 upgrade -y
fi
apt_install \
git git-lfs git-extras curl wget ca-certificates gnupg \
build-essential pkg-config libssl-dev \
zip unzip xz-utils bzip2 tar \
btop tmux zsh vim \
net-tools iproute2 dnsutils \
jq tree procps make \
direnv rsync shellcheck ffmpeg \
locales fonts-noto-cjk fonts-wqy-microhei
# Ubuntu-only language pack; Debian doesn't ship this package.
if apt-cache show language-pack-zh-hans >/dev/null 2>&1; then
apt_install language-pack-zh-hans
fi
git lfs install --skip-repo >/dev/null 2>&1 || true
if ! has yq; then
log "[system] 安装 yq (${ARCH})"
curl_download "$TMP_DIR/yq" \
"https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${ARCH}"
sudo install -m 0755 "$TMP_DIR/yq" /usr/local/bin/yq
fi
if ! has fastfetch; then
log "[system] 安装 fastfetch 官方 Release (${FASTFETCH_ARCH})"
if install_github_asset fastfetch-cli/fastfetch \
"^fastfetch-linux-${FASTFETCH_ARCH}\\.deb$" "$TMP_DIR/fastfetch.deb"; then
sudo dpkg -i "$TMP_DIR/fastfetch.deb" || apt_get -f install -y
else
warn "找不到 fastfetch 对应架构的官方 .deb,跳过。"
fi
fi
# GitHub CLI official apt repository. dpkg architecture handles amd64/arm64.
if ! has gh; then
log "[system] 安装 GitHub CLI"
sudo install -d -m 0755 /etc/apt/keyrings
curl_download "$TMP_DIR/githubcli.gpg" \
https://cli.github.com/packages/githubcli-archive-keyring.gpg
sudo install -m 0644 "$TMP_DIR/githubcli.gpg" /etc/apt/keyrings/githubcli-archive-keyring.gpg
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\n' \
"$(dpkg --print-architecture)" | sudo tee /etc/apt/sources.list.d/github-cli.list >/dev/null
apt_get update
apt_install gh
fi
}
phase_desktop() {
log "[desktop] 配置桌面 Linux 中文显示与 Fcitx5 输入法"
if is_wsl; then
info "WSL 环境:中文输入由 Windows 输入法负责,跳过 Linux 输入法安装。"
return 0
fi
if ! should_install_desktop_i18n; then
info "未检测到桌面环境,或已按配置跳过 desktop phase。"
return 0
fi
# Keep English as the system/UI language for easier diagnostics, while
# ensuring both English and Simplified Chinese UTF-8 locales exist.
apt_install_if_available locales language-pack-en language-pack-zh-hans \
fonts-noto-cjk fonts-wqy-microhei
if [[ -f /etc/locale.gen ]]; then
sudo sed -i -E \
-e 's/^# *en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' \
-e 's/^# *zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' \
/etc/locale.gen
sudo locale-gen en_US.UTF-8 zh_CN.UTF-8
else
sudo locale-gen en_US.UTF-8 zh_CN.UTF-8 || true
fi
# Do not force the desktop UI into Chinese. If LANG is not already set
# persistently, retain the developer-friendly English UTF-8 default.
if [[ -z "$(grep -E '^LANG=' /etc/default/locale 2>/dev/null || true)" ]]; then
sudo update-locale LANG=en_US.UTF-8
fi
apt_install_if_available \
fcitx5 fcitx5-chinese-addons fcitx5-config-qt \
fcitx5-frontend-gtk3 fcitx5-frontend-gtk4 \
fcitx5-frontend-qt5 fcitx5-frontend-qt6 \
im-config
# Debian/Ubuntu's supported selector. This writes ~/.xinputrc and is
# preferable to blindly appending GTK/Qt environment variables to shell rc.
if has im-config; then
im-config -n fcitx5 || warn "im-config 无法自动选择 fcitx5,可登录桌面后运行: im-config"
fi
# Provide a desktop autostart fallback. Existing user customizations are
# not overwritten; Fcitx5 itself safely ignores duplicate start attempts.
if has fcitx5; then
mkdir -p "$HOME/.config/autostart"
cat > "$HOME/.config/autostart/org.fcitx.Fcitx5.desktop" <<'EOF_FCITX'
[Desktop Entry]
Type=Application
Name=Fcitx 5
Comment=Start Fcitx 5 input method
Exec=fcitx5 -d
Terminal=false
X-GNOME-Autostart-enabled=true
NoDisplay=true
EOF_FCITX
fi
info "已安装 Fcitx5 与中文附加组件。"
info "注销并重新登录桌面后生效;首次使用若没有“拼音”,请打开 fcitx5-config-qt 添加 Pinyin。"
}
phase_rust() {
log "[rust] 安装 Rust 与现代 CLI"
if ! has rustup; then
retry 5 3 curl --proto '=https' --tlsv1.2 -fL \
--retry 5 --retry-delay 2 --retry-all-errors \
--connect-timeout 20 --max-time 300 \
https://sh.rustup.rs -o "$TMP_DIR/rustup-init.sh"
sh "$TMP_DIR/rustup-init.sh" -y --no-modify-path
fi
# shellcheck disable=SC1091
[[ -f "$HOME/.cargo/env" ]] && source "$HOME/.cargo/env"
export PATH="$HOME/.cargo/bin:$PATH"
# Bootstrap should not fail just because rustup's own self-update endpoint
# has a transient TLS/CDN problem. Upgrade rustup explicitly when desired.
rustup set auto-self-update disable >/dev/null 2>&1 || true
if ! rustup toolchain list 2>/dev/null | grep -q '^stable-'; then
retry 5 5 rustup toolchain install stable --profile default
else
info "stable Rust toolchain 已存在,跳过重复下载。"
fi
retry 3 3 rustup default stable
if ! has cargo-binstall; then
curl_download "$TMP_DIR/install-cargo-binstall.sh" \
https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh
bash "$TMP_DIR/install-cargo-binstall.sh"
fi
local tools=(
eza bat fd-find ripgrep du-dust procs zoxide hyperfine
cargo-watch cargo-nextest cargo-update cargo-audit
starship bob-nvim topgrade tealdeer zellij
)
local tool
for tool in "${tools[@]}"; do
if cargo install --list 2>/dev/null | grep -qE "^${tool//-/[-_]} v"; then
info "Rust 工具已存在: $tool"
else
optional "cargo binstall $tool" retry 3 5 cargo binstall -y "$tool"
fi
done
if ! has atuin; then
# Preserve the original preference: local compile avoids prebuilt glibc surprises.
optional "cargo install atuin" retry 3 8 cargo install --locked atuin
fi
apt_get remove -y tldr >/dev/null 2>&1 || true
}
phase_agent_tools() {
log "[agent-tools] 安装 Agent 常用文档 / 数据 / 媒体处理工具"
# P0/P1/P2 agent toolbox. apt_install_if_available keeps the bootstrap
# portable across supported Debian/Ubuntu versions when a package name is
# temporarily absent from a specific repository.
apt_install_if_available \
file p7zip-full zstd \
poppler-utils pandoc imagemagick ghostscript libimage-exiftool-perl ffmpeg \
sqlite3 miller csvkit xmlstarlet shfmt
# jq/yq are installed in phase_system because other bootstrap helpers also
# depend on them (GitHub release parsing, YAML manipulation).
# DuckDB publishes native CLI archives for both Linux amd64 and arm64.
if ! has duckdb; then
log "[agent-tools] 安装 DuckDB CLI (${ARCH})"
if install_github_asset duckdb/duckdb \
"^duckdb_cli-linux-${ARCH}\\.zip$" "$TMP_DIR/duckdb.zip"; then
rm -rf "$TMP_DIR/duckdb-extract"
mkdir -p "$TMP_DIR/duckdb-extract"
unzip -q "$TMP_DIR/duckdb.zip" -d "$TMP_DIR/duckdb-extract"
[[ -x "$TMP_DIR/duckdb-extract/duckdb" ]] || chmod +x "$TMP_DIR/duckdb-extract/duckdb" 2>/dev/null || true
sudo install -m 0755 "$TMP_DIR/duckdb-extract/duckdb" /usr/local/bin/duckdb
else
die "未找到 DuckDB Linux ${ARCH} CLI Release。"
fi
fi
# htmlq is tiny, but on ARM64 we deliberately compile locally rather than
# relying on a third-party prebuilt binary with an unknown glibc baseline.
if ! has htmlq; then
has cargo || die "htmlq 需要 Cargo;请先运行: $SCRIPT_NAME rust"
retry 3 8 cargo install --locked htmlq
fi
}
phase_neovim() {
log "[neovim] 通过 Bob 安装 stable Neovim"
export PATH="$HOME/.cargo/bin:$HOME/.local/share/bob/nvim-bin:$PATH"
has bob || die "bob 未安装;请先运行: $SCRIPT_NAME rust"
bob install stable
bob use stable
if [[ "$INSTALL_LAZYVIM" == 1 ]]; then
if [[ ! -e "$HOME/.config/nvim/lua/config/lazy.lua" && ! -e "$HOME/.config/nvim/lua/config/options.lua" ]]; then
if [[ -e "$HOME/.config/nvim" ]]; then
local backup="$HOME/.config/nvim.pre-bootstrap.$(date +%Y%m%d-%H%M%S)"
warn "检测到已有 ~/.config/nvim,备份为: $backup"
mv "$HOME/.config/nvim" "$backup"
fi
mkdir -p "$HOME/.config"
retry 3 2 git clone --depth=1 https://github.com/LazyVim/starter "$HOME/.config/nvim"
rm -rf "$HOME/.config/nvim/.git"
else
info "检测到已有 Neovim/LazyVim 配置,不覆盖。"
fi
fi
}
phase_node() {
log "[node] 安装 NVM / Node LTS / pnpm / AI CLI"
export NVM_DIR="$HOME/.nvm"
if [[ ! -s "$NVM_DIR/nvm.sh" ]]; then
# Clone instead of letting the nvm installer mutate shell rc files.
if [[ -e "$NVM_DIR" ]]; then
local nvm_backup="${NVM_DIR}.pre-bootstrap.$(date +%Y%m%d-%H%M%S)"
warn "检测到不完整的 $NVM_DIR,移动到: $nvm_backup"
mv "$NVM_DIR" "$nvm_backup"
fi
retry 3 2 git clone --depth=1 https://github.com/nvm-sh/nvm.git "$NVM_DIR"
(
cd "$NVM_DIR"
git fetch --tags --quiet
local latest
latest="$(git tag --sort=-v:refname | head -n1)"
[[ -n "$latest" ]] && git checkout --quiet "$latest"
)
fi
# shellcheck disable=SC1090
. "$NVM_DIR/nvm.sh"
nvm install --lts
nvm alias default 'lts/*'
nvm use default
export PNPM_HOME="$HOME/.local/share/pnpm"
export PNPM_GLOBAL_BIN_DIR="$PNPM_HOME/bin"
mkdir -p "$PNPM_HOME" "$PNPM_GLOBAL_BIN_DIR"
# pnpm may persist a global-bin-dir from a previous install. Put both the
# normal PNPM_HOME and the configured global bin directory in PATH now so
# the very first `pnpm add -g` works in this non-interactive shell.
export PATH="$PNPM_GLOBAL_BIN_DIR:$PNPM_HOME:$PATH"
# Corepack is shipped with supported Node releases and avoids another curl|sh installer.
if has corepack; then
corepack enable
corepack prepare pnpm@latest --activate
elif ! has pnpm; then
retry 5 3 npm install -g pnpm
fi
# Own pnpm's global bin location instead of inheriting an old per-machine
# value that may not be present in PATH on a fresh bootstrap run.
pnpm config set global-bin-dir "$PNPM_GLOBAL_BIN_DIR"
retry 5 3 pnpm add -g typescript tsx eslint prettier
if [[ "$INSTALL_AI_CLI" == 1 ]]; then
# OpenAI's official Codex CLI install command currently uses npm.
# Keep it independent from pnpm so upgrades follow OpenAI's own path.
if ! has codex; then
retry 5 3 npm install -g @openai/codex
else
info "Codex CLI 已存在,跳过安装。"
fi
# Anthropic recommends the native installer on Linux/WSL. Download it
# first so our curl retry/timeout policy still applies, then execute it.
if ! has claude; then
curl_download "$TMP_DIR/claude-install.sh" https://claude.ai/install.sh
retry 5 3 bash "$TMP_DIR/claude-install.sh"
else
info "Claude Code 已存在,跳过安装。"
fi
fi
}
phase_python() {
log "[python] 安装 uv 与 Python CLI"
export PATH="$HOME/.local/bin:$PATH"
if ! has uv; then
curl_download "$TMP_DIR/uv-install.sh" https://astral.sh/uv/install.sh
sh "$TMP_DIR/uv-install.sh"
fi
local tool
for tool in ruff mypy black poetry httpie; do
if uv tool list 2>/dev/null | grep -qE "^${tool} v"; then
info "uv tool 已存在: $tool"
else
optional "uv tool install $tool" uv tool install "$tool"
fi
done
if [[ "$INSTALL_MINICONDA" == 1 ]]; then
local conda_dir="$HOME/miniconda3"
if [[ ! -x "$conda_dir/bin/conda" ]]; then
log "[python] 安装 Miniconda (${MINICONDA_ARCH})"
curl_download "$TMP_DIR/miniconda.sh" \
"https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-${MINICONDA_ARCH}.sh"
bash "$TMP_DIR/miniconda.sh" -b -p "$conda_dir"
fi
"$conda_dir/bin/conda" config --set auto_activate_base false
# Do NOT run conda init; shell config is owned by this bootstrap.
fi
}
phase_git() {
log "[git] 安装 lazygit"
if ! has lazygit; then
if install_github_asset jesseduffield/lazygit \
"^lazygit_[0-9.]+_linux_${GH_ARCH}\\.tar\\.gz$" "$TMP_DIR/lazygit.tar.gz"; then
tar -xzf "$TMP_DIR/lazygit.tar.gz" -C "$TMP_DIR" lazygit
sudo install -m 0755 "$TMP_DIR/lazygit" /usr/local/bin/lazygit
else
die "未找到 lazygit 的 Linux ${GH_ARCH} Release。"
fi
fi
}
phase_shell() {
log "[shell] 配置 Oh My Zsh / fzf / Starship / aliases"
if [[ ! -d "$HOME/.oh-my-zsh/.git" ]]; then
retry 3 2 git clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git "$HOME/.oh-my-zsh"
fi
if [[ ! -d "$HOME/.fzf/.git" ]]; then
retry 3 2 git clone --depth=1 https://github.com/junegunn/fzf.git "$HOME/.fzf"
fi
local shell_block
shell_block=$(cat <<'EOS'
# PATHs
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.local/share/bob/nvim-bin:$PATH"
# Rust
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
# NVM
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
# pnpm
export PNPM_HOME="$HOME/.local/share/pnpm"
export PNPM_GLOBAL_BIN_DIR="$PNPM_HOME/bin"
case ":$PATH:" in
*":$PNPM_HOME:"*) ;;
*) export PATH="$PNPM_HOME:$PATH" ;;
esac
case ":$PATH:" in
*":$PNPM_GLOBAL_BIN_DIR:"*) ;;
*) export PATH="$PNPM_GLOBAL_BIN_DIR:$PATH" ;;
esac
# Conda: available on demand; base does not auto-activate.
[ -f "$HOME/miniconda3/etc/profile.d/conda.sh" ] && . "$HOME/miniconda3/etc/profile.d/conda.sh"
# Oh My Zsh
export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME=""
plugins=(git git-extras debian tmux screen history extract colorize web-search docker sudo)
[ -s "$ZSH/oh-my-zsh.sh" ] && source "$ZSH/oh-my-zsh.sh"
# fzf (without installer-driven rc mutation)
[ -f "$HOME/.fzf/shell/key-bindings.zsh" ] && source "$HOME/.fzf/shell/key-bindings.zsh"
[ -f "$HOME/.fzf/shell/completion.zsh" ] && source "$HOME/.fzf/shell/completion.zsh"
# Interactive tools
command -v atuin >/dev/null 2>&1 && eval "$(atuin init zsh)"
command -v zoxide >/dev/null 2>&1 && eval "$(zoxide init zsh)"
command -v starship >/dev/null 2>&1 && eval "$(starship init zsh)"
# Aliases
command -v bat >/dev/null 2>&1 && alias cat='bat'
command -v eza >/dev/null 2>&1 && alias ls='eza --icons'
command -v eza >/dev/null 2>&1 && alias ll='eza -lah --icons --git'
command -v eza >/dev/null 2>&1 && alias lt='eza -lah --tree --level=2 --icons'
command -v nvim >/dev/null 2>&1 && alias vi='nvim'
command -v nvim >/dev/null 2>&1 && alias vim='nvim'
command -v zellij >/dev/null 2>&1 && alias zj='zellij'
command -v ranger >/dev/null 2>&1 && alias ra='ranger'
EOS
)
update_managed_block \
"$HOME/.zshrc" \
'# >>> bootstrap-dev managed >>>' \
'# <<< bootstrap-dev managed <<<' \
"$shell_block"
local bash_block
bash_block=$(cat <<'EOS'
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.local/share/bob/nvim-bin:$PATH"
[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
export PNPM_HOME="$HOME/.local/share/pnpm"
case ":$PATH:" in
*":$PNPM_HOME:"*) ;;
*) export PATH="$PNPM_HOME:$PATH" ;;
esac
[ -f "$HOME/miniconda3/etc/profile.d/conda.sh" ] && . "$HOME/miniconda3/etc/profile.d/conda.sh"
EOS
)
update_managed_block \
"$HOME/.bashrc" \
'# >>> bootstrap-dev managed >>>' \
'# <<< bootstrap-dev managed <<<' \
"$bash_block"
# Change login shell only when necessary.
local zsh_path
zsh_path="$(command -v zsh)"
if [[ "${SHELL:-}" != "$zsh_path" ]]; then
chsh -s "$zsh_path" || warn "chsh 失败;可稍后手动执行: chsh -s $zsh_path"
fi
}
phase_tmux() {
log "[tmux] 安装 oh-my-tmux"
local repo="$HOME/.tmux"
if [[ ! -d "$repo/.git" ]]; then
retry 3 2 git clone --depth=1 https://github.com/gpakosz/.tmux.git "$repo"
else
git -C "$repo" pull --ff-only || warn "oh-my-tmux 更新失败,保留现有版本。"
fi
if [[ ! -e "$HOME/.tmux.conf" || -L "$HOME/.tmux.conf" ]]; then
ln -sfn "$repo/.tmux.conf" "$HOME/.tmux.conf"
elif ! cmp -s "$HOME/.tmux.conf" "$repo/.tmux.conf"; then
warn "~/.tmux.conf 是已有普通文件,不自动覆盖。"
fi
if [[ ! -f "$HOME/.tmux.conf.local" ]]; then
cp "$repo/.tmux.conf.local" "$HOME/.tmux.conf.local"
fi
}
phase_docker() {
[[ "$INSTALL_DOCKER" == 1 ]] || { info "[docker] 已按配置跳过。"; return; }
log "[docker] 安装 Docker Engine 官方 apt 仓库"
if ! has docker; then
sudo install -m 0755 -d /etc/apt/keyrings
curl_download "$TMP_DIR/docker.asc" \
"https://download.docker.com/linux/${ID}/gpg"
sudo install -m 0644 "$TMP_DIR/docker.asc" /etc/apt/keyrings/docker.asc
# shellcheck disable=SC1091
. /etc/os-release
local codename="${VERSION_CODENAME:-}"
[[ -n "$codename" ]] || die "无法获取 VERSION_CODENAME,不能配置 Docker 仓库。"
printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/%s %s stable\n' \
"$(dpkg --print-architecture)" "$ID" "$codename" \
| sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
apt_get update
apt_install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
fi
if getent group docker >/dev/null 2>&1; then
if ! id -nG "$USER" | grep -qw docker; then
sudo usermod -aG docker "$USER"
warn "已将 $USER 加入 docker 组;重新登录后生效。"
fi
fi
if is_wsl; then
info "WSL 环境:Docker 服务是否由 systemd 启动取决于你的 WSL systemd 配置。"
fi
}
phase_verify() {
log "[verify] 验证关键工具"
local required=(
git curl zsh tmux jq yq cargo rustc nvim node pnpm uv gh lazygit starship
file pdftotext pdfinfo pandoc ffmpeg ffprobe exiftool sqlite3 duckdb mlr csvcut xmlstarlet shfmt htmlq
)
local optional_tools=(atuin zoxide eza bat fd rg dust procs hyperfine zellij fastfetch docker codex claude magick convert gs 7z zstd)
local failed=0 cmd
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.local/share/bob/nvim-bin:$HOME/.local/share/pnpm/bin:$HOME/.local/share/pnpm:$PATH"
export NVM_DIR="$HOME/.nvm"
# shellcheck disable=SC1090
[[ -s "$NVM_DIR/nvm.sh" ]] && . "$NVM_DIR/nvm.sh"
for cmd in "${required[@]}"; do
if has "$cmd"; then
printf ' [OK] %-12s %s\n' "$cmd" "$(command -v "$cmd")"
else
printf ' [MISS] %-12s\n' "$cmd"
failed=1
fi
done
for cmd in "${optional_tools[@]}"; do
has "$cmd" && printf ' [OK?] %-12s %s\n' "$cmd" "$(command -v "$cmd")"
done
if ! is_wsl && has_graphical_desktop; then
if has fcitx5; then
printf ' [OK?] %-12s %s\n' "fcitx5" "$(command -v fcitx5)"
else
printf ' [INFO] %-12s %s\n' "fcitx5" "not installed"
fi
locale -a 2>/dev/null | grep -qi '^zh_CN\.utf\|^zh_CN\.UTF' \
&& printf ' [OK?] %-12s %s\n' "zh_CN.UTF-8" "locale available" \
|| printf ' [INFO] %-12s %s\n' "zh_CN.UTF-8" "locale missing"
fi
if (( failed )); then
warn "有必需工具未通过验证,请查看上方日志。"
return 1
fi
log "验证完成。"
}
usage() {
cat <<EOF_USAGE
Usage: $SCRIPT_NAME [phase ...]
Phases:
system Base packages, yq, fastfetch, gh
desktop Native GUI: Chinese fonts/locales + Fcitx5 (WSL skips)
rust Rust + modern CLI tools
agent-tools PDF/JSON/data/media utilities for coding agents
neovim Bob + Neovim + optional LazyVim
node NVM + Node LTS + pnpm + AI CLIs
python uv + Python tools + optional Miniconda
git lazygit
shell managed .zshrc/.bashrc + Oh My Zsh + fzf
tmux oh-my-tmux
docker Docker Engine
verify final verification
all all phases (default)
EOF_USAGE
}
run_phase() {
case "$1" in
system) phase_system ;;
desktop) phase_desktop ;;
rust) phase_rust ;;
agent-tools) phase_agent_tools ;;
neovim) phase_neovim ;;
node) phase_node ;;
python) phase_python ;;
git) phase_git ;;
shell) phase_shell ;;
tmux) phase_tmux ;;
docker) phase_docker ;;
verify) phase_verify ;;
*) die "未知 phase: $1" ;;
esac
}
main() {
require_normal_user
require_supported_os
detect_arch
setup_sudo
TMP_DIR="$(mktemp -d)"
setup_proxy
info "OS: ${PRETTY_NAME:-unknown}"
info "Arch: $UNAME_ARCH -> $ARCH"
if is_wsl; then
info "Runtime: WSL"
else
info "Runtime: native Linux"
fi
if (($# == 0)) || [[ "${1:-}" == all ]]; then
local phases=(system desktop rust agent-tools neovim node python git shell tmux docker verify)
local phase
for phase in "${phases[@]}"; do
run_phase "$phase"
done
else
local phase
for phase in "$@"; do
case "$phase" in
-h|--help|help) usage; return 0 ;;
esac
run_phase "$phase"
done
fi
printf '\n'
log "Bootstrap 完成。"
info "建议重新登录 shell,或执行: exec zsh -l"
}
main "$@"

5.1 关于yt-dlp#

Terminal window
# 建议使用default安装
# 直接`uv tool install yt-dlp --with curl_cffi --with pycryptodomex --upgrade --force`会导致curl_cffi变成最新版
# 但yt-dlp可能不支持这么高版本的curl_cffi,而default的extra会自动补齐requests、urllib3、websockets、mutagen、brotli、pycryptodomex
uv tool install 'yt-dlp[default,curl-cffi]' --upgrade --force
# 记得装ffmpeg和nvm node,配置node变量
mkdir -p ~/.config/yt-dlp && cat <<EOF >> ~/.config/yt-dlp/config
--js-runtimes node
--remote-components ejs:github
EOF

arm设备可能会遇到curl_cffi安装了也没有伪装的问题,转回x86平台吧,暂时无解,像这样:

Terminal window
yt-dlp --list-impersonate-targets
[info] Available impersonate targets
Client OS Source
--------------------------------------------
Tor - curl_cffi>=0.11 (unavailable)
Edge - curl_cffi (unavailable)
Firefox - curl_cffi>=0.10 (unavailable)
Safari - curl_cffi (unavailable)
Chrome - curl_cffi (unavailable)

5.2 关于恢复备份的.ssh文件夹#

拷过去权限得改对

Terminal window
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_*
chmod 644 ~/.ssh/*.pub
# 如果有
chmod 600 ~/.ssh/config

5.3 关于nomachine#

安装时如果遇到60%卡住终端没响应,关掉重开一个终端修复一下安装即可,其实是已经装好,之是post install脚本可能有问题挂起了:

Terminal window
sudo killall apt apt-get dpkg
sudo dpkg --configure -a

arm上有时会遇到wayland桌面环境nomachine连上是黑底各种乱七八糟的竖条纹的情况,改回x11就会好:

Terminal window
sudo vim /etc/gdm3/custom.conf
WaylandEnable=false

重启gdm3,或直接重启系统更稳妥。

5.4 关于中文环境,上面装了字体和中文编码,需要配置语言环境:#

Terminal window
sudo dpkg-reconfigure locales

zh_CN.UTF-8 UTF-8即可,echo $LANG还不行的话:

Terminal window
echo "export LANGUAGE=zh_CN.zh" >> ~/.bashrc
echo "export LC_ALL=zh_CN.UTF-8" >> ~/.bashrc
echo "export LANG=zh_CN.UTF-8" >> ~/.bashrc
echo "export LANGUAGE=zh_CN.zh" >> ~/.zshrc
echo "export LC_ALL=zh_CN.UTF-8" >> ~/.zshrc
echo "export LANG=zh_CN.UTF-8" >> ~/.zshrc

关于输入法,就用 Fcitx5 默认,省事:

Terminal window
# 安装 Fcitx5 核心、中文拼音插件及图形化配置工具
sudo apt update
sudo apt install fcitx5 fcitx5-chinese-addons fcitx5-frontend-gtk3 fcitx5-frontend-gtk2 fcitx5-frontend-qt5 kde-config-fcitx5 fcitx5-config-qt
# 将环境变量写入 ~/.xprofile 或 /etc/profile
echo 'export GTK_IM_MODULE=fcitx' >> ~/.xprofile
echo 'export QT_IM_MODULE=fcitx' >> ~/.xprofile
echo 'export XMODIFIERS=@im=fcitx' >> ~/.xprofile

运行im-config,选择fcitx5,重启,右上角出现 Fcitx5 的小键盘图标,右键点击选择 配置 (Configure),在右侧列表中找到 拼音 (Pinyin) 并添加到左侧。

现在使用topgrade自动感知并更新安装工具。

5.5 关于starship#

配置文件可以改好看点:

Terminal window
cat > ~/.config/starship.toml << 'EOF'
# ==========================
# 1. 机器身份识别 (防错核心)
# ==========================
[hostname]
ssh_only = false # 即使是本地终端也显示主机名
format = '在 [$hostname]($style) '
style = "bold purple" # 建议:不同机器改成不同颜色,比如测试板用红色,生产环境用紫色
# IP 显示 (仅限 SSH)
[localip]
ssh_only = true # 性能优化:只有在你通过 SSH 连入时才去查 IP,本地直连时不查
disabled = false
format = '🌐 [$localipv4]($style) '
style = "bold blue"
# ==========================
# 2. 资源报警 (阈值触发,零延迟感知)
# ==========================
# 内存使用率
[memory_usage]
disabled = false
threshold = 80 # 性能优化:内存占用没超过 80% 时完全不渲染此模块,节省算力
format = '🐏 [$ram]($style) '
style = "bold red"
# ==========================
# 3. 运维排障辅助
# ==========================
# 命令执行耗时 (仅当命令执行超过 2 秒时显示)
[cmd_duration]
min_time = 2000
format = '耗时 [$duration]($style) '
style = "bold yellow"
# 退出码状态 (命令报错时变红)
[status]
disabled = false
format = '[$symbol$status]($style) '
success_symbol = "🟢 "
symbol = "🔴 "
style = "bold red"
EOF

6 tailscale 与 ipv6#

节后移动给的GPON坏了,运营商换了一个后发现有ipv6了。家里的rock5b、PVE工作站都可以用ipv6直连了。

PVE装tailscale#

需要注意的是,PVE默认没打开ipv6支持,所以需要手动设置:

Terminal window
vi /etc/sysctl.conf
net.ipv6.conf.all.disable_ipv6 = 0
net.ipv6.conf.default.disable_ipv6 = 0
net.ipv6.conf.lo.disable_ipv6 = 0
net.ipv6.conf.all.forwarding = 1
vi /etc/sysctl.d/99-tailscale.conf
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
# 或:
echo 'net.ipv4.ip_forward = 1' | tee -a /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | tee -a /etc/sysctl.d/99-tailscale.conf
# 应用网络配置
ifreload -a

此时用ip -6 addr show会发现vmbr0上多出来一个2409(移动)开头的地址,再用手机等公网设备打开tailscale去ping这台PVE就会从relay转到direct了。

Terminal window
5: vmbr0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 1000
inet6 2409:xxxx:xxx:xxxx:xxxx:xxxx:xxxx:xxxx/64 scope global dynamic mngtmpaddr proto kernel_ra
valid_lft 86181sec preferred_lft 14181sec

全家搬到tailscale#

通过rock5b转发流量,可以把家里的192.168.1.x/24都放在tailscale上。在rock5b上配置系统转发:

Terminal window
echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.conf
sudo sysctl -p
sudo tailscale up --advertise-routes=192.168.1.0/24 --accept-dns=false

出于安全考虑,被“广播”的网段 Tailscale 默认不信任,需要手动确认:

  1. 登录 Tailscale Admin Console
  2. Rock 5B 点击右侧的 三个点 (…)
  3. 选择 Edit route settings
  4. 在弹出的窗口中,勾选广播的网段 192.168.1.0/24
  5. 点击 Save

此时,手机用运营商的网,打开tailscale,也可以使用192.168.1.0/24的IP控制家里的设备了。

6.1 tailscale可能存在的坑1 - 外带设备o6n回家后只能单向ping#

6.1.1 现象#

在家里的局域网 (192.168.1.0/24) 有:

  • 开发板 A (o6n): 192.168.1.140
  • 开发板 B (Rock5B): 192.168.1.120
  • Windows 11 (含 WSL2): 192.168.1.125

症状: 开发板 A (140) 可以正常 Ping 通局域网内的 B (120) 和 Windows (125),但是 B 和 Windows 无法 Ping 通 A。呈现典型的 “单向失联” 状态。

6.1.2 排查#

遇到单向不通,通常的排查链路是:物理连接 -> 防火墙 -> ARP 解析 -> 路由表。

  1. 排除防火墙与物理隔离: 检查了 ufwiptables,未发现拦截规则。
  2. 检查 ARP 缓存 (ip neigh show): 开发板 A 上能成功获取到 B 和 Windows 的 MAC 地址,说明物理层和数据链路层通信正常。
  3. 排查代理软件: 怀疑是 Windows 端的 Clash TUN 模式劫持了局域网流量。关闭 TUN 模式并使用 ping -S 192.168.1.125 192.168.1.140 强制指定源 IP,依然不通,排除发件端代理问题。
  4. 定位核心问题——策略路由 (ip rule): 在开发板 A (140) 上执行 ip rule 发现异常:
    Terminal window
    5270: from all lookup 52
    32766: from all lookup main
    执行 ip route show table 52 发现:
    Terminal window
    192.168.1.0/24 dev tailscale0

6.1.3 原因#

网络中存在另一台设备——开发板 B (Rock5B, 192.168.1.120) 通过 Tailscale 执行了 --advertise-routes=192.168.1.0/24,广播了该局域网的 Subnet Route(子网路由),而开发板 A (140) 默认接受了该路由。

Tailscale 会接管系统的策略路由,创建一个高优先级(5270)的路由表 52。 当开发板 A (140) 收到局域网(比如来自 Windows 或 Rock5B 本身)发来的 Ping 请求时,它需要发送 ICMP Echo Reply(回包)。在查找路由时,它优先命中了表 52 的规则,导致本该通过物理网卡(wlan0/eth0)发回本地局域网的数据包,被错误地塞进了 Tailscale 的虚拟隧道 (tailscale0) 中,造成了“有去无回”的路由黑洞。

6.1.4 解决#

由于既需要在家时局域网直连,又需要带开发板外出时能通过 Tailscale 访问家里的内网,直接关闭 accept-routes 并不符合需求。

最优解:基于源 IP 的智能分流 (Smart Routing) 在开发板 A (140) 上添加一条比 Tailscale 优先级更高(例如 2500)的策略路由规则:

Terminal window
sudo ip rule add from 192.168.1.140 to 192.168.1.0/24 priority 2500 lookup main

原理解析:

  • 在家时: 开发板 A 分配到的物理 IP 是 192.168.1.140。当局域网设备 Ping 它时,它使用该 IP 作为源 IP 回包,精确命中此规则,流量直接走 main 表(物理网卡),瞬间连通。
  • 外出时: 开发板 A 连接其他网络,物理 IP 改变(不再是 192.168.1.140)。该规则失效,系统继续往下匹配,命中 Tailscale 的表 52,从而实现正常的异地组网访问。

避坑 尽量避免将家中的主路由网段设置为 192.168.1.0/24192.168.0.0/24 这种极其常见的默认网段。如果携带开启了异地组网的设备去酒店或单位,一旦公共 Wi-Fi 也是这个网段,就会引发严重的子网冲突(Overlapping Subnets)。建议改成类似 10.88.99.0/24 的冷门私有网段。

6.1.5 进阶固化:使用 systemd 实现路由规则开机自启#

为了在 Armbian/Debian 12 等现代 Linux 环境下一劳永逸地解决此问题,最稳妥的方案是创建一个标准的 systemd 服务,确保在网络和 Tailscale 均就绪后,自动注入我们的高优先级本地路由规则。

Step 1: 编写路由修复脚本#

创建一个专门的 Shell 脚本,采用“先清理后添加”的逻辑,防止因网络服务重启导致规则重复堆叠。

Terminal window
sudo vim /usr/local/bin/fix-lan-route.sh

内容为:

#!/bin/bash
# 忽略报错删除可能存在的旧规则,避免重复
ip rule del from 192.168.1.140 to 192.168.1.0/24 priority 2500 lookup main 2>/dev/null
# 添加本地局域网高优先级规则
ip rule add from 192.168.1.140 to 192.168.1.0/24 priority 2500 lookup main

赋予执行权限:

Terminal window
sudo chmod +x /usr/local/bin/fix-lan-route.sh

Step 2: 编写 systemd 服务文件#

配置服务依赖,确保脚本在网络连通 (network-online.target) 且 Tailscale 服务启动 (tailscaled.service) 之后再执行。

Terminal window
sudo vim /etc/systemd/system/fix-lan-route.service

内容为:

[Unit]
Description=Fix LAN routing conflict with Tailscale
After=network-online.target tailscaled.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/fix-lan-route.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target

Step 3: 激活服务并验证#

重载 systemd 守护进程,并将该服务设为开机自启:

Terminal window
# 重载系统配置
sudo systemctl daemon-reload
# 设置为开机自启
sudo systemctl enable fix-lan-route.service
# 立即启动该服务进行验证
sudo systemctl start fix-lan-route.service

最终验证: 在终端执行 ip rule 命令。如果列表中清晰地出现了 2500: from 192.168.1.140 to 192.168.1.0/24 lookup main,即代表配置大功告成。从此,无论设备重启还是随时启停 Tailscale,本地局域网的通信策略都将稳如泰山。

6.2 tailscale可能存在的坑2 - 子网发布网关rock5b开启TUN导致tailscale无法链接#

6.2.1 现象:#

Clash开TUN以后,tailscale启动报错。

  1. 开启TUN时的异常日志:

    Terminal window
    ➜  ~ sudo systemctl status tailscaled.service
    tailscaled.service - Tailscale node agent
         Loaded: loaded (/lib/systemd/system/tailscaled.service; enabled; vendor preset: enabled)
         Active: active (running) since Sat 2026-03-28 10:00:48 CST; 28s ago
           Docs: https://tailscale.com/docs/
       Main PID: 578694 (tailscaled)
          Tasks: 15 (limit: 18526)
         Memory: 54.6M
            CPU: 878ms
         CGroup: /system.slice/tailscaled.service
                 └─578694 /usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=41641
    Mar 28 10:00:48 rock-5b tailscaled[578694]: logpolicy: using $STATE_DIRECTORY, "/var/lib/tailscale"
    Mar 28 10:00:48 rock-5b tailscaled[578694]: linkChange: in state NoState; PAC or proxyConfig changed; updating routes
    Mar 28 10:00:48 rock-5b tailscaled[578694]: got LocalBackend in 56ms
    Mar 28 10:00:48 rock-5b tailscaled[578694]: Start
    Mar 28 10:00:48 rock-5b tailscaled[578694]: ipnext: active extensions: conn25, portlist, posture, clientupdate, relayserver, taildrop
    Mar 28 10:00:48 rock-5b tailscaled[578694]: Backend: logs: be:de27a4d50030bb2fa9eba85172884903b5e6894feb434ee6ece1a4b0f407b618 fe:
    Mar 28 10:00:48 rock-5b tailscaled[578694]: control: client.Login(0)
    Mar 28 10:00:48 rock-5b tailscaled[578694]: health(warnable=warming-up): error: Tailscale is starting. Please wait.
    Mar 28 10:00:48 rock-5b tailscaled[578694]: control: doLogin(regen=false, hasUrl=false)
    Mar 28 10:00:53 rock-5b tailscaled[578694]: health(warnable=warming-up): ok

    此时:

    Terminal window
    ➜  ~ tailscale status
    unexpected state: NoState
  2. 关闭tun后的正常日志:

    Terminal window
    ➜  ~ sudo systemctl status tailscaled.service
    tailscaled.service - Tailscale node agent
         Loaded: loaded (/lib/systemd/system/tailscaled.service; enabled; vendor preset: enabled)
         Active: active (running) since Sat 2026-03-28 10:01:28 CST; 3s ago
           Docs: https://tailscale.com/docs/
       Main PID: 579582 (tailscaled)
          Tasks: 15 (limit: 18526)
         Memory: 42.3M
            CPU: 708ms
         CGroup: /system.slice/tailscaled.service
                 └─579582 /usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=41641
    Mar 28 10:01:31 rock-5b tailscaled[579582]: monitor: RTM_DELROUTE: src=198.18.0.1/0, dst=198.18.0.1/32, gw=, outif=16, table=255
    Mar 28 10:01:31 rock-5b tailscaled[579582]: LinkChange: major, rebinding: old: interfaces.State{defaultRoute=enP4p65s0 ifs={Meta:[198.18.0.>
    Mar 28 10:01:31 rock-5b tailscaled[579582]: dns: Set: {DefaultResolvers:[] Routes:{} SearchDomains:[] Hosts:6}
    Mar 28 10:01:31 rock-5b tailscaled[579582]: dns: Resolvercfg: {Routes:{} Hosts:6 LocalDomains:[]}
    Mar 28 10:01:31 rock-5b tailscaled[579582]: dns: OScfg: {}
    Mar 28 10:01:31 rock-5b tailscaled[579582]: wgengine: set DNS config again after major link change
    Mar 28 10:01:31 rock-5b tailscaled[579582]: router: portUpdate(port=41641, network=udp6)
    Mar 28 10:01:31 rock-5b tailscaled[579582]: Rebind; defIf="enP4p65s0", ips=[192.168.1.120/24 2409:8a70:b61:b91::1005/128 2409:8a70:b61:b91:>
    Mar 28 10:01:31 rock-5b tailscaled[579582]: magicsock: 0 active derp conns
    Mar 28 10:01:31 rock-5b tailscaled[579582]: router: portUpdate(port=41641, network=udp4)

    此时:

    Terminal window
    ➜  ~ tailscale status
    100.xx.xx.xx    rock-5b              xxx@  linux    -
    100.xx.xx.xx     server            xxx@  linux    offline, last seen xxd ago
    100.xx.xx.xx phone1               xxx@  android  offline, last seen xxd ago
    100.xx.xx.xx   orion-o6             xxx@  linux    offline, last seen xxh ago
    100.xx.xx.xx   phone2   xxx@  android  offline, last seen xxh ago
    100.xx.xx.xx     macbook-pro   xxx@  macOS    offline, last seen xxd ago

6.2.2 原因#

简单来说,Clash 的 TUN 模式把 Tailscale 的建连流量给“劫持”了。

  1. Clash TUN 的霸道机制:当开启 Clash TUN 模式时,它会在系统里建一个虚拟网卡(比如 Mihomo),并通过修改系统路由表(下发 0.0.0.0/1 这种默认路由)或者用 fwmark(防火墙标记)把机器上所有的流量都强行吸进自己的核心里。同时,它通常还会开启 Fake-IP 来劫持 DNS 请求(日志里关闭 TUN 时出现的 198.18.0.1 就是 Clash 的 Fake-IP 网段)。
  2. Tailscale 的迷失tailscaled 服务启动时,需要向它自己的控制面板(login.tailscale.com)和分布在全球的 DERP 中继服务器发送 UDP 握手包来建立状态。
  3. 死锁形成:开启 TUN 后,Tailscale 刚发出建连请求,就被 Clash 一把抓走。如果这些流量被 Clash 丢到了错误的代理节点,或者由于 Fake-IP 导致 Tailscale 拿不到中继服务器的真实 IP,它的握手包就永远发不出去,一直卡在 NoState 等待响应。

6.2.3 解决#

思路很简单:给 Clash 设个规矩,让它对 Tailscale 相关的 IP、域名和进程“高抬贵手”,直接放行。

添加 Merge (合并) 路由规则#

为了万无一失,我们需要让 Mihomo 核心彻底放行 tailscaled 进程和相关域名的流量。

将以下规则添加到直连规则(Direct)中,并且要放在规则列表的 最前面

rules:
# 直连 Tailscale 后台守护进程
- PROCESS-NAME,tailscaled,DIRECT
# 直连 Tailscale 的内网网段
- IP-CIDR,100.64.0.0/10,DIRECT
# 直连 Tailscale 的控制面域名
- DOMAIN-SUFFIX,tailscale.com,DIRECT
- DOMAIN-SUFFIX,tailscale.io,DIRECT

修复 Fake-IP 污染(关键):如果 Clash 配置里开启了 Fake-IP,必须把 Tailscale 的域名加入 fake-ip-filter,否则它连不上中继。在配置(实际配置位于dns_config.yaml)中补充:

dns:
fake-ip-filter:
- '*.tailscale.com'
- '*.tailscale.io'

应用这些设置并重启 Clash 的 TUN 开关后,Clash 就会把 Tailscale 的建连流量和 100.x.x.x 的内网流量原封不动地交还给系统路由表,两边就能和平共处了。

注:关于如何查找配置文件位置#

在 Linux 中,要查看一个进程在启动时到底读取了哪些文件,最强大且标准的工具是 strace。它可以追踪进程调用的所有系统 API,包括打开文件的操作。

因为 clash-verge 是基于 Tauri 的应用,它会启动多个子进程(前端渲染、后端服务等),所以需要加上特定参数来追踪所有子进程,并过滤出跟配置文件相关的操作。

Step1: 进程追踪#

安装 strace ,用 strace 运行组合命令并输出日志,将 strace 加在 clash 环境变量之后、 clash-verge 之前。为了防止终端输出被海量的系统调用淹没,把日志重定向到一个文本文件中:

Terminal window
sudo apt install strace
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1 GDK_BACKEND=x11 strace -f -e trace=open,openat clash-verge 2> startup_trace.log

参数解释:

  • -f:告诉 strace 追踪所有由 clash-verge 派生出来的子进程(关键,因为配置通常是核心子进程读取的)。
  • -e trace=open,openat:只监听“打开文件”相关的系统调用,过滤掉网络、内存等无关操作。
  • 2> startup_trace.log:将 strace 的标准错误输出(它的追踪日志默认输出到这里)保存到 startup_trace.log 文件中。

运行上面这条命令后,等终端停留在之前的状态(说明应用已经启动并转入后台监听),按 Ctrl + C 结束它。

Step2: 分析追踪日志#

现在当前目录下生成了一个 startup_trace.log 文件。可以用 grep 过滤出所有常见的配置文件后缀(如 .yaml, .yml, .json):

Terminal window
grep -i -E '\.yaml|\.yml|\.json' startup_trace.log | grep -v "ENOENT"

参数解释:

  • -i -E '\.yaml|\.yml|\.json':忽略大小写,匹配这三种常见的配置文件格式。
  • grep -v "ENOENT":过滤掉“文件不存在(Error No Entry)”的记录。程序在启动时通常会按照一定顺序去多个默认目录“试探性”地寻找配置文件,过滤掉找不到的记录,剩下的就是它真正成功读取到的文件路径:
    ➜ ~ grep -i -E '\.yaml|\.yml|\.json' startup_trace.log | grep -v "ENOENT"
    [pid 568056] openat(AT_FDCWD, "/home/pi/.local/share/io.github.clash-verge-rev.clash-verge-rev/verge.yaml", O_RDONLY|O_CLOEXEC) = 19
    [pid 568057] openat(AT_FDCWD, "/home/pi/.local/share/io.github.clash-verge-rev.clash-verge-rev/config.yaml", O_RDONLY|O_CLOEXEC) = 19
    [pid 568057] openat(AT_FDCWD, "/home/pi/.local/share/io.github.clash-verge-rev.clash-verge-rev/verge.yaml", O_RDONLY|O_CLOEXEC) = 19
    [pid 568056] openat(AT_FDCWD, "/home/pi/.local/share/io.github.clash-verge-rev.clash-verge-rev/profiles.yaml", O_RDONLY|O_CLOEXEC) = 19
    [pid 568036] openat(AT_FDCWD, "/home/pi/.config/io.github.clash-verge-rev.clash-verge-rev/window_state.json", O_RDONLY|O_CLOEXEC) = 20
    [pid 568057] openat(AT_FDCWD, "/home/pi/.local/share/io.github.clash-verge-rev.clash-verge-rev/profiles/RrCXXb9eVReP.yaml", O_RDONLY|O_CLOEXEC) = 30
    [pid 568058] openat(AT_FDCWD, "/home/pi/.local/share/io.github.clash-verge-rev.clash-verge-rev/profiles/Merge.yaml", O_RDONLY|O_CLOEXEC <unfinished ...>
    [pid 568058] openat(AT_FDCWD, "/home/pi/.local/share/io.github.clash-verge-rev.clash-verge-rev/dns_config.yaml", O_RDONLY|O_CLOEXEC) = 21
    [pid 568057] openat(AT_FDCWD, "/home/pi/.local/share/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0666) = 21
    [pid 568058] openat(AT_FDCWD, "/home/pi/.local/share/io.github.clash-verge-rev.clash-verge-rev/clash-verge-check.yaml", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0666) = 21
    [pid 568095] openat(AT_FDCWD, "/home/pi/.local/share/io.github.clash-verge-rev.clash-verge-rev/clash-verge-check.yaml", O_RDONLY|O_CLOEXEC) = 3
    [pid 568057] openat(AT_FDCWD, "/home/pi/.local/share/io.github.clash-verge-rev.clash-verge-rev/clash-verge.yaml", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0666) = 21```

7 self host atuin#

atuin 的主机间 sync 命令行历史记录非常有用,按照官方说明 Self Hosting - Docker Compose 用 Docker Compose 的方式可以很容易的拉起来一个私有服务。之后,在任意需要 atuin sync 的主机上,修改 atuin 的配置文件 ~/.config/atuin/config.tomlsync_address = "http://<server-ip:port>"即可。

将本地的shell命令导入 atuin 管理,运行atuin import autoatuin import zsh。注:如果要 atuin 接管 ctrl-r,则要保证.zshrc中 atuin 在 fzf 之后初始化。

接下来,在第一台要同步的主机命令行里,运行首次用户注册:atuin register -u <username> -e <email>,此时 atuin 会让你运行atuin key拿到助记词,用来在之后的登录时使用。注册完毕后运行atuin sync向服务器同步历史记录。完成注册后,再在其他主机上只需要执行登录即可:atuin login -u <username>再输入助记词即可,成功后atuin sync

此时ctrl-r打开 atuin ,多次按ctrl-r就会在 全局、本机、本次会话、此目录 等环境中切换。在全局环境,选中命令按ctrl-o进入检视页会看到此命令所属的主机。

最后设置系统服务,新建sudo vim /etc/systemd/system/atuin.service,把官方说明中systemd那段脚本贴进去,然后将WorkingDirectory换成刚才docker-compose.yml所在的绝对路径即可,再sudo systemctl daemon-reloadsudo systemctl enable --now atuin.servicesudo systemctl status atuin.service即可看到开机自启的 atuin 服务。

firefox esr apt安装说明

8 启用 rock 5b 的 pwm#

风扇的pwm线插在pin 27上,sudo armbian-config进入TUI界面 -> System -> Kernel -> DTO01 - Manage device tree overlays -> 选 rk3588-pwm7-m3。或者在/boot/armbianEnv.txtoverlays后面续上字符串rk3588-pwm7-m3

重启。

PWM 子系统的 pwmchipN 编号是按 probe 顺序生成的,每次启动可能不一样,必须看一下:

Terminal window
sudo cat /sys/kernel/debug/pwm
platform/febd0030.pwm, 1 PWM device
pwm-0 ((null) ): period: 0 ns duty: 0 ns polarity: normal
platform/fd8b0010.pwm, 1 PWM device
pwm-0 (pwm-fan ): requested period: 60000 ns duty: 0 ns polarity: normal

febd0030 这个地址就是 PWM7(RK3588 PWM7 寄存器基址)。然后看哪个 pwmchip 链接到它:

Terminal window
ls -l /sys/class/pwm/
lrwxrwxrwx - root 29 4月 18:51 pwmchip0 -> ../../devices/platform/fd8b0010.pwm/pwm/pwmchip0
lrwxrwxrwx - root 29 4月 18:51 pwmchip1 -> ../../devices/platform/febd0030.pwm/pwm/pwmchip1

记住这个pwmchip1

先写个脚本测试:

Terminal window
uv init pwm7-m3
cd pwm7-m3
uv add python-periphery

写一个python脚本 fan_pwm.py 测试:

"""Hardware PWM fan control on Rock 5B PIN_27 (GPIO4_C6 / PWM7_IR_M3).
需先启用 rk3588-pwm7-m3 overlay 并重启。
通过 /sys/kernel/debug/pwm 找到 febd0030.pwm 对应的 pwmchip 号。
"""
import signal
import sys
import time
from periphery import PWM
# === 改成你机器上实际看到的 pwmchip 号 ===
PWM_CHIP = 1 # pwmchipN 中的 N
PWM_CHANNEL = 0 # Rockchip 每个 chip 只有一个 channel,固定为 0
# =====================================
FREQ_HZ = 25000 # 4 线 PC 风扇标准 PWM 频率
DUTY = 0.30 # 30% 占空比
def main() -> None:
pwm = PWM(PWM_CHIP, PWM_CHANNEL)
try:
pwm.frequency = FREQ_HZ # 25 kHz
pwm.duty_cycle = DUTY # 0.0 ~ 1.0
pwm.polarity = "normal"
pwm.enable()
print(f"PWM7 on PIN_27: {DUTY * 100:.0f}% @ {FREQ_HZ} Hz "
f"(pwmchip{PWM_CHIP})")
print("Ctrl+C 退出")
def shutdown(_sig, _frame):
print("\n停止 PWM...")
pwm.disable()
pwm.close()
sys.exit(0)
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
while True:
time.sleep(1)
except Exception:
pwm.close()
raise
if __name__ == "__main__":
main()

运行脚本测试 sudo $(which uv) run python fan_pwm.py,此时风扇应该降速了。

成功后封装为系统服务,服务运行时不能依赖家目录(特别是如果以 root 跑),放到 /opt

Terminal window
sudo mv ~/pwm7-m3 /opt/pwm7-m3
sudo chown -R root:root /opt/pwm7-m3
cd /opt/pwm7-m3

确认依赖装好(uv 会在 /opt/pwm7-m3/.venv 建虚拟环境):

Terminal window
sudo $(which uv) sync

记下 uv 的绝对路径,service 里要用:

Terminal window
which uv
/home/radxa/.local/bin/uv

由于 uv 装在家目录下(~/.local/bin/uv),root 跑服务时找不到,把 uv 放到系统路径:

Terminal window
sudo cp ~/.local/bin/uv /usr/local/bin/uv

微调一下脚本,让它对 SIGTERM(systemd stop 发的信号)也能干净退出,并且把输出冲到日志里(journald 会自动收):

"""Hardware PWM fan control on Rock 5B PIN_27 (PWM7_IR_M3)."""
import signal
import sys
import time
from periphery import PWM
PWM_CHIP = 1 # 改成你机器上 febd0030.pwm 对应的 pwmchipN
PWM_CHANNEL = 0
FREQ_HZ = 25000
DUTY = 0.30
def main() -> None:
pwm = PWM(PWM_CHIP, PWM_CHANNEL)
pwm.frequency = FREQ_HZ
pwm.duty_cycle = DUTY
pwm.polarity = "normal"
pwm.enable()
print(f"pwm7-m3: {DUTY * 100:.0f}% @ {FREQ_HZ} Hz on pwmchip{PWM_CHIP}",
flush=True)
stopping = False
def shutdown(_sig, _frame):
nonlocal stopping
stopping = True
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
try:
while not stopping:
time.sleep(1)
finally:
print("pwm7-m3: stopping", flush=True)
try:
pwm.disable()
finally:
pwm.close()
if __name__ == "__main__":
sys.exit(main())

flush=True 是为了让 print 立刻被 systemd 抓到日志里,不然 Python 默认会做行缓冲/块缓冲,停服务前可能看不到输出。

创建 systemd service 文件:

Terminal window
sudo vim /etc/systemd/system/pwm7-m3.service
[Unit]
Description=Rock 5B PWM fan control (PIN_27 / PWM7)
After=multi-user.target
[Service]
Type=simple
WorkingDirectory=/opt/fan-pwm
ExecStart=/usr/local/bin/uv run python fan_pwm.py
Restart=on-failure
RestartSec=3
User=root
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

几个要点:

  • Type=simple:脚本本身就是常驻进程,不 fork,所以 simple 即可。
  • ExecStart 里 uv 必须用绝对路径——systemd 不读的 shell PATH
  • WorkingDirectory 让 uv 找得到项目里的 pyproject.toml.venv
  • Restart=on-failure:脚本异常退出就重启,但 systemctl stop 主动停的时候不会重启。
  • User=root:因为 PWM sysfs 默认只有 root 能写。

启用 + 启动,enable --now 等于 enable(开机自启)+ start(立刻启动)一起做。

Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now pwm7-m3.service

常用操作:

Terminal window
# 看状态
sudo systemctl status pwm7-m3
# 看实时日志
sudo journalctl -u pwm7-m3 -f
# 停止 / 重启
sudo systemctl stop pwm7-m3
sudo systemctl restart pwm7-m3
# 改完占空比想生效,重启服务即可
sudo systemctl restart pwm7-m3
# 不要开机自启了
sudo systemctl disable pwm7-m3

成功后也可以把脚本改成自动控温的模式:

"""Temperature-based PWM fan control for Rock 5B (PIN_27 / PWM7_IR_M3)."""
import signal
import sys
import time
from periphery import PWM
# === 硬件配置 ===
PWM_CHIP = 1 # febd0030.pwm 对应的 pwmchipN
PWM_CHANNEL = 0
FREQ_HZ = 25000
THERMAL_ZONE = "/sys/class/thermal/thermal_zone0/temp" # CPU 温度
POLL_INTERVAL = 3.0 # 秒,温度采样间隔
# === 风扇曲线 ===
# (温度 °C, 占空比 0.0~1.0)
# 大风扇没必要跑全速,最高也就 60%
FAN_CURVE = [
(45.0, 0.30), # 45°C 以下30%
(50.0, 0.35), # 起转点
(60.0, 0.40),
(70.0, 0.45),
(80.0, 0.60), # 80°C 以上也只到 60%
]
# 滞回,避免在阈值附近反复启停
HYSTERESIS_C = 2.0
# 起转辅助:从 0% 跳到目标值时,先全力踹一下让风扇转起来
KICKSTART_DUTY = 0.50
KICKSTART_SEC = 1.0
def read_temp_c() -> float:
with open(THERMAL_ZONE) as f:
return int(f.read().strip()) / 1000.0
def duty_for_temp(temp_c: float, curve: list[tuple[float, float]]) -> float:
"""在曲线上做线性插值。"""
if temp_c <= curve[0][0]:
return curve[0][1]
if temp_c >= curve[-1][0]:
return curve[-1][1]
for (t0, d0), (t1, d1) in zip(curve, curve[1:]):
if t0 <= temp_c <= t1:
ratio = (temp_c - t0) / (t1 - t0)
return d0 + (d1 - d0) * ratio
return curve[-1][1]
def main() -> int:
pwm = PWM(PWM_CHIP, PWM_CHANNEL)
pwm.frequency = FREQ_HZ
pwm.duty_cycle = 0.0
pwm.polarity = "normal"
pwm.enable()
print(f"fan-pwm: started, curve={FAN_CURVE}", flush=True)
stopping = False
def shutdown(_sig, _frame):
nonlocal stopping
stopping = True
signal.signal(signal.SIGINT, shutdown)
signal.signal(signal.SIGTERM, shutdown)
last_temp = read_temp_c()
current_duty = 0.0
try:
while not stopping:
temp = read_temp_c()
# 滞回:温度变化小于 HYSTERESIS_C 时不动
if abs(temp - last_temp) < HYSTERESIS_C:
target = current_duty
else:
target = duty_for_temp(temp, FAN_CURVE)
last_temp = temp
if target != current_duty:
# 从停转到要起转 → 先 kickstart
if current_duty == 0.0 and target > 0.0:
pwm.duty_cycle = max(target, KICKSTART_DUTY)
print(f"fan-pwm: {temp:.1f}°C kickstart "
f"-> {pwm.duty_cycle * 100:.0f}%", flush=True)
time.sleep(KICKSTART_SEC)
pwm.duty_cycle = target
current_duty = target
print(f"fan-pwm: {temp:.1f}°C duty -> {target * 100:.0f}%",
flush=True)
time.sleep(POLL_INTERVAL)
finally:
print("fan-pwm: stopping", flush=True)
try:
pwm.duty_cycle = 0.0
pwm.disable()
finally:
pwm.close()
return 0
if __name__ == "__main__":
sys.exit(main())
新年要用新系统
https://zlog.zxdata.uk/2026/03/08/init-2026-win11/
作者
Z
发布于
2026-03-08
许可协议
CC BY-NC-SA 4.0