| Tutor | Hao Ren |
| hao.ren@sydney.edu.au |
- COMP2017 2026 S1 Week 7 Tutorial B
- B.1 Compilation: The C Pre Processor
- B.2 Compilation: The Compiler
- B.3 Compilation: The Assembler
- B.4 Compilation: The Linker
- B.5 External Linkage & Internal Linkage
- B.6 Keywords:
extern,static - B.7 Keywords:
constandrestrict - B.8 Exercise: Trees
- B.9 Exercise: Basic File System
- B.10 Exercise: Market
- B.11 Exercise: Tiny Delegator
- B.12 Exercise: Generic HashMap
The preprocessor runs before the real compiler. It handles things like #include, #define, and conditional compilation. A useful way to think about it is: the preprocessor mainly rewrites the source text before C compilation really begins.
It resolves:
#include#define#ifdef,#ifndef,#endif- generally, anything starting with
#
These are not ordinary C statements.
#define SIZE 4
int arr[SIZE];After preprocessing, this is effectively:
int arr[4];To inspect the preprocessed output:
cpp tasks.cand
gcc -E tasks.cThis is especially useful when macros behave strangely.
After preprocessing, the compiler translates C into assembly.
Assembly is still human-readable text, but it is architecture-specific. That means it is no longer portable C source.
A short example command:
gcc -S -g -std=c11 -Wall -Werror demo.cThis produces something like:
demo.s
That .s file contains assembly instructions for the target CPU.
For following codes,
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
int main() {
int x = 1;
int y = 2;
return add(x, y);
}it become assembly instructions that load values into registers, perform an add instruction, and return.
The assembler takes assembly code and turns it into an object file.
To compile and assemble into an object file:
gcc -c -g -std=c11 -Wall -Werror list.cThis produces something like:
list.o
An object file contains machine code, but usually also symbol information and relocation information. It is not yet a full executable program.
You can inspect object files with:
objdump -M intel -S list.oWe could understand these files as:
.cfile = source code.sfile = assembly text.ofile = assembled object code
Also, each .c file is a separate translation unit, so in a multi-file program you usually get one object file per .c file.
The linker combines object files into a final executable.
If main.c calls a function defined in helpers.c, the linker is the stage that connects those together.
Example:
gcc -c main.c
gcc -c helpers.c
gcc main.o helpers.o -o appIf the linker cannot find a required symbol, you get an "undefined reference" error.
In summary, we could say
- the compiler translates each translation unit separately,
- the linker joins them together at the end.
Linkage is about whether the same name in different translation units refers to the same thing. A translation unit is basically one .c file after preprocessing. So if a project has:
main.c
helper.c
then there are two translation units. When both files are compiled, the linker decides whether names from one file can connect to names from another file.
- external linkage: the name can be shared across
.cfiles - internal linkage: the name is private to one
.cfile only
A name with external linkage can be used from another translation unit. At file scope, ordinary functions and ordinary global variables usually have external linkage by default.
/* file1.c */
int global_count = 10;/* file2.c */
extern int global_count;
int main(void) {
return global_count;
}file1.ccontains the real definition ofglobal_countfile2.cusesexternto say "this variable exists somewhere else"
Because global_count has external linkage, the linker can connect them. So external linkage means "this name is visible outside the current translation unit".
A name with internal linkage is only visible inside the current translation unit. At file scope, this is usually created by static.
/* helper.c */
static int secret_value = 42;Now secret_value can be used inside helper.c, but another file cannot refer to it by name.
For example, this will fail:
/* main.c */
extern int secret_value; // wrong if secret_value was static in helper.cbecause secret_value is private to helper.c. So internal linkage means "this name is private to one .c file".
extern usually means the name exists, but its definition is somewhere else.
/* config.c */
int max_users = 100;/* main.c */
extern int max_users;This is the most common pattern. To avoids duplicate definitions, Only one .c file should contain the actual definition:
int max_users = 100;Other files should just declare it with extern:
extern int max_users;Another common header pattern is
/* config.h */
#ifndef CONFIG_H
#define CONFIG_H
extern int max_users;
#endif/* config.c */
#include "config.h"
int max_users = 100;/* main.c */
#include <stdio.h>
#include "config.h"
int main(void) {
printf("%d\n", max_users);
}- put the
externdeclaration in the header - put the real definition in exactly one
.cfile
Function declarations are external by default, so this:
int add(int a, int b);already behaves like an external declaration.
Writing:
extern int add(int a, int b);is valid, but usually unnecessary.
static is one of the trickiest keywords in C because it means different things in different places. There are two main cases.
At file scope, static gives internal linkage. That means the name becomes private to the current translation unit.
/* math_utils.c */
static int square(int x) {
return x * x;
}Now square can only be called inside math_utils.c. That is very useful for helper functions that should not be part of the public interface.
For a private global variable, it could also be written. as
/* counter.c */
static int current_id = 0;Again, current_id is private to counter.c. A good rule is that if a file-scope function or variable does not need to be used from another .c file, make it static.
Inside a function, static does not mean internal linkage. Instead, it means the local variable keeps its value between function calls. For example, we have
#include <stdio.h>
int next_id(void) {
static int id = 0;
id++;
return id;
}
int main(void) {
printf("%d\n", next_id());
printf("%d\n", next_id());
printf("%d\n", next_id());
}Output:
1
2
3
If id were an ordinary local variable:
int next_id(void) {
int id = 0;
id++;
return id;
}then the output would be:
1
1
1
because the variable would be recreated each time.
const means "do not modify through this name".
const int MAX_SIZE = 100;This should not be reassigned later.
With pointers, const can apply to the pointed-to value or to the pointer itself.
const int *ptr = &x;means:
*ptrshould not be modified throughptrptritself may point somewhere else later
int * const ptr = &x;means:
ptrmust always point to the same address- the value at that address can still be modified
const int * const ptr = &x;means:
- the pointer cannot move
- the value cannot be changed through it
restrict is a promise about aliasing.
If a pointer is declared restrict, it means that for that scope, the object it points to will be accessed only through that pointer or values derived from it.
A short example:
void add_arrays(int n,
int * restrict out,
const int * restrict a,
const int * restrict b) {
for (int i = 0; i < n; i++) {
out[i] = a[i] + b[i];
}
}This promises that out, a, and b do not overlap in a way that breaks the restrict rule.
That helps the compiler optimize.
Caution
restrictis not enforced automatically.- It is a promise made by the programmer.
The task is to build a tree structure, support adding children, perform DFS, find a node, and destroy the tree.
Important
Refer to the folder tree/* for codes.
tree/
├── tree.h
├── tree.c
└── main.c
tree.hcontains the struct and function declarationstree.ccontains the tree logicmain.cjust builds a sample tree and tests the functions
To compile:
gcc -std=c11 -Wall -Wextra -Werror tree.c main.c -o tree_demoTo design a tree, we could say:
- each node stores one value
- each node stores a dynamic array of child pointers
child_counttells how many children currently existchild_capacitytells how much space is allocated
Because the number of children is not known in advance, realloc() is useful for growing the child array.
struct tree_node {
int value;
struct tree_node **children;
size_t child_count;
size_t child_capacity;
};This function needs to:
- allocate memory for the node
- store the value
- initialize the children array to
NULL - set the count and capacity to
0
When adding a child:
- if the current array is full, grow it
- append the child pointer
- increase
child_count
A common growth strategy is doubling the capacity.
The required output A B E C F D matches preorder DFS:
- visit current node
- recursively visit each child from left to right
This is also naturally recursive:
- if current node matches, return it
- otherwise search each child
- return the first match found
- return
NULLif nothing matches
To free the tree safely:
- destroy all children first
- free the child array
- free the node itself
That is postorder destruction.
Important
Refer to the folder basic_fs/* for codes.
basic_fs/
├── fs.h
├── fs.c
└── main.c
fs.hcontains the file-system node definition and public operationsfs.ccontains the tree-based file-system logicmain.cimplements the shell-style command loop
To compile:
gcc -std=c11 -Wall -Wextra -Werror fs.c main.c -o basic_fsThis task is open-ended, but the cleanest solution is to model the file system as a tree.
Each node is either a file or a directory. Directories can have children, while files are leaves. A good design is one struct for both.
For my solutions, I put
typedef struct fs_node {
char *name;
int is_directory;
struct fs_node *parent;
struct fs_node **children;
size_t child_count;
size_t child_capacity;
} fs_node;This gives me:
name- whether it is a directory
- a parent pointer for
cd .. - a dynamic array of children for directories
Each shell command is really just a tree operation.
touch <fname>means create a file node and add it to the current directory.mkdir <dname>means create a directory node and add it to the current directory.lsmeans print the children of the current directory.cd ..means move toparent, unless already at root.cd <name>means find a child with that name and move into it if it is a directory.rm <name>means remove the child and destroy its whole subtree.find <name>means search the current subtree recursively.treemeans print the subtree recursively with indentation.
Following ideas could be helpful when you implement your own codes.
Without parent, cd .. becomes awkward. It also makes it easy to print the current path like:
>~/dir1/dir2
Meanwhile, if the removed node is a directory, all files and subdirectories under it should disappear too.
So rm really means:
- unlink child from parent
- destroy the subtree rooted at that child
Important
Refer to the folder market/* for codes.
market/
├── purchase_queue.h
├── purchase_queue.c
├── transactions.h
├── transactions.c
└── main.c
purchase_queue.*manages the purchase priority queuetransactions.*manages the customer linked list and receipt writingmain.chandles input and drives the workflow
To compile:
gcc -std=c11 -Wall -Wextra -Werror purchase_queue.c transactions.c main.c -o marketThe outer structure stores each customer checkout.
A linked list is a good fit because checkouts happen one after another during the day.
A transaction node stores:
- customer name
- phone number
- checkout time
- pointer to the item structure
- next transaction pointer
The exercise says items should be sorted by price.
A priority queue is a good fit for that. I can use a min-heap so the cheapest item comes out first.
Each purchase stores:
- item name
- quantity
- cost
The workflow becomes:
- read customer name
- read phone number
- read checkout time
- keep reading item lines in the form
<item> <quantity> <cost> - stop that customer when the cashier types
DONE - repeat for the next customer until EOF
At EOF:
- traverse the linked list of transactions
- create one file per transaction
- pop the purchases out of the priority queue in sorted order
- write the receipt
- free everything
Important
Refer to the folder delegator/* for codes.
delegator/
├── ops.h
├── ops.c
├── program.h
├── program.c
└── main.c
ops.*stores the arithmetic functions and operation parserprogram.*stores instruction parsing, execution, and cleanupmain.creads instructions from stdin and runs the program
To compile:
gcc -std=c11 -Wall -Wextra -Werror ops.c program.c main.c -o delegatorThis task is about turning string instructions into executable operations. A natural design is:
- one linked list node per instruction
- each node stores a function pointer
- each node also stores the operands
%means "use previous result"
typedef int (*binop_fn)(int, int);
struct instruction {
binop_fn op;
int lhs;
int rhs;
int lhs_is_prev;
int rhs_is_prev;
struct instruction *next;
};This means each node stores:
- the operation
- two operands
- two flags saying whether either operand should come from the previous result
- the next instruction
For each line like:
ADD 9 10
SUB % 10
MUL 3 %
DIV % 1
the parser needs to:
- identify the operation
- map it to the correct function pointer
- parse operand 1
- parse operand 2
- record whether
%was used
Execution is simple once the list is built:
- keep one
previousvariable - if operand is
%, useprevious - otherwise use the literal number
- call the function pointer
- store the result back into
previous
Important
Refer to the folder hashmap/* for codes.
hashmap/
├── hashmap.h
├── hashmap.c
├── hash_utils.h
├── hash_utils.c
└── main.c
hashmap.*stores the separate-chaining map implementationhash_utils.*stores reusable helper callbacks likedjb2main.cis a small demo program
To compile:
gcc -std=c11 -Wall -Wextra -Werror hashmap.c hash_utils.c main.c -o hashmap_demoThis task wants a generic hashmap with:
- separate chaining
- resizing
- generic keys and values
- callback functions for hashing, comparison, and cleanup
My solution is an array of buckets, where each bucket is the head of a linked list.
A bucket array alone is not enough, because collisions happen. So each bucket stores a linked list of entries. Each entry stores:
- key
- value
- pointer to next entry in the chain
The map itself stores:
- bucket array
- bucket count
- element count
- hash callback
- key delete callback
- value delete callback
- compare callback
To insert or search a key:
- compute the hash
- take modulo by bucket count
- walk that chain
That is the basic hashmap pattern.
When the load factor gets too high, collisions increase. So the map should resize, usually by doubling the bucket count.
When resizing, every entry must be rehashed into the new bucket array, because the modulo result depends on bucket count.