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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
ℹ️ No need to memorise this yet
At this point you only need the gist of those four names. In section 5 you will run each stage yourself and open every intermediate file to see what is inside β€” by then your machine will have a compiler installed and a 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:
Terminal
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:

Terminal (Ubuntu/Debian)
sudo apt update
sudo apt install build-essential

On Fedora/CentOS/RHEL:

Terminal (Fedora)
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:

PowerShell (as Administrator)
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.

ℹ️ Why not MinGW or Visual Studio directly?
Both compile C and there is nothing wrong with either. But they differ from Unix in exactly the details this series touches: paths use \ 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:

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.

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:
hello.c
#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 of stdio.h in here. That file is where printf is declared. Without this line the compiler has no idea what printf is.
  • int main() { ... } β€” the function main is the starting point: when you run the program, the operating system always calls into it first. Every C program must have exactly one function called main. The curly braces { } wrap its body β€” the work to be done.
  • printf("Hello, World!\n"); β€” prints a line of text to the screen. The \n at the end is the newline character; drop it and your Terminal prompt will appear glued to the word World!. 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 word int in front of main is precisely what declares "this function returns an integer", and return 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:

Terminal
gcc hello.c -o hello

2. Run the program you just compiled:

Terminal
./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 the build-essential package (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 where printf comes 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:

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

The recommended command for a release build:

Terminal
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:

Makefile
# 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:

Terminal
# 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:

debug_demo.c
#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):

Terminal
gcc -g -O0 debug_demo.c -o debug_demo

Step 2: Start GDB with the executable:

Terminal
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:

A sample GDB session
$ 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.

πŸ’‘ On macOS, use LLDB
macOS does not ship 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.
πŸ“ Check your understanding β€” Lesson 1
Which of the following commands compiles hello.c into an executable named hello in the Terminal?

Related lessons in this series

Lesson 2: C syntax, variables, data types & I/O Back to the C series roadmap (Vietnamese)