Building Cross-Platform C++ Applications

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:

  1. long is 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 a long works on Linux but fails on Windows. Always use intptr_t for pointer-sized integers or int64_t for explicitly 64-bit values.
  2. Fixed-width integer types (int8_t, int16_t, int32_t, int64_t from <cstdint>) are always exactly the specified size — the right tool for protocol parsing, binary file formats, and any code where size matters.
  3. 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.
  4. Predefined macros (_WIN32, __linux__, __APPLE__, __GNUC__, _MSC_VER) are the standard mechanism for platform detection. _WIN32 is defined even on 64-bit Windows — it means “Windows”, not “32-bit Windows”. Use _WIN64 for 64-bit Windows specifically.
  5. Path separators: Windows accepts / in most contexts, but programs that generate paths should use std::filesystem::path which 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:

  1. platform.hpp defines only the interface — types and function declarations. No #ifdef in the header. Callers include only platform.hpp and write portable code against the abstraction.
  2. Three implementation files: platform_posix.cpp (Linux + macOS using POSIX APIs), platform_windows.cpp (Windows using Win32 APIs), and potentially platform_mac.cpp for macOS-specific extensions. The build system selects which to compile.
  3. #define WIN32_LEAN_AND_MEAN and #define NOMINMAX before <windows.h> are essential: WIN32_LEAN_AND_MEAN reduces the size of the Windows header (omitting rarely used APIs), and NOMINMAX prevents Windows from defining min and max macros that conflict with std::min/std::max.
  4. 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.
  5. 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:

  1. fs::path uses the platform-correct separator automatically: / on POSIX, \ on Windows. The / operator concatenates path components without you needing to know which separator to use.
  2. fs::temp_directory_path() returns the system’s temp directory — /tmp on Linux/macOS, %TEMP% or %TMP% on Windows. Always use this instead of hardcoding /tmp.
  3. fs::create_directories(path) creates the full directory tree (like mkdir -p) — works on all platforms.
  4. fs::directory_iterator and fs::recursive_directory_iterator iterate directory contents portably — no opendir/readdir (POSIX) or FindFirstFile/FindNextFile (Windows) required.
  5. 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.

Hot this week

C++ Performance Profiling and Optimization Techniques

Master C++ performance optimization. Learn how to profile code, eliminate bottlenecks, leverage CPU caches, use Google Benchmark, and apply modern C++ techniques for maximum speed.

Implementing Design Patterns in Modern C++: A Complete Guide

Discover how modern C++ (C++11/14/17/20) revolutionizes classic GoF design patterns. Learn to write safer, cleaner, and more efficient code using smart pointers, lambdas, concepts, and std::variant.

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

Topics

C++ Performance Profiling and Optimization Techniques

Master C++ performance optimization. Learn how to profile code, eliminate bottlenecks, leverage CPU caches, use Google Benchmark, and apply modern C++ techniques for maximum speed.

Implementing Design Patterns in Modern C++: A Complete Guide

Discover how modern C++ (C++11/14/17/20) revolutionizes classic GoF design patterns. Learn to write safer, cleaner, and more efficient code using smart pointers, lambdas, concepts, and std::variant.

SIMD Programming in C++: A Comprehensive Guide to Vectorization

SIMD (Single Instruction, Multiple Data) programming in C++ is...

Writing Cache-Friendly C++ Code

Learn to write cache-friendly C++ code — understand CPU caches, cache lines, spatial and temporal locality, data-oriented design, struct layout, false sharing, and how to measure cache performance.

Understanding Undefined Behavior in C++

Master C++ undefined behavior — learn what it is, the most dangerous forms (signed overflow, null dereference, data races, UB in templates), how compilers exploit it, and how to detect and eliminate it.

CMake Mastery: Modern C++ Build Systems

Master CMake for modern C++ projects — learn targets, properties, find_package, FetchContent, generator expressions, testing with CTest, and professional project structure.

Coroutines in C++20: Asynchronous Programming

Master C++20 coroutines — learn co_await, co_yield, co_return, promise types, awaitables, generators, and how to build async tasks and lazy sequences without callback hell.

The Ranges Library in C++20: Pipeline Operations

Master C++20 Ranges — learn views, range adaptors, lazy evaluation, pipeline composition with |, and how ranges make STL algorithms more expressive and composable.

Related Articles

Popular Categories