Managing Modularity: Makefiles and Libraries

Managing Modularity: Makefiles and Libraries

Norman Matloff

Department of Computer Science
University of California at Davis
(530) 752-1953
matloff@cs.ucdavis.edu

July 4, 2005

Contents

1  Technical Overview
    1.1  Managing Size and Complexity
    1.2  "Did I Remember to Recompile That File or Not?"
2  Example
    2.1  Overview of the Files
    2.2  The Files' Contents
    2.3  Use of Header Files
    2.4  Details of Using `make'
3  More Sophisticated Use of Make
4  Library Archive Files
    4.1  The Basics
    4.2  Advanced Material
        4.2.1  Dynamic Libraries
        4.2.2  How Can One Tell What Is in a .a File?

1  Technical Overview

1.1  Managing Size and Complexity

In commercial software engineering environments, a program is typically large and complex. In order to better manage that size and complexity, good software practice is, as you know, to use modular, top-down design, with a large number of function calls. There typically are dozens, even hundreds of functions.
During the debugging of such a program, we will make a change to the source code, then compile and run, then make another change to the source code, then compile and run again, and so on. However, this is wasteful: We usually will change only a small part of the program each time, but we must recompile the entire program, including the parts which were not changed and thus should not have to be recompiled.
With this problem in mind, we typically will split the source code into a large number of files, rather than just one file. We may, for example, have a separate file for each significant function, and maybe one more file for the small, miscellaneous functions. Each file will have a name whose suffix is .c. We will compile each of these files separately, yielding machine-language files with .o suffices. Finally, we will use the linker, ld (which is a separate program from the compiler, but is called by the compiler), to link all the .o files into one executable file named a.out or some other name which we choose ourselves. When we subsequently make a change to our program, if that change affects only one of the .c files, we only recompile that file, forming a revised .o file. We then link again.
This saves a large amount of time! A large program might take, say, 10 or 15 minutes to compile in its entirety, whereas with the approach described here we might only recompile a small portion of the program. The time saved-and far more important, the minimizing of the interruption to the programmer's train of thought during the debugging process-can increase productivity tremendously.

1.2  "Did I Remember to Recompile That File or Not?"

In the frenzy of the debugging process, it is easy to get confused. One might make a couple of changes to some of the source files, but then when recompiling them lose track of which files one has recompiled and which still remain to be recompiled. To simplify life, the Unix make program is used.1
The make program will automatically decide whether a file needs to be recompiled or not. The way it does this is quite simple: If the last date/time of modification for the .c file is later than that of the corresponding .o file, the latter is obviously out of date, and thus the former must be recompiled. Whenever the programmer makes a change (or several changes), he/she simply types `make', and the required recompiling and relinking will be done automatically.
Also, if we have a set of modules which will be used by many programs, we create a library out of them. Then in compiling any program which uses that set of modules, we simply include the library as if it were a .o file.

2  Example

Here I have taken our program WC.c and split it into a number of files, and also set up a file for make to use.

2.1  Overview of the Files

Here are the contents of my directory:
Defs.h          Main.c          ReadLine.c      WordCount.c
ExternVars.h    PrintLine.c     UpdateCounts.c

I have placed each function in its own file, e.g. I've put the function ReadLine() in the file ReadLine.c. Main.c contains main(), and also the declarations of the global variables. For each of the latter, I have put an extern entry in the file ExternVars.h; accordingly, in each of the .c files except Main.c, I have put in a line
#include "ExternVars.h"

I've also made a file Defs.h for any #define lines which I need. The file Makefile contains the instructions for make.

2.2  The Files' Contents

Here are the listings of the various files:
ExternVars.h
extern char Line[MaxLine];  
extern int NChars,NWords,NLines,LineLength;

Defs.h
#define MaxLine 200  

Main.c
/* introductory C program 

   implements (a subset of) the Unix wc command  --  reports character, 
   word and line counts; in this version, the "file" is read from the 
   standard input, since we have not covered C file manipulation yet, 
   so that we read a real file can be read by using the Unix `<'
   redirection feature */


#include "Defs.h"


extern int ReadLine(),WordCount();


char Line[MaxLine];  /* one line from the file */


int NChars = 0,  /* number of characters seen so far */
    NWords = 0,  /* number of words seen so far */
    NLines = 0,  /* number of lines seen so far */
    LineLength;  /* length of the current line */ 


main()  

{  while (1)  {
      LineLength = ReadLine();
      if (LineLength == 0) break;
      UpdateCounts();
   }
   printf("%d %d %d\n",NChars,NWords,NLines);
}

PrintLine.c
#include "Defs.h"
#include "ExternVars.h"


PrintLine()  /* for debugging purposes only */

{  int I;

   for (I = 0; I < LineLength; I++) printf("%c",Line[I]);
   printf("\n");
}

ReadLine.c
#include "Defs.h"
#include "ExternVars.h"


int ReadLine()

/* reads one line of the file, returning also the number of characters
   read (including the end-of-line character); that number will be 0
   if the end of the file was reached */

{  char C;  int I;

   if (scanf("%c",&C) == -1) return 0;
   Line[0] = C;
   if (C == '\n') return 1; 
   for (I = 1; ; I++) {
      scanf("%c",&C);     
      Line[I] = C;  
      if (C == '\n') return I+1;
   }  
}

WordCount.c
#include "Defs.h"
#include "ExternVars.h"


int WordCount()

/* counts the number of words in the current line, which will be taken
   to be the number of blanks in the line, plus 1 (except in the case 
   in which the line is empty, i.e. consists only of the end-of-line 
   character); this definition is not completely general, and will be
   refined in another version of this function later on */

{  int I,NBlanks = 0;  

   for (I = 0; I < LineLength; I++)  
   if (Line[I] == ' ') NBlanks++;

   if (LineLength > 1) return NBlanks+1;
   else return 0;
}

UpdateCounts.c
#include "Defs.h"
#include "ExternVars.h"


extern int WordCount();


UpdateCounts()

{  NChars += LineLength;
   NWords += WordCount();
   NLines++;
}

Makefile
  3	WC: Main.o ReadLine.o WordCount.o UpdateCounts.o PrintLine.o 
  4	        cc -g -o WC Main.o ReadLine.o WordCount.o UpdateCounts.o \
                PrintLine.o 
  5	
  6	Main.o: Main.c Defs.h ExternVars.h
  7	        cc -g -c Main.c
  8	
  9	ReadLine.o: ReadLine.c Defs.h ExternVars.h
 10	        cc -g -c ReadLine.c
 11	
 12	WordCount.o: WordCount.c Defs.h ExternVars.h
 13	        cc -g -c WordCount.c
 14	
 15	UpdateCounts.o: UpdateCounts.c Defs.h ExternVars.h
 16	        cc -g -c UpdateCounts.c
 17	
 18	PrintLine.o: PrintLine.c Defs.h ExternVars.h
 19	        cc -g -c PrintLine.c

(Note that I have added line numbers only to Makefile, but even then, keep in mind that that file does not actually contain these numbers.)

2.3  Use of Header Files

All the .c files above have a line
#include "Defs.h"

What an include statement does is tell the compiler to make a copy of the indicated file and compile it together with the file containing the include. For example, when the file PrintLine.c is being compiled, the compiler will see the include, and simply pretend that the entire contents of Defs.h had been in PrintLine.c.
All the .c files except for Main.c also have a line
#include "ExternVars.h"

Again, when the compiler is compiling, say, WordCount.c, it will simply act as if all the lines in ExternVars.h were in WordCount.c. This is a convenient way to get all the extern statements we need into WordCount.c, ReadLine.c and so on. It's just a convenience to avoid typing, but it also helps clear our minds-we don't have to remember to put in all the extern lines in each of these files.
Note that it wouldn't make sense to have the line
#include "ExternVars.h"

in Main.c, since the global variables are being declared there, thus making extern statements for these variables incorrect.
Note too that I have put in extern's for the functions; e.g. I have the line
extern int ReadLine(),WordCount();

in Main.c. I don't really need this, since C assumes a function's return value type is int by default, but I would need it for non-int types, and thus it is a good habit to have such a statement even though the two functions concerned here are of int type.
The .h files should be for variable declarations, "define's" and other include's. They should not include any program statements (other than macros).

2.4  Details of Using `make'

Now let's take a look at the file Makefile. It consists of a series of dependency/build entries; Lines 3-4 comprise one such entry, Lines 6-7 form another, etc.2 Each such entry tells first, what other files a given file depends on, and second, how to rebuild the given file if one of the ones it depends on changes.
For instance, look at Lines 3-4. Here Line 3 is a dependency line, while Line 4 is a command line. The file WC, which is our executable file for the program, is called a target file, meaning a file which we hope to build; Lines 3-4 provide information
Specifically, Line 3 says that WC depends on a number of .o files, i.e. Main.o, ReadLine.o, WordCount.o, UpdateCounts.o and PrintLine.o. So, if make is told to produce the file WC, it will check whether the current version of WC is at least as new as these .o files; if even one of these files is newer than WC, make will see that WC is out of date, and will rebuild WC, using the prescription in the command line, Line 4.
A couple of things about the latter should be noted. First, we are using the -o option of cc, which states that we don't want the default name of a.out for our executable file; we want the executable file to be named WC. Second, note that there are no .c files in this command at all, so there is no compiling to be done; why, then, are we using cc?
The answer to the last question is that cc automatically calls ld, the Unix linker command; this is always done, but you have not had a chance to be aware of it before now. In this case, we need all those .o files to be linked together into one executable file (WC), and calling cc will result in ld being called to do the linking.3
In general, a makefile consists of sets of lines of the form we see in Lines 3-4, i.e. in the form
target: dependency components
TAB shell command
TAB shell command
TAB shell command
...

(where TAB means the tab key, not the string `TAB'). Note that for instance Line 4 is a shell command.4
Let's see make in action:
  1 heather% ls
  2 Defs.h          Main.c          PrintLine.c     UpdateCounts.c  typescript
  3 ExternVars.h    Makefile        ReadLine.c      WordCount.c
  4 heather% make
  5 cc -g -c Main.c
  6 cc -g -c ReadLine.c
  7 cc -g -c WordCount.c
  8 cc -g -c UpdateCounts.c
  9 cc -g -c PrintLine.c
 10 cc -g -o WC Main.o ReadLine.o WordCount.o UpdateCounts.o PrintLine.o
 11 heather% ex WordCount.c
 12 :1
 13	
 14	:i
 15	#define ZERO 0
 16	.
 17	:wq
 18	Wrote "WordCount.c"  26 lines, 582 characters
 19	heather% make
 20	cc -g -c WordCount.c
 21	cc -g -o WC Main.o ReadLine.o WordCount.o UpdateCounts.o PrintLine.o

At first, we see that there are no .o files at all (Lines 2-3). When we type "make" (Line 4), make will read the file Makefile and try to build the first item it encounters (if it is not already up-to-date), which is WC, which depends on all the .o files (Line 3 of the Makefile listing). Since none of those files exist, make will have to generate them, and to do so, it looks further down in the file Makefile. For example, it sees on Line 6 of the Makefile that it can build Main.o by doing "cc -g -c Main.c" (the -c means compile only, i.e. just produce a .o file, and don't call ld), and it goes ahead and does so (Line 5 of the script file). After generating all the .o files, it then links them (Line 10 of the script file). Now all the .o files have been generated, as has WC.
Now let's update a file, say WordCount.c, and see what happens. Lines 11-18 of the script file show me making a change to this file, by adding a #define line (I used the ex editor so that it could be seen easily within the script file).
Now let's see how make will handle this change (Lines 19ff). What happened is that make found that WC depends on WordCount.o (Line 3 of Makefile), which in turn depends on WordCount.c (Line 12 of Makefile), and make noticed that the timestamp on WordCount.c was later than that of WordCount.o, i.e. the latter was out-of-date. So, make first recompiled WordCount.c, to produce an up-to-date WordCount.o, and then linked all the .o files together to produce an up-to-date file WC.
By the way, we can also specify individual targets with make. For exmaple, if for some reason we wanted to only build ReadLine.o, we could type
make ReadLine.o

Note the cc will also call ld, the linker. In Line 21, for example, the real work of linking all those .o files together into an executable file will be done by ld. For this reason, the cc command line might include some options which you don't find in the man page for cc; these are options for ld, and will be passed on to the latter by cc.

3  More Sophisticated Use of Make

The make program is extremely powerful, and is capable of much more than we see in this simple introduction. For example, one can define variables, as in the following version of Makefile:
CC = cc

wc: Main.o ReadLine.o WordCount.o PrintLine.o 
	$(CC) -o wc Main.o ReadLine.o WordCount.o PrintLine.o 

Main.o: Main.c Include.h
	$(CC) -g -c Main.c

ReadLine.o: ReadLine.c Include.h
	$(CC) -g -c ReadLine.c

WordCount.o: WordCount.c Include.h
	$(CC) -g -c WordCount.c

PrintLine.o: PrintLine.c Include.h
	$(CC) -g -c PrintLine.c

Here we have defined the variable CC (when referring to it, we must include the dollar sign and parentheses) to be cc, the usual C compiler. The advantage of doing this is that if we instead wanted to use the Gnu C compiler, gcc, all we would have to do is change that one line in Makefile to
CC = gcc

instead of having to change all the lines where cc had been used. For large, complex programs with very long makefiles, this is a big help.
Note that any shell command can be put in the target entries; this is also a very powerful feature.
To learn more, see the man page as a first step, and the GNU documentation (available with make at ftp sites).

4  Library Archive Files

4.1  The Basics

When we have a collection of functions which often use, it is convenient to collect their compiled versions into a library archive file, which has a suffix of, for example, .a. Say for example we have some functions in files x.c and y.c. After compiling them into .o files (using cc's -c option), we could then type
ar r z.a x.o y.o

This would create the file z.a containing x.o and y.o. (After running ar, we often will follow up with ranlib, which will add an index of the contents to the file.) If we then had a source file w.c which made calls to functions in x.c and y.c, we could simply type
cc w.c z.a

The compiler would compile w.c and then link the modules in z.a to it, resulting in an executable file a.out.
You see above how you can make your own library archives. There are also archives for the C library, for the X11 windows functions and so on, which are on every Unix system (though they may be in different directories). If for example you want to call the C library's square root function, sqrt(), you put "-lm" on your compiler command line, e.g.
cc r.c -lm

In doing so you are also linking in a library archive, libm.a, which contains all the C library math functions. The notation "-lsomething" means, "link in an archive named libsomething.a"
Where does the compiler find these library archives? First, the compiler knows to search in certain "official" directories, such as /usr/lib. But if you have your own archive elsewhere, you have various options available to you.
For example, you can link archives in explicitly, by placing the full path name on the compiler command line, e.g.
cc abc.c /u/v/libq.a

(Note again that here cc is not only compiling abc.c, but is also linking the archive file /u/v/libq.a.)
Or, if your archive is named libsomething.a, you can use the compiler's -L option, say
cc def.c -L/u/v -lq

This tells the compiler that it should add the directory /u/v to the list of directories it searches for libraries, including in this case libq.a.
Similarly, the compiler's -I option tells the compiler to add the given directory to the list of directories where the compiler searches for include-files specified by "elbows."
For instance, say the file ghi.c contains a line
#include <zzz.h>

Then
cc -I/m/n ghi.c

tells the compiler to look for this include-file (and others) in /m/n, in addition to its standard spot /usr/include.
Typical makefiles contain many -L and -I constructs.

4.2  Advanced Material

4.2.1  Dynamic Libraries

By default, libraries are statically linked. For example, in our command line above,
cc abc.c /u/v/libq.a

then the resulting executable file, a.out, will contain a copy of libq.a.
But if many programs use libq.a, this would be wasteful of disk space, since that would mean that many copies of libq.a would be stored on disk.
A better alternative would be dynamic linking. In this case, instead of a.out containing a copy of libq.a, there would be "note" in that file explaining that libq.a is needed, and that when a.out is loaded, the file libq.a should be loaded together with a.out. This way, the disk would have only one copy of libq.a, not many.
In Unix, the dynamically-linked library files are generally given .so ("shared object") suffixes in their names, e.g. libm.so.6 for the math library, where the `6' indicates a version number.5
Creation of dynamically-linkable libraries requires careful usage of the compiler, linker and the shell environment. It is not enough to use the -L and -l command-line options at compile time, because at run time the loader will not know which directories to look in to find the dynamic library specified by the "note."
One approach to solving this problem is to have the user set the environment variable LD_LIBRARY_PATH. In the C shell, for example, the directories /x/y/z can be added to this path via the command
setenv  LD_LIBRARY_PATH /x/y/z

This is the easy way to go, and I recommend it. However, you may not want to bother the user with this, and there are other reasons to avoid it.6 Or you can add a line
/x/y/z

to your file /etc/ld.so.conf, which will take effect the next time you boot up.
The GNU version of C/C++ compiler, gcc, features a command-line option, -fPIC, specifying that the compiler create position-independent code, while allows it to exist anywhere in memory, rather than at a particular distance from a.out. The ld command-line option -rpath (again, this is typically passed to ld by gcc), results in a note in a.out specifying which directory to look for the library if the OS does not find it in the places the OS usually searches. The ld option -Bdynamic specifies that the executable file a.out load its libraries dynamically.
The original library load path is gotten at bootup from the file /etc/ld.so.conf
A lot of this can be automated if one uses a GNU program, libtool, when invoking gcc. The command line will have the form
libtool gcc <options>

So, here we are actually running libtool, but it in turn fires up gcc. The design of libtool is such that it will provide exactly the right options to gcc, e.g. -fPIC, in order to produce dynamically-linked executables.7
Sometimes you might download a binary package from the Web, e.g. free RPM packages for Linux, only to find that they tell you a library is missing. You can use the ldd command to get more detailed information, and then either download the missing libraries, or if you have them, put them in directories where the OS will see them.

4.2.2  How Can One Tell What Is in a .a File?

Say we have libabc.a. Then the two commands,

ar t libabc.a
nm -s libabc.a


will tell us which files libabc.a was created from, and which symbols - function and variable names - are in the file.

Footnotes:

1This became so popular that versions of make were later developed for PCs running Windows, etc., and make is now considered a standard software engineering tool, as indispensable as a keyboard.
2By the way, if you need to have a very long entry, spanning several lines, place a backslash at the end of each line, serving as a continuation character. Do not-do NOT!-put lines longer than 80 characters in your Makefile, as it may produce bizarre errors which you-and others whom you might consult-will waste a large amount of time trying to figure out.
3We could call ld ourselves, but this is rather complicated.
4Of course, all the special options of cc which we are using here, e.g. -o in Line 4, belong to cc, not to make; we could use these same options if we were invoking cc from the shell.
5Often you will see what appear to be two or more files with similar-looking names, e.g. /lib/libm-2.1.2.so and /lib/libm.so.6, but usually the files will be identical, one being a symbolic link of the others, set up using the Unix ln command.
6See "Why LD_LIBRARY_PATH is bad," http://www.visi.com/ barr/ldpath.html
7In intermediate steps, by the way, libtool creates files with suffixes of .lo intead of .o and .la instead of .a, so if you see these files when you compile packages made available on the Web, for example, you will know what they are.


File translated from TEX by TTH, version 3.30.
On 4 Jul 2005, 14:20.