Functions in C — theory with prototypes, void and pass by value and by reference, charging a bus ticket (and the credit that wouldn’t go down)
My first “real” function in C charged a trip to my bus pass. I called it twice, printed the credit and it was still exactly 10 euros. I went over the subtraction, the printf and even the ticket price before I understood what was going on: the function had charged the trips to a copy of the credit. In C, unless you say otherwise, every function works with copies.
This article is about that and everything around it: how to write a function in C, what exactly void means (the most searched question on the topic), why prototypes exist and why gcc 14 no longer forgives you for forgetting one, how to return a result, what the call stack is and, above all, the difference between pass by value and pass by reference, which is where functions meet what you already know about pointers in C. The running example is still the bus: tickets by zones, the pass credit in cents and the passengers at each stop. Every output comes from a real run with gcc 14.
Table of Contents
What is a function in C?
A function is a named block of code that does one specific job and can be used from anywhere in the program. main is already a function: the one the system calls when the program starts. If you’re coming from Python functions, the idea is the same:
def ticket_price(zones):
return 155 + (zones - 1) * 20
In C, the same function has to state the type of what it returns and the type of each parameter:
#include <stdio.h>
/* Returns the ticket price in cents for the number of zones */
int ticket_price(int zones) {
return 155 + (zones - 1) * 20; /* 1.55 euros and 20 cents per extra zone */
}
int main(void) {
int price = ticket_price(3); /* CALL: 3 goes in as zones */
printf("3-zone ticket: %d cents\n", price);
printf("1-zone ticket: %d cents\n", ticket_price(1));
return 0;
}
Output:
3-zone ticket: 195 cents 1-zone ticket: 155 cents
The parts of a function, from left to right:
| Part | In the example | What it’s for |
|---|---|---|
| Return type | int | what type of value it returns |
| Name | ticket_price | how you call it |
| Parameters | (int zones) | what it receives, each with its type |
| Body | { return …; } | what it does |
When main writes ticket_price(3), the 3 is copied into the parameter zones, the function works out 155 + 2 × 20 = 195 and return hands that number back to the place of the call, where it’s stored in price. With 1 zone, 155 + 0 = 155.
In Python types are optional; in C they are mandatory. If a function returns int and you write return 1.95;, C turns the result into 1 without asking. That’s why the price is in cents: with integers there are no decimals to lose.
What is void in C: the type that confuses most
void means “nothing”. It’s C’s way of saying a slot is empty, and it appears in two different places with two different meanings:
- As the return type: the function does something but returns no value. It has no
return value;(at most a barereturn;to leave early). - As the parameter list: the function takes nothing.
int first_departure(void)is called like this:first_departure().
Both together in one program:
#include <stdio.h>
/* void as return type: does something and returns nothing */
void separator(int length) {
for (int i = 0; i < length; i++)
printf("-");
printf("\n");
}
/* void in the parameters: takes nothing */
int first_departure(void) {
return 610; /* 6:10, the first bus */
}
/* void in both places */
void header(void) {
separator(26);
printf("ROUTE 201 San Telmo-Teror\n");
separator(26);
}
int main(void) {
header();
printf("First departure: %d\n", first_departure());
return 0;
}
Output:
-------------------------- ROUTE 201 San Telmo-Teror -------------------------- First departure: 610
separator draws the line and returns nothing (void in front); first_departure takes nothing (void in the brackets) and returns 610; header neither takes nor returns anything: it just calls two other functions. Note that a void function can’t be used as a value: int x = separator(26); doesn’t compile, because there’s nothing to store.
What about empty brackets, int first_departure()? In C11, which is the IC2 standard, empty brackets in a definition mean “I’m not saying what parameters it has”, not “it has no parameters”. The compiler won’t check that you call it with no arguments. With (void) it does check. Since C23 both forms mean the same, but the course compiles with -std=c11: better to use (void).
Read the declaration from left to right: void header(void) is “returns nothing, is called header, takes nothing”. If there’s a type other than void in front, the function must end with a return of that type.
void main: what you’ll see in the notes
In the IC2 notes many examples start with void main(). Is that OK? gcc compiles it, but warns:
#include <stdio.h>
void main(void) { /* like in the notes */
printf("The guagua leaves at 6:10\n");
}
void_main.c:3: warning: return type of 'main' is not 'int' [-Wmain]
The guagua leaves at 6:10
The C standard says main returns int: that number is the exit code the terminal receives (Linux’s echo $?), and 0 means “all good”. With void main you return nothing and the exit code is left to chance: in my run the program ended with 26 (almost certainly the value printf left behind after writing its 26 characters). A script checking whether the program worked would count it as a failure. In your programs, always int main(void) and return 0;.
How to declare and call a function correctly
A function has three moments: the definition (the signature with its body), the prototype (just the signature, ending in a semicolon) and the call (using it). The order in which they appear in the file matters, because the compiler reads the program from top to bottom, once.
See what happens if main calls a function defined further down and there is no prototype:
#include <stdio.h>
int main(void) {
printf("%d\n", ticket_price(3)); /* the compiler doesn't know it yet */
return 0;
}
int ticket_price(int zones) {
return 155 + (zones - 1) * 20;
}
no_prototype.c: In function 'main': no_prototype.c:4: error: implicit declaration of function 'ticket_price' [-Wimplicit-function-declaration]
“implicit declaration”: when gcc reaches line 4 it doesn’t yet know what ticket_price is. On old compilers this was just a warning and C made up that the function returned int; since gcc 14 (Fedora’s) it is an error and the program doesn’t compile. There are two fixes: put the whole function before main, or put its prototype at the top.
Prototypes: why C needs them
The prototype tells the compiler “this function exists, it takes this and returns that; the body comes later”. That’s enough to check every call:
#include <stdio.h>
/* PROTOTYPES: the signature, ending in ; */
int ticket_price(int zones);
void print_ticket(int zones, int price);
int main(void) {
for (int z = 1; z <= 3; z++)
print_ticket(z, ticket_price(z)); /* one function inside another */
return 0;
}
/* DEFINITIONS: after main, with their bodies */
int ticket_price(int zones) {
return 155 + (zones - 1) * 20;
}
void print_ticket(int zones, int price) {
printf("Zones: %d | %d.%02d euros\n", zones, price / 100, price % 100);
}
Output:
Zones: 1 | 1.55 euros Zones: 2 | 1.75 euros Zones: 3 | 1.95 euros
That’s the IC2 lab style: prototypes at the top, main in the middle, definitions at the end. That way main stays in view and reads like a table of contents for the program. And when the program grows and you split it into several files, the prototypes are exactly what goes into the .h.
Two details in this program: print_ticket(z, ticket_price(z)) uses one function’s result as another function’s argument (first the price is worked out and then printed), and %02d pads with a zero so 5 cents come out as 05: 175 / 100 = 1 euro and 175 % 100 = 75 cents.
A prototype and a definition that don’t match (int ticket_price(int zones); at the top and double ticket_price(int zones) below). gcc catches it as “conflicting types”. The prototype is a contract: the definition must keep it to the letter.
return: sending values back
return does two things at once: it ends the function right there and hands the value to whoever called it. A function can have several returns, but on each call only one runs:
#include <stdio.h>
double mean(int a, int b, int c) {
return (a + b + c) / 3.0; /* 3.0 makes it a decimal division */
}
int maximum(int a, int b) {
if (a > b)
return a; /* leaves here if a is larger */
return b; /* otherwise, it gets here */
}
int youth_pass(int age) {
return age >= 14 && age <= 30; /* 1 (true) or 0 (false) */
}
int main(void) {
printf("Mean passengers: %.2f\n", mean(12, 7, 9));
printf("Busiest stop: %d\n", maximum(12, 15));
printf("Youth pass at 19? %d\n", youth_pass(19));
printf("Youth pass at 45? %d\n", youth_pass(45));
return 0;
}
Output:
Mean passengers: 9.33 Busiest stop: 15 Youth pass at 19? 1 Youth pass at 45? 0
meandivides by3.0and not by3: with threeints, (12 + 7 + 9) / 3 would give 9 (integer division); with3.0it gives 28 / 3 = 9.33.maximumhas two exits: ifa > b, the firstreturnends it and the second isn’t even looked at. With (12, 15), 12 > 15 is false and it reachesreturn b.youth_passdirectly returns the result of a comparison: in C that’s anintworth 1 (true) or 0 (false). At 19, 19 >= 14 and 19 <= 30: 1. At 45: 0.
A non-void function where some path has no return. gcc warns with control reaches end of non-void function and, if that path runs, the function returns garbage. You’ll see it in “What’s wrong here?” in the exercises, with a 3-zone ticket that cost 3 cents.
Functions with no return value: void
When what matters is what the function does (printing, changing an array) and not a value, the return type is void. You’ve already seen separator, header and print_ticket. A void function can use a bare return; to end early, for instance when it spots invalid data:
void print_ticket(int zones, int price) {
if (zones < 1)
return; /* nothing to print: leave now */
printf("Zones: %d | %d.%02d euros\n", zones, price / 100, price % 100);
}
And the other way round: if the function has to report whether something went well or badly, don’t make it void; have it return an int with 1 or 0. That’s what the charge function does a bit further down.
Pass by value: the copy
Here’s the story from the start. When you call a function, C copies each argument into its parameter. The function works with that copy, and whatever it does with it doesn’t reach the caller’s variables. Printing the addresses makes it very clear:
#include <stdio.h>
void charge_copy(int credit, int price) {
printf(" inside: credit lives at %p\n", (void *)&credit);
credit = credit - price; /* changes the copy */
printf(" inside: credit = %d\n", credit);
}
int main(void) {
int credit = 1000;
printf("main: credit lives at %p\n", (void *)&credit);
charge_copy(credit, 155);
printf("main: credit = %d\n", credit); /* unchanged */
return 0;
}
Output:
main: credit lives at 0x7ffc212aef64 inside: credit lives at 0x7ffc212aef4c inside: credit = 845 main: credit = 1000
main‘s credit lives at …ef64; the function’s credit, at …ef4c. They are two different variables with the same name. Inside, the copy drops to 845; outside, main still has 1000. It’s the same thing that happens in Python when you pass a number to a function and change it inside.
Pass by value is the norm and it’s safe: a function can’t ruin your variables by accident. But sometimes that’s exactly what you want it to do.
Pass by reference, with pointers
C doesn’t have “true” pass by reference like C++; it has something that does the same: passing the address of the variable. The function receives a pointer and, with *, writes into the original variable. It’s exactly what you saw in the swap in the pointers practice.
This version of charge combines both things: the credit changes through the pointer and return says whether it could charge:
#include <stdio.h>
/* Charges if there is credit: returns 1 if it could charge and 0 if not.
The credit changes through the pointer; success comes out via return. */
int charge(int *credit, int price) {
if (*credit < price)
return 0; /* don't touch anything */
*credit = *credit - price; /* change main's variable */
return 1;
}
int main(void) {
int credit = 400;
int trip = 1;
while (charge(&credit, 155)) { /* &credit: the address, not the value */
printf("Trip %d paid, left: %d cents\n", trip, credit);
trip++;
}
printf("No credit for trip %d (left: %d)\n", trip, credit);
return 0;
}
Output:
Trip 1 paid, left: 245 cents Trip 2 paid, left: 90 cents No credit for trip 3 (left: 90)
Lap by lap: 400 − 155 = 245, 245 − 155 = 90, and with 90 cents there’s not enough for another trip: charge returns 0 without touching the credit, the while ends and the program says trip 3 can’t be paid.
| Pass by value | Pass by reference | |
|---|---|---|
| Parameter | int credit | int *credit |
| Call | charge_copy(credit, 155) | charge(&credit, 155) |
| Used inside as | credit | *credit |
| Does main’s variable change? | No | Yes |
| Use it when | the function only needs the data | the function must change it or return more than one result |
Forgetting the & in the call: charge(credit, 155). With gcc 14 it’s a compile error (you pass an int where an int * is expected); with old compilers it compiled and the function wrote to address 400, with an almost certain Segmentation fault.
Returning multiple values through pointers
charge already returns two things: a 1/0 via return and the new credit via the pointer. That’s the general pattern for a function with several results in C: one goes out through return and each of the others through a pointer parameter, with & in the call. You’ll use it all the time in the practice (a search that returns the value and the position) and in the exercises.
Visual comparison: value versus reference
With the real addresses from the run above, this is the picture worth keeping in your head:
At the top, the copy lives in another box and dies with the function; at the bottom, the function holds the address of main‘s box and writes into it. If you want to see it move, the program in the Python Tutor section makes both calls one after the other.
Passing arrays to functions
Arrays are the special case. When you pass an array to a function, C doesn’t copy the boxes: it passes the address of the first one (what you already know from pointers: the array name turns into that address). Consequences:
- The function can modify the original array without needing
&. int v[]in a parameter is exactly the same asint *v.- The function doesn’t know how many boxes the array has: you have to pass the size separately.
The third point is a classic trap, and gcc watches for it:
#include <stdio.h>
/* int v[] in a parameter is the same as int *v */
int total_boarding(int v[], int n) {
printf(" sizeof v in the function = %zu\n", sizeof v);
int total = 0;
for (int i = 0; i < n; i++)
total += v[i];
return total;
}
void double_all(int *v, int n) { /* changes main's array */
for (int i = 0; i < n; i++)
v[i] = v[i] * 2;
}
int main(void) {
int boarding[5] = {12, 7, 9, 4, 15};
printf("sizeof boarding in main = %zu\n", sizeof boarding);
printf("Total: %d\n", total_boarding(boarding, 5));
double_all(boarding, 5); /* no &: the name already is an address */
printf("After doubling, boarding[0] = %d\n", boarding[0]);
return 0;
}
arrays.c: In function 'total_boarding': arrays.c:5: warning: 'sizeof' on array function parameter 'v' will return size of 'int *' [-Wsizeof-array-argument] arrays.c:4: note: declared here
Output:
sizeof boarding in main = 20 sizeof v in the function = 8 Total: 47 After doubling, boarding[0] = 24
In main, sizeof boarding gives 20 (5 four-byte ints). Inside the function, sizeof v gives 8: the size of a pointer. That’s why the warning says sizeof on an array parameter “will return size of int *“. The total is still correct, 12 + 7 + 9 + 4 + 15 = 47, because it walks the array with the n we pass. And double_all changes main‘s array: box 0 goes from 12 to 24 with no & and no return.
The const modifier: read-only parameters
If a function receives an array it must not modify, declare it const int v[]. If by mistake you write v[i] = … inside, gcc gives an error. It’s a cheap way to make clear which functions read and which write.
Get into the habit with const: in the exam it shows the examiner you know which parameters are inputs and which are outputs, and it turns a silent bug into a compile error.
The call stack and local variables
Every time you call a function, the program sets aside a frame for it on the stack: that’s where its parameters and local variables live. When the function does return, the frame is freed and those variables stop existing. That’s why two functions can each have a total variable without clashing.
With the address of one variable from each frame you can see how they stack:
#include <stdio.h>
double mean(int total, int n) {
double m = (double)total / n;
printf(" mean: m at %p\n", (void *)&m);
return m;
}
void summary(int *v, int n) {
int total = 0;
printf(" summary: total at %p\n", (void *)&total);
for (int i = 0; i < n; i++)
total += v[i];
printf(" result: %.2f\n", mean(total, n));
}
int main(void) {
int boarding[4] = {12, 7, 9, 4};
printf("main: boarding at %p\n", (void *)boarding);
summary(boarding, 4);
return 0;
}
Output:
main: boarding at 0x7ffe57bcaf00
summary: total at 0x7ffe57bcaee0
mean: m at 0x7ffe57bcaeb0
result: 8.00
main calls summary and summary calls mean: three frames alive at once. The addresses go down (…af00, …aee0, …aeb0): on Linux the stack grows towards smaller addresses. The mean of 12, 7, 9 and 4 is 32 / 4 = 8.00.
This explains two mistakes you’ll meet soon: returning the address of a local variable (its frame no longer exists when you use it, as in the pointers exercises) and endless recursion, which piles up frames until the stack runs out.
Global variables: the ones from the notes
A variable declared outside every function is global: they can all see it and change it. The IC2 notes have an example with two functions, F1 and F2, that add to and subtract from a global variable R. Here’s the same program with the bus:
#include <stdio.h>
#define N 5 /* stops where one passenger gets on */
#define M 2 /* stops where two get off */
int inside = 0; /* GLOBAL: every function can see it */
void get_on(void) {
for (int i = 0; i < N; i++) {
inside++;
printf("On: %d\n", inside);
}
}
void get_off(void) {
for (int i = 0; i < M; i++) {
inside = inside - 2;
printf("Off: %d\n", inside);
}
}
int main(void) {
get_on(); /* we pass nothing: they use the global */
get_off();
printf("At the end: %d passengers\n", inside);
return 0;
}
Output:
On: 1 On: 2 On: 3 On: 4 On: 5 Off: 3 Off: 1 At the end: 1 passengers
It works: 5 passengers get on one at a time and then two get off, twice (5 − 2 − 2 = 1). But look at the calls: get_on() and get_off() say nothing about what they touch. In a big program, a global anyone can change is a source of bugs that are hard to follow (and in the threads topic you’ll see it’s exactly the shared variable that causes race conditions). The clean version would be void get_on(int *inside, int n): the signature says what it modifies.
Constants defined with #define (like N and M) aren’t global variables: the preprocessor replaces them with their value before compiling. Using them for sizes and limits is good practice.
A complete practical example
Prototypes at the top, main in the middle, definitions at the end, functions that return a value, void functions that change the array and one that calls another passing it the addresses of two boxes:
#include <stdio.h>
/* Prototypes */
int maximum(int v[], int n);
int minimum(int v[], int n);
double mean(int v[], int n);
void swap(int *a, int *b);
void sort(int v[], int n);
void show(const char *title, int v[], int n);
int main(void) {
int passengers[8] = {42, 7, 19, 3, 85, 26, 11, 54}; /* per stop, route 201 */
int n = 8;
show("Original: ", passengers, n);
printf("Max: %d\n", maximum(passengers, n));
printf("Min: %d\n", minimum(passengers, n));
printf("Mean: %.2f\n", mean(passengers, n));
sort(passengers, n); /* changes the original */
show("Sorted: ", passengers, n);
return 0;
}
int maximum(int v[], int n) {
int m = v[0];
for (int i = 1; i < n; i++)
if (v[i] > m) m = v[i];
return m;
}
int minimum(int v[], int n) {
int m = v[0];
for (int i = 1; i < n; i++)
if (v[i] < m) m = v[i];
return m;
}
double mean(int v[], int n) {
int sum = 0;
for (int i = 0; i < n; i++)
sum += v[i];
return (double)sum / n;
}
void swap(int *a, int *b) {
int t = *a;
*a = *b;
*b = t;
}
/* Bubble: on each pass the largest left rises to the end */
void sort(int v[], int n) {
for (int i = 0; i < n - 1; i++)
for (int j = 0; j < n - 1 - i; j++)
if (v[j] > v[j + 1])
swap(&v[j], &v[j + 1]); /* & of each box */
}
void show(const char *title, int v[], int n) {
printf("%s", title);
for (int i = 0; i < n; i++)
printf("%d ", v[i]);
printf("\n");
}
Output:
Original: 42 7 19 3 85 26 11 54 Max: 85 Min: 3 Mean: 30.88 Sorted: 3 7 11 19 26 42 54 85
Check: 42 + 7 + 19 + 3 + 85 + 26 + 11 + 54 = 247 passengers, and 247 / 8 = 30.875, which %.2f rounds to 30.88. The most interesting part is sort (bubble sort): when two neighbouring boxes are out of order it calls swap(&v[j], &v[j + 1]). Even though v is already a pointer, v[j] is an int, so you need & to pass the address of that particular box.
Common mistakes with functions
- Calling a function before declaring it.
implicit declaration: an error in gcc 14. Prototype at the top. - Expecting a function to change a variable passed by value. If it has to modify it, the parameter is a pointer and the call carries
&. - Forgetting the
&in the call to a function that takes pointers. - Working out an array’s size with
sizeofinside the function. Always passnas a parameter. - A path with no
returnin a function that returns something. - Using the result of a
voidfunction, or ignoring the result of one that returns something important (likecharge‘s 1/0, or whatscanfreturns). void maininstead ofint main(void)withreturn 0;.
Watch it step by step in Python Tutor
Python Tutor draws the call stack: each function in its own frame, with its variables, and pointers as arrows. Choose C and paste this program, which makes one call by value and one by reference:
#include <stdio.h>
void charge_copy(int credit, int price) {
credit = credit - price;
}
int charge(int *credit, int price) {
if (*credit < price) {
return 0;
}
*credit = *credit - price;
return 1;
}
int main(void) {
int credit = 400;
charge_copy(credit, 155);
int ok = charge(&credit, 155);
printf("ok = %d, credit = %d\n", ok, credit);
return 0;
}
What to watch step by step:
- On entering
charge_copya new frame appears with its owncredit(400) andprice. There are twocreditvariables on screen, one in each frame. - On
credit = credit - price;the one incharge_copy‘s frame changes (245).main‘s stays at 400. - When it returns, the whole frame disappears, and the 245 with it.
- On entering
charge, itscreditisn’t a number any more: it’s an arrow pointing atmain‘scredit. - On
*credit = *credit - price;main‘s box changes (from 400 to 245), andokgets the 1 fromreturn. It should printok = 1, credit = 245.
Summary
| I want… | Code | Bus example |
|---|---|---|
| A function that returns a value | int ticket_price(int zones) | 195 cents for 3 zones |
| A function that returns nothing | void separator(int length) | draws the line |
| A function with no parameters | int first_departure(void) | 610 |
| To use it before defining it | prototype int ticket_price(int zones); at the top | no “implicit declaration” |
| To change a variable in main | void charge(int *credit, …) and charge(&credit, …) | the credit really drops |
| To return two results | one via return and the other via a pointer | charge returns 1/0 and changes the credit |
| To pass an array | int v[] (or int *v) and n separately | total_boarding(boarding, 5) |
| A read-only array | const int v[] | read only |
Three ideas to take away:
- A C function receives copies: if it has to change something of the caller’s, it receives its address.
- The compiler reads from top to bottom: prototypes at the top, and
voidwhen there’s no return value or no parameters. - An array travels as the address of its first box: the function can change it, but doesn’t know its size.
Now it’s time to practise with complete programs. In the functions in C practice we write search functions that return two results, functions that transform arrays in place and a statistics calculator for route 201 that reads its data with scanf.
Did this article help you? Here is how you can support SergioLearns →




The hardware that holds up the operating system — theory with my IC2 assignment on the printer, an interrupt that stops everything and a cache that hits (and the infinite loop that didn’t freeze my laptop)