OS Lab 1 - Linux setup. Bash primer. C primer
Overview
In every lab, you will write C programs, build them, and run them on Linux from a shell. In this first lab, you will set up a Linux environment on your own computer, learn to use the command line, and review the parts of C used most often in systems programming. You will use these tools throughout the course.
Learning objectives
By the end of this lab you should be able to:
- explain, in a few sentences, what the kernel, the shell and a system call are, and how they relate;
- open a Linux shell on your own computer and connect VS Code to the same Linux system;
- navigate the file system, manage files and run programs from the shell;
- read and write short bash scripts that take arguments, make decisions and loop;
- compile a C program by hand and explain what each stage of the build does;
- use C types, pointers, arrays, structs, strings and function pointers in short programs;
- build, run and debug a multi-file C project with our VS Code setup.
Before you start
We assume you have taken a C programming course: you should know types, functions, arrays, pointers and structs, even if some of it is rusty.
You need a computer running Windows 10 or 11, macOS 14+, or Ubuntu Linux 26.04, with administrator access and roughly 10 GB of free disk space.
Lab contents
| Section | What it covers |
|---|---|
| The stack | what Linux, bash and C are, and why we use them |
| Setting up | official installation guides and course settings |
| Working in bash | the command line, from first commands to scripts |
| C refresher | C as it is used in systems code, and our VS Code setup |
| Exercises | C exercises to work on during the lab session |
| Reference | troubleshooting, quick reference, further reading |
Conventions
Text like thisis something you type, a file name, or program output.In listings, lines starting with
$are commands: type them without the$. The lines that follow are their output.Code blocks without
$are meant to be copied whole.<you>stands for your own username; replace it, including the brackets.Boxes like this one are tips, warnings and fixes for common problems:
Tip. These boxes contain advice and answers to common questions.
The stack
What an operating system does
A computer's hardware (processor, memory, disks, network card) is shared by every program that runs on it. The operating system kernel is the program that owns the hardware and shares it out. It decides which program runs on which processor and for how long, gives each program its own private memory, organises the disks into files and directories, and keeps programs from interfering with each other.
Ordinary programs, including everything you write in this course, run in user space. They cannot touch the hardware directly. When a program needs something only the kernel can do, such as opening a file, creating a process or sending data over the network, it makes a system call: a request to the kernel, which checks it, does the work, and returns the result.
bash your C programs other programs ← user space
────────────── system calls: open, read, write, fork, ... ──────────────
Linux kernel ← kernel space
─────────────────────────────────────────────────────────────────────────
processor · memory · disks · network
In this course, you will write user programs that use system calls to create processes, share memory, synchronise threads and work with files. You will also study how the kernel supports these operations.
Linux
Linux is the operating system we study, and the one all your code runs on. Strictly speaking, Linux is only the kernel. A complete system, such as Ubuntu, which we use, adds the C library, the shell, the compilers and thousands of other programs around it.
Linux is open source, so you can read its kernel code. It provides a Unix-compatible environment while adding modern features, and runs most of the world's servers, phones (Android) and embedded devices. The same concepts apply in systems programming outside the course.
You don't have to replace the operating system on your laptop. WSL 2 on Windows and OrbStack on macOS run Linux in lightweight virtual machines (VMs), using the host operating system's virtualization support. Windows manages the WSL 2 VM, while OrbStack manages its VM on macOS. These VMs expose a minimal set of hardware to the Linux kernel (they do not expose a full graphics stack, for example), and so run much faster than traditional VMs.
The shell and the terminal
bash is a shell: a program that reads the commands you type, starts other programs to carry them out, and connects those programs together. When you type ls, bash finds a program called ls, asks the kernel to run it, waits for it to finish, and prompts you again.
The terminal is the window that shows text and sends your keystrokes to the shell. You can run bash in many different terminals.
bash is also a programming language. The commands you type one at a time can be saved in a file, a script, with variables, conditions and loops, to automate anything you would otherwise do by hand.
C
C is the main language of the Linux kernel and its interface: every system call is documented as a C function, and the C library is the layer every other language uses to reach the kernel.
Rust is also being added to the kernel, but its use is still limited compared with C. C remains the lingua franca of Linux systems programming and the language we use in this course.
C gives you direct access to memory, through it's low level pointer semantics. This makes C useful for operating systems, but you are responsible for memory and error handling, and the compiler cannot catch most mistakes.
Setting up
The shape of the setup
On every platform, your code is compiled and runs inside Linux. The terminal and VS Code run on your own operating system and connect to Linux.
┌─ Your computer (Windows or macOS) ────────────────────────┐
│ │
│ Terminal window VS Code window │
│ │ │ │
│ ▼ ▼ │
│ ┌─ Linux: Ubuntu 26.04 ──────────────────────────────┐ │
│ │ bash · your files · clang · gdb · VS Code server │ │
│ └────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────┘
VS Code has two parts: the window runs on your computer, and a small server runs inside Linux. The server reads your files, runs the compiler and debugger, and opens terminals on the Linux side. You use the editor as you would for a local project.
| Your OS | Linux runs in | Terminal | VS Code connects with |
|---|---|---|---|
| Windows | WSL 2 (Ubuntu 26.04) | Windows Terminal | the WSL extension |
| macOS | OrbStack (Ubuntu 26.04) | Ghostty | the Remote-SSH extension |
| Linux | your machine, natively | the one you have | nothing, it's local |
Everyone should use Ubuntu 26.04 LTS, so we all have the same compiler, debugger, libraries and manual pages, and a program that works on your computer works on ours. Other versions and other distributions are not supported.
Windows: WSL, Windows Terminal, VS Code
Follow these guides, using the settings below:
- WSL: Install WSL. Use WSL 2 with Ubuntu 26.04 LTS and create a Linux username and password when prompted.
- Windows Terminal: Install and set up Windows Terminal. Open the Ubuntu 26.04 profile.
- VS Code: Install on Windows, then follow Developing in WSL to install the WSL extension and connect to Ubuntu 26.04. Install VS Code on Windows, not inside Ubuntu.
Check: the bottom-left corner of VS Code should say WSL: Ubuntu-26.04. Keep your lab files in your Linux home directory (~), rather than under /mnt/c/. You can access them through the Linux entry in Explorer.
In Windows Terminal, copy and paste with Ctrl+Shift+C / Ctrl+Shift+V. Ctrl+C stops the running program. Continue with Install the toolchain.
macOS: OrbStack, Ghostty, VS Code
Run the labs in a Linux machine through OrbStack. Follow these guides:
- OrbStack: Quick start. Install OrbStack and create an Ubuntu 26.04 machine named oslab.
- Ghostty: Install on macOS. Open Ghostty and run
orbto enter Linux. - VS Code: Install on macOS, then follow Remote Development using SSH to install the Remote - SSH extension and connect to
orb. OrbStack provides the SSH connection; see its SSH access guide. Choose Linux if VS Code asks for the remote platform.
Check: the bottom-left corner of VS Code should say SSH: orb. Keep your lab files in your Linux home directory (/home/<you>), not in your Mac home directory and build and run there. Your Linux username is the same as on your Mac. The Linux files are also accessible in Finder under ~/OrbStack/oslab.
For an xterm-ghostty error, see Troubleshooting. Continue with Install the toolchain.
Linux (Ubuntu 26.04)
- Ubuntu: if you are using an older release, follow the official Ubuntu Desktop upgrade guide to upgrade to 26.04 LTS.
- VS Code: follow Installing VS Code on Linux, using the Debian and Ubuntu instructions for Microsoft's apt repository. If you have the Snap version, remove it first with
sudo snap remove code.
Run code . in a directory to open it in VS Code. Continue with Install the toolchain.
Install the toolchain (everyone)
Open a Linux terminal: an Ubuntu 26.04 tab in Windows Terminal, orb in Ghostty, or your own terminal on Linux. Bring the system up to date and install the tools:
sudo apt update && sudo apt upgrade -y
sudo apt install -y build-essential clang clangd clang-tidy clang-format gdb \
cmake ninja-build pkg-config libreadline-dev git curl unzip \
man-db manpages-dev
What you just installed:
| Package | What it is for |
|---|---|
clang
|
the C compiler |
build-essential
|
the C library headers, the linker and make
|
clangd
|
completion and live error checking in VS Code |
clang-tidy, clang-format
|
extra bug-finding checks; automatic code formatting |
gdb
|
the debugger |
cmake, ninja-build
|
the build system our projects use |
pkg-config, libreadline-dev
|
a library our project template uses, and how to find it |
git
|
version control |
curl
|
download the project template |
unzip
|
extract the template into a new project directory |
man-db, manpages-dev
|
the manual, including every C function and system call |
At the end of C refresher, you will check that VS Code can build and debug a project.
Working in bash
Open a Linux terminal and keep it open while you read: every example in this section is meant to be typed.
First contact
You'll see a prompt like this:
student@oslab:~$
It tells you who you are (student), which machine you're on (oslab), and which directory you're in (~, your home directory). The $ means bash is waiting for a command.
A command is a program name followed by arguments, separated by spaces. Arguments starting with - are usually options that change what the program does:
$ echo Hello, world
Hello, world
$ date
Fri Oct 9 10:14:03 UTC 2026
$ ls -l /etc/hostname
-rw-r--r-- 1 root root 6 Oct 1 09:12 /etc/hostname
Short options can usually be combined: ls -l -a and ls -la are the same. Long options are spelled out with two dashes, such as ls --all.
Useful keyboard shortcuts:
| Key | What it does |
|---|---|
| Tab | completes a command or file name; press twice to see the options |
| ↑ / ↓ | step through earlier commands |
| Ctrl+R | search earlier commands: type part of one, Enter to run it |
| Ctrl+C | stop the running program |
| Ctrl+D | end of input (on an empty prompt, it closes the shell) |
| Ctrl+L | clear the screen |
| Ctrl+A / Ctrl+E | jump to the start / end of the line |
Use Tab to complete names and help catch typing mistakes.
The file system
Linux has a single tree of directories, starting at the root, /. There are no drive letters: other disks, and even Windows' drives under WSL, appear as directories somewhere in the tree.
| Directory | What's in it |
|---|---|
/home/<you>
|
your home directory: your files. ~ is short for it.
|
/etc
|
system configuration, as plain text files |
/usr/bin
|
most programs: ls, clang, gdb, ... are files here
|
/usr/include
|
the C headers: stdio.h is /usr/include/stdio.h
|
/tmp
|
temporary files, deleted on restart |
/dev
|
devices, shown as files |
A path names a place in the tree. An absolute path starts at the root: /home/student/oslab. A relative path starts from your current directory: if you are in /home/student, then oslab means the same thing. Three special names work everywhere:
| Name | Means |
|---|---|
.
|
the current directory |
..
|
the parent directory |
~
|
your home directory |
Names are case-sensitive: Notes.txt and notes.txt are different files. Names that start with . are hidden: ls doesn't show them unless you ask with -a. Configuration files often look like this, such as ~/.bashrc. Spaces are allowed in names, but each one then needs quotes on the command line (cd "My Files"), so it's usually easier to avoid them.
Moving around and managing files
| Command | What it does |
|---|---|
pwd
|
print the current (working) directory |
ls
|
list the current directory, or the ones you name |
ls -la
|
the same, with details (-l), including hidden files (-a)
|
cd dir
|
go into dir. cd .. goes up, cd alone goes home, cd - goes back
|
mkdir dir
|
make a directory. mkdir -p a/b/c makes the parents too
|
touch f
|
create an empty file (or update the time of an existing one) |
cp src dst
|
copy a file. cp -r copies a directory and everything in it
|
mv src dst
|
move or rename |
rm f
|
delete a file. rm -r dir deletes a directory and everything in it
|
rmdir dir
|
delete an empty directory |
A short session:
$ cd ~
$ mkdir -p oslab/scratch
$ cd oslab/scratch
$ pwd
/home/student/oslab/scratch
$ touch a.txt b.txt
$ ls
a.txt b.txt
$ mv a.txt notes.txt
$ cp notes.txt backup.txt
$ ls
b.txt backup.txt notes.txt
$ rm b.txt
$ cd ..
$ ls
scratch
Warning.
rmhas no trash and no undo.rm -rdeletes everything under a directory without asking. Read the command before pressing Enter, and never runrm -rfon a path you haven't checked.
Wildcards let one argument stand for many files. bash replaces them with the matching names before the command runs:
| Pattern | Matches |
|---|---|
*
|
any characters, including none |
?
|
exactly one character |
[abc]
|
one of a, b or c
|
So ls *.c lists every C file in the directory, and rm test?.txt removes test1.txt and testA.txt but not test10.txt. You can check what a pattern matches before using it with echo: echo *.c.
Looking inside files
| Command | What it does |
|---|---|
cat f
|
print the whole file |
less f
|
page through a file: arrows and Space to move, /word to search, q to quit
|
head f, tail f
|
the first / last 10 lines. -n 20 for 20
|
tail -f f
|
keep printing lines as they're added to f (Ctrl+C to stop)
|
wc f
|
count lines, words and bytes. wc -l counts only lines
|
grep word f
|
print the lines of f that contain word
|
grep -rn word dir
|
search every file under dir, with line numbers
|
file f
|
guess what kind of file f is
|
grep is one of the commands you'll use most. A few useful options: -i ignores case, -v prints the lines that don't match, -c counts matching lines, and -w matches whole words only.
To edit a file without leaving the terminal, use nano f (save with Ctrl+O, quit with Ctrl+X; install it with sudo apt install nano if it's missing). For anything longer, open it in VS Code with code f.
Getting help
The manual documents every command, and every C function and system call. man followed by a name opens its page:
$ man ls
Move with the arrows and Space, search with /word (then n for the next match), and quit with q. The manual is split into numbered sections, and the same name can appear in several:
| Section | Contains | Example |
|---|---|---|
| 1 | commands | man 1 printf
|
| 2 | system calls | man 2 open
|
| 3 | C library functions | man 3 printf
|
| 7 | overviews and concepts | man 7 signal
|
So man printf shows the shell command, and man 3 printf the C function. To find pages about a topic, search their descriptions with man -k: man -k directory.
Most commands also print a short summary of their options with --help, such as ls --help.
Use the manual as your reference throughout the course. For any system call or C function, its page tells you which headers to include, what the arguments mean, what it returns, and how it can fail.
sudo
sudo runs a command with administrator privileges. Use it when installing software or changing system settings, for example:
sudo apt install tree
When prompted, enter your Linux password.
Running programs
A command name is the name of a program. bash looks for it in each directory listed in the PATH variable, in order, and runs the first match. which shows where it found one:
$ echo $PATH
/usr/local/bin:/usr/bin:/bin
$ which ls
/usr/bin/ls
The current directory is usually not in PATH. To run a program in the current directory, include its path:
$ ./app
Every program finishes with an exit status, a number it hands back to the shell: 0 means success, anything else means failure. $? holds the status of the last command:
$ ls /nonexistant
ls: cannot access '/nonexistant': No such file or directory
$ echo $?
2
Two operators run a command depending on how the previous one ended:
mkdir build && cd build # cd only if mkdir succeeded
grep -q TODO main.c || echo "no TODOs left" # echo only if grep found nothing
The exit status is how programs report success to each other, and it is the value your C program's main returns (see main, arguments and the exit status).
Scripts
A script is a file of bash commands. Save this example as greet.sh:
#!/usr/bin/env bash
# Greets everyone named on the command line.
if [[ $# -eq 0 ]]; then
echo "usage: $0 NAME..." >&2
exit 1
fi
for name in "$@"; do
echo "Hello, $name!"
done
Make it executable, then run it:
$ chmod +x greet.sh
$ ./greet.sh Ada Linus
Hello, Ada!
Hello, Linus!
$ ./greet.sh
usage: ./greet.sh NAME...
$ echo $?
1
Line by line:
#!/usr/bin/env bash, the shebang, tells the kernel which program should run this file: bash.- Inside a script,
$1,$2, ... are its arguments,$#is how many there are,"$@"is all of them, and$0is the script's own name. [[ ... ]]is a test.ifruns its body when the test succeeds.>&2sends the usage message to stderr, where error messages belong.exit 1ends the script with status 1, indicating a faliure.
Tests. These are the ones you'll use most:
| Test | True when |
|---|---|
[[ -f "$p" ]]
|
$p is an existing file
|
[[ -d "$p" ]]
|
$p is an existing directory
|
[[ -z "$s" ]]
|
$s is empty
|
[[ "$a" == "$b" ]]
|
the strings are equal |
[[ $x -eq $y ]]
|
the numbers are equal (also -ne, -lt, -le, -gt, -ge)
|
[[ ! ... ]]
|
the test inside is false |
Conditions and loops. if takes any command, not only tests: it checks the command's exit status.
if grep -q "main" "$file"; then
echo "$file has a main"
elif [[ -d "$file" ]]; then
echo "$file is a directory"
else
echo "$file: nothing to see"
fi
for f in *.c; do # loop over files
echo "$f has $(wc -l < "$f") lines"
done
for i in {1..5}; do # loop over numbers
echo "$i"
done
while read -r line; do # loop over the lines of a file
echo "> $line"
done < notes.txt
count=0
while [[ $count -lt 3 ]]; do # loop while a test holds
count=$((count + 1))
done
Functions group commands under a name. They take arguments the same way a script does:
backup() {
cp "$1" "$1.bak" && echo "saved $1.bak"
}
backup notes.txt
Debugging a script. Run it with
bash -x script.shto see each command, with variables filled in, just before it runs.
Installing software
Ubuntu installs software from packages with apt:
sudo apt update # refresh the list of available packages
sudo apt install tree # install a package
apt search json # look for packages
sudo apt upgrade # update everything that's installed
Run sudo apt update before installing, so apt knows about the latest versions. C libraries come in two packages: the library itself, and a -dev package with its headers, which you need to compile new programs against it.
C refresher
This section reviews the parts of C used most often in systems programming and introduces the setup you'll use to build and debug programs throughout the course.
From source to program
Save this as hello.c, in a directory of its own:
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("Hello from %s, with %d argument(s)\n", argv[0], argc - 1);
return 0;
}
Build it and run it:
$ clang -std=gnu23 -Wall -Wextra -g -o hello hello.c
$ ./hello one two
Hello from ./hello, with 2 argument(s)
The options:
| Option | Meaning |
|---|---|
-std=gnu23
|
compile as C23, the newest C standard, with GNU extensions |
-Wall -Wextra
|
turn on warnings about likely bugs |
-g
|
include debugging information, so the debugger can show your code |
-o hello
|
name the result hello (instead of a.out)
|
Treat warnings as errors. A warning means the compiler found something that is legal C but very probably not what you meant. Fix every one, until you know for certain that one isn't a bug.
That one command runs four separate steps. You can run them one at a time to see what each produces:
$ clang -std=gnu23 -E hello.c -o hello.i # 1. preprocess
$ clang -std=gnu23 -S hello.i -o hello.s # 2. compile to assembly
$ clang -c hello.s -o hello.o # 3. assemble to machine code
$ clang hello.o -o hello # 4. link
- The preprocessor handles every line starting with
#.#includepastes in the whole header file, and#definemacros are replaced by their definitions. Look athello.i: your seven lines have become hundreds, mostly the declarations fromstdio.h. - The compiler translates C into assembly language for your processor.
hello.sis readable, if you're curious. - The assembler turns assembly into machine code, in an object file.
hello.oholds yourmain, butprintfis only a name it refers to. - The linker joins object files together with the libraries they use, here the C library, which contains
printf. The result is a program the kernel can run.
When a program is made of several .c files, each is compiled to its own object file, and the linker joins them. This explains two kinds of error you will meet: a compile error is a problem inside one file, while an "undefined reference" is a link error: a function that was declared but whose definition the linker couldn't find in any object file or library.
main, arguments and the exit status
main is where your program starts, and it gets the command-line arguments:
argcis the number of arguments, including the program's name.argv[0]is the program's name,argv[1]toargv[argc - 1]are the arguments, as strings, andargv[argc]is a null pointer.
The value main returns is the program's exit status, the number bash shows in $?. Return 0 (or EXIT_SUCCESS) on success, and something else (usually 1, or EXIT_FAILURE) on failure. Calling exit(status) from anywhere in the program ends it early.
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
if (argc != 2) {
fprintf(stderr, "usage: %s NAME\n", argv[0]);
return EXIT_FAILURE;
}
printf("Hello, %s!\n", argv[1]);
return EXIT_SUCCESS;
}
This is the same pattern as the bash script in Scripts: check the arguments, print usage to stderr, fail with a non-zero status. Programs that follow these rules work well with the shell, and with each other.
Types and sizes
The C standard sets only minimum sizes for its types. On the 64-bit Linux systems we use, they are:
| Type | Size (bytes) | printf format
|
|---|---|---|
char
|
1 | %c, or %hhd for the number
|
short
|
2 | %hd
|
int
|
4 | %d
|
long, long long
|
8 | %ld, %lld
|
| any pointer | 8 | %p
|
size_t (sizes, counts)
|
8 | %zu
|
ssize_t (size, or -1)
|
8 | %zd
|
Each also has an unsigned version (%u, %lu, ...; %x prints in hex). When you need an exact size, use the types from <stdint.h>: int32_t, uint8_t, uint64_t and so on.
size_t is the type for sizes and array indexes. sizeof produces one, and strlen, malloc and read use it.
Under C23, bool, true and false are built in, with no #include, and nullptr is the null pointer constant. Older code, and many manual pages, use NULL and <stdbool.h>.
Pointers and arrays
A pointer holds the address of something in memory. &x is the address of x, and *p is the thing p points to:
int x = 10;
int *p = &x; /* p holds the address of x */
*p = 20; /* changes x, through p */
C passes every argument by value: a function gets a copy. To let a function change the caller's variable, pass its address:
void swap(int *a, int *b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
swap(&x, &y);
This is also how C functions return more than one result. Many system calls use the return value to indicate success or failure and write results through pointers you pass in.
An array is a fixed number of elements, side by side in memory. In most expressions, the array's name turns into a pointer to its first element, so a[i] and *(a + i) mean the same thing. Pointer arithmetic counts in elements, not bytes: if p is an int *, p + 1 is 4 bytes further on.
When an array is passed to a function, it arrives as a pointer, and its size is lost:
void f(int a[10])
{
/* a is really an int *: sizeof(a) is 8, not 40 */
}
So functions that take an array also take its length, as a size_t.
A struct groups related values. -> reaches a field through a pointer:
struct point {
int x;
int y;
};
void move(struct point *p, int dx, int dy)
{
p->x += dx; /* the same as (*p).x += dx */
p->y += dy;
}
(In the Linux kernel style we use, structs are written struct point, without a typedef.)
Strings
A C string is an array of char that ends with a zero byte, '\0'. Nothing else marks where it ends: every string function walks along the array until it finds that zero.
char s[] = "cat"; → 'c' 'a' 't' '\0'
s[0] s[1] s[2] s[3]
The consequences:
strlen(s)is 3, but the array takes 4 bytes. A buffer for a string of lengthnneedsn + 1bytes.- A string without its
'\0'makes every string function read past its end, into whatever comes next in memory. ==compares two pointers, not two strings. Usestrcmp(a, b) == 0.char *p = "text";points to a string that may not be changed;char buf[] = "text";is a copy, in an array of your own, that you may.
Copying into a fixed-size buffer is a common source of string bugs. strcpy, strcat and sprintf write without checking the buffer's size. Use snprintf, which takes the buffer's size and never writes past it:
char buf[32];
snprintf(buf, sizeof(buf), "process %d exited with %d", pid, status);
To read input a line at a time, of any length, use getline. It allocates the buffer for you and grows it as needed:
char *line = nullptr;
size_t cap = 0;
ssize_t len;
while ((len = getline(&line, &cap, stdin)) != -1) {
/* line holds len characters, including the '\n' at the end */
}
free(line);
getlineis an example of a POSIX (Unix) extension to the C standard.
Function pointers and void *
A function also has an address, and a function pointer holds one. The declaration looks like the function's, with (*name) in place of the name:
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }
int (*op)(int, int); /* op points to a function taking two ints, returning int */
op = add; /* a function's name is its address; &add also works */
printf("%d\n", op(6, 7)); /* 13 */
op = mul;
printf("%d\n", op(6, 7)); /* 42 */
The parentheses matter: int *op(int, int); declares a function that returns an int *.
Function pointers let you choose which code runs at run time. Store them in an array or a struct, and you have a table of operations to pick from:
struct operation {
const char *name;
int (*fn)(int, int);
};
static const struct operation ops[] = {
{ "add", add },
{ "mul", mul },
};
A function you pass to another function, for it to call back, is a callback. The C library's qsort sorts an array of any type: you give it the array, the number of elements, the size of one element, and a function that compares two elements:
int cmp_int(const void *a, const void *b)
{
int x = *(const int *)a;
int y = *(const int *)b;
return (x > y) - (x < y); /* negative, zero or positive */
}
int nums[] = { 42, 7, 19 };
qsort(nums, 3, sizeof(nums[0]), cmp_int);
qsort can't know the type of your elements, so it hands your comparison function two void *. A void * is a pointer to "something": an address with no type attached.
- Any object pointer converts to
void *and back without a cast. - You can't dereference a
void *. To use the value, convert it back to a pointer of the right type, ascmp_intdoes. - The compiler can't check that the type is right. If you convert a
void *back to the wrong type, the program compiles and reads the bytes as that wrong type.
Callbacks often take a void * context: a pointer the caller chooses, which the library passes back to the callback without looking at it. The callback converts it back to the type it expects. You'll see this pattern again when you create threads with pthread_create.
Programs in several files
As soon as a program grows past a few hundred lines, split it into several .c files, each with a header (.h) describing what it offers the others.
/* list.h: what other files may use */
#ifndef LIST_H
#define LIST_H
#include <stddef.h>
struct list;
struct list *list_new(void);
void list_push(struct list *l, int value);
size_t list_len(const struct list *l);
void list_free(struct list *l);
#endif /* LIST_H */
/* list.c: how it works */
#include "list.h"
#include <stdlib.h>
struct list {
int *items;
size_t len;
size_t cap;
};
/* static: only list.c can call it */
static bool grow(struct list *l);
/* ... the definitions of grow, list_new, list_push, ... */
- A header declares functions and types; a
.cfile defines them. Every.cfile that uses the list does#include "list.h". - Use
<...>for system headers and"..."for your own. - The include guard (
#ifndef/#define/#endif) stops the header's contents from being read twice when it's included twice. - A function marked
staticis private to its.cfile. Make every functionstaticunless another file needs it. struct list;in the header, without its fields, lets other files usestruct list *without knowing what's inside. Onlylist.ccan touch the fields, so you can change them without breaking anything else.
Our VS Code setup
We provide a project template to extract into a new directory for each program you write. It includes the following configuration:
- clangd completes names as you type, shows errors and warnings immediately, and adds extra bug-finding checks (
clang-tidy); - CMake builds the program with the right options;
- GDB debugs it, with breakpoints and variables shown in the editor;
- saving a file formats it in the Linux kernel style (
clang-format).
Getting the template
In a Linux terminal, download the project template and extract it into a new project directory:
mkdir -p ~/oslab && cd ~/oslab
curl -OL https://wiki.dcae.pub.ro/images/9/93/Os_lab_skeleton.zip
unzip -d hello Os_lab_skeleton.zip
The archive contains the project files directly, without an enclosing folder. -d hello extracts them into ~/oslab/hello. For each new program, extract the archive again with a different directory name.
If you copy a project you've already built, delete
build/in the copy: CMake remembers the old project's path.
What's in it
hello/
├── src/ your code
│ ├── main.c main() is here
│ ├── utils.c an example of a second source file...
│ └── utils.h ...and its header
├── CMakeLists.txt how the program is built
├── CMakePresets.json the Debug and Release build configurations
├── .clang-format the code style, applied when you save
├── .clang-tidy the extra bug-finding checks
├── .vscode/ editor settings, build tasks and debug configuration
└── build/ created when you build; safe to delete
Your code goes in src/. Every .c file there is compiled into the program automatically: to add one, create it and build. You normally don't need to change anything outside src/.
The template includes a small program for checking the setup. It reads lines (with line editing: the arrow keys work) and prints each one back in upper case, until you press Ctrl+D. Replace it with your own code.
Opening a project
- Windows: connect to Ubuntu 26.04 (see Windows setup), then File → Open Folder… →
/home/<you>/oslab/hello. Or, in an Ubuntu tab,cd ~/oslab/hello && code . - macOS: connect to
orb(see macOS setup), then File → Open Folder… →/home/<you>/oslab/hello - Linux:
cd ~/oslab/hello && code .
The first time you open a project:
- If VS Code asks whether you trust the authors of the folder, say yes.
- When it offers the recommended extensions (clangd, CMake Tools, C/C++), install them. On Windows and macOS they install on the Linux side. You can also find them in Extensions under
@recommended. - If clangd offers to turn off IntelliSense, accept.
- If CMake Tools asks for a configure preset, choose Debug.
For help installing extensions, see the official VS Code extension guide.
Building and running
| What | How |
|---|---|
| Build | F7, or Ctrl/Cmd+Shift+B |
| Run | the Run (▷) button, or ./build/debug/app in the terminal (Ctrl+`)
|
| Debug | F5 |
| Errors | red and yellow squiggles as you type; the Problems panel (Ctrl/Cmd+Shift+M) |
The program is called app, and lives in build/debug/.
Errors and warnings appear as you type, underlined in the editor. Hover over one to read it. The build reports the same problems again in the Problems panel; click one to jump to the line.
Formatting. Saving a file reformats it in the Linux kernel style: tabs for indentation, shown 8 columns wide, lines of at most 80 characters, and a function's opening brace on a line of its own. The formatter applies these rules when you save. Wide indentation makes deeply nested code easier to spot; consider splitting such code into smaller functions.
Debugging
A debugger lets you stop your program at a line, inspect variables, and continue one line at a time to trace its behaviour. Try it on the template:
- Open
src/utils.cand click to the left of the line number ons[i] = toupper(s[i]);. A red dot appears: a breakpoint. - Press F5. The program builds and starts in the terminal panel. Type
helloand press Enter. - The program stops on your line, before running it. The Variables panel shows
sandi; hovering over a variable in the editor shows its value too. The Call Stack panel shows how you got here:maincalledto_upper_str. - Press F10 a few times, watching
iandschange as the loop runs. - Press F5 to continue. The program stops again for the next line you type, until you remove the breakpoint (click the red dot) or stop the program.
For an illustrated explanation of the debugger controls and panels, see the official VS Code debugging guide.
| Key | What it does |
|---|---|
| F5 | start, or continue to the next breakpoint |
| F10 | step over: run the current line |
| F11 | step into: follow the call on this line into the function |
| Shift+F11 | step out: run until the current function returns |
| Shift+F5 | stop the program |
To pass command-line arguments to your program when debugging, list them in "args" in .vscode/launch.json, for example "args": ["one", "two"].
Without VS Code
Everything VS Code does is an ordinary command you can run in a terminal, in the project directory:
cmake --preset debug # configure: once, before the first build
cmake --build --preset debug # build
./build/debug/app # run
You can also run gdb, the debugger VS Code uses, directly:
$ gdb ./build/debug/app
(gdb) break to_upper_str # stop when this function is called
(gdb) run # start the program
(gdb) next # run one line
(gdb) print s # show a variable
(gdb) backtrace # how did we get here?
(gdb) continue # carry on to the next breakpoint
(gdb) quit
Check your setup
You're done with the setup when:
- ☐ the bottom-left corner of VS Code shows WSL: Ubuntu-26.04 or SSH: orb (on Windows and macOS);
- ☐ the template builds with no errors or warnings, and runs;
- ☐ the debugger stops at your breakpoint and shows your variables.
If any of these fails, see Troubleshooting, or ask in the lab.
Exercises
Work through these exercises during the lab session. Write each program in its own copy of the template (see Our VS Code setup), and make sure it builds without warnings. The exercises cover strings, arrays of structs and sorting, then function pointers and void *.
A config file reader
Many programs read their settings from a text file of key=value lines. Write a program that reads such a file and prints the values of the keys named on its command line:
$ cat settings.txt
# who and where
name=Alice
city=Paris
age=30
greeting=hello=world
oops
$ ./build/debug/app settings.txt name age colour
settings.txt:7: missing '='
name: Alice
age: 30
colour: (not set)
$ echo $?
1
Requirements:
- Store the settings in an array of at most 64 elements of type
struct pair { char key[32]; char value[64]; }; - Read the file a line at a time, with
fopenandgetline, and remove the'\n'at the end of each line. - Skip empty lines, and lines starting with
#. - Split each line at its first
=(look upstrchr). Everything after it is the value, even if it contains another=. - For a line with no
=, or a key or value too long to fit, print a warning to stderr with the file name and line number, and skip the line. - Write a function
const char *get(const struct pair *pairs, size_t n, const char *key)that returns the value forkey, ornullptrif there is none. - Exit with status 0 if every key was found, and 1 otherwise. If the file can't be opened, print an error and exit with status 2.
get returns a pointer into the array rather than a copy of the value. What would go wrong if pairs were local to a function that had already returned?
Word frequencies
Write a program that reads text from stdin and prints the most frequent words and their counts:
$ ./build/debug/app 3 < some-book.txt
4127 the
2316 and
1876 of
The argument specifies how many words to print. If it is omitted, print the 10 most frequent words.
Requirements:
- A word is a run of letters (
isalpha). Count words without regard to case:Theandtheare the same word, so convert each to lower case. - Store the counts in an array of at most 4096 elements of type
struct entry { char word[32]; int count; };Skip words that are too long to fit, and stop adding new words (but keep counting existing ones) when the array is full. - Write a function
struct entry *find(struct entry *entries, size_t n, const char *word)that returns a pointer to the entry forword, ornullptr. When it finds one, increase the count through the returned pointer. - Sort the entries with
qsortand a comparison function you write (see Function pointers and void *): most frequent first, and words with equal counts in alphabetical order.
Use a long plain-text file as input, such as a book from Project Gutenberg. Compare your output with this bash pipeline, which should print the same counts:
tr -cs 'A-Za-z' '\n' < some-book.txt | tr 'A-Z' 'a-z' | grep -v '^$' \
| sort | uniq -c | sort -k1,1rn -k2,2 | head -3
find checks every entry, so counting a book of w words with n different words takes about w × n steps. How could you make the search faster?
A command interpreter
Write a small interpreter that reads and runs commands from stdin, one per line. Store its state in the following struct:
struct point {
int x;
int y;
};
struct state {
int counter;
char text[256];
struct point pos;
};
Support these commands:
| Command | What it does |
|---|---|
inc N
|
adds N to the counter
|
append WORDS
|
appends WORDS to the text
|
move DX DY
|
moves the point by DX, DY
|
show
|
prints all three |
help
|
lists the commands |
$ ./build/debug/app
> inc 5
> inc -2
> append hello
> append world
> move 3 -1
> show
counter: 3
text: hello world
pos: (3, -1)
> jump 4
unknown command: jump (try help)
> help
inc append move show help
>
The program exits at the end of input (Ctrl+D).
Store commands in a table with one row per command:
struct command {
const char *name;
void (*fn)(void *ctx, const char *args);
void *ctx;
};
Each handler gets a void * to the state it works on, and the rest of the line as args. inc gets &state.counter, an int *; append gets state.text, a char *; move gets &state.pos, a struct point *; show gets &state. Each handler converts ctx back to the type it expects.
Requirements:
- The main loop reads a line, splits off the first word as the command's name, finds the matching row in the table, and calls
cmd->fn(cmd->ctx, args). The main loop must not depend on any specific state type. Adding a command should require only a new handler and a new row in the table. - Implement
helpas a command in the table, with the table itself as its context. - Handlers check their arguments:
incwithout a number, ormovewith only one, prints an error and changes nothing.sscanforstrtolwill help. appendmust never write past the end oftext(see Strings).
Change the inc row's context to &state.pos. Does the program still compile? What does inc 5 do now, and why? What does this show about type checking when using void *?
Troubleshooting
Windows: error 0x80370102, or "virtualization is not enabled". Turn on virtualization (Intel VT-x / AMD-V / SVM) in your computer's BIOS/UEFI settings, then try again.
macOS: Linux programs complain about xterm-ghostty ("unknown terminal type"). From a Ghostty tab on macOS (not in Linux), run once:
infocmp -x xterm-ghostty | ssh orb -- tic -x -
man says "This system has been minimized". Run sudo unminimize (if that command isn't found, sudo apt install unminimize first).
CMake complains that the cache "is different" from where it was created. You copied a project that had already been built. Delete its build/ directory and build again.
clangd says a header isn't found, or shows errors in correct code. The project hasn't been configured yet. Build once (F7), or run CMake: Configure from the command palette (Ctrl/Cmd+Shift+P).
CMake says required packages were not found (readline). Install the toolchain from Install the toolchain, then configure again.
VS Code's terminal runs PowerShell or zsh instead of bash. The window isn't connected to Linux: check the bottom-left corner, and connect as in Setting up.
Permission denied when running a program or script. It isn't executable: chmod +x it. If it's a program you built, check that you're running the one in build/debug/.
The program seems to hang. It's probably waiting for input on stdin. Type some, press Ctrl+D for end of input, or Ctrl+C to stop it.
Quick reference
bash
| Task | Command |
|---|---|
| where am I / what's here | pwd, ls -la
|
| move around | cd dir, cd .., cd, cd -
|
| create | mkdir -p dir, touch f
|
| copy, move, delete | cp -r, mv, rm -r
|
| read a file | cat, less, head, tail
|
| search | grep -rn text dir
|
| help | man cmd, man 2 call, man 3 func, cmd --help
|
| last exit status | echo $?
|
| make executable | chmod +x f
|
| install | sudo apt install pkg
|
VS Code
| Task | Key |
|---|---|
| build | F7 |
| debug / continue | F5 |
| step over / into / out | F10 / F11 / Shift+F11 |
| stop | Shift+F5 |
| terminal | Ctrl+` |
| command palette | Ctrl/Cmd+Shift+P |
| go to file | Ctrl/Cmd+P |
| go to definition | F12 |
| problems panel | Ctrl/Cmd+Shift+M |
gdb
| Command | Short | What it does |
|---|---|---|
break func
|
b
|
stop at a function (or file.c:42)
|
run args...
|
r
|
start the program |
next
|
n
|
run one line |
step
|
s
|
step into a call |
continue
|
c
|
run to the next breakpoint |
print expr
|
p
|
show a value |
backtrace
|
bt
|
show the call stack |
quit
|
q
|
leave gdb |
Further reading
- The manual.
man man,man bash,man 7 intro, and the page of every function you use. Also online at man7.org. - William Shotts, The Linux Command Line: an introduction to the shell, free at linuxcommand.org.
- The GNU Bash manual, for everything about bash itself.
- Brian Kernighan and Dennis Ritchie, The C Programming Language, 2nd edition: a concise introduction to C.
- Michael Kerrisk, The Linux Programming Interface: the reference for system programming on Linux, and a companion for the whole course.