OS Lab 2 - System calls. The initial stack

De la WikiLabs
Versiunea din 16 octombrie 2026 12:50, autor: Vserbu (discuție | contribuții) (Pagină nouă: 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...)
(dif) ← Versiunea anterioară | Versiunea curentă (dif) | Versiunea următoare → (dif)
Jump to navigationJump to search

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:

  1. explain how a user-space program enters the kernel through a system call instruction and how system calls are multiplexed by number;
  2. invoke Linux system calls directly from C using syscall() and handle errors via errno;
  3. describe the memory regions of a loaded program (.text, .rodata, .data, .bss, and the stack) and the steps that run before main();
  4. 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;
  5. explain what happens when a program terminates and the difference between the exit() library function and the SYS_exit system 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.

This multiplexing allows the system call to be implemented as follows:

Fișier:OS Lab 2 - syscall multiplex.png
All system calls go through a single instruction. The system call number is shifted in front of the arguments in user space, and the kernel uses it to select the function to run.

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}
Note: To see the error path of the above program, redirect its standard output to /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
Note: The explanation in this lab is focused on statically linked executables. Most modern executables, including the ones produced by the lab setup, are dynamically linked. For these, the dynamic loader runs first (defined by the 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.

Note: All of these regions reside in virtual memory, not directly in physical RAM. Virtual pages that appear contiguous to the program are usually not contiguous in physical memory, and may not even be allocated in physical RAM until the program first accesses them or may be swapped out to disk, trapping into the kernel on access so the kernel can map the backing page transparently.

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 stack at program start: argc, argv, environ and auxv, pointing to data placed at the top of the stack.

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
Note: The command line is split into a list of strings by the shell; the kernel gets the arguments as a list in the execve system call.
The shell splits the command line into argv: an array of pointers to \0-terminated strings, ending with NULL.

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).

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;
    }
}
Note: In C parameter declarations, an array parameter decays to a pointer to its first element. Because of this, 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
The environment is inherited from the program that started us (e.g. the shell) and stored as an array of "NAME=value" strings.

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.

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.

The auxiliary vector: (type, value) pairs provided by the kernel. Some values are integers, others point 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 variable
    • setenv() -- to set the value of an environment variable
    • unsetenv() -- 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.

NOTE: for the exercises below, DO NOT use these functions. You can define similar functions yourself.

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.

NOTE: The 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, ...), not printf() or puts().
  • Walk argv, environ and the auxiliary vector yourself, as arrays. No getenv(), getauxval() or getopt().
  • 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 hello and world on 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 -i runs a program with only the variables you list.
  • Be careful with the matching: asking for HOME must not match HOMEDIR=/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 (try grep '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.

Note: The bits of AT_HWCAP mean something different on every architecture:
  • On AArch64 (macOS with OrbStack, ARM laptops), the bits are named in <asm/hwcap.h>, for example HWCAP_AES (bit 3) and HWCAP_SVE (bit 22), with more in AT_HWCAP2. The Features line in /proc/cpuinfo lists what your CPU has.
  • On RV64I, bit n is set if the CPU has the single-letter extension at position n in the alphabet: A is bit 0, C bit 2, D bit 3, F bit 5, I bit 8, M bit 12, V bit 21. Longer extension names (Zba, Zbb, ...) are not in AT_HWCAP; the kernel reports them through the riscv_hwprobe system call instead.