15-趣谈Linux操作系统

开篇词 | 为什么要学习Linux操作系统？

你好，我是你的老朋友刘超。在“趣谈网络协议”结课半年之后，我又给你带来了一个新的基础课程，“趣谈 Linux 操作系统”。

在“趣谈网络协议”的开篇词中，我表达了作为一个合格的 IT 工程师，在职业生涯中学习基础知识的重要性。如果说当时，我对这件事只是一种感性认识，在专栏推出之后，我的想法有了一些变化。

我通过留言区和同学们进行了很多互动，也和其他做基础知识专栏的作者有了不少交流，我发现，无论是从个人的职业发展角度，还是从公司招聘候选人的角度来看，扎实的基础知识是很多人的诉求。这让我更加坚信，我应该在“趣谈”基础知识这条道路上走下去。

目前极客时间的专栏，覆盖了网络、算法、数学、数据库、编程语言等各个方面，而操作系统也是基础中非常重要的一环。尤其我作为一名云架构师，Linux 操作系统的基础知识更是必不可少的。在实践中收获了很多心得之后，我希望在极客时间继续跟你分享。

你可能会说，我们大学里上过操作系统的课，而且每天都在用操作系统，为什么还要专门学一遍呢？尽管我的操作系统课成绩不错，但是在大学的时候，我和你的看法一样，我觉得这门课没有什么用，现在回想起来可能有这样几个原因。

第一，大学里普遍使用的操作系统是 Windows，老师大多也用 Windows。Windows 的优势是界面友好，很容易上手，于是我们就养成了要配置东西了就去菜单找，用鼠标点点的习惯，似乎会攒电脑、装系统、配软件就能搞定一切问题。

第二，一种操作系统对应的是一系列的软件生态，而大学里很多课程都是围绕 Windows 软件生态展开的。例如学 C++ 用的是 Vistual Studio，学数据库用的是 SQL Server，做网站用的是 IIS 等等。

第三，大学里的操作系统课往往都是纯讲理论，讲了很多原理，但是压根儿没法和平时用的 Windows 系统的行为关联起来，也根本弄不清操作系统在底层到底是怎么做的。

直到毕业之后，我加入 EMC，第一个项目就是基于 Linux 开发分布式文件系统。你能想象，只能对着一个黑框敲命令时，我心中的崩溃吗？我那时真的觉得，我大学的操作系统算是白学了。于是，我痛定思痛，开启了学习 Linux 的征程。

一旦开始学，我发现，Linux 对于编程世界来说，简直就像一扇门。尽管门里的知识浩如烟海，每一本书都厚如砖头，但我发现这条路上任何一片景色都精彩无比。

打开 Linux 操作系统这扇门，你才是合格的软件工程师

根据 2018 年 W3Techs 的数据统计，对于服务器端，Unix-Like OS 占的比例近 70%，其中 Linux 可以称得上是中流砥柱。随着移动互联网的发展，客户端基本上以 Android 和 iOS 为主。Android 是基于 Linux 内核的，因而客户端也进入了 Linux 阵营。可以说，在编程世界中，Linux 就是主流，不会 Linux 你就会格格不入。

那些火得不行的技术，什么云计算、虚拟化、容器、大数据、人工智能，几乎都是基于 Linux 技术的。那些牛得不行的系统，团购、电商、打车、快递，都是部署在服务端，也几乎都是基于 Linux 技术的。

所以说，如果你想进大公司，想学新技术，Linux 一定是一道绕不过去的坎。只有进入 Linux 操作系统这扇门，你才能成为合格的软件工程师。

研究 Linux 内核代码，你能学到数据结构与设计模式的落地实践

Linux 最大的优点就是开源。作为程序员，有了代码，啥都好办了。只要有足够的耐心，我们就可以一层一层看下去，看内核调度函数，看内存分配过程。理论理解起来不容易，但是一行行的“if-else”却不会产生歧义。

在 Linux 内核里，你会看到数据结构和算法的经典使用案例；你甚至还会看到并发情况下的保护这种复杂场景；在实践中遇到问题的时候，你可以直接参考内核中的实现。

例如，平时看起来最简单的文件操作，通过阅读 Linux 代码，你能学到从应用层、系统调用层、进程文件操作抽象层、虚拟文件系统层、具体文件系统层、缓存层、设备 I/O 层的完美分层机制，尤其是虚拟文件系统对于接入多种类型文件系统的抽象设计，在很多复杂的系统里面，这个思想都能用得上。

再如，当你写代码的时候，大部分情况下都可以使用现成的数据结构和算法库，但是有些场景对于内存的使用需要限制到很小，对于搜索的时间需要限制到很小的时候，我们需要定制化一些数据结构，这个时候内核里面这些实现就很有参考意义了。

了解 Linux 操作系统生态，能让你事半功倍地学会新技术

Linux 是一个生态，里面丰富多彩。很多大牛都是基于 Linux 来开发各种各样的软件。可以这么说，只要你能想象到的技术领域，几乎都能在里面找到 Linux 的身影。

数据库 MySQL、PostgreSQL，消息队列 RabbitMQ、Kafka，大数据 Hadoop、Spark，虚拟化 KVM、Openvswitch，容器 Kubernetes、Docker，这些软件都会默认提供 Linux 下的安装、使用、运维手册，都会默认先适配 Linux。

因此，在 Linux 环境下，很容易能够找到现成的工具，这不仅会让你的工作事半功倍，还能让你有亲密接触大牛思想的机会，这对于你个人的技术进步和职业发展都非常有益。

如果不进入 Linux 世界，你恐怕很难享受到开源软件如此多的红利。

考虑到以上这些，在设计“趣谈 Linux 操作系统”专栏的时候，我主要秉承两大原则，希望能够帮你打开 Linux 操作系统这扇门。

第一个原则仍然是“趣谈”。我希望通过故事化的方式，将枯燥的基础知识结合某个场景，给你生动、具象地讲述出来，帮你加深理解、巩固记忆、夯实基础。

操作系统是干什么的呢？我们都知道，一台物理机上有很多硬件，最重要的就是 CPU、内存、硬盘、网络。同时，一台物理机上也要跑很多程序，这些资源应该给谁用呢？当然是大家轮着用，谁也别独占，谁也别饿着。为了完成资源分配这件事，操作系统承担了一个“大管家”的作用。它将硬件资源分配给不同的用户程序使用，并且在适当的时间将这些资源拿回来，再分配给其他的用户进程。

鉴于操作系统这个“大管家”的角色，我设计了一个故事，将各个知识点串起来，来帮助你理解和记忆。

假设，我们现在就是在做一家外包公司，我们的目标是把这家公司做上市。其中，操作系统就是这家外包公司的老板。我们把这家公司的发展阶段分为这样几个时期：

初创期：这个老板基于开放的营商环境（x86 体系结构），创办一家外包公司（系统的启动）。因为一开始没有其他员工，老板需要亲自接项目（实模式）。

发展期：公司慢慢做大，项目越接越多（保护模式、多进程），为了管理各个外包项目，建立了项目管理体系（进程管理）、会议室管理体系（内存管理）、文档资料管理系统（文件系统）、售前售后体系（输入输出设备管理）。

壮大期：公司越来越牛，开始促进内部项目的合作（进程间通信）和外部公司合作（网络通信）。

集团化：公司的业务越来越多，会成立多家子公司（虚拟化），或者鼓励内部创业（容器化），这个时候公司就变成了集团。大管家的调度能力不再局限于一家公司，而是集团公司（Linux 集群），从而成功上市（从单机操作系统到数据中心操作系统）。

第二个原则就是图解。Linux 操作系统中的概念非常多，数据结构也很多，流程也复杂，一般人在学习的过程中很容易迷路。所谓“一图胜千言”，我希望能够通过图的方式，将这些复杂的概念、数据结构、流程表现出来，争取用一张图串起一篇文章的知识点。最终，整个专栏下来，你如果能把这些图都掌握了，你的知识就会形成体系和连接。在此基础上再进行深入学习，就会如鱼得水、易如反掌。

例如，这张图就表示了文件操作在各个层的数据结构的关联。只要你学完之后，能对着这张图将它们之间的关系讲清楚，对于文件系统的部分，你就会了然于心了。

一段新的征途即将开始，今天就是“开学典礼”。从今天开始，在接下来的四个月时间里，我会带你一步一步进入 Linux 操作系统的大门，让基础变成你技术生涯的左膀右臂。

在开始正式学习之前，我也想听你讲讲，之前你在学习和工作过程中，遇到过哪些操作系统相关的问题，有哪些困惑，又有哪些经验，也可以谈谈你对新学期的期许。

欢迎在留言区和我分享。

01 | 入学测验：你究竟对Linux操作系统了解多少？

02 | 学习路径：爬过这六个陡坡，你就能对Linux了如指掌

很多人觉得 Linux 操作系统刚开始学起来很难，主要是因为我们平时办公或者学习用的都是 Windows 系统，而 Windows 和 Linux 的使用模式是完全不一样的。

Windows 的基本使用模式是“图形化界面 + 菜单”。也就是说，无论我们做什么事情，首先要找一个图形化的界面。在这里面，“开始”菜单是统一的入口，无论是运行程序，还是做系统设置，你都能找到一个界面，界面上会有各种各样的输入框和菜单。我们只要挨个儿看过去，总能找到想操作的功能。实在不行，还有杀手锏，就是右键菜单，挨个儿一项一项看下去，最终也能实现想做的操作。

如果你刚刚上手 Linux，就会发现，情况完全不一样。你基本是这也找不着，那也找不着，觉得 Linux 十分难用，从而“从入门到放弃”。

Linux 上手难，学习曲线陡峭，所以它的学习过程更像一个爬坡模式。这些坡看起来都很陡，但是一旦爬上一阶，就会一马平川。你会惊叹 Linux 的设计之美，而 Linux 的灵活性也会使得你有 N 多种方法解决问题，从而事半功倍，你就会有一切尽在掌握的感觉。只可惜，大部分同学都停留在了山脚下。

那怎样才能掌握这项爬坡技能呢？我们首先需要明确，我们要爬哪些坡。

我总结了一下，在整个 Linux 的学习过程中，要爬的坡有六个，分别是：熟练使用 Linux 命令行、使用 Linux 进行程序设计、了解 Linux 内核机制、阅读 Linux 内核代码、实验定制 Linux 组件，以及最后落到生产实践上。以下是我为你准备的爬坡秘籍以及辅助的书单弹药。

第一个坡：抛弃旧的思维习惯，熟练使用 Linux 命令行

上手 Linux 的第一步，要先从 Windows 的思维习惯，切换成 Linux 的“命令行 + 文件”使用模式。

在 Linux 中，无论我们做什么事情，都会有相应的命令工具。虽然这些命令一般会在 bin 或者 sbin 目录下面，但是这些命令的数量太多了。如果你事先不知道该用哪个命令，很难通过枚举的方式找到。因此，在这样没有统一入口的情况下，就需要你对最基本的命令有所掌握。

一旦找到某个命令行工具，替代输入框的是各种各样的启动参数。这些参数怎么填，一般可以通过 -h 查看 help，挨个儿看过去，就能找到相应的配置项；还可以通过 man 命令，查看文档。无论是什么命令行工具，最终的配置一般会落到一个文件上，只要找到了那个文件，文件中会有注释，也可以挨个儿看下去，基本就知道如何配置了。

这个过程可能非常痛苦，在没有足够熟练地掌握命令行之前，你会发现干个非常小的事情都需要搜索半天，读很多文档，即便如此还不一定能得到期望的结果。这个时候你一定不要气馁，坚持下去，继续看文档、查资料，慢慢你就会发现，大部分命令的行为模式都很像，你几乎不需要搜索就能完成大部分操作了。

恭喜你，这个时候你已经爬上第一个坡了。这个时候，你能看到一些很美丽的风景，例如一些很有技巧的命令 sed 和 awk、很神奇的正则表达式、灵活的管道和 grep、强大的 bash。你可以自动化地做一些事情了，例如处理一些数据，会比你使用 Excel 要又快又准，关键是不用框框点点，在后台就能完成一系列操作。在处理数据的同时，你还可以干别的事情，半夜处理数据，第二天早上发个邮件报告，这都是 Excel 很难做到的事情。

不过，在这个专栏里，命令行并不是我们的重点，但是考虑到一些刚起步的同学，在第一部分我会简单介绍一些能够让你快速上手 Linux 的命令行。专栏每一模块的第一节，我都会有针对性地讲解这一模块的常用命令，足够你把 Linux 用起来。

如果你想全面学习 Linux 命令，推荐你阅读《鸟哥的 Linux 私房菜》。如果想再深入一点，推荐你阅读《Linux 系统管理技术手册》。这本砖头厚的书，可以说是 Linux 运维手边必备。

第二个坡：通过系统调用或者 glibc，学会自己进行程序设计

命令行工具也是程序，只不过是别人写的程序。从用别人写的程序，到自己能够写程序，通过程序来操作 Linux，这是第二个要爬的坡。

用代码操作 Linux，可以直接使用 Linux 系统调用，也可以使用 glibc 的库。

Linux 的系统调用非常多，而且每个函数都非常复杂，传入的参数、返回值、调用的方式等等都有很多讲究。这里面需要掌握很多 Linux 操作系统的原理，否则你会无法理解为什么应该这样调用。

刚开始学 Linux 程序设计的时候，你会发现它比命令行复杂得多。因为你的角色再次变化，这是为啥呢？我这么说，估计你就能理解了。

如果说使用命令行的人是吃馒头的，那写代码操作命令行的人就是做馒头的。看着简简单单的一个馒头，可能要经过 N 个工序才能蒸出来。同样，你会发现，你平时用的一个简单的命令行，却需要 N 个系统调用组合才能完成。其中每个系统调用都要进行深入地学习、读文档、做实验。

经过一段时间的学习，你啃下了这些东西，恭喜你，又爬上了一个坡。这时候，你已经很接近操作系统的原理了，你能看到另一番风景了。大学里学的那些理论，你再回去看，现在就会开始有感觉了。你本来不理解进程树，调用了 fork，就明白了；你本来不理解进程同步机制，调用了信号量，也明白了；你本来分不清楚网络应用层和传输层的分界线，调用了 socket，都明白了。

同样，专栏的第一模块，我会简单介绍一下 Linux 有哪些系统调用，每一模块的第一节，我还会讲解这一模块的常用系统调用，以及如何编程调用这些系统调用。这样可以使你对 Linux 程序设计入个门，但是这对于实战肯定是远远不够的。如果要进一步学习 Linux 程序设计，推荐你阅读《UNIX 环境高级编程》，这本书有代码，有介绍，有原理，非常实用。

第三个坡：了解 Linux 内核机制，反复研习重点突破

当你已经会使用代码操作 Linux 的时候，你已经很希望解开这层面纱，看看系统调用背后到底做了什么。

这个时候，你的角色要再次面临变化，就像你蒸馒头时间长了，发现要蒸出更好吃的馒头，就必须要对面粉有所研究。怎么研究呢？当然你可以去面粉厂看人家的加工过程，但是面粉厂的流水线也很复杂，很多和你蒸馒头没有直接关系，直接去看容易蒙圈，所以这时候你最好先研究一下，面粉制造工艺与馒头口味的关系。

对于 Linux 也是一样的，进一步了解内核的原理，有助于你更好地使用命令行和进行程序设计，能让你的运维和开发水平上升一个层次，但是我不建议你直接看代码，因为 Linux 代码量太大，很容易迷失，找不到头绪。最好的办法是，先了解一下 Linux 内核机制，知道基本的原理和流程就可以了。

一旦学起来的时候，你会发现，Linux 内核机制也非常复杂，而且其中相互关联。比如说，进程运行要分配内存，内存映射涉及文件的关联，文件的读写需要经过块设备，从文件中加载代码才能运行起来进程。这些知识点要反复对照，才能理清。

但是一旦爬上这个坡，你会发现 Linux 这个复杂的系统开始透明起来。无论你是运维，还是开发，你都能大概知道背后发生的事情，并在出现异常的情况时，比较准确地定位到问题所在。

Linux 内核机制是我们这个专栏重点要讲述的部分，我会基于最新 4.x 的内核进行讲解，当然我也意识到了内核机制的复杂性，所以我选择通过故事性和图形化的方式，帮助你了解并记住这些机制。

这块内容的辅助学习，我推荐一本《深入理解 LINUX 内核》。这本书言简意赅地讲述了主要的内核机制。看完这本书，你会对 Linux 内核有总体的了解。不过这本书的内核版本有点老，不过对于了解原理来讲，没有任何问题。

第四坡：阅读 Linux 内核代码，聚焦核心逻辑和场景

在了解内核机制的时候，你肯定会遇到困惑的地方，因为理论的描述和提炼虽然能够让你更容易看清全貌，但是容易让你忽略细节。

我在看内核原理的书的时候也遇到过这种问题，有的地方实在是难以理解，或者不同的书说的不一样，这时候该怎么办呢？其实很好办，Linux 是开源的呀，我们可以看代码呀，代码是精准的。哪里有问题，找到那段代码看一看，很多问题就有方法了。

另外，当你在工作中需要重点研究某方面技术的时候，如果涉及内核，这个时候仅仅了解原理已经不够了，你需要看这部分的代码。

但是开源软件代码纷繁复杂，一开始看肯定晕，找不着北。这里有一个诀窍，就是一开始阅读代码不要纠结一城一池的得失，不要每一行都一定要搞清楚它是干嘛的，而要聚焦于核心逻辑和使用场景。

一旦爬上这个坡，对于操作系统的原理，你应该就掌握得比较清楚了。就像蒸馒头的人已经将面粉加工流程烂熟于心。这个时候，你就可以有针对性地去做课题，把所学和你现在做的东西结合起来重点突破。例如你是研究虚拟化的，就重点看 KVM 的部分；如果你是研究网络的，就重点看内核协议栈的部分。

在专栏里，我在讲述 Linux 原理的同时，也会根据场景和主要流程来分析部分代码，例如创建进程、分配内存、打开文件、读写文件、收发网络包等等。考虑到大量代码粘贴会让你看起来比较费劲，也会占用大量篇幅，因而我采取只叙述主要流程，只放必要的代码，大部分的逻辑和相互关系，尽量通过图的方式展现出来，给你讲解。

这里也推荐一本书，《LINUX 内核源代码情景分析》。这本书最大的优点是结合场景进行分析，看得见、摸得着，非常直观，唯一的缺点还是内核版本比较老。

第五坡：实验定制化 Linux 组件，已经没人能阻挡你成为内核开发工程师了

纸上得来终觉浅，绝知此事要躬行。从只看内核代码，到上手修改内核代码，这又是一个很大的坎。这相当于蒸馒头的人为了定制口味，要开始修改面粉生产流程了。

因为 Linux 有源代码，很多地方可以参考现有的实现，定制化自己的模块。例如，你可以自己实现一个设备驱动程序，实现一个自己的系统调用，或者实现一个自己的文件系统等等。

这个难度比较大，涉及的细节比较多，上一个阶段，我的建议是不计较一城一地的得失，不需要每个细节都搞清楚，这一个阶段要求就更高了。一旦代码有一个细微的 bug，都有可能导致实验失败。

专栏最后一个部分，我专门设计了两个实验，帮你度过这个坎。只要跟着我的步伐进行学习，接下来，就没人能够阻挡你成为一名内核开发工程师了。

最后一坡：面向真实场景的开发，实践没有终点

说了这么多，我们都只是走出了万里长征第一步。我始终坚信，真正的高手都是在实战中摸爬滚打练出来的。

如果你是运维，仅仅熟悉上面基本的操作是不够的，生产环境会有大量的不可控因素，尤其是集群规模大的更是如此，大量的运维经验是实战来的，不能光靠读书。如果你是开发，对内核进行少量修改容易，但是一旦面临真实的场景，需要考虑各种因素，并发与并行，锁与保护，扩展性和兼容性，都需要真实项目才能练出来。

总结时刻

今天，我把爬坡的过程，分解成了六个阶段，并给你分享了我的私家爬坡宝典。你都记住了吗？我把今天的内容总结成了下面这张图。建议你牢牢记住这张图，在接下来的四个月中，按照这个路径稳步前进，攻克 Linux 操作系统。

Linux 操作系统爬坡路线图

课堂练习

你可以结合第一节的测试结果，并根据我今天讲的爬坡方法，思考一下，在接下来的四个月里，你准备怎么学习这个专栏。

欢迎在留言区写下你的爬坡计划，也欢迎你把今天的文章分享给你的朋友，和他一起学习、进步。

编辑乱入：超哥推荐的图书，部分已上架极客时间商城，点击下方图片，即可购买。和专栏一起配合使用，学习效果会更好哦！

03 | 你可以把Linux内核当成一家软件外包公司的老板

在平时的生活中，我们几乎时时刻刻都在使用操作系统，只是大部分时间你都意识不到它的存在。比如你买了一部手机或者一台平板电脑，立马就能上手使用，这是因为它们里面都预先安装了操作系统。

所以啊，哪有什么岁月静好，只不过有人替你负重前行。而操作系统就扮演了这样一个负重前行的角色。那操作系统到底在背后默默地做了哪些事情，才能让我们轻松地使用这些电子设备呢？要想回答这个问题，我们需要把眼光放回到自己攒电脑的那个时代。

电脑组装好就能直接用吗？

那时候买电脑，经常是这样一个情景：三五个哥们儿一起来到电脑城，呼啦呼啦采购了一大堆硬件，有密密麻麻都是针脚的CPU；有铺满各种复杂电路的一块板子，也就是主板；还需要买块显卡，用来连接显示器；还需要买个网卡，里面可以插网线；还要买块硬盘，将来用来存放文件；然后还需要一大堆线，将这些设备和主板连接起来；最终再来一个鼠标，一个键盘，还有一个显示器。设备差不多啦，准备开整！

好不容易组装完这一大堆硬件，还是不能直接用，你还需要安装一个操作系统。安装操作系统也是一件非常复杂的事，一点儿也不亚于把刚才那堆东西组装起来。这个安装过程可能会涉及十几个步骤、几十项配置。每一步骤配置完了，点击下一步，会出现个进度条。伴随着一堆难以理解的描述，最终安装步骤到达百分之百，才出现你熟悉的那个界面。

我这么说起来好像很容易，但是要把这事儿讲清楚估计得用一个专栏。这个复杂程度，咱们父母估计是上不了手了。所以，那个时候，能把这套东西都组装起来，是一件很拉风的事情。很多 IT 男甚至因为这项绝技“泡”到了妹子。

当操作系统安装完毕的时候，我妈通常会要求我一定要装一个 QQ。看到妈妈在你装好的操作系统前愉快地和她的朋友聊天，这时候，经历过以上过程的你，多少应该能感受到操作系统的厉害了。

操作系统究竟是如何把这么多套复杂的东西管理起来，从而弄出来一个简单到父母都会用的东西呢？

很多事情就怕细想。不知道你有没有产生过这些疑问：

桌面上的图标到底是啥？凭啥我在鼠标上一双击，就会出来一个美丽的画面？这都是从哪里跑出来的？

凭什么我在键盘上噼里啪啦地敲，某个位置就会显示我想要的那些字符？

电脑怎么知道我鼠标点击的是这个地方，又是怎么知道我要输入的是这个地方？

我在键盘上点“a”，是谁在显示器上画出“a”这个图像呢？

为什么我一回车，这些字符就发到遥远的另外一台机器上去了？

对于普通用户来讲，其实只要会用就行了，但是咱们作为专业人士，要深入探究一下背后的答案。你别小看“双击鼠标打开聊天软件”这样一个简单的操作，它几乎涵盖了操作系统的所有功能。我们就从这个熟悉的操作，来认识陌生的操作系统。

操作系统其实就像一个软件外包公司，其内核就相当于这家外包公司的老板。所以接下来的整个课程中，请你将自己的角色切换成这家软件外包公司的老板，设身处地地去理解操作系统是如何协调各种资源，帮客户做成事情的。

想要学好咱们这门课，你要牢牢记住这段话，把这个概念牢牢扎根在心里，我之后的讲解都会基于此，帮你理解、记忆那些难搞的概念和原理。

同时，为了防止你混淆，我这里先强调一下。今后我所说的“用户”，都是指操作系统的用户，“客户”则是指外包公司的客户，这两者是对应的。

“双击 QQ”这个过程，都需要用到哪些硬件？

好，现在用户开始对着屏幕上的 QQ 图标双击鼠标了。

鼠标和键盘是计算机的输入设备。大部分的普通用户想要告诉计算机应该做什么，都是通过这两个设备。例如，用户移动了一下鼠标，鼠标就会通过鼠标线给电脑发消息，告知电脑，鼠标向某个方向移动了多少距离。

如果是一家外包公司，怎么才能知道客户的需求呢？你需要配备销售、售前等角色，专门负责和客户对接，把客户需求拿回来，我们把这些人统称为客户对接员。你可以跟客户说，有什么事儿都找对接员。

屏幕，也就是显示器，是计算机的输出设备，将计算机处理用户请求后的结果展现给客户，要不然用户无法知道自己的请求是不是到达并且执行了。

显示器上面显示的东西是由显卡控制的。无论是显示器还是显卡，这里都有个“坐标”的概念，也就是说，什么图像在哪个坐标，都是定义好了才画上去的。本来在某个坐标画了一个鼠标箭头，当接到鼠标移动的事件之后，你应该按相同的方向，按照一定的比例（鼠标灵敏度），在屏幕的某个坐标再画一个鼠标箭头。

作为外包公司，当客户给你提了需求，不管你做还是不做，最终做成什么样，你都需要给客户反馈，所以你要配备交付人员，将做好的需求展示给他们看。

在操作系统中，输入设备驱动其实就是客户对接员。有时候新插上一个鼠标的时候，会弹出一个通知你安装驱动，这就是操作系统这家外包公司给你配备对接人员呢。当客户告诉对接员需求的时候，对于操作系统来讲，输入设备会发送一个中断。这个概念很好理解。客户肯定希望外包公司把正在做的事情都停下来服务它。所以，这个时候客户发送的需求就被称为中断事件（Interrupt Event）。

显卡会有显卡驱动，在操作系统中称为输出设备驱动，也就是上面说的交付人员。

从点击 QQ 图标，看操作系统全貌

有了客户对接员和交付人员，外包公司就可以处理用户“在桌面上点击 QQ 图标”的事件了。

首先，鼠标双击会触发一个中断，这相当于客户告知客户对接员“有了新需求，需要处理一下”。你会事先把处理这种问题的方法教给客户对接员。在操作系统里面就是调用中断处理函数。操作系统发现双击的是一个图标，就明白了用户的原始诉求，准备运行 QQ 和别人聊天。

你会发现，运行 QQ 是一件大事，因为将来的一段时间，用户要一直和 QQ 进行交互。这就相当于你们公司接了一个大单，而不是处理零星的客户需求，这个时候应该单独立项。一旦立了项，以后与这个项目有关的事情，都由这个项目组来处理。

立项可不能随便立，一定要有一个项目执行计划书，说明这个项目打算怎么做，一步一步如何执行，遇到什么情况应该怎么办等等。换句话说，对 QQ 这个程序来说，它能做哪些事情，每个事情怎么做，先做啥后做啥，都已经作为程序逻辑写在程序里面，并且编译成为二进制了。这个程序就相当于项目执行计划书。

电脑上的程序有很多，什么有道云笔记的程序、Word 程序等等，它们都以二进制文件的形式保存在硬盘上。硬盘是个物理设备，要按照规定格式化成为文件系统，才能存放这些程序。文件系统需要一个系统进行统一管理，称为文件管理子系统（File Management Subsystem）。

对于你们公司，项目立得多了，项目执行计划书也会很多，同样需要有个统一保存文件的档案库，而且需要有序地管理起来。

当你从资料库里面拿到这个项目执行计划书，接下来就需要开始执行这个项目了。项目执行计划书是静态的，项目的执行是动态的。

同理，当操作系统拿到 QQ 的二进制执行文件的时候，就可以运行这个文件了。QQ 的二进制文件是静态的，称为程序（Program），而运行起来的 QQ，是不断进行的，称为进程（Process）。

说了这么多，怎样才能立项呢？你会发现，一个项目要想顺畅进行，需要用到公司的各种资源，比如说盖个公章、开个证明、申请个会议室、打印个材料等等。这里有个两难的权衡，一方面，资源毕竟是有限的，甚至是涉及机密的，不能由项目组滥取滥用；另一方面，就是效率，咱是一个私营企业，保证项目申请资源的时候只跑一次，这样才能比较高效。

为了平衡这一点，一方面涉及核心权限的资源，还是应该被公司严格把控，审批了才能用；另外一方面，为了提高效率，最好有个统一的办事大厅，明文列出提供哪些服务，谁需要可以来申请，然后就会有回应。

在操作系统中，也有同样的问题，例如多个进程都要往打印机上打印文件，如果随便乱打印进程，就会出现同样一张纸，第一行是 A 进程输出的文字，第二行是 B 进程输出的文字，全乱套了。所以，打印机的直接操作是放在操作系统内核里面的，进程不能随便操作。但是操作系统也提供一个办事大厅，也就是系统调用（System Call）。

系统调用也能列出来提供哪些接口可以调用，进程有需要的时候就可以去调用。这其中，立项是办事大厅提供的关键服务之一。同样，任何一个程序要想运行起来，就需要调用系统调用，创建进程。

一旦项目正式立项，就要开始执行，就要成立项目组，将开发人员分配到这个项目组，按照项目执行计划书一步一步执行。为了管理这个项目，我们还需要一个项目经理、一套项目管理流程、一个项目管理系统，例如程序员比较熟悉的 Jira。如果项目多，可能一个开发人员需要同时执行多个项目，这就要考验项目经理的调度能力了。

在操作系统中，进程的执行也需要分配 CPU 进行执行，也就是按照程序里面的二进制代码一行一行地执行。于是，为了管理进程，我们还需要一个进程管理子系统（Process Management Subsystem）。如果运行的进程很多，则一个 CPU 会并发运行多个进程，也就需要 CPU 的调度能力了。

每个项目都有自己的私密资料，这些资料不能被其他项目组看到。这些资料主要是项目在执行的过程中，产生的很多中间成果，例如架构图、流程图。

执行过程中，难免要在白板上或者本子上写写画画，如果不同项目的办公空间不隔离，一方面，项目的私密性不能得到保证，A 项目的细节，B 项目也能看到；另一方面，项目之间会相互干扰，A 项目组的人刚在白板上画了一个架构图，出去上个厕所，结果 B 项目组的人就给擦了。

如果把不同的项目组分配到不同的会议室，就解决了这个问题。当然会议室是有限的，需要有人管理和分配，并且需要一个会议室管理系统。

在操作系统中，不同的进程有不同的内存空间，但是整个电脑内存就这么点儿，所以需要统一的管理和分配，这就需要内存管理子系统（Memory Management Subsystem）。

如果想直观地了解 QQ 如何使用 CPU 和内存，可以打开任务管理器，你就能看到 QQ 这个进程耗费的 CPU 和内存。

项目执行的时候，有了一定的成果，就要给客户演示。例如客户说要做个应用，我们做出来了要给客户看看，如果客户说哪里需要改，可以根据客户的需求再改，这就需要交付人员了。

QQ 启动之后，有一部分代码会在显示器上画一个对话框，并且将键盘的焦点放在了输入框里面。CPU 根据这些指令，就会告知显卡驱动程序，将这个对话框画出来。

于是使用 QQ 的用户就会很开心地发现，他能和别人开始聊天了。

当用户通过键盘噼里啪啦打字的时候，键盘也是输入设备，也会触发中断，通知相应的输入设备驱动程序。

我们假设用户输入了一个“a”。这就像客户提出了新的需求给客户对接员。客户对接员收到需求后，因为是对接这个项目的，因而就回来报告，客户提新需求了，项目组需要处理一下。项目执行计划书里面一般都会有当遇到何种需求应该怎么做的规定，项目组就按这个规定做了，然后让交付人员再去客户那里演示就行了。

对于 QQ 来讲，由于键盘闪啊闪的焦点在 QQ 这个对话框上，因而操作系统知道，这个事件是给这个进程的。QQ 的代码里面肯定有遇到这种事件如何处理的代码，就会执行。一般是记录下客户的输入，并且告知显卡驱动程序，在那个地方画一个“a”。显卡画完了，客户看到了，就觉得自己的输入成功了。

当用户输入完毕之后，回车一下，还是会通过键盘驱动程序告诉操作系统，操作系统还是会找到 QQ，QQ 会将用户的输入发送到网络上。QQ 进程是不能直接发送网络包的，需要调用系统调用，内核使用网卡驱动程序进行发送。

这就像客户对接员接到一个需求，但是这个需求需要和其他公司沟通，这就需要依靠公司的对外合作部，对外合作部在办事大厅有专门的窗口，非常方便。

总结时刻

到这里，一个外包公司大部分的职能部门都凑齐了。你可以对应着下图的操作系统内核体系结构，回顾一下它们是如何组成一家公司的。

QQ 的运行过程，只是一个简单的比喻。在后面的章节中，我会展开讲述每个部分是怎么工作的，最后我会再将这个过程串起来，这样你就能了解操作系统的全貌了。

操作系统内核体系结构图

课堂练习

学习 Linux，看代码是必须的。你可以找到最新版本的 Linux 代码，在里面找找，这几个子系统的代码都在哪里。

欢迎留言和我分享你的思考和疑问，也欢迎你把今天的内容分享给你的朋友，和他一起学习、进步。

04 | 快速上手几个Linux命令：每家公司都有自己的黑话

如果你还没有上手用过 Linux，那么接下来的课程，你可能会感受到困惑。因为没有一手的体验，你可能很难将 Linux 的机制和你的使用行为关联起来。所以这一节，咱们先介绍几个上手 Linux 的命令，通过这些命令，我们试试先把 Linux 用起来。

为什么我把 Linux 命令称为“黑话”呢？就像上一节我们介绍的，Linux 操作系统有很多功能，我们有很多种方式可以使用这些功能，其中最简单和直接的方式就是命令行（Command Line）。命令行就相当于你请求服务使用的专业术语。干任何事情，第一步就是学会使用正确的术语。这样，Linux 作为服务方，才能听懂。这些术语可不就是“黑话”吗？

Window 系统你肯定很熟悉吧？现在，我就沿着你使用 Windows 的习惯，来给你介绍相应的 Linux 命令。

用户与密码

当我们打开一个新系统的时候，第一件要做的事就是登录。系统默认有一个 Administrator 用户，也就是系统管理员，它的权限很大，可以在这个系统上干任何事。Linux 上面也有一个类似的用户，我们叫 Root。同样，它也具有最高的操作权限。

接下来，你需要输入密码了。密码从哪里来呢？对于 Windows 来讲，在你安装操作系统的过程中，会让你设置一下 Administrator 的密码；对于 Linux，Root 的密码同样也是在安装过程中设置的。

对于 Windows，你设好之后，可以多次修改这个密码。比如说，我们在控制面板的账户管理里面就可以完成这个操作。但是对于 Linux 呢？不好意思，没有这么一个统一的配置中心了。你需要使用命令来完成这件事情。这个命令很好记，passwd，其实就是 password 的简称。

# passwd

Changing password for user root.

New password:

按照这个命令，我们就可以输入新密码啦。

在 Windows 里，除了 Administrator 之外，我们还可以创建一个以自己名字命名的用户。那在 Linux 里可不可以创建其他用户呢？当然可以了，我们同样需要一个命令useradd。

 useradd cliu8

执行这个命令，一个用户就被创建了。它不会弹出什么让你输入密码之类的页面，就会直接返回了。因为接下来你需要自己调用 passwd cliu8 来设置密码，再进行登录。

在 Windows 里设置用户的时候，用户有一个“组”的概念。你可能没注意过，不过我一说名字你估计就能想起来了，比如“Adminsitrator 组”“Guests 组”“Power User 组”等等。同样，Linux 里也是分组的。前面我们创建用户的时候，没有说加入哪个组，于是默认就会创建一个同名的组。

能不能在创建用户的时候就指定属于哪个组呢？我们来试试。我们可以使用 -h 参数看一下，使用 useradd 这个命令，有没有相应的选项。

[root@deployer ~]# useradd -h

Usage: useradd [options] LOGIN

       useradd -D

       useradd -D [options]

Options:

  -g, --gid GROUP               name or ID of the primary group of the new account

一看还真有这个选项。以后命令不会用的时候，就可以通过 -h 参数看一下，它的意思是 help。

如果想看更加详细的文档，你可以通过 man useradd 获得，细细阅读。

上一节我们说过，Linux 里是“命令行 + 文件”模式。对于用户管理来说，也是一样的。咱们通过命令创建的用户，其实是放在 /etc/passwd 文件里的。这是一个文本文件。我们可以通过 cat 命令，将里面的内容输出在命令行上。组的信息我们放在 /etc/group 文件中。

# cat /etc/passwd

root:x:0:0:root:/root:/bin/bash

......

cliu8:x:1000:1000::/home/cliu8:/bin/bash

# cat /etc/group

root:x:0:

......

cliu8:x:1000:

在 /etc/passwd 文件里，我们可以看到 root 用户和咱们刚创建的 cliu8 用户。x 的地方应该是密码，密码当然不能放在这里，不然谁都知道了。接下来是用户 ID 和组 ID，这和 /etc/group 里面就对应上了。

/root 和 /home/cliu8 是什么呢？它们分别是 root 用户和 cliu8 用户的主目录。主目录是用户登录进去后默认的路径。其实 Windows 里面也是这样的。当我们打开文件夹浏览器的时候，左面会有“文档”“图片”“下载”等文件夹，路径在 C:\Users\cliu8 下面。要注意，同一台电脑，不同的用户情况会不一样。

/bin/bash 的位置是用于配置登录后的默认交互命令行的，不像 Windows，登录进去是界面，其实就是 explorer.exe。而 Linux 登录后的交互命令行是一个解析脚本的程序，这里配置的是 /bin/bash。

浏览文件

终于登录进来啦，接下来你可以在文件系统里面随便逛一逛、看一看了。

可以看到，Linux 的文件系统和 Windows 是一样的，都是用文件夹把文件组织起来，形成一个树形的结构。这一点没有什么差别。只不过在 Linux 下面，大多数情况，我们需要通过命令行来查看 Linux 的文件。

其实在 Windows 下也有命令行，例如cd就是 change directory，就是切换目录；cd . 表示切换到当前目录；cd .. 表示切换到上一级目录；使用 dir，可以列出当前目录下的文件。Linux 基本也是这样，只不过列出当前目录下的文件我们用的是ls，意思是 list。

我们常用的是 ls -l，也就是用列表的方式列出文件。

# ls -l

drwxr-xr-x 6 root root    4096 Oct 20  2017 apt

-rw-r--r-- 1 root root     211 Oct 20  2017 hosts

其中第一个字段的第一个字符是文件类型。如果是“-”，表示普通文件；如果是 d，就表示目录。当然还有很多种文件类型，咱们后面遇到的时候再说，你现在先记住我说的这两个就行了。

第一个字段剩下的 9 个字符是模式，其实就是权限位（access permission bits）。3 个一组，每一组 rwx 表示“读（read）”“写（write）”“执行（execute）”。如果是字母，就说明有这个权限；如果是横线，就是没有这个权限。

这三组分别表示文件所属的用户权限、文件所属的组权限以及其他用户的权限。例如，上面的例子中，-rw-r–r-- 就可以翻译为，这是一个普通文件，对于所属用户，可读可写不能执行；对于所属的组，仅仅可读；对于其他用户，也是仅仅可读。如果想改变权限，可以使用命令 chmod 711 hosts。

第二个字段是硬链接（hard link）数目，这个比较复杂，讲文件的时候我会详细说。

第三个字段是所属用户，第四个字段是所属组。第五个字段是文件的大小，第六个字段是文件被修改的日期，最后是文件名。你可以通过命令chown改变所属用户，chgrp改变所属组。

安装软件

好了，你现在应该会浏览文件夹了，接下来应该做什么呢？当然是开始安装那些“装机必备”的软件啦！

在 Windows 下面，在没有类似软件管家的软件之前，我们其实都是在网上下载 installer，然后再进行安装的。

就以我们经常要安装的 JDK 为例子。应该去哪里下载呢？为了安全起见，一般去官网比较好。如果你去 JDK 的官网，它会给你一个这样的列表。

对于 Windows 系统，最方便的方式就是下载 exe，也就是安装文件。下载后我们直接双击安装即可。

对于 Linux 来讲，也是类似的方法，你可以下载 rpm 或者 deb。这个就是 Linux 下面的安装包。为什么有两种呢？因为 Linux 现在常用的有两大体系，一个是 CentOS 体系，一个是 Ubuntu 体系，前者使用 rpm，后者使用 deb。

在 Linux 上面，没有双击安装这一说，因此想要安装，我们还得需要命令。CentOS 下面使用rpm -i jdk-XXX_linux-x64_bin.rpm进行安装，Ubuntu 下面使用dpkg -i jdk-XXX_linux-x64_bin.deb。其中 -i 就是 install 的意思。

在 Windows 下面，控制面板里面有程序管理，我们可以查看目前安装了哪些软件，可以删除这些软件。

在 Linux 下面，凭借rpm -qa和dpkg -l就可以查看安装的软件列表，-q 就是 query，a 就是 all，-l 的意思就是 list。

如果真的去运行的话，你会发现这个列表很长很长，很难找到你安装的软件。如果你知道要安装的软件包含某个关键词，可以用一个很好用的搜索工具 grep。

rpm -qa | grep jdk，这个命令是将列出来的所有软件形成一个输出。| 是管道，用于连接两个程序，前面 rpm -qa 的输出就放进管道里面，然后作为 grep 的输入，grep 将在里面进行搜索带关键词 jdk 的行，并且输出出来。grep 支持正则表达式，因此搜索的时候很灵活，再加上管道，这是一个很常用的模式。同理dpkg -l | grep jdk也是能够找到的。

如果你不知道关键词，可以使用rpm -qa | more和rpm -qa | less这两个命令，它们可以将很长的结果分页展示出来。这样你就可以一个个来找了。

我们还是利用管道的机制。more 是分页后只能往后翻页，翻到最后一页自动结束返回命令行，less 是往前往后都能翻页，需要输入 q 返回命令行，q 就是 quit。

如果要删除，可以用rpm -e和dpkg -r。-e 就是 erase，-r 就是 remove。

我们刚才说的都是没有软件管家的情况，后来 Windows 上有了软件管家，就方便多了。我们直接搜索一下，然后点击安装就行了。

Linux 也有自己的软件管家，CentOS 下面是 yum，Ubuntu 下面是 apt-get。

你可以根据关键词搜索，例如搜索jdk、yum search jdk和apt-cache search jdk，可以搜索出很多很多可以安装的 jdk 版本。如果数目太多，你可以通过管道 grep、more、less 来进行过滤。

选中一个之后，我们就可以进行安装了。你可以用yum install java-11-openjdk.x86_64和apt-get install openjdk-9-jdk来进行安装。

安装以后，如何卸载呢？我们可以使用yum erase java-11-openjdk.x86_64和apt-get purge openjdk-9-jdk。

Windows 上的软件管家会有一个统一的服务端，来保存这些软件，但是我们不知道服务端在哪里。而 Linux 允许我们配置从哪里下载这些软件的，地点就在配置文件里面。

对于 CentOS 来讲，配置文件在/etc/yum.repos.d/CentOS-Base.repo里。

[base]

name=CentOS-$releasever - Base - 163.com

baseurl=http://mirrors.163.com/centos/$releasever/os/$basearch/

gpgcheck=1

gpgkey=http://mirrors.163.com/centos/RPM-GPG-KEY-CentOS-7

对于 Ubuntu 来讲，配置文件在/etc/apt/sources.list里。

deb http://mirrors.163.com/ubuntu/ xenial main restricted universe multiverse

deb http://mirrors.163.com/ubuntu/ xenial-security main restricted universe multiverse

deb http://mirrors.163.com/ubuntu/ xenial-updates main restricted universe multiverse

deb http://mirrors.163.com/ubuntu/ xenial-proposed main restricted universe multiverse

deb http://mirrors.163.com/ubuntu/ xenial-backports main restricted universe multiverse

这里为什么都是 163.com 呢？因为 Linux 服务器遍布全球，不能都从一个地方下载，最好选一个就近的地方下载，例如在中国，选择 163.com，就不用跨越重洋了。

其实无论是先下载再安装，还是通过软件管家进行安装，都是下载一些文件，然后将这些文件放在某个路径下，然后在相应的配置文件中配置一下。例如，在 Windows 里面，最终会变成 C:\Program Files 下面的一个文件夹以及注册表里面的一些配置。对应 Linux 里面会放的更散一点。例如，主执行文件会放在 /usr/bin 或者 /usr/sbin 下面，其他的库文件会放在 /var 下面，配置文件会放在 /etc 下面。

所以其实还有一种简单粗暴的方法，就是将安装好的路径直接下载下来，然后解压缩成为一个整的路径。在 JDK 的安装目录中，Windows 有 jdk-XXX_Windows-x64_bin.zip，这是 Windows 下常用的压缩模式。Linux 有 jdk-XXX_linux-x64_bin.tar.gz，这是 Linux 下常用的压缩模式。

如何下载呢？Linux 上面有一个工具 wget，后面加上链接，就能从网上下载了。

下载下来后，我们就可以进行解压缩了。Windows 下可以有 winzip 之类的解压缩程序，Linux 下面默认会有 tar 程序。如果是解压缩 zip 包，就需要另行安装。

yum install zip.x86_64 unzip.x86_64

apt-get install zip unzip

如果是 tar.gz 这种格式的，通过 tar xvzf jdk-XXX_linux-x64_bin.tar.gz 就可以解压缩了。

对于 Windows 上 jdk 的安装，如果采取这种下载压缩包的格式，需要在系统设置的环境变量配置里面设置JAVA_HOME和PATH。

在 Linux 也是一样的，通过 tar 解压缩之后，也需要配置环境变量，可以通过 export 命令来配置。

export JAVA_HOME=/root/jdk-XXX_linux-x64

export PATH=$JAVA_HOME/bin:$PATH

export 命令仅在当前命令行的会话中管用，一旦退出重新登录进来，就不管用了，有没有一个地方可以像 Windows 里面可以配置永远管用呢？

在当前用户的默认工作目录，例如 /root 或者 /home/cliu8 下面，有一个.bashrc 文件，这个文件是以点开头的，这个文件默认看不到，需要 ls -la 才能看到，a 就是 all。每次登录的时候，这个文件都会运行，因而把它放在这里。这样登录进来就会自动执行。当然也可以通过 source .bashrc 手动执行。

要编辑.bashrc 文件，可以使用文本编辑器 vi，也可以使用更加友好的 vim。如果默认没有安装，可以通过 yum install vim 及 apt-get install vim 进行安装。

vim 就像 Windows 里面的 notepad 一样，是我们第一个要学会的工具。要不然编辑、查看配置文件，这些操作你都没办法完成。vim 是一个很复杂的工具，刚上手的时候，你只需要记住几个命令就行了。

vim hello，就是打开一个文件，名字叫 hello。如果没有这个文件，就先创建一个。

我们其实就相当于打开了一个 notepad。如果文件有内容，就会显示出来。移动光标的位置，通过上下左右键就行。如果想要编辑，就把光标移动到相应的位置，输入i，意思是 insert。进入编辑模式，可以插入、删除字符，这些都和 notepad 很像。要想保存编辑的文本，我们使用esc键退出编辑模式，然后输入“:”，然后在“:”后面输入命令w，意思是 write，这样就可以保存文本，冒号后面输入q，意思是 quit，这样就会退出 vim。如果编辑了，还没保存，不想要了，可以输入q!。

好了，掌握这些基本够用了，想了解更复杂的，你可以自己去看文档。

通过 vim .bashrc，将 export 的两行加入后，输入:wq，写入并且退出，这样就编辑好了。

运行程序

好了，装好了程序，可以运行程序了。

我们都知道 Windows 下的程序，如果后缀名是 exe，双击就可以运行了。

Linux 不是根据后缀名来执行的。它的执行条件是这样的：只要文件有 x 执行权限，都能到文件所在的目录下，通过./filename运行这个程序。当然，如果放在 PATH 里设置的路径下面，就不用./ 了，直接输入文件名就可以运行了，Linux 会帮你找。

这是Linux 执行程序最常用的一种方式，通过 shell 在交互命令行里面运行。

这样执行的程序可能需要和用户进行交互，例如允许让用户输入，然后输出结果也打印到交互命令行上。这种方式比较适合运行一些简单的命令，例如通过 date 获取当前时间。这种模式的缺点是，一旦当前的交互命令行退出，程序就停止运行了。

这样显然不能用来运行那些需要“永远“在线的程序。比如说，运行一个博客程序，我总不能老是开着交互命令行，博客才可以提供服务。一旦我要去睡觉了，关了命令行，我的博客别人就不能访问了，这样肯定是不行的。

于是，我们就有了Linux 运行程序的第二种方式，后台运行。

这个时候，我们往往使用nohup命令。这个命令的意思是 no hang up（不挂起），也就是说，当前交互命令行退出的时候，程序还要在。

当然这个时候，程序不能霸占交互命令行，而是应该在后台运行。最后加一个 &，就表示后台运行。

另外一个要处理的就是输出，原来什么都打印在交互命令行里，现在在后台运行了，输出到哪里呢？输出到文件是最好的。

最终命令的一般形式为nohup command >out.file 2>&1 &。这里面，“1”表示文件描述符 1，表示标准输出，“2”表示文件描述符 2，意思是标准错误输出，“2>&1”表示标准输出和错误输出合并了。合并到哪里去呢？到 out.file 里。

那这个进程如何关闭呢？我们假设启动的程序包含某个关键字，那就可以使用下面的命令。

ps -ef |grep 关键字  |awk '{print $2}'|xargs kill -9

从这个命令中，我们多少能看出 shell 的灵活性和精巧组合。

其中 ps -ef 可以单独执行，列出所有正在运行的程序，grep 上面我们介绍过了，通过关键字找到咱们刚才启动的程序。

awk 工具可以很灵活地对文本进行处理，这里的 awk '{print $2}'是指第二列的内容，是运行的程序 ID。我们可以通过 xargs 传递给 kill -9，也就是发给这个运行的程序一个信号，让它关闭。如果你已经知道运行的程序 ID，可以直接使用 kill 关闭运行的程序。

在 Windows 里面还有一种程序，称为服务。这是系统启动的时候就在的，我们可以通过控制面板的服务管理启动和关闭它。

Linux 也有相应的服务，这就是程序运行的第三种方式，以服务的方式运行。例如常用的数据库 MySQL，就可以使用这种方式运行。

例如在 Ubuntu 中，我们可以通过 apt-get install mysql-server 的方式安装 MySQL，然后通过命令systemctl start mysql启动 MySQL，通过systemctl enable mysql设置开机启动。之所以成为服务并且能够开机启动，是因为在 /lib/systemd/system 目录下会创建一个 XXX.service 的配置文件，里面定义了如何启动、如何关闭。

在 CentOS 里有些特殊，MySQL 被 Oracle 收购后，因为担心授权问题，改为使用 MariaDB，它是 MySQL 的一个分支。通过命令yum install mariadb-server mariadb进行安装，命令systemctl start mariadb启动，命令systemctl enable mariadb设置开机启动。同理，会在 /usr/lib/systemd/system 目录下，创建一个 XXX.service 的配置文件，从而成为一个服务。

systemd 的机制十分复杂，这里咱们不讨论。如果有兴趣，你可以自己查看相关文档。

最后咱们要学习的是如何关机和重启。这个就很简单啦。shutdown -h now是现在就关机，reboot就是重启。

总结时刻

好了，掌握这些基本命令足够你熟练操作 Linux 了。如果你是个初学者，这些命令估计看起来还是很多。我把今天这些基本的命令以及对应的操作总结了一下，方便你操作和查阅。

你不用可以去死记硬背，按照我讲的这个步骤，从设置用户和密码、浏览文件、安装软件，最后到运行程序，自己去操作几遍，再自己整理一遍，手脑并用，加深理解，巩固记忆，效果可能会更好。

(建议保存查看清晰大图)

课堂练习

现在你应该已经学会了安装 JDK 和 MySQL，你可以尝试搭建一个基于 Java+MySQL 的服务端应用，上手使用一下。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

05 | 学会几个系统调用：咱们公司能接哪些类型的项目？

上一节我们讲了几个重要的 Linux 命令行，只有通过这些命令，用户才能把 Linux 系统用起来，不知道你掌握得如何了？其实 Linux 命令也是一个程序，只不过代码是别人写好的，你直接用就可以了。你可以自己试着写写代码，通过代码把 Linux 系统用起来，这样印象会更深刻。

不过，无论是别人写的程序，还是你写的程序，运行起来都是进程。如果你是一家外包公司，一个项目的运行要使用公司的服务，那就应该去办事大厅，也就是说，你写的程序应该使用系统调用。

你看，系统调用决定了这个操作系统好用不好用、功能全不全。对应到咱们这个公司中，作为一个老板，你应该好好规划一下，你的办事大厅能够提供哪些服务，这决定了你这个公司会被打五星还是打差评。

立项服务与进程管理

首先，我们得有个项目，那就要有立项服务。对应到 Linux 操作系统中就是创建进程。

创建进程的系统调用叫fork。这个名字很奇怪，中文叫“分支”。为啥启动一个新进程叫“分支”呢？

在 Linux 里，要创建一个新的进程，需要一个老的进程调用 fork 来实现，其中老的进程叫作父进程（Parent Process），新的进程叫作子进程（Child Process）。

前面我们说过，一个进程的运行是要有一个程序的，就像一个项目的执行，要有一个项目执行计划书。本来老的项目，按照项目计划书按部就班地来，项目执行到一半，突然接到命令，说是要新启动一个项目，这个时候应该怎么办呢？

一个项目的执行是很复杂的，需要涉及公司各个部门的工作，比如说，项目管理部门需要给这个项目组开好 Jira 和 Wiki，会议室管理部要为这个项目分配会议室等等。

所以，我们现在有两种方式，一种是列一个清单，清单里面写明每个新项目组都要开哪些账号。但是，这样每次有项目，都要重新配置一遍新的 Jira、Wiki，复杂得很。另一种方式就是咱们程序员常用的方式，CTRL/C + CTRL/V。也就是说，如果想为新项目建立一套 Jira，但是觉一个个填 Jira 里面的选项太麻烦，那就可以拷贝一个别人的，然后根据新项目的实际情况，将相应的配置改改。

Linux 就是这样想的。当父进程调用 fork 创建进程的时候，子进程将各个子系统为父进程创建的数据结构也全部拷贝了一份，甚至连程序代码也是拷贝过来的。按理说，如果不进行特殊的处理，父进程和子进程都按相同的程序代码进行下去，这样就没有意义了。

所以，我们往往会这样处理：对于 fork 系统调用的返回值，如果当前进程是子进程，就返回 0；如果当前进程是父进程，就返回子进程的进程号。这样首先在返回值这里就有了一个区分，然后通过 if-else 语句判断，如果是父进程，还接着做原来应该做的事情；如果是子进程，需要请求另一个系统调用execve来执行另一个程序，这个时候，子进程和父进程就彻底分道扬镳了，也即产生了一个分支（fork）了。

同样是“先拷贝，再修改”的策略，你可能会问，新进程都是父进程 fork 出来的，那到底谁是第一个呢？

作为一个外包公司老板，有了新项目当然会分给手下做，但是当公司刚起步的时候呢？没有下属，只好自己上了。先建立项目运行体系，等后面再做项目的时候，就都按这个来。

对于操作系统也一样，启动的时候先创建一个所有用户进程的“祖宗进程”。这个在讲系统启动的时候还会详细讲，我这里先不多说。

有时候，父进程要关心子进程的运行情况，这毕竟是自己身上掉下来的肉。有个系统调用waitpid，父进程可以调用它，将子进程的进程号作为参数传给它，这样父进程就知道子进程运行完了没有，成功与否。

所以说，所有子项目最终都是老板，也就是祖宗进程 fork 过来的，因而它要对整个公司的项目执行负最终的责任。

会议室管理与内存管理

项目启动之后，每个项目组有独立的会议室，存放自己项目相关的数据。每个项目组都感觉自己有独立的办公空间。

在操作系统中，每个进程都有自己的内存，互相之间不干扰，有独立的进程内存空间。

那独立的办公空间里面，都放些什么呢？

项目执行计划书肯定是要放进去的，因为执行过程中肯定要不断地看。对于进程的内存空间来讲，放程序代码的这部分，我们称为代码段（Code Segment）。

项目执行的过程中，会产生一些架构图、流程图，这些也放在会议室里面。有的画在白板上，讨论完了，进入下个主题就会擦了；有的画在纸和本子上，讨论的时候翻出来，不讨论的时候堆在那里，会保留比较长的一段时间，除非指明的确不需要了才会去销毁。

对于进程的内存空间来讲，放进程运行中产生数据的这部分，我们称为数据段（Data Segment）。其中局部变量的部分，在当前函数执行的时候起作用，当进入另一个函数时，这个变量就释放了；也有动态分配的，会较长时间保存，指明才销毁的，这部分称为堆（Heap）。

一个进程的内存空间是很大的，32 位的是 4G，64 位的就更大了，我们不可能有这么多物理内存。就像一个公司的会议室是有限的，作为老板，你不可能事先都给项目组分配好。哪有这么多会议室啊，一定是需要的时候再分配。

所以，进程自己不用的部分就不用管，只有进程要去使用部分内存的时候，才会使用内存管理的系统调用来登记，说自己马上就要用了，希望分配一部分内存给它，但是这还不代表真的就对应到了物理内存。只有真的写入数据的时候，发现没有对应物理内存，才会触发一个中断，现分配物理内存。

这里我们介绍两个在堆里面分配内存的系统调用，brk和mmap。

当分配的内存数量比较小的时候，使用 brk，会和原来的堆的数据连在一起，这就像多分配两三个工位，在原来的区域旁边搬两把椅子就行了。当分配的内存数量比较大的时候，使用 mmap，会重新划分一块区域，也就是说，当办公空间需要太多的时候，索性来个一整块。

档案库管理与文件管理

项目执行计划书要保存在档案库里，有一些需要长时间保存，这样哪怕公司暂时停业，再次经营的时候还可以继续使用。同样，程序、文档、照片等，哪怕关机再开机也能不丢的，就需要放在文件系统里面。

文件之所以能做到这一点，一方面是因为介质，另一方面是因为格式。公司之所以强调资料库，也是希望将一些知识固化为标准格式，放在一起进行管理，无论多少人来人走，都不影响公司业务。

文件管理其实花样不多，拍着脑袋都能想出来，无非是创建、打开、读、写等。

对于文件的操作，下面这六个系统调用是最重要的：

对于已经有的文件，可以使用open打开这个文件，close关闭这个文件；

对于没有的文件，可以使用creat创建文件；

打开文件以后，可以使用lseek跳到文件的某个位置；

可以对文件的内容进行读写，读的系统调用是read，写是write。

但是别忘了，Linux 里有一个特点，那就是一切皆文件。

启动一个进程，需要一个程序文件，这是一个二进制文件。

启动的时候，要加载一些配置文件，例如 yml、properties 等，这是文本文件；启动之后会打印一些日志，如果写到硬盘上，也是文本文件。

但是如果我想把日志打印到交互控制台上，在命令行上唰唰地打印出来，这其实也是一个文件，是标准输出stdout 文件。

这个进程的输出可以作为另一个进程的输入，这种方式称为管道，管道也是一个文件。

进程可以通过网络和其他进程进行通信，建立的Socket，也是一个文件。

进程需要访问外部设备，设备也是一个文件。

文件都被存储在文件夹里面，其实文件夹也是一个文件。

进程运行起来，要想看到进程运行的情况，会在 /proc 下面有对应的进程号，还是一系列文件。

每个文件，Linux 都会分配一个文件描述符（File Descriptor），这是一个整数。有了这个文件描述符，我们就可以使用系统调用，查看或者干预进程运行的方方面面。

所以说，文件操作是贯穿始终的，这也是“一切皆文件”的优势，就是统一了操作的入口，提供了极大的便利。

项目异常处理与信号处理

在项目运行过程中，不一定都是一帆风顺的，很可能遇到各种异常情况。作为老板，处理异常情况的能力是非常重要的，所以办事大厅也一定要包含这部分服务。

当项目遇到异常情况，例如项目中断，做到一半不做了。这时候就需要发送一个信号（Signal）给项目组。经常遇到的信号有以下几种：

在执行一个程序的时候，在键盘输入“CTRL+C”，这就是中断的信号，正在执行的命令就会中止退出；

如果非法访问内存，例如你跑到别人的会议室，可能会看到不该看的东西；

硬件故障，设备出了问题，当然要通知项目组；

用户进程通过kill函数，将一个用户信号发送给另一个进程。

当项目组收到信号的时候，项目组需要决定如何处理这些异常情况。

对于一些不严重的信号，可以忽略，该干啥干啥，但是像 SIGKILL（用于终止一个进程的信号）和 SIGSTOP（用于中止一个进程的信号）是不能忽略的，可以执行对于该信号的默认动作。每种信号都定义了默认的动作，例如硬件故障，默认终止；也可以提供信号处理函数，可以通过sigaction系统调用，注册一个信号处理函数。

提供了信号处理服务，项目执行过程中一旦有变动，就可以及时处理了。

项目组间沟通与进程间通信

当某个项目比较大的时候，可能分成多个项目组，不同的项目组需要相互交流、相互配合才能完成，这就需要一个项目组之间的沟通机制。项目组之间的沟通方式有很多种，我们来一一规划。

首先就是发个消息，不需要一段很长的数据，这种方式称为消息队列（Message Queue）。由于一个公司内的多个项目组沟通时，这个消息队列是在内核里的，我们可以通过msgget创建一个新的队列，msgsnd将消息发送到消息队列，而消息接收方可以使用msgrcv从队列中取消息。

当两个项目组需要交互的信息比较大的时候，可以使用共享内存的方式，也即两个项目组共享一个会议室（这样数据就不需要拷贝来拷贝去）。大家都到这个会议室来，就可以完成沟通了。这时候，我们可以通过shmget创建一个共享内存块，通过shmat将共享内存映射到自己的内存空间，然后就可以读写了。

但是，两个项目组共同访问一个会议室里的数据，就会存在“竞争”的问题。如果大家同时修改同一块数据咋办？这就需要有一种方式，让不同的人能够排他地访问，这就是信号量的机制Semaphore。

这个机制比较复杂，我这里说一种简单的场景。

对于只允许一个人访问的需求，我们可以将信号量设为 1。当一个人要访问的时候，先调用sem_wait。如果这时候没有人访问，则占用这个信号量，他就可以开始访问了。如果这个时候另一个人要访问，也会调用 sem_wait。由于前一个人已经在访问了，所以后面这个人就必须等待上一个人访问完之后才能访问。当上一个人访问完毕后，会调用sem_post将信号量释放，于是下一个人等待结束，可以访问这个资源了。

公司间沟通与网络通信

同一个公司不同项目组之间的合作搞定了，如果是不同公司之间呢？也就是说，这台 Linux 要和另一台 Linux 交流，这时候，我们就需要用到网络服务。

不同机器的通过网络相互通信，要遵循相同的网络协议，也即TCP/IP 网络协议栈。Linux 内核里有对于网络协议栈的实现。如何暴露出服务给项目组使用呢？

网络服务是通过套接字 Socket 来提供服务的。Socket 这个名字很有意思，可以作“插口”或者“插槽”讲。虽然我们是写软件程序，但是你可以想象成弄一根网线，一头插在客户端，一头插在服务端，然后进行通信。因此，在通信之前，双方都要建立一个 Socket。

我们可以通过 Socket 系统调用建立一个 Socket。Socket 也是一个文件，也有一个文件描述符，也可以通过读写函数进行通信。

好了，我们分门别类地规划了这么多办事大厅的服务，如果这些都有了，足够我们成长为一个大型跨国公司了。

查看源代码中的系统调用

你如果问，这里的系统调用列举全了吗？其实没有，系统调用非常多。我建议你访问https://www.kernel.org下载一份 Linux 内核源代码。因为在接下来的整个课程里，我讲述的逻辑都是这些内核代码的逻辑。

对于 64 位操作系统，找到 unistd_64.h 文件，里面对于系统调用的定义，就是下面这样。

#define __NR_restart_syscall	  0

#define __NR_exit		  1

#define __NR_fork		  2

#define __NR_read		  3

#define __NR_write		  4

#define __NR_open		  5

#define __NR_close		  6

#define __NR_waitpid		  7

#define __NR_creat		  8

......

中介与 Glibc

如果你做过开发，你会觉得刚才讲的和平时咱们调用的函数不太一样。这是因为，平时你并没有直接使用系统调用。虽然咱们的办事大厅已经很方便了，但是为了对用户更友好，我们还可以使用中介Glibc，有事情找它就行，它会转换成为系统调用，帮你调用。

Glibc 是 Linux 下使用的开源的标准 C 库，它是 GNU 发布的 libc 库。Glibc 为程序员提供丰富的 API，除了例如字符串处理、数学运算等用户态服务之外，最重要的是封装了操作系统提供的系统服务，即系统调用的封装。

每个特定的系统调用对应了至少一个 Glibc 封装的库函数，比如说，系统提供的打开文件系统调用 sys_open 对应的是 Glibc 中的 open 函数。

有时候，Glibc 一个单独的 API 可能调用多个系统调用，比如说，Glibc 提供的 printf 函数就会调用如 sys_open、sys_mmap、sys_write、sys_close 等等系统调用。

也有时候，多个 API 也可能只对应同一个系统调用，如 Glibc 下实现的 malloc、calloc、free 等函数用来分配和释放内存，都利用了内核的 sys_brk 的系统调用。

总结时刻

学了这么多系统调用，我们还是用一个图来总结一下。

课堂练习

有个命令 strace，常用来跟踪进程执行时系统调用和所接收的信号。你可以试一下咱们学过的命令行，看看都执行了哪些系统调用。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

06 | x86架构：有了开放的架构，才能打造开放的营商环境

做生意的人最喜欢开放的营商环境，也就是说，我的这家公司，只要符合国家的法律，到哪里做生意，都能受到公平的对待，这样就不用为了适配各个地方的规则煞费苦心，只要集中精力优化自己的服务就可以了。

作为 Linux 操作系统，何尝不是这样。如果下面的硬件环境千差万别，就会很难集中精力做出让用户易用的产品。毕竟天天适配不同的平台，就已经够头大了。x86 架构就是这样一个开放的平台。今天我们就来解析一下它。

计算机的工作模式是什么样的？

还记得咱们攒电脑时买的那堆硬件吗？虽然你可以根据经验，把那些复杂的设备和线安装起来，但是你真的了解它们为什么要这么连接吗？

现在我就把硬件图和计算机的逻辑图对应起来，带你看看计算机的工作模式。

对于一个计算机来讲，最核心的就是CPU（Central Processing Unit，中央处理器）。这是这台计算机的大脑，所有的设备都围绕它展开。

对于公司来说，CPU 是真正干活的，将来执行项目都要靠它。

CPU 就相当于咱们公司的程序员，我们常说，二十一世最缺的是什么？是人才！所以，大量水平高、干活快的程序员，才是营商环境中最重要的部分。

CPU 和其他设备连接，要靠一种叫作总线（Bus）的东西，其实就是主板上密密麻麻的集成电路，这些东西组成了 CPU 和其他设备的高速通道。

在这些设备中，最重要的是内存（Memory）。因为单靠 CPU 是没办法完成计算任务的，很多复杂的计算任务都需要将中间结果保存下来，然后基于中间结果进行进一步的计算。CPU 本身没办法保存这么多中间结果，这就要依赖内存了。

内存就相当于办公室，我们要看看方不方便租到办公室，有没有什么创新科技园之类的。有了共享的、便宜的办公位，公司就有注册地了。

当然总线上还有一些其他设备，例如显卡会连接显示器、磁盘控制器会连接硬盘、USB 控制器会连接键盘和鼠标等等。

CPU 和内存是完成计算任务的核心组件，所以这里我们重点介绍一下CPU 和内存是如何配合工作的。

CPU 其实也不是单纯的一块，它包括三个部分，运算单元、数据单元和控制单元。

运算单元只管算，例如做加法、做位移等等。但是，它不知道应该算哪些数据，运算结果应该放在哪里。

运算单元计算的数据如果每次都要经过总线，到内存里面现拿，这样就太慢了，所以就有了数据单元。数据单元包括 CPU 内部的缓存和寄存器组，空间很小，但是速度飞快，可以暂时存放数据和运算结果。

有了放数据的地方，也有了算的地方，还需要有个指挥到底做什么运算的地方，这就是控制单元。控制单元是一个统一的指挥中心，它可以获得下一条指令，然后执行这条指令。这个指令会指导运算单元取出数据单元中的某几个数据，计算出个结果，然后放在数据单元的某个地方。

每个项目都有一个项目执行计划书，里面是一行行项目执行的指令，这些都是放在档案库里面的。每个进程都有一个程序放在硬盘上，是二进制的，再里面就是一行行的指令，会操作一些数据。

进程一旦运行，比如图中两个进程 A 和 B，会有独立的内存空间，互相隔离，程序会分别加载到进程 A 和进程 B 的内存空间里面，形成各自的代码段。当然真实情况肯定比我说的要复杂的多，进程的内存虽然隔离但不连续，除了简单的区分代码段和数据段，还会分的更细。

程序运行的过程中要操作的数据和产生的计算结果，都会放在数据段里面。那 CPU 怎么执行这些程序，操作这些数据，产生一些结果，并写入回内存呢？

CPU 的控制单元里面，有一个指令指针寄存器，它里面存放的是下一条指令在内存中的地址。控制单元会不停地将代码段的指令拿进来，先放入指令寄存器。

当前的指令分两部分，一部分是做什么操作，例如是加法还是位移；一部分是操作哪些数据。

要执行这条指令，就要把第一部分交给运算单元，第二部分交给数据单元。

数据单元根据数据的地址，从数据段里读到数据寄存器里，就可以参与运算了。运算单元做完运算，产生的结果会暂存在数据单元的数据寄存器里。最终，会有指令将数据写回内存中的数据段。

你可能会问，上面算来算去执行的都是进程 A 里的指令，那进程 B 呢？CPU 里有两个寄存器，专门保存当前处理进程的代码段的起始地址，以及数据段的起始地址。这里面写的都是进程 A，那当前执行的就是进程 A 的指令，等切换成进程 B，就会执行 B 的指令了，这个过程叫作进程切换（Process Switch）。这是一个多任务系统的必备操作，我们后面有专门的章节讲这个内容，这里你先有个印象。

到这里，你会发现，CPU 和内存来来回回传数据，靠的都是总线。其实总线上主要有两类数据，一个是地址数据，也就是我想拿内存中哪个位置的数据，这类总线叫地址总线（Address Bus）；另一类是真正的数据，这类总线叫数据总线（Data Bus）。

所以说，总线其实有点像连接 CPU 和内存这两个设备的高速公路，说总线到底是多少位，就类似说高速公路有几个车道。但是这两种总线的位数意义是不同的。

地址总线的位数，决定了能访问的地址范围到底有多广。例如只有两位，那 CPU 就只能认 00，01，10，11 四个位置，超过四个位置，就区分不出来了。位数越多，能够访问的位置就越多，能管理的内存的范围也就越广。

而数据总线的位数，决定了一次能拿多少个数据进来。例如只有两位，那 CPU 一次只能从内存拿两位数。要想拿八位，就要拿四次。位数越多，一次拿的数据就越多，访问速度也就越快。

x86 成为开放平台历史中的重要一笔

那 CPU 中总线的位数有没有个标准呢？如果没有标准，那操作系统作为软件就很难办了，因为软件层没办法实现通用的运算逻辑。这就像很多非标准的元器件一样，你烧你的电路板，我烧我的电路板，谁都不能用彼此的。

早期的 IBM 凭借大型机技术成为计算机市场的领头羊，直到后来个人计算机兴起，苹果公司诞生。但是，那个时候，无论是大型机还是个人计算机，每家的 CPU 架构都不一样。如果一直是这样，个人电脑、平板电脑、手机等等，都没办法形成统一的体系，就不会有我们现在通用的计算机了，更别提什么云计算、大数据这些统一的大平台了。

好在历史将 x86 平台推到了开放、统一、兼容的位置。我们继续来看 IBM 和 x86 的故事。

IBM 开始做 IBM PC 时，一开始并没有让最牛的华生实验室去研发，而是交给另一个团队。一年时间，软硬件全部自研根本不可能完成，于是他们采用了英特尔的 8088 芯片作为 CPU，使用微软的 MS-DOS 做操作系统。

谁能想到 IBM PC 卖的超级好，好到因为垄断市场而被起诉。IBM 就在被逼的情况下公开了一些技术，使得后来无数 IBM-PC 兼容机公司的出现，也就有了后来占据市场的惠普、康柏、戴尔等等。

能够开放自己的技术是一件了不起的事。从技术和发展的层面来讲，它会使得一项技术大面积铺开，形成行业标准。就比如现在常用的 Android 手机，如果没有开放的 Android 系统，我们也没办法享受到这么多不同类型的手机。

对于当年的 PC 机来说，其实也是这样。英特尔的技术因此成为了行业的开放事实标准。由于这个系列开端于 8086，因此称为 x86 架构。

后来英特尔的 CPU 数据总线和地址总线越来越宽，处理能力越来越强。但是一直不能忘记三点，一是标准，二是开放，三是兼容。因为要想如此大的一个软硬件生态都基于这个架构，符合它的标准，如果是封闭或者不兼容的，那谁都不答应。

从 8086 的原理说起

说完了 x86 的历史，我们再来看 x86 中最经典的一款处理器，8086 处理器。虽然它已经很老了，但是咱们现在操作系统中的很多特性都和它有关，并且一直保持兼容。

我们把 CPU 里面的组件放大之后来看。你可以看我画的这幅图。

我们先来看数据单元。

为了暂存数据，8086 处理器内部有 8 个 16 位的通用寄存器，也就是刚才说的 CPU 内部的数据单元，分别是 AX、BX、CX、DX、SP、BP、SI、DI。这些寄存器主要用于在计算过程中暂存数据。

这些寄存器比较灵活，其中 AX、BX、CX、DX 可以分成两个 8 位的寄存器来使用，分别是 AH、AL、BH、BL、CH、CL、DH、DL，其中 H 就是 High（高位），L 就是 Low（低位）的意思。

这样，比较长的数据也能暂存，比较短的数据也能暂存。你可能会说 16 位并不长啊，你可别忘了，那是在计算机刚刚起步的时代。

接着我们来看控制单元。

IP 寄存器就是指令指针寄存器（Instruction Pointer Register)，指向代码段中下一条指令的位置。CPU 会根据它来不断地将指令从内存的代码段中，加载到 CPU 的指令队列中，然后交给运算单元去执行。

如果需要切换进程呢？每个进程都分代码段和数据段，为了指向不同进程的地址空间，有四个 16 位的段寄存器，分别是 CS、DS、SS、ES。

其中，CS 就是代码段寄存器（Code Segment Register），通过它可以找到代码在内存中的位置；DS 是数据段的寄存器，通过它可以找到数据在内存中的位置。

SS 是栈寄存器（Stack Register）。栈是程序运行中一个特殊的数据结构，数据的存取只能从一端进行，秉承后进先出的原则，push 就是入栈，pop 就是出栈。

凡是与函数调用相关的操作，都与栈紧密相关。例如，A 调用 B，B 调用 C。当 A 调用 B 的时候，要执行 B 函数的逻辑，因而 A 运行的相关信息就会被 push 到栈里面。当 B 调用 C 的时候，同样，B 运行相关信息会被 push 到栈里面，然后才运行 C 函数的逻辑。当 C 运行完毕的时候，先 pop 出来的是 B，B 就接着调用 C 之后的指令运行下去。B 运行完了，再 pop 出来的就是 A，A 接着运行，直到结束。

如果运算中需要加载内存中的数据，需要通过 DS 找到内存中的数据，加载到通用寄存器中，应该如何加载呢？对于一个段，有一个起始的地址，而段内的具体位置，我们称为偏移量（Offset）。例如 8 号会议室的第三排，8 号会议室就是起始地址，第三排就是偏移量。

在 CS 和 DS 中都存放着一个段的起始地址。代码段的偏移量在 IP 寄存器中，数据段的偏移量会放在通用寄存器中。

这时候问题来了，CS 和 DS 都是 16 位的，也就是说，起始地址都是 16 位的，IP 寄存器和通用寄存器都是 16 位的，偏移量也是 16 位的，但是 8086 的地址总线地址是 20 位。怎么凑够这 20 位呢？方法就是“起始地址 *16+ 偏移量”，也就是把 CS 和 DS 中的值左移 4 位，变成 20 位的，加上 16 位的偏移量，这样就可以得到最终 20 位的数据地址。

从这个计算方式可以算出，无论真正的内存多么大，对于只有 20 位地址总线的 8086 来讲，能够区分出的地址也就 2^20=1M，超过这个空间就访问不到了。这又是为啥呢？如果你想访问 1M+X 的地方，这个位置已经超过 20 位了，由于地址总线只有 20 位，在总线上超过 20 位的部分根本是发不出去的，所以发出去的还是 X，最后还是会访问 1M 内的 X 的位置。

那一个段最大能有多大呢？因为偏移量只能是 16 位的，所以一个段最大的大小是 2^16=64k。

是不是好可怜？对于 8086CPU，最多只能访问 1M 的内存空间，还要分成多个段，每个段最多 64K。尽管我们现在看来这不可想象的小，根本没法儿用，但是在当时其实够用了。

再来说 32 位处理器

当然，后来计算机的发展日新月异，内存越来越大，总线也越来越宽。在 32 位处理器中，有 32 根地址总线，可以访问 2^32=4G 的内存。使用原来的模式肯定不行了，但是又不能完全抛弃原来的模式，因为这个架构是开放的。

“开放”，意味着有大量其他公司的软硬件是基于这个架构来实现的，不能为所欲为，想怎么改怎么改，一定要和原来的架构兼容，而且要一直兼容，这样大家才愿意跟着你这个开放平台一直玩下去。如果你朝令夕改，那其他厂商就惨了。

如果是不开放的架构，那就没有问题。硬件、操作系统，甚至上面的软件都是自己搞的，你想怎么改就可以怎么改。

我们下面来说说，在开放架构的基础上，如何保持兼容呢？

首先，通用寄存器有扩展，可以将 8 个 16 位的扩展到 8 个 32 位的，但是依然可以保留 16 位的和 8 位的使用方式。你可能会问，为什么高 16 位不分成两个 8 位使用呢？因为这样就不兼容了呀！

其中，指向下一条指令的指令指针寄存器 IP，就会扩展成 32 位的，同样也兼容 16 位的。

而改动比较大，有点不兼容的就是段寄存器（Segment Register）。

因为原来的模式其实有点不伦不类，因为它没有把 16 位当成一个段的起始地址，也没有按 8 位或者 16 位扩展的形式，而是根据当时的硬件，弄了一个不上不下的 20 位的地址。这样每次都要左移四位，也就意味着段的起始地址不能是任何一个地方，只是能整除 16 的地方。

如果新的段寄存器都改成 32 位的，明明 4G 的内存全部都能访问到，还左移不左移四位呢？

那我们索性就重新定义一把吧。CS、SS、DS、ES 仍然是 16 位的，但是不再是段的起始地址。段的起始地址放在内存的某个地方。这个地方是一个表格，表格中的一项一项是段描述符（Segment Descriptor）。这里面才是真正的段的起始地址。而段寄存器里面保存的是在这个表格中的哪一项，称为选择子（Selector）。

这样，将一个从段寄存器直接拿到的段起始地址，就变成了先间接地从段寄存器找到表格中的一项，再从表格中的一项中拿到段起始地址。

这样段起始地址就会很灵活了。当然为了快速拿到段起始地址，段寄存器会从内存中拿到 CPU 的描述符高速缓存器中。

这样就不兼容了，咋办呢？好在后面这种模式灵活度非常高，可以保持将来一直兼容下去。前面的模式出现的时候，没想到自己能够成为一个标准，所以设计就没这么灵活。

因而到了 32 位的系统架构下，我们将前一种模式称为实模式（Real Pattern），后一种模式称为保护模式（Protected Pattern）。

当系统刚刚启动的时候，CPU 是处于实模式的，这个时候和原来的模式是兼容的。也就是说，哪怕你买了 32 位的 CPU，也支持在原来的模式下运行，只不过快了一点而已。

当需要更多内存的时候，你可以遵循一定的规则，进行一系列的操作，然后切换到保护模式，就能够用到 32 位 CPU 更强大的能力。

这也就是说，不能无缝兼容，但是通过切换模式兼容，也是可以接受的。

在接下来的几节，我们就来看一下，CPU 如何从启动开始，逐渐从实模式变为保护模式的。

总结时刻

这一节，我们讲了 x86 架构。在以后的操作系统讲解中，我们也是主要基于 x86 架构进行讲解，只有了解了底层硬件的基本工作原理，将来才能理解操作系统的工作模式。

x86 架构总体来说还是很复杂的，其中和操作系统交互比较密切的部分，我画了个图。在这个图中，建议你重点牢记这些寄存器的作用，以及段的工作模式，后面我们马上就能够用到了。

课堂练习

操作这些底层的寄存器往往需要使用汇编语言，操作系统的一些底层的模块也是用汇编语言写的，因而你需要简单回顾一些汇编语言中的一些简单的命令的作用。所以，今天给你留个练习题，简单了解一下这些命令。

mov, call, jmp, int, ret, add, or, xor, shl, shr, push, pop, inc, dec, sub, cmp。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

07 | 从BIOS到bootloader：创业伊始，有活儿老板自己上

有了开放的营商环境，咱们外包公司的创业之旅就要开始了。

上一节我们说，x86 作为一个开放的营商环境，有两种模式，一种模式是实模式，只能寻址 1M，每个段最多 64K。这个太小了，相当于咱们创业的个体户模式。有了项目只能老板自己上，本小利微，万事开头难。另一种是保护模式，对于 32 位系统，能够寻址 4G。这就是大买卖了，老板要雇佣很多人接项目。

几乎所有成功的公司，都是从个体户模式发展壮大的，因此，这一节咱们就从系统刚刚启动的个体户模式开始说起。

BIOS 时期

当你轻轻按下计算机的启动按钮时，你的主板就加上电了。

按照我们之前说的，这时候你的 CPU 应该开始执行指令了。你作为老板，同时也作为员工，要开始干活了。可是你发现，这个时候还没有项目执行计划书，所以你没啥可干的。

也就是说，这个时候没有操作系统，内存也是空的，一穷二白。CPU 该怎么办呢？

你作为这个创业公司的老板，由于原来没开过公司，对于公司的运营当然是一脸懵的。但是我们有一个良好的营商环境，其中的创业指导中心早就考虑到这种情况了。于是，创业指导中心就给了你一套创业公司启动指导手册。你只要按着指导手册来干就行了。

计算机系统也早有计划。在主板上，有一个东西叫ROM（Read Only Memory，只读存储器）。这和咱们平常说的内存RAM（Random Access Memory，随机存取存储器）不同。

咱们平时买的内存条是可读可写的，这样才能保存计算结果。而 ROM 是只读的，上面早就固化了一些初始化的程序，也就是BIOS（Basic Input and Output System，基本输入输出系统）。

如果你自己安装过操作系统，刚启动的时候，按某个组合键，显示器会弹出一个蓝色的界面。能够调整启动顺序的系统，就是我说的 BIOS，然后我们就可以先执行它。

创业初期，你的办公室肯定很小。假如现在你有 1M 的内存地址空间。这个空间非常有限，你需要好好利用才行。

在 x86 系统中，将 1M 空间最上面的 0xF0000 到 0xFFFFF 这 64K 映射给 ROM，也就是说，到这部分地址访问的时候，会访问 ROM。

当电脑刚加电的时候，会做一些重置的工作，将 CS 设置为 0xFFFF，将 IP 设置为 0x0000，所以第一条指令就会指向 0xFFFF0，正是在 ROM 的范围内。在这里，有一个 JMP 命令会跳到 ROM 中做初始化工作的代码，于是，BIOS 开始进行初始化的工作。

创业指导手册第一条，BIOS 要检查一下系统的硬件是不是都好着呢。

创业指导手册第二条，要有个办事大厅，只不过自己就是办事员。这个时期你能提供的服务很简单，但也会有零星的客户来提要求。

这个时候，要建立一个中断向量表和中断服务程序，因为现在你还要用键盘和鼠标，这些都要通过中断进行的。

这个时期也要给客户输出一些结果，因为需要你自己来，所以你还要充当客户对接人。你做了什么工作，做到了什么程度，都要主动显示给客户，也就是在内存空间映射显存的空间，在显示器上显示一些字符。

最后，政府领进门，创业靠个人。接下来就是你发挥聪明才智的时候了。

bootloader 时期

政府给的创业指导手册只能保证你把公司成立起来，但是公司如何做大做强，需要你自己有一套经营方法。你可以试着从档案库里面翻翻，看哪里能够找到《企业经营宝典》。通过这个宝典，可以帮你建立一套完整的档案库管理体系，使得任何项目的档案查询都十分方便。

现在，什么线索都没有的 BIOS，做完自己的事情，只能从档案库门卫开始，慢慢打听操作系统的下落。

操作系统在哪儿呢？一般都会在安装在硬盘上，在 BIOS 的界面上。你会看到一个启动盘的选项。启动盘有什么特点呢？它一般在第一个扇区，占 512 字节，而且以 0xAA55 结束。这是一个约定，当满足这个条件的时候，就说明这是一个启动盘，在 512 字节以内会启动相关的代码。

这些代码是谁放在这里的呢？在 Linux 里面有一个工具，叫Grub2，全称 Grand Unified Bootloader Version 2。顾名思义，就是搞系统启动的。

你可以通过 grub2-mkconfig -o /boot/grub2/grub.cfg 来配置系统启动的选项。你可以看到里面有类似这样的配置。

menuentry 'CentOS Linux (3.10.0-862.el7.x86_64) 7 (Core)' --class centos --class gnu-linux --class gnu --class os --unrestricted $menuentry_id_option 'gnulinux-3.10.0-862.el7.x86_64-advanced-b1aceb95-6b9e-464a-a589-bed66220ebee' {

	load_video

	set gfxpayload=keep

	insmod gzio

	insmod part_msdos

	insmod ext2

	set root='hd0,msdos1'

	if [ x$feature_platform_search_hint = xy ]; then

	  search --no-floppy --fs-uuid --set=root --hint='hd0,msdos1'  b1aceb95-6b9e-464a-a589-bed66220ebee

	else

	  search --no-floppy --fs-uuid --set=root b1aceb95-6b9e-464a-a589-bed66220ebee

	fi

	linux16 /boot/vmlinuz-3.10.0-862.el7.x86_64 root=UUID=b1aceb95-6b9e-464a-a589-bed66220ebee ro console=tty0 console=ttyS0,115200 crashkernel=auto net.ifnames=0 biosdevname=0 rhgb quiet

	initrd16 /boot/initramfs-3.10.0-862.el7.x86_64.img

}

这里面的选项会在系统启动的时候，成为一个列表，让你选择从哪个系统启动。最终显示出来的结果就是下面这张图。至于上面选项的具体意思，我们后面再说。

使用 grub2-install /dev/sda，可以将启动程序安装到相应的位置。

grub2 第一个要安装的就是 boot.img。它由 boot.S 编译而成，一共 512 字节，正式安装到启动盘的第一个扇区。这个扇区通常称为MBR（Master Boot Record，主引导记录 / 扇区）。

BIOS 完成任务后，会将 boot.img 从硬盘加载到内存中的 0x7c00 来运行。

由于 512 个字节实在有限，boot.img 做不了太多的事情。它能做的最重要的一个事情就是加载 grub2 的另一个镜像 core.img。

引导扇区就是你找到的门卫，虽然他看着档案库的大门，但是知道的事情很少。他不知道你的宝典在哪里，但是，他知道应该问谁。门卫说，档案库入口处有个管理处，然后把你领到门口。

core.img 就是管理处，它们知道的和能做的事情就多了一些。core.img 由 lzma_decompress.img、diskboot.img、kernel.img 和一系列的模块组成，功能比较丰富，能做很多事情。

boot.img 先加载的是 core.img 的第一个扇区。如果从硬盘启动的话，这个扇区里面是 diskboot.img，对应的代码是 diskboot.S。

boot.img 将控制权交给 diskboot.img 后，diskboot.img 的任务就是将 core.img 的其他部分加载进来，先是解压缩程序 lzma_decompress.img，再往下是 kernel.img，最后是各个模块 module 对应的映像。这里需要注意，它不是 Linux 的内核，而是 grub 的内核。

lzma_decompress.img 对应的代码是 startup_raw.S，本来 kernel.img 是压缩过的，现在执行的时候，需要解压缩。

在这之前，我们所有遇到过的程序都非常非常小，完全可以在实模式下运行，但是随着我们加载的东西越来越大，实模式这 1M 的地址空间实在放不下了，所以在真正的解压缩之前，lzma_decompress.img 做了一个重要的决定，就是调用 real_to_prot，切换到保护模式，这样就能在更大的寻址空间里面，加载更多的东西。

从实模式切换到保护模式

好了，管理处听说你要找宝典，知道你将来是要做老板的人。既然是老板，早晚都要雇人干活的。这不是个体户小打小闹，所以，你需要切换到老板角色，进入保护模式了，把哪些是你的权限，哪些是你可以授权给别人的，都分的清清楚楚。

切换到保护模式要干很多工作，大部分工作都与内存的访问方式有关。

第一项是启用分段，就是在内存里面建立段描述符表，将寄存器里面的段寄存器变成段选择子，指向某个段描述符，这样就能实现不同进程的切换了。第二项是启动分页。能够管理的内存变大了，就需要将内存分成相等大小的块，这些我们放到内存那一节详细再讲。

切换到了老板角色，也是为了招聘很多人，同时接多个项目，这时候就需要划清界限，懂得集权与授权。

当了老板，眼界要宽多了，同理保护模式需要做一项工作，那就是打开 Gate A20，也就是第 21 根地址线的控制线。在实模式 8086 下面，一共就 20 个地址线，可访问 1M 的地址空间。如果超过了这个限度怎么办呢？当然是绕回来了。在保护模式下，第 21 根要起作用了，于是我们就需要打开 Gate A20。

切换保护模式的函数 DATA32 call real_to_prot 会打开 Gate A20，也就是第 21 根地址线的控制线。

现在好了，有的是空间了。接下来我们要对压缩过的 kernel.img 进行解压缩，然后跳转到 kernel.img 开始运行。

切换到了老板角色，你可以正大光明地进入档案馆，寻找你的那本宝典。

kernel.img 对应的代码是 startup.S 以及一堆 c 文件，在 startup.S 中会调用 grub_main，这是 grub kernel 的主函数。

在这个函数里面，grub_load_config() 开始解析，我们上面写的那个 grub.conf 文件里的配置信息。

如果是正常启动，grub_main 最后会调用 grub_command_execute (“normal”, 0, 0)，最终会调用 grub_normal_execute() 函数。在这个函数里面，grub_show_menu() 会显示出让你选择的那个操作系统的列表。

同理，作为老板，你发现这类的宝典不止一本，经营企业的方式也有很多种，到底是人性化的，还是强纪律的，这个时候你要做一个选择。

一旦，你选定了某个宝典，启动某个操作系统，就要开始调用 grub_menu_execute_entry() ，开始解析并执行你选择的那一项。接下来你的经营企业之路就此打开了。

例如里面的 linux16 命令，表示装载指定的内核文件，并传递内核启动参数。于是 grub_cmd_linux() 函数会被调用，它会首先读取 Linux 内核镜像头部的一些数据结构，放到内存中的数据结构来，进行检查。如果检查通过，则会读取整个 Linux 内核镜像到内存。

如果配置文件里面还有 initrd 命令，用于为即将启动的内核传递 init ramdisk 路径。于是 grub_cmd_initrd() 函数会被调用，将 initramfs 加载到内存中来。

当这些事情做完之后，grub_command_execute (“boot”, 0, 0) 才开始真正地启动内核。

总结时刻

启动的过程比较复杂，我这里画一个图，让你比较形象地理解这个过程。你可以根据我讲的，自己来梳理一遍这个过程，做到不管是从流程还是细节上，都能心中有数。

课堂练习

grub2 是一个非常牛的 Linux 启动管理器，请你研究一下 grub2 的命令和配置，并试试通过它启动 Ubuntu 和 centOS 两个操作系统。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

08 | 内核初始化：生意做大了就得成立公司

上一节，你获得了一本《企业经营宝典》，完成了一件大事，切换到了老板角色，从实模式切换到了保护模式。有了更强的寻址能力，接下来，我们就要按照宝典里面的指引，开始经营企业了。

内核的启动从入口函数 start_kernel() 开始。在 init/main.c 文件中，start_kernel 相当于内核的 main 函数。打开这个函数，你会发现，里面是各种各样初始化函数 XXXX_init。

初始化公司职能部门

于是，公司要开始建立各种职能部门了。

首先是项目管理部门。咱们将来肯定要接各种各样的项目，因此，项目管理体系和项目管理流程首先要建立起来。之前讲的创建项目都是复制老项目，现在咱们需要有第一个全新的项目。这个项目需要你这个老板来打个样。

在操作系统里面，先要有个创始进程，有一行指令 set_task_stack_end_magic(&init_task)。这里面有一个参数 init_task，它的定义是 struct task_struct init_task = INIT_TASK(init_task)。它是系统创建的第一个进程，我们称为0 号进程。这是唯一一个没有通过 fork 或者 kernel_thread 产生的进程，是进程列表的第一个。

所谓进程列表（Procese List），就是咱们前面说的项目管理工具，里面列着我们所有接的项目。

第二个要初始化的就是办事大厅。有了办事大厅，我们就可以响应客户的需求。

这里面对应的函数是 trap_init()，里面设置了很多中断门（Interrupt Gate），用于处理各种中断。其中有一个 set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32)，这是系统调用的中断门。系统调用也是通过发送中断的方式进行的。当然，64 位的有另外的系统调用方法，这一点我们放到后面的系统调用章节详细谈。

接下来要初始化的是咱们的会议室管理系统。对应的，mm_init() 就是用来初始化内存管理模块。

项目需要项目管理进行调度，需要执行一定的调度策略。sched_init() 就是用于初始化调度模块。

vfs_caches_init() 会用来初始化基于内存的文件系统 rootfs。在这个函数里面，会调用 mnt_init()->init_rootfs()。这里面有一行代码，register_filesystem(&rootfs_fs_type)。在 VFS 虚拟文件系统里面注册了一种类型，我们定义为 struct file_system_type rootfs_fs_type。

文件系统是我们的项目资料库，为了兼容各种各样的文件系统，我们需要将文件的相关数据结构和操作抽象出来，形成一个抽象层对上提供统一的接口，这个抽象层就是 VFS（Virtual File System），虚拟文件系统。

这里的 rootfs 还有其他用处，下面我们会用到。

最后，start_kernel() 调用的是 rest_init()，用来做其他方面的初始化，这里面做了好多的工作。

初始化 1 号进程

rest_init 的第一大工作是，用 kernel_thread(kernel_init, NULL, CLONE_FS) 创建第二个进程，这个是1 号进程。

1 号进程对于操作系统来讲，有“划时代”的意义。因为它将运行一个用户进程，这意味着这个公司把一个老板独立完成的制度，变成了可以交付他人完成的制度。这个 1 号进程就相当于老板带了一个大徒弟，有了第一个，就有第二个，后面大徒弟开枝散叶，带了很多徒弟，形成一棵进程树。

一旦有了用户进程，公司的运行模式就要发生一定的变化。因为原来你是老板，没有雇佣其他人，所有东西都是你的，无论多么关键的资源，第一，不会有人给你抢，第二，不会有人恶意破坏、恶意使用。

但是现在有了其他人，你就要开始做一定的区分，哪些是核心资源，哪些是非核心资源；办公区也要分开，有普通的项目人员都能访问的项目工作区，还有职业核心人员能够访问的核心保密区。

好在 x86 提供了分层的权限机制，把区域分成了四个 Ring，越往里权限越高，越往外权限越低。

操作系统很好地利用了这个机制，将能够访问关键资源的代码放在 Ring0，我们称为内核态（Kernel Mode）；将普通的程序代码放在 Ring3，我们称为用户态（User Mode）。

你别忘了，现在咱们的系统已经处于保护模式了，保护模式除了可访问空间大一些，还有另一个重要功能，就是“保护”，也就是说，当处于用户态的代码想要执行更高权限的指令，这种行为是被禁止的，要防止他们为所欲为。

如果用户态的代码想要访问核心资源，怎么办呢？咱们不是有提供系统调用的办事大厅吗？这里是统一的入口，用户态代码在这里请求就是了。办事大厅后面就是内核态，用户态代码不用管后面发生了什么，做完了返回结果就可以了。

当一个用户态的程序运行到一半，要访问一个核心资源，例如访问网卡发一个网络包，就需要暂停当前的运行，调用系统调用，接下来就轮到内核中的代码运行了。

首先，内核将从系统调用传过来的包，在网卡上排队，轮到的时候就发送。发送完了，系统调用就结束了，返回用户态，让暂停运行的程序接着运行。

这个暂停怎么实现呢？其实就是把程序运行到一半的情况保存下来。例如，我们知道，内存是用来保存程序运行时候的中间结果的，现在要暂时停下来，这些中间结果不能丢，因为再次运行的时候，还要基于这些中间结果接着来。另外就是，当前运行到代码的哪一行了，当前的栈在哪里，这些都是在寄存器里面的。

所以，暂停的那一刻，要把当时 CPU 的寄存器的值全部暂存到一个地方，这个地方可以放在进程管理系统很容易获取的地方。在后面讨论进程管理数据结构的时候，我们还会详细讲。当系统调用完毕，返回的时候，再从这个地方将寄存器的值恢复回去，就能接着运行了。

这个过程就是这样的：用户态 - 系统调用 - 保存寄存器 - 内核态执行系统调用 - 恢复寄存器 - 返回用户态，然后接着运行。

从内核态到用户态

我们再回到 1 号进程启动的过程。当前执行 kernel_thread 这个函数的时候，我们还在内核态，现在我们就来跨越这道屏障，到用户态去运行一个程序。这该怎么办呢？很少听说“先内核态再用户态”的。

kernel_thread 的参数是一个函数 kernel_init，也就是这个进程会运行这个函数。在 kernel_init 里面，会调用 kernel_init_freeable()，里面有这样的代码：

if (!ramdisk_execute_command)

		ramdisk_execute_command = "/init";

先不管 ramdisk 是啥，我们回到 kernel_init 里面。这里面有这样的代码块：

	if (ramdisk_execute_command) {

		ret = run_init_process(ramdisk_execute_command);

......

	}

......

	if (!try_to_run_init_process("/sbin/init") ||

	    !try_to_run_init_process("/etc/init") ||

	    !try_to_run_init_process("/bin/init") ||

	    !try_to_run_init_process("/bin/sh"))

		return 0;

这就说明，1 号进程运行的是一个文件。如果我们打开 run_init_process 函数，会发现它调用的是 do_execve。

这个名字是不是看起来很熟悉？前面讲系统调用的时候，execve 是一个系统调用，它的作用是运行一个执行文件。加一个 do_ 的往往是内核系统调用的实现。没错，这就是一个系统调用，它会尝试运行 ramdisk 的“/init”，或者普通文件系统上的“/sbin/init”“/etc/init”“/bin/init”“/bin/sh”。不同版本的 Linux 会选择不同的文件启动，但是只要有一个起来了就可以。

static int run_init_process(const char *init_filename)

{

	argv_init[0] = init_filename;

	return do_execve(getname_kernel(init_filename),

		(const char __user *const __user *)argv_init,

		(const char __user *const __user *)envp_init);

}

如何利用执行 init 文件的机会，从内核态回到用户态呢？

我们从系统调用的过程可以得到启发，“用户态 - 系统调用 - 保存寄存器 - 内核态执行系统调用 - 恢复寄存器 - 返回用户态”，然后接着运行。而咱们刚才运行 init，是调用 do_execve，正是上面的过程的后半部分，从内核态执行系统调用开始。

do_execve->do_execveat_common->exec_binprm->search_binary_handler，这里面会调用这段内容：

int search_binary_handler(struct linux_binprm *bprm)

{

  ......

  struct linux_binfmt *fmt;

  ......

  retval = fmt->load_binary(bprm);

  ......

}

也就是说，我要运行一个程序，需要加载这个二进制文件，这就是我们常说的项目执行计划书。它是有一定格式的。Linux 下一个常用的格式是ELF（Executable and Linkable Format，可执行与可链接格式）。于是我们就有了下面这个定义：

static struct linux_binfmt elf_format = {

.module	= THIS_MODULE,

.load_binary	= load_elf_binary,

.load_shlib	= load_elf_library,

.core_dump	= elf_core_dump,

.min_coredump	= ELF_EXEC_PAGESIZE,

};

这其实就是先调用 load_elf_binary，最后调用 start_thread。

void

start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)

{

set_user_gs(regs, 0);

regs->fs	= 0;

regs->ds	= __USER_DS;

regs->es	= __USER_DS;

regs->ss	= __USER_DS;

regs->cs	= __USER_CS;

regs->ip	= new_ip;

regs->sp	= new_sp;

regs->flags	= X86_EFLAGS_IF;

force_iret();

}

EXPORT_SYMBOL_GPL(start_thread);

看到这里，你是不是有点感觉了？struct pt_regs，看名字里的 register，就是寄存器啊！这个结构就是在系统调用的时候，内核中保存用户态运行上下文的，里面将用户态的代码段 CS 设置为 __USER_CS，将用户态的数据段 DS 设置为 __USER_DS，以及指令指针寄存器 IP、栈指针寄存器 SP。这里相当于补上了原来系统调用里，保存寄存器的一个步骤。

最后的 iret 是干什么的呢？它是用于从系统调用中返回。这个时候会恢复寄存器。从哪里恢复呢？按说是从进入系统调用的时候，保存的寄存器里面拿出。好在上面的函数补上了寄存器。CS 和指令指针寄存器 IP 恢复了，指向用户态下一个要执行的语句。DS 和函数栈指针 SP 也被恢复了，指向用户态函数栈的栈顶。所以，下一条指令，就从用户态开始运行了。

ramdisk 的作用

init 终于从内核到用户态了。一开始到用户态的是 ramdisk 的 init，后来会启动真正根文件系统上的 init，成为所有用户态进程的祖先。

为什么会有 ramdisk 这个东西呢？还记得上一节咱们内核启动的时候，配置过这个参数：

initrd16 /boot/initramfs-3.10.0-862.el7.x86_64.img

就是这个东西，这是一个基于内存的文件系统。为啥会有这个呢？

是因为刚才那个 init 程序是在文件系统上的，文件系统一定是在一个存储设备上的，例如硬盘。Linux 访问存储设备，要有驱动才能访问。如果存储系统数目很有限，那驱动可以直接放到内核里面，反正前面我们加载过内核到内存里了，现在可以直接对存储系统进行访问。

但是存储系统越来越多了，如果所有市面上的存储系统的驱动都默认放进内核，内核就太大了。这该怎么办呢？

我们只好先弄一个基于内存的文件系统。内存访问是不需要驱动的，这个就是 ramdisk。这个时候，ramdisk 是根文件系统。

然后，我们开始运行 ramdisk 上的 /init。等它运行完了就已经在用户态了。/init 这个程序会先根据存储系统的类型加载驱动，有了驱动就可以设置真正的根文件系统了。有了真正的根文件系统，ramdisk 上的 /init 会启动文件系统上的 init。

接下来就是各种系统的初始化。启动系统的服务，启动控制台，用户就可以登录进来了。

先别忙着高兴，rest_init 的第一个大事情才完成。我们仅仅形成了用户态所有进程的祖先。

创建 2 号进程

用户态的所有进程都有大师兄了，那内核态的进程有没有一个人统一管起来呢？有的，rest_init 第二大事情就是第三个进程，就是 2 号进程。

kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES) 又一次使用 kernel_thread 函数创建进程。这里需要指出一点，函数名 thread 可以翻译成“线程”，这也是操作系统很重要的一个概念。它和进程有什么区别呢？为什么这里创建的是进程，函数名却是线程呢？

从用户态来看，创建进程其实就是立项，也就是启动一个项目。这个项目包含很多资源，例如会议室、资料库等。这些东西都属于这个项目，但是这个项目需要人去执行。有多个人并行执行不同的部分，这就叫多线程（Multithreading）。如果只有一个人，那它就是这个项目的主线程。

但是从内核态来看，无论是进程，还是线程，我们都可以统称为任务（Task），都使用相同的数据结构，平放在同一个链表中。这些在进程的那一章节，我会更加详细地讲。

这里的函数 kthreadd，负责所有内核态的线程的调度和管理，是内核态所有线程运行的祖先。

这下好了，用户态和内核态都有人管了，可以开始接项目了。

总结时刻

这一节，我们讲了内核的初始化过程，主要做了以下几件事情：

各个职能部门的创建；

用户态祖先进程的创建；

内核态祖先进程的创建。

咱们还是用一个图来总结一下这个过程。

课堂练习

这一节，我们看到内核创建了一些进程，这些进程都是放在一个列表中的，请你研读内核代码，看看这个列表是如何实现的。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

09 | 系统调用：公司成立好了就要开始接项目

上一节，系统终于进入了用户态，公司由一个“皮包公司”进入正轨，可以开始接项目了。

这一节，我们来解析 Linux 接项目的办事大厅是如何实现的，这是因为后面介绍的每一个模块，都涉及系统调用。站在系统调用的角度，层层深入下去，就能从某个系统调用的场景出发，了解内核中各个模块的实现机制。

有的时候，我们的客户觉得，直接去办事大厅还是不够方便。没问题，Linux 还提供了 glibc 这个中介。它更熟悉系统调用的细节，并且可以封装成更加友好的接口。你可以直接用。

glibc 对系统调用的封装

我们以最常用的系统调用 open，打开一个文件为线索，看看系统调用是怎么实现的。这一节我们仅仅会解析到从 glibc 如何调用到内核的 open，至于 open 怎么实现，怎么打开一个文件，留到文件系统那一节讲。

现在我们就开始在用户态进程里面调用 open 函数。

为了方便，大部分用户会选择使用中介，也就是说，调用的是 glibc 里面的 open 函数。这个函数是如何定义的呢？

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

在 glibc 的源代码中，有个文件 syscalls.list，里面列着所有 glibc 的函数对应的系统调用，就像下面这个样子：

# File name Caller  Syscall name    Args    Strong name Weak names

open		-	open		Ci:siv	__libc_open __open open

另外，glibc 还有一个脚本 make-syscall.sh，可以根据上面的配置文件，对于每一个封装好的系统调用，生成一个文件。这个文件里面定义了一些宏，例如 #define SYSCALL_NAME open。

glibc 还有一个文件 syscall-template.S，使用上面这个宏，定义了这个系统调用的调用方式。

T_PSEUDO (SYSCALL_SYMBOL, SYSCALL_NAME, SYSCALL_NARGS)

    ret

T_PSEUDO_END (SYSCALL_SYMBOL)

#define T_PSEUDO(SYMBOL, NAME, N)		PSEUDO (SYMBOL, NAME, N)

这里的 PSEUDO 也是一个宏，它的定义如下：

#define PSEUDO(name, syscall_name, args)                      \

  .text;                                      \

  ENTRY (name)                                    \

    DO_CALL (syscall_name, args);                         \

    cmpl $-4095, %eax;                               \

    jae SYSCALL_ERROR_LABEL

里面对于任何一个系统调用，会调用 DO_CALL。这也是一个宏，这个宏 32 位和 64 位的定义是不一样的。

32 位系统调用过程

我们先来看 32 位的情况（i386 目录下的 sysdep.h 文件）。

/* Linux takes system call arguments in registers:

	syscall number	%eax	     call-clobbered

	arg 1		%ebx	     call-saved

	arg 2		%ecx	     call-clobbered

	arg 3		%edx	     call-clobbered

	arg 4		%esi	     call-saved

	arg 5		%edi	     call-saved

	arg 6		%ebp	     call-saved

......

*/

#define DO_CALL(syscall_name, args)                           \

    PUSHARGS_##args                               \

    DOARGS_##args                                 \

    movl $SYS_ify (syscall_name), %eax;                          \

    ENTER_KERNEL                                  \

    POPARGS_##args

这里，我们将请求参数放在寄存器里面，根据系统调用的名称，得到系统调用号，放在寄存器 eax 里面，然后执行 ENTER_KERNEL。

在 Linux 的源代码注释里面，我们可以清晰地看到，这些寄存器是如何传递系统调用号和参数的。

这里面的 ENTER_KERNEL 是什么呢？

# define ENTER_KERNEL int $0x80

int 就是 interrupt，也就是“中断”的意思。int $0x80 就是触发一个软中断，通过它就可以陷入（trap）内核。

在内核启动的时候，还记得有一个 trap_init()，其中有这样的代码：

set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);

这是一个软中断的陷入门。当接收到一个系统调用的时候，entry_INT80_32 就被调用了。

ENTRY(entry_INT80_32)

        ASM_CLAC

        pushl   %eax                    /* pt_regs->orig_ax */

        SAVE_ALL pt_regs_ax=$-ENOSYS    /* save rest */

        movl    %esp, %eax

        call    do_syscall_32_irqs_on

.Lsyscall_32_done:

......

.Lirq_return:

	INTERRUPT_RETURN

通过 push 和 SAVE_ALL 将当前用户态的寄存器，保存在 pt_regs 结构里面。

进入内核之前，保存所有的寄存器，然后调用 do_syscall_32_irqs_on。它的实现如下：

static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs)

{

	struct thread_info *ti = current_thread_info();

	unsigned int nr = (unsigned int)regs->orig_ax;

......

	if (likely(nr < IA32_NR_syscalls)) {

		regs->ax = ia32_sys_call_table[nr](

			(unsigned int)regs->bx, (unsigned int)regs->cx,

			(unsigned int)regs->dx, (unsigned int)regs->si,

			(unsigned int)regs->di, (unsigned int)regs->bp);

	}

	syscall_return_slowpath(regs);

}

在这里，我们看到，将系统调用号从 eax 里面取出来，然后根据系统调用号，在系统调用表中找到相应的函数进行调用，并将寄存器中保存的参数取出来，作为函数参数。如果仔细比对，就能发现，这些参数所对应的寄存器，和 Linux 的注释是一样的。

根据宏定义，#define ia32_sys_call_table sys_call_table，系统调用就是放在这个表里面。至于这个表是如何形成的，我们后面讲。

当系统调用结束之后，在 entry_INT80_32 之后，紧接着调用的是 INTERRUPT_RETURN，我们能够找到它的定义，也就是 iret。

#define INTERRUPT_RETURN                iret

iret 指令将原来用户态保存的现场恢复回来，包含代码段、指令指针寄存器等。这时候用户态进程恢复执行。

这里我总结一下 32 位的系统调用是如何执行的。

64 位系统调用过程

我们再来看 64 位的情况（x86_64 下的 sysdep.h 文件）。

/* The Linux/x86-64 kernel expects the system call parameters in

   registers according to the following table:

    syscall number	rax

    arg 1		rdi

    arg 2		rsi

    arg 3		rdx

    arg 4		r10

    arg 5		r8

    arg 6		r9

......

*/

#define DO_CALL(syscall_name, args)					      \

  lea SYS_ify (syscall_name), %rax;					      \

  syscall

和之前一样，还是将系统调用名称转换为系统调用号，放到寄存器 rax。这里是真正进行调用，不是用中断了，而是改用 syscall 指令了。并且，通过注释我们也可以知道，传递参数的寄存器也变了。

syscall 指令还使用了一种特殊的寄存器，我们叫特殊模块寄存器（Model Specific Registers，简称 MSR）。这种寄存器是 CPU 为了完成某些特殊控制功能为目的的寄存器，其中就有系统调用。

在系统初始化的时候，trap_init 除了初始化上面的中断模式，这里面还会调用 cpu_init->syscall_init。这里面有这样的代码：

wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

rdmsr 和 wrmsr 是用来读写特殊模块寄存器的。MSR_LSTAR 就是这样一个特殊的寄存器，当 syscall 指令调用的时候，会从这个寄存器里面拿出函数地址来调用，也就是调用 entry_SYSCALL_64。

在 arch/x86/entry/entry_64.S 中定义了 entry_SYSCALL_64。

ENTRY(entry_SYSCALL_64)

        /* Construct struct pt_regs on stack */

        pushq   $__USER_DS                      /* pt_regs->ss */

        pushq   PER_CPU_VAR(rsp_scratch)        /* pt_regs->sp */

        pushq   %r11                            /* pt_regs->flags */

        pushq   $__USER_CS                      /* pt_regs->cs */

        pushq   %rcx                            /* pt_regs->ip */

        pushq   %rax                            /* pt_regs->orig_ax */

        pushq   %rdi                            /* pt_regs->di */

        pushq   %rsi                            /* pt_regs->si */

        pushq   %rdx                            /* pt_regs->dx */

        pushq   %rcx                            /* pt_regs->cx */

        pushq   $-ENOSYS                        /* pt_regs->ax */

        pushq   %r8                             /* pt_regs->r8 */

        pushq   %r9                             /* pt_regs->r9 */

        pushq   %r10                            /* pt_regs->r10 */

        pushq   %r11                            /* pt_regs->r11 */

        sub     $(6*8), %rsp                    /* pt_regs->bp, bx, r12-15 not saved */

        movq    PER_CPU_VAR(current_task), %r11

        testl   $_TIF_WORK_SYSCALL_ENTRY|_TIF_ALLWORK_MASK, TASK_TI_flags(%r11)

        jnz     entry_SYSCALL64_slow_path

......

entry_SYSCALL64_slow_path:

        /* IRQs are off. */

        SAVE_EXTRA_REGS

        movq    %rsp, %rdi

        call    do_syscall_64           /* returns with IRQs disabled */

return_from_SYSCALL_64:

	RESTORE_EXTRA_REGS

	TRACE_IRQS_IRETQ

	movq	RCX(%rsp), %rcx

	movq	RIP(%rsp), %r11

    movq	R11(%rsp), %r11

......

syscall_return_via_sysret:

	/* rcx and r11 are already restored (see code above) */

	RESTORE_C_REGS_EXCEPT_RCX_R11

	movq	RSP(%rsp), %rsp

	USERGS_SYSRET64

这里先保存了很多寄存器到 pt_regs 结构里面，例如用户态的代码段、数据段、保存参数的寄存器，然后调用 entry_SYSCALL64_slow_pat->do_syscall_64。

__visible void do_syscall_64(struct pt_regs *regs)

{

        struct thread_info *ti = current_thread_info();

        unsigned long nr = regs->orig_ax;

......

        if (likely((nr & __SYSCALL_MASK) < NR_syscalls)) {

                regs->ax = sys_call_table[nr & __SYSCALL_MASK](

                        regs->di, regs->si, regs->dx,

                        regs->r10, regs->r8, regs->r9);

        }

        syscall_return_slowpath(regs);

}

在 do_syscall_64 里面，从 rax 里面拿出系统调用号，然后根据系统调用号，在系统调用表 sys_call_table 中找到相应的函数进行调用，并将寄存器中保存的参数取出来，作为函数参数。如果仔细比对，你就能发现，这些参数所对应的寄存器，和 Linux 的注释又是一样的。

所以，无论是 32 位，还是 64 位，都会到系统调用表 sys_call_table 这里来。

在研究系统调用表之前，我们看 64 位的系统调用返回的时候，执行的是 USERGS_SYSRET64。定义如下：

#define USERGS_SYSRET64				\

	swapgs;					\

	sysretq;

这里，返回用户态的指令变成了 sysretq。

我们这里总结一下 64 位的系统调用是如何执行的。

系统调用表

前面我们重点关注了系统调用的方式，都是最终到了系统调用表，但是到底调用内核的什么函数，还没有解读。

现在我们再来看，系统调用表 sys_call_table 是怎么形成的呢？

32 位的系统调用表定义在面 arch/x86/entry/syscalls/syscall_32.tbl 文件里。例如 open 是这样定义的：

5	i386	open			sys_open  compat_sys_open

64 位的系统调用定义在另一个文件 arch/x86/entry/syscalls/syscall_64.tbl 里。例如 open 是这样定义的：

2	common	open			sys_open

第一列的数字是系统调用号。可以看出，32 位和 64 位的系统调用号是不一样的。第三列是系统调用的名字，第四列是系统调用在内核的实现函数。不过，它们都是以 sys_ 开头。

系统调用在内核中的实现函数要有一个声明。声明往往在 include/linux/syscalls.h 文件中。例如 sys_open 是这样声明的：

asmlinkage long sys_open(const char __user *filename,

                                int flags, umode_t mode);

真正的实现这个系统调用，一般在一个.c 文件里面，例如 sys_open 的实现在 fs/open.c 里面，但是你会发现样子很奇怪。

SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)

{

        if (force_o_largefile())

                flags |= O_LARGEFILE;

        return do_sys_open(AT_FDCWD, filename, flags, mode);

}

SYSCALL_DEFINE3 是一个宏系统调用最多六个参数，根据参数的数目选择宏。具体是这样定义的：

#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)

#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)

#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)

#define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__)

#define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__)

#define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__)

#define SYSCALL_DEFINEx(x, sname, ...)                          \

        SYSCALL_METADATA(sname, x, __VA_ARGS__)                 \

        __SYSCALL_DEFINEx(x, sname, __VA_ARGS__)

#define __PROTECT(...) asmlinkage_protect(__VA_ARGS__)

#define __SYSCALL_DEFINEx(x, name, ...)                                 \

        asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))       \

                __attribute__((alias(__stringify(SyS##name))));         \

        static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__));  \

        asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__));      \

        asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__))       \

        {                                                               \

                long ret = SYSC##name(__MAP(x,__SC_CAST,__VA_ARGS__));  \

                __MAP(x,__SC_TEST,__VA_ARGS__);                         \

                __PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__));       \

                return ret;                                             \

        }                                                               \

        static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__)

如果我们把宏展开之后，实现如下，和声明的是一样的。

asmlinkage long sys_open(const char __user * filename, int flags, int mode)

{

 long ret;

 if (force_o_largefile())

  flags |= O_LARGEFILE;

 ret = do_sys_open(AT_FDCWD, filename, flags, mode);

 asmlinkage_protect(3, ret, filename, flags, mode);

 return ret;

声明和实现都好了。接下来，在编译的过程中，需要根据 syscall_32.tbl 和 syscall_64.tbl 生成自己的 unistd_32.h 和 unistd_64.h。生成方式在 arch/x86/entry/syscalls/Makefile 中。

这里面会使用两个脚本，其中第一个脚本 arch/x86/entry/syscalls/syscallhdr.sh，会在文件中生成 #define __NR_open；第二个脚本 arch/x86/entry/syscalls/syscalltbl.sh，会在文件中生成 __SYSCALL(__NR_open, sys_open)。这样，unistd_32.h 和 unistd_64.h 是对应的系统调用号和系统调用实现函数之间的对应关系。

在文件 arch/x86/entry/syscall_32.c，定义了这样一个表，里面 include 了这个头文件，从而所有的 sys_ 系统调用都在这个表里面了。

__visible const sys_call_ptr_t ia32_sys_call_table[__NR_syscall_compat_max+1] = {

        /*

         * Smells like a compiler bug -- it doesn't work

         * when the & below is removed.

         */

        [0 ... __NR_syscall_compat_max] = &sys_ni_syscall,

#include <asm/syscalls_32.h>

};

同理，在文件 arch/x86/entry/syscall_64.c，定义了这样一个表，里面 include 了这个头文件，这样所有的 sys_ 系统调用就都在这个表里面了。

/* System call table for x86-64. */

asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {

	/*

	 * Smells like a compiler bug -- it doesn't work

	 * when the & below is removed.

	 */

	[0 ... __NR_syscall_max] = &sys_ni_syscall,

#include <asm/syscalls_64.h>

};

总结时刻

系统调用的过程还是挺复杂的吧？如果加上上一节的内核态和用户态的模式切换，就更复杂了。这里我们重点分析 64 位的系统调用，我将整个完整的过程画了一张图，帮你总结、梳理一下。

课堂练习

请你根据这一节的分析，看一下与 open 这个系统调用相关的文件都有哪些，在每个文件里面都做了什么？如果你要自己实现一个系统调用，能不能照着 open 来一个呢？

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

10 | 进程：公司接这么多项目，如何管？

有了系统调用，咱们公司就能开始批量接项目啦！对应到 Linux 操作系统，就是可以创建进程了。

在命令行那一节，我们讲了使用命令创建 Linux 进程的几种方式。现在学习了系统调用，你是不是想尝试一下，如何通过写代码使用系统调用创建一个进程呢？我们一起来看看。

写代码：用系统调用创建进程

在 Linux 上写程序和编译程序，也需要一系列的开发套件，就像 Visual Studio 一样。运行下面的命令，就可以在 centOS 7 操作系统上安装开发套件。在以后的章节里面，我们的实验都是基于 centOS 7 操作系统进行的。

yum -y groupinstall "Development Tools"

接下来，我们要开始写程序了。在 Windows 上写的程序，都会被保存成.h 或者.c 文件，容易让人感觉这是某种有特殊格式的文件，但其实这些文件只是普普通通的文本文件。因而在 Linux 上，我们用 Vim 来创建并编辑一个文件就行了。

我们先来创建一个文件，里面用一个函数封装通用的创建进程的逻辑，名字叫 process.c，代码如下：

#include <stdio.h>

#include <stdlib.h>

#include <sys/types.h>

#include <unistd.h>

extern int create_process (char* program, char** arg_list);

int create_process (char* program, char** arg_list)

{

    pid_t child_pid;

    child_pid = fork ();

    if (child_pid != 0)

        return child_pid;

    else {

        execvp (program, arg_list);

        abort ();

    }

这里面用到了咱们学过的 fork 系统调用，通过这里面的 if-else，我们可以看到，根据 fork 的返回值不同，父进程和子进程就此分道扬镳了。在子进程里面，我们需要通过 execvp 运行一个新的程序。

接下来我们创建第二个文件，调用上面这个函数。

#include <stdio.h>

#include <stdlib.h>

#include <sys/types.h>

#include <unistd.h>

extern int create_process (char* program, char** arg_list);

int main ()

{

    char* arg_list[] = {

        "ls",

        "-l",

        "/etc/yum.repos.d/",

        NULL

    };

    create_process ("ls", arg_list);

    return 0;

}

在这里，我们创建的子程序运行了一个最最简单的命令 ls。学过命令行的那一节之后，这里你应该很熟悉了。

进行编译：程序的二进制格式

程序写完了，是不是很简单？你可能要问了，这是不是就是我们所谓的项目执行计划书了呢？当然不是了，这两个文件只是文本文件，CPU 是不能执行文本文件里面的指令的，这些指令只有人能看懂，CPU 能够执行的命令是二进制的，比如“0101”这种，所以这些指令还需要翻译一下，这个翻译的过程就是编译（Compile）。编译好的二进制文件才是项目执行计划书。

现在咱们是正规的公司了，接项目要有章法，项目执行计划书也要有统一的格式，这样才能保证无论项目交到哪个项目组手里，都能以固定的流程执行。按照里面的指令来，项目也能达到预期的效果。

在 Linux 下面，二进制的程序也要有严格的格式，这个格式我们称为ELF（Executeable and Linkable Format，可执行与可链接格式）。这个格式可以根据编译的结果不同，分为不同的格式。

接下来我们看一下，如何从文本文件编译成二进制格式。

在上面两段代码中，上面 include 的部分是头文件，而我们写的这个.c 结尾的是源文件。

接下来我们编译这两个程序。

gcc -c -fPIC process.c

gcc -c -fPIC createprocess.c

在编译的时候，先做预处理工作，例如将头文件嵌入到正文中，将定义的宏展开，然后就是真正的编译过程，最终编译成为.o 文件，这就是 ELF 的第一种类型，可重定位文件（Relocatable File）。

这个文件的格式是这样的：

ELF 文件的头是用于描述整个文件的。这个文件格式在内核中有定义，分别为 struct elf32_hdr 和 struct elf64_hdr。

接下来我们来看一个一个的 section，我们也叫节。这里面的名字有点晦涩，不过你可以猜一下它们是干什么的。

这个编译好的二进制文件里面，应该是代码，还有一些全局变量、静态变量等等。没错，我们依次来看。

.text：放编译好的二进制可执行代码

.data：已经初始化好的全局变量

.rodata：只读数据，例如字符串常量、const 的变量

.bss：未初始化全局变量，运行时会置 0

.symtab：符号表，记录的则是函数和变量

.strtab：字符串表、字符串常量和变量名

为啥这里只有全局变量呢？其实前面我们讲函数栈的时候说过，局部变量是放在栈里面的，是程序运行过程中随时分配空间，随时释放的，现在我们讨论的是二进制文件，还没启动呢，所以只需要讨论在哪里保存全局变量。

这些节的元数据信息也需要有一个地方保存，就是最后的节头部表（Section Header Table）。在这个表里面，每一个 section 都有一项，在代码里面也有定义 struct elf32_shdr 和 struct elf64_shdr。在 ELF 的头里面，有描述这个文件的节头部表的位置，有多少个表项等等信息。

我们刚才说了可重定位，为啥叫可重定位呢？我们可以想象一下，这个编译好的代码和变量，将来加载到内存里面的时候，都是要加载到一定位置的。比如说，调用一个函数，其实就是跳到这个函数所在的代码位置执行；再比如修改一个全局变量，也是要到变量的位置那里去修改。但是现在这个时候，还是.o 文件，不是一个可以直接运行的程序，这里面只是部分代码片段。

例如这里的 create_process 函数，将来被谁调用，在哪里调用都不清楚，就更别提确定位置了。所以，.o 里面的位置是不确定的，但是必须是可重新定位的，因为它将来是要做函数库的嘛，就是一块砖，哪里需要哪里搬，搬到哪里就重新定位这些代码、变量的位置。

有的 section，例如.rel.text, .rel.data 就与重定位有关。例如这里的 createprocess.o，里面调用了 create_process 函数，但是这个函数在另外一个.o 里面，因而 createprocess.o 里面根本不可能知道被调用函数的位置，所以只好在 rel.text 里面标注，这个函数是需要重定位的。

要想让 create_process 这个函数作为库文件被重用，不能以.o 的形式存在，而是要形成库文件，最简单的类型是静态链接库.a 文件（Archives），仅仅将一系列对象文件（.o）归档为一个文件，使用命令 ar 创建。

ar cr libstaticprocess.a process.o

虽然这里 libstaticprocess.a 里面只有一个.o，但是实际情况可以有多个.o。当有程序要使用这个静态连接库的时候，会将.o 文件提取出来，链接到程序中。

gcc -o staticcreateprocess createprocess.o -L. -lstaticprocess

在这个命令里，-L 表示在当前目录下找.a 文件，-lstaticprocess 会自动补全文件名，比如加前缀 lib，后缀.a，变成 libstaticprocess.a，找到这个.a 文件后，将里面的 process.o 取出来，和 createprocess.o 做一个链接，形成二进制执行文件 staticcreateprocess。

这个链接的过程，重定位就起作用了，原来 createprocess.o 里面调用了 create_process 函数，但是不能确定位置，现在将 process.o 合并了进来，就知道位置了。

形成的二进制文件叫可执行文件，是 ELF 的第二种格式，格式如下：

这个格式和.o 文件大致相似，还是分成一个个的 section，并且被节头表描述。只不过这些 section 是多个.o 文件合并过的。但是这个时候，这个文件已经是马上就可以加载到内存里面执行的文件了，因而这些 section 被分成了需要加载到内存里面的代码段、数据段和不需要加载到内存里面的部分，将小的 section 合成了大的段 segment，并且在最前面加一个段头表（Segment Header Table）。在代码里面的定义为 struct elf32_phdr 和 struct elf64_phdr，这里面除了有对于段的描述之外，最重要的是 p_vaddr，这个是这个段加载到内存的虚拟地址。

在 ELF 头里面，有一项 e_entry，也是个虚拟地址，是这个程序运行的入口。

当程序运行起来之后，就是下面这个样子：

# ./staticcreateprocess

# total 40

-rw-r--r--. 1 root root 1572 Oct 24 18:38 CentOS-Base.repo

......

静态链接库一旦链接进去，代码和变量的 section 都合并了，因而程序运行的时候，就不依赖于这个库是否存在。但是这样有一个缺点，就是相同的代码段，如果被多个程序使用的话，在内存里面就有多份，而且一旦静态链接库更新了，如果二进制执行文件不重新编译，也不随着更新。

因而就出现了另一种，动态链接库（Shared Libraries），不仅仅是一组对象文件的简单归档，而是多个对象文件的重新组合，可被多个程序共享。

gcc -shared -fPIC -o libdynamicprocess.so process.o

当一个动态链接库被链接到一个程序文件中的时候，最后的程序文件并不包括动态链接库中的代码，而仅仅包括对动态链接库的引用，并且不保存动态链接库的全路径，仅仅保存动态链接库的名称。

gcc -o dynamiccreateprocess createprocess.o -L. -ldynamicprocess

当运行这个程序的时候，首先寻找动态链接库，然后加载它。默认情况下，系统在 /lib 和 /usr/lib 文件夹下寻找动态链接库。如果找不到就会报错，我们可以设定 LD_LIBRARY_PATH 环境变量，程序运行时会在此环境变量指定的文件夹下寻找动态链接库。

# export LD_LIBRARY_PATH=.

# ./dynamiccreateprocess

# total 40

-rw-r--r--. 1 root root 1572 Oct 24 18:38 CentOS-Base.repo

......

动态链接库，就是 ELF 的第三种类型，共享对象文件（Shared Object）。

基于动态连接库创建出来的二进制文件格式还是 ELF，但是稍有不同。

首先，多了一个.interp 的 Segment，这里面是 ld-linux.so，这是动态链接器，也就是说，运行时的链接动作都是它做的。

另外，ELF 文件中还多了两个 section，一个是.plt，过程链接表（Procedure Linkage Table，PLT），一个是.got.plt，全局偏移量表（Global Offset Table，GOT）。

它们是怎么工作的，使得程序运行的时候，可以将 so 文件动态链接到进程空间的呢？

dynamiccreateprocess 这个程序要调用 libdynamicprocess.so 里的 create_process 函数。由于是运行时才去找，编译的时候，压根不知道这个函数在哪里，所以就在 PLT 里面建立一项 PLT[x]。这一项也是一些代码，有点像一个本地的代理，在二进制程序里面，不直接调用 create_process 函数，而是调用 PLT[x] 里面的代理代码，这个代理代码会在运行的时候找真正的 create_process 函数。

去哪里找代理代码呢？这就用到了 GOT，这里面也会为 create_process 函数创建一项 GOT[y]。这一项是运行时 create_process 函数在内存中真正的地址。

如果这个地址在，dynamiccreateprocess 调用 PLT[x] 里面的代理代码，代理代码调用 GOT 表中对应项 GOT[y]，调用的就是加载到内存中的 libdynamicprocess.so 里面的 create_process 函数了。

但是 GOT 怎么知道的呢？对于 create_process 函数，GOT 一开始就会创建一项 GOT[y]，但是这里面没有真正的地址，因为它也不知道，但是它有办法，它又回调 PLT，告诉它，你里面的代理代码来找我要 create_process 函数的真实地址，我不知道，你想想办法吧。

PLT 这个时候会转而调用 PLT[0]，也即第一项，PLT[0] 转而调用 GOT[2]，这里面是 ld-linux.so 的入口函数，这个函数会找到加载到内存中的 libdynamicprocess.so 里面的 create_process 函数的地址，然后把这个地址放在 GOT[y] 里面。下次，PLT[x] 的代理函数就能够直接调用了。

这个过程有点绕，但是是不是也很巧妙？

运行程序为进程

知道了 ELF 这个格式，这个时候它还是个程序，那怎么把这个文件加载到内存里面呢？

在内核中，有这样一个数据结构，用来定义加载二进制文件的方法。

struct linux_binfmt {

        struct list_head lh;

        struct module *module;

        int (*load_binary)(struct linux_binprm *);

        int (*load_shlib)(struct file *);

        int (*core_dump)(struct coredump_params *cprm);

        unsigned long min_coredump;     /* minimal dump size */

} __randomize_layout;

对于 ELF 文件格式，有对应的实现。

static struct linux_binfmt elf_format = {

        .module         = THIS_MODULE,

        .load_binary    = load_elf_binary,

        .load_shlib     = load_elf_library,

        .core_dump      = elf_core_dump,

        .min_coredump   = ELF_EXEC_PAGESIZE,

};

load_elf_binary 是不是你很熟悉？没错，我们加载内核镜像的时候，用的也是这种格式。

还记得当时是谁调用的 load_elf_binary 函数吗？具体是这样的：do_execve->do_execveat_common->exec_binprm->search_binary_handler。

那 do_execve 又是被谁调用的呢？我们看下面的代码。

SYSCALL_DEFINE3(execve,

		const char __user *, filename,

		const char __user *const __user *, argv,

		const char __user *const __user *, envp)

{

	return do_execve(getname(filename), argv, envp);

}

学过了系统调用一节，你会发现，原理是 exec 这个系统调用最终调用的 load_elf_binary。

exec 比较特殊，它是一组函数：

包含 p 的函数（execvp, execlp）会在 PATH 路径下面寻找程序；

不包含 p 的函数需要输入程序的全路径；

包含 v 的函数（execv, execvp, execve）以数组的形式接收参数；

包含 l 的函数（execl, execlp, execle）以列表的形式接收参数；

包含 e 的函数（execve, execle）以数组的形式接收环境变量。

在上面 process.c 的代码中，我们创建 ls 进程，也是通过 exec。

进程树

既然所有的进程都是从父进程 fork 过来的，那总归有一个祖宗进程，这就是咱们系统启动的 init 进程。

在解析 Linux 的启动过程的时候，1 号进程是 /sbin/init。如果在 centOS 7 里面，我们 ls 一下，可以看到，这个进程是被软链接到 systemd 的。

/sbin/init -> ../lib/systemd/systemd

系统启动之后，init 进程会启动很多的 daemon 进程，为系统运行提供服务，然后就是启动 getty，让用户登录，登录后运行 shell，用户启动的进程都是通过 shell 运行的，从而形成了一棵进程树。

我们可以通过 ps -ef 命令查看当前系统启动的进程，我们会发现有三类进程。

[root@deployer ~]# ps -ef

UID        PID  PPID  C STIME TTY          TIME CMD

root         1     0  0  2018 ?        00:00:29 /usr/lib/systemd/systemd --system --deserialize 21

root         2     0  0  2018 ?        00:00:00 [kthreadd]

root         3     2  0  2018 ?        00:00:00 [ksoftirqd/0]

root         5     2  0  2018 ?        00:00:00 [kworker/0:0H]

root         9     2  0  2018 ?        00:00:40 [rcu_sched]

......

root       337     2  0  2018 ?        00:00:01 [kworker/3:1H]

root       380     1  0  2018 ?        00:00:00 /usr/lib/systemd/systemd-udevd

root       415     1  0  2018 ?        00:00:01 /sbin/auditd

root       498     1  0  2018 ?        00:00:03 /usr/lib/systemd/systemd-logind

......

root       852     1  0  2018 ?        00:06:25 /usr/sbin/rsyslogd -n

root      2580     1  0  2018 ?        00:00:00 /usr/sbin/sshd -D

root     29058     2  0 Jan03 ?        00:00:01 [kworker/1:2]

root     29672     2  0 Jan04 ?        00:00:09 [kworker/2:1]

root     30467     1  0 Jan06 ?        00:00:00 /usr/sbin/crond -n

root     31574     2  0 Jan08 ?        00:00:01 [kworker/u128:2]

......

root     32792  2580  0 Jan10 ?        00:00:00 sshd: root@pts/0

root     32794 32792  0 Jan10 pts/0    00:00:00 -bash

root     32901 32794  0 00:01 pts/0    00:00:00 ps -ef

你会发现，PID 1 的进程就是我们的 init 进程 systemd，PID 2 的进程是内核线程 kthreadd，这两个我们在内核启动的时候都见过。其中用户态的不带中括号，内核态的带中括号。

接下来进程号依次增大，但是你会看所有带中括号的内核态的进程，祖先都是 2 号进程。而用户态的进程，祖先都是 1 号进程。tty 那一列，是问号的，说明不是前台启动的，一般都是后台的服务。

pts 的父进程是 sshd，bash 的父进程是 pts，ps -ef 这个命令的父进程是 bash。这样整个链条都比较清晰了。

总结时刻

这一节我们讲了一个进程从代码到二进制到运行时的一个过程，我们用一个图总结一下。

我们首先通过图右边的文件编译过程，生成 so 文件和可执行文件，放在硬盘上。下图左边的用户态的进程 A 执行 fork，创建进程 B，在进程 B 的处理逻辑中，执行 exec 系列系统调用。这个系统调用会通过 load_elf_binary 方法，将刚才生成的可执行文件，加载到进程 B 的内存中执行。

课堂练习

对于 ELF，有几个工具能帮你看这些文件的格式。readelf 工具用于分析 ELF 的信息，objdump 工具用来显示二进制文件的信息，hexdump 工具用来查看文件的十六进制编码，nm 工具用来显示关于指定文件中符号的信息。你可以尝试用这几个工具，来解析这一节生成的.o, .so 和可执行文件。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

11 | 线程：如何让复杂的项目并行执行？

上一节我们讲了如何创建进程，这一节我们来看如何创建线程。

为什么要有线程？

其实，对于任何一个进程来讲，即便我们没有主动去创建线程，进程也是默认有一个主线程的。线程是负责执行二进制指令的，它会根据项目执行计划书，一行一行执行下去。进程要比线程管的宽多了，除了执行指令之外，内存、文件系统等等都要它来管。

所以，进程相当于一个项目，而线程就是为了完成项目需求，而建立的一个个开发任务。默认情况下，你可以建一个大的任务，就是完成某某功能，然后交给一个人让它从头做到尾，这就是主线程。但是有时候，你发现任务是可以拆解的，如果相关性没有非常大前后关联关系，就可以并行执行。

例如，你接到了一个开发任务，要开发 200 个页面，最后组成一个网站。这时候你就可以拆分成 20 个任务，每个任务 10 个页面，并行开发。都开发完了，再做一次整合，这肯定比依次开发 200 个页面快多了。

那我们能不能成立多个项目组实现并行开发呢？当然可以了，只不过这样做有两个比较麻烦的地方。

第一个麻烦是，立项。涉及的部门比较多，总是劳师动众。你本来想的是，只要能并行执行任务就可以，不需要把会议室都搞成独立的。另一个麻烦是，项目组是独立的，会议室是独立的，很多事情就不受你控制了，例如一旦有了两个项目组，就会有沟通问题。

所以，使用进程实现并行执行的问题也有两个。第一，创建进程占用资源太多；第二，进程之间的通信需要数据在不同的内存空间传来传去，无法共享。

除了希望任务能够并行执行，有的时候，你作为项目管理人员，肯定要管控风险，因此还会预留一部分人作为应急小分队，来处理紧急的事情。

例如，主线程正在一行一行执行二进制命令，突然收到一个通知，要做一点小事情，应该停下主线程来做么？太耽误事情了，应该创建一个单独的线程，单独处理这些事件。

另外，咱们希望自己的公司越来越有竞争力。要想实现远大的目标，我们不能把所有人力都用在接项目上，应该预留一些人力来做技术积累，比如开发一些各个项目都能用到的共享库、框架等等。

在 Linux 中，有时候我们希望将前台的任务和后台的任务分开。因为有些任务是需要马上返回结果的，例如你输入了一个字符，不可能五分钟再显示出来；而有些任务是可以默默执行的，例如将本机的数据同步到服务器上去，这个就没刚才那么着急。因此这样两个任务就应该在不同的线程处理，以保证互不耽误。

如何创建线程？

看来多线程还是有很多好处的。接下来我们来看一下，如何使用线程来干一件大事。

假如说，现在我们有 N 个非常大的视频需要下载，一个个下载需要的时间太长了。按照刚才的思路，我们可以拆分成 N 个任务，分给 N 个线程各自去下载。

我们知道，进程的执行是需要项目执行计划书的，那线程是一个项目小组，这个小组也应该有自己的项目执行计划书，也就是一个函数。我们将要执行的子任务放在这个函数里面，比如上面的下载任务。

这个函数参数是 void 类型的指针，用于接收任何类型的参数。我们就可以将要下载的文件的文件名通过这个指针传给它。

为了方便，我将代码整段都贴在这里，这样你把下面的代码放在一个文件里面就能成功编译。

当然，这里我们不是真的下载这个文件，而仅仅打印日志，并生成一个一百以内的随机数，作为下载时间返回。这样，每个子任务干活的同时在喊：“我正在下载，终于下载完了，用了多少时间。”

#include <pthread.h>

#include <stdio.h>

#include <stdlib.h>

#define NUM_OF_TASKS 5

void *downloadfile(void *filename)

{

   printf("I am downloading the file %s!\n", (char *)filename);

   sleep(10);

   long downloadtime = rand()%100;

   printf("I finish downloading the file within %d minutes!\n", downloadtime);

   pthread_exit((void *)downloadtime);

}

int main(int argc, char *argv[])

{

   char files[NUM_OF_TASKS][20]={"file1.avi","file2.rmvb","file3.mp4","file4.wmv","file5.flv"};

   pthread_t threads[NUM_OF_TASKS];

   int rc;

   int t;

   int downloadtime;

   pthread_attr_t thread_attr;

   pthread_attr_init(&thread_attr);

   pthread_attr_setdetachstate(&thread_attr,PTHREAD_CREATE_JOINABLE);

   for(t=0;t<NUM_OF_TASKS;t++){

     printf("creating thread %d, please help me to download %s\n", t, files[t]);

     rc = pthread_create(&threads[t], &thread_attr, downloadfile, (void *)files[t]);

     if (rc){

       printf("ERROR; return code from pthread_create() is %d\n", rc);

       exit(-1);

     }

   }

   pthread_attr_destroy(&thread_attr);

   for(t=0;t<NUM_OF_TASKS;t++){

     pthread_join(threads[t],(void**)&downloadtime);

     printf("Thread %d downloads the file %s in %d minutes.\n",t,files[t],downloadtime);

   }

   pthread_exit(NULL);

}

一个运行中的线程可以调用 pthread_exit 退出线程。这个函数可以传入一个参数转换为 (void *) 类型。这是线程退出的返回值。

接下来，我们来看主线程。在这里面，我列了五个文件名。接下来声明了一个数组，里面有五个 pthread_t 类型的线程对象。

接下来，声明一个线程属性 pthread_attr_t。我们通过 pthread_attr_init 初始化这个属性，并且设置属性 PTHREAD_CREATE_JOINABLE。这表示将来主线程程等待这个线程的结束，并获取退出时的状态。

接下来是一个循环。对于每一个文件和每一个线程，可以调用 pthread_create 创建线程。一共有四个参数，第一个参数是线程对象，第二个参数是线程的属性，第三个参数是线程运行函数，第四个参数是线程运行函数的参数。主线程就是通过第四个参数，将自己的任务派给子线程。

任务分配完毕，每个线程下载一个文件，接下来主线程要做的事情就是等待这些子任务完成。当一个线程退出的时候，就会发送信号给其他所有同进程的线程。有一个线程使用 pthread_join 获取这个线程退出的返回值。线程的返回值通过 pthread_join 传给主线程，这样子线程就将自己下载文件所耗费的时间，告诉给主线程。

好了，程序写完了，开始编译。多线程程序要依赖于 libpthread.so。

gcc download.c -lpthread

编译好了，执行一下，就能得到下面的结果。

# ./a.out

creating thread 0, please help me to download file1.avi

creating thread 1, please help me to download file2.rmvb

I am downloading the file file1.avi!

creating thread 2, please help me to download file3.mp4

I am downloading the file file2.rmvb!

creating thread 3, please help me to download file4.wmv

I am downloading the file file3.mp4!

creating thread 4, please help me to download file5.flv

I am downloading the file file4.wmv!

I am downloading the file file5.flv!

I finish downloading the file within 83 minutes!

I finish downloading the file within 77 minutes!

I finish downloading the file within 86 minutes!

I finish downloading the file within 15 minutes!

I finish downloading the file within 93 minutes!

Thread 0 downloads the file file1.avi in 83 minutes.

Thread 1 downloads the file file2.rmvb in 86 minutes.

Thread 2 downloads the file file3.mp4 in 77 minutes.

Thread 3 downloads the file file4.wmv in 93 minutes.

Thread 4 downloads the file file5.flv in 15 minutes.

这里我们画一张图总结一下，一个普通线程的创建和运行过程。

线程的数据

线程可以将项目并行起来，加快进度，但是也带来的负面影响，过程并行起来了，那数据呢？

我们把线程访问的数据细分成三类。下面我们一一来看。

第一类是线程栈上的本地数据，比如函数执行过程中的局部变量。前面我们说过，函数的调用会使用栈的模型，这在线程里面是一样的。只不过每个线程都有自己的栈空间。

栈的大小可以通过命令 ulimit -a 查看，默认情况下线程栈大小为 8192（8MB）。我们可以使用命令 ulimit -s 修改。

对于线程栈，可以通过下面这个函数 pthread_attr_t，修改线程栈的大小。

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);

主线程在内存中有一个栈空间，其他线程栈也拥有独立的栈空间。为了避免线程之间的栈空间踩踏，线程栈之间还会有小块区域，用来隔离保护各自的栈空间。一旦另一个线程踏入到这个隔离区，就会引发段错误。

第二类数据就是在整个进程里共享的全局数据。例如全局变量，虽然在不同进程中是隔离的，但是在一个进程中是共享的。如果同一个全局变量，两个线程一起修改，那肯定会有问题，有可能把数据改的面目全非。这就需要有一种机制来保护他们，比如你先用我再用。这一节的最后，我们专门来谈这个问题。

那线程能不能像进程一样，也有自己的私有数据呢？如果想声明一个线程级别，而非进程级别的全局变量，有没有什么办法呢？虽然咱们都是一个大组，分成小组，也应该有点隐私。

这就是第三类数据，线程私有数据（Thread Specific Data），可以通过以下函数创建：

int pthread_key_create(pthread_key_t *key, void (*destructor)(void*))

可以看到，创建一个 key，伴随着一个析构函数。

key 一旦被创建，所有线程都可以访问它，但各线程可根据自己的需要往 key 中填入不同的值，这就相当于提供了一个同名而不同值的全局变量。

我们可以通过下面的函数设置 key 对应的 value。

int pthread_setspecific(pthread_key_t key, const void *value)

我们还可以通过下面的函数获取 key 对应的 value。

void *pthread_getspecific(pthread_key_t key)

而等到线程退出的时候，就会调用析构函数释放 value。

数据的保护

接下来，我们来看共享的数据保护问题。

我们先来看一种方式，Mutex，全称 Mutual Exclusion，中文叫互斥。顾名思义，有你没我，有我没你。它的模式就是在共享数据访问的时候，去申请加把锁，谁先拿到锁，谁就拿到了访问权限，其他人就只好在门外等着，等这个人访问结束，把锁打开，其他人再去争夺，还是遵循谁先拿到谁访问。

我这里构建了一个“转账”的场景。相关的代码我放到这里，你可以看看。

#include <pthread.h>

#include <stdio.h>

#include <stdlib.h>

#define NUM_OF_TASKS 5

int money_of_tom = 100;

int money_of_jerry = 100;

// 第一次运行去掉下面这行

pthread_mutex_t g_money_lock;

void *transfer(void *notused)

{

  pthread_t tid = pthread_self();

  printf("Thread %u is transfering money!\n", (unsigned int)tid);

  // 第一次运行去掉下面这行

  pthread_mutex_lock(&g_money_lock);

  sleep(rand()%10);

  money_of_tom+=10;

  sleep(rand()%10);

  money_of_jerry-=10;

  // 第一次运行去掉下面这行

  pthread_mutex_unlock(&g_money_lock);

  printf("Thread %u finish transfering money!\n", (unsigned int)tid);

  pthread_exit((void *)0);

}

int main(int argc, char *argv[])

{

  pthread_t threads[NUM_OF_TASKS];

  int rc;

  int t;

  // 第一次运行去掉下面这行

  pthread_mutex_init(&g_money_lock, NULL);

  for(t=0;t<NUM_OF_TASKS;t++){

    rc = pthread_create(&threads[t], NULL, transfer, NULL);

    if (rc){

      printf("ERROR; return code from pthread_create() is %d\n", rc);

      exit(-1);

    }

  }

  for(t=0;t<100;t++){

    // 第一次运行去掉下面这行

    pthread_mutex_lock(&g_money_lock);

    printf("money_of_tom + money_of_jerry = %d\n", money_of_tom + money_of_jerry);

    // 第一次运行去掉下面这行

    pthread_mutex_unlock(&g_money_lock);

  }

  // 第一次运行去掉下面这行

  pthread_mutex_destroy(&g_money_lock);

  pthread_exit(NULL);

}

这里说，有两个员工 Tom 和 Jerry，公司食堂的饭卡里面各自有 100 元，并行启动 5 个线程，都是 Jerry 转 10 元给 Tom，主线程不断打印 Tom 和 Jerry 的资金之和。按说，这样的话，总和应该永远是 200 元。

在上面的程序中，我们先去掉 mutex 相关的行，就像注释里面写的那样。在没有锁的保护下，在 Tom 的账户里面加上 10 元，在 Jerry 的账户里面减去 10 元，这不是一个原子操作。

我们来编译一下。

gcc mutex.c -lpthread

然后运行一下，就看到了下面这样的结果。

[root@deployer createthread]# ./a.out

Thread 508479232 is transfering money!

Thread 491693824 is transfering money!

Thread 500086528 is transfering money!

Thread 483301120 is transfering money!

Thread 516871936 is transfering money!

money_of_tom + money_of_jerry = 200

money_of_tom + money_of_jerry = 200

money_of_tom + money_of_jerry = 220

money_of_tom + money_of_jerry = 220

money_of_tom + money_of_jerry = 230

money_of_tom + money_of_jerry = 240

Thread 483301120 finish transfering money!

money_of_tom + money_of_jerry = 240

Thread 508479232 finish transfering money!

Thread 500086528 finish transfering money!

money_of_tom + money_of_jerry = 220

Thread 516871936 finish transfering money!

money_of_tom + money_of_jerry = 210

money_of_tom + money_of_jerry = 210

Thread 491693824 finish transfering money!

money_of_tom + money_of_jerry = 200

money_of_tom + money_of_jerry = 200

可以看到，中间有很多状态不正确，比如两个人的账户之和出现了超过 200 的情况，也就是 Tom 转入了，Jerry 还没转出。

接下来我们在上面的代码里面，加上 mutex，然后编译、运行，就得到了下面的结果。

[root@deployer createthread]# ./a.out

Thread 568162048 is transfering money!

Thread 576554752 is transfering money!

Thread 551376640 is transfering money!

Thread 542983936 is transfering money!

Thread 559769344 is transfering money!

Thread 568162048 finish transfering money!

Thread 576554752 finish transfering money!

money_of_tom + money_of_jerry = 200

money_of_tom + money_of_jerry = 200

money_of_tom + money_of_jerry = 200

Thread 542983936 finish transfering money!

Thread 559769344 finish transfering money!

money_of_tom + money_of_jerry = 200

money_of_tom + money_of_jerry = 200

Thread 551376640 finish transfering money!

money_of_tom + money_of_jerry = 200

money_of_tom + money_of_jerry = 200

money_of_tom + money_of_jerry = 200

money_of_tom + money_of_jerry = 200

这个结果就正常了。两个账号之和永远是 200。这下你看到锁的作用了吧？

使用 Mutex，首先要使用 pthread_mutex_init 函数初始化这个 mutex，初始化后，就可以用它来保护共享变量了。

pthread_mutex_lock() 就是去抢那把锁的函数，如果抢到了，就可以执行下一行程序，对共享变量进行访；如果没抢到，就被阻塞在那里等待。

如果不想被阻塞，可以使用 pthread_mutex_trylock 去抢那把锁，如果抢到了，就可以执行下一行程序，对共享变量进行访问；如果没抢到，不会被阻塞，而是返回一个错误码。

当共享数据访问结束了，别忘了使用 pthread_mutex_unlock 释放锁，让给其他人使用，最终调用 pthread_mutex_destroy 销毁掉这把锁。

这里我画个图，总结一下 Mutex 的使用流程。

在使用 Mutex 的时候，有个问题是如果使用 pthread_mutex_lock()，那就需要一直在那里等着。如果是 pthread_mutex_trylock()，就可以不用等着，去干点儿别的，但是我怎么知道什么时候回来再试一下，是不是轮到我了呢？能不能在轮到我的时候，通知我一下呢？

这其实就是条件变量，也就是说如果没事儿，就让大家歇着，有事儿了就去通知，别让人家没事儿就来问问，浪费大家的时间。

但是当它接到了通知，来操作共享资源的时候，还是需要抢互斥锁，因为可能很多人都受到了通知，都来访问了，所以条件变量和互斥锁是配合使用的。

我这里还是用一个场景给你解释。

你这个老板，招聘了三个员工，但是你不是有了活才去招聘员工，而是先把员工招来，没有活的时候员工需要在那里等着，一旦有了活，你要去通知他们，他们要去抢活干（为啥要抢活？因为有绩效呀！），干完了再等待，你再有活，再通知他们。

具体的样例代码我也放在这里。你可以直接编译运行。

#include <pthread.h>

#include <stdio.h>

#include <stdlib.h>

#define NUM_OF_TASKS 3

#define MAX_TASK_QUEUE 11

char tasklist[MAX_TASK_QUEUE]="ABCDEFGHIJ";

int head = 0;

int tail = 0;

int quit = 0;

pthread_mutex_t g_task_lock;

pthread_cond_t g_task_cv;

void *coder(void *notused)

{

  pthread_t tid = pthread_self();

  while(!quit){

    pthread_mutex_lock(&g_task_lock);

    while(tail == head){

      if(quit){

        pthread_mutex_unlock(&g_task_lock);

        pthread_exit((void *)0);

      }

      printf("No task now! Thread %u is waiting!\n", (unsigned int)tid);

      pthread_cond_wait(&g_task_cv, &g_task_lock);

      printf("Have task now! Thread %u is grabing the task !\n", (unsigned int)tid);

    }

    char task = tasklist[head++];

    pthread_mutex_unlock(&g_task_lock);

    printf("Thread %u has a task %c now!\n", (unsigned int)tid, task);

    sleep(5);

    printf("Thread %u finish the task %c!\n", (unsigned int)tid, task);

  }

  pthread_exit((void *)0);

}

int main(int argc, char *argv[])

{

  pthread_t threads[NUM_OF_TASKS];

  int rc;

  int t;

  pthread_mutex_init(&g_task_lock, NULL);

  pthread_cond_init(&g_task_cv, NULL);

  for(t=0;t<NUM_OF_TASKS;t++){

    rc = pthread_create(&threads[t], NULL, coder, NULL);

    if (rc){

      printf("ERROR; return code from pthread_create() is %d\n", rc);

      exit(-1);

    }

  }

  sleep(5);

  for(t=1;t<=4;t++){

    pthread_mutex_lock(&g_task_lock);

    tail+=t;

    printf("I am Boss, I assigned %d tasks, I notify all coders!\n", t);

    pthread_cond_broadcast(&g_task_cv);

    pthread_mutex_unlock(&g_task_lock);

    sleep(20);

  }

  pthread_mutex_lock(&g_task_lock);

  quit = 1;

  pthread_cond_broadcast(&g_task_cv);

  pthread_mutex_unlock(&g_task_lock);

  pthread_mutex_destroy(&g_task_lock);

  pthread_cond_destroy(&g_task_cv);

  pthread_exit(NULL);

}

首先，我们创建了 10 个任务，每个任务一个字符，放在一个数组里面，另外有两个变量 head 和 tail，表示当前分配的工作从哪里开始，到哪里结束。如果 head 等于 tail，则当前的工作分配完毕；如果 tail 加 N，就是新分配了 N 个工作。

接下来声明的 pthread_mutex_t g_task_lock 和 pthread_cond_t g_task_cv，是用于通知和抢任务的，工作模式如下图所示：

图中左边的就是员工的工作模式，对于每一个员工 coder，先要获取锁 pthread_mutex_lock，这样才能保证一个任务只分配给一个员工。

然后，我们要判断有没有任务，也就是说，head 和 tail 是否相等。如果不相等的话，就是有任务，则取出 head 位置代表的任务 task，然后将 head 加一，这样整个任务就给了这个员工，下个员工来抢活的时候，也需要获取锁，获取之后抢到的就是下一个任务了。当这个员工抢到任务后，pthread_mutex_unlock 解锁，让其他员工可以进来抢任务。抢到任务后就开始干活了，这里没有真正开始干活，而是 sleep，也就是摸鱼了 5 秒。

如果发现 head 和 tail 相当，也就是没有任务，则需要调用 pthread_cond_wait 进行等待，这个函数会把锁也作为变量传进去。这是因为等待的过程中需要解锁，要不然，你不干活，等待睡大觉，还把门给锁了，别人也干不了活，而且老板也没办法获取锁来分配任务。

一开始三个员工都是在等待的状态，因为初始化的时候，head 和 tail 相等都为零。

现在我们把目光聚焦到老板这里，也就是主线程上。它初始化了条件变量和锁，然后创建三个线程，也就是我们说的招聘了三个员工。

接下来要开始分配任务了，总共 10 个任务。老板分四批分配，第一批一个任务三个人抢，第二批两个任务，第三批三个任务，正好每人抢到一个，第四批四个任务，可能有一个员工抢到两个任务。这样三个员工，四批工作，经典的场景差不多都覆盖到了。

老板分配工作的时候，也是要先获取锁 pthread_mutex_lock，然后通过 tail 加一来分配任务，这个时候 head 和 tail 已经不一样了，但是这个时候三个员工还在 pthread_cond_wait 那里睡着呢，接下来老板要调用 pthread_cond_broadcast 通知所有的员工，来活了，醒醒，起来干活。

这个时候三个员工醒来后，先抢锁，生怕老板只分配了一个任务，让别人抢去。当然抢锁这个动作是 pthread_cond_wait 在收到通知的时候，自动做的，不需要我们另外写代码。

抢到锁的员工就通过 while 再次判断 head 和 tail 是否相同。这次因为有了任务，不相同了，所以就抢到了任务。而没有抢到任务的员工，由于抢锁失败，只好等待抢到任务的员工释放锁，抢到任务的员工在 tasklist 里面拿到任务后，将 head 加一，然后就释放锁。这个时候，另外两个员工才能从 pthread_cond_wait 中返回，然后也会再次通过 while 判断 head 和 tail 是否相同。不过已经晚了，任务都让人家抢走了，head 和 tail 又一样了，所以只好再次进入 pthread_cond_wait，接着等任务。

这里，我们只解析了第一批一个任务的工作的过程。如果运行上面的程序，可以得到下面的结果。我将整个过程在里面写了注释，你看起来就比较容易理解了。

[root@deployer createthread]# ./a.out

// 招聘三个员工，一开始没有任务，大家睡大觉

No task now! Thread 3491833600 is waiting!

No task now! Thread 3483440896 is waiting!

No task now! Thread 3475048192 is waiting!

// 老板开始分配任务了，第一批任务就一个，告诉三个员工醒来抢任务

I am Boss, I assigned 1 tasks, I notify all coders!

// 员工一先发现有任务了，开始抢任务

Have task now! Thread 3491833600 is grabing the task !

// 员工一抢到了任务 A，开始干活

Thread 3491833600 has a task A now!

// 员工二也发现有任务了，开始抢任务，不好意思，就一个任务，让人家抢走了，接着等吧

Have task now! Thread 3483440896 is grabing the task !

No task now! Thread 3483440896 is waiting!

// 员工三也发现有任务了，开始抢任务，你比员工二还慢，接着等吧

Have task now! Thread 3475048192 is grabing the task !

No task now! Thread 3475048192 is waiting!

// 员工一把任务做完了，又没有任务了，接着等待

Thread 3491833600 finish the task A !

No task now! Thread 3491833600 is waiting!

// 老板又有新任务了，这次是两个任务，叫醒他们

I am Boss, I assigned 2 tasks, I notify all coders!

// 这次员工二比较积极，先开始抢，并且抢到了任务 B

Have task now! Thread 3483440896 is grabing the task !

Thread 3483440896 has a task B now!

// 这次员工三也聪明了，赶紧抢，要不然没有年终奖了，终于抢到了任务 C

Have task now! Thread 3475048192 is grabing the task !

Thread 3475048192 has a task C now!

// 员工一上次抢到了，这次抢的慢了，没有抢到，是不是飘了

Have task now! Thread 3491833600 is grabing the task !

No task now! Thread 3491833600 is waiting!

// 员工二做完了任务 B，没有任务了，接着等待

Thread 3483440896 finish the task B !

No task now! Thread 3483440896 is waiting!

// 员工三做完了任务 C，没有任务了，接着等待

Thread 3475048192 finish the task C !

No task now! Thread 3475048192 is waiting!

// 又来任务了，这次是三个任务，人人有份

I am Boss, I assigned 3 tasks, I notify all coders!

// 员工一抢到了任务 D，员工二抢到了任务 E，员工三抢到了任务 F

Have task now! Thread 3491833600 is grabing the task !

Thread 3491833600 has a task D now!

Have task now! Thread 3483440896 is grabing the task !

Thread 3483440896 has a task E now!

Have task now! Thread 3475048192 is grabing the task !

Thread 3475048192 has a task F now!

// 三个员工都完成了，然后都又开始等待

Thread 3491833600 finish the task D !

Thread 3483440896 finish the task E !

Thread 3475048192 finish the task F !

No task now! Thread 3491833600 is waiting!

No task now! Thread 3483440896 is waiting!

No task now! Thread 3475048192 is waiting!

// 公司活越来越多了，来了四个任务，赶紧干呀

I am Boss, I assigned 4 tasks, I notify all coders!

// 员工一抢到了任务 G，员工二抢到了任务 H，员工三抢到了任务 I

Have task now! Thread 3491833600 is grabing the task !

Thread 3491833600 has a task G now!

Have task now! Thread 3483440896 is grabing the task !

Thread 3483440896 has a task H now!

Have task now! Thread 3475048192 is grabing the task !

Thread 3475048192 has a task I now!

// 员工一和员工三先做完了，发现还有一个任务开始抢

Thread 3491833600 finish the task G !

Thread 3475048192 finish the task I !

// 员工三没抢到，接着等

No task now! Thread 3475048192 is waiting!

// 员工一抢到了任务 J，多做了一个任务

Thread 3491833600 has a task J now!

// 员工二这才把任务 H 做完，黄花菜都凉了，接着等待吧

Thread 3483440896 finish the task H !

No task now! Thread 3483440896 is waiting!

// 员工一做完了任务 J，接着等待

Thread 3491833600 finish the task J !

No task now! Thread 3491833600 is waiting!

总结时刻

这一节，我们讲了如何创建线程，线程都有哪些数据，如何对线程数据进行保护。

写多线程的程序是有套路的，我这里用一张图进行总结。你需要记住的是，创建线程的套路、mutex 使用的套路、条件变量使用的套路。

课堂练习

这一节讲了多线程编程的套路，但是我没有对于每一个函数进行详细的讲解，相关的还有很多其他的函数可以调用，这需要你自己去学习。这里我给你推荐一本书 ProgrammingwithPOSIXThreadsProgrammingwithPOSIXThreads，你可以系统地学习一下。另外，上面的代码，建议你一定要上手编译运行一下。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

12 | 进程数据结构（上）：项目多了就需要项目管理系统

前面两节，我们讲了如何使用系统调用，创建进程和线程。你是不是觉得进程和线程管理，还挺复杂的呢？如此复杂的体系，在内核里面应该如何管理呢？

有的进程只有一个线程，有的进程有多个线程，它们都需要由内核分配 CPU 来干活。可是 CPU 总共就这么几个，应该怎么管理，怎么调度呢？你是老板，这个事儿得你来操心。

首先，我们得明确，公司的项目售前售后人员，接来了这么多的项目，这是个好事儿。这些项目都通过办事大厅立了项的，有的需要整个项目组一起开发，有的是一个项目组分成多个小组并行开发。无论哪种模式，到你这个老板这里，都需要有一个项目管理体系，进行统一排期、统一管理和统一协调。这样，你才能对公司的业务了如指掌。

那具体应该怎么做呢？还记得咱们平时开发的时候，用的项目管理软件 Jira 吧？它的办法对我们来讲，就很有参考意义。

我们这么来看，其实，无论是一个大的项目组一起完成一个大的功能（单体应用模式），还是把一个大的功能拆成小的功能并行开发（微服务模式），这些都是开发组根据客户的需求来定的，项目经理没办法决定，但是从项目经理的角度来看，这些都是任务，需要同样关注进度、协调资源等等。

同样在 Linux 里面，无论是进程，还是线程，到了内核里面，我们统一都叫任务（Task），由一个统一的结构task_struct进行管理。这个结构非常复杂，但你也不用怕，我们慢慢来解析。

接下来，我们沿着建立项目管理体系的思路，设想一下，Linux 的任务管理都应该干些啥？

首先，所有执行的项目应该有个项目列表吧，所以 Linux 内核也应该先弄一个链表，将所有的 task_struct 串起来。

struct list_head		tasks;

接下来，我们来看每一个任务都应该包含哪些字段。

任务 ID

每一个任务都应该有一个 ID，作为这个任务的唯一标识。到时候排期啊、下发任务啊等等，都按 ID 来，就不会产生歧义。

task_struct 里面涉及任务 ID 的，有下面几个：

pid_t pid;

pid_t tgid;

struct task_struct *group_leader;

你可能觉得奇怪，既然是 ID，有一个就足以做唯一标识了，这个怎么看起来这么麻烦？这是因为，上面的进程和线程到了内核这里，统一变成了任务，这就带来两个问题。

第一个问题是，任务展示。

啥是任务展示呢？这么说吧，你作为老板，想了解的肯定是，公司都接了哪些项目，每个项目多少营收。什么项目执行是不是分了小组，每个小组是啥情况，这些细节，项目经理没必要全都展示给你看。

前面我们学习命令行的时候，知道 ps 命令可以展示出所有的进程。但是如果你是这个命令的实现者，到了内核，按照上面的任务列表把这些命令都显示出来，把所有的线程全都平摊开来显示给用户。用户肯定觉得既复杂又困惑。复杂在于，列表这么长；困惑在于，里面出现了很多并不是自己创建的线程。

第二个问题是，给任务下发指令。

如果客户突然给项目组提个新的需求，比如说，有的客户觉得项目已经完成，可以终止；再比如说，有的客户觉得项目做到一半没必要再进行下去了，可以中止，这时候应该给谁发指令？当然应该给整个项目组，而不是某个小组。我们不能让客户看到，不同的小组口径不一致。这就好比说，中止项目的指令到达一个小组，这个小组很开心就去休息了，同一个项目组的其他小组还干的热火朝天的。

Linux 也一样，前面我们学习命令行的时候，知道可以通过 kill 来给进程发信号，通知进程退出。如果发给了其中一个线程，我们就不能只退出这个线程，而是应该退出整个进程。当然，有时候，我们希望只给某个线程发信号。

所以在内核中，它们虽然都是任务，但是应该加以区分。其中，pid 是 process id，tgid 是 thread group ID。

任何一个进程，如果只有主线程，那 pid 是自己，tgid 是自己，group_leader 指向的还是自己。

但是，如果一个进程创建了其他线程，那就会有所变化了。线程有自己的 pid，tgid 就是进程的主线程的 pid，group_leader 指向的就是进程的主线程。

好了，有了 tgid，我们就知道 tast_struct 代表的是一个进程还是代表一个线程了。

信号处理

这里既然提到了下发指令的问题，我就顺便提一下 task_struct 里面关于信号处理的字段。

/* Signal handlers: */

struct signal_struct		*signal;

struct sighand_struct		*sighand;

sigset_t			blocked;

sigset_t			real_blocked;

sigset_t			saved_sigmask;

struct sigpending		pending;

unsigned long			sas_ss_sp;

size_t				sas_ss_size;

unsigned int			sas_ss_flags;

这里定义了哪些信号被阻塞暂不处理（blocked），哪些信号尚等待处理（pending），哪些信号正在通过信号处理函数进行处理（sighand）。处理的结果可以是忽略，可以是结束进程等等。

信号处理函数默认使用用户态的函数栈，当然也可以开辟新的栈专门用于信号处理，这就是 sas_ss_xxx 这三个变量的作用。

上面我说了下发信号的时候，需要区分进程和线程。从这里我们其实也能看出一些端倪。

task_struct 里面有一个 struct sigpending pending。如果我们进入 struct signal_struct *signal 去看的话，还有一个 struct sigpending shared_pending。它们一个是本任务的，一个是线程组共享的。

关于信号，你暂时了解到这里就够用了，后面我们会有单独的章节进行解读。

任务状态

作为一个项目经理，另外一个需要关注的是项目当前的状态。例如，在 Jira 里面，任务的运行就可以分成下面的状态。

在 task_struct 里面，涉及任务状态的是下面这几个变量：

 volatile long state;    /* -1 unrunnable, 0 runnable, >0 stopped */

 int exit_state;

 unsigned int flags;

state（状态）可以取的值定义在 include/linux/sched.h 头文件中。

/* Used in tsk->state: */

#define TASK_RUNNING                    0

#define TASK_INTERRUPTIBLE              1

#define TASK_UNINTERRUPTIBLE            2

#define __TASK_STOPPED                  4

#define __TASK_TRACED                   8

/* Used in tsk->exit_state: */

#define EXIT_DEAD                       16

#define EXIT_ZOMBIE                     32

#define EXIT_TRACE                      (EXIT_ZOMBIE | EXIT_DEAD)

/* Used in tsk->state again: */

#define TASK_DEAD                       64

#define TASK_WAKEKILL                   128

#define TASK_WAKING                     256

#define TASK_PARKED                     512

#define TASK_NOLOAD                     1024

#define TASK_NEW                        2048

#define TASK_STATE_MAX                  4096

从定义的数值很容易看出来，flags 是通过 bitset 的方式设置的也就是说，当前是什么状态，哪一位就置一。

TASK_RUNNING 并不是说进程正在运行，而是表示进程在时刻准备运行的状态。当处于这个状态的进程获得时间片的时候，就是在运行中；如果没有获得时间片，就说明它被其他进程抢占了，在等待再次分配时间片。

在运行中的进程，一旦要进行一些 I/O 操作，需要等待 I/O 完毕，这个时候会释放 CPU，进入睡眠状态。

在 Linux 中，有两种睡眠状态。

一种是TASK_INTERRUPTIBLE，可中断的睡眠状态。这是一种浅睡眠的状态，也就是说，虽然在睡眠，等待 I/O 完成，但是这个时候一个信号来的时候，进程还是要被唤醒。只不过唤醒后，不是继续刚才的操作，而是进行信号处理。当然程序员可以根据自己的意愿，来写信号处理函数，例如收到某些信号，就放弃等待这个 I/O 操作完成，直接退出，也可也收到某些信息，继续等待。

另一种睡眠是TASK_UNINTERRUPTIBLE，不可中断的睡眠状态。这是一种深度睡眠状态，不可被信号唤醒，只能死等 I/O 操作完成。一旦 I/O 操作因为特殊原因不能完成，这个时候，谁也叫不醒这个进程了。你可能会说，我 kill 它呢？别忘了，kill 本身也是一个信号，既然这个状态不可被信号唤醒，kill 信号也被忽略了。除非重启电脑，没有其他办法。

因此，这其实是一个比较危险的事情，除非程序员极其有把握，不然还是不要设置成 TASK_UNINTERRUPTIBLE。

于是，我们就有了一种新的进程睡眠状态，TASK_KILLABLE，可以终止的新睡眠状态。进程处于这种状态中，它的运行原理类似 TASK_UNINTERRUPTIBLE，只不过可以响应致命信号。

从定义可以看出，TASK_WAKEKILL 用于在接收到致命信号时唤醒进程，而 TASK_KILLABLE 相当于这两位都设置了。

#define TASK_KILLABLE           (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)

TASK_STOPPED 是在进程接收到 SIGSTOP、SIGTTIN、SIGTSTP 或者 SIGTTOU 信号之后进入该状态。

TASK_TRACED 表示进程被 debugger 等进程监视，进程执行被调试程序所停止。当一个进程被另外的进程所监视，每一个信号都会让进程进入该状态。

一旦一个进程要结束，先进入的是 EXIT_ZOMBIE 状态，但是这个时候它的父进程还没有使用 wait() 等系统调用来获知它的终止信息，此时进程就成了僵尸进程。

EXIT_DEAD 是进程的最终状态。

EXIT_ZOMBIE 和 EXIT_DEAD 也可以用于 exit_state。

上面的进程状态和进程的运行、调度有关系，还有其他的一些状态，我们称为标志。放在 flags 字段中，这些字段都被定义称为宏，以 PF 开头。我这里举几个例子。

#define PF_EXITING		0x00000004

#define PF_VCPU			0x00000010

#define PF_FORKNOEXEC		0x00000040

PF_EXITING表示正在退出。当有这个 flag 的时候，在函数 find_alive_thread 中，找活着的线程，遇到有这个 flag 的，就直接跳过。

PF_VCPU表示进程运行在虚拟 CPU 上。在函数 account_system_time 中，统计进程的系统运行时间，如果有这个 flag，就调用 account_guest_time，按照客户机的时间进行统计。

PF_FORKNOEXEC表示 fork 完了，还没有 exec。在 _do_fork 函数里面调用 copy_process，这个时候把 flag 设置为 PF_FORKNOEXEC。当 exec 中调用了 load_elf_binary 的时候，又把这个 flag 去掉。

进程调度

进程的状态切换往往涉及调度，下面这些字段都是用于调度的。为了让你理解 task_struct 进程管理的全貌，我先在这里列一下，咱们后面会有单独的章节讲解，这里你只要大概看一下里面的注释就好了。

// 是否在运行队列上

int				on_rq;

// 优先级

int				prio;

int				static_prio;

int				normal_prio;

unsigned int			rt_priority;

// 调度器类

const struct sched_class	*sched_class;

// 调度实体

struct sched_entity		se;

struct sched_rt_entity		rt;

struct sched_dl_entity		dl;

// 调度策略

unsigned int			policy;

// 可以使用哪些 CPU

int				nr_cpus_allowed;

cpumask_t			cpus_allowed;

struct sched_info		sched_info;

总结时刻

这一节，我们讲述了进程管理复杂的数据结构，我还是画一个图总结一下。这个图是进程管理 task_struct 的的结构图。其中红色的部分是今天讲的部分，你可以对着这张图说出它们的含义。

课堂练习

这一节我们讲了任务的状态，你可以试着在代码里面搜索一下这些状态改变的地方是哪个函数，是什么时机，从而进一步理解任务的概念。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

13 | 进程数据结构（中）：项目多了就需要项目管理系统

上一节我们讲了，task_struct 这个结构非常长。由此我们可以看出，Linux 内核的任务管理是非常复杂的。上一节，我们只是讲了一部分，今天我们接着来解析剩下的部分。

运行统计信息

作为项目经理，你肯定需要了解项目的运行情况。例如，有的员工很长时间都在做一个任务，这个时候你就需要特别关注一下；再如，有的员工的琐碎任务太多，这会大大影响他的工作效率。

那如何才能知道这些员工的工作情况呢？在进程的运行过程中，会有一些统计量，具体你可以看下面的列表。这里面有进程在用户态和内核态消耗的时间、上下文切换的次数等等。

u64				utime;// 用户态消耗的 CPU 时间

u64				stime;// 内核态消耗的 CPU 时间

unsigned long			nvcsw;// 自愿 (voluntary) 上下文切换计数

unsigned long			nivcsw;// 非自愿 (involuntary) 上下文切换计数

u64				start_time;// 进程启动时间，不包含睡眠时间

u64				real_start_time;// 进程启动时间，包含睡眠时间

进程亲缘关系

从我们之前讲的创建进程的过程，可以看出，任何一个进程都有父进程。所以，整个进程其实就是一棵进程树。而拥有同一父进程的所有进程都具有兄弟关系。

struct task_struct __rcu *real_parent; /* real parent process */

struct task_struct __rcu *parent; /* recipient of SIGCHLD, wait4() reports */

struct list_head children;      /* list of my children */

struct list_head sibling;       /* linkage in my parent's children list */

parent 指向其父进程。当它终止时，必须向它的父进程发送信号。

children 表示链表的头部。链表中的所有元素都是它的子进程。

sibling 用于把当前进程插入到兄弟链表中。

通常情况下，real_parent 和 parent 是一样的，但是也会有另外的情况存在。例如，bash 创建一个进程，那进程的 parent 和 real_parent 就都是 bash。如果在 bash 上使用 GDB 来 debug 一个进程，这个时候 GDB 是 real_parent，bash 是这个进程的 parent。

进程权限

了解了运行统计信息，接下来，我们需要关注一下项目组权限的控制。什么是项目组权限控制呢？这么说吧，我这个项目组能否访问某个文件，能否访问其他的项目组，以及我这个项目组能否被其他项目组访问等等，这都是项目组权限的控制范畴。

在 Linux 里面，对于进程权限的定义如下：

/* Objective and real subjective task credentials (COW): */

const struct cred __rcu         *real_cred;

/* Effective (overridable) subjective task credentials (COW): */

const struct cred __rcu         *cred;

这个结构的注释里，有两个名词比较拗口，Objective 和 Subjective。事实上，所谓的权限，就是我能操纵谁，谁能操纵我。

“谁能操作我”，很显然，这个时候我就是被操作的对象，就是 Objective，那个想操作我的就是 Subjective。“我能操作谁”，这个时候我就是 Subjective，那个要被我操作的就是 Objectvie。

“操作”，就是一个对象对另一个对象进行某些动作。当动作要实施的时候，就要审核权限，当两边的权限匹配上了，就可以实施操作。其中，real_cred 就是说明谁能操作我这个进程，而 cred 就是说明我这个进程能够操作谁。

这里 cred 的定义如下：

struct cred {

......

        kuid_t          uid;            /* real UID of the task */

        kgid_t          gid;            /* real GID of the task */

        kuid_t          suid;           /* saved UID of the task */

        kgid_t          sgid;           /* saved GID of the task */

        kuid_t          euid;           /* effective UID of the task */

        kgid_t          egid;           /* effective GID of the task */

        kuid_t          fsuid;          /* UID for VFS ops */

        kgid_t          fsgid;          /* GID for VFS ops */

......

        kernel_cap_t    cap_inheritable; /* caps our children can inherit */

        kernel_cap_t    cap_permitted;  /* caps we're permitted */

        kernel_cap_t    cap_effective;  /* caps we can actually use */

        kernel_cap_t    cap_bset;       /* capability bounding set */

        kernel_cap_t    cap_ambient;    /* Ambient capability set */

......

} __randomize_layout;

从这里的定义可以看出，大部分是关于用户和用户所属的用户组信息。

第一个是 uid 和 gid，注释是 real user/group id。一般情况下，谁启动的进程，就是谁的 ID。但是权限审核的时候，往往不比较这两个，也就是说不大起作用。

第二个是 euid 和 egid，注释是 effective user/group id。一看这个名字，就知道这个是起“作用”的。当这个进程要操作消息队列、共享内存、信号量等对象的时候，其实就是在比较这个用户和组是否有权限。

第三个是 fsuid 和 fsgid，也就是 filesystem user/group id。这个是对文件操作会审核的权限。

一般说来，fsuid、euid，和 uid 是一样的，fsgid、egid，和 gid 也是一样的。因为谁启动的进程，就应该审核启动的用户到底有没有这个权限。

但是也有特殊的情况。

例如，用户 A 想玩一个游戏，这个游戏的程序是用户 B 安装的。游戏这个程序文件的权限为 rwxr–r--。A 是没有权限运行这个程序的，因而用户 B 要给用户 A 权限才行。用户 B 说没问题，都是朋友嘛，于是用户 B 就给这个程序设定了所有的用户都能执行的权限 rwxr-xr-x，说兄弟你玩吧。

于是，用户 A 就获得了运行这个游戏的权限。当游戏运行起来之后，游戏进程的 uid、euid、fsuid 都是用户 A。看起来没有问题，玩的很开心。

用户 A 好不容易通过一关，想保存通关数据的时候，发现坏了，这个游戏的玩家数据是保存在另一个文件里面的。这个文件权限 rw-------，只给用户 B 开了写入权限，而游戏进程的 euid 和 fsuid 都是用户 A，当然写不进去了。完了，这一局白玩儿了。

那怎么解决这个问题呢？我们可以通过 chmod u+s program 命令，给这个游戏程序设置 set-user-ID 的标识位，把游戏的权限变成 rwsr-xr-x。这个时候，用户 A 再启动这个游戏的时候，创建的进程 uid 当然还是用户 A，但是 euid 和 fsuid 就不是用户 A 了，因为看到了 set-user-id 标识，就改为文件的所有者的 ID，也就是说，euid 和 fsuid 都改成用户 B 了，这样就能够将通关结果保存下来。

在 Linux 里面，一个进程可以随时通过 setuid 设置用户 ID，所以，游戏程序的用户 B 的 ID 还会保存在一个地方，这就是 suid 和 sgid，也就是 saved uid 和 save gid。这样就可以很方便地使用 setuid，通过设置 uid 或者 suid 来改变权限。

除了以用户和用户组控制权限，Linux 还有另一个机制就是capabilities。

原来控制进程的权限，要么是高权限的 root 用户，要么是一般权限的普通用户，这时候的问题是，root 用户权限太大，而普通用户权限太小。有时候一个普通用户想做一点高权限的事情，必须给他整个 root 的权限。这个太不安全了。

于是，我们引入新的机制 capabilities，用位图表示权限，在 capability.h 可以找到定义的权限。我这里列举几个。

#define CAP_CHOWN            0

#define CAP_KILL             5

#define CAP_NET_BIND_SERVICE 10

#define CAP_NET_RAW          13

#define CAP_SYS_MODULE       16

#define CAP_SYS_RAWIO        17

#define CAP_SYS_BOOT         22

#define CAP_SYS_TIME         25

#define CAP_AUDIT_READ          37

#define CAP_LAST_CAP         CAP_AUDIT_READ

对于普通用户运行的进程，当有这个权限的时候，就能做这些操作；没有的时候，就不能做，这样粒度要小很多。

cap_permitted 表示进程能够使用的权限。但是真正起作用的是 cap_effective。cap_permitted 中可以包含 cap_effective 中没有的权限。一个进程可以在必要的时候，放弃自己的某些权限，这样更加安全。假设自己因为代码漏洞被攻破了，但是如果啥也干不了，就没办法进一步突破。

cap_inheritable 表示当可执行文件的扩展属性设置了 inheritable 位时，调用 exec 执行该程序会继承调用者的 inheritable 集合，并将其加入到 permitted 集合。但在非 root 用户下执行 exec 时，通常不会保留 inheritable 集合，但是往往又是非 root 用户，才想保留权限，所以非常鸡肋。

cap_bset，也就是 capability bounding set，是系统中所有进程允许保留的权限。如果这个集合中不存在某个权限，那么系统中的所有进程都没有这个权限。即使以超级用户权限执行的进程，也是一样的。

这样有很多好处。例如，系统启动以后，将加载内核模块的权限去掉，那所有进程都不能加载内核模块。这样，即便这台机器被攻破，也做不了太多有害的事情。

cap_ambient 是比较新加入内核的，就是为了解决 cap_inheritable 鸡肋的状况，也就是，非 root 用户进程使用 exec 执行一个程序的时候，如何保留权限的问题。当执行 exec 的时候，cap_ambient 会被添加到 cap_permitted 中，同时设置到 cap_effective 中。

内存管理

每个进程都有自己独立的虚拟内存空间，这需要有一个数据结构来表示，就是 mm_struct。这个我们在内存管理那一节详细讲述。这里你先有个印象。

struct mm_struct                *mm;

struct mm_struct                *active_mm;

文件与文件系统

每个进程有一个文件系统的数据结构，还有一个打开文件的数据结构。这个我们放到文件系统那一节详细讲述。

/* Filesystem information: */

struct fs_struct                *fs;

/* Open file information: */

struct files_struct             *files;

总结时刻

这一节，我们终于把进程管理复杂的数据结构基本讲完了，请你重点记住以下两点：

进程亲缘关系维护的数据结构，是一种很有参考价值的实现方式，在内核中会多个地方出现类似的结构；

进程权限中 setuid 的原理，这一点比较难理解，但是很重要，面试经常会考。

你可以对着下面这张图，看看自己是否真的理解了，进程树是如何组织的，以及如何控制进程的权限的。

课堂练习

通过这一节的学习，你会发现，一个进程的运行竟然要保存这么多信息，这些信息都可以通过命令行取出来，所以今天的练习题就是，对于一个正在运行的进程，通过命令行找到上述进程运行的所有信息。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

14 | 进程数据结构（下）：项目多了就需要项目管理系统

上两节，我们解读了 task_struct 的大部分的成员变量。这样一个任务执行的方方面面，都可以很好地管理起来，但是其中有一个问题我们没有谈。在程序执行过程中，一旦调用到系统调用，就需要进入内核继续执行。那如何将用户态的执行和内核态的执行串起来呢？

这就需要以下两个重要的成员变量：

struct thread_info		thread_info;

void  *stack;

用户态函数栈

在用户态中，程序的执行往往是一个函数调用另一个函数。函数调用都是通过栈来进行的。我们前面大致讲过函数栈的原理，今天我们仔细分析一下。

函数调用其实也很简单。如果你去看汇编语言的代码，其实就是指令跳转，从代码的一个地方跳到另外一个地方。这里比较棘手的问题是，参数和返回地址应该怎么传递过去呢？

我们看函数的调用过程，A 调用 B、调用 C、调用 D，然后返回 C、返回 B、返回 A，这是一个后进先出的过程。有没有觉得这个过程很熟悉？没错，咱们数据结构里学的栈，也是后进先出的，所以用栈保存这些最合适。

在进程的内存空间里面，栈是一个从高地址到低地址，往下增长的结构，也就是上面是栈底，下面是栈顶，入栈和出栈的操作都是从下面的栈顶开始的。

我们先来看 32 位操作系统的情况。在 CPU 里，ESP（Extended Stack Pointer）是栈顶指针寄存器，入栈操作 Push 和出栈操作 Pop 指令，会自动调整 ESP 的值。另外有一个寄存器EBP（Extended Base Pointer），是栈基地址指针寄存器，指向当前栈帧的最底部。

例如，A 调用 B，A 的栈里面包含 A 函数的局部变量，然后是调用 B 的时候要传给它的参数，然后返回 A 的地址，这个地址也应该入栈，这就形成了 A 的栈帧。接下来就是 B 的栈帧部分了，先保存的是 A 栈帧的栈底位置，也就是 EBP。因为在 B 函数里面获取 A 传进来的参数，就是通过这个指针获取的，接下来保存的是 B 的局部变量等等。

当 B 返回的时候，返回值会保存在 EAX 寄存器中，从栈中弹出返回地址，将指令跳转回去，参数也从栈中弹出，然后继续执行 A。

对于 64 位操作系统，模式多少有些不一样。因为 64 位操作系统的寄存器数目比较多。rax 用于保存函数调用的返回结果。栈顶指针寄存器变成了 rsp，指向栈顶位置。堆栈的 Pop 和 Push 操作会自动调整 rsp，栈基指针寄存器变成了 rbp，指向当前栈帧的起始位置。

改变比较多的是参数传递。rdi、rsi、rdx、rcx、r8、r9 这 6 个寄存器，用于传递存储函数调用时的 6 个参数。如果超过 6 的时候，还是需要放到栈里面。

然而，前 6 个参数有时候需要进行寻址，但是如果在寄存器里面，是没有地址的，因而还是会放到栈里面，只不过放到栈里面的操作是被调用函数做的。

以上的栈操作，都是在进程的内存空间里面进行的。

内核态函数栈

接下来，我们通过系统调用，从进程的内存空间到内核中了。内核中也有各种各样的函数调用来调用去的，也需要这样一个机制，这该怎么办呢？

这时候，上面的成员变量 stack，也就是内核栈，就派上了用场。

Linux 给每个 task 都分配了内核栈。在 32 位系统上 arch/x86/include/asm/page_32_types.h，是这样定义的：一个 PAGE_SIZE 是 4K，左移一位就是乘以 2，也就是 8K。

#define THREAD_SIZE_ORDER	1

#define THREAD_SIZE		(PAGE_SIZE << THREAD_SIZE_ORDER)

内核栈在 64 位系统上 arch/x86/include/asm/page_64_types.h，是这样定义的：在 PAGE_SIZE 的基础上左移两位，也即 16K，并且要求起始地址必须是 8192 的整数倍。

#ifdef CONFIG_KASAN

#define KASAN_STACK_ORDER 1

#else

#define KASAN_STACK_ORDER 0

#endif

#define THREAD_SIZE_ORDER	(2 + KASAN_STACK_ORDER)

#define THREAD_SIZE  (PAGE_SIZE << THREAD_SIZE_ORDER)

内核栈是一个非常特殊的结构，如下图所示：

这段空间的最低位置，是一个 thread_info 结构。这个结构是对 task_struct 结构的补充。因为 task_struct 结构庞大但是通用，不同的体系结构就需要保存不同的东西，所以往往与体系结构有关的，都放在 thread_info 里面。

在内核代码里面有这样一个 union，将 thread_info 和 stack 放在一起，在 include/linux/sched.h 文件中就有。

union thread_union {

#ifndef CONFIG_THREAD_INFO_IN_TASK

	struct thread_info thread_info;

#endif

	unsigned long stack[THREAD_SIZE/sizeof(long)];

};

这个 union 就是这样定义的，开头是 thread_info，后面是 stack。

在内核栈的最高地址端，存放的是另一个结构 pt_regs，定义如下。其中，32 位和 64 位的定义不一样。

#ifdef __i386__

struct pt_regs {

	unsigned long bx;

	unsigned long cx;

	unsigned long dx;

	unsigned long si;

	unsigned long di;

	unsigned long bp;

	unsigned long ax;

	unsigned long ds;

	unsigned long es;

	unsigned long fs;

	unsigned long gs;

	unsigned long orig_ax;

	unsigned long ip;

	unsigned long cs;

	unsigned long flags;

	unsigned long sp;

	unsigned long ss;

};

#else

struct pt_regs {

	unsigned long r15;

	unsigned long r14;

	unsigned long r13;

	unsigned long r12;

	unsigned long bp;

	unsigned long bx;

	unsigned long r11;

	unsigned long r10;

	unsigned long r9;

	unsigned long r8;

	unsigned long ax;

	unsigned long cx;

	unsigned long dx;

	unsigned long si;

	unsigned long di;

	unsigned long orig_ax;

	unsigned long ip;

	unsigned long cs;

	unsigned long flags;

	unsigned long sp;

	unsigned long ss;

/* top of stack page */

};

#endif

看到这个是不是很熟悉？咱们在讲系统调用的时候，已经多次见过这个结构。当系统调用从用户态到内核态的时候，首先要做的第一件事情，就是将用户态运行过程中的 CPU 上下文保存起来，其实主要就是保存在这个结构的寄存器变量里。这样当从内核系统调用返回的时候，才能让进程在刚才的地方接着运行下去。

如果我们对比系统调用那一节的内容，你会发现系统调用的时候，压栈的值的顺序和 struct pt_regs 中寄存器定义的顺序是一样的。

在内核中，CPU 的寄存器 ESP 或者 RSP，已经指向内核栈的栈顶，在内核态里的调用都有和用户态相似的过程。

通过 task_struct 找内核栈

如果有一个 task_struct 的 stack 指针在手，你可以通过下面的函数找到这个线程内核栈：

static inline void *task_stack_page(const struct task_struct *task)

{

	return task->stack;

}

从 task_struct 如何得到相应的 pt_regs 呢？我们可以通过下面的函数：

/*

 * TOP_OF_KERNEL_STACK_PADDING reserves 8 bytes on top of the ring0 stack.

 * This is necessary to guarantee that the entire "struct pt_regs"

 * is accessible even if the CPU haven't stored the SS/ESP registers

 * on the stack (interrupt gate does not save these registers

 * when switching to the same priv ring).

 * Therefore beware: accessing the ss/esp fields of the

 * "struct pt_regs" is possible, but they may contain the

 * completely wrong values.

 */

#define task_pt_regs(task) \

({									\

	unsigned long __ptr = (unsigned long)task_stack_page(task);	\

	__ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING;		\

	((struct pt_regs *)__ptr) - 1;					\

})

你会发现，这是先从 task_struct 找到内核栈的开始位置。然后这个位置加上 THREAD_SIZE 就到了最后的位置，然后转换为 struct pt_regs，再减一，就相当于减少了一个 pt_regs 的位置，就到了这个结构的首地址。

这里面有一个 TOP_OF_KERNEL_STACK_PADDING，这个的定义如下：

#ifdef CONFIG_X86_32

# ifdef CONFIG_VM86

#  define TOP_OF_KERNEL_STACK_PADDING 16

# else

#  define TOP_OF_KERNEL_STACK_PADDING 8

# endif

#else

# define TOP_OF_KERNEL_STACK_PADDING 0

#endif

也就是说，32 位机器上是 8，其他是 0。这是为什么呢？因为压栈 pt_regs 有两种情况。我们知道，CPU 用 ring 来区分权限，从而 Linux 可以区分内核态和用户态。

因此，第一种情况，我们拿涉及从用户态到内核态的变化的系统调用来说。因为涉及权限的改变，会压栈保存 SS、ESP 寄存器的，这两个寄存器共占用 8 个 byte。

另一种情况是，不涉及权限的变化，就不会压栈这 8 个 byte。这样就会使得两种情况不兼容。如果没有压栈还访问，就会报错，所以还不如预留在这里，保证安全。在 64 位上，修改了这个问题，变成了定长的。

好了，现在如果你 task_struct 在手，就能够轻松得到内核栈和内核寄存器。

通过内核栈找 task_struct

那如果一个当前在某个 CPU 上执行的进程，想知道自己的 task_struct 在哪里，又该怎么办呢？

这个艰巨的任务要交给 thread_info 这个结构。

struct thread_info {

	struct task_struct	*task;		/* main task structure */

	__u32			flags;		/* low level flags */

	__u32			status;		/* thread synchronous flags */

	__u32			cpu;		/* current CPU */

	mm_segment_t		addr_limit;

	unsigned int		sig_on_uaccess_error:1;

	unsigned int		uaccess_err:1;	/* uaccess failed */

};

这里面有个成员变量 task 指向 task_struct，所以我们常用 current_thread_info()->task 来获取 task_struct。

static inline struct thread_info *current_thread_info(void)

{

	return (struct thread_info *)(current_top_of_stack() - THREAD_SIZE);

}

而 thread_info 的位置就是内核栈的最高位置，减去 THREAD_SIZE，就到了 thread_info 的起始地址。

但是现在变成这样了，只剩下一个 flags。

struct thread_info {

        unsigned long           flags;          /* low level flags */

};

那这时候怎么获取当前运行中的 task_struct 呢？current_thread_info 有了新的实现方式。

在 include/linux/thread_info.h 中定义了 current_thread_info。

#include <asm/current.h>

#define current_thread_info() ((struct thread_info *)current)

#endif

那 current 又是什么呢？在 arch/x86/include/asm/current.h 中定义了。

struct task_struct;

DECLARE_PER_CPU(struct task_struct *, current_task);

static __always_inline struct task_struct *get_current(void)

{

	return this_cpu_read_stable(current_task);

}

#define current get_current

到这里，你会发现，新的机制里面，每个 CPU 运行的 task_struct 不通过 thread_info 获取了，而是直接放在 Per CPU 变量里面了。

多核情况下，CPU 是同时运行的，但是它们共同使用其他的硬件资源的时候，我们需要解决多个 CPU 之间的同步问题。

Per CPU 变量是内核中一种重要的同步机制。顾名思义，Per CPU 变量就是为每个 CPU 构造一个变量的副本，这样多个 CPU 各自操作自己的副本，互不干涉。比如，当前进程的变量 current_task 就被声明为 Per CPU 变量。

要使用 Per CPU 变量，首先要声明这个变量，在 arch/x86/include/asm/current.h 中有：

DECLARE_PER_CPU(struct task_struct *, current_task);

然后是定义这个变量，在 arch/x86/kernel/cpu/common.c 中有：

DEFINE_PER_CPU(struct task_struct *, current_task) = &init_task;

也就是说，系统刚刚初始化的时候，current_task 都指向 init_task。

当某个 CPU 上的进程进行切换的时候，current_task 被修改为将要切换到的目标进程。例如，进程切换函数 __switch_to 就会改变 current_task。

__visible __notrace_funcgraph struct task_struct *

__switch_to(struct task_struct *prev_p, struct task_struct *next_p)

{

......

this_cpu_write(current_task, next_p);

......

return prev_p;

}

当要获取当前的运行中的 task_struct 的时候，就需要调用 this_cpu_read_stable 进行读取。

#define this_cpu_read_stable(var)       percpu_stable_op("mov", var)

好了，现在如果你是一个进程，正在某个 CPU 上运行，就能够轻松得到 task_struct 了。

总结时刻

这一节虽然只介绍了内核栈，但是内容更加重要。如果说 task_struct 的其他成员变量都是和进程管理有关的，内核栈是和进程运行有关系的。

我这里画了一张图总结一下 32 位和 64 位的工作模式，左边是 32 位的，右边是 64 位的。

在用户态，应用程序进行了至少一次函数调用。32 位和 64 的传递参数的方式稍有不同，32 位的就是用函数栈，64 位的前 6 个参数用寄存器，其他的用函数栈。

在内核态，32 位和 64 位都使用内核栈，格式也稍有不同，主要集中在 pt_regs 结构上。

在内核态，32 位和 64 位的内核栈和 task_struct 的关联关系不同。32 位主要靠 thread_info，64 位主要靠 Per-CPU 变量。

课堂练习

这一节讲函数调用的时候，我们讲了函数栈的工作模式。请你写一个程序，然后编译为汇编语言，打开看一下，函数栈是如何起作用的。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

15 | 调度（上）：如何制定项目管理流程？

前几节，我们介绍了 task_struct 数据结构。它就像项目管理系统一样，可以帮项目经理维护项目运行过程中的各类信息，但这并不意味着项目管理工作就完事大吉了。task_struct 仅仅能够解决“看到”的问题，咱们还要解决如何制定流程，进行项目调度的问题，也就是“做到”的问题。

公司的人员总是有限的。无论接了多少项目，公司不可能短时间增加很多人手。有的项目比较紧急，应该先进行排期；有的项目可以缓缓，但是也不能让客户等太久。所以这个过程非常复杂，需要平衡。

对于操作系统来讲，它面对的 CPU 的数量是有限的，干活儿都是它们，但是进程数目远远超过 CPU 的数目，因而就需要进行进程的调度，有效地分配 CPU 的时间，既要保证进程的最快响应，也要保证进程之间的公平。这也是一个非常复杂的、需要平衡的事情。

调度策略与调度类

在 Linux 里面，进程大概可以分成两种。

一种称为实时进程，也就是需要尽快执行返回结果的那种。这就好比我们是一家公司，接到的客户项目需求就会有很多种。有些客户的项目需求比较急，比如一定要在一两个月内完成的这种，客户会加急加钱，那这种客户的优先级就会比较高。

另一种是普通进程，大部分的进程其实都是这种。这就好比，大部分客户的项目都是普通的需求，可以按照正常流程完成，优先级就没实时进程这么高，但是人家肯定也有确定的交付日期。

那很显然，对于这两种进程，我们的调度策略肯定是不同的。

在 task_struct 中，有一个成员变量，我们叫调度策略。

unsigned int policy;

它有以下几个定义：

#define SCHED_NORMAL		0

#define SCHED_FIFO		1

#define SCHED_RR		2

#define SCHED_BATCH		3

#define SCHED_IDLE		5

#define SCHED_DEADLINE		6

配合调度策略的，还有我们刚才说的优先级，也在 task_struct 中。

int prio, static_prio, normal_prio;

unsigned int rt_priority;

优先级其实就是一个数值，对于实时进程，优先级的范围是 0～99；对于普通进程，优先级的范围是 100～139。数值越小，优先级越高。从这里可以看出，所有的实时进程都比普通进程优先级要高。毕竟，谁让人家加钱了呢。

实时调度策略

对于调度策略，其中 SCHED_FIFO、SCHED_RR、SCHED_DEADLINE 是实时进程的调度策略。

虽然大家都是加钱加急的项目，但是也不能乱来，还是需要有个办事流程才行。

例如，SCHED_FIFO就是交了相同钱的，先来先服务，但是有的加钱多，可以分配更高的优先级，也就是说，高优先级的进程可以抢占低优先级的进程，而相同优先级的进程，我们遵循先来先得。

另外一种策略是，交了相同钱的，轮换着来，这就是SCHED_RR 轮流调度算法，采用时间片，相同优先级的任务当用完时间片会被放到队列尾部，以保证公平性，而高优先级的任务也是可以抢占低优先级的任务。

还有一种新的策略是SCHED_DEADLINE，是按照任务的 deadline 进行调度的。当产生一个调度点的时候，DL 调度器总是选择其 deadline 距离当前时间点最近的那个任务，并调度它执行。

普通调度策略

对于普通进程的调度策略有，SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE。

既然大家的项目都没有那么紧急，就应该按照普通的项目流程，公平地分配人员。

SCHED_NORMAL 是普通的进程，就相当于咱们公司接的普通项目。

SCHED_BATCH 是后台进程，几乎不需要和前端进行交互。这有点像公司在接项目同时，开发一些可以复用的模块，作为公司的技术积累，从而使得在之后接新项目的时候，能够减少工作量。这类项目可以默默执行，不要影响需要交互的进程，可以降低他的优先级。

SCHED_IDLE 是特别空闲的时候才跑的进程，相当于咱们学习训练类的项目，比如咱们公司很长时间没有接到外在项目了，可以弄几个这样的项目练练手。

上面无论是 policy 还是 priority，都设置了一个变量，变量仅仅表示了应该这样这样干，但事情总要有人去干，谁呢？在 task_struct 里面，还有这样的成员变量：

const struct sched_class *sched_class;

调度策略的执行逻辑，就封装在这里面，它是真正干活的那个。

sched_class 有几种实现：

stop_sched_class 优先级最高的任务会使用这种策略，会中断所有其他线程，且不会被其他任务打断；

dl_sched_class 就对应上面的 deadline 调度策略；

rt_sched_class 就对应 RR 算法或者 FIFO 算法的调度策略，具体调度策略由进程的 task_struct->policy 指定；

fair_sched_class 就是普通进程的调度策略；

idle_sched_class 就是空闲进程的调度策略。

这里实时进程的调度策略 RR 和 FIFO 相对简单一些，而且由于咱们平时常遇到的都是普通进程，在这里，咱们就重点分析普通进程的调度问题。普通进程使用的调度策略是 fair_sched_class，顾名思义，对于普通进程来讲，公平是最重要的。

完全公平调度算法

在 Linux 里面，实现了一个基于 CFS 的调度算法。CFS 全称 Completely Fair Scheduling，叫完全公平调度。听起来很“公平”。那这个算法的原理是什么呢？我们来看看。

首先，你需要记录下进程的运行时间。CPU 会提供一个时钟，过一段时间就触发一个时钟中断。就像咱们的表滴答一下，这个我们叫 Tick。CFS 会为每一个进程安排一个虚拟运行时间 vruntime。如果一个进程在运行，随着时间的增长，也就是一个个 tick 的到来，进程的 vruntime 将不断增大。没有得到执行的进程 vruntime 不变。

显然，那些 vruntime 少的，原来受到了不公平的对待，需要给它补上，所以会优先运行这样的进程。

这有点像让你把一筐球平均分到 N 个口袋里面，你看着哪个少，就多放一些；哪个多了，就先不放。这样经过多轮，虽然不能保证球完全一样多，但是也差不多公平。

你可能会说，不还有优先级呢？如何给优先级高的进程多分时间呢？

这个简单，就相当于 N 个口袋，优先级高的袋子大，优先级低的袋子小。这样球就不能按照个数分配了，要按照比例来，大口袋的放了一半和小口袋放了一半，里面的球数目虽然差很多，也认为是公平的。

在更新进程运行的统计量的时候，我们其实就可以看出这个逻辑。

/*

 * Update the current task's runtime statistics.

 */

static void update_curr(struct cfs_rq *cfs_rq)

{

	struct sched_entity *curr = cfs_rq->curr;

	u64 now = rq_clock_task(rq_of(cfs_rq));

	u64 delta_exec;

......

	delta_exec = now - curr->exec_start;

......

	curr->exec_start = now;

......

	curr->sum_exec_runtime += delta_exec;

......

	curr->vruntime += calc_delta_fair(delta_exec, curr);

	update_min_vruntime(cfs_rq);

......

}

/*

 * delta /= w

 */

static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)

{

	if (unlikely(se->load.weight != NICE_0_LOAD))

        /* delta_exec * weight / lw.weight */

		delta = __calc_delta(delta, NICE_0_LOAD, &se->load);

	return delta;

}

在这里得到当前的时间，以及这次的时间片开始的时间，两者相减就是这次运行的时间 delta_exec ，但是得到的这个时间其实是实际运行的时间，需要做一定的转化才作为虚拟运行时间 vruntime。转化方法如下：

虚拟运行时间 vruntime += 实际运行时间 delta_exec * NICE_0_LOAD/ 权重

这就是说，同样的实际运行时间，给高权重的算少了，低权重的算多了，但是当选取下一个运行进程的时候，还是按照最小的 vruntime 来的，这样高权重的获得的实际运行时间自然就多了。这就相当于给一个体重 (权重)200 斤的胖子吃两个馒头，和给一个体重 100 斤的瘦子吃一个馒头，然后说，你们两个吃的是一样多。这样虽然总体胖子比瘦子多吃了一倍，但是还是公平的。

调度队列与调度实体

看来 CFS 需要一个数据结构来对 vruntime 进行排序，找出最小的那个。这个能够排序的数据结构不但需要查询的时候，能够快速找到最小的，更新的时候也需要能够快速的调整排序，要知道 vruntime 可是经常在变的，变了再插入这个数据结构，就需要重新排序。

能够平衡查询和更新速度的是树，在这里使用的是红黑树。

红黑树的的节点是应该包括 vruntime 的，称为调度实体。

在 task_struct 中有这样的成员变量：

struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;

这里有实时调度实体 sched_rt_entity，Deadline 调度实体 sched_dl_entity，以及完全公平算法调度实体 sched_entity。

看来不光 CFS 调度策略需要有这样一个数据结构进行排序，其他的调度策略也同样有自己的数据结构进行排序，因为任何一个策略做调度的时候，都是要区分谁先运行谁后运行。

而进程根据自己是实时的，还是普通的类型，通过这个成员变量，将自己挂在某一个数据结构里面，和其他的进程排序，等待被调度。如果这个进程是个普通进程，则通过 sched_entity，将自己挂在这棵红黑树上。

对于普通进程的调度实体定义如下，这里面包含了 vruntime 和权重 load_weight，以及对于运行时间的统计。

struct sched_entity {

	struct load_weight		load;

	struct rb_node			run_node;

	struct list_head		group_node;

	unsigned int			on_rq;

	u64				exec_start;

	u64				sum_exec_runtime;

	u64				vruntime;

	u64				prev_sum_exec_runtime;

	u64				nr_migrations;

	struct sched_statistics		statistics;

......

};

下图是一个红黑树的例子。

所有可运行的进程通过不断地插入操作最终都存储在以时间为顺序的红黑树中，vruntime 最小的在树的左侧，vruntime 最多的在树的右侧。 CFS 调度策略会选择红黑树最左边的叶子节点作为下一个将获得 cpu 的任务。

这棵红黑树放在那里呢？就像每个软件工程师写代码的时候，会将任务排成队列，做完一个做下一个。

CPU 也是这样的，每个 CPU 都有自己的 struct rq 结构，其用于描述在此 CPU 上所运行的所有进程，其包括一个实时进程队列 rt_rq 和一个 CFS 运行队列 cfs_rq，在调度时，调度器首先会先去实时进程队列找是否有实时进程需要运行，如果没有才会去 CFS 运行队列找是否有进行需要运行。

struct rq {

	/* runqueue lock: */

	raw_spinlock_t lock;

	unsigned int nr_running;

	unsigned long cpu_load[CPU_LOAD_IDX_MAX];

......

	struct load_weight load;

	unsigned long nr_load_updates;

	u64 nr_switches;

	struct cfs_rq cfs;

	struct rt_rq rt;

	struct dl_rq dl;

......

	struct task_struct *curr, *idle, *stop;

......

};

对于普通进程公平队列 cfs_rq，定义如下：

/* CFS-related fields in a runqueue */

struct cfs_rq {

	struct load_weight load;

	unsigned int nr_running, h_nr_running;

	u64 exec_clock;

	u64 min_vruntime;

#ifndef CONFIG_64BIT

	u64 min_vruntime_copy;

#endif

	struct rb_root tasks_timeline;

	struct rb_node *rb_leftmost;

	struct sched_entity *curr, *next, *last, *skip;

......

};

这里面 rb_root 指向的就是红黑树的根节点，这个红黑树在 CPU 看起来就是一个队列，不断的取下一个应该运行的进程。rb_leftmost 指向的是最左面的节点。

到这里终于凑够数据结构了，上面这些数据结构的关系如下图：

调度类是如何工作的？

凑够了数据结构，接下来我们来看调度类是如何工作的。

调度类的定义如下：

struct sched_class {

	const struct sched_class *next;

	void (*enqueue_task) (struct rq *rq, struct task_struct *p, int flags);

	void (*dequeue_task) (struct rq *rq, struct task_struct *p, int flags);

	void (*yield_task) (struct rq *rq);

	bool (*yield_to_task) (struct rq *rq, struct task_struct *p, bool preempt);

	void (*check_preempt_curr) (struct rq *rq, struct task_struct *p, int flags);

	struct task_struct * (*pick_next_task) (struct rq *rq,

						struct task_struct *prev,

						struct rq_flags *rf);

	void (*put_prev_task) (struct rq *rq, struct task_struct *p);

	void (*set_curr_task) (struct rq *rq);

	void (*task_tick) (struct rq *rq, struct task_struct *p, int queued);

	void (*task_fork) (struct task_struct *p);

	void (*task_dead) (struct task_struct *p);

	void (*switched_from) (struct rq *this_rq, struct task_struct *task);

	void (*switched_to) (struct rq *this_rq, struct task_struct *task);

	void (*prio_changed) (struct rq *this_rq, struct task_struct *task, int oldprio);

	unsigned int (*get_rr_interval) (struct rq *rq,

					 struct task_struct *task);

	void (*update_curr) (struct rq *rq)

这个结构定义了很多种方法，用于在队列上操作任务。这里请大家注意第一个成员变量，是一个指针，指向下一个调度类。

上面我们讲了，调度类分为下面这几种：

extern const struct sched_class stop_sched_class;

extern const struct sched_class dl_sched_class;

extern const struct sched_class rt_sched_class;

extern const struct sched_class fair_sched_class;

extern const struct sched_class idle_sched_class;

它们其实是放在一个链表上的。这里我们以调度最常见的操作，取下一个任务为例，来解析一下。可以看到，这里面有一个 for_each_class 循环，沿着上面的顺序，依次调用每个调度类的方法。

/*

 * Pick up the highest-prio task:

 */

static inline struct task_struct *

pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)

{

	const struct sched_class *class;

	struct task_struct *p;

......

	for_each_class(class) {

		p = class->pick_next_task(rq, prev, rf);

		if (p) {

			if (unlikely(p == RETRY_TASK))

				goto again;

			return p;

		}

	}

}

这就说明，调度的时候是从优先级最高的调度类到优先级低的调度类，依次执行。而对于每种调度类，有自己的实现，例如，CFS 就有 fair_sched_class。

const struct sched_class fair_sched_class = {

	.next			= &idle_sched_class,

	.enqueue_task		= enqueue_task_fair,

	.dequeue_task		= dequeue_task_fair,

	.yield_task		= yield_task_fair,

	.yield_to_task		= yield_to_task_fair,

	.check_preempt_curr	= check_preempt_wakeup,

	.pick_next_task		= pick_next_task_fair,

	.put_prev_task		= put_prev_task_fair,

	.set_curr_task          = set_curr_task_fair,

	.task_tick		= task_tick_fair,

	.task_fork		= task_fork_fair,

	.prio_changed		= prio_changed_fair,

	.switched_from		= switched_from_fair,

	.switched_to		= switched_to_fair,

	.get_rr_interval	= get_rr_interval_fair,

	.update_curr		= update_curr_fair,

};

对于同样的 pick_next_task 选取下一个要运行的任务这个动作，不同的调度类有自己的实现。fair_sched_class 的实现是 pick_next_task_fair，rt_sched_class 的实现是 pick_next_task_rt。

我们会发现这两个函数是操作不同的队列，pick_next_task_rt 操作的是 rt_rq，pick_next_task_fair 操作的是 cfs_rq。

static struct task_struct *

pick_next_task_rt(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)

{

	struct task_struct *p;

	struct rt_rq *rt_rq = &rq->rt;

......

}

static struct task_struct *

pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)

{

	struct cfs_rq *cfs_rq = &rq->cfs;

	struct sched_entity *se;

	struct task_struct *p;

......

}

这样整个运行的场景就串起来了，在每个 CPU 上都有一个队列 rq，这个队列里面包含多个子队列，例如 rt_rq 和 cfs_rq，不同的队列有不同的实现方式，cfs_rq 就是用红黑树实现的。

当有一天，某个 CPU 需要找下一个任务执行的时候，会按照优先级依次调用调度类，不同的调度类操作不同的队列。当然 rt_sched_class 先被调用，它会在 rt_rq 上找下一个任务，只有找不到的时候，才轮到 fair_sched_class 被调用，它会在 cfs_rq 上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。

下面我们仔细看一下 sched_class 定义的与调度有关的函数。

enqueue_task 向就绪队列中添加一个进程，当某个进程进入可运行状态时，调用这个函数；

dequeue_task 将一个进程从就就绪队列中删除；

pick_next_task 选择接下来要运行的进程；

put_prev_task 用另一个进程代替当前运行的进程；

set_curr_task 用于修改调度策略；

task_tick 每次周期性时钟到的时候，这个函数被调用，可能触发调度。

在这里面，我们重点看 fair_sched_class 对于 pick_next_task 的实现 pick_next_task_fair，获取下一个进程。调用路径如下：pick_next_task_fair->pick_next_entity->__pick_first_entity。

struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)

{

	struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);

	if (!left)

		return NULL;

	return rb_entry(left, struct sched_entity, run_node);

从这个函数的实现可以看出，就是从红黑树里面取最左面的节点。

总结时刻

好了，这一节我们讲了调度相关的数据结构，还是比较复杂的。一个 CPU 上有一个队列，CFS 的队列是一棵红黑树，树的每一个节点都是一个 sched_entity，每个 sched_entity 都属于一个 task_struct，task_struct 里面有指针指向这个进程属于哪个调度类。

在调度的时候，依次调用调度类的函数，从 CPU 的队列中取出下一个进程。上面图中的调度器、上下文切换这一节我们没有讲，下一节我们讲讲基于这些数据结构，如何实现调度。

课堂练习

这里讲了进程调度的策略和算法，你知道如何通过 API 设置进程和线程的调度策略吗？你可以写个程序尝试一下。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

16 | 调度（中）：主动调度是如何发生的？

上一节，我们为调度准备了这么多的数据结构，这一节我们来看调度是如何发生的。

所谓进程调度，其实就是一个人在做 A 项目，在某个时刻，换成做 B 项目去了。发生这种情况，主要有两种方式。

方式一：A 项目做着做着，发现里面有一条指令 sleep，也就是要休息一下，或者在等待某个 I/O 事件。那没办法了，就要主动让出 CPU，然后可以开始做 B 项目。

方式二：A 项目做着做着，旷日持久，实在受不了了。项目经理介入了，说这个项目 A 先停停，B 项目也要做一下，要不然 B 项目该投诉了。

主动调度

我们这一节先来看方式一，主动调度。

这里我找了几个代码片段。第一个片段是 Btrfs，等待一个写入。Btrfs（B-Tree）是一种文件系统，感兴趣你可以自己去了解一下。

这个片段可以看作写入块设备的一个典型场景。写入需要一段时间，这段时间用不上 CPU，还不如主动让给其他进程。

static void btrfs_wait_for_no_snapshoting_writes(struct btrfs_root *root)

{

......

	do {

		prepare_to_wait(&root->subv_writers->wait, &wait,

				TASK_UNINTERRUPTIBLE);

		writers = percpu_counter_sum(&root->subv_writers->counter);

		if (writers)

			schedule();

		finish_wait(&root->subv_writers->wait, &wait);

	} while (writers);

}

另外一个例子是，从 Tap 网络设备等待一个读取。Tap 网络设备是虚拟机使用的网络设备。当没有数据到来的时候，它也需要等待，所以也会选择把 CPU 让给其他进程。

static ssize_t tap_do_read(struct tap_queue *q,

			   struct iov_iter *to,

			   int noblock, struct sk_buff *skb)

{

......

	while (1) {

		if (!noblock)

			prepare_to_wait(sk_sleep(&q->sk), &wait,

					TASK_INTERRUPTIBLE);

......

		/* Nothing to read, let's sleep */

		schedule();

	}

......

}

你应该知道，计算机主要处理计算、网络、存储三个方面。计算主要是 CPU 和内存的合作；网络和存储则多是和外部设备的合作；在操作外部设备的时候，往往需要让出 CPU，就像上面两段代码一样，选择调用 schedule() 函数。

接下来，我们就来看schedule 函数的调用过程。

asmlinkage __visible void __sched schedule(void)

{

	struct task_struct *tsk = current;

	sched_submit_work(tsk);

	do {

		preempt_disable();

		__schedule(false);

		sched_preempt_enable_no_resched();

	} while (need_resched());

}

这段代码的主要逻辑是在 __schedule 函数中实现的。这个函数比较复杂，我们分几个部分来讲解。

static void __sched notrace __schedule(bool preempt)

{

	struct task_struct *prev, *next;

	unsigned long *switch_count;

	struct rq_flags rf;

	struct rq *rq;

	int cpu;

	cpu = smp_processor_id();

	rq = cpu_rq(cpu);

	prev = rq->curr;

......

首先，在当前的 CPU 上，我们取出任务队列 rq。

task_struct *prev 指向这个 CPU 的任务队列上面正在运行的那个进程 curr。为啥是 prev？因为一旦将来它被切换下来，那它就成了前任了。

接下来代码如下：

next = pick_next_task(rq, prev, &rf);

clear_tsk_need_resched(prev);

clear_preempt_need_resched();

第二步，获取下一个任务，task_struct *next 指向下一个任务，这就是继任。

pick_next_task 的实现如下：

static inline struct task_struct *

pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)

{

	const struct sched_class *class;

	struct task_struct *p;

	/*

	 * Optimization: we know that if all tasks are in the fair class we can call that function directly, but only if the @prev task wasn't of a higher scheduling class, because otherwise those loose the opportunity to pull in more work from other CPUs.

	 */

	if (likely((prev->sched_class == &idle_sched_class ||

		    prev->sched_class == &fair_sched_class) &&

		   rq->nr_running == rq->cfs.h_nr_running)) {

		p = fair_sched_class.pick_next_task(rq, prev, rf);

		if (unlikely(p == RETRY_TASK))

			goto again;

		/* Assumes fair_sched_class->next == idle_sched_class */

		if (unlikely(!p))

			p = idle_sched_class.pick_next_task(rq, prev, rf);

		return p;

	}

again:

	for_each_class(class) {

		p = class->pick_next_task(rq, prev, rf);

		if (p) {

			if (unlikely(p == RETRY_TASK))

				goto again;

			return p;

		}

	}

}

我们来看 again 这里，就是咱们上一节讲的依次调用调度类。但是这里有了一个优化，因为大部分进程是普通进程，所以大部分情况下会调用上面的逻辑，调用的就是 fair_sched_class.pick_next_task。

根据上一节对于 fair_sched_class 的定义，它调用的是 pick_next_task_fair，代码如下：

static struct task_struct *

pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)

{

	struct cfs_rq *cfs_rq = &rq->cfs;

	struct sched_entity *se;

	struct task_struct *p;

	int new_tasks;

对于 CFS 调度类，取出相应的队列 cfs_rq，这就是我们上一节讲的那棵红黑树。

		struct sched_entity *curr = cfs_rq->curr;

		if (curr) {

			if (curr->on_rq)

				update_curr(cfs_rq);

			else

				curr = NULL;

......

		}

		se = pick_next_entity(cfs_rq, curr);

取出当前正在运行的任务 curr，如果依然是可运行的状态，也即处于进程就绪状态，则调用 update_curr 更新 vruntime。update_curr 咱们上一节就见过了，它会根据实际运行时间算出 vruntime 来。

接着，pick_next_entity 从红黑树里面，取最左边的一个节点。这个函数的实现我们上一节也讲过了。

	p = task_of(se);

	if (prev != p) {

		struct sched_entity *pse = &prev->se;

......

		put_prev_entity(cfs_rq, pse);

		set_next_entity(cfs_rq, se);

	}

	return p

task_of 得到下一个调度实体对应的 task_struct，如果发现继任和前任不一样，这就说明有一个更需要运行的进程了，就需要更新红黑树了。前面前任的 vruntime 更新过了，put_prev_entity 放回红黑树，会找到相应的位置，然后 set_next_entity 将继任者设为当前任务。

第三步，当选出的继任者和前任不同，就要进行上下文切换，继任者进程正式进入运行。

if (likely(prev != next)) {

		rq->nr_switches++;

		rq->curr = next;

		++*switch_count;

......

		rq = context_switch(rq, prev, next, &rf);

进程上下文切换

上下文切换主要干两件事情，一是切换进程空间，也即虚拟内存；二是切换寄存器和 CPU 上下文。

我们先来看 context_switch 的实现。

/*

 * context_switch - switch to the new MM and the new thread's register state.

 */

static __always_inline struct rq *

context_switch(struct rq *rq, struct task_struct *prev,

	       struct task_struct *next, struct rq_flags *rf)

{

	struct mm_struct *mm, *oldmm;

......

	mm = next->mm;

	oldmm = prev->active_mm;

......

	switch_mm_irqs_off(oldmm, mm, next);

......

	/* Here we just switch the register state and the stack. */

	switch_to(prev, next, prev);

	barrier();

	return finish_task_switch(prev);

}

这里首先是内存空间的切换，里面涉及内存管理的内容比较多。内存管理后面我们会有专门的章节来讲，这里你先知道有这么一回事就行了。

接下来，我们看 switch_to。它就是寄存器和栈的切换，它调用到了 __switch_to_asm。这是一段汇编代码，主要用于栈的切换。

对于 32 位操作系统来讲，切换的是栈顶指针 esp。

/*

 * %eax: prev task

 * %edx: next task

 */

ENTRY(__switch_to_asm)

......

	/* switch stack */

	movl	%esp, TASK_threadsp(%eax)

	movl	TASK_threadsp(%edx), %esp

......

	jmp	__switch_to

END(__switch_to_asm)

对于 64 位操作系统来讲，切换的是栈顶指针 rsp。

/*

 * %rdi: prev task

 * %rsi: next task

 */

ENTRY(__switch_to_asm)

......

	/* switch stack */

	movq	%rsp, TASK_threadsp(%rdi)

	movq	TASK_threadsp(%rsi), %rsp

......

	jmp	__switch_to

END(__switch_to_asm)

最终，都返回了 __switch_to 这个函数。这个函数对于 32 位和 64 位操作系统虽然有不同的实现，但里面做的事情是差不多的。所以我这里仅仅列出 64 位操作系统做的事情。

__visible __notrace_funcgraph struct task_struct *

__switch_to(struct task_struct *prev_p, struct task_struct *next_p)

{

	struct thread_struct *prev = &prev_p->thread;

	struct thread_struct *next = &next_p->thread;

......

	int cpu = smp_processor_id();

	struct tss_struct *tss = &per_cpu(cpu_tss, cpu);

......

	load_TLS(next, cpu);

......

	this_cpu_write(current_task, next_p);

	/* Reload esp0 and ss1.  This changes current_thread_info(). */

	load_sp0(tss, next);

......

	return prev_p;

}

这里面有一个 Per CPU 的结构体 tss。这是个什么呢？

在 x86 体系结构中，提供了一种以硬件的方式进行进程切换的模式，对于每个进程，x86 希望在内存里面维护一个 TSS（Task State Segment，任务状态段）结构。这里面有所有的寄存器。

另外，还有一个特殊的寄存器 TR（Task Register，任务寄存器），指向某个进程的 TSS。更改 TR 的值，将会触发硬件保存 CPU 所有寄存器的值到当前进程的 TSS 中，然后从新进程的 TSS 中读出所有寄存器值，加载到 CPU 对应的寄存器中。

下图就是 32 位的 TSS 结构。

图片来自 Intel® 64 and IA-32 Architectures Software Developer’s Manual Combined Volumes

但是这样有个缺点。我们做进程切换的时候，没必要每个寄存器都切换，这样每个进程一个 TSS，就需要全量保存，全量切换，动作太大了。

于是，Linux 操作系统想了一个办法。还记得在系统初始化的时候，会调用 cpu_init 吗？这里面会给每一个 CPU 关联一个 TSS，然后将 TR 指向这个 TSS，然后在操作系统的运行过程中，TR 就不切换了，永远指向这个 TSS。TSS 用数据结构 tss_struct 表示，在 x86_hw_tss 中可以看到和上图相应的结构。

void cpu_init(void)

{

	int cpu = smp_processor_id();

	struct task_struct *curr = current;

	struct tss_struct *t = &per_cpu(cpu_tss, cpu);

    ......

    load_sp0(t, thread);

	set_tss_desc(cpu, t);

	load_TR_desc();

    ......

}

struct tss_struct {

	/*

	 * The hardware state:

	 */

	struct x86_hw_tss	x86_tss;

	unsigned long		io_bitmap[IO_BITMAP_LONGS + 1];

}

在 Linux 中，真的参与进程切换的寄存器很少，主要的就是栈顶寄存器。

于是，在 task_struct 里面，还有一个我们原来没有注意的成员变量 thread。这里面保留了要切换进程的时候需要修改的寄存器。

/* CPU-specific state of this task: */

	struct thread_struct		thread;

所谓的进程切换，就是将某个进程的 thread_struct 里面的寄存器的值，写入到 CPU 的 TR 指向的 tss_struct，对于 CPU 来讲，这就算是完成了切换。

例如 __switch_to 中的 load_sp0，就是将下一个进程的 thread_struct 的 sp0 的值加载到 tss_struct 里面去。

指令指针的保存与恢复

你是不是觉得，这样真的就完成切换了吗？是的，不信我们来盘点一下。

从进程 A 切换到进程 B，用户栈要不要切换呢？当然要，其实早就已经切换了，就在切换内存空间的时候。每个进程的用户栈都是独立的，都在内存空间里面。

那内核栈呢？已经在 __switch_to 里面切换了，也就是将 current_task 指向当前的 task_struct。里面的 void *stack 指针，指向的就是当前的内核栈。

内核栈的栈顶指针呢？在 __switch_to_asm 里面已经切换了栈顶指针，并且将栈顶指针在 __switch_to 加载到了 TSS 里面。

用户栈的栈顶指针呢？如果当前在内核里面的话，它当然是在内核栈顶部的 pt_regs 结构里面呀。当从内核返回用户态运行的时候，pt_regs 里面有所有当时在用户态的时候运行的上下文信息，就可以开始运行了。

唯一让人不容易理解的是指令指针寄存器，它应该指向下一条指令的，那它是如何切换的呢？这里有点绕，请你仔细看。

这里我先明确一点，进程的调度都最终会调用到 __schedule 函数。为了方便你记住，我姑且给它起个名字，就叫“进程调度第一定律”。后面我们会多次用到这个定律，你一定要记住。

我们用最前面的例子仔细分析这个过程。本来一个进程 A 在用户态是要写一个文件的，写文件的操作用户态没办法完成，就要通过系统调用到达内核态。在这个切换的过程中，用户态的指令指针寄存器是保存在 pt_regs 里面的，到了内核态，就开始沿着写文件的逻辑一步一步执行，结果发现需要等待，于是就调用 __schedule 函数。

这个时候，进程 A 在内核态的指令指针是指向 __schedule 了。这里请记住，A 进程的内核栈会保存这个 __schedule 的调用，而且知道这是从 btrfs_wait_for_no_snapshoting_writes 这个函数里面进去的。

__schedule 里面经过上面的层层调用，到达了 context_switch 的最后三行指令（其中 barrier 语句是一个编译器指令，用于保证 switch_to 和 finish_task_switch 的执行顺序，不会因为编译阶段优化而改变，这里咱们可以忽略它）。

switch_to(prev, next, prev);

barrier();

return finish_task_switch(prev);

当进程 A 在内核里面执行 switch_to 的时候，内核态的指令指针也是指向这一行的。但是在 switch_to 里面，将寄存器和栈都切换到成了进程 B 的，唯一没有变的就是指令指针寄存器。当 switch_to 返回的时候，指令指针寄存器指向了下一条语句 finish_task_switch。

但这个时候的 finish_task_switch 已经不是进程 A 的 finish_task_switch 了，而是进程 B 的 finish_task_switch 了。

这样合理吗？你怎么知道进程 B 当时被切换下去的时候，执行到哪里了？恢复 B 进程执行的时候一定在这里呢？这时候就要用到咱的“进程调度第一定律”了。

当年 B 进程被别人切换走的时候，也是调用 __schedule，也是调用到 switch_to，被切换成为 C 进程的，所以，B 进程当年的下一个指令也是 finish_task_switch，这就说明指令指针指到这里是没有错的。

接下来，我们要从 finish_task_switch 完毕后，返回 __schedule 的调用了。返回到哪里呢？按照函数返回的原理，当然是从内核栈里面去找，是返回到 btrfs_wait_for_no_snapshoting_writes 吗？当然不是了，因为 btrfs_wait_for_no_snapshoting_writes 是在 A 进程的内核栈里面的，它早就被切换走了，应该从 B 进程的内核栈里面找。

假设，B 就是最前面例子里面调用 tap_do_read 读网卡的进程。它当年调用 __schedule 的时候，是从 tap_do_read 这个函数调用进去的。

当然，B 进程的内核栈里面放的是 tap_do_read。于是，从 __schedule 返回之后，当然是接着 tap_do_read 运行，然后在内核运行完毕后，返回用户态。这个时候，B 进程内核栈的 pt_regs 也保存了用户态的指令指针寄存器，就接着在用户态的下一条指令开始运行就可以了。

假设，我们只有一个 CPU，从 B 切换到 C，从 C 又切换到 A。在 C 切换到 A 的时候，还是按照“进程调度第一定律”，C 进程还是会调用 __schedule 到达 switch_to，在里面切换成为 A 的内核栈，然后运行 finish_task_switch。

这个时候运行的 finish_task_switch，才是 A 进程的 finish_task_switch。运行完毕从 __schedule 返回的时候，从内核栈上才知道，当年是从 btrfs_wait_for_no_snapshoting_writes 调用进去的，因而应该返回 btrfs_wait_for_no_snapshoting_writes 继续执行，最后内核执行完毕返回用户态，同样恢复 pt_regs，恢复用户态的指令指针寄存器，从用户态接着运行。

到这里你是不是有点理解为什么 switch_to 有三个参数呢？为啥有两个 prev 呢？其实我们从定义就可以看到。

#define switch_to(prev, next, last)					\

do {									\

	prepare_switch_to(prev, next);					\

									\

	((last) = __switch_to_asm((prev), (next)));			\

} while (0)

在上面的例子中，A 切换到 B 的时候，运行到 __switch_to_asm 这一行的时候，是在 A 的内核栈上运行的，prev 是 A，next 是 B。但是，A 执行完 __switch_to_asm 之后就被切换走了，当 C 再次切换到 A 的时候，运行到 __switch_to_asm，是从 C 的内核栈运行的。这个时候，prev 是 C，next 是 A，但是 __switch_to_asm 里面切换成为了 A 当时的内核栈。

还记得当年的场景“prev 是 A，next 是 B”，__switch_to_asm 里面 return prev 的时候，还没 return 的时候，prev 这个变量里面放的还是 C，因而它会把 C 放到返回结果中。但是，一旦 return，就会弹出 A 当时的内核栈。这个时候，prev 变量就变成了 A，next 变量就变成了 B。这就还原了当年的场景，好在返回值里面的 last 还是 C。

通过三个变量 switch_to(prev = A, next=B, last=C)，A 进程就明白了，我当时被切换走的时候，是切换成 B，这次切换回来，是从 C 回来的。

总结时刻

这一节我们讲主动调度的过程，也即一个运行中的进程主动调用 __schedule 让出 CPU。在 __schedule 里面会做两件事情，第一是选取下一个进程，第二是进行上下文切换。而上下文切换又分用户态进程空间的切换和内核态的切换。

课堂练习

你知道应该用什么命令查看进程的运行时间和上下文切换次数吗？

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

17 | 调度（下）：抢占式调度是如何发生的？

上一节，我们讲了主动调度，就是进程运行到一半，因为等待 I/O 等操作而主动让出 CPU，然后就进入了我们的“进程调度第一定律”。所有进程的调用最终都会走 __schedule 函数。那这个定律在这一节还是要继续起作用。

抢占式调度

上一节我们讲的主动调度是第一种方式，第二种方式，就是抢占式调度。什么情况下会发生抢占呢？

最常见的现象就是一个进程执行时间太长了，是时候切换到另一个进程了。那怎么衡量一个进程的运行时间呢？在计算机里面有一个时钟，会过一段时间触发一次时钟中断，通知操作系统，时间又过去一个时钟周期，这是个很好的方式，可以查看是否是需要抢占的时间点。

时钟中断处理函数会调用 scheduler_tick()，它的代码如下：

void scheduler_tick(void)

{

	int cpu = smp_processor_id();

	struct rq *rq = cpu_rq(cpu);

	struct task_struct *curr = rq->curr;

......

	curr->sched_class->task_tick(rq, curr, 0);

	cpu_load_update_active(rq);

	calc_global_load_tick(rq);

......

}

这个函数先取出当然 cpu 的运行队列，然后得到这个队列上当前正在运行中的进程的 task_struct，然后调用这个 task_struct 的调度类的 task_tick 函数，顾名思义这个函数就是来处理时钟事件的。

如果当前运行的进程是普通进程，调度类为 fair_sched_class，调用的处理时钟的函数为 task_tick_fair。我们来看一下它的实现。

static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)

{

	struct cfs_rq *cfs_rq;

	struct sched_entity *se = &curr->se;

	for_each_sched_entity(se) {

		cfs_rq = cfs_rq_of(se);

		entity_tick(cfs_rq, se, queued);

	}

......

}

根据当前进程的 task_struct，找到对应的调度实体 sched_entity 和 cfs_rq 队列，调用 entity_tick。

static void

entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)

{

	update_curr(cfs_rq);

	update_load_avg(curr, UPDATE_TG);

	update_cfs_shares(curr);

.....

	if (cfs_rq->nr_running > 1)

		check_preempt_tick(cfs_rq, curr);

}

在 entity_tick 里面，我们又见到了熟悉的 update_curr。它会更新当前进程的 vruntime，然后调用 check_preempt_tick。顾名思义就是，检查是否是时候被抢占了。

static void

check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)

{

	unsigned long ideal_runtime, delta_exec;

	struct sched_entity *se;

	s64 delta;

	ideal_runtime = sched_slice(cfs_rq, curr);

	delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;

	if (delta_exec > ideal_runtime) {

		resched_curr(rq_of(cfs_rq));

		return;

	}

......

	se = __pick_first_entity(cfs_rq);

	delta = curr->vruntime - se->vruntime;

	if (delta < 0)

		return;

	if (delta > ideal_runtime)

		resched_curr(rq_of(cfs_rq));

}

check_preempt_tick 先是调用 sched_slice 函数计算出的 ideal_runtime，他是一个调度周期中，这个进程应该运行的实际时间。

sum_exec_runtime 指进程总共执行的实际时间，prev_sum_exec_runtime 指上次该进程被调度时已经占用的实际时间。每次在调度一个新的进程时都会把它的 se->prev_sum_exec_runtime = se->sum_exec_runtime，所以 sum_exec_runtime-prev_sum_exec_runtime 就是这次调度占用实际时间。如果这个时间大于 ideal_runtime，则应该被抢占了。

除了这个条件之外，还会通过 __pick_first_entity 取出红黑树中最小的进程。如果当前进程的 vruntime 大于红黑树中最小的进程的 vruntime，且差值大于 ideal_runtime，也应该被抢占了。

当发现当前进程应该被抢占，不能直接把它踢下来，而是把它标记为应该被抢占。为什么呢？因为进程调度第一定律呀，一定要等待正在运行的进程调用 __schedule 才行啊，所以这里只能先标记一下。

标记一个进程应该被抢占，都是调用 resched_curr，它会调用 set_tsk_need_resched，标记进程应该被抢占，但是此时此刻，并不真的抢占，而是打上一个标签 TIF_NEED_RESCHED。

static inline void set_tsk_need_resched(struct task_struct *tsk)

{

	set_tsk_thread_flag(tsk,TIF_NEED_RESCHED);

}

另外一个可能抢占的场景是当一个进程被唤醒的时候。

我们前面说过，当一个进程在等待一个 I/O 的时候，会主动放弃 CPU。但是当 I/O 到来的时候，进程往往会被唤醒。这个时候是一个时机。当被唤醒的进程优先级高于 CPU 上的当前进程，就会触发抢占。try_to_wake_up() 调用 ttwu_queue 将这个唤醒的任务添加到队列当中。ttwu_queue 再调用 ttwu_do_activate 激活这个任务。ttwu_do_activate 调用 ttwu_do_wakeup。这里面调用了 check_preempt_curr 检查是否应该发生抢占。如果应该发生抢占，也不是直接踢走当然进程，而也是将当前进程标记为应该被抢占。

static void ttwu_do_wakeup(struct rq *rq, struct task_struct *p, int wake_flags,

			   struct rq_flags *rf)

{

	check_preempt_curr(rq, p, wake_flags);

	p->state = TASK_RUNNING;

	trace_sched_wakeup(p);

到这里，你会发现，抢占问题只做完了一半。就是标识当前运行中的进程应该被抢占了，但是真正的抢占动作并没有发生。

抢占的时机

真正的抢占还需要时机，也就是需要那么一个时刻，让正在运行中的进程有机会调用一下 __schedule。

你可以想象，不可能某个进程代码运行着，突然要去调用 __schedule，代码里面不可能这么写，所以一定要规划几个时机，这个时机分为用户态和内核态。

用户态的抢占时机

对于用户态的进程来讲，从系统调用中返回的那个时刻，是一个被抢占的时机。

前面讲系统调用的时候，64 位的系统调用的链路位 do_syscall_64->syscall_return_slowpath->prepare_exit_to_usermode->exit_to_usermode_loop，当时我们还没关注 exit_to_usermode_loop 这个函数，现在我们来看一下。

static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)

{

	while (true) {

		/* We have work to do. */

		local_irq_enable();

		if (cached_flags & _TIF_NEED_RESCHED)

			schedule();

......

	}

}

现在我们看到在 exit_to_usermode_loop 函数中，上面打的标记起了作用，如果被打了 _TIF_NEED_RESCHED，调用 schedule 进行调度，调用的过程和上一节解析的一样，会选择一个进程让出 CPU，做上下文切换。

对于用户态的进程来讲，从中断中返回的那个时刻，也是一个被抢占的时机。

在 arch/x86/entry/entry_64.S 中有中断的处理过程。又是一段汇编语言代码，你重点领会它的意思就行，不要纠结每一行都看懂。

common_interrupt:

        ASM_CLAC

        addq    $-0x80, (%rsp)

        interrupt do_IRQ

ret_from_intr:

        popq    %rsp

        testb   $3, CS(%rsp)

        jz      retint_kernel

/* Interrupt came from user space */

GLOBAL(retint_user)

        mov     %rsp,%rdi

        call    prepare_exit_to_usermode

        TRACE_IRQS_IRETQ

        SWAPGS

        jmp     restore_regs_and_iret

/* Returning to kernel space */

retint_kernel:

#ifdef CONFIG_PREEMPT

        bt      $9, EFLAGS(%rsp)

        jnc     1f

0:      cmpl    $0, PER_CPU_VAR(__preempt_count)

        jnz     1f

        call    preempt_schedule_irq

        jmp     0b

中断处理调用的是 do_IRQ 函数，中断完毕后分为两种情况，一个是返回用户态，一个是返回内核态。这个通过注释也能看出来。

咱们先来来看返回用户态这一部分，先不管返回内核态的那部分代码，retint_user 会调用 prepare_exit_to_usermode，最终调用 exit_to_usermode_loop，和上面的逻辑一样，发现有标记则调用 schedule()。

内核态的抢占时机

用户态的抢占时机讲完了，接下来我们看内核态的抢占时机。

对内核态的执行中，被抢占的时机一般发生在在 preempt_enable() 中。

在内核态的执行中，有的操作是不能被中断的，所以在进行这些操作之前，总是先调用 preempt_disable() 关闭抢占，当再次打开的时候，就是一次内核态代码被抢占的机会。

就像下面代码中展示的一样，preempt_enable() 会调用 preempt_count_dec_and_test()，判断 preempt_count 和 TIF_NEED_RESCHED 看是否可以被抢占。如果可以，就调用 preempt_schedule->preempt_schedule_common->__schedule 进行调度。还是满足进程调度第一定律的。

#define preempt_enable() \

do { \

	if (unlikely(preempt_count_dec_and_test())) \

		__preempt_schedule(); \

} while (0)

#define preempt_count_dec_and_test() \

	({ preempt_count_sub(1); should_resched(0); })

static __always_inline bool should_resched(int preempt_offset)

{

	return unlikely(preempt_count() == preempt_offset &&

			tif_need_resched());

}

#define tif_need_resched() test_thread_flag(TIF_NEED_RESCHED)

static void __sched notrace preempt_schedule_common(void)

{

	do {

......

		__schedule(true);

......

	} while (need_resched())

在内核态也会遇到中断的情况，当中断返回的时候，返回的仍然是内核态。这个时候也是一个执行抢占的时机，现在我们再来上面中断返回的代码中返回内核的那部分代码，调用的是 preempt_schedule_irq。

asmlinkage __visible void __sched preempt_schedule_irq(void)

{

......

	do {

		preempt_disable();

		local_irq_enable();

		__schedule(true);

		local_irq_disable();

		sched_preempt_enable_no_resched();

	} while (need_resched());

......

}

preempt_schedule_irq 调用 __schedule 进行调度。还是满足进程调度第一定律的。

总结时刻

好了，抢占式调度就讲到这里了。我这里画了一张脑图，将整个进程的调度体系都放在里面。

这个脑图里面第一条就是总结了进程调度第一定律的核心函数 __schedule 的执行过程，这是上一节讲的，因为要切换的东西比较多，需要你详细了解每一部分是如何切换的。

第二条总结了标记为可抢占的场景，第三条是所有的抢占发生的时机，这里是真正验证了进程调度第一定律的。

课堂练习

通过对于内核中进程调度的分析，我们知道，时间对于调度是很重要的，你知道 Linux 内核是如何管理和度量时间的吗？

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

18 | 进程的创建：如何发起一个新项目？

前面我们学习了如何使用 fork 创建进程，也学习了进程管理和调度的相关数据结构。这一节，我们就来看一看，创建进程这个动作在内核里都做了什么事情。

fork 是一个系统调用，根据咱们讲过的系统调用的流程，流程的最后会在 sys_call_table 中找到相应的系统调用 sys_fork。

sys_fork 是如何定义的呢？根据 SYSCALL_DEFINE0 这个宏的定义，下面这段代码就定义了 sys_fork。

SYSCALL_DEFINE0(fork)

{

......

	return _do_fork(SIGCHLD, 0, 0, NULL, NULL, 0);

}

sys_fork 会调用 _do_fork。

long _do_fork(unsigned long clone_flags,

	      unsigned long stack_start,

	      unsigned long stack_size,

	      int __user *parent_tidptr,

	      int __user *child_tidptr,

	      unsigned long tls)

{

	struct task_struct *p;

	int trace = 0;

	long nr;

......

	p = copy_process(clone_flags, stack_start, stack_size,

			 child_tidptr, NULL, trace, tls, NUMA_NO_NODE);

......

	if (!IS_ERR(p)) {

		struct pid *pid;

		pid = get_task_pid(p, PIDTYPE_PID);

		nr = pid_vnr(pid);

		if (clone_flags & CLONE_PARENT_SETTID)

			put_user(nr, parent_tidptr);

......

		wake_up_new_task(p);

......

		put_pid(pid);

	}

......

fork 的第一件大事：复制结构

_do_fork 里面做的第一件大事就是 copy_process，咱们前面讲过这个思想。如果所有数据结构都从头创建一份太麻烦了，还不如使用惯用“伎俩”，Ctrl C + Ctrl V。

这里我们再把 task_struct 的结构图拿出来，对比着看如何一个个复制。

static __latent_entropy struct task_struct *copy_process(

					unsigned long clone_flags,

					unsigned long stack_start,

					unsigned long stack_size,

					int __user *child_tidptr,

					struct pid *pid,

					int trace,

					unsigned long tls,

					int node)

{

	int retval;

	struct task_struct *p;

......

	p = dup_task_struct(current, node);

dup_task_struct 主要做了下面几件事情：

调用 alloc_task_struct_node 分配一个 task_struct 结构；

调用 alloc_thread_stack_node 来创建内核栈，这里面调用 __vmalloc_node_range 分配一个连续的 THREAD_SIZE 的内存空间，赋值给 task_struct 的 void *stack 成员变量；

调用 arch_dup_task_struct(struct task_struct *dst, struct task_struct *src)，将 task_struct 进行复制，其实就是调用 memcpy；

调用 setup_thread_stack 设置 thread_info。

到这里，整个 task_struct 复制了一份，而且内核栈也创建好了。

我们再接着看 copy_process。

retval = copy_creds(p, clone_flags);

轮到权限相关了，copy_creds 主要做了下面几件事情：

调用 prepare_creds，准备一个新的 struct cred *new。如何准备呢？其实还是从内存中分配一个新的 struct cred 结构，然后调用 memcpy 复制一份父进程的 cred；

接着 p->cred = p->real_cred = get_cred(new)，将新进程的“我能操作谁”和“谁能操作我”两个权限都指向新的 cred。

接下来，copy_process 重新设置进程运行的统计量。

p->utime = p->stime = p->gtime = 0;

p->start_time = ktime_get_ns();

p->real_start_time = ktime_get_boot_ns();

接下来，copy_process 开始设置调度相关的变量。

retval = sched_fork(clone_flags, p);

sched_fork 主要做了下面几件事情：

调用 __sched_fork，在这里面将 on_rq 设为 0，初始化 sched_entity，将里面的 exec_start、sum_exec_runtime、prev_sum_exec_runtime、vruntime 都设为 0。你还记得吗，这几个变量涉及进程的实际运行时间和虚拟运行时间。是否到时间应该被调度了，就靠它们几个；

设置进程的状态 p->state = TASK_NEW；

初始化优先级 prio、normal_prio、static_prio；

设置调度类，如果是普通进程，就设置为 p->sched_class = &fair_sched_class；

调用调度类的 task_fork 函数，对于 CFS 来讲，就是调用 task_fork_fair。在这个函数里，先调用 update_curr，对于当前的进程进行统计量更新，然后把子进程和父进程的 vruntime 设成一样，最后调用 place_entity，初始化 sched_entity。这里有一个变量 sysctl_sched_child_runs_first，可以设置父进程和子进程谁先运行。如果设置了子进程先运行，即便两个进程的 vruntime 一样，也要把子进程的 sched_entity 放在前面，然后调用 resched_curr，标记当前运行的进程 TIF_NEED_RESCHED，也就是说，把父进程设置为应该被调度，这样下次调度的时候，父进程会被子进程抢占。

接下来，copy_process 开始初始化与文件和文件系统相关的变量。

retval = copy_files(clone_flags, p);

retval = copy_fs(clone_flags, p);

copy_files 主要用于复制一个进程打开的文件信息。这些信息用一个结构 files_struct 来维护，每个打开的文件都有一个文件描述符。在 copy_files 函数里面调用 dup_fd，在这里面会创建一个新的 files_struct，然后将所有的文件描述符数组 fdtable 拷贝一份。

copy_fs 主要用于复制一个进程的目录信息。这些信息用一个结构 fs_struct 来维护。一个进程有自己的根目录和根文件系统 root，也有当前目录 pwd 和当前目录的文件系统，都在 fs_struct 里面维护。copy_fs 函数里面调用 copy_fs_struct，创建一个新的 fs_struct，并复制原来进程的 fs_struct。

接下来，copy_process 开始初始化与信号相关的变量。

init_sigpending(&p->pending);

retval = copy_sighand(clone_flags, p);

retval = copy_signal(clone_flags, p);

copy_sighand 会分配一个新的 sighand_struct。这里最主要的是维护信号处理函数，在 copy_sighand 里面会调用 memcpy，将信号处理函数 sighand->action 从父进程复制到子进程。

init_sigpending 和 copy_signal 用于初始化，并且复制用于维护发给这个进程的信号的数据结构。copy_signal 函数会分配一个新的 signal_struct，并进行初始化。

接下来，copy_process 开始复制进程内存空间。

retval = copy_mm(clone_flags, p);

进程都自己的内存空间，用 mm_struct 结构来表示。copy_mm 函数中调用 dup_mm，分配一个新的 mm_struct 结构，调用 memcpy 复制这个结构。dup_mmap 用于复制内存空间中内存映射的部分。前面讲系统调用的时候，我们说过，mmap 可以分配大块的内存，其实 mmap 也可以将一个文件映射到内存中，方便可以像读写内存一样读写文件，这个在内存管理那节我们讲。

接下来，copy_process 开始分配 pid，设置 tid，group_leader，并且建立进程之间的亲缘关系。

	INIT_LIST_HEAD(&p->children);

	INIT_LIST_HEAD(&p->sibling);

......

    p->pid = pid_nr(pid);

	if (clone_flags & CLONE_THREAD) {

		p->exit_signal = -1;

		p->group_leader = current->group_leader;

		p->tgid = current->tgid;

	} else {

		if (clone_flags & CLONE_PARENT)

			p->exit_signal = current->group_leader->exit_signal;

		else

			p->exit_signal = (clone_flags & CSIGNAL);

		p->group_leader = p;

		p->tgid = p->pid;

	}

......

	if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {

		p->real_parent = current->real_parent;

		p->parent_exec_id = current->parent_exec_id;

	} else {

		p->real_parent = current;

		p->parent_exec_id = current->self_exec_id;

	}

好了，copy_process 要结束了，上面图中的组件也初始化的差不多了。

fork 的第二件大事：唤醒新进程

_do_fork 做的第二件大事是 wake_up_new_task。新任务刚刚建立，有没有机会抢占别人，获得 CPU 呢？

void wake_up_new_task(struct task_struct *p)

{

	struct rq_flags rf;

	struct rq *rq;

......

	p->state = TASK_RUNNING;

......

	activate_task(rq, p, ENQUEUE_NOCLOCK);

	p->on_rq = TASK_ON_RQ_QUEUED;

	trace_sched_wakeup_new(p);

	check_preempt_curr(rq, p, WF_FORK);

......

}

首先，我们需要将进程的状态设置为 TASK_RUNNING。

activate_task 函数中会调用 enqueue_task。

static inline void enqueue_task(struct rq *rq, struct task_struct *p, int flags)

{

.....

	p->sched_class->enqueue_task(rq, p, flags);

}

如果是 CFS 的调度类，则执行相应的 enqueue_task_fair。

static void

enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)

{

	struct cfs_rq *cfs_rq;

	struct sched_entity *se = &p->se;

......

	cfs_rq = cfs_rq_of(se);

	enqueue_entity(cfs_rq, se, flags);

......

	cfs_rq->h_nr_running++;

......

}

在 enqueue_task_fair 中取出的队列就是 cfs_rq，然后调用 enqueue_entity。

在 enqueue_entity 函数里面，会调用 update_curr，更新运行的统计量，然后调用 __enqueue_entity，将 sched_entity 加入到红黑树里面，然后将 se->on_rq = 1 设置在队列上。

回到 enqueue_task_fair 后，将这个队列上运行的进程数目加一。然后，wake_up_new_task 会调用 check_preempt_curr，看是否能够抢占当前进程。

在 check_preempt_curr 中，会调用相应的调度类的 rq->curr->sched_class->check_preempt_curr(rq, p, flags)。对于 CFS 调度类来讲，调用的是 check_preempt_wakeup。

static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)

{

	struct task_struct *curr = rq->curr;

	struct sched_entity *se = &curr->se, *pse = &p->se;

	struct cfs_rq *cfs_rq = task_cfs_rq(curr);

......

	if (test_tsk_need_resched(curr))

		return;

......

	find_matching_se(&se, &pse);

	update_curr(cfs_rq_of(se));

	if (wakeup_preempt_entity(se, pse) == 1) {

		goto preempt;

	}

	return;

preempt:

	resched_curr(rq);

......

}

在 check_preempt_wakeup 函数中，前面调用 task_fork_fair 的时候，设置 sysctl_sched_child_runs_first 了，已经将当前父进程的 TIF_NEED_RESCHED 设置了，则直接返回。

否则，check_preempt_wakeup 还是会调用 update_curr 更新一次统计量，然后 wakeup_preempt_entity 将父进程和子进程 PK 一次，看是不是要抢占，如果要则调用 resched_curr 标记父进程为 TIF_NEED_RESCHED。

如果新创建的进程应该抢占父进程，在什么时间抢占呢？别忘了 fork 是一个系统调用，从系统调用返回的时候，是抢占的一个好时机，如果父进程判断自己已经被设置为 TIF_NEED_RESCHED，就让子进程先跑，抢占自己。

总结时刻

好了，fork 系统调用的过程咱们就解析完了。它包含两个重要的事件，一个是将 task_struct 结构复制一份并且初始化，另一个是试图唤醒新创建的子进程。

这个过程我画了一张图，你可以对照着这张图回顾进程创建的过程。

这个图的上半部分是复制 task_struct 结构，你可以对照着右面的 task_struct 结构图，看这里面的成员是如何一部分一部分的被复制的。图的下半部分是唤醒新创建的子进程，如果条件满足，就会将当前进程设置应该被调度的标识位，就等着当前进程执行 __schedule 了。

课堂练习

你可以试着设置 sysctl_sched_child_runs_first 参数，然后使用系统调用写程序创建进程，看看执行结果。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

19 | 线程的创建：如何执行一个新子项目？

上一节，我们了解了进程创建的整个过程，今天我们来看线程创建的过程。

我们前面已经写过多线程编程的程序了，你应该都知道创建一个线程调用的是 pthread_create，可以你知道它背后的机制吗？

用户态创建线程

你可能会问，咱们之前不是讲过了吗？无论是进程还是线程，在内核里面都是任务，管起来不是都一样吗？但是问题来了，如果两个完全一样，那为什么咱们前两节写的程序差别那么大？如果不一样，那怎么在内核里面加以区分呢？

其实，线程不是一个完全由内核实现的机制，它是由内核态和用户态合作完成的。pthread_create 不是一个系统调用，是 Glibc 库的一个函数，所以我们还要去 Glibc 里面去找线索。

果然，我们在 nptl/pthread_create.c 里面找到了这个函数。这里的参数我们应该比较熟悉了。

int __pthread_create_2_1 (pthread_t *newthread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg)

{

......

}

versioned_symbol (libpthread, __pthread_create_2_1, pthread_create, GLIBC_2_1);

下面我们依次来看这个函数做了些啥。

首先处理的是线程的属性参数。例如前面写程序的时候，我们设置的线程栈大小。如果没有传入线程属性，就取默认值。

const struct pthread_attr *iattr = (struct pthread_attr *) attr;

struct pthread_attr default_attr;

if (iattr == NULL)

{

  ......

  iattr = &default_attr;

}

接下来，就像在内核里一样，每一个进程或者线程都有一个 task_struct 结构，在用户态也有一个用于维护线程的结构，就是这个 pthread 结构。

struct pthread *pd = NULL;

凡是涉及函数的调用，都要使用到栈。每个线程也有自己的栈。那接下来就是创建线程栈了。

int err = ALLOCATE_STACK (iattr, &pd);

ALLOCATE_STACK 是一个宏，我们找到它的定义之后，发现它其实就是一个函数。只是，这个函数有些复杂，所以我这里把主要的代码列一下。

# define ALLOCATE_STACK(attr, pd) allocate_stack (attr, pd, &stackaddr)

static int

allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,

                ALLOCATE_STACK_PARMS)

{

  struct pthread *pd;

  size_t size;

  size_t pagesize_m1 = __getpagesize () - 1;

......

  size = attr->stacksize;

......

  /* Allocate some anonymous memory.  If possible use the cache.  */

  size_t guardsize;

  void *mem;

  const int prot = (PROT_READ | PROT_WRITE

                   | ((GL(dl_stack_flags) & PF_X) ? PROT_EXEC : 0));

  /* Adjust the stack size for alignment.  */

  size &= ~__static_tls_align_m1;

  /* Make sure the size of the stack is enough for the guard and

  eventually the thread descriptor.  */

  guardsize = (attr->guardsize + pagesize_m1) & ~pagesize_m1;

  size += guardsize;

  pd = get_cached_stack (&size, &mem);

  if (pd == NULL)

  {

    /* If a guard page is required, avoid committing memory by first

    allocate with PROT_NONE and then reserve with required permission

    excluding the guard page.  */

	mem = __mmap (NULL, size, (guardsize == 0) ? prot : PROT_NONE,

			MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);

    /* Place the thread descriptor at the end of the stack.  */

#if TLS_TCB_AT_TP

    pd = (struct pthread *) ((char *) mem + size) - 1;

#elif TLS_DTV_AT_TP

    pd = (struct pthread *) ((((uintptr_t) mem + size - __static_tls_size) & ~__static_tls_align_m1) - TLS_PRE_TCB_SIZE);

#endif

    /* Now mprotect the required region excluding the guard area. */

    char *guard = guard_position (mem, size, guardsize, pd, pagesize_m1);

    setup_stack_prot (mem, size, guard, guardsize, prot);

    pd->stackblock = mem;

    pd->stackblock_size = size;

    pd->guardsize = guardsize;

    pd->specific[0] = pd->specific_1stblock;

    /* And add to the list of stacks in use.  */

    stack_list_add (&pd->list, &stack_used);

  }

  *pdp = pd;

  void *stacktop;

# if TLS_TCB_AT_TP

  /* The stack begins before the TCB and the static TLS block.  */

  stacktop = ((char *) (pd + 1) - __static_tls_size);

# elif TLS_DTV_AT_TP

  stacktop = (char *) (pd - 1);

# endif

  *stack = stacktop;

......

}

我们来看一下，allocate_stack 主要做了以下这些事情：

如果你在线程属性里面设置过栈的大小，需要你把设置的值拿出来；

为了防止栈的访问越界，在栈的末尾会有一块空间 guardsize，一旦访问到这里就错误了；

其实线程栈是在进程的堆里面创建的。如果一个进程不断地创建和删除线程，我们不可能不断地去申请和清除线程栈使用的内存块，这样就需要有一个缓存。get_cached_stack 就是根据计算出来的 size 大小，看一看已经有的缓存中，有没有已经能够满足条件的；

如果缓存里面没有，就需要调用 __mmap 创建一块新的，系统调用那一节我们讲过，如果要在堆里面 malloc 一块内存，比较大的话，用 __mmap；

线程栈也是自顶向下生长的，还记得每个线程要有一个 pthread 结构，这个结构也是放在栈的空间里面的。在栈底的位置，其实是地址最高位；

计算出 guard 内存的位置，调用 setup_stack_prot 设置这块内存的是受保护的；

接下来，开始填充 pthread 这个结构里面的成员变量 stackblock、stackblock_size、guardsize、specific。这里的 specific 是用于存放 Thread Specific Data 的，也即属于线程的全局变量；

将这个线程栈放到 stack_used 链表中，其实管理线程栈总共有两个链表，一个是 stack_used，也就是这个栈正被使用；另一个是 stack_cache，就是上面说的，一旦线程结束，先缓存起来，不释放，等有其他的线程创建的时候，给其他的线程用。

搞定了用户态栈的问题，其实用户态的事情基本搞定了一半。

内核态创建任务

接下来，我们接着 pthread_create 看。其实有了用户态的栈，接着需要解决的就是用户态的程序从哪里开始运行的问题。

pd->start_routine = start_routine;

pd->arg = arg;

pd->schedpolicy = self->schedpolicy;

pd->schedparam = self->schedparam;

/* Pass the descriptor to the caller.  */

*newthread = (pthread_t) pd;

atomic_increment (&__nptl_nthreads);

retval = create_thread (pd, iattr, &stopped_start, STACK_VARIABLES_ARGS, &thread_ran);

start_routine 就是咱们给线程的函数，start_routine，start_routine 的参数 arg，以及调度策略都要赋值给 pthread。

接下来 __nptl_nthreads 加一，说明有多了一个线程。

真正创建线程的是调用 create_thread 函数，这个函数定义如下：

static int

create_thread (struct pthread *pd, const struct pthread_attr *attr,

bool *stopped_start, STACK_VARIABLES_PARMS, bool *thread_ran)

{

  const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | 0);

  ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS, clone_flags, pd, &pd->tid, tp, &pd->tid)；

  /* It's started now, so if we fail below, we'll have to cancel it

and let it clean itself up.  */

  *thread_ran = true;

}

这里面有很长的 clone_flags，这些咱们原来一直没注意，不过接下来的过程，我们要特别的关注一下这些标志位。

然后就是 ARCH_CLONE，其实调用的是 __clone。看到这里，你应该就有感觉了，马上就要到系统调用了。

# define ARCH_CLONE __clone

/* The userland implementation is:

   int clone (int (*fn)(void *arg), void *child_stack, int flags, void *arg),

   the kernel entry is:

   int clone (long flags, void *child_stack).

   The parameters are passed in register and on the stack from userland:

   rdi: fn

   rsi: child_stack

   rdx: flags

   rcx: arg

   r8d: TID field in parent

   r9d: thread pointer

%esp+8: TID field in child

   The kernel expects:

   rax: system call number

   rdi: flags

   rsi: child_stack

   rdx: TID field in parent

   r10: TID field in child

   r8:  thread pointer  */

        .text

ENTRY (__clone)

        movq    $-EINVAL,%rax

......

        /* Insert the argument onto the new stack.  */

        subq    $16,%rsi

        movq    %rcx,8(%rsi)

        /* Save the function pointer.  It will be popped off in the

           child in the ebx frobbing below.  */

        movq    %rdi,0(%rsi)

        /* Do the system call.  */

        movq    %rdx, %rdi

        movq    %r8, %rdx

        movq    %r9, %r8

        mov     8(%rsp), %R10_LP

        movl    $SYS_ify(clone),%eax

......

        syscall

......

PSEUDO_END (__clone)

如果对于汇编不太熟悉也没关系，你可以重点看上面的注释。

我们能看到最后调用了 syscall，这一点 clone 和我们原来熟悉的其他系统调用几乎是一致的。但是，也有少许不一样的地方。

如果在进程的主线程里面调用其他系统调用，当前用户态的栈是指向整个进程的栈，栈顶指针也是指向进程的栈，指令指针也是指向进程的主线程的代码。此时此刻执行到这里，调用 clone 的时候，用户态的栈、栈顶指针、指令指针和其他系统调用一样，都是指向主线程的。

但是对于线程来说，这些都要变。因为我们希望当 clone 这个系统调用成功的时候，除了内核里面有这个线程对应的 task_struct，当系统调用返回到用户态的时候，用户态的栈应该是线程的栈，栈顶指针应该指向线程的栈，指令指针应该指向线程将要执行的那个函数。

所以这些都需要我们自己做，将线程要执行的函数的参数和指令的位置都压到栈里面，当从内核返回，从栈里弹出来的时候，就从这个函数开始，带着这些参数执行下去。

接下来我们就要进入内核了。内核里面对于 clone 系统调用的定义是这样的：

SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,

		 int __user *, parent_tidptr,

		 int __user *, child_tidptr,

		 unsigned long, tls)

{

	return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);

}

看到这里，发现了熟悉的面孔 _do_fork，是不是轻松了一些？上一节我们已经沿着它的逻辑过了一遍了。这里我们重点关注几个区别。

第一个是上面复杂的标志位设定，我们来看都影响了什么。

对于 copy_files，原来是调用 dup_fd 复制一个 files_struct 的，现在因为 CLONE_FILES 标识位变成将原来的 files_struct 引用计数加一。

static int copy_files(unsigned long clone_flags, struct task_struct *tsk)

{

	struct files_struct *oldf, *newf;

	oldf = current->files;

	if (clone_flags & CLONE_FILES) {

		atomic_inc(&oldf->count);

		goto out;

	}

	newf = dup_fd(oldf, &error);

	tsk->files = newf;

out:

	return error;

}

对于 copy_fs，原来是调用 copy_fs_struct 复制一个 fs_struct，现在因为 CLONE_FS 标识位变成将原来的 fs_struct 的用户数加一。

static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)

{

	struct fs_struct *fs = current->fs;

	if (clone_flags & CLONE_FS) {

		fs->users++;

		return 0;

	}

	tsk->fs = copy_fs_struct(fs);

	return 0;

}

对于 copy_sighand，原来是创建一个新的 sighand_struct，现在因为 CLONE_SIGHAND 标识位变成将原来的 sighand_struct 引用计数加一。

static int copy_sighand(unsigned long clone_flags, struct task_struct *tsk)

{

	struct sighand_struct *sig;

	if (clone_flags & CLONE_SIGHAND) {

		atomic_inc(&current->sighand->count);

		return 0;

	}

	sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);

	atomic_set(&sig->count, 1);

	memcpy(sig->action, current->sighand->action, sizeof(sig->action));

	return 0;

}

对于 copy_signal，原来是创建一个新的 signal_struct，现在因为 CLONE_THREAD 直接返回了。

static int copy_signal(unsigned long clone_flags, struct task_struct *tsk)

{

	struct signal_struct *sig;

	if (clone_flags & CLONE_THREAD)

		return 0;

	sig = kmem_cache_zalloc(signal_cachep, GFP_KERNEL);

	tsk->signal = sig;

    init_sigpending(&sig->shared_pending);

......

}

对于 copy_mm，原来是调用 dup_mm 复制一个 mm_struct，现在因为 CLONE_VM 标识位而直接指向了原来的 mm_struct

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)

{

	struct mm_struct *mm, *oldmm;

	oldmm = current->mm;

	if (clone_flags & CLONE_VM) {

		mmget(oldmm);

		mm = oldmm;

		goto good_mm;

	}

	mm = dup_mm(tsk);

good_mm:

	tsk->mm = mm;

	tsk->active_mm = mm;

	return 0;

}

第二个就是对于亲缘关系的影响，毕竟我们要识别多个线程是不是属于一个进程。

p->pid = pid_nr(pid);

if (clone_flags & CLONE_THREAD) {

	p->exit_signal = -1;

	p->group_leader = current->group_leader;

	p->tgid = current->tgid;

} else {

	if (clone_flags & CLONE_PARENT)

		p->exit_signal = current->group_leader->exit_signal;

	else

		p->exit_signal = (clone_flags & CSIGNAL);

	p->group_leader = p;

	p->tgid = p->pid;

}

	/* CLONE_PARENT re-uses the old parent */

if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {

	p->real_parent = current->real_parent;

	p->parent_exec_id = current->parent_exec_id;

} else {

	p->real_parent = current;

	p->parent_exec_id = current->self_exec_id;

}

从上面的代码可以看出，使用了 CLONE_THREAD 标识位之后，使得亲缘关系有了一定的变化。

如果是新进程，那这个进程的 group_leader 就是他自己，tgid 是它自己的 pid，这就完全重打锣鼓另开张了，自己是线程组的头。如果是新线程，group_leader 是当前进程的，group_leader，tgid 是当前进程的 tgid，也就是当前进程的 pid，这个时候还是拜原来进程为老大。

如果是新进程，新进程的 real_parent 是当前的进程，在进程树里面又见一辈人；如果是新线程，线程的 real_parent 是当前的进程的 real_parent，其实是平辈的。

第三，对于信号的处理，如何保证发给进程的信号虽然可以被一个线程处理，但是影响范围应该是整个进程的。例如，kill 一个进程，则所有线程都要被干掉。如果一个信号是发给一个线程的 pthread_kill，则应该只有线程能够收到。

在 copy_process 的主流程里面，无论是创建进程还是线程，都会初始化 struct sigpending pending，也就是每个 task_struct，都会有这样一个成员变量。这就是一个信号列表。如果这个 task_struct 是一个线程，这里面的信号就是发给这个线程的；如果这个 task_struct 是一个进程，这里面的信号是发给主线程的。

init_sigpending(&p->pending);

另外，上面 copy_signal 的时候，我们可以看到，在创建进程的过程中，会初始化 signal_struct 里面的 struct sigpending shared_pending。但是，在创建线程的过程中，连 signal_struct 都共享了。也就是说，整个进程里的所有线程共享一个 shared_pending，这也是一个信号列表，是发给整个进程的，哪个线程处理都一样。

init_sigpending(&sig->shared_pending);

至此，clone 在内核的调用完毕，要返回系统调用，回到用户态。

用户态执行线程

根据 __clone 的第一个参数，回到用户态也不是直接运行我们指定的那个函数，而是一个通用的 start_thread，这是所有线程在用户态的统一入口。

#define START_THREAD_DEFN \

  static int __attribute__ ((noreturn)) start_thread (void *arg)

START_THREAD_DEFN

{

    struct pthread *pd = START_THREAD_SELF;

    /* Run the code the user provided.  */

    THREAD_SETMEM (pd, result, pd->start_routine (pd->arg));

    /* Call destructors for the thread_local TLS variables.  */

    /* Run the destructor for the thread-local data.  */

    __nptl_deallocate_tsd ();

    if (__glibc_unlikely (atomic_decrement_and_test (&__nptl_nthreads)))

        /* This was the last thread.  */

        exit (0);

    __free_tcb (pd);

    __exit_thread ();

}

在 start_thread 入口函数中，才真正的调用用户提供的函数，在用户的函数执行完毕之后，会释放这个线程相关的数据。例如，线程本地数据 thread_local variables，线程数目也减一。如果这是最后一个线程了，就直接退出进程，另外 __free_tcb 用于释放 pthread。

void

internal_function

__free_tcb (struct pthread *pd)

{

  ......

  __deallocate_stack (pd);

}

void

internal_function

__deallocate_stack (struct pthread *pd)

{

  /* Remove the thread from the list of threads with user defined

     stacks.  */

  stack_list_del (&pd->list);

  /* Not much to do.  Just free the mmap()ed memory.  Note that we do

     not reset the 'used' flag in the 'tid' field.  This is done by

     the kernel.  If no thread has been created yet this field is

     still zero.  */

  if (__glibc_likely (! pd->user_stack))

    (void) queue_stack (pd);

}

__free_tcb 会调用 __deallocate_stack 来释放整个线程栈，这个线程栈要从当前使用线程栈的列表 stack_used 中拿下来，放到缓存的线程栈列表 stack_cache 中。

好了，整个线程的生命周期到这里就结束了。

总结时刻

线程的调用过程解析完毕了，我画了一个图总结一下。这个图对比了创建进程和创建线程在用户态和内核态的不同。

创建进程的话，调用的系统调用是 fork，在 copy_process 函数里面，会将五大结构 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct 都复制一遍，从此父进程和子进程各用各的数据结构。而创建线程的话，调用的是系统调用 clone，在 copy_process 函数里面， 五大结构仅仅是引用计数加一，也即线程共享进程的数据结构。

课堂练习

你知道如果查看一个进程的线程以及线程栈的使用情况吗？请找一下相关的命令和 API，尝试一下。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

20 | 内存管理（上）：为客户保密，规划进程内存空间布局

平时我们说计算机的“计算”两个字，其实说的就是两方面，第一，进程和线程对于 CPU 的使用；第二，对于内存的管理。所以从这一节开始，我们来看看内存管理的机制。

我之前说把内存管理比喻为一个项目组的“封闭开发的会议室”。很显然，如果不隔离，就会不安全、就会泄密，因而，我们说每个进程应该有自己的内存空间。内存空间都是独立的、相互隔离的。对于每个进程来讲，看起来应该都是独占的。

独享内存空间的原理

之前我只是简单地形容了一下。这一节，我们来深入分析一下，为啥一定要封闭开发呢？

执行一个项目，要依赖于项目执行计划书里的一行一行指令。项目只要按这些指令运行就行了。但是，在运行指令的过程中，免不了要产生一些数据。这些数据要保存在一个地方，这个地方就是内存，也就是我们刚才说的“会议室”。

和会议室一样，内存都被分成一块一块儿的，都编好了号。例如 3F-10，就是三楼十号会议室。内存也有这样一个地址。这个地址是实实在在的地址，通过这个地址我们就能够定位到物理内存的位置。

使用这种类型的地址会不会有问题呢？我们的二进制程序，也就是项目执行计划书，都是事先写好的，可以多次运行的。如果里面有个指令是，要把用户输入的数字保存在内存中，那就会有问题。

会产生什么问题呢？我举个例子你就明白了。如果我们使用那个实实在在的地址，3F-10，打开三个相同的程序，都执行到某一步。比方说，打开了三个计算器，用户在这三个程序的界面上分别输入了 10、100、1000。如果内存中的这个位置只能保存一个数，那应该保存哪个呢？这不就冲突了吗？

如果不用这个实实在在的地址，那应该怎么办呢？于是，我们就想出一个办法，那就是封闭开发。

每个项目的物理地址对于进程不可见，谁也不能直接访问这个物理地址。操作系统会给进程分配一个虚拟地址。所有进程看到的这个地址都是一样的，里面的内存都是从 0 开始编号。

在程序里面，指令写入的地址是虚拟地址。例如，位置为 10M 的内存区域，操作系统会提供一种机制，将不同进程的虚拟地址和不同内存的物理地址映射起来。

当程序要访问虚拟地址的时候，由内核的数据结构进行转换，转换成不同的物理地址，这样不同的进程运行的时候，写入的是不同的物理地址，这样就不会冲突了。

规划虚拟地址空间

通过以上的原理，我们可以看出，操作系统的内存管理，主要分为三个方面。

第一，物理内存的管理，相当于会议室管理员管理会议室。

第二，虚拟地址的管理，也即在项目组的视角，会议室的虚拟地址应该如何组织。

第三，虚拟地址和物理地址如何映射，也即会议室管理员如果管理映射表。

接下来，我们都会围绕虚拟地址和物理地址展开。这两个概念有点绕，很多时候你可能会犯糊涂：这个地方，我们用的是虚拟地址呢，还是物理地址呢？所以，请你在学习这一章节的时候，时刻问自己这个问题。

我们还是切换到外包公司老板的角度。现在，如果让你规划一下，到底应该怎么管理会议室，你会怎么办？是不是可以先听听项目组的意见，收集一下需求。

于是，你看到了项目组的项目执行计划书是这样一个程序。

#include <stdio.h>

#include <stdlib.h>

int max_length = 128;

char * generate(int length){

  int i;

  char * buffer = (char*) malloc (length+1);

  if (buffer == NULL)

    return NULL;

  for (i=0; i<length; i++){

    buffer[i]=rand()%26+'a';

  }

  buffer[length]='\0';

  return buffer;

}

int main(int argc, char *argv[])

{

  int num;

  char * buffer;

  printf ("Input the string length : ");

  scanf ("%d", &num);

  if(num > max_length){

    num = max_length;

  }

  buffer = generate(num);

  printf ("Random string is: %s\n",buffer);

  free (buffer);

  return 0;

}

这个程序比较简单，就是根据用户输入的整数来生成字符串，最长是 128。由于字符串的长度不是固定的，因而不能提前知道，需要动态地分配内存，使用 malloc 函数。当然用完了需要释放内存，这就要使用 free 函数。

我们来总结一下，这个简单的程序使用哪些内存的几种方式：

代码需要放在内存里面；

全局变量，例如 max_length；

常量字符串"Input the string length : "；

函数栈，例如局部变量 num 是作为参数传给 generate 函数的，这里面涉及了函数调用，局部变量，函数参数等都是保存在函数栈上面的；

堆，malloc 分配的内存在堆里面；

这里面涉及对 glibc 的调用，所以 glibc 的代码是以 so 文件的形式存在的，也需要放在内存里面。

这就完了吗？还没有呢，别忘了 malloc 会调用系统调用，进入内核，所以这个程序一旦运行起来，内核部分还需要分配内存：

内核的代码要在内存里面；

内核中也有全局变量；

每个进程都要有一个 task_struct；

每个进程还有一个内核栈；

在内核里面也有动态分配的内存；

虚拟地址到物理地址的映射表放在哪里？

竟然收集了这么多的需求，看来做个内存管理还是挺复杂的啊！

我们现在来问一下自己，上面的这些内存里面的数据，应该用虚拟地址访问呢？还是应该用物理地址访问呢？

你可能会说，这很简单嘛。用户态的用虚拟地址访问，内核态的用物理地址访问。其实不是的。你有没有想过，内核里面的代码如果都使用物理地址，就相当于公司里的项目管理部门、文档管理部门都可以直接使用实际的地址访问会议室，这对于会议室管理部门来讲，简直是一个“灾难”。因为一旦到了内核，大家对于会议室的访问都脱离了会议室管理部门的控制。

所以，我们应该清楚一件事情，真正能够使用会议室的物理地址的，只有会议室管理部门，所有其他部门的行为涉及访问会议室的，都要统统使用虚拟地址，统统到会议室管理部门哪里转换一道，才能进行统一的控制。

我上面列举出来的，对于内存的访问，用户态的进程使用虚拟地址，这点毫无疑问，内核态的也基本都是使用虚拟地址，只有最后一项容易让人产生疑问。虚拟地址到物理地址的映射表，这个感觉起来是内存管理模块的一部分，这个是“实”是“虚”呢？这个问题先保留，我们暂不讨论，放到内存映射那一节见分晓。

既然都是虚拟地址，我们就先不管映射到物理地址以后是如何布局的，反正现在至少从“虚”的角度来看，这一大片连续的内存空间都是我的了。

如果是 32 位，有 2^32 = 4G 的内存空间都是我的，不管内存是不是真的有 4G。如果是 64 位，在 x86_64 下面，其实只使用了 48 位，那也挺恐怖的。48 位地址长度也就是对应了 256TB 的地址空间。我都没怎么见过 256T 的硬盘，别说是内存了。

现在，你可比世界首富房子还大。虽然是虚拟的。下面你可以尽情地去排列咱们要放的东西。请记住，现在你是站在一个进程的角度去看这个虚拟的空间，不用管其他进程。

首先，这么大的虚拟空间一切二，一部分用来放内核的东西，称为内核空间，一部分用来放进程的东西，称为用户空间。用户空间在下，在低地址，我们假设就是 0 号到 29 号会议室；内核空间在上，在高地址，我们假设是 30 号到 39 号会议室。这两部分空间的分界线因为 32 位和 64 位的不同而不同，我们这里不深究。

对于普通进程来说，内核空间的那部分虽然虚拟地址在那里，但是不能访问。这就像作为普通员工，你明明知道财务办公室在这个 30 号会议室门里面，但是门上挂着“闲人免进”，你只能在自己的用户空间里面折腾。

我们从最低位开始排起，先是Text Segment、Data Segment 和 BSS Segment。Text Segment 是存放二进制可执行代码的位置，Data Segment 存放静态常量，BSS Segment 存放未初始化的静态变量。是不是觉得这几个名字很熟悉？没错，咱们前面讲 ELF 格式的时候提到过，在二进制执行文件里面，就有这三个部分。这里就是把二进制执行文件的三个部分加载到内存里面。

接下来是堆（Heap）段。堆是往高地址增长的，是用来动态分配内存的区域，malloc 就是在这里面分配的。

接下来的区域是Memory Mapping Segment。这块地址可以用来把文件映射进内存用的，如果二进制的执行文件依赖于某个动态链接库，就是在这个区域里面将 so 文件映射到了内存中。

再下面就是栈（Stack）地址段。主线程的函数调用的函数栈就是用这里的。

如果普通进程还想进一步访问内核空间，是没办法的，只能眼巴巴地看着。如果需要进行更高权限的工作，就需要调用系统调用，进入内核。

一旦进入了内核，就换了一副视角。刚才是普通进程的视角，觉着整个空间是它独占的，没有其他进程存在。当然另一个进程也这样认为，因为它们互相看不到对方。这也就是说，不同进程的 0 号到 29 号会议室放的东西都不一样。

但是到了内核里面，无论是从哪个进程进来的，看到的都是同一个内核空间，看到的都是同一个进程列表。虽然内核栈是各用个的，但是如果想知道的话，还是能够知道每个进程的内核栈在哪里的。所以，如果要访问一些公共的数据结构，需要进行锁保护。也就是说，不同的进程进入到内核后，进入的 30 号到 39 号会议室是同一批会议室。

内核的代码访问内核的数据结构，大部分的情况下都是使用虚拟地址的，虽然内核代码权限很大，但是能够使用的虚拟地址范围也只能在内核空间，也即内核代码访问内核数据结构。只能用 30 号到 39 号这些编号，不能用 0 到 29 号，因为这些是被进程空间占用的。而且，进程有很多个。你现在在内核，但是你不知道当前指的 0 号是哪个进程的 0 号。

在内核里面也会有内核的代码，同样有 Text Segment、Data Segment 和 BSS Segment，别忘了咱们讲内核启动的时候，内核代码也是 ELF 格式的。

内核的其他数据结构的分配方式就比较复杂了，这一节我们先不讲。

总结时刻

好了，这一节就到这里了，我们来总结一下。这一节我们讲了为什么要独享内存空间，并且站在老板的角度，设计了虚拟地址空间应该存放的数据。

通过这一节，你应该知道，一个内存管理系统至少应该做三件事情：

第一，虚拟内存空间的管理，每个进程看到的是独立的、互不干扰的虚拟地址空间；

第二，物理内存的管理，物理内存地址只有内存管理模块能够使用；

第三，内存映射，需要将虚拟内存和物理内存映射、关联起来。

课堂练习

这一节我们讲了进程内存空间的布局，请找一下，有没有一个命令可以查看进程内存空间的布局，打印出来看一下，这对我们后面解析非常有帮助。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

21 | 内存管理（下）：为客户保密，项目组独享会议室封闭开发

上一节，我们讲了虚拟空间的布局。接下来，我们需要知道，如何将其映射成为物理地址呢？

你可能已经想到了，咱们前面讲 x86 CPU 的时候，讲过分段机制，咱们规划虚拟空间的时候，也是将空间分成多个段进行保存。

那就直接用分段机制呗。我们来看看分段机制的原理。

分段机制下的虚拟地址由两部分组成，段选择子和段内偏移量。段选择子就保存在咱们前面讲过的段寄存器里面。段选择子里面最重要的是段号，用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。虚拟地址中的段内偏移量应该位于 0 和段界限之间。如果段内偏移量是合法的，就将段基地址加上段内偏移量得到物理内存地址。

例如，我们将上面的虚拟空间分成以下 4 个段，用 0～3 来编号。每个段在段表中有一个项，在物理空间中，段的排列如下图的右边所示。

如果要访问段 2 中偏移量 600 的虚拟地址，我们可以计算出物理地址为，段 2 基地址 2000 + 偏移量 600 = 2600。

多好的机制啊！我们来看看 Linux 是如何使用这个机制的。

在 Linux 里面，段表全称段描述符表（segment descriptors），放在全局描述符表 GDT（Global Descriptor Table）里面，会有下面的宏来初始化段描述符表里面的表项。

#define GDT_ENTRY_INIT(flags, base, limit) { { { \

		.a = ((limit) & 0xffff) | (((base) & 0xffff) << 16), \

		.b = (((base) & 0xff0000) >> 16) | (((flags) & 0xf0ff) << 8) | \

			((limit) & 0xf0000) | ((base) & 0xff000000), \

	} } }

一个段表项由段基地址 base、段界限 limit，还有一些标识符组成。

DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = {

#ifdef CONFIG_X86_64

	[GDT_ENTRY_KERNEL32_CS]		= GDT_ENTRY_INIT(0xc09b, 0, 0xfffff),

	[GDT_ENTRY_KERNEL_CS]		= GDT_ENTRY_INIT(0xa09b, 0, 0xfffff),

	[GDT_ENTRY_KERNEL_DS]		= GDT_ENTRY_INIT(0xc093, 0, 0xfffff),

	[GDT_ENTRY_DEFAULT_USER32_CS]	= GDT_ENTRY_INIT(0xc0fb, 0, 0xfffff),

	[GDT_ENTRY_DEFAULT_USER_DS]	= GDT_ENTRY_INIT(0xc0f3, 0, 0xfffff),

	[GDT_ENTRY_DEFAULT_USER_CS]	= GDT_ENTRY_INIT(0xa0fb, 0, 0xfffff),

#else

	[GDT_ENTRY_KERNEL_CS]		= GDT_ENTRY_INIT(0xc09a, 0, 0xfffff),

	[GDT_ENTRY_KERNEL_DS]		= GDT_ENTRY_INIT(0xc092, 0, 0xfffff),

	[GDT_ENTRY_DEFAULT_USER_CS]	= GDT_ENTRY_INIT(0xc0fa, 0, 0xfffff),

	[GDT_ENTRY_DEFAULT_USER_DS]	= GDT_ENTRY_INIT(0xc0f2, 0, 0xfffff),

......

#endif

} };

EXPORT_PER_CPU_SYMBOL_GPL(gdt_page);

这里面对于 64 位的和 32 位的，都定义了内核代码段、内核数据段、用户代码段和用户数据段。

另外，还会定义下面四个段选择子，指向上面的段描述符表项。这四个段选择子看着是不是有点眼熟？咱们讲内核初始化的时候，启动第一个用户态的进程，就是将这四个值赋值给段寄存器。

#define __KERNEL_CS			(GDT_ENTRY_KERNEL_CS*8)

#define __KERNEL_DS			(GDT_ENTRY_KERNEL_DS*8)

#define __USER_DS			(GDT_ENTRY_DEFAULT_USER_DS*8 + 3)

#define __USER_CS			(GDT_ENTRY_DEFAULT_USER_CS*8 + 3)

通过分析，我们发现，所有的段的起始地址都是一样的，都是 0。这算哪门子分段嘛！所以，在 Linux 操作系统中，并没有使用到全部的分段功能。那分段是不是完全没有用处呢？分段可以做权限审核，例如用户态 DPL 是 3，内核态 DPL 是 0。当用户态试图访问内核态的时候，会因为权限不足而报错。

其实 Linux 倾向于另外一种从虚拟地址到物理地址的转换方式，称为分页（Paging）。

对于物理内存，操作系统把它分成一块一块大小相同的页，这样更方便管理，例如有的内存页面长时间不用了，可以暂时写到硬盘上，称为换出。一旦需要的时候，再加载进来，叫作换入。这样可以扩大可用物理内存的大小，提高物理内存的利用率。

这个换入和换出都是以页为单位的。页面的大小一般为 4KB。为了能够定位和访问每个页，需要有个页表，保存每个页的起始地址，再加上在页内的偏移量，组成线性地址，就能对于内存中的每个位置进行访问了。

虚拟地址分为两部分，页号和页内偏移。页号作为页表的索引，页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。

下面的图，举了一个简单的页表的例子，虚拟内存中的页通过页表映射为了物理内存中的页。

32 位环境下，虚拟地址空间共 4GB。如果分成 4KB 一个页，那就是 1M 个页。每个页表项需要 4 个字节来存储，那么整个 4GB 空间的映射就需要 4MB 的内存来存储映射表。如果每个进程都有自己的映射表，100 个进程就需要 400MB 的内存。对于内核来讲，有点大了 。

页表中所有页表项必须提前建好，并且要求是连续的。如果不连续，就没有办法通过虚拟地址里面的页号找到对应的页表项了。

那怎么办呢？我们可以试着将页表再分页，4G 的空间需要 4M 的页表来存储映射。我们把这 4M 分成 1K（1024）个 4K，每个 4K 又能放在一页里面，这样 1K 个 4K 就是 1K 个页，这 1K 个页也需要一个表进行管理，我们称为页目录表，这个页目录表里面有 1K 项，每项 4 个字节，页目录表大小也是 4K。

页目录有 1K 项，用 10 位就可以表示访问页目录的哪一项。这一项其实对应的是一整页的页表项，也即 4K 的页表项。每个页表项也是 4 个字节，因而一整页的页表项是 1K 个。再用 10 位就可以表示访问页表项的哪一项，页表项中的一项对应的就是一个页，是存放数据的页，这个页的大小是 4K，用 12 位可以定位这个页内的任何一个位置。

这样加起来正好 32 位，也就是用前 10 位定位到页目录表中的一项。将这一项对应的页表取出来共 1k 项，再用中间 10 位定位到页表中的一项，将这一项对应的存放数据的页取出来，再用最后 12 位定位到页中的具体位置访问数据。

你可能会问，如果这样的话，映射 4GB 地址空间就需要 4MB+4KB 的内存，这样不是更大了吗？ 当然如果页是满的，当时是更大了，但是，我们往往不会为一个进程分配那么多内存。

比如说，上面图中，我们假设只给这个进程分配了一个数据页。如果只使用页表，也需要完整的 1M 个页表项共 4M 的内存，但是如果使用了页目录，页目录需要 1K 个全部分配，占用内存 4K，但是里面只有一项使用了。到了页表项，只需要分配能够管理那个数据页的页表项页就可以了，也就是说，最多 4K，这样内存就节省多了。

当然对于 64 位的系统，两级肯定不够了，就变成了四级目录，分别是全局页目录项 PGD（Page Global Directory）、上层页目录项 PUD（Page Upper Directory）、中间页目录项 PMD（Page Middle Directory）和页表项 PTE（Page Table Entry）。

总结时刻

这一节我们讲了分段机制、分页机制以及从虚拟地址到物理地址的映射方式。总结一下这两节，我们可以把内存管理系统精细化为下面三件事情：

第一，虚拟内存空间的管理，将虚拟内存分成大小相等的页；

第二，物理内存的管理，将物理内存分成大小相等的页；

第三，内存映射，将虚拟内存也和物理内存也映射起来，并且在内存紧张的时候可以换出到硬盘中。

课堂练习

这一节我们说一个页的大小为 4K，有时候我们需要为应用配置大页（HugePage）。请你查一下大页的大小及配置方法，咱们后面会用到。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

22 | 进程空间管理：项目组还可以自行布置会议室

上两节，我们讲了内存管理的三个方面，虚拟内存空间的管理、物理内存的管理以及内存映射。你现在对进程内存空间的整体布局应该有了一个大致的了解。今天我们就来详细看看第一个方面，进程的虚拟内存空间是如何管理的。

32 位系统和 64 位系统的内存布局有的地方相似，有的地方差别比较大，接下来介绍的时候，请你注意区分。好，我们现在正式开始！

用户态和内核态的划分

进程的虚拟地址空间，其实就是站在项目组的角度来看内存，所以我们就从 task_struct 出发来看。这里面有一个 struct mm_struct 结构来管理内存。

struct mm_struct		*mm;

在 struct mm_struct 里面，有这样一个成员变量：

unsigned long task_size;		/* size of task vm space */

我们之前讲过，整个虚拟内存空间要一分为二，一部分是用户态地址空间，一部分是内核态地址空间，那这两部分的分界线在哪里呢？这就要 task_size 来定义。

对于 32 位的系统，内核里面是这样定义 TASK_SIZE 的：

#ifdef CONFIG_X86_32

/*

 * User space process size: 3GB (default).

 */

#define TASK_SIZE		PAGE_OFFSET

#define TASK_SIZE_MAX		TASK_SIZE

/*

config PAGE_OFFSET

        hex

        default 0xC0000000

        depends on X86_32

*/

#else

/*

 * User space process size. 47bits minus one guard page.

*/

#define TASK_SIZE_MAX	((1UL << 47) - PAGE_SIZE)

#define TASK_SIZE		(test_thread_flag(TIF_ADDR32) ? \

					IA32_PAGE_OFFSET : TASK_SIZE_MAX)

......

当执行一个新的进程的时候，会做以下的设置：

current->mm->task_size = TASK_SIZE;

对于 32 位系统，最大能够寻址 2^32=4G，其中用户态虚拟地址空间是 3G，内核态是 1G。

对于 64 位系统，虚拟地址只使用了 48 位。就像代码里面写的一样，1 左移了 47 位，就相当于 48 位地址空间一半的位置，0x0000800000000000，然后减去一个页，就是 0x00007FFFFFFFF000，共 128T。同样，内核空间也是 128T。内核空间和用户空间之间隔着很大的空隙，以此来进行隔离。

用户态布局

我们先来看用户态虚拟空间的布局。

之前我们讲了用户态虚拟空间里面有几类数据，例如代码、全局变量、堆、栈、内存映射区等。在 struct mm_struct 里面，有下面这些变量定义了这些区域的统计信息和位置。

unsigned long mmap_base;	/* base of mmap area */

unsigned long total_vm;		/* Total pages mapped */

unsigned long locked_vm;	/* Pages that have PG_mlocked set */

unsigned long pinned_vm;	/* Refcount permanently increased */

unsigned long data_vm;		/* VM_WRITE & ~VM_SHARED & ~VM_STACK */

unsigned long exec_vm;		/* VM_EXEC & ~VM_WRITE & ~VM_STACK */

unsigned long stack_vm;		/* VM_STACK */

unsigned long start_code, end_code, start_data, end_data;

unsigned long start_brk, brk, start_stack;

unsigned long arg_start, arg_end, env_start, env_end;

其中，total_vm 是总共映射的页的数目。我们知道，这么大的虚拟地址空间，不可能都有真实内存对应，所以这里是映射的数目。当内存吃紧的时候，有些页可以换出到硬盘上，有的页因为比较重要，不能换出。locked_vm 就是被锁定不能换出，pinned_vm 是不能换出，也不能移动。

data_vm 是存放数据的页的数目，exec_vm 是存放可执行文件的页的数目，stack_vm 是栈所占的页的数目。

start_code 和 end_code 表示可执行代码的开始和结束位置，start_data 和 end_data 表示已初始化数据的开始位置和结束位置。

start_brk 是堆的起始位置，brk 是堆当前的结束位置。前面咱们讲过 malloc 申请一小块内存的话，就是通过改变 brk 位置实现的。

start_stack 是栈的起始位置，栈的结束位置在寄存器的栈顶指针中。

arg_start 和 arg_end 是参数列表的位置， env_start 和 env_end 是环境变量的位置。它们都位于栈中最高地址的地方。

mmap_base 表示虚拟地址空间中用于内存映射的起始地址。一般情况下，这个空间是从高地址到低地址增长的。前面咱们讲 malloc 申请一大块内存的时候，就是通过 mmap 在这里映射一块区域到物理内存。咱们加载动态链接库 so 文件，也是在这个区域里面，映射一块区域到 so 文件。

这下所有用户态的区域的位置基本上都描述清楚了。整个布局就像下面这张图这样。虽然 32 位和 64 位的空间相差很大，但是区域的类别和布局是相似的。

除了位置信息之外，struct mm_struct 里面还专门有一个结构 vm_area_struct，来描述这些区域的属性。

struct vm_area_struct *mmap;		/* list of VMAs */

struct rb_root mm_rb;

这里面一个是单链表，用于将这些区域串起来。另外还有一个红黑树。又是这个数据结构，在进程调度的时候我们用的也是红黑树。它的好处就是查找和修改都很快。这里用红黑树，就是为了快速查找一个内存区域，并在需要改变的时候，能够快速修改。

struct vm_area_struct {

	/* The first cache line has the info for VMA tree walking. */

	unsigned long vm_start;		/* Our start address within vm_mm. */

	unsigned long vm_end;		/* The first byte after our end address within vm_mm. */

	/* linked list of VM areas per task, sorted by address */

	struct vm_area_struct *vm_next, *vm_prev;

	struct rb_node vm_rb;

	struct mm_struct *vm_mm;	/* The address space we belong to. */

	struct list_head anon_vma_chain; /* Serialized by mmap_sem &

					  * page_table_lock */

	struct anon_vma *anon_vma;	/* Serialized by page_table_lock */

	/* Function pointers to deal with this struct. */

	const struct vm_operations_struct *vm_ops;

	struct file * vm_file;		/* File we map to (can be NULL). */

	void * vm_private_data;		/* was vm_pte (shared mem) */

} __randomize_layout;

vm_start 和 vm_end 指定了该区域在用户空间中的起始和结束地址。vm_next 和 vm_prev 将这个区域串在链表上。vm_rb 将这个区域放在红黑树上。vm_ops 里面是对这个内存区域可以做的操作的定义。

虚拟内存区域可以映射到物理内存，也可以映射到文件，映射到物理内存的时候称为匿名映射，anon_vma 中，anoy 就是 anonymous，匿名的意思，映射到文件就需要有 vm_file 指定被映射的文件。

那这些 vm_area_struct 是如何和上面的内存区域关联的呢？

这个事情是在 load_elf_binary 里面实现的。没错，就是它。加载内核的是它，启动第一个用户态进程 init 的是它，fork 完了以后，调用 exec 运行一个二进制程序的也是它。

当 exec 运行一个二进制程序的时候，除了解析 ELF 的格式之外，另外一个重要的事情就是建立内存映射。

static int load_elf_binary(struct linux_binprm *bprm)

{

......

  setup_new_exec(bprm);

......

  retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),

				 executable_stack);

......

  error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,

				elf_prot, elf_flags, total_size);

......

  retval = set_brk(elf_bss, elf_brk, bss_prot);

......

  elf_entry = load_elf_interp(&loc->interp_elf_ex,

					    interpreter,

					    &interp_map_addr,

					    load_bias, interp_elf_phdata);

......

  current->mm->end_code = end_code;

  current->mm->start_code = start_code;

  current->mm->start_data = start_data;

  current->mm->end_data = end_data;

  current->mm->start_stack = bprm->p;

......

}

load_elf_binary 会完成以下的事情：

调用 setup_new_exec，设置内存映射区 mmap_base；

调用 setup_arg_pages，设置栈的 vm_area_struct，这里面设置了 mm->arg_start 是指向栈底的，current->mm->start_stack 就是栈底；

elf_map 会将 ELF 文件中的代码部分映射到内存中来；

set_brk 设置了堆的 vm_area_struct，这里面设置了 current->mm->start_brk = current->mm->brk，也即堆里面还是空的；

load_elf_interp 将依赖的 so 映射到内存中的内存映射区域。

最终就形成下面这个内存映射图。

映射完毕后，什么情况下会修改呢？

第一种情况是函数的调用，涉及函数栈的改变，主要是改变栈顶指针。

第二种情况是通过 malloc 申请一个堆内的空间，当然底层要么执行 brk，要么执行 mmap。关于内存映射的部分，我们后面的章节讲，这里我们重点看一下 brk 是怎么做的。

brk 系统调用实现的入口是 sys_brk 函数，就像下面代码定义的一样。

SYSCALL_DEFINE1(brk, unsigned long, brk)

{

	unsigned long retval;

	unsigned long newbrk, oldbrk;

	struct mm_struct *mm = current->mm;

	struct vm_area_struct *next;

......

	newbrk = PAGE_ALIGN(brk);

	oldbrk = PAGE_ALIGN(mm->brk);

	if (oldbrk == newbrk)

		goto set_brk;

	/* Always allow shrinking brk. */

	if (brk <= mm->brk) {

		if (!do_munmap(mm, newbrk, oldbrk-newbrk, &uf))

			goto set_brk;

		goto out;

	}

	/* Check against existing mmap mappings. */

	next = find_vma(mm, oldbrk);

	if (next && newbrk + PAGE_SIZE > vm_start_gap(next))

		goto out;

	/* Ok, looks good - let it rip. */

	if (do_brk(oldbrk, newbrk-oldbrk, &uf) < 0)

		goto out;

set_brk:

	mm->brk = brk;

......

	return brk;

out:

	retval = mm->brk;

	return retval

前面我们讲过了，堆是从低地址向高地址增长的，sys_brk 函数的参数 brk 是新的堆顶位置，而当前的 mm->brk 是原来堆顶的位置。

首先要做的第一个事情，将原来的堆顶和现在的堆顶，都按照页对齐地址，然后比较大小。如果两者相同，说明这次增加的堆的量很小，还在一个页里面，不需要另行分配页，直接跳到 set_brk 那里，设置 mm->brk 为新的 brk 就可以了。

如果发现新旧堆顶不在一个页里面，麻烦了，这下要跨页了。如果发现新堆顶小于旧堆顶，这说明不是新分配内存了，而是释放内存了，释放的还不小，至少释放了一页，于是调用 do_munmap 将这一页的内存映射去掉。

如果堆将要扩大，就要调用 find_vma。如果打开这个函数，看到的是对红黑树的查找，找到的是原堆顶所在的 vm_area_struct 的下一个 vm_area_struct，看当前的堆顶和下一个 vm_area_struct 之间还能不能分配一个完整的页。如果不能，没办法只好直接退出返回，内存空间都被占满了。

如果还有空间，就调用 do_brk 进一步分配堆空间，从旧堆顶开始，分配计算出的新旧堆顶之间的页数。

static int do_brk(unsigned long addr, unsigned long len, struct list_head *uf)

{

	return do_brk_flags(addr, len, 0, uf);

}

static int do_brk_flags(unsigned long addr, unsigned long request, unsigned long flags, struct list_head *uf)

{

	struct mm_struct *mm = current->mm;

	struct vm_area_struct *vma, *prev;

	unsigned long len;

	struct rb_node **rb_link, *rb_parent;

	pgoff_t pgoff = addr >> PAGE_SHIFT;

	int error;

	len = PAGE_ALIGN(request);

......

	find_vma_links(mm, addr, addr + len, &prev, &rb_link,

			      &rb_parent);

......

	vma = vma_merge(mm, prev, addr, addr + len, flags,

			NULL, NULL, pgoff, NULL, NULL_VM_UFFD_CTX);

	if (vma)

		goto out;

......

	vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);

	INIT_LIST_HEAD(&vma->anon_vma_chain);

	vma->vm_mm = mm;

	vma->vm_start = addr;

	vma->vm_end = addr + len;

	vma->vm_pgoff = pgoff;

	vma->vm_flags = flags;

	vma->vm_page_prot = vm_get_page_prot(flags);

	vma_link(mm, vma, prev, rb_link, rb_parent);

out:

	perf_event_mmap(vma);

	mm->total_vm += len >> PAGE_SHIFT;

	mm->data_vm += len >> PAGE_SHIFT;

	if (flags & VM_LOCKED)

		mm->locked_vm += (len >> PAGE_SHIFT);

	vma->vm_flags |= VM_SOFTDIRTY;

	return 0;

在 do_brk 中，调用 find_vma_links 找到将来的 vm_area_struct 节点在红黑树的位置，找到它的父节点、前序节点。接下来调用 vma_merge，看这个新节点是否能够和现有树中的节点合并。如果地址是连着的，能够合并，则不用创建新的 vm_area_struct 了，直接跳到 out，更新统计值即可；如果不能合并，则创建新的 vm_area_struct，既加到 anon_vma_chain 链表中，也加到红黑树中。

内核态的布局

用户态虚拟空间分析完毕，接下来我们分析内核态虚拟空间。

内核态的虚拟空间和某一个进程没有关系，所有进程通过系统调用进入到内核之后，看到的虚拟地址空间都是一样的。

这里强调一下，千万别以为到了内核里面，咱们就会直接使用物理内存地址了，想当然地认为下面讨论的都是物理内存地址，不是的，这里讨论的还是虚拟内存地址，但是由于内核总是涉及管理物理内存，因而总是隐隐约约发生关系，所以这里必须思路清晰，分清楚物理内存地址和虚拟内存地址。

在内核态，32 位和 64 位的布局差别比较大，主要是因为 32 位内核态空间太小了。

我们来看 32 位的内核态的布局。

32 位的内核态虚拟地址空间一共就 1G，占绝大部分的前 896M，我们称为直接映射区。

所谓的直接映射区，就是这一块空间是连续的，和物理内存是非常简单的映射关系，其实就是虚拟内存地址减去 3G，就得到物理内存的位置。

在内核里面，有两个宏：

__pa(vaddr) 返回与虚拟地址 vaddr 相关的物理地址；

__va(paddr) 则计算出对应于物理地址 paddr 的虚拟地址。

    #define __va(x)			((void *)((unsigned long)(x)+PAGE_OFFSET))

    #define __pa(x)		__phys_addr((unsigned long)(x))

    #define __phys_addr(x)		__phys_addr_nodebug(x)

    #define __phys_addr_nodebug(x)	((x) - PAGE_OFFSET)

但是你要注意，这里虚拟地址和物理地址发生了关联关系，在物理内存的开始的 896M 的空间，会被直接映射到 3G 至 3G+896M 的虚拟地址，这样容易给你一种感觉，是这些内存访问起来和物理内存差不多，别这样想，在大部分情况下，对于这一段内存的访问，在内核中，还是会使用虚拟地址的，并且将来也会为这一段空间建设页表，对这段地址的访问也会走上一节我们讲的分页地址的流程，只不过页表里面比较简单，是直接的一一对应而已。

这 896M 还需要仔细分解。在系统启动的时候，物理内存的前 1M 已经被占用了，从 1M 开始加载内核代码段，然后就是内核的全局变量、BSS 等，也是 ELF 里面涵盖的。这样内核的代码段，全局变量，BSS 也就会被映射到 3G 后的虚拟地址空间里面。具体的物理内存布局可以查看 /proc/iomem。

在内核运行的过程中，如果碰到系统调用创建进程，会创建 task_struct 这样的实例，内核的进程管理代码会将实例创建在 3G 至 3G+896M 的虚拟空间中，当然也会被放在物理内存里面的前 896M 里面，相应的页表也会被创建。

在内核运行的过程中，会涉及内核栈的分配，内核的进程管理的代码会将内核栈创建在 3G 至 3G+896M 的虚拟空间中，当然也就会被放在物理内存里面的前 896M 里面，相应的页表也会被创建。

896M 这个值在内核中被定义为 high_memory，在此之上常称为“高端内存”。这是个很笼统的说法，到底是虚拟内存的 3G+896M 以上的是高端内存，还是物理内存 896M 以上的是高端内存呢？

这里仍然需要辨析一下，高端内存是物理内存的概念。它仅仅是内核中的内存管理模块看待物理内存的时候的概念。前面我们也说过，在内核中，除了内存管理模块直接操作物理地址之外，内核的其他模块，仍然要操作虚拟地址，而虚拟地址是需要内存管理模块分配和映射好的。

假设咱们的电脑有 2G 内存，现在如果内核的其他模块想要访问物理内存 1.5G 的地方，应该怎么办呢？如果你觉得，我有 32 位的总线，访问个 2G 还不小菜一碟，这就错了。

首先，你不能使用物理地址。你需要使用内存管理模块给你分配的虚拟地址，但是虚拟地址的 0 到 3G 已经被用户态进程占用去了，你作为内核不能使用。因为你写 1.5G 的虚拟内存位置，一方面你不知道应该根据哪个进程的页表进行映射；另一方面，就算映射了也不是你真正想访问的物理内存的地方，所以你发现你作为内核，能够使用的虚拟内存地址，只剩下 1G 减去 896M 的空间了。

于是，我们可以将剩下的虚拟内存地址分成下面这几个部分。

在 896M 到 VMALLOC_START 之间有 8M 的空间。

VMALLOC_START 到 VMALLOC_END 之间称为内核动态映射空间，也即内核想像用户态进程一样 malloc 申请内存，在内核里面可以使用 vmalloc。假设物理内存里面，896M 到 1.5G 之间已经被用户态进程占用了，并且映射关系放在了进程的页表中，内核 vmalloc 的时候，只能从分配物理内存 1.5G 开始，就需要使用这一段的虚拟地址进行映射，映射关系放在专门给内核自己用的页表里面。

PKMAP_BASE 到 FIXADDR_START 的空间称为持久内核映射。使用 alloc_pages() 函数的时候，在物理内存的高端内存得到 struct page 结构，可以调用 kmap 将其在映射到这个区域。

FIXADDR_START 到 FIXADDR_TOP(0xFFFF F000) 的空间，称为固定映射区域，主要用于满足特殊需求。

在最后一个区域可以通过 kmap_atomic 实现临时内核映射。假设用户态的进程要映射一个文件到内存中，先要映射用户态进程空间的一段虚拟地址到物理内存，然后将文件内容写入这个物理内存供用户态进程访问。给用户态进程分配物理内存页可以通过 alloc_pages()，分配完毕后，按说将用户态进程虚拟地址和物理内存的映射关系放在用户态进程的页表中，就完事大吉了。这个时候，用户态进程可以通过用户态的虚拟地址，也即 0 至 3G 的部分，经过页表映射后访问物理内存，并不需要内核态的虚拟地址里面也划出一块来，映射到这个物理内存页。但是如果要把文件内容写入物理内存，这件事情要内核来干了，这就只好通过 kmap_atomic 做一个临时映射，写入物理内存完毕后，再 kunmap_atomic 来解映射即可。

32 位的内核态布局我们看完了，接下来我们再来看 64 位的内核布局。

其实 64 位的内核布局反而简单，因为虚拟空间实在是太大了，根本不需要所谓的高端内存，因为内核是 128T，根本不可能有物理内存超过这个值。

64 位的内存布局如图所示。

64 位的内核主要包含以下几个部分。

从 0xffff800000000000 开始就是内核的部分，只不过一开始有 8T 的空档区域。

从 __PAGE_OFFSET_BASE(0xffff880000000000) 开始的 64T 的虚拟地址空间是直接映射区域，也就是减去 PAGE_OFFSET 就是物理地址。虚拟地址和物理地址之间的映射在大部分情况下还是会通过建立页表的方式进行映射。

从 VMALLOC_START（0xffffc90000000000）开始到 VMALLOC_END（0xffffe90000000000）的 32T 的空间是给 vmalloc 的。

从 VMEMMAP_START（0xffffea0000000000）开始的 1T 空间用于存放物理页面的描述结构 struct page 的。

从 __START_KERNEL_map（0xffffffff80000000）开始的 512M 用于存放内核代码段、全局变量、BSS 等。这里对应到物理内存开始的位置，减去 __START_KERNEL_map 就能得到物理内存的地址。这里和直接映射区有点像，但是不矛盾，因为直接映射区之前有 8T 的空当区域，早就过了内核代码在物理内存中加载的位置。

到这里内核中虚拟空间的布局就介绍完了。

总结时刻

还记得咱们上一节咱们收集项目组需求的时候，我们知道一个进程要运行起来需要以下的内存结构。

用户态：

代码段、全局变量、BSS

函数栈

堆

内存映射区

内核态：

内核的代码、全局变量、BSS

内核数据结构例如 task_struct

内核栈

内核中动态分配的内存

现在这些是不是已经都有了着落？

我画了一个图，总结一下进程运行状态在 32 位下对应关系。

对于 64 位的对应关系，只是稍有区别，我这里也画了一个图，方便你对比理解。

课堂练习

请通过命令行工具查看进程虚拟内存的布局和物理内存的布局，对照着这一节讲的内容，看一下各部分的位置。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

23 | 物理内存管理（上）：会议室管理员如何分配会议室？

前一节，我们讲了如何从项目经理的角度看内存，看到的是虚拟地址空间，这些虚拟的地址，总是要映射到物理的页面。这一节，我们来看，物理的页面是如何管理的。

物理内存的组织方式

前面咱们讲虚拟内存，涉及物理内存的映射的时候，我们总是把内存想象成它是由连续的一页一页的块组成的。我们可以从 0 开始对物理页编号，这样每个物理页都会有个页号。

由于物理地址是连续的，页也是连续的，每个页大小也是一样的。因而对于任何一个地址，只要直接除一下每页的大小，很容易直接算出在哪一页。每个页有一个结构 struct page 表示，这个结构也是放在一个数组里面，这样根据页号，很容易通过下标找到相应的 struct page 结构。

如果是这样，整个物理内存的布局就非常简单、易管理，这就是最经典的平坦内存模型（Flat Memory Model）。

我们讲 x86 的工作模式的时候，讲过 CPU 是通过总线去访问内存的，这就是最经典的内存使用方式。

在这种模式下，CPU 也会有多个，在总线的一侧。所有的内存条组成一大片内存，在总线的另一侧，所有的 CPU 访问内存都要过总线，而且距离都是一样的，这种模式称为SMP（Symmetric multiprocessing），即对称多处理器。当然，它也有一个显著的缺点，就是总线会成为瓶颈，因为数据都要走它。

为了提高性能和可扩展性，后来有了一种更高级的模式，NUMA（Non-uniform memory access），非一致内存访问。在这种模式下，内存不是一整块。每个 CPU 都有自己的本地内存，CPU 访问本地内存不用过总线，因而速度要快很多，每个 CPU 和内存在一起，称为一个 NUMA 节点。但是，在本地内存不足的情况下，每个 CPU 都可以去另外的 NUMA 节点申请内存，这个时候访问延时就会比较长。

这样，内存被分成了多个节点，每个节点再被分成一个一个的页面。由于页需要全局唯一定位，页还是需要有全局唯一的页号的。但是由于物理内存不是连起来的了，页号也就不再连续了。于是内存模型就变成了非连续内存模型，管理起来就复杂一些。

这里需要指出的是，NUMA 往往是非连续内存模型。而非连续内存模型不一定就是 NUMA，有时候一大片内存的情况下，也会有物理内存地址不连续的情况。

后来内存技术牛了，可以支持热插拔了。这个时候，不连续成为常态，于是就有了稀疏内存模型。

节点

我们主要解析当前的主流场景，NUMA 方式。我们首先要能够表示 NUMA 节点的概念，于是有了下面这个结构 typedef struct pglist_data pg_data_t，它里面有以下的成员变量：

每一个节点都有自己的 ID：node_id；

node_mem_map 就是这个节点的 struct page 数组，用于描述这个节点里面的所有的页；

node_start_pfn 是这个节点的起始页号；

node_spanned_pages 是这个节点中包含不连续的物理内存地址的页面数；

node_present_pages 是真正可用的物理页面的数目。

例如，64M 物理内存隔着一个 4M 的空洞，然后是另外的 64M 物理内存。这样换算成页面数目就是，16K 个页面隔着 1K 个页面，然后是另外 16K 个页面。这种情况下，node_spanned_pages 就是 33K 个页面，node_present_pages 就是 32K 个页面。

typedef struct pglist_data {

	struct zone node_zones[MAX_NR_ZONES];

	struct zonelist node_zonelists[MAX_ZONELISTS];

	int nr_zones;

	struct page *node_mem_map;

	unsigned long node_start_pfn;

	unsigned long node_present_pages; /* total number of physical pages */

	unsigned long node_spanned_pages; /* total size of physical page range, including holes */

	int node_id;

......

} pg_data_t;

每一个节点分成一个个区域 zone，放在数组 node_zones 里面。这个数组的大小为 MAX_NR_ZONES。我们来看区域的定义。

enum zone_type {

#ifdef CONFIG_ZONE_DMA

	ZONE_DMA,

#endif

#ifdef CONFIG_ZONE_DMA32

	ZONE_DMA32,

#endif

	ZONE_NORMAL,

#ifdef CONFIG_HIGHMEM

	ZONE_HIGHMEM,

#endif

	ZONE_MOVABLE,

	__MAX_NR_ZONES

};

ZONE_DMA 是指可用于作 DMA（Direct Memory Access，直接内存存取）的内存。DMA 是这样一种机制：要把外设的数据读入内存或把内存的数据传送到外设，原来都要通过 CPU 控制完成，但是这会占用 CPU，影响 CPU 处理其他事情，所以有了 DMA 模式。CPU 只需向 DMA 控制器下达指令，让 DMA 控制器来处理数据的传送，数据传送完毕再把信息反馈给 CPU，这样就可以解放 CPU。

对于 64 位系统，有两个 DMA 区域。除了上面说的 ZONE_DMA，还有 ZONE_DMA32。在这里你大概理解 DMA 的原理就可以，不必纠结，我们后面会讲 DMA 的机制。

ZONE_NORMAL 是直接映射区，就是上一节讲的，从物理内存到虚拟内存的内核区域，通过加上一个常量直接映射。

ZONE_HIGHMEM 是高端内存区，就是上一节讲的，对于 32 位系统来说超过 896M 的地方，对于 64 位没必要有的一段区域。

ZONE_MOVABLE 是可移动区域，通过将物理内存划分为可移动分配区域和不可移动分配区域来避免内存碎片。

这里你需要注意一下，我们刚才对于区域的划分，都是针对物理内存的。

nr_zones 表示当前节点的区域的数量。node_zonelists 是备用节点和它的内存区域的情况。前面讲 NUMA 的时候，我们讲了 CPU 访问内存，本节点速度最快，但是如果本节点内存不够怎么办，还是需要去其他节点进行分配。毕竟，就算在备用节点里面选择，慢了点也比没有强。

既然整个内存被分成了多个节点，那 pglist_data 应该放在一个数组里面。每个节点一项，就像下面代码里面一样：

struct pglist_data *node_data[MAX_NUMNODES] __read_mostly;

区域

到这里，我们把内存分成了节点，把节点分成了区域。接下来我们来看，一个区域里面是如何组织的。

表示区域的数据结构 zone 的定义如下：

struct zone {

......

	struct pglist_data	*zone_pgdat;

	struct per_cpu_pageset __percpu *pageset;

	unsigned long		zone_start_pfn;

	/*

	 * spanned_pages is the total pages spanned by the zone, including

	 * holes, which is calculated as:

	 * 	spanned_pages = zone_end_pfn - zone_start_pfn;

	 *

	 * present_pages is physical pages existing within the zone, which

	 * is calculated as:

	 *	present_pages = spanned_pages - absent_pages(pages in holes);

	 *

	 * managed_pages is present pages managed by the buddy system, which

	 * is calculated as (reserved_pages includes pages allocated by the

	 * bootmem allocator):

	 *	managed_pages = present_pages - reserved_pages;

	 *

	 */

	unsigned long		managed_pages;

	unsigned long		spanned_pages;

	unsigned long		present_pages;

	const char		*name;

......

	/* free areas of different sizes */

	struct free_area	free_area[MAX_ORDER];

	/* zone flags, see below */

	unsigned long		flags;

	/* Primarily protects free_area */

	spinlock_t		lock;

......

} ____cacheline_internodealigned_in_

在一个 zone 里面，zone_start_pfn 表示属于这个 zone 的第一个页。

如果我们仔细看代码的注释，可以看到，spanned_pages = zone_end_pfn - zone_start_pfn，也即 spanned_pages 指的是不管中间有没有物理内存空洞，反正就是最后的页号减去起始的页号。

present_pages = spanned_pages - absent_pages(pages in holes)，也即 present_pages 是这个 zone 在物理内存中真实存在的所有 page 数目。

managed_pages = present_pages - reserved_pages，也即 managed_pages 是这个 zone 被伙伴系统管理的所有的 page 数目，伙伴系统的工作机制我们后面会讲。

per_cpu_pageset 用于区分冷热页。什么叫冷热页呢？咱们讲 x86 体系结构的时候讲过，为了让 CPU 快速访问段描述符，在 CPU 里面有段描述符缓存。CPU 访问这个缓存的速度比内存快得多。同样对于页面来讲，也是这样的。如果一个页被加载到 CPU 高速缓存里面，这就是一个热页（Hot Page），CPU 读起来速度会快很多，如果没有就是冷页（Cold Page）。由于每个 CPU 都有自己的高速缓存，因而 per_cpu_pageset 也是每个 CPU 一个。

页

了解了区域 zone，接下来我们就到了组成物理内存的基本单位，页的数据结构 struct page。这是一个特别复杂的结构，里面有很多的 union，union 结构是在 C 语言中被用于同一块内存根据情况保存不同类型数据的一种方式。这里之所以用了 union，是因为一个物理页面使用模式有多种。

第一种模式，要用就用一整页。这一整页的内存，或者直接和虚拟地址空间建立映射关系，我们把这种称为匿名页（Anonymous Page）。或者用于关联一个文件，然后再和虚拟地址空间建立映射关系，这样的文件，我们称为内存映射文件（Memory-mapped File）。

如果某一页是这种使用模式，则会使用 union 中的以下变量：

struct address_space *mapping 就是用于内存映射，如果是匿名页，最低位为 1；如果是映射文件，最低位为 0；

pgoff_t index 是在映射区的偏移量；

atomic_t _mapcount，每个进程都有自己的页表，这里指有多少个页表项指向了这个页；

struct list_head lru 表示这一页应该在一个链表上，例如这个页面被换出，就在换出页的链表中；

compound 相关的变量用于复合页（Compound Page），就是将物理上连续的两个或多个页看成一个独立的大页。

第二种模式，仅需分配小块内存。有时候，我们不需要一下子分配这么多的内存，例如分配一个 task_struct 结构，只需要分配小块的内存，去存储这个进程描述结构的对象。为了满足对这种小内存块的需要，Linux 系统采用了一种被称为slab allocator的技术，用于分配称为 slab 的一小块内存。它的基本原理是从内存管理模块申请一整块页，然后划分成多个小块的存储池，用复杂的队列来维护这些小块的状态（状态包括：被分配了 / 被放回池子 / 应该被回收）。

也正是因为 slab allocator 对于队列的维护过于复杂，后来就有了一种不使用队列的分配器 slub allocator，后面我们会解析这个分配器。但是你会发现，它里面还是用了很多 slab 的字眼，因为它保留了 slab 的用户接口，可以看成 slab allocator 的另一种实现。

还有一种小块内存的分配器称为slob，非常简单，主要使用在小型的嵌入式系统。

如果某一页是用于分割成一小块一小块的内存进行分配的使用模式，则会使用 union 中的以下变量：

s_mem 是已经分配了正在使用的 slab 的第一个对象；

freelist 是池子中的空闲对象；

rcu_head 是需要释放的列表。

    struct page {

    	unsigned long flags;

    	union {

    		struct address_space *mapping;

    		void *s_mem;			/* slab first object */

    		atomic_t compound_mapcount;	/* first tail page */

    	};

    	union {

    		pgoff_t index;		/* Our offset within mapping. */

    		void *freelist;		/* sl[aou]b first free object */

    	};

    	union {

    		unsigned counters;

    		struct {

    			union {

    				atomic_t _mapcount;

    				unsigned int active;		/* SLAB */

    				struct {			/* SLUB */

    					unsigned inuse:16;

    					unsigned objects:15;

    					unsigned frozen:1;

    				};

    				int units;			/* SLOB */

    			};

    			atomic_t _refcount;

    		};

    	};

    	union {

    		struct list_head lru;	/* Pageout list	 */

    		struct dev_pagemap *pgmap;

    		struct {		/* slub per cpu partial pages */

    			struct page *next;	/* Next partial slab */

    			int pages;	/* Nr of partial slabs left */

    			int pobjects;	/* Approximate # of objects */

    		};

    		struct rcu_head rcu_head;

    		struct {

    			unsigned long compound_head; /* If bit zero is set */

    			unsigned int compound_dtor;

    			unsigned int compound_order;

    		};

    	};

    	union {

    		unsigned long private;

    		struct kmem_cache *slab_cache;	/* SL[AU]B: Pointer to slab */

    	};

    ......

    }

页的分配

好了，前面我们讲了物理内存的组织，从节点到区域到页到小块。接下来，我们来看物理内存的分配。

对于要分配比较大的内存，例如到分配页级别的，可以使用伙伴系统（Buddy System）。

Linux 中的内存管理的“页”大小为 4KB。把所有的空闲页分组为 11 个页块链表，每个块链表分别包含很多个大小的页块，有 1、2、4、8、16、32、64、128、256、512 和 1024 个连续页的页块。最大可以申请 1024 个连续页，对应 4MB 大小的连续内存。每个页块的第一个页的物理地址是该页块大小的整数倍。

第 i 个页块链表中，页块中页的数目为 2^i。

在 struct zone 里面有以下的定义：

struct free_area	free_area[MAX_ORDER];

MAX_ORDER 就是指数。

#define MAX_ORDER 11

当向内核请求分配 (2^(i-1)，2^i] 数目的页块时，按照 2^i 页块请求处理。如果对应的页块链表中没有空闲页块，那我们就在更大的页块链表中去找。当分配的页块中有多余的页时，伙伴系统会根据多余的页块大小插入到对应的空闲页块链表中。

例如，要请求一个 128 个页的页块时，先检查 128 个页的页块链表是否有空闲块。如果没有，则查 256 个页的页块链表；如果有空闲块的话，则将 256 个页的页块分成两份，一份使用，一份插入 128 个页的页块链表中。如果还是没有，就查 512 个页的页块链表；如果有的话，就分裂为 128、128、256 三个页块，一个 128 的使用，剩余两个插入对应页块链表。

上面这个过程，我们可以在分配页的函数 alloc_pages 中看到。

static inline struct page *

alloc_pages(gfp_t gfp_mask, unsigned int order)

{

	return alloc_pages_current(gfp_mask, order);

}

/**

 * 	alloc_pages_current - Allocate pages.

 *

 *	@gfp:

 *		%GFP_USER   user allocation,

 *      	%GFP_KERNEL kernel allocation,

 *      	%GFP_HIGHMEM highmem allocation,

 *      	%GFP_FS     don't call back into a file system.

 *      	%GFP_ATOMIC don't sleep.

 *	@order: Power of two of allocation size in pages. 0 is a single page.

 *

 *	Allocate a page from the kernel page pool.  When not in

 *	interrupt context and apply the current process NUMA policy.

 *	Returns NULL when no page can be allocated.

 */

struct page *alloc_pages_current(gfp_t gfp, unsigned order)

{

	struct mempolicy *pol = &default_policy;

	struct page *page;

......

	page = __alloc_pages_nodemask(gfp, order,

				policy_node(gfp, pol, numa_node_id()),

				policy_nodemask(gfp, pol));

......

	return page;

}

alloc_pages 会调用 alloc_pages_current，这里面的注释比较容易看懂了，gfp 表示希望在哪个区域中分配这个内存：

GFP_USER 用于分配一个页映射到用户进程的虚拟地址空间，并且希望直接被内核或者硬件访问，主要用于一个用户进程希望通过内存映射的方式，访问某些硬件的缓存，例如显卡缓存；

GFP_KERNEL 用于内核中分配页，主要分配 ZONE_NORMAL 区域，也即直接映射区；

GFP_HIGHMEM，顾名思义就是主要分配高端区域的内存。

另一个参数 order，就是表示分配 2 的 order 次方个页。

接下来调用 __alloc_pages_nodemask。这是伙伴系统的核心方法。它会调用 get_page_from_freelist。这里面的逻辑也很容易理解，就是在一个循环中先看当前节点的 zone。如果找不到空闲页，则再看备用节点的 zone。

static struct page *

get_page_from_freelist(gfp_t gfp_mask, unsigned int order, int alloc_flags,

						const struct alloc_context *ac)

{

......

	for_next_zone_zonelist_nodemask(zone, z, ac->zonelist, ac->high_zoneidx, ac->nodemask) {

		struct page *page;

......

		page = rmqueue(ac->preferred_zoneref->zone, zone, order,

				gfp_mask, alloc_flags, ac->migratetype);

......

}

每一个 zone，都有伙伴系统维护的各种大小的队列，就像上面伙伴系统原理里讲的那样。这里调用 rmqueue 就很好理解了，就是找到合适大小的那个队列，把页面取下来。

接下来的调用链是 rmqueue->__rmqueue->__rmqueue_smallest。在这里，我们能清楚看到伙伴系统的逻辑。

static inline

struct page *__rmqueue_smallest(struct zone *zone, unsigned int order,

						int migratetype)

{

	unsigned int current_order;

	struct free_area *area;

	struct page *page;

	/* Find a page of the appropriate size in the preferred list */

	for (current_order = order; current_order < MAX_ORDER; ++current_order) {

		area = &(zone->free_area[current_order]);

		page = list_first_entry_or_null(&area->free_list[migratetype],

							struct page, lru);

		if (!page)

			continue;

		list_del(&page->lru);

		rmv_page_order(page);

		area->nr_free--;

		expand(zone, page, order, current_order, area, migratetype);

		set_pcppage_migratetype(page, migratetype);

		return page;

	}

	return NULL;

从当前的 order，也即指数开始，在伙伴系统的 free_area 找 2^order 大小的页块。如果链表的第一个不为空，就找到了；如果为空，就到更大的 order 的页块链表里面去找。找到以后，除了将页块从链表中取下来，我们还要把多余的的部分放到其他页块链表里面。expand 就是干这个事情的。area–就是伙伴系统那个表里面的前一项，前一项里面的页块大小是当前项的页块大小除以 2，size 右移一位也就是除以 2，list_add 就是加到链表上，nr_free++ 就是计数加 1。

static inline void expand(struct zone *zone, struct page *page,

	int low, int high, struct free_area *area,

	int migratetype)

{

	unsigned long size = 1 << high;

	while (high > low) {

		area--;

		high--;

		size >>= 1;

......

		list_add(&page[size].lru, &area->free_list[migratetype]);

		area->nr_free++;

		set_page_order(&page[size], high);

	}

}

总结时刻

对于物理内存的管理的讲解，到这里要告一段落了。这一节我们主要讲了物理内存的组织形式，就像下面图中展示的一样。

如果有多个 CPU，那就有多个节点。每个节点用 struct pglist_data 表示，放在一个数组里面。

每个节点分为多个区域，每个区域用 struct zone 表示，也放在一个数组里面。

每个区域分为多个页。为了方便分配，空闲页放在 struct free_area 里面，使用伙伴系统进行管理和分配，每一页用 struct page 表示。

课堂练习

伙伴系统是一种非常精妙的实现方式，无论你使用什么语言，请自己实现一个这样的分配系统，说不定哪天你在做某个系统的时候，就用到了。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

24 | 物理内存管理（下）：会议室管理员如何分配会议室？

前一节，前面我们解析了整页的分配机制。如果遇到小的对象，物理内存是如何分配的呢？这一节，我们一起来看一看。

小内存的分配

前面我们讲过，如果遇到小的对象，会使用 slub 分配器进行分配。那我们就先来解析它的工作原理。

还记得咱们创建进程的时候，会调用 dup_task_struct，它想要试图复制一个 task_struct 对象，需要先调用 alloc_task_struct_node，分配一个 task_struct 对象。

从这段代码可以看出，它调用了 kmem_cache_alloc_node 函数，在 task_struct 的缓存区域 task_struct_cachep 分配了一块内存。

static struct kmem_cache *task_struct_cachep;

task_struct_cachep = kmem_cache_create("task_struct",

			arch_task_struct_size, align,

			SLAB_PANIC|SLAB_NOTRACK|SLAB_ACCOUNT, NULL);

static inline struct task_struct *alloc_task_struct_node(int node)

{

	return kmem_cache_alloc_node(task_struct_cachep, GFP_KERNEL, node);

}

static inline void free_task_struct(struct task_struct *tsk)

{

	kmem_cache_free(task_struct_cachep, tsk);

}

在系统初始化的时候，task_struct_cachep 会被 kmem_cache_create 函数创建。这个函数也比较容易看懂，专门用于分配 task_struct 对象的缓存。这个缓存区的名字就叫 task_struct。缓存区中每一块的大小正好等于 task_struct 的大小，也即 arch_task_struct_size。

有了这个缓存区，每次创建 task_struct 的时候，我们不用到内存里面去分配，先在缓存里面看看有没有直接可用的，这就是kmem_cache_alloc_node的作用。

当一个进程结束，task_struct 也不用直接被销毁，而是放回到缓存中，这就是kmem_cache_free的作用。这样，新进程创建的时候，我们就可以直接用现成的缓存中的 task_struct 了。

我们来仔细看看，缓存区 struct kmem_cache 到底是什么样子。

struct kmem_cache {

	struct kmem_cache_cpu __percpu *cpu_slab;

	/* Used for retriving partial slabs etc */

	unsigned long flags;

	unsigned long min_partial;

	int size;		/* The size of an object including meta data */

	int object_size;	/* The size of an object without meta data */

	int offset;		/* Free pointer offset. */

#ifdef CONFIG_SLUB_CPU_PARTIAL

	int cpu_partial;	/* Number of per cpu partial objects to keep around */

#endif

	struct kmem_cache_order_objects oo;

	/* Allocation and freeing of slabs */

	struct kmem_cache_order_objects max;

	struct kmem_cache_order_objects min;

	gfp_t allocflags;	/* gfp flags to use on each alloc */

	int refcount;		/* Refcount for slab cache destroy */

	void (*ctor)(void *);

......

	const char *name;	/* Name (only for display!) */

	struct list_head list;	/* List of slab caches */

......

	struct kmem_cache_node *node[MAX_NUMNODES];

};

在 struct kmem_cache 里面，有个变量 struct list_head list，这个结构我们已经看到过多次了。我们可以想象一下，对于操作系统来讲，要创建和管理的缓存绝对不止 task_struct。难道 mm_struct 就不需要吗？fs_struct 就不需要吗？都需要。因此，所有的缓存最后都会放在一个链表里面，也就是 LIST_HEAD(slab_caches)。

对于缓存来讲，其实就是分配了连续几页的大内存块，然后根据缓存对象的大小，切成小内存块。

所以，我们这里有三个 kmem_cache_order_objects 类型的变量。这里面的 order，就是 2 的 order 次方个页面的大内存块，objects 就是能够存放的缓存对象的数量。

最终，我们将大内存块切分成小内存块，样子就像下面这样。

每一项的结构都是缓存对象后面跟一个下一个空闲对象的指针，这样非常方便将所有的空闲对象链成一个链。其实，这就相当于咱们数据结构里面学的，用数组实现一个可随机插入和删除的链表。

所以，这里面就有三个变量：size 是包含这个指针的大小，object_size 是纯对象的大小，offset 就是把下一个空闲对象的指针存放在这一项里的偏移量。

那这些缓存对象哪些被分配了、哪些在空着，什么情况下整个大内存块都被分配完了，需要向伙伴系统申请几个页形成新的大内存块？这些信息该由谁来维护呢？

接下来就是最重要的两个成员变量出场的时候了。kmem_cache_cpu 和 kmem_cache_node，它们都是每个 NUMA 节点上有一个，我们只需要看一个节点里面的情况。

在分配缓存块的时候，要分两种路径，fast path和slow path，也就是快速通道和普通通道。其中 kmem_cache_cpu 就是快速通道，kmem_cache_node 是普通通道。每次分配的时候，要先从 kmem_cache_cpu 进行分配。如果 kmem_cache_cpu 里面没有空闲的块，那就到 kmem_cache_node 中进行分配；如果还是没有空闲的块，才去伙伴系统分配新的页。

我们来看一下，kmem_cache_cpu 里面是如何存放缓存块的。

struct kmem_cache_cpu {

	void **freelist;	/* Pointer to next available object */

	unsigned long tid;	/* Globally unique transaction id */

	struct page *page;	/* The slab from which we are allocating */

#ifdef CONFIG_SLUB_CPU_PARTIAL

	struct page *partial;	/* Partially allocated frozen slabs */

#endif

......

};

在这里，page 指向大内存块的第一个页，缓存块就是从里面分配的。freelist 指向大内存块里面第一个空闲的项。按照上面说的，这一项会有指针指向下一个空闲的项，最终所有空闲的项会形成一个链表。

partial 指向的也是大内存块的第一个页，之所以名字叫 partial（部分），就是因为它里面部分被分配出去了，部分是空的。这是一个备用列表，当 page 满了，就会从这里找。

我们再来看 kmem_cache_node 的定义。

struct kmem_cache_node {

	spinlock_t list_lock;

......

#ifdef CONFIG_SLUB

	unsigned long nr_partial;

	struct list_head partial;

......

#endif

};

这里面也有一个 partial，是一个链表。这个链表里存放的是部分空闲的大内存块。这是 kmem_cache_cpu 里面的 partial 的备用列表，如果那里没有，就到这里来找。

下面我们就来看看这个分配过程。kmem_cache_alloc_node 会调用 slab_alloc_node。你还是先重点看这里面的注释，这里面说的就是快速通道和普通通道的概念。

/*

 * Inlined fastpath so that allocation functions (kmalloc, kmem_cache_alloc)

 * have the fastpath folded into their functions. So no function call

 * overhead for requests that can be satisfied on the fastpath.

 *

 * The fastpath works by first checking if the lockless freelist can be used.

 * If not then __slab_alloc is called for slow processing.

 *

 * Otherwise we can simply pick the next object from the lockless free list.

 */

static __always_inline void *slab_alloc_node(struct kmem_cache *s,

		gfp_t gfpflags, int node, unsigned long addr)

{

	void *object;

	struct kmem_cache_cpu *c;

	struct page *page;

	unsigned long tid;

......

	tid = this_cpu_read(s->cpu_slab->tid);

	c = raw_cpu_ptr(s->cpu_slab);

......

	object = c->freelist;

	page = c->page;

	if (unlikely(!object || !node_match(page, node))) {

		object = __slab_alloc(s, gfpflags, node, addr, c);

		stat(s, ALLOC_SLOWPATH);

	}

......

	return object;

}

快速通道很简单，取出 cpu_slab 也即 kmem_cache_cpu 的 freelist，这就是第一个空闲的项，可以直接返回了。如果没有空闲的了，则只好进入普通通道，调用 __slab_alloc。

static void *___slab_alloc(struct kmem_cache *s, gfp_t gfpflags, int node,

			  unsigned long addr, struct kmem_cache_cpu *c)

{

	void *freelist;

	struct page *page;

......

redo:

......

	/* must check again c->freelist in case of cpu migration or IRQ */

	freelist = c->freelist;

	if (freelist)

		goto load_freelist;

	freelist = get_freelist(s, page);

	if (!freelist) {

		c->page = NULL;

		stat(s, DEACTIVATE_BYPASS);

		goto new_slab;

	}

load_freelist:

	c->freelist = get_freepointer(s, freelist);

	c->tid = next_tid(c->tid);

	return freelist;

new_slab:

	if (slub_percpu_partial(c)) {

		page = c->page = slub_percpu_partial(c);

		slub_set_percpu_partial(c, page);

		stat(s, CPU_PARTIAL_ALLOC);

		goto redo;

	}

	freelist = new_slab_objects(s, gfpflags, node, &c);

......

	return freeli

在这里，我们首先再次尝试一下 kmem_cache_cpu 的 freelist。为什么呢？万一当前进程被中断，等回来的时候，别人已经释放了一些缓存，说不定又有空间了呢。如果找到了，就跳到 load_freelist，在这里将 freelist 指向下一个空闲项，返回就可以了。

如果 freelist 还是没有，则跳到 new_slab 里面去。这里面我们先去 kmem_cache_cpu 的 partial 里面看。如果 partial 不是空的，那就将 kmem_cache_cpu 的 page，也就是快速通道的那一大块内存，替换为 partial 里面的大块内存。然后 redo，重新试下。这次应该就可以成功了。

如果真的还不行，那就要到 new_slab_objects 了。

static inline void *new_slab_objects(struct kmem_cache *s, gfp_t flags,

			int node, struct kmem_cache_cpu **pc)

{

	void *freelist;

	struct kmem_cache_cpu *c = *pc;

	struct page *page;

	freelist = get_partial(s, flags, node, c);

	if (freelist)

		return freelist;

	page = new_slab(s, flags, node);

	if (page) {

		c = raw_cpu_ptr(s->cpu_slab);

		if (c->page)

			flush_slab(s, c);

		freelist = page->freelist;

		page->freelist = NULL;

		stat(s, ALLOC_SLAB);

		c->page = page;

		*pc = c;

	} else

		freelist = NULL;

	return freelis

在这里面，get_partial 会根据 node id，找到相应的 kmem_cache_node，然后调用 get_partial_node，开始在这个节点进行分配。

/*

 * Try to allocate a partial slab from a specific node.

 */

static void *get_partial_node(struct kmem_cache *s, struct kmem_cache_node *n,

				struct kmem_cache_cpu *c, gfp_t flags)

{

	struct page *page, *page2;

	void *object = NULL;

	int available = 0;

	int objects;

......

	list_for_each_entry_safe(page, page2, &n->partial, lru) {

		void *t;

		t = acquire_slab(s, n, page, object == NULL, &objects);

		if (!t)

			break;

		available += objects;

		if (!object) {

			c->page = page;

			stat(s, ALLOC_FROM_PARTIAL);

			object = t;

		} else {

			put_cpu_partial(s, page, 0);

			stat(s, CPU_PARTIAL_NODE);

		}

		if (!kmem_cache_has_cpu_partial(s)

			|| available > slub_cpu_partial(s) / 2)

			break;

	}

......

	return object;

acquire_slab 会从 kmem_cache_node 的 partial 链表中拿下一大块内存来，并且将 freelist，也就是第一块空闲的缓存块，赋值给 t。并且当第一轮循环的时候，将 kmem_cache_cpu 的 page 指向取下来的这一大块内存，返回的 object 就是这块内存里面的第一个缓存块 t。如果 kmem_cache_cpu 也有一个 partial，就会进行第二轮，再次取下一大块内存来，这次调用 put_cpu_partial，放到 kmem_cache_cpu 的 partial 里面。

如果 kmem_cache_node 里面也没有空闲的内存，这就说明原来分配的页里面都放满了，就要回到 new_slab_objects 函数，里面 new_slab 函数会调用 allocate_slab。

static struct page *allocate_slab(struct kmem_cache *s, gfp_t flags, int node)

{

	struct page *page;

	struct kmem_cache_order_objects oo = s->oo;

	gfp_t alloc_gfp;

	void *start, *p;

	int idx, order;

	bool shuffle;

	flags &= gfp_allowed_mask;

......

	page = alloc_slab_page(s, alloc_gfp, node, oo);

	if (unlikely(!page)) {

		oo = s->min;

		alloc_gfp = flags;

		/*

		 * Allocation may have failed due to fragmentation.

		 * Try a lower order alloc if possible

		 */

		page = alloc_slab_page(s, alloc_gfp, node, oo);

		if (unlikely(!page))

			goto out;

		stat(s, ORDER_FALLBACK);

	}

......

	return page;

}

在这里，我们看到了 alloc_slab_page 分配页面。分配的时候，要按 kmem_cache_order_objects 里面的 order 来。如果第一次分配不成功，说明内存已经很紧张了，那就换成 min 版本的 kmem_cache_order_objects。

好了，这个复杂的层层分配机制，我们就讲到这里，你理解到这里也就够用了。

页面换出

另一个物理内存管理必须要处理的事情就是，页面换出。每个进程都有自己的虚拟地址空间，无论是 32 位还是 64 位，虚拟地址空间都非常大，物理内存不可能有这么多的空间放得下。所以，一般情况下，页面只有在被使用的时候，才会放在物理内存中。如果过了一段时间不被使用，即便用户进程并没有释放它，物理内存管理也有责任做一定的干预。例如，将这些物理内存中的页面换出到硬盘上去；将空出的物理内存，交给活跃的进程去使用。

什么情况下会触发页面换出呢？

可以想象，最常见的情况就是，分配内存的时候，发现没有地方了，就试图回收一下。例如，咱们解析申请一个页面的时候，会调用 get_page_from_freelist，接下来的调用链为 get_page_from_freelist->node_reclaim->__node_reclaim->shrink_node，通过这个调用链可以看出，页面换出也是以内存节点为单位的。

当然还有一种情况，就是作为内存管理系统应该主动去做的，而不能等真的出了事儿再做，这就是内核线程kswapd。这个内核线程，在系统初始化的时候就被创建。这样它会进入一个无限循环，直到系统停止。在这个循环中，如果内存使用没有那么紧张，那它就可以放心睡大觉；如果内存紧张了，就需要去检查一下内存，看看是否需要换出一些内存页。

/*

 * The background pageout daemon, started as a kernel thread

 * from the init process.

 *

 * This basically trickles out pages so that we have _some_

 * free memory available even if there is no other activity

 * that frees anything up. This is needed for things like routing

 * etc, where we otherwise might have all activity going on in

 * asynchronous contexts that cannot page things out.

 *

 * If there are applications that are active memory-allocators

 * (most normal use), this basically shouldn't matter.

 */

static int kswapd(void *p)

{

	unsigned int alloc_order, reclaim_order;

	unsigned int classzone_idx = MAX_NR_ZONES - 1;

	pg_data_t *pgdat = (pg_data_t*)p;

	struct task_struct *tsk = current;

    for ( ; ; ) {

......

        kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order,

					classzone_idx);

......

        reclaim_order = balance_pgdat(pgdat, alloc_order, classzone_idx);

......

    }

}

这里的调用链是 balance_pgdat->kswapd_shrink_node->shrink_node，是以内存节点为单位的，最后也是调用 shrink_node。

shrink_node 会调用 shrink_node_memcg。这里面有一个循环处理页面的列表，看这个函数的注释，其实和上面我们想表达的内存换出是一样的。

/*

 * This is a basic per-node page freer.  Used by both kswapd and direct reclaim.

 */

static void shrink_node_memcg(struct pglist_data *pgdat, struct mem_cgroup *memcg,

			      struct scan_control *sc, unsigned long *lru_pages)

{

......

	unsigned long nr[NR_LRU_LISTS];

	enum lru_list lru;

......

	while (nr[LRU_INACTIVE_ANON] || nr[LRU_ACTIVE_FILE] ||

					nr[LRU_INACTIVE_FILE]) {

		unsigned long nr_anon, nr_file, percentage;

		unsigned long nr_scanned;

		for_each_evictable_lru(lru) {

			if (nr[lru]) {

				nr_to_scan = min(nr[lru], SWAP_CLUSTER_MAX);

				nr[lru] -= nr_to_scan;

				nr_reclaimed += shrink_list(lru, nr_to_scan,

							    lruvec, memcg, sc);

			}

		}

......

	}

......

这里面有个 lru 列表。从下面的定义，我们可以想象，所有的页面都被挂在 LRU 列表中。LRU 是 Least Recent Use，也就是最近最少使用。也就是说，这个列表里面会按照活跃程度进行排序，这样就容易把不怎么用的内存页拿出来做处理。

内存页总共分两类，一类是匿名页，和虚拟地址空间进行关联；一类是内存映射，不但和虚拟地址空间关联，还和文件管理关联。

它们每一类都有两个列表，一个是 active，一个是 inactive。顾名思义，active 就是比较活跃的，inactive 就是不怎么活跃的。这两个里面的页会变化，过一段时间，活跃的可能变为不活跃，不活跃的可能变为活跃。如果要换出内存，那就是从不活跃的列表中找出最不活跃的，换出到硬盘上。

enum lru_list {

	LRU_INACTIVE_ANON = LRU_BASE,

	LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,

	LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,

	LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,

	LRU_UNEVICTABLE,

	NR_LRU_LISTS

};

#define for_each_evictable_lru(lru) for (lru = 0; lru <= LRU_ACTIVE_FILE; lru++)

static unsigned long shrink_list(enum lru_list lru, unsigned long nr_to_scan,

				 struct lruvec *lruvec, struct mem_cgroup *memcg,

				 struct scan_control *sc)

{

	if (is_active_lru(lru)) {

		if (inactive_list_is_low(lruvec, is_file_lru(lru),

					 memcg, sc, true))

			shrink_active_list(nr_to_scan, lruvec, sc, lru);

		return 0;

	}

	return shrink_inactive_list(nr_to_scan, lruvec, sc, lru);

从上面的代码可以看出，shrink_list 会先缩减活跃页面列表，再压缩不活跃的页面列表。对于不活跃列表的缩减，shrink_inactive_list 就需要对页面进行回收；对于匿名页来讲，需要分配 swap，将内存页写入文件系统；对于内存映射关联了文件的，我们需要将在内存中对于文件的修改写回到文件中。

总结时刻

好了，对于物理内存的管理就讲到这里了，我们来总结一下。对于物理内存来讲，从下层到上层的关系及分配模式如下：

物理内存分 NUMA 节点，分别进行管理；

每个 NUMA 节点分成多个内存区域；

每个内存区域分成多个物理页面；

伙伴系统将多个连续的页面作为一个大的内存块分配给上层；

kswapd 负责物理页面的换入换出；

Slub Allocator 将从伙伴系统申请的大内存块切成小块，分配给其他系统。

课堂练习

内存的换入和换出涉及 swap 分区，那你知道如何检查当前 swap 分区情况，如何启用和关闭 swap 区域，如何调整 swappiness 吗？

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

25 | 用户态内存映射：如何找到正确的会议室？

前面几节，我们既看了虚拟内存空间如何组织的，也看了物理页面如何管理的。现在我们需要一些数据结构，将二者关联起来。

mmap 的原理

在虚拟地址空间那一节，我们知道，每一个进程都有一个列表 vm_area_struct，指向虚拟地址空间的不同的内存块，这个变量的名字叫mmap。

struct mm_struct {

	struct vm_area_struct *mmap;		/* list of VMAs */

......

}

struct vm_area_struct {

	/*

	 * For areas with an address space and backing store,

	 * linkage into the address_space->i_mmap interval tree.

	 */

	struct {

		struct rb_node rb;

		unsigned long rb_subtree_last;

	} shared;

	/*

	 * A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma

	 * list, after a COW of one of the file pages.	A MAP_SHARED vma

	 * can only be in the i_mmap tree.  An anonymous MAP_PRIVATE, stack

	 * or brk vma (with NULL file) can only be in an anon_vma list.

	 */

	struct list_head anon_vma_chain; /* Serialized by mmap_sem &

					  * page_table_lock */

	struct anon_vma *anon_vma;	/* Serialized by page_table_lock */

	/* Function pointers to deal with this struct. */

	const struct vm_operations_struct *vm_ops;

	/* Information about our backing store: */

	unsigned long vm_pgoff;		/* Offset (within vm_file) in PAGE_SIZE

					   units */

	struct file * vm_file;		/* File we map to (can be NULL). */

	void * vm_private_data;		/* was vm_pte (shared mem) */

其实内存映射不仅仅是物理内存和虚拟内存之间的映射，还包括将文件中的内容映射到虚拟内存空间。这个时候，访问内存空间就能够访问到文件里面的数据。而仅有物理内存和虚拟内存的映射，是一种特殊情况。

前面咱们讲堆的时候讲过，如果我们要申请小块内存，就用 brk。brk 函数之前已经解析过了，这里就不多说了。如果申请一大块内存，就要用 mmap。对于堆的申请来讲，mmap 是映射内存空间到物理内存。

另外，如果一个进程想映射一个文件到自己的虚拟内存空间，也要通过 mmap 系统调用。这个时候 mmap 是映射内存空间到物理内存再到文件。可见 mmap 这个系统调用是核心，我们现在来看 mmap 这个系统调用。

SYSCALL_DEFINE6(mmap, unsigned long, addr, unsigned long, len,

                unsigned long, prot, unsigned long, flags,

                unsigned long, fd, unsigned long, off)

{

......

        error = sys_mmap_pgoff(addr, len, prot, flags, fd, off >> PAGE_SHIFT);

......

}

SYSCALL_DEFINE6(mmap_pgoff, unsigned long, addr, unsigned long, len,

		unsigned long, prot, unsigned long, flags,

		unsigned long, fd, unsigned long, pgoff)

{

	struct file *file = NULL;

......

	file = fget(fd);

......

	retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);

	return retval;

}

如果要映射到文件，fd 会传进来一个文件描述符，并且 mmap_pgoff 里面通过 fget 函数，根据文件描述符获得 struct file。struct file 表示打开的一个文件。

接下来的调用链是 vm_mmap_pgoff->do_mmap_pgoff->do_mmap。这里面主要干了两件事情：

调用 get_unmapped_area 找到一个没有映射的区域；

调用 mmap_region 映射这个区域。

我们先来看 get_unmapped_area 函数。

unsigned long

get_unmapped_area(struct file *file, unsigned long addr, unsigned long len,

		unsigned long pgoff, unsigned long flags)

{

	unsigned long (*get_area)(struct file *, unsigned long,

				  unsigned long, unsigned long, unsigned long);

......

	get_area = current->mm->get_unmapped_area;

	if (file) {

		if (file->f_op->get_unmapped_area)

			get_area = file->f_op->get_unmapped_area;

	}

......

}

这里面如果是匿名映射，则调用 mm_struct 里面的 get_unmapped_area 函数。这个函数其实是 arch_get_unmapped_area。它会调用 find_vma_prev，在表示虚拟内存区域的 vm_area_struct 红黑树上找到相应的位置。之所以叫 prev，是说这个时候虚拟内存区域还没有建立，找到前一个 vm_area_struct。

如果不是匿名映射，而是映射到一个文件，这样在 Linux 里面，每个打开的文件都有一个 struct file 结构，里面有一个 file_operations，用来表示和这个文件相关的操作。如果是我们熟知的 ext4 文件系统，调用的是 thp_get_unmapped_area。如果我们仔细看这个函数，最终还是调用 mm_struct 里面的 get_unmapped_area 函数。殊途同归。

const struct file_operations ext4_file_operations = {

......

        .mmap           = ext4_file_mmap

        .get_unmapped_area = thp_get_unmapped_area,

};

unsigned long __thp_get_unmapped_area(struct file *filp, unsigned long len,

                loff_t off, unsigned long flags, unsigned long size)

{

        unsigned long addr;

        loff_t off_end = off + len;

        loff_t off_align = round_up(off, size);

        unsigned long len_pad;

        len_pad = len + size;

......

        addr = current->mm->get_unmapped_area(filp, 0, len_pad,

                                              off >> PAGE_SHIFT, flags);

        addr += (off - addr) & (size - 1);

        return addr;

}

我们再来看 mmap_region，看它如何映射这个虚拟内存区域。

unsigned long mmap_region(struct file *file, unsigned long addr,

		unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,

		struct list_head *uf)

{

	struct mm_struct *mm = current->mm;

	struct vm_area_struct *vma, *prev;

	struct rb_node **rb_link, *rb_parent;

	/*

	 * Can we just expand an old mapping?

	 */

	vma = vma_merge(mm, prev, addr, addr + len, vm_flags,

			NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);

	if (vma)

		goto out;

	/*

	 * Determine the object being mapped and call the appropriate

	 * specific mapper. the address has already been validated, but

	 * not unmapped, but the maps are removed from the list.

	 */

	vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);

	if (!vma) {

		error = -ENOMEM;

		goto unacct_error;

	}

	vma->vm_mm = mm;

	vma->vm_start = addr;

	vma->vm_end = addr + len;

	vma->vm_flags = vm_flags;

	vma->vm_page_prot = vm_get_page_prot(vm_flags);

	vma->vm_pgoff = pgoff;

	INIT_LIST_HEAD(&vma->anon_vma_chain);

	if (file) {

		vma->vm_file = get_file(file);

		error = call_mmap(file, vma);

		addr = vma->vm_start;

		vm_flags = vma->vm_flags;

	}

......

	vma_link(mm, vma, prev, rb_link, rb_parent);

	return addr;

.....

还记得咱们刚找到了虚拟内存区域的前一个 vm_area_struct，我们首先要看，是否能够基于它进行扩展，也即调用 vma_merge，和前一个 vm_area_struct 合并到一起。

如果不能，就需要调用 kmem_cache_zalloc，在 Slub 里面创建一个新的 vm_area_struct 对象，设置起始和结束位置，将它加入队列。如果是映射到文件，则设置 vm_file 为目标文件，调用 call_mmap。其实就是调用 file_operations 的 mmap 函数。对于 ext4 文件系统，调用的是 ext4_file_mmap。从这个函数的参数可以看出，这一刻文件和内存开始发生关系了。这里我们将 vm_area_struct 的内存操作设置为文件系统操作，也就是说，读写内存其实就是读写文件系统。

static inline int call_mmap(struct file *file, struct vm_area_struct *vma)

{

	return file->f_op->mmap(file, vma);

}

static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)

{

......

      vma->vm_ops = &ext4_file_vm_ops;

......

}

我们再回到 mmap_region 函数。最终，vma_link 函数将新创建的 vm_area_struct 挂在了 mm_struct 里面的红黑树上。

这个时候，从内存到文件的映射关系，至少要在逻辑层面建立起来。那从文件到内存的映射关系呢？vma_link 还做了另外一件事情，就是 __vma_link_file。这个东西要用于建立这层映射关系。

对于打开的文件，会有一个结构 struct file 来表示。它有个成员指向 struct address_space 结构，这里面有棵变量名为 i_mmap 的红黑树，vm_area_struct 就挂在这棵树上。

struct address_space {

	struct inode		*host;		/* owner: inode, block_device */

......

	struct rb_root		i_mmap;		/* tree of private and shared mappings */

......

	const struct address_space_operations *a_ops;	/* methods */

......

}

static void __vma_link_file(struct vm_area_struct *vma)

{

	struct file *file;

	file = vma->vm_file;

	if (file) {

		struct address_space *mapping = file->f_mapping;

		vma_interval_tree_insert(vma, &mapping->i_mmap);

	}

到这里，内存映射的内容要告一段落了。你可能会困惑，好像还没和物理内存发生任何关系，还是在虚拟内存里面折腾呀？

对的，因为到目前为止，我们还没有开始真正访问内存呀！这个时候，内存管理并不直接分配物理内存，因为物理内存相对于虚拟地址空间太宝贵了，只有等你真正用的那一刻才会开始分配。

用户态缺页异常

一旦开始访问虚拟内存的某个地址，如果我们发现，并没有对应的物理页，那就触发缺页中断，调用 do_page_fault。

dotraplinkage void notrace

do_page_fault(struct pt_regs *regs, unsigned long error_code)

{

	unsigned long address = read_cr2(); /* Get the faulting address */

......

	__do_page_fault(regs, error_code, address);

......

}

/*

 * This routine handles page faults.  It determines the address,

 * and the problem, and then passes it off to one of the appropriate

 * routines.

 */

static noinline void

__do_page_fault(struct pt_regs *regs, unsigned long error_code,

		unsigned long address)

{

	struct vm_area_struct *vma;

	struct task_struct *tsk;

	struct mm_struct *mm;

	tsk = current;

	mm = tsk->mm;

	if (unlikely(fault_in_kernel_space(address))) {

		if (vmalloc_fault(address) >= 0)

			return;

	}

......

	vma = find_vma(mm, address);

......

	fault = handle_mm_fault(vma, address, flags);

......

在 __do_page_fault 里面，先要判断缺页中断是否发生在内核。如果发生在内核则调用 vmalloc_fault，这就和咱们前面学过的虚拟内存的布局对应上了。在内核里面，vmalloc 区域需要内核页表映射到物理页。咱们这里把内核的这部分放放，接着看用户空间的部分。

接下来在用户空间里面，找到你访问的那个地址所在的区域 vm_area_struct，然后调用 handle_mm_fault 来映射这个区域。

static int __handle_mm_fault(struct vm_area_struct *vma, unsigned long address,

		unsigned int flags)

{

	struct vm_fault vmf = {

		.vma = vma,

		.address = address & PAGE_MASK,

		.flags = flags,

		.pgoff = linear_page_index(vma, address),

		.gfp_mask = __get_fault_gfp_mask(vma),

	};

	struct mm_struct *mm = vma->vm_mm;

	pgd_t *pgd;

	p4d_t *p4d;

	int ret;

	pgd = pgd_offset(mm, address);

	p4d = p4d_alloc(mm, pgd, address);

......

	vmf.pud = pud_alloc(mm, p4d, address);

......

	vmf.pmd = pmd_alloc(mm, vmf.pud, address);

......

	return handle_pte_fault(&vmf);

}

到这里，终于看到了我们熟悉的 PGD、P4G、PUD、PMD、PTE，这就是前面讲页表的时候，讲述的四级页表的概念，因为暂且不考虑五级页表，我们暂时忽略 P4G。

pgd_t 用于全局页目录项，pud_t 用于上层页目录项，pmd_t 用于中间页目录项，pte_t 用于直接页表项。

每个进程都有独立的地址空间，为了这个进程独立完成映射，每个进程都有独立的进程页表，这个页表的最顶级的 pgd 存放在 task_struct 中的 mm_struct 的 pgd 变量里面。

在一个进程新创建的时候，会调用 fork，对于内存的部分会调用 copy_mm，里面调用 dup_mm。

/*

 * Allocate a new mm structure and copy contents from the

 * mm structure of the passed in task structure.

 */

static struct mm_struct *dup_mm(struct task_struct *tsk)

{

	struct mm_struct *mm, *oldmm = current->mm;

	mm = allocate_mm();

	memcpy(mm, oldmm, sizeof(*mm));

	if (!mm_init(mm, tsk, mm->user_ns))

		goto fail_nomem;

	err = dup_mmap(mm, oldmm);

	return mm;

}

在这里，除了创建一个新的 mm_struct，并且通过 memcpy 将它和父进程的弄成一模一样之外，我们还需要调用 mm_init 进行初始化。接下来，mm_init 调用 mm_alloc_pgd，分配全局页目录项，赋值给 mm_struct 的 pdg 成员变量。

static inline int mm_alloc_pgd(struct mm_struct *mm)

{

	mm->pgd = pgd_alloc(mm);

	return 0;

}

pgd_alloc 里面除了分配 PDG 之外，还做了很重要的一个事情，就是调用 pgd_ctor。

static void pgd_ctor(struct mm_struct *mm, pgd_t *pgd)

{

	/* If the pgd points to a shared pagetable level (either the

	   ptes in non-PAE, or shared PMD in PAE), then just copy the

	   references from swapper_pg_dir. */

	if (CONFIG_PGTABLE_LEVELS == 2 ||

	    (CONFIG_PGTABLE_LEVELS == 3 && SHARED_KERNEL_PMD) ||

	    CONFIG_PGTABLE_LEVELS >= 4) {

		clone_pgd_range(pgd + KERNEL_PGD_BOUNDARY,

				swapper_pg_dir + KERNEL_PGD_BOUNDARY,

				KERNEL_PGD_PTRS);

	}

......

}

pgd_ctor 干了什么事情呢？我们注意看里面的注释，它拷贝了对于 swapper_pg_dir 的引用。swapper_pg_dir 是内核页表的最顶级的全局页目录。

一个进程的虚拟地址空间包含用户态和内核态两部分。为了从虚拟地址空间映射到物理页面，页表也分为用户地址空间的页表和内核页表，这就和上面遇到的 vmalloc 有关系了。在内核里面，映射靠内核页表，这里内核页表会拷贝一份到进程的页表。至于 swapper_pg_dir 是什么，怎么初始化的，怎么工作的，我们还是先放一放，放到下一节统一讨论。

至此，一个进程 fork 完毕之后，有了内核页表，有了自己顶级的 pgd，但是对于用户地址空间来讲，还完全没有映射过。这需要等到这个进程在某个 CPU 上运行，并且对内存访问的那一刻了。

当这个进程被调度到某个 CPU 上运行的时候，咱们在调度那一节讲过，要调用 context_switch 进行上下文切换。对于内存方面的切换会调用 switch_mm_irqs_off，这里面会调用 load_new_mm_cr3。

cr3 是 CPU 的一个寄存器，它会指向当前进程的顶级 pgd。如果 CPU 的指令要访问进程的虚拟内存，它就会自动从 cr3 里面得到 pgd 在物理内存的地址，然后根据里面的页表解析虚拟内存的地址为物理内存，从而访问真正的物理内存上的数据。

这里需要注意两点。第一点，cr3 里面存放当前进程的顶级 pgd，这个是硬件的要求。cr3 里面需要存放 pgd 在物理内存的地址，不能是虚拟地址。因而 load_new_mm_cr3 里面会使用 __pa，将 mm_struct 里面的成员变量 pdg（mm_struct 里面存的都是虚拟地址）变为物理地址，才能加载到 cr3 里面去。

第二点，用户进程在运行的过程中，访问虚拟内存中的数据，会被 cr3 里面指向的页表转换为物理地址后，才在物理内存中访问数据，这个过程都是在用户态运行的，地址转换的过程无需进入内核态。

只有访问虚拟内存的时候，发现没有映射多物理内存，页表也没有创建过，才触发缺页异常。进入内核调用 do_page_fault，一直调用到 __handle_mm_fault，这才有了上面解析到这个函数的时候，我们看到的代码。既然原来没有创建过页表，那只好补上这一课。于是，__handle_mm_fault 调用 pud_alloc 和 pmd_alloc，来创建相应的页目录项，最后调用 handle_pte_fault 来创建页表项。

绕了一大圈，终于将页表整个机制的各个部分串了起来。但是咱们的故事还没讲完，物理的内存还没找到。我们还得接着分析 handle_pte_fault 的实现。

static int handle_pte_fault(struct vm_fault *vmf)

{

	pte_t entry;

......

	vmf->pte = pte_offset_map(vmf->pmd, vmf->address);

	vmf->orig_pte = *vmf->pte;

......

	if (!vmf->pte) {

		if (vma_is_anonymous(vmf->vma))

			return do_anonymous_page(vmf);

		else

			return do_fault(vmf);

	}

	if (!pte_present(vmf->orig_pte))

		return do_swap_page(vmf);

......

}

这里面总的来说分了三种情况。如果 PTE，也就是页表项，从来没有出现过，那就是新映射的页。如果是匿名页，就是第一种情况，应该映射到一个物理内存页，在这里调用的是 do_anonymous_page。如果是映射到文件，调用的就是 do_fault，这是第二种情况。如果 PTE 原来出现过，说明原来页面在物理内存中，后来换出到硬盘了，现在应该换回来，调用的是 do_swap_page。

我们来看第一种情况，do_anonymous_page。对于匿名页的映射，我们需要先通过 pte_alloc 分配一个页表项，然后通过 alloc_zeroed_user_highpage_movable 分配一个页。之后它会调用 alloc_pages_vma，并最终调用 __alloc_pages_nodemask。

这个函数你还记得吗？就是咱们伙伴系统的核心函数，专门用来分配物理页面的。do_anonymous_page 接下来要调用 mk_pte，将页表项指向新分配的物理页，set_pte_at 会将页表项塞到页表里面。

static int do_anonymous_page(struct vm_fault *vmf)

{

	struct vm_area_struct *vma = vmf->vma;

	struct mem_cgroup *memcg;

	struct page *page;

	int ret = 0;

	pte_t entry;

......

	if (pte_alloc(vma->vm_mm, vmf->pmd, vmf->address))

		return VM_FAULT_OOM;

......

	page = alloc_zeroed_user_highpage_movable(vma, vmf->address);

......

	entry = mk_pte(page, vma->vm_page_prot);

	if (vma->vm_flags & VM_WRITE)

		entry = pte_mkwrite(pte_mkdirty(entry));

	vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, vmf->address,

			&vmf->ptl);

......

	set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);

......

}

第二种情况映射到文件 do_fault，最终我们会调用 __do_fault。

static int __do_fault(struct vm_fault *vmf)

{

	struct vm_area_struct *vma = vmf->vma;

	int ret;

......

	ret = vma->vm_ops->fault(vmf);

......

	return ret;

}

这里调用了 struct vm_operations_struct vm_ops 的 fault 函数。还记得咱们上面用 mmap 映射文件的时候，对于 ext4 文件系统，vm_ops 指向了 ext4_file_vm_ops，也就是调用了 ext4_filemap_fault。

static const struct vm_operations_struct ext4_file_vm_ops = {

	.fault		= ext4_filemap_fault,

	.map_pages	= filemap_map_pages,

	.page_mkwrite   = ext4_page_mkwrite,

};

int ext4_filemap_fault(struct vm_fault *vmf)

{

	struct inode *inode = file_inode(vmf->vma->vm_file);

......

	err = filemap_fault(vmf);

......

	return err;

}

ext4_filemap_fault 里面的逻辑我们很容易就能读懂。vm_file 就是咱们当时 mmap 的时候映射的那个文件，然后我们需要调用 filemap_fault。对于文件映射来说，一般这个文件会在物理内存里面有页面作为它的缓存，find_get_page 就是找那个页。如果找到了，就调用 do_async_mmap_readahead，预读一些数据到内存里面；如果没有，就跳到 no_cached_page。

int filemap_fault(struct vm_fault *vmf)

{

	int error;

	struct file *file = vmf->vma->vm_file;

	struct address_space *mapping = file->f_mapping;

	struct inode *inode = mapping->host;

	pgoff_t offset = vmf->pgoff;

	struct page *page;

	int ret = 0;

......

	page = find_get_page(mapping, offset);

	if (likely(page) && !(vmf->flags & FAULT_FLAG_TRIED)) {

		do_async_mmap_readahead(vmf->vma, ra, file, page, offset);

	} else if (!page) {

		goto no_cached_page;

	}

......

	vmf->page = page;

	return ret | VM_FAULT_LOCKED;

no_cached_page:

	error = page_cache_read(file, offset, vmf->gfp_mask);

......

}

如果没有物理内存中的缓存页，那我们就调用 page_cache_read。在这里显示分配一个缓存页，将这一页加到 lru 表里面，然后在 address_space 中调用 address_space_operations 的 readpage 函数，将文件内容读到内存中。address_space 的作用咱们上面也介绍过了。

static int page_cache_read(struct file *file, pgoff_t offset, gfp_t gfp_mask)

{

	struct address_space *mapping = file->f_mapping;

	struct page *page;

......

	page = __page_cache_alloc(gfp_mask|__GFP_COLD);

......

	ret = add_to_page_cache_lru(page, mapping, offset, gfp_mask & GFP_KERNEL);

......

	ret = mapping->a_ops->readpage(file, page);

......

}

struct address_space_operations 对于 ext4 文件系统的定义如下所示。这么说来，上面的 readpage 调用的其实是 ext4_readpage。因为我们还没讲到文件系统，这里我们不详细介绍 ext4_readpage 具体干了什么。你只要知道，最后会调用 ext4_read_inline_page，这里面有部分逻辑和内存映射有关就行了。

static const struct address_space_operations ext4_aops = {

	.readpage		= ext4_readpage,

	.readpages		= ext4_readpages,

......

};

static int ext4_read_inline_page(struct inode *inode, struct page *page)

{

	void *kaddr;

......

	kaddr = kmap_atomic(page);

	ret = ext4_read_inline_data(inode, kaddr, len, &iloc);

	flush_dcache_page(page);

	kunmap_atomic(kaddr);

......

}

在 ext4_read_inline_page 函数里，我们需要先调用 kmap_atomic，将物理内存映射到内核的虚拟地址空间，得到内核中的地址 kaddr。 我们在前面提到过 kmap_atomic，它是用来做临时内核映射的。本来把物理内存映射到用户虚拟地址空间，不需要在内核里面映射一把。但是，现在因为要从文件里面读取数据并写入这个物理页面，又不能使用物理地址，我们只能使用虚拟地址，这就需要在内核里面临时映射一把。临时映射后，ext4_read_inline_data 读取文件到这个虚拟地址。读取完毕后，我们取消这个临时映射 kunmap_atomic 就行了。

至于 kmap_atomic 的具体实现，我们还是放到内核映射部分再讲。

我们再来看第三种情况，do_swap_page。之前我们讲过物理内存管理，你这里可以回忆一下。如果长时间不用，就要换出到硬盘，也就是 swap，现在这部分数据又要访问了，我们还得想办法再次读到内存中来。

int do_swap_page(struct vm_fault *vmf)

{

	struct vm_area_struct *vma = vmf->vma;

	struct page *page, *swapcache;

	struct mem_cgroup *memcg;

	swp_entry_t entry;

	pte_t pte;

......

	entry = pte_to_swp_entry(vmf->orig_pte);

......

	page = lookup_swap_cache(entry);

	if (!page) {

		page = swapin_readahead(entry, GFP_HIGHUSER_MOVABLE, vma,

					vmf->address);

......

	}

......

	swapcache = page;

......

	pte = mk_pte(page, vma->vm_page_prot);

......

	set_pte_at(vma->vm_mm, vmf->address, vmf->pte, pte);

	vmf->orig_pte = pte;

......

	swap_free(entry);

......

}

do_swap_page 函数会先查找 swap 文件有没有缓存页。如果没有，就调用 swapin_readahead，将 swap 文件读到内存中来，形成内存页，并通过 mk_pte 生成页表项。set_pte_at 将页表项插入页表，swap_free 将 swap 文件清理。因为重新加载回内存了，不再需要 swap 文件了。

swapin_readahead 会最终调用 swap_readpage，在这里，我们看到了熟悉的 readpage 函数，也就是说读取普通文件和读取 swap 文件，过程是一样的，同样需要用 kmap_atomic 做临时映射。

int swap_readpage(struct page *page, bool do_poll)

{

	struct bio *bio;

	int ret = 0;

	struct swap_info_struct *sis = page_swap_info(page);

	blk_qc_t qc;

	struct block_device *bdev;

......

	if (sis->flags & SWP_FILE) {

		struct file *swap_file = sis->swap_file;

		struct address_space *mapping = swap_file->f_mapping;

		ret = mapping->a_ops->readpage(swap_file, page);

		return ret;

	}

......

}

通过上面复杂的过程，用户态缺页异常处理完毕了。物理内存中有了页面，页表也建立好了映射。接下来，用户程序在虚拟内存空间里面，可以通过虚拟地址顺利经过页表映射的访问物理页面上的数据了。

为了加快映射速度，我们不需要每次从虚拟地址到物理地址的转换都走一遍页表。

页表一般都很大，只能存放在内存中。操作系统每次访问内存都要折腾两步，先通过查询页表得到物理地址，然后访问该物理地址读取指令、数据。

为了提高映射速度，我们引入了TLB（Translation Lookaside Buffer），我们经常称为快表，专门用来做地址映射的硬件设备。它不在内存中，可存储的数据比较少，但是比内存要快。所以，我们可以想象，TLB 就是页表的 Cache，其中存储了当前最可能被访问到的页表项，其内容是部分页表项的一个副本。

有了 TLB 之后，地址映射的过程就像图中画的。我们先查块表，块表中有映射关系，然后直接转换为物理地址。如果在 TLB 查不到映射关系时，才会到内存中查询页表。

总结时刻

用户态的内存映射机制，我们解析的差不多了，我们来总结一下，用户态的内存映射机制包含以下几个部分。

用户态内存映射函数 mmap，包括用它来做匿名映射和文件映射。

用户态的页表结构，存储位置在 mm_struct 中。

在用户态访问没有映射的内存会引发缺页异常，分配物理页表、补齐页表。如果是匿名映射则分配物理内存；如果是 swap，则将 swap 文件读入；如果是文件映射，则将文件读入。

课堂练习

你可以试着用 mmap 系统调用，写一个程序来映射一个文件，并读取文件的内容。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

26 | 内核态内存映射：如何找到正确的会议室？

前面讲用户态内存映射机制的时候，我们已经多次引申出了内核的映射机制，但是咱们都暂时放了放，这一节我们就来详细解析一下，让你彻底搞懂它。

首先，你要知道，内核态的内存映射机制，主要包含以下几个部分：

内核态内存映射函数 vmalloc、kmap_atomic 是如何工作的；

内核态页表是放在哪里的，如何工作的？swapper_pg_dir 是怎么回事；

出现了内核态缺页异常应该怎么办？

内核页表

和用户态页表不同，在系统初始化的时候，我们就要创建内核页表了。

我们从内核页表的根 swapper_pg_dir 开始找线索，在 arch/x86/include/asm/pgtable_64.h 中就能找到它的定义。

extern pud_t level3_kernel_pgt[512];

extern pud_t level3_ident_pgt[512];

extern pmd_t level2_kernel_pgt[512];

extern pmd_t level2_fixmap_pgt[512];

extern pmd_t level2_ident_pgt[512];

extern pte_t level1_fixmap_pgt[512];

extern pgd_t init_top_pgt[];

#define swapper_pg_dir init_top_pgt

swapper_pg_dir 指向内核最顶级的目录 pgd，同时出现的还有几个页表目录。我们可以回忆一下，64 位系统的虚拟地址空间的布局，其中 XXX_ident_pgt 对应的是直接映射区，XXX_kernel_pgt 对应的是内核代码区，XXX_fixmap_pgt 对应的是固定映射区。

它们是在哪里初始化的呢？在汇编语言的文件里面的 arch\x86\kernel\head_64.S。这段代码比较难看懂，你只要明白它是干什么的就行了。

__INITDATA

NEXT_PAGE(init_top_pgt)

	.quad   level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE

	.org    init_top_pgt + PGD_PAGE_OFFSET*8, 0

	.quad   level3_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE

	.org    init_top_pgt + PGD_START_KERNEL*8, 0

	/* (2^48-(2*1024*1024*1024))/(2^39) = 511 */

	.quad   level3_kernel_pgt - __START_KERNEL_map + _PAGE_TABLE

NEXT_PAGE(level3_ident_pgt)

	.quad	level2_ident_pgt - __START_KERNEL_map + _KERNPG_TABLE

	.fill	511, 8, 0

NEXT_PAGE(level2_ident_pgt)

	/* Since I easily can, map the first 1G.

	 * Don't set NX because code runs from these pages.

	 */

	PMDS(0, __PAGE_KERNEL_IDENT_LARGE_EXEC, PTRS_PER_PMD)

NEXT_PAGE(level3_kernel_pgt)

	.fill	L3_START_KERNEL,8,0

	/* (2^48-(2*1024*1024*1024)-((2^39)*511))/(2^30) = 510 */

	.quad	level2_kernel_pgt - __START_KERNEL_map + _KERNPG_TABLE

	.quad	level2_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE

NEXT_PAGE(level2_kernel_pgt)

	/*

	 * 512 MB kernel mapping. We spend a full page on this pagetable

	 * anyway.

	 *

	 * The kernel code+data+bss must not be bigger than that.

	 *

	 * (NOTE: at +512MB starts the module area, see MODULES_VADDR.

	 *  If you want to increase this then increase MODULES_VADDR

	 *  too.)

	 */

	PMDS(0, __PAGE_KERNEL_LARGE_EXEC,

		KERNEL_IMAGE_SIZE/PMD_SIZE)

NEXT_PAGE(level2_fixmap_pgt)

	.fill	506,8,0

	.quad	level1_fixmap_pgt - __START_KERNEL_map + _PAGE_TABLE

	/* 8MB reserved for vsyscalls + a 2MB hole = 4 + 1 entries */

	.fill	5,8,0

NEXT_PAGE(level1_fixmap_pgt)

	.fill	51

内核页表的顶级目录 init_top_pgt，定义在 __INITDATA 里面。咱们讲过 ELF 的格式，也讲过虚拟内存空间的布局。它们都有代码段，还有一些初始化了的全局变量，放在.init 区域。这些说的就是这个区域。可以看到，页表的根其实是全局变量，这就使得我们初始化的时候，甚至内存管理还没有初始化的时候，很容易就可以定位到。

接下来，定义 init_top_pgt 包含哪些项，这个汇编代码比较难懂了。你可以简单地认为，quad 是声明了一项的内容，org 是跳到了某个位置。

所以，init_top_pgt 有三项，上来先有一项，指向的是 level3_ident_pgt，也即直接映射区页表的三级目录。为什么要减去 __START_KERNEL_map 呢？因为 level3_ident_pgt 是定义在内核代码里的，写代码的时候，写的都是虚拟地址，谁写代码的时候也不知道将来加载的物理地址是多少呀，对不对？

因为 level3_ident_pgt 是在虚拟地址的内核代码段里的，而 __START_KERNEL_map 正是虚拟地址空间的内核代码段的起始地址，这在讲 64 位虚拟地址空间的时候都讲过了，要是想不起来就赶紧去回顾一下。这样，level3_ident_pgt 减去 __START_KERNEL_map 才是物理地址。

第一项定义完了以后，接下来我们跳到 PGD_PAGE_OFFSET 的位置，再定义一项。从定义可以看出，这一项就应该是 __PAGE_OFFSET_BASE 对应的。__PAGE_OFFSET_BASE 是虚拟地址空间里面内核的起始地址。第二项也指向 level3_ident_pgt，直接映射区。

PGD_PAGE_OFFSET = pgd_index(__PAGE_OFFSET_BASE)

PGD_START_KERNEL = pgd_index(__START_KERNEL_map)

L3_START_KERNEL = pud_index(__START_KERNEL_map)

第二项定义完了以后，接下来跳到 PGD_START_KERNEL 的位置，再定义一项。从定义可以看出，这一项应该是 __START_KERNEL_map 对应的项，__START_KERNEL_map 是虚拟地址空间里面内核代码段的起始地址。第三项指向 level3_kernel_pgt，内核代码区。

接下来的代码就很类似了，就是初始化个表项，然后指向下一级目录，最终形成下面这张图。

内核页表定义完了，一开始这里面的页表能够覆盖的内存范围比较小。例如，内核代码区 512M，直接映射区 1G。这个时候，其实只要能够映射基本的内核代码和数据结构就可以了。可以看出，里面还空着很多项，可以用于将来映射巨大的内核虚拟地址空间，等用到的时候再进行映射。

如果是用户态进程页表，会有 mm_struct 指向进程顶级目录 pgd，对于内核来讲，也定义了一个 mm_struct，指向 swapper_pg_dir。

struct mm_struct init_mm = {

	.mm_rb		= RB_ROOT,

	.pgd		= swapper_pg_dir,

	.mm_users	= ATOMIC_INIT(2),

	.mm_count	= ATOMIC_INIT(1),

	.mmap_sem	= __RWSEM_INITIALIZER(init_mm.mmap_sem),

	.page_table_lock =  __SPIN_LOCK_UNLOCKED(init_mm.page_table_lock),

	.mmlist		= LIST_HEAD_INIT(init_mm.mmlist),

	.user_ns	= &init_user_ns,

	INIT_MM_CONTEXT(init_mm)

};

定义完了内核页表，接下来是初始化内核页表，在系统启动的时候 start_kernel 会调用 setup_arch。

void __init setup_arch(char **cmdline_p)

{

	/*

	 * copy kernel address range established so far and switch

	 * to the proper swapper page table

	 */

	clone_pgd_range(swapper_pg_dir     + KERNEL_PGD_BOUNDARY,

			initial_page_table + KERNEL_PGD_BOUNDARY,

			KERNEL_PGD_PTRS);

	load_cr3(swapper_pg_dir);

	__flush_tlb_all();

......

	init_mm.start_code = (unsigned long) _text;

	init_mm.end_code = (unsigned long) _etext;

	init_mm.end_data = (unsigned long) _edata;

	init_mm.brk = _brk_end;

......

	init_mem_mapping();

......

}

在 setup_arch 中，load_cr3(swapper_pg_dir) 说明内核页表要开始起作用了，并且刷新了 TLB，初始化 init_mm 的成员变量，最重要的就是 init_mem_mapping。最终它会调用 kernel_physical_mapping_init。

/*

 * Create page table mapping for the physical memory for specific physical

 * addresses. The virtual and physical addresses have to be aligned on PMD level

 * down. It returns the last physical address mapped.

 */

unsigned long __meminit

kernel_physical_mapping_init(unsigned long paddr_start,

			     unsigned long paddr_end,

			     unsigned long page_size_mask)

{

	unsigned long vaddr, vaddr_start, vaddr_end, vaddr_next, paddr_last;

	paddr_last = paddr_end;

	vaddr = (unsigned long)__va(paddr_start);

	vaddr_end = (unsigned long)__va(paddr_end);

	vaddr_start = vaddr;

	for (; vaddr < vaddr_end; vaddr = vaddr_next) {

		pgd_t *pgd = pgd_offset_k(vaddr);

		p4d_t *p4d;

		vaddr_next = (vaddr & PGDIR_MASK) + PGDIR_SIZE;

		if (pgd_val(*pgd)) {

			p4d = (p4d_t *)pgd_page_vaddr(*pgd);

			paddr_last = phys_p4d_init(p4d, __pa(vaddr),

						   __pa(vaddr_end),

						   page_size_mask);

			continue;

		}

		p4d = alloc_low_page();

		paddr_last = phys_p4d_init(p4d, __pa(vaddr), __pa(vaddr_end),

					   page_size_mask);

		p4d_populate(&init_mm, p4d_offset(pgd, vaddr), (pud_t *) p4d);

	}

	__flush_tlb_all();

	return paddr_l

在 kernel_physical_mapping_init 里，我们先通过 __va 将物理地址转换为虚拟地址，然后在创建虚拟地址和物理地址的映射页表。

你可能会问，怎么这么麻烦啊？既然对于内核来讲，我们可以用 __va 和 __pa 直接在虚拟地址和物理地址之间直接转来转去，为啥还要辛辛苦苦建立页表呢？因为这是 CPU 和内存的硬件的需求，也就是说，CPU 在保护模式下访问虚拟地址的时候，就会用 CR3 这个寄存器，这个寄存器是 CPU 定义的，作为操作系统，我们是软件，只能按照硬件的要求来。

你可能又会问了，按照咱们将初始化的时候的过程，系统早早就进入了保护模式，到了 setup_arch 里面才 load_cr3，如果使用 cr3 是硬件的要求，那之前是怎么办的呢？如果你仔细去看 arch\x86\kernel\head_64.S，这里面除了初始化内核页表之外，在这之前，还有另一个页表 early_top_pgt。看到关键字 early 了嘛？这个页表就是专门用在真正的内核页表初始化之前，为了遵循硬件的要求而设置的。早期页表不是我们这节的重点，这里我就不展开多说了。

vmalloc 和 kmap_atomic 原理

在用户态可以通过 malloc 函数分配内存，当然 malloc 在分配比较大的内存的时候，底层调用的是 mmap，当然也可以直接通过 mmap 做内存映射，在内核里面也有相应的函数。

在虚拟地址空间里面，有个 vmalloc 区域，从 VMALLOC_START 开始到 VMALLOC_END，可以用于映射一段物理内存。

/**

 *	vmalloc  -  allocate virtually contiguous memory

 *	@size:		allocation size

 *	Allocate enough pages to cover @size from the page level

 *	allocator and map them into contiguous kernel virtual space.

 *

 *	For tight control over page level allocator and protection flags

 *	use __vmalloc() instead.

 */

void *vmalloc(unsigned long size)

{

	return __vmalloc_node_flags(size, NUMA_NO_NODE,

				    GFP_KERNEL);

}

static void *__vmalloc_node(unsigned long size, unsigned long align,

			    gfp_t gfp_mask, pgprot_t prot,

			    int node, const void *caller)

{

	return __vmalloc_node_range(size, align, VMALLOC_START, VMALLOC_END,

				gfp_mask, prot, 0, node, caller);

}

我们再来看内核的临时映射函数 kmap_atomic 的实现。从下面的代码我们可以看出，如果是 32 位有高端地址的，就需要调用 set_pte 通过内核页表进行临时映射；如果是 64 位没有高端地址的，就调用 page_address，里面会调用 lowmem_page_address。其实低端内存的映射，会直接使用 __va 进行临时映射。

void *kmap_atomic_prot(struct page *page, pgprot_t prot)

{

......

	if (!PageHighMem(page))

		return page_address(page);

......

	vaddr = __fix_to_virt(FIX_KMAP_BEGIN + idx);

	set_pte(kmap_pte-idx, mk_pte(page, prot));

......

	return (void *)vaddr;

}

void *kmap_atomic(struct page *page)

{

	return kmap_atomic_prot(page, kmap_prot);

}

static __always_inline void *lowmem_page_address(const struct page *page)

{

	return page_to_virt(page);

}

#define page_to_virt(x)	__va(PFN_PHYS(page_to_pfn(x)

内核态缺页异常

可以看出，kmap_atomic 和 vmalloc 不同。kmap_atomic 发现，没有页表的时候，就直接创建页表进行映射了。而 vmalloc 没有，它只分配了内核的虚拟地址。所以，访问它的时候，会产生缺页异常。

内核态的缺页异常还是会调用 do_page_fault，但是会走到咱们上面用户态缺页异常中没有解析的那部分 vmalloc_fault。这个函数并不复杂，主要用于关联内核页表项。

/*

 * 32-bit:

 *

 *   Handle a fault on the vmalloc or module mapping area

 */

static noinline int vmalloc_fault(unsigned long address)

{

	unsigned long pgd_paddr;

	pmd_t *pmd_k;

	pte_t *pte_k;

	/* Make sure we are in vmalloc area: */

	if (!(address >= VMALLOC_START && address < VMALLOC_END))

		return -1;

	/*

	 * Synchronize this task's top level page-table

	 * with the 'reference' page table.

	 *

	 * Do _not_ use "current" here. We might be inside

	 * an interrupt in the middle of a task switch..

	 */

	pgd_paddr = read_cr3_pa();

	pmd_k = vmalloc_sync_one(__va(pgd_paddr), address);

	if (!pmd_k)

		return -1;

	pte_k = pte_offset_kernel(pmd_k, address);

	if (!pte_present(*pte_k))

		return -1;

	return 0

总结时刻

至此，内核态的内存映射也讲完了。这下，我们可以将整个内存管理的体系串起来了。

物理内存根据 NUMA 架构分节点。每个节点里面再分区域。每个区域里面再分页。

物理页面通过伙伴系统进行分配。分配的物理页面要变成虚拟地址让上层可以访问，kswapd 可以根据物理页面的使用情况对页面进行换入换出。

对于内存的分配需求，可能来自内核态，也可能来自用户态。

对于内核态，kmalloc 在分配大内存的时候，以及 vmalloc 分配不连续物理页的时候，直接使用伙伴系统，分配后转换为虚拟地址，访问的时候需要通过内核页表进行映射。

对于 kmem_cache 以及 kmalloc 分配小内存，则使用 slub 分配器，将伙伴系统分配出来的大块内存切成一小块一小块进行分配。

kmem_cache 和 kmalloc 的部分不会被换出，因为用这两个函数分配的内存多用于保持内核关键的数据结构。内核态中 vmalloc 分配的部分会被换出，因而当访问的时候，发现不在，就会调用 do_page_fault。

对于用户态的内存分配，或者直接调用 mmap 系统调用分配，或者调用 malloc。调用 malloc 的时候，如果分配小的内存，就用 sys_brk 系统调用；如果分配大的内存，还是用 sys_mmap 系统调用。正常情况下，用户态的内存都是可以换出的，因而一旦发现内存中不存在，就会调用 do_page_fault。

课堂练习

伙伴系统分配好了物理页面之后，如何转换成为虚拟地址呢？请研究一下 page_address 函数的实现。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

27 | 文件系统：项目成果要归档，我们就需要档案库

咱们花了这么长的时间，规划了会议室管理系统，这样多个项目执行的时候，隔离性可以得到保证。但是，会议室里面保存的资料还是暂时的，一旦项目结束，会议室会被回收，会议室里面的资料就丢失了。有一些资料我们希望项目结束也能继续保存，这就需要一个和项目运行生命周期无关的地方，可以永久保存，并且空间也要比会议室大的多。

文件系统的功能规划

要知道，这些资料才是咱们公司的财富，是执行多个项目积累下来的，是公司竞争力的保证，需要有一个地方归档。这就需要我们有一个存放资料的档案库，在操作系统中就是文件系统。那我们应该如何组织规划文件系统这个档案库呢？

对于运行的进程来说，内存就像一个纸箱子，仅仅是一个暂存数据的地方，而且空间有限。如果我们想要进程结束之后，数据依然能够保存下来，就不能只保存在内存里，而是应该保存在外部存储中。就像图书馆这种地方，不仅空间大，而且能够永久保存。

我们最常用的外部存储就是硬盘，数据是以文件的形式保存在硬盘上的。为了管理这些文件，我们在规划文件系统的时候，需要考虑到以下几点。

第一点，文件系统要有严格的组织形式，使得文件能够以块为单位进行存储。这就像图书馆里，我们会给设置一排排书架，然后再把书架分成一个个小格子，有的项目存放的资料非常多，一个格子放不下，就需要多个格子来进行存放。我们把这个区域称为存放原始资料的仓库区。

第二点，文件系统中也要有索引区，用来方便查找一个文件分成的多个块都存放在了什么位置。这就好比，图书馆的书太多了，为了方便查找，我们需要专门设置一排书架，这里面会写清楚整个档案库有哪些资料，资料在哪个架子的哪个格子上。这样找资料的时候就不用跑遍整个档案库，在这个书架上找到后，直奔目标书架就可以了。

第三点，如果文件系统中有的文件是热点文件，近期经常被读取和写入，文件系统应该有缓存层。这就相当于图书馆里面的热门图书区，这里面的书都是畅销书或者是常常被借还的图书。因为借还的次数比较多，那就没必要每次有人还了之后，还放回遥远的货架，我们可以专门开辟一个区域，放置这些借还频次高的图书。这样借还的效率就会提高。

第四点，文件应该用文件夹的形式组织起来，方便管理和查询。这就像在图书馆里面，你可以给这些资料分门别类，比如分成计算机类、文学类、历史类等等。这样你也容易管理，项目组借阅的时候只要在某个类别中去找就可以了。

在文件系统中，每个文件都有一个名字，这样我们访问一个文件，希望通过它的名字就可以找到。文件名就是一个普通的文本。当然文件名会经常冲突，不同用户取相同的名字的情况还是会经常出现的。

要想把很多的文件有序地组织起来，我们就需要把它们成为目录或者文件夹。这样，一个文件夹里可以包含文件夹，也可以包含文件，这样就形成了一种树形结构。而我们可以将不同的用户放在不同的用户目录下，就可以一定程度上避免了命名的冲突问题。

如图所示，不同的用户的文件放在不同的目录下，虽然很多文件都叫“文件 1”，只要在不同的目录下，就不会有问题。

有了目录结构，定位一个文件的时候，我们还会分绝对路径（Absolute Path）和相对路径（Relative Path）。所谓绝对路径，就是从根目录开始一直到当前的文件，例如“/ 根目录 / 用户 A 目录 / 目录 1/ 文件 2”就是一个绝对路径。而通过 cd 命令可以改变当前路径，例如“cd / 根目录 / 用户 A 目录”，就是将用户 A 目录设置为当前目录，而刚才那个文件的相对路径就变成了“./ 目录 1/ 文件 2”。

第五点，Linux 内核要在自己的内存里面维护一套数据结构，来保存哪些文件被哪些进程打开和使用。这就好比，图书馆里会有个图书管理系统，记录哪些书被借阅了，被谁借阅了，借阅了多久，什么时候归还。

好了，这样下来，这文件系统的几个部分，是不是就很好理解、记忆了？你不用死记硬背，只要按照一个正常的逻辑去理解，自然而然就能记住了。接下来的整个章节，我们都要围绕这五点展开解析。

文件系统相关命令行

在 Linux 命令的那一节，我们学了一些简单的文件操作的命令，这里我们再来学几个常用的。

首先是格式化，也即将一块盘使用命令组织成一定格式的文件系统的过程。咱们买个硬盘或者 U 盘，经常说要先格式化，才能放文件，说的就是这个。

使用 Windows 的时候，咱们常格式化的格式为NTFS（New Technology File System）。在 Linux 下面，常用的是 ext3 或者 ext4。

当一个 Linux 系统插入了一块没有格式化的硬盘的时候，我们可以通过命令fdisk -l，查看格式化和没有格式化的分区。

# fdisk -l

Disk /dev/vda: 21.5 GB, 21474836480 bytes, 41943040 sectors

Units = sectors of 1 * 512 = 512 bytes

Sector size (logical/physical): 512 bytes / 512 bytes

I/O size (minimum/optimal): 512 bytes / 512 bytes

Disk label type: dos

Disk identifier: 0x000a4c75

   Device Boot      Start         End      Blocks   Id  System

/dev/vda1   *        2048    41943006    20970479+  83  Linux

Disk /dev/vdc: 107.4 GB, 107374182400 bytes, 209715200 sectors

Units = sectors of 1 * 512 = 512 bytes

Sector size (logical/physical): 512 bytes / 512 bytes

I/O size (minimum/optimal): 512 bytes / 512 bytes

例如，从上面的命令的输出结果可以看出，vda 这块盘大小 21.5G，是格式化了的，有一个分区 /dev/vda1。vdc 这块盘大小 107.4G，是没有格式化的。

我们可以通过命令mkfs.ext3或者mkfs.ext4进行格式化。

mkfs.ext4 /dev/vdc

执行完这个命令后，vdc 会建立一个分区，格式化为 ext4 文件系统的格式。至于这个格式是如果组织的，我们下一节仔细讲。

当然，你也可以选择不将整块盘格式化为一个分区，而是格式化为多个分区。下面的这个命令行可以启动一个交互式程序。

fdisk /dev/vdc

在这个交互式程序中，你可以输入p来打印当前分了几个区。如果没有分过，那这个列表应该是空的。

接下来，你可以输入n新建一个分区。它会让你选择创建主分区 primary，还是扩展分区 extended。我们一般都会选择主分区 p。

接下来，它会让你输入分区号。如果原来没有分过区，应该从 1 开始。或者你直接回车，使用默认值也行。

接下来，你可以一路选择默认值，直到让你指定这个分区的大小，通过 +sizeM 或者 +sizeK 的方式，默认值是整块盘都用上。你可以 输入 +5620M 分配一个 5G 的分区。这个时候再输入 p，就能看到新创建的分区了，最后输入 w，将对分区的修改写入硬盘。

分区结束之后，可能会出现 vdc1, vdc2 等多个分区，这个时候你可以 mkfs.ext3 /dev/vdc1 将第一个分区格式化为 ext3，通过 mkfs.ext4 /dev/vdc2 将第二个分区格式化为 ext4.

格式化后的硬盘，需要挂在到某个目录下面，才能作为普通的文件系统进行访问。

mount /dev/vdc1 / 根目录 / 用户 A 目录 / 目录 1

例如，上面这个命令就是将这个文件系统挂在到“/ 根目录 / 用户 A 目录 / 目录 1”这个目录下面。一旦挂在过去，“/ 根目录 / 用户 A 目录 / 目录 1”这个目录下面原来的文件 1 和文件 2 就都看不到了，换成了 vdc1 这个硬盘里面的文件系统的根目录。

有挂载就有卸载，卸载使用umount命令。

umount / 根目录 / 用户 A 目录 / 目录 1

前面我们讲过，Linux 里面一切都是文件，那从哪里看出是什么文件呢？要从 ls -l 的结果的第一位标识位看出来。

- 表示普通文件；

d 表示文件夹；

c 表示字符设备文件，这在设备那一节讲解；

b 表示块设备文件，这也在设备那一节讲解；

s 表示套接字 socket 文件，这在网络那一节讲解；

l 表示符号链接，也即软链接，就是通过名字指向另外一个文件，例如下面的代码，instance 这个文件就是指向了 /var/lib/cloud/instances 这个文件。软链接的机制我们这一章会讲解。

# ls -l

lrwxrwxrwx 1 root root   61 Dec 14 19:53 instance -> /var/lib/cloud/instances

文件系统相关系统调用

看完了命令行，我们来看一下，如何使用系统调用在操作文件？我们先来看一个完整的例子。

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <fcntl.h>

int main(int argc, char *argv[])

{

  int fd = -1;

  int ret = 1;

  int buffer = 1024;

  int num = 0;

  if((fd=open("./test", O_RDWR|O_CREAT|O_TRUNC))==-1)

  {

    printf("Open Error\n");

    exit(1);

  }

  ret = write(fd, &buffer, sizeof(int));

  if( ret < 0)

  {

    printf("write Error\n");

    exit(1);

  }

  printf("write %d byte(s)\n",ret);

  lseek(fd, 0L, SEEK_SET);

  ret= read(fd, &num, sizeof(int));

  if(ret==-1)

  {

    printf("read Error\n");

    exit(1);

  }

  printf("read %d byte(s)，the number is %d\n", ret, num);

  close(fd);

  return 0;

}

当使用系统调用 open 打开一个文件时，操作系统会创建一些数据结构来表示这个被打开的文件。下一节，我们就会看到这些。为了能够找到这些数据结构，在进程中，我们会为这个打开的文件分配一个文件描述符 fd（File Descriptor）。

文件描述符，就是用来区分一个进程打开的多个文件的。它的作用域就是当前进程，出了当前进程这个文件描述符就没有意义了。open 返回的 fd 必须记录好，我们对这个文件的所有操作都要靠这个 fd，包括最后关闭文件。

在 Open 函数中，有一些参数：

O_CREAT 表示当文件不存在，创建一个新文件；

O_RDWR 表示以读写方式打开；

O_TRUNC 表示打开文件后，将文件的长度截断为 0。

接下来，write 要用于写入数据。第一个参数就是文件描述符，第二个参数表示要写入的数据存放位置，第三个参数表示希望写入的字节数，返回值表示成功写入到文件的字节数。

lseek 用于重新定位读写的位置，第一个参数是文件描述符，第二个参数是重新定位的位置，第三个参数是 SEEK_SET，表示起始位置为文件头，第二个参数和第三个参数合起来表示将读写位置设置为从文件头开始 0 的位置，也即从头开始读写。

read 用于读取数据，第一个参数是文件描述符，第二个参数是读取来的数据存到指向的空间，第三个参数是希望读取的字节数，返回值表示成功读取的字节数。

最终，close 将关闭一个文件。

对于命令行来讲，通过 ls 可以得到文件的属性，使用代码怎么办呢？

我们有下面三个函数，可以返回与打开的文件描述符相关的文件状态信息。这个信息将会写到类型为 struct stat 的 buf 结构中。

int stat(const char *pathname, struct stat *statbuf);

int fstat(int fd, struct stat *statbuf);

int lstat(const char *pathname, struct stat *statbuf);

struct stat {

  dev_t     st_dev;         /* ID of device containing file */

  ino_t     st_ino;         /* Inode number */

  mode_t    st_mode;        /* File type and mode */

  nlink_t   st_nlink;       /* Number of hard links */

  uid_t     st_uid;         /* User ID of owner */

  gid_t     st_gid;         /* Group ID of owner */

  dev_t     st_rdev;        /* Device ID (if special file) */

  off_t     st_size;        /* Total size, in bytes */

  blksize_t st_blksize;     /* Block size for filesystem I/O */

  blkcnt_t  st_blocks;      /* Number of 512B blocks allocated */

  struct timespec st_atim;  /* Time of last access */

  struct timespec st_mtim;  /* Time of last modification */

  struct timespec st_ctim;  /* Time of last status change */

};

函数 stat 和 lstat 返回的是通过文件名查到的状态信息。这两个方法区别在于，stat 没有处理符号链接（软链接）的能力。如果一个文件是符号链接，stat 会直接返回它所指向的文件的属性，而 lstat 返回的就是这个符号链接的内容，fstat 则是通过文件描述符获取文件对应的属性。

接下来我们来看，如何使用系统调用列出一个文件夹下面的文件以及文件的属性。

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <fcntl.h>

#include <sys/types.h>

#include <sys/stat.h>

#include <dirent.h>

int main(int argc, char *argv[])

{

  struct stat sb;

  DIR *dirp;

  struct dirent *direntp;

  char filename[128];

  if ((dirp = opendir("/root")) == NULL) {

    printf("Open Directory Error%s\n");

    exit(1);

  }

  while ((direntp = readdir(dirp)) != NULL){

    sprintf(filename, "/root/%s", direntp->d_name);

    if (lstat(filename, &sb) == -1)

    {

      printf("lstat Error%s\n");

      exit(1);

    }

    printf("name : %s, mode : %d, size : %d, user id : %d\n", direntp->d_name, sb.st_mode, sb.st_size, sb.st_uid);

  }

  closedir(dirp);

  return 0

}

opendir 函数打开一个目录名所对应的 DIR 目录流。并返回指向 DIR 目录流的指针。流定位在 DIR 目录流的第一个条目。

readdir 函数从 DIR 目录流中读取一个项目，返回的是一个指针，指向 dirent 结构体，且流的自动指向下一个目录条目。如果已经到流的最后一个条目，则返回 NULL。

closedir() 关闭参数 dir 所指的目录流。

到这里，你应该既会使用系统调用操作文件，也会使用系统调用操作目录了。下一节，我们开始来看内核如何实现的。

总结时刻

这一节，我们对于文件系统的主要功能有了一个总体的印象，我们通过下面这张图梳理一下。

在文件系统上，需要维护文件的严格的格式，要通过 mkfs.ext4 命令来格式化为严格的格式。

每一个硬盘上保存的文件都要有一个索引，来维护这个文件上的数据块都保存在哪里。

文件通过文件夹组织起来，可以方便用户使用。

为了能够更快读取文件，内存里会分配一块空间作为缓存，让一些数据块放在缓存里面。

在内核中，要有一整套的数据结构来表示打开的文件。

在用户态，每个打开的文件都有一个文件描述符，可以通过各种文件相关的系统调用，操作这个文件描述符。

课堂练习

你可以试着将一块空闲的硬盘，分区成为两块，并安装不同的文件系统，进行挂载。这是 Linux 运维人员经常做的一件事情。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

28 | 硬盘文件系统：如何最合理地组织档案库的文档？

上一节，我们按照图书馆的模式，规划了档案库，也即文件系统应该有的样子。这一节，我们将这个模式搬到硬盘上来看一看。

我们常见的硬盘是上面这幅图左边的样子，中间圆的部分是磁盘的盘片，右边的图是抽象出来的图。每一层里分多个磁道，每个磁道分多个扇区，每个扇区是 512 个字节。

文件系统就是安装在这样的硬盘之上。这一节我们重点目前 Linux 下最主流的文件系统格式——ext 系列的文件系统的格式。

inode 与块的存储

就像图书馆的书架都要分成大小相同的格子，硬盘也是一样的。硬盘分成相同大小的单元，我们称为块（Block）。一块的大小是扇区大小的整数倍，默认是 4K。在格式化的时候，这个值是可以设定的。

一大块硬盘被分成了一个个小的块，用来存放文件的数据部分。这样一来，如果我们像存放一个文件，就不用给他分配一块连续的空间了。我们可以分散成一个个小块进行存放。这样就灵活得多，也比较容易添加、删除和插入数据。

但是这也带来一个新的问题，那就是文件的数据存放得太散，找起来就比较困难。有什么办法解决呢？我们是不是可以像图书馆那样，也设立一个索引区域，用来维护“某个文件分成几块、每一块在哪里“等等这些基本信息?

另外，文件还有元数据部分，例如名字、权限等，这就需要一个结构inode来存放。

什么是 inode 呢？inode 的“i”是 index 的意思，其实就是“索引”，类似图书馆的索引区域。既然如此，我们每个文件都会对应一个 inode；一个文件夹就是一个文件，也对应一个 inode。

至于 inode 里面有哪些信息，其实我们在内核中就有定义。你可以看下面这个数据结构。

struct ext4_inode {

	__le16	i_mode;		/* File mode */

	__le16	i_uid;		/* Low 16 bits of Owner Uid */

	__le32	i_size_lo;	/* Size in bytes */

	__le32	i_atime;	/* Access time */

	__le32	i_ctime;	/* Inode Change time */

	__le32	i_mtime;	/* Modification time */

	__le32	i_dtime;	/* Deletion Time */

	__le16	i_gid;		/* Low 16 bits of Group Id */

	__le16	i_links_count;	/* Links count */

	__le32	i_blocks_lo;	/* Blocks count */

	__le32	i_flags;	/* File flags */

......

	__le32	i_block[EXT4_N_BLOCKS];/* Pointers to blocks */

	__le32	i_generation;	/* File version (for NFS) */

	__le32	i_file_acl_lo;	/* File ACL */

	__le32	i_size_high;

......

};

从这个数据结构中，我们可以看出，inode 里面有文件的读写权限 i_mode，属于哪个用户 i_uid，哪个组 i_gid，大小是多少 i_size_io，占用多少个块 i_blocks_io。咱们讲 ls 命令行的时候，列出来的权限、用户、大小这些信息，就是从这里面取出来的。

另外，这里面还有几个与文件相关的时间。i_atime 是 access time，是最近一次访问文件的时间；i_ctime 是 change time，是最近一次更改 inode 的时间；i_mtime 是 modify time，是最近一次更改文件的时间。

这里你需要注意区分几个地方。首先，访问了，不代表修改了，也可能只是打开看看，就会改变 access time。其次，修改 inode，有可能修改的是用户和权限，没有修改数据部分，就会改变 change time。只有数据也修改了，才改变 modify time。

我们刚才说的“某个文件分成几块、每一块在哪里”，这些在 inode 里面，应该保存在 i_block 里面。

具体如何保存的呢？EXT4_N_BLOCKS 有如下的定义，计算下来一共有 15 项。

#define	EXT4_NDIR_BLOCKS		12

#define	EXT4_IND_BLOCK			EXT4_NDIR_BLOCKS

#define	EXT4_DIND_BLOCK			(EXT4_IND_BLOCK + 1)

#define	EXT4_TIND_BLOCK			(EXT4_DIND_BLOCK + 1)

#define	EXT4_N_BLOCKS			(EXT4_TIND_BLOCK + 1)

在 ext2 和 ext3 中，其中前 12 项直接保存了块的位置，也就是说，我们可以通过 i_block[0-11]，直接得到保存文件内容的块。

但是，如果一个文件比较大，12 块放不下。当我们用到 i_block[12] 的时候，就不能直接放数据块的位置了，要不然 i_block 很快就会用完了。这该怎么办呢？我们需要想个办法。我们可以让 i_block[12] 指向一个块，这个块里面不放数据块，而是放数据块的位置，这个块我们称为间接块。也就是说，我们在 i_block[12] 里面放间接块的位置，通过 i_block[12] 找到间接块后，间接块里面放数据块的位置，通过间接块可以找到数据块。

如果文件再大一些，i_block[13] 会指向一个块，我们可以用二次间接块。二次间接块里面存放了间接块的位置，间接块里面存放了数据块的位置，数据块里面存放的是真正的数据。如果文件再大一些，i_block[14] 会指向三次间接块。原理和上面都是一样的，就像一层套一层的俄罗斯套娃，一层一层打开，才能拿到最中心的数据块。

如果你稍微有点经验，现在你应该能够意识到，这里面有一个非常显著的问题，对于大文件来讲，我们要多次读取硬盘才能找到相应的块，这样访问速度就会比较慢。

为了解决这个问题，ext4 做了一定的改变。它引入了一个新的概念，叫作Extents。

我们来解释一下 Extents。比方说，一个文件大小为 128M，如果使用 4k 大小的块进行存储，需要 32k 个块。如果按照 ext2 或者 ext3 那样散着放，数量太大了。但是 Extents 可以用于存放连续的块，也就是说，我们可以把 128M 放在一个 Extents 里面。这样的话，对大文件的读写性能提高了，文件碎片也减少了。

Exents 如何来存储呢？它其实会保存成一棵树。

树有一个个的节点，有叶子节点，也有分支节点。每个节点都有一个头，ext4_extent_header 可以用来描述某个节点。

struct ext4_extent_header {

	__le16	eh_magic;	/* probably will support different formats */

	__le16	eh_entries;	/* number of valid entries */

	__le16	eh_max;		/* capacity of store in entries */

	__le16	eh_depth;	/* has tree real underlying blocks? */

	__le32	eh_generation;	/* generation of the tree */

};

我们仔细来看里面的内容。eh_entries 表示这个节点里面有多少项。这里的项分两种，如果是叶子节点，这一项会直接指向硬盘上的连续块的地址，我们称为数据节点 ext4_extent；如果是分支节点，这一项会指向下一层的分支节点或者叶子节点，我们称为索引节点 ext4_extent_idx。这两种类型的项的大小都是 12 个 byte。

/*

 * This is the extent on-disk structure.

 * It's used at the bottom of the tree.

 */

struct ext4_extent {

	__le32	ee_block;	/* first logical block extent covers */

	__le16	ee_len;		/* number of blocks covered by extent */

	__le16	ee_start_hi;	/* high 16 bits of physical block */

	__le32	ee_start_lo;	/* low 32 bits of physical block */

};

/*

 * This is index on-disk structure.

 * It's used at all the levels except the bottom.

 */

struct ext4_extent_idx {

	__le32	ei_block;	/* index covers logical blocks from 'block' */

	__le32	ei_leaf_lo;	/* pointer to the physical block of the next *

				 * level. leaf or next index could be there */

	__le16	ei_leaf_hi;	/* high 16 bits of physical block */

	__u16	ei_unused;

};

如果文件不大，inode 里面的 i_block 中，可以放得下一个 ext4_extent_header 和 4 项 ext4_extent。所以这个时候，eh_depth 为 0，也即 inode 里面的就是叶子节点，树高度为 0。

如果文件比较大，4 个 extent 放不下，就要分裂成为一棵树，eh_depth>0 的节点就是索引节点，其中根节点深度最大，在 inode 中。最底层 eh_depth=0 的是叶子节点。

除了根节点，其他的节点都保存在一个块 4k 里面，4k 扣除 ext4_extent_header 的 12 个 byte，剩下的能够放 340 项，每个 extent 最大能表示 128MB 的数据，340 个 extent 会使你的表示的文件达到 42.5GB。这已经非常大了，如果再大，我们可以增加树的深度。

inode 位图和块位图

到这里，我们知道了，硬盘上肯定有一系列的 inode 和一系列的块排列起来。

接下来的问题是，如果我要保存一个数据块，或者要保存一个 inode，我应该放在硬盘上的哪个位置呢？难道需要将所有的 inode 列表和块列表扫描一遍，找个空的地方随便放吗？

当然，这样效率太低了。所以在文件系统里面，我们专门弄了一个块来保存 inode 的位图。在这 4k 里面，每一位对应一个 inode。如果是 1，表示这个 inode 已经被用了；如果是 0，则表示没被用。同样，我们也弄了一个块保存 block 的位图。

上海虹桥火车站的厕位智能引导系统，不知道你有没有见过？这个系统很厉害，我们要想知道哪个位置有没有被占用，不用挨个拉门，从这样一个电子版上就能看到了。

接下来，我们来看位图究竟是如何在 Linux 操作系统里面起作用的。前一节我们讲过，如果创建一个新文件，会调用 open 函数，并且参数会有 O_CREAT。这表示当文件找不到的时候，我们就需要创建一个。open 是一个系统调用，在内核里面会调用 sys_open，定义如下：

SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)

{

	if (force_o_largefile())

		flags |= O_LARGEFILE;

	return do_sys_open(AT_FDCWD, filename, flags, mode);

}

这里我们还是重点看对于 inode 的操作。其实 open 一个文件很复杂，下一节我们会详细分析整个过程。

我们来看接下来的调用链：do_sys_open-> do_filp_open->path_openat->do_last->lookup_open。这个调用链的逻辑是，要打开一个文件，先要根据路径找到文件夹。如果发现文件夹下面没有这个文件，同时又设置了 O_CREAT，就说明我们要在这个文件夹下面创建一个文件，那我们就需要一个新的 inode。

static int lookup_open(struct nameidata *nd, struct path *path,

			struct file *file,

			const struct open_flags *op,

			bool got_write, int *opened)

{

......

	if (!dentry->d_inode && (open_flag & O_CREAT)) {

......

		error = dir_inode->i_op->create(dir_inode, dentry, mode,

						open_flag & O_EXCL);

......

	}

......

}

想要创建新的 inode，我们就要调用 dir_inode，也就是文件夹的 inode 的 create 函数。它的具体定义是这样的：

const struct inode_operations ext4_dir_inode_operations = {

	.create		= ext4_create,

	.lookup		= ext4_lookup,

	.link		= ext4_link,

	.unlink		= ext4_unlink,

	.symlink	= ext4_symlink,

	.mkdir		= ext4_mkdir,

	.rmdir		= ext4_rmdir,

	.mknod		= ext4_mknod,

	.tmpfile	= ext4_tmpfile,

	.rename		= ext4_rename2,

	.setattr	= ext4_setattr,

	.getattr	= ext4_getattr,

	.listxattr	= ext4_listxattr,

	.get_acl	= ext4_get_acl,

	.set_acl	= ext4_set_acl,

	.fiemap         = ext4_fiemap,

};

这里面定义了，如果文件夹 inode 要做一些操作，每个操作对应应该调用哪些函数。这里 create 操作调用的是 ext4_create。

接下来的调用链是这样的：ext4_create->ext4_new_inode_start_handle->__ext4_new_inode。在 __ext4_new_inode 函数中，我们会创建新的 inode。

struct inode *__ext4_new_inode(handle_t *handle, struct inode *dir,

			       umode_t mode, const struct qstr *qstr,

			       __u32 goal, uid_t *owner, __u32 i_flags,

			       int handle_type, unsigned int line_no,

			       int nblocks)

{

......

inode_bitmap_bh = ext4_read_inode_bitmap(sb, group);

......

ino = ext4_find_next_zero_bit((unsigned long *)

					      inode_bitmap_bh->b_data,

					      EXT4_INODES_PER_GROUP(sb), ino);

......

}

这里面一个重要的逻辑就是，从文件系统里面读取 inode 位图，然后找到下一个为 0 的 inode，就是空闲的 inode。

对于 block 位图，在写入文件的时候，也会有这个过程，我就不展开说了。感兴趣的话，你可以自己去找代码看。

文件系统的格式

看起来，我们现在应该能够很顺利地通过 inode 位图和 block 位图创建文件了。如果仔细计算一下，其实还是有问题的。

数据块的位图是放在一个块里面的，共 4k。每位表示一个数据块，共可以表示 4∗1024∗8=2154∗1024∗8=215 个数据块。如果每个数据块也是按默认的 4K，最大可以表示空间为 215∗4∗1024=227215∗4∗1024=227 个 byte，也就是 128M。

也就是说按照上面的格式，如果采用“一个块的位图 + 一系列的块”，外加“一个块的 inode 的位图 + 一系列的 inode 的结构”，最多能够表示 128M。是不是太小了？现在很多文件都比这个大。我们先把这个结构称为一个块组。有 N 多的块组，就能够表示 N 大的文件。

对于块组，我们也需要一个数据结构来表示为 ext4_group_desc。这里面对于一个块组里的 inode 位图 bg_inode_bitmap_lo、块位图 bg_block_bitmap_lo、inode 列表 bg_inode_table_lo，都有相应的成员变量。

这样一个个块组，就基本构成了我们整个文件系统的结构。因为块组有多个，块组描述符也同样组成一个列表，我们把这些称为块组描述符表。

当然，我们还需要有一个数据结构，对整个文件系统的情况进行描述，这个就是超级块ext4_super_block。这里面有整个文件系统一共有多少 inode，s_inodes_count；一共有多少块，s_blocks_count_lo，每个块组有多少 inode，s_inodes_per_group，每个块组有多少块，s_blocks_per_group 等。这些都是这类的全局信息。

对于整个文件系统，别忘了咱们讲系统启动的时候说的。如果是一个启动盘，我们需要预留一块区域作为引导区，所以第一个块组的前面要留 1K，用于启动引导区。

最终，整个文件系统格式就是下面这个样子。

这里面我还需要重点说一下，超级块和块组描述符表都是全局信息，而且这些数据很重要。如果这些数据丢失了，整个文件系统都打不开了，这比一个文件的一个块损坏更严重。所以，这两部分我们都需要备份，但是采取不同的策略。

默认情况下，超级块和块组描述符表都有副本保存在每一个块组里面。

如果开启了 sparse_super 特性，超级块和块组描述符表的副本只会保存在块组索引为 0、3、5、7 的整数幂里。除了块组 0 中存在一个超级块外，在块组 1（30=130=1）的第一个块中存在一个副本；在块组 3（31=331=3）、块组 5（51=551=5）、块组 7（71=771=7）、块组 9（32=932=9）、块组 25（52=2552=25）、块组 27（33=2733=27）的第一个 block 处也存在一个副本。

对于超级块来讲，由于超级块不是很大，所以就算我们备份多了也没有太多问题。但是，对于块组描述符表来讲，如果每个块组里面都保存一份完整的块组描述符表，一方面很浪费空间；另一个方面，由于一个块组最大 128M，而块组描述符表里面有多少项，这就限制了有多少个块组，128M * 块组的总数目是整个文件系统的大小，就被限制住了。

我们的改进的思路就是引入Meta Block Groups 特性。

首先，块组描述符表不会保存所有块组的描述符了，而是将块组分成多个组，我们称为元块组（Meta Block Group）。每个元块组里面的块组描述符表仅仅包括自己的，一个元块组包含 64 个块组，这样一个元块组中的块组描述符表最多 64 项。我们假设一共有 256 个块组，原来是一个整的块组描述符表，里面有 256 项，要备份就全备份，现在分成 4 个元块组，每个元块组里面的块组描述符表就只有 64 项了，这就小多了，而且四个元块组自己备份自己的。

根据图中，每一个元块组包含 64 个块组，块组描述符表也是 64 项，备份三份，在元块组的第一个，第二个和最后一个块组的开始处。

这样化整为零，我们就可以发挥出 ext4 的 48 位块寻址的优势了，在超级块 ext4_super_block 的定义中，我们可以看到块寻址的分为高位和地位，均为 32 位，其中有用的是 48 位，2^48 个块是 1EB，足够用了。

struct ext4_super_block {

......

	__le32	s_blocks_count_lo;	/* Blocks count */

	__le32	s_r_blocks_count_lo;	/* Reserved blocks count */

	__le32	s_free_blocks_count_lo;	/* Free blocks count */

......

	__le32	s_blocks_count_hi;	/* Blocks count */

	__le32	s_r_blocks_count_hi;	/* Reserved blocks count */

	__le32	s_free_blocks_count_hi;	/* Free blocks count */

......

}

目录的存储格式

通过前面的描述，我们现在知道了一个普通的文件是如何存储的。有一类特殊的文件，我们会经常用到，就是目录，它是如何保存的呢？

其实目录本身也是个文件，也有 inode。inode 里面也是指向一些块。和普通文件不同的是，普通文件的块里面保存的是文件数据，而目录文件的块里面保存的是目录里面一项一项的文件信息。这些信息我们称为 ext4_dir_entry。从代码来看，有两个版本，在成员来讲几乎没有差别，只不过第二个版本 ext4_dir_entry_2 是将一个 16 位的 name_len，变成了一个 8 位的 name_len 和 8 位的 file_type。

struct ext4_dir_entry {

	__le32	inode;			/* Inode number */

	__le16	rec_len;		/* Directory entry length */

	__le16	name_len;		/* Name length */

	char	name[EXT4_NAME_LEN];	/* File name */

};

struct ext4_dir_entry_2 {

	__le32	inode;			/* Inode number */

	__le16	rec_len;		/* Directory entry length */

	__u8	name_len;		/* Name length */

	__u8	file_type;

	char	name[EXT4_NAME_LEN];	/* File name */

};

在目录文件的块中，最简单的保存格式是列表，就是一项一项地将 ext4_dir_entry_2 列在哪里。

每一项都会保存这个目录的下一级的文件的文件名和对应的 inode，通过这个 inode，就能找到真正的文件。第一项是“.”，表示当前目录，第二项是“…”，表示上一级目录，接下来就是一项一项的文件名和 inode。

有时候，如果一个目录下面的文件太多的时候，我们想在这个目录下找一个文件，按照列表一个个去找，太慢了，于是我们就添加了索引的模式。

如果在 inode 中设置 EXT4_INDEX_FL 标志，则目录文件的块的组织形式将发生变化，变成了下面定义的这个样子：

struct dx_root

{

	struct fake_dirent dot;

	char dot_name[4];

	struct fake_dirent dotdot;

	char dotdot_name[4];

	struct dx_root_info

	{

		__le32 reserved_zero;

		u8 hash_version;

		u8 info_length; /* 8 */

		u8 indirect_levels;

		u8 unused_flags;

	}

	info;

	struct dx_entry	entries[0];

};

当然，首先出现的还是差不多的，第一项是“.”，表示当前目录；第二项是“…”，表示上一级目录，这两个不变。接下来就开始发生改变了。是一个 dx_root_info 的结构，其中最重要的成员变量是 indirect_levels，表示间接索引的层数。

接下来我们来看索引项 dx_entry。这个也很简单，其实就是文件名的哈希值和数据块的一个映射关系。

struct dx_entry

{

	__le32 hash;

	__le32 block;

};

如果我们要查找一个目录下面的文件名，可以通过名称取哈希。如果哈希能够匹配上，就说明这个文件的信息在相应的块里面。然后打开这个块，如果里面不再是索引，而是索引树的叶子节点的话，那里面还是 ext4_dir_entry_2 的列表，我们只要一项一项找文件名就行。通过索引树，我们可以将一个目录下面的 N 多的文件分散到很多的块里面，可以很快地进行查找。

软链接和硬链接的存储格式

还有一种特殊的文件格式，硬链接（Hard Link）和软链接（Symbolic Link）。在讲操作文件的命令的时候，我们讲过软链接的概念。所谓的链接（Link），我们可以认为是文件的别名，而链接又可分为两种，硬链接与软链接。通过下面的命令可以创建。

 ln [参数][源文件或目录][目标文件或目录]

ln -s 创建的是软链接，不带 -s 创建的是硬链接。它们有什么区别呢？在文件系统里面是怎么保存的呢？

如图所示，硬链接与原始文件共用一个 inode 的，但是 inode 是不跨文件系统的，每个文件系统都有自己的 inode 列表，因而硬链接是没有办法跨文件系统的。

而软链接不同，软链接相当于重新创建了一个文件。这个文件也有独立的 inode，只不过打开这个文件看里面内容的时候，内容指向另外的一个文件。这就很灵活了。我们可以跨文件系统，甚至目标文件被删除了，链接文件还是在的，只不过指向的文件找不到了而已。

总结时刻

这一节，我们描述了复杂的硬盘上的文件系统，但是对于咱们平时的应用来讲，用的最多的是两个概念，一个是 inode，一个是数据块。

这里我画了一张图，来总结一下 inode 和数据块在文件系统上的关联关系。

为了表示图中上半部分的那个简单的树形结构，在文件系统上的布局就像图的下半部分一样。无论是文件夹还是文件，都有一个 inode。inode 里面会指向数据块，对于文件夹的数据块，里面是一个表，是下一层的文件名和 inode 的对应关系，文件的数据块里面存放的才是真正的数据。

课堂练习

你知道如何查看 inode 的内容和文件夹的内容吗？

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

29 | 虚拟文件系统：文件多了就需要档案管理系统

上一节，咱们的图书馆书架，也就是硬盘上的文件系统格式都搭建好了，现在我们还需要一个图书管理与借阅系统，也就是文件管理模块，不然我们怎么知道书都借给谁了呢？

进程要想往文件系统里面读写数据，需要很多层的组件一起合作。具体是怎么合作的呢？我们一起来看一看。

在应用层，进程在进行文件读写操作时，可通过系统调用如 sys_open、sys_read、sys_write 等。

在内核，每个进程都需要为打开的文件，维护一定的数据结构。

在内核，整个系统打开的文件，也需要维护一定的数据结构。

Linux 可以支持多达数十种不同的文件系统。它们的实现各不相同，因此 Linux 内核向用户空间提供了虚拟文件系统这个统一的接口，来对文件系统进行操作。它提供了常见的文件系统对象模型，例如 inode、directory entry、mount 等，以及操作这些对象的方法，例如 inode operations、directory operations、file operations 等。

然后就是对接的是真正的文件系统，例如我们上节讲的 ext4 文件系统。

为了读写 ext4 文件系统，要通过块设备 I/O 层，也即 BIO 层。这是文件系统层和块设备驱动的接口。

为了加快块设备的读写效率，我们还有一个缓存层。

最下层是块设备驱动程序。

接下来我们逐层解析。

在这之前，有一点你需要注意。解析系统调用是了解内核架构最有力的一把钥匙，这里我们只要重点关注这几个最重要的系统调用就可以了：

mount 系统调用用于挂载文件系统；

open 系统调用用于打开或者创建文件，创建要在 flags 中设置 O_CREAT，对于读写要设置 flags 为 O_RDWR；

read 系统调用用于读取文件内容；

write 系统调用用于写入文件内容。

挂载文件系统

想要操作文件系统，第一件事情就是挂载文件系统。

内核是不是支持某种类型的文件系统，需要我们进行注册才能知道。例如，咱们上一节解析的 ext4 文件系统，就需要通过 register_filesystem 进行注册，传入的参数是 ext4_fs_type，表示注册的是 ext4 类型的文件系统。这里面最重要的一个成员变量就是 ext4_mount。记住它，这个我们后面还会用。

register_filesystem(&ext4_fs_type);

static struct file_system_type ext4_fs_type = {

	.owner		= THIS_MODULE,

	.name		= "ext4",

	.mount		= ext4_mount,

	.kill_sb	= kill_block_super,

	.fs_flags	= FS_REQUIRES_DEV,

};

如果一种文件系统的类型曾经在内核注册过，这就说明允许你挂载并且使用这个文件系统。

刚才我说了几个需要重点关注的系统调用，那我们就从第一个 mount 系统调用开始解析。mount 系统调用的定义如下：

SYSCALL_DEFINE5(mount, char __user *, dev_name, char __user *, dir_name, char __user *, type, unsigned long, flags, void __user *, data)

{

......

	ret = do_mount(kernel_dev, dir_name, kernel_type, flags, options);

......

}

接下来的调用链为：do_mount->do_new_mount->vfs_kern_mount。

struct vfsmount *

vfs_kern_mount(struct file_system_type *type, int flags, const char *name, void *data)

{

......

	mnt = alloc_vfsmnt(name);

......

	root = mount_fs(type, flags, name, data);

......

	mnt->mnt.mnt_root = root;

	mnt->mnt.mnt_sb = root->d_sb;

	mnt->mnt_mountpoint = mnt->mnt.mnt_root;

	mnt->mnt_parent = mnt;

	list_add_tail(&mnt->mnt_instance, &root->d_sb->s_mounts);

	return &mnt->mnt;

}

vfs_kern_mount 先是创建 struct mount 结构，每个挂载的文件系统都对应于这样一个结构。

struct mount {

	struct hlist_node mnt_hash;

	struct mount *mnt_parent;

	struct dentry *mnt_mountpoint;

	struct vfsmount mnt;

	union {

		struct rcu_head mnt_rcu;

		struct llist_node mnt_llist;

	};

	struct list_head mnt_mounts;	/* list of children, anchored here */

	struct list_head mnt_child;	/* and going through their mnt_child */

	struct list_head mnt_instance;	/* mount instance on sb->s_mounts */

	const char *mnt_devname;	/* Name of device e.g. /dev/dsk/hda1 */

	struct list_head mnt_list;

......

} __randomize_layout;

struct vfsmount {

	struct dentry *mnt_root;	/* root of the mounted tree */

	struct super_block *mnt_sb;	/* pointer to superblock */

	int mnt_flags;

} __randomize_layout;

其中，mnt_parent 是装载点所在的父文件系统，mnt_mountpoint 是装载点在父文件系统中的 dentry；struct dentry 表示目录，并和目录的 inode 关联；mnt_root 是当前文件系统根目录的 dentry，mnt_sb 是指向超级块的指针。

接下来，我们来看调用 mount_fs 挂载文件系统。

struct dentry *

mount_fs(struct file_system_type *type, int flags, const char *name, void *data)

{

	struct dentry *root;

	struct super_block *sb;

......

	root = type->mount(type, flags, name, data);

......

	sb = root->d_sb;

......

}

这里调用的是 ext4_fs_type 的 mount 函数，也就是咱们上面提到的 ext4_mount，从文件系统里面读取超级块。在文件系统的实现中，每个在硬盘上的结构，在内存中也对应相同格式的结构。当所有的数据结构都读到内存里面，内核就可以通过操作这些数据结构，来操作文件系统了。

可以看出来，理解各个数据结构在这里的关系，非常重要。我这里举一个例子，来解析经过 mount 之后，刚刚那些数据结构之间的关系。

我们假设根文件系统下面有一个目录 home，有另外一个文件系统 A 挂载在这个目录 home 下面。在文件系统 A 的根目录下面有另外一个文件夹 hello。由于文件系统 A 已经挂载到了目录 home 下面，所以我们就有了目录 /home/hello，然后有另外一个文件系统 B 挂在在 /home/hello 下面。在文件系统 B 的根目录下面有另外一个文件夹 world，在 world 下面有个文件夹 data。由于文件系统 B 已经挂载到了 /home/hello 下面，所以我们就有了目录 /home/hello/world/data。

为了维护这些关系，操作系统创建了这一系列数据结构。具体你可以看下面的图。

文件系统是树形关系。如果所有的文件夹都是几代单传，那就变成了一条线。你注意看图中的三条斜线。

第一条线是最左边的向左斜的dentry 斜线。每一个文件和文件夹都有 dentry，用于和 inode 关联。第二条线是最右面的向右斜的mount 斜线，因为这个例子涉及两次文件系统的挂载，再加上启动的时候挂载的根文件系统，一共三个 mount。第三条线是中间的向右斜的file 斜线，每个打开的文件都有一个 file 结构，它里面有两个变量，一个指向相应的 mount，一个指向相应的 dentry。

我们从最上面往下看。根目录 / 对应一个 dentry，根目录是在根文件系统上的，根文件系统是系统启动的时候挂载的，因而有一个 mount 结构。这个 mount 结构的 mount point 指针和 mount root 指针都是指向根目录的 dentry。根目录对应的 file 的两个指针，一个指向根目录的 dentry，一个指向根目录的挂载结构 mount。

我们再来看第二层。下一层目录 home 对应了两个 dentry，而且它们的 parent 都指向第一层的 dentry。这是为什么呢？这是因为文件系统 A 挂载到了这个目录下。这使得这个目录有两个用处。一方面，home 是根文件系统的一个挂载点；另一方面，home 是文件系统 A 的根目录。

因为还有一次挂载，因而又有了一个 mount 结构。这个 mount 结构的 mount point 指针指向作为挂载点的那个 dentry。mount root 指针指向作为根目录的那个 dentry，同时 parent 指针指向第一层的 mount 结构。home 对应的 file 的两个指针，一个指向文件系统 A 根目录的 dentry，一个指向文件系统 A 的挂载结构 mount。

我们再来看第三层。目录 hello 又挂载了一个文件系统 B，所以第三层的结构和第二层几乎一样。

接下来是第四层。目录 world 就是一个普通的目录。只要它的 dentry 的 parent 指针指向上一层就可以了。我们来看 world 对应的 file 结构。由于挂载点不变，还是指向第三层的 mount 结构。

接下来是第五层。对于文件 data，是一个普通的文件，它的 dentry 的 parent 指向第四层的 dentry。对于 data 对应的 file 结构，由于挂载点不变，还是指向第三层的 mount 结构。

打开文件

接下来，我们从分析 Open 系统调用说起。

在系统调用的那一节，我们知道，在进程里面通过 open 系统调用打开文件，最终对调用到内核的系统调用实现 sys_open。当时我们仅仅解析了系统调用的原理，没有接着分析下去，现在我们接着分析这个过程。

SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)

{

......

	return do_sys_open(AT_FDCWD, filename, flags, mode);

}

long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)

{

......

	fd = get_unused_fd_flags(flags);

	if (fd >= 0) {

		struct file *f = do_filp_open(dfd, tmp, &op);

		if (IS_ERR(f)) {

			put_unused_fd(fd);

			fd = PTR_ERR(f);

		} else {

			fsnotify_open(f);

			fd_install(fd, f);

		}

	}

	putname(tmp);

	return fd;

}

要打开一个文件，首先要通过 get_unused_fd_flags 得到一个没有用的文件描述符。如何获取这个文件描述符呢？

在每一个进程的 task_struct 中，有一个指针 files，类型是 files_struct。

struct files_struct		*files;

files_struct 里面最重要的是一个文件描述符列表，每打开一个文件，就会在这个列表中分配一项，下标就是文件描述符。

struct files_struct {

......

	struct file __rcu * fd_array[NR_OPEN_DEFAULT];

};

对于任何一个进程，默认情况下，文件描述符 0 表示 stdin 标准输入，文件描述符 1 表示 stdout 标准输出，文件描述符 2 表示 stderr 标准错误输出。另外，再打开的文件，都会从这个列表中找一个空闲位置分配给它。

文件描述符列表的每一项都是一个指向 struct file 的指针，也就是说，每打开一个文件，都会有一个 struct file 对应。

do_sys_open 中调用 do_filp_open，就是创建这个 struct file 结构，然后 fd_install(fd, f) 是将文件描述符和这个结构关联起来。

struct file *do_filp_open(int dfd, struct filename *pathname,

		const struct open_flags *op)

{

......

	set_nameidata(&nd, dfd, pathname);

	filp = path_openat(&nd, op, flags | LOOKUP_RCU);

......

	restore_nameidata();

	return filp;

}

do_filp_open 里面首先初始化了 struct nameidata 这个结构。我们知道，文件都是一串的路径名称，需要逐个解析。这个结构就是解析和查找路径的时候做辅助作用。

在 struct nameidata 里面有一个关键的成员变量 struct path。

struct path {

	struct vfsmount *mnt;

	struct dentry *dentry;

} __randomize_layout;

其中，struct vfsmount 和文件系统的挂载有关。另一个 struct dentry，除了上面说的用于标识目录之外，还可以表示文件名，还会建立了文件名及其 inode 之间的关联。

接下来就调用 path_openat，主要做了以下几件事情：

get_empty_filp 生成一个 struct file 结构；

path_init 初始化 nameidata，准备开始节点路径查找；

link_path_walk 对于路径名逐层进行节点路径查找，这里面有一个大的循环，用“/”分隔逐层处理；

do_last 获取文件对应的 inode 对象，并且初始化 file 对象。

static struct file *path_openat(struct nameidata *nd,

			const struct open_flags *op, unsigned flags)

{

......

	file = get_empty_filp();

......

	s = path_init(nd, flags);

......

	while (!(error = link_path_walk(s, nd)) &&

		(error = do_last(nd, file, op, &opened)) > 0) {

......

	}

	terminate_walk(nd);

......

	return file;

}

例如，文件“/root/hello/world/data”，link_path_walk 会解析前面的路径部分“/root/hello/world”，解析完毕的时候 nameidata 的 dentry 为路径名的最后一部分的父目录“/root/hello/world”，而 nameidata 的 filename 为路径名的最后一部分“data”。

最后一部分的解析和处理，我们交给 do_last。

static int do_last(struct nameidata *nd,

		   struct file *file, const struct open_flags *op,

		   int *opened)

{

......

	error = lookup_fast(nd, &path, &inode, &seq);

......

    error = lookup_open(nd, &path, file, op, got_write, opened);

......

	error = vfs_open(&nd->path, file, current_cred());

......

}

在这里面，我们需要先查找文件路径最后一部分对应的 dentry。如何查找呢？

Linux 为了提高目录项对象的处理效率，设计与实现了目录项高速缓存 dentry cache，简称 dcache。它主要由两个数据结构组成：

哈希表 dentry_hashtable：dcache 中的所有 dentry 对象都通过 d_hash 指针链到相应的 dentry 哈希链表中；

未使用的 dentry 对象链表 s_dentry_lru：dentry 对象通过其 d_lru 指针链入 LRU 链表中。LRU 的意思是最近最少使用，我们已经好几次看到它了。只要有它，就说明长时间不使用，就应该释放了。

这两个列表之间会产生复杂的关系：

引用为 0：一个在散列表中的 dentry 变成没有人引用了，就会被加到 LRU 表中去；

再次被引用：一个在 LRU 表中的 dentry 再次被引用了，则从 LRU 表中移除；

分配：当 dentry 在散列表中没有找到，则从 Slub 分配器中分配一个；

过期归还：当 LRU 表中最长时间没有使用的 dentry 应该释放回 Slub 分配器；

文件删除：文件被删除了，相应的 dentry 应该释放回 Slub 分配器；

结构复用：当需要分配一个 dentry，但是无法分配新的，就从 LRU 表中取出一个来复用。

所以，do_last() 在查找 dentry 的时候，当然先从缓存中查找，调用的是 lookup_fast。

如果缓存中没有找到，就需要真的到文件系统里面去找了，lookup_open 会创建一个新的 dentry，并且调用上一级目录的 Inode 的 inode_operations 的 lookup 函数，对于 ext4 来讲，调用的是 ext4_lookup，会到咱们上一节讲的文件系统里面去找 inode。最终找到后将新生成的 dentry 付给 path 变量。

static int lookup_open(struct nameidata *nd, struct path *path,

			struct file *file,

			const struct open_flags *op,

			bool got_write, int *opened)

{

    ......

    dentry = d_alloc_parallel(dir, &nd->last, &wq);

    ......

    struct dentry *res = dir_inode->i_op->lookup(dir_inode, dentry,

							     nd->flags);

    ......

    path->dentry = dentry;

	path->mnt = nd->path.mnt;

}

const struct inode_operations ext4_dir_inode_operations = {

	.create		= ext4_create,

	.lookup		= ext4_lookup,

...

do_last() 的最后一步是调用 vfs_open 真正打开文件。

int vfs_open(const struct path *path, struct file *file,

	     const struct cred *cred)

{

	struct dentry *dentry = d_real(path->dentry, NULL, file->f_flags, 0);

......

	file->f_path = *path;

	return do_dentry_open(file, d_backing_inode(dentry), NULL, cred);

}

static int do_dentry_open(struct file *f,

			  struct inode *inode,

			  int (*open)(struct inode *, struct file *),

			  const struct cred *cred)

{

......

	f->f_mode = OPEN_FMODE(f->f_flags) | FMODE_LSEEK |

				FMODE_PREAD | FMODE_PWRITE;

	path_get(&f->f_path);

	f->f_inode = inode;

	f->f_mapping = inode->i_mapping;

......

	f->f_op = fops_get(inode->i_fop);

......

	open = f->f_op->open;

......

	error = open(inode, f);

......

	f->f_flags &= ~(O_CREAT | O_EXCL | O_NOCTTY | O_TRUNC);

	file_ra_state_init(&f->f_ra, f->f_mapping->host->i_mapping);

	return 0;

......

}

const struct file_operations ext4_file_operations = {

......

	.open		= ext4_file_open,

......

};

vfs_open 里面最终要做的一件事情是，调用 f_op->open，也就是调用 ext4_file_open。另外一件重要的事情是将打开文件的所有信息，填写到 struct file 这个结构里面。

struct file {

	union {

		struct llist_node	fu_llist;

		struct rcu_head 	fu_rcuhead;

	} f_u;

	struct path		f_path;

	struct inode		*f_inode;	/* cached value */

	const struct file_operations	*f_op;

	spinlock_t		f_lock;

	enum rw_hint		f_write_hint;

	atomic_long_t		f_count;

	unsigned int 		f_flags;

	fmode_t			f_mode;

	struct mutex		f_pos_lock;

	loff_t			f_pos;

	struct fown_struct	f_owner;

	const struct cred	*f_cred;

......

	struct address_space	*f_mapping;

	errseq_t		f_wb_err;

}

总结时刻

对于虚拟文件系统的解析就到这里了，我们可以看出，有关文件的数据结构层次多，而且很复杂，就得到了下面这张图，这张图在这个专栏最开始的时候，已经展示过一遍，到这里，你应该能明白它们之间的关系了。

这张图十分重要，一定要掌握。因为我们后面的字符设备、块设备、管道、进程间通信、网络等等，全部都要用到这里面的知识。希望当你再次遇到它的时候，能够马上说出各个数据结构直接的关系。

这里我带你简单做一个梳理，帮助你理解记忆它。

对于每一个进程，打开的文件都有一个文件描述符，在 files_struct 里面会有文件描述符数组。每个一个文件描述符是这个数组的下标，里面的内容指向一个 file 结构，表示打开的文件。这个结构里面有这个文件对应的 inode，最重要的是这个文件对应的操作 file_operation。如果操作这个文件，就看这个 file_operation 里面的定义了。

对于每一个打开的文件，都有一个 dentry 对应，虽然叫作 directory entry，但是不仅仅表示文件夹，也表示文件。它最重要的作用就是指向这个文件对应的 inode。

如果说 file 结构是一个文件打开以后才创建的，dentry 是放在一个 dentry cache 里面的，文件关闭了，他依然存在，因而他可以更长期的维护内存中的文件的表示和硬盘上文件的表示之间的关系。

inode 结构就表示硬盘上的 inode，包括块设备号等。

几乎每一种结构都有自己对应的 operation 结构，里面都是一些方法，因而当后面遇到对于某种结构进行处理的时候，如果不容易找到相应的处理函数，就先找这个 operation 结构，就清楚了。

课堂练习

上一节的总结中，我们说，同一个文件系统中，文件夹和文件的对应关系。如果跨的是文件系统，你知道如何维护这种映射关系吗？

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

30 | 文件缓存：常用文档应该放在触手可得的地方

上一节，我们讲了文件系统的挂载和文件的打开，并通过打开文件的过程，构建了一个文件管理的整套数据结构体系。其实到这里，我们还没有对文件进行读写，还属于对于元数据的操作。那这一节，我们就重点关注读写。

系统调用层和虚拟文件系统层

文件系统的读写，其实就是调用系统函数 read 和 write。由于读和写的很多逻辑是相似的，这里我们一起来看一下这个过程。

下面的代码就是 read 和 write 的系统调用，在内核里面的定义。

SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)

{

	struct fd f = fdget_pos(fd);

......

	loff_t pos = file_pos_read(f.file);

	ret = vfs_read(f.file, buf, count, &pos);

......

}

SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,

		size_t, count)

{

	struct fd f = fdget_pos(fd);

......

	loff_t pos = file_pos_read(f.file);

    ret = vfs_write(f.file, buf, count, &pos);

......

}

对于 read 来讲，里面调用 vfs_read->__vfs_read。对于 write 来讲，里面调用 vfs_write->__vfs_write。

下面是 __vfs_read 和 __vfs_write 的代码。

ssize_t __vfs_read(struct file *file, char __user *buf, size_t count,

		   loff_t *pos)

{

	if (file->f_op->read)

		return file->f_op->read(file, buf, count, pos);

	else if (file->f_op->read_iter)

		return new_sync_read(file, buf, count, pos);

	else

		return -EINVAL;

}

ssize_t __vfs_write(struct file *file, const char __user *p, size_t count,

		    loff_t *pos)

{

	if (file->f_op->write)

		return file->f_op->write(file, p, count, pos);

	else if (file->f_op->write_iter)

		return new_sync_write(file, p, count, pos);

	else

		return -EINVAL;

}

上一节，我们讲了，每一个打开的文件，都有一个 struct file 结构。这里面有一个 struct file_operations f_op，用于定义对这个文件做的操作。__vfs_read 会调用相应文件系统的 file_operations 里面的 read 操作，__vfs_write 会调用相应文件系统 file_operations 里的 write 操作。

ext4 文件系统层

对于 ext4 文件系统来讲，内核定义了一个 ext4_file_operations。

const struct file_operations ext4_file_operations = {

......

	.read_iter	= ext4_file_read_iter,

	.write_iter	= ext4_file_write_iter,

......

}

由于 ext4 没有定义 read 和 write 函数，于是会调用 ext4_file_read_iter 和 ext4_file_write_iter。

ext4_file_read_iter 会调用 generic_file_read_iter，ext4_file_write_iter 会调用 __generic_file_write_iter。

ssize_t

generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)

{

......

    if (iocb->ki_flags & IOCB_DIRECT) {

......

        struct address_space *mapping = file->f_mapping;

......

        retval = mapping->a_ops->direct_IO(iocb, iter);

    }

......

    retval = generic_file_buffered_read(iocb, iter, retval);

}

ssize_t __generic_file_write_iter(struct kiocb *iocb, struct iov_iter *from)

{

......

    if (iocb->ki_flags & IOCB_DIRECT) {

......

        written = generic_file_direct_write(iocb, from);

......

    } else {

......

		written = generic_perform_write(file, from, iocb->ki_pos);

......

    }

}

generic_file_read_iter 和 __generic_file_write_iter 有相似的逻辑，就是要区分是否用缓存。

缓存其实就是内存中的一块空间。因为内存比硬盘快的多，Linux 为了改进性能，有时候会选择不直接操作硬盘，而是将读写都在内存中，然后批量读取或者写入硬盘。一旦能够命中内存，读写效率就会大幅度提高。

因此，根据是否使用内存做缓存，我们可以把文件的 I/O 操作分为两种类型。

第一种类型是缓存 I/O。大多数文件系统的默认 I/O 操作都是缓存 I/O。对于读操作来讲，操作系统会先检查，内核的缓冲区有没有需要的数据。如果已经缓存了，那就直接从缓存中返回；否则从磁盘中读取，然后缓存在操作系统的缓存中。对于写操作来讲，操作系统会先将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说，写操作就已经完成。至于什么时候再写到磁盘中由操作系统决定，除非显式地调用了 sync 同步命令。

第二种类型是直接 IO，就是应用程序直接访问磁盘数据，而不经过内核缓冲区，从而减少了在内核缓存和用户程序之间数据复制。

如果在读的逻辑 generic_file_read_iter 里面，发现设置了 IOCB_DIRECT，则会调用 address_space 的 direct_IO 的函数，将数据直接读取硬盘。我们在 mmap 映射文件到内存的时候讲过 address_space，它主要用于在内存映射的时候将文件和内存页产生关联。

同样，对于缓存来讲，也需要文件和内存页进行关联，这就要用到 address_space。address_space 的相关操作定义在 struct address_space_operations 结构中。对于 ext4 文件系统来讲， address_space 的操作定义在 ext4_aops，direct_IO 对应的函数是 ext4_direct_IO。

static const struct address_space_operations ext4_aops = {

......

	.direct_IO		= ext4_direct_IO,

......

};

如果在写的逻辑 __generic_file_write_iter 里面，发现设置了 IOCB_DIRECT，则调用 generic_file_direct_write，里面同样会调用 address_space 的 direct_IO 的函数，将数据直接写入硬盘。

ext4_direct_IO 最终会调用到 __blockdev_direct_IO->do_blockdev_direct_IO，这就跨过了缓存层，直接到了文件系统的设备驱动层。由于文件系统是块设备，所以这个调用的是 blockdev 相关的函数，有关块设备驱动程序的原理我们下一章详细讲，这一节我们就讲到文件系统到块设备的分界线部分。

/*

 * This is a library function for use by filesystem drivers.

 */

static inline ssize_t

do_blockdev_direct_IO(struct kiocb *iocb, struct inode *inode,

		      struct block_device *bdev, struct iov_iter *iter,

		      get_block_t get_block, dio_iodone_t end_io,

		      dio_submit_t submit_io, int flags)

{......}

接下来，我们重点看带缓存的部分如果进行读写。

带缓存的写入操作

我们先来看带缓存写入的函数 generic_perform_write。

ssize_t generic_perform_write(struct file *file,

				struct iov_iter *i, loff_t pos)

{

	struct address_space *mapping = file->f_mapping;

	const struct address_space_operations *a_ops = mapping->a_ops;

	do {

		struct page *page;

		unsigned long offset;	/* Offset into pagecache page */

		unsigned long bytes;	/* Bytes to write to page */

		status = a_ops->write_begin(file, mapping, pos, bytes, flags,

						&page, &fsdata);

		copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);

		flush_dcache_page(page);

		status = a_ops->write_end(file, mapping, pos, bytes, copied,

						page, fsdata);

		pos += copied;

		written += copied;

		balance_dirty_pages_ratelimited(mapping);

	} while (iov_iter_count(i));

}

这个函数里，是一个 while 循环。我们需要找出这次写入影响的所有的页，然后依次写入。对于每一个循环，主要做四件事情：

对于每一页，先调用 address_space 的 write_begin 做一些准备；

调用 iov_iter_copy_from_user_atomic，将写入的内容从用户态拷贝到内核态的页中；

调用 address_space 的 write_end 完成写操作；

调用 balance_dirty_pages_ratelimited，看脏页是否太多，需要写回硬盘。所谓脏页，就是写入到缓存，但是还没有写入到硬盘的页面。

我们依次来看这四个步骤。

static const struct address_space_operations ext4_aops = {

......

	.write_begin		= ext4_write_begin,

	.write_end		= ext4_write_end,

......

}

第一步，对于 ext4 来讲，调用的是 ext4_write_begin。

ext4 是一种日志文件系统，是为了防止突然断电的时候的数据丢失，引入了日志**（Journal）**模式。日志文件系统比非日志文件系统多了一个 Journal 区域。文件在 ext4 中分两部分存储，一部分是文件的元数据，另一部分是数据。元数据和数据的操作日志 Journal 也是分开管理的。你可以在挂载 ext4 的时候，选择 Journal 模式。这种模式在将数据写入文件系统前，必须等待元数据和数据的日志已经落盘才能发挥作用。这样性能比较差，但是最安全。

另一种模式是order 模式。这个模式不记录数据的日志，只记录元数据的日志，但是在写元数据的日志前，必须先确保数据已经落盘。这个折中，是默认模式。

还有一种模式是writeback，不记录数据的日志，仅记录元数据的日志，并且不保证数据比元数据先落盘。这个性能最好，但是最不安全。

在 ext4_write_begin，我们能看到对于 ext4_journal_start 的调用，就是在做日志相关的工作。

在 ext4_write_begin 中，还做了另外一件重要的事情，就是调用 grab_cache_page_write_begin 来，得到应该写入的缓存页。

struct page *grab_cache_page_write_begin(struct address_space *mapping,

					pgoff_t index, unsigned flags)

{

	struct page *page;

	int fgp_flags = FGP_LOCK|FGP_WRITE|FGP_CREAT;

	page = pagecache_get_page(mapping, index, fgp_flags,

			mapping_gfp_mask(mapping));

	if (page)

		wait_for_stable_page(page);

	return page;

}

在内核中，缓存以页为单位放在内存里面，那我们如何知道，一个文件的哪些数据已经被放到缓存中了呢？每一个打开的文件都有一个 struct file 结构，每个 struct file 结构都有一个 struct address_space 用于关联文件和内存，就是在这个结构里面，有一棵树，用于保存所有与这个文件相关的的缓存页。

我们查找的时候，往往需要根据文件中的偏移量找出相应的页面，而基数树 radix tree 这种数据结构能够快速根据一个长整型查找到其相应的对象，因而这里缓存页就放在 radix 基数树里面。

struct address_space {

	struct inode		*host;		/* owner: inode, block_device */

	struct radix_tree_root	page_tree;	/* radix tree of all pages */

	spinlock_t		tree_lock;	/* and lock protecting it */

......

}

pagecache_get_page 就是根据 pgoff_t index 这个长整型，在这棵树里面查找缓存页，如果找不到就会创建一个缓存页。

第二步，调用 iov_iter_copy_from_user_atomic。先将分配好的页面调用 kmap_atomic 映射到内核里面的一个虚拟地址，然后将用户态的数据拷贝到内核态的页面的虚拟地址中，调用 kunmap_atomic 把内核里面的映射删除。

size_t iov_iter_copy_from_user_atomic(struct page *page,

		struct iov_iter *i, unsigned long offset, size_t bytes)

{

	char *kaddr = kmap_atomic(page), *p = kaddr + offset;

	iterate_all_kinds(i, bytes, v,

		copyin((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len),

		memcpy_from_page((p += v.bv_len) - v.bv_len, v.bv_page,

				 v.bv_offset, v.bv_len),

		memcpy((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len)

	)

	kunmap_atomic(kaddr);

	return bytes;

}

第三步，调用 ext4_write_end 完成写入。这里面会调用 ext4_journal_stop 完成日志的写入，会调用 block_write_end->__block_commit_write->mark_buffer_dirty，将修改过的缓存标记为脏页。可以看出，其实所谓的完成写入，并没有真正写入硬盘，仅仅是写入缓存后，标记为脏页。

但是这里有一个问题，数据很危险，一旦宕机就没有了，所以需要一种机制，将写入的页面真正写到硬盘中，我们称为回写（Write Back）。

第四步，调用 balance_dirty_pages_ratelimited，是回写脏页的一个很好的时机。

/**

 * balance_dirty_pages_ratelimited - balance dirty memory state

 * @mapping: address_space which was dirtied

 *

 * Processes which are dirtying memory should call in here once for each page

 * which was newly dirtied.  The function will periodically check the system's

 * dirty state and will initiate writeback if needed.

  */

void balance_dirty_pages_ratelimited(struct address_space *mapping)

{

	struct inode *inode = mapping->host;

	struct backing_dev_info *bdi = inode_to_bdi(inode);

	struct bdi_writeback *wb = NULL;

	int ratelimit;

......

	if (unlikely(current->nr_dirtied >= ratelimit))

		balance_dirty_pages(mapping, wb, current->nr_dirtied);

......

}

在 balance_dirty_pages_ratelimited 里面，发现脏页的数目超过了规定的数目，就调用 balance_dirty_pages->wb_start_background_writeback，启动一个背后线程开始回写。

void wb_start_background_writeback(struct bdi_writeback *wb)

{

	/*

	 * We just wake up the flusher thread. It will perform background

	 * writeback as soon as there is no other work to do.

	 */

	wb_wakeup(wb);

}

static void wb_wakeup(struct bdi_writeback *wb)

{

	spin_lock_bh(&wb->work_lock);

	if (test_bit(WB_registered, &wb->state))

		mod_delayed_work(bdi_wq, &wb->dwork, 0);

	spin_unlock_bh(&wb->work_lock);

}

  (_tflags) | TIMER_IRQSAFE);		\

	} while (0)

/* bdi_wq serves all asynchronous writeback tasks */

struct workqueue_struct *bdi_wq;

/**

 * mod_delayed_work - modify delay of or queue a delayed work

 * @wq: workqueue to use

 * @dwork: work to queue

 * @delay: number of jiffies to wait before queueing

 *

 * mod_delayed_work_on() on local CPU.

 */

static inline bool mod_delayed_work(struct workqueue_struct *wq,

				    struct delayed_work *dwork,

				    unsigned long delay)

{....

通过上面的代码，我们可以看出，bdi_wq 是一个全局变量，所有回写的任务都挂在这个队列上。mod_delayed_work 函数负责将一个回写任务 bdi_writeback 挂在这个队列上。bdi_writeback 有个成员变量 struct delayed_work dwork，bdi_writeback 就是以 delayed_work 的身份挂到队列上的，并且把 delay 设置为 0，意思就是一刻不等，马上执行。

那具体这个任务由谁来执行呢？这里的 bdi 的意思是 backing device info，用于描述后端存储相关的信息。每个块设备都会有这样一个结构，并且在初始化块设备的时候，调用 bdi_init 初始化这个结构，在初始化 bdi 的时候，也会调用 wb_init 初始化 bdi_writeback。

static int wb_init(struct bdi_writeback *wb, struct backing_dev_info *bdi,

		   int blkcg_id, gfp_t gfp)

{

	wb->bdi = bdi;

	wb->last_old_flush = jiffies;

	INIT_LIST_HEAD(&wb->b_dirty);

	INIT_LIST_HEAD(&wb->b_io);

	INIT_LIST_HEAD(&wb->b_more_io);

	INIT_LIST_HEAD(&wb->b_dirty_time);

	wb->bw_time_stamp = jiffies;

	wb->balanced_dirty_ratelimit = INIT_BW;

	wb->dirty_ratelimit = INIT_BW;

	wb->write_bandwidth = INIT_BW;

	wb->avg_write_bandwidth = INIT_BW;

	spin_lock_init(&wb->work_lock);

	INIT_LIST_HEAD(&wb->work_list);

	INIT_DELAYED_WORK(&wb->dwork, wb_workfn);

	wb->dirty_sleep = jiffies;

......

}

#define __INIT_DELAYED_WORK(_work, _func, _tflags)			\

	do {								\

		INIT_WORK(&(_work)->work, (_func));			\

		__setup_timer(&(_work)->timer, delayed_work_timer_fn,	\

			      (unsigned long)(_work),			\

这里面最重要的是 INIT_DELAYED_WORK。其实就是初始化一个 timer，也即定时器，到时候我们就执行 wb_workfn 这个函数。

接下来的调用链为：wb_workfn->wb_do_writeback->wb_writeback->writeback_sb_inodes->__writeback_single_inode->do_writepages，写入页面到硬盘。

在调用 write 的最后，当发现缓存的数据太多的时候，会触发回写，这仅仅是回写的一种场景。另外还有几种场景也会触发回写：

用户主动调用 sync，将缓存刷到硬盘上去，最终会调用 wakeup_flusher_threads，同步脏页；

当内存十分紧张，以至于无法分配页面的时候，会调用 free_more_memory，最终会调用 wakeup_flusher_threads，释放脏页；

脏页已经更新了较长时间，时间上超过了 timer，需要及时回写，保持内存和磁盘上数据一致性。

带缓存的读操作

带缓存的写分析完了，接下来，我们看带缓存的读，对应的是函数 generic_file_buffered_read。

static ssize_t generic_file_buffered_read(struct kiocb *iocb,

		struct iov_iter *iter, ssize_t written)

{

	struct file *filp = iocb->ki_filp;

	struct address_space *mapping = filp->f_mapping;

	struct inode *inode = mapping->host;

	for (;;) {

		struct page *page;

		pgoff_t end_index;

		loff_t isize;

		page = find_get_page(mapping, index);

		if (!page) {

			if (iocb->ki_flags & IOCB_NOWAIT)

				goto would_block;

			page_cache_sync_readahead(mapping,

					ra, filp,

					index, last_index - index);

			page = find_get_page(mapping, index);

			if (unlikely(page == NULL))

				goto no_cached_page;

		}

		if (PageReadahead(page)) {

			page_cache_async_readahead(mapping,

					ra, filp, page,

					index, last_index - index);

		}

		/*

		 * Ok, we have the page, and it's up-to-date, so

		 * now we can copy it to user space...

		 */

		ret = copy_page_to_iter(page, offset, nr, iter);

    }

}

读取比写入总体而言简单一些，主要涉及预读的问题。

在 generic_file_buffered_read 函数中，我们需要先找到 page cache 里面是否有缓存页。如果没有找到，不但读取这一页，还要进行预读，这需要在 page_cache_sync_readahead 函数中实现。预读完了以后，再试一把查找缓存页，应该能找到了。

如果第一次找缓存页就找到了，我们还是要判断，是不是应该继续预读；如果需要，就调用 page_cache_async_readahead 发起一个异步预读。

最后，copy_page_to_iter 会将内容从内核缓存页拷贝到用户内存空间。

总结时刻

这一节对于读取和写入的分析就到这里了。我们发现这个过程还是很复杂的，我这里画了一张调用图，你可以看到调用过程。

在系统调用层我们需要仔细学习 read 和 write。在 VFS 层调用的是 vfs_read 和 vfs_write 并且调用 file_operation。在 ext4 层调用的是 ext4_file_read_iter 和 ext4_file_write_iter。

接下来就是分叉。你需要知道缓存 I/O 和直接 I/O。直接 I/O 读写的流程是一样的，调用 ext4_direct_IO，再往下就调用块设备层了。缓存 I/O 读写的流程不一样。对于读，从块设备读取到缓存中，然后从缓存中拷贝到用户态。对于写，从用户态拷贝到缓存，设置缓存页为脏，然后启动一个线程写入块设备。

课堂练习

你知道如何查询和清除文件系统缓存吗？

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

31 | 输入与输出：如何建立售前售后生态体系？

到这一节，操作系统作为一家外包公司，里面最核心的职能部门差不多都凑齐了。我们有了项目管理部门（进程管理），有为了维护项目执行期间数据的会议室管理部门（内存管理），有项目执行完毕后归档的档案库管理部门（文件系统）。

这一节，我们来规划一下这家公司的售前售后生态体系（输入输出系统）。这里你需要注意“生态”两个字，我们不仅仅是招聘一些售前和售后员工，而是应该建立一套体系让供应商，让渠道帮着我们卖，形成一个生态。

计算机系统的输入和输出系统都有哪些呢？我们能举出来的，例如键盘、鼠标、显示器、网卡、硬盘、打印机、CD/DVD 等等，多种多样。这样，当然方便用户使用了，但是对于操作系统来讲，却是一件复杂的事情，因为这么多设备，形状、用法、功能都不一样，怎么才能统一管理起来呢？

用设备控制器屏蔽设备差异

这有点像一家公司要做 To B 的生意，发现客户多种多样，众口难调，不同的地域不一样，不同的行业不一样。如果你不懂某个地方的规矩，根本卖不出去东西；如果你不懂某个具体行业的使用场景，也无法满足客户的需求。怎么办呢？一般公司采取的策略就是建立生态，设置很多代理商，让各个地区和各个行业的代理商帮你屏蔽这些差异化。你和代理商之间只要进行简单的标准产品交付就可以了。

计算机系统也是这样的，CPU 并不直接和设备打交道，它们中间有一个叫作设备控制器（Device Control Unit）的组件，例如硬盘有磁盘控制器、USB 有 USB 控制器、显示器有视频控制器等。这些控制器就像代理商一样，它们知道如何应对硬盘、鼠标、键盘、显示器的行为。

如果你是一家大公司，你的代理商往往是小公司。控制器其实有点儿像一台小电脑。它有它的芯片，类似小 CPU，执行自己的逻辑。它也有它的寄存器。这样 CPU 就可以通过写这些寄存器，对控制器下发指令，通过读这些寄存器，查看控制器对于设备的操作状态。

CPU 对于寄存器的读写，可比直接控制硬件，要标准和轻松很多。这就相当于你和代理商的标准产品交付。

输入输出设备我们大致可以分为两类：块设备（Block Device）和字符设备（Character Device）。

块设备将信息存储在固定大小的块中，每个块都有自己的地址。硬盘就是常见的块设备。

字符设备发送或接受的是字节流。而不用考虑任何块结构，没有办法寻址。鼠标就是常见的字符设备。

由于块设备传输的数据量比较大，控制器里往往会有缓冲区。CPU 写入缓冲区的数据攒够一部分，才会发给设备。CPU 读取的数据，也需要在缓冲区攒够一部分，才拷贝到内存。

这个也比较好理解，代理商我们也可以分成两种。一种是集成商模式，也就是说没有客户的时候，代理商不会在你这里采购产品，每次它遇到一个客户的时候，会带上你，共同应标。你出标准产品，地域的和行业的差异，它来搞定。这有点儿像字符设备。另外一种是代购代销模式，也就是说从你这里批量采购一批产品，然后没卖完之前，基本就不会找你了。这有点儿像块设备。

CPU 如何同控制器的寄存器和数据缓冲区进行通信呢？

每个控制寄存器被分配一个 I/O 端口，我们可以通过特殊的汇编指令（例如 in/out 类似的指令）操作这些寄存器。

数据缓冲区，可内存映射 I/O，可以分配一段内存空间给它，就像读写内存一样读写数据缓冲区。如果你去看内存空间的话，有一个原来我们没有讲过的区域 ioremap，就是做这个的。

这有点儿像，如果你要给你的代理商下一个任务，或者询问订单的状态，直接打电话联系他们的负责人就可以了。如果你需要和代理商做大量的交互，共同讨论应标方案，那电话说太麻烦了，你可以把代理商拉到你们公司来，你们直接在一个会议室里面出方案。

对于 CPU 来讲，这些外部设备都有自己的大脑，可以自行处理一些事情，但是有个问题是，当你给设备发了一个指令，让它读取一些数据，它读完的时候，怎么通知你呢？

控制器的寄存器一般会有状态标志位，可以通过检测状态标志位，来确定输入或者输出操作是否完成。第一种方式就是轮询等待，就是一直查，一直查，直到完成。当然这种方式很不好，于是我们有了第二种方式，就是可以通过中断的方式，通知操作系统输入输出操作已经完成。

为了响应中断，我们一般会有一个硬件的中断控制器，当设备完成任务后出发中断到中断控制器，中断控制器就通知 CPU，一个中断产生了，CPU 需要停下当前手里的事情来处理中断。

这就像代理商有了新客户，客户有了新需求，客户交付完毕等事件，都需要有一种机制通知你们公司，在哪里呢？当然是在办事大厅呀。如果你问，不对呀，办事大厅不是处理系统调用的么？还记得 32 位系统调用是通过 INT 产生软中断触发的么？这就统一起来了，中断有两种，一种软中断，例如代码调用 INT 指令触发，一种是硬件中断，就是硬件通过中断控制器触发的。所以将中断作为办事大厅的一项服务，没有什么问题。

有的设备需要读取或者写入大量数据。如果所有过程都让 CPU 协调的话，就需要占用 CPU 大量的时间，比方说，磁盘就是这样的。这种类型的设备需要支持 DMA 功能，也就是说，允许设备在 CPU 不参与的情况下，能够自行完成对内存的读写。实现 DMA 机制需要有个 DMA 控制器帮你的 CPU 来做协调，就像下面这个图中显示的一样。

CPU 只需要对 DMA 控制器下指令，说它想读取多少数据，放在内存的某个地方就可以了，接下来 DMA 控制器会发指令给磁盘控制器，读取磁盘上的数据到指定的内存位置，传输完毕之后，DMA 控制器发中断通知 CPU 指令完成，CPU 就可以直接用内存里面现成的数据了。还记得咱们讲内存的时候，有个 DMA 区域，就是这个作用。

DMA 有点儿像一些比较大的代理商，不但能够帮你代购代销，而且自己有能力售前、售后和技术支持，实施部署都能自己搞定。

用驱动程序屏蔽设备控制器差异

虽然代理商机制能够帮我们屏蔽很多设备的细节，但是从上面的描述我们可以看出，由于每种设备的控制器的寄存器、缓冲区等使用模式，指令都不同，所以对于操作系统这家公司来讲，需要有个部门专门对接代理商，向其他部门屏蔽代理商的差异，类似公司的渠道管理部门。

那什么才是操作系统的渠道管理部门呢？就是用来对接各个设备控制器的设备驱动程序。

这里需要注意的是，设备控制器不属于操作系统的一部分，但是设备驱动程序属于操作系统的一部分。操作系统的内核代码可以像调用本地代码一样调用驱动程序的代码，而驱动程序的代码需要发出特殊的面向设备控制器的指令，才能操作设备控制器。

设备驱动程序中是一些面向特殊设备控制器的代码。不同的设备不同。但是对于操作系统其它部分的代码而言，设备驱动程序应该有统一的接口。就像下面图中的一样，不同的设备驱动程序，可以以同样的方式接入操作系统，而操作系统的其它部分的代码，也可以无视不同设备的区别，以同样的接口调用设备驱动程序。

接下来两节，我们会讲字符设备驱动程序和块设备驱动程序的模型，从那里我们也可以看出，所有设备驱动程序都要，按照同样的规则，实现同样的方法。

上面咱们说了，设备做完了事情要通过中断来通知操作系统。那操作系统就需要有一个地方处理这个中断，既然设备驱动程序是用来对接设备控制器的，中断处理也应该在设备驱动里面完成。

然而中断的触发最终会到达 CPU，会中断操作系统当前运行的程序，所以操作系统也要有一个统一的流程来处理中断，使得不同设备的中断使用统一的流程。

一般的流程是，一个设备驱动程序初始化的时候，要先注册一个该设备的中断处理函数。咱们讲进程切换的时候说过，中断返回的那一刻是进程切换的时机。不知道你还记不记得，中断的时候，触发的函数是 do_IRQ。这个函数是中断处理的统一入口。在这个函数里面，我们可以找到设备驱动程序注册的中断处理函数 Handler，然后执行它进行中断处理。

另外，对于块设备来讲，在驱动程序之上，文件系统之下，还需要一层通用设备层。比如咱们上一章讲的文件系统，里面的逻辑和磁盘设备没有什么关系，可以说是通用的逻辑。在写文件的最底层，我们看到了 BIO 字眼的函数，但是好像和设备驱动也没有什么关系。是的，因为块设备类型非常多，而 Linux 操作系统里面一切是文件。我们也不想文件系统以下，就直接对接各种各样的块设备驱动程序，这样会使得文件系统的复杂度非常高。所以，我们在中间加了一层通用块层，将与块设备相关的通用逻辑放在这一层，维护与设备无关的块的大小，然后通用块层下面对接各种各样的驱动程序。

用文件系统接口屏蔽驱动程序的差异

上面我们从硬件设备到设备控制器，到驱动程序，到通用块层，到文件系统，层层屏蔽不同的设备的差别，最终到这里涉及对用户使用接口，也要统一。

虽然我们操作设备，都是基于文件系统的接口，也要有一个统一的标准。

首先要统一的是设备名称。所有设备都在 /dev/ 文件夹下面创建一个特殊的设备文件。这个设备特殊文件也有 inode，但是它不关联到硬盘或任何其他存储介质上的数据，而是建立了与某个设备驱动程序的连接。

硬盘设备这里有一点绕。假设是 /dev/sdb，这是一个设备文件。这个文件本身和硬盘上的文件系统没有任何关系。这个设备本身也不对应硬盘上的任何一个文件，/dev/sdb 其实是在一个特殊的文件系统 devtmpfs 中。但是当我们将 /dev/sdb 格式化成一个文件系统 ext4 的时候，就会将它 mount 到一个路径下面。例如在 /mnt/sdb 下面。这个时候 /dev/sdb 还是一个设备文件在特殊文件系统 devtmpfs 中，而 /mnt/sdb 下面的文件才是在 ext4 文件系统中，只不这个设备是在 /dev/sdb 设备上的。

这里我们只关心设备文件，当我们用 ls -l 在 /dev 下面执行的时候，就会有这样的结果。

# ls -l

crw------- 1 root root      5,   1 Dec 14 19:53 console

crw-r----- 1 root kmem      1,   1 Dec 14 19:53 mem

crw-rw-rw- 1 root root      1,   3 Dec 14 19:53 null

crw-r----- 1 root kmem      1,   4 Dec 14 19:53 port

crw-rw-rw- 1 root root      1,   8 Dec 14 19:53 random

crw--w---- 1 root tty       4,   0 Dec 14 19:53 tty0

crw--w---- 1 root tty       4,   1 Dec 14 19:53 tty1

crw-rw-rw- 1 root root      1,   9 Dec 14 19:53 urandom

brw-rw---- 1 root disk    253,   0 Dec 31 19:18 vda

brw-rw---- 1 root disk    253,   1 Dec 31 19:19 vda1

brw-rw---- 1 root disk    253,  16 Dec 14 19:53 vdb

brw-rw---- 1 root disk    253,  32 Jan  2 11:24 vdc

crw-rw-rw- 1 root root      1,   5 Dec 14 19:53 zero

对于设备文件，ls 出来的内容和我们原来讲过的稍有不同。

首先是第一位字符。如果是字符设备文件，则以 c 开头，如果是块设备文件，则以 b 开头。其次是这里面的两个号，一个是主设备号，一个是次设备号。主设备号定位设备驱动程序，次设备号作为参数传给启动程序，选择相应的单元。

从上面的列表我们可以看出来，mem、null、random、urandom、zero 都是用同样的主设备号 1，也就是它们使用同样的字符设备驱动，而 vda、vda1、vdb、vdc 也是同样的主设备号，也就是它们使用同样的块设备驱动。

有了设备文件，我们就可以使用对于文件的操作命令和 API 来操作文件了。例如，使用 cat 命令，可以读取 /dev/random 和 /dev/urandom 的数据流，可以用 od 命令转换为十六进制后查看。

cat /dev/urandom | od -x

这里还是要明确一下，如果用文件的操作作用于 /dev/sdb 的话，会无法操作文件系统上的文件，操作的这个设备。

如果 Linux 操作系统新添加了一个设备，应该做哪些事情呢？就像咱们使用 Windows 的时候，如果新添加了一种设备，首先要看这个设备有没有相应的驱动。如果没有就需要安装一个驱动，等驱动安装好了，设备就在 Windows 的设备列表中显示出来了。

在 Linux 上面，如果一个新的设备从来没有加载过驱动，也需要安装驱动。Linux 的驱动程序已经被写成和操作系统有标准接口的代码，可以看成一个标准的内核模块。在 Linux 里面，安装驱动程序，其实就是加载一个内核模块。

我们可以用命令 lsmod，查看有没有加载过相应的内核模块。这个列表很长，我这里列举了其中一部分。可以看到，这里面有网络和文件系统的驱动。

# lsmod

Module                  Size  Used by

iptable_filter         12810  1

bridge                146976  1 br_netfilter

vfat                   17461  0

fat                    65950  1 vfat

ext4                  571716  1

cirrus                 24383  1

crct10dif_pclmul       14307  0

crct10dif_common       12595  1 crct10dif_pclmul

如果没有安装过相应的驱动，可以通过 insmod 安装内核模块。内核模块的后缀一般是 ko。

例如，我们要加载 openvswitch 的驱动，就要通过下面的命令：

insmod openvswitch.ko

一旦有了驱动，我们就可以通过命令 mknod 在 /dev 文件夹下面创建设备文件，就像下面这样：

mknod filename type major minor

其中 filename 就是 /dev 下面的设备名称，type 就是 c 为字符设备，b 为块设备，major 就是主设备号，minor 就是次设备号。一旦执行了这个命令，新创建的设备文件就和上面加载过的驱动关联起来，这个时候就可以通过操作设备文件来操作驱动程序，从而操作设备。

你可能会问，人家 Windows 都说插上设备后，一旦安装了驱动，就直接在设备列表中出来了，你这里怎么还要人来执行命令创建呀，能不能智能一点？

当然可以，这里就要用到另一个管理设备的文件系统，也就是 /sys 路径下面的 sysfs 文件系统。它把实际连接到系统上的设备和总线组成了一个分层的文件系统。这个文件系统是当前系统上实际的设备数的真实反映。

在 /sys 路径下有下列的文件夹：

/sys/devices 是内核对系统中所有设备的分层次的表示；

/sys/dev 目录下一个 char 文件夹，一个 block 文件夹，分别维护一个按字符设备和块设备的主次号码 (major:minor) 链接到真实的设备 (/sys/devices 下) 的符号链接文件；

/sys/block 是系统中当前所有的块设备；

/sys/module 有系统中所有模块的信息。

有了 sysfs 以后，我们还需要一个守护进程 udev。当一个设备新插入系统的时候，内核会检测到这个设备，并会创建一个内核对象 kobject 。 这个对象通过 sysfs 文件系统展现到用户层，同时内核还向用户空间发送一个热插拔消息。udevd 会监听这些消息，在 /dev 中创建对应的文件。

有了文件系统接口之后，我们不但可以通过文件系统的命令行操作设备，也可以通过程序，调用 read、write 函数，像读写文件一样操作设备。但是有些任务只使用读写很难完成，例如检查特定于设备的功能和属性，超出了通用文件系统的限制。所以，对于设备来讲，还有一种接口称为 ioctl，表示输入输出控制接口，是用于配置和修改特定设备属性的通用接口，这个我们后面几节会详细说。

总结时刻

这一节，我们讲了输入与输出设备的管理，内容比较多。输入输出设备就像管理代理商一样。因为代理商复杂多变，代理商管理也同样复杂多变，需要层层屏蔽差异化的部分，给上层提供标准化的部分，最终到用户态，给用户提供了基于文件系统的统一的接口。

课堂练习

如果你手头的 Linux 是一台物理机，试着插进一块 U 盘，看文件系统中设备的变化。如果你没有 Linux 物理机，可以使用公有云的云主机，添加一块硬盘，看文件系统中设备的变化。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

32 | 字符设备（上）：如何建立直销模式？

上一节，我们讲了输入输出设备的层次模型，还是比较复杂的，块设备尤其复杂。这一节为了让你更清晰地了解设备驱动程序的架构，我们先来讲稍微简单一点的字符设备驱动。

这一节，我找了两个比较简单的字符设备驱动来解析一下。一个是输入字符设备，鼠标。代码在 drivers/input/mouse/logibm.c 这里。

/*

 * Logitech Bus Mouse Driver for Linux

 */

module_init(logibm_init);

module_exit(logibm_exit);

另外一个是输出字符设备，打印机，代码 drivers/char/lp.c 这里。

/*

 * Generic parallel printer driver

 */

module_init(lp_init_module);

module_exit(lp_cleanup_module);

内核模块

上一节，我们讲过，设备驱动程序是一个内核模块，以 ko 的文件形式存在，可以通过 insmod 加载到内核中。那我们首先来看一下，怎么样才能构建一个内核模块呢？

一个内核模块应该由以下几部分组成。

第一部分，头文件部分。一般的内核模块，都需要 include 下面两个头文件：

#include <linux/module.h>

#include <linux/init.h>

如果你去看上面两个驱动程序，都能找到这两个头文件。当然如果需要的话，我们还可以引入更多的头文件。

第二部分，定义一些函数，用于处理内核模块的主要逻辑。例如打开、关闭、读取、写入设备的函数或者响应中断的函数。

例如，logibm.c 里面就定义了 logibm_open。logibm_close 就是处理打开和关闭的，定义了 logibm_interrupt 就是用来响应中断的。再如，lp.c 里面就定义了 lp_read，lp_write 就是处理读写的。

第三部分，定义一个 file_operations 结构。前面我们讲过，设备是可以通过文件系统的接口进行访问的。咱们讲文件系统的时候说过，对于某种文件系统的操作，都是放在 file_operations 里面的。例如 ext4 就定义了这么一个结构，里面都是 ext4_xxx 之类的函数。设备要想被文件系统的接口操作，也需要定义这样一个结构。

例如，lp.c 里面就定义了这样一个结构。

static const struct file_operations lp_fops = {

	.owner		= THIS_MODULE,

	.write		= lp_write,

	.unlocked_ioctl	= lp_ioctl,

#ifdef CONFIG_COMPAT

	.compat_ioctl	= lp_compat_ioctl,

#endif

	.open		= lp_open,

	.release	= lp_release,

#ifdef CONFIG_PARPORT_1284

	.read		= lp_read,

#endif

	.llseek		= noop_llseek,

};

在 logibm.c 里面，我们找不到这样的结构，是因为它属于众多输入设备的一种，而输入设备的操作被统一定义在 drivers/input/input.c 里面，logibm.c 只是定义了一些自己独有的操作。

static const struct file_operations input_devices_fileops = {

	.owner		= THIS_MODULE,

	.open		= input_proc_devices_open,

	.poll		= input_proc_devices_poll,

	.read		= seq_read,

	.llseek		= seq_lseek,

	.release	= seq_release,

};

第四部分，定义整个模块的初始化函数和退出函数，用于加载和卸载这个 ko 的时候调用。

例如 lp.c 就定义了 lp_init_module 和 lp_cleanup_module，logibm.c 就定义了 logibm_init 和 logibm_exit。

第五部分，调用 module_init 和 module_exit，分别指向上面两个初始化函数和退出函数。就像本节最开头展示的一样。

第六部分，声明一下 lisense，调用 MODULE_LICENSE。

有了这六部分，一个内核模块就基本合格了，可以工作了。

打开字符设备

字符设备可不是一个普通的内核模块，它有自己独特的行为。接下来，我们就沿着打开一个字符设备的过程，看看字符设备这个内核模块做了哪些特殊的事情。

要使用一个字符设备，我们首先要把写好的内核模块，通过 insmod 加载进内核。这个时候，先调用的就是 module_init 调用的初始化函数。

例如，在 lp.c 的初始化函数 lp_init 对应的代码如下：

static int __init lp_init (void)

{

......

	if (register_chrdev (LP_MAJOR, "lp", &lp_fops)) {

		printk (KERN_ERR "lp: unable to get major %d\n", LP_MAJOR);

		return -EIO;

	}

......

}

int __register_chrdev(unsigned int major, unsigned int baseminor,

		      unsigned int count, const char *name,

		      const struct file_operations *fops)

{

	struct char_device_struct *cd;

	struct cdev *cdev;

	int err = -ENOMEM;

......

	cd = __register_chrdev_region(major, baseminor, count, name);

	cdev = cdev_alloc();

	cdev->owner = fops->owner;

	cdev->ops = fops;

	kobject_set_name(&cdev->kobj, "%s", name);

	err = cdev_add(cdev, MKDEV(cd->major, baseminor), count);

	cd->cdev = cdev;

	return major ? 0 : cd->major;

}

在字符设备驱动的内核模块加载的时候，最重要的一件事情就是，注册这个字符设备。注册的方式是调用 __register_chrdev_region，注册字符设备的主次设备号和名称，然后分配一个 struct cdev 结构，将 cdev 的 ops 成员变量指向这个模块声明的 file_operations。然后，cdev_add 会将这个字符设备添加到内核中一个叫作 struct kobj_map *cdev_map 的结构，来统一管理所有字符设备。

其中，MKDEV(cd->major, baseminor) 表示将主设备号和次设备号生成一个 dev_t 的整数，然后将这个整数 dev_t 和 cdev 关联起来。

/**

 * cdev_add() - add a char device to the system

 * @p: the cdev structure for the device

 * @dev: the first device number for which this device is responsible

 * @count: the number of consecutive minor numbers corresponding to this

 *         device

 *

 * cdev_add() adds the device represented by @p to the system, making it

 * live immediately.  A negative error code is returned on failure.

 */

int cdev_add(struct cdev *p, dev_t dev, unsigned count)

{

	int error;

	p->dev = dev;

	p->count = count;

	error = kobj_map(cdev_map, dev, count, NULL,

			 exact_match, exact_lock, p);

	kobject_get(p->kobj.parent);

	return 0;

在 logibm.c 中，我们在 logibm_init 找不到注册字符设备，这是因为 input.c 里面的初始化函数 input_init 会调用 register_chrdev_region，注册输入的字符设备，会在 logibm_init 中调用 input_register_device，将 logibm.c 这个字符设备注册到 input.c 里面去，这就相当于 input.c 对多个输入字符设备进行统一的管理。

内核模块加载完毕后，接下来要通过 mknod 在 /dev 下面创建一个设备文件，只有有了这个设备文件，我们才能通过文件系统的接口，对这个设备文件进行操作。

mknod 也是一个系统调用，定义如下：

SYSCALL_DEFINE3(mknod, const char __user *, filename, umode_t, mode, unsigned, dev)

{

	return sys_mknodat(AT_FDCWD, filename, mode, dev);

}

SYSCALL_DEFINE4(mknodat, int, dfd, const char __user *, filename, umode_t, mode,

		unsigned, dev)

{

	struct dentry *dentry;

	struct path path;

......

	dentry = user_path_create(dfd, filename, &path, lookup_flags);

......

	switch (mode & S_IFMT) {

......

		case S_IFCHR: case S_IFBLK:

			error = vfs_mknod(path.dentry->d_inode,dentry,mode,

					new_decode_dev(dev));

			break;

......

	}

}

我们可以在这个系统调用里看到，在文件系统上，顺着路径找到 /dev/xxx 所在的文件夹，然后为这个新创建的设备文件创建一个 dentry。这是维护文件和 inode 之间的关联关系的结构。

接下来，如果是字符文件 S_IFCHR 或者设备文件 S_IFBLK，我们就调用 vfs_mknod。

int vfs_mknod(struct inode *dir, struct dentry *dentry, umode_t mode, dev_t dev)

{

......

	error = dir->i_op->mknod(dir, dentry, mode, dev);

......

}

这里需要调用对应的文件系统的 inode_operations。应该调用哪个文件系统呢？

如果我们在 linux 下面执行 mount 命令，能看到下面这一行：

devtmpfs on /dev type devtmpfs (rw,nosuid,size=3989584k,nr_inodes=997396,mode=755)

也就是说，/dev 下面的文件系统的名称为 devtmpfs，我们可以在内核中找到它。

static struct dentry *dev_mount(struct file_system_type *fs_type, int flags,

		      const char *dev_name, void *data)

{

#ifdef CONFIG_TMPFS

	return mount_single(fs_type, flags, data, shmem_fill_super);

#else

	return mount_single(fs_type, flags, data, ramfs_fill_super);

#endif

}

static struct file_system_type dev_fs_type = {

	.name = "devtmpfs",

	.mount = dev_mount,

	.kill_sb = kill_litter_super,

};

从这里可以看出，devtmpfs 在挂载的时候，有两种模式，一种是 ramfs，一种是 shmem 都是基于内存的文件系统。这里你先不用管，基于内存的文件系统具体是怎么回事儿。

static const struct inode_operations ramfs_dir_inode_operations = {

......

	.mknod		= ramfs_mknod,

};

static const struct inode_operations shmem_dir_inode_operations = {

#ifdef CONFIG_TMPFS

......

	.mknod		= shmem_mknod,

};

这两个 mknod 虽然实现不同，但是都会调用到用一个函数 init_special_inode。

void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)

{

	inode->i_mode = mode;

	if (S_ISCHR(mode)) {

		inode->i_fop = &def_chr_fops;

		inode->i_rdev = rdev;

	} else if (S_ISBLK(mode)) {

		inode->i_fop = &def_blk_fops;

		inode->i_rdev = rdev;

	} else if (S_ISFIFO(mode))

		inode->i_fop = &pipefifo_fops;

	else if (S_ISSOCK(mode))

		;	/* leave it no_open_fops */

}

显然这个文件是个特殊文件，inode 也是特殊的。这里这个 inode 可以关联字符设备、块设备、FIFO 文件、Socket 等。我们这里只看字符设备。

这里的 inode 的 file_operations 指向一个 def_chr_fops，这里面只有一个 open，就等着你打开它。

另外，inode 的 i_rdev 指向这个设备的 dev_t。还记得 cdev_map 吗？通过这个 dev_t，可以找到我们刚在加载的字符设备 cdev。

const struct file_operations def_chr_fops = {

	.open = chrdev_open,

};

到目前为止，我们只是创建了 /dev 下面的一个文件，并且和相应的设备号关联起来。但是，我们还没有打开这个 /dev 下面的设备文件。

现在我们来打开它。打开一个文件的流程，我们在文件系统那一节讲过了，这里不再重复。最终就像打开字符设备的图中一样，打开文件的进程的 task_struct 里，有一个数组代表它打开的文件，下标就是文件描述符 fd，每一个打开的文件都有一个 struct file 结构，会指向一个 dentry 项。dentry 可以用来关联 inode。这个 dentry 就是咱们上面 mknod 的时候创建的。

在进程里面调用 open 函数，最终对调用到这个特殊的 inode 的 open 函数，也就是 chrdev_open。

static int chrdev_open(struct inode *inode, struct file *filp)

{

	const struct file_operations *fops;

	struct cdev *p;

	struct cdev *new = NULL;

	int ret = 0;

	p = inode->i_cdev;

	if (!p) {

		struct kobject *kobj;

		int idx;

		kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);

		new = container_of(kobj, struct cdev, kobj);

		p = inode->i_cdev;

		if (!p) {

			inode->i_cdev = p = new;

			list_add(&inode->i_devices, &p->list);

			new = NULL;

		}

	}

......

	fops = fops_get(p->ops);

......

	replace_fops(filp, fops);

	if (filp->f_op->open) {

		ret = filp->f_op->open(inode, filp);

......

	}

......

}

在这个函数里面，我们首先看这个 inode 的 i_cdev，是否已经关联到 cdev。如果第一次打开，当然没有。没有没关系，inode 里面有 i_rdev 呀，也就是有 dev_t。我们可以通过它在 cdev_map 中找 cdev。咱们上面注册过了，所以肯定能够找到。找到后我们就将 inode 的 i_cdev，关联到找到的 cdev new。

找到 cdev 就好办了。cdev 里面有 file_operations，这是设备驱动程序自己定义的。我们可以通过它来操作设备驱动程序，把它付给 struct file 里面的 file_operations。这样以后操作文件描述符，就是直接操作设备了。

最后，我们需要调用设备驱动程序的 file_operations 的 open 函数，真正打开设备。对于打印机，调用的是 lp_open。对于鼠标调用的是 input_proc_devices_open，最终会调用到 logibm_open。这些多和设备相关，你不必看懂它们。

写入字符设备

当我们像打开一个文件一样打开一个字符设备之后，接下来就是对这个设备的读写。对于文件的读写咱们在文件系统那一章详细讲述过，读写的过程是类似的，所以这里我们只解析打印机驱动写入的过程。

写入一个字符设备，就是用文件系统的标准接口 write，参数文件描述符 fd，在内核里面调用的 sys_write，在 sys_write 里面根据文件描述符 fd 得到 struct file 结构。接下来再调用 vfs_write。

ssize_t __vfs_write(struct file *file, const char __user *p, size_t count, loff_t *pos)

{

	if (file->f_op->write)

		return file->f_op->write(file, p, count, pos);

	else if (file->f_op->write_iter)

		return new_sync_write(file, p, count, pos);

	else

		return -EINVAL;

}

我们可以看到，在 __vfs_write 里面，我们会调用 struct file 结构里的 file_operations 的 write 函数。上面我们打开字符设备的时候，已经将 struct file 结构里面的 file_operations 指向了设备驱动程序的 file_operations 结构，所以这里的 write 函数最终会调用到 lp_write。

static ssize_t lp_write(struct file * file, const char __user * buf,

		        size_t count, loff_t *ppos)

{

	unsigned int minor = iminor(file_inode(file));

	struct parport *port = lp_table[minor].dev->port;

	char *kbuf = lp_table[minor].lp_buffer;

	ssize_t retv = 0;

	ssize_t written;

	size_t copy_size = count;

......

	/* Need to copy the data from user-space. */

	if (copy_size > LP_BUFFER_SIZE)

		copy_size = LP_BUFFER_SIZE;

......

	if (copy_from_user (kbuf, buf, copy_size)) {

		retv = -EFAULT;

		goto out_unlock;

	}

......

	do {

		/* Write the data. */

		written = parport_write (port, kbuf, copy_size);

		if (written > 0) {

			copy_size -= written;

			count -= written;

			buf  += written;

			retv += written;

		}

......

        if (need_resched())

			schedule ();

		if (count) {

			copy_size = count;

			if (copy_size > LP_BUFFER_SIZE)

				copy_size = LP_BUFFER_SIZE;

			if (copy_from_user(kbuf, buf, copy_size)) {

				if (retv == 0)

					retv = -EFAULT;

				break;

			}

		}

	} while (count > 0);

......

这个设备驱动程序的写入函数的实现还是比较典型的。先是调用 copy_from_user 将数据从用户态拷贝到内核态的缓存中，然后调用 parport_write 写入外部设备。这里还有一个 schedule 函数，也即写入的过程中，给其他线程抢占 CPU 的机会。然后，如果 count 还是大于 0，也就是数据还没有写完，那我们就接着 copy_from_user，接着 parport_write，直到写完为止。

使用 IOCTL 控制设备

对于 I/O 设备来讲，我们前面也说过，除了读写设备，还会调用 ioctl，做一些特殊的 I/O 操作。

ioctl 也是一个系统调用，它在内核里面的定义如下：

SYSCALL_DEFINE3(ioctl, unsigned int, fd, unsigned int, cmd, unsigned long, arg)

{

	int error;

	struct fd f = fdget(fd);

......

	error = do_vfs_ioctl(f.file, fd, cmd, arg);

	fdput(f);

	return error;

}

其中，fd 是这个设备的文件描述符，cmd 是传给这个设备的命令，arg 是命令的参数。其中，对于命令和命令的参数，使用 ioctl 系统调用的用户和驱动程序的开发人员约定好行为即可。

其实 cmd 看起来是一个 int，其实他的组成比较复杂，它由几部分组成：

最低八位为 NR，是命令号；

然后八位是 TYPE，是类型；

然后十四位是参数的大小；

最高两位是 DIR，是方向，表示写入、读出，还是读写。

由于组成比较复杂，有一些宏是专门用于组成这个 cmd 值的。

/*

 * Used to create numbers.

 */

#define _IO(type,nr)		_IOC(_IOC_NONE,(type),(nr),0)

#define _IOR(type,nr,size)	_IOC(_IOC_READ,(type),(nr),(_IOC_TYPECHECK(size)))

#define _IOW(type,nr,size)	_IOC(_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))

#define _IOWR(type,nr,size)	_IOC(_IOC_READ|_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))

/* used to decode ioctl numbers.. */

#define _IOC_DIR(nr)		(((nr) >> _IOC_DIRSHIFT) & _IOC_DIRMASK)

#define _IOC_TYPE(nr)		(((nr) >> _IOC_TYPESHIFT) & _IOC_TYPEMASK)

#define _IOC_NR(nr)		(((nr) >> _IOC_NRSHIFT) & _IOC_NRMASK)

#define _IOC_SIZE(nr)		(((nr) >> _IOC_SIZESHIFT) & _IOC_SIZEMASK)

在用户程序中，可以通过上面的”Used to create numbers“这些宏，根据参数生成 cmd，在驱动程序中，可以通过下面的”used to decode ioctl numbers“这些宏，解析 cmd 后，执行指令。

ioctl 中会调用 do_vfs_ioctl，这里面对于已经定义好的 cmd，进行相应的处理。如果不是默认定义好的 cmd，则执行默认操作。对于普通文件，调用 file_ioctl；对于其他文件调用 vfs_ioctl。

int do_vfs_ioctl(struct file *filp, unsigned int fd, unsigned int cmd,

	     unsigned long arg)

{

	int error = 0;

	int __user *argp = (int __user *)arg;

	struct inode *inode = file_inode(filp);

	switch (cmd) {

......

	case FIONBIO:

		error = ioctl_fionbio(filp, argp);

		break;

	case FIOASYNC:

		error = ioctl_fioasync(fd, filp, argp);

		break;

......

	case FICLONE:

		return ioctl_file_clone(filp, arg, 0, 0, 0);

	default:

		if (S_ISREG(inode->i_mode))

			error = file_ioctl(filp, cmd, arg);

		else

			error = vfs_ioctl(filp, cmd, arg);

		break;

	}

	return error;

由于咱们这里是设备驱动程序，所以调用的是 vfs_ioctl。

/**

 * vfs_ioctl - call filesystem specific ioctl methods

 * @filp:	open file to invoke ioctl method on

 * @cmd:	ioctl command to execute

 * @arg:	command-specific argument for ioctl

 *

 * Invokes filesystem specific ->unlocked_ioctl, if one exists; otherwise

 * returns -ENOTTY.

 *

 * Returns 0 on success, -errno on error.

 */

long vfs_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)

{

	int error = -ENOTTY;

	if (!filp->f_op->unlocked_ioctl)

		goto out;

	error = filp->f_op->unlocked_ioctl(filp, cmd, arg);

	if (error == -ENOIOCTLCMD)

		error = -ENOTTY;

 out:

	return error;

这里面调用的是 struct file 里 file_operations 的 unlocked_ioctl 函数。我们前面初始化设备驱动的时候，已经将 file_operations 指向设备驱动的 file_operations 了。这里调用的是设备驱动的 unlocked_ioctl。对于打印机程序来讲，调用的是 lp_ioctl。可以看出来，这里面就是 switch 语句，它会根据不同的 cmd，做不同的操作。

static long lp_ioctl(struct file *file, unsigned int cmd,

			unsigned long arg)

{

	unsigned int minor;

	struct timeval par_timeout;

	int ret;

	minor = iminor(file_inode(file));

	mutex_lock(&lp_mutex);

	switch (cmd) {

......

	default:

		ret = lp_do_ioctl(minor, cmd, arg, (void __user *)arg);

		break;

	}

	mutex_unlock(&lp_mutex);

	return ret;

}

static int lp_do_ioctl(unsigned int minor, unsigned int cmd,

	unsigned long arg, void __user *argp)

{

	int status;

	int retval = 0;

	switch ( cmd ) {

		case LPTIME:

			if (arg > UINT_MAX / HZ)

				return -EINVAL;

			LP_TIME(minor) = arg * HZ/100;

			break;

		case LPCHAR:

			LP_CHAR(minor) = arg;

			break;

		case LPABORT:

			if (arg)

				LP_F(minor) |= LP_ABORT;

			else

				LP_F(minor) &= ~LP_ABORT;

			break;

		case LPABORTOPEN:

			if (arg)

				LP_F(minor) |= LP_ABORTOPEN;

			else

				LP_F(minor) &= ~LP_ABORTOPEN;

			break;

		case LPCAREFUL:

			if (arg)

				LP_F(minor) |= LP_CAREFUL;

			else

				LP_F(minor) &= ~LP_CAREFUL;

			break;

		case LPWAIT:

			LP_WAIT(minor) = arg;

			break;

		case LPSETIRQ:

			return -EINVAL;

			break;

		case LPGETIRQ:

			if (copy_to_user(argp, &LP_IRQ(minor),

					sizeof(int)))

				return -EFAULT;

			break;

		case LPGETSTATUS:

			if (mutex_lock_interruptible(&lp_table[minor].port_mutex))

				return -EINTR;

			lp_claim_parport_or_block (&lp_table[minor]);

			status = r_str(minor);

			lp_release_parport (&lp_table[minor]);

			mutex_unlock(&lp_table[minor].port_mutex);

			if (copy_to_user(argp, &status, sizeof(int)))

				return -EFAULT;

			break;

		case LPRESET:

			lp_reset(minor);

			break;

 		case LPGETFLAGS:

 			status = LP_F(minor);

			if (copy_to_user(argp, &status, sizeof(int)))

				return -EFAULT;

			break;

		default:

			retval = -EINVAL;

	}

	return retval

总结时刻

这一节我们讲了字符设备的打开、写入和 ioctl 等最常见的操作。一个字符设备要能够工作，需要三部分配合。

第一，有一个设备驱动程序的 ko 模块，里面有模块初始化函数、中断处理函数、设备操作函数。这里面封装了对于外部设备的操作。加载设备驱动程序模块的时候，模块初始化函数会被调用。在内核维护所有字符设备驱动的数据结构 cdev_map 里面注册，我们就可以很容易根据设备号，找到相应的设备驱动程序。

第二，在 /dev 目录下有一个文件表示这个设备，这个文件在特殊的 devtmpfs 文件系统上，因而也有相应的 dentry 和 inode。这里的 inode 是一个特殊的 inode，里面有设备号。通过它，我们可以在 cdev_map 中找到设备驱动程序，里面还有针对字符设备文件的默认操作 def_chr_fops。

第三，打开一个字符设备文件和打开一个普通的文件有类似的数据结构，有文件描述符、有 struct file、指向字符设备文件的 dentry 和 inode。字符设备文件的相关操作 file_operations 一开始指向 def_chr_fops，在调用 def_chr_fops 里面的 chrdev_open 函数的时候，修改为指向设备操作函数，从而读写一个字符设备文件就会直接变成读写外部设备了。

课堂练习

这节我用打印机驱动程序作为例子来给你讲解字符设备，请你仔细看一下它的代码，设想一下，如果让你自己写一个字符设备驱动程序，应该实现哪些函数呢？

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

33 | 字符设备（下）：如何建立直销模式？

上一节，我们讲了一个设备能够被打开、能够读写，主流的功能基本就完成了。我们讲输出输出设备的时候说到，如果一个设备有事情需要通知操作系统，会通过中断和设备驱动程序进行交互，今天我们就来解析中断处理机制。

鼠标就是通过中断，将自己的位置和按键信息，传递给设备驱动程序。

static int logibm_open(struct input_dev *dev)

{

	if (request_irq(logibm_irq, logibm_interrupt, 0, "logibm", NULL)) {

		printk(KERN_ERR "logibm.c: Can't allocate irq %d\n", logibm_irq);

		return -EBUSY;

	}

	outb(LOGIBM_ENABLE_IRQ, LOGIBM_CONTROL_PORT);

	return 0;

}

static irqreturn_t logibm_interrupt(int irq, void *dev_id)

{

	char dx, dy;

	unsigned char buttons;

	outb(LOGIBM_READ_X_LOW, LOGIBM_CONTROL_PORT);

	dx = (inb(LOGIBM_DATA_PORT) & 0xf);

	outb(LOGIBM_READ_X_HIGH, LOGIBM_CONTROL_PORT);

	dx |= (inb(LOGIBM_DATA_PORT) & 0xf) << 4;

	outb(LOGIBM_READ_Y_LOW, LOGIBM_CONTROL_PORT);

	dy = (inb(LOGIBM_DATA_PORT) & 0xf);

	outb(LOGIBM_READ_Y_HIGH, LOGIBM_CONTROL_PORT);

	buttons = inb(LOGIBM_DATA_PORT);

	dy |= (buttons & 0xf) << 4;

	buttons = ~buttons >> 5;

	input_report_rel(logibm_dev, REL_X, dx);

	input_report_rel(logibm_dev, REL_Y, dy);

	input_report_key(logibm_dev, BTN_RIGHT,  buttons & 1);

	input_report_key(logibm_dev, BTN_MIDDLE, buttons & 2);

	input_report_key(logibm_dev, BTN_LEFT,   buttons & 4);

	input_sync(logibm_dev);

	outb(LOGIBM_ENABLE_IRQ, LOGIBM_CONTROL_PORT);

	return IRQ_HANDLED

要处理中断，需要有一个中断处理函数。定义如下：

irqreturn_t (*irq_handler_t)(int irq, void * dev_id);

/**

 * enum irqreturn

 * @IRQ_NONE		interrupt was not from this device or was not handled

 * @IRQ_HANDLED		interrupt was handled by this device

 * @IRQ_WAKE_THREAD	handler requests to wake the handler thread

 */

enum irqreturn {

	IRQ_NONE		= (0 << 0),

	IRQ_HANDLED		= (1 << 0),

	IRQ_WAKE_THREAD		= (1 << 1),

};

其中，irq 是一个整数，是中断信号。dev_id 是一个 void * 的通用指针，主要用于区分同一个中断处理函数对于不同设备的处理。

这里的返回值有三种：IRQ_NONE 表示不是我的中断，不归我管；IRQ_HANDLED 表示处理完了的中断；IRQ_WAKE_THREAD 表示有一个进程正在等待这个中断，中断处理完了，应该唤醒它。

上面的例子中，logibm_interrupt 这个中断处理函数，先是获取了 x 和 y 的移动坐标，以及左中右的按键，上报上去，然后返回 IRQ_HANDLED，这表示处理完毕。

其实，写一个真正生产用的中断处理程序还是很复杂的。当一个中断信号 A 触发后，正在处理的过程中，这个中断信号 A 是应该暂时关闭的，这样是为了防止再来一个中断信号 A，在当前的中断信号 A 的处理过程中插一杠子。但是，这个暂时关闭的时间应该多长呢？

如果太短了，应该原子化处理完毕的没有处理完毕，又被另一个中断信号 A 中断了，很多操作就不正确了；如果太长了，一直关闭着，新的中断信号 A 进不来，系统就显得很慢。所以，很多中断处理程序将整个中断要做的事情分成两部分，称为上半部和下半部，或者成为关键处理部分和延迟处理部分。在中断处理函数中，仅仅处理关键部分，完成了就将中断信号打开，使得新的中断可以进来，需要比较长时间处理的部分，也即延迟部分，往往通过工作队列等方式慢慢处理。

这个写起来可以是一本书了，推荐你好好读一读《Linux Device Drivers》这本书，这里我就不详细介绍了。

有了中断处理函数，接下来要调用 request_irq 来注册这个中断处理函数。request_irq 有这样几个参数：

unsigned int irq 是中断信号；

irq_handler_t handler 是中断处理函数；

unsigned long flags 是一些标识位；

const char *name 是设备名称；

void *dev 这个通用指针应该和中断处理函数的 void *dev 相对应。

static inline int __must_check

request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev)

{

	return request_threaded_irq(irq, handler, NULL, flags, name, dev);

}

中断处理函数被注册到哪里去呢？让我们沿着 request_irq 看下去。request_irq 调用的是 request_threaded_irq。代码如下：

int request_threaded_irq(unsigned int irq, irq_handler_t handler,

			 irq_handler_t thread_fn, unsigned long irqflags,

			 const char *devname, void *dev_id)

{

	struct irqaction *action;

	struct irq_desc *desc;

	int retval;

......

	desc = irq_to_desc(irq);

......

	action = kzalloc(sizeof(struct irqaction), GFP_KERNEL);

	action->handler = handler;

	action->thread_fn = thread_fn;

	action->flags = irqflags;

	action->name = devname;

	action->dev_id = dev_id;

......

	retval = __setup_irq(irq, desc, action);

......

}

对于每一个中断，都有一个对中断的描述结构 struct irq_desc。它有一个重要的成员变量是 struct irqaction，用于表示处理这个中断的动作。如果我们仔细看这个结构，会发现，它里面有 next 指针，也就是说，这是一个链表，对于这个中断的所有处理动作，都串在这个链表上。

struct irq_desc {

......

	struct irqaction	*action;	/* IRQ action list */

......

	struct module		*owner;

	const char		*name;

};

/**

 * struct irqaction - per interrupt action descriptor

 * @handler:	interrupt handler function

 * @name:	name of the device

 * @dev_id:	cookie to identify the device

 * @percpu_dev_id:	cookie to identify the device

 * @next:	pointer to the next irqaction for shared interrupts

 * @irq:	interrupt number

 * @flags:	flags (see IRQF_* above)

 * @thread_fn:	interrupt handler function for threaded interrupts

 * @thread:	thread pointer for threaded interrupts

 * @secondary:	pointer to secondary irqaction (force threading)

 * @thread_flags:	flags related to @thread

 * @thread_mask:	bitmask for keeping track of @thread activity

 * @dir:	pointer to the proc/irq/NN/name entry

 */

struct irqaction {

	irq_handler_t		handler;

	void			*dev_id;

	void __percpu		*percpu_dev_id;

	struct irqaction	*next;

	irq_handler_t		thread_fn;

	struct task_struct	*thread;

	struct irqaction	*secondary;

	unsigned int		irq;

	unsigned int		flags;

	unsigned long		thread_flags;

	unsigned long		thread_mask;

	const char		*name;

	struct proc_dir_entry	*dir;

};

每一个中断处理动作的结构 struct irqaction，都有以下成员：

中断处理函数 handler；

void *dev_id 为设备 id；

irq 为中断信号；

如果中断处理函数在单独的线程运行，则有 thread_fn 是线程的执行函数，thread 是线程的 task_struct。

在 request_threaded_irq 函数中，irq_to_desc 根据中断信号查找中断描述结构。如何查找呢？这就要区分情况。一般情况下，所有的 struct irq_desc 都放在一个数组里面，我们直接按下标查找就可以了。如果配置了 CONFIG_SPARSE_IRQ，那中断号是不连续的，就不适合用数组保存了，

我们可以放在一棵基数树上。我们不是第一次遇到这个数据结构了。这种结构对于从某个整型 key 找到 value 速度很快，中断信号 irq 是这个整数。通过它，我们很快就能定位到对应的 struct irq_desc。

#ifdef CONFIG_SPARSE_IRQ

static RADIX_TREE(irq_desc_tree, GFP_KERNEL);

struct irq_desc *irq_to_desc(unsigned int irq)

{

	return radix_tree_lookup(&irq_desc_tree, irq);

}

#else /* !CONFIG_SPARSE_IRQ */

struct irq_desc irq_desc[NR_IRQS] __cacheline_aligned_in_smp = {

	[0 ... NR_IRQS-1] = {

	}

};

struct irq_desc *irq_to_desc(unsigned int irq)

{

	return (irq < NR_IRQS) ? irq_desc + irq : NULL;

}

#endif /* !CONFIG_SPARSE_IRQ */

为什么中断信号会有稀疏，也就是不连续的情况呢？这里需要说明一下，这里的 irq 并不是真正的、物理的中断信号，而是一个抽象的、虚拟的中断信号。因为物理的中断信号和硬件关联比较大，中断控制器也是各种各样的。

作为内核，我们不可能写程序的时候，适配各种各样的硬件中断控制器，因而就需要有一层中断抽象层。这里虚拟中断信号到中断描述结构的映射，就是抽象中断层的主要逻辑。

下面我们讲真正中断响应的时候，会涉及物理中断信号。可以想象，如果只有一个 CPU，一个中断控制器，则基本能够保证从物理中断信号到虚拟中断信号的映射是线性的，这样用数组表示就没啥问题，但是如果有多个 CPU，多个中断控制器，每个中断控制器各有各的物理中断信号，就没办法保证虚拟中断信号是连续的，所以就要用到基数树了。

接下来，request_threaded_irq 函数分配了一个 struct irqaction，并且初始化它，接着调用 __setup_irq。在这个函数里面，如果 struct irq_desc 里面已经有 struct irqaction 了，我们就将新的 struct irqaction 挂在链表的末端。如果设定了以单独的线程运行中断处理函数，setup_irq_thread 就会创建这个内核线程，wake_up_process 会唤醒它。

static int

__setup_irq(unsigned int irq, struct irq_desc *desc, struct irqaction *new)

{

	struct irqaction *old, **old_ptr;

	unsigned long flags, thread_mask = 0;

	int ret, nested, shared = 0;

......

	new->irq = irq;

......

	/*

	 * Create a handler thread when a thread function is supplied

	 * and the interrupt does not nest into another interrupt

	 * thread.

	 */

	if (new->thread_fn && !nested) {

		ret = setup_irq_thread(new, irq, false);

	}

......

	old_ptr = &desc->action;

	old = *old_ptr;

	if (old) {

		/* add new interrupt at end of irq queue */

		do {

			thread_mask |= old->thread_mask;

			old_ptr = &old->next;

			old = *old_ptr;

		} while (old);

	}

......

	*old_ptr = new;

......

	if (new->thread)

		wake_up_process(new->thread);

......

}

static int

setup_irq_thread(struct irqaction *new, unsigned int irq, bool secondary)

{

	struct task_struct *t;

	struct sched_param param = {

		.sched_priority = MAX_USER_RT_PRIO/2,

	};

	t = kthread_create(irq_thread, new, "irq/%d-%s", irq, new->name);

	sched_setscheduler_nocheck(t, SCHED_FIFO, &param);

	get_task_struct(t);

	new->thread = t;

......

	return 0;

至此为止，request_irq 完成了它的使命。总结来说，它就是根据中断信号 irq，找到基数树上对应的 irq_desc，然后将新的 irqaction 挂在链表上。

接下来，我们就来看，真正中断来了的时候，会发生一些什么。

真正中断的发生还是要从硬件开始。这里面有四个层次。

第一个层次是外部设备给中断控制器发送物理中断信号。

第二个层次是中断控制器将物理中断信号转换成为中断向量 interrupt vector，发给各个 CPU。

第三个层次是每个 CPU 都会有一个中断向量表，根据 interrupt vector 调用一个 IRQ 处理函数。注意这里的 IRQ 处理函数还不是咱们上面指定的 irq_handler_t，到这一层还是 CPU 硬件的要求。

第四个层次是在 IRQ 处理函数中，将 interrupt vector 转化为抽象中断层的中断信号 irq，调用中断信号 irq 对应的中断描述结构里面的 irq_handler_t。

在这里，我们不解析硬件的部分，我们从 CPU 收到中断向量开始分析。

CPU 收到的中断向量是什么样的呢？这个定义在文件 arch/x86/include/asm/irq_vectors.h 中。这里面的注释非常好，建议你仔细阅读。

/*

 * Linux IRQ vector layout.

 *

 * There are 256 IDT entries (per CPU - each entry is 8 bytes) which can

 * be defined by Linux. They are used as a jump table by the CPU when a

 * given vector is triggered - by a CPU-external, CPU-internal or

 * software-triggered event.

 *

 * Linux sets the kernel code address each entry jumps to early during

 * bootup, and never changes them. This is the general layout of the

 * IDT entries:

 *

 *  Vectors   0 ...  31 : system traps and exceptions - hardcoded events

 *  Vectors  32 ... 127 : device interrupts

 *  Vector  128         : legacy int80 syscall interface

 *  Vectors 129 ... INVALIDATE_TLB_VECTOR_START-1 except 204 : device interrupts

 *  Vectors INVALIDATE_TLB_VECTOR_START ... 255 : special interrupts

 *

 * 64-bit x86 has per CPU IDT tables, 32-bit has one shared IDT table.

 *

 * This file enumerates the exact layout of them:

 */

#define FIRST_EXTERNAL_VECTOR		0x20

#define IA32_SYSCALL_VECTOR		0x80

#define NR_VECTORS			 256

#define FIRST_SYSTEM_VECTOR		NR_VECTORS

通过这些注释，我们可以看出，CPU 能够处理的中断总共 256 个，用宏 NR_VECTOR 或者 FIRST_SYSTEM_VECTOR 表示。

为了处理中断，CPU 硬件要求每一个 CPU 都有一个中断向量表，通过 load_idt 加载，里面记录着每一个中断对应的处理方法，这个中断向量表定义在文件 arch/x86/kernel/traps.c 中。

gate_desc idt_table[NR_VECTORS] __page_aligned_bss;

对于一个 CPU 可以处理的中断被分为几个部分，第一部分 0 到 31 的前 32 位是系统陷入或者系统异常，这些错误无法屏蔽，一定要处理。

这些中断的处理函数在系统初始化的时候，在 start_kernel 函数中调用过 trap_init()。这个咱们讲系统初始化和系统调用的时候，都大概讲过这个函数，这里还需要仔细看一下。

void __init trap_init(void)

{

	int i;

...

	set_intr_gate(X86_TRAP_DE, divide_error);

// 各种各样的 set_intr_gate，不都贴在这里了，只贴一头一尾

...

	set_intr_gate(X86_TRAP_XF, simd_coprocessor_error);

	/* Reserve all the builtin and the syscall vector: */

	for (i = 0; i < FIRST_EXTERNAL_VECTOR; i++)

		set_bit(i, used_vectors);

#ifdef CONFIG_X86_32

	set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);

	set_bit(IA32_SYSCALL_VECTOR, used_vectors);

#endif

	/*

	 * Set the IDT descriptor to a fixed read-only location, so that the

	 * "sidt" instruction will not leak the location of the kernel, and

	 * to defend the IDT against arbitrary memory write vulnerabilities.

	 * It will be reloaded in cpu_init() */

	__set_fixmap(FIX_RO_IDT, __pa_symbol(idt_table), PAGE_KERNEL_RO);

	idt_descr.address = fix_to_virt(FIX_RO_IDT);

......

我这里贴的代码省略了很多，在 trap_init 函数的一开始，调用了大量的 set_intr_gate，最终都会调用 _set_gate，代码如下：

static inline void _set_gate(int gate, unsigned type, void *addr,

			     unsigned dpl, unsigned ist, unsigned seg)

{

	gate_desc s;

	pack_gate(&s, type, (unsigned long)addr, dpl, ist, seg);

	write_idt_entry(idt_table, gate, &s);

}

从代码可以看出，set_intr_gate 其实就是将每个中断都设置了中断处理函数，放在中断向量表 idt_table 中。

在 trap_init 中，由于 set_intr_gate 调用的太多，容易让人眼花缭乱。其实 arch/x86/include/asm/traps.h 文件中，早就定义好了前 32 个中断。如果仔细对比一下，你会发现，这些都在 trap_init 中使用 set_intr_gate 设置过了。

/* Interrupts/Exceptions */

enum {

	X86_TRAP_DE = 0,	/*  0, Divide-by-zero */

	X86_TRAP_DB,		/*  1, Debug */

	X86_TRAP_NMI,		/*  2, Non-maskable Interrupt */

	X86_TRAP_BP,		/*  3, Breakpoint */

	X86_TRAP_OF,		/*  4, Overflow */

	X86_TRAP_BR,		/*  5, Bound Range Exceeded */

	X86_TRAP_UD,		/*  6, Invalid Opcode */

	X86_TRAP_NM,		/*  7, Device Not Available */

	X86_TRAP_DF,		/*  8, Double Fault */

	X86_TRAP_OLD_MF,	/*  9, Coprocessor Segment Overrun */

	X86_TRAP_TS,		/* 10, Invalid TSS */

	X86_TRAP_NP,		/* 11, Segment Not Present */

	X86_TRAP_SS,		/* 12, Stack Segment Fault */

	X86_TRAP_GP,		/* 13, General Protection Fault */

	X86_TRAP_PF,		/* 14, Page Fault */

	X86_TRAP_SPURIOUS,	/* 15, Spurious Interrupt */

	X86_TRAP_MF,		/* 16, x87 Floating-Point Exception */

	X86_TRAP_AC,		/* 17, Alignment Check */

	X86_TRAP_MC,		/* 18, Machine Check */

	X86_TRAP_XF,		/* 19, SIMD Floating-Point Exception */

	X86_TRAP_IRET = 32,	/* 32, IRET Exception */

};

我们回到 trap_init 中，当前 32 个中断都用 set_intr_gate 设置完毕。在中断向量表 idt_table 中填完了之后，接下来的 for 循环，for (i = 0; i < FIRST_EXTERNAL_VECTOR; i++)，将前 32 个中断都在 used_vectors 中标记为 1，表示这些都设置过中断处理函数了。

接下来，trap_init 对单独调用 set_intr_gate 来设置 32 位系统调用的中断。IA32_SYSCALL_VECTOR，也即 128，单独将 used_vectors 中的第 128 位标记为 1。

在 trap_init 的最后，我们将 idt_table 放在一个固定的虚拟地址上。trap_init 结束后，中断向量表中已经填好了前 32 位，外加一位 32 位系统调用，其他的都是用于设备中断。

在 start_kernel 调用完毕 trap_init 之后，还会调用 init_IRQ() 来初始化其他的设备中断，最终会调用到 native_init_IRQ。

void __init native_init_IRQ(void)

{

	int i;

	i = FIRST_EXTERNAL_VECTOR;

#ifndef CONFIG_X86_LOCAL_APIC

#define first_system_vector NR_VECTORS

#endif

	for_each_clear_bit_from(i, used_vectors, first_system_vector) {

		/* IA32_SYSCALL_VECTOR could be used in trap_init already. */

		set_intr_gate(i, irq_entries_start +

				8 * (i - FIRST_EXTERNAL_VECTOR));

	}

......

}

这里面从第 32 个中断开始，到最后 NR_VECTORS 为止，对于 used_vectors 中没有标记为 1 的位置，都会调用 set_intr_gate 设置中断向量表。

其实 used_vectors 中没有标记为 1 的，都是设备中断的部分。

也即所有的设备中断的中断处理函数，在中断向量表里面都会设置为从 irq_entries_start 开始，偏移量为 i - FIRST_EXTERNAL_VECTOR 的一项。

看来中断处理函数是定义在 irq_entries_start 这个表里面的，我们在 arch\x86\entry\entry_32.S 和 arch\x86\entry\entry_64.S 都能找到这个函数表的定义。

这又是汇编语言，不需要完全看懂，但是我们还是能看出来，这里面定义了 FIRST_SYSTEM_VECTOR - FIRST_EXTERNAL_VECTOR 项。每一项都是中断处理函数，会跳到 common_interrupt 去执行。这里会最终调用 do_IRQ，调用完毕后，就从中断返回。这里我们需要区分返回用户态还是内核态。这里会有一个几乎触发抢占，咱们讲进程切换的时候讲过的。

ENTRY(irq_entries_start)

    vector=FIRST_EXTERNAL_VECTOR

    .rept (FIRST_SYSTEM_VECTOR - FIRST_EXTERNAL_VECTOR)

	pushl	$(~vector+0x80)			/* Note: always in signed byte range */

    vector=vector+1

	jmp	common_interrupt /* 会调用到 do_IRQ */

	.align	8

    .endr

END(irq_entries_start)

common_interrupt:

	ASM_CLAC

	addq	$-0x80, (%rsp)			/* Adjust vector to [-256, -1] range */

	interrupt do_IRQ

	/* 0(%rsp): old RSP */

ret_from_intr:

......

	/* Interrupt came from user space */

GLOBAL(retint_user)

......

/* Returning to kernel space */

retint_kernel:

......

这样任何一个中断向量到达任何一个 CPU，最终都会走到 do_IRQ。我们来看 do_IRQ 的实现。

/*

 * do_IRQ handles all normal device IRQ's (the special

 * SMP cross-CPU interrupts have their own specific

 * handlers).

 */

__visible unsigned int __irq_entry do_IRQ(struct pt_regs *regs)

{

	struct pt_regs *old_regs = set_irq_regs(regs);

	struct irq_desc * desc;

	/* high bit used in ret_from_ code  */

	unsigned vector = ~regs->orig_ax;

......

	desc = __this_cpu_read(vector_irq[vector]);

	if (!handle_irq(desc, regs)) {

......

	}

......

	set_irq_regs(old_regs);

	return 1;

}

在这里面，从 AX 寄存器里面拿到了中断向量 vector，但是别忘了中断控制器发送给每个 CPU 的中断向量都是每个 CPU 局部的，而抽象中断处理层的虚拟中断信号 irq 以及它对应的中断描述结构 irq_desc 是全局的，也即这个 CPU 的 200 号的中断向量和另一个 CPU 的 200 号中断向量对应的虚拟中断信号 irq 和中断描述结构 irq_desc 可能不一样，这就需要一个映射关系。这个映射关系放在 Per CPU 变量 vector_irq 里面。

DECLARE_PER_CPU(vector_irq_t, vector_irq);

在系统初始化的时候，我们会调用 __assign_irq_vector，将虚拟中断信号 irq 分配到某个 CPU 上的中断向量。

static int __assign_irq_vector(int irq, struct apic_chip_data *d,

			       const struct cpumask *mask,

			       struct irq_data *irqdata)

{

	static int current_vector = FIRST_EXTERNAL_VECTOR + VECTOR_OFFSET_START;

	static int current_offset = VECTOR_OFFSET_START % 16;

	int cpu, vector;

......

	while (cpu < nr_cpu_ids) {

		int new_cpu, offset;

......

		vector = current_vector;

		offset = current_offset;

next:

		vector += 16;

		if (vector >= first_system_vector) {

			offset = (offset + 1) % 16;

			vector = FIRST_EXTERNAL_VECTOR + offset;

		}

		/* If the search wrapped around, try the next cpu */

		if (unlikely(current_vector == vector))

			goto next_cpu;

		if (test_bit(vector, used_vectors))

			goto next;

......

		/* Found one! */

		current_vector = vector;

		current_offset = offset;

		/* Schedule the old vector for cleanup on all cpus */

		if (d->cfg.vector)

			cpumask_copy(d->old_domain, d->domain);

		for_each_cpu(new_cpu, vector_searchmask)

			per_cpu(vector_irq, new_cpu)[vector] = irq_to_desc(irq);

		goto update;

next_cpu:

		cpumask_or(searched_cpumask, searched_cpumask, vector_cpumask);

		cpumask_andnot(vector_cpumask, mask, searched_cpumask);

		cpu = cpumask_first_and(vector_cpumask, cpu_online_mask);

		continue;

	}

....

在这里，一旦找到某个向量，就将 CPU 的此向量对应的向量描述结构 irq_desc，设置为虚拟中断信号 irq 对应的向量描述结构 irq_to_desc(irq)。

这样 do_IRQ 会根据中断向量 vector 得到对应的 irq_desc，然后调用 handle_irq。handle_irq 会调用 generic_handle_irq_desc，里面调用 irq_desc 的 handle_irq。

static inline void generic_handle_irq_desc(struct irq_desc *desc)

{

	desc->handle_irq(desc);

}

这里的 handle_irq，最终会调用 __handle_irq_event_percpu。代码如下：

irqreturn_t __handle_irq_event_percpu(struct irq_desc *desc, unsigned int *flags)

{

	irqreturn_t retval = IRQ_NONE;

	unsigned int irq = desc->irq_data.irq;

	struct irqaction *action;

	record_irq_time(desc);

	for_each_action_of_desc(desc, action) {

		irqreturn_t res;

		res = action->handler(irq, action->dev_id);

		switch (res) {

		case IRQ_WAKE_THREAD:

			__irq_wake_thread(desc, action);

		case IRQ_HANDLED:

			*flags |= action->flags;

			break;

		default:

			break;

		}

		retval |= res;

	}

	return retval;

__handle_irq_event_percpu 里面调用了 irq_desc 里每个 hander，这些 hander 是我们在所有 action 列表中注册的，这才是我们设置的那个中断处理函数。如果返回值是 IRQ_HANDLED，就说明处理完毕；如果返回值是 IRQ_WAKE_THREAD 就唤醒线程。

至此，中断的整个过程就结束了。

总结时刻

这一节，我们讲了中断的整个处理过程。中断是从外部设备发起的，会形成外部中断。外部中断会到达中断控制器，中断控制器会发送中断向量 Interrupt Vector 给 CPU。

对于每一个 CPU，都要求有一个 idt_table，里面存放了不同的中断向量的处理函数。中断向量表中已经填好了前 32 位，外加一位 32 位系统调用，其他的都是用于设备中断。

硬件中断的处理函数是 do_IRQ 进行统一处理，在这里会让中断向量，通过 vector_irq 映射为 irq_desc。

irq_desc 是一个用于描述用户注册的中断处理函数的结构，为了能够根据中断向量得到 irq_desc 结构，会把这些结构放在一个基数树里面，方便查找。

irq_desc 里面有一个成员是 irqaction，指向设备驱动程序里面注册的中断处理函数。

课堂练习

你知道如何查看每个 CPU 都收到了哪些中断吗？

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

34 | 块设备（上）：如何建立代理商销售模式？

上一章，我们解析了文件系统，最后讲文件系统读写的流程到达底层的时候，没有更深入地分析下去，这是因为文件系统再往下就是硬盘设备了。上两节，我们解析了字符设备的 mknod、打开和读写流程。那这一节我们就来讲块设备的 mknod、打开流程，以及文件系统和下层的硬盘设备的读写流程。

块设备一般会被格式化为文件系统，但是，下面的讲述中，你可能会有一点困惑。你会看到各种各样的 dentry 和 inode。块设备涉及三种文件系统，所以你看到的这些 dentry 和 inode 可能都不是一回事儿，请注意分辨。

块设备需要 mknod 吗？对于启动盘，你可能觉得，启动了就在那里了。可是如果我们要插进一块新的 USB 盘，还是要有这个操作的。

mknod 还是会创建在 /dev 路径下面，这一点和字符设备一样。/dev 路径下面是 devtmpfs 文件系统。这是块设备遇到的第一个文件系统。我们会为这个块设备文件，分配一个特殊的 inode，这一点和字符设备也是一样的。只不过字符设备走 S_ISCHR 这个分支，对应 inode 的 file_operations 是 def_chr_fops；而块设备走 S_ISBLK 这个分支，对应的 inode 的 file_operations 是 def_blk_fops。这里要注意，inode 里面的 i_rdev 被设置成了块设备的设备号 dev_t，这个我们后面会用到，你先记住有这么一回事儿。

void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)

{

	inode->i_mode = mode;

	if (S_ISCHR(mode)) {

		inode->i_fop = &def_chr_fops;

		inode->i_rdev = rdev;

	} else if (S_ISBLK(mode)) {

		inode->i_fop = &def_blk_fops;

		inode->i_rdev = rdev;

	} else if (S_ISFIFO(mode))

		inode->i_fop = &pipefifo_fops;

	else if (S_ISSOCK(mode))

		;	/* leave it no_open_fops */

}

特殊 inode 的默认 file_operations 是 def_blk_fops，就像字符设备一样，有打开、读写这个块设备文件，但是我们常规操作不会这样做。我们会将这个块设备文件 mount 到一个文件夹下面。

const struct file_operations def_blk_fops = {

        .open           = blkdev_open,

        .release        = blkdev_close,

        .llseek         = block_llseek,

        .read_iter      = blkdev_read_iter,

        .write_iter     = blkdev_write_iter,

        .mmap           = generic_file_mmap,

        .fsync          = blkdev_fsync,

        .unlocked_ioctl = block_ioctl,

        .splice_read    = generic_file_splice_read,

        .splice_write   = iter_file_splice_write,

        .fallocate      = blkdev_fallocate,

};

不过，这里我们还是简单看一下，打开这个块设备的操作 blkdev_open。它里面调用的是 blkdev_get 打开这个块设备，了解到这一点就可以了。

接下来，我们要调用 mount，将这个块设备文件挂载到一个文件夹下面。如果这个块设备原来被格式化为一种文件系统的格式，例如 ext4，那我们调用的就是 ext4 相应的 mount 操作。这是块设备遇到的第二个文件系统，也是向这个块设备读写文件，需要基于的主流文件系统。咱们在文件系统那一节解析的对于文件的读写流程，都是基于这个文件系统的。

还记得，咱们注册 ext4 文件系统的时候，有下面这样的结构：

static struct file_system_type ext4_fs_type = {

	.owner		= THIS_MODULE,

	.name		= "ext4",

	.mount		= ext4_mount,

	.kill_sb	= kill_block_super,

	.fs_flags	= FS_REQUIRES_DEV,

};

在将一个硬盘的块设备 mount 成为 ext4 的时候，我们会调用 ext4_mount->mount_bdev。

static struct dentry *ext4_mount(struct file_system_type *fs_type, int flags, const char *dev_name, void *data)

{

	return mount_bdev(fs_type, flags, dev_name, data, ext4_fill_super);

}

struct dentry *mount_bdev(struct file_system_type *fs_type,

	int flags, const char *dev_name, void *data,

	int (*fill_super)(struct super_block *, void *, int))

{

	struct block_device *bdev;

	struct super_block *s;

	fmode_t mode = FMODE_READ | FMODE_EXCL;

	int error = 0;

	if (!(flags & MS_RDONLY))

		mode |= FMODE_WRITE;

	bdev = blkdev_get_by_path(dev_name, mode, fs_type);

......

	s = sget(fs_type, test_bdev_super, set_bdev_super, flags | MS_NOSEC, bdev);

......

	return dget(s->s_root);

......

}

mount_bdev 主要做了两件大事情。第一，blkdev_get_by_path 根据 /dev/xxx 这个名字，找到相应的设备并打开它；第二，sget 根据打开的设备文件，填充 ext4 文件系统的 super_block，从而以此为基础，建立一整套咱们在文件系统那一章讲的体系。

一旦这套体系建立起来以后，对于文件的读写都是通过 ext4 文件系统这个体系进行的，创建的 inode 结构也是指向 ext4 文件系统的。文件系统那一章我们只解析了这部分，由于没有到达底层，也就没有关注块设备相关的操作。这一章我们重新回过头来，一方面看 mount 的时候，对于块设备都做了哪些操作，另一方面看读写的时候，到了底层，对于块设备做了哪些操作。

这里我们先来看 mount_bdev 做的第一件大事情，通过 blkdev_get_by_path，根据设备名 /dev/xxx，得到 struct block_device *bdev。

/**

 * blkdev_get_by_path - open a block device by name

 * @path: path to the block device to open

 * @mode: FMODE_* mask

 * @holder: exclusive holder identifier

 *

 * Open the blockdevice described by the device file at @path.  @mode

 * and @holder are identical to blkdev_get().

 *

 * On success, the returned block_device has reference count of one.

 */

struct block_device *blkdev_get_by_path(const char *path, fmode_t mode,

					void *holder)

{

	struct block_device *bdev;

	int err;

	bdev = lookup_bdev(path);

......

	err = blkdev_get(bdev, mode, holder);

......

	return bdev;

}

blkdev_get_by_path 干了两件事情。第一个，lookup_bdev 根据设备路径 /dev/xxx 得到 block_device。第二个，打开这个设备，调用 blkdev_get。

咱们上面分析过 def_blk_fops 的默认打开设备函数 blkdev_open，它也是调用 blkdev_get 的。块设备的打开往往不是直接调用设备文件的打开函数，而是调用 mount 来打开的。

/**

 * lookup_bdev  - lookup a struct block_device by name

 * @pathname:	special file representing the block device

 *

 * Get a reference to the blockdevice at @pathname in the current

 * namespace if possible and return it.  Return ERR_PTR(error)

 * otherwise.

 */

struct block_device *lookup_bdev(const char *pathname)

{

	struct block_device *bdev;

	struct inode *inode;

	struct path path;

	int error;

	if (!pathname || !*pathname)

		return ERR_PTR(-EINVAL);

	error = kern_path(pathname, LOOKUP_FOLLOW, &path);

	if (error)

		return ERR_PTR(error);

	inode = d_backing_inode(path.dentry);

......

	bdev = bd_acquire(inode);

......

	goto out;

}

lookup_bdev 这里的 pathname 是设备的文件名，例如 /dev/xxx。这个文件是在 devtmpfs 文件系统中的，kern_path 可以在这个文件系统里面，一直找到它对应的 dentry。接下来，d_backing_inode 会获得 inode。这个 inode 就是那个 init_special_inode 生成的特殊 inode。

接下来，bd_acquire 通过这个特殊的 inode，找到 struct block_device。

static struct block_device *bd_acquire(struct inode *inode)

{

	struct block_device *bdev;

......

	bdev = bdget(inode->i_rdev);

	if (bdev) {

		spin_lock(&bdev_lock);

		if (!inode->i_bdev) {

			/*

			 * We take an additional reference to bd_inode,

			 * and it's released in clear_inode() of inode.

			 * So, we can access it via ->i_mapping always

			 * without igrab().

			 */

			bdgrab(bdev);

			inode->i_bdev = bdev;

			inode->i_mapping = bdev->bd_inode->i_mapping;

		}

	}

	return bdev;

}

bd_acquire 中最主要的就是调用 bdget，它的参数是特殊 inode 的 i_rdev。这里面在 mknod 的时候，放的是设备号 dev_t。

struct block_device *bdget(dev_t dev)

{

        struct block_device *bdev;

        struct inode *inode;

        inode = iget5_locked(blockdev_superblock, hash(dev),

                        bdev_test, bdev_set, &dev);

        bdev = &BDEV_I(inode)->bdev;

        if (inode->i_state & I_NEW) {

                bdev->bd_contains = NULL;

                bdev->bd_super = NULL;

                bdev->bd_inode = inode;

                bdev->bd_block_size = i_blocksize(inode);

                bdev->bd_part_count = 0;

                bdev->bd_invalidated = 0;

                inode->i_mode = S_IFBLK;

                inode->i_rdev = dev;

                inode->i_bdev = bdev;

                inode->i_data.a_ops = &def_blk_aops;

                mapping_set_gfp_mask(&inode->i_data, GFP_USER);

                spin_lock(&bdev_lock);

                list_add(&bdev->bd_list, &all_bdevs);

                spin_unlock(&bdev_lock);

                unlock_new_inode(inode);

        }

        return bdev;

}

在 bdget 中，我们遇到了第三个文件系统，bdev 伪文件系统。bdget 函数根据传进来的 dev_t，在 blockdev_superblock 这个文件系统里面找到 inode。这里注意，这个 inode 已经不是 devtmpfs 文件系统的 inode 了。blockdev_superblock 的初始化在整个系统初始化的时候，会调用 bdev_cache_init 进行初始化。它的定义如下：

struct super_block *blockdev_superblock __read_mostly;

static struct file_system_type bd_type = {

        .name           = "bdev",

        .mount          = bd_mount,

        .kill_sb        = kill_anon_super,

};

void __init bdev_cache_init(void)

{

        int err;

        static struct vfsmount *bd_mnt;

        bdev_cachep = kmem_cache_create("bdev_cache", sizeof(struct bdev_inode), 0, (SLAB_HWCACHE_ALIGN|SLAB_RECLAIM_ACCOUNT|SLAB_MEM_SPREAD|SLAB_ACCOUNT|SLAB_PANIC), init_once);

        err = register_filesystem(&bd_type);

        if (err)

                panic("Cannot register bdev pseudo-fs");

        bd_mnt = kern_mount(&bd_type);

        if (IS_ERR(bd_mnt))

                panic("Cannot create bdev pseudo-fs");

        blockdev_superblock = bd_mnt->mnt_sb;   /* For writeback */

}

所有表示块设备的 inode 都保存在伪文件系统 bdev 中，这些对用户层不可见，主要为了方便块设备的管理。Linux 将块设备的 block_device 和 bdev 文件系统的块设备的 inode，通过 struct bdev_inode 进行关联。所以，在 bdget 中，BDEV_I 就是通过 bdev 文件系统的 inode，获得整个 struct bdev_inode 结构的地址，然后取成员 bdev，得到 block_device。

struct bdev_inode {

	struct block_device bdev;

	struct inode vfs_inode;

};

绕了一大圈，我们终于通过设备文件 /dev/xxx，获得了设备的结构 block_device。有点儿绕，我们再捋一下。设备文件 /dev/xxx 在 devtmpfs 文件系统中，找到 devtmpfs 文件系统中的 inode，里面有 dev_t。我们可以通过 dev_t，在伪文件系统 bdev 中找到对应的 inode，然后根据 struct bdev_inode 找到关联的 block_device。

接下来，blkdev_get_by_path 开始做第二件事情，在找到 block_device 之后，要调用 blkdev_get 打开这个设备。blkdev_get 会调用 __blkdev_get。

在分析打开一个设备之前，我们先来看 block_device 这个结构是什么样的。

struct block_device {

	dev_t			bd_dev;  /* not a kdev_t - it's a search key */

	int			bd_openers;

	struct super_block *	bd_super;

......

	struct block_device *	bd_contains;

	unsigned		bd_block_size;

	struct hd_struct *	bd_part;

	unsigned		bd_part_count;

	int			bd_invalidated;

	struct gendisk *	bd_disk;

	struct request_queue *  bd_queue;

	struct backing_dev_info *bd_bdi;

	struct list_head	bd_list;

......

} ;

你应该能发现，这个结构和其他几个结构有着千丝万缕的联系，比较复杂。这是因为块设备本身就比较复杂。

比方说，我们有一个磁盘 /dev/sda，我们既可以把它整个格式化成一个文件系统，也可以把它分成多个分区 /dev/sda1、 /dev/sda2，然后把每个分区格式化成不同的文件系统。如果我们访问某个分区的设备文件 /dev/sda2，我们应该能知道它是哪个磁盘设备的。按说它们的驱动应该是一样的。如果我们访问整个磁盘的设备文件 /dev/sda，我们也应该能知道它分了几个区域，所以就有了下图这个复杂的关系结构。

struct gendisk 是用来描述整个设备的，因而上面的例子中，gendisk 只有一个实例，指向 /dev/sda。它的定义如下：

struct gendisk {

	int major;			/* major number of driver */

	int first_minor;

	int minors;                     /* maximum number of minors, =1 for disks that can't be partitioned. */

	char disk_name[DISK_NAME_LEN];	/* name of major driver */

	char *(*devnode)(struct gendisk *gd, umode_t *mode);

......

	struct disk_part_tbl __rcu *part_tbl;

	struct hd_struct part0;

	const struct block_device_operations *fops;

	struct request_queue *queue;

	void *private_data;

	int flags;

	struct kobject *slave_dir;

......

};

这里 major 是主设备号，first_minor 表示第一个分区的从设备号，minors 表示分区的数目。

disk_name 给出了磁盘块设备的名称。

struct disk_part_tbl 结构里是一个 struct hd_struct 的数组，用于表示各个分区。struct block_device_operations fops 指向对于这个块设备的各种操作。struct request_queue queue 是表示在这个块设备上的请求队列。

struct hd_struct 是用来表示某个分区的，在上面的例子中，有两个 hd_struct 的实例，分别指向 /dev/sda1、 /dev/sda2。它的定义如下：

struct hd_struct {

	sector_t start_sect;

	sector_t nr_sects;

......

	struct device __dev;

	struct kobject *holder_dir;

	int policy, partno;

	struct partition_meta_info *info;

......

	struct disk_stats dkstats;

	struct percpu_ref ref;

	struct rcu_head rcu_head;

};

在 hd_struct 中，比较重要的成员变量保存了如下的信息：从磁盘的哪个扇区开始，到哪个扇区结束。

而 block_device 既可以表示整个块设备，也可以表示某个分区，所以对于上面的例子，block_device 有三个实例，分别指向 /dev/sda1、/dev/sda2、/dev/sda。

block_device 的成员变量 bd_disk，指向的 gendisk 就是整个块设备。这三个实例都指向同一个 gendisk。bd_part 指向的某个分区的 hd_struct，bd_contains 指向的是整个块设备的 block_device。

了解了这些复杂的关系，我们再来看打开设备文件的代码，就会清晰很多。

static int __blkdev_get(struct block_device *bdev, fmode_t mode, int for_part)

{

	struct gendisk *disk;

	struct module *owner;

	int ret;

	int partno;

	int perm = 0;

	if (mode & FMODE_READ)

		perm |= MAY_READ;

	if (mode & FMODE_WRITE)

		perm |= MAY_WRITE;

......

	disk = get_gendisk(bdev->bd_dev, &partno);

......

	owner = disk->fops->owner;

......

	if (!bdev->bd_openers) {

		bdev->bd_disk = disk;

		bdev->bd_queue = disk->queue;

		bdev->bd_contains = bdev;

		if (!partno) {

			ret = -ENXIO;

			bdev->bd_part = disk_get_part(disk, partno);

......

			if (disk->fops->open) {

				ret = disk->fops->open(bdev, mode);

......

			}

			if (!ret)

				bd_set_size(bdev,(loff_t)get_capacity(disk)<<9);

			if (bdev->bd_invalidated) {

				if (!ret)

					rescan_partitions(disk, bdev);

......

			}

......

		} else {

			struct block_device *whole;

			whole = bdget_disk(disk, 0);

......

			ret = __blkdev_get(whole, mode, 1);

......

			bdev->bd_contains = whole;

			bdev->bd_part = disk_get_part(disk, partno);

......

			bd_set_size(bdev, (loff_t)bdev->bd_part->nr_sects << 9);

		}

	}

......

	bdev->bd_openers++;

	if (for_part)

		bdev->bd_part_count++;

.....

}

在 __blkdev_get 函数中，我们先调用 get_gendisk，根据 block_device 获取 gendisk。具体代码如下：

/**

 * get_gendisk - get partitioning information for a given device

 * @devt: device to get partitioning information for

 * @partno: returned partition index

 *

 * This function gets the structure containing partitioning

 * information for the given device @devt.

 */

struct gendisk *get_gendisk(dev_t devt, int *partno)

{

	struct gendisk *disk = NULL;

	if (MAJOR(devt) != BLOCK_EXT_MAJOR) {

		struct kobject *kobj;

		kobj = kobj_lookup(bdev_map, devt, partno);

		if (kobj)

			disk = dev_to_disk(kobj_to_dev(kobj));

	} else {

		struct hd_struct *part;

		part = idr_find(&ext_devt_idr, blk_mangle_minor(MINOR(devt)));

		if (part && get_disk(part_to_disk(part))) {

			*partno = part->partno;

			disk = part_to_disk(part);

		}

	}

	return disk;

}

我们可以想象这里面有两种情况。第一种情况是，block_device 是指向整个磁盘设备的。这个时候，我们只需要根据 dev_t，在 bdev_map 中将对应的 gendisk 拿出来就好。

bdev_map 是干什么的呢？前面咱们学习字符设备驱动的时候讲过，任何一个字符设备初始化的时候，都需要调用 __register_chrdev_region，注册这个字符设备。对于块设备也是类似的，每一个块设备驱动初始化的时候，都会调用 add_disk 注册一个 gendisk。

这里需要说明一下，gen 的意思是 general 通用的意思，也就是说，所有的块设备，不仅仅是硬盘 disk，都会用一个 gendisk 来表示，然后通过调用链 add_disk->device_add_disk->blk_register_region，将 dev_t 和一个 gendisk 关联起来，保存在 bdev_map 中。

static struct kobj_map *bdev_map;

static inline void add_disk(struct gendisk *disk)

{

	device_add_disk(NULL, disk);

}

/**

 * device_add_disk - add partitioning information to kernel list

 * @parent: parent device for the disk

 * @disk: per-device partitioning information

 *

 * This function registers the partitioning information in @disk

 * with the kernel.

 */

void device_add_disk(struct device *parent, struct gendisk *disk)

{

......

blk_register_region(disk_devt(disk), disk->minors, NULL,

			    exact_match, exact_lock, disk);

.....

}

/*

 * Register device numbers dev..(dev+range-1)

 * range must be nonzero

 * The hash chain is sorted on range, so that subranges can override.

 */

void blk_register_region(dev_t devt, unsigned long range, struct module *module,

			 struct kobject *(*probe)(dev_t, int *, void *),

			 int (*lock)(dev_t, void *), void *data)

{

	kobj_map(bdev_map, devt, range, module, probe, lock, data);

}

get_gendisk 要处理的第二种情况是，block_device 是指向某个分区的。这个时候我们要先得到 hd_struct，然后通过 hd_struct，找到对应的整个设备的 gendisk，并且把 partno 设置为分区号。

我们再回到 __blkdev_get 函数中，得到 gendisk。接下来我们可以分两种情况。

如果 partno 为 0，也就是说，打开的是整个设备而不是分区，那我们就调用 disk_get_part，获取 gendisk 中的分区数组，然后调用 block_device_operations 里面的 open 函数打开设备。

如果 partno 不为 0，也就是说打开的是分区，那我们就获取整个设备的 block_device，赋值给变量 struct block_device *whole，然后调用递归 __blkdev_get，打开 whole 代表的整个设备，将 bd_contains 设置为变量 whole。

block_device_operations 就是在驱动层了。例如在 drivers/scsi/sd.c 里面，也就是 MODULE_DESCRIPTION(“SCSI disk (sd) driver”) 中，就有这样的定义。

static const struct block_device_operations sd_fops = {

	.owner			= THIS_MODULE,

	.open			= sd_open,

	.release		= sd_release,

	.ioctl			= sd_ioctl,

	.getgeo			= sd_getgeo,

#ifdef CONFIG_COMPAT

	.compat_ioctl		= sd_compat_ioctl,

#endif

	.check_events		= sd_check_events,

	.revalidate_disk	= sd_revalidate_disk,

	.unlock_native_capacity	= sd_unlock_native_capacity,

	.pr_ops			= &sd_pr_ops,

};

/**

 *	sd_open - open a scsi disk device

 *	@bdev: Block device of the scsi disk to open

 *	@mode: FMODE_* mask

 *

 *	Returns 0 if successful. Returns a negated errno value in case

 *	of error.

 **/

static int sd_open(struct block_device *bdev, fmode_t mode)

{

......

}

在驱动层打开了磁盘设备之后，我们可以看到，在这个过程中，block_device 相应的成员变量该填的都填上了，这才完成了 mount_bdev 的第一件大事，通过 blkdev_get_by_path 得到 block_device。

接下来就是第二件大事情，我们要通过 sget，将 block_device 塞进 superblock 里面。注意，调用 sget 的时候，有一个参数是一个函数 set_bdev_super。这里面将 block_device 设置进了 super_block。而 sget 要做的，就是分配一个 super_block，然后调用 set_bdev_super 这个 callback 函数。这里的 super_block 是 ext4 文件系统的 super_block。

sget(fs_type, test_bdev_super, set_bdev_super, flags | MS_NOSEC, bdev);

static int set_bdev_super(struct super_block *s, void *data)

{

	s->s_bdev = data;

	s->s_dev = s->s_bdev->bd_dev;

	s->s_bdi = bdi_get(s->s_bdev->bd_bdi);

	return 0;

}

/**

 *	sget	-	find or create a superblock

 *	@type:	  filesystem type superblock should belong to

 *	@test:	  comparison callback

 *	@set:	  setup callback

 *	@flags:	  mount flags

 *	@data:	  argument to each of them

 */

struct super_block *sget(struct file_system_type *type,

			int (*test)(struct super_block *,void *),

			int (*set)(struct super_block *,void *),

			int flags,

			void *data)

{

......

	return sget_userns(type, test, set, flags, user_ns, data);

}

/**

 *	sget_userns -	find or create a superblock

 *	@type:	filesystem type superblock should belong to

 *	@test:	comparison callback

 *	@set:	setup callback

 *	@flags:	mount flags

 *	@user_ns: User namespace for the super_block

 *	@data:	argument to each of them

 */

struct super_block *sget_userns(struct file_system_type *type,

			int (*test)(struct super_block *,void *),

			int (*set)(struct super_block *,void *),

			int flags, struct user_namespace *user_ns,

			void *data)

{

	struct super_block *s = NULL;

	struct super_block *old;

	int err;

......

	if (!s) {

		s = alloc_super(type, (flags & ~MS_SUBMOUNT), user_ns);

......

	}

	err = set(s, data);

......

	s->s_type = type;

	strlcpy(s->s_id, type->name, sizeof(s->s_id));

	list_add_tail(&s->s_list, &super_blocks);

	hlist_add_head(&s->s_instances, &type->fs_supers);

	spin_unlock(&sb_lock);

	get_filesystem(type);

	register_shrinker(&s->s_shrink);

	return s;

}

好了，到此为止，mount 中一个块设备的过程就结束了。设备打开了，形成了 block_device 结构，并且塞到了 super_block 中。

有了 ext4 文件系统的 super_block 之后，接下来对于文件的读写过程，就和文件系统那一章的过程一摸一样了。只要不涉及真正写入设备的代码，super_block 中的这个 block_device 就没啥用处。这也是为什么文件系统那一章，我们丝毫感觉不到它的存在，但是一旦到了底层，就到了 block_device 起作用的时候了，这个我们下一节仔细分析。

总结时刻

从这一节我们可以看出，块设备比字符设备复杂多了，涉及三个文件系统，工作过程我用一张图总结了一下，下面带你总结一下。

所有的块设备被一个 map 结构管理从 dev_t 到 gendisk 的映射；

所有的 block_device 表示的设备或者分区都在 bdev 文件系统的 inode 列表中；

mknod 创建出来的块设备文件在 devtemfs 文件系统里面，特殊 inode 里面有块设备号；

mount 一个块设备上的文件系统，调用这个文件系统的 mount 接口；

通过按照 /dev/xxx 在文件系统 devtmpfs 文件系统上搜索到特殊 inode，得到块设备号；

根据特殊 inode 里面的 dev_t 在 bdev 文件系统里面找到 inode；

根据 bdev 文件系统上的 inode 找到对应的 block_device，根据 dev_t 在 map 中找到 gendisk，将两者关联起来；

找到 block_device 后打开设备，调用和 block_device 关联的 gendisk 里面的 block_device_operations 打开设备；

创建被 mount 的文件系统的 super_block。

课堂练习

到这里，你是否真的体会到了 Linux 里面“一切皆文件”了呢？那个特殊的 inode 除了能够表示字符设备和块设备，还能表示什么呢？请你看代码分析一下。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

35 | 块设备（下）：如何建立代理商销售模式？

在文件系统那一节，我们讲了文件的写入，到了设备驱动这一层，就没有再往下分析。上一节我们又讲了 mount 一个块设备，将 block_device 信息放到了 ext4 文件系统的 super_block 里面，有了这些基础，是时候把整个写入的故事串起来了。

还记得咱们在文件系统那一节分析写入流程的时候，对于 ext4 文件系统，最后调用的是 ext4_file_write_iter，它将 I/O 的调用分成两种情况：

第一是直接 I/O。最终我们调用的是 generic_file_direct_write，这里调用的是 mapping->a_ops->direct_IO，实际调用的是 ext4_direct_IO，往设备层写入数据。

第二种是缓存 I/O。最终我们会将数据从应用拷贝到内存缓存中，但是这个时候，并不执行真正的 I/O 操作。它们只将整个页或其中部分标记为脏。写操作由一个 timer 触发，那个时候，才调用 wb_workfn 往硬盘写入页面。

接下来的调用链为：wb_workfn->wb_do_writeback->wb_writeback->writeback_sb_inodes->__writeback_single_inode->do_writepages。在 do_writepages 中，我们要调用 mapping->a_ops->writepages，但实际调用的是 ext4_writepages，往设备层写入数据。

这一节，我们就沿着这两种情况分析下去。

直接 I/O 如何访问块设备？

我们先来看第一种情况，直接 I/O 调用到 ext4_direct_IO。

static ssize_t ext4_direct_IO(struct kiocb *iocb, struct iov_iter *iter)

{

	struct file *file = iocb->ki_filp;

	struct inode *inode = file->f_mapping->host;

	size_t count = iov_iter_count(iter);

	loff_t offset = iocb->ki_pos;

	ssize_t ret;

......

	ret = ext4_direct_IO_write(iocb, iter);

......

}

static ssize_t ext4_direct_IO_write(struct kiocb *iocb, struct iov_iter *iter)

{

	struct file *file = iocb->ki_filp;

	struct inode *inode = file->f_mapping->host;

	struct ext4_inode_info *ei = EXT4_I(inode);

	ssize_t ret;

	loff_t offset = iocb->ki_pos;

	size_t count = iov_iter_count(iter);

......

	ret = __blockdev_direct_IO(iocb, inode, inode->i_sb->s_bdev, iter,

				   get_block_func, ext4_end_io_dio, NULL,

				   dio_flags);

……

}

在 ext4_direct_IO_write 调用 __blockdev_direct_IO，有个参数你需要特别注意一下，那就是 inode->i_sb->s_bdev。通过当前文件的 inode，我们可以得到 super_block。这个 super_block 中的 s_bdev，就是咱们上一节填进去的那个 block_device。

__blockdev_direct_IO 会调用 do_blockdev_direct_IO，在这里面我们要准备一个 struct dio 结构和 struct dio_submit 结构，用来描述将要发生的写入请求。

static inline ssize_t

do_blockdev_direct_IO(struct kiocb *iocb, struct inode *inode,

		      struct block_device *bdev, struct iov_iter *iter,

		      get_block_t get_block, dio_iodone_t end_io,

		      dio_submit_t submit_io, int flags)

{

	unsigned i_blkbits = ACCESS_ONCE(inode->i_blkbits);

	unsigned blkbits = i_blkbits;

	unsigned blocksize_mask = (1 << blkbits) - 1;

	ssize_t retval = -EINVAL;

	size_t count = iov_iter_count(iter);

	loff_t offset = iocb->ki_pos;

	loff_t end = offset + count;

	struct dio *dio;

	struct dio_submit sdio = { 0, };

	struct buffer_head map_bh = { 0, };

......

	dio = kmem_cache_alloc(dio_cache, GFP_KERNEL);

	dio->flags = flags;

	dio->i_size = i_size_read(inode);

	dio->inode = inode;

	if (iov_iter_rw(iter) == WRITE) {

		dio->op = REQ_OP_WRITE;

		dio->op_flags = REQ_SYNC | REQ_IDLE;

		if (iocb->ki_flags & IOCB_NOWAIT)

			dio->op_flags |= REQ_NOWAIT;

	} else {

		dio->op = REQ_OP_READ;

	}

	sdio.blkbits = blkbits;

	sdio.blkfactor = i_blkbits - blkbits;

	sdio.block_in_file = offset >> blkbits;

	sdio.get_block = get_block;

	dio->end_io = end_io;

	sdio.submit_io = submit_io;

	sdio.final_block_in_bio = -1;

	sdio.next_block_for_io = -1;

	dio->iocb = iocb;

	dio->refcount = 1;

	sdio.iter = iter;

	sdio.final_block_in_request =

		(offset + iov_iter_count(iter)) >> blkbits;

......

	sdio.pages_in_io += iov_iter_npages(iter, INT_MAX);

	retval = do_direct_IO(dio, &sdio, &map_bh);

.....

}

do_direct_IO 里面有两层循环，第一层循环是依次处理这次要写入的所有块。对于每一块，取出对应的内存中的页 page，在这一块中，有写入的起始地址 from 和终止地址 to，所以，第二层循环就是依次处理 from 到 to 的数据，调用 submit_page_section，提交到块设备层进行写入。

static int do_direct_IO(struct dio *dio, struct dio_submit *sdio,

			struct buffer_head *map_bh)

{

	const unsigned blkbits = sdio->blkbits;

	const unsigned i_blkbits = blkbits + sdio->blkfactor;

	int ret = 0;

	while (sdio->block_in_file < sdio->final_block_in_request) {

		struct page *page;

		size_t from, to;

		page = dio_get_page(dio, sdio);

        from = sdio->head ? 0 : sdio->from;

		to = (sdio->head == sdio->tail - 1) ? sdio->to : PAGE_SIZE;

		sdio->head++;

		while (from < to) {

			unsigned this_chunk_bytes;	/* # of bytes mapped */

			unsigned this_chunk_blocks;	/* # of blocks */

......

            ret = submit_page_section(dio, sdio, page,

						  from,

						  this_chunk_bytes,

						  sdio->next_block_for_io,

						  map_bh);

......

			sdio->next_block_for_io += this_chunk_blocks;

			sdio->block_in_file += this_chunk_blocks;

			from += this_chunk_bytes;

			dio->result += this_chunk_bytes;

			sdio->blocks_available -= this_chunk_blocks;

			if (sdio->block_in_file == sdio->final_block_in_request)

				break;

......

        }

    }

}

submit_page_section 会调用 dio_bio_submit，进而调用 submit_bio 向块设备层提交数据。其中，参数 struct bio 是将数据传给块设备的通用传输对象。定义如下：

/**

 * submit_bio - submit a bio to the block device layer for I/O

 * @bio: The &struct bio which describes the I/O

 */

blk_qc_t submit_bio(struct bio *bio)

{

......

  return generic_make_request(bio);

}

缓存 I/O 如何访问块设备？

我们再来看第二种情况，缓存 I/O 调用到 ext4_writepages。这个函数比较长，我们这里只截取最重要的部分来讲解。

static int ext4_writepages(struct address_space *mapping,

			   struct writeback_control *wbc)

{

......

	struct mpage_da_data mpd;

	struct inode *inode = mapping->host;

	struct ext4_sb_info *sbi = EXT4_SB(mapping->host->i_sb);

......

	mpd.do_map = 0;

	mpd.io_submit.io_end = ext4_init_io_end(inode, GFP_KERNEL);

	ret = mpage_prepare_extent_to_map(&mpd);

	/* Submit prepared bio */

	ext4_io_submit(&mpd.io_submit);

......

}

这里比较重要的一个数据结构是 struct mpage_da_data。这里面有文件的 inode、要写入的页的偏移量，还有一个重要的 struct ext4_io_submit，里面有通用传输对象 bio。

struct mpage_da_data {

	struct inode *inode;

......

	pgoff_t first_page;	/* The first page to write */

	pgoff_t next_page;	/* Current page to examine */

	pgoff_t last_page;	/* Last page to examine */

	struct ext4_map_blocks map;

	struct ext4_io_submit io_submit;	/* IO submission data */

	unsigned int do_map:1;

};

struct ext4_io_submit {

......

	struct bio		*io_bio;

	ext4_io_end_t		*io_end;

	sector_t		io_next_block;

};

在 ext4_writepages 中，mpage_prepare_extent_to_map 用于初始化这个 struct mpage_da_data 结构。接下来的调用链为：mpage_prepare_extent_to_map->mpage_process_page_bufs->mpage_submit_page->ext4_bio_write_page->io_submit_add_bh。

在 io_submit_add_bh 中，此时的 bio 还是空的，因而我们要调用 io_submit_init_bio，初始化 bio。

static int io_submit_init_bio(struct ext4_io_submit *io,

			      struct buffer_head *bh)

{

	struct bio *bio;

	bio = bio_alloc(GFP_NOIO, BIO_MAX_PAGES);

	if (!bio)

		return -ENOMEM;

	wbc_init_bio(io->io_wbc, bio);

	bio->bi_iter.bi_sector = bh->b_blocknr * (bh->b_size >> 9);

	bio->bi_bdev = bh->b_bdev;

	bio->bi_end_io = ext4_end_bio;

	bio->bi_private = ext4_get_io_end(io->io_end);

	io->io_bio = bio;

	io->io_next_block = bh->b_blocknr;

	return 0;

}

我们再回到 ext4_writepages 中。在 bio 初始化完之后，我们要调用 ext4_io_submit，提交 I/O。在这里我们又是调用 submit_bio，向块设备层传输数据。ext4_io_submit 的实现如下：

void ext4_io_submit(struct ext4_io_submit *io)

{

	struct bio *bio = io->io_bio;

	if (bio) {

		int io_op_flags = io->io_wbc->sync_mode == WB_SYNC_ALL ?

				  REQ_SYNC : 0;

		io->io_bio->bi_write_hint = io->io_end->inode->i_write_hint;

		bio_set_op_attrs(io->io_bio, REQ_OP_WRITE, io_op_flags);

		submit_bio(io->io_bio);

	}

	io->io_bio = NULL;

}

如何向块设备层提交请求？

既然无论是直接 I/O，还是缓存 I/O，最后都到了 submit_bio 里面，我们就来重点分析一下它。

submit_bio 会调用 generic_make_request。代码如下：

blk_qc_t generic_make_request(struct bio *bio)

{

	/*

	 * bio_list_on_stack[0] contains bios submitted by the current

	 * make_request_fn.

	 * bio_list_on_stack[1] contains bios that were submitted before

	 * the current make_request_fn, but that haven't been processed

	 * yet.

	 */

	struct bio_list bio_list_on_stack[2];

	blk_qc_t ret = BLK_QC_T_NONE;

......

	if (current->bio_list) {

		bio_list_add(&current->bio_list[0], bio);

		goto out;

	}

	bio_list_init(&bio_list_on_stack[0]);

	current->bio_list = bio_list_on_stack;

	do {

		struct request_queue *q = bdev_get_queue(bio->bi_bdev);

		if (likely(blk_queue_enter(q, bio->bi_opf & REQ_NOWAIT) == 0)) {

			struct bio_list lower, same;

			/* Create a fresh bio_list for all subordinate requests */

			bio_list_on_stack[1] = bio_list_on_stack[0];

			bio_list_init(&bio_list_on_stack[0]);

			ret = q->make_request_fn(q, bio);

			blk_queue_exit(q);

			/* sort new bios into those for a lower level

			 * and those for the same level

			 */

			bio_list_init(&lower);

			bio_list_init(&same);

			while ((bio = bio_list_pop(&bio_list_on_stack[0])) != NULL)

				if (q == bdev_get_queue(bio->bi_bdev))

					bio_list_add(&same, bio);

				else

					bio_list_add(&lower, bio);

			/* now assemble so we handle the lowest level first */

			bio_list_merge(&bio_list_on_stack[0], &lower);

			bio_list_merge(&bio_list_on_stack[0], &same);

			bio_list_merge(&bio_list_on_stack[0], &bio_list_on_stack[1]);

		}

......

		bio = bio_list_pop(&bio_list_on_stack[0]);

	} while (bio);

	current->bio_list = NULL; /* deactivate */

out:

	return ret;

}

这里的逻辑有点复杂，我们先来看大的逻辑。在 do-while 中，我们先是获取一个请求队列 request_queue，然后调用这个队列的 make_request_fn 函数。

块设备队列结构

如果再来看 struct block_device 结构和 struct gendisk 结构，我们会发现，每个块设备都有一个请求队列 struct request_queue，用于处理上层发来的请求。

在每个块设备的驱动程序初始化的时候，会生成一个 request_queue。

struct request_queue {

	/*

	 * Together with queue_head for cacheline sharing

	 */

	struct list_head	queue_head;

	struct request		*last_merge;

	struct elevator_queue	*elevator;

......

	request_fn_proc		*request_fn;

	make_request_fn		*make_request_fn;

......

}

在请求队列 request_queue 上，首先是有一个链表 list_head，保存请求 request。

struct request {

	struct list_head queuelist;

......

	struct request_queue *q;

......

	struct bio *bio;

	struct bio *biotail;

......

}

每个 request 包括一个链表的 struct bio，有指针指向一头一尾。

struct bio {

	struct bio		*bi_next;	/* request queue link */

	struct block_device	*bi_bdev;

	blk_status_t		bi_status;

......

    struct bvec_iter	bi_iter;

	unsigned short		bi_vcnt;	/* how many bio_vec's */

	unsigned short		bi_max_vecs;	/* max bvl_vecs we can hold */

	atomic_t		__bi_cnt;	/* pin count */

	struct bio_vec		*bi_io_vec;	/* the actual vec list */

......

};

struct bio_vec {

	struct page	*bv_page;

	unsigned int	bv_len;

	unsigned int	bv_offset;

}

在 bio 中，bi_next 是链表中的下一项，struct bio_vec 指向一组页面。

在请求队列 request_queue 上，还有两个重要的函数，一个是 make_request_fn 函数，用于生成 request；另一个是 request_fn 函数，用于处理 request。

块设备的初始化

我们还是以 scsi 驱动为例。在初始化设备驱动的时候，我们会调用 scsi_alloc_queue，把 request_fn 设置为 scsi_request_fn。我们还会调用 blk_init_allocated_queue->blk_queue_make_request，把 make_request_fn 设置为 blk_queue_bio。

/**

 * scsi_alloc_sdev - allocate and setup a scsi_Device

 * @starget: which target to allocate a &scsi_device for

 * @lun: which lun

 * @hostdata: usually NULL and set by ->slave_alloc instead

 *

 * Description:

 *     Allocate, initialize for io, and return a pointer to a scsi_Device.

 *     Stores the @shost, @channel, @id, and @lun in the scsi_Device, and

 *     adds scsi_Device to the appropriate list.

 *

 * Return value:

 *     scsi_Device pointer, or NULL on failure.

 **/

static struct scsi_device *scsi_alloc_sdev(struct scsi_target *starget,

					   u64 lun, void *hostdata)

{

	struct scsi_device *sdev;

	sdev = kzalloc(sizeof(*sdev) + shost->transportt->device_size,

		       GFP_ATOMIC);

......

	sdev->request_queue = scsi_alloc_queue(sdev);

......

}

struct request_queue *scsi_alloc_queue(struct scsi_device *sdev)

{

	struct Scsi_Host *shost = sdev->host;

	struct request_queue *q;

	q = blk_alloc_queue_node(GFP_KERNEL, NUMA_NO_NODE);

	if (!q)

		return NULL;

	q->cmd_size = sizeof(struct scsi_cmnd) + shost->hostt->cmd_size;

	q->rq_alloc_data = shost;

	q->request_fn = scsi_request_fn;

	q->init_rq_fn = scsi_init_rq;

	q->exit_rq_fn = scsi_exit_rq;

	q->initialize_rq_fn = scsi_initialize_rq;

    // 调用 blk_queue_make_request(q, blk_queue_bio);

	if (blk_init_allocated_queue(q) < 0) {

		blk_cleanup_queue(q);

		return NULL;

	}

	__scsi_init_queue(shost, q);

......

	return q

}

在 blk_init_allocated_queue 中，除了初始化 make_request_fn 函数，我们还要做一件很重要的事情，就是初始化 I/O 的电梯算法。

int blk_init_allocated_queue(struct request_queue *q)

{

	q->fq = blk_alloc_flush_queue(q, NUMA_NO_NODE, q->cmd_size);

......

	blk_queue_make_request(q, blk_queue_bio);

......

	/* init elevator */

	if (elevator_init(q, NULL)) {

......

	}

......

}

电梯算法有很多种类型，定义为 elevator_type。下面我来逐一说一下。

struct elevator_type elevator_noop

Noop 调度算法是最简单的 IO 调度算法，它将 IO 请求放入到一个 FIFO 队列中，然后逐个执行这些 IO 请求。

struct elevator_type iosched_deadline

Deadline 算法要保证每个 IO 请求在一定的时间内一定要被服务到，以此来避免某个请求饥饿。为了完成这个目标，算法中引入了两类队列，一类队列用来对请求按起始扇区序号进行排序，通过红黑树来组织，我们称为 sort_list，按照此队列传输性能会比较高；另一类队列对请求按它们的生成时间进行排序，由链表来组织，称为 fifo_list，并且每一个请求都有一个期限值。

struct elevator_type iosched_cfq

又看到了熟悉的 CFQ 完全公平调度算法。所有的请求会在多个队列中排序。同一个进程的请求，总是在同一队列中处理。时间片会分配到每个队列，通过轮询算法，我们保证了 I/O 带宽，以公平的方式，在不同队列之间进行共享。

elevator_init 中会根据名称来指定电梯算法，如果没有选择，那就默认使用 iosched_cfq。

请求提交与调度

接下来，我们回到 generic_make_request 函数中。调用队列的 make_request_fn 函数，其实就是调用 blk_queue_bio。

static blk_qc_t blk_queue_bio(struct request_queue *q, struct bio *bio)

{

	struct request *req, *free;

	unsigned int request_count = 0;

......

	switch (elv_merge(q, &req, bio)) {

	case ELEVATOR_BACK_MERGE:

		if (!bio_attempt_back_merge(q, req, bio))

			break;

		elv_bio_merged(q, req, bio);

		free = attempt_back_merge(q, req);

		if (free)

			__blk_put_request(q, free);

		else

			elv_merged_request(q, req, ELEVATOR_BACK_MERGE);

		goto out_unlock;

	case ELEVATOR_FRONT_MERGE:

		if (!bio_attempt_front_merge(q, req, bio))

			break;

		elv_bio_merged(q, req, bio);

		free = attempt_front_merge(q, req);

		if (free)

			__blk_put_request(q, free);

		else

			elv_merged_request(q, req, ELEVATOR_FRONT_MERGE);

		goto out_unlock;

	default:

		break;

	}

get_rq:

	req = get_request(q, bio->bi_opf, bio, GFP_NOIO);

......

	blk_init_request_from_bio(req, bio);

......

	add_acct_request(q, req, where);

	__blk_run_queue(q);

out_unlock:

......

	return BLK_QC_T_NONE;

}

blk_queue_bio 首先做的一件事情是调用 elv_merge 来判断，当前这个 bio 请求是否能够和目前已有的 request 合并起来，成为同一批 I/O 操作，从而提高读取和写入的性能。

判断标准和 struct bio 的成员 struct bvec_iter 有关，它里面有两个变量，一个是起始磁盘簇 bi_sector，另一个是大小 bi_size。

enum elv_merge elv_merge(struct request_queue *q, struct request **req,

		struct bio *bio)

{

	struct elevator_queue *e = q->elevator;

	struct request *__rq;

......

	if (q->last_merge && elv_bio_merge_ok(q->last_merge, bio)) {

		enum elv_merge ret = blk_try_merge(q->last_merge, bio);

		if (ret != ELEVATOR_NO_MERGE) {

			*req = q->last_merge;

			return ret;

		}

	}

......

	__rq = elv_rqhash_find(q, bio->bi_iter.bi_sector);

	if (__rq && elv_bio_merge_ok(__rq, bio)) {

		*req = __rq;

		return ELEVATOR_BACK_MERGE;

	}

	if (e->uses_mq && e->type->ops.mq.request_merge)

		return e->type->ops.mq.request_merge(q, req, bio);

	else if (!e->uses_mq && e->type->ops.sq.elevator_merge_fn)

		return e->type->ops.sq.elevator_merge_fn(q, req, bio);

	return ELEVATOR_NO_MERGE;

}

elv_merge 尝试了三次合并。

第一次，它先判断和上一次合并的 request 能不能再次合并，看看能不能赶上马上要走的这部电梯。在 blk_try_merge 主要做了这样的判断：如果 blk_rq_pos(rq) + blk_rq_sectors(rq) == bio->bi_iter.bi_sector，也就是说这个 request 的起始地址加上它的大小（其实是这个 request 的结束地址），如果和 bio 的起始地址能接得上，那就把 bio 放在 request 的最后，我们称为 ELEVATOR_BACK_MERGE。

如果 blk_rq_pos(rq) - bio_sectors(bio) == bio->bi_iter.bi_sector，也就是说，这个 request 的起始地址减去 bio 的大小等于 bio 的起始地址，这说明 bio 放在 request 的最前面能够接得上，那就把 bio 放在 request 的最前面，我们称为 ELEVATOR_FRONT_MERGE。否则，那就不合并，我们称为 ELEVATOR_NO_MERGE。

enum elv_merge blk_try_merge(struct request *rq, struct bio *bio)

{

......

    if (blk_rq_pos(rq) + blk_rq_sectors(rq) == bio->bi_iter.bi_sector)

		return ELEVATOR_BACK_MERGE;

	else if (blk_rq_pos(rq) - bio_sectors(bio) == bio->bi_iter.bi_sector)

		return ELEVATOR_FRONT_MERGE;

	return ELEVATOR_NO_MERGE;

}

第二次，如果和上一个合并过的 request 无法合并，那我们就调用 elv_rqhash_find。然后按照 bio 的起始地址查找 request，看有没有能够合并的。如果有的话，因为是按照起始地址找的，应该接在人家的后面，所以是 ELEVATOR_BACK_MERGE。

第三次，调用 elevator_merge_fn 试图合并。对于 iosched_cfq，调用的是 cfq_merge。在这里面，cfq_find_rq_fmerge 会调用 elv_rb_find 函数，里面的参数是 bio 的结束地址。我们还是要看，能不能找到可以合并的。如果有的话，因为是按照结束地址找的，应该接在人家前面，所以是 ELEVATOR_FRONT_MERGE。

static enum elv_merge cfq_merge(struct request_queue *q, struct request **req,

		     struct bio *bio)

{

	struct cfq_data *cfqd = q->elevator->elevator_data;

	struct request *__rq;

	__rq = cfq_find_rq_fmerge(cfqd, bio);

	if (__rq && elv_bio_merge_ok(__rq, bio)) {

		*req = __rq;

		return ELEVATOR_FRONT_MERGE;

	}

	return ELEVATOR_NO_MERGE;

}

static struct request *

cfq_find_rq_fmerge(struct cfq_data *cfqd, struct bio *bio)

{

	struct task_struct *tsk = current;

	struct cfq_io_cq *cic;

	struct cfq_queue *cfqq;

	cic = cfq_cic_lookup(cfqd, tsk->io_context);

	if (!cic)

		return NULL;

	cfqq = cic_to_cfqq(cic, op_is_sync(bio->bi_opf));

	if (cfqq)

		return elv_rb_find(&cfqq->sort_list, bio_end_sector(bio));

	return NUL

}

等从 elv_merge 返回 blk_queue_bio 的时候，我们就知道，应该做哪种类型的合并，接着就要进行真的合并。如果没有办法合并，那就调用 get_request，创建一个新的 request，调用 blk_init_request_from_bio，将 bio 放到新的 request 里面，然后调用 add_acct_request，把新的 request 加到 request_queue 队列中。

至此，我们解析完了 generic_make_request 中最重要的两大逻辑：获取一个请求队列 request_queue 和调用这个队列的 make_request_fn 函数。

其实，generic_make_request 其他部分也很令人困惑。感觉里面有特别多的 struct bio_list，倒腾过来，倒腾过去的。这是因为，很多块设备是有层次的。

比如，我们用两块硬盘组成 RAID，两个 RAID 盘组成 LVM，然后我们就可以在 LVM 上创建一个块设备给用户用，我们称接近用户的块设备为高层次的块设备，接近底层的块设备为低层次（lower）的块设备。这样，generic_make_request 把 I/O 请求发送给高层次的块设备的时候，会调用高层块设备的 make_request_fn，高层块设备又要调用 generic_make_request，将请求发送给低层次的块设备。虽然块设备的层次不会太多，但是对于代码 generic_make_request 来讲，这可是递归的调用，一不小心，就会递归过深，无法正常退出，而且内核栈的大小又非常有限，所以要比较小心。

这里你是否理解了 struct bio_list bio_list_on_stack[2] 的名字为什么叫 stack 呢？其实，将栈的操作变成对于队列的操作，队列不在栈里面，会大很多。每次 generic_make_request 被当前任务调用的时候，将 current->bio_list 设置为 bio_list_on_stack，并在 generic_make_request 的一开始就判断 current->bio_list 是否为空。如果不为空，说明已经在 generic_make_request 的调用里面了，就不必调用 make_request_fn 进行递归了，直接把请求加入到 bio_list 里面就可以了，这就实现了递归的及时退出。

如果 current->bio_list 为空，那我们就将 current->bio_list 设置为 bio_list_on_stack 后，进入 do-while 循环，做咱们分析过的 generic_make_request 的两大逻辑。但是，当前的队列调用 make_request_fn 的时候，在 make_request_fn 的具体实现中，会生成新的 bio。调用更底层的块设备，也会生成新的 bio，都会放在 bio_list_on_stack 的队列中，是一个边处理还边创建的过程。

bio_list_on_stack[1] = bio_list_on_stack[0] 这一句在 make_request_fn 之前，将之前队列里面遗留没有处理的保存下来，接着 bio_list_init 将 bio_list_on_stack[0] 设置为空，然后调用 make_request_fn，在 make_request_fn 里面如果有新的 bio 生成，都会加到 bio_list_on_stack[0] 这个队列里面来。

make_request_fn 执行完毕后，可以想象 bio_list_on_stack[0] 可能又多了一些 bio 了，接下来的循环中调用 bio_list_pop 将 bio_list_on_stack[0] 积攒的 bio 拿出来，分别放在两个队列 lower 和 same 中，顾名思义，lower 就是更低层次的块设备的 bio，same 是同层次的块设备的 bio。

接下来我们能将 lower、same 以及 bio_list_on_stack[1] 都取出来，放在 bio_list_on_stack[0] 统一进行处理。当然应该 lower 优先了，因为只有底层的块设备的 I/O 做完了，上层的块设备的 I/O 才能做完。

到这里，generic_make_request 的逻辑才算解析完毕。对于写入的数据来讲，其实仅仅到将 bio 请求放在请求队列上，设备驱动程序还没往设备里面写呢。

请求的处理

设备驱动程序往设备里面写，调用的是请求队列 request_queue 的另外一个函数 request_fn。对于 scsi 设备来讲，调用的是 scsi_request_fn。

static void scsi_request_fn(struct request_queue *q)

	__releases(q->queue_lock)

	__acquires(q->queue_lock)

{

	struct scsi_device *sdev = q->queuedata;

	struct Scsi_Host *shost;

	struct scsi_cmnd *cmd;

	struct request *req;

	/*

	 * To start with, we keep looping until the queue is empty, or until

	 * the host is no longer able to accept any more requests.

	 */

	shost = sdev->host;

	for (;;) {

		int rtn;

		/*

		 * get next queueable request.  We do this early to make sure

		 * that the request is fully prepared even if we cannot

		 * accept it.

		 */

		req = blk_peek_request(q);

......

		/*

		 * Remove the request from the request list.

		 */

		if (!(blk_queue_tagged(q) && !blk_queue_start_tag(q, req)))

			blk_start_request(req);

.....

		cmd = req->special;

......

		/*

		 * Dispatch the command to the low-level driver.

		 */

		cmd->scsi_done = scsi_done;

		rtn = scsi_dispatch_cmd(cmd);

......

	}

	return;

......

}

在这里面是一个 for 无限循环，从 request_queue 中读取 request，然后封装更加底层的指令，给设备控制器下指令，实施真正的 I/O 操作。

总结时刻

这一节我们讲了如何将块设备 I/O 请求送达到外部设备。

对于块设备的 I/O 操作分为两种，一种是直接 I/O，另一种是缓存 I/O。无论是哪种 I/O，最终都会调用 submit_bio 提交块设备 I/O 请求。

对于每一种块设备，都有一个 gendisk 表示这个设备，它有一个请求队列，这个队列是一系列的 request 对象。每个 request 对象里面包含多个 BIO 对象，指向 page cache。所谓的写入块设备，I/O 就是将 page cache 里面的数据写入硬盘。

对于请求队列来讲，还有两个函数，一个函数叫 make_request_fn 函数，用于将请求放入队列。submit_bio 会调用 generic_make_request，然后调用这个函数。

另一个函数往往在设备驱动程序里实现，我们叫 request_fn 函数，它用于从队列里面取出请求来，写入外部设备。

至此，整个写入文件的过程才完整结束。这真是个复杂的过程，涉及系统调用、内存管理、文件系统和输入输出。这足以说明，操作系统真的是一个非常复杂的体系，环环相扣，需要分层次层层展开来学习。

到这里，专栏已经过半了，你应该能发现，很多我之前说“后面会细讲”的东西，现在正在一点一点解释清楚，而文中越来越多出现“前面我们讲过”的字眼，你是否当时学习前面知识的时候，没有在意，导致学习后面的知识产生困惑了呢？没关系，及时倒回去复习，再回过头去看，当初学过的很多知识会变得清晰很多。

课堂练习

你知道如何查看磁盘调度算法、修改磁盘调度算法以及 I/O 队列的长度吗？

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

36 | 进程间通信：遇到大项目需要项目组之间的合作才行

前面咱们接项目的时候，主要强调项目之间的隔离性。这是因为，我们刚开始接的都是小项目。随着我们接的项目越来越多，就不免遇到大项目，这就需要多个项目组进行合作才能完成。

两个项目组应该通过什么样的方式进行沟通与合作呢？作为老板，你应该如何设计整个流程呢？

管道模型

好在有这么多成熟的项目管理流程可以参考。最最传统的模型就是软件开发的瀑布模型（Waterfall Model）。所谓的瀑布模型，其实就是将整个软件开发过程分成多个阶段，往往是上一个阶段完全做完，才将输出结果交给下一个阶段。就像下面这张图展示的一样。

这种模型类似进程间通信的管道模型。还记得咱们最初学 Linux 命令的时候，有下面这样一行命令：

ps -ef | grep 关键字 | awk '{print $2}' | xargs kill -9

这里面的竖线“|”就是一个管道。它会将前一个命令的输出，作为后一个命令的输入。从管道的这个名称可以看出来，管道是一种单向传输数据的机制，它其实是一段缓存，里面的数据只能从一端写入，从另一端读出。如果想互相通信，我们需要创建两个管道才行。

管道分为两种类型，“|” 表示的管道称为匿名管道，意思就是这个类型的管道没有名字，用完了就销毁了。就像上面那个命令里面的一样，竖线代表的管道随着命令的执行自动创建、自动销毁。用户甚至都不知道自己在用管道这种技术，就已经解决了问题。所以这也是面试题里面经常会问的，到时候千万别说这是竖线，而要回答背后的机制，管道。

另外一种类型是命名管道。这个类型的管道需要通过 mkfifo 命令显式地创建。

mkfifo hello

hello 就是这个管道的名称。管道以文件的形式存在，这也符合 Linux 里面一切皆文件的原则。这个时候，我们 ls 一下，可以看到，这个文件的类型是 p，就是 pipe 的意思。

# ls -l

prw-r--r--  1 root root         0 May 21 23:29 hello

接下来，我们可以往管道里面写入东西。例如，写入一个字符串。

# echo "hello world" > hello

这个时候，管道里面的内容没有被读出，这个命令就是停在这里的，这说明当一个项目组要把它的输出交接给另一个项目组做输入，当没有交接完毕的时候，前一个项目组是不能撒手不管的。

这个时候，我们就需要重新连接一个终端。在终端中，用下面的命令读取管道里面的内容：

# cat < hello

hello world

一方面，我们能够看到，管道里面的内容被读取出来，打印到了终端上；另一方面，echo 那个命令正常退出了，也即交接完毕，前一个项目组就完成了使命，可以解散了。

我们可以看出，瀑布模型的开发流程效率比较低下，因为团队之间无法频繁地沟通。而且，管道的使用模式，也不适合进程间频繁的交换数据。

于是，我们还得想其他的办法，例如我们是不是可以借鉴传统外企的沟通方式——邮件。邮件有一定的格式，例如抬头，正文，附件等，发送邮件可以建立收件人列表，所有在这个列表中的人，都可以反复的在此邮件基础上回复，达到频繁沟通的目的。

消息队列模型

这种模型类似进程间通信的消息队列模型。和管道将信息一股脑儿地从一个进程，倒给另一个进程不同，消息队列有点儿像邮件，发送数据时，会分成一个一个独立的数据单元，也就是消息体，每个消息体都是固定大小的存储块，在字节流上不连续。

这个消息结构的定义我写在下面了。这里面的类型 type 和正文 text 没有强制规定，只要消息的发送方和接收方约定好即可。

struct msg_buffer {

    long mtype;

    char mtext[1024];

};

接下来，我们需要创建一个消息队列，使用msgget 函数。这个函数需要有一个参数 key，这是消息队列的唯一标识，应该是唯一的。如何保持唯一性呢？这个还是和文件关联。

我们可以指定一个文件，ftok 会根据这个文件的 inode，生成一个近乎唯一的 key。只要在这个消息队列的生命周期内，这个文件不要被删除就可以了。只要不删除，无论什么时刻，再调用 ftok，也会得到同样的 key。这种 key 的使用方式在这一章会经常遇到，这是因为它们都属于 System V IPC 进程间通信机制体系中。

#include <stdio.h>

#include <stdlib.h>

#include <sys/msg.h>

int main() {

  int messagequeueid;

  key_t key;

  if((key = ftok("/root/messagequeue/messagequeuekey", 1024)) < 0)

  {

      perror("ftok error");

      exit(1);

  }

  printf("Message Queue key: %d.\n", key);

  if ((messagequeueid = msgget(key, IPC_CREAT|0777)) == -1)

  {

      perror("msgget error");

      exit(1);

  }

  printf("Message queue id: %d.\n", messagequeueid);

}

在运行上面这个程序之前，我们先使用命令 touch messagequeuekey，创建一个文件，然后多次执行的结果就会像下面这样：

# ./a.out

Message Queue key: 92536.

Message queue id: 32768.

System V IPC 体系有一个统一的命令行工具：ipcmk，ipcs 和 ipcrm 用于创建、查看和删除 IPC 对象。

例如，ipcs -q 就能看到上面我们创建的消息队列对象。

# ipcs -q

------ Message Queues --------

key        msqid      owner      perms      used-bytes   messages

0x00016978 32768      root       777        0            0

接下来，我们来看如何发送信息。发送消息主要调用msgsnd 函数。第一个参数是 message queue 的 id，第二个参数是消息的结构体，第三个参数是消息的长度，最后一个参数是 flag。这里 IPC_NOWAIT 表示发送的时候不阻塞，直接返回。

下面的这段程序，getopt_long、do-while 循环以及 switch，是用来解析命令行参数的。命令行参数的格式定义在 long_options 里面。每一项的第一个成员“id”“type““message”是参数选项的全称，第二个成员都为 1，表示参数选项后面要跟参数，最后一个成员’i’‘t’'m’是参数选项的简称。

#include <stdio.h>

#include <stdlib.h>

#include <sys/msg.h>

#include <getopt.h>

#include <string.h>

struct msg_buffer {

    long mtype;

    char mtext[1024];

};

int main(int argc, char *argv[]) {

  int next_option;

  const char* const short_options = "i:t:m:";

  const struct option long_options[] = {

    { "id", 1, NULL, 'i'},

    { "type", 1, NULL, 't'},

    { "message", 1, NULL, 'm'},

    { NULL, 0, NULL, 0 }

  };

  int messagequeueid = -1;

  struct msg_buffer buffer;

  buffer.mtype = -1;

  int len = -1;

  char * message = NULL;

  do {

    next_option = getopt_long (argc, argv, short_options, long_options, NULL);

    switch (next_option)

    {

      case 'i':

        messagequeueid = atoi(optarg);

        break;

      case 't':

        buffer.mtype = atol(optarg);

        break;

      case 'm':

        message = optarg;

        len = strlen(message) + 1;

        if (len > 1024) {

          perror("message too long.");

          exit(1);

        }

        memcpy(buffer.mtext, message, len);

        break;

      default:

        break;

    }

  }while(next_option != -1);

  if(messagequeueid != -1 && buffer.mtype != -1 && len != -1 && message != NULL){

    if(msgsnd(messagequeueid, &buffer, len, IPC_NOWAIT) == -1){

      perror("fail to send message.");

      exit(1);

    }

  } else {

    perror("arguments error");

  }

  return 0;

}

接下来，我们可以编译并运行这个发送程序。

gcc -o send sendmessage.c

./send -i 32768 -t 123 -m "hello world"

接下来，我们再来看如何收消息。收消息主要调用msgrcv 函数，第一个参数是 message queue 的 id，第二个参数是消息的结构体，第三个参数是可接受的最大长度，第四个参数是消息类型, 最后一个参数是 flag，这里 IPC_NOWAIT 表示接收的时候不阻塞，直接返回。

#include <stdio.h>

#include <stdlib.h>

#include <sys/msg.h>

#include <getopt.h>

#include <string.h>

struct msg_buffer {

    long mtype;

    char mtext[1024];

};

int main(int argc, char *argv[]) {

  int next_option;

  const char* const short_options = "i:t:";

  const struct option long_options[] = {

    { "id", 1, NULL, 'i'},

    { "type", 1, NULL, 't'},

    { NULL, 0, NULL, 0 }

  };

  int messagequeueid = -1;

  struct msg_buffer buffer;

  long type = -1;

  do {

    next_option = getopt_long (argc, argv, short_options, long_options, NULL);

    switch (next_option)

    {

      case 'i':

        messagequeueid = atoi(optarg);

        break;

      case 't':

        type = atol(optarg);

        break;

      default:

        break;

    }

  }while(next_option != -1);

  if(messagequeueid != -1 && type != -1){

    if(msgrcv(messagequeueid, &buffer, 1024, type, IPC_NOWAIT) == -1){

      perror("fail to recv message.");

      exit(1);

    }

    printf("received message type : %d, text: %s.", buffer.mtype, buffer.mtext);

  } else {

    perror("arguments error");

  }

  return 0;

}

接下来，我们可以编译并运行这个发送程序。可以看到，如果有消息，可以正确地读到消息；如果没有，则返回没有消息。

# ./recv -i 32768 -t 123

received message type : 123, text: hello world.

# ./recv -i 32768 -t 123

fail to recv message.: No message of desired type

有了消息这种模型，两个进程之间的通信就像咱们平时发邮件一样，你来一封，我回一封，可以频繁沟通了。

共享内存模型

但是有时候，项目组之间的沟通需要特别紧密，而且要分享一些比较大的数据。如果使用邮件，就发现，一方面邮件的来去不及时；另外一方面，附件大小也有限制，所以，这个时候，我们经常采取的方式就是，把两个项目组在需要合作的期间，拉到一个会议室进行合作开发，这样大家可以直接交流文档呀，架构图呀，直接在白板上画或者直接扔给对方，就可以直接看到。

可以看出来，共享会议室这种模型，类似进程间通信的共享内存模型。前面咱们讲内存管理的时候，知道每个进程都有自己独立的虚拟内存空间，不同的进程的虚拟内存空间映射到不同的物理内存中去。这个进程访问 A 地址和另一个进程访问 A 地址，其实访问的是不同的物理内存地址，对于数据的增删查改互不影响。

但是，咱们是不是可以变通一下，拿出一块虚拟地址空间来，映射到相同的物理内存中。这样这个进程写入的东西，另外一个进程马上就能看到了，都不需要拷贝来拷贝去，传来传去。

共享内存也是 System V IPC 进程间通信机制体系中的，所以从它使用流程可以看到熟悉的面孔。

我们可以创建一个共享内存，调用 shmget。在这个体系中，创建一个 IPC 对象都是 xxxget，这里面第一个参数是 key，和 msgget 里面的 key 一样，都是唯一定位一个共享内存对象，也可以通过关联文件的方式实现唯一性。第二个参数是共享内存的大小。第三个参数如果是 IPC_CREAT，同样表示创建一个新的。

int shmget(key_t key, size_t size, int flag);

创建完毕之后，我们可以通过 ipcs 命令查看这个共享内存。

#ipcs --shmems

------ Shared Memory Segments ------

key        shmid    owner perms    bytes nattch status

0x00000000 19398656 marc  600    1048576 2      dest

接下来，如果一个进程想要访问这一段共享内存，需要将这个内存加载到自己的虚拟地址空间的某个位置，通过 shmat 函数，就是 attach 的意思。其中 addr 就是要指定 attach 到这个地方。但是这个地址的设定难度比较大，除非对于内存布局非常熟悉，否则可能会 attach 到一个非法地址。所以，通常的做法是将 addr 设为 NULL，让内核选一个合适的地址。返回值就是真正被 attach 的地方。

void *shmat(int shm_id, const void *addr, int flag);

如果共享内存使用完毕，可以通过 shmdt 解除绑定，然后通过 shmctl，将 cmd 设置为 IPC_RMID，从而删除这个共享内存对象。

int shmdt(void *addr);

int shmctl(int shm_id, int cmd, struct shmid_ds *buf);

信号量

这里你是不是有一个疑问，如果两个进程 attach 同一个共享内存，大家都往里面写东西，很有可能就冲突了。例如两个进程都同时写一个地址，那先写的那个进程会发现内容被别人覆盖了。

所以，这里就需要一种保护机制，使得同一个共享的资源，同时只能被一个进程访问。在 System V IPC 进程间通信机制体系中，早就想好了应对办法，就是信号量（Semaphore）。因此，信号量和共享内存往往要配合使用。

信号量其实是一个计数器，主要用于实现进程间的互斥与同步，而不是用于存储进程间通信数据。

我们可以将信号量初始化为一个数值，来代表某种资源的总体数量。对于信号量来讲，会定义两种原子操作，一个是P 操作，我们称为申请资源操作。这个操作会申请将信号量的数值减去 N，表示这些数量被他申请使用了，其他人不能用了。另一个是V 操作，我们称为归还资源操作，这个操作会申请将信号量加上 M，表示这些数量已经还给信号量了，其他人可以使用了。

例如，你有 100 元钱，就可以将信号量设置为 100。其中 A 向你借 80 元，就会调用 P 操作，申请减去 80。如果同时 B 向你借 50 元，但是 B 的 P 操作比 A 晚，那就没有办法，只好等待 A 归还钱的时候，B 的 P 操作才能成功。之后，A 调用 V 操作，申请加上 30 元，也就是还给你 30 元，这个时候信号量有 50 元了，这时候 B 的 P 操作才能成功，才能借走这 50 元。

所谓原子操作（Atomic Operation），就是任何一块钱，都只能通过 P 操作借给一个人，不能同时借给两个人。也就是说，当 A 的 P 操作（借 80）和 B 的 P 操作（借 50），几乎同时到达的时候，不能因为大家都看到账户里有 100 就都成功，必须分个先来后到。

如果想创建一个信号量，我们可以通过 semget 函数。看，又是 xxxget，第一个参数 key 也是类似的，第二个参数 num_sems 不是指资源的数量，而是表示可以创建多少个信号量，形成一组信号量，也就是说，如果你有多种资源需要管理，可以创建一个信号量组。

 int semget(key_t key, int num_sems, int sem_flags);

接下来，我们需要初始化信号量的总的资源数量。通过 semctl 函数，第一个参数 semid 是这个信号量组的 id，第二个参数 semnum 才是在这个信号量组中某个信号量的 id，第三个参数是命令，如果是初始化，则用 SETVAL，第四个参数是一个 union。如果初始化，应该用里面的 val 设置资源总量。

int semctl(int semid, int semnum, int cmd, union semun args);

union semun

{

  int val;

  struct semid_ds *buf;

  unsigned short int *array;

  struct seminfo *__buf;

};

无论是 P 操作还是 V 操作，我们统一用 semop 函数。第一个参数还是信号量组的 id，一次可以操作多个信号量。第三个参数 numops 就是有多少个操作，第二个参数将这些操作放在一个数组中。

数组的每一项是一个 struct sembuf，里面的第一个成员是这个操作的对象是哪个信号量。

第二个成员就是要对这个信号量做多少改变。如果 sem_op < 0，就请求 sem_op 的绝对值的资源。如果相应的资源数可以满足请求，则将该信号量的值减去 sem_op 的绝对值，函数成功返回。

当相应的资源数不能满足请求时，就要看 sem_flg 了。如果把 sem_flg 设置为 IPC_NOWAIT，也就是没有资源也不等待，则 semop 函数出错返回 EAGAIN。如果 sem_flg 没有指定 IPC_NOWAIT，则进程挂起，直到当相应的资源数可以满足请求。若 sem_op > 0，表示进程归还相应的资源数，将 sem_op 的值加到信号量的值上。如果有进程正在休眠等待此信号量，则唤醒它们。

int semop(int semid, struct sembuf semoparray[], size_t numops);

struct sembuf

{

  short sem_num; // 信号量组中对应的序号，0～sem_nums-1

  short sem_op;  // 信号量值在一次操作中的改变量

  short sem_flg; // IPC_NOWAIT, SEM_UNDO

}

信号量和共享内存都比较复杂，两者还要结合起来用，就更加复杂，它们内核的机制就更加复杂。这一节我们先不讲，放到本章的最后一节重点讲解。

信号

上面讲的进程间通信的方式，都是常规状态下的工作模式，对应到咱们平时的工作交接，收发邮件、联合开发等，其实还有一种异常情况下的工作模式。

例如出现线上系统故障，这个时候，什么流程都来不及了，不可能发邮件，也来不及开会，所有的架构师、开发、运维都要被通知紧急出动。所以，7 乘 24 小时不间断执行的系统都需要有告警系统，一旦出事情，就要通知到人，哪怕是半夜，也要电话叫起来，处理故障。

对应到操作系统中，就是信号。信号没有特别复杂的数据结构，就是用一个代号一样的数字。Linux 提供了几十种信号，分别代表不同的意义。信号之间依靠它们的值来区分。这就像咱们看警匪片，对于紧急的行动，都是说，“1 号作战任务”开始执行，警察就开始行动了。情况紧急，不能啰里啰嗦了。

信号可以在任何时候发送给某一进程，进程需要为这个信号配置信号处理函数。当某个信号发生的时候，就默认执行这个函数就可以了。这就相当于咱们运维一个系统应急手册，当遇到什么情况，做什么事情，都事先准备好，出了事情照着做就可以了。

总结时刻

这一节，我们整体讲解了一下进程间通信的各种模式。你现在还能记住多少？

类似瀑布开发模式的管道

类似邮件模式的消息队列

类似会议室联合开发的共享内存加信号量

类似应急预案的信号

当你自己使用的时候，可以根据不同的通信需要，选择不同的模式。

管道，请你记住这是命令行中常用的模式，面试问到的话，不要忘了。

消息队列其实很少使用，因为有太多的用户级别的消息队列，功能更强大。

共享内存加信号量是常用的模式。这个需要牢记，常见到一些知名的以 C 语言开发的开源软件都会用到它。

信号更加常用，机制也比较复杂。我们后面会有单独的一节来解析。

课堂练习

这节课的程序，请你务必自己编译通过，搞清楚参数解析是怎么做的，这个以后你自己写程序的时候，很有用，另外消息队列模型的 API 调用流程，也要搞清楚，要知道他们都属于 System V 系列，后面我们学共享内存和信号量，能看到完全类似的 API 调用流程。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

37 | 信号（上）：项目组A完成了，如何及时通知项目组B？

上一节最后，我们讲了信号的机制。在某些紧急情况下，我们需要给进程发送一个信号，紧急处理一些事情。

这种方式有点儿像咱们运维一个线上系统，为了应对一些突发事件，往往需要制定应急预案。就像下面的列表中一样。一旦发生了突发事件，马上能够找到负责人，根据处理步骤进行紧急响应，并且在限定的事件内搞定。

我们现在就按照应急预案的设计思路，来看一看 Linux 信号系统的机制。

首先，第一件要做的事情就是，整个团队要想一下，线上到底能够产生哪些异常情况，越全越好。于是，我们就有了上面这个很长很长的列表。

在 Linux 操作系统中，为了响应各种各样的事件，也是定义了非常多的信号。我们可以通过 kill -l 命令，查看所有的信号。

# kill -l

 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP

 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1

11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM

16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP

21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ

26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR

31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3

38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8

43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13

48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12

53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7

58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2

63) SIGRTMAX-1  64) SIGRTMAX

这些信号都是什么作用呢？我们可以通过 man 7 signal 命令查看，里面会有一个列表。

Signal     Value     Action   Comment

──────────────────────────────────────────────────────────────────────

SIGHUP        1       Term    Hangup detected on controlling terminal

                              or death of controlling process

SIGINT        2       Term    Interrupt from keyboard

SIGQUIT       3       Core    Quit from keyboard

SIGILL        4       Core    Illegal Instruction

SIGABRT       6       Core    Abort signal from abort(3)

SIGFPE        8       Core    Floating point exception

SIGKILL       9       Term    Kill signal

SIGSEGV      11       Core    Invalid memory reference

SIGPIPE      13       Term    Broken pipe: write to pipe with no

                              readers

SIGALRM      14       Term    Timer signal from alarm(2)

SIGTERM      15       Term    Termination signal

SIGUSR1   30,10,16    Term    User-defined signal 1

SIGUSR2   31,12,17    Term    User-defined signal 2

……

就像应急预案里面给出的一样，每个信号都有一个唯一的 ID，还有遇到这个信号的时候的默认操作。

一旦有信号产生，我们就有下面这几种，用户进程对信号的处理方式。

1.执行默认操作。Linux 对每种信号都规定了默认操作，例如，上面列表中的 Term，就是终止进程的意思。Core 的意思是 Core Dump，也即终止进程后，通过 Core Dump 将当前进程的运行状态保存在文件里面，方便程序员事后进行分析问题在哪里。

2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时，我们就执行相应的信号处理函数。

3.忽略信号。当我们不希望处理某些信号的时候，就可以忽略该信号，不做任何处理。有两个信号是应用进程无法捕捉和忽略的，即 SIGKILL 和 SEGSTOP，它们用于在任何时候中断或结束某一进程。

接下来，我们来看一下信号处理最常见的流程。这个过程主要是分成两步，第一步是注册信号处理函数。第二步是发送信号。这一节我们主要看第一步。

如果我们不想让某个信号执行默认操作，一种方法就是对特定的信号注册相应的信号处理函数，设置信号处理方式的是signal 函数。

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

这其实就是定义一个方法，并且将这个方法和某个信号关联起来。当这个进程遇到这个信号的时候，就执行这个方法。

如果我们在 Linux 下面执行 man signal 的话，会发现 Linux 不建议我们直接用这个方法，而是改用 sigaction。定义如下：

int sigaction(int signum, const struct sigaction *act,

                     struct sigaction *oldact);

这两者的区别在哪里呢？其实它还是将信号和一个动作进行关联，只不过这个动作由一个结构 struct sigaction 表示了。

struct sigaction {

	__sighandler_t sa_handler;

	unsigned long sa_flags;

	__sigrestore_t sa_restorer;

	sigset_t sa_mask;		/* mask last for extensibility */

};

和 signal 类似的是，这里面还是有 __sighandler_t。但是，其他成员变量可以让你更加细致地控制信号处理的行为。而 signal 函数没有给你机会设置这些。这里需要注意的是，signal 不是系统调用，而是 glibc 封装的一个函数。这样就像 man signal 里面写的一样，不同的实现方式，设置的参数会不同，会导致行为的不同。

例如，我们在 glibc 里面会看到了这样一个实现：

#  define signal __sysv_signal

__sighandler_t

__sysv_signal (int sig, __sighandler_t handler)

{

  struct sigaction act, oact;

......

  act.sa_handler = handler;

  __sigemptyset (&act.sa_mask);

  act.sa_flags = SA_ONESHOT | SA_NOMASK | SA_INTERRUPT;

  act.sa_flags &= ~SA_RESTART;

  if (__sigaction (sig, &act, &oact) < 0)

    return SIG_ERR;

  return oact.sa_handler;

}

weak_alias (__sysv_signal, sysv_signal)

在这里面，sa_flags 进行了默认的设置。SA_ONESHOT 是什么意思呢？意思就是，这里设置的信号处理函数，仅仅起作用一次。用完了一次后，就设置回默认行为。这其实并不是我们想看到的。毕竟我们一旦安装了一个信号处理函数，肯定希望它一直起作用，直到我显式地关闭它。

另外一个设置就是SA_NOMASK。我们通过 __sigemptyset，将 sa_mask 设置为空。这样的设置表示在这个信号处理函数执行过程中，如果再有其他信号，哪怕相同的信号到来的时候，这个信号处理函数会被中断。如果一个信号处理函数真的被其他信号中断，其实问题也不大，因为当处理完了其他的信号处理函数后，还会回来接着处理这个信号处理函数的，但是对于相同的信号就有点尴尬了，这就需要这个信号处理函数写的比较有技巧了。

例如，对于这个信号的处理过程中，要操作某个数据结构，因为是相同的信号，很可能操作的是同一个实例，这样的话，同步、死锁这些都要想好。其实一般的思路应该是，当某一个信号的信号处理函数运行的时候，我们暂时屏蔽这个信号。后面我们还会仔细分析屏蔽这个动作，屏蔽并不意味着信号一定丢失，而是暂存，这样能够做到信号处理函数对于相同的信号，处理完一个再处理下一个，这样信号处理函数的逻辑要简单得多。

还有一个设置就是设置了SA_INTERRUPT，清除了 SA_RESTART。这是什么意思呢？我们知道，信号的到来时间是不可预期的，有可能程序正在调用某个漫长的系统调用的时候（你可以在一台 Linux 机器上运行 man 7 signal 命令，在这里找 Interruption of system calls and library functions by signal handlers 的部分，里面说的非常详细），这个时候一个信号来了，会中断这个系统调用，去执行信号处理函数，那执行完了以后呢？系统调用怎么办呢？

这时候有两种处理方法，一种就是 SA_INTERRUPT，也即系统调用被中断了，就不再重试这个系统调用了，而是直接返回一个 -EINTR 常量，告诉调用方，这个系统调用被信号中断了，但是怎么处理你看着办。如果是这样的话，调用方可以根据自己的逻辑，重新调用或者直接返回，这会使得我们的代码非常复杂，在所有系统调用的返回值判断里面，都要特殊判断一下这个值。

另外一种处理方法是 SA_RESTART。这个时候系统调用会被自动重新启动，不需要调用方自己写代码。当然也可能存在问题，例如从终端读入一个字符，这个时候用户在终端输入一个'a'字符，在处理'a'字符的时候被信号中断了，等信号处理完毕，再次读入一个字符的时候，如果用户不再输入，就停在那里了，需要用户再次输入同一个字符。

因而，建议你使用 sigaction 函数，根据自己的需要定制参数。

接下来，我们来看 sigaction 具体做了些什么。

还记得在学习系统调用那一节的时候，我们知道，glibc 里面有个文件 syscalls.list。这里面定义了库函数调用哪些系统调用，在这里我们找到了 sigaction。

sigaction    -       sigaction       i:ipp   __sigaction     sigaction

接下来，在 glibc 中，__sigaction 会调用 __libc_sigaction，并最终调用的系统调用是 rt_sigaction。

int

__sigaction (int sig, const struct sigaction *act, struct sigaction *oact)

{

......

  return __libc_sigaction (sig, act, oact);

}

int

__libc_sigaction (int sig, const struct sigaction *act, struct sigaction *oact)

{

  int result;

  struct kernel_sigaction kact, koact;

  if (act)

    {

      kact.k_sa_handler = act->sa_handler;

      memcpy (&kact.sa_mask, &act->sa_mask, sizeof (sigset_t));

      kact.sa_flags = act->sa_flags | SA_RESTORER;

      kact.sa_restorer = &restore_rt;

    }

  result = INLINE_SYSCALL (rt_sigaction, 4,

                           sig, act ? &kact : NULL,

                           oact ? &koact : NULL, _NSIG / 8);

  if (oact && result >= 0)

    {

      oact->sa_handler = koact.k_sa_handler;

      memcpy (&oact->sa_mask, &koact.sa_mask, sizeof (sigset_t));

      oact->sa_flags = koact.sa_flags;

      oact->sa_restorer = koact.sa_restorer;

    }

  return result;

}

这也是很多人看信号处理的内核实现的时候，比较困惑的地方。例如，内核代码注释里面会说，系统调用 signal 是为了兼容过去，系统调用 sigaction 也是为了兼容过去，连参数都变成了 struct compat_old_sigaction，所以说，我们的库函数虽然调用的是 sigaction，到了系统调用层，调用的可不是系统调用 sigaction，而是系统调用 rt_sigaction。

SYSCALL_DEFINE4(rt_sigaction, int, sig,

		const struct sigaction __user *, act,

		struct sigaction __user *, oact,

		size_t, sigsetsize)

{

	struct k_sigaction new_sa, old_sa;

	int ret = -EINVAL;

......

	if (act) {

		if (copy_from_user(&new_sa.sa, act, sizeof(new_sa.sa)))

			return -EFAULT;

	}

	ret = do_sigaction(sig, act ? &new_sa : NULL, oact ? &old_sa : NULL);

	if (!ret && oact) {

		if (copy_to_user(oact, &old_sa.sa, sizeof(old_sa.sa)))

			return -EFAULT;

	}

out:

	return ret;

}

在 rt_sigaction 里面，我们将用户态的 struct sigaction 结构，拷贝为内核态的 k_sigaction，然后调用 do_sigaction。do_sigaction 也很简单，还记得进程内核的数据结构里，struct task_struct 里面有一个成员 sighand，里面有一个 action。这是一个数组，下标是信号，内容就是信号处理函数，do_sigaction 就是设置 sighand 里的信号处理函数。

int do_sigaction(int sig, struct k_sigaction *act, struct k_sigaction *oact)

{

	struct task_struct *p = current, *t;

	struct k_sigaction *k;

	sigset_t mask;

......

	k = &p->sighand->action[sig-1];

	spin_lock_irq(&p->sighand->siglock);

	if (oact)

		*oact = *k;

	if (act) {

		sigdelsetmask(&act->sa.sa_mask,

			      sigmask(SIGKILL) | sigmask(SIGSTOP));

		*k = *act;

......

	}

	spin_unlock_irq(&p->sighand->siglock);

	return 0;

}

至此，信号处理函数的注册已经完成了。

总结时刻

这一节讲了如何通过 API 注册一个信号处理函数，整个过程如下图所示。

在用户程序里面，有两个函数可以调用，一个是 signal，一个是 sigaction，推荐使用 sigaction。

用户程序调用的是 Glibc 里面的函数，signal 调用的是 __sysv_signal，里面默认设置了一些参数，使得 signal 的功能受到了限制，sigaction 调用的是 __sigaction，参数用户可以任意设定。

无论是 __sysv_signal 还是 __sigaction，调用的都是统一的一个系统调用 rt_sigaction。

在内核中，rt_sigaction 调用的是 do_sigaction 设置信号处理函数。在每一个进程的 task_struct 里面，都有一个 sighand 指向 struct sighand_struct，里面是一个数组，下标是信号，里面的内容是信号处理函数。

课堂练习

你可以试着写一个程序，调用 sigaction 为某个信号设置一个信号处理函数，在信号处理函数中，如果收到信号则打印一些字符串，然后用命令 kill 发送信号，看是否字符串被正常输出。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

38 | 信号（下）：项目组A完成了，如何及时通知项目组B？

信号处理最常见的流程主要是两步，第一步是注册信号处理函数，第二步是发送信号和处理信号。上一节，我们讲了注册信号处理函数，那一般什么情况下会产生信号呢？我们这一节就来看一看。

信号的发送

有时候，我们在终端输入某些组合键的时候，会给进程发送信号，例如，Ctrl+C 产生 SIGINT 信号，Ctrl+Z 产生 SIGTSTP 信号。

有的时候，硬件异常也会产生信号。比如，执行了除以 0 的指令，CPU 就会产生异常，然后把 SIGFPE 信号发送给进程。再如，进程访问了非法内存，内存管理模块就会产生异常，然后把信号 SIGSEGV 发送给进程。

这里同样是硬件产生的，对于中断和信号还是要加以区别。咱们前面讲过，中断要注册中断处理函数，但是中断处理函数是在内核驱动里面的，信号也要注册信号处理函数，信号处理函数是在用户态进程里面的。

对于硬件触发的，无论是中断，还是信号，肯定是先到内核的，然后内核对于中断和信号处理方式不同。一个是完全在内核里面处理完毕，一个是将信号放在对应的进程 task_struct 里信号相关的数据结构里面，然后等待进程在用户态去处理。当然有些严重的信号，内核会把进程干掉。但是，这也能看出来，中断和信号的严重程度不一样，信号影响的往往是某一个进程，处理慢了，甚至错了，也不过这个进程被干掉，而中断影响的是整个系统。一旦中断处理中有了 bug，可能整个 Linux 都挂了。

有时候，内核在某些情况下，也会给进程发送信号。例如，向读端已关闭的管道写数据时产生 SIGPIPE 信号，当子进程退出时，我们要给父进程发送 SIG_CHLD 信号等。

最直接的发送信号的方法就是，通过命令 kill 来发送信号了。例如，我们都知道的 kill -9 pid 可以发送信号给一个进程，杀死它。

另外，我们还可以通过 kill 或者 sigqueue 系统调用，发送信号给某个进程，也可以通过 tkill 或者 tgkill 发送信号给某个线程。虽然方式多种多样，但是最终都是调用了 do_send_sig_info 函数，将信号放在相应的 task_struct 的信号数据结构中。

kill->kill_something_info->kill_pid_info->group_send_sig_info->do_send_sig_info

tkill->do_tkill->do_send_specific->do_send_sig_info

tgkill->do_tkill->do_send_specific->do_send_sig_info

rt_sigqueueinfo->do_rt_sigqueueinfo->kill_proc_info->kill_pid_info->group_send_sig_info->do_send_sig_info

do_send_sig_info 会调用 send_signal，进而调用 __send_signal。

SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)

{

	struct siginfo info;

	info.si_signo = sig;

	info.si_errno = 0;

	info.si_code = SI_USER;

	info.si_pid = task_tgid_vnr(current);

	info.si_uid = from_kuid_munged(current_user_ns(), current_uid());

	return kill_something_info(sig, &info, pid);

}

static int __send_signal(int sig, struct siginfo *info, struct task_struct *t,

			int group, int from_ancestor_ns)

{

	struct sigpending *pending;

	struct sigqueue *q;

	int override_rlimit;

	int ret = 0, result;

......

	pending = group ? &t->signal->shared_pending : &t->pending;

......

	if (legacy_queue(pending, sig))

		goto ret;

	if (sig < SIGRTMIN)

		override_rlimit = (is_si_special(info) || info->si_code >= 0);

	else

		override_rlimit = 0;

	q = __sigqueue_alloc(sig, t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE,

		override_rlimit);

	if (q) {

		list_add_tail(&q->list, &pending->list);

		switch ((unsigned long) info) {

		case (unsigned long) SEND_SIG_NOINFO:

			q->info.si_signo = sig;

			q->info.si_errno = 0;

			q->info.si_code = SI_USER;

			q->info.si_pid = task_tgid_nr_ns(current,

							task_active_pid_ns(t));

			q->info.si_uid = from_kuid_munged(current_user_ns(), current_uid());

			break;

		case (unsigned long) SEND_SIG_PRIV:

			q->info.si_signo = sig;

			q->info.si_errno = 0;

			q->info.si_code = SI_KERNEL;

			q->info.si_pid = 0;

			q->info.si_uid = 0;

			break;

		default:

			copy_siginfo(&q->info, info);

			if (from_ancestor_ns)

				q->info.si_pid = 0;

			break;

		}

		userns_fixup_signal_uid(&q->info, t);

	}

......

out_set:

	signalfd_notify(t, sig);

	sigaddset(&pending->signal, sig);

	complete_signal(sig, t, group);

ret:

	return ret;

}

在这里，我们看到，在学习进程数据结构中 task_struct 里面的 sigpending。在上面的代码里面，我们先是要决定应该用哪个 sigpending。这就要看我们发送的信号，是给进程的还是线程的。如果是 kill 发送的，也就是发送给整个进程的，就应该发送给 t->signal->shared_pending。这里面是整个进程所有线程共享的信号；如果是 tkill 发送的，也就是发给某个线程的，就应该发给 t->pending。这里面是这个线程的 task_struct 独享的。

struct sigpending 里面有两个成员，一个是一个集合 sigset_t，表示都收到了哪些信号，还有一个链表，也表示收到了哪些信号。它的结构如下：

struct sigpending {

	struct list_head list;

	sigset_t signal;

};

如果都表示收到了信号，这两者有什么区别呢？我们接着往下看 __send_signal 里面的代码。接下来，我们要调用 legacy_queue。如果满足条件，那就直接退出。那 legacy_queue 里面判断的是什么条件呢？我们来看它的代码。

static inline int legacy_queue(struct sigpending *signals, int sig)

{

	return (sig < SIGRTMIN) && sigismember(&signals->signal, sig);

}

#define SIGRTMIN	32

#define SIGRTMAX	_NSIG

#define _NSIG		64

当信号小于 SIGRTMIN，也即 32 的时候，如果我们发现这个信号已经在集合里面了，就直接退出了。这样会造成什么现象呢？就是信号的丢失。例如，我们发送给进程 100 个 SIGUSR1（对应的信号为 10），那最终能够被我们的信号处理函数处理的信号有多少呢？这就不好说了，比如总共 5 个 SIGUSR1，分别是 A、B、C、D、E。

如果这五个信号来得太密。A 来了，但是信号处理函数还没来得及处理，B、C、D、E 就都来了。根据上面的逻辑，因为 A 已经将 SIGUSR1 放在 sigset_t 集合中了，因而后面四个都要丢失。 如果是另一种情况，A 来了已经被信号处理函数处理了，内核在调用信号处理函数之前，我们会将集合中的标志位清除，这个时候 B 再来，B 还是会进入集合，还是会被处理，也就不会丢。

这样信号能够处理多少，和信号处理函数什么时候被调用，信号多大频率被发送，都有关系，而且从后面的分析，我们可以知道，信号处理函数的调用时间也是不确定的。看小于 32 的信号如此不靠谱，我们就称它为不可靠信号。

如果大于 32 的信号是什么情况呢？我们接着看。接下来，__sigqueue_alloc 会分配一个 struct sigqueue 对象，然后通过 list_add_tail 挂在 struct sigpending 里面的链表上。这样就靠谱多了是不是？如果发送过来 100 个信号，变成链表上的 100 项，都不会丢，哪怕相同的信号发送多遍，也处理多遍。因此，大于 32 的信号我们称为可靠信号。当然，队列的长度也是有限制的，如果我们执行 ulimit 命令，可以看到，这个限制 pending signals (-i) 15408。

当信号挂到了 task_struct 结构之后，最后我们需要调用 complete_signal。这里面的逻辑也很简单，就是说，既然这个进程有了一个新的信号，赶紧找一个线程处理一下吧。

static void complete_signal(int sig, struct task_struct *p, int group)

{

	struct signal_struct *signal = p->signal;

	struct task_struct *t;

	/*

	 * Now find a thread we can wake up to take the signal off the queue.

	 *

	 * If the main thread wants the signal, it gets first crack.

	 * Probably the least surprising to the average bear.

	 */

	if (wants_signal(sig, p))

		t = p;

	else if (!group || thread_group_empty(p))

		/*

		 * There is just one thread and it does not need to be woken.

		 * It will dequeue unblocked signals before it runs again.

		 */

		return;

	else {

		/*

		 * Otherwise try to find a suitable thread.

		 */

		t = signal->curr_target;

		while (!wants_signal(sig, t)) {

			t = next_thread(t);

			if (t == signal->curr_target)

				return;

		}

		signal->curr_target = t;

	}

......

	/*

	 * The signal is already in the shared-pending queue.

	 * Tell the chosen thread to wake up and dequeue it.

	 */

	signal_wake_up(t, sig == SIGKILL);

	return;

}

在找到了一个进程或者线程的 task_struct 之后，我们要调用 signal_wake_up，来企图唤醒它，signal_wake_up 会调用 signal_wake_up_state。

void signal_wake_up_state(struct task_struct *t, unsigned int state)

{

	set_tsk_thread_flag(t, TIF_SIGPENDING);

	if (!wake_up_state(t, state | TASK_INTERRUPTIBLE))

		kick_process(t);

}

signal_wake_up_state 里面主要做了两件事情。第一，就是给这个线程设置 TIF_SIGPENDING，这就说明其实信号的处理和进程的调度是采取这样一种类似的机制。还记得咱们调度的时候是怎么操作的吗？

当发现一个进程应该被调度的时候，我们并不直接把它赶下来，而是设置一个标识位 TIF_NEED_RESCHED，表示等待调度，然后等待系统调用结束或者中断处理结束，从内核态返回用户态的时候，调用 schedule 函数进行调度。信号也是类似的，当信号来的时候，我们并不直接处理这个信号，而是设置一个标识位 TIF_SIGPENDING，来表示已经有信号等待处理。同样等待系统调用结束，或者中断处理结束，从内核态返回用户态的时候，再进行信号的处理。

signal_wake_up_state 的第二件事情，就是试图唤醒这个进程或者线程。wake_up_state 会调用 try_to_wake_up 方法。这个函数我们讲进程的时候讲过，就是将这个进程或者线程设置为 TASK_RUNNING，然后放在运行队列中，这个时候，当随着时钟不断的滴答，迟早会被调用。如果 wake_up_state 返回 0，说明进程或者线程已经是 TASK_RUNNING 状态了，如果它在另外一个 CPU 上运行，则调用 kick_process 发送一个处理器间中断，强制那个进程或者线程重新调度，重新调度完毕后，会返回用户态运行。这是一个时机会检查 TIF_SIGPENDING 标识位。

信号的处理

好了，信号已经发送到位了，什么时候真正处理它呢？

就是在从系统调用或者中断返回的时候，咱们讲调度的时候讲过，无论是从系统调用返回还是从中断返回，都会调用 exit_to_usermode_loop，只不过我们上次主要关注了 _TIF_NEED_RESCHED 这个标识位，这次我们重点关注_TIF_SIGPENDING 标识位。

static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)

{

	while (true) {

......

		if (cached_flags & _TIF_NEED_RESCHED)

			schedule();

......

		/* deal with pending signal delivery */

		if (cached_flags & _TIF_SIGPENDING)

			do_signal(regs);

......

		if (!(cached_flags & EXIT_TO_USERMODE_LOOP_FLAGS))

			break;

	}

}

如果在前一个环节中，已经设置了 _TIF_SIGPENDING，我们就调用 do_signal 进行处理。

void do_signal(struct pt_regs *regs)

{

	struct ksignal ksig;

	if (get_signal(&ksig)) {

		/* Whee! Actually deliver the signal.  */

		handle_signal(&ksig, regs);

		return;

	}

	/* Did we come from a system call? */

	if (syscall_get_nr(current, regs) >= 0) {

		/* Restart the system call - no handlers present */

		switch (syscall_get_error(current, regs)) {

		case -ERESTARTNOHAND:

		case -ERESTARTSYS:

		case -ERESTARTNOINTR:

			regs->ax = regs->orig_ax;

			regs->ip -= 2;

			break;

		case -ERESTART_RESTARTBLOCK:

			regs->ax = get_nr_restart_syscall(regs);

			regs->ip -= 2;

			break;

		}

	}

	restore_saved_sigmask();

}

do_signal 会调用 handle_signal。按说，信号处理就是调用用户提供的信号处理函数，但是这事儿没有看起来这么简单，因为信号处理函数是在用户态的。

咱们又要来回忆系统调用的过程了。这个进程当时在用户态执行到某一行 Line A，调用了一个系统调用，在进入内核的那一刻，在内核 pt_regs 里面保存了用户态执行到了 Line A。现在我们从系统调用返回用户态了，按说应该从 pt_regs 拿出 Line A，然后接着 Line A 执行下去，但是为了响应信号，我们不能回到用户态的时候返回 Line A 了，而是应该返回信号处理函数的起始地址。

static void

handle_signal(struct ksignal *ksig, struct pt_regs *regs)

{

	bool stepping, failed;

......

	/* Are we from a system call? */

	if (syscall_get_nr(current, regs) >= 0) {

		/* If so, check system call restarting.. */

		switch (syscall_get_error(current, regs)) {

		case -ERESTART_RESTARTBLOCK:

		case -ERESTARTNOHAND:

			regs->ax = -EINTR;

			break;

		case -ERESTARTSYS:

			if (!(ksig->ka.sa.sa_flags & SA_RESTART)) {

				regs->ax = -EINTR;

				break;

			}

		/* fallthrough */

		case -ERESTARTNOINTR:

			regs->ax = regs->orig_ax;

			regs->ip -= 2;

			break;

		}

	}

......

	failed = (setup_rt_frame(ksig, regs) < 0);

......

	signal_setup_done(failed, ksig, stepping);

}

这个时候，我们就需要干预和自己来定制 pt_regs 了。这个时候，我们要看，是否从系统调用中返回。如果是从系统调用返回的话，还要区分我们是从系统调用中正常返回，还是在一个非运行状态的系统调用中，因为会被信号中断而返回。

我们这里解析一个最复杂的场景。还记得咱们解析进程调度的时候，我们举的一个例子，就是从一个 tap 网卡中读取数据。当时我们主要关注 schedule 那一行，也即如果当发现没有数据的时候，就调用 schedule，自己进入等待状态，然后将 CPU 让给其他进程。具体的代码如下：

static ssize_t tap_do_read(struct tap_queue *q,

			   struct iov_iter *to,

			   int noblock, struct sk_buff *skb)

{

......

	while (1) {

		if (!noblock)

			prepare_to_wait(sk_sleep(&q->sk), &wait,

					TASK_INTERRUPTIBLE);

		/* Read frames from the queue */

		skb = skb_array_consume(&q->skb_array);

		if (skb)

			break;

		if (noblock) {

			ret = -EAGAIN;

			break;

		}

		if (signal_pending(current)) {

			ret = -ERESTARTSYS;

			break;

		}

		/* Nothing to read, let's sleep */

		schedule();

	}

......

}

这里我们关注和信号相关的部分。这其实是一个信号中断系统调用的典型逻辑。

首先，我们把当前进程或者线程的状态设置为 TASK_INTERRUPTIBLE，这样才能是使这个系统调用可以被中断。

其次，可以被中断的系统调用往往是比较慢的调用，并且会因为数据不就绪而通过 schedule 让出 CPU 进入等待状态。在发送信号的时候，我们除了设置这个进程和线程的 _TIF_SIGPENDING 标识位之外，还试图唤醒这个进程或者线程，也就是将它从等待状态中设置为 TASK_RUNNING。

当这个进程或者线程再次运行的时候，我们根据进程调度第一定律，从 schedule 函数中返回，然后再次进入 while 循环。由于这个进程或者线程是由信号唤醒的，而不是因为数据来了而唤醒的，因而是读不到数据的，但是在 signal_pending 函数中，我们检测到了 _TIF_SIGPENDING 标识位，这说明系统调用没有真的做完，于是返回一个错误 ERESTARTSYS，然后带着这个错误从系统调用返回。

然后，我们到了 exit_to_usermode_loop->do_signal->handle_signal。在这里面，当发现出现错误 ERESTARTSYS 的时候，我们就知道这是从一个没有调用完的系统调用返回的，设置系统调用错误码 EINTR。

接下来，我们就开始折腾 pt_regs 了，主要通过调用 setup_rt_frame->__setup_rt_frame。

static int __setup_rt_frame(int sig, struct ksignal *ksig,

			    sigset_t *set, struct pt_regs *regs)

{

	struct rt_sigframe __user *frame;

	void __user *fp = NULL;

	int err = 0;

	frame = get_sigframe(&ksig->ka, regs, sizeof(struct rt_sigframe), &fp);

......

	put_user_try {

......

		/* Set up to return from userspace.  If provided, use a stub

		   already in userspace.  */

		/* x86-64 should always use SA_RESTORER. */

		if (ksig->ka.sa.sa_flags & SA_RESTORER) {

			put_user_ex(ksig->ka.sa.sa_restorer, &frame->pretcode);

		}

	} put_user_catch(err);

	err |= setup_sigcontext(&frame->uc.uc_mcontext, fp, regs, set->sig[0]);

	err |= __copy_to_user(&frame->uc.uc_sigmask, set, sizeof(*set));

	/* Set up registers for signal handler */

	regs->di = sig;

	/* In case the signal handler was declared without prototypes */

	regs->ax = 0;

	regs->si = (unsigned long)&frame->info;

	regs->dx = (unsigned long)&frame->uc;

	regs->ip = (unsigned long) ksig->ka.sa.sa_handler;

	regs->sp = (unsigned long)frame;

	regs->cs = __USER_CS;

......

	return 0;

}

frame 的类型是 rt_sigframe。frame 的意思是帧。我们只有在学习栈的时候，提到过栈帧的概念。对的，这个 frame 就是一个栈帧。

我们在 get_sigframe 中会得到 pt_regs 的 sp 变量，也就是原来这个程序在用户态的栈顶指针，然后 get_sigframe 中，我们会将 sp 减去 sizeof(struct rt_sigframe)，也就是把这个栈帧塞到了栈里面，然后我们又在 __setup_rt_frame 中把 regs->sp 设置成等于 frame。这就相当于强行在程序原来的用户态的栈里面插入了一个栈帧，并在最后将 regs->ip 设置为用户定义的信号处理函数 sa_handler。这意味着，本来返回用户态应该接着原来的代码执行的，现在不了，要执行 sa_handler 了。那执行完了以后呢？按照函数栈的规则，弹出上一个栈帧来，也就是弹出了 frame。

那如果我们假设 sa_handler 成功返回了，怎么回到程序原来在用户态运行的地方呢？玄机就在 frame 里面。要想恢复原来运行的地方，首先，原来的 pt_regs 不能丢，这个没问题，是在 setup_sigcontext 里面，将原来的 pt_regs 保存在了 frame 中的 uc_mcontext 里面。

另外，很重要的一点，程序如何跳过去呢？在 __setup_rt_frame 中，还有一个不引起重视的操作，那就是通过 put_user_ex，将 sa_restorer 放到了 frame->pretcode 里面，而且还是按照函数栈的规则。函数栈里面包含了函数执行完跳回去的地址。当 sa_handler 执行完之后，弹出的函数栈是 frame，也就应该跳到 sa_restorer 的地址。这是什么地址呢？

咱们在 sigaction 介绍的时候就没有介绍它，在 Glibc 的 __libc_sigaction 函数中也没有注意到，它被赋值成了 restore_rt。这其实就是 sa_handler 执行完毕之后，马上要执行的函数。从名字我们就能感觉到，它将恢复原来程序运行的地方。

在 Glibc 中，我们可以找到它的定义，它竟然调用了一个系统调用，系统调用号为 __NR_rt_sigreturn。

RESTORE (restore_rt, __NR_rt_sigreturn)

#define RESTORE(name, syscall) RESTORE2 (name, syscall)

# define RESTORE2(name, syscall) \

asm                                     \

  (                                     \

   ".LSTART_" #name ":\n"               \

   "    .type __" #name ",@function\n"  \

   "__" #name ":\n"                     \

   "    movq $" #syscall ", %rax\n"     \

   "    syscall\n"                      \

......

我们可以在内核里面找到 __NR_rt_sigreturn 对应的系统调用。

asmlinkage long sys_rt_sigreturn(void)

{

	struct pt_regs *regs = current_pt_regs();

	struct rt_sigframe __user *frame;

	sigset_t set;

	unsigned long uc_flags;

	frame = (struct rt_sigframe __user *)(regs->sp - sizeof(long));

	if (__copy_from_user(&set, &frame->uc.uc_sigmask, sizeof(set)))

		goto badframe;

	if (__get_user(uc_flags, &frame->uc.uc_flags))

		goto badframe;

	set_current_blocked(&set);

	if (restore_sigcontext(regs, &frame->uc.uc_mcontext, uc_flags))

		goto badframe;

......

	return regs->ax;

......

}

在这里面，我们把上次填充的那个 rt_sigframe 拿出来，然后 restore_sigcontext 将 pt_regs 恢复成为原来用户态的样子。从这个系统调用返回的时候，应用还误以为从上次的系统调用返回的呢。

至此，整个信号处理过程才全部结束。

总结时刻

信号的发送与处理是一个复杂的过程，这里来总结一下。

假设我们有一个进程 A，main 函数里面调用系统调用进入内核。

按照系统调用的原理，会将用户态栈的信息保存在 pt_regs 里面，也即记住原来用户态是运行到了 line A 的地方。

在内核中执行系统调用读取数据。

当发现没有什么数据可读取的时候，只好进入睡眠状态，并且调用 schedule 让出 CPU，这是进程调度第一定律。

将进程状态设置为 TASK_INTERRUPTIBLE，可中断的睡眠状态，也即如果有信号来的话，是可以唤醒它的。

其他的进程或者 shell 发送一个信号，有四个函数可以调用 kill，tkill，tgkill，rt_sigqueueinfo

四个发送信号的函数，在内核中最终都是调用 do_send_sig_info

do_send_sig_info 调用 send_signal 给进程 A 发送一个信号，其实就是找到进程 A 的 task_struct，或者加入信号集合，为不可靠信号，或者加入信号链表，为可靠信号

do_send_sig_info 调用 signal_wake_up 唤醒进程 A。

进程 A 重新进入运行状态 TASK_RUNNING，根据进程调度第一定律，一定会接着 schedule 运行。

进程 A 被唤醒后，检查是否有信号到来，如果没有，重新循环到一开始，尝试再次读取数据，如果还是没有数据，再次进入 TASK_INTERRUPTIBLE，即可中断的睡眠状态。

当发现有信号到来的时候，就返回当前正在执行的系统调用，并返回一个错误表示系统调用被中断了。

系统调用返回的时候，会调用 exit_to_usermode_loop，这是一个处理信号的时机

调用 do_signal 开始处理信号

根据信号，得到信号处理函数 sa_handler，然后修改 pt_regs 中的用户态栈的信息，让 pt_regs 指向 sa_handler。同时修改用户态的栈，插入一个栈帧 sa_restorer，里面保存了原来的指向 line A 的 pt_regs，并且设置让 sa_handler 运行完毕后，跳到 sa_restorer 运行。

返回用户态，由于 pt_regs 已经设置为 sa_handler，则返回用户态执行 sa_handler。

sa_handler 执行完毕后，信号处理函数就执行完了，接着根据第 15 步对于用户态栈帧的修改，会跳到 sa_restorer 运行。

sa_restorer 会调用系统调用 rt_sigreturn 再次进入内核。

在内核中，rt_sigreturn 恢复原来的 pt_regs，重新指向 line A。

从 rt_sigreturn 返回用户态，还是调用 exit_to_usermode_loop。

这次因为 pt_regs 已经指向 line A 了，于是就到了进程 A 中，接着系统调用之后运行，当然这个系统调用返回的是它被中断了，没有执行完的错误。

课堂练习

在 Linux 内核里面，很多地方都存在信号和信号处理，所以 signal_pending 这个函数也随处可见，这样我们就能判断是否有信号发生。请你在内核代码中找到 signal_pending 出现的一些地方，看有什么规律，我们后面的章节会经常遇到它。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

39 | 管道：项目组A完成了，如何交接给项目组B？

在这一章的第一节里，我们大概讲了管道的使用方式以及相应的命令行。这一节，我们就具体来看一下管道是如何实现的。

我们先来看，我们常用的匿名管道（Anonymous Pipes），也即将多个命令串起来的竖线，背后的原理到底是什么。

上次我们说，它是基于管道的，那管道如何创建呢？管道的创建，需要通过下面这个系统调用。

int pipe(int fd[2])

在这里，我们创建了一个管道 pipe，返回了两个文件描述符，这表示管道的两端，一个是管道的读取端描述符 fd[0]，另一个是管道的写入端描述符 fd[1]。

我们来看在内核里面是如何实现的。

SYSCALL_DEFINE1(pipe, int __user *, fildes)

{

	return sys_pipe2(fildes, 0);

}

SYSCALL_DEFINE2(pipe2, int __user *, fildes, int, flags)

{

	struct file *files[2];

	int fd[2];

	int error;

	error = __do_pipe_flags(fd, files, flags);

	if (!error) {

		if (unlikely(copy_to_user(fildes, fd, sizeof(fd)))) {

......

			error = -EFAULT;

		} else {

			fd_install(fd[0], files[0]);

			fd_install(fd[1], files[1]);

		}

	}

	return error;

}

在内核中，主要的逻辑在 pipe2 系统调用中。这里面要创建一个数组 files，用来存放管道的两端的打开文件，另一个数组 fd 存放管道的两端的文件描述符。如果调用 __do_pipe_flags 没有错误，那就调用 fd_install，将两个 fd 和两个 struct file 关联起来。这一点和打开一个文件的过程很像了。

我们来看 __do_pipe_flags。这里面调用了 create_pipe_files，然后生成了两个 fd。从这里可以看出，fd[0] 是用于读的，fd[1] 是用于写的。

static int __do_pipe_flags(int *fd, struct file **files, int flags)

{

	int error;

	int fdw, fdr;

......

	error = create_pipe_files(files, flags);

......

	error = get_unused_fd_flags(flags);

......

	fdr = error;

	error = get_unused_fd_flags(flags);

......

	fdw = error;

	fd[0] = fdr;

	fd[1] = fdw;

	return 0;

......

}

创建一个管道，大部分的逻辑其实都是在 create_pipe_files 函数里面实现的。这一章第一节的时候，我们说过，命名管道是创建在文件系统上的。从这里我们可以看出，匿名管道，也是创建在文件系统上的，只不过是一种特殊的文件系统，创建一个特殊的文件，对应一个特殊的 inode，就是这里面的 get_pipe_inode。

int create_pipe_files(struct file **res, int flags)

{

	int err;

	struct inode *inode = get_pipe_inode();

	struct file *f;

	struct path path;

......

	path.dentry = d_alloc_pseudo(pipe_mnt->mnt_sb, &empty_name);

......

	path.mnt = mntget(pipe_mnt);

	d_instantiate(path.dentry, inode);

	f = alloc_file(&path, FMODE_WRITE, &pipefifo_fops);

......

	f->f_flags = O_WRONLY | (flags & (O_NONBLOCK | O_DIRECT));

	f->private_data = inode->i_pipe;

	res[0] = alloc_file(&path, FMODE_READ, &pipefifo_fops);

......

	path_get(&path);

	res[0]->private_data = inode->i_pipe;

	res[0]->f_flags = O_RDONLY | (flags & O_NONBLOCK);

	res[1] = f;

	return 0;

......

}

从 get_pipe_inode 的实现，我们可以看出，匿名管道来自一个特殊的文件系统 pipefs。这个文件系统被挂载后，我们就得到了 struct vfsmount *pipe_mnt。然后挂载的文件系统的 superblock 就变成了：pipe_mnt->mnt_sb。如果你对文件系统的操作还不熟悉，要返回去复习一下文件系统那一章啊。

static struct file_system_type pipe_fs_type = {

	.name		= "pipefs",

	.mount		= pipefs_mount,

	.kill_sb	= kill_anon_super,

};

static int __init init_pipe_fs(void)

{

	int err = register_filesystem(&pipe_fs_type);

	if (!err) {

		pipe_mnt = kern_mount(&pipe_fs_type);

	}

......

}

static struct inode * get_pipe_inode(void)

{

	struct inode *inode = new_inode_pseudo(pipe_mnt->mnt_sb);

	struct pipe_inode_info *pipe;

......

	inode->i_ino = get_next_ino();

	pipe = alloc_pipe_info();

......

	inode->i_pipe = pipe;

	pipe->files = 2;

	pipe->readers = pipe->writers = 1;

	inode->i_fop = &pipefifo_fops;

	inode->i_state = I_DIRTY;

	inode->i_mode = S_IFIFO | S_IRUSR | S_IWUSR;

	inode->i_uid = current_fsuid();

	inode->i_gid = current_fsgid();

	inode->i_atime = inode->i_mtime = inode->i_ctime = current_time(inode);

	return inode;

......

}

我们从 new_inode_pseudo 函数创建一个 inode。这里面开始填写 Inode 的成员，这里和文件系统的很像。这里值得注意的是 struct pipe_inode_info，这个结构里面有个成员是 struct pipe_buffer *bufs。我们可以知道，所谓的匿名管道，其实就是内核里面的一串缓存。

另外一个需要注意的是 pipefifo_fops，将来我们对于文件描述符的操作，在内核里面都是对应这里面的操作。

const struct file_operations pipefifo_fops = {

	.open		= fifo_open,

	.llseek		= no_llseek,

	.read_iter	= pipe_read,

	.write_iter	= pipe_write,

	.poll		= pipe_poll,

	.unlocked_ioctl	= pipe_ioctl,

	.release	= pipe_release,

	.fasync		= pipe_fasync,

};

我们回到 create_pipe_files 函数，创建完了 inode，还需创建一个 dentry 和他对应。dentry 和 inode 对应好了，我们就要开始创建 struct file 对象了。先创建用于写入的，对应的操作为 pipefifo_fops；再创建读取的，对应的操作也为 pipefifo_fops。然后把 private_data 设置为 pipe_inode_info。这样从 struct file 这个层级上，就能直接操作底层的读写操作。

至此，一个匿名管道就创建成功了。如果对于 fd[1] 写入，调用的是 pipe_write，向 pipe_buffer 里面写入数据；如果对于 fd[0] 的读入，调用的是 pipe_read，也就是从 pipe_buffer 里面读取数据。

但是这个时候，两个文件描述符都是在一个进程里面的，并没有起到进程间通信的作用，怎么样才能使得管道是跨两个进程的呢？还记得创建进程调用的 fork 吗？在这里面，创建的子进程会复制父进程的 struct files_struct，在这里面 fd 的数组会复制一份，但是 fd 指向的 struct file 对于同一个文件还是只有一份，这样就做到了，两个进程各有两个 fd 指向同一个 struct file 的模式，两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。

由于管道只能一端写入，另一端读出，所以上面的这种模式会造成混乱，因为父进程和子进程都可以写入，也都可以读出，通常的方法是父进程关闭读取的 fd，只保留写入的 fd，而子进程关闭写入的 fd，只保留读取的 fd，如果需要双向通行，则应该创建两个管道。

一个典型的使用管道在父子进程之间的通信代码如下：

#include <unistd.h>

#include <fcntl.h>

#include <stdlib.h>

#include <stdio.h>

#include <errno.h>

#include <string.h>

int main(int argc, char *argv[])

{

  int fds[2];

  if (pipe(fds) == -1)

    perror("pipe error");

  pid_t pid;

  pid = fork();

  if (pid == -1)

    perror("fork error");

  if (pid == 0){

    close(fds[0]);

    char msg[] = "hello world";

    write(fds[1], msg, strlen(msg) + 1);

    close(fds[1]);

    exit(0);

  } else {

    close(fds[1]);

    char msg[128];

    read(fds[0], msg, 128);

    close(fds[0]);

    printf("message : %s\n", msg);

    return 0;

  }

}

到这里，我们仅仅解析了使用管道进行父子进程之间的通信，但是我们在 shell 里面的不是这样的。在 shell 里面运行 A|B 的时候，A 进程和 B 进程都是 shell 创建出来的子进程，A 和 B 之间不存在父子关系。

不过，有了上面父子进程之间的管道这个基础，实现 A 和 B 之间的管道就方便多了。

我们首先从 shell 创建子进程 A，然后在 shell 和 A 之间建立一个管道，其中 shell 保留读取端，A 进程保留写入端，然后 shell 再创建子进程 B。这又是一次 fork，所以，shell 里面保留的读取端的 fd 也被复制到了子进程 B 里面。这个时候，相当于 shell 和 B 都保留读取端，只要 shell 主动关闭读取端，就变成了一管道，写入端在 A 进程，读取端在 B 进程。

接下来我们要做的事情就是，将这个管道的两端和输入输出关联起来。这就要用到 dup2 系统调用了。

int dup2(int oldfd, int newfd);

这个系统调用，将老的文件描述符赋值给新的文件描述符，让 newfd 的值和 oldfd 一样。

我们还是回忆一下，在 files_struct 里面，有这样一个表，下标是 fd，内容指向一个打开的文件 struct file。

struct files_struct {

  struct file __rcu * fd_array[NR_OPEN_DEFAULT];

}

在这个表里面，前三项是定下来的，其中第零项 STDIN_FILENO 表示标准输入，第一项 STDOUT_FILENO 表示标准输出，第三项 STDERR_FILENO 表示错误输出。

在 A 进程中，写入端可以做这样的操作：dup2(fd[1],STDOUT_FILENO)，将 STDOUT_FILENO（也即第一项）不再指向标准输出，而是指向创建的管道文件，那么以后往标准输出写入的任何东西，都会写入管道文件。

在 B 进程中，读取端可以做这样的操作，dup2(fd[0],STDIN_FILENO)，将 STDIN_FILENO 也即第零项不再指向标准输入，而是指向创建的管道文件，那么以后从标准输入读取的任何东西，都来自于管道文件。

至此，我们才将 A|B 的功能完成。

为了模拟 A|B 的情况，我们可以将前面的那一段代码，进一步修改成为下面这样：

#include <unistd.h>

#include <fcntl.h>

#include <stdlib.h>

#include <stdio.h>

#include <errno.h>

#include <string.h>

int main(int argc, char *argv[])

{

  int fds[2];

  if (pipe(fds) == -1)

    perror("pipe error");

  pid_t pid;

  pid = fork();

  if (pid == -1)

    perror("fork error");

  if (pid == 0){

    dup2(fds[1], STDOUT_FILENO);

    close(fds[1]);

    close(fds[0]);

    execlp("ps", "ps", "-ef", NULL);

  } else {

    dup2(fds[0], STDIN_FILENO);

    close(fds[0]);

    close(fds[1]);

    execlp("grep", "grep", "systemd", NULL);

  }

  return 0;

}

接下来，我们来看命名管道。我们在讲命令的时候讲过，命名管道需要事先通过命令 mkfifo，进行创建。如果是通过代码创建命名管道，也有一个函数，但是这不是一个系统调用，而是 Glibc 提供的函数。它的定义如下：

int

mkfifo (const char *path, mode_t mode)

{

  dev_t dev = 0;

  return __xmknod (_MKNOD_VER, path, mode | S_IFIFO, &dev);

}

int

__xmknod (int vers, const char *path, mode_t mode, dev_t *dev)

{

  unsigned long long int k_dev;

......

  /* We must convert the value to dev_t type used by the kernel.  */

  k_dev = (*dev) & ((1ULL << 32) - 1);

......

  return INLINE_SYSCALL (mknodat, 4, AT_FDCWD, path, mode,

                         (unsigned int) k_dev);

}

Glibc 的 mkfifo 函数会调用 mknodat 系统调用，还记得咱们学字符设备的时候，创建一个字符设备的时候，也是调用的 mknod。这里命名管道也是一个设备，因而我们也用 mknod。

SYSCALL_DEFINE4(mknodat, int, dfd, const char __user *, filename, umode_t, mode, unsigned, dev)

{

	struct dentry *dentry;

	struct path path;

	unsigned int lookup_flags = 0;

......

retry:

	dentry = user_path_create(dfd, filename, &path, lookup_flags);

......

	switch (mode & S_IFMT) {

......

		case S_IFIFO: case S_IFSOCK:

			error = vfs_mknod(path.dentry->d_inode,dentry,mode,0);

			break;

	}

......

}

对于 mknod 的解析，我们在字符设备那一节已经解析过了，先是通过 user_path_create 对于这个管道文件创建一个 dentry，然后因为是 S_IFIFO，所以调用 vfs_mknod。由于这个管道文件是创建在一个普通文件系统上的，假设是在 ext4 文件上，于是 vfs_mknod 会调用 ext4_dir_inode_operations 的 mknod，也即会调用 ext4_mknod。

const struct inode_operations ext4_dir_inode_operations = {

......

	.mknod		= ext4_mknod,

......

};

static int ext4_mknod(struct inode *dir, struct dentry *dentry,

		      umode_t mode, dev_t rdev)

{

	handle_t *handle;

	struct inode *inode;

......

	inode = ext4_new_inode_start_handle(dir, mode, &dentry->d_name, 0,

					    NULL, EXT4_HT_DIR, credits);

	handle = ext4_journal_current_handle();

	if (!IS_ERR(inode)) {

		init_special_inode(inode, inode->i_mode, rdev);

		inode->i_op = &ext4_special_inode_operations;

		err = ext4_add_nondir(handle, dentry, inode);

		if (!err && IS_DIRSYNC(dir))

			ext4_handle_sync(handle);

	}

	if (handle)

		ext4_journal_stop(handle);

......

}

#define ext4_new_inode_start_handle(dir, mode, qstr, goal, owner, \

				    type, nblocks)		    \

	__ext4_new_inode(NULL, (dir), (mode), (qstr), (goal), (owner), \

			 0, (type), __LINE__, (nblocks))

void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)

{

	inode->i_mode = mode;

	if (S_ISCHR(mode)) {

		inode->i_fop = &def_chr_fops;

		inode->i_rdev = rdev;

	} else if (S_ISBLK(mode)) {

		inode->i_fop = &def_blk_fops;

		inode->i_rdev = rdev;

	} else if (S_ISFIFO(mode))

		inode->i_fop = &pipefifo_fops;

	else if (S_ISSOCK(mode))

		;	/* leave it no_open_fops */

	else

......

}

在 ext4_mknod 中，ext4_new_inode_start_handle 会调用 __ext4_new_inode，在 ext4 文件系统上真的创建一个文件，但是会调用 init_special_inode，创建一个内存中特殊的 inode，这个函数我们在字符设备文件中也遇到过，只不过当时 inode 的 i_fop 指向的是 def_chr_fops，这次换成管道文件了，inode 的 i_fop 变成指向 pipefifo_fops，这一点和匿名管道是一样的。

这样，管道文件就创建完毕了。

接下来，要打开这个管道文件，我们还是会调用文件系统的 open 函数。还是沿着文件系统的调用方式，一路调用到 pipefifo_fops 的 open 函数，也就是 fifo_open。

static int fifo_open(struct inode *inode, struct file *filp)

{

	struct pipe_inode_info *pipe;

	bool is_pipe = inode->i_sb->s_magic == PIPEFS_MAGIC;

	int ret;

	filp->f_version = 0;

	if (inode->i_pipe) {

		pipe = inode->i_pipe;

		pipe->files++;

	} else {

		pipe = alloc_pipe_info();

		pipe->files = 1;

		inode->i_pipe = pipe;

		spin_unlock(&inode->i_lock);

	}

	filp->private_data = pipe;

	filp->f_mode &= (FMODE_READ | FMODE_WRITE);

	switch (filp->f_mode) {

	case FMODE_READ:

		pipe->r_counter++;

		if (pipe->readers++ == 0)

			wake_up_partner(pipe);

		if (!is_pipe && !pipe->writers) {

			if ((filp->f_flags & O_NONBLOCK)) {

			filp->f_version = pipe->w_counter;

			} else {

				if (wait_for_partner(pipe, &pipe->w_counter))

					goto err_rd;

			}

		}

		break;

	case FMODE_WRITE:

		pipe->w_counter++;

		if (!pipe->writers++)

			wake_up_partner(pipe);

		if (!is_pipe && !pipe->readers) {

			if (wait_for_partner(pipe, &pipe->r_counter))

				goto err_wr;

		}

		break;

	case FMODE_READ | FMODE_WRITE:

		pipe->readers++;

		pipe->writers++;

		pipe->r_counter++;

		pipe->w_counter++;

		if (pipe->readers == 1 || pipe->writers == 1)

			wake_up_partner(pipe);

		break;

......

	}

......

}

在 fifo_open 里面，创建 pipe_inode_info，这一点和匿名管道也是一样的。这个结构里面有个成员是 struct pipe_buffer *bufs。我们可以知道，所谓的命名管道，其实是也是内核里面的一串缓存。

接下来，对于命名管道的写入，我们还是会调用 pipefifo_fops 的 pipe_write 函数，向 pipe_buffer 里面写入数据。对于命名管道的读入，我们还是会调用 pipefifo_fops 的 pipe_read，也就是从 pipe_buffer 里面读取数据。

总结时刻

无论是匿名管道，还是命名管道，在内核都是一个文件。只要是文件就要有一个 inode。这里我们又用到了特殊 inode、字符设备、块设备，其实都是这种特殊的 inode。

在这种特殊的 inode 里面，file_operations 指向管道特殊的 pipefifo_fops，这个 inode 对应内存里面的缓存。

当我们用文件的 open 函数打开这个管道设备文件的时候，会调用 pipefifo_fops 里面的方法创建 struct file 结构，他的 inode 指向特殊的 inode，也对应内存里面的缓存，file_operations 也指向管道特殊的 pipefifo_fops。

写入一个 pipe 就是从 struct file 结构找到缓存写入，读取一个 pipe 就是从 struct file 结构找到缓存读出。

课堂练习

上面创建匿名管道的程序，你一定要运行一下，然后试着通过 strace 查看自己写的程序的系统调用，以及直接在命令行使用匿名管道的系统调用，做一个比较。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

40 | IPC（上）：不同项目组之间抢资源，如何协调？

我们前面讲了，如果项目组之间需要紧密合作，那就需要共享内存，这样就像把两个项目组放在一个会议室一起沟通，会非常高效。这一节，我们就来详细讲讲这个进程之间共享内存的机制。

有了这个机制，两个进程可以像访问自己内存中的变量一样，访问共享内存的变量。但是同时问题也来了，当两个进程共享内存了，就会存在同时读写的问题，就需要对于共享的内存进行保护，就需要信号量这样的同步协调机制。这些也都是我们这节需要探讨的问题。下面我们就一一来看。

共享内存和信号量也是 System V 系列的进程间通信机制，所以很多地方和我们讲过的消息队列有点儿像。为了将共享内存和信号量结合起来使用，我这里定义了一个 share.h 头文件，里面放了一些共享内存和信号量在每个进程都需要的函数。

#include <stdio.h>

#include <stdlib.h>

#include <sys/ipc.h>

#include <sys/shm.h>

#include <sys/types.h>

#include <sys/ipc.h>

#include <sys/sem.h>

#include <string.h>

#define MAX_NUM 128

struct shm_data {

  int data[MAX_NUM];

  int datalength;

};

union semun {

  int val;

  struct semid_ds *buf;

  unsigned short int *array;

  struct seminfo *__buf;

};

int get_shmid(){

  int shmid;

  key_t key;

  if((key = ftok("/root/sharememory/sharememorykey", 1024)) < 0){

      perror("ftok error");

          return -1;

  }

  shmid = shmget(key, sizeof(struct shm_data), IPC_CREAT|0777);

  return shmid;

}

int get_semaphoreid(){

  int semid;

  key_t key;

  if((key = ftok("/root/sharememory/semaphorekey", 1024)) < 0){

      perror("ftok error");

          return -1;

  }

  semid = semget(key, 1, IPC_CREAT|0777);

  return semid;

}

int semaphore_init (int semid) {

  union semun argument;

  unsigned short values[1];

  values[0] = 1;

  argument.array = values;

  return semctl (semid, 0, SETALL, argument);

}

int semaphore_p (int semid) {

  struct sembuf operations[1];

  operations[0].sem_num = 0;

  operations[0].sem_op = -1;

  operations[0].sem_flg = SEM_UNDO;

  return semop (semid, operations, 1);

}

int semaphore_v (int semid) {

  struct sembuf operations[1];

  operations[0].sem_num = 0;

  operations[0].sem_op = 1;

  operations[0].sem_flg = SEM_UNDO;

  return semop (semid, operations, 1);

}

共享内存

我们先来看里面对于共享内存的操作。

首先，创建之前，我们要有一个 key 来唯一标识这个共享内存。这个 key 可以根据文件系统上的一个文件的 inode 随机生成。

然后，我们需要创建一个共享内存，就像创建一个消息队列差不多，都是使用 xxxget 来创建。其中，创建共享内存使用的是下面这个函数：

int shmget(key_t key, size_t size, int shmflag);

其中，key 就是前面生成的那个 key，shmflag 如果为 IPC_CREAT，就表示新创建，还可以指定读写权限 0777。

对于共享内存，需要指定一个大小 size，这个一般要申请多大呢？一个最佳实践是，我们将多个进程需要共享的数据放在一个 struct 里面，然后这里的 size 就应该是这个 struct 的大小。这样每一个进程得到这块内存后，只要强制将类型转换为这个 struct 类型，就能够访问里面的共享数据了。

在这里，我们定义了一个 struct shm_data 结构。这里面有两个成员，一个是一个整型的数组，一个是数组中元素的个数。

生成了共享内存以后，接下来就是将这个共享内存映射到进程的虚拟地址空间中。我们使用下面这个函数来进行操作。

void *shmat(int  shm_id, const  void *addr, int shmflg);

这里面的 shm_id，就是上面创建的共享内存的 id，addr 就是指定映射在某个地方。如果不指定，则内核会自动选择一个地址，作为返回值返回。得到了返回地址以后，我们需要将指针强制类型转换为 struct shm_data 结构，就可以使用这个指针设置 data 和 datalength 了。

当共享内存使用完毕，我们可以通过 shmdt 解除它到虚拟内存的映射。

int shmdt(const  void *shmaddr)；

信号量

看完了共享内存，接下来我们再来看信号量。信号量以集合的形式存在的。

首先，创建之前，我们同样需要有一个 key，来唯一标识这个信号量集合。这个 key 同样可以根据文件系统上的一个文件的 inode 随机生成。

然后，我们需要创建一个信号量集合，同样也是使用 xxxget 来创建，其中创建信号量集合使用的是下面这个函数。

int semget(key_t key, int nsems, int semflg);

这里面的 key，就是前面生成的那个 key，shmflag 如果为 IPC_CREAT，就表示新创建，还可以指定读写权限 0777。

这里，nsems 表示这个信号量集合里面有几个信号量，最简单的情况下，我们设置为 1。

信号量往往代表某种资源的数量，如果用信号量做互斥，那往往将信号量设置为 1。这就是上面代码中 semaphore_init 函数的作用，这里面调用 semctl 函数，将这个信号量集合的中的第 0 个信号量，也即唯一的这个信号量设置为 1。

对于信号量，往往要定义两种操作，P 操作和 V 操作。对应上面代码中 semaphore_p 函数和 semaphore_v 函数，semaphore_p 会调用 semop 函数将信号量的值减一，表示申请占用一个资源，当发现当前没有资源的时候，进入等待。semaphore_v 会调用 semop 函数将信号量的值加一，表示释放一个资源，释放之后，就允许等待中的其他进程占用这个资源。

我们可以用这个信号量，来保护共享内存中的 struct shm_data，使得同时只有一个进程可以操作这个结构。

你是否记得咱们讲线程同步机制的时候，构建了一个老板分配活的场景。这里我们同样构建一个场景，分为 producer.c 和 consumer.c，其中 producer 也即生产者，负责往 struct shm_data 塞入数据，而 consumer.c 负责处理 struct shm_data 中的数据。

下面我们来看 producer.c 的代码。

#include "share.h"

int main() {

  void *shm = NULL;

  struct shm_data *shared = NULL;

  int shmid = get_shmid();

  int semid = get_semaphoreid();

  int i;

  shm = shmat(shmid, (void*)0, 0);

  if(shm == (void*)-1){

    exit(0);

  }

  shared = (struct shm_data*)shm;

  memset(shared, 0, sizeof(struct shm_data));

  semaphore_init(semid);

  while(1){

    semaphore_p(semid);

    if(shared->datalength > 0){

      semaphore_v(semid);

      sleep(1);

    } else {

      printf("how many integers to caculate : ");

      scanf("%d",&shared->datalength);

      if(shared->datalength > MAX_NUM){

        perror("too many integers.");

        shared->datalength = 0;

        semaphore_v(semid);

        exit(1);

      }

      for(i=0;i<shared->datalength;i++){

        printf("Input the %d integer : ", i);

        scanf("%d",&shared->data[i]);

      }

      semaphore_v(semid);

    }

  }

}

在这里面，get_shmid 创建了共享内存，get_semaphoreid 创建了信号量集合，然后 shmat 将共享内存映射到了虚拟地址空间的 shm 指针指向的位置，然后通过强制类型转换，shared 的指针指向放在共享内存里面的 struct shm_data 结构，然后初始化为 0。semaphore_init 将信号量进行了初始化。

接着，producer 进入了一个无限循环。在这个循环里面，我们先通过 semaphore_p 申请访问共享内存的权利，如果发现 datalength 大于零，说明共享内存里面的数据没有被处理过，于是 semaphore_v 释放权利，先睡一会儿，睡醒了再看。如果发现 datalength 等于 0，说明共享内存里面的数据被处理完了，于是开始往里面放数据。让用户输入多少个数，然后每个数是什么，都放在 struct shm_data 结构中，然后 semaphore_v 释放权利，等待其他的进程将这些数拿去处理。

我们再来看 consumer 的代码。

#include "share.h"

int main() {

  void *shm = NULL;

  struct shm_data *shared = NULL;

  int shmid = get_shmid();

  int semid = get_semaphoreid();

  int i;

  shm = shmat(shmid, (void*)0, 0);

  if(shm == (void*)-1){

    exit(0);

  }

  shared = (struct shm_data*)shm;

  while(1){

    semaphore_p(semid);

    if(shared->datalength > 0){

      int sum = 0;

      for(i=0;i<shared->datalength-1;i++){

        printf("%d+",shared->data[i]);

        sum += shared->data[i];

      }

      printf("%d",shared->data[shared->datalength-1]);

      sum += shared->data[shared->datalength-1];

      printf("=%d\n",sum);

      memset(shared, 0, sizeof(struct shm_data));

      semaphore_v(semid);

    } else {

      semaphore_v(semid);

      printf("no tasks, waiting.\n");

      sleep(1);

    }

  }

}

在这里面，get_shmid 获得 producer 创建的共享内存，get_semaphoreid 获得 producer 创建的信号量集合，然后 shmat 将共享内存映射到了虚拟地址空间的 shm 指针指向的位置，然后通过强制类型转换，shared 的指针指向放在共享内存里面的 struct shm_data 结构。

接着，consumer 进入了一个无限循环，在这个循环里面，我们先通过 semaphore_p 申请访问共享内存的权利，如果发现 datalength 等于 0，就说明没什么活干，需要等待。如果发现 datalength 大于 0，就说明有活干，于是将 datalength 个整型数字从 data 数组中取出来求和。最后将 struct shm_data 清空为 0，表示任务处理完毕，通过 semaphore_v 释放权利。

通过程序创建的共享内存和信号量集合，我们可以通过命令 ipcs 查看。当然，我们也可以通过 ipcrm 进行删除。

# ipcs

------ Message Queues --------

key        msqid      owner      perms      used-bytes   messages

------ Shared Memory Segments --------

key        shmid      owner      perms      bytes      nattch     status

0x00016988 32768      root       777        516        0

------ Semaphore Arrays --------

key        semid      owner      perms      nsems

0x00016989 32768      root       777        1

下面我们来运行一下 producer 和 consumer，可以得到下面的结果：

# ./producer

how many integers to caculate : 2

Input the 0 integer : 3

Input the 1 integer : 4

how many integers to caculate : 4

Input the 0 integer : 3

Input the 1 integer : 4

Input the 2 integer : 5

Input the 3 integer : 6

how many integers to caculate : 7

Input the 0 integer : 9

Input the 1 integer : 8

Input the 2 integer : 7

Input the 3 integer : 6

Input the 4 integer : 5

Input the 5 integer : 4

Input the 6 integer : 3

# ./consumer

3+4=7

3+4+5+6=18

9+8+7+6+5+4+3=42

总结时刻

这一节的内容差不多了，我们来总结一下。共享内存和信号量的配合机制，如下图所示：

无论是共享内存还是信号量，创建与初始化都遵循同样流程，通过 ftok 得到 key，通过 xxxget 创建对象并生成 id；

生产者和消费者都通过 shmat 将共享内存映射到各自的内存空间，在不同的进程里面映射的位置不同；

为了访问共享内存，需要信号量进行保护，信号量需要通过 semctl 初始化为某个值；

接下来生产者和消费者要通过 semop(-1) 来竞争信号量，如果生产者抢到信号量则写入，然后通过 semop(+1) 释放信号量，如果消费者抢到信号量则读出，然后通过 semop(+1) 释放信号量；

共享内存使用完毕，可以通过 shmdt 来解除映射。

课堂练习

信号量大于 1 的情况下，应该如何使用？你可以试着构建一个场景。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

41 | IPC（中）：不同项目组之间抢资源，如何协调？

了解了如何使用共享内存和信号量集合之后，今天我们来解析一下，内核里面都做了什么。

不知道你有没有注意到，咱们讲消息队列、共享内存、信号量的机制的时候，我们其实能够从中看到一些统一的规律：它们在使用之前都要生成 key，然后通过 key 得到唯一的 id，并且都是通过 xxxget 函数。

在内核里面，这三种进程间通信机制是使用统一的机制管理起来的，都叫 ipcxxx。

为了维护这三种进程间通信进制，在内核里面，我们声明了一个有三项的数组。

我们通过这段代码，来具体看一看。

struct ipc_namespace {

......

	struct ipc_ids	ids[3];

......

}

#define IPC_SEM_IDS	0

#define IPC_MSG_IDS	1

#define IPC_SHM_IDS	2

#define sem_ids(ns)	((ns)->ids[IPC_SEM_IDS])

#define msg_ids(ns)	((ns)->ids[IPC_MSG_IDS])

#define shm_ids(ns)	((ns)->ids[IPC_SHM_IDS])

根据代码中的定义，第 0 项用于信号量，第 1 项用于消息队列，第 2 项用于共享内存，分别可以通过 sem_ids、msg_ids、shm_ids 来访问。

这段代码里面有 ns，全称叫 namespace。可能不容易理解，你现在可以将它认为是将一台 Linux 服务器逻辑的隔离为多台 Linux 服务器的机制，它背后的原理是一个相当大的话题，我们需要在容器那一章详细讲述。现在，你就可以简单的认为没有 namespace，整个 Linux 在一个 namespace 下面，那这些 ids 也是整个 Linux 只有一份。

接下来，我们再来看 struct ipc_ids 里面保存了什么。

首先，in_use 表示当前有多少个 ipc；其次，seq 和 next_id 用于一起生成 ipc 唯一的 id，因为信号量，共享内存，消息队列，它们三个的 id 也不能重复；ipcs_idr 是一棵基数树，我们又碰到它了，一旦涉及从一个整数查找一个对象，它都是最好的选择。

struct ipc_ids {

	int in_use;

	unsigned short seq;

	struct rw_semaphore rwsem;

	struct idr ipcs_idr;

	int next_id;

};

struct idr {

	struct radix_tree_root	idr_rt;

	unsigned int		idr_next;

};

也就是说，对于 sem_ids、msg_ids、shm_ids 各有一棵基数树。那这棵树里面究竟存放了什么，能够统一管理这三类 ipc 对象呢？

通过下面这个函数 ipc_obtain_object_idr，我们可以看出端倪。这个函数根据 id，在基数树里面找出来的是 struct kern_ipc_perm。

struct kern_ipc_perm *ipc_obtain_object_idr(struct ipc_ids *ids, int id)

{

	struct kern_ipc_perm *out;

	int lid = ipcid_to_idx(id);

	out = idr_find(&ids->ipcs_idr, lid);

	return out;

}

如果我们看用于表示信号量、消息队列、共享内存的结构，就会发现，这三个结构的第一项都是 struct kern_ipc_perm。

struct sem_array {

	struct kern_ipc_perm	sem_perm;	/* permissions .. see ipc.h */

	time_t			sem_ctime;	/* create/last semctl() time */

	struct list_head	pending_alter;	/* pending operations */

						                /* that alter the array */

	struct list_head	pending_const;	/* pending complex operations */

						/* that do not alter semvals */

	struct list_head	list_id;	/* undo requests on this array */

	int			sem_nsems;	/* no. of semaphores in array */

	int			complex_count;	/* pending complex operations */

	unsigned int		use_global_lock;/* >0: global lock required */

	struct sem		sems[];

} __randomize_layout;

struct msg_queue {

	struct kern_ipc_perm q_perm;

	time_t q_stime;			/* last msgsnd time */

	time_t q_rtime;			/* last msgrcv time */

	time_t q_ctime;			/* last change time */

	unsigned long q_cbytes;		/* current number of bytes on queue */

	unsigned long q_qnum;		/* number of messages in queue */

	unsigned long q_qbytes;		/* max number of bytes on queue */

	pid_t q_lspid;			/* pid of last msgsnd */

	pid_t q_lrpid;			/* last receive pid */

	struct list_head q_messages;

	struct list_head q_receivers;

	struct list_head q_senders;

} __randomize_layout;

struct shmid_kernel /* private to the kernel */

{

	struct kern_ipc_perm	shm_perm;

	struct file		*shm_file;

	unsigned long		shm_nattch;

	unsigned long		shm_segsz;

	time_t			shm_atim;

	time_t			shm_dtim;

	time_t			shm_ctim;

	pid_t			shm_cprid;

	pid_t			shm_lprid;

	struct user_struct	*mlock_user;

	/* The task created the shm object.  NULL if the task is dead. */

	struct task_struct	*shm_creator;

	struct list_head	shm_clist;	/* list by creator */

} __randomize_layout;

也就是说，我们完全可以通过 struct kern_ipc_perm 的指针，通过进行强制类型转换后，得到整个结构。做这件事情的函数如下：

static inline struct sem_array *sem_obtain_object(struct ipc_namespace *ns, int id)

{

	struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&sem_ids(ns), id);

	return container_of(ipcp, struct sem_array, sem_perm);

}

static inline struct msg_queue *msq_obtain_object(struct ipc_namespace *ns, int id)

{

	struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&msg_ids(ns), id);

	return container_of(ipcp, struct msg_queue, q_perm);

}

static inline struct shmid_kernel *shm_obtain_object(struct ipc_namespace *ns, int id)

{

	struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&shm_ids(ns), id);

	return container_of(ipcp, struct shmid_kernel, shm_perm);

}

通过这种机制，我们就可以将信号量、消息队列、共享内存抽象为 ipc 类型进行统一处理。你有没有觉得，这有点儿面向对象编程中抽象类和实现类的意思？没错，如果你试图去了解 C++ 中类的实现机制，其实也是这么干的。

有了抽象类，接下来我们来看共享内存和信号量的具体实现。

如何创建共享内存？

首先，我们来看创建共享内存的的系统调用。

SYSCALL_DEFINE3(shmget, key_t, key, size_t, size, int, shmflg)

{

	struct ipc_namespace *ns;

	static const struct ipc_ops shm_ops = {

		.getnew = newseg,

		.associate = shm_security,

		.more_checks = shm_more_checks,

	};

	struct ipc_params shm_params;

	ns = current->nsproxy->ipc_ns;

	shm_params.key = key;

	shm_params.flg = shmflg;

	shm_params.u.size = size;

	return ipcget(ns, &shm_ids(ns), &shm_ops, &shm_params);

}

这里面调用了抽象的 ipcget、参数分别为共享内存对应的 shm_ids、对应的操作 shm_ops 以及对应的参数 shm_params。

如果 key 设置为 IPC_PRIVATE 则永远创建新的，如果不是的话，就会调用 ipcget_public。ipcget 的具体代码如下：

int ipcget(struct ipc_namespace *ns, struct ipc_ids *ids,

			const struct ipc_ops *ops, struct ipc_params *params)

{

	if (params->key == IPC_PRIVATE)

		return ipcget_new(ns, ids, ops, params);

	else

		return ipcget_public(ns, ids, ops, params);

}

static int ipcget_public(struct ipc_namespace *ns, struct ipc_ids *ids, const struct ipc_ops *ops, struct ipc_params *params)

{

	struct kern_ipc_perm *ipcp;

	int flg = params->flg;

	int err;

	ipcp = ipc_findkey(ids, params->key);

	if (ipcp == NULL) {

		if (!(flg & IPC_CREAT))

			err = -ENOENT;

		else

			err = ops->getnew(ns, params);

	} else {

		if (flg & IPC_CREAT && flg & IPC_EXCL)

			err = -EEXIST;

		else {

			err = 0;

			if (ops->more_checks)

				err = ops->more_checks(ipcp, params);

......

		}

	}

	return err;

}

在 ipcget_public 中，我们会按照 key，去查找 struct kern_ipc_perm。如果没有找到，那就看是否设置了 IPC_CREAT；如果设置了，就创建一个新的。如果找到了，就将对应的 id 返回。

我们这里重点看，如何按照参数 shm_ops，创建新的共享内存，会调用 newseg。

static int newseg(struct ipc_namespace *ns, struct ipc_params *params)

{

	key_t key = params->key;

	int shmflg = params->flg;

	size_t size = params->u.size;

	int error;

	struct shmid_kernel *shp;

	size_t numpages = (size + PAGE_SIZE - 1) >> PAGE_SHIFT;

	struct file *file;

	char name[13];

	vm_flags_t acctflag = 0;

......

	shp = kvmalloc(sizeof(*shp), GFP_KERNEL);

......

	shp->shm_perm.key = key;

	shp->shm_perm.mode = (shmflg & S_IRWXUGO);

	shp->mlock_user = NULL;

	shp->shm_perm.security = NULL;

......

	file = shmem_kernel_file_setup(name, size, acctflag);

......

	shp->shm_cprid = task_tgid_vnr(current);

	shp->shm_lprid = 0;

	shp->shm_atim = shp->shm_dtim = 0;

	shp->shm_ctim = get_seconds();

	shp->shm_segsz = size;

	shp->shm_nattch = 0;

	shp->shm_file = file;

	shp->shm_creator = current;

	error = ipc_addid(&shm_ids(ns), &shp->shm_perm, ns->shm_ctlmni);

......

	list_add(&shp->shm_clist, &current->sysvshm.shm_clist);

......

	file_inode(file)->i_ino = shp->shm_perm.id;

	ns->shm_tot += numpages;

	error = shp->shm_perm.id;

......

	return error;

}

newseg 函数的第一步，通过 kvmalloc 在直接映射区分配一个 struct shmid_kernel 结构。这个结构就是用来描述共享内存的。这个结构最开始就是上面说的 struct kern_ipc_perm 结构。接下来就是填充这个 struct shmid_kernel 结构，例如 key、权限等。

newseg 函数的第二步，共享内存需要和文件进行关联。** 为什么要做这个呢？我们在讲内存映射的时候讲过，虚拟地址空间可以和物理内存关联，但是物理内存是某个进程独享的。虚拟地址空间也可以映射到一个文件，文件是可以跨进程共享的。

咱们这里的共享内存需要跨进程共享，也应该借鉴文件映射的思路。只不过不应该映射一个硬盘上的文件，而是映射到一个内存文件系统上的文件。mm/shmem.c 里面就定义了这样一个基于内存的文件系统。这里你一定要注意区分 shmem 和 shm 的区别，前者是一个文件系统，后者是进程通信机制。

在系统初始化的时候，shmem_init 注册了 shmem 文件系统 shmem_fs_type，并且挂在到了 shm_mnt 下面。

int __init shmem_init(void)

{

	int error;

	error = shmem_init_inodecache();

	error = register_filesystem(&shmem_fs_type);

	shm_mnt = kern_mount(&shmem_fs_type);

......

	return 0;

}

static struct file_system_type shmem_fs_type = {

	.owner		= THIS_MODULE,

	.name		= "tmpfs",

	.mount		= shmem_mount,

	.kill_sb	= kill_litter_super,

	.fs_flags	= FS_USERNS_MOUNT,

};

接下来，newseg 函数会调用 shmem_kernel_file_setup，其实就是在 shmem 文件系统里面创建一个文件。

/**

 * shmem_kernel_file_setup - get an unlinked file living in tmpfs which must be kernel internal.

 * @name: name for dentry (to be seen in /proc/<pid>/maps

 * @size: size to be set for the file

 * @flags: VM_NORESERVE suppresses pre-accounting of the entire object size */

struct file *shmem_kernel_file_setup(const char *name, loff_t size, unsigned long flags)

{

	return __shmem_file_setup(name, size, flags, S_PRIVATE);

}

static struct file *__shmem_file_setup(const char *name, loff_t size,

				       unsigned long flags, unsigned int i_flags)

{

	struct file *res;

	struct inode *inode;

	struct path path;

	struct super_block *sb;

	struct qstr this;

......

	this.name = name;

	this.len = strlen(name);

	this.hash = 0; /* will go */

	sb = shm_mnt->mnt_sb;

	path.mnt = mntget(shm_mnt);

	path.dentry = d_alloc_pseudo(sb, &this);

	d_set_d_op(path.dentry, &anon_ops);

......

	inode = shmem_get_inode(sb, NULL, S_IFREG | S_IRWXUGO, 0, flags);

	inode->i_flags |= i_flags;

	d_instantiate(path.dentry, inode);

	inode->i_size = size;

......

	res = alloc_file(&path, FMODE_WRITE | FMODE_READ,

		  &shmem_file_operations);

	return res;

}

__shmem_file_setup 会创建新的 shmem 文件对应的 dentry 和 inode，并将它们两个关联起来，然后分配一个 struct file 结构，来表示新的 shmem 文件，并且指向独特的 shmem_file_operations。

static const struct file_operations shmem_file_operations = {

	.mmap		= shmem_mmap,

	.get_unmapped_area = shmem_get_unmapped_area,

#ifdef CONFIG_TMPFS

	.llseek		= shmem_file_llseek,

	.read_iter	= shmem_file_read_iter,

	.write_iter	= generic_file_write_iter,

	.fsync		= noop_fsync,

	.splice_read	= generic_file_splice_read,

	.splice_write	= iter_file_splice_write,

	.fallocate	= shmem_fallocate,

#endif

};

newseg 函数的第三步，通过 ipc_addid 将新创建的 struct shmid_kernel 结构挂到 shm_ids 里面的基数树上，并返回相应的 id，并且将 struct shmid_kernel 挂到当前进程的 sysvshm 队列中。

至此，共享内存的创建就完成了。

如何将共享内存映射到虚拟地址空间？

从上面的代码解析中，我们知道，共享内存的数据结构 struct shmid_kernel，是通过它的成员 struct file *shm_file，来管理内存文件系统 shmem 上的内存文件的。无论这个共享内存是否被映射，shm_file 都是存在的。

接下来，我们要将共享内存映射到虚拟地址空间中。调用的是 shmat，对应的系统调用如下：

SYSCALL_DEFINE3(shmat, int, shmid, char __user *, shmaddr, int, shmflg)

{

    unsigned long ret;

    long err;

    err = do_shmat(shmid, shmaddr, shmflg, &ret, SHMLBA);

    force_successful_syscall_return();

    return (long)ret;

}

long do_shmat(int shmid, char __user *shmaddr, int shmflg,

	      ulong *raddr, unsigned long shmlba)

{

	struct shmid_kernel *shp;

	unsigned long addr = (unsigned long)shmaddr;

	unsigned long size;

	struct file *file;

	int    err;

	unsigned long flags = MAP_SHARED;

	unsigned long prot;

	int acc_mode;

	struct ipc_namespace *ns;

	struct shm_file_data *sfd;

	struct path path;

	fmode_t f_mode;

	unsigned long populate = 0;

......

	prot = PROT_READ | PROT_WRITE;

	acc_mode = S_IRUGO | S_IWUGO;

	f_mode = FMODE_READ | FMODE_WRITE;

......

	ns = current->nsproxy->ipc_ns;

	shp = shm_obtain_object_check(ns, shmid);

......

	path = shp->shm_file->f_path;

	path_get(&path);

	shp->shm_nattch++;

	size = i_size_read(d_inode(path.dentry));

......

	sfd = kzalloc(sizeof(*sfd), GFP_KERNEL);

......

	file = alloc_file(&path, f_mode,

			  is_file_hugepages(shp->shm_file) ?

				&shm_file_operations_huge :

				&shm_file_operations);

......

	file->private_data = sfd;

	file->f_mapping = shp->shm_file->f_mapping;

	sfd->id = shp->shm_perm.id;

	sfd->ns = get_ipc_ns(ns);

	sfd->file = shp->shm_file;

	sfd->vm_ops = NULL;

......

	addr = do_mmap_pgoff(file, addr, size, prot, flags, 0, &populate, NULL);

	*raddr = addr;

	err = 0;

......

	return err;

}

在这个函数里面，shm_obtain_object_check 会通过共享内存的 id，在基数树中找到对应的 struct shmid_kernel 结构，通过它找到 shmem 上的内存文件。

接下来，我们要分配一个 struct shm_file_data，来表示这个内存文件。将 shmem 中指向内存文件的 shm_file 赋值给 struct shm_file_data 中的 file 成员。

然后，我们创建了一个 struct file，指向的也是 shmem 中的内存文件。

为什么要再创建一个呢？这两个的功能不同，shmem 中 shm_file 用于管理内存文件，是一个中立的，独立于任何一个进程的角色。而新创建的 struct file 是专门用于做内存映射的，就像咱们在讲内存映射那一节讲过的，一个硬盘上的文件要映射到虚拟地址空间中的时候，需要在 vm_area_struct 里面有一个 struct file *vm_file 指向硬盘上的文件，现在变成内存文件了，但是这个结构还是不能少。

新创建的 struct file 的 private_data，指向 struct shm_file_data，这样内存映射那部分的数据结构，就能够通过它来访问内存文件了。

新创建的 struct file 的 file_operations 也发生了变化，变成了 shm_file_operations。

static const struct file_operations shm_file_operations = {

	.mmap		= shm_mmap,

	.fsync		= shm_fsync,

	.release	= shm_release,

	.get_unmapped_area	= shm_get_unmapped_area,

	.llseek		= noop_llseek,

	.fallocate	= shm_fallocate,

};

接下来，do_mmap_pgoff 函数我们遇到过，原来映射硬盘上的文件的时候，也是调用它。这里我们不再详细解析了。它会分配一个 vm_area_struct 指向虚拟地址空间中没有分配的区域，它的 vm_file 指向这个内存文件，然后它会调用 shm_file_operations 的 mmap 函数，也即 shm_mmap 进行映射。

static int shm_mmap(struct file *file, struct vm_area_struct *vma)

{

	struct shm_file_data *sfd = shm_file_data(file);

	int ret;

	ret = __shm_open(vma);

	ret = call_mmap(sfd->file, vma);

	sfd->vm_ops = vma->vm_ops;

	vma->vm_ops = &shm_vm_ops;

	return 0;

}

shm_mmap 中调用了 shm_file_data 中的 file 的 mmap 函数，这次调用的是 shmem_file_operations 的 mmap，也即 shmem_mmap。

static int shmem_mmap(struct file *file, struct vm_area_struct *vma)

{

	file_accessed(file);

	vma->vm_ops = &shmem_vm_ops;

	return 0;

}

这里面，vm_area_struct 的 vm_ops 指向 shmem_vm_ops。等从 call_mmap 中返回之后，shm_file_data 的 vm_ops 指向了 shmem_vm_ops，而 vm_area_struct 的 vm_ops 改为指向 shm_vm_ops。

我们来看一下，shm_vm_ops 和 shmem_vm_ops 的定义。

static const struct vm_operations_struct shm_vm_ops = {

	.open	= shm_open,	/* callback for a new vm-area open */

	.close	= shm_close,	/* callback for when the vm-area is released */

	.fault	= shm_fault,

};

static const struct vm_operations_struct shmem_vm_ops = {

	.fault		= shmem_fault,

	.map_pages	= filemap_map_pages,

};

它们里面最关键的就是 fault 函数，也即访问虚拟内存的时候，访问不到应该怎么办。

当访问不到的时候，先调用 vm_area_struct 的 vm_ops，也即 shm_vm_ops 的 fault 函数 shm_fault。然后它会转而调用 shm_file_data 的 vm_ops，也即 shmem_vm_ops 的 fault 函数 shmem_fault。

static int shm_fault(struct vm_fault *vmf)

{

	struct file *file = vmf->vma->vm_file;

	struct shm_file_data *sfd = shm_file_data(file);

	return sfd->vm_ops->fault(vmf);

}

虽然基于内存的文件系统，已经为这个内存文件分配了 inode，但是内存也却是一点儿都没分配，只有在发生缺页异常的时候才进行分配。

static int shmem_fault(struct vm_fault *vmf)

{

	struct vm_area_struct *vma = vmf->vma;

	struct inode *inode = file_inode(vma->vm_file);

	gfp_t gfp = mapping_gfp_mask(inode->i_mapping);

......

	error = shmem_getpage_gfp(inode, vmf->pgoff, &vmf->page, sgp,

				  gfp, vma, vmf, &ret);

......

}

/*

 * shmem_getpage_gfp - find page in cache, or get from swap, or allocate

 *

 * If we allocate a new one we do not mark it dirty. That's up to the

 * vm. If we swap it in we mark it dirty since we also free the swap

 * entry since a page cannot live in both the swap and page cache.

 *

 * fault_mm and fault_type are only supplied by shmem_fault:

 * otherwise they are NULL.

 */

static int shmem_getpage_gfp(struct inode *inode, pgoff_t index,

	struct page **pagep, enum sgp_type sgp, gfp_t gfp,

	struct vm_area_struct *vma, struct vm_fault *vmf, int *fault_type)

{

......

    page = shmem_alloc_and_acct_page(gfp, info, sbinfo,

					index, false);

......

}

shmem_fault 会调用 shmem_getpage_gfp 在 page cache 和 swap 中找一个空闲页，如果找不到就通过 shmem_alloc_and_acct_page 分配一个新的页，他最终会调用内存管理系统的 alloc_page_vma 在物理内存中分配一个页。

至此，共享内存才真的映射到了虚拟地址空间中，进程可以像访问本地内存一样访问共享内存。

总结时刻

我们来总结一下共享内存的创建和映射过程。

调用 shmget 创建共享内存。

先通过 ipc_findkey 在基数树中查找 key 对应的共享内存对象 shmid_kernel 是否已经被创建过，如果已经被创建，就会被查询出来，例如 producer 创建过，在 consumer 中就会查询出来。

如果共享内存没有被创建过，则调用 shm_ops 的 newseg 方法，创建一个共享内存对象 shmid_kernel。例如，在 producer 中就会新建。

在 shmem 文件系统里面创建一个文件，共享内存对象 shmid_kernel 指向这个文件，这个文件用 struct file 表示，我们姑且称它为 file1。

调用 shmat，将共享内存映射到虚拟地址空间。

shm_obtain_object_check 先从基数树里面找到 shmid_kernel 对象。

创建用于内存映射到文件的 file 和 shm_file_data，这里的 struct file 我们姑且称为 file2。

关联内存区域 vm_area_struct 和用于内存映射到文件的 file，也即 file2，调用 file2 的 mmap 函数。

file2 的 mmap 函数 shm_mmap，会调用 file1 的 mmap 函数 shmem_mmap，设置 shm_file_data 和 vm_area_struct 的 vm_ops。

内存映射完毕之后，其实并没有真的分配物理内存，当访问内存的时候，会触发缺页异常 do_page_fault。

vm_area_struct 的 vm_ops 的 shm_fault 会调用 shm_file_data 的 vm_ops 的 shmem_fault。

在 page cache 中找一个空闲页，或者创建一个空闲页。

课堂练习

在这里，我们只分析了 shm_ids 的结构，消息队列的程序我们写过了，但是 msg_ids 的结构没有解析，你可以试着解析一下。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

42 | IPC（下）：不同项目组之间抢资源，如何协调？

IPC 这块的内容比较多，为了让你能够更好地理解，我分成了三节来讲。前面我们解析完了共享内存的内核机制后，今天我们来看最后一部分，信号量的内核机制。

首先，我们需要创建一个信号量，调用的是系统调用 semget。代码如下：

SYSCALL_DEFINE3(semget, key_t, key, int, nsems, int, semflg)

{

	struct ipc_namespace *ns;

	static const struct ipc_ops sem_ops = {

		.getnew = newary,

		.associate = sem_security,

		.more_checks = sem_more_checks,

	};

	struct ipc_params sem_params;

	ns = current->nsproxy->ipc_ns;

	sem_params.key = key;

	sem_params.flg = semflg;

	sem_params.u.nsems = nsems;

	return ipcget(ns, &sem_ids(ns), &sem_ops, &sem_params);

}

我们解析过了共享内存，再看信号量，就顺畅很多了。这里同样调用了抽象的 ipcget，参数分别为信号量对应的 sem_ids、对应的操作 sem_ops 以及对应的参数 sem_params。

ipcget 的代码我们已经解析过了。如果 key 设置为 IPC_PRIVATE 则永远创建新的；如果不是的话，就会调用 ipcget_public。

在 ipcget_public 中，我们能会按照 key，去查找 struct kern_ipc_perm。如果没有找到，那就看看是否设置了 IPC_CREAT。如果设置了，就创建一个新的。如果找到了，就将对应的 id 返回。

我们这里重点看，如何按照参数 sem_ops，创建新的信号量会调用 newary。

static int newary(struct ipc_namespace *ns, struct ipc_params *params)

{

	int retval;

	struct sem_array *sma;

	key_t key = params->key;

	int nsems = params->u.nsems;

	int semflg = params->flg;

	int i;

......

	sma = sem_alloc(nsems);

......

	sma->sem_perm.mode = (semflg & S_IRWXUGO);

	sma->sem_perm.key = key;

	sma->sem_perm.security = NULL;

......

	for (i = 0; i < nsems; i++) {

		INIT_LIST_HEAD(&sma->sems[i].pending_alter);

		INIT_LIST_HEAD(&sma->sems[i].pending_const);

		spin_lock_init(&sma->sems[i].lock);

	}

	sma->complex_count = 0;

	sma->use_global_lock = USE_GLOBAL_LOCK_HYSTERESIS;

	INIT_LIST_HEAD(&sma->pending_alter);

	INIT_LIST_HEAD(&sma->pending_const);

	INIT_LIST_HEAD(&sma->list_id);

	sma->sem_nsems = nsems;

	sma->sem_ctime = get_seconds();

	retval = ipc_addid(&sem_ids(ns), &sma->sem_perm, ns->sc_semmni);

......

	ns->used_sems += nsems;

......

	return sma->sem_perm.id;

}

newary 函数的第一步，通过 kvmalloc 在直接映射区分配一个 struct sem_array 结构。这个结构是用来描述信号量的，这个结构最开始就是上面说的 struct kern_ipc_perm 结构。接下来就是填充这个 struct sem_array 结构，例如 key、权限等。

struct sem_array 里有多个信号量，放在 struct sem sems[] 数组里面，在 struct sem 里面有当前的信号量的数值 semval。

struct sem {

	int	semval;		/* current value */

	/*

	 * PID of the process that last modified the semaphore. For

	 * Linux, specifically these are:

	 *  - semop

	 *  - semctl, via SETVAL and SETALL.

	 *  - at task exit when performing undo adjustments (see exit_sem).

	 */

	int	sempid;

	spinlock_t	lock;	/* spinlock for fine-grained semtimedop */

	struct list_head pending_alter; /* pending single-sop operations that alter the semaphore */

	struct list_head pending_const; /* pending single-sop operations that do not alter the semaphore*/

	time_t	sem_otime;	/* candidate for sem_otime */

} ____cacheline_aligned_in_smp;

struct sem_array 和 struct sem 各有一个链表 struct list_head pending_alter，分别表示对于整个信号量数组的修改和对于某个信号量的修改。

newary 函数的第二步，就是初始化这些链表。

newary 函数的第三步，通过 ipc_addid 将新创建的 struct sem_array 结构，挂到 sem_ids 里面的基数树上，并返回相应的 id。

信号量创建的过程到此结束，接下来我们来看，如何通过 semctl 对信号量数组进行初始化。

SYSCALL_DEFINE4(semctl, int, semid, int, semnum, int, cmd, unsigned long, arg)

{

	int version;

	struct ipc_namespace *ns;

	void __user *p = (void __user *)arg;

	ns = current->nsproxy->ipc_ns;

	switch (cmd) {

	case IPC_INFO:

	case SEM_INFO:

	case IPC_STAT:

	case SEM_STAT:

		return semctl_nolock(ns, semid, cmd, version, p);

	case GETALL:

	case GETVAL:

	case GETPID:

	case GETNCNT:

	case GETZCNT:

	case SETALL:

		return semctl_main(ns, semid, semnum, cmd, p);

	case SETVAL:

		return semctl_setval(ns, semid, semnum, arg);

	case IPC_RMID:

	case IPC_SET:

		return semctl_down(ns, semid, cmd, version, p);

	default:

		return -EINVAL;

	}

}

这里我们重点看，SETALL 操作调用的 semctl_main 函数，以及 SETVAL 操作调用的 semctl_setval 函数。

对于 SETALL 操作来讲，传进来的参数为 union semun 里面的 unsigned short *array，会设置整个信号量集合。

static int semctl_main(struct ipc_namespace *ns, int semid, int semnum,

		int cmd, void __user *p)

{

	struct sem_array *sma;

	struct sem *curr;

	int err, nsems;

	ushort fast_sem_io[SEMMSL_FAST];

	ushort *sem_io = fast_sem_io;

	DEFINE_WAKE_Q(wake_q);

	sma = sem_obtain_object_check(ns, semid);

	nsems = sma->sem_nsems;

......

	switch (cmd) {

......

	case SETALL:

	{

		int i;

		struct sem_undo *un;

......

		if (copy_from_user(sem_io, p, nsems*sizeof(ushort))) {

......

		}

......

		for (i = 0; i < nsems; i++) {

			sma->sems[i].semval = sem_io[i];

			sma->sems[i].sempid = task_tgid_vnr(current);

		}

......

		sma->sem_ctime = get_seconds();

		/* maybe some queued-up processes were waiting for this */

		do_smart_update(sma, NULL, 0, 0, &wake_q);

		err = 0;

		goto out_unlock;

	}

	}

......

    wake_up_q(&wake_q);

......

}

在 semctl_main 函数中，先是通过 sem_obtain_object_check，根据信号量集合的 id 在基数树里面找到 struct sem_array 对象，发现如果是 SETALL 操作，就将用户的参数中的 unsigned short *array 通过 copy_from_user 拷贝到内核里面的 sem_io 数组，然后是一个循环，对于信号量集合里面的每一个信号量，设置 semval，以及修改这个信号量值的 pid。

对于 SETVAL 操作来讲，传进来的参数 union semun 里面的 int val，仅仅会设置某个信号量。

static int semctl_setval(struct ipc_namespace *ns, int semid, int semnum,

		unsigned long arg)

{

	struct sem_undo *un;

	struct sem_array *sma;

	struct sem *curr;

	int err, val;

	DEFINE_WAKE_Q(wake_q);

......

	sma = sem_obtain_object_check(ns, semid);

......

	curr = &sma->sems[semnum];

......

	curr->semval = val;

	curr->sempid = task_tgid_vnr(current);

	sma->sem_ctime = get_seconds();

	/* maybe some queued-up processes were waiting for this */

	do_smart_update(sma, NULL, 0, 0, &wake_q);

......

	wake_up_q(&wake_q);

	return 0;

}

在 semctl_setval 函数中，我们先是通过 sem_obtain_object_check，根据信号量集合的 id 在基数树里面找到 struct sem_array 对象，对于 SETVAL 操作，直接根据参数中的 val 设置 semval，以及修改这个信号量值的 pid。

至此，信号量数组初始化完毕。接下来我们来看 P 操作和 V 操作。无论是 P 操作，还是 V 操作都是调用 semop 系统调用。

SYSCALL_DEFINE3(semop, int, semid, struct sembuf __user *, tsops,

		unsigned, nsops)

{

	return sys_semtimedop(semid, tsops, nsops, NULL);

}

SYSCALL_DEFINE4(semtimedop, int, semid, struct sembuf __user *, tsops,

		unsigned, nsops, const struct timespec __user *, timeout)

{

	int error = -EINVAL;

	struct sem_array *sma;

	struct sembuf fast_sops[SEMOPM_FAST];

	struct sembuf *sops = fast_sops, *sop;

	struct sem_undo *un;

	int max, locknum;

	bool undos = false, alter = false, dupsop = false;

	struct sem_queue queue;

	unsigned long dup = 0, jiffies_left = 0;

	struct ipc_namespace *ns;

	ns = current->nsproxy->ipc_ns;

......

	if (copy_from_user(sops, tsops, nsops * sizeof(*tsops))) {

		error =  -EFAULT;

		goto out_free;

	}

	if (timeout) {

		struct timespec _timeout;

		if (copy_from_user(&_timeout, timeout, sizeof(*timeout))) {

		}

		jiffies_left = timespec_to_jiffies(&_timeout);

	}

......

	/* On success, find_alloc_undo takes the rcu_read_lock */

	un = find_alloc_undo(ns, semid);

......

	sma = sem_obtain_object_check(ns, semid);

......

	queue.sops = sops;

	queue.nsops = nsops;

	queue.undo = un;

	queue.pid = task_tgid_vnr(current);

	queue.alter = alter;

	queue.dupsop = dupsop;

	error = perform_atomic_semop(sma, &queue);

	if (error == 0) { /* non-blocking succesfull path */

		DEFINE_WAKE_Q(wake_q);

......

		do_smart_update(sma, sops, nsops, 1, &wake_q);

......

		wake_up_q(&wake_q);

		goto out_free;

	}

	/*

	 * We need to sleep on this operation, so we put the current

	 * task into the pending queue and go to sleep.

	 */

	if (nsops == 1) {

		struct sem *curr;

		curr = &sma->sems[sops->sem_num];

......

		list_add_tail(&queue.list,

						&curr->pending_alter);

......

	} else {

......

		list_add_tail(&queue.list, &sma->pending_alter);

......

	}

	do {

		queue.status = -EINTR;

		queue.sleeper = current;

		__set_current_state(TASK_INTERRUPTIBLE);

		if (timeout)

			jiffies_left = schedule_timeout(jiffies_left);

		else

			schedule();

......

		/*

		 * If an interrupt occurred we have to clean up the queue.

		 */

		if (timeout && jiffies_left == 0)

			error = -EAGAIN;

	} while (error == -EINTR && !signal_pending(current)); /* spurious */

......

}

semop 会调用 semtimedop，这是一个非常复杂的函数。

semtimedop 做的第一件事情，就是将用户的参数，例如，对于信号量的操作 struct sembuf，拷贝到内核里面来。另外，如果是 P 操作，很可能让进程进入等待状态，是否要为这个等待状态设置一个超时，timeout 也是一个参数，会把它变成时钟的滴答数目。

semtimedop 做的第二件事情，是通过 sem_obtain_object_check，根据信号量集合的 id，获得 struct sem_array，然后，创建一个 struct sem_queue 表示当前的信号量操作。为什么叫 queue 呢？因为这个操作可能马上就能完成，也可能因为无法获取信号量不能完成，不能完成的话就只好排列到队列上，等待信号量满足条件的时候。semtimedop 会调用 perform_atomic_semop 在实施信号量操作。

static int perform_atomic_semop(struct sem_array *sma, struct sem_queue *q)

{

	int result, sem_op, nsops;

	struct sembuf *sop;

	struct sem *curr;

	struct sembuf *sops;

	struct sem_undo *un;

	sops = q->sops;

	nsops = q->nsops;

	un = q->undo;

	for (sop = sops; sop < sops + nsops; sop++) {

		curr = &sma->sems[sop->sem_num];

		sem_op = sop->sem_op;

		result = curr->semval;

......

		result += sem_op;

		if (result < 0)

			goto would_block;

......

		if (sop->sem_flg & SEM_UNDO) {

			int undo = un->semadj[sop->sem_num] - sem_op;

.....

		}

	}

	for (sop = sops; sop < sops + nsops; sop++) {

		curr = &sma->sems[sop->sem_num];

		sem_op = sop->sem_op;

		result = curr->semval;

		if (sop->sem_flg & SEM_UNDO) {

			int undo = un->semadj[sop->sem_num] - sem_op;

			un->semadj[sop->sem_num] = undo;

		}

		curr->semval += sem_op;

		curr->sempid = q->pid;

	}

	return 0;

would_block:

	q->blocking = sop;

	return sop->sem_flg & IPC_NOWAIT ? -EAGAIN : 1;

}

在 perform_atomic_semop 函数中，对于所有信号量操作都进行两次循环。在第一次循环中，如果发现计算出的 result 小于 0，则说明必须等待，于是跳到 would_block 中，设置 q->blocking = sop 表示这个 queue 是 block 在这个操作上，然后如果需要等待，则返回 1。如果第一次循环中发现无需等待，则第二个循环实施所有的信号量操作，将信号量的值设置为新的值，并且返回 0。

接下来，我们回到 semtimedop，来看它干的第三件事情，就是如果需要等待，应该怎么办？

如果需要等待，则要区分刚才的对于信号量的操作，是对一个信号量的，还是对于整个信号量集合的。如果是对于一个信号量的，那我们就将 queue 挂到这个信号量的 pending_alter 中；如果是对于整个信号量集合的，那我们就将 queue 挂到整个信号量集合的 pending_alter 中。

接下来的 do-while 循环，就是要开始等待了。如果等待没有时间限制，则调用 schedule 让出 CPU；如果等待有时间限制，则调用 schedule_timeout 让出 CPU，过一段时间还回来。当回来的时候，判断是否等待超时，如果没有等待超时则进入下一轮循环，再次等待，如果超时则退出循环，返回错误。在让出 CPU 的时候，设置进程的状态为 TASK_INTERRUPTIBLE，并且循环的结束会通过 signal_pending 查看是否收到过信号，这说明这个等待信号量的进程是可以被信号中断的，也即一个等待信号量的进程是可以通过 kill 杀掉的。

我们再来看，semtimedop 要做的第四件事情，如果不需要等待，应该怎么办？

如果不需要等待，就说明对于信号量的操作完成了，也改变了信号量的值。接下来，就是一个标准流程。我们通过 DEFINE_WAKE_Q(wake_q) 声明一个 wake_q，调用 do_smart_update，看这次对于信号量的值的改变，可以影响并可以激活等待队列中的哪些 struct sem_queue，然后把它们都放在 wake_q 里面，调用 wake_up_q 唤醒这些进程。其实，所有的对于信号量的值的修改都会涉及这三个操作，如果你回过头去仔细看 SETALL 和 SETVAL 操作，在设置完毕信号量之后，也是这三个操作。

我们来看 do_smart_update 是如何实现的。do_smart_update 会调用 update_queue。

static int update_queue(struct sem_array *sma, int semnum, struct wake_q_head *wake_q)

{

	struct sem_queue *q, *tmp;

	struct list_head *pending_list;

	int semop_completed = 0;

	if (semnum == -1)

		pending_list = &sma->pending_alter;

	else

		pending_list = &sma->sems[semnum].pending_alter;

again:

	list_for_each_entry_safe(q, tmp, pending_list, list) {

		int error, restart;

......

		error = perform_atomic_semop(sma, q);

		/* Does q->sleeper still need to sleep? */

		if (error > 0)

			continue;

		unlink_queue(sma, q);

......

		wake_up_sem_queue_prepare(q, error, wake_q);

......

	}

	return semop_completed;

}

static inline void wake_up_sem_queue_prepare(struct sem_queue *q, int error,

					     struct wake_q_head *wake_q)

{

	wake_q_add(wake_q, q->sleeper);

......

}

update_queue 会依次循环整个信号量集合的等待队列 pending_alter，或者某个信号量的等待队列。试图在信号量的值变了的情况下，再次尝试 perform_atomic_semop 进行信号量操作。如果不成功，则尝试队列中的下一个；如果尝试成功，则调用 unlink_queue 从队列上取下来，然后调用 wake_up_sem_queue_prepare，将 q->sleeper 加到 wake_q 上去。q->sleeper 是一个 task_struct，是等待在这个信号量操作上的进程。

接下来，wake_up_q 就依次唤醒 wake_q 上的所有 task_struct，调用的是我们在进程调度那一节学过的 wake_up_process 方法。

void wake_up_q(struct wake_q_head *head)

{

	struct wake_q_node *node = head->first;

	while (node != WAKE_Q_TAIL) {

		struct task_struct *task;

		task = container_of(node, struct task_struct, wake_q);

		node = node->next;

		task->wake_q.next = NULL;

		wake_up_process(task);

		put_task_struct(task);

	}

}

至此，对于信号量的主流操作都解析完毕了。

其实还有一点需要强调一下，信号量是一个整个 Linux 可见的全局资源，而不像咱们在线程同步那一节讲过的都是某个进程独占的资源，好处是可以跨进程通信，坏处就是如果一个进程通过 P 操作拿到了一个信号量，但是不幸异常退出了，如果没有来得及归还这个信号量，可能所有其他的进程都阻塞了。

那怎么办呢？Linux 有一种机制叫 SEM_UNDO，也即每一个 semop 操作都会保存一个反向 struct sem_undo 操作，当因为某个进程异常退出的时候，这个进程做的所有的操作都会回退，从而保证其他进程可以正常工作。

如果你回头看，我们写的程序里面的 semaphore_p 函数和 semaphore_v 函数，都把 sem_flg 设置为 SEM_UNDO，就是这个作用。

等待队列上的每一个 struct sem_queue，都有一个 struct sem_undo，以此来表示这次操作的反向操作。

struct sem_queue {

	struct list_head	list;	 /* queue of pending operations */

	struct task_struct	*sleeper; /* this process */

	struct sem_undo		*undo;	 /* undo structure */

	int			pid;	 /* process id of requesting process */

	int			status;	 /* completion status of operation */

	struct sembuf		*sops;	 /* array of pending operations */

	struct sembuf		*blocking; /* the operation that blocked */

	int			nsops;	 /* number of operations */

	bool			alter;	 /* does *sops alter the array? */

	bool                    dupsop;	 /* sops on more than one sem_num */

};

在进程的 task_struct 里面对于信号量有一个成员 struct sysv_sem，里面是一个 struct sem_undo_list，将这个进程所有的 semop 所带来的 undo 操作都串起来。

struct task_struct {

......

struct sysv_sem			sysvsem;

......

}

struct sysv_sem {

	struct sem_undo_list *undo_list;

};

struct sem_undo {

	struct list_head	list_proc;	/* per-process list: *

						 * all undos from one process

						 * rcu protected */

	struct rcu_head		rcu;		/* rcu struct for sem_undo */

	struct sem_undo_list	*ulp;		/* back ptr to sem_undo_list */

	struct list_head	list_id;	/* per semaphore array list:

						 * all undos for one array */

	int			semid;		/* semaphore set identifier */

	short			*semadj;	/* array of adjustments */

						/* one per semaphore */

};

struct sem_undo_list {

	atomic_t		refcnt;

	spinlock_t		lock;

	struct list_head	list_proc;

};

为了让你更清楚地理解 struct sem_undo 的原理，我们这里举一个例子。

假设我们创建了两个信号量集合。一个叫 semaphore1，它包含三个信号量，初始化值为 3，另一个叫 semaphore2，它包含 4 个信号量，初始化值都为 4。初始化时候的信号量以及 undo 结构里面的值如图中 (1) 标号所示。

首先，我们来看进程 1。我们调用 semop，将 semaphore1 的三个信号量的值，分别加 1、加 2 和减 3，从而信号量的值变为 4,5,0。于是在 semaphore1 和进程 1 链表交汇的 undo 结构里面，填写 -1,-2,+3，是 semop 操作的反向操作，如图中 (2) 标号所示。

然后，我们来看进程 2。我们调用 semop，将 semaphore1 的三个信号量的值，分别减 3、加 2 和加 1，从而信号量的值变为 1、7、1。于是在 semaphore1 和进程 2 链表交汇的 undo 结构里面，填写 +3、-2、-1，是 semop 操作的反向操作，如图中 (3) 标号所示。

然后，我们接着看进程 2。我们调用 semop，将 semaphore2 的四个信号量的值，分别减 3、加 1、加 4 和减 1，从而信号量的值变为 1、5、8、3。于是，在 semaphore2 和进程 2 链表交汇的 undo 结构里面，填写 +3、-1、-4、+1，是 semop 操作的反向操作，如图中 (4) 标号所示。

然后，我们再来看进程 1。我们调用 semop，将 semaphore2 的四个信号量的值，分别减 1、减 4、减 5 和加 2，从而信号量的值变为 0、1、3、5。于是在 semaphore2 和进程 1 链表交汇的 undo 结构里面，填写 +1、+4、+5、-2，是 semop 操作的反向操作，如图中 (5) 标号所示。

从这个例子可以看出，无论哪个进程异常退出，只要将 undo 结构里面的值加回当前信号量的值，就能够得到正确的信号量的值，不会因为一个进程退出，导致信号量的值处于不一致的状态。

总结时刻

信号量的机制也很复杂，我们对着下面这个图总结一下。

调用 semget 创建信号量集合。

ipc_findkey 会在基数树中，根据 key 查找信号量集合 sem_array 对象。如果已经被创建，就会被查询出来。例如 producer 被创建过，在 consumer 中就会查询出来。

如果信号量集合没有被创建过，则调用 sem_ops 的 newary 方法，创建一个信号量集合对象 sem_array。例如，在 producer 中就会新建。

调用 semctl(SETALL) 初始化信号量。

sem_obtain_object_check 先从基数树里面找到 sem_array 对象。

根据用户指定的信号量数组，初始化信号量集合，也即初始化 sem_array 对象的 struct sem sems[] 成员。

调用 semop 操作信号量。

创建信号量操作结构 sem_queue，放入队列。

创建 undo 结构，放入链表。

课堂练习

现在，我们的共享内存、信号量、消息队列都讲完了，你是不是觉得，它们的 API 非常相似。为了方便记忆，你可以自己整理一个表格，列一下这三种进程间通信机制、行为创建 xxxget、使用、控制 xxxctl、对应的 API 和系统调用。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

43 预习 | Socket通信之网络协议基本原理

上一节我们讲的进程间通信，其实是通过内核的数据结构完成的，主要用于在一台 Linux 上两个进程之间的通信。但是，一旦超出一台机器的范畴，我们就需要一种跨机器的通信机制。

一台机器将自己想要表达的内容，按照某种约定好的格式发送出去，当另外一台机器收到这些信息后，也能够按照约定好的格式解析出来，从而准确、可靠地获得发送方想要表达的内容。这种约定好的格式就是网络协议（Networking Protocol）。

我们将要讲的 Socket 通信以及相关的系统调用、内核机制，都是基于网络协议的，如果不了解网络协议的机制，解析 Socket 的过程中，你就会迷失方向，因此这一节，我们有必要做一个预习，先来大致讲一下网络协议的基本原理。

网络为什么要分层？

我们这里先构建一个相对简单的场景，之后几节内容，我们都要基于这个场景进行讲解。

我们假设这里就涉及三台机器。Linux 服务器 A 和 Linux 服务器 B 处于不同的网段，通过中间的 Linux 服务器作为路由器进行转发。

说到网络协议，我们还需要简要介绍一下两种网络协议模型，一种是OSI 的标准七层模型，一种是业界标准的 TCP/IP 模型。它们的对应关系如下图所示：

为什么网络要分层呢？因为网络环境过于复杂，不是一个能够集中控制的体系。全球数以亿记的服务器和设备各有各的体系，但是都可以通过同一套网络协议栈通过切分成多个层次和组合，来满足不同服务器和设备的通信需求。

我们这里简单介绍一下网络协议的几个层次。

我们从哪一个层次开始呢？从第三层，网络层开始，因为这一层有我们熟悉的 IP 地址。也因此，这一层我们也叫 IP 层。

我们通常看到的 IP 地址都是这个样子的：192.168.1.100/24。斜杠前面是 IP 地址，这个地址被点分隔为四个部分，每个部分 8 位，总共是 32 位。斜线后面 24 的意思是，32 位中，前 24 位是网络号，后 8 位是主机号。

为什么要这样分呢？我们可以想象，虽然全世界组成一张大的互联网，美国的网站你也能够访问的，但是这个网络不是一整个的。你们小区有一个网络，你们公司也有一个网络，联通、移动、电信运营商也各有各的网络，所以一个大网络是被分成个小的网络。

那如何区分这些网络呢？这就是网络号的概念。一个网络里面会有多个设备，这些设备的网络号一样，主机号不一样。不信你可以观察一下你家里的手机、电视、电脑。

连接到网络上的每一个设备都至少有一个 IP 地址，用于定位这个设备。无论是近在咫尺的你旁边同学的电脑，还是远在天边的电商网站，都可以通过 IP 地址进行定位。因此，IP 地址类似互联网上的邮寄地址，是有全局定位功能的。

就算你要访问美国的一个地址，也可以从你身边的网络出发，通过不断的打听道儿，经过多个网络，最终到达目的地址，和快递员送包裹的过程差不多。打听道儿的协议也在第三层，称为路由协议（Routing protocol），将网络包从一个网络转发给另一个网络的设备称为路由器。

路由器和路由协议十分复杂，我们这里就不详细讲解了，感兴趣可以去看我写的另一个专栏“趣谈网络协议”里的相关文章。

总而言之，第三层干的事情，就是网络包从一个起始的 IP 地址，沿着路由协议指的道儿，经过多个网络，通过多次路由器转发，到达目标 IP 地址。

从第三层，我们往下看，第二层是数据链路层。有时候我们简称为二层或者 MAC 层。所谓 MAC，就是每个网卡都有的唯一的硬件地址（不绝对唯一，相对大概率唯一即可，类比UUID）。这虽然也是一个地址，但是这个地址是没有全局定位功能的。

就像给你送外卖的小哥，不可能根据手机尾号找到你家，但是手机尾号有本地定位功能的，只不过这个定位主要靠“吼”。外卖小哥到了你的楼层就开始大喊：“尾号 xxxx 的，你外卖到了！”

MAC 地址的定位功能局限在一个网络里面，也即同一个网络号下的 IP 地址之间，可以通过 MAC 进行定位和通信。从 IP 地址获取 MAC 地址要通过 ARP 协议，是通过在本地发送广播包，也就是“吼”，获得的 MAC 地址。

由于同一个网络内的机器数量有限，通过 MAC 地址的好处就是简单。匹配上 MAC 地址就接收，匹配不上就不接收，没有什么所谓路由协议这样复杂的协议。当然坏处就是，MAC 地址的作用范围不能出本地网络，所以一旦跨网络通信，虽然 IP 地址保持不变，但是 MAC 地址每经过一个路由器就要换一次。

我们看前面的图。服务器 A 发送网络包给服务器 B，原 IP 地址始终是 192.168.1.100，目标 IP 地址始终是 192.168.2.100，但是在网络 1 里面，原 MAC 地址是 MAC1，目标 MAC 地址是路由器的 MAC2，路由器转发之后，原 MAC 地址是路由器的 MAC3，目标 MAC 地址是 MAC4。

所以第二层干的事情，就是网络包在本地网络中的服务器之间定位及通信的机制。

我们再往下看，第一层，物理层，这一层就是物理设备。例如连着电脑的网线，我们能连上的 WiFi，这一层我们不打算进行分析。

从第三层往上看，第四层是传输层，这里面有两个著名的协议 TCP 和 UDP。尤其是 TCP，更是广泛使用，在 IP 层的代码逻辑中，仅仅负责数据从一个 IP 地址发送给另一个 IP 地址，丢包、乱序、重传、拥塞，这些 IP 层都不管。处理这些问题的代码逻辑写在了传输层的 TCP 协议里面。

我们常称，TCP 是可靠传输协议，也是难为它了。因为从第一层到第三层都不可靠，网络包说丢就丢，是 TCP 这一层通过各种编号、重传等机制，让本来不可靠的网络对于更上层来讲，变得“看起来”可靠。哪有什么应用层岁月静好，只不过 TCP 层帮你负重前行。

传输层再往上就是应用层，例如咱们在浏览器里面输入的 HTTP，Java 服务端写的 Servlet，都是这一层的。

二层到四层都是在 Linux 内核里面处理的，应用层例如浏览器、Nginx、Tomcat 都是用户态的。内核里面对于网络包的处理是不区分应用的。

从四层再往上，就需要区分网络包发给哪个应用。在传输层的 TCP 和 UDP 协议里面，都有端口的概念，不同的应用监听不同的端口。例如，服务端 Nginx 监听 80、Tomcat 监听 8080；再如客户端浏览器监听一个随机端口，FTP 客户端监听另外一个随机端口。

应用层和内核互通的机制，就是通过 Socket 系统调用。所以经常有人会问，Socket 属于哪一层，其实它哪一层都不属于，它属于操作系统的概念，而非网络协议分层的概念。只不过操作系统选择对于网络协议的实现模式是，二到四层的处理代码在内核里面，七层的处理代码让应用自己去做，两者需要跨内核态和用户态通信，就需要一个系统调用完成这个衔接，这就是 Socket。

发送数据包

网络分完层之后，对于数据包的发送，就是层层封装的过程。

就像下面的图中展示的一样，在 Linux 服务器 B 上部署的服务端 Nginx 和 Tomcat，都是通过 Socket 监听 80 和 8080 端口。这个时候，内核的数据结构就知道了。如果遇到发送到这两个端口的，就发送给这两个进程。

在 Linux 服务器 A 上的客户端，打开一个 Firefox 连接 Ngnix。也是通过 Socket，客户端会被分配一个随机端口 12345。同理，打开一个 Chrome 连接 Tomcat，同样通过 Socket 分配随机端口 12346。

在客户端浏览器，我们将请求封装为 HTTP 协议，通过 Socket 发送到内核。内核的网络协议栈里面，在 TCP 层创建用于维护连接、序列号、重传、拥塞控制的数据结构，将 HTTP 包加上 TCP 头，发送给 IP 层，IP 层加上 IP 头，发送给 MAC 层，MAC 层加上 MAC 头，从硬件网卡发出去。

网络包会先到达网络 1 的交换机。我们常称交换机为二层设备，这是因为，交换机只会处理到第二层，然后它会将网络包的 MAC 头拿下来，发现目标 MAC 是在自己右面的网口，于是就从这个网口发出去。

网络包会到达中间的 Linux 路由器，它左面的网卡会收到网络包，发现 MAC 地址匹配，就交给 IP 层，在 IP 层根据 IP 头中的信息，在路由表中查找。下一跳在哪里，应该从哪个网口发出去？在这个例子中，最终会从右面的网口发出去。我们常把路由器称为三层设备，因为它只会处理到第三层。

从路由器右面的网口发出去的包会到网络 2 的交换机，还是会经历一次二层的处理，转发到交换机右面的网口。

最终网络包会被转发到 Linux 服务器 B，它发现 MAC 地址匹配，就将 MAC 头取下来，交给上一层。IP 层发现 IP 地址匹配，将 IP 头取下来，交给上一层。TCP 层会根据 TCP 头中的序列号等信息，发现它是一个正确的网络包，就会将网络包缓存起来，等待应用层的读取。

应用层通过 Socket 监听某个端口，因而读取的时候，内核会根据 TCP 头中的端口号，将网络包发给相应的应用。

HTTP 层的头和正文，是应用层来解析的。通过解析，应用层知道了客户端的请求，例如购买一个商品，还是请求一个网页。当应用层处理完 HTTP 的请求，会将结果仍然封装为 HTTP 的网络包，通过 Socket 接口，发送给内核。

内核会经过层层封装，从物理网口发送出去，经过网络 2 的交换机，Linux 路由器到达网络 1，经过网络 1 的交换机，到达 Linux 服务器 A。在 Linux 服务器 A 上，经过层层解封装，通过 socket 接口，根据客户端的随机端口号，发送给客户端的应用程序，浏览器。于是浏览器就能够显示出一个绚丽多彩的页面了。

即便在如此简单的一个环境中，网络包的发送过程，竟然如此的复杂。不过这一章后面，我们还是会层层剖析每一层做的事情。

总结时刻

网络协议是一个大话题，如果你想了解网络协议的方方面面，欢迎你订阅我写的另一个专栏“趣谈网络协议”。这个专栏重点解析在这个网络通信过程中，发送端和接收端的操作系统都做了哪些事情，对于中间通路上的的复杂的网络通信逻辑没有做深入解析。

如果只是为了掌握这一章的内容，这一节我们讲的网络协议的七个层次，你不必每一层的每一个协议都很清楚，只要记住 TCP/UDP->IPv4->ARP 这一条链就可以了，因为后面我们的分析都是重点分析这条链。

另外，前面那个简单的拓扑图中，网络包的封装、转发、解封装的过程，建议你多看几遍，了熟于心，因为接下来，我们就能从代码层面，看到这个过程。到时候，对应起来，你就比较容易理解。

了解了 Socket 的基本原理，下一篇文章，我们就来看一看在 Linux 操作系统里面 Socket 系统调用的接口是什么样的。

43 | Socket通信：遇上特大项目，要学会和其他公司合作

上一篇预习文章说了这么多，现在我们终于可以来看一下，在应用层，我们应该如何使用 socket 的接口来进行通信。

如果你对 socket 相关的网络协议原理不是非常了解，建议你先去看一看上一篇的预习文章，再来看这一篇的内容，就会比较轻松。

按照前一篇文章说的分层机制，我们可以想到，socket 接口大多数情况下操作的是传输层，更底层的协议不用它来操心，这就是分层的好处。

在传输层有两个主流的协议 TCP 和 UDP，所以我们的 socket 程序设计也是主要操作这两个协议。这两个协议的区别是什么呢？通常的答案是下面这样的。

TCP 是面向连接的，UDP 是面向无连接的。

TCP 提供可靠交付，无差错、不丢失、不重复、并且按序到达；UDP 不提供可靠交付，不保证不丢失，不保证按顺序到达。

TCP 是面向字节流的，发送时发的是一个流，没头没尾；UDP 是面向数据报的，一个一个的发送。

TCP 是可以提供流量控制和拥塞控制的，既防止对端被压垮，也防止网络被压垮。

这些答案没有问题，但是没有到达本质，也经常让人产生错觉。例如，下面这些问题，你看看你是否了解？

所谓的连接，容易让人误以为，使用 TCP 会使得两端之间的通路和使用 UDP 不一样，那我们会在沿途建立一条线表示这个连接吗？

我从中国访问美国网站，中间这么多环节，我怎么保证连接不断呢？

中间有个网络管理员拔了一根网线不就断了吗？我不能控制它，它也不会通知我，我一个个人电脑怎么能够保持连接呢？

还让我做流量控制和拥塞控制，我既管不了中间的链路，也管不了对端的服务器呀，我怎么能够做到？

按照网络分层，TCP 和 UDP 都是基于 IP 协议的，IP 都不能保证可靠，说丢就丢，TCP 怎么能够保证呢？

IP 层都是一个包一个包的发送，TCP 怎么就变成流了？

从本质上来讲，所谓的建立连接，其实是为了在客户端和服务端维护连接，而建立一定的数据结构来维护双方交互的状态，并用这样的数据结构来保证面向连接的特性。TCP 无法左右中间的任何通路，也没有什么虚拟的连接，中间的通路根本意识不到两端使用了 TCP 还是 UDP。

所谓的连接，就是两端数据结构状态的协同，两边的状态能够对得上。符合 TCP 协议的规则，就认为连接存在；两面状态对不上，连接就算断了。

流量控制和拥塞控制其实就是根据收到的对端的网络包，调整两端数据结构的状态。TCP 协议的设计理论上认为，这样调整了数据结构的状态，就能进行流量控制和拥塞控制了，其实在通路上是不是真的做到了，谁也管不着。

所谓的可靠，也是两端的数据结构做的事情。不丢失其实是数据结构在“点名”，顺序到达其实是数据结构在“排序”，面向数据流其实是数据结构将零散的包，按照顺序捏成一个流发给应用层。总而言之，“连接”两个字让人误以为功夫在通路，其实功夫在两端。

当然，无论是用 socket 操作 TCP，还是 UDP，我们首先都要调用 socket 函数。

int socket(int domain, int type, int protocol);

socket 函数用于创建一个 socket 的文件描述符，唯一标识一个 socket。我们把它叫作文件描述符，因为在内核中，我们会创建类似文件系统的数据结构，并且后续的操作都有用到它。

socket 函数有三个参数。

domain：表示使用什么 IP 层协议。AF_INET 表示 IPv4，AF_INET6 表示 IPv6。

type：表示 socket 类型。SOCK_STREAM，顾名思义就是 TCP 面向流的，SOCK_DGRAM 就是 UDP 面向数据报的，SOCK_RAW 可以直接操作 IP 层，或者非 TCP 和 UDP 的协议。例如 ICMP。

protocol 表示的协议，包括 IPPROTO_TCP、IPPTOTO_UDP。

通信结束后，我们还要像关闭文件一样，关闭 socket。

针对 TCP 应该如何编程？

接下来我们来看，针对 TCP，我们应该如何编程。

TCP 的服务端要先监听一个端口，一般是先调用 bind 函数，给这个 socket 赋予一个端口和 IP 地址。

int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

struct sockaddr_in {

  __kernel_sa_family_t	sin_family;	/* Address family		*/

  __be16		sin_port;	/* Port number			*/

  struct in_addr	sin_addr;	/* Internet address		*/

  /* Pad to size of `struct sockaddr'. */

  unsigned char		__pad[__SOCK_SIZE__ - sizeof(short int) -

			sizeof(unsigned short int) - sizeof(struct in_addr)];

};

struct in_addr {

	__be32	s_addr;

};

其中，sockfd 是上面我们创建的 socket 文件描述符。在 sockaddr_in 结构中，sin_family 设置为 AF_INET，表示 IPv4；sin_port 是端口号；sin_addr 是 IP 地址。

服务端所在的服务器可能有多个网卡、多个地址，可以选择监听在一个地址，也可以监听 0.0.0.0 表示所有的地址都监听。服务端一般要监听在一个众所周知的端口上，例如，Nginx 一般是 80，Tomcat 一般是 8080。

客户端要访问服务端，肯定事先要知道服务端的端口。无论是电商，还是游戏，还是视频，如果你仔细观察，会发现都有一个这样的端口。可能你会发现，客户端不需要 bind，因为浏览器嘛，随机分配一个端口就可以了，只有你主动去连接别人，别人不会主动连接你，没有人关心客户端监听到了哪里。

如果你看上面代码中的数据结构，里面的变量名称都有“be”两个字母，代表的意思是“big-endian”。如果在网络上传输超过 1 Byte 的类型，就要区分大端（Big Endian）和小端（Little Endian）。

假设，我们要在 32 位 4 Bytes 的一个空间存放整数 1，很显然只要 1 Byte 放 1，其他 3 Bytes 放 0 就可以了。那问题是，最后一个 Byte 放 1 呢，还是第一个 Byte 放 1 呢？或者说，1 作为最低位，应该放在 32 位的最后一个位置呢，还是放在第一个位置呢？

最低位放在最后一个位置，我们叫作小端，最低位放在第一个位置，叫作大端。TCP/IP 栈是按照大端来设计的，而 x86 机器多按照小端来设计，因而发出去时需要做一个转换。

接下来，就要建立 TCP 的连接了，也就是著名的三次握手，其实就是将客户端和服务端的状态通过三次网络交互，达到初始状态是协同的状态。下图就是三次握手的序列图以及对应的状态转换。

接下来，服务端要调用 listen 进入 LISTEN 状态，等待客户端进行连接。

int listen(int sockfd, int backlog);

连接的建立过程，也即三次握手，是 TCP 层的动作，是在内核完成的，应用层不需要参与。

接着，服务端只需要调用 accept，等待内核完成了至少一个连接的建立，才返回。如果没有一个连接完成了三次握手，accept 就一直等待；如果有多个客户端发起连接，并且在内核里面完成了多个三次握手，建立了多个连接，这些连接会被放在一个队列里面。accept 会从队列里面取出一个来进行处理。如果想进一步处理其他连接，需要调用多次 accept，所以 accept 往往在一个循环里面。

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

接下来，客户端可以通过 connect 函数发起连接。

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

我们先在参数中指明要连接的 IP 地址和端口号，然后发起三次握手。内核会给客户端分配一个临时的端口。一旦握手成功，服务端的 accept 就会返回另一个 socket。

这里需要注意的是，监听的 socket 和真正用来传送数据的 socket，是两个 socket，一个叫作监听 socket，一个叫作已连接 socket。成功连接建立之后，双方开始通过 read 和 write 函数来读写数据，就像往一个文件流里面写东西一样。

针对 UDP 应该如何编程？

接下来我们来看，针对 UDP 应该如何编程。

UDP 是没有连接的，所以不需要三次握手，也就不需要调用 listen 和 connect，但是 UDP 的交互仍然需要 IP 地址和端口号，因而也需要 bind。

对于 UDP 来讲，没有所谓的连接维护，也没有所谓的连接的发起方和接收方，甚至都不存在客户端和服务端的概念，大家就都是客户端，也同时都是服务端。只要有一个 socket，多台机器就可以任意通信，不存在哪两台机器是属于一个连接的概念。因此，每一个 UDP 的 socket 都需要 bind。每次通信时，调用 sendto 和 recvfrom，都要传入 IP 地址和端口。

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

总结时刻

这一节我们讲了网络协议的基本原理和 socket 系统调用，这里请你重点关注 TCP 协议的系统调用。

通过学习，我们知道，socket 系统调用是用户态和内核态的接口，网络协议的四层以下都是在内核中的。很多的书籍会讲如何开发一个高性能的 socket 程序，但是这不是我们这门课的重点，所以我们主要看内核里面的机制就行了。

因此，你需要记住 TCP 协议的 socket 调用的过程。我们接下来就按照这个顺序，依次回忆一下这些系统调用到内核都做了什么：

服务端和客户端都调用 socket，得到文件描述符；

服务端调用 listen，进行监听；

服务端调用 accept，等待客户端连接；

客户端调用 connect，连接服务端；

服务端 accept 返回用于传输的 socket 的文件描述符；

客户端调用 write 写入数据；

服务端调用 read 读取数据。

课堂练习

请你根据今天讲的 socket 系统调用，写一个简单的 socket 程序来传输一个字符串。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

44 | Socket内核数据结构：如何成立特大项目合作部？

上一节我们讲了 Socket 在 TCP 和 UDP 场景下的调用流程。这一节，我们就沿着这个流程到内核里面一探究竟，看看在内核里面，都创建了哪些数据结构，做了哪些事情。

解析 socket 函数

我们从 Socket 系统调用开始。

SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)

{

	int retval;

	struct socket *sock;

	int flags;

......

	if (SOCK_NONBLOCK != O_NONBLOCK && (flags & SOCK_NONBLOCK))

		flags = (flags & ~SOCK_NONBLOCK) | O_NONBLOCK;

	retval = sock_create(family, type, protocol, &sock);

......

	retval = sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));

......

	return retval;

}

这里面的代码比较容易看懂，Socket 系统调用会调用 sock_create 创建一个 struct socket 结构，然后通过 sock_map_fd 和文件描述符对应起来。

在创建 Socket 的时候，有三个参数。

一个是family，表示地址族。不是所有的 Socket 都要通过 IP 进行通信，还有其他的通信方式。例如，下面的定义中，domain sockets 就是通过本地文件进行通信的，不需要 IP 地址。只不过，通过 IP 地址只是最常用的模式，所以我们这里着重分析这种模式。

#define AF_UNIX 1/* Unix domain sockets */

#define AF_INET 2/* Internet IP Protocol */

第二个参数是type，也即 Socket 的类型。类型是比较少的。

第三个参数是protocol，是协议。协议数目是比较多的，也就是说，多个协议会属于同一种类型。

常用的 Socket 类型有三种，分别是 SOCK_STREAM、SOCK_DGRAM 和 SOCK_RAW。

enum sock_type {

SOCK_STREAM = 1,

SOCK_DGRAM = 2,

SOCK_RAW = 3,

......

}

SOCK_STREAM 是面向数据流的，协议 IPPROTO_TCP 属于这种类型。SOCK_DGRAM 是面向数据报的，协议 IPPROTO_UDP 属于这种类型。如果在内核里面看的话，IPPROTO_ICMP 也属于这种类型。SOCK_RAW 是原始的 IP 包，IPPROTO_IP 属于这种类型。

这一节，我们重点看 SOCK_STREAM 类型和 IPPROTO_TCP 协议。

为了管理 family、type、protocol 这三个分类层次，内核会创建对应的数据结构。

接下来，我们打开 sock_create 函数看一下。它会调用 __sock_create。

int __sock_create(struct net *net, int family, int type, int protocol,

			 struct socket **res, int kern)

{

	int err;

	struct socket *sock;

	const struct net_proto_family *pf;

......

	sock = sock_alloc();

......

	sock->type = type;

......

	pf = rcu_dereference(net_families[family]);

......

	err = pf->create(net, sock, protocol, kern);

......

	*res = sock;

	return 0;

}

这里先是分配了一个 struct socket 结构。接下来我们要用到 family 参数。这里有一个 net_families 数组，我们可以以 family 参数为下标，找到对应的 struct net_proto_family。

/* Supported address families. */

#define AF_UNSPEC	0

#define AF_UNIX		1	/* Unix domain sockets 		*/

#define AF_LOCAL	1	/* POSIX name for AF_UNIX	*/

#define AF_INET		2	/* Internet IP Protocol 	*/

......

#define AF_INET6	10	/* IP version 6			*/

......

#define AF_MPLS		28	/* MPLS */

......

#define AF_MAX		44	/* For now.. */

#define NPROTO		AF_MAX

struct net_proto_family __rcu *net_families[NPROTO] __read_mostly;

我们可以找到 net_families 的定义。每一个地址族在这个数组里面都有一项，里面的内容是 net_proto_family。每一种地址族都有自己的 net_proto_family，IP 地址族的 net_proto_family 定义如下，里面最重要的就是，create 函数指向 inet_create。

//net/ipv4/af_inet.c

static const struct net_proto_family inet_family_ops = {

	.family = PF_INET,

	.create = inet_create,// 这个用于 socket 系统调用创建

......

}

我们回到函数 __sock_create。接下来，在这里面，这个 inet_create 会被调用。

static int inet_create(struct net *net, struct socket *sock, int protocol, int kern)

{

	struct sock *sk;

	struct inet_protosw *answer;

	struct inet_sock *inet;

	struct proto *answer_prot;

	unsigned char answer_flags;

	int try_loading_module = 0;

	int err;

	/* Look for the requested type/protocol pair. */

lookup_protocol:

	list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {

		err = 0;

		/* Check the non-wild match. */

		if (protocol == answer->protocol) {

			if (protocol != IPPROTO_IP)

				break;

		} else {

			/* Check for the two wild cases. */

			if (IPPROTO_IP == protocol) {

				protocol = answer->protocol;

				break;

			}

			if (IPPROTO_IP == answer->protocol)

				break;

		}

		err = -EPROTONOSUPPORT;

	}

......

	sock->ops = answer->ops;

	answer_prot = answer->prot;

	answer_flags = answer->flags;

......

	sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);

......

	inet = inet_sk(sk);

	inet->nodefrag = 0;

	if (SOCK_RAW == sock->type) {

		inet->inet_num = protocol;

		if (IPPROTO_RAW == protocol)

			inet->hdrincl = 1;

	}

	inet->inet_id = 0;

	sock_init_data(sock, sk);

	sk->sk_destruct	   = inet_sock_destruct;

	sk->sk_protocol	   = protocol;

	sk->sk_backlog_rcv = sk->sk_prot->backlog_rcv;

	inet->uc_ttl	= -1;

	inet->mc_loop	= 1;

	inet->mc_ttl	= 1;

	inet->mc_all	= 1;

	inet->mc_index	= 0;

	inet->mc_list	= NULL;

	inet->rcv_tos	= 0;

	if (inet->inet_num) {

		inet->inet_sport = htons(inet->inet_num);

		/* Add to protocol hash chains. */

		err = sk->sk_prot->hash(sk);

	}

	if (sk->sk_prot->init) {

		err = sk->sk_prot->init(sk);

	}

......

}

在 inet_create 中，我们先会看到一个循环 list_for_each_entry_rcu。在这里，第二个参数 type 开始起作用。因为循环查看的是 inetsw[sock->type]。

这里的 inetsw 也是一个数组，type 作为下标，里面的内容是 struct inet_protosw，是协议，也即 inetsw 数组对于每个类型有一项，这一项里面是属于这个类型的协议。

static struct list_head inetsw[SOCK_MAX];

static int __init inet_init(void)

{

......

	/* Register the socket-side information for inet_create. */

	for (r = &inetsw[0]; r < &inetsw[SOCK_MAX]; ++r)

		INIT_LIST_HEAD(r);

	for (q = inetsw_array; q < &inetsw_array[INETSW_ARRAY_LEN]; ++q)

		inet_register_protosw(q);

......

}

inetsw 数组是在系统初始化的时候初始化的，就像下面代码里面实现的一样。

首先，一个循环会将 inetsw 数组的每一项，都初始化为一个链表。咱们前面说了，一个 type 类型会包含多个 protocol，因而我们需要一个链表。接下来一个循环，是将 inetsw_array 注册到 inetsw 数组里面去。inetsw_array 的定义如下，这个数组里面的内容很重要，后面会用到它们。

static struct inet_protosw inetsw_array[] =

{

	{

		.type =       SOCK_STREAM,

		.protocol =   IPPROTO_TCP,

		.prot =       &tcp_prot,

		.ops =        &inet_stream_ops,

		.flags =      INET_PROTOSW_PERMANENT |

			      INET_PROTOSW_ICSK,

	},

	{

		.type =       SOCK_DGRAM,

		.protocol =   IPPROTO_UDP,

		.prot =       &udp_prot,

		.ops =        &inet_dgram_ops,

		.flags =      INET_PROTOSW_PERMANENT,

     },

     {

		.type =       SOCK_DGRAM,

		.protocol =   IPPROTO_ICMP,

		.prot =       &ping_prot,

		.ops =        &inet_sockraw_ops,

		.flags =      INET_PROTOSW_REUSE,

     },

     {

        .type =       SOCK_RAW,

	    .protocol =   IPPROTO_IP,	/* wild card */

	    .prot =       &raw_prot,

	    .ops =        &inet_sockraw_ops,

	    .flags =      INET_PROTOSW_REUSE,

     }

}

我们回到 inet_create 的 list_for_each_entry_rcu 循环中。到这里就好理解了，这是在 inetsw 数组中，根据 type 找到属于这个类型的列表，然后依次比较列表中的 struct inet_protosw 的 protocol 是不是用户指定的 protocol；如果是，就得到了符合用户指定的 family->type->protocol 的 struct inet_protosw *answer 对象。

接下来，struct socket *sock 的 ops 成员变量，被赋值为 answer 的 ops。对于 TCP 来讲，就是 inet_stream_ops。后面任何用户对于这个 socket 的操作，都是通过 inet_stream_ops 进行的。

接下来，我们创建一个 struct sock *sk 对象。这里比较让人困惑。socket 和 sock 看起来几乎一样，容易让人混淆，这里需要说明一下，socket 是用于负责对上给用户提供接口，并且和文件系统关联。而 sock，负责向下对接内核网络协议栈。

在 sk_alloc 函数中，struct inet_protosw *answer 结构的 tcp_prot 赋值给了 struct sock *sk 的 sk_prot 成员。tcp_prot 的定义如下，里面定义了很多的函数，都是 sock 之下内核协议栈的动作。

struct proto tcp_prot = {

	.name			= "TCP",

	.owner			= THIS_MODULE,

	.close			= tcp_close,

	.connect		= tcp_v4_connect,

	.disconnect		= tcp_disconnect,

	.accept			= inet_csk_accept,

	.ioctl			= tcp_ioctl,

	.init			= tcp_v4_init_sock,

	.destroy		= tcp_v4_destroy_sock,

	.shutdown		= tcp_shutdown,

	.setsockopt		= tcp_setsockopt,

	.getsockopt		= tcp_getsockopt,

	.keepalive		= tcp_set_keepalive,

	.recvmsg		= tcp_recvmsg,

	.sendmsg		= tcp_sendmsg,

	.sendpage		= tcp_sendpage,

	.backlog_rcv		= tcp_v4_do_rcv,

	.release_cb		= tcp_release_cb,

	.hash			= inet_hash,

    .get_port		= inet_csk_get_port,

......

}

在 inet_create 函数中，接下来创建一个 struct inet_sock 结构，这个结构一开始就是 struct sock，然后扩展了一些其他的信息，剩下的代码就填充这些信息。这一幕我们会经常看到，将一个结构放在另一个结构的开始位置，然后扩展一些成员，通过对于指针的强制类型转换，来访问这些成员。

socket 的创建至此结束。

解析 bind 函数

接下来，我们来看 bind。

SYSCALL_DEFINE3(bind, int, fd, struct sockaddr __user *, umyaddr, int, addrlen)

{

	struct socket *sock;

	struct sockaddr_storage address;

	int err, fput_needed;

	sock = sockfd_lookup_light(fd, &err, &fput_needed);

	if (sock) {

		err = move_addr_to_kernel(umyaddr, addrlen, &address);

		if (err >= 0) {

			err = sock->ops->bind(sock,

						      (struct sockaddr *)

						      &address, addrlen);

		}

		fput_light(sock->file, fput_needed);

	}

	return err;

}

在 bind 中，sockfd_lookup_light 会根据 fd 文件描述符，找到 struct socket 结构。然后将 sockaddr 从用户态拷贝到内核态，然后调用 struct socket 结构里面 ops 的 bind 函数。根据前面创建 socket 的时候的设定，调用的是 inet_stream_ops 的 bind 函数，也即调用 inet_bind。

int inet_bind(struct socket *sock, struct sockaddr *uaddr, int addr_len)

{

	struct sockaddr_in *addr = (struct sockaddr_in *)uaddr;

	struct sock *sk = sock->sk;

	struct inet_sock *inet = inet_sk(sk);

	struct net *net = sock_net(sk);

	unsigned short snum;

......

	snum = ntohs(addr->sin_port);

......

	inet->inet_rcv_saddr = inet->inet_saddr = addr->sin_addr.s_addr;

	/* Make sure we are allowed to bind here. */

	if ((snum || !inet->bind_address_no_port) &&

	    sk->sk_prot->get_port(sk, snum)) {

......

	}

	inet->inet_sport = htons(inet->inet_num);

	inet->inet_daddr = 0;

	inet->inet_dport = 0;

	sk_dst_reset(sk);

}

bind 里面会调用 sk_prot 的 get_port 函数，也即 inet_csk_get_port 来检查端口是否冲突，是否可以绑定。如果允许，则会设置 struct inet_sock 的本方的地址 inet_saddr 和本方的端口 inet_sport，对方的地址 inet_daddr 和对方的端口 inet_dport 都初始化为 0。

bind 的逻辑相对比较简单，就到这里了。

解析 listen 函数

接下来我们来看 listen。

SYSCALL_DEFINE2(listen, int, fd, int, backlog)

{

	struct socket *sock;

	int err, fput_needed;

	int somaxconn;

	sock = sockfd_lookup_light(fd, &err, &fput_needed);

	if (sock) {

		somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;

		if ((unsigned int)backlog > somaxconn)

			backlog = somaxconn;

		err = sock->ops->listen(sock, backlog);

		fput_light(sock->file, fput_needed);

	}

	return err;

}

在 listen 中，我们还是通过 sockfd_lookup_light，根据 fd 文件描述符，找到 struct socket 结构。接着，我们调用 struct socket 结构里面 ops 的 listen 函数。根据前面创建 socket 的时候的设定，调用的是 inet_stream_ops 的 listen 函数，也即调用 inet_listen。

int inet_listen(struct socket *sock, int backlog)

{

	struct sock *sk = sock->sk;

	unsigned char old_state;

	int err;

	old_state = sk->sk_state;

	/* Really, if the socket is already in listen state

	 * we can only allow the backlog to be adjusted.

	 */

	if (old_state != TCP_LISTEN) {

		err = inet_csk_listen_start(sk, backlog);

	}

	sk->sk_max_ack_backlog = backlog;

}

如果这个 socket 还不在 TCP_LISTEN 状态，会调用 inet_csk_listen_start 进入监听状态。

int inet_csk_listen_start(struct sock *sk, int backlog)

{

	struct inet_connection_sock *icsk = inet_csk(sk);

	struct inet_sock *inet = inet_sk(sk);

	int err = -EADDRINUSE;

	reqsk_queue_alloc(&icsk->icsk_accept_queue);

	sk->sk_max_ack_backlog = backlog;

	sk->sk_ack_backlog = 0;

	inet_csk_delack_init(sk);

	sk_state_store(sk, TCP_LISTEN);

	if (!sk->sk_prot->get_port(sk, inet->inet_num)) {

......

	}

......

}

这里面建立了一个新的结构 inet_connection_sock，这个结构一开始是 struct inet_sock，inet_csk 其实做了一次强制类型转换，扩大了结构，看到了吧，又是这个套路。

struct inet_connection_sock 结构比较复杂。如果打开它，你能看到处于各种状态的队列，各种超时时间、拥塞控制等字眼。我们说 TCP 是面向连接的，就是客户端和服务端都是有一个结构维护连接的状态，就是指这个结构。我们这里先不详细分析里面的变量，因为太多了，后面我们遇到一个分析一个。

首先，我们遇到的是 icsk_accept_queue。它是干什么的呢？

在 TCP 的状态里面，有一个 listen 状态，当调用 listen 函数之后，就会进入这个状态，虽然我们写程序的时候，一般要等待服务端调用 accept 后，等待在哪里的时候，让客户端就发起连接。其实服务端一旦处于 listen 状态，不用 accept，客户端也能发起连接。其实 TCP 的状态中，没有一个是否被 accept 的状态，那 accept 函数的作用是什么呢？

在内核中，为每个 Socket 维护两个队列。一个是已经建立了连接的队列，这时候连接三次握手已经完毕，处于 established 状态；一个是还没有完全建立连接的队列，这个时候三次握手还没完成，处于 syn_rcvd 的状态。

服务端调用 accept 函数，其实是在第一个队列中拿出一个已经完成的连接进行处理。如果还没有完成就阻塞等待。这里的 icsk_accept_queue 就是第一个队列。

初始化完之后，将 TCP 的状态设置为 TCP_LISTEN，再次调用 get_port 判断端口是否冲突。

至此，listen 的逻辑就结束了。

解析 accept 函数

接下来，我们解析服务端调用 accept。

SYSCALL_DEFINE3(accept, int, fd, struct sockaddr __user *, upeer_sockaddr,

		int __user *, upeer_addrlen)

{

	return sys_accept4(fd, upeer_sockaddr, upeer_addrlen, 0);

}

SYSCALL_DEFINE4(accept4, int, fd, struct sockaddr __user *, upeer_sockaddr,

		int __user *, upeer_addrlen, int, flags)

{

	struct socket *sock, *newsock;

	struct file *newfile;

	int err, len, newfd, fput_needed;

	struct sockaddr_storage address;

......

	sock = sockfd_lookup_light(fd, &err, &fput_needed);

	newsock = sock_alloc();

	newsock->type = sock->type;

	newsock->ops = sock->ops;

	newfd = get_unused_fd_flags(flags);

	newfile = sock_alloc_file(newsock, flags, sock->sk->sk_prot_creator->name);

	err = sock->ops->accept(sock, newsock, sock->file->f_flags, false);

	if (upeer_sockaddr) {

		if (newsock->ops->getname(newsock, (struct sockaddr *)&address, &len, 2) < 0) {

		}

		err = move_addr_to_user(&address,

					len, upeer_sockaddr, upeer_addrlen);

	}

	fd_install(newfd, newfile);

......

}

accept 函数的实现，印证了 socket 的原理中说的那样，原来的 socket 是监听 socket，这里我们会找到原来的 struct socket，并基于它去创建一个新的 newsock。这才是连接 socket。除此之外，我们还会创建一个新的 struct file 和 fd，并关联到 socket。

这里面还会调用 struct socket 的 sock->ops->accept，也即会调用 inet_stream_ops 的 accept 函数，也即 inet_accept。

int inet_accept(struct socket *sock, struct socket *newsock, int flags, bool kern)

{

	struct sock *sk1 = sock->sk;

	int err = -EINVAL;

	struct sock *sk2 = sk1->sk_prot->accept(sk1, flags, &err, kern);

	sock_rps_record_flow(sk2);

	sock_graft(sk2, newsock);

	newsock->state = SS_CONNECTED;

}

inet_accept 会调用 struct sock 的 sk1->sk_prot->accept，也即 tcp_prot 的 accept 函数，inet_csk_accept 函数。

/*

 * This will accept the next outstanding connection.

 */

struct sock *inet_csk_accept(struct sock *sk, int flags, int *err, bool kern)

{

	struct inet_connection_sock *icsk = inet_csk(sk);

	struct request_sock_queue *queue = &icsk->icsk_accept_queue;

	struct request_sock *req;

	struct sock *newsk;

	int error;

	if (sk->sk_state != TCP_LISTEN)

		goto out_err;

	/* Find already established connection */

	if (reqsk_queue_empty(queue)) {

		long timeo = sock_rcvtimeo(sk, flags & O_NONBLOCK);

		error = inet_csk_wait_for_connect(sk, timeo);

	}

	req = reqsk_queue_remove(queue, sk);

	newsk = req->sk;

......

}

/*

 * Wait for an incoming connection, avoid race conditions. This must be called

 * with the socket locked.

 */

static int inet_csk_wait_for_connect(struct sock *sk, long timeo)

{

	struct inet_connection_sock *icsk = inet_csk(sk);

	DEFINE_WAIT(wait);

	int err;

	for (;;) {

		prepare_to_wait_exclusive(sk_sleep(sk), &wait,

					  TASK_INTERRUPTIBLE);

		release_sock(sk);

		if (reqsk_queue_empty(&icsk->icsk_accept_queue))

			timeo = schedule_timeout(timeo);

		sched_annotate_sleep();

		lock_sock(sk);

		err = 0;

		if (!reqsk_queue_empty(&icsk->icsk_accept_queue))

			break;

		err = -EINVAL;

		if (sk->sk_state != TCP_LISTEN)

			break;

		err = sock_intr_errno(timeo);

		if (signal_pending(current))

			break;

		err = -EAGAIN;

		if (!timeo)

			break;

	}

	finish_wait(sk_sleep(sk), &wait);

	return err;

}

inet_csk_accept 的实现，印证了上面我们讲的两个队列的逻辑。如果 icsk_accept_queue 为空，则调用 inet_csk_wait_for_connect 进行等待；等待的时候，调用 schedule_timeout，让出 CPU，并且将进程状态设置为 TASK_INTERRUPTIBLE。

如果再次 CPU 醒来，我们会接着判断 icsk_accept_queue 是否为空，同时也会调用 signal_pending 看有没有信号可以处理。一旦 icsk_accept_queue 不为空，就从 inet_csk_wait_for_connect 中返回，在队列中取出一个 struct sock 对象赋值给 newsk。

解析 connect 函数

什么情况下，icsk_accept_queue 才不为空呢？当然是三次握手结束才可以。接下来我们来分析三次握手的过程。

三次握手一般是由客户端调用 connect 发起。

SYSCALL_DEFINE3(connect, int, fd, struct sockaddr __user *, uservaddr,

		int, addrlen)

{

	struct socket *sock;

	struct sockaddr_storage address;

	int err, fput_needed;

	sock = sockfd_lookup_light(fd, &err, &fput_needed);

	err = move_addr_to_kernel(uservaddr, addrlen, &address);

	err = sock->ops->connect(sock, (struct sockaddr *)&address, addrlen, sock->file->f_flags);

}

connect 函数的实现一开始你应该很眼熟，还是通过 sockfd_lookup_light，根据 fd 文件描述符，找到 struct socket 结构。接着，我们会调用 struct socket 结构里面 ops 的 connect 函数，根据前面创建 socket 的时候的设定，调用 inet_stream_ops 的 connect 函数，也即调用 inet_stream_connect。

/*

 *	Connect to a remote host. There is regrettably still a little

 *	TCP 'magic' in here.

 */

int __inet_stream_connect(struct socket *sock, struct sockaddr *uaddr,

			  int addr_len, int flags, int is_sendmsg)

{

	struct sock *sk = sock->sk;

	int err;

	long timeo;

	switch (sock->state) {

......

	case SS_UNCONNECTED:

		err = -EISCONN;

		if (sk->sk_state != TCP_CLOSE)

			goto out;

		err = sk->sk_prot->connect(sk, uaddr, addr_len);

		sock->state = SS_CONNECTING;

		break;

	}

	timeo = sock_sndtimeo(sk, flags & O_NONBLOCK);

	if ((1 << sk->sk_state) & (TCPF_SYN_SENT | TCPF_SYN_RECV)) {

......

		if (!timeo || !inet_wait_for_connect(sk, timeo, writebias))

			goto out;

		err = sock_intr_errno(timeo);

		if (signal_pending(current))

			goto out;

	}

	sock->state = SS_CONNECTED;

}

在 __inet_stream_connect 里面，我们发现，如果 socket 处于 SS_UNCONNECTED 状态，那就调用 struct sock 的 sk->sk_prot->connect，也即 tcp_prot 的 connect 函数——tcp_v4_connect 函数。

int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)

{

	struct sockaddr_in *usin = (struct sockaddr_in *)uaddr;

	struct inet_sock *inet = inet_sk(sk);

	struct tcp_sock *tp = tcp_sk(sk);

	__be16 orig_sport, orig_dport;

	__be32 daddr, nexthop;

	struct flowi4 *fl4;

	struct rtable *rt;

......

	orig_sport = inet->inet_sport;

	orig_dport = usin->sin_port;

	rt = ip_route_connect(fl4, nexthop, inet->inet_saddr,

			      RT_CONN_FLAGS(sk), sk->sk_bound_dev_if,

			      IPPROTO_TCP,

			      orig_sport, orig_dport, sk);

......

	tcp_set_state(sk, TCP_SYN_SENT);

	err = inet_hash_connect(tcp_death_row, sk);

	sk_set_txhash(sk);

	rt = ip_route_newports(fl4, rt, orig_sport, orig_dport,

			       inet->inet_sport, inet->inet_dport, sk);

	/* OK, now commit destination to socket.  */

	sk->sk_gso_type = SKB_GSO_TCPV4;

	sk_setup_caps(sk, &rt->dst);

    if (likely(!tp->repair)) {

		if (!tp->write_seq)

			tp->write_seq = secure_tcp_seq(inet->inet_saddr,

						       inet->inet_daddr,

						       inet->inet_sport,

						       usin->sin_port);

		tp->tsoffset = secure_tcp_ts_off(sock_net(sk),

						 inet->inet_saddr,

						 inet->inet_daddr);

	}

	rt = NULL;

......

	err = tcp_connect(sk);

......

}

在 tcp_v4_connect 函数中，ip_route_connect 其实是做一个路由的选择。为什么呢？因为三次握手马上就要发送一个 SYN 包了，这就要凑齐源地址、源端口、目标地址、目标端口。目标地址和目标端口是服务端的，已经知道源端口是客户端随机分配的，源地址应该用哪一个呢？这时候要选择一条路由，看从哪个网卡出去，就应该填写哪个网卡的 IP 地址。

接下来，在发送 SYN 之前，我们先将客户端 socket 的状态设置为 TCP_SYN_SENT。然后初始化 TCP 的 seq num，也即 write_seq，然后调用 tcp_connect 进行发送。

/* Build a SYN and send it off. */

int tcp_connect(struct sock *sk)

{

	struct tcp_sock *tp = tcp_sk(sk);

	struct sk_buff *buff;

	int err;

......

	tcp_connect_init(sk);

......

	buff = sk_stream_alloc_skb(sk, 0, sk->sk_allocation, true);

......

	tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN);

	tcp_mstamp_refresh(tp);

	tp->retrans_stamp = tcp_time_stamp(tp);

	tcp_connect_queue_skb(sk, buff);

	tcp_ecn_send_syn(sk, buff);

	/* Send off SYN; include data in Fast Open. */

	err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :

	      tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);

......

	tp->snd_nxt = tp->write_seq;

	tp->pushed_seq = tp->write_seq;

	buff = tcp_send_head(sk);

	if (unlikely(buff)) {

		tp->snd_nxt	= TCP_SKB_CB(buff)->seq;

		tp->pushed_seq	= TCP_SKB_CB(buff)->seq;

	}

......

	/* Timer for repeating the SYN until an answer. */

	inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,

				  inet_csk(sk)->icsk_rto, TCP_RTO_MAX);

	return 0;

}

在 tcp_connect 中，有一个新的结构 struct tcp_sock，如果打开他，你会发现他是 struct inet_connection_sock 的一个扩展，struct inet_connection_sock 在 struct tcp_sock 开头的位置，通过强制类型转换访问，故伎重演又一次。

struct tcp_sock 里面维护了更多的 TCP 的状态，咱们同样是遇到了再分析。

接下来 tcp_init_nondata_skb 初始化一个 SYN 包，tcp_transmit_skb 将 SYN 包发送出去，inet_csk_reset_xmit_timer 设置了一个 timer，如果 SYN 发送不成功，则再次发送。

发送网络包的过程，我们放到下一节讲解。这里我们姑且认为 SYN 已经发送出去了。

我们回到 __inet_stream_connect 函数，在调用 sk->sk_prot->connect 之后，inet_wait_for_connect 会一直等待客户端收到服务端的 ACK。而我们知道，服务端在 accept 之后，也是在等待中。

网络包是如何接收的呢？对于解析的详细过程，我们会在下下节讲解，这里为了解析三次握手，我们简单的看网络包接收到 TCP 层做的部分事情。

static struct net_protocol tcp_protocol = {

	.early_demux	=	tcp_v4_early_demux,

	.early_demux_handler =  tcp_v4_early_demux,

	.handler	=	tcp_v4_rcv,

	.err_handler	=	tcp_v4_err,

	.no_policy	=	1,

	.netns_ok	=	1,

	.icmp_strict_tag_validation = 1,

}

我们通过 struct net_protocol 结构中的 handler 进行接收，调用的函数是 tcp_v4_rcv。接下来的调用链为 tcp_v4_rcv->tcp_v4_do_rcv->tcp_rcv_state_process。tcp_rcv_state_process，顾名思义，是用来处理接收一个网络包后引起状态变化的。

int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb)

{

	struct tcp_sock *tp = tcp_sk(sk);

	struct inet_connection_sock *icsk = inet_csk(sk);

	const struct tcphdr *th = tcp_hdr(skb);

	struct request_sock *req;

	int queued = 0;

	bool acceptable;

	switch (sk->sk_state) {

......

	case TCP_LISTEN:

......

		if (th->syn) {

			acceptable = icsk->icsk_af_ops->conn_request(sk, skb) >= 0;

			if (!acceptable)

				return 1;

			consume_skb(skb);

			return 0;

		}

......

}

目前服务端是处于 TCP_LISTEN 状态的，而且发过来的包是 SYN，因而就有了上面的代码，调用 icsk->icsk_af_ops->conn_request 函数。struct inet_connection_sock 对应的操作是 inet_connection_sock_af_ops，按照下面的定义，其实调用的是 tcp_v4_conn_request。

const struct inet_connection_sock_af_ops ipv4_specific = {

        .queue_xmit        = ip_queue_xmit,

        .send_check        = tcp_v4_send_check,

        .rebuild_header    = inet_sk_rebuild_header,

        .sk_rx_dst_set     = inet_sk_rx_dst_set,

        .conn_request      = tcp_v4_conn_request,

        .syn_recv_sock     = tcp_v4_syn_recv_sock,

        .net_header_len    = sizeof(struct iphdr),

        .setsockopt        = ip_setsockopt,

        .getsockopt        = ip_getsockopt,

        .addr2sockaddr     = inet_csk_addr2sockaddr,

        .sockaddr_len      = sizeof(struct sockaddr_in),

        .mtu_reduced       = tcp_v4_mtu_reduced,

};

tcp_v4_conn_request 会调用 tcp_conn_request，这个函数也比较长，里面调用了 send_synack，但实际调用的是 tcp_v4_send_synack。具体发送的过程我们不去管它，看注释我们能知道，这是收到了 SYN 后，回复一个 SYN-ACK，回复完毕后，服务端处于 TCP_SYN_RECV。

int tcp_conn_request(struct request_sock_ops *rsk_ops,

		     const struct tcp_request_sock_ops *af_ops,

		     struct sock *sk, struct sk_buff *skb)

{

......

af_ops->send_synack(sk, dst, &fl, req, &foc,

				    !want_cookie ? TCP_SYNACK_NORMAL :

						   TCP_SYNACK_COOKIE);

......

}

/*

 *	Send a SYN-ACK after having received a SYN.

 */

static int tcp_v4_send_synack(const struct sock *sk, struct dst_entry *dst,

			      struct flowi *fl,

			      struct request_sock *req,

			      struct tcp_fastopen_cookie *foc,

			      enum tcp_synack_type synack_type)

{......}

这个时候，轮到客户端接收网络包了。都是 TCP 协议栈，所以过程和服务端没有太多区别，还是会走到 tcp_rcv_state_process 函数的，只不过由于客户端目前处于 TCP_SYN_SENT 状态，就进入了下面的代码分支。

int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb)

{

	struct tcp_sock *tp = tcp_sk(sk);

	struct inet_connection_sock *icsk = inet_csk(sk);

	const struct tcphdr *th = tcp_hdr(skb);

	struct request_sock *req;

	int queued = 0;

	bool acceptable;

	switch (sk->sk_state) {

......

	case TCP_SYN_SENT:

		tp->rx_opt.saw_tstamp = 0;

		tcp_mstamp_refresh(tp);

		queued = tcp_rcv_synsent_state_process(sk, skb, th);

		if (queued >= 0)

			return queued;

		/* Do step6 onward by hand. */

		tcp_urg(sk, skb, th);

		__kfree_skb(skb);

		tcp_data_snd_check(sk);

		return 0;

	}

......

}

tcp_rcv_synsent_state_process 会调用 tcp_send_ack，发送一个 ACK-ACK，发送后客户端处于 TCP_ESTABLISHED 状态。

又轮到服务端接收网络包了，我们还是归 tcp_rcv_state_process 函数处理。由于服务端目前处于状态 TCP_SYN_RECV 状态，因而又走了另外的分支。当收到这个网络包的时候，服务端也处于 TCP_ESTABLISHED 状态，三次握手结束。

int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb)

{

	struct tcp_sock *tp = tcp_sk(sk);

	struct inet_connection_sock *icsk = inet_csk(sk);

	const struct tcphdr *th = tcp_hdr(skb);

	struct request_sock *req;

	int queued = 0;

	bool acceptable;

......

	switch (sk->sk_state) {

	case TCP_SYN_RECV:

		if (req) {

			inet_csk(sk)->icsk_retransmits = 0;

			reqsk_fastopen_remove(sk, req, false);

		} else {

			/* Make sure socket is routed, for correct metrics. */

			icsk->icsk_af_ops->rebuild_header(sk);

			tcp_call_bpf(sk, BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB);

			tcp_init_congestion_control(sk);

			tcp_mtup_init(sk);

			tp->copied_seq = tp->rcv_nxt;

			tcp_init_buffer_space(sk);

		}

		smp_mb();

		tcp_set_state(sk, TCP_ESTABLISHED);

		sk->sk_state_change(sk);

		if (sk->sk_socket)

			sk_wake_async(sk, SOCK_WAKE_IO, POLL_OUT);

		tp->snd_una = TCP_SKB_CB(skb)->ack_seq;

		tp->snd_wnd = ntohs(th->window) << tp->rx_opt.snd_wscale;

		tcp_init_wl(tp, TCP_SKB_CB(skb)->seq);

		break;

......

}

总结时刻

这一节除了网络包的接收和发送，其他的系统调用我们都分析到了。可以看出来，它们有一个统一的数据结构和流程。具体如下图所示：

首先，Socket 系统调用会有三级参数 family、type、protocal，通过这三级参数，分别在 net_proto_family 表中找到 type 链表，在 type 链表中找到 protocal 对应的操作。这个操作分为两层，对于 TCP 协议来讲，第一层是 inet_stream_ops 层，第二层是 tcp_prot 层。

于是，接下来的系统调用规律就都一样了：

bind 第一层调用 inet_stream_ops 的 inet_bind 函数，第二层调用 tcp_prot 的 inet_csk_get_port 函数；

listen 第一层调用 inet_stream_ops 的 inet_listen 函数，第二层调用 tcp_prot 的 inet_csk_get_port 函数；

accept 第一层调用 inet_stream_ops 的 inet_accept 函数，第二层调用 tcp_prot 的 inet_csk_accept 函数；

connect 第一层调用 inet_stream_ops 的 inet_stream_connect 函数，第二层调用 tcp_prot 的 tcp_v4_connect 函数。

课堂练习

TCP 的三次握手协议非常重要，请你务必跟着代码走读一遍。另外我们这里重点关注了 TCP 的场景，请走读代码的时候，也看一下 UDP 是如何实现各层的函数的。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

45 | 发送网络包（上）：如何表达我们想让合作伙伴做什么？

上一节，我们通过 socket 函数、bind 函数、listen 函数、accept 函数以及 connect 函数，在内核建立好了数据结构，并完成了 TCP 连接建立的三次握手过程。

这一节，我们接着来分析，发送一个网络包的过程。

解析 socket 的 Write 操作

socket 对于用户来讲，是一个文件一样的存在，拥有一个文件描述符。因而对于网络包的发送，我们可以使用对于 socket 文件的写入系统调用，也就是 write 系统调用。

write 系统调用对于一个文件描述符的操作，大致过程都是类似的。在文件系统那一节，我们已经详细解析过，这里不再多说。对于每一个打开的文件都有一个 struct file 结构，write 系统调用会最终调用 stuct file 结构指向的 file_operations 操作。

对于 socket 来讲，它的 file_operations 定义如下：

static const struct file_operations socket_file_ops = {

	.owner =	THIS_MODULE,

	.llseek =	no_llseek,

	.read_iter =	sock_read_iter,

	.write_iter =	sock_write_iter,

	.poll =		sock_poll,

	.unlocked_ioctl = sock_ioctl,

	.mmap =		sock_mmap,

	.release =	sock_close,

	.fasync =	sock_fasync,

	.sendpage =	sock_sendpage,

	.splice_write = generic_splice_sendpage,

	.splice_read =	sock_splice_read,

};

按照文件系统的写入流程，调用的是 sock_write_iter。

static ssize_t sock_write_iter(struct kiocb *iocb, struct iov_iter *from)

{

	struct file *file = iocb->ki_filp;

	struct socket *sock = file->private_data;

	struct msghdr msg = {.msg_iter = *from,

			     .msg_iocb = iocb};

	ssize_t res;

......

	res = sock_sendmsg(sock, &msg);

	*from = msg.msg_iter;

	return res;

}

在 sock_write_iter 中，我们通过 VFS 中的 struct file，将创建好的 socket 结构拿出来，然后调用 sock_sendmsg。而 sock_sendmsg 会调用 sock_sendmsg_nosec。

static inline int sock_sendmsg_nosec(struct socket *sock, struct msghdr *msg)

{

	int ret = sock->ops->sendmsg(sock, msg, msg_data_left(msg));

......

}

这里调用了 socket 的 ops 的 sendmsg，我们在上一节已经遇到它好几次了。根据 inet_stream_ops 的定义，我们这里调用的是 inet_sendmsg。

int inet_sendmsg(struct socket *sock, struct msghdr *msg, size_t size)

{

	struct sock *sk = sock->sk;

......

	return sk->sk_prot->sendmsg(sk, msg, size);

}

这里面，从 socket 结构中，我们可以得到更底层的 sock 结构，然后调用 sk_prot 的 sendmsg 方法。这个我们同样在上一节遇到好几次了。

解析 tcp_sendmsg 函数

根据 tcp_prot 的定义，我们调用的是 tcp_sendmsg。

int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size)

{

	struct tcp_sock *tp = tcp_sk(sk);

	struct sk_buff *skb;

	int flags, err, copied = 0;

	int mss_now = 0, size_goal, copied_syn = 0;

	long timeo;

......

	/* Ok commence sending. */

	copied = 0;

restart:

	mss_now = tcp_send_mss(sk, &size_goal, flags);

	while (msg_data_left(msg)) {

		int copy = 0;

		int max = size_goal;

		skb = tcp_write_queue_tail(sk);

		if (tcp_send_head(sk)) {

			if (skb->ip_summed == CHECKSUM_NONE)

				max = mss_now;

			copy = max - skb->len;

		}

		if (copy <= 0 || !tcp_skb_can_collapse_to(skb)) {

			bool first_skb;

new_segment:

			/* Allocate new segment. If the interface is SG,

			 * allocate skb fitting to single page.

			 */

			if (!sk_stream_memory_free(sk))

				goto wait_for_sndbuf;

......

			first_skb = skb_queue_empty(&sk->sk_write_queue);

			skb = sk_stream_alloc_skb(sk,

						  select_size(sk, sg, first_skb),

						  sk->sk_allocation,

						  first_skb);

......

			skb_entail(sk, skb);

			copy = size_goal;

			max = size_goal;

......

		}

		/* Try to append data to the end of skb. */

		if (copy > msg_data_left(msg))

			copy = msg_data_left(msg);

		/* Where to copy to? */

		if (skb_availroom(skb) > 0) {

			/* We have some space in skb head. Superb! */

			copy = min_t(int, copy, skb_availroom(skb));

			err = skb_add_data_nocache(sk, skb, &msg->msg_iter, copy);

......

		} else {

			bool merge = true;

			int i = skb_shinfo(skb)->nr_frags;

			struct page_frag *pfrag = sk_page_frag(sk);

......

			copy = min_t(int, copy, pfrag->size - pfrag->offset);

......

			err = skb_copy_to_page_nocache(sk, &msg->msg_iter, skb,

						       pfrag->page,

						       pfrag->offset,

						       copy);

......

			pfrag->offset += copy;

		}

......

		tp->write_seq += copy;

		TCP_SKB_CB(skb)->end_seq += copy;

		tcp_skb_pcount_set(skb, 0);

		copied += copy;

		if (!msg_data_left(msg)) {

			if (unlikely(flags & MSG_EOR))

				TCP_SKB_CB(skb)->eor = 1;

			goto out;

		}

		if (skb->len < max || (flags & MSG_OOB) || unlikely(tp->repair))

			continue;

		if (forced_push(tp)) {

			tcp_mark_push(tp, skb);

			__tcp_push_pending_frames(sk, mss_now, TCP_NAGLE_PUSH);

		} else if (skb == tcp_send_head(sk))

			tcp_push_one(sk, mss_now);

		continue;

......

	}

......

}

tcp_sendmsg 的实现还是很复杂的，这里面做了这样几件事情。

msg 是用户要写入的数据，这个数据要拷贝到内核协议栈里面去发送；在内核协议栈里面，网络包的数据都是由 struct sk_buff 维护的，因而第一件事情就是找到一个空闲的内存空间，将用户要写入的数据，拷贝到 struct sk_buff 的管辖范围内。而第二件事情就是发送 struct sk_buff。

在 tcp_sendmsg 中，我们首先通过强制类型转换，将 sock 结构转换为 struct tcp_sock，这个是维护 TCP 连接状态的重要数据结构。

接下来是 tcp_sendmsg 的第一件事情，把数据拷贝到 struct sk_buff。

我们先声明一个变量 copied，初始化为 0，这表示拷贝了多少数据。紧接着是一个循环，while (msg_data_left(msg))，也即如果用户的数据没有发送完毕，就一直循环。循环里声明了一个 copy 变量，表示这次拷贝的数值，在循环的最后有 copied += copy，将每次拷贝的数量都加起来。

我们这里只需要看一次循环做了哪些事情。

第一步，tcp_write_queue_tail 从 TCP 写入队列 sk_write_queue 中拿出最后一个 struct sk_buff，在这个写入队列中排满了要发送的 struct sk_buff，为什么要拿最后一个呢？这里面只有最后一个，可能会因为上次用户给的数据太少，而没有填满。

第二步，tcp_send_mss 会计算 MSS，也即 Max Segment Size。这是什么呢？这个意思是说，我们在网络上传输的网络包的大小是有限制的，而这个限制在最底层开始就有。

MTU（Maximum Transmission Unit，最大传输单元）是二层的一个定义。以以太网为例，MTU 为 1500 个 Byte，前面有 6 个 Byte 的目标 MAC 地址，6 个 Byte 的源 MAC 地址，2 个 Byte 的类型，后面有 4 个 Byte 的 CRC 校验，共 1518 个 Byte。

在 IP 层，一个 IP 数据报在以太网中传输，如果它的长度大于该 MTU 值，就要进行分片传输。

在 TCP 层有个MSS（Maximum Segment Size，最大分段大小），等于 MTU 减去 IP 头，再减去 TCP 头。也就是，在不分片的情况下，TCP 里面放的最大内容。

在这里，max 是 struct sk_buff 的最大数据长度，skb->len 是当前已经占用的 skb 的数据长度，相减得到当前 skb 的剩余数据空间。

第三步，如果 copy 小于 0，说明最后一个 struct sk_buff 已经没地方存放了，需要调用 sk_stream_alloc_skb，重新分配 struct sk_buff，然后调用 skb_entail，将新分配的 sk_buff 放到队列尾部。

struct sk_buff 是存储网络包的重要的数据结构，在应用层数据包叫 data，在 TCP 层我们称为 segment，在 IP 层我们叫 packet，在数据链路层称为 frame。在 struct sk_buff，首先是一个链表，将 struct sk_buff 结构串起来。

接下来，我们从 headers_start 开始，到 headers_end 结束，里面都是各层次的头的位置。这里面有二层的 mac_header、三层的 network_header 和四层的 transport_header。

struct sk_buff {

	union {

		struct {

			/* These two members must be first. */

			struct sk_buff		*next;

			struct sk_buff		*prev;

......

		};

		struct rb_node	rbnode; /* used in netem & tcp stack */

	};

......

	/* private: */

	__u32			headers_start[0];

	/* public: */

......

	__u32			priority;

	int			skb_iif;

	__u32			hash;

	__be16			vlan_proto;

	__u16			vlan_tci;

......

	union {

		__u32		mark;

		__u32		reserved_tailroom;

	};

	union {

		__be16		inner_protocol;

		__u8		inner_ipproto;

	};

	__u16			inner_transport_header;

	__u16			inner_network_header;

	__u16			inner_mac_header;

	__be16			protocol;

	__u16			transport_header;

	__u16			network_header;

	__u16			mac_header;

	/* private: */

	__u32			headers_end[0];

	/* public: */

	/* These elements must be at the end, see alloc_skb() for details.  */

	sk_buff_data_t		tail;

	sk_buff_data_t		end;

	unsigned char		*head,

				*data;

	unsigned int		truesize;

	refcount_t		users;

};

最后几项， head 指向分配的内存块起始地址。data 这个指针指向的位置是可变的。它有可能随着报文所处的层次而变动。当接收报文时，从网卡驱动开始，通过协议栈层层往上传送数据报，通过增加 skb->data 的值，来逐步剥离协议首部。而要发送报文时，各协议会创建 sk_buff{}，在经过各下层协议时，通过减少 skb->data 的值来增加协议首部。tail 指向数据的结尾，end 指向分配的内存块的结束地址。

要分配这样一个结构，sk_stream_alloc_skb 会最终调用到 __alloc_skb。在这个函数里面，除了分配一个 sk_buff 结构之外，还要分配 sk_buff 指向的数据区域。这段数据区域分为下面这几个部分。

第一部分是连续的数据区域。紧接着是第二部分，一个 struct skb_shared_info 结构。这个结构是对于网络包发送过程的一个优化，因为传输层之上就是应用层了。按照 TCP 的定义，应用层感受不到下面的网络层的 IP 包是一个个独立的包的存在的。反正就是一个流，往里写就是了，可能一下子写多了，超过了一个 IP 包的承载能力，就会出现上面 MSS 的定义，拆分成一个个的 Segment 放在一个个的 IP 包里面，也可能一次写一点，一次写一点，这样数据是分散的，在 IP 层还要通过内存拷贝合成一个 IP 包。

为了减少内存拷贝的代价，有的网络设备支持分散聚合（Scatter/Gather）I/O，顾名思义，就是 IP 层没必要通过内存拷贝进行聚合，让散的数据零散的放在原处，在设备层进行聚合。如果使用这种模式，网络包的数据就不会放在连续的数据区域，而是放在 struct skb_shared_info 结构里面指向的离散数据，skb_shared_info 的成员变量 skb_frag_t frags[MAX_SKB_FRAGS]，会指向一个数组的页面，就不能保证连续了。

于是我们就有了第四步。在注释 /* Where to copy to? */ 后面有个 if-else 分支。if 分支就是 skb_add_data_nocache 将数据拷贝到连续的数据区域。else 分支就是 skb_copy_to_page_nocache 将数据拷贝到 struct skb_shared_info 结构指向的不需要连续的页面区域。

第五步，就是要发生网络包了。第一种情况是积累的数据报数目太多了，因而我们需要通过调用 __tcp_push_pending_frames 发送网络包。第二种情况是，这是第一个网络包，需要马上发送，调用 tcp_push_one。无论 __tcp_push_pending_frames 还是 tcp_push_one，都会调用 tcp_write_xmit 发送网络包。

至此，tcp_sendmsg 解析完了。

解析 tcp_write_xmit 函数

接下来我们来看，tcp_write_xmit 是如何发送网络包的。

static bool tcp_write_xmit(struct sock *sk, unsigned int mss_now, int nonagle, int push_one, gfp_t gfp)

{

	struct tcp_sock *tp = tcp_sk(sk);

	struct sk_buff *skb;

	unsigned int tso_segs, sent_pkts;

	int cwnd_quota;

......

	max_segs = tcp_tso_segs(sk, mss_now);

	while ((skb = tcp_send_head(sk))) {

		unsigned int limit;

......

		tso_segs = tcp_init_tso_segs(skb, mss_now);

......

		cwnd_quota = tcp_cwnd_test(tp, skb);

......

		if (unlikely(!tcp_snd_wnd_test(tp, skb, mss_now))) {

			is_rwnd_limited = true;

			break;

		}

......

		limit = mss_now;

        if (tso_segs > 1 && !tcp_urg_mode(tp))

            limit = tcp_mss_split_point(sk, skb, mss_now, min_t(unsigned int, cwnd_quota, max_segs), nonagle);

		if (skb->len > limit &&

		    unlikely(tso_fragment(sk, skb, limit, mss_now, gfp)))

			break;

......

		if (unlikely(tcp_transmit_skb(sk, skb, 1, gfp)))

			break;

repair:

		/* Advance the send_head.  This one is sent out.

		 * This call will increment packets_out.

		 */

		tcp_event_new_data_sent(sk, skb);

		tcp_minshall_update(tp, mss_now, skb);

		sent_pkts += tcp_skb_pcount(skb);

		if (push_one)

			break;

	}

......

}

这里面主要的逻辑是一个循环，用来处理发送队列，只要队列不空，就会发送。

在一个循环中，涉及 TCP 层的很多传输算法，我们来一一解析。

第一个概念是TSO（TCP Segmentation Offload）。如果发送的网络包非常大，就像上面说的一样，要进行分段。分段这个事情可以由协议栈代码在内核做，但是缺点是比较费 CPU，另一种方式是延迟到硬件网卡去做，需要网卡支持对大数据包进行自动分段，可以降低 CPU 负载。

在代码中，tcp_init_tso_segs 会调用 tcp_set_skb_tso_segs。这里面有这样的语句：DIV_ROUND_UP(skb->len, mss_now)。也就是 sk_buff 的长度除以 mss_now，应该分成几个段。如果算出来要分成多个段，接下来就是要看，是在这里（协议栈的代码里面）分好，还是等待到了底层网卡再分。

于是，调用函数 tcp_mss_split_point，开始计算切分的 limit。这里面会计算 max_len = mss_now * max_segs，根据现在不切分来计算 limit，所以下一步的判断中，大部分情况下 tso_fragment 不会被调用，等待到了底层网卡来切分。

第二个概念是拥塞窗口的概念（cwnd，congestion window），也就是说为了避免拼命发包，把网络塞满了，定义一个窗口的概念，在这个窗口之内的才能发送，超过这个窗口的就不能发送，来控制发送的频率。

那窗口大小是多少呢？就是遵循下面这个著名的拥塞窗口变化图。

一开始的窗口只有一个 mss 大小叫作 slow start（慢启动）。一开始的增长速度的很快的，翻倍增长。一旦到达一个临界值 ssthresh，就变成线性增长，我们就称为拥塞避免。什么时候算真正拥塞呢？就是出现了丢包。一旦丢包，一种方法是马上降回到一个 mss，然后重复先翻倍再线性对的过程。如果觉得太过激进，也可以有第二种方法，就是降到当前 cwnd 的一半，然后进行线性增长。

在代码中，tcp_cwnd_test 会将当前的 snd_cwnd，减去已经在窗口里面尚未发送完毕的网络包，那就是剩下的窗口大小 cwnd_quota，也即就能发送这么多了。

第三个概念就是接收窗口rwnd 的概念（receive window），也叫滑动窗口。如果说拥塞窗口是为了怕把网络塞满，在出现丢包的时候减少发送速度，那么滑动窗口就是为了怕把接收方塞满，而控制发送速度。

滑动窗口，其实就是接收方告诉发送方自己的网络包的接收能力，超过这个能力，我就受不了了。因为滑动窗口的存在，将发送方的缓存分成了四个部分。

第一部分：发送了并且已经确认的。这部分是已经发送完毕的网络包，这部分没有用了，可以回收。

第二部分：发送了但尚未确认的。这部分，发送方要等待，万一发送不成功，还要重新发送，所以不能删除。

第三部分：没有发送，但是已经等待发送的。这部分是接收方空闲的能力，可以马上发送，接收方收得了。

第四部分：没有发送，并且暂时还不会发送的。这部分已经超过了接收方的接收能力，再发送接收方就收不了了。

因为滑动窗口的存在，接收方的缓存也要分成了三个部分。

第一部分：接受并且确认过的任务。这部分完全接收成功了，可以交给应用层了。

第二部分：还没接收，但是马上就能接收的任务。这部分有的网络包到达了，但是还没确认，不算完全完毕，有的还没有到达，那就是接收方能够接受的最大的网络包数量。

第三部分：还没接收，也没法接收的任务。这部分已经超出接收方能力。

在网络包的交互过程中，接收方会将第二部分的大小，作为 AdvertisedWindow 发送给发送方，发送方就可以根据他来调整发送速度了。

在 tcp_snd_wnd_test 函数中，会判断 sk_buff 中的 end_seq 和 tcp_wnd_end(tp) 之间的关系，也即这个 sk_buff 是否在滑动窗口的允许范围之内。如果不在范围内，说明发送要受限制了，我们就要把 is_rwnd_limited 设置为 true。

接下来，tcp_mss_split_point 函数要被调用了。

static unsigned int tcp_mss_split_point(const struct sock *sk,

                                        const struct sk_buff *skb,

                                        unsigned int mss_now,

                                        unsigned int max_segs,

                                        int nonagle)

{

        const struct tcp_sock *tp = tcp_sk(sk);

        u32 partial, needed, window, max_len;

        window = tcp_wnd_end(tp) - TCP_SKB_CB(skb)->seq;

        max_len = mss_now * max_segs;

        if (likely(max_len <= window && skb != tcp_write_queue_tail(sk)))

                return max_len;

        needed = min(skb->len, window);

        if (max_len <= needed)

                return max_len;

......

        return needed;

}

这里面除了会判断上面讲的，是否会因为超出 mss 而分段，还会判断另一个条件，就是是否在滑动窗口的运行范围之内，如果小于窗口的大小，也需要分段，也即需要调用 tso_fragment。

在一个循环的最后，是调用 tcp_transmit_skb，真的去发送一个网络包。

static int tcp_transmit_skb(struct sock *sk, struct sk_buff *skb, int clone_it,

                gfp_t gfp_mask)

{

    const struct inet_connection_sock *icsk = inet_csk(sk);

    struct inet_sock *inet;

    struct tcp_sock *tp;

    struct tcp_skb_cb *tcb;

    struct tcphdr *th;

    int err;

    tp = tcp_sk(sk);

    skb->skb_mstamp = tp->tcp_mstamp;

    inet = inet_sk(sk);

    tcb = TCP_SKB_CB(skb);

    memset(&opts, 0, sizeof(opts));

    tcp_header_size = tcp_options_size + sizeof(struct tcphdr);

    skb_push(skb, tcp_header_size);

    /* Build TCP header and checksum it. */

    th = (struct tcphdr *)skb->data;

    th->source      = inet->inet_sport;

    th->dest        = inet->inet_dport;

    th->seq         = htonl(tcb->seq);

    th->ack_seq     = htonl(tp->rcv_nxt);

    *(((__be16 *)th) + 6)   = htons(((tcp_header_size >> 2) << 12) |

                    tcb->tcp_flags);

    th->check       = 0;

    th->urg_ptr     = 0;

......

    tcp_options_write((__be32 *)(th + 1), tp, &opts);

    th->window  = htons(min(tp->rcv_wnd, 65535U));

......

    err = icsk->icsk_af_ops->queue_xmit(sk, skb, &inet->cork.fl);

......

}

tcp_transmit_skb 这个函数比较长，主要做了两件事情，第一件事情就是填充 TCP 头，如果我们对着 TCP 头的格式。

这里面有源端口，设置为 inet_sport，有目标端口，设置为 inet_dport；有序列号，设置为 tcb->seq；有确认序列号，设置为 tp->rcv_nxt。我们把所有的 flags 设置为 tcb->tcp_flags。设置选项为 opts。设置窗口大小为 tp->rcv_wnd。

全部设置完毕之后，就会调用 icsk_af_ops 的 queue_xmit 方法，icsk_af_ops 指向 ipv4_specific，也即调用的是 ip_queue_xmit 函数。

const struct inet_connection_sock_af_ops ipv4_specific = {

        .queue_xmit        = ip_queue_xmit,

        .send_check        = tcp_v4_send_check,

        .rebuild_header    = inet_sk_rebuild_header,

        .sk_rx_dst_set     = inet_sk_rx_dst_set,

        .conn_request      = tcp_v4_conn_request,

        .syn_recv_sock     = tcp_v4_syn_recv_sock,

        .net_header_len    = sizeof(struct iphdr),

        .setsockopt        = ip_setsockopt,

        .getsockopt        = ip_getsockopt,

        .addr2sockaddr     = inet_csk_addr2sockaddr,

        .sockaddr_len      = sizeof(struct sockaddr_in),

        .mtu_reduced       = tcp_v4_mtu_reduced,

};

总结时刻

这一节，我们解析了发送一个网络包的一部分过程，如下图所示。

这个过程分成几个层次。

VFS 层：write 系统调用找到 struct file，根据里面的 file_operations 的定义，调用 sock_write_iter 函数。sock_write_iter 函数调用 sock_sendmsg 函数。

Socket 层：从 struct file 里面的 private_data 得到 struct socket，根据里面 ops 的定义，调用 inet_sendmsg 函数。

Sock 层：从 struct socket 里面的 sk 得到 struct sock，根据里面 sk_prot 的定义，调用 tcp_sendmsg 函数。

TCP 层：tcp_sendmsg 函数会调用 tcp_write_xmit 函数，tcp_write_xmit 函数会调用 tcp_transmit_skb，在这里实现了 TCP 层面向连接的逻辑。

IP 层：扩展 struct sock，得到 struct inet_connection_sock，根据里面 icsk_af_ops 的定义，调用 ip_queue_xmit 函数。

课堂练习

如果你对 TCP 协议的结构不太熟悉，可以使用 tcpdump 命令截取一个 TCP 的包，看看里面的结构。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

46 | 发送网络包（下）：如何表达我们想让合作伙伴做什么？

上一节我们讲网络包的发送，讲了上半部分，也即从 VFS 层一直到 IP 层，这一节我们接着看下去，看 IP 层和 MAC 层是如何发送数据的。

解析 ip_queue_xmit 函数

从 ip_queue_xmit 函数开始，我们就要进入 IP 层的发送逻辑了。

int ip_queue_xmit(struct sock *sk, struct sk_buff *skb, struct flowi *fl)

{

    struct inet_sock *inet = inet_sk(sk);

    struct net *net = sock_net(sk);

    struct ip_options_rcu *inet_opt;

    struct flowi4 *fl4;

    struct rtable *rt;

    struct iphdr *iph;

    int res;

    inet_opt = rcu_dereference(inet->inet_opt);

    fl4 = &fl->u.ip4;

    rt = skb_rtable(skb);

    /* Make sure we can route this packet. */

    rt = (struct rtable *)__sk_dst_check(sk, 0);

    if (!rt) {

        __be32 daddr;

        /* Use correct destination address if we have options. */

        daddr = inet->inet_daddr;

 ......

        rt = ip_route_output_ports(net, fl4, sk,

                       daddr, inet->inet_saddr,

                       inet->inet_dport,

                       inet->inet_sport,

                       sk->sk_protocol,

                       RT_CONN_FLAGS(sk),

                       sk->sk_bound_dev_if);

        if (IS_ERR(rt))

            goto no_route;

        sk_setup_caps(sk, &rt->dst);

    }

    skb_dst_set_noref(skb, &rt->dst);

packet_routed:

    /* OK, we know where to send it, allocate and build IP header. */

    skb_push(skb, sizeof(struct iphdr) + (inet_opt ? inet_opt->opt.optlen : 0));

    skb_reset_network_header(skb);

    iph = ip_hdr(skb);

    *((__be16 *)iph) = htons((4 << 12) | (5 << 8) | (inet->tos & 0xff));

    if (ip_dont_fragment(sk, &rt->dst) && !skb->ignore_df)

        iph->frag_off = htons(IP_DF);

    else

        iph->frag_off = 0;

    iph->ttl      = ip_select_ttl(inet, &rt->dst);

    iph->protocol = sk->sk_protocol;

    ip_copy_addrs(iph, fl4);

    /* Transport layer set skb->h.foo itself. */

    if (inet_opt && inet_opt->opt.optlen) {

        iph->ihl += inet_opt->opt.optlen >> 2;

        ip_options_build(skb, &inet_opt->opt, inet->inet_daddr, rt, 0);

    }

    ip_select_ident_segs(net, skb, sk,

                 skb_shinfo(skb)->gso_segs ?: 1);

    /* TODO : should we use skb->sk here instead of sk ? */

    skb->priority = sk->sk_priority;

    skb->mark = sk->sk_mark;

    res = ip_local_out(net, sk, skb);

......

}

在 ip_queue_xmit 中，也即 IP 层的发送函数里面，有三部分逻辑。

第一部分，选取路由，也即我要发送这个包应该从哪个网卡出去。

这件事情主要由 ip_route_output_ports 函数完成。接下来的调用链为：ip_route_output_ports->ip_route_output_flow->__ip_route_output_key->ip_route_output_key_hash->ip_route_output_key_hash_rcu。

struct rtable *ip_route_output_key_hash_rcu(struct net *net, struct flowi4 *fl4, struct fib_result *res, const struct sk_buff *skb)

{

	struct net_device *dev_out = NULL;

	int orig_oif = fl4->flowi4_oif;

	unsigned int flags = 0;

	struct rtable *rth;

......

    err = fib_lookup(net, fl4, res, 0);

......

make_route:

	rth = __mkroute_output(res, fl4, orig_oif, dev_out, flags);

......

}

ip_route_output_key_hash_rcu 先会调用 fib_lookup。

FIB全称是 Forwarding Information Base，转发信息表。其实就是咱们常说的路由表。

static inline int fib_lookup(struct net *net, const struct flowi4 *flp, struct fib_result *res, unsigned int flags)

{	struct fib_table *tb;

......

	tb = fib_get_table(net, RT_TABLE_MAIN);

	if (tb)

		err = fib_table_lookup(tb, flp, res, flags | FIB_LOOKUP_NOREF);

......

}

路由表可以有多个，一般会有一个主表，RT_TABLE_MAIN。然后 fib_table_lookup 函数在这个表里面进行查找。

路由表是一个什么样的结构呢？

路由就是在 Linux 服务器上的路由表里面配置的一条一条规则。这些规则大概是这样的：想访问某个网段，从某个网卡出去，下一跳是某个 IP。

之前我们讲过一个简单的拓扑图，里面的三台 Linux 机器的路由表都可以通过 ip route 命令查看。

# Linux 服务器 A

default via 192.168.1.1 dev eth0

192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.100 metric 100

# Linux 服务器 B

default via 192.168.2.1 dev eth0

192.168.2.0/24 dev eth0 proto kernel scope link src 192.168.2.100 metric 100

# Linux 服务器做路由器

192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.1

192.168.2.0/24 dev eth1 proto kernel scope link src 192.168.2.1

其实，对于两端的服务器来讲，我们没有太多路由可以选，但是对于中间的 Linux 服务器做路由器来讲，这里有两条路可以选，一个是往左面转发，一个是往右面转发，就需要路由表的查找。

fib_table_lookup 的代码逻辑比较复杂，好在注释比较清楚。因为路由表要按照前缀进行查询，希望找到最长匹配的那一个，例如 192.168.2.0/24 和 192.168.0.0/16 都能匹配 192.168.2.100/24。但是，我们应该使用 192.168.2.0/24 的这一条。

为了更方面的做这个事情，我们使用了 Trie 树这种结构。比如我们有一系列的字符串：{bcs#, badge#, baby#, back#, badger#, badness#}。之所以每个字符串都加上 #，是希望不要一个字符串成为另外一个字符串的前缀。然后我们把它们放在 Trie 树中，如下图所示：

对于将 IP 地址转成二进制放入 trie 树，也是同样的道理，可以很快进行路由的查询。

找到了路由，就知道了应该从哪个网卡发出去。

然后，ip_route_output_key_hash_rcu 会调用 __mkroute_output，创建一个 struct rtable，表示找到的路由表项。这个结构是由 rt_dst_alloc 函数分配的。

struct rtable *rt_dst_alloc(struct net_device *dev,

			    unsigned int flags, u16 type,

			    bool nopolicy, bool noxfrm, bool will_cache)

{

	struct rtable *rt;

	rt = dst_alloc(&ipv4_dst_ops, dev, 1, DST_OBSOLETE_FORCE_CHK,

		       (will_cache ? 0 : DST_HOST) |

		       (nopolicy ? DST_NOPOLICY : 0) |

		       (noxfrm ? DST_NOXFRM : 0));

	if (rt) {

		rt->rt_genid = rt_genid_ipv4(dev_net(dev));

		rt->rt_flags = flags;

		rt->rt_type = type;

		rt->rt_is_input = 0;

		rt->rt_iif = 0;

		rt->rt_pmtu = 0;

		rt->rt_gateway = 0;

		rt->rt_uses_gateway = 0;

		rt->rt_table_id = 0;

		INIT_LIST_HEAD(&rt->rt_uncached);

		rt->dst.output = ip_output;

		if (flags & RTCF_LOCAL)

			rt->dst.input = ip_local_deliver;

	}

	return rt;

}

最终返回 struct rtable 实例，第一部分也就完成了。

第二部分，就是准备 IP 层的头，往里面填充内容。这就要对着 IP 层的头的格式进行理解。

在这里面，服务类型设置为 tos，标识位里面设置是否允许分片 frag_off。如果不允许，而遇到 MTU 太小过不去的情况，就发送 ICMP 报错。TTL 是这个包的存活时间，为了防止一个 IP 包迷路以后一直存活下去，每经过一个路由器 TTL 都减一，减为零则“死去”。设置 protocol，指的是更上层的协议，这里是 TCP。源地址和目标地址由 ip_copy_addrs 设置。最后，设置 options。

第三部分，就是调用 ip_local_out 发送 IP 包。

int ip_local_out(struct net *net, struct sock *sk, struct sk_buff *skb)

{

	int err;

	err = __ip_local_out(net, sk, skb);

	if (likely(err == 1))

		err = dst_output(net, sk, skb);

	return err;

}

int __ip_local_out(struct net *net, struct sock *sk, struct sk_buff *skb)

{

	struct iphdr *iph = ip_hdr(skb);

	iph->tot_len = htons(skb->len);

	skb->protocol = htons(ETH_P_IP);

	return nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT,

		       net, sk, skb, NULL, skb_dst(skb)->dev,

		       dst_output);

}

ip_local_out 先是调用 __ip_local_out，然后里面调用了 nf_hook。这是什么呢？nf 的意思是 Netfilter，这是 Linux 内核的一个机制，用于在网络发送和转发的关键节点上加上 hook 函数，这些函数可以截获数据包，对数据包进行干预。

一个著名的实现，就是内核模块 ip_tables。在用户态，还有一个客户端程序 iptables，用命令行来干预内核的规则。

iptables 有表和链的概念，最终要的是两个表。

filter 表处理过滤功能，主要包含以下三个链。

INPUT 链：过滤所有目标地址是本机的数据包

FORWARD 链：过滤所有路过本机的数据包

OUTPUT 链：过滤所有由本机产生的数据包

nat 表主要处理网络地址转换，可以进行 SNAT（改变源地址）、DNAT（改变目标地址），包含以下三个链。

PREROUTING 链：可以在数据包到达时改变目标地址

OUTPUT 链：可以改变本地产生的数据包的目标地址

POSTROUTING 链：在数据包离开时改变数据包的源地址

在这里，网络包马上就要发出去了，因而是 NF_INET_LOCAL_OUT，也即 ouput 链，如果用户曾经在 iptables 里面写过某些规则，就会在 nf_hook 这个函数里面起作用。

ip_local_out 再调用 dst_output，就是真正的发送数据。

/* Output packet to network from transport.  */

static inline int dst_output(struct net *net, struct sock *sk, struct sk_buff *skb)

{

	return skb_dst(skb)->output(net, sk, skb);

}

这里调用的就是 struct rtable 成员 dst 的 ouput 函数。在 rt_dst_alloc 中，我们可以看到，output 函数指向的是 ip_output。

int ip_output(struct net *net, struct sock *sk, struct sk_buff *skb)

{

	struct net_device *dev = skb_dst(skb)->dev;

	skb->dev = dev;

	skb->protocol = htons(ETH_P_IP);

	return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING,

			    net, sk, skb, NULL, dev,

			    ip_finish_output,

			    !(IPCB(skb)->flags & IPSKB_REROUTED));

}

在 ip_output 里面，我们又看到了熟悉的 NF_HOOK。这一次是 NF_INET_POST_ROUTING，也即 POSTROUTING 链，处理完之后，调用 ip_finish_output。

解析 ip_finish_output 函数

从 ip_finish_output 函数开始，发送网络包的逻辑由第三层到达第二层。ip_finish_output 最终调用 ip_finish_output2。

static int ip_finish_output2(struct net *net, struct sock *sk, struct sk_buff *skb)

{

	struct dst_entry *dst = skb_dst(skb);

	struct rtable *rt = (struct rtable *)dst;

	struct net_device *dev = dst->dev;

	unsigned int hh_len = LL_RESERVED_SPACE(dev);

	struct neighbour *neigh;

	u32 nexthop;

......

	nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)->daddr);

	neigh = __ipv4_neigh_lookup_noref(dev, nexthop);

	if (unlikely(!neigh))

		neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);

	if (!IS_ERR(neigh)) {

		int res;

		sock_confirm_neigh(skb, neigh);

		res = neigh_output(neigh, skb);

		return res;

	}

......

}

在 ip_finish_output2 中，先找到 struct rtable 路由表里面的下一跳，下一跳一定和本机在同一个局域网中，可以通过二层进行通信，因而通过 __ipv4_neigh_lookup_noref，查找如何通过二层访问下一跳。

static inline struct neighbour *__ipv4_neigh_lookup_noref(struct net_device *dev, u32 key)

{

	return ___neigh_lookup_noref(&arp_tbl, neigh_key_eq32, arp_hashfn, &key, dev);

}

__ipv4_neigh_lookup_noref 是从本地的 ARP 表中查找下一跳的 MAC 地址。ARP 表的定义如下：

struct neigh_table arp_tbl = {

    .family     = AF_INET,

    .key_len    = 4,

    .protocol   = cpu_to_be16(ETH_P_IP),

    .hash       = arp_hash,

    .key_eq     = arp_key_eq,

    .constructor    = arp_constructor,

    .proxy_redo = parp_redo,

    .id     = "arp_cache",

......

    .gc_interval    = 30 * HZ,

    .gc_thresh1 = 128,

    .gc_thresh2 = 512,

    .gc_thresh3 = 1024,

};

如果在 ARP 表中没有找到相应的项，则调用 __neigh_create 进行创建。

struct neighbour *__neigh_create(struct neigh_table *tbl, const void *pkey, struct net_device *dev, bool want_ref)

{

    u32 hash_val;

    int key_len = tbl->key_len;

    int error;

    struct neighbour *n1, *rc, *n = neigh_alloc(tbl, dev);

    struct neigh_hash_table *nht;

    memcpy(n->primary_key, pkey, key_len);

    n->dev = dev;

    dev_hold(dev);

    /* Protocol specific setup. */

    if (tbl->constructor && (error = tbl->constructor(n)) < 0) {

......

    }

......

    if (atomic_read(&tbl->entries) > (1 << nht->hash_shift))

        nht = neigh_hash_grow(tbl, nht->hash_shift + 1);

    hash_val = tbl->hash(pkey, dev, nht->hash_rnd) >> (32 - nht->hash_shift);

    for (n1 = rcu_dereference_protected(nht->hash_buckets[hash_val],

                        lockdep_is_held(&tbl->lock));

         n1 != NULL;

         n1 = rcu_dereference_protected(n1->next,

            lockdep_is_held(&tbl->lock))) {

        if (dev == n1->dev && !memcmp(n1->primary_key, pkey, key_len)) {

            if (want_ref)

                neigh_hold(n1);

            rc = n1;

            goto out_tbl_unlock;

        }

    }

......

    rcu_assign_pointer(n->next,

               rcu_dereference_protected(nht->hash_buckets[hash_val],

                             lockdep_is_held(&tbl->lock)));

    rcu_assign_pointer(nht->hash_buckets[hash_val], n);

......

}

__neigh_create 先调用 neigh_alloc，创建一个 struct neighbour 结构，用于维护 MAC 地址和 ARP 相关的信息。这个名字也很好理解，大家都是在一个局域网里面，可以通过 MAC 地址访问到，当然是邻居了。

static struct neighbour *neigh_alloc(struct neigh_table *tbl, struct net_device *dev)

{

	struct neighbour *n = NULL;

	unsigned long now = jiffies;

	int entries;

......

	n = kzalloc(tbl->entry_size + dev->neigh_priv_len, GFP_ATOMIC);

	if (!n)

		goto out_entries;

	__skb_queue_head_init(&n->arp_queue);

	rwlock_init(&n->lock);

	seqlock_init(&n->ha_lock);

	n->updated	  = n->used = now;

	n->nud_state	  = NUD_NONE;

	n->output	  = neigh_blackhole;

	seqlock_init(&n->hh.hh_lock);

	n->parms	  = neigh_parms_clone(&tbl->parms);

	setup_timer(&n->timer, neigh_timer_handler, (unsigned long)n);

	NEIGH_CACHE_STAT_INC(tbl, allocs);

	n->tbl		  = tbl;

	refcount_set(&n->refcnt, 1);

	n->dead		  = 1;

......

}

在 neigh_alloc 中，我们先分配一个 struct neighbour 结构并且初始化。这里面比较重要的有两个成员，一个是 arp_queue，所以上层想通过 ARP 获取 MAC 地址的任务，都放在这个队列里面。另一个是 timer 定时器，我们设置成，过一段时间就调用 neigh_timer_handler，来处理这些 ARP 任务。

__neigh_create 然后调用了 arp_tbl 的 constructor 函数，也即调用了 arp_constructor，在这里面定义了 ARP 的操作 arp_hh_ops。

static int arp_constructor(struct neighbour *neigh)

{

	__be32 addr = *(__be32 *)neigh->primary_key;

	struct net_device *dev = neigh->dev;

	struct in_device *in_dev;

	struct neigh_parms *parms;

......

	neigh->type = inet_addr_type_dev_table(dev_net(dev), dev, addr);

	parms = in_dev->arp_parms;

	__neigh_parms_put(neigh->parms);

	neigh->parms = neigh_parms_clone(parms);

......

	neigh->ops = &arp_hh_ops;

......

	neigh->output = neigh->ops->output;

......

}

static const struct neigh_ops arp_hh_ops = {

	.family =		AF_INET,

	.solicit =		arp_solicit,

	.error_report =		arp_error_report,

	.output =		neigh_resolve_output,

	.connected_output =	neigh_resolve_output,

};

__neigh_create 最后是将创建的 struct neighbour 结构放入一个哈希表，从里面的代码逻辑比较容易看出，这是一个数组加链表的链式哈希表，先计算出哈希值 hash_val，得到相应的链表，然后循环这个链表找到对应的项，如果找不到就在最后插入一项。

我们回到 ip_finish_output2，在 __neigh_create 之后，会调用 neigh_output 发送网络包。

static inline int neigh_output(struct neighbour *n, struct sk_buff *skb)

{

......

	return n->output(n, skb);

}

按照上面对于 struct neighbour 的操作函数 arp_hh_ops 的定义，output 调用的是 neigh_resolve_output。

int neigh_resolve_output(struct neighbour *neigh, struct sk_buff *skb)

{

	if (!neigh_event_send(neigh, skb)) {

......

		rc = dev_queue_xmit(skb);

	}

......

}

在 neigh_resolve_output 里面，首先 neigh_event_send 触发一个事件，看能否激活 ARP。

int __neigh_event_send(struct neighbour *neigh, struct sk_buff *skb)

{

	int rc;

	bool immediate_probe = false;

	if (!(neigh->nud_state & (NUD_STALE | NUD_INCOMPLETE))) {

		if (NEIGH_VAR(neigh->parms, MCAST_PROBES) +

		    NEIGH_VAR(neigh->parms, APP_PROBES)) {

			unsigned long next, now = jiffies;

			atomic_set(&neigh->probes,

				   NEIGH_VAR(neigh->parms, UCAST_PROBES));

			neigh->nud_state     = NUD_INCOMPLETE;

			neigh->updated = now;

			next = now + max(NEIGH_VAR(neigh->parms, RETRANS_TIME),

					 HZ/2);

			neigh_add_timer(neigh, next);

			immediate_probe = true;

		}

......

	} else if (neigh->nud_state & NUD_STALE) {

		neigh_dbg(2, "neigh %p is delayed\n", neigh);

		neigh->nud_state = NUD_DELAY;

		neigh->updated = jiffies;

		neigh_add_timer(neigh, jiffies +

				NEIGH_VAR(neigh->parms, DELAY_PROBE_TIME));

	}

	if (neigh->nud_state == NUD_INCOMPLETE) {

		if (skb) {

.......

			__skb_queue_tail(&neigh->arp_queue, skb);

			neigh->arp_queue_len_Bytes += skb->truesize;

		}

		rc = 1;

	}

out_unlock_bh:

	if (immediate_probe)

		neigh_probe(neigh);

.......

}

在 __neigh_event_send 中，激活 ARP 分两种情况，第一种情况是马上激活，也即 immediate_probe。另一种情况是延迟激活则仅仅设置一个 timer。然后将 ARP 包放在 arp_queue 上。如果马上激活，就直接调用 neigh_probe；如果延迟激活，则定时器到了就会触发 neigh_timer_handler，在这里面还是会调用 neigh_probe。

我们就来看 neigh_probe 的实现，在这里面会从 arp_queue 中拿出 ARP 包来，然后调用 struct neighbour 的 solicit 操作。

static void neigh_probe(struct neighbour *neigh)

        __releases(neigh->lock)

{

        struct sk_buff *skb = skb_peek_tail(&neigh->arp_queue);

......

        if (neigh->ops->solicit)

                neigh->ops->solicit(neigh, skb);

......

}

按照上面对于 struct neighbour 的操作函数 arp_hh_ops 的定义，solicit 调用的是 arp_solicit，在这里我们可以找到对于 arp_send_dst 的调用，创建并发送一个 arp 包，得到结果放在 struct dst_entry 里面。

static void arp_send_dst(int type, int ptype, __be32 dest_ip,

                         struct net_device *dev, __be32 src_ip,

                         const unsigned char *dest_hw,

                         const unsigned char *src_hw,

                         const unsigned char *target_hw,

                         struct dst_entry *dst)

{

        struct sk_buff *skb;

......

        skb = arp_create(type, ptype, dest_ip, dev, src_ip,

                         dest_hw, src_hw, target_hw);

......

        skb_dst_set(skb, dst_clone(dst));

        arp_xmit(skb);

}

我们回到 neigh_resolve_output 中，当 ARP 发送完毕，就可以调用 dev_queue_xmit 发送二层网络包了。

/**

 *	__dev_queue_xmit - transmit a buffer

 *	@skb: buffer to transmit

 *	@accel_priv: private data used for L2 forwarding offload

 *

 *	Queue a buffer for transmission to a network device.

 */

static int __dev_queue_xmit(struct sk_buff *skb, void *accel_priv)

{

	struct net_device *dev = skb->dev;

	struct netdev_queue *txq;

	struct Qdisc *q;

......

	txq = netdev_pick_tx(dev, skb, accel_priv);

	q = rcu_dereference_bh(txq->qdisc);

	if (q->enqueue) {

		rc = __dev_xmit_skb(skb, q, dev, txq);

		goto out;

	}

......

}

就像咱们在讲述硬盘块设备的时候讲过，每个块设备都有队列，用于将内核的数据放到队列里面，然后设备驱动从队列里面取出后，将数据根据具体设备的特性发送给设备。

网络设备也是类似的，对于发送来说，有一个发送队列 struct netdev_queue *txq。

这里还有另一个变量叫做 struct Qdisc，这个是什么呢？如果我们在一台 Linux 机器上运行 ip addr，我们能看到对于一个网卡，都有下面的输出。

# ip addr

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000

    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

    inet 127.0.0.1/8 scope host lo

       valid_lft forever preferred_lft forever

    inet6 ::1/128 scope host

       valid_lft forever preferred_lft forever

2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1400 qdisc pfifo_fast state UP group default qlen 1000

    link/ether fa:16:3e:75:99:08 brd ff:ff:ff:ff:ff:ff

    inet 10.173.32.47/21 brd 10.173.39.255 scope global noprefixroute dynamic eth0

       valid_lft 67104sec preferred_lft 67104sec

    inet6 fe80::f816:3eff:fe75:9908/64 scope link

       valid_lft forever preferred_lft forever

这里面有个关键字 qdisc pfifo_fast 是什么意思呢？qdisc 全称是 queueing discipline，中文叫排队规则。内核如果需要通过某个网络接口发送数据包，都需要按照为这个接口配置的 qdisc（排队规则）把数据包加入队列。

最简单的 qdisc 是 pfifo，它不对进入的数据包做任何的处理，数据包采用先入先出的方式通过队列。pfifo_fast 稍微复杂一些，它的队列包括三个波段（band）。在每个波段里面，使用先进先出规则。

三个波段的优先级也不相同。band 0 的优先级最高，band 2 的最低。如果 band 0 里面有数据包，系统就不会处理 band 1 里面的数据包，band 1 和 band 2 之间也是一样。

数据包是按照服务类型（Type of Service，TOS）被分配到三个波段里面的。TOS 是 IP 头里面的一个字段，代表了当前的包是高优先级的，还是低优先级的。

pfifo_fast 分为三个先入先出的队列，我们能称为三个 Band。根据网络包里面的 TOS，看这个包到底应该进入哪个队列。TOS 总共四位，每一位表示的意思不同，总共十六种类型。

通过命令行 tc qdisc show dev eth0，我们可以输出结果 priomap，也是十六个数字。在 0 到 2 之间，和 TOS 的十六种类型对应起来。不同的 TOS 对应不同的队列。其中 Band 0 优先级最高，发送完毕后才轮到 Band 1 发送，最后才是 Band 2。

# tc qdisc show dev eth0

qdisc pfifo_fast 0: root refcnt 2 bands 3 priomap  1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1

接下来，__dev_xmit_skb 开始进行网络包发送。

static inline int __dev_xmit_skb(struct sk_buff *skb, struct Qdisc *q,

                 struct net_device *dev,

                 struct netdev_queue *txq)

{

......

    rc = q->enqueue(skb, q, &to_free) & NET_XMIT_MASK;

    if (qdisc_run_begin(q)) {

......

        __qdisc_run(q);

    }

......

}

void __qdisc_run(struct Qdisc *q)

{

    int quota = dev_tx_weight;

    int packets;

     while (qdisc_restart(q, &packets)) {

        /*

         * Ordered by possible occurrence: Postpone processing if

         * 1. we've exceeded packet quota

         * 2. another process needs the CPU;

         */

        quota -= packets;

        if (quota <= 0 || need_resched()) {

            __netif_schedule(q);

            break;

        }

     }

     qdisc_run_end(q);

}

__dev_xmit_skb 会将请求放入队列，然后调用 __qdisc_run 处理队列中的数据。qdisc_restart 用于数据的发送。根据注释中的说法，qdisc 的另一个功能是用于控制网络包的发送速度，因而如果超过速度，就需要重新调度，则会调用 __netif_schedule。

static void __netif_reschedule(struct Qdisc *q)

{

    struct softnet_data *sd;

    unsigned long flags;

    local_irq_save(flags);

    sd = this_cpu_ptr(&softnet_data);

    q->next_sched = NULL;

    *sd->output_queue_tailp = q;

    sd->output_queue_tailp = &q->next_sched;

    raise_softirq_irqoff(NET_TX_SOFTIRQ);

    local_irq_restore(flags);

}

__netif_schedule 会调用 __netif_reschedule，发起一个软中断 NET_TX_SOFTIRQ。咱们讲设备驱动程序的时候讲过，设备驱动程序处理中断，分两个过程，一个是屏蔽中断的关键处理逻辑，一个是延迟处理逻辑。当时说工作队列是延迟处理逻辑的处理方案，软中断也是一种方案。

在系统初始化的时候，我们会定义软中断的处理函数。例如，NET_TX_SOFTIRQ 的处理函数是 net_tx_action，用于发送网络包。还有一个 NET_RX_SOFTIRQ 的处理函数是 net_rx_action，用于接收网络包。接收网络包的过程咱们下一节解析。

open_softirq(NET_TX_SOFTIRQ, net_tx_action);

open_softirq(NET_RX_SOFTIRQ, net_rx_action);

这里我们来解析一下 net_tx_action。

static __latent_entropy void net_tx_action(struct softirq_action *h)

{

    struct softnet_data *sd = this_cpu_ptr(&softnet_data);

......

    if (sd->output_queue) {

        struct Qdisc *head;

        local_irq_disable();

        head = sd->output_queue;

        sd->output_queue = NULL;

        sd->output_queue_tailp = &sd->output_queue;

        local_irq_enable();

        while (head) {

            struct Qdisc *q = head;

            spinlock_t *root_lock;

            head = head->next_sched;

......

            qdisc_run(q);

        }

    }

}

我们会发现，net_tx_action 还是调用了 qdisc_run，还是会调用 __qdisc_run，然后调用 qdisc_restart 发送网络包。

我们来看一下 qdisc_restart 的实现。

static inline int qdisc_restart(struct Qdisc *q, int *packets)

{

        struct netdev_queue *txq;

        struct net_device *dev;

        spinlock_t *root_lock;

        struct sk_buff *skb;

        bool validate;

        /* Dequeue packet */

        skb = dequeue_skb(q, &validate, packets);

        if (unlikely(!skb))

                return 0;

        root_lock = qdisc_lock(q);

        dev = qdisc_dev(q);

        txq = skb_get_tx_queue(dev, skb);

        return sch_direct_xmit(skb, q, dev, txq, root_lock, validate);

}

qdisc_restart 将网络包从 Qdisc 的队列中拿下来，然后调用 sch_direct_xmit 进行发送。

int sch_direct_xmit(struct sk_buff *skb, struct Qdisc *q,

            struct net_device *dev, struct netdev_queue *txq,

            spinlock_t *root_lock, bool validate)

{

    int ret = NETDEV_TX_BUSY;

    if (likely(skb)) {

        if (!netif_xmit_frozen_or_stopped(txq))

            skb = dev_hard_start_xmit(skb, dev, txq, &ret);

    }

......

    if (dev_xmit_complete(ret)) {

        /* Driver sent out skb successfully or skb was consumed */

        ret = qdisc_qlen(q);

    } else {

        /* Driver returned NETDEV_TX_BUSY - requeue skb */

        ret = dev_requeue_skb(skb, q);

    }

......

}

在 sch_direct_xmit 中，调用 dev_hard_start_xmit 进行发送，如果发送不成功，会返回 NETDEV_TX_BUSY。这说明网络卡很忙，于是就调用 dev_requeue_skb，重新放入队列。

struct sk_buff *dev_hard_start_xmit(struct sk_buff *first, struct net_device *dev, struct netdev_queue *txq, int *ret)

{

    struct sk_buff *skb = first;

    int rc = NETDEV_TX_OK;

    while (skb) {

        struct sk_buff *next = skb->next;

        rc = xmit_one(skb, dev, txq, next != NULL);

        skb = next;

        if (netif_xmit_stopped(txq) && skb) {

            rc = NETDEV_TX_BUSY;

            break;

        }

    }

......

}

在 dev_hard_start_xmit 中，是一个 while 循环。每次在队列中取出一个 sk_buff，调用 xmit_one 发送。

接下来的调用链为：xmit_one->netdev_start_xmit->__netdev_start_xmit。

static inline netdev_tx_t __netdev_start_xmit(const struct net_device_ops *ops, struct sk_buff *skb, struct net_device *dev, bool more)

{

    skb->xmit_more = more ? 1 : 0;

    return ops->ndo_start_xmit(skb, dev);

}

这个时候，已经到了设备驱动层了。我们能看到，drivers/net/ethernet/intel/ixgb/ixgb_main.c 里面有对于这个网卡的操作的定义。

static const struct net_device_ops ixgb_netdev_ops = {

        .ndo_open               = ixgb_open,

        .ndo_stop               = ixgb_close,

        .ndo_start_xmit         = ixgb_xmit_frame,

        .ndo_set_rx_mode        = ixgb_set_multi,

        .ndo_validate_addr      = eth_validate_addr,

        .ndo_set_mac_address    = ixgb_set_mac,

        .ndo_change_mtu         = ixgb_change_mtu,

        .ndo_tx_timeout         = ixgb_tx_timeout,

        .ndo_vlan_rx_add_vid    = ixgb_vlan_rx_add_vid,

        .ndo_vlan_rx_kill_vid   = ixgb_vlan_rx_kill_vid,

        .ndo_fix_features       = ixgb_fix_features,

        .ndo_set_features       = ixgb_set_features,

};

在这里面，我们可以找到对于 ndo_start_xmit 的定义，调用 ixgb_xmit_frame。

static netdev_tx_t

ixgb_xmit_frame(struct sk_buff *skb, struct net_device *netdev)

{

    struct ixgb_adapter *adapter = netdev_priv(netdev);

......

    if (count) {

        ixgb_tx_queue(adapter, count, vlan_id, tx_flags);

        /* Make sure there is space in the ring for the next send. */

        ixgb_maybe_stop_tx(netdev, &adapter->tx_ring, DESC_NEEDED);

    }

......

    return NETDEV_TX_OK;

}

在 ixgb_xmit_frame 中，我们会得到这个网卡对应的适配器，然后将其放入硬件网卡的队列中。

至此，整个发送才算结束。

总结时刻

这一节，我们继续解析了发送一个网络包的过程，我们整个过程的图画在了下面。

这个过程分成几个层次。

VFS 层：write 系统调用找到 struct file，根据里面的 file_operations 的定义，调用 sock_write_iter 函数。sock_write_iter 函数调用 sock_sendmsg 函数。

Socket 层：从 struct file 里面的 private_data 得到 struct socket，根据里面 ops 的定义，调用 inet_sendmsg 函数。

Sock 层：从 struct socket 里面的 sk 得到 struct sock，根据里面 sk_prot 的定义，调用 tcp_sendmsg 函数。

TCP 层：tcp_sendmsg 函数会调用 tcp_write_xmit 函数，tcp_write_xmit 函数会调用 tcp_transmit_skb，在这里实现了 TCP 层面向连接的逻辑。

IP 层：扩展 struct sock，得到 struct inet_connection_sock，根据里面 icsk_af_ops 的定义，调用 ip_queue_xmit 函数。

IP 层：ip_route_output_ports 函数里面会调用 fib_lookup 查找路由表。FIB 全称是 Forwarding Information Base，转发信息表，也就是路由表。

在 IP 层里面要做的另一个事情是填写 IP 层的头。

在 IP 层还要做的一件事情就是通过 iptables 规则。

MAC 层：IP 层调用 ip_finish_output 进行 MAC 层。

MAC 层需要 ARP 获得 MAC 地址，因而要调用 ___neigh_lookup_noref 查找属于同一个网段的邻居，他会调用 neigh_probe 发送 ARP。

有了 MAC 地址，就可以调用 dev_queue_xmit 发送二层网络包了，它会调用 __dev_xmit_skb 会将请求放入队列。

设备层：网络包的发送回触发一个软中断 NET_TX_SOFTIRQ 来处理队列中的数据。这个软中断的处理函数是 net_tx_action。

在软中断处理函数中，会将网络包从队列上拿下来，调用网络设备的传输函数 ixgb_xmit_frame，将网络包发的设备的队列上去。

课堂练习

上一节你应该通过 tcpdump 看到了 TCP 包头的格式，这一节，请你查看一下 IP 包的格式以及 ARP 的过程。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

47 | 接收网络包（上）：如何搞明白合作伙伴让我们做什么？

前面两节，我们分析了发送网络包的整个过程。这一节，我们来解析接收网络包的过程。

如果说网络包的发送是从应用层开始，层层调用，一直到网卡驱动程序的话，网络包的结束过程，就是一个反过来的过程，我们不能从应用层的读取开始，而应该从网卡接收到一个网络包开始。我们用两节来解析这个过程，这一节我们从硬件网卡解析到 IP 层，下一节，我们从 IP 层解析到 Socket 层。

设备驱动层

网卡作为一个硬件，接收到网络包，应该怎么通知操作系统，这个网络包到达了呢？咱们学习过输入输出设备和中断。没错，我们可以触发一个中断。但是这里有个问题，就是网络包的到来，往往是很难预期的。网络吞吐量比较大的时候，网络包的到达会十分频繁。这个时候，如果非常频繁地去触发中断，想想就觉得是个灾难。

比如说，CPU 正在做某个事情，一些网络包来了，触发了中断，CPU 停下手里的事情，去处理这些网络包，处理完毕按照中断处理的逻辑，应该回去继续处理其他事情。这个时候，另一些网络包又来了，又触发了中断，CPU 手里的事情还没捂热，又要停下来去处理网络包。能不能大家要来的一起来，把网络包好好处理一把，然后再回去集中处理其他事情呢？

网络包能不能一起来，这个我们没法儿控制，但是我们可以有一种机制，就是当一些网络包到来触发了中断，内核处理完这些网络包之后，我们可以先进入主动轮询 poll 网卡的方式，主动去接收到来的网络包。如果一直有，就一直处理，等处理告一段落，就返回干其他的事情。当再有下一批网络包到来的时候，再中断，再轮询 poll。这样就会大大减少中断的数量，提升网络处理的效率，这种处理方式我们称为NAPI。

为了帮你了解设备驱动层的工作机制，我们还是以上一节发送网络包时的网卡 drivers/net/ethernet/intel/ixgb/ixgb_main.c 为例子，来进行解析。

static struct pci_driver ixgb_driver = {

	.name     = ixgb_driver_name,

	.id_table = ixgb_pci_tbl,

	.probe    = ixgb_probe,

	.remove   = ixgb_remove,

	.err_handler = &ixgb_err_handler

};

MODULE_AUTHOR("Intel Corporation, <linux.nics@intel.com>");

MODULE_DESCRIPTION("Intel(R) PRO/10GbE Network Driver");

MODULE_LICENSE("GPL");

MODULE_VERSION(DRV_VERSION);

/**

 * ixgb_init_module - Driver Registration Routine

 *

 * ixgb_init_module is the first routine called when the driver is

 * loaded. All it does is register with the PCI subsystem.

 **/

static int __init

ixgb_init_module(void)

{

	pr_info("%s - version %s\n", ixgb_driver_string, ixgb_driver_version);

	pr_info("%s\n", ixgb_copyright);

	return pci_register_driver(&ixgb_driver);

}

module_init(ixgb_init_module);

在网卡驱动程序初始化的时候，我们会调用 ixgb_init_module，注册一个驱动 ixgb_driver，并且调用它的 probe 函数 ixgb_probe。

static int

ixgb_probe(struct pci_dev *pdev, const struct pci_device_id *ent)

{

	struct net_device *netdev = NULL;

	struct ixgb_adapter *adapter;

......

	netdev = alloc_etherdev(sizeof(struct ixgb_adapter));

	SET_NETDEV_DEV(netdev, &pdev->dev);

	pci_set_drvdata(pdev, netdev);

	adapter = netdev_priv(netdev);

	adapter->netdev = netdev;

	adapter->pdev = pdev;

	adapter->hw.back = adapter;

	adapter->msg_enable = netif_msg_init(debug, DEFAULT_MSG_ENABLE);

	adapter->hw.hw_addr = pci_ioremap_bar(pdev, BAR_0);

......

	netdev->netdev_ops = &ixgb_netdev_ops;

	ixgb_set_ethtool_ops(netdev);

	netdev->watchdog_timeo = 5 * HZ;

	netif_napi_add(netdev, &adapter->napi, ixgb_clean, 64);

	strncpy(netdev->name, pci_name(pdev), sizeof(netdev->name) - 1);

	adapter->bd_number = cards_found;

	adapter->link_speed = 0;

	adapter->link_duplex = 0;

......

}

在 ixgb_probe 中，我们会创建一个 struct net_device 表示这个网络设备，并且 netif_napi_add 函数为这个网络设备注册一个轮询 poll 函数 ixgb_clean，将来一旦出现网络包的时候，就是要通过他来轮询了。

当一个网卡被激活的时候，我们会调用函数 ixgb_open->ixgb_up，在这里面注册一个硬件的中断处理函数。

int

ixgb_up(struct ixgb_adapter *adapter)

{

	struct net_device *netdev = adapter->netdev;

......

    err = request_irq(adapter->pdev->irq, ixgb_intr, irq_flags,

	                  netdev->name, netdev);

......

}

/**

 * ixgb_intr - Interrupt Handler

 * @irq: interrupt number

 * @data: pointer to a network interface device structure

 **/

static irqreturn_t

ixgb_intr(int irq, void *data)

{

	struct net_device *netdev = data;

	struct ixgb_adapter *adapter = netdev_priv(netdev);

	struct ixgb_hw *hw = &adapter->hw;

......

	if (napi_schedule_prep(&adapter->napi)) {

		IXGB_WRITE_REG(&adapter->hw, IMC, ~0);

		__napi_schedule(&adapter->napi);

	}

	return IRQ_HANDLED;

}

如果一个网络包到来，触发了硬件中断，就会调用 ixgb_intr，这里面会调用 __napi_schedule。

/**

 * __napi_schedule - schedule for receive

 * @n: entry to schedule

 *

 * The entry's receive function will be scheduled to run.

 * Consider using __napi_schedule_irqoff() if hard irqs are masked.

 */

void __napi_schedule(struct napi_struct *n)

{

	unsigned long flags;

	local_irq_save(flags);

	____napi_schedule(this_cpu_ptr(&softnet_data), n);

	local_irq_restore(flags);

}

static inline void ____napi_schedule(struct softnet_data *sd,

				     struct napi_struct *napi)

{

	list_add_tail(&napi->poll_list, &sd->poll_list);

	__raise_softirq_irqoff(NET_RX_SOFTIRQ);

}

__napi_schedule 是处于中断处理的关键部分，在他被调用的时候，中断是暂时关闭的，但是处理网络包是个复杂的过程，需要到延迟处理部分，所以 ____napi_schedule 将当前设备放到 struct softnet_data 结构的 poll_list 里面，说明在延迟处理部分可以接着处理这个 poll_list 里面的网络设备。

然后 ____napi_schedule 触发一个软中断 NET_RX_SOFTIRQ，通过软中断触发中断处理的延迟处理部分，也是常用的手段。

上一节，我们知道，软中断 NET_RX_SOFTIRQ 对应的中断处理函数是 net_rx_action。

static __latent_entropy void net_rx_action(struct softirq_action *h)

{

	struct softnet_data *sd = this_cpu_ptr(&softnet_data);

    LIST_HEAD(list);

    list_splice_init(&sd->poll_list, &list);

......

	for (;;) {

		struct napi_struct *n;

......

		n = list_first_entry(&list, struct napi_struct, poll_list);

		budget -= napi_poll(n, &repoll);

	}

......

}

在 net_rx_action 中，会得到 struct softnet_data 结构，这个结构在发送的时候我们也遇到过。当时它的 output_queue 用于网络包的发送，这里的 poll_list 用于网络包的接收。

struct softnet_data {

	struct list_head	poll_list;

......

	struct Qdisc		*output_queue;

	struct Qdisc		**output_queue_tailp;

......

}

在 net_rx_action 中，接下来是一个循环，在 poll_list 里面取出网络包到达的设备，然后调用 napi_poll 来轮询这些设备，napi_poll 会调用最初设备初始化的时候，注册的 poll 函数，对于 ixgb_driver，对应的函数是 ixgb_clean。

ixgb_clean 会调用 ixgb_clean_rx_irq。

static bool

ixgb_clean_rx_irq(struct ixgb_adapter *adapter, int *work_done, int work_to_do)

{

	struct ixgb_desc_ring *rx_ring = &adapter->rx_ring;

	struct net_device *netdev = adapter->netdev;

	struct pci_dev *pdev = adapter->pdev;

	struct ixgb_rx_desc *rx_desc, *next_rxd;

	struct ixgb_buffer *buffer_info, *next_buffer, *next2_buffer;

	u32 length;

	unsigned int i, j;

	int cleaned_count = 0;

	bool cleaned = false;

	i = rx_ring->next_to_clean;

	rx_desc = IXGB_RX_DESC(*rx_ring, i);

	buffer_info = &rx_ring->buffer_info[i];

	while (rx_desc->status & IXGB_RX_DESC_STATUS_DD) {

		struct sk_buff *skb;

		u8 status;

		status = rx_desc->status;

		skb = buffer_info->skb;

		buffer_info->skb = NULL;

		prefetch(skb->data - NET_IP_ALIGN);

		if (++i == rx_ring->count)

			i = 0;

		next_rxd = IXGB_RX_DESC(*rx_ring, i);

		prefetch(next_rxd);

		j = i + 1;

		if (j == rx_ring->count)

			j = 0;

		next2_buffer = &rx_ring->buffer_info[j];

		prefetch(next2_buffer);

		next_buffer = &rx_ring->buffer_info[i];

......

		length = le16_to_cpu(rx_desc->length);

		rx_desc->length = 0;

......

		ixgb_check_copybreak(&adapter->napi, buffer_info, length, &skb);

		/* Good Receive */

		skb_put(skb, length);

		/* Receive Checksum Offload */

		ixgb_rx_checksum(adapter, rx_desc, skb);

		skb->protocol = eth_type_trans(skb, netdev);

		netif_receive_skb(skb);

......

		/* use prefetched values */

		rx_desc = next_rxd;

		buffer_info = next_buffer;

	}

	rx_ring->next_to_clean = i;

......

}

在网络设备的驱动层，有一个用于接收网络包的 rx_ring。它是一个环，从网卡硬件接收的包会放在这个环里面。这个环里面的 buffer_info[] 是一个数组，存放的是网络包的内容。i 和 j 是这个数组的下标，在 ixgb_clean_rx_irq 里面的 while 循环中，依次处理环里面的数据。在这里面，我们看到了 i 和 j 加一之后，如果超过了数组的大小，就跳回下标 0，就说明这是一个环。

ixgb_check_copybreak 函数将 buffer_info 里面的内容，拷贝到 struct sk_buff *skb，从而可以作为一个网络包进行后续的处理，然后调用 netif_receive_skb。

网络协议栈的二层逻辑

从 netif_receive_skb 函数开始，我们就进入了内核的网络协议栈。

接下来的调用链为：netif_receive_skb->netif_receive_skb_internal->__netif_receive_skb->__netif_receive_skb_core。

在 __netif_receive_skb_core 中，我们先是处理了二层的一些逻辑。例如，对于 VLAN 的处理，接下来要想办法交给第三层。

static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc)

{

	struct packet_type *ptype, *pt_prev;

......

	type = skb->protocol;

......

	deliver_ptype_list_skb(skb, &pt_prev, orig_dev, type,

			       &orig_dev->ptype_specific);

	if (pt_prev) {

		ret = pt_prev->func(skb, skb->dev, pt_prev, orig_dev);

	}

......

}

static inline void deliver_ptype_list_skb(struct sk_buff *skb,

					  struct packet_type **pt,

					  struct net_device *orig_dev,

					  __be16 type,

					  struct list_head *ptype_list)

{

	struct packet_type *ptype, *pt_prev = *pt;

	list_for_each_entry_rcu(ptype, ptype_list, list) {

		if (ptype->type != type)

			continue;

		if (pt_prev)

			deliver_skb(skb, pt_prev, orig_dev);

		pt_prev = ptype;

	}

	*pt = pt_prev;

}

在网络包 struct sk_buff 里面，二层的头里面有一个 protocol，表示里面一层，也即三层是什么协议。deliver_ptype_list_skb 在一个协议列表中逐个匹配。如果能够匹配到，就返回。

这些协议的注册在网络协议栈初始化的时候， inet_init 函数调用 dev_add_pack(&ip_packet_type)，添加 IP 协议。协议被放在一个链表里面。

void dev_add_pack(struct packet_type *pt)

{

    struct list_head *head = ptype_head(pt);

    list_add_rcu(&pt->list, head);

}

static inline struct list_head *ptype_head(const struct packet_type *pt)

{

    if (pt->type == htons(ETH_P_ALL))

        return pt->dev ? &pt->dev->ptype_all : &ptype_all;

    else

        return pt->dev ? &pt->dev->ptype_specific : &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];

}

假设这个时候的网络包是一个 IP 包，则在这个链表里面一定能够找到 ip_packet_type，在 __netif_receive_skb_core 中会调用 ip_packet_type 的 func 函数。

static struct packet_type ip_packet_type __read_mostly = {

	.type = cpu_to_be16(ETH_P_IP),

	.func = ip_rcv,

};

从上面的定义我们可以看出，接下来，ip_rcv 会被调用。

网络协议栈的 IP 层

从 ip_rcv 函数开始，我们的处理逻辑就从二层到了三层，IP 层。

int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev)

{

	const struct iphdr *iph;

	struct net *net;

	u32 len;

......

	net = dev_net(dev);

......

	iph = ip_hdr(skb);

	len = ntohs(iph->tot_len);

	skb->transport_header = skb->network_header + iph->ihl*4;

......

	return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING,

		       net, NULL, skb, dev, NULL,

		       ip_rcv_finish);

......

}

在 ip_rcv 中，得到 IP 头，然后又遇到了我们见过多次的 NF_HOOK，这次因为是接收网络包，第一个 hook 点是 NF_INET_PRE_ROUTING，也就是 iptables 的 PREROUTING 链。如果里面有规则，则执行规则，然后调用 ip_rcv_finish。

static int ip_rcv_finish(struct net *net, struct sock *sk, struct sk_buff *skb)

{

	const struct iphdr *iph = ip_hdr(skb);

	struct net_device *dev = skb->dev;

	struct rtable *rt;

	int err;

......

	rt = skb_rtable(skb);

.....

	return dst_input(skb);

}

static inline int dst_input(struct sk_buff *skb)

{

	return skb_dst(skb)->input(skb);

ip_rcv_finish 得到网络包对应的路由表，然后调用 dst_input，在 dst_input 中，调用的是 struct rtable 的成员的 dst 的 input 函数。在 rt_dst_alloc 中，我们可以看到，input 函数指向的是 ip_local_deliver。

int ip_local_deliver(struct sk_buff *skb)

{

	/*

	 *	Reassemble IP fragments.

	 */

	struct net *net = dev_net(skb->dev);

	if (ip_is_fragment(ip_hdr(skb))) {

		if (ip_defrag(net, skb, IP_DEFRAG_LOCAL_DELIVER))

			return 0;

	}

	return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN,

		       net, NULL, skb, skb->dev, NULL,

		       ip_local_deliver_finish);

}

在 ip_local_deliver 函数中，如果 IP 层进行了分段，则进行重新的组合。接下来就是我们熟悉的 NF_HOOK。hook 点在 NF_INET_LOCAL_IN，对应 iptables 里面的 INPUT 链。在经过 iptables 规则处理完毕后，我们调用 ip_local_deliver_finish。

static int ip_local_deliver_finish(struct net *net, struct sock *sk, struct sk_buff *skb)

{

	__skb_pull(skb, skb_network_header_len(skb));

	int protocol = ip_hdr(skb)->protocol;

	const struct net_protocol *ipprot;

	ipprot = rcu_dereference(inet_protos[protocol]);

	if (ipprot) {

		int ret;

		ret = ipprot->handler(skb);

......

	}

......

}

在 IP 头中，有一个字段 protocol 用于指定里面一层的协议，在这里应该是 TCP 协议。于是，从 inet_protos 数组中，找出 TCP 协议对应的处理函数。这个数组的定义如下，里面的内容是 struct net_protocol。

struct net_protocol __rcu *inet_protos[MAX_INET_PROTOS] __read_mostly;

int inet_add_protocol(const struct net_protocol *prot, unsigned char protocol)

{

......

	return !cmpxchg((const struct net_protocol **)&inet_protos[protocol],

			NULL, prot) ? 0 : -1;

}

static int __init inet_init(void)

{

......

	if (inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0)

		pr_crit("%s: Cannot add UDP protocol\n", __func__);

	if (inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0)

		pr_crit("%s: Cannot add TCP protocol\n", __func__);

......

}

static struct net_protocol tcp_protocol = {

	.early_demux	=	tcp_v4_early_demux,

	.early_demux_handler =  tcp_v4_early_demux,

	.handler	=	tcp_v4_rcv,

	.err_handler	=	tcp_v4_err,

	.no_policy	=	1,

	.netns_ok	=	1,

	.icmp_strict_tag_validation = 1,

};

static struct net_protocol udp_protocol = {

	.early_demux =	udp_v4_early_demux,

	.early_demux_handler =	udp_v4_early_demux,

	.handler =	udp_rcv,

	.err_handler =	udp_err,

	.no_policy =	1,

	.netns_ok =	1,

};

在系统初始化的时候，网络协议栈的初始化调用的是 inet_init，它会调用 inet_add_protocol，将 TCP 协议对应的处理函数 tcp_protocol、UDP 协议对应的处理函数 udp_protocol，放到 inet_protos 数组中。

在上面的网络包的接收过程中，会取出 TCP 协议对应的处理函数 tcp_protocol，然后调用 handler 函数，也即 tcp_v4_rcv 函数。

总结时刻

这一节我们讲了接收网络包的上半部分，分以下几个层次。

硬件网卡接收到网络包之后，通过 DMA 技术，将网络包放入 Ring Buffer。

硬件网卡通过中断通知 CPU 新的网络包的到来。

网卡驱动程序会注册中断处理函数 ixgb_intr。

中断处理函数处理完需要暂时屏蔽中断的核心流程之后，通过软中断 NET_RX_SOFTIRQ 触发接下来的处理过程。

NET_RX_SOFTIRQ 软中断处理函数 net_rx_action，net_rx_action 会调用 napi_poll，进而调用 ixgb_clean_rx_irq，从 Ring Buffer 中读取数据到内核 struct sk_buff。

调用 netif_receive_skb 进入内核网络协议栈，进行一些关于 VLAN 的二层逻辑处理后，调用 ip_rcv 进入三层 IP 层。

在 IP 层，会处理 iptables 规则，然后调用 ip_local_deliver，交给更上层 TCP 层。

在 TCP 层调用 tcp_v4_rcv。

课堂练习

我们没有仔细分析对于二层 VLAN 的处理，请你研究一下 VLAN 的原理，然后在代码中看一下对于 VLAN 的处理过程，这是一项重要的网络基础知识。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

48 | 接收网络包（下）：如何搞明白合作伙伴让我们做什么？

上一节，我们解析了网络包接收的上半部分，从硬件网卡到 IP 层。这一节，我们接着来解析 TCP 层和 Socket 层都做了哪些事情。

网络协议栈的 TCP 层

从 tcp_v4_rcv 函数开始，我们的处理逻辑就从 IP 层到了 TCP 层。

int tcp_v4_rcv(struct sk_buff *skb)

{

	struct net *net = dev_net(skb->dev);

	const struct iphdr *iph;

	const struct tcphdr *th;

	bool refcounted;

	struct sock *sk;

	int ret;

......

	th = (const struct tcphdr *)skb->data;

	iph = ip_hdr(skb);

......

	TCP_SKB_CB(skb)->seq = ntohl(th->seq);

	TCP_SKB_CB(skb)->end_seq = (TCP_SKB_CB(skb)->seq + th->syn + th->fin + skb->len - th->doff * 4);

	TCP_SKB_CB(skb)->ack_seq = ntohl(th->ack_seq);

	TCP_SKB_CB(skb)->tcp_flags = tcp_flag_byte(th);

	TCP_SKB_CB(skb)->tcp_tw_isn = 0;

	TCP_SKB_CB(skb)->ip_dsfield = ipv4_get_dsfield(iph);

	TCP_SKB_CB(skb)->sacked	 = 0;

lookup:

	sk = __inet_lookup_skb(&tcp_hashinfo, skb, __tcp_hdrlen(th), th->source, th->dest, &refcounted);

process:

	if (sk->sk_state == TCP_TIME_WAIT)

		goto do_time_wait;

	if (sk->sk_state == TCP_NEW_SYN_RECV) {

......

	}

......

	th = (const struct tcphdr *)skb->data;

	iph = ip_hdr(skb);

	skb->dev = NULL;

	if (sk->sk_state == TCP_LISTEN) {

		ret = tcp_v4_do_rcv(sk, skb);

		goto put_and_return;

	}

......

	if (!sock_owned_by_user(sk)) {

		if (!tcp_prequeue(sk, skb))

			ret = tcp_v4_do_rcv(sk, skb);

	} else if (tcp_add_backlog(sk, skb)) {

		goto discard_and_relse;

	}

......

}

在 tcp_v4_rcv 中，得到 TCP 的头之后，我们可以开始处理 TCP 层的事情。因为 TCP 层是分状态的，状态被维护在数据结构 struct sock 里面，因而我们要根据 IP 地址以及 TCP 头里面的内容，在 tcp_hashinfo 中找到这个包对应的 struct sock，从而得到这个包对应的连接的状态。

接下来，我们就根据不同的状态做不同的处理，例如，上面代码中的 TCP_LISTEN、TCP_NEW_SYN_RECV 状态属于连接建立过程中。这个我们在讲三次握手的时候讲过了。再如，TCP_TIME_WAIT 状态是连接结束的时候的状态，这个我们暂时可以不用看。

接下来，我们来分析最主流的网络包的接收过程，这里面涉及三个队列：

backlog 队列

prequeue 队列

sk_receive_queue 队列

为什么接收网络包的过程，需要在这三个队列里面倒腾过来、倒腾过去呢？这是因为，同样一个网络包要在三个主体之间交接。

第一个主体是软中断的处理过程。如果你没忘记的话，我们在执行 tcp_v4_rcv 函数的时候，依然处于软中断的处理逻辑里，所以必然会占用这个软中断。

第二个主体就是用户态进程。如果用户态触发系统调用 read 读取网络包，也要从队列里面找。

第三个主体就是内核协议栈。哪怕用户进程没有调用 read，读取网络包，当网络包来的时候，也得有一个地方收着呀。

这时候，我们就能够了解上面代码中 sock_owned_by_user 的意思了，其实就是说，当前这个 sock 是不是正有一个用户态进程等着读数据呢，如果没有，内核协议栈也调用 tcp_add_backlog，暂存在 backlog 队列中，并且抓紧离开软中断的处理过程。

如果有一个用户态进程等待读取数据呢？我们先调用 tcp_prequeue，也即赶紧放入 prequeue 队列，并且离开软中断的处理过程。在这个函数里面，我们会看到对于 sysctl_tcp_low_latency 的判断，也即是不是要低时延地处理网络包。

如果把 sysctl_tcp_low_latency 设置为 0，那就要放在 prequeue 队列中暂存，这样不用等待网络包处理完毕，就可以离开软中断的处理过程，但是会造成比较长的时延。如果把 sysctl_tcp_low_latency 设置为 1，我们还是调用 tcp_v4_do_rcv。

int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)

{

	struct sock *rsk;

	if (sk->sk_state == TCP_ESTABLISHED) { /* Fast path */

		struct dst_entry *dst = sk->sk_rx_dst;

......

		tcp_rcv_established(sk, skb, tcp_hdr(skb), skb->len);

		return 0;

	}

......

	if (tcp_rcv_state_process(sk, skb)) {

......

	}

	return 0;

......

}

在 tcp_v4_do_rcv 中，分两种情况，一种情况是连接已经建立，处于 TCP_ESTABLISHED 状态，调用 tcp_rcv_established。另一种情况，就是其他的状态，调用 tcp_rcv_state_process。

int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb)

{

	struct tcp_sock *tp = tcp_sk(sk);

	struct inet_connection_sock *icsk = inet_csk(sk);

	const struct tcphdr *th = tcp_hdr(skb);

	struct request_sock *req;

	int queued = 0;

	bool acceptable;

	switch (sk->sk_state) {

	case TCP_CLOSE:

......

	case TCP_LISTEN:

......

	case TCP_SYN_SENT:

......

	}

......

	switch (sk->sk_state) {

	case TCP_SYN_RECV:

......

	case TCP_FIN_WAIT1:

......

	case TCP_CLOSING:

......

	case TCP_LAST_ACK:

......

    }

	/* step 7: process the segment text */

	switch (sk->sk_state) {

	case TCP_CLOSE_WAIT:

	case TCP_CLOSING:

	case TCP_LAST_ACK:

......

	case TCP_FIN_WAIT1:

	case TCP_FIN_WAIT2:

......

	case TCP_ESTABLISHED:

......

	}

}

在 tcp_rcv_state_process 中，如果我们对着 TCP 的状态图进行比对，能看到，对于 TCP 所有状态的处理，其中和连接建立相关的状态，咱们已经分析过，所以我们重点关注连接状态下的工作模式。

在连接状态下，我们会调用 tcp_rcv_established。在这个函数里面，我们会调用 tcp_data_queue，将其放入 sk_receive_queue 队列进行处理。

static void tcp_data_queue(struct sock *sk, struct sk_buff *skb)

{

	struct tcp_sock *tp = tcp_sk(sk);

	bool fragstolen = false;

......

	if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt) {

		if (tcp_receive_window(tp) == 0)

			goto out_of_window;

		/* Ok. In sequence. In window. */

		if (tp->ucopy.task == current &&

		    tp->copied_seq == tp->rcv_nxt && tp->ucopy.len &&

		    sock_owned_by_user(sk) && !tp->urg_data) {

			int chunk = min_t(unsigned int, skb->len,

					  tp->ucopy.len);

			__set_current_state(TASK_RUNNING);

			if (!skb_copy_datagram_msg(skb, 0, tp->ucopy.msg, chunk)) {

				tp->ucopy.len -= chunk;

				tp->copied_seq += chunk;

				eaten = (chunk == skb->len);

				tcp_rcv_space_adjust(sk);

			}

		}

		if (eaten <= 0) {

queue_and_out:

......

			eaten = tcp_queue_rcv(sk, skb, 0, &fragstolen);

		}

		tcp_rcv_nxt_update(tp, TCP_SKB_CB(skb)->end_seq);

......

		if (!RB_EMPTY_ROOT(&tp->out_of_order_queue)) {

			tcp_ofo_queue(sk);

......

		}

......

		return;

	}

	if (!after(TCP_SKB_CB(skb)->end_seq, tp->rcv_nxt)) {

		/* A retransmit, 2nd most common case.  Force an immediate ack. */

		tcp_dsack_set(sk, TCP_SKB_CB(skb)->seq, TCP_SKB_CB(skb)->end_seq);

out_of_window:

		tcp_enter_quickack_mode(sk);

		inet_csk_schedule_ack(sk);

drop:

		tcp_drop(sk, skb);

		return;

	}

	/* Out of window. F.e. zero window probe. */

	if (!before(TCP_SKB_CB(skb)->seq, tp->rcv_nxt + tcp_receive_window(tp)))

		goto out_of_window;

	tcp_enter_quickack_mode(sk);

	if (before(TCP_SKB_CB(skb)->seq, tp->rcv_nxt)) {

		/* Partial packet, seq < rcv_next < end_seq */

		tcp_dsack_set(sk, TCP_SKB_CB(skb)->seq, tp->rcv_nxt);

		/* If window is closed, drop tail of packet. But after

		 * remembering D-SACK for its head made in previous line.

		 */

		if (!tcp_receive_window(tp))

			goto out_of_window;

		goto queue_and_out;

	}

	tcp_data_queue_ofo(sk, skb);

}

在 tcp_data_queue 中，对于收到的网络包，我们要分情况进行处理。

第一种情况，seq == tp->rcv_nxt，说明来的网络包正是我服务端期望的下一个网络包。这个时候我们判断 sock_owned_by_user，也即用户进程也是正在等待读取，这种情况下，就直接 skb_copy_datagram_msg，将网络包拷贝给用户进程就可以了。

如果用户进程没有正在等待读取，或者因为内存原因没有能够拷贝成功，tcp_queue_rcv 里面还是将网络包放入 sk_receive_queue 队列。

接下来，tcp_rcv_nxt_update 将 tp->rcv_nxt 设置为 end_seq，也即当前的网络包接收成功后，更新下一个期待的网络包。

这个时候，我们还会判断一下另一个队列，out_of_order_queue，也看看乱序队列的情况，看看乱序队列里面的包，会不会因为这个新的网络包的到来，也能放入到 sk_receive_queue 队列中。

例如，客户端发送的网络包序号为 5、6、7、8、9。在 5 还没有到达的时候，服务端的 rcv_nxt 应该是 5，也即期望下一个网络包是 5。但是由于中间网络通路的问题，5、6 还没到达服务端，7、8 已经到达了服务端了，这就出现了乱序。

乱序的包不能进入 sk_receive_queue 队列。因为一旦进入到这个队列，意味着可以发送给用户进程。然而，按照 TCP 的定义，用户进程应该是按顺序收到包的，没有排好序，就不能给用户进程。所以，7、8 不能进入 sk_receive_queue 队列，只能暂时放在 out_of_order_queue 乱序队列中。

当 5、6 到达的时候，5、6 先进入 sk_receive_queue 队列。这个时候我们再来看 out_of_order_queue 乱序队列中的 7、8，发现能够接上。于是，7、8 也能进入 sk_receive_queue 队列了。tcp_ofo_queue 函数就是做这个事情的。

至此第一种情况处理完毕。

第二种情况，end_seq 不大于 rcv_nxt，也即服务端期望网络包 5。但是，来了一个网络包 3，怎样才会出现这种情况呢？肯定是服务端早就收到了网络包 3，但是 ACK 没有到达客户端，中途丢了，那客户端就认为网络包 3 没有发送成功，于是又发送了一遍，这种情况下，要赶紧给客户端再发送一次 ACK，表示早就收到了。

第三种情况，seq 不小于 rcv_nxt + tcp_receive_window。这说明客户端发送得太猛了。本来 seq 肯定应该在接收窗口里面的，这样服务端才来得及处理，结果现在超出了接收窗口，说明客户端一下子把服务端给塞满了。

这种情况下，服务端不能再接收数据包了，只能发送 ACK 了，在 ACK 中会将接收窗口为 0 的情况告知客户端，客户端就知道不能再发送了。这个时候双方只能交互窗口探测数据包，直到服务端因为用户进程把数据读走了，空出接收窗口，才能在 ACK 里面再次告诉客户端，又有窗口了，又能发送数据包了。

第四种情况，seq 小于 rcv_nxt，但是 end_seq 大于 rcv_nxt，这说明从 seq 到 rcv_nxt 这部分网络包原来的 ACK 客户端没有收到，所以重新发送了一次，从 rcv_nxt 到 end_seq 时新发送的，可以放入 sk_receive_queue 队列。

当前四种情况都排除掉了，说明网络包一定是一个乱序包了。这里有点儿难理解，我们还是用上面那个乱序的例子仔细分析一下 rcv_nxt=5。

我们假设 tcp_receive_window 也是 5，也即超过 10 服务端就接收不了了。当前来的这个网络包既不在 rcv_nxt 之前（不是 3 这种），也不在 rcv_nxt + tcp_receive_window 之后（不是 11 这种），说明这正在我们期望的接收窗口里面，但是又不是 rcv_nxt（不是我们马上期望的网络包 5），这正是上面的例子中网络包 7、8 的情况。

对于网络包 7、8，我们只好调用 tcp_data_queue_ofo 进入 out_of_order_queue 乱序队列，但是没有关系，当网络包 5、6 到来的时候，我们会走第一种情况，把 7、8 拿出来放到 sk_receive_queue 队列中。

至此，网络协议栈的处理过程就结束了。

Socket 层

当接收的网络包进入各种队列之后，接下来我们就要等待用户进程去读取它们了。

读取一个 socket，就像读取一个文件一样，读取 socket 的文件描述符，通过 read 系统调用。

read 系统调用对于一个文件描述符的操作，大致过程都是类似的，在文件系统那一节，我们已经详细解析过。最终它会调用到用来表示一个打开文件的结构 stuct file 指向的 file_operations 操作。

对于 socket 来讲，它的 file_operations 定义如下：

static const struct file_operations socket_file_ops = {

	.owner =	THIS_MODULE,

	.llseek =	no_llseek,

	.read_iter =	sock_read_iter,

	.write_iter =	sock_write_iter,

	.poll =		sock_poll,

	.unlocked_ioctl = sock_ioctl,

	.mmap =		sock_mmap,

	.release =	sock_close,

	.fasync =	sock_fasync,

	.sendpage =	sock_sendpage,

	.splice_write = generic_splice_sendpage,

	.splice_read =	sock_splice_read,

};

按照文件系统的读取流程，调用的是 sock_read_iter。

static ssize_t sock_read_iter(struct kiocb *iocb, struct iov_iter *to)

{

	struct file *file = iocb->ki_filp;

	struct socket *sock = file->private_data;

	struct msghdr msg = {.msg_iter = *to,

			     .msg_iocb = iocb};

	ssize_t res;

	if (file->f_flags & O_NONBLOCK)

		msg.msg_flags = MSG_DONTWAIT;

......

	res = sock_recvmsg(sock, &msg, msg.msg_flags);

	*to = msg.msg_iter;

	return res;

}

在 sock_read_iter 中，通过 VFS 中的 struct file，将创建好的 socket 结构拿出来，然后调用 sock_recvmsg，sock_recvmsg 会调用 sock_recvmsg_nosec。

static inline int sock_recvmsg_nosec(struct socket *sock, struct msghdr *msg, int flags)

{

	return sock->ops->recvmsg(sock, msg, msg_data_left(msg), flags);

}

这里调用了 socket 的 ops 的 recvmsg，这个我们遇到好几次了。根据 inet_stream_ops 的定义，这里调用的是 inet_recvmsg。

int inet_recvmsg(struct socket *sock, struct msghdr *msg, size_t size,

		 int flags)

{

	struct sock *sk = sock->sk;

	int addr_len = 0;

	int err;

......

	err = sk->sk_prot->recvmsg(sk, msg, size, flags & MSG_DONTWAIT,

				   flags & ~MSG_DONTWAIT, &addr_len);

......

}

这里面，从 socket 结构，我们可以得到更底层的 sock 结构，然后调用 sk_prot 的 recvmsg 方法。这个同样遇到好几次了，根据 tcp_prot 的定义，调用的是 tcp_recvmsg。

int tcp_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int nonblock,

		int flags, int *addr_len)

{

	struct tcp_sock *tp = tcp_sk(sk);

	int copied = 0;

	u32 peek_seq;

	u32 *seq;

	unsigned long used;

	int err;

	int target;		/* Read at least this many bytes */

	long timeo;

	struct task_struct *user_recv = NULL;

	struct sk_buff *skb, *last;

.....

	do {

		u32 offset;

......

		/* Next get a buffer. */

		last = skb_peek_tail(&sk->sk_receive_queue);

		skb_queue_walk(&sk->sk_receive_queue, skb) {

			last = skb;

			offset = *seq - TCP_SKB_CB(skb)->seq;

			if (offset < skb->len)

				goto found_ok_skb;

......

		}

......

		if (!sysctl_tcp_low_latency && tp->ucopy.task == user_recv) {

			/* Install new reader */

			if (!user_recv && !(flags & (MSG_TRUNC | MSG_PEEK))) {

				user_recv = current;

				tp->ucopy.task = user_recv;

				tp->ucopy.msg = msg;

			}

			tp->ucopy.len = len;

			/* Look: we have the following (pseudo)queues:

			 *

			 * 1. packets in flight

			 * 2. backlog

			 * 3. prequeue

			 * 4. receive_queue

			 *

			 * Each queue can be processed only if the next ones

			 * are empty.

			 */

			if (!skb_queue_empty(&tp->ucopy.prequeue))

				goto do_prequeue;

		}

		if (copied >= target) {

			/* Do not sleep, just process backlog. */

			release_sock(sk);

			lock_sock(sk);

		} else {

			sk_wait_data(sk, &timeo, last);

		}

		if (user_recv) {

			int chunk;

			chunk = len - tp->ucopy.len;

			if (chunk != 0) {

				len -= chunk;

				copied += chunk;

			}

			if (tp->rcv_nxt == tp->copied_seq &&

			    !skb_queue_empty(&tp->ucopy.prequeue)) {

do_prequeue:

				tcp_prequeue_process(sk);

				chunk = len - tp->ucopy.len;

				if (chunk != 0) {

					len -= chunk;

					copied += chunk;

				}

			}

		}

		continue;

	found_ok_skb:

		/* Ok so how much can we use? */

		used = skb->len - offset;

		if (len < used)

			used = len;

		if (!(flags & MSG_TRUNC)) {

			err = skb_copy_datagram_msg(skb, offset, msg, used);

......

		}

		*seq += used;

		copied += used;

		len -= used;

		tcp_rcv_space_adjust(sk);

......

	} while (len > 0);

......

}

tcp_recvmsg 这个函数比较长，里面逻辑也很复杂，好在里面有一段注释概扩了这里面的逻辑。注释里面提到了三个队列，receive_queue 队列、prequeue 队列和 backlog 队列。这里面，我们需要把前一个队列处理完毕，才处理后一个队列。

tcp_recvmsg 的整个逻辑也是这样执行的：这里面有一个 while 循环，不断地读取网络包。

这里，我们会先处理 sk_receive_queue 队列。如果找到了网络包，就跳到 found_ok_skb 这里。这里会调用 skb_copy_datagram_msg，将网络包拷贝到用户进程中，然后直接进入下一层循环。

直到 sk_receive_queue 队列处理完毕，我们才到了 sysctl_tcp_low_latency 判断。如果不需要低时延，则会有 prequeue 队列。于是，我们能就跳到 do_prequeue 这里，调用 tcp_prequeue_process 进行处理。

如果 sysctl_tcp_low_latency 设置为 1，也即没有 prequeue 队列，或者 prequeue 队列为空，则需要处理 backlog 队列，在 release_sock 函数中处理。

release_sock 会调用 __release_sock，这里面会依次处理队列中的网络包。

void release_sock(struct sock *sk)

{

......

	if (sk->sk_backlog.tail)

		__release_sock(sk);

......

}

static void __release_sock(struct sock *sk)

	__releases(&sk->sk_lock.slock)

	__acquires(&sk->sk_lock.slock)

{

	struct sk_buff *skb, *next;

	while ((skb = sk->sk_backlog.head) != NULL) {

		sk->sk_backlog.head = sk->sk_backlog.tail = NULL;

		do {

			next = skb->next;

			prefetch(next);

			skb->next = NULL;

			sk_backlog_rcv(sk, skb);

			cond_resched();

			skb = next;

		} while (skb != NULL);

	}

......

}

最后，哪里都没有网络包，我们只好调用 sk_wait_data，继续等待在哪里，等待网络包的到来。

至此，网络包的接收过程到此结束。

总结时刻

这一节我们讲完了接收网络包，我们来从头串一下，整个过程可以分成以下几个层次。

硬件网卡接收到网络包之后，通过 DMA 技术，将网络包放入 Ring Buffer；

硬件网卡通过中断通知 CPU 新的网络包的到来；

网卡驱动程序会注册中断处理函数 ixgb_intr；

中断处理函数处理完需要暂时屏蔽中断的核心流程之后，通过软中断 NET_RX_SOFTIRQ 触发接下来的处理过程；

NET_RX_SOFTIRQ 软中断处理函数 net_rx_action，net_rx_action 会调用 napi_poll，进而调用 ixgb_clean_rx_irq，从 Ring Buffer 中读取数据到内核 struct sk_buff；

调用 netif_receive_skb 进入内核网络协议栈，进行一些关于 VLAN 的二层逻辑处理后，调用 ip_rcv 进入三层 IP 层；

在 IP 层，会处理 iptables 规则，然后调用 ip_local_deliver 交给更上层 TCP 层；

在 TCP 层调用 tcp_v4_rcv，这里面有三个队列需要处理，如果当前的 Socket 不是正在被读；取，则放入 backlog 队列，如果正在被读取，不需要很实时的话，则放入 prequeue 队列，其他情况调用 tcp_v4_do_rcv；

在 tcp_v4_do_rcv 中，如果是处于 TCP_ESTABLISHED 状态，调用 tcp_rcv_established，其他的状态，调用 tcp_rcv_state_process；

在 tcp_rcv_established 中，调用 tcp_data_queue，如果序列号能够接的上，则放入 sk_receive_queue 队列；如果序列号接不上，则暂时放入 out_of_order_queue 队列，等序列号能够接上的时候，再放入 sk_receive_queue 队列。

至此内核接收网络包的过程到此结束，接下来就是用户态读取网络包的过程，这个过程分成几个层次。

VFS 层：read 系统调用找到 struct file，根据里面的 file_operations 的定义，调用 sock_read_iter 函数。sock_read_iter 函数调用 sock_recvmsg 函数。

Socket 层：从 struct file 里面的 private_data 得到 struct socket，根据里面 ops 的定义，调用 inet_recvmsg 函数。

Sock 层：从 struct socket 里面的 sk 得到 struct sock，根据里面 sk_prot 的定义，调用 tcp_recvmsg 函数。

TCP 层：tcp_recvmsg 函数会依次读取 receive_queue 队列、prequeue 队列和 backlog 队列。

课堂练习

对于 TCP 协议、三次握手、发送和接收的连接维护、拥塞控制、滑动窗口，我们都解析过了。唯独四次挥手我们没有解析，对应的代码你应该知道在什么地方了，你可以自己试着解析一下四次挥手的过程。

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

49 | 虚拟机：如何成立子公司，让公司变集团？

我们前面所有章节涉及的 Linux 操作系统原理，都是在一台 Linux 服务器上工作的. 在前面的原理阐述中，我们一直把 Linux 当作一家外包公司的老板来看待。想要管理这么复杂、这么大的一个公司，需要配备咱们前面讲过的所有机制。

Linux 很强大，Linux 服务器也随之变得越来越强大了。无论是计算、网络、存储，都越来越牛。例如，内存动不动就是百 G 内存，网络设备一个端口的带宽就能有几十 G 甚至上百 G，存储在数据中心至少是 PB 级别的（一个 P 是 1024 个 T，一个 T 是 1024 个 G）。

公司大有大了的好处，自然也有大的毛病，也就是咱们常见的“大公司病”——不灵活。这里面的不灵活，有下面这几种，我列一下，你看看你是不是都见过。

资源大小不灵活：有时候我们不需要这么大规格的机器，可能只想尝试一下某些新业务，申请个 4 核 8G 的服务器试一下，但是不可能采购这么小规格的机器。无论每个项目需要多大规格的机器，公司统一采购就限制几种，全部是上面那种大规格的。

资源申请不灵活：规格定死就定死吧，可是每次申请机器都要重新采购，周期很长。

资源复用不灵活：反正我需要的资源不多，和别人共享一台机器吧，这样不同的进程可能会产生冲突，例如 socket 的端口冲突。另外就是别人用过的机器，不知道上面做过哪些操作，有很多的历史包袱，如果重新安装则代价太大。

这些是不是和咱们在大公司里面遇到的问题很像？按说，大事情流程严禁没问题，很多小事情也要被拖累走整个流程，而且很容易出现资源冲突，每天跨部门的协调很累人，历史包袱严重，创新没有办法轻装上阵。

很多公司处理这种问题采取的策略是成立独立的子公司，独立决策，独立运营，往往用于创新型的项目。

Linux 也采取了这样的手段，就是在物理机上面创建虚拟机。每个虚拟机有自己单独的操作系统、灵活的规格，一个命令就能启动起来。每次创建都是新的操作系统，很好地解决了上面不灵活的问题。

但是要使用虚拟机，还有一些问题需要解决一下。

我们知道，操作系统上的程序分为两种，一种是用户态的程序，例如 Word、Excel 等，一种是内核态的程序，例如内核代码、驱动程序等。

为了区分内核态和用户态，CPU 专门设置四个特权等级 0、1、2、3 来做这个事情。

当时写 Linux 内核的时候，估计大牛们还不知道将来虚拟机会大放异彩。大牛们想，一共两级特权，一个内核态，一个用户态，却有四个等级，好奢侈、好富裕，于是就敞开了用。内核态运行在第 0 等级，用户态运行在第 3 等级，占了两头，中间的都不用，太不会过日子了。

大牛们在写 Linux 内核的时候，如果用户态程序做事情，就将扳手掰到第 3 等级，一旦要申请使用更多的资源，就需要申请将扳手掰到第 0 等级，内核才能在高权限访问这些资源，申请完资源，返回到用户态，扳手再掰回去。

这个程序一直非常顺利地运行着，直到虚拟机出现了。

三种虚拟化方式

如果你安装 VirtualBox 桌面版，你可以用这个虚拟化软件创建虚拟机，在虚拟机里面安装一个 Linux，外面的操作系统也可以是 Linux。VirtualBox 这个虚拟化软件，和你的 Excel 一样，都是在你的任务栏里面并排放着，是一个普通的应用。

当你进入虚拟机的时候，虚拟机里面的 Excel 也是一个普通的应用。

这个时候麻烦的事情出现了，当你设身处地地站在虚拟机的内核角度，去思考一下人生，你就会出现困惑了，会想，我到底是啥？

在硬件上的操作系统来看，我是一个普通的应用，只能运行在用户态。可是大牛们“生“我的时候，我的每一行代码都告诉我，我是个内核啊，应该运行在内核态。当虚拟机里面的 Excel 要访问网络的时候，向我请求，我的代码就要努力地去操作网卡。尽管我努力，但是我做不到啊，我没有权限！

我分裂了……

怎么办呢？虚拟化层，也就是 Virtualbox 会帮你解决这个问题，它有三种虚拟化的方式。

我们先来看第一种方式，完全虚拟化（Full virtualization）。其实说白了，这是一种“骗人”的方式。虚拟化软件会模拟假的 CPU、内存、网络、硬盘给到我，让我自我感觉良好，感觉自己终于又像个内核了。

但是，真正的工作模式其实是下面这样的。

虚拟机内核说：我要在 CPU 上跑一个指令！
虚拟化软件说：没问题，你是内核嘛，可以跑！
虚拟化软件转过头去找物理机内核说：报告，我管理的虚拟机里面的一个要执行一个 CPU 指令，帮忙来一小段时间空闲的 CPU 时间，让我代它跑个指令。
物理机内核说：你等着，另一个跑着呢。（过了一会儿）它跑完了，该你了。
虚拟化软件说：我代它跑，终于跑完了，出来结果了。
虚拟化软件转头给虚拟机内核说：哥们儿，跑完了，结果是这个。我说你是内核吧，绝对有权限，没问题，下次跑指令找我啊！
虚拟机内核说：看来我真的是内核呢，可是，哥，好像这点儿指令跑得有点慢啊！
虚拟化软件说：这就不错啦，好几个排着队跑呢！

内存的申请模式是下面这样的。

虚拟机内核说：我启动需要 4G 内存，我好分给我上面的应用。
虚拟化软件说：没问题，才 4G，你是内核嘛，我马上申请好。
虚拟化软件转头给物理机内核说：报告，我启动了一个虚拟机，需要 4G 内存，给我 4 个房间呗。
物理机内核：怎么又一个虚拟机啊！好吧，给你 90、91、92、93 四个房间。
虚拟化软件转头给虚拟机内核说：哥们，内存有了，0、1、2、3 这个四个房间都是你的。你看，你是内核嘛，独占资源，从 0 编号的就是你的。
虚拟机内核说：看来我真的是内核啊，能从头开始用。那好，我就在房间 2 的第三个柜子里面放个东西吧！
虚拟化软件说：要放东西啊，没问题。但是，它心里想：我查查看，这个虚拟机是 90 号房间开头的，它要在房间 2 放东西，那就相当于在房间 92 放东西。
虚拟化软件转头给物理机内核说：报告，我上面的虚拟机要在 92 号房间的第三个柜子里面放个东西。

好了，说完了 CPU 和内存的例子，网络和硬盘就不细说了，情况也是类似的，都是虚拟化软件模拟一个给虚拟机内核看的，其实啥事儿都需要虚拟化软件转一遍。

这种方式一个坏处就是，慢，而且往往慢到不能忍受。

于是，虚拟化软件想，我能不能不当传话筒，要让虚拟机内核正视自己的身份。别说你是内核，你还真喘上了。你不是物理机，你是虚拟机！

但是，怎么解决权限等级的问题呢？于是，Intel 的 VT-x 和 AMD 的 AMD-V 从硬件层面帮上了忙。当初谁让你们这些写内核的大牛用等级这么奢侈，用完了 0，就是 3，也不省着点儿用，没办法，只好另起炉灶弄一个新的标志位，表示当前是在虚拟机状态下，还是在真正的物理机内核下。

对于虚拟机内核来讲，只要将标志位设为虚拟机状态，我们就可以直接在 CPU 上执行大部分的指令，不需要虚拟化软件在中间转述，除非遇到特别敏感的指令，才需要将标志位设为物理机内核态运行，这样大大提高了效率。

所以，安装虚拟机的时候，我们务必要将物理 CPU 的这个标志位打开。想知道是否打开，对于 Intel，你可以查看 grep “vmx” /proc/cpuinfo；对于 AMD，你可以查看 grep “svm” /proc/cpuinfo

这叫作硬件辅助虚拟化（Hardware-Assisted Virtualization）。

另外就是访问网络或者硬盘的时候，为了取得更高的性能，也需要让虚拟机内核加载特殊的驱动，也是让虚拟机内核从代码层面就重新定位自己的身份，不能像访问物理机一样访问网络或者硬盘，而是用一种特殊的方式。

我知道我不是物理机内核，我知道我是虚拟机，我没那么高的权限，我很可能和很多虚拟机共享物理资源，所以我要学会排队，我写硬盘其实写的是一个物理机上的文件，那我的写文件的缓存方式是不是可以变一下。我发送网络包，根本就不是发给真正的网络设备，而是给虚拟的设备，我可不可以直接在内存里面拷贝给它，等等等等。

一旦我知道我不是物理机内核，痛定思痛，只好重新认识自己，反而能找出很多方式来优化我的资源访问。

这叫作半虚拟化（Paravirtualization）。

对于桌面虚拟化软件，我们多采用 VirtualBox，如果使用服务器的虚拟化软件，则有另外的选型。

服务器上的虚拟化软件，多使用 qemu，其中关键字 emu，全称是 emulator，模拟器。所以，单纯使用 qemu，采用的是完全虚拟化的模式。

qemu 向 Guest OS 模拟 CPU，也模拟其他的硬件，GuestOS 认为自己和硬件直接打交道，其实是同 qemu 模拟出来的硬件打交道，qemu 会将这些指令转译给真正的硬件。由于所有的指令都要从 qemu 里面过一手，因而性能就会比较差。

按照上面的介绍，完全虚拟化是非常慢的，所以要使用硬件辅助虚拟化技术 Intel-VT，AMD-V，所以需要 CPU 硬件开启这个标志位，一般在 BIOS 里面设置。

当确认开始了标志位之后，通过 KVM，GuestOS 的 CPU 指令不用经过 Qemu 转译，直接运行，大大提高了速度。

所以，KVM 在内核里面需要有一个模块，来设置当前 CPU 是 Guest OS 在用，还是 Host OS 在用。

下面，我们来查看内核模块中是否含有 kvm, lsmod | grep kvm。

KVM 内核模块通过 /dev/kvm 暴露接口，用户态程序可以通过 ioctl 来访问这个接口。例如，你可以通过下面的流程编写程序。

Qemu 将 KVM 整合进来，将有关 CPU 指令的部分交由内核模块来做，就是 qemu-kvm (qemu-system-XXX)。

qemu 和 kvm 整合之后，CPU 的性能问题解决了。另外 Qemu 还会模拟其他的硬件，如网络和硬盘。同样，全虚拟化的方式也会影响这些设备的性能。

于是，qemu 采取半虚拟化的方式，让 Guest OS 加载特殊的驱动来做这件事情。

例如，网络需要加载 virtio_net，存储需要加载 virtio_blk，Guest 需要安装这些半虚拟化驱动，GuestOS 知道自己是虚拟机，所以数据会直接发送给半虚拟化设备，经过特殊处理（例如排队、缓存、批量处理等性能优化方式），最终发送给真正的硬件。这在一定程度上提高了性能。

至此，整个关系如下图所示。

创建虚拟机

了解了 qemu-kvm 的工作原理之后，下面我们来看一下，如何使用 qemu-kvm 创建一个能够上网的虚拟机。

如果使用 VirtualBox 创建过虚拟机，通过界面点点就能创建一个能够上网的虚拟机。如果使用 qemu-kvm，就没有这么简单了。一切都得自己来做，不过这个过程可以了解 KVM 虚拟机的创建原理。

首先，我们要给虚拟机起一个名字，在 KVM 里面就是 -name ubuntutest。

设置一个内存大小，在 KVM 里面就是 -m 1024。

创建一个虚拟硬盘，对于 VirtualBox 是 VDI 格式，对于 KVM 则不同。

硬盘有两种格式，一个是动态分配，也即开始创建的时候，看起来很大，其实占用的空间很少，真实有多少数据，才真的占用多少空间。一个是固定大小，一开始就占用指定的大小。

比如，我这台电脑，硬盘的大小为 8G。

在 KVM 中，创建一个虚拟机镜像，大小为 8G，其中 qcow2 格式为动态分配，raw 格式为固定大小。

qemu-img create -f qcow2 ubuntutest.img 8G

我们将 Ubuntu 的 ISO 挂载为光盘，在 KVM 里面 -cdrom ubuntu-xxx-server-amd64.iso。

创建一个网络，有时候会选择桥接网络，有时候会选择 NAT 网络，这个在 KVM 里面只有自己配置了。

接下来 Virtualbox 就会有一个界面，可以看到安装的整个过程，在 KVM 里面，我们用 VNC 来做。参数为 -vnc :19

于是，我们也可以创建 KVM 虚拟机了，可以用下面的命令：

qemu-system-x86_64 -enable-kvm-name ubuntutest  -m 2048 -hda ubuntutest.img -cdromubuntu-14.04-server-amd64.iso -boot d -vnc :19

启动了虚拟机后，连接 VNC，我们也能看到安装的过程。

按照普通安装 Ubuntu 的流程安装好 Ubuntu，然后 shutdown -h now，关闭虚拟机。

接下来，我们可以对 KVM 创建桥接网络了。这个要模拟 virtualbox 的桥接网络模式。

如果在桌面虚拟化软件上选择桥接网络，在你的笔记本电脑上，就会形成下面的结构。

每个虚拟机都会有虚拟网卡，在你的笔记本电脑上，会发现多了几个网卡，其实是虚拟交换机。这个虚拟交换机将虚拟机连接在一起。在桥接模式下，物理网卡也连接到这个虚拟交换机上。物理网卡在桌面虚拟化软件的“界面名称”那里选定。

如果使用桥接网络，当你登录虚拟机里看 IP 地址时会发现，你的虚拟机的地址和你的笔记本电脑的地址，以及你旁边的同事的电脑的网段是一个网段。这是为什么呢？这其实相当于将物理机和虚拟机放在同一个网桥上，相当于这个网桥上有三台机器，是一个网段的，全部打平了。

在数据中心里面，采取的也是类似的技术，连接方式如下图所示，只不过是 Linux 在每台机器上都创建网桥 br0，虚拟机的网卡都连到 br0 上，物理网卡也连到 br0 上，所有的 br0 都通过物理网卡连接到物理交换机上。

同样我们换一个角度看待这个拓扑图。同样是将网络打平，虚拟机会和物理网络具有相同的网段，就相当于两个虚拟交换机、一个物理交换机，一共三个交换机连在一起。两组四个虚拟机和两台物理机都是在一个二层网络里面的。

qemu-kvm 如何才能创建一个这样的桥接网络呢？

1. 在 Host 机器上创建 bridge br0。

brctl addbr br0

2. 将 br0 设为 up。

ip link set br0 up

3. 创建 tap device。

tunctl -b

4. 将 tap0 设为 up。

ip link set tap0 up

5. 将 tap0 加入到 br0 上。

brctl addif br0 tap0

6. 启动虚拟机, 虚拟机连接 tap0、tap0 连接 br0。

qemu-system-x86_64 -enable-kvm -name ubuntutest -m 2048 -hda ubuntutest.qcow2 -vnc :19 -net nic,model=virtio -nettap,ifname=tap0,script=no,downscript=no

7. 虚拟机启动后，网卡没有配置，所以无法连接外网，先给 br0 设置一个 ip。

ifconfig br0 192.168.57.1/24

8.VNC 连上虚拟机，给网卡设置地址，重启虚拟机，可 ping 通 br0。

9. 要想访问外网，在 Host 上设置 NAT，并且 enable ip forwarding，可以 ping 通外网网关。

# sysctl -p

net.ipv4.ip_forward = 1

sudo iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE

10. 如果 DNS 没配错，可以进行 apt-get update。

在这里，请记住 qemu-system-x86_64 的启动命令，这里面有 CPU 虚拟化 KVM，有内存虚拟化、硬盘虚拟化、网络虚拟化。接下来的章节，我们会看内核是如何进行虚拟化的。

总结时刻

今天我们讲了虚拟化的基本原理，并且手动创建一个可以上网的虚拟机。请记住下面这一点，非常重要，理解虚拟机启动的参数就是理解虚拟化技术的入口。学会创建虚拟机，在后面做内核相关实验的时候就会非常方便。

具体到知识点上，这一节你需要需要记住下面的这些知识点：

虚拟化的本质是用 qemu 的软件模拟硬件，但是模拟方式比较慢，需要加速；

虚拟化主要模拟 CPU、内存、网络、存储，分别有不同的加速办法；

CPU 和内存主要使用硬件辅助虚拟化进行加速，需要配备特殊的硬件才能工作；

网络和存储主要使用特殊的半虚拟化驱动加速，需要加载特殊的驱动程序。

课堂练习

请你务必自己使用 qemu，按照上面我写的步骤创建一台虚拟机。

欢迎留言和我分享你的疑惑和见解，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

50 | 计算虚拟化之CPU（上）：如何复用集团的人力资源？

上一节，我们讲了一下虚拟化的基本原理，以及 qemu、kvm 之间的关系。这一节，我们就来看一下，用户态的 qemu 和内核态的 kvm 如何一起协作，来创建虚拟机，实现 CPU 和内存虚拟化。

这里是上一节我们讲的 qemu 启动时候的命令。

qemu-system-x86_64 -enable-kvm -name ubuntutest  -m 2048 -hda ubuntutest.qcow2 -vnc :19 -net nic,model=virtio -nettap,ifname=tap0,script=no,downscript=no

接下来，我们在这里下载qemu 的代码。qemu 的 main 函数在 vl.c 下面。这是一个非常非常长的函数，我们来慢慢地解析它。

1. 初始化所有的 Module

第一步，初始化所有的 Module，调用下面的函数。

module_call_init(MODULE_INIT_QOM);

上一节我们讲过，qemu 作为中间人其实挺累的，对上面的虚拟机需要模拟各种各样的外部设备。当虚拟机真的要使用物理资源的时候，对下面的物理机上的资源要进行请求，所以它的工作模式有点儿类似操作系统对接驱动。驱动要符合一定的格式，才能算操作系统的一个模块。同理，qemu 为了模拟各种各样的设备，也需要管理各种各样的模块，这些模块也需要符合一定的格式。

定义一个 qemu 模块会调用 type_init。例如，kvm 的模块要在 accel/kvm/kvm-all.c 文件里面实现。在这个文件里面，有一行下面的代码：

type_init(kvm_type_init);

#define type_init(function) module_init(function, MODULE_INIT_QOM)

#define module_init(function, type)                                         \

static void __attribute__((constructor)) do_qemu_init_ ## function(void)    \

{                                                                           \

    register_module_init(function, type);                                   \

}

void register_module_init(void (*fn)(void), module_init_type type)

{

    ModuleEntry *e;

    ModuleTypeList *l;

    e = g_malloc0(sizeof(*e));

    e->init = fn;

    e->type = type;

    l = find_type(type);

    QTAILQ_INSERT_TAIL(l, e, node);

}

从代码里面的定义我们可以看出来，type_init 后面的参数是一个函数，调用 type_init 就相当于调用 module_init，在这里函数就是 kvm_type_init，类型就是 MODULE_INIT_QOM。是不是感觉和驱动有点儿像？

module_init 最终要调用 register_module_init。属于 MODULE_INIT_QOM 这种类型的，有一个 Module 列表 ModuleTypeList，列表里面是一项一项的 ModuleEntry。KVM 就是其中一项，并且会初始化每一项的 init 函数为参数表示的函数 fn，也即 KVM 这个 module 的 init 函数就是 kvm_type_init。

当然，MODULE_INIT_QOM 这种类型会有很多很多的 module，从后面的代码我们可以看到，所有调用 type_init 的地方都注册了一个 MODULE_INIT_QOM 类型的 Module。

了解了 Module 的注册机制，我们继续回到 main 函数中 module_call_init 的调用。

void module_call_init(module_init_type type)

{

    ModuleTypeList *l;

    ModuleEntry *e;

    l = find_type(type);

    QTAILQ_FOREACH(e, l, node) {

        e->init();

    }

}

在 module_call_init 中，我们会找到 MODULE_INIT_QOM 这种类型对应的 ModuleTypeList，找出列表中所有的 ModuleEntry，然后调用每个 ModuleEntry 的 init 函数。这里需要注意的是，在 module_call_init 调用的这一步，所有 Module 的 init 函数都已经被调用过了。

后面我们会看到很多的 Module，当你看到它们的时候，你需要意识到，它的 init 函数在这里也被调用过了。这里我们还是以对于 kvm 这个 module 为例子，看看它的 init 函数都做了哪些事情。你会发现，其实它调用的是 kvm_type_init。

static void kvm_type_init(void)

{

    type_register_static(&kvm_accel_type);

}

TypeImpl *type_register_static(const TypeInfo *info)

{

    return type_register(info);

}

TypeImpl *type_register(const TypeInfo *info)

{

    assert(info->parent);

    return type_register_internal(info);

}

static TypeImpl *type_register_internal(const TypeInfo *info)

{

    TypeImpl *ti;

    ti = type_new(info);

    type_table_add(ti);

    return ti;

}

static TypeImpl *type_new(const TypeInfo *info)

{

    TypeImpl *ti = g_malloc0(sizeof(*ti));

    int i;

    if (type_table_lookup(info->name) != NULL) {

    }

    ti->name = g_strdup(info->name);

    ti->parent = g_strdup(info->parent);

    ti->class_size = info->class_size;

    ti->instance_size = info->instance_size;

    ti->class_init = info->class_init;

    ti->class_base_init = info->class_base_init;

    ti->class_data = info->class_data;

    ti->instance_init = info->instance_init;

    ti->instance_post_init = info->instance_post_init;

    ti->instance_finalize = info->instance_finalize;

    ti->abstract = info->abstract;

    for (i = 0; info->interfaces && info->interfaces[i].type; i++) {

        ti->interfaces[i].typename = g_strdup(info->interfaces[i].type);

    }

    ti->num_interfaces = i;

    return ti;

}

static void type_table_add(TypeImpl *ti)

{

    assert(!enumerating_types);

    g_hash_table_insert(type_table_get(), (void *)ti->name, ti);

}

static GHashTable *type_table_get(void)

{

    static GHashTable *type_table;

    if (type_table == NULL) {

        type_table = g_hash_table_new(g_str_hash, g_str_equal);

    }

    return type_table;

}

static const TypeInfo kvm_accel_type = {

    .name = TYPE_KVM_ACCEL,

    .parent = TYPE_ACCEL,

    .class_init = kvm_accel_class_init,

    .instance_size = sizeof(KVMState),

};

每一个 Module 既然要模拟某种设备，那应该定义一种类型 TypeImpl 来表示这些设备，这其实是一种面向对象编程的思路，只不过这里用的是纯 C 语言的实现，所以需要变相实现一下类和对象。

kvm_type_init 会注册 kvm_accel_type，定义上面的代码，我们可以认为这样动态定义了一个类。这个类的名字是 TYPE_KVM_ACCEL，这个类有父类 TYPE_ACCEL，这个类的初始化应该调用函数 kvm_accel_class_init（看，这里已经直接叫类 class 了）。如果用这个类声明一个对象，对象的大小应该是 instance_size。是不是有点儿 Java 语言反射的意思，根据一些名称的定义，一个类就定义好了。

这里的调用链为：kvm_type_init->type_register_static->type_register->type_register_internal。

在 type_register_internal 中，我们会根据 kvm_accel_type 这个 TypeInfo，创建一个 TypeImpl 来表示这个新注册的类，也就是说，TypeImpl 才是我们想要声明的那个 class。在 qemu 里面，有一个全局的哈希表 type_table，用来存放所有定义的类。在 type_new 里面，我们先从全局表里面根据名字找这个类。如果找到，说明这个类曾经被注册过，就报错；如果没有找到，说明这是一个新的类，则将 TypeInfo 里面信息填到 TypeImpl 里面。type_table_add 会将这个类注册到全局的表里面。到这里，我们注意，class_init 还没有被调用，也即这个类现在还处于纸面的状态。

这点更加像 Java 的反射机制了。在 Java 里面，对于一个类，首先我们写代码的时候要写一个 class xxx 的定义，编译好就放在.class 文件中，这也是出于纸面的状态。然后，Java 会有一个 Class 对象，用于读取和表示这个纸面上的 class xxx，可以生成真正的对象。

相同的过程在后面的代码中我们也可以看到，class_init 会生成 XXXClass，就相当于 Java 里面的 Class 对象，TypeImpl 还会有一个 instance_init 函数，相当于构造函数，用于根据 XXXClass 生成 Object，这就相当于 Java 反射里面最终创建的对象。和构造函数对应的还有 instance_finalize，相当于析构函数。

这一套反射机制放在 qom 文件夹下面，全称 QEMU Object Model，也即用 C 实现了一套面向对象的反射机制。

说完了初始化 Module，我们还回到 main 函数接着分析。

2. 解析 qemu 的命令行

第二步我们就要开始解析 qemu 的命令行了。qemu 的命令行解析，就是下面这样一长串。还记得咱们自己写过一个解析命令行参数的程序吗？这里的 opts 是差不多的意思。

    qemu_add_opts(&qemu_drive_opts);

    qemu_add_opts(&qemu_chardev_opts);

    qemu_add_opts(&qemu_device_opts);

    qemu_add_opts(&qemu_netdev_opts);

    qemu_add_opts(&qemu_nic_opts);

    qemu_add_opts(&qemu_net_opts);

    qemu_add_opts(&qemu_rtc_opts);

    qemu_add_opts(&qemu_machine_opts);

    qemu_add_opts(&qemu_accel_opts);

    qemu_add_opts(&qemu_mem_opts);

    qemu_add_opts(&qemu_smp_opts);

    qemu_add_opts(&qemu_boot_opts);

    qemu_add_opts(&qemu_name_opts);

    qemu_add_opts(&qemu_numa_opts);

为什么有这么多的 opts 呢？这是因为，我们上一节给的参数都是简单的参数，实际运行中创建的 kvm 参数会复杂 N 倍。这里我们贴一个开源云平台软件 OpenStack 创建出来的 KVM 的参数，如下所示。不要被吓坏，你不需要全部看懂，只需要看懂一部分就行了。具体我来给你解析。

qemu-system-x86_64

-enable-kvm

-name instance-00000024

-machine pc-i440fx-trusty,accel=kvm,usb=off

-cpu SandyBridge,+erms,+smep,+fsgsbase,+pdpe1gb,+rdrand,+f16c,+osxsave,+dca,+pcid,+pdcm,+xtpr,+tm2,+est,+smx,+vmx,+ds_cpl,+monitor,+dtes64,+pbe,+tm,+ht,+ss,+acpi,+ds,+vme

-m 2048

-smp 1,sockets=1,cores=1,threads=1

......

-rtc base=utc,driftfix=slew

-drive file=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/disk,if=none,id=drive-virtio-disk0,format=qcow2,cache=none

-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1

-netdev tap,fd=32,id=hostnet0,vhost=on,vhostfd=37

-device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:d1:2d:99,bus=pci.0,addr=0x3

-chardev file,id=charserial0,path=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/console.log

-vnc 0.0.0.0:12

-device cirrus-vga,id=video0,bus=pci.0,addr=0x2

-enable-kvm：表示启用硬件辅助虚拟化。

-name instance-00000024：表示虚拟机的名称。

-machine pc-i440fx-trusty,accel=kvm,usb=off：machine 是什么呢？其实就是计算机体系结构。不知道什么是体系结构的话，可以订阅极客时间的另一个专栏《深入浅出计算机组成原理》。
qemu 会模拟多种体系结构，常用的有普通 PC 机，也即 x86 的 32 位或者 64 位的体系结构、Mac 电脑 PowerPC 的体系结构、Sun 的体系结构、MIPS 的体系结构，精简指令集。如果使用 KVM hardware-assisted virtualization，也即 BIOS 中 VD-T 是打开的，则参数中 accel=kvm。如果不使用 hardware-assisted virtualization，用的是纯模拟，则有参数 accel = tcg，-no-kvm。

-cpu SandyBridge,+erms,+smep,+fsgsbase,+pdpe1gb,+rdrand,+f16c,+osxsave,+dca,+pcid,+pdcm,+xtpr,+tm2,+est,+smx,+vmx,+ds_cpl,+monitor,+dtes64,+pbe,+tm,+ht,+ss,+acpi,+ds,+vme：表示设置 CPU，SandyBridge 是 Intel 处理器，后面的加号都是添加的 CPU 的参数，这些参数会显示在 /proc/cpuinfo 里面。

-m 2048：表示内存。

-smp 1,sockets=1,cores=1,threads=1：SMP 我们解析过，叫对称多处理器，和 NUMA 对应。qemu 仿真了一个具有 1 个 vcpu，一个 socket，一个 core，一个 threads 的处理器。
socket、core、threads 是什么概念呢？socket 就是主板上插 cpu 的槽的数目，也即常说的“路”，core 就是我们平时说的“核”，即双核、4 核等。thread 就是每个 core 的硬件线程数，即超线程。举个具体的例子，某个服务器是：2 路 4 核超线程（一般默认为 2 个线程），通过 cat /proc/cpuinfo，我们看到的是 242=16 个 processor，很多人也习惯成为 16 核了。

-rtc base=utc,driftfix=slew：表示系统时间由参数 -rtc 指定。

-device cirrus-vga,id=video0,bus=pci.0,addr=0x2：表示显示器用参数 -vga 设置，默认为 cirrus，它模拟了 CL-GD5446PCI VGA card。

有关网卡，使用 -net 参数和 -device。

从 HOST 角度：-netdev tap,fd=32,id=hostnet0,vhost=on,vhostfd=37。

从 GUEST 角度：-device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:d1:2d:99,bus=pci.0,addr=0x3。

有关硬盘，使用 -hda -hdb，或者使用 -drive 和 -device。

从 HOST 角度：-drive file=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/disk,if=none,id=drive-virtio-disk0,format=qcow2,cache=none

从 GUEST 角度：-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1

-vnc 0.0.0.0:12：设置 VNC。

在 main 函数中，接下来的 for 循环和大量的 switch case 语句，就是对于这些参数的解析，我们不一一解析，后面真的用到这些参数的时候，我们再仔细看。

3. 初始化 machine

回到 main 函数，接下来是初始化 machine。

machine_class = select_machine();

current_machine = MACHINE(object_new(object_class_get_name(

                          OBJECT_CLASS(machine_class))));

这里面的 machine_class 是什么呢？这还得从 machine 参数说起。

-machine pc-i440fx-trusty,accel=kvm,usb=off

这里的 pc-i440fx 是 x86 机器默认的体系结构。在 hw/i386/pc_piix.c 中，它定义了对应的 machine_class。

DEFINE_I440FX_MACHINE(v4_0, "pc-i440fx-4.0", NULL,

                      pc_i440fx_4_0_machine_options);

#define DEFINE_I440FX_MACHINE(suffix, name, compatfn, optionfn) \

    static void pc_init_##suffix(MachineState *machine) \

    { \

......

        pc_init1(machine, TYPE_I440FX_PCI_HOST_BRIDGE, \

                 TYPE_I440FX_PCI_DEVICE); \

    } \

    DEFINE_PC_MACHINE(suffix, name, pc_init_##suffix, optionfn)

#define DEFINE_PC_MACHINE(suffix, namestr, initfn, optsfn) \

    static void pc_machine_##suffix##_class_init(ObjectClass *oc, void *data

) \

    { \

        MachineClass *mc = MACHINE_CLASS(oc); \

        optsfn(mc); \

        mc->init = initfn; \

    } \

    static const TypeInfo pc_machine_type_##suffix = { \

        .name       = namestr TYPE_MACHINE_SUFFIX, \

        .parent     = TYPE_PC_MACHINE, \

        .class_init = pc_machine_##suffix##_class_init, \

    }; \

    static void pc_machine_init_##suffix(void) \

    { \

        type_register(&pc_machine_type_##suffix); \

    } \

    type_init(pc_machine_init_##suffix)

为了定义 machine_class，这里有一系列的宏定义。入口是 DEFINE_I440FX_MACHINE。这个宏有几个参数，v4_0 是后缀，"pc-i440fx-4.0"是名字，pc_i440fx_4_0_machine_options 是一个函数，用于定义 machine_class 相关的选项。这个函数定义如下：

static void pc_i440fx_4_0_machine_options(MachineClass *m)

{

    pc_i440fx_machine_options(m);

    m->alias = "pc";

    m->is_default = 1;

}

static void pc_i440fx_machine_options(MachineClass *m)

{

    PCMachineClass *pcmc = PC_MACHINE_CLASS(m);

    pcmc->default_nic_model = "e1000";

    m->family = "pc_piix";

    m->desc = "Standard PC (i440FX + PIIX, 1996)";

    m->default_machine_opts = "firmware=bios-256k.bin";

    m->default_display = "std";

    machine_class_allow_dynamic_sysbus_dev(m, TYPE_RAMFB_DEVICE);

}

我们先不看 pc_i440fx_4_0_machine_options，先来看 DEFINE_I440FX_MACHINE。

这里面定义了一个 pc_init_##suffix，也就是 pc_init_v4_0。这里面转而调用 pc_init1。注意这里这个函数只是定义了一下，没有被调用。

接下来，DEFINE_I440FX_MACHINE 里面又定义了 DEFINE_PC_MACHINE。它有四个参数，除了 DEFINE_I440FX_MACHINE 传进来的三个参数以外，多了一个 initfn，也即初始化函数，指向刚才定义的 pc_init_##suffix。

在 DEFINE_PC_MACHINE 中，我们定义了一个函数 pc_machine_##suffix##class_init。从函数的名字 class_init 可以看出，这是 machine_class 从纸面上的 class 初始化为 Class 对象的方法。在这个函数里面，我们可以看到，它创建了一个 MachineClass 对象，这个就是 Class 对象。MachineClass 对象的 init 函数指向上面定义的 pc_init##suffix，说明这个函数是 machine 这种类型初始化的一个函数，后面会被调用。

接着，我们看 DEFINE_PC_MACHINE。它定义了一个 pc_machine_type_##suffix 的 TypeInfo。这是用于生成纸面上的 class 的原材料，果真后面调用了 type_init。

看到了 type_init，我们应该能够想到，既然它定义了一个纸面上的 class，那上面的那句 module_call_init，会和我们上面解析的 type_init 是一样的，在全局的表里面注册了一个全局的名字是"pc-i440fx-4.0"的纸面上的 class，也即 TypeImpl。

现在全局表中有这个纸面上的 class 了。我们回到 select_machine。

static MachineClass *select_machine(void)

{

    MachineClass *machine_class = find_default_machine();

    const char *optarg;

    QemuOpts *opts;

......

    opts = qemu_get_machine_opts();

    qemu_opts_loc_restore(opts);

    optarg = qemu_opt_get(opts, "type");

    if (optarg) {

        machine_class = machine_parse(optarg);

    }

......

    return machine_class;

}

MachineClass *find_default_machine(void)

{

    GSList *el, *machines = object_class_get_list(TYPE_MACHINE, false);

    MachineClass *mc = NULL;

    for (el = machines; el; el = el->next) {

        MachineClass *temp = el->data;

        if (temp->is_default) {

            mc = temp;

            break;

        }

    }

    g_slist_free(machines);

    return mc;

}

static MachineClass *machine_parse(const char *name)

{

    MachineClass *mc = NULL;

    GSList *el, *machines = object_class_get_list(TYPE_MACHINE, false);

    if (name) {

        mc = find_machine(name);

    }

    if (mc) {

        g_slist_free(machines);

        return mc;

    }

......

}

在 select_machine 中，有两种方式可以生成 MachineClass。一种方式是 find_default_machine，找一个默认的；另一种方式是 machine_parse，通过解析参数生成 MachineClass。无论哪种方式，都会调用 object_class_get_list 获得一个 MachineClass 的列表，然后在里面找。object_class_get_list 定义如下：

GSList *object_class_get_list(const char *implements_type,

                              bool include_abstract)

{

    GSList *list = NULL;

    object_class_foreach(object_class_get_list_tramp,

                         implements_type, include_abstract, &list);

    return list;

}

void object_class_foreach(void (*fn)(ObjectClass *klass, void *opaque), const char *implements_type, bool include_abstract,

                          void *opaque)

{

    OCFData data = { fn, implements_type, include_abstract, opaque };

    enumerating_types = true;

    g_hash_table_foreach(type_table_get(), object_class_foreach_tramp, &data);

    enumerating_types = false;

}

在全局表 type_table_get() 中，对于每一项 TypeImpl，我们都执行 object_class_foreach_tramp。

static void object_class_foreach_tramp(gpointer key, gpointer value,

                                       gpointer opaque)

{

    OCFData *data = opaque;

    TypeImpl *type = value;

    ObjectClass *k;

    type_initialize(type);

    k = type->class;

......

    data->fn(k, data->opaque);

}

static void type_initialize(TypeImpl *ti)

{

    TypeImpl *parent;

......

    ti->class_size = type_class_get_size(ti);

    ti->instance_size = type_object_get_size(ti);

    if (ti->instance_size == 0) {

        ti->abstract = true;

    }

......

    ti->class = g_malloc0(ti->class_size);

......

    ti->class->type = ti;

    while (parent) {

        if (parent->class_base_init) {

            parent->class_base_init(ti->class, ti->class_data);

        }

        parent = type_get_parent(parent);

    }

    if (ti->class_init) {

        ti->class_init(ti->class, ti->class_data);

    }

}

在 object_class_foreach_tramp 中，会调用将 type_initialize，这里面会调用 class_init 将纸面上的 class 也即 TypeImpl 变为 ObjectClass，ObjectClass 是所有 Class 类的祖先，MachineClass 是它的子类。

因为在 machine 的命令行里面，我们指定了名字为"pc-i440fx-4.0"，就肯定能够找到我们注册过了的 TypeImpl，并调用它的 class_init 函数。

因而 pc_machine_##suffix##class_init 会被调用，在这里面，pc_i440fx_machine_options 才真正被调用初始化 MachineClass，并且将 MachineClass 的 init 函数设置为 pc_init##suffix。也即，当 select_machine 执行完毕后，就有一个 MachineClass 了。

接着，我们回到 object_new。这就很好理解了，MachineClass 是一个 Class 类，接下来应该通过它生成一个 Instance，也即对象，这就是 object_new 的作用。

Object *object_new(const char *typename)

{

    TypeImpl *ti = type_get_by_name(typename);

    return object_new_with_type(ti);

}

static Object *object_new_with_type(Type type)

{

    Object *obj;

    type_initialize(type);

    obj = g_malloc(type->instance_size);

    object_initialize_with_type(obj, type->instance_size, type);

    obj->free = g_free;

    return obj;

}

object_new 中，TypeImpl 的 instance_init 会被调用，创建一个对象。current_machine 就是这个对象，它的类型是 MachineState。

至此，绕了这么大一圈，有关体系结构的对象才创建完毕，接下来很多的设备的初始化，包括 CPU 和内存的初始化，都是围绕着体系结构的对象来的，后面我们会常常看到 current_machine。

总结时刻

这一节，我们学到，虚拟机对于设备的模拟是一件非常复杂的事情，需要用复杂的参数模拟各种各样的设备。为了能够适配这些设备，qemu 定义了自己的模块管理机制，只有了解了这种机制，后面看每一种设备的虚拟化的时候，才有一个整体的思路。

这里的 MachineClass 是我们遇到的第一个，我们需要掌握它里面各种定义之间的关系。

每个模块都会有一个定义 TypeInfo，会通过 type_init 变为全局的 TypeImpl。TypeInfo 以及生成的 TypeImpl 有以下成员：

name 表示当前类型的名称

parent 表示父类的名称

class_init 用于将 TypeImpl 初始化为 MachineClass

instance_init 用于将 MachineClass 初始化为 MachineState

所以，以后遇到任何一个类型的时候，将父类和子类之间的关系，以及对应的初始化函数都要看好，这样就一目了然了。

课堂练习

你可能会问，这么复杂的 qemu 命令，我是怎么找到的，当然不是我一个字一个字打的，这是著名的云平台管理软件 OpenStack 创建虚拟机的时候自动生成的命令行。所以，给你留一道课堂练习题，请你看一下 OpenStack 的基本原理，看它是通过什么工具来管理如此复杂的命令行的。

欢迎留言和我分享你的疑惑和见解，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

51 | 计算虚拟化之CPU（下）：如何复用集团的人力资源？

上一节 qemu 初始化的 main 函数，我们解析了一个开头，得到了表示体系结构的 MachineClass 以及 MachineState。

4. 初始化块设备

我们接着回到 main 函数，接下来初始化的是块设备，调用的是 configure_blockdev。这里我们需要重点关注上面参数中的硬盘，不过我们放在存储虚拟化那一节再解析。

configure_blockdev(&bdo_queue, machine_class, snapshot);

5. 初始化计算虚拟化的加速模式

接下来初始化的是计算虚拟化的加速模式，也即要不要使用 KVM。根据参数中的配置是启用 KVM。这里调用的是 configure_accelerator。

configure_accelerator(current_machine, argv[0]);

void configure_accelerator(MachineState *ms, const char *progname)

{

    const char *accel;

    char **accel_list, **tmp;

    int ret;

    bool accel_initialised = false;

    bool init_failed = false;

    AccelClass *acc = NULL;

    accel = qemu_opt_get(qemu_get_machine_opts(), "accel");

    accel = "kvm";

    accel_list = g_strsplit(accel, ":", 0);

    for (tmp = accel_list; !accel_initialised && tmp && *tmp; tmp++) {

        acc = accel_find(*tmp);

        ret = accel_init_machine(acc, ms);

    }

}

static AccelClass *accel_find(const char *opt_name)

{

    char *class_name = g_strdup_printf(ACCEL_CLASS_NAME("%s"), opt_name);

    AccelClass *ac = ACCEL_CLASS(object_class_by_name(class_name));

    g_free(class_name);

    return ac;

}

static int accel_init_machine(AccelClass *acc, MachineState *ms)

{

    ObjectClass *oc = OBJECT_CLASS(acc);

    const char *cname = object_class_get_name(oc);

    AccelState *accel = ACCEL(object_new(cname));

    int ret;

    ms->accelerator = accel;

    *(acc->allowed) = true;

    ret = acc->init_machine(ms);

    return ret;

}

在 configure_accelerator 中，我们看命令行参数里面的 accel，发现是 kvm，则调用 accel_find 根据名字，得到相应的纸面上的 class，并初始化为 Class 类。

MachineClass 是计算机体系结构的 Class 类，同理，AccelClass 就是加速器的 Class 类，然后调用 accel_init_machine，通过 object_new，将 AccelClass 这个 Class 类实例化为 AccelState，类似对于体系结构的实例是 MachineState。

在 accel_find 中，我们会根据名字 kvm，找到纸面上的 class，也即 kvm_accel_type，然后调用 type_initialize，里面调用 kvm_accel_type 的 class_init 方法，也即 kvm_accel_class_init。

static void kvm_accel_class_init(ObjectClass *oc, void *data)

{

    AccelClass *ac = ACCEL_CLASS(oc);

    ac->name = "KVM";

    ac->init_machine = kvm_init;

    ac->allowed = &kvm_allowed;

}

在 kvm_accel_class_init 中，我们创建 AccelClass，将 init_machine 设置为 kvm_init。在 accel_init_machine 中其实就调用了这个 init_machine 函数，也即调用 kvm_init 方法。

static int kvm_init(MachineState *ms)

{

    MachineClass *mc = MACHINE_GET_CLASS(ms);

    int soft_vcpus_limit, hard_vcpus_limit;

    KVMState *s;

    const KVMCapabilityInfo *missing_cap;

    int ret;

    int type = 0;

    const char *kvm_type;

    s = KVM_STATE(ms->accelerator);

    s->fd = qemu_open("/dev/kvm", O_RDWR);

    ret = kvm_ioctl(s, KVM_GET_API_VERSION, 0);

......

    do {

        ret = kvm_ioctl(s, KVM_CREATE_VM, type);

    } while (ret == -EINTR);

......

    s->vmfd = ret;

    /* check the vcpu limits */

    soft_vcpus_limit = kvm_recommended_vcpus(s);

    hard_vcpus_limit = kvm_max_vcpus(s);

......

    ret = kvm_arch_init(ms, s);

    if (ret < 0) {

        goto err;

    }

    if (machine_kernel_irqchip_allowed(ms)) {

        kvm_irqchip_create(ms, s);

    }

......

    return 0;

}

这里面的操作就从用户态到内核态的 KVM 了。就像前面原理讲过的一样，用户态使用内核态 KVM 的能力，需要打开一个文件 /dev/kvm，这是一个字符设备文件，打开一个字符设备文件的过程我们讲过，这里不再赘述。

static struct miscdevice kvm_dev = {

    KVM_MINOR,

    "kvm",

    &kvm_chardev_ops,

};

static struct file_operations kvm_chardev_ops = {

    .unlocked_ioctl = kvm_dev_ioctl,

    .compat_ioctl   = kvm_dev_ioctl,

    .llseek     = noop_llseek,

};

KVM 这个字符设备文件定义了一个字符设备文件的操作函数 kvm_chardev_ops，这里面只定义了 ioctl 的操作。

接下来，用户态就通过 ioctl 系统调用，调用到 kvm_dev_ioctl 这个函数。这个过程我们在字符设备那一节也讲了。

static long kvm_dev_ioctl(struct file *filp,

              unsigned int ioctl, unsigned long arg)

{

    long r = -EINVAL;

    switch (ioctl) {

    case KVM_GET_API_VERSION:

        r = KVM_API_VERSION;

        break;

    case KVM_CREATE_VM:

        r = kvm_dev_ioctl_create_vm(arg);

        break;

    case KVM_CHECK_EXTENSION:

        r = kvm_vm_ioctl_check_extension_generic(NULL, arg);

        break;

    case KVM_GET_VCPU_MMAP_SIZE:

        r = PAGE_SIZE;     /* struct kvm_run */

        break;

......

    }

out:

    return r;

}

我们可以看到，在用户态 qemu 中，调用 KVM_GET_API_VERSION 查看版本号，内核就有相应的分支，返回版本号，如果能够匹配上，则调用 KVM_CREATE_VM 创建虚拟机。

创建虚拟机，需要调用 kvm_dev_ioctl_create_vm。

static int kvm_dev_ioctl_create_vm(unsigned long type)

{

    int r;

    struct kvm *kvm;

    struct file *file;

    kvm = kvm_create_vm(type);

......

    r = get_unused_fd_flags(O_CLOEXEC);

......

    file = anon_inode_getfile("kvm-vm", &kvm_vm_fops, kvm, O_RDWR);

......

    fd_install(r, file);

    return r;

}

在 kvm_dev_ioctl_create_vm 中，首先调用 kvm_create_vm 创建一个 struct kvm 结构。这个结构在内核里面代表一个虚拟机。

从下面结构的定义里，我们可以看到，这里面有 vcpu，有 mm_struct 结构。这个结构本来用来管理进程的内存的。虚拟机也是一个进程，所以虚拟机的用户进程空间也是用它来表示。虚拟机里面的操作系统以及应用的进程空间不归它管。

在 kvm_dev_ioctl_create_vm 中，第二件事情就是创建一个文件描述符，和 struct file 关联起来，这个 struct file 的 file_operations 会被设置为 kvm_vm_fops。

struct kvm {

	struct mm_struct *mm; /* userspace tied to this vm */

	struct kvm_memslots __rcu *memslots[KVM_ADDRESS_SPACE_NUM];

	struct kvm_vcpu *vcpus[KVM_MAX_VCPUS];

	atomic_t online_vcpus;

	int created_vcpus;

	int last_boosted_vcpu;

	struct list_head vm_list;

	struct mutex lock;

	struct kvm_io_bus __rcu *buses[KVM_NR_BUSES];

......

	struct kvm_vm_stat stat;

	struct kvm_arch arch;

	refcount_t users_count;

......

	long tlbs_dirty;

	struct list_head devices;

	pid_t userspace_pid;

};

static struct file_operations kvm_vm_fops = {

	.release        = kvm_vm_release,

	.unlocked_ioctl = kvm_vm_ioctl,

	.llseek		= noop_llseek,

};

kvm_dev_ioctl_create_vm 结束之后，对于一台虚拟机而言，只是在内核中有一个数据结构，对于相应的资源还没有分配，所以我们还需要接着看。

6. 初始化网络设备

接下来，调用 net_init_clients 进行网络设备的初始化。我们可以解析 net 参数，也会在 net_init_clients 中解析 netdev 参数。这属于网络虚拟化的部分，我们先暂时放一下。

int net_init_clients(Error **errp)

{

    QTAILQ_INIT(&net_clients);

    if (qemu_opts_foreach(qemu_find_opts("netdev"),

                          net_init_netdev, NULL, errp)) {

        return -1;

    }

    if (qemu_opts_foreach(qemu_find_opts("nic"), net_param_nic, NULL, errp)) {

        return -1;

   }

    if (qemu_opts_foreach(qemu_find_opts("net"), net_init_client, NULL, errp)) {

        return -1;

    }

    return 0;

}

7.CPU 虚拟化

接下来，我们要调用 machine_run_board_init。这里面调用了 MachineClass 的 init 函数。盼啊盼才到了它，这才调用了 pc_init1。

void machine_run_board_init(MachineState *machine)

{

    MachineClass *machine_class = MACHINE_GET_CLASS(machine);

    numa_complete_configuration(machine);

    if (nb_numa_nodes) {

        machine_numa_finish_cpu_init(machine);

    }

......

    machine_class->init(machine);

}

在 pc_init1 里面，我们重点关注两件重要的事情，一个的 CPU 的虚拟化，主要调用 pc_cpus_init；另外就是内存的虚拟化，主要调用 pc_memory_init。这一节我们重点关注 CPU 的虚拟化，下一节，我们来看内存的虚拟化。

void pc_cpus_init(PCMachineState *pcms)

{

......

    for (i = 0; i < smp_cpus; i++) {

        pc_new_cpu(possible_cpus->cpus[i].type, possible_cpus->cpus[i].arch_id, &error_fatal);

    }

}

static void pc_new_cpu(const char *typename, int64_t apic_id, Error **errp)

{

    Object *cpu = NULL;

    cpu = object_new(typename);

    object_property_set_uint(cpu, apic_id, "apic-id", &local_err);

    object_property_set_bool(cpu, true, "realized", &local_err);// 调用 object_property_add_bool 的时候，设置了用 device_set_realized 来设置

......

}

在 pc_cpus_init 中，对于每一个 CPU，都调用 pc_new_cpu，在这里，我们又看到了 object_new，这又是一个从 TypeImpl 到 Class 类再到对象的一个过程。

这个时候，我们就要看 CPU 的类是怎么组织的了。

在上面的参数里面，CPU 的配置是这样的：

-cpu SandyBridge,+erms,+smep,+fsgsbase,+pdpe1gb,+rdrand,+f16c,+osxsave,+dca,+pcid,+pdcm,+xtpr,+tm2,+est,+smx,+vmx,+ds_cpl,+monitor,+dtes64,+pbe,+tm,+ht,+ss,+acpi,+ds,+vme

在这里我们知道，SandyBridge 是 CPU 的一种类型。在 hw/i386/pc.c 中，我们能看到这种 CPU 的定义。

{ "SandyBridge" "-" TYPE_X86_CPU, "min-xlevel", "0x8000000a" }

接下来，我们就来看"SandyBridge"，也即 TYPE_X86_CPU 这种 CPU 的类，是一个什么样的结构。

static const TypeInfo device_type_info = {

    .name = TYPE_DEVICE,

    .parent = TYPE_OBJECT,

    .instance_size = sizeof(DeviceState),

    .instance_init = device_initfn,

    .instance_post_init = device_post_init,

    .instance_finalize = device_finalize,

    .class_base_init = device_class_base_init,

    .class_init = device_class_init,

    .abstract = true,

    .class_size = sizeof(DeviceClass),

};

static const TypeInfo cpu_type_info = {

    .name = TYPE_CPU,

    .parent = TYPE_DEVICE,

    .instance_size = sizeof(CPUState),

    .instance_init = cpu_common_initfn,

    .instance_finalize = cpu_common_finalize,

    .abstract = true,

    .class_size = sizeof(CPUClass),

    .class_init = cpu_class_init,

};

static const TypeInfo x86_cpu_type_info = {

    .name = TYPE_X86_CPU,

    .parent = TYPE_CPU,

    .instance_size = sizeof(X86CPU),

    .instance_init = x86_cpu_initfn,

    .abstract = true,

    .class_size = sizeof(X86CPUClass),

    .class_init = x86_cpu_common_class_init,

};

CPU 这种类的定义是有多层继承关系的。TYPE_X86_CPU 的父类是 TYPE_CPU，TYPE_CPU 的父类是 TYPE_DEVICE，TYPE_DEVICE 的父类是 TYPE_OBJECT。到头了。

这里面每一层都有 class_init，用于从 TypeImpl 生产 xxxClass，也有 instance_init 将 xxxClass 初始化为实例。

在 TYPE_X86_CPU 这一层的 class_init 中，也即 x86_cpu_common_class_init 中，设置了 DeviceClass 的 realize 函数为 x86_cpu_realizefn。这个函数很重要，马上就能用到。

static void x86_cpu_common_class_init(ObjectClass *oc, void *data)

{

    X86CPUClass *xcc = X86_CPU_CLASS(oc);

    CPUClass *cc = CPU_CLASS(oc);

    DeviceClass *dc = DEVICE_CLASS(oc);

    device_class_set_parent_realize(dc, x86_cpu_realizefn,

                                    &xcc->parent_realize);

......

}

在 TYPE_DEVICE 这一层的 instance_init 函数 device_initfn，会为这个设备添加一个属性"realized"，要设置这个属性，需要用函数 device_set_realized。

static void device_initfn(Object *obj)

{

    DeviceState *dev = DEVICE(obj);

    ObjectClass *class;

    Property *prop;

    dev->realized = false;

    object_property_add_bool(obj, "realized",

                             device_get_realized, device_set_realized, NULL);

......

}

我们回到 pc_new_cpu 函数，这里面就是通过 object_property_set_bool 设置这个属性为 true，所以 device_set_realized 函数会被调用。

在 device_set_realized 中，DeviceClass 的 realize 函数 x86_cpu_realizefn 会被调用。这里面 qemu_init_vcpu 会调用 qemu_kvm_start_vcpu。

static void qemu_kvm_start_vcpu(CPUState *cpu)

{

    char thread_name[VCPU_THREAD_NAME_SIZE];

    cpu->thread = g_malloc0(sizeof(QemuThread));

    cpu->halt_cond = g_malloc0(sizeof(QemuCond));

    qemu_cond_init(cpu->halt_cond);

    qemu_thread_create(cpu->thread, thread_name, qemu_kvm_cpu_thread_fn, cpu, QEMU_THREAD_JOINABLE);

}

在这里面，为这个 vcpu 创建一个线程，也即虚拟机里面的一个 vcpu 对应物理机上的一个线程，然后这个线程被调度到某个物理 CPU 上。

我们来看这个 vcpu 的线程执行函数。

static void *qemu_kvm_cpu_thread_fn(void *arg)

{

    CPUState *cpu = arg;

    int r;

    rcu_register_thread();

    qemu_mutex_lock_iothread();

    qemu_thread_get_self(cpu->thread);

    cpu->thread_id = qemu_get_thread_id();

    cpu->can_do_io = 1;

    current_cpu = cpu;

    r = kvm_init_vcpu(cpu);

    kvm_init_cpu_signals(cpu);

    /* signal CPU creation */

    cpu->created = true;

    qemu_cond_signal(&qemu_cpu_cond);

    do {

        if (cpu_can_run(cpu)) {

            r = kvm_cpu_exec(cpu);

        }

        qemu_wait_io_event(cpu);

    } while (!cpu->unplug || cpu_can_run(cpu));

    qemu_kvm_destroy_vcpu(cpu);

    cpu->created = false;

    qemu_cond_signal(&qemu_cpu_cond);

    qemu_mutex_unlock_iothread();

    rcu_unregister_thread();

    return NULL;

}

在 qemu_kvm_cpu_thread_fn 中，先是 kvm_init_vcpu 初始化这个 vcpu。

int kvm_init_vcpu(CPUState *cpu)

{

    KVMState *s = kvm_state;

    long mmap_size;

    int ret;

......

    ret = kvm_get_vcpu(s, kvm_arch_vcpu_id(cpu));

......

    cpu->kvm_fd = ret;

    cpu->kvm_state = s;

    cpu->vcpu_dirty = true;

    mmap_size = kvm_ioctl(s, KVM_GET_VCPU_MMAP_SIZE, 0);

......

    cpu->kvm_run = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, cpu->kvm_fd, 0);

......

    ret = kvm_arch_init_vcpu(cpu);

err:

    return ret;

}

在 kvm_get_vcpu 中，我们会调用 kvm_vm_ioctl(s, KVM_CREATE_VCPU, (void *)vcpu_id)，在内核里面创建一个 vcpu。在上面创建 KVM_CREATE_VM 的时候，我们已经创建了一个 struct file，它的 file_operations 被设置为 kvm_vm_fops，这个内核文件也是可以响应 ioctl 的。

如果我们切换到内核 KVM，在 kvm_vm_ioctl 函数中，有对于 KVM_CREATE_VCPU 的处理，调用的是 kvm_vm_ioctl_create_vcpu。

static long kvm_vm_ioctl(struct file *filp,

			   unsigned int ioctl, unsigned long arg)

{

	struct kvm *kvm = filp->private_data;

	void __user *argp = (void __user *)arg;

	int r;

	switch (ioctl) {

	case KVM_CREATE_VCPU:

		r = kvm_vm_ioctl_create_vcpu(kvm, arg);

		break;

	case KVM_SET_USER_MEMORY_REGION: {

		struct kvm_userspace_memory_region kvm_userspace_mem;

		if (copy_from_user(&kvm_userspace_mem, argp,

						sizeof(kvm_userspace_mem)))

			goto out;

		r = kvm_vm_ioctl_set_memory_region(kvm, &kvm_userspace_mem);

		break;

	}

......

	case KVM_CREATE_DEVICE: {

		struct kvm_create_device cd;

		if (copy_from_user(&cd, argp, sizeof(cd)))

			goto out;

		r = kvm_ioctl_create_device(kvm, &cd);

		if (copy_to_user(argp, &cd, sizeof(cd)))

			goto out;

		break;

	}

	case KVM_CHECK_EXTENSION:

		r = kvm_vm_ioctl_check_extension_generic(kvm, arg);

		break;

	default:

		r = kvm_arch_vm_ioctl(filp, ioctl, arg);

	}

out:

	return r;

}

在 kvm_vm_ioctl_create_vcpu 中，kvm_arch_vcpu_create 调用 kvm_x86_ops 的 vcpu_create 函数来创建 CPU。

static int kvm_vm_ioctl_create_vcpu(struct kvm *kvm, u32 id)

{

    int r;

    struct kvm_vcpu *vcpu;

    kvm->created_vcpus++;

......

    vcpu = kvm_arch_vcpu_create(kvm, id);

    preempt_notifier_init(&vcpu->preempt_notifier, &kvm_preempt_ops);

    r = kvm_arch_vcpu_setup(vcpu);

......

    /* Now it's all set up, let userspace reach it */

    kvm_get_kvm(kvm);

    r = create_vcpu_fd(vcpu);

    kvm->vcpus[atomic_read(&kvm->online_vcpus)] = vcpu;

......

}

struct kvm_vcpu *kvm_arch_vcpu_create(struct kvm *kvm,

                        unsigned int id)

{

    struct kvm_vcpu *vcpu;

    vcpu = kvm_x86_ops->vcpu_create(kvm, id);

    return vcpu;

}

static int create_vcpu_fd(struct kvm_vcpu *vcpu)

{

    return anon_inode_getfd("kvm-vcpu", &kvm_vcpu_fops, vcpu, O_RDWR | O_CLOEXEC);

}

然后，create_vcpu_fd 又创建了一个 struct file，它的 file_operations 指向 kvm_vcpu_fops。从这里可以看出，KVM 的内核模块是一个文件，可以通过 ioctl 进行操作。基于这个内核模块创建的 VM 也是一个文件，也可以通过 ioctl 进行操作。在这个 VM 上创建的 vcpu 同样是一个文件，同样可以通过 ioctl 进行操作。

我们回过头来看，kvm_x86_ops 的 vcpu_create 函数。kvm_x86_ops 对于不同的硬件加速虚拟化指向不同的结构，如果是 vmx，则指向 vmx_x86_ops；如果是 svm，则指向 svm_x86_ops。我们这里看 vmx_x86_ops。这个结构很长，里面有非常多的操作，我们用一个看一个。

static struct kvm_x86_ops vmx_x86_ops __ro_after_init = {

......

	.vcpu_create = vmx_create_vcpu,

......

}

static struct kvm_vcpu *vmx_create_vcpu(struct kvm *kvm, unsigned int id)

{

    int err;

    struct vcpu_vmx *vmx = kmem_cache_zalloc(kvm_vcpu_cache, GFP_KERNEL);

    int cpu;

    vmx->vpid = allocate_vpid();

    err = kvm_vcpu_init(&vmx->vcpu, kvm, id);

    vmx->guest_msrs = kmalloc(PAGE_SIZE, GFP_KERNEL);

    vmx->loaded_vmcs = &vmx->vmcs01;

    vmx->loaded_vmcs->vmcs = alloc_vmcs();

    vmx->loaded_vmcs->shadow_vmcs = NULL;

    loaded_vmcs_init(vmx->loaded_vmcs);

    cpu = get_cpu();

    vmx_vcpu_load(&vmx->vcpu, cpu);

    vmx->vcpu.cpu = cpu;

    err = vmx_vcpu_setup(vmx);

    vmx_vcpu_put(&vmx->vcpu);

    put_cpu();

    if (enable_ept) {

        if (!kvm->arch.ept_identity_map_addr)

            kvm->arch.ept_identity_map_addr =

                VMX_EPT_IDENTITY_PAGETABLE_ADDR;

        err = init_rmode_identity_map(kvm);

    }

    return &vmx->vcpu;

}

vmx_create_vcpu 创建用于表示 vcpu 的结构 struct vcpu_vmx，并填写里面的内容。例如 guest_msrs，咱们在讲系统调用的时候提过 msr 寄存器，虚拟机也需要有这样的寄存器。

enable_ept 是和内存虚拟化相关的，EPT 全称 Extended Page Table，顾名思义，是优化内存虚拟化的，这个功能我们放到内存的那一节讲。

最最重要的就是 loaded_vmcs 了。VMCS 是什么呢？它的全称是 Virtual Machine Control Structure。它是来干什么呢？

前面咱们将进程调度的时候讲过，为了支持进程在 CPU 上的切换，CPU 硬件要求有一个 TSS 结构，用于保存进程运行时的所有寄存器的状态，进程切换的时候，需要根据 TSS 恢复寄存器。

虚拟机也是一个进程，也需要切换，而且切换更加的复杂，可能是两个虚拟机之间切换，也可能是虚拟机切换给内核，虚拟机因为里面还有另一个操作系统，要保存的信息比普通的进程多得多。那就需要有一个结构来保存虚拟机运行的上下文，VMCS 就是是 Intel 实现 CPU 虚拟化，记录 vCPU 状态的一个关键数据结构。

VMCS 数据结构主要包含以下信息。

Guest-state area，即 vCPU 的状态信息，包括 vCPU 的基本运行环境，例如寄存器等。

Host-state area，是物理 CPU 的状态信息。物理 CPU 和 vCPU 之间也会来回切换，所以，VMCS 中既要记录 vCPU 的状态，也要记录物理 CPU 的状态。

VM-execution control fields，对 vCPU 的运行行为进行控制。例如，发生中断怎么办，是否使用 EPT（Extended Page Table）功能等。

接下来，对于 VMCS，有两个重要的操作。

VM-Entry，我们称为从根模式切换到非根模式，也即切换到 guest 上，这个时候 CPU 上运行的是虚拟机。VM-Exit 我们称为 CPU 从非根模式切换到根模式，也即从 guest 切换到宿主机。例如，当要执行一些虚拟机没有权限的敏感指令时。

为了维护这两个动作，VMCS 里面还有几项内容：

VM-exit control fields，对 VM Exit 的行为进行控制。比如，VM Exit 的时候对 vCPU 来说需要保存哪些 MSR 寄存器，对于主机 CPU 来说需要恢复哪些 MSR 寄存器。

VM-entry control fields，对 VM Entry 的行为进行控制。比如，需要保存和恢复哪些 MSR 寄存器等。

VM-exit information fields，记录下发生 VM Exit 发生的原因及一些必要的信息，方便对 VM Exit 事件进行处理。

至此，内核准备完毕。

我们再回到 qemu 的 kvm_init_vcpu 函数，这里面除了创建内核中的 vcpu 结构之外，还通过 mmap 将内核的 vcpu 结构，映射到 qemu 中 CPUState 的 kvm_run 中，为什么能用 mmap 呢，上面咱们不是说过了吗，vcpu 也是一个文件。

我们再回到这个 vcpu 的线程函数 qemu_kvm_cpu_thread_fn，他在执行 kvm_init_vcpu 创建 vcpu 之后，接下来是一个 do-while 循环，也即一直运行，并且通过调用 kvm_cpu_exec，运行这个虚拟机。

int kvm_cpu_exec(CPUState *cpu)

{

    struct kvm_run *run = cpu->kvm_run;

    int ret, run_ret;

......

    do {

......

        run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);

......

        switch (run->exit_reason) {

        case KVM_EXIT_IO:

            kvm_handle_io(run->io.port, attrs,

                          (uint8_t *)run + run->io.data_offset,

                          run->io.direction,

                          run->io.size,

                          run->io.count);

            break;

        case KVM_EXIT_IRQ_WINDOW_OPEN:

            ret = EXCP_INTERRUPT;

            break;

        case KVM_EXIT_SHUTDOWN:

            qemu_system_reset_request(SHUTDOWN_CAUSE_GUEST_RESET);

            ret = EXCP_INTERRUPT;

            break;

        case KVM_EXIT_UNKNOWN:

            fprintf(stderr, "KVM: unknown exit, hardware reason %" PRIx64 "\n",(uint64_t)run->hw.hardware_exit_reason);

            ret = -1;

            break;

        case KVM_EXIT_INTERNAL_ERROR:

            ret = kvm_handle_internal_error(cpu, run);

            break;

......

        }

    } while (ret == 0);

......

    return ret;

}

在 kvm_cpu_exec 中，我们能看到一个循环，在循环中，kvm_vcpu_ioctl(KVM_RUN) 运行这个虚拟机，这个时候 CPU 进入 VM-Entry，也即进入客户机模式。

如果一直是客户机的操作系统占用这个 CPU，则会一直停留在这一行运行，一旦这个调用返回了，就说明 CPU 进入 VM-Exit 退出客户机模式，将 CPU 交还给宿主机。在循环中，我们会对退出的原因 exit_reason 进行分析处理，因为有了 I/O，还有了中断等，做相应的处理。处理完毕之后，再次循环，再次通过 VM-Entry，进入客户机模式。如此循环，直到虚拟机正常或者异常退出。

我们来看 kvm_vcpu_ioctl(KVM_RUN) 在内核做了哪些事情。

上面我们也讲了，vcpu 在内核也是一个文件，也是通过 ioctl 进行用户态和内核态通信的，在内核中，调用的是 kvm_vcpu_ioctl。

static long kvm_vcpu_ioctl(struct file *filp,

               unsigned int ioctl, unsigned long arg)

{

    struct kvm_vcpu *vcpu = filp->private_data;

    void __user *argp = (void __user *)arg;

    int r;

    struct kvm_fpu *fpu = NULL;

    struct kvm_sregs *kvm_sregs = NULL;

......

    r = vcpu_load(vcpu);

    switch (ioctl) {

    case KVM_RUN: {

        struct pid *oldpid;

        r = kvm_arch_vcpu_ioctl_run(vcpu, vcpu->run);

        break;

    }

    case KVM_GET_REGS: {

        struct kvm_regs *kvm_regs;

        kvm_regs = kzalloc(sizeof(struct kvm_regs), GFP_KERNEL);

        r = kvm_arch_vcpu_ioctl_get_regs(vcpu, kvm_regs);

        if (copy_to_user(argp, kvm_regs, sizeof(struct kvm_regs)))

            goto out_free1;

        break;

    }

    case KVM_SET_REGS: {

        struct kvm_regs *kvm_regs;

        kvm_regs = memdup_user(argp, sizeof(*kvm_regs));

        r = kvm_arch_vcpu_ioctl_set_regs(vcpu, kvm_regs);

        break;

    }

......

}

kvm_arch_vcpu_ioctl_run 会调用 vcpu_run，这里面也是一个无限循环。

static int vcpu_run(struct kvm_vcpu *vcpu)

{

	int r;

	struct kvm *kvm = vcpu->kvm;

	for (;;) {

		if (kvm_vcpu_running(vcpu)) {

			r = vcpu_enter_guest(vcpu);

		} else {

			r = vcpu_block(kvm, vcpu);

		}

....

		if (signal_pending(current)) {

			r = -EINTR;

			vcpu->run->exit_reason = KVM_EXIT_INTR;

			++vcpu->stat.signal_exits;

			break;

		}

		if (need_resched()) {

			cond_resched();

		}

	}

......

	return r;

}

在这个循环中，除了调用 vcpu_enter_guest 进入客户机模式运行之外，还有对于信号的响应 signal_pending，也即一台虚拟机是可以被 kill 掉的，还有对于调度的响应，这台虚拟机可以被从当前的物理 CPU 上赶下来，换成别的虚拟机或者其他进程。

我们这里重点看 vcpu_enter_guest。

static int vcpu_enter_guest(struct kvm_vcpu *vcpu)

{

	r = kvm_mmu_reload(vcpu);

	vcpu->mode = IN_GUEST_MODE;

	kvm_load_guest_xcr0(vcpu);

......

	guest_enter_irqoff();

	kvm_x86_ops->run(vcpu);

	vcpu->mode = OUTSIDE_GUEST_MODE;

......

	kvm_put_guest_xcr0(vcpu);

	kvm_x86_ops->handle_external_intr(vcpu);

	++vcpu->stat.exits;

	guest_exit_irqoff();

	r = kvm_x86_ops->handle_exit(vcpu);

	return r;

......

}

static struct kvm_x86_ops vmx_x86_ops __ro_after_init = {

......

	.run = vmx_vcpu_run,

......

}

在 vcpu_enter_guest 中，我们会调用 vmx_x86_ops 的 vmx_vcpu_run 函数，进入客户机模式。

static void __noclone vmx_vcpu_run(struct kvm_vcpu *vcpu)

{

	struct vcpu_vmx *vmx = to_vmx(vcpu);

	unsigned long debugctlmsr, cr3, cr4;

......

	cr3 = __get_current_cr3_fast();

......

	cr4 = cr4_read_shadow();

......

	vmx->__launched = vmx->loaded_vmcs->launched;

	asm(

		/* Store host registers */

		"push %%" _ASM_DX "; push %%" _ASM_BP ";"

		"push %%" _ASM_CX " \n\t" /* placeholder for guest rcx */

		"push %%" _ASM_CX " \n\t"

......

		/* Load guest registers.  Don't clobber flags. */

		"mov %crax, %%" _ASM_AX " \n\t"

		"mov %crbx, %%" _ASM_BX " \n\t"

		"mov %crdx, %%" _ASM_DX " \n\t"

		"mov %crsi, %%" _ASM_SI " \n\t"

		"mov %crdi, %%" _ASM_DI " \n\t"

		"mov %crbp, %%" _ASM_BP " \n\t"

#ifdef CONFIG_X86_64

		"mov %cr8,  %%r8  \n\t"

		"mov %cr9,  %%r9  \n\t"

		"mov %cr10, %%r10 \n\t"

		"mov %cr11, %%r11 \n\t"

		"mov %cr12, %%r12 \n\t"

		"mov %cr13, %%r13 \n\t"

		"mov %cr14, %%r14 \n\t"

		"mov %cr15, %%r15 \n\t"

#endif

		"mov %crcx, %%" _ASM_CX " \n\t" /* kills %0 (ecx) */

		/* Enter guest mode */

		"jne 1f \n\t"

		__ex(ASM_VMX_VMLAUNCH) "\n\t"

		"jmp 2f \n\t"

		"1: " __ex(ASM_VMX_VMRESUME) "\n\t"

		"2: "

		/* Save guest registers, load host registers, keep flags */

		"mov %0, %cwordsize \n\t"

		"pop %0 \n\t"

		"mov %%" _ASM_AX ", %crax \n\t"

		"mov %%" _ASM_BX ", %crbx \n\t"

		__ASM_SIZE(pop) " %crcx \n\t"

		"mov %%" _ASM_DX ", %crdx \n\t"

		"mov %%" _ASM_SI ", %crsi \n\t"

		"mov %%" _ASM_DI ", %crdi \n\t"

		"mov %%" _ASM_BP ", %crbp \n\t"

#ifdef CONFIG_X86_64

		"mov %%r8,  %cr8 \n\t"

		"mov %%r9,  %cr9 \n\t"

		"mov %%r10, %cr10 \n\t"

		"mov %%r11, %cr11 \n\t"

		"mov %%r12, %cr12 \n\t"

		"mov %%r13, %cr13 \n\t"

		"mov %%r14, %cr14 \n\t"

		"mov %%r15, %cr15 \n\t"

#endif

		"mov %%cr2, %%" _ASM_AX "   \n\t"

		"mov %%" _ASM_AX ", %ccr2 \n\t"

		"pop  %%" _ASM_BP "; pop  %%" _ASM_DX " \n\t"

		"setbe %cfail \n\t"

		".pushsection .rodata \n\t"

		".global vmx_return \n\t"

		"vmx_return: " _ASM_PTR " 2b \n\t"

......

	      );

......

	vmx->loaded_vmcs->launched = 1;

	vmx->exit_reason = vmcs_read32(VM_EXIT_REASON);

......

}

在 vmx_vcpu_run 中，出现了汇编语言的代码，比较难看懂，但是没有关系呀，里面有注释呀，我们可以沿着注释来看。

首先是 Store host registers，要从宿主机模式变为客户机模式了，所以原来宿主机运行时候的寄存器要保存下来。

接下来是 Load guest registers，将原来客户机运行的时候的寄存器加载进来。

接下来是 Enter guest mode，调用 ASM_VMX_VMLAUNCH 进入客户机模型运行，或者 ASM_VMX_VMRESUME 恢复客户机模型运行。

如果客户机因为某种原因退出，Save guest registers, load host registers，也即保存客户机运行的时候的寄存器，就加载宿主机运行的时候的寄存器。

最后将 exit_reason 保存在 vmx 结构中。

至此，CPU 虚拟化就解析完了。

总结时刻

CPU 的虚拟化过程还是很复杂的，我画了一张图总结了一下。

首先，我们要定义 CPU 这种类型的 TypeInfo 和 TypeImpl、继承关系，并且声明它的类初始化函数。

在 qemu 的 main 函数中调用 MachineClass 的 init 函数，这个函数既会初始化 CPU，也会初始化内存。

CPU 初始化的时候，会调用 pc_new_cpu 创建一个虚拟 CPU，它会调用 CPU 这个类的初始化函数。

每一个虚拟 CPU 会调用 qemu_thread_create 创建一个线程，线程的执行函数为 qemu_kvm_cpu_thread_fn。

在虚拟 CPU 对应的线程执行函数中，我们先是调用 kvm_vm_ioctl(KVM_CREATE_VCPU)，在内核的 KVM 里面，创建一个结构 struct vcpu_vmx，表示这个虚拟 CPU。在这个结构里面，有一个 VMCS，用于保存当前虚拟机 CPU 的运行时的状态，用于状态切换。

在虚拟 CPU 对应的线程执行函数中，我们接着调用 kvm_vcpu_ioctl(KVM_RUN)，在内核的 KVM 里面运行这个虚拟机 CPU。运行的方式是保存宿主机的寄存器，加载客户机的寄存器，然后调用 __ex(ASM_VMX_VMLAUNCH) 或者 __ex(ASM_VMX_VMRESUME)，进入客户机模式运行。一旦退出客户机模式，就会保存客户机寄存器，加载宿主机寄存器，进入宿主机模式运行，并且会记录退出虚拟机模式的原因。大部分的原因是等待 I/O，因而宿主机调用 kvm_handle_io 进行处理。

课堂练习

在咱们上面操作 KVM 的过程中，出现了好几次文件系统。不愧是“Linux 中一切皆文件”。那你能否整理一下这些文件系统之间的关系呢？

欢迎留言和我分享你的疑惑和见解，也欢迎收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

52 | 计算虚拟化之内存：如何建立独立的办公室？

上一节，我们解析了计算虚拟化之 CPU。可以看到，CPU 的虚拟化是用户态的 qemu 和内核态的 KVM 共同配合完成的。它们二者通过 ioctl 进行通信。对于内存管理来讲，也是需要这两者配合完成的。

咱们在内存管理的时候讲过，操作系统给每个进程分配的内存都是虚拟内存，需要通过页表映射，变成物理内存进行访问。当有了虚拟机之后，情况会变得更加复杂。因为虚拟机对于物理机来讲是一个进程，但是虚拟机里面也有内核，也有虚拟机里面跑的进程。所以有了虚拟机，内存就变成了四类：

虚拟机里面的虚拟内存（Guest OS Virtual Memory，GVA），这是虚拟机里面的进程看到的内存空间；

虚拟机里面的物理内存（Guest OS Physical Memory，GPA），这是虚拟机里面的操作系统看到的内存，它认为这是物理内存；

物理机的虚拟内存（Host Virtual Memory，HVA），这是物理机上的 qemu 进程看到的内存空间；

物理机的物理内存（Host Physical Memory，HPA），这是物理机上的操作系统看到的内存。

咱们内存管理那一章讲的两大内容，一个是内存管理，它变得非常复杂；另一个是内存映射，具体来说就是，从 GVA 到 GPA，到 HVA，再到 HPA，这样几经转手，计算机的性能就会变得很差。当然，虚拟化技术成熟的今天，有了一些优化的手段，具体怎么优化呢？我们这一节就来一一解析。

内存管理

我们先来看内存管理的部分。

由于 CPU 和内存是紧密结合的，因而内存虚拟化的初始化过程，和 CPU 虚拟化的初始化是一起完成的。

上一节说 CPU 虚拟化初始化的时候，我们会调用 kvm_init 函数，这里面打开了"/dev/kvm"这个字符文件，并且通过 ioctl 调用到内核 kvm 的 KVM_CREATE_VM 操作，除了这些 CPU 相关的调用，接下来还有内存相关的。我们来看看。

static int kvm_init(MachineState *ms)

{

    MachineClass *mc = MACHINE_GET_CLASS(ms);

......

    kvm_memory_listener_register(s, &s->memory_listener,

                                 &address_space_memory, 0);

    memory_listener_register(&kvm_io_listener,

                             &address_space_io);

......

}

AddressSpace address_space_io;

AddressSpace address_space_memory;

这里面有两个地址空间 AddressSpace，一个是系统内存的地址空间 address_space_memory，一个用于 I/O 的地址空间 address_space_io。这里我们重点看 address_space_memory。

struct AddressSpace {

    /* All fields are private. */

    struct rcu_head rcu;

    char *name;

    MemoryRegion *root;

    /* Accessed via RCU.  */

    struct FlatView *current_map;

    int ioeventfd_nb;

    struct MemoryRegionIoeventfd *ioeventfds;

    QTAILQ_HEAD(, MemoryListener) listeners;

    QTAILQ_ENTRY(AddressSpace) address_spaces_link;

};

对于一个地址空间，会有多个内存区域 MemoryRegion 组成树形结构。这里面，root 是这棵树的根。另外，还有一个 MemoryListener 链表，当内存区域发生变化的时候，需要做一些动作，使得用户态和内核态能够协同，就是由这些 MemoryListener 完成的。

在 kvm_init 这个时候，还没有内存区域加入进来，root 还是空的，但是我们可以先注册 MemoryListener，这里注册的是 KVMMemoryListener。

void kvm_memory_listener_register(KVMState *s, KVMMemoryListener *kml,

                                  AddressSpace *as, int as_id)

{

    int i;

    kml->slots = g_malloc0(s->nr_slots * sizeof(KVMSlot));

    kml->as_id = as_id;

    for (i = 0; i < s->nr_slots; i++) {

        kml->slots[i].slot = i;

    }

    kml->listener.region_add = kvm_region_add;

    kml->listener.region_del = kvm_region_del;

    kml->listener.priority = 10;

    memory_listener_register(&kml->listener, as);

}

在这个 KVMMemoryListener 中是这样配置的：当添加一个 MemoryRegion 的时候，region_add 会被调用，这个我们后面会用到。

接下来，在 qemu 启动的 main 函数中，我们会调用 cpu_exec_init_all->memory_map_init.

static void memory_map_init(void)

{

    system_memory = g_malloc(sizeof(*system_memory));

    memory_region_init(system_memory, NULL, "system", UINT64_MAX);

    address_space_init(&address_space_memory, system_memory, "memory");

    system_io = g_malloc(sizeof(*system_io));

    memory_region_init_io(system_io, NULL, &unassigned_io_ops, NULL, "io",

                          65536);

    address_space_init(&address_space_io, system_io, "I/O");

}

在这里，对于系统内存区域 system_memory 和用于 I/O 的内存区域 system_io，我们都进行了初始化，并且关联到了相应的地址空间 AddressSpace。

void address_space_init(AddressSpace *as, MemoryRegion *root, const char *name)

{

    memory_region_ref(root);

    as->root = root;

    as->current_map = NULL;

    as->ioeventfd_nb = 0;

    as->ioeventfds = NULL;

    QTAILQ_INIT(&as->listeners);

    QTAILQ_INSERT_TAIL(&address_spaces, as, address_spaces_link);

    as->name = g_strdup(name ? name : "anonymous");

    address_space_update_topology(as);

    address_space_update_ioeventfds(as);

}

对于系统内存地址空间 address_space_memory，我们需要把它里面内存区域的根 root 设置为 system_memory。

另外，在这里，我们还调用了 address_space_update_topology。

static void address_space_update_topology(AddressSpace *as)

{

    MemoryRegion *physmr = memory_region_get_flatview_root(as->root);

    flatviews_init();

    if (!g_hash_table_lookup(flat_views, physmr)) {

        generate_memory_topology(physmr);

    }

    address_space_set_flatview(as);

}

static void address_space_set_flatview(AddressSpace *as)

{

    FlatView *old_view = address_space_to_flatview(as);

    MemoryRegion *physmr = memory_region_get_flatview_root(as->root);

    FlatView *new_view = g_hash_table_lookup(flat_views, physmr);

    if (old_view == new_view) {

        return;

    }

......

    if (!QTAILQ_EMPTY(&as->listeners)) {

        FlatView tmpview = { .nr = 0 }, *old_view2 = old_view;

        if (!old_view2) {

            old_view2 = &tmpview;

        }

        address_space_update_topology_pass(as, old_view2, new_view, false);

        address_space_update_topology_pass(as, old_view2, new_view, true);

    }

    /* Writes are protected by the BQL.  */

    atomic_rcu_set(&as->current_map, new_view);

......

}

这里面会生成 AddressSpace 的 flatview。flatview 是什么意思呢？

我们可以看到，在 AddressSpace 里面，除了树形结构的 MemoryRegion 之外，还有一个 flatview 结构，其实这个结构就是把这样一个树形的内存结构变成平的内存结构。因为树形内存结构比较容易管理，但是平的内存结构，比较方便和内核里面通信，来请求物理内存。虽然操作系统内核里面也是用树形结构来表示内存区域的，但是用户态向内核申请内存的时候，会按照平的、连续的模式进行申请。这里，qemu 在用户态，所以要做这样一个转换。

在 address_space_set_flatview 中，我们将老的 flatview 和新的 flatview 进行比较。如果不同，说明内存结构发生了变化，会调用 address_space_update_topology_pass->MEMORY_LISTENER_UPDATE_REGION->MEMORY_LISTENER_CALL。

这里面调用所有的 listener。但是，这个逻辑这里不会执行的。这是因为这里内存处于初始化的阶段，全局的 flat_views 里面肯定找不到。因而 generate_memory_topology 第一次生成了 FlatView，然后才调用了 address_space_set_flatview。这里面，老的 flatview 和新的 flatview 一定是一样的。

但是，请你记住这个逻辑，到这里我们还没解析 qemu 有关内存的参数，所以这里添加的 MemoryRegion 虽然是一个根，但是是空的，是为了管理使用的，后面真的添加内存的时候，这个逻辑还会调用到。

我们再回到 qemu 启动的 main 函数中。接下来的初始化过程会调用 pc_init1。在这里面，对于 CPU 虚拟化，我们会调用 pc_cpus_init。这个我们在上一节已经讲过了。另外，pc_init1 还会调用 pc_memory_init，进行内存的虚拟化，我们这里解析这一部分。

void pc_memory_init(PCMachineState *pcms,

                    MemoryRegion *system_memory,

                    MemoryRegion *rom_memory,

                    MemoryRegion **ram_memory)

{

    int linux_boot, i;

    MemoryRegion *ram, *option_rom_mr;

    MemoryRegion *ram_below_4g, *ram_above_4g;

    FWCfgState *fw_cfg;

    MachineState *machine = MACHINE(pcms);

    PCMachineClass *pcmc = PC_MACHINE_GET_CLASS(pcms);

......

    /* Allocate RAM.  We allocate it as a single memory region and use

     * aliases to address portions of it, mostly for backwards compatibility with older qemus that used qemu_ram_alloc().

     */

    ram = g_malloc(sizeof(*ram));

    memory_region_allocate_system_memory(ram, NULL, "pc.ram",

                                         machine->ram_size);

    *ram_memory = ram;

    ram_below_4g = g_malloc(sizeof(*ram_below_4g));

    memory_region_init_alias(ram_below_4g, NULL, "ram-below-4g", ram,

                             0, pcms->below_4g_mem_size);

    memory_region_add_subregion(system_memory, 0, ram_below_4g);

    e820_add_entry(0, pcms->below_4g_mem_size, E820_RAM);

    if (pcms->above_4g_mem_size > 0) {

        ram_above_4g = g_malloc(sizeof(*ram_above_4g));

        memory_region_init_alias(ram_above_4g, NULL, "ram-above-4g", ram, pcms->below_4g_mem_size, pcms->above_4g_mem_size);

        memory_region_add_subregion(system_memory, 0x100000000ULL,

                                    ram_above_4g);

        e820_add_entry(0x100000000ULL, pcms->above_4g_mem_size, E820_RAM);

    }

......

}

在 pc_memory_init 中，我们已经知道了虚拟机要申请的内存 ram_size，于是通过 memory_region_allocate_system_memory 来申请内存。

接下来的调用链为：memory_region_allocate_system_memory->allocate_system_memory_nonnuma->memory_region_init_ram_nomigrate->memory_region_init_ram_shared_nomigrate。

void memory_region_init_ram_shared_nomigrate(MemoryRegion *mr,

                                             Object *owner,

                                             const char *name,

                                             uint64_t size,

                                             bool share,

                                             Error **errp)

{

    Error *err = NULL;

    memory_region_init(mr, owner, name, size);

    mr->ram = true;

    mr->terminates = true;

    mr->destructor = memory_region_destructor_ram;

    mr->ram_block = qemu_ram_alloc(size, share, mr, &err);

......

}

static

RAMBlock *qemu_ram_alloc_internal(ram_addr_t size, ram_addr_t max_size, void (*resized)(const char*,uint64_t length,void *host),void *host, bool resizeable, bool share,MemoryRegion *mr, Error **errp)

{

    RAMBlock *new_block;

    size = HOST_PAGE_ALIGN(size);

    max_size = HOST_PAGE_ALIGN(max_size);

    new_block = g_malloc0(sizeof(*new_block));

    new_block->mr = mr;

    new_block->resized = resized;

    new_block->used_length = size;

    new_block->max_length = max_size;

    new_block->fd = -1;

    new_block->page_size = getpagesize();

    new_block->host = host;

......

    ram_block_add(new_block, &local_err, share);

    return new_block;

}

static void ram_block_add(RAMBlock *new_block, Error **errp, bool shared)

{

    RAMBlock *block;

    RAMBlock *last_block = NULL;

    ram_addr_t old_ram_size, new_ram_size;

    Error *err = NULL;

    old_ram_size = last_ram_page();

    new_block->offset = find_ram_offset(new_block->max_length);

    if (!new_block->host) {

        new_block->host = phys_mem_alloc(new_block->max_length, &new_block->mr->align, shared);

......

        }

    }

......

}

这里面，我们会调用 qemu_ram_alloc，创建一个 RAMBlock 用来表示内存块。这里面调用 ram_block_add->phys_mem_alloc。phys_mem_alloc 是一个函数指针，指向函数 qemu_anon_ram_alloc，这里面调用 qemu_ram_mmap，在 qemu_ram_mmap 中调用 mmap 分配内存。

static void *(*phys_mem_alloc)(size_t size, uint64_t *align, bool shared) = qemu_anon_ram_alloc;

void *qemu_anon_ram_alloc(size_t size, uint64_t *alignment, bool shared)

{

    size_t align = QEMU_VMALLOC_ALIGN;

    void *ptr = qemu_ram_mmap(-1, size, align, shared);

......

    if (alignment) {

        *alignment = align;

    }

    return ptr;

}

void *qemu_ram_mmap(int fd, size_t size, size_t align, bool shared)

{

    int flags;

    int guardfd;

    size_t offset;

    size_t pagesize;

    size_t total;

    void *guardptr;

    void *ptr;

......

    total = size + align;

    guardfd = -1;

    pagesize = getpagesize();

    flags = MAP_PRIVATE | MAP_ANONYMOUS;

    guardptr = mmap(0, total, PROT_NONE, flags, guardfd, 0);

......

    flags = MAP_FIXED;

    flags |= fd == -1 ? MAP_ANONYMOUS : 0;

    flags |= shared ? MAP_SHARED : MAP_PRIVATE;

    offset = QEMU_ALIGN_UP((uintptr_t)guardptr, align) - (uintptr_t)guardptr;

    ptr = mmap(guardptr + offset, size, PROT_READ | PROT_WRITE, flags, fd, 0);

......

    return ptr;

}

我们回到 pc_memory_init，通过 memory_region_allocate_system_memory 申请到内存以后，为了兼容过去的版本，我们分成两个 MemoryRegion 进行管理，一个是 ram_below_4g，一个是 ram_above_4g。对于这两个 MemoryRegion，我们都会初始化一个 alias，也即别名，意思是说，两个 MemoryRegion 其实都指向 memory_region_allocate_system_memory 分配的内存，只不过分成两个部分，起两个别名指向不同的区域。

这两部分 MemoryRegion 都会调用 memory_region_add_subregion，将这两部分作为子的内存区域添加到 system_memory 这棵树上。

接下来的调用链为：memory_region_add_subregion->memory_region_add_subregion_common->memory_region_update_container_subregions。

static void memory_region_update_container_subregions(MemoryRegion *subregion)

{

    MemoryRegion *mr = subregion->container;

    MemoryRegion *other;

    memory_region_transaction_begin();

    memory_region_ref(subregion);

    QTAILQ_FOREACH(other, &mr->subregions, subregions_link) {

        if (subregion->priority >= other->priority) {

            QTAILQ_INSERT_BEFORE(other, subregion, subregions_link);

            goto done;

        }

    }

    QTAILQ_INSERT_TAIL(&mr->subregions, subregion, subregions_link);

done:

    memory_region_update_pending |= mr->enabled && subregion->enabled;

    memory_region_transaction_commit();

}

在 memory_region_update_container_subregions 中，我们会将子区域放到链表中，然后调用 memory_region_transaction_commit。在这里面，我们会调用 address_space_set_flatview。因为内存区域变了，flatview 也会变，就像上面分析过的一样，listener 会被调用。

因为添加了一个 MemoryRegion，region_add 也即 kvm_region_add。

static void kvm_region_add(MemoryListener *listener,

                           MemoryRegionSection *section)

{

    KVMMemoryListener *kml = container_of(listener, KVMMemoryListener, listener);

    kvm_set_phys_mem(kml, section, true);

}

static void kvm_set_phys_mem(KVMMemoryListener *kml,

                             MemoryRegionSection *section, bool add)

{

    KVMSlot *mem;

    int err;

    MemoryRegion *mr = section->mr;

    bool writeable = !mr->readonly && !mr->rom_device;

    hwaddr start_addr, size;

    void *ram;

......

    size = kvm_align_section(section, &start_addr);

......

    /* use aligned delta to align the ram address */

    ram = memory_region_get_ram_ptr(mr) + section->offset_within_region + (start_addr - section->offset_within_address_space);

......

    /* register the new slot */

    mem = kvm_alloc_slot(kml);

    mem->memory_size = size;

    mem->start_addr = start_addr;

    mem->ram = ram;

    mem->flags = kvm_mem_flags(mr);

    err = kvm_set_user_memory_region(kml, mem, true);

......

}

kvm_region_add 调用的是 kvm_set_phys_mem，这里面分配一个用于放这块那内存的 KVMSlot 结构，就像一个内存条一样，当然这是在用户态模拟出来的内存条，放在 KVMState 结构里面。这个结构是我们上一节创建虚拟机的时候创建的。

接下来，kvm_set_user_memory_region 就会将用户态模拟出来的内存条，和内核中的 KVM 模块关联起来。

static int kvm_set_user_memory_region(KVMMemoryListener *kml, KVMSlot *slot, bool new)

{

    KVMState *s = kvm_state;

    struct kvm_userspace_memory_region mem;

    int ret;

    mem.slot = slot->slot | (kml->as_id << 16);

    mem.guest_phys_addr = slot->start_addr;

    mem.userspace_addr = (unsigned long)slot->ram;

    mem.flags = slot->flags;

......

    mem.memory_size = slot->memory_size;

    ret = kvm_vm_ioctl(s, KVM_SET_USER_MEMORY_REGION, &mem);

    slot->old_flags = mem.flags;

......

    return ret;

}

终于，在这里，我们又看到了可以和内核通信的 kvm_vm_ioctl。我们来看内核收到 KVM_SET_USER_MEMORY_REGION 会做哪些事情。

static long kvm_vm_ioctl(struct file *filp,

               unsigned int ioctl, unsigned long arg)

{

    struct kvm *kvm = filp->private_data;

    void __user *argp = (void __user *)arg;

    switch (ioctl) {

    case KVM_SET_USER_MEMORY_REGION: {

        struct kvm_userspace_memory_region kvm_userspace_mem;

        if (copy_from_user(&kvm_userspace_mem, argp,

                        sizeof(kvm_userspace_mem)))

            goto out;

        r = kvm_vm_ioctl_set_memory_region(kvm, &kvm_userspace_mem);

        break;

    }

......

}

接下来的调用链为：kvm_vm_ioctl_set_memory_region->kvm_set_memory_region->__kvm_set_memory_region。

int __kvm_set_memory_region(struct kvm *kvm,

			    const struct kvm_userspace_memory_region *mem)

{

	int r;

	gfn_t base_gfn;

	unsigned long npages;

	struct kvm_memory_slot *slot;

	struct kvm_memory_slot old, new;

	struct kvm_memslots *slots = NULL, *old_memslots;

	int as_id, id;

	enum kvm_mr_change change;

......

	as_id = mem->slot >> 16;

	id = (u16)mem->slot;

	slot = id_to_memslot(__kvm_memslots(kvm, as_id), id);

	base_gfn = mem->guest_phys_addr >> PAGE_SHIFT;

	npages = mem->memory_size >> PAGE_SHIFT;

......

	new = old = *slot;

	new.id = id;

	new.base_gfn = base_gfn;

	new.npages = npages;

	new.flags = mem->flags;

......

 	if (change == KVM_MR_CREATE) {

		new.userspace_addr = mem->userspace_addr;

		if (kvm_arch_create_memslot(kvm, &new, npages))

			goto out_free;

	}

......

	slots = kvzalloc(sizeof(struct kvm_memslots), GFP_KERNEL);

	memcpy(slots, __kvm_memslots(kvm, as_id), sizeof(struct kvm_memslots));

......

	r = kvm_arch_prepare_memory_region(kvm, &new, mem, change);

	update_memslots(slots, &new);

	old_memslots = install_new_memslots(kvm, as_id, slots);

	kvm_arch_commit_memory_region(kvm, mem, &old, &new, change);

	return 0;

......

}

在用户态每个 KVMState 有多个 KVMSlot，在内核里面，同样每个 struct kvm 也有多个 struct kvm_memory_slot，两者是对应起来的。

// 用户态

struct KVMState

{

......

    int nr_slots;

......

    KVMMemoryListener memory_listener;

......

};

typedef struct KVMMemoryListener {

    MemoryListener listener;

    KVMSlot *slots;

    int as_id;

} KVMMemoryListener

typedef struct KVMSlot

{

    hwaddr start_addr;

    ram_addr_t memory_size;

    void *ram;

    int slot;

    int flags;

    int old_flags;

} KVMSlot;

// 内核态

struct kvm {

	spinlock_t mmu_lock;

	struct mutex slots_lock;

	struct mm_struct *mm; /* userspace tied to this vm */

	struct kvm_memslots __rcu *memslots[KVM_ADDRESS_SPACE_NUM];

......

}

struct kvm_memslots {

	u64 generation;

	struct kvm_memory_slot memslots[KVM_MEM_SLOTS_NUM];

	/* The mapping table from slot id to the index in memslots[]. */

	short id_to_index[KVM_MEM_SLOTS_NUM];

	atomic_t lru_slot;

	int used_slots;

};

struct kvm_memory_slot {

	gfn_t base_gfn;// 根据 guest_phys_addr 计算

	unsigned long npages;

	unsigned long *dirty_bitmap;

	struct kvm_arch_memory_slot arch;

	unsigned long userspace_addr;

	u32 flags;

	short id;

};

并且，id_to_memslot 函数可以根据用户态的 slot 号得到内核态的 slot 结构。

如果传进来的参数是 KVM_MR_CREATE，表示要创建一个新的内存条，就会调用 kvm_arch_create_memslot 来创建 kvm_memory_slot 的成员 kvm_arch_memory_slot。

接下来就是创建 kvm_memslots 结构，填充这个结构，然后通过 install_new_memslots 将这个新的内存条，添加到 struct kvm 结构中。

至此，用户态的内存结构和内核态的内存结构算是对应了起来。

页面分配和映射

上面对于内存的管理，还只是停留在元数据的管理。对于内存的分配与映射，我们还没有涉及，接下来，我们就来看看，页面是如何进行分配和映射的。

上面咱们说了，内存映射对于虚拟机来讲是一件非常麻烦的事情，从 GVA 到 GPA 到 HVA 到 HPA，性能很差，为了解决这个问题，有两种主要的思路。

影子页表

第一种方式就是软件的方式，影子页表 （Shadow Page Table）。

按照咱们在内存管理那一节讲的，内存映射要通过页表来管理，页表地址应该放在 cr3 寄存器里面。本来的过程是，客户机要通过 cr3 找到客户机的页表，实现从 GVA 到 GPA 的转换，然后在宿主机上，要通过 cr3 找到宿主机的页表，实现从 HVA 到 HPA 的转换。

为了实现客户机虚拟地址空间到宿主机物理地址空间的直接映射。客户机中每个进程都有自己的虚拟地址空间，所以 KVM 需要为客户机中的每个进程页表都要维护一套相应的影子页表。

在客户机访问内存时，使用的不是客户机的原来的页表，而是这个页表对应的影子页表，从而实现了从客户机虚拟地址到宿主机物理地址的直接转换。而且，在 TLB 和 CPU 缓存上缓存的是来自影子页表中客户机虚拟地址和宿主机物理地址之间的映射，也因此提高了缓存的效率。

但是影子页表的引入也意味着 KVM 需要为每个客户机的每个进程的页表都要维护一套相应的影子页表，内存占用比较大，而且客户机页表和和影子页表也需要进行实时同步。

扩展页表

于是就有了第二种方式，就是硬件的方式，Intel 的 EPT（Extent Page Table，扩展页表）技术。

EPT 在原有客户机页表对客户机虚拟地址到客户机物理地址映射的基础上，又引入了 EPT 页表来实现客户机物理地址到宿主机物理地址的另一次映射。客户机运行时，客户机页表被载入 CR3，而 EPT 页表被载入专门的 EPT 页表指针寄存器 EPTP。

有了 EPT，在客户机物理地址到宿主机物理地址转换的过程中，缺页会产生 EPT 缺页异常。KVM 首先根据引起异常的客户机物理地址，映射到对应的宿主机虚拟地址，然后为此虚拟地址分配新的物理页，最后 KVM 再更新 EPT 页表，建立起引起异常的客户机物理地址到宿主机物理地址之间的映射。

KVM 只需为每个客户机维护一套 EPT 页表，也大大减少了内存的开销。

这里，我们重点看第二种方式。因为使用了 EPT 之后，客户机里面的页表映射，也即从 GVA 到 GPA 的转换，还是用传统的方式，和在内存管理那一章讲的没有什么区别。而 EPT 重点帮我们解决的就是从 GPA 到 HPA 的转换问题。因为要经过两次页表，所以 EPT 又 tdp(two dimentional paging)。

EPT 的页表结构也是分为四层，EPT Pointer （EPTP）指向 PML4 的首地址。

管理物理页面的 Page 结构和咱们讲内存管理那一章是一样的。EPT 页表也需要存放在一个页中，这些页要用 kvm_mmu_page 这个结构来管理。

当一个虚拟机运行，进入客户机模式的时候，我们上一节解析过，它会调用 vcpu_enter_guest 函数，这里面会调用 kvm_mmu_reload->kvm_mmu_load。

int kvm_mmu_load(struct kvm_vcpu *vcpu)

{

......

	r = mmu_topup_memory_caches(vcpu);

	r = mmu_alloc_roots(vcpu);

	kvm_mmu_sync_roots(vcpu);

	/* set_cr3() should ensure TLB has been flushed */

	vcpu->arch.mmu.set_cr3(vcpu, vcpu->arch.mmu.root_hpa);

......

}

static int mmu_alloc_roots(struct kvm_vcpu *vcpu)

{

	if (vcpu->arch.mmu.direct_map)

		return mmu_alloc_direct_roots(vcpu);

	else

		return mmu_alloc_shadow_roots(vcpu);

}

static int mmu_alloc_direct_roots(struct kvm_vcpu *vcpu)

{

	struct kvm_mmu_page *sp;

	unsigned i;

	if (vcpu->arch.mmu.shadow_root_level == PT64_ROOT_LEVEL) {

		spin_lock(&vcpu->kvm->mmu_lock);

		make_mmu_pages_available(vcpu);

		sp = kvm_mmu_get_page(vcpu, 0, 0, PT64_ROOT_LEVEL, 1, ACC_ALL);

		++sp->root_count;

		spin_unlock(&vcpu->kvm->mmu_lock);

		vcpu->arch.mmu.root_hpa = __pa(sp->spt);

	}

......

}

这里构建的是页表的根部，也即顶级页表，并且设置 cr3 来刷新 TLB。mmu_alloc_roots 会调用 mmu_alloc_direct_roots，因为我们用的是 EPT 模式，而非影子表。在 mmu_alloc_direct_roots 中，kvm_mmu_get_page 会分配一个 kvm_mmu_page，来存放顶级页表项。

接下来，当虚拟机真的要访问内存的时候，会发现有的页表没有建立，有的物理页没有分配，这都会触发缺页异常，在 KVM 里面会发送 VM-Exit，从客户机模式转换为宿主机模式，来修复这个缺失的页表或者物理页。

static int (*const kvm_vmx_exit_handlers[])(struct kvm_vcpu *vcpu) = {

    [EXIT_REASON_EXCEPTION_NMI]           = handle_exception,

    [EXIT_REASON_EXTERNAL_INTERRUPT]      = handle_external_interrupt,

    [EXIT_REASON_IO_INSTRUCTION]          = handle_io,

......

    [EXIT_REASON_EPT_VIOLATION]       = handle_ept_violation,

......

}

咱们前面讲过，虚拟机退出客户机模式有很多种原因，例如接收到中断、接收到 I/O 等，EPT 的缺页异常也是一种类型，我们称为 EXIT_REASON_EPT_VIOLATION，对应的处理函数是 handle_ept_violation。

static int handle_ept_violation(struct kvm_vcpu *vcpu)

{

	gpa_t gpa;

......

	gpa = vmcs_read64(GUEST_PHYSICAL_ADDRESS);

......

	vcpu->arch.gpa_available = true;

	vcpu->arch.exit_qualification = exit_qualification;

	return kvm_mmu_page_fault(vcpu, gpa, error_code, NULL, 0);

}

int kvm_mmu_page_fault(struct kvm_vcpu *vcpu, gva_t cr2, u64 error_code,

		       void *insn, int insn_len)

{

......

	r = vcpu->arch.mmu.page_fault(vcpu, cr2, lower_32_bits(error_code),false);

......

}

在 handle_ept_violation 里面，我们从 VMCS 中得到没有解析成功的 GPA，也即客户机的物理地址，然后调用 kvm_mmu_page_fault，看为什么解析不成功。kvm_mmu_page_fault 会调用 page_fault 函数，其实是 tdp_page_fault 函数。tdp 的意思就是 EPT，前面我们解释过了。

static int tdp_page_fault(struct kvm_vcpu *vcpu, gva_t gpa, u32 error_code, bool prefault)

{

	kvm_pfn_t pfn;

	int r;

	int level;

	bool force_pt_level;

	gfn_t gfn = gpa >> PAGE_SHIFT;

	unsigned long mmu_seq;

	int write = error_code & PFERR_WRITE_MASK;

	bool map_writable;

	r = mmu_topup_memory_caches(vcpu);

	level = mapping_level(vcpu, gfn, &force_pt_level);

......

	if (try_async_pf(vcpu, prefault, gfn, gpa, &pfn, write, &map_writable))

		return 0;

	if (handle_abnormal_pfn(vcpu, 0, gfn, pfn, ACC_ALL, &r))

		return r;

	make_mmu_pages_available(vcpu);

	r = __direct_map(vcpu, write, map_writable, level, gfn, pfn, prefault);

......

}

既然没有映射，就应该加上映射，tdp_page_fault 就是干这个事情的。

在 tdp_page_fault 这个函数开头，我们通过 gpa，也即客户机的物理地址得到客户机的页号 gfn。接下来，我们要通过调用 try_async_pf 得到宿主机的物理地址对应的页号，也即真正的物理页的页号，然后通过 __direct_map 将两者关联起来。

static bool try_async_pf(struct kvm_vcpu *vcpu, bool prefault, gfn_t gfn, gva_t gva, kvm_pfn_t *pfn, bool write, bool *writable)

{

	struct kvm_memory_slot *slot;

	bool async;

	slot = kvm_vcpu_gfn_to_memslot(vcpu, gfn);

	async = false;

	*pfn = __gfn_to_pfn_memslot(slot, gfn, false, &async, write, writable);

	if (!async)

		return false; /* *pfn has correct page already */

	if (!prefault && kvm_can_do_async_pf(vcpu)) {

		if (kvm_find_async_pf_gfn(vcpu, gfn)) {

			kvm_make_request(KVM_REQ_APF_HALT, vcpu);

			return true;

		} else if (kvm_arch_setup_async_pf(vcpu, gva, gfn))

			return true;

	}

	*pfn = __gfn_to_pfn_memslot(slot, gfn, false, NULL, write, writable);

	return false;

}

在 try_async_pf 中，要想得到 pfn，也即物理页的页号，会先通过 kvm_vcpu_gfn_to_memslot，根据客户机的物理地址对应的页号找到内存条，然后调用 __gfn_to_pfn_memslot，根据内存条找到 pfn。

kvm_pfn_t __gfn_to_pfn_memslot(struct kvm_memory_slot *slot, gfn_t gfn,bool atomic, bool *async, bool write_fault,bool *writable)

{

	unsigned long addr = __gfn_to_hva_many(slot, gfn, NULL, write_fault);

......

	return hva_to_pfn(addr, atomic, async, write_fault,

			  writable);

}

在 __gfn_to_pfn_memslot 中，我们会调用 __gfn_to_hva_many，从客户机物理地址对应的页号，得到宿主机虚拟地址 hva，然后从宿主机虚拟地址到宿主机物理地址，调用的是 hva_to_pfn。

hva_to_pfn 会调用 hva_to_pfn_slow。

static int hva_to_pfn_slow(unsigned long addr, bool *async, bool write_fault,

			   bool *writable, kvm_pfn_t *pfn)

{

	struct page *page[1];

	int npages = 0;

......

	if (async) {

		npages = get_user_page_nowait(addr, write_fault, page);

	} else {

......

		npages = get_user_pages_unlocked(addr, 1, page, flags);

	}

......

	*pfn = page_to_pfn(page[0]);

	return npages;

}

在 hva_to_pfn_slow 中，我们要先调用 get_user_page_nowait，得到一个物理页面，然后再调用 page_to_pfn 将物理页面转换成为物理页号。

无论是哪一种 get_user_pages_XXX，最终都会调用 __get_user_pages 函数。这里面会调用 faultin_page，在 faultin_page 中我们会调用 handle_mm_fault。看到这个是不是很熟悉？这就是咱们内存管理那一章讲的缺页异常的逻辑，分配一个物理内存。

至此，try_async_pf 得到了物理页面，并且转换为对应的物理页号。

接下来，__direct_map 会关联客户机物理页号和宿主机物理页号。

static int __direct_map(struct kvm_vcpu *vcpu, int write, int map_writable,

			int level, gfn_t gfn, kvm_pfn_t pfn, bool prefault)

{

	struct kvm_shadow_walk_iterator iterator;

	struct kvm_mmu_page *sp;

	int emulate = 0;

	gfn_t pseudo_gfn;

	if (!VALID_PAGE(vcpu->arch.mmu.root_hpa))

		return 0;

	for_each_shadow_entry(vcpu, (u64)gfn << PAGE_SHIFT, iterator) {

		if (iterator.level == level) {

			emulate = mmu_set_spte(vcpu, iterator.sptep, ACC_ALL,

					       write, level, gfn, pfn, prefault,

					       map_writable);

			direct_pte_prefetch(vcpu, iterator.sptep);

			++vcpu->stat.pf_fixed;

			break;

		}

		drop_large_spte(vcpu, iterator.sptep);

		if (!is_shadow_present_pte(*iterator.sptep)) {

			u64 base_addr = iterator.addr;

			base_addr &= PT64_LVL_ADDR_MASK(iterator.level);

			pseudo_gfn = base_addr >> PAGE_SHIFT;

			sp = kvm_mmu_get_page(vcpu, pseudo_gfn, iterator.addr,

					      iterator.level - 1, 1, ACC_ALL);

			link_shadow_page(vcpu, iterator.sptep, sp);

		}

	}

	return emulate;

}

__direct_map 首先判断页表的根是否存在，当然存在，我们刚才初始化了。

接下来是 for_each_shadow_entry 一个循环。每一个循环中，先是会判断需要映射的 level，是否正是当前循环的这个 iterator.level。如果是，则说明是叶子节点，直接映射真正的物理页面 pfn，然后退出。接着是非叶子节点的情形，判断如果这一项指向的页表项不存在，就要建立页表项，通过 kvm_mmu_get_page 得到保存页表项的页面，然后将这一项指向下一级的页表页面。

至此，内存映射就结束了。

总结时刻

我们这里来总结一下，虚拟机的内存管理也是需要用户态的 qemu 和内核态的 KVM 共同完成。为了加速内存映射，需要借助硬件的 EPT 技术。

在用户态 qemu 中，有一个结构 AddressSpace address_space_memory 来表示虚拟机的系统内存，这个内存可能包含多个内存区域 struct MemoryRegion，组成树形结构，指向由 mmap 分配的虚拟内存。

在 AddressSpace 结构中，有一个 struct KVMMemoryListener，当有新的内存区域添加的时候，会被通知调用 kvm_region_add 来通知内核。

在用户态 qemu 中，对于虚拟机有一个结构 struct KVMState 表示这个虚拟机，这个结构会指向一个数组的 struct KVMSlot 表示这个虚拟机的多个内存条，KVMSlot 中有一个 void *ram 指针指向 mmap 分配的那块虚拟内存。

kvm_region_add 是通过 ioctl 来通知内核 KVM 的，会给内核 KVM 发送一个 KVM_SET_USER_MEMORY_REGION 消息，表示用户态 qemu 添加了一个内存区域，内核 KVM 也应该添加一个相应的内存区域。

和用户态 qemu 对应的内核 KVM，对于虚拟机有一个结构 struct kvm 表示这个虚拟机，这个结构会指向一个数组的 struct kvm_memory_slot 表示这个虚拟机的多个内存条，kvm_memory_slot 中有起始页号，页面数目，表示这个虚拟机的物理内存空间。

虚拟机的物理内存空间里面的页面当然不是一开始就映射到物理页面的，只有当虚拟机的内存被访问的时候，也即 mmap 分配的虚拟内存空间被访问的时候，先查看 EPT 页表，是否已经映射过，如果已经映射过，则经过四级页表映射，就能访问到物理页面。

如果没有映射过，则虚拟机会通过 VM-Exit 指令回到宿主机模式，通过 handle_ept_violation 补充页表映射。先是通过 handle_mm_fault 为虚拟机的物理内存空间分配真正的物理页面，然后通过 __direct_map 添加 EPT 页表映射。

课堂练习

这一节，影子页表我们没有深入去讲，你能自己研究一下，它是如何实现的吗？

欢迎留言和我分享你的疑惑和见解，也欢迎收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

53 | 存储虚拟化（上）：如何建立自己保管的单独档案库？

前面几节，我们讲了 CPU 和内存的虚拟化。我们知道，完全虚拟化是很慢的，而通过内核的 KVM 技术和 EPT 技术，加速虚拟机对于物理 CPU 和内存的使用，我们称为硬件辅助虚拟化。

对于一台虚拟机而言，除了要虚拟化 CPU 和内存，存储和网络也需要虚拟化，存储和网络都属于外部设备，这些外部设备应该如何虚拟化呢？

当然一种方式还是完全虚拟化。比如，有什么样的硬盘设备或者网卡设备，我们就用 qemu 模拟一个一模一样的软件的硬盘和网卡设备，这样在虚拟机里面的操作系统看来，使用这些设备和使用物理设备是一样的。当然缺点就是，qemu 模拟的设备又是一个翻译官的角色。虽然这个时候虚拟机里面的操作系统，意识不到自己是运行在虚拟机里面的，但是这种每个指令都翻译的方式，实在是太慢了。

另外一种方式就是，虚拟机里面的操作系统不是一个通用的操作系统，它知道自己是运行在虚拟机里面的，使用的硬盘设备和网络设备都是虚拟的，应该加载特殊的驱动才能运行。这些特殊的驱动往往要通过虚拟机里面和外面配合工作的模式，来加速对于物理存储和网络设备的使用。

virtio 的基本原理

在虚拟化技术的早期，不同的虚拟化技术会针对不同硬盘设备和网络设备实现不同的驱动，虚拟机里面的操作系统也要根据不同的虚拟化技术和物理存储和网络设备，选择加载不同的驱动。但是，由于硬盘设备和网络设备太多了，驱动纷繁复杂。

后来慢慢就形成了一定的标准，这就是virtio，就是虚拟化 I/O 设备的意思。virtio 负责对于虚拟机提供统一的接口。也就是说，在虚拟机里面的操作系统加载的驱动，以后都统一加载 virtio 就可以了。

在虚拟机外，我们可以实现不同的 virtio 的后端，来适配不同的物理硬件设备。那 virtio 到底长什么样子呢？我们一起来看一看。

virtio 的架构可以分为四层。

首先，在虚拟机里面的 virtio 前端，针对不同类型的设备有不同的驱动程序，但是接口都是统一的。例如，硬盘就是 virtio_blk，网络就是 virtio_net。

其次，在宿主机的 qemu 里面，实现 virtio 后端的逻辑，主要就是操作硬件的设备。例如通过写一个物理机硬盘上的文件来完成虚拟机写入硬盘的操作。再如向内核协议栈发送一个网络包完成虚拟机对于网络的操作。

在 virtio 的前端和后端之间，有一个通信层，里面包含virtio 层和virtio-ring 层。virtio 这一层实现的是虚拟队列接口，算是前后端通信的桥梁。而 virtio-ring 则是该桥梁的具体实现。

virtio 使用 virtqueue 进行前端和后端的高速通信。不同类型的设备队列数目不同。virtio-net 使用两个队列，一个用于接受，另一个用于发送；而 virtio-blk 仅使用一个队列。

如果客户机要向宿主机发送数据，宿主机会将数据的 buffer 添加到 virtqueue 中，然后通过写入寄存器通知宿主机。这样宿主机就可以从 virtqueue 中收到的 buffer 里面的数据。

了解了 virtio 的基本原理，接下来，我们以硬盘写入为例，具体看一下存储虚拟化的过程。

初始化阶段的存储虚拟化

和咱们在学习 CPU 的时候看到的一样，Virtio Block Device 也是一种类。它的继承关系如下：

static const TypeInfo device_type_info = {

    .name = TYPE_DEVICE,

    .parent = TYPE_OBJECT,

    .instance_size = sizeof(DeviceState),

    .instance_init = device_initfn,

    .instance_post_init = device_post_init,

    .instance_finalize = device_finalize,

    .class_base_init = device_class_base_init,

    .class_init = device_class_init,

    .abstract = true,

    .class_size = sizeof(DeviceClass),

};

static const TypeInfo virtio_device_info = {

    .name = TYPE_VIRTIO_DEVICE,

    .parent = TYPE_DEVICE,

    .instance_size = sizeof(VirtIODevice),

    .class_init = virtio_device_class_init,

    .instance_finalize = virtio_device_instance_finalize,

    .abstract = true,

    .class_size = sizeof(VirtioDeviceClass),

};

static const TypeInfo virtio_blk_info = {

    .name = TYPE_VIRTIO_BLK,

    .parent = TYPE_VIRTIO_DEVICE,

    .instance_size = sizeof(VirtIOBlock),

    .instance_init = virtio_blk_instance_init,

    .class_init = virtio_blk_class_init,

};

static void virtio_register_types(void)

{

    type_register_static(&virtio_blk_info);

}

type_init(virtio_register_types)

Virtio Block Device 这种类的定义是有多层继承关系的。TYPE_VIRTIO_BLK 的父类是 TYPE_VIRTIO_DEVICE，TYPE_VIRTIO_DEVICE 的父类是 TYPE_DEVICE，TYPE_DEVICE 的父类是 TYPE_OBJECT。到头了。

type_init 用于注册这种类。这里面每一层都有 class_init，用于从 TypeImpl 生产 xxxClass。还有 instance_init，可以将 xxxClass 初始化为实例。

在 TYPE_VIRTIO_BLK 层的 class_init 函数 virtio_blk_class_init 中，定义了 DeviceClass 的 realize 函数为 virtio_blk_device_realize，这一点在CPU那一节也有类似的结构。

static void virtio_blk_device_realize(DeviceState *dev, Error **errp)

{

    VirtIODevice *vdev = VIRTIO_DEVICE(dev);

    VirtIOBlock *s = VIRTIO_BLK(dev);

    VirtIOBlkConf *conf = &s->conf;

......

    blkconf_blocksizes(&conf->conf);

    virtio_blk_set_config_size(s, s->host_features);

    virtio_init(vdev, "virtio-blk", VIRTIO_ID_BLOCK, s->config_size);

    s->blk = conf->conf.blk;

    s->rq = NULL;

    s->sector_mask = (s->conf.conf.logical_block_size / BDRV_SECTOR_SIZE) - 1;

    for (i = 0; i < conf->num_queues; i++) {

        virtio_add_queue(vdev, conf->queue_size, virtio_blk_handle_output);

    }

    virtio_blk_data_plane_create(vdev, conf, &s->dataplane, &err);

    s->change = qemu_add_vm_change_state_handler(virtio_blk_dma_restart_cb, s);

    blk_set_dev_ops(s->blk, &virtio_block_ops, s);

    blk_set_guest_block_size(s->blk, s->conf.conf.logical_block_size);

    blk_iostatus_enable(s->blk);

}

在 virtio_blk_device_realize 函数中，我们先是通过 virtio_init 初始化 VirtIODevice 结构。

void virtio_init(VirtIODevice *vdev, const char *name,

                 uint16_t device_id, size_t config_size)

{

    BusState *qbus = qdev_get_parent_bus(DEVICE(vdev));

    VirtioBusClass *k = VIRTIO_BUS_GET_CLASS(qbus);

    int i;

    int nvectors = k->query_nvectors ? k->query_nvectors(qbus->parent) : 0;

    if (nvectors) {

        vdev->vector_queues =

            g_malloc0(sizeof(*vdev->vector_queues) * nvectors);

    }

    vdev->device_id = device_id;

    vdev->status = 0;

    atomic_set(&vdev->isr, 0);

    vdev->queue_sel = 0;

    vdev->config_vector = VIRTIO_NO_VECTOR;

    vdev->vq = g_malloc0(sizeof(VirtQueue) * VIRTIO_QUEUE_MAX);

    vdev->vm_running = runstate_is_running();

    vdev->broken = false;

    for (i = 0; i < VIRTIO_QUEUE_MAX; i++) {

        vdev->vq[i].vector = VIRTIO_NO_VECTOR;

        vdev->vq[i].vdev = vdev;

        vdev->vq[i].queue_index = i;

    }

    vdev->name = name;

    vdev->config_len = config_size;

    if (vdev->config_len) {

        vdev->config = g_malloc0(config_size);

    } else {

        vdev->config = NULL;

    }

    vdev->vmstate = qemu_add_vm_change_state_handler(virtio_vmstate_change,

                                                     vdev);

    vdev->device_endian = virtio_default_endian();

    vdev->use_guest_notifier_mask = true;

}

从 virtio_init 中可以看出，VirtIODevice 结构里面有一个 VirtQueue 数组，这就是 virtio 前端和后端互相传数据的队列，最多 VIRTIO_QUEUE_MAX 个。

我们回到 virtio_blk_device_realize 函数。接下来，根据配置的队列数目 num_queues，对于每个队列都调用 virtio_add_queue 来初始化队列。

VirtQueue *virtio_add_queue(VirtIODevice *vdev, int queue_size,

                            VirtIOHandleOutput handle_output)

{

    int i;

    vdev->vq[i].vring.num = queue_size;

    vdev->vq[i].vring.num_default = queue_size;

    vdev->vq[i].vring.align = VIRTIO_PCI_VRING_ALIGN;

    vdev->vq[i].handle_output = handle_output;

    vdev->vq[i].handle_aio_output = NULL;

    return &vdev->vq[i];

}

在每个 VirtQueue 中，都有一个 vring，用来维护这个队列里面的数据；另外还有一个函数 virtio_blk_handle_output，用于处理数据写入，这个函数我们后面会用到。

至此，VirtIODevice，VirtQueue，vring 之间的关系如下图所示。这是在 qemu 里面的对应关系，请你记好，后面我们还能看到类似的结构。

qemu 启动过程中的存储虚拟化

初始化过程解析完毕以后，我们接下来从 qemu 的启动过程看起。

对于硬盘的虚拟化，qemu 的启动参数里面有关的是下面两行：

-drive file=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/disk,if=none,id=drive-virtio-disk0,format=qcow2,cache=none

-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1

其中，第一行指定了宿主机硬盘上的一个文件，文件的格式是 qcow2，这个格式我们这里不准备解析它，你只要明白，对于宿主机上的一个文件，可以被 qemu 模拟称为客户机上的一块硬盘就可以了。

而第二行说明了，使用的驱动是 virtio-blk 驱动。

configure_blockdev(&bdo_queue, machine_class, snapshot);

在 qemu 启动的 main 函数里面，初始化块设备，是通过 configure_blockdev 调用开始的。

static void configure_blockdev(BlockdevOptionsQueue *bdo_queue, MachineClass *machine_class, int snapshot)

{

......

    if (qemu_opts_foreach(qemu_find_opts("drive"), drive_init_func,

                          &machine_class->block_default_type, &error_fatal)) {

.....

    }

}

static int drive_init_func(void *opaque, QemuOpts *opts, Error **errp)

{

    BlockInterfaceType *block_default_type = opaque;

    return drive_new(opts, *block_default_type, errp) == NULL;

}

在 configure_blockdev 中，我们能看到对于 drive 这个参数的解析，并且初始化这个设备要调用 drive_init_func 函数，这里面会调用 drive_new 创建一个设备。

DriveInfo *drive_new(QemuOpts *all_opts, BlockInterfaceType block_default_type, Error **errp)

{

    const char *value;

    BlockBackend *blk;

    DriveInfo *dinfo = NULL;

    QDict *bs_opts;

    QemuOpts *legacy_opts;

    DriveMediaType media = MEDIA_DISK;

    BlockInterfaceType type;

    int max_devs, bus_id, unit_id, index;

    const char *werror, *rerror;

    bool read_only = false;

    bool copy_on_read;

    const char *filename;

    Error *local_err = NULL;

    int i;

......

    legacy_opts = qemu_opts_create(&qemu_legacy_drive_opts, NULL, 0,

                                   &error_abort);

......

    /* Add virtio block device */

    if (type == IF_VIRTIO) {

        QemuOpts *devopts;

        devopts = qemu_opts_create(qemu_find_opts("device"), NULL, 0,

                                   &error_abort);

        qemu_opt_set(devopts, "driver", "virtio-blk-pci", &error_abort);

        qemu_opt_set(devopts, "drive", qdict_get_str(bs_opts, "id"),

                     &error_abort);

    }

    filename = qemu_opt_get(legacy_opts, "file");

......

    /* Actual block device init: Functionality shared with blockdev-add */

    blk = blockdev_init(filename, bs_opts, &local_err);

......

    /* Create legacy DriveInfo */

    dinfo = g_malloc0(sizeof(*dinfo));

    dinfo->opts = all_opts;

    dinfo->type = type;

    dinfo->bus = bus_id;

    dinfo->unit = unit_id;

    blk_set_legacy_dinfo(blk, dinfo);

    switch(type) {

    case IF_IDE:

    case IF_SCSI:

    case IF_XEN:

    case IF_NONE:

        dinfo->media_cd = media == MEDIA_CDROM;

        break;

    default:

        break;

    }

......

}

在 drive_new 里面，会解析 qemu 的启动参数。对于 virtio 来讲，会解析 device 参数，把 driver 设置为 virtio-blk-pci；还会解析 file 参数，就是指向那个宿主机上的文件。

接下来，drive_new 会调用 blockdev_init，根据参数进行初始化，最后会创建一个 DriveInfo 来管理这个设备。

我们重点来看 blockdev_init。在这里面，我们发现，如果 file 不为空，则应该调用 blk_new_open 打开宿主机上的硬盘文件，返回的结果是 BlockBackend，对应我们上面讲原理的时候的 virtio 的后端。

BlockBackend *blk_new_open(const char *filename, const char *reference,

                           QDict *options, int flags, Error **errp)

{

    BlockBackend *blk;

    BlockDriverState *bs;

    uint64_t perm = 0;

......

    blk = blk_new(perm, BLK_PERM_ALL);

    bs = bdrv_open(filename, reference, options, flags, errp);

    blk->root = bdrv_root_attach_child(bs, "root", &child_root,

                                       perm, BLK_PERM_ALL, blk, errp);

    return blk;

}

接下来的调用链为：bdrv_open->bdrv_open_inherit->bdrv_open_common.

static int bdrv_open_common(BlockDriverState *bs, BlockBackend *file,

                            QDict *options, Error **errp)

{

    int ret, open_flags;

    const char *filename;

    const char *driver_name = NULL;

    const char *node_name = NULL;

    const char *discard;

    QemuOpts *opts;

    BlockDriver *drv;

    Error *local_err = NULL;

......

    drv = bdrv_find_format(driver_name);

......

    ret = bdrv_open_driver(bs, drv, node_name, options, open_flags, errp);

......

}

static int bdrv_open_driver(BlockDriverState *bs, BlockDriver *drv,

                            const char *node_name, QDict *options,

                            int open_flags, Error **errp)

{

......

    bs->drv = drv;

    bs->read_only = !(bs->open_flags & BDRV_O_RDWR);

    bs->opaque = g_malloc0(drv->instance_size);

    if (drv->bdrv_open) {

        ret = drv->bdrv_open(bs, options, open_flags, &local_err);

    }

......

}

在 bdrv_open_common 中，根据硬盘文件的格式，得到 BlockDriver。因为虚拟机的硬盘文件格式有很多种，qcow2 是一种，raw 是一种，vmdk 是一种，各有优缺点，启动虚拟机的时候，可以自由选择。

对于不同的格式，打开的方式不一样，我们拿 qcow2 来解析。它的 BlockDriver 定义如下：

BlockDriver bdrv_qcow2 = {

    .format_name        = "qcow2",

    .instance_size      = sizeof(BDRVQcow2State),

    .bdrv_probe         = qcow2_probe,

    .bdrv_open          = qcow2_open,

    .bdrv_close         = qcow2_close,

......

    .bdrv_snapshot_create   = qcow2_snapshot_create,

    .bdrv_snapshot_goto     = qcow2_snapshot_goto,

    .bdrv_snapshot_delete   = qcow2_snapshot_delete,

    .bdrv_snapshot_list     = qcow2_snapshot_list,

    .bdrv_snapshot_load_tmp = qcow2_snapshot_load_tmp,

    .bdrv_measure           = qcow2_measure,

    .bdrv_get_info          = qcow2_get_info,

    .bdrv_get_specific_info = qcow2_get_specific_info,

    .bdrv_save_vmstate    = qcow2_save_vmstate,

    .bdrv_load_vmstate    = qcow2_load_vmstate,

    .supports_backing           = true,

    .bdrv_change_backing_file   = qcow2_change_backing_file,

    .bdrv_refresh_limits        = qcow2_refresh_limits,

......

};

根据上面的定义，对于 qcow2 来讲，bdrv_open 调用的是 qcow2_open。

static int qcow2_open(BlockDriverState *bs, QDict *options, int flags,

                      Error **errp)

{

    BDRVQcow2State *s = bs->opaque;

    QCow2OpenCo qoc = {

        .bs = bs,

        .options = options,

        .flags = flags,

        .errp = errp,

        .ret = -EINPROGRESS

    };

    bs->file = bdrv_open_child(NULL, options, "file", bs, &child_file,

                               false, errp);

    qemu_coroutine_enter(qemu_coroutine_create(qcow2_open_entry, &qoc));

......

}

在 qcow2_open 中，我们会通过 qemu_coroutine_enter 进入一个协程 coroutine。什么叫协程呢？我们可以简单地将它理解为用户态自己实现的线程。

前面咱们讲线程的时候说过，如果一个程序想实现并发，可以创建多个线程，但是线程是一个内核的概念，创建的每一个线程内核都能看到，内核的调度也是以线程为单位的。这对于普通的进程没有什么问题，但是对于 qemu 这种虚拟机，如果在用户态和内核态切换来切换去，由于还涉及虚拟机的状态，代价比较大。

但是，qemu 的设备也是需要多线程能力的，怎么办呢？我们就在用户态实现一个类似线程的东西，也就是协程，用于实现并发，并且不被内核看到，调度全部在用户态完成。

从后面的读写过程可以看出，协程在后端经常使用。这里打开一个 qcow2 文件就是使用一个协程，创建一个协程和创建一个线程很像，也需要指定一个函数来执行，qcow2_open_entry 就是协程的函数。

static void coroutine_fn qcow2_open_entry(void *opaque)

{

    QCow2OpenCo *qoc = opaque;

    BDRVQcow2State *s = qoc->bs->opaque;

    qemu_co_mutex_lock(&s->lock);

    qoc->ret = qcow2_do_open(qoc->bs, qoc->options, qoc->flags, qoc->errp);

    qemu_co_mutex_unlock(&s->lock);

}

我们可以看到，qcow2_open_entry 函数前面有一个 coroutine_fn，说明它是一个协程函数。在 qcow2_do_open 中，qcow2_do_open 根据 qcow2 的格式打开硬盘文件。这个格式官网就有，我们这里就不花篇幅解析了。

总结时刻

我们这里来总结一下，存储虚拟化的过程分为前端、后端和中间的队列。

前端有前端的块设备驱动 Front-end driver，在客户机的内核里面，它符合普通设备驱动的格式，对外通过 VFS 暴露文件系统接口给客户机里面的应用。这一部分这一节我们没有讲，放在下一节解析。

后端有后端的设备驱动 Back-end driver，在宿主机的 qemu 进程中，当收到客户机的写入请求的时候，调用文件系统的 write 函数，写入宿主机的 VFS 文件系统，最终写到物理硬盘设备上的 qcow2 文件。

中间的队列用于前端和后端之间传输数据，在前端的设备驱动和后端的设备驱动，都有类似的数据结构 virt-queue 来管理这些队列，这一部分这一节我们也没有讲，也放到下一节解析。

课堂练习

对于 qemu-kvm 来讲，qcow2 是一种常见的文件格式。它有精妙的格式设计，从而适应虚拟化的场景，请你研究一下这个文件格式。

欢迎留言和我分享你的疑惑和见解，也欢迎收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

54 | 存储虚拟化（下）：如何建立自己保管的单独档案库？

上一节，我们讲了 qemu 启动过程中的存储虚拟化。好了，现在 qemu 启动了，硬盘设备文件已经打开了。那如果我们要往虚拟机的一个进程写入一个文件，该怎么做呢？最终这个文件又是如何落到宿主机上的硬盘文件的呢？这一节，我们一起来看一看。

前端设备驱动 virtio_blk

虚拟机里面的进程写入一个文件，当然要通过文件系统。整个过程和咱们在文件系统那一节讲的过程没有区别。只是到了设备驱动层，我们看到的就不是普通的硬盘驱动了，而是 virtio 的驱动。

virtio 的驱动程序代码在 Linux 操作系统的源代码里面，文件名叫 drivers/block/virtio_blk.c。

static int __init init(void)

{

	int error;

	virtblk_wq = alloc_workqueue("virtio-blk", 0, 0);

	major = register_blkdev(0, "virtblk");

	error = register_virtio_driver(&virtio_blk);

......

}

module_init(init);

module_exit(fini);

MODULE_DEVICE_TABLE(virtio, id_table);

MODULE_DESCRIPTION("Virtio block driver");

MODULE_LICENSE("GPL");

static struct virtio_driver virtio_blk = {

......

	.driver.name			= KBUILD_MODNAME,

	.driver.owner			= THIS_MODULE,

	.id_table			= id_table,

	.probe				= virtblk_probe,

	.remove				= virtblk_remove,

......

};

前面我们介绍过设备驱动程序，从这里的代码中，我们能看到非常熟悉的结构。它会创建一个 workqueue，注册一个块设备，并获得一个主设备号，然后注册一个驱动函数 virtio_blk。

当一个设备驱动作为一个内核模块被初始化的时候，probe 函数会被调用，因而我们来看一下 virtblk_probe。

static int virtblk_probe(struct virtio_device *vdev)

{

	struct virtio_blk *vblk;

	struct request_queue *q;

......

	vdev->priv = vblk = kmalloc(sizeof(*vblk), GFP_KERNEL);

	vblk->vdev = vdev;

	vblk->sg_elems = sg_elems;

	INIT_WORK(&vblk->config_work, virtblk_config_changed_work);

......

	err = init_vq(vblk);

......

	vblk->disk = alloc_disk(1 << PART_BITS);

	memset(&vblk->tag_set, 0, sizeof(vblk->tag_set));

	vblk->tag_set.ops = &virtio_mq_ops;

	vblk->tag_set.queue_depth = virtblk_queue_depth;

	vblk->tag_set.numa_node = NUMA_NO_NODE;

	vblk->tag_set.flags = BLK_MQ_F_SHOULD_MERGE;

	vblk->tag_set.cmd_size =

		sizeof(struct virtblk_req) +

		sizeof(struct scatterlist) * sg_elems;

	vblk->tag_set.driver_data = vblk;

	vblk->tag_set.nr_hw_queues = vblk->num_vqs;

	err = blk_mq_alloc_tag_set(&vblk->tag_set);

......

	q = blk_mq_init_queue(&vblk->tag_set);

	vblk->disk->queue = q;

	q->queuedata = vblk;

	virtblk_name_format("vd", index, vblk->disk->disk_name, DISK_NAME_LEN);

	vblk->disk->major = major;

	vblk->disk->first_minor = index_to_minor(index);

	vblk->disk->private_data = vblk;

	vblk->disk->fops = &virtblk_fops;

	vblk->disk->flags |= GENHD_FL_EXT_DEVT;

	vblk->index = index;

......

	device_add_disk(&vdev->dev, vblk->disk);

	err = device_create_file(disk_to_dev(vblk->disk), &dev_attr_serial);

......

}

在 virtblk_probe 中，我们首先看到的是 struct request_queue，这是每一个块设备都有的一个队列。还记得吗？它有两个函数，一个是 make_request_fn 函数，用于生成 request；另一个是 request_fn 函数，用于处理 request。

这个 request_queue 的初始化过程在 blk_mq_init_queue 中。它会调用 blk_mq_init_allocated_queue->blk_queue_make_request。在这里面，我们可以将 make_request_fn 函数设置为 blk_mq_make_request，也就是说，一旦上层有写入请求，我们就通过 blk_mq_make_request 这个函数，将请求放入 request_queue 队列中。

另外，在 virtblk_probe 中，我们会初始化一个 gendisk。前面我们也讲了，每一个块设备都有这样一个结构。

在 virtblk_probe 中，还有一件重要的事情就是，init_vq 会来初始化 virtqueue。

static int init_vq(struct virtio_blk *vblk)

{

	int err;

	int i;

	vq_callback_t **callbacks;

	const char **names;

	struct virtqueue **vqs;

	unsigned short num_vqs;

	struct virtio_device *vdev = vblk->vdev;

......

	vblk->vqs = kmalloc_array(num_vqs, sizeof(*vblk->vqs), GFP_KERNEL);

	names = kmalloc_array(num_vqs, sizeof(*names), GFP_KERNEL);

	callbacks = kmalloc_array(num_vqs, sizeof(*callbacks), GFP_KERNEL);

	vqs = kmalloc_array(num_vqs, sizeof(*vqs), GFP_KERNEL);

......

	for (i = 0; i < num_vqs; i++) {

		callbacks[i] = virtblk_done;

		names[i] = vblk->vqs[i].name;

	}

	/* Discover virtqueues and write information to configuration.  */

	err = virtio_find_vqs(vdev, num_vqs, vqs, callbacks, names, &desc);

	for (i = 0; i < num_vqs; i++) {

		vblk->vqs[i].vq = vqs[i];

	}

	vblk->num_vqs = num_vqs;

......

}

按照上面的原理来说，virtqueue 是一个介于客户机前端和 qemu 后端的一个结构，用于在这两端之间传递数据。这里建立的 struct virtqueue 是客户机前端对于队列的管理的数据结构，在客户机的 linux 内核中通过 kmalloc_array 进行分配。

而队列的实体需要通过函数 virtio_find_vqs 查找或者生成，所以这里我们还把 callback 函数指定为 virtblk_done。当 buffer 使用发生变化的时候，我们需要调用这个 callback 函数进行通知。

static inline

int virtio_find_vqs(struct virtio_device *vdev, unsigned nvqs,

			struct virtqueue *vqs[], vq_callback_t *callbacks[],

			const char * const names[],

			struct irq_affinity *desc)

{

	return vdev->config->find_vqs(vdev, nvqs, vqs, callbacks, names, NULL, desc);

}

static const struct virtio_config_ops virtio_pci_config_ops = {

	.get		= vp_get,

	.set		= vp_set,

	.generation	= vp_generation,

	.get_status	= vp_get_status,

	.set_status	= vp_set_status,

	.reset		= vp_reset,

	.find_vqs	= vp_modern_find_vqs,

	.del_vqs	= vp_del_vqs,

	.get_features	= vp_get_features,

	.finalize_features = vp_finalize_features,

	.bus_name	= vp_bus_name,

	.set_vq_affinity = vp_set_vq_affinity,

	.get_vq_affinity = vp_get_vq_affinity,

};

根据 virtio_config_ops 的定义，virtio_find_vqs 会调用 vp_modern_find_vqs。

static int vp_modern_find_vqs(struct virtio_device *vdev, unsigned nvqs,

			      struct virtqueue *vqs[],

			      vq_callback_t *callbacks[],

			      const char * const names[], const bool *ctx,

			      struct irq_affinity *desc)

{

	struct virtio_pci_device *vp_dev = to_vp_device(vdev);

	struct virtqueue *vq;

	int rc = vp_find_vqs(vdev, nvqs, vqs, callbacks, names, ctx, desc);

	/* Select and activate all queues. Has to be done last: once we do

	 * this, there's no way to go back except reset.

	 */

	list_for_each_entry(vq, &vdev->vqs, list) {

		vp_iowrite16(vq->index, &vp_dev->common->queue_select);

		vp_iowrite16(1, &vp_dev->common->queue_enable);

	}

	return 0;

}

在 vp_modern_find_vqs 中，vp_find_vqs 会调用 vp_find_vqs_intx。

static int vp_find_vqs_intx(struct virtio_device *vdev, unsigned nvqs,

		struct virtqueue *vqs[], vq_callback_t *callbacks[],

		const char * const names[], const bool *ctx)

{

	struct virtio_pci_device *vp_dev = to_vp_device(vdev);

	int i, err;

	vp_dev->vqs = kcalloc(nvqs, sizeof(*vp_dev->vqs), GFP_KERNEL);

	err = request_irq(vp_dev->pci_dev->irq, vp_interrupt, IRQF_SHARED,

			dev_name(&vdev->dev), vp_dev);

	vp_dev->intx_enabled = 1;

	vp_dev->per_vq_vectors = false;

	for (i = 0; i < nvqs; ++i) {

		vqs[i] = vp_setup_vq(vdev, i, callbacks[i], names[i],

				     ctx ? ctx[i] : false,

				     VIRTIO_MSI_NO_VECTOR);

......

	}

}

在 vp_find_vqs_intx 中，我们通过 request_irq 注册一个中断处理函数 vp_interrupt，当设备的配置信息发生改变，会产生一个中断，当设备向队列中写入信息时，也会会产生一个中断，我们称为 vq 中断，中断处理函数需要调用相应的队列的回调函数。

然后，我们根据队列的数目，依次调用 vp_setup_vq，完成 virtqueue、vring 的分配和初始化。

static struct virtqueue *vp_setup_vq(struct virtio_device *vdev, unsigned index,

				     void (*callback)(struct virtqueue *vq),

				     const char *name,

				     bool ctx,

				     u16 msix_vec)

{

	struct virtio_pci_device *vp_dev = to_vp_device(vdev);

	struct virtio_pci_vq_info *info = kmalloc(sizeof *info, GFP_KERNEL);

	struct virtqueue *vq;

	unsigned long flags;

......

	vq = vp_dev->setup_vq(vp_dev, info, index, callback, name, ctx,

			      msix_vec);

	info->vq = vq;

	if (callback) {

		spin_lock_irqsave(&vp_dev->lock, flags);

		list_add(&info->node, &vp_dev->virtqueues);

		spin_unlock_irqrestore(&vp_dev->lock, flags);

	} else {

		INIT_LIST_HEAD(&info->node);

	}

	vp_dev->vqs[index] = info;

	return vq;

}

static struct virtqueue *setup_vq(struct virtio_pci_device *vp_dev,

				  struct virtio_pci_vq_info *info,

				  unsigned index,

				  void (*callback)(struct virtqueue *vq),

				  const char *name,

				  bool ctx,

				  u16 msix_vec)

{

	struct virtio_pci_common_cfg __iomem *cfg = vp_dev->common;

	struct virtqueue *vq;

	u16 num, off;

	int err;

	/* Select the queue we're interested in */

	vp_iowrite16(index, &cfg->queue_select);

	/* Check if queue is either not available or already active. */

	num = vp_ioread16(&cfg->queue_size);

	/* get offset of notification word for this vq */

	off = vp_ioread16(&cfg->queue_notify_off);

	info->msix_vector = msix_vec;

	/* create the vring */

	vq = vring_create_virtqueue(index, num,

				    SMP_CACHE_BYTES, &vp_dev->vdev,

				    true, true, ctx,

				    vp_notify, callback, name);

	/* activate the queue */

	vp_iowrite16(virtqueue_get_vring_size(vq), &cfg->queue_size);

	vp_iowrite64_twopart(virtqueue_get_desc_addr(vq),

			     &cfg->queue_desc_lo, &cfg->queue_desc_hi);

	vp_iowrite64_twopart(virtqueue_get_avail_addr(vq),

			     &cfg->queue_avail_lo, &cfg->queue_avail_hi);

	vp_iowrite64_twopart(virtqueue_get_used_addr(vq),

			     &cfg->queue_used_lo, &cfg->queue_used_hi);

......

	return vq;

}

struct virtqueue *vring_create_virtqueue(

	unsigned int index,

	unsigned int num,

	unsigned int vring_align,

	struct virtio_device *vdev,

	bool weak_barriers,

	bool may_reduce_num,

	bool context,

	bool (*notify)(struct virtqueue *),

	void (*callback)(struct virtqueue *),

	const char *name)

{

	struct virtqueue *vq;

	void *queue = NULL;

	dma_addr_t dma_addr;

	size_t queue_size_in_bytes;

	struct vring vring;

	/* TODO: allocate each queue chunk individually */

	for (; num && vring_size(num, vring_align) > PAGE_SIZE; num /= 2) {

		queue = vring_alloc_queue(vdev, vring_size(num, vring_align),

					  &dma_addr,

					  GFP_KERNEL|__GFP_NOWARN|__GFP_ZERO);

		if (queue)

			break;

	}

	if (!queue) {

		/* Try to get a single page. You are my only hope! */

		queue = vring_alloc_queue(vdev, vring_size(num, vring_align),

					  &dma_addr, GFP_KERNEL|__GFP_ZERO);

	}

	queue_size_in_bytes = vring_size(num, vring_align);

	vring_init(&vring, num, queue, vring_align);

	vq = __vring_new_virtqueue(index, vring, vdev, weak_barriers, context, notify, callback, name);

	to_vvq(vq)->queue_dma_addr = dma_addr;

	to_vvq(vq)->queue_size_in_bytes = queue_size_in_bytes;

	to_vvq(vq)->we_own_ring = true;

	return vq;

}

在 vring_create_virtqueue 中，我们会调用 vring_alloc_queue，来创建队列所需要的内存空间，然后调用 vring_init 初始化结构 struct vring，来管理队列的内存空间，调用 __vring_new_virtqueue，来创建 struct vring_virtqueue。

这个结构的一开始，是 struct virtqueue，它也是 struct virtqueue 的一个扩展，紧接着后面就是 struct vring。

struct vring_virtqueue {

	struct virtqueue vq;

	/* Actual memory layout for this queue */

	struct vring vring;

......

}

至此我们发现，虚拟机里面的 virtio 的前端是这样的结构：struct virtio_device 里面有一个 struct vring_virtqueue，在 struct vring_virtqueue 里面有一个 struct vring。

中间 virtio 队列的管理

还记不记得我们上面讲 qemu 初始化的时候，virtio 的后端有数据结构 VirtIODevice，VirtQueue 和 vring 一模一样，前端和后端对应起来，都应该指向刚才创建的那一段内存。

现在的问题是，我们刚才分配的内存在客户机的内核里面，如何告知 qemu 来访问这段内存呢？

别忘了，qemu 模拟出来的 virtio block device 只是一个 PCI 设备。对于客户机来讲，这是一个外部设备，我们可以通过给外部设备发送指令的方式告知外部设备，这就是代码中 vp_iowrite16 的作用。它会调用专门给外部设备发送指令的函数 iowrite，告诉外部的 PCI 设备。

告知的有三个地址 virtqueue_get_desc_addr、virtqueue_get_avail_addr，virtqueue_get_used_addr。从客户机角度来看，这里面的地址都是物理地址，也即 GPA（Guest Physical Address）。因为只有物理地址才是客户机和 qemu 程序都认可的地址，本来客户机的物理内存也是 qemu 模拟出来的。

在 qemu 中，对 PCI 总线添加一个设备的时候，我们会调用 virtio_pci_device_plugged。

static void virtio_pci_device_plugged(DeviceState *d, Error **errp)

{

    VirtIOPCIProxy *proxy = VIRTIO_PCI(d);

......

    memory_region_init_io(&proxy->bar, OBJECT(proxy),

                              &virtio_pci_config_ops,

                              proxy, "virtio-pci", size);

......

}

static const MemoryRegionOps virtio_pci_config_ops = {

    .read = virtio_pci_config_read,

    .write = virtio_pci_config_write,

    .impl = {

        .min_access_size = 1,

        .max_access_size = 4,

    },

    .endianness = DEVICE_LITTLE_ENDIAN,

};

在这里面，对于这个加载的设备进行 I/O 操作，会映射到读写某一块内存空间，对应的操作为 virtio_pci_config_ops，也即写入这块内存空间，这就相当于对于这个 PCI 设备进行某种配置。

对 PCI 设备进行配置的时候，会有这样的调用链：virtio_pci_config_write->virtio_ioport_write->virtio_queue_set_addr。设置 virtio 的 queue 的地址是一项很重要的操作。

void virtio_queue_set_addr(VirtIODevice *vdev, int n, hwaddr addr)

{

    vdev->vq[n].vring.desc = addr;

    virtio_queue_update_rings(vdev, n);

}

从这里我们可以看出，qemu 后端的 VirtIODevice 的 VirtQueue 的 vring 的地址，被设置成了刚才给队列分配的内存的 GPA。

接着，我们来看一下这个队列的格式。

/* Virtio ring descriptors: 16 bytes.  These can chain together via "next". */

struct vring_desc {

	/* Address (guest-physical). */

	__virtio64 addr;

	/* Length. */

	__virtio32 len;

	/* The flags as indicated above. */

	__virtio16 flags;

	/* We chain unused descriptors via this, too */

	__virtio16 next;

};

struct vring_avail {

	__virtio16 flags;

	__virtio16 idx;

	__virtio16 ring[];

};

/* u32 is used here for ids for padding reasons. */

struct vring_used_elem {

	/* Index of start of used descriptor chain. */

	__virtio32 id;

	/* Total length of the descriptor chain which was used (written to) */

	__virtio32 len;

};

struct vring_used {

	__virtio16 flags;

	__virtio16 idx;

	struct vring_used_elem ring[];

};

struct vring {

	unsigned int num;

	struct vring_desc *desc;

	struct vring_avail *avail;

	struct vring_used *used;

};

vring 包含三个成员：

vring_desc 指向分配的内存块，用于存放客户机和 qemu 之间传输的数据。

avail->ring[] 是发送端维护的环形队列，指向需要接收端处理的 vring_desc。

used->ring[] 是接收端维护的环形队列，指向自己已经处理过了的 vring_desc。

数据写入的流程

接下来，我们来看，真的写入一个数据的时候，会发生什么。

按照上面 virtio 驱动初始化的时候的逻辑，blk_mq_make_request 会被调用。这个函数比较复杂，会分成多个分支，但是最终都会调用到 request_queue 的 virtio_mq_ops 的 queue_rq 函数。

struct request_queue *q = rq->q;

q->mq_ops->queue_rq(hctx, &bd);

static const struct blk_mq_ops virtio_mq_ops = {

	.queue_rq	= virtio_queue_rq,

	.complete	= virtblk_request_done,

	.init_request	= virtblk_init_request,

	.map_queues	= virtblk_map_queues,

};

根据 virtio_mq_ops 的定义，我们现在要调用 virtio_queue_rq。

static blk_status_t virtio_queue_rq(struct blk_mq_hw_ctx *hctx,

			   const struct blk_mq_queue_data *bd)

{

	struct virtio_blk *vblk = hctx->queue->queuedata;

	struct request *req = bd->rq;

	struct virtblk_req *vbr = blk_mq_rq_to_pdu(req);

......

	err = virtblk_add_req(vblk->vqs[qid].vq, vbr, vbr->sg, num);

......

	if (notify)

		virtqueue_notify(vblk->vqs[qid].vq);

	return BLK_STS_OK;

}

在 virtio_queue_rq 中，我们会将请求写入的数据，通过 virtblk_add_req 放入 struct virtqueue。

因此，接下来的调用链为：virtblk_add_req->virtqueue_add_sgs->virtqueue_add。

static inline int virtqueue_add(struct virtqueue *_vq,

				struct scatterlist *sgs[],

				unsigned int total_sg,

				unsigned int out_sgs,

				unsigned int in_sgs,

				void *data,

				void *ctx,

				gfp_t gfp)

{

	struct vring_virtqueue *vq = to_vvq(_vq);

	struct scatterlist *sg;

	struct vring_desc *desc;

	unsigned int i, n, avail, descs_used, uninitialized_var(prev), err_idx;

	int head;

	bool indirect;

......

	head = vq->free_head;

	indirect = false;

	desc = vq->vring.desc;

	i = head;

	descs_used = total_sg;

	for (n = 0; n < out_sgs; n++) {

		for (sg = sgs[n]; sg; sg = sg_next(sg)) {

			dma_addr_t addr = vring_map_one_sg(vq, sg, DMA_TO_DEVICE);

......

			desc[i].flags = cpu_to_virtio16(_vq->vdev, VRING_DESC_F_NEXT);

			desc[i].addr = cpu_to_virtio64(_vq->vdev, addr);

			desc[i].len = cpu_to_virtio32(_vq->vdev, sg->length);

			prev = i;

			i = virtio16_to_cpu(_vq->vdev, desc[i].next);

		}

	}

	/* Last one doesn't continue. */

	desc[prev].flags &= cpu_to_virtio16(_vq->vdev, ~VRING_DESC_F_NEXT);

	/* We're using some buffers from the free list. */

	vq->vq.num_free -= descs_used;

	/* Update free pointer */

	vq->free_head = i;

	/* Store token and indirect buffer state. */

	vq->desc_state[head].data = data;

	/* Put entry in available array (but don't update avail->idx until they do sync). */

	avail = vq->avail_idx_shadow & (vq->vring.num - 1);

	vq->vring.avail->ring[avail] = cpu_to_virtio16(_vq->vdev, head);

	/* Descriptors and available array need to be set before we expose the new available array entries. */

	virtio_wmb(vq->weak_barriers);

	vq->avail_idx_shadow++;

	vq->vring.avail->idx = cpu_to_virtio16(_vq->vdev, vq->avail_idx_shadow);

	vq->num_added++;

......

	return 0;

}

在 virtqueue_add 函数中，我们能看到，free_head 指向的整个内存块空闲链表的起始位置，用 head 变量记住这个起始位置。

接下来，i 也指向这个起始位置，然后是一个 for 循环，将数据放到内存块里面，放的过程中，next 不断指向下一个空闲位置，这样空闲的内存块被不断的占用。等所有的写入都结束了，i 就会指向这次存放的内存块的下一个空闲位置，然后 free_head 就指向 i，因为前面的都填满了。

至此，从 head 到 i 之间的内存块，就是这次写入的全部数据。

于是，在 vring 的 avail 变量中，在 ring[] 数组中分配新的一项，在 avail 的位置，avail 的计算是 avail_idx_shadow & (vq->vring.num - 1)，其中，avail_idx_shadow 是上一次的 avail 的位置。这里如果超过了 ring[] 数组的下标，则重新跳到起始位置，就说明是一个环。这次分配的新的 avail 的位置就存放新写入的从 head 到 i 之间的内存块。然后是 avail_idx_shadow++，这说明这一块内存可以被接收方读取了。

接下来，我们回到 virtio_queue_rq，调用 virtqueue_notify 通知接收方。而 virtqueue_notify 会调用 vp_notify。

bool vp_notify(struct virtqueue *vq)

{

	/* we write the queue's selector into the notification register to

	 * signal the other end */

	iowrite16(vq->index, (void __iomem *)vq->priv);

	return true;

}

然后，我们写入一个 I/O 会触发 VM exit。我们在解析 CPU 的时候看到过这个逻辑。

int kvm_cpu_exec(CPUState *cpu)

{

    struct kvm_run *run = cpu->kvm_run;

    int ret, run_ret;

......

    run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);

......

    switch (run->exit_reason) {

        case KVM_EXIT_IO:

            DPRINTF("handle_io\n");

            /* Called outside BQL */

            kvm_handle_io(run->io.port, attrs,

                          (uint8_t *)run + run->io.data_offset,

                          run->io.direction,

                          run->io.size,

                          run->io.count);

            ret = 0;

            break;

    }

......

}

这次写入的也是一个 I/O 的内存空间，同样会触发 virtio_ioport_write，这次会调用 virtio_queue_notify。

void virtio_queue_notify(VirtIODevice *vdev, int n)

{

    VirtQueue *vq = &vdev->vq[n];

......

    if (vq->handle_aio_output) {

        event_notifier_set(&vq->host_notifier);

    } else if (vq->handle_output) {

        vq->handle_output(vdev, vq);

    }

}

virtio_queue_notify 会调用 VirtQueue 的 handle_output 函数，前面我们已经设置过这个函数了，是 virtio_blk_handle_output。

接下来的调用链为：virtio_blk_handle_output->virtio_blk_handle_output_do->virtio_blk_handle_vq。

bool virtio_blk_handle_vq(VirtIOBlock *s, VirtQueue *vq)

{

    VirtIOBlockReq *req;

    MultiReqBuffer mrb = {};

    bool progress = false;

......

    do {

        virtio_queue_set_notification(vq, 0);

        while ((req = virtio_blk_get_request(s, vq))) {

            progress = true;

            if (virtio_blk_handle_request(req, &mrb)) {

                virtqueue_detach_element(req->vq, &req->elem, 0);

                virtio_blk_free_request(req);

                break;

            }

        }

        virtio_queue_set_notification(vq, 1);

    } while (!virtio_queue_empty(vq));

    if (mrb.num_reqs) {

        virtio_blk_submit_multireq(s->blk, &mrb);

    }

......

    return progress;

}

在 virtio_blk_handle_vq 中，有一个 while 循环，在循环中调用函数 virtio_blk_get_request 从 vq 中取出请求，然后调用 virtio_blk_handle_request 处理从 vq 中取出的请求。

我们先来看 virtio_blk_get_request。

static VirtIOBlockReq *virtio_blk_get_request(VirtIOBlock *s, VirtQueue *vq)

{

    VirtIOBlockReq *req = virtqueue_pop(vq, sizeof(VirtIOBlockReq));

    if (req) {

        virtio_blk_init_request(s, vq, req);

    }

    return req;

}

void *virtqueue_pop(VirtQueue *vq, size_t sz)

{

    unsigned int i, head, max;

    VRingMemoryRegionCaches *caches;

    MemoryRegionCache *desc_cache;

    int64_t len;

    VirtIODevice *vdev = vq->vdev;

    VirtQueueElement *elem = NULL;

    unsigned out_num, in_num, elem_entries;

    hwaddr addr[VIRTQUEUE_MAX_SIZE];

    struct iovec iov[VIRTQUEUE_MAX_SIZE];

    VRingDesc desc;

    int rc;

......

    /* When we start there are none of either input nor output. */

    out_num = in_num = elem_entries = 0;

    max = vq->vring.num;

    i = head;

    caches = vring_get_region_caches(vq);

    desc_cache = &caches->desc;

    vring_desc_read(vdev, &desc, desc_cache, i);

......

    /* Collect all the descriptors */

    do {

        bool map_ok;

        if (desc.flags & VRING_DESC_F_WRITE) {

            map_ok = virtqueue_map_desc(vdev, &in_num, addr + out_num,

                                        iov + out_num,

                                        VIRTQUEUE_MAX_SIZE - out_num, true,

                                        desc.addr, desc.len);

        } else {

            map_ok = virtqueue_map_desc(vdev, &out_num, addr, iov,

                                        VIRTQUEUE_MAX_SIZE, false,

                                        desc.addr, desc.len);

        }

......

        rc = virtqueue_read_next_desc(vdev, &desc, desc_cache, max, &i);

    } while (rc == VIRTQUEUE_READ_DESC_MORE);

......

    /* Now copy what we have collected and mapped */

    elem = virtqueue_alloc_element(sz, out_num, in_num);

    elem->index = head;

    for (i = 0; i < out_num; i++) {

        elem->out_addr[i] = addr[i];

        elem->out_sg[i] = iov[i];

    }

    for (i = 0; i < in_num; i++) {

        elem->in_addr[i] = addr[out_num + i];

        elem->in_sg[i] = iov[out_num + i];

    }

    vq->inuse++;

......

    return elem;

}

我们可以看到，virtio_blk_get_request 会调用 virtqueue_pop。在这里面，我们能看到对于 vring 的操作，也即从这里面将客户机里面写入的数据读取出来，放到 VirtIOBlockReq 结构中。

接下来，我们就要调用 virtio_blk_handle_request 处理这些数据。所以接下来的调用链为：virtio_blk_handle_request->virtio_blk_submit_multireq->submit_requests。

static inline void submit_requests(BlockBackend *blk, MultiReqBuffer *mrb,int start, int num_reqs, int niov)

{

    QEMUIOVector *qiov = &mrb->reqs[start]->qiov;

    int64_t sector_num = mrb->reqs[start]->sector_num;

    bool is_write = mrb->is_write;

    if (num_reqs > 1) {

        int i;

        struct iovec *tmp_iov = qiov->iov;

        int tmp_niov = qiov->niov;

        qemu_iovec_init(qiov, niov);

        for (i = 0; i < tmp_niov; i++) {

            qemu_iovec_add(qiov, tmp_iov[i].iov_base, tmp_iov[i].iov_len);

        }

        for (i = start + 1; i < start + num_reqs; i++) {

            qemu_iovec_concat(qiov, &mrb->reqs[i]->qiov, 0,

                              mrb->reqs[i]->qiov.size);

            mrb->reqs[i - 1]->mr_next = mrb->reqs[i];

        }

        block_acct_merge_done(blk_get_stats(blk),

                              is_write ? BLOCK_ACCT_WRITE : BLOCK_ACCT_READ,

                              num_reqs - 1);

    }

    if (is_write) {

        blk_aio_pwritev(blk, sector_num << BDRV_SECTOR_BITS, qiov, 0,

                        virtio_blk_rw_complete, mrb->reqs[start]);

    } else {

        blk_aio_preadv(blk, sector_num << BDRV_SECTOR_BITS, qiov, 0,

                       virtio_blk_rw_complete, mrb->reqs[start]);

    }

}

在 submit_requests 中，我们看到了 BlockBackend。这是在 qemu 启动的时候，打开 qcow2 文件的时候生成的，现在我们可以用它来写入文件了，调用的是 blk_aio_pwritev。

BlockAIOCB *blk_aio_pwritev(BlockBackend *blk, int64_t offset,

                            QEMUIOVector *qiov, BdrvRequestFlags flags,

                            BlockCompletionFunc *cb, void *opaque)

{

    return blk_aio_prwv(blk, offset, qiov->size, qiov,

                        blk_aio_write_entry, flags, cb, opaque);

}

static BlockAIOCB *blk_aio_prwv(BlockBackend *blk, int64_t offset, int bytes,

                                void *iobuf, CoroutineEntry co_entry,

                                BdrvRequestFlags flags,

                                BlockCompletionFunc *cb, void *opaque)

{

    BlkAioEmAIOCB *acb;

    Coroutine *co;

    acb = blk_aio_get(&blk_aio_em_aiocb_info, blk, cb, opaque);

    acb->rwco = (BlkRwCo) {

        .blk    = blk,

        .offset = offset,

        .iobuf  = iobuf,

        .flags  = flags,

        .ret    = NOT_DONE,

    };

    acb->bytes = bytes;

    acb->has_returned = false;

    co = qemu_coroutine_create(co_entry, acb);

    bdrv_coroutine_enter(blk_bs(blk), co);

    acb->has_returned = true;

    return &acb->common;

}

在 blk_aio_pwritev 中，我们看到，又是创建了一个协程来进行写入。写入完毕之后调用 virtio_blk_rw_complete->virtio_blk_req_complete。

static void virtio_blk_req_complete(VirtIOBlockReq *req, unsigned char status)

{

    VirtIOBlock *s = req->dev;

    VirtIODevice *vdev = VIRTIO_DEVICE(s);

    trace_virtio_blk_req_complete(vdev, req, status);

    stb_p(&req->in->status, status);

    virtqueue_push(req->vq, &req->elem, req->in_len);

    virtio_notify(vdev, req->vq);

}

在 virtio_blk_req_complete 中，我们先是调用 virtqueue_push，更新 vring 中 used 变量，表示这部分已经写入完毕，空间可以回收利用了。但是，这部分的改变仅仅改变了 qemu 后端的 vring，我们还需要通知客户机中 virtio 前端的 vring 的值，因而要调用 virtio_notify。virtio_notify 会调用 virtio_irq 发送一个中断。

还记得咱们前面注册过一个中断处理函数 vp_interrupt 吗？它就是干这个事情的。

static irqreturn_t vp_interrupt(int irq, void *opaque)

{

	struct virtio_pci_device *vp_dev = opaque;

	u8 isr;

	/* reading the ISR has the effect of also clearing it so it's very

	 * important to save off the value. */

	isr = ioread8(vp_dev->isr);

	/* Configuration change?  Tell driver if it wants to know. */

	if (isr & VIRTIO_PCI_ISR_CONFIG)

		vp_config_changed(irq, opaque);

	return vp_vring_interrupt(irq, opaque);

}

就像前面说的一样 vp_interrupt 这个中断处理函数，一是处理配置变化，二是处理 I/O 结束。第二种的调用链为：vp_interrupt->vp_vring_interrupt->vring_interrupt。

irqreturn_t vring_interrupt(int irq, void *_vq)

{

	struct vring_virtqueue *vq = to_vvq(_vq);

......

	if (vq->vq.callback)

		vq->vq.callback(&vq->vq);

	return IRQ_HANDLED;

}

在 vring_interrupt 中，我们会调用 callback 函数，这个也是在前面注册过的，是 virtblk_done。

接下来的调用链为：virtblk_done->virtqueue_get_buf->virtqueue_get_buf_ctx。

void *virtqueue_get_buf_ctx(struct virtqueue *_vq, unsigned int *len,

			    void **ctx)

{

	struct vring_virtqueue *vq = to_vvq(_vq);

	void *ret;

	unsigned int i;

	u16 last_used;

......

	last_used = (vq->last_used_idx & (vq->vring.num - 1));

	i = virtio32_to_cpu(_vq->vdev, vq->vring.used->ring[last_used].id);

	*len = virtio32_to_cpu(_vq->vdev, vq->vring.used->ring[last_used].len);

......

	/* detach_buf clears data, so grab it now. */

	ret = vq->desc_state[i].data;

	detach_buf(vq, i, ctx);

	vq->last_used_idx++;

......

	return ret;

}

在 virtqueue_get_buf_ctx 中，我们可以看到，virtio 前端的 vring 中的 last_used_idx 加一，说明这块数据 qemu 后端已经消费完毕。我们可以通过 detach_buf 将其放入空闲队列中，留给以后的写入请求使用。

至此，整个存储虚拟化的写入流程才全部完成。

总结时刻

下面我们来总结一下存储虚拟化的场景下，整个写入的过程。

在虚拟机里面，应用层调用 write 系统调用写入文件。

write 系统调用进入虚拟机里面的内核，经过 VFS，通用块设备层，I/O 调度层，到达块设备驱动。

虚拟机里面的块设备驱动是 virtio_blk，它和通用的块设备驱动一样，有一个 request queue，另外有一个函数 make_request_fn 会被设置为 blk_mq_make_request，这个函数用于将请求放入队列。

虚拟机里面的块设备驱动是 virtio_blk 会注册一个中断处理函数 vp_interrupt。当 qemu 写入完成之后，它会通知虚拟机里面的块设备驱动。

blk_mq_make_request 最终调用 virtqueue_add，将请求添加到传输队列 virtqueue 中，然后调用 virtqueue_notify 通知 qemu。

在 qemu 中，本来虚拟机正处于 KVM_RUN 的状态，也即处于客户机状态。

qemu 收到通知后，通过 VM exit 指令退出客户机状态，进入宿主机状态，根据退出原因，得知有 I/O 需要处理。

qemu 调用 virtio_blk_handle_output，最终调用 virtio_blk_handle_vq。

virtio_blk_handle_vq 里面有一个循环，在循环中，virtio_blk_get_request 函数从传输队列中拿出请求，然后调用 virtio_blk_handle_request 处理请求。

virtio_blk_handle_request 会调用 blk_aio_pwritev，通过 BlockBackend 驱动写入 qcow2 文件。

写入完毕之后，virtio_blk_req_complete 会调用 virtio_notify 通知虚拟机里面的驱动。数据写入完成，刚才注册的中断处理函数 vp_interrupt 会收到这个通知。

课堂练习

请你沿着代码，仔细分析并牢记 virtqueue 的结构以及写入和读取方式。这个结构在下面的网络传输过程中，还要起大作用。

欢迎留言和我分享你的疑惑和见解，也欢迎收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

55 | 网络虚拟化：如何成立独立的合作部？

上一节，我们讲了存储虚拟化，这一节我们来讲网络虚拟化。

网络虚拟化有和存储虚拟化类似的地方，例如，它们都是基于 virtio 的，因而我们在看网络虚拟化的过程中，会看到和存储虚拟化很像的数据结构和原理。但是，网络虚拟化也有自己的特殊性。例如，存储虚拟化是将宿主机上的文件作为客户机上的硬盘，而网络虚拟化需要依赖于内核协议栈进行网络包的封装与解封装。那怎么实现客户机和宿主机之间的互通呢？我们就一起来看一看。

解析初始化过程

我们还是从 Virtio Network Device 这个设备的初始化讲起。

static const TypeInfo device_type_info = {

    .name = TYPE_DEVICE,

    .parent = TYPE_OBJECT,

    .instance_size = sizeof(DeviceState),

    .instance_init = device_initfn,

    .instance_post_init = device_post_init,

    .instance_finalize = device_finalize,

    .class_base_init = device_class_base_init,

    .class_init = device_class_init,

    .abstract = true,

    .class_size = sizeof(DeviceClass),

};

static const TypeInfo virtio_device_info = {

    .name = TYPE_VIRTIO_DEVICE,

    .parent = TYPE_DEVICE,

    .instance_size = sizeof(VirtIODevice),

    .class_init = virtio_device_class_init,

    .instance_finalize = virtio_device_instance_finalize,

    .abstract = true,

    .class_size = sizeof(VirtioDeviceClass),

};

static const TypeInfo virtio_net_info = {

    .name = TYPE_VIRTIO_NET,

    .parent = TYPE_VIRTIO_DEVICE,

    .instance_size = sizeof(VirtIONet),

    .instance_init = virtio_net_instance_init,

    .class_init = virtio_net_class_init,

};

static void virtio_register_types(void)

{

    type_register_static(&virtio_net_info);

}

type_init(virtio_register_types)

Virtio Network Device 这种类的定义是有多层继承关系的，TYPE_VIRTIO_NET 的父类是 TYPE_VIRTIO_DEVICE，TYPE_VIRTIO_DEVICE 的父类是 TYPE_DEVICE，TYPE_DEVICE 的父类是 TYPE_OBJECT，继承关系到头了。

type_init 用于注册这种类。这里面每一层都有 class_init，用于从 TypeImpl 生产 xxxClass，也有 instance_init，会将 xxxClass 初始化为实例。

TYPE_VIRTIO_NET 层的 class_init 函数 virtio_net_class_init，定义了 DeviceClass 的 realize 函数为 virtio_net_device_realize，这一点和存储块设备是一样的。

static void virtio_net_device_realize(DeviceState *dev, Error **errp)

{

    VirtIODevice *vdev = VIRTIO_DEVICE(dev);

    VirtIONet *n = VIRTIO_NET(dev);

    NetClientState *nc;

    int i;

......

    virtio_init(vdev, "virtio-net", VIRTIO_ID_NET, n->config_size);

    /*

     * We set a lower limit on RX queue size to what it always was.

     * Guests that want a smaller ring can always resize it without

     * help from us (using virtio 1 and up).

     */

    if (n->net_conf.rx_queue_size < VIRTIO_NET_RX_QUEUE_MIN_SIZE ||

        n->net_conf.rx_queue_size > VIRTQUEUE_MAX_SIZE ||

        !is_power_of_2(n->net_conf.rx_queue_size)) {

......

        return;

    }

    if (n->net_conf.tx_queue_size < VIRTIO_NET_TX_QUEUE_MIN_SIZE ||

        n->net_conf.tx_queue_size > VIRTQUEUE_MAX_SIZE ||

        !is_power_of_2(n->net_conf.tx_queue_size)) {

......

        return;

    }

    n->max_queues = MAX(n->nic_conf.peers.queues, 1);

    if (n->max_queues * 2 + 1 > VIRTIO_QUEUE_MAX) {

......

        return;

    }

    n->vqs = g_malloc0(sizeof(VirtIONetQueue) * n->max_queues);

    n->curr_queues = 1;

......

    n->net_conf.tx_queue_size = MIN(virtio_net_max_tx_queue_size(n),

                                    n->net_conf.tx_queue_size);

    for (i = 0; i < n->max_queues; i++) {

        virtio_net_add_queue(n, i);

    }

    n->ctrl_vq = virtio_add_queue(vdev, 64, virtio_net_handle_ctrl);

    qemu_macaddr_default_if_unset(&n->nic_conf.macaddr);

    memcpy(&n->mac[0], &n->nic_conf.macaddr, sizeof(n->mac));

    n->status = VIRTIO_NET_S_LINK_UP;

    if (n->netclient_type) {

        n->nic = qemu_new_nic(&net_virtio_info, &n->nic_conf,

                              n->netclient_type, n->netclient_name, n);

    } else {

        n->nic = qemu_new_nic(&net_virtio_info, &n->nic_conf,

                              object_get_typename(OBJECT(dev)), dev->id, n);

    }

......

}

这里面创建了一个 VirtIODevice，这一点和存储虚拟化也是一样的。virtio_init 用来初始化这个设备。VirtIODevice 结构里面有一个 VirtQueue 数组，这就是 virtio 前端和后端互相传数据的队列，最多有 VIRTIO_QUEUE_MAX 个。

刚才我们说的都是一样的地方，其实也有不一样的地方，我们下面来看。

你会发现，这里面有这样的语句 n->max_queues * 2 + 1 > VIRTIO_QUEUE_MAX。为什么要乘以 2 呢？这是因为，对于网络设备来讲，应该分发送队列和接收队列两个方向，所以乘以 2。

接下来，我们调用 virtio_net_add_queue 来初始化队列，可以看出来，这里面就有发送 tx_vq 和接收 rx_vq 两个队列。

typedef struct VirtIONetQueue {

    VirtQueue *rx_vq;

    VirtQueue *tx_vq;

    QEMUTimer *tx_timer;

    QEMUBH *tx_bh;

    uint32_t tx_waiting;

    struct {

        VirtQueueElement *elem;

    } async_tx;

    struct VirtIONet *n;

} VirtIONetQueue;

static void virtio_net_add_queue(VirtIONet *n, int index)

{

    VirtIODevice *vdev = VIRTIO_DEVICE(n);

    n->vqs[index].rx_vq = virtio_add_queue(vdev, n->net_conf.rx_queue_size, virtio_net_handle_rx);

......

    n->vqs[index].tx_vq = virtio_add_queue(vdev, n->net_conf.tx_queue_size, virtio_net_handle_tx_bh);

    n->vqs[index].tx_bh = qemu_bh_new(virtio_net_tx_bh, &n->vqs[index]);

    n->vqs[index].n = n;

}

每个 VirtQueue 中，都有一个 vring 用来维护这个队列里面的数据；另外还有函数 virtio_net_handle_rx 用于处理网络包的接收；函数 virtio_net_handle_tx_bh 用于网络包的发送，这个函数我们后面会用到。

NICState *qemu_new_nic(NetClientInfo *info,

                       NICConf *conf,

                       const char *model,

                       const char *name,

                       void *opaque)

{

    NetClientState **peers = conf->peers.ncs;

    NICState *nic;

    int i, queues = MAX(1, conf->peers.queues);

......

    nic = g_malloc0(info->size + sizeof(NetClientState) * queues);

    nic->ncs = (void *)nic + info->size;

    nic->conf = conf;

    nic->opaque = opaque;

    for (i = 0; i < queues; i++) {

        qemu_net_client_setup(&nic->ncs[i], info, peers[i], model, name, NULL);

        nic->ncs[i].queue_index = i;

    }

    return nic;

}

static void qemu_net_client_setup(NetClientState *nc,

                                  NetClientInfo *info,

                                  NetClientState *peer,

                                  const char *model,

                                  const char *name,

                                  NetClientDestructor *destructor)

{

    nc->info = info;

    nc->model = g_strdup(model);

    if (name) {

        nc->name = g_strdup(name);

    } else {

        nc->name = assign_name(nc, model);

    }

    QTAILQ_INSERT_TAIL(&net_clients, nc, next);

    nc->incoming_queue = qemu_new_net_queue(qemu_deliver_packet_iov, nc);

    nc->destructor = destructor;

    QTAILQ_INIT(&nc->filters);

}

接下来，qemu_new_nic 会创建一个虚拟机里面的网卡。

qemu 的启动过程中的网络虚拟化

初始化过程解析完毕以后，我们接下来从 qemu 的启动过程看起。

对于网卡的虚拟化，qemu 的启动参数里面有关的是下面两行：

-netdev tap,fd=32,id=hostnet0,vhost=on,vhostfd=37

-device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:d1:2d:99,bus=pci.0,addr=0x3

qemu 的 main 函数会调用 net_init_clients 进行网络设备的初始化，可以解析 net 参数，也可以在 net_init_clients 中解析 netdev 参数。

int net_init_clients(Error **errp)

{

    QTAILQ_INIT(&net_clients);

    if (qemu_opts_foreach(qemu_find_opts("netdev"),

                          net_init_netdev, NULL, errp)) {

        return -1;

    }

    if (qemu_opts_foreach(qemu_find_opts("nic"), net_param_nic, NULL, errp)) {

        return -1;

   }

    if (qemu_opts_foreach(qemu_find_opts("net"), net_init_client, NULL, errp)) {

        return -1;

    }

    return 0;

}

net_init_clients 会解析参数。上面的参数 netdev 会调用 net_init_netdev->net_client_init->net_client_init1。

net_client_init1 会根据不同的 driver 类型，调用不同的初始化函数。

static int (* const net_client_init_fun[NET_CLIENT_DRIVER__MAX])(

    const Netdev *netdev,

    const char *name,

    NetClientState *peer, Error **errp) = {

        [NET_CLIENT_DRIVER_NIC]       = net_init_nic,

        [NET_CLIENT_DRIVER_TAP]       = net_init_tap,

        [NET_CLIENT_DRIVER_SOCKET]    = net_init_socket,

        [NET_CLIENT_DRIVER_HUBPORT]   = net_init_hubport,

......

};

由于我们配置的 driver 的类型是 tap，因而这里会调用 net_init_tap->net_tap_init->tap_open。

#define PATH_NET_TUN "/dev/net/tun"

int tap_open(char *ifname, int ifname_size, int *vnet_hdr,

             int vnet_hdr_required, int mq_required, Error **errp)

{

    struct ifreq ifr;

    int fd, ret;

    int len = sizeof(struct virtio_net_hdr);

    unsigned int features;

    TFR(fd = open(PATH_NET_TUN, O_RDWR));

    memset(&ifr, 0, sizeof(ifr));

    ifr.ifr_flags = IFF_TAP | IFF_NO_PI;

    if (ioctl(fd, TUNGETFEATURES, &features) == -1) {

        features = 0;

    }

    if (features & IFF_ONE_QUEUE) {

        ifr.ifr_flags |= IFF_ONE_QUEUE;

    }

    if (*vnet_hdr) {

        if (features & IFF_VNET_HDR) {

            *vnet_hdr = 1;

            ifr.ifr_flags |= IFF_VNET_HDR;

        } else {

            *vnet_hdr = 0;

        }

        ioctl(fd, TUNSETVNETHDRSZ, &len);

    }

......

    ret = ioctl(fd, TUNSETIFF, (void *) &ifr);

......

    fcntl(fd, F_SETFL, O_NONBLOCK);

    return fd;

}

在 tap_open 中，我们打开一个文件"/dev/net/tun"，然后通过 ioctl 操作这个文件。这是 Linux 内核的一项机制，和 KVM 机制很像。其实这就是一种通过打开这个字符设备文件，然后通过 ioctl 操作这个文件和内核打交道，来使用内核的能力。

为什么需要使用内核的机制呢？因为网络包需要从虚拟机里面发送到虚拟机外面，发送到宿主机上的时候，必须是一个正常的网络包才能被转发。要形成一个网络包，我们那就需要经过复杂的协议栈，协议栈的复杂咱们在发送网络包那一节讲过了。

客户机会将网络包发送给 qemu。qemu 自己没有网络协议栈，现去实现一个也不可能，太复杂了。于是，它就要借助内核的力量。

qemu 会将客户机发送给它的网络包，然后转换成为文件流，写入"/dev/net/tun"字符设备。就像写一个文件一样。内核中 TUN/TAP 字符设备驱动会收到这个写入的文件流，然后交给 TUN/TAP 的虚拟网卡驱动。这个驱动会将文件流再次转成网络包，交给 TCP/IP 栈，最终从虚拟 TAP 网卡 tap0 发出来，成为标准的网络包。后面我们会看到这个过程。

现在我们到内核里面，看一看打开"/dev/net/tun"字符设备后，内核会发生什么事情。内核的实现在 drivers/net/tun.c 文件中。这是一个字符设备驱动程序，应该符合字符设备的格式。

module_init(tun_init);

module_exit(tun_cleanup);

MODULE_DESCRIPTION(DRV_DESCRIPTION);

MODULE_AUTHOR(DRV_COPYRIGHT);

MODULE_LICENSE("GPL");

MODULE_ALIAS_MISCDEV(TUN_MINOR);

MODULE_ALIAS("devname:net/tun");

static int __init tun_init(void)

{

......

	ret = rtnl_link_register(&tun_link_ops);

......

	ret = misc_register(&tun_miscdev);

......

	ret = register_netdevice_notifier(&tun_notifier_block);

......

}

这里面注册了一个 tun_miscdev 字符设备，从它的定义可以看出，这就是"/dev/net/tun"字符设备。

static struct miscdevice tun_miscdev = {

	.minor = TUN_MINOR,

	.name = "tun",

	.nodename = "net/tun",

	.fops = &tun_fops,

};

static const struct file_operations tun_fops = {

	.owner	= THIS_MODULE,

	.llseek = no_llseek,

	.read_iter  = tun_chr_read_iter,

	.write_iter = tun_chr_write_iter,

	.poll	= tun_chr_poll,

	.unlocked_ioctl	= tun_chr_ioctl,

	.open	= tun_chr_open,

	.release = tun_chr_close,

	.fasync = tun_chr_fasync,

};

qemu 的 tap_open 函数会打开这个字符设备 PATH_NET_TUN。打开字符设备的过程我们不再重复。我就说一下，到了驱动这一层，调用的是 tun_chr_open。

static int tun_chr_open(struct inode *inode, struct file * file)

{

	struct tun_file *tfile;

	tfile = (struct tun_file *)sk_alloc(net, AF_UNSPEC, GFP_KERNEL,

					    &tun_proto, 0);

	RCU_INIT_POINTER(tfile->tun, NULL);

	tfile->flags = 0;

	tfile->ifindex = 0;

	init_waitqueue_head(&tfile->wq.wait);

	RCU_INIT_POINTER(tfile->socket.wq, &tfile->wq);

	tfile->socket.file = file;

	tfile->socket.ops = &tun_socket_ops;

	sock_init_data(&tfile->socket, &tfile->sk);

	tfile->sk.sk_write_space = tun_sock_write_space;

	tfile->sk.sk_sndbuf = INT_MAX;

	file->private_data = tfile;

	INIT_LIST_HEAD(&tfile->next);

	sock_set_flag(&tfile->sk, SOCK_ZEROCOPY);

	return 0;

}

在 tun_chr_open 的参数里面，有一个 struct file，这是代表什么文件呢？它代表的就是打开的字符设备文件"/dev/net/tun"，因而往这个字符设备文件中写数据，就会通过这个 struct file 写入。这个 struct file 里面的 file_operations，按照字符设备打开的规则，指向的就是 tun_fops。

另外，我们还需要在 tun_chr_open 创建了一个结构 struct tun_file，并且将 struct file 的 private_data 指向它。

/* A tun_file connects an open character device to a tuntap netdevice. It

 * also contains all socket related structures

 * to serve as one transmit queue for tuntap device.

 */

struct tun_file {

	struct sock sk;

	struct socket socket;

	struct socket_wq wq;

	struct tun_struct __rcu *tun;

	struct fasync_struct *fasync;

	/* only used for fasnyc */

	unsigned int flags;

	union {

		u16 queue_index;

		unsigned int ifindex;

	};

	struct list_head next;

	struct tun_struct *detached;

	struct skb_array tx_array;

};

struct tun_struct {

	struct tun_file __rcu	*tfiles[MAX_TAP_QUEUES];

	unsigned int            numqueues;

	unsigned int 		flags;

	kuid_t			owner;

	kgid_t			group;

	struct net_device	*dev;

	netdev_features_t	set_features;

	int			align;

	int			vnet_hdr_sz;

	int			sndbuf;

	struct tap_filter	txflt;

	struct sock_fprog	fprog;

	/* protected by rtnl lock */

	bool			filter_attached;

	spinlock_t lock;

	struct hlist_head flows[TUN_NUM_FLOW_ENTRIES];

	struct timer_list flow_gc_timer;

	unsigned long ageing_time;

	unsigned int numdisabled;

	struct list_head disabled;

	void *security;

	u32 flow_count;

	u32 rx_batched;

	struct tun_pcpu_stats __percpu *pcpu_stats;

};

static const struct proto_ops tun_socket_ops = {

	.peek_len = tun_peek_len,

	.sendmsg = tun_sendmsg,

	.recvmsg = tun_recvmsg,

};

在 struct tun_file 中，有一个成员 struct tun_struct，它里面有一个 struct net_device，这个用来表示宿主机上的 tuntap 网络设备。在 struct tun_file 中，还有 struct socket 和 struct sock，因为要用到内核的网络协议栈，所以就需要这两个结构，这在网络协议那一节已经分析过了。

所以，按照 struct tun_file 的注释说的，这是一个很重要的数据结构。"/dev/net/tun"对应的 struct file 的 private_data 指向它，因而可以接收 qemu 发过来的数据。除此之外，它还可以通过 struct sock 来操作内核协议栈，然后将网络包从宿主机上的 tuntap 网络设备发出去，宿主机上的 tuntap 网络设备对应的 struct net_device 也归它管。

在 qemu 的 tap_open 函数中，打开这个字符设备文件之后，接下来要做的事情是，通过 ioctl 来设置宿主机的网卡 TUNSETIFF。

接下来，ioctl 到了内核里面，会调用 tun_chr_ioctl。

static long __tun_chr_ioctl(struct file *file, unsigned int cmd,

			    unsigned long arg, int ifreq_len)

{

	struct tun_file *tfile = file->private_data;

	struct tun_struct *tun;

	void __user* argp = (void __user*)arg;

	struct ifreq ifr;

	kuid_t owner;

	kgid_t group;

	int sndbuf;

	int vnet_hdr_sz;

	unsigned int ifindex;

	int le;

	int ret;

	if (cmd == TUNSETIFF || cmd == TUNSETQUEUE || _IOC_TYPE(cmd) == SOCK_IOC_TYPE) {

		if (copy_from_user(&ifr, argp, ifreq_len))

			return -EFAULT;

	}

......

	tun = __tun_get(tfile);

	if (cmd == TUNSETIFF) {

		ifr.ifr_name[IFNAMSIZ-1] = '\0';

		ret = tun_set_iff(sock_net(&tfile->sk), file, &ifr);

......

		if (copy_to_user(argp, &ifr, ifreq_len))

			ret = -EFAULT;

	}

......

}

在 __tun_chr_ioctl 中，我们首先通过 copy_from_user 把配置从用户态拷贝到内核态，调用 tun_set_iff 设置 tuntap 网络设备，然后调用 copy_to_user 将配置结果返回。

static int tun_set_iff(struct net *net, struct file *file, struct ifreq *ifr)

{

	struct tun_struct *tun;

	struct tun_file *tfile = file->private_data;

	struct net_device *dev;

......

	char *name;

	unsigned long flags = 0;

	int queues = ifr->ifr_flags & IFF_MULTI_QUEUE ?

			     MAX_TAP_QUEUES : 1;

	if (ifr->ifr_flags & IFF_TUN) {

		/* TUN device */

		flags |= IFF_TUN;

		name = "tun%d";

	} else if (ifr->ifr_flags & IFF_TAP) {

		/* TAP device */

		flags |= IFF_TAP;

		name = "tap%d";

	} else

		return -EINVAL;

	if (*ifr->ifr_name)

		name = ifr->ifr_name;

	dev = alloc_netdev_mqs(sizeof(struct tun_struct), name,

				       NET_NAME_UNKNOWN, tun_setup, queues,

				       queues);

	err = dev_get_valid_name(net, dev, name);

	dev_net_set(dev, net);

	dev->rtnl_link_ops = &tun_link_ops;

	dev->ifindex = tfile->ifindex;

	dev->sysfs_groups[0] = &tun_attr_group;

	tun = netdev_priv(dev);

	tun->dev = dev;

	tun->flags = flags;

	tun->txflt.count = 0;

	tun->vnet_hdr_sz = sizeof(struct virtio_net_hdr);

	tun->align = NET_SKB_PAD;

	tun->filter_attached = false;

	tun->sndbuf = tfile->socket.sk->sk_sndbuf;

	tun->rx_batched = 0;

	tun_net_init(dev);

	tun_flow_init(tun);

	err = tun_attach(tun, file, false);

	err = register_netdevice(tun->dev);

	netif_carrier_on(tun->dev);

	if (netif_running(tun->dev))

		netif_tx_wake_all_queues(tun->dev);

	strcpy(ifr->ifr_name, tun->dev->name);

	return 0;

}

tun_set_iff 创建了 struct tun_struct 和 struct net_device，并且将这个 tuntap 网络设备通过 register_netdevice 注册到内核中。这样，我们就能在宿主机上通过 ip addr 看到这个网卡了。

至此宿主机上的内核的数据结构也完成了。

关联前端设备驱动和后端设备驱动

下面，我们来解析在客户机中发送一个网络包的时候，会发生哪些事情。

虚拟机里面的进程发送一个网络包，通过文件系统和 Socket 调用网络协议栈，到达网络设备层。只不过这个不是普通的网络设备，而是 virtio_net 的驱动。

virtio_net 的驱动程序代码在 Linux 操作系统的源代码里面，文件名为 drivers/net/virtio_net.c。

static __init int virtio_net_driver_init(void)

{

    ret = register_virtio_driver(&virtio_net_driver);

......

}

module_init(virtio_net_driver_init);

module_exit(virtio_net_driver_exit);

MODULE_DEVICE_TABLE(virtio, id_table);

MODULE_DESCRIPTION("Virtio network driver");

MODULE_LICENSE("GPL");

static struct virtio_driver virtio_net_driver = {

	.driver.name =	KBUILD_MODNAME,

	.driver.owner =	THIS_MODULE,

	.id_table =	id_table,

	.validate =	virtnet_validate,

	.probe =	virtnet_probe,

	.remove =	virtnet_remove,

	.config_changed = virtnet_config_changed,

......

};

在 virtio_net 的驱动程序的初始化代码中，我们需要注册一个驱动函数 virtio_net_driver。

当一个设备驱动作为一个内核模块被初始化的时候，probe 函数会被调用，因而我们来看一下 virtnet_probe。

static int virtnet_probe(struct virtio_device *vdev)

{

	int i, err;

	struct net_device *dev;

	struct virtnet_info *vi;

	u16 max_queue_pairs;

	int mtu;

	/* Allocate ourselves a network device with room for our info */

	dev = alloc_etherdev_mq(sizeof(struct virtnet_info), max_queue_pairs);

	/* Set up network device as normal. */

	dev->priv_flags |= IFF_UNICAST_FLT | IFF_LIVE_ADDR_CHANGE;

	dev->netdev_ops = &virtnet_netdev;

	dev->features = NETIF_F_HIGHDMA;

	dev->ethtool_ops = &virtnet_ethtool_ops;

	SET_NETDEV_DEV(dev, &vdev->dev);

......

	/* MTU range: 68 - 65535 */

	dev->min_mtu = MIN_MTU;

	dev->max_mtu = MAX_MTU;

	/* Set up our device-specific information */

	vi = netdev_priv(dev);

	vi->dev = dev;

	vi->vdev = vdev;

	vdev->priv = vi;

	vi->stats = alloc_percpu(struct virtnet_stats);

	INIT_WORK(&vi->config_work, virtnet_config_changed_work);

......

	vi->max_queue_pairs = max_queue_pairs;

	/* Allocate/initialize the rx/tx queues, and invoke find_vqs */

	err = init_vqs(vi);

	netif_set_real_num_tx_queues(dev, vi->curr_queue_pairs);

	netif_set_real_num_rx_queues(dev, vi->curr_queue_pairs);

	virtnet_init_settings(dev);

	err = register_netdev(dev);

	virtio_device_ready(vdev);

	virtnet_set_queues(vi, vi->curr_queue_pairs);

......

}

在 virtnet_probe 中，会创建 struct net_device，并且通过 register_netdev 注册这个网络设备，这样在客户机里面，就能看到这个网卡了。

在 virtnet_probe 中，还有一件重要的事情就是，init_vqs 会初始化发送和接收的 virtqueue。

static int init_vqs(struct virtnet_info *vi)

{

	int ret;

	/* Allocate send & receive queues */

	ret = virtnet_alloc_queues(vi);

	ret = virtnet_find_vqs(vi);

......

	get_online_cpus();

	virtnet_set_affinity(vi);

	put_online_cpus();

	return 0;

}

static int virtnet_alloc_queues(struct virtnet_info *vi)

{

	int i;

	vi->sq = kzalloc(sizeof(*vi->sq) * vi->max_queue_pairs, GFP_KERNEL);

	vi->rq = kzalloc(sizeof(*vi->rq) * vi->max_queue_pairs, GFP_KERNEL);

	INIT_DELAYED_WORK(&vi->refill, refill_work);

	for (i = 0; i < vi->max_queue_pairs; i++) {

		vi->rq[i].pages = NULL;

		netif_napi_add(vi->dev, &vi->rq[i].napi, virtnet_poll,

			       napi_weight);

		netif_tx_napi_add(vi->dev, &vi->sq[i].napi, virtnet_poll_tx,

				  napi_tx ? napi_weight : 0);

		sg_init_table(vi->rq[i].sg, ARRAY_SIZE(vi->rq[i].sg));

		ewma_pkt_len_init(&vi->rq[i].mrg_avg_pkt_len);

		sg_init_table(vi->sq[i].sg, ARRAY_SIZE(vi->sq[i].sg));

	}

	return 0;

}

按照上一节的 virtio 原理，virtqueue 是一个介于客户机前端和 qemu 后端的一个结构，用于在这两端之间传递数据，对于网络设备来讲有发送和接收两个方向的队列。这里建立的 struct virtqueue 是客户机前端对于队列的管理的数据结构。

队列的实体需要通过函数 virtnet_find_vqs 查找或者生成，这里还会指定接收队列的 callback 函数为 skb_recv_done，发送队列的 callback 函数为 skb_xmit_done。那当 buffer 使用发生变化的时候，我们可以调用这个 callback 函数进行通知。

static int virtnet_find_vqs(struct virtnet_info *vi)

{

	vq_callback_t **callbacks;

	struct virtqueue **vqs;

	int ret = -ENOMEM;

	int i, total_vqs;

	const char **names;

	/* Allocate space for find_vqs parameters */

	vqs = kzalloc(total_vqs * sizeof(*vqs), GFP_KERNEL);

	callbacks = kmalloc(total_vqs * sizeof(*callbacks), GFP_KERNEL);

	names = kmalloc(total_vqs * sizeof(*names), GFP_KERNEL);

	/* Allocate/initialize parameters for send/receive virtqueues */

	for (i = 0; i < vi->max_queue_pairs; i++) {

		callbacks[rxq2vq(i)] = skb_recv_done;

		callbacks[txq2vq(i)] = skb_xmit_done;

		names[rxq2vq(i)] = vi->rq[i].name;

		names[txq2vq(i)] = vi->sq[i].name;

	}

	ret = vi->vdev->config->find_vqs(vi->vdev, total_vqs, vqs, callbacks, names, ctx, NULL);

......

	for (i = 0; i < vi->max_queue_pairs; i++) {

		vi->rq[i].vq = vqs[rxq2vq(i)];

		vi->rq[i].min_buf_len = mergeable_min_buf_len(vi, vi->rq[i].vq);

		vi->sq[i].vq = vqs[txq2vq(i)];

	}

......

}

这里的 find_vqs 是在 struct virtnet_info 里的 struct virtio_device 里的 struct virtio_config_ops *config 里面定义的。

根据 virtio_config_ops 的定义，find_vqs 会调用 vp_modern_find_vqs，到这一步和块设备是一样的了。

在 vp_modern_find_vqs 中，vp_find_vqs 会调用 vp_find_vqs_intx。在 vp_find_vqs_intx 中，通过 request_irq 注册一个中断处理函数 vp_interrupt。当设备向队列中写入信息时，会产生一个中断，也就是 vq 中断。中断处理函数需要调用相应的队列的回调函数，然后根据队列的数目，依次调用 vp_setup_vq 完成 virtqueue、vring 的分配和初始化。

同样，这些数据结构会和 virtio 后端的 VirtIODevice、VirtQueue、vring 对应起来，都应该指向刚才创建的那一段内存。

客户机同样会通过调用专门给外部设备发送指令的函数 iowrite 告诉外部的 pci 设备，这些共享内存的地址。

至此前端设备驱动和后端设备驱动之间的两个收发队列就关联好了，这两个队列的格式和块设备是一样的。

发送网络包过程

接下来，我们来看当真的发送一个网络包的时候，会发生什么。

当网络包经过客户机的协议栈到达 virtio_net 驱动的时候，按照 net_device_ops 的定义，start_xmit 会被调用。

static const struct net_device_ops virtnet_netdev = {

	.ndo_open            = virtnet_open,

	.ndo_stop   	     = virtnet_close,

	.ndo_start_xmit      = start_xmit,

	.ndo_validate_addr   = eth_validate_addr,

	.ndo_set_mac_address = virtnet_set_mac_address,

	.ndo_set_rx_mode     = virtnet_set_rx_mode,

	.ndo_get_stats64     = virtnet_stats,

	.ndo_vlan_rx_add_vid = virtnet_vlan_rx_add_vid,

	.ndo_vlan_rx_kill_vid = virtnet_vlan_rx_kill_vid,

	.ndo_xdp		= virtnet_xdp,

	.ndo_features_check	= passthru_features_check,

};

接下来的调用链为：start_xmit->xmit_skb-> virtqueue_add_outbuf->virtqueue_add，将网络包放入队列中，并调用 virtqueue_notify 通知接收方。

static netdev_tx_t start_xmit(struct sk_buff *skb, struct net_device *dev)

{

	struct virtnet_info *vi = netdev_priv(dev);

	int qnum = skb_get_queue_mapping(skb);

	struct send_queue *sq = &vi->sq[qnum];

	int err;

	struct netdev_queue *txq = netdev_get_tx_queue(dev, qnum);

	bool kick = !skb->xmit_more;

	bool use_napi = sq->napi.weight;

......

	/* Try to transmit */

	err = xmit_skb(sq, skb);

......

	if (kick || netif_xmit_stopped(txq))

		virtqueue_kick(sq->vq);

	return NETDEV_TX_OK;

}

bool virtqueue_kick(struct virtqueue *vq)

{

	if (virtqueue_kick_prepare(vq))

		return virtqueue_notify(vq);

	return true;

}

写入一个 I/O 会使得 qemu 触发 VM exit，这个逻辑我们在解析 CPU 的时候看到过。

接下来，我们那会调用 VirtQueue 的 handle_output 函数。前面我们已经设置过这个函数了，其实就是 virtio_net_handle_tx_bh。

static void virtio_net_handle_tx_bh(VirtIODevice *vdev, VirtQueue *vq)

{

    VirtIONet *n = VIRTIO_NET(vdev);

    VirtIONetQueue *q = &n->vqs[vq2q(virtio_get_queue_index(vq))];

    q->tx_waiting = 1;

    virtio_queue_set_notification(vq, 0);

    qemu_bh_schedule(q->tx_bh);

}

virtio_net_handle_tx_bh 调用了 qemu_bh_schedule，而在 virtio_net_add_queue 中调用 qemu_bh_new，并把函数设置为 virtio_net_tx_bh。

virtio_net_tx_bh 函数调用发送函数 virtio_net_flush_tx。

static int32_t virtio_net_flush_tx(VirtIONetQueue *q)

{

    VirtIONet *n = q->n;

    VirtIODevice *vdev = VIRTIO_DEVICE(n);

    VirtQueueElement *elem;

    int32_t num_packets = 0;

    int queue_index = vq2q(virtio_get_queue_index(q->tx_vq));

    for (;;) {

        ssize_t ret;

        unsigned int out_num;

        struct iovec sg[VIRTQUEUE_MAX_SIZE], sg2[VIRTQUEUE_MAX_SIZE + 1], *out_sg;

        struct virtio_net_hdr_mrg_rxbuf mhdr;

        elem = virtqueue_pop(q->tx_vq, sizeof(VirtQueueElement));

        out_num = elem->out_num;

        out_sg = elem->out_sg;

......

        ret = qemu_sendv_packet_async(qemu_get_subqueue(n->nic, queue_index),out_sg, out_num, virtio_net_tx_complete);

    }

......

    return num_packets;

}

virtio_net_flush_tx 会调用 virtqueue_pop。这里面，我们能看到对于 vring 的操作，也即从这里面将客户机里面写入的数据读取出来。

然后，我们调用 qemu_sendv_packet_async 发送网络包。接下来的调用链为：qemu_sendv_packet_async->qemu_net_queue_send_iov->qemu_net_queue_flush->qemu_net_queue_deliver。

在 qemu_net_queue_deliver 中，我们会调用 NetQueue 的 deliver 函数。前面 qemu_new_net_queue 会把 deliver 函数设置为 qemu_deliver_packet_iov。它会调用 nc->info->receive_iov。

static NetClientInfo net_tap_info = {

    .type = NET_CLIENT_DRIVER_TAP,

    .size = sizeof(TAPState),

    .receive = tap_receive,

    .receive_raw = tap_receive_raw,

    .receive_iov = tap_receive_iov,

    .poll = tap_poll,

    .cleanup = tap_cleanup,

    .has_ufo = tap_has_ufo,

    .has_vnet_hdr = tap_has_vnet_hdr,

    .has_vnet_hdr_len = tap_has_vnet_hdr_len,

    .using_vnet_hdr = tap_using_vnet_hdr,

    .set_offload = tap_set_offload,

    .set_vnet_hdr_len = tap_set_vnet_hdr_len,

    .set_vnet_le = tap_set_vnet_le,

    .set_vnet_be = tap_set_vnet_be,

};

根据 net_tap_info 的定义调用的是 tap_receive_iov。他会调用 tap_write_packet->writev 写入这个字符设备。

在内核的字符设备驱动中，tun_chr_write_iter 会被调用。

static ssize_t tun_chr_write_iter(struct kiocb *iocb, struct iov_iter *from)

{

	struct file *file = iocb->ki_filp;

	struct tun_struct *tun = tun_get(file);

	struct tun_file *tfile = file->private_data;

	ssize_t result;

	result = tun_get_user(tun, tfile, NULL, from,

			      file->f_flags & O_NONBLOCK, false);

	tun_put(tun);

	return result;

}

当我们使用 writev() 系统调用向 tun/tap 设备的字符设备文件写入数据时，tun_chr_write 函数将被调用。它会使用 tun_get_user，从用户区接收数据，将数据存入 skb 中，然后调用关键的函数 netif_rx_ni(skb) ，将 skb 送给 tcp/ip 协议栈处理，最终完成虚拟网卡的数据接收。

至此，从虚拟机内部到宿主机的网络传输过程才算结束。

总结时刻

最后，我们把网络虚拟化场景下网络包的发送过程总结一下。

在虚拟机里面的用户态，应用程序通过 write 系统调用写入 socket。

写入的内容经过 VFS 层，内核协议栈，到达虚拟机里面的内核的网络设备驱动，也即 virtio_net。

virtio_net 网络设备有一个操作结构 struct net_device_ops，里面定义了发送一个网络包调用的函数为 start_xmit。

在 virtio_net 的前端驱动和 qemu 中的后端驱动之间，有两个队列 virtqueue，一个用于发送，一个用于接收。然后，我们需要在 start_xmit 中调用 virtqueue_add，将网络包放入发送队列，然后调用 virtqueue_notify 通知 qemu。

qemu 本来处于 KVM_RUN 的状态，收到通知后，通过 VM exit 指令退出客户机模式，进入宿主机模式。发送网络包的时候，virtio_net_handle_tx_bh 函数会被调用。

接下来是一个 for 循环，我们需要在循环中调用 virtqueue_pop，从传输队列中获取要发送的数据，然后调用 qemu_sendv_packet_async 进行发送。

qemu 会调用 writev 向字符设备文件写入，进入宿主机的内核。

在宿主机内核中字符设备文件的 file_operations 里面的 write_iter 会被调用，也即会调用 tun_chr_write_iter。

在 tun_chr_write_iter 函数中，tun_get_user 将要发送的网络包从 qemu 拷贝到宿主机内核里面来，然后调用 netif_rx_ni 开始调用宿主机内核协议栈进行处理。

宿主机内核协议栈处理完毕之后，会发送给 tap 虚拟网卡，完成从虚拟机里面到宿主机的整个发送过程。

课堂练习

这一节我们解析的是发送过程，请你根据类似的思路，解析一下接收过程。

欢迎留言和我分享你的疑惑和见解，也欢迎收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

56 | 容器：大公司为保持创新，鼓励内部创业

上一章，我们讲了虚拟化的原理。从一台物理机虚拟化出很多的虚拟机这种方式，一定程度上实现了资源创建的灵活性。但是你同时会发现，虚拟化的方式还是非常复杂的。这有点儿像，你去成立子公司，虽然说公司小，但毕竟是一些独立的公司，麻雀虽小，五脏俱全，因而就像上一章我们看到的那样，CPU、内存、网络、硬盘全部需要虚拟化，一个都不能偷懒。

那有没有一种更加灵活的方式，既可以隔离出一部分资源，专门用于某个进程，又不需要费劲周折的虚拟化这么多的硬件呢？毕竟最终我只想跑一个程序，而不是要一整个 Linux 系统。这就像在一家大公司搞创新，如果每一个创新项目都要成立一家子公司的话，那简直太麻烦了。一般方式是在公司内部成立一个独立的组织，分配独立的资源和人力，先做一段时间的内部创业。如果真的做成功了，再成立子公司也不迟。

在 Linux 操作系统中，有一项新的技术，称为容器，它就可以做到这一点。

容器的英文叫 Container，Container 的另一个意思是“集装箱”。其实容器就像船上的不同的集装箱装着不同的货物，有一定的隔离，但是隔离性又没有那么好，仅仅做简单的封装。当然封装也带来了好处，一个是打包，二是标准。

在没有集装箱的时代，假设我们要将货物从 A 运到 B，中间要经过三个码头、换三次船。那么每次都要将货物卸下船来，弄得乱七八糟，然后还要再搬上船重新摆好。因此在没有集装箱的时候，每次换船，船员们都要在岸上待几天才能干完活。

有了尺寸全部都一样的集装箱以后，我们可以把所有的货物都打包在一起。每次换船的时候，把整个集装箱搬过去就行了，几个小时就能完成。船员换船时间大大缩短了。这是集装箱的“打包”和“标准”两大特点在生活中的应用。

其实容器的思想就是要变成软件交付的集装箱。那么容器如何对应用打包呢？

我们先来学习一下集装箱的打包过程。首先，我们得有个封闭的环境，将货物封装起来，让货物之间互不干扰，互相隔离，这样装货卸货才方便。

容器实现封闭的环境主要要靠两种技术，一种是看起来是隔离的技术，称为namespace（命名空间）。在每个 namespace 中的应用看到的，都是不同的 IP 地址、用户空间、进程 ID 等。另一种是用起来是隔离的技术，称为cgroup（网络资源限制），即明明整台机器有很多的 CPU、内存，但是一个应用只能用其中的一部分。

有了这两项技术，就相当于我们焊好了集装箱。接下来的问题就是，如何“将这些集装箱标准化”，在哪艘船上都能运输。这里就要用到镜像了。

所谓镜像（Image），就是在你焊好集装箱的那一刻，将集装箱的状态保存下来。就像孙悟空说：“定！”，集装箱里的状态就被“定”在了那一刻，然后这一刻的状态会被保存成一系列文件。无论在哪里运行这个镜像，都能完整地还原当时的情况。

当程序员根据产品设计开发完毕之后，可以将代码连同运行环境打包成一个容器镜像。这个时候集装箱就焊好了。接下来，无论是在开发环境、测试环境，还是生产环境运行代码，都可以使用相同的镜像。就好像集装箱在开发、测试、生产这三个码头非常顺利地整体迁移，这样产品的发布和上线速度就加快了。

下面，我们就来体验一下这个 Linux 上的容器技术！

首先，我们要安装一个目前最主流的容器技术的实现 Docker。假设我们的操作系统是 CentOS，你可以参考https://docs.docker.com/install/linux/docker-ce/centos/这个官方文档，进行安装。

第一步，删除原有版本的 Docker。

yum remove docker \

                  docker-client \

                  docker-client-latest \

                  docker-common \

                  docker-latest \

                  docker-latest-logrotate \

                  docker-logrotate \

                  docker-engine

第二步，安装依赖的包。

yum install -y yum-utils \

  device-mapper-persistent-data \

  lvm2

第三步，安装 Docker 所属的库。

yum-config-manager \

    --add-repo \

    https://download.docker.com/linux/centos/docker-ce.repo

第四步，安装 Docker。

yum install docker-ce docker-ce-cli containerd.io

第五步，启动 Docker。

systemctl start docker

Docker 安装好之后，接下来我们就来运行一个容器。

就像上面我们讲过的，容器的运行需要一个镜像，这是我们集装箱封装的那个环境，在https://hub.docker.com/上，你能找到你能想到的几乎所有环境。

最基础的环境就是操作系统。

咱们最初讲命令行的时候讲过，每种操作系统的命令行不太一样，就像黑话一样。有时候我们写一个脚本，需要基于某种类型的操作系统，例如，Ubuntu 或者 centOS。但是，Ubuntu 或者 centOS 不同版本的命令也不一样，需要有一个环境尝试一下命令是否正确。

最常见的做法是有几种类型的操作系统，就弄几台物理机。当然这样一般人可玩不起，但是有了虚拟机就好一些了。你可以在你的笔记本电脑上创建多台虚拟机，但是这个时候又会有另一个苦恼，那就是，虚拟机往往需要比较大的内存，一般一台笔记本电脑上无法启动多台虚拟机，所以做起实验来要经常切换虚拟机，非常麻烦。现在有了容器，好了，我们可以在一台虚拟机上创建任意的操作系统环境了。

比方说，你可以在https://hub.docker.com/上搜索 Ubuntu。点开之后，找到 Tags。镜像都有 Tag，这是镜像制作者自己任意指定的，多用于表示这个镜像的版本号。

如果仔细看这些 Tags，我们会发现，哪怕非常老版本的 Ubuntu，这里面都有，例如 14.04。如果我们突然需要一个基于 Ubuntu 14.04 的命令，那就不需要费劲去寻找、安装一个这么老的虚拟机，只要根据命令下载这个镜像就可以了。

# docker pull ubuntu:14.04

14.04: Pulling from library/ubuntu

a7344f52cb74: Pull complete

515c9bb51536: Pull complete

e1eabe0537eb: Pull complete

4701f1215c13: Pull complete

Digest: sha256:2f7c79927b346e436cc14c92bd4e5bd778c3bd7037f35bc639ac1589a7acfa90

Status: Downloaded newer image for ubuntu:14.04

下载完毕之后，我们可以通过下面的命令查看镜像。

# docker images

REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE

ubuntu              14.04               2c5e00d77a67        2 months ago        188MB

有了镜像，我们就可以通过下面的启动一个容器啦。

启动一个容器需要一个叫 entrypoint 的东西，也就是入口。一个容器启动起来之后，会从这个指令开始运行，并且只有这个指令在运行，容器才启动着。如果这个指令退出，整个容器就退出了。

因为我们想尝试命令，所以这里 entrypoint 要设置为 bash。通过 cat /etc/lsb-release，我们可以看出，这里面已经是一个老的 Ubuntu 14.04 的环境。

# docker run -it --entrypoint bash ubuntu:14.04

root@0e35f3f1fbc5:/# cat /etc/lsb-release

DISTRIB_ID=Ubuntu

DISTRIB_RELEASE=14.04

DISTRIB_CODENAME=trusty

DISTRIB_DESCRIPTION="Ubuntu 14.04.6 LTS"

如果我们想尝试 centOS 6，也是没问题的。

# docker pull centos:6

6: Pulling from library/centos

ff50d722b382: Pull complete

Digest: sha256:dec8f471302de43f4cfcf82f56d99a5227b5ea1aa6d02fa56344986e1f4610e7

Status: Downloaded newer image for centos:6

# docker images

REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE

ubuntu              14.04               2c5e00d77a67        2 months ago        188MB

centos              6                   d0957ffdf8a2        4 months ago        194MB

# docker run -it --entrypoint bash centos:6

[root@af4c8d598bdf /]# cat /etc/redhat-release

CentOS release 6.10 (Final)

除了可以如此简单地创建一个操作系统环境，容器还有一个很酷的功能，就是镜像里面带应用。这样的话，应用就可以像集装箱一样，到处迁移，启动即可提供服务。而不用像虚拟机那样，要先有一个操作系统的环境，然后再在里面安装应用。

我们举一个最简单的应用的例子，也就是 nginx。我们可以下载一个 nginx 的镜像，运行起来，里面就自带 nginx 了，并且直接可以访问了。

# docker pull nginx

Using default tag: latest

latest: Pulling from library/nginx

fc7181108d40: Pull complete

d2e987ca2267: Pull complete

0b760b431b11: Pull complete

Digest: sha256:48cbeee0cb0a3b5e885e36222f969e0a2f41819a68e07aeb6631ca7cb356fed1

Status: Downloaded newer image for nginx:latest

# docker run -d -p 8080:80 nginx

73ff0c8bea6e169d1801afe807e909d4c84793962cba18dd022bfad9545ad488

# docker ps

CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                  NAMES

73ff0c8bea6e        nginx               "nginx -g 'daemon of…"   2 minutes ago       Up 2 minutes        0.0.0.0:8080->80/tcp   modest_payne

# curl http://localhost:8080

<!DOCTYPE html>

<html>

<head>

<title>Welcome to nginx!</title>

这次 nginx 镜像运行的方式和操作系统不太一样，一个是 -d，因为它是一个应用，不需要像操作系统那样有交互命令行，而是以后台方式运行，-d 就是 daemon 的意思。

另外一个就是端口 -p 8080:80。容器这么容易启动，每台机器上可以启动 N 个 nginx。大家都监听 80 端口，不就冲突了吗？所以我们要设置端口，冒号后面的 80 是容器内部环境监听的端口，冒号前面的 8080 是宿主机上监听的端口。

一旦容器启动起来之后，通过 docker ps 就可以查看都有哪些容器正在运行。

接下来，我们通过 curl 命令，访问本机的 8080 端口，可以打印出 nginx 的欢迎页面。

docker run 一下，应用就启动起来了，是不是非常方便？nginx 是已经有人打包好的容器镜像，放在公共的镜像仓库里面。如果是你自己开发的应用，应该如何打包成为镜像呢？

因为 Java 代码比较麻烦，我们这里举一个简单的例子。假设你自己写的 HTML 的文件就是代码。

<!DOCTYPE html>

<html>

  <head>

    <title>Welcome to nginx Test 7!</title>

    <style>

      body {

        width: 35em;

        margin: 0 auto;

        font-family: Tahoma, Verdana, Arial, sans-serif;

      }

    </style>

  </head>

  <body>

    <h1>Test 7</h1>

    <p>If you see this page, the nginx web server is successfully installed and

    working. Further configuration is required.</p>

    <p>For online documentation and support please refer to

    <a href="http://nginx.org/">nginx.org</a>.<br/>

    Commercial support is available at

    <a href="http://nginx.com/">nginx.com</a>.</p>

    <p><em>Thank you for using nginx.</em></p>

  </body>

</html>

那我们如何将这些代码放到容器镜像里面呢？要通过 Dockerfile，Dockerfile 的格式应该包含下面的部分：

FROM 基础镜像

RUN 运行过的所有命令

COPY 拷贝到容器中的资源

ENTRYPOINT 前台启动的命令或者脚本

按照上面说的格式，可以有下面的 Dockerfile。

FROM ubuntu:14.04

RUN echo "deb http://archive.ubuntu.com/ubuntu trusty main restricted universe multiverse" > /etc/apt/sources.list

RUN echo "deb http://archive.ubuntu.com/ubuntu trusty-updates main restricted universe multiverse" >> /etc/apt/sources.list

RUN apt-get -y update

RUN apt-get -y install nginx

COPY test.html /usr/share/nginx/html/test.html

ENTRYPOINT nginx -g "daemon off;"

将代码、Dockerfile、脚本，放在一个文件夹下，以上面的 Dockerfile 为例子。

[nginx]# ls

Dockerfile  test.html

现在我们编译这个 Dockerfile。

docker build -f Dockerfile -t testnginx:1 .

编译过后，我们就有了一个新的镜像。

# docker images

REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE

testnginx           1                   3b0e5da1a384        11 seconds ago      221MB

nginx               latest              f68d6e55e065        13 days ago         109MB

ubuntu              14.04               2c5e00d77a67        2 months ago        188MB

centos              6                   d0957ffdf8a2        4 months ago        194MB

接下来，我们就可以运行这个新的镜像。

# docker run -d -p 8081:80 testnginx:1

f604f0e34bc263bc32ba683d97a1db2a65de42ab052da16df3c7811ad07f0dc3

# docker ps

CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                  NAMES

f604f0e34bc2        testnginx:1         "/bin/sh -c 'nginx -…"   2 seconds ago       Up 2 seconds        0.0.0.0:8081->80/tcp   youthful_torvalds

73ff0c8bea6e        nginx               "nginx -g 'daemon of…"   33 minutes ago      Up 33 minutes       0.0.0.0:8080->80/tcp   modest_payne

我们再来访问我们在 nginx 里面写的代码。

[root@deployer nginx]# curl http://localhost:8081/test.html

<!DOCTYPE html>

<html>

  <head>

    <title>Welcome to nginx Test 7!</title>

看，我们的代码已经运行起来了。是不是很酷？

其实这种运行方式又更加酷的功能。

第一就是持续集成。

想象一下，你写了一个程序，然后把它打成了上面一样的镜像。你在本地一运行 docker run 就把他运行起来了。接下来，你交给测试的就不是一个“程序包 + 配置 + 手册”了，而是一个容器镜像了。测试小伙伴同样通过 docker run 也就运行起来了，不存在“你这里跑的起来，他那里跑不起来的情况”。测试完了再上生产，交给运维的小伙伴也是这样一个镜像，同样的运行同样的顺畅。这种模式使得软件的交付效率大大提高，可以一天发布多次。

第二就是弹性伸缩。

想象一下，你写了一个程序，平时用的人少，只需要 10 个副本就能够扛住了。突然有一天，要做促销，需要 100 个副本，另外 90 台机器创建出来相对比较容易，用任何一个云都可以做到，但是里面 90 个副本的应用如何部署呢？一个个上去手动部署吗？有了容器就方便多了，只要在每台机器上 docker run 一下就搞定了。

第三就是跨云迁移。

如果你不相信任何一个云，怕被一个云绑定，怕一个云挂了自己的应用也就挂了。那我们想一想，该怎么办呢？你只能手动在一个云上部署一份，在另外一个云上也部署一份。有了容器了之后，由于容器镜像对于云是中立的，你在这个云上 docker run，就在这个云上提供服务，等哪天想用另一个云了，不用怕应用迁移不走，只要在另外一个云上 docker run 一下就解决了。

到现在，是不是能够感受到容器的集装箱功能了，这就是看起来隔离的作用。

你可能会问，多个容器运行在一台机器上，不会相互影响吗？如何限制 CPU 和内存的使用呢？

Docker 本身提供了这样的功能。Docker 可以限制对于 CPU 的使用，我们可以分几种的方式。

Docker 允许用户为每个容器设置一个数字，代表容器的 CPU share，默认情况下每个容器的 share 是 1024。这个数值是相对的，本身并不能代表任何确定的意义。当主机上有多个容器运行时，每个容器占用的 CPU 时间比例为它的 share 在总额中的比例。Docker 为容器设置 CPU share 的参数是 -c --cpu-shares。

Docker 提供了 --cpus 参数可以限定容器能使用的 CPU 核数。

Docker 可以通过 --cpuset 参数让容器只运行在某些核上

Docker 会限制容器内存使用量，下面是一些具体的参数。

-m --memory：容器能使用的最大内存大小。

–memory-swap：容器能够使用的 swap 大小。

–memory-swappiness：默认情况下，主机可以把容器使用的匿名页 swap 出来，你可以设置一个 0-100 之间的值，代表允许 swap 出来的比例。

–memory-reservation：设置一个内存使用的 soft limit，如果 docker 发现主机内存不足，会执行 OOM (Out of Memory) 操作。这个值必须小于 --memory 设置的值。

–kernel-memory：容器能够使用的 kernel memory 大小。

–oom-kill-disable：是否运行 OOM (Out of Memory) 的时候杀死容器。只有设置了 -m，才可以把这个选项设置为 false，否则容器会耗尽主机内存，而且导致主机应用被杀死。

这就是用起来隔离的效果。

那这些看起来隔离和用起来隔离的技术，到内核里面是如何实现的呢？我们下一节仔细分析。

总结时刻

这里我们来总结一下这一节的内容。无论是容器，还是虚拟机，都依赖于内核中的技术，虚拟机依赖的是 KVM，容器依赖的是 namespace 和 cgroup 对进程进行隔离。

为了运行 Docker，有一个 daemon 进程 Docker Daemon 用于接收命令行。

为了描述 Docker 里面运行的环境和应用，有一个 Dockerfile，通过 build 命令称为容器镜像。容器镜像可以上传到镜像仓库，也可以通过 pull 命令从镜像仓库中下载现成的容器镜像。

通过 Docker run 命令将容器镜像运行为容器，通过 namespace 和 cgroup 进行隔离，容器里面不包含内核，是共享宿主机的内核的。对比虚拟机，虚拟机在 qemu 进程里面是有客户机内核的，应用运行在客户机的用户态。

课堂练习

请你试着用 Tomcat 的容器镜像启动一个 Java 网站程序，并进行访问。

欢迎留言和我分享你的疑惑和见解，也欢迎收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

57 | Namespace技术：内部创业公司应该独立运营

上一节我们讲了 Docker 的基本原理，今天我们来看一下，“看起来隔离的”技术 namespace 在内核里面是如何工作的。

既然容器是一种类似公司内部创业的技术，我们可以设想一下，如果一个创新项目要独立运营，应该成立哪些看起来独立的组织和部门呢？

首先是用户管理，咱们这个小分队应该有自己独立的用户和组管理体系，公司里面并不是任何人都知道我们在做什么。

其次是项目管理，咱们应该有自己独立的项目管理体系，不能按照大公司的来。

然后是档案管理，咱们这个创新项目的资料一定要保密，要不然创意让人家偷走了可不好。

最后就是合作部，咱们这个小分队还是要和公司其他部门或者其他公司合作的，所以需要一个外向的人来干这件事情。

对应到容器技术，为了隔离不同类型的资源，Linux 内核里面实现了以下几种不同类型的 namespace。

UTS，对应的宏为 CLONE_NEWUTS，表示不同的 namespace 可以配置不同的 hostname。

User，对应的宏为 CLONE_NEWUSER，表示不同的 namespace 可以配置不同的用户和组。

Mount，对应的宏为 CLONE_NEWNS，表示不同的 namespace 的文件系统挂载点是隔离的

PID，对应的宏为 CLONE_NEWPID，表示不同的 namespace 有完全独立的 pid，也即一个 namespace 的进程和另一个 namespace 的进程，pid 可以是一样的，但是代表不同的进程。

Network，对应的宏为 CLONE_NEWNET，表示不同的 namespace 有独立的网络协议栈。

还记得咱们启动的那个容器吗？

# docker ps

CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                  NAMES

f604f0e34bc2        testnginx:1         "/bin/sh -c 'nginx -…"   17 hours ago        Up 17 hours         0.0.0.0:8081->80/tcp   youthful_torvalds

我们可以看这个容器对应的 entrypoint 的 pid。通过 docker inspect 命令，可以看到，进程号为 58212。

[root@deployer ~]# docker inspect f604f0e34bc2

[

    {

        "Id": "f604f0e34bc263bc32ba683d97a1db2a65de42ab052da16df3c7811ad07f0dc3",

        "Created": "2019-07-15T17:43:44.158300531Z",

        "Path": "/bin/sh",

        "Args": [

            "-c",

            "nginx -g \"daemon off;\""

        ],

        "State": {

            "Status": "running",

            "Running": true,

            "Pid": 58212,

            "ExitCode": 0,

            "StartedAt": "2019-07-15T17:43:44.651756682Z",

            "FinishedAt": "0001-01-01T00:00:00Z"

        },

......

        "Name": "/youthful_torvalds",

        "RestartCount": 0,

        "Driver": "overlay2",

        "Platform": "linux",

        "HostConfig": {

            "NetworkMode": "default",

            "PortBindings": {

                "80/tcp": [

                    {

                        "HostIp": "",

                        "HostPort": "8081"

                    }

                ]

            },

......

        },

        "Config": {

            "Hostname": "f604f0e34bc2",

            "ExposedPorts": {

                "80/tcp": {}

            },

            "Image": "testnginx:1",

            "Entrypoint": [

                "/bin/sh",

                "-c",

                "nginx -g \"daemon off;\""

            ],

        },

        "NetworkSettings": {

            "Bridge": "",

            "SandboxID": "7fd3eb469578903b66687090e512958658ae28d17bce1a7cee2da3148d1dfad4",

            "Ports": {

                "80/tcp": [

                    {

                        "HostIp": "0.0.0.0",

                        "HostPort": "8081"

                    }

                ]

            },

            "Gateway": "172.17.0.1",

            "IPAddress": "172.17.0.3",

            "IPPrefixLen": 16,

            "MacAddress": "02:42:ac:11:00:03",

            "Networks": {

                "bridge": {

                    "NetworkID": "c8eef1603afb399bf17af154be202fd1e543d3772cc83ef4a1ca3f97b8bd6eda",

                    "EndpointID": "8d9bb18ca57889112e758ede193d2cfb45cbf794c9d952819763c08f8545da46",

                    "Gateway": "172.17.0.1",

                    "IPAddress": "172.17.0.3",

                    "IPPrefixLen": 16,

                    "MacAddress": "02:42:ac:11:00:03",

                }

            }

        }

    }

]

如果我们用 ps 查看机器上的 nginx 进程，可以看到 master 和 worker，worker 的父进程是 master。

# ps -ef |grep nginx

root     58212 58195  0 01:43 ?        00:00:00 /bin/sh -c nginx -g "daemon off;"

root     58244 58212  0 01:43 ?        00:00:00 nginx: master process nginx -g daemon off;

33       58250 58244  0 01:43 ?        00:00:00 nginx: worker process

33       58251 58244  0 01:43 ?        00:00:05 nginx: worker process

33       58252 58244  0 01:43 ?        00:00:05 nginx: worker process

33       58253 58244  0 01:43 ?        00:00:05 nginx: worker process

在 /proc/pid/ns 里面，我们能够看到这个进程所属于的 6 种 namespace。我们拿出两个进程来，应该可以看出来，它们属于同一个 namespace。

# ls -l /proc/58212/ns

lrwxrwxrwx 1 root root 0 Jul 16 19:19 ipc -> ipc:[4026532278]

lrwxrwxrwx 1 root root 0 Jul 16 19:19 mnt -> mnt:[4026532276]

lrwxrwxrwx 1 root root 0 Jul 16 01:43 net -> net:[4026532281]

lrwxrwxrwx 1 root root 0 Jul 16 19:19 pid -> pid:[4026532279]

lrwxrwxrwx 1 root root 0 Jul 16 19:19 user -> user:[4026531837]

lrwxrwxrwx 1 root root 0 Jul 16 19:19 uts -> uts:[4026532277]

# ls -l /proc/58253/ns

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 ipc -> ipc:[4026532278]

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 mnt -> mnt:[4026532276]

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 net -> net:[4026532281]

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 pid -> pid:[4026532279]

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 user -> user:[4026531837]

lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 uts -> uts:[4026532277]

接下来，我们来看，如何操作 namespace。这里我们重点关注 pid 和 network。

操作 namespace 的常用指令nsenter，可以用来运行一个进程，进入指定的 namespace。例如，通过下面的命令，我们可以运行 /bin/bash，并且进入 nginx 所在容器的 namespace。

# nsenter --target 58212 --mount --uts --ipc --net --pid -- env --ignore-environment -- /bin/bash

root@f604f0e34bc2:/# ip addr

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000

    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

    inet 127.0.0.1/8 scope host lo

       valid_lft forever preferred_lft forever

23: eth0@if24: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default

    link/ether 02:42:ac:11:00:03 brd ff:ff:ff:ff:ff:ff

    inet 172.17.0.3/16 brd 172.17.255.255 scope global eth0

       valid_lft forever preferred_lft forever

另一个命令是unshare，它会离开当前的 namespace，创建且加入新的 namespace，然后执行参数中指定的命令。

例如，运行下面这行命令之后，pid 和 net 都进入了新的 namespace。

unshare --mount --ipc --pid --net --mount-proc=/proc --fork /bin/bash

如果从 shell 上运行上面这行命令的话，好像没有什么变化，但是因为 pid 和 net 都进入了新的 namespace，所以我们查看进程列表和 ip 地址的时候应该会发现有所不同。

# ip addr

1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000

    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

# ps aux

USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND

root         1  0.0  0.0 115568  2136 pts/0    S    22:55   0:00 /bin/bash

root        13  0.0  0.0 155360  1872 pts/0    R+   22:55   0:00 ps aux

果真，我们看不到宿主机上的 IP 地址和网卡了，也看不到宿主机上的所有进程了。

另外，我们还可以通过函数操作 namespace。

第一个函数是clone，也就是创建一个新的进程，并把它放到新的 namespace 中。

int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);

clone 函数我们原来介绍过。这里面有一个参数 flags，原来我们没有注意它。其实它可以设置为 CLONE_NEWUTS、CLONE_NEWUSER、CLONE_NEWNS、CLONE_NEWPID。CLONE_NEWNET 会将 clone 出来的新进程放到新的 namespace 中。

第二个函数是setns，用于将当前进程加入到已有的 namespace 中。

int setns(int fd, int nstype);

其中，fd 指向 /proc/[pid]/ns/ 目录里相应 namespace 对应的文件，表示要加入哪个 namespace。nstype 用来指定 namespace 的类型，可以设置为 CLONE_NEWUTS、CLONE_NEWUSER、CLONE_NEWNS、CLONE_NEWPID 和 CLONE_NEWNET。

第三个函数是unshare，它可以使当前进程退出当前的 namespace，并加入到新创建的 namespace。

int unshare(int flags);

其中，flags 用于指定一个或者多个上面的 CLONE_NEWUTS、CLONE_NEWUSER、CLONE_NEWNS、CLONE_NEWPID 和 CLONE_NEWNET。

clone 和 unshare 的区别是，unshare 是使当前进程加入新的 namespace；clone 是创建一个新的子进程，然后让子进程加入新的 namespace，而当前进程保持不变。

这里我们尝试一下，通过 clone 函数来进入一个 namespace。

#define _GNU_SOURCE

#include <sys/wait.h>

#include <sys/utsname.h>

#include <sched.h>

#include <string.h>

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#define STACK_SIZE (1024 * 1024)

static int childFunc(void *arg)

{

    printf("In child process.\n");

    execlp("bash", "bash", (char *) NULL);

    return 0;

}

int main(int argc, char *argv[])

{

    char *stack;

    char *stackTop;

    pid_t pid;

    stack = malloc(STACK_SIZE);

    if (stack == NULL)

    {

        perror("malloc");

        exit(1);

    }

    stackTop = stack + STACK_SIZE;

    pid = clone(childFunc, stackTop, CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWNET|SIGCHLD, NULL);

    if (pid == -1)

    {

        perror("clone");

        exit(1);

    }

    printf("clone() returned %ld\n", (long) pid);

    sleep(1);

    if (waitpid(pid, NULL, 0) == -1)

    {

        perror("waitpid");

        exit(1);

    }

    printf("child has terminated\n");

    exit(0);

}

在上面的代码中，我们调用 clone 的时候，给的参数是 CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWNET，也就是说，我们会进入一个新的 pid、network，以及 mount 的 namespace。

如果我们编译运行它，可以得到下面的结果。

# echo $$

64267

# ps aux | grep bash | grep -v grep

root     64267  0.0  0.0 115572  2176 pts/0    Ss   16:53   0:00 -bash

# ./a.out

clone() returned 64360

In child process.

# echo $$

1

# ip addr

1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000

    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

# exit

exit

child has terminated

# echo $$

64267

通过 echo $$，我们可以得到当前 bash 的进程号。一旦运行了上面的程序，我们就会进入一个新的 pid 的 namespace。

当我们再次 echo

的时候，就会发现当前bash的进程号变成了1。上面的程序运行了一个新的bash，它在一个独立的pidnamespace里面，自己是1号进程。如果运行ipaddr，可以看到，宿主机的网卡都找不到了，因为新的bash也在一个独立的networknamespace里面，等退出了，再次echo的时候，就会发现当前bash的进程号变成了1。上面的程序运行了一个新的bash，它在一个独立的pidnamespace里面，自己是1号进程。如果运行ipaddr，可以看到，宿主机的网卡都找不到了，因为新的bash也在一个独立的networknamespace里面，等退出了，再次echo

的时候，就可以得到原来进程号。

clone 系统调用我们在进程的创建那一节解析过，当时我们没有看关于 namespace 的代码，现在我们就来看一看，namespace 在内核做了哪些事情。

在内核里面，clone 会调用 _do_fork->copy_process->copy_namespaces，也就是说，在创建子进程的时候，有一个机会可以复制和设置 namespace。

namespace 是在哪里定义的呢？在每一个进程的 task_struct 里面，有一个指向 namespace 结构体的指针 nsproxy。

struct task_struct {

......

	/* Namespaces: */

	struct nsproxy			*nsproxy;

......

}

/*

 * A structure to contain pointers to all per-process

 * namespaces - fs (mount), uts, network, sysvipc, etc.

 *

 * The pid namespace is an exception -- it's accessed using

 * task_active_pid_ns.  The pid namespace here is the

 * namespace that children will use.

 */

struct nsproxy {

	atomic_t count;

	struct uts_namespace *uts_ns;

	struct ipc_namespace *ipc_ns;

	struct mnt_namespace *mnt_ns;

	struct pid_namespace *pid_ns_for_children;

	struct net 	     *net_ns;

	struct cgroup_namespace *cgroup_ns;

};

我们可以看到在 struct nsproxy 结构里面，有我们上面讲过的各种 namespace。

在系统初始化的时候，有一个默认的 init_nsproxy。

struct nsproxy init_nsproxy = {

	.count			= ATOMIC_INIT(1),

	.uts_ns			= &init_uts_ns,

#if defined(CONFIG_POSIX_MQUEUE) || defined(CONFIG_SYSVIPC)

	.ipc_ns			= &init_ipc_ns,

#endif

	.mnt_ns			= NULL,

	.pid_ns_for_children	= &init_pid_ns,

#ifdef CONFIG_NET

	.net_ns			= &init_net,

#endif

#ifdef CONFIG_CGROUPS

	.cgroup_ns		= &init_cgroup_ns,

#endif

};

下面，我们来看 copy_namespaces 的实现。

/*

 * called from clone.  This now handles copy for nsproxy and all

 * namespaces therein.

 */

int copy_namespaces(unsigned long flags, struct task_struct *tsk)

{

	struct nsproxy *old_ns = tsk->nsproxy;

	struct user_namespace *user_ns = task_cred_xxx(tsk, user_ns);

	struct nsproxy *new_ns;

	if (likely(!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |

			      CLONE_NEWPID | CLONE_NEWNET |

			      CLONE_NEWCGROUP)))) {

		get_nsproxy(old_ns);

		return 0;

	}

	if (!ns_capable(user_ns, CAP_SYS_ADMIN))

		return -EPERM;

......

	new_ns = create_new_namespaces(flags, tsk, user_ns, tsk->fs);

	tsk->nsproxy = new_ns;

	return 0;

}

如果 clone 的参数里面没有 CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWCGROUP，就返回原来的 namespace，调用 get_nsproxy。

接着，我们调用 create_new_namespaces。

/*

 * Create new nsproxy and all of its the associated namespaces.

 * Return the newly created nsproxy.  Do not attach this to the task,

 * leave it to the caller to do proper locking and attach it to task.

 */

static struct nsproxy *create_new_namespaces(unsigned long flags,

	struct task_struct *tsk, struct user_namespace *user_ns,

	struct fs_struct *new_fs)

{

	struct nsproxy *new_nsp;

	new_nsp = create_nsproxy();

......

	new_nsp->mnt_ns = copy_mnt_ns(flags, tsk->nsproxy->mnt_ns, user_ns, new_fs);

......

	new_nsp->uts_ns = copy_utsname(flags, user_ns, tsk->nsproxy->uts_ns);

......

	new_nsp->ipc_ns = copy_ipcs(flags, user_ns, tsk->nsproxy->ipc_ns);

......

	new_nsp->pid_ns_for_children =

		copy_pid_ns(flags, user_ns, tsk->nsproxy->pid_ns_for_children);

......

	new_nsp->cgroup_ns = copy_cgroup_ns(flags, user_ns,

					    tsk->nsproxy->cgroup_ns);

......

	new_nsp->net_ns = copy_net_ns(flags, user_ns, tsk->nsproxy->net_ns);

......

	return new_nsp;

......

}

在 create_new_namespaces 中，我们可以看到对于各种 namespace 的复制。

我们来看 copy_pid_ns 对于 pid namespace 的复制。

struct pid_namespace *copy_pid_ns(unsigned long flags,

	struct user_namespace *user_ns, struct pid_namespace *old_ns)

{

	if (!(flags & CLONE_NEWPID))

		return get_pid_ns(old_ns);

	if (task_active_pid_ns(current) != old_ns)

		return ERR_PTR(-EINVAL);

	return create_pid_namespace(user_ns, old_ns);

}

在 copy_pid_ns 中，如果没有设置 CLONE_NEWPID，则返回老的 pid namespace；如果设置了，就调用 create_pid_namespace，创建新的 pid namespace.

我们再来看 copy_net_ns 对于 network namespace 的复制。

struct net *copy_net_ns(unsigned long flags,

			struct user_namespace *user_ns, struct net *old_net)

{

	struct ucounts *ucounts;

	struct net *net;

	int rv;

	if (!(flags & CLONE_NEWNET))

		return get_net(old_net);

	ucounts = inc_net_namespaces(user_ns);

......

	net = net_alloc();

......

	get_user_ns(user_ns);

	net->ucounts = ucounts;

	rv = setup_net(net, user_ns);

......

	return net;

}

在这里面，我们需要判断，如果 flags 中不包含 CLONE_NEWNET，也就是不会创建一个新的 network namespace，则返回 old_net；否则需要新建一个 network namespace。

然后，copy_net_ns 会调用 net = net_alloc()，分配一个新的 struct net 结构，然后调用 setup_net 对新分配的 net 结构进行初始化，之后调用 list_add_tail_rcu，将新建的 network namespace，添加到全局的 network namespace 列表 net_namespace_list 中。

我们来看一下 setup_net 的实现。

/*

 * setup_net runs the initializers for the network namespace object.

 */

static __net_init int setup_net(struct net *net, struct user_namespace *user_ns)

{

	/* Must be called with net_mutex held */

	const struct pernet_operations *ops, *saved_ops;

	LIST_HEAD(net_exit_list);

	atomic_set(&net->count, 1);

	refcount_set(&net->passive, 1);

	net->dev_base_seq = 1;

	net->user_ns = user_ns;

	idr_init(&net->netns_ids);

	spin_lock_init(&net->nsid_lock);

	list_for_each_entry(ops, &pernet_list, list) {

		error = ops_init(ops, net);

......

	}

......

}

在 setup_net 中，这里面有一个循环 list_for_each_entry，对于 pernet_list 的每一项 struct pernet_operations，运行 ops_init，也就是调用 pernet_operations 的 init 函数。

这个 pernet_list 是怎么来的呢？在网络设备初始化的时候，我们要调用 net_dev_init 函数，这里面有下面的代码。

register_pernet_device(&loopback_net_ops)

int register_pernet_device(struct pernet_operations *ops)

{

	int error;

	mutex_lock(&net_mutex);

	error = register_pernet_operations(&pernet_list, ops);

	if (!error && (first_device == &pernet_list))

		first_device = &ops->list;

	mutex_unlock(&net_mutex);

	return error;

}

struct pernet_operations __net_initdata loopback_net_ops = {

        .init = loopback_net_init,

};

register_pernet_device 函数注册了一个 loopback_net_ops，在这里面，把 init 函数设置为 loopback_net_init.

static __net_init int loopback_net_init(struct net *net)

{

        struct net_device *dev;

        dev = alloc_netdev(0, "lo", NET_NAME_UNKNOWN, loopback_setup);

......

        dev_net_set(dev, net);

        err = register_netdev(dev);

......

        net->loopback_dev = dev;

        return 0;

......

}

在 loopback_net_init 函数中，我们会创建并且注册一个名字为"lo"的 struct net_device。注册完之后，在这个 namespace 里面就会出现一个这样的网络设备，称为 loopback 网络设备。

这就是为什么上面的实验中，创建出的新的 network namespace 里面有一个 lo 网络设备。

总结时刻

这一节我们讲了 namespace 相关的技术，有六种类型，分别是 UTS、User、Mount、Pid、Network 和 IPC。

还有两个常用的命令 nsenter 和 unshare，主要用于操作 Namespace，有三个常用的函数 clone、setns 和 unshare。

在内核里面，对于任何一个进程 task_struct 来讲，里面都会有一个成员 struct nsproxy，用于保存 namespace 相关信息，里面有 struct uts_namespace、struct ipc_namespace、struct mnt_namespace、struct pid_namespace、struct net *net_ns 和 struct cgroup_namespace *cgroup_ns。

创建 namespace 的时候，我们在内核中会调用 copy_namespaces，调用顺序依次是 copy_mnt_ns、copy_utsname、copy_ipcs、copy_pid_ns、copy_cgroup_ns 和 copy_net_ns，来复制 namespace。

课堂练习

网络的 Namespace 有一个非常好的命令 ip netns。请你研究一下这个命令，并且创建一个容器，用这个命令查看网络 namespace。

欢迎留言和我分享你的疑惑和见解，也欢迎收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

58 | CGroup技术：内部创业公司应该独立核算成本

我们前面说了，容器实现封闭的环境主要要靠两种技术，一种是“看起来是隔离”的技术 Namespace，另一种是用起来是隔离的技术 CGroup。

上一节我们讲了“看起来隔离“的技术 Namespace，这一节我们就来看一下“用起来隔离“的技术 CGroup。

CGroup 全称是 Control Group，顾名思义，它是用来做“控制”的。控制什么东西呢？当然是资源的使用了。那它都能控制哪些资源的使用呢？我们一起来看一看。

首先，cgroups 定义了下面的一系列子系统，每个子系统用于控制某一类资源。

cpu 子系统，主要限制进程的 cpu 使用率。

cpuacct 子系统，可以统计 cgroups 中的进程的 cpu 使用报告。

cpuset 子系统，可以为 cgroups 中的进程分配单独的 cpu 节点或者内存节点。

memory 子系统，可以限制进程的 memory 使用量。

blkio 子系统，可以限制进程的块设备 io。

devices 子系统，可以控制进程能够访问某些设备。

net_cls 子系统，可以标记 cgroups 中进程的网络数据包，然后可以使用 tc 模块（traffic control）对数据包进行控制。

freezer 子系统，可以挂起或者恢复 cgroups 中的进程。

这么多子系统，你可能要说了，那我们不用都掌握吧？没错，这里面最常用的是对于 CPU 和内存的控制，所以下面我们详细来说它。

在容器这一章的第一节，我们讲了，Docker 有一些参数能够限制 CPU 和内存的使用，如果把它落地到 Cgroup 里面会如何限制呢？

为了验证 Docker 的参数与 Cgroup 的映射关系，我们运行一个命令特殊的 docker run 命令，这个命令比较长，里面的参数都会映射为 cgroup 的某项配置，然后我们运行 docker ps，可以看到，这个容器的 id 为 3dc0601189dd。

docker run -d --cpu-shares 513 --cpus 2 --cpuset-cpus 1,3 --memory 1024M --memory-swap 1234M --memory-swappiness 7 -p 8081:80 testnginx:1

# docker ps

CONTAINER ID        IMAGE               COMMAND                  CREATED              STATUS              PORTS                  NAMES

3dc0601189dd        testnginx:1         "/bin/sh -c 'nginx -…"   About a minute ago   Up About a minute   0.0.0.0:8081->80/tcp   boring_cohen

在 Linux 上，为了操作 Cgroup，有一个专门的 Cgroup 文件系统，我们运行 mount 命令可以查看。

# mount -t cgroup

cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)

cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_prio,net_cls)

cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)

cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)

cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)

cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu)

cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)

cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)

cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)

cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)

cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)

cgroup 文件系统多挂载到 /sys/fs/cgroup 下，通过上面的命令行，我们可以看到我们可以用 cgroup 控制哪些资源。

对于 CPU 的控制，我在这一章的第一节讲过，Docker 可以控制 cpu-shares、cpus 和 cpuset。

我们在 /sys/fs/cgroup/ 下面能看到下面的目录结构。

drwxr-xr-x 5 root root  0 May 30 17:00 blkio

lrwxrwxrwx 1 root root 11 May 30 17:00 cpu -> cpu,cpuacct

lrwxrwxrwx 1 root root 11 May 30 17:00 cpuacct -> cpu,cpuacct

drwxr-xr-x 5 root root  0 May 30 17:00 cpu,cpuacct

drwxr-xr-x 3 root root  0 May 30 17:00 cpuset

drwxr-xr-x 5 root root  0 May 30 17:00 devices

drwxr-xr-x 3 root root  0 May 30 17:00 freezer

drwxr-xr-x 3 root root  0 May 30 17:00 hugetlb

drwxr-xr-x 5 root root  0 May 30 17:00 memory

lrwxrwxrwx 1 root root 16 May 30 17:00 net_cls -> net_cls,net_prio

drwxr-xr-x 3 root root  0 May 30 17:00 net_cls,net_prio

lrwxrwxrwx 1 root root 16 May 30 17:00 net_prio -> net_cls,net_prio

drwxr-xr-x 3 root root  0 May 30 17:00 perf_event

drwxr-xr-x 5 root root  0 May 30 17:00 pids

drwxr-xr-x 5 root root  0 May 30 17:00 systemd

我们可以想象，CPU 的资源控制的配置文件，应该在 cpu,cpuacct 这个文件夹下面。

# ls

cgroup.clone_children  cpu.cfs_period_us  notify_on_release

cgroup.event_control   cpu.cfs_quota_us   release_agent

cgroup.procs           cpu.rt_period_us   system.slice

cgroup.sane_behavior   cpu.rt_runtime_us  tasks

cpuacct.stat           cpu.shares         user.slice

cpuacct.usage          cpu.stat

cpuacct.usage_percpu   docker

果真，这下面是对 cpu 的相关控制，里面还有一个路径叫 docker。我们进入这个路径。

]# ls

cgroup.clone_children

cgroup.event_control

cgroup.procs

cpuacct.stat

cpuacct.usage

cpuacct.usage_percpu

cpu.cfs_period_us

cpu.cfs_quota_us

cpu.rt_period_us

cpu.rt_runtime_us

cpu.shares

cpu.stat

3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd

notify_on_release

tasks

这里面有个很长的 id，是我们创建的 docker 的 id。

[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# ls

cgroup.clone_children  cpuacct.usage_percpu  cpu.shares

cgroup.event_control   cpu.cfs_period_us     cpu.stat

cgroup.procs           cpu.cfs_quota_us      notify_on_release

cpuacct.stat           cpu.rt_period_us      tasks

cpuacct.usage          cpu.rt_runtime_us

在这里，我们能看到 cpu.shares，还有一个重要的文件 tasks。这里面是这个容器里所有进程的进程号，也即所有这些进程都被这些 cpu 策略控制。

[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat tasks

39487

39520

39526

39527

39528

39529

如果我们查看 cpu.shares，里面就是我们设置的 513。

[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat cpu.shares

513

另外，我们还配置了 cpus，这个值其实是由 cpu.cfs_period_us 和 cpu.cfs_quota_us 共同决定的。cpu.cfs_period_us 是运行周期，cpu.cfs_quota_us 是在周期内这些进程占用多少时间。我们设置了 cpus 为 2，代表的意思是，在周期 100000 毫秒的运行周期内，这些进程要占用 200000 毫秒的时间，也即需要两个 CPU 同时运行一个整整的周期。

[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat cpu.cfs_period_us

100000

[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat cpu.cfs_quota_us

200000

对于 cpuset，也即 cpu 绑核的参数，在另外一个文件夹里面 /sys/fs/cgroup/cpuset，这里面同样有一个 docker 文件夹，下面同样有 docker id 也即 3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd 文件夹，这里面的 cpuset.cpus 就是配置的绑定到 1、3 两个核。

[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat cpuset.cpus

1,3

这一章的第一节我们还讲了 Docker 可以限制内存的使用量，例如 memory、memory-swap、memory-swappiness。这些在哪里控制呢？

/sys/fs/cgroup/ 下面还有一个 memory 路径，控制策略就是在这里面定义的。

[root@deployer memory]# ls

cgroup.clone_children               memory.memsw.failcnt

cgroup.event_control                memory.memsw.limit_in_bytes

cgroup.procs                        memory.memsw.max_usage_in_bytes

cgroup.sane_behavior                memory.memsw.usage_in_bytes

docker                              memory.move_charge_at_immigrate

memory.failcnt                      memory.numa_stat

memory.force_empty                  memory.oom_control

memory.kmem.failcnt                 memory.pressure_level

memory.kmem.limit_in_bytes          memory.soft_limit_in_bytes

memory.kmem.max_usage_in_bytes      memory.stat

memory.kmem.slabinfo                memory.swappiness

memory.kmem.tcp.failcnt             memory.usage_in_bytes

memory.kmem.tcp.limit_in_bytes      memory.use_hierarchy

memory.kmem.tcp.max_usage_in_bytes  notify_on_release

memory.kmem.tcp.usage_in_bytes      release_agent

memory.kmem.usage_in_bytes          system.slice

memory.limit_in_bytes               tasks

memory.max_usage_in_bytes           user.slice

这里面全是对于 memory 的控制参数，在这里面我们可看到了 docker，里面还有容器的 id 作为文件夹。

[docker]# ls

3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd

cgroup.clone_children

cgroup.event_control

cgroup.procs

memory.failcnt

memory.force_empty

memory.kmem.failcnt

memory.kmem.limit_in_bytes

memory.kmem.max_usage_in_bytes

memory.kmem.slabinfo

memory.kmem.tcp.failcnt

memory.kmem.tcp.limit_in_bytes

memory.kmem.tcp.max_usage_in_bytes

memory.kmem.tcp.usage_in_bytes

memory.kmem.usage_in_bytes

memory.limit_in_bytes

memory.max_usage_in_bytes

memory.memsw.failcnt

memory.memsw.limit_in_bytes

memory.memsw.max_usage_in_bytes

memory.memsw.usage_in_bytes

memory.move_charge_at_immigrate

memory.numa_stat

memory.oom_control

memory.pressure_level

memory.soft_limit_in_bytes

memory.stat

memory.swappiness

memory.usage_in_bytes

memory.use_hierarchy

notify_on_release

tasks

[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# ls

cgroup.clone_children               memory.memsw.failcnt

cgroup.event_control                memory.memsw.limit_in_bytes

cgroup.procs                        memory.memsw.max_usage_in_bytes

memory.failcnt                      memory.memsw.usage_in_bytes

memory.force_empty                  memory.move_charge_at_immigrate

memory.kmem.failcnt                 memory.numa_stat

memory.kmem.limit_in_bytes          memory.oom_control

memory.kmem.max_usage_in_bytes      memory.pressure_level

memory.kmem.slabinfo                memory.soft_limit_in_bytes

memory.kmem.tcp.failcnt             memory.stat

memory.kmem.tcp.limit_in_bytes      memory.swappiness

memory.kmem.tcp.max_usage_in_bytes  memory.usage_in_bytes

memory.kmem.tcp.usage_in_bytes      memory.use_hierarchy

memory.kmem.usage_in_bytes          notify_on_release

memory.limit_in_bytes               tasks

memory.max_usage_in_bytes

在 docker id 的文件夹下面，有一个 memory.limit_in_bytes，里面配置的就是 memory。

[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat memory.limit_in_bytes

1073741824

还有 memory.swappiness，里面配置的就是 memory-swappiness。

[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat memory.swappiness

7

还有就是 memory.memsw.limit_in_bytes，里面配置的是 memory-swap。

[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat memory.memsw.limit_in_bytes

1293942784

我们还可以看一下 tasks 文件的内容，tasks 里面是容器里面所有进程的进程号。

[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat tasks

39487

39520

39526

39527

39528

39529

至此，我们看到了 cgroup 对于 Docker 资源的控制，在用户态是如何表现的。我画了一张图总结一下。

在内核中，cgroup 是如何实现的呢？

首先，在系统初始化的时候，cgroup 也会进行初始化，在 start_kernel 中，cgroup_init_early 和 cgroup_init 都会进行初始化。

asmlinkage __visible void __init start_kernel(void)

{

......

  cgroup_init_early();

......

  cgroup_init();

......

}

在 cgroup_init_early 和 cgroup_init 中，会有下面的循环。

for_each_subsys(ss, i) {

	ss->id = i;

	ss->name = cgroup_subsys_name[i];

......

	cgroup_init_subsys(ss, true);

}

#define for_each_subsys(ss, ssid)					\

	for ((ssid) = 0; (ssid) < CGROUP_SUBSYS_COUNT &&		\

	     (((ss) = cgroup_subsys[ssid]) || true); (ssid)++)

for_each_subsys 会在 cgroup_subsys 数组中进行循环。这个 cgroup_subsys 数组是如何形成的呢？

#define SUBSYS(_x) [_x ## _cgrp_id] = &_x ## _cgrp_subsys,

struct cgroup_subsys *cgroup_subsys[] = {

#include <linux/cgroup_subsys.h>

};

#undef SUBSYS

SUBSYS 这个宏定义了这个 cgroup_subsys 数组，数组中的项定义在 cgroup_subsys.h 头文件中。例如，对于 CPU 和内存有下面的定义。

//cgroup_subsys.h

#if IS_ENABLED(CONFIG_CPUSETS)

SUBSYS(cpuset)

#endif

#if IS_ENABLED(CONFIG_CGROUP_SCHED)

SUBSYS(cpu)

#endif

#if IS_ENABLED(CONFIG_CGROUP_CPUACCT)

SUBSYS(cpuacct)

#endif

#if IS_ENABLED(CONFIG_MEMCG)

SUBSYS(memory)

#endif

根据 SUBSYS 的定义，SUBSYS(cpu) 其实是 [cpu_cgrp_id] = &cpu_cgrp_subsys，而 SUBSYS(memory) 其实是 [memory_cgrp_id] = &memory_cgrp_subsys。

我们能够找到 cpu_cgrp_subsys 和 memory_cgrp_subsys 的定义。

cpuset_cgrp_subsys

struct cgroup_subsys cpuset_cgrp_subsys = {

	.css_alloc	= cpuset_css_alloc,

	.css_online	= cpuset_css_online,

	.css_offline	= cpuset_css_offline,

	.css_free	= cpuset_css_free,

	.can_attach	= cpuset_can_attach,

	.cancel_attach	= cpuset_cancel_attach,

	.attach		= cpuset_attach,

	.post_attach	= cpuset_post_attach,

	.bind		= cpuset_bind,

	.fork		= cpuset_fork,

	.legacy_cftypes	= files,

	.early_init	= true,

};

cpu_cgrp_subsys

struct cgroup_subsys cpu_cgrp_subsys = {

	.css_alloc	= cpu_cgroup_css_alloc,

	.css_online	= cpu_cgroup_css_online,

	.css_released	= cpu_cgroup_css_released,

	.css_free	= cpu_cgroup_css_free,

	.fork		= cpu_cgroup_fork,

	.can_attach	= cpu_cgroup_can_attach,

	.attach		= cpu_cgroup_attach,

	.legacy_cftypes	= cpu_files,

	.early_init	= true,

};

memory_cgrp_subsys

struct cgroup_subsys memory_cgrp_subsys = {

	.css_alloc = mem_cgroup_css_alloc,

	.css_online = mem_cgroup_css_online,

	.css_offline = mem_cgroup_css_offline,

	.css_released = mem_cgroup_css_released,

	.css_free = mem_cgroup_css_free,

	.css_reset = mem_cgroup_css_reset,

	.can_attach = mem_cgroup_can_attach,

	.cancel_attach = mem_cgroup_cancel_attach,

	.post_attach = mem_cgroup_move_task,

	.bind = mem_cgroup_bind,

	.dfl_cftypes = memory_files,

	.legacy_cftypes = mem_cgroup_legacy_files,

	.early_init = 0,

};

在 for_each_subsys 的循环里面，cgroup_subsys[] 数组中的每一个 cgroup_subsys，都会调用 cgroup_init_subsys，对于 cgroup_subsys 对于初始化。

static void __init cgroup_init_subsys(struct cgroup_subsys *ss, bool early)

{

	struct cgroup_subsys_state *css;

......

	idr_init(&ss->css_idr);

	INIT_LIST_HEAD(&ss->cfts);

	/* Create the root cgroup state for this subsystem */

	ss->root = &cgrp_dfl_root;

	css = ss->css_alloc(cgroup_css(&cgrp_dfl_root.cgrp, ss));

......

	init_and_link_css(css, ss, &cgrp_dfl_root.cgrp);

......

	css->id = cgroup_idr_alloc(&ss->css_idr, css, 1, 2, GFP_KERNEL);

	init_css_set.subsys[ss->id] = css;

......

	BUG_ON(online_css(css));

......

}

cgroup_init_subsys 里面会做两件事情，一个是调用 cgroup_subsys 的 css_alloc 函数创建一个 cgroup_subsys_state；另外就是调用 online_css，也即调用 cgroup_subsys 的 css_online 函数，激活这个 cgroup。

对于 CPU 来讲，css_alloc 函数就是 cpu_cgroup_css_alloc。这里面会调用 sched_create_group 创建一个 struct task_group。在这个结构中，第一项就是 cgroup_subsys_state，也就是说，task_group 是 cgroup_subsys_state 的一个扩展，最终返回的是指向 cgroup_subsys_state 结构的指针，可以通过强制类型转换变为 task_group。

struct task_group {

	struct cgroup_subsys_state css;

#ifdef CONFIG_FAIR_GROUP_SCHED

	/* schedulable entities of this group on each cpu */

	struct sched_entity **se;

	/* runqueue "owned" by this group on each cpu */

	struct cfs_rq **cfs_rq;

	unsigned long shares;

#ifdef	CONFIG_SMP

	atomic_long_t load_avg ____cacheline_aligned;

#endif

#endif

	struct rcu_head rcu;

	struct list_head list;

	struct task_group *parent;

	struct list_head siblings;

	struct list_head children;

	struct cfs_bandwidth cfs_bandwidth;

};

在 task_group 结构中，有一个成员是 sched_entity，前面我们讲进程调度的时候，遇到过它。它是调度的实体，也即这一个 task_group 也是一个调度实体。

接下来，online_css 会被调用。对于 CPU 来讲，online_css 调用的是 cpu_cgroup_css_online。它会调用 sched_online_group->online_fair_sched_group。

void online_fair_sched_group(struct task_group *tg)

{

	struct sched_entity *se;

	struct rq *rq;

	int i;

	for_each_possible_cpu(i) {

		rq = cpu_rq(i);

		se = tg->se[i];

		update_rq_clock(rq);

		attach_entity_cfs_rq(se);

		sync_throttle(tg, i);

	}

}

在这里面，对于每一个 CPU，取出每个 CPU 的运行队列 rq，也取出 task_group 的 sched_entity，然后通过 attach_entity_cfs_rq 将 sched_entity 添加到运行队列中。

对于内存来讲，css_alloc 函数就是 mem_cgroup_css_alloc。这里面会调用 mem_cgroup_alloc，创建一个 struct mem_cgroup。在这个结构中，第一项就是 cgroup_subsys_state，也就是说，mem_cgroup 是 cgroup_subsys_state 的一个扩展，最终返回的是指向 cgroup_subsys_state 结构的指针，我们可以通过强制类型转换变为 mem_cgroup。

struct mem_cgroup {

	struct cgroup_subsys_state css;

	/* Private memcg ID. Used to ID objects that outlive the cgroup */

	struct mem_cgroup_id id;

	/* Accounted resources */

	struct page_counter memory;

	struct page_counter swap;

	/* Legacy consumer-oriented counters */

	struct page_counter memsw;

	struct page_counter kmem;

	struct page_counter tcpmem;

	/* Normal memory consumption range */

	unsigned long low;

	unsigned long high;

	/* Range enforcement for interrupt charges */

	struct work_struct high_work;

	unsigned long soft_limit;

......

	int	swappiness;

......

	/*

	 * percpu counter.

	 */

	struct mem_cgroup_stat_cpu __percpu *stat;

	int last_scanned_node;

	/* List of events which userspace want to receive */

	struct list_head event_list;

	spinlock_t event_list_lock;

	struct mem_cgroup_per_node *nodeinfo[0];

	/* WARNING: nodeinfo must be the last member here */

};

在 cgroup_init 函数中，cgroup 的初始化还做了一件很重要的事情，它会调用 cgroup_init_cftypes(NULL, cgroup1_base_files)，来初始化对于 cgroup 文件类型 cftype 的操作函数，也就是将 struct kernfs_ops *kf_ops 设置为 cgroup_kf_ops。

struct cftype cgroup1_base_files[] = {

......

    {

        .name = "tasks",

        .seq_start = cgroup_pidlist_start,

        .seq_next = cgroup_pidlist_next,

        .seq_stop = cgroup_pidlist_stop,

        .seq_show = cgroup_pidlist_show,

        .private = CGROUP_FILE_TASKS,

        .write = cgroup_tasks_write,

    },

}

static struct kernfs_ops cgroup_kf_ops = {

	.atomic_write_len	= PAGE_SIZE,

	.open			= cgroup_file_open,

	.release		= cgroup_file_release,

	.write			= cgroup_file_write,

	.seq_start		= cgroup_seqfile_start,

	.seq_next		= cgroup_seqfile_next,

	.seq_stop		= cgroup_seqfile_stop,

	.seq_show		= cgroup_seqfile_show,

};

在 cgroup 初始化完毕之后，接下来就是创建一个 cgroup 的文件系统，用了配置和操作 cgroup。

cgroup 是一种特殊的文件系统。它的定义如下：

struct file_system_type cgroup_fs_type = {

	.name = "cgroup",

	.mount = cgroup_mount,

	.kill_sb = cgroup_kill_sb,

	.fs_flags = FS_USERNS_MOUNT,

};

当我们 mount 这个 cgroup 文件系统的时候，会调用 cgroup_mount->cgroup1_mount。

struct dentry *cgroup1_mount(struct file_system_type *fs_type, int flags,

			     void *data, unsigned long magic,

			     struct cgroup_namespace *ns)

{

	struct super_block *pinned_sb = NULL;

	struct cgroup_sb_opts opts;

	struct cgroup_root *root;

	struct cgroup_subsys *ss;

	struct dentry *dentry;

	int i, ret;

	bool new_root = false;

......

	root = kzalloc(sizeof(*root), GFP_KERNEL);

	new_root = true;

	init_cgroup_root(root, &opts);

	ret = cgroup_setup_root(root, opts.subsys_mask, PERCPU_REF_INIT_DEAD);

......

	dentry = cgroup_do_mount(&cgroup_fs_type, flags, root,

				 CGROUP_SUPER_MAGIC, ns);

......

	return dentry;

}

cgroup 被组织成为树形结构，因而有 cgroup_root。init_cgroup_root 会初始化这个 cgroup_root。cgroup_root 是 cgroup 的根，它有一个成员 kf_root，是 cgroup 文件系统的根 struct kernfs_root。kernfs_create_root 就是用来创建这个 kernfs_root 结构的。

int cgroup_setup_root(struct cgroup_root *root, u16 ss_mask, int ref_flags)

{

	LIST_HEAD(tmp_links);

	struct cgroup *root_cgrp = &root->cgrp;

	struct kernfs_syscall_ops *kf_sops;

	struct css_set *cset;

	int i, ret;

	root->kf_root = kernfs_create_root(kf_sops,

					   KERNFS_ROOT_CREATE_DEACTIVATED,

					   root_cgrp);

	root_cgrp->kn = root->kf_root->kn;

	ret = css_populate_dir(&root_cgrp->self);

	ret = rebind_subsystems(root, ss_mask);

......

	list_add(&root->root_list, &cgroup_roots);

	cgroup_root_count++;

......

	kernfs_activate(root_cgrp->kn);

......

}

就像在普通文件系统上，每一个文件都对应一个 inode，在 cgroup 文件系统上，每个文件都对应一个 struct kernfs_node 结构，当然 kernfs_root 作为文件系的根也对应一个 kernfs_node 结构。

接下来，css_populate_dir 会调用 cgroup_addrm_files->cgroup_add_file->cgroup_add_file，来创建整棵文件树，并且为树中的每个文件创建对应的 kernfs_node 结构，并将这个文件的操作函数设置为 kf_ops，也即指向 cgroup_kf_ops 。

static int cgroup_add_file(struct cgroup_subsys_state *css, struct cgroup *cgrp,

			   struct cftype *cft)

{

	char name[CGROUP_FILE_NAME_MAX];

	struct kernfs_node *kn;

......

	kn = __kernfs_create_file(cgrp->kn, cgroup_file_name(cgrp, cft, name),

				  cgroup_file_mode(cft), 0, cft->kf_ops, cft,

				  NULL, key);

......

}

struct kernfs_node *__kernfs_create_file(struct kernfs_node *parent,

					 const char *name,

					 umode_t mode, loff_t size,

					 const struct kernfs_ops *ops,

					 void *priv, const void *ns,

					 struct lock_class_key *key)

{

	struct kernfs_node *kn;

	unsigned flags;

	int rc;

	flags = KERNFS_FILE;

	kn = kernfs_new_node(parent, name, (mode & S_IALLUGO) | S_IFREG, flags);

	kn->attr.ops = ops;

	kn->attr.size = size;

	kn->ns = ns;

	kn->priv = priv;

......

	rc = kernfs_add_one(kn);

......

	return kn;

}

从 cgroup_setup_root 返回后，接下来，在 cgroup1_mount 中，要做的一件事情是 cgroup_do_mount，调用 kernfs_mount 真的去 mount 这个文件系统，返回一个普通的文件系统都认识的 dentry。这种特殊的文件系统对应的文件操作函数为 kernfs_file_fops。

const struct file_operations kernfs_file_fops = {

	.read		= kernfs_fop_read,

	.write		= kernfs_fop_write,

	.llseek		= generic_file_llseek,

	.mmap		= kernfs_fop_mmap,

	.open		= kernfs_fop_open,

	.release	= kernfs_fop_release,

	.poll		= kernfs_fop_poll,

	.fsync		= noop_fsync,

};

当我们要写入一个 CGroup 文件来设置参数的时候，根据文件系统的操作，kernfs_fop_write 会被调用，在这里面会调用 kernfs_ops 的 write 函数，根据上面的定义为 cgroup_file_write，在这里会调用 cftype 的 write 函数。对于 CPU 和内存的 write 函数，有以下不同的定义。

static struct cftype cpu_files[] = {

#ifdef CONFIG_FAIR_GROUP_SCHED

    {

        .name = "shares",

        .read_u64 = cpu_shares_read_u64,

        .write_u64 = cpu_shares_write_u64,

    },

#endif

#ifdef CONFIG_CFS_BANDWIDTH

    {

        .name = "cfs_quota_us",

        .read_s64 = cpu_cfs_quota_read_s64,

        .write_s64 = cpu_cfs_quota_write_s64,

    },

    {

        .name = "cfs_period_us",

        .read_u64 = cpu_cfs_period_read_u64,

        .write_u64 = cpu_cfs_period_write_u64,

    },

}

static struct cftype mem_cgroup_legacy_files[] = {

    {

        .name = "usage_in_bytes",

        .private = MEMFILE_PRIVATE(_MEM, RES_USAGE),

        .read_u64 = mem_cgroup_read_u64,

    },

    {

        .name = "max_usage_in_bytes",

        .private = MEMFILE_PRIVATE(_MEM, RES_MAX_USAGE),

        .write = mem_cgroup_reset,

        .read_u64 = mem_cgroup_read_u64,

    },

    {

        .name = "limit_in_bytes",

        .private = MEMFILE_PRIVATE(_MEM, RES_LIMIT),

        .write = mem_cgroup_write,

        .read_u64 = mem_cgroup_read_u64,

    },

    {

        .name = "soft_limit_in_bytes",

        .private = MEMFILE_PRIVATE(_MEM, RES_SOFT_LIMIT),

        .write = mem_cgroup_write,

        .read_u64 = mem_cgroup_read_u64,

    },

}

如果设置的是 cpu.shares，则调用 cpu_shares_write_u64。在这里面，task_group 的 shares 变量更新了，并且更新了 CPU 队列上的调度实体。

int sched_group_set_shares(struct task_group *tg, unsigned long shares)

{

	int i;

	shares = clamp(shares, scale_load(MIN_SHARES), scale_load(MAX_SHARES));

	tg->shares = shares;

	for_each_possible_cpu(i) {

		struct rq *rq = cpu_rq(i);

		struct sched_entity *se = tg->se[i];

		struct rq_flags rf;

		update_rq_clock(rq);

		for_each_sched_entity(se) {

			update_load_avg(se, UPDATE_TG);

			update_cfs_shares(se);

		}

	}

......

}

但是这个时候别忘了，我们还没有将 CPU 的文件夹下面的 tasks 文件写入进程号呢。写入一个进程号到 tasks 文件里面，按照 cgroup1_base_files 里面的定义，我们应该调用 cgroup_tasks_write。

接下来的调用链为：cgroup_tasks_write->__cgroup_procs_write->cgroup_attach_task-> cgroup_migrate->cgroup_migrate_execute。将这个进程和一个 cgroup 关联起来，也即将这个进程迁移到这个 cgroup 下面。

static int cgroup_migrate_execute(struct cgroup_mgctx *mgctx)

{

	struct cgroup_taskset *tset = &mgctx->tset;

	struct cgroup_subsys *ss;

	struct task_struct *task, *tmp_task;

	struct css_set *cset, *tmp_cset;

......

	if (tset->nr_tasks) {

		do_each_subsys_mask(ss, ssid, mgctx->ss_mask) {

			if (ss->attach) {

				tset->ssid = ssid;

				ss->attach(tset);

			}

		} while_each_subsys_mask();

	}

......

}

每一个 cgroup 子系统会调用相应的 attach 函数。而 CPU 调用的是 cpu_cgroup_attach-> sched_move_task-> sched_change_group。

static void sched_change_group(struct task_struct *tsk, int type)

{

	struct task_group *tg;

	tg = container_of(task_css_check(tsk, cpu_cgrp_id, true),

			  struct task_group, css);

	tg = autogroup_task_group(tsk, tg);

	tsk->sched_task_group = tg;

#ifdef CONFIG_FAIR_GROUP_SCHED

	if (tsk->sched_class->task_change_group)

		tsk->sched_class->task_change_group(tsk, type);

	else

#endif

		set_task_rq(tsk, task_cpu(tsk));

}

在 sched_change_group 中设置这个进程以这个 task_group 的方式参与调度，从而使得上面的 cpu.shares 起作用。

对于内存来讲，写入内存的限制使用函数 mem_cgroup_write->mem_cgroup_resize_limit 来设置 struct mem_cgroup 的 memory.limit 成员。

在进程执行过程中，申请内存的时候，我们会调用 handle_pte_fault->do_anonymous_page()->mem_cgroup_try_charge()。

int mem_cgroup_try_charge(struct page *page, struct mm_struct *mm,

			  gfp_t gfp_mask, struct mem_cgroup **memcgp,

			  bool compound)

{

	struct mem_cgroup *memcg = NULL;

......

	if (!memcg)

		memcg = get_mem_cgroup_from_mm(mm);

	ret = try_charge(memcg, gfp_mask, nr_pages);

......

}

在 mem_cgroup_try_charge 中，先是调用 get_mem_cgroup_from_mm 获得这个进程对应的 mem_cgroup 结构，然后在 try_charge 中，根据 mem_cgroup 的限制，看是否可以申请分配内存。

至此，cgroup 对于内存的限制才真正起作用。

总结时刻

内核中 cgroup 的工作机制，我们在这里总结一下。

第一步，系统初始化的时候，初始化 cgroup 的各个子系统的操作函数，分配各个子系统的数据结构。

第二步，mount cgroup 文件系统，创建文件系统的树形结构，以及操作函数。

第三步，写入 cgroup 文件，设置 cpu 或者 memory 的相关参数，这个时候文件系统的操作函数会调用到 cgroup 子系统的操作函数，从而将参数设置到 cgroup 子系统的数据结构中。

第四步，写入 tasks 文件，将进程交给某个 cgroup 进行管理，因为 tasks 文件也是一个 cgroup 文件，统一会调用文件系统的操作函数进而调用 cgroup 子系统的操作函数，将 cgroup 子系统的数据结构和进程关联起来。

第五步，对于 cpu 来讲，会修改 scheduled entity，放入相应的队列里面去，从而下次调度的时候就起作用了。对于内存的 cgroup 设定，只有在申请内存的时候才起作用。

课堂练习

这里我们用 cgroup 限制了 CPU 和内存，如何限制网络呢？给你一个提示 tc，请你研究一下。

欢迎留言和我分享你的疑惑和见解，也欢迎收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

59 | 数据中心操作系统：上市敲钟

在这门课程里面，我们说了，在内核态有很多的模块，可以帮助我们管理硬件设备，最重要的四种硬件资源是 CPU、内存、存储和网络。

最初使用汇编语言的前辈，在程序中需要指定使用的硬件资源，例如，指定使用哪个寄存器、放在内存的哪个位置、写入或者读取那个串口等等。对于这些资源的使用，需要程序员自己心里非常地清楚，要不然一旦 jump 错了位置，程序就无法运行。

为了将程序员从对硬件的直接操作中解放出来，提升程序设计的效率，于是，我们有了操作系统这一层，用来实现对于硬件资源的统一管理。某个程序使用哪个 CPU、哪部分内存、哪部分硬盘，只需要调用 API 就可以了，这些都由操作系统自行分配和管理。

其实操作系统最总要的事情，就是调度。因此，在内核态就产生这些模块：进程管理子系统、内存管理子系统、文件子系统、设备子系统和网络子系统。

这些模块通过统一的 API，也就是系统调用，对上提供服务。基于这些 API，用户态有很多的工具可以帮我们使用好 Linux 操作系统，比如用户管理、软件安装、软件运行、周期性进程、文件管理、网络管理和存储管理。

但是到目前为止，我们能管理的还是少数几台机器。当我们面临数据中心成千上万台机器的时候，仍然非常“痛苦”。如果我们运维数据中心依然像的运维一台台物理机的前辈一样，天天关心哪个程序放在了哪台机器上，使用多少内存、多少硬盘，每台机器总共有多少内存、多少硬盘，还剩多少内存和硬盘，那头就大了。

因而对应到数据中心，我们也需要一个调度器，将运维人员从指定物理机或者虚拟机的痛苦中解放出来，实现对于物理资源的统一管理，这就是 Kubernetes。

Kubernetes 究竟有哪些功能，可以解放运维人员呢？为什么它能做数据中心的操作系统呢？

我列了两个表格，将操作系统的功能和模块与 Kubernetes 的功能和模块做了一个对比，你可以看看。

Kubernetes 作为数据中心的操作系统还是主要管理数据中心里面的四种硬件资源：CPU、内存、存储、网络。

对于 CPU 和内存这两种计算资源的管理，我们可以通过 Docker 技术完成。它可以将 CPU 和内存资源，通过 namespace 和 cgroup，从大的资源池里面隔离出来，并通过镜像技术，实现计算资源在数据中心里面的自由漂移。

就像我们上面说的一样，那没有操作系统的时候，汇编程序员需要指定程序运行的 CPU 和内存物理地址。同理，数据中心的管理员，原来还需要指定程序运行的服务器及使用的 CPU 和内存。现在，Kubernetes 里面有一个调度器 Scheduler，你只需要告诉它，你想运行 10 个 4 核 8G 的 Java 程序，它会自动帮你选择空闲的、有足够资源的服务器，去运行这些程序。

对于操作系统上的进程来讲，有主线程做主要的工作，还有其它线程做辅助的工作。对于数据中心里面的运行的程序来讲，可以也会有一个主要提供服务的程序，例如上面的 Java 程序，也会有一些提供辅助功能的程序；例如监控、环境预设值等。Kubernetes 将多个 Docker 组装成一个 Pod 的概念，在一个 Pod 里面，往往有一个 Docker 为主，多个 Docker 为辅。

操作系统上的进程会在 CPU 上切换来切换去，它使用的内存也会换入换出。在数据中心里面，这些运行中的程序能不能在机器之间迁移呢？能不能在一台服务器故障的时候，选择其它的服务器运行呢？反正我关心的是运行 10 个 4 核 8G 的 Java 程序，又不在乎它在哪台上运行。Kubernetes 里面有 Controller 的概念，可以控制 Pod 们的运行状态以及占用的资源，如果 10 个变 9 个就选一台机器添加一个，10 个变 11 个，就随机删除一个。

操作系统上的进程有时候有亲和性的要求，比如它可能希望再某一个 CPU 上运行，不切换 CPU 从而提高运行效率，或者两个线程要求在一个 CPU 上，从而可以使用 Per CPU 变量不加锁，交互和协作比较方便。有的时候，一个线程想避开另一个线程，不要共用 CPU，以防相互干扰。Kubernetes 的 Scheduler 也是有亲和性功能的，你可以选择两个 Pod 永远运行在一台物理机上，这样本地通信就可以了，也可以选择两个 Pod 永远不要运行在同一台物理机上，这样一个挂了不影响另一个。

你可能会问，Docker 可以将 CPU 内存资源进行抽象，在服务器之间迁移，那数据应该怎么办呢？如果数据放在每一台服务器上，其实就像散落在汪洋大海里面，用的时候根本找不到，所以必须要有统一的存储。正像一台操作系统上多个进程之间，要通过文件系统保存持久化的数据并且实现共享，在数据中心里面也需要一个这样的基础设施。

统一的存储常常有三种形式，我们分别来看。

第一种方式是对象存储。

顾名思义，这种方式是将文件作为一个完整对象的方式来保存。每一个文件对我们来说，都应该有一个唯一标识这个对象的 key，而文件的内容就是 value。对象可以分门别类地保存在一个叫作存储空间（Bucket）的地方，有点儿像文件夹。

对于任何一个文件对象，我们都可以通过 HTTP RESTful API 来远程获取对象。由于是简单的 key-value 模式，当需要保存大容量数据的时候，我们就比较容易根据唯一的 key 进行横向扩展，所以对象存储往往能够容纳的数据量非常大。在数据中心里面保存文档，视频等很好的方式，当然缺点就是，你没办法像操作文件一样操作它，而是要将 value 当成整个的来对待。

第二种方式是分布式文件系统。

这种是最容易习惯的，因为使用它和使用本地的文件系统几乎没有什么区别，只不过是通过网络的方式访问远程的文件系统。多个容器能看到统一的文件系统，一个容器写入文件系统的，另一个容器能够看到，可以实现共享。缺点是分布式文件系统的性能和规模是个矛盾，规模一大性能就难以保证，性能好则规模不会很大，所以不像对象存储一样能够保持海量的数据。

第三种方式是分布式块存储。

这是云硬盘，也即存储虚拟化的方式，只不过将盘挂载给容器而不是虚拟机。块存储没有分布式文件系统这一层，一旦挂载到某一个容器，可以有本地的文件系统，这样缺点是一般情况下，不同容器挂载的块存储都是不共享的，好处是在同样的规模的情况下，性能相对分布式文件系统要好。如果为了解决一个容器从一台服务器迁移到另一台服务器，如何保持存储的数据的问题，块存储是一个很好的选择。它不用解决多个容器共享数据的问题。

这三种形式，对象存储使用 HTTP 进行访问，当然任何容器都能访问到，不需要 Kubernetes 去管理它。而分布式文件系统和分布式块存储，就需要对接到 Kubernetes，让 Kubernetes 可以管理它们。如何对接呢？Kubernetes 提供 Container Storage Interface (CSI) 接口，这是一个标准接口，不同的存储可以实现这个接口来对接 Kubernetes。是不是特别像设备驱动程序呀。操作系统只要定义统一的接口，不同的存储设备的驱动实现这些接口，就能被操作系统使用了。

存储的问题解决了，接下来是网络。因为不同的服务器上的 Docker 还是需要互相通信的。

Kubernetes 有自己的网络模型，里面是这样规定的。

IP-per-Pod，每个 Pod 都拥有一个独立 IP 地址，Pod 内所有容器共享一个网络命名空间。

集群内所有 Pod 都在一个直接连通的扁平网络中，可通过 IP 直接访问。

所有容器之间无需 NAT 就可以直接互相访问。

所有 Node 和所有容器之间无需 NAT 就可以直接互相访问。

容器自己看到的 IP 跟其它容器看到的一样。

这其实是说，里面的每一个 Docker 访问另一个 Docker 的时候，都是感觉在一个扁平的网络里面就可以了。

要实现这样的网络模型，有很多种方式，例如 Kubernetes 自己提供 Calico、Flannel。当然，也可以对接 Openvswitch 这样的虚拟交换机，也可以使用 brctl 这种传统的桥接模式，也可以对接硬件交换机。

看，这又是一种类似驱动的模式，和操作系统面临的问题是一样的。Kubernetes 同样是提供统一的接口 Container Network Interface（CNI，容器网络接口）。无论你用哪种方式实现网络模型，只要对接这个统一的接口，Kubernetes 就可以管理容器的网络。

至此，Kubernetes 作为数据中心的操作系统，内核的问题解决了。

接下来是用户态的工具问题了。我们能不能像操作一台服务器那样操作数据中心呢？

使用操作系统，需要安装一些软件，于是，我们需要 yum 之类的包管理系统，使得软件的使用者和软件的编译者分隔开来，软件的编译者需要知道这个软件需要安装哪些包，包之间的依赖关系是什么，软件安装到什么地方，而软件的使用者仅仅需要 yum install 就可以了。Kubernetes 就有这样一套包管理软件 Helm，你可以用它来很方便的安装，升级，扩容一些数据中心里面的常用软件，例如数据库，缓存，消息队列。

使用操作系统，运行一个进程是最常见的需求。第一种进程是交互式命令行，运行起来就是执行一个任务，结束了马上返回结果。在 Kubernetes 里面有对应的概念叫做 Job，Job 负责批量处理短暂的一次性任务 (Short Lived One-off Tasks)，即仅执行一次的任务，它保证批处理任务的一个或多个 Pod 成功结束。

第二种进程是nohup 长期运行的进程。在 Kubernetes 里对应的概念是 Deployment，使用 Deployment 来创建 ReplicaSet。ReplicaSet 在后台创建 pod。也即 Doployment 里面会声明我希望某个进程以 N 的 Pod 副本的形式运行，并且长期运行，一但副本变少就会自动添加。

第三种进程是系统服务。在 Kubernetes 里面对应的概念是 DaemonSet，它保证在每个节点上都运行一个容器副本，常用来部署一些集群的日志、监控或者其它系统管理应用。

第四种进程是周期性进程，也即crontab，常常用来设置一些周期性的任务。在 Kubernetes 里面对应的概念是 CronJob 定时任务，就类似于 Linux 系统的 crontab，在指定的时间周期运行指定的任务。

使用操作系统，我们还需使用文件系统，或者使用网络发送数据，虽然在 Kubernetes 里面有 CSI 和 CNI 来对接存储和网络，在用户态，不能让用户意识到后面具体设备，而是应该有抽象的概念。

对于存储来讲，Kubernetes 有 Volume 的概念，Kubernetes Volume 的生命周期与 Pod 绑定，

容器挂掉后 Kubelet 再次重启容器时，Volume 的数据依然还在，而 Pod 删除时，Volume 才会清理。数据是否丢失取决于具体的 Volume 类型。Volume 的概念是对具体存储设备的抽象，就像当我们使用 ext4 文件系统不用管它是基于什么硬盘一样。

对于网络来讲，Kubernetes 有自己的 DNS，有 Service 的概念，Kubernetes Service 是一个 Pod 的逻辑分组，这一组 Pod 能够被 Service 访问。每一个 Service 都一个名字，Kubernetes 会将 Service 的名字作为域名进行解析，称为一个虚拟的 Cluster IP，然后通过负载均衡，转发到后端的 Pod，虽然 Pod 可能漂移，IP 会变，但是 Service 会一直不变。

对应到 Linux 操作系统的 iptables，Kubernetes 在有个概念叫 Network Policy，Network Policy 提供了基于策略的网络控制，用于隔离应用并减少攻击面。它使用标签选择器模拟传统的分段网络，并通过策略控制它们之间的流量以及来自外部的流量。

看，是不是很神奇？有了 Kubernetes，我们就能像管理一台 Linux 服务器那样，去管理数据中心了。

如果想深入了解 Kubernetes 这个数据中心的操作系统，你可以订阅极客时间的专栏“深入剖析 Kubernetes”。

总结时刻

下面，你可以对照着这个图，来总结一下这个数据中心操作系统的功能。

课堂练习

Kubernetes 有一个简单的版本，可以用一台虚拟机进行尝试，请你按照官方文档安装一个进行尝试。

欢迎留言和我分享你的疑惑和见解，也欢迎收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

60 | 搭建操作系统实验环境（上）：授人以鱼不如授人以渔

操作系统的理论部分我们就讲完了，但是计算机这门学科是实验性的。为了更加深入地了解操作系统的本质，我们必须能够做一些上手实验。操作系统的实验，相比其他计算机课程的实验要更加复杂一些。

我们做任何实验，都需要一个实验环境。这个实验环境要搭建在操作系统之上，但是，我们这个课程本身就是操作系统实验，难不成要自己 debug 自己？到底该咋整呢？

我们有一个利器，那就是 qemu 啊，不知道你还记得吗？它可以在操作系统之上模拟一个操作系统，就像一个普通的进程。那我们是否可以像 debug 普通进程那样，通过 qemu 来 debug 虚拟机里面的操作系统呢？

这一节和下一节，我们就按照这个思路，来试试看，搭建一个操作系统的实验环境。

运行一个 qemu 虚拟机，首先我们要有一个虚拟机的镜像。咱们在虚拟机那一节，已经制作了一个虚拟机的镜像。假设我们要基于 ubuntu-18.04.2-live-server-amd64.iso，它对应的内核版本是 linux-source-4.15.0。

当时我们启动虚拟机的过程很复杂，设置参数的时候也很复杂，以至于解析这些参数就花了我们一章的时间。所以，这里我介绍一个简单的创建和管理虚拟机的方法。

在CPU 虚拟化那一节，我留过一个思考题，OpenStack 是如何创建和管理虚拟机的？当时我给了你一个提示，就是用 libvirt。没错，这一节，我们就用 libvirt 来创建和管理虚拟机。

创建虚拟机

首先，在宿主机上，我们需要一个网桥。我们用下面的命令创建一个网桥，并且设置一个 IP 地址。

brctl addbr br0

ip link set br0 up

ifconfig br0 192.168.57.1/24

为了访问外网，这里还需要设置 /etc/sysctl.conf 文件中 net.ipv4.ip_forward=1 参数，并且执行以下的命令，设置 NAT。

iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE

接下来，就要创建虚拟机了。这次我们就不再一个个指定虚拟机启动的参数，而是用 libvirt。首先，使用下面的命令，安装 libvirt。

apt-get install libvirt-bin

apt-get install virtinst

libvirt 管理 qemu 虚拟机，是基于 XML 文件，这样容易维护。

<domain type='qemu'>

  <name>ubuntutest</name>

  <uuid>0f0806ab-531d-6134-5def-c5b4955292aa</uuid>

  <memory unit='GiB'>4</memory>

  <currentMemory unit='GiB'>4</currentMemory>

  <vcpu placement='static'>2</vcpu>

  <os>

    <type arch='x86_64' machine='pc-i440fx-trusty'>hvm</type>

    <boot dev='hd'/>

  </os>

  <features>

    <acpi/>

    <apic/>

    <pae/>

  </features>

  <clock offset='utc'/>

  <on_poweroff>destroy</on_poweroff>

  <on_reboot>restart</on_reboot>

  <on_crash>restart</on_crash>

 <devices>

    <emulator>/usr/bin/qemu-system-x86_64</emulator>

    <disk type='file' device='disk'>

      <driver name='qemu' type='qcow2'/>

      <source file='/mnt/vdc/ubuntutest.img'/>

      <target dev='vda' bus='virtio'/>

    </disk>

    <controller type='pci' index='0' model='pci-root'/>

    <interface type='bridge'>

      <mac address='fa:16:3e:6e:89:ce'/>

      <source bridge='br0'/>

      <target dev='tap1'/>

      <model type='virtio'/>

    </interface>

    <serial type='pty'>

      <target port='0'/>

    </serial>

    <console type='pty'>

      <target type='serial' port='0'/>

    </console>

    <graphics type='vnc' port='-1' autoport='yes' listen='0.0.0.0'>

      <listen type='address' address='0.0.0.0'/>

    </graphics>

    <video>

      <model type='cirrus'/>

    </video>

  </devices>

</domain>

在这个 XML 文件中，/mnt/vdc/ubuntutest.img 就是虚拟机的镜像，br0 就是我们创建的网桥，连接到网桥上的网卡 libvirt 会自动帮我们创建。

接下来，需要将这个 XML 保存为 domain.xml，然后调用下面的命令，交给 libvirt 进行管理。

virsh define domain.xml

接下来，运行 virsh list --all，我们就可以看到这个定义好的虚拟机了，然后我们调用 virsh start ubuntutest，启动这个虚拟机。

# virsh list

 Id    Name                           State

----------------------------------------------------

 1     ubuntutest                     running

我们可以通过 ps 查看 libvirt 启动的 qemu 进程。这个命令行是不是很眼熟？我们之前花了一章来讲解。如果不记得了，你可以回去看看前面的内容。

# ps aux | grep qemu

libvirt+  9343 85.1 34.7 10367352 5699400 ?    Sl   Jul27 1239:18 /usr/bin/qemu-system-x86_64 -name ubuntutest -S -machine pc-i440fx-trusty,accel=tcg,usb=off -m 4096 -realtime mlock=off -smp 2,sockets=2,cores=1,threads=1 -uuid 0f0806ab-531d-6134-5def-c5b4955292aa -no-user-config -nodefaults -chardev socket,id=charmonitor,path=/var/lib/libvirt/qemu/domain-ubuntutest/monitor.sock,server,nowait -mon chardev=charmonitor,id=monitor,mode=control -rtc base=utc -no-shutdown -boot strict=on -device piix3-usb-uhci,id=usb,bus=pci.0,addr=0x1.0x2 -drive file=/mnt/vdc/ubuntutest.img,format=qcow2,if=none,id=drive-virtio-disk0 -device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1 -netdev tap,fd=26,id=hostnet0 -device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:6e:89:ce,bus=pci.0,addr=0x3 -chardev pty,id=charserial0 -device isa-serial,chardev=charserial0,id=serial0 -vnc 0.0.0.0:0 -device cirrus-vga,id=video0,bus=pci.0,addr=0x2 -device virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x5 -msg timestamp=on

从这里，我们可以看到，VNC 的设置为 0.0.0.0:0。我们可以用 VNCViewer 工具登录到这个虚拟机的界面，但是这样实在是太麻烦了，其实 virsh 有一个特别好的工具，但是需要在虚拟机里面配置一些东西。

在虚拟机里面，我们修改 /boot/grub/ 里面的两个文件，一个是 grub.cfg，另一个是 menu.lst，这里面就是咱们在系统初始化的时候，讲过的那个启动列表。

在 grub.cfg 中，在 submenu ‘Advanced options for Ubuntu’ 这一项，在这一行的 linux /boot/vmlinuz-4.15.0-55-generic root=UUID=470f3a42-7a97-4b9d-aaa0-26deb3d234f9 ro console=ttyS0 maybe-ubiquity 中，加上了 console=ttyS0。

submenu 'Advanced options for Ubuntu' $menuentry_id_option 'gnulinux-advanced-470f3a42-7a97-4b9d-aaa0-26deb3d234f9' {

    menuentry 'Ubuntu, with Linux 4.15.0-55-generic' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-4.15.0-55-generic-advanced-470f3a42-7a97-4b9d-aaa0-26deb3d234f9' {

        recordfail

        load_video

        gfxmode $linux_gfx_mode

        insmod gzio

        if [ x$grub_platform = xxen ]; then insmod xzio; insmod lzopio; fi

        insmod part_gpt

        insmod ext2

        set root='hd0,gpt2'

        if [ x$feature_platform_search_hint = xy ]; then

            search --no-floppy --fs-uuid --set=root --hint-bios=hd0,gpt2 --hint-efi=hd0,gpt2 --hint-baremetal=ahci0,gpt2  470f3a42-7a97-4b9d-aaa0-26deb3d234f9

        else

            search --no-floppy --fs-uuid --set=root 470f3a42-7a97-4b9d-aaa0-26deb3d234f9

        fi

        echo    'Loading Linux 4.15.0-55-generic ...'

        linux   /boot/vmlinuz-4.15.0-55-generic root=UUID=470f3a42-7a97-4b9d-aaa0-26deb3d234f9 ro console=ttyS0 maybe-ubiquity

        echo    'Loading initial ramdisk ...'

        initrd  /boot/initrd.img-4.15.0-55-generic

    }

在 menu.lst 文件中，在 Ubuntu 18.04.2 LTS, kernel 4.15.0-55-generic 这一项，在 kernel /boot/vmlinuz-4.15.0-55-generic root=/dev/hda1 ro console=hvc0 console=ttyS0 这一行加入 console=ttyS0。

title           Ubuntu 18.04.2 LTS, kernel 4.15.0-55-generic

root            (hd0)

kernel          /boot/vmlinuz-4.15.0-55-generic root=/dev/hda1 ro console=hvc0 console=ttyS0

initrd          /boot/initrd.img-4.15.0-55-generic

接下来，我们重启虚拟机，重启后上面的配置就起作用了。这时候，我们可以通过下面的命令，进入机器的控制台，可以不依赖于 SSH 和 IP 地址进行登录。

# virsh console ubuntutest

Connected to domain ubuntutest

Escape character is ^]

下面，我们可以配置这台机器的 IP 地址了。对于 ubuntu-18.04 来讲，IP 地址的配置方式为修改 /etc/netplan/50-cloud-init.yaml 文件。

network:

    ethernets:

        ens3:

                addresses: [192.168.57.100/24]

                gateway4: 192.168.57.1

                dhcp4: no

                nameservers:

                        addresses: [8.8.8.8,114.114.114.114]

                optional: true

    version: 2

然后，我们可以通过 netplan apply，让配置生效，这样，虚拟机里面的 IP 地址就配置好了。现在，我们应该能 ping 得通公网的一个网站了。

虚拟机就此创建好了，接下来我们需要下载源代码重新编译。

下载源代码

首先，我们先下载源代码。

apt-get install linux-source-4.15.0

这行命令会将代码下载到 /usr/src/ 目录下，我们可以通过下面的命令解压缩。

tar vjxkf linux-source-4.15.0.tar.bz2

至此，路径 /usr/src/linux-source-4.15.0 下，就是解压好的内核代码。

准备工作都做好了。这一节，我们先来做第一个实验，也就是，在原有内核代码的基础上加一个我们自己的系统调用。

在哪里加代码呢？如果你忘了，请出门左转，回顾一下系统调用那一节。

第一个要加的地方是 arch/x86/entry/syscalls/syscall_64.tbl。这里面登记了所有的系统调用号以及相应的处理函数。

332     common  statx                   sys_statx

333     64      sayhelloworld           sys_sayhelloworld

在这里，我们找到 332 号系统调用 sys_statx，然后照猫画虎，添加一个 sys_sayhelloworld，这里我们只添加 64 位操作系统的。

第二个要加的地方是 include/linux/syscalls.h，也就是系统调用的头文件，然后添加一个系统调用的声明。

asmlinkage long sys_statx(int dfd, const char __user *path, unsigned flags,

                          unsigned mask, struct statx __user *buffer);

asmlinkage int sys_sayhelloworld(char * words, int count);

同样，我们找到 sys_statx 的声明，照猫画虎，声明一个 sys_sayhelloworld。其中，words 参数是用户态传递给内核态的文本的指针，count 是数目。

第三个就是对于这个系统调用的实现，方便起见，我们不再用 SYSCALL_DEFINEx 系列的宏来定义了，直接在 kernel/sys.c 中实现。

asmlinkage int sys_sayhelloworld(char * words, int count){

	int ret;

	char buffer[512];

	if(count >= 512){

		return -1;

	}

	copy_from_user(buffer, words, count);

	ret=printk("User Mode says %s to the Kernel Mode!", buffer);

	return ret;

}

接下来就要开始编译内核了。

编译内核

编译之前，我们需要安装一些编译要依赖的包。

apt-get install libncurses5-dev libssl-dev bison flex libelf-dev gcc make openssl libc6-dev

首先，我们要定义编译选项。

make menuconfig

然后，我们能通过选中下面的选项，激活 CONFIG_DEBUG_INFO 和 CONFIG_FRAME_POINTER 选项。

Kernel hacking  --->

Compile-time checks and compiler options  --->

[*] Compile the kernel with debug info

[*] Compile the kernel with frame pointers

选择完毕之后，配置会保存在.config 文件中。如果我们打开看，能看到这样的配置：

CONFIG_FRAME_POINTER=y

CONFIG_DEBUG_INFO=y

接下来，我们编译内核。

nohup make -j8 > make1.log 2>&1 &

nohup make modules_install > make2.log 2>&1 &

nohup make install > make3.log 2>&1 &

这是一个非常长的过程，请耐心等待，可能需要数个小时，因而这里用了 nohup，你可以去干别的事情。

当编译完毕之后，grub 和 menu.lst 都会发生改变。例如，grub.conf 里面会多一个新内核的项。

submenu 'Advanced options for Ubuntu' $menuentry_id_option 'gnulinux-advanced-470f3a42-7a97-4b9d-aaa0-26deb3d234f9' {

        menuentry 'Ubuntu, with Linux 4.15.18' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-4.15.18-advanced-470f3a42-7a97-4b9d-aaa0-26deb3d234f9' {

                recordfail

                load_video

                gfxmode $linux_gfx_mode

                insmod gzio

                if [ x$grub_platform = xxen ]; then insmod xzio; insmod lzopio; fi

                insmod part_gpt

                insmod ext2

                if [ x$feature_platform_search_hint = xy ]; then

                  search --no-floppy --fs-uuid --set=root  470f3a42-7a97-4b9d-aaa0-26deb3d234f9

                else

                  search --no-floppy --fs-uuid --set=root 470f3a42-7a97-4b9d-aaa0-26deb3d234f9

                fi

                echo    'Loading Linux 4.15.18 ...'

                linux   /boot/vmlinuz-4.15.18 root=UUID=470f3a42-7a97-4b9d-aaa0-26deb3d234f9 ro console=ttyS0 maybe-ubiquity

                echo    'Loading initial ramdisk ...'

                initrd  /boot/initrd.img-4.15.18

        }

例如，menu.lst 也多了新的内核的项。

title           Ubuntu 18.04.2 LTS, kernel 4.15.18

root            (hd0)

kernel          /boot/vmlinuz-4.15.18 root=/dev/hda1 ro console=hvc0 console=ttyS0

initrd          /boot/initrd.img-4.15.18

别忘了，这里面都要加上 console=ttyS0。

下面，我们要做的就是重启虚拟机。进入的时候，会出现 GRUB 界面。我们选择 Ubuntu 高级选项，然后选择第一项进去，通过 uname 命令，我们就进入了新的内核。

# uname -a

Linux popsuper 4.15.18 #1 SMP Sat Jul 27 13:43:42 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux

进入新的系统后，我们写一个测试程序。

#include <stdio.h>

#include <stdlib.h>

#include <unistd.h>

#include <linux/kernel.h>

#include <sys/syscall.h>

#include <string.h>

int main ()

{

  char * words = "I am liuchao from user mode.";

  int ret;

  ret = syscall(333, words, strlen(words)+1);

  printf("return %d from kernel mode.\n", ret);

  return 0;

}

然后，我们能利用 gcc 编译器编译后运行。如果我们查看日志 /var/log/syslog，就能够看到里面打印出来下面的日志，这说明我们的系统调用已经添加成功了。

Aug  1 06:33:12 popsuper kernel: [ 2048.873393] User Mode says I am liuchao from user mode. to the Kernel Mode!

总结时刻

这一节是一节实战课，我们创建了一台虚拟机，在里面下载源代码，尝试修改了 Linux 内核，添加了一个自己的系统调用，并且进行了编译并安装了新内核。如果你按照这个过程做下来，你会惊喜地发现，原来令我们敬畏的内核，也是能够加以干预，为我而用的呢。没错，这就是你开始逐渐掌握内核的重要一步。

课堂练习

这一节的课堂练习，希望你能够按照整个过程，一步一步操作下来。毕竟看懂不算懂，做出来才算入门啊。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

61 | 搭建操作系统实验环境（下）：授人以鱼不如授人以渔

上一节我们做了一个实验，添加了一个系统调用，并且编译了内核。这一节，我们来尝试调试内核。这样，我们就可以一步一步来看，内核的代码逻辑执行到哪一步了，对应的变量值是什么。

了解 gdb

在 Linux 下面，调试程序使用一个叫作 gdb 的工具。通过这个工具，我们可以逐行运行程序。

例如，上一节我们写的 syscall.c 这个程序，我们就可以通过下面的命令编译。

gcc -g syscall.c

其中，参数 -g 的意思就是在编译好的二进制程序中，加入 debug 所需的信息。

接下来，我们安装一下 gdb。

apt-get install gdb

然后，我们就可以来调试这个程序了。

~/syscall# gdb ./a.out

GNU gdb (Ubuntu 8.1-0ubuntu3.1) 8.1.0.20180409-git

......

Reading symbols from ./a.out...done.

(gdb) l

1       #include <stdio.h>

2       #include <stdlib.h>

3       #include <unistd.h>

4       #include <linux/kernel.h>

5       #include <sys/syscall.h>

6       #include <string.h>

7

8       int main ()

9       {

10        char * words = "I am liuchao from user mode.";

(gdb) b 10

Breakpoint 1 at 0x6e2: file syscall.c, line 10.

(gdb) r

Starting program: /root/syscall/a.out

Breakpoint 1, main () at syscall.c:10

10        char * words = "I am liuchao from user mode.";

(gdb) n

12        ret = syscall(333, words, strlen(words)+1);

(gdb) p words

$1 = 0x5555555547c4 "I am liuchao from user mode."

(gdb) s

__strlen_sse2 () at ../sysdeps/x86_64/multiarch/../strlen.S:79

(gdb) bt

#0  __strlen_sse2 () at ../sysdeps/x86_64/multiarch/../strlen.S:79

#1  0x00005555555546f9 in main () at syscall.c:12

(gdb) c

Continuing.

return 63 from kernel mode.

[Inferior 1 (process 1774) exited normally]

(gdb) q

在上面的例子中，我们只要掌握简单的几个 gdb 的命令就可以了。

l，即 list，用于显示多行源代码。

b，即 break，用于设置断点。

r，即 run，用于开始运行程序。

n，即 next，用于执行下一条语句。如果该语句为函数调用，则不会进入函数内部执行。

p，即 print，用于打印内部变量值。

s，即 step，用于执行下一条语句。如果该语句为函数调用，则进入函数，执行其中的第一条语句。

c，即 continue，用于继续程序的运行，直到遇到下一个断点。

bt，即 backtrace，用于产看函数调用信息。

q，即 quit，用于退出 gdb 环境。

Debug kernel

看了 debug 一个进程还是简单的，接下来，我们来试着 debug 整个 kernel。

第一步，要想 kernel 能够被 debug，需要向上面编译程序一样，将 debug 所需信息也放入二进制文件里面去。这个我们在编译内核的时候已经设置过了，也就是把“CONFIG_DEBUG_INFO”和“CONFIG_FRAME_POINTER“两个变量设置为 yes。

第二步，就是安装 gdb。kernel 运行在 qemu 虚拟机里面，gdb 运行在宿主机上，所以我们应该在宿主机上进行安装。

第三步，找到 gdb 要运行的那个内核的二进制文件。这个文件在哪里呢？根据 grub 里面的配置，它应该在 /boot/vmlinuz-4.15.18 这里。

另外，为了方便在 debug 的过程中查看源代码，我们可以将 /usr/src/linux-source-4.15.0 整个目录，都拷贝到宿主机上来。因为内核一旦进入 debug 模式，就不能运行了。

scp -r popsuper@192.168.57.100:/usr/src/linux-source-4.15.0 ./

在 /usr/src/linux-source-4.15.0 这个目录下面，vmlinux 文件也是内核的二进制文件。

第四步，修改 qemu 的启动参数和 qemu 里面虚拟机的启动参数，从而使得 gdb 可以远程 attach 到 qemu 里面的内核上。

我们知道，gdb debug 一个进程的时候，gdb 会监控进程的运行，使得进程一行一行地执行二进制文件。如果像 syscall.c 的二进制文件 a.out 一样，就在本地，gdb 可以通过 attach 到这个进程上，作为这个进程的父进程，来监控它的运行。

但是，gdb debug 一个内核的时候，因为内核在 qemu 虚拟机里面，所以我们无法监控本地进程，而要通过 qemu 来监控 qemu 里面的内核，这就要借助 qemu 的机制。

qemu 有个参数 -s，它代表参数 -gdb tcp::1234，意思是 qemu 监听 1234 端口，gdb 可以 attach 到这个端口上来，debug qemu 里面的内核。

为了完成这一点，我们需要修改 ubuntutest 这个虚拟机的定义文件。

virsh edit ubuntutest

在这里，我们能将虚拟机的定义文件修改成下面的样子，其中主要改了两项：

在 domain 的最后加上了 qemu:commandline，里面指定了参数 -s；

在 domain 中添加 xmlns:qemu。没有这个 XML 的 namespace，qemu:commandline 这个参数 libvirt 不认。

<domain type='qemu' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>

  <name>ubuntutest</name>

  <uuid>0f0806ab-531d-6134-5def-c5b4955292aa</uuid>

  <memory unit='KiB'>8388608</memory>

  <currentMemory unit='KiB'>8388608</currentMemory>

  <vcpu placement='static'>8</vcpu>

  <os>

    <type arch='x86_64' machine='pc-i440fx-trusty'>hvm</type>

    <boot dev='hd'/>

  </os>

  <clock offset='utc'/>

  <on_poweroff>destroy</on_poweroff>

  <on_reboot>restart</on_reboot>

  <on_crash>restart</on_crash>

  <devices>

    <emulator>/usr/bin/qemu-system-x86_64</emulator>

    <disk type='file' device='disk'>

      <driver name='qemu' type='qcow2'/>

      <source file='/mnt/vdc/ubuntutest.img'/>

      <backingStore/>

      <target dev='vda' bus='virtio'/>

      <alias name='virtio-disk0'/>

      <address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x0'/>

    </disk>

......

    <interface type='bridge'>

      <mac address='fa:16:3e:6e:89:ce'/>

      <source bridge='br0'/>

      <target dev='tap1'/>

      <model type='virtio'/>

      <alias name='net0'/>

      <address type='pci' domain='0x0000' bus='0x00' slot='0x03' function='0x0'/>

    </interface>

......

  </devices>

  <qemu:commandline>

    <qemu:arg value='-s'/>

  </qemu:commandline>

</domain>

另外，为了远程 debug 成功，我们还需要修改 qemu 里面的虚拟机的 grub 和 menu.list，在内核命令行中添加 nokaslr，来关闭 KASLR。KASLR 会使得内核地址空间布局随机化，从而会造成我们打的断点不起作用。

对于 grub.conf，修改如下：

submenu 'Advanced options for Ubuntu' $menuentry_id_option 'gnulinux-advanced-470f3a42-7a97-4b9d-aaa0-26deb3d234f9' {

        menuentry 'Ubuntu, with Linux 4.15.18' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-4.15.18-advanced-470f3a42-7a97-4b9d-aaa0-26deb3d234f9' {

                recordfail

                load_video

                gfxmode $linux_gfx_mode

                insmod gzio

                if [ x$grub_platform = xxen ]; then insmod xzio; insmod lzopio; fi

                insmod part_gpt

                insmod ext2

                if [ x$feature_platform_search_hint = xy ]; then

                  search --no-floppy --fs-uuid --set=root  470f3a42-7a97-4b9d-aaa0-26deb3d234f9

                else

                  search --no-floppy --fs-uuid --set=root 470f3a42-7a97-4b9d-aaa0-26deb3d234f9

                fi

                echo    'Loading Linux 4.15.18 ...'

                linux   /boot/vmlinuz-4.15.18 root=UUID=470f3a42-7a97-4b9d-aaa0-26deb3d234f9 ro nokaslr console=ttyS0 maybe-ubiquity

                echo    'Loading initial ramdisk ...'

                initrd  /boot/initrd.img-4.15.18

        }

对于 menu.list，修改如下：

title           Ubuntu 18.04.2 LTS, kernel 4.15.18

root            (hd0)

kernel          /boot/vmlinuz-4.15.18 root=/dev/hda1 ro nokaslr console=hvc0 console=ttyS0

initrd          /boot/initrd.img-4.15.18

修改完毕后，我们需要在虚拟机里面 shutdown -h now，来关闭虚拟机。注意不要 reboot，因为虚拟机里面运行 reboot，我们改过的那个 XML 会不起作用。

当我们在宿主机上发现虚拟机关机之后，就可以通过 virsh start ubuntutest 启动虚拟机，这个时候我们添加的参数 -s 才起作用。

第五步，使用 gdb 运行内核的二进制文件，执行 gdb vmlinux。

/mnt/vdc/linux-source-4.15.0# gdb vmlinux

GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1

......

To enable execution of this file add

        add-auto-load-safe-path /mnt/vdc/linux-source-4.15.0/vmlinux-gdb.py

......

(gdb) b sys_sayhelloworld

Breakpoint 1 at 0xffffffff8109e2f0: file kernel/sys.c, line 192.

(gdb) target remote :1234

Remote debugging using :1234

native_safe_halt () at ./arch/x86/include/asm/irqflags.h:61

61      }

(gdb) c

Continuing.

[Switching to Thread 2]

Thread 2 hit Breakpoint 1, sys_sayhelloworld (words=0x563cbfa907c4 "I am liuchao from user mode.", count=29) at kernel/sys.c:192

192     {

(gdb) bt

#0  sys_sayhelloworld (words=0x55b2811537c4 "I am liuchao from user mode.", count=29) at kernel/sys.c:192

#1  0xffffffff810039f7 in do_syscall_64 (regs=0xffffc9000133bf58) at arch/x86/entry/common.c:290

#2  0xffffffff81a00081 in entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:237

(gdb) n

195             if(count >= 1024){

(gdb) n

198             copy_from_user(buffer, words, count);

(gdb) n

199             ret=printk("User Mode says %s to the Kernel Mode!", buffer);

(gdb) p buffer

$1 = "I am liuchao from user mode.\000\177\000\000\...

(gdb) n

200             return ret;

(gdb) p ret

$2 = 63

(gdb) c

(gdb) n

do_syscall_64 (regs=0xffffc9000133bf58) at arch/x86/entry/common.c:295

295             syscall_return_slowpath(regs);

(gdb) s

syscall_return_slowpath (regs=<optimized out>) at arch/x86/entry/common.c:295

(gdb) n

268             prepare_exit_to_usermode(regs);

(gdb) n

do_syscall_64 (regs=0xffffc9000133bf58) at arch/x86/entry/common.c:296

296     }

(gdb) n

entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:246

246             movq    RCX(%rsp), %rcx

......

(gdb) n

entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:330

330             USERGS_SYSRET64

我们先设置一个断点在我们自己写的系统调用上 b sys_sayhelloworld，通过执行 target remote :1234，来 attach 到 qemu 上，然后，执行 c，也即 continue 运行内核。这个时候内核始终在 Continuing 的状态，也即持续在运行中，这个时候我们可以远程登录到 qemu 里的虚拟机上，执行各种命令。

如果我们在虚拟机里面运行 syscall.c 编译好的 a.out，这个时候肯定会调用到内核。内核肯定会经过系统调用的过程，到达 sys_sayhelloworld 这个函数，这就碰到了我们设置的那个断点。

如果执行 bt，我们能看到，这个系统调用是从 entry_64.S 里面的 entry_SYSCALL_64 () 函数，调用到 do_syscall_64 函数，再调用到 sys_sayhelloworld 函数的。这一点和我们在系统调用那一节分析的过程是一模一样的。

我们可以通过执行 next 命令，来看 sys_sayhelloworld 一步一步是怎么执行的，通过 p buffer 查看 buffer 里面的内容。在这个过程中，由于内核是逐行运行的，因而我们在虚拟机里面的命令行是卡死的状态。

当我们不断地 next，直到执行完毕 sys_sayhelloworld 的时候，会看到，do_syscall_64 会调用 syscall_return_slowpath。它会调用 prepare_exit_to_usermode，然后会回到 entry_SYSCALL_64，然后对于寄存器进行操作，最后调用指令 USERGS_SYSRET64 回到用户态。这个返回的过程和系统调用那一节也一模一样。

看，通过 debug 我们能够跟踪系统调用的整个过程。你可以将我们这一门课里面学得的所有的过程都 debug 一下，看看变量的值，从而对于内核的工作机制有更加深入的了解。

总结时刻

在这个课程里面，我们写过一些程序，为了保证程序能够顺利运行，我一般会将代码完整地放到文本中，让你拷贝下来就能编译和运行。如果你运行的时候发现有问题，或者想了解一步一步运行的细节，这一节介绍的 gdb 是一个很好的工具。

这一节你尤其应该掌握的是，如何通过宿主机上的 gdb 来 debug 虚拟机里面的内核。这一点非常重要，会了这个，你就能够返回去，挨个研究每一章每一节的内核数据结构和运行逻辑了。

在这门课中，进程管理、内存管理、文件管理、设备管理网络管理，我们都介绍了从系统调用到底层的整个逻辑。如果你对我前面的代码解析还比较困惑，你可以尝试着去 debug 这些过程，只要把断点打在系统调用的入口位置就可以了。

从此，开启你的内核 debug 之旅吧！

课堂练习

这里给你留一道题目，你可以试着 debug 一下文件打开的过程。

欢迎留言和我分享你的疑惑和见解，也欢迎你收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习、进步。

62 | 知识串讲 | 用一个创业故事串起操作系统原理（一）

操作系统是一门体系复杂、知识点很多的课程，经过这么多节的讲解，你是否已经感觉自己被淹没在细节的汪洋大海里面了？没关系，从这一节开始，我们用五节的时间，通过一个创业故事，串起来操作系统的整个知识体系。

接下来，我们就来看主人公是如何从小马，变成马哥，再变成马总的吧！

小马创业选园区，开放标准是第一

小马最终还是决定走出大公司，自己去创业了。

他之所以这样决定，有两个原因，一方面，大企业多年的工作经验让他练就了从前端到后端，从 Web 到 App，从产品设计到测试交付的全栈能力。他很自信，靠着这些能力，闯荡江湖应该没什么问题；另外一方面，他听说，姓“马”的，创业成功的概率好像比较大。

创业首先要注册公司。注册公司就需要有一个办公地点。所以，小马需要选择一个适合创业的环境。他找了很多地方，发现有的地方政策倾斜大型企业，有的地方倾斜本地企业，有的地方鼓励金融创新。小马感觉这些地方都不太适合他这个 IT 男。

直到有一天，小马来到了位于杭州滨江的 x86 创业园区。他被深深地吸引住了，当然首要吸引他的就是园区工作人员的热情。

园区的工作人员向小马介绍了以下信息。

“首先，咱们这个 x86 园区，主要有三大特点，一是标准，二是开放，三是兼容。像您这种创业者还是非常多的。初次创业不一定有经验，园区提供标准的企业运行流程辅导。”

“另外，我们园区秉承完全开放的态度，对待各种各样的企业。不封闭，不保守。只要您符合国家的法律法规，我们都接纳。而且，整个园区是一种开放合作的生态，也有利于不同企业之间的协作。”

“再就是兼容。我们园区的流程和规则的设计都会兼容历史上的既有政策，既不会朝令夕改，也不会因为变化而影响您公司的运转。总而言之，来了咱们园区，您就埋头干业务就可以啦！”

小马显然对于 x86 园区的开放性十分满意，于是追问道：“您刚才说的企业运行流程辅导，能详细介绍一下吗？将来我这个企业在这个园区，应该怎么个运转法儿？”

工作人员接着说：“咱们这个园区毗邻全国知名高校，每年都有大量的优秀毕业生来园区找工作，这是企业非常重要的人才来源。葛优说了，二十一世纪了，人才是核心嘛。每年我们园区都会招聘大量的毕业生，先进行一个月的培训，合格毕业的可以推荐给您这种企业。这些人才啊，就是咱们企业的 CPU。”

“经过我们园区培训过的‘CPU 人才’，具备了三种老板们喜欢的核心竞争力：

第一，实干能力强，干活快，我们称为运算才能——也即指令执行能力；

第二，记忆力好，记得又快又准，我们称为数据才能——也即数据寄存能力；

第三，听话，自控能力强，可以多任务并发执行，我们称为控制才能——也即指令寄存能力。

到时候，你可以根据需求，看雇佣多少个‘CPU 人才’。

另外，人才得有个办公的地方，这一片呢，就是我们的办公区域，称为也就是内存区域。您可以包几个工位，或者包一片区域，或者几个会议室，让您公司的人才在里面做项目就可以了。这里面有的是地方，同时运行多少各项目都行。”

跟着工作人员的介绍，小马走在 x86 园区中，看着这一片片的内存办公区，脑子里已经浮现出将来热火朝天的办公场景了。

“也许不到半年的时间，我肯定能够接两三个大项目，招聘十个八个 CPU 员工。那项目 A 的员工就坐在这片内存办公区，项目 B 的员工就坐在那片内存办公区。我根据积累的人脉，将接到的项目写成一个一个的项目执行计划书，里面是一行行项目执行的指令，这些指令操作数据产生一些结果，我们就可以叫程序啦。”小马这么想着。

“然后呢，我把不同的项目执行计划书，交给不同的项目组去执行。那项目组就叫进程吧！两个项目组，进程 A 和 B，会有独立的内存办公空间，互相隔离，程序会分别加载到进程 A 和进程 B 的内存办公空间里面，形成各自的代码段。要操作的数据和产生的结果，就放在数据段里面。“

“除此之外，我应该找一个或者多个 CPU 员工来运行项目执行计划书，我只要告诉他下一条指令在内存办公区中的地址，经过训练的 CPU 员工就会很自觉地、不停地将代码段的指令拿进来进行处理。“

“指令一般是分两部分，一部分表示做什么操作，例如是加法还是位移；另一部分是操作哪些数据。数据的部分，CPU 员工会从数据段里面读取出来，记在脑子里，然后进行处理，处理完毕的结果，在写回数据段。当项目执行计划书里面的所有指令都执行完毕之后，项目也就完成了，那就可以等着收钱啦。”

小马沉浸在思绪中久久不能自拔，直到工作人员打断了他的思绪：“您觉得园区如何？要不要入住呀？先租几个工位，招聘几个人呢？“

小马想了想，说道：“园区我很满意，以后就在您这里创业了，创业开始，我先不招人，自己先干吧。”

启动公司有手册，获取内核当宝典

工作人员说：“感谢您入驻咱们创业园区，由于您是初次创业，这里有一本《创业指导手册》，在这一本叫作 BIOS 的小册子上，有您启动一家公司的通用流程，你只要按照里面做就可以了。”

小马接过 BIOS 小册子，开始按照里面的指令启动公司了。

创业初期，小马的办公室肯定很小，只有有 1M 的内存办公空间。在 1M 空间最上面的 0xF0000 到 0xFFFFF 这 64K 映射给 ROM，通过读这部分地址，可以访问这个 BIOS 小册子里面的指令。

创业指导手册第一条，BIOS 要检查一些系统的硬件是不是都好着呢。创业指导手册第二条，要有个办事大厅，只不过小马自己就是办事员。因为一旦开张营业，就会有人来找到这家公司，因而基本的中断向量表和中断服务程序还是需要的，至少要能够使用键盘和鼠标。

BIOS 这个手册空间有限，只能帮小马把公司建立起来，公司如何运转和经营，就需要另外一个东西——《企业经营宝典》，因而 BIOS 还要做的一件事情，就是帮助小马找到这个宝典，然后让小马以后根据这个宝典里面的方法来经营公司，这个《企业经营宝典》就是这家公司的内核。

运营一个企业非常的复杂，因而这本《企业经营宝典》也很厚，BIOS 手册无法直接加载出来，而需要从门卫开始问起，不断打听这本内核的位置，然后才能加载他。

门卫只有巴掌大的一块地方，在启动盘的第一个扇区，512K 的大小，我们通常称为 MBR（Master Boot Record，主引导记录 / 扇区）。这里保存了 boot.img，BIOS 手册会将他加载到内存中的 0x7c00 来运行。

boot.img 做不了太多的事情。他能做的最重要的一个事情，就是加载 grub2 的另一个镜像 core.img。

引导扇区就是小马找到的门卫，虽然他看着档案库的大门，但是知道的事情很少。他不知道宝典在哪里，但是，他知道应该问谁。门卫说，档案库入口处有个管理处，然后把小马领到门口。

core.img 就是管理处，他们知道的和能做的事情就多了一些。core.img 由 lzma_decompress.img、diskboot.img、kernel.img 和一系列的模块组成，功能比较丰富，能做很多事情。

boot.img 将控制权交给 diskboot.img 后，diskboot.img 的任务就是将 core.img 的其他部分加载进来，先是解压缩程序 lzma_decompress.img，再往下是 kernel.img，最后是各个模块 module 对应的映像。

管理处听说小马要找宝典，知道他将来是要做老板的人。管理处就告诉小马，既然是老板，早晚都要雇人干活的。这不是个体户小打小闹，所以，你需要切换到老板角色，进入保护模式，把哪些是你的权限，哪些是你可以授权给别人的，都分得清清楚楚。

这些，小马都铭记在心，此时此刻，虽然公司还是只有他一个人，但是小马的眼界放宽了，能够管理的内存空间大多了，也开始区分哪些是用户态，哪些是内核态了。

接下来，kernel.img 里面的 grub_main 会给小马展示一个《企业经营宝典》的列表，也即操作系统的列表，让小马进行选择。经营企业的方式也有很多种，到底是人性化的，还是强纪律的，这个时候你要做一个选择。

在这里，小马毫不犹豫地选择了《狼性文化》操作系统，至此 grub 才开始启动《狼性文化》操作系统内核。

拿到了宝典的小马，开始越来越像一个老板了。他要开始以老板的思维，来建立这家公司。

初创公司有章法，请来兄弟做臂膀

这注定是一个不眠夜，办公室里面一片漆黑中，唯一亮着的台灯下，小马独自捧着《企业经营宝典》仔细研读，读着读着，小马若有所思，开始书写公司内核的初始化计划。

公司首先应该有个项目管理部门，咱们将来肯定要接各种各样的项目，因此，项目管理体系和项目管理流程首先要建立起来。虽然现在还没有项目，但是小马还是弄了一个项目模板 init_task。这是公司的第一个项目（进程），是项目管理系统里面的项目列表中的第一个，我们能称为 0 号进程。这个项目是虚拟的，不对应一个真实的项目（也就是进程）。

项目需要项目管理进行调度，还需要制定一些调度策略。

另外，为了快速响应客户需求，为了各个项目组能够方便地使用公司的公共资源，还应该有一个办事大厅。这里面可以设置了很多中断门（Interrupt Gate），用于处理各种中断，以便快速响应突发事件；还可以提供系统调用，为项目组服务。

如果项目接得多了，为了提高研发效率，对项目内容进行保密，就需要封闭开发，所以将来会有很多的会议室，因而还需要一个会议室管理系统。

项目的执行肯定会留下很多文档，这些是公司的积累，将来的核心竞争力，一定要好好管理，因而应该建立一个项目档案库，也即文件系统。

随着思绪的展开，小马奋笔疾书，已经写了满满的几页纸，小马顿感经营一个公司还是挺复杂的，一旦项目接多了肯定忙不过来。俗话说得好，“一个好汉三个帮”，小马准备找两个兄弟来一起创业。

小马想到的第一个人，是自己的大学室友，外号“周瑜”。大学一毕业，周瑜就转项目管理了，在一家大公司管理着大型项目。将来外部接了项目，可以让他来管。小马想到的第二个人，是自己上一家公司的同事，外号“张昭”，是他们总经理的好帮手，公司的流程、人事、财务打理得都轻轻楚楚，将来公司内部要运行的井井有条，也需要这样一个人。

第二天，小马请周瑜和张昭吃饭，邀请他们加入他的创业公司。小马说，公司要正规运转起来，应该分清内外，外部项目需要有人帮忙管理好——也就是用户态，内部公司的核心资源也需要管理好——也就是内核态。现在我一个人忙不过来，需要两位兄弟的加入，周瑜主外，张昭主内，正所谓，内事不决问张昭，外事不决问周郎嘛。

三个人相谈甚欢，谈及往日友谊、未来前景、上市敲钟……

第三天，周瑜早早就来到公司，开始了他的事业。小马拜托周瑜做的第一件事情是调用 kernel_init 运行 1 号项目（进程）。这个 1 号项目会在用户态运行 init 项目（进程）。这是第一个以外部项目的名义运行的，之所以叫 init，就是做初始化的工作，周瑜根据自己多年的项目管理经验，将这个 init 项目立为标杆，以后所有外部项目的运行都要按照他来，是外部项目的祖先项目。

下午，张昭也来到了公司，小马拜托张昭做的第一件事情是调用 kthreadd 运行 2 号项目（进程）。这个 2 号项目是内核项目的祖先。将来所有的项目都有父项目、祖先项目，会形成一棵项目树。公司大了之后，周瑜和张昭做的公司 VP 级别的任务，就可以坐在塔尖上了。

好了，这一节小马终于将公司的架子搭起来了，兄弟三人如当年桃园三结义一样，开始自己的创业生涯，小马的这家公司能不能顺利接到项目呢？欲知后事，且听下回分解。

63 | 知识串讲：用一个创业故事串起操作系统原理（二）

上一节说到小马同学的公司已经创立了，还请来了周瑜和张昭作为帮手，所谓“兄弟齐心，其利断金”。可是，现在这家公司，还得从接第一个外部项目开始。

首个项目虽简单，项目管理成体系

这第一个项目，还是小马亲自去谈的。其实软件公司了解客户需求还是比较难的，因为客户都说着接近人类的语言，例如 C/C++。这些咱们公司招聘的 CPU 小伙伴们可听不懂，需要有一个人将客户需求，转换为项目执行计划书，CPU 小伙伴们才能执行，这个过程我们称为编译。

编译其实是一个需求分析和需求转换的过程。这个过程会将接近人类的 C/C++ 语言，转换为 CPU 小伙伴能够听懂的二进制语言，并且以一定的文档格式，写成项目执行计划书。这种文档格式是作为一个标准化的公司事先制定好的一种格式，是周瑜从大公司里面借鉴来的，称为 ELF 格式，这个项目执行计划书有总论 ELF Header 的部分，有包含指令的代码段的部分，有包含全局变量的数据段的部分。

小马和客户聊了整整一天，确认了项目的每一个细节，保证编译能够通过，才写成项目执行计划书 ELF 文件，放到档案库中。此时已经半夜了。

第二天，周瑜一到公司，小马就兴奋地给周瑜说，“我昨天接到了第一个项目，而且是一个大项目，项目执行计划书我都写好了，你帮我监督、执行、管理，记得按时交付哦！”

周瑜说，“没问题。”于是，周瑜从父项目开始，fork 一个子项目，然后在子项目中，调用 exec 系统调用， 然后到了内核里面，通过 load_elf_binary 将项目执行计划书加载到子进程内存中，交给一个 CPU 执行。

虽然这是第一个项目，以周瑜的项目管理经验，他告诉小马，项目的执行要保质保量，需要有一套项目管理系统来管理项目的状态，而不能靠脑子记。“项目管理系统？当然应该有了”，小马说。他在《企业经营宝典》中看到过。

于是，项目管理系统就搭建起来了。在这里面，所有项目都放在一个 task_struct 列表中，对于每一个项目，都非常详细地登记了项目方方面面的信息。

每一个项目都应该有一个 ID，作为这个项目的唯一标识。到时候排期啊、下发任务啊等等，都按 ID 来，就不会产生歧义。

项目应该有运行中的状态，TASK_RUNNING 并不是说进程正在运行，而是表示进程在时刻准备运行的状态。这个时候，要看 CPU 小伙伴有没有空，有空就运行他，没空就得等着。

有时候，进程运行到一半，需要等待某个条件才能运行下去，这个时候只能睡眠。睡眠状态有两种。一种是 TASK_INTERRUPTIBLE，可中断的睡眠状态。这是一种浅睡眠的状态，也就是说，虽然在睡眠，等条件成熟，进程可以被唤醒。

另一种睡眠是 TASK_UNINTERRUPTIBLE，不可中断的睡眠状态。这是一种深度睡眠状态，不可被唤醒，只能死等条件满足。有了一种新的进程睡眠状态，TASK_KILLABLE，可以终止的新睡眠状态。进程处于这种状态中，他的运行原理类似 TASK_UNINTERRUPTIBLE，只不过可以响应致命信号，也即虽然在深度睡眠，但是可以被干掉。

一旦一个进程要结束，先进入的是 EXIT_ZOMBIE 状态，但是这个时候他的父进程还没有使用 wait() 等系统调用来获知他的终止信息，此时进程就成了僵尸进程。

EXIT_DEAD 是进程的最终状态。

另外，项目运行的统计信息也非常重要。例如，有的员工很长时间都在做一个任务，这个时候你就需要特别关注一下；再如，有的员工的琐碎任务太多，这会大大影响他的工作效率。

那如何才能知道这些员工的工作情况呢？在进程的运行过程中，会有一些统计量，例如进程在用户态和内核态消耗的时间、上下文切换的次数等等。

项目之间的亲缘关系也需要维护，任何一个进程都有父进程。所以，整个进程其实就是一棵进程树。而拥有同一父进程的所有进程都具有兄弟关系。

另外，对于项目来讲，项目组权限的控制也很重要。什么是项目组权限控制呢？这么说吧，我这个项目组能否访问某个文件，能否访问其他的项目组，以及我这个项目组能否被其他项目组访问等等

另外，项目运行过程中占用的公司的资源，例如会议室（内存）、档案库（文件系统）也需要在项目管理系统里面登记。

周瑜同学将项目登记好，然后就分配给 CPU 同学们说，开始执行吧。

好在第一个项目还是比较简单的，一个 CPU 同学按照项目执行计划书按部就班一条条的执行，很快就完成了，客户评价还不错，很快收到了回款。

项目大了要并行，项目多了要排期

小马很开心，可谓开门红。接着，第二个项目就到来了，这可是一个大项目，要帮一家知名公司开发一个交易网站，共 200 个页面，这下要赚翻了，就是时间要的比较急，要求两个星期搞定。

小马把项目带回来，周瑜同学说，这个项目有点大，估计一个 CPU 同学干不过来了，估计要多个 CPU 同学一起协作了。

为了完成这个大的项目（进程），就不能一个人从头干到尾了，这样肯定赶不上工期。于是，周瑜将一个大项目拆分成 20 个子项目，每个子项目完成 10 个页面，一个大项目组也分成 20 个小组，并行开发，都开发完了，再做一次整合，这肯定比依次开发 200 个页面快多了。如果项目叫进程，那子项目就叫线程。

在 Linux 里面，无论是进程，还是线程，到了内核里面，我们统一都叫任务，由一个统一的结构 task_struct 进行管理。

不知道是好消息，还是坏消息，这么大一个项目还没有做完，新的项目又找上门了。看来有了前面的标杆客户，名声算是打出去了，一个项目接一个地不停。

小马是既高兴，又犯愁，于是找周瑜和张昭商量应该咋办。要不多招人？多来几个 CPU 小伙伴，就不搞定了？可是咱们还是在创业阶段，养不起这么多人。另外的办法就是，人力复用，一个 CPU 小伙伴干多个项目，干不过来，就加加班，实在不行就 996，这样应该就没问题了。

一旦涉及一个 CPU 小伙伴同时参与多个项目，就非常考验项目管理的水平了。如何排期、如何调度，是一个大学问。例如，有的项目比较紧急，应该先进行排期；有的项目可以缓缓，但是也不能让客户等太久。所以这个过程非常复杂，需要平衡。

对于操作系统来讲，他面对的 CPU 的数量是有限的，干活儿都是他们，但是进程数目远远超过 CPU 的数目，因而就需要进行进程的调度，有效地分配 CPU 的时间，既要保证进程的最快响应，也要保证进程之间的公平。

如何调度呢？周瑜能够想到的方式就是排队。每一个 CPU 小伙伴旁边都有一个白板，上面写着自己需要完成的任务，来了新任务就写到白板上，做完了就擦掉。

一个 CPU 上有一个队列，队列里面是一系列 sched_entity，每个 sched_entity 都属于一个 task_struct，代表进程或者线程。

调度要解决的第一个问题是，每一个 CPU 小伙伴每过一段时间，都要想一下，白板上这么多项目，我应该干哪一个？CPU 的队列里面有这么多的进程或者线程，应该取出哪一个来执行？

这就是调度规则或者调度算法的问题。

周瑜说，他原来在大公司的时候，调度算法常用是这样设计的。

一个是公平性，对于接到的多个项目，不能厚此薄彼。这个算法主要由 fair_sched_class 实现，fair 就是公平的意思。

另一个是优先级，有的项目要急一点，客户出的钱多，所以应该多分配一些精力在高优先级的项目里面。

在 Linux 里面，讲究的公平可不是一般的公平，而是 CFS 调度算法，CFS 全称是 Completely Fair Scheduling，完全公平调度。

为了公平，项目经理需要记录下进程的运行时间。CPU 会提供一个时钟，过一段时间就触发一个时钟中断。就像咱们的表滴答一下，这个我们叫 Tick。CFS 会为每一个进程安排一个虚拟运行时间 vruntime。如果一个进程在运行，随着时间的增长，也就是一个个 Tick 的到来，进程的 vruntime 将不断增大。没有得到执行的进程 vruntime 不变。

显然，那些 vruntime 少的，原来受到了不公平的对待，需要给他补上，所以会优先运行这样的进程。

这有点儿像让你把一筐球平均分到 N 个口袋里面，你看着哪个少，就多放一些；哪个多了，就先不放。这样经过多轮，虽然不能保证球完全一样多，但是也差不多公平。

有时候，进程会分优先级，如何给优先级高的进程多分时间呢？

这个简单，就相当于 N 个口袋，优先级高的袋子大，优先级低的袋子小。这样球就不能按照个数分配了，要按照比例来，大口袋的放了一半和小口袋放了一半，里面的球数目虽然差很多，也认为是公平的。

函数 update_curr 用于更新进程运行的统计量 vruntime ，CFS 还需要一个数据结构来对 vruntime 进行排序，找出最小的那个。在这里使用的是红黑树。红黑树的的节点是 sched_entity，里面包含 vruntime。

调度算法的本质就是解决下一个进程应该轮到谁运行的问题，这个逻辑在 fair_sched_class.pick_next_task 中完成。

调度要解决的第二个问题是，什么时候切换任务？也即，什么时候，CPU 小伙伴应该停下一个进程，换另一个进程运行？

一个人在做 A 项目，在某个时刻，换成做 B 项目去了。发生这种情况，主要有两种方式。

方式一，A 项目做着做着，里面有一条指令 sleep，也就是要休息一下，或者等待某个 I/O 事件。那没办法了，要主动让出 CPU，然后可以开始做 B 项目。主动让出 CPU 的进程，会主动调用 schedule() 函数。

在 schedule() 函数中，会通过 fair_sched_class.pick_next_task，在红黑树形成的队列上取出下一个进程，然后调用 context_switch 进行进程上下文切换。

进程上下文切换主要干两件事情，一是切换进程空间，也即进程的内存，也即 CPU 小伙伴不能 A 项目的会议室里面干活了，要跑到 B 项目的会议室去。二是切换寄存器和 CPU 上下文，也即 CPU 将当期在 A 项目中干到哪里了，记录下来，方便以后接着干。

方式二，A 项目做着做着，旷日持久，实在受不了了。项目经理介入了，说这个项目 A 先停停，B 项目也要做一下，要不然 B 项目该投诉了。最常见的现象就是，A 进程执行时间太长了，是时候切换到 B 进程了。这个时候叫作 A 进程被被动抢占。

抢占还要通过 CPU 的时钟 Tick，来衡量进程的运行时间。时钟 Tick 一下，是很好查看是否需要抢占的时间点。 时钟中断处理函数会调用 scheduler_tick()，他会调用 fair_sched_class 的 task_tick_fair，在这里面会调用 update_curr 更新运行时间。当发现当前进程应该被抢占，不能直接把他踢下来，而是把他标记为应该被抢占，打上一个标签 TIF_NEED_RESCHED。

另外一个可能抢占的场景发生在，当一个进程被唤醒的时候。一个进程在等待一个 I/O 的时候，会主动放弃 CPU。但是，当 I/O 到来的时候，进程往往会被唤醒。这个时候是一个时机。当被唤醒的进程优先级高于 CPU 上的当前进程，就会触发抢占。如果应该发生抢占，也不是直接踢走当然进程，而也是将当前进程标记为应该被抢占，打上一个标签 TIF_NEED_RESCHED。

真正的抢占还是需要上下文切换，也就是需要那么一个时刻，让正在运行中的进程有机会调用一下 schedule。调用 schedule 有以下四个时机。

对于用户态的进程来讲，从系统调用中返回的那个时刻，是一个被抢占的时机。

对于用户态的进程来讲，从中断中返回的那个时刻，也是一个被抢占的时机。

对内核态的执行中，被抢占的时机一般发生在 preempt_enable() 中。在内核态的执行中，有的操作是不能被中断的，所以在进行这些操作之前，总是先调用 preempt_disable() 关闭抢占。再次打开的时候，就是一次内核态代码被抢占的机会。

在内核态也会遇到中断的情况，当中断返回的时候，返回的仍然是内核态。这个时候也是一个执行抢占的时机。

周瑜和张昭商定了这个规则，然后给 CPU 小伙伴们交代之后，项目虽然越来越多，但是也井井有条起来。CPU 小伙伴不会像原来一样火急火燎，不知所从了。

可是其实对于项目的开发，这家公司还是有严重漏洞的，就是项目的保密问题，不管哪家客户将系统外包出去，肯定也不想让其他公司知道详情。如果解决不好这个问题，没人敢把重要的项目交给这家公司，小马的公司也就永远只能接点边角系统，还是不能保证温饱问题。

那接下来，小马会怎么解决项目之间的保密问题呢？欲知后事，且听下回分解。

64 | 知识串讲：用一个创业故事串起操作系统原理（三）

上一节我们说到，周瑜和张昭商定了调用 schedule 的时机。尽管项目越来越多，但是也井井有条。可是我们也说了，不管你的事情做得有多好，项目保密问题都是要解决的重要问题。怎么解决呢？今天我们就来看一看。

保密需封闭开发，空间小巧妙安排

慢慢地，小马发现，项目接的多了之后，CPU 小伙伴的任务调度问题解决了之后，会议室的使用经常陷入混乱。不同的项目使用会议室的时候，经常冲突，一个项目组没用完，另一个项目组就在那里等着，十分耽误开发效率。

小马说：“要不咱们的项目别用会议室封闭开发了，原来总是说封闭开发，就是为了隔离，保密。这对于公司声誉来说很重要，但是能不能通过签订保密协议的方式来，干嘛非得封闭开发呢？”

周瑜说：“马哥，以我在大公司管理项目的经验来看，您还是想简单了。”

“你看，每次你接一个项目，总要写成项目执行计划书，CPU 小伙伴们才能执行吧，项目计划书中的一行一行指令运行过程中，免不了要产生一些数据。这些数据要保存在一个地方，这个地方就是会议室（内存）。会议室（内存）被分成一块一块儿的，都编好了号。例如 3F-10，就是三楼十号会议室。这个地址是实实在在的地址，通过这个地址我们就能够定位到物理内存的位置。”

“现在问题来了，写项目执行计划书的时候，里面的指令使用的地址是否可以使用物理地址呢？当然不行了，项目执行计划书，都是事先写好的，可以多次运行的。如果里面有个指令是，要把用户输入的数字保存在内存中，那就会有问题。”

“会产生什么问题呢？我举个例子你就明白了。如果我们使用那个实实在在的地址，3F-10，打开三个相同的程序，都执行到某一步。比方说，打开了三个计算器，用户在这三个程序的界面上分别输入了 10、100、1000。如果内存中的这个位置只能保存一个数，那应该保存哪个呢？这不就冲突了吗？”

“如果不用这个实实在在的地址，那应该怎么办呢？那就必须用封闭开发的办法。

每个项目的物理地址对于进程不可见，谁也不能直接访问这个物理地址。操作系统会给进程分配一个虚拟地址。所有进程看到的这个地址都是一样的，里面的内存都是从 0 开始编号。

在程序里面，指令写入的地址是虚拟地址。例如，位置为 10M 的内存区域，操作系统会提供一种机制，将不同进程的虚拟地址和内存的物理地址映射起来。

当程序要访问虚拟地址的时候，由内核的数据结构进行转换，转换成不同的物理地址，这样不同的进程运行的时候，写入的是不同的物理地址，就不会冲突了。”

小马想想，对啊，这是个好办法，咱们得规划一套会议室管理系统（内存管理）。根据刚才的分析，这个系统应该包含以下三个部分：

第一，物理内存的管理，相当于会议室管理员管理会议室；

第二，虚拟地址的管理，也即在项目组的视角，会议室的虚拟地址应该如何组织；

第三，虚拟地址和物理地址如何映射的问题，也即会议室管理员如果管理映射表。

我们先来盘点一下物理内存的情况。

不同的园区工位的安排和会议室的布局各不相同。

第一种情况是，CPU 小伙伴们坐在一起，会议室在楼层的另一面，大家到会议室里面去都要通过统一的过道，优点简单，缺点是通道会成为瓶颈。

第二种情况是，会议室分成多个节点，离散地分布在 CPU 小伙伴周围。有的小伙伴去这个会议室近一些，有的小伙伴离另外一些会议室近一些。这样做的优点是，如果 CPU 小伙伴干活总是能够去离他最近的会议室，则速度非常快，但是一旦离他最近的会议室被占用了，他只能去其他会议室，这样就比较远了。

现在的园区基本都设计成第二种样子，也即会议室（内存）要分节点，每个节点用 struct pglist_data 表示。

每个节点里面再分区域，用于区分内存不同部分的不同用法。ZONE_NORMAL 是最常用的区域。ZONE_MOVABLE 是可移动区域。我们通过将物理内存划分为，可移动分配区域和不可移动分配区域，来避免内存碎片。每个区域用 struct zone 表示，也放在一个数组里面。

每个区域里面再分页。默认的大小为 4KB。这就相当于每个会议室的最小单位。

如果有项目要使用会议室，应该如何分配呢？不能任何项目来了，咱都给他整个会议室。会议室也是可以再分割的，例如在中间拼起一堵墙，这样一个会议室就可以分成两个，继续分，可以再分成四个 1/4 大小的会议室，直到不能再分，我们就能得到一页的大小。

物理页面分配的时候，也可以采取这样的思路，我们称为伙伴系统。

空闲页放在 struct free_area 里面，每一页用 struct page 表示。

把所有的空闲页分组为 11 个页块链表，每个块链表分别包含很多个大小的页块，有 1、2、4、8、16、32、64、128、256、512 和 1024 个连续页的页块。最大可以申请 1024 个连续页，对应 4MB 大小的连续内存。每个页块的第一个页的物理地址是该页块大小的整数倍。

例如，要请求一个 128 个页的页块时，我们要先检查 128 个页的页块链表是否有空闲块。如果没有，则查 256 个页的页块链表；如果有空闲块的话，则将 256 个页的页块分成两份，一份使用，一份插入 128 个页的页块链表中。如果还是没有，就查 512 个页的页块链表；如果有的话，就分裂为 128、128、256 三个页块，一个 128 的使用，剩余两个插入对应页块链表。

把物理页面分成一块一块大小相同的页，这样带来的另一个好处是，当有的内存页面长时间不用了，可以暂时写到硬盘上，我们称为换出。一旦需要的时候，再加载进来，就叫作换入。这样可以扩大可用物理内存的大小，提高物理内存的利用率。在内核里面，也即张昭的管理下，有一个进程 kswapd，可以根据物理页面的使用情况，对页面进行换入换出。

小马觉得这种方式太好了，如此高效地使用会议室，公司不用租用多少会议室，就能解决当前的项目问题了。

会议室排列有序，分视角各有洞天

周瑜说，“你先别急，这还仅仅是会议室物理地址的管理，每一个项目组能够看到的虚拟地址，咱还没规划呢！这个规划不好，执行项目还是会有问题的。”

每个项目组能看到的虚拟地址怎么规划呢？我们要给项目组这样一种感觉，从项目组的角度，也即从虚的角度来看，这一大片连续的内存空间都是他们的了。

如果是 32 位，有 2^32 = 4G 的内存空间都是他们的，不管内存是不是真的有 4G。如果是 64 位，在 x86_64 下面，其实只使用了 48 位，那也挺恐怖的。48 位地址长度也就是对应了 256TB 的地址空间。

小马说：“我都没怎么见过 256T 的硬盘，别说是内存了。”

周瑜接着说：“现在，一个项目组觉得，会议室可比世界首富房子还大。虽然是虚拟的，下面尽情地去排列咱们要放的东西吧！请记住，现在我们是站在一个进程的角度，去看这个虚拟的空间，不用管其他进程。”

首先，这么大的虚拟空间一切二，一部分用来放内核的东西，称为内核空间；一部分用来放进程的东西，称为用户空间。用户空间在下，在低地址，我们假设是 0 号到 29 号会议室；内核空间在上，在高地址，我们假设是 30 号到 39 号会议室。这两部分空间的分界线，因为 32 位和 64 位的不同而不同，我们这里不深究。

对于普通进程来说，内核空间的那部分，虽然虚拟地址在那里，但是不能访问。这就像作为普通员工，你明明知道财务办公室在这个 30 号会议室门里面，但是门上挂着“闲人免进”，你只能在自己的用户空间里面折腾。

我们从最低位开始排起，先是 Text Segment、Data Segment 和 BSS Segment。Text Segment 是存放二进制可执行代码的位置，Data Segment 存放静态常量，BSS Segment 存放未初始化的静态变量。这些都是在项目执行计划书里面有的。

接下来是堆段。堆是往高地址增长的，是用来动态分配内存的区域，malloc 就是在这里面分配的。

接下来的区域是 Memory Mapping Segment。这块地址可以用来把文件映射进内存用的，如果二进制的执行文件依赖于某个动态链接库，就是在这个区域里面将 so 文件映射到了内存中。

再下面就是栈地址段了，主线程的函数调用的函数栈就是用这里的。

如果普通进程还想进一步访问内核空间，是没办法的，只能眼巴巴地看着。如果需要进行更高权限的工作，就需要调用系统调用，进入内核。

一旦进入了内核，就换了一副视角。刚才是普通进程的视角，觉着整个空间是它独占的，没有其他进程存在。当然另一个进程也这样认为，因为它们互相看不到对方。这也就是说，不同进程的 0 号到 29 号会议室放的东西都不一样。

但是，到了内核里面，无论是从哪个进程进来的，看到的是同一个内核空间，看到的是同一个进程列表。虽然内核栈是各用个的，但是如果想知道的话，还是能够知道每个进程的内核栈在哪里的。所以，如果要访问一些公共的数据结构，需要进行锁保护。也就是说，不同的进程进入到内核后，进入的 30 号到 39 号会议室是同一批会议室。

内核的代码访问内核的数据结构，大部分的情况下都是使用虚拟地址的。虽然内核代码权限很大，但是能够使用的虚拟地址范围也只能在内核空间，也即内核代码访问内核数据结构，只能用 30 号到 39 号这些编号，不能用 0 到 29 号，因为这些是被进程空间占用的。而且，进程有很多个。你现在在内核，但是你不知道当前指的 0 号是哪个进程的 0 号。

在内核里面也会有内核的代码，同样有 Text Segment、Data Segment 和 BSS Segment，内核代码也是 ELF 格式的。

不过有了这个规定以后，项目执行计划书要写入数据的时候，就需要符合里面的规定了，数据不能随便乱放了。

小马说，“没问题，这个作为项目章程，每一个新员工来了都培训。”

管理系统全搞定，至此生存无问题

周瑜接着说：“物理会议室和虚拟空间都分成大小相同的页，我们还得有一个会议室管理系统，将两者关联起来，这样项目组申请会议室的时候，也有个系统可以统一的管理，要不然会议室还不得老冲突呀。”

对于虚拟内存的访问，也是有一个地址的，我们需要找到一种策略，实现从虚拟地址到物理地址的转换。

为了能够定位和访问每个页，需要有个页表，保存每个页的起始地址，再加上在页内的偏移量，组成线性地址，就能对于内存中的每个位置进行访问了。

虚拟地址分为两部分，页号和页内偏移。页号作为页表的索引，页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。

下面的图，举了一个简单的页表的例子，虚拟内存中的页通过页表映射对应到物理内存中的页。

32 位环境下，虚拟地址空间共 4GB。如果分成 4KB 一个页，那就是 1M 个页。每个页表项需要 4 个字节来存储，那么整个 4GB 空间的映射就需要 4MB 的内存来存储映射表。如果每个进程都有自己的映射表，100 个进程就需要 400MB 的内存。对于内核来讲，有点大了 。

页表中所有页表项必须提前建好，并且要求是连续的。如果不连续，就没有办法通过虚拟地址里面的页号找到对应的页表项了。

那怎么办呢？我们可以试着将页表再分页，4G 的空间需要 4M 的页表来存储映射。我们把这 4M 分成 1K（1024）个 4K，每个 4K 又能放在一页里面，这样 1K 个 4K 就是 1K 个页，这 1K 个页也需要一个表进行管理，我们称为页目录表，这个页目录表里面有 1K 项，每项 4 个字节，页目录表大小也是 4K。

页目录有 1K 项，用 10 位就可以表示访问页目录的哪一项。这一项其实对应的是一整页的页表项，也即 4K 的页表项。每个页表项也是 4 个字节，因而一整页的页表项是 1k 个。再用 10 位就可以表示访问页表项的哪一项，页表项中的一项对应的就是一个页，是存放数据的页，这个页的大小是 4K，用 12 位可以定位这个页内的任何一个位置。

这样加起来正好 32 位，也就是用前 10 位定位到页目录表中的一项。将这一项对应的页表取出来共 1k 项，再用中间 10 位定位到页表中的一项，将这一项对应的存放数据的页取出来，再用最后 12 位定位到页中的具体位置访问数据。

你可能会问，如果这样的话，映射 4GB 地址空间就需要 4MB+4KB 的内存，这样不是更大了吗？ 当然如果页是满的，当时是更大了，但是，我们往往不会为一个进程分配那么多内存。

比如说，上面图中，我们假设只给这个进程分配了一个数据页。如果只使用页表，也需要完整的 1M 个页表项共 4M 的内存，但是如果使用了页目录，页目录需要 1K 个全部分配，占用内存 4K，但是里面只有一项使用了。到了页表项，只需要分配能够管理那个数据页的页表项页就可以了，也就是说，最多 4K，这样内存就节省多了。

当然对于 64 位的系统，两级肯定不够了，就变成了四级目录，分别是全局页目录项 PGD（Page Global Directory）、上层页目录项 PUD（Page Upper Directory）、中间页目录项 PMD（Page Middle Directory）和页表项 PTE（Page Table Entry）。

设计完毕会议室管理系统，再加上前面的项目管理系统，对于一家外包公司来讲，无论接什么样的项目都能轻松搞定了。我们常把 CPU 和内存合称为计算。至此，计算的问题就算搞定了。解决了这两大问题，一家外包公司的生存问题，就算解决了。

小马总算是可以松一口气了，他和周瑜、张昭好好地搓了一顿，喝得昏天黑地。周瑜和张昭纷纷感慨，幸亏当年跟了马哥，今日才有出头之日。

生存问题虽然解决了，马哥可非池中之物，接下来要解决的就是发展问题，马哥能想出什么办法进一步壮大企业呢？欲知后事，且听下回分解。

65 | 知识串讲：用一个创业故事串起操作系统原理（四）

上一节，小马的公司已经解决了生存问题，成功从小马晋升马哥。

马哥是一个有危机意识的人。尽管公司开始不断盈利，项目像流水一样，一个接一个，赚了点儿钱，但是他感觉还是有点儿像狗熊掰棒子。因为公司没有积累，永远就都是在做小生意，无法实现成倍的增长。

马哥想，公司做了这么多的项目，应该有很多的共同点，能积累下来非常多的资料。如果能够把这些资料归档、总结、积累，形成核心竞争力，就可以随着行业的飞跃，深耕一个行业，实现快速增长。

公司发展需积累，马哥建立知识库

这就需要我们有一个存放资料的档案库（文件系统）。档案库应该不依赖于项目而独立存在，应该井井有条、利于查询；应该长久保存，不随人员流动而损失。

公司到了这个阶段，除了周瑜和张昭，应该专门请一个能够积累核心竞争力的人来主持大局了。马哥想到了，前一阵行业交流大会上，他遇到了一个很牛的架构师——鲁肃。他感觉鲁肃在这方面很有想法，于是就请他来主持大局。

鲁肃跟马哥说，构建公司的核心技术能力，这个档案库（文件系统）也可以叫作知识库，这个需要好好规划一下。规划文件系统的时候，需要考虑以下几点。

第一点，文件系统要有严格的组织形式，使得文件能够以块为单位进行存储。

这就像图书馆里，我们会给设置一排排书架，然后再把书架分成一个个小格子。有的项目存放的资料非常多，一个格子放不下，就需要多个格子来进行存放。我们把这个区域称为存放原始资料的仓库区。对于操作系统，硬盘分成相同大小的单元，我们称为块。一块的大小是扇区大小的整数倍，默认是 4K，用来存放文件的数据部分。这样一来，如果我们像存放一个文件，就不用给他分配一块连续的空间了。我们可以分散成一个个小块进行存放。这样就灵活得多，也比较容易添加、删除和插入数据。

第二点，文件系统中也要有索引区，用来方便查找一个文件分成的多个块都存放在了什么位置。

这就好比，图书馆的书太多了，为了方便查找，我们需要专门设置一排书架，这里面会写清楚整个档案库有哪些资料，资料在哪个架子的哪个格子上。这样找资料的时候就不用跑遍整个档案库，只要在这个书架上找到后，直奔目标书架就可以了。

在 Linux 操作系统里面，每一个文件有一个 Inode，inode 的“i”是 index 的意思，其实就是“索引”。inode 里面有文件的读写权限 i_mode，属于哪个用户 i_uid，哪个组 i_gid，大小是多少 i_size_io，占用多少个块 i_blocks_io。“某个文件分成几块、每一块在哪里”，这些信息也在 inode 里面，保存在 i_block 里面。

第三点，如果文件系统中有的文件是热点文件，近期经常被读取和写入，文件系统应该有缓存层。

这就相当于图书馆里面的热门图书区，这里面的书都是畅销书或者是常常被借还的图书。因为借还的次数比较多，那就没必要每次有人还了之后，还放回遥远的货架，我们可以专门开辟一个区域，放置这些借还频次高的图书。这样借还的效率就会提高。

第四点，文件应该用文件夹的形式组织起来，方便管理和查询。

这就像在图书馆里面，你可以给这些资料分门别类，比如分成计算机类、文学类、历史类等等。这样你也容易管理，项目组借阅的时候只要在某个类别中去找就可以了。

在文件系统中，每个文件都有一个名字，我们访问一个文件，希望通过他的名字就可以找到。文件名就是一个普通的文本，所以文件名经常会冲突，不同用户取相同的名字的情况会经常出现的。

要想把很多的文件有序地组织起来，我们就需要把他们做成目录或者文件夹。这样，一个文件夹里可以包含文件夹，也可以包含文件，这样就形成了一种树形结构。我们可以将不同的用户放在不同的用户目录下，就可以一定程度上避免了命名的冲突问题。

第五点，Linux 内核要在自己的内存里面维护一套数据结构，来保存哪些文件被哪些进程打开和使用。

这就好比，图书馆里会有个图书管理系统，记录哪些书被借阅了，被谁借阅了，借阅了多久，什么时候归还。

这个图书管理系统尤为重要，如果不是很方便使用，以后项目中积累了经验，就没有人愿意往知识库里面放了。

无论哪个项目（进程），都可以通过 write 系统调用写入知识库。

对于每一个进程，打开的文件都有一个文件描述符。files_struct 里面会有文件描述符数组。每个一个文件描述符是这个数组的下标，里面的内容指向一个 struct file 结构，表示打开的文件。这个结构里面有这个文件对应的 inode，最重要的是这个文件对应的操作 file_operation。如果操作这个文件，就看这个 file_operation 里面的定义了。

每一个打开的文件，都有一个 dentry 对应，虽然我们叫作 directory entry，但是他不仅仅表示文件夹，也表示文件。他最重要的作用就是指向这个文件对应的 inode。

如果说 file 结构是一个文件打开以后才创建的，dentry 是放在一个 dentry cache 里面的。文件关闭了，他依然存在，因而他可以更长期的维护内存中的文件的表示和硬盘上文件的表示之间的关系。

inode 结构就表示硬盘上的 inode，包括块设备号等。这个 inode 对应的操作保存在 inode operations 里面。真正写入数据，是写入硬盘上的文件系统，例如 ext4 文件系统。

马哥听了知识库和档案库的设计，非常开心，对鲁肃说，你这五大秘籍，可是帮了我大忙了。于是马上下令实施。

有了积累建生态，成立渠道管理部

有了知识库，公司的面貌果然大为改观。

马哥发现，当知识库积累到一定程度，公司接任何项目都能找到相似的旧项目作为参考，不用重新设计，效率大大提高。而且最重要的一点是，没有知识库的时候，原来项目做的好不好，完全取决于程序员，因为所有的知识都在程序员的脑子里，所以公司必须要招聘高质量的程序员，才能保证项目的质量。一方面优秀的程序员数量很少，这大大限制了公司能够接项目的规模，一方面优秀的程序员实在太贵，大大提高了公司的成本。

有了知识库，依赖于原来积累的体系，只要找到类似的旧项目，哪怕是普通的程序员，只要会照猫画虎，结果就不会太差。

于是，马哥马上想到，现在公司只有百十来号人，能赚这些钱，现在招人门槛降低了，我要是招聘一万人，这能赚多少钱啊！

鲁肃对马哥说，“你可先别急着招人，建立知识库，降低招人成本才是第一步。公司招聘太多人不容易管理。既然项目的执行可以照猫画虎，很多项目可以不用咱们公司来，我们可以建立渠道销售体系（输入和输出系统），让供应商、渠道帮我们卖，形成一个生态。这公司的盈利规模可就不是招一万人这么点儿了，这是指数级的增长啊！”

计算机系统的输入和输出系统都有哪些呢？我们能举出来的，例如键盘、鼠标、显示器、网卡、硬盘、打印机、CD/DVD 等等，多种多样。这样，当然方便用户使用了，但是对于操作系统来讲，却是一件复杂的事情，因为这么多设备，形状、用法、功能都不一样，怎么才能统一管理起来呢？我们一层一层来看。

第一层，用设备控制器屏蔽设备差异。

马哥说，“把生意做到全国，我也想过，这个可不容易。咱们客户多种多样，众口难调，不同的地域不一样，不同的行业不一样。如果你不懂某个地方的规矩，根本卖不出去东西；如果你不懂某个具体行业的使用场景，也无法满足客户的需求。”

鲁肃说：“所以说，建议您建立生态，设置很多代理商，让各个地区和各个行业的代理商帮你屏蔽这些差异化。你和代理商之间只要进行简单的标准产品交付就可以了。”

计算机系统就是这样的。CPU 并不直接和设备打交道，他们中间有一个叫作设备控制器（Device Control Unit）的组件。例如，硬盘有磁盘控制器、USB 有 USB 控制器、显示器有视频控制器等。这些控制器就像代理商一样，他们知道如何应对硬盘、鼠标、键盘、显示器的行为。

你的代理商往往是小公司。控制器其实有点儿像一台小电脑。他有他的芯片，类似小 CPU，执行自己的逻辑。他也有他的寄存器。这样 CPU 就可以通过写这些寄存器，对控制器下发指令，通过读这些寄存器，查看控制器对于设备的操作状态。

CPU 对于寄存器的读写，可比直接控制硬件，要标准和轻松很多。这就相当于你和代理商的标准产品交付。

第二层，用驱动程序屏蔽设备控制器差异。

马哥说：“你这么一说，还真有道理，如果我们能够找到足够多的代理商，那就高枕无忧了。”

鲁肃说：“其实事情还没这么简单，虽然代理商机制能够帮我们屏蔽很多设备的细节，但是从上面的描述我们可以看出，由于每种设备的控制器的寄存器、缓冲区等使用模式，指令都不同。对于咱们公司来讲，就需要有个部门专门对接代理商，向其他部门屏蔽代理商的差异，成立公司的渠道管理部门。”

那对于操作系统来讲，渠道管理部门就是用来对接各个设备控制器的设备驱动程序。

这里需要注意的是，设备控制器不属于操作系统的一部分，但是设备驱动程序属于操作系统的一部分。操作系统的内核代码可以像调用本地代码一样调用驱动程序的代码，而驱动程序的代码需要发出特殊的面向设备控制器的指令，才能操作设备控制器。

设备驱动程序中是一些面向特殊设备控制器的代码。不同的设备不同。但是对于操作系统其他部分的代码而言，设备驱动程序应该有统一的接口。就像下面图中的一样，不同的设备驱动程序，可以以同样的方式接入操作系统，而操作系统的其他部分的代码，也可以无视不同设备的区别，以同样的接口调用设备驱动程序。

第三，用中断控制器统一外部事件处理。

马哥听了恍然大悟：“原来代理商也是五花八门，里面有这么多门道啊！”

鲁肃说：“当咱们对接的代理商多了，代理商可能会有各种各样的问题找到我们，例如代理商有了新客户，客户有了新需求，客户交付完毕等事件，都需要有一种机制通知你们公司，当然是中断，那操作系统就需要有一个地方处理这个中断，既然设备驱动程序是用来对接设备控制器的，中断处理也应该在设备驱动里面完成。”

然而，中断的触发最终会到达 CPU，会中断操作系统当前运行的程序，所以操作系统也要有一个统一的流程来处理中断，使得不同设备的中断使用统一的流程。

一般的流程是，一个设备驱动程序初始化的时候，要先注册一个该设备的中断处理函数。咱们讲进程切换的时候说过，中断返回的那一刻是进程切换的时机。中断的时候，触发的函数是 do_IRQ。这个函数是中断处理的统一入口。在这个函数里面，我们可以找到设备驱动程序注册的中断处理函数 Handler，然后执行他进行中断处理。

第四，用文件系统接口屏蔽驱动程序的差异。

马哥又问了：“对接了这么多代理商，如果咱们内部的工程师要和他们打交道，有没有一种统一的方式呢？”

鲁肃说：“当然应该了，我们内部员工操作外部设备，可以基于文件系统的接口，制定一个统一的标准。”

其实文件系统的机制是一个非常好的机制，咱们公司应该定下这样的规则，一切皆文件。

所有设备都在 /dev/ 文件夹下面，创建一个特殊的设备文件。这个设备特殊文件也有 inode，但是他不关联到硬盘或任何其他存储介质上的数据，而是建立了与某个设备驱动程序的连接。

有了文件系统接口之后，我们不但可以通过文件系统的命令行操作设备，也可以通过程序，调用 read、write 函数，像读写文件一样操作设备。

对于块设备来讲，在驱动程序之上，文件系统之下，还需要一层通用设备层。比如，咱们讲的文件系统，里面的逻辑和磁盘设备没有什么关系，可以说是通用的逻辑。在写文件的最底层，我们看到了 BIO 字眼的函数，但是好像和设备驱动也没有什么关系。

是的，因为块设备类型非常多，而 Linux 操作系统里面一切是文件。我们也不想文件系统以下，就直接对接各种各样的块设备驱动程序，这样会使得文件系统的复杂度非常高。所以，我们在中间加了一层通用块层，将与块设备相关的通用逻辑放在这一层，维护与设备无关的块的大小，然后通用块层下面对接各种各样的驱动程序。

鲁肃帮助马哥建立了这套体系之后，果真业务有了很大起色。原来公司只敢接华东区的项目，毕竟比较近，沟通交付都很方便。后来项目扩展到所有一线城市、二线城市、省会城市，项目数量实现了几十倍的增长。

千万项目难度大，集体合作可断金

项目接的多了，就不免有大型的项目，涉及多个行业多个领域，需要多个项目组进行合作才能完成。那两个项目组应该通过什么样的方式，进行沟通与合作呢？作为老板，马哥应该如何设计整个流程呢？

马哥叫来周瑜、张昭、鲁肃，一起商量团队间的合作模式。大家一起献计献策。好在有很多成熟的项目管理流程可以参考。

最最传统的模型就是软件开发的瀑布模型。所谓的瀑布模型，其实就是将整个软件开发过程分成多个阶段，往往是上一个阶段完全做完，才将输出结果交给下一个阶段。这种模型类似进程间通信的管道模型。

所谓的管道，就是在两个进程之间建立一条单向的通道，其实是一段缓存，它会将前一个命令的输出，作为后一个命令的输入。

张昭说，瀑布模型的开发流程效率比较低下，现在大部分公司都不使用这种开发模式了，因为团队之间无法频繁地沟通。而且，管道的使用模式，也不适合进程间频繁的交换数据。

于是，他们还得想其他的办法。是不是可以借鉴传统外企的沟通方式——邮件呢？邮件有一定的格式，例如抬头、正文、附件等。发送邮件可以建立收件人列表，所有在这个列表中的人，都可以反复地在此邮件基础上回复，达到频繁沟通的目的。这个啊，就是消息队列模型。

和管道将信息一股脑儿地从一个进程，倒给另一个进程不同，消息队列有点儿像邮件，发送数据时，会分成一个一个独立的数据单元，也就是消息体，每个消息体都是固定大小的存储块，在字节流上不连续。

有了消息这种模型，两个进程之间的通信就像咱们平时发邮件一样，你来一封，我回一封，可以频繁沟通了。

但是有时候，项目组之间的沟通需要特别紧密，而且要分享一些比较大的数据。如果使用邮件，就发现，一方面邮件的来去不及时；另外一方面，附件大小也有限制，所以，这个时候，我们经常采取的方式就是，把两个项目组在需要合作的期间，拉到一个会议室进行合作开发，这样大家可以直接交流文档呀，架构图呀，直接在白板上画或者直接扔给对方，就可以直接看到。

可以看出来，共享会议室这种模型，类似进程间通信的共享内存模型。前面咱们讲内存管理的时候，知道每个进程都有自己独立的虚拟内存空间，不同的进程的虚拟内存空间映射到不同的物理内存中去。这个进程访问 A 地址和另一个进程访问 A 地址，其实访问的是不同的物理内存地址，对于数据的增删查改互不影响。

但是，咱们是不是可以变通一下，拿出一块虚拟地址空间来，映射到相同的物理内存中。这样这个进程写入的东西，另外一个进程马上就能看到了，都不需要拷贝来拷贝去，传来传去。

马哥说：“共享内存也有问题呀。如果两个进程使用同一个共享内存，大家都往里面写东西，很有可能就冲突了。例如两个进程都同时写一个地址，那先写的那个进程会发现内容被别人覆盖了。”

张昭说：“当然，和共享内存配合的，有另一种保护机制，使得同一个共享的资源，同时只能被一个进程访问叫信号量。”

信号量其实是一个计数器，主要用于实现进程间的互斥与同步，而不是用于存储进程间通信数据。

我们可以将信号量初始化为一个数值，来代表某种资源的总体数量。对于信号量来讲，会定义两种原子操作，一个是 P 操作，我们称为申请资源操作。这个操作会申请将信号量的数值减去 N，表示这些数量被他申请使用了，其他人不能用了。另一个是 V 操作，我们称为归还资源操作，这个操作会申请将信号量加上 M，表示这些数量已经还给信号量了，其他人可以使用了。

例如，你有 100 元钱，就可以将信号量设置为 100。其中 A 向你借 80 元，就会调用 P 操作，申请减去 80。如果同时 B 向你借 50 元，但是 B 的 P 操作比 A 晚，那就没有办法，只好等待 A 归还钱的时候，B 的 P 操作才能成功。之后，A 调用 V 操作，申请加上 30 元，也就是还给你 30 元，这个时候信号量有 50 元了，这时候 B 的 P 操作才能成功，才能借走这 50 元。

所谓原子操作（Atomic Operation），就是任何一块钱，都只能通过 P 操作借给一个人，不能同时借给两个人。也就是说，当 A 的 P 操作（借 80）和 B 的 P 操作（借 50），几乎同时到达的时候，不能因为大家都看到账户里有 100 就都成功，必须分个先来后到。

马哥说：“有了上面的这些机制，基本常规状态下的工作模式，对应到咱们平时的工作交接，收发邮件、联合开发等。我还想到，如果发生了异常怎么办？例如出现线上系统故障，这个时候，什么流程都来不及了，不可能发邮件，也来不及开会，所有的架构师、开发、运维都要被通知紧急出动。所以，7 乘 24 小时不间断执行的系统都需要有告警系统，一旦出事情，就要通知到人，哪怕是半夜，也要电话叫起来，处理故障。是不是应该还有一种异常情况下的工作模式。”

张昭说：“当然应该有，我们可以建立像操作系统里面的信号机制。信号没有特别复杂的数据结构，就是用一个代号一样的数字。Linux 提供了几十种信号，分别代表不同的意义。信号之间依靠它们的值来区分。这就像咱们看警匪片，对于紧急的行动，都是说，‘1 号作战任务’开始执行，警察就开始行动了。情况紧急，不能啰里啰嗦了。”

信号可以在任何时候发送给某一进程，进程需要为这个信号配置信号处理函数。当某个信号发生的时候，就默认执行这个函数就可以了。这就相当于咱们运维一个系统应急手册，当遇到什么情况，做什么事情，都事先准备好，出了事情照着做就可以了。

这些项目组合作的流程设计合理，因而推行起来十分顺畅，现在接个千万级别的项目没有任何问题，根据交易量估值市值，起码有十个亿。

马哥有些小激动，原来自己身价这么高了，是不是也能上个市啥的，实现亿万富翁的梦想呢？于是马哥找了一些投资人聊了聊，投资人说，要想冲一把上市，还差点劲，目前的项目虽然大，但是想象力不够丰富。

那接下来，马哥如何做才能满足市场的想象力，最终成功上市呢？预知后事，且听下回分解。

66 | 知识串讲：用一个创业故事串起操作系统原理（五）

上一节我们说到，马哥的公司现在接个千万级别的项目没有任何问题，但是投资人说，要想冲一把上市，还差点劲，目前的项目虽然大，但是想象力不够丰富。

亿级项目创品牌，战略合作遵协议

马哥突然想到，西部有一个智慧城市的打单，金额几个亿，绝对标杆性质的。如果能够参与其中，应该是很有想象力的事情。

可是，甲方明确地说，“整个智慧城市的建设体系非常的大，一家公司做不下来，需要多家公司合作才能完成。你们有多家公司合作的经验和机制吗？”

马哥咬牙说道：“当然有！”先应下来再说呗，可是这心里是真没底。原来公司都是独自接单，现在要和其他公司合作，协议怎么签，价格怎么谈呢？

马哥找到鲁肃。鲁肃说：“我给你推荐一个人吧！这个人人脉广，项目运作能力强，叫陆逊，说不定能帮上忙。”

鲁肃找来陆逊。陆逊说：“这个好办。公司间合作嘛，就是条款谈好，利益分好就行，关键是大家要遵守行规。大家都按统一的规则来，事情就好办。”

这其实就像机器与机器之间合作，一台机器将自己想要表达的内容，按照某种约定好的格式发送出去。当另外一台机器收到这些信息后，也能够按照约定好的格式解析出来，从而准确、可靠地获得发送方想要表达的内容。这种约定好的格式就是网络协议。

现在业内知名的有两种网络协议模型，一种是 OSI 的标准七层模型，一种是业界标准的 TCP/IP 模型。它们的对应关系如下图所示：

我们先从第三层网络层开始，因为这一层有我们熟悉的 IP 地址，所以这一层我们也叫 IP 层。

连接到网络上的每一个设备都至少有一个 IP 地址，用于定位这个设备。无论是近在咫尺的、你旁边同学的电脑，还是远在天边的电商网站，都可以通过 IP 地址进行定位。因此，IP 地址类似互联网上的邮寄地址，是有全局定位功能的。

就算你要访问美国的一个地址，也可以从你身边的网络出发，通过不断地打听道儿，经过多个网络，最终到达目的地址，和快递员送包裹的过程差不多。打听道儿的协议也在第三层，我们称为路由协议。将网络包从一个网络转发给另一个网络的设备，我们称为路由器。

总而言之，第三层干的事情，就是网络包从一个起始的 IP 地址，沿着路由协议指的道儿，经过多个网络，通过多次路由器转发，到达目标 IP 地址。

从第三层，我们往下看。第二层是数据链路层。有时候我们简称为二层或者 MAC 层。所谓 MAC，就是每个网卡都有的唯一的硬件地址（不绝对唯一，相对大概率唯一即可，类比 UUID）。这虽然也是一个地址，但是这个地址是没有全局定位功能的。

就像给你送外卖的小哥，不可能根据手机尾号找到你家，但是手机尾号有本地定位功能的，只不过这个定位主要靠“吼“。外卖小哥到了你的楼层就开始大喊：“尾号 xxxx 的，你外卖到了！“

MAC 地址的定位功能局限在一个网络里面，也即同一个网络号下的 IP 地址之间，可以通过 MAC 进行定位和通信。从 IP 地址获取 MAC 地址要通过 ARP 协议，是通过在本地发送广播包，也就是“吼“，获得的 MAC 地址。

由于同一个网络内的机器数量有限，通过 MAC 地址的好处就是简单。匹配上 MAC 地址就接收，匹配不上就不接收，没有什么所谓路由协议这样复杂的协议。当然坏处就是，MAC 地址的作用范围不能出本地网络，所以一旦跨网络通信，虽然 IP 地址保持不变，但是 MAC 地址每经过一个路由器就要换一次。

所以第二层干的事情，就是网络包在本地网络中的服务器之间定位及通信的机制。

我们再往下看第一层，物理层。这一层就是物理设备。例如，连着电脑的网线，我们能连上的 WiFi。

从第三层往上看，第四层是传输层，这里面有两个著名的协议，TCP 和 UDP。尤其是 TCP，更是广泛使用，在 IP 层的代码逻辑中，仅仅负责数据从一个 IP 地址发送给另一个 IP 地址，丢包、乱序、重传、拥塞，这些 IP 层都不管。处理这些问题的代码逻辑写在了传输层的 TCP 协议里面。

我们常说，TCP 是可靠传输协议，也是难为它了。因为从第一层到第三层都不可靠，网络包说丢就丢，是 TCP 这一层通过各种编号、重传等机制，让本来不可靠的网络对于更上层来讲，变得“看起来”可靠。哪有什么应用层的岁月静好，只不过是 TCP 层在负重前行。

传输层再往上就是应用层，例如，咱们在浏览器里面输入的 HTTP，Java 服务端写的 Servlet，都是这一层的。

二层到四层都是在 Linux 内核里面处理的，应用层例如浏览器、Nginx、Tomcat 都是用户态的。内核里面对于网络包的处理是不区分应用的。

从四层再往上，就需要区分网络包发给哪个应用。在传输层的 TCP 和 UDP 协议里面，都有端口的概念，不同的应用监听不同的端口。例如，服务端 Nginx 监听 80、Tomcat 监听 8080；再如客户端浏览器监听一个随机端口，FTP 客户端监听另外一个随机端口。

应用层和内核互通的机制，就是通过 Socket 系统调用。所以经常有人会问，Socket 属于哪一层，其实它哪一层都不属于，它属于操作系统的概念，而非网络协议分层的概念。

操作系统对于网络协议的实现模式是这样的：二到四层的处理代码在内核里面，七层的处理代码让应用自己去做。两者需要跨内核态和用户态通信，就需要一个系统调用完成这个衔接，这就是 Socket。

如果公司想要和其他公司沟通，我们将请求封装为 HTTP 协议，通过 Socket 发送到内核。内核的网络协议栈里面，在 TCP 层创建用于维护连接、序列号、重传、拥塞控制的数据结构，将 HTTP 包加上 TCP 头，发送给 IP 层，IP 层加上 IP 头，发送给 MAC 层，MAC 层加上 MAC 头，从硬件网卡发出去。

最终网络包会被转发到目标服务器，它发现 MAC 地址匹配，就将 MAC 头取下来，交给上一层。IP 层发现 IP 地址匹配，将 IP 头取下来，交给上一层。TCP 层会根据 TCP 头中的序列号等信息，发现它是一个正确的网络包，就会将网络包缓存起来，等待应用层的读取。

应用层通过 Socket 监听某个端口，因而读取的时候，内核会根据 TCP 头中的端口号，将网络包发给相应的应用。

这样一个大项目中，各个公司都按协议来，别说两家公司合作，二十家也没有问题。

于是陆逊带着马哥，到甲方那里，将自己的方案，以及和其他公司的合作模式讲述清楚。马哥成功入围。

这次参与竞标的公司可不少，马哥公司的竞争力和专业性一点都不差，最后终于拿下了智慧生态合作平台的建设部分。这下不得了，一提马哥的公司，业内无人不知，无人不晓，大家纷纷称呼他为“马总”。

公司大了不灵活，鼓励创新有妙招

慢慢地，马总发现，公司大有大的好处，自然也有大的毛病，也就是咱们常见的“大公司病”——不灵活。

这里面的不灵活，就像 Linux 服务器，越来越强大的时候，无论是计算、网络、存储，都越来越牛。例如，内存动不动就是百 G 内存，网络设备一个端口的带宽就能有几十 G 甚至上百 G。存储在数据中心至少是 PB 级别的，自然也有不灵活的毛病。

资源大小不灵活：有时候我们不需要这么大规格的机器，可能只想尝试一下某些新业务，申请个 4 核 8G 的服务器试一下，但是不可能采购这么小规格的机器。无论每个项目需要多大规格的机器，公司统一采购就限制几种，全部是上面那种大规格的。

资源申请不灵活：规格定死就定死吧，可是每次申请机器都要重新采购，周期很长。

资源复用不灵活：反正我需要的资源不多，和别人共享一台机器吧，这样不同的进程可能会产生冲突，例如 socket 的端口冲突。另外就是别人用过的机器，不知道上面做过哪些操作，有很多的历史包袱，如果重新安装则代价太大。

按说，大事情流程严禁没问题，很多小事情也要被拖累走整个流程，而且很容易出现资源冲突，每天跨部门的协调很累人，历史包袱严重，创新没有办法轻装上阵。

很多公司处理这种问题采取的策略是成立独立的子公司，独立决策，独立运营。这种办法往往会用在创新型的项目上。

Linux 也采取了这样的手段，就是在物理机上面创建虚拟机。每个虚拟机有自己单独的操作系统、灵活的规格，一个命令就能启动起来。每次创建都是新的操作系统，很好地解决了上面不灵活的问题。

在物理机上的操作系统看来，虚拟机是一个普通的应用，他和 Excel 一样，只能运行在用户态。但是对于虚拟机里面的操作系统内核来讲，运行在内核态，应该有高的权限。

要做到这件事情，第一种方式，完全虚拟化。其实说白了，这是一种“骗人”的方式。虚拟化软件会模拟假的 CPU、内存、网络、硬盘给到虚拟机，让虚拟机里面的内核自我感觉良好，感觉他终于又像个内核了。在 Linux 上，一个叫作 qemu 的工具可以做到这一点。

qemu 向虚拟机里面的客户机操作系统模拟 CPU 和其他的硬件，骗客户机，GuestOS 认为自己和硬件直接打交道，其实是同 qemu 模拟出来的硬件打交道，qemu 会将这些指令转译给真正的硬件。由于所有的指令都要从 qemu 里面过一手，因而性能就会比较差。

第二种方式，硬件辅助虚拟化。可以使用硬件 CPU 的 Intel-VT 和 AMD-V 技术，需要 CPU 硬件开启这个标志位（一般在 BIOS 里面设置）。当确认开始了标志位之后，通过内核模块 KVM，GuestOS 的 CPU 指令将不用经过 Qemu 转译，直接运行，大大提高了速度。qemu 和 KVM 融合以后，就是 qemu-kvm。

第三种方式称为半虚拟化。对于网络或者硬盘的访问，我们让虚拟机内核加载特殊的驱动，重新定位自己的身份。虚拟机操作系统的内核知道自己不是物理机内核，没那么高的权限。他很可能要和很多虚拟机共享物理资源，所以学会了排队。虚拟机写硬盘其实写的是一个物理机上的文件，那我的写文件的缓存方式是不是可以变一下。我发送网络包，根本就不是发给真正的网络设备，而是给虚拟的设备，我可不可以直接在内存里面拷贝给它，等等等等。

网络半虚拟化方式是 virtio_net，存储是 virtio_blk。客户机需要安装这些半虚拟化驱动。客户机内核知道自己是虚拟机，所以会直接把数据发送给半虚拟化设备，然后经过特殊处理（例如排队、缓存、批量处理等性能优化方式），最终发送给真正的硬件。这在一定程度上提高了性能。

有了虚拟化的技术，公司的状态改观了不少，在主要的经营方向之外，公司还推出了很多新的创新方向，都是通过虚拟机创建子公司的方式进行的，例如跨境电商、工业互联网、社交等。一方面，能够享受大公司的支持；一方面，也可以和灵活的创业公司进行竞争。

于是，公司就变成集团公司了。

独占鳌头定格局，上市敲钟责任重

随着公司越来越大，钱赚的越来越多，马总的公司慢慢从行业的追随者，变成了领导者。这一方面，让马总觉得“会当凌绝顶，一览众山小”；另一方面，马总也觉得“高处不胜寒”。原来公司总是追着别人跑，产业格局，市场格局从来不用自己操心，只要自己的公司能赚钱就行。现在做了领头羊，马总也就慢慢成了各种政府论坛、产业论坛，甚至国际论坛的座上宾。

穷则独善其身，达则兼济天下。马总的决策可能关系到产业的发展、地方的 GDP 和就业，甚至未来的国际竞争力。因此，即便是和原来相同的事情，现在来做，方式和层次都不一样了。

就像对于单台 Linux 服务器，最重要的四种硬件资源是 CPU、内存、存储和网络。面对整个数据中心成千上万台机器，我们只要重点关注这四种硬件资源就可以了。如果运维数据中心依然像的运维一台台物理机的前辈一样，天天关心哪个程序放在了哪台机器上，使用多少内存、多少硬盘，每台机器总共有多少内存、多少硬盘，还剩多少内存和硬盘，那头就大了。

对于数据中心，我们需要一个调度器，将运维人员从指定物理机或者虚拟机的痛苦中解放出来，实现对于物理资源的统一管理，这就是 Kubernetes，也就是数据中心的操作系统。

对于 CPU 和内存这两种计算资源的管理，我们可以通过 Docker 技术完成。

容器实现封闭的环境主要要靠两种技术，一种是看起来是隔离的技术，称为 namespace。在每个 namespace 中的应用看到的，都是不同的 IP 地址、用户空间、进程 ID 等。另一种是用起来是隔离的技术，称为 cgroup，即明明整台机器有很多的 CPU、内存，但是一个应用只能用其中的一部分。

另外，容器里还有镜像。也就是说，在你焊好集装箱的那一刻，将集装箱的状态保存下来的样子。就像孙悟空说“定！”，集装箱里的状态就被“定”在了那一刻。然后，这一刻的状态会被保存成一系列文件。无论在哪里运行这个镜像，都能完整地还原当时的情况。

通过容器，我们可以将 CPU 和内存资源，从大的资源池里面隔离出来，并通过镜像技术，在数据中心里面实现计算资源的自由漂移。

没有操作系统的时候，汇编程序员需要指定程序运行的 CPU 和内存物理地址。同理，数据中心的管理员，原来也需要指定程序运行的服务器以及使用的 CPU 和内存。现在，Kubernetes 里面有一个调度器 Scheduler，你只需要告诉它，你想运行 10 个 4 核 8G 的 Java 程序，它会自动帮你选择空闲的、有足够资源的服务器，去运行这些程序。

对于存储，无论是分布式文件系统和分布式块存储，需要对接到 Kubernetes，让 Kubernetes 管理它们。如何对接呢？Kubernetes 会提供 CSI 接口。这是一个标准接口，不同的存储可以实现这个接口来对接 Kubernetes。是不是特别像设备驱动程序呀？操作系统只要定义统一的接口，不同的存储设备的驱动实现这些接口，就能被操作系统使用了。

对于网络，也是类似的机制，Kubernetes 同样是提供统一的接口 CNI。无论你用哪种方式实现网络模型，只要对接这个统一的接口，Kubernetes 就可以管理容器的网络。

到此，有了这套鼎定市场格局的策略，作为龙头企业，马总的公司终于可以顺利上市敲钟，走向人生巅峰。从此，江湖人称“马爸爸”。

好了，马同学的创业故事就讲到这里了，操作系统的原理也给你串了一遍。你是否真的记住了这些原理呢？试着将这个创业故事讲给你的朋友听吧！

67 | 期末测试：这些操作系统问题，你真的掌握了吗？

你好，我是刘超。当你看到这篇文章的时候，说明你已经历经九九八十一难，完成了整个课程的学习，你是否已经悟到操作系统的“真谛”了呢？我们今天就来测试一下。

先和你说一下，期末测试题的设计，我采取了和入学测验不一样的思路。当时入学测验里的题目比较细节，类似你在大学里学完操作系统课之后的考试题目。现在整个专栏学完了，我们需要换一种检测方式。所以，期末测试题，我设计成了开放式的问题。这才是我们现实生活中，不管是面试还是工作中，常常遇到的“考试”方式。

有人说，面试的时候问操作系统知识，就是“面试造航母，上班拧螺丝”，没有啥用。其实不然，这些看似简单开放的问题，其实最能检验真本事的。面试官毕竟阅人无数，带人无数，看似他只是在问你这个问题的答案，其实这些问题背后都有实际的工作场景——只是你可能不了解或者想象不到而已。

因此，我把专栏内容每个部分面试可能会考到的问题，总结了出来，共 11 道。你可不要小看这些问题，也不要以为，面试官真的只是单纯地在问你这些问题的答案。因为这些问题都产生于真实的工作场景中。

在这个场景中，秃头哥因为工作经验丰富，发际线已经非常可怜。长发哥是新进到公司的员工。你可以看看，他们在工作中是怎么互动的，感受一下长发哥回答不出问题的“尴尬”。

同时，你可以想一想，如果换做是你，在面试或者工作中遇到这些问题，你会怎么回答呢？你可以把思考之后的答案写在留言里。当然，如果你的朋友也在为面试或者操作系统知识烦忧，你可以把这篇文章分享给他。

这里呢，我还给你准备了几个回答问题的小 Tips。你在答题的时候，可以参照下面这三个 Tips，组织你的答案。

第一，回答尽量体系化。不仅仅回答这个问题的知识点，还可以简单描述一下这个点背后完整的体系，然后根据面试官接下来的追问，更细地描述其他相关的内容。

这样做的好处是：一，防止你因为这个点没有回答好而丢分所有的分；二，可以体现你的知识掌握比较全面，可以加分；三，还可以避免冷场和尬聊。

面试官开放式的提问，最不想听到的就是“封闭式“回答。有的面试者“惜字如金“，有的人能聊一个小时，有的人不到半个小时就答完了，让面试官没有办法深入全面的了解你。

第二，在体系化的阐述过程中，可以加入一些你使用过的相关工具或者技巧的阐述。例如，操作系统干这个事情有十个步骤。第三步会在某个日志文件里面打印这样的日志，如果发现打印出 xxx 说明可能有错误，第七步可以通过某个命令行查看某某状态，等等。诸如此类，可以让面试官感觉到你是实战派而非理论派。

第三，在体系化的阐述过程中，如果能加入一些项目经验就更好了。例如，在第九个步骤，当时在做某项目的时候，因为客户现场的版本问题，导致了什么结果，最后如何进行解决的等等。

我刚才说了，面试官问问题的时候，脑子里都是有实际工作场景的。如果你能把他带到你的工作场景里面，有利于面试官对你当时的情形感同身受，你的分数自然不会很低。

好了，现在开始，进入答题状态！

结束语 | 永远别轻视任何技术，也永远别轻视自己

你好，我是刘超。又一次时隔 5 个多月，“趣谈 Linux 操作系统”专栏终于结束了。

之所以说“终于”二字，是因为这门课实在是太硬核了。写作的过程中，几乎每篇文章都超长。极客时间的要求是每篇 3000 字左右，而这个专栏差不多每篇 6000 字左右。我之前规划好的很多主题，本来写一篇，最后都变成了上、中、下三篇。最终，在我十分“搂着”的情况下，这个专栏从最初计划的 52 篇，扩展到 67 篇。

说实话，写完这个专栏，我觉得我自身实现了一定的升华，这其中真的产生了非常多的感悟，我在这里跟你分享一下。

第一，永远敬畏技术，坚持不懈，持续深耕

当极客时间想让我再写一个基础知识专栏的时候，我很快就想到了 Linux 操作系统。

毕竟，我平时几乎天天和 Linux 操作系统打交道。安装、运维、调优，从操作到内核原理从来没有放下过，按理来说，写一个专栏趣谈一下应该不是问题。

于是，我很快构建了一个创业公司的故事大纲，和编辑做好了课程设计，写出了各节的标题，觉得不用太长，就能写清楚。刚开始写前几篇的时候，还没有涉及内核代码的解读，感觉一切可控，该说原理说原理，该讲故事讲故事。

可是，真的到了后面硬核的内核代码部分，我发现，写起来和想起来完全是两码事。

我个人特别喜欢读优秀的开源软件代码，从中可以学习原理。我一度认为，只要是给我代码看，我还能搞不定原理？毕竟自己原来也跟过内核代码的流程，写专栏按说应该驾轻就熟。但是我发现，内核代码的变化超出我的想象，我自己也迷失在代码的汪洋大海里面了……

内核代码分析，特别像走迷宫通关，有时候你觉得自己进入了一个房间，左看右看也就这么多内容了，刚刚欣喜一把，就会突然发现角落里有个门，打开以后很可能是一个更大的房间。最终，就算通了关，你也无法保证你能够看到整个全貌。这不由得让我对 Linux 内核更加敬畏，也对技术更加敬畏。

任何一个开源软件，以当前的快速迭代速度，如果三年没碰，肯定面目全非；五年没碰，你就当自己不懂就行了。所以，对于开源软件，千万不要当下能用就好，完全不管原理。那些面试官问你开源软件背后的机制，这不是故意刁难，也不是“面试造航母，上班拧螺丝”，因为在大规模复杂场景下，无论如何重视基座的稳定，都是不过分的。

永远敬畏技术，别轻视技术，你轻视它。它就会静静地看着你，直到某一个时刻给你当头一棒，而且，这一刻来得越晚，这一棒就会打得越狠，打得你爬不起来。

在工作中，当架构的系统因为长期忽略技术被“打倒”的时候，很多人期盼有一个电视里面的老中医，哪怕你一生都不爱惜身体，他也能一副药妙手回春。其实世界上哪有风清扬，令狐冲走投无路，被他指点几招就能秒杀田伯光，做技术要像郭靖练习功夫，先练个十八年马步，再一掌一掌地劈下去。

第二，对自己狠一点，发现还是有潜力可挖

写这个专栏虽然很辛苦，但是，我很庆幸，自己还是咬着牙完成了。我不敢保证这个专栏一定是最全面的，也不敢保证里面的一定毫无错漏，但是我敢说，专栏每一节的论述都是有佐证和凭据的。因为除了看代码，我还看了大量的参考书。我在云盘里专门建立了一个单独的文件夹，里面放了我平时写专栏的参考资料，方便随时随地查询。

有的时候，弄清楚一个知识点的内容，就像打开迷宫中一扇门，里面不是一个房间，而是一片草原，需要看几本书才能搞定。那怎么办呢？不服就干呗！

于是，磕磕绊绊，坚持写完整个专栏。当我再次打开那个文件夹的时候，我发现我竟然看了这么多书。数了一下，总共 32 本书。我在这里列一下，骄傲一把。

《自己动手写操作系统》

《UNIX 环境高级编程》

《一个操作系统的实现》

《系统虚拟化原理与实现》

《深入理解 Linux 虚拟内存管理》

《深入理解 Linux 内核》

《深入 Linux 内核架构》

《穿越计算机的迷雾》

《程序员的自我修养：链接、装载与库》

《操作系统真象还原》

《操作系统设计与实现》

《x86 汇编语言：从实模式到保护模式》

《linux 内核设计的艺术图解》

《Linux 设备驱动开发详解》

《Linux 内核完全注释》

《Linux 内核设计与实现》

《Linux 多线程服务端编程》

《Linux 内核分析及编程》

《IBM PC 汇编语言程序设计》

《深入理解计算机系统》

《性能之巅：洞悉系统、企业与云计算》

《Linux 内核协议栈源代码解析》

《UNIX 网络编程》

《Linux/UNIX 系统编程手册》

《深入 Linux 设备驱动程序内核机制》

《深入理解 Linux 驱动程序设计》

《Linux Device Drivers》

《TCP/IP 详解卷》

《The TCP/IP Guide》

《深入理解 LINUX 网络技术内幕》

《Linux 内核源代码情景分析》

《UNIX/Linux 系统管理技术手册》

现在如果你问我，操作系统这么多人都在讲，学你这个专栏还有啥用，我可以自豪地说，我没有做啥“原创”的事情。你也同样可以选择将上面的书看完，然后对照着自己去解读最新的代码。你自己想想，觉得哪个选择更好呢？

很多读者留言说，读这个专栏非常吃力，难以坚持下去。我想说的是，别轻言放弃，逼自己一把，就像我逼自己咬牙看上面的那些书一样，你会发现自己潜力无穷。

当然，“逼”自己，也是要讲究方法的。如果你基础比较好，你可以在上下班路上听一听，作为复习巩固。对于真正解析流程和数据结构的那些章节，建议你还是坐下来拿着笔边记边读；对于里面的程序，还有课后练习，也不要偷懒，建议你全部做一下，才会有上手的感觉。

一遍看不懂，那就多看几遍。我这里推荐一种方式，你可以先从头到尾看一遍，看到总结的部分，将总结的图拿出来，每一节都打印成一张纸，对着流程从头再看一遍，最后把这些纸订成一个手册，这样就会好很多。

每当你坚持不下去的时候，你可以想一想，这门课一共 60 节课，也就 60 个图，啃下来这些就能掌握操作系统，也不算难吧？

你看，我都逼了自己一把，激发出来了一点潜力，写完了这个巨硬核的专栏，你要不要也逼自己一把呢？加油啊，愿我们能一起每日精进！

学习攻略（一）：学好操作系统，需要掌握哪些前置知识？

你好，我是刘超。

专栏已经更新一段时间了，随着涉及的技术越来越深，很多同学觉得学习的难度也越来越大了，并在留言区提出了一些问题。有些问题，是我之前学习也碰到过的或者我思考过的；也有些问题是我没想到的，但是这些问题归根结底其实就是一个，如何更好地去学习操作系统，学习这门课。所以，今天我就给你讲讲，我认为学习这个专栏的一些好的方法和正确的态度。

1. 态度：不要等一切都准备好了再前行

很多人问到，“学习这个专栏需要什么基础”，说实话，我在设计这个专栏的时候，和编辑也专门讨论过这个问题。为此，我曾经专门研究过大学的课程设计。大学的操作系统前置课程有程序设计基础、数据结构与算法、计算机组成与体系结构、编译原理等。这几门前置课程，几乎每一门都非常“硬核”，学习难度和大多数人的掌握程度往往是成反比的。

你可能要问了，我是不是要把这些前置课程都搞定了才能学操作系统呢？当然不是。这些课程它们本身就很难，除此之外，它们也有前置课程。如果这样，你就会发现你永远开始不了。

如果把一切都准备好了再前行，那我们永远迈不出第一步。

你可能会说，没有这些基础知识，我每看一篇文章，知识就铺天盖地席卷过来，仿佛每一个知识点都准确地打在了自己的盲点上，这该怎么办呢？

我非常能理解这种心情，而我想说的是，你不需要对这几门前置课程有非常深入的理解，你其实就可以学好这门课。但如果你是编程零基础，学习起来的确比较困难。

毕竟我们这门课相对于大学的操作系统课，更加面向真实的操作系统分析，因而更深入一步，加上操作系统这门课本身的技术深度和知识点琐碎程度，就是很高的，所以感觉就更加困难一些。

这有点像咱们平时玩游戏，作为主人公，你既不能完全没有任何装备和法术就开始，也不能等练级练到满，将整个地图都了解清楚才开始。你只要有一个初始装备，例如一把木剑、一套基本的法术就可以开始了。先上路，之后在探险的路上，慢慢摸清地图，积累装备，训练法术，最终 PK 终极 Boss。

这么一来，想要学好操作系统，我们要解决的其实就是这样两个问题：第一是初始装备问题，也就是说前置知识应该掌握到什么程度；第二就是练级攻略问题，也就是掌握这些操作系统知识的方法。

2. 资料：C 语言 + 数据结构和算法 + 编译原理 + 计算机组成

学习操作系统需要对 C 语言基础知识、数据结构和算法、编译原理以及计算机组成原理有一些了解，这样学起来就会更轻松。

因为我个人是从 C 语言转向 Java 的，我觉得 Java 和 C 语言的基本语法没有太大的差别。

仅仅就学习咱们这个操作系统专栏来讲，其实你只要有计算机二级的那些 C 语言基础知识基本就够了。如果你要严肃地学好 C 语言，几乎每个前辈都会建议你从《C 程序设计语言》这本书学起。这是一本很薄很薄的书。如果你有编程基础，但是之前没有学过 C 语言，稍微翻一翻它，了解一下 C 语言的基本编程方法也就足够用了。

这本书里，第 1 章到第 3 章涉及类型、运算符、表达式、控制流，这些和其他语言区别不大，你应该很容易理解。你重点看第 4 章函数与程序结构、第 5 章指针与数组、第 6 章结构，这样基本就可以了。

对于数据结构和算法，业界有大量砖头一样厚的书籍。如果从头学起，你可能会比较崩溃。其实你只要了解表、栈、队列、树，这些基本的内容就够用了。现在学习资料这么多，想学会这些内容不是难事，关键看你是不是用心，是不是真心想要学习。既然是基于 C 语言的数据结构和算法，我这里给你推荐一本书《数据结构与算法分析：C 语言描述》。在这本书里，你重点看第 3 章表、栈和队列和第 4 章树，基本就可以了。

编译原理和计算机组成，这些都是操作系统的基础。在咱们的专栏里面，我也有简单的阐述，你只要掌握我平时说的那些，基本上足够了。计算机组成与系统原理的书，相对来说都比较复杂。

一般的汇编语言的书都会简单介绍 x86 的机制，以及简单的汇编语言。例如，《汇编语言：基于 x86 处理器》这本书里，你可以重点看第 2 章 x86 处理器架构和第 3 章汇编语言基础，掌握这些基本就可以了。

除此之外，极客时间出品的《数据结构与算法之美》《深入浅出计算机组成原理》也是非常优秀的学习资料。所有基础知识，本是一家。如果有精力，推荐你认真学习这两个专栏，对我们这门课会非常有帮助。

3. 方法：三遍学习法 +PPT 笔记法

读书有一个理念，是先把书读厚，再把书读薄，这往往是针对有一定基础的同学的学习方法，如果你原来没有研究过操作系统，或者没有基于 Linux 的 API 进行过程序设计，要想看懂每一行代码的确是非常难的事情。所以学习这门课程，可以采取“先读薄，再读厚，再读薄”这样的三遍学习法。

所谓的先读薄，就像我在开篇词中说的，了解操作系统的原理，一开始不要纠结一城一地的得失。不要死抠每一个代码细节，而重点了解主要的流程和原理。

学习是循序渐进的过程，不要妄想一遍就掌握所有。我自己在学习的时候，经常是秉持着这么一个方法：刚开始接触一个新东西，多去看别人的学习方法，从别人的方法中摸索中“主要矛盾”和“次要矛盾”，每一遍学习都提纲挈领地抓当前的主要矛盾，然后一遍一遍去学这个内容，当然每一遍的主要矛盾可能都不太一样。

比如在第一遍学习这门课的时候，你没必要把文稿中的代码细节当成学习目标，只把它看作一个对操作系统原理的佐证就行了。我讲操作系统这个地方是这样做的，你看代码，大概就能看出来，它真的就是这样做的。这样一方面先形成一个大致的印象，另一方面先在心里对原理有一个具象的理解。至于代码中每一行的意思，这一遍不要纠结。

另外，Linux 代码中有很多注释，如果实在看不懂代码，那你就阅读注释就行了，然后按照注释中的意思，浏览一下代码大概的意思就可以了。尤其是对于汇编语言，更是这样。比如说压栈操作，你看到 push，就理解是正在压栈，然后后面有注释说，压入了哪个栈。这时候，你暂时没必要纠结，为什么这行命令压入的是这个栈。

当你可以按照我每节画的图，把原理理解得差不多的时候，第一遍基本就完成了。

第一遍相当于咱们打游戏，到了一个新的场景，先到处走走看看情况，不纠结和妖怪的对战。

接下来的第二遍，就相当于我们需要完成在游戏里面的任务，碰到怪就打怪，碰到迷宫走迷宫了。这也是把书读厚的方法，就是见山开路，遇水搭桥。

这一遍并不需要你把所有不懂的知识都找到一本砖头厚的书全部看完，这样你会迷失在庞大的知识体系里面，丧失方向，偏离目标，你会忘了为什么要看这本书，甚至会疑惑通过这本书到底能不能帮你理解操作系统的某个知识。

所以把书读厚，还是要以操作系统的知识体系为主线，遇到不懂的知识点的时候，通过搜索引擎，看我给出的参考资料里面的某个章节，读某个博客等方式，达到了解这个知识点就可以了。一旦了解到能够帮助你理解操作系统的程度，就不需要再深入下去，马上回到主线，攻克下一个知识点。

这里可以分享一下我的学习大量陌生知识方法，就是记笔记，我一般是用 PPT 来记。当读了每篇文章的一个小节的时候，甚至可以更细到几个自然段，就像咱们学语文的时候划分自然段一样，你可以为这一段知识做一页 PPT，PPT 的标题就是这一段的段落大意，能够总结出段落大意。其实你已经知道这一段主要是做什么的，只不过很多细节的知识点还不清楚，这样将来通过 标题浏览 PPT 的时候，就很容易把握整篇文章的总体思路。

接下来在这页 PPT 的正文中，将所有的知识点都列出来，其中不懂的部分编个号。例如，知识点 17，然后接下来的几页 PPT，我们就以知识点 17 为标题，开始查所有能够帮助你了解这个知识点的文章和书籍，将内容贴进来，直到你发现你已经懂了这个知识点，当然知识点可能还要再套知识点，接着编号就行。当从这个知识点的 PPT 返回的时候，你知道你理解了知识点 17，接下来应该攻克知识点 18 了。当所有的知识点都趟过去，你会发现这是一个特别长的 PPT，没有关系，你把所有的以知识点为标题的 PPT 页都放在最后做附页，再次看整篇文章，就会有一种一马平川的感觉。如果暂时忘了，可以到附页中去查看自己记的笔记，很容易就可以回忆起来。

这个特别长的 PPT 就是起到了把书读厚的作用。如果你有兴趣搜索我写的文章《别以为真懂 Openstack：虚拟机创建的 50 个步骤和 100 个知识点》，看看我是怎样趟过这 100 个 OpenStack 相关知识点的。

第三遍再读薄，是我希望能够达到这样一个效果。经过前两遍的学习，现在你对知识的细节和整体的脉络都应该有了一定了解。因此这第三遍无需面面俱到，你需要把这些知识真正变成你自己的东西。

专栏每篇文章后面我都会用一张图对这篇文章做一个总结。这些总结图代表了我对知识的梳理和理解。你可以先试着看着我的图来复习，之后，你可以自己尝试去总结这些内容。把知识真正装进你自己的脑袋里。等整个专栏全部结束的时候，你只需要对着这些图，将整个操作系统的知识串下来，基本就融会贯通了。

4. 做好练习，用好音频

你应该有感觉，操作系统的知识量非常大。但凡想要讲的面面俱到，都需要一本砖头厚的书籍才行。专栏不可能和书籍一样，我主要是基于 Linux 给你讲，我是怎么理解操作系统的。因而，我讲的肯定是最核心的内容，很多旁支的东西需要你自己去预习和复习。

每节课我留的练习题往往都是对当节课程的补充，也是对后面章节要讲知识的准备，所以你一定要重视课后题。每节课后都认真去做一做题，思考题目背后的知识点。

另外，音频这个形式非常重要。文稿里是最核心的内容，很多对于代码的分析放进去会非常繁琐，但是这块又非常重要，于是在音频讲解里，我会对代码部分进行更加详尽的讲述。如果你遇到，仅仅看文字不能理解的部分，尝试听听语音，或许会有不一样的收获。

最后，我想借用陈皓的一句话，“学习不是为了找到答案，而是为了找到方法”。哪怕我已经尽力为你考虑周全，但是没有你自己主动学习，去钻研，去下苦功寻找适合自己的方法，成功是不可能自己找上门来的。希望你可以为自己而努力。

好了，今天我想分享的就是这些。不知道我今天的分享哪个地方对你最有用呢？你在学专栏的过程中还遇到过什么问题呢？

欢迎留言和我分享你的疑惑和见解。你可以收藏今天的文章，反复研读。你也可以把今天文章分享给你的朋友，和他一起学习操作系统。