When you type a command such as:
ls
a lot happens behind the scenes.
The shell does not directly “become” ls by itself. Instead, it asks the Linux kernel to execute the program. The main system call involved is:
execve()
The execve system call replaces the current process image with a new program.
That means the process keeps the same process ID, but its memory, code, stack, arguments, and environment are replaced with those of the new program.
A simple view looks like this:
Before execve:
Process PID 1234
+----------------------+
| Program: shell |
| Code: /bin/bash |
| Args: bash |
| Env: PATH=... |
+----------------------+
execve("/bin/ls", ["ls"], envp)
After execve:
Process PID 1234
+----------------------+
| Program: ls |
| Code: /bin/ls |
| Args: ls |
| Env: PATH=... |
+----------------------+
The process did not get a new PID. It became a different program.
Program execution usually involves several layers.
User types command
|
v
Shell parses command
|
v
Shell usually calls fork()
|
v
Child process calls execve()
|
v
Kernel loads executable
|
v
Kernel sets up memory, stack, arguments, environment
|
v
Program starts running
For a normal shell command, the shell usually uses both:
This is why the shell survives after running a command.
If the shell directly called execve("ls", ...) without forking first, the shell itself would be replaced by ls.
execve System CallThe C function signature is:
int execve(const char *pathname, char *const argv[], char *const envp[]);
The arguments are:
Example:
execve("/bin/ls", ["ls", "-l", "/tmp", NULL], envp);
This asks the kernel to run:
/bin/ls -l /tmp
The new program receives:
argv[0] = "ls"
argv[1] = "-l"
argv[2] = "/tmp"
Environment variables such as PATH, HOME, USER, and LANG can also be passed to the new program.
execveexecve does not create a new process.
It transforms the current process.
This distinction is very important.
Shell
|
| fork()
v
Child process
|
| execve("/bin/ls")
v
ls process
The shell remains alive because it forked first. The child becomes ls.
execveWhen a process calls execve, the kernel performs several steps.
A simplified kernel-side flow looks like this:
execve()
|
v
do_execveat_common()
|
v
prepare binary parameters
|
v
search_binary_handler()
|
+--> ELF executable -> load_elf_binary()
|
+--> script with #! -> load_script()
|
+--> other binary format -> matching handler
|
v
set up memory mappings
|
v
set up stack and registers
|
v
jump to program entry point
Function names can vary slightly between kernel versions. On many modern kernels, useful functions to inspect include:
execve with straceThe easiest way to observe program execution is with strace.
Run:
strace -e execve ls
Example output:
execve("/usr/bin/ls", ["ls"], 0x7ffc8e0a9b10 /* 45 vars */) = 0
Interpretation:
Normally, a successful execve does not return to the old program. If execve returns, it usually means it failed.
Example failure:
strace -e execve /no/such/program
Example output:
execve("/no/such/program", ["/no/such/program"], 0x7ffd...) = -1 ENOENT (No such file or directory)
Interpretation:
execve and the ShellWhen you run:
ls -l
the shell usually searches your PATH to find ls.
If PATH includes:
/usr/local/bin:/usr/bin:/bin
the shell may find:
/usr/bin/ls
Then it runs something conceptually like:
fork();
execve("/usr/bin/ls", ["ls", "-l", NULL], envp);
The kernel does not search PATH for execve.
Important distinction:
Some higher-level functions, such as execvp, do search PATH, but they are library functions. They eventually call execve.
Not every executable file is a compiled binary.
A script may begin with a shebang line:
#!/bin/sh
Example script:
#!/bin/sh
echo "Hello from script"
When the kernel sees the #! line, it uses the interpreter.
Script file:
./hello.sh
First line:
#!/bin/sh
Kernel executes:
/bin/sh ./hello.sh
The flow looks like this:
execve("./hello.sh")
|
v
Kernel sees #!/bin/sh
|
v
Kernel executes interpreter
|
v
execve("/bin/sh", ["/bin/sh", "./hello.sh"], envp)
This is why the interpreter path must be valid.
If the shebang points to a missing interpreter, the script fails even if the script itself exists.
For execve to work, permissions matter.
Check a file:
ls -l ./hello
Example:
-rwxr-xr-x 1 user user 16000 Jun 1 12:00 hello
The x bits mean the file is executable.
If a file is not executable:
-rw-r--r-- 1 user user 16000 Jun 1 12:00 hello
running it may fail:
./hello
Example output:
bash: ./hello: Permission denied
Fix:
chmod +x ./hello
Linux can execute several types of files through binary format handlers.
Common examples:
Most compiled Linux programs use the ELF format.
ELF stands for Executable and Linkable Format.
To inspect a binary:
file /bin/ls
Example output:
/bin/ls: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
Interpretation:
For ELF binaries, the kernel uses the ELF loader.
A simplified ELF loading flow:
Kernel opens executable
|
v
Reads ELF header
|
v
Checks architecture and format
|
v
Maps program segments into memory
|
v
Sets up stack with argv, envp, auxiliary vector
|
v
Sets instruction pointer to entry point
|
v
Program begins execution
To inspect the ELF header:
readelf -h /bin/ls
Example output:
ELF Header:
Class: ELF64
Machine: Advanced Micro Devices X86-64
Entry point address: 0x61d0
Interpretation:
Programs can be statically linked or dynamically linked.
Static linking: library code is included inside the executable
Dynamic linking: the executable depends on shared libraries at runtime
A statically linked binary contains the library code it needs.
Compile statically:
gcc -static -o hello-static hello.c
Check it:
file hello-static
Example output:
hello-static: ELF 64-bit LSB executable, x86-64, statically linked
Run:
ldd hello-static
Example output:
not a dynamic executable
Interpretation:
Static binaries are useful for initramfs environments because they do not need many external libraries.
Most normal Linux programs are dynamically linked.
A dynamically linked program depends on shared libraries such as:
The kernel loads the executable and notices that it needs an interpreter, commonly:
/lib64/ld-linux-x86-64.so.2
or on Debian/Ubuntu systems:
/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
Then the dynamic linker loads the shared libraries.
Executable
|
v
Kernel sees dynamic linker path
|
v
Dynamic linker starts
|
v
Shared libraries are loaded
|
v
Program main() begins
Diagram:
+-------------------+ +-------------------+ +-------------------+
| Executable | ----> | Dynamic Linker | ----> | Shared Libraries |
| ./myapp | | ld-linux... | | libc.so.6, etc. |
+-------------------+ +-------------------+ +-------------------+
lddUse:
ldd /usr/bin/ls
Example output:
linux-vdso.so.1 (0x00007ffc12345000)
libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2
Interpretation:
If a library is missing, ldd may show:
libexample.so => not found
That means the program may fail to start.
The dynamic linker searches for libraries using several sources.
Common places include:
The flow is roughly:
Program needs libexample.so
|
v
Dynamic linker checks configured search paths
|
v
If found, library is loaded
|
v
If not found, program fails
ldconfigThe ldconfig command updates the shared library cache.
The cache is stored at:
/etc/ld.so.cache
To update it:
sudo ldconfig
To add a custom library directory:
echo "/opt/myapp/lib" | sudo tee /etc/ld.so.conf.d/myapp.conf
sudo ldconfig
Check whether a library is known:
ldconfig -p | grep mylib
LD_LIBRARY_PATHLD_LIBRARY_PATH temporarily adds directories to the library search path.
Example:
export LD_LIBRARY_PATH=/opt/myapp/lib:$LD_LIBRARY_PATH
./myapp
This is useful for testing and development.
However, it can cause version conflicts if used carelessly.
A good rule is:
Building a custom kernel is a useful way to understand how Linux executes programs internally.
This is a lab activity. It should be done in a virtual machine, containerized build environment, or disposable test system.
Do not replace the kernel on an important machine unless you understand kernel recovery.
Clone the kernel source:
git clone https://github.com/torvalds/linux.git
cd linux
Create a default configuration:
make defconfig
This creates a .config file for your architecture.
For kernel debugging, useful settings include:
Open the configuration menu:
make menuconfig
Useful options to consider:
KASLR means Kernel Address Space Layout Randomization. It randomizes where the kernel is loaded in memory.
For debugging, disabling KASLR makes addresses easier to understand.
Build with all CPU cores:
make -j$(nproc)
Build GDB helper scripts:
make scripts_gdb
The uncompressed debug kernel image is usually:
vmlinux
The bootable compressed kernel image on x86 is usually:
arch/x86/boot/bzImage
An initramfs is an initial RAM filesystem loaded by the kernel during boot.
It provides a minimal user-space environment before the real root filesystem is mounted.
For a kernel execution lab, an initramfs can contain:
The kernel starts the first user-space process, usually:
/init
inside the initramfs.
BusyBox provides many small Unix utilities in one binary.
It is commonly used in initramfs labs.
Download and extract:
wget https://busybox.net/downloads/busybox-1.35.0.tar.bz2
tar -xjf busybox-1.35.0.tar.bz2
cd busybox-1.35.0
Configure:
make defconfig
make menuconfig
Enable:
Build:
make -j$(nproc)
make install
This creates:
_install/
containing BusyBox and utility symlinks.
Create directories:
mkdir -p ../initramfs/{bin,sbin,etc,proc,sys,usr/{bin,sbin}}
Copy BusyBox files:
cp -r _install/* ../initramfs/
Create /init:
cat > ../initramfs/init << 'EOF'
#!/bin/sh
mount -t proc none /proc
mount -t sysfs none /sys
echo "Welcome to the custom initramfs shell!"
exec /bin/sh
EOF
Make it executable:
chmod +x ../initramfs/init
Important note:
Create a small C program:
cat > ../initramfs/hello.c << 'EOF'
#include <stdio.h>
int main(void) {
printf("Hello from the custom kernel!\n");
return 0;
}
EOF
Compile it statically:
gcc -static -o ../initramfs/bin/hello ../initramfs/hello.c
Check:
file ../initramfs/bin/hello
Expected output includes:
statically linked
This matters because the minimal initramfs may not contain shared libraries.
From inside the initramfs directory:
cd ../initramfs
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz
cd ..
This creates:
initramfs.cpio.gz
The kernel can load this archive as its initial filesystem.
QEMU can boot the custom kernel without replacing your real system kernel.
Run:
qemu-system-x86_64 \
-kernel linux/arch/x86/boot/bzImage \
-initrd initramfs.cpio.gz \
-append "console=ttyS0" \
-nographic \
-s -S
Option meanings:
The -s -S options are useful for debugging.
In another terminal:
gdb linux/vmlinux
Connect to QEMU:
target remote :1234
The kernel is paused.
Useful setup:
set pagination off
Set a breakpoint.
Depending on kernel version, try:
break do_execveat_common
or:
break bprm_execve
or search for relevant symbols:
info functions execve
info functions search_binary_handler
Continue:
continue
When you run a program inside QEMU, the breakpoint should trigger during execution.
The kernel uses binary handlers to decide how to execute a file.
The important function is commonly:
search_binary_handler
It checks available handlers and selects the right one.
Examples:
Set a breakpoint:
break search_binary_handler
continue
When it stops, inspect the execution path.
For ELF binaries, another useful breakpoint is:
break load_elf_binary
continue
After loading the binary, the kernel prepares CPU registers so execution begins at the program entry point.
On x86-64, the instruction pointer register controls the next instruction to execute.
In kernel debugging, you may inspect something like:
print/x regs->ip
The exact register structure and symbol names can vary by kernel version and architecture.
Compare with:
readelf -h hello
Look for:
Entry point address
Interpretation:
execve for a Normal CommandSee the system call used to execute a program.
strace -e execve ls
execve("/usr/bin/ls", ["ls"], 0x7ffc8e0a9b10 /* 45 vars */) = 0
Interpretation:
Use this when you want to confirm which executable is actually being run.
execve Replaces the Current ProcessDemonstrate that execve does not create a new PID.
cat > exec-demo.c << 'EOF'
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("Before execve: PID=%d\n", getpid());
char *argv[] = {"/bin/echo", "Hello after execve", NULL};
char *envp[] = {NULL};
execve("/bin/echo", argv, envp);
perror("execve failed");
return 1;
}
EOF
Compile:
gcc -o exec-demo exec-demo.c
Run:
./exec-demo
Before execve: PID=8123
Hello after execve
Interpretation:
The line after execve only runs if execve fails.
See how executing a script causes the interpreter to run.
cat > hello-script.sh << 'EOF'
#!/bin/sh
echo "hello from script"
EOF
chmod +x hello-script.sh
Trace it:
strace -e execve ./hello-script.sh
execve("./hello-script.sh", ["./hello-script.sh"], 0x7ffc...) = 0
hello from script
Depending on tracing options and shell behavior, you may also observe /bin/sh being involved.
Interpretation:
Change the shebang to a missing interpreter:
cat > bad-script.sh << 'EOF'
#!/no/such/interpreter
echo "this will not run"
EOF
chmod +x bad-script.sh
./bad-script.sh
Example output:
bash: ./bad-script.sh: cannot execute: required file not found
Interpretation:
Understand the difference between static and dynamic linking.
cat > hello.c << 'EOF'
#include <stdio.h>
int main(void) {
puts("hello");
return 0;
}
EOF
Compile dynamically:
gcc -o hello-dynamic hello.c
Compile statically:
gcc -static -o hello-static hello.c
Check:
file hello-dynamic hello-static
hello-dynamic: ELF 64-bit LSB pie executable, x86-64, dynamically linked
hello-static: ELF 64-bit LSB executable, x86-64, statically linked
Run ldd:
ldd hello-dynamic
ldd hello-static
linux-vdso.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2
not a dynamic executable
Interpretation:
Practice identifying why a dynamically linked program fails to start.
If a program depends on a missing library, running ldd may show:
ldd ./myapp
Example output:
libexample.so => not found
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
Interpretation:
Temporary development fix:
export LD_LIBRARY_PATH=/opt/myapp/lib:$LD_LIBRARY_PATH
./myapp
System-wide fix:
echo "/opt/myapp/lib" | sudo tee /etc/ld.so.conf.d/myapp.conf
sudo ldconfig
Verify:
ldconfig -p | grep libexample
Verify that a custom kernel can start a minimal user space and execute a test program.
Boot QEMU:
qemu-system-x86_64 \
-kernel linux/arch/x86/boot/bzImage \
-initrd initramfs.cpio.gz \
-append "console=ttyS0" \
-nographic
Inside QEMU, run:
/bin/hello
Welcome to the custom initramfs shell!
/ # /bin/hello
Hello from the custom kernel!
Interpretation:
This confirms that the kernel can load and execute a user-space ELF program.
Observe the kernel execution path when a program runs.
qemu-system-x86_64 \
-kernel linux/arch/x86/boot/bzImage \
-initrd initramfs.cpio.gz \
-append "console=ttyS0" \
-nographic \
-s -S
gdb linux/vmlinux
Inside GDB:
target remote :1234
set pagination off
info functions execve
break do_execveat_common
continue
If the symbol is unavailable, try:
break bprm_execve
break search_binary_handler
break load_elf_binary
Inside QEMU:
/bin/hello
Breakpoint 1, do_execveat_common (...)
Interpretation:
Watch the kernel choose the correct binary format handler.
break search_binary_handler
break load_elf_binary
continue
Run inside QEMU:
/bin/hello
This shows how Linux supports multiple executable formats.
Connect the ELF entry point to where execution begins.
Inside the build host:
readelf -h initramfs/bin/hello
Example output:
Entry point address: 0x401530
Near the end of ELF loading, inspect the instruction pointer setup.
Example:
print/x regs->ip
Example output:
$1 = 0x401530
Interpretation:
Exact addresses and structures may vary depending on architecture, compiler, static vs dynamic linking, and kernel version.
Understand that dynamically linked programs involve the dynamic linker.
file /bin/ls
Example:
/bin/ls: ELF 64-bit LSB pie executable, x86-64, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
Inspect program headers:
readelf -l /bin/ls | grep interpreter
Example output:
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
Interpretation:
This is why dynamically linked binaries need the correct linker and libraries to exist in the runtime environment.
Example:
bash: ./hello: Permission denied
Check:
ls -l ./hello
Fix:
chmod +x ./hello
Interpretation:
The file exists, but it is not executable.
Example:
bash: ./hello: No such file or directory
Possible causes:
Check:
ls -l ./hello
file ./hello
readelf -l ./hello | grep interpreter
ldd ./hello
If file shows a dynamic interpreter that does not exist, the binary cannot start.
Example:
error while loading shared libraries: libexample.so: cannot open shared object file
Check:
ldd ./myapp
Fix:
export LD_LIBRARY_PATH=/path/to/lib:$LD_LIBRARY_PATH
or:
echo "/path/to/lib" | sudo tee /etc/ld.so.conf.d/myapp.conf
sudo ldconfig
Example:
Kernel panic - not syncing: No working init found
Possible causes:
Check initramfs contents:
mkdir /tmp/initramfs-check
cd /tmp/initramfs-check
zcat /path/to/initramfs.cpio.gz | cpio -idmv
ls -l init
file bin/busybox
Fix:
chmod +x init
Make sure /bin/sh exists.
Possible causes:
Check symbols:
info functions execve
info functions binary_handler
info functions load_elf
Make sure GDB uses:
linux/vmlinux
not only the compressed bzImage.
Observe execution:
strace -e execve ls
strace -f -e execve bash -c 'ls | wc -l'
Inspect binaries:
file ./program
readelf -h ./program
readelf -l ./program
ldd ./program
Compile test programs:
gcc -o hello hello.c
gcc -static -o hello-static hello.c
Library cache:
sudo ldconfig
ldconfig -p | grep library
Kernel build:
git clone https://github.com/torvalds/linux.git
cd linux
make defconfig
make menuconfig
make -j$(nproc)
make scripts_gdb
Initramfs archive:
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz
QEMU boot:
qemu-system-x86_64 \
-kernel linux/arch/x86/boot/bzImage \
-initrd initramfs.cpio.gz \
-append "console=ttyS0" \
-nographic
QEMU with GDB:
qemu-system-x86_64 \
-kernel linux/arch/x86/boot/bzImage \
-initrd initramfs.cpio.gz \
-append "console=ttyS0" \
-nographic \
-s -S
GDB:
target remote :1234
break do_execveat_common
break search_binary_handler
break load_elf_binary
continue
Kernel and QEMU labs can be complex. Use a safe environment.
strace -e execve ls and explain the output.execve("/bin/echo", ...). Confirm that the original program does not continue after a successful execve.hello.c program dynamically and statically. Compare file and ldd output.readelf -h to find the entry point of a compiled program.readelf -l to find the dynamic linker requested by a dynamically linked binary.hello program.Once QEMU is waiting for a debugger connection, open another terminal, navigate to your kernel source, and run gdb vmlinux. In GDB, do target remote :1234 and then continue. Describe what you see on the QEMU console and why you’re dropped into your initramfs shell.