Skip to content

Latest commit

 

History

History
687 lines (497 loc) · 29.9 KB

01.md

File metadata and controls

687 lines (497 loc) · 29.9 KB

一、构建 C++ 应用简介

编程语言因其程序执行模型而异;最常见的是解释和编译语言。编译器将源代码翻译成机器代码,计算机可以在没有中间支持系统的情况下运行。另一方面,解释语言代码需要支持系统、解释器和虚拟环境才能工作。

C++ 是一种编译语言,它使程序比解释语言运行得更快。虽然 C++ 程序应该为每个平台编译,但解释程序可以跨平台运行。

我们将讨论程序构建过程的细节,从处理源代码的阶段(由编译器完成)开始,到可执行文件(编译器的输出)的细节结束。我们还将了解为什么为一个平台构建的程序不能在另一个平台上运行。

本章将涵盖以下主题:

  • C++ 20 入门
  • C++ 预处理器的详细信息
  • 在源代码编译的掩护下
  • 了解链接器及其功能
  • 加载和运行可执行文件的过程

技术要求

带有选项-std=c++ 2a的 g++ 编译器用于编译整个章节的示例。你可以在https://github.com/PacktPublishing/Expert-CPP找到本章使用的源文件。

C++ 20 入门

C++ 经过多年的发展,现在已经有了一个全新的版本,C++ 20。自 C++ 11 以来,C++ 标准极大地扩展了语言特性集。让我们看看新的 C++ 20 标准中值得注意的特性。

概念

概念是 C++ 20 中的一个主要特性,它为类型提供了一组需求。概念背后的基本思想是模板参数的编译时验证。例如,要指定模板参数必须有默认构造函数,我们使用default_constructible概念的方式如下:

template <default_constructible T>
void make_T() { return T(); }

在前面的代码中,我们遗漏了typename关键字。相反,我们设置了一个描述template函数的T参数的概念。

我们可以说概念是描述其他类型的类型——可以说是元类型。它们允许模板参数的编译时验证以及基于类型属性的函数调用。我们将在第 3 章面向对象编程的细节第 4 章理解和设计模板中详细讨论概念。

协同程序

协同程序是特殊的函数,能够在任何定义的执行点停止,并在以后恢复。Coroutines 用以下新关键词扩展了语言:

  • co_await中止执行共同诉讼请求。
  • co_yield暂停执行协同程序,同时还返回一个值。
  • co_return类似于常规的return关键词;它完成协同并返回一个值。看看下面这个经典的例子:
generator<int> step_by_step(int n = 0) {
  while (true) {
    co_yield n++ ;
  }
}

协同词与promise对象相关联。promise对象存储并提醒验尸官的状态。我们将在第 8 章并发和多线程中深入探讨协同工作。

范围

ranges库提供了一种处理元素范围的新方法。要使用它们,您应该包含<ranges>头文件。让我们用一个例子来看看ranges。范围是有开始和结束的元素序列。它提供了一个begin迭代器和一个end哨兵。考虑以下整数向量:

import <vector>

int main()
{
  std::vector<int> elements{0, 1, 2, 3, 4, 5, 6};
}

带有范围适配器的范围(|运算符)提供了处理一系列元素的强大功能。例如,检查以下代码:

import <vector>
import <ranges>

int main()
{
  std::vector<int> elements{0, 1, 2, 3, 4, 5, 6};
  for (int current : elements | ranges::view::filter([](int e) { return 
   e % 2 == 0; }))
  {
    std::cout << current << " ";
  }
}

在前面的代码中,我们使用ranges::view::filter()过滤了偶数的范围。注意应用于元素向量的范围适配器|。我们将在第 7 章功能编程中讨论范围及其强大功能。

更多 C++ 20 特性

C++ 20 是 C++ 语言的一个新的大版本。它包含许多功能,使语言更加复杂和灵活。概念范围相关是本书将讨论的许多特性中的一些。

最值得期待的特性之一是模块,它提供了声明模块以及在这些模块中导出类型和值的能力。您可以将模块视为头文件的改进版本,现在有了冗余的包含保护。我们将在本章中介绍 C++ 20 模块。

除了 C++ 20 中添加的显著特性之外,还有一系列其他特性,我们将在整本书中讨论:

  • 宇宙飞船操作员:operator<=>()。操作员超载的详细程度现在可以通过利用operator<=>()来控制。
  • constexpr征服了语言中越来越多的空间。C++ 20 现在有consteval功能、constexpr std::vectorstd::string以及更多功能。
  • 数学常数,如std::number::pistd::number::log2e
  • 对线程库的主要更新,包括停止令牌和加入线程。
  • 迭代器概念。
  • 仅移动视图和其他功能。

为了更好地理解一些新特性,并深入了解语言的本质,我们将从以前的版本开始介绍语言的核心。这将有助于我们找到新特性比旧特性更好的用途,也有助于支持传统的 C++ 代码。现在让我们从了解 C++ 应用构建过程开始。

构建和运行程序

您可以使用任何文本编辑器来编写代码,因为归根结底,代码只是文本。要编写代码,您可以自由选择简单的文本编辑器,如 Vim ,或者高级的集成开发环境 ( IDE )如 MS Visual Studio 。情书和源代码唯一的区别是后者可能会被一个叫做编译器的特殊程序解释(虽然情书不能被编译成程序,但它可能会让你感到紧张不安)。

为了区分纯文本文件和源代码,使用了一个特殊的文件扩展名。C++ 使用.cpp.h扩展运行(你也可能偶尔会遇到.cxx.hpp)。在进入细节之前,把编译器想象成一个把源代码翻译成可运行程序的工具,被称为可执行文件或者仅仅是一个可执行文件。从源代码制作可执行文件的过程称为编译。编译 C++ 程序是导致机器代码生成的一系列复杂任务。机器码是计算机的母语——这就是它被称为机器码的原因。

通常,C++ 编译器解析和分析源代码,然后生成中间代码,优化它,最后在名为目标文件的文件中生成机器代码。您可能已经遇到了对象文件;它们有独立的扩展 Linux 中的.o和 Windows 中的.obj。创建的目标文件包含的不仅仅是计算机可以运行的机器代码。编译通常涉及几个源文件,编译每个源文件会产生一个单独的目标文件。然后,这些目标文件通过一个名为链接器的工具链接在一起,形成一个可执行文件。链接器使用存储在目标文件中的附加信息来正确链接它们(链接将在本章后面讨论)。

下图描述了程序构建阶段:

C++ 应用构建过程包括三个主要步骤:预处理编译链接。所有这些步骤都是使用不同的工具完成的,但是现代编译器将它们封装在一个工具中,从而为程序员提供了一个单一且更简单的界面。

生成的可执行文件保存在计算机的硬盘上。为了运行它,它应该被复制到主内存,内存。复制由另一个名为加载器的工具完成。加载程序是操作系统的一部分,它知道应该从可执行文件的内容中复制什么以及复制到哪里。将可执行文件加载到主内存后,原始可执行文件不会从硬盘上删除。

程序的加载和运行由操作系统 ( 操作系统)完成。操作系统管理程序的执行,优先于其他程序,完成后卸载,等等。程序的运行副本被称为过程。进程是可执行文件的实例。

理解预处理

一个预处理器旨在处理源文件,使它们准备好编译。预处理器使用预处理器指令,如#define#include等。指令不代表程序语句,但它们是预处理器的命令,告诉它如何处理源文件的文本。编译器无法识别这些指令,因此每当您在代码中使用预处理器指令时,预处理器都会在代码的实际编译开始之前相应地解析它们。例如,以下代码将在编译器开始编译之前被更改:

#define NUMBER 41 
int main() { 
  int a = NUMBER + 1; 
  return 0; 
}

使用#define指令定义的一切都被称为。预处理后,编译器获得以下形式的转换源:

int main() { 
  int a = 41 + 1; 
  return 0;
}

如前所述,预处理器只是处理文本,并不关心语言规则或其语法。使用预处理器指令,尤其是宏定义,就像前面的例子一样,#define NUMBER 41很容易出错,除非你意识到预处理器只是用41 替换NUMBER的任何出现,而没有将41解释为整数。对于预处理器,以下两行都有效:

int b = NUMBER + 1; 
struct T {}; // user-defined type 
T t = NUMBER; // preprocessed successfully, but compile error 

这会产生以下代码:

int b = 41 + 1
struct T {};
T t = 41; // error line

当编译器开始编译时,发现赋值t = 41错误,因为有no viable conversion from 'int' to 'T'

使用语法正确但有逻辑错误的宏甚至是危险的:

#define DOUBLE_IT(arg) (arg * arg) 

预处理器将把DOUBLE_IT(arg)的任何出现替换为(arg * arg),因此下面的代码将输出16:

int st = DOUBLE_IT(4);
std::cout << st;

编译器将收到如下代码:

int st = (4 * 4);
std::cout << st;

当我们使用复杂表达式作为宏参数时,就会出现问题:

int bad_result = DOUBLE_IT(4 + 1); 
std::cout << bad_result;

直觉上,这段代码会产生25,但事实是预处理器除了文本处理什么都不做,在这种情况下,它这样替换宏:

int bad_result = (4 + 1 * 4 + 1);
std::cout << bad_result;

这样输出99显然不是25

若要修正宏定义,请在宏参数周围加上括号:

#define DOUBLE_IT(arg) ((arg) * (arg)) 

现在表达式将采用以下形式:

int bad_result = ((4 + 1) * (4 + 1)); 

强烈建议尽可能使用const声明而不是宏定义。

As a rule of thumb, avoid using macro definitions. Macros are error-prone and C++ provides a set of constructs that make the use of macros obsolete. 

如果我们使用constexpr函数,前面的例子将在编译时进行类型检查和处理:

constexpr int double_it(int arg) { return arg * arg; } 
int bad_result = double_it(4 + 1); 

使用constexpr说明符可以在编译时计算函数的返回值(或变量值)。使用const变量可以更好地重写定义为NUMBER的示例:

const int NUMBER = 41; 

头文件

预处理器最常见的用法是#include指令,旨在将头文件包含在源代码中。头文件包含函数、类等的定义:

// file: main.cpp 
#include <iostream> 
#include "rect.h"
int main() { 
  Rect r(3.1, 4.05) 
  std::cout << r.get_area() << std::endl;
}

假设头文件rect.h定义如下:

// file: rect.h
struct Rect  
{
private:
  double side1_;
  double side2_;
public:
  Rect(double s1, double s2);
  const double get_area() const;
};

实现包含在rect.cpp中:

// file: rect.cpp
#include "rect.h"

Rect::Rect(double s1, double s2)
  : side1_(s1), side2_(s2)
{}

const double Rect::get_area() const {
  return side1_ * side2_;
}

预处理器检查main.cpprect.cpp后,将#include指令替换为main.cppiostreamrect.h以及rect.cpprect.h的相应内容。C++ 17 引入了__has_include预处理器常量表达式。如果找到指定名称的文件,则__has_include评估为1,如果没有,则评估为0:

#if __has_include("custom_io_stream.h")
#include "custom_io_stream.h"
#else
#include <iostream>
#endif

声明头文件时,强烈建议使用所谓的 include-guards ( #ifndef, #define, #endif)避免双重声明错误。我们将很快介绍这项技术。同样,这些是预处理器指令,允许我们避免以下情况:类型Squaresquare*.*h中定义,它包括rect.h,以便从Rect派生Square:

// file: square.h
#include "rect.h"
struct Square : Rect {
  Square(double s);
};

main.cpp中的square.hrect.h都包含在内,导致两次包含rect.h:

// file: main.cpp
#include <iostream> 
#include "rect.h" 
#include "square.h"
/* 
  preprocessor replaces the following with the contents of square.h
*/
// code omitted for brevity

预处理后,编译器会收到如下形式的main.cpp:

// contents of the iostream file omitted for brevity 
struct Rect {
  // code omitted for brevity
};
struct Rect {
  // code omitted for brevity
};
struct Square : Rect {
  // code omitted for brevity
};
int main() {
  // code omitted for brevity
}

然后编译器会产生一个错误,因为它遇到两个类型为Rect的声明。头文件应通过以下方式使用 include-guards 来防止多个包含:

#ifndef RECT_H 
#define RECT_H 
struct Rect { ... }; // code omitted for brevity  
#endif // RECT_H 

预处理器第一次遇到表头时,RECT_H没有定义,#ifndef#endif之间的一切都会相应处理,包括RECT_H定义。预处理器第二次在同一个源文件中包含同一个头文件时,会省略内容,因为RECT_H已经定义好了。

这些 include-guards 是控制部分源文件编译的指令的一部分。所有的条件编译指令都是#if#ifdef#ifndef#else#elif#endif

条件编译在许多情况下是有用的;其中之一就是在所谓的调试模式下记录函数调用。在发布程序之前,建议调试程序并测试逻辑缺陷。您可能希望看到调用某个函数后代码中发生了什么,例如:

void foo() {
  log("foo() called");
  // do some useful job
}
void start() {
  log("start() called");
  foo();
  // do some useful job
}

每个函数调用log()函数,实现如下:

void log(const std::string& msg) {
#if DEBUG
  std::cout << msg << std::endl;
#endif
}

如果定义了DEBUG,则log()功能将打印msg。如果编译启用DEBUG的项目(使用编译器标志,比如 g++ 中的-D,那么log()函数将打印传递给它的字符串;否则,它将一事无成。

在 C++ 20 中使用模块

模块修复了头文件令人讨厌的包含保护问题。我们现在可以去掉预处理宏了。模块包含两个关键词,importexport。要使用一个模块,我们import它。要用导出的属性声明一个模块,我们使用export。在列出使用模块的好处之前,让我们看一个简单的使用示例。下面的代码声明了一个模块:

export module test;

export int twice(int a) { return a * a; }

第一行声明名为test的模块。接下来,我们声明twice()功能并将其设置为export。这意味着我们可以拥有未导出的函数和其他实体,因此,它们在模块之外是私有的。通过导出实体,我们将其设置为module用户。要使用module,我们按照以下代码导入它:

import test;

int main()
{
  twice(21);
}

模块是人们期待已久的 C++ 特性,它在编译和维护方面提供了更好的性能。以下特性使模块在与常规头文件的竞争中更胜一筹:

  • 模块只导入一次,类似于自定义语言实现支持的预编译头。这大大减少了编译时间。未导出的实体对导入模块的翻译单元没有影响。
  • 通过允许您选择哪些单元应该导出,哪些不应该导出,模块允许表达代码的逻辑结构。模块可以捆绑成更大的模块。
  • 摆脱像前面描述的包含防护这样的变通方法。我们可以以任何顺序导入模块。人们不再担心宏观的重新定义。

模块可以和头文件一起使用。我们可以在同一个文件中导入和包含标题,如下例所示:

import <iostream>;
#include <vector>

int main()
{
  std::vector<int> vec{1, 2, 3};
  for (int elem : vec) std::cout << elem;
}

创建模块时,您可以自由导出模块接口文件中的实体,并将实现移动到其他文件中。逻辑与管理.h.cpp文件相同。

理解编译

C++ 编译过程由几个阶段组成。有些阶段旨在分析源代码,而其他阶段则生成并优化目标机器代码。下图显示了编译的各个阶段:

让我们详细看看这些阶段。

标记化

编译器的分析阶段旨在将源代码分成称为标记的小单元。一个标记可以是一个单词,也可以只是一个符号,比如=(等号)。令牌是源代码的最小单位,为编译器携带有意义的值。例如,表达式int a = 42;将被分为代币inta=42;。表达式不仅仅被空格分割,因为下面的表达式被分割成相同的标记(尽管建议不要忘记操作数之间的空格):

int a=42;

使用复杂的方法,使用正则表达式,将源代码拆分成标记。它被称为l****exic analysis,或者tokens 化(分为 token)。对于编译器来说,使用标记化输入提供了一种更好的方式来构建用于分析代码语法的内部数据结构。让我们看看如何。

语法分析

当谈到编程语言编译时,我们通常区分两个术语:语法和语义。语法是代码的结构;它定义了一些规则,通过这些规则,组合起来的标记在结构上是有意义的。例如, day nice 在英语中是一个语法正确的短语,因为它在两个标记中都不包含错误。语义另一方面,关注代码的实际含义。也就是说,美好的一天在语义上是不正确的,应该更正为美好的一天

语法分析是源分析的一个关键部分,因为标记将在语法和语义上进行分析,即它们是否具有符合一般语法规则的任何意义。以下面为例:

int b = a + 0;

这对我们来说可能没有意义,因为给变量加零不会改变它的值,但是编译器不会在这里看逻辑意义——它会寻找代码的语法正确性(缺少分号、缺少右括号等等)。在编译的语法分析阶段检查代码的语法正确性。词法分析将代码分成标记;语法分析检查语法正确性,这意味着如果我们遗漏了一个分号,前面提到的表达式将产生语法错误:

int b = a + 0

g++ 会抱怨expected ';' at end of declaration错误。

语义分析

如果前面的表达式类似于it b = a + 0; ,编译器会将其分成标记itb=等。我们已经看到it是一个未知的东西,但是对于编译器来说,在这一点上是可以的。这会导致 g++ 中的编译错误unknown type name "it"。寻找表达式背后的含义是语义分析(解析)的任务。

中间代码生成

完成所有分析后,编译器生成中间代码,该代码是 C++ 的轻量级版本,主要是 C。

class A { 
public:
  int get_member() { return mem_; }
private: 
  int mem_; 
};

分析代码后,将生成中间代码(这是一个抽象的例子,旨在展示中间代码生成的思想;编译器可能在实现上有所不同):

struct A { 
  int mem_; 
};
int A_get_member(A* this) { return this->mem_; } 

最佳化

生成中间代码有助于编译器对代码进行优化。编译器经常尝试优化代码。优化不止一次完成。例如,下面的代码:

int a = 41; 
int b = a + 1; 

这将在编译过程中进行优化:

int a = 41; 
int b = 41 + 1; 

这将再次优化为以下内容:

int a = 41; 
int b = 42; 

一些程序员毫不怀疑,如今,编译器比程序员写得更好。

机器代码生成

编译器优化在中间代码和生成的机器代码中完成。那么当我们编译项目时是什么样的呢?在本章的前面,当我们讨论源代码的预处理时,我们看到了一个包含几个源文件的简单结构,包括两个头,rect.hsquare.h,每个头都有自己的.cpp文件,以及包含程序入口点(main()函数)的main.cpp 。预处理后,以下单元作为编译器的输入:main.cpprect.cppsquare.cpp、*、*如下图所示:

编译器将分别编译每一个。编译单元,也称为源文件,在某种程度上是相互独立的。编译器编译main.cpp时,调用的是Rect中的get_area()函数,不包括main.cpp中的get_area()实现。相反,它只是确保功能在项目中的某个地方实现。当编译器到达rect*.*cpp时,它不知道get_area()函数在某个地方被使用。

以下是编译器在main.cpp通过预处理阶段后得到的结果:

// contents of the iostream 
struct Rect {
private:
  double side1_;
  double side2_;
public:
  Rect(double s1, double s2);
  const double get_area() const;
};

struct Square : Rect {
  Square(double s);
};

int main() {
  Rect r(3.1, 4.05);
  std::cout << r.get_area() << std::endl;
  return 0;
}

分析main.cpp后,编译器生成如下中间代码(为了简单表达编译背后的思想,省略了很多细节):

struct Rect { 
  double side1_; 
  double side2_; 
};
void _Rect_init_(Rect* this, double s1, double s2); 
double _Rect_get_area_(Rect* this); 

struct Square { 
  Rect _subobject_; 
};
void _Square_init_(Square* this, double s); 

int main() {
  Rect r;
  _Rect_init_(&r, 3.1, 4.05); 
  printf("%d\n", _Rect_get_area(&r)); 
  // we've intentionally replace cout with printf for brevity and 
  // supposing the compiler generates a C intermediate code
  return 0;
}

编译器将在优化代码时移除带有构造函数的Square结构(我们将其命名为_Square_init_,因为它从未在源代码中使用过。

此时,编译器仅使用main.cpp操作,因此它看到我们调用了_Rect_init__Rect_get_area_函数,但没有在同一个文件中提供它们的实现。然而,由于我们事先提供了它们的声明,编译器信任我们,并且相信这些函数是在其他编译单元中实现的。基于这种信任和关于函数签名的最小信息(其返回类型、名称及其参数的数量和类型),编译器生成一个包含main.cpp中的工作代码的目标文件,并以某种方式标记没有实现但被信任稍后解析的函数。解析由链接器完成。

在下面的例子中,我们有生成的对象文件的简化变体,它包含两个部分——代码和信息。代码部分有每个指令的地址(十六进制值):

code: 
0x00 main
 0x01 Rect r; 
  0x02 _Rect_init_(&r, 3.1, 4.05); 
  0x03 printf("%d\n", _Rect_get_area(&r)); 
information:
  main: 0x00
  _Rect_init_: ????
  printf: ????
  _Rect_get_area_: ????

看一下information部分。编译器用????标记代码段中所有未在同一编译单元中找到的函数。这些问号将被链接器在其他单元中找到的函数的实际地址替换。完成main.cpp后,编译器开始编译rect.cpp文件:

// file: rect.cpp 
struct Rect {
  // #include "rect.h" replaced with the contents  
  // of the rect.h file in the preprocessing phase 
  // code omitted for brevity 
};
Rect::Rect(double s1, double s2) 
  : side1_(s1), side2_(s2)
{}
const double Rect::get_area() const { 
  return side1_ * side2_;
} 

遵循这里相同的逻辑,这个单元的编译产生以下输出(别忘了,我们还在提供抽象的例子):

code:  
 0x00 _Rect_init_ 
  0x01 side1_ = s1 
  0x02 side2_ = s2 
  0x03 return 
  0x04 _Rect_get_area_ 
  0x05 register = side1_ 
  0x06 reg_multiply side2_ 
  0x07 return 
information: 
  _Rect_init_: 0x00
  _Rect_get_area_: 0x04 

这个输出包含了所有函数的地址,所以不需要等待一些函数被解析。

平台和目标文件

我们刚才看到的抽象输出有点类似于编译器在编译一个单元后产生的实际目标文件结构。对象文件的结构取决于平台;例如在 Linux 中,ELF 格式表示( ELF 代表可执行可链接格式)。平台是执行程序的环境。在这种情况下,我们所说的平台是指计算机体系结构(更具体地说,指令集体系结构)和操作系统的结合。硬件和操作系统由不同的团队和公司设计和创建。他们每个人对设计问题都有不同的解决方案,这导致了平台之间的重大差异。平台在许多方面有所不同,这些差异也反映在可执行文件的格式和结构上。例如,Windows 系统中的可执行文件格式是可移植可执行文件 ( PE ),它与 Linux 中的 ELF 格式有着不同的结构、编号和节的顺序。

对象文件分为部分。对我们来说最重要的是代码段(标记为.text)和数据段(.data)。 .text部分保存程序指令,.data部分保存指令使用的数据。数据本身可以分为几个部分,如初始化未初始化只读数据。

除了.text.data部分之外,对象文件的一个重要部分是符号表。符号表存储字符串(符号)到对象文件中位置的映射。在前面的例子中,编译器生成的输出有两部分,第二部分被标记为information:,它保存了代码中使用的函数的名称及其相对地址。这个information:是对象文件的实际符号表的抽象版本。符号表保存代码中定义的符号和代码中需要解析的符号。然后链接器使用这些信息将目标文件链接在一起,形成最终的可执行文件。

引入链接

编译器为每个编译单元输出一个目标文件。在前面的例子中,我们有三个.cpp文件,编译器产生了三个目标文件。链接器的任务是将这些目标文件组合成一个目标文件。将文件组合在一起会导致相对地址变化;例如,如果链接器将rect.o文件放在main.o之后,rect.o的起始地址将变为0x04,而不是0x00的前一个值:

code: 
 0x00 main
  0x01 Rect r; 
  0x02 _Rect_init_(&r, 3.1, 4.05); 
  0x03 printf("%d\n", _Rect_get_area(&r)); 
 0x04 _Rect_init_ 
 0x05 side1_ = s1 
 0x06 side2_ = s2 
 0x07 return 
 0x08 _Rect_get_area_ 
 0x09 register = side1_ 
 0x0A reg_multiply side2_ 
 0x0B return 
information (symbol table):
  main: 0x00
  _Rect_init_: 0x04
  printf: ????
  _Rect_get_area_: 0x08 
 _Rect_init_: 0x04
 _Rect_get_area_: 0x08

链接器相应地更新符号表地址(在我们的例子中是information:部分)。如前所述,每个对象文件都有它的符号表,它将符号的字符串名称映射到它在文件中的相对位置(地址)。链接的下一步是解析对象文件中所有未解析的符号。

现在链接器已经将main.orect.o组合在一起,它知道未解析符号的相对位置,因为它们现在位于同一个文件中。printf符号将以相同的方式解析,除了这次它将对象文件与标准库链接。所有的目标文件组合在一起后(为了简洁,我们省略了square.o的链接),所有的地址都被更新,所有的符号都被解析,链接器输出最后一个可以被操作系统执行的目标文件。正如本章前面所讨论的,操作系统使用一种称为加载器的工具将可执行文件的内容加载到内存中。

链接库

库类似于可执行文件,但有一个主要区别:它没有main()函数,这意味着它不能作为常规程序调用。库用于组合可能在多个程序中重用的代码。例如,您已经通过包含<iostream>头将您的程序与标准库链接起来。

库可以作为静态动态库与可执行文件链接。当您将它们链接为静态库时,它们会成为最终可执行文件的一部分。操作系统还应该将动态链接库加载到内存中,以便为您的程序提供调用其函数的能力。假设我们想求一个函数的平方根:

int main() {
  double result = sqrt(49.0);
}

C++ 标准库提供了sqrt()函数,该函数返回其参数的平方根。如果编译前面的例子,会产生一个错误,坚持说sqrt函数没有声明。我们知道,要使用标准库函数,我们应该包含相应的<cmath>头。但是头文件不包含函数的实现;它只是声明了函数(在std命名空间中),然后包含在我们的源文件中:

#include <cmath>
int main() {
  double result = std::sqrt(49.0);
}

编译器将sqrt符号的地址标记为未知,链接器应该在链接阶段解析。如果源文件没有与标准库实现(包含库函数的目标文件)链接,链接器将无法解析它。

如果链接是静态的,链接器生成的最终可执行文件将由我们的程序和标准库组成。另一方面,如果链接是动态的,链接器会在运行时标记要找到的sqrt符号。

现在,当我们运行程序时,加载程序也加载动态链接到我们程序的库。它还将标准库的内容加载到内存中,然后解析sqrt()函数在内存中的实际位置。已经加载到内存中的同一个库也可以被其他程序使用。

摘要

在这一章中,我们谈到了 C++ 20 的许多新特性中的一些,现在准备深入探讨这种语言。我们讨论了构建 C++ 应用的过程及其编译阶段。这包括分析代码以检测语法和语法错误,生成中间代码以进行优化,最后生成目标文件,该文件将与其他生成的目标文件链接在一起以形成最终的可执行文件。

在下一章中,我们将学习 C++ 数据类型、数组和指针。我们还将了解什么是指针,并了解条件句的低级细节。

问题

  1. 编译器和解释器有什么区别?
  2. 列出程序编译阶段。
  3. 预处理器是做什么的?
  4. 链接器的任务是什么?
  5. 静态链接库和动态链接库有什么区别?

进一步阅读

更多信息请参考https://www . Amazon . com/Advanced-C-computing-Milan-斯蒂凡诺维奇/dp/1430266678/ 上的AT2】高级 C 和 C++ 编译

LLVM Essentials,https://www . packtpub . com/application-development/LLVM-Essentials