Intro to Compiling C
Jul 19, 2026 – 4869 words, ~23 minutes
This guide explains to someone very new to C how to make compiling C code as simple and extensible as possible. I wrote it a while ago on a whim, and I have been using the resulting build system in personal projects for a while now. As part of making this website, I decided to clean it up and make it my first blog post.
I will assume you can read basic C code. Since so many languages take inspiration from C, you can probably do this already if you know another language. I will also assume you have some experience navigating a UNIX shell.
Now that you are ready, we can proceed with the guide.
Compiling?
C is a compiled language, meaning that it must first be translated into another language before it can be run. In the case of C, it is translated into machine code that your computer’s processor can execute directly. This makes C quite fast and lightweight, since it does not need help from another program in order to run. However, if not handled correctly, this compilation process can become unwieldy and complex, resulting in a lot of development overhead.
Many say C’s compilation overhead is unavoidable and makes it a poor language
for data science, which requires rapid iteration on code, but I disagree.
Modern computers can compile C quite quickly and build tools like GNU make, which I will introduce later, allow
this process to be automated to a substantial degree. With a little scafolding,
iterative development with C can be just as fast as interpreted (i.e. non-compiled) languages
like Python.
How is C compiled?
To compile C, you use a program called a compiler. There are many to
choose from, but I would recommend either gcc or clang.⊕These two compilers are very well-established, feature rich, and should be able to run on any system. Other compilers may not work for this guide due to missing features or incompatible command-line interfaces.
If you would like to follow along, you should install one of
these. For the purposes of this guide, either of them will work fine. I will
use gcc in my example commands, but you can simply replace it with clang
and it will still work, since they have nearly identical command-line
interfaces.
First, we create an empty directory containing, cd into it, and create a single C file called
hello_world.c with the following contents⊕Note that I prefix the contents with a comment with the file name. This is so
it is more clear later when there are multiple files.
:
// hello_world.c
#include <stdio.h>
int main(int argc, char ** argv) {
printf("Hello, world!\n");
return 0;
}
To compile this single C file into an executable, you can simply run the compiler and pass the file path as a command line arguments like so:
$ gcc hello_world.c
The compiler reads the code in this file and compiles it.
The result is an executable file next to the code called a.out, which can be run.
$ ./a.out
Hello, world!
a.out is a weird file name. There are historical reasons it is called this by
default that I won’t discuss here⊕For those interested.
, but it can be changed using gcc’s
command line options like so:
$ gcc -o hello_world hello_world.c
$ ./hello_world
Hello, world!
If you pass “-o” to gcc, it will use the next argument as the file name it should
use for the output.
C flags
The compilation process is quite complex, and there are many ways it can be
adjusted to meet your needs. To help with this, the C compiler accepts many
options called C flags, which alter the compiled code in various ways. While
the defaults are fairly sane, some of them can be changed to improve the
experience of developing C. The ones I typically use are
-std=c99 -O2 -Wall -Wextra -Wpedantic -Werror. Here is what they do:
- -std=c99: This tells the compiler which version of the C standard to use. This affects what syntax is considered valid. I personally like C99, but there are also more modern ones like C17 or C23 which you can also use.
- -O2: The -O flag (not to be confused with the -o flag) tells the compiler how aggressive it should be with optimization. The larger the number, the more aggressive. The number can range from 0 to 3. The compiler will accept larger numbers, but it will treat them the same as 3. Generally, 2 is recommended for most use cases, while 3 can sometimes offer improved performance, at the cost of larger compiled program sizes and sometimes less stability. There are also special flags like -Os and -Oz, which optimize for size instead of performance.
- -Wall -Wextra -Wpedantic: These flags enable many compiler warnings that will let you know if you are doing something the compiler thinks is wrong. Modern compilers are quite good at understanding C code, so you should take these warnings seriously.
- -Werror: This tells the compiler to treat all warnings as errors and refuse to compile code that produces warnings.
Another nice thing about C flags is that you can choose to enable or disable
them based on what you are trying to do. The above C flags are what I use when
trying to actually run code “for real”. When I’m trying to fix bugs, I use a different set of C
flags: -std=c99 -g -O0 -Wall -Wextra -Wpedantic -Werror -fsanitize=address.
Some of these are the same as before, but others aren’t:
- -g: This enables debug symbols being included in the compiled executable. These are short strings that a debugger like gdb can read to match up the instructions in the executable file with the source code. This lets it do things like step through the source code line-by-line, and even print out the values of variables mid-execution.
- -O0: This is the same flag as before and selects the optimization level. Here we are disabling optimization entirely. This helps the debugger work properly by making the compiled executable follow the source code much more closely. The optimization flag should not affect what the code actually does, so this won’t affect the code’s behavior while making it significantly easier to debug.
- -fsanitize=address: This enables the C address sanitizer, which is a runtime library that hooks into your code’s memory management and prints out useful information regarding poorly managed memory. Manual memory management is well beyond the scope of this guide, but suffice it to say some of the bugs caused by doing it incorrectly can be nightmarishly difficult to fix without tools such as this.
The Gritty Details
Before we can cover more advanced concepts relating to compiling C, such as compiling programs split across multiple files or using libraries, we must first get a better idea of how exactly the compilation process works.
Although so far it seems like compiling C code is an atomic process (put .c file in, get executable out), it is actually a multi-stage process that can be divided into 4 steps:
- Preprocessing: Here, the code is fed through a program called a preprocessor, which modifies the code according to directives located inside it. These directives are prefixed with a hashtag and can do things like copy contents from other files or define macros.
- Compiling: This is the main part of the compilation process, where the code, now with all the preprocessor directives resolved, is converted into assembly.
- Assembling⊕Some compilers skip assembly and go straight from C to machine code. : Once the assembly is created, it is then converted into machine code by a separate program called an assembler. The result of this is a relocatable binary file called an object file (typically with a suffix .o). While it is machine code, it is not executable yet since it may have unresolved calls to external functions.
- Linking: The last step, which is performed by another separate program called a linker, takes many object files and combines them into a single executable file. In the process, all the unresolved function calls are connected together. If the linker can’t resolve any of the function calls, it fails and prints an error.
Compiling Multi-file Programs
Suppose we have two C files that we want to compile together: greet.c and main.c.
In greet.c, we have a function that greets the user, and in main.c, we have our
program’s entry point, in which we want to call the function in greet.c:
// greet.c
#include <stdio.h>
void greet(const char *name) {
printf("Hello, %s!\n", name);
}
// main.c
int main(int argc, char **argv) {
greet(argv[1]);
return 0;
}
To compile these two together, we could try passing both to the compiler:
$ gcc -o greet main.c greet.c
main.c: In function ‘main’:
main.c:4:9: error: implicit declaration of function ‘greet’ [-Wimplicit-function-declaration]
4 | greet(argv[1]);
| ^~~~~
When we try to do this, we get an error saying that the function “greet” is not
declared anywhere, even though we did so in greet.c. While this may seem
strange, it makes sense if we consider the steps of the compilation process we
just discussed. This error is occurring in the second step, when the compiler
is trying to convert the code into assembly. Since this is done with each file
individually, before it has even tried to combine the files together, it
doesn’t actually know what is in greet.c yet, or that greet is defined there.
How do we fix this? The key is to add something called a function definition. This is
similar to the declaration we already have, but without the actual code that
describes how the function should be executed. If we add one to the top of main.c, we get
this:
// greet.c
#include <stdio.h>
void greet(const char *name) {
printf("Hello, %s!\n", name);
}
// main.c
void greet(const char *name);
int main(int argc, char **argv) {
greet(argv[1]);
return 0;
}
This definition tells the compiler that this function is implemented elsewhere in the project and should leave it for the linker to resolve. It is necessary to put it there since the compiler needs to know what parameters the function takes and what (if anything) it returns in order to compile the code that uses it.
Now if we try to compile our code, it works as we would expect:
$ gcc -o greet main.c greet.c
$ ./greet John
Hello, John!
In a complex project, it would get quite unwieldy managing all of these duplicated definitions of the same function. It would become challenging to modify this function to, for example, add another parameter. To fix this, we can use one of the other steps of compilation: the preprocessing step. One of the things it can do is copy the contents of another file into the one we are compiling. This way, we can put the definition of “greet” in a separate file and use the preprocessor to copy it into any C file where we need it. If we do this, the resulting code looks like this:
// greet.c
#include <stdio.h>
#include "greet.h"
void greet(const char *name) {
printf("Hello, %s!\n", name);
}
// greet.h
void greet(const char *name);
// main.c
#include "greet.h"
int main(int argc, char **argv) {
greet(argv[1]);
return 0;
}
A few things to note:
- These files containing function definitions are called “header files” and typically have the suffix “.h”.
- Note that we use the
#includedirective to tell the preprocessor to copy the contents of greet.h into main.c, so the compiler knows the greet function is defined elsewhere while compiling main.c. - Although it is not always required, it is standard practice to include a c
file’s own header in itself. Hence why we add
#include "greet.h"togreet.c.
Now, if we need to add a parameter to our greet function, we only have to do
it in two places, regardless of how many places we use it.
And of course, it still compiles:
$ gcc -o greet main.c greet.c
$ ./greet John
Hello, John!
Using Libraries
In the previous section, you may have noticed the #include <stdio.h> in
greet.c. This is an include directive just like the one for greet.h, but we
do not have a stdio.h file in our project. This is because this is a
system header file, provided by a library installed on our computer. In this
case, this file, the name of which is short for “standard input/output” is part
of the C standard library, libc, and is needed to use the printf function
to actually print the greeting in greet.c.
Generally, C libraries are distributed as several files: one or more header
files containing the definitions of functions the library provides, and a
single shared object file with the suffix .so, which, like an object file,
contains the compiled machine code that should be executed when these functions
are called.
In order to use a library, we must do two things. We must first include the
library’s header file in our code so the compiler knows the functions exist.
Usually, libraries install headers in standard locations the compiler knows to
look in, so we can simply use an include directive like #include <stdio.h>.
Note the use of angle brackets instead of quotes. This tells the preprocessor
to look in the standard system header file locations instead of the current
directory. Sometimes you will need to include system header files in
non-standard locations. This can be done using the compiler command line option
-I followed by a path to a directory to search.
The second thing we must do is tell the linker to include the shared object
file when linking. This is done with a linker flag. These are passed to the
compiler in the same way as other flags, though they are typically placed at
the end of the command instead of the beginning. To tell the linker to include
a shared object
file, we pass the -l flag followed by the name of the library, which is the
name of the shared object file without the “lib” prefix or “.so” suffix. For
example, to include libc.so, we would pass the flag -lc⊕libc is usually included by default so -lc is rarely needed.
. As with headers,
if a library installs its shared object file to a non-standard location,
we can tell the compiler to search additional directories with the -L flag in
the same way.
As an example, suppose we want to do trigonometry in our C code. There is a
common library called libm that includes code that does this. It has a
corresponding header called math.h which is installed in a standard location.
To use this library, our code would look like this, for example:
// cosine.c
#include <stdio.h>
#include <math.h>
int main(int argc, char **argv) {
float x = 2;
float y = cosf(x); // <- cosf is defined in math.h
printf("The cosine of %.3f is %.3f.\n", x, y);
return 0;
}
To compile this, we would run the following:
$ gcc -o cosine cosine.c -lm
$ ./cosine
The cosine of 2.000 is -0.416.
Splitting the Process
Often, the first three compilation steps are separated from the last one. This
is usually done to save time. If we have a program with many C files, it would
be nice if we only had to recompile the ones we changed. To do this, we must first separate the linking step from the other three. This can be done by
passing the -c flag to gcc. This tells it to stop just before the linking
step and instead of saving an executable file, to save the resulting object
file that would have been passed to the linker. The linking process can then be
done using the usual gcc command, but this time passing it the object files
instead of the source code.
For example, in order to compile our greeter program from before, instead of using this single command.
$ gcc -o greet main.c greet.c
We could use the following sequence of commands:
$ gcc -c -o main.o main.c
$ gcc -c -o greet.o greet.c
$ gcc -o greet main.o greet.o
This allows us to recompile the program without having to recompile every
individual C file. For example, if we only modified greet.c, then we could
start at the second command and not bother recompiling main.c.
At this point, you know everything you need to start automating this process.
In practice, people rarely run these commands manually, but use tools to
automate them. I will now switch focus to one popular such tool: GNU make.
What is GNU make?
GNU make is, at its heart, an automation tool. It allows you to specify
“recipes” for performing various tasks and lets you execute them via a simple
command: make. Although it was originally designed to automate complex
compilation processes (which is what we will be using it for), it can be used
in many other circumstances, and it is worth learning how to use well, even
beyond what I discuss here.
When you run make, it searches for a file named “Makefile” in your current
directory, and runs the specified recipe (called a “target”). You pick which
recipe to run by passing its name to make as a command line argument.
Targets are specified using the following syntax:
<target name>: <prerequisites> ...
command 1
command 2
...
By default, make will assume that the target is the name of a file that should be made using other files, called the prerequisites. When it runs this recipe, it first checks if the target file doesn’t exist or if one of its prerequisites has changed since the target was last created. If either of these is the case, then it will run the specified commands to recreate the target, otherwise it assumes that recreating the target file is unnecessary, since it would give the same result anyway.
The real power of make emerges once you chain many recipes together. Each prerequisite file can itself be a target with its own recipe, and make will check if it needs to be updated and do so before continuing with the current recipe. Through this process, we can chain many recipes together to complete complex multi-step tasks while also minimizing the steps taken.
A Basic Makefile
If we go back to our greet.c and main.c files from before, we have the
following sequence of commands we used to compile the program:
$ gcc -c -o main.o main.c
$ gcc -c -o greet.o greet.c
$ gcc -o greet main.o greet.o
We can think of each command as taking several files and making a new one. Once framed like this, we can convert this into a simple Makefile:
greet: main.o greet.o
gcc -o greet main.o greet.o
main.o: main.c
gcc -c -o main.o main.c
greet.o: greet.c
gcc -c -o greet.o greet.c
If we save this file and just run make, it will default to the first recipe
to make the greet executable. It will check that main.o and greet.o exist
and have been modified more recently than their respective prerequisites, and
if not, will run the commands to (re)create them before finally linking them
together.
Now if we modify greet.c and run make again, it will know it doesn’t need
to recompile main.c and skip running that command.
However, now we have a new problem: maintaining this Makefile. Every time we add a new C file we have to update our Makefile. Luckily, Make has a powerful system of variables and macros we can use to make it self-update to handle this. I will show you the updated Makefile and then explain all the differences:
c_files := $(wildcard *.c)
o_files := $(patsubst %.c,%.o,$(c_files))
greet: $(o_files)
gcc -o $@ $^
%.o: %.c
gcc -c -o $@ $^
- First we use the
wildcardmacro to get a list of all the C files in our current directory and assign that list to the variablec_files. - Next we use the
patsubstmacro to replace all the.cextensions in ourc_filesvariable with.o, which gives us a list of object files. - Now, when specifying our recipe for our final executable, we use the
o_filesvariable to say it requires all the object files that result from compiling our C files. In the compilation command, we use the special variables$@and$^, which expand to the target file and the list of prerequisites, respectively. - Lastly, instead of having a separate rule for each C file, we can use
wildcards to create a generic recipe for any object file. Here we also use
$@and$^to make the compiler command as generic as possible.
Now when we add a C file to our project, it will be included in the c_files
variable. Its corresponding object file will also appear in the o_files
variable, so it will be included in our final executable automatically without
us having to modify the Makefile.
Adding C flags
We can use make’s variables to specify compiler and linker flags:
CFLAGS ?= -std=c99 -O2 -Wall -Wextra -Wpedantic -Werror
LDFLAGS ?= -lc
c_files := $(wildcard *.c)
o_files := $(patsubst %.c,%.o,$(c_files))
greet: $(o_files)
gcc $(CFLAGS) -o $@ $^ $(LDFLAGS)
%.o: %.c
gcc $(CFLAGS) -c -o $@ $^
Note the use of ?=. This allows the person running make to override these
values via the command line:
$ CFLAGS="-O0" make
We can also introduce two separate targets, release and debug, and
configure the C flags depending on which we are building:
.DEFAULT_GOAL := release # You can manually specify the default target like this.
release: CFLAGS ?= -std=c99 -O2 -Wall -Wextra -Wpedantic
debug: CFLAGS ?= -std=c99 -g -O0 -Wall -Wextra -Wpedantic -fsanitize=address
LDFLAGS ?= -lc
c_files := $(wildcard *.c)
o_files := $(patsubst %.c,%.o,$(c_files))
release: greet
debug: greet
greet: $(o_files)
gcc $(CFLAGS) -o $@ $^ $(LDFLAGS)
%.o: %.c
gcc $(CFLAGS) -c -o $@ $^
.PHONY: release debug
Now we can specify which set of C flags to use by running make release or
make debug. It will default to the former. Note the last line, which is a
special “recipe” that tells make that release and debug are not intended to be
files and those recipes should always be run.
Also note the final recipe: .PHONY. This is a special target that tells
make that its prerequisite targets don’t represent actual files (i.e. when
we run make release, it shouldn’t check if a file named release exists).
Handling Headers
You may have noticed an issue with this Makefile: the header files. These files are also used to generate the object files, but we currently do not list them as prerequisites. This means if we change a header, the object files made with it won’t be updated. This doesn’t seem trivial to fix either, since now each object file has a separate list of prerequisites that can change.
Luckily, C compilers have a nifty little feature that solves this problem. We
can have our C compiler output a list of headers in the C file being compiled
in the form of make recipes. We can then use make’s include macro to
include these recipes in our main Makefile, which will add the headers for each
C file to the prerequisites of the resulting object file.
To do this, we first add the needed flags to the compiler (see your compiler’s documentation for details on what each does):
#...
%.o: %.c
gcc $(CFLAGS) -MT $@ -MD -MP -MF $(patsubst %.o,%.d,$@) -c -o $@ $<
#...
Note that we have to change $^, which includes all prerequisites, to $<,
which only includes the first, since we will now have headers in our
prerequisites that we do not want to pass to the compiler.
Next, we add an include statement to include all these generated files:
#...
-include $(wildcard *.d)
#...
Our Makefile now looks like this:
.DEFAULT_GOAL := release
release: CFLAGS ?= -std=c99 -O2 -Wall -Wextra -Wpedantic
debug: CFLAGS ?= -std=c99 -g -O0 -Wall -Wextra -Wpedantic -fsanitize=address
LDFLAGS ?= -lc
c_files := $(wildcard *.c)
o_files := $(patsubst %.c,%.o,$(c_files))
release: greet
debug: greet
greet: $(o_files)
gcc $(CFLAGS) -o $@ $^ $(LDFLAGS)
%.o: %.c
gcc $(CFLAGS) -MT $@ -MD -MP -MF $(patsubst %.o,%.d,$@) -c -o $@ $<
-include $(wildcard *.d)
.PHONY: release debug
Now if we change a C file to include a new header, the compiler will rewrite
the corresponding .d file to add the new header to the
prerequisites of the corresponding object file. make will see this and know
to recompile the object if the header changes.
Some Helper PHONY Targets
We can use the .PHONY target to also add some simple utility recipes. One I
always add is “clean” which deletes any intermediate files that can be
regenerated. In this case it should delete all the .o and .d files as well
as our executable:
# ...
clean:
-rm *.o *.d greet
.PHONY: release debug clean
You can define as many of these as you want. Other common ones are install
and uninstall to add or remove your executable to the system’s installed
programs, or dist to create a distributable tarball of your source code
An implementation of these on a Linux system may be:
# ...
clean:
-rm *.o *.d greet
install:
cp greet /usr/local/bin/greet
chmod 755 /usr/local/bin/greet
uninstall:
rm /usr/local/bin/greet
dist: clean
tar czf ../greet.tar.gz --transform 's+^+greet/+' .
.PHONY: release debug clean install uninstall dist
Separating Out Configuration
The last thing I like to do is to separate out some of the options into a
config.mk file that I can include in my main Makefile. Here I put some of the
build flags and other options so I do not have to modify my main Makefile. I
also separated them by purpose and added some other settings, like letting the
user specify which C compiler to use, or adding a C preprocessor macro to embed
the program name and version into the executable.
# config.mk
PROGRAM := greet
VERSION := 0.1
BINPREFIX := /usr/local/bin
INCS :=
LIBS := -lc
CPPFLAGS := $(INCS) -DVERSION=$(VERSION)
release: CFLAGS := -std=c99 -O2 -Wall -Wextra -Wpedantic $(CPPFLAGS)
debug: CFLAGS := -std=c99 -g -O0 -Wall -Wextra -Wpedantic -fsanitize=address $(CPPFLAGS)
LDFLAGS := $(LIBS)
CC ?= gcc
# Makefile
.DEFAULT_GOAL := release
include config.mk
c_files := $(wildcard *.c)
o_files := $(patsubst %.c,%.o,$(c_files))
release: $(PROGRAM)
debug: $(PROGRAM)
$(PROGRAM): $(o_files)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
%.o: %.c
$(CC) $(CFLAGS) -MT $@ -MD -MP -MF $(patsubst %.o,%.d,$@) -c -o $@ $<
-include $(wildcard *.d)
clean:
-rm *.o *.d $(PROGRAM)
install:
mkdir -p $(DESTDIR)$(BINPREFIX)
cp $(PROGRAM) $(DESTDIR)$(BINPREFIX)/$(PROGRAM)
chmod 755 $(DESTDIR)$(BINPREFIX)/$(PROGRAM)
uninstall:
rm $(DESTDIR)$(BINPREFIX)/$(PROGRAM)
dist: clean
tar czf ../$(PROGRAM)-$(VERSION).tar.gz --transform 's+^+$(PROGRAM)-$(VERSION)/+' .
.PHONY: release debug clean install uninstall dist
Wrapping up
At this point, you now have a simple build system that can easily compile C programs consisting of many files. You can use make to quickly iterate on portions of the program without having to recompile the whole thing. The build system can also handle complex interdependencies between these files without the Makefile needing to be changed, letting the program be extended with minimal friction to the developer.
As part of motivating this system, we have also explained how the C compilation process is structured, and touched on some of the reasons why it is the way it is. I hopee to have shown you that the process of compiling C code is not actually that complicated, and with a little bit of effort, it can be made extremely simple and automatic.
The remainder of this guide are some simple extensions to this Makefile that I occasionally use.
Miscellaneous Tips and Tricks
Git Versions
If you use git to distribute your source code, you can use it to automatically generate version strings:
VERSION := $(shell git describe --tags --dirty)
The above command will print out the currently checked-out tag if there is one, otherwise it will append the current commit hash to the last tag in the git log. Finally, if the work tree is dirty, it will append “-dirty” to the version string.
Note that this does not work if you also distribute tarballs, in that case you can use this more complex logic:
VERSION := $(shell test -f ".version" && cat .version || git describe --tags --dirty)
# ...
dist: clean
test -f ".version" && exit 1
echo "$(VERSION)" > .version
tar ...
rm .version
This will check if a file called “.version” exists and use its contents as the
version before trying to use git. Then, when making a distribution tarball, it
will create the “.version” file before calling tar, so the tarballs have that
file in them. This way, an extracted tarball will still have the correct
version information (although it won’t tell you if the code was modified like
git would).
Embedding Build Settings Into the Executable
If you have a public issue tracker, it could be helpful to have people include the build settings in their bug reports. To do this, first pass the relevant info to the C preprocessor as macro definitions:
program: ...
gcc ... -DVERSION='"$(VERSION)"' -DCFLAGS='"$(CFLAGS)"' -DLDFLAGS='"$(LDFLAGS)"'
...
Now you can use these in your program to print this information out:
void print_build() {
printf("program-" VERSION "\n");
printf("CFLAGS: " CFLAGS "\n");
printf("LDFLAGS: " LDFLAGS "\n");
}
Now simply add a command line argument, like “-V”, that causes your program to call this function and exit.