⚠️ This is not the current iteration of the course! Head here for the current offering.

Lecture 6: Alignment and Collections

» Lecture video (Brown login required)
» Lecture code
» Post-Lecture Quiz (due 11:59 Tuesday, February 15).

The sizeof and typedef keywords

Finding object sizes with sizeof

The sizeof keyword returns the size in bytes (!) of its argument, which can either be a type or an object. Some examples:

sizeof is at the same time great and a huge source of confusion. It is crucial to remember that sizeof only returns the byte size of the known compile-time type of its argument. Importantly, sizeof cannot return the length of an array, nor can it return the size of the memory allocation behind a pointer. If you call sizeof(ptr), where ptr is a char*, you will get 8 bytes (since the size of a pointer is 8 bytes), independent of whether that char* points to a much larger memory allocation (e.g., 100 bytes on the heap).

Why does sizeof work this way?

The dirty, but amazing, secret behind sizeof is that it actually results in no actual compiled code. Instead, the C compiler replaces any invocation of sizeof(type) or sizeof(expression) with the byte size of the argument known at compile time (so sizeof(int) just turns into a literal 4 in the program). Hence, sizeof cannot possibly determine runtime information like the size of a memory allocation; but it also requires zero processor operations and memory at runtime!

Type aliases: typedef

If you're sick of writing code like struct x_t all the time when you use structs, the typedef keyword, which defines a type alias, is for you.

Normally, you always need to put the struct keyword in front of your new struct type whenever you use it. But this gets tedious, and the C language provides the helpful keyword typedef to save you some work. You can use typedef with a struct definition like this:

typedef struct {
  int i1;
  int i2;
  int i3;
  char c1;
  char c2;
  char c3;
} x_t;
... and henceforth you just write x_t to refer to your struct type.

Alignment

Why are we covering this?

Since C requires you to work closely with memory addresses, it is important to understand how the compiler lays out data in memory, and why the layout may not always be exactly what you expect. If you understand alignment, you will get pointer arithmetic and byte offsets right when you deal with them, and you will understand why programs sometimes use more memory than you would think based on your data structure specifications.

The chips in your computer are very good at working with fixed-size numbers. This is the reason why the basic integer types in C grow in powers of two (char = 1 byte, short = 2 bytes, int = 4 bytes, long = 8 bytes). But it further turns out that the computer can only work efficiently if these fixed-size numbers are aligned at specific addresses in memory. This is especially important when dealing with structs, which could be of arbitrary size based on their definition, and could have odd memory layouts following the struct rule.

Just like each primitive type has a size, it also has an alignment. The alignment means that all objects of this type must start at an address divisible by the alignment. In other words, an integer with size 4 and alignment 4 must always start at an address divisible by 4. (This applies independently of whether the object is inside a collection, such as a struct or array, or not.) The table below shows the alignment restrictions of primitive types on an x86-64 Linux machine.

Type Size Address restriction
char (signed char, unsigned char) 1 No restriction
short (unsigned short) 2 Multiple of 2
int (unsigned int) 4 Multiple of 4
long (unsigned long) 8 Multiple of 8
float 4 Multiple of 4
double 8 Multiple of 8
T* 8 Multiple of 8

The reason for this lies in the way hardware is constructed: to end up with simpler wiring and logic, computers often move fixed amounts of data around. In particular, when the computer's process accesses memory, it actually does not go directly to RAM (the random access memory whose chips hold our bytes). Instead, it accesses a fast piece of memory that contains a tiny subset of the contents of RAM (this is called a "cache" and we'll learn more about it in future lectures!). But building logic that can copy memory at any arbitrary byte address in RAM into this smaller memory would be hugely complicated, so the hardware designers chunk RAM into fixed-size "blocks" that can be copied efficiently. The size of these blocks differs between computers, but their existence reveals why alignment is necessary.

Let's assume there were no alignment constraints, and consider a situation like the one shown in the following:

                | 4B int  |     <-- unaligned integer stored across block boundary
                | 2B | 2B |     <-- 2 bytes in block k, 2 bytes in block k+1
     ----+-----------+-----------+-----------+--
    ...  | block k   | block k+1 | block k+2 |   ...  <- memory blocks ("cache lines")
     ----+-----------+-----------+-----------+--

An unaligned integer could end up being stored across the boundary between two memory blocks. This would require the processor to fetch two blocks of RAM into its fast cache memory, which would not only take longer, but also make the circuits much harder to build. With alignment, the circuit can assume that every integer (and indeed, every primitive type in C) is always contained entirely in one memory block.

                     | 4B int  |     <-- aligned integer stored entirely in one block
                     | 4B      |     <-- all 4 bytes in block k+1
     ----+-----------+-----------+-----------+--
    ...  | block k   | block k+1 | block k+2 |   ...  <- memory blocks ("cache lines")
     ----+-----------+-----------+-----------+--

The compiler, standard library, and operating system all work together to enforce alignment restrictions. If you want to get the alignment of a type in a C program, you can use the sizeof operator's cousin alignof. In other words, alignof(int) is replaced with 4 by the compiler, and similarly for other types.

We can now write down a precise definition of alignment: The alignment of a type T is a number a ≥ 1 such that the address of every object of type T is a multiple of a. Every object with type T has size sizeof(T), meaning that it occupies sizeof(T) contiguous bytes of memory; and each object of type T has alignment alignof(T), meaning that the address of its first byte is a multiple of alignof(T).

You might wonder what the maximum alignment is – the larger an alignment, the more memory might get wasted by being unusable! It turns out that the 64-bit architectures we use today have maximum 16-byte alignment, which is sufficient for the largest primitive type, long double.

Note that structs are not primitive types, so they aren't as such subject to alignment constraints. However, each struct has a first member, and by the first member rule for collections, the address of the struct is the address of the first member. Since struct members are primitive types (even with nested structures, eventually you'll end up with primitive type members after expansion), and those members do need to be aligned. We will talk more about this next time!

Alignment constraints also apply when the compiler lays out variables on the stack. mexplore-order.c illustrates this: with all int variables and char variables defined consecutively, we end up with the memory addresses we might expect (the three ints are consecutive in memory, and the three chars are in the bytes below them). But if I move c1 up to declare it just after i1, the compiler leaves a gap below the character, so that the next integer is aligned correctly on a four-byte boundary.

But: if we turn on compiler optimizations, there is no gap! The compiler has reordered the variables on the stack to avoid wasting memory: all integers are again consecutive in memory, even though we didn't declare them in that order. This is permitted, as there is no rule about the order of stack-allocated variables in memory (nor is there one about the order of heap-allocated ones, though addresses returned from malloc() do need to be aligned). If these variables were in a struct (as in x_t), however, the compiler could not perform this optimization because the struct rule forbids reordering members.

Summary

Today, we explored the sizeof() operator in C, which allows programs to determine the size of types (including custom ones, such as structs you define) at compile time. We also looked at how typedef allows C programmers to define type aliases. Finally, we dove into the tricky subject of alignment in memory, where the compiler sometimes wastes memory to achieve faster program execution, and learned how the bytes of types larger than a char, are actually laid out in memory.