Module 1 · Unit 2 · Lesson 5 of 5
Development Environment
The compilers, build tools, version control, and debuggers that every subsequent laboratory in this course assumes. By the end of this lesson you will have a working systems-programming environment and a workflow that scales from a single-file exercise to a multi-file project.
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:
| 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.
.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:
| 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.distordistclean— 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
.gitsubdirectory 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(formerlymaster). 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
| 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. |
finish | Run 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 locals | Show 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. |
quit | Exit 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 ./programto get a report. - strace
-
Prints every system call a program makes. Invaluable for
understanding what a program actually does at the kernel
boundary.
strace ./programshows 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 statreports 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 agmon.outfile 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:
-
Create a repository.
git initin a new directory, add a.gitignore. - Write source and a Makefile. Even for a single file, a Makefile saves typing and documents the build commands.
-
Build.
make. Fix any compiler warnings — a warning ignored is a bug waiting to happen. - Test the behaviour. Run the program. Compare output against what you expect.
- Debug if necessary. If something is wrong, use GDB (or valgrind, if the problem looks like a memory error) to identify the cause.
-
Commit. Once the program behaves as
intended,
git addandgit commitwith a descriptive message. - Repeat. Extend the program, add tests, refine.
-
Push. At the end of each session,
git pushto 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>(orgit <subcommand> --help) opens the man page for a specific subcommand. GDB has an internal help system: typehelpinside 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.
-
Verify the toolchain is installed.
If any are missing, install them with the appropriate package manager (gcc --version make --version git --version gdb --version valgrind --version strace -Vsudo apt install build-essential gdb git valgrind straceon Debian/Ubuntu). -
Watch the pipeline.
Create a small C file and inspect each stage of the
compilation:
Inspect each intermediate file. What changed between them?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 -
Use warnings to catch a bug.
Create a file with a subtle bug, compile it with and
without warnings, and observe the difference:
Why does the first version not warn?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 -
Write a Makefile.
Create a project with two source files and a Makefile that
builds them into a single executable. Verify that
makerecompiles only the changed file when you modify one source. Verify thatmake cleanremoves the artifacts. -
Initialise a Git repository.
Create a second branch, make a change, and merge it back into main. Confirm that the merge succeeds and the history reflects the change.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 -
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. -
Run valgrind.
Write a program that allocates memory with
mallocbut never frees it, and another that reads uninitialised memory. Run both under valgrind:
What do the reports show?valgrind --leak-check=full ./leaky valgrind ./uninitialised -
Trace with strace.
Run any simple command under strace:
Which files didstrace -e trace=openat,read,write,close cat /etc/hostnamecatopen? 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
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
-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
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.
-
Set up the course workspace.
Create the following structure in your home directory:
Initialise a Git repository in~/comp3150/ ├── labs/ # for the course laboratories ├── exercises/ # for practice code └── notes/ # for your notes~/comp3150with a.gitignorethat excludes compiled binaries and object files. -
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.cthat 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. -
Use warnings to catch a bug. Deliberately
introduce a common C bug (for example, using
=instead of==, or passing the wrong type toprintf). Compile without warnings and observe that it compiles silently. Then compile with-Wall -Wextraand observe the warning. Fix the bug. -
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.
-
Check for memory errors with valgrind.
Write a program that:
- Allocates memory with
mallocand 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).
valgrind --leak-check=fulland observe the reports. - Allocates memory with
-
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. -
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 --onelineandgit log --statto review the history. - 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),-O0or-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
-gso 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.