Your first program

A C program starts as a text file with the extension .c. This one prints a single line:

#include <stdio.h>

int main(void)
{
    printf("Hello from Kestrel Energy!\n");
    return 0;
}
Hello from Kestrel Energy!
  • #include <stdio.h> pulls in the declarations of the standard input and output functions, so the compiler knows what printf is and which arguments it takes.
  • int main(void) is where the program starts. It takes no arguments here (void) and returns an int.
  • \n is a newline. printf does not add one by itself, unlike Python's print.
  • return 0; ends the program with exit status 0: success.

To turn the file into a program and run it, in a terminal on Linux:

gcc -std=c17 -Wall -Wextra -o hello hello.c
./hello

-o hello names the output file (without it, GCC calls it a.out). The ./ is needed because the shell only looks for commands in the folders on the PATH, and the current folder is not one of them. The result is a native executable: you can copy it to another machine with the same processor type and operating system and run it there, without a compiler.

The four steps of a build

The single gcc command hides a chain of four tools. Knowing them helps you read error messages, because each step fails in its own way.

The build pipeline of a C program. The source file hello.c goes to the preprocessor, which produces the expanded source hello.i. The compiler turns that into assembly code, hello.s. The assembler turns the assembly into the object file hello.o. The linker combines object files with the C library and startup code into the executable hello. hello.c hello.i hello.s hello.o hello 1. Preprocess gcc -E 2. Compile gcc -S 3. Assemble gcc -c 4. Link ld other .o files, C library, startup code
One gcc command runs all four steps; the options -E, -S and -c stop after steps 1, 2 and 3.
  1. Preprocess. The preprocessor handles the lines that start with #: it copies the text of stdio.h into the file, replaces macros and removes comments. The result is still C, only much longer. See The Preprocessor and Build Tools.
  2. Compile. The compiler proper checks the C code, reports errors and warnings, optimizes, and translates it into assembly language for the target processor.
  3. Assemble. The assembler turns the assembly text into machine code in an object file.
  4. Link. The linker combines all object files with the C library and a little startup code that prepares the process and then calls main.

You can run the steps one by one and look at each result. This example was run on a 64-bit Linux machine (ARM64), so the assembly is ARM64 code:

cat > hello.c <<'EOF'
#include <stdio.h>

int main(void)
{
    printf("Hello from Kestrel Energy!\n");
    return 0;
}
EOF
gcc -std=c17 -E hello.c -o hello.i      # 1. preprocess
wc -l hello.i
gcc -std=c17 -S hello.i -o hello.s      # 2. compile to assembly
awk '$1 == "bl" {print $1, $2}' hello.s
gcc -c hello.s -o hello.o               # 3. assemble
file hello.o
nm hello.o
gcc hello.o -o hello                    # 4. link
./hello
echo "exit status: $?"
553 hello.i
bl puts
hello.o: ELF 64-bit LSB relocatable, ARM aarch64, version 1 (SYSV), not stripped
0000000000000000 T main
                 U puts
Hello from Kestrel Energy!
exit status: 0

Seven lines of source became 553 lines after preprocessing, almost all of them declarations from stdio.h. The assembly calls (bl, "branch with link") puts, not printf: GCC noticed that the format string contains no % and ends in a newline, and swapped in the simpler function. file calls the object file "relocatable": its machine code is there, but addresses of things outside it are still open. nm lists its symbols: T main means "defined here, in the code (text) section"; U puts means "used here, but undefined": the linker must find it elsewhere, in this case in the C library.

Translation units and object files

A translation unit is one .c file together with everything its #include lines pull in: the unit the compiler works on. The compiler sees one translation unit at a time and knows nothing about the others; it trusts the declarations you give it. For each unit it writes an object file (.o on Linux and macOS, .obj on Windows): machine code plus a table of the symbols the file defines and the ones it needs from elsewhere.

The linker (ld on Linux, started by gcc) then resolves those needs: it combines the object files and libraries into one executable and fills in the addresses of every function and global variable. Here is a small billing program in two files. tariff.c calculates a cost with integer money (Kestrel's fixed tariff, €0.2700 per kWh, stored as 2700 ten-thousandths of a euro); main.c declares that function and calls it:

cat > tariff.c <<'EOF'
/* Price per kWh in ten-thousandths of a euro: 2700 = €0.2700 */
long cost_cents(long kwh)
{
    return (kwh * 2700 + 50) / 100;   /* + 50: round to whole cents */
}
EOF
cat > main.c <<'EOF'
#include <stdio.h>

long cost_cents(long kwh);   /* defined in tariff.c */

int main(void)
{
    long cents = cost_cents(350);
    printf("350 kWh costs €%ld.%02ld\n", cents / 100, cents % 100);
    return 0;
}
EOF
gcc -std=c17 -Wall -Wextra -c main.c      # two translation units,
gcc -std=c17 -Wall -Wextra -c tariff.c    # compiled separately
nm main.o
nm tariff.o
gcc main.o tariff.o -o billing            # the linker joins them
./billing
gcc main.o -o billing                     # forgot tariff.o
                 U cost_cents
0000000000000000 T main
                 U printf
0000000000000000 T cost_cents
350 kWh costs €94.50
/usr/bin/ld: main.o: in function `main':
main.c:(.text+0xc): undefined reference to `cost_cents'
collect2: error: ld returned 1 exit status

Both files compiled without complaint; the error only appears when the linker cannot find cost_cents. Linker errors are recognizable by ld or collect2 in the message and by the absence of a source line number: the source code is fine, the pieces just do not fit. The two you meet most:

  • undefined reference to `name': a function or variable is declared and used but never defined in any file or library you link. You forgot a .o file or a library, or the name is spelled differently.
  • multiple definition of `name': two files define the same global name, often because a variable was defined (not just declared) in a header file that several source files include. See Functions and Header Files.

Real projects have dozens or thousands of .c files. A build tool such as make recompiles only the units that changed and then relinks; see The Preprocessor and Build Tools.

GCC and Clang

Two free compilers dominate C on Linux and in embedded work:

  • GCC, the GNU Compiler Collection, is the standard compiler of Linux distributions and of most embedded toolchains. The command gcc compiles C (and, with other front ends, C++, Fortran, Ada and more). The Linux kernel is built with it, and Espressif's ESP-IDF ships a GCC build for its ESP32 chips.
  • Clang is the C and C++ compiler of the LLVM project. It accepts almost all of GCC's options, so in most build files clang can replace gcc. It is the default compiler on macOS (in Xcode) and on FreeBSD, and is known for clear error messages.

On Windows, Microsoft's own compiler (MSVC, the command cl) is common, and MinGW-w64 provides GCC for Windows. As of October 2026 the newest releases are GCC 16.2 and LLVM/Clang 23.1.3. Linux distributions ship older, well-tested versions: the machine that checks this site's examples has GCC 12.2 from Debian 12 and uses Clang 21 for C23 examples. The version matters, because defaults change between releases (see below). Check it with gcc --version or clang --version.

The two compilers word their messages differently. For the same mistake, a long printed with the %d format meant for int, GCC says:

fmt.c: In function ‘main’:
fmt.c:6:14: warning: format ‘%d’ expects argument of type ‘int’, but argument 2 has type ‘long int’ [-Wformat=]
    6 |     printf("%d meters\n", meters);
      |             ~^            ~~~~~~
      |              |            |
      |              int          long int
      |             %ld

and Clang:

fmt.c:6:27: warning: format specifies type 'int' but the argument has type 'long' [-Wformat]
    6 |     printf("%d meters\n", meters);
      |             ~~            ^~~~~~
      |             %ld
1 warning generated.

Both give the file, line and column, the source line, markers under the relevant spots, a suggested fix (%ld), and in square brackets the option that controls this warning. That last part is useful: it tells you what to search for in the compiler's documentation.

Warnings: let the compiler find bugs

A compiler warning is a message about code that is valid C but probably wrong: the program is still built. C compilers have hundreds of them, and almost all are off unless you ask. Plain gcc fmt.c, without options, compiles the format mistake above without a single message. The options every C programmer should use:

Option What it does
-Wall A broad set of common warnings: wrong printf formats, undeclared functions, uninitialized variables, a missing return, = where == was probably meant, unused variables
-Wextra More warnings on top of that; in C among others comparisons between signed and unsigned numbers, fall-through in a switch, unused parameters, and an empty if body
-Werror Treat every warning as an error: the build stops, so warnings cannot pile up unnoticed
-Wpedantic Warn about compiler extensions that are not standard C (useful for portable code)
-Wconversion Warn about implicit conversions that may change a value, such as double to int; noisy in old code, valuable in new code

Every example on this site is compiled with -Wall -Wextra -Werror, so a warning makes the example fail. A function used without its header is one of the most common mistakes; here #include <math.h> is missing:

#include <stdio.h>

int main(void)
{
    double total = sqrt(2.0);
    printf("%.3f\n", total);
    return 0;
}
example.c:5:20: error: implicit declaration of function ‘sqrt’ [-Werror=implicit-function-declaration]

Without a declaration, old C rules let the compiler guess that sqrt returns an int, which is wrong and can give garbage results. GCC also prints a note suggesting include ‘<math.h>’. The format mismatch from above, under the same options, also stops the build:

#include <stdio.h>

int main(void)
{
    long meters = 400000;
    printf("%d meters\n", meters);
    return 0;
}
example.c:6:14: error: format ‘%d’ expects argument of type ‘int’, but argument 2 has type ‘long int’ [-Werror=format=]

With -Werror each warning is reported as error: and names its option as -Werror=…; the compiler ends with cc1: all warnings being treated as errors.

cat > invoice.c <<'EOF'
#include <stdio.h>

int main(void)
{
    double price = 0.29;
    int cents = price * 100;
    int readings[3] = {120, 95, 130};
    printf("%d cents, last reading %d\n", cents, readings[3]);
    return 0;
}
EOF
echo "--- -Wall -Wextra -O0"
gcc -std=c17 -Wall -Wextra -O0 -c invoice.c
echo "--- -Wall -Wextra -O2"
gcc -std=c17 -Wall -Wextra -O2 -c invoice.c
echo "--- -Wconversion"
gcc -std=c17 -Wall -Wextra -Wconversion -c invoice.c
--- -Wall -Wextra -O0
--- -Wall -Wextra -O2
invoice.c: In function ‘main’:
invoice.c:8:5: warning: array subscript 3 is above array bounds of ‘int[3]’ [-Warray-bounds]
    8 |     printf("%d cents, last reading %d\n", cents, readings[3]);
      |     ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
invoice.c:7:9: note: while referencing ‘readings’
    7 |     int readings[3] = {120, 95, 130};
      |         ^~~~~~~~
--- -Wconversion
invoice.c: In function ‘main’:
invoice.c:6:17: warning: conversion from ‘double’ to ‘int’ may change value [-Wfloat-conversion]
    6 |     int cents = price * 100;
      |                 ^~~~~

At -O0 GCC says nothing about either bug. The read past the end of readings is only found at -O2, because the analysis behind that warning runs as part of the optimizer. The money bug needs -Wconversion: 0.29 * 100 is not exactly 29 in binary floating point, and converting it to int gives 28 (see Variables, Types and Integers). Warnings are a safety net with holes; sanitizers and tests catch more (see Debugging C).

Choosing a standard

-std= tells the compiler which edition of the C standard to follow. There are two families of values:

  • -std=c17, -std=c11, -std=c23: standard C. Compiler extensions that clash with the standard are switched off, and the C library hides functions that are not in the ISO standard, such as the POSIX function strdup (until C23 added it).
  • -std=gnu17, -std=gnu23: the same standard plus GNU extensions, such as the typeof keyword before C23. This is what you get without -std.

The default changes with the compiler version: GCC 8 to 14 use gnu17, GCC 15 and later gnu23, and Clang still uses gnu17 (as of October 2026). Old code that names a variable bool or declares int f() and calls it with arguments compiles under C17 but fails under C23. Naming the standard in the build makes builds reproducible across compiler upgrades. Add -Wpedantic when the code must also build with other compilers.

Optimization levels and debug info

An optimization level tells the compiler how hard to work at making the program faster or smaller. The machine code changes; the program's meaning should not, as long as the code has no undefined behavior.

Option Meaning Typical use
-O0 No optimization (GCC's default); fast to compile Development and debugging
-O1, -O2 Increasing optimization; -O2 is the usual level for release builds Distribution packages, production
-O3 More aggressive optimization, larger code Number crunching, after measuring
-Os Optimize for size Firmware with little flash memory
-g Add debug information: which machine instruction belongs to which source line and variable Any build you may need to debug

-g can be combined with any level; the debugger then shows source lines and variable names (see Debugging C). At higher levels the optimizer removes and reorders code, so a debugger may show variables as "optimized out". Two practical consequences: some warnings appear only at -O2 (as shown above), and a program with undefined behavior can give different answers at different levels. "It works at -O0 but not at -O2" almost always means a bug in the program, not in the compiler.

Running a program and its exit status

When main returns, its return value becomes the process's exit status: 0 for success, anything from 1 to 255 for some kind of failure. The standard header <stdlib.h> also offers the names EXIT_SUCCESS and EXIT_FAILURE (see Error Handling). Shell scripts, cron jobs, systemd and CI pipelines decide what to do next by looking at that number:

cat > check.c <<'EOF'
#include <stdio.h>

int main(void)
{
    int missing_readings = 3;   /* pretend we checked a file */

    if (missing_readings > 0) {
        printf("%d readings missing\n", missing_readings);
        return 1;
    }
    printf("all readings present\n");
    return 0;
}
EOF
gcc -std=c17 -Wall -Wextra -o check check.c
./check
echo "exit status: $?"
./check && echo "import started"
3 readings missing
exit status: 1
3 readings missing

The second run shows why the exit status matters: because check failed, the shell did not run the command after &&. A program that is killed by a signal, for example after a crash, has no return value; the shell then reports 128 plus the signal number, such as 139 for a segmentation fault (signal 11). See Processes and Jobs.