Skip to content

08 · Init Systems & Services

An embedded device has no user to double-click your program. Whatever your product does — reading sensors, serving a UI, talking to the cloud — some piece of software must start it at boot, restart it when it crashes, and stop it cleanly at shutdown. That piece is the init system, the very PID 1 from module 3. Embedded Linux really uses two: tiny BusyBox init (a dozen lines of config, the Buildroot default) and full systemd (what production i.MX BSP images actually ship). A professional writes the same service for both without blinking — so that's exactly what you'll do here, with the sensor daemon pattern the capstone will reuse.

BusyBox init: the whole system in one file

BusyBox init reads a single config, /etc/inittab, of lines id::action:command. Your Alpine guest uses it as PID 1 — look at the real thing:

localhost:~# ps -p 1 -o comm=
init
localhost:~# grep -v "^#" /etc/inittab
::sysinit:/sbin/openrc sysinit
::sysinit:/sbin/openrc boot
::wait:/sbin/openrc default
tty1::respawn:/sbin/getty 38400 tty1
ttyAMA0::respawn:/sbin/getty -L 115200 ttyAMA0 vt100
::shutdown:/sbin/openrc shutdown
::ctrlaltdel:/sbin/reboot

The three actions that matter:

Action Meaning
sysinit / wait Run once at boot, in order (mount filesystems, one-shot setup)
respawn Start the command and restart it whenever it exits — the embedded watchdog-lite
shutdown Run at poweroff/reboot

That ttyAMA0::respawn:...getty line is your serial login prompt: log out, and init respawns getty — which is why the prompt comes back. (Alpine layers the OpenRC service manager on top for its boot scripts — a common middle ground — but PID 1 itself is pure BusyBox inittab.)

A service the BusyBox way

Take a trivial daemon (the capstone's sensor simulator in miniature):

#!/bin/sh
# /usr/sbin/sensord-lite — fake sensor: log a reading every 2 s
while true; do
    echo "sensord: temp=$((20 + $(date +%S) % 5)).${$(date +%N):-0}C" \
        >> /tmp/sensord.log 2>/dev/null || true
    echo "sensord: reading logged $(date)" >> /tmp/sensord.log
    sleep 2
done

Simpler, portable version to actually use:

#!/bin/sh
# /usr/sbin/sensord-lite
while true; do
    echo "$(date '+%H:%M:%S') temp=$((20 + $(cat /proc/uptime | cut -d. -f1) % 5))C" \
        >> /tmp/sensord.log
    sleep 2
done

Wiring it in is one line in /etc/inittab:

::respawn:/usr/sbin/sensord-lite

Try it live in the guest:

localhost:~# cat > /usr/sbin/sensord-lite   # paste the script, then Ctrl-D
localhost:~# chmod +x /usr/sbin/sensord-lite
localhost:~# echo "::respawn:/usr/sbin/sensord-lite" >> /etc/inittab
localhost:~# kill -HUP 1                    # tell init to re-read inittab
localhost:~# tail -2 /tmp/sensord.log
12:04:11 temp=22C
12:04:13 temp=22C
localhost:~# kill $(pgrep -f sensord-lite) && sleep 1 && pgrep -f sensord-lite
1201                                        # new PID — init respawned it!

That respawn is the entire crash-recovery story of countless shipped devices. Its limits are real though: no dependencies ("start after network"), no shutdown ordering, no per-service logging — you get to about five services and start reinventing systemd badly.

systemd: what production BSPs ship

systemd models services as declarative unit files with dependencies, restart policy, logging, watchdogs, and resource limits. It's heavier (RAM, image size, boot complexity) — and it's what NXP's Yocto-based i.MX BSP images use by default, because products need exactly those features. The same service, the systemd way:

# /etc/systemd/system/sensord.service
[Unit]
Description=Sensor simulator daemon
After=network.target

[Service]
ExecStart=/usr/sbin/sensord-lite
Restart=always
RestartSec=1

[Install]
WantedBy=multi-user.target

And its lifecycle commands (run these on any systemd machine — your Linux PC, a Raspberry Pi, or the capstone's systemd image):

$ sudo systemctl daemon-reload          # re-read unit files
$ sudo systemctl enable --now sensord   # start at boot + start now
$ systemctl status sensord
● sensord.service - Sensor simulator daemon
     Active: active (running) since ...
$ journalctl -u sensord -n 3            # this unit's logs only

Line-by-line against inittab: ExecStart is the command, Restart=always is respawn, WantedBy=multi-user.target is "part of normal boot," and After=network.target is the dependency line inittab simply cannot express.

Logging: syslog vs journald

Where do a device's logs go? Two answers, matching the two inits:

BusyBox syslogd systemd-journald
Format Plain text (/var/log/messages) Indexed binary journal
Read with logread, tail journalctl -u <unit>, -b, --since
Size control -s rotate size SystemMaxUse=, RuntimeMaxUse=
Embedded concern Log to RAM (/var/log on tmpfs) to spare flash Storage=volatile for the same reason

The shared embedded rule: flash wears out. Production devices either log to RAM (losing logs at reboot — often fine), cap and rotate aggressively, or forward logs off-device (Level 4's fleet management).

Choosing, in one table

BusyBox init systemd
Config /etc/inittab lines Unit files
Crash restart respawn Restart=always (+ backoff, rate limits)
Dependencies None (script it yourself) First-class (After=, Wants=)
Footprint ~0 (inside busybox) Tens of MB + several daemons
Boot time Fastest possible Fast (parallel) but heavier
Best for Minimal appliances, fast-boot devices Products with many interacting services (i.MX-class)

Cheat sheet

Item Purpose
/etc/inittabid::action:command Entire BusyBox init config
::respawn:/usr/sbin/mydaemon Start + auto-restart a service
kill -HUP 1 Make BusyBox init re-read inittab
.service file: [Unit] [Service] [Install] systemd unit anatomy
Restart=always, After=, WantedBy= Restart policy, ordering, boot hookup
systemctl enable --now foo Enable at boot and start immediately
systemctl status foo / journalctl -u foo State + logs per service
logread Read BusyBox syslog ring buffer
Storage=volatile / tmpfs /var/log Spare the flash
ps -p 1 -o comm= Identify the init system you're on

How It Actually Works

BusyBox init is genuinely a straight-line interpreter: it reads /etc/inittab once at startup, and for every line marked respawn it fork()s a child, exec()s the given command, and blocks in a wait() loop; when that child dies for any reason, wait() returns and init immediately forks a fresh one. There's no dependency graph — order is whatever order the lines appear in the file, which is exactly why BusyBox init is fine for one appliance daemon and unworkable once two services need "B must not start until A's socket exists."

systemd solves that ordering problem by not being a straight-line interpreter at all — at startup it parses every unit file into an in-memory dependency graph (Requires=/After=/Wants= become graph edges), computes a valid transaction order via topological sort, and then activates units in parallel wherever the graph allows — which is why systemd boots faster than sequential init despite doing more bookkeeping. Each unit's process is also placed into its own cgroup, so systemd learns a service crashed via the kernel's SIGCHLD/cgroup-empty notification, not by polling — the same mechanism module 9 covers for restart policy. journald's binary log format exists specifically to support O(1) structured lookups and deterministic size capping — a growing-string syslog file has neither.

Exercise

(1) In the guest, extend sensord-lite to write a pid file (echo $$ > /tmp/sensord.pid at the top) and prove across three kills that init respawns it with a new PID each time. (2) Add a second inittab entry that runs once at boot (action wait) creating /tmp/boot-marker with a timestamp — which module-3 stage is this imitating? (3) Rewrite sensord.service so it (a) only starts after /tmp/boot-marker exists (hint: ConditionPathExists=), and (b) gives up if it crashes 5 times in 10 seconds (hint: StartLimitBurst=, StartLimitIntervalSec= — check the systemd.unit man page online). (4) One paragraph: your i.MX95 product has a UI, a CAN daemon, an OTA agent, and a cloud agent with strict start ordering — which init do you ship and which three specific features justify it?