OS Lab 1 - Linux setup. Bash primer. C primer

De la WikiLabs
Jump to navigationJump to search

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:

  1. explain, in a few sentences, what the kernel, the shell and a system call are, and how they relate;
  2. open a Linux shell on your own computer and connect VS Code to the same Linux system;
  3. navigate the file system, manage files and run programs from the shell;
  4. read and write short bash scripts that take arguments, make decisions and loop;
  5. compile a C program by hand and explain what each stage of the build does;
  6. use C types, pointers, arrays, structs, strings and function pointers in short programs;
  7. 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 this is 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:

  1. WSL: Install WSL. Use WSL 2 with Ubuntu 26.04 LTS and create a Linux username and password when prompted.
  2. Windows Terminal: Install and set up Windows Terminal. Open the Ubuntu 26.04 profile.
  3. 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:

  1. OrbStack: Quick start. Install OrbStack and create an Ubuntu 26.04 machine named oslab.
  2. Ghostty: Install on macOS. Open Ghostty and run orb to enter Linux.
  3. 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)

  1. Ubuntu: if you are using an older release, follow the official Ubuntu Desktop upgrade guide to upgrade to 26.04 LTS.
  2. 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. rm has no trash and no undo. rm -r deletes everything under a directory without asking. Read the command before pressing Enter, and never run rm -rf on 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 $0 is the script's own name.
  • [[ ... ]] is a test. if runs its body when the test succeeds.
  • >&2 sends the usage message to stderr, where error messages belong.
  • exit 1 ends 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.sh to 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
  1. The preprocessor handles every line starting with #. #include pastes in the whole header file, and #define macros are replaced by their definitions. Look at hello.i: your seven lines have become hundreds, mostly the declarations from stdio.h.
  2. The compiler translates C into assembly language for your processor. hello.s is readable, if you're curious.
  3. The assembler turns assembly into machine code, in an object file. hello.o holds your main, but printf is only a name it refers to.
  4. 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:

  • argc is the number of arguments, including the program's name.
  • argv[0] is the program's name, argv[1] to argv[argc - 1] are the arguments, as strings, and argv[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 length n needs n + 1 bytes.
  • 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. Use strcmp(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);

getline is 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, as cmp_int does.
  • 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 .c file defines them. Every .c file 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 static is private to its .c file. Make every function static unless another file needs it.
  • struct list; in the header, without its fields, lets other files use struct list * without knowing what's inside. Only list.c can 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:

  1. If VS Code asks whether you trust the authors of the folder, say yes.
  2. 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.
  3. If clangd offers to turn off IntelliSense, accept.
  4. 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:

  1. Open src/utils.c and click to the left of the line number on s[i] = toupper(s[i]);. A red dot appears: a breakpoint.
  2. Press F5. The program builds and starts in the terminal panel. Type hello and press Enter.
  3. The program stops on your line, before running it. The Variables panel shows s and i; hovering over a variable in the editor shows its value too. The Call Stack panel shows how you got here: main called to_upper_str.
  4. Press F10 a few times, watching i and s change as the loop runs.
  5. 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 fopen and getline, and remove the '\n' at the end of each line.
  • Skip empty lines, and lines starting with #.
  • Split each line at its first = (look up strchr). 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 for key, or nullptr if 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: The and the are 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 for word, or nullptr. When it finds one, increase the count through the returned pointer.
  • Sort the entries with qsort and 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 help as a command in the table, with the table itself as its context.
  • Handlers check their arguments: inc without a number, or move with only one, prints an error and changes nothing. sscanf or strtol will help.
  • append must never write past the end of text (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.