Revisiting C

Revisiting C

I'll assume you have written some C code. Perhaps:

#include <stdio.h>

int main() {
    printf("Hello world!\n");
}

Revisiting C

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!

Revisiting C

As said before, we'll be doing some programming in C, which is almost a subset of C++.

This isn't a particularly modern thing to do: modern C++ is a radically different language that happens to share a lot of syntax with C.

Command Line C

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…

Command Line C

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

Command Line C

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.

The Heap

Hopefully you have seen a picture like this before:

static, heap, stack memory

The Heap

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

The heap is the programmer's responsibilty. You can put some data on the heap any time (e.g. with new in C++), but you also have to remember to say when you're done with it (delete in C++).

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.

The Heap

For example, you (hopefully) have seen creating a heap-allocated C++ object, and it probably went like this:

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.

The Heap

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';

The Heap

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;

The Heap

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);

The Heap

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);

The Heap

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.
  • We're going to treat that as a pointer to a my_data value, stored in d.

The Heap

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 C++ reference.

Also note: d->count is a shorthand for (*d).count. i.e. follow the pointer/​reference, and refer to a field in the struct/class.

The Heap

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.

The Heap

Because C and C++ don't manage heap memory for us, it's our responsibility to do it. It's easy to get 99% right and extremely hard to get 100% right, and that sucks.

Essentially no other modern language forces the programmer to worry about these details.

Multi-file C

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;
}

Multi-file C

We need a header file that can be #included when we need it. In helpers.h:

#ifndef HELPERS_H
#define HELPERS_H
int weighted_add(int a, int b, int weight);
#endif

Multi-file C

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).

Multi-file C

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…

Compiling C

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

Compiling C

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 WEIGHT was replaced with 10.

Compiling C

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 WEIGHT to 10 (but only as code: not in string literals, for example).

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).

Compiling C

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.

Compiling C

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

Compiling C

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.

Compiling C

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

Compiling C

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.

Compiling C

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.

Compiling C

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.

Compiling C

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.

Compiling C

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.

Compiling C

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'

Compiling C

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.

Compiling C

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.

Compiling C

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.

Compiling C

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.

Compiling C

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

Compiling C

The full story of compiling C code:

C code (.c)
preprocess
Preprocessed C code (.i)
compile
Assembly code (.s)
assemble
Object code (.o)
link
Executable

We don't generally do all of those steps explicitly because there's no reason to, but we could.

Compiling C

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

Compiling C

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

Compiling C

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

Compiling C

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

Compiling C

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.

Compiling C

Because we're going to be writing C, C++, and assembly, creating an executable might be more like:

compiling and assembling together

Compiling 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

Aside: Integers in C

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.

Aside: Integers in C

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.