对于大型的C\C++项目编译而言,毫无疑问是一个非常复杂,非常令人作呕的过程。C++不同于其他语言,对于编译过程中的ABI兼容都做出了处理。同时,在这门语言中还有非常复杂,近乎于另外一门语言的Cmake,以及两个版本的编译器。你还需要考虑编译过程中的库版本,编译器版本,甚至最后的最后还需要手动将.dll文件复制。下面希望在逐层的抽丝剥茧中可以帮助你我更深刻的理解这个问题,从而解决这个问题。(推荐使用xmake
编译过程
实际上对于一个程序员来说,编译过程应该已经非常熟悉了,但是在这里还需要进一步的阐述以下。
预处理
- 预处理器(cpp)将所有的#define删除,并且展开所有的宏定义。
- 处理所有的条件预编译指令,比如#if、#ifdef、#elif、#else、#endif等。
- 处理#include预编译指令,将被包含的文件直接插入到预编译指令的位置。
- 删除所有的注释。
- 添加行号和文件标识,以便编译时产生调试用的行号及编译错误警告行号。
- 保留所有的#pragma编译器指令,因为编译器需要使用它们。
- 使用gcc -E hello.c -o hello.i命令来进行预处理, 预处理得到的另一个程序通常是以.i作为文件扩展名。
编译
- 词法分析:扫描器(Scanner)将源代的字符序列分割成一系列的记号(Token)。lex工具可实现词法扫描。
- 语法分析:语法分析器将记号(Token)产生语法树(Syntax Tree)。yacc工具可实现语法分析(yacc: Yet Another Compiler Compiler)。
- 语义分析:静态语义(在编译器可以确定的语义)、动态语义(只能在运行期才能确定的语义)。
- 源代码优化:源代码优化器(Source Code Optimizer),将整个语法书转化为中间代码(Intermediate Code)(中间代码是与目标机器和运行环境无关的)。中间代码使得编译器被分为前端和后端。编译器前端负责产生机器无关的中间代码;编译器后端将中间代码转化为目标机器代码。
- 目标代码生成:代码生成器(Code Generator).
- 目标代码优化:目标代码优化器(Target Code Optimizer)。
汇编
汇编器(as)将hello.s翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件中,这是一个二进制文件
链接
连接器的主要作用是把各个模块之间相互引用的部分处理好,使得各个模块之间能够正确的衔接。同时这里存在着动态链接和静态链接。静态链接使用静态库进行链接,生成的程序包含程序运行所需要的全部库,可以直接运行,不过静态链接生成的程序比较大。动态链接使用动态链接库进行链接,生成的程序在执行的时候需要加载所需的动态库才能运行。动态链接生成的程序体积较小,但是必须依赖所需的动态库,否则无法执行。
其他语言对于编译过程所做的优化
C/C++ 的问题:ABI不兼容
- 问题核心:代码被直接编译成特定CPU架构、特定操作系统、特定编译器、特定运行时库的原生机器码。任何一个环节不匹配,就会导致二进制层面的不兼容。
- 具体表现:
.lib/.a链接错误,.dll/.so运行时崩溃,Debug/Release 库混用导致内存错误等。
现代原生编译语言的改进
像 Rust 和 Go 这类语言,在设计之初就吸取了 C++ 的教训,从根本上解决了这类问题。
Rust
- 解决方案:语言自带了官方的构建系统和包管理器 Cargo。
- 如何解决:
- 统一的构建标准:所有 Rust 库都通过 Cargo 进行编译,保证了编译器版本和标志的一致性。
- 中央仓库:有一个官方的包仓库 (crates.io),开发者可以轻松下载和分享库。
- 依赖解析:Cargo 会自动处理依赖的版本和传递性依赖,几乎杜绝了版本冲突。
- C/C++ 互操作:当 Rust需要调用 C/C++ 库时,C++的“硬核”问题会再次出现在两种语言的边界上,但 Rust 内部生态是完全免疫的。
Go
- 解决方案:默认静态链接,语言内置包管理工具。
- 如何解决:
- 无 DLL 地狱:
go build命令默认会将所有依赖的库(包括 Go 的运行时)静态编译成一个单一的可执行文件。部署时只需拷贝这一个文件,完全没有.dll或.so的烦恼。 - Go Modules:内置的依赖管理系统,可以很好地控制版本。
- 无 DLL 地狱:
这类语言依然编译到原生代码,但通过强大的官方工具链统一了构建和分发过程,从源头上避免了二进制不兼容问题。
虚拟机/托管语言的改进
Java/Kotlin (JVM) 和 C#/.NET 这类语言,采用了完全不同的策略。
- 解决方案:引入一个中间层——虚拟机 (VM) 或运行时 (Runtime)。
- 如何解决:
- 编译到字节码:代码不直接编译成机器码,而是编译成一种平台无关的中间语言(Java 的 Bytecode,C# 的 IL)。
- 运行时 JIT 编译:在程序运行时,由虚拟机(JVM)或 .NET 运行时将字节码即时编译(JIT)成本地机器码。
- 依赖是字节码:库依赖(如 Java 的
.jar文件,.NET 的.dll文件)也是字节码。只要目标机器上安装了相应版本的运行时,这些库就可以直接使用,完全不存在编译器版本、运行时库(Debug/Release)、平台架构的匹配问题。 - 成熟的包管理器:它们拥有非常成熟的包管理器,如 Java 的 Maven/Gradle 和 .NET 的 NuGet,可以完美处理库的下载、版本和依赖。
这类语言通过牺牲一些启动性能和内存占用,换来了彻底的跨平台和二进制兼容性,依赖管理体验非常好。
动态/解释性语言版本与环境兼容问题
Python 和 JavaScript (Node.js) 这类语言不存在我们讨论的“编译”问题,但它们有自己的“依赖地狱”。
- 问题核心:库与库之间、库与解释器版本之间的API兼容性问题,以及环境隔离问题。
- 具体表现:
- 一个项目依赖
library-A的 1.0 版本,另一个项目依赖 2.0 版本,这两个版本不兼容,导致全局安装时会出问题。 - 代码在一个库的新版本下行为不一致或直接报错。
- 一个项目依赖
- 解决方案:
- 虚拟环境:通过
venv(Python) 或nvm(Node.js) 等工具创建隔离的项目环境。 - 包管理器:使用
pip(Python) 和npm/yarn(Node.js) 来管理requirements.txt或package.json中定义的依赖版本。
- 虚拟环境:通过
一些经历
对于基本的情况,首先阅读项目文件中对于各个依赖库的版本要求,注意各个库文件的兼容性。当然如果是docker打包就可以减免这个环节了。接下来修改CMakeLists,改一下其中的文件路径等等。接下来最好使用命令行进行编译。使用cmake –build build –config Release注意检查你的工具链和环境变量,由于C++中没有统一的虚拟环境或者包管理(除了docker),你需要格外注意你的编译器工具链配置。在我的电脑中有三个版本的MingW,包括普通版本,多线程版本,和QT中自带的。另外我一般使用Clion,因此需要同时配置普通的工具链和Cmake本身的工具链,以及环境变量。
之前在编译libdatachannel的时候由于它的依赖很多,所以想要去找一个包管理器来编译。当然第一个想到的就是vcpkg,但是呢,我的项目为了保证平台兼容性,是使用mingw进行编译的,因此不得不寻找mingw的包版本,很遗憾vcpkg对于mvsc非常兼容但是mingw就不是了,理所应当的我配置失败了,因此我试图去寻找其他的包管理器。实验了msys2,这是通过虚拟一个类linux环境来实现包的编译和管理,这当然是不行的,我不能够要求使用我开源项目的人在电脑上装一个虚拟环境,仅仅是为了编译,遂放弃。另外就是conan,这个管理器很让人意外,其中很多之前碰到的编译不兼容在这里都过了,可能是社区支持的比较好,但是最后在libdatachannel本身的编译上面还是出现了unit_32不兼容,需要修改成static_cast
,好在conan的配置方式很多样化,和AI聊了聊找到了一个利用git生成patch的方法然后再构建包的时候引用我生成的patch,但是无论如何都注入不了,其他还有很多注入方式都没办法注入到包的编译过程中去,例如首先在conan把其他依赖构建好,缓存好,然后直接编译libdatachannel的源代码,通过注入其cmakelists修改构建规则,使用conan内缓存的库。所以也只能作罢,最后还是决定牺牲虚无缥缈的兼容性,选用mvsc编译器+vcpkg,但是这次折腾并不是一无所获的,我决定使用xmake来替换cmake得到一个更好的体验。