1. Introduction

C/C++ is a powerful, general-purpose programming language. The C/C++ source code is transformed into an executable file, which runs directly in the system environment. So, a cross-platform program should compile and work equally well whether under Linux, Windows, or macOS.

In this tutorial, we’ll learn how to create a cross-platform application with the help of CMake.

2. The CMake Utility

The creation of the executable file from the C source code includes preprocessing the source code, then compiling it, and finally linking the binary objects. All these steps are platform dependent.

CMake facilitates the whole process of application creation. It makes use of tools existing in the system. So in Linux, we can obtain the Makefile for the make command. Under Windows, we can select the Visual Studio project as the target. However, CMake provides an abstraction layer, so we don’t need to go into details of the Makefile or vcxproj structure. Instead, we can use CMake’s commands to specify what we want to do.

Let’s install CMake on Ubuntu with apt:

$ sudo apt install cmake

Then, let’s learn the basic syntax of the cmake command to generate the project:

$ cmake [options] <path-to-source>

With this first form, we create a project in the path-to-source folder. All CMake-generated files will be put therein. In addition, it also applies to files generated by the compiler. The source folder needs to contain the CMakeLists.txt file.

Next, we can work with already generated CMake files in the path-to-existing-build location:

$ cmake [options] <path-to-existing-build>

Finally, we can separate the source files from the generated ones, So, we can keep the project tidy:

$ cmake [options] -S <path-to-source> -B <path-to-build>

If we prefer to work from the GUI, we should call cmake-gui. Here’s how we can install the qt version:

$ sudo apt install cmake-qt-gui

On Windows, we can install the program by means of a wizard provided by the installation MSI file.

3. A Simple Program’s Lifecycle With CMake

Let’s use CMake to build a simple C hello_world.c program:

#include <stdio.h>

int main()
{ 
    printf("Hello World!\n");
    return 0;
}

We’ll discuss the lifecycle of the program development, from writing code to installation. So, let’s create the CMake file CMakeLists.txt to control the build process of our program:

# CMakeLists file

cmake_minimum_required (VERSION 3.22.1)
project (HELLOWORLD)

#the executable helloworld will be built from the source file hello_world.c
add_executable (helloworld hello_world.c)

#install the helloworld program system-wide
install(TARGETS helloworld)

With add_executable we set the binary file’s name for our program. Next, with the install command we ask for an installation of the finished executable.

Subsequently, let’s put both files into the ~/prj/cmake/helloworld folder, so its content looks like this:

$ ls -A1
CMakeLists.txt
hello_world.c

Finally, let’s add the cmake/helloworld_bin folder to hold CMake working files, compiler object files, and the executable program:

$ mkdir ../helloworld_bin

3.1. The Linux Way

Let’s start building the program with cmake in the helloworld folder:

$ cmake . -B ../helloworld_bin
-- The C compiler identification is GNU 11.3.0
# ...

Next, let’s build the executable with the build command of cmake. Thus, we need to point to the binaries folder. In addition, we’re going to configure the build for release, with the config option:

$ cmake --build ../helloworld_bin --config Release

Now, we’re ready to install our program with the install command. By default, the program is copied to the /usr/local/bin folder:

$ sudo cmake --install ../helloworld_bin

And finally, let’s open a new terminal and enjoy our program:

$ helloworld
Hello World!

3.2. The Windows Way

Now let’s move to a Windows machine and copy both hello_world.c and CMakeLists.txt to the C:\prj\cmake\helloworld folder. We need the C:\prj\cmake\helloworld_bin folder as well.

Next, let’s perform the three steps as before in the command-line window. First, we generate the CMake project:

C:\prj\cmake\helloworld>cmake . -B ..\helloworld_bin
-- Building for: Visual Studio 15 2017
...

Then, let’s build it:

C:\prj\cmake\helloworld>cmake --build  ..\helloworld_bin --config Release

And finally, we need to install it. By default, the program will be copied to the C:/Program Files (x86)/HELLOWORLD/bin folder. For that, we need to run the cmake command in the administrative command-line window:

C:\prj\cmake\helloworld>cmake --install  ..\helloworld_bin

It’s worth noting that these steps are exactly the same as under Linux – and it’s the point of using CMake. We didn’t even bother about system specifics in the CMakeLists.txt file as everything was performed automatically by CMake.

However, to run the program, we need to add its bin folder to the PATH environment variable:

C:\prj\cmake\helloworld>PATH=C:/Program Files (x86)/HELLOWORLD/bin;%PATH%

Finally, we can run the helloworld.exe program under Windows:

C:\prj\cmake\helloworld>helloworld.exe
Hello World!

4. Detecting the Operating System

CMake has the ability to detect the operating system. So, let’s write a CMakeLists.txt file that defines different variables for each operating system. For example, under Linux, we’re going to define the variable LINUX_OS, in Windows its name is WINDOW_OS, and so on. Then the C preprocessor can check if the variable is set, and conditionally arrange the code. As a result, the instructions for the specific operating system will be compiled.

To achieve it, we’re going to use CMake’s internal variable CMAKE_SYSTEM_NAME:

# CMakeLists file

cmake_minimum_required (VERSION 3.22.1)
project (OSTEST)

add_executable (ostest ostest.c)

# set variable for the C preprocessor to detect the operatong system
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
  target_compile_definitions(ostest PUBLIC "LINUX_OS")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
  target_compile_definitions(ostest PUBLIC "MACOS_OS")
endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
  target_compile_definitions(ostest PUBLIC "WINDOWS_OS")
endif()

install(TARGETS ostest)

We rely on the CMake internal code names for platforms, such as Linux, Darwin, or Windows. Notably, they describe both the system and the compiler. So, Windows stands for Visual Studio or MinGW GCC under the Windows system. Then, a series of target_compile_definitions commands sets appropriate variables.

Now, we’ll write the corresponding C program, ostest.c:

#include <stdio.h>

int print_hello()
{
#ifdef WINDOWS_OS
    printf("Hello from Windows!\n");
#elif LINUX_OS
    printf("Hello from Linux!\n");
#elif MACOS_OS
    printf("Hello from macOS!\n");
#else
    printf("Hello from an unknown system!\n");
#endif
   return 0;
}

int main()
{
    print_hello();
    return 0;
}

Finally, let’s list the CMake commands for this project:

$ cmake . -B ../ostest_bin
$ cmake --build  ../ostest_bin --config Release
$ sudo cmake --install ../ostest_bin

5. Headers and Libraries – Case Study

When creating an executable from the C source code, we often need to provide third-party libraries. Since Linux and Windows manage libraries differently, we can handle it with CMake.

Let’s assume we’re using the GCC compiler in Linux and Visual Studio in Windows. Next, we’ll use the curl development library in our program. On Ubuntu, we can install this library with apt:

$ sudo apt install libcurl4-nss-dev

Afterwards, we should be provided with both curl header files and shared libraries. Moreover, their locations are known to the GCC tools.

In Windows, we can download the appropriate files and put them into the C:\curl\ folder. For a proper generation of the EXE file, we need curl.h, libcurl.lib, and for running libcurl.dll too. So here’s what the folder should contain:

C:\curl
├───bin
│       curl.exe
│       libcurl.dll
│
├───include
│   └───curl
│           curl.h
...    REM skipped
│
└───lib
        libcurl.exp
        libcurl.lib

Finally, we should add the C:\curl\bin folder to the PATH environment variable.

5.1. C Header Files

Usually, the definitions of function, object, and other program entities are held in the header file. These files are just copied into the C source code file with the include preprocessor directive.

We can come across two include formats. The first one demands the double-quoted file name, e.g., #include “my_functions.h”. In this case, we usually should put this file in the same folder where the source code is. As these files are inside our project, we don’t need to bother with them in the cross-platform context.

The second form uses brackets to enclose the header file’s name, e.g., #include <stdio.h>. We should use it for all standard, system, or third-party libraries. In the source code, we don’t need to specify the location of these headers – it’s up to the compiler. Moreover, it depends solely on the compiler implementation. So, we need to take it into account when creating a cross-platform program.

In the case of GCC, the C Preprocessor cpp looks for headers. Let’s check it out by calling it with the verbose option –v, without the input file:

$ cpp -v
...
#include "..." search starts here:
#include <...> search starts here:
 /usr/lib/gcc/x86_64-linux-gnu/11/include
 /usr/local/include
 /usr/include/x86_64-linux-gnu
 /usr/include
End of search list.

Let’s find the curl.h file with locate:

$ locate curl.h
/usr/include/x86_64-linux-gnu/curl/curl.h

In the case of Visual Studio, we should introduce the non-standard libraries to the project as an additional dependency. So, curl’s header entry should be provided in the vcxproj file:

<AdditionalIncludeDirectories>C:\curl\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>

5.2. The curltest Program

Let’s write a simple program that uses the curl library. Its only purpose is to print curl‘s version with the curl_version function:

#include <curl/curl.h>
#include <stdio.h>

int main()
{
    printf("libcurl version %s\n", curl_version());
    return 0;
}

5.3. The CMakeLists File

Now, let’s prepare the CMakeLists file. We’re going to use the CMAKE_SYSTEN_NAME internal variable to distinguish between Linux and Windows:

# CMakeLists file

cmake_minimum_required (VERSION 3.22.1)

project (CURLTEST)

add_executable (curltest curltest.c)

# point to included file and add library for Windows
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
	target_include_directories(curltest PUBLIC "C:\\curl\\include")
	target_link_libraries(curltest C:\\curl\\lib\\libcurl.lib)
endif()

if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
	target_link_libraries(curltest curl)
endif()

install(TARGETS curltest)

We use the target_link_libraries() function to ask the linker to add the curl library. We should do it regardless of whether we use Linux or Windows. Additionally, we need to point to the header files location with the target_include_directories function in the Windows case.

Let’s see the CMake commands for this project:

$ cmake . -B ../curltest_bin
$ cmake --build  ../curltest_bin --config Release
$ cmake --install ../curltest_bin

On Windows, we need to additionally specify the target architecture with the -A x64 switch to match the libcurl.lib architecture:

cmake . -B /curltest_bin -A x64

6. Conclusion

In this article, we used the CMake utility to cross-compile C programs. First, we wrote a simple “hello world” program in C. Then, we configured CMake to walk the whole path from the compilation to the installation of this program under Linux and Windows.

Subsequently, we took a look at the identification of the operating system by CMake. Then, we found a way to use this information during program compilation.

Finally, we performed a case study on using the curl development library in the C program. So, we needed to deal with different ways of handling the header files and libraries in Linux and Windows.

Comments are open for 30 days after publishing a post. For any issues past this date, use the Contact form on the site.