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。