C++ 系统编程实用指南(二)
原文:
zh.annas-archive.org/md5/F0907D5DE5A0BFF31E8751590DCE27D9译者:飞龙
第四章:C++,RAII 和 GSL 复习
在本章中,我们将概述本书中利用的 C++的一些最新进展。我们将首先概述 C++17 规范中对 C++所做的更改。然后我们将简要介绍一种名为资源获取即初始化(RAII)的 C++设计模式,以及它在 C++中的使用方式以及为什么它对 C++以及许多其他利用相同设计模式的语言如此重要。本章将以介绍指导支持库(GSL)并讨论它如何通过帮助遵守 C++核心指南来增加系统编程的可靠性和稳定性而结束。
在本章中,我们将涵盖以下主题:
-
讨论 C++17 中的进展
-
概述 RAII
-
介绍 GSL
技术要求
为了编译和执行本章中的示例,读者必须具备以下条件:
-
能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)
-
GCC 7+
-
CMake 3.6+
-
互联网连接
要下载本章中的所有代码,包括示例和代码片段,请转到以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter04。
C++17 的简要概述
本节的目标是简要概述 C++17 和添加到 C++的功能。要了解更全面和深入的 C++17,请参阅本章的进一步阅读部分,其中列出了 Packt Publishing 关于该主题的其他书籍。
语言变化
C++17 语言和语法进行了几处更改。以下是一些示例。
if/switch 语句中的初始化器
在 C++17 中,现在可以在if和switch语句的定义中定义变量并初始化,如下所示:
#include <iostream>
int main(void)
{
if (auto i = 42; i > 0) {
std::cout << "Hello World\n";
}
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
在前面的示例中,i变量在if语句内部使用分号(;)进行定义和初始化。这对于返回错误代码的 C 和 POSIX 风格函数特别有用,因为存储错误代码的变量可以在适当的上下文中定义。
这个特性如此重要和有用的原因在于只有在条件满足时才定义变量。也就是说,在前面的示例中,只有当i大于0时,i才存在。
这对确保变量在有效时可用非常有帮助,有助于减少使用无效变量的可能性。
switch语句可以发生相同类型的初始化,如下所示:
#include <iostream>
int main(void)
{
switch(auto i = 42) {
case 42:
std::cout << "Hello World\n";
break;
default:
break;
}
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
在前面的示例中,i变量仅在switch语句的上下文中创建。与if语句不同,i变量存在于所有情况下,这意味着i变量在default状态中可用,这可能代表无效状态。
增加编译时设施
在 C++11 中,constexpr被添加为一种声明,告诉编译器变量、函数等可以在编译时进行评估和优化,从而减少运行时代码的复杂性并提高整体性能。在某些情况下,编译器足够聪明,可以将constexpr语句扩展到其他组件,包括分支语句,例如:
#include <iostream>
constexpr const auto val = true;
int main(void)
{
if (val) {
std::cout << "Hello World\n";
}
}
在这个例子中,我们创建了一个constexpr变量,并且只有在constexpr为true时才将Hello World输出到stdout。由于在这个例子中它总是为真,编译器将完全从代码中删除该分支,如下所示:
push %rbp
mov %rsp,%rbp
lea 0x100(%rip),%rsi
lea 0x200814(%rip),%rdi
callq 6c0 <...cout...>
mov $0x0,%eax
pop %rbp
retq
正如你所看到的,代码加载了一些寄存器并调用std::cout,而没有检查val是否为真,因为编译器完全从生成的二进制代码中删除了该代码。C++11 的问题在于作者可能会假设这种类型的优化正在进行,而实际上可能并没有。
为了防止这种类型的错误,C++17 添加了constexpr if语句,告诉编译器在编译时特别优化分支。如果编译器无法优化if语句,将会发生显式的编译时错误,告诉用户无法进行优化,为用户提供修复问题的机会(而不是假设优化正在进行,实际上可能并没有进行),例如:
#include <iostream>
int main(void)
{
if constexpr (constexpr const auto i = 42; i > 0) {
std::cout << "Hello World\n";
}
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
在前面的例子中,我们有一个更复杂的if语句,它利用了编译时的constexpr优化以及if语句的初始化器。生成的二进制代码如下:
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
movl $0x2a,-0x4(%rbp)
lea 0x104(%rip),%rsi
lea 0x200809(%rip),%rdi
callq 6c0 <...cout...>
mov $0x0,%eax
leaveq
retq
可以看到,结果的二进制代码中已经移除了分支,更具体地说,如果表达式不是常量,编译器会抛出一个错误,说明这段代码无法按照所述进行编译。
应该注意到,这个结果并不是之前的相同二进制代码,可能会有人期望的那样。似乎 GCC 7.3 在其优化引擎中还有一些额外的改进,因为在这段代码中定义和初始化的constexpr i变量没有被移除(当代码中并不需要为i分配栈空间时)。
另一个编译时的变化是static_assert编译时函数的不同版本。在 C++11 中,添加了以下内容:
#include <iostream>
int main(void)
{
static_assert(42 == 42, "the answer");
}
// > g++ scratchpad.cpp; ./a.out
//
static_assert函数的目标是确保某些编译时的假设是正确的。当编写系统时,这是特别有帮助的,比如确保一个结构体的大小是特定的字节数,或者根据你正在编译的系统来确保某个代码路径被执行。这个断言的问题在于它需要添加一个在编译时输出的描述,这个描述可能只是用英语描述了断言而没有提供任何额外的信息。在 C++17 中,添加了另一个版本的这个断言,它去掉了对描述的需求,如下所示:
#include <iostream>
int main(void)
{
static_assert(42 == 42);
}
// > g++ scratchpad.cpp; ./a.out
//
命名空间
C++17 中一个受欢迎的变化是添加了嵌套命名空间。在 C++17 之前,嵌套命名空间必须在不同的行上定义,如下所示:
#include <iostream>
namespace X
{
namespace Y
{
namespace Z
{
auto msg = "Hello World\n";
}
}
}
int main(void)
{
std::cout << X::Y::Z::msg;
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
在前面的例子中,我们定义了一个在嵌套命名空间中输出到stdout的消息。这种语法的问题是显而易见的——它占用了大量的空间。在 C++17 中,通过在同一行上声明嵌套命名空间来消除了这个限制,如下所示:
#include <iostream>
namespace X::Y::Z
{
auto msg = "Hello World\n";
}
int main(void)
{
std::cout << X::Y::Z::msg;
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
在前面的例子中,我们能够定义一个嵌套的命名空间,而不需要单独的行。
结构化绑定
我对 C++17 的一个最喜欢的新增功能是结构化绑定。在 C++17 之前,复杂的结构,比如结构体或std::pair,可以用来作为函数输出的多个值,但语法很繁琐,例如:
#include <utility>
#include <iostream>
std::pair<const char *, int>
give_me_a_pair()
{
return {"The answer is: ", 42};
}
int main(void)
{
auto p = give_me_a_pair();
std::cout << std::get<0>(p) << std::get<1>(p) << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// The answer is: 42
在前面的例子中,give_me_a_pair()函数返回一个带有The answer is:字符串和整数42的std::pair。这个函数的结果存储在main函数中的一个名为p的变量中,需要使用std::get()来获取std::pair的第一部分和第二部分。这段代码在没有进行积极的优化时既笨拙又低效,因为需要额外的函数调用来获取give_me_a_pair()的结果。
在 C++17 中,结构化绑定为我们提供了一种检索结构体或std::pair的各个字段的方法,如下所示:
#include <iostream>
std::pair<const char *, int>
give_me_a_pair()
{
return {"The answer is: ", 42};
}
int main(void)
{
auto [msg, answer] = give_me_a_pair();
std::cout << msg << answer << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// The answer is: 42
在前面的例子中,give_me_a_pair()函数返回与之前相同的std::pair,但这次我们使用了结构化绑定来获取give_me_a_pair()的结果。msg和answer变量被初始化为std::pair的结果,为我们提供了直接访问结果的方式,而不需要使用std::get()。
同样的也适用于结构体,如下所示:
#include <iostream>
struct mystruct
{
const char *msg;
int answer;
};
mystruct
give_me_a_struct()
{
return {"The answer is: ", 42};
}
int main(void)
{
auto [msg, answer] = give_me_a_struct();
std::cout << msg << answer << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// The answer is: 42
在前面的示例中,我们创建了一个由give_me_a_struct()返回的结构。使用结构化绑定获取此函数的结果,而不是使用std::get()。
内联变量
C++17 中更具争议的一个新增功能是内联变量的包含。随着时间的推移,越来越多的仅头文件库由 C++社区的各个成员开发。这些库提供了在 C++中提供复杂功能的能力,而无需安装和链接到库(只需包含库即可)。这些类型的库的问题在于它们必须在库本身中使用花哨的技巧来包含全局变量。
内联变量解决了这个问题,如下所示:
#include <iostream>
inline auto msg = "Hello World\n";
int main(void)
{
std::cout << msg;
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
在前面的示例中,msg变量被声明为inline。这种类型的变量可以在头文件(即.h文件)中定义,并且可以多次包含而不会在链接期间定义多个定义。值得注意的是,内联变量还消除了对以下内容的需求:
extern const char *msg;
通常,多个源文件需要一个全局变量,并且使用前述模式将变量暴露给所有这些源文件。前面的代码添加到一个由所有源文件包含的头文件中,然后一个源文件实际上定义变量,例如:
const char *msg = "Hello World\n";
尽管这种方法有效,但它很麻烦,而且并不总是清楚哪个源文件实际上应该定义变量。使用内联变量可以解决这个问题,因为头文件既定义了变量,又将符号暴露给所有需要它的源文件,消除了歧义。
库的更改
除了对语言语法的更改,还对库进行了一些更改。以下是一些显著的更改。
字符串视图
正如本章的GSL部分将讨论的那样,C++社区内部正在推动消除对指针和数组的直接访问。在应用程序中发现的大多数段错误和漏洞都可以归因于对指针和数组的处理不当。随着程序变得越来越复杂,并由多人修改而没有完整了解应用程序及其如何使用每个指针和/或数组的情况,引入错误的可能性也会增加。
为了解决这个问题,C++社区已经采纳了 C++核心指南:github.com/isocpp/CppCoreGuidelines。
C++核心指南的目标是定义一组最佳实践,以帮助防止在使用 C++编程时出现的常见错误,以限制引入程序的总错误数量。 C++已经存在多年了,尽管它有很多设施来防止错误,但它仍然保持向后兼容性,允许旧程序与新程序共存。 C++核心指南帮助新用户和专家用户浏览可用的许多功能,以帮助创建更安全和更健壮的应用程序。
C++17 中为支持这一努力添加的一个功能是std::string_view{}类。std::string_view是字符数组的包装器,类似于std::array,有助于使使用基本 C 字符串更安全和更容易,例如:
#include <iostream>
#include <string_view>
int main(void)
{
std::string_view str("Hello World\n");
std::cout << str;
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
在前面的示例中,我们创建了std::string_view{}并将其初始化为 ASCII C 字符串。然后使用std::cout将字符串输出到stdout。与std::array一样,std::string_view{}提供了对基础数组的访问器,如下所示:
#include <iostream>
#include <string_view>
int main(void)
{
std::string_view str("Hello World");
std::cout << str.front() << '\n';
std::cout << str.back() << '\n';
std::cout << str.at(1) << '\n';
std::cout << str.data() << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// H
// d
// e
// Hello World
在上面的例子中,front()和back()函数可用于获取字符串中的第一个和最后一个字符,而at()函数可用于获取字符串中的任何字符;如果索引超出范围(即,提供给at()的索引比字符串本身还长),则会抛出std::out_of_range{}异常。最后,data()函数可用于直接访问底层数组。不过,应谨慎使用此函数,因为其使用会抵消std::string_view{}的安全性好处。
除了访问器之外,std::string_view{}类还提供了有关字符串大小的信息:
#include <iostream>
#include <string_view>
int main(void)
{
std::string_view str("Hello World");
std::cout << str.size() << '\n';
std::cout << str.max_size() << '\n';
std::cout << str.empty() << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// 11
// 4611686018427387899
// 0
在上面的例子中,size()函数返回字符串中的字符总数,而empty()函数在size() == 0时返回true,否则返回false。max_size()函数定义了std::string_view{}可以容纳的最大大小,在大多数情况下是无法实现或现实的。在上面的例子中,最大字符串大小超过一百万兆字节。
与std::array不同,std::string_view{}提供了通过从字符串的前面或后面删除字符来减小字符串视图的能力,如下所示:
#include <iostream>
#include <string_view>
int main(void)
{
std::string_view str("Hello World");
str.remove_prefix(1);
str.remove_suffix(1);
std::cout << str << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// ello Worl
在上面的例子中,remove_prefix()和remove_suffix()函数用于从字符串的前面和后面各删除一个字符,结果是将ello Worl输出到stdout。需要注意的是,这只是改变了起始字符并重新定位了结束的空字符指针,而无需重新分配内存。对于更高级的功能,应该使用std::string{},但这会带来额外的内存分配性能损失。
也可以按如下方式访问子字符串:
#include <iostream>
#include <string_view>
int main(void)
{
std::string_view str("Hello World");
std::cout << str.substr(0, 5) << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// Hello
在上面的例子中,我们使用substr()函数访问Hello子字符串。
也可以比较字符串:
#if SNIPPET13
#include <iostream>
#include <string_view>
int main(void)
{
std::string_view str("Hello World");
if (str.compare("Hello World") == 0) {
std::cout << "Hello World\n";
}
std::cout << str.compare("Hello") << '\n';
std::cout << str.compare("World") << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
// 6
// -1
与strcmp()函数类似,比较函数在比较两个字符串时返回0,而它们不同时返回差异。
最后,搜索函数如下所示:
#include <iostream>
int main(void)
{
std::string_view str("Hello this is a test of Hello World");
std::cout << str.find("Hello") << '\n';
std::cout << str.rfind("Hello") << '\n';
std::cout << str.find_first_of("Hello") << '\n';
std::cout << str.find_last_of("Hello") << '\n';
std::cout << str.find_first_not_of("Hello") << '\n';
std::cout << str.find_last_not_of("Hello") << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// 0
// 24
// 0
// 33
// 5
// 34
这个例子的结果如下:
-
find()函数返回字符串中第一次出现Hello的位置,这种情况下是0。 -
rfind()返回提供的字符串的最后出现位置,在这种情况下是24。 -
find_first_of()和find_last_of()找到提供的任何字符的第一个和最后一个出现位置(而不是整个字符串)。在这种情况下,H在提供的字符串中,而H是msg中的第一个字符,这意味着find_first_of()返回0,因为0是字符串中的第一个索引。 -
在
find_last_of()中,l是最后出现的字母,位置在33。 -
find_first_not_of()和find_last_not_of()是find_first_of()和find_last_of()的相反,返回提供的字符串中任何字符的第一个和最后一个出现位置。
std::any,std::variant 和 std::optional
C++17 中的其他受欢迎的新增功能是std::any{},std::variant{}和std::optional{}类。std::any{}能够随时存储任何值。需要特殊的访问器来检索std::any{}中的数据,但它们能够以类型安全的方式保存任何值。为了实现这一点,std::any{}利用了内部指针,并且每次更改类型时都必须分配内存,例如:
#include <iostream>
#include <any>
struct mystruct {
int data;
};
int main(void)
{
auto myany = std::make_any<int>(42);
std::cout << std::any_cast<int>(myany) << '\n';
myany = 4.2;
std::cout << std::any_cast<double>(myany) << '\n';
myany = mystruct{42};
std::cout << std::any_cast<mystruct>(myany).data << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// 42
// 4.2
// 42
在上面的例子中,我们创建了std::any{}并将其设置为具有值42的int,具有值4.2的double,以及具有值42的struct。
std::variant更像是一个类型安全的联合。联合在编译时为联合中存储的所有类型保留存储空间(因此不需要分配,但是所有可能的类型必须在编译时已知)。标准 C 联合的问题在于无法知道任何给定时间存储的是什么类型。同时存储 int 和double是有问题的,因为同时使用两者会导致损坏。使用std::variant可以避免这种问题,因为std::variant知道它当前存储的是什么类型,并且不允许尝试以不同类型访问数据(因此,std::variant是类型安全的),例如:
#include <iostream>
#include <variant>
int main(void)
{
std::variant<int, double> v = 42;
std::cout << std::get<int>(v) << '\n';
v = 4.2;
std::cout << std::get<double>(v) << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// 42
// 4.2
在前面的例子中,std::variant被用来存储integer和double,我们可以安全地从std::variant中检索数据而不会损坏。
std::optional是一个可空的值类型。指针是一个可空的引用类型,其中指针要么无效,要么有效并存储一个值。要创建一个指针值,必须分配内存(或者至少指向内存)。std::optional是一个值类型,这意味着不需要为std::optional分配内存,并且在底层,只有在可选项有效时才执行构造,消除了在实际未设置时构造默认值类型的开销。对于复杂对象,这不仅提供了确定对象是否有效的能力,还允许我们在无效情况下跳过构造,从而提高性能,例如:
#include <iostream>
#include <optional>
class myclass
{
public:
int val;
myclass(int v) :
val{v}
{
std::cout << "constructed\n";
}
};
int main(void)
{
std::optional<myclass> o;
std::cout << "created, but not constructed\n";
if (o) {
std::cout << "Attempt #1: " << o->val << '\n';
}
o = myclass{42};
if (o) {
std::cout << "Attempt #2: " << o->val << '\n';
}
}
// > g++ scratchpad.cpp; ./a.out
// created, but not constructed
// constructed
// Attempt #2: 42
在前面的例子中,我们创建了一个简单的类,用于存储一个integer。在这个类中,当类被构造时,我们向 stdout 输出一个字符串。然后我们使用std::optional创建了这个类的一个实例。我们尝试在实际设置类为有效值之前和之后访问这个std::optional。如所示,只有在我们实际设置类为有效值之后,类才被构造。由于sts::unique_ptr曾经是创建 optionals 的常用方法,因此std::optional共享一个常用的接口并不奇怪。
资源获取即初始化(RAII)
RAII 可以说是 C 和 C++之间最显著的区别之一。RAII 为整个 C++库奠定了基础和设计模式,并且已经成为无数其他语言的灵感之源。这个简单的概念为 C++提供了无与伦比的安全性,与 C 相比,这个概念将在本书中被充分利用,当 C 和 POSIX 必须用于替代 C++时(例如,当 C++的替代方案要么不存在,要么不完整时)。
RAII 的理念很简单。如果分配了资源,它是在对象构造期间分配的,当对象被销毁时,资源被释放。为了实现这一点,RAII 利用了 C++的构造和销毁特性,例如:
#include <iostream>
class myclass
{
public:
myclass()
{
std::cout << "Hello from constructor\n";
}
~myclass()
{
std::cout << "Hello from destructor\n";
}
};
int main(void)
{
myclass c;
}
// > g++ scratchpad.cpp; ./a.out
// Hello from constructor
// Hello from destructor
在前面的例子中,我们创建了一个在构造和销毁时向stdout输出的类。如所示,当类被实例化时,类被构造,当类失去焦点时,类被销毁。
这个简单的概念可以用来保护资源,如下所示:
#include <iostream>
class myclass
{
int *ptr;
public:
myclass() :
ptr{new int(42)}
{ }
~myclass()
{
delete ptr;
}
int get()
{
return *ptr;
}
};
int main(void)
{
myclass c;
std::cout << "The answer is: " << c.get() << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// The answer is: 42
在前面的例子中,当myclass{}被构造时,分配了一个指针,并且当myclass{}被销毁时,指针被释放。这种模式提供了许多优势:
-
只要
myclass{}的实例可见(即可访问),指针就是有效的。因此,任何尝试访问类中的内存都是安全的,因为只有在类的范围丢失时才会释放内存,这将导致无法访问类(假设没有使用指向类的指针和引用)。 -
不会发生内存泄漏。如果类可见,类分配的内存将是有效的。一旦类不再可见(即失去范围),内存就会被释放,不会发生泄漏。
具体来说,RAII 确保在对象初始化时获取资源,并在不再需要对象时释放资源。正如稍后将在第七章中展示的那样,std::unique_ptr[]和std::shared_ptr{}利用了这种精确的设计模式(尽管,这些类不仅仅是上面的例子,还要求在获取资源的同时确保所有权)。
RAII 不仅适用于指针;它可以用于必须获取然后释放的任何资源,例如:
#include <iostream>
class myclass
{
FILE *m_file;
public:
myclass(const char *filename) :
m_file{fopen(filename, "rb")}
{
if (m_file == 0) {
throw std::runtime_error("unable to open file");
}
}
~myclass()
{
fclose(m_file);
std::clog << "Hello from destructor\n";
}
};
int main(void)
{
myclass c1("test.txt");
try {
myclass c2("does_not_exist.txt");
}
catch(const std::exception &e) {
std::cout << "exception: " << e.what() << '\n';
}
}
// > g++ scratchpad.cpp; touch test.txt; ./a.out
// exception: unable to open file
// Hello from destructor
在前面的例子中,我们创建了一个在构造时打开文件并存储其句柄,然后在销毁时关闭文件并释放句柄的类。在主函数中,我们创建了一个类的实例,它既被构造又被正常销毁,利用 RAII 来防止文件泄漏。
除了正常情况外,我们创建了第二个类,试图打开一个不存在的文件。在这种情况下,会抛出异常。这里需要注意的重要一点是,对于这个第二个实例,析构函数不会被调用。这是因为构造失败并抛出了异常。因此,没有获取资源,因此也不需要销毁。也就是说,资源的获取直接与类本身的初始化相关联,而安全地构造类可以防止销毁从未分配的资源。
RAII 是 C++的一个简单而强大的特性,在 C++中被广泛利用,这种设计模式将在本书中进行扩展。
指导支持库(GSL)
如前所述,C++核心指南的目标是提供与 C++编程相关的最佳实践。GSL 是一个旨在帮助遵守这些指南的库。总的来说,GSL 有一些整体主题:
-
指针所有权:定义谁拥有指针是防止内存泄漏和指针损坏的简单方法。一般来说,定义所有权的最佳方法是通过使用
std::unique_ptr{}和std::shared_ptr{},这将在第七章中深入解释,但在某些情况下,这些不能使用,GSL 有助于处理这些边缘情况。 -
期望管理:GSL 还有助于定义函数对输入的期望和对输出的保证,目标是将这些概念转换为 C++合同。
-
没有指针算术:指针算术是程序不稳定和易受攻击的主要原因之一。消除指针算术(或者至少将指针算术限制在经过充分测试的支持库中)是消除这些问题的简单方法。
指针所有权
经典的 C++不区分谁拥有指针(即负责释放与指针关联的内存的代码或对象)和谁只是使用指针访问内存,例如:
void init(int *p)
{
*p = 0;
}
int main(void)
{
auto p = new int;
init(p);
delete p;
}
// > g++ scratchpad.cpp; ./a.out
//
在前面的例子中,我们分配了一个指向整数的指针,然后将该指针传递给一个名为init()的函数,该函数初始化指针。最后,在init()函数使用完指针后,我们删除了指针。如果init()函数位于另一个文件中,就不清楚init()函数是否应该删除指针。尽管在这个简单的例子中,这可能是显而易见的,但在有大量代码的复杂项目中,这种意图可能会丢失。对这样的代码进行未来修改可能会导致使用未定义所有权的指针。
为了克服这一点,GSL 提供了一个gsl::owner<>修饰,用于记录给定变量是否是指针的所有者,例如:
#include <gsl/gsl>
void init(int *p)
{
*p = 0;
}
int main(void)
{
gsl::owner<int *> p = new int;
init(p);
delete p;
}
// > g++ scratchpad.cpp; ./a.out
//
在前面的例子中,我们记录了main函数中的p是指针的所有者,这意味着一旦p不再需要,指针应该被释放。前面例子中的另一个问题是init()函数期望指针不为空。如果指针为空,将发生空指针解引用。
有两种常见的方法可以克服空指针解引用的可能性。第一种选择是检查nullptr并抛出异常。这种方法的问题在于你必须在每个函数上执行这个空指针检查。这些类型的检查成本高,而且会使代码混乱。另一个选择是使用gsl::not_null<>{}类。像gsl::owner<>{}一样,gsl::not_null<>{}是一个装饰,可以在不使用调试时从代码中编译出来。然而,如果启用了调试,gsl::not_null<>{}将抛出异常,abort(),或者在某些情况下,如果变量设置为 null,拒绝编译。使用gsl::not_null<>{},函数可以明确说明是否允许和安全处理空指针,例如:
#include <gsl/gsl>
gsl::not_null<int *>
test(gsl::not_null<int *> p)
{
return p;
}
int main(void)
{
auto p1 = std::make_unique<int>();
auto p2 = test(gsl::not_null(p1.get()));
}
// > g++ scratchpad.cpp; ./a.out
//
在前面的例子中,我们使用std::unique_ptr{}创建了一个指针,然后将得到的指针传递给一个名为test()的函数。test()函数不支持空指针,因此使用gsl::not_null<>{}来表示这一点。反过来,test()函数返回gsl::not_null<>{},告诉用户test()函数确保函数的结果不为空(这也是为什么test函数一开始不支持空指针的原因)。
指针算术
指针算术是导致不稳定和易受攻击的常见错误源。因此,C++核心指南不鼓励使用这种类型的算术。以下是一些指针算术的例子:
int array[10];
auto r1 = array + 1;
auto r2 = *(array + 1);
auto r3 = array[1];
最后一个例子可能是最令人惊讶的。下标运算符实际上是指针算术,其使用可能导致越界错误。为了克服这一点,GSL 提供了gsl::span{}类,为我们提供了一个安全的接口,用于处理指针,包括数组,例如:
#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>
int main(void)
{
int array[5] = {1, 2, 3, 4, 5};
auto span = gsl::span(array);
for (const auto &elem : span) {
std::clog << elem << '\n';
}
for (auto i = 0; i < 5; i++) {
std::clog << span[i] << '\n';
}
try {
std::clog << span[5] << '\n';
}
catch(const gsl::fail_fast &e) {
std::cout << "exception: " << e.what() << '\n';
}
}
// > g++ scratchpad.cpp; ./a.out
// 1
// 2
// 3
// 4
// 5
// 1
// 2
// 3
// 4
// 5
// exception: GSL: Precondition failure at ...
让我们看看前面的例子是如何工作的:
-
我们创建一个数组,并用一组整数初始化它。
-
我们创建一个 span,以便可以安全地与数组交互。我们使用基于范围的
for循环(因为 span 包括一个迭代器接口)将数组输出到stdout。 -
我们使用传统的索引和下标运算符(即
[]运算符)将数组第二次输出到stdout。这个下标运算符的不同之处在于每个数组访问都会检查是否越界。为了证明这一点,我们尝试访问数组越界,gsl::span{}抛出了一个gsl::fail_fast{}异常。应该注意的是,GSL_THROW_ON_CONTRACT_VIOLATION用于告诉 GSL 抛出异常,而不是执行std::terminate或完全忽略边界检查。
除了gsl::span{}之外,GSL 还包含gsl::span{}的特殊化,这些特殊化在处理常见类型的数组时对我们有所帮助。例如,GSL 提供了gsl::cstring_span{},如下所示:
#include <gsl/gsl>
#include <iostream>
int main(void)
{
gsl::cstring_span<> str = gsl::ensure_z("Hello World\n");
std::cout << str.data();
for (const auto &elem : str) {
std::clog << elem;
}
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
gsl::cstring_span{}是一个包含标准 C 风格字符串的gsl::span{}。在前面的例子中,我们使用gsl::ensure_z()函数将gsl::cstring_span{}加载到标准 C 风格字符串中,以确保字符串在继续之前以空字符结尾。然后我们使用常规的std::cout调用和使用基于范围的循环输出标准 C 风格字符串。
合同
C++合同为用户提供了一种说明函数期望的输入以及函数确保的输出的方法。具体来说,C++合同记录了 API 的作者和 API 的用户之间的合同,并提供了对该合同的编译时和运行时验证。
未来的 C++版本将内置支持合同,但在此之前,GSL 通过提供expects()和ensures()宏的库实现了 C++合同,例如:
#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>
int main(void)
{
try {
Expects(false);
}
catch(const gsl::fail_fast &e) {
std::cout << "exception: " << e.what() << '\n';
}
}
// > g++ scratchpad.cpp; ./a.out
// exception: GSL: Precondition failure at ...
在前面的例子中,我们使用Expects()宏并将其传递为false。与标准 C 库提供的assert()函数不同,Expects()宏在false时失败。与assert()不同,即使在禁用调试时,如果传递给Expects()的表达式求值为false,Expects()也将执行std::terminate()。在前面的例子中,我们声明Expects()应该抛出gsl::fail_fast{}异常,而不是执行std::terminate()。
Ensures()宏与Expects()相同,唯一的区别是名称,用于记录合同的输出而不是输入,例如:
#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>
int
test(int i)
{
Expects(i >= 0 && i < 41);
i++;
Ensures(i < 42);
return i;
}
int main(void)
{
test(0);
try {
test(42);
}
catch(const gsl::fail_fast &e) {
std::cout << "exception: " << e.what() << '\n';
}
}
// > g++ scratchpad.cpp; ./a.out
// exception: GSL: Precondition failure at ...
在前面的例子中,我们创建了一个函数,该函数期望输入大于或等于0且小于41。然后函数对输入进行操作,并确保结果输出始终小于42。一个正确编写的函数将定义其期望,以便Ensures()宏永远不会触发。相反,如果输入导致输出违反合同,则Expects()检查可能会触发。
实用程序
GSL 还提供了一些有用的辅助工具,有助于创建更可靠和可读的代码。其中一个例子是gsl::finally{}API,如下:
#define concat1(a,b) a ## b
#define concat2(a,b) concat1(a,b)
#define ___ concat2(dont_care, __COUNTER__)
#include <gsl/gsl>
#include <iostream>
int main(void)
{
auto ___ = gsl::finally([]{
std::cout << "Hello World\n";
});
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
gsl::finally{}提供了一种简单的方法,在函数退出之前执行代码,利用 C++析构函数。当函数必须在退出之前执行清理时,这是有帮助的。应该注意,gsl::finally{}在存在异常时最有用。通常,当触发异常时,清理代码被遗忘,导致清理逻辑永远不会执行。gsl::finally{} API 将始终执行,即使发生异常,只要它在执行可能生成异常的操作之前定义。
在前面的代码中,我们还包括了一个有用的宏,允许使用___来定义要使用的gsl::finally{}的名称。具体来说,gsl::finally{}的用户必须存储gsl::finally{}对象的实例,以便在退出函数时销毁该对象,但是必须命名gsl::finally{}对象是繁琐且无意义的,因为没有 API 与gsl::finally{}对象交互(它的唯一目的是在exit时执行)。这个宏提供了一种简单的方式来表达,“我不在乎变量的名称是什么”。
GSL 提供的其他实用程序包括gsl::narrow<>()和gsl::narrow_cast<>(),例如:
#include <gsl/gsl>
#include <iostream>
int main(void)
{
uint64_t val = 42;
auto val1 = gsl::narrow<uint32_t>(val);
auto val2 = gsl::narrow_cast<uint32_t>(val);
}
// > g++ scratchpad.cpp; ./a.out
//
这两个 API 与常规的static_cast<>()相同,唯一的区别是gsl::narrow<>()执行溢出检查,而gsl::narrow_cast<>()只是static_cast<>()的同义词,用于记录整数的缩小(即将具有更多位的整数转换为具有较少位的整数)。
#endif
#if SNIPPET30
#define GSL_THROW_ON_CONTRACT_VIOLATION
#include <gsl/gsl>
#include <iostream>
int main(void)
{
uint64_t val = 0xFFFFFFFFFFFFFFFF;
try {
gsl::narrow<uint32_t>(val);
}
catch(...) {
std::cout << "narrow failed\n";
}
}
// > g++ scratchpad.cpp; ./a.out
// narrow failed
在前面的例子中,我们尝试使用gsl::narrow<>()函数将 64 位整数转换为 32 位整数,该函数执行溢出检查。由于发生了溢出,抛出了异常。
总结
在本章中,我们概述了本书中使用的 C++的一些最新进展。我们从 C++17 规范中对 C++所做的更改开始。然后我们简要介绍了一个称为 RAII 的 C++设计模式,以及它如何被 C++使用。最后,我们介绍了 GSL 以及它如何通过帮助遵守 C++核心指南来增加系统编程的可靠性和稳定性。
在下一章中,我们将介绍 UNIX 特定的主题,如 UNIX 进程和信号,以及 System V 规范的全面概述,该规范用于定义如何在 Intel CPU 上为 UNIX 编写程序。
问题
-
什么是结构化绑定?
-
C++17 对嵌套命名空间做了哪些改变?
-
C++17 对
static_assert()函数做了哪些改变? -
什么是
if语句的初始化器? -
RAII 代表什么?
-
RAII 用于什么?
-
gsl::owner<>{}有什么作用? -
Expects()和Ensures()的目的是什么?
进一步阅读
第五章:编程 Linux/Unix 系统
本章的目标是解释在 Linux/Unix 系统上编程的基础知识。这将提供一个更完整的图景,说明程序在 Unix/Linux 系统上如何执行,如何编写更高效的代码,以及在出现难以找到的错误时应该去哪里寻找。
为此,本章首先全面审视 Linux ABI,或者更具体地说,System V ABI。在本节中,我们将从寄存器和栈布局到 System V 调用约定和 ELF 二进制对象规范进行全面审查。
下一节将简要介绍 Linux 文件系统,包括标准布局和权限。然后,我们将全面审查 Unix 进程以及如何对它们进行编程,包括考虑到创建新进程和进程间通信等方面。
最后,本章将简要概述基于 Unix 的信号以及如何处理它们(发送和接收)。
在本章中,我们将讨论以下内容:
-
Linux ABI
-
Unix 文件系统
-
Unix 进程 API
-
Unix 信号 API
技术要求
为了跟随本章中的示例,您必须具备以下条件:
-
一个能够编译和执行 C++17 的基于 Linux 的系统(例如,Ubuntu 17.10+)
-
GCC 7+
-
CMake 3.6+
-
互联网连接
要下载本章中的所有代码,包括示例和代码片段,请转到以下链接:github.com/PacktPublishing/Hands-On-System-Programming-with-CPP/tree/master/Chapter05。
Linux ABI
在本节中,我们将讨论 Linux ABI(实际上称为System V ABI),以及 ELF 标准及其在 Linux/Unix 中的使用。
我们还将深入探讨与 ELF 文件相关的一些细节,如何读取和解释它们,以及 ELF 文件中特定组件的一些含义。
System V ABI
Unix System V 是最早可用的 Unix 版本之一,并在很大程度上定义了多年的 Unix。在内部,System V 利用了 System V ABI。随着 Linux 和 BSD(类 Unix 操作系统)的广泛使用,System V 的流行度下降了。然而,System V ABI 仍然很受欢迎,因为诸如 Linux 之类的操作系统采用了这一规范用于基于 Intel 的个人电脑。
在本章中,我们将重点关注 Linux 操作系统上 Intel 平台的 System V ABI。然而,需要注意的是,其他架构和操作系统可能使用不同的 ABI。例如,ARM 有自己的 ABI,它在很大程度上基于 System V(奇怪的是,还有 Itanium 64 规范),但有一些关键的区别。
本节的目标是揭示单个 Unix ABI 的内部工作原理,从而使必要时学习其他 ABI 更容易。
本章讨论的大部分规范可以在以下链接找到:refspecs.linuxfoundation.org/。
System V ABI 定义了程序的大部分低级细节(从而定义了系统编程的接口),包括:
-
寄存器布局
-
栈帧
-
函数前言和尾声
-
调用约定(即参数传递)
-
异常处理
-
虚拟内存布局
-
调试
-
二进制对象格式(在本例中为 ELF)
-
程序加载和链接
在第二章中,学习 C、C++17 和 POSIX 标准,我们讨论了程序链接和动态加载的细节,并专门讨论了二进制对象格式(ELF)。
以下是关于 Intel 64 位架构的 System V 规范的其余细节的简要描述。
寄存器布局
为了简化这个话题,我们将专注于英特尔 64 位。每个 ABI、操作系统和架构组合的不同寄存器布局都可以写成一本书。
英特尔 64 位架构(通常称为 AMD64,因为 AMD 实际上编写了它)定义了几个寄存器,其中一些在指令集中有定义的含义。
指令指针rip定义了程序在可执行内存中的当前位置。具体来说,当程序执行时,它从rip中存储的位置执行,并且每次指令执行完毕,rip都会前进到下一条指令。
堆栈指针和基指针(分别为rsp和rbp)用于定义堆栈中的当前位置,以及堆栈帧的开始位置(我们将在后面提供更多信息)。
以下是剩余的通用寄存器。它们有不同的含义,将在本节的其余部分中讨论:rax、rbx、rcx、rdx、rdi、rsi、r8、r9、r10、r11、r12、r13、r14和r15。
在继续之前,应该指出系统上还定义了几个具有非常具体目的的寄存器,包括浮点寄存器和宽寄存器(这些寄存器由专门设计用于加速某些类型计算的特殊指令使用;例如,SSE 和 AVX)。这些超出了本讨论的范围。
最后,一些寄存器以字母结尾,而另一些以数字结尾,因为英特尔的 x86 处理器版本只有基于字母的寄存器,真正的通用寄存器只有 AX、BX、CX 和 DX。
当 AMD 引入 64 位时,通用寄存器的数量翻了一番,为了保持简单,寄存器名称被赋予了数字。
堆栈帧
堆栈帧用于存储每个函数的返回地址,并存储函数参数和基于堆栈的变量。它是所有程序都大量使用的资源,它采用以下形式:
high |----------| <- top of stack
| |
| Used |
| |
|----------| <- Current frame (rbp)
| | <- Stack pointer (rsp)
|----------|
| |
| Unused |
| |
low |----------|
堆栈帧只不过是一个从顶部向底部增长的内存数组。也就是说,在英特尔 PC 上,向堆栈推送会从堆栈指针中减去,而从堆栈弹出会向堆栈指针中加上,这意味着内存实际上是向下增长的(假设您的观点是随着地址增加,内存向上增长,就像前面的图表中一样)。
System V ABI 规定堆栈由堆栈帧组成。每个帧看起来像下面这样:
high |----------|
| .... |
|----------|
| arg8 |
|----------|
| arg7 |
|----------|
| ret addr |
|----------| <- Stack pointer (rbp)
| |
low |----------|
每个帧代表一个函数调用,并以超过前六个参数的任何参数开始调用函数(前六个参数作为寄存器传递,这将在后面更详细地讨论)。最后,返回地址被推送到堆栈中,然后调用函数。
返回地址后的内存属于函数本身范围内的变量。这就是为什么我们称在函数中定义的变量为基于堆栈的变量。剩下的堆栈将被未来将要调用的函数使用。每当一个函数调用另一个函数时,堆栈就会增长,而每当一个函数返回时,堆栈就会缩小。
操作系统的工作是管理堆栈的大小,确保它始终有足够的内存。例如,如果应用程序尝试使用太多内存,操作系统将终止该程序。
最后,应该指出,在大多数 CPU 架构上,提供了特殊指令,用于从函数调用返回并自动弹出堆栈的返回地址。在英特尔的情况下,call指令将跳转到一个函数并将当前的rip推送到堆栈作为返回地址,然后ret将从堆栈中弹出返回地址并跳转到被弹出的地址。
函数前言和尾声
每个函数都带有一个堆栈帧,如前所述,存储函数参数、函数变量和返回地址。管理这些资源的代码称为函数的前导(开始)和结尾(结束)。
为了更好地解释这一点,让我们创建一个简单的例子并检查生成的二进制文件:
int test()
{
int i = 1;
int j = 2;
return i + j;
}
int main(void)
{
test();
}
// > g++ scratchpad.cpp; ./a.out
//
如果我们反汇编生成的二进制文件,我们得到以下结果:
...
00000000000005fa <_Z4testv>:
push %rbp
mov %rsp,%rbp
movl $0x1,-0x8(%rbp)
movl $0x2,-0x4(%rbp)
mov -0x8(%rbp),%edx
mov -0x4(%rbp),%eax
add %edx,%eax
pop %rbp
retq
...
在我们的测试函数中,前两条指令是函数的前导。前导是推送当前堆栈帧(即前一个函数的堆栈帧),然后将当前堆栈指针设置为rbp,从而创建一个新的堆栈帧。
接下来的两条指令使用堆栈的未使用部分来创建变量i和j。最后,将结果加载到寄存器中,并在rax中添加并返回结果(这是为英特尔定义的大多数 ABI 的返回寄存器)。
该函数的结尾是这个例子中的最后两条指令。具体来说,先前的堆栈帧的位置(在前导中推送到堆栈中)从堆栈中弹出并存储在rbp中,有效地切换到先前的堆栈帧,然后使用ret指令返回到先前的函数(就在函数调用之后)。
敏锐的眼睛可能已经注意到,通过移动rsp来为变量i和j保留了堆栈上的空间。这是因为 System V ABI 的 64 位版本定义了所谓的红区。红区仅适用于叶函数(在我们的例子中,测试函数是一个叶函数,意味着它不调用任何其他函数)。
叶函数永远不会进一步增加堆栈,这意味着剩余的堆栈可以被函数使用,而无需推进堆栈指针,因为所有剩余的内存都是公平竞争的。
在系统编程时,如果在内核中编程,有时可能会出现问题。具体来说,如果中断触发(使用当前堆栈指针作为其堆栈),如果堆栈没有正确保留,可能会导致损坏,因此中断会破坏基于堆栈的叶函数的变量。
为了克服这一点,必须使用 GCC 的-mno-red-zone标志关闭红区。例如,如果我们使用这个标志编译前面的例子,我们得到以下二进制输出:
...
00000000000005fa <_Z4testv>:
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
movl $0x1,-0x8(%rbp)
movl $0x2,-0x4(%rbp)
mov -0x8(%rbp),%edx
mov -0x4(%rbp),%eax
add %edx,%eax
leaveq
retq
...
如所示,生成的二进制文件与原始文件非常相似。然而,有两个主要区别。第一个是sub指令,用于移动堆栈指针,从而保留堆栈空间,而不是使用红区。
第二个区别是使用leave指令。这条指令像前面的例子一样弹出rbp,但也恢复了堆栈指针,这个指针已经移动以为基于堆栈的变量腾出空间。在这个例子中,leave和ret指令是新的结尾。
调用约定
调用约定规定了哪些寄存器是易失性的,哪些寄存器是非易失性的,哪些寄存器用于参数传递以及顺序,以及哪个寄存器用于返回函数的结果。
非易失性寄存器是在函数结束之前恢复到其原始值的寄存器(即在其结尾)。System V ABI 将rbx、rbp、r12、r13、r14和r15定义为非易失性寄存器。相比之下,易失性寄存器是被调用函数可以随意更改的寄存器,无需在返回时恢复其值。
为了证明这一点,让我们看下面的例子:
0000000000000630 <__libc_csu_init>:
push %r15
push %r14
mov %rdx,%r15
push %r13
push %r12
如前面的例子所示,__libc_csu_init()函数(被libc用于初始化)会触及r12、r13、r14和r15。因此,在执行初始化过程之前,它必须将这些寄存器的原始值推送到堆栈中。
此外,在这段代码的中间,编译器将rdx存储在r15中。稍后将会展示,编译器正在保留函数的第三个参数。仅仅根据这段代码,我们知道这个函数至少需要三个参数。
快速的谷歌搜索会显示这个函数有以下签名:
__libc_csu_init (int argc, char **argv, char **envp)
由于这个函数触及了非易失性寄存器,它必须在离开之前将这些寄存器恢复到它们的原始值。让我们看一下函数的尾声:
pop %rbx
pop %rbp
pop %r12
pop %r13
pop %r14
pop %r15
retq
如前所示,__libc_csu_init()函数在离开之前恢复所有非易失性寄存器。这意味着,在函数的中间某处,rbx也被破坏了(原始值先被推送到堆栈上)。
除了定义易失性和非易失性寄存器之外,System V 的调用约定还定义了用于传递函数参数的寄存器。具体来说,寄存器rdi、rsi、rdx、rcx、r8和r9用于传递参数(按照提供的顺序)。
为了证明这一点,让我们看下面的例子:
int test(int val1, int val2)
{
return val1 + val2;
}
int main(void)
{
auto ret = test(42, 42);
}
// > g++ scratchpad.cpp; ./a.out
//
在前面的例子中,我们创建了一个接受两个参数,将它们相加并返回结果的测试函数。现在让我们看一下main()函数的生成二进制文件:
000000000000060e <main>:
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
mov $0x2a,%esi
mov $0x2a,%edi
callq 5fa <_Z4testii>
mov %eax,-0x4(%rbp)
mov $0x0,%eax
leaveq
retq
main()函数的第一件事是提供其前言(如前几章所述,main()函数不是第一个执行的函数,因此,就像任何其他函数一样,需要前言和尾声)。
然后,main()函数在堆栈上为test()函数的返回值保留空间,并在调用test()之前用传递给test()的参数填充esi和edi。
如前所述,call指令将返回地址推送到堆栈上,然后跳转到test()函数。test()函数的结果存储在堆栈上(如果启用了优化,这个操作将被移除),然后在返回之前将eax中放入0。
正如我们所看到的,我们没有为main函数提供返回值。这是因为,如果没有提供返回值,编译器将自动为我们插入返回0,这就是我们在这段代码中看到的,因为rax是 System V 的返回寄存器。
现在让我们看一下测试函数的二进制文件:
00000000000005fa <_Z4testii>:
push %rbp
mov %rsp,%rbp
mov %edi,-0x4(%rbp)
mov %esi,-0x8(%rbp)
mov -0x4(%rbp),%edx
mov -0x8(%rbp),%eax
add %edx,%eax
pop %rbp
retq
test函数设置前言,然后将函数的参数存储在堆栈上(如果启用了优化,这个操作将被移除)。然后将堆栈变量放入易失性寄存器中(以防止它们需要被保存和恢复),然后将寄存器相加,并将结果存储在eax中。最后,函数通过尾声返回。
如前所述,System V 的返回寄存器是rax,这意味着每个返回值的函数都将使用rax来返回。要返回多个值,也可以使用rdx。例如,看下面的例子:
#include <cstdint>
struct mystruct
{
uint64_t data1;
uint64_t data2;
};
mystruct test()
{
return {1, 2};
}
int main(void)
{
auto ret = test();
}
// > g++ scratchpad.cpp; ./a.out
//
在前面的例子中,我们创建了一个返回包含两个 64 位整数的结构的test函数。我们选择了两个 64 位整数,因为如果我们使用常规的 int,编译器将尝试将结构的内容存储在一个 64 位寄存器中。
test()函数的生成二进制文件如下:
00000000000005fa <_Z4testv>:
push %rbp
mov %rsp,%rbp
mov $0x1,%eax
mov $0x2,%edx
pop %rbp
retq
如前所示,test函数在返回之前将结果存储在rax和rdx中。如果返回的数据超过 128 位,那么main()函数和test()函数都会变得更加复杂。这是因为main()函数必须保留堆栈空间,然后test()函数必须利用这个堆栈空间来返回函数的结果。
这是如何工作的具体细节超出了本书的范围,但简而言之,为返回值保留的堆栈空间的地址实际上成为函数的第一个参数,所有这些都是由 System V ABI 定义的。
应该注意,示例大量使用以e为前缀而不是r的寄存器。这是因为e表示 32 位寄存器,而r表示 64 位寄存器。之所以这么多使用e版本,是因为我们利用基于整数的文字,如1、2和42。这些都是int类型,根据 C 和 C++规范(如前几章所述),在 Intel 64 位 CPU 上默认是 32 位值。
异常处理和调试
C++异常提供了一种在调用堆栈的某个地方返回错误到catch处理程序的方法。我们将在第十三章中详细介绍 C++异常,异常处理。
现在,我们将使用以下简单的例子:
#include <iostream>
#include <exception>
void test(int i)
{
if (i == 42) {
throw 42;
}
}
int main(void)
{
try {
test(1);
std::cout << "attempt #1: passed\n";
test(21);
std::cout << "attempt #2: passed\n";
}
catch(...) {
std::cout << "exception catch\n";
}
}
// > g++ scratchpad.cpp; ./a.out
// attempt #1: passed
// exception catch
在前面的例子中,我们创建了一个简单的test()函数,它接受一个输入。如果输入等于42,我们会抛出一个异常。这将导致函数返回(并且每个调用函数继续返回),直到遇到try或catch块。在块的try部分执行的任何代码都将在抛出异常时执行块的catch部分。
应该注意,被调用函数的返回值不被考虑或使用。这提供了一种在调用函数调用堆栈的任何点抛出错误,并在任何点捕获可能的错误的方法(最有可能在错误可以安全处理或程序可以安全中止时)。
如前面的例子所示,第一次尝试执行test()函数成功,并且将attempt #1: passed字符串输出到stdout。第二次尝试执行test()函数失败,因为函数抛出异常,结果,attempt #2: passed字符串不会输出到stdout,因为这段代码永远不会执行。相反,将执行catch块,该块处理错误(忽略它)。
异常处理(和调试)的细节非常困难(故意的双关语),因此本节的目标是解释 System V 规范如何规定与异常(和调试)支持相关的 ABI。
我在以下视频中提供了有关 C++异常内部工作原理的更多细节,该视频是在 CppCon 上录制的:www.youtube.com/watch?v=uQSQy-7lveQ。
在本节结束时,以下内容应该是清楚的:
-
C++异常执行起来很昂贵,因此不应该用于控制流(仅用于错误处理)。
-
C++异常在可执行文件中占用大量空间,如果不使用,应该传递
-fno-exceptions标志给 GCC,以减少生成代码的总体大小。这也意味着不应该使用可能引发异常的库设施。
为了支持前面的例子,堆栈必须被展开。也就是说,为了程序跳转到catch块,非易失性寄存器需要被设置,以使得test()函数看起来从未执行过。为了做到这一点,我们以某种方式以编译器提供的一组指令的方式,以相反的方式执行test()函数。
在我们深入了解这些信息之前,让我们先看一下与我们之前的例子相关的汇编代码:
0000000000000c11 <main>:
push %rbp
mov %rsp,%rbp
push %rbx
sub $0x8,%rsp
mov $0x1,%edi
callq b9a <test>
...
callq a30 <std::cout>
...
mov $0x0,%eax
jmp c90
...
callq 9f0 <__cxa_begin_catch@plt>
...
callq a70 <_Unwind_Resume@plt>
add $0x8,%rsp
pop %rbx
pop %rbp
retq
为了保持易于理解,上述代码已经简化。让我们从头开始。这个函数的第一件事是设置函数前言(即堆栈帧),然后在堆栈上保留一些空间。完成这些操作后,代码将0x1移动到edi中,这将传递1给test()函数。
接下来,调用test()函数。然后发生一些事情(细节不重要),然后调用std::cout(尝试将attempt #1: passed字符串输出到stdout)。这个过程对test(42)也是一样的。
接下来的代码是main()函数变得有趣的地方。mov $0x0,%eax将eax设置为0,正如我们所知,这是返回寄存器。这段代码设置了main()函数的返回值,但有趣的是,下一条指令相对跳转到main()函数中的c90,也就是add $0x8,%rsp代码。这是函数的结尾的开始,它清理堆栈并恢复非易失性寄存器。
中间的代码是我们的catch块。这是在抛出异常时执行的代码。如果没有抛出异常,就会执行jmp c90代码,跳过catch块。
test函数要简单得多:
0000000000000a6a <_Z4testi>:
push %rbp
mov %rsp,%rbp
sub $0x10,%rsp
mov %edi,-0x4(%rbp)
cmpl $0x2a,-0x4(%rbp)
jne a9f
mov $0x4,%edi
callq 8e0 <__cxa_allocate_exception@plt>
...
callq 930 <__cxa_throw@plt>
nop
leaveq
retq
在test函数中,函数的前言被设置,堆栈空间被保留(如果启用了优化,这可能会被移除)。然后将输入与42进行比较,如果它们不相等(通过使用jne来表示),函数就会跳转到结尾并返回。如果它们相等,就会分配并抛出一个 C++异常。
这里需要注意的重要一点是,__cxa_throw()函数不会返回,这意味着函数的结尾部分永远不会被执行。原因是,当抛出异常时,程序员表示函数的剩余部分无法执行,而是需要__cxa_throw()跳转到调用堆栈中的catch块(在这种情况下是在main()函数中),或者如果找不到catch块,则终止程序。
由于函数的结尾从未被执行,非易失性寄存器需要以某种方式恢复到它们的原始状态。这就引出了 DWARF 规范和嵌入在应用程序中的.eh_frame表。
正如本章后面将要展示的,大多数基于 Unix 的应用程序都是编译成一种名为ELF的二进制格式。任何使用 C++异常支持编译的 ELF 应用程序都包含一个特殊的表,叫做.eh_frame表(这代表异常处理框架)。
例如,如果你在之前的应用程序上运行readelf,你会看到.eh_frame表,如下所示:
> readelf -SW a.out
There are 31 section headers, starting at offset 0x2d18:
Section Headers:
...
[18] .eh_frame PROGBITS 0000000000000ca8 000ca8 000190 00 A 0 0 8
...
DWARF 规范(官方上没有特定的含义)提供了调试应用程序所需的所有信息。当 GCC 启用调试时,会向应用程序添加几个调试表,以帮助 GDB。
DWARF 规范也用于定义反转堆栈所需的指令;换句话说,以相对于非易失性寄存器的内容执行函数的反向操作。
让我们使用readelf来查看.eh_frame表的内容,如下所示:
> readelf --debug-dump=frames a.out
...
00000088 000000000000001c 0000005c FDE ...
DW_CFA_advance_loc: 1 to 0000000000000a6b
DW_CFA_def_cfa_offset: 16
DW_CFA_offset: r6 (rbp) at cfa-16
DW_CFA_advance_loc: 3 to 0000000000000a6e
DW_CFA_def_cfa_register: r6 (rbp)
DW_CFA_advance_loc: 51 to 0000000000000aa1
DW_CFA_def_cfa: r7 (rsp) ofs 8
DW_CFA_nop
DW_CFA_nop
DW_CFA_nop
...
关于这段代码的功能可以写一整本书,但这里的目标是保持简单。对于程序中的每个函数(对于代码量很大的程序可能有数十万个函数),.eh_frame中都提供了类似上面的一个块。
前面的代码块(通过使用objdump找到的地址匹配)是我们test()函数的Frame Description Entry(FDE)。这个 FDE 描述了如何使用 DWARF 指令反转堆栈,这些指令是为了尽可能小(以减小.eh_frame表的大小)而压缩的指令。
FDE 根据抛出的位置提供了堆栈反转指令。也就是说,当一个函数执行时,它会继续触及堆栈。如果一个函数中存在多个抛出,那么在每次抛出之间可能会触及更多的堆栈,这意味着需要更多的堆栈反转指令来正确地将堆栈恢复到正常状态。
一旦函数的堆栈被反转,调用堆栈中的下一个函数也需要被反转。这个过程会一直持续,直到找到一个catch块。问题在于.eh_frame表是这些 FDE 的列表,这意味着反转堆栈是一个O(N²)的操作。
已经进行了优化,包括使用哈希表,但仍然有两个事实是真实的:
-
反转堆栈是一个缓慢的过程。
-
使用 C++异常会占用大量空间。这是因为代码中定义的每个函数不仅必须包含该函数的代码,还必须包含一个 FDE,告诉代码如何在触发异常时展开堆栈。
虚拟内存布局
虚拟内存布局也是由 System V 规范提供的。在下一节中,我们将讨论 ELF 格式的细节,这将提供关于虚拟内存布局以及如何更改它的更多信息。
可执行和可链接格式(ELF)
可执行和可链接格式(ELF)是大多数基于 Unix 的操作系统中使用的主要格式,包括 Linux。每个 ELF 文件以十六进制数0x7F开头,然后是ELF字符串。
例如,让我们看一下以下程序:
int main(void)
{
}
// > g++ scratchpad.cpp; ./a.out
//
如果我们查看生成的a.out ELF 文件的hexdump,我们会看到以下内容:
> hexdump -C a.out
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 03 00 3e 00 01 00 00 00 f0 04 00 00 00 00 00 00 |..>.............|
00000020 40 00 00 00 00 00 00 00 e8 18 00 00 00 00 00 00 |@...............|
00000030 00 00 00 00 40 00 38 00 09 00 40 00 1c 00 1b 00 |....@.8...@.....|
如图所示,ELF字符串位于开头。
每个 ELF 文件都包含一个 ELF 头,描述了 ELF 文件本身的一些关键组件。以下命令可用于查看 ELF 文件的头部:
> readelf -hW a.out
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x4f0
Start of program headers: 64 (bytes into file)
Start of section headers: 6376 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 9
Size of section headers: 64 (bytes)
Number of section headers: 28
Section header string table index: 27
如图所示,我们编译的 ELF 文件链接到了一个符合 Unix System V ABI for Intel 64-bit 的 ELF-64 文件。在头部的底部附近,您可能会注意到程序头和部分头的提及。
每个 ELF 文件都可以从其段或其部分的角度来查看。为了可视化这一点,让我们从两个角度来看一个 ELF 文件,如下所示:
Segments Sections
|------------| |------------|
| Header | | Header |
|------------| |------------|
| | | |
| | |------------|
| | | |
| | | |
|------------| |------------|
| | | |
| | |------------|
| | | |
|------------| |------------|
如前所示,每个 ELF 文件由部分组成。然后将这些部分分组成段,用于定义需要加载哪些部分,以及如何加载(例如,一些部分需要以读写方式加载,其他部分需要以读取-执行方式加载,或者在一些次优化的情况下,以读写-执行方式加载)。
ELF 部分
要查看所有部分的列表,请使用以下命令:
> readelf -SW a.out
这将导致以下输出:
如图所示,即使在一个简单的例子中,也有几个部分。其中一些部分包含了在前几章中已经讨论过的信息:
-
eh_frame/.eh_frame_hdr:这些包含了在处理异常时反转堆栈的 FDE 信息,正如刚才讨论的。eh_frame_hdr部分包含了用于改进 C++异常性能的其他信息,包括一个哈希表,用于定位 FDE,而不是循环遍历 FDE 列表(否则将是一个O(n²)操作)。 -
.init_array/.fini_array/.init/.fini:这些包含了代码执行的构造函数和析构函数,包括任何链接到您的代码的库(如前所述,在底层可能链接到您的应用程序的库很多)。还应该注意,这些部分包含能够执行运行时重定位的代码,必须在任何应用程序的开头执行,以确保代码被正确链接和重定位。 -
.dynsym:这包含了用于动态链接的所有符号。如前所述,如果使用 GCC,这些符号将全部包含 C 运行时链接名称,而如果使用 G++,它们还将包含有缠结的名称。我们将很快更详细地探讨这一部分。
从readelf的部分输出中可以学到很多东西。例如,所有地址都以0开头,而不是一些更高内存中的地址。这意味着应用程序在链接期间使用了-pie标志进行编译,这意味着应用程序是可重定位的。具体来说,位置无关可执行文件(PIE)(因此 ELF 文件)包含了.plt和.got部分,用于在内存中重定位可执行文件。
这也可以从.rela.xxx部分的包含中看出,其中包含了 ELF 加载器用于在内存中重新定位可执行文件的实际重定位命令。为了证明这个应用程序是使用-pie标志编译的,让我们看看应用程序的编译标志:
> g++ scratchpad.cpp -v
...
/usr/lib/gcc/x86_64-linux-gnu/7/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/7/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper -plugin-opt=-fresolution=/tmp/ccmBVeIh.res -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lgcc --sysroot=/ --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/7/../../.. /tmp/ccZU6K8e.o -lstdc++ -lm -lgcc_s -lgcc -lc -lgcc_s -lgcc /usr/lib/gcc/x86_64-linux-gnu/7/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crtn.o
...
如前所示,提供了-pie标志。
还要注意的一点是,部分从地址0开始并继续,但是在某个时候,地址跳到0x200000并从那里继续。这意味着应用程序是 2MB 对齐的,这对于 64 位应用程序来说是典型的,因为它们有更大的地址空间可供使用。
正如将要展示的,跳转到0x200000的点是 ELF 文件中一个新程序段的开始,并且表示正在加载的部分权限的改变。
还有一些值得指出的显著部分:
-
.text:这包含了与程序相关的大部分(如果不是全部)代码。这个部分通常位于标记为读取-执行的段中,并且理想情况下不具有写权限。 -
.data:这包含了初始化为非0值的全局变量。如前所示,这个部分存在于 ELF 文件本身中,因此这些类型的变量应该谨慎使用,因为它们会增加生成的 ELF 文件的大小(这会减少应用程序的加载时间,并在磁盘上占用额外的空间)。还应该注意,一些编译器会将未初始化的变量放在这个部分中,所以如果一个变量应该是0,就要初始化为0。 -
.bss:这个部分包含所有应该初始化为0的全局变量(假设使用 C 和 C++)。这个部分总是最后一个被加载的部分(也就是说,它是由段标记的最后一个部分),并且实际上并不存在于 ELF 文件本身。相反,当一个 ELF 文件被加载到内存中时,ELF 加载器(或 C 运行时)会扩展 ELF 文件的大小以包括这个部分的总大小,并且额外的内存会被初始化为0。 -
.dynstr/.strtab:这些表包含用于符号名称(即变量和函数名称)的字符串。.dynstr表包含在动态链接期间需要的所有字符串,而.strtab部分包含程序中的所有符号。关键点在于这些字符串出现了两次。在变量或函数前使用static可以防止变量的符号出现在.dynsym部分中,这意呈现它不会出现在.dynstr部分中。这样做的缺点是,变量在动态链接期间无法被看到,这意味着如果另一个库尝试在该变量上使用extern,它将失败。默认情况下,所有变量和函数都应该被标记为static,除非你打算让它们在外部可访问,这样可以减少磁盘和内存上文件的总大小。这也加快了链接时间,因为它减少了.dynsym部分的大小,该部分用于动态链接。
为了进一步研究字符串在 ELF 文件中的存储方式,让我们创建一个简单的示例,其中包含一个易于查找的字符串。
#include <iostream>
int main(void)
{
std::cout << "The answer is: 42\n";
}
// > g++ scratchpad.cpp; ./a.out
// The answer is: 42
如前所示,这个示例将The answer is: 42输出到stdout。
现在让我们在 ELF 文件本身中查找这个字符串,使用以下内容:
> hexdump -C a.out | grep "The" -B1 -A1
000008f0 f3 c3 00 00 48 83 ec 08 48 83 c4 08 c3 00 00 00 |....H...H.......|
00000900 01 00 02 00 00 54 68 65 20 61 6e 73 77 65 72 20 |.....The answer |
00000910 69 73 3a 20 34 32 0a 00 01 1b 03 3b 4c 00 00 00 |is: 42.....;L...|
如前所示,字符串存在于我们的程序中,并位于0x905。现在让我们看看这个应用程序的 ELF 部分:
如果我们查看部分内的地址,我们可以看到字符串存在于一个名为.rodata的部分中,其中包含常量数据。
现在让我们使用objdump查看这个应用程序的汇编,它会反汇编.text部分中的代码,如下所示:
如前所示,代码在调用 std::cout 之前将 rsi 加载为字符串的地址(在 0x905 处),这是第二个参数。需要注意的是,与之前一样,这个应用是使用 -pie 命令编译的,这意味着应用本身将被重定位。这最终意味着字符串的地址不会在 0x905 处,而是在 # + 0x905 处。
为了避免需要重定位条目(即全局偏移表(GOT)中的条目),程序使用了指令指针相对偏移。在这种情况下,加载 rsi 的指令位于 0x805 处,使用了偏移量 0x100,从而返回 0x905 + rip。这意味着无论应用程序在内存中的哪个位置,代码都可以找到字符串,而无需需要重定位条目。
ELF 段
正如之前所述,ELF 段将各个部分分组为可加载的组件,并描述了如何在内存中加载 ELF 文件的位置和方式。理想的 ELF 加载器只需要读取 ELF 段来加载 ELF 文件,并且(对于可重定位的 ELF 文件)还需要加载动态部分和重定位部分。
要查看 ELF 的段,请使用以下代码:
如前所示,简单示例有几个程序段。第一个段描述了程序头(定义了段),在很大程度上可以忽略。
第二个段告诉 ELF 加载器它期望使用哪个重定位器。具体来说,这个段中描述的程序用于延迟重定位。当程序动态链接时,GOT 和过程链接表(PLT)中的符号包含每个符号在内存中的实际地址,代码引用这个表中的条目,而不是直接引用一个符号。
这是必要的,因为编译器无法知道另一个库中符号的位置,因此 ELF 加载器通过加载其他库中存在的符号的 GOT 和 PLT 来填充每个符号的位置(或者没有标记为静态的符号)。
问题在于一个大型程序可能有数百甚至数千个这些 GOT 或 PLT 条目,因此加载一个程序可能需要很长时间。更糟糕的是,许多外部库的符号可能永远不会被调用,这意味着 ELF 加载器需要填充一个不需要的符号位置的 GOT 或 PLT 条目。
为了克服这些问题,ELF 加载器将 GOT 和 PLT 加载为懒加载器的位置,而不是符号本身的位置。懒加载器(就是你在第二个段中看到的程序)在第一次使用符号时加载符号的位置,从而减少程序加载时间。
第三个段标记为 LOAD,告诉 ELF 加载器将 ELF 文件的下一部分加载到内存中。如前面的输出所示,这个段包含几个部分,所有这些部分都标记为读取-执行。例如,.text 部分存在于这个段中。
ELF 加载器所需做的就是按照段标记的指示将 ELF 文件的部分加载到提供的虚拟地址(以及提供的内存大小)中。
第四个段与第三个相同,但标记的不是读取-执行部分,而是标记的读取-写入部分,包括 .data 等部分。
需要注意的是,加载第四个段的内存偏移增加了 0x200000。正如之前所述,这是因为程序是 2 MB 对齐的。更具体地说,英特尔 64 位 CPU 支持 4 KB、2 MB 和 1 GB 页面。
由于第一个可加载段标记为读-执行,第二个可加载段不能在同一页上(否则,它也必须标记为读-执行)。因此,第二个可加载段被设计为从下一个可用页面开始,这种情况下是内存中的 2MB。这允许操作系统将第一个可加载段标记为读-执行,第二个可加载段标记为读-写,并且 CPU 可以强制执行这些权限。
下一部分定义了动态部分的位置,ELF 加载器用于执行动态重定位。这是必要的,因为可执行文件是使用-pie编译的。需要注意的是,ELF 加载器可以扫描 ELF 部分以找到这些数据,但程序段的目标是定义加载 ELF 文件所需的所有信息,而无需扫描部分。不幸的是,在实践中,这并不总是正确的,但理想情况下应该是这样。
notes部分可以安全地忽略。以下部分为 ELF 加载器提供了异常信息的位置(如描述);可执行文件期望的堆栈权限,理想情况下应该始终是读写而不是读写执行;以及只读部分的位置,加载后可以更改其权限为只读。
Unix 文件系统
Unix 文件系统被大多数基于 Unix 的操作系统使用,包括 Linux,它由一个虚拟文件系统树组成,这是用户和应用程序的前端。树从根目录(即/)开始,所有文件、设备和其他资源都位于这个单一的根目录中。
从那里,物理文件系统通常映射到虚拟文件系统,提供了一种存储和检索文件的机制。需要注意的是,这个物理文件系统不一定是一个磁盘;它也可以是 RAM 或其他类型的存储设备。
为了执行这种映射,操作系统有一个机制指示 OS 执行这种映射。在 Linux 上,这是通过/etc/fstab完成的,如下所示:
> cat /etc/fstab
UUID=... / ext4 ...
UUID=... /boot/efi vfat ...
如本例所示,根文件系统映射到一个特定的物理设备(用 UUID 表示),其中包含一个ext4文件系统。此外,在这个根文件系统中,另一个物理分区映射到/boot/efi,包含一个 VFAT 文件系统。
这意味着对虚拟文件系统的所有访问都默认为ext4分区,而对/boot/efi下的任何内容的访问都会被重定向到一个包含特定于 UEFI 的文件的单独的 VFAT 分区(这是用于编写本书的文本框中使用的特定 BIOS)。
虚拟文件系统中的任何节点都可以重新映射到任何设备或资源。这种设计的精妙之处在于,应用程序不需要关心虚拟文件系统当前映射的设备类型,只要应用程序对它正在尝试访问的文件系统部分有权限,并且有能力打开文件并读写它。
例如,让我们看看以下内容:
> ls /dev/null
/dev/null
在大多数基于 Linux 的系统上,存在一个名为/dev/null的文件。这个文件实际上并不映射到一个真实的文件。相反,虚拟文件系统将这个文件映射到一个忽略所有写入并在读取时返回空的设备驱动程序。例如,参见以下内容:
> echo "Hello World" > /dev/null
> hexdump -n16 /dev/null
<nothing>
大多数基于 Linux 的系统还提供了/dev/zero,当读取时返回所有的零,如下所示:
> hexdump -n16 /dev/zero
0000000 0000 0000 0000 0000 0000 0000 0000 0000
0000010
还有/dev/random,当读取时返回一个随机数,如下所示:
> hexdump -n16 /dev/random
0000000 3ed9 25c2 ad88 bf62 d3b3 0f72 b32a 32b3
0000010
如前所述,在第二章中,学习 C、C++17 和 POSIX 标准,POSIX 定义的文件系统布局如下:
-
/bin:用于所有用户使用的二进制文件
-
/boot:用于引导操作系统所需的文件 -
/dev:用于物理和虚拟设备 -
/etc:操作系统需要的配置文件 -
/home:用于特定用户文件 -
/lib:用于可执行文件所需的库 -
/mnt和/media:用作临时挂载点 -
/sbin:用于系统特定的二进制文件 -
/tmp:用于在重启时删除的文件 -
/usr:用于前述文件夹的特定用户版本
通常,/boot下的文件指向与根分区不同的物理分区,/dev文件夹包含映射到设备的文件(而不是存储和检索在磁盘上的文件),/mnt或/media用于挂载临时设备,如 USB 存储设备和 CD-ROM。
在一些系统上,/home可能被映射到一个完全独立的硬盘驱动器,允许用户完全格式化和重新安装根文件系统(即重新安装操作系统),而不会丢失任何个人文件或配置。
Unix 文件系统还维护了一整套权限,定义了谁被允许读取、写入和执行文件。参见以下示例:
> ls -al
total 40
drwxrwxr-x 3 user user ... .
drwxrwxr-x 16 user user ... ..
-rwxrwxr-x 1 user user ... a.out
drwxrwxr-x 3 user user ... build
-rw-rw-r-- 1 user user ... CMakeLists.txt
-rw-rw-r-- 1 user user ... scratchpad.cpp
文件系统定义了文件所有者、文件组和其他人(既不是所有者也不是文件组成员)的权限。
前面示例中的第一列定义了文件的权限。d定义了节点是目录还是文件。前三个字符定义了文件所有者的读/写/执行权限,第二个定义了文件组的权限,最后一个定义了其他用户的权限。
前面示例中的第三列定义了所有者的名称,而第二列定义了组的名称(在大多数情况下也是所有者)。
使用这种权限模型,Unix 文件系统可以控制任何给定用户、一组用户和其他所有人对任何文件或目录的访问。
Unix 进程
Unix 系统上的进程是由操作系统执行和调度的用户空间应用程序。在本书中,我们将进程和用户空间应用程序互换使用。
正如将要展示的,大多数在任何给定时间运行的基于 Unix 的进程都是某些其他父进程的子进程,每个内核在底层实现进程的方式可能不同,但所有 Unix 操作系统都提供了相同的基本命令来创建和管理进程。
在本节中,我们将讨论如何使用常见的 POSIX 接口创建和管理基于 Unix 的进程。
fork()函数
在基于 Unix 的系统上,fork()函数用于创建进程。fork()函数是操作系统提供的一个相对简单的系统调用,它接受当前进程,并创建进程的一个重复子版本。父进程和子进程的一切都是相同的,包括打开的文件句柄、内存等,唯一的区别是子进程有一个新的进程 ID。
在第十二章中,《学习编程 POSIX 和 C++线程》,我们将讨论线程(在系统编程中比进程更常用)。线程和进程都由操作系统调度;线程和进程的主要区别在于子进程和父进程无法访问彼此的内存,而线程可以。
即使fork()创建了一个具有相同资源和内存布局的新进程,父进程和子进程之间共享的内存被标记为写时复制。这意味着,当父进程和子进程执行时,任何尝试写入可能已经共享的内存的操作会导致子进程创建自己的内存副本,只有它自己可以写入。结果是,父进程无法看到子进程对内存所做的修改。
对于线程来说并不是这样,因为线程保持相同的内存布局,不会被标记为写时复制。因此,一个线程能够看到另一个线程(或父进程)对内存所做的更改。
让我们看下面的例子:
#include <unistd.h>
#include <iostream>
int main(void)
{
fork();
std::cout << "Hello World\n";
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
在这个例子中,我们使用fork()系统调用创建一个重复的进程。重复的子进程使用std::cout向stdout输出Hello World。如示例所示,这个例子的结果是Hello World被输出两次。
fork()系统调用在父进程中返回子进程的进程 ID,在子进程中返回0。如果发生错误,将返回-1并将errno设置为适当的错误代码。看下面的例子:
#include <unistd.h>
#include <iostream>
int main(void)
{
if (fork() != 0) {
std::cout << "Hello\n";
}
else {
std::cout << "World\n";
}
}
// > g++ scratchpad.cpp; ./a.out
// Hello
// World
在这个例子中,父进程输出Hello,而子进程输出World。
为了检查父进程和子进程之间如何处理共享内存,让我们看下面的例子:
#include <unistd.h>
#include <iostream>
int data = 0;
int main(void)
{
if (fork() != 0)
{
data = 42;
}
std::cout << "The answer is: " << data << '\n';
}
// > g++ scratchpad.cpp; ./a.out
// The answer is: 42
// The answer is: 0
在这个例子中,我们为父进程和子进程输出The answer is:字符串。两个进程都可以访问一个名为data的全局变量,它被初始化为0。不同之处在于父进程将data变量设置为42,而子进程没有。
父进程在操作系统调度子进程之前完成了它的工作,因此The answer is: 42首先被输出到stdout。
一旦子进程有机会执行,它也输出这个字符串,但答案是0而不是42。这是因为对于子进程来说,数据变量从未被设置过。父进程和子进程都可以访问自己的内存(至少是写入的内存),因此42是在父进程的内存中设置的,而不是子进程的。
在大多数基于 Unix 的操作系统中,第一个执行的进程是init,它使用fork()启动系统上的其他进程。这意味着init进程是用户空间应用程序的根级父进程(有时被称为祖父)。因此,fork()系统调用可以用来创建复杂的进程树。
看下面的例子:
#include <unistd.h>
#include <iostream>
int main(void)
{
fork();
fork();
std::cout << "Hello World\n";
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
// Hello World
// Hello World
在前面的例子中,我们两次执行fork()系统调用,生成了三个额外的进程。为了理解为什么会创建三个进程而不是两个,让我们对例子进行简单修改,以突出所创建的树结构,如下所示:
#include <unistd.h>
#include <iostream>
int main(void)
{
auto id1 = fork();
std::cout << "id1: " << id1 << '\n';
auto id2 = fork();
std::cout << "id2: " << id2 << '\n';
std::cout << "-----------\n";
}
// > g++ scratchpad.cpp; ./a.out
// id1: 14181
// id2: 14182
// -----------
// id1: 0
// id2: 14183
// -----------
// id2: 0
// -----------
// id2: 0
// -----------
在这个例子中,我们像之前一样两次执行fork(),唯一的区别是我们输出每个创建的进程的 ID。父进程执行fork(),输出 ID,再次执行fork(),然后再次输出 ID 后执行。
由于 ID 不是0(实际上是14181和14182),我们知道这是父进程,并且如预期的那样,它创建了两个子进程。接下来显示的 ID 是0和14183。这是第一个子进程(14181),它出现在父进程第一次调用fork()时。
然后这个子进程继续创建它自己的子进程(ID 为14183)。当第二次执行fork()时,父进程和子进程分别创建了一个额外的进程(14182和14183),它们都为id2输出了0。这解释了最后两个输出。
需要注意的是,这个例子可能需要执行多次才能得到一个干净的结果,因为每个额外的子进程增加了一个子进程与其他子进程同时执行的机会,从而破坏了输出。由于进程不共享内存,在这样的例子中实现同步输出的方法并不简单。
使用fork()创建n² 个进程,其中n是调用fork()的总次数。例如,如果fork()被调用三次而不是两次,就像在简化的前面的例子中一样,我们期望Hello World输出的次数是八次而不是四次,如下所示:
#include <unistd.h>
#include <iostream>
int main(void)
{
fork();
fork();
fork();
std::cout << "Hello World\n";
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
// Hello World
// Hello World
// Hello World
// Hello World
// Hello World
// Hello World
除了显示的进程呈指数增长外,一些进程可能选择创建子进程,而另一些可能不会,导致复杂的进程树结构。
请看以下示例:
#include <unistd.h>
#include <iostream>
int main(void)
{
if (fork() != 0) {
std::cout << "The\n";
}
else {
if (fork() != 0) {
std::cout << "answer\n";
}
else {
if (fork() != 0) {
std::cout << "is\n";
}
else {
std::cout << 42 << '\n';
}
}
}
}
// > g++ scratchpad.cpp; ./a.out
// The
// answer
// is
// 42
在这个例子中,父进程创建子进程,而每个子进程都不做任何事情。这导致The answer is 42字符串仅由父进程输出到stdout。
wait()函数
正如所述,操作系统以操作系统选择的任何顺序执行每个进程。因此,父进程在子进程完成之前可能会完成执行。在某些操作系统上,这可能会导致损坏,因为某些操作系统要求父进程在子进程成功完成之前保持活动状态。
为了处理这个问题,POSIX 提供了wait()函数:
#include <unistd.h>
#include <iostream>
#include <sys/wait.h>
int main(void)
{
if (fork() != 0) {
std::cout << "parent\n";
wait(nullptr);
}
else {
std::cout << "child\n";
}
}
// > g++ scratchpad.cpp; ./a.out
// parent
// child
在这个例子中,我们创建了一个子进程,将child输出到stdout。与此同时,父进程将parent输出到stdout,然后执行wait()函数,告诉父进程等待子进程完成执行。
我们将nullptr传递给wait()函数,因为这告诉wait()函数我们对错误代码不感兴趣。
wait()函数等待任何子进程完成。它不等待特定子进程完成。因此,如果创建了多个子进程,必须多次执行wait()。
请看以下示例:
#include <unistd.h>
#include <iostream>
#include <sys/wait.h>
int main(void)
{
int id;
auto id1 = fork();
auto id2 = fork();
auto id3 = fork();
while(1)
{
id = wait(nullptr);
if (id == -1)
break;
if (id == id1)
std::cout << "child #1 finished\n";
if (id == id2)
std::cout << "child #2 finished\n";
if (id == id3)
std::cout << "child #3 finished\n";
}
if (id1 != 0 && id2 != 0 && id3 != 0)
std::cout << "parent done\n";
}
// > g++ scratchpad.cpp; ./a.out
// child #3 finished
// child #3 finished
// child #3 finished
// child #3 finished
// child #2 finished
// child #2 finished
// child #1 finished
// parent done
在上面的示例中,我们创建了八个子进程。如前所述,创建的进程总数是 2^(调用fork的次数)。然而,在这个例子中,我们希望确保祖父进程,也就是根父进程,是最后一个完成执行的进程。
请记住,当我们像这样调用fork()时,第一次调用创建第一个子进程。第二次调用fork()创建另一个子进程,但第一个子进程现在成为父进程,因为它调用fork()。当我们第三次调用fork()时,同样的事情发生了(甚至更多)。祖父进程是根父进程。
无论哪个进程是祖父进程,我们都希望确保所有子进程在其父进程之前完成。为了实现这一点,我们记录每次执行fork()时的进程 ID。对于子进程,此 ID 设置为0。
接下来,我们进入一个while(1)循环,然后调用wait()。wait()函数将在子进程完成时退出。进程完成后,我们输出退出到stdout的子进程。如果我们从wait()获取的进程 ID 是-1,我们知道没有更多的子进程存在,我们可以退出while(1)循环。
最后,如果没有一个进程 ID 等于0,我们知道该进程是祖父,我们输出它何时退出,只是为了显示它是最后一个退出的进程。
由于wait()函数不会返回0,我们知道当子进程退出时,我们只会在我们的while(1)循环中输出退出的子进程。如所示,我们看到一个带有id1的子进程退出,两个带有id2的子进程退出,以及四个带有id3的子进程退出。这与我们之前执行的数学计算一致。
还应该注意,这个例子确保所有子进程在父进程之前完成。这意味着祖父必须等待其子进程完成。由于祖父的子进程也创建自己的进程,因此祖父必须首先等待父进程完成,而父进程必须依次等待其子进程完成。
这导致了子进程在其父进程之前完成的级联效应,一直到最终完成祖父进程。
最后,还应该注意,尽管父进程必须等待子进程完成,但这并不意味着所有带有id3的子进程将在带有id2的子进程之前退出。这是因为子树的一半可能在另一半完成之前或以任何顺序完成而没有问题。因此,可能会得到这样的输出:
child #3 finished
child #3 finished
child #3 finished
child #2 finished
child #2 finished
child #3 finished
child #1 finished
parent done
在这个例子中,最后完成的child #3是由祖父进程最后一次调用fork()创建的进程。
进程间通信(IPC)
在我们之前的一个例子中,我们演示了如何使用fork()从父进程创建一个子进程,如下所示:
#include <unistd.h>
#include <iostream>
#include <sys/wait.h>
int main(void)
{
if (fork() != 0) {
std::cout << "parent\n";
wait(nullptr);
}
else {
std::cout << "child\n";
}
}
// > g++ scratchpad.cpp; ./a.out
// parent
// child
在这个例子中,我们看到parent在child之前输出仅仅是因为操作系统启动子进程的时间比从子进程输出的时间长。如果父进程需要更长的时间,child将首先输出。
请参见以下示例:
#include <unistd.h>
#include <iostream>
#include <sys/wait.h>
int main(void)
{
if (fork() != 0) {
sleep(1);
std::cout << "parent\n";
wait(nullptr);
}
else {
std::cout << "child\n";
}
}
// > g++ scratchpad.cpp; ./a.out
// child
// parent
这与之前的例子相同,唯一的区别是在父进程中添加了一个sleep()命令,告诉操作系统暂停父进程的执行一秒钟。结果,子进程有足够的时间来执行,导致child首先输出。
为了防止子进程先执行,我们需要在父进程和子进程之间建立一个通信通道,以便子进程知道在父进程完成向stdout输出之前等待。这被称为同步。
有关同步、如何处理同步以及同步引起的死锁和竞争条件等问题的更多信息,请参见本章的进一步阅读部分。
在本节中,我们将用来同步父进程和子进程的机制称为进程间通信(IPC)。在继续之前,应该注意到,创建多个进程并使用 IPC 来同步它们是在操作系统上创建和协调多个任务的一种笨重的方式。除非绝对需要单独的进程,更好的方法是使用线程,这是我们在第十二章中详细介绍的一个主题,学习编程 POSIX 和 C++线程。
在 Unix 系统中有几种不同类型的 IPC 可以利用。在这里,我们将介绍两种最流行的方法:
-
Unix 管道
-
Unix 共享内存
Unix 管道
管道是一种从一个进程向另一个进程发送信息的机制。在其最简单的形式中,管道是一个文件(在 RAM 中),一个进程可以向其写入,另一个进程可以从中读取。文件最初为空,直到有字节写入它,才能从管道中读取字节。
让我们看下面的例子:
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <array>
#include <iostream>
#include <string_view>
class mypipe
{
std::array<int, 2> m_handles;
public:
mypipe()
{
if (pipe(m_handles.data()) < 0) {
exit(1);
}
}
~mypipe()
{
close(m_handles.at(0));
close(m_handles.at(1));
}
std::string
read()
{
std::array<char, 256> buf;
std::size_t bytes = ::read(m_handles.at(0), buf.data(), buf.size());
if (bytes > 0) {
return {buf.data(), bytes};
}
return {};
}
void
write(const std::string &msg)
{
::write(m_handles.at(1), msg.data(), msg.size());
}
};
int main(void)
{
mypipe p;
if (fork() != 0) {
sleep(1);
std::cout << "parent\n";
p.write("done");
wait(nullptr);
}
else {
auto msg = p.read();
std::cout << "child\n";
std::cout << "msg: " << msg << '\n';
}
}
// > g++ scratchpad.cpp -std=c++17; ./a.out
// parent
// child
// msg: done
这个例子与之前的例子类似,增加了一个 Unix 管道。这是为了确保即使父进程需要一段时间来执行,父进程在子进程执行之前输出到stdout。为了实现这一点,我们创建了一个类,利用资源获取即初始化(RAII)来封装 Unix 管道,确保正确抽象 C API 的细节,并在mypipe类失去作用域时关闭支持 Unix 管道的句柄。
我们在课上做的第一件事是打开管道,如下所示:
mypipe()
{
if (pipe(m_handles.data()) < 0) {
exit(1);
}
}
管道本身是两个文件句柄的数组。第一个句柄用于从管道中读取,而第二个句柄用于向管道中写入。如果发生错误,pipe()函数将返回-1。
需要注意的是,如果pipe()函数成功,结果是两个文件句柄,当不再使用时应该关闭。为了支持这一点,我们在类的析构函数中关闭打开的文件句柄,这样当管道失去作用域时,管道就关闭了,如下所示:
~mypipe()
{
close(m_handles.at(0));
close(m_handles.at(1));
}
然后我们提供一个read()函数,如下所示:
std::string
read()
{
std::array<char, 256> buf;
std::size_t bytes = ::read(m_handles.at(0), buf.data(), buf.size());
if (bytes > 0) {
return {buf.data(), bytes};
}
return {};
}
read()函数创建一个可以读取的缓冲区,我们从管道中读取并将结果放入缓冲区。注意我们从第一个文件句柄中读取,如所述。
需要注意的是,我们在这里利用的read()和write()函数将在第八章中详细介绍,学习文件输入/输出编程。现在,重要的是要注意,read()函数在这种情况下是一个阻塞函数,直到从管道中读取数据才会返回。如果发生错误(例如,管道关闭了),将返回-1。
为了解决这个问题,我们只在从管道中读取实际字节时返回数据;否则,我们返回一个空字符串,用户可以用这个类来检测错误(或者我们可以使用 C++异常,如第十三章中所述的异常处理)。
最后,我们还添加了一个write()函数到管道,如下所示:
void
write(const std::string &msg)
{
::write(m_handles.at(1), msg.data(), msg.size());
}
write()函数更简单,使用write() Unix 函数写入管道的写端。
在父进程中我们做以下事情:
sleep(1);
std::cout << "parent\n";
p.write("done");
wait(nullptr);
首先,我们要做的是睡一秒钟,这样可以确保父进程需要很长时间才能执行。如果不使用同步,子进程会在父进程之前输出到stdout,这是由于使用了sleep()函数的结果。
接下来我们要做的是输出到stdout,然后将done消息写入管道。最后,我们等待子进程完成后再退出。
子进程做以下事情:
auto msg = p.read();
std::cout << "child\n";
std::cout << "msg: " << msg << '\n';
正如所述,read()函数是一个阻塞函数,这意味着它在从文件句柄中读取数据(或发生错误)之前不会返回。我们假设不会发生错误,并将结果字符串存储在一个名为msg的变量中。
由于read()函数是阻塞的,子进程会等待父进程输出到stdout,然后写入管道。无论父进程在写入管道之前做了什么,子进程都会等待。
一旦read()调用返回,我们输出到stdout child 和父进程发送的消息,然后退出。
通过这个简单的例子,我们能够从一个进程发送信息到另一个进程。在这种情况下,我们使用这种通信来同步父进程和子进程。
Unix 共享内存
Unix 共享内存是另一种流行的 IPC 形式。与 Unix 管道不同,Unix 共享内存提供了一个可以被两个进程读写的缓冲区。
让我们来看下面的例子:
#include <string.h>
#include <unistd.h>
#include <sys/shm.h>
#include <sys/wait.h>
#include <iostream>
char *
get_shared_memory()
{
auto key = ftok("myfile", 42);
auto shm = shmget(key, 0x1000, 0666 | IPC_CREAT);
return static_cast<char *>(shmat(shm, nullptr, 0));
}
int main(void)
{
if (fork() != 0) {
sleep(1);
std::cout << "parent\n";
auto msg = get_shared_memory();
msg[0] = 42;
wait(nullptr);
}
else {
auto msg = get_shared_memory();
while(msg[0] != 42);
std::cout << "child\n";
}
}
// > g++ scratchpad.cpp; ./a.out
// parent
// child
在前面的例子中,我们创建了以下函数,负责在父进程和子进程之间打开共享内存:
char *
get_shared_memory()
{
auto key = ftok("myfile", 42);
auto shm = shmget(key, 0x1000, 0666 | IPC_CREAT);
return static_cast<char *>(shmat(shm, nullptr, 0));
}
这个函数首先创建一个唯一的键,操作系统用它来关联父进程和子进程之间的共享内存。一旦生成了这个键,就使用shmget()来打开共享内存。
0x1000告诉shmget()我们想要打开 4KB 的内存,0666 | IPC_CREATE用于告诉shmget()我们想要以读写权限打开内存,并在不存在时创建共享内存文件。
shmget()的结果是一个句柄,可以被shmat()使用来返回指向共享内存的指针。
需要注意的是,一个更完整的例子会将这个共享内存封装在一个类中,这样 RAII 也可以被使用,并且利用 GSL 来正确保护两个进程之间共享的缓冲区。
在父进程中,我们做以下事情:
sleep(1);
std::cout << "parent\n";
auto msg = get_shared_memory();
msg[0] = 42;
wait(nullptr);
与前面的例子一样,父进程在输出到stdout之前睡眠一秒。接下来,父进程获取共享内存区域,并向缓冲区写入42。最后,父进程在退出之前等待子进程完成。
子进程执行以下操作:
auto msg = get_shared_memory();
while(msg[0] != 42);
std::cout << "child\n";
如前所示,子进程获取共享内存缓冲区,并等待缓冲区的值为42。一旦这样做了,也就是说父进程已经完成了对stdout的输出,子进程就会输出到stdout并退出。
exec()函数
直到这一点,我们创建的所有子进程都是父进程的副本,具有相同的代码和内存结构。虽然这是可以做到的,但这种情况不太可能发生,因为 POSIX 线程提供了相同的功能,而且没有共享内存和 IPC 的问题。POSIX 线程将在第十二章中更详细地讨论,学习编程 POSIX 和 C++线程。
相反,更有可能的是对fork()的调用后面跟着对exec()的调用。exec()系统调用用全新的进程覆盖现有的进程。看下面的例子:
#include <unistd.h>
#include <iostream>
int main(void)
{
execl("/bin/ls", "ls", nullptr);
std::cout << "Hello World\n";
}
// > g++ scratchpad.cpp; ./a.out
// <output of ls>
在前面的例子中,我们调用了execl(),这是exec()系统调用系列的一个特定版本。execl()系统调用执行函数的第一个参数,并将剩余的参数作为argv[]传递给进程。最后一个参数总是必须是nullptr,就像argv[]中的最后一个参数总是nullptr一样。
对exec()(和相关函数)的调用会用新执行的进程替换当前进程。因此,对stdout输出Hello World的调用不会被执行。这是因为这个调用是a.out程序的一部分,而不是ls程序的一部分,而且由于exec()用新的可执行文件替换了当前进程,输出就永远不会发生。
这就是为什么fork()和exec()通常一起调用的原因。fork()的调用创建了一个新的进程,而exec()的调用将新的进程作为新的进程执行所需的程序。
这就是system()系统调用的工作原理:
#include <unistd.h>
#include <iostream>
int main(void)
{
system("ls");
std::cout << "Hello World\n";
}
// > g++ scratchpad.cpp; ./a.out
// <output of ls -al>
// Hello World
调用system()时,ls可执行文件被运行,system()函数会等待直到可执行文件完成。一旦完成,执行就会继续,对stdout输出Hello World的调用就会被执行。
这是因为system()调用会 fork 一个新的进程,并从新的进程中运行exec()。父进程运行wait(),并在子进程完成时返回。
为了演示这一点,我们可以制作我们自己的系统调用版本,如下所示:
#include <unistd.h>
#include <iostream>
#include <sys/wait.h>
void
mysystem(const char *command)
{
if (fork() == 0) {
execlp(command, command, nullptr);
}
else {
wait(nullptr);
}
}
int main(void)
{
mysystem("ls");
std::cout << "Hello World\n";
}
// > g++ scratchpad.cpp; ./a.out
// <output of ls>
// Hello World
在mysystem()函数中,我们执行fork()来创建一个新的子进程,然后执行execlp()来执行ls。(对execlp()的调用将在后面解释。)
父进程调用wait(),并等待新创建的子进程完成。一旦完成,对mysystem()的调用就结束了,允许Hello World的输出执行。
应该注意的是,有一些改进可以使这个函数更完整。实际的system()函数会将参数传递给exec()调用,而我们的版本没有。
wait()调用不会检查已完成的子进程是否是被 fork 的进程。相反,wait()的调用应该循环,直到被 fork 的子进程实际完成。
为了向子进程传递参数,我们可以使用execl()进行以下操作:
#include <unistd.h>
#include <iostream>
int main(void)
{
execl("/bin/ls", "ls", "-al", nullptr);
}
// > g++ scratchpad.cpp; ./a.out
// <output of ls -al>
在这个例子中,我们执行/bin/ls并将-al传递给进程。
第二个参数ls与argv[0]相同,它总是进程的名称。就像argv[argc] == nullptr一样,我们的最后一个参数也是nullptr。
如前所述,exec()有不同的版本。看下面的例子:
#include <unistd.h>
#include <iostream>
int main(void)
{
const char *envp[] = {"ENV1=1", "ENV2=2", nullptr};
execle("/bin/ls", "ls", nullptr, envp);
}
// > g++ scratchpad.cpp; ./a.out
// <output of ls>
execle()版本与execl()执行相同的操作,但还提供了传递环境变量的能力。在这种情况下,我们为ls提供了进程特定的环境变量ENV1和ENV2。
到目前为止,execl()函数已经采用了ls的绝对路径。可以使用PATH环境变量来定位可执行文件,而不是使用绝对路径,如下所示:
#include <unistd.h>
#include <iostream>
int main(void)
{
execlp("ls", "ls", nullptr);
}
// > g++ scratchpad.cpp; ./a.out
// <output of ls>
execlp()调用使用PATH来定位ls,而不是使用绝对路径。
另外,exec()系列还提供了使用变量详细说明argv[]参数的能力,而不是直接作为exec()的函数参数,如下所示:
#include <unistd.h>
#include <iostream>
int main(void)
{
const char *argv[] = {"ls", nullptr};
execv("/bin/ls", const_cast<char **>(argv));
}
// > g++ scratchpad.cpp; ./a.out
// <output of ls>
如此所示,execv()调用允许你将argv[]定义为一个单独的变量。
execv()系列调用的一个问题是argv[]在技术上是指向 C 风格字符串的指针数组,在 C++中采用const char *的形式。然而,execv()和相关函数的调用需要char**而不是const char**,这意味着需要使用const_cast来转换参数。
execv()系列还提供了像execl()一样传递环境变量的能力,如下所示:
#include <unistd.h>
#include <iostream>
int main(void)
{
const char *argv[] = {"ls", nullptr};
const char *envp[] = {"ENV1=1", "ENV2=2", nullptr};
execve(
"/bin/ls",
const_cast<char **>(argv),
const_cast<char **>(envp)
);
}
// > g++ scratchpad.cpp; ./a.out
// <output of ls>
在前面的例子中,我们使用execve()传递了argv[]参数和环境变量。
最后,也可以使用路径来定位可执行文件,而不是使用绝对值,如下所示:
\#include <unistd.h>
#include <iostream>
int main(void)
{
const char *argv[] = {"ls", nullptr};
execvp("ls", const_cast<char **>(argv));
}
// > g++ scratchpad.cpp; ./a.out
// <output of ls>
在这个例子中,PATH环境变量用于定位ls。
输出重定向
在本章中,我们已经概述了编写自己的 shell 所需的所有系统调用。现在你可以创建自己的进程,加载任意可执行文件,并等待进程完成。
创建一个完整的 shell 还需要一些东西。其中之一是 Unix 信号,这将很快讨论;另一个是捕获子进程的输出。
为此,我们将利用 Unix 管道进行 IPC,并告诉子进程将其输出重定向到此管道,以便父进程可以接收它。
请参阅以下示例:
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <array>
#include <iostream>
#include <string_view>
class mypipe
{
std::array<int, 2> m_handles;
public:
mypipe()
{
if (pipe(m_handles.data()) < 0) {
exit(1);
}
}
~mypipe()
{
close(m_handles.at(0));
close(m_handles.at(1));
}
std::string
read()
{
std::array<char, 256> buf;
std::size_t bytes = ::read(m_handles.at(0), buf.data(), buf.size());
if (bytes > 0) {
return {buf.data(), bytes};
}
return {};
}
void
redirect()
{
dup2(m_handles.at(1), STDOUT_FILENO);
close(m_handles.at(0));
close(m_handles.at(1));
}
};
int main(void)
{
mypipe p;
if(fork() == 0) {
p.redirect();
execlp("ls", "ls", nullptr);
}
else {
wait(nullptr);
std::cout << p.read() << '\n';
}
}
// > g++ scratchpad.cpp; ./a.out
// <output of ls>
在前面的例子中,我们使用了与前一个例子中创建的相同的 Unix 管道类。然而,不同之处在于子进程不会写入 Unix 管道,而是输出到stdout。因此,我们需要将stdout的输出重定向到我们的 Unix 管道。
为此,我们将write()函数替换为redirect(),如下所示:
void
redirect()
{
dup2(m_handles.at(1), STDOUT_FILENO);
close(m_handles.at(0));
close(m_handles.at(1));
}
在这个redirect()函数中,我们告诉操作系统将所有写入我们的管道的stdout的写入重定向(管道的写入端)。因此,当子进程写入stdout时,写入被重定向到父进程的读取端的管道。
因此,不再需要子进程的管道句柄(并且在执行子进程之前关闭)。
示例的其余部分与我们对自定义mysystem()调用的调用类似,如下所示:
if(fork() == 0) {
p.redirect();
execlp("ls", "ls", nullptr);
}
else {
wait(nullptr);
std::cout << p.read() << '\n';
}
创建了一个子进程。在执行ls命令之前,我们重定向了子进程的输出。父进程,就像mysystem()一样,等待子进程完成,然后读取管道的内容。
要创建自己的完整 shell,需要更多的功能,包括为stdout和stderr提供对子进程输出的异步访问的能力,执行前台和后台的进程,解析参数等。然而,这里提供了所需概念的大部分。
在下一节中,我们将讨论 Unix 信号的工作原理。
Unix 信号
Unix 信号提供了中断给定进程的能力,并允许子进程接收此中断并以任何希望的方式处理它。
具体来说,Unix 信号提供了处理特定类型的控制流和可能发生的错误的能力,比如终端试图关闭你的程序,或者可能是可恢复的分段错误。
请参阅以下示例:
#include <unistd.h>
#include <iostream>
int main(void)
{
while(true) {
std::cout << "Hello World\n";
sleep(1);
}
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
// Hello World
// ...
// ^C
在前面的例子中,我们创建了一个永远执行的进程,每秒输出Hello World。要停止这个应用程序,我们必须使用CTRL+C命令,这告诉 shell 终止进程。这是使用 Unix 信号完成的。
我们可以这样捕获这个信号:
#include <signal.h>
#include <unistd.h>
#include <iostream>
void handler(int sig)
{
if (sig == SIGINT)
{
std::cout << "handler called\n";
}
}
int main(void)
{
signal(SIGINT, handler);
for (auto i = 0; i < 10; i++)
{
std::cout << "Hello World\n";
sleep(1);
}
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
// ^Chandler called
// Hello World
// ^Chandler called
// Hello World
// ^Chandler called
// Hello World
// ^Chandler called
// Hello World
// Hello World
// Hello World
// Hello World
// Hello World
在这个例子中,我们创建了一个循环,每秒向stdout输出Hello World,并且这样做了 10 次。然后我们使用signal()函数安装了一个信号处理程序。这个信号处理程序告诉操作系统,我们希望在调用SIGINT时调用handler()函数。
因此,现在,如果我们使用CTRL+C,信号处理程序将被调用,我们会在stdout上看到handler called的输出。
应该注意的是,由于我们成功处理了SIGINT,使用CTRL+C不再会终止进程,这就是为什么我们使用for()循环而不是while(1)循环。您也可以使用CTRL+/来发送SIGSTOP,而不是SIGINT,这也会终止前面例子中的应用程序。
另一种克服这个问题的方法是使用一个能够停止循环的全局变量,如下所示:
#include <signal.h>
#include <unistd.h>
#include <iostream>
auto loop = true;
void handler(int sig)
{
if (sig == SIGINT)
{
std::cout << "handler called\n";
loop = false;
}
}
int main(void)
{
signal(SIGINT, handler);
while(loop) {
std::cout << "Hello World\n";
sleep(1);
}
}
// > g++ scratchpad.cpp; ./a.out
// Hello World
// Hello World
// ^Chandler called
这个例子与我们之前的例子相同,只是我们使用了一个while()循环,该循环一直循环,直到loop变量为false为止。在我们的信号处理程序中,我们将loop变量设置为true,这会停止循环。这是因为信号处理程序不会在与while()循环相同的线程中执行。
这一点很重要,因为如果不解决这些问题,使用信号处理程序时可能会出现死锁、损坏和竞争条件。有关线程的更多信息,请参见第十二章,学习编程 POSIX 和 C++线程。
最后,在我们结束之前,kill()函数可以用来向子进程发送信号,如下所示:
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <iostream>
void
mysystem(const char *command)
{
if(auto id = fork(); id > 0) {
sleep(2);
kill(id, SIGINT);
}
else {
execlp(command, command, nullptr);
}
}
int main(void)
{
mysystem("b.out");
}
// > g++ scratchpad.cpp -std=c++17; ./a.out
//
在这个例子中,我们再次创建了我们的mysystem()函数调用,但这次在父进程中,我们在两秒后杀死了子进程,而不是等待它完成。然后我们编译了我们的while(1)例子,并将其重命名为b.out。
然后我们执行了子进程,它将永远执行,或者直到父进程发送kill命令。
总结
在本章中,我们全面介绍了 Linux(System V)ABI。我们讨论了寄存器和堆栈布局,System V 调用约定以及 ELF 规范。
然后,我们回顾了 Unix 文件系统,包括标准文件系统布局和权限。
接下来,我们将介绍如何处理 Unix 进程,包括常见函数,如fork()、exec()和wait(),以及 IPC。
最后,本章简要概述了基于 Unix 的信号以及如何处理它们。
在下一章中,我们将全面介绍使用 C++进行控制台输入和输出。
问题
-
System V 架构(64 位)在 Intel 上的第一个返回寄存器是什么?
-
System V 架构(64 位)在 Intel 上的第一个参数寄存器是什么?
-
在 Intel 上将数据推送到堆栈时,您是添加还是减去堆栈指针?
-
ELF 中段和节之间有什么区别?
-
在 ELF 文件的
.eh_frame部分中存储了什么? -
fork()和exec()之间有什么区别? -
在创建 Unix 管道时,写文件句柄是第一个还是第二个?
-
wait()系统调用的返回值是什么?