COMP 3150Operating Systems

01 Introduction

The first four lessons of this unit gave you the platform: a working Linux system, a shell, an identity, and the permissions to operate. This lesson gives you the tools. Everything you build in this course — process-management labs, threading exercises, a scheduler simulator, a virtual-memory simulator, a container — will be developed with the same set of tools: the GNU Compiler Collection for building, Make for automating the build, Git for version control, and GDB for debugging.

These are not arbitrary choices. Every one of them is the de facto standard on Linux systems, used in production by kernel developers and application programmers alike. The patterns you learn here are the same patterns used in open-source projects with millions of lines of code — scaled down to the size of an assignment. Learning them well now means you will spend your time on operating-systems concepts rather than on tooling friction in the labs.

The lesson is organised as a workflow. We begin with the compiler and what happens when you invoke it, then move to the build system that automates repeated compilation, then to the version control that records every change, and finally to the debugger that lets you observe your program from the inside. Each tool is introduced in the context of a small, realistic example.

02 Learning objectives

By the end of this lesson you will be able to

  • Configure Linux development tools.
  • Compile programs with GCC.
  • Use Makefiles.
  • Apply Git version control.
  • Debug using GDB.

This lesson contributes to U2.4, U2.5, and to course learning outcomes CLO6 and CLO8.

03 Required reading

  • Love, R. — Linux System Programming, 2nd ed., Chapter 1 (Introduction and Essential Concepts) and Appendix B (GCC extensions). A practical introduction to the toolchain from a systems programming perspective.
  • Stallman, R. M., & the GCC Developer Community — Using the GNU Compiler Collection (GCC). The definitive reference for GCC; freely available online at gcc.gnu.org/onlinedocs/.
  • Chacon, S., & Straub, B. — Pro Git, 2nd ed. Freely available online at git-scm.com/book. Chapters 1–3 cover the material this lesson introduces; Chapters 6–7 are useful when the course requires more elaborate workflows.
  • Stallman, R. M., & Pesch, R. — Debugging with GDB. Freely available at sourceware.org/gdb/current/onlinedocs/gdb/.
  • Man pages: man 1 gcc, man 1 make, man 1 git, man 1 gdb. These are the fastest references while working.

Reading strategy

The GCC, Make, Git, and GDB manuals are extensive and intended for reference, not for reading cover to cover. Read the chapters of Love's book first for orientation, then use the manuals as lookups. Treat this lesson as a syllabus for the tools rather than an exhaustive guide.

04 Core content

4.1 What "development environment" means

A development environment is the collection of tools a programmer uses to write, build, test, and debug software. On a modern Linux system, the standard toolkit for systems programming consists of:

The standard Linux systems-programming toolchain
Tool Purpose Package (Debian/Ubuntu)
gcc C compiler gcc
g++ C++ compiler g++
make Build automation make
git Version control git
gdb Debugger gdb
valgrind Memory-error detector valgrind
strace System-call tracer strace
ltrace Library-call tracer ltrace
man-pages Manual pages man-db, manpages-dev

On Debian-derived distributions (Ubuntu, Pop!_OS, Linux Mint), most of this is bundled in a single meta-package:

# Install the essential toolchain on Debian/Ubuntu
$ sudo apt update
$ sudo apt install build-essential gdb git valgrind strace ltrace

# Verify the toolchain
$ gcc --version
gcc (Ubuntu 13.2.0-23ubuntu4) 13.2.0
Copyright (C) 2023 Free Software Foundation, Inc.
...

$ make --version
GNU Make 4.3

$ git --version
git version 2.43.0

$ gdb --version
GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403

On Fedora and RHEL-derived systems, the equivalent is the "Development Tools" package group:

$ sudo dnf groupinstall "Development Tools"
$ sudo dnf install gdb git valgrind strace

On Alpine (common in containers), GCC is available via build-base:

$ apk add build-base gdb git

4.2 The compilation pipeline

When you invoke gcc on a source file, several distinct stages occur before the final executable is produced. Understanding these stages makes it possible to diagnose build errors more precisely and to control the compiler's behaviour.

The compilation pipeline A source file passes through four stages: preprocessing, compilation, assembly, and linking. Each stage transforms the file and produces an intermediate artifact. The output is an executable that the loader brings into memory at run time. THE COMPILATION PIPELINE hello.c source file 1 · Preprocess cpp handles #include, #define 2 · Compile cc1 C → assembly 3 · Assemble as assembly → object source hello.i hello.s hello.o 4 · Link ld · combines object files with libraries resolves symbols, produces the executable executable — hello loaded into memory by the kernel at run time The four stages can be run separately (with -E, -S, -c) or together with a single gcc invocation.
Figure 2.5 — The compilation pipeline. GCC drives all four stages; the intermediate files (.i, .s, .o) can be inspected or produced separately.

4.3 Compiling with GCC

GCC is the GNU Compiler Collection. It compiles C, C++, and several other languages. For this course, the C front end is the one that matters. The minimum invocation is:

$ gcc hello.c -o hello
$ ./hello

That is enough to build a working program, but for anything larger than a trivial exercise, additional flags are essential. The following set is the standard starting point for this course:

gcc -Wall -Wextra -g -O2 -std=c17 -o hello hello.c

Each flag contributes something specific:

Important GCC flags for this course
Flag Meaning
-Wall Enable a broad set of warnings. Nearly always correct to use; the warnings catch real bugs.
-Wextra Enable even more warnings beyond -Wall. Occasionally noisy but rarely wrong.
-Werror Treat warnings as errors. Useful in production builds; can be annoying during exploration.
-g Include debug symbols. Required for GDB to show source-level information.
-O0 / -O2 Optimisation level. -O0 is fastest to compile and easiest to debug. -O2 produces fast code. -O2 can reorder and inline, making single-stepping less intuitive.
-std=c17 Select the C standard. c17 is the current default on modern GCC.
-pthread Enable POSIX threads and link against the threading library. Required for the threading labs.
-lm Link against the math library (libm). Required when using sin, cos, etc.
-I<dir> Add <dir> to the include search path.
-L<dir> Add <dir> to the library search path.
-l<name> Link against library lib<name>.

Warning example

Consider a program with a subtle bug:

#include <stdio.h>

int main(void) {
    int x = 10;
    if (x = 5) {                     /* assignment, not comparison */
        printf("x is five\n");
    }
    return 0;
}

Compiled with default flags, this compiles silently. With -Wall, GCC warns:

warning: suggest parentheses around assignment used as truth value [-Wparentheses]
     if (x = 5) {
         ^

The warning is not a false positive — the code assigns 5 to x, then tests whether the result is non-zero, which it always is. The -Wall flag catches a genuine bug that would otherwise compile and run with wrong behaviour. Always use -Wall.

Building multiple source files

Real programs are rarely a single file. GCC compiles each source file to an object file, then links them:

# Compile each source file to an object file
$ gcc -Wall -Wextra -g -c main.c -o main.o
$ gcc -Wall -Wextra -g -c util.c -o util.o

# Link the object files into an executable
$ gcc main.o util.o -o program

Separating the compile and link steps is essential for large projects: if only one source file changes, only that file needs to be recompiled. But doing this by hand for dozens of files is tedious and error-prone. That is what make is for.

4.4 Make and Makefiles

Make is a build automation tool. It reads a Makefile — a file describing the relationships between source files and the outputs derived from them — and runs only the commands needed to bring the outputs up to date. When you change one source file, make recompiles only what depends on it.

Anatomy of a Makefile

A Makefile is a set of rules. Each rule has the form:

target: prerequisites
[TAB]recipe

The target is the file to build. The prerequisites are the files it depends on. The recipe is the command that builds the target from the prerequisites. The recipe lines must be indented with a tab, not spaces — this is one of the most common pitfalls for beginners.

A minimal Makefile for a two-file program:

# Variables for the compiler and flags
CC      = gcc
CFLAGS  = -Wall -Wextra -g -O2 -std=c17

# The default target — built when you run "make" with no arguments
program: main.o util.o
	$(CC) $(CFLAGS) -o program main.o util.o

# Rules to build each object file from its source
main.o: main.c
	$(CC) $(CFLAGS) -c main.c -o main.o

util.o: util.c
	$(CC) $(CFLAGS) -c util.c -o util.o

# A phony target: not a file, but a command
clean:
	rm -f program *.o

.PHONY: clean

With this Makefile, the workflow becomes:

# Build everything (runs the default target)
$ make
gcc -Wall -Wextra -g -O2 -std=c17 -c main.c -o main.o
gcc -Wall -Wextra -g -O2 -std=c17 -c util.c -o util.o
gcc -Wall -Wextra -g -O2 -std=c17 -o program main.o util.o

# Change util.c, then rebuild
$ make
gcc -Wall -Wextra -g -O2 -std=c17 -c util.c -o util.o
gcc -Wall -Wextra -g -O2 -std=c17 -o program main.o util.o
# main.c was not recompiled — its source was unchanged

# Remove everything
$ make clean

Make's value is easiest to see when a project grows. In a project with dozens of source files and multiple libraries, the Makefile encodes all the dependency information, and make recompiles only what is necessary. A change to a single header file triggers recompilation of every source file that includes it — but nothing else.

The variables make predefined

Make also has a set of built-in rules and variables. For simple projects, you can write a Makefile that uses them:

CFLAGS  = -Wall -Wextra -g -O2
LDLIBS  = -lm

program: main.o util.o
	$(CC) $(CFLAGS) -o $@ $^ $(LDLIBS)

clean:
	rm -f program *.o

.PHONY: clean

Here $(CC) is the compiler (defaulting to cc), $@ expands to the target name (program), and $^ expands to the list of prerequisites. These automatic variables make Makefiles shorter and more readable.

Common Make targets

By convention, most Makefiles provide these targets:

  • all — build everything. Often the default target.
  • clean — remove all build artifacts.
  • install — copy built files to system locations (usually requires root).
  • test — run the project's tests.
  • dist or distclean — remove everything that can be regenerated, including generated configuration files.

4.5 Git version control

Git is a distributed version control system. It records every change to a set of files, allowing you to review history, revert mistakes, work on multiple features in parallel, and collaborate with others. Every laboratory and assignment in this course will be managed with Git.

Core concepts

Repository
A directory containing a project's files plus a hidden .git subdirectory that holds the entire history. The repository exists independently of any server; Git is distributed, meaning every clone is a complete copy.
Commit
A snapshot of the project's tracked files at a point in time. Each commit has a unique hash, an author, a timestamp, a message, and a reference to its parent commit(s).
Branch
A movable pointer to a commit. The default branch is called main (formerly master). Branches allow parallel lines of work.
Working tree
The files you see in your project directory — the current state that you edit.
Index (or staging area)
An intermediate area where you assemble the next commit. Changes you make to files are not automatically included in the next commit; you stage them explicitly.
Remote
A reference to a copy of the repository on another machine — typically a server such as GitHub, GitLab, or a university's own hosting.

The basic workflow

# One-time setup
$ git config --global user.name  "Alice Example"
$ git config --global user.email "alice@example.com"
$ git config --global init.defaultBranch main

# Start a new repository
$ mkdir myproject && cd myproject
$ git init

# Create a file and stage it
$ echo "hello" > hello.txt
$ git add hello.txt

# Commit the staged changes
$ git commit -m "Add initial greeting"

# Check the status
$ git status
On branch main
nothing to commit, working tree clean

# View the history
$ git log --oneline
a1b2c3d Add initial greeting

Making changes

# Modify a file
$ echo "world" >> hello.txt

# See what changed
$ git diff
diff --git a/hello.txt b/hello.txt
index 3b18e51..cc4d0f6 100644
--- a/hello.txt
+++ b/hello.txt
@@ -1 +1,2 @@
 hello
+world

# Stage and commit
$ git add hello.txt
$ git commit -m "Extend greeting to include world"

Working with remotes

# Clone an existing repository
$ git clone https://github.com/user/project.git

# Add a remote to a local repository
$ git remote add origin https://github.com/user/myproject.git

# Push local commits to the remote
$ git push -u origin main

# Fetch and merge changes from the remote
$ git pull

Branches

# Create and switch to a new branch
$ git switch -c feature/threading
# (older syntax: git checkout -b feature/threading)

# List branches
$ git branch
* feature/threading
  main

# Switch back to main
$ git switch main

# Merge a branch into the current branch
$ git merge feature/threading

The .gitignore file

Most projects contain files that should not be committed: compiled executables, object files, editor swap files, debug logs, and so on. A .gitignore file at the repository root lists patterns to exclude:

# .gitignore
*.o
*.a
*.so
program
*.log
*.tmp
__pycache__/
.cache/
.vscode/
build/

The distinction between tracked and ignored files is important: ignored files are invisible to Git, so a compiled binary in the working directory does not appear in git status and will never be accidentally committed.

Why version control matters for this course

Three reasons:

  • Safety. A working version of your assignment is preserved even if you make a mistake in later edits. You can always revert.
  • Experimentation. You can try a design change on a branch, discover it does not work, and discard the branch without affecting your working main branch.
  • Evidence of authorship. The commit history is a record of your work over time. Some courses and institutions use this as part of the submission process.

Commit early, commit often

Commit every time you complete a small, coherent piece of work — a function that compiles, a bug that is fixed, a feature that works. The point of Git is not to save only the final state; it is to record the history of how you arrived at that state. A repository with hundreds of small commits is easier to understand and easier to correct than one with a single large commit.

4.6 GDB — the GNU debugger

GDB is the standard debugger on Linux. It lets you run a program under your control: pause it at any point, inspect variables, step through source lines, examine memory, and observe the call stack. For systems programming — where programs manipulate raw memory and interact with the kernel — a debugger is essential.

Building for debug

GDB works best when the executable contains debug symbols. Compile with -g and, for the most intuitive debugging experience, at optimisation level -O0:

gcc -Wall -Wextra -g -O0 -o program program.c

At -O2, the compiler may reorder instructions, inline functions, and eliminate variables that are unused. Debugging still works, but the mapping between source lines and machine instructions becomes less direct.

A debug session

Consider a small program with a bug:

#include <stdio.h>

int compute(int n) {
    int sum = 0;
    for (int i = 1; i <= n; i++) {
        sum += i;
    }
    return sum;
}

int main(void) {
    int result = compute(10);
    printf("result: %d\n", result);   /* expected: 55 */
    return 0;
}

To debug it with GDB:

# Compile with debug symbols
$ gcc -Wall -Wextra -g -O0 -o sum sum.c

# Start GDB
$ gdb ./sum
GNU gdb (Ubuntu 15.0.50.20240403-0ubuntu1) 15.0.50.20240403
...
(gdb)

Inside GDB, a typical session:

(gdb) break compute               # set a breakpoint at the function
Breakpoint 1 at 0x1149: file sum.c, line 4.

(gdb) run                          # start the program
Starting program: /home/alice/sum
Breakpoint 1, compute (n=10) at sum.c:4
4           int sum = 0;

(gdb) next                         # execute the next line
5           for (int i = 1; i <= n; i++) {

(gdb) print n                      # inspect a variable
$1 = 10

(gdb) next
6               sum += i;

(gdb) print sum
$2 = 0

(gdb) print i
$3 = 1

(gdb) continue                     # resume execution
Continuing.
result: 55
[Inferior 1 (process 12345) exited normally]

(gdb) quit

Essential GDB commands

Core GDB commands
Command Effect
break <location>Set a breakpoint. Location can be a function name, a line number, or a filename:line.
run [args]Start the program (with optional arguments).
continue (or c)Resume execution until the next breakpoint.
next (or n)Execute the next source line, stepping over function calls.
step (or s)Execute the next source line, stepping into function calls.
finishRun until the current function returns.
print <expr> (or p)Evaluate and print an expression.
display <expr>Print an expression after every step.
backtrace (or bt)Show the call stack.
frame <n>Switch to a specific stack frame.
info localsShow all local variables in the current frame.
watch <expr>Break whenever the value of <expr> changes.
list (or l)Show source code around the current line.
quitExit GDB.

Debugging a crash

One of GDB's most useful features is examining a crash after the fact. Suppose a program segfaults:

$ ./crash
Segmentation fault (core dumped)

If core dumps are enabled, the kernel writes a core file containing the state of the process at the moment of the crash. GDB can open it:

$ gdb ./crash core
...
(gdb) backtrace
#0  0x00005555555551a9 in compute (n=10) at crash.c:6
#1  0x00005555555551d4 in main () at crash.c:11
(gdb) frame 0
(gdb) print i
$1 = 30
(gdb) print sum
$2 = 435

The backtrace shows the call chain — main called compute, and compute was executing line 6 when the crash occurred. Inspecting the local variables reveals the state at the moment of failure.

Enabling core dumps

Many distributions disable core dumps by default. To enable them for your shell:

ulimit -c unlimited

The core file is written to the current directory by default (or wherever /proc/sys/kernel/core_pattern specifies). Use ulimit -c 0 to disable.

4.7 Complementary tools

Beyond the four core tools, several others are useful enough to mention in this lesson.

valgrind
A tool for detecting memory errors: use of uninitialised memory, invalid reads and writes, memory leaks, and other undefined behaviour. Essential for C programs that manipulate memory manually. Run any program with valgrind --leak-check=full ./program to get a report.
strace
Prints every system call a program makes. Invaluable for understanding what a program actually does at the kernel boundary. strace ./program shows the calls in order.
ltrace
Similar to strace but for library calls. Used less often than strace in systems programming but useful for diagnosing interactions with libraries.
perf
A performance analysis tool that uses hardware performance counters. perf stat reports CPU cycles, instructions, cache misses, and other metrics for a program.
gprof
A profiler that reports where a program spends its time. Compiled with -pg, a program records a gmon.out file that gprof reads to produce a call graph and timing report.

4.8 Bringing it together: a workflow

The four tools are used together in a repeating cycle. A realistic workflow for developing a systems program looks like this:

  1. Create a repository. git init in a new directory, add a .gitignore.
  2. Write source and a Makefile. Even for a single file, a Makefile saves typing and documents the build commands.
  3. Build. make. Fix any compiler warnings — a warning ignored is a bug waiting to happen.
  4. Test the behaviour. Run the program. Compare output against what you expect.
  5. Debug if necessary. If something is wrong, use GDB (or valgrind, if the problem looks like a memory error) to identify the cause.
  6. Commit. Once the program behaves as intended, git add and git commit with a descriptive message.
  7. Repeat. Extend the program, add tests, refine.
  8. Push. At the end of each session, git push to the remote repository so the work is backed up off-machine.

This cycle is the daily experience of a systems programmer. It is worth adopting from the very first exercise, because the habits you form in small programs are the ones you will rely on for the larger assignments.

4.9 Editor choice

The tools covered in this lesson are independent of the editor you use to write source code. The three most common choices among systems programmers are:

  • vim. A modal editor available on every UNIX system. Steep learning curve but extremely fast once mastered. Includes basic syntax highlighting, code navigation, and plugin support.
  • emacs. A highly extensible editor, popular among users who prefer a single integrated environment for editing, compiling, debugging, and version control. Its built-in GDB integration is particularly good.
  • VS Code. A modern graphical editor with strong debugging, Git, and terminal integration. Runs natively on Linux and connects to remote machines via SSH — a common pattern for developing on a server from a laptop.

For this course, any of these (or any other editor you already know) will work. The important thing is that you have a comfortable way to write source, jump to a specific line, and search within a file. The tools in this lesson — gcc, make, git, gdb — are invoked from the terminal regardless of the editor.

4.10 Where to find help

Every tool in this lesson has extensive documentation:

  • Man pages. man gcc, man make, man git, man gdb. Long, but authoritative.
  • Built-in help. gcc --help, make --help, git help, gdb --help. Shorter summaries of the options.
  • Subcommand help. For Git, git help <subcommand> (or git <subcommand> --help) opens the man page for a specific subcommand. GDB has an internal help system: type help inside GDB.
  • Online documentation. The GCC, Make, Git, and GDB projects all maintain current documentation on their respective websites.

Learning to navigate this documentation is itself a skill. Being able to look up the right flag or the exact syntax quickly is more valuable than memorising every option.

05 Worked example

To make the toolchain concrete, let's build a complete workflow around a small project: a program that computes factorials, with a deliberate bug that GDB helps us find.

Developing, debugging, and committing a small program

Step 1: set up the project.

$ mkdir factorial && cd factorial
$ git init
Initialized empty Git repository in /home/alice/factorial/.git

$ cat > .gitignore << 'EOF'
*.o
factorial
core
EOF

Step 2: write the source.

# factorial.c
#include <stdio.h>
#include <stdlib.h>

long factorial(int n) {
    long result = 1;
    for (int i = 1; i <= n; i++) {
        result *= i;
    }
    return result;
}

int main(int argc, char **argv) {
    if (argc != 2) {
        fprintf(stderr, "usage: %s N\n", argv[0]);
        return 1;
    }
    int n = atoi(argv[1]);
    printf("%d! = %ld\n", n, factorial(n));
    return 0;
}

Step 3: write a Makefile.

CC      = gcc
CFLAGS  = -Wall -Wextra -g -O0 -std=c17

factorial: factorial.o
	$(CC) $(CFLAGS) -o $@ $^

factorial.o: factorial.c
	$(CC) $(CFLAGS) -c -o $@ $<

clean:
	rm -f factorial *.o

.PHONY: clean

Step 4: build and run.

$ make
gcc -Wall -Wextra -g -O0 -std=c17 -c -o factorial.o factorial.c
gcc -Wall -Wextra -g -O0 -std=c17 -o factorial factorial.o

$ ./factorial 5
5! = 120

$ ./factorial 10
10! = 3628800

$ ./factorial 25
25! = -2036746689
# something is wrong: 25! cannot be negative

Step 5: debug with GDB.

$ gdb ./factorial
(gdb) break factorial
Breakpoint 1 at 0x1149: file factorial.c, line 5.
(gdb) run 25
Starting program: /home/alice/factorial/factorial 25
Breakpoint 1, factorial (n=25) at factorial.c:5
5           long result = 1;

(gdb) break factorial.c:7 if result < 0
Breakpoint 2 at 0x116b: file factorial.c, line 7.

(gdb) continue
Continuing.
Breakpoint 2, factorial (n=25) at factorial.c:7
7               result *= i;

(gdb) print i
$1 = 13

(gdb) print result
$2 = 479001600        # 12!

(gdb) next
8           }
(gdb) print result
$3 = -1932053504     # 13! is now negative — integer overflow

(gdb) print sizeof(long)
$4 = 8

(gdb) print 479001600 * 13
$5 = -1932053504     # confirms the overflow

The bug is integer overflow. Even on a 64-bit system, 13! exceeds the range of a 32-bit int. But we declared result as long, which is 64-bit on Linux. Why did it overflow?

The answer: on 64-bit Linux, long is 64 bits — but the multiplication result *= i is done in whatever type the compiler chooses, and the intermediate is computed with the same type as result. Long is wide enough for 25! (which is about 1.5 × 1025), so the overflow must be elsewhere. Inspect more carefully:

(gdb) print result
$6 = -1932053504
(gdb) ptype result
type = long
(gdb) print result >> 32
$7 = -1
(gdb) print result >> 31
$8 = 1
# The value has wrapped around the 32-bit boundary, suggesting the
# multiplication was done in 32-bit arithmetic somewhere.

The compiler has silently narrowed the multiplication to 32 bits because both operands were treated as int in the intermediate. The fix is to make the multiplication explicit in a wider type: result = result * (long)i;.

Step 6: fix and verify.

# Edit factorial.c to use a wider intermediate type
for (int i = 1; i <= n; i++) {
    result *= (long)i;
}

$ make
gcc -Wall -Wextra -g -O0 -std=c17 -c -o factorial.o factorial.c
gcc -Wall -Wextra -g -O0 -std=c17 -o factorial factorial.o

$ ./factorial 25
25! = 15511210043330985984000000

Step 7: commit.

$ git add factorial.c Makefile .gitignore
$ git commit -m "Add factorial program; fix integer overflow on 25!"
[main (root-commit) a1b2c3d] Add factorial program; fix integer overflow on 25!
 3 files changed, 45 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 Makefile
 create mode 100644 factorial.c

$ git log --oneline
a1b2c3d Add factorial program; fix integer overflow on 25!

Step 8: extend the program.

Suppose we want to support arbitrary-size numbers. We would rewrite factorial to use a bignum library, write a test that compares against known values, and commit the new version. Later, if we decide the change was wrong, we can git revert to the previous commit. The history is preserved.

This example is deliberately small, but the workflow it illustrates is the same one you will use throughout the course. Every laboratory, every assignment, every project begins the same way: a Git repository, a Makefile, a program written with debugging in mind, and a cycle of build, test, fix, commit.

06 Interactive activity

Activity · build the toolchain and use it

Open a terminal and work through the following. By the end you should have a working development environment and a small project under version control.

  1. Verify the toolchain is installed.
    gcc --version
    make --version
    git --version
    gdb --version
    valgrind --version
    strace -V
    If any are missing, install them with the appropriate package manager (sudo apt install build-essential gdb git valgrind strace on Debian/Ubuntu).
  2. Watch the pipeline. Create a small C file and inspect each stage of the compilation:
    cat > stages.c << 'EOF'
    #include <stdio.h>
    #define GREETING "hello"
    int main(void) {
        printf("%s\n", GREETING);
        return 0;
    }
    EOF
    
    # Stage 1: preprocessor output
    gcc -E stages.c -o stages.i
    head stages.i | tail -20
    
    # Stage 2: assembly output
    gcc -S stages.c -o stages.s
    head -20 stages.s
    
    # Stage 3: object file
    gcc -c stages.c -o stages.o
    file stages.o
    
    # Stage 4: link
    gcc stages.o -o stages
    ./stages
    Inspect each intermediate file. What changed between them?
  3. Use warnings to catch a bug. Create a file with a subtle bug, compile it with and without warnings, and observe the difference:
    cat > bug.c << 'EOF'
    #include <stdio.h>
    int main(void) {
        int x = 10;
        if (x = 5) printf("five\n");
        return 0;
    }
    EOF
    
    gcc -o bug bug.c && ./bug
    # compiles silently; prints "five"
    
    gcc -Wall -o bug bug.c
    # warns about the assignment-in-condition
    Why does the first version not warn?
  4. Write a Makefile. Create a project with two source files and a Makefile that builds them into a single executable. Verify that make recompiles only the changed file when you modify one source. Verify that make clean removes the artifacts.
  5. Initialise a Git repository.
    mkdir practice && cd practice
    git init
    echo "hello" > readme.txt
    git add readme.txt
    git commit -m "Add readme"
    
    # Make a change, commit again
    echo "world" >> readme.txt
    git diff
    git add readme.txt
    git commit -m "Extend readme"
    
    # View the history
    git log --oneline
    Create a second branch, make a change, and merge it back into main. Confirm that the merge succeeds and the history reflects the change.
  6. Use GDB on a small program. Write a program that reads an integer from the command line, computes the sum 1..n, and prints it. Set a breakpoint at the function that computes the sum, step through the loop a few times, print the variables, and continue to completion. Change the program to deliberately crash (e.g., dereference a null pointer), then use GDB to examine the crash site with backtrace.
  7. Run valgrind. Write a program that allocates memory with malloc but never frees it, and another that reads uninitialised memory. Run both under valgrind:
    valgrind --leak-check=full ./leaky
    valgrind ./uninitialised
    What do the reports show?
  8. Trace with strace. Run any simple command under strace:
    strace -e trace=openat,read,write,close cat /etc/hostname
    Which files did cat open? What were the system calls that read and wrote the data?

Reflect: which of these tools did you find easiest to use? Which was most surprising? Which do you expect to use most often in the laboratories?

07 Knowledge check

Check your understanding

Question 1

What does the -g flag to GCC do, and why is it important when using GDB?

Show answer
-g tells GCC to include debugging symbols in the executable. These symbols map machine instructions back to source lines, and record the names and locations of variables and functions. Without -g, GDB can still run a program and inspect machine-level state, but it cannot show you source lines, variable names, or the logical structure of the program — you would be debugging in assembly. With -g, GDB can show you the exact source line where execution is paused, print variables by name, set breakpoints on functions, and step through the program one source line at a time.

Question 2

What is the purpose of a Makefile, and what is the advantage of using one instead of running GCC by hand?

Show answer
A Makefile describes the dependencies between source files and the outputs derived from them. Its purpose is to automate the build process: with a single command (make), the correct sequence of compilations and links is performed, and only the work that is actually needed is done. The advantage over running GCC by hand is that make tracks modification times, so when you change one source file, only that file and the outputs that depend on it are rebuilt — not the entire project. In a large project with dozens of files, this can reduce build time from minutes to seconds. A Makefile also documents the exact flags used to build the project, ensuring that everyone gets consistent results.

Question 3

What is the difference between git add, git commit, and git push?

Show answer
git add stages changes: it records the current content of the specified files in the index, marking them as part of the next commit. Nothing is permanently recorded yet; the changes are just queued. git commit takes the staged changes and creates a commit — a permanent snapshot with a hash, an author, a timestamp, and a message — stored in the local repository's history. git push sends the local commits to a remote repository, making them available to others. The three steps are sequential: you stage, then commit, then push.

Question 4

A program you compiled with -Wall -Wextra produces a warning. Is it safe to ignore the warning if the program seems to work? Why or why not?

Show answer
No. A compiler warning indicates that the compiler has noticed something suspicious: an assignment where a comparison was likely intended, an unused variable that might be a leftover from incomplete editing, a format string mismatch, or another pattern the compiler's designers flagged as a common source of bugs. The fact that the program "seems to work" on a particular input does not mean the warning is spurious — it may manifest as a bug only under different conditions, at larger data sizes, on different architectures, or in code paths not yet exercised. Warnings should be treated as bugs and addressed. Many serious production systems compile with -Werror, which makes warnings fatal.

Question 5

Describe a realistic scenario in which using GDB would be substantially faster than adding print statements to find a bug.

Show answer
A program crashes with a segmentation fault after running for several seconds, and you have no idea which part of the code is responsible. With print statements, you would need to guess where the crash is happening, insert printouts, rebuild, rerun, and repeat — and each iteration may still miss the actual failure point. With GDB, you can run the program under the debugger, let it crash, and immediately see the exact source line, the call stack, the values of local variables, and the state of any memory the crash touched. The bug is diagnosed in a single debugging session rather than dozens of rebuilds. Other scenarios where GDB excels: bugs that manifest only in memory accesses (data races in threaded code, use of freed memory), bugs that depend on timing (where adding printouts changes the timing), and bugs that require observing a variable changing across many iterations (where watch is far more practical than printouts).

08 Unit discussion

Discussion prompt

The UNIX toolchain — gcc, make, git, gdb — has been stable for decades. Each tool does one thing, reads plain text formats, composes cleanly with the others, and can be scripted from the shell. Meanwhile, modern integrated development environments offer a single, graphical interface that bundles editing, building, debugging, version control, and more, and are increasingly the default for new programmers.

Is there value in learning the classical toolchain when a modern IDE can do much of the same work with less setup? Consider contexts like cloud servers, containerised builds, continuous integration pipelines, and remote debugging. Argue for whichever position you find more persuasive.

Post your response to the Unit 2 discussion forum and reply to at least two classmates. This closes Unit 2. Supports CLO6 and CLO8.

09 Practice exercise

This ungraded exercise consolidates the lesson and prepares you for Lab 1. Aim to spend 60–90 minutes on it.

  1. Set up the course workspace. Create the following structure in your home directory:
    ~/comp3150/
    ├── labs/         # for the course laboratories
    ├── exercises/    # for practice code
    └── notes/        # for your notes
    Initialise a Git repository in ~/comp3150 with a .gitignore that excludes compiled binaries and object files.
  2. Write a multi-file C program. Create a project with three files: a header declaring a function, an implementation file defining it, and a main.c that calls it. The function should compute the nth Fibonacci number iteratively. Write a Makefile that builds the project, and verify that only the changed files are recompiled after a modification.
  3. Use warnings to catch a bug. Deliberately introduce a common C bug (for example, using = instead of ==, or passing the wrong type to printf). Compile without warnings and observe that it compiles silently. Then compile with -Wall -Wextra and observe the warning. Fix the bug.
  4. Debug with GDB. Write a program with a deliberate logic error — a loop that iterates the wrong number of times, or a variable that is initialised incorrectly. Use GDB to:
    • Set a breakpoint at the start of the problematic function.
    • Step through the loop several times.
    • Print the value of each relevant variable.
    • Identify the exact point at which the program's behaviour diverges from your expectation.
    Fix the bug and verify with another GDB session.
  5. Check for memory errors with valgrind. Write a program that:
    • Allocates memory with malloc and forgets to free it (a leak).
    • Reads from memory it did not initialise (an uninitialised read).
    • Writes one byte past the end of an allocated buffer (a heap overflow).
    Run each buggy version under valgrind --leak-check=full and observe the reports.
  6. Trace with strace. Write a small program that opens a file, reads from it, and prints the contents. Run it under strace -e trace=openat,read,write,close. Identify the exact system calls the program made and explain what each one did.
  7. Commit your work. Commit every file you created in this exercise to the repository. Write clear commit messages describing what each commit adds. Use git log --oneline and git log --stat to review the history.
  8. Reflect. In your own words, describe what each tool in this lesson contributes to the development workflow, and identify one situation from your own experience where each tool would have saved you time.

10 Summary

  • The standard Linux systems-programming toolchain consists of GCC (compiler), Make (build automation), Git (version control), and GDB (debugger), plus complementary tools such as valgrind, strace, ltrace, and perf.
  • GCC compiles a source file in four stages: preprocessing, compilation, assembly, and linking. Each stage produces an intermediate file that can be inspected.
  • Important GCC flags for this course: -Wall -Wextra (warnings), -g (debug symbols), -O0 or -O2 (optimisation), -pthread (threading), -lm (math library).
  • A Makefile describes the dependencies between sources and outputs; make rebuilds only what is necessary. Recipe lines must be indented with a tab, not spaces.
  • Git records the history of a project. The basic workflow is stage (git add), commit (git commit), and optionally push (git push) to a remote.
  • GDB runs a program under your control, allowing breakpoints, stepping, variable inspection, stack inspection, and post-crash analysis. Compile with -g so GDB can map machine state back to source lines.
  • valgrind detects memory errors and leaks, strace shows system calls, ltrace shows library calls, and perf reports performance counters.
  • The tools compose into a workflow: write source, build with make, test, debug with GDB, commit with Git, repeat. This is the daily workflow of a systems programmer and the foundation for every laboratory in the course.

Unit 2 complete

This closes the practical foundation of Module 1. You now have a working Linux environment, the shell skills to use it, the identity and permission model to operate within it, and a development toolchain that scales from a one-line program to a full project. Module 2 begins with the process abstraction — the first of the operating-system concepts that the rest of the course is built on.

11 References

  • Love, R. Linux System Programming, 2nd ed., Chapter 1 and Appendix B. O'Reilly, 2013.
  • Stallman, R. M., & the GCC Developer Community. Using the GNU Compiler Collection (GCC). gcc.gnu.org/onlinedocs/.
  • Chacon, S., & Straub, B. Pro Git, 2nd ed. Apress, 2014. Freely available at git-scm.com/book.
  • Stallman, R. M., Pesch, R., Shebs, S., & the GDB Developer Community. Debugging with GDB. sourceware.org/gdb/current/onlinedocs/gdb/.
  • Stallman, R. M., & McGrath, R. GNU Make: A Program for Directing Compilation. www.gnu.org/software/make/ manual/.
  • Nemeth, E., Snyder, G., Whaley, B., & Mackin, D. UNIX and Linux System Administration Handbook, 5th ed., Chapter 4 (process control, briefly). Addison-Wesley, 2017.
👤