In CS1 we unravelled some of the history of computer science and how software was the next cool thing, as well as prepared a nice and cozy basis of C. In CS2 we explored a bit beyond that coziness and learned to interact with (almost) the memory from E2. We learned of the existence of the stack and how function calls create their own stack frame. We explored pointers and how they reference memory locations, allowing us to bypass the strong boundary of the function scopes.
However we've left some open threads in the course:
void* type?Beyond that, today we are going to explore:
Ready for C wizardry?
Here's today's plan:
We have introduced two new operators: the address-of & and the dereference * operators.
With address-of we can get the address of a variable, and with dereference we can get the value at that address.
int a = 10;
printf("%p", &a); // address of a
printf("%d", *(&a)); // the value at that address: 10
We however cannot store the address of a directly into an int variable, although they are still numbers. It is like storing numbers and apples: one is the price, the other is the product. That's why we have pointers.
A pointer is a variable that holds a reference to a variable. In other words it holds an address of the mailbox that a variable represents. It has a special type — the pointer type. There are several:
int *p; // a pointer to an int type
double *p; // a pointer to a double type
char *p; // a pointer to a char type
void *p; // a void pointer, does not point to any specific type, but can be "cast"
There is also the NULL pointer, which is any pointer initialised with the value NULL. This is a special pointer that when dereferenced will trigger a segfault — another way of saying the Operating System will stop the program due to skill issue. The NULL pointer is a safety mechanism that we should always check and use in C programs.
if (p != NULL) {
// dereference p
}
One very important property of pointers is that we can do arithmetic on them. More exactly, we can add numbers to pointers and we can jump thus to other mailboxes in contiguous order. Meaning that we are exploring mailboxes down the road in order.
int *p = &a;
printf("%p", p);
printf("%p", p + 1); // the next int in order
And the key property of pointer arithmetic is that the arithmetic keeps the type. Meaning that even if we do p + 123414 and p is of type int*, we will always have a mailbox of type int* that we are referencing.
This is closely related to how arrays work — an array name decays to a pointer to its first element when used in most expressions (see CS2), but the array itself has a fixed, allocated size the compiler and OS know about. That word allocated is extremely important: the OS will not yell at us for accessing some address with a pointer that came from an array. Instead it knows that region can be safely explored. It means that on the stack frame of the function where the array is declared, some space is allocated. Read more about what a stack frame is in CS2.
There is one very important consequence of stack frames in C: we cannot return a pointer to something initialised in a function back into the caller function. That means we cannot do something like this:
int* my_func() {
int a = 10;
int *p = &a;
return p;
}
This will result in a dangling pointer, since everything stored on the stack frame is deallocated when the function returns and our pointer still holds the address of that deallocated memory:
We will learn today how to deal with this kind of situation by accessing one of the most important features in C and in programming in general: the heap. But not before we talk about casting.
Have you ever tried dividing ints? Let's say you have this code:
int main(void) {
int a = 5;
int b = 2;
float c = 5 / 2;
printf("%f", c);
}
What do you think the result will be?
2.000000
But float is a Rational Number, why didn't we get 2.5 instead? The issue is that binary operators tie the operands together. If both operands are of the same type, the result will be of that type. If you add two ints, you will get an int. If you place two apples together you will still have apples, not interballistic missiles.
The 5 / 2 above is int / int, so the result is an int: namely 2, with the fractional part discarded. Only after that computation is finished does the 2 get assigned to the float c, becoming 2.0f. The division already happened; the damage is done.
However, if the two operands differ in type, C will implicitly convert one of them so they meet at a common type. Consider adding an int and a float:
int b = 3;
float c = 3.5f;
float d = b + c; // b is promoted to float, then addition happens: d = 6.5f
The int b was implicitly promoted to a float so that + had two same-typed operands to work with. This is called arithmetic conversion, and it follows a rule:
.png)
In arithmetic, the wider (more expressive) datatype wins. Floating-point beats integer (a
floatordoublecan hold values anintcannot). Within the same family, the type with more bytes wins (alongbeats anint, adoublebeats afloat).
A float and an int in the same expression? Both become float. A double and an int? Both become double. The compiler protects the more expressive type. It is like a mini battle: whoever can represent more values wins.
Why doesn't the implicit conversion happen the other way?
Because, if we were to convert a double down to an int we would need to cut some bytes: the double has 8, the int has 4. Half the bytes would have to go and we do need to be really sure we won't be losing something important in these 4 bytes.
Implicit conversion is dangerous. Take this example:
-1 < 1u. Theusuffix tells C that1uis anunsigned int. For the comparison, the two operands need to meet at a common type and since one is unsigned, the signed-1gets converted tounsigned int(check the chart above). In two's complement,-1as unsigned becomes4294967295(see E2's binary representation for the mechanics). So the expression becomes4294967295 < 1, which is false. Yes:-1is not less than1in C when unsigned enters the picture.
But why does
int a = 2.3f;compile ? The "wider wins" rule above applies to arithmetic, with these operands:+,-,*,/,<,>, and so on. In an assignment the rule is different: the type on the left wins by definition. You asked for anint, C gives you anintand the right-hand side gets converted to fit, whether that means widening (float c = 3;into3.0f) or narrowing (int a = 2.3f;into2, fractional part chopped). The compiler will warn you about narrowing (if you have the compile flag-Wconversion), but it will not refuse to compile. This is the C philosophy on types: if you asked for it, then the compiler assumes you know what you're doing. Assignment, function parameter passing, andreturnall follow this LHS-wins (Left Hand Operator) rule.
Now, how can we convert down a float into an int explicitly, when we actually want it and don't just want the compiler grumbling in the corner?
Casting is the procedure of telling the compiler that a datatype needs to be reinterpreted as another datatype. C has a single cast syntax: (new_type) value. The behaviour it triggers depends on what you're casting:
A conversion cast can be the following:
int my_float_is_rip = (int) 1.4f;
We are explicitly telling the compiler that we are OK with losing some data and we want to downcast our values/variables. Then a computation happens: it is usually an approximation required to fit the variable into its new datatype. When we cast 3.4f to int we will get 3:
.png)
This is the whole mechanism of conversion casting in broad strokes, and the outcome is usually pretty intuitive: downcasting a float into an int yields an approximated int. An int downcasted into a char gets effectively chopped: just the least significant single byte is kept in order to fix into a char's 1 byte.
int main(){
unsigned star = 42; // 0101010
unsigned star2 = 298; // 256 + 42 = 1 00101010
unsigned star3 = 554; // 512 + 42 = 10 00101010
char cstar = (char) star;
char cstar2 = (char) star2;
char cstar3 = (char) star3;
printf("%c\n%c\n%c", star, star2, star3); //%c for character
}
This code will print:
*
*
*
When downcasting we usually expect the most significant bits to be cut away.

This is where we explore a new way of pointer acrobatics. Pointer casting allows us to change the way the compiler interprets an address on the fly. We tell the compiler that the bits that live at a certain address must be reinterpreted as they are as bits in the new datatype.
int a = 65; // 4 bytes on the stack, holding the value 65
int *ip = &a; // ip is int*, points to a
char *cp = (char *) ip; // cp is char*, points to the SAME address
printf("%d\n", *ip); // reads 4 bytes as an int: 65
printf("%c\n", *cp); // reads 1 byte as a char: 'A' (ASCII 65)
Running it:
65
A
Here we have the same address, the same values in memory but we tell the compiler to change only the label. When we use pointer casts we DICTATE The compiler that at that address we have a char instead of an int.
This is what "reinterpretation" means. Say you have a pencil. Its purpose is to draw. However if you stick it into the ground and tie a flower to it, it becomes a pole for that flower. It is the same pencil but you have reinterpreted it as a pole. Or, here's a more historical example: 0 historically did not mean at first "nothing". Its first purpose was to be placed near another digit - 3 for example: to form a 30. It was only later on reinterpreted to mean "nothing".
That's exactly what pointer casting is.
Let's see another example: both float and int are 4 bytes wide, but they encode numbers using completely different rules: a float uses IEEE 754 (sign + exponent + mantissa), an int uses plain two's complement. So the same 4 bytes read through a float * versus an int * give wildly different numbers:
float pi = 3.14f;
float *fp = π
int *ip = (int *) fp; // cast float* to int*
printf("%f\n", *fp); // reads 4 bytes as a float
printf("%d\n", *ip); // reads the SAME 4 bytes as int
Running it:
3.140000
1078523331
fp and ip point to the exact same memory, but dereferencing through fp interprets those bytes as 3.14 whilest dereferencing through ip interprets them as 1078523331. Now nothing prevents us from operating on the int pointer. Let's see what happens if we multiply the int value with 1.5f and store it back into the pointer.
#include <stdlib.h>
#include <stdio.h>
int main(void){
float pi = 3.14f;
float *fp = π
int *ip = (int *) fp;
*ip = (*ip) * 1.5f; // evil stuff here
printf("%f\n", *fp);
printf("%d\n", *ip);
}
Running it:
68437825837428899840.000000
1617785088
Our floating number exploded, although the int increased by only 50%. This is because of the floating point representation. The mantissa (the part in the fp representation that is responsible for the number of digits after the .) effectively exploded when we modified the int. We will go deeper into the floating point representation in binary, you can read about it here: fp. the point I wanted to make is that this paves a landing for pretty clever tricks like Quake III's q_rsqrt that I heavely suggest you to learn about. Here's a link to a very good documentary: documentary link
Now, remember:
Pointer casting is dangerous precisely because it does nothing at runtime. The compiler trusts you completely. Two ways this hurts:
- Reading past the memory. Cast a
char *(1 byte) to adouble *(8 bytes), then dereference, and you're reading 7 bytes of whatever happens to sit next to your char. Undefined behaviour: usually garbage, sometimes a crash, occasionally a security bug.- Alignment. Some architectures require certain types to sit at addresses that are multiples of their size (a
doubleat an address divisible by 8, for example). Cast a randomly-aligned pointer todouble *and dereference on such an architecture, and your program crashes with a hardware trap.But take this as a rule of thumb: only reinterpret addresses that you are sure you want to.
Until now we've seen quite a few times this obscure name: void. It's is C's way of writing **"there is literally no value here.". It is simply void of anything. There are three common instances where you'll meet void:
1. As a return type: "this function returns nothing."
void say_hello() {
printf("Hello!\n");
// no return statement needed; there's nothing to return
}
Try to force something out of a function that is explicitly said it returns void and you'll get a compiler error. The actual reason behind this is that if you do:
int result = say_hello();
You will be doing an implicit conversion from void to int and that's the actual illegal part. You cannot bring something out of void.
2. In (void) parameter lists: "this function takes nothing."
int main(void) is the classic example. The (void) inside the parentheses is a special syntactic form meaning "this function takes zero parameters, please type-check my calls." (Historically, an empty () in C meant "unspecified parameters". dangerous, because any call would compile. (void) was invented as the explicit way to say "actually zero.")
3. And finally the void * pointer type:
void * pointer typevoid * is the actual honest pointer type: it holds an address and it does not specify what type it points to, it does not impose any restrictions whatsoever. I have to emphatise again that it still holds an 8 byte value, as opposite to a void that just means literally nothing. It just points towards, well, anything, since technically anything can be "voided".
This means that we can cast a void * to any type. Not only that, if we do want to get something of an address referenced by a void * we will need to cast it. Let's see with an example:
void *p; // p holds an address, but to what? Anyone's guess.
int a = 42;
p = &a; // legal — void* accepts any address
printf("%d", *p); // ILLEGAL — the compiler doesn't know how to
// read what's at p (how many bytes? what type?)
Cast it to a specific pointer type first, such that the compiler knows what are we working with:
int *ip = (int *) p;
printf("%d", *ip); // now the compiler knows: read 4 bytes as an int
It is kinda like a running a camera filter through your smartphone: you can make a person look like anything: superhero, cat, watermelon. You just make a cast (change the filter) and the person now looks like that. Your phone is the void * pointer. Cast it and the person it is pointed at takes any shape you want.
The only caviat: it is not a read only view: If you slice the watermelon in the phone you actually act on the "person" behind it as well :).
Anyways, remember: a void * can be casted to anything
We need
void *pointers in two cases:
- When we work with memory - we are operating on raw bytes.
- When we geniunely do not want to specify the type that we will work with. This is necessary in order to implement
genericity- meaning code that can be reused for more types. You can read more in the philosophy annex of this course.
Bonus: the discard cast. You'll occasionally see
(void)used as a value cast:(void) printf("hello");. This doesn't destroy the return value ofprintf— it evaluates the call and then throws the result away, in a way that tells the compiler "yes, I knowprintfreturns something; no, I don't want it." Its main use in real code is silencing "unused return value" warnings.
Remember when we said that you cannot return pointers to variables declared in the scope of a function because they are automatically deallocated when a function returns and the stack frame is popped? Well, we've seen that C is notoriously good at offering us solutions to any kind of restriction it imposes. Even (primitive) datatypes can be casted explicitly to almost anything we want. Now we will learn to escape the great wall of stack frames into the heap.
Why do we have stack frames in the first place? Check CS2's annex for a reminder.
Enter the heap — when we explicitly tell the compiler that we want to keep some variables around for longer. The heap is a region in memory (just like the stack) where we can store variables for as much time as we want, but we take the responsibility of free-ing those variables ourselves. It is like that table/chair where you store all the stuff that you might need soon but don't know where to place.
.png)
Before we look at how you actually put variables on that table, we'll need one small tool.
sizeofsizeof is a special operator (not a function — it's evaluated at compile time) that returns the size in bytes of any datatype or variable. We will discover how to create custom datatypes soon, and this operator will save us the pain of calculating the size of them by hand. Here's how to use it:
int main(void) {
int a = 10;
printf("%zu", sizeof(a)); // size of the variable a
printf("%zu", sizeof(int)); // we can even pass a primitive type as parameter
}
This will output:
4
4
The size of an int is indeed 4 (you remember that from E2). And that's the same size as a variable of type int.
%zu, not%d.sizeofreturns a value of typesize_t, which is an unsigned integer type. On 64-bit systems it's 8 bytes wide, wider than anint. Passing it toprintfwith%dis technically undefined behaviour (it happens to work on most machines, but don't rely on it). Use%zu, the format specifier designed forsize_t.
malloc, calloc & reallocThese are the tools that we'll use to put variables on the heap. They all come from <stdlib.h>, so add this include to the top of your source file:
#include <stdlib.h>
malloc:malloc means (memory allocate). It has one job: tell it how many bytes you want, it goes to the OS, asks for that chunk of the heap, and hands you back a pointer to where the chunk starts.
Signature:
void *malloc(size_t size);
Now, you do not really tell malloc the type that will live in the chunk you are requesting it to provide. It will simply return a pointer to that chunk and further you can store whatever you want there as long as it fits. Sounds familiar? That's the reason we have void * as a return type here. Your only job is to cast it into whatever you want it to be.
The counterpart is free: the function that gives the chunk back to the OS when you're done. free takes the pointer you got from malloc and marks that chunk as available again:
void free(void *ptr);
Every malloc must eventually be matched with a free.
Let's see some examples for this.
int on the heap#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = (int *) malloc(sizeof(int)); // ask the heap for 4 bytes, get a pointer back
if (p == NULL) return 1; // malloc can fail, more on this soon
*p = 42; // store 42 in that mailbox
printf("%d\n", *p); // 42
free(p); // give the bytes back to the OS
return 0;
}
Walking through what happens:
malloc(sizeof(int)) asks the heap for sizeof(int) bytes.malloc returns a void * pointing at those bytes.(int *) pointer-casts the generic address to an int *. Now the compiler knows that at that address there is an int.*p = 42 writes 42 into those 4 bytes.free(p) tells the heap: "I'm done with these bytes, you can hand them to someone else now.".png)
Remember the broken function from earlier in this lesson?
int* my_func() {
int a = 10;
int *p = &a;
return p; // dangling! a lives on the stack frame, dies when my_func returns
}
The a lived on my_func's stack frame, and the moment my_func returned, that frame was thrown away and p was left pointing at deallocated memory. Now let's put the variable on the heap instead:
int* my_func_fixed() {
int *p = (int *) malloc(sizeof(int));
if (p == NULL) return NULL; // Now this becomes the caller's problem.
// Do you remember the NULL pointer checks?
*p = 10;
return p; // p points into the heap!
}
The bytes at p now live on the heap, not on my_func_fixed's stack frame. When the function returns, its stack frame is popped as usual, but the heap memory stays right where it is. The caller receives a pointer that's still valid.
There's a catch, though: now the caller has to free it. The function that allocates is not necessarily the function that frees, and this creates an ownership question: who's responsible for cleaning up? By convention, when a function returns a heap-allocated pointer, it is transferring ownership of that memory to the caller:
int *result = my_func_fixed();
if (result == NULL) return 1;
printf("%d\n", *result);
free(result); // caller's responsibility
Every real C library has to answer this question for every function that returns a pointer. "Does the caller own the return value, or does the library keep it?" is one of the first things you'll learn to look for in documentation.
This is where the heap becomes indispensable. On the stack, an array's size must be known at compile time since you can't write int arr[n] for arbitrary n. On the heap, however the size can be whatever you decide at runtime:
int n;
printf("How many readings? ");
scanf("%d", &n);
int *readings = (int *) malloc(n * sizeof(int)); // room for n ints in a row
if (readings == NULL) return 1;
n * sizeof(int) is enough room for n ints packed next to each other. malloc gives back the address of the first int, and (remember from CS2: arrays and pointers are two views of the same thing) you can then use readings[i] exactly like a stack-allocated array:
for (int i = 0; i < n; i++) {
readings[i] = i * i; // fill with squares
}
for (int i = 0; i < n; i++) {
printf("%d ", readings[i]);
}
free(readings); // ONE free for the whole block
One malloc, one free. You do not free(readings[i]) for each element, you only free(readings) once, and the entire block of n * sizeof(int) bytes is released.
Bonus:
calloc. Same idea asmalloc, with two small changes:
- It takes two arguments — number of elements and size of each — instead of one total byte count:
calloc(n, sizeof(int))gives you room fornints.- It zeros out the memory before handing it to you.
malloc's bytes contain whatever junk was there before;calloc's bytes are guaranteed0.int *readings = (int *) calloc(100, sizeof(int)); // 100 ints, all initialised to 0Trade-off:
callocis slightly slower because zeroing takes time. Reach for it when you want clean bytes; stick withmallocwhen you're going to overwrite everything right away anyway.
Bonus:
realloc. Resizes an existing heap allocation. You hand it the old pointer and the new size; it either grows or shrinks the block in place, or copies your data to a fresh, larger block and gives you the new address:int *readings = (int *) malloc(10 * sizeof(int)); // ... fill it, use some of it ... readings = (int *) realloc(readings, 20 * sizeof(int)); // now 20 ints of roomThe classic use is a growable list: start with room for 10, and when you fill it,
reallocto 20, then 40, then 80. This is how dynamic arrays (Python'slist, C++'sstd::vector) work under the hood.Watch out:
realloccan move the block. If it does, your old pointer becomes invalid. Always assign the return value back to a pointer (like above) — never assume the address stays the same.
The heap is powerful. It's also where most memorable C bugs come from. Let's see some classic issues that arise with the heap allocations.
1. malloc returning NULL.
What happens if you'll try to malloc the equivalent for 20PB? (for reference, a PB = 1000000 GB, one million GB and to give you an ideea your modern laptop has around 16GB of RAM). This is a number your PC will proabably be unable to allocate this huge chunk on the Heap in RAM. malloc has a way to signal that an allocation failed by returning a NULL pointer. In case the pointer is not null we are guaranteed to have a chunk allocated on the Heap.
int *p = (int *) malloc(sizeof(int));
if (p == NULL) {
printf("malloc failed!\n");
return 1; // bail out gracefully
}
*p = 42; // now safe
On the AirBox microcontroller (an ESP32S2) with 320 KB SRAM you will happen to hit the heap limit. Check every malloc.
2. Memory leaks.
If you malloc and never free, those bytes stay reserved for your program until it exits. It is not a big deal if you'll do it once on a machine with a lot of RAM (again, you computer will not feel it if you malloc 300kb and forget to free it: once the program ends the memory is freed anyways). However on a microcontroller one forgotten allocation might leave you questioning NULL pointers for hours.
Here's an example that will leak memory even on a modern computer: though we have small allocations these are acumulated due to the loop:
while (1) {
int *p = (int *) malloc(sizeof(int)); // 4 bytes leaked every iteration
// ... do something with p ...
// FORGOT TO FREE
}
Every iteration reserves 4 more bytes that nobody can ever reclaim. Run this loop for a few minutes and you'll eat all the RAM on your machine. Fun fact, the memory leaks are the number one reason long-running server programs crash after days of uptime.

3. Double-free.
freeing a pointer that has already been freed is undefined behaviour, and usually a crash:
int *p = (int *) malloc(sizeof(int));
free(p);
free(p); // BAD — double-free
The first free returned those bytes to the heap's internal free list. The second free tries to return them again, corrupting the free list's bookkeeping. Depending on your libc implementation, this might crash immediately, crash later at some unrelated malloc, or (worst case) give an attacker a way to hijack your program.
A defensive habit: set the pointer to NULL right after freeing. free(NULL) is safe (it's a no-op), so setting p = NULL after free(p) neutralises any accidental double-free:
free(p);
p = NULL;
free(p); // no-op, safe
4. Use-after-free.
Reading or writing through a pointer after the memory has been freed. Also undefined behaviour, and possibly the most insidious of the four because the code often appears to work:
int *p = (int *) malloc(sizeof(int));
*p = 42;
free(p);
printf("%d", *p); // BAD — reading freed memory. Might print 42, might crash,
// might print garbage, might expose data from another allocation.
The bytes at p still hold 42 for a brief moment after free, but the heap now considers those bytes available and might hand them to the next malloc at any time. When that happens, your *p keeps reading the same address, which means your two "unrelated" pieces of code are silently sharing memory. Debugging this is agony: the bug appears intermittently, only under specific allocation patterns, and often only in production. This is exactly the dangling pointers that we've seen in CS2 with stack frames.
Same defensive habit works here: p = NULL right after free(p).
The one rule of heap discipline. For every
malloc(x), know exactly which line of code willfree(x). If you can't point at that line, you have a leak in the making. Ownership discipline — "who's responsible for freeing this?" — is the single most important habit in writing C that doesn't rot.
Up until now, all our variables have been of "primitive" types: int, float, char, or pointers to those. We could bundle them into arrays when we wanted many of the same kind. But what if we want to bundle different kinds of data together? What if we want to keep a person's age (int), their name (char *), and their height (float) all in one place?
We could of course keep three parallel arrays — int ages[], char *names[], float heights[] and hope we never mismatch the indices. The "hope" should not be really an option when talking about deterministic devices. C offers us a much better tool: the struct.
A struct is a compound datatype that groups multiple named fields under one type. Think of it as designing your own custom mailbox with several compartments, each of a different type, all under a single label.
Here's a concrete AirBox case: a single sensor reading has a timestamp (int), a PM2.5 measurement (float), and a humidity measurement (float). We can group these into one struct:
struct Reading {
int timestamp;
float pm25;
float humidity;
};
Structs are strictly declarations, just like function declarations they end with ;. We do not initialise any value in a struct.
This struct that we just declared creates a new type called struct Reading. The Reading bit is called the tag. From now on, we can use struct Reading anywhere we'd use int or float.
Once declared, we can instantiate a struct:
struct Reading r; // uninitialised — junk in each field
struct Reading r2 = {1234, 12.5f, 65.0f}; // initialised in declaration order
// or use designated initialisers (C99+):
struct Reading r3 = {.timestamp = 1234, .pm25 = 12.5f, .humidity = 65.0f};
The designated initialiser syntax is worth preferring since it's explicit about which field is which, and reordering the fields in the struct definition later won't silently break existing initialisers.
Once we have an instance, we access each field with the dot operator .:
r.timestamp = 1234;
r.pm25 = 12.5f;
r.humidity = 65.0f;
printf("%d %f %f", r.timestamp, r.pm25, r.humidity);
Think of r.pm25 as "reach into the mailbox r and grab the compartment labelled pm25."
-> operatorHere's where structs tie back beautifully to the heap. We can have pointers to structs, just like pointers to anything else:
struct Reading r = {.timestamp = 1234, .pm25 = 12.5f, .humidity = 65.0f};
struct Reading *pr = &r;
Now how do we access the fields through the pointer? We could write:
(*pr).pm25 = 15.0f; // works, but ugly
We first dereference the pointer to get the struct, then apply . to grab the field. The parentheses are required because . has higher precedence than *.
C offers a much cleaner shortcut: the arrow operator ->.
pr->pm25 = 15.0f; // same thing, much cleaner
pr->pm25 means exactly (*pr).pm25. Whenever you have a pointer to a struct, use -> to reach into its fields. It's just the same operation, but a different syntax.
However onyl use -> for pointers to structs and . for direct struct access.
typedef struct: giving your struct a cleaner nameWriting struct Reading everywhere gets tedious. C has a keyword typedef that lets us create aliases for types:
typedef struct {
int timestamp;
float pm25;
float humidity;
} Reading;
Now Reading alone (no struct prefix) is the type name. We can use it directly:
Reading r = {.timestamp = 1234, .pm25 = 12.5f, .humidity = 65.0f};
Reading *pr = &r;
This typedef struct { ... } Name; idiom is the standard way to declare structs in modern C. Stick with it going forward.
Since a struct is just another datatype, we can allocate one on the heap using malloc:
Reading *pr = (Reading *) malloc(sizeof(Reading));
if (pr == NULL) return 1;
pr->timestamp = 1234;
pr->pm25 = 12.5f;
pr->humidity = 65.0f;
// ... use it ...
free(pr);
Notice sizeof(Reading) — we ask the heap for exactly enough bytes to hold one Reading, and sizeof computes that for us so we don't have to add up sizeof(int) + sizeof(float) + sizeof(float) by hand. This is precisely why we introduced sizeof before malloc: for custom types, hand-calculating the size is fragile and often plain wrong.
And notice pr->pm25 — since pr is a pointer to a struct on the heap, we use -> to access its fields. Every heap-allocated struct will be accessed through ->.
A small warning about
sizeof(struct). The size of a struct is not always the plain sum of its fields' sizes. The compiler inserts invisible padding bytes between fields for alignment reasons (some CPUs are much faster, or outright refuse, to read misaligned data). Sosizeof(Reading)might be 16 bytes instead of the naive 12 (4 + 4 + 4), depending on the target architecture. That's exactly why we always usesizeof(Reading)and never hand-calculate.
You can pass a struct to a function by value or by pointer, just like any other type:
// By value: the whole struct is copied into the function's stack frame
void print_reading(Reading r) {
printf("%d %f %f", r.timestamp, r.pm25, r.humidity);
}
// By pointer: only the address (8 bytes on 64-bit) is passed
void print_reading_by_ref(Reading *r) {
printf("%d %f %f", r->timestamp, r->pm25, r->humidity);
}
For small structs, passing by value is fine — the copy is cheap. For large structs (say, one with 20+ fields), passing by pointer is much faster because only 8 bytes get copied instead of the whole struct. More importantly, passing by pointer lets the function mutate the caller's struct. Passing by value hands the function a private copy that gets thrown away when the function returns; any modifications the function makes are lost.
A convention that will save you time. In real C code, structs are almost always passed by pointer, even small ones. It keeps things consistent, avoids performance surprises when the struct grows over time, and lets you mutate the caller's data when you need to. The only exception is when you deliberately want an isolated copy.
Naturally, we can have arrays of structs, both on the stack and on the heap:
// Stack: fixed size known at compile time
Reading history[10];
// Heap: size decided at runtime
int n;
scanf("%d", &n);
Reading *history = (Reading *) malloc(n * sizeof(Reading));
if (history == NULL) return 1;
Access is with [] for the index, then . for the struct's field:
history[0].pm25 = 12.5f;
history[0].timestamp = 1234;
for (int i = 0; i < n; i++) {
history[i].pm25 = read_sensor();
}
free(history); // ONE free for the whole block
This pattern — an array of structs on the heap — is one of the most common data structures you'll write in C. AirBox uses it every time we accumulate a batch of sensor readings before uploading them.
Up until now, all our code has lived in a single .c file. That's fine for tiny programs, but real projects don't work like that. A real project is split across many files — often dozens or hundreds — for several reasons:
C provides mechanisms for splitting code across files, but the model is a bit peculiar: the compiler compiles each .c file completely on its own, in isolation. Only after all files are compiled does another program called the linker stitch them together into one executable.
.c and .h file splitC distinguishes two kinds of source files:
.c files contain the implementation — the actual function bodies and (usually) the global variables..h files (called headers) contain the interface — declarations of functions, structs, and constants that other files need to know about, but not the actual implementation.Rule of thumb: a .h file tells the compiler "these things exist, and here are their signatures"; the corresponding .c file provides "and here's how they actually work".
Here's a concrete example. Say we want a small module for our AirBox sensor:
sensor.h (the interface):
#ifndef SENSOR_H
#define SENSOR_H
typedef struct {
int timestamp;
float pm25;
float humidity;
} Reading;
// Reads one measurement from the sensor
Reading sensor_read(void);
// Prints a reading to stdout
void reading_print(Reading r);
#endif
sensor.c (the implementation):
#include "sensor.h"
#include <stdio.h>
Reading sensor_read(void) {
Reading r;
r.timestamp = 1234; // in reality: read from hardware
r.pm25 = 12.5f;
r.humidity = 65.0f;
return r;
}
void reading_print(Reading r) {
printf("%d %f %f\n", r.timestamp, r.pm25, r.humidity);
}
main.c (uses the module):
#include "sensor.h"
int main(void) {
Reading r = sensor_read();
reading_print(r);
return 0;
}
Notice: main.c doesn't need to know how sensor_read works internally. It only needs to know that it exists and what its signature is. That's what sensor.h tells it. This is a form of abstraction — hiding implementation, exposing only the interface.
#include directive#include is a preprocessor directive. Before the compiler even looks at your code, a program called the preprocessor runs through it and, whenever it sees #include "somefile.h", it replaces that line with the entire content of somefile.h. That's it. Textual substitution, no more, no less.
Two forms:
#include <stdio.h>: system headers (angle brackets). The preprocessor searches system directories — this is how you access standard library and system headers.#include "sensor.h": user headers (double quotes). The preprocessor looks in the current directory first, then falls back to system paths.You can technically #include any file you want (even a .c file!), but by convention only .h files get included. Including a .c file will paste its function bodies into the current file, which causes multiple-definition errors as soon as another .c file also includes it — see below.
Now here's a classic C footgun. What happens if the same header gets included twice?
Say sensor.h gets included by main.c directly. That's fine. But what if sensor.h itself includes common.h, and main.c also includes common.h? Then during the preprocessing of main.c, common.h's content gets pasted in twice. If common.h declares a struct or a constant, that thing is now declared twice in the same file, and the compiler complains: error: redefinition of ....
Include guards are the classical solution. Wrap the entire contents of every .h file with these three lines:
#ifndef SENSOR_H // "if not defined, SENSOR_H"
#define SENSOR_H // now define SENSOR_H
// ... the actual contents of the header ...
#endif // end of #ifndef
The first time this header is included, SENSOR_H is not defined, so the block goes through — and by defining SENSOR_H, we mark it as "already seen". The second time the same header is #included in the same compilation, SENSOR_H is defined, so the entire block is skipped and no re-declaration happens.
Every header file must have include guards. No exceptions. The convention is to name the guard macro after the file: sensor.h → SENSOR_H, network_client.h → NETWORK_CLIENT_H.
Modern alternative:
#pragma once. Many compilers support a shorter, non-standard directive that achieves the same effect in a single line at the top of the header:#pragma once // ... header contents ...It's not officially part of the C standard, but it's supported by GCC, Clang, and MSVC — the compilers you're likely to use. Some codebases prefer it because it's less error-prone (you can't accidentally forget the
#endifor reuse the same guard name in two headers). Others stick with the classic#ifndefpattern for maximum portability. Both are fine — pick one and be consistent within a project.
.h vs .cThe rule of thumb, illustrated by our sensor example:
Put in the header (.h):
typedef struct { ... } Foo;)#define constantsextern (see below)Put in the source (.c):
static (private) helper functionsNotice the subtle but important distinction: declaration ("this thing exists, here's its signature") vs definition ("here is the actual thing"). A function declaration in a .h file tells everyone the signature. The function definition in the .c file actually allocates code for it. If two .c files both #include the header, they both see the declaration — but only one place in the whole program has the definition.
Put a function definition (with body) in a .h file and include it from two .c files, and the linker will complain about a multiple definition error, because the same function ended up compiled into two object files.
From the command line, using gcc:
gcc sensor.c main.c -o airbox
This tells gcc: compile both .c files, link the resulting object files together, and produce an executable called airbox. The compiler figures out on its own that both files depend on sensor.h via their #include lines.
Under the hood, this happens in two logical steps: each .c file is compiled to an intermediate .o object file separately, then the linker merges them:
gcc -c sensor.c -o sensor.o # compile only, no linking
gcc -c main.c -o main.o # compile only
gcc sensor.o main.o -o airbox # link the object files into one executable
For small projects the one-liner is fine. For larger projects you'll want a build system — Makefile, CMake, or similar — that manages this automatically and only recompiles the files that actually changed. That's a topic for another course.
static functions: hiding private helpersSometimes you want a helper function that's used only within one .c file and shouldn't be exposed in the header. Prefix its declaration with static:
// in sensor.c
static float calibrate_raw(int raw) {
return raw * 0.1f - 5.0f;
}
Reading sensor_read(void) {
int raw = read_hardware();
Reading r;
r.pm25 = calibrate_raw(raw); // only this file can see calibrate_raw
// ...
return r;
}
The static keyword restricts the function's visibility to this .c file. Other files can't link against it, so it stays a private helper. Use static for anything you don't want to expose in the header — it keeps your interface clean and lets you refactor internals freely without breaking callers.
externIf you need a variable that persists across function calls and is shared between files, declare it with extern in the header:
// in sensor.h
extern int sensor_count;
Then define it exactly once in some .c file:
// in sensor.c
int sensor_count = 0;
The extern int sensor_count; in the header tells every file that includes it: "there is a variable called sensor_count somewhere — I'm not defining it here, but you can use it." The int sensor_count = 0; in sensor.c is the actual definition. If you forget the extern in the header (writing just int sensor_count; there) and multiple .c files include it, each one gets its own definition, and the linker will complain about multiple definitions.
Global variables are dangerous. Every function that touches a global creates hidden coupling — code that looks independent turns out to share state through the global. In larger codebases this becomes a scaling headache. Prefer passing state explicitly through parameters where possible. Globals do have their place (especially in embedded systems where you can't easily pipe state through every function, or for genuinely shared resources like a logger), but reach for them deliberately.
#define macrosThere's one more preprocessor directive worth knowing: #define. It creates a macro — a name that gets textually substituted throughout the file before compilation:
#define MAX_READINGS 100
#define PM25_THRESHOLD 25.0f
// elsewhere in the code:
Reading history[MAX_READINGS]; // becomes: Reading history[100];
if (r.pm25 > PM25_THRESHOLD) { ... } // becomes: if (r.pm25 > 25.0f) { ... }
These are called constant macros. Convention: uppercase names, to visually distinguish them from variables. Use them for values you want to change in one central place — limits, thresholds, magic numbers, config settings. They're everywhere in embedded C code.
Macros can also take arguments:
#define SQUARE(x) ((x) * (x))
int a = SQUARE(3); // becomes: int a = ((3) * (3));
These "function-like macros" look convenient but have subtle pitfalls. If you drop the parentheses in the definition — #define SQUARE(x) x * x — then SQUARE(1+2) expands to 1+2*1+2 = 5, not 9. The rule of thumb in modern C: prefer regular static inline functions over function-like macros, and reach for macros only when you truly need textual substitution.
What are external libraries then? Remember open thread #1 from the CS3 intro? External libraries are just multi-file programs someone else wrote and packaged for you.
#include <stdio.h>pulls in the header of the C standard library, and when the compiler links your program it automatically links againstlibc(the compiled implementation of that standard library). Larger libraries likelibcurl(HTTP),SDL(graphics), orlibm(math functions likesin,sqrt) work the same way — you#includetheir header, and you pass-l<name>togccto tell the linker to also link against the library:gcc main.c -lmlinks against libm. Everything is just headers + object files, all the way down.