Why a Standalone Kernel Module Builds Fine but Still Never Lands in the Image
In early driver porting work, building a kernel module separately is often the fastest way to iterate. A .ko can be compiled on its own, sometimes even through Android build scripts, then pushed to a device and loaded manually with insmod. The trouble usually comes later: once a full image build is done, that same module may still be missing from the final image, or it may be present but never loaded automatically during boot.
To understand why that happens, it helps to separate two different questions:
- How is the
.koactually compiled? - How does that compiled module get packaged into the final image?
How .ko files are built
If a module lives outside the kernel tree and uses Android.bp or Android.mk, it can often be built directly with mm. But the visible Android build rule is only the outer layer. Underneath, Qualcomm and MediaTek platforms both rely on their own module build templates.
Out-of-tree driver builds
Qualcomm
On Qualcomm platforms, an out-of-tree driver's Android.mk commonly looks like this:
DISPLAY_SELECT := CONFIG_DRM_MSM=m
LOCAL_PATH := $(call my-dir)
LOCAL_MODULE_DDK_BUILD := true
include $(CLEAR_VARS)
# This makefile is only for DLKM
ifneq ($(findstring vendor,$(LOCAL_PATH)),)
ifneq ($(findstring opensource,$(LOCAL_PATH)),)
DISPLAY_BLD_DIR := $(TOP)/vendor/qcom/opensource/display-drivers
endif # opensource
DLKM_DIR := $(TOP)/device/qcom/common/dlkm
LOCAL_ADDITIONAL_DEPENDENCIES := $(wildcard $(LOCAL_PATH)/**/*) $(wildcard $(LOCAL_PATH)/*)
# Build display.ko as msm_drm.ko
###########################################################
# This is set once per LOCAL_PATH, not per (kernel) module
KBUILD_OPTIONS := DISPLAY_ROOT=$(DISPLAY_BLD_DIR)
KBUILD_OPTIONS += MODNAME=msm_drm
KBUILD_OPTIONS += BOARD_PLATFORM=$(TARGET_BOARD_PLATFORM)
KBUILD_OPTIONS += $(DISPLAY_SELECT)
###########################################################
include $(CLEAR_VARS)
LOCAL_SRC_FILES := $(wildcard $(LOCAL_PATH)/**/*) $(wildcard $(LOCAL_PATH)/*)
LOCAL_MODULE := msm_drm.ko
LOCAL_MODULE_KBUILD_NAME := msm_drm.ko
LOCAL_MODULE_TAGS := optional
LOCAL_MODULE_DEBUG_ENABLE := true
LOCAL_MODULE_PATH := $(KERNEL_MODULES_OUT)
include $(DLKM_DIR)/Build_external_kernelmodule.mk
###########################################################
endif # DLKM check
The important part is not the LOCAL_* definitions themselves, but this line:
include $(DLKM_DIR)/Build_external_kernelmodule.mk
For Qualcomm out-of-tree drivers, module compilation depends on a shared template, Build_external_kernelmodule.mk, located under device/qcom/common/dlkm/.
The core action inside that template is:
./build/build_module.sh $(kbuild_options)
Its usage is exposed like this:
# Usage:
# build/build_module.sh <make options>*
# or:
# OUT_DIR=<out dir> DIST_DIR=<dist dir> build/build_module.sh <make options>*
#
# Example:
# OUT_DIR=output DIST_DIR=dist build/build_module.sh -j24 V=1#
#
# The following environment variables are considered during execution:
#
# BUILD_CONFIG
# Build config file to initialize the build environment from. The location
# is to be defined relative to the repo root directory.
# Defaults to 'build.config'.
#
# MODULE_CONFIG
# Build config file for module to initialize the build environment from.
# The location is to be defined relative to the repo root directory.
# Defaults to 'module.config'.
#
# EXT_MODULES
# Space separated list of external kernel modules to be build.
#
# UNSTRIPPED_MODULES
# Space separated list of modules to be copied to <DIST_DIR>/unstripped
# for debugging purposes.
#
# INPLACE_COMPILE
# Conditional flag for in-place compilation. When set to 'y', the intermediate
# files and final module output will be stored in the module source directory.
# In-place compilation is tot supported in DDK build.
#
# MODULE_OUT
# Location to place compiled module output. When this option is specified,
# Only one EXT_MODULES may be specified. A symlink is created from the
# output Kbuild will use to MODULE_OUT.
#
# OUT_DIR
# Location to store the intermediate kernel environemnt for module compilation.
#
# Environment variables to influence the stages of the kernel build.
#
# SKIP_MRPROPER
# if defined, skip `make mrproper`
#
# INSTALL_MODULE_HEADERS
# if defined, install uapi headers from the module.
#
# BUILD_DTBS
# if defined, install uapi headers from the module.
At the bottom layer, the build path differs by kernel version, even though the Android-facing entry points like Build_external_kernelmodule.mk and Android.mk stay basically the same.
Before kernel 6.1, Qualcomm uses make:
make -C ${EXT_MOD} M=${EXT_MOD_REL} KERNEL_SRC=${ROOT_DIR}/${KERNEL_DIR} \
O=${OUT_DIR} "${TOOL_ARGS[@]}" \
INSTALL_HDR_PATH="${KERNEL_UAPI_HEADERS_DIR}/usr" \
${MAKE_ARGS} headers_install
From kernel 6.1 onward, it switches to bazel:
# Run the dist command passing in the output directory from Android build system
./tools/bazel run "${build_flags[@]}" "$build_target" \
-- --dist_dir="${OUT_DIR}/${EXT_MOD_REL}"
So if the visible Android-side build logic appears unchanged while the actual module behavior changes, the kernel-version-specific build backend is often the reason.
MediaTek
MediaTek follows a similar pattern and also provides a shared build template for kernel modules:
include $(CLEAR_VARS)
LOCAL_MODULE := $(WIFI_NAME).ko
LOCAL_PROPRIETARY_MODULE := true
LOCAL_MODULE_OWNER := mtk
LOCAL_REQUIRED_MODULES := $(WIFI_CHRDEV_MODULE)
ifeq ($(CONNAC_VER), 3_0)
LOCAL_REQUIRED_MODULES += wlan_page_pool.ko
LOCAL_REQUIRED_MODULES += conninfra.ko
LOCAL_REQUIRED_MODULES += connfem.ko
else ifeq ($(CONNAC_VER), 2_0)
LOCAL_REQUIRED_MODULES += conninfra.ko
LOCAL_REQUIRED_MODULES += connfem.ko
else
LOCAL_REQUIRED_MODULES += wmt_drv.ko
endif
include $(MTK_KERNEL_MODULE)
Here, MTK_KERNEL_MODULE points to:
MTK_KERNEL_MODULE := $(LOCAL_PATH)/build_ko.mk
The critical call is:
cd kernel && OUT_DIR=$(KOUT) BUILD_CONFIG=$(PRIVATE_KERNEL_BUILD_CONFIG) ./../device/mediatek/build/build/tools/build_kernel.sh $(OPTS) src=$(PRIVATE_LOCAL_PATH) && cd ..
Inside build_kernel.sh, the actual work includes:
(cd ${OUT_DIR} && make O=${OUT_DIR} "${TOOL_ARGS[@]}" "${MAKE_ARGS[@]}")
In practice, on several MediaTek projects using 6.1, 5.15, and 6.6 kernels, out-of-tree drivers are still being built with make. That may change later, but in the environments described here, it remains the common path.
In-tree driver builds
For in-tree drivers, the mechanism is different. Taking Qualcomm as the example:
RECOMPILE_KERNEL=1 LTO=thin \
./kernel_platform/build/android/prepare_vendor.sh \
bengal consolidate
Here, consolidate is roughly analogous to a userdebug build. Some user baselines use GKI, while others use perf.
The relevant logic includes:
if [ "${RECOMPILE_KERNEL}" == "1" ]; then
echo
echo " Recompiling kernel"
PLATFORM_SECURITY_PATCH=$(get_build_var PLATFORM_SECURITY_PATCH)
echo " PLATFORM_SECURITY_PATCH: ${PLATFORM_SECURITY_PATCH}"
echo ${PLATFORM_SECURITY_PATCH} > ${ANDROID_BUILD_TOP}/out/target/product/${DEVICE_NAME}/platform_security_patch.txt
# shellcheck disable=SC2086
"${ROOT_DIR}/build_with_bazel.py" \
-t "$KERNEL_TARGET" "$KERNEL_VARIANT" $LTO_KBUILD_ARG $EXTRA_KBUILD_ARGS --define=FACTORY_BUILD=${FACTORY_BUILD} \
--define=PLATFORM_SECURITY_PATCH=${PLATFORM_SECURITY_PATCH} \
--out_dir "${ANDROID_KP_OUT_DIR}"
COPY_NEEDED=1
fi
Using kernel 6.1 as the reference point, the key part lies in how Bazel targets are run:
def run_targets(self, targets):
"""Run "bazel run" on all targets in serial (since bazel run cannot have multiple targets)"""
for target in targets:
# Set the output directory based on if it's a host target
if any(
re.match(r"//{}:.*_{}_dist".format(self.kernel_dir, h), target.bazel_label)
for h in HOST_TARGETS
):
out_dir = target.get_out_dir("host")
else:
out_dir = target.get_out_dir("dist")
self.bazel(
"run",
[target],
extra_options=self.user_opts,
bazel_target_opts=["--dist_dir", out_dir]
)
self.write_opts(out_dir)
This is worth understanding if you need custom behavior during kernel compilation, especially when passing extra parameters into the kernel build. Without understanding where bazel run is invoked and how dist_dir is assigned, those custom requirements are hard to implement cleanly.
How a .ko gets packed into the image
With Google GKI in place, third-party drivers are expected to exist as kernel modules. That makes two packaging questions especially important:
- Which image stores these
.kofiles? - What mechanism puts them there?
The destination is vendor_dlkm.img. Because Android now uses dynamic partitions, that image is ultimately merged into super.img.
The packaging logic can be seen in the build_vendor_dlkm flow:
function build_vendor_dlkm() {
local vendor_dlkm_archive=$1
echo "========================================================"
echo " Creating vendor_dlkm image"
create_modules_staging "${VENDOR_DLKM_MODULES_LIST}" "${MODULES_STAGING_DIR}" \
"${VENDOR_DLKM_STAGING_DIR}" "${VENDOR_DLKM_MODULES_BLOCKLIST}"
local vendor_dlkm_modules_root_dir=$(echo ${VENDOR_DLKM_STAGING_DIR}/lib/modules/*)
local vendor_dlkm_modules_load=${vendor_dlkm_modules_root_dir}/modules.load
if [ -f ${vendor_dlkm_modules_root_dir}/modules.blocklist ]; then
cp ${vendor_dlkm_modules_root_dir}/modules.blocklist ${DIST_DIR}/vendor_dlkm.modules.blocklist
fi
# Modules loaded in vendor_boot (and optionally system_dlkm if dedup_dlkm_modules)
# should not be loaded in vendor_dlkm.
if [ -f ${DIST_DIR}/modules.load ]; then
local stripped_modules_load="$(mktemp)"
! grep -x -v -F -f ${DIST_DIR}/modules.load \
${vendor_dlkm_modules_load} > ${stripped_modules_load}
mv -f ${stripped_modules_load} ${vendor_dlkm_modules_load}
fi
cp ${vendor_dlkm_modules_load} ${DIST_DIR}/vendor_dlkm.modules.load
if [ -e ${vendor_dlkm_modules_root_dir}/modules.blocklist ]; then
cp ${vendor_dlkm_modules_root_dir}/modules.blocklist \
${DIST_DIR}/vendor_dlkm.modules.blocklist
fi
local vendor_dlkm_props_file
local vendor_dlkm_default_fs_type="ext4"
if [[ "${VENDOR_DLKM_FS_TYPE}" != "ext4" && "${VENDOR_DLKM_FS_TYPE}" != "erofs" ]]; then
echo "WARNING: Invalid VENDOR_DLKM_FS_TYPE = ${VENDOR_DLKM_FS_TYPE}"
VENDOR_DLKM_FS_TYPE="${vendor_dlkm_default_fs_type}"
echo "INFO: Defaulting VENDOR_DLKM_FS_TYPE to ${VENDOR_DLKM_FS_TYPE}"
fi
if [ -z "${VENDOR_DLKM_PROPS}" ]; then
vendor_dlkm_props_file="$(mktemp)"
echo -e "vendor_dlkm_fs_type=${VENDOR_DLKM_FS_TYPE}\n" >> ${vendor_dlkm_props_file}
echo -e "use_dynamic_partition_size=true\n" >> ${vendor_dlkm_props_file}
if [[ "${VENDOR_DLKM_FS_TYPE}" == "ext4" ]]; then
echo -e "ext_mkuserimg=mkuserimg_mke2fs\n" >> ${vendor_dlkm_props_file}
echo -e "ext4_share_dup_blocks=true\n" >> ${vendor_dlkm_props_file}
fi
else
vendor_dlkm_props_file="${VENDOR_DLKM_PROPS}"
if [[ -f "${ROOT_DIR}/${vendor_dlkm_props_file}" ]]; then
vendor_dlkm_props_file="${ROOT_DIR}/${vendor_dlkm_props_file}"
elif [[ "${vendor_dlkm_props_file}" != /* ]]; then
echo "VENDOR_DLKM_PROPS must be an absolute path or relative to ${ROOT_DIR}: ${vendor_dlkm_props_file}"
exit 1
elif [[ ! -f "${vendor_dlkm_props_file}" ]]; then
echo "Failed to find VENDOR_DLKM_PROPS: ${vendor_dlkm_props_file}"
exit 1
fi
fi
# Copy etc files to ${DIST_DIR} and ${VENDOR_DLKM_STAGING_DIR}/etc
if [[ -n "${VENDOR_DLKM_ETC_FILES}" ]]; then
local etc_files_dst_folder="${VENDOR_DLKM_STAGING_DIR}/etc"
mkdir -p "${etc_files_dst_folder}"
cp ${VENDOR_DLKM_ETC_FILES} "${etc_files_dst_folder}"
cp ${VENDOR_DLKM_ETC_FILES} "${DIST_DIR}"
fi
build_image "${VENDOR_DLKM_STAGING_DIR}" "${vendor_dlkm_props_file}" \
"${DIST_DIR}/vendor_dlkm.img" /dev/null
avbtool add_hashtree_footer \
--partition_name vendor_dlkm \
--hash_algorithm sha256 \
--image "${DIST_DIR}/vendor_dlkm.img"
if [ -n "${vendor_dlkm_archive}" ]; then
# Archive vendor_dlkm_staging_dir
tar -czf "${DIST_DIR}/vendor_dlkm_staging_archive.tar.gz" -C "${VENDOR_DLKM_STAGING_DIR}" .
fi
}
The important takeaway is that dynamically loaded modules are copied into the vendor_dlkm partition. So if the module builds successfully but never shows up in the image, the problem is often not compilation at all—it is the staging or packaging path into vendor_dlkm.img.
That is also why building vendor_dlkm.img explicitly can be useful. Running make vendor_dlkmimage allows you to generate that image first, and because it belongs to the dynamic partition layout, it is then merged into super.
Once vendor_dlkm.img has been generated, it can also be flashed independently:
adb reboot fastboot //进入fastbootd mode
fastboot flash vendor_dlkm vendor_dlkm.img

One final point: Android version changes and kernel version changes can both affect build methods and output paths. The exact commands, scripts, and artifact locations may differ between projects, so the build chain should always be verified against the specific baseline in use.