Information Structure - Top-Down
Stack & Heap
Let’s dive a litter deeper into the memory - from programs’ perspective. Programs view memory as consisting of two parts: the stack and the heap.
The stack is commonly used for function calls. A call creates a stack frame containing information such as parameters, local variables, and the return address. A local variable normally lives until its function returns. The stack is fast and automatically reclaimed, but its size is limited.
Note
Stack vs. Stack Frame
A stack is allocated in memory, while a stack frame is created on the stack.
A stack is created when a program executes. (More precisely, each thread has its own stack.) A stack frame is created when a function is called.
You may have heard of stack overflow. As you can imagine, if you call a recursive function, many stack frames are created during the call. If too many stack frames exist at the same time, the stack runs out of space, and a stack overflow occurs. That’s why standard libraries prefer iteration over recursion.
The heap is a region managed by a memory allocator. It is used when an object must outlive the current function or when its size is known only at run time. The heap is much larger than the stack, but accessing it is usually slower.
In C, a programmer normally calls malloc to allocate space on the heap. You have to manually free the space allocated by malloc; otherwise, a memory leak occurs.
#include <stdio.h>
#include <stdlib.h>
// Allocate an int on the heap with an initial value of val.
// val is a parameter, which is stored on the stack
int* make_number(int val) {
// p is a local variable, which is stored on the stack.
int* p = malloc(sizeof *p); // sizeof *p === sizeof int
if (p == NULL) { // If malloc fails, it returns NULL.
return NULL;
}
// *p accesses an address on the heap.
*p = val;
return p;
}
int main(void) {
int* p = make_number();
if (p != NULL) {
printf("%d\n", *p);
free(p); // The pointer returned by malloc must be freed manually.
}
}
Note
When a process is terminated, all the memory it used, whether freed or not, is safely reclaimed. The
freecall simply tells the allocator that a block of space can be freed in advance. This matters when your program runs for a long time or when memory is extremely limited. For simple programs such as this one, including afreecall is just a good habit and doesn’t actually make a difference.
See cppreference for details.
For more details, see the GNU C Library memory allocation documentation and the Rust documentation for Box.
Type Layout
An abstract type has values and operations. A concrete implementation also needs a layout: the size of each value, its alignment requirement, and the position of every field. sizeof in C and size_of in Rust report the size in bytes, but the result depends on the type and the target machine.
sizeof(char) is exactly 1 in C, but a C byte is defined by CHAR_BIT and is not required by the language standard to contain eight bits. On our Ubuntu machines, char has 8 bits. The sizes of int, pointers, and structures are implementation-defined. Rust’s primitive integer types have fixed widths (u8, i32, u64, and so on), while usize and isize are chosen to hold a pointer-sized value on the target.
Most types also have an alignment. An object with alignment 4 must start at an address divisible by 4. A structure can consequently contain padding between fields and at its end. The trailing padding makes every element of an array correctly aligned.
Primary Types
The primary types in this example are int, char, and double. Each value occupies a fixed number of bytes for a given target, and each type has its own alignment requirement.
#include <stdio.h>
int main() {
printf("int: size = %zu, alignment = %zu\n",
sizeof(int), _Alignof(int));
printf("char: size = %zu, alignment = %zu\n",
sizeof(char), _Alignof(char));
printf("double: size = %zu, alignment = %zu\n",
sizeof(double), _Alignof(double));
}
Pointer
A pointer has its own layout too: on a typical x86-64 machine, an int* occupies 8 bytes even though the int it points to occupies 4 bytes. All pointer types have the same size on the same machine.
#include<stdio.h>
int main() {
printf("int*: size = %zu, alignment = %zu\n",
sizeof(int*), _Alignof(int*));
}
Structure
In a structure, each member has its own storage. Members appear in declaration order, but the compiler may insert padding between them so that every member is correctly aligned. It may also append trailing padding, so that every element of an array of structures is aligned.
#include <stddef.h>
#include <stdio.h>
typedef struct Student {
unsigned id;
char name[20];
double score;
} Student;
int main(void) {
Student student = {2200015213, "Jill", 3.92};
printf("size = %zu\n", sizeof(Student));
printf("alignment = %zu\n", _Alignof(Student));
printf("offset of id = %zu\n", offsetof(Student, id));
printf("offset of name = %zu\n", offsetof(Student, name));
printf("offset of score = %zu\n", offsetof(Student, score));
}
On Ubuntu x86-64, score usually starts at offset 24: id takes 4 bytes and name takes 20 bytes. The total size is usually 32 bytes, with the layout rounded up to the structure’s 8-byte alignment. These are typical results, not promises made by the C language for every target.
Union
Unlike a structure, a union gives all of its members the same starting address. Its size is large enough for its largest member and is rounded up to satisfy the union’s alignment. A union can save space when a value needs to have one of several representations, but only one member should be treated as the current value at a time.
#include <stdio.h>
typedef union VariantNumber {
int integer;
char big_integer[20];
double real_number;
} VariantNumber;
int main(void) {
VariantNumber uni = {.real_number = 0.33};
printf("size = %zu\n", sizeof(VariantNumber));
printf("alignment = %zu\n", _Alignof(VariantNumber));
printf("real number = %f\n", uni.real_number);
uni.integer = 42; // The integer member is now the value we just wrote.
printf("integer = %d\n", uni.integer);
}
All three members start at offset zero, but they do not all contain meaningful values at the same time. Reading a member other than the one most recently written has implementation-defined or otherwise non-portable results; a union is not a portable substitute for numeric conversion.
Array
An array stores elements of the same type consecutively. There is no gap between two array elements, so the size of an array is the number of elements multiplied by the size of one element. If an element is itself a structure, its own padding is included in that size.
#include <stdio.h>
int main(void) {
int arr[4] = {1, 2, 3, 4};
printf("array size = %zu\n", sizeof arr);
printf("element count = %zu\n", sizeof arr / sizeof arr[0]);
printf("third element = %d\n", arr[2]);
}
arr is an array, not a pointer. In most expressions, however, an array is automatically converted to a pointer to its first element. This is why a function receiving an array usually also needs a separate length argument. The exception is important: sizeof arr above sees the whole array, while sizeof on a function parameter sees a pointer.
For exact rules, see the C17 object representation and alignment clauses and the Rust Reference: Type layout.
Bytes
The smallest addressable unit is a byte. Hexadecimal is a convenient way to write bytes: one hexadecimal digit represents four bits, so two digits represent an 8-bit byte. The following program prints the native byte order of 0x12345678.
In C, unsigned char can inspect the object representation of any object, and memcpy can copy those bytes without pretending that one type is another.
#include <stdint.h>
#include <stdio.h>
#include <string.h>
int main(void) {
uint32_t value = UINT32_C(0x12345678);
unsigned char bytes[sizeof value];
memcpy(bytes, &value, sizeof bytes);
for (size_t i = 0; i < sizeof bytes; ++i) {
printf("%02x%s", bytes[i], i + 1 == sizeof bytes ? "\n" : " ");
}
}
On a little-endian machine, the output is 78 56 34 12: the least significant byte is stored at the lowest address. On a big-endian machine, it is 12 34 56 78.
You can read more in the C standard’s object representation rules and the Rust documentation for integer byte-order conversions.