Skip to content

force push

Modular Programming in C

C-programming7 min read

Introduction

Today I'm going to demonstrate the concept of modular programming in C. Together, we'll build a simple integer arithmetic calculator. By the end of this, you'll have an idea of how to do modular programming in C with the use of header files, library files, include guards, and Makefile.

Our program will function like this:

1$ ./calculator 1 + 1
22
3$ ./calculator 4 - 2
42
5$ ./calculator 2 '*' 3
66
7$ ./calculator 6 / 2
83

You can see the full source of the final product here.

What's Modular Programming?

Modular programming is a concept in software design where the program is subdivided into independent modules that are easy to interchange and modify. It's a critical part of making your software easily maintainable, because the last thing you want is a future developer (oftentimes it's you, 6 months from now) cursing your name while trying to decipher a 1000-line main.c file.

If you want to read more about modular programming, I highly recommend taking a look at the Wikipedia article on modular programming. It's especially important in setting up a robust version control workflow involving atomic commits – they're easy to track, revert, and understand.

In the context of our example, our modules are going to be C files. We'll use features of GCC and Make to link our modules together to create a single executable.

Step 1: Organization

The first step of programming is to plan out its structure. We'll build our calculator with the following files:

  • a calc.c file: this will handle commandline arguments that we pass to the executable.
  • an operations.c file: this contains functions implementing the operations our calculator can do.
  • an operations.h file: this contains the function prototypes of operations.c and will be included from calc.c.

Let's create these files in our repo (visible at commit d59597e):

screenshot If you examine calc.c you'll notice I already put a skeleton main() in the file. All files also have the requisite license boilerplate.

Now let's fill in some of the #include statements for our .c files:

1/* calc.c */
2#include <stdio.h>
3#include "operations.h"
4
5int main(int argc, char *argv[])
6{
7 return 0;
8}
1/* operations.c */
2#include "operations.h"

Before we try compiling for the first time, we first need to put an include guard in operations.h:

1/* operations.h */
2#ifndef OPERATIONS_H
3#define OPERATIONS_H
4/* TODO: function prototypes here */
5#endif /* OPERATIONS_H */

The need for an include guard is because of how the preprocessor portion of GCC works: it looks for any preprocessor directives (denoted by anything beginning with a # symbol) and does a text replacement in the C source code for that file as needed before compiling it. If any symbol (variables, functions, etc) is defined more than once in a source file before compilation, GCC will yell at you. The #ifndef OPERATIONS_H-#define OPERATIONS_H-#endif preprocessor directives essentially tells the preprocessor not to include the enclosed sourcecode if OPERATIONS_H has already been included. This allows both calc.c and operations.c to include operations.h without any errors or warnings being thrown by GCC when we compile it all with:

1$ gcc -c calc.c operations.c
2$ gcc -o calculator calc.o operations.o

You can read more about include guards here.

At this point, we can make a new commit (see 77bf9b6).

Step 2: Automating the Build

You might have noticed that the GCC commands above are a bit verbose to run each time. Let's automate that with a Makefile. I won't go into too much detail about what's going on here because that deserves its own post, but the tl;dr is that Make looks at a (carefully crafted) Makefile to determine the minimum compilation commands required for the executable to reflect changes in source code. I highly recommend looking at the O'Reilly book on Make, available for free here.

Here's the Makefile:

1CC = /usr/bin/gcc
2CFLAGS = -Wextra -Wpedantic
3
4# Project files
5SRCS = calc.c operations.c
6OBJS = $(SRCS:.c=.o)
7EXE = calculator
8DEPS = %.h
9
10.PHONY: clean
11
12###############################################################################
13# Release rules #
14###############################################################################
15# Create final executable
16$(EXE): $(OBJS)
17 $(CC) $(CFLAGS) -o $@ $^
18
19# Compile and assemble source into object files
20%.o: %.c $(DEPS)
21 $(CC) -c $(CFLAGS) -o $@ $<
22
23# Clean rules
24clean:
25 rm -f $(EXE) $(OBJS)

Now we can just run make to compile the executable each time:

1$ make
2/usr/bin/gcc -c -Wextra -Wpedantic -o operations.o operations.c
3operations.c:24:24: warning: ISO C requires a translation unit to contain at
4 least one declaration [-Wempty-translation-unit]
5#include "operations.h"
6 ^
71 warning generated.
8/usr/bin/gcc -Wextra -Wpedantic -o calculator calc.o operations.o

We're getting some warnings because I've chosen to include the GCC flags -Wextra and -Wpedantic. Generally it's good practice to compile with these flags because they'll warn you when you try to compile things like a source file with no declarations, as shown above.

I can also run make clean to run the clean rule defined in Makefile:

1$ make clean
2rm -f calculator calc.o operations.o

You can browse the state of the repository at this step here.

Step 3: Automating Tests

In the Introduction, we described what functions calculator will have and the outputs we expect. We can automate the process of testing each function in a simple shellscript to save us some time.

1# test.sh
2#!/usr/bin/env bash
3
4echo "test: ./calculator 1 + 1"
5./calculator 1 + 1
6
7echo "test: ./calculator 4 - 2"
8./calculator 4 - 2
9
10echo "test: ./calculator 2 '*' 3"
11./calculator 2 '*' 3
12
13echo "test: ./calculator 6 / 2"
14./calculator 6 / 2

note that the * symbol has to be single-quoted to prevent shell expansion

You can browse the state of the repository at this step here.

Step 4: (Finally) Implementing Features

Now we can finally write some C code! Let's first declare our functions in operations.h:

1/* operations.h */
2#ifndef OPERATIONS_H
3#define OPERATIONS_H
4
5int add(int x, int y);
6
7int subtract(int x, int y);
8
9int multiply(int x, int y);
10
11int divide(int x, int y);
12#endif /* OPERATIONS_H */

Now let's implement them in operations.c:

1/* operations.c */
2#include "operations.h"
3
4/* Returns the int result of x + y where x and y are type int */
5int add(int x, int y)
6{
7 return x + y;
8} /* add() */
9
10/* Returns the int result of x - y where x and y are type int */
11int subtract(int x, int y)
12{
13 return x - y;
14} /* subtract() */
15
16/* Returns the int result of x * y where x and y are type int */
17int multiply(int x, int y)
18{
19 return x * y;
20} /* multiply() */
21
22/* Returns the int result of x / y where x and y are type int */
23int divide(int x, int y)
24{
25 return x / y;
26} /* divide() */

At this point, we only have to deal with the commandline arguments in calc.c and then we're done! Here's calc.c:

1/* calc.c */
2#include <stdio.h>
3#include <stdlib.h>
4#include "operations.h"
5
6int main(int argc, char *argv[])
7{
8 int num1;
9 char operation;
10 int num2;
11 int result;
12
13 if (argc != 4)
14 return EXIT_FAILURE;
15 num1 = atoi(argv[1]);
16 operation = argv[2][0];
17 num2 = atoi(argv[3]);
18
19 switch (operation) {
20 case '+':
21 result = add(num1, num2);
22 break;
23 case '-':
24 result = subtract(num1, num2);
25 break;
26 case '*':
27 result = multiply(num1, num2);
28 break;
29 case '/':
30 result = divide(num1, num2);
31 break;
32 }
33
34 printf("%d\n", result);
35
36 return EXIT_SUCCESS;
37}

Let's make the program and test it:

1$ make
2/usr/bin/gcc -Wextra -Wpedantic -c -o calc.o calc.c
3/usr/bin/gcc -c -Wextra -Wpedantic -o operations.o operations.c
4/usr/bin/gcc -Wextra -Wpedantic -o calculator calc.o operations.o
5$ ./test.sh
6test: ./calculator 1 + 1
72
8test: ./calculator 4 - 2
92
10test: ./calculator 2 '*' 3
116
12test: ./calculator 6 / 2
133

Looks good!

You can browse the state of the repository at this step here.

Wrap-Up

This program demonstrates modular programming using C header files, #ifndef OPERATIONS_H-#define OPERATIONS_H-#endif preprocessor directives, and Make. It's by no means the most robust way of implmenting an integer calculator – for example, it assumes input is formatted correctly and doesn't handle integers beyond the width of int. Perhaps most glaringly, it is overengineered since an integer calculator can easily be implemented in a single-file C program. However, by now you should have a good grasp of modular programming in C.

The repository in its final state can be viewed here.

Questions? Comments? Write to me at [email protected].