前言
我现在使用的手机是 Motorola Edge+ 2023,一台 Android 手机。为了更好的自定义手机的功能,我解锁了手机的 Bootloader,并且获取了 Root 权限,以便安装 LSPosed 以及基于 LSPosed 的各种插件。
我使用的 Root 方案是 KernelSU,通过修改 Linux 内核,从而允许且只允许指定程序获取 Root 权限。虽然 KernelSU 官方提供了适配大部分手机的 GKI 内核镜像,但我给手机刷了不兼容 GKI 的 LineageOS,所以只能自己编译内核。
由于直接修改内核镜像的难度较大,我们一般是从手机厂商获取以 GPLv2 协议开源的内核源码,按照 KernelSU 的官方教程进行修改后,再编译成完整的内核。
注:现在有一种新的 Root 方案 APatch,通过直接修改内核镜像来实现类似 KernelSU 的功能。我没试过 APatch,但如果你不想自己编译内核,可以尝试一下。
由于 KernelSU 使用广泛,有一些开发者编写了 GitHub Actions 的 Workflow,例如 https://github.com/xiaoleGun/KernelSU_Action,可以自动给内核源码打补丁并进行编译。但我在尝试这些 Workflow 时发现了一些问题:
- 这些 Workflow 会同时安装多种编译器,包括 GitHub Actions 环境自带的 GCC,Google 专门为 Android 提供的 ARM32 GCC 和 ARM64 GCC,以及 Clang。如果编译参数设置错误,编译 Android 内核时会混用多种编译器进行编译,导致最终生成的内核运行不稳定,甚至无法启动。
- 这些 Workflow 都只能在 GitHub Actions 上运行,难以在本地进行调试。
- 这些 Workflow 一般都是定时运行,或者由用户手动触发。如果采用定时运行,即使内核源码和 KernelSU 源码都没有更新,Workflow 也会反复重新编译内核,浪费计算资源。如果由用户手动触发,可能无法及时获取到最新的内核更新。
因为我最近一直在用 NixOS 作为操作系统,我自然想到了用 Nix 包管理器解决上面的问题:
- Nix 在构建软件包时会创建一个隔离环境,其中只含有我指定的编译器。这就避免了混用编译器导致的问题。
- Nix 包管理器既可以在本地的 Linux 系统上运行,也可以在 GitHub Actions 上运行,并且创建的隔离环境都是一样的。因此我可以在本地调试完流程,然后放心地上传到 GitHub 上让 Actions 去自动编译更新后的内核。
- Nix 在构建软件包时同样会记录所有源代码的版本(实际上是记录源码的 SHA256)和编译命令。如果源代码版本和编译命令都和之前的相同,Nix 可以直接调出上次的编译结果,不用重复编译。
于是,我就写了一套 Nix 的编译脚本(Nix Derivation),用来给我的手机编译内核。
使用
我把这套编译脚本上传到了 GitHub:https://github.com/xddxdd/nix-kernelsu-builder
这套脚本可以自动给你的内核源码打上 KernelSU 和 SusFS 补丁,然后编译内核并生成基于 AnyKernel 的刷机包,供在 Recovery 中刷入。
在安装 Nix 包管理器后,你可以 Fork 仓库,修改 kernels.nix
加入你的手机的内核信息,然后通过 nix build .#[内核名称]
来编译内核。具体的配置参数已经列出在仓库的 README 中。
如果你使用 Flake.parts,你也可以把我的仓库当成 Flake.parts 的模块使用:
{
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nix-kernelsu-builder.url = "github:xddxdd/nix-kernelsu-builder";
};
outputs =
{ flake-parts, ... }@inputs:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
inputs.nix-kernelsu-builder.flakeModules.default
];
systems = [ "x86_64-linux" ];
perSystem =
{ pkgs, ... }:
{
kernelsu = {
# 在此处添加你的内核配置
example-kernel = {
anyKernelVariant = "kernelsu";
clangVersion = "latest";
kernelSU.variant = "next";
susfs = {
enable = true;
src = path/to/sufs/source;
kernelsuPatch = ./patches/susfs-for-kernelsu-next.patch;
};
kernelDefconfigs = [
"gki_defconfig"
"vendor/kalama_GKI.config"
"vendor/ext_config/moto-kalama.config"
"vendor/ext_config/moto-kalama-gki.config"
"vendor/ext_config/moto-kalama-rtwo.config"
];
kernelImageName = "Image";
kernelMakeFlags = [
"KCFLAGS=\"-w\""
"KCPPFLAGS=\"-w\""
];
kernelSrc = path/to/kernel/source;
};
};
};
};
}
关键部分
下文将介绍这套脚本中的一些关键部分。
准备编译器
要编译内核,首先要准备好的就是编译器。编译 Android 内核时常用的编译器是 Clang,但一些老旧设备的内核可能太老,不支持 Clang,此时就需要使用 GCC 编译器。
Clang 编译器很好解决,Nixpkgs 里就有 Clang 的软件包,可以用
nix run nixpkgs#clang -- --version
看到最新的 Clang 版本:
# nix run nixpkgs#clang -- --version
clang version 19.1.6
Target: x86_64-unknown-linux-gnu
Thread model: posix
InstalledDir: /nix/store/2d1r5kvz7plg24bwb316972knqmiyf2p-clang-19.1.6/bin
Clang 编译器本身支持交叉编译,可以直接在 x86_64 的电脑上编译 ARM32、ARM64 的程序,不需要额外的工具链,因此 Nixpkgs 里的 Clang 可以直接使用。
老旧内核的 GCC 就有点麻烦了,Nixpkgs 里早已删除了老旧的 GCC 版本,目前(2025 年 2 月)Nixpkgs 里最旧的 GCC 版本是 9.5.0。而且,默认的 GCC 只能编译同平台的程序,要交叉编译 ARM32/ARM64 的内核需要特殊的 GCC 和工具链。
在这里我选择了取巧的方法:我直接把 Google 提供的 GCC 编译器打包成 Nix 的软件包,提供给编译环境。虽然 Google 已经从服务器上删除了较老的 GCC 编译器,但 GitHub 上有人提供了备份。
ARM32 GCC 的打包结果如下:
{
stdenv,
lib,
fetchFromGitHub,
autoPatchelfHook,
python3,
}:
stdenv.mkDerivation rec {
pname = "gcc-arm-linux-androideabi";
version = "3ecb542702c2ca0e502533c3f6d02f0f06f584f1";
src = fetchFromGitHub {
owner = "KudProject";
repo = "arm-linux-androideabi-4.9";
rev = "3ecb542702c2ca0e502533c3f6d02f0f06f584f1";
fetchSubmodules = false;
sha256 = "sha256-5aF2Pl+h6J8/5TfQf2ojp3FCnoKakWH6KBCkWdy5ho8=";
};
nativeBuildInputs = [ autoPatchelfHook ];
buildInputs = [ python3 ];
installPhase = ''
mkdir -p $out
cp -r * $out/
'';
meta = {
maintainers = with lib.maintainers; [ xddxdd ];
license = lib.licenses.gpl3Plus;
description = "ARM32 GCC for building Android kernels";
platforms = [ "x86_64-linux" ];
};
}
而 ARM64 GCC 的打包结果如下:
{
stdenv,
lib,
fetchFromGitHub,
autoPatchelfHook,
python3,
}:
stdenv.mkDerivation rec {
pname = "gcc-aarch64-linux-android";
version = "5797d7f622321e734558bd3372a9ab5ad6e6a48e";
src = fetchFromGitHub {
owner = "kindle4jerry";
repo = "aarch64-linux-android-4.9-bakup";
rev = "5797d7f622321e734558bd3372a9ab5ad6e6a48e";
fetchSubmodules = false;
sha256 = "sha256-ZrQmFyiDOKg+qcgdpZqtz+LgDDaao2W27kdZZ2As8XU=";
};
nativeBuildInputs = [ autoPatchelfHook ];
buildInputs = [ python3 ];
installPhase = ''
mkdir -p $out
cp -r * $out/
'';
meta = {
maintainers = with lib.maintainers; [ xddxdd ];
license = lib.licenses.gpl3Plus;
description = "ARM64 GCC for building Android kernels";
platforms = [ "x86_64-linux" ];
};
}
获取内核和 KernelSU 源码
有了编译器,下一步就是要获取内核和 KernelSU 的源代码。因为我用的是 LineageOS,我可以直接从 LineageOS 的 GitHub 仓库里下载到内核源码:https://github.com/LineageOS/android_kernel_motorola_sm8550
你也可以去手机厂商的官网上找内核代码。由于 Linux 内核的授权协议是 GPLv2,所有手机厂商都必须把他们修改后的内核代码开源。
在 Nix 包管理器中,你可以用 fetchFromGitHub
函数从 GitHub 上下载内核源码:
fetchFromGitHub {
owner = "LineageOS";
repo = "android_kernel_motorola_sm8550";
rev = "1bdeb4f5c8d2b98ef5f2bedaa5d704032dffd676";
fetchSubmodules = false;
sha256 = "sha256-ZK/DH5N5LdkLe48cANESjw1x74aXoZLFoMAwEDvzEk4=";
};
但这样下载的是 rev
参数指定的 Commit 的内核源码,不会自动更新。要解决这个问题,我们可以用
Nvfetcher
工具,自动获取最新 Commit。首先创建一个 nvfetcher.toml
文件:
[linux-moto-rtwo-lineageos-22_1]
src.git = "https://github.com/LineageOS/android_kernel_motorola_sm8550.git"
src.branch = "lineage-22.1"
fetch.github = "LineageOS/android_kernel_motorola_sm8550"
然后运行 Nvfetcher:nix run github:berberman/nvfetcher
Nvfetcher 会根据你的配置自动下载最新的 Commit,并写入 _sources/generated.nix
文件中。然后你就可以调用这个文件里配置好的内核源码了:
let
sources = callPackage ../_sources/generated.nix { };
in
sources.linux-moto-rtwo-lineageos-22_1.src
我们可以用同样的方法获取 KernelSU 的版本,但由于 KernelSU 从 1.0 开始的版本只支持 GKI 内核,我们只能使用最后一个支持其它内核的 0.9.5 版本:
# nvfetcher.toml
[kernelsu-stable]
src.manual = "v0.9.5"
fetch.git = "https://github.com/tiann/KernelSU.git"
# 我们还需要获取 KernelSU 的 Revision Code(即 Commit 数量)。对于 0.9.5 版本可以直接写死
[kernelsu-stable-revision-code]
src.manual = "11872"
# 下载地址无所谓,我们只用版本号
fetch.url = "https://example.com"
不过现在有一个 KernelSU 的 Fork KernelSU-Next,其最新版本同时支持 GKI 和非 GKI 内核,因此我们可以用它获取最新功能:
# nvfetcher.toml
[kernelsu-next]
src.github = "rifsxd/KernelSU-Next"
fetch.git = "https://github.com/rifsxd/KernelSU-Next.git"
# 从 KernelSU-Next 官方发布的管理器 APK 文件名提取 Commit 数量
[kernelsu-next-revision-code]
src.webpage = "https://api.github.com/repos/rifsxd/KernelSU-Next/releases?per_page=1"
src.regex = "download\\/v[0-9\\._]+\\/KernelSU[^\"]*_([0-9]+)-release\\.apk"
# 下载地址无所谓,我们只用版本号
fetch.url = "https://example.com"
给内核源码打补丁
有了内核源码和 KernelSU,下一步就是按照 KernelSU 的官方教程修改内核。这一步我只是将官方教程的步骤转写成 Bash 脚本放入 Nix 编译脚本中。
唯一需要注意的是,KernelSU 会尝试通过 Git 获取 Commit 数量,也就是你在 KernelSU 管理器里看到的版本号。由于 Nix 包管理器的限制,获取的源码中没有 Git 仓库信息,因此我们需要修改 KernelSU 的脚本,使用我们预先获取的 Commit 数量:
let
# 创建一个假的 git 命令,防止找不到命令出错
fakeGit = writeShellScriptBin "git" ''
exit 0
'';
in
stdenv.mkDerivation {
# ...
nativeBuildInputs = [
fakeGit
];
postPatch = ''
export HOME=$(pwd)
# 把 KernelSU 复制到内核源码文件夹下
cp -r ${kernelSU.src} ${kernelSU.subdirectory}
chmod -R +w ${kernelSU.subdirectory}
# 强制设置 KernelSU 版本,不让它从 Git 获取版本号
sed -i "/ version:/d" ${kernelSU.subdirectory}/kernel/Makefile
sed -i "/KSU_GIT_VERSION not defined/d" ${kernelSU.subdirectory}/kernel/Makefile
sed -i "s|ccflags-y += -DKSU_VERSION=|ccflags-y += -DKSU_VERSION=\"${kernelSU.revision}\"\n#|g" ${kernelSU.subdirectory}/kernel/Makefile
# 将内核编译脚本的 #!/bin/sh 等 Shebang 替换成隔离环境中的路径
patchShebangs .
# 调用 KernelSU 的脚本打补丁
bash ${kernelSU.subdirectory}/kernel/setup.sh
'';
# ...
}
完整的代码可以在 https://github.com/xddxdd/nix-kernelsu-builder/blob/main/pipeline/patch-kernel-src.nix 看到。
(可选)SusFS 补丁
SusFS 是一组额外的内核补丁,它可以隐藏 Root 后对系统的一些文件修改,让他们只对获取了 Root 权限的软件以及系统本身可见,从而防止软件检测 Root 并拒绝启动。
这里也是根据 SusFS 的 README 一步一步走就好:
stdenv.mkDerivation {
# ...
postPatch = ''
export HOME=$(pwd)
# 把 KernelSU 复制到内核源码文件夹下
cp -r ${kernelSU.src} ${kernelSU.subdirectory}
chmod -R +w ${kernelSU.subdirectory}
# 强制设置 KernelSU 版本,不让它从 Git 获取版本号
sed -i "/ version:/d" ${kernelSU.subdirectory}/kernel/Makefile
sed -i "/KSU_GIT_VERSION not defined/d" ${kernelSU.subdirectory}/kernel/Makefile
sed -i "s|ccflags-y += -DKSU_VERSION=|ccflags-y += -DKSU_VERSION=\"${kernelSU.revision}\"\n#|g" ${kernelSU.subdirectory}/kernel/Makefile
# 把 SusFS 复制到内核源码文件夹下
cp -r ${susfs.src}/kernel_patches/fs/* fs/
cp -r ${susfs.src}/kernel_patches/include/linux/* include/linux/
chmod -R +w fs include/linux
# 对内核本身应用 SusFS 的补丁
patch -p1 < ${susfs.kernelPatch}
# 对 KernelSU 应用 SusFS 的补丁
pushd ${kernelSU.subdirectory}
patch -p1 < ${susfs.kernelsuPatch}
popd
# 将内核编译脚本的 #!/bin/sh 等 Shebang 替换成隔离环境中的路径
patchShebangs .
# 调用 KernelSU 的脚本打补丁
bash ${kernelSU.subdirectory}/kernel/setup.sh
'';
# ...
}
完整的代码可以在 https://github.com/xddxdd/nix-kernelsu-builder/blob/main/pipeline/patch-kernel-src.nix 看到。
开启 KernelSU 相关编译选项
添加 KernelSU 补丁后,还需要在内核的 defconfig
中开启相关的选项,保证 KernelSU 功能被加入到编译出的内核中:
# 指定 defconfig 的路径
export CFG_PATH=arch/${arch}/configs/${defconfig}
# 如果启用了 KernelSU
echo "CONFIG_MODULES=y" >> $CFG_PATH
echo "CONFIG_KPROBES=y" >> $CFG_PATH
echo "CONFIG_HAVE_KPROBES=y" >> $CFG_PATH
echo "CONFIG_KPROBE_EVENTS=y" >> $CFG_PATH
echo "CONFIG_OVERLAY_FS=y" >> $CFG_PATH
echo "CONFIG_KSU=y" >> $CFG_PATH
# 如果启用了 SusFS
echo "CONFIG_KSU_SUSFS=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_HAS_MAGIC_MOUNT=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_SUS_PATH=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_SUS_MOUNT=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_AUTO_ADD_SUS_KSU_DEFAULT_MOUNT=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_AUTO_ADD_SUS_BIND_MOUNT=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_SUS_KSTAT=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_SUS_OVERLAYFS=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_TRY_UMOUNT=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_AUTO_ADD_TRY_UMOUNT_FOR_BIND_MOUNT=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_SPOOF_UNAME=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_ENABLE_LOG=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_HIDE_KSU_SUSFS_SYMBOLS=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_SPOOF_CMDLINE_OR_BOOTCONFIG=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_OPEN_REDIRECT=y" >> $CFG_PATH
echo "CONFIG_KSU_SUSFS_SUS_SU=y" >> $CFG_PATH
echo "CONFIG_TMPFS_XATTR=y" >> $CFG_PATH
完整命令可以在 https://github.com/xddxdd/nix-kernelsu-builder/blob/main/pipeline/kernel-config-cmd.nix 看到。
编译内核
下一步就是用打过补丁的内核源码编译出内核镜像。
如果使用 GCC 作为编译器,需要将 Google 提供的编译器加入编译环境,并在编译参数中指定编译器命令前缀即可:
let
gcc-aarch64-linux-android = pkgs.callPackage ../pkgs/gcc-aarch64-linux-android.nix { };
gcc-arm-linux-androideabi = pkgs.callPackage ../pkgs/gcc-arm-linux-androideabi.nix { };
# 稍后传给 make 命令
finalMakeFlags = [
"ARCH=${arch}"
"CROSS_COMPILE=aarch64-linux-android-"
"CROSS_COMPILE_ARM32=arm-linux-androideabi-"
"O=$out"
];
in
stdenv.mkDerivation {
# ...
nativeBuildInputs = [
gcc-aarch64-linux-android
gcc-arm-linux-androideabi
];
# ...
}
完整命令可以在 https://github.com/xddxdd/nix-kernelsu-builder/blob/main/pipeline/build-kernel-gcc.nix 看到。
如果使用 Clang 作为编译器,可以直接使用 Nixpkgs 提供的 Clang
stdenv,并在编译参数中指定使用 LLVM 和 lld
:
let
# 稍后传给 make 命令
finalMakeFlags = [
"ARCH=${arch}"
"CC=clang"
"O=$out"
"LD=ld.lld"
"LLVM=1"
"LLVM_IAS=1"
"CLANG_TRIPLE=aarch64-linux-gnu-"
] ++ makeFlags;
# 使用用户指定的 Clang/LLVM 版本
usedLLVMPackages = pkgs."llvmPackages_${builtins.toString clangVersion}";
in
# 使用 Clang/LLVM 的 stdenv,自带了 Clang/LLVM 工具链
usedLLVMPackages.stdenv.mkDerivation {
# ...
nativeBuildInputs = [
# 添加 ld.lld 命令
usedLLVMPackages.bintools
];
# ...
}
完整命令可以在 https://github.com/xddxdd/nix-kernelsu-builder/blob/main/pipeline/build-kernel-clang.nix 看到。
生成 AnyKernel 刷机包
AnyKernel 是一个 Android 刷机包模板,可以将给定的内核镜像刷入手机中。AnyKernel 的一大优势是它可以只刷内核镜像,不修改 Initramfs 中的其它启动命令,包括 Android 系统的启动命令和 Magisk 的命令(如果安装了的话)。
AnyKernel 本身的使用非常简单:只需要根据手机的情况修改 anykernel.sh
中的几个参数,然后把内核文件和 AnyKernel 的其它文件放在一起打成 zip
压缩包即可。
唯一需要注意的是,原版的 AnyKernel 只支持非 GKI 的设备,在支持 GKI 的设备上会报错。KernelSU 团队提供了一个修改的 AnyKernel,它和原版正相反,只支持 GKI 设备,在非 GKI 设备上会报错。根据你的设备选择即可。
我把两个版本的 AnyKernel 都放入了 nvfetcher.toml
以供调用:
[anykernel-kernelsu]
src.git = "https://github.com/Kernel-SU/AnyKernel3.git"
fetch.github = "Kernel-SU/AnyKernel3"
[anykernel-osm0sis]
src.git = "https://github.com/osm0sis/AnyKernel3.git"
fetch.github = "osm0sis/AnyKernel3"
完整的打包代码可以在 https://github.com/xddxdd/nix-kernelsu-builder/blob/main/pipeline/build-anykernel-zip.nix 看到。
(可选)生成 boot.img 镜像
如果你不想/不能使用 AnyKernel,例如你的设备没有第三方 Recovery 可用,你可以找一份你的设备的
boot.img
镜像。只要把镜像中的内核替换掉,并保持其它部分不变,也可以达成和 AnyKernel 一样的效果。
# 记录原设备 boot.img 镜像的参数
IMG_FORMAT=$(unpack_bootimg --boot_img ${bootImg} --format mkbootimg)
echo "Image format: \"$IMG_FORMAT\""
# 解压 boot.img
unpack_bootimg --boot_img ${bootImg}
# 用新编译出的内核替换原始内核
cp ${kernel}/arch/${arch}/boot/${kernelImageName} out/kernel
# 用原参数重新打包一份带新内核的 boot.img
eval "mkbootimg $IMG_FORMAT -o $out/boot.img"
完整的打包代码可以在 https://github.com/xddxdd/nix-kernelsu-builder/blob/main/pipeline/build-boot-img.nix 看到。