文章

modern c++ 的 编译工具链、包管理和坑

本来打算写前端学习笔记的,因为platform基本上弄完了.

但是中间被CNB项目中断施法了,所以先转回本行C++。

CNB项目的游戏客户端采用UNREAL。出于学习第一,开发第二的思想。我们全部采用了c++20、cmake、gcc、gdb、vcpkg跨平台体系来编写基础库。

项目部的几位成员都是同一个moba游戏的游戏服务器开发出身。由于我们工作中的项目立项非常早,采用c++98(甚至还是带类的c写法)、vs2005、windows server技术栈,坑也基本上被前辈踩平了。所以对于这些前沿的技术和底层编译流程不太清楚,同时我们的IDE、系统(vs+win、vsc+win、clion+win、clion+macos)也不一样,踩了很多坑。在此系统的记录下。

c++的编译

基础流程

c++源码一般由.h和.cpp或等价的inc、cc组成。c++分为声明和定义两个部分。定义主要实现功能,在实现功能的过程中,可以调用别的功能。所有的功能都对外暴露符号,以供别的功能调用。这个符号就是声明。定义一般在.cpp,声明一般在.h。

第一个阶段,编译器会将所有.cpp文件编译为.o文件。在此过程中,会根据单个.cpp中的include递归遍历.h查找符号是否存在。

第二个阶段,编译器会将所有.o连接为可执行文件,例如pe或者.exe。

gcc

现在最流程的编译器就是GNU Compiler Collection(GNU(GNU ISNT UNIX)编译器套件)中的gcc编译器(c++编译器,一般小写以区分套件GCC)。他可以将.cpp编译成.o或者将.o链接。

在足够小型的项目中,直接使用gcc是可行的。但是在大型的项目中,.cpp一般非常多,编译链接非常麻烦。我们也不能将所有.h和.cpp放在同一层文件夹(库、模块化),每一次编译时需要指定非常多的包含目录,非常麻烦,甚至是不现实的。特别是跨平台、多种宏的情况下,改动项目结构可能要改动一堆gcc命令。

make、sln、nmake、ninja

类似于docker、docker composed。开发者编写调用gcc的批处理构建项目的过程中,发现编写批处理这个重复的过程本身,可以进行元编程。只要指定共用的编译参数和依赖关系,其实就能确定gcc命令。这些高级批处理工具非常多,主流的有make-makefile,微软系的namake-sln,谷歌搞的ninja等等。根据逻辑不变定律(要么功能简单,要么语法复杂,要么两者都不到),各自有所取舍。

C++第cc定律,凡是能元编程的一定有人元编程,如果足够便利,甚至会成为主流做法。。

cmake

类似于makefile的批处理在一些简单的工程中完全可以手写。但是当工程非常大的时候,手写makefile也是非常麻烦的,如果换了个平台makefile又要重新修改。因此有了cmake这样的工具可以生成makefile。不仅仅可以跨平台进行编译,甚至可以生成sln文件等等。

现在的IDE基本上都是接入的cmake层的编译。

vcpkg

c++诞生太早了,一直到c++20才有模块这个概念。又因为社区比较分散导致工具链、包管理非常的混乱。写过c++的都吃过什么引入boost、菱形依赖等等坑爹的亏。除了手动管理或者依靠conna(是的,就是python那个)依赖,然后解决非常复杂的包含问题外,现在最流行的就是微软家的vcpkg了。当然,还是不如pip、go mod甚至不如node的npm、yarn之类的好用。不过对于c++来说也算是不错了。

vcpkg负责管理代码依赖,拉下来的代码会提供.cmake文件解决模块自身的依赖问题。当然这个工具本身就很c++,提供的安装方式是源码安装……功能上也是什么都有,甚至可以反过来管理cmake。

mingw

从GCC的名字上就可以看出来人家是linux体系的。但是如果有win的项目总不能重写一套或者用不太跨平台的微软全家桶吧。因此windows上有对应的工具链,MInGW,全称为:Minimalist GNU on Windows。

反过来微软也偷家做了nmake。

如何编写makelist

实例

首先我们先看一下例子

set(CMAKE_CXX_STANDARD 20)
​
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
​
add_definitions(-D_WIN32_WINNT=0x0A00)  # win10
​
cmake_minimum_required(VERSION 3.10)
​
project(AsioNet)
​
find_package(asio CONFIG REQUIRED)
find_package(protobuf CONFIG REQUIRED)
find_package(kcp CONFIG REQUIRED)
​
file(GLOB_RECURSE ALL_CPP_SRCS
    "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp"
​
    # protoc
    "${CMAKE_CURRENT_SOURCE_DIR}/protoc/*.cc"
​
    # for test
    "${CMAKE_CURRENT_SOURCE_DIR}/test/*.cpp"
)
​
add_executable(AsioNet ${ALL_CPP_SRCS})
​
target_compile_features(AsioNet PRIVATE cxx_std_17)
​
target_link_libraries(AsioNet PRIVATE asio::asio)
target_link_libraries(AsioNet PRIVATE protobuf::libprotoc protobuf::libprotobuf protobuf::libprotobuf-lite)
target_link_libraries(AsioNet PRIVATE kcp::kcp)
​
target_link_libraries(AsioNet PRIVATE ws2_32)
target_link_libraries(AsioNet PRIVATE mswsock)

我们逐行解析这个makelist

# 设置C++标准为C++20
set(CMAKE_CXX_STANDARD 20)
​
# 强制要求使用设置的C++标准版本
set(CMAKE_CXX_STANDARD_REQUIRED ON)
​
# 关闭编译器特定扩展,确保代码的可移植性
set(CMAKE_CXX_EXTENSIONS OFF)
​
# 添加宏定义指示Windows API的版本(在这里是Windows 10)
add_definitions(-D_WIN32_WINNT=0x0A00)  
​
# 指定CMake的最低版本要求
cmake_minimum_required(VERSION 3.10)
​
# 设置项目名称
project(AsioNet)
​
# 查找并加载asio库的配置信息,需要asio已经安装且存在配置文件
find_package(asio CONFIG REQUIRED)
​
# 查找并加载protobuf库的配置信息,需要protobuf已经安装且存在配置文件
find_package(protobuf CONFIG REQUIRED)
​
# 查找并加载kcp库的配置信息,需要kcp已经安装且存在配置文件
find_package(kcp CONFIG REQUIRED)
​
# 使用file命令和GLOB_RECURSE选项来查找所有满足路径模式的源文件,并将列表保存到变量ALL_CPP_SRCS
file(GLOB_RECURSE ALL_CPP_SRCS
    "${CMAKE_CURRENT_SOURCE_DIR}/src/*.cpp"           # 主源码目录
    "${CMAKE_CURRENT_SOURCE_DIR}/protoc/*.cc"         # Protobuf生成的文件
    "${CMAKE_CURRENT_SOURCE_DIR}/test/*.cpp"          # 测试代码目录
)
​
# 基于收集到的源文件创建名为AsioNet的可执行文件
add_executable(AsioNet ${ALL_CPP_SRCS})
​
# 为目标AsioNet添加编译特性,这里要求支持C++17标准
target_compile_features(AsioNet PRIVATE cxx_std_17)
​
# 将asio库链接到AsioNet可执行文件
target_link_libraries(AsioNet PRIVATE asio::asio)
​
# 将protobuf相关库链接到AsioNet可执行文件
target_link_libraries(AsioNet PRIVATE protobuf::libprotoc protobuf::libprotobuf protobuf::libprotobuf-lite)
​
# 将kcp库链接到AsioNet可执行文件
target_link_libraries(AsioNet PRIVATE kcp::kcp)
​
# 链接Windows系统库ws2_32.lib和mswsock.lib到AsioNet可执行文件
target_link_libraries(AsioNet PRIVATE ws2_32)
target_link_libraries(AsioNet PRIVATE mswsock)

看懂了基础的makelist后,我们讲解如何从头编写一个makelist

如何编写CMakeLists.txt

1. 指定CMake的最小版本

cmake_minimum_required(VERSION 3.10)

这条指令告诉CMake你的项目需要至少3.10版本的CMake才能正确构建。这里选择一个最新版本一般就行。

2. 设置项目名称和版本

project(MyProject VERSION 1.0)

这里我们设置了项目名称为MyProject,并给项目标上了1.0版本号。版本号可以考虑主流的演进规律。大版本-小版本-补丁。大版本代表大幅度改动,重构。小版本代表功能增加或者bug修复。

3. 设置C++标准

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED True)

这些指令设定了C++的版本和是否强制要求该标准。在本例中,我们要求C++11标准,在代码不符合标准时会产生编译错误。

4. 寻找外部依赖包

find_package(SomeLib REQUIRED)

如果你的项目依赖于外部库,可以使用find_package命令寻找并链接这些库。IDE会将这些库读取然后加上高亮

5. 添加源文件

add_executable(MyExecutable main.cpp)

通过add_executable命令,我们可以指定目标可执行文件MyExecutable和必要的源文件main.cpp。之后汇编出可执行文件

6. 链接库文件

target_link_libraries(MyExecutable PRIVATE SomeLib)

使用target_link_libraries命令将前面通过find_package找到的库链接到你的可执行文件或库。

7. 指定头文件目录

target_include_directories(MyExecutable PUBLIC include/)

如果你有一些头文件需要被项目中所有文件包含,可以使用这个命令来添加头文件目录。

8. 构建库而非可执行文件

add_library(MyLibrary STATIC src/mylibrary.cpp)

如果你打算构建一个库而不是可执行文件,使用add_library命令,并指定STATIC, SHARED或者MODULE来说明库的类型。

9. 添加子目录

add_subdirectory(subdir)

如果你的项目结构较为复杂,拥有多个子目录,可以使用这个命令将它们加入构建过程。一般用于模块化,可以在子目录中继续编写makelist。类似于vs中的solution和project。可以分别编译并互相依赖。

DEBUG、RELEASE和编译优化

编译库

要在CMake中编译一个库,你可以使用add_library()函数。你可以创建静态库(STATIC)、共享库(SHARED)或模块库(MODULE)。

# 静态库
add_library(MyStaticLibrary STATIC src/library_code.cpp)
​
# 共享库
add_library(MySharedLibrary SHARED src/library_code.cpp)

编译可执行文件

对于可执行文件,使用add_executable()函数,并指定目标名称及源文件。

add_executable(MyExecutable src/main.cpp)

如果你有多个含有main()函数的cpp文件,通常情况下你只能有一个主入口点。但是,你可以创建多个不同的目标(每个目标对应一个主入口),或者使用条件选择不同的源文件。主要用于编写测试文件等。

# 为每个主入口创建不同的可执行文件
add_executable(Main1 main1.cpp)
add_executable(Main2 main2.cpp)

或者,如果想基于某些条件来选择不同的主入口点,例如命令行参数:

if(USE_MAIN1)
  set(MAIN_SOURCE main1.cpp)
else()
  set(MAIN_SOURCE main2.cpp)
endif()
​
add_executable(MyExecutable ${MAIN_SOURCE})

指定Release或Debug模式

不同于vs,cmake可以将编译模式的切换放在make中。

你可以通过设置CMake的构建类型来指定是Release还是Debug模式。

set(CMAKE_BUILD_TYPE Release)

或者在命令行中使用-DCMAKE_BUILD_TYPE=Release-DCMAKE_BUILD_TYPE=Debug选项。

指定编译优化等级

编译优化等级可以在目标属性中设置,例如:

target_compile_options(MyExecutable PRIVATE -O3) # 对gcc和clang这样设置

对于Visual Studio,使用相应标志:

target_compile_options(MyExecutable PRIVATE /O2) # 对MSVC这样设置

在代码中确定自己处于Debug还是Release

CMake会自动定义一些宏来帮助你判断当前的构建类型。最常用的宏是NDEBUG,在Release模式下被定义,在Debug模式下没有定义。这个一般的IDE也支持。

在C++代码中,你可以这样使用:

#ifndef NDEBUG
// Debug-specific code
#else
// Release-specific (or non-debug) code
#endif

编译问题

如何避免编译问题

君子不立于危墙之下,合理的前期准备可以避免大量问题。

建议下载最新版本的mingw64。使用clion或者vs初始化项目或者打开别人写好的cmkelist。使用vcpkg管理大部分依赖,手改的直接克隆到项目目录。一般不会动太多的脑子。

vcpkg的包编不过

如果是主流库,优先考虑升gcc版本。不要抱着主传的mingw6不放手了

需要一个vs实例

在vcpkg install的过程中,有时会报这个编译错误。

需要指出,vcpkg是可以完全脱离vs存在的,如果在审核cmake配置,确认没有vs的生成器参与后还是报这个错。可以考虑加入配置。再不行可以参考issue

License:  CC BY 4.0