Kernel Architecture & OS Internals Questions
How an operating system kernel is structured and what it is responsible for: monolithic vs. microkernel designs, the role of kernel subsystems (scheduler, memory manager, VFS, drivers), kernel vs. user space, and the boot/initialization path. Includes how kernel modules and device drivers extend the kernel and how the kernel mediates access to hardware.
Describe how hardware interrupts are handled by an operating system. Explain the flow from IRQ to interrupt handler, the concepts of interrupt context vs process context, and kernel mechanisms like softirqs, tasklets, and threaded IRQs used to defer work safely. Discuss performance and latency trade-offs.
Sample Answer
When a device raises an IRQ the CPU sees an interrupt signal and hardware/CPU gates switch to the interrupt vector table to find the registered handler (ISR). Flow: hardware interrupt -> CPU saves minimal state and switches to kernel mode -> jump to the top-half ISR registered for that IRQ -> ISR performs minimal, time-critical work (ack the device, clear interrupt, read small status) and then defers longer processing to a bottom-half mechanism -> return from interrupt, CPU restores state and resumes the interrupted context.
Interrupt context vs process context:
- Interrupt context runs on behalf of the kernel with interrupts disabled (or with lower priority masks) and cannot sleep or block; it must not call blocking APIs or take long locks.
- Process context is a normal thread path (can sleep, block, be scheduled) and runs on behalf of a process.
Kernel deferred-work mechanisms:
- Softirqs: statically defined, run in software-interrupt context; can be re-entered and are used for networking and block device processing. They run with certain guarantees and are processed on return from hardware ISR or by ksoftirqd.
- Tasklets: a lightweight, per-CPU layer built on softirqs providing serialized execution (non-reentrant) for simpler deferral semantics.
- Threaded IRQs: the kernel creates a kernel thread for a given IRQ; the hard IRQ handler runs minimal work and wakes the thread, which runs in process context so it may sleep and use blocking primitives—easier to write but higher latency.
Performance and latency trade-offs:
- Top-half only: lowest latency but must be tiny and non-blocking.
- Softirqs/tasklets: good throughput and low overhead for high-rate events because they avoid context switches, but risk hogging CPU and increasing tail latency; softirqs run in interrupt context so still can't block.
- Threaded IRQs: higher latency due to scheduling and context switches but simpler, safer code and better for handlers that may sleep or take long locks; useful to isolate heavy handlers and reduce interrupt disabling time.
Tuning requires balancing throughput vs worst-case latency: use softirqs/tasklets for high-frequency, short work; use threaded IRQs for complex processing where predictability and safety matter.
Write a POSIX-compliant shell script (bash) that atomically updates /etc/myapp/config.yaml: it should validate the new config with a provided validation command, create a timestamped backup of the current file, and replace the config only if validation succeeds. Describe how you avoid race conditions and partial updates.
Sample Answer
Approach: write the candidate config to a secure temp file on the same filesystem, validate that temp with the supplied validation command, create a timestamped backup of the live config, then atomically replace the live config by renaming the temp file into place. Use an atomic lock (mkdir-based) to avoid concurrent writers and traps to clean up partial state.
#!/usr/bin/env bash
set -eu -o pipefail
TARGET="/etc/myapp/config.yaml"
LOCKDIR="/var/lock/myapp-config.lock"
TIMESTAMP="$(date +%Y%m%dT%H%M%S%z)"
BACKUP="${TARGET}.${TIMESTAMP}.bak"
usage() {
echo "Usage: $0 <validation-cmd> <new-config-file-or->"
exit 2
}
if [ $# -ne 2 ]; then usage; fi
VALIDATOR="$1"
INPUT="$2"
cleanup() {
rm -f "$TMPFILE" || true
rmdir "$LOCKDIR" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Acquire lock (atomic mkdir). Fails if another process holds it.
if ! mkdir "$LOCKDIR" 2>/dev/null; then
echo "Failed to acquire lock. Another update may be in progress." >&2
exit 1
fi
# Create temp file in same dir to guarantee rename atomicity
TMPDIR="$(dirname "$TARGET")"
TMPFILE="$(mktemp "${TMPDIR}/config.yaml.tmp.XXXXXXXX")"
# Populate temp file from stdin or named file
if [ "$INPUT" = "-" ]; then
cat - > "$TMPFILE"
else
cp -- "$INPUT" "$TMPFILE"
fi
# Ensure restricted permissions like original (if exists), otherwise 0640
if [ -e "$TARGET" ]; then
PERMS=$(stat -c '%a' "$TARGET")
OWNER=$(stat -c '%u:%g' "$TARGET")
chmod "$PERMS" "$TMPFILE"
chown "$OWNER" "$TMPFILE" || true
else
chmod 0640 "$TMPFILE"
fi
# Validate temp file
if ! $VALIDATOR "$TMPFILE"; then
echo "Validation failed; aborting update." >&2
exit 1
fi
# Create timestamped backup of existing config (if exists)
if [ -e "$TARGET" ]; then
cp -- "$TARGET" "$BACKUP"
fi
# Atomic replace: move temp into place
mv -f -- "$TMPFILE" "$TARGET"
# success; cleanup handled by trap (rmdir)
trap - EXIT
cleanup
echo "Config updated successfully; backup: ${BACKUP:-none}"
exit 0
Why this is atomic and race-resistant:
- mkdir acts as an atomic lock primitive (portable and robust) to serialize updaters.
- Writing to a temp file in the same directory ensures mv/rename is atomic on POSIX filesystems — no partial writes visible.
- Validation runs against the temp file, so the live config is unchanged until validation succeeds.
- Backup is taken before the final mv so you can restore if needed.
- Traps and cleanup remove leftover temp files and release the lock on exit or signals.
Explain how to configure and use Linux capabilities to run network-facing services without full root privileges. Provide an example command to give a binary the ability to bind to low-numbered ports without being root, and discuss security implications.
Sample Answer
Linux capabilities partition the all-powerful root privileges into fine-grained rights you can grant to processes or binaries. For network-facing services you typically want CAP_NET_BIND_SERVICE so a non-root process can bind to ports <1024 (e.g., 80/443) without running as UID 0.
Example: give a binary the capability to bind low ports:
sudo setcap 'cap_net_bind_service=+ep' /usr/local/bin/myserver
- cap_net_bind_service=+ep: add ( + ) the capability to the file's effective ( e ) and permitted ( p ) sets.
- After this, running /usr/local/bin/myserver as an unprivileged user can bind to port 80.
Verify:
getcap /usr/local/bin/myserver
ss -ltnp | grep :80
Security implications and best practices:
- Grant the minimal capability required (principle of least privilege). CAP_NET_BIND_SERVICE only affects binding; it doesn't let the process read/write arbitrary files.
- setcap attaches capabilities to the file; replacing the binary (package update) can remove them—ensure deployment pipelines reapply if needed.
- Capabilities can be inherited by child processes; design the service to drop capabilities after binding if possible (e.g., bind first, then drop privileges).
- An exploited service with this capability still lets an attacker bind low ports if they can run code in that binary’s context—so combine with other mitigations: sandboxing (seccomp, namespaces), chroot/containers, SELinux/AppArmor, and running as a dedicated low-privilege user.
- Avoid giving capabilities to scripts (they typically won’t work) and prefer granting to compiled binaries or using systemd’s capability directives (CapabilityBoundingSet, AmbientCapabilities) for better lifecycle control.
Alternative approaches: use a reverse proxy (run proxy as root or with capability and forward to high port), use authbind, or run behind a load balancer.
Compare threads and processes on Linux from an implementation perspective: address space layout, how clone/fork work, how signals are delivered to threads, and how schedulers treat them. Explain the cost of fork() in modern Linux (copy-on-write) and when vfork() or posix_spawn are appropriate.
Sample Answer
Address space layout
- Processes: each process has its own mm_struct — separate virtual address space, page tables, and VMAs. Kernel resources (files, fds, pgrp) are referenced via kernel objects.
- Threads (Linux "light-weight processes"): kernel threads created via clone() with CLONE_VM share the same mm_struct and VMAs — same virtual addresses, same heap/stack regions (but each thread has its own kernel stack and TID).
How clone/fork work
- fork(): kernel creates a new task_struct for child, duplicates pointers to mm_struct but uses copy-on-write (COW) for pages; a new mm_struct may be allocated (depending on implementation) but page tables are shared logically until write. File descriptors, signal handlers, etc., are copied as references.
- clone(flags): fine-grained control; flags like CLONE_VM (share address space), CLONE_FILES, CLONE_SIGHAND, CLONE_THREAD determine which resources are shared. clone can implement both threads (share VM + signal handlers) and processes (no shared VM).
Signals delivery to threads
- Linux has the concept of a thread group. Signals directed to the process (kill(pid, sig)) are delivered to one eligible thread in the group; thread-directed signals (tgkill/tg) or pthread_kill target a specific TID. Some signals (SIGKILL, SIGSTOP) act on the whole process. Signal handlers (if shared via CLONE_SIGHAND) are common to threads; per-thread masks control delivery.
Scheduling
- The kernel scheduler schedules each task_struct (each thread or process) independently — threads are kernel-schedulable tasks with their own priorities and scheduling policies. From scheduler's POV, a thread is just another task with shared memory; cgroups/rt policies still apply per thread unless managed at process level.
Cost of fork() today
- COW avoids copying all memory pages at fork time, so the dominant costs are: allocating/duplicating kernel bookkeeping (task_struct, file descriptor refs), copying VMAs and page tables metadata, walking and pinning page tables, and potential TLB flushes/context overhead. In large-memory, multi-threaded programs, fork can be expensive because the child inherits a huge address space (even though pages are not copied immediately), and fork in a multi-threaded parent requires careful synchronization (only async-signal-safe functions in child before exec).
When to use vfork() or posix_spawn()
- vfork(): historically used to avoid copying page tables — child runs in parent's address space and the parent is suspended until child execs/_exit. Use only if child immediately calls execve() or _exit() and must not modify data or return from the function that called vfork; dangerous in threaded programs.
- posix_spawn(): higher-level API that combines fork+exec efficiently; implementations often use vfork() or optimized kernel calls and handle file actions without full fork. Prefer posix_spawn (or posix_spawnp) in multi-threaded applications or when fork+exec is the goal — safer and often faster than fork+exec, avoiding large COW overhead and pthread locks/risks.
Practical guidance
- For single-threaded short-lived children: fork+exec is fine.
- For multi-threaded or large-memory parents: use posix_spawn or design a helper process (spawn server) to avoid expensive fork or vfork pitfalls.
Describe at a high level how to write a Linux kernel module that registers a character device. Include the steps to allocate device numbers, create a device file (/dev/mychardev), implement file_operations open/read/write/release, and load/unload the module safely. Mention common pitfalls for memory and concurrency in kernel modules.
Sample Answer
Approach (high level):
- Reserve a major/minor via alloc_chrdev_region (or register_chrdev_region for fixed numbers).
- Initialize a struct cdev with cdev_init and add it with cdev_add.
- Create a sysfs class and device so udev creates /dev/mychardev (class_create + device_create).
- Implement struct file_operations with .open, .read, .write, .release (and .unlocked_ioctl if needed).
- Use proper locking (mutex or spinlock) and safe memory allocation/free (kmalloc/kfree).
- Provide module_init to perform setup and module_exit to undo in reverse order.
Minimal skeleton (illustrative):
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/mutex.h>
#define DEV_NAME "mychardev"
static dev_t devt;
static struct cdev my_cdev;
static struct class *my_class;
static char *kbuf;
static DEFINE_MUTEX(my_lock);
static int my_open(struct inode *inode, struct file *filp){
return 0;
}
static ssize_t my_read(struct file *filp, char __user *buf, size_t len, loff_t *off){
ssize_t ret = 0;
if (mutex_lock_interruptible(&my_lock)) return -EINTR;
/* copy_to_user from kbuf */
if (copy_to_user(buf, kbuf, min(len, (size_t)strlen(kbuf)))) ret = -EFAULT;
else ret = min(len, (size_t)strlen(kbuf));
mutex_unlock(&my_lock);
return ret;
}
static ssize_t my_write(struct file *filp, const char __user *buf, size_t len, loff_t *off){
ssize_t ret = 0;
if (len > 1024) return -EINVAL;
if (mutex_lock_interruptible(&my_lock)) return -EINTR;
if (copy_from_user(kbuf, buf, len)) ret = -EFAULT;
else ret = len;
mutex_unlock(&my_lock);
return ret;
}
static int my_release(struct inode *inode, struct file *filp){ return 0; }
static const struct file_operations fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
static int __init my_init(void){
if (alloc_chrdev_region(&devt, 0, 1, DEV_NAME) < 0) return -1;
cdev_init(&my_cdev, &fops);
if (cdev_add(&my_cdev, devt, 1) < 0) goto unregister;
my_class = class_create(THIS_MODULE, DEV_NAME);
if (IS_ERR(my_class)) goto del_cdev;
device_create(my_class, NULL, devt, NULL, DEV_NAME);
kbuf = kmalloc(1024, GFP_KERNEL);
if (!kbuf) goto destroy_class;
return 0;
destroy_class:
class_destroy(my_class);
device_destroy(my_class, devt);
del_cdev:
cdev_del(&my_cdev);
unregister:
unregister_chrdev_region(devt, 1);
return -ENOMEM;
}
static void __exit my_exit(void){
kfree(kbuf);
device_destroy(my_class, devt);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(devt, 1);
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
Key explanations & pitfalls:
- Order matters: create device after cdev_add; on unload reverse order and handle partial failures in init.
- Memory: always free kmalloc in exit and on init failure paths to avoid leaks; prefer kzalloc for zeroed memory.
- Concurrency: protect shared state with mutex for sleeping contexts; use spinlock for IRQ context and be careful with copy_to/from_user (must be called in process context).
- Blocking: do not sleep in atomic contexts; avoid calling copy_to_user while holding spinlocks.
- User pointers: always validate and use copy_to_user/copy_from_user to avoid page faults.
- Reference counts: use module_put/get if exporting symbols; ensure file->private_data cleaned on release.
- Device numbers: consider dynamic alloc (alloc_chrdev_region) to avoid conflicts; store major/minor for logging.
- Error handling: on init failure undo any partial setup to avoid orphan devices.
Unlock Full Question Bank
Get access to all Kernel Architecture & OS Internals interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.