Compiling and Running C
From source file to running program: the preprocessor, compiler, assembler and linker, GCC and Clang, warning flags that catch bugs early, optimization levels and exit status.
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 whatprintfis and which arguments it takes. -
int main(void)is where the program starts. It takes no arguments here (void) and returns anint. -
\nis a newline.printfdoes not add one by itself, unlike Python'sprint. -
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.
gcc command runs all four steps; the options
-E, -S and -c stop after
steps 1, 2 and 3.
-
Preprocess. The preprocessor handles the lines
that start with
#: it copies the text ofstdio.hinto the file, replaces macros and removes comments. The result is still C, only much longer. See The Preprocessor and Build Tools. - Compile. The compiler proper checks the C code, reports errors and warnings, optimizes, and translates it into assembly language for the target processor.
- Assemble. The assembler turns the assembly text into machine code in an object file.
-
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.ofile 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
gcccompiles 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
clangcan replacegcc. 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 functionstrdup(until C23 added it). -
-std=gnu17,-std=gnu23: the same standard plus GNU extensions, such as thetypeofkeyword 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.