C++ 크로스 플랫폼 기초: 플랫폼 감지 매크로, std::filesystem 경로, 동적 로딩 추상화
들어가며: “Windows에서 빌드한 게 Linux에서 안 돌아가요”
실제 겪는 문제 시나리오
크로스 플랫폼 C++ 프로젝트를 운영하면 플랫폼마다 다른 경로 구분자·라이브러리·API 때문에 빌드가 실패하거나, 한 OS에서만 동작하는 코드가 섞여 들어갑니다. 이 글은 그중 소스 코드 쪽, 즉 플랫폼 감지·경로 추상화·동적 로딩·추상화 레이어를 다룬다. 툴체인과 패키징 같은 빌드 쪽은 55-7에서 다룬다.
flowchart TD
subgraph wrong[❌ 플랫폼 의존 코드]
W1[하드코딩 경로 config\file.txt]
W2[Windows 전용 LoadLibrary]
W3[플랫폼별 #include 누락]
W4[한 OS에서만 빌드 성공]
end
subgraph right[✅ 크로스 플랫폼]
R1[std::filesystem::path]
R2[동적 로딩 추상화]
R3[플랫폼 매크로 분기]
R4[모든 타겟에서 빌드]
end
_WIN32·__linux__·__APPLE__ 같은 플랫폼 감지 매크로, std::filesystem을 이용한 경로 처리, LoadLibrary/dlopen을 감싸는 동적 로딩 추상화, #ifdef를 한곳에 가두는 플랫폼 추상화 레이어를 다루고, 엔디안과 구조체 정렬 문제도 짚습니다.
요구 환경: C++17 이상, CMake 3.16+
이 글이 맡는 범위: 코드 수준의 차이
크로스 플랫폼 프로젝트가 깨지는 상황(경로 구분자, 라이브러리 확장자, pthread 링크, unistd.h 누락, MinGW/MSVC ABI 혼용, 모바일 크로스 컴파일, 패키징 의존성 누락, CI 매트릭스 실패)은 55-7 크로스 플랫폼 빌드의 시나리오 목록에 모아 두었습니다. 두 글은 이렇게 나눕니다.
| 이 글 (55-6, 코드) | 55-7 (빌드) |
|---|---|
| 플랫폼 감지 매크로, 기능 감지 | CMake 툴체인 파일 (MinGW·iOS·Android NDK) |
std::filesystem 경로 | CPack 패키징, 설치 레이아웃, 런타임 검색 경로 |
LoadLibrary/dlopen 추상화, 심볼 내보내기 | 심볼 버전 스크립트, LTO, 디버그 심볼 분리 |
| 플랫폼 추상화 레이어 (헤더 + 구현 파일) | CI 매트릭스, 통합 빌드 스크립트 |
| 타입 크기, 엔디안, 구조체 정렬 | 빌드·링크·패키징 에러 |
같은 문제라도 원인이 소스 코드에 있으면 이 글, 빌드 설정에 있으면 55-7에서 다룬다고 보면 됩니다. 예를 들어 “Windows에서 unistd.h가 없다”는 소스 코드를 고칠 문제이고, “Android에서 libc++_shared.so를 찾지 못한다”는 툴체인 설정 문제입니다.
플랫폼 감지 매크로
컴파일러별 정의되는 매크로
| 플랫폼 | 매크로 | 비고 |
|---|---|---|
| Windows (32/64) | _WIN32 | 64비트에서도 정의됨 |
| Windows 64비트 | _WIN64 | _WIN32와 함께 정의 |
| Linux | __linux__ | |
| macOS | __APPLE__, __MACH__ | iOS와 구분 필요 |
| Android | __ANDROID__ | __linux__와 함께 정의 |
| iOS | __APPLE__ + TARGET_OS_IPHONE | TargetConditionals.h 필요 |
완전한 플랫폼 감지 헤더
// platform_detect.hpp — 모든 주요 플랫폼 감지
#ifndef PLATFORM_DETECT_HPP
#define PLATFORM_DETECT_HPP
#if defined(_WIN32) || defined(_WIN64)
#define PLATFORM_WINDOWS 1
#define PLATFORM_NAME "Windows"
#elif defined(__APPLE__)
#include <TargetConditionals.h>
// TARGET_OS_IPHONE은 시뮬레이터에서도 1이므로 시뮬레이터를 먼저 검사
#if TARGET_OS_SIMULATOR
#define PLATFORM_IOS 1
#define PLATFORM_IOS_SIMULATOR 1
#define PLATFORM_NAME "iOS Simulator"
#elif TARGET_OS_IPHONE
#define PLATFORM_IOS 1
#define PLATFORM_NAME "iOS"
#else
#define PLATFORM_MACOS 1
#define PLATFORM_NAME "macOS"
#endif
#elif defined(__linux__)
#if defined(__ANDROID__)
#define PLATFORM_ANDROID 1
#define PLATFORM_NAME "Android"
#else
#define PLATFORM_LINUX 1
#define PLATFORM_NAME "Linux"
#endif
#elif defined(__FreeBSD__)
#define PLATFORM_FREEBSD 1
#define PLATFORM_NAME "FreeBSD"
#else
#define PLATFORM_UNKNOWN 1
#define PLATFORM_NAME "Unknown"
#endif
// 편의 매크로: POSIX 계열 (Linux, macOS, BSD, Android)
#if defined(__unix__) || (defined(__APPLE__) && defined(__MACH__))
#define PLATFORM_POSIX 1
#else
#define PLATFORM_POSIX 0
#endif
#endif // PLATFORM_DETECT_HPP
주의: _WIN32는 64비트 Windows에서도 정의됩니다. _WIN64만으로 64비트를 구분하는 것은 권장하지 않습니다. _WIN32가 있으면 Windows로 간주합니다.
CMake 플랫폼 변수
# CMake에서 플랫폼 감지
if(WIN32)
message(STATUS "Building for Windows")
elseif(APPLE)
message(STATUS "Building for macOS or iOS")
elseif(UNIX AND NOT APPLE)
message(STATUS "Building for Linux")
endif()
# 아키텍처
message(STATUS "CMAKE_SYSTEM_PROCESSOR: ${CMAKE_SYSTEM_PROCESSOR}")
# x86_64, AMD64, ARM64, aarch64 등
std::filesystem으로 경로 다루기
C++17 std::filesystem 기본 사용
// path_example.cpp — std::filesystem으로 크로스 플랫폼 경로
#include <filesystem>
#include <iostream>
#include <string>
namespace fs = std::filesystem;
int main() {
// 경로 결합: / 또는 \ 자동 처리
auto config_path = fs::path("config") / "settings.json";
std::cout << "Config: " << config_path.string() << "\n";
// 현재 작업 디렉터리
auto cwd = fs::current_path();
std::cout << "CWD: " << cwd.string() << "\n";
// 파일 존재 확인
if (fs::exists(config_path)) {
std::cout << "Config exists\n";
// 파일 크기
auto size = fs::file_size(config_path);
std::cout << "Size: " << size << " bytes\n";
}
// 디렉터리 생성
fs::create_directories("output/logs");
// 디렉터리 순회
for (const auto& entry : fs::directory_iterator(".")) {
std::cout << entry.path().filename().string() << "\n";
}
return 0;
}
경로 유틸리티 함수
// path_utils.hpp — 크로스 플랫폼 경로 유틸
#include <filesystem>
#include <string>
#include <vector>
namespace platform {
namespace fs = std::filesystem;
inline fs::path join(const fs::path& base, const std::string& sub) {
return base / sub;
}
inline std::string extension(const std::string& path) {
return fs::path(path).extension().string();
}
inline std::string stem(const std::string& path) {
return fs::path(path).stem().string();
}
inline std::string parent_path(const std::string& path) {
return fs::path(path).parent_path().string();
}
inline bool exists(const std::string& path) {
return fs::exists(path);
}
inline std::vector<std::string> listDirectory(const std::string& dir) {
std::vector<std::string> result;
for (const auto& entry : fs::directory_iterator(dir)) {
result.push_back(entry.path().filename().string());
}
return result;
}
} // namespace platform
경로 구분자 주의사항
// ❌ 나쁜 예: 하드코딩된 구분자
std::string path = "config" + std::string(1, '\\') + "file.txt"; // Linux에서 문제
// ✅ 좋은 예: std::filesystem
auto path = fs::path("config") / "file.txt";
// Windows: config\file.txt
// Linux/macOS: config/file.txt
dlopen/LoadLibrary 동적 로딩 추상화
플랫폼별 API
| 플랫폼 | 로드 | 심볼 조회 | 해제 | 확장자 |
|---|---|---|---|---|
| Windows | LoadLibrary | GetProcAddress | FreeLibrary | .dll |
| Linux | dlopen | dlsym | dlclose | .so |
| macOS | dlopen | dlsym | dlclose | .dylib, .so |
완전한 동적 로딩 추상화
// dynamic_loader.hpp — 크로스 플랫폼 동적 로딩
#ifndef DYNAMIC_LOADER_HPP
#define DYNAMIC_LOADER_HPP
#include <string>
#include <stdexcept>
#if defined(_WIN32)
#include <windows.h>
using ModuleHandle = HMODULE;
#else
#include <dlfcn.h>
using ModuleHandle = void*;
#endif
class DynamicLoader {
public:
static std::string getSharedLibExtension() {
#if defined(_WIN32)
return ".dll";
#elif defined(__APPLE__)
return ".dylib";
#else
return ".so";
#endif
}
static std::string getSharedLibPrefix() {
#if defined(_WIN32)
return "";
#else
return "lib";
#endif
}
static std::string makeLibraryName(const std::string& base) {
return getSharedLibPrefix() + base + getSharedLibExtension();
}
static ModuleHandle load(const std::string& path) {
#if defined(_WIN32)
return LoadLibraryA(path.c_str());
#else
return dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
#endif
}
static void* getSymbol(ModuleHandle handle, const char* name) {
#if defined(_WIN32)
return reinterpret_cast<void*>(GetProcAddress(handle, name));
#else
return dlsym(handle, name);
#endif
}
static void unload(ModuleHandle handle) {
#if defined(_WIN32)
FreeLibrary(handle);
#else
dlclose(handle);
#endif
}
static std::string getLastError() {
#if defined(_WIN32)
DWORD err = GetLastError();
return "Error code: " + std::to_string(err);
#else
// dlerror()는 호출할 때마다 에러 상태를 비우므로 한 번만 읽는다
const char* msg = dlerror();
return msg ? msg : "Unknown error";
#endif
}
};
// RAII 래퍼
class ScopedModule {
public:
explicit ScopedModule(const std::string& path)
: handle_(DynamicLoader::load(path)) {
if (!handle_) {
throw std::runtime_error("Failed to load: " + path + " - " +
DynamicLoader::getLastError());
}
}
~ScopedModule() {
if (handle_) {
DynamicLoader::unload(handle_);
}
}
ScopedModule(const ScopedModule&) = delete; // 핸들 이중 해제 방지
ScopedModule& operator=(const ScopedModule&) = delete;
// Func는 함수 포인터 타입이어야 한다 (예: int(*)(int, int))
template <typename Func>
Func getFunc(const char* name) {
void* sym = DynamicLoader::getSymbol(handle_, name);
return reinterpret_cast<Func>(sym);
}
explicit operator bool() const { return handle_ != nullptr; }
private:
ModuleHandle handle_;
};
#endif // DYNAMIC_LOADER_HPP
사용 예시
// plugin_usage.cpp
#include "dynamic_loader.hpp"
#include <iostream>
int main() {
auto lib_name = DynamicLoader::makeLibraryName("mylib");
std::cout << "Loading: " << lib_name << "\n";
try {
ScopedModule mod(lib_name);
auto add = mod.getFunc<int (*)(int, int)>("add");
if (add) {
std::cout << "add(2, 3) = " << add(2, 3) << "\n";
}
} catch (const std::exception& e) {
std::cerr << e.what() << "\n";
}
return 0;
}
CMake 3대 플랫폼 빌드와 추상화 레이어 예제
최소 CMakeLists.txt (데스크톱 3대)
cmake_minimum_required(VERSION 3.16)
project(CrossPlatformApp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
# 플랫폼별 라이브러리
if(WIN32)
set(PLATFORM_LIBS ws2_32)
elseif(UNIX AND NOT APPLE)
find_package(Threads REQUIRED)
set(PLATFORM_LIBS Threads::Threads ${CMAKE_DL_LIBS})
elseif(APPLE)
find_package(Threads REQUIRED)
set(PLATFORM_LIBS Threads::Threads) # macOS는 dlopen이 libSystem에 포함
endif()
add_executable(app main.cpp)
target_link_libraries(app PRIVATE ${PLATFORM_LIBS})
# 플랫폼별 컴파일 정의
if(WIN32)
target_compile_definitions(app PRIVATE PLATFORM_WINDOWS=1)
elseif(APPLE)
target_compile_definitions(app PRIVATE PLATFORM_APPLE=1)
else()
target_compile_definitions(app PRIVATE PLATFORM_LINUX=1)
endif()
전체 동작 예제: main.cpp
// main.cpp — 실행 시 플랫폼 정보 출력
#include <iostream>
#include <filesystem>
#include "platform_detect.hpp"
#include "dynamic_loader.hpp"
int main() {
std::cout << "Platform: " << PLATFORM_NAME << "\n";
std::cout << "CWD: " << std::filesystem::current_path().string() << "\n";
// 경로 결합 — 모든 플랫폼에서 동작
auto path = std::filesystem::path("config") / "settings.json";
std::cout << "Config path: " << path.string() << "\n";
// 동적 라이브러리 확장자
std::cout << "Shared lib ext: " << DynamicLoader::getSharedLibExtension() << "\n";
return 0;
}
네트워크 초기화 (Windows WSA)
// network_platform.hpp — 소켓 초기화 (Windows는 WSA 필요)
#include <stdexcept>
#if defined(_WIN32)
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
#else
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#endif
class NetworkPlatform {
public:
static void init() {
#if defined(_WIN32)
WSADATA wsa;
if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) {
throw std::runtime_error("WSAStartup failed");
}
#endif
}
static void shutdown() {
#if defined(_WIN32)
WSACleanup();
#endif
}
};
위 클래스는 조건부 컴파일의 가장 흔한 형태를 보여 줍니다. 인터페이스(init/shutdown)는 모든 플랫폼에서 같고, Windows에서만 할 일이 있을 때 그 부분만 #if로 감쌉니다. POSIX 쪽은 빈 함수가 되어 호출 비용도 없습니다. 호출하는 코드는 플랫폼을 몰라도 되므로 #ifdef가 애플리케이션 코드로 번지지 않습니다.
조건부 헤더 include
// ❌ 나쁜 예: 플랫폼별 헤더를 조건 없이 include
#include <windows.h> // Linux에서 컴파일 실패
// ✅ 좋은 예: 조건부 include
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN // 잘 안 쓰는 API 헤더를 빼서 빌드 시간·충돌 감소
#define NOMINMAX // windows.h의 min/max 매크로가 std::min을 깨는 것 방지
#include <windows.h>
#include <io.h>
#else
#include <dlfcn.h>
#include <unistd.h>
#endif
예전 코드에서 #define close _close처럼 POSIX 이름을 매크로로 덮어쓰는 방식을 종종 봅니다. 이 방식은 권하지 않습니다. 전처리기는 문맥을 모르기 때문에 file.close()나 socket.close() 같은 멤버 함수 호출까지 _close로 바꿔 버립니다. 이름이 다른 함수가 필요하면 매크로 대신 platform::closeFd() 같은 래퍼 함수를 만들어 추상화 레이어의 구현 파일 안에 두는 편이 안전합니다.
windows.h는 이런 매크로를 스스로도 많이 정의합니다. min/max 외에도 CreateFile이 CreateFileA/CreateFileW로 바뀌는 식이라, 공용 헤더에서 windows.h를 include하면 이 매크로들이 그 헤더를 쓰는 모든 번역 단위로 퍼집니다. 그래서 windows.h는 공용 헤더가 아니라 Windows 구현 .cpp 안에서만 include하는 것을 원칙으로 삼는 편이 좋습니다.
플랫폼 추상화 레이어: 헤더 하나 + 구현 파일 여러 개
#if를 함수 안에 넣는 방식은 분기가 두세 개일 때까지는 괜찮습니다. 플랫폼별 코드가 길어지면 한 파일 안에서 Windows 코드와 POSIX 코드가 번갈아 나와 읽기 어렵고, 한쪽을 고치다 다른 쪽을 깨뜨려도 그 플랫폼에서 빌드하기 전까지 모릅니다. 규모가 커지면 공통 헤더에는 선언만 두고, 구현은 플랫폼별 .cpp로 나누는 구조가 관리하기 쉽습니다.
// include/platform/file.hpp — 모든 플랫폼이 공유하는 선언
#pragma once
#include <cstdint>
#include <optional>
#include <string>
namespace platform {
bool fileExists(const std::string& path);
std::optional<std::string> readFile(const std::string& path); // 실패는 nullopt
std::uint64_t processId();
} // namespace platform
// src/platform/file_common.cpp — 표준 라이브러리로 충분한 부분은 한 번만 구현
#include "platform/file.hpp"
#include <filesystem>
#include <fstream>
#include <iterator>
namespace platform {
bool fileExists(const std::string& path) {
std::error_code ec; // 예외 대신 에러 코드로 받기
return std::filesystem::exists(path, ec);
}
std::optional<std::string> readFile(const std::string& path) {
std::ifstream f(path, std::ios::binary); // 텍스트 모드는 Windows에서 \r\n을 변환함
if (!f) return std::nullopt;
return std::string(std::istreambuf_iterator<char>(f),
std::istreambuf_iterator<char>());
}
} // namespace platform
// src/platform/process_win.cpp
#include "platform/file.hpp"
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
std::uint64_t platform::processId() { return GetCurrentProcessId(); }
// src/platform/process_posix.cpp
#include "platform/file.hpp"
#include <unistd.h>
std::uint64_t platform::processId() { return static_cast<std::uint64_t>(getpid()); }
# 어떤 구현 파일을 컴파일할지는 CMake가 결정
add_library(platform STATIC src/platform/file_common.cpp)
if(WIN32)
target_sources(platform PRIVATE src/platform/process_win.cpp)
else()
target_sources(platform PRIVATE src/platform/process_posix.cpp)
endif()
target_include_directories(platform PUBLIC include)
이렇게 나누면 좋은 점이 세 가지 있습니다. 첫째, 각 구현 파일 안에는 #ifdef가 하나도 없어서 그 플랫폼의 코드만 읽으면 됩니다. 둘째, windows.h나 unistd.h가 구현 파일 밖으로 새어 나가지 않아 공통 헤더를 include하는 쪽에 매크로 오염이 없습니다. 셋째, 새 플랫폼을 추가할 때 헤더는 그대로 두고 구현 파일 하나와 CMake 분기 하나만 늘리면 됩니다.
fileExists와 readFile처럼 std::filesystem과 std::ifstream으로 충분한 기능은 플랫폼별로 나누지 않고 공통 파일에서 한 번만 구현했습니다. 추상화 레이어는 “모든 것을 감싸는 층”이 아니라 표준 라이브러리로 해결되지 않는 부분만 모아 두는 곳으로 두는 것이 좋습니다. 감쌀 필요가 없는 것까지 감싸면 레이어가 비대해지고, 표준 API를 아는 사람도 사내 API를 다시 배워야 합니다.
다른 OS·CPU용으로 빌드할 때
지금까지는 빌드하는 머신과 실행하는 머신이 같은 경우를 다뤘습니다. 다른 OS·CPU용 바이너리를 만들려면(크로스 컴파일) CMake에 툴체인 파일을 넘겨 컴파일러, SDK/sysroot, 라이브러리 검색 위치를 알려 줘야 합니다. MinGW-w64, iOS, Android NDK 툴체인 파일 작성과 호스트별 조합은 55-7 크로스 플랫폼 빌드의 툴체인 절에서 다룹니다.
코드 쪽에서 챙길 것은 하나입니다. 툴체인을 바꿔도 소스 코드는 바뀌지 않아야 합니다. 그렇게 만들어 주는 것이 플랫폼 감지 매크로와 추상화 레이어입니다. 크로스 컴파일에서 빌드가 깨질 때 소스에 #ifdef __ANDROID__를 하나 더 넣어 해결하고 싶어지는데, 대부분은 툴체인 설정(sysroot, STL 선택, 최소 API 레벨)을 고쳐야 하는 문제입니다. 소스에 분기를 늘리기 전에 빌드 설정을 먼저 의심하는 편이 결과적으로 코드가 깨끗하게 유지됩니다.
헤더 누락·링크 에러·타입 크기 차이
“fatal error: ‘unistd.h’ file not found” (Windows)
원인: unistd.h는 POSIX 전용 헤더입니다. Windows(MSVC)에는 없습니다.
해결:
// ❌ 잘못된 코드
#include <unistd.h> // Windows에서 없음
// ✅ 올바른 코드: 헤더만 분기하고, 이름이 다른 함수는 래퍼로 감싼다 (5.4절 참고)
#if defined(_WIN32)
#include <io.h>
#include <process.h>
#else
#include <unistd.h>
#endif
또는 std::filesystem·std::thread 등 표준 라이브러리로 대체합니다.
”undefined reference to dlopen” (Linux)
원인: dlopen는 libdl에 있습니다. 링크하지 않으면 에러가 납니다.
해결:
# CMakeLists.txt
if(UNIX AND NOT APPLE)
target_link_libraries(app PRIVATE dl)
endif()
# 또는 CMake가 플랫폼별로 채워 주는 변수 사용 (필요 없는 플랫폼에서는 빈 값)
target_link_libraries(app PRIVATE ${CMAKE_DL_LIBS})
glibc 2.34부터는 dlopen이 libc 본체로 옮겨져 -ldl 없이도 링크되지만, 오래된 배포판을 함께 지원한다면 CMAKE_DL_LIBS를 쓰는 편이 안전합니다.
”DLL not found” 또는 “dyld: Library not loaded” (런타임)
원인: 실행 시점에 로더가 동적 라이브러리를 찾지 못했습니다. 코드에서 dlopen("libfoo.so")처럼 파일 이름만 넘기면 로더의 검색 경로(Windows는 실행 파일 디렉터리와 PATH, Linux는 RPATH·LD_LIBRARY_PATH·시스템 경로, macOS는 install name과 @rpath)에 의존하게 됩니다.
해결: 플러그인처럼 앱이 직접 로드하는 라이브러리는 이름만 넘기지 말고, 실행 파일 위치를 기준으로 절대 경로를 만들어 로드합니다. 검색 경로 자체를 올바르게 설정하는 방법(RPATH, @rpath, 설치 레이아웃)은 55-7의 설치 레이아웃과 런타임 검색 경로에서 다룹니다. LD_LIBRARY_PATH로 해결하는 것은 개발 중 임시방편으로만 쓰는 편이 좋습니다.
”symbol not found” (macOS)
원인: GCC/Clang의 기본 가시성은 default라서 원래는 모든 심볼이 내보내집니다. 그런데 Xcode 프로젝트 기본 설정이나 -fvisibility=hidden(CMake의 CXX_VISIBILITY_PRESET hidden)을 켜면 명시하지 않은 심볼이 모두 숨겨지고, dlsym이 심볼을 찾지 못합니다. 이 경우 내보낼 심볼에 __attribute__((visibility("default")))를 붙여야 합니다.
해결:
#if defined(__APPLE__)
#define EXPORT_API __attribute__((visibility("default")))
#elif defined(_WIN32)
#define EXPORT_API __declspec(dllexport)
#else
#define EXPORT_API __attribute__((visibility("default")))
#endif
extern "C" EXPORT_API void my_exported_function();
Windows “LNK2019: unresolved external symbol”
원인: 라이브러리를 링크하지 않았거나, __declspec(dllimport)/dllexport가 누락되었습니다.
해결:
// mylib.h
#if defined(_WIN32)
#ifdef MYLIB_EXPORTS
#define MYLIB_API __declspec(dllexport)
#else
#define MYLIB_API __declspec(dllimport)
#endif
#else
#define MYLIB_API
#endif
MYLIB_API void my_function();
# DLL 빌드 시
add_library(mylib SHARED mylib.cpp)
target_compile_definitions(mylib PRIVATE MYLIB_EXPORTS)
long 타입 크기 차이
원인: Windows 64비트에서 long은 4바이트, Linux 64비트에서는 8바이트입니다.
해결:
// ❌ 나쁜 예: long에 의존
long file_size;
// ✅ 좋은 예: 고정 크기 타입
#include <cstdint>
int64_t file_size;
uint32_t count;
엔디안(바이트 순서)과 구조체 정렬
원인: 오늘날 데스크톱·모바일에서 쓰는 x86-64와 ARM64는 사실상 모두 리틀 엔디안이라 PC와 스마트폰 사이에서는 잘 드러나지 않습니다. 문제는 네트워크 바이트 순서(빅 엔디안)로 정의된 프로토콜을 다룰 때, 그리고 일부 임베디드·네트워크 장비(PowerPC, 빅 엔디안 MIPS 등)와 데이터를 주고받을 때 생깁니다. 이보다 더 자주 부딪히는 것은 구조체 정렬입니다. 구조체를 fwrite(&s, sizeof(s), 1, f)처럼 통째로 쓰면, 컴파일러가 넣은 패딩과 long 같은 플랫폼 의존 타입의 크기가 파일 포맷에 그대로 박힙니다. 그래서 다른 컴파일러나 다른 ABI(예: 32비트 ARM과 x86-64)로 읽으면 필드 위치가 어긋납니다.
해결:
#include <cstdint>
inline uint32_t swapEndian(uint32_t x) {
return ((x >> 24) & 0xff) | ((x >> 8) & 0xff00) |
((x << 8) & 0xff0000) | ((x << 24) & 0xff000000);
}
#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
#define TO_NETWORK_ORDER(x) (x)
#else
#define TO_NETWORK_ORDER(x) swapEndian(x)
#endif
__BYTE_ORDER__는 GCC/Clang 전용 매크로라 MSVC에서는 정의되지 않습니다(위 코드는 MSVC에서 항상 swap 쪽으로 가는데, Windows 타겟은 리틀 엔디안이라 결과는 맞습니다). C++20이면 std::endian::native == std::endian::little로 컴파일러와 무관하게 검사할 수 있고, C++23에는 std::byteswap이 있습니다.
정렬 문제는 구조체를 메모리 그대로 쓰지 말고 필드 단위로 고정 크기·고정 순서로 직렬화해서 피합니다.
#include <cstdint>
#include <vector>
struct Header {
std::uint32_t magic;
std::uint16_t version;
std::uint64_t payloadSize; // 앞 필드 뒤에 패딩이 들어갈 수 있음
};
// 메모리 레이아웃에 기대지 않고 바이트를 직접 배치 (리틀 엔디안으로 고정)
inline void putU32(std::vector<std::uint8_t>& out, std::uint32_t v) {
for (int i = 0; i < 4; ++i) out.push_back(static_cast<std::uint8_t>(v >> (8 * i)));
}
inline void putU16(std::vector<std::uint8_t>& out, std::uint16_t v) {
out.push_back(static_cast<std::uint8_t>(v));
out.push_back(static_cast<std::uint8_t>(v >> 8));
}
inline void putU64(std::vector<std::uint8_t>& out, std::uint64_t v) {
for (int i = 0; i < 8; ++i) out.push_back(static_cast<std::uint8_t>(v >> (8 * i)));
}
std::vector<std::uint8_t> serialize(const Header& h) {
std::vector<std::uint8_t> out;
putU32(out, h.magic);
putU16(out, h.version);
putU64(out, h.payloadSize); // 항상 14바이트, 패딩 없음
return out;
}
어쩔 수 없이 구조체를 그대로 매핑해야 한다면(메모리 맵 파일, 하드웨어 레지스터 등) static_assert(sizeof(Header) == 16)과 static_assert(offsetof(Header, payloadSize) == 8)처럼 레이아웃을 컴파일 시점에 고정해 두면, 다른 플랫폼에서 레이아웃이 달라졌을 때 빌드가 바로 실패합니다. #pragma pack(1)으로 패딩을 없애는 방법도 있지만, 정렬되지 않은 필드에 접근하면 일부 아키텍처에서는 느려지거나 예외가 날 수 있으니 직렬화 경계에서만 제한적으로 쓰는 편이 좋습니다.
MinGW의 WinMain 링크 에러, macOS·iOS 코드 서명, Android libc++_shared 누락, GLIBCXX 버전, CPack 의존성 에러처럼 빌드·링크·패키징 설정에서 생기는 에러는 55-7의 에러 절에 있습니다.
플랫폼 분기 코드를 한곳에 모으는 원칙
플랫폼별 코드는 한곳에
// ❌ 나쁜 예: 플랫폼별 코드가 함수 안에 산재
void doWork() {
#ifdef _WIN32
// Windows 전용 50줄
#else
// Unix 전용 50줄
#endif
}
// ✅ 좋은 예: 추상화 레이어(5.5절)로 분리
void doWork() {
platform::init();
platform::execute();
platform::cleanup();
}
OS보다 기능을 감지하기
#ifdef __linux__는 “Linux이면 이 기능이 있다”는 가정을 담고 있습니다. 이 가정은 생각보다 자주 틀립니다. Android도 __linux__를 정의하지만 glibc가 아니라 Bionic을 쓰므로 일부 함수가 없거나 API 레벨에 따라 달라지고, musl 기반 Alpine Linux는 glibc 전용 확장이 없습니다. 반대로 macOS와 FreeBSD는 OS는 달라도 같은 BSD 계열 API를 공유합니다.
그래서 가능하면 “어떤 OS인가”가 아니라 “이 헤더·함수가 있는가”를 검사하는 편이 새 플랫폼이 추가될 때 덜 깨집니다. C++17의 __has_include는 헤더 존재를 컴파일 시점에 확인합니다.
#if __has_include(<unistd.h>)
#include <unistd.h>
#define HAVE_UNISTD 1
#endif
#if __has_include(<sys/epoll.h>)
#define HAVE_EPOLL 1 // Linux(Android 포함)
#elif __has_include(<sys/event.h>)
#define HAVE_KQUEUE 1 // macOS, FreeBSD
#endif
헤더는 있지만 함수가 없는 경우(특정 glibc 버전 이후 추가된 함수 등)는 CMake의 check_symbol_exists나 check_cxx_source_compiles로 빌드 설정 단계에서 검사하고, 결과를 target_compile_definitions로 넘깁니다. OS 매크로는 “Windows냐 아니냐”처럼 API 체계 자체가 갈리는 경계에만 쓰고, 그 안의 세부 기능은 기능 감지로 나누는 방식이 유지보수가 가장 쉽습니다.
모듈 경계는 C ABI로
// 플러그인·DLL 경계는 C ABI 사용
extern "C" {
EXPORT_API void plugin_init();
EXPORT_API void plugin_shutdown();
}
MinGW와 MSVC처럼 C++ 표준 라이브러리 구현이 다른 툴체인으로 빌드한 모듈을 섞을 때, 경계에서 std::string이나 예외가 오가면 크래시가 납니다. 경계를 C 함수와 POD 타입으로 제한하면 이 문제를 구조적으로 피할 수 있습니다. 라이브러리 ABI 버전 관리는 ABI 호환성 글에서 다룹니다.
크로스 플랫폼 코드 점검 목록
- 경로는
std::filesystem::path로 조합 - OS 매크로는 API 체계가 갈리는 경계에만, 세부 기능은
__has_include·CMake 검사로 -
windows.h·unistd.h는 플랫폼 구현.cpp안에서만 include - 동적 로딩 시 확장자(
.dll/.so/.dylib)·접두사 분기, 가능하면 절대 경로로 로드 - 내보낼 심볼에
dllexport/visibility("default")매크로 - 크기가 중요한 값은
int64_t,uint32_t등 고정 크기 타입 - 파일·네트워크 포맷은 필드 단위로 직렬화 (구조체 통째 쓰기 금지)
플랫폼별 차이 요약
| 항목 | 설명 |
|---|---|
| 플랫폼 감지 | _WIN32, __linux__, __APPLE__, __ANDROID__, TARGET_OS_IPHONE, 가능하면 기능 감지 |
| 경로 | std::filesystem::path로 크로스 플랫폼 |
| 동적 로딩 | LoadLibrary/dlopen 추상화, 확장자별 분기, RAII 핸들 |
| 추상화 레이어 | 공통 헤더 + 플랫폼별 구현 파일, CMake가 선택 |
| 에러 | unistd.h·dlopen·visibility·dllexport·long 크기·엔디안/정렬 |
빌드 쪽(툴체인, 패키징, CI, 릴리스 최적화)은 55-7 크로스 플랫폼 빌드로 이어집니다.
자주 묻는 질문 (FAQ)
Q. 같은 64비트인데 Windows와 Linux에서 long 크기가 다른 이유는 무엇인가요?
A. 64비트 Windows는 LLP64 모델을 써서 long이 4바이트이고, 64비트 Linux와 macOS는 LP64 모델이라 long이 8바이트입니다. 파일 크기나 바이너리 포맷의 필드를 long으로 선언하면 한쪽 플랫폼에서만 값이 잘리거나 구조체 레이아웃이 달라집니다. 크기가 중요한 값은 <cstdint>의 std::int64_t, std::uint32_t 같은 고정 크기 타입을 사용해야 합니다.
Q. MinGW와 MSVC 중 어떤 것을 써야 하나요?
A. Windows 전용이라면 MSVC가 편합니다. Linux에서 Windows 크로스 컴파일이 필요하면 MinGW를 사용합니다. MinGW와 MSVC로 빌드한 DLL을 섞어 쓰면 ABI 불일치로 크래시가 발생하므로, 플러그인 경계는 extern "C"로 통일합니다.
Q. 프로덕션에서 주의할 점은?
A. 코드 쪽에서는 (1) 플랫폼 의존 코드를 추상화 레이어 밖으로 새지 않게 하고, (2) 파일·네트워크 포맷을 필드 단위로 직렬화하며, (3) 플러그인·DLL 경계를 extern "C"로 만드는 것이 핵심입니다. CI 매트릭스와 배포는 55-7을 참고하세요.
같이 보면 좋은 글
- C++ 크로스 플랫폼 빌드: CMake 툴체인 파일, CPack 패키징, 릴리스 최적화
- C++ 크로스 플랫폼 테스트: CI 매트릭스, Docker 환경, 엔디안 검증
- C++ 라이브러리 ABI를 깨지 않는 법: PIMPL, extern “C” 인터페이스, 버전 관리
- CMake 입문 | 수십 개 파일 컴파일할 때 필요한 빌드 자동화 (CMakeLists.txt 기초)
- C++ 동적 로딩: dlopen·LoadLibrary·실전 패턴 [#55-2]