Handling Different Architectures

Learn to write architecture-aware CMake scripts for 32/64-bit systems and Apple's Universal Binaries (Intel/ARM).

Greg Filak
Published

In the , we learned how to write portable CMakeLists.txt files that can adapt to different operating systems and compilers. By detecting variables like WIN32 and MSVC, we can conditionally include the right source files and set the right flags, creating a single build script that works everywhere.

But the platform is more than just the OS. The underlying hardware architecture also plays a role. A program compiled for a 64-bit Intel processor won't run on a 32-bit ARM chip. While these differences are often handled by the compiler, sometimes our build system needs to be aware of them.

This lesson focuses on handling architectural differences. We'll cover:

  • The classic 32-bit vs. 64-bit distinction and how to detect it.
  • The unique challenge on macOS of supporting both Intel and Apple Silicon CPUs by building a universal binary.

Handling Architecture (32-bit vs. 64-bit)

Let's start with a quick overview. The "bitness" of an architecture (32-bit or 64-bit) primarily refers to the size of its memory addresses and CPU registers.

  • A 32-bit architecture can address a maximum of $2^32$ bytes of memory, which is 4 GiB. Pointers and fundamental types like long are often 32 bits wide.
  • A 64-bit architecture can address a theoretical maximum of $2^64$ bytes, which is an astronomical amount. Pointers are 64 bits wide.

Today, 64-bit systems are overwhelmingly dominant in desktop and server computing. However, 32-bit architectures are still relevant in some embedded systems or for maintaining compatibility with older legacy applications.

Your build system might need to know the target architecture to:

  • Link against the correct version of a pre-compiled third-party library.
  • Enable architecture-specific optimizations or code paths.
  • Define preprocessor macros that let your C++ code adapt to the pointer size.

The CMAKE_SIZEOF_VOID_P Variable

The canonical way to check the "bitness" of the target architecture in CMake is by inspecting the CMAKE_SIZEOF_VOID_P variable. This variable holds the size, in bytes, of a void pointer (void*).

  • On a 64-bit target, sizeof(void*) is 8 bytes (64 bits).
  • On a 32-bit target, sizeof(void*) is 4 bytes (32 bits).

CMake determines this value by doing a small test compilation when it first identifies the compiler. You can then use this variable in an if() block to make decisions.

Practical Example: Architecture-Specific Macros

A common pattern is to use CMake to detect the architecture and then pass that information down to your C++ code via a preprocessor definition. This keeps the platform detection logic in your build script, allowing your C++ code to focus on the implementation.

Let's modify our GreeterLib to report the architecture it was compiled for.

First, we'll update greeter/CMakeLists.txt to check CMAKE_SIZEOF_VOID_P and define a macro accordingly.

greeter/CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

add_library(GreeterLib src/Greeter.cpp)

if(CMAKE_SIZEOF_VOID_P EQUAL 8) 
  target_compile_definitions(GreeterLib PRIVATE ARCH_64BIT) 
  message(STATUS "Targeting 64-bit architecture.") 
else() 
  target_compile_definitions(GreeterLib PRIVATE ARCH_32BIT) 
  message(STATUS "Targeting 32-bit architecture.") 
endif() 

target_include_directories(GreeterLib PUBLIC
  "${CMAKE_CURRENT_SOURCE_DIR}/include"
)
-- Targeting 64-bit architecture.

We've set the compile definition as PRIVATE because this is an internal detail. The C++ code inside GreeterLib will use this macro, but consumers of the library don't need to know about it.

Let's test this in Greeter.cpp, using the preprocessor macros we just defined to choose the correct string at compile time.

greeter/src/Greeter.cpp

#include <greeter/Greeter.h>

std::string get_arch_string() { 
#if defined(ARCH_64BIT) 
  return "Compiled for 64-bit!"; 
#elif defined(ARCH_32BIT) 
  return "Compiled for 32-bit!"; 
#else  
  return "Unknown architecture."; // Fallback 
#endif  
} 

std::string get_greeting() {
  return "Hello from the modular greeter library! "
    + get_arch_string();
}

Now, if we re-configure and build, the message() command will tell us what CMake detected, and our application will confirm it.

Hello from the modular greeter library! Compiled for 64-bit!

Forcing a 32-bit Build

What if you're on a 64-bit machine but need to build a 32-bit version of your application, perhaps for legacy compatibility?

The direct way is to pass the appropriate compiler flag. This is /ARCH:IA32 on MSVC, and -m32 on GCC/Clang.

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

if(MSVC)
  set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} /ARCH:IA32")
  set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /ARCH:IA32")
else()
  set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -m32")
  set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -m32")
endif()

project(Greeter VERSION "1.0")

add_subdirectory(greeter)
add_subdirectory(app)

This is generally discouraged because it hardcodes compiler-specific flags, and it means our build script only works in environments that natively support 32 bit architectures.

In most real-world projects, this is best handled with a toolchain file. A toolchain file is a separate .cmake script that tells CMake how to configure the compilers and flags for a specific target platform.

We will cover toolchain files in detail in the .

macOS Architectures: Intel and Apple Silicon

The world of Apple computing presents a unique architectural challenge. For many years, Macs used Intel processors with the x86_64 architecture. In 2020, Apple began transitioning to their own "Apple Silicon" chips (M1, M2, etc.), which use the arm64 architecture.

During this transition period, developers often need to ship applications that run natively on both types of Macs. Apple's solution is the universal binary, sometimes also known as a fat binary.

A universal binary is a single executable file that contains the compiled machine code for multiple architectures. When you run it, the macOS loader (dyld) automatically detects the host CPU and executes the appropriate "slice" of the binary. This provides the best performance on both platforms without requiring separate downloads.

The CMAKE_OSX_ARCHITECTURES Variable

CMake provides a simple way to control this: the CMAKE_OSX_ARCHITECTURES variable. This variable is only used on Apple platforms. You can set it to a semicolon-separated list of the architectures you want to include in the final binary.

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)

if(APPLE) 
  # This tells the compiler to build for both Intel (x86_64) and
  # Apple Silicon (arm64), and the linker to combine them.
  set(CMAKE_OSX_ARCHITECTURES "x86_64;arm64") 
  message(STATUS "Configuring for Universal Binary") 
endif() 

project(Greeter VERSION "1.0")

add_subdirectory(greeter)
add_subdirectory(app)

That's it! When you configure and build this project on a Mac, CMake will automatically pass the correct flags to the compiler and linker (e.g., -arch x86_64 -arch arm64) to produce a universal binary.

cmake ..
-- Configuring for Universal Binary

After building, we can examine the content of our executable and confirm it is a univeral binary by passing its path to the file command:

file ./app/GreeterApp

The output should be something like:

Mach-O universal binary with 2 architectures:
 for architecture x86_64: Mach-O 64-bit executable x86_64
 for architecture arm64:  Mach-O 64-bit executable arm64

Overriding CMAKE_OSX_ARCHITECTURES

You can set this to a single value if you only want to target one architecture, for example: set(CMAKE_OSX_ARCHITECTURES "arm64").

This variable can also be set from the command line by the user, allowing them to override the project's default. Below, we configure an arm64-only binary:

cmake .. -DCMAKE_OSX_ARCHITECTURES=arm64

Summary

Being able to adapt to the target hardware is a key part of writing truly portable build scripts. CMake provides clean, high-level variables that abstract away the low-level compiler flags needed to manage different architectures.

  • 32-bit vs. 64-bit: Use if(CMAKE_SIZEOF_VOID_P EQUAL 8) to detect 64-bit systems. This is the standard, portable way to check the target's pointer size.
  • Apple Universal Binaries: On macOS, set the CMAKE_OSX_ARCHITECTURES variable to a list of architectures (e.g., "x86_64;arm64") to build a single executable that runs natively on both Intel and Apple Silicon Macs.
  • Abstraction is Key: Use these variables to define preprocessor macros or INTERFACE targets. This keeps the low-level detection logic in your CMakeLists.txt and presents a clean, abstract interface to your C++ code and consuming targets.
Next Lesson
Lesson 27 of 27

Cross-Compilation and Toolchain Files

Learn to build for different operating systems and architectures using CMake's cross-compilation support and toolchain files.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate