Now that you have your basic OS running, let’s start exploring it bit by bit.
You ran a command such as ls in the shell. It displays the contents of the current directory. But how does it actually do that?
This is possible because the shell launches an ls program. So our main question becomes:
How does the shell run this command?
The shell first creates a new process using the fork() system call and then runs the ls program in the newly created process. This immediately raises a few questions:
i) What is a system call?
ii) How does fork() work?
iii) How is ls run after fork()?
iv) Why do we create a new process?
v) What does the ls program actually contain?
System calls are interfaces through which user programs request services from the kernel. They transfer control from user space to kernel space, allowing the kernel to perform operations that require its privileges.
A few examples include:
open
read
write
close
fork
When you say you are using the system call fork(), you actually call glibc's fork() function. Check the Dive-in-glibc section for more details.
Consider the following program:
int pid = fork();
if (pid > 0) {
printf("parent: child=%d\n", pid);
pid = wait((int *) 0);
printf("child %d is done\n", pid);
}
else if (pid == 0) {
printf("child: exiting\n");
exit(0);
}
else {
printf("fork error\n");
}
After fork():
The parent process receives the child's PID, so pid > 0.
The child process receives 0, so pid == 0.
If fork() fails, it returns -1.
The parent calls wait() so that it waits for the child to finish before continuing.
The child calls exit(0) to terminate itself.
The exit() system call terminates the calling process. When a process exits, the operating system releases the resources associated with the process, such as its memory and open files.
For example:
exit(0);
terminates the process and reports an exit status of 0, conventionally indicating successful termination.
Calling exit() is not the only way for a program to terminate. When the main() function reaches:
return 0;
the process also terminates normally.
After creating a child using fork(), the child can use an exec() system call to run another program.
For example:
#include <unistd.h>
int main()
{
char *args[] = {"ls", "-l", NULL};
execvp(args[0], args);
return 1;
}
The important thing to understand is:
exec() does not create a new process. It replaces the current process with a new program.
For example, if the child calls:
execvp("ls", args);
the child process stops executing the old program and starts executing the ls program.
The PID does not change.
Conceptually:
Before exec():
Child process
PID = 1234
|
└── Shell program
After exec():
Child process
PID = 1234
|
└── ls program
The process is still PID 1234, but the program running inside that process has been replaced.
If exec() succeeds, it does not return to the calling program. If it returns, it means that the exec() call failed.
When we type:
ls
into a shell, the shell needs to run the ls program without destroying itself.
A simplified shell therefore does:
Shell
|
fork()
|
+--------+--------+
| |
Parent Child
| |
wait() exec("ls")
| |
| ls
| |
+------<----------+
finishes
The steps are:
The shell calls fork().
fork() creates a child process.
The parent remains the shell.
The child calls exec() to become the ls program.
The parent calls wait() and waits for ls to finish.
When ls terminates, wait() returns.
The shell continues and displays the next prompt.
This combination is the basic mechanism behind how a shell executes external commands:
fork() → exec() → wait()
Suppose the shell creates the child but does not call wait():
pid = fork();
if (pid == 0) {
execvp("ls", args);
}
/* Parent does not call wait() */
The parent does not wait for the child to finish. It can immediately continue executing.
For a shell, this means that the shell could display another prompt while ls is still running.
For example:
Shell
|
+-- fork()
|
+-- Parent continues immediately
|
+-- Child runs ls
There is also an important consequence when the child finishes.
When a child terminates, the operating system keeps a small amount of information about the terminated child so that its parent can collect its termination status. Until the parent does so using wait() or waitpid(), the child can remain as a zombie process.
Therefore, for a normal foreground command, the shell waits for the child.
A process does not have to explicitly call exit() to terminate.
For example:
int main()
{
printf("Hello\n");
return 0;
}
When main() returns, the program terminates normally.
Therefore, in our earlier example, instead of:
printf("child: exiting\n");
exit(0);
we could allow the child to reach the end of main().
However, exit() is useful when we want to terminate the process at a particular point or return a particular status:
if (error) {
exit(1);
}
Consider what would happen if the shell directly executed:
execvp("ls", args);
without first calling fork().
The exec() call would replace the shell process itself with the ls program.
For example:
Before exec():
PID 1000
|
└── Shell
After exec():
PID 1000
|
└── ls
The PID remains 1000, but the shell program has been replaced by ls.
When ls finishes, the original shell cannot continue because it no longer exists as a running shell process.
Therefore, a shell cannot normally execute an external command simply by calling exec() in the shell process.
Instead, it uses fork() first:
Shell
|
fork()
|
+--------+--------+
| |
Parent Child
| |
wait() exec("ls")
| |
| ls
| |
+<----------------+
child exits
|
shell continues
The three system calls have different roles:
System call
Purpose
fork()
Creates a new process
exec()
Replaces the current child process with another program
wait()
Makes the parent wait for a child to terminate
exit()
Terminates the current process
So, when a simple shell executes a foreground command such as ls, the basic sequence is:
fork()
Shell --------------------> Child process
|
exec()
|
v
ls
|
exit
|
v
Shell <-------------------- wait()
fork() creates the process, exec() gives that process a new program to run, exit() terminates it, and wait() allows the parent shell to wait for it and collect its termination status.
Once the shell executes:
execvp("ls", args);
the child process becomes the ls program.
A simplified version of ls can be thought of as:
int main()
{
DIR *dir;
struct dirent *entry;
dir = opendir(".");
while ((entry = readdir(dir)) != NULL) {
printf("%s\n", entry->d_name);
}
closedir(dir);
return 0;
}
The main steps are:
opendir(".") — opens the current directory.
readdir() — reads directory entries one by one.
printf() — prints each file/directory name.
closedir() — closes the directory.
return 0 — terminates the ls process.
The real ls program is much more complex and supports options such as -l, -a, and -R. It also uses functions such as stat() to obtain file information.
opendir() — No. It is a C library function that internally uses the openat() system call to open the directory.
readdir() — No. It is a C library function that internally uses the getdents64() system call to obtain directory entries from the kernel.
closedir() — No. It is a C library function that internally uses the close() system call to close the directory.
The exact internal system calls can depend on the operating system and C library implementation. On modern Linux systems, openat(), getdents64(), and close() are commonly involved.
entry is a pointer to a struct dirent, which contains information about a directory entry.
A simplified structure is:
struct dirent {
ino_t d_ino; // inode number
off_t d_off; // offset of the next entry
unsigned short d_reclen; // size of this entry
unsigned char d_type; // file type
char d_name[]; // file/directory name
};
The most commonly used field by ls is:
entry->d_name which contains the name of the file or directory.
Conceptually:
ls
|
| opendir(), readdir(), stat()
v
C Library
|
| system calls
v
Kernel
|
v
File System
So, ls is an ordinary program that uses operating-system services to access and display information about files.
We explored ls but the concept of running shell commands is the same unless it is shell-built-in.
We can look at the C level implementations of other commands like cat, cd, echo, wc, rm at https://github.com/jhilikb/OS.git
Once we are confident lets take the next step. Every command is treated as a new process, so when I combine two commands how does it work. Say ls | wc which counts the output of the ls command. We need to understand the concept of pipes for this. Lets dive in.
A pipe provides a one-way communication channel between processes.
One process writes data into the pipe, and another process reads it.
Process A Process B
| |
| write() | read()
↓ ↑
+-------> PIPE ------------+
A pipe is created using:
int fd[2];
pipe(fd);
The two file descriptors are:
fd[0] → read end
fd[1] → write end
A simple parent-child example:
int fd[2];
char buffer[100];
pipe(fd);
if (fork() == 0) {
// Child
close(fd[1]);
read(fd[0], buffer, sizeof(buffer));
printf("Child received: %s\n", buffer);
close(fd[0]);
}
else {
// Parent
close(fd[0]);
write(fd[1], "Hello child", 12);
close(fd[1]);
wait(NULL);
}
The sequence is:
Parent
|
| pipe()
↓
[ read end | write end ]
|
fork()
|
+----+----+
| |
Parent Child
| |
write() read()
| |
+----→---+
The pipe is created before fork(), so the child inherits the pipe's file descriptors.
The parent only writes:
close(fd[0]);
The child only reads:
close(fd[1]);
This is important because the reader can detect end-of-file (EOF) only when there are no remaining write ends of the pipe.
Now we can understand a command such as:
ls | wc
The | means:
Send the output of ls as the input of wc.
Conceptually:
PIPE
ls stdout ----------> wc stdin
The shell creates the pipe:
int fd[2];
pipe(fd);
Then it creates processes for the two commands.
PIPE
┌─────────┐
│ │
ls │ │ wc
┌───────┐ │ │ ┌───────┐
│ stdout├────►│ ├───►│ stdin │
└───────┘ │ │ └───────┘
└─────────┘
The shell uses dup2() to connect the pipe to the standard file descriptors.
For ls:
dup2(fd[1], STDOUT_FILENO);
Now ls's standard output goes into the pipe.
For wc:
dup2(fd[0], STDIN_FILENO);
Now wc reads its standard input from the pipe.
The overall process becomes:
Shell
|
+---- fork() ----> ls
| |
| stdout
| |
| v
| PIPE
| |
| v
+---- fork() ----> wc
|
stdin
Finally, each child calls exec():
// First child
dup2(fd[1], STDOUT_FILENO);
close(fd[0]);
close(fd[1]);
execvp("ls", args);
and:
// Second child
dup2(fd[0], STDIN_FILENO);
close(fd[0]);
close(fd[1]);
execvp("wc", args);
So:
ls
↓
stdout (fd 1)
↓
PIPE
↓
stdin (fd 0)
↓
wc
We can now connect the concepts:
fork() → creates processes
exec() → runs programs
pipe() → connects processes
dup2() → redirects standard input/output
wait() → waits for child processes
Therefore, when you type:
ls | wc
the shell essentially:
1. Creates a pipe
2. fork() → creates ls process
3. fork() → creates wc process
4. Connects ls stdout to the pipe
5. Connects wc stdin to the pipe
6. exec() → runs ls
7. exec() → runs wc
8. wait() → waits for the children
This is the same process creation and communication mechanism we have already seen—fork(), exec(), wait()—with pipe() and dup2() added to connect the processes.
glibc provides the user-space functions that eventually invoke system calls.
For example, glibc's implementation of a system-call wrapper eventually uses the syscall mechanism with the appropriate system-call number. These numbers are defined by the architecture-specific Linux headers, such as those under:
/usr/include/x86_64-linux-gnu/asm/
This happens for many system calls.
glibc is normally installed as part of a Linux system and is available long before you install a C compiler.
It contains compiled implementations of functions such as:
printf
malloc
strlen
parts of fork
parts of pthread
Note that Linux has commands such as cp, mv, and rm, which are generally implemented as separate programs, many of them written in C.
When you install:
sudo apt install build-essential
you install packages including gcc, g++, make, libc6-dev, and development tools.
glibc (libc6) → already installed so Linux programs can use the standard C library.
glibc development (libc6-dev) → provides headers and development files needed to compile programs using glibc.
GCC → compiler that translates your C code.
Linux kernel → provides the actual system-call interface.
So when you compile:
#include <unistd.h>
int main()
{
fork();
}
the broad picture is:
unistd.h
(from libc6-dev)
↓
gcc compiles
↓
glibc wrapper
↓
syscall instruction
↓
Linux kernel
Although we started by talking about system calls, we have now touched libraries as well. So let’s take a quick look at them.
Libraries provide implementations of functions that programs can use.
Examples:
glibc — standard C library (printf, fork, malloc, open, ...)
libm — math library (sin, cos, sqrt, ...)
libpthread — POSIX threads (historically a separate library; many functions are now integrated into glibc)
libstdc++ — C++ standard library
The linker's job is to connect your program with the libraries it uses.
For static libraries, the required object code is copied into the executable. For dynamic libraries, references to the required symbols are kept and resolved when the program is loaded.
GCC eventually invokes ld (directly or through helper programs). Its job includes:
Combining object files (.o)
Resolving symbols such as printf
Linking against libraries such as libc.so
Producing the final executable
You can see which shared/dynamic libraries an executable will use at runtime with:
gcc hello.c -o hello
ldd hello
You may see something like:
linux-vdso.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2
Additional libraries such as libm.so.6 or libstdc++.so.6 will appear if the program actually uses them.
ld → static linker, used when building the executable.
ld-linux-x86-64.so.2 → dynamic loader, used when starting a dynamically linked executable.
Yes. You can build a statically linked executable with:
gcc -static hello.c -o hello_static
Now:
ldd hello_static
will typically report:
not a dynamic executable
As you are already familiar with C, you know that there are several steps between C source code and an executable.
Preprocessor → expands #include, replaces macros, and handles conditional compilation.
Compiler → generates assembly code.
Assembler → converts assembly into machine/object code.
Linker → combines object files and creates the executable.
Consider:
#include <stdio.h>
#define MSG "Hello"
int main() {
printf(MSG);
return 0;
}
gcc -E main.c -o main.i
The preprocessor:
processes #include
replaces #define
expands macros
A simplified version of main.i will look like:
// contents of stdio.h inserted here ...
int main() {
printf("Hello");
return 0;
}
gcc -S main.i -o main.s
The compiler converts the C code into assembly.
Simplified assembly output (main.s):
main:
push rbp
mov rbp, rsp
lea rdi, .LC0
call printf
mov eax, 0
pop rbp
ret
gcc -c main.s -o main.o
This produces:
main.o
The object file contains machine code along with information such as symbols and relocation information.
For example, its symbol table may contain:
main
printf (unresolved)
printf is unresolved at this stage because its implementation will be provided by a library during linking.
main.c
|
| gcc -E
v
main.i (preprocessed C code)
|
| gcc -S
v
main.s (assembly)
|
| gcc -c
v
main.o (machine code + symbols)
|
| gcc
v
main (executable)
So basically, you can perform these steps individually to see what happens at each stage, or let GCC perform the entire process for you with a single command.