最近做了几个 Magisk / KSU 模块,自己用下来感觉挺好用,放出来顺便写写实现。
- runsvdir-magisk:把 runit 的工具打包进 Android,负责开机启动服务监管进程。
- sing-box-magisk-runsv:把 sing-box 裸核接到 runsv 服务里。
- openlist-magisk-runsv:用同一套方式跑 OpenList。
后两个模块都带管理器更新检测,支持稳定版和预发布版渠道。应用模块也适配了 APatch,不过我这里主要按 Magisk / KernelSU 的用法写。
后台进程交给 runit
在 Android 上跑这些东西,开机执行一次启动命令很容易。麻烦的是后面的事:进程退出以后谁来拉起,要停用时怎么保证它不会又启动,日志又往哪里放。
这几个模块里,我把这些工作交给了 runit。runsvdir-magisk 用的是 Termux 的预编译二进制,构建时去掉原来的 RUNPATH。模块本身负责安装 runsvdir、runsv、sv、svlogd、chpst 等工具,开机等到 sys.boot_completed=1,再启动 runsvdir。它扫描的服务目录是:
/data/adb/runsvdir/service/
sing-box 和 OpenList 各占一个子目录,里面的 run 是启动脚本,conf 放启动参数,log/run 则启动日志子服务。runsvdir 为每个服务启动 runsv,由后者监管具体进程;服务退出后会重新执行 run。
这里有个很实际的要求:被监管的程序要保持前台运行,启动脚本最后用 exec 替换自身。以 OpenList 的 nomount 版为例,省略存储检查后,启动部分就是:
#!/system/bin/sh
exec 2>&1
cd "$(dirname "$0")" || exit 1
. ./conf
exec ./bin/openlist server --data "$OPENLIST_DATA"
如果在最后加个 &,shell 启动完后台进程就退出了,runsv 会把它当成服务退出,再启动一份。exec 2>&1 把标准错误并到标准输出,接着由 log/run 里的 svlogd -tt 收日志、加时间戳并轮转。看日志时直接读 current:
tail -f /data/adb/runsvdir/log/sv/sing-box/current
tail -f /data/adb/runsvdir/log/sv/openlist/current
以后再接别的程序,沿用这个目录结构就行,服务的启停命令也不用换一套。
启停和开机自启分开处理
先装 runsvdir-magisk 并重启,再刷应用模块,ZIP 要选对应的 ABI。大部分手机用 arm64-v8a,仓库里也打了 armeabi-v7a、x86_64 和 x86 的包。应用模块的安装脚本会检查前置模块,缺了就中止安装。
sing-box 和 OpenList 的安装包都带一个 down 文件,首次安装默认不启动。尤其是 sing-box,还没放配置就拉起来只会反复报错。它默认读取的配置在:
/data/adb/runsvdir/service/sing-box/workdir/config.json
准备好配置以后,可以点管理器里的模块「操作」按钮,音量下切换选项,音量上执行,选「启动并启用」。我也保留了直接用命令的方式,下面这些在 root shell 里执行:
export SVDIR=/data/adb/runsvdir/service
sv-enable sing-box
sv-enable openlist
sv status "$SVDIR/sing-box" "$SVDIR/openlist"
# 这次停止,保留原来的开机自启设置
sv down "$SVDIR/sing-box"
# 停止,并取消开机自启
sv-disable sing-box
# 重新启动服务,读取当前的程序和配置
sv restart "$SVDIR/openlist"
sv down 改的是当前监管进程的状态;sv-disable 还会创建 down 文件,下次开机依然保持停用。反过来,sv-enable 会删除这个文件并启动服务。手动停一次和长期禁用有各自的命令,用起来比较省心。

手机上的 sing-box 操作菜单。用的是 nomount,核心为 1.15.0-alpha.12,主服务和日志服务都在运行。
为什么分 mount 和 nomount
两个应用模块都做了这两种安装方式,区别先从核心放在哪里说起。
| nomount | mount | |
|---|---|---|
| 核心位置 | 服务目录里的 bin/ | 模块的 system/bin/,挂载到 /system/bin/ |
| 应用模块是否挂载核心 | 否 | 是 |
| 更新核心后生效方式 | 重启对应服务 | 重启手机 |
nomount 的程序直接放在 /data/adb/runsvdir/service/<服务名>/bin/。替换文件以后,下一次启动服务就会执行新的核心,自用时更新很方便。不过 /data/adb 普通用户无权访问,这种方式下应用就以 root 运行。
mount 把核心挂到 /system/bin,普通用户也能读取和执行,于是可以用 chpst 切换运行身份。OpenList 的 mount 版默认配置是:
RUN_AS="shell:shell:sdcard_r:sdcard_rw:media_rw:external_storage"
OPENLIST_DATA="/storage/emulated/0/Android/openlist"
WAIT_DECRYPT=1
这里让 OpenList 以 shell 用户运行,附带访问存储用的组,数据放在共享存储上。nomount 则默认用服务目录内的 ./data。两种方式连数据位置都不同,切换安装方式时也要处理自己的数据路径,不能把刷入另一版当成数据迁移。
共享存储还涉及开机后的解密。WAIT_DECRYPT=1 时,run 检查 sys.user.0.ce_available;没就绪就等十秒后退出,让 runsv 再试,避免程序在存储尚不可用时直接启动。
sing-box 的 mount 版也有 RUN_AS,默认是 root:net_admin。要用 TUN 就得考虑权限,所以不能因为核心放到了 /system/bin,就顺手把它改成普通用户。
mount 更新后需要重启手机,让新核心重新挂载;重启服务本身不会刷新模块挂载。这一点和 nomount 不一样,下载包的时候得看清楚。
更新检测按渠道、安装方式和架构分开
sing-box 和 OpenList 都会跟着上游更新,隔一阵子手动去 Releases 找包挺烦的。两个模块接了 module.prop 里的 updateJson,管理器从对应 JSON 读取版本号、下载地址和更新说明。
更新元数据按三个维度分开存放,例如:
update/stable/nomount/arm64-v8a.json
update/stable/mount/arm64-v8a.json
update/prerelease/nomount/arm64-v8a.json
拿 sing-box 稳定版的 arm64 nomount 包来说,安装后的 module.prop 会指向这个地址:
updateJson=https://raw.githubusercontent.com/sorubedo/sing-box-magisk-runsv/main/update/stable/nomount/arm64-v8a.json
JSON 里是 version、versionCode、zipUrl 和 changelog。这样管理器拿到的下载地址会对应当前架构和安装方式。模块操作菜单里的渠道切换也是改这个 URL,保留 variant 和 ABI,只把 stable 换成 prerelease,或者反过来。

OpenList 也用同一套菜单,当前选择预发布渠道。这里还加了随机重置管理员密码的入口。
这只是切换后续检查更新的来源。如果预发布版的版本号比稳定版高,切回稳定渠道不会自动把已安装的核心降级,想回退仍然要手动刷包。
sing-box 的预发布版本还有个细节:alpha.11 和 alpha.12 必须有不同的 versionCode,否则管理器看不出更新。构建脚本把主版本、次版本、补丁号、阶段和阶段内序号编码成整数,同一组主次补丁号里按 alpha < beta < rc < stable 排序。展示给人的 version 仍然沿用上游版本号。
发布流程放在 GitHub Actions 里,定时检查上游,也能手动触发。发现新版本以后,下载核心、打出两种安装方式和四种 ABI 的 ZIP,发布 Release,再生成更新 JSON。设备端的更新检测和仓库端的自动打包各自完成自己的工作,不需要在手机后台另跑一个更新守护进程。
升级时保留哪些东西
服务目录放在 /data/adb/runsvdir/ 下,和模块安装目录分开。升级覆盖刷入时,sing-box 的 workdir、OpenList 的数据以及原来的自启状态都会保留。
不过 run、conf 和 log/run 也允许自己改,直接覆盖它们会把本地修改冲掉。在安装方式不变时,升级可以用音量键选:音量上只更新核心,音量下完整更新服务脚本和核心。完整更新会覆盖安装包里同名的文件,只有本地才有的文件会留下;自己改过 conf 的话,就要留意这个选择。切换 mount / nomount 会强制完整更新,让启动脚本和新的安装方式匹配。
nomount 的二进制替换也做了点处理:先复制到同目录的临时文件,再重命名覆盖目标。直接往正在执行的二进制里写可能遇到 Text file busy,这种做法可以替换路径,同时让旧进程继续运行。装完以后执行 sv restart,新进程才会用上新核心。
升级保留数据和卸载是两回事。sing-box 卸载会删除整个服务目录,配置也会一起删;OpenList nomount 的 ./data 同样在卸载范围里。OpenList mount 默认放在共享存储上的数据则会留下。卸载前该备份的还是要备份。