C · lesson 2 of 25
Setting Up a C Development Environment
Install GCC, configure VS Code, and learn to compile and run C programs with Makefiles.
By SkillByExample · Updated
Installing GCC
Before you can write and run C programs, you need a compiler — the tool that translates your C source code into an executable binary. GCC (GNU Compiler Collection) is the most widely used C compiler and the best choice for learning.
Linux (Ubuntu / Debian)
sudo apt update
sudo apt install build-essential gdb
gcc --version # verify installation
build-essential installs GCC, G++, make, and the standard headers. gdb is the GNU debugger — you’ll want it for tracking down bugs.
macOS
macOS ships with Clang aliased to gcc. To get real GCC via Homebrew:
xcode-select --install # installs Apple's Clang (sufficient for learning)
brew install gcc # optional: GNU GCC
gcc --version
For most purposes, Apple’s Clang works identically to GCC for C code.
Windows
The easiest route is MSYS2, which gives you a real GCC toolchain inside a Unix-like shell:
- Download the installer from msys2.org
- Run the installer and open the MSYS2 terminal
- Install the toolchain:
pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-gdb make
- Add
C:\msys64\ucrt64\binto your Windows PATH - Open a new terminal and run
gcc --version
Alternatively, install WSL2 (Windows Subsystem for Linux) and follow the Linux instructions — this gives you a full Linux environment.
VS Code Setup
VS Code is a lightweight editor with excellent C support through extensions. It gives you syntax highlighting, IntelliSense autocompletion, and an integrated debugger without the weight of a full IDE.
- Install VS Code
- Install the C/C++ extension by Microsoft (ms-vscode.cpptools)
- Optionally install C/C++ Extension Pack for additional tools
Configure IntelliSense
Create .vscode/c_cpp_properties.json in your project folder so IntelliSense knows which standard you’re targeting:
{
"configurations": [
{
"name": "Linux",
"includePath": ["${workspaceFolder}/**"],
"defines": [],
"compilerPath": "/usr/bin/gcc",
"cStandard": "c11",
"intelliSenseMode": "linux-gcc-x64"
}
],
"version": 4
}
Configure Build Task
Create .vscode/tasks.json so you can build with Ctrl+Shift+B instead of typing the compiler command every time:
{
"version": "2.0.0",
"tasks": [
{
"label": "Build C file",
"type": "shell",
"command": "gcc",
"args": [
"-Wall", "-Wextra", "-std=c11", "-g",
"${file}", "-o", "${fileDirname}/${fileBasenameNoExtension}"
],
"group": { "kind": "build", "isDefault": true },
"problemMatcher": ["$gcc"]
}
]
}
Press Ctrl+Shift+B to build the currently open file.
Compiling and Running
Understanding the compiler flags is important — they control what warnings you see and what standard features are available. Good flags catch bugs early, before they become hard-to-find runtime errors.
Single File
# Basic compile
gcc hello.c -o hello
# Recommended flags for development
gcc -Wall -Wextra -Wpedantic -std=c11 -g -o hello hello.c
# Run
./hello
Key compiler flags:
| Flag | Purpose |
|---|---|
-Wall | Enable common warnings |
-Wextra | Enable extra warnings |
-Wpedantic | Strict standard conformance |
-std=c11 | Use C11 standard |
-g | Include debug symbols |
-O2 | Optimization level 2 (for release) |
-fsanitize=address | Enable AddressSanitizer (detects memory errors) |
Multiple Files
When your project grows beyond one file, you compile each source file to an object file (.o) and then link them together. This also makes incremental builds faster — only changed files need recompilation.
# Compile each source file to an object file, then link
gcc -Wall -std=c11 -c main.c -o main.o
gcc -Wall -std=c11 -c utils.c -o utils.o
gcc main.o utils.o -o myprogram
# Or in one step
gcc -Wall -std=c11 main.c utils.c -o myprogram
Makefile Basics
A Makefile automates compilation and tracks which files need to be rebuilt. Without it, you have to remember and type all the compiler commands yourself, and rebuild everything even when only one file changed. Here is a practical template for a multi-file C project:
# Compiler and flags
CC = gcc
CFLAGS = -Wall -Wextra -std=c11 -g
LDFLAGS =
# Project files
TARGET = myprogram
SRCS = main.c utils.c parser.c
OBJS = $(SRCS:.c=.o)
# Default target
all: $(TARGET)
# Link object files into the executable
$(TARGET): $(OBJS)
$(CC) $(OBJS) $(LDFLAGS) -o $(TARGET)
# Compile each .c file into a .o file
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# Remove build artifacts
clean:
rm -f $(OBJS) $(TARGET)
# Rebuild from scratch
rebuild: clean all
# Declare non-file targets
.PHONY: all clean rebuild
Run it with:
make # builds the project
make clean # removes object files and executable
make rebuild # clean then build
Important: Makefile recipes must be indented with a tab, not spaces. This is the most common Makefile error for beginners.
Using the Debugger (GDB)
Compile with -g to include debug symbols — this embeds information about your source code into the binary so the debugger can show you line numbers and variable names. Without it, debugging is nearly impossible.
gcc -Wall -std=c11 -g -o myprogram main.c
gdb ./myprogram
Useful GDB commands:
(gdb) break main # set breakpoint at main
(gdb) run # start the program
(gdb) next # step over (execute one line)
(gdb) step # step into (enter function calls)
(gdb) print x # print value of variable x
(gdb) backtrace # show call stack
(gdb) continue # continue until next breakpoint
(gdb) quit # exit GDB
VS Code’s debugger provides a GUI over GDB. Create .vscode/launch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug C Program",
"type": "cppdbg",
"request": "launch",
"program": "${fileDirname}/${fileBasenameNoExtension}",
"args": [],
"stopAtEntry": false,
"cwd": "${workspaceFolder}",
"MIMode": "gdb",
"miDebuggerPath": "/usr/bin/gdb",
"preLaunchTask": "Build C file"
}
]
}
Press F5 to build and debug.
Checking Memory Errors
C gives you direct memory access, which means bugs like buffer overflows and use-after-free can be silent — your program appears to work correctly until it suddenly crashes or produces wrong results. These tools make memory errors visible immediately, which is why you should use them from day one.
AddressSanitizer (built into GCC/Clang):
gcc -Wall -std=c11 -fsanitize=address -fsanitize=undefined -g -o myprogram main.c
./myprogram
Valgrind (Linux only):
sudo apt install valgrind
valgrind --leak-check=full ./myprogram
Both tools will catch use-after-free bugs, buffer overflows, and memory leaks that would otherwise cause hard-to-diagnose crashes. Make them part of your normal workflow from day one.
Practice
-
Let the flags find the bugs. Save this as
average.c. Compile it with plaingcc average.c -o averageand run it, then compile it again with the recommended development flags. Fix everything the warnings point at.#include <stdio.h> int main(void) { int scores[] = {7, 8, 9}; int total; for (int i = 0; i < 3; i++) total += scores[i]; double avg = total / 3.0; printf("average = %d\n", avg); return 0; }
Solution
#include <stdio.h>
int main(void) {
int scores[] = {7, 8, 9};
int total = 0;
for (int i = 0; i < 3; i++)
total += scores[i];
double avg = total / 3.0;
printf("average = %.2f\n", avg);
return 0;
}
$ gcc average.c -o average && ./average
average = -128532360
$ gcc -Wall -Wextra -Wpedantic -std=c11 -g -o average average.c
average.c: In function 'main':
average.c:9:24: warning: format '%d' expects argument of type 'int', but argument 2 has type 'double' [-Wformat=]
9 | printf("average = %d\n", avg);
| ~^ ~~~
| | |
| int double
| %f
$ gcc -Wall -Wextra -std=c11 -O2 -o average average.c
average.c: In function 'main':
average.c:9:24: warning: format '%d' expects argument of type 'int', but argument 2 has type 'double' [-Wformat=]
9 | printf("average = %d\n", avg);
| ~^ ~~~
| | |
| int double
| %f
average.c:7:15: warning: 'total' is used uninitialized [-Wuninitialized]
7 | total += scores[i];
| ~~~~~~^~~~~~~~~~~~
average.c:5:9: note: 'total' was declared here
5 | int total;
| ^~~~~
$ gcc -Wall -Wextra -Wpedantic -std=c11 -g -o average average_fixed.c && ./average
average = 8.00
Without flags, GCC compiled the program silently and it printed garbage. -Wall caught the %d/double
mismatch, but the uninitialized total only appeared once -O2 was added, because GCC needs the
optimiser’s data-flow analysis to notice it. It is worth building with both -g and -O2 now and then.
- Split it across files. Move a
squarefunction intoutils.cwith a declaration inutils.h, and call it frommain.c. First try compilingmain.con its own, then build the program the two-step way, with one object file per source.
Solution
/* utils.h */
#ifndef UTILS_H
#define UTILS_H
int square(int n);
#endif
/* utils.c */
#include "utils.h"
int square(int n) {
return n * n;
}
/* main.c */
#include <stdio.h>
#include "utils.h"
int main(void) {
for (int i = 1; i <= 4; i++)
printf("%d squared is %d\n", i, square(i));
return 0;
}
$ gcc -Wall -std=c11 main.c -o myprogram
/usr/bin/ld: /tmp/ccDgjfqm.o: in function `main':
main.c:(.text+0x17): undefined reference to `square'
collect2: error: ld returned 1 exit status
$ gcc -Wall -std=c11 -c main.c -o main.o
$ gcc -Wall -std=c11 -c utils.c -o utils.o
$ gcc main.o utils.o -o myprogram && ./myprogram
1 squared is 1
2 squared is 4
3 squared is 9
4 squared is 16
The first error comes from ld, the linker, not from the compiler. main.c compiled fine because
utils.h declared square, but no object file supplied its body. Linking main.o with utils.o provides
the definition.
- Let make decide what to rebuild. Adapt the Makefile template so
SRCSlistsmain.c utils.c. Runmaketwice, thentouch utils.cand run it again. Which files does it recompile each time?
Solution
CC = gcc
CFLAGS = -Wall -Wextra -std=c11 -g
LDFLAGS =
TARGET = myprogram
SRCS = main.c utils.c
OBJS = $(SRCS:.c=.o)
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(OBJS) $(LDFLAGS) -o $(TARGET)
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) $(TARGET)
.PHONY: all clean
$ make
gcc -Wall -Wextra -std=c11 -g -c main.c -o main.o
gcc -Wall -Wextra -std=c11 -g -c utils.c -o utils.o
gcc main.o utils.o -o myprogram
$ make
make: Nothing to be done for 'all'.
$ touch utils.c
$ make
gcc -Wall -Wextra -std=c11 -g -c utils.c -o utils.o
gcc main.o utils.o -o myprogram
$ ./myprogram
1 squared is 1
2 squared is 4
3 squared is 9
4 squared is 16
make compares file timestamps. After touch, utils.c is newer than utils.o, so only that object is
recompiled and then relinked, while main.o is reused as it is. The double space in the link line comes
from the empty $(LDFLAGS).
-
Catch an off-by-one with AddressSanitizer. This program reads one element past the end of its heap array. Run it normally, then build it with the sanitizer flags from this lesson, find the line it reports, and fix it.
#include <stdio.h> #include <stdlib.h> int main(void) { int *values = malloc(5 * sizeof(int)); if (!values) return 1; for (int i = 0; i < 5; i++) values[i] = i + 1; int sum = 0; for (int i = 0; i <= 5; i++) /* off by one: reads values[5] */ sum += values[i]; printf("sum = %d\n", sum); free(values); return 0; }
Solution
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *values = malloc(5 * sizeof(int));
if (!values) return 1;
for (int i = 0; i < 5; i++)
values[i] = i + 1;
int sum = 0;
for (int i = 0; i < 5; i++)
sum += values[i];
printf("sum = %d\n", sum);
free(values);
return 0;
}
$ gcc -Wall -std=c11 -g -o sum sum.c && ./sum
sum = 15
$ gcc -Wall -std=c11 -fsanitize=address -fsanitize=undefined -g -o sum sum.c && ./sum 2>&1 | grep SUMMARY
SUMMARY: AddressSanitizer: heap-buffer-overflow /src/sum.c:12 in main
$ gcc -Wall -std=c11 -fsanitize=address -fsanitize=undefined -g -o sum sum_fixed.c && ./sum
sum = 15
The unsanitized build printed the right answer only because the 4 bytes after the array happened to be zero.
That luck is what makes this bug dangerous. ASan stops at the first out-of-bounds read and points at line
12. The full report, trimmed here with grep, also shows the malloc on line 5 that owns the 20-byte
block.
- Same bug, no recompile. Build the broken
sum.cfrom task 4 without sanitizers and run it under Valgrind instead. What does Valgrind report that the plain run didn’t?
Solution
$ gcc -Wall -std=c11 -g -o sum sum.c && valgrind -q ./sum
==26== Invalid read of size 4
==26== at 0x4011C0: main (sum.c:12)
==26== Address 0x4a5a054 is 0 bytes after a block of size 20 alloc'd
==26== at 0x4844818: malloc (vg_replace_malloc.c:446)
==26== by 0x401157: main (sum.c:5)
==26==
sum = 15
Valgrind finds the same read, at the same line, inside the same 20-byte block. Unlike ASan, it keeps
running afterwards, which is why sum = 15 still prints. Valgrind needs only -g and an ordinary binary,
but it runs much slower. Don’t combine the two: a binary built with -fsanitize=address refuses to start
under Valgrind.