C Basics · beginner · ~12 min
By the end of this lesson you will be able to: - Define a function with a return type, a name, a typed parameter list, and a body, and call it from `main` or from other functions. - Explain why C passes arguments **by value** and predict whether a change inside a function is visible to the caller. - Use a **pointer parameter** to let a function modify the caller's data (the only way to get "output" beyond the single return value). - Write and place **function prototypes** (declarations) so files can call functions defined elsewhere or later. - Decompose one large `main` into small, single-purpose functions with clear names and contracts. - Avoid the classic memory bug of returning a pointer to a local variable.
A function is a named, reusable block of code with defined inputs and one output. You write it once and call it as many times as you like. Think of a function as a small machine: you feed it values (the arguments), it does its job, and it hands back a result (the return value).
You already met one function in every program you have written: main. C programs are nothing but functions calling other functions. main calls printf; printf calls lower-level functions; and so on. The skill you learn here — splitting work into named pieces — is the single biggest jump from "a beginner who can write a loop" to "a programmer who can build something."
This lesson builds directly on for / while / do-while loops. A loop lets you repeat work; a function lets you name a chunk of work and reuse it. The two combine constantly: you will often write a function whose body is a loop (for example, summing an array), then call that function wherever you need the sum.
The vocabulary you will see throughout: a function's signature (its return type, name, and parameter types) is its public contract; parameters are the named inputs declared in the definition; arguments are the actual values you pass at a call; the body is the code between the braces; and a prototype is a one-line declaration of the signature that lets other code call the function before seeing its body.
Functions are your primary tool for managing complexity. A single 500-line main is nearly impossible to read, test, or change without fear. Ten 50-line functions, each with a clear name and one job, read almost like prose and can be reasoned about one at a time.
Three concrete payoffs:
In real software this is non-negotiable. Every library you will ever use — the C standard library, an HTTP client, a database driver — is just a set of functions with documented signatures. Good function boundaries are also a quiet security benefit: input validation, bounds checks, and error handling all live in one named place where they can be reviewed, instead of being scattered and forgotten.
Definition. A signature is return_type name(parameter_list). It is the function's public promise: callers rely on it, and the body must honor it.
Plain language. The signature tells you everything you need to use a function without reading its body: what to give it and what you get back. double sqrt(double x) says "give me one double, I return one double."
How it works internally. When you call a function, the compiler uses the signature to check that your arguments have compatible types and that you use the return value correctly. It also lets the compiler set up the call: arguments are placed in registers or on the call stack, control jumps to the function's code, and the return value comes back in a register.
When to use / when not to. Always give a function the narrowest, most honest signature you can. Do not sneak extra behavior in through global variables when a parameter would be clearer.
Pitfall. Omitting the return type or parameter types. In modern C, int f() (empty parentheses) means "unspecified parameters," which disables argument checking. Write int f(void) to mean "takes no arguments."
Knowledge check: In your own words, what is the difference between int f() and int f(void)?
Definition. When you call f(x), the function receives its own copy of each argument. Modifying a parameter inside the function does not change the caller's variable.
Plain language. Passing an argument is like photocopying a document and handing over the copy. The function can scribble all over its copy; your original is untouched.
How it works internally. Each call creates a new stack frame — a small region of memory holding that call's parameters and local variables. The parameter lives in this frame and is initialized from the argument's value.
CALL: modify(x) x is 10 in main
main's frame modify's frame (fresh copy)
+-----------+ +-----------+
| x = 10 | copy --> | n = 10 | n++ -> n becomes 11
+-----------+ +-----------+
^ |
| v (frame destroyed on return)
x still 10 <----------- no link back
When to use / when not to. Pass by value for plain inputs (numbers, small structs, flags). Do not expect the caller to see changes — for that you need a pointer (next concept).
Pitfall. Writing a swap(int a, int b) that swaps a and b inside the function and being surprised the caller's variables are unchanged. The copies were swapped, not the originals.
Knowledge check (predict the output): if void inc(int n){ n++; } is called as int v=5; inc(v); printf("%d", v);, what prints, and why?
Definition. A pointer parameter receives the address of a caller's variable, so the function can read and write that variable directly.
Plain language. Instead of a photocopy, you hand over the location of the original. Now the function can edit the real thing.
CALL: add_one(&x) x is 10 at address 0x7ffe...
main's frame add_one's frame
+-------------------+ +-------------------+
| x = 10 @0x7ffe |<----| p = 0x7ffe |
+-------------------+ +-------------------+
^ |
+----- *p = *p + 1 --------+ writes through the address
x is now 11
How it works internally. The pointer holds the variable's address. Inside the function, *p (dereference) reaches back to the caller's storage. This is how scanf("%d", &n) fills your variable.
When to use / when not to. Use a pointer when the function must produce more than one output, or modify data in place. Do not use one just to avoid copying a single int — the copy is cheaper than the indirection. Use const (e.g. const int *p) to promise you will only read through the pointer.
Pitfall. Passing the value instead of the address: calling add_one(x) where the function expects int * — the compiler will warn; never ignore that warning.
Definition. A function returns at most one value, of the type named in its signature. void means it returns nothing.
Plain language. One result comes back through the return statement. To hand back several results, return a struct (a bundle of values) or write through pointer parameters.
Pitfall. Reaching the end of a non-void function without a return. The returned value is then undefined — a real bug the compiler warns about under -Wall.
Knowledge check (find the bug): int max(int a,int b){ if(a>b) return a; } — what is wrong, and when does it bite?
Definition. A declaration (prototype) such as int square(int); states the signature only. A definition provides the body.
Plain language. A prototype is an introduction ("this function exists and looks like this"); the definition is the actual person showing up to work. The compiler needs the introduction before the first call so it can check the call.
header.h other.c
+--------------+ +-------------------------+
| int sq(int); |--->| includes header.h |
+--------------+ | calls sq(5); // checked |
+-------------------------+
math.c
+-------------------------+
| int sq(int n){return n*n;} // the real body
+-------------------------+
When to use / when not to. Put prototypes in a .h header so many .c files can call the function; put each definition in exactly one .c. Within a single file you can skip the prototype if the definition appears above every call.
Pitfall. A prototype that disagrees with the definition (different parameter count or types). The two contracts conflict; behavior becomes unreliable.
// 1. Declaration / prototype: signature only, ends with a semicolon.
int add(int a, int b);
// 2. Definition: same signature, plus a body in braces.
int add(int a, int b) {
return a + b; // hand one value back to the caller
}
// 3. void return: performs an action, returns nothing.
void greet(const char *name) { // const = we only read 'name'
printf("Hello, %s\n", name);
}
// 4. Pointer parameter: lets the function change the caller's variable.
void add_one(int *p) { // p holds an address
*p = *p + 1; // write through the address
}
// 5. No parameters: use (void), not ().
int roll_die(void);
// 6. Call sites:
int result = add(2, 3); // result == 5
int v = 10; add_one(&v); // pass the ADDRESS; v becomes 11
A function has four parts: a return type, a name, a parameter list, and a body.
You call it by name and pass arguments.
Arguments are passed by value. The function works on copies, so changes to a parameter do not affect the caller.
To let a function modify the caller's data, pass a pointer instead. (Pointers are covered in the Pointers section.)
Declare a prototype, such as int sum(int, int);, in a header file. This lets other files call the function before they have seen its definition.
#include <stdio.h>
/* Prototypes: introduce each function before main uses it. */
int square(int n);
int max_int(int a, int b);
void swap(int *a, int *b); /* pointers => can modify caller's data */
int sum_to(int n); /* uses a loop inside a function */
int main(void) {
/* Pass by value: square gets a copy of 5. */
printf("square(5) = %d\n", square(5));
/* Use a return value directly inside another call. */
printf("max_int = %d\n", max_int(square(3), 8)); /* max(9, 8) = 9 */
/* Pointers let swap change x and y for real. */
int x = 1, y = 2;
printf("before swap: x=%d y=%d\n", x, y);
swap(&x, &y); /* pass the ADDRESSES of x and y */
printf("after swap: x=%d y=%d\n", x, y);
printf("sum_to(5) = %d\n", sum_to(5)); /* 1+2+3+4+5 = 15 */
return 0;
}
/* Definitions */
int square(int n) {
return n * n;
}
int max_int(int a, int b) {
return (a > b) ? a : b; /* always returns on every path */
}
void swap(int *a, int *b) {
int tmp = *a; /* read through the pointers */
*a = *b;
*b = tmp; /* originals are now exchanged */
}
int sum_to(int n) {
int total = 0;
for (int i = 1; i <= n; i++) { /* loop body lives inside a function */
total += i;
}
return total;
}
What it does. main exercises four functions: square (pass by value), max_int (chooses the larger of two ints and is used as an argument to another call), swap (uses pointers to exchange the caller's variables), and sum_to (a loop wrapped in a reusable function).
Expected output:
square(5) = 25
max_int = 9
before swap: x=1 y=2
after swap: x=2 y=1
sum_to(5) = 15
Edge cases. square(46341) overflows a 32-bit int (the product exceeds INT_MAX) — undefined behavior; use long for larger ranges. sum_to(0) returns 0 because the loop never runs; sum_to(-3) also returns 0 for the same reason, which may or may not be what you want — decide and document.
Walking through the program in execution order:
square, max_int, swap, and sum_to. The compiler now knows each signature, so it can check every call inside main even though the bodies appear later.square(5) — 5 is copied into parameter n. The body returns 5 * 5 = 25. The copy is discarded when the function returns.max_int(square(3), 8) — arguments are evaluated first: square(3) returns 9. Then max_int(9, 8) runs; 9 > 8 is true, so the ternary yields 9.int x = 1, y = 2; then swap(&x, &y). The & operator produces the addresses of x and y. Inside swap, a points at x, b points at y.swap: tmp = *a reads 1; *a = *b writes 2 into x; *b = tmp writes 1 into y. Because we wrote through the pointers, the real x and y change.sum_to(5) — total starts at 0; the loop adds 1,2,3,4,5; returns 15.Trace table for swap(&x, &y) (x at addr A, y at addr B):
step tmp *a (=x) *b (=y)
---- --- ------- -------
start ? 1 2
tmp = *a 1 1 2
*a = *b 1 2 2
*b = tmp 1 2 1
After the call, main sees x=2, y=1. Contrast this with a value-based swap(int a,int b): it would shuffle copies in its own frame and main's x and y would stay 1 and 2.
Mistake 1 — expecting pass-by-value to change the caller.
void swap(int a, int b) { // WRONG: copies
int t = a; a = b; b = t;
}
// caller: swap(x, y); -> x and y are unchanged
Why it is wrong: a and b are copies; the originals are never touched. Fix: take pointers and pass addresses.
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
// caller: swap(&x, &y); -> x and y are exchanged
Recognize it: the function "does nothing" from the caller's view. Always ask "did I need a copy or the original?"
Mistake 2 — a path with no return in a non-void function.
int max(int a, int b) { // WRONG: nothing returned when a <= b
if (a > b) return a;
}
Why it is wrong: when a <= b the function falls off the end and the returned value is undefined. Fix: cover every path.
int max(int a, int b) { return (a > b) ? a : b; }
Prevent it: compile with -Wall; it warns "control reaches end of non-void function."
Mistake 3 — prototype and definition disagree.
int area(int w, int h); // prototype says two ints
int area(int w) { return w * w; } // WRONG: body takes one
Why it is wrong: callers trust the prototype and pass two arguments; the definition expects one. Keep the two identical. Putting the prototype in a header and including that header in the .c that defines it makes the compiler catch the mismatch for you.
Mistake 4 — int f() when you mean no parameters. Use int f(void) so the compiler rejects accidental extra arguments.
Compiler errors / warnings
implicit declaration of function 'foo' — you called foo before declaring it. Add a prototype or move the definition above the call, and #include the right header.conflicting types for 'foo' — the prototype and definition (or two prototypes) disagree. Make them identical.control reaches end of non-void function (under -Wall) — add a return on every path.passing argument 1 of 'foo' makes pointer from integer without a cast — you passed a value where a pointer is expected; you probably forgot &.Runtime / logic errors
return. Compare against a hand calculation for one small case.Always build with warnings on: gcc -std=c11 -Wall -Wextra prog.c -o prog. Most function bugs are warnings you can turn into errors with -Werror.
Questions to ask when it doesn't work: Did I pass the address or the value? Does every path return? Do the prototype and definition match exactly? Is the return type wide enough for the result?
Never return a pointer to a local variable. A local's storage lives in the function's stack frame, which is destroyed the instant the function returns. The caller would hold a dangling pointer to reclaimed memory — reading or writing through it is undefined behavior (often a crash or silent corruption).
char *bad(void) {
char buf[32];
strcpy(buf, "hi");
return buf; // WRONG: buf dies when bad() returns
}
Safe ways to return data from a function:
void greet(char *out, size_t n) { snprintf(out, n, "hi"); }
malloc inside the function and document that the caller must free it. Always check malloc's return for NULL, and free exactly once.Other function-level concerns:
n * n, a + b) can overflow the return type; choose a wide enough type or check bounds.NULL, check for it before dereferencing, and document the contract ("p must not be NULL").Every C API you will ever touch is a set of functions: printf, strlen, fopen/fclose, malloc/free, pthread_create, socket, read, write. Operating system kernels, embedded firmware, databases, and web servers are all built from layers of small functions calling one another. When you read library documentation, you are reading signatures.
Professional habits for functions:
Beginner rules
parse_line, compute_average), it is probably doing too much.Advanced rules
const on pointer parameters you only read; it documents intent and lets the compiler help.free, fclose, close) should pair with allocation, ideally in the same function.Beginner 1 — square. Write int square(int n) that returns n * n. Call it from main for n = 0, 4, -3 and print each result. Concepts: defining and calling a function, return value. Hint: declare a prototype above main or define square before it.
Beginner 2 — larger_double. Write double larger(double a, double b) that returns the larger of two doubles (return either if equal). Print larger(3.5, 2.1) and larger(-1.0, -0.5). Concepts: parameters, a single return on every path. Hint: a ternary ?: keeps it to one line and guarantees every path returns.
Intermediate 1 — swap with pointers. Write void swap(int *a, int *b) that exchanges the two integers the pointers refer to. In main, set x=7, y=9, call swap(&x, &y), and print before and after. Constraints: do not use a global; do all work through the pointers. Concepts: pointer parameters, dereference, pass-by-address. Hint: use a temporary int.
Intermediate 2 — min_max via out-parameters. Write void min_max(const int *arr, int n, int *out_min, int *out_max) that scans an array of n ints and writes the smallest to *out_min and the largest to *out_max. Example: for {4, 1, 9, 2} it sets *out_min = 1, *out_max = 9. Constraints: assume n >= 1; treat arr as read-only (note the const). Concepts: returning multiple results through pointers, looping inside a function, const correctness. Hint: initialize both outputs to arr[0] before the loop.
Challenge — refactor a monolith. Take a single long main that (1) reads numbers into an array, (2) computes their average, and (3) prints a small histogram of how many fall in each of three ranges. Split it into at least three functions — e.g. int read_values(int *buf, int cap), double average(const int *buf, int n), and void print_histogram(const int *buf, int n) — and have main call them in order. Requirements: each function has a single job and a clear name; pass the buffer and its length (never a global); validate that n >= 1 before dividing for the average. Concepts: decomposition, pointer parameters with length, const, returning by value vs. through buffers. Hint: decide and document what average does for an empty input before you write the division.
main is a function. Functions make code testable, reusable, and replaceable.return_type name(params) is the contract callers rely on; use (void) for no parameters.&, then write through *p.struct or out-parameters for more. Make sure every path returns in a non-void function..c each.malloc and document who frees.-Wall -Wextra; most function bugs show up as warnings. Remember: a program built from small, well-named functions reads almost like prose.