分类
技术

C/C++ 程序的构建与运行

C/C++ 程序的生命周期从源代码编写开始,最终转化为可以在计算机上运行的可执行文件。这个过程涵盖了多个重要的阶段,包括预处理、编译、汇编、链接、加载和最终的执行。每个阶段都有其独特的作用,共同将人类可读的源代码转换为机器能够理解并执行的二进制指令。

这个生命周期可以归纳为两个主要环节:构建运行。在构建阶段,程序经历了从源代码到目标代码的转化过程,包括预处理、编译、汇编和链接。最后,链接器将静态库的代码嵌入到最终的可执行文件中,同时记录对共享库的引用标记。

在运行阶段,当用户启动可执行文件时,操作系统的加载器会将可执行文件以及所需的共享库从硬盘载入内存,CPU 执行内存中的机器指令,处理外部输入并生成输出,最终完成程序的执行。

1. 构建阶段

1.1 预处理阶段(Preprocessing)

在构建的第一步,预处理器 会对源代码进行处理,主要完成以下任务:

  • 宏展开:例如,将 #define 定义的宏替换为实际内容。
  • 头文件包含:将 #include 引入的头文件内容嵌入到源代码中。
  • 删除注释:去掉源代码中的注释。
  • 条件编译:根据 #if#ifdef 等指令选择编译不同的代码段。

预处理后,源代码中不再包含任何预处理指令,结果是一个纯净的源代码文件。

使用 g++ 执行预处理:

g++ -E source.cpp -o source.i

-E 选项告诉编译器只进行预处理,不进行后续编译,生成经过宏展开和头文件包含后的代码。

常见错误:最常见的错误是 “No such file or directory”,通常是由于头文件路径错误或文件缺失。

1.2 编译阶段(Compilation)

在编译阶段,编译器会对预处理后的代码进行 词法分析语法分析语义分析,并生成汇编代码。编译器还会对代码进行优化,生成特定目标平台的汇编代码。

使用 g++ 执行编译:

g++ -S source.i -o source.s

-S 选项告诉编译器将源代码编译为汇编代码,生成 .s 文件。

常见错误:编译阶段常见的错误包括 语法错误(如缺少分号)和 语义错误(如变量未声明)。

1.3 汇编阶段(Assembly)

汇编器将编译阶段生成的汇编代码转换为机器语言,并生成目标文件(.o 文件)。目标文件包含机器能够执行的二进制指令,但它们尚未被链接为一个完整的可执行文件。

使用 g++ 执行汇编

g++ -c source.s -o source.o

-c 选项告诉编译器将汇编代码转化为目标文件。

1.4 链接阶段(Linking)

链接阶段将多个目标文件和静态库合并,解析符号,匹配函数调用与其实际定义,最终生成一个可执行文件。链接器还会将静态库的代码嵌入到可执行文件中,如果是动态库,则会记录对外部库的引用。

静态链接会将静态库的代码复制到最终的可执行文件中,使得程序独立运行,不依赖外部库。

动态链接则在链接时只记录对共享库的引用,实际的符号解析会在运行时进行,多个程序可以共享同一个库文件的内存拷贝,减少内存占用。

使用 g++ 执行链接

g++ source.o -o executable
  • 链接错误:在静态链接时,如果链接器无法找到某个函数或变量的实现体,会抛出 undefined reference 错误。
  • 库的链接顺序错误:在使用静态库时,库的链接顺序非常重要。例如,库 A 依赖于库 B 时,库 B 必须在库 A 后面链接。

2. 运行阶段

构建阶段结束后,我们得到了一个完整的可执行文件。但程序并不会立刻执行 main(),而是先进入 运行时加载阶段。这一阶段由操作系统的 动态加载器(Dynamic Loader) 负责。

运行阶段主要包括:加载、符号解析和执行。

2.1 加载阶段(Loading)

当可执行文件被启动时,操作系统会创建进程,并启动动态加载器。

加载器首先会解析可执行文件中的 引用标记 —— 这些标记是在构建阶段由链接器写入的占位符,用来表明程序需要来自外部动态库的函数。

随后,加载器会在系统的标准搜索路径中查找所需的动态库文件,例如:

  • Linux:.so
  • Windows:.dll

找到后,会将这些动态库加载到当前进程的内存空间中。

需要注意的是,动态库在构建时必须被编译为 位置无关代码(Position Independent Code, PIC)。这样它们才能被安全地加载到内存中的任意地址,并支持多进程共享。如果某个库已经被加载到内存中,其他程序可以直接共享同一份代码副本。

如果加载器无法在标准搜索路径中找到动态库,程序将在运行时报错。这也是动态链接程序最常见的问题之一。

可以看到,运行时的动态加载之所以能够实现,其前提是构建阶段已经在可执行文件中正确写入了这些引用信息。

2.2 符号解析(Symbol Resolution)

当所有动态库成功加载到内存后,加载器会进行 运行时符号解析

在动态链接的程序中,函数调用在构建阶段并没有确定最终地址,而只是记录了符号引用。加载器会:

  • 找到函数在动态库中的实际地址
  • 完成地址重定位
  • 将程序中的函数调用修正为指向真实的内存位置

也就是说,程序中对函数的“模糊调用”会被修正为指向库代码在内存中的确切地址。

完成符号解析后,内存中就形成了一个完整的程序镜像。

2.3 程序执行(Execution)

当加载和符号解析全部完成后,操作系统将控制权交给程序入口,CPU 开始逐条执行机器指令。

程序开始接收外部输入、执行逻辑计算并输出结果,直到执行结束。

3. 静态库与动态库

3.1 静态库(Static Library)

静态库(通常以 .a.lib 为扩展名)在编译时被链接到程序中。静态库的代码会被直接复制到最终的可执行文件中。

静态库的工作原理:
  • 创建静态库:静态库由一个或多个目标文件(.o.obj 文件)组成。当链接器将目标文件与静态库合并时,库的代码会被复制到程序中。
  • 链接阶段:链接器将静态库的代码复制到可执行文件中,生成一个不依赖外部库的独立程序。
静态库的优缺点:

优点

  • 自包含:可执行文件包含了库的所有代码,程序不依赖外部库。
  • 无需运行时库:程序启动时不需要加载库文件。

缺点

  • 文件体积大:静态库的代码会被嵌入到程序中,增加了可执行文件的体积。
  • 更新困难:如果库更新,所有使用该库的程序都需要重新编译。
如何使用静态库:
  1. 编译源代码文件为目标文件:
g++ -c add.cpp -o add.o
g++ -c multiply.cpp -o multiply.o

2. 使用 ar 工具创建静态库:

ar rcs libmath.a add.o multiply.o

-r 替换库中已有的同名.o文件

-c 库如果不存在就创建

-s 创建一个符号索引,加快后续链接速度

3. 链接静态库:

g++ main.cpp -L. -lmath -o my_program

-L. 指定库所在路径(当前目录)

-ltest 链接名为 libtest.a 的库,这里不需要加上前缀 lib 和后缀 .a l 表示跨平台快捷方式

3.2 动态库(Dynamic Library)

动态库(通常以 .so.dll 为扩展名)在程序运行时由操作系统加载。动态库的代码并不嵌入到可执行文件中,而是在程序运行时通过符号解析来链接。

动态库的工作原理:
  • 创建动态库:动态库通过编译目标文件,并使用 -shared 选项创建。
  • 运行时加载:程序运行时,操作系统根据符号解析从内存中加载共享库。
动态库的优缺点:

优点

  • 节省内存和磁盘空间:多个程序可以共享同一个动态库的内存拷贝,减少内存使用。
  • 灵活性:程序和库是分开的,程序可以在不重新编译的情况下使用新的库版本。

缺点

  • 依赖于外部库:动态库需要在运行时加载,如果找不到动态库,程序将无法启动。
  • 运行时开销:动态库的加载和符号解析可能导致一定的运行时开销。
如何使用动态库:
  1. 编译源代码文件为目标文件,并生成位置无关代码(PIC):
g++ -fPIC -c add.cpp -o add.o 
g++ -fPIC -c multiply.cpp -o multiply.o

-fPIC 生成位置无关(PIC)代码

2. 创建动态库:

g++ -shared -o libmath.so add.o multiply.o

-shared 生成动态库

3. 链接动态库:

g++ main.cpp -L. -lmath -o my_program

3.3 静态链接与动态链接的区别

特性静态链接动态链接
链接时间在编译时链接,将所有代码嵌入到可执行文件中在运行时加载,链接器只记录引用标记,实际符号解析在运行时进行
文件大小可执行文件较大,因为静态库代码被嵌入其中可执行文件较小,只有代码引用标记,不包含库代码
性能由于不需要在运行时加载库,程序启动较快启动时需要加载库,可能会有一些延迟
灵活性程序无法动态更新库,更新库需要重新编译可以动态更新库,程序可以在不重新编译的情况下使用新版本的库
内存共享每个程序都有自己的库副本,浪费内存多个程序可以共享同一份库,节省内存
依赖管理不需要依赖外部库,程序完全自包含依赖于库的存在,运行时需要确保库文件可用

4. 使用 CMake 简化构建过程

对于大型项目,手动管理构建过程非常繁琐,尤其是在跨平台开发中。CMake 是一个跨平台的自动化构建系统,可以根据项目需要生成各平台的构建文件(如 Makefile、Visual Studio 项目文件等),大大简化了构建过程。

CMake 是一个开源的跨平台构建系统生成工具。它通过使用配置文件(通常是 CMakeLists.txt)来生成适合当前平台的构建文件。CMake 本身不是构建工具,而是一个构建系统生成器,它会根据你提供的配置文件自动生成 MakefileVisual Studio 项目文件、Xcode 工程文件等,从而简化构建过程。

CMake 的主要优势在于其跨平台性。通过 CMake,你可以在不同操作系统(如 Linux、Windows、macOS)上进行构建,而无需修改构建文件。它支持多种编译器和 IDE,使得 C++ 项目能够在多平台上顺利构建和开发。

4.1 CMake 的工作原理

CMake 通过 CMakeLists.txt 文件来描述如何构建项目。在 CMakeLists.txt 文件中,你可以指定项目的源代码、头文件、外部依赖项、编译选项、安装规则等。CMake 根据这个配置文件生成具体的构建文件,如 MakefileVisual Studio 项目等。

4.2 CMake 的基本使用

4.2.1 创建一个简单的 CMake 项目

假设我们有一个简单的 C++ 项目,包含一个源文件 main.cpp,我们希望使用 CMake 来简化构建过程。首先,创建一个 CMakeLists.txt 文件来描述项目的构建规则。

1. 项目目录结构
project_directory/

├── CMakeLists.txt
└── main.cpp
2. main.cpp 文件内容
#include <iostream>

int main() {
    std::cout << "Hello, CMake!" << std::endl;
    return 0;
}
3. 创建 CMakeLists.txt 文件

在项目根目录下创建 CMakeLists.txt 文件,内容如下:

# 指定最低版本的 CMake
cmake_minimum_required(VERSION 3.10)

# 设置项目名称
project(HelloCMake)

# 设置 C++ 标准
set(CMAKE_CXX_STANDARD 14)

# 添加源文件
add_executable(hello main.cpp)

解释:

  • cmake_minimum_required(VERSION 3.10):指定 CMake 的最低版本。
  • project(HelloCMake):指定项目名称。
  • set(CMAKE_CXX_STANDARD 14):指定使用 C++14 标准。
  • add_executable(hello main.cpp):指定生成可执行文件 hello,并指定源文件 main.cpp
4. 构建项目

在项目根目录中创建一个 构建目录,然后使用 CMake 生成构建文件:

mkdir build
cd build
cmake ..
make
  • mkdir build:创建一个新的构建目录。
  • cd build:进入构建目录。
  • cmake ..:运行 CMake,生成适合当前平台的构建文件(如 Makefile)。
  • make:使用 Makefile 进行编译,生成可执行文件。
4.2.2 跨平台构建

CMake 的一个主要优点是它能够在不同平台上生成适合该平台的构建文件。CMake 支持的常见构建系统包括:

  • Linux:生成 Makefile 或者支持 Ninja
  • macOS:生成 Xcode 项目文件。
  • Windows:生成 Visual Studio 项目文件或者 MinGW Makefile。

例如,在 Linux 系统上,你可以使用以下命令:

cmake ..
make

而在 Windows 系统上,可以使用以下命令生成 Visual Studio 项目文件:

cmake -G "Visual Studio 16 2019" ..

通过这种方式,CMake 能够自动为不同平台生成对应的构建文件,无需手动编写和管理平台特定的构建文件。

4.2.3 配置 CMake 构建选项

CMake 允许我们为构建过程配置各种选项,例如启用调试信息、设置优化选项、指定外部库等。

1. 启用调试信息

CMakeLists.txt 中添加如下代码来启用调试信息:

set(CMAKE_BUILD_TYPE Debug)

如果希望生成发布版本,可以设置为 Release

set(CMAKE_BUILD_TYPE Release)
2. 指定外部库

假设你需要链接一个外部库(如 boost),你可以在 CMakeLists.txt 中使用 find_package 查找库,并使用 target_link_libraries 链接该库:

cmake复制代码# 查找 Boost 库
find_package(Boost REQUIRED)

# 将 Boost 库链接到项目中
target_link_libraries(hello Boost::Boost)

这样,CMake 会根据系统自动找到 Boost 库并将其链接到项目中。

4.2.4 使用 CMake 管理多个文件

当项目变得复杂时,源代码和头文件会变得非常多。你可以在 CMake 中使用 aux_source_directory 指令来自动添加源文件:

cmake复制代码# 自动将所有 .cpp 文件添加到 SOURCES 变量中
aux_source_directory(src SOURCES)

# 将所有源文件编译为可执行文件
add_executable(hello ${SOURCES})

在这种方式下,只需要将所有的源文件放在 src 目录下,CMake 会自动将它们添加到构建过程中。

4.3 常见的 CMake 实践与技巧

4.3.1 使用 CMakeLists.txt 来管理多目录项目

对于更复杂的项目,源代码通常会被分成多个子目录。在这种情况下,CMake 提供了 add_subdirectory 来将子目录添加到构建中。每个子目录都可以有自己的 CMakeLists.txt 文件。

项目结构示例:

project_directory/
├── CMakeLists.txt
├── src/
│ ├── CMakeLists.txt
│ └── main.cpp
└── lib/
├── CMakeLists.txt
└── math.cpp

CMakeLists.txt 文件:

cmake_minimum_required(VERSION 3.10)
project(ComplexProject)

# 添加 lib 子目录
add_subdirectory(lib)

# 添加 src 子目录
add_subdirectory(src)

lib/CMakeLists.txt 文件:

# 创建静态库
add_library(math STATIC math.cpp)

src/CMakeLists.txt 文件:

# 指定可执行文件
add_executable(hello main.cpp)

# 链接静态库
target_link_libraries(hello math)
4.3.2 使用 CMake 配置不同的构建类型

CMake 支持不同的构建配置,如 DebugRelease。你可以通过指定构建类型来控制编译器优化选项和调试信息。

# Debug 配置
cmake -DCMAKE_BUILD_TYPE=Debug ..
make

# Release 配置
cmake -DCMAKE_BUILD_TYPE=Release ..
make

在 CMakeLists.txt 中也可以直接设置:

set(CMAKE_BUILD_TYPE Debug)
4.3.3 自定义安装规则

CMake 还可以生成安装规则,使得项目可以轻松地被安装到系统中。通过 install 命令,你可以指定安装路径、库文件、可执行文件等。

# 安装可执行文件
install(TARGETS hello DESTINATION /usr/local/bin)

# 安装头文件
install(FILES hello.h DESTINATION /usr/local/include)

运行 make install 时,CMake 会将文件安装到指定的目录中。

4.3.4 使用 CMake 构建外部项目

CMake 还可以用来构建外部项目,比如使用 ExternalProject 模块来自动化下载、构建和安装依赖项。以下是一个使用 ExternalProject 的示例:

include(ExternalProject)

ExternalProject_Add(
    my_project
    GIT_REPOSITORY https://github.com/example/repo.git
    GIT_TAG master
    CMAKE_ARGS -DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/install
)

5. 总结

从人类可读的源代码开始,经历预处理、编译、汇编与链接,在构建阶段完成形态上的塑造;再到运行阶段,由操作系统加载、解析符号、重定位地址,最终交由 CPU 执行。一个看似简单的程序背后,其实是一整套严谨而精密的协作机制。

学习这些从构建到运行的底层知识,并不是为了记住 -fPIC-L-l 这些类似“咒语”的命令参数。在工具越来越智能、AI 能够自动生成构建脚本的时代,单纯的命令记忆价值正在降低。真正重要的,是理解这些机制背后的设计思想——理解链接器为什么要写入引用标记,理解加载器如何在运行时解析依赖,理解静态链接与动态链接在架构层面上的差异。

当我们站在开发者的角度看待这一切,会发现本质上我们始终在权衡一些绕不开的矛盾:灵活性与稳定性,共享与隔离,部署的独立性与维护的便利性。选择静态还是动态,并不只是一个技术选项,而是一种工程决策;理解程序的一生,也不仅仅是理解工具链流程,而是理解软件系统是如何在约束中寻找平衡。这是软件工程的哲学,也是 C++ 的艺术。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

在此处输入验证码 : *

Reload Image