Building cross-platform C++ applications requires isolating platform-specific code behind abstraction layers, using CMake as the build system, and writing ISO-compliant C++ that avoids implementation-defined behavior. The key techniques are: using #ifdef or template specialization to select platform APIs (Windows vs. POSIX), keeping platform-specific code in separate translation units, relying on the C++ standard library for portable abstractions (filesystem, threads, chrono), and using CI/CD pipelines to continuously build and test on Linux, macOS, and Windows.
Introduction
C++ promises “write once, compile everywhere” — but the reality is more nuanced. The C++ standard defines the language and standard library, but leaves many details to implementations. The size of int, the behavior of signed integer overflow, the availability of long long, file path separators, line endings, filesystem APIs, process management, shared library loading, networking — all of these differ across operating systems and compilers.
A truly portable C++ program can be compiled and run correctly on Windows (MSVC, MinGW), Linux (GCC, Clang), macOS (Clang, GCC), embedded systems (arm-none-eabi-gcc), and anywhere else a conforming C++ compiler exists. Achieving this requires discipline, the right abstractions, and a build system that understands target platforms.
This article teaches the essential techniques for cross-platform C++ development. You will learn to identify and isolate platform-specific code, write a CMakeLists.txt that works on all major platforms, use the C++ standard library’s portable abstractions (filesystem, threads, chrono, networking), handle the most common portability pitfalls (integer sizes, endianness, path separators, line endings), and set up a CI/CD pipeline that builds and tests on all target platforms automatically.
Understanding Platform Differences
The first step is understanding what varies across platforms:
#include <iostream>
#include <cstdint>
#include <climits>
#include <type_traits>
using namespace std;
void showPlatformInfo() {
cout << "=== Compiler and Platform ===" << endl;
// Compiler detection
#if defined(_MSC_VER)
cout << "Compiler: MSVC " << _MSC_VER << endl;
#elif defined(__clang__)
cout << "Compiler: Clang " << __clang_major__ << "." << __clang_minor__ << endl;
#elif defined(__GNUC__)
cout << "Compiler: GCC " << __GNUC__ << "." << __GNUC_MINOR__ << endl;
#else
cout << "Compiler: Unknown" << endl;
#endif
// OS detection
#if defined(_WIN32) || defined(_WIN64)
cout << "OS: Windows" << endl;
#if defined(_WIN64)
cout << "Architecture: 64-bit" << endl;
#else
cout << "Architecture: 32-bit" << endl;
#endif
#elif defined(__APPLE__)
#include <TargetConditionals.h>
#if TARGET_OS_MAC
cout << "OS: macOS" << endl;
#elif TARGET_OS_IPHONE
cout << "OS: iOS" << endl;
#endif
#elif defined(__linux__)
cout << "OS: Linux" << endl;
#elif defined(__FreeBSD__)
cout << "OS: FreeBSD" << endl;
#elif defined(__ANDROID__)
cout << "OS: Android" << endl;
#else
cout << "OS: Unknown" << endl;
#endif
cout << "\n=== Integer Sizes (may vary by platform) ===" << endl;
cout << "char: " << sizeof(char) << " byte(s)" << endl;
cout << "short: " << sizeof(short) << " byte(s)" << endl;
cout << "int: " << sizeof(int) << " byte(s)" << endl;
cout << "long: " << sizeof(long) << " byte(s)" << endl; // 4 on Win64, 8 on Linux/macOS 64
cout << "long long: " << sizeof(long long) << " byte(s)" << endl;
cout << "pointer: " << sizeof(void*) << " byte(s)" << endl;
cout << "\n=== Fixed-width types (always correct size) ===" << endl;
cout << "int8_t: " << sizeof(int8_t) << " byte(s)" << endl;
cout << "int16_t: " << sizeof(int16_t) << " byte(s)" << endl;
cout << "int32_t: " << sizeof(int32_t) << " byte(s)" << endl;
cout << "int64_t: " << sizeof(int64_t) << " byte(s)" << endl;
cout << "\n=== Endianness ===" << endl;
uint32_t test = 0x01020304;
uint8_t* bytes = reinterpret_cast<uint8_t*>(&test);
if (bytes[0] == 0x01) {
cout << "Big-endian (rare on desktop: SPARC, some ARM)" << endl;
} else if (bytes[0] == 0x04) {
cout << "Little-endian (x86, x86-64, most ARM)" << endl;
} else {
cout << "Mixed/unknown endianness" << endl;
}
cout << "\n=== Path separator ===" << endl;
#if defined(_WIN32)
cout << "Path separator: \\ (backslash)" << endl;
#else
cout << "Path separator: / (forward slash)" << endl;
#endif
cout << "\n=== Line endings ===" << endl;
#if defined(_WIN32)
cout << "Line ending: \\r\\n (CRLF)" << endl;
#else
cout << "Line ending: \\n (LF)" << endl;
#endif
}
int main() {
showPlatformInfo();
return 0;
}
Output on Linux/GCC:
=== Compiler and Platform ===
Compiler: GCC 12.2
OS: Linux
=== Integer Sizes (may vary by platform) ===
char: 1 byte(s)
short: 2 byte(s)
int: 4 byte(s)
long: 8 byte(s) ← 8 on Linux 64-bit, 4 on Windows 64-bit!
long long: 8 byte(s)
pointer: 8 byte(s)
=== Fixed-width types (always correct size) ===
int8_t: 1 byte(s)
int16_t: 2 byte(s)
int32_t: 4 byte(s)
int64_t: 8 byte(s)
=== Endianness ===
Little-endian (x86, x86-64, most ARM)
=== Path separator ===
Path separator: / (forward slash)
=== Line ending ===
Line ending: \n (LF)
Step-by-step explanation:
longis the most dangerous portability trap: it is 32 bits on Windows 64-bit (MSVC, MinGW) but 64 bits on Linux and macOS 64-bit. Code that stores a pointer in alongworks on Linux but fails on Windows. Always useintptr_tfor pointer-sized integers orint64_tfor explicitly 64-bit values.- Fixed-width integer types (
int8_t,int16_t,int32_t,int64_tfrom<cstdint>) are always exactly the specified size — the right tool for protocol parsing, binary file formats, and any code where size matters. - Endianness affects how multi-byte integers are stored in memory. x86/x86-64 and most ARM are little-endian; network protocols are big-endian. When reading binary files or network packets, always convert explicitly.
- Predefined macros (
_WIN32,__linux__,__APPLE__,__GNUC__,_MSC_VER) are the standard mechanism for platform detection._WIN32is defined even on 64-bit Windows — it means “Windows”, not “32-bit Windows”. Use_WIN64for 64-bit Windows specifically. - Path separators: Windows accepts
/in most contexts, but programs that generate paths should usestd::filesystem::pathwhich handles the correct separator automatically.
Platform Abstraction: Isolating OS-Specific Code
The cleanest approach to portability is to isolate all platform-specific code behind a narrow abstraction interface:
// platform.hpp — the portable interface
#pragma once
#include <string>
#include <cstdint>
#include <chrono>
#include <vector>
using namespace std;
// Opaque handle type for platform resources
struct FileHandle;
struct ProcessHandle;
// High-resolution timer
class PlatformTimer {
public:
void start();
double elapsedMs() const;
private:
chrono::high_resolution_clock::time_point startTime_;
};
// Process information
struct ProcessInfo {
uint64_t pid;
string name;
uint64_t memoryBytes;
};
// Platform-independent interface
namespace Platform {
string getOSName();
uint32_t getCPUCount();
uint64_t getTotalMemoryBytes();
uint64_t getAvailableMemoryBytes();
string getHostname();
string getUsername();
string getTempDirectory();
vector<ProcessInfo> listProcesses();
bool setEnvironmentVariable(const string& name, const string& value);
string getEnvironmentVariable(const string& name);
void sleep(uint32_t milliseconds);
bool isDebuggerAttached();
}
// ----- platform_linux.cpp / platform_mac.cpp -----
// (POSIX implementation)
#if defined(__linux__) || defined(__APPLE__)
#include "platform.hpp"
#include <unistd.h>
#include <sys/utsname.h>
#include <sys/types.h>
#include <sys/sysinfo.h>
#include <thread>
#include <cstring>
#include <cstdlib>
namespace Platform {
string getOSName() {
struct utsname info;
uname(&info);
return string(info.sysname) + " " + info.release;
}
uint32_t getCPUCount() {
return static_cast<uint32_t>(thread::hardware_concurrency());
}
string getHostname() {
char buf[256];
gethostname(buf, sizeof(buf));
return buf;
}
string getUsername() {
const char* user = getenv("USER");
return user ? user : "unknown";
}
string getTempDirectory() {
const char* tmp = getenv("TMPDIR");
return tmp ? tmp : "/tmp";
}
string getEnvironmentVariable(const string& name) {
const char* val = getenv(name.c_str());
return val ? val : "";
}
bool setEnvironmentVariable(const string& name, const string& value) {
return setenv(name.c_str(), value.c_str(), 1) == 0;
}
void sleep(uint32_t milliseconds) {
this_thread::sleep_for(chrono::milliseconds(milliseconds));
}
bool isDebuggerAttached() {
// Linux: check /proc/self/status for TracerPid
FILE* f = fopen("/proc/self/status", "r");
if (!f) return false;
char line[256];
bool attached = false;
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "TracerPid:", 10) == 0) {
int pid = atoi(line + 10);
attached = pid != 0;
break;
}
}
fclose(f);
return attached;
}
} // namespace Platform
#endif
// ----- platform_windows.cpp -----
#if defined(_WIN32)
#include "platform.hpp"
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#include <thread>
namespace Platform {
string getOSName() {
OSVERSIONINFOEXA info{};
info.dwOSVersionInfoSize = sizeof(info);
// GetVersionEx deprecated — use VersionHelper.h in production
return "Windows";
}
uint32_t getCPUCount() {
SYSTEM_INFO si;
GetSystemInfo(&si);
return si.dwNumberOfProcessors;
}
string getHostname() {
char buf[MAX_COMPUTERNAME_LENGTH + 1];
DWORD size = sizeof(buf);
GetComputerNameA(buf, &size);
return buf;
}
string getUsername() {
char buf[256];
DWORD size = sizeof(buf);
GetUserNameA(buf, &size);
return buf;
}
string getTempDirectory() {
char buf[MAX_PATH];
GetTempPathA(MAX_PATH, buf);
return buf;
}
string getEnvironmentVariable(const string& name) {
char buf[32767]; // Max env var size on Windows
DWORD len = GetEnvironmentVariableA(name.c_str(), buf, sizeof(buf));
return len > 0 ? string(buf, len) : "";
}
bool setEnvironmentVariable(const string& name, const string& value) {
return SetEnvironmentVariableA(name.c_str(), value.c_str()) != 0;
}
void sleep(uint32_t milliseconds) {
Sleep(milliseconds); // Windows Sleep()
}
bool isDebuggerAttached() {
return IsDebuggerPresent() != 0;
}
} // namespace Platform
#endif
Step-by-step explanation:
platform.hppdefines only the interface — types and function declarations. No#ifdefin the header. Callers include onlyplatform.hppand write portable code against the abstraction.- Three implementation files:
platform_posix.cpp(Linux + macOS using POSIX APIs),platform_windows.cpp(Windows using Win32 APIs), and potentiallyplatform_mac.cppfor macOS-specific extensions. The build system selects which to compile. #define WIN32_LEAN_AND_MEANand#define NOMINMAXbefore<windows.h>are essential:WIN32_LEAN_AND_MEANreduces the size of the Windows header (omitting rarely used APIs), andNOMINMAXprevents Windows from definingminandmaxmacros that conflict withstd::min/std::max.- The POSIX implementation uses standard POSIX interfaces available on Linux, macOS, and other UNIX-like systems — the same implementation compiles on both with minor conditional differences.
thread::hardware_concurrency()is a portable way to get CPU count — it is standard C++ and works on all platforms.
The Standard Library’s Portable Abstractions
Modern C++ provides powerful portable abstractions that replace platform-specific APIs:
Filesystem (C++17)
#include <iostream>
#include <filesystem>
#include <fstream>
#include <string>
using namespace std;
namespace fs = filesystem;
void demonstrateFilesystem() {
cout << "=== std::filesystem (C++17) ===" << endl;
// Current directory (portable)
fs::path cwd = fs::current_path();
cout << "CWD: " << cwd << endl;
// Path construction — automatically uses correct separator
fs::path dataDir = cwd / "data" / "config"; // "/" operator joins paths
cout << "Data dir: " << dataDir << endl;
// Path components
fs::path file = "/home/user/documents/report.pdf";
cout << "Filename: " << file.filename() << endl; // report.pdf
cout << "Stem: " << file.stem() << endl; // report
cout << "Extension: " << file.extension() << endl; // .pdf
cout << "Parent: " << file.parent_path()<< endl; // /home/user/documents
// Create directories
fs::path tmpDir = fs::temp_directory_path() / "cpp_portability_demo";
fs::create_directories(tmpDir); // Creates all intermediate dirs
cout << "Created: " << tmpDir << endl;
// Write a file
fs::path testFile = tmpDir / "test.txt";
{
ofstream out(testFile);
out << "Hello, cross-platform world!\n";
out << "Second line\n";
}
// File properties
if (fs::exists(testFile)) {
cout << "File size: " << fs::file_size(testFile) << " bytes" << endl;
auto lastWrite = fs::last_write_time(testFile);
cout << "File exists: yes" << endl;
cout << "Is regular: " << fs::is_regular_file(testFile) << endl;
}
// Iterate directory
cout << "\nFiles in " << tmpDir << ":" << endl;
for (const auto& entry : fs::directory_iterator(tmpDir)) {
cout << " " << entry.path().filename()
<< " (" << entry.file_size() << " bytes)" << endl;
}
// Recursive directory iteration
cout << "\nAll files (recursive):" << endl;
for (const auto& entry : fs::recursive_directory_iterator(cwd)) {
if (entry.is_regular_file() && entry.path().extension() == ".cpp") {
cout << " " << entry.path().filename() << endl;
}
}
// Copy, rename, remove
fs::path copyFile = tmpDir / "test_copy.txt";
fs::copy_file(testFile, copyFile);
cout << "\nCopied: " << copyFile.filename() << endl;
fs::rename(copyFile, tmpDir / "test_renamed.txt");
cout << "Renamed to: test_renamed.txt" << endl;
// Cleanup
fs::remove_all(tmpDir);
cout << "Cleaned up temp directory" << endl;
}
int main() {
demonstrateFilesystem();
return 0;
}
Output (Linux):
=== std::filesystem (C++17) ===
CWD: /home/user/projects/myapp
Data dir: /home/user/projects/myapp/data/config
Filename: report.pdf
Stem: report
Extension: .pdf
Parent: /home/user/documents
Created: /tmp/cpp_portability_demo
File size: 35 bytes
File exists: yes
Is regular: 1
Files in /tmp/cpp_portability_demo:
test.txt (35 bytes)
All files (recursive):
main.cpp
platform.cpp
Copied: test_copy.txt
Renamed to: test_renamed.txt
Cleaned up temp directory
Step-by-step explanation:
fs::pathuses the platform-correct separator automatically:/on POSIX,\on Windows. The/operator concatenates path components without you needing to know which separator to use.fs::temp_directory_path()returns the system’s temp directory —/tmpon Linux/macOS,%TEMP%or%TMP%on Windows. Always use this instead of hardcoding/tmp.fs::create_directories(path)creates the full directory tree (likemkdir -p) — works on all platforms.fs::directory_iteratorandfs::recursive_directory_iteratoriterate directory contents portably — noopendir/readdir(POSIX) orFindFirstFile/FindNextFile(Windows) required.fs::path::operator/()is cleaner than string concatenation for paths.cwd / "data" / "config"handles trailing separators and empty components correctly.
Threads and Synchronization (C++11)
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <atomic>
#include <future>
#include <vector>
using namespace std;
// Portable thread pool — uses only C++ standard library
class ThreadPool {
vector<thread> workers_;
vector<function<void()>> tasks_;
mutex mtx_;
condition_variable cv_;
atomic<bool> stop_{false};
public:
explicit ThreadPool(size_t n = thread::hardware_concurrency()) {
for (size_t i = 0; i < n; i++) {
workers_.emplace_back([this] {
while (true) {
function<void()> task;
{
unique_lock lock(mtx_);
cv_.wait(lock, [this] {
return stop_ || !tasks_.empty();
});
if (stop_ && tasks_.empty()) return;
task = move(tasks_.front());
tasks_.erase(tasks_.begin());
}
task();
}
});
}
}
template<typename F>
auto submit(F&& f) -> future<invoke_result_t<F>> {
using R = invoke_result_t<F>;
auto task = make_shared<packaged_task<R()>>(forward<F>(f));
future<R> result = task->get_future();
{
lock_guard lock(mtx_);
tasks_.push_back([task] { (*task)(); });
}
cv_.notify_one();
return result;
}
~ThreadPool() {
stop_ = true;
cv_.notify_all();
for (auto& w : workers_) w.join();
}
};
int main() {
cout << "Hardware threads: " << thread::hardware_concurrency() << endl;
ThreadPool pool(4);
// Submit work — fully portable, no pthreads or WinAPI
vector<future<int>> results;
for (int i = 0; i < 8; i++) {
results.push_back(pool.submit([i] {
this_thread::sleep_for(chrono::milliseconds(10 * (i % 3)));
return i * i;
}));
}
cout << "Results: ";
for (auto& f : results) cout << f.get() << " ";
cout << endl;
return 0;
}
Output:
Hardware threads: 8
Results: 0 1 4 9 16 25 36 49
The ThreadPool uses only standard C++ — std::thread, std::mutex, std::condition_variable, std::atomic. It compiles and runs identically on Linux, macOS, and Windows without any platform-specific code.
Common Portability Pitfalls and Fixes
#include <iostream>
#include <cstdint>
#include <bit> // C++20: for byteswap
#include <algorithm>
#include <string>
#include <sstream>
using namespace std;
// ===== Pitfall 1: Integer size assumptions =====
void integerPortability() {
cout << "=== Integer Portability ===" << endl;
// BAD: assumes long is 64-bit (false on Windows 64-bit)
// long bigNum = 8000000000L; // May overflow on Windows!
// GOOD: use fixed-width types from <cstdint>
int64_t bigNum = 8000000000LL;
cout << "int64_t bigNum: " << bigNum << endl;
// BAD: uses int for bit manipulation (undefined if sign bit involved)
// int flags = 0x80000000; // UB: overflows signed int
// GOOD: use unsigned types for bit operations
uint32_t flags = 0x80000000u;
cout << "uint32_t flags: 0x" << hex << flags << dec << endl;
// BAD: assumes size_t fits in int (64-bit: size_t is 8 bytes, int is 4)
vector<int> v(100);
// int sz = v.size(); // Warning: truncation
// GOOD: use size_t or ptrdiff_t
size_t sz = v.size();
cout << "size: " << sz << endl;
}
// ===== Pitfall 2: Endianness in binary I/O =====
void endianness() {
cout << "\n=== Endianness ===" << endl;
// Portable byte swap — C++23 has std::byteswap
auto swap32 = [](uint32_t x) -> uint32_t {
return ((x & 0x000000FF) << 24) |
((x & 0x0000FF00) << 8) |
((x & 0x00FF0000) >> 8) |
((x & 0xFF000000) >> 24);
};
// Host to network (big-endian) byte order
uint32_t hostValue = 0x01020304;
uint32_t networkValue;
if constexpr (endian::native == endian::little) {
networkValue = swap32(hostValue);
} else {
networkValue = hostValue;
}
cout << "Host: 0x" << hex << hostValue
<< " Network: 0x" << networkValue << dec << endl;
// Always use fixed-width types for binary file I/O
struct BinaryHeader {
uint32_t magic;
uint16_t version;
uint32_t dataSize;
} __attribute__((packed)); // GCC/Clang; MSVC: use #pragma pack
// Better: use static_assert to verify expected layout
static_assert(sizeof(BinaryHeader) == 10, "Header size mismatch");
}
// ===== Pitfall 3: String and character encoding =====
void stringPortability() {
cout << "\n=== String Portability ===" << endl;
// char is signed on some platforms, unsigned on others
// Always cast to uint8_t before arithmetic
char c = 'A';
// int val = c; // May be negative for chars > 127
int val = static_cast<uint8_t>(c); // Always 0-255
cout << "char 'A' as uint8_t: " << val << endl;
// snprintf is portable; sprintf is not (no bounds checking)
char buf[64];
snprintf(buf, sizeof(buf), "Value: %d", 42);
cout << buf << endl;
// Prefer string streams over C-style formatting
ostringstream oss;
oss << "Portable string: " << 42 << " " << 3.14;
cout << oss.str() << endl;
}
// ===== Pitfall 4: Line endings in text files =====
void lineEndingPortability() {
cout << "\n=== Line Endings ===" << endl;
// Reading text files: open in text mode (default)
// The C++ runtime translates \r\n to \n on Windows
// ifstream file("data.txt"); // Text mode: safe
// Reading binary files: open in binary mode to avoid translation
// ifstream file("data.bin", ios::binary); // Binary mode: exact bytes
// Writing: use "\n" in text mode; runtime handles \r\n on Windows
// ofstream out("file.txt");
// out << "Line 1\n"; // Correct; written as \r\n on Windows, \n elsewhere
cout << "Use text mode for text, binary mode for binary — done!" << endl;
}
// ===== Pitfall 5: Compiler-specific extensions =====
void compilerExtensions() {
cout << "\n=== Avoiding Compiler Extensions ===" << endl;
// __int128: GCC/Clang only, not MSVC
// Use int64_t or boost::multiprecision for large integers
// Variable-length arrays (VLAs): C99 feature, GCC extension, not in C++ standard
// int arr[n]; // BAD: VLA — not portable to MSVC
// GOOD: use std::vector
int n = 10;
vector<int> arr(n); // Portable
cout << "Vector size: " << arr.size() << endl;
// __attribute__((visibility("default"))): GCC/Clang only
// MSVC uses __declspec(dllexport)
// Use a portability macro:
// #if defined(_MSC_VER)
// # define EXPORT __declspec(dllexport)
// #elif defined(__GNUC__)
// # define EXPORT __attribute__((visibility("default")))
// #else
// # define EXPORT
// #endif
// MSVC: __forceinline; GCC: __attribute__((always_inline))
// Portable approach: hint with [[likely]]/[[unlikely]] and let the optimizer decide
cout << "Prefer standard C++ over compiler extensions" << endl;
}
int main() {
integerPortability();
endianness();
stringPortability();
lineEndingPortability();
compilerExtensions();
return 0;
}
Output:
=== Integer Portability ===
int64_t bigNum: 8000000000
uint32_t flags: 0x80000000
size: 100
=== Endianness ===
Host: 0x1020304 Network: 0x4030201
=== String Portability ===
char 'A' as uint8_t: 65
Value: 42
Portable string: 42 3.14
=== Line Endings ===
Use text mode for text, binary mode for binary — done!
=== Avoiding Compiler Extensions ===
Vector size: 10
Prefer standard C++ over compiler extensions
Export Macros for Cross-Platform Libraries
When building shared libraries (DLLs on Windows, .so on Linux, .dylib on macOS), you need portable export/import macros:
// export_macros.hpp
#pragma once
// Visibility macros for shared library symbols
#if defined(_WIN32) || defined(__CYGWIN__)
// Windows: explicit export/import
#ifdef MYLIB_BUILDING_SHARED // Defined when building the library
#define MYLIB_API __declspec(dllexport)
#else
#define MYLIB_API __declspec(dllimport)
#endif
#define MYLIB_LOCAL // No concept of hidden on Windows
#elif defined(__GNUC__) || defined(__clang__)
// GCC/Clang: use visibility attributes
#define MYLIB_API __attribute__((visibility("default")))
#define MYLIB_LOCAL __attribute__((visibility("hidden")))
#else
// Fallback: no visibility control
#define MYLIB_API
#define MYLIB_LOCAL
#endif
// Inline hint — portable across compilers
#if defined(_MSC_VER)
#define MYLIB_FORCEINLINE __forceinline
#elif defined(__GNUC__) || defined(__clang__)
#define MYLIB_FORCEINLINE __attribute__((always_inline)) inline
#else
#define MYLIB_FORCEINLINE inline
#endif
// Suppress specific warnings portably
#if defined(_MSC_VER)
#define DISABLE_WARNING_PUSH __pragma(warning(push))
#define DISABLE_WARNING_POP __pragma(warning(pop))
#define DISABLE_WARNING_UNUSED_VAR __pragma(warning(disable: 4100))
#elif defined(__GNUC__) || defined(__clang__)
#define DISABLE_WARNING_PUSH _Pragma("GCC diagnostic push")
#define DISABLE_WARNING_POP _Pragma("GCC diagnostic pop")
#define DISABLE_WARNING_UNUSED_VAR _Pragma("GCC diagnostic ignored \"-Wunused-variable\"")
#else
#define DISABLE_WARNING_PUSH
#define DISABLE_WARNING_POP
#define DISABLE_WARNING_UNUSED_VAR
#endif
// Usage example:
class MYLIB_API PortableClass {
public:
MYLIB_API void publicMethod();
MYLIB_LOCAL void internalHelper(); // Not exported
};
CMake: The Cross-Platform Build System
CMake is the industry standard for cross-platform C++ build systems. A well-written CMakeLists.txt generates platform-correct build files:
# CMakeLists.txt — cross-platform C++ project
cmake_minimum_required(VERSION 3.20)
project(MyApp VERSION 1.0.0 LANGUAGES CXX)
# Require C++20
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) # Don't use GNU/MSVC extensions — pure standard
# Source files — platform-specific files selected automatically
set(COMMON_SOURCES
src/main.cpp
src/application.cpp
src/config.cpp
)
# Select platform-specific implementation
if(WIN32)
list(APPEND PLATFORM_SOURCES src/platform_windows.cpp)
message(STATUS "Building for Windows")
elseif(APPLE)
list(APPEND PLATFORM_SOURCES src/platform_posix.cpp src/platform_mac.cpp)
message(STATUS "Building for macOS")
elseif(UNIX)
list(APPEND PLATFORM_SOURCES src/platform_posix.cpp)
message(STATUS "Building for Linux/POSIX")
endif()
# Create the executable
add_executable(myapp ${COMMON_SOURCES} ${PLATFORM_SOURCES})
# Include directories
target_include_directories(myapp PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include
${CMAKE_CURRENT_SOURCE_DIR}/src
)
# Platform-specific definitions and libraries
if(WIN32)
target_compile_definitions(myapp PRIVATE
WIN32_LEAN_AND_MEAN
NOMINMAX
_WIN32_WINNT=0x0A00 # Windows 10 minimum
UNICODE
_UNICODE
)
target_link_libraries(myapp PRIVATE
ws2_32 # Winsock (networking)
kernel32
)
elseif(UNIX)
# Link pthreads on Linux (not needed on macOS)
find_package(Threads REQUIRED)
target_link_libraries(myapp PRIVATE Threads::Threads)
if(APPLE)
target_link_libraries(myapp PRIVATE
"-framework CoreFoundation"
"-framework Security"
)
else()
# Linux-specific
target_link_libraries(myapp PRIVATE dl rt)
endif()
endif()
# Compiler warnings — different flags for different compilers
if(MSVC)
target_compile_options(myapp PRIVATE
/W4 # Warning level 4
/WX # Warnings as errors
/permissive- # Strict conformance mode
/Zc:__cplusplus # Report correct __cplusplus value
)
else()
# GCC and Clang
target_compile_options(myapp PRIVATE
-Wall
-Wextra
-Wpedantic
-Werror
-Wshadow
-Wnon-virtual-dtor
-Wold-style-cast
-Wcast-align
-Woverloaded-virtual
)
if(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
target_compile_options(myapp PRIVATE
-Weverything
-Wno-c++98-compat # We require C++20
)
endif()
endif()
# Install rules (portable)
include(GNUInstallDirs)
install(TARGETS myapp
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
)
# CPack: create installers for each platform
set(CPACK_PACKAGE_NAME "MyApp")
set(CPACK_PACKAGE_VERSION ${PROJECT_VERSION})
if(WIN32)
set(CPACK_GENERATOR "NSIS;ZIP")
elseif(APPLE)
set(CPACK_GENERATOR "DragNDrop;TGZ")
else()
set(CPACK_GENERATOR "DEB;RPM;TGZ")
endif()
include(CPack)
# Testing
enable_testing()
add_subdirectory(tests)
CI/CD: Building on All Platforms
A GitHub Actions workflow that builds on all three major platforms simultaneously:
# .github/workflows/cross-platform.yml
name: Cross-Platform Build
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build:
strategy:
matrix:
include:
# Linux builds
- os: ubuntu-22.04
compiler: gcc-12
cc: gcc-12
cxx: g++-12
name: "Linux GCC 12"
- os: ubuntu-22.04
compiler: clang-15
cc: clang-15
cxx: clang++-15
name: "Linux Clang 15"
# macOS build
- os: macos-13
compiler: clang
cc: clang
cxx: clang++
name: "macOS Clang"
# Windows builds
- os: windows-2022
compiler: msvc
name: "Windows MSVC 2022"
- os: windows-2022
compiler: mingw
cc: gcc
cxx: g++
name: "Windows MinGW"
runs-on: ${{ matrix.os }}
name: ${{ matrix.name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y cmake ninja-build
- name: Install dependencies (macOS)
if: runner.os == 'macOS'
run: brew install cmake ninja
- name: Setup MSVC (Windows)
if: matrix.compiler == 'msvc'
uses: ilammy/msvc-dev-cmd@v1
- name: Setup MinGW (Windows)
if: matrix.compiler == 'mingw'
uses: msys2/setup-msys2@v2
with:
install: mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake
- name: Configure (Unix)
if: runner.os != 'Windows' || matrix.compiler == 'mingw'
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
run: |
cmake -B build -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_STANDARD=20
- name: Configure (MSVC)
if: matrix.compiler == 'msvc'
run: |
cmake -B build `
-DCMAKE_BUILD_TYPE=Release `
-DCMAKE_CXX_STANDARD=20
- name: Build
run: cmake --build build --config Release -j
- name: Test
working-directory: build
run: ctest --build-config Release --output-on-failure
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.name }}-binary
path: build/
Portability Checklist
| Category | Rule | Bad | Good |
|---|---|---|---|
| Integer sizes | Use fixed-width types | long val = getSize() |
int64_t val = getSize() |
| Pointer arithmetic | Use intptr_t/uintptr_t |
long ptr_val = (long)ptr |
intptr_t ptr_val = (intptr_t)ptr |
| Array sizes | Use size_t for container sizes |
int sz = v.size() |
size_t sz = v.size() |
| Bit operations | Use unsigned types | int mask = 0x80000000 |
uint32_t mask = 0x80000000u |
| File paths | Use std::filesystem::path |
string p = "/tmp/" + name |
fs::temp_directory_path() / name |
| Platform APIs | Abstract behind interface | CreateFileA(...) in main code |
Platform::openFile(...) |
| Threads | Use std::thread |
pthread_create(...) |
thread t(fn) |
| Timing | Use std::chrono |
gettimeofday(...) |
chrono::high_resolution_clock::now() |
| Char signedness | Cast to uint8_t for arithmetic |
int v = char_val |
int v = (uint8_t)char_val |
| Windows headers | Add lean + nominmax | #include <windows.h> |
#define WIN32_LEAN_AND_MEAN… |
| Compiler extensions | Use standard C++ | int arr[n] (VLA) |
vector<int> arr(n) |
| Line endings | Use text mode for text files | ios::binary for text |
Default (text) mode |
| Endianness | Explicit byte-order conversion | Cast pointer to int directly | Use byteswap or explicit swap |
Conclusion
Cross-platform C++ development is a discipline rather than a single technique. It requires understanding what the language standard specifies uniformly, what varies by platform, and how to bridge the gap cleanly.
The most important rules are: use fixed-width integer types (int32_t, int64_t) when size matters; abstract OS-specific APIs behind narrow interfaces in separate translation units; use std::filesystem instead of POSIX or Win32 file APIs; use std::thread, std::mutex, and std::chrono instead of platform threading and timing APIs; and always test on all target platforms — bugs that hide on one compiler frequently surface on another.
CMake is the build system of choice for cross-platform C++. Its target-based model (target_link_libraries, target_compile_definitions, target_compile_options) makes it natural to express platform-specific dependencies and settings without polluting the global build environment. Combined with find_package for third-party libraries and CPack for creating platform-specific installers, CMake handles the build side of portability comprehensively.
CI/CD with matrix builds on GitHub Actions (or similar) is the enforcement mechanism. Every commit that compiles and passes tests on Linux GCC, Linux Clang, macOS Clang, and Windows MSVC is demonstrably portable. Portability bugs that slip through are caught immediately, before they accumulate into a large refactoring burden.
The investment in cross-platform discipline pays dividends in flexibility: code that runs on Linux also runs on embedded Linux; code that works on macOS often compiles on BSD; code validated on MSVC usually compiles on the Intel compiler. Write to the standard, test broadly, and the same source serves every platform your users run.




