Skip to main content
C · lesson 2 of 25
C beginner 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:

  1. Download the installer from msys2.org
  2. Run the installer and open the MSYS2 terminal
  3. Install the toolchain:
pacman -S mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-gdb make
  1. Add C:\msys64\ucrt64\bin to your Windows PATH
  2. 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.

  1. Install VS Code
  2. Install the C/C++ extension by Microsoft (ms-vscode.cpptools)
  3. 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:

FlagPurpose
-WallEnable common warnings
-WextraEnable extra warnings
-WpedanticStrict standard conformance
-std=c11Use C11 standard
-gInclude debug symbols
-O2Optimization level 2 (for release)
-fsanitize=addressEnable 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

  1. Let the flags find the bugs. Save this as average.c. Compile it with plain gcc average.c -o average and 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.

  1. Split it across files. Move a square function into utils.c with a declaration in utils.h, and call it from main.c. First try compiling main.c on 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.

  1. Let make decide what to rebuild. Adapt the Makefile template so SRCS lists main.c utils.c. Run make twice, then touch utils.c and 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).

  1. 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.

  1. Same bug, no recompile. Build the broken sum.c from 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.

Frequently Asked Questions

Which compiler should I use — GCC, Clang, or MSVC?
GCC is the most common choice on Linux. Clang produces better error messages and is default on macOS. MSVC is Windows-native. All three support modern C standards. GCC is recommended for learning.
Do I need an IDE or is a text editor enough?
A text editor plus the terminal is perfectly fine and actually builds better habits. VS Code with the C/C++ extension gives you IntelliSense and debugging without the overhead of a full IDE.
What is a Makefile and do I need one?
A Makefile automates compilation. For single-file programs you don't need one, but once you have multiple source files a Makefile saves significant time.