Using compiler and build system
Transferring the source code into an executable binary is the task of the compiler, which in our case will be g++ from the GNU Compiler Collection (GCC).
First simple program
To get started, write a simple program like the following that prints out its first command line argument.
1#include <iostream>
2#include <cstdlib>
3
4int main(int argc, char *argv[])
5{
6 // if the number of given command line arguments is only 1 (= the program name), print out usage information and exit
7 if (argc == 1)
8 {
9 std::cout << "usage: " << argv[0] << " <filename>" << std::endl;
10
11 return EXIT_FAILURE;
12 }
13
14 // read in the first argument
15 std::string filename = argv[1];
16
17 // print message
18 std::cout << "Filename: \"" << filename << "\"" << std::endl;
19
20 return EXIT_SUCCESS;
21}
Note what the program does. In line 7 the number of arguments to the program is checked. The value of 1 means we have no arguments, only the program name that we always get. In this case, we print a message and exit. In line 15 we have at least one argument and store it in a string and print it out.
Compile the program to produce a binary called test with the command
g++ main.cpp -o test
and verify that it works as expected:
$ ./test
usage: ./test <filename>
$ ./test input.txt
Filename: "input.txt"
Multiple source files
Next, we add a function to parse the given file, which will contain the parameters for our simulation later. In order to keep the code well organized, we add a *.h header file, settings.h with the declaration of the new function:
1#include <iostream>
2
3//! parse a txt file with settings, each line contains "<parameterName> = <value>"
4void loadFromFile(std::string filename);
The corresponding *.cpp file, settings.cpp, contains the definition of the function:
1#include <fstream> // for file operations
2#include <iostream> // for cout
3
4void loadFromFile(std::string filename)
5{
6 // open file
7 std::ifstream file(filename.c_str(), std::ios::in);
8
9 // check if file is open
10 if (!file.is_open())
11 {
12 std::cout << "Could not open parameter file \"" << filename << "\"." << std::endl;
13 return;
14 }
15
16 // loop over lines of file
17 for (int lineNo = 0;; lineNo++)
18 {
19 // read line
20 std::string line;
21 getline(file, line);
22
23 // at the end of the file break for loop
24 if (file.eof())
25 break;
26
27 // print line
28 std::cout << "line " << lineNo << ": " << line << std::endl;
29 }
30}
Again, take a closer look at the code to understand what happens. The file will be read line by line in the for loop at line 17, in line 28 we simply print the currently parsed line.
In the already existing main.cpp source file, add #include “settings.h” at the very top and the call to the new function,
1loadFromFile(filename);
at the proper position at the end of the main function.
Compilation of all files is done by
g++ main.cpp settings.cpp -o test2
Note, that only the source files with *.cpp suffix need to be given to GCC. The order does not matter.
In this example, the compiler did actually two steps. In the first step, it compiled all (two) compilation units separately. A compilation unit consists of a source file with ending .cpp. Besides the contained source code it may call functions of other compilation units. For this it needs to know only their signature which is given by the declaration in the included header file. The second step is the linking step, where the linker connects all compiled compilation units as well as external libraries to form a single executable binary. In the above example, both steps together were performed by a single command, but it is also possible and common to do these steps separately. The compilation of source files is independent of each other and thus could be done in parallel. In projects with a lot of files, this can vastly speed up the build process. Only the linking step has to be done afterwards.
Now the purpose of header files becomes clear. First, it is a way to tell the compiler which functions (and classes, structs or global variables) are available in other compilation units. Second, it is nice for the programmer to see these compact collections of functions without the detail of their actual implementation. Therefore, header files usually contain good comments that specify what the functions do and in what context they could be used. Source files almost always appear as a pair of file.cpp and file.h (except for main.cpp that doesn’t need any header file because it is not included by anything else).
To validate the compiled program, create the following test file, parameters.txt (it ends with an empty line):
# settings
endTime=1.5 # duration of the simulation
re = 1000 # Reynolds number
and test your program:
$ ./test2 parameters.txt
Filename: "parameters.txt"
line 0: # settings
line 1: endTime=1.5 # duration of the simulation
line 2:
line 3: re = 1000 # Reynolds number
line 4:
Advanced compiler usage
There exist more compiler options that are relevant. The compiler can try to produce more efficient binary code than the naive one that results from directly transferring each C++ statement to machine code. The optimization level of the compiler can be set to -O0 (disable optimizations, reduce compile time) over -O1, -O2 to -O3 (optimize the most, but sometimes -O2 is faster). Ultimately, there is also the -Ofast level, where it is allowed to alter the numerical results by small rounding errors, e.g. by applying the associative law to additions and multiplications or assuming there will be no division by zero, etc. (interested readers can look at the full documentation).
Usually when software is developed, there exists a debug target without any optimization that is used during development and a release target with optimization that produces an efficient program. Code compiled for a release target often produces less debugging output. In order to differentiate the two targets in the code, the constant NDEBUG is defined in the release target by adding -DNDEBUG at the compiler command. Conditional output in the code can take the following form:
1#ifndef NDEBUG
2 // only run this code in debug target
3 std::cout << "lots of inefficient but informative output . . .";
4#endif
(Note the double negation in #ifndef NDEBUG which looks awkward, but is commonly used.)
The release target compile command, for the previous test program, can look as follows.
g++ settings.cpp main.cpp -Ofast -DNDEBUG -o test2_release
You won’t notice any differences in runtime here, because there is not enough computation going on in this example. For the final submission of exercise 1, there will be a significant difference, though, e.g. a factor 12 between -O0 and -O3 in the reference implementation.
Further compiler options deal with warnings (starting with -W…) or debugging (starting with -g) among others. See the GCC documentation for details.
One more frequently needed functionality is to link to external libraries. Libraries contain compiled code that can be accessed from a C++ program. E.g. to link to the library libvtk, which implements data output to files that can be visualized with the tool ParaView, use the option -lvtk.
Makefiles
Handling different compiler commands for different targets can be facilitated by using make. This is a Linux program that executes commands given in a Makefile. The commands can be organized by a target name.
E.g., create the following file with name Makefile:
1all: debug release
2
3debug:
4 g++ settings.cpp main.cpp -g -o test2
5
6release:
7 g++ settings.cpp main.cpp -Ofast -DNDEBUG -o test2_release
8
9clean:
10 rm test2 test2_release
Note, the indenting characters have to be tabs, not spaces. If you now execute
make debug
the command in line 4 will be executed, which builds the debug version of the test program. Analogously, make release builds the release target given in line 7. A clean target, make clean, is usually specified to delete all previously built files. If you specify no target, i.e. simply call make, the first target will be executed, in this case it has the same effect as make all. This will build both the debug and the release target.
To avoid repetitions in the Makefiles, variables can be used, e.g. for source files and compiler flags. The following Makefile uses variables and has the same effect as the previous version, except for the additional -Wall flag, which enables more useful compiler warnings.
1CXX=g++ # C++ compiler
2CXXFLAGS=-Wall # compiler flags
3
4# source files
5SRC=settings.cpp main.cpp
6
7
8all: debug release
9
10debug:
11 $(CXX) $(SRC) $(CXXFLAGS) -g -o test2
12
13release:
14 $(CXX) $(SRC) $(CXXFLAGS) -Ofast -DNDEBUG -o test2_release
15
16clean:
17 rm test2 test2_release
Makefiles are very powerful and there is much more to learn about it, which is beyond the scope of this exercise. (E.g. try this tutorial). But with the basics presented here, it is already possible to conviently use the compiler for small projects.