使用CMake跨平台的一些经验

Masimaro
2025-04-12 / 0 评论 / 4 阅读 / 正在检测是否收录...

使用CMake构建工程的一个原因是不希望Windows上有一个工程文件,Linux又单独配置一套工程文件。我们希望对于整个工程只配置一便,就可以直接编译运行。CMake本身也具备这样的特性,脚本只用写一次,在其他平台能自动构建工程。这里谈的跨平台主要是如何组织工程和编写CMake来针对Windows和Linux进行一些特殊的处理,在这里说说我常用的几种办法

介绍这些方法的前提是有一些代码只能在Windows上跑,例如调用Win32 API或者使用GDI/GDI+,而在Linux上也有一些Windows不支持的代码。我们就需要一些方法来隔离开这两套代码。假设现在有一个项目,它有一套共同的接口对外提供功能,而在Windows上和Linux上各有一份代码来实现这些接口。可以假设有一套图形相关的功能,对外采用统一的接口,具体实现时Windows上使用GDI+,而Linux上使用其他方案来实现。现在整个项目的目录结构如下

.
├── include
└── platform
    ├── linux
    └── windows

include 目录用来对外提供接口,是导出的头文件。platform隔离了Windows和Linux上的实现代码。

使用宏来控制

我们知道Windows和Linux平台有特定编译器定义的宏,根据这些宏是否定义我们可以知道当前是在Linux还是Windows上编译,是需要编译成32位或者64位程序,又或者编译成debug版本或者release版本。例如下面是我用的简单的判断版本的方式

#define MY_PLATFORM_WINDOWS        0
#define MY_PLATFORM_LINUX        1
#define MY_PLATFORM_APPLE        2
#define MY_PLATFORM_ANDROID        3
#define MY_PLATFORM_UNIX            4

#define MY_ARCH32 1
#define MY_ARCH64 2

#if defined(_WIN32) || defined(_WIN64)
#define MY_PLATFORM MY_PLATFORM_WINDOWS
#ifdef _WIN64
#define PLATFORM_NAME "Windows 64-bit"
#define MY_ARCH MY_ARCH64
#else
#define PLATFORM_NAME "Windows 32-bit"
#define MY_ARCH MY_ARCH32
#endif
#elif defined(__APPLE__)
#include "TargetConditionals.h"
#define MY_PLATFORM MY_PLATFORM_APPLE
#ifdef ARCHX64
#define MY_ARCH MO_ARCH64
#else
#define MY_ARCH MO_ARCH32
#endif
#if TARGET_IPHONE_SIMULATOR
#define PLATFORM_NAME "iOS Simulator"
#elif TARGET_OS_IPHONE
#define PLATFORM_NAME "iOS"
#elif TARGET_OS_MAC
#define PLATFORM_NAME "macOS"
#endif
#elif defined(__linux__)
#define MY_PLATFORM MY_PLATFORM_LINUX
#if defined (ARCHX64) || defined (__x86_64__)
#define MY_ARCH MY_ARCH64
#else
#define MY_ARCH MY_ARCH32
#endif
#define PLATFORM_NAME "Linux"
#elif defined(__unix__)
#ifdef ARCHX64
#define MY_ARCH MY_ARCH64
#else
#define MY_ARCH MY_ARCH32
#endif
#define PLATFORM_NAME "Unix"
#define MY_PLATFORM MY_PLATFORM_UNIX
#else
#error "Unknown platform"
#endif

上面代码根据一些常见的编译器宏来决定是什么版本,并且根据不同的版本将MY_PLATFORM 进行赋值。后面只需要使用 MY_PLATFORM 进行版本判断即可。同样的关于架构使用 MY_ARCH 来判断。
例如根据架构来定义不同的数据长度

#if (MY_PLATFORM == MY_PLATFORM_WINDOWS)
typedef __int64                MY_INT64;
typedef unsigned __int64        MY_UINT64;
#else
typedef long long               MY_INT64;
typedef unsigned long long      MY_UINT64;
#endif

#if (MY_ARCH == MY_ARCH64)
typedef MY_UINT64               MY_ULONG_PTR;
typedef MY_INT64                MY_INT_PTR;
#else
typedef MY_UINT                 MY_ULONG_PTR;
typedef MY_INT                  MY_INT_PTR;
#endif

定义的常见的数据结构之后,对于一些接口的视线就可以利用宏来隔开

// platform/windows/image.c
#if (MY_PLATFORM == MY_PLATFORM_WINDOWS)
// todo something
#endif
// platform/linux/image.c
#if (MY_PLATFORM == MY_PLATFORM_LINUX)
// todo something
#endif

这样我们可以利用上一节介绍过的 cmakefile 或者 aux_source_directory 将整个platform目录都包含到工程里面。

使用cmake来判断版本

除了在C/C++ 源码中利用编译器特定的宏来判断版本,其实CMake自身也有一些方式来判断编译的版本。

CMake 检测操作系统使用 CMAKE_SYSTEM_NAME 来判断。这里要提一句,CMake中的变量本质上都是一个字符串值,没有严格的区分类型,所以 set(variable 1)set(variable "1") 在底层存储都是字符串 1。所以cmake在定义变量的时候可以不使用双引号,但是对于特殊的字符串,例如带有空格的字符串,为了避免语法上的歧义,可以带上双引号。

虽然说底层存储的都是字符串,但是在上层判断变量是否相等的时候却又区分数字和字符串。判断变量是否等于某个值可以使用 EQUAL 或者 STREQUALEQUAL 是用来判断数字类型是否相等,一般判断版本号或者数字参数。而 STREQUAL 来判断字符串是否相等,一般用来判断配置选项、路径、平台标识符。例如这里的 CMAKE_SYSTEM_NAME 就需要采用 STREQUAL 来判断

# 检测平台
set(PLATFORM_WINDOWS 1)
set(PLATFORM_LINUX 2)
set(PLATFORM_MACOS 3)
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
    set(PLATFORM ${PLATFORM_WINDOWS})
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
    set(PLATFORM ${PLATFORM_LINUX})
elseif(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
    set(PLATFORM ${PLATFORM_MACOS})
endif()

判断架构可以使用 CMAKE_SIZEOF_VOID_P。顾名思义,它表示一个void* 指针变量的大小,8位就是64位,4位则是32位架构。

if(CMAKE_SIZEOF_VOID_P EQUAL 8)
    set(PLATFORM_ARCH "x64")
elseif(CMAKE_SIZEOF_VOID_P EQUAL 4)
    set(PLATFORM_ARCH "x86")
else()
    message(FATAL_ERROR "Unsupported architecture pointer size: ${CMAKE_SIZEOF_VOID_P}")
endif()

至于判断当前编译的版本是debug 还是 release 可以使用 CMAKE_BUILD_TYPE 来判断,它的值主要有4个,分别是 DebugRelWithDebInfoMinSizeRelRelease。它们四个各有特色。其中 RelWithDebInfo 是带有符号表的发布版,便于调试,它的优化级别最低。MinSizeRelRelease在优化上各有千秋,前者追求最小体积,后者追求最快的速度,所以后者有时候会为了运行速度添加一些额外的内容导致体积变大。

我们可以在cmake文件中判断对应的值以便做出一些额外的设置。例如

if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    add_compile_definitions(-D_DEBUG)
else()
    add_compile_definitions(-DNDEBUG)
endif()

有了这些基础,我们可以在不同的条件下,定义不同的编译宏,然后根据编译宏的不同在C/C++ 源码中判断这些宏从而隔离不同平台的实现代码

通过cmake list 来隔离不同平台的代码

使用 file 或者 aux_source_directory 的到的是一个源代码文件的列表。我们可以操作这个列表来达到控制编译文件的需求。

cmake 中针对列表的操作符是 list,它的定义如下:

Reading
  list(LENGTH <list> <out-var>)
  list(GET <list> <element index> [<index> ...] <out-var>)
  list(JOIN <list> <glue> <out-var>)
  list(SUBLIST <list> <begin> <length> <out-var>)

Search
  list(FIND <list> <value> <out-var>)

Modification
  list(APPEND <list> [<element>...])
  list(FILTER <list> {INCLUDE | EXCLUDE} REGEX <regex>)
  list(INSERT <list> <index> [<element>...])
  list(POP_BACK <list> [<out-var>...])
  list(POP_FRONT <list> [<out-var>...])
  list(PREPEND <list> [<element>...])
  list(REMOVE_ITEM <list> <value>...)
  list(REMOVE_AT <list> <index>...)
  list(REMOVE_DUPLICATES <list>)
  list(TRANSFORM <list> <ACTION> [...])

Ordering
  list(REVERSE <list>)
  list(SORT <list> [...])

官方提供了这么一些操作list的操作符,但是在这个需求中我们只需要两个操作符APPENDREMOVE_ITEM 即可。后面的参数分别是源列表,以及需要增加或者删除的项,它们都可以传入多项。但是删除时它是根据传入字符串,从列表中进行字符串比较,如果相等则进行删除。所以在传入路径的时候需要特别注意,不能一个传入全路径一个传入相对路径或者一个传入 / 开头的路径,另一个传入 ~ 开头的路径。

上述两个操作都可以传入多个单个的字符串也可以传入一个列表。

如果我们有下列目录结构

src/platform/windows
src/platform/linux
src/*.cpp
src/other/*.cpp

也就说我们将不同平台的代码放入到src目录,并且src目录也有其他代码,我们如果使用 file 操作符来查找src目录中的源码文件必定会包含两个平台的实现代码。这个时候就可以考虑使用REMOVE_ITEM 根据平台来舍弃一些代码,例如

file(GLOB_RECURSE
    SOURCES
    ${PROJECT_SOURCE_DIR}/src/*.c
)
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
file(GLOB_RECURSE
    NOT_INCLUDE
    ${PROJECT_SOURCE_DIR}/src/platform/linux/*.cpp
)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
file(GLOB_RECURSE
    NOT_INCLUDE
    ${PROJECT_SOURCE_DIR}/src/platform/windows/*.cpp
)
endif()

list(
    REMOVE_ITEM SOURCES
    SOURCE
    ${NOT_INCLUDE}
)

又或者我们采用最上面的给出的目录结构,也就是说platform 目录位于src目录之外,相对于src来说是额外添加的代码文件,那么就可以使用 APPEND 来进行添加

if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
    aux_source_directory(${PROJECT_SOURCE_DIR}/platform/windows PLATFORM_SOURCE)
elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux")
    aux_source_directory(${PROJECT_SOURCE_DIR}/platform/linux PLATFORM_SOURCE)
endif()

list(APPEND SOURCES ${PLATFORM_SOURCE})

通过 toolchain 文件来组织平台特殊配置

cmake 允许我们在生成Makefile的时候指定toolchain 文件来实现一些自定义的配置。例如可以根据平台的不同将生成路径指定在对应的toolchain中。

toolchain 的语法与cmake语法相同,例如针对Windows和Linux可以创建 win32_toolchain.cmake win64_toolchain.cmake linux_86_toolchain.cmakelinux_x64_toolchain.cmake 文件来区别
我还是以上一篇文章中多工程嵌套的例子作为示例来演示如何使用,它的目录结构如下

.
├── calc
│   ├── add.cpp
│   ├── CMakeLists.txt
│   ├── div.cpp
│   ├── mult.cpp
│   └── sub.cpp
├── CMakeLists.txt
├── include
│   ├── calc.h
│   └── sort.h
├── sort
│   ├── CMakeLists.txt
│   ├── insert_sort.cpp
│   └── select_sort.cpp
├── test_calc
│   ├── CMakeLists.txt
│   └── main.cpp
└── test_sort
    ├── CMakeLists.txt
    └── main.cpp

这个例子我们只需要改动根目录下的生成库和可执行程序的路径。

cmake_minimum_required(VERSION 3.15)
project(test)

add_subdirectory(sort)
add_subdirectory(calc)
add_subdirectory(test_calc)
add_subdirectory(test_sort)

这个文件只需要保留最基础的配置即可,而生成程序的路劲都在 toolchain 中。
下面是 linux_x86_toolchain.cmake 文件的内容

set(LIBPATH ${PROJECT_SOURCE_DIR}/lib/linux/x86)
set(EXECPATH ${PROJECT_SOURCE_DIR}/bin/linux/x86)
set(HEADPATH ${PROJECT_SOURCE_DIR}/include)


if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    set(CALCLIB calc_d)
    set(SORTLIB sort_d)
    set(CALCAPP test_calc_d)
    set(SORTAPP test_sort_d)
else()
    set(CALCLIB calc)
    set(SORTLIB sort)

    set(CALCAPP test_calc)
    set(SORTAPP test_sort)
endif()

set(CMAKE_SYSTEM_PROCESSOR i686)
set(CMAKE_C_FLAGS "-m32 -L/usr/lib32" CACHE STRING "" FORCE)
set(CMAKE_CXX_FLAGS "-m32 -L/usr/lib32" CACHE STRING "" FORCE)
set(CMAKE_EXE_LINKER_FLAGS "-m32 -L/usr/lib32" CACHE STRING "" FORCE)

这个文件我们定义了debug和release的库名称和生成的路径,并且指定相关参数用于生成32位程序。

CMAKE 中定义了一堆 CMAKE_LANGUAGE_FLAGS 这些都是相关工具的参数,这里的FLAGS 分别是 gcc 和 ld 编译链接的参数。

在使用时直接用命令 cmake .. -DCMAKE_TOOLCHAIN_FILE=../linux_x32_toolchain.cmake -DCMAKE_BUILD_TYPE=Debug 生成Makefile。

Windows平台上上面的参数稍微有些差距。例如下面是 windows_x32_toolchain.cmake 的定义

# 静态库生成路径
set(LIBPATH ${PROJECT_SOURCE_DIR}/lib/windows/x86)
# 可执行程序的存储目录
set(EXECPATH ${PROJECT_SOURCE_DIR}/bin/windows/x86)
# 头文件路径
set(HEADPATH ${PROJECT_SOURCE_DIR}/include)

if(CMAKE_BUILD_TYPE STREQUAL "Debug")
    # calc库名称
    set(CALCLIB calc_d)
    # sort 库名称
    set(SORTLIB sort_d)

    # 测试程序的名字
    set(CALCAPP test_calc_d)
    set(SORTAPP test_sort_d)
else()
    # calc库名称
    set(CALCLIB calc)
    # sort 库名称
    set(SORTLIB sort)

    # 测试程序的名字
    set(CALCAPP test_calc)
    set(SORTAPP test_sort)
endif()

set(CMAKE_GENERATOR_PLATFORM "Win32" CACHE STRING "Target Platform")
# 指定32位编译器路径
set(CMAKE_C_COMPILER "$ENV{VCToolsInstallDir}/bin/Hostx86/x86/cl.exe")
set(CMAKE_CXX_COMPILER "$ENV{VCToolsInstallDir}/bin/Hostx86/x86/cl.exe")
set(CMAKE_SYSTEM_PROCESSOR x86)

需要注意的是 CMAKE_GENERATOR_PLATFORM 对应的是VS 中的解决方案平台,也就是 Win32x64 这两个选项。 而 CMAKE_SYSTEM_PROCESSOR 对应的是VS中的目标计算机选项,一般是X86X64 或者 ARM64AMD64

我们可以使用命令 cmake -G "Visual Studio 15 2017" -A "win32" -DCMAKE_BUILD_TYPE=Debug -DCMAKE_TOOLCHAIN_FILE="..\windows_x86_toolchain.cmake" .. 这里指定使用 VS 2017 进行构建,构建架构是32位,版本是Debug。

命令成功执行之后会生成一个.sln 文件,我们可以用VS打开然后在VS中编译,也可以执行使用命令 cmake --build . --config Debug 来编译。一般来说我习惯使用后者,过去使用vs 打开.sln 可以在vs中进行开发,如今vs 已经可以打开并编译cmake工程,所以现在我基本不使用 .sln 文件了,除非公司项目要求使用 .sln。

好了,目前我掌握的关于cmake的内容就是这些了,我利用这些知识已经完成了公司项目的跨平台开发和部署。后续如果有新的需求说不定我会学点新的内容,到时候再来更新这一系列文章吧!!!

0

评论 (0)

取消