Breaking free from Bazel in the Pixel kernel pipeline
When I first got my Pixel 7 in March of this year, I didn't know what I'd be signing up for in the future.
I used to just use the prebuilt kernel trees provided by Google for building my ROMs because I found it convenient. Nonetheless, cyberknight777, a kernel developer, and a good friend of mine, got me to finally start building a custom kernel for the device.
And when I finally started on around June of this year, we stumbled upon tons of unexpected design choices. This blog covers our journey of building the gs201 kernel with just Make and a build script, and throwing Google's bazel in the bin.
The very start
We didn't do much, so we just cloned the specific tag of the ACK (Android Common Kernel) for
the device (android-gs-pantah-6.1) from Google's source, and started the build with the default build scripts that Google
gives its users - build_pantah.sh
What I noticed was that Google makes it looks like an AOSP build, it compresses verbose messages like you see while building the Kernel in pure make, and just acts as a script that does some magic in the background and prints some messages.
We instantly found this to be holding us back from our freedom. So I ran the following commands manually:
1. ARCH=arm64 make LLVM=/home/sidharthify/kernel/prebuilts/clang/host/linux-x86/clang-r487747c/bin/ O=out gki_defconfig
2. ARCH=arm64 make -j$(nproc) LLVM=/home/sidharthify/kernel/prebuilts/clang/host/linux-x86/clang-r487747c/bin/ O=out Image.lz4
And there we have it, we started the build with make. And no, this isn't the end, not even close.
I used this clang version because it corresponds to the version used in stock, we can see this by expanding the kernel info inside about phone.
After this, we just expanded on the script to build the modules as well. Modules, modules, oh modules... This will be the most important talking point of this whole blog.
Either way, the next thing we needed to do was build the modules as well. Because of course, this is a modern GKI (Generic Kernel Image) Android kernel. Heavy words, eh?
Generic Kernel Image
GKI does not mean that the kernel is smaller, simpler, or “minimal” in the usual sense. It means that the kernel is no longer fragmented.
Before GKI, the Android kernel stack looked roughly like this: Linux Stable → Android Common Kernel → Vendor Kernel → OEM Kernel. Each layer carried its own patches, its own ABI expectations, and its own long-term maintenance burden.
GKI collapses that stack.
With GKI, there is a single, Google-maintained core kernel image that is shared across devices. This kernel is generic in the literal sense: it contains support for a wide range of hardware and use cases, rather than being tightly tailored to a specific device.
Device-specific functionality is no longer implemented by modifying the core kernel. Instead, it is delivered through loadable kernel modules.
Storage drivers, WiFi, Bluetooth, NFC, display drivers, fingerprint drivers, and large parts of SoC-specific logic all live outside the core image as modules.
This is the key tradeoff of GKI. The core kernel is unified, but the system as a whole becomes more modular, more policy-driven, and more sensitive to how modules are built, installed, signed, and loaded.
Building the kernel image with make was only half the job.
The real work began when we had to replicate how Google builds, installs,
signs, and validates kernel modules across
system_dlkm, vendor_dlkm, and
vendor_kernel_boot.
From this point on, most failures were no longer obvious boot failures. They manifested as silent breakages, missing functionality, modules refusing to load, and VINTF rejecting the build outright.
Building the modules
We started by declaring the directories of the modules we want: (for now, this logic was heavily modified later on)
EXT_MODULES="private/google-modules/amplifiers/snd_soc_wm_adsp \
private/google-modules/amplifiers/cs35l41 \
private/google-modules/amplifiers/cs35l45 \
private/google-modules/amplifiers/cs40l25 \
private/google-modules/amplifiers/cs40l26 \
private/google-modules/amplifiers/drv2624 \
private/google-modules/amplifiers/tas256x \
private/google-modules/amplifiers/tas25xx \
private/google-modules/amplifiers/audiometrics \
private/google-modules/aoc \
private/google-modules/aoc/alsa \
private/google-modules/aoc/usb \
private/google-modules/bluetooth/broadcom \
private/google-modules/gps/broadcom/bcm47765 \
private/google-modules/gpu/mali_kbase \
private/google-modules/gpu/borr_mali_kbase \
private/google-modules/soc/gs/drivers/soc/google/s2mpu \
private/google-modules/soc/gs/drivers/soc/google/pkvm-s2mpu/common/hyp \
private/google-modules/soc/gs/drivers/soc/google/pkvm-s2mpu/pkvm-s2mpu \
private/google-modules/soc/gs/drivers/soc/google/pkvm-s2mpu/pkvm-s2mpu-v9 \
private/google-modules/soc/gs/drivers/soc/google/pt \
private/google-modules/soc/gs/drivers/soc/google/smra \
private/google-modules/soc/gs/drivers/soc/google/vh/kernel \
private/google-modules/soc/gs/drivers/soc/google/vh/kernel/cgroup \
private/google-modules/soc/gs/drivers/soc/google/vh/kernel/fs \
private/google-modules/soc/gs/drivers/soc/google/vh/kernel/metrics \
private/google-modules/soc/gs/drivers/soc/google/vh/kernel/mm \
private/google-modules/soc/gs/drivers/soc/google/vh/kernel/pixel_em \
private/google-modules/soc/gs/drivers/soc/google/vh/kernel/sched \
private/google-modules/soc/gs/drivers/soc/google/vh/kernel/thermal \
private/google-modules/soc/gs/drivers/spi \
private/google-modules/soc/gs/drivers/spmi \
private/google-modules/soc/gs/drivers/thermal \
private/google-modules/soc/gs/drivers/thermal/google \
private/google-modules/soc/gs/drivers/thermal/samsung \
private/google-modules/soc/gs/drivers/tty/serial \
private/google-modules/soc/gs/drivers/ufs \
private/google-modules/soc/gs/drivers/usb \
private/google-modules/soc/gs/drivers/usb/dwc3 \
private/google-modules/soc/gs/drivers/usb/gadget \
private/google-modules/soc/gs/drivers/usb/gadget/function \
private/google-modules/soc/gs/drivers/usb/host \
private/google-modules/soc/gs/drivers/usb/typec \
private/google-modules/soc/gs/drivers/usb/typec/tcpm \
private/google-modules/soc/gs/drivers/usb/typec/tcpm/google \
private/google-modules/soc/gs/drivers/video/backlight \
private/google-modules/soc/gs/drivers/watchdog \
private/google-modules/trusty \
private/google-modules/trusty/drivers/trusty \
private/google-modules/uwb/qorvo/dw3000/kernel \
private/google-modules/uwb/qorvo/qm35/qm35s \
private/google-modules/video/gchips \
private/google-modules/wlan/bcm4383 \
private/google-modules/wlan/bcm4389 \
private/google-modules/wlan/bcm4390 \
private/google-modules/wlan/bcm4398 \
private/google-modules/wlan/dhd43752p \
private/google-modules/wlan/wcn6740/cnss2 \
private/google-modules/wlan/wcn6740/wlan/qcacld-3.0 \
private/google-modules/wlan/wcn6740/wlan/qca-wifi-host-cmn/iot_sim \
private/google-modules/wlan/wcn6740/wlan/qca-wifi-host-cmn/qdf \
private/google-modules/wlan/wcn6740/wlan/qca-wifi-host-cmn/spectral \
private/google-modules/wlan/wlan_ptracker \
private/google-modules/sensors/hall_sensor \
private/google-modules/touch/synaptics/syna_c10 \
private/google-modules/touch/synaptics/syna_gtd \
private/google-modules/touch/focaltech/ft3658 \
private/google-modules/touch/focaltech/ft3683u \
private/google-modules/touch/fts/fst2 \
private/google-modules/touch/fts/ftm5_legacy \
private/google-modules/touch/fts/ftm5 \
private/google-modules/touch/goodix \
private/google-modules/touch/novatek/nt36xxx \
private/google-modules/touch/common \
private/google-modules/touch/common/usi \
private/google-modules/touch/sec \
private/google-modules/power/reset \
private/google-modules/power/mitigation \
private/google-modules/misc/sscoredump"
Yeah, long list. But either way, these were pulled directly from android.googlesource.com , by cloning the directories into a seperate folder outside the kernel.
After defining these, we build the modules roughly like so:
for EXT_MOD in ${EXT_MODULES}; do
ABS_EXT_MOD="${ROOT_DIR}/${EXT_MOD}"
EXT_MOD_REL=$(rel_path "${ABS_EXT_MOD}" "${KERNEL_DIR}")
mkdir -p "${OUT_DIR}/${EXT_MOD_REL}"
set -x
make -C "${ABS_EXT_MOD}" M="${EXT_MOD_REL}" KERNEL_SRC="${KERNEL_DIR}" \
O="${OUT_DIR}" "${TOOL_ARGS[@]}" "${MAKE_ARGS[@]}" modules_install
make -C "${ABS_EXT_MOD}" M="${EXT_MOD_REL}" KERNEL_SRC="${KERNEL_DIR}" \
O="${OUT_DIR}" "${TOOL_ARGS[@]}" ${MODULE_STRIP_FLAG} \
INSTALL_MOD_PATH="${MODULES_STAGING_DIR}" \
INSTALL_HDR_PATH="${KERNEL_UAPI_HEADERS_DIR}/usr" \
"${MAKE_ARGS[@]}" modules_install
set +x
done
This... well, I later realized that this wasn't the best method to do this. But for now, I was getting my kernel to be built with the modules, so I was happy enough.
Why this approach was flawed
At first glance, this felt like the correct way to do things.
Google keeps most Pixel specific code under
private/google-modules/, outside the core kernel directories.
Bazel treats these as separate units. The natural assumption, then, was
that these modules needed to be built separately as well.
That assumption was wrong.
From Kbuild’s point of view, there is no meaningful distinction between
“kernel code” and “Google modules”. Anything that lives inside the kernel
source tree and contains proper Kbuild files is simply part of
the kernel build graph.
By treating these directories as external modules, I was effectively fighting Kbuild instead of using it.
The long EXT_MODULES list, the manual loop, the relative-path
juggling, all of that existed to maintain an artificial boundary that the
kernel build system does not recognize.
This worked, but only in the sense that forcing things to work often does.
What actually needed to happen
Once I understood this, the solution became obvious.
Instead of treating Google’s modules as out-of-tree components, they should be built the same way every other kernel module is built: as part of a single, unified kernel tree.
In practice, this meant removing the explicit
EXT_MODULES list entirely and letting Kbuild discover modules
through its normal obj-m and obj-y relationships.
Instead of invoking make modules separately for each directory,
the entire module set could be built in a single pass, with all modules
staged through one unified modules_install step.
In other words, stop telling Kbuild what to build, and let it do what it was designed to do.
The refactor
The script no longer loops over module directories or calls
make -C on individual paths. Instead, it builds the kernel and
all associated modules in one pass:
ROOT_DIR="/home/sidharthify/kernel"
KERNEL_DIR="${ROOT_DIR}/aosp"
LLVM_DIR="${ROOT_DIR}/prebuilts/clang/host/linux-x86/clang-r487747c/bin/"
OUT_DIR="${KERNEL_DIR}/out"
MODULES_STAGING_DIR="${OUT_DIR}/modules_staging"
TOOL_ARGS=(LLVM="${LLVM_DIR}")
# enter kernel tree
cd "${KERNEL_DIR}"
# build kernel image
make -j96 "${TOOL_ARGS[@]}" gs201_defconfig
make -j96 "${TOOL_ARGS[@]}" Image.lz4
# build in-kernel modules
make -j96 "${TOOL_ARGS[@]}" modules
make -j96 "${TOOL_ARGS[@]}" INSTALL_MOD_PATH="${MODULES_STAGING_DIR}" modules_install
This dramatically simplified the script, removing more than a hundred lines of fragile glue logic, and aligning the build flow with how upstream Kbuild expects to operate.
More importantly, it removed an entire class of subtle failures.
And?
This pattern repeated itself... a lot of times.
Google’s tooling makes the Pixel kernel look more complex than it actually is. Bazel enforces boundaries that are convenient for Google’s internal build infrastructure, but those boundaries do not exist at the kernel level.
Once the tooling is stripped away, what remains is a conventional (android) Linux kernel tree with conventional rules.
Finishing the script up
Up to this point, the kernel technically worked. It built, it booted, and the modules existed. But this was still not a real kernel in the Android sense.
What Bazel had been quietly handling all along was not just compilation,
but integration. Modules were not meant to simply exist as .ko
files copied into a directory. They needed to be sorted, stripped, grouped
into the correct partitions, indexed properly, and finally packaged into
filesystem images that Android actually understands.
Cleaning Up the Old Assumptions
The first visible change was simple: instead of trying to
update individual .ko files in an existing prebuilt tree,
the script now deletes all previously staged modules and copies over
everything that was just built.
This matters more than it sounds. Kernel modules are tightly coupled to the exact kernel image they are built against. Allowing old modules to linger creates “zombie” mismatches that are extremely hard to debug. Starting from a clean slate every time makes the result deterministic.
Introducing Proper DLKM Partitioning
#####
#####
# pack images
#####
#####
echo "packing images..."
# erofs for dlkms
mkfs.erofs -z lz4hc "${OUT_DIR}/vendor_dlkm.img" "${DLKM_STAGING}/vendor_dlkm"
mkfs.erofs -z lz4hc "${OUT_DIR}/system_dlkm.img" "${DLKM_STAGING}/system_dlkm"
# combine dtbs
DTB_PATHS=("${OUT_DIR}/arch/arm64/boot/dts/google/gs201" "${OUT_DIR}/google-devices/gs201/dts" "${OUT_DIR}/arch/arm64/boot/dts/google")
DTB_SOURCE=""
for path in "${DTB_PATHS[@]}"; do
if [ -d "$path" ] && compgen -G "$path/*.dtb" > /dev/null; then DTB_SOURCE="$path"; break; fi
done
cat "${DTB_SOURCE}"/*.dtb > "${OUT_DIR}/combined.dtb"
# generate dtbo
if [ -f "${MKDTBOIMG}" ]; then
DTBO_FILES=$(find "${DTB_SOURCE}" -name "*.dtbo")
if [ -n "${DTBO_FILES}" ]; then python3 "${MKDTBOIMG}" create "${OUT_DIR}/dtbo.img" ${DTBO_FILES}; fi
fi
# vendor ramdisk and boot image
( cd "${DLKM_STAGING}/vendor_kernel_boot" && find . | cpio -H newc -o 2>/dev/null | lz4 -l -12 --favor-decSpeed > "${OUT_DIR}/vendor_ramdisk.cpio.lz4" )
python3 "${MKBOOTIMG}" --vendor_ramdisk "${OUT_DIR}/vendor_ramdisk.cpio.lz4" --dtb "${OUT_DIR}/combined.dtb" --header_version 4 --vendor_boot "${OUT_DIR}/vendor_kernel_boot.img"
Modern Android kernels do not load all modules from a single location. Instead, modules are split across three partitions, each with different expectations and lifetimes:
vendor_kernel_boot contains modules that must be available
extremely early in the boot process. These are modules that need to be
loadable in recovery and during first-stage init, before the full userspace
is up.
On Pixel devices, modules that previously lived in
vendor_boot were moved into
vendor_kernel_boot.
Google does not document the exact reason for this change. It appears to be a Pixel-specific design decision related to boot flow and tooling, rather than a general Android requirement.
What makes this evolution clearer is how it unfolded across Pixel generations.
On the Pixel 6 series, Google shipped multiple
vendor_ramdisk fragments: a default fragment and a
dlkm fragment, with kernel modules stored in the latter.
This design allowed kernel modules to be separated from the rest of
vendor_boot.
However, vendor ramdisk fragments are treated as independently replaceable by flashing tools, which made it easy to accidentally remove required fragments and break early boot.
Starting with Pixel 7, Google stopped placing modules in
vendor_ramdisk entirely and introduced a dedicated
vendor_kernel_boot partition. This isolated early-boot modules
from ramdisk fragment handling and removed an entire category of failure
modes.
vendor_dlkm contains vendor-owned hardware modules. These are
SoC and device-specific drivers supplied by the vendor and loaded
dynamically at runtime, outside of the early boot path.
system_dlkm contains core GKI modules. These modules are treated
as an extension of the kernel itself under the GKI model. They are expected
to remain untouched by OEMs and are governed by Google’s kernel and KMI
policy, rather than vendor customization.
Bazel encodes this logic implicitly. Without it, the rules have to be made explicit.
The partition assignment is driven by an explicit priority order.
Modules listed for vendor_kernel_boot are placed there
unconditionally. If a module is not listed there, it is then evaluated
against vendor_dlkm. Any remaining modules fall back to
system_dlkm.
Stripping, Staging, and Dependency Resolution
Every module is stripped of debug symbols before being staged. This is not strictly required by Android, but it is the standard practice used by Google and most downstream kernel builders.
Unstripped modules are significantly larger, often ballooning to hundreds of megabytes, which is undesirable for production images. Stripping keeps module size reasonable and avoids unnecessary bloat in DLKM partitions.
Once modules are placed into their respective partition trees, the script
runs depmod separately for each partition root. This step is
critical.
Its job is to generate the metadata required for the kernel to understand the dependency structure of kernel modules at runtime.
This metadata is what allows the kernel and userspace tooling to determine load order, resolve symbol requirements, and handle inter-module relationships correctly.
Running it per-partition ensures that dependency information is generated against the exact set of modules visible within that partition root. This allows the kernel to correctly resolve inter-module dependencies at runtime, including cross-partition references, without leaking assumptions from unrelated module trees.
To make this work, the script temporarily provides depmod
with the same metadata files Bazel would normally supply:
modules.builtin, modules.order, and
System.map.
Once dependency generation is complete, all temporary artifacts are removed, leaving behind a clean, self-contained module tree.
Finally, each DLKM tree is packaged into an EROFS image using
mkfs.erofs, and the vendor_kernel_boot
is packaged with mkbootimg.
Building the kernel binary into AOSP
Now that the prebuilt kernel directory was built, I had to finally start up a build and get my phone booted, right? Well, yes, but here's where it gets good.
The kernel built. The images were picked up correctly. The build made it much further than before.
And then it failed.
Not with a compiler error, not with a missing module. But with a hard stop from VINTF.
VINTF, or the Vendor Interface, is Android’s way of enforcing compatibility between the kernel, vendor components, and userspace. It does not care whether your kernel boots. It cares whether your kernel is allowed to boot.
The error was exactly like so:
2025-12-12 07:53:36 - check_target_files_vintf.py - INFO : stderr: ERROR: files are incompatible: Runtime info and framework compatibility matrix are incompatible: No kernel entry found for kernel version 6.1 at kernel FCM version 6. The following kernel requirements are checked:
Minimum LTS: 4.19.191, kernel FCM version: 6
Minimum LTS: 4.19.191, kernel FCM version: 6, with conditionals
Minimum LTS: 4.19.191, kernel FCM version: 6, with conditionals
Minimum LTS: 4.19.191, kernel FCM version: 6, with conditionals
Minimum LTS: 4.19.191, kernel FCM version: 6, with conditionals
Minimum LTS: 4.19.191, kernel FCM version: 6, with conditionals
Minimum LTS: 4.19.191, kernel FCM version: 6, with conditionals
Minimum LTS: 4.19.191, kernel FCM version: 6, with conditionals
Minimum LTS: 5.4.86, kernel FCM version: 6
Minimum LTS: 5.4.86, kernel FCM version: 6, with conditionals
Minimum LTS: 5.4.86, kernel FCM version: 6, with conditionals
Minimum LTS: 5.4.86, kernel FCM version: 6, with conditionals
Minimum LTS: 5.4.86, kernel FCM version: 6, with conditionals
Minimum LTS: 5.4.86, kernel FCM version: 6, with conditionals
Minimum LTS: 5.4.86, kernel FCM version: 6, with conditionals
Minimum LTS: 5.4.86, kernel FCM version: 6, with conditionals
Minimum LTS: 5.4.86, kernel FCM version: 6, with conditionals
Minimum LTS: 5.10.43, kernel FCM version: 6
Minimum LTS: 5.10.43, kernel FCM version: 6, with conditionals
Minimum LTS: 5.10.43, kernel FCM version: 6, with conditionals
Minimum LTS: 5.10.43, kernel FCM version: 6, with conditionals
Minimum LTS: 5.10.43, kernel FCM version: 6, with conditionals
Minimum LTS: 5.10.43, kernel FCM version: 6, with conditionals
Minimum LTS: 5.10.43, kernel FCM version: 6, with conditionals
Minimum LTS: 5.10.43, kernel FCM version: 6, with conditionals
Minimum LTS: 5.10.43, kernel FCM version: 6, with conditionals
Minimum LTS: 5.10.107, kernel FCM version: 7
Minimum LTS: 5.10.107, kernel FCM version: 7, with conditionals
Minimum LTS: 5.10.107, kernel FCM version: 7, with conditionals
Minimum LTS: 5.10.107, kernel FCM version: 7, with conditionals
Minimum LTS: 5.10.107, kernel FCM version: 7, with conditionals
Minimum LTS: 5.10.107, kernel FCM version: 7, with conditionals
Minimum LTS: 5.10.107, kernel FCM version: 7, with conditionals
Minimum LTS: 5.10.107, kernel FCM version: 7, with conditionals
Minimum LTS: 5.10.107, kernel FCM version: 7, with conditionals
Minimum LTS: 5.10.107, kernel FCM version: 7, with conditionals
Minimum LTS: 5.10.107, kernel FCM version: 7, with conditionals
Minimum LTS: 5.15.41, kernel FCM version: 7
Minimum LTS: 5.15.41, kernel FCM version: 7, with conditionals
Minimum LTS: 5.15.41, kernel FCM version: 7, with conditionals
Minimum LTS: 5.15.41, kernel FCM version: 7, with conditionals
Minimum LTS: 5.15.41, kernel FCM version: 7, with conditionals
Minimum LTS: 5.15.41, kernel FCM version: 7, with conditionals
Minimum LTS: 5.15.41, kernel FCM version: 7, with conditionals
Minimum LTS: 5.15.41, kernel FCM version: 7, with conditionals
Minimum LTS: 5.15.41, kernel FCM version: 7, with conditionals
Minimum LTS: 5.15.41, kernel FCM version: 7, with conditionals
Minimum LTS: 5.15.41, kernel FCM version: 7, with conditionals
Minimum LTS: 5.15.0, kernel FCM version: 8
Minimum LTS: 5.15.0, kernel FCM version: 8, with conditionals
Minimum LTS: 5.15.0, kernel FCM version: 8, with conditionals
Minimum LTS: 5.15.0, kernel FCM version: 8, with conditionals
Minimum LTS: 5.15.0, kernel FCM version: 8, with conditionals
Minimum LTS: 5.15.0, kernel FCM version: 8, with conditionals
Minimum LTS: 5.15.0, kernel FCM version: 8, with conditionals
Minimum LTS: 5.15.0, kernel FCM version: 8, with conditionals
Minimum LTS: 5.15.0, kernel FCM version: 8, with conditionals
Minimum LTS: 5.15.0, kernel FCM version: 8, with conditionals
Minimum LTS: 5.15.0, kernel FCM version: 8, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 8
Minimum LTS: 6.1.0, kernel FCM version: 8, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 8, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 8, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 8, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 8, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 8, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 8, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 8, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 8, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 8, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 202404
Minimum LTS: 6.1.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.1.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.6.0, kernel FCM version: 202404
Minimum LTS: 6.6.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.6.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.6.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.6.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.6.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.6.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.6.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.6.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.6.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.6.0, kernel FCM version: 202404, with conditionals
Minimum LTS: 6.12.0, kernel FCM version: 202504
Minimum LTS: 6.12.0, kernel FCM version: 202504, with conditionals
Minimum LTS: 6.12.0, kernel FCM version: 202504, with conditionals
Minimum LTS: 6.12.0, kernel FCM version: 202504, with conditionals
Minimum LTS: 6.12.0, kernel FCM version: 202504, with conditionals
Minimum LTS: 6.12.0, kernel FCM version: 202504, with conditionals
Minimum LTS: 6.12.0, kernel FCM version: 202504, with conditionals
Minimum LTS: 6.12.0, kernel FCM version: 202504, with conditionals
Minimum LTS: 6.12.0, kernel FCM version: 202504, with conditionals
Minimum LTS: 6.12.0, kernel FCM version: 202504, with conditionals
Minimum LTS: 6.12.0, kernel FCM version: 202504, with conditionals: Success
The Apparent Contradiction
On paper, the failure made sense. The Pixel 7 ships with API level 33. API level 33 maps to FCM level 7. FCM level 7 officially supports kernels up to 5.15.
But the kernel I was building was 6.1.141.
From VINTF’s perspective, this should never work. Except it clearly does.
Pixel 7 runs 6.1 kernels on the exact same API and FCM levels. There was no experimental configuration here. This was not undefined behavior. The stock kernel itself was 6.1.124.
Which meant one thing: the rules we were looking at were incomplete.
What was actually happening
At this point, we stopped trying to "fix" the error and started to trace it instead.
We dug through the VINTF validation logic and the code paths responsible for determining the effective kernel compatibility level. We even edited the code of the file that checks for the kernel requirements with VINTF and added more debugging. What we found was not a configuration flag, a manifest override, or a device-specific exception.
It was a heuristic.
Google’s build logic does not rely solely on the numeric kernel version.
It also inspects the kernel release string. When a specific prefix (android14-11) is
detected, the FCM kernel level is silently adjusted during
validation.
// To support devices without GKI, RuntimeInfo::fetchAllInformation does not report errors
// if kernel level cannot be retrieved. If so, fetch kernel FCM version from device HAL
// manifest and store it in RuntimeInfo too.
if (flags & RuntimeInfo::FetchFlag::KERNEL_FCM) {
Level deviceManifestKernelLevel = Level::UNSPECIFIED;
auto manifest = getDeviceHalManifest();
if (manifest) {
deviceManifestKernelLevel = manifest->inferredKernelLevel();
}
if (deviceManifestKernelLevel != Level::UNSPECIFIED) {
Level kernelLevel = mDeviceRuntimeInfo.object->kernelLevel();
if (kernelLevel == Level::UNSPECIFIED) {
mDeviceRuntimeInfo.object->setKernelLevel(deviceManifestKernelLevel);
} else if (kernelLevel != deviceManifestKernelLevel) {
LOG(WARNING) << "uname() reports kernel level " << kernelLevel
<< " but device manifest sets kernel level "
<< deviceManifestKernelLevel << ". Using kernel level " << kernelLevel;
}
}
}
Snippet from VintfObject.cpp#671
In other words, compatibility was being inferred from metadata, not declared explicitly.
My custom kernel was functionally identical to Google’s, but it did not carry the expected release prefix. Because of that, VINTF did not recognize it as a Google-uprevved kernel.
Once that heuristic failed to trigger, VINTF treated the kernel like it would on any other device: it validated it strictly against the FCM level declared by the device configuration, without applying any Google-specific exceptions.
On Pixel devices, those exceptions exist for a reason. The Pixel 6 and 7 families were ported from older kernels to 6.1 late in their lifecycle. To make that work, Google relaxed the usual FCM checks when a known release signature is detected.
My kernel did not match that signature, so the exception was never applied. As a result, VINTF enforced the standard FCM rules, and the build failed.
Why this was so hard to see
This behavior is not documented.
Nothing in the manifests explains it. Nothing in the device configuration hints at it. If you approach the problem assuming the system is purely declarative, you will never find the answer.
The only way to uncover it is to read the code and observe how the system behaves when the metadata changes.
Once the prefix was restored and the kernel release string matched what Pixel devices expect, the VINTF error disappeared entirely.
Getting the kernel to fully boot
Now, everything was nearly done. I wasn't relying on bazel at all, and it was building normally. All I needed to check now, was whether it actually boots or not.
Well, not everything can go well, so no, it didn't. For a very specific reason. This actually completely hardbricked my phone too, and I had to format it and downgrade to Android 16 QPR0.
The reason turned out to be subtle, deeply tied to how GKI works, and completely invisible until runtime.
GKI, DLKM, and Why Modules Are Special
Under the GKI model, the kernel image and device-specific functionality are deliberately split.
The kernel image itself is generic and shared across devices, while most
hardware and feature-specific logic is delivered through loadable kernel
modules. These modules live on separate dynamic partitions inside the super
image, most notably system_dlkm and vendor_dlkm.
This split is the core idea behind GKI: allow the kernel image to evolve independently from vendor-specific components, without forcing every OEM to ship a fully custom kernel.
It is important to distinguish between different kinds of modules.
GKI modules are the modules placed in
system_dlkm. These are considered part of the GKI surface
itself and are treated as an extension of the kernel image. They are built
and signed by Google and are expected to follow the GKI Kernel Module
Interface (KMI) guarantees for a given release.
Because of this, the kernel image and its corresponding GKI modules are tightly coupled. While GKI exists to provide a stable interface, that stability is scoped and versioned. A kernel image expects a matching set of GKI modules built against the same KMI generation.
vendor_dlkm modules are different. These are vendor-owned,
device-specific drivers. They are not part of the GKI surface and do not
benefit from the same compatibility guarantees. In practice, they must be
built against the exact kernel they are intended to run on.
This is where many assumptions break down.
GKI allows the kernel image and GKI modules to be updated as a coherent, Google-controlled unit, while vendor modules remain tightly bound to the kernel they were built against. Android may store these pieces on different partitions, but at runtime the dependencies are very real.
Cross-Partition dependencies
Another important detail is that modules are not isolated by partition.
Modules in vendor_dlkm are allowed to depend on modules in
system_dlkm. These dependencies are recorded in
modules.dep during the build.
At runtime, when a vendor module is loaded, the kernel automatically
resolves these dependencies and loads the required GKI modules from
system_dlkm.
This only works if dependency metadata is correct and the modules can actually be loaded by the kernel.
Early module load errors
The first sign of trouble appeared very early during boot from pstore:
[ 0.408049][ T1] init: Failed to load kernel modules
Digging deeper revealed a specific failure:
[ 0.382399][ T122] init: Failed to insmod '/lib/modules/ufs-pixel-fips140.ko'
with args 'fips_first_lba=86406 fips_last_lba=86917 fips_lu=0 use_hw_keys=true':
Exec format error
This module is extremely sensitive and tightly coupled to the kernel
configuration. Disabling it via
CONFIG_SCSI_UFS_PIXEL_FIPS140=n allowed it to get
further in the boot process.
The device now booted, but major subsystems were missing.
It Booted, But Nothing Worked
WiFi, Bluetooth, NFC, UWB, and USB networking were all broken.
A quick lsmod showed that many critical modules were simply
not loaded, even though they had been built correctly.
Attempting to load them manually resulted in a flood of errors like:
bcmdhd4389: Unknown symbol cfg80211_register_netdevice (err -2)
bcmdhd4389: Unknown symbol rfkill_alloc (err -2)
bluetooth: Unknown symbol rfkill_register (err -2)
These errors are extremely specific. They do not indicate missing code. They indicate that the kernel refuses to link the module at load time.
Even more telling was this:
insmod rfkill.ko
insmod: failed to load rfkill.ko: Permission denied
Module signing
At this point, it became clear to cyberknight777.
The modules were present. Their dependencies were correct. The symbols existed in the kernel. But the kernel was refusing to load them.
On Android, core GKI kernel modules (system_dlkm) are not just built. They are signed.
The kernel enforces module signature verification. Any module that is not signed with the expected key is rejected at load time. This failure surfaces indirectly as “unknown symbol” errors or permission failures.
Bazel normally handles this silently.
By replacing Bazel, I had also removed the step that signs every module with the kernel’s build-time key pair.
Re-Signing the modules
The fix was straightforward once we understood the mechanisms
After staging the modules into their respective DLKM trees, every
.ko needed to be signed using the kernel’s own signing tools
and keys.
# re-sign modules
SIGN_FILE="${OUT_DIR}/scripts/sign-file"
SIGN_KEY="${OUT_DIR}/certs/signing_key.pem"
SIGN_CERT="${OUT_DIR}/certs/signing_key.x509"
if [ -f "${SIGN_FILE}" ] && [ -f "${SIGN_KEY}" ] && [ -f "${SIGN_CERT}" ]; then
find "${DLKM_STAGING}" -type f -name "*.ko" | while read -r module; do
"${SIGN_FILE}" sha1 "${SIGN_KEY}" "${SIGN_CERT}" "${module}"
done
fi
This was taken from kleaf's own build_system_dlkm function:
build_utils.sh#340
:
# Re-sign the stripped modules using kernel build time key
# If SYSTEM_DLKM_RE_SIGN=0, this is a trick in Kleaf for building
# device-specific system_dlkm image, where keys are not available but the
# signed and stripped modules are in MODULES_STAGING_DIR.
if [[ ${SYSTEM_DLKM_RE_SIGN:-1} == "1" ]]; then
for module in $(find ${SYSTEM_DLKM_STAGING_DIR} -type f -name "*.ko"); do
${OUT_DIR}/scripts/sign-file sha1 \
${OUT_DIR}/certs/signing_key.pem \
${OUT_DIR}/certs/signing_key.x509 "${module}"
done
fi
This step restores the trust relationship between the kernel and its modules.
Once the modules were properly signed, all subsystems loaded correctly. WiFi, Bluetooth, NFC, and USB came up without further changes.
Why this was so easy to miss
None of this is obvious if you approach the problem purely from a build perspective.
The kernel built. The modules built. The images flashed. The failure only appeared at runtime, and the error messages pointed in misleading directions.
This is another example of an implicit contract enforced by the Android kernel pipeline. Bazel does not just build code. It encodes policy.
Once that final piece was in place, the kernel booted cleanly, without disabling the signing and the modules fully loaded.
We did it!
This was never about proving that Bazel is bad, or that Google’s tooling is wrong. Bazel exists for real reasons, at real scale.
This was about understanding what that tooling actually does.
Once Bazel was removed, nothing magically became simpler. In fact, things became harder. Responsibilities that were previously implicit had to be made explicit. Policies had to be discovered, not assumed. Contracts had to be honored manually.
But in doing so, the system stopped being opaque.
What emerged was not a fragile hack, but a conventional Linux kernel build pipeline that behaves exactly as Android expects, as long as its rules are followed. The difficulty was never in building the kernel. It was in learning which parts of the process were technical, and which parts were policy.
A special thanks goes to cyberknight777, who helped me do all this, did a lot of the tracing, and stayed with me during the bringup.
The full build script developed during this process is available here: https://github.com/sidharthify/auto
The kernel source itself, including all related changes, lives here: https://github.com/sidharthify/android_kernel_google_gs201
The kernel is named syd. The name is not meant as branding. It is a reference to Syd Barrett, and more specifically to the idea of creation that is raw, unstable, and unconcerned with polish or approval.
This kernel exists because the system did not make sense until it was understood end to end. The name reflects that process more than the result.
If there is a takeaway from all of this, it is that most complexity in modern systems does not come from necessity. It comes from abstraction layers quietly encoding decisions on your behalf.
This was my longest blog yet, and I am glad it is. Thank you for reading!