Intro to Compiling C | Joseph Hand
Joseph Hand
blog
code
publications

Intro to Compiling C

Jul 19, 2026 – 4869 words, ~23 minutes

C, code

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 contentsNote 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 hereFor 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:

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:

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:

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:

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 -lclibc 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 $@ $^

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.