Who is pid 1? Android's boot sequence, measured

· aospinitbootselinuxcuttlefish


Search for “Android boot sequence” and you get the same picture every time: four boxes, left to right, bootloader → kernel → init → zygote, with an arrow labelled “system_server” trailing off the right edge. It’s not wrong. It’s just not load-bearing — you cannot answer a single practical question with it. Can my daemon read /data yet? Which of these boxes is /system mounted in? If boot takes eighteen seconds, which box has the seconds in it?

The useful framing isn’t “which program is running.” It’s what exists now that didn’t a moment ago. Boot is capability accretion, and there are four capabilities that matter: a root filesystem, a loaded SELinux policy, the property service, and the framework. Every boundary worth drawing in a boot diagram is one of those four arriving.

The good news is that you don’t have to take anyone’s word for the timings, including mine. Init instruments its own boot into system properties, so any device will tell you where its seconds went. Everything below is android-15.0.0_r36 on Cuttlefish (aosp_cf_x86_64_auto-userdebug), and every number comes off that device.

pid 1 kernel init second_stage first_stage selinux_setup 24 ms — restorecon /system/bin/init, then exec again an SELinux domain change requires an exec services reaching 'running' early-init 2.03 init 2.22 late-init 2.28 early-fs 2.29 fs 2.30 post-fs 2.31 late-fs 2.44 28 services post-fs-data 7.64 apexd 7.70 zygote-start 8.29 early-boot 8.31 boot 8.33 84 services in one second /data is mounted, APEXes activated nothing starts here 0 2 4 6 8 10 12 14 16 s sys.boot_completed 16.83 set by system_server, not by init wait /sys/class/net/eth1 5.009 s, then timed out One blocking command in one vendor .rc file. init's action queue is single-threaded, so every stage after late-fs — and every service that needs /data — waits behind it.
One boot, measured. The top strip is the identity of pid 1, which changes twice before any service starts. The histogram counts services reaching running per second — note that it is not a ramp but two bursts with a five-second void between them, and that the void has a cause you can point at.

Init is one binary that execs itself three times

This is the part no diagram shows, and it’s the first thing that made the rest make sense. There is no separate “first stage init” executable. There’s one /system/bin/init, and it dispatches on how it was invoked — system/core/init/main.cpp:53:

int main(int argc, char** argv) {
    if (!strcmp(basename(argv[0]), "ueventd")) {
        return ueventd_main(argc, argv);
    }
    if (argc > 1) {
        if (!strcmp(argv[1], "subcontext"))    return SubcontextMain(argc, argv, &function_map);
        if (!strcmp(argv[1], "selinux_setup")) return SetupSelinux(argv);
        if (!strcmp(argv[1], "second_stage"))  return SecondStageMain(argc, argv);
    }
    return FirstStageMain(argc, argv);
}

Five modes in one binary. The kernel starts it with no arguments, so it falls through to FirstStageMain, whose job is to get enough of a filesystem mounted that the rest of Android is reachable. Then, at first_stage_init.cpp:559:

const char* path = "/system/bin/init";
const char* args[] = {path, "selinux_setup", nullptr};
execv(path, const_cast<char**>(args));

It execs itself. The selinux_setup stage loads the policy, and then does the move that explains the whole design — selinux.cpp:735:

if (selinux_android_restorecon("/system/bin/init", 0) == -1) {
    PLOG(FATAL) << "restorecon failed of /system/bin/init failed";
}
const char* path = "/system/bin/init";
const char* args[] = {path, "second_stage", nullptr};
execv(path, const_cast<char**>(args));

Pid 1 relabels its own executable and re-execs. Why? Because an SELinux domain transition only happens on exec. You cannot change a running process’s security context; the kernel assigns it when the process is exec’d. Init starts life in the kernel domain, and the only way for it to become u:r:init:s0 — the context it needs in order to spawn everything else into the right domains — is to load the policy, fix its own file label, and run itself again. Three stages exist because the SELinux model demands three execs, not because someone liked the number.

On my device that middle stage costs 24 ms, and the two execv calls are the reason init: init second stage started! is a thing you see in the log rather than an implementation detail.

The other two modes are worth knowing. ueventd — the device-node and firmware manager — is the same binary, invoked through a different argv[0]. And subcontext is how vendor_init works: init forks a copy of itself into u:r:vendor_init:s0 so that commands from vendor .rc files run with vendor privileges rather than init’s. You can watch it happen:

init: Forked subcontext for 'u:r:vendor_init:s0' with pid 91

The ordering is a 30-line block, not an emergent property

Second-stage init doesn’t have a hardcoded boot sequence in C++. It has an action queue, and it primes it with exactly three built-in triggers — init.cpp:1061, 1080, 1087:

am.QueueEventTrigger("early-init");
...
am.QueueEventTrigger("init");
...
am.QueueEventTrigger("late-init");

That’s it. Everything else you think of as a boot stage is queued from init.rc itself. And late-init turns out not to be a stage at all — it’s a sequencer. Its entire body, at system/core/rootdir/init.rc:503, is trigger commands:

on late-init
    trigger early-fs
    trigger fs
    trigger post-fs
    trigger late-fs
    trigger post-fs-data
    trigger load-bpf-programs
    trigger bpf-progs-loaded
    trigger zygote-start
    trigger firmware_mounts_complete
    trigger early-boot
    trigger boot

I found this genuinely clarifying. The stage ordering that gets drawn as architecture is a list, in a file, that you can read in thirty seconds and edit. And because trigger enqueues rather than calls, the stages are sequential by queue position — which becomes important in a minute.

Measured on my device, that list plays out as:

trigger at
early-init 2.030 s
init 2.223 s
late-init 2.283 s
early-fs 2.295 s
fs 2.302 s
post-fs 2.306 s
late-fs 2.443 s
post-fs-data 7.641 s
zygote-start 8.294 s
early-boot 8.314 s
boot 8.329 s

Six of the eleven stages complete inside 150 ms. Then something takes five seconds. We’ll get there.

Two kinds of trigger, and who fires them

Init has event triggers, which are the ones above, and property triggers, which look almost identical in an .rc file:

on boot
    ...

on property:sys.boot_completed=1
    ...

Both run their commands in the same single-threaded queue, executed by init. The difference is who decides when. on boot fires because init worked its way down that list in init.rc. on property:sys.boot_completed=1 fires because someone set a property — and that someone is Java, in system_server:

// frameworks/base/services/core/java/com/android/server/am/ActivityManagerService.java:5223
SystemProperties.set("sys.boot_completed", "1");

That’s inside finishBooting(). So on boot means “init has finished its own bring-up,” and sys.boot_completed means “the framework believes it has finished booting” — two claims about different things, made by different processes, 8.5 seconds apart on my device (8.329 s vs 16.831 s). A crash-looping system_server never fires the second one; nothing about init would notice.

This distinction is the one I’d most want a beginner to take away, because on boot reads like it means “the device has booted” and it does not. It means init is done with the parts init controls. Zygote’s service had already been started 18 ms earlier.

The device tells you its own timings

Every service init starts gets a property — service.cpp:183:

if (new_state == "running") {
    uint64_t start_ns = time_started_.time_since_epoch().count();
    std::string boottime_property = "ro.boottime." + name_;
    if (GetProperty(boottime_property, "").empty()) {
        SetProperty(boottime_property, std::to_string(start_ns));
    }
}

So adb shell getprop | grep ro.boottime is a free boot profile. On my device that’s 140 entries: ten of them are init’s own stage instrumentation, the other 130 are one per service. The if (...empty()) matters — only the first start is recorded, so a service that gets restarted keeps its original timestamp.

One absence is more informative than any of the values: ro.boottime.system_server is empty. Init never started system_server — zygote forked it. Init instruments its own children, so the gap in the data marks exactly where init’s authority ends and the framework’s begins. Same reason there’s no ro.boottime for any app process.

What “booted” means, precisely

So, four capability arrivals, with numbers:

“Booted” is five different moments, and which one you mean determines whether your code works. That’s the diagram I actually wanted when I started: not which program is running, but what you’re allowed to assume.

Reading list

The previous post, on Treble enforcement, covers the other half of what init is doing when it parses those .rc files out of four different partitions — and why which partition a file came from decides what it’s permitted to do.