November 16, 2024
Understand Build Tool for C
Understand Build Tool for C

C build tools are essential for compiling and linking C programs. There are many build tools available, from native programs to cross-platform tools that allow you to build C projects on different operating systems. In this post, we will explore some of the most popular build tools for C and how to use them.

Introduction#

Build tools are orchestration tools that automate the process of compiling and linking source code into executable programs. They allow you to group source files, include libraries, and define compiler flags in a configuration file.

For example, if you have a C program that consists of multiple source files and with a complex build options such as:

gcc -DPACKAGE_NAME=\"reactor\" -DPACKAGE_TARNAME=\"reactor\" -DPACKAGE_VERSION=\"0.0.1\" -DPACKAGE_STRING=\"reactor\ 0.0.1\" -DPACKAGE_BUGREPORT=\"\" -DPACKAGE_URL=\"\" -DPACKAGE=\"reactor\" -DVERSION=\"0.0.1\" -DHAVE_STDIO_H=1 -DHAVE_STDLIB_H=1 -DHAVE_STRING_H=1 -DHAVE_INTTYPES_H=1 -DHAVE_STDINT_H=1 -DHAVE_STRINGS_H=1 -DHAVE_SYS_STAT_H=1 -DHAVE_SYS_TYPES_H=1 -DHAVE_UNISTD_H=1 -DSTDC_HEADERS=1 -I. -I../include -Wall -Wextra -Werror -pedantic -std=c11 -fPIC -O3 -g -O2 -MT librx_la-rx_request.lo -MD -MP -MF .deps/librx_la-rx_request.Tpo -c -o librx_la-rx_request.lo `test -f 'rx_request.c' || echo './'`rx_request.c

and imagine you have to type this command for 10 other files. It would be a painful experience.

This is why build tools are created. They allow you to define a common pattern that can be reused for all the source files.

GNU Make#

GNU Make is one of the most popular build tools, which controls the generation of executables and other non-source files of a program from the program’s source files. make uses a Makefile to define a set of rules and source files to build the target executable.

File structure#

Let’s take a look at a typical project that uses make:

using-make ├── Makefile ├── include │ └── whois.h └── src ├── whois.c ├── whoisclient.c └── whoisserver.c 3 directories, 5 files

Here is the content of the Makefile:

# Compiler options # gcc for C source code CC=gcc INCLUDEDIR=include SRCDIR=src # Optimized and fully warnings CFLAGS=-Wall -Wextra -Werror -O3 # For debugging and testing only DEBUGS=-DDEBUG -g -O0 # Include directory INCLUDE=-I./$(INCLUDEDIR) # Short - Compile all all: whoisserver whoisclient # Use to debug and test. This will display some useful message and provide quick # development. However do not use for grading as it might not cover all hidden # bugs. debug: $(CC) $(DEBUGS) $(INCLUDE) src/whoisclient.c src/whois.c -o whoisclient_debug $(CC) $(DEBUGS) $(INCLUDE) src/whoisserver.c src/whois.c -o whoisserver_debug whoisclient: whois.o whoisclient.o $(CC) $(CFLAGS) $(INCLUDE) whoisclient.o whois.o -o whoisclient whoisserver: whois.o whoisserver.o $(CC) $(CFLAGS) $(INCLUDE) whoisserver.o whois.o -o whoisserver # Compile all the source code to object files %.o: $(SRCDIR)/%.c $(CC) $(CFLAGS) $(INCLUDE) -c $< -o $@ clean: rm -rf *.o whoisclient whoisserver whoisclient_debug whoisserver_debug

Variables#

In this Makefile, we define some compiling variables such as CC, CFLAGS, and INCLUDE. Defining these variables allows us to change only one place where the variable is defined, and it will be applied to all the source files.

To reference a variable, you can use the syntax $(VARIABLE_NAME). For example, $(CC) will be replaced with gcc when the make command is executed.

Targets#

The next thing is the target. Target is defined by a rule followed by a colon. This rule allows you to specify the desired computations to be performed.

For example, the target debug will compile the whoisclient and whoisserver in debug mode. To use this target, you can run the command make debug.

The target all is preserved for the default target, which you can run by just typing make. This target will run all the dependencies in the order specified.

The dependencies are targets that need to be run before the specified target. For example, the all target depends on whoisserver and whoisclient. So when you run make, it will run the computations defined under whoisserver and whoisclient first. The dependencies are also computed recursively, which means if a dependency has its own sub-dependencies, the sub-dependencies will be computed until all the dependencies are resolved.

$ make gcc -Wall -Wextra -Werror -O3 -I./include -c src/whois.c -o whois.o gcc -Wall -Wextra -Werror -O3 -I./include -c src/whoisserver.c -o whoisserver.o gcc -Wall -Wextra -Werror -O3 -I./include whoisserver.o whois.o -o whoisserver gcc -Wall -Wextra -Werror -O3 -I./include -c src/whoisclient.c -o whoisclient.o gcc -Wall -Wextra -Werror -O3 -I./include whoisclient.o whois.o -o whoisclient

Thanks to the optimization of make, no target is compiled more than once.

Advantages#

Here are some advantages of using make:

  • make is an language-agnostic build tool, which means you can use it for building many different types of languages other than C.
  • make knows how to build a target from its dependencies, which means you can define the order of the build process.
  • make knows how to optimize the build process by only building the files that have changed, instead of compiling all the source files.
  • make is more than just a build tool. It can install, uninstall, clean up, generate files and more.

Disadvantages#

make is a powerful build tool and a great entry point for beginners who want to learn how to automate the build process. However, make has some disadvantages:

  • make is exclusive to Unix-like operating systems. If you want to build your C project on Windows, it might not be the best choice, unless you are using Windows Subsystem for Linux
  • make cannot check for libraries and dependencies. For example, make does not have a builtin function to check if a library is installed on the system.
  • make does support refactoring. For example, having multiple Makefile files for different parts of the project is not supported natively and requires some manual work to get it done.

GNU Autotools#

GNU Autotools is a collection of GNU build tools that are designed to make packages portable to many Unix-like systems1. The Autotools create a standard unified build system for all the UNIX/Linux packages:

./configure && make && make install

The configure script is generated by the autotools and it will check for dependencies, libraries, and headers that are required to build the project. After the configure script is run successfully, a Makefile will be generated with the corresponding flags, libraries, and dependencies that are required to run the make command.

Although autotools is a big collection of tools supporting many different stages of a build process, you only need to focus on two files: configure.ac and Makefile.am.

File structure#

Let’s reuse our implementation for whois project and convert it to use autotools:

using-autotools ├── configure.ac ├── include │ └── whois.h ├── Makefile.am ├── src │ ├── Makefile.am │ ├── whois.c │ ├── whoisclient.c │ └── whoisserver.c └── README.md

configure.ac#

Let’s take a look at the content of configure.ac:

# -*- Autoconf -*- # Process this file with autoconf to produce a configure script. # Initialize autoconf AC_INIT([using-autotools], [0.0.1]) AC_PREREQ([2.69]) # Configure the build process # AC_CONFIG_HEADERS([config.h]) AC_CONFIG_MACRO_DIR([m4]) AC_PROG_CC AC_PROG_INSTALL # Check for automake AM_INIT_AUTOMAKE([foreign subdir-objects -Wall]) AM_PROG_AR AM_PROG_CC_C_O LT_INIT # Check if `--enable-debug` flag is passed to configure script AC_ARG_ENABLE([debug], AS_HELP_STRING([--enable-debug], [Enable debugging]), [debug=$enableval], [debug=no] ) CFLAGS="-Wall -Werror" if test "x$debug" = "xyes"; then CFLAGS="$CFLAGS -DDEBUG -g -O0" else CFLAGS="$CFLAGS -Wextra -O3" fi # Check for libraries or dependencies (if needed) # Example: AC_CHECK_LIB([library_name], [function_name], [action-if-found], [action-if-not-found]) # Check for header files (if needed) # Example: AC_CHECK_HEADERS([header_file], [action-if-found], [action-if-not-found]) AC_CHECK_HEADERS([fcntl.h], [], [AC_MSG_ERROR([fcntl.h not found])]) AC_CHECK_HEADERS([unistd.h], [], [AC_MSG_ERROR([unistd.h not found])]) AC_CHECK_HEADERS([sys/socket.h], [], [AC_MSG_ERROR([sys/socket.h not found])]) AC_CHECK_HEADERS([netinet/in.h], [], [AC_MSG_ERROR([netinet/in.h not found])]) AC_CHECK_HEADERS([arpa/inet.h], [], [AC_MSG_ERROR([arpa/inet.h not found])]) AC_CHECK_HEADERS([sys/types.h], [], [AC_MSG_ERROR([sys/types.h not found])]) AC_CHECK_HEADERS([sys/stat.h], [], [AC_MSG_ERROR([sys/stat.h not found])]) AC_CHECK_HEADERS([stdlib.h], [], [AC_MSG_ERROR([stdlib.h not found])]) AC_CHECK_HEADERS([stdio.h], [], [AC_MSG_ERROR([stdio.h not found])]) AC_CHECK_HEADERS([string.h], [], [AC_MSG_ERROR([string.h not found])]) AC_CHECK_HEADERS([errno.h], [], [AC_MSG_ERROR([errno.h not found])]) AC_CHECK_HEADERS([signal.h], [], [AC_MSG_ERROR([signal.h not found])]) AC_CHECK_HEADERS([sys/time.h], [], [AC_MSG_ERROR([sys/time.h not found])]) AC_CHECK_HEADERS([time.h], [], [AC_MSG_ERROR([time.h not found])]) # Check for functions (if needed) # Example: AC_CHECK_FUNCS([function_name], [action-if-found], [action-if-not-found]) AC_CHECK_FUNCS([fcntl], [], [AC_MSG_ERROR([fcntl not found])]) AC_CHECK_FUNCS([socket], [], [AC_MSG_ERROR([socket not found])]) AC_CHECK_FUNCS([bind], [], [AC_MSG_ERROR([bind not found])]) AC_CHECK_FUNCS([listen], [], [AC_MSG_ERROR([listen not found])]) AC_CHECK_FUNCS([accept], [], [AC_MSG_ERROR([accept not found])]) AC_CHECK_FUNCS([close], [], [AC_MSG_ERROR([close not found])]) AC_CHECK_FUNCS([read], [], [AC_MSG_ERROR([read not found])]) AC_CHECK_FUNCS([write], [], [AC_MSG_ERROR([write not found])]) AC_CHECK_FUNCS([recv], [], [AC_MSG_ERROR([recv not found])]) # Check for declarations (if needed) # Example: AC_CHECK_DECLS([type_name], [action-if-found], [action-if-not-found]) # Tolerant macro declarations AC_CHECK_DECLS([NULL], [RX_HAVE_NULLPTR=1], []) # Check for type definitions # Example: AC_CHECK_TYPES([type_name], [action-if-found], [action-if-not-found]) AC_CHECK_TYPES([pthread_t], [RX_HAVE_PTHREAD_T=1], [AC_MSG_ERROR([pthread_t type not found])]) AC_CHECK_TYPES([int8_t], [RX_HAVE_INT8_T=1], []) AC_CHECK_TYPES([ssize_t], [RX_HAVE_SSIZE_T=1], []) AC_CHECK_TYPES([size_t], [RX_HAVE_SIZE_T=1], []) AC_CHECK_TYPES([u_char], [RX_HAVE_U_CHAR=1], []) # Generate a configuration header file that contains the above checks AC_CONFIG_HEADERS([include/config.h]) # Generate Makefiles for subdirectories AC_CONFIG_FILES([ Makefile src/Makefile ]) AC_OUTPUT

configure.ac is the main file that defines how the build system works. Like a traditional Makefile, configure.ac allows you to specify compiling options such as compiler, flags and definitions.

However, configure.ac shows its big advantages when it comes to checking for headers. Checking for headers and libraries is a common task when building a portable C project to ensure that certain functions are available on the target system. configure.ac provides a set of macros that allow

# Example: AC_CHECK_HEADERS([header_file], [action-if-found], [action-if-not-found]) AC_CHECK_HEADERS([fcntl.h], [], [AC_MSG_ERROR([fcntl.h not found])]) # Example: AC_CHECK_FUNCS([function_name], [action-if-found], [action-if-not-found]) AC_CHECK_FUNCS([fcntl], [], [AC_MSG_ERROR([fcntl not found])]) # Example: AC_CHECK_TYPES([type_name], [action-if-found], [action-if-not-found]) AC_CHECK_TYPES([pthread_t], [RX_HAVE_PTHREAD_T=1], [AC_MSG_ERROR([pthread_t type not found])])

configure.ac also allows you to break down your Makefile into smaller files with the builtin support of subdirectories:

AC_CONFIG_FILES([ Makefile src/Makefile ])

Makefile.am#

Let’s take a look at the content of Makefile.am and src/Makefile.am:

ACLOCAL_AMFLAGS = -I m4 SUBDIRS=src dist_doc_DATA=README.md
lib_LTLIBRARIES=libris.la libwhois_la_SOURCES=whois.c ../include/whois.h bin_PROGRAMS=whoisclient whoisserver whoisclient_SOURCES=whoisclient.c whoisserver_SOURCES=whoisserver.c whoisclient_LDADD=libwhois.la whoisserver_LDADD=libwhois.la

Instead of defining the rules in a Makefile, autotools use Makefile.am, which is a template file that defines the rules for building the project.

To create executables, you need to define the targets in bin_PROGRAMS. Each target will have its own source files and dependencies, which can be defined in the <target>_SOURCES variable.

Another concept of Makefile.am is to create a shared library that both targets use as a dependency. This is done by defining a library in lib_LTLIBRARIES and specifying the source files in <library>_SOURCES.

Running the build process#

Autotools requires a few steps to build the project:

  1. Run autoreconf -fi to generate the configure script.
autoreconf -fi
  1. Run ./configure to check for dependencies and generate the Makefile.
./configure # or ./configure --enable-debug
  1. Run make to build the project.
make

Advantages#

Here are some advantages of using autotools:

  • autotools supports checking for dependencies and libraries, which makes it easier to build portable C projects.
  • autotools supports refactoring by allowing you to break down the Makefile into smaller files.
  • autotools provides different tools for different stages of the build process, which allows you to fine-tune the build process.

Disadvantages#

autotools is a powerful build tool that provides many features to help you build portable C projects. However, autotools has some disadvantages:

  • autotools is complex and has a steep learning curve. It might be difficult for beginners to understand how to use autotools effectively.
  • autotools is exclusive to Unix-like operating systems, like GNU Make.
  • autotools does not provide a package manager. If you want to use external libraries, you need to install them manually on your system.

CMake#

CMake is a cross-platform build tool that allows you to define the build process in a configuration file. Like autotools, CMake provides a configuration step that generates the build files for the target system.

That said, Cmake is more modern and easier to use than autotools. Cmake provides a unified build system. Instead of having different tools for different stages or multiple files for the build process, Cmake provides a single entry point for the build.

File structure#

Let’s take a look at the file structure for a project that uses CMake:

. ├── CMakeLists.txt ├── include │ └── whois.h └── src ├── CMakeLists.txt ├── whois.c ├── whoisclient.c └── whoisserver.c 3 directories, 6 files

We are going to reuse the implementation for whois project and convert it to use CMake. CMakeLists.txt is the main file that defines the build process, including the source files, dependency checking and compiler flags.

src/CMakeLists.txt is a subdirectory file that defines the source files for the targets. This file is included in the main CMakeLists.txt file for refactoring purposes.

CMakeLists.txt#

Let’s take a look at the content of CMakeLists.txt:

cmake_minimum_required(VERSION 3.13) project(using-cmake) set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) # Set common compiler flags set(CMAKE_C_FLAGS "-Wall -Werror") # Check for header files include(CheckIncludeFile) check_include_file(fcntl.h HAVE_FCNTL_H) check_include_file(unistd.h HAVE_UNISTD_H) check_include_file(sys/socket.h HAVE_SYS_SOCKET_H) check_include_file(netinet/in.h HAVE_NETINET_IN_H) check_include_file(arpa/inet.h HAVE_ARPA_INET_H) check_include_file(sys/types.h HAVE_SYS_TYPES_H) check_include_file(sys/stat.h HAVE_SYS_STAT_H) check_include_file(stdlib.h HAVE_STDLIB_H) check_include_file(stdio.h HAVE_STDIO_H) check_include_file(string.h HAVE_STRING_H) check_include_file(errno.h HAVE_ERRNO_H) check_include_file(signal.h HAVE_SIGNAL_H) check_include_file(sys/time.h HAVE_SYS_TIME_H) check_include_file(time.h HAVE_TIME_H) # Check for functions include(CheckFunctionExists) check_function_exists(fcntl HAVE_FCNTL) check_function_exists(socket HAVE_SOCKET) check_function_exists(bind HAVE_BIND) check_function_exists(listen HAVE_LISTEN) check_function_exists(accept HAVE_ACCEPT) check_function_exists(close HAVE_CLOSE) check_function_exists(read HAVE_READ) check_function_exists(write HAVE_WRITE) check_function_exists(recv HAVE_RECV) # Check for declarations include(CheckSymbolExists) check_symbol_exists(NULL "stddef.h" HAVE_NULLPTR) # Check for type definitions include(CheckTypeSize) check_type_size(pthread_t HAVE_PTHREAD_T) check_type_size(int8_t HAVE_INT8_T) check_type_size(ssize_t HAVE_SSIZE_T) check_type_size(size_t HAVE_SIZE_T) check_type_size(u_char HAVE_U_CHAR) # Add subdirectories add_subdirectory(src)

It is pretty similar to configure.ac. We define some common variables such as compiler, compiler flags, dependency checking and include the subdirectory file. However, CMake provides a more robust and concise way to achieve the goal.

src/CMakeLists.txt#

Let’s take a look at the content of src/CMakeLists.txt:

cmake_minimum_required(VERSION 3.13) project(using-cmake) set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) # Add compiler flags based on the build type if(CMAKE_BUILD_TYPE MATCHES Debug) set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DDEBUG -g -O0") else() set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wextra -O3") endif() # Compile shared library set(BUILD_SHARED_LIBS ON) # Add source code to library add_library(whois whois.c ${CMAKE_SOURCE_DIR}/include/whois.h) target_include_directories(whois PUBLIC ${CMAKE_SOURCE_DIR}/include) # Compile target whoisclient add_executable(whoisclient whoisclient.c) # Link whoisclient with whois library target_include_directories(whoisclient PUBLIC ${CMAKE_SOURCE_DIR}/include) target_link_libraries(whoisclient whois) # Compile target whoisserver add_executable(whoisserver whoisserver.c) # Link whoisserver with whois library target_include_directories(whoisserver PUBLIC ${CMAKE_SOURCE_DIR}/include) target_link_libraries(whoisserver whois)

The structure of the CMakeLists.txt file is also similar to Makefile.am. We define executables by using add_executable(<name> <options>... <sources>...).2 Each target has its own source files, include directories and linkages.

We can also define a shared library for performance purposes by using add_library(<name> <options>...<sources>...)3. This library can be compiled once and reused by other targets.

Running the build process#

To build the project using CMake, you need to create a build directory and run the cmake command to generate the build files:

mkdir build cd build cmake -S .. -B . -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Debug # or Release make

Advantages#

  • CMake is a cross-platform build tool so you can build your C project on many major operating systems.
  • CMake provides a unified configuration file that allows you to define your build process, including compiler flags, dependencies, source files and executables.
  • CMake also provides modules that allow you to check for dependencies, or install one if you need to such as FetchContent.

Disadvantages#

CMake is considered the de facto build tool for C and C++ projects. However, it still has some common negative feedback:

  • Its syntax is cumbersome to work with. A simple project can be defined in an unnecessarily complex way.
  • Its documentation is often cryptic and esoteric because it does not provide a clear, explicit explanation of the modules.
  • There are hardly any example codes in the documentation. Most of the time, you have to rely on reading other people’s code on GitHub/GitLab.

Conclusion#

In this post, we have explored different build tools for C/C++ projects. GNU Make is a smallest and most simple tool that is suitable for small projects. Even bigger projects still use GNU Make for fine-grained controls. GNU Autotools provides a large set of tools that support specific stages of the build process. It is suitable for large projects, especially those written in Linux. Then, we have Cmake, a cross-platform build tool that provides a unified configuration file for the build process. It is suitable for major projects and has become the industry standard for C/C++ projects.

If you are new to C/C++, I would recommend learning GNU Make first to understand how build tools work and orchestrate. Learn and try it out to see what it brings and what it misses. Then, you can move on to CMake to learn to build a more complex project that requires more features such as dependency checking, shared libraries, testing and cross-platform support.

You can find the source code used in this post on GitHub

References#

Footnotes#

  1. GNU Autotools

  2. add_executable

  3. add_library

Tags: