C++ 크로스 플랫폼 빌드: MinGW·iOS·Android NDK 툴체인 파일과 CPack 패키징
들어가며: “팀원마다 OS가 다른데 빌드가 매번 깨져요”
“Windows에서 빌드한 DLL을 Linux 서버에서 로드하려다 크래시했어요”
크로스 플랫폼 C++ 프로젝트에서는 플랫폼별 경로·라이브러리·컴파일러·ABI가 달라 빌드 실패, 런타임 크래시, 패키징 불일치가 빈번합니다. 비유하면 “각 나라마다 전압·플러그·규격이 다른 것”처럼, OS별로 빌드·실행·배포 환경이 다르기 때문에 툴체인·조건부 컴파일·ABI 안정화가 필수다.
문제의 핵심:
-
Windows:
\경로,LoadLibrary,ws2_32.dll, MSVC/MinGW ABI 불일치 -
Linux:
/경로,dlopen,pthread, GCC/Clang, 심볼 버전 관리 -
macOS:
dlopen,pthread, Clang, 코드 서명·Xcode 규칙 -
모바일: iOS/Android 각각 별도 툴체인·SDK·ABI 이 글에서 다루는 것:
-
문제 시나리오: 실무에서 겪는 크로스 플랫폼 빌드 고통 8가지
-
CMake 툴체인 파일: MinGW·iOS·Android NDK (플랫폼 감지 매크로 등 코드 쪽은 55-6)
-
CPack 패키징: DEB·RPM·NSIS·DMG·ZIP, 플랫폼별 설치 레이아웃과 런타임 검색 경로
-
ABI 안정성: PIMPL·extern C·심볼 버전 관리
-
테스트 매트릭스: CTest와 크로스 컴파일 에뮬레이터
-
릴리스 빌드 최적화: LTO, 디버그 심볼 분리,
-march=native의 함정, 크로스 컴파일 중 호스트 도구 빌드 -
빌드·링크·패키징 에러와 해결법
-
모범 사례와 프로덕션 패턴 요구 환경: C++17 이상, CMake 3.16+
크로스 플랫폼 빌드가 깨지는 순간
경로 구분자로 인한 파일 열기 실패
문제: Windows에서 path/to/file.txt 같은 경로로 파일을 열면 std::ifstream이 실패합니다. Windows는 \를 구분자로 쓰지만, Linux는 /를 씁니다. 하드코딩된 경로로는 한 플랫폼에서만 동작합니다.
해결: std::filesystem::path를 사용하면 OS가 자동으로 올바른 구분자를 사용합니다.
Windows에서만 “undefined reference to pthread”
문제: Linux에서 -lpthread로 링크한 코드가 Windows에서 빌드됩니다. Windows에는 pthread가 없으며, MSVC는 스레드를 기본 지원합니다. CMake find_package(Threads)로 플랫폼별 스레드 라이브러리를 자동 링크합니다.
동적 라이브러리 확장자 불일치
문제: 플러그인을 로드할 때 plugin.so로만 찾습니다. Windows에서는 plugin.dll, macOS에서는 plugin.dylib이 필요합니다.
해결: CMAKE_SHARED_LIBRARY_SUFFIX 또는 플랫폼별 매크로로 확장자를 분기합니다.
모바일 크로스 컴파일 실패
문제: 데스크톱용 CMake로 iOS·Android 앱을 빌드하려 합니다. arm64·armv7 아키텍처, iOS SDK 경로, Android NDK 경로가 설정되지 않아 빌드가 실패합니다.
해결: CMake 툴체인 파일로 iOS/Android 전용 컴파일러·SDK를 지정합니다.
MSVC로 빌드한 DLL을 MinGW 앱에서 로드 시 크래시
문제: Windows에서 MSVC로 빌드한 DLL을 MinGW로 빌드한 앱에서 로드합니다. C++ ABI·표준 라이브러리(libstdc++ vs MSVCRT)가 달라 런타임 크래시가 발생합니다.
해결: C ABI로 경계를 만들거나, 같은 툴체인으로 빌드합니다. 플러그인·DLL은 extern "C"로 내보냅니다.
패키징 시 의존성 누락
문제: CPack으로 DEB를 만들었는데, 설치 후 실행 시 “libfoo.so.1: cannot open shared object file” 에러가 납니다. 런타임 의존성이 패키지에 포함되지 않았습니다.
해결: CPACK_INSTALL_CMAKE_PROJECTS, CPACK_DEBIAN_PACKAGE_DEPENDS 등으로 의존성을 명시하며, install(RUNTIME_DEPENDENCY_SET)으로 동적 라이브러리를 수집합니다.
Linux에서 심볼 버전 충돌
문제: libstdc++를 업그레이드한 후, 구버전으로 빌드한 플러그인이 “version `GLIBCXX_3.4.30’ not found”로 로드 실패합니다. 해결: 플러그인에 심볼 버전 스크립트를 적용해 필요한 심볼만 내보내고, 배포 시 최소 glibc/libstdc++ 버전을 명시합니다.
CI에서 플랫폼별 빌드 매트릭스 실패
문제: 로컬 macOS에서는 빌드가 되는데, GitHub Actions의 windows-latest에서만 실패합니다. MSVC 경로·vcpkg triplet·코드 페이지 설정이 다릅니다.
해결: 툴체인 파일을 표준화하며, CI 스크립트에서 플랫폼별로 동일한 CMake 옵션을 사용합니다.
플랫폼 감지와 툴체인 흐름
소스 코드 쪽 감지와 빌드 쪽 감지
소스 코드에서 _WIN32, __APPLE__+TargetConditionals.h, __ANDROID__ 등으로 플랫폼을 구분하는 매크로와 __has_include 기반 기능 감지는 55-6 크로스 플랫폼 기초에서 다룹니다. 빌드 쪽에서는 CMake 변수로 같은 판단을 합니다. 둘 중 어느 쪽에서 분기할지 고를 때의 기준은 간단합니다. 어떤 파일을 컴파일하고 어떤 라이브러리를 링크할지는 CMake에서, 같은 파일 안에서 몇 줄이 달라지는 것은 소스 매크로에서 처리합니다. 크로스 컴파일에서는 CMAKE_SYSTEM_NAME이 타깃 OS, CMAKE_HOST_SYSTEM_NAME이 빌드 머신 OS라는 점도 구분해야 합니다. if(WIN32)는 타깃 기준이라 Linux에서 MinGW로 빌드할 때도 참입니다.
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 등
크로스 플랫폼 빌드 흐름
flowchart TB
subgraph source[소스 코드]
S1[공통 C++ 코드]
S2[플랫폼별 #ifdef]
S3[추상화 레이어]
end
subgraph cmake[CMake]
C1[CMakeLists.txt]
C2[툴체인 파일]
C3[플랫폼별 옵션]
end
subgraph targets[타겟 플랫폼]
T1[Windows .exe/.dll]
T2[Linux .so]
T3[macOS .dylib]
T4[iOS/Android]
end
S1 --> C1
S2 --> C1
S3 --> C1
C1 --> C2
C1 --> C3
C2 --> T1
C2 --> T2
C2 --> T3
C2 --> T4
CMake 툴체인 파일
프로젝트 구조
project/
├── toolchains/
│ ├── linux-x64.cmake
│ ├── mingw64.cmake
│ ├── ios.toolchain.cmake
│ └── android-arm64.cmake
├── CMakeLists.txt
└── src/
Linux x64 툴체인 (크로스 컴파일용)
# toolchains/linux-x64.cmake
# 사용: cmake -B build -DCMAKE_TOOLCHAIN_FILE=toolchains/linux-x64.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
# 크로스 컴파일러 지정 (선택)
# set(CMAKE_C_COMPILER /usr/bin/x86_64-linux-gnu-gcc)
# set(CMAKE_CXX_COMPILER /usr/bin/x86_64-linux-gnu-g++)
# sysroot (크로스 컴파일 시)
# set(CMAKE_SYSROOT /path/to/sysroot)
# 빌드 머신 도구는 호스트 것 사용
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
MinGW64 툴체인 (Linux/macOS에서 Windows 빌드)
# toolchains/mingw64.cmake
# 사용: cmake -B build -DCMAKE_TOOLCHAIN_FILE=toolchains/mingw64.cmake
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR x86_64)
# MinGW 경로 (환경에 맞게 수정)
set(MINGW_PREFIX /usr/local/x86_64-w64-mingw32
CACHE PATH "MinGW-w64 installation prefix")
set(CMAKE_C_COMPILER ${MINGW_PREFIX}/bin/x86_64-w64-mingw32-gcc)
set(CMAKE_CXX_COMPILER ${MINGW_PREFIX}/bin/x86_64-w64-mingw32-g++)
set(CMAKE_RC_COMPILER ${MINGW_PREFIX}/bin/x86_64-w64-mingw32-windres)
set(CMAKE_FIND_ROOT_PATH ${MINGW_PREFIX})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
# 정적 링크 옵션 (선택)
# set(CMAKE_EXE_LINKER_FLAGS "-static-libgcc -static-libstdc++")
iOS 툴체인
# toolchains/ios.toolchain.cmake
# 사용: cmake -B build-ios -DCMAKE_TOOLCHAIN_FILE=toolchains/ios.toolchain.cmake
set(CMAKE_SYSTEM_NAME iOS)
set(CMAKE_OSX_SYSROOT iphoneos CACHE STRING "iOS SDK")
set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "iOS architectures")
set(CMAKE_OSX_DEPLOYMENT_TARGET "14.0" CACHE STRING "Minimum iOS version")
# 코드 서명 (개발 빌드)
set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "" CACHE STRING "")
set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "NO" CACHE STRING "")
set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "NO" CACHE STRING "")
# 빌드 타입
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "")
endif()
Android NDK 툴체인
Android는 툴체인 파일을 직접 쓰지 않고 NDK에 들어 있는 android.toolchain.cmake를 그대로 씁니다. 프로젝트가 정하는 것은 그 툴체인에 넘기는 캐시 변수뿐입니다. 이 값을 별도 .cmake 파일에서 set()으로 지정하면 툴체인이 먼저 읽힌 뒤라 반영되지 않을 수 있으므로, 명령행이나 CMakePresets.json에 둡니다.
{
"version": 3,
"configurePresets": [
{
"name": "android-arm64",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/android-arm64",
"toolchainFile": "$env{ANDROID_NDK}/build/cmake/android.toolchain.cmake",
"cacheVariables": {
"ANDROID_ABI": "arm64-v8a",
"ANDROID_PLATFORM": "android-24",
"ANDROID_STL": "c++_shared"
}
}
]
}
ANDROID_PLATFORM은 최소 지원 API 레벨입니다. 이 값보다 새 API 레벨에서 추가된 함수를 쓰면 링크 에러가 나거나, 약한 심볼로 링크된 경우 구형 기기에서 런타임에 크래시가 납니다. 앱의 minSdkVersion과 같은 값으로 맞춥니다. ANDROID_STL 선택 기준은 Android libc++_shared 에러 항목에 정리했습니다.
메인 CMakeLists.txt — 툴체인 통합
# CMakeLists.txt
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)
find_library(DL_LIBRARY dl)
set(PLATFORM_LIBS Threads::Threads ${DL_LIBRARY})
elseif(APPLE)
find_package(Threads REQUIRED)
set(PLATFORM_LIBS Threads::Threads)
endif()
add_executable(app src/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()
# 공유 라이브러리 (플러그인)
add_library(plugin SHARED src/plugin.cpp)
target_link_libraries(plugin PRIVATE ${PLATFORM_LIBS})
set_target_properties(plugin PROPERTIES
POSITION_INDEPENDENT_CODE ON
WINDOWS_EXPORT_ALL_SYMBOLS OFF
)
if(NOT WIN32)
target_compile_definitions(plugin PRIVATE PLUGIN_EXPORT=__attribute__\(\(visibility\(\"default\"\)\)\))
endif()
CPack 크로스 플랫폼 패키징
CPack 기본 설정
# CMakeLists.txt 하단에 추가
include(InstallRequiredSystemLibraries)
set(CPACK_PACKAGE_NAME "MyApp")
set(CPACK_PACKAGE_VERSION "1.0.0")
set(CPACK_PACKAGE_VENDOR "MyCompany")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "Cross-platform C++ Application")
set(CPACK_RESOURCE_FILE_README "${CMAKE_SOURCE_DIR}/README.md")
# 설치 규칙
install(TARGETS app RUNTIME DESTINATION bin)
install(TARGETS plugin LIBRARY DESTINATION lib RUNTIME DESTINATION bin)
# 플랫폼별 제너레이터
if(WIN32)
set(CPACK_GENERATOR "NSIS;ZIP")
set(CPACK_NSIS_MODIFY_PATH ON)
set(CPACK_NSIS_INSTALLER_MUI_ICON "${CMAKE_SOURCE_DIR}/icon.ico")
elseif(APPLE)
set(CPACK_GENERATOR "DragNDrop;TGZ")
set(CPACK_DMG_DS_STORE_SET_OWNER "${CMAKE_INSTALL_PREFIX}")
else()
set(CPACK_GENERATOR "DEB;RPM;TGZ")
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "[email protected]")
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libc6 (>= 2.27)")
set(CPACK_RPM_PACKAGE_LICENSE "MIT")
endif()
include(CPack)
DEB 패키지 상세 설정
# DEB 전용 옵션
set(CPACK_DEBIAN_PACKAGE_NAME "myapp")
set(CPACK_DEBIAN_PACKAGE_VERSION "1.0.0")
set(CPACK_DEBIAN_PACKAGE_ARCHITECTURE "amd64")
set(CPACK_DEBIAN_PACKAGE_DEPENDS "libc6 (>= 2.27), libstdc++6 (>= 9)")
set(CPACK_DEBIAN_PACKAGE_SECTION "utils")
set(CPACK_DEBIAN_PACKAGE_PRIORITY "optional")
NSIS (Windows 인스톨러) 설정
set(CPACK_NSIS_PACKAGE_NAME "MyApp")
set(CPACK_NSIS_DISPLAY_NAME "My Application")
set(CPACK_NSIS_HELP_LINK "https://example.com/support")
set(CPACK_NSIS_URL_INFO_ABOUT "https://example.com")
set(CPACK_NSIS_CONTACT "[email protected]")
set(CPACK_NSIS_MODIFY_PATH ON)
런타임 의존성 수집 (CMake 3.21+)
# 동적 라이브러리 의존성 자동 수집
include(GNUInstallDirs)
install(TARGETS app plugin
RUNTIME
COMPONENT runtime
DESTINATION ${CMAKE_INSTALL_BINDIR}
LIBRARY
COMPONENT runtime
DESTINATION ${CMAKE_INSTALL_LIBDIR}
)
install(RUNTIME_DEPENDENCY_SET app_deps
TARGETS app
PRE_INCLUDE_REGEXES ".*"
PRE_EXCLUDE_REGEXES ".*system.*"
POST_INCLUDE_REGEXES ".*"
POST_EXCLUDE_REGEXES ""
DIRECTORIES ${CMAKE_SOURCE_DIR}/lib
)
CPack 빌드 명령
# 빌드 후 패키징
cmake -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build
cd build && cpack -G DEB # 또는 NSIS, ZIP, TGZ 등
플랫폼별 설치 레이아웃과 런타임 검색 경로
패키지를 만들 때 가장 자주 틀리는 부분은 “설치된 실행 파일이 자기 라이브러리를 어떻게 찾는가”입니다. 세 OS의 동적 로더는 규칙이 다릅니다.
| OS | 공유 라이브러리를 둘 곳 | 로더가 찾는 방식 |
|---|---|---|
| Windows | 실행 파일과 같은 bin/ | 실행 파일 디렉터리를 먼저 검색. RPATH 개념이 없음 |
| Linux | lib/ (배포판에 따라 lib64/) | 바이너리에 박힌 RUNPATH, LD_LIBRARY_PATH, 시스템 캐시(ldconfig) |
| macOS | lib/ 또는 번들의 Frameworks/ | 라이브러리의 install name(@rpath/...)과 실행 파일의 LC_RPATH |
Windows에서 DLL을 bin/에 두는 이유가 여기 있습니다. CMake에서 DLL은 RUNTIME 산출물로 분류되고, .so/.dylib은 LIBRARY 산출물입니다. 그래서 install(TARGETS)에 두 목적지를 모두 적어 두면 플랫폼 분기 없이 각자 맞는 자리로 들어갑니다.
include(GNUInstallDirs) # bin, lib/lib64, include 같은 표준 이름을 플랫폼에 맞게 채움
install(TARGETS app mylib
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} # .exe, Windows .dll
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} # .so, .dylib
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} # .a, .lib (정적/임포트 라이브러리)
)
# Linux/macOS: 설치 후 실행 파일이 ../lib 를 상대 경로로 찾도록 RPATH 지정
if(APPLE)
set_target_properties(app PROPERTIES INSTALL_RPATH "@loader_path/../${CMAKE_INSTALL_LIBDIR}")
elseif(UNIX)
set_target_properties(app PROPERTIES INSTALL_RPATH "$ORIGIN/../${CMAKE_INSTALL_LIBDIR}")
endif()
$ORIGIN(Linux)과 @loader_path(macOS)는 “이 바이너리가 있는 디렉터리”를 뜻합니다. 이렇게 상대 RPATH를 넣으면 사용자가 패키지를 어느 경로에 풀든 LD_LIBRARY_PATH 없이 실행됩니다. 절대 경로 RPATH(빌드 머신의 /home/ci/build/lib 같은 값)가 설치본에 남아 있으면 다른 머신에서는 라이브러리를 찾지 못하고, 빌드 머신에서는 우연히 잘 돌아가서 문제를 늦게 발견하게 됩니다. 설치 결과는 Linux에서 readelf -d app | grep -i path, macOS에서 otool -l app | grep -A2 LC_RPATH로 확인할 수 있습니다.
설치 접두사(CMAKE_INSTALL_PREFIX)는 CMakeLists.txt 안에서 set(... "/usr/local")처럼 고정하지 않는 편이 좋습니다. 패키지 관리자나 사용자가 정하는 값이기 때문입니다. 설치할 때 cmake --install build --prefix /opt/myapp처럼 지정하면 되고, CPack은 제너레이터마다 알맞은 접두사를 알아서 씁니다.
ABI 안정성: PIMPL·extern C·심볼 버전
PIMPL로 ABI 고정
PIMPL(Pointer to Implementation)은 공개 클래스가 구현체 포인터만 갖고, 구현체 정의는 .cpp에만 두어 공개 클래스 크기를 고정합니다. 내부 구현 변경 시에도 ABI가 유지됩니다.
// widget.h — 공개 헤더 (ABI 고정)
#pragma once
#include <memory>
#include <string>
class WidgetImpl;
class Widget {
public:
Widget();
~Widget();
Widget(const Widget&);
Widget& operator=(const Widget&);
Widget(Widget&&) noexcept = default;
Widget& operator=(Widget&&) noexcept = default;
void setTitle(const std::string& title);
[[nodiscard]] std::string getTitle() const;
private:
std::unique_ptr<WidgetImpl> pImpl_; // 크기 고정: 8바이트
};
// widget.cpp — 구현 (내부 변경 가능)
#include "widget.h"
#include <unordered_map>
class WidgetImpl {
public:
std::string title;
std::unordered_map<std::string, size_t> cache; // v2에서 추가해도 ABI 유지
};
Widget::Widget() : pImpl_(std::make_unique<WidgetImpl>()) {}
Widget::~Widget() = default;
Widget::Widget(const Widget& other)
: pImpl_(std::make_unique<WidgetImpl>(*other.pImpl_)) {}
Widget& Widget::operator=(const Widget& other) {
if (this != &other) *pImpl_ = *other.pImpl_;
return *this;
}
void Widget::setTitle(const std::string& title) { pImpl_->title = title; }
std::string Widget::getTitle() const { return pImpl_->title; }
extern “C” 플러그인 API
C++ name mangling은 컴파일러마다 다릅니다. extern “C”로 내보내면 심볼 이름이 고정되어, 다른 컴파일러로 빌드한 바이너리와도 링크할 수 있습니다.
// plugin_api.h — C 인터페이스 (ABI 최대 안정)
#pragma once
#include <cstdint>
#include <cstddef>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct PluginHandle* PluginHandlePtr;
PluginHandlePtr plugin_create(const char* config);
void plugin_destroy(PluginHandlePtr handle);
int plugin_process(PluginHandlePtr handle, const void* input, size_t input_size,
void* output, size_t output_size);
#ifdef __cplusplus
}
#endif
// plugin_impl.cpp — C++ 래퍼가 C API 구현
#include "plugin_api.h"
#include <string>
#include <cstring>
struct PluginImpl {
std::string config;
};
extern "C" {
#if defined(_WIN32)
#define PLUGIN_EXPORT __declspec(dllexport)
#else
#define PLUGIN_EXPORT __attribute__((visibility("default")))
#endif
PLUGIN_EXPORT PluginHandlePtr plugin_create(const char* config) {
auto* p = new PluginImpl;
if (config) p->config = config;
return p;
}
PLUGIN_EXPORT void plugin_destroy(PluginHandlePtr handle) {
delete static_cast<PluginImpl*>(handle);
}
PLUGIN_EXPORT int plugin_process(PluginHandlePtr handle,
const void* input, size_t input_size, void* output, size_t output_size) {
(void)handle;
(void)input;
(void)input_size;
(void)output;
(void)output_size;
return 0;
}
}
Linux 심볼 버전 관리
플러그인·라이브러리에서 필요한 심볼만 내보내고, 버전을 부여해 ABI 호환성을 관리합니다.
# plugin.ver — 심볼 버전 스크립트
PLUGIN_1.0 {
global:
plugin_create;
plugin_destroy;
plugin_process;
local: *;
};
PLUGIN_1.1 {
global:
plugin_init;
} PLUGIN_1.0;
# CMakeLists.txt — Linux에서 심볼 버전 적용
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_link_options(plugin PRIVATE
"LINKER:--version-script=${CMAKE_CURRENT_SOURCE_DIR}/plugin.ver"
)
endif()
# 직접 링크 시
g++ -shared -Wl,--version-script=plugin.ver -o libplugin.so plugin.cpp
ABI 안정성 비교
| 기법 | ABI 안정성 | 사용 편의성 | 적용 시점 |
|---|---|---|---|
| PIMPL | 높음 | 중간 | 공개 C++ 클래스 |
| extern “C” | 최고 | 낮음 | 플러그인·DLL 경계 |
| 심볼 버전 | 중간 | 낮음 | Linux .so 버전 관리 |
자주 발생하는 빌드·링크·패키징 에러
소스 코드를 고쳐야 하는 에러, 즉 Windows의 unistd.h 누락, macOS dlsym의 “symbol not found”(visibility), Windows LNK2019(dllexport/dllimport 누락), long 크기 차이, 엔디안·구조체 정렬은 55-6 크로스 플랫폼 기초의 에러 절에서 다룹니다. 여기서는 빌드 설정에서 생기는 에러만 봅니다.
”undefined reference to dlopen” (Linux)
원인: glibc 2.34 이전에는 dlopen이 libdl에 있어서 따로 링크해야 했습니다.
해결: dl을 하드코딩하지 말고 CMake가 플랫폼별로 채워 주는 변수를 씁니다(필요 없는 플랫폼에서는 빈 값).
target_link_libraries(app PRIVATE ${CMAKE_DL_LIBS})
”DLL not found” 또는 “dyld: Library not loaded” (런타임)
원인: 로더가 동적 라이브러리를 찾지 못합니다. 빌드 트리에서는 CMake가 RPATH를 빌드 디렉터리로 넣어 주기 때문에 잘 실행되다가, 설치하거나 패키지로 배포한 뒤에만 실패하는 경우가 대부분입니다.
해결: LD_LIBRARY_PATH·DYLD_LIBRARY_PATH는 개발 중 임시방편으로만 쓰고(macOS에서는 SIP 때문에 시스템 바이너리를 거쳐 실행하면 DYLD_* 변수가 지워지기도 합니다), 설치 레이아웃에 맞는 RPATH($ORIGIN/../lib, @loader_path/../lib)를 설정합니다. Windows는 RPATH가 없으므로 DLL을 실행 파일과 같은 디렉터리에 설치합니다. 구체적인 설정은 플랫폼별 설치 레이아웃에 있습니다.
MinGW에서 “undefined reference to WinMain”
원인: 콘솔 앱인데 add_executable(app WIN32 ...)로 GUI 서브시스템(-mwindows)으로 빌드했습니다. GUI 서브시스템의 진입점은 main이 아니라 WinMain입니다.
해결:
add_executable(app main.cpp) # 콘솔 앱
# add_executable(app WIN32 main.cpp) # GUI 앱만 WIN32 사용
“version `GLIBCXX_3.4.30’ not found”
원인: 빌드 머신의 libstdc++가 실행 환경보다 새 버전이라, 바이너리가 실행 환경에 없는 심볼 버전을 요구합니다.
해결: 지원할 가장 오래된 배포판과 같은 환경(Docker 이미지 등)에서 빌드하거나, -static-libstdc++로 정적 링크합니다. glibc는 정적 링크가 사실상 어려우므로 “가장 오래된 환경에서 빌드”가 가장 확실한 방법입니다. 배포 문서에 최소 glibc/libstdc++ 버전을 명시합니다.
CPack DEB 빌드 시 “dpkg-shlibdeps: error”
원인: dpkg-shlibdeps가 패키지에 들어간 라이브러리의 의존성을 분석하지 못합니다. 대개 패키지 안의 자체 .so를 시스템 패키지에서 찾으려다 실패하는 경우입니다.
해결:
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
# 자체 라이브러리가 있는 디렉터리를 검색 경로에 추가
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS_PRIVATE_DIRS "${CMAKE_BINARY_DIR}/lib")
SHLIBDEPS를 OFF로 끄면 에러는 사라지지만 Depends 필드가 비어 설치 후 실행 시 라이브러리 누락으로 이어지므로 권하지 않습니다.
macOS·iOS 코드 서명 오류
원인: iOS 앱과 배포용 macOS 앱은 서명이 필요한데, 개발 중 CI에는 인증서가 없습니다. 해결: CI의 빌드 확인 단계에서는 서명을 끄고, 서명은 배포 잡에서만 합니다.
set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGNING_REQUIRED "NO")
set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "NO")
서명을 끈 iOS 빌드는 실기기에 설치할 수 없으므로 “컴파일이 되는지” 확인하는 용도로만 씁니다. Apple Silicon Mac에서는 서명이 전혀 없는 arm64 바이너리가 실행되지 않습니다. 링커가 기본으로 ad-hoc 서명을 넣어 주지만, 빌드 후 install_name_tool 등으로 바이너리를 수정하면 서명이 깨지므로 codesign -s - <파일>로 다시 걸어야 합니다.
Android “cannot find -lc++_shared” 또는 런타임 “libc++_shared.so not found”
원인: NDK 툴체인을 거치지 않고 링크 플래그를 직접 넣었거나, ANDROID_STL이 모듈마다 다릅니다.
해결: ANDROID_STL은 NDK 툴체인에 넘기는 캐시 변수로 한 곳에서 지정합니다. 기본값은 c++_static인데, 앱 안에 C++ .so가 여러 개면 c++_shared로 통일하는 것이 권장됩니다. 여러 .so가 각자 c++_static을 가지면 C++ 런타임이 모듈마다 복제되어 모듈 경계를 넘는 예외나 전역 상태가 오동작할 수 있습니다. c++_shared를 쓰면 libc++_shared.so를 APK에 함께 넣어야 합니다(Gradle의 CMake 통합을 쓰면 자동으로 들어갑니다).
CI 매트릭스·툴체인 분리·CTest 운영
코드 수준의 원칙(플랫폼별 코드 분리, std::filesystem, 고정 크기 정수, 기능 감지)은 55-6의 코드 원칙을 참고하세요. 빌드 쪽 원칙은 아래와 같습니다.
CI에서 멀티 플랫폼 빌드
# .github/workflows/build.yml
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: aminya/setup-cpp@v1
with:
compiler: ${{ matrix.os == 'windows-latest' && 'msvc' || 'gcc' }}
- name: Configure
run: cmake -B build -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build
- name: Package
run: cd build && cpack -G ${{ matrix.os == 'ubuntu-latest' && 'DEB' || (matrix.os == 'windows-latest' && 'NSIS' || 'TGZ') }}
툴체인 파일 분리
project/
├── toolchains/
│ ├── linux-x64.cmake
│ ├── mingw64.cmake
│ ├── ios.toolchain.cmake
│ └── android-arm64.cmake
├── CMakeLists.txt
└── src/
ABI 경계에서 C 타입 사용
// ❌ API 경계에 std::string
extern "C" void process(std::string input);
// ✅ C 타입 사용
extern "C" void process(const char* input, size_t len);
CTest로 플랫폼별 테스트 실행
매트릭스 빌드가 통과했다는 것은 “컴파일이 된다”는 뜻일 뿐입니다. 경로 구분자, long 크기, 엔디안처럼 크로스 플랫폼에서 문제가 되는 버그는 대부분 컴파일은 되고 실행 결과만 다릅니다. 그래서 매트릭스의 각 OS에서 같은 테스트를 실제로 실행하는 단계가 필요합니다.
enable_testing()
add_executable(platform_tests tests/platform_tests.cpp)
target_link_libraries(platform_tests PRIVATE platform)
add_test(NAME platform_tests COMMAND platform_tests)
- name: Test
run: ctest --test-dir build -C Release --output-on-failure
-C Release는 Visual Studio나 Xcode처럼 구성(Debug/Release)을 빌드 시점에 고르는 멀티 구성 제너레이터에서 필요합니다. 단일 구성 제너레이터(Makefile, Ninja)에서는 무시되니 매트릭스 전체에 똑같이 넣어 두면 됩니다.
크로스 컴파일한 바이너리는 빌드 머신에서 바로 실행할 수 없습니다. 이때는 툴체인 파일에 CMAKE_CROSSCOMPILING_EMULATOR를 지정하면 CTest가 테스트를 에뮬레이터를 통해 실행합니다. MinGW로 만든 Windows 바이너리는 wine으로, ARM64 Linux 바이너리는 qemu-aarch64로 돌리는 식입니다.
# toolchains/mingw64.cmake 에 추가
set(CMAKE_CROSSCOMPILING_EMULATOR wine)
# toolchains/linux-aarch64.cmake 라면
# set(CMAKE_CROSSCOMPILING_EMULATOR qemu-aarch64;-L;/usr/aarch64-linux-gnu)
에뮬레이터는 실제 기기와 완전히 같지는 않아서(예: wine은 Windows의 파일 잠금·경로 대소문자 규칙을 완벽히 재현하지 않습니다), 에뮬레이터 테스트는 빠른 1차 확인으로 쓰고 최종 확인은 해당 OS의 러너에서 하는 편이 안전합니다. Docker 기반 환경과 엔디안 검증 테스트는 55-8 크로스 플랫폼 테스트에서 자세히 다룹니다.
통합 빌드 스크립트와 플랫폼별 플래그
통합 빌드 스크립트
#!/bin/bash
# build-all.sh — 모든 플랫폼 빌드
set -e
BUILD_DIR=build
for p in linux windows macos; do
echo "Building for $p..."
case $p in
linux)
cmake -B $BUILD_DIR/linux -DCMAKE_BUILD_TYPE=Release
cmake --build $BUILD_DIR/linux
;;
windows)
cmake -B $BUILD_DIR/windows -DCMAKE_TOOLCHAIN_FILE=toolchains/mingw64.cmake
cmake --build $BUILD_DIR/windows
;;
macos)
cmake -B $BUILD_DIR/macos -DCMAKE_BUILD_TYPE=Release
cmake --build $BUILD_DIR/macos
;;
esac
done
버전·플랫폼 정보 주입
if(EXISTS "${CMAKE_SOURCE_DIR}/.git")
execute_process(
COMMAND git rev-parse --short HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
)
else()
set(GIT_HASH "unknown")
endif()
target_compile_definitions(app PRIVATE
GIT_HASH="${GIT_HASH}"
BUILD_PLATFORM="${CMAKE_SYSTEM_NAME}"
)
환경 변수 기반 툴체인 선택
#!/bin/bash
TARGET=${1:-native}
case $TARGET in
native)
cmake -B build
;;
windows)
cmake -B build -DCMAKE_TOOLCHAIN_FILE=toolchains/mingw64.cmake
;;
android)
cmake -B build -DCMAKE_TOOLCHAIN_FILE=$ANDROID_NDK/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a
;;
*)
echo "Unknown target: $TARGET"
exit 1
;;
esac
cmake --build build
플랫폼별 최적화 플래그
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64")
target_compile_options(app PRIVATE -march=native)
elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64")
target_compile_options(app PRIVATE -mcpu=native)
endif()
elseif(MSVC)
target_compile_options(app PRIVATE /arch:AVX2)
endif()
이 플래그들은 배포 방식에 따라 조심해서 써야 합니다. 이유는 아래 -march=native의 함정에서 설명합니다.
빌드 검증 시퀀스
sequenceDiagram
participant Dev as 개발자
participant CMake as CMake
participant CC as 컴파일러
participant CI as CI
Dev->>CMake: cmake -B build
CMake->>CMake: 플랫폼 감지
CMake->>CMake: 툴체인 설정
CMake-->>Dev: Makefile/솔루션 생성
Dev->>CC: cmake --build build
CC-->>Dev: 바이너리
Dev->>CI: push
CI->>CMake: 매트릭스 빌드 (Win/Linux/macOS)
CMake->>CC: 각 플랫폼 빌드
CI-->>Dev: 빌드 결과
릴리스 빌드 최적화
LTO(링크 타임 최적화)
LTO는 링크 단계에서 번역 단위 경계를 넘어 인라이닝과 죽은 코드 제거를 합니다. 컴파일러마다 플래그가 다르고(GCC -flto, Clang -flto/-flto=thin, MSVC /GL + /LTCG) 링커 지원도 필요하므로, 플래그를 직접 쓰기보다 CMake의 INTERPROCEDURAL_OPTIMIZATION에 맡기는 편이 이식성이 좋습니다.
include(CheckIPOSupported)
check_ipo_supported(RESULT ipo_ok OUTPUT ipo_msg LANGUAGES CXX)
if(ipo_ok)
# 구성별 속성: Release에서만 켬 (멀티 구성 제너레이터에서도 동작)
set_property(TARGET app PROPERTY INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE)
else()
message(STATUS "LTO not supported: ${ipo_msg}")
endif()
if(CMAKE_BUILD_TYPE STREQUAL "Release")로 감싸는 방식은 Visual Studio·Xcode 같은 멀티 구성 제너레이터에서 동작하지 않습니다. 이 제너레이터들은 구성 단계에서 CMAKE_BUILD_TYPE이 비어 있기 때문입니다. INTERPROCEDURAL_OPTIMIZATION_<CONFIG> 속성을 쓰면 두 종류 제너레이터에서 모두 같게 동작합니다.
LTO를 켤 때 크로스 플랫폼 관점에서 주의할 점이 있습니다. LTO 객체 파일은 컴파일러 중간 표현(GCC GIMPLE, LLVM 비트코드)이라서, 같은 컴파일러·같은 버전으로 만든 파일끼리만 링크됩니다. 정적 라이브러리를 LTO로 빌드해 다른 팀이나 다른 툴체인에 배포하면 링크가 실패하거나 LTO가 조용히 꺼질 수 있습니다. 배포용 정적 라이브러리는 LTO 없이 만들고, 최종 실행 파일에서만 켜는 것이 무난합니다.
디버그 심볼: 배포본과 분리해서 보관
릴리스 바이너리에서 크래시 덤프를 해석하려면 심볼이 필요하지만, 심볼을 바이너리에 넣어 배포하면 용량이 커지고 내부 구조가 드러납니다. 세 플랫폼 모두 최적화된 바이너리는 배포하고 심볼은 따로 보관하는 방식을 지원합니다.
if(MSVC)
# /Zi: 별도 .pdb 파일에 디버그 정보, /DEBUG: 링커가 PDB 생성
# /OPT:REF,ICF: /DEBUG가 기본으로 끄는 링커 최적화를 다시 켬
target_compile_options(app PRIVATE $<$<CONFIG:Release>:/Zi>)
target_link_options(app PRIVATE $<$<CONFIG:Release>:/DEBUG /OPT:REF /OPT:ICF>)
else()
target_compile_options(app PRIVATE $<$<CONFIG:Release>:-g>)
endif()
# Linux: 심볼을 별도 파일로 떼고, 바이너리에는 연결 정보만 남김
objcopy --only-keep-debug app app.debug
objcopy --strip-debug --add-gnu-debuglink=app.debug app
# macOS: dSYM 번들로 추출한 뒤 strip
dsymutil app -o app.dSYM
strip -S app
Windows의 PDB는 원래 바이너리와 분리된 파일이라 따로 할 일이 적습니다. 대신 PDB와 바이너리는 빌드마다 고유 ID로 짝지어지므로, 배포한 바로 그 빌드의 PDB를 보관해야 합니다. 나중에 같은 소스로 다시 빌드한 PDB로는 덤프를 해석하지 못합니다. Linux의 .debug와 macOS의 .dSYM도 build-id/UUID로 짝을 맞추므로 원칙은 같습니다. CI에서 패키지를 만들 때 심볼 파일을 별도 아티팩트로 올려 두는 것이 가장 확실합니다. RelWithDebInfo 구성을 쓰면 CMake가 최적화와 -g//Zi를 함께 켜 주므로 위 설정 대신 쓸 수도 있습니다.
-march=native와 아키텍처 플래그의 함정
플랫폼별 최적화 플래그의 -march=native는 빌드하는 머신의 CPU가 지원하는 명령어를 모두 쓰라는 뜻입니다. 이 옵션에는 두 가지 함정이 있습니다.
- 배포: CI 러너의 CPU가 AVX-512를 지원하면 그 명령어가 바이너리에 들어가고, 사용자의 구형 CPU에서는 “Illegal instruction”으로 즉시 종료됩니다. 배포용 바이너리는
-march=x86-64-v2나-march=x86-64-v3처럼 지원 범위를 명시한 기준선을 쓰고, MSVC의/arch:AVX2도 모든 사용자 CPU가 AVX2를 지원한다고 확신할 때만 켭니다. - 크로스 컴파일: 크로스 컴파일에서
native는 호스트 CPU를 가리키므로 의미가 없거나 에러가 납니다. 타겟 CPU는 툴체인 파일에서-mcpu=cortex-a76처럼 명시합니다.
-march=native는 자기 머신에서만 돌리는 벤치마크나 사내 서버처럼 빌드 머신과 실행 머신이 같은 경우에만 쓰는 것이 안전합니다. 특정 명령어 세트의 이득이 크면, 기준선으로 빌드하고 __builtin_cpu_supports("avx2") 같은 런타임 검사로 빠른 경로를 고르는 방식이 배포에 적합합니다.
크로스 컴파일 중 호스트 도구 빌드
코드 생성기(프로토콜 컴파일러, 리소스 임베더 등)를 같은 프로젝트에서 빌드해 빌드 도중 실행하는 구조라면, 크로스 컴파일 때 문제가 생깁니다. 그 도구도 타겟용(예: ARM64 Android)으로 컴파일되어 빌드 머신에서 실행할 수 없기 때문입니다. 한 번의 CMake 구성은 한 가지 툴체인만 쓰므로, 호스트용 도구는 별도로 네이티브 빌드한 뒤 크로스 빌드에서 가져다 쓰는 방식이 일반적입니다.
if(CMAKE_CROSSCOMPILING)
# 1단계에서 네이티브로 빌드해 둔 도구를 찾아 씀
# (예: cmake -B build-host && cmake --build build-host --target codegen)
find_program(CODEGEN_EXE codegen HINTS ${HOST_TOOLS_DIR} REQUIRED) # REQUIRED는 CMake 3.18+
else()
add_executable(codegen tools/codegen.cpp)
set(CODEGEN_EXE $<TARGET_FILE:codegen>)
endif()
add_custom_command(
OUTPUT ${CMAKE_BINARY_DIR}/generated.cpp
COMMAND ${CODEGEN_EXE} ${CMAKE_SOURCE_DIR}/schema.txt ${CMAKE_BINARY_DIR}/generated.cpp
DEPENDS ${CMAKE_SOURCE_DIR}/schema.txt
)
크로스 빌드를 구성할 때 -DHOST_TOOLS_DIR=build-host를 넘기면 됩니다. ExternalProject_Add로 호스트 빌드를 자동화할 수도 있지만, 호스트 컴파일러 설정이 크로스 툴체인 변수에 오염되지 않도록 주의해야 해서 처음에는 두 단계로 나누는 쪽이 문제를 찾기 쉽습니다.
크로스 플랫폼 빌드 요약
| 항목 | 설명 |
|---|---|
| 플랫폼 감지 | _WIN32, __linux__, __APPLE__ 등 매크로 |
| 툴체인 | -DCMAKE_TOOLCHAIN_FILE로 크로스 컴파일 |
| CPack | DEB·RPM·NSIS·ZIP·TGZ 플랫폼별 패키징 |
| ABI 안정성 | PIMPL·extern C·심볼 버전 |
| 설치 | GNUInstallDirs, DLL은 bin/, 상대 RPATH($ORIGIN/@loader_path) |
| 릴리스 | INTERPROCEDURAL_OPTIMIZATION_RELEASE, 심볼 분리 보관, 배포용 -march 기준선 |
| 에러 | unistd.h·dlopen·DLL 경로·visibility |
핵심 원칙:
- 플랫폼별 코드 최소화: 추상화 레이어로 분리
- 표준 라이브러리 우선:
std::filesystem,std::thread - CMake 툴체인 활용: iOS·Android·MinGW 등
- ABI 경계: extern C 또는 PIMPL
- CI에서 멀티 플랫폼 빌드: 매트릭스 빌드로 검증
크로스 플랫폼 빌드 점검 목록
-
std::filesystem으로 경로 처리 - 플랫폼별 매크로로
#ifdef분기 -
target_link_libraries에 플랫폼별 라이브러리 추가 - 동적 로딩 시 확장자(
.dll/.so/.dylib) 분기 -
extern "C"로 ABI 안정성 확보 (플러그인·DLL) - iOS·Android 툴체인 파일 준비
- CPack으로 DEB·NSIS·TGZ 패키징
- CI에서 Windows·Linux·macOS 빌드 검증
- Linux 심볼 버전 스크립트 (선택)
자주 묻는 질문 (FAQ)
Q. 배포한 바이너리가 다른 Linux 서버에서 “GLIBCXX_3.4.30 not found”로 실행되지 않는 이유는 무엇인가요?
A. 빌드한 머신의 libstdc++가 대상 서버보다 새 버전이면, 바이너리가 대상 서버에는 없는 심볼 버전을 요구하게 되어 로드 단계에서 실패합니다. 지원해야 하는 가장 오래된 배포판과 같은 환경(Docker 이미지 등)에서 빌드하거나, 필요한 버전의 libstdc++를 함께 배포하거나 정적으로 링크하는 방법으로 해결합니다. 배포 문서에 최소 glibc와 libstdc++ 버전을 명시해 두면 같은 문제를 미리 막을 수 있습니다.
Q. 프로덕션에서 주의할 점은?
A. CI에서 모든 지원 플랫폼을 매트릭스로 빌드 검증하며, ABI 경계는 extern C 또는 PIMPL로 고정하며, CPack으로 일관된 패키징을 적용합니다.
Q. vcpkg·Conan과 크로스 플랫폼 빌드를 함께 쓰려면?
A. vcpkg는 --triplet x64-windows 등으로 타겟을 지정합니다. Conan은 -s os=Linux -s arch=x86_64처럼 프로필로 지정합니다. CMake 툴체인과 triplet·프로필이 일치해야 합니다.
참고 자료
- CMake 공식 문서
- CPack 문서
- Itanium C++ ABI
- C++ 라이브러리 ABI를 깨지 않는 법: PIMPL, extern “C” 인터페이스, 버전 관리 CMake 툴체인·CPack·ABI 안정성(PIMPL·extern C·심볼 버전)으로 Windows·Linux·macOS·모바일 크로스 플랫폼 빌드와 배포를 마스터할 수 있습니다.
같이 보면 좋은 글
- C++ 크로스 플랫폼 기초: 플랫폼 감지 매크로, std::filesystem 경로, 동적 로딩 추상화
- C++ 크로스 플랫폼 테스트: CI 매트릭스, Docker 환경, 엔디안 검증
- CMake 입문 | 수십 개 파일 컴파일할 때 필요한 빌드 자동화 (CMakeLists.txt 기초)
- C++ CMake Presets: 멀티 플랫폼·vcpkg·Conan·CI/CD 통합
- C++ 동적 로딩: dlopen·LoadLibrary·실전 패턴 [#55-2]