Unix-环境高级编程笔记-全-

Unix 环境高级编程笔记(全)

001:课程介绍与概述

在本节课中,我们将学习《UNIX环境高级编程》这门课程的整体框架、学习目标、评估方式以及所需资源。我们将明确课程定位,了解学习内容,并做好充分的学习准备。

课程概述与定位

大家好,欢迎来到CS 631课程:UNIX环境高级编程。

我是Janan Shaoman,自2005年左右起在史蒂文斯理工学院担任兼职教授,教授本课程以及CS 615系统管理课程。此外,我在Verizon Media担任首席基础设施与安全架构师。

您可以通过电子邮件 jasalmer@stevens.edu 联系我。课程网站链接显示在幻灯片中。

尽管我已经教授这门课程近15年,但这是第一次完全在线授课。我们将把讲座分解成更小的片段,供您异步学习。预定的课堂时间将用于互动讨论和解答问题。

今天的讲座是对课程的总结。我们将讨论课程内容、学习方式、教学大纲以及您应该收藏的资源。在第二部分,我们将回顾UNIX操作系统的历史,并快速浏览UNIX编程环境和C语言的一些特性。

由于这是我们第一次翻转课堂并将所有内容移至线上,我将依赖您在整个学期的反馈,以帮助您从这门课程中获得最大收益,并帮助我成为一名更高效的教师。

课程内容与目标

这门课程名为“UNIX环境高级编程”。每个词都经过精心挑选,明确这门课程“不是什么”同样重要。

具体来说,这门课程不是UNIX使用入门。所有学生都应能熟练地仅通过命令行使用类UNIX操作系统。我假设您能够使用常见的UNIX文本编辑器,知道如何查找、搜索和管理文件,如何使用Shell和各种常用工具,以及如何编译和运行程序。

其次,这门课程不是编程入门课。您应具备编写大型程序的经验,并熟悉编写和调试代码所涉及的常见实践范式。

最后,在本课程中,我们将使用C编程语言,您也应熟悉它。请注意,C和C++是有区别的。C编程语言与UNIX操作系统紧密相连。在本课程中,我们只编写纯C语言。

总而言之,如果您对屏幕上终端里显示的任何内容感到陌生或奇怪,那么这门课程可能不适合您。它被称为“UNIX环境高级编程”是有原因的,而这正是我们将要做的。

UNIX环境概览

既然我们已经明确了课程定位,现在让我们谈谈这门课程具体是什么。

当我们谈论UNIX环境高级编程时,让我们快速了解一下这个环境。如您所知,系统在 /bin 目录下提供了许多标准工具。到本课程结束时,您应该能够实现这些工具中的任何一个。

您应该能够查看给定工具的手册页,并从中确定如何编写代码来提供给定功能,了解可能遇到的一些边界情况和隐藏要求。

事实上,到第一周结束时,我们已经研究了如何从最基本的层面实现一个交互式Shell、ls命令以及cat实用程序。因此,您已经熟悉的一些最基本命令,我们将在第一讲中在一定程度上涵盖。

看看您在 /bin 目录中找到的命令。想一想所有这些程序是做什么的,以及您将如何实现它们。您能为其中三四个命令写下伪代码吗?

但本课程不仅仅局限于我们日常使用的命令和实用程序。此外,我们还将研究进程间通信,甚至是在客户端-服务器模型下的网络编程。

屏幕上显示的是实现跨互联网主机通信、监听套接字、接受连接、发送和接收数据所需的大部分网络库函数。请注意,所有这些函数都在一个整数文件描述符上操作,从而提供了一个简单、灵活且一致的API。

我们将在未来的讲座中详细讨论这一点。

学习目标与意义

那么,我们在这门课程中要做什么?显然,我们将在UNIX环境中进行编程,正如课程名称所示。但正如学术界常见的那样,一门课程的成果和教训远远超出了实际执行的任务。

也就是说,我们将从程序员的角度深入了解UNIX操作系统。我们还将获得系统编程经验。系统级编程与内核级编程、嵌入式环境编程、移动应用或数据库编程等有所不同。

我们将使用UNIX环境,并理解它是如何实现的,以及我们如何为UNIX环境编写工具。在此过程中,我们将进一步加深对许多基本操作系统概念的理解。即使我们专注于UNIX家族,这些概念也适用于其他操作系统家族。

你们中的许多人可能已经对这些概念有所了解,但我相信,在课堂上重新审视这些概念将巩固您的理解并加深您的知识。

这些概念包括:

  • 多用户概念:一个必须同时容纳多个用户的操作系统将如何运作及其影响。
  • 基本和高级文件I/O
  • 进程关系
  • 进程间通信
  • 如前所述,我们将讨论使用客户端-服务器模型进行基本网络编程,这似乎是任何程序员发展的良好基础。

学习所有这些听起来很棒,但我们为什么要这样做呢?当然,您这样做可能是为了获得好成绩并能够毕业,但我天真地希望我们在学习这门课程时有额外的目标。

首先,理解UNIX操作系统家族能让您更好地理解其他操作系统概念。我们在本课程中获得的系统级经验将使我们成为更好、更高级的系统用户,让我们更好地理解所遇到的所有程序和应用程序的局限性。

其次,如前所述,我们使用C语言编程。如今,C语言通常被认为是一种低级编程语言。使用像C这样的语言进行现代编程任务存在许多问题。然而,理解C的工作原理及其局限性将帮助我们更好地理解许多通用编程和操作系统概念。

最后,C语言远未过时,事实上,它仍然无处不在。当我们查看标准库的不同API和接口时,会发现许多(如果不是大多数)高级编程语言最终都依赖于这些标准库。从系统角度来看,C语言仍然是事实上的标准。理解如何在UNIX环境中编写C语言将使您成为一名更全面的程序员。

学习环境与工具

现在我们知道要做什么以及为什么做,让我们看看如何去做。正如我们将在下一部分讨论的,UNIX操作系统家族的历史悠久而复杂,随着时间的推移出现了不同的变体。

在本课程中,我们需要一个单一的参考平台,以确保所有学生在相同的环境中工作,并且我可以在该平台上对您的作业进行评分。因此,我们将使用NetBSD操作系统作为我们的参考平台。

当然,您可以在您的macOS系统或Linux VPS上开发和运行代码,但最终,您的代码需要在NetBSD 9.0系统上编译和运行,我将在该系统上测试和评分。为了让您更容易上手,我整理了如何在VirtualBox虚拟机中安装NetBSD的分步说明。

这是我在整个课程中将使用的环境,这些幻灯片、讨论或邮件列表中显示的所有代码和终端示例或片段,除非另有说明,均来自NetBSD 9.0虚拟机。因此,尽早设置好这个参考平台符合您的利益。

代码阅读与风格

编程的另一个重要方面是能够阅读代码。事实上,阅读代码是一项关键技能,在编程或计算机科学教育中并不总是得到足够重视。

在本课程中,您应该养成大量阅读代码的习惯。幸运的是,如今许多流行的UNIX变体都是开源的,我们可以轻松浏览它们的源代码。能够驾驭整个操作系统源代码树并识别在哪里找到您要找的代码片段,是一项非常重要的技能。

NetBSD操作系统也是一个开源操作系统。因此,我建议您获取并解压源代码,熟悉代码库,浏览源代码树,打开几个源文件,看看不同的工具是如何实现的,以及如何跳转到C库等。

浏览源代码树,找到我们之前提到的实用程序,看看您是否能找到源代码,然后看看您是否能理解。另一个有趣的事情是比较不同系统如何实现相同的工具。

现在,显然阅读代码只是一部分。当人们听到“UNIX环境高级编程”时,想到的更明显的部分是,我们将在这门课程中编写大量代码,并且我将对您的代码质量非常挑剔。

代码是沟通。代码不仅对现在的作者来说需要易于阅读,对其他人也是如此。原因是,您将花费不成比例的大量时间来阅读和调试代码,而不是实际编写代码。

因此,对于本课程中的每一项作业和所有代码,请确保:

  • 结构清晰:您的代码应被良好地分离、模块化,拆分为函数和不同的模块,以提供易于辨别的合理结构。
  • 格式规范:代码需要格式良好,使用适当的换行和空格,并保持一致。
  • 风格一致:我们将使用特定的编码风格,我将在所有作业中强制执行。
  • 命名有意义:确保您在声明变量、使用函数或对象时,使用描述性和直观的名称。
  • 注释得当:我们只在必要时提供注释。注释应解释为什么做某事,而不是如何做。

我们有一个编码风格指南链接在本幻灯片的底部。

课程评估与要求

现在谈更实际的事情。这是一门大学课程,您支付了高昂的学费。我们必须在学期末给您一个成绩。

即使这门课程是在线的,我们仍希望有互动交流。您的参与很重要。我期待您发言、贡献、提问、跟进讲座,并在精神上全程参与。

因此,课程参与以及每周以课程笔记形式进行的准备(我们稍后会详细讨论)将占50分。

将有两个较小的编程作业,代码量可能在200行左右。然后会有一个更重要的期中项目,在第二周后布置,代码量可能达到几百行,甚至多达2000行。之后会有一个由两到三人团队完成的大型项目,最后在学期末还有一个个人项目。

所有这些分数加起来总共500分。字母成绩的评定方式在课程网站上有说明。

课程笔记与作业提交

如前所述,您的课程参与将部分根据您所做的课程笔记进行评估。我发现这有助于学生为上课做好准备,并引导他们度过整个学期。

具体做法如下:您应该创建一个Git仓库,为每次讲座准备一个单独的文本文件。在每次讲座之前,在该文本文件中记录您阅读的内容、完成的代码练习以及您的问题。这应该能帮助您为课堂做好准备,然后在课堂或邮件列表中提出这些问题。

每次讲座后,我希望您回去写下您是否找到了问题的答案,或者记录下您学到的特别感兴趣的内容。当然,讲座中经常会出现新的问题,您可能也想记下来。

学期结束时,您将所有笔记提交给我。这样做的目的是让您有一种方式来回顾整个学期的进展。

我们布置的编码作业将发布到课程邮件列表,并在视频讲座中宣布。您有责任注意截止日期并按时提交代码。如果发生特殊情况,请立即联系我。如果情况需要,我可以批准延期。

考虑到当前情况并适应新的在线教学大纲,我还改变了本课程的另一个重要方面:将没有补考作业或学期末的额外学分。但是,如果您提交的作业没有获得A,您可以在一周后根据我的反馈重新提交改进后的代码,以提高成绩。

最后,我必须明确指出,您对自己的作业负责。每个学期,至少有一名学生提交非自己编写的代码。这构成抄袭,将立即导致不及格。即使我们使用的UNIX系统代码是公开可用的开源代码,您也不能将其作为自己的作业提交。

避免任何问题的最佳方法是坐下来自己编写所有提交的代码。如果您遇到问题或有疑问,请通过课程邮件列表联系,我们鼓励大家分享代码片段或讨论最佳方法。

教学大纲与资源

现在我们已经处理完所有这些形式上的事情,让我们看看我们的教学大纲。它将大致遵循教科书的提纲,尽管我们也会加入关于高效使用UNIX环境的讲座。

除此之外,我希望您能发现我们所涵盖主题的某种递进关系。我们将从本地文件I/O和文件系统开始,然后研究进程关系,接着是进程间通信和网络编程,最后通过一些混合和高级主题来完善我们对系统的理解。讲座的顺序可能会有所变化。

在这张幻灯片上,我整理了最重要的课程资源,您现在应该已经收藏了。

最关键的是课程网站链接,它是本课程的权威信息来源。请确保您参考课程网站获取所有材料。

第二重要的是课程邮件列表,这将是我们主要的沟通方式。我已订阅所有当前注册的学生。如果您没有订阅,请使用您的 stevens.edu 邮箱地址自行订阅。邮件列表是一个讨论列表,而不仅仅是公告列表。我期望您参与列表上的讨论。

我还为这门课程设置了一个Slack频道,并邀请了所有注册学生加入。Slack频道旨在让我们进行半同步的讨论,您可以随时分享链接、提问或进行代码分析。然而,虽然邮件列表是必读的,但Slack频道对您是可选的。

最后,这里链接到课程的YouTube频道,我将在每周完成后上传这些视频讲座。

本周任务与总结

看起来我们即将到达第一部分的结尾,让我们回顾一下您需要完成的家庭作业,以便从这门课程中获得最大收益。我想强调的是,我期望您完成的家庭作业主要是为了引导您学习,并从这门课程中获得最佳和最多的经验。

对于每次讲座,您应该复习前一周的幻灯片和笔记,观看课程的视频讲座和幻灯片,跟进问题,关注课程网站上该周的链接,并完成推荐的练习。课程网站上有许多所谓的“推荐练习”,它们不是评分作业。我整理了一系列我认为能帮助您更好地理解该周主题的问题或任务,强烈建议您将其用作自学工具,以加深对主题的理解。

您还会注意到,我的讲座幻灯片包含大量代码片段。因此,我建议您在观看讲座后,花时间运行我们使用的命令和示例。这不仅能帮助您理解我们的目标,甚至可能教您一些UNIX环境中的技巧。

当然,您还应该按照我们的讨论更新您的课堂笔记。

对于本周,您的家庭作业基本上是为本课程做好准备:收藏所有资源、初始化您的课程笔记,并设置好您的NetBSD参考平台。如果您在这些任务中遇到任何问题,请发送邮件到邮件列表,我们将在那里讨论。

好了,这就结束了我们2020年秋季学期CS 631 UNIX环境高级编程第一周的第一个视频片段。我希望您能集中注意力,并发现这个视频讲座对您有帮助。本视频的幻灯片当然也可以从课程网站获取。

在我们的下一个视频片段中,我们将介绍UNIX的历史,并了解UNIX编程环境的一些基础知识以及C编程语言的重要特性。感谢观看,下次再见。

002:UNIX历史 🕰️

在本节课中,我们将简要回顾UNIX操作系统的历史。我们将了解其起源、发展过程中的关键事件、主要分支以及它如何演变成当今无处不在的操作系统家族。

概述

上一节我们介绍了课程大纲和整体安排。本节中,我们来看看UNIX操作系统的历史。这段历史充满了引人入胜的诉讼、观点分歧以及一场“圣战”(尽管我们避开了最敏感的政治争论,即viemacs的编辑器之争,以免冒犯任何人)。

UNIX的起源

UNIX操作系统家族的发展历程漫长,从一个在PDP-7上运行的、用于测试和太空旅行游戏的平台,演变为如今使用最广泛的服务器操作系统。

它的历史始于新泽西州的AT&T贝尔实验室,由丹尼斯·里奇和肯·汤普森(如图中在PDP-11上工作的两位)与麦克罗伊博士、乔·奥桑纳等人共同开发,旨在替代Multics操作系统。

C编程语言是并行开发的,由丹尼斯·里奇在B语言(由肯·汤普森为编写的新操作系统开发)的基础上创造。里奇在幻灯片链接所示的文章中描述了该语言和操作系统的历史。

最终,大约在1973年左右,UNIX本身被用C语言重写了。

等一下,如果它是重写的,那最初是用什么写的?答案是:早期,UNIX是用特定目标平台和硬件的汇编语言编写的。只有当肯·汤普森和丹尼斯·里奇将C语言发展为系统编程语言后,他们才决定重写操作系统以利用这种新的高级编程语言。这样一来,操作系统变得可移植了,意味着它不再与特定硬件绑定,可以为其他平台重新编译。

传播与分支:BSD的诞生

1975年,肯·汤普森从贝尔实验室休假,前往加州大学伯克利分校担任客座教授。由于其母公司AT&T被禁止销售该操作系统,实验室将其连同完整的源代码一起授权给了学术机构和商业实体。

有人可能会认为,这最终直接导致了开源概念的产生。当时,加州大学伯克利分校的计算机系统研究小组通过他们的补丁集扩展了操作系统。研究生查克·哈利和比尔·乔伊(他于1982年共同创立了Sun Microsystems)添加了新的工具和其他软件,并最终开始将其作为伯克利软件发行版(BSD)进行分发。

在此期间,UNIX发展出了两个主要谱系:源自BSD或受其影响的系统,以及源自System V(最早的商业UNIX版本之一)的系统。

随着不同操作系统的持续开发,伯克利分校的人们在DARPA研究资助下编写了TCP/IP协议栈。这个协议栈至今仍是TCP/IP的默认实现,可以在许多其他操作系统中找到。

法律纠纷与开源演进

与此同时,一家名为BSDI的公司开始销售基于伯克利软件发行版的UNIX版本,称之为BSD/OS,并将其品牌化为UNIX。他们甚至有一个热线电话“1-800-ITS-UNIX”。贝尔实验室已将操作系统和源代码授权给伯克利,但BSDI销售的是基于此代码的产品,因此随后被UNIX系统实验室(USL,当时AT&T贝尔实验室的子公司)起诉。

BSDI声称他们不可能有过错,因为他们的代码来自加州大学伯克利分校。于是USL说,好吧,那我们也起诉加州大学伯克利分校。但事情变得有趣起来。

BSD补丁一直是在所谓的BSD许可证下授权的,简而言之,该许可证允许你随意使用这些代码,包括将其作为闭源产品销售,但你需要注明出处。听起来很简单,对吧?而且BSD补丁包含了许多很酷的东西:TCP/IP、NFS、vi等等。当时许多商业UNIX供应商已经将这些补丁整合到他们授权和销售的产品中。

只是USL似乎忘记了在他们的版权声明中包含免责声明,说明代码至少部分源自BSD——根据许可证条款,他们本应这样做。因此,当面临诉讼威胁时,加州大学伯克利分校那些“古怪的加州人”说:你知道吗,我们要起诉你。

一段时间后,案件达成和解。加州大学伯克利分校将重写那些受旧AT&T许可证限制的部分代码,从而形成一个没有任何专有代码的代码库,称为4.4BSD-Lite。当时,在18000个文件中,大约只有6个文件仍包含受限代码。最终,这些代码也被重写,4.4BSD-Lite版本的BSD成为了新的、无限制的版本。

BSD的后裔与Linux的崛起

此时,BSD已经诞生了两个后代:NetBSD于1993年3月首次发布,侧重于可移植性和技术正确性;FreeBSD于1993年12月首次发布,侧重于新的i386平台。

与此同时,在芬兰,一位名叫林纳斯·托瓦兹的年轻计算机科学学生一直在研究Minix(由安德鲁·塔能鲍姆创建的类UNIX操作系统),并且刚刚完成了他自己的操作系统内核,称为Linux,于1991年在互联网上发布。

林纳斯为他的内核选择了GNU通用公共许可证。该许可证源自GNU项目,这是理查德·斯托曼在麻省理工学院于80年代初发起的一项努力,旨在开发一个完全自由的类UNIX操作系统。GNU项目已经编写了编译器、编辑器(当然是Emacs)、各种实用程序和其他所有东西,但直到此时他们一直缺少一个内核。没有内核的操作系统不是真正的操作系统,就像没有操作系统其余部分的内核也不是操作系统一样。因此,在GPL下发布Linux内核的公告使得GNU项目最终能够创建一个完整的操作系统:GNU/Linux。事实上,这才是如今每个人及其兄弟姐妹到处称之为“Linux”的操作系统的正确名称。

与BSD许可证相比,GPL对软件接收者施加了额外的限制。这似乎有违直觉,因为它是一个自由软件许可证,但它确实增加了一个重要的限制:你可以以任何你认为合适的方式自由使用代码,但如果你对代码进行了任何更改,这些更改必须在相同条款(即GPL)下发布。这与BSD许可证形成对比,BSD许可证仅声明你可以做任何你想做的事情,包括进行修改、保留这些修改,然后销售最终产品,只要你承认原始代码的来源。

这个新操作系统GNU/Linux的诞生,尽管许可证限制更多(通常可能使企业犹豫是否采用),但在USL诉加州大学伯克利分校/BSDI诉讼进行期间,可能直接导致了Linux比BSD变体获得更广泛的采用,并可能造成了Linux如今的市场主导地位。当然,我们无法确定,因为我们无法回到历史中去评估当时的“如果”。

市场演变与现状

无论如何,在整个90年代和21世纪初,许多商业UNIX版本失去了市场份额,但有趣的发展仍在继续。

以下是按年份列出的一些可能感兴趣的项目(当然,这不是一个详尽的列表):

  • 2000年左右:Darwin操作系统诞生,它源自NeXTSTEP,使用Mach微内核,用户空间代码来自FreeBSD和NetBSD。这并不奇怪,因为离开苹果后又在NeXT工作的史蒂夫·乔布斯一直在使用这个内核,并在重返苹果后开始开发这个后来演变为macOS 10的操作系统。
  • Solaris:继Sun Microsystems的SunOS之后的操作系统,在合并了许多BSD补丁和System V衍生功能后,开发了许多其他突破性功能,包括ZFS(一个具有新颖理念的非常先进的文件系统)、DTrace和容器(当时容器化尚未广泛使用)。
  • Android:Linux变体。
  • iOS:本质上是Darwin的一个版本,因此源自BSD。它们最终都运行在我们的移动设备上。

因此,在过去的50年里,我们看到了数量惊人的UNIX系统。其中一些是“正宗UNIX”系统,直接源自AT&T代码。一些是“商标UNIX”版本,意味着它们经过了认证以满足UNIX规范。这种商标认证费用昂贵,因此没有多少公司会这样做,而且每次进行更改都必须重新进行相同的认证。因此,许多操作系统供应商,尤其是开源项目,不会进行认证,也不会成为商标UNIX,即使它们是所谓的“类UNIX”系统。此外,还有所谓的“类UNIX”操作系统,意味着那些不共享任何代码行,但外观和行为都像UNIX系统的操作系统。

有趣的是,尽管这些不同的UNIX变体在很大程度上行为方式相同(意味着如果你会使用一个操作系统,你应该能够快速轻松地适应另一个;如果你能为一个操作系统编写代码,你应该能够快速为其他系统调整你的代码),但Linux与它们的区别在于,所有不同的Linux发行版只是Linux的发行版,是Linux的打包版本。而在所谓的“现实世界”中,你只会遇到这些不同操作系统中的一小部分。

以下是主要将它们分组为Linux、BSD和其他(尽管“其他”类别有重叠)的列表:

  • Linux:重要的是再次注意,Linux是一个类UNIX操作系统,只是恰好有数量惊人的发行版,不同的项目或公司将不同的软件捆绑在一起。
  • BSD系统(如NetBSD, FreeBSD, OpenBSD, DragonFly BSD):它们是完整的、连贯的单元,不能拆分或重组。我们没有多个NetBSD发行版,只有一个NetBSD。

近年来,除了Linux、BSD和移动平台之外,商业UNIX平台的市场份额日益萎缩。因此,你不太可能遇到上一张幻灯片中展示的各种操作系统变体。尽管如此,其中一些仍然存在并努力工作中。

本课程的参考平台:NetBSD

我们本课程的参考平台是NetBSD。NetBSD是一个真正的、正宗的类UNIX系统,尽管它不持有UNIX商标。开源NetBSD基金会没有财力在每次发布新版本时都为其产品进行认证。但作为一个完整的操作系统,它不仅提供内核,还提供系统库和用户实用程序,所有这些都是一起开发的,提供了一个连贯的自包含的完整操作系统映像。

作为一个完整的操作系统,它还包含一些附加信息,例如与BSD相关的UNIX历史摘要。你可以在/usr/share/misc目录下找到这段历史,浏览这个目录树可能会很有趣。

为了真正理解“UNIX”一词的使用有多广泛、含义有多多样,让我们看一下完整的家族树。你可以看到从贝尔实验室原始系统分支出的大多数UNIX版本的时间线。

Linux发行版的谱系

现在,以防你认为Linux本身的谱系不那么复杂,这里快速看一下不同的Linux发行版是如何随时间发展的。它看起来和常规的UNIX时间线图一样疯狂,不是吗?我们在这里也识别出几个主要谱系:Debian(导致Ubuntu及其所有变体)、Slackware(最古老的发行版之一)以及Red Hat Linux(现在以RHEL的形式在服务器市场享有很高的普及度)。

总结与影响

总之,正如我们所看到的,UNIX以及作为类UNIX操作系统一个版本的Linux的历史是多样化的。因此,如今我们发现UNIX几乎无处不在也就不足为奇了:它运行在你的台式机、笔记本电脑、服务器上;它为亚马逊和谷歌提供的公有云提供动力;它运行在你的电视、手机、手表、音响、汽车导航系统(意味着有时你可能需要靠边停车来安装软件更新)、恒温器、冰箱、烤面包机等等上。

这不仅令人着迷,也带来了一些影响。一方面,这意味着如果你理解UNIX,你将能更好地理解所有这些事物是如何工作的(这也是为什么本课程特别相关且希望对你感兴趣)。另一方面,这也意味着你的冰箱现在可能有一个CVE漏洞,你的恒温器运行着一个可能被黑客攻击的Web服务器。我们在物联网设备上运行通用操作系统,却没有充分考虑如何管理这些设备。

但请查看你一些设备的印刷手册,我相信你会发现许多版权声明,写着“本产品包含源自伯克利贡献的软件”或“本产品包含由加州大学董事会编写的软件”之类的话。

这是一个充满UNIX的世界,UNIX无处不在。

本节课中,我们一起学习了UNIX操作系统的简要历史,从其起源、关键的法律纠纷、BSD与Linux的分支发展,到其当前广泛的应用。在下一节中,我们将更仔细地研究系统的特性、C编程语言的特性,并讨论UNIX程序设计和哲学。我们最终也将开始编写一些代码来探索这些特性,请务必关注下一个视频讲座。

003:Unix基础

在本节课中,我们将学习Unix操作系统的基础知识,并运行一些重要的功能示例。我们还将开始编写代码,请确保准备好你的NePSDv环境并登录,以便跟随示例操作。

Unix系统架构概述

上一节我们介绍了课程背景,本节中我们来看看Unix操作系统的基本设计。其核心是一个单体内核,负责所有繁重的工作,如初始化硬件、管理内存、任务调度、处理文件系统和进程间通信等。

大多数Unix版本使用这种单体内核。主要的例外是Linux(真正的微内核)和Darwin(Mac OS, iOS等使用的混合内核)。

操作系统提供少量称为系统调用的接口,它们是进入内核空间的钩子。系统调用可以被运行在用户空间的库函数包装。应用程序通常调用这些库函数,但也可以直接调用系统调用。

图中的Shell虽然被单独标出,但实际上只是一个常规应用程序。由于它提供了主要的用户界面,所以我们将其单独列出。稍后我们将更详细地了解Shell的功能和工作原理。

使用手册页

Unix系统提供了详细的手册页形式的文档。如果你不知道某个函数的作用或用法,无需依赖搜索引擎或Stack Overflow,只需查阅相关的手册页即可。

Unix系统的手册页分为多个部分。我们将使用括号内标明章节的标准表示法。例如,write系统调用在手册页的第2节,而printf库函数则记录在第3节。

POSIX标准

考虑到Unix历史的多样性,我们需要就系统行为达成某种共识。正如之前讨论的,存在一个Unix认证,这意味着有一套规则必须遵守。

Unix系统的美国标准被称为POSIX(可移植操作系统接口),它定义了API、命令行工具和接口,以确保软件在不同Unix变体间的兼容性。该标准后来发展成为单一Unix规范

在实践中,这意味着什么?让我们快速了解一下。

为了澄清术语的使用以及我们如何引用命令、库函数或系统调用,我们来查看几个手册页。

如果我们简单地输入man printf,我们会得到第1节(通用命令)的手册页。但通常我们需要的不是命令行工具,而是库函数。因此,我们必须明确指定章节:man 3 printf。这会显示我们想要的printf库函数的描述。

类似地,如果我们输入man write,会得到write命令的描述。但write是一个系统调用,因此它记录在手册页的第2节:man 2 write

请注意,man命令总是会拉取找到的第一个页面。例如,输入man fprintf会直接显示第3节的手册页,因为不存在名为fprintf的命令或系统调用。

在本学期的电子邮件和书面作业中,我们将始终使用明确指定手册页章节的约定来引用命令或函数。

标准与现实

你可以在开放组织的网站上找到单一Unix规范,建议你收藏这个网站。该文档提供了许多定义和一个搜索索引。

如果我们搜索ln命令的POSIX定义,页面看起来很像手册页,提供了概要、描述、命令和选项定义,以及关于环境等的更多信息。特别有用的是“原理”部分,它解释了为什么选择某些行为。

现在,让我们将标准与我们感知到的现实进行比较。在标准中,我们看到一个工具为符合POSIX必须支持的命令和选项。

当我们在NePSSD系统上打开手册页时,会注意到我们的ln版本支持标准中未提及的额外标志。这没关系,标准只定义了最低要求。了解哪些选项是POSIX要求的,哪些不是,可以帮助你在编写可移植脚本或代码时更好地设定预期。标准中未提及的任何内容都不能假定在所有平台上都受支持。

C编程语言

尽管C编程语言是在Unix系统上并为其开发的系统编程语言,但它是一种与操作系统无关的语言,因此拥有独立于Unix系统定义的标准。

C语言从早期的K&R C发展而来,于1989年正式标准化为ANSI C。此后,发布了多个新版本。你的编译器可能支持也可能不支持某个特定版本。如果代码依赖新版本的某些特性,你需要明确这一要求。然而,语言的变化远不如其他语言(如Python 2.x与3.x)那样剧烈。

ANSI C引入的重要特性包括:

  • 函数原型:允许使用前向声明,意味着我们可以将编程接口与实现分离。
  • 通用指针(void指针):可用于引用未指定类型的对象,因此具有通用性。这提供了很大的灵活性,但也带来了一些风险,因为粗心的指针操作可能导致软件错误和漏洞。这个特性是C语言“给你足够长的绳子吊死自己”的例子之一。
  • 抽象数据类型:使用C实现Unix使操作系统具有可移植性,抽象数据类型是提高可移植性的特性之一。我们只需将它们声明为指定类型,类型的实现则留给操作系统,对我们是不透明的。例如,time_t数据类型过去通常实现为32位整数。为了避免2038年问题,许多操作系统将其切换为64位整数。使用抽象类型的好处是,应用程序代码无需更改,只需在新平台上重新编译即可。

错误处理与errno

编写良好的C程序遵循Unix最佳实践,即返回有意义的值。程序总是通过返回一个数值来指示是否成功完成。如果该值为0,则程序成功终止;否则,表示发生错误。

更重要的是,错误类型通过返回码传达给用户。考虑到任何程序都可能因多种原因失败,让程序用户能够轻松识别失败原因而无需解析错误消息是很有用的。

这就是errno的用武之地。errno是C标准库的一部分,它是一个整数值,代表失败的原因。数字代码对计算机很好,但人类更喜欢文字。因此,我们有strerrorperror这两个有用的库函数,它们接受errno值并返回适合显示给用户的错误字符串表示。

在本课程中,我们将严格使用有意义的返回值以及strerror等来打印有意义的错误消息。“出错了”不是一个有用的错误消息。“无法打开文件:权限被拒绝”才是。将这些模式融入你的编程肌肉记忆是一个好主意。

第一个代码示例:welcome.c

好了,让我们终于开始写点代码吧。我已经将本课程使用的所有示例程序打包并放在网站上。建议你在B D VM上获取并解压。

解压后,进入lecture 1目录,你会找到一个名为welcome.c的文件,这是一个最简单的打印问候语的程序。

首先,我们获取源代码。使用ftp工具,因为NePSSD默认没有安装curlwget。解压后,找到我们的welcome.c程序。在编译之前,先复制一份文件,以防我们想修改它并需要回退更改。

让我们编译它。编译器似乎在终端输出了一些垃圾信息,但那些应该无关紧要,直接运行./a.out。等等,没有a.out文件?这并不奇怪,因为编译器告诉我们有错误。欢迎来到C编程世界。

幸运的是,编译器准确地告诉我们错误是什么,以及在文件中的位置。让我们修复这个错误:有人忘了在语句末尾加分号。加上它,保存文件,再次编译。这次没有错误。我们可以通过运行echo $?来检查上一个命令的返回码,它显示0,表示编译器成功完成。

确认我们得到了可执行文件,忽略编译器打印的其他垃圾信息,直接运行./a.out。结果段错误了。我们在用C编程,但哪里出错了?

让我们再看一下编译器的输出。编译器给出了一个警告:关于getlogin的隐式函数声明。这是什么函数?别去谷歌这个错误。记得我说过你可以使用手册页来帮助你。试试man getlogingetlogin返回一个char *。我们用了%s,这应该是对的。再编译一次?也许第一次有太阳黑子干扰,再做一次同样的事肯定会得到不同的结果,编程就是这样,对吧?同样的警告。

让我们仔细看看。“隐式函数声明”是什么意思?不出所料,它意味着没有该函数的前向声明,这是我们之前提到的ANSI C特性。我们从手册页知道了函数原型,为什么不直接加上它呢?在头文件下添加函数原型,保存,再次编译。太好了,没有警告了。编译器成功,可执行文件存在,运行它。成功了!程序成功完成,没有段错误,甚至做了我们想做的事。

但为什么我们必须自己添加函数原型?这似乎很繁琐。让我们再看一下手册页。注意在概要部分,手册页还告诉你应该包含哪个头文件。这个头文件包含了相关函数的前向声明。

编译器给了我们警告,但我们的程序却段错误了。这也不太好。让我们回退并重新开始。这次,我们将添加一些编译器标志,指示编译器启用更多警告,并且实际上不仅仅是警告,而是将所有警告视为错误,这样我们就不能盲目忽略它们。

现在我们的输出显示了之前关于缺少分号的相同错误,但有一个新的额外错误消息,并且将之前的警告改为了错误。这个额外的错误消息准确地告诉我们为什么会出现段错误:如果编译器不知道函数返回什么,它会假设返回一个整数。不幸的是,printf格式说明符%s期望一个字符串,但我们的编译器设置为整数,因此导致段错误。

让我们再次修复。首先,修复缺少的分号。和之前一样,这留下了另一个错误。但这次,有了我们额外的编译器标志,编译器不会成功,而是会中止。曾经是警告的内容现在变成了致命错误,以防止我们做傻事。

现在,从手册页我们也记得要包含正确的头文件,所以让我们添加它,而不是自己提供前向声明。再次编译,等等,要带上正确的编译器标志。成功了!

这是一个典型的C编程过程,一行代码首先无法编译,然后触发段错误。在本课程中你会看到很多这样的情况,但大多数时候,通过注意编译器告诉你的内容,你可以避免它们。

在Unix系统上,程序生成的消息是有意义的,不应被忽略。因此,我们将始终启用所有警告,并始终将警告视为错误。这有助于防止我们犯自己的错误。为了确保我们不会忘记启用这些标志,让我们将它们添加到shell启动文件中并创建一个别名。设置好后,你可以简单地调用cc,它将使用正确的标志。

关于可以启用的其他警告标志列表,请查看此链接。你也可以选择添加更严格的标志。但请记住,对于本课程,你的所有代码必须始终符合-Wall -Werror -Wextra标志。

Shell的工作原理

回到我们简要介绍过的架构图,我们讨论了系统调用与库函数。接下来让我们看看Shell。

Shell到底做什么?简化到核心,Shell真的没做太多事。它永远循环,读取用户的命令,然后执行它们。所以我们可以用几行代码写一个非常简单的Shell,让我们开始吧。

再次,请考虑暂停视频,在继续之前阅读代码。但好吧,我们一起做。请注意,这里我们明确表示只尝试执行命令,并指出了几个限制。毕竟,这是世界上最简单的Shell。

我们的主循环很简单。如承诺的那样,我们永远循环,打印Shell提示符,然后从用户读取输入到缓冲区。一旦我们从用户那里获得输入,就fork一个新进程。如果这里出现任何问题,我们将遵循最佳实践,通过strerror函数生成有意义的错误消息。你总是可以从代码中查看手册页。否则,一切正常。

我们执行给定的命令。再次,如果发生任何意外,我们生成一个错误消息并以有意义的退出状态退出,而不是随机数字。最后,父进程等待子进程终止,然后循环回去再次读取输入。如果没有正常输入,我们成功退出。

你注意到我们使用的退出状态了吗?我们如何决定使用哪些,它们来自哪里?sysexits手册页包含了所有有用的信息。使用任意值调用exit不是一个好习惯。我们想要有意义的返回码。浏览手册页,你会发现许多错误场景的退出代码。适当地使用它们是一个好习惯。

让我们编译这段代码。快速提醒一下,我们设置了别名以使用正确的编译器标志。当我们运行cc simpleshell.c时,没有警告,没有错误,一切看起来都很好。

运行这个Shell。我们可以运行简单的命令,如who am ilsdatecat。等等,为什么我们的cat调用不起作用?或者ll?为什么如果之前的ls调用成功了,这个却失败了?

注意错误信息。它说“无法执行ls -l”,而不是“无法执行ll”。这是因为我们的Shell真的只处理单个单词。它执行我们给定的任何内容。

让我们退出Shell。等等,为什么我们甚至无法退出?我们的循环继续从用户读取输入,直到没有输入。所以现在我们必须以某种方式提供“无输入”,即,我们必须向程序发送一个文件结束字符。大多数终端设置为通过组合键Ctrl+D生成此文件结束字符以结束循环。试试看。成功了!

所以我们已经看到,世界上最简单的Shell确实有些限制,但尽管如此,最基本的“读取-执行”循环可以用几行代码完成。

Unix哲学

在这个Shell和代码示例中,我们已经触及了一些核心的Unix编程最佳实践。Unix环境的一大优点是它行为一致。也就是说,基于标准,其上的工具在不同Unix版本间行为一致;而且,未包含在标准中的工具也以可预测的方式行为。

一致性是Unix哲学的重要组成部分,它规定了环境的行为,使得每个工具都能融入并与其他工具结合。这也将是我们为本课程编写的所有程序的目标。我们编写的程序不应看起来像明显的CS课程作业,而应看起来、感觉起来和行为起来就像是操作系统的一部分。如果你没听说过Fred Brooks,你应该查阅他的书《人月神话》,这是任何CS专业学生的必读材料。

Unix哲学简单而强大。简而言之,它规定程序应该简单。也就是说,它们不应试图在单个程序中解决所有问题。构建多个更小、更简单的工具比构建一个过于复杂的单一工具更可取。

你应该遵循“最小意外原则”。用户不应对工具的行为感到意外,无论是在成功用例中,更重要的是在出错时。当你编写工具并不确定应该以某种方式处理某个用例时,问问自己作为用户会期望什么。

我们应该接受标准输入并生成标准输出。这允许你避免文件I/O的复杂性(我们将在下一课中讨论),并确保你的程序可以与其他工具结合。

你应该生成有意义的错误消息到标准错误。将程序生成的正常输出与你产生的任何错误消息分开是很重要的。通过分离它们,你的工具在与其他工具结合的方式上变得更加灵活,用户可以选择将输出和错误消息重定向到不同的地方。

你的工具也应该有一个有意义的退出码。正如我们讨论的,我们希望能够在无需解析错误消息的情况下识别程序何时遇到问题。错误消息是给人类用户的,错误码是给计算机的,供其他工具检查和反应。这也有助于使你的工具成为更有用的构建块。

最后,你的Unix工具应该有一个手册页。手册页是环境的重要组成部分。它们记录了工具并为用户提供了参考,但作为程序员,编写手册页也是一个非常好的习惯,因为它有助于你澄清和定义用户界面。

管道的威力

应用Unix哲学的一个结果是,我们可以将小型工具(构建块)组合成更复杂的东西。这主要是通过管道将它们串联起来实现的。

Unix实践总是操作三个标准文件描述符:标准输入、标准输出和标准错误,这允许组合不同的工具,其中一个工具的输出用作另一个工具的输入。

Unix管道由Doug McIlroy发明。为了更好地说明Unix管道的威力,考虑如何组合小工具可以让你创造出原始工具作者无法预料的东西。

假设你想知道在10个最常访问的英文维基百科页面上找到的最长单词是什么。这里有一个实际完成此任务的管道。在家试试,看看你是否理解每一步的作用。当然,这是一个任意的例子,但我相信它说明了使用管道所带来的灵活性。

文件系统基础

我相信你们大多数人都知道,Unix文件系统是一个树形结构,所有分区都挂载在根目录(也称为/)下。文件名可以由除斜杠和空字符外的任何字符组成,因为路径名是由斜杠分隔的零个或多个文件名的序列,当然,空字符在C中终止字符串,因此不能包含在名称中。

我们将在未来的讲座中深入了解Unix文件系统的细节,但现在让我们快速了解目录。目录是一种文件类型。具体来说,它们提供了文件名与用于在文件系统本身中引用和查找文件的内部数据结构(inode)之间的映射。也就是说,文件名不是文件的属性,而是目录中的一个条目,一种查找文件对象的方式。将目录视为一个简单的查找表,它可以通过文件名的映射为我们提供与文件关联的数据,我们已经可以猜出像ls这样的工具可能如何工作。

是时候看另一个代码示例了。你知道该怎么做:暂停视频,阅读代码,运行此处显示的命令,然后回来。

ls命令的一个简单实现不必非常复杂。这个版本只有34行代码,包括注释和头文件。让我们看看。如果我们查看main函数,我们已经看到了完整的功能,尽管很简单。我们期望被给予一个参数,即要列出内容的目录。然后我们使用 aptly named opendir库函数打开目录,然后循环遍历通过readdir在目录中找到的所有条目,打印我们找到的目录条目的名称。完成后,我们关闭目录并返回。

运行它。如承诺的那样,我们需要给定一个目录名。让我们给它当前工作目录,也称为.。很好,目录中找到的所有条目都逐行打印出来。注意输出没有排序。系统ls命令的行为可能不同。如果我们给它另一个目录,比如我们的主目录,我们会看到更多文件,包括那些以点开头的文件。同样,系统的行为不同,因为它默认隐藏这些文件。但就文件系统而言,以点开头的文件并没有什么特别。默认不显示它们只是ls命令的一个约定。

到目前为止,我们已经写了一个简单的Shell和一个ls命令的版本。接下来看看还有什么。

用户、组与时间

在Unix系统上,所有用户都由一个数字值标识。计算机喜欢数字。但我们这些愚蠢的人类不喜欢被称为数字,所以我们想出了用户名。但就计算机而言,每个用户只是一个UID,并且可能属于多个组,这些组也由数字GID标识。

who am i命令打印有效用户的用户名,手册页告诉我们这个命令实际上已被弃用,推荐使用id命令。如果我们单独运行id命令,我们会得到数字UID以及符号用户名,以及组ID和组名。

Unix系统需要一种表示时间的方式,它通过计算自任意选择日期(1970年1月1日午夜,即Unix纪元)以来的秒数来实现。有趣的事实:Unix创建于1969年,早于Unix纪元。我知道,我知道,正是这种琐事让这门课值得一上。

所以,我们实际上有一个计数器,我们需要以某种方式表示这个数字。正如前面解释的,使用抽象数据类型(具体来说是time_t)是有用的,因为计算机系统上的任何资源都是有限的,不幸的是,时间有持续向前移动的明显趋势,所以这个值必须持续增加,可能导致回绕。具体来说,使用有符号32位整数可以存储的自1970年1月1日以来的最晚时间是2038年1月19日星期二03:14:07。那时,日历将回绕,日期将变为1901年12月13日,这对大多数应用程序来说可能不太好。幸运的是,大多数设计运行在64位硬件上的操作系统已经使用有符号64位time_t整数。通过使用time_t,即使是那些使用有符号32位整数的操作系统也可以更改为使用64位值,而无需修改所有应用程序,尽管它们需要在给定平台上重新构建。

这里的另一个重要教训是,每当你做出这样的改变时,我们实际上是在把问题往后推,因为即使是64位计数器最终也会回绕。然而,64位计数器能够表示的日期大约是宇宙估计年龄的200亿倍以上,所以64位计数器的新回绕日期大约是从现在起的2920亿年。大多数人认为这不是一个重大问题。

但除了计算自纪元以来的秒数之外,还有更多时间概念。有时我们想知道一个程序运行了多长时间。这个时间以三个不同的值衡量:时钟时间(总共经过的时间)、用户CPU时间(进程在用户空间花费的时间)和系统CPU时间(进程在内核空间花费的时间)。你可能会合理地认为系统时间加用户时间等于时钟时间,但不幸的是,情况并非如此。进程被阻塞时(例如,等待I/O)所经过的时间既不计入用户时间,也不计入系统时间。

这里有一个例子。让我们找出/usr/bin下所有工具的所有用户源文件中有多少行代码。我们运行find命令,找到所有相关文件,并通过管道传递给wc命令。我们看到大约有312,000行代码,该命令总共花费了约1.26秒,其中0.35秒花费在用户空间,0.44秒花费在内核空间。注意,缺失了0.47秒,这是实际I/O等待时间。

如果我们对find使用-exec标志,我们将为每个文件调用一次。我们能优化吗?让我们尝试首先生成文件列表,然后使用xargs来减少调用次数。现在快多了。文件系统可能通过缓冲区缓存进行了一些优化,我们将在下一课中更详细地讨论。现在,我们只需注意,我们可以使用这些时间测量作为优化程序的一种方式,也可以找出程序在哪里花费了最多时间。

文件描述符与I/O

如前所述,Unix工具操作标准输入、标准输出和标准错误。这些是默认连接到终端的文件流,分别由文件描述符0、1和2表示。但文件描述符的概念不仅限于这些数字。所有文件I/O都基于文件描述符,这些是表示所讨论文件的小型非负整数。

正如我们已经看到的,Shell可以重定向任何文件描述符。这方面的例子包括无处不在的管道,其中一个程序的标准输出成为另一个程序的标准输入;当然,你们也都已经使用过将输出重定向到文件或/dev/null等。

文件I/O通常有两种风格:缓冲无缓冲。正如你可以通过查看此处引用的手册页看出的那样,无缓冲I/O由系统调用完成,发生在内核空间。相比之下,缓冲I/O由库函数实现,因此在用户空间执行。

简而言之,你的printf提供缓冲I/O,你要求系统打印某些内容,它会说“好的,我马上打印”,但实际上它对你撒谎了。相反,系统会缓冲输入,通常取决于或受输出连接到什么的影响。例如,输出到终端是行缓冲的,意味着库使用换行符来指示应该刷新其缓冲区。而无缓冲I/O将在系统调用完成时立即执行。

我们将在下一讲讨论所有(嗯,大部分)I/O内容,但根据我们在此总结的内容,我们已经可以用不到50行代码编写cat命令的简单实现。

在我们查看代码之前,让我们想想cat命令实际上做了什么。它从标准输入读取数据,然后写入标准输出。所以毫不奇怪,我们的简单cat将做同样的事情。让我们看看主循环。注意我们使用的是常量STDIN_FILENOSTDOUT_FILENO,而不是数字0和1。这是我们将在整个课程中遵循的通用实践。我们避免使用所谓的“魔数”,这些数字假定阅读代码的人知道它们的含义;相反,我们使用符号名称或适当的命名变量。为了保持一致性,即使几乎所有Unix系统确实将STDIN_FILENO定义为0,STDOUT_FILENO定义为1,我们也这样做。我们希望养成习惯,建立肌肉记忆,尽可能避免魔数。

同样,我们不使用任意整数初始化缓冲区,而是使用符号名称。我们如何选择这个数字,我们将在下一讲中讨论。

很简单,对吧?让我们看看它是否按预期运行。我们输入数据,程序完美地回显给我们,很好。由于我们的程序只要有标准输入数据就会循环,我们需要发送EOF(文件结束符)来指示输入结束。如前所述,我们可以在终端按Ctrl+D。现在让我们尝试获取一个文件。这似乎没有达到我们想要的效果。它只是停在那里。这是因为我们的程序根本没有编写来处理任何命令行参数。它总是从标准输入读取。

中止程序的另一种方式,你们可能都很熟悉,是按Ctrl+C,这会生成中断信号。这与生成EOF不同。在这种情况下,我们通过信号导致程序异常退出。

在我们的Shell中,我们注意到任何文件描述符都可以被Shell重定向,包括标准输入。Shell使用小于号<语法来实现。所以如果我们运行这个命令,我们会得到文件内容在标准输入上,我们的简单cat程序很好地打印到标准输出,正如我们预期的那样。

现在,如果我们将标准输出重定向到另一个文件会发生什么?比如说/tmp/copy。我们有效地复制了文件,这并不奇怪。毕竟,复制文件真的只意味着你必须打开一个文件,读取所有输入,将所有数据写入另一个文件,然后就完成了。通过使用Shell提供的重定向,我们避免了程序中处理打开文件的复杂性,我们40行的简单cat也有效地是cp程序的实现。

好了,我们的简单cat使用了readwrite,这是无缓冲I/O。让我们看看如何使用缓冲I/O编写相同的工具。嗯,这里没有太大不同。唯一的区别是,我们使用getcputc,而不是readwrite。运行它。是的,和以前一样。这两个版本之间的代码差异真的很小。我们可以像以前一样复制文件,会发现已经创建了一个真正的副本。但在这个例子中,缓冲和无缓冲I/O确实没有太大区别。

进程与信号

任何在内存中执行的程序都称为进程。进程由一个小型非负整数标识,称为进程IDPID。你只能通过fork系统调用来创建新进程,运行另一个程序的一般流程是fork,然后exec可执行文件,并等待它。正如我们之前编写最简单的Shell时看到的那样。

我们当然会在未来的讲座中讨论进程的所有细节。让我们先玩一下,热热身。首先,安装一个名为pstree(有时称为proctree)的工具,它将帮助我们说明进程关系。

当我们运行proctree时,我们看到系统如何创建不同的进程。它启动了sshd,为我的用户创建了一个会话,该会话运行一个登录Shell,现在运行proctree命令,该命令本身似乎使用此处显示的标志调用ps命令。

我们如何知道当前的PID?我们可以使用getpid。运行这个命令。这是当我们运行打印getpid的程序的./a.out时创建的进程的进程ID。Shell本身有不同的进程ID,我们可以通过让它回显$$变量来打印它。与proctree的输出进行比较,我们可以确认Shell没有对我们撒谎,它的PID确实是9974。

注意,如果我们运行打印PID的程序,我们总是会得到一个不可预测顺序的不同数字。这也意味着,如果你查看进程表,现在看到一个PID为1234的进程,五分钟后再次查看并看到一个PID为1234的进程,你无法保证那仍然是同一个进程。PID可以被重用。所以如果一个进程终止,下一个启动的进程可能会得到那个PID。

最后,快速提一下信号。就在几分钟前,当我们讨论简单cat程序时,我们提到你可以使用Ctrl+C中断程序。这个按键组合按照惯例生成中断信号,进程接收中断信号的默认操作是异常终止。

所以我们已经看到了信号的作用。更一般地说,信号是通知进程某种条件已发生的简单方式。作为程序员,你可以选择对此信息执行以下操作之一:

  • 你可以什么都不做,意味着你允许默认操作发生。这就是我们在简单cat示例中所做的。
  • 你可以有意且明确地忽略信号。这是说,每当这种情况发生时,我不在乎,我不会对此做任何事情,但我也不会允许默认操作发生。
  • 最后,你可以选择每当此事件发生时执行自定义操作。

信号处理涉及许多复杂性,我们将在本学期晚些时候介绍。让我们简要回到我们之前的最简单Shell。当我们运行简单cat并按Ctrl+C时,简单cat终止了。这正是我们想要的。

但现在考虑一下,当你运行的程序是一个Shell时会发生什么。如果我们在这里按Ctrl+C,Shell退出了。这真的不奇怪。但另一方面,这真的不是我们想要的,对吧?想象一下,如果你登录到一个系统,每次你不小心按了Ctrl+C,Shell就退出,你就会被锁在系统外。

那么当我们在常规登录Shell中按Ctrl+C时会发生什么?按一次,两次,三次。看起来Shell只是再次打印提示符,它没有退出。

所以让我们改变我们的简单Shell,使其行为类似。注意,在我们的main函数正上方,我们定义了一个名为sighandler的函数,它仅仅打印出我们已经捕获了中断信号。为了确保安装这个信号处理函数,我们在开始主循环之前调用signal函数。这实际上是在说,嘿,每当用户碰巧生成中断信号时,不要只是中止,而是跳入我为你编写的sighandler函数。其余部分与我们的原始Shell完全相同。

运行这个新Shell,看看是否有效。命令执行仍然有效,按Ctrl+C。嘿,看,它起作用了,很好。

总结

本节课我们一起学习了Unix系统的基础知识和编程环境。我们已经编写了许多程序:首先我们调试了愚蠢的welcome程序,并讨论了正确编译器标志的重要性;然后我们编写了一个简单的Shell,并刚刚扩展它以添加信号处理程序;我们编写了一个简单的ls克隆,并编写了两个版本的cat。对于我们第一周来说,这还不错。

请务必仔细阅读这些代码示例,并在以后自己重复它们,确保你为本课程设置好了环境并初始化了课程笔记。如果你有任何问题或任何困难,请通过课程邮件列表发送或发布在Slack上,我们可以在那里以及我们周一的第一次Zoom同步课上讨论。

为下一周做准备,请确保阅读Stevens书中的第3章。我将在下周某个时间发布该材料的视频讲座。感谢观看,再见。

004:在Apple M1的UTM中安装NetBSD evbarm教程 🖥️

概述

在本节课中,我们将学习如何在搭载Apple M1芯片(基于ARM架构)的Mac电脑上,使用UTM虚拟机软件安装NetBSD操作系统的evbarm移植版本。我们还将配置SSH连接,并创建自动化脚本以简化虚拟机的启动和关闭过程。


为什么选择UTM?🤔

上一节我们介绍了课程将使用NetBSD作为参考平台。如果你没有将NetBSD作为主操作系统,创建一个虚拟机来完成课程作业会很有用。本视频将帮助你完成设置。

课程网站也提供了使用Oracle VirtualBox虚拟机的说明。但如果你尝试在最新的Apple硬件上安装VirtualBox,会发现它仅支持Intel芯片。因此,如果你的系统搭载Apple M1芯片(基于ARM的处理器),那么VirtualBox将无法使用。

所以,我们转而使用UTM。让我们前往 mac.getutm.app 下载UTM。下载后,安装过程很简单,只需将应用图标拖入“应用程序”文件夹即可。


获取NetBSD安装镜像 📥

在开始UTM之前,我们需要获取NetBSD的安装镜像。

访问NetBSD项目网站,可以看到截至2022年6月,最新版本是NetBSD 9.2。不幸的是,该版本无法在M1芯片的UTM上运行。因此,我们需要获取NetBSD的最新开发版本,即NetBSD-current。

前往 www.netbsd.org/releases/current.html。NetBSD-current是每日开发快照,让你可以试用推送到代码仓库的所有最新代码。

你可以自己从源代码构建整个系统,也可以从这个URL下载预构建的快照镜像。我们点击进入最新构建,然后搜索安装镜像。

在这里,我们可以看到针对不同NetBSD移植版本的许多ISO镜像。我们需要寻找 evbarm-aarch64 镜像,然后将其下载到桌面。


创建并配置UTM虚拟机 ⚙️

现在,我们有了NetBSD的ISO镜像。接下来启动UTM,并选择“创建新的虚拟机”。

系统会询问我们是要模拟不同的硬件,还是虚拟化当前硬件。我们选择“虚拟化”,然后在操作系统屏幕中选择“其他”。

接着,加载我们之前下载的ISO镜像,然后继续。

在接下来的几个屏幕上,我们基本上可以接受默认选项,但可以将存储空间调整为16GB(甚至更少也可以)。最后,我们将虚拟机命名为 APUE 并保存。

在启动之前,让我们快速进入虚拟机偏好设置。在这里,我们可以看到关于虚拟机的信息。UTM基于开源的QEMU,你可以看到传递给QEMU的所有不同选项。你甚至可以在这里添加额外选项,例如添加第二个网络接口。

谈到网络,让我们查看一下。将其更改为“桥接”接口,以允许虚拟机获得完整的网络功能。

现在,我们准备启动并安装NetBSD。


安装NetBSD系统 🚀

启动虚拟机后,我们会看到安装程序启动,并有一个30秒的倒计时。你可以等待倒计时结束,或者直接按回车键启动。

启动后,将进入菜单驱动的安装程序。我们选择英语安装消息,然后选择第一个选项,将NetBSD安装到硬盘。

我们选择“是”以继续。安装程序检查磁盘,由于只有一个,我们选择它(例如 ld0)。我们使用GPT分区表来分区磁盘,然后使用默认的NetBSD分区方案,这会给我们一个小型引导分区、一个大型根分区和一些交换空间。

现在,准备将此信息写入磁盘并创建文件系统。这是一个破坏性操作,无法撤销,因此安装程序要求我们确认。

文件系统创建完成后,我们选择安装类型。我们进行完整安装,但不需要X11窗口系统,因此选择选项B。我们从CD获取软件包,然后继续。

软件包解压后,我们可以执行一些配置步骤。首先设置root密码,然后进入配置菜单。

以下是配置步骤:

  1. 从配置网络开始,选择 a 并按回车。我们接受默认值,让DHCP自动配置。
  2. 将虚拟机命名为 APUE,域名留空,然后接受此系统配置。
  3. 返回配置屏幕,向下滚动,启用SSHD。
  4. 启用NTPD和NTP日期同步。
  5. 最后,添加一个普通用户。选择一个你喜欢的用户名,并将其添加到 wheel 组,以便可以切换到root。选择你的shell并设置密码。

现在,我们完成了系统配置,返回到主安装菜单。完成后,退出安装程序并完全关闭虚拟机。


首次启动与基础配置 🔧

我们回到了起点,但现在拥有了一个完全安装好的虚拟机。在底部,我们清除CD驱动器以移除安装镜像,这样下次启动时就会启动实际的虚拟机。

现在,如果我们启动虚拟机,会看到从普通虚拟磁盘启动,并显示默认的NetBSD引导加载程序。默认5秒超时后,内核启动,初始化各种服务,然后我们就可以登录了。使用我们创建的用户账户登录。

让我们查看网络配置,确保可以访问互联网。一切正常。

要关闭虚拟机,我们可以切换到root用户并运行关机命令,如下所示:

sudo shutdown -p now

自动化虚拟机启动 🚀

现在,我们的虚拟机已设置完毕。但让我们看看是否能简化启动过程。目前,每次运行虚拟机时,我们必须启动UTM,选择正确的虚拟机,点击启动等,这有点烦人。我们希望使其更简单、更快捷。

因此,让我们暂停一下,关闭虚拟机并退出UTM。

由于我们几乎所有的操作都在命令行中进行,我们也希望有一种从终端启动虚拟机的方法。让我们打开终端并尝试以下命令:

open utm://start?name=APUE

UTM应用注册了 utm:// URL协议,因此调用 open 命令现在将启动我们的虚拟机。

但这仍然会让两个UTM窗口显示在前台。我们将通过SSH访问虚拟机,因此这些窗口在前台很烦人。

让我们再次停止,看看是否能找到一种方法让这些窗口不碍事。在VirtualBox中,有一种以无头模式运行虚拟机的方法,但UTM似乎不支持。因此,我们必须发挥创意。

我们不能在没有UTM运行的情况下启动虚拟机,但我们不需要它在前台。幸运的是,在macOS中,你可以使用“脚本编辑器”应用来编写与UI交互的脚本。让我们打开它,创建一个新文档,并放入以下脚本:

tell application "System Events"
    tell process "UTM"
        set frontmost to true
        click menu item "Minimize" of menu "Window" of menu bar 1
    end tell
end tell

编译后,脚本显示它所做的只是将UTM窗口带到前台,然后选择前台菜单中的“最小化”选项。我们有两个窗口,所以需要执行两次。

让我们测试一下。首先,需要再次启动虚拟机。现在,随着虚拟机启动,我们测试脚本。点击播放按钮,UTM窗口被最小化了。

现在,我们可以从命令行通过运行 osascript 来执行脚本。但第一次尝试可能会失败,并提示 osascript 没有辅助功能权限,需要显式启用。

为此,打开“系统偏好设置”,进入“安全性与隐私”,选择“辅助功能”,验证后添加“终端”应用。现在再试一次,应该可以了。

现在,我们可以将这两个步骤(打开UTM并启动虚拟机,以及最小化窗口)结合到一个简单的shell脚本中。我们会在命令之间加入短暂的休眠,以确保在尝试最小化之前UTM确实已经启动。

然后,我们将UTM最小化脚本移动到一个合理的位置,并给新脚本执行权限。现在,我们应该可以运行它了。


配置SSH免密登录与自动化关机 🔑

现在,我们已经有了轻松启动虚拟机的方法。让我们思考如何同样轻松地关闭它。为此,我们需要能够登录到虚拟机,将其关闭,然后终止UTM。但为了使用SSH,我们需要知道IP地址并设置SSH,这样就不必每次都输入密码。

还记得我们第一次登录虚拟机时,运行了 ifconfig,它显示了分配给虚拟机的IP地址。因此,我们可以简单地启动虚拟机并通过SSH连接到这个地址(例如 172.16.1.38)。

这是我们第一次连接到虚拟机,所以会收到关于未知主机密钥指纹的警告。与互联网上100%的用户不同,我们实际上关心真实性,并且会在发送密码之前检查这个指纹是否正确。

由于我们在终端上有一个串行控制台,我们可以在这里登录,然后运行以下命令来获取主机密钥指纹:

ssh-keygen -l -f /etc/ssh/ssh_host_ed25519_key.pub

然后比较两个指纹。如果一致,就可以连接。

但我们希望能够在不需要输入密码的情况下进行SSH。因此,让我们设置一个SSH密钥。首先,在虚拟机上创建 .ssh 目录。然后在父系统上生成一个新的SSH密钥对,我们将其命名为 APUE。接着,将公钥复制到虚拟机作为授权密钥。

接下来,我们在 ~/.ssh/config 文件中创建一个简单的条目,添加正确的地址和我们刚刚生成的SSH密钥路径。这样,我们现在只需输入 ssh apue 就可以登录了。

最后,拼图的最后一部分是自动化虚拟机的终止。首先,让我们简化操作,移除root账户的密码。我知道这听起来是个糟糕的主意,但在这种情况下,实际上是可以的。你马上就会明白为什么。

请记住,我们的虚拟机只能通过笔记本电脑的虚拟控制台访问,这需要控制UI或通过SSH。空密码是不允许的。默认情况下,PermitEmptyPasswords 指令是注释掉的,但默认值为 no

通常我们使用 passwd 工具更改密码,但将其设置为空密码实际上是不允许的。因此,我们将直接编辑密码数据库文件。别担心,我们将在第4周详细讨论这个文件。但现在,只需知道我们可以移除密码哈希字段,为root设置空密码,这样在切换到root时就不再需要提供密码。

为了能够以root身份运行,用户需要属于 wheel 组,我们在虚拟机安装早期已将我们的用户添加到了该组。没有其他用户在该组中,因此只有我们的账户能够切换到root。

现在,让我们注销并验证我们是否可以在没有密码的情况下以root身份SSH。我们会收到密码提示,如果直接按回车,仍然会被阻止,很好。

现在,我们可以创建脚本来终止虚拟机。我们将其命名为 stop-apue。在其中,首先通过SSH连接到虚拟机,以root身份运行关机命令。然后,给它几秒钟时间完全关闭。之后,我们简单地终止UTM进程。给脚本执行权限并运行它。你可以看到虚拟机正在关闭,UTM被终止。完成。


总结 📝

本节课中,我们一起学习了如何在Apple M1的UTM中安装NetBSD evbarm。安装过程相当直接:首先下载UTM,然后下载NetBSD-current的evbarm-aarch64镜像,并简单地遵循默认安装过程,没有任何特别之处。

之后,我们使用脚本编辑器应用编写了虚拟机启动和最小化的脚本,设置了SSH免密登录,并创建了简单的脚本来终止虚拟机。这就是全部内容。

如果你在跟随视频时遇到困难,可以在我们的课程网站上找到这里涵盖的所有内容的逐步说明。希望本教程对你有用,如果有任何问题,请告诉我。下次见。

005:工具提示 - ctags 🧰

概述

在本节课中,我们将学习如何使用 ctags 工具来高效地浏览和管理代码库。ctags 能够为代码库建立索引,允许我们在编辑器内快速跳转到函数定义,并返回原处,从而显著提升代码阅读和开发的效率。


代码组织与浏览挑战

在之前的课程中,我们经常查看大量代码示例。当所有代码都位于单个源文件中时,我们可以通过简单的文本搜索来查找函数定义。例如,在 main 函数中调用 get_input 时,可以通过搜索 ^get_input 来快速定位其定义。

然而,在实际项目中,为了保持代码的清晰和可读性,我们通常会将不同功能的代码拆分到多个文件中。例如,将 get_input 函数放入单独的 .c 文件,并将相关函数原型声明在对应的 .h 头文件中。

这种组织方式带来了新的挑战:当我们在 main.c 中看到 get_input 调用,并想查看其实现时,需要手动找到并打开 get_input.c 文件,查看后再返回原文件。在拥有数十甚至数百个源文件的大型项目中,这种方法效率极低。


引入 ctags 工具

幸运的是,UNIX 环境提供了 ctags 工具来解决这个问题。ctags 可以为你的代码库建立索引,创建一个映射文件(tags 文件),记录每个标识符(如函数名)在哪个文件的哪一行定义。编辑器可以利用这个文件,实现一键跳转。

生成 tags 文件

在项目根目录下运行 ctags 命令,即可为当前目录下的所有源文件生成索引:

ctags *.c *.h

执行后,会生成一个名为 tags 的文件。其内容格式如下:

get_input    get_input.c    /^get_input() {$/

它包含三列:标签名文件名和用于定位的正则表达式


在编辑器中使用 tags

大多数 UNIX 编辑器(如 vivim)都内置了对 tags 文件的支持。当 tags 文件存在于当前工作目录时,编辑器会自动识别并使用它。

以下是核心操作:

  1. 跳转到定义:将光标置于某个标识符(如函数名)上,按下 Ctrl + ]
  2. 返回原处:跳转后,按下 Ctrl + t 即可返回之前的位置。

你还可以使用 :ta <tagname> 命令直接跳转到指定的标签,无需将光标置于其上。


为系统库创建全局 tags

上一节我们介绍了如何为本地项目创建 tags。本节中,我们来看看如何将标准 C 库的源代码也加入索引,这样我们就能一键跳转到 printffopen 等系统函数的实现了。

这需要使用功能更强大的 exuberant-ctags 版本。首先确保已安装:

# 例如在基于 Debian 的系统上
sudo apt-get install exuberant-ctags

然后,我们可以递归地为系统头文件目录和库源代码目录建立索引:

ctags -R --c-kinds=+p --fields=+iaS --extra=+q -f ~/global_tags /usr/include /usr/src

这个命令会:

  • -R:递归处理目录。
  • --c-kinds=+p:为函数原型也生成标签。
  • -f ~/global_tags:将输出的 tags 文件保存到主目录下的 global_tags
  • 最后指定要索引的目录路径。


配置编辑器使用多个 tags 文件

为了让编辑器同时使用本地项目的 tags 文件和全局的 global_tags 文件,需要在编辑器配置中设置 tags 路径。

对于 vim,可以在 ~/.vimrc 文件中添加:

set tags=./tags,./TAGS,tags,TAGS,~/global_tags

这样,编辑器会按顺序在这些位置查找 tags 文件。

配置完成后,你就可以在代码中自由跳转了:

  • 从项目代码跳转到本地函数定义。
  • 从项目代码跳转到标准库函数(如 fprintf)的源代码定义。
  • 甚至可以从宏或常量的使用处跳转到其定义(如 BUFSIZ)。

每次跳转都会形成一个栈,连续按 Ctrl + t 可以按跳转顺序逐级返回。


相关技巧:快速查看手册

除了跳转到定义,UNIX 编辑器还提供了一个便捷功能:快速查看系统函数的手册页。将光标置于函数名上,按下 Shift + k,即可直接打开该函数的 man 页面。阅读完毕后,关闭手册页即可回到代码编辑界面。这在编写代码时快速查询函数用法非常有用。


总结

本节课中,我们一起学习了 ctags 这个强大的代码浏览工具。我们掌握了:

  1. 使用 ctags 为本地项目生成索引。
  2. 在编辑器中使用 Ctrl + ]Ctrl + t 进行跳转与返回。
  3. 使用 exuberant-ctags 为系统库生成全局 tags 文件。
  4. 配置编辑器以支持多 tags 文件,实现从项目代码到库源码的无缝跳转。
  5. 使用 Shift + k 快速查看函数手册页。

熟练使用 ctags 可以让你在复杂的代码库中轻松导航,极大地提升阅读和编写代码的效率。建议你立即为自己的开发环境配置好这个工具。

006:文件描述符与资源限制 🖥️

在本节课中,我们将学习UNIX系统中文件描述符(File Descriptors)的核心概念,并通过一个实际程序来探究进程可打开文件描述符的数量限制。我们将了解如何通过系统调用和编程方式获取这一限制,并理解其在不同UNIX系统间的差异。

概述

文件描述符是UNIX系统中进行输入输出(I/O)操作的关键抽象。几乎所有I/O操作都通过文件描述符完成,它们被实现为小的非负整数。标准输入(stdin)、标准输出(stdout)和标准错误(stderr)通常分别对应文件描述符0、1和2。这种设计使得程序无需关心底层是文件、管道还是网络套接字,提供了统一的接口。

然而,文件描述符是一种有限的系统资源。内核需要跟踪每个整数所代表的资源,因此不能允许进程无限制地打开文件。在多用户、多进程的UNIX环境中,必须谨慎管理资源,以防止单个进程耗尽系统资源。

探究文件描述符数量限制

为了回答“一个进程最多能打开多少个文件?”这个问题,我们编写了一个名为 openmax.c 的程序。该程序尝试通过多种方式来确定每个进程可打开文件描述符的最大数量。

以下是程序中使用的主要方法:

  1. 检查 OPEN_MAX 常量:首先检查系统头文件中是否预定义了 OPEN_MAX 常量。

    #ifdef OPEN_MAX
        printf("OPEN_MAX is defined to be %d\n", OPEN_MAX);
    #else
        printf("OPEN_MAX is not defined\n");
    #endif
    
  2. 使用 sysconf 函数:通过 sysconf(_SC_OPEN_MAX) 调用获取系统运行时配置的可打开文件描述符数量。这是一个更动态、更可靠的方法。

    long sc_open_max = sysconf(_SC_OPEN_MAX);
    if (sc_open_max < 0) {
        if (errno == 0) {
            printf("sysconf(_SC_OPEN_MAX) indicates no limit\n");
        } else {
            err_sys("sysconf error for _SC_OPEN_MAX");
        }
    } else {
        printf("sysconf(_SC_OPEN_MAX) = %ld\n", sc_open_max);
    }
    
  3. 使用 getrlimit 函数:通过 getrlimit(RLIMIT_NOFILE, ...) 获取进程级别的资源限制,即最大文件描述符数。

    struct rlimit rl;
    if (getrlimit(RLIMIT_NOFILE, &rl) < 0) {
        err_sys("can‘t get file limit");
    }
    printf("getrlimit(RLIMIT_NOFILE) -> rlim_cur = %ld\n", (long)rl.rlim_cur);
    

程序运行与结果分析

编译并运行 openmax.c 程序后,我们可能会得到看似矛盾的结果。例如,在一个系统上:

  • OPEN_MAX 常量可能被定义为 128。
  • sysconf(_SC_OPEN_MAX)getrlimit 可能都返回 1024。

通过实验(程序会循环打开文件直到失败),我们验证了实际可打开的文件数量与 sysconfgetrlimit 返回的值(1024)一致,而不是头文件中的常量(128)。这包括默认已打开的3个文件描述符(0, 1, 2)以及额外打开的1021个文件。

这个差异说明,每个进程可打开文件的最大数量是一个运行时可配置的值,可以通过 shell 的 ulimit 命令或编程接口进行修改。因此,依赖编译时常量 OPEN_MAX 并不可靠,而应该使用 sysconf(_SC_OPEN_MAX) 在运行时查询当前限制。

跨平台差异

为了加深理解,我们在不同UNIX系统上运行了该程序:

  • NetBSD/参考系统OPEN_MAX 为128,但运行时限制为1024。
  • macOS (BSD衍生系统)OPEN_MAX 可能定义为10240,但 sysconf 返回2560。
  • Linux (Ubuntu)OPEN_MAX 可能未定义,sysconf 返回1024。

这些结果证实了“它取决于具体系统和配置”的结论。OPEN_MAX 常量可能不存在、存在但已过时、或者与运行时实际情况不符。sysconfgetrlimit 是获取此信息的推荐方法。

核心要点与总结

本节课中我们一起学习了文件描述符的基本概念及其资源限制。我们通过编写和运行 openmax.c 程序,实践了探究系统行为的方法:

  1. 文件描述符是UNIX I/O的通用抽象,表现为非负整数。
  2. 进程可打开的文件描述符数量存在软性限制,可通过 ulimit -nsetrlimit 调整。
  3. 获取此限制的可靠方法是使用 sysconf(_SC_OPEN_MAX)getrlimit(RLIMIT_NOFILE),而非依赖可能过时的 OPEN_MAX 编译时常量。
  4. 不同UNIX系统(如BSD、Linux、macOS)对此限制的默认值和定义方式可能不同。
  5. 动手编写测试代码是验证理解和探索系统行为的有效途径。

延伸思考与预习

本节内容引出了更多值得探究的问题:

  • 如果将 ulimit 设置为 unlimited,实际限制是多少?它可能是无限的吗?
  • _POSIX_OPEN_MAX 这个常量有什么意义?
  • 在接下来的视频章节中,我们将详细学习 openclose 系统调用。请提前思考:打开和关闭文件描述符时,内核内部发生了什么?

007:Week 02 - open(2) 与 close(2) 🖥️

概述

在本节课中,我们将学习UNIX环境中两个核心的系统调用:openclose。它们是进行文件输入/输出操作的基础。我们将了解如何使用它们来创建、打开和关闭文件,并探讨相关的标志和常见错误。


系统调用概览

在上一节中我们提到,几乎所有的文件I/O操作都可以通过五个系统调用来完成。本节我们将介绍其中的两个:openclose。其余的三个——readwritelseek——将在下一节中介绍。这些是底层的系统调用,许多库函数都是为了方便或提供额外功能而对其进行的封装。

创建文件:creat 系统调用

在进行任何文件I/O之前,我们首先需要一个文件。我们可以使用 creat 系统调用来创建文件。

以下是 creat 系统调用的基本用法:

int creat(const char *pathname, mode_t mode);

这个调用以路径名作为第一个参数,以访问权限描述作为第二个参数。它会创建一个新文件,以写入模式打开它,并返回一个代表该新文件的文件描述符。

然而,creat 并不在我们之前提到的五个核心系统调用之列。原因是,creat 返回的文件描述符仅以只写模式打开。有时,我们希望创建一个新文件并获得一个允许读写操作的文件句柄。在早期UNIX中,open 无法创建文件,因此必须先用 creat 创建文件,然后关闭它,再用 open 重新打开。这引入了竞态条件,可能导致意外结果。因此,creat 已被带有特定标志的 open 调用所取代。

打开文件:open 系统调用

open 系统调用是打开(或创建)文件的主要方式。

以下是 open 系统调用的基本形式:

int open(const char *pathname, int flags, ... /* mode_t mode */);

它接受一个路径名、一个指示 open 如何行为的标志位掩码,以及一个可选的第三个参数——创建文件时的权限模式(会受到进程umask的影响,我们将在后续课程中讨论)。open 是这组系统调用中唯一一个不接收文件描述符作为参数的,这很合理,因为它是返回文件描述符的调用。

路径名参数的含义不言自明,让我们重点看看 flags 参数。

打开模式标志

打开文件时,需要指定文件是以只读、只写还是读写模式打开。这是一种有效的自我保护机制。

以下是基本模式标志:

  • O_RDONLY: 只读模式
  • O_WRONLY: 只写模式
  • O_RDWR: 读写模式

其他行为标志

除了基本模式,还可以通过“或”操作添加其他标志来改变 open 的行为。

以下是几个重要的标志:

  • O_APPEND: 以追加模式打开文件。
  • O_CREAT: 如果文件不存在则创建它。如果指定了此标志,则必须提供第三个参数 mode
  • O_EXCL: 与 O_CREAT 结合使用,确保原子性地独占创建文件。如果文件已存在,则 open 会失败。这可以避免“检查时间与使用时间”的竞态条件漏洞。
  • O_TRUNC: 如果文件已存在且成功以写入模式打开,则将其长度截断为0。

请注意,不同平台支持的 open 标志可能有所不同,并且可能超出POSIX标准的要求。你应该查阅本地系统的手册页以了解具体支持哪些标志。

openat 系统调用

许多现代UNIX版本还支持 openat 系统调用。它用于原子性地处理来自不同工作目录的相对路径名。考虑一个场景:你改变了工作目录,但希望相对于原始目录来解析一个路径,而不必切换回去或手动构造相对路径。openat 可以帮助防止“检查时间与使用时间”的竞态条件。你可以将此作为一个练习,思考具体场景并编写代码验证。

处理 open 的错误

open 调用成功时返回一个文件描述符,失败时返回-1并设置 errno

以下是 open 可能失败的一些常见原因:

  • EEXIST: 当指定了 O_CREAT | O_EXCL 而文件已存在时。
  • EMFILE: 进程打开的文件描述符数量已达到上限。
  • ENOENT: 文件不存在,且未指定 O_CREAT 标志。
  • EACCES: 对文件没有所请求的访问权限(例如,对只读打开需要读权限,对写或读写打开需要写权限)。

由于 open 可能因多种原因失败,必须在尝试使用其返回的文件描述符之前检查其返回值。永远不要像下面这样写代码:

int fd = open(“file.txt”, O_RDONLY);
read(fd, …); // 危险!如果 open 失败,fd 为 -1

而应该总是这样写:

int fd = open(“file.txt”, O_RDONLY);
if (fd < 0) {
    // 处理错误
} else {
    // 使用 fd
    read(fd, …);
}

关闭文件:close 系统调用

关闭文件相对简单。

以下是 close 系统调用的用法:

int close(int fd);

你只需传递文件描述符即可。关闭文件描述符会释放对该文件的所有记录锁(我们将在学期后期讨论文件记录锁的概念)。

在一个简单的程序中,当进程退出时,内核会自动关闭所有打开的文件描述符,因此你甚至可以不自己调用 close。但这是一种草率的编程习惯,很容易导致在重构代码(例如,将打开操作移入循环中)时泄露文件描述符。为了防止这种情况,你应该养成习惯,总是在 open 调用的同一作用域内显式关闭文件描述符。

作用域管理示例

为了说明在同一作用域内打开和关闭文件的概念,请看一个简单的例子。假设我们有一段代码,需要在其中添加新的文件操作逻辑。

  1. 我们首先在此处添加 open 调用(当然要仔细检查返回值)。
  2. 紧接着,在同一代码块内,我们立即添加 close 调用。
  3. 然后,我们再回到 openclose 调用之间,插入实际操作文件描述符的代码。

这种方式可以确保我们不会忘记稍后添加 close 调用。一个简单的记忆方法是检查缩进级别:确保 openclose 处于相同的缩进层级。

更好的做法是,如果这段逻辑可以独立出来,就将其封装成一个函数。这样,整个打开、操作、关闭的流程都在函数内部完成,资源管理更加清晰和安全。

检查 close 的返回值

你可能注意到,在我们的代码示例中,并没有检查 close 的返回值,尽管我们强调应该检查所有函数的返回值。这是为什么呢?close 难道不会失败吗?

查阅手册页会发现,close 确实可能失败,例如当传入的参数不是有效的文件描述符,或者调用被硬件中断打断时。那么,如果 close 调用失败,你该怎么办?既然你打算关闭文件描述符,那么在 close 调用之后你也不会再使用它了,因此在大多数情况下,即使 close 失败,继续执行也是可以接受的。

然而,作为严谨的程序员,我们希望确保代码读者明白我们并非盲目地忽略返回值。因此,我们可以显式地将 close 的返回值转换为 void,以表明我们有意忽略它,如下所示:

(void)close(fd);

代码示例分析

现在,让我们通过一个代码示例 openex.c 来具体看看如何打开文件。该示例演示了以下场景:

  1. 创建一个新文件。
  2. 尝试创建已存在的文件(不使用 O_EXCL)。
  3. 尝试独占创建已存在的文件(使用 O_EXCL,预期失败)。
  4. 打开一个已存在的文件。
  5. 尝试打开一个不存在的文件(预期失败)。
  6. 以截断模式打开一个已存在的文件。

示例中定义了几个函数来演示这些操作。请注意,在第一个创建文件的函数中,我们故意没有在同一个函数内关闭文件描述符,这是为了展示多次打开文件时,文件描述符号会顺序递增。

运行这个程序,我们可以观察到:

  • 成功创建新文件时,返回的文件描述符是3(因为0、1、2已被标准输入、输出、错误占用)。
  • 再次尝试创建同一文件(无 O_EXCL)会成功,并返回新的描述符4。
  • 尝试用 O_CREAT | O_EXCL 创建已存在文件会失败。
  • 成功打开源文件本身。
  • 尝试打开不存在的文件会失败。
  • O_TRUNC 模式打开文件会成功,并且文件内容被清空。

通过这个例子,我们看到了 open 调用成功和失败的各种情况,并注意到文件描述符似乎是从当前可用的最小数字开始顺序分配的。


总结

本节课我们一起学习了UNIX文件I/O的基础:openclose 系统调用。我们了解了如何使用 open 及其各种标志来创建和打开文件,如何正确处理错误,以及为什么要在同一作用域内管理文件的打开和关闭。我们还通过示例代码观察了这些调用的实际行为。

请务必自己运行代码示例,如果仍有不清楚的地方,请在课程邮件列表中提问。在下一节中,我们将学习更令人兴奋的内容:使用 readwritelseek 来实际读写和操作文件数据。

008:Week-02, Segment-3 - read(2), write(2), lseek(2) 📖✍️🔍

在本节课中,我们将深入学习三个核心的系统调用:readwritelseek。我们将探讨它们如何操作文件描述符,理解文件偏移量的概念,并通过实际代码示例演示它们的行为。此外,我们还将了解如何确保I/O操作的效率,并探索一些与文件描述符相关的奇特现象,例如创建稀疏文件。


概述 📋

上一节我们介绍了文件描述符和 openclose 系统调用的基本概念。本节中,我们将详细探讨用于实际读写数据的 readwrite 系统调用,以及用于移动文件读写位置的 lseek 系统调用。掌握这三个调用是进行文件I/O编程的基础。


read(2) 系统调用 📥

read 系统调用用于从文件描述符读取数据。其函数原型如下:

ssize_t read(int fd, void *buf, size_t count);

以下是关于 read 的关键点:

  • 参数fd 是文件描述符,buf 是指向用于存储读取数据的缓冲区的指针,count 是请求读取的字节数。
  • 缓冲区管理:程序员必须确保提供的缓冲区足够大以容纳 count 字节的数据,否则可能导致段错误。
  • 文件偏移量read 从文件的当前偏移量开始读取。读取成功后,偏移量会自动增加已读取的字节数。
  • 返回值
    • 成功时返回实际读取的字节数。
    • 返回 0 表示已到达文件末尾。
    • 返回 -1 表示发生错误,并设置 errno
  • 部分读取read 不保证读取 count 指定的全部字节。如果剩余数据不足,或从面向记录的设备(如磁带、网络套接字)读取,它可能返回少于请求的字节数。这不是错误。
  • 信号中断read 可能被信号中断,我们将在后续关于信号的课程中详细讨论。


write(2) 系统调用 📤

write 系统调用用于向文件描述符写入数据。其函数原型如下:

ssize_t write(int fd, const void *buf, size_t count);

以下是关于 write 的关键点:

  • 参数:与 read 类似,fd 是文件描述符,buf 指向包含待写数据的缓冲区,count 是请求写入的字节数。
  • 缓冲区管理:同样,必须确保缓冲区至少包含 count 字节的有效数据。
  • 文件偏移量与 O_APPEND:默认情况下,write 从当前文件偏移量开始写入,并随后增加偏移量。如果文件以 O_APPEND 标志打开,则每次写入前都会将偏移量移动到文件末尾,实现追加写入。
  • 返回值
    • 成功时返回实际写入的字节数。
    • 返回 -1 表示发生错误。
  • 部分写入:在阻塞模式下,write 通常会阻塞直到写完所有请求的字节。在非阻塞模式下,或对于受流量控制的对象(如网络套接字),它可能写入少于请求的字节,需要程序重试写入剩余部分。
  • 对 setuid 位的影响:如果向一个可执行的 setuid 文件写入数据,write 会清除其 setuid 位,这是出于安全考虑。

代码示例:读写操作演示 💻

让我们通过一个简单的程序 rwx.c 来演示 readwrite 的行为,特别是文件偏移量和 O_APPEND 标志的影响。

// rwx.c 示例代码框架
#include <fcntl.h>
#include <unistd.h>
#define BUFF_SIZE 64
#define COMMENT “// Appended by program\n”

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/apue/img/fd4ae7ceb5b943b2880cac7cb1925f08_26.png)

int main() {
    int fd = open(“rwx.c”, O_RDWR | O_APPEND); // 使用 O_APPEND 打开
    char buffer[BUFF_SIZE];
    ssize_t bytes_read = read(fd, buffer, BUFF_SIZE);
    write(STDOUT_FILENO, buffer, bytes_read); // 输出读取的内容
    write(fd, COMMENT, sizeof(COMMENT)-1); // 追加注释
    close(fd);
    return 0;
}

程序行为分析:

  1. 使用 O_APPEND:程序以读写和追加模式打开自身源文件。它读取前64字节并打印,然后使用 write 在文件末尾追加一行注释。无论运行多少次,注释都会追加到文件最后。
  2. 不使用 O_APPEND:如果移除 O_APPEND 标志,write 将从当前偏移量(即上次 read 结束的位置,第65字节)开始写入,从而覆盖该位置原有的数据,而不是插入。
  3. 重要结论write 在偏移量处写入数据是覆盖操作,不会自动插入数据或移动后续内容。要在文件中“插入”数据,需要程序自己移动和重写后续部分。

这个例子清楚地展示了 readwrite 如何操作文件偏移量。


lseek(2) 系统调用 🎯

lseek 系统调用用于显式地重新定位文件偏移量,类似于磁带机的快进或倒带功能。其函数原型如下:

off_t lseek(int fd, off_t offset, int whence);

以下是关于 lseek 的关键点:

  • 参数
    • fd:文件描述符。
    • offset:偏移量。
    • whence:决定偏移量的参考位置,可以是:
      • SEEK_SET:从文件开头计算。
      • SEEK_CUR:从当前位置计算。
      • SEEK_END:从文件末尾计算。
  • 返回值:成功时返回新的文件偏移量。失败时返回 -1
  • 特殊用法
    • 可以寻求负偏移量(如回退)。
    • lseek(fd, 0, SEEK_CUR) 可用于获取当前偏移量,或测试文件描述符是否支持寻址(如管道、FIFO不支持)。
    • 可以寻求超过文件末尾的位置,这将创建一个“空洞”。


稀疏文件与文件空洞 🕳️

上一节我们提到了 lseek 可以移动到文件末尾之后。本节中我们来看看这个操作的奇特结果:创建稀疏文件。

如果使用 lseek 将偏移量移动到文件末尾之后很远的地方,然后进行 write 操作,就会在文件中创建一个“空洞”。文件系统不会为空洞分配实际的磁盘块,但在逻辑上,读取空洞会返回一串零字节 (\0)。

示例行为:

  1. 创建一个文件,写入10字节。
  2. lseek 到偏移量 10MB 处。
  3. 再写入10字节。
  4. 结果:文件逻辑大小为 10MB + 20 字节,但磁盘占用可能只接近 20 字节(加上少量元数据),而非完整的 10MB。

注意事项:

  • 稀疏文件的支持取决于底层文件系统(如 ext4 支持,HFS+ 不支持)。
  • 工具(如 cp, cat)对稀疏文件的处理方式不同:有些能识别并保持稀疏性,有些则会“填满”空洞,写入实际的零字节。
  • 可以使用 stat 命令查看文件的“逻辑块大小”,这通常是高效I/O的缓冲区大小参考值。


I/O 效率与缓冲区大小优化 ⚡

在编写类似 cat 的复制程序时,缓冲区大小会影响系统调用次数和效率。我们通过一个基准测试来探索其关系。

测试方法: 使用不同缓冲区大小(从 3MB 到 1 字节)复制 100MB 的文件,测量耗时。

核心发现:

  • 当缓冲区大小达到或超过文件系统的块大小(例如 16KB)后,继续增大缓冲区带来的性能提升微乎其微。
  • 缓冲区过小(如 256 字节以下)会导致系统调用次数剧增,性能显著下降。
  • 最佳实践:无需盲目使用超大缓冲区。使用 stat 命令获取文件的“优选I/O大小”作为缓冲区参考是一个好方法。在Linux上,可以使用 stat -c %o filename

结论: 平衡内存使用和性能,选择一个适中的缓冲区大小(如 4KB 到 64KB)通常是合理且高效的。


总结 🎓

本节课中我们一起学习了UNIX环境下的三个核心I/O系统调用:

  1. read:用于从文件描述符读取数据,需注意部分读取和文件偏移量的变化。
  2. write:用于向文件描述符写入数据,其行为受 O_APPEND 标志影响,且是覆盖操作。
  3. lseek:用于移动文件偏移量,支持相对或绝对定位,并可创建稀疏文件。

我们还探讨了文件空洞的概念以及如何通过选择合适的缓冲区大小来优化I/O效率。理解这些基础调用是进行更高级文件操作和系统编程的基石。

在下一节中,我们将深入研究文件共享以及在多进程环境中处理文件描述符的注意事项。

009:文件共享与高级文件I/O

在本节课中,我们将学习当多个进程访问同一文件时的含义及其对已知系统调用的影响。我们还将探讨一些与文件I/O和文件描述符相关的附加函数。掌握这些知识后,你将能够完成第一份作业,并有效运用目前所学内容。

概述

UNIX自诞生之初就是一个多用户、多进程的操作系统。这意味着多个进程可能同时访问相同的资源,例如文件。为了深入理解其机制,我们需要了解内核中实现这些概念的相关数据结构。

内核数据结构

上一节我们介绍了五个基本的文件I/O系统调用。本节中,我们来看看内核如何管理进程与文件的关系。

内核维护一个进程表,每个进程表条目包含一个文件描述符表。文件描述符表中包含文件描述符标志(我们尚未详细讨论)以及一个指向文件表条目的指针。

除了进程表,内核还维护一个文件表。每个文件表条目包含文件状态标志(例如我们已见过的 O_APPEND)、当前文件偏移量以及一个指向v节点表的指针。

遵循计算机科学中“所有问题都可以通过增加一个间接层来解决”的理念,我们并不直接实现文件系统逻辑,而是通过V节点来抽象文件系统API。不同的文件系统在V节点表条目中包含不同的元信息,但为了与传统UNIX文件系统兼容,它们都实现了i节点信息的接口。

以下是这些数据结构关系的示意图:

如果一个进程两次打开同一个文件,数据结构将如上图所示。注意,中间的文件表条目是独立的,但都指向右侧同一个v节点表条目。

类似地,如果两个不同的进程打开同一个文件,情况可能如下图所示:

文件偏移量与操作

理解了这些内核结构后,让我们回顾一下之前讨论过的系统调用。

readwrite 系统调用从当前偏移量开始,并在完成后将偏移量增加已读取或写入的字节数。如果我们写入数据,并且结果偏移量大于文件大小(即我们向文件添加数据),那么只需将当前偏移量复制到i节点的文件大小字段中。

如果我们使用 O_APPEND 标志打开文件,所有写入操作都将在文件末尾进行。其工作原理是:以 O_APPEND 模式打开文件时,文件状态标志会相应设置。每当调用 write 时,它会首先将当前偏移量设置为当前文件大小,然后更新当前偏移量。由于根据定义,当前偏移量大于文件大小,因此也会更新i节点中的文件大小。

lseek 系统调用仅操作文件状态表中的当前偏移量。要定位到文件末尾,lseek 会将当前文件大小复制到偏移量中。要定位到文件开头,只需将当前偏移量设置为0。注意,lseek 完全独立于文件大小,它不需要访问磁盘获取信息,而是完全在元信息上操作。

原子操作与竞争条件

但是,如果多个进程可能同时访问同一个文件呢?这时我们很快需要原子操作。原子操作是指保证完全执行而不会被另一个进程干扰的操作。

让我们通过一个例子说明这种操作的必要性。早期UNIX版本没有 O_APPEND 标志。如果你想追加到文件,必须先 lseek 到文件末尾,然后执行 write。这需要完成两个操作。如果另一个进程同时做同样的事情会发生什么?

假设有两个进程,都试图将各自的缓冲区内容追加到同一个文件。进程1想追加“P1”,进程2想追加“P2”。如果进程1先获得CPU并调用 lseek(0, SEEK_END),这将把当前文件大小复制到进程1的文件表条目的当前偏移量中。但此时调度器决定让进程2运行,进程2也调用 lseek(0, SEEK_END),将当前文件大小(例如128)复制到进程2的文件表条目的当前偏移量中。进程2然后发出 write,成功将数据追加到末尾。新偏移量变为173,由于大于128,它将173复制到i节点的文件大小中。进程2写入数据后退出。现在,进程1再次获得CPU,从它离开的地方继续,即在调用 lseek 之后。此时它的当前偏移量仍为128。它将在位置128写入缓冲区1的内容,覆盖了进程2之前写入的字节。它调整了自己的偏移量然后退出。最终,文件末尾的数据被损坏。

这说明了追加数据需要原子操作的必要性,而文件状态标志 O_APPEND 解决了这个问题。

但有时我们想写入文件末尾以外的不同位置,并且希望避免刚才解释的竞争条件,这同样适用于多个进程同时尝试写入的情况。

为了解决这个问题,除了标准的 readwrite 系统调用外,我们还有 preadpwrite 函数。它们的工作原理与普通的 readwrite 调用类似,但接受一个额外的参数——偏移量。它们会在发出读/写操作之前原子性地定位到该偏移量。调用这些函数后,当前文件偏移量不会改变,意味着你在文件中的当前位置与调用前相同。与往常一样,如果遇到错误,这些函数返回-1并设置 errno

Shell中的文件状态标志

让我们看看各种文件状态标志在Shell中运行命令时的实际含义。

我们知道,在Shell中使用 > 符号将输出重定向到文件时,如果文件不存在则会创建它并写入数据。如果文件存在,则会被截断。这意味着Shell在此处使用的打开标志是 O_CREATO_TRUNC。因此,文件内容将被覆盖。

如果我们想追加到文件,Shell允许我们通过 >> 符号来实现,将 O_TRUNC 改为 O_APPEND。但请注意,即使在追加模式下,如果文件不存在,我们仍然会创建它,因此 O_CREAT 标志仍然有效。追加到现有文件按预期工作,不会截断文件。

现在,让我们看看多个流的输出重定向。当我们运行一个程序时,我们知道标准输出和标准错误都连接到终端。我们会在终端上收到错误消息和命令的输出。

如果我们想抑制错误消息,可以将另一个文件描述符2重定向到另一个文件。使用特殊设备 /dev/null,我们可以完全丢弃标准错误的输出,但这里的重定向仍然是通过打开文件来完成的。

如果我们把文件描述符2重定向到文件描述符1(标准输出),我们不需要指定文件描述符,Shell单独使用 > 时默认重定向标准输出。标准错误仍将按预期连接到终端。

但看看文件的内容。注意,ls 告诉我们文件大小为0字节。让我们比较一下。文件实际上是51字节大小。为什么重定向输出时它写入了0字节?原因正如一分钟前指出的,输出重定向以 O_TRUNC 模式打开文件。输出重定向在 ls 命令执行之前在Shell中发生,因此Shell截断了文件。当 ls 查看它时,发现它的大小为0字节。

现在,尝试将标准错误捕获到同一个文件中。我们只需使用这种语法在这里重定向它。运行命令时,由于我们将标准输出和标准错误都重定向到了文件,终端上没有输出。但文件只包含命令的标准输出。标准错误的输出去哪了?让我们看看错误消息。它比标准输出的消息短。我们重新生成并比较一下。这是错误消息,这是我们期望显示在文件中的内容(标准输出),这个更长。所以看起来可能是标准错误先被写入文件,然后标准输出覆盖了它的输出,就像我们几分钟前关于 O_APPEND 是原子性的说明一样。

为了验证这个假设,让我们尝试生成一个更长的错误消息。如果我们尝试 ls 两个不存在的目录,那么我们的错误消息将比命令的成功输出更长。让我们试试看。这证实了我们的假设:标准输出和标准错误的重定向都以 O_TRUNC 模式打开文件,因此它们都将文件大小和当前偏移量设置为0。错误消息被写入,然后当标准输出写入时,它覆盖了标准错误的部分字节,标准错误剩余的字节留在了后面。

那么,让我们尝试通过要求标准错误使用 O_APPEND 模式来修复这个问题。不幸的是,这也失败了。标准输出重定向以 O_TRUNC 模式打开文件,标准错误以 O_APPEND 模式打开它,但如果标准错误先写入数据,那么 O_APPEND 也帮不了我们。

让我们试试反过来。这样看起来好多了。所以我们观察到了之前说明的完全相同的竞争条件,并且我们观察到错误似乎总是在标准输出之前完成。情况总是这样吗?一般来说,到标准错误的消息是无缓冲的,而到标准输出的I/O很可能被缓冲,这就是为什么当两者都连接到终端时,你可能会先看到错误消息,然后才是正常输出。

当然,有更好的方法来确保标准错误和标准输出去往同一个地方。我相信你们很多人都用过。更简单的方法不是告诉Shell将标准输出放入文件并将标准错误放入文件,而是将标准输出放入文件,并将标准错误重定向到标准输出当前指向的地方。这是常见的语法。这样做的优点是,我们可以告诉Shell将标准错误重定向到标准输出所指向的任何地方,无论标准输出去哪里。最常见的用例是告诉Shell确保将标准输出和标准错误都写入管道。正如我们讨论的,并且根据设计,当你将输出管道传输到另一个命令时,标准错误仍然连接到终端。所以这个语法是说:打开管道并将第一个命令的标准输出连接到第二个命令的标准输入,然后将第一个命令的标准错误重定向到标准输出所指向的地方(即进入管道),这样标准错误也进入了管道。

但我们如何做到这一点呢?通过 >>> 的重定向使用 open 系统调用。但我们如何“打开”标准输出当前指向的东西呢?

dupdup2 系统调用

答案是 dupdup 系统调用复制一个现有的文件描述符,你会得到第二个文件句柄,指向与第一个相同的文件表条目。让我们再次看看涉及的内核结构。注意左侧进程表中文件描述符标志和中间文件表中文件状态标志的范围差异。

与进程多次调用 open 打开同一文件并获得指向不同文件表条目(这些条目又指向同一个v节点表条目)的文件描述符的情况不同,这里我们有重复的文件描述符指向同一个文件表条目,意味着两者共享偏移量、文件状态标志等。新的文件描述符确实是原始描述符的副本。

现在,为了允许重定向现有的文件描述符,我们可以使用 dup2。现有的文件描述符将被关闭,并改为指向原始的文件表条目。

在代码中,它看起来像这样:

// 示例代码:使用 dup2 重定向标准错误到标准输出
dup2(1, 2); // 将文件描述符2(标准错误)重定向到文件描述符1(标准输出)所指向的地方

让我们运行一个示例程序来演示。我们定义两个消息,一个用于标准输出,一个用于标准错误。write_both 函数会将给定的标记写入标准输出,然后将标准输出消息写入标准输出文件描述符,将标准错误消息写入标准错误文件描述符。在 main 中,我们调用 write_both,然后执行“标准错误到标准输出”的重定向(即我们将任何要发送到标准错误的消息重定向到标准输出当前指向的地方),然后再次调用 write_both

运行它。第一次调用时,标准输出和标准错误都连接到终端,因此输出是顺序的,完全符合预期。现在,让我们通过将其重定向到 /dev/null 来抑制标准错误。注意,在我们的第一次调用中,标准错误被抑制了,因为我们的Shell已将其重定向到 /dev/null。但随后我们将标准错误从它指向的任何地方重定向到标准输出当前指向的地方(终端)。因此,第二次调用 write_both 时的错误消息显示在终端上。

反过来呢?让我们将标准输出重定向到一个文件。现在我们看到终端上有一条错误消息,而文件内容显示,该错误消息来自第一次调用 write_both,当时标准错误默认仍连接到终端。在我们重定向标准错误到标准输出之后,对标准错误的写入最终进入了文件。

这说明了Shell如何使用 dup2 来执行输出重定向,并且是另一个例子,说明我们可以通过编写几行代码来轻松确认我们的理解。

fcntl 函数

通过内核对象的可视化表示,我们看到文件描述符标志位于进程表条目中,而文件状态标志位于文件表中。这些通常在调用 open 时设置。但是,如果我们想在我们自己没有调用 open 的情况下(即当我们只有文件描述符时)检查或更改它们呢?

这就需要 fcntlfcntl 是那种可以完成一大堆事情的“全能”函数之一。例如,你可以获取或设置这些标志,也可以通过 fcntl 复制文件描述符,以及执行许多操作系统特定的操作。请照常查阅手册页。

让我们看一个使用 fcntl 的例子。这里是我们简单 cat 程序的另一个版本。只是在这个版本中,我们希望确保I/O是同步完成的。提醒一下,同步输出将在每次调用后等待I/O刷新到磁盘,而异步输出允许I/O处理程序更高效地缓存I/O。由于我们简单的 cat 在标准输入上运行,我们不能用 O_SYNC 调用 open 来打开同步模式(就像对普通文件所做的那样),所以这里我们改用 fcntl

要设置特定标志,我们必须首先获取现有标志,然后打开或关闭我们想要的标志,最后再设置标志。程序的其余部分与之前相同。

在运行之前,我们将创建一个足够大的文件来说明同步和异步模式之间的性能差异。现在,当我们运行同步 cat 时,我们观察到它需要一些时间才能完成。现在,它与异步模式相比如何?让我们暂时注释掉 O_SYNC 标志,然后再运行一次。同步模式保证I/O总是立即刷新,但显然会带来性能损失。我们可以使用 fcntl 设置适当的文件标志来打开同步模式。

ioctl 与文件描述符作为文件

接下来是 ioctlioctl 是UNIX上另一个“全能”函数。许多接口都被硬塞进文件描述符API中,根本不是文件的东西也可能有某些标志或其他类型的设备特定设置。ioctl 系统调用通常用于为终端I/O、磁带机等设置这些标志。这里我们仅为了完整性而提及它,但鼓励你阅读各种手册页,因为当你进行更高级的系统编程时,会遇到 ioctl

最后,鉴于UNIX中的文件API相当方便,让我们看看文件描述符如何被表示为文件。

我知道这听起来有点像“盗梦空间”,对吧?无论如何,许多UNIX版本在文件系统中支持 /dev/stdin/dev/stdout/dev/stderr 设备,让你可以分别访问标准输入、标准输出和标准错误。在我们的参考平台上,这些被表示为字符设备,可以代替常规文件使用。这允许你编写一个处理文件以及标准输入的程序,例如,通过将 /dev/stdin 指定为 open 的路径名。

为了说明这一点,让我们创建两个文件,并在它们之间通过管道输入一些内容。这里我们看到 cat 按从左到右的顺序处理文件:首先显示第一个文件的内容,然后是 /dev/stdin 的内容(现在它从管道获取),接着是第三个文件的内容。

引用标准输入的另一种路径名方式可能是使用 /dev/fd/0。这是文件描述符0的另一种路径名表示。这些东西看起来像什么?让我们看看 /dev/fd。类似于 /dev/stdin,有连接到你的终端的字符设备。到目前为止还好。

让我们再比较两个其他UNIX版本。首先,再次是macOS。在这个系统上,/dev/stdin 等分别是 /dev/fd/012 的符号链接。但是 /dev/fd/0 看起来像什么?在这里,/dev/fd 表示当前进程的打开文件描述符,所以我们当然期望看到文件描述符0、1和2连接到终端。但我们也看到两个额外的文件描述符,类型为目录。让我们找出它们是什么。让我们看看当前工作目录的i节点号,以及 /dev/fd 的i节点号。然后再看看 /dev/fd/0 的内容,但这次也显示那里显示的文件i节点号。注意,文件描述符0、1和2都是相同的i节点——它们连接到同一个东西,即我们的终端。文件描述符3连接到i节点号393238,这似乎是当前工作目录。文件描述符4,i节点号318,是目录 /dev/fd。所有这些都有道理,因为每个进程都有一个当前工作目录,它有一个句柄。我们要求 ls 命令列出 /dev/fd 的内容,所以它当然也有一个文件句柄。

现在,当我们创建一个管道时会发生什么?看起来我们打开了几乎相同的文件描述符,但这次文件描述符1(标准输出)的类型是54,一个名为“pipe”的命名文件。如果我们也将标准错误重定向到管道中,那么看到文件描述符2也变成FIFO就不足为奇了。这似乎是这个操作系统实现Shell管道的方式。

接下来,让我们再看看Linux。在这里,/dev/stdin 等也是一些符号链接,但指向 /proc/self/fd/proc 是ProcFS的挂载点,ProcFS是一个伪文件系统,允许许多进程特定属性在文件系统中体现。确认 /dev/fd 也指向 /proc/self/fd。在其中,我们找到文件描述符。当我们列出它们时,在其中找到指向终端的文件描述符0、1和2,以及另一个指向 /proc/8461/fd 的条目,其中8461是 ls 命令的进程ID。

如果我们在这里使用管道会发生什么?看,文件描述符1变成了一个套接字。看起来这个操作系统将Shell管道实现为套接字或套接字对。我们将在以后更详细地讨论这些,以及FIFO管道和进程间通信。

现在注意,进程确实在两次调用之间发生了变化,这是预期的,因为每个命令都有自己的进程。我们当前Shell的进程ID是什么?8450。所以让我们看看这个Shell当前打开的文件描述符。和之前一样,文件描述符0、1和2连接到终端。然后,看起来这个Shell有一个指向Shell历史文件的文件句柄。默认情况下,许多Shell将所有输入的命令记录到历史文件中,所以Shell打开那个文件句柄并不奇怪。

最后,让我们看看Shell如何实现管道为什么可能很重要。之前,我们能够使用路径名 /dev/stdin 在管道中处理文件时进行填充。/dev/fd/0(即 /dev/stdin 的端点)在连接到终端时按预期工作。但当我们尝试在管道中使用它时,我们得到一个奇怪的错误:“/dev/stdin: No such device or address”。这是因为,正如我们上面看到的,Linux似乎使用套接字实现管道。但你不能打开一个套接字。我们有其他系统调用来处理套接字。但 cat 命令对套接字一无所知。它被给定了一个路径名 /dev/stdin,并尝试打开该路径名,现在失败了。这提醒我们,即使不同的UNIX系统行为相似并且应该遵循相同的语义,但有时基于某人选择实现某个特性的方式,事情可能会中断或以意想不到的方式运行。

总结

在本节课中,我们详细介绍了用于几乎所有UNIX文件I/O的五个基本系统调用。我们运行了许多代码示例来澄清我们的理解,并说明了一些可能令人惊讶的方面。幻灯片PDF中还有一些额外的链接,请务必查看。掌握了所有这些知识,你现在已经完全可以开始将你的知识付诸实践了。我已经发布了第一份作业的链接。我们将在课堂上更详细地讨论作业。在下一讲中,我们将继续讨论文件和目录,并将花大量时间与我们的新朋友——stat 结构体——相处。届时,感谢观看。再见。

010:Week 03 - 关于 stat(2) 的一切 😊

在本节课中,我们将要学习如何获取文件的元数据信息。我们将深入探讨 stat 系统调用家族,了解其返回的数据结构 struct stat,并学习如何利用这些信息来识别文件类型、权限、大小等属性。

概述

在之前的课程中,我们重点讨论了文件描述符。但在过程中,我们遇到了文件的许多属性,例如在提升一个简单 cat 命令克隆的效率时,我们看到了最佳文件 I/O 块大小。在本节及接下来的课程中,我们将更详细地重新审视这些不同的属性。

获取文件信息:stat 系统调用

为了获取关于文件的信息(即所有元信息,而非文件内容),我们引入了 stat 系列系统调用。与文件和目录上下文中将涵盖的 fstat 相关调用一样,stat 有三种形式:statlstatfstat

fstat 版本与其他版本的不同之处在于它接受一个文件描述符作为参数,这与我们之前讨论的大多数 I/O 操作应在文件描述符上进行是一致的。但是,为了获得文件描述符,首先必须打开一个文件。正如我们将在未来的课程中看到的,在标准的 Unix 文件系统中,文件数据和文件元数据是明确分离的。通常,能够在不打开文件的情况下获取其信息是非常有用的。

因此,为了获取文件信息,我们向 stat 传递一个路径名,以及一个指向 struct stat 的指针,该系统调用将填充这个结构体。正如我们在讨论 open 系统调用时所见,也存在允许原子地访问当前工作目录之外的相对路径名的需求。因此,fstatat 系统调用满足了这一需求,类似于 openat,它接受一个引用目录的额外文件描述符作为参数。

通常,stat 的返回码在成功时为 0,在错误时为 -1,并设置 errno 以指示错误性质。

理解 struct stat 数据结构

我们感兴趣的是获取文件的所有元信息。函数高效地提供复杂信息并遵循简单、有意义的错误代码约定的惯用方法是,接受一个指向数据结构的指针作为输入,然后由调用填充该数据结构。在本例中,该数据结构就是 struct stat

struct stat 的细节通常是特定于版本的,其最低共同标准是由 POSIX 定义的字段,如本幻灯片所示。当然,您特定的 Unix 版本可能支持额外的字段,因此请务必查阅您的手册页以获取详细信息。

POSIX 要求的字段都帮助详细描述文件,并暴露了许多工具使用的重要信息,最著名的当然是 ls 命令。您很快就会非常熟悉它们。

实践:创建测试环境

在我们查看 struct stat 信息之前,让我们为我们的虚拟机创建一个小的第二块磁盘来操作。我们在接下来的几个视频片段中使用这个磁盘来更好地理解文件系统。当然,您可以使用 VirtualBox UI 添加新磁盘,但那样有什么乐趣呢?我们想使用命令行。

以下是创建和挂载新磁盘的步骤:

  1. 创建一个新的 VMDK 格式磁盘,大小为 50 MB。
    VBoxManage createmedium disk --filename disk2.vmdk --size 50 --format VMDK
    
  2. 将新磁盘作为 IDE 驱动器附加到虚拟机。
    VBoxManage storageattach CS631 --storagectl IDE --port 0 --device 1 --type hdd --medium disk2.vmdk
    
  3. 启动虚拟机并登录。
  4. 在新磁盘上创建文件系统(块大小为 4096 字节)。
    newfs -b 4096 /dev/ada1
    
  5. 将新磁盘挂载到 /mnt
    mount /dev/ada1 /mnt
    
  6. /mnt 的所有权更改为当前用户,以便无需超级用户权限即可写入数据。
    chown $USER /mnt
    
  7. 查看可用空间。
    df -h /mnt
    
  8. 创建一个简单的 1 MB 文件。
    dd if=/dev/zero of=/mnt/file bs=1M count=1
    
  9. 使用 ls -l 显示文件。

解析 ls -l 的输出

现在,让我们仔细看看 ls -l 的输出。输出中的每个字段都源自我们之前看到的 struct stat 中的信息。ls 调用 stat,然后以给定格式显示数据。

以下是 ls -l 输出与 struct stat 字段的对应关系:

  • 左侧的权限字符串源自 st_mode 字段。
  • 下一个是此文件的链接数,即与此特定 inode 关联的文件名数量。
  • 之后是文件的所有者。这里显示的符号用户名源自 st_uid 字段。
  • 同样,组所有者名称源自 st_gid 字段。
  • 接下来,ls 显示以字节为单位的文件大小,来自 st_size 字段。
  • 右侧显示的数据是最后修改时间戳,由 ls 根据 st_mtime 字段以便于人类阅读的格式方便地格式化。
  • 最右边当然是文件名,它根本不在 struct stat 中。我们将在下一讲中回到这一点,但现在请记住,文件名不是文件的属性,而是目录中找到的映射。

使用 stat 命令

正如我们所见,ls 命令的输出包含了许多来自 struct stat 的信息。但通常,有不止一种方法可以做到这一点。具体来说,现在大多数 Unix 版本都有一个 stat 命令,允许您检索和检查完整的 struct stat,包括 ls 未暴露的字段。不过我们应该提到,这个 stat 命令不是 POSIX 的一部分。因此,不同 Unix 版本之间 stat 命令的输出和用法差异很大。

以下是之前显示的文件上调用 stat 的示例输出。默认情况下,stat 以人类友好的输出显示信息。-r 标志将显示更改为显示实际值。

具体来说,默认输出显示以下字段:st_devst_inost_modest_nlinkst_uidst_gidst_rdevst_sizest_atimest_mtimest_ctimest_birthtimest_blksizest_blocksst_flags

深入 struct stat 字段

以下是 struct stat 中关键字段的详细说明:

  • st_dev:标识文件所在的设备 ID(例如磁盘)。与 inode 号一起,这在文件系统的所有挂载点中唯一标识一个文件。
  • st_ino:文件的 inode 号。
  • st_mode:编码文件类型以及权限,您可能在这里熟悉的八进制显示中认出它。
  • st_nlink:链接计数。
  • st_uid:数字用户 ID。
  • st_gid:数字组 ID。
  • st_rdev:仅用于某些设备,这就是为什么我们看到对于常规文件(如本例)它被设置为 -1。
  • st_size:文件大小(字节),不言自明。
  • st_atimest_mtimest_ctime:分别表示文件的最后访问时间、修改时间和文件状态更改时间。正如我们在早期课程中描述的,此类时间以自纪元以来的秒数保存。我们将在未来的视频片段中回到这三个时间戳的含义。
  • st_birthtime:另一个时间戳,不是 POSIX 要求的,可能并非所有 Unix 或文件系统版本都支持,它表示 inode 创建时间。在本例中,我们在额外磁盘上创建并挂载在 /mnt 下的文件系统类型是 FFSv1,它不支持 inode 创建时间。如果您在 VM 中对根文件系统上的文件运行 stat 命令(根文件系统是使用系统默认的 FFSv2 文件系统创建的),您应该在这里看到一个实际的时间戳。
  • st_blksizest_blocks:我们已经见过。st_blksize 是文件系统 I/O 的首选块大小。st_blocks 是分配给文件的 512 字节块的数量。
  • st_flags:编码额外的文件标志,我们将在未来看到,尽管如果您好奇,可以 already 查看 chflags 手册页和 ls-O 标志。

文件类型

正如我们提到的,st_mode 不仅包含文件权限,还包含文件类型。让我们仔细看看我们可能遇到的文件类型。

以下是主要的文件类型:

  1. 常规文件:当我们说“文件”时,大多数人真正指的是这个。文件仅仅是将一堆字节存储到磁盘的一种方式。重要的是要记住,内核不关心你用这些字节做什么。也就是说,二进制文件、图像文件、电子表格、电子邮件附件或共享库之间没有区别。就文件系统而言,它们都是没有任何特定含义的常规文件。由应用程序来解释在磁盘上找到的字节序列。
  2. 目录:是的,目录是一种特殊类型的文件,它将符号名称(字符串,人类往往觉得非常方便)映射到 inode 号,允许文件系统找到与该字符串关联的文件元信息和数据块。任何这样的目录条目,即 inode 和字符串之间的映射,被称为链接或硬链接。在任何文件系统上,甚至在一个目录内,可能有许多这样的字符串到特定 inode 的映射。我们将在后面的部分中了解更多关于目录的信息,并在下周的主题中查看一些文件系统实现方面。
  3. 设备文件:通常表示为根文件系统中 /dev 目录下的文件。这些设备分为字符特殊设备(例如终端)和块设备(例如磁盘)。
  4. 命名管道(FIFO):简而言之,这是一个进程间通信端点,类似于 shell 管道,但具体化在文件系统中,允许不相关的进程相互通信。
  5. 套接字文件:同样用作同一系统上不相关进程进行进程间通信的集合点。请注意,这些套接字类型的文件与网络套接字不同,尽管这里使用的通信 API 是相同的。我们将在未来的课程中重新讨论这两者。
  6. 符号链接:有趣的是,这些文件的内容就是另一个文件的路径名,当访问时,它们只是说:“哦,别看我,去看那边的那个文件。”

实践:识别文件类型

让我们看看在文件系统上遇到的一些不同的文件类型。还记得我们第一周的简单 ls 克隆吗?它所做的只是打开目录,遍历条目,并打印出遇到的文件的名称。

让我们改进这个程序,让它告诉我们它遇到的是什么类型的文件。为此,我们进行以下更改:

  1. 首先,我们将当前工作目录更改为目标目录,这样我们就可以通过传递文件名来简单地调用 stat,从而避免了构造绝对路径名的需要。
  2. 然后,当我们遍历目录条目时,我们调用 stat 并让它填充这里的 struct stat 缓冲区。
  3. 根据 struct stat 中的信息,我们确定文件类型。我们的 get_type 函数使用 st_mode 字段上的 S_IS* 宏来识别文件类型,为每种文件类型返回一个描述性字符串,包括处理未知文件类型,因为嘿,你永远不知道,我们希望编写防御性强、健壮的代码。
  4. 在识别出文件类型后,我们调用 lstatlstat 的行为与 stat 类似,但如果所讨论的文件是符号链接类型,我们会获取关于符号链接本身的信息。请记住,符号链接是一个说“不,去看那边的那个文件”的文件。所以当我们调用 stat 时,它会去看那边的那个文件,即符号链接指向的任何文件。因此,如果我们想识别符号链接并确定符号链接本身的文件所有权等,我们需要 lstat

在运行此程序之前,让我们创建一堆不同类型的文件。

以下是创建各种类型文件的命令示例:

# 创建一个目录
mkdir mydir

# 创建指向字符特殊设备的符号链接
ln -s /dev/tty link_to_char

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/apue/img/240faf44a3165d25767e59aa695ee791_9.png)

# 创建指向块特殊设备的符号链接
ln -s /dev/ada0 link_to_block

# 创建指向目录的符号链接
ln -s mydir link_to_dir

# 创建一个命名管道 (FIFO)
mkfifo myfifo

# 创建一个指向套接字文件的符号链接 (假设 /var/run/log 是一个套接字)
ln -s /var/run/log link_to_socket

# 创建一个常规文件
touch file

# 为文件创建第二个硬链接
ln file file2

# 创建一个指向不存在的文件的符号链接
ln -s /nonexistent broken_link

现在,让我们看看 ls -l 的输出是什么样子。我们看到文件类型由左侧的第一个字符标识:l 表示符号链接,d 表示目录,p 表示 FIFO(命名管道),常规文件没有字符。我们观察到 filefile2 大小相同,并且具有相同的最后修改时间戳。

现在,让我们运行我们简单的改进版 ls 程序。正如预期的那样,我们正确识别了文件类型:目录、常规文件,以及那些指向其他内容的符号链接被 lstat 识别为符号链接,而被常规 stat 识别为符号链接指向的任何文件。我们注意到可能存在损坏的符号链接,即指向不存在的路径名的符号链接。在这种情况下,stat 失败,但 lstat 成功,这就是为什么我们得到“slink to unknown”的输出。

关于符号链接很酷的一点是,您可以通过创建目标来简单地修复它们,无需对符号链接本身进行任何更改。一旦目标存在,程序就能够通过符号链接对其进行 stat

硬链接与文件数据

现在让我们回到另一种类型的链接:硬链接。正如我们一分钟前所示,filefile2 看起来完全相同。我们操作一个文件,例如通过截断它,然后将字符串“foo”写入其中。然后 file2 也发生了变化。我们注意到 filefile2 的链接数显示为 2。也就是说,有两个东西指向现在包含“foo”的单个文件。

让我们让 ls 显示文件的 inode 号。我们看到两个文件的 inode 号都是 3,这意味着它们实际上不是两个文件,而是同一个文件。

让我们向文件读入一些数据,也许 10K 就足够了。如果我们想尽可能高效地读取文件,我们希望以 4K 块读取,如 struct statst_blksize 成员所示。请记住,当我们创建文件系统时,我们明确指定了 4K 的块大小,所以这个数字在这里不应该让人感到意外。

我们还可以通过 ls-s 标志查看这个 10K 文件实际使用了多少个磁盘块。在这种情况下,我们看到文件使用了 20 个块。但是等等,20 个块,块大小为 4K?这超过了 10K 的总数据。让我们快速查看 man ls 对块大小有什么说明。好的,我们在这里。-s 以 512 字节块或环境中的块大小为单位显示块。也就是说,这里报告的 20 个块是 20 个 512 字节的块。这正好加起来是 10K。

但我们的文件系统使用 4K 块。那么文件占用了多少个这样的块呢?让我们快速设置块大小环境变量,以便 ls 可以告诉我们。我们得到了,3 个块。嗯,严格来说,10K 需要 2.5 个块,但我们不能使用半个块,所以我们的 10K 需要 3 个 4K 块。

请注意,以 512 字节块显示信息的默认设置在整个系统中都很常见,例如运行 df 命令时。但一致性是 Unix 环境的关键部分,这些工具通常也可以考虑相同的环境变量。因此,将块大小设置为 4K 运行 df 将导致它以这些大小的块显示信息。

关于磁盘上的块大小与文件系统中的块大小,未来课程中还有更多内容。你很幸运。

总结

好了,让我们在此告一段落。我们已经涵盖了很多内容。我们遇到了我们的新朋友 struct stat。相信我,在不久的将来,您会非常熟悉它和所有其他字段。

我们还看到了 ls 命令如何利用 struct stat 信息,以及 stat 命令如何提供剩余的字段。比较这些命令的实现是一个有用的练习。

我们重新审视了关于 I/O 效率讨论中的 st_blocksst_blksize 成员。

我们改进了我们简单的 ls 克隆,以显示它遇到的文件类型,包括处理符号链接。

对于我们的第一个片段来说还不错,但正如经常发生的那样,我们只是触及了表面,还有更多内容即将到来。所以请继续关注我们的下一节,我们将深入探讨文件所有权和权限。

感谢观看,请确保在您自己的系统上重现此处显示的所有命令练习。下次见。

011:Week-03-Segment-2 - UID与GID 🔑

在本节课中,我们将深入学习进程的用户ID(UID)和组ID(GID),这是理解UNIX文件权限的基础。我们将探讨进程如何通过改变其有效ID来获取不同的权限,以及系统如何利用这些ID进行权限检查。

进程的UID与GID

上一节我们介绍了stat结构体,本节中我们来看看进程的身份标识。每个在UNIX系统上运行的进程都关联着六个或更多的UID和GID。

以下是主要的ID类型:

  • 真实用户ID(Real User ID)真实组ID(Real Group ID):代表进程的真实所有者,即启动进程的用户。
  • 有效用户ID(Effective User ID)有效组ID(Effective Group ID):系统在检查权限时实际使用的ID。通常与真实ID相同,但可以改变。
  • 保存的设置用户ID(Saved Set User ID):由exec()系统调用设置,用于记录程序启动时的有效用户ID,使得进程后续可以在特权ID和真实ID之间切换。
  • 附属组ID(Supplementary Group IDs):进程所属的额外组列表。

在大多数情况下,有效ID与真实ID是相同的。然而,系统允许通过一种特殊机制来改变有效ID。

Set-UID 与 Set-GID 机制

为了允许可执行文件以改变(通常是提升)的权限运行,包括root权限,UNIX提供了Set-UID和Set-GID位。

以下是其工作原理:

  • 当在一个可执行文件的st_mode中设置了 Set-UID位,任何用户执行该文件时,进程的有效用户ID(EUID) 会被设置为该文件的所有者ID,而真实用户ID保持不变。
  • 同理,设置 Set-GID位 会将进程的有效组ID(EGID) 设置为文件的组所有者ID。

这个机制的核心目的是允许普通用户执行需要特权的操作。setuid()seteuid()系统调用用于在程序中动态改变这些ID。

uid_t getuid(void);    // 获取真实用户ID
uid_t geteuid(void);   // 获取有效用户ID
int setuid(uid_t uid); // 设置真实、有效及保存的设置用户ID
int seteuid(uid_t euid); // 设置有效用户ID

重要规则:调用seteuid()仅改变有效用户ID,且仅当目标ID是当前真实用户ID或保存的设置用户ID时才被允许。而调用setuid()会同时设置真实、有效及保存的设置用户ID,且一旦将ID设置为非特权值,就无法再恢复提升的权限。

实例分析:pingwall命令

让我们通过两个经典命令来理解Set-UID和Set-GID的应用。

ping命令(Set-UID示例)
ping命令需要创建原始网络套接字,这需要超级用户权限。因此,系统的ping二进制文件通常被设置为Set-UID root。当普通用户执行ping时,其有效用户ID临时变为root,从而获得所需权限。执行完毕后,权限恢复。

wall命令(Set-GID示例)
wall命令用于向所有登录用户广播消息。它需要写入每个用户的终端设备(如/dev/pts/*)。这些设备文件通常的权限是crw--w----,所有者是root,组是tty,且组具有写权限。
wall命令被设置为Set-GID tty,而不是Set-UID root。这样,任何用户执行wall时,其有效组ID变为tty,从而获得了向终端设备写入的权限,同时又避免了拥有完整的root权限。这体现了 最小权限原则:只赋予进程完成任务所必需的最低权限。

权限检查:access()系统调用

当进程以提升的有效用户ID运行时,有时需要判断其真实用户ID是否具有访问某个资源的权限。直接的方法是先降低权限尝试访问,再恢复权限,但这很繁琐且非原子操作。

为此,系统提供了access()系统调用。

int access(const char *pathname, int mode);
int faccessat(int dirfd, const char *pathname, int mode, int flags);

access()根据传入的路径和模式(如R_OK, W_OK, X_OK, F_OK),检查真实用户ID和真实组ID是否具有相应权限,而不是检查有效ID。这使特权程序能够安全地代表真实用户进行前置权限验证。

课程总结

本节课中我们一起学习了UNIX进程身份标识的核心概念:

  1. 进程拥有真实有效的用户ID与组ID。
  2. 通过设置可执行文件的 Set-UIDSet-GID 位,可以使其在运行时改变有效ID,从而获得文件所有者或所属组的权限。
  3. 程序可以使用seteuid()在特权ID和真实ID之间切换,而setuid()会永久放弃提升的权限。
  4. 系统进行文件权限检查时,使用的是进程的有效用户ID和有效组ID
  5. 使用access()系统调用,可以检查进程的真实用户ID是否具有访问某个文件的权限。

理解这些ID及其转换机制,是掌握UNIX安全模型和编写安全系统程序的关键。在下一节中,我们将深入探讨文件权限位的具体含义和检查算法。

012:Week-03-Segment-3 - st_mode与权限详解 🔐

在本节课中,我们将要学习UNIX系统中文件权限是如何通过struct stat中的st_mode字段定义的,以及系统如何根据进程的有效用户ID(EUID)和有效组ID(EGID)来应用这些权限规则。

上一节我们介绍了进程的UID和GID,本节中我们来看看系统在检查文件访问权限时的具体决策顺序。

权限检查的顺序

文件的权限存储在struct statst_mode字段中,分为三组:所有者(user)组(group)其他用户(other)

为了打开一个文件,需要满足以下条件:

  1. 对文件所在目录拥有执行(x)权限。这是因为需要通过目录文件查找文件名到Inode的映射。读取目录内容(如ls命令)需要读(r)权限,而查找特定条目则需要执行(x)权限。
  2. 对路径中所有父目录拥有执行(x)权限。例如,打开路径/var/tmp/foo,需要对//var/var/tmp都拥有执行权限。
  3. 读(r)或读写(rw)模式打开文件,需要对该文件拥有读权限。
  4. 写(w)或读写(rw)模式打开文件,需要对该文件拥有写权限。
  5. 使用O_TRUNC标志打开文件(会清空文件),需要写权限。
  6. 创建新文件,需要对目标目录拥有写(w)和执行(x)权限,因为这需要在目录中添加一个新条目。
  7. 删除文件,同样只需要对文件所在目录拥有写(w)和执行(x)权限,文件自身的权限无关紧要。删除操作实质是修改目录内容,移除一个链接。
  8. 执行一个文件,需要对该文件拥有执行(x)权限,但不一定需要读(r)权限。对于编译型程序(如C程序二进制文件)确实如此。但对于解释型脚本(如Shell、Python),则需要读权限,因为解释器需要读取脚本内容。

以下是验证上述规则的一些实践建议:

  • 创建一个包含多级子目录的目录,进入深层目录创建一个文件,然后移除父目录的执行权限,尝试用相对路径和绝对路径打开该文件。
  • 将编译器生成的可执行文件的读权限移除,然后尝试运行它。
  • 创建一个权限为777的目录,体验不同用户如何不受文件本身权限限制而删除和重建文件。

权限决策树 🌳

了解所需权限后,我们来看系统如何根据进程的EUID和EGID来决定应用哪一组权限(用户、组或其他)。这是一个有序的决策过程:

  1. 超级用户(root):如果进程的EUID是0(root),则立即授予访问权限,完全不检查文件权限。
    • 代码表示:if (euid == 0) access_granted();
  2. 文件所有者:如果进程的EUID与文件的st_uid(所有者UID)匹配,则仅检查用户(user)权限位。若允许则访问成功;若拒绝则访问失败。此时不再检查组或其他权限
    • 代码表示:if (euid == st_uid) check_user_bits();
  3. 文件所属组:如果上述条件不满足,但进程的EGID与文件的st_gid(所属组GID)匹配,则检查组(group)权限位。根据结果决定访问。
    • 代码表示:else if (egid == st_gid) check_group_bits();
  4. 其他用户:如果以上条件均不满足,则最后检查其他用户(other)权限位
    • 代码表示:else check_other_bits();

这种“首次匹配即生效”的机制可能导致一些意想不到的结果。

示例演示 💻

让我们通过一些例子来理解这个决策过程。

首先,查看当前用户ID和组信息:

id

示例1:root的至高权限

  1. 创建一个文件并移除所有权限:chmod 000 file.txt
  2. 切换到root用户:sudo su
  3. 即使文件没有任何权限,root用户依然可以读取它:cat file.txt
  4. 文件的所有者(非root)则无法读取。

示例2:所有者权限优先于组权限

  1. 创建一个文件,所有者是用户A,组是wheel
  2. 设置权限为:所有者无权限,组有读权限(-r----- 或 模式040)。
  3. 用户A(作为所有者)将无法读取文件,因为系统匹配了“所有者”条件,而所有者权限位是拒绝的,因此不再检查组权限。
  4. 另一个在wheel组内的用户B则可以读取该文件。

示例3:组权限与其他权限

  1. 将文件组改为users,权限设为组可读,其他用户无权限(-r--r---- 或 模式440)。
  2. users组内的用户C可以读取。
  3. 不在users组内的用户D无法读取。
  4. 将权限改为组无权限,其他用户可读(-r-----r-- 或 模式404)。
  5. 此时,用户C(在组内)无法读取(因为匹配了组条件且被拒),而用户D(不在组内)可以读取(因为最终匹配了“其他用户”条件)。

示例4:删除文件的权限
尝试删除一个文件,即使所有者对该文件没有写权限,只要对所在目录有写和执行权限,删除操作仍会成功。rm命令的确认提示只是命令本身提供的便利,底层的unlink()系统调用无需确认。

总结

本节课中我们一起学习了UNIX文件权限的核心机制。我们明确了访问文件所需的各类权限,并深入剖析了系统检查权限时遵循的“所有者 -> 组 -> 其他用户”的决策树。关键点在于:root用户拥有无条件访问权;文件所有者的权限检查优先级最高;删除操作只依赖目录权限

理解这些规则对于系统安全和程序开发至关重要。请务必亲自动手复现和修改文中的示例,这比单纯观看更能加深理解。

在下一节中,我们将学习用于修改文件权限和所有者的相关系统调用(如chmod, chown),学完后你将能够自己实现大部分权限管理命令的核心功能。

013:Week-03-Segment-4 - chmod(2)与chown(2) 🛠️

在本节课中,我们将学习用于修改文件权限和所有权的两个核心系统调用:chmodchown。我们将通过代码示例来理解它们的工作原理和使用限制。

概述

在上一节中,我们通过命令行工具(如 chmodchown)操作了文件的权限和所有权。本节我们将聚焦于实现这些命令的底层系统调用。我们将详细介绍 chmodchown 系统调用的不同变体、使用规则以及它们对系统安全的影响。

chmod 系统调用

chmod 命令用于改变文件的访问权限,其底层实现是一系列 chmod 家族的系统调用。这些调用遵循我们熟悉的模式,提供了操作文件路径、文件描述符和相对路径的能力。

以下是 chmod 系统调用的主要函数:

  • int chmod(const char *path, mode_t mode); - 通过路径操作文件。
  • int lchmod(const char *path, mode_t mode); - 操作符号链接本身(如果支持)。
  • int fchmod(int fd, mode_t mode); - 通过文件描述符操作文件。
  • int fchmodat(int fd, const char *path, mode_t mode, int flag); - 在指定目录描述符下操作相对路径。

只有文件的所有者或超级用户(root)才能成功调用 chmod 来修改文件权限。可设置的权限位包括我们之前学过的读(r)、写(w)、执行(x),以及一个特殊的“粘着位”(sticky bit,又称保存文本位)。目前,粘着位主要对目录有意义,我们将在后续课程中详细讨论。

代码示例:修改文件权限

让我们通过一个程序来实践如何操作现有权限以及如何设置绝对权限值。

#include <sys/stat.h>
#include <stdio.h>

int main() {
    struct stat statbuf;

    // 1. 修改现有权限:关闭用户读位,开启设置组ID位
    if (stat("file", &statbuf) < 0) {
        perror("stat error for file");
    } else {
        // 获取当前模式,关闭用户读位,开启设置组ID位
        mode_t new_mode = statbuf.st_mode & ~S_IRUSR; // 关闭用户读
        new_mode |= S_ISGID; // 开启设置组ID位
        if (chmod("file", new_mode) < 0) {
            perror("chmod error for file");
        }
    }

    // 2. 设置绝对权限值:直接设置为八进制0644
    if (chmod("file1", S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH) < 0) {
        perror("chmod error for file1");
    }

    return 0;
}

程序首先调用 stat 获取文件“file”的当前权限状态。然后,它通过位操作关闭了用户读权限(S_IRUSR),并开启了设置组ID位(S_ISGID)。接着,程序直接为文件“file1”设置了绝对的权限模式 0644(即用户可读可写,组和其他用户只读)。

运行程序后,使用 ls -l 查看结果。可以看到“file”文件的用户读权限被移除,并且组权限位置显示为大写 S,这表示设置了 setgid 位但文件本身没有组执行权限。为“file”添加组执行权限后,S 会变为小写 s

chown 系统调用

chown 系统调用用于改变文件的所有者和所属组。其函数家族与 chmod 保持一致。

以下是 chown 系统调用的主要函数:

  • int chown(const char *path, uid_t owner, gid_t group);
  • int lchown(const char *path, uid_t owner, gid_t group);
  • int fchown(int fd, uid_t owner, gid_t group);
  • int fchownat(int fd, const char *path, uid_t owner, gid_t group, int flag);

改变文件所有者通常需要超级用户(root)权限,因为这直接影响系统安全。虽然某些遵循早期System V规范的系统允许文件所有者将文件转让给其他用户,但在实践中,绝大多数现代系统都要求root权限(通过定义 _POSIX_CHOWN_RESTRICTED 常量来限制)。

然而,改变文件的所属组则相对宽松。如果用户是文件的所有者,并且目标组是该用户的主组或附加组之一,那么该用户就可以成功修改文件的组所有权。

代码示例:修改文件所有权

以下程序演示了 chown 的不同使用场景。

#include <unistd.h>
#include <stdio.h>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/apue/img/0576aeb2bbdee3ff80e403f89f18e128_27.png)

int main() {
    // 尝试将文件所有者改为自己(通常无意义,除非是root)
    if (chown("testfile", getuid(), (gid_t)-1) < 0) {
        perror("chown to self error");
    }

    // 尝试将所有者改为用户fred (UID 1001),保留原组
    if (chown("testfile", 1001, (gid_t)-1) < 0) {
        perror("chown to fred error");
    }

    // 尝试将组改为“users”(假设用户在users组中)
    if (chown("testfile", (uid_t)-1, 100) < 0) { // 假设组“users”的GID是100
        perror("chown group to users error");
    }

    // 尝试将组改为“tty”(假设用户不在tty组中)
    if (chown("testfile", (uid_t)-1, 4) < 0) { // 假设组“tty”的GID是4
        perror("chown group to tty error");
    }

    return 0;
}

程序首先尝试将文件所有者改为当前用户自己(这通常总会成功,因为用户就是所有者)。接着,它尝试将所有者改为用户“fred”,这通常需要root权限,因此会失败。然后,程序尝试将文件组改为“users”,如果当前用户是“users”组的成员,这个操作会成功。最后,尝试将组改为“tty”,由于用户不在该组中,操作会失败。

当以root身份运行程序时,所有操作都会成功,因为root可以执行任何所有权更改。

总结

本节课我们一起学习了 chmodchown 系统调用。

  • chmodchown 系统调用家族提供了操作文件路径、文件描述符和相对路径的一致性接口。
  • 只有文件的所有者或root用户才能修改文件权限。
  • 通常只有root用户才能更改文件的所有者。文件所有者可以更改文件的所属组,但目标组必须是其主组或附加组之一。
  • 修改文件权限和所有权具有重要的安全影响,因此必须谨慎授予访问权限。

在下一个视频片段中,我们将探讨新创建文件的默认所有权和权限是如何确定的。

014:Week-03-Segment-5 - umask(2) 📁

在本节课中,我们将要学习当创建新文件时,系统如何决定其所有权和权限。我们将了解进程的UMask概念,并探究UMask Shell内置命令是如何实现的。


在上一节中,我们介绍了如何更改文件权限和所有权。本节中,我们来看看系统如何为新创建的文件分配默认的所有权和权限。

默认文件所有权

当新文件被创建时,其所有者通常是创建该文件的进程的有效用户ID(EUID)。这很合理。

然而,文件的组所有权可能取决于Unix版本或系统配置。以下是两种常见情况:

  • 在大多数Linux系统上,默认设置是从进程的有效组ID(EGID)继承组所有权。
  • 在BSD衍生系统上,默认是从文件所在目录继承组所有权。

这种设计的原因是,在多用户系统中,人们希望在由某个组拥有的目录中进行协作,该目录中的文件也应继承该组所有权。

让我们在实践中观察一下。我们在/tmp下创建一个新目录。这个新目录的组所有权是wheel,这与/tmp的组所有权一致。在deer目录下创建的新文件也应获得wheel的组所有权。看起来一切正常。

如果我们把目录的组所有权改为users呢?现在,新文件也由users组所有,因为它是在这个目录中创建的。当然,之前的文件保留了其原有的组所有权。

但是,如果目录被一个用户不属于的组所拥有呢?让我们试试看。文件file3从目录继承了daemon组所有权,即使创建它的用户并非daemon组的成员。

这是否意味着我可以创建由我不属于的组所拥有的文件呢?现在,我有了一个由daemon组拥有的程序。我是该文件的所有者,所以我应该能够更改其权限。这是否意味着我可以将我的权限提升到daemon组的权限呢?

幸运的是,这行不通。我们之前讨论过的权限规则仍然适用。

现在,让我们看看在Linux系统上是什么情况。在我的主目录下,新目录与当前目录具有相同的组所有权,这恰好也是我的主组。如果我们创建一个新文件,它会获得相同的组所有者。那么,这个系统也会从目录继承组所有权吗?

让我们将目录的组所有权改为root。新文件并没有继承目录的组所有权,而是始终从进程的主组ID获取组所有权。

正如我提到的,不同的系统行为不同,在管理它们时了解这些差异很重要。好了,这涵盖了默认所有权。

文件创建模式掩码(UMask)🔧

上一节我们介绍了默认所有权,本节中我们来看看默认权限。为此,我们需要了解UMask。

UMask是文件创建模式掩码,其中任何被置位的位在创建文件时都会被关闭。你可以在进程中设置UMask,以确保之后创建的任何文件都具有特定的默认权限。为此,你需要调用umask()系统调用。

请注意,UMask只适用于当前进程。系统可能为新进程设置了与你不同的默认UMask。

虽然你们中的许多人可能使用过umask这个Shell内置命令,但让我们通过本视频段中的最后一个命令示例来说明它是如何工作的。

以下是我们的程序umask.c。在其中,我们有一个open()调用,它将创建一个具有指定权限的新文件。

open("newfile", O_CREAT | O_WRONLY, 0777);

这里的权限指定为用户、组和其他用户都拥有读、写、执行权限。根据我们之前的课程,这里指定的权限会受到进程当前UMask的影响,这意味着在文件创建时,UMask中置位的权限位会被关闭,即使open()调用指定了它们。

所以,第一次调用此函数时,我们将拥有从Shell继承的任何UMask值。然后,我们显式地将UMask中的所有位关闭,这意味着我们将允许open()调用所指定的所有权限。在第三次调用之前,我们设置了一个UMask来关闭组和其他用户的写权限。让我们运行它。

我们当前的UMask是0022。这意味着,默认情况下,我们希望关闭组和其他用户的写权限。当我们创建一个新文件时,它将以模式644创建,允许用户读写,但只允许组和其他用户读。

现在,我们运行我们的程序。生成的文件如下所示。回想一下,我们的open()调用每次都请求为所有用户、组和其他用户设置读、写、执行权限。

  • 第一个文件file1:UMask关闭了组和其他用户的写权限。
  • 第二个文件file2:我们显式地关闭了UMask,因此它获得了open()请求的所有权限。
  • 第三个文件file3:我们关闭了组和其他用户的读写权限,但保留了执行权限。

让我们将UMask更改为不同的值0077再试一次。0077意味着默认情况下,禁用组和其他用户的读、写、执行权限。因此,我们新创建的文件只获得模式600。我们的程序创建的文件,在程序设置UMask的情况下,结果与第一次几乎相同;但在继承Shell的UMask的情况下,结果则不同。

如果还不清楚,请尝试在Shell中设置新的UMask为特定值,并观察对新创建文件的影响,以确保你理解其效果。


总结与预告 🎯

本节课中我们一起学习了文件权限和所有权、有效用户ID和组ID、如何更改它们,以及系统如何强制执行权限。在这个简短的视频中,我们讨论了UMask,以及它如何允许用户影响新文件创建时的默认权限。

掌握了所有这些知识,你应该能够实现大部分chmod命令的功能,以及stat命令。事实上,仔细想想,根据我们目前所学,你实际上应该能够编写一个接近系统ls命令的版本。你知道吗?让我们就这么做吧。这将是一个期中编码作业,我保证会很有趣。

请仔细阅读此URL处的作业说明,我们将在下一节课中讨论它。

在下一个视频片段中,我们将讨论一些关于目录的内容,为我们第四周关于文件系统和大量系统调用的材料做准备。感谢观看,下次再见。

015:工具提示 - screen(1)

在本节课中,我们将学习一个强大的工具:GNU Screen。它是一个终端复用器,可以帮助你更好地通过键盘管理多个终端窗口,防止网络中断的影响,并允许你在断开连接后重新连接到远程会话。掌握它,你的工作效率将得到显著提升。

概述:远程开发中的常见问题

上一节我们介绍了Screen工具的基本概念。在深入其功能之前,让我们先看看在远程开发平台上可能遇到的典型问题。

假设我们使用虚拟机作为开发平台,所有工作都需要通过SSH连接到虚拟机。我们通常从一个窗口开始,通过SSH连接。

  • 我们开始编码。
  • 假设我们需要查阅cuid的手册页,但又不想离开代码编辑器,于是我们打开一个新终端窗口,再次SSH连接,并打开手册页。
  • 接着,我们可能还需要查看日志。于是,又得打开一个新终端,SSH连接,运行命令。
  • 然后,我们可能想一边运行程序,一边观察其他窗口。于是再打开一个终端,SSH连接,编译并运行程序。

很快,你会在多个窗口之间来回切换,屏幕变得杂乱无章。更糟糕的是,如果网络连接意外中断,或者你不小心关闭了终端窗口,所有未保存的工作和会话状态都会丢失,你必须从头开始重新建立所有连接和窗口布局。

解决方案:使用GNU Screen

那么,如何改善这种情况呢?让我们安装并使用GNU Screen程序。

Screen是一个全屏窗口管理器,可以复用多个虚拟终端。启动Screen后,你会获得一个Shell,然后可以通过键盘快捷键创建额外的窗口并在它们之间切换。

以下是Screen的核心操作:

  • 启动Screen:在终端中输入 screen 命令。
  • 创建新窗口:按下 Ctrl + A,然后按 c
  • 在窗口间切换
    • 按编号切换:Ctrl + A 后按 012...
    • 列出所有窗口:Ctrl + A 后按 "(引号),然后使用 jk 键选择。
  • 重命名窗口Ctrl + A 后按 Shift + A,然后输入新标题。
  • 分离会话Ctrl + A 后按 d。这会使Screen在后台运行。
  • 重新连接会话:使用 screen -r 命令。

核心优势:会话持久化与窗口管理

现在,让我们看看Screen如何解决之前提到的问题。假设你在Screen中进行重要工作时网络突然中断。

因为所有工作都在Screen会话内进行,所以即使断开连接,你的所有窗口和工作状态都会被保存。当你重新SSH登录系统后,只需执行 screen -r 命令,就能重新连接到之前的会话,一切都会恢复到中断前的状态,没有任何工作丢失。

此外,Screen还支持分屏功能,让你能更高效地组织工作空间:

  • 垂直分屏Ctrl + A 后按 |
  • 水平分屏Ctrl + A 后按 Shift + S
  • 在分屏区域间切换Ctrl + A 后按 Tab
  • 关闭当前分屏区域Ctrl + A 后按 X(注意:这仅关闭区域视图,对应的窗口依然存在)。

通过这些功能,你可以将终端窗口组织得像一个集成开发环境(IDE)一样,同时管理代码编辑、日志查看和命令执行。

更多可能性

当然,Screen的功能远不止于此。本次工具提示仅为你提供了一个快速入门,旨在激发你的兴趣。

除了上述功能,你还可以使用Screen:

  • 与多个用户共享一个Screen会话,允许他们观察甚至操作你的终端。
  • 在窗口之间复制粘贴终端输出。
  • 记录会话日志。

互联网上有大量关于Screen的详细教程。学习并使用它,这些快捷键很快就会成为你的肌肉记忆。不久之后,你可能会感叹:“没有它的时候我是怎么工作的?”

总结

本节课我们一起学习了GNU Screen终端复用器。我们了解了它在管理多个远程终端窗口、防止网络中断导致工作丢失方面的巨大优势,并学习了创建窗口、切换、重命名、分屏以及分离和重连会话等基本操作。希望这个工具能成为你提高工作效率的得力助手。

016:Week 04, Segment 1 - Unix文件系统 📂

在本节课中,我们将深入学习Unix文件系统和目录的核心概念,这些内容主要基于W. Richard Stevens的《APUE》教材第4章和第6章。我们将通过可视化数据结构来理解文件系统的组织方式、硬链接、目录结构及其相关操作。


磁盘与分区 💽

上一节我们回顾了文件系统的基本属性,本节中我们来看看文件系统在物理存储上是如何组织的。

首先,考虑一个硬盘。硬盘可以被划分为多个分区。分区有不同的类型,例如BIOS分区表或操作系统特定的分区。在我们的参考虚拟机(运行NetBSD)上,可以使用disklabel命令查看分区表。

disklabel sd0

命令输出会描述物理(或虚拟)磁盘的信息,包括物理块大小(通常是512字节)以及各个分区的起始和结束扇区。例如,系统可能包含一个根分区(如a分区)用于整个文件系统,以及一个较小的交换分区(如b分区)。


文件系统结构 🏗️

创建分区表后,我们可以在每个分区上创建文件系统(交换分区除外,它直接使用原始磁盘空间进行内存交换)。

文件系统将存储空间组织成柱面组,并通过一个称为超级块的结构来管理这些组。超级块包含了整个文件系统的关键信息。

struct superblock {
    // ... 文件系统元数据,如大小、块数、inode数等
};

由于超级块至关重要,文件系统会在多个柱面组中复制它,以便在原始超级块损坏时进行恢复。

每个柱面组包含:

  • 数据块:用于存储文件的实际字节内容。
  • inode表:用于存储文件元数据(即struct stat中的信息)的结构列表。
  • 用于inode和数据块的位图:用于跟踪块的使用情况。

文件的实际数据(文件内容)和其元数据(inode信息)是分开存储的。


Inode与硬链接 🔗

让我们回顾一个关键概念:文件名并不存储在文件的inode中。文件名是作为目录项存储的。

一个将文件名映射到inode的目录项被称为硬链接。我们可以这样理解:文件名本身就是指向inode的一个硬链接。因此,同一个inode可以有多个来自不同目录(或同一目录下的不同文件名)的硬链接。

例如,inode 123可能同时被以下链接指向:

  • 目录/home/user中的文件data.txt
  • 目录/var/backup中的文件data_backup.txt

这两个路径指向磁盘上完全相同的数据块。


目录详解 📁

目录在文件系统中是一种特殊类型的文件,其内容是一系列目录项,每个项将一个名字映射到一个inode编号。

以下是目录的核心组成部分:

  • . (点):指向目录自身的硬链接。
  • .. (点点):指向父目录的硬链接。
  • 其他条目:目录中包含的文件和子目录的硬链接。

每个目录至少有两个硬链接:一个是自身的.,另一个是其在父目录中的条目名。因此,目录的链接数(st_nlink)至少为2。

st_dev(设备号)和st_ino(inode号)组合在一起,才能在全系统范围内唯一标识一个文件,因为不同文件系统(分区)中可能存在相同的inode编号。


文件操作与硬链接 ⚙️

理解了上述结构后,我们就能明白一些文件操作的底层原理:

  • 移动(重命名)文件:在同一个文件系统内移动文件是一个非常快速的操作,因为它不涉及复制数据。实际上,它只是:
    1. 在目标目录创建一个新的硬链接(目录项)。
    2. 从源目录删除旧的硬链接。
      整个过程无需触碰文件的实际数据块。
  • 跨文件系统移动文件:这需要真正复制文件数据到目标文件系统,然后删除源文件,因此速度较慢。
  • 删除文件:当删除一个文件(即移除其最后一个硬链接)且没有进程打开它时,其inode和数据块才会被标记为可重用。


实践与思考 💡

本节我们一起学习了Unix文件系统的逻辑结构和硬链接机制。建议你在终端中实践以下命令,观察inode号和链接计数的变化:

mkdir test_dir
cd test_dir
touch file1
ls -li
ln file1 file2  # 创建硬链接
ls -li

同时思考一些边界情况,例如:文件系统根目录/的父目录是什么?它的...条目指向哪里?

在下一节视频中,我们将探讨用于创建、删除、重命名目录和链接(包括符号链接)的系统调用,并通过更多实例加深理解。

本节课中,我们一起学习了:

  1. 磁盘分区与文件系统的物理布局。
  2. Inode、数据块和超级块的作用。
  3. 硬链接的本质及其与目录的关系。
  4. 目录中...条目的含义。
  5. 基于硬链接机制的文件移动和删除原理。

这些知识是理解更高级文件操作和系统编程的基础。

017:硬链接与符号链接

在本节课中,我们将学习用于创建、删除和重命名硬链接与符号链接的系统调用。上一节我们通过图示了解了文件系统如何处理目录和链接,本节我们将具体探讨相关的系统调用。

创建硬链接

要创建一个新的硬链接,可以使用 link 系统调用。

  • link 系统调用接收两个路径名作为参数,而不是文件描述符。这与我们在第二周讨论的其他 I/O 系统调用有所不同。
  • 这很合理,因为硬链接是文件名与 inode 号之间的映射。更明确地说,硬链接是一个目录项,因此独立于文件描述符。
  • 其次,为了创建硬链接,源文件必须存在。
  • link 调用完成后,会增加目标文件的链接计数。

与我们已经见过的其他系统调用类似,我们也有 linkat 变体,用于原子地处理当前工作目录之外的相对路径名。

以下是关于硬链接的重要限制:

  • 在 UNIX 文件系统上,创建新链接会将文件名映射到 inode 号。inode 号是文件系统特定的,因此不能创建指向另一个文件系统的硬链接。POSIX 标准允许这样做,但大多数 UNIX 系统并未实现跨文件系统的硬链接。
  • 其次,不允许创建指向目录的硬链接,除非有效用户 ID 为 0(即 root 用户)。原因是多个硬链接在遍历文件系统时无法区分,指向目录的硬链接可能导致文件系统层次结构出现循环,从而引发文件系统损坏。

以上两点限制也是发明符号链接的部分原因,符号链接的行为与硬链接有很大不同。

删除硬链接

删除硬链接通过 unlink 系统调用完成。

  • 正如 link 会增加链接计数,unlink 会减少链接计数。
  • 链接计数帮助系统确定何时可以释放文件的数据块。
  • 由于文件系统上可以有多个名称指向同一个 inode,在确定数据块不再需要之前,系统不能覆盖它们。
  • 这通过检查链接计数是否为 0 来实现。但仅此还不够。一个进程 P 可能已经打开了该文件,之后该目录项被此进程或其他进程删除。此时文件的链接计数为 0,但如果允许覆盖数据块,进程 P 已打开的文件描述符上的 I/O 操作可能会不一致。
  • 因此,系统只有在链接计数为 0 没有进程持有此文件的打开文件句柄时,才会释放数据块。

实践演示

以下程序演示了在添加和删除硬链接时,可用磁盘空间的变化。

// 示例代码:展示创建文件、硬链接、打开后unlink对磁盘空间的影响
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/apue/img/a24215a83433c1c08ea4e2f8d6cf06a4_5.png)

int main() {
    // 1. 显示当前可用空间
    system("df .");

    // 2. 创建一个 500MB 的文件
    system("dd if=/dev/zero of=foo bs=1M count=500 2>/dev/null");
    system("df .");

    // 3. 创建第二个硬链接
    system("ln foo bar");
    system("ls -li foo bar");
    system("df .");

    // 4. 打开文件并立即 unlink
    int fd = open("foo", O_RDONLY);
    unlink("foo");
    system("ls -li foo bar 2>/dev/null || echo 'foo not found'");
    system("df .");

    // 5. 删除第二个链接
    unlink("bar");
    system("ls -li foo bar 2>/dev/null || echo 'foo and bar not found'");
    system("df .");

    // 6. 关闭仍打开的文件描述符
    close(fd);
    system("df .");

    return 0;
}

程序运行逻辑如下:

  1. 初始显示可用磁盘空间。
  2. 创建 500MB 文件 foo,磁盘空间减少。
  3. 创建硬链接 bar 指向 fools -li 显示两者 inode 号和链接计数相同(为2)。磁盘空间未变化。
  4. 打开 foo 文件,然后立即 unlink("foo")。此时,foo 的目录项被删除,链接计数减为1,但我们仍持有该文件的打开描述符。磁盘空间仍未释放。
  5. 删除第二个链接 bar。链接计数变为 0,但因为我们仍持有打开的描述符,数据块仍未释放。
  6. 关闭文件描述符。此时,链接计数为 0 且无打开句柄,系统释放数据块,磁盘空间恢复。

这个演示说明,我们通常所说的“删除文件”,实际上只是删除了指向该文件的目录项。数据块只有在没有链接且没有进程使用时才会被真正释放。这也是为什么即使误删了文件,数据通常仍能从磁盘恢复。

重命名文件与目录

在同一个文件系统内重命名文件,使用 rename 系统调用,其行为符合直觉:

  • 如果 from 是一个文件,to 不存在,则简单地创建一个新链接 to,然后取消链接 from(这些操作是原子性的)。
  • 如果 to 存在且是一个文件,则先取消链接 to,然后将 from 链接到 to,最后取消链接 from
  • 如果试图将文件重命名为一个目录,会出错。mv 命令将文件移动到目录下的行为是程序为满足用户期望而提供的便利,并非 rename 系统调用的功能。
  • 显然,我们需要对进行更改的目录拥有写权限。

重命名目录的行为大致相同:

  • 可以将目录重命名为另一个目录。如果目标目录非空,则出错。
  • 将目录重命名为文件没有意义,会出错。
  • 同样需要受影响目录的写权限。
  • 对于目录,还有一个边界条件:不能将目录重命名为其自身的前缀(例如,不能将 /a/b 重命名为 /a/b/c)。

rename 也不能跨文件系统工作。

符号链接

你已经看到了硬链接的一些限制:不能跨文件系统链接,不能创建指向目录的链接。有时这些功能很有用,因此我们有了 symlink 系统调用。

symlink 创建一个符号链接,这是一个特殊文件,其内容仅包含另一个文件的路径名。操作系统在遇到符号链接时会说:“嘿,别看我,看那个家伙。”

符号链接的优点在于:

  • 可以创建指向任何类型文件的符号链接,包括不存在的文件或目录。
  • 当你想获取符号链接本身的信息(而不是它引用的目标)时,需要使用 l 系列函数,如 lstatlchownlchmod 等。

符号链接示例

以下是一个最简单的 ln -s 命令实现:

// 示例:创建符号链接
#include <unistd.h>

int main(int argc, char *argv[]) {
    if (argc != 3) {
        // 错误处理
        return 1;
    }
    symlink(argv[1], argv[2]); // argv[1]是目标,argv[2]是链接名
    return 0;
}

使用符号链接时:

  • 可以创建指向不存在文件的符号链接,操作成功。但操作该链接文件会提示目标不存在。
  • 一旦创建了符号链接的目标文件,操作链接文件就会成功。
  • 符号链接可以指向另一个符号链接,操作时会递归解引用。
  • 这可能导致创建符号链接的循环链(A->B->C->A)。幸运的是,文件系统会检测到这种情况并报错,而不是陷入无限解引用循环。
  • 可以创建指向子目录的符号链接,但这可能导致路径遍历时出现奇怪现象(例如,.. 指向可能不符合预期)。
  • 与硬链接不同,可以创建跨文件系统的符号链接,因为符号链接只包含一个路径名字符串。

读取符号链接内容

对符号链接的操作通常会解引用到目标。但像 ls -l 这样的命令如何知道链接指向哪里呢?调用 open 会打开目标文件,没有 lopen 调用。

为此,有一个特殊的系统调用 readlink,用于获取类型为符号链接的文件内容(即它指向的路径)。

ssize_t readlink(const char *restrict pathname, char *restrict buf, size_t bufsize);

你需要处理适当大小的缓冲区,并告诉系统填充缓冲区。注意,此缓冲区的内容不是空字符终止的,这意味着你必须自己确保正确处理字符串。如果你想实现类似 ls -l 显示符号链接目标的功能,就需要使用 readlink

总结

本节课我们一起学习了硬链接与符号链接的系统调用。

  • 我们了解了如何使用 linkunlink 创建和删除硬链接,并理解了链接计数在决定何时释放数据块中的关键作用。
  • 我们探讨了 rename 系统调用如何在文件系统内高效地重命名文件和目录。
  • 我们学习了符号链接(symlink)如何克服硬链接的限制,可以指向任意目标,包括跨文件系统的目标。
  • 我们还了解了如何用 readlink 读取符号链接本身的内容。

回顾这几节的内容,你现在应该能够实现更多常见的 UNIX 命令,特别是 lnmvrm。记住,在文件系统内移动文件是快速且独立于数据块的,而跨文件系统边界则需要复制数据。尝试自己动手,更创造性地使用符号链接,观察可能引发的意外行为或输出。

在下一个视频片段中,我们将更深入地探讨目录的结构,以及如何创建和删除目录。

018:目录操作 📁

在本节课中,我们将学习如何在UNIX环境中创建、删除和读取目录。我们还将探讨进程的当前工作目录概念,以及相关系统调用的使用和注意事项。

上一节我们介绍了创建硬链接和符号链接。我们了解到,文件名本质上是目录中的条目。本节中,我们来看看如何创建目录。

创建目录

创建目录非常简单。只需调用 mkdir 系统调用。

int mkdir(const char *pathname, mode_t mode);

mkdir 会创建一个新的空目录,其中仅包含必要的条目 ...。新目录的权限由 mode 参数指定,但会受进程的当前 umask 值修改,这与创建新文件时的规则相同。目录的所有权遵循与之前讨论的文件所有权相同的语义,具体取决于所使用的UNIX版本。

删除目录

删除目录也不复杂,调用 rmdir 即可。

int rmdir(const char *pathname);

rmdir 会删除给定的目录,前提是该目录是空的(即只包含 ...)。如果目录为空,它将被删除,并且其父目录的链接计数会减1。如果此调用后链接计数变为零,并且没有其他进程打开此目录,则该目录将被移除。

关于目录,还存在进程将其作为当前工作目录打开的情况。删除这样的目录会导致一些令人困惑的情况。

读取目录

读取目录的操作应该从我们第一讲中简单的 ls 克隆程序里看起来很熟悉。

首先,我们调用 opendir 来打开目录的一个句柄。然后,通过反复调用 readdir 来遍历目录中的条目。最后,操作完成后调用 closedir

DIR *opendir(const char *name);
struct dirent *readdir(DIR *dirp);
int closedir(DIR *dirp);

需要注意的是,我们从目录获取条目的顺序对我们来说是不透明的。条目不以任何方式排序,或者即使排序,也是文件系统实现相关的、我们不能依赖的方式。

打开目录并列出其内容需要对该目录拥有读权限。然而,访问目录内的任何文件,正如上一节讨论的,需要对该目录拥有执行(或搜索)权限。

读取目录应始终使用 readdirgetdents,因为目录条目的实现是文件系统相关的。相关结构记录在 dirent 手册页中。

当你打开目录时,会得到一个 DIR 句柄,它将目录表示为一个流,意味着条目以有序的方式返回给你。但你不能假设这个顺序是可预测的,例如按字母顺序或按目录条目创建时间。目录内部的顺序对你是透明的。

虽然你可以通过一系列 opendirreaddirclosedir 调用来执行文件系统层次结构的遍历,但这很快就会变得非常复杂。相反,建议你查看 FTS 库函数,特别是对于你的 ls 期中项目。FTS 库函数底层确实调用了 opendir 等系统调用,但提供了许多额外的便利。这些文件层次遍历函数不能保证在所有UNIX版本中都可用,但幸运的是,它们在你们的目标平台上可用。

目录权限边界情况

以下是处理目录时的一些权限边界情况。

假设目录中有一个文件。如果我们从目录中移除执行权限,我们仍然能够列出其内容(即打开目录并调用 readdir 可以在没有执行权限的情况下工作)。然而,访问目录内的文件会失败,因为我们没有权限执行或搜索该目录。

如果我们更改权限以允许执行但移除读权限,那么尽管无法读取目录,我们仍然可以访问目录内的文件。但列出目录内容会失败。可能出乎意料的是,删除目录也会失败,因为我们无法打开它来查看其中是否有文件。

如果我们翻转权限,我们可以打开目录,看到里面有一个文件,但我们无法删除那个文件,因为我们不能执行该目录。由于无法删除文件,目录就不会为空,因此我们无法删除它。换句话说,要递归删除目录,我们需要同时拥有读和执行权限。

当前工作目录

我们已经看到,在进入一个目录后,我们可能拥有该目录的打开文件句柄,这引入了当前工作目录的概念,我们在学期早些时候提到过每个进程都有一个当前工作目录。

要获取当前工作目录,可以调用 getcwd。例如,pwd 命令以及大多数shell中的特殊变量都是这样做的。

我们刚刚也看到了如何通过 cd 命令更改当前工作目录,那么这是如何工作的呢?

要更改当前进程的当前工作目录,可以调用 chdir。如前所述,你需要对目标目录拥有执行权限,否则无法更改到该目录。

需要注意的是,当前工作目录是按进程设置的。也就是说,如果我们回想第一讲中的简单shell,当我们在shell中执行命令时,通常会 fork 一个新进程,然后执行二进制文件并返回。但这对于像 cd 这样的命令有一些有趣的影响。

让我们尝试实现一个 cd 程序。这并不特别困难。但当我们运行它时,会发现虽然程序内部成功调用了 chdir,但父进程(shell)的当前工作目录并没有改变。这是因为 chdir 只能影响当前进程,而不能影响父进程。

那么为什么shell内置的 cd 命令能工作呢?实际上,并没有一个独立的 cd 可执行文件。相反,cd 命令是内置在shell中的,这意味着shell不会为了运行它而 fork 一个新进程,它是在当前shell进程内调用的。事实上,cd 必须是shell内置命令才能正常工作。

但事情变得有点奇怪。在macOS上,确实存在一个 /usr/bin/cd 命令,但尝试使用它同样无法改变父shell的目录。那么,这个程序有什么用呢?事实证明,POSIX标准要求存在一个名为 cd 的独立实用程序。所以,如果你想成为一个符合POSIX标准的UNIX系统,你必须提供这个命令,即使它不起作用,因此完全没用。你仍然必须在你的shell中提供一个 cd 内置命令,以便实际更改当前工作目录,但同时你也会附带一个无用的实用程序来遵守一个无意义的标准。

总结

本节课中我们一起学习了:

  • 创建目录:调用 mkdir
  • 删除目录:调用 rmdir
  • 遍历文件系统层次结构:可以使用 opendirreaddir,但对于期中项目,应使用 FTS 库。
  • 当前工作目录特定于当前进程,更改当前工作目录只能在同一个进程内生效,这就是为什么 cd 必须始终是shell内置命令,而不能是一个标准的可执行文件,即使你的操作系统可能附带一个。

在下一节中,我们将看看目录的大小,你会发现它们可能比你最初想象的要复杂一些。

019:目录大小 📁

在本节课中,我们将要学习目录在文件系统中的大小是如何确定的。我们将探讨目录大小与其中文件数量、文件名长度的关系,并深入了解目录在磁盘上的内部组织结构。

上一节我们介绍了硬链接和文件大小(st_size)的概念。本节中我们来看看这些概念如何应用于目录。

不同类型文件的大小 📊

首先,回顾一下不同类型文件的“大小”含义。

  • 管道(FIFO)和套接字(Socket):文件大小始终为 0。它们仅用作进程间通信的汇合点,内核在进程间传递数据,这些数据从不写入磁盘。
  • 普通文件(Regular File):大小就是其包含的字节数。例如,包含三个星号和一个回车符的文件大小为 4 字节。
  • 符号链接(Symbolic Link):其“内容”是目标文件的路径字符串。例如,指向 /wherever 的符号链接大小为 9 字节(不包含结尾的空字符)。
  • 设备文件(Device)ls -l 命令不显示大小字段,而是显示设备的主次设备号。

目录的初始大小 🔍

现在让我们关注目录。为了观察,我们使用一个新建的空文件系统。

在一个空文件系统上创建一个新目录后,ls -ld 显示其大小为 512 字节。这个目录并非真正“空”,它包含两个默认条目:.(当前目录)和 ..(父目录)。

有趣的是,... 可能拥有相同的 inode 编号。例如,在新挂载的文件系统根目录中,... 都指向该文件系统的根 inode(编号为2)。这说明了为什么不能跨文件系统创建硬链接,也说明一个文件的唯一标识是 设备号inode 编号 的组合。

注意:在整个系统的根目录(/)下,... 也指向同一个 inode,因为根目录没有父目录。

目录大小如何变化? 📈

一个“空”目录(仅含 ...)占用 512 字节。那么添加新文件时,目录大小会增加吗?

以下是我们的实验观察:

  1. 添加文件:向目录中添加多个文件(即使是大小为 3.1 MB 的大文件),目录大小可能保持不变(仍为 512 字节)。
  2. 突破阈值:当文件数量增加到某个点(例如第 43 个文件),目录大小会突然增加到 1024 字节。继续添加,会在 84 个文件时再次增加到 1536 字节。
  3. 删除文件:删除文件后,目录大小不会缩小。即使删除所有文件,目录大小也保持不变。
  4. 文件名长度的影响:创建长文件名(如 255 字符)的文件后,目录大小会增长。这表明目录条目是变长的,取决于文件名长度。

总结初步发现:

  • 目录可以增长以容纳更多文件,但删除文件时不会缩小。
  • 目录大小与其中文件的数据大小无关
  • 目录条目大小与文件名长度有关

目录的内部结构 🧱

为了理解上述行为,我们需要查看目录在磁盘上的实际结构。我们可以使用 hexdump 工具(需注意,直接读取目录文件是文件系统相关的,并非所有系统都允许)。

以下是一个目录内容的十六进制示例片段(已简化):

# 假设的目录条目结构(单位:字节)
# +------------+-----------------+------------+-------------+----------+
# | inode (4)  | 记录长度 (2)    | 文件名长度(1)| 文件类型(1) | 文件名...|
# +------------+-----------------+------------+-------------+----------+

通过分析 hexdump 输出,我们可以解析出:

  • 每个目录条目包含:inode 编号(4字节)、本条目总长度(2字节)、文件名长度(1字节)、文件类型(1字节),然后是实际的文件名字符串。
  • 条目总长度总是向上对齐到 4 字节边界。
  • 最后一个条目的“记录长度”会覆盖到目录块的末尾,标记剩余空间。

目录条目的管理机制 🔄

基于此结构,目录的管理机制变得清晰:

  • 查找条目:通过“记录长度”字段定位下一个条目的起始位置。
  • 删除条目:删除一个条目时,并不擦除磁盘数据。而是增加前一个条目的“记录长度”,使其“跳过”被删除的条目。这就是目录大小不缩小的原因。
  • 添加条目:添加新文件时,系统会寻找第一个有足够空间容纳新条目的“空隙”(即被前一个条目的“记录长度”覆盖的已删除条目区域)。如果找到,就在那里写入新条目,并调整前后条目的长度。如果找不到足够大的空隙,就在目录末尾添加,并可能触发目录大小的增长。
  • 重用 inode:新创建的文件可能重用之前已删除文件的 inode 编号。这提醒我们,inode 编号本身并不永久代表一个特定文件。

不同文件系统的差异 ⚠️

需要强调的是,目录的具体实现是文件系统相关的。本文描述的行为基于类似 UNIX FFS(快速文件系统)的传统文件系统。其他文件系统(如 Linux 的 ext4、Windows 的 NTFS、macOS 的 APFS 或内存文件系统 tmpfs)可能有不同的行为,例如:

  • 使用不同的数据结构(如 B 树)来高效管理大量文件。
  • 可能支持目录收缩。
  • 文件名长度限制和条目格式可能不同。

鼓励你在自己可访问的系统上运行类似的实验,观察 ls -l 输出的链接数(link count)和大小(size)如何随文件增删和文件名长度变化。

总结 📝

本节课中我们一起学习了目录大小的奥秘。关键点如下:

  1. 目录大小独立于文件内容:只记录文件名到 inode 的映射。
  2. 目录大小与文件名长度相关:条目是变长的。
  3. 目录增长但不收缩:这是由其内部管理已删除条目空间的机制决定的。
  4. 实现依赖文件系统:不同文件系统管理目录的方式可能不同。

理解目录的内部工作原理,有助于我们理解文件系统性能(如为什么包含巨量文件的目录操作可能变慢)和一些系统行为。

下一节,我们将讨论与系统数据库(如密码文件)相关的几个系统函数,以及文件时间、系统标识等内容。

020:用户数据库 /etc/passwd 🔍

概述

在本节课中,我们将要学习UNIX系统中用户账户的核心数据库——/etc/passwd文件。我们将了解其结构、每个字段的含义,并通过实例探讨一些特殊和“奇怪”的配置可能带来的影响。


用户ID与用户名映射

上一节我们介绍了与账户相关的不同用户标识符(UID)。本节中我们来看看一个不可避免的问题:用户名和UID是如何关联起来的?

我们知道,UNIX系统使用数字UID来进行所有访问决策,因为计算机喜欢数字。但人类通常更喜欢字符串,所以我们也有用户名。密码数据库负责将这些用户名映射到用户ID,从而定义了系统上的本地账户。


/etc/passwd 文件结构

传统上,这个用户数据库位于文件 /etc/passwd 中,它包含以下字段。这些字段映射到一个名为 struct passwd 的数据结构,该结构由执行查找的函数使用。

以下是 /etc/passwd 文件中各字段的说明:

  1. 用户名:一个简单的字符串。
  2. 加密密码:一个哈希密码。不过我们稍后会看到,如今这些数据通常存放在一个单独的文件中。
  3. 数字用户ID(UID)
  4. 数字组ID(GID)
  5. GECOS字段:存储为 pw_gecos。GECOS这个名字源于“通用综合操作系统”(General Comprehensive Operating System),这是UNIX早期为了与GEOS机器兼容而留下的遗迹。
  6. 初始工作目录
  7. 初始shell

用户数据库遵循经典的UNIX传统,是一个位于 /etc/passwd 下的纯文本文件。它包含以换行符分隔的记录,每条记录中的字段由冒号分隔。这意味着任何字段都不能包含冒号。

一个典型的 /etc/passwd 文件会包含以下账户的条目:超级用户 root、一些专门用于权限分离的服务账户,以及一些人类用户账户。


字段详解与特殊案例

现在,让我们仔细查看这个密码数据库。我们会发现一些需要深入理解的特殊情况。

首先,这个文件是定义系统本地账户的用户数据库。因此,我们通常期望一个用户名严格对应一个UID。但在文件开头,我们看到有两个账户拥有相同的UID(0),其中一个是众所周知的 root。这并非错误。拥有多个相同UID的账户虽然很少见,但系统是允许的。其实际意义是,有两个用户名在认证后都会拥有有效UID和真实UID 0。一旦登录,这两个账户在系统看来是完全无法区分的,因为请记住,系统只根据UID而非用户名来做访问决策。我们稍后会看到为什么可能需要两个相同ID的账户。

让我们再看几个其他特殊情况。

我们提到密码字段是加密密码的占位符。但这个字段也可以是空的,这意味着该账户没有密码,任何人都可以以此用户身份登录。这可能不是你想要的,但系统允许这样做,因为系统无权规定你授予谁访问权限。

其次,登录shell可以设置为任何程序。由于我们有许多仅用于权限分离的服务账户(即让进程拥有专用UID而不具备其他特权),但我们从不希望允许交互式登录,我们可以将登录shell设置为 /sbin/nologin/sbin/nologin 在执行时直接返回失败,这意味着成功以此用户身份登录的用户会立即被登出。但你也可以将此shell设置为任何程序。

此外,你可以将登录shell留空,如示例所示。在这种情况下,系统会默认使用 /bin/sh。你的初始工作目录通常设置为你的家目录,但也可以留空,此时初始当前工作目录会变成根目录 /

GECOS字段允许使用 & 符号扩展为大写的用户名(我们稍后会看到实例),也可以提供如前所述的附加信息。当然,如果你愿意,也可以将此字段留空。

多个相同组ID仅意味着这些用户属于同一个主组,这很正常。但为同一个用户名设置多个条目则绝对不正常。我们稍后也会演示为什么这是个坏主意。


实例演示

现在,让我们通过实例说明在 /etc/passwd 中可能遇到的各种“奇怪”情况。

首先,说明拥有第二个与 root 相同UID的账户(例如 toor 账户,同样是超级用户)的用途。假设你正以 root 身份工作,不小心以某种方式损坏了你的登录shell。你遇到了问题,无法再登录了。怎么办?有了 toor 账户,你仍然可以登录,因为 toor 账户有一个不同的登录shell(例如救援工具集中的静态链接shell)。一旦你以 toor 身份登录,你就是 root。等等,这是如何工作的?还记得吗,系统只关心你的UID,而 toor 的UID是0。因此,就系统而言,你就是超级用户。所以你现在可以去修复损坏的shell,之后登出,再以 root 身份登录就会恢复正常了。请注意,toor 账户的使用是BSD系统的传统,大多数非BSD系统没有或未启用此账户。

接下来,我们看看用户 fredfred 没有密码哈希,意味着任何人都可以成为 fred 而无需提供密码。这可能不是你想要的,但确实可以。

然后,我们检查用户 doctor。记住,doctor 的登录shell是 date 命令。所以当我们以 doctor 身份登录时,该命令被执行,命令终止后,我们就被登出,变回平常的自己。这里我们看到 date 命令被执行,因为那是 doctor 的登录shell。

接着是用户 alicealice 没有指定登录shell,但我们仍然可以以她的身份登录。这里我们使用 login 命令仅仅是为了演示以用户身份登录的不同方法(这就是系统在你登录时所做的事情,我们将在未来的课程中讨论这个程序)。如前所述,alice 得到了 /bin/sh 作为她的shell,因为如果在 /etc/passwd 中没有指定shell,这就是默认值。

但请记住,关于 alice 还有一件奇怪的事情。除了没有shell,我们有两个 alice 账户,它们共享同一个家目录。现在来看看我们的文件。所有的 palace 文件。但 alice 不允许在她自己的家目录中创建文件。我们说过UNIX系统只关心数字ID。所以让我们看看这些ID。目录的所有者是UID 1002,但我们是UID 1004。这解释了为什么我们不能在这里创建文件。我们之所以能区分这两个教程账户,是因为我们看的是用户名,但系统只检查数字ID。因此,为同一个用户名设置两个账户可能是个坏主意。

最后,让我们看看GECOS字段的实际应用。finger 命令可以用来查找关于用户的信息。正如我们在这里看到的,root 的GECOS字段中的 & 符号被转换成了大写名称,所以我们得到了“Charlie Root”。为什么是Charlie Root?好问题,我找不到权威答案,但传闻这个账户确实是以棒球运动员Charlie Root命名的。UNIX的历史传说很奇特。让我们看看用户 jschauma 的信息。注意 finger 命令是如何能够从传统的逗号分隔值中解析出GECOS信息的,你在这里看到了我的办公室位置和电话号码。顺便说一下,finger 命令也可以通过网络查询另一台系统,前提是那台系统提供 fingerd 服务。


总结

本节课中我们一起学习了用户数据库 /etc/passwd。它是一个基于文本的文件,包含冒号分隔的字段。这些字段大多可以为空。一个空的密码字段意味着没有密码,这可能是个坏主意。一个空的家目录字段意味着你登录时会被放入 / 目录。一个空的shell字段意味着你会得到 /bin/sh。文件中的某些字段可能会重复,这并不总是错误。例如,多个用户共享同一个主组是完全正常的。多个用户名对应同一个UID通常很少见,但我们看到了它如何可能有用。然而,同一个用户名对应多个UID几乎肯定是个错误,会导致意想不到的问题。

下次课,我们将看看用于处理这些用户ID查找的各种函数,以及如何获取关于组的信息。

021:Week-04, Segment-6 - 用户与组信息查询 🔍

在本节课中,我们将要学习如何通过库函数查询系统中的用户和组信息。我们将重点介绍 getpwuidgetpwnam 等函数,并了解 /etc/passwd/etc/group 文件的结构与访问方式。


回顾密码数据库

上一节我们讨论了 /etc/passwd 文件及其包含的各种账户字段。然而,我们并未深入探讨用于查询这些信息的库函数。本节将弥补这一空白。

以下是用于查询用户账户信息的两个核心函数:

struct passwd *getpwuid(uid_t uid);
struct passwd *getpwnam(const char *name);

这两个函数在成功时都会返回一个 struct passwd 结构体指针,该结构体包含了 /etc/passwd 文件中的所有字段。需要注意的是,在某些系统(如BSD)上,此结构体可能包含额外字段,例如登录类别或账户过期时间。

那么,这些额外信息存储在哪里呢?


主密码文件

答案在于 /etc/master.passwd 文件。这个文件包含了 /etc/passwd 中的所有字段,以及额外的字段,最重要的是,它包含了真正的加密(哈希)密码。

/etc/passwd 文件中通常只显示一个星号 * 作为占位符,而实际的密码哈希值则存储在 /etc/master.passwd 中。这样做是因为 /etc/passwd 需要对所有用户可读,以便像 ls 这样的命令(通过调用 getpwuid)能够将文件所有者的UID转换为用户名,而无需超级用户权限。

/etc/passwd 文件本身是通过特定工具从 /etc/master.passwd 文件生成的。


函数行为与权限

当普通用户调用 getpwnam 时,返回的 struct passwd 中的密码字段是星号 *。只有当调用者是超级用户(UID 0)时,getpwnam 才会从 /etc/master.passwd 中返回真实的密码哈希值。

其他类Unix系统(如Linux)采用类似但不同的机制,它们使用 /etc/shadow 文件来存储密码哈希,并提供了 getspnam 等单独的函数来访问这些信息。


遍历所有用户条目

除了查询单个用户,有时我们需要遍历整个密码数据库。为此,我们使用以下函数:

struct passwd *getpwent(void);
void setpwent(void);
void endpwent(void);

getpwent 会顺序读取密码数据库(尽管顺序对用户不透明),setpwent 将流重置回开头,endpwent 则关闭流。

以下是一个简单的程序示例,展示了如何遍历所有条目或查询特定用户:

// 示例代码框架:遍历或查询用户
struct passwd *pwd;
// 遍历所有条目
setpwent();
while ((pwd = getpwent()) != NULL) {
    // 打印 pwd->pw_name, pwd->pw_uid 等信息
}
endpwent();

// 查询特定用户(通过UID或用户名)
pwd = getpwnam("username");
// 或
pwd = getpwuid(1000);

组数据库 /etc/group

现在,让我们来看看组数据库。与用户信息类似,组信息存储在 /etc/group 文件中。

该文件每行的格式通常为:
group_name:password_hash:group_id:user_list

其中 user_list 是一个逗号分隔的用户名列表,标识了该组的成员。

用于查询组信息的库函数与用户函数非常相似:

struct group *getgrgid(gid_t gid);
struct group *getgrnam(const char *name);
struct group *getgrent(void);


组密码与 newgrp 命令

你可能注意到 /etc/group 文件中也有一个密码哈希字段。这源于Unix的早期设计:当时用户一次只能属于一个主要组。如果用户需要访问属于另一个组的文件,他们需要使用 newgrp 命令并输入该组的密码来切换主要组。

虽然现代系统通过“附加组”机制解决了这个问题,使得 newgrp 命令很少使用,但了解其历史背景仍然很有意义。


获取用户的组列表

获取用户所属的所有组比获取用户信息更复杂一些。用户有一个主要组ID(存储在 /etc/passwd 中)和一系列附加组ID(存储在 /etc/group 中)。

POSIX提供了 getgroups 系统调用来获取附加组列表,但它不一定包含主要组ID。一个更全面的非标准函数是 getgrouplist,在使用前需要检查你的系统是否支持。

以下是一个实现 groups 命令功能的简化思路:

  1. 获取用户名或UID作为输入。
  2. 调用 getgrouplist 函数获取该用户所属的所有组ID列表。由于不知道用户属于多少组,可能需要动态分配内存。
  3. 对于列表中的每个组ID,调用 getgrgid 来获取组名并打印。


系统资源访问模式总结

本节课中我们一起学习了两个关键的系统数据库:/etc/passwd/etc/group,以及访问它们的库函数。这些函数遵循一个通用模式:对于一个存储在 /etc 下的纯文本资源,会提供相应的 getXbyY 类函数来将资源检索到数据结构中。

我们了解到:

  • 处理 /etc/passwd 数据相对直接。
  • 密码哈希被保存在一个只有UID 0能访问的单独文件中(如 /etc/master.passwd/etc/shadow)。
  • 组可以拥有密码,但这在现代系统中已不常见。
  • 遍历用户所属的组比遍历用户密码数据要复杂一些。

这种访问系统资源的一致模式,我们将在未来的课程中看到更多类似的例子。


在下一讲中,我们将尝试理解“时间”本身在Unix系统中的表示和处理。但正如道格拉斯·亚当斯所言,“时间是一种幻觉,午餐时间更是如此。”我们下节课再见。

022:文件时间戳详解 📁⏰

在本节课中,我们将深入学习UNIX系统中文件的时间戳概念。我们将探讨atimemtimectime这三个核心时间字段的含义、它们如何被系统操作所影响,以及相关的系统调用和实用命令。


概述

文件系统不仅存储数据,还记录关于文件的时间信息。理解这些时间戳对于系统管理、程序开发和性能分析至关重要。本节将详细解析atimemtimectime


核心时间戳概念

一个文件的inode结构中至少包含三个与时间相关的字段:

  • atime:表示文件的最后访问时间。当访问文件数据块时,此时间更新。
  • mtime:表示文件的最后修改时间。当文件数据内容被修改时,此时间更新。
  • ctime:表示文件的最后状态改变时间。当文件的inode元数据(如权限、所有者、链接数)发生变化时,此时间更新。

可以使用ls命令查看这些时间。


使用ls命令查看时间戳

默认情况下,ls -l显示的是文件的mtime

以下是使用不同选项查看时间戳的方法:

  • ls -lu:显示atime
  • ls -l:显示mtime(默认)。
  • ls -lc:显示ctime

例如,在归档文件后,所有文件的atime可能相同,因为归档过程需要读取文件。


常见操作对时间戳的影响

上一节我们介绍了如何查看时间戳,本节中我们来看看执行不同文件操作时,这些时间戳如何变化。以下是各种操作对时间戳的影响:

  • 创建新文件atimemtimectime(以及如果支持,birthtime)均被设置为当前时间。
  • 读取文件atime更新。
  • 向文件追加数据mtimectime更新(因为文件大小st_size改变)。
  • 创建硬链接ctime更新(因为链接数st_nlink改变),atimemtime不变。
  • 更改文件权限ctime更新(因为模式st_mode改变),atimemtime不变。
  • 更改文件所有者ctime更新,atimemtime不变。

使用touch命令可以主动更新这些时间戳。默认情况下,touch会更新文件的atimemtime到当前时间,同时也会触发ctime更新。


touch命令的权限与行为

touch命令的行为受到文件权限的影响:

  • 如果对文件有读权限,可以通过读取文件来更新atime
  • 如果对文件有写权限,可以通过写入文件来更新mtime
  • 如果要将时间戳设置为任意指定时间(而非当前时间),则必须是文件的所有者

ctime无法被直接设置。任何改变文件状态(包括用touch修改atimemtime)的操作都会自动将ctime更新为当前时间。

使用touch -a仅更新atime,使用touch -m仅更新mtime


关于atime的性能考量与挂载选项

每次读取文件都更新atime会导致频繁的磁盘I/O操作,这可能影响性能,尤其是对固态硬盘(SSD)的寿命有损。

因此,许多文件系统支持挂载选项来优化atime行为:

  • noatime:完全禁用atime更新。即使使用touch -a命令也无法更新atime
  • relatime (相对atime):这是许多Linux系统的默认行为。atime只在以下情况更新:
    1. mtimectime比当前的atime新。
    2. 当前的atime超过24小时未更新。
      这种方式在保证依赖atime的程序(如邮件客户端)正常运行的同时,大幅减少了I/O开销。

不同的UNIX变体和文件系统可能支持不同的选项。


相关的系统调用:utimes家族

touch命令的功能底层是通过utimes系列系统调用实现的。

其函数原型如下:

#include <sys/time.h>
int utimes(const char *filename, const struct timeval times[2]);
  • 第二个参数times是一个包含两个timeval结构(分别对应atimemtime)的数组。
  • 如果传入NULL,则将atimemtime设置为当前时间(需要写权限)。
  • 如果传入具体的时间值,则按此设置时间戳(需要文件所有权)。
  • 无论哪种情况,ctime都会被自动更新为当前时间。

此外,还有更精确的变体函数(如utimensat),它们使用timespec结构,支持纳秒级精度。


总结

本节课中我们一起学习了UNIX文件系统时间戳的核心知识:

  1. atime(访问时间):记录最后数据访问时间。出于性能考虑,可通过noatimerelatime挂载选项进行优化。
  2. mtime(修改时间):记录最后数据修改时间,最为常用。
  3. ctime(状态改变时间):记录文件元数据的任何改变。这是唯一一个不能由用户直接设置的时间戳。
  4. touch命令与utimes调用touch命令是用户层工具,其核心功能通过utimes()等系统调用实现。设置任意时间戳需要文件所有权。

理解这些时间戳的差异和更新机制,有助于你更好地进行文件管理、编写系统工具或分析程序行为。现在,你可以尝试实现一个简化版的touch命令,或者去研究开源系统(如NetBSD)中相关工具的源代码了。

关于时间这个“一团乱麻”的话题,我们将在后续视频中继续探讨。

023:时间(3)是一个幻觉

在本节课中,我们将深入探讨UNIX系统中的时间处理。我们将学习如何获取系统时间,理解不同时间数据结构的含义,并掌握如何在机器可读的时间戳和人类可读的日期字符串之间进行转换。同时,我们也会揭示时间处理中一些令人困惑的细节和陷阱。

上一节我们介绍了struct stat中的访问时间、修改时间和状态改变时间。本节中我们来看看如何从系统获取当前时间。

时间的来源与获取

UNIX内核负责追踪时间,它通过计算晶体振荡等方式来维护时间。系统将时间记录为自纪元(1970年1月1日午夜)以来经过的秒数。

你可以通过调用time函数来获取这个计数,它返回一个抽象数据类型time_t

time_t t = time(NULL);
printf("Seconds since epoch: %ld\n", (long)t);

time是一个库函数,那么它从哪里获取时间呢?查看源码可以发现,它内部调用了gettimeofday系统调用。

gettimeofday返回一个struct timeval结构,其中包含自纪元以来的秒数和微秒数。

struct timeval {
    time_t      tv_sec;     /* 秒 */
    suseconds_t tv_usec;    /* 微秒 */
};

struct timeval tv;
gettimeofday(&tv, NULL); // 第二个参数(时区)已被弃用,应设为NULL
printf("Seconds: %ld, Microseconds: %ld\n", (long)tv.tv_sec, (long)tv.tv_usec);

然而,gettimeofday的第二个参数(时区)已被忽略,且其精度只到微秒。为了获得更高精度(纳秒),POSIX标准推荐使用clock_gettime系统调用。

clock_gettime使用struct timespec结构,提供秒和纳秒的精度。

struct timespec {
    time_t   tv_sec;        /* 秒 */
    long     tv_nsec;       /* 纳秒 */
};

struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
printf("Seconds: %ld, Nanoseconds: %ld\n", (long)ts.tv_sec, ts.tv_nsec);

至此,我们知道了如何获取精确的时间戳:struct timespecstruct timevaltime_t,它们都以自纪元以来的秒数为单位,只是精度不同。

将时间戳转换为可读日期

人类不喜欢计算自1970年以来的秒数,我们需要将time_t转换为可读的日期。首先,我们需要使用gmtime库函数将time_t分解为struct tm结构。

gmtime函数将time_t转换为协调世界时

struct tm结构包含了年、月、日、时、分、秒等字段。但其中有一些“怪癖”:

  • tm_year字段表示的是自1900年以来的年数。
  • tm_mdaytm_mon等字段的计数通常从0开始(例如,一月是0)。
  • tm_sec字段的有效值可以是60,这涉及到闰秒

闰秒是为了协调国际原子时与地球自转时间而偶尔增加或减少的一秒。POSIX标准要求自纪元以来的秒数单调递增,并且实现不需要考虑闰秒,这导致在闰秒时刻进行时间转换会出现问题。

尽管有这些复杂性,我们仍然可以使用asctime函数将struct tm格式化为一个标准的日期字符串。

time_t now = time(NULL);
struct tm *tm_utc = gmtime(&now);
printf("UTC time: %s", asctime(tm_utc));

处理时区和夏令时

我们刚才看到的是UTC时间。为了显示本地时间,需要考虑时区偏移和夏令时。localtime函数可以完成这个任务。

localtimegmtime一样,将time_t分解为struct tm,但它会根据本地时区规则进行调整。

struct tm *tm_local = localtime(&now);
printf("Local time: %s", asctime(tm_local));

默认情况下,许多系统使用UTC。可以通过设置TZ环境变量来指定时区。

export TZ=America/New_York

时区和夏令时规则是软件工程师的噩梦。它们由政治边界、历史原因甚至南极科考站所属国决定,并且规则会频繁更改。这些信息存储在系统的时区数据库中,由IANA维护,需要定期更新。

自定义时间格式与反向转换

asctimectime生成的格式是固定的。为了生成自定义格式的日期字符串,可以使用strftime函数。

strftime允许你使用格式说明符(类似于printf)来格式化struct tm

char buf[BUFSIZ];
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S %Z", tm_local);
printf("Formatted: %s\n", buf);

我们也可以反向操作:从一个struct tm结构生成time_t。这通过mktime函数实现。

mktime将本地时间的struct tm转换回time_t。它会自动规范化字段(例如,将超出范围的分钟数转换为小时)。

struct tm some_time = {0};
some_time.tm_year = 121; // 2021年
some_time.tm_mon = 0;    // 一月
some_time.tm_mday = 1;
some_time.tm_hour = 0;
time_t t_epoch_2021 = mktime(&some_time);
printf("Epoch for 2021-01-01: %ld\n", (long)t_epoch_2021);

核心转换流程总结

本节课中我们一起学习了UNIX时间处理的核心流程,可以用下图概括:

  1. 获取时间:内核提供time_t(通过time,或更高精度的clock_gettime)。
  2. 分解时间:使用gmtime(得到UTC时间)或localtime(得到本地时间)将time_t转换为struct tm
  3. 格式化时间:使用asctimectime或更灵活的strftimestruct tm转换为人类可读的字符串。
  4. 反向构建:使用mktimestruct tm转换回time_t

时间在很大程度上是一种幻觉。计算机在表示时间时已经困难重重,而大部分问题源于人类对时间本身及其应有形态的不同理解。这意味着,编写任何处理时间的程序都可能隐藏着许多令人惊讶的“坑”。建议阅读更多关于“程序员对时间的错误认知”的资料,这既有趣又发人深省。本次讨论也提醒我们,所有编程都在往往不明显的方面受到地缘政治事件的影响。

今天就到这里。感谢观看。

024:Unix开发环境 🛠️

在本节课中,我们将学习Unix开发环境的核心概念。我们将了解Unix系统本身如何构成一个强大的集成开发环境,并探索其核心组件如何协同工作,以提高编程效率。

概述

在之前的课程中,我们已经编写了一些代码并尝试调试程序,对Unix开发有了初步了解。为了更高效地使用系统,我们需要掌握更多工具。软件开发不仅仅是向文件中写入代码,还需要一系列工具来辅助。本周,我们将介绍一系列能极大简化程序员工作的工具,这些知识不仅适用于Unix或C编程,在其他环境中也同样有用。

什么是集成开发环境?

一个优秀的集成开发环境通常至少包含一个编辑器,该编辑器通常支持编程语言的语法高亮、自动缩进和其他格式化功能。它还应提供一个显示调试输出的区域、一个用于加载和组织项目文件的文件浏览器,以及一个可以查阅文档的地方。所有这些功能都旨在提高编程效率。一些IDE还包含额外的功能,如自动代码补全。

例如,Eclipse是一个非常流行的IDE,尤其受Java程序员欢迎。然而,在典型的IDE中,用于编写代码的屏幕区域可能只占整个应用程序窗口的30%左右,这与Unix哲学中高效利用资源的原则并不完全一致。

Unix本身就是IDE 🖥️

Unix系统本身就是一个精心设计的集成开发环境。它遵循Unix哲学,将整个开发流程分解为多个独立、专注的工具。要利用这个环境,我们可以组合使用以下工具:

  • 编辑器:你可以选择任何你喜欢的编辑器,如viemacs
  • 编译器工具链:对于C语言,这包括预处理器、编译器、汇编器和链接器。
  • 调试器:用于调试和分析代码,例如gdb
  • 项目构建管理工具:如make,它可以根据文件变更情况,有条件地重新构建项目的特定部分。

除了这些核心工具,还有用于软件项目管理和协作的工具,例如基础的patchdiff工具,以及代码版本控制系统,如CVS、SVN,以及现在几乎无处不在的git。这些独立、小巧的工具共同构成了一个适用于任何编程语言的丰富开发环境,并且由于它们的模块化设计,你可以根据喜好替换其中的组件,实现高度定制。

工具协同工作示例

为了让你快速了解这些工具如何协同工作,让我们看一个简单的例子。

假设我们正在编辑一个C语言源文件。当我们完成部分代码后,希望编译程序,但不想离开编辑器窗口或点击任何按钮。这时,我们可以在编辑器中使用构建命令来调用make工具构建项目。

例如,执行一个简单的编译命令:

make myprogram

如果代码中存在错误,编译器会提供有帮助的错误信息,包括文件名、行号、列号以及错误描述。

修复错误后,我们可以直接返回源代码。优秀的编辑器(如配置得当的vi)甚至可以将光标直接定位到出错的行,并在下方显示错误输出。我们可以使用编辑器的内置快捷键(例如,在vi中按K键)调出光标下函数的联机帮助手册来确认其正确用法。查阅完毕后,我们会立刻回到代码编辑界面。

修复第一个错误后,我们可以直接跳转到下一个错误所在位置,继续进行修改。所有错误修复完成后,保存文件并再次运行make命令。如果编译成功,我们就可以运行生成的可执行文件了。

这个例子虽然简单,但展示了Unix开发环境中各工具流畅衔接的工作方式。在接下来的视频章节中,我们将更详细地探讨每个组件。

总结

本节课我们一起学习了Unix开发环境的核心思想。我们认识到,Unix系统通过一系列遵循“做一件事并做好”哲学的小型工具,本身就构成了一个强大、灵活且高效的集成开发环境。我们概述了编辑器、编译器工具链、调试器和构建工具等核心组件,并通过一个简单示例看到了它们如何协同工作。掌握这些工具将显著提升你的编程效率。下一节,我们将从编辑器开始,深入探讨每个组件。

025:编辑器 🖥️

在本节课中,我们将学习Unix编程环境作为集成开发环境(IDE)的一个核心组件——编辑器。你将了解到一个优秀编程编辑器应具备的核心功能,并通过实例演示如何高效地使用它。

概述

Unix用户环境实际上是一个集成开发环境。本周的视频将快速总结其各个组成部分。编辑器是你最重要的工具,你将花费大量时间在其中编写、阅读和调试代码。

编辑器的核心功能

每个程序员都有自己偏爱的编辑器,但选择哪个并不重要,关键在于熟练使用。以下是优秀编程编辑器应具备的核心功能:

  • 语法高亮:为你使用的不同编程语言提供色彩区分。
  • 高效的键盘操作:无需将手离开键盘即可完成工作,使用鼠标会降低效率并导致思维切换。
  • 标记与跳转:能够在代码中设置标记并快速跳转。
  • 多缓冲区操作:使用多个缓冲区来复制、剪切、折叠和操作代码块。
  • 高效的搜索与替换:快速定位和修改代码。
  • 多窗口显示:能够并排或以其他布局显示多个代码窗口。
  • 自动补全:为标准函数、常量甚至重复代码块提供补全建议。
  • 文档查询:轻松查找正在使用的库或API的文档。
  • 外部命令过滤:允许通过外部命令处理输入。

即使你已有惯用的编辑器,也应花时间学习其高级功能,而不是回避未知操作。

高效移动:无需图形界面

上一节我们介绍了编辑器的核心功能,本节中我们来看看如何在不依赖图形界面的情况下高效地在代码中移动。这包括使用 HJKL 键进行上下左右移动,这些键位于打字基准行,手无需离开。

Vim 使用 HJKL 作为方向键源于其前身 VIVI 的开发者 Bill Joy 使用的终端键盘将方向键设置在了 HJKL 的位置。

除了基本移动,我们还可以:

  • 按单词跳转。
  • 向前或向后搜索。
  • 跳转到行首或行尾。
  • 使用 Ctrl+DCtrl+B 进行向下和向上翻页。
  • 使用 zz 将当前行置于屏幕中央,zt 置于顶部,zb 置于底部。
  • 在代码块内移动,跳转到文件开头或结尾,或在多个文件间切换。

基本编辑任务

掌握了基本移动后,让我们来学习一些常见的编辑任务。为了在代码中任意位置高效跳转,我们可以设置标记以便返回。在移动代码时,高亮和选择代码块、自动缩进或格式化、以及将代码段删除或复制到临时缓冲区都非常有用。

处理大段代码时,折叠功能可以在不删除代码的情况下隐藏某些逻辑块或选定的行。编写代码时,我们可能希望编辑器提供自动补全或建议。

以下是使用同一段代码示例的快速演示:

  1. 显示行号:set number
  2. 设置标记:在当前位置按 m 键后跟一个字母(如 ma)来设置标记 a
  3. 选择与剪切代码块:按 Shift+V 进入可视行模式,选择行,然后按 d 剪切到默认缓冲区。
  4. 粘贴代码:移动到目标位置,按 p 粘贴缓冲区内容。
  5. 跳回标记:按 '(单引号)后跟标记字母(如 'a)跳回标记处。
  6. 撤销操作:按 u 撤销更改。
  7. 创建折叠:移动到代码块开始处,按 zf 后跟移动命令(如 zfap 折叠一个段落)或跳转到标记处(如 zf'a 折叠到标记 a 处)。
  8. 展开折叠:将光标移到折叠行上,按 za
  9. 自动补全:在插入模式下,输入部分内容后按 Ctrl+NCtrl+P 进行补全。
  10. 查询手册:将光标置于函数名上,按 Shift+K(默认查第1节)或 3 Shift+K(查第3节,即库函数)可查看手册页。

与开发工具集成

一个好的代码编辑器应能与你的其他开发工具集成。记住,Unix 本身就是一个 IDE。我们应该能够从编辑器内部运行编译器、调试器或构建软件。

以下是使用 make 功能及处理编译错误的示例:

  1. 运行构建:在命令模式下输入 :make。编辑器窗口会暂时消失,显示 make 命令在 shell 中的输出。
  2. 跳转到第一个错误:查看错误信息后,按回车键,Vim 会自动将你定位到第一个错误出现的文件和行。
  3. 查看完整错误信息:按 :cc 可以查看当前错误的完整信息。
  4. 打开快速修复列表:按 :copen 可以在底部打开一个窗口,持续显示所有错误列表。
  5. 在错误间导航:修复一个错误后,可以使用 :cnext 跳转到下一个错误。
  6. 跳转到定义:将光标置于标识符(如变量或函数名)上,按 Ctrl+] 可以尝试跳转到其定义处,按 Ctrl+T 返回。
  7. 关闭快速修复窗口:所有错误修复后,使用 :cclose 关闭错误列表窗口。

这个流程很好地展示了编辑器如何与环境集成,让我们高效地编写代码并修复编译器指出的错误。

总结

本节课中我们一起学习了编辑器作为 Unix IDE 核心工具的重要性。我们探讨了优秀编辑器应具备的功能,并通过实例演示了高效移动、常见编辑操作以及与编译器等开发工具的集成。当然,编辑器还有成千上万的其他功能,建议你寻找并跟随一份好的教程来深入学习你的首选编辑器。VIVim 只是一个例子,其他优秀编辑器也提供非常类似的功能。

最后,编辑器与其他开发工具(如 ctagsscreen 多路复用器)还有许多集成方式,你可以在相关视频中了解更多。在下一个视频中,我们将介绍默认的编译器工具链以及如何从 C 代码生成可执行文件。

026:编译器(第一部分)👨‍💻

在本节课中,我们将学习编译器工具链。我们将了解编译器是什么,它如何将高级编程语言(如C语言)的源代码转换为特定机器架构的可执行文件,并概述编译过程的主要步骤。

上一节我们介绍了编辑器的核心功能,本节中我们来看看编译器工具链本身。

什么是编译器?🤔

与解释型语言不同,当我们使用C等编译型语言编写代码时,需要编译器将代码翻译成针对特定目标机器架构的字节码,以创建特定二进制格式的可执行文件。

更具体地说,编译器将高级编程语言的源代码翻译成特定架构的机器码。不同的源语言可以使用不同的编译器,它们可以为不同的目标架构生成不同的代码。

编译器的任务通常被分解为一系列不同的步骤。

编译过程的主要步骤

以下是编译器将源代码转换为可执行文件所经历的主要阶段。

1. 预处理

许多编程语言允许通过宏定义、常量或包含其他文件的代码来使用快捷方式,这些语法在严格意义上可能不属于该编程语言的有效语法。例如,考虑这个简单的程序:

#include <stdio.h>
#define MESSAGE "Hello"
int main() {
    printf(MESSAGE);
    return 0;
}

在预处理阶段,编译器或独立的预处理器程序会展开宏,并从给定的头文件中引入代码。最重要的是,它会提供各种函数的前向声明。预处理器还会替换所有已定义的宏,无论它们是在输入文件中定义的还是从包含的文件中引入的,从而将左侧的打印语句转换为右侧的形式。

2. 词法分析

在此阶段,编译器解析给定的源代码,并将其分解为单独的标记(Token)。例如,像 if (num > 42) message = “yes”; 这样的语句可能被分析并分解为:语言特定的关键字、标记、变量标识符、运算符、数字、另一个标记、另一个变量标识符、另一个标记、字符串和最终的分号。这种词法分析也使得编辑器能够执行语法高亮。

3. 语法分析

在将输入分解为标记后,我们可以进行语法分析。即,根据编程语言的语法规则,检查我们得到的标记序列是否有意义。对于简单的语句,此阶段可能会捕获错误,例如忘记分号或括号未闭合。然后,编译器会构建一个表示该语句的抽象语法树。

4. 语义分析

在此阶段,编译器不仅检查基本语法是否正确,还检查我们所写的内容是否至少具有基本的逻辑意义。例如,假设我们写的是 if (message > 42) num = “yes”;,那么对此抽象语法树的语义分析会指出,虽然if语句的整体逻辑似乎正确,但尝试将字符串与数字进行比较在此处没有意义,尝试将字符串赋值给int类型的变量也没有意义。因此,编译器将抛出错误。

5. 代码生成与优化

如果一切检查无误,我们将进入代码生成阶段,通常还包括代码优化。编译器可以通过优化循环、减少诸如循环不变式等语句的求值次数或移除无效果的语句来提高代码效率。例如,消除冗余语句,使左侧的代码被编译器转换为右侧的代码。有些优化是机器无关的,但有些优化可能依赖于目标架构,这就是为什么代码优化通常在生成中间代码之后进行。

无论如何,在代码生成阶段,编译器将我们漂亮的C代码转换为一种中间格式,例如汇编代码。

6. 汇编与链接

然后,编译器将该中间代码汇编成目标平台的实际字节码,生成一个采用平台原生二进制格式(例如ELF)的目标文件。这一系列步骤针对所有输入文件执行。请记住,大多数软件项目不仅仅包含单个源文件。因此,我们到目前为止讨论的所有内容都会为每个输入文件重复。

我们最终会得到一组目标文件,这些文件需要与系统库链接在一起以创建最终的可执行文件。这通常涉及另一个工具——链接器的帮助,链接器由编译器在最后阶段调用,它知道在哪里查找某些库,例如C运行时库(称为libc)等。所有这些步骤最终使我们从输入程序得到可执行文件,并完成了编译器执行的一系列步骤。

编译器的组件与分类

观察所有这些步骤,我们可能会注意到它们属于不同的类别。例如,预处理、代码分析、抽象语法树的构建和语义一致性保证都必然与特定的编程语言相关。也就是说,这些步骤取决于所使用的编程语言,如果你使用C、C++或Go,它们会有所不同。

然而,一旦过了那个阶段,开始生成中间代码,就不再与特定编程语言相关。你的编译器可以通过为不同编程语言提供不同的前端,配合相同的代码生成引擎,来支持多种编程语言。

最后,汇编和链接步骤既不再与编程语言相关,也与特定平台相关。根据目标机器架构的不同,编译器需要生成不同的输出。

常见的编译器

由于C等编程语言是标准化的,因此存在许多不同的编译器实现,包括商业闭源产品和开源编译器。一些比较知名的编译器有英特尔C编译器(ICC)、Borland Turbo C编译器和微软的Visual C++编译器。这三个例子是商业闭源编译器,可能需要购买,并且可能仅作为集成开发环境的一部分提供,而不是作为独立工具。

更常见的开源编译器包括Clang(LLVM编译器工具链的C/C++/Objective-C前端)和GCC(GNU编译器套件)。Clang近年来已成为FreeBSD、OpenBSD(至少在amd64和i386平台上)以及macOS上的默认编译器。在这些平台上,Clang取代了另一个最流行、使用最广泛的开源编译器GCC。

GCC仍然是大多数Linux发行版以及NetBSD上的默认编译器,它支持许多硬件架构和平台,而Clang可能不支持。因此,在我们的课程中,我们使用GCC作为默认编译器。

还有其他开源编译器,例如PCC(可移植C编译器),你可以从各自的包管理系统中将其安装在不同的Unix系统上。PCC是20世纪70年代在贝尔实验室编写的早期编译器之一,并在21世纪中期进行了修订以支持现代C标准。

此外,还存在许多其他编译器,尝试使用不同于默认编译器的编译器来构建项目可能非常有用。包括GCC在内的许多编译器都添加或支持对语言的自定义扩展。如果你想编写可移植的代码,最好了解这些扩展是什么,何时使用以及何时不使用。因此,安装第二个编译器可以帮助你捕获不可移植的代码。观察不同编译器是否生成更高效的可执行文件以及各自应用了哪些类型的优化也很有趣。

总结

本节课中我们一起学习了编译器工具链的基础知识。我们了解到,编译器是一个将高级语言源代码转换为机器可执行代码的复杂工具集,其过程包括预处理、词法分析、语法分析、语义分析、代码生成与优化、汇编和链接等多个阶段。我们还简要介绍了几种常见的编译器,如GCC和Clang。在接下来的视频中,我们将详细探讨这些阶段的细节以及其中提到的各个工具。

027:预处理器详解 🧠

在本节课中,我们将深入学习编译器工作的第一阶段:预处理器。我们将通过具体示例,详细讲解预处理器如何展开源代码,以及如何通过命令行工具手动控制这一过程。

上一节我们介绍了编译器的整体功能,本节中我们来看看预处理器在实践中的具体工作。

预处理器的作用

预处理器是编译过程的第一步。它接收我们的源代码,并根据特定的指令(如 #include#define)对代码进行展开和替换。这个过程发生在真正的编译之前。

一个简单的示例

我们从一个简单的C语言程序开始,文件名为 hello.c

#include <stdio.h>

void func2() {
    printf("%s are great on anything.\n", FOOD);
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/apue/img/e1a2752fba5b7569041b0ce83a2195f4_9.png)

void func1() {
    func2();
}

int main() {
    func1();
    return 0;
}

在这个程序中,我们使用了一个预处理器指令 #ifdef 来根据是否定义了 FOOD 宏,决定打印哪种食物。

观察预处理器的输出

编译器 cc 会自动调用预处理器 cpp。但我们可以手动运行 cpp 来查看预处理后的代码。

运行以下命令:

cpp hello.c

你会看到大量输出。除了我们自己的代码,还包含了从 stdio.h 等头文件中引入的所有类型定义和函数声明。这表明预处理器的主要工作之一就是处理 #include 指令,将头文件的内容“复制粘贴”到源文件中。

为了获得更清晰的视图,我们可以使用 -P 选项来移除行号等调试信息:

cpp -P hello.c

预处理后的代码(通常保存在 .i 文件中)仍然是合法的C源代码,但已经没有任何预处理器指令了。例如,所有的 #ifdef 判断都已被解析,FOOD 被替换成了具体的值(如果没有定义,则使用代码中的默认值)。

通过编译器驱动控制预处理

我们通常不直接调用 cpp,而是通过 cc 来控制。cc-E 选项会让它在执行完预处理阶段后停止。

运行以下命令,效果与直接调用 cpp -P 类似:

cc -E hello.c

如果你想观察 cc 在背后具体执行了哪些步骤,可以加上 -v(verbose)选项:

cc -E -v hello.c

输出会显示编译器查找头文件的路径、使用的内部标志等详细信息。

定义宏

预处理器另一个关键功能是宏替换。我们可以在命令行中直接定义宏。

例如,以下命令将 FOOD 宏定义为字符串 "tomato"

cc -D FOOD='"tomato"' hello.c -o hello
./hello

程序将输出:“tomato are great on anything.”

核心概念-D 标志用于在命令行定义宏。其语法为 -DNAME-DNAME=VALUE。预处理器会在编译前,将源代码中所有 NAME 出现的地方替换为 VALUE

需要注意的是,这种替换是简单的文本替换。因此,如果 VALUE 在C语法中需要引号(比如字符串),你必须在定义时加上引号,并确保shell能正确传递它们(通常需要转义)。

编译全过程预览

使用 -v 标志而不使用 -E,我们可以看到完整的编译流程:

cc -v hello.c -o hello

输出会显示 cc 依次调用了:

  1. cpp(预处理器)
  2. cc1(真正的编译器,进行词法、语法、语义分析并生成汇编代码 .s 文件)
  3. as(汇编器,将汇编代码转换为目标文件 .o
  4. ld(链接器,将目标文件与库链接成最终可执行文件)

本节要点总结

本节课中我们一起学习了:

  • 预处理器 cpp 是编译的第一步,负责处理 #include#define 等指令。
  • 我们可以手动运行 cpp 或使用 cc -E 来查看预处理后的代码。
  • 预处理后的代码(.i 文件)是纯C代码,已展开所有头文件和宏。
  • 使用 -D 标志可以在命令行定义宏,预处理器会据此进行文本替换。
  • 使用 -v 标志可以详细观察 cc 驱动整个编译过程所调用的所有工具和步骤。

通过理解预处理器的工作,我们能够更好地控制源代码在编译前的形态,这是理解整个编译链和进行高级调试的基础。下一节,我们将进入编译的下一阶段:编译与代码生成。

028:编译与汇编 🛠️

在本节课中,我们将要学习编译过程的两个核心阶段:编译(Compilation proper)和汇编(Assembly)。我们将通过一个简单的“Hello World”程序示例,一步步演示如何手动执行这些步骤,并观察编译器优化带来的影响。

上一节我们介绍了预处理阶段,本节中我们来看看如何将预处理后的C源代码转换为机器可识别的目标文件。

概述

编译过程包含多个阶段。在预处理之后,接下来的步骤由 cc 命令处理,包括词法分析、语法分析、语义分析、中间代码生成以及可能的优化。之后,我们会得到汇编代码,再通过汇编器 as 将其转换为机器码,最终生成一个二进制目标文件(.o 文件)。本节课将重点讲解编译和汇编这两个阶段。

从预处理到编译

首先,我们回顾一下上节课结束时的状态。我们有一个简单的C程序 hello.c,并使用 cc -E 命令生成了预处理后的文件 hello.i

现在,我们将 hello.i 文件作为输入,进入编译阶段。我们可以使用 cc 命令的 -S 选项来在编译后停止,生成汇编代码。

以下是生成汇编代码的命令:

cc -S hello.i

执行该命令后,会生成一个名为 hello.s 的汇编代码文件。

查看汇编代码

生成的 hello.s 文件内容大致如下。我们可以看到 main 函数,以及其中调用的 func1func2 函数。汇编代码中包含了诸如 LFB(局部函数开始)和 LFE(局部函数结束)的标记,以及将字符串常量(LC0, LC1)加载到寄存器并调用 printf 的指令。

这个流程与我们的C源代码逻辑一致:main 调用 func1func1 调用 func2func2 调用 printf

编译器优化

然而,func1func2 的调用看起来是多余的。编译器在编译阶段可以进行优化。通过查阅手册,我们知道 cc 命令支持不同级别的优化选项,例如 -O1, -O2, -O3

让我们尝试使用最高级别的优化来重新编译:

以下是启用优化后生成汇编代码的命令:

cc -S -O3 hello.i

现在,再看生成的 hello.s 文件,你会发现 main 函数不再调用 func1func2,而是直接执行了原本在 func2 中的操作——加载字符串并调用 printf。编译器通过优化,识别并消除了不必要的函数调用链。

从汇编到目标文件

现在,我们得到了汇编代码(.s 文件)。下一步是使用汇编器 as 将其转换为机器码,生成目标文件(.o 文件)。

以下是使用汇编器的命令:

as -o hello.o hello.s

as 工具读取汇编代码,并生成一个与机器相关的、适合链接器 ld 使用的目标文件。

生成的目标文件是二进制格式,无法直接用文本编辑器阅读。但我们可以使用 strings 工具来查看其中包含的可读字符串,这通常会显示程序中用到的函数名和常量字符串。

使用 cc 命令整合步骤

实际上,我们不需要手动执行每一个步骤。cc 命令的 -c 选项可以一次性完成从预处理、编译到汇编的所有工作,直接生成目标文件。

以下是使用 cc -c 命令直接生成目标文件的命令:

cc -c hello.c

这条命令会生成 hello.o 文件,效果与我们分步执行 cc -Ecc -Sas 是相同的。

此时,我们尝试运行 hello.o 文件,会发现它无法执行。这是因为目标文件还不是最终的可执行文件,它缺少链接(Linking)这一最后步骤。

总结

本节课中我们一起学习了编译过程的两个关键阶段。

  1. 编译:我们使用 cc -S 将C源代码(或预处理后的代码)转换为汇编语言。在这个过程中,编译器可以进行各种优化(通过 -O 选项),以提高代码效率。
  2. 汇编:我们使用汇编器 as 将汇编代码(.s 文件)转换为二进制的目标文件(.o 文件)。这个文件包含了机器指令,但还不是独立的可执行程序。

我们还了解到,可以使用 cc -c 命令来简化流程,直接生成目标文件。目前,我们得到了一个目标文件,但它还不能运行。在下一节课中,我们将探讨编译过程的最后一步——链接(Linking),它将多个目标文件和库文件组合在一起,生成最终的可执行文件。

029:编译器工具链(第四部分)🔗

在本节课中,我们将完成对编译过程及编译器工具链的讨论,重点学习链接(Linking)这一最终步骤。我们将了解如何将目标文件与必要的库文件链接,生成最终的可执行程序。

在之前的视频中,我们已经了解了编译器如何通过cpp命令进行预处理,通过cc命令进行编译,以及通过as命令将中间代码汇编成目标文件(.o文件)。本节中,我们来看看最后一个步骤:链接。

链接阶段由ld命令执行,它将多个不同的文件组合成最终的输出文件(默认为a.out)。如下图所示,链接器所做的工作似乎不仅仅是使用我们生成的目标文件。

我们唯一能控制的文件是汇编阶段生成的.o文件。那么图中显示的其他文件是什么呢?让我们来探究一下。

回顾与尝试链接

首先,让我们回顾之前的步骤并尝试链接。我们调用了预处理器生成.i文件,然后通过gcc进行编译和代码生成,得到汇编代码.s文件,接着使用as生成.o文件。当我们尝试执行这个目标文件时,理所当然地失败了。

因此,我们尝试使用ld命令进行链接。

ld hello.o

然而,这并不顺利,我们遇到了错误。

错误信息显示“未定义的引用:printf”。为什么会这样?

链接标准C库

printf是标准C库(libc)提供的标准I/O函数。根据手册页,我们需要使用-lc标志来链接libc库。

让我们将这个标志添加到ld命令中:

ld hello.o -lc

这次看起来好多了,不再有关于printf的未定义引用错误,命令成功执行,只显示了一个警告。

我们得到了可执行文件a.out。让我们运行它。程序执行失败了,这是为什么?

链接C运行时文件

检查文件类型,看起来没问题。我们可能还缺少其他需要链接的库。回顾手册页,它提到了一个名为crt0.o的文件(crt代表C运行时)。这个文件提供了程序执行的启动例程,我们需要在链接过程中包含它。

但我们之前尝试过吗?让我们看看之前得到的警告。警告提示缺少符号_start。我们需要找到包含这个符号的crt文件。在/usr/lib目录中,我们找到了几个crt文件。使用grep或更好的工具nm(用于列出目标文件中的符号)来查找。

使用nm命令检查/usr/lib/crt0.o

nm /usr/lib/crt0.o

输出显示符号_start被定义了(标记为T),但符号_fini是未定义的(标记为U)。

而在文件crti.o中,我们找到了_fini的定义。因此,看起来我们需要同时链接crt0.o(提供_start)和crti.o(提供_fini)。

让我们尝试链接这两个文件:

ld hello.o /usr/lib/crt0.o /usr/lib/crti.o -lc

很好,没有警告了。让我们运行程序。结果提示“a.out: not found”。但文件明明在那里,为什么说找不到?

动态链接器与解释器

使用file命令检查a.out

file a.out

输出显示这是一个“动态链接的可执行文件”,并且使用了一个解释器/lib/ld64.so.1。“not found”信息指的不是a.out本身,而是指这个解释器/lib/ld64.so.1不存在。

让我们与一个已知能工作的可执行文件(如/bin/ls)进行比较。ls也是一个动态链接的可执行文件,但它使用的解释器是/lib/ld-linux.sold-linux.so是一个运行时链接编辑器,用于在程序执行时加载可执行文件和所有必需的对象。

因此,我们需要告诉ld使用正确的动态链接器。我们可以通过-dynamic-linker选项来指定。

完整的链接命令

与其手动摸索所有必需的库和启动文件,不如看看gcc在幕后是如何调用链接器的。使用gcc -v标志可以显示它实际运行的命令。

gcc -v hello.c -o hello

观察输出,我们可以看到gcc传递了许多标志给ld,包括动态链接器路径、多个crt对象文件以及其他启动和结束对象文件(如crtbegin.o, crtend.o),这些文件为程序提供了启动和从main函数返回所需的簿记代码。

将这些元素添加到我们的手动链接命令中,我们最终得到了一个可以工作的可执行文件。

库搜索路径

为什么我们必须通过绝对路径指定crt文件,却可以简单地用-lc来链接libc?手册页指出,要链接一个名为libNAME的库,我们需要传递-lNAME,链接器会去寻找名为libNAME.a(静态库)或libNAME.so(共享库)的文件。

链接器在哪些目录中搜索这些库文件呢?我们可以使用-L标志指定额外的搜索目录。要查看链接器的默认搜索路径,可以运行:

ld --verbose | grep SEARCH_DIR

输出显示它默认在/usr/lib等目录中查找。因此,我们可以在/usr/lib下找到libc库(libc.alibc.so)。

过程回顾与总结

本节课中我们一起学习了链接的完整过程。如果我们要手动完成所有阶段,需要:

  1. 运行cpp预处理源文件。
  2. 运行gcc将C代码编译成汇编。
  3. 运行as将汇编代码转为目标代码。
  4. 运行ld将目标文件与C运行时代码和标准C库链接,创建可执行文件。

当然,我们可以让编译器驱动程序gcc通过一个命令为我们执行所有这些步骤,这要简单得多。

正如你所看到的,这个看似简单的步骤序列实际上涉及多个独立的命令,每个命令都有其复杂性和众多选项。gcc驱动了整个流程,它将各种命令和选项按需传递给其他工具。

在这个过程中,理解标志(flags)的顺序可能很重要,这与大多数UNIX工具不同。此外,编译器的行为既受内置默认值影响,也常受环境变量影响。

虽然我们完成了对编译器的高级介绍,但你可能已经注意到,在这一系列视频中,我们偶尔使用了另一个工具make来帮助我们节省击键次数,并使用预定义的规则来构建文件。make是UNIX开发环境中极其重要且有用的工具,我们将在下一个视频中详细介绍它。

感谢观看。

030:make(1)工具详解 🛠️

在本节课中,我们将要学习一个名为 make 的Unix工具。make 是一个用于根据源文件和目标文件之间的依赖关系,有选择性地构建代码项目的工具。我们将看到,make 不仅仅是一个在调用编译器时节省打字的便捷方式,它还能智能地管理复杂的构建过程。

为什么需要make工具? 🤔

让我们从一个简单的软件项目开始说明为什么需要 make。假设我们的项目由几个源文件和头文件组成。

我们最初可能会这样编译整个程序:

cc -Wall -Werror -Wextra *.c -o ls

这可以正常工作。

现在,假设我们编辑了 cmp.c 文件并做了一个简单的修改。当我们重新编译项目时,我们再次运行了相同的命令。但请注意,在这种情况下,我们再次编译了所有源文件,即使我们只修改了 cmp.c 这一个文件。

对于一个拥有成百上千个源文件的复杂软件项目来说,每次都重新编译所有文件会浪费大量时间。

更高效的构建方法 ⚙️

一种更高效的方法是单独编译每个源文件,生成目标文件(.o 文件),然后再将它们链接成可执行文件。

我们可以使用 -c 选项让编译器只生成目标文件:

cc -Wall -Werror -Wextra -c cmp.c
cc -Wall -Werror -Wextra -c ls.c
cc -Wall -Werror -Wextra -c main.c
cc -Wall -Werror -Wextra -c stat_flags.c

然后链接它们:

cc cmp.o ls.o main.o stat_flags.o -o ls

现在,如果我们只修改了 cmp.c 文件,我们只需要重新编译这一个文件,然后重新链接所有目标文件即可。这比重新编译所有文件要高效得多。

依赖关系图 📊

然而,当修改涉及到头文件时,情况会变得更复杂。例如,如果我们修改了 stat_flags.h 头文件,那么所有包含了这个头文件的源文件(比如 main.cstat_flags.c)都可能受到影响,需要重新编译。

我们的软件项目可以表示为一个依赖关系图。图中显示了文件之间的依赖关系。例如:

  • cmp.c 的修改要求我们重建 cmp.o,然后重建 ls 可执行文件。
  • stat_flags.h 的修改意味着 main.cstat_flags.c 必须被视为过时,需要重建 main.ostat_flags.o,然后重新链接生成可执行文件。

在脑海中维护这样一张依赖关系图是不可行的,尤其是对于大型项目。

引入make工具 🚀

make 工具就是用来维护这些依赖关系的。它从一个名为 Makefile 的文件中读取定义,确定所谓的“目标”是由哪些“源”文件构建的。然后,make 通过执行指定的 shell 命令来构建这些目标。

make 允许我们指定从源文件构建可执行文件所需的命令。

让我们创建一个简单的 Makefile。按照惯例,我们使用一个名为 all 的目标:

all:
    cc -Wall -Werror -Wextra *.c -o ls

现在,只需输入 make 命令即可构建项目。这节省了打字,但并没有解决依赖问题,我们仍然在重新编译所有文件。

改进Makefile:指定依赖 🔗

我们可以通过指定目标及其依赖项来改进 Makefile。我们希望构建 ls 可执行文件,所以将其作为目标:

ls: cmp.o ls.o main.o stat_flags.o
    cc cmp.o ls.o main.o stat_flags.o -o ls

cmp.o: cmp.c
    cc -Wall -Werror -Wextra -c cmp.c

ls.o: ls.c
    cc -Wall -Werror -Wextra -c ls.c

main.o: main.c
    cc -Wall -Werror -Wextra -c main.c

stat_flags.o: stat_flags.c
    cc -Wall -Werror -Wextra -c stat_flags.c

现在,make 知道 ls 依赖于这些 .o 文件,而每个 .o 文件又依赖于对应的 .c 文件。如果 cmp.c 的修改时间比 cmp.o 新,make 就会运行指定的命令重新编译 cmp.o,然后重新链接生成 ls

这样,当我们只修改一个源文件时,make 会智能地只重新编译必要的文件。

使用变量和隐式规则简化 📝

上面的 Makefile 有很多重复。我们可以使用变量和隐式规则(后缀规则)来简化它。

以下是如何在 Makefile 中定义和使用变量(按照惯例使用大写):

PROGRAM = ls
OBJS = cmp.o ls.o main.o stat_flags.o
CFLAGS = -Wall -Werror -Wextra

我们可以定义一个后缀规则,告诉 make 如何从 .c 文件构建 .o 文件:

.c.o:
    $(CC) $(CFLAGS) -c $<

在这个规则中:

  • $(CC)$(CFLAGS) 是变量。
  • $< 是一个特殊的自动化变量,代表依赖项(即 .c 文件)。

现在,我们可以简化我们的目标规则:

all: $(PROGRAM)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/apue/img/7b1d7ab0b1cd31a0edd73b30d5dd50eb_7.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/apue/img/7b1d7ab0b1cd31a0edd73b30d5dd50eb_8.png)

$(PROGRAM): $(OBJS)
    $(CC) $(OBJS) -o $(PROGRAM)

make 会自动使用我们定义的后缀规则来构建所需的 .o 文件。

添加其他常用目标 🧹

一个常见的做法是添加一个 clean 目标,用于清理中间生成的文件(如 .o 文件):

clean:
    rm -f $(OBJS) $(PROGRAM)

运行 make clean 会删除所有目标文件和可执行文件,让你可以从头开始构建。

处理头文件依赖 🧠

到目前为止,我们的 Makefile 还没有处理头文件依赖。如果我们修改了 stat_flags.hmake 不会知道需要重新编译 main.cstat_flags.c

我们可以手动添加这些依赖:

main.o: main.c stat_flags.h
stat_flags.o: stat_flags.c stat_flags.h

但这对于大型项目来说非常繁琐且容易出错。

自动生成依赖:makedepend 工具 🔧

幸运的是,有一个名为 makedepend 的工具可以自动分析 C 源文件并生成依赖规则。

我们可以在 Makefile 中添加一个 depend 目标:

depend:
    makedepend -- $(CFLAGS) -- *.c

运行 make depend 会生成一个包含所有依赖关系的文件(通常是 .dependMakefile.dep),然后我们可以将这个文件包含到主 Makefile 中。这样,make 就能知道所有头文件的依赖关系,并在头文件改变时重新编译正确的源文件。

make 的内置规则和变量 ⚡

make 有许多内置的规则和变量,这使得编写 Makefile 更加简单。例如:

  • CC 变量默认是 cc(C 编译器)。
  • 对于从 .c 文件构建 .o 文件,make 有一个内置的隐式规则,其命令大致是 $(CC) $(CFLAGS) -c -o $@ $<

这意味着,即使我们不显式地编写 .c.o 后缀规则,make 通常也知道如何构建 .o 文件。我们只需要设置好 CFLAGS 等变量即可。

命令行覆盖变量 🎛️

我们可以在运行 make 时通过命令行覆盖 Makefile 中定义的变量。例如:

make CFLAGS="-O2"

这将在构建时使用 -O2 优化标志,而不是 Makefile 中定义的 -Wall -Werror -Wextra

总结 📚

本节课中我们一起学习了 make 工具的核心概念和用法。

make 本质上是一个命令生成器,它根据 Makefile 中的描述来运行命令。但其强大之处在于,它能够基于文件之间的依赖关系和时间戳,智能地决定需要执行哪些命令以及执行的顺序,从而避免在只修改少量文件时重新构建整个项目。

我们学习了如何:

  1. 编写基本的 Makefile 来定义目标和命令。
  2. 指定文件间的依赖关系,实现选择性编译。
  3. 使用变量来简化和参数化 Makefile
  4. 利用隐式规则(后缀规则)来避免重复代码。
  5. 添加像 clean 这样的实用目标。
  6. 理解头文件依赖的问题,并了解如何使用 makedepend 工具(或现代编译器如 gcc -M)自动生成依赖。
  7. 利用 make 的内置规则和变量。
  8. 通过命令行覆盖变量值。

虽然我们在这里介绍的示例相对简单,但 make 的功能非常强大和复杂。存在不同的实现(如 BSD make 和 GNU make),它们在变量扩展、流程控制等方面略有不同。掌握 make 这样的强大构建工具,是充分利用 Unix 作为集成开发环境的重要一环。

031:调试你的代码 🐛

在本节课中,我们将要学习软件开发过程中可能占用你最多时间的一个环节:调试。我们将探讨代码可能出错的原因,并了解为什么使用调试器这样的工具可以显著改善开发流程。

概述

上一节我们介绍了如何使用编译器从多个文件构建可执行文件。本节中,我们来看看当代码编译成功但行为不符合预期时,应该如何进行调试。

调试的本质

调试不仅仅是“去除错误”。这个过程更偏向于哲学层面,即确定“什么是”和“什么不是”。我们认为自己告诉了计算机要做什么,但计算机有一个恼人的习惯:它只会精确地执行我们告诉它的事情,而不是我们本意想让它做的事情。调试的核心就在于找出我们理解和假设中的错误之处。

常见的调试方法

以下是几种最流行的调试方法:

  1. 凝视代码并深入思考:尝试通过阅读代码本身来理解问题。
  2. 查阅文档:阅读编译器错误信息、手册页或其他相关文档。
  3. 猜测与尝试:提出可能的修改方案,这通常与猜测无异。我们不断尝试各种改动,然后重新编译和运行程序来观察效果。
  4. 使用打印语句:为了检查程序当前在做什么、变量的值或执行到了代码的哪个位置,我们会在代码中插入大量的 printf 语句。

一个调试示例

让我们回顾一个早期的简单 ls 示例。我们对其进行了扩展,使其除了打印文件名外,还打印文件所有者。

// 示例代码片段
struct passwd *pwd = getpwuid(file_stat.st_uid);
printf("Owner: %s\n", pwd->pw_name);

这段代码看起来没问题。让我们在 /tmp 目录下试试。运行正常。现在,在我们为元终端作业创建的测试目录中试试。哦,看,又一个段错误。这些错误似乎经常发生,真烦人。

我们尝试解决的每个程序最终都以段错误告终。显然,我们的程序存在缺陷。

为什么需要调试器

正如刚才试图说明的,使用打印语句进行调试确实是一种有些痛苦的方法。有没有更好的方式呢?答案是肯定的:我们可以使用调试器。

调试器是一个工具,它允许我们检查正在运行的程序,查看程序执行时的确切状态,或者查明程序崩溃时正在做什么。这是一个非常有用的方法。

诚然,真正的程序员可能会声称他们只需戴上“思考帽”,比别人更专注地凝视代码就能解决问题。但在现实中,几乎每个人都在使用快速迭代的猜测性修改混合打印语句的方法。实际上,我们都应该使用一个合适的调试器。

本节总结

本节课中,我们一起学习了调试在软件开发中的重要性,回顾了几种常见的、但效率较低的调试方法,并认识到使用专用调试工具的必要性。我们明白了调试的核心是找出自身理解与代码实际行为之间的偏差。

在接下来的视频中,我将向你展示一些简单的例子,教你如何使用强大的调试器 GDB 来修复我们之前未能纠正的程序,以及如何轻松地精确定位程序失败的地点和原因。一旦你掌握了这个技巧,你的效率将会大大提高。准备好你出错的代码示例,点击进入下一个视频吧。

032:使用GDB调试程序 🐛

在本节课中,我们将学习如何使用GDB调试器来检查正在运行的程序,并观察其执行过程中的状态。

概述

上一节我们遇到了程序段错误。本节中,我们来看看如何使用GDB来定位并解决这个问题。

编译程序以启用调试

首先,我们需要在编译程序时启用调试符号。这可以通过在编译命令中添加 -g 标志来实现。

gcc -g simple.c -o simple

启动GDB并运行程序

编译完成后,我们可以启动GDB来调试程序。

gdb ./simple

GDB会提供一个交互式提示符。要运行程序,可以使用 run 命令,后面可以跟上程序所需的命令行参数。

run

程序将在调试器中执行。当程序发生段错误时,GDB会立即告诉我们错误发生的具体位置。

定位错误

GDB会显示错误发生的函数、文件名和行号。例如,它可能显示错误发生在 simple.c 文件的第22行,位于 print_owner 函数中。

这是调试器的核心功能之一:它无需我们手动添加 printf 语句来追踪,就能直接指出问题所在。

我们可以使用 list 命令查看出错位置附近的代码。

list

检查变量值

在程序崩溃时,我们可以检查变量的值。例如,要查看变量 name 的值,可以使用 print 命令或其缩写 p

p name

如果发现某个指针变量(例如 pw)的值为 0x0(即 NULL),这通常就是导致段错误的原因——我们试图解引用一个空指针。

检查函数返回值

GDB允许我们在调试会话中直接调用函数并检查其返回值。这有助于理解为什么某个函数调用会失败。

例如,我们可以手动调用 getpwuid 函数,看看它对于特定用户ID(UID)返回什么。

p getpwuid(0)

如果返回一个有效的结构体指针,说明UID 0(root用户)存在。如果我们传入程序中导致崩溃的UID(例如1234),并发现返回 NULL,则说明系统中没有对应的用户。

修复代码

基于调试信息,我们发现问题在于 getpwuid 可能返回 NULL,而代码没有检查这一点就直接使用了返回值。

以下是修复后的常见模式:

struct passwd *pw = getpwuid(statbuf.st_uid);
if (pw == NULL) {
    printf("%d", statbuf.st_uid); // 打印数字UID
} else {
    printf("%s", pw->pw_name); // 打印用户名
}

这种模式与 ls 命令的行为一致:当文件的所有者UID在系统中没有对应的用户名时,就显示数字UID。

验证修复

重新编译并运行修复后的程序,验证段错误是否已解决,并且程序能按预期显示数字UID。

gcc -g simple.c -o simple
./simple

总结

本节课中我们一起学习了使用GDB调试程序的基本步骤:

  1. 使用 -g 标志编译程序以包含调试信息。
  2. 启动GDB并运行程序。
  3. 利用GDB在程序崩溃时精确定位错误位置(函数、文件、行号)。
  4. 在调试器中检查变量的值。
  5. 在调试会话中调用函数以测试其行为。
  6. 关键收获:任何可能失败的函数调用,都必须检查其返回值

GDB的功能远不止于此。在下一节中,我们将在此基础上,学习如何单步执行程序,并修复之前有缺陷的斐波那契数列程序。


附:核心命令速查

  • 编译调试:gcc -g file.c -o output
  • 启动GDB:gdb ./output
  • 运行程序:run
  • 显示代码:list
  • 打印变量:print variable_namep variable_name
  • 查看堆栈:backtracebt
  • 退出GDB:quit

033:使用gdb调试(第二部分)🔧

在本节课中,我们将继续学习如何使用gdb调试器来修复程序中的错误。我们将通过一个存在逻辑缺陷的斐波那契数列程序,实践如何设置断点、单步执行以及观察程序运行状态,从而定位并解决问题。

上一节我们通过检查函数返回值修复了一个段错误。本节中,我们来看看如何调试一个逻辑有问题的递归程序。

程序问题回顾

我们之前遗留的斐波那契数列程序存在缺陷。其代码如下所示,运行时会导致段错误。

int fib(int i) {
    if (i == 0) {
        return 0;
    }
    return fib(i-1) + fib(i-2);
}

启动调试并定位问题

首先,在调试器中运行程序。gdb会立即指出错误发生的位置。

Segmentation fault in fib on line 6.

但这次的错误原因并不像上次那样显而易见。因此,我们不再让程序直接运行失败,而是观察其运行过程。

设置断点与观察执行

以下是设置断点并控制程序执行的步骤。

  1. 在主函数设置断点:在程序启动时暂停执行,以便我们从开始就进行观察。使用命令 break main
  2. 运行程序:使用 run 命令启动程序,它会在 main 函数入口处暂停。
  3. 查看代码:按下 Ctrl + X 然后按 O,可以切换到同时显示源代码和汇编代码的视图,方便我们跟踪执行到了哪一行。
  4. 单步执行:使用 next (或 n) 命令执行当前函数内的下一条语句。

如果此时让程序继续运行,它仍然会在 fib 函数中触发段错误。这并没有帮助我们找到根本原因。

深入递归过程

为了理解递归是如何出错的,我们在 fib 函数内部也设置一个断点。

  1. 在fib函数设置断点:使用命令 break fib
  2. 查看所有断点:使用 info breakpoints 确认我们有两个断点。
  3. 重新运行并单步跟踪:从 main 开始,使用 next 执行到调用 fib(7) 的语句。然后使用 step (或 s) 命令进入 fib 函数内部。
  4. 观察递归调用:反复使用 step 或直接按回车(重复上一条命令),观察递归的调用过程。我们发现程序会以 i-2 的方式递归调用 fib(5), fib(3), fib(1), fib(-1)... 这永远不会满足 i == 0 的基本条件,从而导致了无限递归和栈溢出。

info breakpoints 显示断点已被触发了数十次,这证实了递归没有正确终止。

分析与修复逻辑错误

问题的根源在于递归的基本情况定义不完整。斐波那契数列的正确定义是:

  • fib(0) = 0
  • fib(1) = 1
  • fib(n) = fib(n-1) + fib(n-2) (当 n > 1 时)

我们的原始代码缺少了对 fib(1) 的处理。因此,我们修改 fib 函数:

int fib(int i) {
    if (i == 0) {
        return 0;
    }
    if (i == 1) {
        return 1; // 修复:添加基本情况 fib(1) = 1
    }
    return fib(i-1) + fib(i-2);
}

修复后,程序为 fib(7) 正确返回了结果 13。我们进一步测试了从0到10的输入,结果均正确。

核心调试技巧总结

本节课中我们一起学习了以下关键的gdb调试技巧:

  • 设置断点:使用 break <函数名>break <行号> 在特定位置暂停程序执行。
  • 控制执行流
    • next:执行当前函数内的下一条语句,不进入被调用的函数内部。
    • step:执行下一条语句,如果该语句是函数调用,则进入该函数内部。
  • 观察状态:在断点处暂停时,可以检查变量的值,并使用布局视图观察代码执行位置。

通过设置断点和单步执行,我们能够可视化程序的执行流程,从而识别并修复那些仅靠错误信息难以追踪的逻辑问题。

当然,调试器还有更多强大的功能。在下一个视频中,我们将探索另一个需要调试的程序,并学习更多调试技巧。敬请期待,感谢观看!

034:使用gdb(1)第三部分 🔍

在本节课中,我们将学习如何使用调试器(gdb)来修复一个存在问题的程序。我们将通过一个具体的例子,演示调试的迭代过程,并巩固一些重要的编码实践。

概述

我们将分析一个简单的C程序,该程序因未正确处理命令行参数和内存分配而崩溃。通过使用gdb,我们将逐步定位问题根源,并学习如何动态修改变量以测试不同场景。


上一节我们介绍了调试器的基本用法,本节中我们来看看如何应用这些技能解决实际问题。

这是一个非常简单的main函数,它调用了一个名为print_buffs的函数。

int main(int argc, char **argv) {
    print_buffs(argv[1]);
    return 0;
}

print_buffs函数接收一个数字参数,为几个缓冲区分配内存,复制一些数据,读取用户输入并打印结果。

void print_buffs(char *num_str) {
    long n = strtol(num_str, NULL, 10);
    char *buff = malloc(n);
    gets(buff);
    printf("You entered: %s\n", buff);
    free(buff);
}

编译程序时,编译器会警告我们使用了不安全的gets函数,但我们将暂时忽略此警告。

运行程序时,它崩溃了(段错误)。这是预料之中的。

初步调试

我们遇到这类问题时,首先在调试器中运行程序。

使用gdb启动程序,并列出print_buffs函数的代码。注意,我们无需指定文件名即可列出多个源文件的代码,这非常方便。

运行程序,再次发生段错误。但这次我们注意到一个有趣的现象:段错误并非发生在我们的函数中,而是发生在libc库的某个地方(例如strtol)。

我们只看到问号,因为标准C库没有调试符号,所以调试器无法像查看我们的代码一样深入查看库代码。

让我们查看调用堆栈(backtrace)。错误仍然发生在main函数中。

选择相关的栈帧。错误发生在这里。段错误发生在libcstrtol中,而不是print_buffs中。因此,问题一定出在对strtol的调用上。

我们向strtol传递了argv[1],让我们检查argv

argv是一个char **类型。它的第一个元素是什么?

要获取数组的第一个元素,我们可以查看内存位置。我们会发现可执行文件的路径,正如预期的那样。*argvargv[0]在标准C语义中是相同的。

那么,argv[1]是什么?它是NULL,因为我们没有提供任何命令行参数。因此,将NULL作为参数传递给strtol很可能导致了这次段错误。

修复第一个问题

我们在main函数中添加一个检查,以确保程序被正确使用。

int main(int argc, char **argv) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <number>\n", argv[0]);
        return 1;
    }
    print_buffs(argv[1]);
    return 0;
}

现在,当我们不带参数运行程序时,会得到一个正确的用法说明。到目前为止,一切顺利。

发现第二个问题

现在我们需要提供一个数字。让我们输入-1。程序提示我们输入。我们输入“hey there”。结果,我们又得到了一个段错误。

再次进入调试流程。gets尝试读取我们在此处分配的缓冲区数据。段错误发生在gets中。

查看调用堆栈。main调用了print_buffsprint_buffs调用了gets,然后gets崩溃了。

让我们查看栈帧1(print_buffs)。这里有什么?n-1buff是什么?它是NULL

这并不奇怪。我们告诉malloc分配-1字节,这当然会失败。但由于我们没有检查函数的返回值,我们随后尝试访问NULL来存放用户数据,因此导致了段错误。

动态测试与验证

与其立即修复,不如先通过检查来确认我们的理论。我们在print_buffs函数处设置断点。

再次以-1作为参数运行。n确实是-1。它的类型是long

假设我们没有使用-1,而是用了不同的值,会发生什么?让我们在gdb中尝试一下。我们可以在运行时修改变量。

因此,即使我们传递了-1,我们现在也可以将其更改为不同的值。-1显然是无效的,我们不知道用户可能想输入多少数据。所以,让我们通过将n设置为一个非常大的数字(例如1024)来确保获得足够大的缓冲区。

检查并确认n现在是1024。然后继续执行。那么malloc将不会尝试分配-1字节,而是分配那么多字节。

我们可以再次输入数据。然而,再次发生段错误,在相同的位置。因为buff再次是NULL。这同样不奇怪。我们尝试为buff分配一个 insane 数量的内存(接近1PB)。当然,malloc会失败。但我们又一次没有检查返回值。

修复核心问题

让我们更新程序,修复它,使其验证malloc是否成功,无论用户在命令行输入什么值。

void print_buffs(char *num_str) {
    long n = strtol(num_str, NULL, 10);
    char *buff = malloc(n);
    if (buff == NULL) {
        fprintf(stderr, "malloc failed!\n");
        exit(1);
    }
    gets(buff);
    printf("You entered: %s\n", buff);
    free(buff);
}

这是所有代码应有的样子。如果malloc失败,就报错。始终检查任何可能失败的函数的返回值,并适当地处理错误。对其他缓冲区也重复此操作。

现在编译它。用户不再能输入无效的数字。

总结

本节课中我们一起学习了如何利用gdb进行迭代式调试。

  • 我们能够看到调试器可以毫无问题地跟踪程序的执行,并将其与代码行关联起来,即使是跨多个源文件。
  • 然而,如果错误发生在没有调试符号的代码位置(例如,失败发生在标准C库提供的函数中),那么调试器只能告诉我们错误发生了,或提供其他最少的信息。
  • 最后,我们在这个例子中看到,我们不仅能够被动地观察程序的执行,还可以在程序运行时改变它。例如,我们可以为变量分配不同的值,以观察程序可能的行为。

现在,我们的程序看起来行为正常了,但我们还有最后一个视频要讲解,我将展示如何使用调试器来检查特定的内存位置,并帮助你理解数组和指针的工作原理。敬请关注,感谢观看。

035:使用gdb理解指针 🔍

在本节课中,我们将使用调试器gdb来探索程序运行时内存地址的布局。通过这个过程,我们也将复习C语言中指针和数组的工作原理,这对于消除缓冲区溢出等常见程序故障和安全问题至关重要。


程序回顾与问题引入

上一节我们介绍了调试器的基本用法。本节中,我们来看看一个具体的程序示例,并观察其内存行为。

我们回到 print_buffs 程序。这次,我们使用 -g3 选项编译,以确保包含额外的调试符号,稍后我们将用这些符号来访问宏定义。

gcc -g3 -o print_buffs print_buffs.c

运行程序,指定缓冲区大小为8,并输入8个字符:“Hey now!”。

程序输出如下:

buff is: Hey now!
buff2 is:
buff3 is: Hello, I am buff3

buff 的输出看起来正确,但 buff2 显示为空。让我们再次运行,这次输入7个字符:“Hey now”。输出变为:

buff is: Hey now
buff2 is: O
buff3 is: Hello, I am buff3

buff2 的输出变成了“O”。这是怎么回事?是时候请出调试器了。


使用gdb进行调试

我们启动gdb,并在 print_buffs 函数处设置断点。

gdb ./print_buffs
(gdb) break print_buffs
(gdb) run

程序运行并停在断点处。我们检查三个缓冲区的大小,它们都被分配了8字节的内存。

buff = malloc(8);
buff2 = malloc(8);
buff3 = malloc(8);

我们单步执行,确认 strcpy 操作后,buff2buff3 的数据是正确的。接着,执行 gets(buff) 并输入“Hey now!”。此时,buff2 的内容再次出错,它似乎包含了本应写入 buff3 的数据。

为了理解原因,我们需要查看这些数组的内存地址。


检查内存地址与指针运算

以下是检查内存的核心命令:

(gdb) print buff
$1 = 0xb0008 "H"
(gdb) print buff2
$2 = 0xb0010 "O"
(gdb) print buff3
$3 = 0xb0018 "Hello, I am buff3"

我们看到:

  • buff 的地址是 0xb0008
  • buff2 的地址是 0xb0010
  • buff3 的地址是 0xb0018

地址是连续的。buff2 紧接在 buff 分配的8字节之后(0xb0008 + 8 = 0xb0010),buff3 又紧接在 buff2 之后。

在gdb中,我们可以使用 x 命令检查任意内存地址的内容。x/c 表示以字符格式打印。

(gdb) x/c 0xb0008
0xb0008: 72 'H'
(gdb) x/c 0xb0009
0xb0009: 101 'e'

数组下标访问 buff[0] 等价于对起始地址 buff 进行指针运算 *(buff + 0)。因此,指针和数组在底层是相通的。

字符串“Hey now!”包含7个字符,加上终止符 \0 共8字节。这个 \0 应该位于 0xb000f (0xb0008 + 7)。然而,当我们向 buff2 写入数据“Hello, I am buff2”时,该字符串长达17字节,远超为其分配的8字节。strcpy 函数从 buff2 的地址 0xb0010 开始,写入了17字节,覆盖了 0xb0018 之后的内存区域,而这正是 buff3 的起始位置。

当我们打印 buff2 时,printf 会从 0xb0010 开始打印字符,直到遇到 \0。但由于我们写入的数据过长,覆盖了原本应在 buff2 末尾的 \0,所以打印会一直继续,直到找到 buff3 字符串末尾的 \0,因此 buff2 显示了 buff3 的部分内容。


缓冲区溢出的影响

让我们进行另一个实验。在调用 gets 之前设置断点,然后输入8个字符“Hey now!”(包含7个字符加隐含的换行符转成的 \0)。

我们发现,buff 的8字节被填满,而终止符 \0 被写入了 buff2 的第一个字节(地址 0xb0010)。这使得 buff2 在打印时一开始就遇到 \0,所以显示为空,尽管其后的内存中仍有“Hello, I am buff2”的剩余字符。

这揭示了一个关键点:写入超出缓冲区边界不一定会导致段错误(Segmentation Fault)。它只是意味着你将数据写入了给定的内存位置。如果那个位置恰好是另一个变量的地址,你就覆盖了那个变量的数据。

例如,如果我们输入一个更长的字符串(如16个字符),buff 的溢出数据会覆盖 buff2 甚至 buff3 的部分内容,从而控制这些内存区域的数据。


如何避免缓冲区溢出?

为了避免这个问题,我们不应使用不安全的函数(如 gets、不检查边界的 strcpy),而应使用其安全版本。

  1. 使用 strncpy 替代 strcpy
    strncpy 允许指定最大复制字符数。但请注意,strncpy 如果源字符串长度超过指定大小,它不会自动添加终止符 \0。因此,我们必须手动添加。

    strncpy(buff2, data2, 8); // 最多复制8个字符到buff2
    buff2[7] = '\0';          // 手动确保字符串以null结尾
    
  2. 永远不要使用 gets
    gets 函数极其危险,因为它无法限制输入长度。编译器会明确警告不要使用它。应使用 fgets 替代。

    // gets(buff); // 危险!不要使用
    fgets(buff, 8, stdin); // 安全:最多读取8-1个字符,并为'\0'预留空间
    

修改代码后重新编译运行,程序不再发生缓冲区溢出,行为符合预期。


总结与核心概念

本节课中,我们一起学习了如何使用gdb深入理解指针和内存布局。

  • 指针与数组:在C语言中,数组名本质上是一个指向其首元素的常量指针。访问 array[i] 等价于 *(array + i)
  • 字符串的本质:C语言中的字符串是以 \0 结尾的字符数组。
  • 缓冲区溢出:当向一个缓冲区写入超过其容量的数据时,多余的数据会覆盖相邻的内存区域。这可能导致程序行为异常、数据损坏或严重的安全漏洞(如任意代码执行)。
  • 安全编程实践
    • 使用 strncpy 并手动添加终止符。
    • 使用 fgets 代替 gets
    • 始终检查函数手册,了解其安全性。

调试器(如gdb)不仅是定位段错误的强大工具,更是我们理解程序底层内存行为、验证逻辑和提升代码安全性的不可或缺的伙伴。通过本系列课程,希望你已初步掌握利用调试器探索程序世界的方法。

036:sizeof与strlen的区别 🔍

在本节课中,我们将深入探讨C语言中的sizeof运算符。许多初学者容易将其与strlen函数混淆,尤其是在处理缓冲区大小时。我们将通过实例和调试器观察,明确两者的根本区别,并解释为何在动态分配内存的场景下错误使用sizeof会导致严重问题。

1:常见误解与问题引入

上一节我们回顾了字符串操作的基础。本节中,我们来看看一个常见的编码误区:将sizeof等同于strlen

许多代码错误地使用sizeof来确定缓冲区的大小。例如,strncpy的手册页提供了如下示例代码:

strncpy(buf, src, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';

这段代码之所以正确,是因为buf是一个在编译时已知大小的固定长度数组。sizeof(buf)返回的是整个数组的存储大小。

然而,更常见的情况是使用动态分配的内存:

char *buf = malloc(SOME_SIZE);
strncpy(buf, src, sizeof(buf) - 1); // 错误!

如果在这里使用sizeof,将会出现问题。代码存在固有缺陷和风险。为了理解原因,我们需要探究sizeof的真正行为。

2:探究sizeof运算符的本质

sizeof是一个运算符,而非库函数,因此没有单独的手册页。要了解它,最好的方法是直接观察。

我们可以使用调试器来观察sizeof对各种数据类型的求值结果。以下是sizeof对基本数据类型的返回结果(在典型64位系统上):

  • sizeof(char) 返回 1
  • sizeof(int) 返回 4
  • sizeof(float) 返回 4
  • sizeof(long) 返回 8
  • sizeof(double) 返回 8
  • sizeof(指针类型)(如 char*void*)返回 8

sizeof返回的是传递给它的数据类型或表达式所占用的存储空间大小(以字节为单位)

3:结构体与灵活数组成员

在C语言中,我们可以自定义结构体类型。sizeof对结构体的计算会包含内存对齐填充

考虑以下结构体:

struct s1 { char *p; };                 // sizeof 可能为 8
struct s2 { char *p; int i; };          // sizeof 可能为 16(含填充)
struct s3 { int i; int j; char *p; };   // sizeof 可能为 16
struct s4 { struct s1 a; struct s2 b; struct s3 c; }; // sizeof 为各成员之和(含填充)

C99引入了灵活数组成员,即结构体末尾大小未指定的数组。对于这样的结构体:

struct flex { char c; char array[]; };

sizeof(struct flex) 仅返回成员 c 的大小(即1)。灵活数组成员在sizeof计算中视为零大小。这意味着对包含灵活数组成员的结构体使用sizeof无法获得其实际存储的数据长度。

4:sizeof与strlen的正面比较

现在,我们通过一个具体程序来对比sizeofstrlen

以下是程序中不同变量的定义和初始化:

char buff1[] = "ABC";                    // 数组,初始化
char buff2[1024];                        // 数组,未初始化
char buff3[1024] = "ABC";                // 数组,部分初始化
char *s1 = "ABC";                        // 指针,指向字符串字面量
char *s2 = buff3;                        // 指针,指向数组
char *s3;                                // 指针,未初始化
s3 = malloc(1024);                       // 指针,指向动态内存
strncpy(s3, "HelloWorld", 10);           // 向动态内存写入数据

观察结果如下:

  • 对于数组buff1
    • sizeof(buff1)4(包含结尾的\0)。
    • strlen(buff1)3(不包含结尾的\0)。
  • 对于未初始化的数组buff2
    • sizeof(buff2)1024(数组总大小)。
    • strlen(buff2) 结果未定义,会一直计数直到遇到内存中的第一个\0
  • 对于指针s1s3
    • sizeof(s1) 始终为 8(指针本身的大小)。
    • strlen(s1) 取决于指针所指向的字符串内容。

核心区别总结

  • sizeof编译时运算符(大部分情况下),返回数据类型或对象的存储大小
  • strlen运行时函数,计算字符串中直到第一个空字符(\0)之前的字符数

5:关键结论与正确用法

回到最初的strncpy示例,我们可以得出关键结论:

  1. 当操作对象是固定大小的数组(编译时已知大小)时,可以使用sizeof来获取缓冲区大小。
  2. 当操作对象是指针(尤其是指向动态分配内存的指针)时,绝不能使用sizeof来获取缓冲区长度。sizeof(指针)永远返回指针变量本身的大小(如8字节),而非它所指向的内存块的大小。

对于动态分配的内存,你必须自己记录或知晓缓冲区的最大容量,并将其作为参数传递给strncpy等函数。

// 正确做法
char *buf = malloc(SOME_SIZE);
strncpy(buf, src, SOME_SIZE - 1);
buf[SOME_SIZE - 1] = '\0';

sizeof常用于动态内存分配,特别是为结构体分配空间时:

struct somestruct *ptr = malloc(sizeof(struct somestruct));

但务必将其与strlen区分开来。

总结

本节课中我们一起学习了sizeof运算符与strlen函数的根本区别。sizeof用于获取存储大小,在编译时(通常)确定;而strlen用于计算字符串长度,在运行时确定。混淆二者,尤其是在处理指针和动态内存时,会导致缓冲区溢出等严重错误。请务必根据上下文选择正确的工具。

037:标准文本编辑器ed(1) 🖋️

在本节课中,我们将要学习Unix系统中的标准文本编辑器——ed。尽管它看起来古老且不直观,但理解ed的工作原理对于深入掌握Unix哲学、理解后续工具(如vised)的起源,以及在某些特定场景下的脚本化编辑至关重要。

概述:ed是什么?🤔

ed是一个行导向的文本编辑器。它不会在屏幕上显示文件的全部内容,而是基于行进行操作。ed由Ken Thompson于1969年编写,是原始Unix系统的三个核心组件之一(另外两个是Shell和汇编器)。它后来启发了ex编辑器,并最终演变为vi

初识ed:一个典型的“初体验” 😅

让我们启动ed,看看会发生什么。

$ ed
?
?

输入任何字符,ed都只回复一个问号。输入help?也得不到帮助。这是因为ed启动后默认处于命令模式,而我们输入的内容被当作无效命令处理了。

要退出,可以尝试按Ctrl+C,或者输入Ctrl+D(发送EOF信号)。这通常是用户第一次使用ed的经历,并不友好。

理解ed的工作模式 🔄

根据手册页,ed在两种不同的模式下运行:

  • 命令模式:在此模式下,可以输入命令来操作文本(如打印、删除、替换)。
  • 输入模式:在此模式下,输入的内容会被当作文本插入到文件中。

模式之间的切换是关键。我们第一次启动时处于命令模式,所以无法直接输入文本。

基础编辑:创建并保存文件 📝

上一节我们介绍了ed的两种模式,本节中我们来看看如何实际使用它来创建和编辑一个文件。

  1. 启动ed并进入命令模式。
    $ ed
    
  2. 输入追加命令a,进入输入模式。
    a
    
  3. 输入文本内容,每行以回车结束。
    Hello, world!
    This is a test.
    
  4. 在新的一行输入一个单独的句点.,退出输入模式,返回命令模式。
    .
    
  5. 使用写入命令w后跟文件名,保存内容。
    w myfile.txt
    29
    
    ed会显示写入的字节数(例如29)。
  6. 使用退出命令q离开编辑器。
    q
    

现在,文件myfile.txt已经保存了我们输入的内容。

编辑现有文件与命令语法 ✏️

我们已经学会了创建新文件,现在让我们学习如何编辑一个已存在的文件。

使用文件名作为参数启动ed

$ ed myfile.txt
29

ed会显示文件的字节数,并将当前位置置于文件末尾的命令模式。

ed命令可以对行范围进行操作。行地址可以是:

  • 数字:如1表示第一行。
  • $:表示最后一行。
  • 正则表达式:如/pattern/

以下是常用命令示例:

  • 打印文件内容:使用p(print)命令。
    1,$p
    
    打印第1行到最后一行。可以简写为:
    ,p
    
  • 插入文本:使用i(insert)命令,在指定行之前插入。
    1i
    [进入输入模式,输入文本]
    .
    
  • 追加文本:使用a(append)命令,在指定行之后追加。
    $a
    [进入输入模式,输入文本]
    .
    
  • 替换文本:使用s(substitute)命令,语法为s/旧内容/新内容/
    1s/Hello/Hi/
    
    将第一行的“Hello”替换为“Hi”。
  • 删除行:使用d(delete)命令。
    2d
    
    删除第二行。
  • 显示行号:使用=命令。
    ,=
    
    显示文件的总行数。
    1,3=
    
    显示第1到3行的行号。

实战:将文本文件改为C程序 💻

让我们用ed将一个文本文件编辑成一个简单的C程序。

  1. 假设我们有一个文件hello.txt,内容为两行文本。我们想把它变成:
    #include <stdio.h>
    int main() {
        printf("Hello, world!\n");
        printf("This is a test.\n");
    }
    
  2. 首先,在开头插入头文件和主函数定义。
    $ ed hello.txt
    1i
    #include <stdio.h>
    int main() {
    .
    
  3. 将原有的两行文本替换为printf语句。我们可以使用全局命令g配合替换命令s
    g/.*/s/^/    printf("/  # 在所有行前添加"printf("
    g/.*/s/$/");/           # 在所有行后添加");"
    
  4. 在文件末尾追加右大括号。
    $a
    }
    .
    
  5. 保存并退出。
    w hello.c
    q
    

ed与脚本化编辑 🤖

由于ed拥有独立的命令模式,并且可以从标准输入读取命令,这使得它非常适合脚本化编辑。这是ed一个非常强大且至今仍有价值的特性。

我们可以将一系列ed命令写在一个脚本文件中,或者通过管道传递给它。

示例1:通过echo传递命令

$ echo -e '1,$p\nq' | ed hello.c

这个命令会打印hello.c的所有内容然后退出。-e参数允许echo解释反斜杠转义符(如\n为换行)。

示例2:使用脚本文件进行编辑
创建一个脚本文件ed_script.txt

4,5s/");/\\n");
w
q

然后执行:

$ ed -s hello.c < ed_script.txt

-s标志用于抑制诊断信息(如字节数)。这个脚本将第4-5行的");替换为\n");,然后保存退出。

这种“通过命令描述变更”的思想,直接影响了后来的diffpatch工具,也是sed(stream editor,流编辑器)工具诞生的基础。事实上,sed的许多命令语法与ed一脉相承。

ed与vi的联系 🔗

理解ed能极大地帮助你掌握vi(或vim),因为vi是从edex演化而来的可视化编辑器。

它们的核心共同点包括:

  • 模式分离vi也有明确的命令模式输入模式(在vi中叫插入模式)。你需要按i进入插入模式才能输入文本,按Esc返回命令模式。
  • 命令相似性:许多基本编辑命令是相通的。
    • :w 写入文件(vi中需加冒号)。
    • :q 退出。
    • :s/old/new/ 替换当前行文本。
    • :1,$pvi的命令行模式下同样可以执行。
  • 行地址操作:在vi的命令行模式(按:进入)中,你可以使用和ed一样的行地址语法来执行操作,例如:4,8d删除4-8行。

主要区别在于,ed是纯行导向的,而vi可视化的,允许你在屏幕上直接看到和移动光标。

总结 🎯

本节课中我们一起学习了Unix的标准文本编辑器ed。我们从其令人困惑的初次体验开始,逐步理解了它的命令模式输入模式。我们学习了如何创建文件、编辑行、使用行地址范围以及执行搜索替换。

更重要的是,我们探讨了ed脚本化编辑方面的强大能力,以及它与后续工具(如sedvi)的深刻联系。虽然今天你可能不会在日常工作中使用ed,但理解它的设计哲学和操作方式,能让你更深入地领悟Unix“一切皆文件,工具做一事并做好”的理念,并成为一个更强大的命令行用户。

在接下来关于Unix开发工具(如diffpatch)的课程中,我们将再次回到“通过行命令描述变更”这一核心思想。同时,不妨偶尔打开ed,和这位Unix世界的老朋友打个招呼,体验一下最原始的编辑之力。

UNIX高级编程:第6周:进程的内存布局 🧠

在本节课中,我们将学习进程在内存中的布局。理解进程的内存结构对于后续学习进程执行、内存管理等高级概念至关重要。我们将通过编写C语言程序来实际查看不同程序元素的内存地址,从而直观地理解进程的各个内存段。


概述:进程内存布局

进程在内存中并非杂乱无章,而是被划分为几个逻辑段。每个段都有特定的用途。一个典型的进程内存布局如下图所示,从高地址到低地址依次包含:环境变量、命令行参数、栈、堆、未初始化数据段(BSS)、初始化数据段和文本段。

上一节我们介绍了进程的基本概念,本节中我们来看看如何通过代码来探索这个内存布局。


如何查看内存地址

在C语言中,我们可以直接查看任何程序元素的内存地址,甚至无需使用调试器。每个变量本质上就是存储在特定内存位置的数据,其占用的内存大小由变量类型决定。指针则是一种特殊的变量,其值是一个内存地址。

使用取地址运算符 &,我们可以获取变量的地址。然后,我们可以将这个地址转换为数字(例如 long 类型)并以十六进制形式打印出来。

以下是获取变量地址的示例代码:

int var = 10;
printf("变量 var 的地址是:%p\n", (void*)&var);

通过这种方式,我们可以开始探索程序在内存中的分布。


探索程序内存布局

为了更全面地了解内存布局,我们编写一个更复杂的程序,声明不同类型的变量和函数,并打印它们的地址。

以下是程序中包含的主要元素:

  • 全局变量:在函数外部声明的变量。
  • 静态变量:使用 static 关键字声明的变量,其生命周期贯穿整个程序运行期。
  • 局部变量:在 main 函数内部声明的变量。
  • 函数main 函数和其他自定义函数。
  • 动态分配的内存:使用 malloc 在堆上分配的内存。

运行该程序后,我们得到按内存地址排序的输出结果。输出显示,从高地址到低地址依次是:环境变量、命令行参数、栈、堆、BSS段、数据段和文本段。这与我们之前的内存布局图完全吻合。


详解各内存段

现在,让我们详细看看每个内存段的作用和特点。

1. 文本段(Text Segment)

文本段位于内存的最低地址区域,包含程序的可执行指令。此段通常被标记为只读(read-only)。历史上,文本段与“粘着位”(sticky bit)的用途有关。如果可执行文件设置了粘着位,操作系统可以在进程终止后将文本段保留在交换空间(swap space)中。当再次执行同一命令时,内核可以快速地将该段从交换空间移回物理内存,这比从磁盘读取要快得多。因此,粘着位也被称为“保存文本位”(save-text bit)。现代Unix系统已很少使用此功能。

2. 数据段(Data Segment)

数据段位于文本段之上,包含已初始化的全局变量和静态变量。这些变量在程序启动时就从可执行文件中读取并加载到内存中。

3. BSS段(BSS Segment)

BSS段位于数据段之上,其名称来源于“Block Started by Symbol”。该段包含未初始化的全局变量和静态变量。在程序加载时,操作系统(exec)会将这个区域的所有内存初始化为零。这包括显式初始化为零的静态变量。

4. 堆(Heap)

堆位于BSS段之上,是用于动态内存分配的区域。当程序调用 malloccallocrealloc 等函数时,内存就从堆中分配。堆由进程中的所有线程共享。随着内存的不断分配,堆向高地址方向增长

5. 栈(Stack)

栈位于内存的高地址区域,紧邻命令行参数和环境变量。栈是一种后进先出(LIFO) 的数据结构,用于管理函数调用。每次调用函数时,一个新的“栈帧”会被压入栈中,其中包含该函数的局部变量、参数和返回地址等信息。栈向低地址方向增长。栈指针(SP)总是指向栈的“顶部”(即当前使用的最高地址)。

注意:有些图示会将内存布局上下翻转,使栈在顶部(高地址在底部),栈指针指向视觉上的“顶部”。本教程采用更常见的图示,即高地址在顶部,栈向下增长。

6. 命令行参数与环境变量

进程内存的最高地址区域存放着传递给 main 函数的命令行参数(argv环境变量(envpargv 是一个指针数组,其中 argv[0] 是程序名,最后一个元素是 NULL


栈溢出实验

我们了解到,堆向上增长,栈向下增长,两者相向而行。那么,如果持续进行函数调用,不断将新的栈帧压入栈中,会发生什么?

为了验证这一点,我们修改程序,让函数 func2 调用 funcfunc 又调用 func2,形成无限递归。正常情况下,程序会因递归过深而崩溃。

如果我们编译时定义了 STACK_OVERFLOW 标志来启用这个无限递归,程序运行一段时间后,最终会触发段错误(Segmentation Fault)。这是因为递归调用导致栈不断增长,最终超出了操作系统为栈分配的内存区域,访问了非法内存地址,这就是栈溢出(Stack Overflow)

这个实验直观地展示了栈的动态增长和其边界。


总结

本节课中我们一起学习了进程在内存中的布局。我们通过编写C程序,实际查看了文本段、数据段、BSS段、堆和栈等关键内存区域的地址分布。我们了解到:

  • 文本段存放只读的机器指令。
  • 数据段和BSS段存放全局和静态变量。
  • 用于动态内存分配,并向高地址增长。
  • 用于函数调用管理,并向低地址增长。
  • 不当的递归或过深的函数调用可能导致栈溢出错误。

理解进程的内存布局是深入学习进程控制、内存管理和系统编程的基础。建议下载课程提供的源代码进行实验,并阅读相关链接以巩固知识。

下一节,我们将讨论进程是如何启动的,以及我们如何进入 main 函数。

039:程序启动过程 🚀

在本节课中,我们将学习一个C程序从启动到退出的完整生命周期。我们将深入探讨main函数的特殊性、程序的真实入口点以及启动过程中的关键步骤。


上一节我们介绍了进程在内存中的布局,本节中我们来看看程序启动时的具体过程。

我们知道,进程是通过exec系列函数之一加载到内存中的。编写过C程序的人都知道,程序从main函数开始执行。然而,main函数有些特殊。在终端输入man main不会得到手册页。在系统头文件中搜索main函数的前向声明,也找不到它。那么,main函数是在哪里定义的呢?

这需要参考标准,但这次不是POSIX标准,而是C语言标准。C标准规定,程序启动时调用的函数名为main。实现方不为该函数声明任何原型。它应被定义为返回类型为int且不带参数。具体来说,标准列出了几种可能的原型,最常见的是带两个参数的原型:

int main(int argc, char *argv[]);

但标准也指出,可能存在其他实现定义的方式。这意味着我们可以有其他调用main的方式。

根据标准给出的宽泛指导,我们知道在程序启动时,main函数会被调用。当某个exec函数被调用时,内核需要以某种方式启动程序。为此,它会调用一个特殊的启动例程,该例程设置进程基础,将命令行参数和环境变量放入正确的位置等。

我们知道,argc作为命令行参数的数量传递给mainargv是一个指向参数(包括程序名argv[0])的指针数组。我们还知道这个数组以NULL结尾,因此可以轻松遍历。

以下是观察程序启动过程的步骤:

  1. 编写一个简单的程序,打印main函数的地址。
  2. 使用-g标志编译以启用调试符号,并使用-std=c89标准。
  3. 使用readelf工具查看可执行文件的头部信息,注意其入口点地址。
  4. 使用调试器(如gdb)在入口点地址设置断点,观察程序启动时的执行流程。

通过调试器,我们发现程序并非直接从main开始执行。真正的入口点是一个名为_start的例程,它位于地址0x4005f0_start例程会填充一些寄存器,然后调用__libc_start_main

__libc_start_main函数执行一系列初始化操作,包括调用__libc_csu_init等。最终,它会调用我们的main函数。当main函数执行完毕并返回一个整数值后,控制权会回到__libc_start_main,该函数随后调用exit,并将main的返回值传递给exit

那么,_start例程在哪里定义呢?我们可以在源代码树的libc/csu/目录下找到它,具体在crt0.S文件中(CRT代表C运行时)。这个文件包含了程序的初始入口点,即_start汇编例程,它会调用__libc_start_main函数。

__libc_start_main是一个常规的C函数,位于crt0.c文件中。它接收两个参数:一个函数指针和一个struct ps_strings结构体。这个结构体位于栈顶,用于定位参数和环境字符串。

__libc_start_main函数使用这些字符串来设置environ变量和__progname字符串(后者被getprogname库函数使用)。接着,它调用atexit(我们将在下个视频中讨论)和__libc_csu_init函数。最后,它调用exit,并将main的返回值(连同argcargv和环境指针一起传递给main)传递给exit。这意味着main实际上可以被实现定义为一个接收三个参数的函数。

既然入口点是_start而不是main,并且_start会调用main,我们能否绕过main呢?我们可以尝试使用一个名为foo的函数作为入口点。通过向编译器和链接器传递-e foo标志,可以创建一个直接跳转到foo而不是main的可执行文件。

然而,当我们运行这个程序时,它直接调用了foo,但在之后出现了段错误。这是因为foo函数执行完毕后,尝试返回到一个无效的地址(保存在未初始化的返回地址寄存器中),而正常的_start例程则正确地设置了返回路径。

为了避免段错误,我们可以让替代入口点函数不返回,而是直接调用exit。例如,创建一个名为bar的函数,它在打印消息后调用exit(0)。这样,程序就能正常退出,而不会因为无效的返回地址而崩溃。


本节课中我们一起学习了程序启动的完整过程。我们了解到,程序的入口点是由编译器/链接器定义的,这意味着我们可以更改它。我们看到了C启动例程如何设置环境并将命令行参数移动到适当位置。我们还明白了为什么main函数总是返回一个int值——这个值会被传递给exit函数。总而言之,我们的程序并非简单地以main开始,而是默认从_start入口点开始,_start调用__libc_start_main,后者最终调用exit,并将main的返回值作为参数。我们还看到了程序如何退出和返回,这将在下一个视频片段中进一步讨论。

040:程序终止 🚪

在本节课中,我们将要学习程序终止的多种方式,包括从main函数返回、调用exit函数、以及使用_exit系统调用。我们还将探讨如何注册退出处理函数,并理解不同终止方式对程序行为的影响。


上一节我们介绍了程序启动的过程,本节中我们来看看程序如何终止。尽管我们在上一段视频中已经简单提及,但这里将进行更深入的探讨。

还记得我们上次是如何开始的吗?我们只是打印了main函数的地址,然后没有显式返回值就结束了main函数。在使用默认编译器选项时,返回值是0。但如果使用C89标准编译程序,我们会收到一个关于缺少返回值的警告,并观察到程序返回了一个神秘的值20,而这个值恰好是printf打印消息的长度。

让我们比较一下使用C89和C11标准时生成的指令有何不同。为此,我们使用objdump工具来反汇编可执行文件。首先查找main函数。我们看到调用了printf,随后函数返回,没有其他操作。

现在,让我们使用默认的C11标准进行比较。反汇编并对比。生成的代码差异很小。以+开头的行是C89代码,以-开头的行是C11代码。它们几乎相同。但在C11代码中,我们看到在返回之前,有一条显式语句将值0移入返回值寄存器EAX

因此,C语言似乎被修改为:如果没有显式指定返回值,则默认返回0。毕竟,我们并没有真正使用printf调用的返回值。

让我们改变一下。如果我们将返回值赋给一个变量会发生什么?当我们编译代码时,现在会收到一个关于整数n被设置但未被使用的警告。然而,我们的返回值保持不变。再次反汇编代码并与C89代码比较。在那里,我们也得到了相同的警告,但同样看到了关于缺少显式返回值的警告。

让我们看看C89的汇编代码。在main函数内部,我们看到我们使用了printf的返回值,并将其移到一个通用寄存器中,这一步代表了将值赋给我们的整数n。在函数返回之前,与C11代码相比,我们看到完全相同的步骤发生。但此时,它后面再次跟着将0显式赋值给返回值寄存器的操作。

因此,仅仅将值赋给变量本身并没有改变太多。编译器似乎足够聪明,能意识到n并没有真正被使用,因此我们在编译时收到了警告,然后仍然按照C11标准的要求显式地将0赋为返回值。

现在,让我们比较一下如果我们确实使用了返回值的情况。这里,我们编辑代码使其返回n。注意,现在我们不再收到警告,因为我们正在使用这个变量。对于C11标准编译,行为相同。让我反汇编两者。我们应该发现生成的代码现在是相同的。看起来是这样:我们调用printf,将返回值移入寄存器,然后执行返回语句,将该寄存器的值移入返回值寄存器。

使用预期的返回码20,即printf打印的总字符数。


在之前的视频中,我们已经看到了进程终止的多种方式。

一方面,有正常的、预期的终止。我们见过:

  • main函数的隐式返回,根据所使用的C标准,可能产生不同的返回值。
  • main函数的显式返回。
  • 调用exit库函数,这可以发生在main函数内或任何其他函数中。

但也有_exit系统调用,以及奇怪的大写变体_Exit。这是C语言标准(要求_Exit)和POSIX标准(要求_exit)之间差异的产物。

此外,还有两种仅适用于多线程程序的方式,我们在此跳过。

除此之外,我们也有异常终止进程的方式。我们可以调用abort。我们可能被信号(如SIGABRTSIGKILL)终止。同样,如果是多线程的,我们可能会在收到取消请求时终止。

让我们仔细看看exit函数。exit是一个库函数,因此你可以推测它包装了_exit系统调用并提供了额外的功能。我们知道,exit接受一个整数值作为参数,该值随后成为程序的退出状态。当然,exit本身不会返回,因为它会导致进程终止,正如我们在代码探索中看到的那样。

在退出之前,它执行以下步骤:

  1. 调用任何先前通过atexit注册为退出处理程序的函数。
  2. 刷新所有打开的输出流,然后关闭它们。
  3. 删除由tmpfile函数创建的任何文件。
  4. 最后,调用_exit

_exit系统调用可以直接调用,然后进程会立即终止,而不执行上述任何操作。不过,通过此调用退出进程时,仍然会发生一些事情,但我们将在未来的讲座中讨论进程间通信和信号时重新审视这些考虑。

如前所述,exit库函数可能会调用任何先前通过atexit函数注册的退出处理程序。让我们看看这个。atexit接受一个函数指针作为参数,该函数是一个简单的、无参数且无返回类型的函数。成功时,atexit将函数指针压入一个栈中,而exit则会从该栈中弹出函数指针并执行,这意味着在进程终止时,它们的执行顺序与注册顺序相反。

没有理由不能多次注册同一个函数,我们只需要多次注册它即可。所有这些都很有用,可以确保不仅在从main返回时,而且在任何时候退出时执行某些任务。

让我们看一个例子。这里有几个琐碎的函数,它们只是打印一条消息。函数func打印一条消息,然后根据给定的参数数量,可能调用exit_exitabort来终止程序。在main中,我们通过atexit将上述函数注册为退出处理程序。请记住,函数将以与我们注册顺序相反的顺序从栈中弹出并执行,因此我们预计退出时的顺序是my_exit1my_exit2。运行它,顺序符合预期:我们调用了func,然后返回到main,再从main返回,然后我们的退出处理程序按我们看到的顺序被调用。

如果我们传递一个参数,那么func会自己调用exit,我们观察到我们没有返回到main,但注册的退出处理程序仍然按承诺执行了。如果我们传递第二个参数,那么func会调用_exit,这将立即终止进程,而不触发对退出处理程序的调用。最后,传递第三个参数时,func会调用abort。我们通过调用退出处理程序退出,但这次带有一个核心转储。检查核心转储,我们可以看到回溯,然后找到程序被SIGABRT信号终止的确切代码位置。看起来发生在example.c的第36行。当然,我们可以在执行时检查变量。


让我们通过这张图来看看UNIX进程的生命周期。新进程通过内核的exec调用诞生,然后进入C启动例程和用户空间,最终调用mainmain可以调用任意数量的函数,这些函数最终返回到mainmain返回后,我们回到C启动例程,它调用exit,我们从那里离开用户空间,并通过_exit终止进程。

然而,我们也可以从main内部或任何函数中自己调用exitexit可能会调用任何先前通过atexit注册的退出处理程序,然后在调用_exit之前执行对未关闭文件流或临时文件的清理。

但我们也有选择从main或任何其他函数中自己调用_exit


本节课中我们一起学习了多种退出程序的方式。

main返回(无论是显式还是隐式)将通过C启动例程导致对exit的调用。返回值取决于你是否传递了一个值,以及你所遵循的C标准版本。

我们可以随时自己调用exit_exit。如果我们希望执行某些任务,或者希望在程序终止时执行某些操作,可以通过atexit注册退出处理程序。这些退出处理程序可以通过调用_exitabort来绕过。

当我们的程序最终终止时,对其他进程的影响我们将在未来的讲座中讨论。

至此,是时候结束本视频段落了。感谢观看。

041:进程环境详解 🖥️

在本节课中,我们将学习进程环境。我们将利用之前学到的进程内存布局知识,来理解环境变量是如何存储的,以及在必要时是如何被移动的。在这个过程中,我们也会快速了解一下 malloc 函数的工作原理。

环境变量概述

在Shell中,我们可以通过运行 env 命令来打印当前的环境。我们看到的是一个键值对列表,其中许多变量在我们日常使用Unix系统时非常熟悉。

通过设置环境变量,进程可以轻松地向用户提供信息,同时用户也可以向那些设计为查找特定环境变量的程序提供信息。按照惯例,许多Unix工具都会识别并使用一些常见的环境变量。具体示例可以参考 environ 的手册页。

通过实验和日常使用,你可能已经注意到,在一个进程中设置变量不会对另一个进程产生任何影响。因此,我们可以得出结论:环境是进程特定的。

进程如何访问环境?

在之前的视频中,我们记得进程在内存中的布局可以这样可视化。我们已经注意到,环境变量可以在栈上方的高地址区域找到。

环境变量的存储位置

让我们更仔细地观察一下。我们的外部变量 extern char **environ 位于BSS段,因为它在我们的代码中被声明但未定义。一旦 exec 将控制权转移给启动程序,环境就被初始化了。正如所承诺的,环境变量本身位于高地址附近。

环境变量如何到达那里?

回想一下之前章节中 __start 例程的样子。在这里,我们设置了环境,并将其作为参数传递给 main 函数。

也就是说,我们实际上有两个版本的环境变量数组:一个是作为参数传递给 mainchar **envp,另一个是全局变量 extern char **environ。在程序启动时,environ 是空的。

让我们更新代码,比较外部变量 environ 和作为参数传递给 mainenvp。我们将计算环境变量的数量,并输出数组的起始和结束地址。

运行程序后,我们看到环境包含18个元素,最后一个元素(索引17)位于最高地址。我们的外部变量 environ 位于BSS段,而 char **envp 作为参数传递给 main,因此位于栈上,就像 argcargv 一样。注意,envpenviron 看起来相同,它们起始于相同的地址,也结束于相同的地址。

如何操作环境变量?

为了访问或操作环境,我们有以下库函数:

  • getenv:检索环境变量的值。
  • putenv:添加一个新的环境变量。
  • setenv:更改给定环境变量的值。
  • unsetenv:从环境中移除一个环境变量。

需要注意的是,将一个变量设置为空字符串与将其从环境中完全移除是不同的。有些工具可能只测试变量是否存在,而不测试其具体值,而一个没有值的变量仍然被视为已设置。

现在,让我们更深入地思考这些函数是如何工作的。

环境变量的动态管理

请记住,环境最初被放置在高地址附近,因此空间必然是有限的。然而,我们被允许更新、添加或删除这个数组中的值。这是如何实现的呢?

正如我们所说,作为 main 参数的 envp 必须在栈上。这个数组的第一个元素 envp[0] 指向栈上方的一个地址(例如 0xcb18)。在这个位置,我们找到一个 char * 类型的指针,它指向包含实际字符串(如 "FOO=bar")的字符数组,该字符串位于地址 0xcc080

我们的外部变量 environ 位于BSS段,但它的第一个元素也指向相同的地址 0xcb18。同样,数组的最后一个元素也位于相同的地址,envpenviron 都指向高地址附近。

使用 setenv 改变值

现在,让我们看看当我们使用 setenv 来更改环境中已存在的变量的值时会发生什么。假设我们将 FOO 的值改为一个更长的字符串。

调用 setenv 后,envp[0] 仍然指向 0xb8198environ[0] 也是如此。但是,该地址处的 char * 指针现在指向一个不同的地方。具体来说,setenv 似乎通过 malloc 为新的、更长的字符串动态分配了一些内存,并更新了 0xb8198 处的指针以指向这个新位置。

malloc 函数简介

它是如何做到这一点的?为了动态分配缓冲区,我们使用 malloc 函数。这个库函数会尝试分配指定数量的内存,并返回一个指向它保留的内存区域起始位置的指针。

这段内存是未初始化的。如果你希望它被初始化为全零,请使用 calloc。如果你需要改变所需的内存大小,可以使用 realloc 来增大或缩小内存区域。如果它可以在不移动数据的情况下进行更改,你可能会得到与传入相同的指针,但它仍然会更新保留的字节数。当然,与所有资源一样,一旦你使用完毕,应该使用 free 释放指针。这类似于我们总是在打开文件描述符的同一作用域内关闭它。同样,你应该总是在分配指针的同一作用域内释放它们,否则就有内存泄漏的风险。

在回到 setenv 之前,让我们简要观察一下 malloc 及其相关函数在实际中的表现。左侧是一个小程序,它首先分配内存,然后在中间将其重新分配到更大和更小的区域。在输出中,我们看到第一次调用 malloc 在堆上保留了一些数据。随后调用 realloc 最终在我们最初保留的区域下方保留了一个更小的区域。同样,重新分配到更大区域发生在另一个地方。而最后一次重新分配到更小区域时,又在堆上保留了另一个内存区域。在这种情况下,每次重新分配都使用了新的指针,即使我们缩小了保留的内存大小。这意味着你不能对返回的指针做任何假设。

使用 putenv 添加新变量

回到我们的环境。我们看到 setenv 通过 malloc 为新值保留了一些空间。现在,让我们看看当向环境中添加一个新变量时会发生什么。

调用 putenv 后,envp[0] 仍然指向与之前相同的地址。但是,environ 中的第一个变量现在位于不同的地方——它在堆上了。然而,这个新地址处的指针仍然指向我们调用 setenv 后得到的相同地址。

那么,为什么 environ[0] 移动了?我们位于栈顶的环境只有那么大,向环境中添加一个新变量可能无法在栈顶容纳。因此,为了添加一个新变量,putenv 必须首先将整个环境复制到一个新位置。也就是说,putenv 在堆上为整个环境数组分配了新的内存,然后复制了环境的所有元素(即所有指向值的指针),只有这样才能追加新值。这当然解释了为什么我们的新值 environ[18] 指向堆上的这个地址。而这个地址又指向另一个变量的位置,该变量位于程序的数据段中。这个字符串位于程序的数据段,因为它是作为固定字符串包含在程序中的。

所以,我们注意到在这里 envpenviron 出现了分歧。调用 putenv 后,environ 被更新了,但 envp 没有。这有一定道理,因为 envp 实际上只是 main 函数的一个局部变量,而 environ 是一个全局变量。

使用 unsetenv 移除变量

现在,让我们看看调用 unsetenv 函数时会发生什么。我们调用 unsetenv("FOO"),因此数组减少了一个元素,原来索引18的内容现在变成了索引17。但它仍然位于我们调用 unsetenv 之前的相同地址。

然而,unsetenv 移除了数组的第一个元素(在这个例子中是 environ[0]),因此所有元素都向下移动了一位。原来 environ[1] 的内容现在变成了 environ[0]environ[0] 本身的地址没有改变,但它包含的指针(指向 "HOME=/jxmas/HRRC")被更新了。而这个值仍然位于原始环境的最顶部,所以 environ[0] 现在可以指向那里。与此同时,envp 保持不变。

总结与注意事项

让我们回顾一下。进程环境由一个字符串数组组成,形式为 key=value 对。这是一种惯例,尽管你当然可以在其中放入任何内容。

初始环境由启动例程在进程空间顶部设置,并由外部变量 extern char **environ 指向。启动例程可能会将当前环境作为第三个参数传递给 main

我们看到,当我们添加新变量、更新或移除现有变量时,数组的元素可能会被移除或移动。我们看到,数组的元素可能会在我们添加新变量、更新或移除现有变量时被移动。有时这涉及到将它们移动到 malloc 分配的区域中。但重要的是要注意,对环境的操作应该只通过库函数和外部变量 environ 进行,而不是通过传递给函数的 envp

最后,虽然许多工具依赖环境或以常规方式使用它,但作为一名防御性程序员,你必须始终验证计划使用的任何变量的内容的合理性。用户可能能够更改它们,如果你不小心,很容易导致未定义或至少是意外的行为。

在结束之前,这里有一个练习链接供你思考:环境是如何更新的?如果用户添加了数百、数千甚至数万个新的环境变量,可能会发生什么?或者,如果用户添加了一个长达数千个字符的单个变量,可能会发生什么?尝试探索一下,看看是否能识别出其中的限制和副作用。

祝你好运,玩得开心!😊

UNIX高级编程:P42:进程限制与标识符 🔧

在本节课中,我们将学习进程的两个重要方面:资源限制和进程标识符。我们将了解如何查看和设置进程的资源限制,以及进程ID(PID)和父进程ID(PPID)的含义与用法。

在之前的视频中,我们探讨了进程的内存布局以及进程如何启动和终止。在深入进程控制和进程关系之前,我们将花几分钟时间了解每个进程的另外两个特性:资源限制和进程ID。

进程资源限制

我们可以通过 ulimit 命令来检查进程的资源限制。

这些限制描述了例如一个进程允许占用CPU的时间、可以分配多少内存、可以创建多少子进程,或者一个进程可以打开多少个文件描述符。

以下是 ulimit 命令支持的一些常见资源选项:

  • -t:CPU时间(秒)
  • -v:虚拟内存(KB)
  • -n:打开文件描述符的数量
  • -u:单个用户可创建的最大进程数

ulimit 命令是Shell的内置命令。其底层实现依赖于C库函数 getrlimitsetrlimit。在头文件 <sys/resource.h> 中定义了资源限制的结构体 rlimit,它包含两个成员:rlim_cur(软限制)和 rlim_max(硬限制)。

struct rlimit {
    rlim_t rlim_cur;  // 软限制
    rlim_t rlim_max;  // 硬限制
};

资源限制分为软限制和硬限制。当进程尝试超出软限制时,操作可能会失败或进程收到一个信号,但如果进程捕获了该信号,它可以继续运行。硬限制是资源使用的绝对上限,进程无法超越。

使用 setrlimit 更改限制遵循以下规则:

  1. 进程可以降低软限制。
  2. 进程可以将软限制提高到不超过硬限制的值。
  3. 任何对软限制的更改都会同时设置一个新的硬限制(等于新的软限制值)。这意味着一旦你降低了软限制,就无法再将其提升到原来的硬限制之上,除非你是超级用户。
  4. 只有超级用户(root)可以提高硬限制。
  5. 所有更改仅影响当前进程及其未来的子进程。

这解释了为什么 ulimit 必须是Shell内置命令,而不能是一个独立的可执行程序。

实践示例

让我们回顾第2周关于打开文件数量限制的例子。默认软限制可能是1024,而硬限制可能更高(例如4096)。

我们可以将软限制提高到硬限制内的任意值,例如2048。之后,我们也可以将其降低到512。但一旦降低,我们就无法再将其提升回最初的1024,因为降低操作同时设置了新的硬限制。

这种机制确保了一个进程可以限制自身及其子进程,并且之后无法自行解除这个限制,类似于永久降低权限的机制。

再看一个CPU时间限制的例子。如果我们将CPU时间限制设置为3秒,那么任何后续运行超过3秒CPU时间的程序都会被终止。需要注意的是,像 sleep 这样的函数是主动挂起进程,并不消耗CPU时间,因此不会触发CPU时间限制。要测试限制,需要一个真正进行计算的“忙”循环程序。

进程标识符

现在,我们来看看进程标识符。每个进程都有一个唯一的进程ID(PID),同时也有一个父进程ID(PPID)。你可以使用 getpid()getppid() 系统调用来获取它们。

#include <unistd.h>
pid_t getpid(void); // 返回当前进程的PID
pid_t getppid(void); // 返回当前进程的父进程的PID

进程ID在特定时间点唯一标识一个进程。但系统不保证一个PID在进程终止后不会被重用。当进程终止,新进程启动时,可能会被分配相同的PID。PID的分配方式对用户是不透明的(无法预测)。

有一些特殊的进程ID:

  • PID 0:通常是系统进程(如调度进程或空闲进程)。
  • PID 1:通常是 initsystemd 进程,它是所有用户进程的祖先,负责系统启动和清理孤儿进程。
  • PID 2:在一些系统(如旧版BSD)上是页面守护进程(pagedaemon),负责虚拟内存的分页操作。

查看进程关系

通过一个简单的程序打印PID和PPID,我们可以看到进程间的父子关系。例如,一个程序的PID是980,其PPID是396(即启动它的Shell的PID)。使用 ps 命令或 pstree 命令可以更清晰地展示整个系统的进程树状关系,从PID 1(init)开始,逐级向下到我们的Shell,再到我们运行的程序。

总结

本节课我们一起学习了进程的资源限制和标识符。

我们了解到,进程在资源使用上受到软限制和硬限制的约束。进程可以降低这两种限制,但只能将软限制提高到不超过硬限制的值,且只有root用户能提高硬限制。这些限制是进程特定的,因此 ulimit 必须是Shell内置命令。

我们还初步了解了进程标识符(PID和PPID)以及一些特殊PID的含义,并通过实例观察了进程间的父子关系。在接下来的视频中,我们将更详细地探讨进程关系。

043:进程控制 🧬

在本节课中,我们将学习进程控制的核心概念:如何通过可执行文件启动新进程,以及进程终止时会发生什么。这是理解后续课程中更详细的进程关系知识的先决条件。

进程创建:fork系统调用

上一节我们介绍了进程的基本概念,本节中我们来看看如何创建一个新进程。fork系统调用是创建新进程的基础,它的行为有些特殊。

fork系统调用会返回两次。它创建一个新进程,导致当前进程出现两个副本。这意味着它会在原始进程和新创建的进程中各返回一次。我们分别称这两个进程为父进程子进程

新进程与父进程几乎完全相同,但存在以下关键差异:

  • 子进程拥有一个唯一的进程ID(PID)
  • 子进程拥有其父进程的ID(PPID),即调用fork的进程的PID。
  • 子进程获得父进程中所有打开的文件描述符的副本,这些副本指向相同的内核数据结构。这意味着,例如,在子进程中对某个文件描述符进行读取操作,会影响父进程中同一文件描述符的偏移量。
  • 子进程的资源使用统计被重置为零。
  • 需要特别注意的是,fork返回后,父进程和子进程的执行顺序是不确定的。你不能依赖任何观察到的特定执行顺序。

为了更详细地说明父进程和子进程在文件描述符上的关系,让我们回顾一下与打开文件相关的数据结构。当一个进程打开文件时,会创建文件表项。当该进程调用fork时,会创建一个子进程,它继承了父进程的文件描述符。重要的是,子进程的文件描述符指针指向与父进程相同的文件表项。这意味着,在一个进程中对这些文件描述符进行的任何操作(如lseek)都会影响另一个进程。

以下是一个演示此行为的代码示例:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/apue/img/aec132c384b1b195fcaf43b022a27b0f_7.png)

void read_data(int fd) {
    off_t cur_offset = lseek(fd, 0, SEEK_CUR); // 获取当前偏移量
    printf("Current offset: %ld\n", (long)cur_offset);
    char buf[32];
    read(fd, buf, sizeof(buf)); // 读取数据,改变偏移量
}

int main() {
    int fd = open("data.txt", O_RDONLY);
    pid_t pid = getpid();
    printf("Process ID: %d\n", pid);

    read_data(fd); // 父进程读取,偏移量变为32

    pid_t child_pid = fork();
    if (child_pid == 0) {
        // 子进程
        lseek(fd, 32, SEEK_CUR); // 向前移动32字节
        sleep(1); // 让父进程先运行
        read_data(fd);
    } else {
        // 父进程
        sleep(1); // 让子进程先执行lseek
        read_data(fd);
    }
    return 0;
}

运行此程序,你将看到子进程的lseek操作改变了父进程中文件描述符的偏移量。

变量与缓冲区的继承

现在,让我们看看fork如何处理内存中的变量和I/O缓冲区。

以下程序演示了变量在fork后的行为:

#include <stdio.h>
#include <unistd.h>

int global_var = 10;

int main() {
    int local_var = 20;
    write(STDOUT_FILENO, "Before fork\n", 12);
    printf("Printf before fork\n");

    pid_t pid = fork();
    if (pid == 0) {
        // 子进程
        global_var++;
        local_var--;
        printf("Child: PID=%d, PPID=%d, global=%d, local=%d\n",
               getpid(), getppid(), global_var, local_var);
    } else {
        // 父进程
        global_var--;
        local_var++;
        printf("Parent: PID=%d, PPID=%d, global=%d, local=%d\n",
               getpid(), getppid(), global_var, local_var);
    }
    return 0;
}

运行此程序,你会看到父进程和子进程拥有全局变量和局部变量的独立副本,修改互不影响。

然而,当输出被重定向时(例如,通过管道),情况会变得有趣。如果你运行./program | cat,可能会发现“Printf before fork”这条消息被打印了两次。这是因为printf使用了缓冲区。

以下是原因分析:

  1. write无缓冲I/O,会立即将数据写入终端。
  2. printf缓冲I/O。当标准输出连接到终端时,它是行缓冲的,遇到换行符\n时缓冲区会被刷新。
  3. 当标准输出被重定向到管道时,它变成全缓冲printf的数据会留在缓冲区中,不会立即刷新。
  4. 调用fork时,这个包含printf数据的输出缓冲区也被复制到了子进程。
  5. 当父进程和子进程终止时,它们各自的缓冲区都会被刷新,写入管道,从而导致消息出现两次。

因此,在调用fork之前,显式地刷新I/O缓冲区(例如使用fflush)通常是一个好习惯。同样,子进程调用_exit而不是exit可以避免刷新从父进程继承的缓冲区。

执行新程序:exec函数族

使用fork,我们可以创建一个新进程,但父进程和子进程是相同的,任何差异都必须编码在if (pid == 0)的逻辑中。然而,更多时候,我们希望在fork之后执行一个完全不同的程序。为此,我们使用exec函数族。

exec函数用指定的可执行文件的新映像替换当前进程。如果exec返回,则意味着发生了错误。所有exec函数都是execve系统调用的前端。

以下是exec函数族的常见变体及其参数特点:

  • execl, execv: 需要提供可执行文件的完整路径
  • execlp, execvp: 使用PATH环境变量搜索可执行文件(函数名中的p代表path)。
  • execle, execvpe: 允许传递一个新的环境变量数组(函数名中的e代表environment)。
  • 函数名中的l代表参数以列表(list)形式传递(可变参数)。
  • 函数名中的v代表参数以向量(vector/数组)形式传递。

例如:

execl("/bin/ls", "ls", "-l", NULL); // 参数列表
char *argv[] = {"ls", "-l", NULL};
execv("/bin/ls", argv); // 参数数组
execvp("ls", argv); // 使用PATH搜索“ls”

调用exec时需要注意的副作用:

  • 打开的文件描述符默认会被继承,除非在文件打开时设置了FD_CLOEXEC标志。
  • 调用进程中设置为忽略的信号,在exec后仍被忽略;但捕获(caught)的信号会被重置为默认动作。
  • 进程继承真实的用户ID(UID)和组ID(GID)。除非可执行文件设置了set-user-IDset-group-ID位,否则有效的UID和GID也一同继承。

进程同步与回收:wait函数族

创建新进程(并可能执行新程序)后,父进程和子进程同时执行。为了等待特定子进程终止并获取其退出状态,父进程需要调用wait函数。

wait挂起当前进程的执行,直到有子进程的状态信息可用。如果你想等待特定的进程或进程组,可以使用waitpidwait4函数。

“等待”一个进程意味着:

  1. 阻塞直到该进程终止。
  2. 更具体地说,是检视一个已不再存活的进程的特定信息

进程退出时,会留下一个退出状态wait函数可以获取这个状态,并通过一系列宏进行解析:

  • WIFEXITED(status): 判断进程是否正常退出。
  • WEXITSTATUS(status): 如果正常退出,获取退出状态码。
  • WIFSIGNALED(status): 判断进程是否因信号而终止。
  • WTERMSIG(status): 如果因信号终止,获取导致终止的信号编号。
  • WCOREDUMP(status): 判断进程是否产生了核心转储(core dump)。
  • WIFSTOPPED(status): 判断进程是否被暂停(例如,被作业控制信号暂停)。

此外,wait3wait4还可以检索终止进程的资源使用情况(如CPU时间、内存使用等)。

僵尸进程与进程回收

为什么必须等待(回收)子进程?因为当进程终止时,其退出状态信息必须保留以供父进程查询。如果永远没有人来清理这些退出状态信息,那么这个进程就变成了一个僵尸进程

僵尸进程是已经死亡但尚未被等待(reaped)的进程。它不再运行,但仍然占用着进程ID、退出状态、资源使用记录等内核资源。

考虑以下创建僵尸进程的例子:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main() {
    for (int i = 0; i < 5; i++) {
        if (fork() == 0) {
            // 子进程立即退出
            exit(0);
        }
        // 父进程不调用wait,子进程成为僵尸
        printf("Created zombie child #%d\n", i+1);
        sleep(2);
    }
    // 父进程退出前不回收子进程
    sleep(10);
    return 0;
}

运行此程序,在另一个终端使用ps命令查看,可以看到状态栏标记为Z的僵尸进程。你无法用kill命令杀死僵尸进程,因为它们已经死了。

当父进程先于子进程终止时,子进程成为孤儿进程。内核会将孤儿进程过继init进程(PID 1)。当孤儿进程终止时,init进程会收到信号并调用wait回收它,从而避免其成为僵尸。因此,在上面的例子中,如果你杀死了父进程,所有僵尸子进程都会被init回收并消失。

总结

本节课中我们一起学习了进程控制的核心机制:

  1. 我们学习了如何使用fork创建新进程,并了解到fork会创建当前进程的一个几乎完全相同的副本,包括文件描述符和I/O缓冲区。
  2. 我们探讨了调用exec函数族的不同方式,以使用新的程序映像替换当前进程。
  3. 我们掌握了如何通过wait函数族获取终止进程的状态信息和资源使用情况,并理解了必须调用wait来回收子进程,以防止僵尸进程占用系统资源。同时,我们也了解到init进程会负责回收孤儿进程。

在接下来的课程中,我们将更深入地研究进程之间的关系,然后进入信号处理的学习。

044:第1节 - 登录过程 🚀

在本节课中,我们将要学习UNIX系统启动和用户登录过程中进程是如何被创建和关联的。我们将从系统启动开始,一直追踪到用户获得一个可交互的Shell,并理解这个过程中进程ID、父进程ID以及用户ID的变化。

概述

当UNIX系统启动时,内核会初始化硬件并最终将控制权交给init进程。随后,系统会为每个终端启动getty进程来管理登录。用户输入用户名和密码后,login程序会验证凭证并最终启动用户的Shell。本节将详细拆解这一系列步骤。

系统启动与进程创建

系统启动时,内核会在串行控制台输出大量消息。这些消息记录了硬件初始化的过程。内核启动完成后,控制权会交给init进程,此后发送到串行控制台的消息通常不会被保存到文件中。

要查看这些消息,需要直接连接到物理串行端口,或者通过虚拟主机提供商提供的远程控制台访问。这些消息显示了各种守护进程的启动过程,直到系统最终打印出登录提示符,准备接受连接。

登录流程详解

如果你连接到串行控制台,系统会提示你输入用户名。输入用户名并回车后,系统会提示输入密码。提供正确的密码后,系统会记录你的登录信息并启动你的Shell。

以下是登录过程中进程创建的顺序:

  1. init进程由内核显式创建。
  2. 系统通过配置文件/etc/ttys可以配置允许多个终端连接。对于其中的每一个条目,init都会fork一个新进程。
  3. 该新进程会exec getty程序。getty负责配置TTY、读取登录名,然后调用login命令。
  4. 当用户在终端输入用户名时,gettyexec login程序。
  5. 登录成功后,login程序会exec用户的Shell。
  6. 正如我们之前所见,对于在Shell中输入的每个命令,Shell都会fork一个新进程并exec给定的命令。
  7. 当Shell退出时,init会回收该进程,再次fork,并在现在可用的终端上重新exec getty

登录程序的关键操作

登录过程的前几个步骤都以超级用户权限运行。login程序会执行以下关键操作:

  • 关闭终端回显,这样输入密码时就不会显示。
  • 读取密码,计算其哈希值,并与存储在/etc/master.passwd中的哈希值进行比较。
  • 如果验证成功,将登录信息记录到系统数据库中(可通过wwho命令查询)。
  • 读取并可能向用户显示系统文件,例如/etc/motd,允许管理员在登录时向所有用户显示重要消息。
  • 初始化用户的所有补充组成员身份。
  • 将当前工作目录更改为用户passwd条目中列出的目录。
  • 更改终端设备的所有权,使用户可以读写该设备。
  • 最后,在调用Shell之前,将用户ID从0(root)更改为用户的UID。

进程ID与用户ID的变化

我们刚刚看到了进程是如何创建的。现在来看看这些进程的ID和用户ID是如何变化的:

  • init:进程ID为1,父进程ID为0,有效用户ID为0。
  • getty:由init exec,具有未指定的新进程ID,父进程为init,有效用户ID仍为0。
  • login:由getty exec,继承getty的进程ID和父进程ID,有效用户ID仍为0。
  • Shell:由login exec,继承login的进程ID和父进程ID,但有效用户ID已变为用户的UID。
  • 用户命令:由Shell forkexec,获得新的进程ID,以Shell的进程ID作为父进程ID,并以用户的有效用户ID运行。

如果你使用VirtualBox启动带有图形控制台的虚拟机,可以在此虚拟串行控制台上登录,并显示进程树来复现我们刚刚讨论的内容。

例如,我们可能看到:

  • init的进程ID是1。
  • 监听多个串行控制台的getty进程ID是504、606和707。
  • 我们从getty运行的login程序进程ID是519。
  • 我们的Shell进程ID是581。
  • proc tree命令本身的进程ID是401。

总结与过渡

本节中,我们一起学习了系统启动和登录过程如何创建进程,以及进程ID、父进程ID和进程所有权的变化。我们也看到了某些进程是如何被分组在一起的,这暗示了我们将要在下一节视频中讨论的更大主题:进程组和登录会话

此外,我们还看到登录过程可能比最初想象的要复杂一些。它不仅仅是打印提示符、验证密码然后为用户执行Shell那么简单。

为了更深入地理解其中涉及的所有细节,建议你:

  • 查看login程序的源代码(通常位于/usr/src/usr.bin/login)。
  • 复习sh手册页中关于Shell启动及其可能读取的各种配置文件的部分。

希望这些内容能让你有所收获,直到下一节视频发布。

045:进程组与会话 👨‍💻

在本节课中,我们将要学习UNIX系统中进程组和会话的概念。理解这些概念对于掌握进程管理、作业控制和信号分发至关重要。

上一节我们介绍了进程的创建和用户登录流程,本节中我们来看看进程如何被组织成组和会话。

我们直观地理解某些进程是相互关联的,应该形成一个组,例如当我们把一个命令的输出通过管道传递给另一个命令时。接下来,让我们详细了解其工作原理。

什么是进程组? 👥

首先,每个进程,无论它是否是命令管道的一部分,都属于一个进程组。

进程组通常是指属于同一个作业或终端的进程集合。进程组像进程ID一样,通过一个进程组ID来标识。这些进程组ID也是小的非负整数,唯一地标识一个进程组,并且实际上可以存储在 pid_t 数据类型中。

你可以通过调用 getpgrp() 系统调用来获取当前进程的进程组ID,或者使用 getpgid(pid) 来确定任何指定进程的进程组ID。

每个进程组可能有一个进程组组长。这个组长的特点是其进程ID与进程组ID相同。这个组长可以创建一个新的进程组,并在此组内创建进程。

要显式地设置任何进程的进程组,可以调用 setpgid()。当然,这只对当前进程或其子进程有效,除非你是超级用户。

进程组示例 🖥️

让我们看看这在实际中是什么样子。当我们登录时,会发现自己处于一个登录Shell中,准备代表我们执行命令。这个登录Shell将处于它自己的进程组中。

当我们从这个Shell调用一个新命令时,例如这里显示的后台管道 proc1 | proc2,那么管道中的所有命令都将被放入它们自己的进程组中。

由于我们将 proc1 | proc2 管道放入了后台,我们现在可以执行一个新的命令管道 proc3 | proc4 | proc5。这组进程也将被放入它们自己唯一的进程组中。

这意味着,在这种情况下,我们至少有三个进程组

  • 一个用于Shell。
  • 一个用于在后台运行的进程。
  • 一个用于在前台运行的管道。

以这种方式将进程分组是有意义的,因为我们以不同的方式与不同的进程交互。

什么是会话? 📦

但是,所有这些进程仍然以某种方式属于一个整体,对吗?它们都是作为登录Shell的子进程启动的。如果你从终端断开连接,你会发现它们都会被终止,因为我们的登录会话被中断了。而这正是这组进程组的集合——一个会话

你可以通过调用 setsid() 来创建这样一个会话,即一个进程组的集合。当你这样做时,会发生以下情况:

  1. 你成为一个新会话的会话领导者
  2. 你也成为一个新创建的进程组的进程组领导者

也就是说,你从一个干净的状态开始,调用进程将是会话和进程组中唯一的进程。这个新会话也将没有控制终端

因此,如果你希望分配一个控制终端,你必须在System V衍生的UNIX变体中打开一个,或者调用 ioctl() 来请求一个控制终端。设备 /dev/tty 就代表控制终端。

会话结构图 🗺️

这就是我们所理解的会话结构:它包含我们之前看到的所有三个进程组。会话中的不同进程组可以以不同的方式与控制终端交互。

前台进程组可以接收键盘输入以及键盘生成的信号。终端的挂断信号将发送给会话领导者。

我们将在下一个视频片段中看到更多这方面的实例。现在,让我们快速在终端上展示这些组和会话是如何组合在一起的。

终端演示 🧪

以下是演示步骤的总结:

  1. 我们的登录Shell进程ID是843。
  2. 当我们运行后台管道 proc1 | proc2 时,proc1proc2 作为Shell的子进程出现,拥有自己的进程组ID(例如947)。
  3. 当后台进程运行时,我们再运行前台管道 proc3 | proc4 | proc5。现在,proc3proc4proc5 出现在另一个独立的进程组中(例如891),但仍然是进程843的子进程。
  4. 所有这些进程都被分组在同一个会话中,会话ID为843,我们的Shell作为会话领导者。

管道与进程组映射 🔄

让我们再次回顾一下管道中的各个进程如何映射到进程组。这里我们有两个 cat 命令的副本,以便在进程表中更容易区分它们。

过程如下:

  1. 我们从登录Shell(进程ID 265)开始。
  2. 输入命令行后,Shell解析整个命令,然后 fork 一个新进程(PID 296)来执行 ps 命令。
  3. Shell再次 fork,生成PID 689来执行第一个 cat 命令(cat1)。这个进程通过管道连接到 ps 命令(PID 296)。
  4. Shell第三次 fork,创建PID 981来执行第二个 cat 命令(cat2)。这个进程也通过管道连接到 cat1
  5. 父进程(初始Shell)得到通知。

需要注意的是,在 ps 的输出中,我们可能不会立即看到 cat1cat2 出现。这是因为 ps 命令被调用时,cat 程序可能尚未执行。Shell为管道的三个组件 fork 了三个进程,然后 ps 读取进程表,看到这些进程被列为 sh(Shell),之后两个子Shell进程才执行 cat 命令。

所有三个命令都在同一个进程组(组296)中,ps 命令是进程组组长。但它们都与初始Shell一起,处于同一个会话(会话265)中,初始Shell是会话领导者。

总结 📝

本节课中我们一起学习了进程组和会话的核心概念。

  • 除了拥有唯一的进程ID,每个进程都属于恰好一个进程组
  • 多个进程组可以组合成一个会话
  • 这些进程组和会话用于分发信号,从而允许Shell实现作业控制,引入了前台和后台进程组的概念。

前台进程组可以与控制终端交互,但是当后台进程组想要与控制终端通信(无论是读取还是写入数据)时会发生什么?我们将在下一个视频中探讨这个问题。


核心概念公式/代码表示:

  • 获取当前进程组ID:pid_t getpgrp(void);
  • 获取指定进程的进程组ID:pid_t getpgid(pid_t pid);
  • 设置进程组ID:int setpgid(pid_t pid, pid_t pgid);
  • 创建新会话并成为会话领导者:pid_t setsid(void);

046:作业控制 🖥️

概述

在本节课中,我们将学习UNIX环境下的作业控制概念。我们将探讨进程组、会话与控制终端如何交互,以及Shell如何利用这些机制来管理前台和后台任务。


进程组、会话与控制终端回顾

上一节我们介绍了进程组、会话以及它们与控制终端的交互。一个登录会话包含多个进程组,其中前台进程组与控制终端交互,会话领导者则接收来自控制终端的变更通知,例如挂起信号。

作业控制简介

本节中,我们来看看作业控制的具体实现。作业控制最初由C Shell引入,后被Korn Shell采纳,并最终成为标准Bourne Shell的一部分。其设计源于80年代单终端交互的局限性,用户只有一个终端,无法同时运行多个任务。伯克利的Bill Joy(后来共同创立了Sun Microsystems)在C Shell中引入了让Shell暂停当前进程并切换到另一个前台进程的方法。

前台与后台进程组

以下是前台与后台进程组工作方式的对比。

前台进程示例

在Shell中执行命令时,该命令会被放入自己的进程组。例如,Shell(进程ID 41,进程组领导者,会话领导者)执行 ps 命令。Shell会调用 forkexec,然后使用 setpgid 将新进程放入其自己的进程组(例如PGID 753)。ps 完成后退出,其退出状态会保留,直到父进程(Shell)通过 wait 回收它。Shell会阻塞等待子进程终止,然后报告其退出状态。

后台进程示例

使用 & 符号在后台启动命令时,该命令同样被放入其会话内的一个独立进程组。如果此后台命令完成,表面上似乎没有立即发生什么,因为Shell没有阻塞等待它。但当我们按下回车键时,Shell会报告后台进程已完成。这是因为当后台进程终止并生成 SIGCHLD 信号通知父进程(Shell)时,Shell会捕获此信号并调用 wait 获取状态信息。

与控制终端的交互

控制终端允许我们控制前台进程组。登录Shell必须通过调用 tcsetpgrp 来为控制终端设置前台进程组。

输入输出流

来自终端的输入会发送给前台进程(例如 cat)。命令的输出默认发送到终端,除非被重定向到文件。当 cat 等待输入时,它会调用 read 并阻塞,直到我们提供更多数据。

键盘信号

我们可以通过控制终端发送特定键盘序列来影响前台进程:

  • Control-D:发送EOF字符。
  • Control-C:发送 SIGINT 信号,中断程序。
  • Control-Z:发送 SIGTSTP 信号,挂起程序。

终端驱动程序负责将这些键盘序列转换为信号,并发送给前台进程组。

后台进程的终端访问

如果后台进程组尝试进行I/O操作会怎样?

默认行为

如果我们在后台运行 cat file,该进程会向仍连接着控制终端的标准输出写入数据。Shell不会等待该命令终止就打印下一个提示符,导致命令输出可能会干扰当前终端屏幕。

限制后台输出

我们可以配置终端,禁止后台进程随意输出。使用 stty 命令,可以要求终端驱动程序在后台进程尝试生成输出时将其挂起,并发送 SIGTTOU 信号。此后,当后台进程尝试写终端时,它会被挂起,直到我们将其切换到前台。

类似地,如果后台进程尝试从终端读取(SIGTTIN),也会被挂起。Shell处理这些信号,并能在前台和后台之间移动进程组,这就是所谓的作业控制。

Shell作业控制命令实践

大多数Shell都实现了 jobs 内置命令,用于查看当前所有作业的状态。

以下是使用作业控制管理多个任务的示例步骤。

  1. 启动后台作业:运行一个后台管道命令。

    command1 | command2 &
    

    使用 jobs -l 查看,会显示这个包含两个进程的进程组正在后台运行。

  2. 前台工作与挂起:在前台启动一个编辑器(如 vim)。如果想暂时做别的事情,可以按 Control-Z 挂起整个前台进程组(编辑器)。Shell会报告进程已挂起。

  3. 管理多个作业:此时,可以运行其他前台命令,或再次挂起它们。jobs -l 会列出所有作业状态:后台运行的任务、被挂起的编辑器、被挂起的管道。

  1. 恢复作业

    • 使用 bg %作业号 将挂起的作业放到后台继续运行。
    • 使用 fg %作业号 将后台或挂起的作业带到前台。
  2. 向进程发送信号:我们可以使用 kill 命令向任何进程发送信号。

    • 挂起一个后台进程:kill -SIGTSTP %作业号
    • 让一个挂起的进程继续:kill -SIGCONT %作业号
    • 终止一个作业:kill -SIGKILL %作业号
  3. 管道作业的影响:如果终止一个管道中的某个进程,其他进程会受到影响。例如,写入已关闭管道的进程会收到 SIGPIPE 信号;从已关闭管道读取的进程会读到EOF并正常结束。

  1. 返回原有工作:最后,可以使用 fg 将之前挂起的编辑器带回前台,并正常退出。

总结

本节课中我们一起学习了UNIX作业控制的核心机制:

  • 前台和后台进程组都能向登录Shell报告状态变更。
  • 前台进程组可以在控制终端进行I/O。
  • 控制终端能通过键盘交互生成信号(如 Control-C 发送 SIGINTControl-Z 发送 SIGTSTP)发送给前台进程组。
  • 后台进程组在尝试进行终端I/O时可能被挂起,并产生 SIGTTOUSIGTTIN 信号。
  • Shell可以移动进程组到前台或后台、挂起或继续它们,这构成了作业控制。
  • 可以使用 kill 命令向任何进程发送任何信号。

在下一个视频中,我们将更深入地研究信号传递机制以及进程接收到信号时的行为。

UNIX高级编程:P47:信号详解 🚦

在本节课中,我们将深入学习UNIX环境下的信号机制。信号是一种进程间异步通信的基本方式,允许一个进程通知另一个进程某个事件已经发生。我们将探讨信号的产生、发送、接收和处理方式。

上一节我们介绍了终端控制,看到了shell如何通过控制终端与前后台进程组通信。本节中,我们将详细探讨信号这一异步进程间通信机制。

信号是计算机通知进程某事件发生的一种方式。多数情况下,这并非好消息。事实上,进程常因接收到信号而被终止。

但并非总是如此。让我们更仔细地观察一下。

信号是进程获知事件发生的一种方式。信号最重要的特性是异步性不可预测性。你无法预知信号何时发生,甚至不知道它是否会发生。信号可能在任何时间点到达,也可能永不发生。

你已经见过许多例子。至少,你已经无数次使用过 SIGINT 信号。这个信号,连同我们已经见过的其他一些信号,是由终端驱动程序在按下特定键盘组合键时生成的。

其中包括我们上一节讨论过的 Ctrl+ZSIGTSTP)。我们还看到一些信号无需我们干预,由进程自身生成。例如,当后台进程试图在终端进行I/O操作时。

其他可能导致信号产生的情况包括:定时器超时、用户与控制终端断开连接(导致 SIGHUP 被发送给会话首进程)、用户调整窗口大小(要求可视化编辑器重绘屏幕)等等。当然,还有很多其他情况。

你可以在 signal 手册页中找到完整的信号列表。根据你的UNIX版本,细节可能在不同章节描述。

除了键盘组合键,某些硬件异常也会产生信号,例如常见的段错误 SIGSEGV,或除零错误 SIGFPE。还有一些软件条件,例如当你尝试向一个读取端进程已终止的管道写入数据时。

我们可以通过 kill 命令向任何进程发送任何信号。你或许不会惊讶,kill 命令是通过 kill 系统调用来实现的,其原型如下:

int kill(pid_t pid, int sig);

如果你传入一个有效的进程ID(PID),该系统调用的用法很简单。但你可能希望向多个进程发送信号,例如当前进程组中的所有进程。为此,可以传入一个 pid 值为 0

那么,如果传入的进程ID是 -1 会怎样?POSIX标准没有定义此行为,但BSD衍生系统和Linux至少实现了以下逻辑:如果你是超级用户,信号将发送给除某些系统进程(如 initswapper)和当前进程之外的所有进程。这允许你在不注销或终止进程的情况下,将系统带入可调试或重启的状态。

如果你不是超级用户,信号将发送给你拥有的所有进程,除了调用进程本身。

Linux还支持另一种特殊行为:如果你传入一个小于 -1 的负数,信号将发送给该PID绝对值所代表的进程组中的所有进程。注意,这显然不具备可移植性。

最后,值得注意的是,你可以传入 0 作为信号编号(sig 参数)。在这种情况下,kill 系统调用仅检查进程是否存在:如果进程存在则返回 0,否则返回 -1。这样,你可以在不实际发送信号的情况下,轻松检查给定进程是否存在。

当一个信号被递送到你的进程时,你可以做什么?最简单的做法是:在你的代码中什么都不做,这样就会执行该信号的默认行为。

在大多数情况下,默认动作是终止当前进程,但这可能并非你想要的。例如,回想我们第一讲中的简单shell程序。因为我们没有做任何信号处理,如果用户按下 Ctrl+C 发送 SIGINT 给shell,它就会被终止。

因此,一个更好的解决方案可能是明确指示你的代码完全忽略该信号。也就是说,忽略信号需要一个明确的动作

你也可以决定在信号发生时执行其他操作。为此,你需要指定一个要调用的函数,这被称为安装信号处理器

最后,你还可以选择“现在不行”,通过阻塞信号来阻止其被递送。这与忽略信号不同,因为你可以在稍后解除阻塞,然后查看是否有此类信号被递送,并在你准备好时让它执行上述三种动作之一。

😊,我们稍后会看到一些这方面的例子。

那么,我们如何告诉进程我们希望如何处理给定的信号呢?我们可以调用恰如其名的 signal 函数,其函数原型如下所示:

void (*signal(int sig, void (*func)(int)))(int);

顺便说一句,如果你在简历上写“我懂C语言”,那么几乎可以肯定,在面试中会有人要求你解释这个函数原型。这是一个非常流行的问题。你能说出这个函数返回什么,它的参数是什么吗?

如果不能,或者即使你只是想简化一下,你可以使用以下变体:

typedef void (*sighandler_t)(int);

这里,我们将 sighandler_t 类型定义为一个指向函数的指针,该函数接受一个 int 参数并返回 void。有了这个类型定义,你就可以将 signal 函数原型写成如下形式:

sighandler_t signal(int sig, sighandler_t func);

也就是说,signal 函数接受一个整数和一个 sighandler_t 作为参数,并返回一个 sighandler_t。或者更具体地说,它是一个函数,其参数是一个整数和一个指向函数的指针(该指针指向的函数接受一个整数参数并返回 void),而 signal 本身返回一个指向同类函数的指针。

具体来说,它返回的是之前的信号处理器

下面,让我们看一个简单的例子来说明如何调用 signal 函数。

#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void sig_handler(int signo) {
    if (signo == SIGUSR1)
        printf("Received SIGUSR1\n");
    else if (signo == SIGUSR2)
        printf("Received SIGUSR2\n");
}

int main(void) {
    if (signal(SIGUSR1, sig_handler) == SIG_ERR)
        printf("\ncan't catch SIGUSR1\n");
    if (signal(SIGUSR2, sig_handler) == SIG_ERR)
        printf("\ncan't catch SIGUSR2\n");
    printf("My PID is %d\n", getpid());
    while(1)
        pause(); // 等待信号
    return 0;
}

这里我们看到一个函数 sig_handler,它将作为我们的信号处理器。在 main 函数中,我们为 SIGUSR1SIGUSR2SIGINT 信号安装了这个信号处理器,打印当前PID,然后永远暂停,等待信号发送给我们。

在运行这个程序之前,让我们创建第二个窗口,以便在向程序发送信号时观察它。

现在我们的程序以PID 1021运行。让我们使用 kill 命令向它发送 SIGUSR1。成功了。现在发送 SIGUSR2。也成功了。

如果我们发送一个我们没有设置信号处理器的信号会怎样?那么默认动作就会发生。在这种情况下,我们很幸运,SIGINT 的默认动作是什么都不做。如果我们发送 SIGHUP,那么我们的信号处理器将终止程序。

这是一个使用 signal 函数的简单例子。

但你可能已经注意到,signal 是一个库函数,而不是一个系统调用。这意味着有一个系统调用可以用来实现这个函数,那就是 sigaction

sigaction 系统调用允许更全面地处理信号,这是必要的,因为信号由于其异步性可能有点奇怪。例如,当你在一个信号处理器中时,触发此处理器的信号会被阻塞,但另一个信号可能到来,这意味着你会从当前的信号处理器跳转到新信号的处理器。

如果当前被阻塞的任何信号被递送,你可以检查它们,看看是否是这种情况。

如果你完成了信号处理器的执行,触发此处理器的信号会自动解除阻塞。如果你在处理程序执行期间收到了相同的信号,它会在你返回后立即被递送,使你立刻重新进入处理器。

如果在信号被阻塞期间触发了多个相同的信号,你不会在返回后逐个接收它们。相反,它们会被合并成一个信号,然后被递送。

了解了如何改变进程处理信号的方式后,我们还需要考虑当我们 fork 一个新进程或 exec 一个新程序时会发生什么。

由于 fork 创建了当前进程的完整副本,所有已建立的信号处理器或其他处置方式当然会被子进程继承。

但是,如果我们 exec 一个新程序,那么已建立的处置方式会被重置为默认动作,而明确被忽略的信号则继续被忽略。

所有这些考虑因素在你第一次听到时可能会有点令人困惑,所以让我们看一些例子,希望你自己也能重现它们。

我们的第一个程序 signals1.c 有两个信号处理器:sigquitsigintsigquit 会打印一条消息,然后休眠,再打印另一条消息,每次都显示一个全局变量的值。sigint 只是打印一条消息并返回。

main 函数中,我们建立信号处理器,然后休眠,允许用户向我们发送某种信号。当我们运行这个程序时,我们会按 Ctrl+\ 来生成 SIGQUIT,并看到我们进入了 SIGQUIT 信号处理器。

在该信号处理器返回后,我们再次按 Ctrl+\,再次进入信号处理器,然后立即尝试再次按 Ctrl+\。但这一次,什么也没发生,因为 SIGQUIT 当前被阻塞了。但当我们的 SIGQUIT 函数返回时,它会立即有另一个 SIGQUIT 信号被递送,并立即重新进入信号处理器。

如果生成了多个这样的信号,我们仍然只重新进入处理器一次。

现在,让我们再次运行它。我们再次进入 SIGQUIT 处理器。但这一次,当我们在执行 SIGQUIT 处理器时,我们按了 Ctrl+C,导致 SIGINT 被递送,从而中断了我们的 SIGQUIT 处理器。在 SIGINT 返回后,SIGQUIT 打印其消息并返回。

如果我们重新进入 SIGQUIT 处理器,然后递送另一个 SIGQUIT 信号,我们知道它会被阻塞,然后被递送。但如果我们随后按了 Ctrl+C,我们会再次立即跳入 SIGINT 处理器,返回,但现在立即重新进入 SIGQUIT 处理器,因为被阻塞的信号现在被递送了。完成所有这些后,我们退出。

这说明了我们可以在信号处理器内部被中断,以及多个被阻塞的信号会被合并成一个,并在解除阻塞时被递送。

现在让我们看看 signals2.c。这个程序几乎完全相同,只是在这里我们模拟了旧的行为:信号处理器在每次触发时总是将处置方式重置为默认。

其他一切保持不变。运行它,我们再次进入 SIGQUIT 处理器,然后从该信号处理器返回后,我们再次递送相同的信号。但由于我们的信号处理器已将处理器更改为默认,第二个 SIGQUIT 导致 SIGQUIT 的默认动作发生,即中止并产生核心转储。

好的,让我们再试一次。再次进入 SIGQUIT 处理器。现在,用 Ctrl+C 中断该处理器。再次,下一个 SIGQUIT 将中止程序。

现在第三次尝试。像以前一样进入 SIGQUIT 处理器。现在生成几个更多的 SIGQUIT 信号,它们被阻塞。但一旦信号处理器返回,合并的信号被递送。但此时,我们的信号处理器已经将处置方式重置为默认,因此单个合并的信号被递送时再次触发默认动作。

这表明这种行为相当不便。幸运的是,如今信号处理器在信号被递送后仍然保持安装状态。

最后,让我们看一个使用 sigaction 的代码示例。我们的信号处理器本身保持不变,但现在我们初始化一个新的信号掩码,并将 SIGQUIT 添加到其中。

然后我们使用 sigprocmask 明确阻塞该信号。我们允许基于参数 argv[1] 的替代行为来忽略 SIGQUIT。然后检查是否有任何 SIGQUIT 信号被递送。最后,再次解除对该信号的阻塞。

让我们运行这个程序。现在,如果我们按 Ctrl+\,什么也不会发生。由终端驱动程序生成的 SIGQUIT 信号被阻塞。在我们调用 sleep 之后,我们看到有问题的信号被递送了。因此,在解除阻塞后,我们进入了信号处理器,现在它的行为又和以前一样了。

但请注意,我们在解除阻塞信号后立即进入了信号处理器,然后才打印我们已解除阻塞信号的信息。

如果在信号被阻塞期间有信号被递送,但我们在检查未决信号后将处置方式更改为忽略该信号,会发生什么?和之前一样,按 Ctrl+\ 没有任何作用,因为信号被阻塞了。然而,在它被解锁后,我们看到没有发现未决信号。也就是说,将信号处置方式更改为 SIG_IGN 会从待递送队列中移除未决信号。

请务必自己重现代码示例并阅读注释。但现在,让我们快速回顾一下。

信号是异步且不可预测的。它们可能在任何时间点被递送。我们可以允许默认动作发生、忽略信号、安装信号处理器或阻塞信号。

我们看到多个到达的信号可能会被合并,但我们的信号处理器本身也可能被另一个信号中断。

但是,我们在信号处理器内部可以做什么样的事情呢?如果我们能在那里被中断,我们可能必须非常小心我们能做什么和不能做什么。

我们将在下一个视频中讨论这个问题。在那之前,感谢观看。

本节课中我们一起学习了UNIX信号的基本概念、发送方式(kill 系统调用)、处理方式(默认、忽略、捕获、阻塞),并通过代码示例观察了信号处理中的关键行为,如异步性、中断、阻塞与合并。我们还比较了 signal 函数与更强大的 sigaction 系统调用,并了解了信号在 forkexec 时的继承规则。

049:进程间通信简介 🚀

在本节课中,我们将要学习进程间通信的基本概念、分类以及UNIX系统中可用的主要IPC机制。我们将探讨不同IPC方法的特点和适用场景。


在之前的视频中,我们讨论了信号,它是一种进程间通信的方式。本节中,我们来看看进程间通信的更多形式和分类。

进程间通信是我们经常使用而不多加思考的功能,例如当我们运行一个命令并将其输出通过管道传递给第二个命令时。这种数据从一个进程流向另一个进程的形式就是一种IPC。信号则是另一种类型的IPC,用于通知另一个进程某个事件已发生。这两种形式——管道和信号——差异巨大,体现了IPC的不同方面。

IPC的分类 📊

以下是IPC机制的几个关键分类维度:

  • 异步 vs 同步:异步IPC中,通信无需协调即可发生,如信号。发送方发送消息后,接收方可以在稍后时间点接收。同步IPC则需要发送方和接收方协调操作,消息被立即接收。
  • 单向 vs 双向:单向通信只允许一个进程向另一个进程发送消息,接收方无法通过同一通道回复。双向通信则允许双方互相发送消息。
  • 相关进程 vs 无关进程:某些IPC机制要求通信的进程具有亲缘关系(共享同一个祖先进程),例如典型的Shell管道。而其他机制(如信号)允许无关进程间通信,前提是它们具有相同的有效用户ID。
  • 本地 vs 网络:大多数IPC机制仅限于同一主机上的进程。网络通信则需要专门的API,允许不同主机上的进程进行通信。

常见的IPC机制对比 ⚖️

以下是UNIX系统中几种主要IPC机制的简要对比:

  • 文件系统:进程A写入文件,进程B读取文件。这是一种异步、双向的机制,允许相关或无关的本地进程通信,并能传递任意数据,但无法有效通知对方新数据可用。
  • 信号:异步、单向。允许相关或无关的本地进程通信,但传递的信息量和类型非常有限,通常只能表明“条件X发生”。
  • 信号量:异步、单向。允许相关或无关的本地进程通信,但主要用于进程同步(如锁定机制),传递的信息有限。
  • 共享内存与消息队列:异步、双向。允许相关或无关的本地进程通信,并且可以交换任意数据。
  • 管道:同步、单向。要求通信进程具有亲缘关系,仅限于本地,但可以传递任意数据。
  • 命名管道:同步、单向。与管道类似,但允许无关的本地进程通信。
  • 套接字对:同步、双向。允许相关的本地进程通信。
  • 套接字API:同步、双向。功能最全面,不仅支持本地相关或无关进程通信,更重要的是支持网络通信,是构建互联网各种服务(如基于TCP/IP的消息队列服务)的基础。

本节课中我们一起学习了进程间通信的基本概念和UNIX系统中的主要IPC机制。我们了解了IPC可以根据通信的协调性、方向性、进程关系以及网络支持进行分类。从简单的文件、信号,到管道、共享内存,再到强大的套接字,每种机制都有其特定的用途和限制。在接下来的视频中,我们将首先深入探讨System V IPC,包括信号量、共享内存和消息队列。

050:System V IPC 🧩

在本节课中,我们将深入学习System V IPC(进程间通信)的三种核心机制:信号量、共享内存和消息队列。这些机制是早期UNIX系统实现进程间通信的基础,至今仍被广泛支持。

上一节我们介绍了IPC的基本概念,本节中我们将详细探讨System V IPC的具体实现。

System V IPC概述

System V IPC之所以得名,是因为这三种进程间通信机制最早于20世纪80年代在AT&T的UNIX System V中引入。与此同时,BSD系统则专注于套接字API。

这三种机制包括:

  • 信号量
  • 共享内存
  • 消息队列

如今,大多数UNIX系统都支持这些机制。有趣的是,消息队列的概念甚至被移植到了跨网络系统中,并被一些大型云计算提供商作为服务提供(当然,这些服务是构建在套接字API之上的)。

System V IPC机制使用特定的内核结构,通过标识符和键来引用这些内部资源。因此,它们显然仅限于同一系统上的进程间通信。

一个可能令人惊讶的特点是,这些资源是持久化的。这意味着即使创建或访问它们的进程已经终止,这些内核结构仍然存在。我们稍后会通过示例看到这一点。

由于这些结构只存在于内核空间,通常不在文件系统中体现,因此我们不能使用常规的文件描述符API来访问它们。相反,我们需要特殊的系统调用和命令行工具来操作它们。

信号量 🔒

信号量本质上是一种用于访问临界区的锁或协调机制。在操作系统课程中讨论并发和哲学家就餐问题时,你应该已经熟悉它了。

在System V的实现中,信号量是一个简单的计数器,内核为其提供特定的原子操作和保证。例如,为了获取共享资源(如拿起第二根筷子),你需要测试控制该资源的信号量,检查计数器是否大于0。如果是,则递减计数器,使用资源(拿起筷子吃面),然后在使用完毕后递增计数器。如果信号量的值为0,则进程进入睡眠状态。测试、递增、递减或阻塞直到某个值等操作序列都是原子性的,这正是信号量的意义所在。

以下是用于操作信号量的函数:

  • semget: 获取信号量集标识符。
  • semop: 对信号量执行操作(如P/V操作)。
  • semctl: 控制信号量(如初始化、删除)。

让我们看一个示例程序 sem_demo

我们首先调用 ftok 库函数来创建一个用于标识信号量的键。该函数结合给定路径名的inode和设备ID以及传入的ID,生成一个适合System V IPC系统调用的标识符,从而避免了硬编码键值。

接下来,我们调用 init_sem 来初始化信号量,这是一个处理边缘情况的自定义函数。代码中的注释应该足以帮助你理解。

然后,程序请求用户获取锁,之后调用 semop。这个调用执行信号量的测试和后续递减操作,并在获取信号量之前阻塞。之后,用户可以再次解锁。为了避免死锁,内核会在进程终止时释放锁。但作为良好的编程习惯,我们在完成后也会主动解锁信号量。

运行这个程序,我们会立即获得信号量,因为当前没有其他进程尝试获取它。在解锁之前,我们启动第二个进程尝试获取锁。

第二个进程现在会阻塞,因为信号量当前被第一个进程持有。当我们解锁第一个进程后,第二个进程获得锁并可以进入其临界区。

同样,在左侧再次运行程序显示,该进程现在被阻塞,直到第二个进程释放其锁。

我们之前提到System V IPC结构是持久化的。内核已经根据我们的键创建了信号量集,我们应该能够通过运行 ipcs 命令来检查它。这里我们看到信号量集具有关联的权限和所有权,就像文件一样,从而允许根据需要控制用户访问。

让我们通过切换到另一个用户 fred 来说明这一点。注意,fred 可以运行程序并获取信号量,因为信号量集的权限(如左侧所示)允许。两个进程竞争锁的行为与之前完全相同。

内核分配的信号量集对所有用户可见。但是,非信号量集所有者的用户不能删除它。fred 可以看到信号量集和权限,但不能通过标识符删除它。然而,所有者可以。

共享内存 💾

现在让我们考虑数据在常规IPC中的流动方式。

这里有一个简单的Shell管道示例,一个进程从文件读取数据,通过管道传递给另一个进程,后者再将数据写入另一个文件。在这个过程中,数据需要在用户空间和内核空间之间多次穿越边界。

当我们调用 cat input 时,进程必须从磁盘获取数据,这意味着穿越用户空间和内核空间的边界。但IPC本身也发生在内核空间。在将所有数据从内核空间“铲”到用户空间后,又需要将其传输回内核空间,然后在接收端再次传回用户空间,我们的第二个 cat 命令将数据写入磁盘,再次从用户空间进入内核空间。

因此,我们穿越用户空间-内核空间边界四次,这样做通常开销很大。也许我们可以想出一种更高效的方法。

这就是共享内存。在这种模型中,两个进程都访问内存中的某个区域,这意味着我们可以减少数据必须从用户空间进入内核空间的次数。考虑到这种改进,共享内存是进程间通信最快的形式也就不足为奇了。

由于我们共享一个内存区域,保护对该区域的访问以避免两个进程相互覆盖数据可能是有意义的。而使用信号量是实现这一点的好方法。请注意,如果你通过某种其他约定的访问方式进行严格的顺序访问,这可能不是必需的,但对于可能的并发写入,你需要某种锁定机制,信号量很适合于此。

要获取共享内存段,你需要使用 shmget。然后使用 shmat 将段附加到进程地址空间,使用 shmdt 分离,使用 shmctl 进行其他操作。

让我们再看一个实际例子。与信号量示例类似,我们首先使用 ftok 确定一个合适的标识符,然后获取一个新的共享内存段(如果不存在则创建,类似于用 O_CREAT 标志调用 open 创建新文件)。我们附加内存段,然后从该内存区域读取或写入数据。和往常一样,我们在退出前进行清理,这次是分离内存段。

运行程序时,我们首先将一些数据写入共享内存段。注意,现在我们的进程终止了,但数据仍然存储在内存段中。ipcs -m 可以显示有关此内存段的信息,特别是我们可以像检查信号量一样检查权限和所有权。我们看到创建段的进程ID、最后访问它的进程ID,以及通过 shmctl 最后附加、分离和更改段的时间戳。

如果我们在没有参数的情况下再次运行该命令,它将简单地从内存段中检索数据。但请注意,数据是持久化的,因此第二次调用将再次显示相同的结果。也就是说,共享内存确实就像一个常规文件,附加和分离时间的时间戳是确定最后访问时间的一种方式。

同样,我们可以允许其他用户访问数据。fred 当然可以从共享段中检索信息,也可以向段中写入数据,因为我们创建它时授予了其他用户写入权限。输出字段显示最后访问共享内存段的进程ID。

与信号量一样,由于段是持久化的,用户有责任删除任何未使用的映射。如果你尝试从没有数据的段中读取数据,由于我们的程序创建了一个全新的段,你只会得不到任何数据。

由于我们讨论的是存储在内存中的数据,我们应该能够观察到内存段的地址。因此,如果我们更新第6周的内存布局程序,可以观察到共享内存段空间似乎出现在栈下方和堆上方的某个位置。共享内存的大小限制是什么,以及当你试图耗尽它们时会发生什么,这是一个留给你在课后完成的练习。

消息队列 📨

现在让我们继续讨论消息队列。顾名思义,消息队列是消息的链表。更具体地说,该列表是一个FIFO(先进先出)队列,即消息按顺序排列,并且只能按指定顺序消费。

正如我们在本视频中讨论的其他形式的System V IPC一样,它们也存储在内核空间中,并遵循一些相同的语义。

你通过 msgget 创建新的消息队列,通过 msgrcv 从队列中消费消息,并通过 msgctl 控制其他属性。

那么,消息到底是什么?这实际上取决于用户。你可以自己定义消息的结构,唯一的规定是第一个元素必须是定义消息类型的 long,这有助于确定消息的传递。更多细节请参阅手册页。在我们的示例中,我们将使用一个非常简单的结构,仅传递几个字节的文本。

因此,对于我们的示例,我们需要两个程序:一个发送消息,一个接收消息。最终消息是一个简单的文本消息。我们要求用户提供键标识符,而不是像之前那样使用 ftok。这只是为了说明我们可以使用任何整数作为标识符,并允许用户通过命令行指定不同的消息队列。

消息队列的创建与其他System V IPC示例的语义基本相同。然后我们构建要传递的消息,并以非阻塞方式将其提交到队列中。我们编译它,然后查看接收程序。

接收程序看起来没有太大不同。我们在这里所做的就是从队列中获取消息,打印文本,然后退出。让我们创建一个新队列并提交一条消息,比如“hello”。ipcs -q 向我们显示所有详细信息,包括队列中的字节数、消息数、最后访问它的进程ID、最后从中读取的进程ID,以及队列创建、数据提交和最后读取数据的时间。

由于这是一个异步队列,我们可以发送多条消息,而不会覆盖先前的数据(这与共享内存示例不同)。我们可以观察到队列状态反映了这一点。

当我们运行接收程序时,它将获取队列中的第一条消息(本例中为“hello”)。ipcs -q 显示更新后的信息。

和之前一样,我们也允许多个用户访问此队列。让 fred 也这样做。fred 运行接收程序并获取队列中的下一条消息,这是可行的,因为创建队列时为其他用户设置了读取权限。

现在我们的队列已清空。当我们尝试运行接收程序时,调用将阻塞,因为没有消息存在。看看 fred 是否能解除我们的阻塞。但不幸的是,fred 救不了我们,他没有写入队列的权限,只能读取。所以让 fred 读取。现在我们有两个进程从队列中读取。

让我们创建另一个Shell,从那里我们可以向队列发送消息。当前被阻塞的两个进程中,哪一个会收到这条消息,是 fred 还是另一个进程?fred 仍然被阻塞,但另一个进程现在解除了阻塞。这表明队列的消费者即使在阻塞时也按顺序等待。

我们现在可以通过向队列发送新消息来解除可怜的 fred 的阻塞。现在我们的队列又空了。

如果我们尝试从不同的队列读取会发生什么?毫不奇怪,这不会阻塞,它会直接失败,因为具有该键的队列不存在。再次清理我们的IPC资源与之前相同。

正如你在这里看到的,消息队列看起来确实很巧妙。请记住,它们最初是为了克服当时唯一可用的其他IPC机制(半双工管道,我们将在下一个视频中介绍)的局限性而创建的。

然而,在现代系统中,UNIX管道和套接字API缩小了性能差距。但如果你想实现真正的应用程序,你需要一些额外的功能,包括表达消息优先级的方式。

因此,POSIX消息队列诞生了。它们在语法和语义上与System V消息队列相似,但现在已标准化,并提供了一些额外的功能。首先,我们不再需要使用 ftok 或类似的键,因为我们现在可以通过名称来标识消息队列。这些命名的消息队列现在可以暴露在文件系统中,这很方便。我们允许阻塞和非阻塞通信。我们允许通过分配更高的优先级来跳过队列并将消息放在头部。最后,我们提供了一种方式,让你的进程可以收到新消息的通知,而不是坐在那里等待。这特别有用,也许最好用另一个例子来说明。

好的,这里我们有我们的 posix_mq_sender 示例。我们将指定一个名称而不是键,该名称遵循路径名的语义,即使它不一定在文件系统中表示。我们打开消息队列进行写入,然后发送所有具有相同默认优先级的给定消息。之后,我们还发送了一条更高优先级的消息。它应该排在其他消息之后,但可能被另一个进程在其他消息之前接收。然后我们关闭队列并退出。

接收程序看起来像这样。在 main 函数中,我们注册一个信号处理程序来设置一个标志,表示有新消息到达。我们打开消息队列,然后通过调用 mq_notify 将其配置为在空队列中有新消息到达时向我们发送信号。我们暂停,每当收到信号时,我们清空消息队列,迭代队列中的所有消息并打印它们。

编译我们的发送者和接收者时,我们必须将它们链接到实时库,因为这是提供POSIX消息队列接口的库。接收者在没有消息存在时会阻塞,因为我们正在暂停等待消息到达。

如果我们现在发送几条消息,我们将观察到接收者在被通知后唤醒。但即使我们的消息是按顺序传递的,接收者也会将最后发送的更高优先级的消息视为要从队列中获取的第一条消息。如果我们在消息提交之间等待一秒钟,我们会看到接收者在消息到达时一条一条地获取它们,因为它为每个传入消息接收一个新信号。如果我们再次运行该命令,我们可能会观察到传入消息的通知发生得足够快,以至于接收者处理它们时,更高优先级的消息只最后到达。如果你使用等待标志运行接收者,它将允许所有消息到达,然后再获取它们,再次将更高优先级的消息显示为第一条消息(如果队列之前没有被清空的话)。

总结 📝

我们快速浏览了System V IPC。让我们在这里回顾一下。

我们在这里看到的所有IPC形式都是异步的,并且只适用于同一系统上的进程。System V IPC是最古老的IPC形式之一,但绝非过时。

信号量的使用主要是为了保护临界区和协调对共享资源的访问。共享内存允许非常快速的IPC。消息队列,虽然在System V incarnation中可能不常见,但作为一个概念,在过去十年左右变得相当流行,因为它们实现了生产者和消费者交换消息的常见生产者-消费者模型。

构建在其他形式的IPC之上(我们将在未来的视频中看到),它们可以作为服务由各种提供商提供,并在不同的软件栈中实现,包括开源和专有。因此,它们绝对是值得理解的有用技术。

本节课中我们一起学习了System V IPC的三种核心机制:信号量、共享内存和消息队列。我们了解了它们的基本概念、使用方法和特点。在下一个视频中,我们将讨论另一种最古老且仍然最普遍的IPC形式:UNIX管道。

051:管道与FIFO 🚰

在本节课中,我们将学习UNIX系统中两种最古老且最常用的进程间通信(IPC)机制:管道(pipe)和命名管道(FIFO)。它们是UNIX哲学中“构建小型工具,处理文本流”理念的基础。

管道(Pipe)简介

上一节我们讨论了System V IPC,本节中我们来看看日常使用最频繁的IPC形式——管道。管道是一种内核数据结构,它通过提供一个读端和一个写端来实现单向通信。

创建管道的系统调用是 pipe,其函数原型如下:

int pipe(int pipefd[2]);

调用 pipe 后,数组 pipefd 中的两个文件描述符将分别连接到新创建管道的读端(pipefd[0])和写端(pipefd[1])。但管道本身并不能直接用于进程间通信,因为它只存在于单个进程的上下文中。

使用管道进行进程间通信

为了进行进程间通信,我们需要另一个进程。这通常通过 fork 系统调用来实现。fork 创建的子进程是父进程的近乎完全相同的副本,因此它也继承了管道的文件描述符。

然而,管道是单向的,因此父子进程必须约定好谁负责读、谁负责写。按照惯例,每个进程会关闭它不使用的那个文件描述符端。例如,父进程关闭读端,只保留写端;子进程关闭写端,只保留读端。这样就建立了一个从父进程到子进程的单向通信通道。

以下是使用管道通信的一个简单示例,它模拟了 cat 命令通过管道传输数据:

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>

int main() {
    int pipefd[2];
    char buf[100];
    pid_t pid;

    pipe(pipefd); // 创建管道
    pid = fork(); // 创建子进程

    if (pid > 0) { // 父进程
        close(pipefd[0]); // 关闭读端
        printf(“Parent (PID: %d) sending message.\n”, getpid());
        write(pipefd[1], “Hello from parent!\n”, 20); // 写入管道
        close(pipefd[1]); // 关闭写端
        wait(NULL); // 等待子进程
    } else if (pid == 0) { // 子进程
        close(pipefd[1]); // 关闭写端
        printf(“Child (PID: %d) reading message.\n”, getpid());
        read(pipefd[0], buf, 100); // 从管道读取
        write(STDOUT_FILENO, buf, strlen(buf)); // 输出到标准输出
        close(pipefd[0]); // 关闭读端
    }
    return 0;
}

缓冲与执行顺序

运行上述程序时,你可能会注意到输出的顺序与代码顺序不一致。这是因为 printf 使用的是带缓冲的I/O,而 write 是无缓冲的。当标准输出连接到终端时,它是行缓冲的;但当它连接到管道时,会采用默认的完全缓冲模式,导致数据刷新时机不同。

此外,fork 之后,子进程和父进程的执行顺序是不确定的。但在我们的例子中,通信逻辑强制了某种顺序:子进程必须等待父进程写入数据后才能读取,而父进程在写入数据后,通过 wait 调用显式等待子进程完成。

如果父进程不等待子进程(注释掉 wait(NULL)),父进程可能先于子进程退出。此时,子进程会成为孤儿进程,并被 init 进程(PID 1)接管。这解释了为什么子进程有时会报告其父进程PID为1。

与外部程序通信:popen

通常,我们不想自己实现管道两端的代码,而是希望与另一个完全独立的程序通信。例如,执行一个外部程序并将数据通过管道传递给它,让它从标准输入读取数据。

UNIX C库提供了一个便捷的函数 popen 来处理这种常见场景。popen 会创建一个管道,启动一个shell来执行给定的命令,并返回一个文件流(FILE*),供你读取(如果命令输出)或写入(如果命令输入)。

popen 的函数原型如下:

FILE *popen(const char *command, const char *type);

参数 type 指定了管道的方向:”r” 表示从命令读取,”w” 表示向命令写入。

以下是一个使用 popen 将文件内容通过管道传递给分页程序(如 more)的示例:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    FILE *fp, *pager_fp;
    char buf[1024];
    char *pager = getenv(“PAGER”); // 从环境变量获取分页程序
    if (!pager) pager = “/usr/bin/more”; // 默认使用 more

    if (argc != 2) {
        fprintf(stderr, “Usage: %s <file>\n”, argv[0]);
        exit(1);
    }

    fp = fopen(argv[1], “r”);
    if (!fp) {
        perror(“fopen”);
        exit(1);
    }

    // 使用 popen 打开分页程序,准备向其写入数据
    pager_fp = popen(pager, “w”);
    if (!pager_fp) {
        perror(“popen”);
        exit(1);
    }

    while (fgets(buf, sizeof(buf), fp) != NULL) {
        fputs(buf, pager_fp); // 将文件内容写入管道,即分页程序的标准输入
    }

    fclose(fp);
    pclose(pager_fp); // 关闭管道,等待命令结束
    return 0;
}

重要安全警告popen 通过shell执行命令,这意味着如果命令字符串来自不可信的输入(如用户),则可能造成命令注入漏洞。攻击者可以通过注入shell元字符(如 ;|&)来执行任意命令。因此,在生产代码中应避免使用 popen,或对其输入进行严格的验证和清理。一些系统(如NetBSD)提供了更安全的变体 popenve

命名管道(FIFO)

管道只能在有亲缘关系的进程(如父子进程)之间使用。如果希望两个完全不相关的进程进行通信,可以使用命名管道,也称为FIFO。

FIFO在文件系统中以一个特殊类型的文件(类型为 p)存在,因此任何知道其路径的进程都可以打开它进行读写。数据通过FIFO流动,但并不会实际存储到磁盘上。

使用 mkfifo 命令或 mkfifo() 系统调用可以创建FIFO:

$ mkfifo myfifo

或者用C代码:

#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);

FIFO的一个典型用途是“分流”(tee)数据。假设你想将一个命令的输出同时发送给两个不同的程序处理。普通的管道无法做到这一点。但你可以结合 tee 命令和FIFO来实现:

  1. 创建一个FIFO:mkfifo mypipe
  2. 启动第一个消费者进程,从FIFO读取:consumer1 < mypipe &
  3. 使用 tee 命令将原始命令的输出同时写入标准输出和FIFO:producer | tee mypipe | consumer2

这样,producer 的输出会同时传递给 consumer2 和从FIFO读取的 consumer1

管道与FIFO的行为细节

以下是关于管道和FIFO的一些关键点总结:

  • 缓冲:连接到管道的标准I/O流不是行缓冲,而是完全缓冲。
  • 多读者/写者:理论上,通过多次 fork,一个管道可以有多个读者或写者。管道保证小于 PIPE_BUF 大小的写入是原子的,不会与其他写入者的数据交错。但这种情况很少见,通常更好的设计是使用多个管道或其他IPC。
  • 关闭管道端
    • 如果写端被关闭,读者在读完所有数据后,下一次 read 将返回0(EOF)。
    • 如果读端被关闭,写者尝试写入时,内核会向其发送 SIGPIPE 信号(默认行为是终止进程)。如果信号被捕获或忽略,则 write 调用会失败,并设置 errnoEPIPE

总结

本节课中我们一起学习了UNIX中两种基础的进程间通信机制:

  1. 管道(Pipe):用于有亲缘关系进程间的单向通信。它是UNIX工具链和过滤器模式的核心。
  2. 命名管道(FIFO):以文件形式存在于文件系统中,允许任意进程间进行通信,常用于构建复杂的数据流处理管道。

我们了解了它们的基本用法、缓冲特性、执行顺序问题,以及使用 popen 与外部程序交互的便利性与安全风险。管道和FIFO是理解更高级IPC机制(如下节课将介绍的套接字)的重要基础。

UNIX高级编程:P52:探索 /usr/share/doc 文档目录 📚

在本节课中,我们将一起探索 NetBSD 操作系统 /usr/share/doc 目录下的历史文档资源。这些文档包含了大量经典的 Unix 研究论文和补充资料,尽管年代久远,但其核心概念至今依然适用。


上一节我们介绍了 Unix 系统的历史背景,本节中我们来看看系统自带的宝贵文档资源。NetBSD 作为一个类 Unix 操作系统,附带了许多历史性的 Unix 研究论文和补充文档。这些文档大多在近 40 年后的今天仍然完全适用。

文档被安装在 /usr/share/doc 目录下。以下是该目录下的主要结构:

  • papers/:存放已发表的研究论文。
  • psd/:存放程序员补充文档。
  • ref/:存放参考文档。
  • smm/:存放系统管理员手册。
  • usd/:存放用户补充文档。

让我们具体看看其中一些有代表性的文档。首先是一篇由 Marshall Kirk McKusick 在 1984 年撰写的关于快速文件系统的论文。这些文档使用了系统排版工具进行格式化,因此需要使用合适的阅读器来查看。我们可以使用 more 命令来正确显示:

more /usr/share/doc/papers/ffs.ascii

这篇论文详细阐述了 FFS,即我们一直在讨论的默认文件系统。这些论文格式精良,在 NetBSD 系统上无需图形界面或借助谷歌等外部工具即可轻松阅读。

你会发现许多作者的名字都非常熟悉。例如,这里有一篇关于密码安全的论文,作者是 Ken Thompson 和 Robert Morris Sr.。后者后来成为了美国国家安全局的首席科学家,而他的儿子 Robert Tappan Morris 则发布了互联网上第一个计算机蠕虫病毒——莫里斯蠕虫。


在文档目录中,我们还能找到 Stephen Bourne 撰写的《Unix Shell 介绍》。Stephen Bourne 正是 Bourne Shell 的作者。这份文档涵盖了 Shell 的所有实用功能,包括输入输出重定向、管道和过滤器等核心概念。

例如,管道的基本形式是:
command1 | command2
这表示将 command1 的输出作为 command2 的输入。

此外,目录中还有 VI 编辑器的参考手册,以及经典的 BSD IPC 教程。这些教程详细讨论了我们已覆盖和即将在后续视频中讲解的进程间通信内容。这两份文档也可以从我们的课程网站以 PDF 格式获取。

管道现在对你来说应该很熟悉了。在高级 IPC 教程中,涵盖了套接字对和套接字,这将是我们下一个视频的主题。

接着是《伯克利软件架构手册》,它描述了所有的系统调用和内核的整体架构。


在用户补充文档目录中,我们可以找到 Brian Kernighan 编写的《Unix 初学者指南》,以及一份《VI 快速参考》。这份参考回答了“如何退出 VI”这个看似棘手的问题,并介绍了如何移动光标、进行操作以及更高效地使用 VI。

同时,还有一份由 VI 的创作者 Bill Joy 编写的更详细的教程。所有这些文档都位于 /usr/share/doc 目录中。由于我们拥有操作系统的源代码,因此也能查找到这些文档的来源。README 文件列出了所有文档的位置,并让你了解还有哪些其他文档可用,但同时也指出部分历史文档可能缺失。


那么,为何不亲自花点时间探索一下这个目录呢?里面包含了大量有用的信息,你会发现其中很多内容特别适用于我们的课程。

正如我们在介绍课中讨论过的,Unix 最初的主要用途之一就是排版软件手册。因此,这些文档使用了 Unix 系统上所有现成的工具也就不足为奇了。特别是 roff 工具集,它是原始 runoff 文本格式化工具的 Unix 版本。

你可以查看 troff 格式的源文件,然后使用如下命令将其转换为格式化文档:

troff -Tascii document.tr | less

本节课中我们一起学习了如何定位和利用 /usr/share/doc 目录下的历史 Unix 文档。这些文档是理解 Unix 系统设计哲学和核心技术的宝贵第一手资料。希望你能花时间浏览这些文档,你会发现我们在后续视频中讨论的许多内容,都能在这些 IPC 教程中找到熟悉的影子。

下次见,感谢观看。

053:socketpair系统调用 🧩

在本节课中,我们将学习socketpair系统调用,并将其与上一节讨论的pipe系统调用进行比较。我们将探讨如何使用socketpair创建一对已连接的套接字,以实现进程间的双向通信。

概述

上一节我们介绍了使用pipe进行单向进程间通信。本节中,我们来看看socketpair系统调用,它提供了一种创建双向通信通道的便捷方法。

socketpair系统调用创建一对已连接的套接字,允许在两个方向上进行数据流通信。其使用方式与管道类似,但天生支持双向通信。实际上,现代Unix系统中的管道通常就是使用socketpair实现的。

socketpair与pipe的对比

以下是socketpairpipe的核心区别:

  • pipe:创建一个单向通信通道。需要两个文件描述符(一个用于读,一个用于写)。要实现双向通信,必须创建两个管道。
  • socketpair:创建一对已连接的套接字,两个文件描述符都可以用于读取和写入,天生支持双向通信。

socketpair的函数原型如下:

int socketpair(int domain, int type, int protocol, int sv[2]);

其中:

  • domain:指定通信域。对于socketpair,通常使用AF_UNIX(或PF_LOCAL)表示Unix域(本地)套接字。
  • type:指定套接字类型,如SOCK_STREAM(流式)或SOCK_DGRAM(数据报式)。
  • protocol:通常指定为0,让系统为给定的域和类型选择默认协议。
  • sv:一个包含两个整数的数组,用于接收新创建的两个套接字文件描述符。

调用成功后,sv[0]sv[1]就是一对可以双向通信的套接字描述符。

使用流程

socketpair的典型使用流程与pipe相似,但更简洁:

  1. 调用socketpair创建一对套接字。
  2. 调用fork创建子进程。
  3. 父进程和子进程分别关闭自己不需要的那个文件描述符(虽然每个描述符都可读可写,但通常每个进程只保留一个用于通信)。
  4. 现在,父进程和子进程可以通过各自保留的文件描述符进行读写操作,实现双向通信。

代码示例

以下是一个使用socketpair的简单示例程序,改编自BSD IPC教程:

#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main() {
    int sockets[2];
    char buf[1024];
    pid_t pid;

    // 1. 创建一对Unix域流式套接字
    if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) {
        perror("socketpair");
        return 1;
    }

    // 2. 创建子进程
    if ((pid = fork()) < 0) {
        perror("fork");
        return 1;
    }

    if (pid != 0) { // 父进程
        close(sockets[1]); // 关闭子进程将使用的描述符

        const char *msg = "Hello from parent!";
        write(sockets[0], msg, strlen(msg)); // 向子进程发送数据

        ssize_t n = read(sockets[0], buf, sizeof(buf) - 1); // 从子进程读取数据
        if (n > 0) {
            buf[n] = '\0';
            printf("Parent received: %s\n", buf);
        }
        close(sockets[0]);

    } else { // 子进程
        close(sockets[0]); // 关闭父进程将使用的描述符

        const char *msg = "Hello from child!";
        write(sockets[1], msg, strlen(msg)); // 向父进程发送数据

        ssize_t n = read(sockets[1], buf, sizeof(buf) - 1); // 从父进程读取数据
        if (n > 0) {
            buf[n] = '\0';
            printf("Child received: %s\n", buf);
        }
        close(sockets[1]);
    }
    return 0;
}

程序执行流程如下:

  1. 调用socketpair创建一对套接字。
  2. 调用fork创建子进程。
  3. 父进程关闭sockets[1],向sockets[0]写入数据,然后从sockets[0]读取数据并打印。
  4. 子进程关闭sockets[0],向sockets[1]写入数据,然后从sockets[1]读取数据并打印。

注意:父进程和子进程几乎同时开始发送数据,执行顺序没有保证,这属于典型的并发编程场景。

练习与思考

在继续学习通用套接字之前,可以尝试用以下问题巩固理解:

  • 如果只有pipe而没有socketpair,如何修改上一节的管道示例程序,以实现本节socketpair示例相同的双向通信功能?(提示:需要在fork前创建两个管道)。
  • 尝试修改socketpair调用中的type参数(如尝试SOCK_DGRAM),观察程序行为有何变化。
  • 修改示例程序,让父进程和子进程使用第二个连接(即各自保留的那个描述符)也交换一次数据。
  • 如果改变两个进程中readwrite的调用顺序,会发生什么?

总结

本节课中我们一起学习了socketpair系统调用。我们了解到,socketpair是创建进程间双向通信通道的一种高效方法,它创建一对已连接的套接字,简化了双向通信的设置。我们将其与pipe进行了对比,并通过一个代码示例演示了其基本用法。下一节,我们将探讨更通用的套接字概念,特别是本地域(Unix域)套接字。

054:本地域套接字(socket) 🖥️

概述

在本节课中,我们将学习UNIX/Linux系统中的本地域套接字。我们将了解如何创建和使用套接字进行同一系统内的进程间通信,并探索其核心概念和API。


套接字创建与基本概念

上一节我们介绍了管道,本节中我们来看看常规的套接字,特别是在UNIX或本地域中的套接字。

再次依赖系统提供的BSD IPC教程,将有助于深入理解套接字API。

要创建一个套接字,我们使用名为socket的系统调用。这将创建一个通信端点,并以文件描述符的形式返回一个标识符。

套接字可以在不同的域中创建,域是一个地址或命名空间,从中可以获取合适的套接字名称。这个域定义了某些通信属性,并对通信内部的具体实现提供了一个有用的抽象层。

套接字API的一个重要特性是,它允许你实现进程间通信逻辑,这些逻辑对于在同一系统上通信的进程和跨网络通信的进程来说基本相同。在这两种情况下,你都可以通过调用socket系统调用来开始。

此外,套接字根据用户进程在给定域中与套接字的交互方式进行类型化。

最后,用户可以选择特定的协议,即进一步管理通信细节的一组规则。通常每种套接字类型对应一个协议,在大多数情况下,用户只需让内核为选定的套接字域和类型选择适当的默认协议即可。这可以通过为协议参数指定0来实现。

以下是可选的域,具体取决于操作系统和版本,但至少应支持以下域:

  • PF_LOCAL:本地域。这以前被称为UNIX域。不同的域过去使用AF前缀表示地址格式。现在使用的PF前缀代表协议族。此域用于同一系统内的通信,套接字使用标准路径名命名。我们稍后会看到,这种类型的套接字确实会以socket类型的文件形式出现在文件系统中,并被通信进程用作会合点。
  • PF_INET:如果我们希望通过网络进行通信,可以在此域中创建套接字。
  • PF_INET6:用于通过IPv6通信。

你的系统可能还支持其他几个域。请查阅手册页以及socket.h头文件以获取详细信息。


套接字类型

就像有多个域一样,也有多种套接字类型。

以下是主要的套接字类型:

  • 流式套接字:提供有序、可靠、基于连接的双向字节流。对于网络通信,典型的例子是TCP
  • 数据报套接字:支持双向、无连接、不可靠的消息。对于网络通信,明显的例子是UDP
  • 原始套接字:允许调用进程访问底层通信协议。例如,如果你想发送ICMP数据包,就需要使用原始套接字。原始套接字仅对超级用户可用。

系统可能定义其他套接字类型,例如用于顺序数据包流或面向连接的数据报的类型。和之前一样,请查阅你的手册页和系统头文件以了解支持哪些类型。


实践:本地域数据报套接字示例

理论讲得够多了,让我们看看在实践中使用套接字是什么样子。

我们将再次遵循系统提供的BSD IPC教程。类似于我们在第08周处理消息队列的方式,我们现在有独立的发送方和接收方程序,从而说明我们可以在不相关的进程之间进行通信。

以下是我们的接收方程序(reader):

// 创建本地域的数据报套接字
int sockfd = socket(PF_LOCAL, SOCK_DGRAM, 0);

// 填充结构体 sockaddr_un,包含族和路径
struct sockaddr_un addr;
addr.sun_family = PF_LOCAL;
strcpy(addr.sun_path, "/tmp/mysocket");

// 调用 bind,将名称分配给给定的套接字
bind(sockfd, (struct sockaddr *)&addr, sizeof(addr));

// 使用 read 系统调用操作套接字的文件描述符,读取客户端发送给我们的任何数据
char buffer[256];
read(sockfd, buffer, sizeof(buffer)-1);

// 之后,我们再次关闭套接字,就像关闭文件描述符一样。但在退出之前,我们还需要取消链接我们创建的套接字文件。
close(sockfd);
unlink("/tmp/mysocket");

我们将此程序编译成名为reader的可执行文件。

发送方程序(sender)的设置与接收方非常相似:

// 在本地域中创建一个数据报套接字
int sockfd = socket(PF_LOCAL, SOCK_DGRAM, 0);

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/apue/img/7dec439671970a740c531f7df7a3dd9f_7.png)

// 再次填充结构体 sockaddr_un
struct sockaddr_un addr;
addr.sun_family = PF_LOCAL;
strcpy(addr.sun_path, "/tmp/mysocket");

// 然后使用 sendto 系统调用将数据发送给我们的接收方
char *message = "Hello from sender!";
sendto(sockfd, message, strlen(message), 0, (struct sockaddr *)&addr, sizeof(addr));

// 完成后,关闭套接字并退出
close(sockfd);

我们将此程序编译成名为sender的可执行文件。

现在,我们启动接收方。它创建套接字,打印其名称,然后阻塞,等待数据出现。

我们创建第二个shell,列出套接字文件,正如预期的那样,我们发现它是一个类型为s(套接字)且大小为0的文件。这看起来类似于我们之前使用的管道,所以也许我们可以尝试直接向套接字写入数据。让我们试试看。不行,这不起作用。Shell没有使用strerror,所以我们只得到错误号。让我们查一下错误45是什么。是的,Operation not supported for the type of object。所以我们不能直接写入套接字。

但是让我们使用我们的sender程序。我们看到数据被我们的程序发送,并立即被左边的接收方读取。

如果我们尝试再次发送数据会发生什么?我们得到一个错误,说我们的套接字不再存在;请记住,接收方在读取数据后已经取消了文件的链接。

让我们尝试保留套接字文件,看看会发生什么。好的,我们再次运行接收方读取。然后再次发送数据。现在套接字文件保留在文件系统中,大小仍为0字节。让我们尝试再次发送。现在我们得到一个不同的错误:Connection refused,我们的接收方不再监听我们的套接字,所以我们无法连接到它。

让我们尝试再次运行接收方。哦,另一个错误:Address already in use。这是因为接收方正试图创建一个新的套接字,但文件已经存在。也就是说,我们得到了与尝试监听网络套接字而另一个应用程序已经在监听时相同的错误。这就是为什么我们需要在程序完成后删除套接字文件。

如果我们删除套接字文件,然后再次运行接收方,它将能够再次创建套接字并使用它。我们的发送方也将能够再次发送数据。


关键API与行为分析

好的,我们已经看到了通过套接字进行通信是如何工作的。具体来说,我们注意到在创建套接字之后,我们必须绑定它。

当你第一次调用socket时,新的套接字将存在于给定的命名空间中,但它还没有名称。通过调用bind,我们为其分配一个名称。当套接字在UNIX或本地域中时,调用bind会导致套接字在文件系统中显现为一个文件。

由于这会创建一个新文件,并且套接字用于允许另一个进程与我们通信,我们必须考虑该文件的权限。虽然考虑到umask创建类型为socket的新文件这一事实不应令人惊讶,但需要注意的是,这并不可移植,我们应该确保提供一个路径名,并且该路径位于权限受适当限制的目录中。

在我们调用bind之后,我们的套接字以文件形式存在于文件系统中,在内部由一个文件描述符表示,我们可以用它来读取,就像我们的接收方所做的那样。但我们看到,我们的发送方没有调用write,而是使用了另一个系统调用来发送数据:sendto系统调用。

sendsendto系统调用可用于将消息传输到另一个套接字。与write相比,它们的优势在于它们是专为套接字使用而设计的,因此允许设置额外的标志(我们将在未来的讲座中看到其中一些)。此外,为了能够可靠地传输数据(即使用流式套接字),套接字必须处于已连接状态。由于在我们的示例中使用的是数据报,我们可以使用sendto提交数据而无需调用connect

现在,在我们的接收方程序中,我们确实使用了read,但那里也有特定的套接字API调用来接收数据:recvrecvfrom,它们是sendsendto的等效调用,在成功时返回发送/接收的字节数,失败时返回-1。我们将在下一个视频片段中进行网络通信时再次更详细地看到其用法。


总结与练习

一个在本地域中使用数据报的简短示例,很好地介绍了套接字API。我们已经看到,首先必须通过调用socket系统调用来创建套接字,指定域、类型和协议。然后我们必须绑定套接字以为其分配名称。为了使通信成为可能,双方必须就使用的名称达成一致(使用相同的路径名),并拥有该文件的访问权限。当我们调用bind时创建的套接字类型的文件,就像一个FIFO,仅用于两个程序之间的会合。然而,由于我们得到了一个文件描述符,我们能够使用标准的I/O系统调用(如readwrite)对其进行操作。但存在专用的系统调用,如recvsend等,它们提供了套接字API特定的附加功能。我们还看到,在完成通信后,需要由我们来删除文件。

在下一个片段中,我们将看到如何使用套接字进行网络通信,并讨论其与本地域进程间通信的相似之处。

在结束之前,这里有一些问题和练习供你思考:

  1. 修改发送方程序:当前的发送方程序总是发送固定消息,这对于说明基本功能是可以的,但不太有用。你能修改程序,改为将从标准输入读取的数据(一次一行)写入套接字吗?
  2. 探索套接字权限:在服务器调用bind之后,尝试修改套接字文件的权限。如果你限制权限,会发生什么?其他哪些进程可以使用它?
  3. 更换I/O系统调用:更改两个程序,使用各自的另一个系统调用来执行I/O:将接收方改为使用recvfrom,发送方改为使用write。哪个更好用?哪个更容易?
  4. 多发送方对单接收方:你能让多个进程使用同一个套接字向单个接收方发送数据吗?为此,你必须更改接收方以循环重复读取。可以参考我们之前的一些IPC示例,而不仅仅是这里的代码。
  5. 混合协议和类型:如果你更改协议或套接字类型会发生什么?我们可以混合匹配吗?

我认为这些足以让你忙到下一个视频了。祝你好运,感谢观看。

055:Week 09, Segment 3 - INET域中的数据报套接字 🔌

在本节课中,我们将学习如何在互联网域中使用数据报套接字进行进程间通信。我们将通过一个具体的代码示例,演示如何创建UDP套接字,实现两个不同主机之间的通信,并分析网络数据包的传输过程。


上一节我们介绍了在UNIX本地域中使用数据报套接字进行通信。本节中,我们来看看如何将通信扩展到互联网上,实现不同主机间的数据交换。

socket系统调用的原型如下。我们指定一个域、一个类型和一个协议,从而创建一个适合这些属性相关约定的套接字。

int socket(int domain, int type, int protocol);

我们的示例基于BSD IPC教程,分为两个程序:一个读取器和一个发送器。读取器程序 dgrecv.c 如下所示。

首先,我们调用socket,指定域为PF_INET,类型为SOCK_DGRAM,这意味着我们将在互联网域中使用UDP数据报。

int sockfd = socket(PF_INET, SOCK_DGRAM, 0);

接着,我们填充sockaddr_in结构体。与本地域不同,这里我们需要指定一个IP地址。我们可以提供主机上的任何IP地址,或者像本例一样,通过INADDR_ANY允许来自任何地址的连接。同样,如果我们不关心监听哪个端口,可以通过传递0让内核为我们选择一个。

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(0);

之后,我们像之前一样调用bind。由于我们让内核选择端口,我们不知道具体是哪个端口。为了找出端口号,我们调用getsockname。根据手册页的说明,此函数的常见用例是检索内核分配的端口号。

bind(sockfd, (struct sockaddr *) &servaddr, sizeof(servaddr));
getsockname(sockfd, (struct sockaddr *) &servaddr, &len);

接下来,我们想打印这个端口号。但我们必须小心,首先需要将其从网络字节序转换为主机字节序。TCP/IP网络规定使用大端字节序。为了方便,我们使用ntohshtons等函数来处理转换,这些函数在大端系统上可能只是空操作。

printf("Listening on port: %d\n", ntohs(servaddr.sin_port));

最后,我们从套接字读取数据,打印接收到的内容,然后退出。

recvfrom(sockfd, buf, MAXLINE, 0, NULL, NULL);
printf("Received: %s\n", buf);

UDP发送器程序 dgsend.c 如下所示。

我们要求用户提供一个端口号,并检查其有效性。然后,我们模仿读取器进行socket调用,并尝试将用户提供的主机名转换为IP地址。

struct hostent *hp = gethostbyname(argv[1]);

接着,我们填充sockaddr_in结构体,使用htons转换端口号,然后发送我们的消息。

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
memcpy(&servaddr.sin_addr, hp->h_addr, hp->h_length);
servaddr.sin_port = htons(port);
sendto(sockfd, msg, strlen(msg), 0, (struct sockaddr *) &servaddr, sizeof(servaddr));

为了演示互联网通信,我们在一个远程系统上运行读取器。运行后,它告诉我们正在监听某个端口(例如54670)。我们可以在本地虚拟机上运行发送器,指定远程主机名和该端口。消息成功地从本地虚拟机发送到了远程系统。

但是,如果我们再次尝试发送,而远程端没有读取器在监听,会发生什么?发送器会安静地发送消息,但我们不会收到任何错误。这是因为UDP是无连接且不可靠的。你可以发送一个数据包,但无法知道它是否会到达。

为了观察数据包的发送,我们使用tcpdump工具捕获并显示本机与远程主机之间的所有数据包。当我们发送消息时,可以看到UDP数据包被成功发送。如果我们重复发送,而读取器已终止,我们不仅会看到UDP数据包被发出,还会从远程端收到一个ICMP数据包,通知我们目标端口不可达。这个错误发生在UDP上下文之外。


现在,让我们总结一下刚刚观察到的现象,并与本地域中套接字的使用进行对比。

由于INET类型的套接字完全存在于内核空间,并由IP地址和端口号对标识,我们不需要进行任何清理工作,内核会在进程终止后自动清理。

我们可以指定一个特定的IP地址,也可以通过传递INADDR_ANY来监听所有可用的IP地址。同样,我们可以通过传递0来请求内核为我们选择一个临时端口。如果我们想要一个特定的端口,可以提供它,但所谓的“知名端口”(1024以下的端口)只有有效用户ID为0时才能绑定。

我们可能需要将数字转换为网络字节序,但幸运的是,我们有htonsntohs等便利函数来为我们处理。

最后,正如我们所见,我们可以发送消息,而无需关心或知道远程端当前是否在监听或是否会接收它们。这是UDP的设计使然。

在下一个视频中,我们将看到这与面向流的协议(如TCP)有何不同。


在本节课结束前,以下是一些问题或建议,供您练习以更好地理解INET域中的UDP套接字。

首先,尝试让读取器工具更用户友好,允许用户指定IP地址和端口号。

其次,尝试使用ntohshtons函数,看看如果不使用这些函数会发生什么。

思考一下,当主机有多个IP地址时,您可能希望如何处理。

考虑当您的系统处于双栈环境或仅支持IPv6时会发生什么。您能修改示例程序使其在那里工作吗?

最后,如果您以前没有做过,请练习捕获和分析网络数据包。除了tcpdump,还有许多其他工具,例如出色的图形用户界面工具Wireshark,它可以帮助您深入分析数据包的细节,但通过tcpdump在命令行下分析数据包也是一个很好的起点。

祝您在这些练习中好运并玩得开心。下次,我们将讨论互联网域中的流套接字。届时再见,谢谢观看。

056:Week 9, Segment 4 - INET6域中的流式套接字 🔌

概述

在本节课中,我们将学习如何在INET6域中使用流式套接字进行网络通信。我们将对比之前学习的UDP数据报,重点理解基于TCP协议的、可靠的、双向字节流通信模型,并使用tcpdump工具观察网络数据包。

上一节我们介绍了在IPV4域中使用数据报套接字进行通信。本节中我们来看看如何使用IPV6域中的流式套接字。

发送端程序分析

首先,我们分析发送端程序。与UDP版本类似,程序首先接受端口号并创建套接字。关键区别在于创建套接字时使用的参数和后续的连接建立过程。

以下是创建套接字的关键代码:

socket(PF_INET6, SOCK_STREAM, 0);

此调用指定了协议族为PF_INET6,套接字类型为SOCK_STREAM,即流式套接字。

接下来,程序查询给定主机名的IPV6地址,并填充sockaddr_in6结构体。端口号需要使用htons函数从主机字节序转换为网络字节序。

与UDP发送端的主要区别在于,流式套接字需要调用connect函数来发起与远程主机的连接。

connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

如果连接成功,程序便可以使用write函数将消息写入套接字描述符,然后关闭套接字并退出。

接收端程序分析

接收端程序在初始阶段与UDP版本相似。它创建套接字,填充sockaddr_in6结构体(让内核选择IP地址和端口),并绑定套接字。

程序通过getsockname获取内核选择的端口号,并将其从网络字节序转换为主机字节序后打印。

流式接收端与UDP接收端的不同之处从调用listen函数开始。

listen(sockfd, backlog);

listen函数表明套接字愿意接受传入连接,并指定一个待处理连接队列的长度限制(backlog)。

设置好backlog后,程序进入一个无限循环以等待连接。对于流式连接,需要使用accept函数从待处理连接队列中提取第一个连接请求。

int connfd = accept(sockfd, (struct sockaddr *)&client_addr, &addr_len);

accept调用会创建一个新的套接字(connfd),用于读取数据。在此程序中,我们不是只读取单条消息,而是持续读取套接字上的数据,直到遇到文件结束符(EOF),并将每条消息连同客户端IP地址一起打印到标准输出。

程序演示与数据包分析

我们登录到远程系统运行TCP接收端程序,并在另一个终端中监控网络连接。运行接收端后,可以看到程序正在监听某个端口(例如61174),netstat命令确认该套接字仅对IPV6开放。

当接收端处于监听状态时,我们可以发送数据。接收端会打印出发送的消息,并前缀以客户端IP地址。值得注意的是,接收端在打印消息后不会终止,而是继续等待新的连接。

如果我们中断接收端程序,然后再次尝试发送消息,将会收到“Connection refused”错误。这是因为TCP是面向连接的协议,当远程端没有监听时,connect调用会失败。

我们使用tcpdump工具来捕获和分析网络数据包。以下是观察到的关键交互过程:

  • 成功连接与通信:首次发送消息时,可以看到TCP三次握手(SYN, SYN-ACK, ACK),然后是携带数据的PSH包,最后是连接拆除的四次挥手(FIN, ACK, FIN, ACK)。
  • 连接失败:当接收端未运行时,客户端发送SYN包尝试建立连接,但服务器会回复RST包,表示连接被拒绝。

在单一连接中发送多条消息

TCP连接的建立和拆除有一定开销。为了更高效地通信,我们可以尝试在同一个连接中发送多条消息。

我们再次启动接收端和tcpdump。首先用我们的发送程序发送一条消息,这会建立连接、发送数据然后断开。接着,我们使用telnet命令手动建立一条TCP连接到接收端。

运行netstat可以看到,此时有一个已建立的连接(来自telnet),同时原始的监听套接字仍然可用。这说明了listen调用所建立的连接backlog概念。

通过telnet连接,我们可以发送多条消息,而无需每次都建立新连接。只有当我们退出telnet时,该连接才会终止。观察tcpdump的输出,可以清晰地看到telnet会话期间的所有数据都在同一个TCP连接中传输。

核心概念与总结

本节课中我们一起学习了如何在INET6域中建立和使用TCP流式套接字连接。

与数据报套接字相比,流式套接字的连接是不对称的。对于流式套接字,我们需要为每个接入的请求创建一个独立的通信套接字。初始的套接字必须通过调用listen来标记为愿意接受连接,该调用同时设定了愿意接受的待处理连接队列长度(backlog)。之后,我们可以使用accept调用来获取传入的连接,如果没有客户端连接,该调用可能会阻塞。

我们还观察到,每个TCP连接都需要完整的三次握手来建立,以及四次挥手来断开。

扩展练习与思考

本程序帮助我们更好地理解了流式套接字,但也留下了许多可以增强和探索的空间。

以下是几个可以尝试的练习方向:

  • 修改程序,使用sendsendto函数代替write,观察程序行为有何变化。
  • 我们目前看到的程序要么只使用IPV4,要么只使用IPV6。但在实际中,系统往往同时拥有IPV4和IPV6地址。尝试更新UDP和TCP示例程序,使其能同时处理两者。
  • 我们的流式接收端允许多个客户端连接,但它如何处理多个客户端同时发送来的消息?
  • 如果一个客户端在连接进入backlog队列后,但在我们处理其消息之前就断开了连接,会发生什么?
  • 最后,最多可以连接多少个客户端?我们知道这与传递给listenbacklog参数有关,但如果客户端数量超过这个值会发生什么?

在下一个视频中,我们将探讨其中一些问题。建议你尝试修改代码并进行实验。

感谢观看。

057:I/O多路复用 🚀

在本节课中,我们将学习如何使用I/O多路复用来处理多个客户端连接,这是构建高性能服务器(如Web服务器)的关键技术。我们将从回顾上一节的问题开始,逐步介绍select系统调用的工作原理,并最终实现一个能同时处理多个客户端连接的服务器模型。

概述

在之前的课程中,我们学习了进程间通信(IPC)和基本的客户端-服务器模型。然而,之前的模型仅限于一对一通信,即服务器一次只能处理一个客户端连接。在真实的互联网环境中,服务器需要能够同时处理多个客户端的请求。本节将探讨如何通过I/O多路复用来实现这一目标。

回顾问题:顺序处理的局限性

上一节我们介绍了基本的套接字编程。让我们快速回顾一下上一个例子,看看在处理多个连接时会遇到什么问题。

以下是我们的流读取器代码,它创建套接字、绑定、监听并接受连接。假设我们希望允许多个连接,可能会考虑打开额外的套接字,并将创建套接字和处理连接的功能分离。

我们的新主函数可能如下所示:我们创建两个套接字,然后依次处理它们,希望允许多个连接到我们的服务器。

// 伪代码示例
int main() {
    int sock1 = create_and_bind_socket(port1);
    int sock2 = create_and_bind_socket(port2);
    handle_socket(sock1); // 这里会阻塞
    handle_socket(sock2);
    return 0;
}

运行此程序时,我们将打开两个套接字。它们分别在端口65446和65445上。现在,我们应该能够连接到任一端口并向服务器发送消息。

然而,当我们尝试连接到一个端口并发送消息时,服务器可能没有接收到。这是因为我们的程序在第一个handle_socket函数调用中被阻塞了。即使第二个套接字已经有连接在等待,程序也无法继续处理。只有当第一个连接终止后,程序才能继续并处理第二个套接字。

因此,创建两个套接字然后尝试顺序处理它们的模型效果不佳。第一个套接字上的accept调用会阻塞,即使第二个套接字上已经有连接在等待。

I/O多路复用的解决方案

当我们有多个需要执行I/O操作的文件描述符时,可以怎么做?以下是几种选项:

  • 阻塞等待:我们刚刚看到的方法。简单地打开文件描述符,阻塞等待I/O,然后处理下一个。这显然不是一个好选项。
  • 为每个描述符创建新进程:这似乎合理,但如果需要在不同通道之间传递信息,则需要额外的IPC机制,这显得有些繁琐。
  • 非阻塞模式:如果我们将文件描述符标记为非阻塞,那么任何调用都会立即返回,如果它没有准备好进行I/O,我们就可以继续处理其他任务。
  • 异步I/O:这种方法要求内核在我们感兴趣的文件描述符准备好进行I/O时通知我们。

但每种选项都有其缺点。永远阻塞显然是不可取的。使用非阻塞模式意味着如果没有连接,我们最终会陷入忙等待(busy polling),即不断循环测试,浪费CPU周期。异步I/O虽然听起来很有希望,但通常有诸多限制。

让我们考虑另一种选择:我们构建一个我们关心的文件描述符集合,然后调用一个特殊的函数,它会告诉我们其中是否有任何描述符已准备好进行I/O。

核心工具:select系统调用

这正是select系统调用的作用。

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

我们向它传递我们想要检查的文件描述符的最大编号(nfds)。系统会检查从0到nfds-1的描述符,这就是为什么你总是看到参数被设置为max_fd + 1

然后,我们为每个集合(readfdswritefdsexceptfds)传入一个文件描述符集合,select将分别检查这些集合中的任何文件描述符是否准备好进行读取、写入,或者是否发生了异常条件。异常条件包括,例如,套接字上到达的带外消息。

最后,我们可以控制select在等待这些条件时应阻塞多长时间。我们可以通过传递NULL让它永远阻塞。我们可以指定等待的秒和微秒粒度,或者通过将struct timeval值的秒和微秒都设置为0来让它完全不阻塞。

操作文件描述符集合

使用select进行这种形式的同步I/O多路复用时,我们将操作文件描述符集合,并使用提供的FD宏来操作它们:FD_SETFD_CLRFD_ISSETFD_ZERO

如前所述,readfdswritefdsexceptfds集合用于检查读、写和异常条件。关于从文件描述符读取,需要记住的一点是,EOF(文件结束符)也是一种I/O形式。也就是说,文件描述符集合可能被标记为已准备好进行I/O,但随后你只得到0字节返回,但这并不奇怪。

如果你需要更精细的阻塞控制,可以使用pselect变体,它提供纳秒级粒度,并允许指定在阻塞时应用的信号掩码。

实践:使用select处理多个套接字

让我们看看是否可以在实践中使用它来解决我们之前的问题:拥有多个套接字,我们希望能够在第一个套接字上等待时,不阻塞第二个套接字的通信。

以下是我们更新后的示例程序,添加了对select的调用:

// 伪代码示例
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(sock1, &readfds);
FD_SET(sock2, &readfds);

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/apue/img/d75ac6c6fcfa9d633ae73225c0c49a60_11.png)

int max_fd = (sock1 > sock2) ? sock1 : sock2;

while (1) {
    fd_set tmp_fds = readfds; // select会修改集合,所以使用副本
    if (select(max_fd + 1, &tmp_fds, NULL, NULL, NULL) > 0) {
        if (FD_ISSET(sock1, &tmp_fds)) {
            // 处理 sock1 上的连接
        }
        if (FD_ISSET(sock2, &tmp_fds)) {
            // 处理 sock2 上的连接
        }
    }
}

select返回后,我们可以使用FD_ISSET单独测试每个套接字,看是否有套接字准备好进行读取。这允许我们的程序拾取任何准备好进行I/O的套接字,这对于需要同时处理多个文件描述符的I/O操作非常有用。

进阶:处理单个套接字上的多个客户端

但更常见的情况是,如果你正在编写一个服务器(例如Web服务器),你可能不希望打开多个套接字。Web服务器监听端口80或端口443(用于TLS连接),但仍然有多个客户端同时连接。

如果我们在这个例子中使用select,我们只有一个套接字。但我们不会阻塞等待连接,这意味着如果没有客户端连接,我们可以做其他事情。以下是这个例子的运行情况:当没有客户端连接时,服务器可以打印其等待消息。当客户端连接时,它会处理连接,而不会进入阻塞状态去打印等待消息。只有在客户端断开连接后,服务器才有机会再次做其他事情。

然而,当我们有两个同时的客户端连接时,问题出现了。第一个连接被立即处理。但如果此时发起第二个连接,该连接会被阻塞,因为第一个连接仍然处于活动状态。为了让第二个连接得到处理,我们需要完成并终止第一个连接。此时,来自第二个客户端的消息才会被传递。这对于编写Web服务器来说并不理想。

最终方案:使用进程处理并发连接

让我们思考如何改进这一点。假设我们重写程序,使得每当套接字上有连接就绪时,我们就派生一个新的子进程。让子进程处理请求并执行服务应该做的任何事情,而父进程则返回等待新连接。

为此,我们首先需要建立一个信号处理程序,让父进程等待任何终止的子进程。否则,每当客户端断开连接时,我们就会留下一个僵尸进程。

然后我们创建套接字并进行select调用。由于当客户端断开连接时我们可能会被中断,我们只需忽略这些条件并循环调用select

我们的handle_socket函数现在在客户端连接时被调用。它可以接受新连接,但不再直接处理,而是派生一个新进程,让该进程处理它。父进程返回,然后可以在新连接到来时继续处理。

handle_connection函数执行我们熟悉的任务,与之前的不同之处在于,在我们完成并断开连接后,我们退出。

让我们看看这是否有帮助。服务器现在监听端口65427。当没有客户端连接就绪时,服务器可以去做其他事情。当第一个客户端连接时,它当然会按预期被处理。但现在请注意,当我们与第一个客户端通信时,服务器仍然可以继续做其他事情。也就是说,它不会被阻塞,因为客户端连接现在由专用的子进程处理。当我们仍然连接到服务器时,我们现在可以在第二个终端发起第二个连接。这一次,服务器立即拾取它。因此,现在两个客户端可以同时与服务器通信而不会被阻塞,并且服务器仍然准备好接受新连接。

总结与扩展

本节课中我们一起学习了如何处理I/O多路复用。我们可以使用select来检查一组文件描述符的特定条件,这使我们能够同时处理多个文件描述符或套接字,或者避免在单个套接字上阻塞,并在等待客户端连接时做其他事情。

我们还看到了如何通过生成子进程或单独的线程来处理单个套接字上的多个同时客户端。如果每个子进程不需要与其他任何客户端通信,这种方法效果很好,并且是标准服务器设计中的常见模式。有时你可能会遇到一些预派生一堆进程来处理连接的程序,这可能会改进这种方法。

还有其他一些用于在I/O准备就绪(或更一般地说,事件发生)时获得通知的替代接口和选项。以下是几个主要的替代方案:

  • poll: 一个比select更可扩展的替代方案。
  • epoll: Linux上的高性能I/O事件通知机制。
  • kqueue: 各种BSD系统(包括macOS)上可用的内核事件通知机制。
  • libevent / libev: 可能封装了其中一些接口,以允许跨平台的I/O多路复用机制。

这些接口都试图克服其他接口的一些缺点,以提高处理大量同时连接时的效率,这是一个需要解决的重要问题。

至此,我们完成了对进程间通信的讨论。通过本视频的讲解,你现在已经为开始进行最终小组项目——编写一个HTTP服务器——做好了充分准备。我们将在课堂上讨论该项目,然后继续我们的视频讲座系列,探讨守护进程和共享库。


本节课中我们一起学习了:

  1. 顺序处理多个套接字连接的局限性。
  2. I/O多路复用的概念及其必要性。
  3. 如何使用select系统调用监控多个文件描述符的状态。
  4. 如何结合fork创建子进程,实现单个服务端口同时处理多个客户端连接。
  5. 了解除了select之外的其他高性能I/O多路复用机制(如pollepollkqueue)。

058:守护进程 👹

在本节课中,我们将要学习守护进程。守护进程是长期在后台运行、提供特定服务的进程,是构建服务器(如HTTP服务器)的基础。理解守护进程的特性与创建方法,对于编写可靠的服务至关重要。

在上一节我们介绍了进程间通信,本节中我们来看看如何创建一个能持续在后台运行的服务进程,即守护进程。

守护进程概述

守护进程是一种特殊的后台进程。它通常随系统启动而自动运行,并在系统关闭时终止。它没有控制终端,因此无法与用户直接交互。这类进程的命名源于物理学思想实验“麦克斯韦妖”,寓意一个在后台持续运行并执行任务的代理。

在早期的BSD软件发行版中,守护进程的图示成为了BSD的官方吉祥物。值得一提的是,最初的BSD恶魔是由前皮克斯动画师约翰·拉塞特绘制的。

守护进程的特性与影响

守护进程具有几个特定属性,这些属性带来了重要的编程影响。

  • 长期运行:守护进程可能运行数小时、数天甚至数月。这意味着必须谨慎管理资源,避免内存或文件描述符泄漏。每次请求处理中的微小泄漏,在长期运行后都会导致资源耗尽。
  • 专注单一服务:它遵循UNIX哲学,只做好一件事。这有助于保持代码简单,降低复杂性。
  • 无控制终端:它无法进行交互式输入,也无法将错误信息打印到标准错误输出。必须通过其他机制(如系统日志)来报告信息。
  • 工作目录:进程的当前工作目录是一个打开的句柄。如果守护进程持有一个目录的句柄,就无法卸载该目录所在的文件系统。因此,守护进程通常会将工作目录更改为文件系统根目录 /

如何编写一个守护进程

了解了上述影响后,我们可以按照一系列标准步骤来创建一个守护进程。

以下是创建守护进程的关键步骤:

  1. 清理环境:清除环境变量,确保服务不受不可控因素影响。
  2. 创建子进程:调用 fork(),并让父进程退出。这使得子进程成为“孤儿进程”,并被 init 进程接管,从而在后台运行。
  3. 设置文件掩码:调用 umask(0) 设置合适的文件创建掩码,确保后续创建的文件具有正确的权限。
  4. 创建新会话:调用 setsid() 成为新会话的首进程。这会断开与控制终端的关联,并让该进程组的所有进程保持在同一个会话中。
  5. 更改工作目录:将当前工作目录更改为一个安全的位置,通常是根目录 /
  6. 关闭文件描述符:关闭或重定向标准输入、标准输出和标准错误描述符(通常是文件描述符0、1、2),防止后续创建的进程错误地使用它们。通常将它们重定向到 /dev/null
  7. 打开日志文件:如果需要记录日志(如系统日志或访问日志),则在此步骤打开相应的文件描述符。
  8. 进入服务主循环:完成上述设置后,程序进入提供实际服务功能的主循环。

守护进程的管理惯例

由于系统上运行着许多守护进程,因此形成了一些管理它们的惯例。

以下是常见的守护进程管理惯例:

  • 使用PID文件:许多守护进程会将自己的进程ID写入一个文件(通常位于 /var/run/ 目录下)。这有助于系统管理员识别主进程,也用于防止启动多个服务实例。
  • 使用锁文件:通过检查或创建特定的锁文件,可以防止同一个守护进程的多个实例同时运行。
  • 提供启动脚本:为了让服务能随系统启动,需要提供启动脚本。脚本风格因系统而异(如BSD rc、System V init 或现代的 systemd)。
  • 使用配置文件:守护进程通常从 /etc/ 目录下的配置文件中读取设置,因为无法与用户交互。配置文件通常以服务名命名。
  • 响应SIGHUP信号:约定是当守护进程收到 SIGHUP(挂起)信号时,重新启动并重读配置文件。由于守护进程没有控制终端,正常情况下不会收到此信号,因此可以将其“挪用”为重启指令。
  • 使用系统日志:通过 syslog 机制来记录调试信息、警告和错误,这是无终端后台服务的标准日志方式。

实例分析:syslogd 守护进程

让我们通过 syslogd(系统日志守护进程)来观察一个典型的守护进程。

/etc/rc.d/ 目录下可以找到各种服务的启动脚本。通过 ps 命令可以看到当前运行的守护进程,如 httpdsshdsyslogd 等。

查看 syslogd 的源代码(syslogd.c),可以看到它进行了典型的守护进程初始化:更改根目录、设置组ID和用户ID,然后调用了 daemon() 库函数。这个函数封装了创建守护进程的繁琐步骤。

daemon() 函数内部完成了我们之前描述的步骤:fork() 让父进程退出,子进程调用 setsid(),并重定向标准文件描述符。调用后,进程就变成了一个合格的守护进程。

syslogd 的启动脚本定义了命令名、PID文件和配置文件。它的PID文件(如 /var/run/syslogd.pid)记录了当前进程ID。配置文件(如 /etc/syslog.conf)决定了日志的存储规则。

如果修改了配置文件,需要让 syslogd 重启以生效。有两种方式:

  1. 直接向进程发送 SIGHUP 信号:kill -HUP <PID>
  2. 使用启动脚本重启:/etc/rc.d/syslogd restart。脚本会检查PID文件,终止旧进程并启动新进程。

通过向系统发送一条日志消息,可以验证新的配置是否已生效。

总结与下一步

本节课中我们一起学习了守护进程的核心概念、创建步骤和管理惯例。守护进程是构建稳定后台服务的基础,其关键在于资源管理脱离终端遵循系统惯例

接下来,你可以尝试修改一个守护进程的配置文件,并向其发送 SIGHUP 信号,观察它如何重启并应用新配置。也可以深入研究启动脚本是如何实现重启逻辑的。

为了进一步探索,建议查阅以下手册页和相关链接:

  • man daemon
  • man syslog
  • man setsid

在接下来的视频中,我们将深入探讨共享库以及可执行文件与链接器的工作原理。

060:链接器与加载器详解 🔗

在本节课中,我们将深入探讨可执行文件是如何从可重定位目标文件生成的,以及执行时所需的工具和过程。我们将回顾编译链和链接器的工作,并理解共享库的加载机制。

概述

上一节我们开始研究可执行与可链接格式(ELF)。我们区分了可重定位目标文件和可执行文件。本节中,我们将更仔细地观察前者如何转变为后者,需要哪些工具,以及可执行文件是如何被执行的。在这个过程中,我们将再次回顾之前的几节课,特别是第五周讨论的编译链和链接器工作。这将帮助我们理解共享库,这是另一种使用ELF描述的文件类型。

从源文件到可执行文件

让我们从上次结束的地方开始。当我们对ELF可执行文件运行readelf命令时,我们注意到了不同的程序头,包括PT_INTERP头,它指向一个文件,例如/usr/lib/ld-linux-x86-64.so.2

那么,这个路径从何而来?它有什么作用?我们为什么需要它?我们在构建可执行文件时并没有明确指定这个路径,不是吗?

实际上,我们并没有明确指定。但让我们退一步,回顾一下如何从源文件构建可执行文件。我们在第5周的课程中更详细地介绍了这个过程,但让我们回忆一下,编译过程是一系列独立的步骤,每个步骤由编译链执行,可能调用不同的工具。

具体来说,我们知道在编译的第一阶段,我们调用C预处理器(CPP)来引入所有包含文件、展开宏等。然后执行从C语言到机器相关汇编代码的编译。接着使用汇编器将输入转换为目标文件(可重定位的.o文件)。只有在编译过程的最后阶段,我们才调用链接器来创建可执行文件,通过组合多个目标文件,包括指定动态链接器选项以引入运行时链接编辑器(ld-linux.so)、C运行时启动例程所需的各种目标文件,以及我们创建的目标文件(例如crypt.o)。

或者,如果我们调用cc并让编译链执行所有步骤,则会创建一个临时文件,如下所示。接下来,我们指定要链接libclibcrypto。然而,我们并没有告诉链接器在哪里找到这些文件。我们稍后会看到它是如何做到的。最后,链接器需要C运行时收尾例程来“钩住”启动例程对象。

现在,获取所有这些文件,重新排列一些位,添加一些关于在哪里找到未定义符号等信息,链接器就生成了ELF可执行文件。

实践示例

让我们看一个实际的例子。这里有一个非常简单的程序,它使用了crypt库函数。我们将调用它来说明除了标准C库libc之外,共享库的使用。

首先,我们编译目标文件,然后运行我们刚刚解释的ld命令。我们需要指定动态链接器ld-linux.so、C运行时启动例程对象、我们的目标文件、要链接的库以及C运行时收尾例程。

当我们运行readelf时,我们看到PT_INTERP头(程序解释器)被设置为我们指定的动态链接器。

动态链接器的作用

那么,这个ld-linux.so到底是什么?手册页告诉我们,ld-linux.so是运行时链接编辑器,一个用于查找和加载程序执行所需的各种共享对象的程序。也就是说,当我们调用程序时,内核将控制权传递给这个运行时链接编辑器,然后它使用ELF不同节中的信息来查找要加载的其他对象。

手册页还告诉我们链接器如何找到正确的库。记住,当我们调用ld时,我们只告诉它“我们需要libcrypto”,但我们没有告诉它这个库在哪里。因此,链接器和链接编辑器必须有一种方法来找到实际的文件。

让我们看看可执行文件的动态节。在这里,我们看到有三个共享对象被标记为“需要”:libc.so.6libcrypto.so.1.1libcrypt.so.1。因此,当我们运行可执行文件时,我们知道这将调用运行时链接编辑器,它将确定在哪里找到我们没有存入可执行文件的符号,然后允许我们的程序执行。

但我们如何验证ld-linux.so真的被执行了呢?让我们使用ktrace工具。ktrace是一个用于检查程序进行了哪些系统调用等信息的工具。跟踪信息以二进制格式存储在一个文件中,你可以使用kdump工具来检查。

我们调用execve来执行a.out二进制文件,然后我们注意到紧接着,我们看到一个对ld-linux.so的调用,它继续打开ld.so.cache,然后引入libc.so.6,接着是libcrypto.so.1.1libcrypt.so.1,然后才继续执行我们指示程序要做的操作。

不指定动态链接器的情况

到目前为止一切顺利。但如果我们不指定动态链接器呢?ld仍然会成功,生成的文件仍然是ELF可执行文件。但请注意,现在请求的解释器是一个不同的程序解释器/lib64/ld-linux-x86-64.so.1。让我们看看执行该命令时会发生什么。

我们得到一个失败信息,说“没有那个文件或目录”。让我们检查这个文件/lib64/ld-linux-x86-64.so.1在哪里。它不存在。因此,ktrace准确地显示程序试图调用程序解释器,但该文件不存在,因此程序执行失败。

但也许我们可以避免使用任何程序解释器。让我们向ld传递-no-dynamic-linker选项。就像之前一样,我们仍然得到一个可执行文件,类型仍然是ELF。但这一次,没有解释器。那么,当我们执行这个二进制文件时会发生什么?

哦,段错误。它出现了。我们尝试执行程序,但立即遇到分段违规,因为我们的进程在内存中的设置方式无法执行,它缺少了链接编辑器本应为我们提供的来自共享对象的各种符号。

如果我们回到指定正确的动态链接器,那么一切又恢复正常了。

链接器与加载器的关系

因此,运行时链接编辑器ld-linux.so在某种程度上执行了与链接器相反的步骤。链接器获取各种目标文件并创建可执行文件,而运行时链接编辑器现在获取可执行文件,检查环境、其自身的配置信息(这些信息可以在可执行文件的DT_RPATH节中找到,我们将在下一个视频中更详细地查看),以及库的默认位置。最终,它找到要使用的正确共享库,然后最终生成要加载到内存中的正确进程映像。

总结

在本节课中,我们一起学习了链接器如何组合目标文件、C运行时序言和收尾对象以及任何共享库来生成可执行文件。正如我们刚刚看到的,加载器通过在运行时执行某些查找来处理这些符号的解析,然后生成允许执行的进程映像。

在一些系统上(最著名的是Linux),运行时链接器本身就是一个可执行文件,你可以传递程序文件来调用它。尝试构建一个没有程序解释器的可执行文件,它应该会执行失败,正如我们在这里观察到的那样。但是,如果你直接调用加载器并将没有指定解释器的可执行文件传递给它,它仍然应该执行。你甚至不需要相关程序的执行权限,这让一些人感到惊讶。

在下一个视频中,我们将更仔细地研究共享库到底是什么,以及加载和链接过程的一些特性。

061:共享库 📚

在本节课中,我们将要学习共享库。我们将详细探讨什么是共享库、如何创建共享库,以及动态链接可执行文件的行为如何受到链接器工作方式的影响。

概述

在上一个视频中,我们详细讨论了创建和执行可执行文件所涉及的进程。我们探讨了链接过程中将多个目标文件组合在一起的步骤,并看到在可执行文件中,PT_INTERP程序头包含了程序解释器(或运行时链接编辑器)的路径名。它的职责是在运行时通过共享库解析任何未定义的符号。

在本视频中,我们将重点介绍共享库本身。

什么是共享库?🔍

让我们再次回顾链接器如何创建可执行文件。我们记得,它插入了程序解释器 /usr/lib/ld-linux.so,然后将目标文件与指定的共享库结合,创建出名为 a.out 的 ELF 二进制文件。

但我们实际上只指定了共享库的名称,没有其他信息。那么链接器是如何使用它们的呢?共享库到底是什么?

当我们讨论第5周的编译链时,我们描述了C预处理器如何引入各种C头文件。我们也看到,这些头文件只提供宏、常量和前向声明(或函数原型),而不是函数的实际实现。例如,当你包含 stdio.h 时,你只提供了 fprintf 等函数的前向声明,而不是它们的实现。实现 fprintf 的完整代码必须来自其他地方,在这个例子中就是C标准库。

换句话说,库包含可以被你的程序使用的实际代码。事实上,任何不是你亲自编写的代码都必须来自某个库。

为了能够将这些代码加载到内存中供任何执行中的程序使用,它需要使用位置无关代码。你的编译器可能默认已经生成了这种位置无关代码,或者你可能需要指定一个特殊标志,我们稍后会看到。

库可以是静态的动态的,我们将在本视频中看看两者之间的区别。动态共享库可以在执行时由加载器加载,或者作为程序的一部分按需加载。

通过示例说明 📝

也许通过示例来说明这一切是最好的方法。

这是我们在上一个视频中的程序 crypt.c,尽管我们添加了另一个函数来说明我们代码中的定义是如何在 .o 文件中被识别的。

无论如何,我们使用了一些标准库函数,例如 fprintf,我们知道它是定义在 stdio.h 中的函数原型。

#include <stdio.h>

crypt 库函数则声明在 unistd.h 头文件中。

#include <unistd.h>

fprintf 的实现由标准C库 libc.so 提供。而 crypt 则在 libcrypt 库中实现。

如果我们将代码编译成目标文件,一切正常,因为编译器找到了前向声明并生成了相应的 .o 文件。

但是,如果我们尝试将 .o 文件链接成可执行文件,就会得到一个错误。链接器找不到 crypt 函数的实现,然后抛出关于“未定义引用”的错误。

幸运的是,手册页告诉我们需要链接哪个库来获取 crypt 函数的实现:我们需要链接 -lcrypt。如果我们这样做,链接器就会成功并生成一个可执行文件。

符号表与动态链接 🔗

通过 readelf 查看符号表,我们可以看到哪些符号在可执行文件中被定义。main 是函数类型且已定义,print 函数也已定义。但 crypt 本身,以及 fprintfexit 仍然是未定义的。

并且所有这些函数的地址都是 0。我们如何能创建一个带有未定义符号的可执行文件呢?

让我们尝试自己运行链接器。如果我们只用目标文件调用 ld,会得到一大堆关于未解析符号的错误,比之前多得多,包括来自标准C库的函数。

所以,我们尝试告诉链接器使用 -lc。现在好多了,我们只得到关于 crypt 未定义的错误,就像之前一样。这意味着我们的编译链默认会自动链接 libc.so,这似乎是合理的做法。

现在,让我们在命令中添加 -lcrypt。现在一切都成功了,没有未定义的引用了。

crypt.o 目标文件的符号表包含未定义的引用。但是可执行文件呢?readelf -a 显示这里也有大量未定义的引用。但现在我们有一堆已定义的东西。例如,我们有 _start 例程,我们知道它是可执行文件的入口点,位于十六进制地址 0x400550。然后 main 也被发现是一个已定义的函数。但同样,cryptexitfprintf 仍然未定义。

让我们看看可执行文件的动态节。这很有趣。这里我们看到可执行文件包含了它需要哪些库的信息:libc.so.12libcrypt.so.1。当我们执行程序时,一切正常。

因此,可执行文件中包含的这些信息会通知加载器,它需要查找这两个库:libclibcrypt

ldd 命令可以用来显示加载器在共享库方面会做什么。它显示 a.out 可执行文件需要 /usr/lib/libc.so.12/usr/lib/libcrypt.so.1

在找到库的绝对路径(其名称包含在可执行文件的要求中)之后,加载器就会加载它们。

正如我们在上一个视频中描述的那样,我们看到 ld-linux.so 必须从可执行文件中找到关于共享库的信息,然后使用一些逻辑将其映射到实际路径名,再加载这些库以在运行时解析未定义的引用。

换句话说,链接器和加载器必须再次协同工作。在链接时,链接器解析各种未定义的符号,并记录需要哪些库。它在可执行文件中提供此信息,但不会将所有实际代码拉入可执行文件。然而,我们编写的实际代码,我们编译的所有目标文件中的内容,都被放入了可执行文件。

如果我们使用了静态库,我们的代码也会被拉入可执行文件。我们稍后会看到一个例子。但是动态库(如我们示例中的 libclibcrypt)的内容不会被拉入可执行文件。相反,我们只在链接时的可执行文件中包含需要哪些库的信息,然后在程序执行时,动态链接器执行反向操作,加载程序可执行文件中指定的共享库。

然而,运行时链接编辑器并不是你加载共享库的唯一方式。你也可以自己显式调用实现此功能的 dlopen 系列函数。我们将在本视频末尾看到一个这样的例子。

创建一个简单的共享库示例 🛠️

首先,让我们创建一个更简单的例子来更好地说明如何创建共享库。

让我们考虑这个简单的程序。main 只调用了三个函数:ldtest0ldtest1ldtest2。仅此而已。main.c 文件不包含这些函数的实现,但提供了让编译器创建目标文件所需的函数原型。这相当于有一个自定义的 include 语句,引入一个提供这些前向声明的文件。

ldtest1.c 文件包含 ldtest0ldtest1 函数的实现。这些函数本身并不令人兴奋,但它们将服务于我们的目的。ldtest2.c 则包含同样无聊的 ldtest2 函数的实现。

现在,如果我们编译 main.c 程序,当然会失败,因为我们没有提供任何实现 ldtest 函数的代码。但请注意,如果我们只是将程序编译成目标文件,那是可行的,因为我们确实提供了函数原型。

所以,如果我们现在将 ldtest.c 文件编译成目标文件,然后将 main.o 与这些目标文件链接,我们就会得到一个功能正常的可执行文件。

让我们看看符号表。这次,我们将使用 nm 工具。我们再次看到程序文本段中定义的一些符号,一些来自共享库 libc 的未定义符号,以及一些在 .bss 段中定义的东西。但这都是我们整个学期一直在构建可执行文件的常规无聊内容。

让我们改变一下。让我们创建一个提供 ldtest 函数的库。为此,我们使用 ar 工具创建一个包含两个 ldtest 目标文件的归档文件。归档文件非常简单,就是一个包含其他几个文件以及目录表的文件,以便你可以更轻松地访问归档内的单个文件。

如果我们查看这个归档的目录表,是的,它现在包含两个目标文件。现在,让我们看看这个归档的符号表。索引告诉我们哪些符号在哪个文件中定义,我们也发现这些文件本身仍然包含一些未解析的引用。

那么我们现在可以用这个归档做什么呢?如果我们单独编译 main.c 文件,我们知道它会失败。但是,如果我们随后将归档作为另一个参数添加,编译和链接就成功了。也就是说,链接器能够从归档中提取文件。换句话说,归档已经成为一个库,只是碰好在当前目录中。

但我们可以把它存放在其他地方。让我们把它移到 /tmp/lib。如果我们现在可以使用 -lldtest 标志,而不是提供归档的固定路径,那就太好了。不幸的是,链接器当然会告诉我们它不知道在哪里找到这样的库。

但是,如果我们通过指定 -L 标志给链接器一个提示,告诉它在 /tmp/lib 目录中查找它可能需要的任何库,那么它就成功了,并且可执行文件像以前一样工作。

静态链接与动态链接对比 ⚖️

现在让我们说明静态链接可执行文件和动态链接可执行文件之间的区别。我们重命名这个可执行文件,然后创建一个新的、静态链接的。

a.out.static 现在是一个静态链接的 ELF 可执行文件。a.out.dyn 是一个动态链接的 ELF 可执行文件。动态链接的可执行文件需要一个程序解释器,静态链接的则不需要。它们的行为方式相同。

但这两个可执行文件显然非常不同。动态的那个要小得多。

readelf 告诉我们,这个可执行文件需要 libc.so.12 才能运行。而 a.out.static 则不需要。

a.out.dyn 的符号表如前所述,包含各种已定义的东西,包括我们放入归档的 ldtest 函数,而来自 libc 的东西仍然是未定义的,因此需要加载 libc

然而,a.out.static 的符号表看起来有点不同。它包含更多的符号,包括所有 I/O 函数的符号。我们来比较一下:a.out.dyn 包含 36 个符号,a.out.static 包含 1075 个。这解释了为什么静态链接的可执行文件要大得多,并且实际上包含了来自 libc 库的所有内容,这就是为什么它不需要程序解释器或链接加载器。它需要的一切都“烘焙”进了可执行文件中。

因此,我们在这里创建的 libldtest.a 归档是一个静态库。正如我们所见,这些库(归档)是使用 ar 工具创建的,通常以 .a 结尾。当然,这是在 Unix 系统上,这绝不是硬性要求。归档实际上只是一个包含其他文件的单个文件,如果你愿意,可以看作是一个带有目录表的文件串联。这当然解释了为什么链接这样的归档会产生一个包含归档所有内容的大二进制文件,因此不需要程序解释器或加载器在程序执行时去寻找任何其他库。

创建动态共享库 🧩

现在,让我们将其与使用动态共享库的动态链接可执行文件进行比较。

如前所述,为了让库在执行时甚至执行期间按需加载,它需要使用位置无关代码。因此,我们需要使用编译器的 -fPIC 标志。位置无关代码通过全局偏移表来解析其地址。

让我们使用这个标志来编译我们的库文件。请注意,我们的目标文件现在在 ELF 符号表中包含一个全局偏移表引用。

让我们创建一个共享库。为此,我们使用编译器的 -shared 标志,并向链接器传递 -soname 标志,以指定我们正在创建的共享对象的名称。按照惯例,共享库根据应用程序二进制接口承诺以主版本号和次版本号命名,并创建符号链接以允许用户、链接器和加载器找到正确的库。

所以 libldtest.so.1.0 现在是一个动态链接的共享对象。和以前一样,不指定库编译 main.c 会失败。仅指定库名是不够的,因为链接器不知道库可能在哪里。但是,如果我们用正确的目录指定 -L 标志,那么就可以继续了。

a.out 是一个动态链接的可执行文件,以 /usr/lib/ld-linux.so 作为程序解释器。

所以现在我们可以运行程序了。哎呀,现在怎么了?我以为我们创建了一个带有正确库的可执行文件。它现在执行失败在哪里?

ldd 也告诉我们找不到库。让我们看看库。在动态节中,它告诉我们它需要 libc,这并不奇怪,并且在 soname 中,它被命名为 libldtest.so.1,正如我们指定的那样。

让我们看看链接编辑器的手册页。这里告诉我们它如何在链接时尝试查找库:它会查看一些用户定义的查找路径、配置文件、可执行文件中明确指定的路径,如果这些都失败了,则查看 /usr/lib

我们没有在执行时指定任何查找位置,所以它在 /usr/lib 中找不到并失败了。让我们设置 LD_LIBRARY_PATH 环境变量,因为它在位置列表中似乎具有优先权。ld-linux.so 会查看它。

你知道吗?ldd 现在找到了库,执行时的链接编辑器也找到了,程序可以运行了。

这很好。我们现在可以指定要使用的共享库的查找目录。但这也就意味着,如果我们在其他地方有一个共享库,我们可以让程序使用那个库。如果那个共享库的行为不同,那么我们的程序行为也会不同。

让我们在这里说明这一点。这是 ldtest 函数的另一个实现。让我们创建一个使用此代码的新库。我们像以前一样编译文件并创建相同的符号链接集。但这次,我们假装这是库版本的一个次版本增量。

我们的 LD_LIBRARY_PATH 仍然指向 ./slib/lib,所以我们的程序行为如常。但是,如果我们将 LD_LIBRARY_PATH 环境变量更改为指向新目录 ./lib2,我们的程序在执行时会立即采用新的行为。

这里真正值得注意的是,我们无需重新编译可执行文件就改变了它的行为,仅仅通过将 LD_LIBRARY_PATH 变量指向不同的目录。

但这似乎意味着我们确实需要设置这个变量,这有点烦人和麻烦。难道没有办法在不设置环境变量的情况下执行二进制文件吗,即使库不在默认库路径中?幸运的是,有办法。

我们可以向链接器传递一个特殊标志 -rpath,指示它在特定位置查找库。现在请注意,readelf 不仅告诉我们可执行文件需要的库名,还告诉我们要查找库的路径(rpath)。就这样,即使 LD_LIBRARY_PATH 变量没有设置,执行程序也能工作。

然而,即使在可执行文件中指定了 rpath,我们仍然可以设置 LD_LIBRARY_PATH 变量并触发可执行文件的不同行为,因为查找顺序(正如我们在手册页中看到的)赋予环境变量优先于二进制文件中的 rpath

但这实际上可能带来一些相当严重的后果。假设我创建一个库,实现一些标准C库函数,例如 printf。这里的 evil.c 正是这样做的。如果我们能构建这样一个库,然后设置 LD_LIBRARY_PATH 变量导致我的库被加载,那会使用我的 printf 函数而不是标准C库函数吗?让我们试试看。

这里,我们的 ldtest 库现在包含了我们的 evil 目标文件。是的,看起来当我们运行 a.out 时,它确实调用了我们邪恶版本的 printf

现在考虑这意味着什么。如果你有一个 setuid root 可执行文件,并且你可以用它来调用你控制下的库函数,你将立即获得完整的 root 访问权限。

让我们试试看。a.out 现在是 setuid root。但当我们运行它时,它没有调用我们邪恶的 printf,即使我们的 LD_LIBRARY_PATH 变量已设置。但如果它不是 setuid,那么它就会调用。

所以这是系统提供的一种安全机制:只有当程序不是 setuid 时,LD_LIBRARY_PATH 变量才会被遵守。这是有道理的:如果程序不是 setuid,那么它已经以你的所有权限运行,你可以诱骗它做任何你已经可以做的事情。但如果你有一个与真实 UID 不同的 EUID,那么我们需要更加小心,ld-linux.so 将不会遵循我们的 LD_LIBRARY_PATH

动态加载库 🧠

好了,与静态库相比,动态库要求目标文件是位置无关代码。我们使用编译器的 -fPIC 标志来使其生成这种位置无关代码。

动态库通常以 .so(共享对象)结尾,并且通常有许多符号链接,以促进应用程序二进制接口的向后兼容性。

动态库中的符号在链接时被查找,但直到执行时才被解析。这要求加载器知道在哪里找到库。正如我们所见,用户和系统管理员可以通过不同的设置来影响加载器的行为。

但我们之前提到,库不仅可以在执行时加载,还可以在程序执行期间的任何时刻按需加载。那是什么样子的呢?

再次,如前几次所见,我们的 crypt.c 程序需要链接 libcrypt。这个信息随后被存储在可执行文件中。

但是,这种库在执行时的加载是如何完成的呢?让我们看看 dlopen 函数的手册页,它自动包含在每个动态链接的程序中。这个函数允许加载位置无关的共享对象。

让我们看看 dlopenex.c 程序,它将说明使用 dlopen 调用来从给定的动态库加载符号并调用函数,而无需链接它。

print_crypt 中,我们现在声明一个变量 _crypt 作为函数指针,匹配我们知道真正的 crypt 函数所符合的原型。我们调用 dlopen,要求它加载 libcrypt.so,然后尝试解析该库中 crypt 函数的符号,并将其分配给我们的函数指针。如果成功,那么我们现在可以通过我们自己的 _crypt 名称调用来自 libcrypt 库的系统 crypt 函数。

让我们编译它。注意,我们没有像以前那样链接 -lcryptldd 确认我们的程序只需要 libc,不需要其他东西。但运行它,仍然有效。

查看我们的符号表,我们也看不到任何对 crypt 函数的引用。然而,我们仍然能够调用该函数。这表明,我们可以在程序执行期间按需加载动态库,而无需链接它。

总结

好了,这把我们带到了本视频的结尾。我们涵盖了很多内容,并学习了相当多关于共享库的知识。

我们已经看到,静态库实际上只不过是目标文件的归档,代码在链接时被放入可执行文件。另一方面,动态库向加载器提供提示,说明应该加载哪些库来执行未解析符号的查找。

正如我们所看到的,LD_LIBRARY_PATH 环境变量或编译器的 -rpath 标志可以在运行时改变可执行文件的行为。

在本节课中,我们一起学习了:

  1. 共享库的基本概念及其与静态库的区别。
  2. 如何创建和使用静态库(.a 文件)。
  3. 如何创建和使用动态共享库(.so 文件),包括编译位置无关代码(-fPIC)。
  4. 链接器和加载器在解析符号时的协同工作方式。
  5. 影响库查找路径的机制:LD_LIBRARY_PATH 环境变量和可执行文件中的 rpath
  6. setuid 程序相关的安全考虑。
  7. 如何使用 dlopen 系列函数在运行时动态加载库。

在结束之前,请考虑花一些时间查看可用的环境变量,并确保理解它们的用例,无论是有效的还是那些滥用该功能用于恶意目的的。在 Linux 系统(或使用 glibc 的系统)或 FreeBSD 系统上,如果库已编译了调试支持,你可以设置一个额外的环境变量 LD_DEBUG 并产生不同的结果。尝试一下,看看你能从动态链接程序的执行中引出什么有趣的信息。

关于这里涉及的二进制格式,还有更多需要学习。不同的工具允许你查看不同的部分。确保比较我们在本系列中使用的不同工具,包括 readelfobjdumpnm。课程网站上还列出了另一个练习,帮助你练习自己编写共享库。试一试吧。

在这之后,我们将继续讨论一些关于高级 I/O 的混合主题。在那之前,感谢观看。

062:第1节 - syslog(3) 🗂️

在本节课中,我们将学习UNIX环境下的系统日志服务 syslog。我们将了解为什么需要集中式的日志记录机制,以及如何使用相关的库函数和工具来记录和管理系统消息。


为什么需要syslog?🤔

在之前关于守护进程的视频中,我们提到没有控制终端的进程需要一种方式向系统管理员或用户报告事件,syslog 正是为此目的而设计。

那么,为什么需要一个专门的守护进程来为我们记录日志呢?让进程在启动时以追加模式打开一个日志文件,需要时写入数据,退出时关闭,这样不是更简单吗?

问题在于,一个守护进程可能希望将不同的消息发送到不同的地方。或者,管理员可能希望调试信息写入一个文件,而关于关键事件的重要消息写入另一个文件。

这可以通过让进程打开两个文件来解决。但有时,如果进程有控制终端,它可能还想写入终端。某些极其重要的事件甚至可能需要写入串行控制台。这些虽然都能实现,但这是一个非常普遍的模式,系统上的所有守护进程都有类似的需求。

不仅每个进程都想写入自己的文件,有时管理员还希望将所有守护进程的错误或警告收集到同一个文件中。这就产生了多个进程对共享资源的协调访问问题。进程与文件之间的映射关系变得更加复杂。此外,每个进程都必须拥有对相关日志文件的写入权限。大多数守护进程以专用用户身份运行,因此需要精细的权限控制,或者让守护进程以更高权限运行来写入所有不同的文件和资源。正如你所见,这会很快变得混乱。

集中式日志记录的优势 🏛️

如果我们有一个集中的日志记录设施,就可以将所有处理文件等繁琐工作从进程中抽象出来。这遵循了“做好一件事”的UNIX哲学。守护进程只需进行几次库调用即可。

这个集中式设施本身仍然需要权限,但各个单独的进程不再需要,这也有利于我们以非常有限的权限运行它们。因此,我们可以将这个集中式日志记录设施本身视为一个守护进程,它负责处理不同应用程序提交的各种消息。

syslogd 守护进程 📥

syslogd 就是一个这样的守护进程。消息传入后,会被迅速归档到正确的文件中。

/var/log 目录下,我们可以看到一些定期且频繁更新的文件。文件名具有自解释性,例如 maillogauth.logcron.logmessages。根据消息是否可能包含敏感信息,不同文件具有不同的权限。

让我们查看 /var/log/messages 中的条目。可以看到来自 sshdsudo 的许多消息。常规消息不敏感,因此普通用户可以查看。我们注意到整个文件的格式是一致的,其中一个字段记录了是哪个进程记录的消息。

配置映射:/etc/syslog.conf ⚙️

syslogd 如何知道哪些消息应该发送到哪些文件呢?答案在配置文件 /etc/syslog.conf 中。该文件展示了特定类型和优先级的消息到日志文件的简单映射。稍后我们将看到如何自己记录消息。

消息传递机制 🌐

有多种方式可以将消息传递到这个集中式日志设施:

  1. 内核:可以通过 klog 函数提交消息,使其自身无需实现实际的日志记录逻辑。
  2. 进程:系统上的任何进程都可以调用 syslog 库函数。
  3. 网络syslogd 还可以接受来自网络的消息,通常是UDP 514端口。这对于拥有成百上千台主机的大型环境非常有用,管理员可以在一个中心位置收集和分析所有日志。

因此,syslog 的整体架构大致如下:每种消息接收机制都允许 syslogd 以统一的方式处理它们,然后根据其配置进行分发,写入文件、发送给用户,或者转发给另一台主机。利用这些,你可以为你的基础设施构建大规模、复杂的消息记录系统。

编程接口:openlog 和 syslog 👨‍💻

作为程序员,我们主要使用两个调用来与 syslog 交互:openlogsyslog

  • openlog:用于影响后续 syslog 调用的行为。为了将你的消息与其他进程记录的消息区分开,你可以为每条消息指定一个前缀字符串(通常使用程序名)。你还可以指定一些日志选项,例如将消息记录到控制台,或在记录到文件的同时也记录进程ID等。最后,你需要指定所谓的 facility(设施),syslog 用它来决定将你的消息路由到哪里。
  • syslog:每当你想记录一条消息时调用此函数。它将带有给定 priority(优先级)标签的消息发送给 syslogd 守护进程,允许守护进程将重要消息与仅用于信息提示或调试的消息分开。

以下是使用这些库函数的一个示例:

#include <syslog.h>
#include <signal.h>
#include <stdlib.h>

void signal_handler(int sig) {
    if (sig == SIGQUIT)
        syslog(LOG_NOTICE, "Received SIGQUIT signal.");
    else if (sig == SIGUSR1)
        syslog(LOG_INFO, "Received SIGUSR1 signal.");
    // ... 其他信号处理
}

int main() {
    // 打开日志连接
    openlog("my_program", LOG_PID | LOG_CONS, LOG_USER);

    // 安装信号处理器
    signal(SIGQUIT, signal_handler);
    signal(SIGUSR1, signal_handler);

    // ... 程序主循环

    closelog(); // 正常退出时关闭日志
    return 0;
}

在这个例子中,openlog 指定了前缀为 “my_program”,要求将消息打印到终端并包含进程ID,并选择 LOG_USER 设施来标识我们的进程。信号处理器根据接收到的不同信号,使用不同的优先级调用 syslog 记录消息。

实践与配置 🛠️

运行上述程序并发送信号(如 SIGQUIT),消息不仅会打印到终端,也会出现在 /var/log/messages 中。syslogd 足够智能,能够检测重复消息并合并报告。

我们可以通过修改 /etc/syslog.conf 来将特定设施和优先级的消息分离到不同的文件。例如,添加以下行可以将 user 设施中 notice 及以上优先级的消息记录到 /var/log/user_messages

user.notice /var/log/user_messages

创建文件并重启 syslogd 后,相关消息就会同时记录到新文件和默认文件中。

命令行工具:logger 💻

除了库函数,我们还可以通过命令行工具 logger 来使用这个集中式日志设施。它提供了对这些库函数的简单接口。

例如:

  • logger -p local0.info "This is an info message from local0."
  • logger -p user.emerg "System is down!"

紧急消息(emerg 级别)会被发送给所有已登录用户的终端。

总结 📝

syslog 已经存在很长时间,自80年代以来一直是UNIX系统上系统日志记录的事实标准。它已被标准化在 RFC 5424 中,该标准定义了网络数据包格式。默认情况下,它使用 UDP 514 端口进行网络通信,不过现在也可能看到通过 TCP 或 TLS 传输的 syslog 流量。

一些新版本的 syslog,如 syslog-ngrsyslog,增加了更多增强功能。然而,在许多大型组织中,如今流行使用不同的消息中继服务,例如 Elasticsearch 或 Splunk。但值得注意的是,仍有大量设备(尤其是网络设备)只能使用标准的 RFC 5424 格式和 UDP 514 端口来报告消息。

无论如何,syslog 是一个非常方便的服务,体现了 UNIX 哲学的许多特性,并且正如我们在代码示例中看到的,它相当易于使用。

063:非阻塞I/O 🚫⏳

在本节课中,我们将学习非阻塞I/O的概念、应用场景以及如何启用它。我们将通过具体的例子来理解阻塞与非阻塞操作的区别。

概述

在之前的课程中,我们讨论过可能被中断的系统调用,以及使用select进行I/O多路复用。然而,这些方法并不能解决所有场景下不希望进程被阻塞的问题。本节将介绍非阻塞I/O模式,它允许系统调用在无法立即完成操作时立即返回,而不是让进程无限期等待。

阻塞I/O的挑战

在第七周第五小节,我们讨论了被中断的系统调用。我们知道,如果一个系统调用长时间未能完成,它更有可能被中断。特别是那些执行I/O操作、可能长时间甚至永久阻塞的系统调用。我们注意到,对于这些调用,中断可能导致返回EINTR错误,或者系统调用可能被自动重启。但这两种方式都没有解决最初的问题:有时你确实不希望被阻塞,你希望执行操作,并且如果它能完成,你希望知道这一点。

当我们讨论使用select进行多路复用时,也暗示了处理多个可能阻塞在I/O上的文件描述符的方法,但这并非适用于所有用例。

启用非阻塞模式

为了避免成为阻塞I/O的受害者,我们希望进入非阻塞I/O模式。通过使用非阻塞模式,如果操作无法立即完成,我们的系统调用将立即返回,并将errno设置为EAGAINEWOULDBLOCK。这两个值本质相同,源于标准化前早期UNIX版本的差异。它们大体上代表相同的错误条件:EAGAIN基本表示你应该重试此操作,而EWOULDBLOCK更字面地表示此操作将会阻塞。在大多数UNIX系统上,两者是等效的。

那么,如何启用非阻塞模式呢?早在第二周,我们已经遇到了最直接的方法:在打开文件时,向open系统调用传递O_NONBLOCK标志。但我们也记得,我们并不总是有机会打开文件,有时可能是在操作一个已经为我们打开的文件描述符。

在这种情况下,我们可以使用fcntl来设置相同的标志。

以下是如何使用fcntl设置非阻塞标志的示例代码:

int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

非阻塞I/O示例

让我们通过一个例子来看看设置这个标志的效果。在这个程序中,我们向标准输出写入50兆字节的数据。根据调用方式,我们会启用或禁用非阻塞模式,并报告每次write调用的成功或错误。

由于我们操作的是标准输出,必须使用fcntl来设置非阻塞模式。之后,我们循环50次,每次尝试写入1兆字节数据。我们知道,write可能返回比请求更少的字节数,也可能失败。我们会在标准错误中记录这些情况。

在常规(阻塞)模式下,程序会顺利运行,每次迭代写入1兆字节数据。然而,当写入目标(如管道或网络套接字)的缓冲区已满时,情况就不同了。

例如,当向一个管道写入数据,且管道的读取端没有及时消费数据时,在阻塞模式下,write调用会一直等待,直到有空间写入。而在非阻塞模式下,write会立即返回EAGAINEWOULDBLOCK错误,程序可以继续执行其他任务,稍后重试。

网络I/O是另一个常见场景。当通过TCP套接字发送数据时,TCP的发送缓冲区可能已满(例如,由于网络拥塞或接收方处理缓慢)。在阻塞模式下,发送数据的进程会被挂起。在非阻塞模式下,sendwrite调用会立即返回错误,允许程序处理其他连接或执行逻辑。

实践与探索

为了更好地理解非阻塞模式,建议你运行我们展示的示例代码。同时,回顾我们引用的早期课程内容。

尝试找出其他可能引入延迟或阻塞I/O的方式。也许可以尝试更改系统设置来调整TCP缓冲区大小,观察这在非阻塞模式下如何导致不同的吞吐量。

你还可以尝试通过网络向另一台主机发送数据,而不仅仅是在本地系统内。或者尝试在使用网络文件系统的系统上执行I/O操作。在这些情况下,你也应该能看到一些阻塞调用。

总结

本节课我们一起学习了非阻塞I/O。我们了解到,通过设置O_NONBLOCK标志(在打开文件时或通过fcntl),可以将文件描述符置于非阻塞模式。在此模式下,如果I/O操作(如readwrite)无法立即完成,系统调用会立即返回一个错误(EAGAINEWOULDBLOCK),而不是阻塞进程。这为构建高性能、响应式的应用程序(如服务器)提供了基础,使其能够在等待某个I/O操作时处理其他任务。下一节视频,我们将学习记录锁。

064:资源与记录锁定 🔒

概述

在本节课中,我们将学习如何在UNIX环境中使用资源与记录锁定。我们将探讨如何确保多个进程能够安全地访问共享资源,避免竞态条件。我们将重点介绍文件描述符的锁定机制,包括flock系统调用和fcntl记录锁定,并讨论它们的特性、限制以及在实际编程中需要注意的问题。


从独占访问到文件锁定

上一节我们介绍了非阻塞I/O,本节中我们来看看资源与记录锁定。我们的目标是找到方法,确保多个进程可以安全地访问共享资源,而不会遇到竞态条件。

我们将再次聚焦于文件相关的语义,更具体地说,是管理文件描述符。

首先,我们识别一些可以保证对资源独占访问的方法。需要明确并记住,我们讨论的是防止具有相同有效用户ID的进程同时访问,这意味着访问控制和权限在此不适用。

假设你有一个文件,并希望确保当前进程对其拥有独占访问权。你可以怎么做?

以下是几种可能的方法:

  • 创建并立即取消链接文件:创建一个新文件(使用O_EXCL标志确保独占创建),然后立即unlink它。只要文件描述符保持打开,文件数据块就不会被释放。其他进程无法打开这个文件,因此我们获得了独占访问权。
  • 使用日志文件:测试一个特定文件是否存在。如果存在,则表明其他进程正在访问我们感兴趣的资源,我们便放弃操作。
  • 使用信号量:如第8周所示,在代码中保护临界区。

然而,这些方法都有一些缺点。例如,一个被取消链接的文件在文件系统中不存在;使用信号量或日志文件需要管理目标文件之外的额外资源,增加了复杂性。

因此,我们可以使用专门为文件描述符锁定设计的系统调用:flock


flock 系统调用

flock系统调用对与文件描述符fd关联的文件应用或移除一个建议性锁。这些锁允许协作进程对文件执行一致的操作,但请注意,它们并不能阻止不检查锁的进程访问文件。也就是说,锁定的保证类似于信号量,依赖于相关进程的协作。

你可以以阻塞或非阻塞的方式应用锁,其语义与我们之前视频中讨论的相同。我们区分两种类型的锁:

  • 共享锁:多个进程可以同时持有共享锁。
  • 独占锁:为了持有独占锁,不能存在任何其他锁(包括共享锁)。

可以将共享锁视为读锁,多个进程可以同时从给定文件读取而不会出现问题;将独占锁视为写锁,即使是一个读操作,也可能被另一个同时写入数据的进程破坏。

如果你有一个共享锁并尝试升级为独占锁,你的共享锁会暂时释放,然后另一个进程可能在你之前获得锁。

要解锁文件,你可以指定LOCK_UN

flock控制的锁应用于整个文件。我们稍后会看到另一种只锁定文件特定区域的方法。由于这些锁应用于文件描述符,我们也可以通过flockfile函数来锁定文件流。


flock 实践演示

让我们通过代码flock.c来演示这个机制。我们有一个progress函数,它在等待时打印一些点。

main函数中,我们打开一个文件,然后请求对结果文件描述符的共享锁。在建立共享锁后,我们等待10秒,让另一个进程尝试同样的操作,然后尝试将共享锁升级为独占锁。当使用非阻塞模式时,我们会在失败时打印消息,然后重试,但最终为了避免死锁而放弃。

运行这个程序,我们观察到:

  1. 第一个进程以非阻塞模式启动,立即获得文件描述符的共享锁。
  2. 在第二个shell中运行相同的程序(阻塞模式),也能获得共享锁,这证明两个进程可以同时持有共享锁。
  3. 第一个进程尝试升级为独占锁,但由于第二个进程仍持有共享锁而失败(非阻塞模式,反复尝试并失败)。
  4. 第二个进程尝试升级其共享锁为独占锁,但为此它必须释放共享锁。此时,第一个进程立即获得了独占锁。
  5. 结果,第二个进程在升级锁时被阻塞。
  6. 一旦第一个进程完成并释放独占锁,第二个进程便获得其独占锁。
  7. 再次启动程序,它会阻塞在获取共享锁上,因为第二个进程仍持有独占锁。
  8. 当第二个进程终止,所有锁被释放,新进程获得共享锁。

这个演示展示了如何对整个文件描述符标识的文件进行锁定。


记录锁定:fcntl

对整个文件加锁有时可能不够优化。考虑一个大型文件,一个进程想写入一个部分,但不关心另一个进程是否写入另一个不同的区域。

为此,我们引入了所谓的记录锁定,它使用fcntl来应用或移除类似的建议性锁。这些记录锁可以应用于文件的一个区域,通过指定一个struct flock结构体来实现,该结构体使用了我们从lseek调用讨论中熟悉的offsetwhence语义。

与之前类似,锁类型可以是读锁、写锁,当然也可以是解锁。共享锁和独占锁的组合规则与之前相同。

此外,我们还有lockf库函数(不要与之前讨论的flock系统调用混淆)。lockf锁定从文件描述符当前偏移量开始、指定大小的文件区域。其选项不言自明,并且有明确的非阻塞测试选项。


锁的继承与释放

有了锁和文件描述符的概念,值得思考当我们fork另一个进程时,这些锁的行为如何。

首先,如果我们调用fork,锁不会被子进程继承。这似乎是合理且直观的,因为如果继承锁,我们就会有两个可能都持有独占锁的进程,这与独占锁的概念相矛盾。

当我们调用exec时,锁保持在原位。这看起来也合乎逻辑,但我们有一个选项可以防止这种情况:如果文件描述符设置了close-on-exec标志,那么在exec时它会被关闭,锁也就不会保留。

同样,当进程终止时,文件描述符上的所有锁都会被释放。这再次显得合理且可取,我们不希望一个文件在应用锁的进程终止后还保持锁定状态。

然而,有一个情况既不显而易见,实际上也不理想:锁与给定进程的文件关联,而不是与文件的特定文件描述符关联,并且如果文件的任何文件描述符被关闭,锁就会被释放。

记住这个图示,它提醒我们同一个文件可以被多个进程或在同一进程内同时打开。我们之前讨论异步信号安全函数时,已经看到过同一文件被多次访问导致问题的例子,例如调用密码文件函数(如getpwnam)时,它会打开密码数据库,执行查找,然后关闭数据库。如果你在密码数据库上获得了锁,然后调用getpwnam,那么在该调用返回后,你的锁就消失了。换句话说,你的进程需要知道你调用的任何库函数是否可能操作你持有锁的文件描述符所引用的任何文件。这是有问题的。


建议性锁与强制性锁

需要记住,我们讨论的所有锁定机制都是建议性的。也就是说,它们要求进程协作,并不能阻止不检查现有锁的另一个进程访问被锁定的资源。

那么,为什么不实现强制性记录锁呢?在某种程度上,一些UNIX版本通过重载组权限来实现强制性锁定。如果你将一个文件设置为setgid,但没有组执行权限,并且如果你的操作系统和文件系统支持强制性文件锁定,那么这会向内核发出信号,拒绝任何进程在另一个进程已锁定的文件上进行读取或写入。

然而,这种实现存在一些微妙的竞态条件,因此即使此功能可用,通常也建议不要依赖它。更重要的是,通常可以绕过强制性锁定,不是通过操作文件本身,而是通过操作与文件相关的目录条目。如果你锁定了文件A,你仍然可以创建一个全新的文件B,进行任何更改,然后取消链接文件A并将文件B移动到其位置。回想一下,删除文件是目录的操作,而不是文件本身的操作。因此,文件上的锁无法阻止它被从目录中删除或重命名。这样,严格来说,你违反了锁,但你获得锁的那个文件描述符从未被操作过,尽管最终结果是相同的。换句话说,强制性锁基本上不起作用,或者实现起来极其困难。

总之,不要依赖强制性锁。我们将在下周更仔细地研究如何限制进程相互干扰。


总结与练习

本节课中我们一起学习了UNIX环境中的资源与记录锁定。我们讨论的锁定机制大多是建议性的,需要进程协作。由于我们讨论的是协调协作进程,因此很容易在这里犯下可能导致死锁的错误。在编写处理锁的代码时,请确保考虑到这种可能性。

为了更好地理解文件和记录锁,请尝试以下练习:

  • 我们看到了锁定引用文件的文件描述符的例子。如果你尝试锁定标准输出stdout会怎样?文件流呢?
  • 编写一些代码来说明我们刚刚讨论的关于文件被关闭导致锁被释放的问题。
  • 尝试使用fcntl而不是flock来重写flock.c的代码。
  • 考虑到在文件中寻址,我们讨论了稀疏文件的概念。你可以寻址超过文件当前末尾的位置。我们能锁定超过文件当前末尾的区域吗?编写一些代码尝试一下。
  • 当你尝试对已拥有整个文件锁的文件部分应用记录锁时,会发生什么?尝试锁定重叠区域呢?

如你所见,可能会出现许多可能令人困惑的情况,通过编写代码来验证你的理解始终是最好的方法。

在下一个视频中,我们将通过介绍异步I/O和内存映射I/O来结束对高级I/O主题的讨论。

066:POSIX ACLs 🔐

在本节课中,我们将学习POSIX访问控制列表(ACLs)。这是一种比传统UNIX文件权限(用户、组、其他)更精细的访问控制机制,允许你为特定用户或组设置独立的权限。

概述

UNIX操作系统自诞生之初就是多任务、多用户的系统。这自然需要一系列概念,如独立的账户、用户权限、文件权限和进程所有权等。此外,系统上的所有资源都是有限的,因此进程之间存在着对CPU时间、内存、磁盘空间和文件描述符等资源的固有竞争。

系统会管理部分资源。例如,调度器使用算法将不同进程分配到可用的CPU上,确保没有进程因资源过度使用而“饿死”。磁盘空间是有限的,但系统可以通过实施用户配额来确保没有单个用户能占用所有可用空间。文件系统本身会为超级用户或系统使用保留一定数量的inode,这样即使系统磁盘完全写满,管理员仍能进行清理操作。

这些方法有些是操作系统特定的,有些则利用通用的系统调用来实现目标。此外,标准化的方法和事实标准也发挥着作用。

回顾已知知识

在开始新内容前,回顾我们已经学过的知识很有帮助。你会发现本学期的许多内容都与本主题直接相关。

例如,在第二周第一段的视频中,我们探讨了系统如何限制一个进程可以打开的文件描述符数量。open_max.c 程序展示了可以使用 getrlimit 检索每个进程的资源限制。系统范围的限制值可能硬编码在内核中,也可能来自固定头文件(如 sys/limits.h 中的 OPEN_MAX),或者是可在运行时调整的系统配置选项。

在讨论UNIX文件系统时,我们了解了文件访问的基本UNIX语义:用户、组和其他。结合第三周第三段中概述的目录访问逻辑,这个简单的模型允许我们限制进程可以访问文件系统中的哪些资源。

我们看到,访问权限是按照从最特定到最不特定的顺序确定的。通过组合组成员身份并仔细选择正确的权限,我们可以根据需要广泛地提供访问。然而,这种机制的粒度有些有限,因为它只允许在这三组用户(所有者、组和其他所有人)之间进行区分。即使用户可能是多个组的成员,在使用NFS的系统上,你可能被限制为只能属于16个组。

更重要的是,通过组成员身份进行访问控制特别繁琐。首先,与用户不同,一个文件只能属于一个组。因此,如果你想与A组和B组的成员共享文件,但不与其他人共享,你就无能为力了。此外,对组成员身份的任何更改都需要系统管理员的操作。用户无法自行控制其组成员身份或在shell中创建新组。

引入POSIX ACLs

一些UNIX文件系统通过使用所谓的访问控制列表(ACLs)来克服这些限制,最著名的是POSIX 1e扩展。这些ACL允许用户指定他们希望授予的更细粒度的访问权限。

这些访问控制列表作为所谓的扩展属性在文件系统中实现。这意味着除了内核支持外,还需要文件系统的支持。在默认的 ls 输出中,扩展文件属性的存在通常由常规权限字符串后的加号(+)表示。

从用户的角度来看,与之交互的工具是 setfaclgetfacl

由于POSIX ACLs的实现在不同UNIX版本中并不完全统一,让我们看看几个不同系统上的实际例子。

在Linux上使用ACLs

在这个终端中,我们将在Linux系统(特别是Ubuntu 16.04)上演示ACLs的使用。我的用户是几个组的成员,这意味着我可以轻松地一次与属于这些组之一的所有用户共享文件。

考虑这个文件 simple_cat.c。目前它只有所有者(即我自己)的读写权限。如果我想允许“professor”组的其他成员访问,我可以更改组权限。如果我想允许“6XV”组的成员访问,我可以将文件所属组改为该组。但这意味着现在“professor”组的成员不再有读取权限。那么,我如何让两个组都有访问权呢?

查看 setfacl 的手册页,我可以找到授予组访问权限的语法。让我们试试。

setfacl -m g:professor:r simple_cat.c

嗯,没成功。让我们看看我们在什么类型的文件系统上。哦,原来是NFS。NFS不支持POSIX ACLs,或者说NFS有自己的ACL实现,仅在NFSv4中受支持,并且使用不同的工具,而我们的系统上没有这些工具。所以让我们把文件移到本地文件系统。

好的,这是一个EXT3文件系统,应该支持POSIX ACLs,让我们再试一次。很好,没有错误。让我们再看看这个文件。

ls -l simple_cat.c

注意,现在 ls 输出在权限字符串后显示了加号,表示文件上有扩展属性。

让我们用 getfacl 来检查一下ACL。

getfacl simple_cat.c

在这里,我们看到我们为“6XV”组设置了组权限。等等,我们已经有了“6XV”组的组权限,但我们希望“professor”组保留读取权限。让我们给那个组读取权限。

setfacl -m g:professor:r simple_cat.c

现在看看 getfaclls 告诉我们什么。文件的组所有权是“professor”,UNIX组权限允许读取访问。但扩展属性也显示,“6AC”组保留了我们之前授予的读取权限。

好的,现在假设我们想把这个文件的访问权限授予班上的某些学生,比如Edward和Mingo。他们都只是“student”组的成员,而我不想让所有学生都能访问我的文件,所以我不想使用这个组。但是有了POSIX ACLs,我也可以指定单个用户,让我们试试。

setfacl -m u:edward:r,u:mingo:r simple_cat.c
getfacl simple_cat.c

这似乎成功了。以表格格式查看,可能更容易阅读权限,它清楚地表明我们现在能够提供细粒度的访问控制。

如果我们复制这个文件会发生什么?嗯,看起来我们必须重新应用所有的ACL,这很麻烦。也许有一种方法可以将ACL从一个文件复制到另一个文件。有了,setfacl 可以从标准输入读取ACL描述并应用它。

getfacl simple_cat.c | setfacl --set-file=- simple_cat_copy.c

很好。但更好的是,像 mvcp 这样的工具能够直接复制ACL。

cp -p simple_cat.c simple_cat_copy2.c
getfacl simple_cat_copy2.c

看,所有三个文件都有扩展属性,getfacl 显示了正确的权限。

如果我想清除所有的ACL,我可以使用 setfacl -b,文件将不再有任何扩展属性。

setfacl -b simple_cat.c
ls -l simple_cat.c

在macOS上使用ACLs

现在我们再次看到我们的 simple_cat.c 文件。但这次我们想授予“wheel”组访问权限,而不改变文件的组所有权。

为此,我们使用 chmod 并带上 +a 标志。

chmod +a "group:wheel allow read" simple_cat.c

和之前一样,如果我们运行 ls -l,会得到一个加号,表示文件有关联的扩展属性。

但在macOS系统上,我们不使用 getfacl。相反,我们使用带 -e 标志的 ls

ls -le simple_cat.c

ls -le 的输出告诉我们,扩展ACL规则0是授予“wheel”组读取权限。

如果我们想确保用户“daemon”无论其组成员身份如何都无法访问,那么我可以使用这个命令。

chmod +a "user:daemon deny read" simple_cat.c
ls -le simple_cat.c

现在,我们的规则集看起来是这样的。chmod 的手册页包含大量关于如何操作ACL以及哪些授权是可能的信息。注意,我们区分了对文件元数据的操作、对目录的操作、对常规文件的操作等等。手册页还包含许多示例,你可以自己尝试。

如果你想删除一个特定的ACL规则,你需要使用 # 号指定其编号。例如,这里我们删除第一条规则(规则0),那么逻辑上下一条规则就变成了第一条规则(新的规则0)。

chmod -a# 0 simple_cat.c
ls -le simple_cat.c

注意,在 -a# 号之间不能有空格。记住,在shell中,# 标记注释的开始,你也可以在命令行上使用注释。也就是说,-a# 0-a # 0 是有区别的。无论如何,要在macOS上删除所有ACL,你可以使用 chmod -N,和之前一样,文件将不再显示任何扩展属性。

chmod -N simple_cat.c
ls -l simple_cat.c

在NetBSD上使用ACLs

最后,让我们看看NetBSD。尽管这是我们最后看的参考平台,这是因为对POSIX ACLs的支持尚未包含在NetBSD的最新稳定版本中,但它将成为NetBSD 10.0的一部分。这里我们创建了一个运行NetBSD-current的独立虚拟机。

好的,这是我们的文件。它归我自己所有,组权限属于“wheel”组。“staff”组包含一些我想共享访问权限的用户,但由于我不是这个组的成员,我无法将文件所属组改为这个组。所以让我们试试 setfacl

setfacl -m g:staff:r simple_cat.c

不行。这里的根文件系统不支持ACL。正如前面解释的,文件系统需要支持POSIX ACLs。让我们像以前一样,在第二个磁盘上使用一个单独的文件系统。

我们在第二个磁盘 wd1 上创建一个新的文件系统。然后,使用 tunefs 在文件系统超级块中启用POSIX ACLs。我们挂载这个文件系统,并将其所有权授予一个普通用户。现在我们可以把文件复制到 /mnt。然后,调用 setfacl 来与“staff”组共享访问权限,使用与在Linux上相同的语法。

和之前一样,ls -l 通过加号显示了扩展属性的存在,getfacl 显示了结果。

现在,作为“staff”组成员的用户“fred”可以显示这个文件了。当然,“fred”仍然不能写入它。好的,现在让我们尝试给“molly”写入权限。

setfacl -m u:molly:w simple_cat.c

哦,看,“molly”是“staff”组的成员,但她不能读取文件,这是因为她的每用户ACL拒绝了读取权限,即使她的组成员身份允许她读取。这反映了我们常规的UNIX权限也是按顺序应用的。但她的写入权限按预期工作。

现在,假设你想给“staff”组中除“jenny”外的所有人访问权限。为此,我们像这样使用 setfacl

setfacl -m u:jenny:--- simple_cat.c

正如预期的那样,“jenny”尽管在“staff”组中,但不能读取文件,同样因为每用户权限优先于每组权限。

我们看到,虽然ACL允许细粒度的访问控制,但它们仍然遵循我们之前学到的一些相同语义。

总结

在本节课中,我们一起学习了POSIX访问控制列表(ACLs)。

访问控制列表作为扩展属性存储在文件系统中,因此需要文件系统对此提供支持。并非所有文件系统都必然支持它们,或者它们可能被作为挂载选项禁用。

如果你开始尝试使用ACL,很快就会遇到冲突的描述。如果你想授予一个组中除一人外的所有成员访问权限怎么办?用户ACL是否优先于组ACL?应用它们的顺序在这里也可能起作用,尝试一下并验证不同的结果。

POSIX ACL的实现可能因操作系统而异。我们看到了Linux上的例子,我们使用 setfaclgetfacl 工具,你可以查看该平台的 acl 手册页以获取更多细节。还有macOS上的例子,其中ACL使用 chmod 工具应用和操作,并通过 ls -le 检查。不同的BSD系统也实现了ACL,尽管我们的参考平台NetBSD仅在即将发布的NetBSD 10.0中才拥有POSIX ACL,并且它将使用从FreeBSD导入的 getfacl/setfacl 语义。

在下一个视频中,我们将重新审视有效用户ID和真实用户ID,以及如何使用 sudosu 命令提升权限,但我们也将看看如何以同样的方式限制你的权限。

067:进程限制机制进阶 🔒

在本节课中,我们将学习如何通过多种机制进一步限制进程的权限。我们将回顾如何更改有效用户ID(EUID)及其潜在风险,并探讨文件标志(chflags)、挂载选项(mount -o)和安全级别(securelevels)等额外的安全机制。

上一节我们介绍了基本的进程权限控制。本节中,我们来看看如何通过更精细的机制来加强系统安全。

回顾有效UID与风险

在之前的课程中,我们了解到Set-UID程序可以更好地控制进程以哪个用户的身份访问文件。具体来说,它控制的是以特定用户的有效UID(EUID)运行的进程可以访问哪些文件。

回想第3周第3节的内容,我们讨论了有效UID和真实UID的用法,以及如何更改或提升权限。一个常见的例子是守护进程(daemon),它们最初以超级用户(root)权限运行,以便绑定特权端口或使用原始套接字进行ICMP通信,然后在处理登录后降低权限。

然而,这些方法也存在一些陷阱:

  • Set-UID程序需要非常小心地编写,只在必要时提升或使用提升的权限,并避免让用户逃脱并运行任何其他程序。这通常比听起来要困难。
  • 这也依赖于二进制文件的正确安装和配置,而程序作者对此没有影响力。
  • 在可执行文件上设置Set-UID位,允许任何能执行该文件的人以文件所有者的权限运行它,这意味着这里没有细粒度的控制。

我们也见过使用su等命令更改有效UID的例子。但能够以另一个用户身份运行依赖于该用户的密码,并且它授予你对该用户控制的一切的完全访问权限。也就是说,你只能允许另一个用户su,从而以目标用户的身份做任何事情,而不能限制为功能或命令的子集。

sudo的局限性与风险

为了克服这些限制,并提供更多的控制、日志记录以及围绕更改EUID或以其他用户权限运行程序的额外保护,许多UNIX系统使用sudo实用程序。虽然它不是每个UNIX系统的一部分,但它是这里最常用的机制,允许细粒度地控制哪个用户可以以哪个其他用户的EUID执行哪些命令。

然而,sudo本身是一个复杂的工具,正确且安全地配置它并不容易。很多时候,试图将权限限制在一组命令中,结果用户却能够从其中一个命令中逃脱,获得比预期更高的权限。因为要求对命令执行进行额外的身份验证可能很繁琐,尤其是在自动化作业中,人们常常简单地禁用它。而且,人们常常没有意识到他们意外授予了哪些额外权限。

让我们通过一个例子来说明这个问题。

以下是sudo配置不当可能导致权限扩展示例的步骤:

  1. 创建两个文件:一个打算与用户fred共享,另一个打算保持私有。
  2. 授予fred以用户jsmith身份编辑文件的sudo权限(例如,允许fredjsmith身份无密码运行vi)。
  3. 用户fred可以成功编辑共享文件。
  4. 然而,fred也可以使用相同的sudo命令编辑私有文件,这展示了过于宽泛的sudo权限。
  5. 即使通过文件权限阻止fred直接访问私有文件,fred仍可能利用vi编辑器的功能(如:!shell)来执行shell命令,从而以jsmith的身份运行命令并修改文件权限。

这个例子说明了使用sudo的一些陷阱。许多命令(不仅仅是编辑器)都允许用户找到运行其他命令的方法。因此,通常最好假设,除非你非常小心,否则给予另一个用户对某些命令的sudo访问权限可能意味着给予他们完全访问权限。

文件标志(chflags)

除了基本的文件权限,我们还有文件标志(file flags)来进一步限制对文件的访问。

文件标志通过chflags命令设置,它有三种版本:作用于文件的标志、作用于符号链接的标志和作用于文件描述符的标志。如果你的文件系统支持它们,那么这些文件标志或文件属性提供了一种方式,例如,指示一个文件完全不能被更改,即使是所有者,甚至是root用户。这样的文件被称为不可变(immutable)。如果设置了这些标志,只有root可以清除标志。

主要的标志有:

  • uappnd:用户只可追加(User append-only)。
  • uchg / uimmutable:用户不可变(User immutable)。
  • sappnd:系统只可追加(System append-only)。
  • schg / simmutable:系统不可变(System immutable)。

让我们看看实际操作。创建一个文件并设置uappnd标志,使其变为只可追加。现在,尝试以常规写入模式打开文件会失败,但追加数据会成功。这意味着我们可以在文件级别为所有访问强制实施追加模式。

要查看文件标志,我们向ls命令传递-lo标志。创建一个新文件并将其标记为系统不可变(simmutable),这意味着所有者无法修改文件,甚至root也不能。

注意:文件不可变也延伸到它不能被删除,尽管我们通常认为取消链接只影响目录。这是因为取消链接仍然会通过减少链接计数来修改文件的元数据。正如我们所见,即使是root也无法删除该文件。当然,root可以撤销更改并清除文件标志。

文件标志也可以设置在目录上。例如,将一个目录标记为系统不可变后,我们仍然可以操作目录内的文件(如果文件本身允许),但无法删除或重命名该文件,因为这会修改其所在的目录。

挂载选项(mount -o)

文件标志是应用于单个文件级别的更改。现在,我们来看看在文件系统级别应用限制的方法。

假设我们在/mnt下挂载了第二个文件系统。我们有一个简单的程序,通过打印有效和真实的用户ID来说明Set-UID位的工作原理。让我们创建一个由nobody拥有的Set-UID可执行文件。当我们运行该程序时,Set-UID行为完全符合预期。

现在,假设我们想使任何Set-UID可执行文件无法从此文件系统运行。我们可以应用nosuid挂载选项。现在,运行程序显示Set-UID没有任何效果,真实和有效UID保持不变。

但我们还可以更进一步。假设你有一个分区,你的Web服务器可以访问。如果你的Web服务器存在漏洞,攻击者可能会将可执行文件写入磁盘以进行调用。因此,如果我们能使任何程序都无法执行,那就太好了。让我们将挂载选项更改为noexec。现在,如果我们尝试运行该程序,会得到“权限被拒绝”的错误。即使是root也无法在此文件系统上执行程序,因为它被挂载为noexec

还有其他挂载选项存在。有许多选项,其中一些可以提高文件系统的性能,而另一些可以帮助你保护系统。例如,让我们尝试readonly选项。现在,我们的文件系统被挂载为noexecreadonly,这可能会很有帮助。考虑到在常规系统操作期间,/usr下的任何内容都不太可能需要更改。通过将整个文件系统层次结构标记为只读,可以防止任何可能被入侵的进程(即使以UID 0运行)将后门安装到/usr/bin中。现在,我们无法删除任何文件,即使是root也不能,也无法创建新文件。当然,和之前一样,root可以撤销这些更改。

安全级别(securelevels)

我们知道,以UID 0运行的进程可以在系统上执行任何操作。这是设计使然,也是必要的。然而,我们也有需要以这种提升的权限运行某些服务,同时我们又希望能够限制恶意UID 0进程可能造成的损害。

解决这个问题的一个方案是所谓的安全级别(securelevels),它在BSD系列的UNIX系统中已经存在了一段时间,Linux后来也获得了支持。超级用户可以提高安全级别,但关键的是,即使是超级用户也不能降低它。实际上,你是在配置系统,使得某些事情在不降低操作系统安全级别的情况下是不可能的。而降低操作系统的安全级别需要系统重启,这对攻击者来说是嘈杂的、更可能被检测到的,并且会终止他们可能拥有的任何当前连接,从而要求攻击者对系统具有持久访问权限。

因此,定义了不同的安全级别:

  • -1:永久不安全模式(Permanently insecure mode)。
  • 0:不安全模式(Insecure mode)。
  • 1:安全模式(Secure mode)。
  • 2:高度安全模式(Highly secure mode)。

不同安全级别强制执行的各种限制在手册页securelevel(7)中列出。一些更有趣的限制涉及追加和不可变文件标志,以及挂载、卸载或重新挂载文件系统的能力。

让我们看看实际操作。如前所述,我们可以将文件系统挂载为只读,甚至root也无法进行更改。当然,root可以以读写模式重新挂载文件系统,然后修改文件。如果我们能阻止即使是root进行此类更改,那就太好了。

例如,在安全模式(级别1)下,文件标志可能无法被移除,内核模块可能无法被加载。在高度安全模式(级别2)下,你甚至无法挂载新磁盘。

让我们在一个新文件上设置系统不可变标志,并将文件系统挂载为只读。现在,通过sysctl命令检查当前的安全级别。我们处于永久不安全模式(-1),这就是为什么我们可以进行所有更改。但这也意味着我们现在可以提高安全级别。如果我们直接进入高度安全模式(2),那么我们就不能再更改文件上的标志,也无法以读写模式挂载文件系统。我们可以卸载文件系统,但无法再重新挂载回来。在安全级别2下,现在真的什么都做不了,即使是root也不行。

那么,让我们将安全级别降低回-1。我们也不能这样做。一旦设置了安全级别,你只能提高它,永远不能降低它,即使是root也不行。撤销这些更改的唯一方法是重启系统。

总结

本节课中我们一起学习了多种限制进程和增强系统安全的机制:

  1. susudo:可用于授予他人以其他用户身份运行命令的能力,但限制访问可能很困难。
  2. 文件标志(chflags):可用于限制某些访问。我们可以将文件标记为只可追加或不可变。在BSD衍生系统上,我们使用chflags;在Linux系统上,有作用类似的chattr命令。
  3. 挂载选项(mount -o):可以限制整个文件系统。我们可以将它们标记为不允许执行、不允许Set-UID,或完全只读。
  4. 安全级别(securelevels):可以防止root通过使用安全级别来撤销某些更改。降低安全级别需要系统重启,这相当具有侵入性,并且攻击者通常难以维持。

在下一个视频中,我们将讨论受限shell、chroot环境(jails),然后再回到查看应用于每个进程级别的限制。

068:受限Shell、Chroot与Jail 🔒

在本节课中,我们将学习如何通过受限Shell、Chroot和Jail等技术来限制进程的权限和访问范围。这些方法有助于增强系统安全性,特别是在运行需要特权的服务时。

上一节我们探讨了通过文件标志和挂载选项来锁定文件系统的方法。本节中,我们将转向更侧重于进程本身的限制技术。

受限Shell 🐚

受限Shell是一种限制用户执行命令能力的Shell。许多流行的Shell,如Bash或Korn Shell,都支持这种模式。在受限模式下,Shell通常会禁止以下操作:

  • 更改当前工作目录(即无法运行 cd 命令)。
  • 修改 PATH 和Shell环境变量。
  • 指定包含斜杠(/)的命令(旨在只允许在 PATH 默认目录中找到的命令)。
  • 将输出重定向到文件。

通过这些限制,管理员可以通过将允许用户运行的可执行文件放在特定位置,并在调用受限Shell前相应地设置 PATH 环境变量,来合理控制用户可调用的命令。

然而,正如我们之前配置 sudo 时所见,管理员需要充分了解并理解允许用户在受限环境中执行的命令。许多命令可以用来“逃逸”到其他程序,或者用来克服受限Shell的部分或全部限制。

以下是使用受限Korn Shell(rksh)的一个示例。首先,我们创建一个受限Shell,并将用户fred的登录Shell更改为它:

# 创建指向受限ksh的链接
ln -s /bin/ksh /usr/local/bin/rksh
# 更改用户fred的登录shell
usermod -s /usr/local/bin/rksh fred

当用户fred登录后,他只能运行PATH中的命令。尝试使用相对路径(如./sudo)或更改目录(cd)都会失败。但是,如果PATH中包含了像ed(标准Unix编辑器)这样的程序,用户就有可能通过编辑启动文件(如.profile)来修改PATH,从而绕过限制。例如,用户可以使用ed编辑器移除限制PATH的代码行。

这个例子说明,在使用受限Shell时,必须非常小心地选择放入PATH的命令,并确保这些命令本身不能被用来突破限制或执行另一个Shell。通常的做法是:

  1. 创建一个新目录作为新的PATH
  2. 仔细选择允许的命令,并将它们链接到该目录。
  3. 确保这些命令都不能用于逃逸Shell、绕过限制或执行另一个Shell。
  4. 设置PATH
  5. 防止用户修改他们的启动文件(例如,使用之前视频中讨论的文件标志)。

Chroot:改变根目录 🏠

虽然受限Shell可以限制用户执行命令的能力,但有时用户可能需要与文件系统的特定部分交互,而不暴露整个文件系统。chroot 系统调用(1979年加入Unix)解决了这个问题。

chroot 将传递给它的目录名设置为进程的根目录。这意味着所有路径名都将在这个新目录下进行解析。通过这种方式,可以向进程暴露一个受限的文件系统副本或视图。

要使用chroot,你需要确保所有必需的库和可执行文件都存在于这个新的根目录中。一旦设置完成,你就可以限制一个需要以超级用户权限运行的进程。即使该服务被攻破,攻击者也会发现自己被困在chroot环境中,无法访问chroot之外的任何资源。

让我们看一个创建最小化chroot环境的脚本示例。该脚本会确定运行特定命令(如shpsid)所需的共享库,并将它们复制到chroot目录中。

#!/bin/sh
# 这是一个简化的示例脚本框架
JAIL=/home/jail
mkdir -p $JAIL
# 复制必要的二进制文件和库
cp /bin/sh $JAIL/bin/
cp /usr/bin/id $JAIL/usr/bin/
cp /bin/ps $JAIL/bin/
# 使用ldd等工具查找并复制依赖的库
# ...

创建好chroot后,可以使用以下命令进入:

chroot /home/jail /bin/sh

进入后,进程的根目录就是/home/jail。它只能看到和访问chroot环境内的文件。例如,运行 id 命令可能只显示数字用户ID,因为 /etc/passwd 文件不在chroot内。

然而,chroot有两个重要的方面可能导致逃逸:

  1. 进程在进入chroot时,可能仍然持有在chroot外打开的文件描述符,这可能导致风险。
  2. chroot内部,你仍然可以看到系统上其他进程的信息(例如通过ps命令),这意味着进程空间并未完全隔离。

FreeBSD Jail:操作系统级虚拟化 🚀

为了进一步限制进程,使其甚至无法感知到系统上其他进程的存在,FreeBSD在2000年左右引入了jail系统调用和工具。

除了chroot的限制外,Jail还:

  • 强制每个Jail有独立的进程视图(看不到Jail外的进程)。
  • 禁止更改系统时间或安全级别。
  • 禁止挂载和卸载文件系统。
  • 可以绑定到特定的IP地址,并将网络功能限制在该地址。
  • 禁止修改网络配置。
  • 禁用原始套接字。

通过这种方式,Jail有效地实现了一个进程沙箱或虚拟环境。你甚至可以为不同的操作系统版本创建Jail(只要宿主内核能够运行或模拟该环境)。Jail是操作系统级虚拟化的早期方法之一,为后来的容器技术(如Docker)铺平了道路。

总结 📝

本节课我们一起学习了三种限制进程的技术:

  1. 受限Shell:在Shell内部限制用户,但限制完全由Shell自身强制执行,而非操作系统内核。
  2. Chroot:通过改变进程的根目录来限制其对文件系统的视图,是一种更强大的限制方式,即使对于UID为0的进程也有效。但它可能存在通过文件描述符逃逸的风险,并且不隔离进程视图。
  3. Jail:在chroot的基础上,增加了进程、网络和系统资源的全面隔离,是早期操作系统级虚拟化的实现,为现代容器技术奠定了基础。

这些技术都体现了限制进程、最小化攻击面的安全思想。在接下来的视频中,我们将探讨允许我们构建现代容器的其他特性和机制。

069:进程优先级 🎯

概述

在本节课中,我们将要学习进程优先级的概念。我们将了解操作系统如何调度进程,以及如何通过调整进程的“友好度”来影响其获取CPU时间的频率。这是进程自我资源限制的一个重要方面。

上一节我们介绍了通过受限Shell、chroot和jail来限制进程。本节中我们来看看如何从CPU调度的角度来限制进程的资源使用。

即使我们能够精细地控制哪些用户可以执行哪些命令,通过文件系统权限或chroot/jail控制文件访问,我们仍然面临一些问题。所有软件都可能存在缺陷,人类也容易错误配置,这可能导致进程执行不应执行的操作,或干扰系统的正常运行。

所有进程,包括那些在jail中运行的进程,本质上都在竞争相同的资源。它们可能无限期运行,或消耗过多资源。

我们在第6周第5节讨论过限制进程资源使用的方法,即使用getrlimitsetrlimit系统调用和shell内置命令ulimit。资源限制的使用引出了一个重要概念:自我限制。即进程可以自愿限制自身资源使用,使得它之后无法重新获得先前拥有的权限。这同样适用于该进程创建的任何子进程,从而允许创建受限制更强的进程或进程组。

我们之前观察到的一个限制是总CPU时间(以秒为单位)。你可能不希望某些作业持续占用CPU。但如果没有这个ulimit限制,进程是否会一直占用CPU而不让其他进程运行?

为了更好地理解进程如何竞争CPU周期,让我们简要看一下调度器如何将进程放置到CPU上运行。

进程调度基础

你可能在基础操作系统课程中见过类似的图示,但为了澄清我们对这里讨论的资源限制的理解,快速回顾一下这个话题仍然很有用。

在一个简单的轮询调度中,有一个等待CPU时间的运行队列。调度器从运行队列中选取下一个作业并将其放到CPU上运行。

但一个作业可能不会运行到完成,而是请求一些I/O操作。如前所述,这在计算机术语中非常慢。因此,当进程因I/O而阻塞时,调度器可能将其移动到等待队列。这释放了CPU来处理另一个作业。调度器从运行队列中选取下一个作业(例如进程2、3、4),并让该作业运行一段时间。

但即使进程2、3、4没有请求I/O或阻塞,它也可能不会运行到完成。如果我们简单地让任何作业获取CPU并一直保持直到完成,那么它将阻塞所有其他作业。因此,调度器在一段时间后会抢占该作业,并将其放回运行队列,给下一个作业运行的机会。

现在进程8723在CPU上运行。进程1、2、3、4可能最终完成了它之前阻塞的I/O操作,因此它重新进入运行队列。但现在它排在队列末尾,必须等到它前面的所有其他作业都获得它们的CPU时间片后,才能再次获得工作机会。

这种简单的轮询方法还假设所有作业具有相同的重要性,并且应该始终获得相同数量的CPU时间。但在现实中,我们有时希望某些作业比其他作业更受青睐。某些对操作系统平稳运行至关重要的系统作业或内核任务,可能不应该被普通用户启动的不重要的长时间运行进程所抢占。

优先级调度

让我们再试一次。这次,除了运行队列,我们还让每个进程拥有一个优先级。如果所有作业具有相同的优先级,那么调度器可以像之前一样从队列中选取第一个作业。

但假设不同的作业有不同的优先级。在这种情况下,调度器将从运行队列中选取优先级最高的作业并将其放到CPU上。例如,进程8、7、2、3的优先级为15,进程8、8、3、3是下一个。请注意,当我们将进程8723放到CPU上时,我们正在增加所有等待进程的优先级。这确保了任何等待很长时间的进程最终会获得足够高的优先级被选中。

现在,如果进程8、7、2、3被阻塞(例如等待I/O),那么它被放入等待队列,同时下一个作业(进程8833)获得CPU,并且运行队列中的其他作业的优先级再次增加。一段时间后,进程8833被抢占,但请注意,当它被放回运行队列时,它的优先级会略微降低,因为它刚刚使用了CPU。然后进程1、2、3、4获得CPU。

如果进程8、7、2、3现在完成了它的I/O操作,它将被放回运行队列,但由于它从未在CPU上获得完整的运行周期,它的优先级一直保持不变。这样,被I/O阻塞的作业现在拥有最高的优先级。当进程1、2、3、4被抢占时,它就获得了CPU。

这是一个非常简化的动态优先级调度视图,但我想你已经理解了:通过允许优先级,我们可以确保作业根据其需求进行调度;并且通过基于CPU使用周期动态调整优先级,我们可以避免即使是低优先级作业的“饥饿”现象。

当然,还有许多其他调度算法,但对我们来说,这个近似模型已经足够好,现在可以看看如何调整进程优先级。

调整进程优先级

为此,我们有getprioritysetpriority系统调用。默认情况下,每个进程的优先级为0(中性)。which参数指定你感兴趣的是进程优先级、进程组优先级还是所有用户进程的优先级。对于多个进程,getpriority将返回这些进程中最高的优先级。

默认优先级0是中性值。数值上更低的优先级值会导致更有利的调度,更高的值意味着更不利的调度。这有点令人困惑,因为通常我们认为更高的优先级会导致更有利的调度。这就是为什么我们要区分实际的内核优先级和进程的友好度

正如我们稍后将看到的,我们可以使用nice工具调整进程的优先级,这里的逻辑映射就更有意义了:一个进程越“友好”,它就越愿意让其他进程使用CPU。😊 所以,更高的数字意味着你更友好,因此获得更低的内核调度优先级。

你可以设置的值范围从-20到+20,尽管在某些UNIX版本上,你最多只能“友好”到19级。值为19或20的进程只有在没有优先级小于或等于0的进程运行时才会被调度。

类似于我们处理ulimit的方式,我们的进程只能提高其友好度(即降低优先级),除非你是超级用户,否则永远不能降低它(即提高优先级)。我们稍后会看到这方面的例子。

最后请注意,由于我们的友好度值范围是-20到+20,而-1是getpriority的有效返回值,这意味着我们不能仅仅依赖返回值来识别错误。因此,我们需要在调用getpriority之前显式设置errno,然后在调用之后检查errno

让我们看看所有这些在实际中是如何运作的。

观察系统负载

当我们运行uptime命令时,不仅会看到主机运行了多长时间,还会看到负载平均值,即三个数字。

这三个数字分别显示过去1分钟、5分钟和15分钟的平均负载。平均负载定义为在该时间间隔内运行队列中进程数量的平均值。

以下是一个简单的程序,仅打印平均负载:

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    double avgs[3];
    if (getloadavg(avgs, 3) < 0) {
        perror("getloadavg");
        exit(1);
    }
    printf("%.2f %.2f %.2f\n", avgs[0], avgs[1], avgs[2]);
    return 0;
}

我们的程序打印的三个数字大致与uptime报告的数字匹配,因为平均值是实时计算的。

现在,让我们尝试生成一些繁忙的工作。

创建繁忙作业

这是一个会让我们的CPU忙碌一会儿的简单脚本。它只是不断地循环进行减法运算。

#!/bin/sh
i=0
while [ $i -lt 100000 ]; do
    i=$((i + 1))
    j=$((100000 - i))
    k=$((j - i))
done

如果我们运行这个脚本,然后在另一个窗口查看top命令的输出,我们会看到我们的繁忙作业(例如进程ID 1361)显示在那里,内核优先级为28,友好度为0。当我们的可运行作业减少时,我们会发现这个脚本完成了。

现在,让我们并行运行几个脚本实例,以观察我们的CPU优先级是如何分配的。

我连续运行了四个实例,中间稍作等待。

我们期望这四个作业按照它们启动的顺序完成:A、B、C、D。

在另一个窗口中,我们应该看到这些作业一个接一个地出现,每个都有相同的友好度级别,并且大致相同的内核优先级(随着它们获得CPU和被抢占,每次更新时都会调整)。随着它们完成,我们将看到预期的顺序:首先是A,然后是B,接着是C,最后是D。

现在,让我们尝试在启动作业后调整它们的优先级。

使用 nicerenice 调整优先级

我们可以使用nice命令指定初始优先级,或者使用renice命令调整已运行进程的优先级。

让我们使用renice。但为此,我们需要作业的进程ID。所以让我们在启动它们时打印出来。

现在我们有了作业A(进程ID 2197)、B(1869)等等。

让我们调整优先级,给进程A最高的友好度级别(即最低优先级),给进程D友好度级别15,给进程B友好度级别5,让进程C保持默认。

现在我们可以再次在这里观察我们的作业。我们看到友好度级别在这里反映出来,当前优先级也相应调整。

随着作业完成,我们应该发现另一个终止顺序。现在,C首先完成,然后是B,接着是D,A最后完成。这反映了我们分配给每个进程的友好度级别,表明优先级更高的作业确实获得了更多的CPU时间,因此能够更快完成。

现在,让我们快速看一下nice如何设置优先级。

编程接口示例

这里有一个程序,它打印其当前优先级,然后尝试将其优先级设置为命令行提供的值,接着打印出新优先级,然后尝试将优先级设置回程序启动时的值,最后再次报告其当前优先级并退出。

#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>
#include <errno.h>

int main(int argc, char *argv[]) {
    int new_nice, old_prio, new_prio;
    id_t pid;

    if (argc != 2) {
        fprintf(stderr, "Usage: %s <nice_value>\n", argv[0]);
        exit(1);
    }
    new_nice = atoi(argv[1]);
    pid = getpid();

    // 获取当前优先级
    errno = 0;
    old_prio = getpriority(PRIO_PROCESS, pid);
    if (errno != 0) {
        perror("getpriority");
        exit(1);
    }
    printf("Initial priority: %d\n", old_prio);

    // 尝试设置新优先级
    if (setpriority(PRIO_PROCESS, pid, new_nice) < 0) {
        perror("setpriority to new value");
    }

    // 获取新优先级
    errno = 0;
    new_prio = getpriority(PRIO_PROCESS, pid);
    if (errno != 0) {
        perror("getpriority after set");
        exit(1);
    }
    printf("Priority after first set: %d\n", new_prio);

    // 尝试设置回旧优先级
    if (setpriority(PRIO_PROCESS, pid, old_prio) < 0) {
        perror("setpriority back to old value");
    }

    // 获取最终优先级
    errno = 0;
    new_prio = getpriority(PRIO_PROCESS, pid);
    if (errno != 0) {
        perror("getpriority final");
        exit(1);
    }
    printf("Final priority: %d\n", new_prio);
    return 0;
}

让我们运行它并请求友好度级别5。我们以默认友好度级别0开始。我们能够设置新的友好度级别,因为我们在提高友好度(即降低优先级)。之后,我们尝试将优先级设置回0,但这失败了。因此,我们的优先级在整个过程中保持为5。

如果我们以超级用户权限运行相同的命令,那么我们就能够再次降低我们的友好度(即提高优先级),从5回到初始的0。

如果我们一开始就非常“友好”会怎样?让我们试试。我们的初始级别现在是10,因此尝试将其设置为5会失败,因为那是在降低我们的友好度(即提高优先级)。但我们当然仍然可以变得更友好,进一步降低我们的优先级。

我们能一开始就“不友好”吗?我们的默认值是0。让我们尝试-5。这失败了。我们不能将友好度级别降低到0以下。但请注意,nice命令仍然让我们以默认优先级运行程序,而超级用户当然可以以较低的友好度值启动,然后随意调整。

总结

本节课中我们一起学习了进程优先级的管理。

如前所述,进程能够自愿自我限制资源使用。这既适用于ulimit资源限制,也适用于CPU调度优先级。

我们可以使用setpriority系统调用,或使用命令行工具nicerenice来调整我们的CPU优先级(即友好度)。这可以针对单个进程或进程组进行。

与人类世界不同,一旦你变得“友好”,你就不能再决定变得“淘气”。这遵循了一个原则:我们可以降低自己的权限,但一旦降低就不能再提高。目的是允许进程有意放弃某些权限,以便它或其子进程在以后不能滥用这些权限。

最后,虽然我们可以通过调整友好度在一定程度上控制获得CPU周期的频率,但这对于我们在拥有多个CPU的系统上被调度到哪个CPU没有直接影响。可以理解,在多CPU系统上,你可能希望保留一个CPU用于系统进程,而将所有其他作业分配给其他CPU。我们将在下一个视频中讨论如何实现这一点。

070:处理器亲和性与CPU集 🎯

概述

在本节课中,我们将学习如何将进程限制在特定的CPU上运行。上一节我们讨论了进程的CPU时间限制,本节中我们来看看如何控制进程在哪个CPU核心上执行,即处理器亲和性与CPU集。

CPU调度与进程放置

假设我们有一个包含四个CPU的系统,上面运行着许多进程。这些进程包括从shell交互运行的命令、一些系统守护进程,以及一些执行CPU密集型工作的通用作业。

在通常的基于时间片和优先级的调度算法下,这些进程可以被放置在任何可用的CPU上。随着工作的完成、作业被抢占和重新调度,它们可能会从一个CPU移动到另一个CPU,或者根据调度器的判断,新作业被放置到CPU上。

处理器亲和性(CPU Pinning)

现在假设我们的工作作业都是CPU密集型的。如果它们被放置在任何CPU上,可能会导致系统负载过高。根据它们的优先级,一些系统守护进程可能无法像你希望的那样快速完成。

因此,我们尝试选择这些工作作业,并确保它们不被放置在任何CPU上,而只放置在CPU 1和CPU 2上。这种做法称为CPU固定或分配处理器亲和性。

当我们这样做时,工作进程被正确地放置到这些CPU上。但请注意,我们仍然有其他作业在CPU 1和CPU 2上运行,shell和find命令并没有从CPU上被驱逐。实际上,新的进程仍然可以根据需要被放置到CPU 1和CPU 2上。只有那些被绑定到指定CPU的工作进程受到限制,其他所有进程仍然可以按照调度器认为合适的方式放置。

实践演示

以下是一个让CPU保持忙碌的简单程序:

// 示例CPU密集型程序
int main() {
    while(1) {
        // 空循环,消耗CPU周期
    }
    return 0;
}

在运行它之前,我们在屏幕下半部分运行top命令。在这个系统上,我们有四个可用的CPU,可以看到一些系统进程。

当我们启动工作进程时,我们注意到它最初在CPU 0上启动,然后被放置到CPU 2上,并在那里持续运行和消耗周期。

我们将其放入后台运行,注意到它现在被移动到了CPU 3。然后我们启动第二个工作进程到后台,它最终在CPU 2上运行。我们再启动一个,又启动一个。现在我们有了四个工作进程,调度器将它们分布到所有四个CPU上。

如果我们再运行另一个作业,例如dd,它必须与其他进程共享CPU,并最终在CPU 2上运行。我们的工作作业完全在用户空间执行,但dd作业在执行I/O时也会在内核空间执行,这可以在top的显示更新中看到。

我们再运行另一个作业,例如wc程序,我们观察到wc程序在后续调用中被放置在不同的CPU上,而我们的工作进程仍然占用着所有四个CPU。

使用taskset命令分配处理器亲和性

我们可以使用taskset控制命令来分配处理器亲和性。除了亲和性,我们还可以调整调度算法和优先级。

首先,让我们查看当前shell的处理器亲和性:

taskset -p $$

现在,我们没有任何亲和性设置,这意味着调度器可以自由地将这个shell放置在任何它喜欢的CPU上。

现在运行一个工作作业,它最终在CPU 2上。我们尝试将其移动到CPU 3:

sudo taskset -pc 3 <PID>

更改CPU亲和性需要超级用户权限,这是合理的。我们不希望允许普通用户随意移动他们的作业,从而可能干扰其他进程。

现在我们已经移动了进程,我们的工作进程对CPU 3有了亲和性。我们可以在下面的top显示中看到它从CPU 2移动到了CPU 3。我们可以将其移回CPU 2、CPU 1或CPU 0,并在top显示中看到更新。

我们可以选择允许用户更改CPU亲和性。为此,我们必须更改用户的set CPU affinity能力。现在我们可以将当前shell从没有亲和性移动到CPU 2。

由于CPU亲和性是由子进程从其父进程继承的,当我们在这里启动一个新的工作进程时,它也会在CPU 2上运行。因此,即使我们运行多个工作作业,它们都将保持绑定到CPU 2,而其他CPU保持空闲。

请注意,虽然我们将工作作业绑定到了CPU 2,我们仍然可以将其他作业移动到该CPU上。例如,移动top进程本身。

同样,即使子进程继承了其父进程的亲和性,dd作为我们shell的子进程,对CPU 2有亲和性,也会被放置在该CPU上。我们仍然可以显式地将其移开,同样也可以将工作作业移动到不同的CPU。

CPU集(CPU Sets)

我们已经看到了如何通过分配处理器亲和性将单个作业移动到特定的CPU上。但正如我们也看到的,这仍然允许其他进程被放置到这些CPU上,可能与我们的作业竞争。我们能否为一个或多个CPU保留特定的任务,使得没有其他作业可以在它们上面运行?

假设我们想使用我们的四个CPU,并保留其中两个给我们的工作作业,一个给我们的shell。我们可以使用CPU集来实现这一点。当你创建CPU集时,你总是会保留一个默认集,用于任何剩余的进程。

在我们的示例中,所有的系统进程将最终在CPU 0上。我们可以显式地将我们的shell绑定到CPU 3,这意味着随后由shell启动的任何进程也将被绑定到该CPU集。如果我们然后将我们的工作作业绑定到CPU 1和CPU 2,那么我们将看到这样的分布。

请注意,我们可以从shell启动其他守护进程,它们将继续仅被放置在CPU 3上。而任何未显式绑定到CPU集的作业的调度将最终在CPU 0上,但除了我们的工作作业外,没有其他作业会被分配到CPU 1和CPU 2。

实践示例

我们稍微扩展了我们的小忙碌程序,以便更容易区分工作作业。现在,我们的程序接受一个参数,指定要启动多少个作业,然后使用易于区分的argv[0]运行它们。

像之前一样,我们在屏幕下半部分运行top。我们启动六个工作作业,它们按预期分布在所有四个CPU上。

但正如我们所说,我们想使用CPU集。cpuset命令显示当前的CPU集:一个默认集,包含所有四个CPU。

现在,让我们从我们的图示中复制一个设置。像之前一样,创建CPU集不是普通用户允许的操作,所以我们需要sudo权限。

现在我们有了三个CPU集:默认CPU集(仅包含CPU 0)、CPU集1(包含CPU 1和CPU 2)以及CPU集2(包含CPU 3)。

现在,我们运行我们的六个工作作业,它们都最终在默认CPU集的CPU 0上。这是因为任何未显式绑定到CPU集的作业只能被放置在默认CPU集中。这不是我们想要的,所以我们将它们放置在CPU集1上。

现在,我们看到它们仅按计划分布在CPU 1和CPU 2上。但请注意,尽管将工作进程绑定到给定的CPU集,我们仍然可以显式地将其移动到默认CPU集中的CPU上。但是,如果我们尝试将top进程从默认CPU集(CPU 0)移动到CPU 2,我们会失败。CPU 2是非默认CPU集的一部分,因此不允许任何未通过其他cpuset命令显式绑定到它的作业。对于CPU 3也是如此,它是非默认CPU集2的一部分。

我们甚至无法将我们从CPU集中移除的进程移回。为了使CPU和CPU集可用于任何其他作业,我们必须再次删除CPU集,此时所有作业可以再次被调度到任何CPU上。

总结

本节课中我们一起学习了如何将作业限制在特定的CPU上。我们注意到,出于性能原因,限制作业到特定的CPU是有益的:在同一CPU上调度进程和线程可以减少CPU缓存未命中的次数,或者确保资源被公平使用,或者防止一组进程干扰其他作业。

有两种方法可以实现这一点。第一种是CPU固定,我们为进程或进程组分配CPU亲和性。虽然指定的进程将被绑定到指定的CPU,但其他作业仍然可以被放置到同一个CPU上。正如我们所看到的,子进程将从其父进程继承CPU亲和性,但更改父进程的CPU亲和性不会同时更改其所有子进程的亲和性。

与处理器亲和性相比,CPU集允许你真正为一个或多个CPU保留特定的作业。调度器将无法将任何作业移动到这些CPU上,除了那些显式绑定到它们的作业。像之前一样,子进程从其父进程继承CPU放置,你可以通过更改其亲和性显式地将进程从CPU集中移除。但要将其移回CPU集,你需要显式调用cpuset命令。

最后,这些都不是标准化的。不同的操作系统以不同的方式实现CPU固定和CPU集,并使用不同的命令行工具和库函数,因此请务必检查你特定操作系统的参考手册。

回顾所有我们可以限制进程的不同方式,我们现在应该能够将它们结合起来构建非常特定的环境。但在我们这样做之前,我们还需要涵盖最后一个主题:控制组、命名空间和能力。我们将在下一个视频中讨论这些内容。

UNIX高级编程:13.6:能力、控制组与容器 🚀

在本节课中,我们将完成对进程限制方法的讨论,重点介绍POSIX能力、Linux控制组(cgroups),以及这些技术与我们之前讨论的其他方法如何共同构成了容器技术(如Docker或LXC)的基础。我们已经了解了限制CPU使用率、文件系统视图、内存和进程表访问的方法,本节将进一步探讨如何通过更精细的控制来更好地隔离和管理进程组。


概述:从能力到容器

上一节我们探讨了资源视图的限制。本节中,我们来看看如何通过定义进程的“能力”来实施更通用的权限控制,并介绍用于资源隔离的Linux命名空间和控制组,最终理解这些技术如何共同构建出轻量级的容器。


POSIX能力模型

一种定义通用需求的方法是使用POSIX能力模型。在这个模型中,我们不是针对特定问题(如受限shell或chroot)提供具体解决方案,而是识别进程所需的通用“能力”,并授予对这些能力的细粒度访问控制。

例如,可以定义以下能力:

  • CAP_CHOWN: 更改文件所有者的能力。
  • CAP_SETUID: 允许设置用户ID(setuid)。
  • CAP_LINUX_IMMUTABLE: 允许设置文件的不可变标志(如chattr +i)。
  • CAP_NET_BIND_SERVICE: 允许将网络套接字绑定到1024以下的端口。
  • CAP_NET_ADMIN: 允许进行网络接口配置和路由表操作。
  • CAP_NET_RAW: 允许使用原始数据包(如ping)。
  • CAP_SYS_ADMIN: 一个更广泛的系统管理能力集合,提供诸如挂载文件系统、设置主机名、管理交换空间等特权。

不同操作系统对此标准的解释和实现方式各异。例如,FreeBSD通过Capsicum框架实现能力模型,而Linux系统的具体实现细节可以在 capabilities(7) 手册页中查阅。


Linux命名空间

另一种划分系统、限制进程和进程组对资源可见性的方法是Linux命名空间,其灵感来源于贝尔实验室的Plan 9操作系统。

使用命名空间,一个进程组可以以不同于其他进程组的方式,或者完全看不到某些系统组件。这些资源可以存在于多个命名空间中,从而在划分系统、仅向特定进程组暴露必要资源方面提供了高度的灵活性。

以下是可通过命名空间进行虚拟化的资源类型:

  • 挂载点:独立的文件系统视图。
  • 进程ID:独立的进程ID空间,仅能看到本命名空间内的进程。
  • 网络:虚拟化的网络栈,每个命名空间拥有自己的IP地址、路由表、防火墙规则等。
  • System V IPC:独立的信号量、共享内存和消息队列内核结构视图。
  • UTS:独立的系统主机名和域名。
  • 用户:独立的用户ID映射,例如,可以将命名空间内的root用户映射到宿主系统的一个非特权用户。
  • 时间:允许不同进程使用不同的系统时间。

控制组

控制组,最初称为“进程容器”,用于隔离我们之前讨论过的各种资源使用。

以下是cgroups允许进行控制的主要方面:

  • 内存限制
  • CPU使用率、优先级和限制
  • 资源统计:跟踪哪些进程以何种方式使用了哪些资源。
  • 进程控制:允许暂停、中断、对进程进行检查点保存和恢复。

cgroups经历了至少一次重新设计,版本2目前支持以下控制器:

  • 任务调度能力。
  • CPU和内存使用率。
  • cgroup自身的活动控制(例如,冻结组中的任务将不会被调度)。
  • 大页支持和使用。
  • 块设备I/O。
  • 内存、内核内存和交换内存。
  • 线程监控。
  • 可用进程数量的限制。
  • 远程目录内存访问。

cgroups通过虚拟文件系统实现,通常挂载在 /sys/fs/cgroup,并允许通过挂载选项启用不同的控制器。

以下是一个限制当前shell内存使用量的简单示例代码:

# 创建一个新的cgroup(在cgroup v2中,通常是在/sys/fs/cgroup下创建子目录)
sudo mkdir /sys/fs/cgroup/memory/my_group

# 将当前shell的进程ID写入cgroup.procs文件
echo $$ | sudo tee /sys/fs/cgroup/memory/my_group/cgroup.procs

# 设置内存限制(例如,限制为100MB)
echo “100M” | sudo tee /sys/fs/cgroup/memory/my_group/memory.max

cgroups(7) 手册页提供了非常详细和广泛的信息,建议你花时间阅读。


容器:技术的集大成者

最后,容器,顾名思义,是一种“容纳”进程的方式。它们在同一操作系统内核上提供了一个隔离的执行环境,这是与完全硬件虚拟化的重要区别,因此是一种更轻量级的方法。

为了“容纳”进程,你可能会组合使用以下技术:

  • 使用 chroot 或联合挂载来提供正确的文件系统视图。
  • 使用资源限制来避免进程干扰父系统或其他进程。
  • 使用命名空间来限制文件系统、进程、网络等的可见性。
  • 使用能力模型来限制进程可以执行的操作。

尽管我们在此上下文中讨论了许多应用此类限制的方法,但cgroups和命名空间经常被一起讨论,因为它们能很好地互补。事实上,cgroups和命名空间的结合构成了许多操作系统级虚拟化和容器技术(如CoreOS、LXC或Docker)的基础。

考虑我们从学期初就使用的操作系统基本分层模型:

  1. 底层是硬件。
  2. 内核管理硬件。
  3. 系统调用是进入内核的接口。
  4. 一系列库函数允许应用程序在操作系统内执行。

在传统模型中,内核拥有对硬件的广泛访问权,进程也大体上保留对硬件的相同视图。每个进程的文件系统、进程空间和网络能力是相同的,尽管我们可以限制每个进程能直接操作的内容。

完全硬件虚拟化中,情况有所不同。我们在硬件和内核之上增加了一个虚拟机监控程序,它虚拟化硬件并将其提供给每个虚拟机。在虚拟机内部,每个客户操作系统只能看到虚拟机监控程序提供的资源。

而在轻量级操作系统级虚拟化中,我们从一个熟悉的基本视图开始,但可以应用本系列视频中的各种技术来创建受限环境。例如:

  • 结合使用受限shell、特定挂载选项、固定的CPU优先级和一些文件属性,来构建受限的文件系统视图并限制进程执行能力。
  • 结合使用chroot jail、CPU集和能力,来限制特定进程的进程、文件系统和网络视图。
  • 创建由精心调优的命名空间、cgroups和资源限制组成的,针对每个进程或进程组的受限环境。

容器本质上就是运行在我们通用UNIX操作系统上的进程,但我们对其进行了限制,使得它们只能看到或访问我们允许的资源视图。 然而,请注意,与完全硬件虚拟化不同,容器仍然是进程或进程组。也就是说,它们仍然运行在与宿主机或父进程相同的操作系统内核上。这既是优势(虚拟环境的初始化比启动虚拟机快得多),也是限制(你只能运行与宿主机相同操作系统的容器,而不能运行另一个操作系统)。


总结与思考

本节课中,我们一起学习了限制进程的各种方法,以及这些技术如何最终催生了流行的容器技术。还有许多其他相关方法,我们只是浅尝辄止,但希望你已经看到,这其中并无魔法。本学期到目前为止所涵盖的所有内容,都应该能让你更好地理解Docker及其同类技术。

或许从这里可以得出的最重要经验是:

  1. 大多数进程限制都可以通过某种方式被绕过。
  2. 目标是自愿地限制自己,使得即使系统被攻破,攻击者也无法获得你之前可能拥有的攻击能力或提升的权限。
  3. 理解Unix进程和基本语义对于设置和配置此类受限环境至关重要。

从我们的第一堂课到现在,我们已经走了很长一段路。或许现在可以带着对这些概念的理解,回头重新审视一些主题。

感谢观看,下次再见!👋

072:Union Mounts与Whiteout文件 🔍

在本节课中,我们将要学习UNIX文件系统中的两个高级概念:联合挂载(Union Mounts)和白化文件(Whiteout Files)。我们将从struct stat中未提及的一种特殊文件类型开始,逐步解释其作用原理,并通过实际操作演示其行为。

概述

在之前的课程中,我们详细讨论了struct stat结构体,它提供了文件的元信息,包括文件权限和类型。我们提到了常规文件、目录、套接字和符号链接等类型,但有一种特殊文件类型尚未涉及,即白化文件。本节课将解释白化文件的作用及其产生的背景——联合挂载。

联合挂载(Union Mounts)简介

上一节我们介绍了文件类型的基本概念,本节中我们来看看联合挂载。联合挂载允许你将两个目录的内容合并,使其在单一位置可见。这通过将一个目录挂载在另一个目录之上来实现,从而展示两个目录的联合内容。

以下是联合挂载的基本操作步骤:

  1. 准备两个目录,例如directory1directory2
  2. 使用mount_union命令将directory2挂载到directory1之上。
  3. 访问挂载点,你将看到两个目录内容的合并视图。

白化文件(Whiteout Files)的作用

当在联合挂载的顶层目录中删除一个文件,而该文件在底层目录中仍然存在时,就会出现一个问题:删除操作不应让底层文件意外显现。为了解决这个问题,联合文件系统会动态生成一个白化文件。

白化文件的核心作用是在联合挂载的视图内隐藏底层文件中仍然存在的文件。它本身不是一个真实的文件,而是一个占位符。当卸载联合文件系统后,白化文件会自动消失。

实践演示:联合挂载与白化文件

现在,让我们通过一个实际例子来观察联合挂载和白化文件的行为。

实验准备

我们创建两个目录:lower(底层)和upper(顶层)。它们的初始内容如下:

upper目录内容:

file1 (内容: "This is file1 in the upper directory")
file2
subdir/file3 (内容: "This is file3 in the upper directory")
upper_file
upper_dir/

lower目录内容:

file1 (内容: "This is file1 in the lower directory")
file2
subdir/file3 (内容: "This is file3 in the lower directory")
lower_file
lower_dir/

创建联合挂载

我们执行以下命令创建联合挂载:

mount_union upper lower
cd lower  # 此时‘lower’目录是联合视图的挂载点

在联合视图(即lower目录)中执行ls -l,你会看到upperlower目录内容的合并。对于同名文件(如file1subdir/file3),显示的是顶层(upper)版本。

观察白化文件的产生

在联合视图中删除file1

rm file1

此时,使用ls -l看不到file1。但使用ls -lW命令(-W选项用于显示白化文件),你会看到一个特殊的条目:

W--------- 1 user wheel 0 Jan 1 00:00 file1

这个类型为W、链接数和大小均为0的文件就是白化文件。它掩盖了底层目录中仍然存在的file1

卸载与状态还原

卸载联合文件系统:

umount lower

检查原始目录:

  • lower/file1 依然存在且内容未变。
  • upper/file1 已被删除。
  • 白化文件已随联合挂载的卸载而消失。

其他行为:影子目录(Shadow Directories)

如果在联合视图中访问一个仅存在于底层目录的子目录(例如lower_dir),系统会在顶层目录自动创建一个对应的影子目录。这个目录在顶层是空的,但确保了路径结构在联合视图中的一致性。

核心概念总结

本节课中我们一起学习了以下核心概念:

  1. 联合挂载:将两个目录层叠合并的机制,顶层目录内容覆盖底层同名内容。
  2. 白化文件:一种特殊的文件类型(S_ISWHT(mode)),用于在联合挂载中标记底层已被删除的文件,防止其暴露。可通过ls -W查看。
  3. 文件类型检测:在代码中,可以使用S_ISWHT(mode)宏来检测一个文件是否为白化文件,就像使用S_ISREG()S_ISDIR()一样。
    #include <sys/stat.h>
    if (S_ISWHT(st.st_mode)) {
        // 这是一个白化文件
    }
    
  4. 系统依赖性:白化文件和联合挂载的支持程度依赖于操作系统和文件系统。本课示例基于NetBSD,其他UNIX变体的实现可能有所不同。

总结

在本节课中,我们探讨了UNIX中的联合挂载机制及其产生的白化文件。联合挂载提供了合并目录内容的能力,而白化文件则巧妙地解决了在联合视图中删除文件时可能出现的逻辑冲突。理解这些概念有助于你深入理解某些文件系统(如OverlayFS、UnionFS)的工作方式。请注意,这些功能是系统相关的,在实际编程和应用时应查阅相关操作系统的手册页(man page)以确认其支持与具体行为。

posted @ 2026-03-29 09:32  布客飞龙II  阅读(10)  评论(0)    收藏  举报