HomeDocumentationAndroid Under The Surface
Android Under The Surface
14

The Kernel and System Calls: When Dart Talks to Linux

Android Kernel and Syscalls for Flutter Developers

March 26, 2026

There's an invisible layer in your Flutter app that runs every time you do anything.

Read a file. Make an HTTP request. Set a timer. Decode an image. Write to a database. Check if the device has a network connection. Every one of these operations — no matter how simple the Dart API looks — ends at the same place: the Linux kernel.

Your Dart code doesn't read files. It can't. It doesn't have permission to talk to the flash storage hardware. Your Dart code asks the kernel to read the file, and the kernel does it. This is true for every interaction with the outside world. The kernel is the intermediary between your code and reality, and it runs — silently, invisibly — thousands of times per second while your app is alive.

Understanding this layer doesn't change how you write widgets. It changes how you reason about performance, debugging, and what's actually happening when something takes longer than it should.

What the kernel does

The kernel is the one piece of software on the device that runs with full hardware access. Everything else — your app, system services, even the Android framework — runs in a restricted mode where direct hardware access is blocked by the CPU itself.

The kernel's responsibilities are concrete:

Process management. Creating processes (the fork() from Post 2), assigning them to CPU cores, deciding how much time each one gets (scheduling), terminating them when asked or when they misbehave. The Low Memory Killer that terminates background apps is a kernel mechanism.

Memory management. Maintaining the page tables that give each process its own virtual address space (Post 1). Handling page faults — when a process accesses a virtual address that isn't mapped to physical memory, the kernel either maps it (legitimate access) or sends a SIGSEGV (illegal access, segfault). Enforcing that Process A can't read Process B's memory. Managing physical RAM: which pages are free, which are used, which can be evicted.

File systems. Translating "open /data/data/your.app/databases/app.db" into actual reads from the NAND flash chip. Managing ext4 or f2fs file system structures, directory entries, file metadata, read/write caching. Enforcing file permissions based on UIDs.

Networking. The entire TCP/IP stack: socket creation, connection establishment (the three-way handshake), data transmission, congestion control, connection teardown. When your Dart code calls http.get(), the kernel manages the TCP connection, the IP routing, and the interaction with the Wi-Fi or cellular radio driver.

Device drivers. The touchscreen reports input events through a kernel driver. The GPU executes rendering commands through a kernel driver. The camera, accelerometer, gyroscope, Bluetooth radio, NFC chip — each has a kernel driver that translates hardware-specific protocols into a standard interface.

Security enforcement. UID-based file permissions (Post 1). SELinux mandatory access control policies. Capability checks. The kernel is the final authority on what any process is allowed to do.

User space and kernel space

The CPU in your phone has privilege levels. ARM processors call them Exception Levels: EL0 (user space, restricted) and EL1 (kernel space, privileged). There are higher levels too — EL2 for the hypervisor, EL3 for the secure monitor — but for our purposes, the split that matters is EL0 vs EL1.

Your Flutter app runs at EL0. At this privilege level, the CPU blocks certain operations:

  • Can't read or write hardware registers (can't talk to the GPU, the network interface, the flash storage)
  • Can't access memory outside the process's page table mappings
  • Can't modify the page tables themselves
  • Can't disable interrupts
  • Can't execute certain privileged instructions

The kernel runs at EL1. No restrictions. It can do anything the hardware supports.

This isn't a software policy. It's enforced by the CPU hardware. An EL0 instruction that tries to access a hardware register triggers a hardware exception — the CPU traps into EL1 (the kernel), which handles it. Usually by killing the process.

The consequence: your Dart code physically cannot read a file, send a network packet, or query a sensor. The hardware prevents it. The only way to do any of these things is to ask the kernel. The mechanism for asking is a system call.

What a system call looks like

A system call is a controlled transition from user space to kernel space. Here's what happens when code calls open("/data/file.txt", O_RDONLY):

  1. The C library (libc, specifically bionic on Android) puts the syscall number (for openat, it's 56 on ARM64) into register x8.
  2. The arguments (file path pointer, flags, mode) go into registers x0, x1, x2.
  3. The svc #0 instruction executes. This is the ARM equivalent of "please trap into the kernel now."
  4. The CPU switches from EL0 to EL1. The program counter jumps to the kernel's syscall entry point.
  5. The kernel reads register x8, looks up syscall 56 in its syscall table, and calls the openat handler.
  6. The handler checks permissions (does this UID have read access to this file?), finds the file in the filesystem, creates a file descriptor entry in the process's file descriptor table, and puts the file descriptor number into register x0.
  7. The kernel executes eret (exception return), switching back to EL0.
  8. The C library reads the return value from x0 and returns it to the caller.

Total time: a few microseconds. Fast, but not free. The privilege level switch, the kernel handler execution, and the return all have real cost. A tight loop that makes thousands of syscalls per second will feel it.

The chain from Dart to the kernel

This is where it gets concrete. Let's trace real operations from Dart through every layer.

Reading a file

dart
final content = await File('/data/data/your.app/files/config.json').readAsString();
javascript
Dart: File.readAsString()
  → dart:io FileSystemEntity
    → Dart VM's native runtime (C++)
      → POSIX: open("/data/data/your.app/files/config.json", O_RDONLY)
        → kernel: sys_openat() → returns fd 7
      → POSIX: fstat(7) → get file size (1,204 bytes)
        → kernel: sys_fstat()
      → POSIX: read(7, buffer, 1204) → reads file content into buffer
        → kernel: sys_read() → ext4 driver → flash storage → data
      → POSIX: close(7) → release file descriptor
        → kernel: sys_close()
    → Dart VM converts byte buffer to Dart String (UTF-8 decode)
  → Completer resolves with the String

Four syscalls for a single readAsString(). Open, stat, read, close. Each one is a user-space-to-kernel-space round trip.

Making a network request

dart
final response = await http.get(Uri.parse('https://api.example.com/data'));
javascript
Dart: http.get()
  → dart:io HttpClient
    → DNS resolution
      → kernel: sys_socket() → create UDP socket
      → kernel: sys_sendto() → send DNS query to resolver
      → kernel: sys_recvfrom() → receive DNS response
    → TCP connection
      → kernel: sys_socket() → create TCP socket (fd 12)
      → kernel: sys_connect() → initiate TCP handshake (SYN)
        → kernel TCP stack: SYN → SYN-ACK → ACK (three-way handshake)
    → TLS handshake
      → kernel: sys_write() / sys_read() × several rounds
        → (BoringSSL runs in user space, but reads/writes go through kernel)
    → HTTP request
      → kernel: sys_write() → send HTTP request bytes
    → HTTP response
      → kernel: sys_read() → receive HTTP response bytes
    → kernel: sys_close() → close socket

A single http.get() involves dozens of syscalls — DNS, TCP handshake, TLS handshake, data transfer. The networking stack in the kernel handles TCP sequencing, retransmission, congestion control, and routing. Your Dart code sees a Response object. The kernel saw a complex sequence of packet exchanges with the network hardware.

A timer

dart
await Future.delayed(const Duration(seconds: 2));
javascript
Dart: Future.delayed()
  → Dart VM registers the timer in the event loop
    → Engine: adds entry to the timer queue
      → Event loop calls epoll_wait() with a timeout
        → kernel: sys_epoll_wait(timeout=2000ms)
          → kernel timer subsystem: schedule a wake-up
          → process sleeps (no CPU usage)
          → 2 seconds later: kernel wakes the thread
        → epoll_wait returns
      → Engine: timer has fired, schedule microtask
    → Dart VM: run the Future's callback

A Future.delayed that "does nothing for 2 seconds" is your process sleeping in a kernel syscall. The process uses zero CPU during the wait. The kernel's timer subsystem handles the wake-up.

The idle app

This is the most revealing one. Your Flutter app is sitting idle — the user is looking at a static screen, nothing is animating, no network requests in flight. What's the process doing?

bash
adb shell strace -p 14823 -e trace=epoll_wait -c

One syscall, over and over: epoll_wait(). This is the heartbeat of your Flutter app. The engine's event loop is blocked in epoll_wait, asking the kernel: "wake me up when any of these things happen: a VSync signal arrives, a timer fires, I/O completes, a platform channel message arrives, or a touch event comes in." Until one of those happens, the process sleeps. Zero CPU.

epoll is Linux's scalable I/O event notification mechanism. It can watch thousands of file descriptors simultaneously. The Flutter engine registers its VSync callback, its timer queue, its I/O completion ports, and its platform channel socket with a single epoll instance. The event loop is a single epoll_wait call that returns whenever any registered event fires.

This is why an idle Flutter app uses almost no CPU. It's literally sleeping in the kernel, waiting to be woken up.

strace: making syscalls visible

strace is the tool that shows you what your process is actually asking the kernel. On a rooted device or with a debug build:

bash
adb shell strace -p 14823 -f -t 2>&1 | head -50
javascript
14823 10:32:15 epoll_wait(8, [{EPOLLIN, {u32=12}}], 16, 16) = 1
14823 10:32:15 read(12, "\1\0\0\0\0\0\0\0", 8) = 8
14823 10:32:15 ioctl(14, BINDER_WRITE_READ, ...) = 0
14830 10:32:15 futex(0x7a9c001000, FUTEX_WAKE, 1) = 1
14831 10:32:15 write(9, "\x00\x00\x02\x40...", 576) = 576
14831 10:32:15 ioctl(6, DRM_IOCTL_..., ...) = 0
14823 10:32:15 epoll_wait(8, [], 16, 16) = 0

What you're seeing:

  • `epoll_wait` — the event loop waiting for events. The 16 at the end is the timeout in milliseconds — one frame budget at 60fps. If nothing happens in 16ms, it returns empty and the loop continues.
  • `read` — reading from a file descriptor (could be a VSync signal, a platform channel message, timer data).
  • `ioctl` on BINDER — a Binder IPC call to a system service (Post 4 covers this in depth).
  • `futex` — a fast userspace mutex. Thread synchronisation between the UI thread and the raster thread.
  • `write` — writing data (could be sending GPU commands, writing to a socket).
  • `ioctl` on DRM — a GPU command, submitting render work to the graphics driver.

This is your Flutter app, stripped of every abstraction. No widgets, no BLoCs, no state management. Just a process making syscalls to the kernel, thousands of times per second.

Syscalls that matter for Flutter

Some syscalls appear constantly in Flutter app traces and are worth recognising:

`epoll_wait` — the event loop. If your app is spending most of its time here, it's idle (good). If epoll_wait returns frequently with short timeouts and your CPU usage is high, something is scheduling unnecessary work.

`ioctl` on `/dev/binder` — Binder IPC. Every platform channel call that reaches a system service goes through this. Frequent Binder ioctls during performance-critical sections (like scrolling) indicate system service calls that should be deferred or cached.

`futex` — thread synchronisation. The engine uses futexes to coordinate between the UI thread, raster thread, and I/O thread. Excessive futex contention (visible in strace as FUTEX_WAIT calls that take milliseconds) indicates thread scheduling problems.

`ioctl` on `/dev/dri/` or `/dev/mali0` — GPU commands. These are Impeller submitting render work to the GPU. On frames that take too long, look at whether the GPU ioctls are slow (GPU bottleneck) or whether the time is spent before the ioctl (CPU bottleneck in the render pipeline).

`mmap` / `munmap` — memory mapping. Large allocations (images, shader buffers) often go through mmap rather than malloc. Frequent mmap/munmap during steady-state operation can indicate memory churn.

`clock_gettime` — time queries. Flutter's frame scheduler, animation controllers, and performance tracing all query the system clock. This syscall is special: on ARM64, it's handled via the vDSO (virtual dynamic shared object), which means it doesn't actually enter the kernel — the kernel maps a read-only page of clock data into user space, and the C library reads it directly. So clock_gettime is a "free" syscall. The kernel optimised it away.

The cost of crossing the boundary

Each syscall has a fixed overhead: saving registers, switching privilege levels, running the kernel handler, switching back. On modern ARM hardware, a minimal syscall (like clock_gettime without vDSO) takes about 0.5-1 microsecond. That sounds trivial, but it adds up.

Reading a file one byte at a time (a syscall per byte) is catastrophically slow — not because reading a byte is slow, but because the syscall overhead dominates. Reading 10,000 bytes in one read() call takes roughly the same time as reading 1 byte, because the kernel overhead is the same and the actual data transfer from the page cache is nearly instantaneous.

This is why buffered I/O exists. Dart's File.readAsString() reads the entire file in one or a few large read() calls, not byte by byte. dart:io's stream-based APIs use internal buffers — typically 64KB — to amortise the syscall overhead across many bytes.

For network I/O, the same principle applies. The kernel's TCP receive buffer accumulates incoming data. A single read() on the socket returns whatever has accumulated — potentially many packets' worth of data in one syscall.

The practical advice: batch operations when possible. If you need to read 10 small files, reading them in parallel (10 concurrent readAsString() calls) is better than reading them sequentially, because the kernel can schedule the I/O operations concurrently. If you're making multiple platform channel calls that each trigger a Binder IPC, consider whether a single call that returns all the data would be more efficient.

Connecting upward

The kernel is the foundation that everything in this series builds on. Binder (Post 4) is a kernel driver. The memory management story (Post 5) is about the kernel's page management. The Low Memory Killer (Post 5) is a kernel daemon. The graphics pipeline (Post 8) submits commands to the GPU through kernel ioctls. Process creation (Post 2's fork()) is a syscall.

Every abstraction your Flutter app uses — Dart's File class, the http package, SharedPreferences, every plugin — eventually reaches a point where it asks the kernel to do something. The syscall boundary is where software meets hardware, mediated by the one piece of code on the device that has permission to touch both.

The next post looks at the most unusual thing that crosses this boundary: Binder, Android's custom IPC mechanism, and the invisible plumbing that connects your app to every system service on the device.

This is Post 3 of the Android Under the Surface series. Previous: Zygote: How Android Launches Apps Fast. Next: Binder: How Your App Talks to the Rest of Android

Related Topics

linux kernel androidsystem calls flutterstrace androidflutter syscallandroid kernel explaineddart file read kernelepoll flutter event loop

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.