CI-built PREEMPT_RT kernel Debian packages for distributions that do not
ship one, based on the Raspberry Pi kernel tree.
- Source:
https://github.com/raspberrypi/linux, branchrpi-6.18.y - Kernel version selected by tag:
v6.18.40→ builds 6.18.40 (defaultv6.18.40) - Targets:
pi4(BCM2711, 4K pages) andpi5(BCM2712, 16K pages) - Output:
linux-image/linux-headers/linux-libc-dev.debs frommake bindeb-pkg LOCALVERSION=-rt, plusrpi-boot-install.sh
No RT patch is applied or needed: PREEMPT_RT has been in mainline since 6.12
and arm64 selects ARCH_SUPPORTS_RT. Raspberry Pi even ships an official
arch/arm64/configs/bcm2711_rt_defconfig, so RT on the Pi 4 is a
vendor-supported configuration. There is no bcm2712_rt_defconfig, so the Pi 5
config is derived here from bcm2712_defconfig.
Actions → Build RT kernels → Run workflow:
| Input | Meaning |
|---|---|
kernel_ref |
Kernel version (v6.18.40, v6.18.40-2), branch, tag or SHA |
targets |
both, pi4 or pi5 |
release_tag |
Publish the packages as a GitHub release under this tag |
git tag v6.18.40 && git push origin v6.18.40That builds kernel 6.18.40 for both boards and publishes the packages as
release v6.18.40 — the tag is the version selector. Tags must be
v<major>.<minor>.<sublevel>[-<revision>]; anything else is rejected with a
clear error instead of failing later in the build.
To rebuild the same kernel after changing the config or packaging, add a revision:
git tag v6.18.40-2 && git push origin v6.18.40-2Same kernel commit, release v6.18.40-2, and the revision becomes the Debian
package revision — linux-image-6.18.40-v8-rt_6.18.40-v8-rt-2_arm64.deb — so
apt/dpkg treat it as an upgrade over the previous build.
The Raspberry Pi tree has no per-version tags (only stable_YYYYMMDD); it
merges the upstream stable releases into a rolling rpi-<maj>.<min>.y branch.
scripts/resolve-kernel-ref.sh therefore turns
v6.18.40 into the newest commit on rpi-6.18.y whose Makefile still reports
6.18.40 — i.e. the stable release plus any Pi fixes on top of it, and never the
first commit of 6.18.41.
If that version is still at the branch tip the tip is used directly. Otherwise
the search runs over the branch's first-parent history, which is the part
that is easy to get wrong: the branch merges upstream stable, so a plain history
walk also sees Linux 6.18.41 and its ancestors, which report the right version
while containing none of the Pi patches — no bcm2711_defconfig, no Pi drivers.
Every first-parent ancestor is a Pi-branch commit by construction, and the
result is checked for arch/arm64/configs/bcm2711_defconfig regardless. The
history is fetched commits-only (--filter=tree:0), so it costs a few hundred
MB once rather than a full clone.
The workflow resolves once in the prepare job so both boards build the
identical commit, and build.sh re-asserts the version of the checked-out tree
before configuring, so a tag can never quietly build something else.
Two consequences worth knowing:
- While a version is still at the branch tip,
v6.18.40follows that tip, so re-running the same tag can pick up Pi fixes landed since. Once 6.18.41 appears the resolution is frozen for good. - The commit actually built is recorded in
build-info-<target>.txtand in the release notes; pass that SHA askernel_refto reproduce a build exactly.
Locally the same build runs with:
./scripts/build.sh pi4 # packages land in ./distLocal requirements (Debian/Ubuntu):
sudo apt install gcc-aarch64-linux-gnu libc6-dev-arm64-cross bc bison flex \
libssl-dev libelf-dev dwarves debhelper dpkg-dev kmod cpio \
rsync zstd ccachelibc6-dev-arm64-cross is easy to miss: kernel code is freestanding and never
includes libc headers, but the linux-headers package rebuilds fixdep and
modpost for the target so out-of-tree modules can be built on the Pi, and
that compiles userspace code. build.sh probes for it up front.
A CONFIG_ONLY=1 run needs only the first six plus dwarves; the packaging
tools are not checked for. Either way build.sh verifies what it needs in its
first second and names anything missing.
Budget ~30 GB of free disk for a full local build — CONFIG_DEBUG_INFO (needed
for BTF) makes the object tree large. A config-only check is far cheaper (~2 GB,
a couple of minutes, no compile) and catches Kconfig regressions on its own:
CONFIG_ONLY=1 ./scripts/build.sh pi4Both targets pass that check against 6.18.40; the resulting kernel releases are
6.18.40-v8-rt and 6.18.40-v8-16k-rt.
ci.yml runs on every push and pull request (tag
pushes go to build.yml instead) and takes a few minutes:
- lint —
shellcheckoverscripts/andpackaging/, and a parse of every workflow file. - config —
CONFIG_ONLY=1 ./scripts/build.shfor both boards, so a Kconfig option that silently stops applying is caught without compiling anything. - packaging —
./scripts/build.sh smoke: atinyconfigkernel taken through the completebindeb-pkgpath in a few minutes, so packaging breakage no longer waits an hour to appear.
smoke is not an RT build and proves nothing about rt.fragment — it exists to
exercise the packaging work: modules get installed, stripped, compressed and
depmod-ed, the -dbg package is generated, and linux-headers cross-builds
fixdep/modpost. It then checks the results: the image package carries
vmlinuz and at least one .ko, and the headers package's fixdep is an
AArch64 binary — if it were not, out-of-tree module builds on the Pi would
fail, and nothing else in the pipeline would notice.
./scripts/build.sh smoke # ~7 min cold, ~2 min incrementalsudo dpkg -i linux-image-6.18.40-v8-rt_*.deb # Pi 5: linux-image-6.18.40-v8-16k-rt_*.deb
sudo ./rpi-boot-install.sh
sudo reboot
uname -a # expect "PREEMPT_RT"Add the matching linux-headers-*.deb if you build out-of-tree modules against
this kernel — see Native XDP on the Pi NICs.
The .deb puts the kernel in /boot/vmlinuz-<release>, its device trees in
/usr/lib/linux-image-<release>/ and its modules in /lib/modules/<release> —
none of which is where the Raspberry Pi firmware looks. rpi-boot-install.sh
bridges that gap: it detects the board, decompresses vmlinuz into
/boot/firmware/kernel8-rt.img (Pi 4) or kernel_2712-rt.img (Pi 5), copies
the device trees and overlays, and appends a marked, board-conditional block to
config.txt:
# BEGIN linux-rt (6.18.40-v8-rt)
[pi4]
kernel=kernel8-rt.img
[all]
# END linux-rt (6.18.40-v8-rt)
The distro kernel image is never overwritten, so a bad boot is fixed by
deleting that block from any other machine; sudo ./rpi-boot-install.sh --uninstall does the same in place. Replaced device trees are backed up to
/boot/firmware/linux-rt-backup/. On Debian systems with the
raspi-firmware package installed, its kernel hook already performs this step
and the script is unnecessary.
Release naming keeps the Pi convention, so both boards can be served from one
release and module directories never collide with the distro's:
CONFIG_LOCALVERSION from the defconfig (-v8 / -v8-16k) plus
LOCALVERSION=-rt on the make command line → 6.18.40-v8-rt and
6.18.40-v8-16k-rt.
All deviations from the Pi defconfig live in config/rt.fragment
and are re-checked after make olddefconfig by
scripts/verify-config.sh — Kconfig drops
unsatisfiable symbols silently, and two of the requested options are affected:
CONFIG_DEBUG_INFO_BTFneeds DWARF debug info, and the "Debug information" choice is hidden behindCONFIG_DEBUG_KERNEL, which the Pi defconfigs do not set. Enabling BTF alone is a no-op; the fragment therefore also setsDEBUG_KERNEL=yandDEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT=y.pahole≥ 1.21 must be installed on the builder (the workflow installsdwarves).CONFIG_NO_HZ_FULLis part of a Kconfig choice; the defconfig sits onNO_HZ_IDLE, which must be unset explicitly or the choice can resolve back. The same applies to the preemption choice.NO_HZ_FULLalready selectsRCU_NOCB_CPU,CPU_ISOLATIONandVIRT_CPU_ACCOUNTING_GEN.
The fragment adds one option beyond the requested set: CONFIG_TIMERLAT_TRACER,
which bcm2711_rt_defconfig also enables and which rtla timerlat needs.
CONFIG_PREEMPT=yandCONFIG_PREEMPT_RT=ycoexist — that is correct.PREEMPT_RTsits outside the "Preemption Model" choice, and sincePREEMPT_NONE/PREEMPT_VOLUNTARYdepend on!PREEMPT_RT, the choice resolves to its first remaining member,PREEMPT. The expandedbcm2711_rt_defconfigcontains exactly the same set, so do not try to unset it.CONFIG_PREEMPTIONis the symbol that says the kernel is preemptible.- What RT costs in driver terms: compared with the plain Pi defconfig, the
only features dropped are the ones that declare
depends on !PREEMPT_RT—LEDS_TRIGGER_CPUandIR_GPIO_TX— plus internal changes RT implies (QUEUED_RWLOCKS,SOFTIRQ_ON_OWN_STACK, tick-based CPU accounting). NO_HZ_FULLdoes nothing without a command line. Addisolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3 irqaffinity=0,1to/boot/firmware/cmdline.txtto actually get tickless isolated CPUs; without it you only pay the context-tracking overhead. On a 4-core Pi, dedicating two cores is the usual split.- Modular
BCMGENET/MACBmeans the Pi must not need the network to reach its root filesystem. SD/USB root is fine (storage drivers stay built in); NFS root needsrpi-boot-install.sh --initramfsor built-in drivers. Keeping them modular is also what lets the in-tree drivers be swapped for the native XDP ones below without rebuilding the kernel. - A
-dbgpackage is discarded by default:CONFIG_DEBUG_INFOmakesmkdebianemitlinux-image-<release>-dbgat roughly a gigabyte. SetKEEP_DBG=1to keep it. - RT and the vendor tree.
bcm2711_rt_defconfigexists precisely because a few drivers depend on!PREEMPT_RT(e.g.LEDS_TRIGGER_CPU);olddefconfigdrops those automatically. Downstream Pi drivers (VC4/DRM, camera) see much less RT testing than the mainline core — validate withcyclictestandrtla timerlatbefore trusting latency numbers. - Version pinning is described under Releasing a kernel version.
The in-tree bcmgenet (Pi 4) and macb (Pi 5) drivers have no native XDP
support, so CONFIG_XDP_SOCKETS alone gets you AF_XDP in copy/SKB mode only —
the packet still traverses sk_buff allocation before any BPF program sees it.
Out-of-tree drivers that retrofit native XDP onto both NICs are available at
https://github.com/stratuMAK/xdp-backports. They are derived from the same
rpi-6.18.y tree this kernel is built from, target PREEMPT_RT, and add
page_pool RX plus ndo_bpf/ndo_xdp_xmit — which is what the userspace
EtherCAT master at stratuMAK/ethercat,
a userspace port of the IgH EtherCAT master, uses for its xdp-native
transport.
They install as genet_xdp.ko / macb_xdp.ko replacing the in-tree modules —
hence CONFIG_MACB=m and CONFIG_BCMGENET=m here. Their DKMS packages rebuild
against the running kernel's headers, so install the matching headers package
from this repo alongside the image:
sudo dpkg -i linux-image-6.18.40-v8-rt_*.deb linux-headers-6.18.40-v8-rt_*.debA full defconfig build on a 4-vCPU GitHub-hosted runner takes roughly 45–90
minutes per board (BTF generation adds a few minutes); ccache brings repeat
builds down substantially. Both boards build in parallel. The workflow
cross-compiles from ubuntu-24.04 with gcc-aarch64-linux-gnu, which works on
private repositories too; scripts/build.sh also runs unmodified on an
ubuntu-24.04-arm runner (free for public repos), where it skips the cross
prefix automatically.
scripts/build.sh compiles with make -j$(nproc) before calling
bindeb-pkg, because dpkg-buildpackage invokes debian/rules with -j1 and
only inherits parallelism through make's jobserver; doing the heavy lifting up
front means the packaging step has nothing left to compile either way.
Two consequences of cross-building the packages, both handled in build.sh:
bindeb-pkgrunsdpkg-buildpackage -a arm64, sodpkg-checkbuilddepsdemands arm64 builds oflibssl-dev/libdw-dev, which cannot be installed on an amd64 runner. Everything is already compiled with our own cross toolchain by then, so the check is skipped viaDPKG_FLAGS=-drather than satisfied with a foreign-architecture apt setup.- ccache is wired in through
PATH, never throughCC=.debian/rulesruns make without our overrides, so an overriddenCCwould make every recorded.cmdline differ from the pre-build and kbuild would recompile the whole tree inside the packaging step.
A preflight check at the start of build.sh verifies the toolchain, the
packaging tools and the pahole version in about a second, so a missing
debhelper fails immediately instead of after an hour of compiling.