Why programs are split into functions at all, and what parameters and return values are, is explained on functions and abstraction. This page covers how C does it, and how a C program grows from one file into several.

Defining and calling functions

A function definition names a return type, the function name and a list of parameters, each with its own type, followed by the body in braces. return hands a value back to the caller. A function that returns nothing has the return type void, and a function without parameters is written with (void), as main is here.

#include <stdio.h>

/* Price in ten-thousandths of a euro per kWh: 2700 = EUR 0.2700 */
long cost_cents(long kwh, long price_per_kwh)
{
    long ten_thousandths = kwh * price_per_kwh;
    return (ten_thousandths + 50) / 100;        /* round to whole cents */
}

void print_euros(long cents)
{
    printf("EUR %ld.%02ld\n", cents / 100, cents % 100);
}

int main(void)
{
    print_euros(cost_cents(350, 2700));
    print_euros(cost_cents(1, 2700));
    return 0;
}
EUR 94.50
EUR 0.27

Money is kept in whole numbers (ten-thousandths of a euro, then cents) so that no floating-point rounding creeps in; why that matters is shown on variables, types and integers. Kestrel's fixed tariff of €0.2700 per kWh gives €94.50 for 350 kWh.

A function with a return type other than void must return a value on every path. Forget one, and the caller receives whatever happens to be in a register: undefined behavior. gcc's -Wall catches the simple cases:

#include <stdio.h>

int tariff_band(int kwh)
{
    if (kwh < 1000) {
        return 1;
    } else if (kwh < 3000) {
        return 2;
    }
}

int main(void)
{
    printf("band %d\n", tariff_band(350));
    return 0;
}
example.c:10:1: error: control reaches end of non-void function [-Werror=return-type]

Declare before use: prototypes

The compiler reads a file from top to bottom, once. When it meets a call, it must already know the function's parameter types and return type, so that it can convert the arguments and check them. A function prototype is a declaration that gives exactly that: the return type, the name and the parameter types, ended by a semicolon instead of a body. It can appear long before the definition, or in another file altogether.

#include <stdio.h>

long cost_cents(long kwh, long price_per_kwh);   /* prototype */

int main(void)
{
    printf("%ld cents\n", cost_cents(350, 2700));
    return 0;
}

long cost_cents(long kwh, long price_per_kwh)    /* definition */
{
    return (kwh * price_per_kwh + 50) / 100;
}
9450 cents

Remove the prototype and the call comes before any declaration. Old C then guessed that the function returns int and accepts anything, an "implicit declaration". gcc 12 still only warns without flags; with this site's flags the program does not compile, and GCC 14 and later refuse it by default:

#include <stdio.h>

int main(void)
{
    printf("%ld cents\n", cost_cents(350, 2700));
    return 0;
}

long cost_cents(long kwh, long price_per_kwh)
{
    return (kwh * price_per_kwh + 50) / 100;
}
example.c:5:27: error: implicit declaration of function ‘cost_cents’ [-Werror=implicit-function-declaration]

The same error appears when you call a library function without including its header, such as sqrt without <math.h>; gcc then even suggests the right header. The headers of the standard library are nothing more than files full of prototypes.

int f() is not int f(void)

Up to C17, empty parentheses in a declaration meant "parameters not specified", not "no parameters". The compiler then checks nothing: gcc 12 in C17 mode compiles a call such as meter_count(2026) to a function declared int meter_count() without a word, although passing the wrong number of arguments that way is undefined behavior. C23 changed the meaning: () now means the same as (void), and the same call is an error. Compiled as C23 with clang 21:

#include <stdio.h>

int meter_count()
{
    return 400000;
}

int main(void)
{
    printf("%d\n", meter_count(2026));
    return 0;
}
example.c:10:32: error: too many arguments to function call, expected 0, have 1

Write (void) for a function without parameters. It means the same thing in every version of C, and old code that relies on () stops compiling on compilers that default to C23, such as GCC 15 and later.

Arguments are copies

C passes every argument by value: the parameter is a new local variable that starts with a copy of the argument. Changing it inside the function does not change the caller's variable. The general idea is described under pass by value.

#include <stdio.h>

void add_bonus(int score)
{
    score += 10;                /* changes the copy only */
    printf("inside: %d\n", score);
}

int main(void)
{
    int score = 70;
    add_bonus(score);
    printf("after:  %d\n", score);
    return 0;
}
inside: 80
after:  70

To let a function change the caller's variable, pass its address and let the function write through the pointer. That is how scanf stores what it reads, and how a function returns more than one result; the pointer version of add_bonus is shown on memory management, and the details are on pointers.

Two things look like exceptions but follow the same rule. A struct argument is copied completely, however large it is, which is why large structs are usually passed by pointer (see structs, unions and enums). An array argument is not copied at all: what is passed, by value, is a pointer to its first element (see array decay), so the function can change the caller's elements.

Header files and source files

Once a program grows beyond one file, the functions it shares are split in two parts. A header file (.h) contains the declarations other files need: prototypes, macros, type definitions and extern declarations of shared variables. The matching source file (.c) contains the definitions, the actual code. Every file that wants to call the functions includes the header with #include "tariff.h" (quotes for your own headers, angle brackets for system headers).

This example builds a small billing program from three files and compiles them in separate steps. Each .c file becomes an object file (.o) with gcc -c, and a final gcc call links the object files into the program billing:

cat > tariff.h <<'EOF'
#ifndef TARIFF_H
#define TARIFF_H

/* Price in ten-thousandths of a euro per kWh: 2700 = EUR 0.2700 */
#define FIXED_PRICE 2700L

long cost_cents(long kwh, long price);

#endif
EOF

cat > tariff.c <<'EOF'
#include "tariff.h"

/* Rounds ten-thousandths of a euro to whole cents (half up). */
static long round_cents(long ten_thousandths)
{
    return (ten_thousandths + 50) / 100;
}

long cost_cents(long kwh, long price)
{
    return round_cents(kwh * price);
}
EOF

cat > main.c <<'EOF'
#include <stdio.h>
#include "tariff.h"

int main(void)
{
    long cents = cost_cents(350, FIXED_PRICE);
    printf("350 kWh costs EUR %ld.%02ld\n", cents / 100, cents % 100);
    return 0;
}
EOF

gcc -std=c17 -Wall -Wextra -Werror -c tariff.c
gcc -std=c17 -Wall -Wextra -Werror -c main.c
gcc -o billing main.o tariff.o
./billing
350 kWh costs EUR 94.50
Separate compilation. tariff.h is included by both tariff.c and main.c. The compiler turns tariff.c into tariff.o and main.c into main.o, each on its own. The linker joins tariff.o and main.o into the executable billing. tariff.h tariff.c main.c #include gcc -c gcc -c tariff.o main.o linker billing
Each source file is compiled on its own, with the header copied in; the linker joins the object files into one program.

The #ifndef TARIFF_H … #endif lines are an include guard: they make sure the header's contents are seen only once per source file, even when it is included twice through other headers. Include guards and the other preprocessor directives, and building the same program with a Makefile, are explained on the preprocessor and build tools. How the compiler and linker steps fit together is on compiling and running C.

What belongs in a header

A header is copied into every source file that includes it. Anything it defines is therefore defined once per file, which the linker rejects for functions and variables. The rule of thumb:

In the header (.h) In one source file (.c)
function prototypes: long cost_cents(long kwh, long price); function definitions with their bodies
extern declarations: extern int report_interval_s; the variable's definition: int report_interval_s = 60;
macros, struct, enum and typedef declarations static helper functions and variables that no other file needs

Every header should also include the headers it needs itself, so that it works whatever order the source files include things in. Small helper functions are sometimes defined in a header as static inline; that is the one common exception to the rule.

Linkage: which names the linker sees

Linkage decides whether the same name in different places refers to the same function or variable. A name with external linkage is visible to the linker, so every file in the program can use it; functions and file-level variables have external linkage by default. A name with internal linkage is private to its own source file: another file can use the same name for something else without conflict. Local variables inside a function have no linkage at all.

The tool nm lists the names (symbols) in an object file. In the billing example, cost_cents is shown with a capital T (code with external linkage) and the static helper round_cents with a lowercase t (internal); U marks a name the file uses but expects the linker to find elsewhere:

cat > tariff.c <<'EOF'
static long round_cents(long ten_thousandths)
{
    return (ten_thousandths + 50) / 100;
}

long cost_cents(long kwh, long price)
{
    return round_cents(kwh * price);
}
EOF

cat > main.c <<'EOF'
#include <stdio.h>

long cost_cents(long kwh, long price);

int main(void)
{
    printf("%ld\n", cost_cents(350, 2700));
    return 0;
}
EOF

gcc -std=c17 -Wall -Wextra -Werror -c tariff.c main.c
echo "--- tariff.o"
nm tariff.o
echo "--- main.o"
nm main.o
--- tariff.o
000000000000003c T cost_cents
0000000000000000 t round_cents
--- main.o
                 U cost_cents
0000000000000000 T main
                 U printf

static: private to the file

The keyword static has two jobs in C, depending on where it stands. At file level, in front of a function or variable, it gives the name internal linkage. Inside a function, in front of a local variable, it gives the variable static storage duration (below). Make every function and file-level variable static unless another file really needs it: the smaller the set of shared names, the fewer clashes and the easier the code is to change.

Calling a static function from another file compiles fine, as long as there is a prototype, but fails when linking. Recorded with gcc 12 (the temporary object file name changes on every run and is shown here as /tmp/ccXXXXXX.o):

/usr/bin/ld: /tmp/ccXXXXXX.o: in function `main':
main2.c:(.text+0x10): undefined reference to `round_cents'
collect2: error: ld returned 1 exit status

Linker errors look different from compiler errors: they name no source line, only an object file and an offset inside it, and they appear only after every file compiled without errors. "undefined reference" means a declared name was never defined in any object file or library that was linked, often because a .c file or a library such as -lm was left off the command line.

extern: one definition, many declarations

A variable that several files share must be defined in exactly one of them. The keyword extern declares a variable without defining it: it tells the compiler "this exists, with this type, somewhere else", and the linker connects all uses to the single definition. The declaration goes in the header, the definition in one source file:

cat > settings.h <<'EOF'
#ifndef SETTINGS_H
#define SETTINGS_H

extern int report_interval_s;     /* declaration: defined in settings.c */

#endif
EOF

cat > settings.c <<'EOF'
#include "settings.h"

int report_interval_s = 60;       /* definition: the factory default */
EOF

cat > main.c <<'EOF'
#include <stdio.h>
#include "settings.h"

int main(void)
{
    printf("factory default: every %d s\n", report_interval_s);
    report_interval_s = 10;
    printf("pilot setting:   every %d s\n", report_interval_s);
    return 0;
}
EOF

gcc -std=c17 -Wall -Wextra -Werror -o dongle main.c settings.c
./dongle
factory default: every 60 s
pilot setting:   every 10 s

The numbers echo the Kestrel Home pilot's dongle, which averages power over 10 seconds instead of its factory default of 60; the code is a teaching example, not the manufacturer's firmware. Defining the variable in two files, for instance by putting int report_interval_s = 60; in the header, makes the link fail. Since GCC 10 (which defaults to -fno-common) that is an error even when neither definition has an initializer; older GCC versions silently merged such definitions. Recorded:

/usr/bin/ld: /tmp/ccXXXXXX.o:(.data+0x0): multiple definition of `report_interval_s'; /tmp/ccYYYYYY.o:(.data+0x0): first defined here
collect2: error: ld returned 1 exit status

Global variables are best kept few. Every file can change them, so a value that is wrong at run time could have been set anywhere. A static variable with a pair of small functions to read and change it keeps control in one file.

Scope and storage duration

Two different questions are easy to mix up. Scope is where in the source text a name can be used. A name declared inside a block has block scope, from its declaration to the closing brace; a name declared outside every function has file scope. Storage duration is how long the object behind the name exists while the program runs:

Storage duration Which objects Lives
automatic local variables and parameters from entering the block until leaving it, usually in the function's stack frame
static file-level variables, and local variables marked static the whole run of the program; initialized once, before main starts, to zero if no value is given
allocated memory from malloc and friends until free (see dynamic memory)
thread variables marked _Thread_local (C11) as long as their thread

A static local variable combines block scope with static storage duration: only its function can see it, but it keeps its value from one call to the next. Its initializer runs once, not on every call:

#include <stdio.h>

static int next_sequence(void)
{
    static int last = 0;        /* initialized once, kept between calls */
    int step = 1;               /* created anew on every call */

    last += step;
    return last;
}

int main(void)
{
    for (int i = 0; i < 3; i++) {
        printf("telegram %d\n", next_sequence());
    }
    return 0;
}
telegram 1
telegram 2
telegram 3

An automatic variable without an initializer is not zero: it holds whatever was in that memory before. gcc refuses to compile an obvious read of one (‘total’ is used uninitialized), but it cannot see every case, so give every local variable a value when you declare it.

Shadowing

A name declared in an inner block hides the same name from an outer block or from file scope for the rest of that block. That is legal, and a source of confusing bugs:

#include <stdio.h>

int total = 0;                          /* file scope */

int main(void)
{
    int readings[3] = {120, 95, 130};

    for (int i = 0; i < 3; i++) {
        int total = readings[i] * 2;    /* a new variable that hides the global */
        printf("doubled: %d\n", total);
    }
    printf("global total: %d\n", total);
    return 0;
}
doubled: 240
doubled: 190
doubled: 260
global total: 0

Neither -Wall nor -Wextra reports this; the separate flag -Wshadow does.

main, recursion and variadic functions

main is the function the startup code calls. It has one of two standard forms: int main(void), or int main(int argc, char *argv[]) to receive the command-line arguments as an array of strings (argv[0] is the program name). Its return value becomes the program's exit status: 0 means success; how to report failure is covered on error handling.

A C function may call itself. Each call gets its own stack frame with its own copies of the parameters and locals, exactly as described on recursion. C does nothing to limit the depth: recursion that goes too deep runs out of stack and the program is killed with a segmentation fault. On a microcontroller with a few kilobytes of stack per task, firmware usually avoids recursion entirely.

Functions such as printf accept a variable number of arguments. Writing your own uses <stdarg.h>; the compiler cannot check those extra arguments, which is why gcc has special checks for printf-style format strings (see input, output and files).