Mirko is a software engineer for PTSC and can be contacted at [email protected].
Ant is a platform-independent build utility that uses XML as its script language. Besides controlling the actual build process, Ant (http://ant.apache.org/) supplies platform-independent command implementations (tasks) that handle classic shell operations such as copying, deleting, zipping, or directory creation. For its part, cpptasks is an Ant extension for building C and C++ projects, and is maintained by the ANT Contrib project (http://sf.net/projects/ant-contrib/). In this article, I use Ant and cpptasks to build a multiplatform C/C++ application, and share tips for migrating existing projects from Make to Ant.
The Ant Philosophy
Being a pure Java solution, Ant is a fully platform-independent build tool. A top-level project of the Apache Software Foundation, Ant was primarily designed to drive the Java compiler and other platform-independent Java tools. Essentially, Ant assumes there is only one toolset that is invoked the same way on all development platforms. Ant is unaware of different target platforms; it targets only the abstract platform of the Java Virtual Machine.
This single-platform notion of platform independence does not necessarily support multiplatform development. For C/C++ projects, for instance, a different idea of platform independence is required. The actual target platforms can no longer be treated as identical. Different files have to be produced for Windows, Linux, or Solaris, and different compilers with different sets of command-line options have to be used to produce them.
Cpptasks addresses this problem by extending Ant's XML-based build description language with a C/C++ compilation task and providing compiler adapters for a variety of compilers (including GCC). You specify compiler options using abstract XML attributes. The compiler adapter chooses the appropriate command-line options for the selected compiler.
Ant does not provide built-in support for multiplatform builds. You must provide the Ant code that properly configures the compiler and linker for the current build platform. Consequently, Ant build scripts for C/C++ are structurally different from scripts for Java.
Listing One is an Ant script that builds an executable from a single source file, hello.c. The default name for Ant build scripts is always "build.xml." To build the project, just type ant. The script file in Listing One is surprisingly large, considering that a one-liner for Make is equivalent to:
hello: hello.c; $(CC) -o $@ hello.c
Due to the structure of XML files, Ant build scripts are always larger and more verbose than makefiles. In makefiles, it is sufficient to write hello: hello.c to specify a target and its dependency. The proper XML statement to specify the same target in Ant is <target name="hello">, which also requires a matching </target> statement and does not even express the dependency on the file hello.c. In fact, Ant does not require source files to be listed as dependencies. Only Ant targets can act as dependencies for other targets.
Every Ant build script in Listing One begins with a <project...> element and ends with </project>. Inside the project, you define properties and targets. A property is roughly comparable to a Make variable. However, Ant properties are immutable: Once defined, they cannot be changed. Ant targets are also similar to Make targets. Each target contains zero or more so-called "tasks." Compiling or copying files are common Ant tasks.
The taskdef and typedef elements at the beginning of the build script initialize the new tasks that are provided by the cpptasks package. These two elements are required in every Ant build script that uses cpptasks.
Initialization Target
The init target initializes all platform-dependent properties to their specific values. This target is only a suggestion and you can name it anything you like. However, such an initialization target is indispensable for most multiplatform projects. Generally, Ant properties are defined at project level (that is, outside any particular target). However, conditional expressions depend on the use of the condition task, and the Ant syntax allows tasks only inside a target definition. In my example, the init task sets the property cc to gcc on a UNIX platform, and msvc under Windows, thus selecting GCC or Visual C/C++. All C/C++ targets in the build must directly or indirectly depend on this init target.
The cc Task
Finally, the target hello contains the actual instructions for producing an executable file from the source file hello.c. In Ant, the name of the target is not identical to the name of the produced file, which is an advantage in this case, since the output file name might differ between platforms.
The name attribute of the cc task selects the compiler adapter specified by the property cc, initialized by the init task. The output file name specified by the outfile attribute serves only as a base name; the cc task adds extensions (like .exe) or prefixes (like lib) as necessary.
Unfortunately, standard Ant tasks are not aware of platform-specific naming differences for C/C++-related files; the build script in Listing Two contains a more advanced initialization task that defines additional properties for filename prefixes and extensions.
A significant advantage of the cc task over Make is its built-in dependency analysis. Makefiles have to specify the complete dependency graph of all source and header files. Usually, makedepend generates these dependency lists for larger projects, but this approach has problems. Manual invocation of makedepend can easily be forgotten (possibly leading to some files not being properly rebuilt). Automatic invocation in every build can be time consuming. The cc task always performs an automatic dependency analysis for all files in a fileset and caches the results in a dependencies.xml file.
The compiler adapters provided by cpptasks allow platform-independent selection of most important compiler features. The adapter automatically maps the following attributes to the proper compiler switches:
- outtype. The type of output that shall be produced; possible values are executable (the default), static (for archives or static libraries; .lib/.a), and shared for DLLs or lib.so files.
- runtime. The method used to link with run-time libraries; possible values are static and dynamic.
- multithreaded. The multithreading requirements of the output file; can be true or false.
- warnings. The warning level during compilation: none, severe, default, production, diagnostic, and failtask.
- debug. The generation of debugging information; can be true or false.
While most common situations can be addressed with these attributes, the need for advanced control of the compiler or linker will eventually arise. Examples for such situations are nonstandard include or library paths, special symbols that need to be defined on the command line, or compiler options that take advantage of processor-specific features.
Advanced Compiler and Linker Control
Cpptasks provides a compiler and a linker element for additional control of the compilation and linking step. These elements can appear at project level or as nested elements of the cc task. Both types of elements can have a unique reference ID tag, which allows you to refer to them in various places without the necessity of repeating the whole element all the time. It is also possible to extend an existing element, similar to extending a C++ or Java class. This is a particularly nice feature because it reduces redundancies in the build script.
Unfortunately, at least with the current version of cpptasks, the compiler and linker elements must be nested in the cc task element to have an effect. Though these elements may be defined at project level, they always have to be referenced from within the cc task. If you have specified global features by compiler/linker elements, each cc element must at least repeat a definition like:
<compiler refid="${cc}-compiler"/>
<linker refid="${cc}-linker"/>
The expression ${cc}-compiler evaluates to "gcc-compiler" or "msvc-compiler" depending on the value of the cc property. Compiler and linker elements on the global project level cannot make references to platform-dependent properties that are dynamically defined by the init target. Ant parses and processes project-level elements before the init target gets executed.
Configuration Levels
Compiler and linker configuration plays a crucial role in a multiplatform build procedure. The main goal here is to avoid redundancies and keep the configuration as simple as possible. Typically, you have to consider multiple levels of configuration items such as:
- Global configuration items, which apply to all targets on all platforms; for example, you might want to generally disable nonANSI language extensions and enable all warnings.
- Platform-specific configuration items, which apply only to a specific platform; optimization features are a good example here.
- Target-specific configuration. Certain individual targets might require a special configuration (on all platforms or only on some particular platforms); for example, certain symbols might have to be predefined for some targets
Listing Two is an advanced Ant build script that uses these configuration levels. The example project consists of a few (imaginary) C sources for a Java Native Interface (JNI) library.
The global compiler configuration has the reference ID cc. The configuration specifies that every compiler run (for every target and every platform) defines the symbol _POSIX_SOURCE to enforce POSIX compatibility. cpptasks adds -D_POSIX_SOURCE (under UNIX) or /D_POSIX_SOURCE (for Visual C/C++) to the compiler command line.
The platform-specific configurations gcc-compiler and msvc-compiler are both derived from the basic cc configuration (note that the reference ID "cc," the property "cc," and the cc task reside in different namespaces and thus do not conflict). The name attribute in the compiler element specifies the compiler adapter for which the configuration applies. Both configurations add a compiler-specific switch for disabling nonANSI extensions. This makes the ANSI strictness a global configuration feature, though it is activated differently for each compiler (-ansi versus /Za).
For the Visual C/C++ compiler, the configuration selects an additional platform-specific option to optimize for Pentium processors (/G5).
The target jnidemo demonstrates a target-specific configuration. Its nested compiler element extends the selected compiler configuration, adds additional include paths for JNI header files, and also disables optimizations. The correct compiler-specific option flag is selected using the optional if attribute of the compilerarg element.
The linker configuration follows the same principles as the compiler configuration.
More Properties and Targets
Listing Two also defines additional platform-specific properties in its init target. The properties lib, static, shared, obj, and exe define platform-specific filename extensions and prefixes. These properties are essential for standard Ant tasks that are unaware of platform-specific naming conventions. The clean-up tasks jnilib-clean and jnidemo-clean use these properties to make sure that the right files are deleted on each platform.
Similar to the clean target in a makefile, every Ant build script should have one or more targets to remove all produced files. Clean-up target names that end in "-clean" are only a naming suggestion; you can name the clean-up targets anything you like.
In addition to properties like cc, which have a platform-specific value ("gcc" or "msvc"), it is also often helpful, or even obligatory, to have Boolean properties that allow you to distinguish between different platforms. The init task in Listing Two alternatively defines the Boolean property msvc or gcc based on the value of the cc property (see the statements <condition property="msvc"> and <condition property="gcc">). Among other things, such Boolean properties are required as arguments for the if attribute, which is supported by a large number of Ant tasks and elements. Examples for the use of a Boolean property are the <compilerarg value="-O0" if="gcc"/> statement in the compiler configuration of the jnidemo target, or the definition of the property additionalfiles in the jnidemo-clean target.
Common Pitfalls
Frequent testing on all targeted platforms during the initial development of a multiplatform Ant build script will keep you out of trouble. Usually, Ant scripts for Java projects are not suitable as template scripts for C/C++ projects. Some common pitfalls are:
- Use of * and ** wildcards, commonly used in Java projects, increases the chance of unwanted source files getting pulled into a build. Instead, use Ant filesets that individually list the source files that are part of the build.
- Wrong property type for directory paths. Ant properties that are defined with the value=... attribute will not undergo automatic conversion of path naming schemes (such as "/" versus "\"). Use the location=... attribute for properties that represent directory paths.
- Wrong relative paths in the cc task. If the objdir attribute is specified in the cc task, the compiler adapter changes into the specified directory before invoking the compiler. Relative paths that are supplied by nested elements inside the cc task must be relative to that specified object directory (not the current directory).
- Redundant use of string constants. If in doubt,you should use a property rather than a string constant. For example, instead of adding the attribute runtime="dynamic" to all cc tasks in a script, it is better to define a property rt (for example) and use runtime="${rt}" instead. In case of a change, you only have to modify one property definition.
- Outdated cpptasks package. There are only very infrequent releases of cpptasks. Check out the current CVS repository for the latest bug fixes.
Ant Pros and Cons
Ant is not a silver bullet. Generally, Ant build scripts are much larger than makefiles, usually by a factor of 2 or 3. If your goal is to save keystrokes, and your makefiles work fine for all your target platforms, Ant is probably not for you. Also, Ant build scripts usually have a much higher level of redundancy than makefiles. After all, Make's classic shorthands (or "automatic variables") $<, $^, and $@ do not only reduce the size of makefiles, but also their redundancy: In Make, you usually have to change the name of a target or a file only in one place, because repeated occurrences usually refer to the same file or target with an automatic variable. Such luxury is not available in Antat least not for the time being. Especially for small projects, Ant also has a noticeable overhead for JVM start-up and initial XML processing. An Ant build will, therefore, take a little longer than a build with Make. Fortunately, in larger projects, this overhead usually does not carry too much weight.
On the other hand, Ant has a number of advantages to offer. Thanks to cpptasks, these advantages are now also available for the C/C++ world. A carefully written Ant build script can run without any modifications on a number of different platforms. The standard Ant tasks provide a platform-independent replacement for shell commands or built-in command interpreter commands, which have caused trouble in the past even if the same variation of Make was used on all platforms. The XML-based build scripts can be easily processed in an automatic fashion with a large number of freely available XML transformation tools. The dependency-analysis features provided by cpptasks can save a lot of hassle with makedepend. And Java projects that also contain JNI libraries written in C or C++ can now be built from a single Ant script; no separate makefile for building the C/C++ part is required.
DDJ
Listing One
<?xml version="1.0"?> <project name="Hello" default="hello" basedir="."> <taskdef resource="cpptasks.tasks"/> <typedef resource="cpptasks.types"/> <target name="init"> <condition property="cc" value="msvc"> <os family="windows"/> </condition> <condition property="cc" value="gcc"> <os family="unix"/> </condition> </target> <target name="hello" depends="init"> <cc name="${cc}" outfile="hello"> <fileset dir="." includes="hello.c"/> </cc> </target> </project>
Listing Two
<?xml version="1.0"?> <project name="JNI Demo" default="jnidemo" basedir="."> <taskdef resource="cpptasks.tasks"/> <typedef resource="cpptasks.types"/> <property environment="getenv"/> <compiler id="cc"> <defineset define="_POSIX_SOURCE"/> </compiler> <compiler name="gcc" id="gcc-compiler" extends="cc"> <compilerarg value="-ansi"/> </compiler> <compiler name="msvc" id="msvc-compiler" extends="cc"> <compilerarg value="/Za"/> <compilerarg value="/G5"/> </compiler> <linker name="gcc" id="gcc-linker"/> <linker name="msvc" id="msvc-linker"> <linkerarg value="/libpath:${getenv.MSDEVDIR}\lib"/> </linker> <target name="init"> <condition property="cc" value="msvc"> <os family="windows"/> </condition> <condition property="cc" value="gcc"> <os family="unix"/> </condition> <condition property="msvc"> <equals arg1="${cc}" arg2="msvc"/> </condition> <condition property="gcc"> <equals arg1="${cc}" arg2="gcc"/> </condition> <condition property="lib" value=""> <isset property="msvc"/> </condition> <condition property="lib" value="lib"> <isset property="gcc"/> </condition> <condition property="static" value=".lib"> <isset property="msvc"/> </condition> <condition property="static" value=".a"> <isset property="gcc"/> </condition> <condition property="shared" value=".dll"> <isset property="msvc"/> </condition> <condition property="shared" value=".so"> <isset property="gcc"/> </condition> <condition property="obj" value=".obj"> <isset property="msvc"/> </condition> <condition property="obj" value=".o"> <isset property="gcc"/> </condition> <condition property="exe" value=".exe"> <isset property="msvc"/> </condition> <condition property="exe" value=""> <isset property="gcc"/> </condition> <condition property="platform" value="linux"> <os name="Linux"/> </condition> <condition property="platform" value="win32"> <os family="windows"/> </condition> <condition property="platform" value="solaris"> <os name="SunOS"/> </condition> </target> <target name="jnilib" depends="init"> <cc name="${cc}" outfile="jnilib" outtype="static"> <compiler refid="${cc}-compiler"/> <linker refid="${cc}-linker"/> <fileset dir="." includes="jnilib1.c jnilib2.c"/> </cc> </target> <target name="jnidemo" depends="jnilib"> <cc name="${cc}" outfile="jnidemo" outtype="shared"> <compiler extends="${cc}-compiler"> <includepath location="${java.home}/../include"/> <includepath location="${java.home}/../include/${platform}"/> <compilerarg value="-O0" if="gcc"/> <compilerarg value="/Od" if="msvc"/> </compiler> <linker refid="${cc}-linker"/> <fileset dir="." includes="jnidemo.c"/> <libset dir="." libs="jnilib"/> </cc> </target> <target name="jnilib-clean" depends="init"> <delete> <fileset dir="." includes="*${obj}"/> <fileset dir="." includes="${lib}jnilib${static}"/> </delete> </target> <target name="jnidemo-clean" depends="init"> <condition property="additionalfiles" value=""> <isset property="gcc"/> </condition> <condition property="additionalfiles" value="jnidemo.lib jnidemo.exp"> <isset property="msvc"/> </condition> <delete> <fileset dir="." includes="jnidemo${obj} ${lib}jnidemo${shared} ${additionalfiles}" /> </delete> </target> <target name="clean" depends="jnidemo-clean, jnilib-clean"/> </project>