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(int)
is 4 bytes, because integers consist of four bytes;sizeof(char)
is 1 byte;sizeof(int*)
andsizeof(char*)
are both 8 bytes, because all pointers on a 64-bit computer are 8 bytes in size;- for
int i = 5;
,sizeof(i)
is also 4 bytes, becausei
is an integer; - for an array
int arr[] = { 1, 2, 3 }
,sizeof(arr)
is 12 bytes. - for a pointer to an array, such as
int* p = &arr[0]
,sizeof(p)
is 8 bytes, independent of the size of the array.
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 doessizeof
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 ofsizeof(type)
orsizeof(expression)
with the byte size of the argument known at compile time (sosizeof(int)
just turns into a literal4
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 int
s are consecutive in memory, and the three
char
s 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.