这个blog两年没更新过了啊!靠着AI打气又拾起来了!
起因是节前Fusion 360警告我win10不受支持了,Gemini也建议我换个系统,并且疯狂安利Win11,安抚我说Win11已经不是几年前那种费拉不堪的状态了。
再信AI一次。
0 备份
尤其是wsl2!:
mkdir -p ~/wsl_backup/manifestscd ~/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 或 pytorchconda env export --no-builds > conda_environment.ymlpip 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(),剩下的给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 清理微软的垃圾
第一时间防火防盗防微软。
-
彻底禁止 Windows 自动更新驱动:
Win+R输入gpedit.msc:计算机配置 -> 管理模板 -> Windows 组件 -> Windows 更新 -> 管理从 Windows 更新提供的更新,右侧的 “Windows 更新不包括驱动程序”,选择 “已启用”。如此,让 Windows Update 只推安全补丁和功能更新,绝不会动显卡、网卡、声卡驱动。 -
保留 Defender,配置“码农豁免区” 打开 Windows 安全中心 -> 病毒和威胁防护 -> 管理设置,下拉找到 “排除项” -> 添加或删除排除项,酌情加入:
D:\WSL(放 WSL 镜像,防止扫描 vhdx 读写);D:\DEV(放代码);D:\MOEDL(放模型);- etc.
-
左下角“天气新闻”等等是 小组件” (Widgets):
Win+R输入gpedit.msc:计算机配置 -> 管理模板 -> Windows 组件 -> 小组件,右侧的 “允许小组件”,选择 “已禁用”。 -
锁屏时“天气新闻”等待是 Windows 聚焦 (Windows Spotlight) :
- 右键桌面 -> “个性化” -> 选择 “锁屏界面”,在个 性化锁屏界面 中选择 “图片” (Picture) 。
- 再往下看,如果有 “在锁屏界面上获取花絮、提示、技巧和更多内容”,选择 关闭。
- 如果有 “锁屏界面状态”,改为 无 (None) 。
-
开始菜单里的垃圾,右键桌面 -> “个性化” -> “开始”:
- 显示最近添加的应用 -> 关。
- 显示最常用的应用 -> 关。
- 在“开始”菜单中……显示最近打开的项目 -> 关
-
开始菜单的搜索 强行把本地搜索和 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) 。
-
任务栏的搜索框,任务栏空白处 右键 -> “任务栏设置”,第一项就是 “搜索”,选择 “隐藏”。
-
Steam/Epic党可以把XBox相关都端了:
- 关闭 Xbox Game Bar:在 设置 -> 游戏 -> Xbox Game Bar,关闭 “允许控制器打开 Game Bar”。
- 开始菜单搜 “Xbox”,右键 -> 卸载。
- 唯一例外:游戏模式 (Game Mode) ,留着,在 设置 -> 游戏 -> 游戏模式 里,保持 开 (ON)。
-
关闭 OneDrive:
- 解除“文件夹备份”,任务栏右下角的那个“云朵”图标,右上角齿轮进入 设置,找到 “同步并备份” (Sync and backup) -> “管理备份” (Manage backup),把“文档”、“图片”、“桌面”全部关掉(Stop backup)。,此时系统会把这些文件夹的路径从
C:\Users\<username>\OneDrive\Desktop恢复为C:\Users\<username>\Desktop - 取消链接:设置 里 -> “账户” (Account),点击 “取消链接此电脑” (Unlink this PC)。
- 卸载:设置 -> 应用 -> 安装的应用,搜 “OneDrive” -> 卸载。
- 解除“文件夹备份”,任务栏右下角的那个“云朵”图标,右上角齿轮进入 设置,找到 “同步并备份” (Sync and backup) -> “管理备份” (Manage backup),把“文档”、“图片”、“桌面”全部关掉(Stop backup)。,此时系统会把这些文件夹的路径从
-
至于 基于虚拟化的安全性 (VBS)” / HVCI,模型和网上的说法都没定论。
Win键搜索 “内核隔离” -> “内存完整性”,收益有限,关不关无所谓。- 网上流传的
bcdedit /set hypervisorlaunchtype off不能设置, wsl2 要用。
-
找回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:
winget install --id Git.Git装微信QQ:
winget install Tencent.WeChatwinget install Tencent.QQPotPlayer:
winget install Daum.PotPlayer装powertoys:
winget install Microsoft.PowerToys装Everything和Listary:
winget install voidtools.Everythingwinget install -e --id Bopsoft.Listary4 安装wsl2
有显卡驱动了再装wsl2。
-
先装 WSL 本体,管理员 PowerShell:
Terminal window wsl --install -
装完建议重启,确认 WSL 版本:
Terminal window wsl --version -
在 D 盘准备一个专门目录
D:\WSL,安装一个临时 Ubuntu(会默认在 C 盘),启动一次 Ubuntu,完成用户名/密码初始化后退出。Terminal window wsl --install -d Ubuntu -
把迁移到 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\下面了。 -
wsl2网络配置:建议按老方法,默认NAT,不要配自动代理、DNS隧道等配置。装个clash-verge,用自己写的订阅发布程序让Windows、wsl、PVE、rock5b、o6n等设备同步订阅配置,虽然heavy但是最稳妥:
[wsl2]networkingMode=natautoProxy=falsednsTunneling=falsednsProxy=false新wsl2支持mirror,直接走Windows的网卡,别的没什么问题,但是gemini-cli或codex可能会用不了:
[wsl2]networkingMode=mirroreddnsTunneling=trueautoProxy=truefirewall=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
建议先改主机名:
sudo hostnamectl set-hostname new_name20260829更新,用gpt-sol-5.6更新了个一键安装脚本 bootstrap-dev-v5.sh ,也可以分块执行。
脚本是幂等的,可以直接用:
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 bashset -Eeuo pipefailIFS=$'\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_PROXYAcquire::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=ApplicationName=Fcitx 5Comment=Start Fcitx 5 input methodExec=fcitx5 -dTerminal=falseX-GNOME-Autostart-enabled=trueNoDisplay=trueEOF_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'# PATHsexport PATH="$HOME/.local/bin:$HOME/.cargo/bin:$HOME/.local/share/bob/nvim-bin:$PATH"
# Rust[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
# NVMexport NVM_DIR="$HOME/.nvm"[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
# pnpmexport PNPM_HOME="$HOME/.local/share/pnpm"export PNPM_GLOBAL_BIN_DIR="$PNPM_HOME/bin"case ":$PATH:" in *":$PNPM_HOME:"*) ;; *) export PATH="$PNPM_HOME:$PATH" ;;esaccase ":$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 Zshexport 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 toolscommand -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)"
# Aliasescommand -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_USAGEUsage: $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
# 建议使用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、pycryptodomexuv 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:githubEOFarm设备可能会遇到curl_cffi安装了也没有伪装的问题,转回x86平台吧,暂时无解,像这样:
yt-dlp --list-impersonate-targets[info] Available impersonate targetsClient 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文件夹
拷过去权限得改对
chmod 700 ~/.sshchmod 600 ~/.ssh/id_*chmod 644 ~/.ssh/*.pub# 如果有chmod 600 ~/.ssh/config5.3 关于nomachine
安装时如果遇到60%卡住终端没响应,关掉重开一个终端修复一下安装即可,其实是已经装好,之是post install脚本可能有问题挂起了:
sudo killall apt apt-get dpkgsudo dpkg --configure -aarm上有时会遇到wayland桌面环境nomachine连上是黑底各种乱七八糟的竖条纹的情况,改回x11就会好:
sudo vim /etc/gdm3/custom.confWaylandEnable=false重启gdm3,或直接重启系统更稳妥。
5.4 关于中文环境,上面装了字体和中文编码,需要配置语言环境:
sudo dpkg-reconfigure locales选zh_CN.UTF-8 UTF-8即可,echo $LANG还不行的话:
echo "export LANGUAGE=zh_CN.zh" >> ~/.bashrcecho "export LC_ALL=zh_CN.UTF-8" >> ~/.bashrcecho "export LANG=zh_CN.UTF-8" >> ~/.bashrcecho "export LANGUAGE=zh_CN.zh" >> ~/.zshrcecho "export LC_ALL=zh_CN.UTF-8" >> ~/.zshrcecho "export LANG=zh_CN.UTF-8" >> ~/.zshrc关于输入法,就用 Fcitx5 默认,省事:
# 安装 Fcitx5 核心、中文拼音插件及图形化配置工具sudo apt updatesudo apt install fcitx5 fcitx5-chinese-addons fcitx5-frontend-gtk3 fcitx5-frontend-gtk2 fcitx5-frontend-qt5 kde-config-fcitx5 fcitx5-config-qt
# 将环境变量写入 ~/.xprofile 或 /etc/profileecho 'export GTK_IM_MODULE=fcitx' >> ~/.xprofileecho 'export QT_IM_MODULE=fcitx' >> ~/.xprofileecho 'export XMODIFIERS=@im=fcitx' >> ~/.xprofile运行im-config,选择fcitx5,重启,右上角出现 Fcitx5 的小键盘图标,右键点击选择 配置 (Configure),在右侧列表中找到 拼音 (Pinyin) 并添加到左侧。
现在使用
topgrade自动感知并更新安装工具。
5.5 关于starship
配置文件可以改好看点:
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 = falseformat = '🌐 [$localipv4]($style) 'style = "bold blue"
# ==========================# 2. 资源报警 (阈值触发,零延迟感知)# ==========================# 内存使用率[memory_usage]disabled = falsethreshold = 80 # 性能优化:内存占用没超过 80% 时完全不渲染此模块,节省算力format = '🐏 [$ram]($style) 'style = "bold red"
# ==========================# 3. 运维排障辅助# ==========================# 命令执行耗时 (仅当命令执行超过 2 秒时显示)[cmd_duration]min_time = 2000format = '耗时 [$duration]($style) 'style = "bold yellow"
# 退出码状态 (命令报错时变红)[status]disabled = falseformat = '[$symbol$status]($style) 'success_symbol = "🟢 "symbol = "🔴 "style = "bold red"EOF6 tailscale 与 ipv6
节后移动给的GPON坏了,运营商换了一个后发现有ipv6了。家里的rock5b、PVE工作站都可以用ipv6直连了。
PVE装tailscale
需要注意的是,PVE默认没打开ipv6支持,所以需要手动设置:
vi /etc/sysctl.conf
net.ipv6.conf.all.disable_ipv6 = 0net.ipv6.conf.default.disable_ipv6 = 0net.ipv6.conf.lo.disable_ipv6 = 0
net.ipv6.conf.all.forwarding = 1
vi /etc/sysctl.d/99-tailscale.confnet.ipv4.ip_forward = 1net.ipv6.conf.all.forwarding = 1# 或:echo 'net.ipv4.ip_forward = 1' | tee -a /etc/sysctl.d/99-tailscale.confecho '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了。
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上配置系统转发:
echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.confecho 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.confsudo sysctl -p
sudo tailscale up --advertise-routes=192.168.1.0/24 --accept-dns=false出于安全考虑,被“广播”的网段 Tailscale 默认不信任,需要手动确认:
- 登录 Tailscale Admin Console。
- Rock 5B 点击右侧的 三个点 (…)。
- 选择 Edit route settings。
- 在弹出的窗口中,勾选广播的网段
192.168.1.0/24。 - 点击 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 解析 -> 路由表。
- 排除防火墙与物理隔离: 检查了
ufw和iptables,未发现拦截规则。 - 检查 ARP 缓存 (
ip neigh show): 开发板 A 上能成功获取到 B 和 Windows 的 MAC 地址,说明物理层和数据链路层通信正常。 - 排查代理软件: 怀疑是 Windows 端的 Clash TUN 模式劫持了局域网流量。关闭 TUN 模式并使用
ping -S 192.168.1.125 192.168.1.140强制指定源 IP,依然不通,排除发件端代理问题。 - 定位核心问题——策略路由 (
ip rule): 在开发板 A (140) 上执行ip rule发现异常:执行Terminal window 5270: from all lookup 5232766: from all lookup mainip 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)的策略路由规则:
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/24或192.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 脚本,采用“先清理后添加”的逻辑,防止因网络服务重启导致规则重复堆叠。
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赋予执行权限:
sudo chmod +x /usr/local/bin/fix-lan-route.shStep 2: 编写 systemd 服务文件
配置服务依赖,确保脚本在网络连通 (network-online.target) 且 Tailscale 服务启动 (tailscaled.service) 之后再执行。
sudo vim /etc/systemd/system/fix-lan-route.service内容为:
[Unit]Description=Fix LAN routing conflict with TailscaleAfter=network-online.target tailscaled.serviceWants=network-online.target
[Service]Type=oneshotExecStart=/usr/local/bin/fix-lan-route.shRemainAfterExit=yes
[Install]WantedBy=multi-user.targetStep 3: 激活服务并验证
重载 systemd 守护进程,并将该服务设为开机自启:
# 重载系统配置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启动报错。
-
开启TUN时的异常日志:
Terminal window ➜ ~ sudo systemctl status tailscaled.service● tailscaled.service - Tailscale node agentLoaded: loaded (/lib/systemd/system/tailscaled.service; enabled; vendor preset: enabled)Active: active (running) since Sat 2026-03-28 10:00:48 CST; 28s agoDocs: https://tailscale.com/docs/Main PID: 578694 (tailscaled)Tasks: 15 (limit: 18526)Memory: 54.6MCPU: 878msCGroup: /system.slice/tailscaled.service└─578694 /usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=41641Mar 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 routesMar 28 10:00:48 rock-5b tailscaled[578694]: got LocalBackend in 56msMar 28 10:00:48 rock-5b tailscaled[578694]: StartMar 28 10:00:48 rock-5b tailscaled[578694]: ipnext: active extensions: conn25, portlist, posture, clientupdate, relayserver, taildropMar 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 statusunexpected state: NoState -
关闭tun后的正常日志:
Terminal window ➜ ~ sudo systemctl status tailscaled.service● tailscaled.service - Tailscale node agentLoaded: loaded (/lib/systemd/system/tailscaled.service; enabled; vendor preset: enabled)Active: active (running) since Sat 2026-03-28 10:01:28 CST; 3s agoDocs: https://tailscale.com/docs/Main PID: 579582 (tailscaled)Tasks: 15 (limit: 18526)Memory: 42.3MCPU: 708msCGroup: /system.slice/tailscaled.service└─579582 /usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=41641Mar 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=255Mar 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 changeMar 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 connsMar 28 10:01:31 rock-5b tailscaled[579582]: router: portUpdate(port=41641, network=udp4)此时:
Terminal window ➜ ~ tailscale status100.xx.xx.xx rock-5b xxx@ linux -100.xx.xx.xx server xxx@ linux offline, last seen xxd ago100.xx.xx.xx phone1 xxx@ android offline, last seen xxd ago100.xx.xx.xx orion-o6 xxx@ linux offline, last seen xxh ago100.xx.xx.xx phone2 xxx@ android offline, last seen xxh ago100.xx.xx.xx macbook-pro xxx@ macOS offline, last seen xxd ago
6.2.2 原因
简单来说,Clash 的 TUN 模式把 Tailscale 的建连流量给“劫持”了。
- Clash TUN 的霸道机制:当开启 Clash TUN 模式时,它会在系统里建一个虚拟网卡(比如
Mihomo),并通过修改系统路由表(下发0.0.0.0/1这种默认路由)或者用fwmark(防火墙标记)把机器上所有的流量都强行吸进自己的核心里。同时,它通常还会开启 Fake-IP 来劫持 DNS 请求(日志里关闭 TUN 时出现的198.18.0.1就是 Clash 的 Fake-IP 网段)。 - Tailscale 的迷失:
tailscaled服务启动时,需要向它自己的控制面板(login.tailscale.com)和分布在全球的 DERP 中继服务器发送 UDP 握手包来建立状态。 - 死锁形成:开启 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 之前。为了防止终端输出被海量的系统调用淹没,把日志重定向到一个文本文件中:
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):
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.toml:sync_address = "http://<server-ip:port>"即可。
将本地的shell命令导入 atuin 管理,运行atuin import auto或atuin 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-reload、sudo systemctl enable --now atuin.service、sudo systemctl status atuin.service即可看到开机自启的 atuin 服务。
8 启用 rock 5b 的 pwm
风扇的pwm线插在pin 27上,sudo armbian-config进入TUI界面 -> System -> Kernel -> DTO01 - Manage device tree overlays -> 选 rk3588-pwm7-m3。或者在/boot/armbianEnv.txt里overlays后面续上字符串rk3588-pwm7-m3。
重启。
PWM 子系统的 pwmchipN 编号是按 probe 顺序生成的,每次启动可能不一样,必须看一下:
sudo cat /sys/kernel/debug/pwmplatform/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: normalfebd0030 这个地址就是 PWM7(RK3588 PWM7 寄存器基址)。然后看哪个 pwmchip 链接到它:
ls -l /sys/class/pwm/lrwxrwxrwx - root 29 4月 18:51 pwmchip0 -> ../../devices/platform/fd8b0010.pwm/pwm/pwmchip0lrwxrwxrwx - root 29 4月 18:51 pwmchip1 -> ../../devices/platform/febd0030.pwm/pwm/pwmchip1记住这个pwmchip1
先写个脚本测试:
uv init pwm7-m3cd pwm7-m3uv 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 signalimport sysimport time
from periphery import PWM
# === 改成你机器上实际看到的 pwmchip 号 ===PWM_CHIP = 1 # pwmchipN 中的 NPWM_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:
sudo mv ~/pwm7-m3 /opt/pwm7-m3sudo chown -R root:root /opt/pwm7-m3cd /opt/pwm7-m3确认依赖装好(uv 会在 /opt/pwm7-m3/.venv 建虚拟环境):
sudo $(which uv) sync记下 uv 的绝对路径,service 里要用:
which uv/home/radxa/.local/bin/uv由于 uv 装在家目录下(~/.local/bin/uv),root 跑服务时找不到,把 uv 放到系统路径:
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 signalimport sysimport time
from periphery import PWM
PWM_CHIP = 1 # 改成你机器上 febd0030.pwm 对应的 pwmchipNPWM_CHANNEL = 0FREQ_HZ = 25000DUTY = 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 文件:
sudo vim /etc/systemd/system/pwm7-m3.service
[Unit]Description=Rock 5B PWM fan control (PIN_27 / PWM7)After=multi-user.target
[Service]Type=simpleWorkingDirectory=/opt/fan-pwmExecStart=/usr/local/bin/uv run python fan_pwm.pyRestart=on-failureRestartSec=3User=rootStandardOutput=journalStandardError=journal
[Install]WantedBy=multi-user.target几个要点:
Type=simple:脚本本身就是常驻进程,不 fork,所以 simple 即可。ExecStart里 uv 必须用绝对路径——systemd 不读的 shellPATH。WorkingDirectory让 uv 找得到项目里的pyproject.toml和.venv。Restart=on-failure:脚本异常退出就重启,但systemctl stop主动停的时候不会重启。User=root:因为 PWM sysfs 默认只有 root 能写。
启用 + 启动,enable --now 等于 enable(开机自启)+ start(立刻启动)一起做。
sudo systemctl daemon-reloadsudo systemctl enable --now pwm7-m3.service常用操作:
# 看状态sudo systemctl status pwm7-m3
# 看实时日志sudo journalctl -u pwm7-m3 -f
# 停止 / 重启sudo systemctl stop pwm7-m3sudo 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 signalimport sysimport time
from periphery import PWM
# === 硬件配置 ===PWM_CHIP = 1 # febd0030.pwm 对应的 pwmchipNPWM_CHANNEL = 0FREQ_HZ = 25000THERMAL_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.50KICKSTART_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())