Welcome to the first lesson of this series on learning C from scratch. C is one of the foundational programming languages: it teaches you how a computer really manages memory, how to get the most out of hardware, and it builds habits of mind that carry over to every language you learn afterwards. Before you can write a single line of it, though, you need a working toolchain. In this first lesson we will set up a compiler and an editor on macOS, Linux and Windows.
1. How does C source code become a running program?
Unlike interpreted languages such as JavaScript or Python β which run straight through an interpreter
engine β C is a compiled language. When you write C source code (saved as a
.c file), the computer cannot act on those English-looking words directly. A
compiler is the program that translates your text, through several intermediate
stages, into the binary executable a CPU understands.
That translation does not happen in one step. It runs through four stages in sequence, each taking the output of the previous one and producing a new intermediate file:
[hello.c (Source Code)]
β
βΌ (Stage 1: Preprocessor)
[hello.i (Preprocessed Code)]
β
βΌ (Stage 2: Compiler - translates to Assembly)
[hello.s (Assembly Code)]
β
βΌ (Stage 3: Assembler - turns Assembly into raw machine code)
[hello.o (Object File)] ββββββββββ
βΌ (Stage 4: Linker - links the libraries)
[hello (Executable Binary)]
Reading that diagram from top to bottom, here is what each of the four stages does:
-
Preprocessing β handles every line beginning with
#. Think of it as the "paste" step: it copies the contents of other files into yours before any real translation happens. The result is still C text, just a great deal longer. - Compilation β translates that C text into Assembly: a language still readable by a human, but already shaped around how the CPU works, where each line corresponds closely to a single instruction of the chip. This is the "translate into the machine's language" step, and the translation differs from one kind of chip to another.
- Assembly β converts each Assembly instruction into the binary numbers the CPU executes directly. The result is called an object file: real machine code, but incomplete, because it contains calls to functions living elsewhere whose addresses it does not yet know.
-
Linking β joins your object file with machine code the system already ships, and
fills in those missing addresses.
printf, for instance, is not something you wrote: it lives in the operating system's standard C library, and this is the step where your program gets wired up to it. Only after this step do you have a file you can run.
hello.c to work on. The next four sections are
that preparation.
2. Installing the compiler
On macOS
The default compiler on macOS is Clang (wrapped behind the gcc command
for compatibility). You do not need the full Xcode download of several tens of gigabytes β the Command
Line Tools package is enough:
-
Open the Terminal app (press
Cmd + Space, type "Terminal", hit Enter). - Type the following command and press Enter:
xcode-select --install
A confirmation dialog will appear asking you to agree to the installation. Click Install and wait for the download to finish.
On Linux
Most Linux distributions use GCC (GNU Compiler Collection). To install the basic development toolchain β GCC, Make and the system libraries you need:
On Ubuntu/Debian/Mint, open a Terminal and run:
sudo apt update
sudo apt install build-essential
On Fedora/CentOS/RHEL:
sudo dnf groupinstall "Development Tools"
On Windows
C runs on Windows perfectly well, but the native Windows toolchain differs from Unix in many small ways, and this whole series uses Unix commands throughout. The cleanest way to avoid translating every command is to enable WSL (Windows Subsystem for Linux) β a Linux system running inside Windows that shares your files. Open PowerShell as Administrator and run:
wsl --install
Restart your machine, open the Ubuntu app that was just installed, then run the same
apt command from the Linux section above. From that point on every command in this series
behaves exactly as it does on Linux. In VS Code, also install Microsoft's
WSL extension so you can open folders inside Ubuntu directly.
\ instead of /,
make is not included, and gdb has to be installed separately. Going the
WSL route means every command, every path and every screenshot in this series matches what you see
on your own machine.
Checking that the compiler installed correctly
To make sure your machine now has a compiler, run this version check in the Terminal:
gcc --version
If the screen prints version information for gcc or Apple clang,
congratulations β you are ready to go.
3. Installing a code editor (VS Code)
We will use Visual Studio Code (VS Code) β a lightweight, free and extremely popular editor from Microsoft.
- Go to code.visualstudio.com and download the installer for your macOS or Linux machine.
- Install the application and open VS Code.
-
Install the extension that adds C language support:
-
Click the Extensions icon in the left sidebar (or press
Cmd + Shift + X). - Search for C/C++ Extension Pack (by Microsoft).
- Click Install. This extension gives you code completion (IntelliSense), automatic formatting and debugging support.
-
Click the Extensions icon in the left sidebar (or press
4. Writing and running your first C program
Now for the legendary program: printing the words "Hello, World!".
- Open an empty folder in VS Code (File β Open Folder).
- Create a new file called
hello.c. - Type the following source code into the file:
#include <stdio.h>
int main() {
printf("Hello, World!\n");
return 0;
}
These six lines are the smallest useful C program there is, and every line has a job:
-
#include <stdio.h>β the line starts with#, so it is an instruction to the preprocessor from section 1: copy the contents ofstdio.hin here. That file is whereprintfis declared. Without this line the compiler has no idea whatprintfis. -
int main() { ... }β the functionmainis the starting point: when you run the program, the operating system always calls into it first. Every C program must have exactly one function calledmain. The curly braces{ }wrap its body β the work to be done. -
printf("Hello, World!\n");β prints a line of text to the screen. The\nat the end is the newline character; drop it and your Terminal prompt will appear glued to the wordWorld!. The semicolon at the end is mandatory β it is how C knows one statement has finished. -
return 0;β tells the operating system the program finished normally. By Unix convention, 0 means success and anything else means an error. The wordintin front ofmainis precisely what declares "this function returns an integer", andreturn 0;is that integer.
Save the file (Cmd + S).
Compiling and running from the Terminal
Open the Terminal built into VS Code with the shortcut Ctrl + ` (backtick), or choose
Terminal β New Terminal from the menu bar.
1. Compile hello.c into an executable called hello:
gcc hello.c -o hello
2. Run the program you just compiled:
./hello
You will see Hello, World! printed in your Terminal. You have officially stepped into the world of C programming.
π₯ Download the sample source: hello.c
5. Walking through the four compilation stages yourself
Your machine now has a compiler and your working folder has a hello.c β everything needed
to open up each of the stages described in section 1 and look inside. Run the commands below one at a
time in the Terminal, from the folder containing hello.c. Each command produces a new
file, and you should open that file in VS Code after every step.
Stage 1: Preprocessing
The preprocessor scans your source and carries out every directive beginning with # (such
as #include, #define, #ifdef). Constants defined as macros get
substituted in place, and header files are copied in wholesale. You can dump the preprocessed result
with:
gcc -E hello.c -o hello.i
Open the resulting hello.i and you will find it runs to hundreds of lines, because it now
contains all of the function declarations from stdio.h pasted in at the top.
Stage 2: Compilation
The compiler takes the preprocessed source (hello.i), parses it, and converts the
constructs of the C language into Assembly for the corresponding CPU architecture (such as x86_64 or
ARM). To emit the Assembly file, run:
gcc -S hello.i -o hello.s
Opening hello.s, you will see basic assembly instructions such as pushq,
movq, call and ret.
Stage 3: Assembly
The assembler converts the textual Assembly (hello.s) into raw binary machine code. The
result is an object file. It holds machine code for your CPU but cannot run on its own, because the
addresses of the external library functions have not been resolved yet. Emit the object file with:
gcc -c hello.s -o hello.o
Stage 4: Linking
The linker joins your object file (hello.o) with the pre-compiled binaries of the system
libraries (such as printf, which lives in the standard C library libc.a or
libc.so). It resolves the memory addresses and merges everything into the final
executable binary:
gcc hello.o -o hello
Those four commands did exactly what the single gcc hello.c -o hello in section 4 did in
one go. The point worth remembering: once your programs grow and start reporting errors, the error
message will tell you which stage it came from β a syntax error belongs to stage 2, while
undefined reference belongs to stage 4. Knowing which stage an error comes from is
already half of fixing it.
6. Fixing common compilation errors
-
Error:
command not found: gccβ the operating system cannot find the compiler. Make sure you successfully installed the Xcode Command Line Tools (macOS) or thebuild-essentialpackage (Linux). -
Error:
implicit declaration of function 'printf'β this happens when you forget the#include <stdio.h>directive at the top of the file. The compiler has no way of knowing whereprintfcomes from.
A small challenge for you
Try changing the text inside printf("...") to your own name, or anything else you like.
Save, recompile and run it again to see the result.
7. Compiler flags worth knowing
When you run the plain gcc hello.c -o hello, the compiler will build successfully and
give you almost no warnings. That does not mean your code is flawless β GCC hides a great many useful
warnings by default. In professional practice, developers always turn on flags that force the compiler
to analyse the source more strictly, catching latent bugs at compile time instead of at run time.
The table below lists the most important flags to have at your fingertips:
| Flag | What it does |
|---|---|
-Wall |
Enables most common warnings (unused variables, missing return, bad type comparisonsβ¦) |
-Wextra |
Enables additional warnings that -Wall does not cover |
-Werror |
Turns every warning into an error β the compiler stops if there is any warning |
-g |
Embeds debug information into the executable, for GDB/LLDB and Valgrind |
-O0 |
No optimisation (the default) β easiest to debug, since machine code maps directly to source |
-O1, -O2 |
Moderate to high optimisation β -O2 is the common choice for release builds
|
-O3 |
The most aggressive optimisation β can grow the file size and occasionally breaks non-standard code |
-std=c11 / -std=c17 |
Selects which C standard to compile against (C11 or C17) |
-pedantic |
Strict ISO C conformance, warning about every non-standard extension |
The recommended command while developing:
gcc -Wall -Wextra -g -std=c17 hello.c -o hello
The recommended command for a release build:
gcc -Wall -Wextra -Werror -O2 -std=c17 hello.c -o hello
8. Automating the build with a Makefile
As a project grows to several source files, retyping those long gcc commands every time
becomes tedious and error-prone. Make is the classic tool that solves this. It lets
you define the whole build process once, after which you only type make. Make also
supports incremental builds β recompiling only the files that changed, which saves a great deal of
time.
Here is a complete sample Makefile for a basic C project:
# Configuration variables
CC = gcc
CFLAGS = -Wall -Wextra -g -std=c17
TARGET = hello
SRCS = hello.c
OBJS = $(SRCS:.c=.o)
# Default rule: build the whole project
all: $(TARGET)
# Link the object files into the executable
$(TARGET): $(OBJS)
$(CC) $(CFLAGS) -o $(TARGET) $(OBJS)
# Compile each .c file into a .o file
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
# Clean up build artefacts
clean:
rm -f $(OBJS) $(TARGET)
# Mark targets that are not real files
.PHONY: all clean
Using it is straightforward. Open a Terminal in the folder containing the Makefile and run:
# Build the project
make
# Run the program
./hello
# Remove build output to rebuild from scratch
make clean
Note: for larger projects with dozens or hundreds of source files, look into CMake β a more powerful cross-platform build system that can generate the right Makefile for each operating system automatically.
9. Basic debugging with GDB (GNU Debugger)
When a program produces the wrong result or crashes, scattering printf calls around to
inspect variables is slow and clumsy. GDB (GNU Debugger) is the professional tool for
this: it lets you pause the program at any line, inspect variable values, and step through one
instruction at a time to find exactly where things go wrong.
GDB is installed along with build-essential (Linux) or the Xcode Command Line Tools
(macOS). On macOS you can also use LLDB (lldb ./program) β Apple's
default debugger, with a very similar command set.
The hello.c from section 4 has no variables and runs a single statement, so there is
nothing to observe. Create another file, debug_demo.c, with a variable that changes over
time β that is the kind of thing GDB lets you see:
#include <stdio.h>
int main() {
int total = 0;
for (int i = 1; i <= 5; i++) {
total = total + i;
}
printf("Total = %d\n", total);
return 0;
}
Run normally, the program prints Total = 15. What we want to watch is total
growing on each pass through the loop β precisely what you cannot see without a debugger.
Step 1: Compile with the -g flag (embed debug information) and
-O0 (disable optimisation):
gcc -g -O0 debug_demo.c -o debug_demo
Step 2: Start GDB with the executable:
gdb ./debug_demo
Step 3: Use the basic GDB commands. The table below collects the essential ones:
| GDB command | What it does |
|---|---|
run |
Runs the program from the start |
break main |
Sets a breakpoint at the main function |
break 15 |
Sets a breakpoint at line 15 |
next |
Executes the next line (stepping over any function calls) |
step |
Executes the next line (stepping into a function call if there is one) |
print x |
Prints the current value of the variable x |
backtrace |
Shows the function call stack β invaluable when the program crashes |
continue |
Carries on until the next breakpoint or the end of the program |
quit |
Exits GDB |
A basic debugging session looks like this:
$ gdb ./debug_demo
(gdb) break main # Dung lai ngay khi vao main
(gdb) run # Chay β chuong trinh dung o dong dau main
(gdb) next # Chay dong "int total = 0;"
(gdb) print total # -> $1 = 0
(gdb) next # Chay het mot vong lap dau tien
(gdb) print total # -> $2 = 1 (0 + 1)
(gdb) next
(gdb) print total # -> $3 = 3 (1 + 2)
(gdb) continue # Chay not phan con lai
(gdb) quit # Thoat GDB
The sequence 0 β 1 β 3 is what makes this worth the trouble: you are watching
total change after each pass of the loop, without adding a single
printf that you would later have to delete. The $1 and
$2 markers are how GDB numbers the results it has printed, so you can refer back to them
later.
gdb, and an installed copy needs to be code-signed before it will
run. Use lldb ./debug_demo instead β the commands are near-equivalents:
b main, run, next, frame variable total,
continue, quit.
hello.c into an executable named
hello in the Terminal?
Comments