C 语言技术指南






4.76/5 (18投票s)
一份强调C语言关键特性的初学者指南。
目标受众
本文档的读者应具备C编程语言概念的基础知识。有兴趣的候选人可以访问并关注C编程语言助理(CLA)认证的在线培训计划,该计划由 www.cppinstitute.org 驱动。
背景/历史
C语言由Dennis Ritchie和Ken Thompson发明,经历了多次发布。所以首先,让我们了解这种语言的发展历程,然后继续前进。这些传奇人物在AT&T贝尔实验室发布的第一个版本被称为K&R C。后来在1989年,一个名为美国国家标准协会(ANSI)的委员会将C语言的这个版本标准化,称为ANSI C或C89。C语言的这个版本后来由ISO修订,发布了C99。这个版本引入了新特性,例如内联函数、变长数组、灵活数组成员、更多数据类型、改进的浮点支持、可变参数宏和单行注释支持。然而,自2007年以来,C99又进行了进一步的修订,后来由ISO发布,目前被认为是最新版本,称为C11。这个版本为C语言和库引入了各种新特性,包括类型泛型宏、匿名结构、改进的Unicode支持、原子操作、多线程和边界检查函数。
有人可能会认为C是一种过时的语言,不适合推荐用于复杂的应用程序编程;然而,根据软件质量公司TIOBE对编程语言定位和评级的评估(https://tiobe.org.cn/index.php/content/paperinfo/tpci/index.html),C语言在当今所有现有高级语言中仍然排名第一。
指针
在C语言的所有特性中,指针、内存管理(无论是堆还是栈)以及库与链接策略(静态和共享)是重要的主题。如果一个人想成为这门语言的专家,他应该对C语言中的指针概念有很强的掌握。要理解指针,可以从Ritchie和Kernighan的《C程序设计语言》中给出的精确信息开始。5.1 指针是一组单元格(两个或四个),可以存储一个地址。因此,牢记这一初步理解,我们可以更深入地探讨指针的概念。让我们看一个简单的例子,如下所示:
char *cPointer;
这是一个类型为 char * 的简单指针变量。这意味着一组名为 cPointer 的单元格,它能够存储一个内存块的地址,该内存块确实应该存储 Char 类型的数据。我们可以说这个声明是完整的吗?不。在其他类型的变量中,没有初始化的简单声明就足够了,但指针应该始终初始化为有效地址,或者至少初始化为 NULL,否则它被视为一个野指针。如果我们继续这个声明,并且无意中尝试解引用或使用这个指针,那么 cPointer 持有的任何垃圾地址都将被访问。如果 cPointer 持有的数据属于操作系统为前一个进程分配的某个内存事务,那么那个旧的(更准确地说,垃圾)数据将被当前进程视为有效地址,并且在解引用 cPointer 的行为中,它会尝试将控制权传递给该地址或从该地址获取数据。然而,它最终会导致分段错误——内存访问冲突——即当前进程正在尝试访问其范围之外的内存块。这个错误直接导致操作系统强制终止进程。现在考虑一下,如果这个进程在运行期间持有系统的关键资源;由于系统突然终止,所有这些资源都没有被释放到资源池中,从而导致系统其他进程在资源利用方面出现竞态条件。这种非静态指针在C语言世界中被视为野指针。
所以让我们在这里完成上面的指令,
// Always initialize a non-static pointer to NULL before use.
char *cPointer = NULL;
此时,cPointer 被视为空指针;宏 NULL 在两到三个标准头文件中被赋值为零,这意味着 cPointer 现在已稳定。
这个概念还有另一面。让我们再稍微调整一下这个例子,尝试将一个有效地址分配给 cPointer,如下所示:
char cVariable;
cPointer = &cVariable;
这组指令没有问题。然而,用户可能会不小心将 int 或 double 类型的数据存储在 cPointer 指向的同一个地址上,而不是 char 类型的数据。在这种情况下会产生什么影响呢?cPointer 仅限于指向或识别与 char 大小相等的字节数。因此,传统上,一个 char 的大小等于一个字节。然而,int 和 double 类型的大小都大于 char。无论相邻字节中存储了什么数据,cPointer 都只能将其作用域限制在一个字节的内存块,即 cVariable。这同样应该理想地导致内存访问冲突,但通常程序不会过早终止以在运行时进一步暴露此类问题。然而,它会导致其他变量的数据损坏,这些变量被分配了那些相邻的内存块,最终导致我所说的“影响错误”,这可能导致程序员在解决不可预见的错误时走上错误的轨道。这就是指针开始和我们“愉快地玩耍”的地方。
假设上述指令包含在一个函数中,如下面的代码片段所示:
在这里,如果执行控制超出函数的范围,局部 cVariable 的内存也会超出范围,从而将 cPointer 的行为更改为悬空指针。由于 cPointer 指向同一个旧内存位置并且未重置为 NULL,下次该内存可能会被程序中的另一个变量分配,从而导致 cPointer 在函数范围之外被解引用时产生不可预测的严重结果。
#include <stdio.h>
char *cPointer = NULL;
void FunctionA( )
{
char cVariable = 0;
cPointer = &cVariable;
/* Pointer operations here */
}
int main()
{
FunctionA( );
/***
* If cPointer is de-referenced here,
* it may lead to serious consequences.
*/
return 0;
}
建议养成习惯,练习各种探索 C 指针安全和致命用法的示例。这是唯一能够学习并预测复杂 C 应用程序中指针神秘行为的方法。在检测指针问题时,程序员应该能够区分根本错误(由于指针使用或实现不正确或粗心导致的错误起源)和影响错误(由于根本错误发生而在其他指令中引入的错误),因为这可以节省大量时间,而不是一开始就发现自己走错了路或死胡同。
与指针相比,数组被认为是存储数据更安全可靠的方式。而指针则让程序员承担了繁琐而精细的内存分配、利用和释放操作。在我看来,数组是“编译时指针”(或法律术语中的常量指针),当声明一个数组变量时,编译器会确定其地址、内存块数量和该数组变量的生命周期。真正的指针则不然,因为程序员可以在任何时间点更改其地址、大小和生命周期。这可以看作是数组和指针之间精确而语义上的区别。对于真正的程序员来说,如果明智而谨慎地使用,指针是C语言的宝贵馈赠。尽管如此,程序员可以将这两种特性结合起来,实现安全的内存管理。
内存管理
为了方便地组织和存储应用程序数据(我认为),C语言提出了一种框架,可以将不同类型的数据打包在一起,称为结构体。联合体继承自结构体,以便更好地利用联合体数据成员之间共享的内存。C语言结构体与指针和一种称为链表的动态数据结构结合使用时,可以实现数据的即时(in-cache)存储和导航。链表是一种数据结构,由一组节点组成,每个节点包含数据和指向下一个节点的引用。结构体和联合体在C语言中都被视为用户定义的数据类型。人们可以根据两个关键因素,即数据结构的大小和数据结构中存储的数据的存储类型,很好地描述C语言中数组和链表之间的区别。正如前面所解释的,即使是指针使用中的一个小错误也会导致严重问题;使用指针实现链表需要特别注意。想象一列火车是一个链表,由许多车厢组成,其中每节车厢代表链表中的一个节点。火车上的乘客年龄、性别、预订号等各不相同,这些信息将共同编译成一个结构体。链表的节点通过指针顺序链接;每个这样的节点都包含一个结构体(乘客数据)和指向下一个节点的引用(指向火车下一节车厢的指针)。除非当前节点的引用用下一个节点的有效地址初始化,否则该引用将被视为野指针。在继续这个例子之前,我们必须采用我们的内存操作知识,因为链表实现涉及使用动态内存(堆)。
内存将分为两个主要部分:静态内存和动态内存。静态内存由操作系统在C应用程序的编译和执行阶段管理。相比之下,C程序员被授予自行管理部分系统内存的权利,这被称为动态内存(堆)。使用堆内存的程序员将全权负责其管理,因为C不提供内置的自动垃圾回收。这部分是程序员会遇到内存相关问题的地方,例如内存泄漏、内存损坏、分段错误等。未能在使用后释放一组已分配的内存,将导致内存泄漏,使该部分内存系统无法使用,直到系统重启;从而减少系统中其他进程可用的总内存。如果这种内存泄漏在C应用程序的每次运行中都以更大的规模重复发生,在某个时候可用内存量将耗尽,系统将崩溃。这就是内存泄漏的严重影响。
内存损坏被认为是另一个严重的系统问题,具有类似的影响。试图使用野指针或悬空指针,以及试图释放已经释放的指针,都会导致内存损坏问题。
C可执行文件的不同段是文本/代码段、初始化数据段和BSS段。可执行指令位于文本/代码段中。已初始化的全局变量和静态变量直接进入初始化数据段。BSS(由符号开始的块)段仅保存未初始化全局变量的统计信息,即BSS在运行时所需的空间量。
局部变量(无论是已初始化还是未初始化)不进入可执行文件,而是在运行时创建。当操作系统接管程序可执行文件进行执行时,它会开始将可执行文件的不同部分映射到系统内存段,并且在程序执行期间根据需要开始使用栈和堆内存。有关此内容的更多详细信息,可以参考Peter Van Der Linden的《Expert C Programming》一书中的“Segments”部分和“Figure 6.2 How the segments of an executable are laid out in memory”。
栈内存段从最高的内存地址开始填充,并在进程地址空间中向下增长。例如,每当调用一个函数时,下一条指令的返回地址将压入栈中,随后是函数属性,例如返回类型、参数和函数执行期间使用的临时变量。由于该段遵循LIFO(后进先出)顺序来保留内存块,因此它被称为栈段。释放一个栈块无非是调整栈指针。这个段让我想起了递归函数,它利用栈段进行嵌套函数调用。这就是为什么我总是提到递归功能的限制完全取决于为运行递归函数的特定进程分配的栈大小。如果递归函数被调用了大量次,它将导致进程突然终止。
与栈和其他段不同,堆只在运行时需要扩展内存时才在进程地址空间中可用,以便在执行中相应进程需要时可以从堆中保留内存块。对于堆,程序员全权负责保留堆的内存块,一旦工作完成,他需要负责将这些特定的内存块释放回堆中可用的内存块池。由于其通过内存块的简单栈指针导航,栈在性能上被认为比堆更快,堆需要更复杂的簿记活动来分配或释放内存块。然而,堆段具有运行时内存扩展的优点,而进程的栈分配是固定大小的。
既然我们对动态内存有了足够的了解,让我们回到链表的例子。与真实的火车(具有顺序的车厢组)不同,在火车的链表示例中,车厢可能会在堆中获得随机的内存块。在这些车厢中遵循顺序的唯一方法是引用指针,如果指针搞错了地址,那么火车剩余的车厢将迷失在堆段的海洋中,从而导致内存泄漏。
如前所述,栈上的内存块大小是固定的,而堆段可以保留任意数量的内存块(也可以在运行时)。让我们尝试理解堆段提供此类内存可用性的原因。与其他段使用连续内存块排列的方式不同,堆内存被认为是整个内存池中当前可用内存块的集合。堆的工作是有效地管理应用程序的进程内存和地址空间。每当使用 `malloc()` 和相关的动态函数调用保留一组内存块时,进程地址空间的大小会相应增加。相反,每当使用 `free()` 释放此保留内存时,相应进程地址空间的大小不会缩小。通常,当进程使用堆内存时,它位于数据段旁边(并作为数据段的一部分),即紧邻数据段的BSS区域上方,并向上增长,而不是像栈那样。过度使用堆也会导致一个关键的内存管理问题,称为内存碎片。这可以用一个例子来解释。假设堆有20个内存块,其中前5个块为进程A保留,接下来的2个块被进程B使用。一段时间后,如果进程A将这5个块释放回堆,则系统将总共有18个块可供其他进程使用。然而,如果进程C需要15个内存块,堆将无法提供,因为这18个可用块分布在两个不同的集合中,分别是13个和5个。因此,除非进程B释放位于可用内存块的两个碎片之间的2个块,否则系统无法满足进程C的请求。这只是大局中的一小部分,堆需要跟踪所有这些可用内存块的碎片,并在内存保留或释放时不断更新这些统计信息。我在此结束我们对内存管理的讨论,因为这是一个巨大而无止境的话题,而且从C语言的角度来看,所需知识已尽其所能地分享。
库和链接策略
在C程序转换为二进制文件或库文件之前,我们来谈谈它的编译和链接阶段。对于本文档,我们将只使用GNU编译器集合(GCC)。尽管GCC在Linux和Unix平台上运行,但Windows程序员可以安装并使用MinGW等工具在Windows系统上使用GCC。通常,任何C程序都必须经过编译阶段才能生成一个目标文件(无论是库目标文件还是二进制目标文件)。如果程序员更感兴趣于展示某些可重用的C函数供其他程序员使用,可以将这样的C程序转换为可重用库,方法是使用“-C”(连字符后跟字母C)选项编译程序,该选项指示编译器在成功编译后从C程序生成库目标文件。省略此选项将导致编译器生成可执行目标文件,如果程序中未定义`main()`函数,编译器将抛出错误。
谈到C语言中的库,可以生成两种类型的库——静态库和共享库。每当程序编译生成.o文件时,它就可以用于链接和调用应用程序的库函数。但是,如果构建应用程序可执行文件时需要链接几十个这样的.o库文件怎么办?在构建应用程序二进制文件时,不能简单地列出和链接所有这些文件。一个更好的选择是将这些.o文件收集并绑定到一个存档文件中,称为静态库,该库本身可以链接到应用程序。您可以将这样的库与真实的图书馆进行比较,其中图书馆中的所有书籍都可以被视为单个目标文件。那么,静态库和共享库之间有什么区别呢?或者为什么需要另一种称为共享库的库呢?可以使用一个重要的平台特定选项“--shared”(连字符后跟关键字shared)将同一组程序目标文件组合在一起形成共享库,该选项适用于Linux平台,并且与Linux编译器一起使用时,会生成带有.so扩展名的共享库。
这两种库的基本区别在于它们的名称:静态库在应用程序构建阶段链接时,会成为可执行文件的一部分。这就像将整个库函数副本(在主程序中调用)嵌入到可执行文件的文本/代码段中,从而增加了二进制文件的大小。然而,共享库在与可执行文件链接时,会在运行时动态链接,而不会将函数嵌入到可执行文件中。运行数十或数百个进程的系统,在链接时进行代码重用只解决了更大问题的一部分。然而,随着现代操作系统中的内存管理实现,还可以在运行时共享代码。这是通过只将代码加载到物理内存中一次,并通过虚拟内存将其重用于多个进程来完成的。这类库被称为共享库。
此外,共享库的动态链接有两种方式。首先,在应用程序的编译或构建阶段,共享库必须可用,并将与可执行文件绑定。从某种意义上说,应用程序可执行文件在构建阶段就已确保了库的可用性和位置,但它并没有成为可执行文件的一部分。另一种方式是通知可执行文件,库将在执行时(即运行时)加载并进行运行时链接,而无需事先提供有关库可用性或位置的任何信息。
简而言之,静态库是链接编辑然后加载运行的,而共享库是链接编辑、加载并在运行时链接运行的。在执行时,在调用main()之前,运行时加载器将共享数据对象带入进程地址空间。它不会解析外部函数调用,直到实际发出调用,因此链接到您可能不会调用的库没有惩罚。动态链接是更现代的方法,具有更小的可执行文件大小的优势。当多个可执行文件/应用程序动态链接到共享库时,它们可以在运行时共享该库的单个副本。
通常,您的程序链接到的库路径可以使用“-L”(连字符后跟大写字母L)选项后跟库名称以及另一个选项“-l”(连字符后跟小写字母l)来指定,如下例所示。请注意,如果在链接器命令中未提及-L选项,系统将在使用LD_LIBRARY_PATH环境变量设置的路径下查找库的可用性。
但是,这并非关于库和链接话题的全部,此讨论仅应被视为我尽力呈现的入门信息,该话题还有许多其他方面未在此处涵盖,因为它具有广泛的用途和重要性,与本文档无关。
文件处理
借助fopen、fread、fwrite和fclose等基本C文件处理操作,程序员可以构建数据库应用程序。但是,您是否曾想过这些操作是如何得到支持的,以及为提供文件处理指针而定义的后端结构是什么?每当我们开始编写文件处理代码时,我们键入的第一行是:
FILE *fPointer;
这意味着 fPointer 是一个 FILE 类型的指针变量,它实际上是 stdio.h 头文件中定义的 _iobuf 结构体的 typedef 变量。每当 fopen 成功调用时,这个结构体就会填充所需的数据,以获取系统的文件打开调用。请记住,fopen 是一个 C 库函数,它内部执行系统的真正打开调用。同样,其他文件处理操作也在系统级别获取。每个处理文件处理操作的进程都会有自己的文件描述符表,每当创建一个新文件或打开一个现有文件时,该文件的信息条目将被添加到此表中。相应地,每当文件关闭时,文件描述符表将通过删除该特定条目进行更新。通常,一旦系统启动新进程并为该进程创建文件描述符表,系统会自动在此表中生成三个条目,如下所示:
- 标准输入(STDIN_FILENO),用值0表示,接收键盘数据。
- 标准输出(STDOUT_FILENO),用值1表示,将数据发送到屏幕。
- 最后,标准错误(STDERR_FILENO)表示值2,并将数据发送到屏幕,也可以重定向到日志文件。
如果当前进程调用 `fork()` 系统调用,从而创建一个子进程,则此表的副本将传递给新进程。从高层次看,内核使用三个不同的表来管理每个进程的打开文件信息:进程表、文件表和 v-node/i-node 表。
字符串
C语言中的字符串是字符数组,以空字符(‘\0’)结尾。对于字符串常量,编译器会自动在字符串末尾添加一个空字符。C语言提供了各种库函数来管理字符串处理功能,例如strcpy、strcat、strstr、strtok等,所有这些函数都假定字符串以空字符结尾。我们可以在C语言中使用数组和指针来处理字符串。将字符串存储在字符数组中时,一个特殊的问题是它不会自动以空终止符或‘\0’字符结尾,这就是为什么大多数字符串库函数会失败,导致缓冲区溢出甚至分段错误。这是因为C语言不支持对变量内容进行自动边界检查。我认为所有这些字符串函数都是内存中的“盲人工作者”,如果不是有意识地引导,它们会引入严重的错误,甚至可能强制系统在运行时终止进程。
此外,还有其他函数可以在C程序中处理字符串,例如 memset、memcpy、memchr、memcmp 等。人们可能对这些函数之间的区别感兴趣,因为它们都具有相同的目的。例如,让我们比较 memcpy 和 strcpy。库函数 strcpy 继续将数据从给定源位置复制到目标位置,直到它在源字符串中检测到 NULL 终止符,而且它甚至不关心它复制到目标的字符数量,即使目标没有剩余空间。相比之下,memcpy 复制的字符数量正好等于函数中的第三个参数,而不考虑 NULL 终止符。即使它在将数据从源复制到目标时检测到 NULL 字符,它也不会停止复制操作,并继续复制直到给定数量的字符被复制到目标。使用 memset,可以轻松地将指定数量的内存块用任何字符刷新或初始化,根据需要。通常,我们使用 memset 将最近分配的一组内存块初始化为 NULL 终止符,以清除内存之前持有的旧/垃圾数据。我更喜欢使用第二组函数,因为它在 C 程序中处理字符串时,提供了精确的内存块操作透明度。
预处理器
这个名字本身就表明它是一个在程序进入编译阶段之前的预处理行为。以“#”字符开头的指令将在程序将代码提交给编译器程序之前被识别和处理。那么,为什么要真的需要这个步骤,而不是让编译器自己完成这项工作呢?
第一个原因之一是为了优化**代码可维护性**(这在编译时不容易实现),如果在编译过程中某些数据需要持续更新,#define宏可以高效地完成这项工作,只需在一个地方进行代码更改,而不是在代码中宏被扩展的任何地方复制这些更改。
预处理器的第二个主要用途是为您的 C 程序带来**可移植性**功能。条件宏指令有助于添加这种平台特定的灵活性,您可以在其中编写特定于某个平台的指令集,并且只有在该特定平台上进行编译时才需要处理这些指令。您可以将预处理器视为一个过滤工具,它根据平台,在程序被编译器处理之前,确保哪些代码行需要处理,哪些需要跳过。
使用预处理器的另一个关键功能是文件包含,其中程序声明可以从当前代码文件中分离出来,并且只使用 #include 指令添加一行代码。文件包含的第二种用法是包含一个标准或用户定义的库文件,其中包含一组已定义的 C 函数,将其包含到现有代码文件中,从而实现**代码重用**。一个典型的例子是 #include <stdio.h>,当它包含在我们的源文件中时,方便程序员使用标准 I/O 流函数。
#pragma 指令是标准 C 的扩展,它允许程序员添加新的预处理器功能或向编译器提供实现定义的信息。#error 是另一个新的指令,它生成一个编译时错误消息,其中包含宏扩展的主题参数标记。#error 将立即终止编译。
运算符和求值顺序
C语言提供了许多运算符来执行算术、逻辑、关系以及更多类型的操作。这些运算符被赋予了特定的优先级,以确保当在单个指令中使用两个或多个运算符时,这些运算符能够明确地求值。
我们知道赋值运算符(=)和等于(==)比较运算符之间存在差异;但是,人们可能会不经意地将赋值操作写成比较,例如:
if(a = b) executeMe(); else endProgram();
在这里,a 被赋值为 b 的值,而不是将 a 的值与 b 进行比较,然后将 a 与 0 进行比较,因此如果 a 的值非零,代码片段将通过条件并执行 executeMe() 函数。程序员从未打算这样做。以类似的方式,一些逻辑运算符和位运算符也可能被无意中交换,即 & 与 &&,以及 | 与 || 运算符。
除了运算符的优先级之外,还需要注意结合性因素。因为编译器被编程为以特定的方向评估某些运算符集。由于括号在所有其他运算符中具有最高的优先级,编译器会在内部使用括号将表达式(在单个指令中包含多个运算符)分成更小的表达式,然后它开始并行评估这些被分割的表达式集(每个括号对内的表达式),因为所有这些集合都是相互独立的。只有在评估了这些单独的表达式并得到一个目标值之后,编译器才会对这些值进行最终评估。现在,编译器在单独评估表达式(当它被括号分割时)以及在最后阶段同时寻找的另一个因素是类型转换,即当它发现一个运算符/表达式的两个操作数具有不同类型的数据时,进行向上转型或向下转型操作。
假设我们有一个简单的表达式如下:
int a=10, c;
float b=2.3;
c = a * 10 / b - 2 * a + 3;
那么,编译器如何处理这样的表达式呢?正如我所说,它使用分而治之的规则,即表达式将进一步分解为更小的表达式,如下所示:
c = ((((a * 10) / b) - (2 * a)) + 3);
如这里所示,幸运的是,此表达式中的所有运算符都具有左到右的结合性,因此括号的使用是相应的。首先,变量“a”后面的乘法运算符被赋予最高优先级,然后是除法运算符,接着是第二个乘法运算符,然后分别是减法和加法。正如我前面提到的,编译器也会跟踪类型转换活动。此表达式中的变量“b”是浮点数。因此,在除法过程中,(a*10) 的输出被提升为浮点数,然后进行除法,结果是一个浮点值。同时,表达式 (2*a) 结果为 20。现在,由于我们有一个浮点数作为减法运算符的左操作数,值 20 被提升为浮点值,从而得到 23.48。现在,最终表达式也遵循将值 3 向上转换为浮点数,从而得到浮点值 26.48。相反,结果变量“c”只能存储整数值,编译器将对右侧进行向下转换,从而将值 26 存储在结果变量“c”中。
到目前为止,我希望您已经清除了之前对运算符求值过程的粗略假设和模糊概念,并掌握了编译器的精确行为和解析运算符表达式的基本规则。
进程
进程是如何创建的?此外,进程和程序之间有什么区别?一个二进制文件,即一个可执行文件,在进入执行阶段之前被视为一个程序。另一方面,进程是一个概念,是“正在执行的程序”的逻辑术语。那么操作系统如何处理进程呢?当我们执行一个二进制文件时,我们指示系统从磁盘上的指定位置拾取二进制文件,将二进制文件的相应段映射到主内存中的段,查找文本段中的初始入口点,并开始执行该段中的指令,同时根据需要利用其他段。正如内存管理部分所述,这些段构成了进程地址空间,一旦文本段中的指令被执行,程序就变成了由操作系统处理的活动进程。
如果程序员想创建进程而不是让系统代劳呢?这时最常见的系统调用 fork() 就登场了,它会派生现有进程并产生一个子进程。现在,使用这个系统调用,程序员可以给子进程分配任何任务,同时要求父进程(创建子进程的进程)继续执行其他指令集。新创建的进程会获得父进程地址空间的一个精确副本。每个进程都有一个唯一的标识符,称为进程ID,以及一个进程控制块,它对应于内核进程表中的一个条目。如果想让现有进程在不创建新进程的情况下执行磁盘上的新程序怎么办?这可以通过使用 exec() 系统调用来实现。exec() 系统调用有不同的变体,方便程序员用新程序替换现有程序的执行。
那么,有没有一个进程来执行操作系统呢?是的,每当我们说系统启动时,`init` 是第一个在后台运行的进程(至少在 Unix/Linux 环境中是这样),所有其他系统/应用程序进程都在其之上被创建、执行、监控、终止、中断和杀死。在父子进程的形式中,如果父进程在子进程之前死亡,那么 `init` 将成为该子进程的直接祖先。
除非您了解进程及其相关方面,否则在尝试追查系统级错误时,理解 C 程序的运行生命周期和系统对它的处理将非常困难。
参考文献
[1] 《C程序设计语言》,第二版(Dennis Ritchie 和 Richard Kernighan 著)
[2] 《C语言编程精粹:深C秘境》(Peter Van Der Linden 著)
[3] 《C语言参考手册》,第五版(Samuel P. Harbison III 和 Guy L. Steele Jr. 著)
[4] 《C语言陷阱与缺陷》,Andrew Koenig 白皮书。