Firmware: config.txt and cmdline.txt#
Why#
config.txt is read by the Raspberry Pi’s firmware before Linux even
starts, this is where the kernel to load, active hardware interfaces, and
the PPS bus behavior get decided. cmdline.txt is read by the kernel right
at the start of its boot, this is where CPU cores get isolated for
real-time use. These two files are the foundation everything else in the
system rests on.
How#
/boot/firmware/config.txt#
# Disable unused interfaces (attack surface, power draw)
dtparam=spi=off
dtparam=audio=off
camera_auto_detect=0
display_auto_detect=1
auto_initramfs=1
dtoverlay=vc4-kms-v3d
max_framebuffers=2
disable_fw_kms_setup=1
# 64-bit, no black bars, max frequency
arm_64bit=1
disable_overscan=1
arm_boost=1
[pi5]
# Target RT kernel image (see previous chapter)
kernel=kernel8_rt.img
[all]
# UART enabled (serial console + GNSS receiver on ttyAMA0)
dtparam=uart0=on
enable_uart=1
# GPU RAM: minimal (no heavy display on a headless server)
gpu_mem=16
# Pi 5's built-in RTC (backup battery)
dtparam=rtc=on
# 3,000,000 µV = 3.0 V: charge voltage matched to the ML-2032 coin cell
# (nominal 3 V) that keeps the RTC alive when powered off, it must be
# physically connected before the first boot to benefit from this
# automatic charging
dtparam=rtc_bbat_vchg=3000000
# PPS on GPIO 4, the centerpiece of the whole project
dtoverlay=pps-gpio,gpiopin=4
# Unused radios, disabled (attack surface, interference)
dtoverlay=disable-wifi
dtoverlay=disable-bt
# Thermal profile: 4 PWM fan tiers
# Kicks in at 40°C to stabilize the oscillator (see the Thermal
# stabilization chapter)
dtparam=fan_temp0=40000
dtparam=fan_temp0_hyst=20000
dtparam=fan_temp0_speed=128
dtparam=fan_temp1=60000
dtparam=fan_temp1_hyst=7000
dtparam=fan_temp1_speed=170
dtparam=fan_temp2=70000
dtparam=fan_temp2_hyst=5000
dtparam=fan_temp2_speed=212
dtparam=fan_temp3=75000
dtparam=fan_temp3_hyst=2000
dtparam=fan_temp3_speed=255These values are in millidegrees (40000 = 40°C) and PWM on a 0-255
scale (128 ≈ 50%, 255 = 100%). The quartz crystal (XO) that clocks the system runs
about 0.1 ppm off per degree Celsius (Thermal stabilization chapter):
fan_temp0=40000 opens up regulation as early as 40°C, deliberately low
compared to the Raspberry Pi default (typically 60°C), to pin down the
thermal operating point before normal system load even has a chance to
drift it. The hysteresis values (_hyst) narrow as the tiers rise
(20000 -> 7000 -> 5000 -> 2000, i.e. 20 -> 7 -> 5 -> 2°C): wide at the
first tier to keep the fan from cycling on and off endlessly around 40°C
(the noise from cycling would be worse than a few extra degrees), tighter
afterward since the higher tiers only need to absorb occasional load
spikes. The goal isn’t the lowest possible temperature, but a fan that
never stops and rarely changes speed, which pins down thermal drift
instead of letting it fluctuate along with the acoustic noise of an
on/off cycle.
This guide does not enable a user-facing I2C bus (
dtparam=i2c_arm=on): not needed for a working Stratum 1, only for extra peripherals (displays, sensors, HATs). The Pi 5’s built-in RTC (dtparam=rtc=on) uses a separate, internal I2C bus, so it stays active regardless.
/boot/firmware/cmdline.txt#
Before setting this parameter, here is the final layout of the Raspberry Pi 5’s 4 cores once this whole guide has been applied (each assignment is explained in detail in the CPU governor, IRQ affinity, and PHC/phc2sys chapters):
┌───────────────────────────────────────────────┐ ┌───────────────────────────────────────────────┐
general-purpose scheduler isolcpus=2,3 (reserved for IRQs)
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ Core 0 │ │ Core 1 │ │ Core 2 │ │ Core 3 │
│ │ │ │ │ │ │ │
│ OS / systemd │ │ phc2sys (FIFO 90) │ │ end0 IRQ (network) │ │ PPS IRQ (GPIO 4) │
│ + gpsd │ │ + chronyd (FIFO 98) │ │ chrt FIFO 90 │ │ chrt FIFO 99 │
│ (Nice -10) │ │ │ │ │ │ │
└─────────────────────┘ └─────────────────────┘ └─────────────────────┘ └─────────────────────┘Two cores (2 and 3) are removed from the general-purpose scheduler
by isolcpus and reserved exclusively for the two most time-sensitive
hardware interrupts: PPS (the GNSS receiver’s second tick) and network
reception, each with maximum real-time priority (chrt) so neither ever
waits behind another task. The two remaining cores (0 and 1) form the
general pool: phc2sys and chronyd are explicitly pinned to core 1,
under real-time FIFO scheduling (priority 90 and 98 respectively, PHC
and Chrony chapters), while gpsd and the rest of the operating system
share core 0 (GNSS/PPS acquisition chapter).
Append to the end of the existing single line:
isolcpus=2,3Full example:
console=tty1 root=PARTUUID=xxxxxxxx-02 rootfstype=ext4 fsck.repair=yes rootwait isolcpus=2,3This parameter removes cores 2 and 3 from the general-purpose scheduler, they’ll be reserved for PPS and network interrupts (next chapter).
Scheduling: FIFO and nice, two different worlds#
The diagram above mixes two vocabularies (“FIFO 99”, “Nice -10”) that belong to two distinct kernel scheduling policies. The scheduler decides, at every instant, which task runs on which core:
SCHED_OTHER, the default policy, based on fair sharing: all ordinary tasks share CPU time. The nice value (-20 to +19, the lower, the sooner the task gets served) merely weights that sharing: a task at nice -10 gets a bigger share, but none can monopolize the core or obtain any latency guarantee.SCHED_FIFO, the real-time policy: a FIFO task of priority N (1 to 99) runs as soon as it is ready and keeps the CPU for as long as it needs, unless a strictly higher-priority FIFO task becomes ready in turn. EverySCHED_OTHERtask waits, whatever its nice: nice carries no weight against FIFO.
On this server, the full hierarchy reads:
FIFO 99 PPS IRQ (second tick, core 3) never yields
FIFO 98 chronyd (clock discipline, core 1)
FIFO 90 network IRQ (core 2), phc2sys (core 1)
········· real-time boundary ·········
nice -10 gpsd (NMEA parsing, core 0) first among ordinary tasks
nice 0 everything else in the system (core 0)FIFO priority decreases as one moves away from the physical instant being measured. The PPS edge first (99), then the discipline loop that consumes it (98), then what aligns the PHC and timestamps the network (90). Below the real-time boundary, only tasks whose occasional lateness does not affect precision remain.
Why gpsd stays on core 0 (and not core 1)#
gpsd is part of the time chain, so intuition would put it on core 1 with chronyd and phc2sys. Three reasons argue against it:
- gpsd needs no timing precision at all. Its role is limited to labeling the second: saying which second just ticked (Overview chapter). The precise instant is captured by the kernel at the PPS edge, on core 3, before gpsd is even consulted. An NMEA sentence can arrive hundreds of milliseconds late with zero effect on the final precision: the useful margin is on the order of half a second, nowhere near the targeted microsecond.
- Core 1 must stay predictable. chronyd and phc2sys run there under FIFO: an ordinary task added to that core would only run in their gaps (nice carries no weight against FIFO) and would bring in exchange its frequent wakeups and cache pollution exactly where chronyd performs its most sensitive computations.
- Promoting gpsd to real time would be worse. gpsd is a general-purpose daemon (serial parsing, JSON clients, sockets): giving it FIFO would widen the circle of tasks able, in a runaway case, to starve everything below. The rule goes the other way: the real-time club must stay as small and auditable as possible.
The Nice -10 on core 0 is enough: it puts gpsd ahead of the system’s
housekeeping tasks so it reads the UART without lag, which is all that is
asked of it.
Pitfalls to avoid#
Any invalid
config.txtdirective is silently ignored, no error, no message. This is the most common trap in this chapter: writingdtoverlay=i2c_arm=oninstead ofdtparam=i2c_arm=on(no overlay namedi2c_armexists, but nothing signals it), or a typo in a parameter name. Always verify the result after reboot, never assume no boot error means the directive was applied.
- Confusing firmware parameters with kernel parameters.
config.txtis read by the firmware (before Linux);cmdline.txtis read by the kernel. A kernel parameter likenohz=offplaced inconfig.txtwould be inert, and it should not be added tocmdline.txteither: the default behavior (tick disabled on an idle isolated core) is the one sought. - Misplaced
[pi5]section. Directives under[pi5]only apply to the Pi 5, but make sure the section is properly closed (implicitly, by the next section header) or following directives will inherit the restriction by mistake.
Verify#
mount -o remount,rw /boot/firmware
# (edit the files)
sync
reboot# config.txt properly picked up by the firmware
vcgencmd get_config int | grep -iE "gpu_mem|arm_boost"
# cmdline.txt: effective isolation
cat /proc/cmdline
# Should contain "isolcpus=2,3"
cat /sys/devices/system/cpu/isolated
# Should return: 2-3
# No kernel warning about unknown parameters
dmesg | grep -i "Unknown kernel command line"
# Should be EMPTY
# PPS active: the device should exist
ls -l /dev/pps0For parameters that aren’t simple integers (like
i2c_arm, absent here),vcgencmd get_config intwon’t show them, that’s expected, not a sign of failure:dtparam=directives are consumed by the kernel’s device-tree overlay, not by the VideoCore firmware’s config store. The real test is whether the corresponding device exists (/dev/pps0,/dev/i2c-1, etc.).