In this article I want to share with you what I learned about kernel modules and makefiles.
The problem
Usually, to deal with breaking changes in the kernel, one would check the kernel version,
but when the breaking changes get backported, that check won’t help.
In the past1, Falco encountered this issue only with RHEL-derived distros, which expose the RHEL_RELEASE_CODE
macro, so we could check that one.
One day, I had to ensure compatibility with EulerOS, which is a RHEL-like distro, but doesn’t expose the RHEL_RELEASE_CODE
macro, nor any other macro which could be used for the same purpose.
Short after, we had a similar issue with RHEL too, which backported a breaking change within the same major/minor version, making it impossible to check with their macro too.
To be more specific, the issue was the change 96d4f267e, introduced in 5.0 and backported by RHEL 8.1. The code to deal with that was the following:
#if (LINUX_VERSION_CODE >= KERNEL_VERSION(5, 0, 0)) || (PPM_RHEL_RELEASE_CODE > 0 && PPM_RHEL_RELEASE_CODE >= PPM_RHEL_RELEASE_VERSION(8, 1))
#define ppm_access_ok(type, addr, size) access_ok(addr, size)
#else
#define ppm_access_ok(type, addr, size) access_ok(type, addr, size)
#endif
The idea
Popular build systems like cmake
and autotools
have a way to ensure compatibility with preliminary checks to verify if a piece of code compiles. This is done in a phase usually called configure.
So I thought about having minimal sub-modules to be compiled just to check if the kernel has a specific feature, and export a macro out of that build.
The code for the access_ok
would then be:
#ifdef HAS_ACCESS_OK_2
#define ppm_access_ok(type, addr, size) access_ok(addr, size)
#else
#define ppm_access_ok(type, addr, size) access_ok(type, addr, size)
#endif
Easier said than done, of course.
The Falco kernel module makefile
If we look at the Falco kernel module makefile, it doesn’t look complex, nor alike any “regular” makefile:
#
# Copyright (C) 2022 The Falco Authors.
#
# This file is dual licensed under either the MIT or GPL 2. See
# MIT.txt or GPL.txt for full copies of the license.
#
falco-y += main.o dynamic_params_table.o fillers_table.o flags_table.o ppm_events.o ppm_fillers.o event_table.o syscall_table32.o syscall_table64.o ppm_cputime.o ppm_tp.o socketcall_to_syscall.o
obj-m += falco.o
ccflags-y :=
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
TOP := $(shell pwd)
all:
$(MAKE) -C $(KERNELDIR) M=$(TOP) modules
clean:
$(MAKE) -C $(KERNELDIR) M=$(TOP) clean
install: all
$(MAKE) -C $(KERNELDIR) M=$(TOP) modules_install
I discovered (the hard way) that those targets were just convenience targets, if one wants to build the module from its directory just running make
.
The anatomy of a kernel module makefile
The simplest Makefile
for a kernel module is:
testmod-y += test.o
obj-m += testmod.o
This is sufficient to build a kernel module named testmod.ko
from a single source file test.c
.
It immediately catches the eye that there are no targets, but only variables.
How does it work then? Everything is taken care of by the so-called “kbuild” build system2, which is a mix of makefiles, scripts and other tools.
Assuming our module is in the current directory, that’s how we build it:
make -C /lib/modules/`uname -r`/build M=${PWD} modules
The -C
switch tells make
to change directory before starting the build,
while the M
variable is used by the kernel’s makefile to know where to find
the module.
Recursive and multistep too!
As we just learned, kbuild includes our module makefile, but is it just that?
Of course not, it’s multistep too!
Now, make keeps a list of the included makefiles, and we can access it with the MAKEFILE_LIST
variable.
If we change our makefile to print it, not only we can see that it’s passed on multiple times, but also what drives that:
testmod-y += test.o
obj-m += testmod.o
$(info MAKEFILE_LIST: $(MAKEFILE_LIST))
We’ll have:
MAKEFILE_LIST: scripts/Makefile.build include/config/auto.conf scripts/Kbuild.include scripts/Makefile.compiler /tmp/foo/ACCESS_OK_2/Makefile
CC [M] /tmp/foo/ACCESS_OK_2/test.o
LD [M] /tmp/foo/ACCESS_OK_2/testmod.o
MAKEFILE_LIST: scripts/Makefile.modpost include/config/auto.conf scripts/Kbuild.include /tmp/foo/ACCESS_OK_2/Makefile
MODPOST /tmp/foo/ACCESS_OK_2/Module.symvers
CC [M] /tmp/foo/ACCESS_OK_2/testmod.mod.o
LD [M] /tmp/foo/ACCESS_OK_2/testmod.ko
BTF [M] /tmp/foo/ACCESS_OK_2/testmod.ko
By testing that on different distros, I found out that the first one is always scripts/Makefile.build
, so I used that to avoid performing my “configure” step multiple times.
All the caveats
Of course the devil’s in the details…
- In most distros I had just
scripts/Makefile.build
, but some had a prefix path, so I had to learn how to manipulate the path to get the file and its parent directory3. That’s the result:# ... FIRST_MAKEFILE := $(firstword $(MAKEFILE_LIST)) FIRST_MAKEFILE_FILENAME := $(notdir $(FIRST_MAKEFILE)) FIRST_MAKEFILE_DIRNAME := $(shell basename $(dir $(FIRST_MAKEFILE))) ifeq ($(FIRST_MAKEFILE_DIRNAME)/$(FIRST_MAKEFILE_FILENAME), scripts/Makefile.build) # ...
- I put a
Makefile.in
file to be included, runmake
for the sub-module and eventually append the flag toccflags-y
. That didn’t work because when we get there kbuild already set everything up to build the main module, thus we have to start clean. I achieved that by using a wrapper script, and calling it withenv -i
.
Done? Of course not. Some distros would use accessory tools, and I needed to passPATH
to get them working.
Everything together
Here you can see it in action:
❯ make -C /lib/modules/`uname -r`/build M=${PWD} modules
make: Entering directory '/usr/src/kernels/5.4.17-2102.204.4.4.el8uek.x86_64'
[configure] Including /usr/src/scap-6.1.0-191+300ba15-driver//configure/DEVNODE_ARG1_CONST/Makefile.inc /usr/src/scap-6.1.0-191+300ba15-driver//configure/ACCESS_OK_2/Makefile.inc
[configure] Build output for HAS_DEVNODE_ARG1_CONST:
[configure] make: Entering directory '/usr/src/scap-6.1.0-191+300ba15-driver/configure/DEVNODE_ARG1_CONST' make -C /lib/modules/5.4.17-2102.204.4.4.el8uek.x86_64/build M=/usr/src/scap-6.1.0-191+300ba15-driver/configure/DEVNODE_ARG1_CONST modules make[1]: Entering directory '/usr/src/kernels/5.4.17-2102.204.4.4.el8uek.x86_64' CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/configure/DEVNODE_ARG1_CONST/test.o /usr/src/scap-6.1.0-191+300ba15-driver/configure/DEVNODE_ARG1_CONST/test.c: In function 'devnode_dev_const_init': /usr/src/scap-6.1.0-191+300ba15-driver/configure/DEVNODE_ARG1_CONST/test.c:29:14: error: initialization of 'char * (*)(struct device *, umode_t *)' {aka 'char * (*)(struct device *, short unsigned int *)'} from incompatible pointer type 'char * (*)(const struct device *, umode_t *)' {aka 'char * (*)(const struct device *, short unsigned int *)'} [-Werror=incompatible-pointer-types] .devnode = ppm_devnode ^~~~~~~~~~~ /usr/src/scap-6.1.0-191+300ba15-driver/configure/DEVNODE_ARG1_CONST/test.c:29:14: note: (near initialization for 'g_ppm_class.devnode') cc1: some warnings being treated as errors make[2]: *** [scripts/Makefile.build:262: /usr/src/scap-6.1.0-191+300ba15-driver/configure/DEVNODE_ARG1_CONST/test.o] Error 1 make[1]: *** [Makefile:1786: /usr/src/scap-6.1.0-191+300ba15-driver/configure/DEVNODE_ARG1_CONST] Error 2 make[1]: Leaving directory '/usr/src/kernels/5.4.17-2102.204.4.4.el8uek.x86_64' make: *** [Makefile:15: all] Error 2 make: Leaving directory '/usr/src/scap-6.1.0-191+300ba15-driver/configure/DEVNODE_ARG1_CONST'
[configure] Setting HAS_ACCESS_OK_2 flag
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/main.o
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/dynamic_params_table.o
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/fillers_table.o
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/flags_table.o
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/ppm_events.o
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/ppm_fillers.o
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/event_table.o
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/syscall_table32.o
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/syscall_table64.o
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/ppm_cputime.o
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/ppm_tp.o
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/socketcall_to_syscall.o
LD [M] /usr/src/scap-6.1.0-191+300ba15-driver/scap.o
Building modules, stage 2.
MODPOST 1 modules
CC [M] /usr/src/scap-6.1.0-191+300ba15-driver/scap.mod.o
LD [M] /usr/src/scap-6.1.0-191+300ba15-driver/scap.ko
make: Leaving directory '/usr/src/kernels/5.4.17-2102.204.4.4.el8uek.x86_64'
The output of the configure mechanism is clearly marked, and it gets really verbose when the sub-module build fails, in order to help debugging if necessary.
Seeing Setting HAS_ACCESS_OK_2 flag
the first time was really satisfying 🤩.
You can check out the code in the Falco Libs repository.