In CS1 we built the magic box from the ground up: history, the von Neumann architecture, the fetch-decode-execute heartbeat, the tower of abstraction, and finally you wrote your first C — hello, world, variables, control flow and your very first functions.
After that you've done E2, where we cracked open the byte itself: MOSFETs, gates, adders, latches, registers, memory as decoders-plus-storage. After E2 you should be able to point at a memory cell and explain, in transistors, exactly what's holding the bit.
Today we will build upon what you learned in CS1: we will talk some software architecture, what an array is in C, what a pointer is and why it's the single most useful idea in low-level programming. By the end of this course you should be able to see more flexibility in C, and it will slowly transition from a rigid stone into malleable clay — allowing you to use that infinitely flexible machine that can become anything from CS1.

some Chinese dude on a Chinese forum
A heads-up about the flavour of this course: CS2 is more workshop than museum. CS2 will double-down on putting C directly in your hands. We will still explore some historical catastrophes that emerged due to scale-limited engineering (a more corporate expression for Skill Issue) and observe how the string implementation in C led to bugs and vulnerabilities that occur to this day. When exploring software architecture, we'll point towards some clearly important persons who shaped the concepts of software engineering.
Now less talk, more do. Here's today's plan:
* and & operators, pointer types, pointer arithmetic.Heads up — annex. Some material we cut from this lesson because it isn't strictly needed to write working C — how function calls evolved historically, the deep mechanics of stack frames, software architecture 101, the truth about virtual memory, and the famous string-handling security disasters — lives in CS2 Annex: For the curious. Skip it if you just want to ship code; read it if you want to know why C is the way it is.
Let's suppose we are writing code for an air quality measurement station (our AirBox, actually). There is some sensor capable of measuring PM2.5 (A type of very small particles). We want to read the value and, let's say, display it. Using what you've learned from CS1 you'd write something like this to simulate your AirBox:
#include <stdio.h>
int main(void) {
double reading;
printf("Sensor reading: ");
scanf("%lf", &reading);
printf("PM2.5: %.2f ug/m3\n", reading);
return 0;
}
As a quick recap on what's going on here: programs in C are compiled top to bottom — this is called sequential programming. Compilation is the action of the compiler translating C code into machine code. Think of the compiler as a very specialised Google Translate: it takes your code written in C and "translates" it into the language your computer understands. Since there might be some "cultural differences" between you and the machine, the compiler might do some "optimisations" — meaning change some aspects of your program in order to make it easier for the machine to understand.
1. #include <stdio.h> is an include directive.
It tells the compiler that we are going to use some functions from an external library, namely stdio.h (which stands for standard input/output).
An external library is some code that somebody else wrote and shared with us to reuse. In the context of C, an external library is a bunch of header and source definition files. For now, don't worry about what these mean exactly — this is something we will work on in CS3. However, as a heads up: a header and a source definition together form, in a way, a "dictionary": you search for the function in the header by its name and its implementation lives, well, in the source implementation.
2. main, scanf and printf are all functions.
main is a special function that the C compiler recognises as the entry point of the program. void means that we do not take any parameters to this function.
Info:
maincan, however, take parameters: they're calledargcandargvand they carry command-line arguments, but for now that's beyond the scope of CS2. If you want to see more, read Kernighan & Ritchie 5.10: Command-line Arguments.
scanf is a function from the external library we just imported with #include <stdio.h>. Overall it gets(scans) the user input. It takes as its first parameter a "format parameter" — specifying in which format the data you input from the keyboard will be (in our case %lf means long float = double = a number with decimals). Its second parameter is a reference (or address) of the output variable.
If a reference is a mail address, then a variable is the mailbox itself. The function receives the reference, does its magic, goes to the address, and puts in that mailbox the value read from the keyboard. We will talk much more about this later in this course.
printf is also imported from stdio.h. It works a bit differently: it takes a template string with format parameters in it, and plugs the values of the variables you pass into those format parameters and displays it on the user screen. It's like preparing a form someone has to fill out — name, surname, etc. — with the values plugged into their places. The order of the format parameters should match the order of the variables that you pass to the function.
Now everything in the main function will be executed top to bottom:
double reading; — declares a variable of type double (a rational number, with decimals).printf("Sensor reading: "); — calls the printf function with a single parameter.scanf("%lf", &reading); — takes the user input as a long float (a double) and puts it into the reading mailbox, knowing the address.printf("PM2.5: %.2f ug/m3\n", reading); — displays the reading. %.2f means "a floating-point number with two decimals."return 0; — quits the program with a success status code.Functions are inherently reusable code blocks. The software engineering philosophy tells us that we should build good functions that do small but reliable actions which can be reused.
A function's variables are local — meaning variables declared in the scope of a function do not escape it. In the same way, variables declared outside a function do not leak into the function scope. This behaviour is called a strong architectural boundary — fancy words for "the thing is isolated."
What's a scope? The region of code where a variable exists and is accessible. In C, scopes are delimited by curly braces
{}. Everything you declare inside a pair of braces exists only until the closing brace; after that, the mailbox is taken down and its memory is reused. Functions have their own scope (the function body), and smaller scopes nest inside —ifblocks,forloops, or plain nested braces each create a sub-scope of their own.int main() { int a = 10; // a lives in main's scope if (a > 5) { int b = 20; // b lives ONLY inside this if-block printf("%d %d\n", a, b); // both a and b visible here } // b no longer exists here — the mailbox was taken down at the } above. // a is still fine; main's scope hasn't closed yet. }
A stack frame is the "temporary storage" of a function. When a function is called, a new frame is pushed onto the stack. When the function returns, the stack is popped and the frame is removed. If you need a recap on what a stack is, check back on CS1.
int sum(a,b){
return a+b;
}
int main(void){
int a=10;
int b=20;
sum(a,b);
return 0;
}
And one more topic to recap here before we dive further: when a function calls itself, the behaviour is called recursion.
It is like giving your friend a stick and telling him: take this stick and break it in half, but each time you break it in half you will break the half-stick in half again. Since a half-stick is still a stick, your friend will keep on breaking the stick — probably to infinity, until he gets tired or until it hits a stop condition.
void break_the_stick(int stick_length) {
if (stick_length <= 0)
return; // meaning stop
break_the_stick(stick_length / 2);
}
When a computer gets "tired" (the stack crosses its limit of frames — a safeguard so the memory doesn't get exhausted from infinite stick-breaking), its OS will throw a Stack overflow error and the program ends.
Time for some vocabulary. It is unavoidable in C, and worse, the words sound interchangeable until you sit down and look at them. They are NOT. The compiler treats them VERY differently, and once you have them straight, a whole class of weird error messages stops being weird.

A declaration announces the function's existence and shape. It is a promise to the compiler: "a function with this name, taking these parameters, returning this type, exists somewhere. I haven't told you what it does yet, but you can trust that it's coming."
A declaration is just the function's header followed by a semicolon; it has no body. Let's take this function that return the average of five numbers.
double average5(double a, double b, double c, double d, double e);
That's a declaration. The parameter names are optional in a declaration (the compiler only cares about the types); both of these are equivalent:
double average5(double a, double b, double c, double d, double e);
double average5(double, double, double, double, double);
Why have declarations at all? Because the C compiler reads your file top to bottom, exactly once, and it has to know about a function before you call it. If main is at the top of your file and calls average5, the compiler hits the call to average5 and asks "what's that? I haven't met it yet." A declaration at the top tells the compiler the shape of average5 early, so when it sees main call it, it can type-check the call correctly even though it hasn't seen the function's body yet.
Why bother with declarations? Because in a real program you'll have dozens of functions, often spread across multiple files. If you tried to physically arrange every definition before every caller, your code would be a tangled mess — and worse, two functions that call each other would be impossible (one of them has to be defined first). Declarations solve this elegantly: declarations at the top say "these exist", definitions below say "here's what they do." It also makes the top of a file a kind of menu of what the file offers, which is genuinely nice to read. Think them like a table of contents: all the chapters that will be in a book are there.
// table of contents here - easy to read
int sum(int a, int b);
int average3(int a, int b, int c);
int flarpify(int a);
void say_hello()
int main(){
// code here
}
// function definitions follow
A definition is the declaration plus the body. It's where you actually say what the function does:
double average5(double a, double b, double c, double d, double e) {
return (a + b + c + d + e) / 5.0;
}
A definition is also a declaration — you've also told the compiler the function exists and what shape it is, just incidentally, by providing the full thing. So if your function is defined above any caller, you don't also need a separate declaration.
The two rules to internalise.
- A function may be declared as many times as you like, as long as all the declarations agree on the shape. (Multiple identical declarations are harmless.)
- A function must be defined exactly once. Define it twice and the linker — remember the linker from CS1, the tool that stitches your object files together — will scream "duplicate definition" and REFUSE to produce an executable.
A prototype is just another word for a function declaration that explicitly specifies the parameter types. In modern C (anything from C89 onward, so basically all the C you'll ever write), all declarations are prototypes anyway. Prototype = declaration. Same thing.
Old C had a weaker form of declaration without parameter types; the term prototype exists to distinguish modern, properly-typed declarations from those ancient ones. Today you can use the words interchangeably.
So this:
double average5(double, double, double, double, double); // prototype = declaration
is the same thing as a declaration, fully typed.
The word signature doesn't have one universal meaning in C: it depends on who's looking at the function.
The cleanest, most useful definition for us is: a function's signature is whatever uniquely identifies it. What that is, in practice, depends on the context:
You can see this with your own eyes. Take this trivial function:
int sum(int a, int b) { return a + b; }
Compile it as C, then compile the identical source as C++, and ask the linker what it sees. Roughly:
C: sum
C++: _Z3sumii (i.e. "sum taking two ints")
To see this for yourself, pop a function into godbolt.org and toggle between C and C++.
For the day-to-day definition of signature we'll pick: return type + name + parameter types. That's enough to fully identify a function for almost every purpose, including when you have to write its declaration to match the definition exactly.
Three quick reminders to keep these straight from now on:
The vocabulary above is mechanical; it just nails down which line of code is doing what for the compiler. The slightly deeper idea sitting just underneath it is probably the most important concept in all of software engineering.
When you read this declaration:
double average5(double a, double b, double c, double d, double e);
You have learned almost everything you need to use this function. You know its name. You know it takes five doubles. You know it returns a double. You know, by virtue of the well-chosen name, what it's for. You don't know how it does it, and you don't need to. Maybe it sums the five and divides. Maybe it uses some clever trick to avoid floating-point error. Maybe it phones an oracle in another country and asks them. From your perspective, you quite frankly don't care. You hand it five numbers, you get back the average. That's a contract, or in the CS jargon:
The interface: a set of rules, mechanisms, and structures that enable communication between different components of a system.
Let's see some examples of interfaces that you pass by almost every day without thinking of them like this:
printf function declaration.The concept of interfaces allows for the definition of boundaries between components. From here, branches of computer science like software architecture and system integration have emerged. If you want to read more about architecture & integration, I'll leave a book in the references section.
Exercise — spot the interface. Look at three technologies you use daily (your phone, an app you like, a home appliance, a website). For each, write two sentences: what's the interface? (what do you touch, press, tap, or type?) and what implementation does it hide from you? (what's happening on the other side that you don't have to think about?). This isn't a coding exercise; it's an eye-training exercise. Once you start seeing interfaces everywhere, you can't unsee them.
In C we can pass variables to functions in one — and I want to REALLY emphasise this — one way:
// declaring sum
int sum(int a, int b);
int main(void) {
int a, b; // give them some value
sum(a, b); // calling this will NOT modify local a and b.
return 0;
}
int sum(int a, int b) { // a and b are copies
a = b;
b = 10 + a;
return a + b; // return the sum
}
This means that whatever happens to the variable in the function, stays in the function. After all, variables declared inside the scope are local, right?
But we've seen that somehow, scanf manages to update a variable back in the main function without using the return statement!
Until now we know that:
A quick note before we go further. The address you see printed with
%pisn't literally where your data sits on the RAM chip. Every process gets its own private virtual address space, and there's a hardware layer (the MMU, or memory management unit) translating what your code sees to what's physically on the chip. For the C we're writing on a laptop, you can treat them as the same and be fine. On embedded systems (like the AirBox microcontroller later in this bootcamp), there's no MMU, and the address really is physical. See the annex for the full story.
Then if the function mechanism copies the value of the parameter onto the stack frame, why not pass the mailbox address — the address of the variable — and operate on that?
That's exactly what scanf does with its output parameter when we use the address-of operator on a variable.
int main(){
int a;
scanf("%d", &a);
}
&You've seen operators in CS1: +, -, *, !, >>, <<, ^ — these are all operators. And & is another one. It is a unary operator, meaning that it operates on a single operand — in our case, a variable — and outputs its address. Let's see it with our own eyes:
#include <stdio.h>
int main() {
int a = 10;
printf("Address: %d", &a);
}
Compiling and running, we get:
Address: 1663789332
That's exactly the address of our variable! We can also see that it can be written as an integer value, exactly. But if you remember E2, you've seen that addresses are usually written in HEX, not in decimal representation. Swap %d for %x:
Address: d7baac24 // note that the address changed; can you tell why?
Some keen eyes might say that
&stands also for binary AND. That's right, and there is one key property of operators that allows&to behave in two ways: operators are context aware:
- Case 1:
&is used as a binary operator = binary AND.1 & 2(base 10) =01 & 10(base 2) =00(base 10).- Case 2:
&is used as a unary operator = address-of.
Using this operator we can send the address of a variable to an external function. Now we only need a way to get the values out and into the address.
*Probably the most hated symbol in the entirety of C is the * operator. It is called the dereference operator and it takes out the value stored in the mailbox.
Let's see it in action:
#include <stdio.h>
int main() {
int a = 10;
int b = *(&a); // We get the address. Then we check what is stored at that address
printf("%d", b);
}
Running it, we get, quite expected:
10
But why can't we write int b = &a; directly?
Remember: C strictly FORBIDS storing address values in regular variables. If we could, then we should also be allowed to dereference any
intvariable. Think of it again: if anyintcould store an address, then we should be able to dereference anyint, right? What would be the type of the value dereferenced if ourintstored the value0x10? The compiler needs to know that, since the types must be deterministic.
If we still try to compile this code:
#include <stdio.h>
int main() {
int a = 10;
int b = &a; // try to store
}
The compiler will scream at us (and even suggest a fix):
./main.c: In function 'main':
./main.c:5:15: error: initialization of 'int' from 'int *' makes integer from pointer without a cast [-Wint-conversion]
5 | int b = &a;
| ^
C provides us with two ways of solving this issue:
These are a class of data types. For every type you can think of, there is a pointer type — since everything lives in memory.
Here's a list of the most common pointer types:
int*char*double*void* — pointer to "something". The compiler doesn't know the type at the address, and you can't dereference a void* directly without casting first. Useful when you genuinely don't know or don't care.int *p; // p is a pointer-to-int
int* p; // same thing
int * p; // also same thing
The whitespace around * doesn't matter to the compiler. Pick a style and stick with it. (The two camps in C: "the * belongs to the type" — int* p, vs "the * belongs to the variable" — int *p. We'll write int *p here.)
Putting it all together — declare, take address, dereference:
#include <stdio.h>
int main() {
int a = 10;
int *p = &a; // p holds the address of a
printf("a = %d\n", a); // value of a
printf("&a = %p\n", &a); // address of a
printf("p = %p\n", p); // p holds that same address
printf("*p = %d\n", *p); // dereference: value AT the address p holds
}
Compiling and running:
a = 10
&a = 0x7ffe1a2bf3cc
p = 0x7ffe1a2bf3cc
*p = 10
Notice &a and p print the SAME hex value and they ARE the same address. And *p gives us back 10, the value sitting at that address. The two operators are inverses: * undoes &, and you can write *(&a) as a needlessly complicated way to say a.
Type matters — that's the whole point of pointer types. The type tells the compiler:
- How many bytes to read when you dereference (an
int*reads 4 bytes, achar*reads 1, adouble*reads 8).- How to interpret those bytes (signed integer? floating-point? a single character?).
- How to move when you do pointer arithmetic — see the next section.
If you mix types, the compiler complains:
int a = 10;
double *p = &a; // ERROR: incompatible pointer types
error: initialization of 'double *' from incompatible pointer type 'int *'
The address &a is of type int* — it points to an int-sized mailbox. Assigning it to a double* would let you read 8 bytes from a 4-byte mailbox, which is exactly the kind of "reach over the fence and grab whatever's there" disaster C wants to prevent. You CAN force it with a cast, but you're declaring you know what you're doing.
A pointer that doesn't point anywhere is NULL:
int *p = NULL;
NULL is a special address value (effectively 0) that means "this pointer is not pointing to a real mailbox." It's the C way of saying "empty hand." Dereferencing NULL (trying to read what's at address 0) will crash your program as the OS forbids reading address 0, and a crash with that cause is called in C a segmentation fault.
Fun fact, the creator of NULL, Tony Hoare, called it the "billion dollar mistake". NULL itself was a cool concept, it fixed the issues of uninitialised pointers. However what it did COMPLETELY WRONG was that it was not an enforcement. You can skip checking if the value returned by a function is NULL and your program will crash when you try to dereference it at runtime. Newer languages like Rust, Haskell and C++'s newer editions fix this by introducing a special type that requires explicit checking. See for example Rust's
Option<T>.
In C we use NULL to:
strchr returns NULL if the character isn't found).if (p != NULL) { ... }.Always. I repeat: ALWAYS initialise a pointer with NULL before you know the address. And ALWAYS check for NULL when working with functions returning pointers.
We have seen that you can extract the value of the variable with a pointer. But what happens when you change it?
int a = 10;
int *p = &a;
*p = 20;
printf("%d\n", *p);
printf("%d", a);
20
20
So we have changed the value that pointer referenced and the variable value changed as well?
Well, of course, since we still have operated on the variable's address.
This tells us some pretty important insights about pointers:
What happens if you accidentally set both the first entry and the second entry to point towards the second track?
When we introduce an abstraction layer so flexible as pointers, there is a lot of cases where behaviour can break. It's left to the programmer to properly use the tools C gives in order to reduce such errors as much as possible. We have already explored such a tool — the pointer types — and this one is explicitly enforced. We will explore more such tools at the end of this course and in CS3, but for now remember:
Use pointers only when necessary.
Let's go back to functions. We can set the parameter types to be of pointer as well, and this allows us to modify variables outside of the function scope.
void increment(int *i) {
(*i)++;
}
int main() {
int a = 10;
increment(&a);
printf("%d", a);
}
We are still passing-by-value, but this time we copy the address of the variable instead of the value it holds into the function's stack frame. The function is holding the address of the mailbox.
What most people call "pass by reference" in C is actually this: pass-by-value, where the value happens to be an address. True pass-by-reference (as in C++, or the
refkeyword in C#) is a language feature where the compiler manages the indirection for you — you writex++and the caller's variable increments. In C you write(*p)++; you handle the dereference explicitly. Same net effect, more honest syntax. C shows you the pointer; other languages hide it.
A pointer is an address. An address is just a number. So can we do math on it?
Yes, but with a twist.
When you add 1 to an int*, you don't move 1 byte forward, you move sizeof(int) bytes forward.
The same with the other datatypes: when you add 1 to a char*, you move 1 byte. When you add 1 to a double*, you move 8 bytes.
An int* points to an int-sized mailbox; the next int-sized mailbox is sizeof(int) bytes down the road, not 1.
#include <stdio.h>
int main() {
int a = 10;
int *p = &a;
printf("p = %p\n", p);
printf("p + 1 = %p\n", p + 1);
}
Running on a typical machine:
p = 0x7ffe1a2bf3cc
p + 1 = 0x7ffe1a2bf3d0
The address jumped by 4 (d0 - cc = 4 in hex), exactly sizeof(int). Try the same with char* and the jump is 1; with double*, it's 8.
The compiler is doing this for you. When you write
p + 1, it multiplies bysizeof(*p)automatically. You write "next mailbox of the same type"; the compiler turns it into "this many bytes forward in memory."
Here's a homework exercise: try to dereference a pointer to the next addresses:
*(p+1), *(p+2)this way. What values do you have? Why? Write an explanation in your own words.
So we just found a way to explore the mailboxes in a contiguous way. This means that we can settle for some first mailbox, then we can explore more mailboxes in order. So we can, sort of, explore an array of mailboxes?
An array is a row of mailboxes — all the same type, all next to each other in memory, accessible by an index. Here's the canonical way of array declaration:
int readings[5]; // five int-sized mailboxes, side by side
After this declaration you have 5 mailboxes, named readings[0] through readings[4]. (Indexing starts at 0 in C — same as in CS1.) You can write to and read from each one:
readings[0] = 12;
readings[1] = 8;
readings[2] = 15;
readings[3] = 9;
readings[4] = 11;
int sum = readings[0] + readings[1] + readings[2] + readings[3] + readings[4];
double avg = sum / 5.0;
You can also initialise the array at declaration time:
int readings[5] = {12, 8, 15, 9, 11};
Or let the compiler count the size for you:
int readings[] = {12, 8, 15, 9, 11}; // size is 5, inferred from the list
A loop makes the average code from the recap section much friendlier:
double sum = 0;
for (int i = 0; i < 5; i++) {
sum += readings[i];
}
double avg = sum / 5.0;
If you want 100 readings instead of 5, you change ONE number (the array size and the loop bound).
Here's the secret that makes C arrays both wonderful and dangerous: an array name, when used in most expressions, decays into a pointer to its first element.
int readings[5] = {12, 8, 15, 9, 11};
printf("%p\n", readings); // prints the address of the first mailbox
printf("%p\n", &readings[0]); // prints the SAME address
readings and &readings[0] are the same address. The array name, by itself, is just a pointer to the start of the row.
And this means readings[i] is literally shorthand for *(readings + i):
readings[2] // value of the third mailbox
*(readings + 2) // EXACT same thing — same machine code, same result
The compiler treats them identically. Pointer arithmetic + dereference IS indexing. (You can even write 2[readings], since + is commutative — *(readings + 2) == *(2 + readings). Don't actually do this, but it explains why arrays and pointers are so tightly tangled in C.)
Arrays and pointers in C are not two different things you'll learn separately. They are TWO VIEWS of the same thing: a row of mailboxes plus an address into that row.
This is where the array-pointer duality bites. When you pass an array to a function, what actually gets passed is just the pointer to the first element — not the whole row.
#include <stdio.h>
double average(int arr[], int n) {
double sum = 0;
for (int i = 0; i < n; i++) {
sum += arr[i];
}
return sum / n;
}
int main(void) {
int readings[5] = {12, 8, 15, 9, 11};
double avg = average(readings, 5);
printf("Average: %.2f\n", avg);
}
Two things to notice:
int arr[] (equivalently int *arr — these are the same thing to the compiler). Inside the function, arr is a pointer.n is passed SEPARATELY. The function has no other way to know how many elements the array has.Remember: once an array has decayed to a pointer, the size information is GONE. If you forget to pass the length, or pass the wrong length, the function will happily read off the end of the array into whatever happens to be in memory next and you've just opened the door to a whole class of security bugs.
This is part of the deal with C: raw, fast, direct access to memory in exchange for keeping track of sizes yourself.
You might be tempted to use a pointer type as a return type. Let's see what happens:
int* flarpify(int n) {
int flarpified = 123 * n + 20;
int *p = &flarpified;
return p;
}
So far so good. But remember a key insight about stack frames and functions: they are popped once the function returns. This means that everything stored in the stack frame of the function — including the variable flarpified and therefore the address that p points to — will be deallocated. Meaning the operating system will no longer consider that mailbox as belonging to someone, and might try to give it to someone else.
The issue is that we do not really know what's going to happen to that deallocated mailbox and when. This is called undefined behaviour, and it is the first source of all evil in computer science.
The pointer returned by this function has a special name — a dangling pointer (meaning hanging; it hangs without anything valid to point to). Dereferencing a dangling pointer will result in undefined behaviour.
Never return a pointer to a variable stored on the stack frame of a function.
Of course C is nice and flexible and will allow us to create a sort-of-permanent storage for variables that persist even after the function returns. This is your teaser for CS3.
Time for some acrobatics: you can have a pointer to a pointer, nothing stops you. The address of a pointer is itself just another address, and any address can be stored in a suitably-typed pointer:
int a = 10;
int *p = &a; // p holds the address of a (p is int*)
int **pp = &p; // pp holds the address of p (pp is int**)
Dereferencing goes one layer at a time:
*p — one hop: go to the address p holds, read the int = 10.*pp — one hop: go to the address pp holds, read the pointer = p.**pp — two hops: first get p, then dereference p to get a = 10.int ***ppp = &pp; is valid, and ***ppp still resolves to a. The compiler doesn't care how many stars you stack since each * in the type says "one more level of indirection," and each * in an expression peels one level off. Practically, T** shows up sometimes. T*** and beyond you'll almost never see in real code but if you do, your code has probably taken a wrong turn somewhere.Try this:
#include <stdio.h>
void print_size(int arr[]) {
printf("sizeof in function: %lu\n", sizeof(arr));
}
int main(void) {
int readings[5] = {12, 8, 15, 9, 11};
printf("sizeof in main: %lu\n", sizeof(readings));
print_size(readings);
}
Output (on a 64-bit machine):
sizeof in main: 20
sizeof in function: 8
In main, sizeof(readings) is 20: five ints, four bytes each. In the function, sizeof(arr) is 8 — the size of a pointer on a 64-bit machine.
readings in main IS the full array. arr in the function is just a pointer to the same data. Same row of mailboxes, different containers, very different sizeof.
Here's the punchline before we start: in C, a "string" is just an array of char with a special invisible byte at the end.
That's it. There is no string type. There is no String object. There is no .length method. There is only char[] with a convention.
When you write "hello" in C, the compiler actually allocates 6 bytes, not 5:
Index: 0 1 2 3 4 5
Bytes: 'h' 'e' 'l' 'l' 'o' '\0'
That last byte — \0, the null terminator — is the signal "the string ends here." Every function in C that handles strings (printf, strlen, strcpy, ...) walks the array byte by byte until it hits \0, then it stops.
This is why you can write:
char message[] = "hello";
printf("%s\n", message); // prints: hello
printf gets the address of the first byte. It reads 'h', prints it. Reads 'e', prints it. Keeps going. Hits '\0' and stops.
It also works if you declare the array as a pointer:
char *message = "hello";
printf("%s\n", message); // prints: hello
Same thing — message holds the address of the first byte of "hello" somewhere in memory.
The convention IS the type. A string in C is not a thing the language knows about. It's an agreement between you and every string-handling function: "I will null-terminate, you will read until null." Break the convention and that's how you get a bunch of bugs coming.
strlen by handThe standard library function strlen returns the length of a string. Here's what it does internally:
int my_strlen(const char *s) {
int n = 0;
while (s[n] != '\0') {
n++;
}
return n;
}
It walks the array, counting characters, until it finds the null terminator. The null terminator is not counted in the length. "hello" has strlen == 5, not 6.
If the null terminator isn't there strlen keeps walking past the end of your string and into whatever happens to be in memory next. It will eventually find a \0 byte somewhere (memory is full of them by accident), and return some absurd number. Or crash. Or both.
char[] vs char* one footgun before we closeA subtle but important distinction:
char s1[] = "hello"; // array of 6 chars on the stack; you can modify it
char *s2 = "hello"; // pointer to a string literal in read-only memory
s1 is a real array of 6 chars, copied onto your stack frame. You can do s1[0] = 'j'; and it becomes "jello".s2 is just a pointer to a string literal that the compiler put in read-only memory. Writing s2[0] = 'j'; will compile, but it will CRASH at runtime since you're trying to modify memory the OS has marked read-only.That's the lesson. You can now:
&, the dereference *, declare pointer types, dereference them safely.p + 1 moves by sizeof(*p) bytes, not 1.a[i] is *(a + i).sizeof lying to you.char array with a null terminator, walk it by hand, and understand why a good half of the security industry exists because of decisions in this paragraph.void swap(int *a, int *b) that swaps two ints. Call it from main: int x = 3, y = 7;
swap(&x, &y);
// x should now be 7, y should now be 3
int sum_array(int *arr, int n) that sums an array of n ints — but instead of using arr[i], iterate using pointer arithmetic (*arr, arr++). Prove to yourself the result matches the indexed version.int divide(int a, int b, int *result) that divides a by b, stores the quotient in *result, and returns 0 on success or -1 if b was zero. The idea: the return value carries a status, the actual output goes through the pointer. This pattern shows up all over real C code. int *get_number(void) {
int local = 42;
return &local;
}
my_strlen. Write it yourself (as in the lesson), then compare against the standard strlen from <string.h>. Do they agree on "hello", "", "a"?head, tail) that "wrap around" the end of the array back to the start. Push values through tail, pop values through head; when either index reaches the end of the array, reset it to 0. This is a ring buffer: a very common pattern in embedded and systems code.r1, r2, r3, r4, r5) and refactor it using arrays, pointers where appropriate, and functions with proper interfaces. Aim for main to be under 15 lines, with each helper function doing one clear job.