OS Lab 2 - System calls. The initial stack
In the last lab, we set up our Linux environment, learned the basics of bash and C, and saw that ordinary programs run in user space while the Linux kernel runs in kernel space and manages the hardware. In this lab, we will look closely at how a user-space program starts up, how it communicates with the kernel through system calls, and how it exits.
Learning objectives
By the end of this lab you should be able to:
- explain how a user-space program enters the kernel through a system call instruction and how system calls are multiplexed by number;
- invoke Linux system calls directly from C using
syscall()and handle errors viaerrno; - describe the memory regions of a loaded program (
.text,.rodata,.data,.bss, and the stack) and the steps that run beforemain(); - explain how command-line arguments (
argc,argv), environment variables (environ), and the auxiliary vector (auxv) are laid out on the initial stack and read them in C; - explain what happens when a program terminates and the difference between the
exit()library function and theSYS_exitsystem call.
System calls
System calls (or syscalls) represent the fundamental programmatic interface between an unprivileged user-space process and the privileged operating system kernel. Whenever a program needs to interact with the outside world or request a resource managed by the operating system — such as reading or writing a file, allocating more memory, spawning a new process, or terminating execution — it must issue a system call.
Calling a system call
In Linux, each system call conceptually corresponds to a function call, for example:
// CONCEPTUAL ILLUSTRATION
syscall_read(file, buffer_ptr, buffer_size);
syscall_write(file, buffer_ptr, buffer_size);
In response to receiving these requests, the kernel validates the parameters provided by user space (for instance, checking that file refers to a valid open file and that buffer_ptr points to readable or writable user-space memory), performs the requested operation in privileged mode, and returns the result (or an error code) back to the calling process.
However, because there is only one syscall entry point when entering kernel space, the actual function called must be multiplexed. This is done by encoding the system call function as the first parameter. The above read and write examples become:
syscall(SYS_read, file, buffer_ptr, buffer_size);
syscall(SYS_write, file, buffer_ptr, buffer_size);
In the above, SYS_read and SYS_write are special numeric values, defined in a Linux header, and these numbers must be agreed upon between the userspace and the kernelspace. These numbers are different for the different architectures supported by Linux. For example, on the x86-64 architecture, the values are defined as SYS_read = 0; SYS_write = 1. On AArch64 (ARM64) and RV64I (RISC-V), the values are defined as SYS_read = 63; SYS_write = 64.
Other examples of common system calls include:
| System call | x86-64 | AArch64 / RV64I | Description |
|---|---|---|---|
SYS_read |
0 | 63 | Read data from an open file into a buffer |
SYS_write |
1 | 64 | Write data from a buffer to an open file |
SYS_close |
3 | 57 | Close an open file |
SYS_mmap |
9 | 222 | Allocate a new region of memory, or map a file into memory |
SYS_brk |
12 | 214 | Resize the program's data segment |
SYS_getpid |
39 | 172 | Get the current process ID |
SYS_execve |
59 | 221 | Replace the current process with a new program |
SYS_exit |
60 | 93 | Terminate the current thread |
Syscall instructions
All modern CPU architectures provide hardware support for system calls.
In general, there is a single instruction which transfers control from an unprivileged execution context to a privileged execution context, and simultaneously jumps to an address specified beforehand by the privileged execution context. Because of the immediate jump to a fixed address, the user-space program cannot jump to arbitrary code inside the kernel; it can only enter the kernel through this single, tightly controlled gatekeeper routine.
The instructions for syscalls are as follows:
- For x86-64:
syscall - For AArch64 (ARM64):
svc #0(stands for supervisor call; the argument is ignored by Linux) - For RV64I (RISC-V):
ecall(stands for environment call)
The arguments for the syscalls are passed in registers. The register conventions are a bit different than for normal function calls, and registers must be shifted accordingly.
Because they are almost never used directly, and different platforms have different syscall conventions, the C language does not define a standard way to generate system call instructions. For ease of use, the GNU C library (glibc) defines most syscall functions as C functions (for example read(), write()) as well as a generic syscall() function.
The syscall() function is declared as:
long syscall(long number, ...);
The function has the following pseudocode logic:
syscall:
set register arguments
syscall instruction
let r = result register
if r <= -1 && r >= -4095:
errno = -r
return -1
else:
return r
Negative return values (in the [-4095, -1] range specifically) from system calls indicate errors. The error is extracted by the GNU C library and saved in the errno global, and -1 is returned.
Common examples of errors:
| Constant | Value | Description |
|---|---|---|
EPERM |
1 | Operation not permitted |
ENOENT |
2 | No such file or directory |
EINTR |
4 | Interrupted system call |
ENOMEM |
12 | Out of memory |
EACCES |
13 | Permission denied |
EFAULT |
14 | Bad address |
EEXIST |
17 | File already exists |
EINVAL |
22 | Invalid argument |
ENOSYS |
38 | Function not implemented (invalid system call number) |
Hello world using system calls
With the generic syscall() function, we can write a complete "Hello World" program that writes directly to standard output and exits without calling standard I/O wrappers like printf():
1#include <errno.h>
2#include <stdlib.h>
3#include <stdio.h>
4#include <string.h>
5#include <sys/syscall.h>
6#include <unistd.h>
7
8int main(void)
9{
10 char msg[] = "Hello World\n";
11
12 long ret = syscall(SYS_write, STDOUT_FILENO, msg, strlen(msg));
13 if (ret == -1) {
14 char error_msg[128];
15 snprintf(error_msg, sizeof error_msg, "Error: %d %s\n", errno, strerror(errno));
16
17 syscall(SYS_write, STDERR_FILENO, error_msg, strlen(error_msg));
18
19 return EXIT_FAILURE;
20 } else {
21 char success_msg[128];
22 snprintf(success_msg, sizeof success_msg, "Success! Wrote %ld bytes\n", ret);
23
24 syscall(SYS_write, STDOUT_FILENO, success_msg, strlen(success_msg));
25
26 return EXIT_SUCCESS;
27 }
28}
/dev/full, a special file which always fails writes with ENOSPC (No space left on device). The error message is still printed, because it is written to standard error, which is not redirected:
$ ./hello > /dev/full
Error: 28 No space left on device
$ echo $?
1
The initial environment
When a new program is loaded into memory, the kernel allocates a number of memory regions for the program. Most of these regions are copied from the executable file directly (from the ELF file).
The allocated memory regions are as follows:
| Region / Section | Permissions | Backed by ELF file? | Contents in C |
|---|---|---|---|
.text |
Read, Execute | Yes | The program instructions (functions) |
.rodata |
Read | Yes | Read-only data: string literals (e.g., "Hello World\n") and const global/static variables
|
.data |
Read, Write | Yes | Global and static variables initialized to non-zero values
|
.bss |
Read, Write | No (only size is specified) | Uninitialized or zero-initialized global and static variables
|
| Stack | Read, Write | No (allocated by kernel) | Initial startup data, function call frames, return addresses, and local variables |
PT_INTERP ELF header entry) and the kernel hands off to it. Consult the course materials for more information.In addition to these, as we will see in future lessons, the program can request additional regions using the mmap() system call, or by growing the data region using the brk() system call. These regions are what C uses to implement dynamic memory allocations, also known as the heap.
Process startup
The stack
Unlike the code and global data segments, whose initial contents come from the ELF executable, the stack is created fresh by the kernel when the process starts. It serves as scratch memory for function calls and starts off empty except for the initial startup information placed at its very top by the kernel.
The stack is allocated by the kernel and is limited. On all the architectures discussed here (x86-64, AArch64, RV64I), on Linux it defaults to 8MiB. Since any dynamic or large data is usually kept outside the stack (on the heap), the stack contains small local variables and pointers to the data structures on the heap. Because of this, the 8MiB stack is considered large. Its main use is to support deep recursion. For example, the kernel (which does not use or strictly limits recursion) only needs a 16KiB stack.
Aside from the local variables each program uses, the kernel puts a set of initial information for the program.
The arguments
When a program is executed, it is launched with a set of strings representing its arguments. The argument array is called argv, and it contains argc strings. Additionally, argv[argc] is set to NULL (nullptr) to indicate the end of the array.
Conventionally, argv[0] represents the command-line name of the program being executed (including any full or relative path used), and the rest are the given arguments.
For example:
| Command | Variable | Value |
|---|---|---|
ls -la /usr/share
|
argc |
3 |
argv[0] |
"ls"
| |
argv[1] |
"-la"
| |
argv[2] |
"/usr/share"
| |
argv[3] |
nullptr
|
execve system call.Upon entry into the entry point (the e_entry marked address in ELF) the kernel sets the stack pointer register (rsp for x86-64, SP for AArch64 and sp/x2 for RV64I) to point to the argc value, with the contents of argv directly above it (the stack grows downwards).
Using the arguments
The C library initialization code (_start), which runs before the main function, extracts these and sets them as parameters to the main function.
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char **argv)
{
if (argc >= 1) {
char msg[128];
snprintf(msg, sizeof msg, "My name is: %s\n", argv[0]);
syscall(SYS_write, STDOUT_FILENO, msg, strlen(msg));
return EXIT_SUCCESS;
} else {
return EXIT_FAILURE;
}
}
char *argv[] (an array of pointers to char) and char **argv (a pointer to a pointer to char) are completely identical in a function signature. You will see both int main(int argc, char *argv[]) and int main(int argc, char **argv) used interchangeably in C code and documentation.The environment
In Linux (and in general in Unix-like operating systems), programs are launched all the time to perform short-lived operations. Because of this, it was considered useful to implement a set of environment variables, which would get passed transparently between programs.
These environment variables define configuration options which are generally shared by many different programs. However, it is not uncommon to see a program support specific environment variables as an alternative way to set configuration.
Common environment variables are (by no means an exhaustive list):
| Environment Variable | Example Value | Description |
|---|---|---|
USER |
john |
The username of the current user |
HOME |
/home/john |
The home directory of the current user |
LANG |
en_US.UTF-8 or ro_RO.UTF-8 |
The current locale (used for the language of messages, number formats etc.) |
TERM |
xterm-256color |
Terminal capability identifier for the open terminal |
SHELL |
/bin/bash |
Preferred shell for the current user |
DISPLAY |
:0 |
Address and number of X11 display (for graphical apps) |
PATH |
/usr/bin:/usr/local/bin:/home/john/.local/bin |
:-separated list of directories to search for executables when running commands in the shell
|
Internally, environ is stored as a list of strings, terminated by nullptr. It may only store strings. By convention, environment variable names are separated from their values by the first = character.
The above environment may be stored as:
| Variable | Value |
|---|---|
environ[0] |
"USER=john"
|
environ[1] |
"HOME=/home/john"
|
environ[2] |
"LANG=en_US.UTF-8"
|
environ[3] |
"TERM=xterm-256color"
|
environ[4] |
"SHELL=/bin/bash"
|
environ[5] |
"DISPLAY=:0"
|
environ[6] |
"PATH=/usr/bin:/usr/local/bin:/home/john/.local/bin"
|
environ[7] |
nullptr
|
Environment variable names may contain any characters (except \0 or =) and environment variable values may contain any characters (except \0), including =. The string "foo=bar=biz" specifies the variable with name foo and value "bar=biz". The string "foo=" specifies the variable with name foo and value "".
Because, to the kernel, environ is just a list of strings it needs to pass, a program may receive duplicate variables (with the same name and the same or different values), or strings without any = at all.
Using the environment
On Linux, the GNU C library (glibc) implements the following rules:
- If a variable is specified multiple times, only the first entry is considered.
- If there are strings that do not contain
=, they are ignored entirely.
The environ vector is set by the kernel on the stack above argc and argv. It is retrieved from that point by the C library, which sets a pointer to it in the environ variable.
#include <stddef.h>
#include <stdlib.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
extern char **environ;
int main(int argc, char **argv)
{
// Unused. This syntax disables unused parameter warnings.
(void) argc;
(void) argv;
const char msg[] = "Environment variables:\n";
syscall(SYS_write, STDOUT_FILENO, msg, strlen(msg));
for (size_t i = 0; environ[i] != nullptr; ++i) {
syscall(SYS_write, STDOUT_FILENO, environ[i], strlen(environ[i]));
syscall(SYS_write, STDOUT_FILENO, "\n", 1);
}
return EXIT_SUCCESS;
}
The auxiliary vector
In addition to the arguments and environment which are passed from userspace program to userspace program with the help of the kernel, the kernel provides each running program a set of auxiliary information parameters in the form of a vector called auxv.
This is generally advisory information, and a large majority of programs do not need to read it directly to work correctly.
Auxiliary information is of the form:
// In: <elf.h>
typedef struct {
uint64_t a_type;
union {
uint64_t a_val;
} a_un;
} Elf64_auxv_t;
Practically, it is a vector of a_type and a_un.a_val pairs, both 64 bits (on 64-bit architectures). Depending on the a_type code, the value of a_un.a_val may be an integer value, a set of bitflags or a pointer to additional data.
Common auxiliary data (not an exhaustive list):
a_type |
a_val type |
Description |
|---|---|---|
AT_NULL |
N/A | End of the array; the value is ignored |
AT_PAGESZ |
integer value | System page size |
AT_UID |
integer value | Real user ID |
AT_GID |
integer value | Real group ID |
AT_PLATFORM |
pointer | String identifying the hardware platform |
AT_HWCAP |
set of bitflags | Architecture-dependent CPU capability mask |
AT_RANDOM |
pointer | Address of 16 random bytes |
The array is terminated by an auxiliary value of type AT_NULL.
Using the auxiliary vector
Unlike the argument and environment arrays, glibc provides no means of accessing a pointer to this array. To arrive at it, the simplest method is walking the environment array.
#include <elf.h>
extern char **environ;
Elf64_auxv_t *get_auxv(void)
{
char **envp = environ;
while (*envp != nullptr) {
++envp;
}
// Walk past the end of environ
++envp;
return (Elf64_auxv_t *) envp;
}
Convenience functions
For easier access to the environment and auxiliary data, glibc provides the functions:
- In
<stdlib.h>getenv()-- to get the value of an environment variablesetenv()-- to set the value of an environment variableunsetenv()-- to remove an environment variable
- In
<sys/auxv.h>getauxval()-- to retrieve a value from the auxiliary vector
For the argument list, most programs interpret this list using their own conventions, parsing it from the arguments passed to main. However, many programs share a convention like:
ls --all -nt dir1 -- -dir2
Here the flags all, n and t are set, and the directories to list are dir1 and -dir2.
A single - followed by characters indicates a list of short options (a single character per option), a -- followed by characters indicates a long option, and a bare -- stops argument parsing, allowing -dir2 to be treated literally.
glibc provides the functions getopt() and getopt_long() for parsing this convention, in unistd.h and getopt.h respectively.
Program exit
When a program finishes its work, it cannot simply return from its initial function because there is simply no caller in user space to return to. Instead, the program must make a system call to ask the kernel to terminate it.
On Linux, the system call for this is SYS_exit. It takes an integer exit status (0 or EXIT_SUCCESS for success, and a non-zero value such as EXIT_FAILURE for failure). The kernel closes all of the program's open resources, frees its memory regions, and passes the exit status back to the parent program (for example, the shell, which stores it in $?).
In C, when you return from main(), control returns to the glibc function _start, which passes main()'s return value to the exit system call via the exit() library function.
exit() library function does some additional cleanup work, such as running callbacks registered using atexit().Example:
#include <stdlib.h>
#include <string.h>
#include <sys/syscall.h>
#include <unistd.h>
int main(void)
{
const char before[] = "Exiting now...\n";
syscall(SYS_write, STDOUT_FILENO, before, strlen(before));
syscall(SYS_exit, 42); // After running this program, echo $? will print 42 in the shell
// Never reached.
const char after[] = "This will never be printed.\n";
syscall(SYS_write, STDOUT_FILENO, after, strlen(after));
return EXIT_SUCCESS;
}
Exercises
Write each program in its own copy of the project template. As a reminder from the last lab:
mkdir -p ~/oslab && cd ~/oslab
curl -OL https://wiki.dcae.pub.ro/images/9/93/Os_lab_skeleton.zip
unzip -d echo Os_lab_skeleton.zip
Use a new directory name instead of echo for each exercise.
For this lab:
- Write your output with
syscall(SYS_write, ...), notprintf()orputs(). - Walk
argv,environand the auxiliary vector yourself, as arrays. Nogetenv(),getauxval()orgetopt(). snprintf()is fine, and recommended, for building a string (formatting a number, for example) that you then write.- You can (and it is recommended to) wrap the system calls you use in small functions of your own.
A. echo
Write your own echo: print the arguments separated by spaces, followed by a newline. If the first argument is -l, don't print it, and put each of the other arguments on its own line instead.
$ ./build/debug/app hello world
hello world
$ ./build/debug/app -l one two three
one
two
three
$ ./build/debug/app
Some things to look at:
- There were three spaces between
helloandworldon the command line, but only one in the output. Why? What changes if you put quotes around"hello world"? - If a write fails, print an error to standard error and exit with status 1. You can check this with
/dev/full, just like the Hello World program above.
B. Environment variables
Write a program that prints the value of the environment variable named in argv[1]. With no argument, it prints every variable, one per line.
$ env -i HOME=/home/john USER=john ./build/debug/app HOME
/home/john
$ env -i HOME=/home/john USER=john ./build/debug/app
HOME=/home/john
USER=john
$ ./build/debug/app NOPE
NOPE: not set
$ echo $?
1
env -iruns a program with only the variables you list.- Be careful with the matching: asking for
HOMEmust not matchHOMEDIR=/somewhere. - If the variable isn't there, say so on standard error and exit with status 1.
Extra: follow the same rules as glibc: if a name appears more than once, only the first one counts, and strings without a = are skipped.
C. The auxiliary vector
Print every entry of the auxiliary vector: its type and its value, one per line, stopping at AT_NULL. Use get_auxv() from above to find it.
$ ./build/debug/app
33 0xffffa6fb3000
51 0x1270
16 0x2fb3ffff
6 0x1000
17 0x64
3 0xaaaad0b50040
...
Your values will be different: this output is from an ARM64 machine.
- The type numbers are defined in
/usr/include/elf.h(trygrep 'define AT_' /usr/include/elf.h). - To check your output, the dynamic loader can print the same list, with names:
LD_SHOW_AUXV=1 /bin/true. - Run your program a few times. Some values change on every run. Which ones, and what do they have in common?
Extra: print the names of the types from the table above (AT_PAGESZ, AT_UID, ...) instead of their numbers.
Bonus: CPU capabilities
Write a command that says whether the CPU has a given feature, and exits with status 0 if it does and 1 if it doesn't:
$ ./build/debug/app sse2
sse2: yes
$ ./build/debug/app ring3mwait
ring3mwait: no
Find the AT_HWCAP entry by walking the auxiliary vector, then test the bit for the feature you were asked about.
On x86-64, AT_HWCAP is a copy of the EDX register returned by the cpuid instruction for leaf 1. A few of its bits:
| Bit | Feature |
|---|---|
| 0 | fpu
|
| 4 | tsc
|
| 23 | mmx
|
| 25 | sse
|
| 26 | sse2
|
The headers don't name these bits, so look up the rest in a CPUID table, and compare your answers with the flags line in /proc/cpuinfo. Almost every bit is set on a modern CPU, so for a feature you don't have, look at AT_HWCAP2 as well. Its two bits are named in <asm/hwcap2.h>: HWCAP2_RING3MWAIT (only set on Intel Xeon Phi CPUs) and HWCAP2_FSGSBASE.
You don't have to support every feature: a handful of names is plenty, as long as you find at least one that your machine has and one that it doesn't.
AT_HWCAP mean something different on every architecture:
- On AArch64 (macOS with OrbStack, ARM laptops), the bits are named in
<asm/hwcap.h>, for exampleHWCAP_AES(bit 3) andHWCAP_SVE(bit 22), with more inAT_HWCAP2. TheFeaturesline in/proc/cpuinfolists what your CPU has. - On RV64I, bit n is set if the CPU has the single-letter extension at position n in the alphabet:
Ais bit 0,Cbit 2,Dbit 3,Fbit 5,Ibit 8,Mbit 12,Vbit 21. Longer extension names (Zba,Zbb, ...) are not inAT_HWCAP; the kernel reports them through theriscv_hwprobesystem call instead.