I'll assume you have written some C code. Perhaps:
#include <stdio.h> int main() { printf("Hello world!\n"); }
Then (in Linux, at the command line), you can compile it:
gcc hello.c -o hello
And run it:
./hello
And see the output:
Hello world!
As said before, we'll be doing some programming in C, which is almost a subset of
This isn't a particularly modern thing to do: modern
There are many many command line switches to the gcc
command, and a similar collection for Clang.
This basic command takes a .c
file (with a main
function) and produces an executable:
gcc hello.c -o hello
But we want some more tricks from the compiler…
We want more warnings (and should actually do something about them): -Wall -Wpedantic
ask the compiler to warn about many things (and we should actually fix them).
We'll ask the compiler to use a modern C standard: -std=c17
And we'll ask it to target a reasonably-recent CPU architecture: -march=haswell
So when I said:
gcc hello.c -o hello
I really meant:
gcc -Wall -Wpedantic -std=c17 -march=haswell hello.c -o hello
I won't write those on every slide example, but mentally insert them.
Hopefully you have seen a picture like this before:
The static section is known at compile time: your code, global variables, etc. [later topic: “Assembly Code Segments”]
The stack is managed by the language: when you call a function, its arguments and local variables are put onto the stack. When that function returns, its stack variables are automatically destroyed.
The heap is the programmer's responsibilty. You can put some data on the heap any time (e.g. with new
in delete
in
If you don't eventually free a heap allocation, it stays on the heap forever and you have a memory leak.
Compare Java/C#: new
to allocate on the heap, and the garbage collector figures out when to free it.
For example, you (hopefully) have seen creating a heap-allocated
MyData* d = new MyData(1.234); cout << *d << '\n'; delete d;
i.e. created a reference d
on the stack; use new
to do the heap allocation; contructor to initialize; delete
to free up the heap memory.
What you should have done: let the object be owned by a unique pointer, and it will delete the object when the pointer is destroyed (i.e. when the function returns).
auto d = make_unique<MyData>(1.234); cout << *d << '\n';
But that's off topic: we're going to swing the other way and do it even older-school: by encapsulating data in a C-style struct instead of a class:
typedef struct { int count; double size; } my_data;
If we want a stack variable with that struct, it's not so different:
char buffer[BUFFER_SIZE]; my_data d = {0, 1.234}; format_my_data(buffer, d); puts(buffer);
But putting something on the heap in C is a different workflow:
my_data* d = (my_data*)malloc(sizeof(my_data)); d->count = 0; d->size = 1.234; format_my_data(buffer, *d); puts(buffer); free(d);
That first line:
my_data* d = (my_data*)malloc(sizeof(my_data));
sizeof
: number of bytes needed in memory for this type.malloc
: allocate that many bytes on the heap; return a pointer to it.my_data
value, stored in d
.Then follow the pointer to initialize the fields:
d->count = 0; d->size = 1.234;
Note: d
here is a pointer (i.e. the literal memory address where the data lives: an integer), not a
Also note:
is a shorthand for d->count
. i.e. follow the pointer/reference, and refer to a field in the struct/class.(*d).count
In the same way that a new
must be paired with a delete
, a malloc
must be paired with a free
.
my_data* d = (my_data*)malloc(sizeof(my_data));
free(d);
If we don't, we're leaking memory.
Because C and
Essentially no other modern language forces the programmer to worry about these details.
The hello.c
skips some steps. Let's imagine we have a project with two code files. In helpers.c
, we define a function that we want to use elsewhere:
#include "helpers.h" int weighted_add(int a, int b, int weight) { return a + b*weight; }
We need a header file that can be #include
d when we need it. In helpers.h
:
#ifndef HELPERS_H #define HELPERS_H int weighted_add(int a, int b, int weight); #endif
Then we can use our library
code in program.c
:
#include <stdio.h> #include "helpers.h" #define WEIGHT 10 int main(int argc, char *argv[]) { int result = weighted_add(3, 2, WEIGHT); printf("The result is %d.\n", result); return 0; }
Note: #include
with <…>
is for a system file (compiler searches system library directories) and "…"
is for your code (compiler searches this directory).
We could compile all of that like this:
gcc helpers.c program.c -o program
But that's a shortcut for several steps that will matter to us in this course…
The first thing a C compiler does is run your code through the preprocessor. This handles any preprocessor directives (like #include
and #define
).
Usually this is part of compiling the code, but can be done separately if we want to:
cpp helpers.c -o helpers.i cpp program.c -o program.i
If we look at the created program.i
, we see the content of stdio.h
and…
# 3 "helpers.h" int weighted_add(int a, int b, int weight); # 3 "program.c" 2 int main(int argc, char *argv[]) { int result = weighted_add(3, 2, 10); printf("The result is %d.\n", result); return 0; }
The content of helpers.h
was literally included in the output, and
was replaced with WEIGHT
.10
The C preprocessor is fairly simple: it's almost just doing string manipulation, but with a little knowledge of C syntax. i.e. a preprocessor directive like this one:
#define WEIGHT 10
… basically causes a search-and-replace for
to WEIGHT
(but only as code: not in string literals, for example).10
There's not much reason to run cpp
manually: the compiler always does it on .c
files (but treats .i
files as already-preprocessed C code).
The preprocessor's output is the compiler's input. This stage doesn't know about stdio.h
or helpers.h
: they have already been literally included in the code.
So when compiling program.c
, the compiler will see this (from helpers.h
):
int weighted_add(int a, int b, int weight);
… but not the function body (which is in helpers.c
). That's enough to check that weighted_add
is being called correctly. Same story for stdio.h
and printf
.
The next step is compiling: turning the code you wrote into something that can be executed. The gcc -S
option tells the compiler to compile the C code to assembly code, then stop. (We haven't talked about assembly code
yet, but let's explore anyway.)
gcc -S helpers.i # or helpers.c gcc -S program.i # or program.c
This creates helpers.s
and program.s
Recall the actual calculation in helpers.c
was:
return a + b*weight;
Whatever the .s
files are, this is in helpers.s
:
weighted_add: ⋮ imull -12(%rbp), %eax ⋮ addl %edx, %eax ⋮ ret
Multiply, add, return.
And the core of program.c
vs program.s
:
int result = weighted_add(3, 2, WEIGHT); printf("The result is %d.\n", result); return 0;
.LC0: .string "The result is %d.\n" ⋮ main: ⋮ movl $10, %edx movl $2, %esi movl $3, %edi call weighted_add@PLT ⋮ leaq .LC0(%rip), %rax ⋮ call printf@PLT ⋮ ret
The .s
files contain assembly code which is a (somewhat) human-readable description of the logic that needs to actually run on the processor and can be assembled.
Notes…
Assembly code is architecture-specific. This is x86-64 assembly code. If we were on ARM or RISC-V or something else, it would look very different.
The way function arguments are handled (e.g. the 3, 2, 10 in weighted_add
) is different on Linux and Windows.
The assembler is as
(the GNU assembler
). We can assemble our assembly code to object code like this:
as helpers.s -o helpers.o as program.s -o program.o
Object code is machine code: bytes that can be sent to the processor to execute.
The assembler produces an object file: a file containing object code but not yet a complete program.
Think of an object file as a fragment of an executable program. It's part of what will be our completed program, but only part.
We could also get to object code in a single step with gcc -c
:
gcc -c helpers.c gcc -c program.c
This will produce helpers.o
and program.o
that should be identical to the previous three steps.
The extra compiler arguments (-Wall
and friends) are specified on the compilation step.
Once we have object files, they need to be linked to create an executable. The linker command is ld
. This is the idea, but it fails:
ld program.o helpers.o -o program
Two things are wrong: it has no definition for printf
and is missing something called _start
.
ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000 ld: program.o: in function `printf': /usr/include/x86_64-linux-gnu/bits/stdio2.h:112: undefined reference to `__printf_chk'
What any sane person would do: acknowledge that gcc
is capable of sorting out all of the C basics and doing the linking:
gcc program.o helpers.o -o program ./program
The result is 23.
Note: the .o
files are the input in this command. GCC is not compiling here, only linking.
I want to do it with ld
.
The missing printf
definition is easy enough to handle: the -lc
option links in libc.a
(an archive of many object files) which contains all of the C standard library. Somewhere in there is the definition of printf
.
Finding _start
is more interesting.
It turns out that the main
function isn't as special as you thought. The linker doesn't know what C's main
is. It starts your program by running _start
.
The _start
logic is provided by the C Runtime (crt). Its job it to set up the process as C code expects it (set up the standard library, figure out argc
and argv
, etc) and then call your main
function.
There's a crt1.o
object file that contains _start
.
We also have to tell the linker the dynamic linker
that can find shared libraries when the program starts. (We'll ignore shared libraries for now.)
ld -o program program.o helpers.o \ -lc /usr/lib/x86_64-linux-gnu/crt1.o \ --dynamic-linker /lib64/ld-linux-x86-64.so.2
But like I said, any sane person would type:
gcc program.o helpers.o -o program
The full story of compiling C code:
We don't generally do all of those steps explicitly because there's no reason to, but we could.
The maximally-pedantic version of compiling our code:
cpp helpers.c -o helpers.i cpp program.c -o program.i gcc -Wall -Wpedantic -std=c17 -march=haswell -S helpers.i gcc -Wall -Wpedantic -std=c17 -march=haswell -S program.i as helpers.s -o helpers.o as program.s -o program.o ld program.o helpers.o -o program \ -lc /usr/lib/x86_64-linux-gnu/crt1.o \ --dynamic-linker /lib64/ld-linux-x86-64.so.2
The reasonably-sane version:
gcc -Wall -Wpedantic -std=c17 -march=haswell -S helpers.c gcc -Wall -Wpedantic -std=c17 -march=haswell -S program.c as helpers.s -o helpers.o as program.s -o program.o gcc program.o helpers.o -o program
Actually sane:
gcc -Wall -Wpedantic -std=c17 -march=haswell -c helpers.c program.c gcc program.o helpers.o -o program
Also fine:
gcc -Wall -Wpedantic -std=c17 -march=haswell program.c helpers.c -o program
There's nothing special about the GCC compiler. You can do the same steps with Clang (which doesn't seem to have separate programs for each step, but will produce each output file if you ask):
clang -E helpers.c -o helpers.i # produces helpers.i clang -E program.c -o program.i # produces program.i clang -S helpers.i # produces helpers.s clang -S program.i # produces program.s clang -c helpers.s # produces helpers.o clang -c program.s # produces program.o clang program.o helpers.o -o program # produces program
Or, of course:
clang -Wall -Wpedantic -std=c17 -march=haswell -c helpers.c program.c clang program.o helpers.o -o program
Also fine:
clang -Wall -Wpedantic -std=c17 -march=haswell program.c helpers.c -o program
As the course goes on, we will need to combine object files from other sources (hand-written assembly) with C to make a single executable. Knowing the steps of compiling
a program will let us get there.
Because we're going to be writing C,
That might be possible with one command:
g++ w.c x.cpp y.s z.s -o program
But we might want different options on different inputs, so maybe:
gcc -Wall -Wpedantic -std=c17 -march=haswell -c w.c g++ -Wall -Wpedantic -std=c++17 -march=haswell -c x.cpp as --warn y.s -o y.o gcc -Wall -c z.s gcc w.o x.o y.o z.o -o program
Before we get to assembly, we need one more piece of C. The C integer types (char
, short
, int
, long
, long long
) annoy me. They promise a minimum number of bits (8, 16, 16, 32, 64) but might be larger and might vary between systems/compilers.
When working in assembly, we'll be looking at registers and data from memory with a specific number of bits, and the sizes need to match.
I'm going to mostly work with the integer types from the stdint.h
library when working in C if there's assembly nearby. Those types are clear and honest with their sizes.
In fact, most often I'll just use int64_t
(signed 64-bit) and uint64_t
(unsigned 64-bit) integer types.