This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
<audio id="audio" title="“趣谈Linux操作系统”食用指南" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ca/07/cae1fbc9c2ddc0a558e9bfbb43b16b07.mp3"></audio>
你好,我是刘超。
“趣谈Linux操作系统”专栏现在已经全部更新完毕。这里有一份“食用指南”希望帮你找到学习本专栏的最佳姿势。
在这份指南中,我为你整理了专栏的所有学习资料,并告诉你如何更高效地使用这些资料,从而帮助你消化吸收,以期获得更好的学习效果。
不管你是刚刚打开这个专栏,还是进入温故的阶段,我的这份指南,都可以帮你更上一个台阶。一起加油吧!
## 1.入学测验
在专栏一开始我准备了12道小题邀请你先来做个小测验。这些题目都是我精心设计反复筛选出来的可以说涵盖了Linux操作系统中最重要、最核心的知识点。我估计你看着它们肯定不会陌生甚至你正在从事相关的工作。
一方面,希望你可以通过这套题目对自己之前的学习做一个检测,查缺补漏。另一方面,希望你可以把这套题作为手边的一个常用资料,每隔一段时间,都可以回过头来检测一下。**希望这套题目能帮你明确“在哪里”和“去哪里”,然后我们整个课程的学习帮你解决“怎么去”的问题。**
点击进行入学测验:[你究竟对Linux操作系统了解多少](https://time.geekbang.org/column/article/87111)
## 2.爬坡路径
Linux上手难学习曲线陡峭。它的学习过程更像一个爬坡模式。这些坡看起来都很陡但是一旦爬上一阶就会一马平川。那怎样才能掌握这项爬坡技能呢首先需要明确我们要爬哪些坡。
我总结了一下在整个Linux的学习过程中要爬的坡有六个分别是熟练使用Linux命令行、使用Linux进行程序设计、了解Linux内核机制、阅读Linux内核代码、实验定制Linux组件以及最后落到生产实践上。
<img src="https://static001.geekbang.org/resource/image/bc/5b/bcf70b988e59522de732bc1b01b45a5b.jpeg" alt="">
点击文章,查看我为你准备的详细爬坡秘籍和辅助的书单弹药:[学习路径爬过这六个陡坡你就能对Linux了如指掌](https://time.geekbang.org/column/article/87628)
## 3.学习方法
随着后面文章涉及的技术越来越深,很多同学会觉得学习的难度越来越大。有的人会想放弃,有的人已经淹没在知识的海洋里。因此,我特意准备了一篇“学习攻略”,作为专栏学习过程中的补充剂。
在学习的过程中,首先,要端正学习态度;其次,用好辅助的学习资料;最后,掌握高效的学习方法。
点击文章链接,获取专栏学习过程的补充剂:[学好操作系统,需要掌握哪些前置知识?](https://time.geekbang.org/column/article/92382)
## 4.知识串讲
操作系统是一门体系复杂、知识点很多的课程经过前面60多节的讲解你是否已经感觉自己被淹没在细节的汪洋大海里面了专栏最后我用5篇知识串讲通过一个创业故事串起来操作系统的整个知识体系。我们一起看看主人公是如何从小马变成马哥再变成马总的
我在这里想特别提一下**专栏音频**。我的每篇文章都包含了很多图片和代码。为了帮助你更好地理解文章内容,我在录音的时候,常常会对图片和代码做一些补充解释和说明。尤其是这几节知识串讲的音频,我建议你一定要多听几遍,最好是达到能够复述的程度。应用的基础是掌握,掌握的基础是熟记于心。
我相信不管你对前面的内容掌握程度如何这个串讲内容都可以让你对Linux操作系统有一个更全面、深入的把握。
点击查看:
[知识串讲(一)](https://time.geekbang.org/column/article/119902)
[知识串讲(二)](https://time.geekbang.org/column/article/120320)
[知识串讲(三)](https://time.geekbang.org/column/article/120928)
[知识串讲(四)](https://time.geekbang.org/column/article/123494)
[知识串讲(五)](https://time.geekbang.org/column/article/128211)
## 5.知识地图
想要真正掌握Linux操作系统只看这个专栏肯定是不够的。极客时间还出品了“[Linux实战技能100讲](https://time.geekbang.org/course/intro/193)”“[Linux性能优化实战](https://time.geekbang.org/column/intro/140)”。这两个课程的内容也非常不错如果时间充裕建议你都订阅一下结合在一起学习。掌握了这三个专栏的内容相信你对Linux操作系统再也不会惧怕了。
如果你习惯纸质阅读,想要随时随地查阅学习,也可以购买极客时间出品的[《Linux操作系统知识地图》](time://mall?url=https%3A%2F%2Fdetail.youzan.com%2Fshow%2Fgoods%3Falias%3D1y7qqgp3ghd2g),这个地图涵盖了三个课程的精华内容。我自己也收藏了一份,放在手边时时翻看,非常实用。
## 6.实验环境
计算机这门学科是实验性的。为了更加深入地了解操作系统的本质,我们必须能够做一些上手实验。操作系统的实验,相比其他计算机课程的实验要更加复杂一些。因此,我用两节内容带你来搭建一个操作系统的实验环境。希望你能够上手操作一下学过的知识。毕竟,只有经过你自己动手和思考产生的内容,才是真正属于你的知识。
点击查看我搭建实验环境时候的具体操作,希望给你的思维晋升指路:
[搭建操作系统实验环境(上):授人以鱼不如授人以渔](https://time.geekbang.org/column/article/117924)
[搭建操作系统实验环境(下):授人以鱼不如授人以渔](https://time.geekbang.org/column/article/117939)
## 7.期末测试
最后的期末测试题我采取了和入学测验不一样的思路。入学测验里的题目比较细节类似你在大学里学完操作系统课之后的考试题目。但是现实生活中不管是面试还是工作中“考试”方式常常是开放式的。因此我把专栏内容每个部分面试和工作常遇到的问题总结了出来共11道。每一道题都产生于真实的工作场景中。
除此之外我还为你准备了一些面试回答问题的小Tips你可以把这些问题放到真实的工作场景中想一想在面试或者工作中遇到这些问题你会怎么回答。
当然,我在测试题后面还准备了一份非常详实的“答案”,对背后的知识体系进行了详细的梳理。不过,建议你还是先做题,再对答案,这样查漏补缺的效果会更好哦!
点击进行期末测试:[这些操作系统问题,你真的掌握了吗?](https://time.geekbang.org/column/article/128978)
除此之外,我们的专栏还有不少功能,提醒你好好利用起来,成为高效的学习者。
比如,在学习的过程中,遇到自己不懂的地方,或者是有深刻感受的地方,一定要及时利用“**划线笔记**”的功能,记录下自己当时的想法。这样在过程中点滴积累,等学完后,还可以回过头来再过一遍。如果有可能,你可以把自己的这些思考梳理成文。相信我,这样做,你的提升速度会快到让自己意外。
再比如“**请朋友读**”功能。如果你觉得某篇内容对自己很有帮助,不妨把它推荐给身边有同样需求的朋友,这一个动作或许就能帮他解决一个手边的问题。最重要的是,通过这些分享,你会找到那些和你一样热爱学习的伙伴,一起学习更快乐。
最后,建议你收藏这篇文章,把这篇“食用指南”好好利用起来,真正做到物尽其用。如果觉得有帮助,你也可以把它分享给你的朋友。加油,胜利的曙光就在眼前!
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,89 @@
<audio id="audio" title="学习攻略(一):学好操作系统,需要掌握哪些前置知识?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/99/9675fa5b4b9cf945db96ec768d9c1299.mp3"></audio>
你好,我是刘超。
专栏已经更新一段时间了,随着涉及的技术越来越深,很多同学觉得学习的难度也越来越大了,并在留言区提出了一些问题。有些问题,是我之前学习也碰到过的或者我思考过的;也有些问题是我没想到的,但是这些问题归根结底其实就是一个,如何更好地去学习操作系统,学习这门课。所以,今天我就给你讲讲,我认为学习这个专栏的一些好的方法和正确的态度。
## 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来记。当读了每篇文章的一个小节的时候甚至可以更细到几个自然段就像咱们学语文的时候划分自然段一样你可以为这一段知识做一页PPTPPT的标题就是这一段的段落大意能够总结出段落大意。其实你已经知道这一段主要是做什么的只不过很多细节的知识点还不清楚这样将来通过 标题浏览PPT的时候就很容易把握整篇文章的总体思路。
接下来在这页PPT的正文中将所有的知识点都列出来其中不懂的部分编个号。例如知识点17然后接下来的几页PPT我们就以知识点17为标题开始查所有能够帮助你了解这个知识点的文章和书籍将内容贴进来直到你发现你已经懂了这个知识点当然知识点可能还要再套知识点接着编号就行。当从这个知识点的PPT返回的时候你知道你理解了知识点17接下来应该攻克知识点18了。当所有的知识点都趟过去你会发现这是一个特别长的PPT没有关系你把所有的以知识点为标题的PPT页都放在最后做附页再次看整篇文章就会有一种一马平川的感觉。如果暂时忘了可以到附页中去查看自己记的笔记很容易就可以回忆起来。
这个特别长的PPT就是起到了把书读厚的作用。如果你有兴趣搜索我写的文章《别以为真懂Openstack虚拟机创建的50个步骤和100个知识点》看看我是怎样趟过这100个OpenStack相关知识点的。
第三遍再读薄,是我希望能够达到这样一个效果。经过前两遍的学习,现在你对知识的细节和整体的脉络都应该有了一定了解。因此这第三遍无需面面俱到,你需要把这些知识真正变成你自己的东西。
专栏每篇文章后面我都会用一张图对这篇文章做一个总结。这些总结图代表了我对知识的梳理和理解。你可以先试着看着我的图来复习,之后,你可以自己尝试去总结这些内容。把知识真正装进你自己的脑袋里。等整个专栏全部结束的时候,你只需要对着这些图,将整个操作系统的知识串下来,基本就融会贯通了。
## 4.做好练习,用好音频
你应该有感觉操作系统的知识量非常大。但凡想要讲的面面俱到都需要一本砖头厚的书籍才行。专栏不可能和书籍一样我主要是基于Linux给你讲我是怎么理解操作系统的。因而我讲的肯定是最核心的内容很多旁支的东西需要你自己去预习和复习。
每节课我留的练习题往往都是对当节课程的补充,也是对后面章节要讲知识的准备,所以你一定要重视课后题。每节课后都认真去做一做题,思考题目背后的知识点。
另外,音频这个形式非常重要。文稿里是最核心的内容,很多对于代码的分析放进去会非常繁琐,但是这块又非常重要,于是在音频讲解里,我会对代码部分进行更加详尽的讲述。如果你遇到,仅仅看文字不能理解的部分,尝试听听语音,或许会有不一样的收获。
最后,我想借用陈皓的一句话,“学习不是为了找到答案,而是为了找到方法”。哪怕我已经尽力为你考虑周全,但是没有你自己主动学习,去钻研,去下苦功寻找适合自己的方法,成功是不可能自己找上门来的。希望你可以为自己而努力。
好了,今天我想分享的就是这些。不知道我今天的分享哪个地方对你最有用呢?你在学专栏的过程中还遇到过什么问题呢?
欢迎留言和我分享你的疑惑和见解。你可以收藏今天的文章,反复研读。你也可以把今天文章分享给你的朋友,和他一起学习操作系统。

View File

@@ -0,0 +1,6 @@
<audio id="audio" title="01 | 入学测验你究竟对Linux操作系统了解多少" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/29/f3/294761293f283421c13a251899acf1f3.mp3"></audio>
<img src="https://static001.geekbang.org/resource/image/1d/30/1d29bf1eb0f943a91fd233105f06c830.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/be/0e/bec42567b46fe69e4e4d4e427f625c0e.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/5a/fd/5a48f52da754b201ff4ca1ab831875fd.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/96/e2/96c2368e46d049dd60f85b82c7cbb1e2.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/4f/52/4fc17ea8b6877c8a3fe7cbb906575e52.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/5f/f5/5f5d850a0eb1998da4005a378078a7f5.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/7f/99/7f42798b9aa414fe10bd240963854e99.jpg" alt="">
[<img src="https://static001.geekbang.org/resource/image/00/f2/00f868b7654dcb50ae2c91fd7688d2f2.jpg" alt="unpreview">](time://mall?url=https%3A%2F%2Fj.youzan.com%2FG69gDi)<br>
限量发售中仅限5000份3大体系22个模块定位工作中80%的高频问题。

View File

@@ -0,0 +1,114 @@
<audio id="audio" title="02 | 学习路径爬过这六个陡坡你就能对Linux了如指掌" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ae/7e/ae339f4a53a07f03795c1c46fa43c67e.mp3"></audio>
很多人觉得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有源代码很多地方可以参考现有的实现定制化自己的模块。例如你可以自己实现一个设备驱动程序实现一个自己的系统调用或者实现一个自己的文件系统等等。
<img src="https://static001.geekbang.org/resource/image/9e/85/9e970ed142da439f6fbe6d7c06f11785.jpeg" alt="">
这个难度比较大涉及的细节比较多上一个阶段我的建议是不计较一城一地的得失不需要每个细节都搞清楚这一个阶段要求就更高了。一旦代码有一个细微的bug都有可能导致实验失败。
专栏最后一个部分,我专门设计了两个实验,帮你度过这个坎。只要跟着我的步伐进行学习,接下来,就没人能够阻挡你成为一名内核开发工程师了。
## 最后一个坡:面向真实场景的开发,实践没有终点
说了这么多,我们都只是走出了万里长征第一步。我始终坚信,真正的高手都是在实战中摸爬滚打练出来的。
如果你是运维,仅仅熟悉上面基本的操作是不够的,生产环境会有大量的不可控因素,尤其是集群规模大的更是如此,大量的运维经验是实战来的,不能光靠读书。如果你是开发,对内核进行少量修改容易,但是一旦面临真实的场景,需要考虑各种因素,并发与并行,锁与保护,扩展性和兼容性,都需要真实项目才能练出来。
## 总结时刻
今天我把爬坡的过程分解成了六个阶段并给你分享了我的私家爬坡宝典。你都记住了吗我把今天的内容总结成了下面这张图。建议你牢牢记住这张图在接下来的四个月中按照这个路径稳步前进攻克Linux操作系统。
<img src="https://static001.geekbang.org/resource/image/bc/5b/bcf70b988e59522de732bc1b01b45a5b.jpeg" alt="">
## 课堂练习
你可以结合第一节的测试结果,并根据我今天讲的爬坡方法,思考一下,在接下来的四个月里,你准备怎么学习这个专栏。
欢迎在留言区写下你的**爬坡计划**,也欢迎你把今天的文章分享给你的朋友,和他一起学习、进步。
编辑乱入:超哥推荐的图书,部分已上架极客时间商城,点击下方图片,即可购买。和专栏一起配合使用,学习效果会更好哦!
[<img src="https://static001.geekbang.org/resource/image/6b/68/6bae103a79601bddd51b87f4e838e868.jpg" alt="">](https://h5.youzan.com/v2/feature/2VpBYpR2As)
[<img src="https://static001.geekbang.org/resource/image/00/f2/00f868b7654dcb50ae2c91fd7688d2f2.jpg" alt="unpreview">](time://mall?url=https%3A%2F%2Fj.youzan.com%2FG69gDi)<br>
限量发售中仅限5000份3大体系22个模块定位工作中80%的高频问题。

View File

@@ -0,0 +1,90 @@
<audio id="audio" title="开篇词 | 为什么要学习Linux操作系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bd/16/bd7af6a01d606dadc8ed1f2f78dea916.mp3"></audio>
你好,我是你的老朋友刘超。在“[趣谈网络协议](https://time.geekbang.org/column/intro/85?utm_term=zeusMX7NJ&amp;utm_source=app&amp;utm_medium=geektime&amp;utm_campaign=85-end&amp;utm_content=caozuoxitongkaipianci)”结课半年之后我又给你带来了一个新的基础课程“趣谈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、内存、硬盘、网络。同时一台物理机上也要跑很多程序这些资源应该给谁用呢当然是大家轮着用谁也别独占谁也别饿着。为了完成资源分配这件事操作系统承担了一个“大管家”的作用。它将硬件资源分配给不同的用户程序使用并且在适当的时间将这些资源拿回来再分配给其他的用户进程。
鉴于操作系统这个“大管家”的角色,我设计了一个故事,将各个知识点串起来,来帮助你理解和记忆。
假设,我们现在就是在做一家外包公司,我们的目标是把这家公司做上市。其中,操作系统就是这家外包公司的老板。我们把这家公司的发展阶段分为这样几个时期:
<li>
**初创期**这个老板基于开放的营商环境x86体系结构创办一家外包公司系统的启动。因为一开始没有其他员工老板需要亲自接项目实模式
</li>
<li>
**发展期**:公司慢慢做大,项目越接越多(保护模式、多进程),为了管理各个外包项目,建立了项目管理体系(进程管理)、会议室管理体系(内存管理)、文档资料管理系统(文件系统)、售前售后体系(输入输出设备管理)。
</li>
<li>
**壮大期**:公司越来越牛,开始促进内部项目的合作(进程间通信)和外部公司合作(网络通信)。
</li>
<li>
**集团化**公司的业务越来越多会成立多家子公司虚拟化或者鼓励内部创业容器化这个时候公司就变成了集团。大管家的调度能力不再局限于一家公司而是集团公司Linux集群从而成功上市从单机操作系统到数据中心操作系统
</li>
<img src="https://static001.geekbang.org/resource/image/80/5d/80a4502300dfa51c8520001c013cee5d.jpeg" alt="">
第二个原则就是**图解**。Linux操作系统中的概念非常多数据结构也很多流程也复杂一般人在学习的过程中很容易迷路。所谓“一图胜千言”我希望能够通过图的方式将这些复杂的概念、数据结构、流程表现出来争取用一张图串起一篇文章的知识点。最终整个专栏下来你如果能把这些图都掌握了你的知识就会形成体系和连接。在此基础上再进行深入学习就会如鱼得水、易如反掌。
<img src="https://static001.geekbang.org/resource/image/bf/02/bf0bcbea6a24bc5084bc0d4ffca7c502.jpeg" alt="">
例如,这张图就表示了文件操作在各个层的数据结构的关联。只要你学完之后,能对着这张图将它们之间的关系讲清楚,对于文件系统的部分,你就会了然于心了。
一段新的征途即将开始今天就是“开学典礼”。从今天开始在接下来的四个月时间里我会带你一步一步进入Linux操作系统的大门让基础变成你技术生涯的左膀右臂。
在开始正式学习之前,我也想听你讲讲,之前你在学习和工作过程中,遇到过哪些操作系统相关的问题,有哪些困惑,又有哪些经验,也可以谈谈你对新学期的期许。
欢迎在留言区和我分享。
[<img src="https://static001.geekbang.org/resource/image/19/bc/19bc90ffcf4b1fba4938727e5bc0ecbc.jpg" alt="unpreview">](time://mall?url=https%3A%2F%2Fshop18793264.youzan.com%2Fv2%2Fgoods%2F1y7qqgp3ghd2g%3Fdc_ps%3D2347114008676525065.200001)
Linux知识地图2.0典藏版现货发售2000份把5米长的图谱装进背包1分钟定位80%的高频问题。

View File

@@ -0,0 +1,382 @@
<audio id="audio" title="60 | 搭建操作系统实验环境(上):授人以鱼不如授人以渔" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/33/51/333d3d5a45fa87ff0761fd8ad0d28151.mp3"></audio>
操作系统的理论部分我们就讲完了,但是计算机这门学科是实验性的。为了更加深入地了解操作系统的本质,我们必须能够做一些上手实验。操作系统的实验,相比其他计算机课程的实验要更加复杂一些。
我们做任何实验都需要一个实验环境。这个实验环境要搭建在操作系统之上但是我们这个课程本身就是操作系统实验难不成要自己debug自己到底该咋整呢
我们有一个利器那就是qemu啊不知道你还记得吗它可以在操作系统之上模拟一个操作系统就像一个普通的进程。那我们是否可以像debug普通进程那样通过qemu来debug虚拟机里面的操作系统呢
这一节和下一节,我们就按照这个思路,来试试看,搭建一个操作系统的实验环境。
运行一个qemu虚拟机首先我们要有一个虚拟机的镜像。咱们在[虚拟机](https://time.geekbang.org/column/article/108964)那一节,已经制作了一个虚拟机的镜像。假设我们要基于 [ubuntu-18.04.2-live-server-amd64.iso](http://ubuntu-18.04.2-live-server-amd64.iso)它对应的内核版本是linux-source-4.15.0。
当时我们启动虚拟机的过程很复杂,设置参数的时候也很复杂,以至于解析这些参数就花了我们一章的时间。所以,这里我介绍一个简单的创建和管理虚拟机的方法。
在[CPU虚拟化](https://time.geekbang.org/column/article/109335)那一节我留过一个思考题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文件这样容易维护。
```
&lt;domain type='qemu'&gt;
&lt;name&gt;ubuntutest&lt;/name&gt;
&lt;uuid&gt;0f0806ab-531d-6134-5def-c5b4955292aa&lt;/uuid&gt;
&lt;memory unit='GiB'&gt;4&lt;/memory&gt;
&lt;currentMemory unit='GiB'&gt;4&lt;/currentMemory&gt;
&lt;vcpu placement='static'&gt;2&lt;/vcpu&gt;
&lt;os&gt;
&lt;type arch='x86_64' machine='pc-i440fx-trusty'&gt;hvm&lt;/type&gt;
&lt;boot dev='hd'/&gt;
&lt;/os&gt;
&lt;features&gt;
&lt;acpi/&gt;
&lt;apic/&gt;
&lt;pae/&gt;
&lt;/features&gt;
&lt;clock offset='utc'/&gt;
&lt;on_poweroff&gt;destroy&lt;/on_poweroff&gt;
&lt;on_reboot&gt;restart&lt;/on_reboot&gt;
&lt;on_crash&gt;restart&lt;/on_crash&gt;
&lt;devices&gt;
&lt;emulator&gt;/usr/bin/qemu-system-x86_64&lt;/emulator&gt;
&lt;disk type='file' device='disk'&gt;
&lt;driver name='qemu' type='qcow2'/&gt;
&lt;source file='/mnt/vdc/ubuntutest.img'/&gt;
&lt;target dev='vda' bus='virtio'/&gt;
&lt;/disk&gt;
&lt;controller type='pci' index='0' model='pci-root'/&gt;
&lt;interface type='bridge'&gt;
&lt;mac address='fa:16:3e:6e:89:ce'/&gt;
&lt;source bridge='br0'/&gt;
&lt;target dev='tap1'/&gt;
&lt;model type='virtio'/&gt;
&lt;/interface&gt;
&lt;serial type='pty'&gt;
&lt;target port='0'/&gt;
&lt;/serial&gt;
&lt;console type='pty'&gt;
&lt;target type='serial' port='0'/&gt;
&lt;/console&gt;
&lt;graphics type='vnc' port='-1' autoport='yes' listen='0.0.0.0'&gt;
&lt;listen type='address' address='0.0.0.0'/&gt;
&lt;/graphics&gt;
&lt;video&gt;
&lt;model type='cirrus'/&gt;
&lt;/video&gt;
&lt;/devices&gt;
&lt;/domain&gt;
```
在这个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这里面就是咱们在[系统初始化](https://time.geekbang.org/column/article/89739)的时候,讲过的那个启动列表。
在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下,就是解压好的内核代码。
准备工作都做好了。这一节,我们先来做第一个实验,也就是,在原有内核代码的基础上加一个我们自己的系统调用。
在哪里加代码呢?如果你忘了,请出门左转,回顾一下[系统调用](https://time.geekbang.org/column/article/90394)那一节。
第一个要加的地方是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 &gt;= 512){
return -1;
}
copy_from_user(buffer, words, count);
ret=printk(&quot;User Mode says %s to the Kernel Mode!&quot;, 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 ---&gt;
Compile-time checks and compiler options ---&gt;
[*] Compile the kernel with debug info
[*] Compile the kernel with frame pointers
```
选择完毕之后,配置会保存在.config文件中。如果我们打开看能看到这样的配置
```
CONFIG_FRAME_POINTER=y
CONFIG_DEBUG_INFO=y
```
接下来,我们编译内核。
```
nohup make -j8 &gt; make1.log 2&gt;&amp;1 &amp;
nohup make modules_install &gt; make2.log 2&gt;&amp;1 &amp;
nohup make install &gt; make3.log 2&gt;&amp;1 &amp;
```
这是一个非常长的过程请耐心等待可能需要数个小时因而这里用了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 &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;unistd.h&gt;
#include &lt;linux/kernel.h&gt;
#include &lt;sys/syscall.h&gt;
#include &lt;string.h&gt;
int main ()
{
char * words = &quot;I am liuchao from user mode.&quot;;
int ret;
ret = syscall(333, words, strlen(words)+1);
printf(&quot;return %d from kernel mode.\n&quot;, 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内核添加了一个自己的系统调用并且进行了编译并安装了新内核。如果你按照这个过程做下来你会惊喜地发现原来令我们敬畏的内核也是能够加以干预为我而用的呢。没错这就是你开始逐渐掌握内核的重要一步。
## 课堂练习
这一节的课堂练习,希望你能够按照整个过程,一步一步操作下来。毕竟看懂不算懂,做出来才算入门啊。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。

View File

@@ -0,0 +1,287 @@
<audio id="audio" title="61 | 搭建操作系统实验环境(下):授人以鱼不如授人以渔" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/dd/f32e425858449891e10cb7ed546227dd.mp3"></audio>
上一节我们做了一个实验,添加了一个系统调用,并且编译了内核。这一节,我们来尝试调试内核。这样,我们就可以一步一步来看,内核的代码逻辑执行到哪一步了,对应的变量值是什么。
## 了解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 &lt;stdio.h&gt;
2 #include &lt;stdlib.h&gt;
3 #include &lt;unistd.h&gt;
4 #include &lt;linux/kernel.h&gt;
5 #include &lt;sys/syscall.h&gt;
6 #include &lt;string.h&gt;
7
8 int main ()
9 {
10 char * words = &quot;I am liuchao from user mode.&quot;;
(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 = &quot;I am liuchao from user mode.&quot;;
(gdb) n
12 ret = syscall(333, words, strlen(words)+1);
(gdb) p words
$1 = 0x5555555547c4 &quot;I am liuchao from user mode.&quot;
(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的namespaceqemu:commandline这个参数libvirt不认。
```
&lt;domain type='qemu' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'&gt;
&lt;name&gt;ubuntutest&lt;/name&gt;
&lt;uuid&gt;0f0806ab-531d-6134-5def-c5b4955292aa&lt;/uuid&gt;
&lt;memory unit='KiB'&gt;8388608&lt;/memory&gt;
&lt;currentMemory unit='KiB'&gt;8388608&lt;/currentMemory&gt;
&lt;vcpu placement='static'&gt;8&lt;/vcpu&gt;
&lt;os&gt;
&lt;type arch='x86_64' machine='pc-i440fx-trusty'&gt;hvm&lt;/type&gt;
&lt;boot dev='hd'/&gt;
&lt;/os&gt;
&lt;clock offset='utc'/&gt;
&lt;on_poweroff&gt;destroy&lt;/on_poweroff&gt;
&lt;on_reboot&gt;restart&lt;/on_reboot&gt;
&lt;on_crash&gt;restart&lt;/on_crash&gt;
&lt;devices&gt;
&lt;emulator&gt;/usr/bin/qemu-system-x86_64&lt;/emulator&gt;
&lt;disk type='file' device='disk'&gt;
&lt;driver name='qemu' type='qcow2'/&gt;
&lt;source file='/mnt/vdc/ubuntutest.img'/&gt;
&lt;backingStore/&gt;
&lt;target dev='vda' bus='virtio'/&gt;
&lt;alias name='virtio-disk0'/&gt;
&lt;address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x0'/&gt;
&lt;/disk&gt;
......
&lt;interface type='bridge'&gt;
&lt;mac address='fa:16:3e:6e:89:ce'/&gt;
&lt;source bridge='br0'/&gt;
&lt;target dev='tap1'/&gt;
&lt;model type='virtio'/&gt;
&lt;alias name='net0'/&gt;
&lt;address type='pci' domain='0x0000' bus='0x00' slot='0x03' function='0x0'/&gt;
&lt;/interface&gt;
......
&lt;/devices&gt;
&lt;qemu:commandline&gt;
&lt;qemu:arg value='-s'/&gt;
&lt;/qemu:commandline&gt;
&lt;/domain&gt;
```
另外为了远程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 &quot;I am liuchao from user mode.&quot;, count=29) at kernel/sys.c:192
192 {
(gdb) bt
#0 sys_sayhelloworld (words=0x55b2811537c4 &quot;I am liuchao from user mode.&quot;, 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 &gt;= 1024){
(gdb) n
198 copy_from_user(buffer, words, count);
(gdb) n
199 ret=printk(&quot;User Mode says %s to the Kernel Mode!&quot;, buffer);
(gdb) p buffer
$1 = &quot;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=&lt;optimized out&gt;) 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函数的。这一点和我们在[系统调用](https://time.geekbang.org/column/article/90394)那一节分析的过程是一模一样的。
我们可以通过执行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一下文件打开的过程。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,127 @@
<audio id="audio" title="62 | 知识串讲:用一个创业故事串起操作系统原理(一)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/31/1a/31ccc375346c1a58f049aeaefbb4781a.mp3"></audio>
操作系统是一门体系复杂、知识点很多的课程,经过这么多节的讲解,你是否已经感觉自己被淹没在细节的汪洋大海里面了?没关系,从这一节开始,我们用五节的时间,通过一个创业故事,串起来操作系统的整个知识体系。
接下来,我们就来看主人公是如何从小马,变成马哥,再变成马总的吧!
## 小马创业选园区,开放标准是第一
小马最终还是决定走出大公司,自己去创业了。
他之所以这样决定有两个原因一方面大企业多年的工作经验让他练就了从前端到后端从Web到App从产品设计到测试交付的全栈能力。他很自信靠着这些能力闯荡江湖应该没什么问题另外一方面他听说姓“马”的创业成功的概率好像比较大。
创业首先要注册公司。注册公司就需要有一个办公地点。所以小马需要选择一个适合创业的环境。他找了很多地方发现有的地方政策倾斜大型企业有的地方倾斜本地企业有的地方鼓励金融创新。小马感觉这些地方都不太适合他这个IT男。
直到有一天小马来到了位于杭州滨江的x86创业园区。他被深深地吸引住了当然首要吸引他的就是园区工作人员的热情。
园区的工作人员向小马介绍了以下信息。
“首先咱们这个x86园区主要有三大特点一是标准二是开放三是兼容。像您这种创业者还是非常多的。初次创业不一定有经验园区提供标准的企业运行流程辅导。”
“另外,我们园区秉承完全开放的态度,对待各种各样的企业。不封闭,不保守。只要您符合国家的法律法规,我们都接纳。而且,整个园区是一种开放合作的生态,也有利于不同企业之间的协作。”
“再就是兼容。我们园区的流程和规则的设计都会兼容历史上的既有政策,既不会朝令夕改,也不会因为变化而影响您公司的运转。总而言之,来了咱们园区,您就埋头干业务就可以啦!”
小马显然对于x86园区的开放性十分满意于是追问道“您刚才说的企业运行流程辅导能详细介绍一下吗将来我这个企业在这个园区应该怎么个运转法儿
工作人员接着说“咱们这个园区毗邻全国知名高校每年都有大量的优秀毕业生来园区找工作这是企业非常重要的人才来源。葛优说了二十一世纪了人才是核心嘛。每年我们园区都会招聘大量的毕业生先进行一个月的培训合格毕业的可以推荐给您这种企业。这些人才啊就是咱们企业的CPU。”
“经过我们园区培训过的CPU人才具备了三种老板们喜欢的核心竞争力
第一,实干能力强,干活快,我们称为运算才能——也即指令执行能力;
第二,记忆力好,记得又快又准,我们称为数据才能——也即数据寄存能力;
第三,听话,自控能力强,可以多任务并发执行,我们称为控制才能——也即指令寄存能力。
到时候你可以根据需求看雇用多少个CPU人才
另外,人才得有个办公的地方,这一片呢,就是我们的办公区域,称为也就是内存区域。您可以包几个工位,或者包一片区域,或者几个会议室,让您公司的人才在里面做项目就可以了。这里面有的是地方,同时运行多少个项目都行。”
<img src="https://static001.geekbang.org/resource/image/3a/23/3afda18fc38e7e53604e9ebf9cb42023.jpeg" alt="">
跟着工作人员的介绍小马走在x86园区中看着这一片片的内存办公区脑子里已经浮现出将来热火朝天的办公场景了。
“也许不到半年的时间我肯定能够接两三个大项目招聘十个八个CPU员工。那项目A的员工就坐在这片内存办公区项目B的员工就坐在那片内存办公区。我根据积累的人脉将接到的项目写成一个一个的项目执行计划书里面是一行行项目执行的指令这些指令操作数据产生一些结果我们就可以叫程序啦。”小马这么想着。
“然后呢我把不同的项目执行计划书交给不同的项目组去执行。那项目组就叫进程吧两个项目组进程A和B会有独立的内存办公空间互相隔离程序会分别加载到进程A和进程B的内存办公空间里面形成各自的代码段。要操作的数据和产生的结果就放在数据段里面。“
“除此之外我应该找一个或者多个CPU员工来运行项目执行计划书我只要告诉他下一条指令在内存办公区中的地址经过训练的CPU员工就会很自觉地、不停地将代码段的指令拿进来进行处理。“
“指令一般是分两部分一部分表示做什么操作例如是加法还是位移另一部分是操作哪些数据。数据的部分CPU员工会从数据段里面读取出来记在脑子里然后进行处理处理完毕的结果再写回数据段。当项目执行计划书里面的所有指令都执行完毕之后项目也就完成了那就可以等着收钱啦。”
小马沉浸在思绪中久久不能自拔,直到工作人员打断了他的思绪:“您觉得园区如何?要不要入住呀?先租几个工位,招聘几个人呢?“
小马想了想,说道:“园区我很满意,以后就在您这里创业了,创业开始,我先不招人,自己先干吧。”
## 启动公司有手册,获取内核当宝典
工作人员说“感谢您入驻咱们创业园区由于您是初次创业这里有一本《创业指导手册》在这一本叫作BIOS的小册子上有您启动一家公司的通用流程你只要按照里面做就可以了。”
小马接过BIOS小册子开始按照里面的指令启动公司了。
创业初期小马的办公室肯定很小只有有1M的内存办公空间。在1M空间最上面的0xF0000到0xFFFFF这64K映射给ROM通过读这部分地址可以访问这个BIOS小册子里面的指令。
创业指导手册第一条BIOS要检查一些系统的硬件是不是都好着呢。创业指导手册第二条要有个办事大厅只不过小马自己就是办事员。因为一旦开张营业就会有人来找到这家公司因而基本的中断向量表和中断服务程序还是需要的至少要能够使用键盘和鼠标。
BIOS这个手册空间有限只能帮小马把公司建立起来公司如何运转和经营就需要另外一个东西——《企业经营宝典》因而BIOS还要做的一件事情就是帮助小马找到这个宝典然后让小马以后根据这个宝典里面的方法来经营公司这个《企业经营宝典》就是这家公司的内核。
<img src="https://static001.geekbang.org/resource/image/0a/6b/0a29c1d3e1a53b2523d2dcab3a59886b.jpeg" alt="">
运营一个企业非常的复杂因而这本《企业经营宝典》也很厚BIOS手册无法直接加载出来而需要从门卫开始问起不断打听这本内核的位置然后才能加载他。
门卫只有巴掌大的一块地方在启动盘的第一个扇区512K的大小我们通常称为MBRMaster Boot Record主引导记录/扇区。这里保存了boot.imgBIOS手册会将他加载到内存中的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会给小马展示一个《企业经营宝典》的列表也即操作系统的列表让小马进行选择。经营企业的方式也有很多种到底是人性化的还是强纪律的这个时候你要做一个选择。
<img src="https://static001.geekbang.org/resource/image/f1/b6/f1be2db375f1503af85535dec5efe9b6.png" alt="">
在这里小马毫不犹豫地选择了《狼性文化》操作系统至此grub才开始启动《狼性文化》操作系统内核。
拿到了宝典的小马,开始越来越像一个老板了。他要开始以老板的思维,来建立这家公司。
## 初创公司有章法,请来兄弟做臂膀
这注定是一个不眠夜,办公室里面一片漆黑中,唯一亮着的台灯下,小马独自捧着《企业经营宝典》仔细研读,读着读着,小马若有所思,开始书写公司内核的初始化计划。
<img src="https://static001.geekbang.org/resource/image/75/cd/758c283cf7633465d24ab3ef778328cd.jpeg" alt="">
公司首先应该有个项目管理部门咱们将来肯定要接各种各样的项目因此项目管理体系和项目管理流程首先要建立起来。虽然现在还没有项目但是小马还是弄了一个项目模板init_task。这是公司的第一个项目进程是项目管理系统里面的项目列表中的第一个我们能称为0号进程。这个项目是虚拟的不对应一个真实的项目也就是进程
项目需要项目管理进行调度,还需要制定一些调度策略。
另外为了快速响应客户需求为了各个项目组能够方便地使用公司的公共资源还应该有一个办事大厅。这里面可以设置了很多中断门Interrupt Gate用于处理各种中断以便快速响应突发事件还可以提供系统调用为项目组服务。
如果项目接得多了,为了提高研发效率,对项目内容进行保密,就需要封闭开发,所以将来会有很多的会议室,因而还需要一个会议室管理系统。
项目的执行肯定会留下很多文档,这些是公司的积累,将来的核心竞争力,一定要好好管理,因而应该建立一个项目档案库,也即文件系统。
随着思绪的展开,小马奋笔疾书,已经写了满满的几页纸,小马顿感经营一个公司还是挺复杂的,一旦项目接多了肯定忙不过来。俗话说得好,“一个好汉三个帮”,小马准备找两个兄弟来一起创业。
小马想到的第一个人,是自己的大学室友,外号“周瑜”。大学一毕业,周瑜就转项目管理了,在一家大公司管理着大型项目。将来外部接了项目,可以让他来管。小马想到的第二个人,是自己上一家公司的同事,外号“张昭”,是他们总经理的好帮手,公司的流程、人事、财务打理得都清清楚楚,将来公司内部要运行得井井有条,也需要这样一个人。
第二天,小马请周瑜和张昭吃饭,邀请他们加入他的创业公司。小马说,公司要正规运转起来,应该分清内外,外部项目需要有人帮忙管理好——也就是用户态,内部公司的核心资源也需要管理好——也就是内核态。现在我一个人忙不过来,需要两位兄弟的加入,周瑜主外,张昭主内,正所谓,内事不决问张昭,外事不决问周郎嘛。
三个人相谈甚欢,谈及往日友谊、未来前景、上市敲钟……
第三天周瑜早早就来到公司开始了他的事业。小马拜托周瑜做的第一件事情是调用kernel_init运行1号项目进程。这个1号项目会在用户态运行init项目进程。这是第一个以外部项目的名义运行的之所以叫init就是做初始化的工作周瑜根据自己多年的项目管理经验将这个init项目立为标杆以后所有外部项目的运行都要按照他来是外部项目的祖先项目。
下午张昭也来到了公司小马拜托张昭做的第一件事情是调用kthreadd运行2号项目进程。这个2号项目是内核项目的祖先。将来所有的项目都有父项目、祖先项目会形成一棵项目树。公司大了之后周瑜和张昭做的公司VP级别的任务就可以坐在塔尖上了。
<img src="https://static001.geekbang.org/resource/image/4d/16/4de740c10670a92bbaa58348e66b7b16.jpeg" alt="">
好了,这一节小马终于将公司的架子搭起来了,兄弟三人如当年桃园三结义一样,开始自己的创业生涯,小马的这家公司能不能顺利接到项目呢?欲知后事,且听下回分解。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,136 @@
<audio id="audio" title="63 | 知识串讲:用一个创业故事串起操作系统原理(二)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/de/c2/de9daa81b093c3a723789fd2757dedc2.mp3"></audio>
上一节说到小马同学的公司已经创立了,还请来了周瑜和张昭作为帮手,所谓“兄弟齐心,其利断金”。可是,现在这家公司,还得从接第一个外部项目开始。
## 首个项目虽简单,项目管理成体系
<img src="https://static001.geekbang.org/resource/image/db/a9/dbd8785da6c3ce3fe1abb7bb5934b7a9.jpeg" alt="">
这第一个项目还是小马亲自去谈的。其实软件公司了解客户需求还是比较难的因为客户都说着接近人类的语言例如C/C++。这些咱们公司招聘的CPU小伙伴们可听不懂需要有一个人将客户需求转换为项目执行计划书CPU小伙伴们才能执行这个过程我们称为编译。
编译其实是一个需求分析和需求转换的过程。这个过程会将接近人类的C/C++语言转换为CPU小伙伴能够听懂的二进制语言并且以一定的文档格式写成项目执行计划书。这种文档格式是作为一个标准化的公司事先制定好的一种格式是周瑜从大公司里面借鉴来的称为ELF格式这个项目执行计划书有总论ELF Header的部分有包含指令的代码段的部分有包含全局变量的数据段的部分。
小马和客户聊了整整一天确认了项目的每一个细节保证编译能够通过才写成项目执行计划书ELF文件放到档案库中。此时已经半夜了。
第二天,周瑜一到公司,小马就兴奋地给周瑜说,“我昨天接到了第一个项目,而且是一个大项目,项目执行计划书我都写好了,你帮我监督、执行、管理,记得按时交付哦!”
周瑜说“没问题。”于是周瑜从父项目开始fork一个子项目然后在子项目中调用exec系统调用 然后到了内核里面通过load_elf_binary将项目执行计划书加载到子进程内存中交给一个CPU执行。
虽然这是第一个项目,以周瑜的项目管理经验,他告诉小马,项目的执行要保质保量,需要有一套项目管理系统来管理项目的状态,而不能靠脑子记。“项目管理系统?当然应该有了”,小马说。他在《企业经营宝典》中看到过。
于是项目管理系统就搭建起来了。在这里面所有项目都放在一个task_struct列表中对于每一个项目都非常详细地登记了项目方方面面的信息。
<img src="https://static001.geekbang.org/resource/image/1c/bc/1c91956b52574b62a4418a7c6993d8bc.jpeg" alt="">
每一个项目都应该有一个ID作为这个项目的唯一标识。到时候排期啊、下发任务啊等等都按ID来就不会产生歧义。
项目应该有运行中的状态TASK_RUNNING并不是说进程正在运行而是表示进程在时刻准备运行的状态。这个时候要看CPU小伙伴有没有空有空就运行他没空就得等着。
有时候进程运行到一半需要等待某个条件才能运行下去这个时候只能睡眠。睡眠状态有两种。一种是TASK_INTERRUPTIBLE可中断的睡眠状态。这是一种浅睡眠的状态也就是说虽然在睡眠等条件成熟进程可以被唤醒。
另一种睡眠是TASK_UNINTERRUPTIBLE不可中断的睡眠状态。这是一种深度睡眠状态不可被唤醒只能死等条件满足。有了一种新的进程睡眠状态TASK_KILLABLE可以终止的新睡眠状态。进程处于这种状态中他的运行原理类似TASK_UNINTERRUPTIBLE只不过可以响应致命信号也即虽然在深度睡眠但是可以被干掉。
一旦一个进程要结束先进入的是EXIT_ZOMBIE状态但是这个时候他的父进程还没有使用wait()等系统调用来获知他的终止信息,此时进程就成了僵尸进程。
EXIT_DEAD是进程的最终状态。
<img src="https://static001.geekbang.org/resource/image/e2/88/e2fa348c67ce41ef730048ff9ca4c988.jpeg" alt="">
另外,项目运行的统计信息也非常重要。例如,有的员工很长时间都在做一个任务,这个时候你就需要特别关注一下;再如,有的员工的琐碎任务太多,这会大大影响他的工作效率。
那如何才能知道这些员工的工作情况呢?在进程的运行过程中,会有一些统计量,例如进程在用户态和内核态消耗的时间、上下文切换的次数等等。
项目之间的亲缘关系也需要维护,任何一个进程都有父进程。所以,整个进程其实就是一棵进程树。而拥有同一父进程的所有进程都具有兄弟关系。
<img src="https://static001.geekbang.org/resource/image/92/04/92711107d8dcdf2c19e8fe4ee3965304.jpeg" alt="">
另外,对于项目来讲,项目组权限的控制也很重要。什么是项目组权限控制呢?这么说吧,我这个项目组能否访问某个文件,能否访问其他的项目组,以及我这个项目组能否被其他项目组访问等等
另外,项目运行过程中占用的公司的资源,例如会议室(内存)、档案库(文件系统)也需要在项目管理系统里面登记。
周瑜同学将项目登记好然后就分配给CPU同学们说开始执行吧。
好在第一个项目还是比较简单的一个CPU同学按照项目执行计划书按部就班一条条地执行很快就完成了客户评价还不错很快收到了回款。
## 项目大了要并行,项目多了要排期
小马很开心可谓开门红。接着第二个项目就到来了这可是一个大项目要帮一家知名公司开发一个交易网站共200个页面这下要赚翻了就是时间要得比较急要求两个星期搞定。
小马把项目带回来周瑜同学说这个项目有点大估计一个CPU同学干不过来了估计要多个CPU同学一起协作了。
为了完成这个大的项目进程就不能一个人从头干到尾了这样肯定赶不上工期。于是周瑜将一个大项目拆分成20个子项目每个子项目完成10个页面一个大项目组也分成20个小组并行开发都开发完了再做一次整合这肯定比依次开发200个页面快多了。如果项目叫进程那子项目就叫线程。
在Linux里面无论是进程还是线程到了内核里面我们统一都叫任务由一个统一的结构task_struct进行管理。
<img src="https://static001.geekbang.org/resource/image/75/2d/75c4d28a9d2daa4acc1107832be84e2d.jpeg" alt="">
不知道是好消息,还是坏消息,这么大一个项目还没有做完,新的项目又找上门了。看来有了前面的标杆客户,名声算是打出去了,一个项目接一个地不停。
小马是既高兴又犯愁于是找周瑜和张昭商量应该咋办。要不多招人多来几个CPU小伙伴就不搞定了可是咱们还是在创业阶段养不起这么多人。另外的办法就是人力复用一个CPU小伙伴干多个项目干不过来就加加班实在不行就996这样应该就没问题了。
一旦涉及一个CPU小伙伴同时参与多个项目就非常考验项目管理的水平了。如何排期、如何调度是一个大学问。例如有的项目比较紧急应该先进行排期有的项目可以缓缓但是也不能让客户等太久。所以这个过程非常复杂需要平衡。
对于操作系统来讲他面对的CPU的数量是有限的干活儿都是他们但是进程数目远远超过CPU的数目因而就需要进行进程的调度有效地分配CPU的时间既要保证进程的最快响应也要保证进程之间的公平。
如何调度呢周瑜能够想到的方式就是排队。每一个CPU小伙伴旁边都有一个白板上面写着自己需要完成的任务来了新任务就写到白板上做完了就擦掉。
一个CPU上有一个队列队列里面是一系列sched_entity每个sched_entity都属于一个task_struct代表进程或者线程。
调度要解决的第一个问题是每一个CPU小伙伴每过一段时间都要想一下白板上这么多项目我应该干哪一个CPU的队列里面有这么多的进程或者线程应该取出哪一个来执行
<img src="https://static001.geekbang.org/resource/image/10/af/10381dbafe0f78d80beb87560a9506af.jpeg" alt="">
这就是调度规则或者调度算法的问题。
周瑜说,他原来在大公司的时候,调度算法常常是这样设计的。
一个是公平性对于接到的多个项目不能厚此薄彼。这个算法主要由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小伙伴不会像原来一样火急火燎不知所从了。
可是其实对于项目的开发,这家公司还是有严重漏洞的,就是项目的保密问题,不管哪家客户将系统外包出去,肯定也不想让其他公司知道详情。如果解决不好这个问题,没人敢把重要的项目交给这家公司,小马的公司也就永远只能接点边角系统,还是不能保证温饱问题。
那接下来,小马会怎么解决项目之间的保密问题呢?欲知后事,且听下回分解。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,149 @@
<audio id="audio" title="64 | 知识串讲:用一个创业故事串起操作系统原理(三)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/07/19cc71d1d2659f99de7309a143b8bc07.mp3"></audio>
上一节我们说到周瑜和张昭商定了调用schedule的时机。尽管项目越来越多但是也井井有条。可是我们也说了不管你的事情做得有多好项目保密问题都是要解决的重要问题。怎么解决呢今天我们就来看一看。
## 保密需封闭开发,空间小巧妙安排
慢慢地小马发现项目接的多了之后CPU小伙伴的任务调度问题解决了之后会议室的使用经常陷入混乱。不同的项目使用会议室的时候经常冲突一个项目组没用完另一个项目组就在那里等着十分耽误开发效率。
小马说:“要不咱们的项目别用会议室封闭开发了,原来总是说封闭开发,就是为了隔离,保密。这对于公司声誉来说很重要,但是能不能通过签订保密协议的方式来,干嘛非得封闭开发呢?”
周瑜说:“马哥,以我在大公司管理项目的经验来看,您还是想简单了。”
“你看每次你接一个项目总要写成项目执行计划书CPU小伙伴们才能执行吧项目计划书中的一行一行指令运行过程中免不了要产生一些数据。这些数据要保存在一个地方这个地方就是会议室内存。会议室内存被分成一块一块儿的都编好了号。例如3F-10就是三楼十号会议室。这个地址是实实在在的地址通过这个地址我们就能够定位到物理内存的位置。”
“现在问题来了,写项目执行计划书的时候,里面的指令使用的地址是否可以使用物理地址呢?当然不行了,项目执行计划书,都是事先写好的,可以多次运行的。如果里面有个指令是,要把用户输入的数字保存在内存中,那就会有问题。”
“会产生什么问题呢我举个例子你就明白了。如果我们使用那个实实在在的地址3F-10打开三个相同的程序都执行到某一步。比方说打开了三个计算器用户在这三个程序的界面上分别输入了10、100、1000。如果内存中的这个位置只能保存一个数那应该保存哪个呢这不就冲突了吗
“如果不用这个实实在在的地址,那应该怎么办呢?那就必须用封闭开发的办法。
每个项目的物理地址对于进程不可见谁也不能直接访问这个物理地址。操作系统会给进程分配一个虚拟地址。所有进程看到的这个地址都是一样的里面的内存都是从0开始编号。
在程序里面指令写入的地址是虚拟地址。例如位置为10M的内存区域操作系统会提供一种机制将不同进程的虚拟地址和内存的物理地址映射起来。
当程序要访问虚拟地址的时候,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,就不会冲突了。”
小马想想,对啊,这是个好办法,咱们得规划一套会议室管理系统(内存管理)。根据刚才的分析,这个系统应该包含以下三个部分:
第一,物理内存的管理,相当于会议室管理员管理会议室;
第二,虚拟地址的管理,也即在项目组的视角,会议室的虚拟地址应该如何组织;
第三,虚拟地址和物理地址如何映射的问题,也即会议室管理员如果管理映射表。
我们先来盘点一下物理内存的情况。
<img src="https://static001.geekbang.org/resource/image/8f/49/8f158f58dda94ec04b26200073e15449.jpeg" alt="">
不同的园区工位的安排和会议室的布局各不相同。
第一种情况是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大小的连续内存。每个页块的第一个页的物理地址是该页块大小的整数倍。
<img src="https://static001.geekbang.org/resource/image/3f/4f/3fa8123990e5ae2c86859f70a8351f4f.jpeg" alt="">
例如要请求一个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号会议室门里面但是门上挂着“闲人免进”你只能在自己的用户空间里面折腾。
<img src="https://static001.geekbang.org/resource/image/af/83/afa4beefd380effefb0e54a8d9345c83.jpeg" alt="">
我们从最低位开始排起先是Text Segment、Data Segment和BSS Segment。Text Segment是存放二进制可执行代码的位置Data Segment存放静态常量BSS Segment存放未初始化的静态变量。这些都是在项目执行计划书里面有的。
接下来是堆段。堆是往高地址增长的是用来动态分配内存的区域malloc就是在这里面分配的。
接下来的区域是Memory Mapping Segment。这块地址可以用来把文件映射进内存用的如果二进制的执行文件依赖于某个动态链接库就是在这个区域里面将so文件映射到了内存中。
再下面就是栈地址段了,主线程的函数调用的函数栈就是用这里的。
如果普通进程还想进一步访问内核空间,是没办法的,只能眼巴巴地看着。如果需要进行更高权限的工作,就需要调用系统调用,进入内核。
一旦进入了内核就换了一副视角。刚才是普通进程的视角觉着整个空间是它独占的没有其他进程存在。当然另一个进程也这样认为因为它们互相看不到对方。这也就是说不同进程的0号到29号会议室放的东西都不一样。
但是到了内核里面无论是从哪个进程进来的看到的是同一个内核空间看到的是同一个进程列表。虽然内核栈是各用各的但是如果想知道的话还是能够知道每个进程的内核栈在哪里的。所以如果要访问一些公共的数据结构需要进行锁保护。也就是说不同的进程进入到内核后进入的30号到39号会议室是同一批会议室。
<img src="https://static001.geekbang.org/resource/image/4e/9d/4ed91c744220d8b4298237d2ab2eda9d.jpeg" alt="">
内核的代码访问内核的数据结构大部分的情况下都是使用虚拟地址的。虽然内核代码权限很大但是能够使用的虚拟地址范围也只能在内核空间也即内核代码访问内核数据结构只能用30号到39号这些编号不能用0到29号因为这些是被进程空间占用的。而且进程有很多个。你现在在内核但是你不知道当前指的0号是哪个进程的0号。
在内核里面也会有内核的代码同样有Text Segment、Data Segment和BSS Segment内核代码也是ELF格式的。
不过有了这个规定以后,项目执行计划书要写入数据的时候,就需要符合里面的规定了,数据不能随便乱放了。
小马说,“没问题,这个作为项目章程,每一个新员工来了都培训。”
## 管理系统全搞定,至此生存无问题
周瑜接着说:“物理会议室和虚拟空间都分成大小相同的页,我们还得有一个会议室管理系统,将两者关联起来,这样项目组申请会议室的时候,也有个系统可以统一的管理,要不然会议室还不得老冲突呀。”
对于虚拟内存的访问,也是有一个地址的,我们需要找到一种策略,实现从虚拟地址到物理地址的转换。
为了能够定位和访问每个页,需要有个页表,保存每个页的起始地址,再加上在页内的偏移量,组成线性地址,就能对于内存中的每个位置进行访问了。
<img src="https://static001.geekbang.org/resource/image/ab/40/abbcafe962d93fac976aa26b7fcb7440.jpg" alt="">
虚拟地址分为两部分,页号和页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。
下面的图,举了一个简单的页表的例子,虚拟内存中的页通过页表映射对应到物理内存中的页。
<img src="https://static001.geekbang.org/resource/image/83/c3/83a5de160088a2e23e7c1a76c013efc3.jpg" alt="">
32位环境下虚拟地址空间共4GB。如果分成4KB一个页那就是1M个页。每个页表项需要4个字节来存储那么整个4GB空间的映射就需要4MB的内存来存储映射表。如果每个进程都有自己的映射表100个进程就需要400MB的内存。对于内核来讲有点大了 。
页表中所有页表项必须提前建好,并且要求是连续的。如果不连续,就没有办法通过虚拟地址里面的页号找到对应的页表项了。
那怎么办呢我们可以试着将页表再分页4G的空间需要4M的页表来存储映射。我们把这4M分成1K1024个4K每个4K又能放在一页里面这样1K个4K就是1K个页这1K个页也需要一个表进行管理我们称为页目录表这个页目录表里面有1K项每项4个字节页目录表大小也是4K。
页目录有1K项用10位就可以表示访问页目录的哪一项。这一项其实对应的是一整页的页表项也即4K的页表项。每个页表项也是4个字节因而一整页的页表项是1k个。再用10位就可以表示访问页表项的哪一项页表项中的一项对应的就是一个页是存放数据的页这个页的大小是4K用12位可以定位这个页内的任何一个位置。
这样加起来正好32位也就是用前10位定位到页目录表中的一项。将这一项对应的页表取出来共1k项再用中间10位定位到页表中的一项将这一项对应的存放数据的页取出来再用最后12位定位到页中的具体位置访问数据。
<img src="https://static001.geekbang.org/resource/image/b6/b8/b6960eb0a7eea008d33f8e0c4facc8b8.jpg" alt="">
你可能会问如果这样的话映射4GB地址空间就需要4MB+4KB的内存这样不是更大了吗 当然如果页是满的,当时是更大了,但是,我们往往不会为一个进程分配那么多内存。
比如说上面图中我们假设只给这个进程分配了一个数据页。如果只使用页表也需要完整的1M个页表项共4M的内存但是如果使用了页目录页目录需要1K个全部分配占用内存4K但是里面只有一项使用了。到了页表项只需要分配能够管理那个数据页的页表项页就可以了也就是说最多4K这样内存就节省多了。
当然对于64位的系统两级肯定不够了就变成了四级目录分别是全局页目录项PGDPage Global Directory、上层页目录项PUDPage Upper Directory、中间页目录项PMDPage Middle Directory和页表项PTEPage Table Entry
<img src="https://static001.geekbang.org/resource/image/42/0b/42eff3e7574ac8ce2501210e25cd2c0b.jpg" alt="">
设计完毕会议室管理系统再加上前面的项目管理系统对于一家外包公司来讲无论接什么样的项目都能轻松搞定了。我们常把CPU和内存合称为计算。至此计算的问题就算搞定了。解决了这两大问题一家外包公司的生存问题就算解决了。
小马总算是可以松一口气了,他和周瑜、张昭好好地搓了一顿,喝得昏天黑地。周瑜和张昭纷纷感慨,幸亏当年跟了马哥,今日才有出头之日。
生存问题虽然解决了,马哥可非池中之物,接下来要解决的就是发展问题,马哥能想出什么办法进一步壮大企业呢?欲知后事,且听下回分解。

View File

@@ -0,0 +1,193 @@
<audio id="audio" title="65 | 知识串讲:用一个创业故事串起操作系统原理(四)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c3/71/c39a7a5ff135351b1eaf4a74890b1c71.mp3"></audio>
上一节,小马的公司已经解决了生存问题,成功从小马晋升马哥。
马哥是一个有危机意识的人。尽管公司开始不断盈利,项目像流水一样,一个接一个,赚了点儿钱,但是他感觉还是有点儿像狗熊掰棒子。因为公司没有积累,永远就都是在做小生意,无法实现成倍的增长。
马哥想,公司做了这么多的项目,应该有很多的共同点,能积累下来非常多的资料。如果能够把这些资料归档、总结、积累,形成核心竞争力,就可以随着行业的飞跃,深耕一个行业,实现快速增长。
## 公司发展需积累,马哥建立知识库
这就需要我们有一个存放资料的档案库(文件系统)。档案库应该不依赖于项目而独立存在,应该井井有条、利于查询;应该长久保存,不随人员流动而损失。
公司到了这个阶段,除了周瑜和张昭,应该专门请一个能够积累核心竞争力的人来主持大局了。马哥想到了,前一阵行业交流大会上,他遇到了一个很牛的架构师——鲁肃。他感觉鲁肃在这方面很有想法,于是就请他来主持大局。
鲁肃跟马哥说,构建公司的核心技术能力,这个档案库(文件系统)也可以叫作知识库,这个需要好好规划一下。规划文件系统的时候,需要考虑以下几点。
第一点,文件系统要有严格的组织形式,使得文件能够以块为单位进行存储。
这就像图书馆里我们会给设置一排排书架然后再把书架分成一个个小格子。有的项目存放的资料非常多一个格子放不下就需要多个格子来进行存放。我们把这个区域称为存放原始资料的仓库区。对于操作系统硬盘分成相同大小的单元我们称为块。一块的大小是扇区大小的整数倍默认是4K用来存放文件的数据部分。这样一来如果我们像存放一个文件就不用给他分配一块连续的空间了。我们可以分散成一个个小块进行存放。这样就灵活得多也比较容易添加、删除和插入数据。
第二点,文件系统中也要有索引区,用来方便查找一个文件分成的多个块都存放在了什么位置。
这就好比,图书馆的书太多了,为了方便查找,我们需要专门设置一排书架,这里面会写清楚整个档案库有哪些资料,资料在哪个架子的哪个格子上。这样找资料的时候就不用跑遍整个档案库,只要在这个书架上找到后,直奔目标书架就可以了。
在Linux操作系统里面每一个文件有一个Inodeinode的“i”是index的意思其实就是“索引”。inode里面有文件的读写权限i_mode属于哪个用户i_uid哪个组i_gid大小是多少i_size_io占用多少个块i_blocks_io。“某个文件分成几块、每一块在哪里”这些信息也在inode里面保存在i_block里面。
<img src="https://static001.geekbang.org/resource/image/93/07/93bf5e8e940752b32531ed6752b5f607.png" alt="">
第三点,如果文件系统中有的文件是热点文件,近期经常被读取和写入,文件系统应该有缓存层。
这就相当于图书馆里面的热门图书区,这里面的书都是畅销书或者是常常被借还的图书。因为借还的次数比较多,那就没必要每次有人还了之后,还放回遥远的货架,我们可以专门开辟一个区域,放置这些借还频次高的图书。这样借还的效率就会提高。
第四点,文件应该用文件夹的形式组织起来,方便管理和查询。
这就像在图书馆里面,你可以给这些资料分门别类,比如分成计算机类、文学类、历史类等等。这样你也容易管理,项目组借阅的时候只要在某个类别中去找就可以了。
在文件系统中,每个文件都有一个名字,我们访问一个文件,希望通过他的名字就可以找到。文件名就是一个普通的文本,所以文件名经常会冲突,不同用户取相同的名字的情况会经常出现的。
要想把很多的文件有序地组织起来,我们就需要把他们做成目录或者文件夹。这样,一个文件夹里可以包含文件夹,也可以包含文件,这样就形成了一种树形结构。我们可以将不同的用户放在不同的用户目录下,就可以一定程度上避免了命名的冲突问题。
<img src="https://static001.geekbang.org/resource/image/e7/4f/e71da53d6e2e4458bcc0af1e23f08e4f.png" alt="">
第五点Linux内核要在自己的内存里面维护一套数据结构来保存哪些文件被哪些进程打开和使用。
这就好比,图书馆里会有个图书管理系统,记录哪些书被借阅了,被谁借阅了,借阅了多久,什么时候归还。
这个图书管理系统尤为重要,如果不是很方便使用,以后项目中积累了经验,就没有人愿意往知识库里面放了。
<img src="https://static001.geekbang.org/resource/image/3c/73/3c506edf93b15341da3db658e9970773.jpg" alt="">
无论哪个项目进程都可以通过write系统调用写入知识库。
对于每一个进程打开的文件都有一个文件描述符。files_struct里面会有文件描述符数组。每个一个文件描述符是这个数组的下标里面的内容指向一个struct file结构表示打开的文件。这个结构里面有这个文件对应的inode最重要的是这个文件对应的操作file_operation。如果操作这个文件就看这个file_operation里面的定义了。
每一个打开的文件都有一个dentry对应虽然我们叫作directory entry但是他不仅仅表示文件夹也表示文件。他最重要的作用就是指向这个文件对应的inode。
如果说file结构是一个文件打开以后才创建的dentry是放在一个dentry cache里面的。文件关闭了他依然存在因而他可以更长期的维护内存中的文件的表示和硬盘上文件的表示之间的关系。
inode结构就表示硬盘上的inode包括块设备号等。这个inode对应的操作保存在inode operations里面。真正写入数据是写入硬盘上的文件系统例如ext4文件系统。
马哥听了知识库和档案库的设计,非常开心,对鲁肃说,你这五大秘籍,可是帮了我大忙了。于是马上下令实施。
## 有了积累建生态,成立渠道管理部
有了知识库,公司的面貌果然大为改观。
马哥发现,当知识库积累到一定程度,公司接任何项目都能找到相似的旧项目作为参考,不用重新设计,效率大大提高。而且最重要的一点是,没有知识库的时候,原来项目做得好不好,完全取决于程序员,因为所有的知识都在程序员的脑子里,所以公司必须要招聘高质量的程序员,才能保证项目的质量。一方面优秀的程序员数量很少,这大大限制了公司能够接项目的规模,一方面优秀的程序员实在太贵,大大提高了公司的成本。
有了知识库,依赖于原来积累的体系,只要找到类似的旧项目,哪怕是普通的程序员,只要会照猫画虎,结果就不会太差。
于是,马哥马上想到,现在公司只有百十来号人,能赚这些钱,现在招人门槛降低了,我要是招聘一万人,这能赚多少钱啊!
鲁肃对马哥说,“你可先别急着招人,建立知识库,降低招人成本才是第一步。公司招聘太多人不容易管理。既然项目的执行可以照猫画虎,很多项目可以不用咱们公司来,我们可以建立渠道销售体系(输入和输出系统),让供应商、渠道帮我们卖,形成一个生态。这公司的盈利规模可就不是招一万人这么点儿了,这是指数级的增长啊!”
<img src="https://static001.geekbang.org/resource/image/80/7f/80e152fe768e3cb4c84be62ad8d6d07f.jpg" alt="">
计算机系统的输入和输出系统都有哪些呢我们能举出来的例如键盘、鼠标、显示器、网卡、硬盘、打印机、CD/DVD等等多种多样。这样当然方便用户使用了但是对于操作系统来讲却是一件复杂的事情因为这么多设备形状、用法、功能都不一样怎么才能统一管理起来呢我们一层一层来看。
第一层,用设备控制器屏蔽设备差异。
马哥说,“把生意做到全国,我也想过,这个可不容易。咱们客户多种多样,众口难调,不同的地域不一样,不同的行业不一样。如果你不懂某个地方的规矩,根本卖不出去东西;如果你不懂某个具体行业的使用场景,也无法满足客户的需求。”
鲁肃说:“所以说,建议您建立生态,设置很多代理商,让各个地区和各个行业的代理商帮你屏蔽这些差异化。你和代理商之间只要进行简单的标准产品交付就可以了。”
计算机系统就是这样的。CPU并不直接和设备打交道他们中间有一个叫作设备控制器Device Control Unit的组件。例如硬盘有磁盘控制器、USB有USB控制器、显示器有视频控制器等。这些控制器就像代理商一样他们知道如何应对硬盘、鼠标、键盘、显示器的行为。
你的代理商往往是小公司。控制器其实有点儿像一台小电脑。他有他的芯片类似小CPU执行自己的逻辑。他也有他的寄存器。这样CPU就可以通过写这些寄存器对控制器下发指令通过读这些寄存器查看控制器对于设备的操作状态。
CPU对于寄存器的读写可比直接控制硬件要标准和轻松很多。这就相当于你和代理商的标准产品交付。
第二层,用驱动程序屏蔽设备控制器差异。
马哥说:“你这么一说,还真有道理,如果我们能够找到足够多的代理商,那就高枕无忧了。”
鲁肃说:“其实事情还没这么简单,虽然代理商机制能够帮我们屏蔽很多设备的细节,但是从上面的描述我们可以看出,由于每种设备的控制器的寄存器、缓冲区等使用模式,指令都不同。对于咱们公司来讲,就需要有个部门专门对接代理商,向其他部门屏蔽代理商的差异,成立公司的渠道管理部门。”
那对于操作系统来讲,渠道管理部门就是用来对接各个设备控制器的设备驱动程序。
这里需要注意的是,设备控制器不属于操作系统的一部分,但是设备驱动程序属于操作系统的一部分。操作系统的内核代码可以像调用本地代码一样调用驱动程序的代码,而驱动程序的代码需要发出特殊的面向设备控制器的指令,才能操作设备控制器。
设备驱动程序中是一些面向特殊设备控制器的代码。不同的设备不同。但是对于操作系统其他部分的代码而言,设备驱动程序应该有统一的接口。就像下面图中的一样,不同的设备驱动程序,可以以同样的方式接入操作系统,而操作系统的其他部分的代码,也可以无视不同设备的区别,以同样的接口调用设备驱动程序。
<img src="https://static001.geekbang.org/resource/image/7b/68/7bf96d3c8e3a82cdac9c7629b81fa368.png" alt="">
第三,用中断控制器统一外部事件处理。
马哥听了恍然大悟:“原来代理商也是五花八门,里面有这么多门道啊!”
鲁肃说:“当咱们对接的代理商多了,代理商可能会有各种各样的问题找到我们,例如代理商有了新客户,客户有了新需求,客户交付完毕等事件,都需要有一种机制通知你们公司,当然是中断,那操作系统就需要有一个地方处理这个中断,既然设备驱动程序是用来对接设备控制器的,中断处理也应该在设备驱动里面完成。”
然而中断的触发最终会到达CPU会中断操作系统当前运行的程序所以操作系统也要有一个统一的流程来处理中断使得不同设备的中断使用统一的流程。
一般的流程是一个设备驱动程序初始化的时候要先注册一个该设备的中断处理函数。咱们讲进程切换的时候说过中断返回的那一刻是进程切换的时机。中断的时候触发的函数是do_IRQ。这个函数是中断处理的统一入口。在这个函数里面我们可以找到设备驱动程序注册的中断处理函数Handler然后执行他进行中断处理。
<img src="https://static001.geekbang.org/resource/image/aa/c0/aa9d074d9819f0eb513e11014a5772c0.jpg" alt="">
第四,用文件系统接口屏蔽驱动程序的差异。
马哥又问了:“对接了这么多代理商,如果咱们内部的工程师要和他们打交道,有没有一种统一的方式呢?”
鲁肃说:“当然应该了,我们内部员工操作外部设备,可以基于文件系统的接口,制定一个统一的标准。”
其实文件系统的机制是一个非常好的机制,咱们公司应该定下这样的规则,一切皆文件。
所有设备都在/dev/文件夹下面创建一个特殊的设备文件。这个设备特殊文件也有inode但是他不关联到硬盘或任何其他存储介质上的数据而是建立了与某个设备驱动程序的连接。
有了文件系统接口之后我们不但可以通过文件系统的命令行操作设备也可以通过程序调用read、write函数像读写文件一样操作设备。
对于块设备来讲在驱动程序之上文件系统之下还需要一层通用设备层。比如咱们讲的文件系统里面的逻辑和磁盘设备没有什么关系可以说是通用的逻辑。在写文件的最底层我们看到了BIO字眼的函数但是好像和设备驱动也没有什么关系。
是的因为块设备类型非常多而Linux操作系统里面一切是文件。我们也不想文件系统以下就直接对接各种各样的块设备驱动程序这样会使得文件系统的复杂度非常高。所以我们在中间加了一层通用块层将与块设备相关的通用逻辑放在这一层维护与设备无关的块的大小然后通用块层下面对接各种各样的驱动程序。
<img src="https://static001.geekbang.org/resource/image/a3/e5/a364f9a9ac045c5d4c1c5a7dfa9ca6e5.png" alt="">
鲁肃帮助马哥建立了这套体系之后,果真业务有了很大起色。原来公司只敢接华东区的项目,毕竟比较近,沟通交付都很方便。后来项目扩展到所有一线城市、二线城市、省会城市,项目数量实现了几十倍的增长。
## 千万项目难度大,集体合作可断金
项目接的多了,就不免有大型的项目,涉及多个行业多个领域,需要多个项目组进行合作才能完成。那两个项目组应该通过什么样的方式,进行沟通与合作呢?作为老板,马哥应该如何设计整个流程呢?
马哥叫来周瑜、张昭、鲁肃,一起商量团队间的合作模式。大家一起献计献策。好在有很多成熟的项目管理流程可以参考。
最最传统的模型就是软件开发的瀑布模型。所谓的瀑布模型,其实就是将整个软件开发过程分成多个阶段,往往是上一个阶段完全做完,才将输出结果交给下一个阶段。这种模型类似进程间通信的管道模型。
所谓的管道,就是在两个进程之间建立一条单向的通道,其实是一段缓存,它会将前一个命令的输出,作为后一个命令的输入。
<img src="https://static001.geekbang.org/resource/image/c0/e2/c042b12de704995e4ba04173e0a304e2.png" alt="">
张昭说,瀑布模型的开发流程效率比较低下,现在大部分公司都不使用这种开发模式了,因为团队之间无法频繁地沟通。而且,管道的使用模式,也不适合进程间频繁的交换数据。
于是,他们还得想其他的办法。是不是可以借鉴传统外企的沟通方式——邮件呢?邮件有一定的格式,例如抬头、正文、附件等。发送邮件可以建立收件人列表,所有在这个列表中的人,都可以反复地在此邮件基础上回复,达到频繁沟通的目的。这个啊,就是消息队列模型。
<img src="https://static001.geekbang.org/resource/image/ac/a4/ac6ad6c9e7e3831f6d813113ae1c5ba4.png" alt="">
和管道将信息一股脑儿地从一个进程,倒给另一个进程不同,消息队列有点儿像邮件,发送数据时,会分成一个一个独立的数据单元,也就是消息体,每个消息体都是固定大小的存储块,在字节流上不连续。
有了消息这种模型,两个进程之间的通信就像咱们平时发邮件一样,你来一封,我回一封,可以频繁沟通了。
<img src="https://static001.geekbang.org/resource/image/df/38/df910e4383885b1aceaafb52b9bb5638.png" alt="">
但是有时候,项目组之间的沟通需要特别紧密,而且要分享一些比较大的数据。如果使用邮件,就发现,一方面邮件的来去不及时;另外一方面,附件大小也有限制,所以,这个时候,我们经常采取的方式就是,把两个项目组在需要合作的期间,拉到一个会议室进行合作开发,这样大家可以直接交流文档呀,架构图呀,直接在白板上画或者直接扔给对方,就可以直接看到。
可以看出来共享会议室这种模型类似进程间通信的共享内存模型。前面咱们讲内存管理的时候知道每个进程都有自己独立的虚拟内存空间不同的进程的虚拟内存空间映射到不同的物理内存中去。这个进程访问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号作战任务开始执行警察就开始行动了。情况紧急不能啰里啰嗦了。”
信号可以在任何时候发送给某一进程,进程需要为这个信号配置信号处理函数。当某个信号发生的时候,就默认执行这个函数就可以了。这就相当于咱们运维一个系统应急手册,当遇到什么情况,做什么事情,都事先准备好,出了事情照着做就可以了。
<img src="https://static001.geekbang.org/resource/image/7c/28/7cb86c73b9e73893e6b0e0433d476928.png" alt="">
这些项目组合作的流程设计合理,因而推行起来十分顺畅,现在接个千万级别的项目没有任何问题,根据交易量估值市值,起码有十个亿。
马哥有些小激动,原来自己身价这么高了,是不是也能上个市啥的,实现亿万富翁的梦想呢?于是马哥找了一些投资人聊了聊,投资人说,要想冲一把上市,还差点劲,目前的项目虽然大,但是想象力不够丰富。
那接下来,马哥如何做才能满足市场的想象力,最终成功上市呢?预知后事,且听下回分解。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,137 @@
<audio id="audio" title="66 | 知识串讲:用一个创业故事串起操作系统原理(五)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7a/8a/7a36609450b6274c4f774e071be3e18a.mp3"></audio>
上一节我们说到,马哥的公司现在接个千万级别的项目没有任何问题,但是投资人说,要想冲一把上市,还差点劲,目前的项目虽然大,但是想象力不够丰富。
## 亿级项目创品牌,战略合作遵协议
马哥突然想到,西部有一个智慧城市的订单,金额几个亿,绝对标杆性质的。如果能够参与其中,应该是很有想象力的事情。
可是,甲方明确地说,“整个智慧城市的建设体系非常的大,一家公司做不下来,需要多家公司合作才能完成。你们有多家公司合作的经验和机制吗?”
马哥咬牙说道:“当然有!”先应下来再说呗,可是这心里是真没底。原来公司都是独自接单,现在要和其他公司合作,协议怎么签,价格怎么谈呢?
马哥找到鲁肃。鲁肃说:“我给你推荐一个人吧!这个人人脉广,项目运作能力强,叫陆逊,说不定能帮上忙。”
鲁肃找来陆逊。陆逊说:“这个好办。公司间合作嘛,就是条款谈好,利益分好就行,关键是大家要遵守行规。大家都按统一的规则来,事情就好办。”
这其实就像机器与机器之间合作,一台机器将自己想要表达的内容,按照某种约定好的格式发送出去。当另外一台机器收到这些信息后,也能够按照约定好的格式解析出来,从而准确、可靠地获得发送方想要表达的内容。这种约定好的格式就是网络协议。
现在业内知名的有两种网络协议模型一种是OSI的标准七层模型一种是业界标准的TCP/IP模型。它们的对应关系如下图所示
<img src="https://static001.geekbang.org/resource/image/92/0e/92f8e85f7b9a9f764c71081b56286e0e.png" alt="">
我们先从第三层网络层开始因为这一层有我们熟悉的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层在负重前行。
传输层再往上就是应用层例如咱们在浏览器里面输入的HTTPJava服务端写的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头中的端口号将网络包发给相应的应用。
<img src="https://static001.geekbang.org/resource/image/d3/5c/d34e667d1c3340deb8c82a2d44f2a65c.png" alt="">
这样一个大项目中,各个公司都按协议来,别说两家公司合作,二十家也没有问题。
于是陆逊带着马哥,到甲方那里,将自己的方案,以及和其他公司的合作模式讲述清楚。马哥成功入围。
这次参与竞标的公司可不少,马哥公司的竞争力和专业性一点都不差,最后终于拿下了智慧生态合作平台的建设部分。这下不得了,一提马哥的公司,业内无人不知,无人不晓,大家纷纷称呼他为“马总”。
## 公司大了不灵活,鼓励创新有妙招
慢慢地,马总发现,公司大有大的好处,自然也有大的毛病,也就是咱们常见的“大公司病”——不灵活。
这里面的不灵活就像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里面设置。当确认开始了标志位之后通过内核模块KVMGuestOS的CPU指令将不用经过Qemu转译直接运行大大提高了速度。qemu和KVM融合以后就是qemu-kvm。
<img src="https://static001.geekbang.org/resource/image/f7/22/f748fd6b6b84fa90a1044a92443c3522.png" alt="">
第三种方式称为半虚拟化。对于网络或者硬盘的访问,我们让虚拟机内核加载特殊的驱动,重新定位自己的身份。虚拟机操作系统的内核知道自己不是物理机内核,没那么高的权限。他很可能要和很多虚拟机共享物理资源,所以学会了排队。虚拟机写硬盘其实写的是一个物理机上的文件,那我的写文件的缓存方式是不是可以变一下。我发送网络包,根本就不是发给真正的网络设备,而是给虚拟的设备,我可不可以直接在内存里面拷贝给它,等等等等。
网络半虚拟化方式是virtio_net存储是virtio_blk。客户机需要安装这些半虚拟化驱动。客户机内核知道自己是虚拟机所以会直接把数据发送给半虚拟化设备然后经过特殊处理例如排队、缓存、批量处理等性能优化方式最终发送给真正的硬件。这在一定程度上提高了性能。
有了虚拟化的技术,公司的状态改观了不少,在主要的经营方向之外,公司还推出了很多新的创新方向,都是通过虚拟机创建子公司的方式进行的,例如跨境电商、工业互联网、社交等。一方面,能够享受大公司的支持;一方面,也可以和灵活的创业公司进行竞争。
于是,公司就变成集团公司了。
## 独占鳌头定格局,上市敲钟责任重
随着公司越来越大,钱赚的越来越多,马总的公司慢慢从行业的追随者,变成了领导者。这一方面,让马总觉得“会当凌绝顶,一览众山小”;另一方面,马总也觉得“高处不胜寒”。原来公司总是追着别人跑,产业格局,市场格局从来不用自己操心,只要自己的公司能赚钱就行。现在做了领头羊,马总也就慢慢成了各种政府论坛、产业论坛,甚至国际论坛的座上宾。
穷则独善其身达则兼济天下。马总的决策可能关系到产业的发展、地方的GDP和就业甚至未来的国际竞争力。因此即便是和原来相同的事情现在来做方式和层次都不一样了。
就像对于单台Linux服务器最重要的四种硬件资源是CPU、内存、存储和网络。面对整个数据中心成千上万台机器我们只要重点关注这四种硬件资源就可以了。如果运维数据中心依然像的运维一台台物理机的前辈一样天天关心哪个程序放在了哪台机器上使用多少内存、多少硬盘每台机器总共有多少内存、多少硬盘还剩多少内存和硬盘那头就大了。
对于数据中心我们需要一个调度器将运维人员从指定物理机或者虚拟机的痛苦中解放出来实现对于物理资源的统一管理这就是Kubernetes也就是数据中心的操作系统。
<img src="https://static001.geekbang.org/resource/image/1a/e5/1a8450f1fcda83b75c9ba301ebf9fbe5.jpg" alt="">
对于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就可以管理容器的网络。
到此,有了这套鼎定市场格局的策略,作为龙头企业,马总的公司终于可以顺利上市敲钟,走向人生巅峰。从此,江湖人称“马爸爸”。
好了,马同学的创业故事就讲到这里了,操作系统的原理也给你串了一遍。你是否真的记住了这些原理呢?试着将这个创业故事讲给你的朋友听吧!
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,112 @@
<audio id="audio" title="期末测试 | 这些操作系统问题,你真的掌握了吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/5d/96ff6d500b5fa98ae05a9ad84300295d.mp3"></audio>
你好,我是刘超。当你看到这篇文章的时候,说明你已经历经九九八十一难,完成了整个课程的学习,你是否已经悟到操作系统的“真谛”了呢?我们今天就来测试一下。
先和你说一下,期末测试题的设计,我采取了和入学测验不一样的思路。当时入学测验里的题目比较细节,类似你在大学里学完操作系统课之后的考试题目。现在整个专栏学完了,我们需要换一种检测方式。所以,期末测试题,我设计成了选择题+开放式问题的形式。这才是我们现实生活中,不管是面试还是工作中,常常会遇到的“考试”方式。
有人说,面试的时候问操作系统知识,就是“面试造航母,上班拧螺丝”,没有啥用。其实不然,这些看似简单开放的问题,其实最能检验真本事的。面试官毕竟阅人无数,带人无数,看似他只是在问你这个问题的答案,其实这些问题背后都有实际的工作场景——只是你可能不了解或者想象不到而已。
因此我把专栏内容中最核心的知识点总结成20道选择题并且把每个部分面试可能会考到的问题也总结成11道开放式面试题。你可不要小看这些面试题也不要以为面试官真的只是单纯地在问你这些问题的答案。因为这些面试题都产生于真实的工作场景中。
在这个场景中,秃头哥因为工作经验丰富,发际线已经非常可怜。长发哥是新进入公司的员工。你可以看看,他们在工作中是怎么互动的,感受一下长发哥回答不出问题的“尴尬”。
同时,你可以想一想,如果换作是你,在面试或者工作中遇到这些问题,你会怎么回答呢?你可以把思考之后的答案写在留言里。当然,如果你的朋友也在为面试或者操作系统知识烦忧,你可以把这篇文章分享给他。
这里呢我还给你准备了几个回答问题的小Tips。你在答题的时候可以参照下面这三个Tips组织你的答案。
**第一,回答尽量体系化。**不仅仅回答这个问题的知识点,还可以简单描述一下这个点背后完整的体系,然后根据面试官接下来的追问,更详细地描述其他相关的内容。
这样做的好处是:一,防止你因为这个点没有回答好而丢掉所有的分;二,可以体现你的知识掌握比较全面,可以加分;三,还可以避免冷场和尬聊。
面试官开放式地提问,最不想听到的就是“封闭式”回答。有的面试者“惜字如金”,有的人能聊一个小时,有的人不到半个小时就答完了,让面试官没有办法深入全面地了解你。
**第二,在体系化的阐述过程中,可以加入一些你使用过的相关工具或者技巧的阐述。**例如操作系统干这个事情有十个步骤。第三步会在某个日志文件里面打印这样的日志如果发现打印出xxx说明可能有错误第七步可以通过某个命令行查看某某状态等等。诸如此类可以让面试官感觉到你是实战派而非理论派。
**第三,在体系化的阐述过程中,如果能加入一些项目经验就更好了。**例如,在第九个步骤,当时在做某项目的时候,因为客户现场的版本问题,导致了什么结果,最后如何进行解决的等等。
我刚才说了,面试官问问题的时候,脑子里都是有实际工作场景的。如果你能把他带到你的工作场景里面,有利于面试官对你当时的情形感同身受,你的分数自然不会很低。
看我说了这么多,你是不是已经迫不及待的想要答题啦?别着急,我们来说说这两套题的答题顺序,我建议你先做选择题,再做面试题,因为这更像我们实际的面试过程,先笔试、再面试。
## 选择题
好了,现在开始答题吧!
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=152&amp;exam_id=337)
## 面试题
### 1.你能说一下操作系统的启动和初始化过程吗?
秃头哥:给虚拟机做一个镜像,让系统起来以后做一些初始化。<br>
长发哥:才初始化,怎么连进去呢?<br>
秃头哥:……
### 2.请问进程和线程的概念和区别是什么?
秃头哥把Dubbo线程池调大一点<br>
长发哥我调到10000了。线程越多是不是性能越高啊<br>
秃头哥:……
### 3.请问函数调用堆栈的原理是什么?
秃头哥:这个接口调用性能比较差,排查一下哪里慢!<br>
长发哥:好的,一定认真排查。<br>
秃头哥:怎么样了?有思路了吗?<br>
长发哥:正在一行行看呢!<br>
秃头哥:……
### 4.对内存管理了解吗?请说一下物理内存和虚拟内存的概念。
秃头哥你看这个进程GC这么严重看看问题在哪儿<br>
长发哥:这么多变量,看哪个?<br>
秃头哥:……
### 5.请介绍一下虚拟文件系统的机制。
长发哥:这个程序运行时间一长就挂了,咋回事呀?<br>
秃头哥:这不有异常吗?超过最大打开文件数了。<br>
长发哥:打开文件还有限制?<br>
秃头哥:……
### 6.你了解文件写入的流程吗?
秃头哥咱们的消息队列模型要慢慢换成Kafka<br>
长发哥我大概瞄了一下Kafka的消息存储是基于硬盘的。这么慢的方式怎么能用<br>
秃头哥:……
### 7.进程间通信的管道机制了解吗?
秃头哥:帮我把这些监控数据里面的第三列拿出来,取一个平均数!<br>
长发哥好的等我打开Excel。<br>
秃头哥:……
### 8.请讲一下信号和中断机制。
秃头哥:为了让这个进程能够优雅的关闭,要给他发一个信号!<br>
长发哥:怎么发信号?<br>
秃头哥kill呀<br>
长发哥:那不就强制干掉了吗?没法儿优雅呀?<br>
秃头哥:……
### 9.请讲一下TCP/IP的分层模型。
长发哥客户要求数据一个不能丢是不是要通过可靠的协议TCP才行<br>
秃头哥当然应该TCP呀<br>
长发哥可是客户还要求数据一个不能多TCP重传可能会导致数据重复啊<br>
秃头哥:……
### 10.请讲一下三次握手和四次挥手以及状态转换。
长发哥:进程明明启动了,为啥连不上呀?<br>
秃头哥:看一下有没有监听端口,处于什么状态!<br>
长发哥:我看配置文件了,端口配置得没错呀。<br>
秃头哥:你看监听的端口和配置的不一样,是不是配置文件有冲突了?<br>
长发哥你真牛你怎么知道另一个jar里面还有一个配置文件<br>
秃头哥:……
### 11.最新的云或者容器的技术了解过吗?
秃头哥客户有一台老的centOS 6需要写一个脚本能在上面安装软件<br>
长发哥:去哪里找这么老的系统啊,帮我申请一台物理机,我找找老镜像装一个。<br>
秃头哥:……

View File

@@ -0,0 +1,85 @@
<audio id="audio" title="结束语 | 永远别轻视任何技术,也永远别轻视自己" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6c/7e/6c567138ea49bf4dafc62386c1a16b7e.mp3"></audio>
你好我是刘超。又一次时隔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个图啃下来这些就能掌握操作系统也不算难吧
你看,我都逼了自己一把,激发出来了一点潜力,写完了这个巨硬核的专栏,你要不要也逼自己一把呢?加油啊,愿我们能一起每日精进!
最后,我在这里放了一个[毕业调查问卷](https://jinshuju.net/f/t3epe1)。如果你对这个专栏或者我本人有什么建议,可以通过这个问卷进行反馈,我一定会认真查看每一封的内容。期待你的反馈!

View File

@@ -0,0 +1,137 @@
<audio id="audio" title="03 | 你可以把Linux内核当成一家软件外包公司的老板" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/50/9a/50def8552f29579af4fe4dd67af1a39a.mp3"></audio>
在平时的生活中,我们几乎时时刻刻都在使用操作系统,只是大部分时间你都意识不到它的存在。比如你买了一部手机或者一台平板电脑,立马就能上手使用,这是因为它们里面都预先安装了操作系统。
所以啊,哪有什么岁月静好,只不过有人替你负重前行。而操作系统就扮演了这样一个负重前行的角色。那操作系统到底在背后默默地做了哪些事情,才能让我们轻松地使用这些电子设备呢?要想回答这个问题,我们需要把眼光放回到自己攒电脑的那个时代。
## 电脑组装好就能直接用吗?
那时候买电脑,经常是这样一个情景:三五个哥们儿一起来到电脑城,呼啦呼啦采购了一大堆硬件,有密密麻麻都是针脚的**CPU**;有铺满各种复杂电路的一块板子,也就是**主板**;还需要买块**显卡**,用来连接显示器;还需要买个**网卡**,里面可以插网线;还要买块**硬盘**,将来用来存放文件;然后还需要一大堆**线**,将这些设备和主板连接起来;最终再来一个**鼠标**,一个**键盘**,还有一个**显示器**。设备差不多啦,准备开整!
<img src="https://static001.geekbang.org/resource/image/ed/45/ed03667738a92d66626914fe5dc78d45.png" alt="">
好不容易组装完这一大堆硬件,还是不能直接用,你还需要安装一个操作系统。安装操作系统也是一件非常复杂的事,一点儿也不亚于把刚才那堆东西组装起来。这个安装过程可能会涉及十几个步骤、几十项配置。每一步骤配置完了,点击下一步,会出现个进度条。伴随着一堆难以理解的描述,最终安装步骤到达百分之百,才出现你熟悉的那个界面。
我这么说起来好像很容易但是要把这事儿讲清楚估计得用一个专栏。这个复杂程度咱们父母估计是上不了手了。所以那个时候能把这套东西都组装起来是一件很拉风的事情。很多IT男甚至因为这项绝技“泡”到了妹子。
当操作系统安装完毕的时候我妈通常会要求我一定要装一个QQ。看到妈妈在你装好的操作系统前愉快地和她的朋友聊天这时候经历过以上过程的你多少应该能感受到操作系统的厉害了。
**操作系统究竟是如何把这么多套复杂的东西管理起来,从而弄出来一个简单到父母都会用的东西呢?**
很多事情就怕细想。不知道你有没有产生过这些疑问:
<li>
桌面上的图标到底是啥?凭啥我在鼠标上一双击,就会出来一个美丽的画面?这都是从哪里跑出来的?
</li>
<li>
凭什么我在键盘上噼里啪啦地敲,某个位置就会显示我想要的那些字符?
</li>
<li>
电脑怎么知道我鼠标点击的是这个地方,又是怎么知道我要输入的是这个地方?
</li>
<li>
我在键盘上点“a”是谁在显示器上画出“a”这个图像呢
</li>
<li>
为什么我一回车,这些字符就发到遥远的另外一台机器上去了?
</li>
对于普通用户来讲,其实只要会用就行了,但是咱们作为专业人士,要深入探究一下背后的答案。你别小看“双击鼠标打开聊天软件”这样一个简单的操作,它几乎涵盖了操作系统的所有功能。我们就从这个熟悉的操作,来认识陌生的操作系统。
操作系统其实就像一个软件外包公司,其内核就相当于这家外包公司的老板。所以接下来的整个课程中,请你将自己的角色切换成这家软件外包公司的老板,设身处地地去理解操作系统是如何协调各种资源,帮客户做成事情的。
想要学好咱们这门课,你要牢牢记住这段话,把这个概念牢牢扎根在心里,我之后的讲解都会基于此,帮你理解、记忆那些难搞的概念和原理。
同时,为了防止你混淆,我这里先强调一下。今后我所说的“用户”,都是指操作系统的用户,“客户”则是指外包公司的客户,这两者是对应的。
## “双击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”。显卡画完了客户看到了就觉得自己的输入成功了。
当用户输入完毕之后回车一下还是会通过键盘驱动程序告诉操作系统操作系统还是会找到QQQQ会将用户的输入发送到网络上。QQ进程是不能直接发送网络包的需要调用系统调用内核使用网卡驱动程序进行发送。
这就像客户对接员接到一个需求,但是这个需求需要和其他公司沟通,这就需要依靠公司的对外合作部,对外合作部在办事大厅有专门的窗口,非常方便。
<img src="https://static001.geekbang.org/resource/image/e1/4a/e15954f1371a4c782f028202dce1f84a.jpeg" alt="">
## 总结时刻
到这里,一个外包公司大部分的职能部门都凑齐了。你可以对应着下图的操作系统内核体系结构,回顾一下它们是如何组成一家公司的。
QQ的运行过程只是一个简单的比喻。在后面的章节中我会展开讲述每个部分是怎么工作的最后我会再将这个过程串起来这样你就能了解操作系统的全貌了。
<img src="https://static001.geekbang.org/resource/image/21/f5/21a9afd64b05cf1ffc87b74515d1d4f5.jpeg" alt="">
## 课堂练习
学习Linux看代码是必须的。你可以找到最新版本的Linux代码在里面找找这几个子系统的代码都在哪里。
欢迎留言和我分享你的思考和疑问,也欢迎你把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,292 @@
<audio id="audio" title="04 | 快速上手几个Linux命令每家公司都有自己的黑话" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/de/18/de5b946452e8ab098fe6292e90372018.mp3"></audio>
如果你还没有上手用过Linux那么接下来的课程你可能会感受到困惑。因为没有一手的体验你可能很难将Linux的机制和你的使用行为关联起来。所以这一节咱们先介绍几个上手Linux的命令通过这些命令我们试试先把Linux用起来。
为什么我把Linux命令称为“黑话”呢就像上一节我们介绍的Linux操作系统有很多功能我们有很多种方式可以使用这些功能其中最简单和直接的方式就是**命令行**Command Line。命令行就相当于你请求服务使用的专业术语。干任何事情第一步就是学会使用正确的术语。这样Linux作为服务方才能听懂。这些术语可不就是“黑话”吗
Window系统你肯定很熟悉吧现在我就沿着你使用Windows的习惯来给你介绍相应的Linux命令。
## 用户与密码
当我们打开一个新系统的时候第一件要做的事就是登录。系统默认有一个Administrator用户也就是系统管理员它的权限很大可以在这个系统上干任何事。Linux上面也有一个类似的用户我们叫Root。同样它也具有最高的操作权限。
接下来你需要输入密码了。密码从哪里来呢对于Windows来讲在你安装操作系统的过程中会让你设置一下Administrator的密码对于LinuxRoot的密码同样也是在安装过程中设置的。
<img src="https://static001.geekbang.org/resource/image/ee/76/ee95d03b1390ae08ca9c752621b03476.png" alt="">
对于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获得细细阅读。
<img src="https://static001.geekbang.org/resource/image/17/2d/179b8fdca3d8d57e8f1d32f3aab60a2d.png" alt="">
上一节我们说过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下面。要注意同一台电脑不同的用户情况会不一样。
<img src="https://static001.geekbang.org/resource/image/d2/a7/d21ce3cd2ade7b71300df6a805b45aa7.png" alt="">
/bin/bash的位置是用于配置登录后的默认交互命令行的不像Windows登录进去是界面其实就是explorer.exe。而Linux登录后的交互命令行是一个解析脚本的程序这里配置的是/bin/bash。
## 浏览文件
终于登录进来啦,接下来你可以在文件系统里面随便逛一逛、看一看了。
可以看到Linux的文件系统和Windows是一样的都是用文件夹把文件组织起来形成一个树形的结构。这一点没有什么差别。只不过在Linux下面大多数情况我们需要通过命令行来查看Linux的文件。
其实在Windows下也有命令行例如cd就是change directory就是切换目录cd .表示切换到当前目录cd ..表示切换到上一级目录使用dir可以列出当前目录下的文件。Linux基本也是这样只不过列出当前目录下的文件我们用的是ls意思是list。
<img src="https://static001.geekbang.org/resource/image/27/2e/27cc0efe8d33b730eba8aee7d51cda2e.png" alt="">
我们常用的是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-rr--就可以翻译为这是一个普通文件对于所属用户可读可写不能执行对于所属的组仅仅可读对于其他用户也是仅仅可读。如果想改变权限可以使用命令chmod 711 hosts。
第二个字段是**硬链接**hard link**数目**,这个比较复杂,讲文件的时候我会详细说。
第三个字段是**所属用户**,第四个字段是**所属组**。第五个字段是文件的大小,第六个字段是**文件被修改的日期**,最后是**文件名**。你可以通过命令chown改变所属用户chgrp改变所属组。
## 安装软件
好了,你现在应该会浏览文件夹了,接下来应该做什么呢?当然是开始安装那些“装机必备”的软件啦!
在Windows下面在没有类似软件管家的软件之前我们其实都是在网上下载installer然后再进行安装的。
就以我们经常要安装的JDK为例子。应该去哪里下载呢为了安全起见一般去官网比较好。如果你去JDK的官网它会给你一个这样的列表。
<img src="https://static001.geekbang.org/resource/image/5e/02/5e54fe2dba0e86e14a7a92d9ea46c202.jpg" alt="">
对于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下面控制面板里面有程序管理我们可以查看目前安装了哪些软件可以删除这些软件。
<img src="https://static001.geekbang.org/resource/image/4c/9b/4c0cddd6f5ea77bc4aeabc135e6e8a9b.png" alt="">
在Linux下面凭借rpm -qa和dpkg -l就可以查看安装的软件列表-q就是querya就是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上有了软件管家就方便多了。我们直接搜索一下然后点击安装就行了。
<img src="https://static001.geekbang.org/resource/image/4c/9b/4c0cddd6f5ea77bc4aeabc135e6e8a9b.png" alt="">
Linux也有自己的软件管家CentOS下面是yumUbuntu下面是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。
<img src="https://static001.geekbang.org/resource/image/ab/be/ab4e83ac1300658649989a2e016ac0be.png" alt="">
在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不挂起也就是说当前交互命令行退出的时候程序还要在。
当然这个时候,程序不能霸占交互命令行,而是应该在后台运行。最后加一个&amp;,就表示后台运行。
另外一个要处理的就是输出,原来什么都打印在交互命令行里,现在在后台运行了,输出到哪里呢?输出到文件是最好的。
最终命令的一般形式为nohup command &gt;out.file 2&gt;&amp;1 &amp;。这里面“1”表示文件描述符1表示标准输出“2”表示文件描述符2意思是标准错误输出“2&gt;&amp;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里面还有一种程序称为服务。这是系统启动的时候就在的我们可以通过控制面板的服务管理启动和关闭它。
<img src="https://static001.geekbang.org/resource/image/f2/a6/f24f0f11bcb9a177861a4782ba1d82a6.png" alt="">
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了。如果你是个初学者这些命令估计看起来还是很多。我把今天这些基本的命令以及对应的操作总结了一下方便你操作和查阅。
你不用可以去死记硬背,按照我讲的这个步骤,从设置用户和密码、浏览文件、安装软件,最后到运行程序,**自己去操作几遍,再自己整理一遍**,手脑并用,加深理解,巩固记忆,效果可能会更好。
<img src="https://static001.geekbang.org/resource/image/88/e5/8855bb645d8ecc35c80aa89cde5d16e5.jpg" alt="">
## 课堂练习
现在你应该已经学会了安装JDK和MySQL你可以尝试搭建一个基于Java+MySQL的服务端应用上手使用一下。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。

View File

@@ -0,0 +1,216 @@
<audio id="audio" title="05 | 学会几个系统调用:咱们公司能接哪些类型的项目?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e7/b1/e7ffca8ca5b09224969b3237723a0bb1.mp3"></audio>
上一节我们讲了几个重要的Linux命令行只有通过这些命令用户才能把Linux系统用起来不知道你掌握得如何了其实Linux命令也是一个程序只不过代码是别人写好的你直接用就可以了。你可以自己试着写写代码通过代码把Linux系统用起来这样印象会更深刻。
不过,无论是别人写的程序,还是你写的程序,运行起来都是进程。如果你是一家外包公司,一个项目的运行要使用公司的服务,那就应该去办事大厅,也就是说,你写的程序应该使用系统调用。
你看,系统调用决定了这个操作系统好用不好用、功能全不全。对应到咱们这个公司中,作为一个老板,你应该好好规划一下,你的办事大厅能够提供哪些服务,这决定了你这个公司会被打五星还是打差评。
## 立项服务与进程管理
首先我们得有个项目那就要有立项服务。对应到Linux操作系统中就是**创建进程**。
创建进程的系统调用叫fork。这个名字很奇怪中文叫“分支”。为啥启动一个新进程叫“分支”呢
在Linux里要创建一个新的进程需要一个老的进程调用fork来实现其中老的进程叫作**父进程**Parent Process新的进程叫作**子进程**Child Process
前面我们说过,一个进程的运行是要有一个程序的,就像一个项目的执行,要有一个项目执行计划书。本来老的项目,按照项目计划书按部就班地来,项目执行到一半,突然接到命令,说是要新启动一个项目,这个时候应该怎么办呢?
一个项目的执行是很复杂的需要涉及公司各个部门的工作比如说项目管理部门需要给这个项目组开好Jira和Wiki会议室管理部要为这个项目分配会议室等等。
所以我们现在有两种方式一种是列一个清单清单里面写明每个新项目组都要开哪些账号。但是这样每次有项目都要重新配置一遍新的Jira、Wiki复杂得很。另一种方式就是咱们程序员常用的方式CTRL/C + CTRL/V。也就是说如果想为新项目建立一套Jira但又觉得一个个填Jira里面的选项太麻烦那就可以拷贝一个别人的然后根据新项目的实际情况将相应的配置改改。
<img src="https://static001.geekbang.org/resource/image/f4/78/f433f5d14e79612032ea625b44ac6178.jpeg" alt="">
Linux就是这样想的。当父进程调用fork创建进程的时候子进程将各个子系统为父进程创建的数据结构也全部拷贝了一份甚至连程序代码也是拷贝过来的。按理说如果不进行特殊的处理父进程和子进程都按相同的程序代码进行下去这样就没有意义了。
所以我们往往会这样处理对于fork系统调用的返回值如果当前进程是子进程就返回0如果当前进程是父进程就返回子进程的进程号。这样首先在返回值这里就有了一个区分然后通过if-else语句判断如果是父进程还接着做原来应该做的事情如果是子进程需要请求另一个系统调用execve来执行另一个程序这个时候子进程和父进程就彻底分道扬镳了也就产生了一个分支fork了。
<img src="https://static001.geekbang.org/resource/image/e8/7f/e8ee83d78538bd43d3835662ded92e7f.jpeg" alt="">
同样是“先拷贝再修改”的策略你可能会问新进程都是父进程fork出来的那到底谁是第一个呢
作为一个外包公司老板,有了新项目当然会分给手下做,但是当公司刚起步的时候呢?没有下属,只好自己上了。先建立**项目运行体系**,等后面再做项目的时候,就都按这个来。
对于操作系统也一样,启动的时候先创建一个所有用户进程的“祖宗进程”。这个在讲系统启动的时候还会详细讲,我这里先不多说。
有时候父进程要关心子进程的运行情况这毕竟是自己身上掉下来的肉。有个系统调用waitpid父进程可以调用它将子进程的进程号作为参数传给它这样父进程就知道子进程运行完了没有成功与否。
所以说所有子项目最终都是老板也就是祖宗进程fork过来的因而它要对整个公司的项目执行负最终的责任。
## 会议室管理与内存管理
项目启动之后,每个项目组有独立的会议室,存放自己项目相关的数据。每个项目组都感觉自己有独立的办公空间。
在操作系统中,每个进程都有自己的内存,互相之间不干扰,有独立的**进程内存空间**。
那独立的办公空间里面,都放些什么呢?
项目执行计划书肯定是要放进去的,因为执行过程中肯定要不断地看。对于进程的内存空间来讲,放程序代码的这部分,我们称为**代码段**Code Segment
项目执行的过程中,会产生一些架构图、流程图,这些也放在会议室里面。有的画在白板上,讨论完了,进入下个主题就会擦了;有的画在纸和本子上,讨论的时候翻出来,不讨论的时候堆在那里,会保留比较长的一段时间,除非指明的确不需要了才会去销毁。
对于进程的内存空间来讲,放进程运行中产生数据的这部分,我们称为**数据段**Data Segment。其中局部变量的部分在当前函数执行的时候起作用当进入另一个函数时这个变量就释放了也有动态分配的会较长时间保存指明才销毁的这部分称为**堆**Heap
一个进程的内存空间是很大的32位的是4G64位的就更大了我们不可能有这么多物理内存。就像一个公司的会议室是有限的作为老板你不可能事先都给项目组分配好。哪有这么多会议室啊一定是需要的时候再分配。
所以,进程自己不用的部分就不用管,只有进程要去使用部分内存的时候,才会使用内存管理的系统调用来登记,说自己马上就要用了,希望分配一部分内存给它,但是这还不代表真的就对应到了物理内存。只有真的写入数据的时候,发现没有对应物理内存,才会触发一个中断,现分配物理内存。
<img src="https://static001.geekbang.org/resource/image/e9/0b/e9bcfb17a7ac8c21bcc6b0828641850b.jpeg" alt="">
这里我们介绍两个在堆里面分配内存的系统调用brk和mmap。
当分配的内存数量比较小的时候使用brk会和原来的堆的数据连在一起这就像多分配两三个工位在原来的区域旁边搬两把椅子就行了。当分配的内存数量比较大的时候使用mmap会重新划分一块区域也就是说当办公空间需要太多的时候索性来个一整块。
## 档案库管理与文件管理
项目执行计划书要保存在档案库里,有一些需要长时间保存,这样哪怕公司暂时停业,再次经营的时候还可以继续使用。同样,程序、文档、照片等,哪怕关机再开机也能不丢的,就需要放在文件系统里面。
文件之所以能做到这一点,一方面是因为**介质**,另一方面是因为**格式**。公司之所以强调资料库,也是希望将一些知识固化为标准格式,放在一起进行管理,无论多少人来人走,都不影响公司业务。
文件管理其实花样不多,拍着脑袋都能想出来,无非是创建、打开、读、写等。
对于文件的操作,下面这六个系统调用是最重要的:
<li>
对于已经有的文件可以使用open打开这个文件close关闭这个文件
</li>
<li>
对于没有的文件可以使用creat创建文件
</li>
<li>
打开文件以后可以使用lseek跳到文件的某个位置
</li>
<li>
可以对文件的内容进行读写读的系统调用是read写是write。
</li>
但是别忘了Linux里有一个特点那就是**一切皆文件**。
<li>
启动一个进程,需要一个程序文件,这是一个**二进制文件**。
</li>
<li>
启动的时候要加载一些配置文件例如yml、properties等这是文本文件启动之后会打印一些日志如果写到硬盘上也是**文本文件**。
</li>
<li>
但是如果我想把日志打印到交互控制台上,在命令行上唰唰地打印出来,这其实也是一个文件,是标准输出**stdout文件**。
</li>
<li>
这个进程的输出可以作为另一个进程的输入,这种方式称为**管道**,管道也是一个文件。
</li>
<li>
进程可以通过网络和其他进程进行通信,建立的**Socket**,也是一个文件。
</li>
<li>
进程需要访问外部设备,**设备**也是一个文件。
</li>
<li>
文件都被存储在文件夹里面,其实**文件夹**也是一个文件。
</li>
<li>
进程运行起来,要想看到进程运行的情况,会在/proc下面有对应的**进程号**,还是一系列文件。
</li>
<img src="https://static001.geekbang.org/resource/image/e4/df/e49b5c2a78ac09903d697126bfe6c5df.jpeg" alt="">
每个文件Linux都会分配一个**文件描述符**File Descriptor这是一个整数。有了这个文件描述符我们就可以使用系统调用查看或者干预进程运行的方方面面。
所以说,文件操作是贯穿始终的,这也是“一切皆文件”的优势,就是统一了操作的入口,提供了极大的便利。
## 项目异常处理与信号处理
在项目运行过程中,不一定都是一帆风顺的,很可能遇到各种异常情况。作为老板,处理异常情况的能力是非常重要的,所以办事大厅也一定要包含这部分服务。
当项目遇到异常情况,例如项目中断,做到一半不做了。这时候就需要发送一个**信号**Signal给项目组。经常遇到的信号有以下几种
<li>
在执行一个程序的时候在键盘输入“CTRL+C”这就是中断的信号正在执行的命令就会中止退出
</li>
<li>
如果非法访问内存,例如你跑到别人的会议室,可能会看到不该看的东西;
</li>
<li>
硬件故障,设备出了问题,当然要通知项目组;
</li>
<li>
用户进程通过kill函数将一个用户信号发送给另一个进程。
</li>
当项目组收到信号的时候,项目组需要决定如何处理这些异常情况。
对于一些不严重的信号可以忽略该干啥干啥但是像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](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的系统调用。
## 总结时刻
学了这么多系统调用,我们还是用一个图来总结一下。
<img src="https://static001.geekbang.org/resource/image/ff/f0/ffb6847b94cb0fd086095ac263ac4ff0.jpg" alt="">
## 课堂练习
有个命令strace常用来跟踪进程执行时系统调用和所接收的信号。你可以试一下咱们学过的命令行看看都执行了哪些系统调用。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,429 @@
<audio id="audio" title="36 | 进程间通信:遇到大项目需要项目组之间的合作才行" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/86/cd5106444237bc58781dbadc29236586.mp3"></audio>
前面咱们接项目的时候,主要强调项目之间的隔离性。这是因为,我们刚开始接的都是小项目。随着我们接的项目越来越多,就难免遇到大项目,这就需要多个项目组进行合作才能完成。
两个项目组应该通过什么样的方式进行沟通与合作呢?作为老板,你应该如何设计整个流程呢?
## 管道模型
好在有这么多成熟的项目管理流程可以参考。最最传统的模型就是软件开发的**瀑布模型**Waterfall Model。所谓的瀑布模型其实就是将整个软件开发过程分成多个阶段往往是上一个阶段完全做完才将输出结果交给下一个阶段。就像下面这张图展示的一样。
<img src="https://static001.geekbang.org/resource/image/ed/c9/ed1fd2ede7a8fef5508c877e722345c9.png" alt="">
这种模型类似进程间通信的**管道模型**。还记得咱们最初学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 &quot;hello world&quot; &gt; hello
```
这个时候,管道里面的内容没有被读出,这个命令就是停在这里的,这说明当一个项目组要把它的输出交接给另一个项目组做输入,当没有交接完毕的时候,前一个项目组是不能撒手不管的。
这个时候,我们就需要重新连接一个终端。在终端中,用下面的命令读取管道里面的内容:
```
# cat &lt; hello
hello world
```
一方面我们能够看到管道里面的内容被读取出来打印到了终端上另一方面echo那个命令正常退出了也即交接完毕前一个项目组就完成了使命可以解散了。
我们可以看出,瀑布模型的开发流程效率比较低下,因为团队之间无法频繁地沟通。而且,管道的使用模式,也不适合进程间频繁地交换数据。
于是,我们还得想其他的办法,例如我们是不是可以借鉴传统外企的沟通方式——邮件。邮件有一定的格式,例如抬头,正文,附件等,发送邮件可以建立收件人列表,所有在这个列表中的人,都可以反复地在此邮件基础上回复,达到频繁沟通的目的。
## 消息队列模型
<img src="https://static001.geekbang.org/resource/image/ac/a4/ac6ad6c9e7e3831f6d813113ae1c5ba4.png" alt="">
这种模型类似进程间通信的消息队列模型。和管道将信息一股脑儿地从一个进程,倒给另一个进程不同,消息队列有点儿像邮件,发送数据时,会分成一个一个独立的数据单元,也就是消息体,每个消息体都是固定大小的存储块,在字节流上不连续。
这个消息结构的定义我写在下面了。这里面的类型type和正文text没有强制规定只要消息的发送方和接收方约定好即可。
```
struct msg_buffer {
long mtype;
char mtext[1024];
};
```
接下来,我们需要创建一个消息队列,使用**msgget函数**。这个函数需要有一个参数key这是消息队列的唯一标识应该是唯一的。如何保持唯一性呢这个还是和文件关联。
我们可以指定一个文件ftok会根据这个文件的inode生成一个近乎唯一的key。只要在这个消息队列的生命周期内这个文件不要被删除就可以了。只要不删除无论什么时刻再调用ftok也会得到同样的key。这种key的使用方式在这一章会经常遇到这是因为它们都属于System V IPC进程间通信机制体系中。
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/msg.h&gt;
int main() {
int messagequeueid;
key_t key;
if((key = ftok(&quot;/root/messagequeue/messagequeuekey&quot;, 1024)) &lt; 0)
{
perror(&quot;ftok error&quot;);
exit(1);
}
printf(&quot;Message Queue key: %d.\n&quot;, key);
if ((messagequeueid = msgget(key, IPC_CREAT|0777)) == -1)
{
perror(&quot;msgget error&quot;);
exit(1);
}
printf(&quot;Message queue id: %d.\n&quot;, messagequeueid);
}
```
在运行上面这个程序之前我们先使用命令touch messagequeuekey创建一个文件然后多次执行的结果就会像下面这样
```
# ./a.out
Message Queue key: 92536.
Message queue id: 32768.
```
System V IPC体系有一个统一的命令行工具ipcmkipcs和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表示参数选项后面要跟参数最后一个成员it'm是参数选项的简称。
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/msg.h&gt;
#include &lt;getopt.h&gt;
#include &lt;string.h&gt;
struct msg_buffer {
long mtype;
char mtext[1024];
};
int main(int argc, char *argv[]) {
int next_option;
const char* const short_options = &quot;i:t:m:&quot;;
const struct option long_options[] = {
{ &quot;id&quot;, 1, NULL, 'i'},
{ &quot;type&quot;, 1, NULL, 't'},
{ &quot;message&quot;, 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 &gt; 1024) {
perror(&quot;message too long.&quot;);
exit(1);
}
memcpy(buffer.mtext, message, len);
break;
default:
break;
}
}while(next_option != -1);
if(messagequeueid != -1 &amp;&amp; buffer.mtype != -1 &amp;&amp; len != -1 &amp;&amp; message != NULL){
if(msgsnd(messagequeueid, &amp;buffer, len, IPC_NOWAIT) == -1){
perror(&quot;fail to send message.&quot;);
exit(1);
}
} else {
perror(&quot;arguments error&quot;);
}
return 0;
}
```
接下来,我们可以编译并运行这个发送程序。
```
gcc -o send sendmessage.c
./send -i 32768 -t 123 -m &quot;hello world&quot;
```
接下来,我们再来看如何收消息。收消息主要调用**msgrcv函数**第一个参数是message queue的id第二个参数是消息的结构体第三个参数是可接受的最大长度第四个参数是消息类型,最后一个参数是flag这里IPC_NOWAIT表示接收的时候不阻塞直接返回。
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/msg.h&gt;
#include &lt;getopt.h&gt;
#include &lt;string.h&gt;
struct msg_buffer {
long mtype;
char mtext[1024];
};
int main(int argc, char *argv[]) {
int next_option;
const char* const short_options = &quot;i:t:&quot;;
const struct option long_options[] = {
{ &quot;id&quot;, 1, NULL, 'i'},
{ &quot;type&quot;, 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 &amp;&amp; type != -1){
if(msgrcv(messagequeueid, &amp;buffer, 1024, type, IPC_NOWAIT) == -1){
perror(&quot;fail to recv message.&quot;);
exit(1);
}
printf(&quot;received message type : %d, text: %s.&quot;, buffer.mtype, buffer.mtext);
} else {
perror(&quot;arguments error&quot;);
}
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
```
有了消息这种模型,两个进程之间的通信就像咱们平时发邮件一样,你来一封,我回一封,可以频繁沟通了。
## 共享内存模型
<img src="https://static001.geekbang.org/resource/image/df/38/df910e4383885b1aceaafb52b9bb5638.png" alt="">
但是有时候,项目组之间的沟通需要特别紧密,而且要分享一些比较大的数据。如果使用邮件,就发现,一方面邮件的来去不及时;另外一方面,附件大小也有限制,所以,这个时候,我们经常采取的方式就是,把两个项目组在需要合作的期间,拉到一个会议室进行合作开发,这样大家可以直接交流文档呀,架构图呀,直接在白板上画或者直接扔给对方,就可以直接看到。
可以看出来,共享会议室这种模型,类似进程间通信的**共享内存模型**。前面咱们讲内存管理的时候知道每个进程都有自己独立的虚拟内存空间不同的进程的虚拟内存空间映射到不同的物理内存中去。这个进程访问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 &lt; 0就请求sem_op的绝对值的资源。如果相应的资源数可以满足请求则将该信号量的值减去sem_op的绝对值函数成功返回。
当相应的资源数不能满足请求时就要看sem_flg了。如果把sem_flg设置为IPC_NOWAIT也就是没有资源也不等待则semop函数出错返回EAGAIN。如果sem_flg 没有指定IPC_NOWAIT则进程挂起直到当相应的资源数可以满足请求。若sem_op &gt; 0表示进程归还相应的资源数将 sem_op 的值加到信号量的值上。如果有进程正在休眠等待此信号量,则唤醒它们。
```
int semop(int semid, struct sembuf semoparray[], size_t numops);
struct sembuf
{
short sem_num; // 信号量组中对应的序号0sem_nums-1
short sem_op; // 信号量值在一次操作中的改变量
short sem_flg; // IPC_NOWAIT, SEM_UNDO
}
```
信号量和共享内存都比较复杂,两者还要结合起来用,就更加复杂,它们内核的机制就更加复杂。这一节我们先不讲,放到本章的最后一节重点讲解。
## 信号
上面讲的进程间通信的方式,都是常规状态下的工作模式,对应到咱们平时的工作交接,收发邮件、联合开发等,其实还有一种异常情况下的工作模式。
例如出现线上系统故障这个时候什么流程都来不及了不可能发邮件也来不及开会所有的架构师、开发、运维都要被通知紧急出动。所以7乘24小时不间断执行的系统都需要有告警系统一旦出事情就要通知到人哪怕是半夜也要电话叫起来处理故障。
对应到操作系统中就是信号。信号没有特别复杂的数据结构就是用一个代号一样的数字。Linux提供了几十种信号分别代表不同的意义。信号之间依靠它们的值来区分。这就像咱们看警匪片对于紧急的行动都是说“1号作战任务”开始执行警察就开始行动了。情况紧急不能啰里啰嗦了。
信号可以在任何时候发送给某一进程,进程需要为这个信号配置信号处理函数。当某个信号发生的时候,就默认执行这个函数就可以了。这就相当于咱们运维一个系统应急手册,当遇到什么情况,做什么事情,都事先准备好,出了事情照着做就可以了。
## 总结时刻
这一节,我们整体讲解了一下进程间通信的各种模式。你现在还能记住多少?
- 类似瀑布开发模式的管道
- 类似邮件模式的消息队列
- 类似会议室联合开发的共享内存加信号量
- 类似应急预案的信号
当你自己使用的时候,可以根据不同的通信需要,选择不同的模式。
- 管道,请你记住这是命令行中常用的模式,面试问到的话,不要忘了。
- 消息队列其实很少使用,因为有太多的用户级别的消息队列,功能更强大。
- 共享内存加信号量是常用的模式。这个需要牢记常见到一些知名的以C语言开发的开源软件都会用到它。
- 信号更加常用,机制也比较复杂。我们后面会有单独的一节来解析。
## 课堂练习
这节课的程序请你务必自己编译通过搞清楚参数解析是怎么做的这个以后你自己写程序的时候很有用另外消息队列模型的API调用流程也要搞清楚要知道他们都属于System V系列后面我们学共享内存和信号量能看到完全类似的API调用流程。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,271 @@
<audio id="audio" title="37 | 信号项目组A完成了如何及时通知项目组B" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/fe/15bee5da75b7dc425621b1b30bd7c2fe.mp3"></audio>
上一节最后,我们讲了信号的机制。在某些紧急情况下,我们需要给进程发送一个信号,紧急处理一些事情。
这种方式有点儿像咱们运维一个线上系统,为了应对一些突发事件,往往需要制定应急预案。就像下面的列表中一样。一旦发生了突发事件,马上能够找到负责人,根据处理步骤进行紧急响应,并且在限定的事件内搞定。
<img src="https://static001.geekbang.org/resource/image/49/0c/498199918340c55f59c91129ceb59f0c.png" alt="">
我们现在就按照应急预案的设计思路来看一看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 (&amp;act.sa_mask);
act.sa_flags = SA_ONESHOT | SA_NOMASK | SA_INTERRUPT;
act.sa_flags &amp;= ~SA_RESTART;
if (__sigaction (sig, &amp;act, &amp;oact) &lt; 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-&gt;sa_handler;
memcpy (&amp;kact.sa_mask, &amp;act-&gt;sa_mask, sizeof (sigset_t));
kact.sa_flags = act-&gt;sa_flags | SA_RESTORER;
kact.sa_restorer = &amp;restore_rt;
}
result = INLINE_SYSCALL (rt_sigaction, 4,
sig, act ? &amp;kact : NULL,
oact ? &amp;koact : NULL, _NSIG / 8);
if (oact &amp;&amp; result &gt;= 0)
{
oact-&gt;sa_handler = koact.k_sa_handler;
memcpy (&amp;oact-&gt;sa_mask, &amp;koact.sa_mask, sizeof (sigset_t));
oact-&gt;sa_flags = koact.sa_flags;
oact-&gt;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(&amp;new_sa.sa, act, sizeof(new_sa.sa)))
return -EFAULT;
}
ret = do_sigaction(sig, act ? &amp;new_sa : NULL, oact ? &amp;old_sa : NULL);
if (!ret &amp;&amp; oact) {
if (copy_to_user(oact, &amp;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 = &amp;p-&gt;sighand-&gt;action[sig-1];
spin_lock_irq(&amp;p-&gt;sighand-&gt;siglock);
if (oact)
*oact = *k;
if (act) {
sigdelsetmask(&amp;act-&gt;sa.sa_mask,
sigmask(SIGKILL) | sigmask(SIGSTOP));
*k = *act;
......
}
spin_unlock_irq(&amp;p-&gt;sighand-&gt;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里面是一个数组下标是信号里面的内容是信号处理函数。
<img src="https://static001.geekbang.org/resource/image/7c/28/7cb86c73b9e73893e6b0e0433d476928.png" alt="">
## 课堂练习
你可以试着写一个程序调用sigaction为某个信号设置一个信号处理函数在信号处理函数中如果收到信号则打印一些字符串然后用命令kill发送信号看是否字符串被正常输出。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,480 @@
<audio id="audio" title="38 | 信号项目组A完成了如何及时通知项目组B" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b1/e5/b1615cc6dbdbe57497d8661780de59e5.mp3"></audio>
信号处理最常见的流程主要是两步,第一步是注册信号处理函数,第二步是发送信号和处理信号。上一节,我们讲了注册信号处理函数,那一般什么情况下会产生信号呢?我们这一节就来看一看。
## 信号的发送
有时候我们在终端输入某些组合键的时候会给进程发送信号例如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-&gt;kill_something_info-&gt;kill_pid_info-&gt;group_send_sig_info-&gt;do_send_sig_info
- tkill-&gt;do_tkill-&gt;do_send_specific-&gt;do_send_sig_info
- tgkill-&gt;do_tkill-&gt;do_send_specific-&gt;do_send_sig_info
- rt_sigqueueinfo-&gt;do_rt_sigqueueinfo-&gt;kill_proc_info-&gt;kill_pid_info-&gt;group_send_sig_info-&gt;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, &amp;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 ? &amp;t-&gt;signal-&gt;shared_pending : &amp;t-&gt;pending;
......
if (legacy_queue(pending, sig))
goto ret;
if (sig &lt; SIGRTMIN)
override_rlimit = (is_si_special(info) || info-&gt;si_code &gt;= 0);
else
override_rlimit = 0;
q = __sigqueue_alloc(sig, t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE,
override_rlimit);
if (q) {
list_add_tail(&amp;q-&gt;list, &amp;pending-&gt;list);
switch ((unsigned long) info) {
case (unsigned long) SEND_SIG_NOINFO:
q-&gt;info.si_signo = sig;
q-&gt;info.si_errno = 0;
q-&gt;info.si_code = SI_USER;
q-&gt;info.si_pid = task_tgid_nr_ns(current,
task_active_pid_ns(t));
q-&gt;info.si_uid = from_kuid_munged(current_user_ns(), current_uid());
break;
case (unsigned long) SEND_SIG_PRIV:
q-&gt;info.si_signo = sig;
q-&gt;info.si_errno = 0;
q-&gt;info.si_code = SI_KERNEL;
q-&gt;info.si_pid = 0;
q-&gt;info.si_uid = 0;
break;
default:
copy_siginfo(&amp;q-&gt;info, info);
if (from_ancestor_ns)
q-&gt;info.si_pid = 0;
break;
}
userns_fixup_signal_uid(&amp;q-&gt;info, t);
}
......
out_set:
signalfd_notify(t, sig);
sigaddset(&amp;pending-&gt;signal, sig);
complete_signal(sig, t, group);
ret:
return ret;
}
```
在这里我们看到在学习进程数据结构中task_struct里面的sigpending。在上面的代码里面我们先是要决定应该用哪个sigpending。这就要看我们发送的信号是给进程的还是线程的。如果是kill发送的也就是发送给整个进程的就应该发送给t-&gt;signal-&gt;shared_pending。这里面是整个进程所有线程共享的信号如果是tkill发送的也就是发给某个线程的就应该发给t-&gt;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 &lt; SIGRTMIN) &amp;&amp; sigismember(&amp;signals-&gt;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-&gt;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-&gt;curr_target;
while (!wants_signal(sig, t)) {
t = next_thread(t);
if (t == signal-&gt;curr_target)
return;
}
signal-&gt;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 &amp; _TIF_NEED_RESCHED)
schedule();
......
/* deal with pending signal delivery */
if (cached_flags &amp; _TIF_SIGPENDING)
do_signal(regs);
......
if (!(cached_flags &amp; EXIT_TO_USERMODE_LOOP_FLAGS))
break;
}
}
```
如果在前一个环节中已经设置了_TIF_SIGPENDING我们就调用do_signal进行处理。
```
void do_signal(struct pt_regs *regs)
{
struct ksignal ksig;
if (get_signal(&amp;ksig)) {
/* Whee! Actually deliver the signal. */
handle_signal(&amp;ksig, regs);
return;
}
/* Did we come from a system call? */
if (syscall_get_nr(current, regs) &gt;= 0) {
/* Restart the system call - no handlers present */
switch (syscall_get_error(current, regs)) {
case -ERESTARTNOHAND:
case -ERESTARTSYS:
case -ERESTARTNOINTR:
regs-&gt;ax = regs-&gt;orig_ax;
regs-&gt;ip -= 2;
break;
case -ERESTART_RESTARTBLOCK:
regs-&gt;ax = get_nr_restart_syscall(regs);
regs-&gt;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) &gt;= 0) {
/* If so, check system call restarting.. */
switch (syscall_get_error(current, regs)) {
case -ERESTART_RESTARTBLOCK:
case -ERESTARTNOHAND:
regs-&gt;ax = -EINTR;
break;
case -ERESTARTSYS:
if (!(ksig-&gt;ka.sa.sa_flags &amp; SA_RESTART)) {
regs-&gt;ax = -EINTR;
break;
}
/* fallthrough */
case -ERESTARTNOINTR:
regs-&gt;ax = regs-&gt;orig_ax;
regs-&gt;ip -= 2;
break;
}
}
......
failed = (setup_rt_frame(ksig, regs) &lt; 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(&amp;q-&gt;sk), &amp;wait,
TASK_INTERRUPTIBLE);
/* Read frames from the queue */
skb = skb_array_consume(&amp;q-&gt;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-&gt;do_signal-&gt;handle_signal。在这里面当发现出现错误ERESTARTSYS的时候我们就知道这是从一个没有调用完的系统调用返回的设置系统调用错误码EINTR。
接下来我们就开始折腾pt_regs了主要通过调用setup_rt_frame-&gt;__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(&amp;ksig-&gt;ka, regs, sizeof(struct rt_sigframe), &amp;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-&gt;ka.sa.sa_flags &amp; SA_RESTORER) {
put_user_ex(ksig-&gt;ka.sa.sa_restorer, &amp;frame-&gt;pretcode);
}
} put_user_catch(err);
err |= setup_sigcontext(&amp;frame-&gt;uc.uc_mcontext, fp, regs, set-&gt;sig[0]);
err |= __copy_to_user(&amp;frame-&gt;uc.uc_sigmask, set, sizeof(*set));
/* Set up registers for signal handler */
regs-&gt;di = sig;
/* In case the signal handler was declared without prototypes */
regs-&gt;ax = 0;
regs-&gt;si = (unsigned long)&amp;frame-&gt;info;
regs-&gt;dx = (unsigned long)&amp;frame-&gt;uc;
regs-&gt;ip = (unsigned long) ksig-&gt;ka.sa.sa_handler;
regs-&gt;sp = (unsigned long)frame;
regs-&gt;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-&gt;sp设置成等于frame。这就相当于强行在程序原来的用户态的栈里面插入了一个栈帧并在最后将regs-&gt;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-&gt;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 \
( \
&quot;.LSTART_&quot; #name &quot;:\n&quot; \
&quot; .type __&quot; #name &quot;,@function\n&quot; \
&quot;__&quot; #name &quot;:\n&quot; \
&quot; movq $&quot; #syscall &quot;, %rax\n&quot; \
&quot; syscall\n&quot; \
......
```
我们可以在内核里面找到__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-&gt;sp - sizeof(long));
if (__copy_from_user(&amp;set, &amp;frame-&gt;uc.uc_sigmask, sizeof(set)))
goto badframe;
if (__get_user(uc_flags, &amp;frame-&gt;uc.uc_flags))
goto badframe;
set_current_blocked(&amp;set);
if (restore_sigcontext(regs, &amp;frame-&gt;uc.uc_mcontext, uc_flags))
goto badframe;
......
return regs-&gt;ax;
......
}
```
在这里面我们把上次填充的那个rt_sigframe拿出来然后restore_sigcontext将pt_regs恢复成为原来用户态的样子。从这个系统调用返回的时候应用还误以为从上次的系统调用返回的呢。
至此,整个信号处理过程才全部结束。
## 总结时刻
信号的发送与处理是一个复杂的过程,这里来总结一下。
1. 假设我们有一个进程Amain函数里面调用系统调用进入内核。
1. 按照系统调用的原理会将用户态栈的信息保存在pt_regs里面也即记住原来用户态是运行到了line A的地方。
1. 在内核中执行系统调用读取数据。
1. 当发现没有什么数据可读取的时候只好进入睡眠状态并且调用schedule让出CPU这是进程调度第一定律。
1. 将进程状态设置为TASK_INTERRUPTIBLE可中断的睡眠状态也即如果有信号来的话是可以唤醒它的。
1. 其他的进程或者shell发送一个信号有四个函数可以调用kill、tkill、tgkill、rt_sigqueueinfo。
1. 四个发送信号的函数在内核中最终都是调用do_send_sig_info。
1. do_send_sig_info调用send_signal给进程A发送一个信号其实就是找到进程A的task_struct或者加入信号集合为不可靠信号或者加入信号链表为可靠信号。
1. do_send_sig_info调用signal_wake_up唤醒进程A。
1. 进程A重新进入运行状态TASK_RUNNING根据进程调度第一定律一定会接着schedule运行。
1. 进程A被唤醒后检查是否有信号到来如果没有重新循环到一开始尝试再次读取数据如果还是没有数据再次进入TASK_INTERRUPTIBLE即可中断的睡眠状态。
1. 当发现有信号到来的时候,就返回当前正在执行的系统调用,并返回一个错误表示系统调用被中断了。
1. 系统调用返回的时候会调用exit_to_usermode_loop。这是一个处理信号的时机。
1. 调用do_signal开始处理信号。
1. 根据信号得到信号处理函数sa_handler然后修改pt_regs中的用户态栈的信息让pt_regs指向sa_handler。同时修改用户态的栈插入一个栈帧sa_restorer里面保存了原来的指向line A的pt_regs并且设置让sa_handler运行完毕后跳到sa_restorer运行。
1. 返回用户态由于pt_regs已经设置为sa_handler则返回用户态执行sa_handler。
1. sa_handler执行完毕后信号处理函数就执行完了接着根据第15步对于用户态栈帧的修改会跳到sa_restorer运行。
1. sa_restorer会调用系统调用rt_sigreturn再次进入内核。
1. 在内核中rt_sigreturn恢复原来的pt_regs重新指向line A。
1. 从rt_sigreturn返回用户态还是调用exit_to_usermode_loop。
1. 这次因为pt_regs已经指向line A了于是就到了进程A中接着系统调用之后运行当然这个系统调用返回的是它被中断了没有执行完的错误。
<img src="https://static001.geekbang.org/resource/image/3d/fb/3dcb3366b11a3594b00805896b7731fb.png" alt="">
## 课堂练习
在Linux内核里面很多地方都存在信号和信号处理所以signal_pending这个函数也随处可见这样我们就能判断是否有信号发生。请你在内核代码中找到signal_pending出现的一些地方看有什么规律我们后面的章节会经常遇到它。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,482 @@
<audio id="audio" title="39 | 管道项目组A完成了如何交接给项目组B" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9f/ca/9f9efe1fb4d988f2099ce48c8cd023ca.mp3"></audio>
在这一章的第一节里,我们大致讲了管道的使用方式以及相应的命令行。这一节,我们就具体来看一下管道是如何实现的。
我们先来看,我们常用的**匿名管道**Anonymous Pipes也即将多个命令串起来的竖线背后的原理到底是什么。
上次我们说,它是基于管道的,那管道如何创建呢?管道的创建,需要通过下面这个系统调用。
```
int pipe(int fd[2])
```
在这里我们创建了一个管道pipe返回了两个文件描述符这表示管道的两端一个是管道的读取端描述符fd[0]另一个是管道的写入端描述符fd[1]。
<img src="https://static001.geekbang.org/resource/image/8f/a7/8fa3144bf3a34ddf789884a75fa2d4a7.png" alt="">
我们来看在内核里面是如何实现的。
```
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-&gt;mnt_sb, &amp;empty_name);
......
path.mnt = mntget(pipe_mnt);
d_instantiate(path.dentry, inode);
f = alloc_file(&amp;path, FMODE_WRITE, &amp;pipefifo_fops);
......
f-&gt;f_flags = O_WRONLY | (flags &amp; (O_NONBLOCK | O_DIRECT));
f-&gt;private_data = inode-&gt;i_pipe;
res[0] = alloc_file(&amp;path, FMODE_READ, &amp;pipefifo_fops);
......
path_get(&amp;path);
res[0]-&gt;private_data = inode-&gt;i_pipe;
res[0]-&gt;f_flags = O_RDONLY | (flags &amp; O_NONBLOCK);
res[1] = f;
return 0;
......
}
```
从get_pipe_inode的实现我们可以看出匿名管道来自一个特殊的文件系统pipefs。这个文件系统被挂载后我们就得到了struct vfsmount *pipe_mnt。然后挂载的文件系统的superblock就变成了pipe_mnt-&gt;mnt_sb。如果你对文件系统的操作还不熟悉要返回去复习一下文件系统那一章啊。
```
static struct file_system_type pipe_fs_type = {
.name = &quot;pipefs&quot;,
.mount = pipefs_mount,
.kill_sb = kill_anon_super,
};
static int __init init_pipe_fs(void)
{
int err = register_filesystem(&amp;pipe_fs_type);
if (!err) {
pipe_mnt = kern_mount(&amp;pipe_fs_type);
}
......
}
static struct inode * get_pipe_inode(void)
{
struct inode *inode = new_inode_pseudo(pipe_mnt-&gt;mnt_sb);
struct pipe_inode_info *pipe;
......
inode-&gt;i_ino = get_next_ino();
pipe = alloc_pipe_info();
......
inode-&gt;i_pipe = pipe;
pipe-&gt;files = 2;
pipe-&gt;readers = pipe-&gt;writers = 1;
inode-&gt;i_fop = &amp;pipefifo_fops;
inode-&gt;i_state = I_DIRTY;
inode-&gt;i_mode = S_IFIFO | S_IRUSR | S_IWUSR;
inode-&gt;i_uid = current_fsuid();
inode-&gt;i_gid = current_fsgid();
inode-&gt;i_atime = inode-&gt;i_mtime = inode-&gt;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写入和读取同一个管道文件实现跨进程通信了。
<img src="https://static001.geekbang.org/resource/image/9c/a3/9c0e38e31c7a51da12faf4a1aca10ba3.png" alt="">
由于管道只能一端写入另一端读出所以上面的这种模式会造成混乱因为父进程和子进程都可以写入也都可以读出通常的方法是父进程关闭读取的fd只保留写入的fd而子进程关闭写入的fd只保留读取的fd如果需要双向通行则应该创建两个管道。
一个典型的使用管道在父子进程之间的通信代码如下:
```
#include &lt;unistd.h&gt;
#include &lt;fcntl.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;stdio.h&gt;
#include &lt;errno.h&gt;
#include &lt;string.h&gt;
int main(int argc, char *argv[])
{
int fds[2];
if (pipe(fds) == -1)
perror(&quot;pipe error&quot;);
pid_t pid;
pid = fork();
if (pid == -1)
perror(&quot;fork error&quot;);
if (pid == 0){
close(fds[0]);
char msg[] = &quot;hello world&quot;;
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(&quot;message : %s\n&quot;, msg);
return 0;
}
}
```
<img src="https://static001.geekbang.org/resource/image/71/b6/71eb7b4d026d04e4093daad7e24feab6.png" alt="">
到这里我们仅仅解析了使用管道进行父子进程之间的通信但是我们在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进程。
<img src="https://static001.geekbang.org/resource/image/81/fa/81be4d460aaa804e9176ec70d59fdefa.png" alt="">
接下来我们要做的事情就是将这个管道的两端和输入输出关联起来。这就要用到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的功能完成。
<img src="https://static001.geekbang.org/resource/image/c0/e2/c042b12de704995e4ba04173e0a304e2.png" alt="">
为了模拟A|B的情况我们可以将前面的那一段代码进一步修改成下面这样
```
#include &lt;unistd.h&gt;
#include &lt;fcntl.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;stdio.h&gt;
#include &lt;errno.h&gt;
#include &lt;string.h&gt;
int main(int argc, char *argv[])
{
int fds[2];
if (pipe(fds) == -1)
perror(&quot;pipe error&quot;);
pid_t pid;
pid = fork();
if (pid == -1)
perror(&quot;fork error&quot;);
if (pid == 0){
dup2(fds[1], STDOUT_FILENO);
close(fds[1]);
close(fds[0]);
execlp(&quot;ps&quot;, &quot;ps&quot;, &quot;-ef&quot;, NULL);
} else {
dup2(fds[0], STDIN_FILENO);
close(fds[0]);
close(fds[1]);
execlp(&quot;grep&quot;, &quot;grep&quot;, &quot;systemd&quot;, 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, &amp;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) &amp; ((1ULL &lt;&lt; 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, &amp;path, lookup_flags);
......
switch (mode &amp; S_IFMT) {
......
case S_IFIFO: case S_IFSOCK:
error = vfs_mknod(path.dentry-&gt;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, &amp;dentry-&gt;d_name, 0,
NULL, EXT4_HT_DIR, credits);
handle = ext4_journal_current_handle();
if (!IS_ERR(inode)) {
init_special_inode(inode, inode-&gt;i_mode, rdev);
inode-&gt;i_op = &amp;ext4_special_inode_operations;
err = ext4_add_nondir(handle, dentry, inode);
if (!err &amp;&amp; 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-&gt;i_mode = mode;
if (S_ISCHR(mode)) {
inode-&gt;i_fop = &amp;def_chr_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISBLK(mode)) {
inode-&gt;i_fop = &amp;def_blk_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISFIFO(mode))
inode-&gt;i_fop = &amp;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-&gt;i_sb-&gt;s_magic == PIPEFS_MAGIC;
int ret;
filp-&gt;f_version = 0;
if (inode-&gt;i_pipe) {
pipe = inode-&gt;i_pipe;
pipe-&gt;files++;
} else {
pipe = alloc_pipe_info();
pipe-&gt;files = 1;
inode-&gt;i_pipe = pipe;
spin_unlock(&amp;inode-&gt;i_lock);
}
filp-&gt;private_data = pipe;
filp-&gt;f_mode &amp;= (FMODE_READ | FMODE_WRITE);
switch (filp-&gt;f_mode) {
case FMODE_READ:
pipe-&gt;r_counter++;
if (pipe-&gt;readers++ == 0)
wake_up_partner(pipe);
if (!is_pipe &amp;&amp; !pipe-&gt;writers) {
if ((filp-&gt;f_flags &amp; O_NONBLOCK)) {
filp-&gt;f_version = pipe-&gt;w_counter;
} else {
if (wait_for_partner(pipe, &amp;pipe-&gt;w_counter))
goto err_rd;
}
}
break;
case FMODE_WRITE:
pipe-&gt;w_counter++;
if (!pipe-&gt;writers++)
wake_up_partner(pipe);
if (!is_pipe &amp;&amp; !pipe-&gt;readers) {
if (wait_for_partner(pipe, &amp;pipe-&gt;r_counter))
goto err_wr;
}
break;
case FMODE_READ | FMODE_WRITE:
pipe-&gt;readers++;
pipe-&gt;writers++;
pipe-&gt;r_counter++;
pipe-&gt;w_counter++;
if (pipe-&gt;readers == 1 || pipe-&gt;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结构找到缓存读出。
<img src="https://static001.geekbang.org/resource/image/48/97/486e2bc73abbe91d7083bb1f4f678097.png" alt="">
## 课堂练习
上面创建匿名管道的程序你一定要运行一下然后试着通过strace查看自己写的程序的系统调用以及直接在命令行使用匿名管道的系统调用做一个比较。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,297 @@
<audio id="audio" title="40 | IPC不同项目组之间抢资源如何协调" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/52/52/5292ca2ad2cdc2f2d54796e5c3d55b52.mp3"></audio>
我们前面讲了,如果项目组之间需要紧密合作,那就需要共享内存,这样就像把两个项目组放在一个会议室一起沟通,会非常高效。这一节,我们就来详细讲讲这个进程之间共享内存的机制。
有了这个机制,两个进程可以像访问自己内存中的变量一样,访问共享内存的变量。但是同时问题也来了,当两个进程共享内存了,就会存在同时读写的问题,就需要对于共享的内存进行保护,就需要信号量这样的同步协调机制。这些也都是我们这节需要探讨的问题。下面我们就一一来看。
共享内存和信号量也是System V系列的进程间通信机制所以很多地方和我们讲过的消息队列有点儿像。为了将共享内存和信号量结合起来使用我这里定义了一个share.h头文件里面放了一些共享内存和信号量在每个进程都需要的函数。
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/ipc.h&gt;
#include &lt;sys/shm.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;sys/sem.h&gt;
#include &lt;string.h&gt;
#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(&quot;/root/sharememory/sharememorykey&quot;, 1024)) &lt; 0){
perror(&quot;ftok error&quot;);
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(&quot;/root/sharememory/semaphorekey&quot;, 1024)) &lt; 0){
perror(&quot;ftok error&quot;);
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就是前面生成的那个keyshmflag如果为IPC_CREAT就表示新创建还可以指定读写权限0777。
对于共享内存需要指定一个大小size这个一般要申请多大呢一个最佳实践是我们将多个进程需要共享的数据放在一个struct里面然后这里的size就应该是这个struct的大小。这样每一个进程得到这块内存后只要强制将类型转换为这个struct类型就能够访问里面的共享数据了。
在这里我们定义了一个struct shm_data结构。这里面有两个成员一个是一个整型的数组一个是数组中元素的个数。
生成了共享内存以后,接下来就是将这个共享内存映射到进程的虚拟地址空间中。我们使用下面这个函数来进行操作。
```
void *shmat(int shm_id, const void *addr, int shmflg);
```
这里面的shm_id就是上面创建的共享内存的idaddr就是指定映射在某个地方。如果不指定则内核会自动选择一个地址作为返回值返回。得到了返回地址以后我们需要将指针强制类型转换为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就是前面生成的那个keyshmflag如果为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 &quot;share.h&quot;
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-&gt;datalength &gt; 0){
semaphore_v(semid);
sleep(1);
} else {
printf(&quot;how many integers to caculate : &quot;);
scanf(&quot;%d&quot;,&amp;shared-&gt;datalength);
if(shared-&gt;datalength &gt; MAX_NUM){
perror(&quot;too many integers.&quot;);
shared-&gt;datalength = 0;
semaphore_v(semid);
exit(1);
}
for(i=0;i&lt;shared-&gt;datalength;i++){
printf(&quot;Input the %d integer : &quot;, i);
scanf(&quot;%d&quot;,&amp;shared-&gt;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 &quot;share.h&quot;
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-&gt;datalength &gt; 0){
int sum = 0;
for(i=0;i&lt;shared-&gt;datalength-1;i++){
printf(&quot;%d+&quot;,shared-&gt;data[i]);
sum += shared-&gt;data[i];
}
printf(&quot;%d&quot;,shared-&gt;data[shared-&gt;datalength-1]);
sum += shared-&gt;data[shared-&gt;datalength-1];
printf(&quot;=%d\n&quot;,sum);
memset(shared, 0, sizeof(struct shm_data));
semaphore_v(semid);
} else {
semaphore_v(semid);
printf(&quot;no tasks, waiting.\n&quot;);
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来解除映射。
<img src="https://static001.geekbang.org/resource/image/46/0b/469552bffe601d594c432d4fad97490b.png" alt="">
## 课堂练习
信号量大于1的情况下应该如何使用你可以试着构建一个场景。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,576 @@
<audio id="audio" title="41 | IPC不同项目组之间抢资源如何协调" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5e/d6/5e5caec562b544ec17f47848e115cdd6.mp3"></audio>
了解了如何使用共享内存和信号量集合之后,今天我们来解析一下,内核里面都做了什么。
不知道你有没有注意到,咱们讲消息队列、共享内存、信号量的机制的时候,我们其实能够从中看到一些统一的规律:**它们在使用之前都要生成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)-&gt;ids[IPC_SEM_IDS])
#define msg_ids(ns) ((ns)-&gt;ids[IPC_MSG_IDS])
#define shm_ids(ns) ((ns)-&gt;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(&amp;ids-&gt;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;/* &gt;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(&amp;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(&amp;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(&amp;shm_ids(ns), id);
return container_of(ipcp, struct shmid_kernel, shm_perm);
}
```
通过这种机制我们就可以将信号量、消息队列、共享内存抽象为ipc类型进行统一处理。你有没有觉得这有点儿面向对象编程中抽象类和实现类的意思没错如果你试图去了解C++中类的实现机制,其实也是这么干的。
<img src="https://static001.geekbang.org/resource/image/08/af/082b742753d862cfeae520fb02aa41af.png" alt="">
有了抽象类,接下来我们来看共享内存和信号量的具体实现。
## 如何创建共享内存?
首先,我们来看创建共享内存的的系统调用。
```
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-&gt;nsproxy-&gt;ipc_ns;
shm_params.key = key;
shm_params.flg = shmflg;
shm_params.u.size = size;
return ipcget(ns, &amp;shm_ids(ns), &amp;shm_ops, &amp;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-&gt;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-&gt;flg;
int err;
ipcp = ipc_findkey(ids, params-&gt;key);
if (ipcp == NULL) {
if (!(flg &amp; IPC_CREAT))
err = -ENOENT;
else
err = ops-&gt;getnew(ns, params);
} else {
if (flg &amp; IPC_CREAT &amp;&amp; flg &amp; IPC_EXCL)
err = -EEXIST;
else {
err = 0;
if (ops-&gt;more_checks)
err = ops-&gt;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-&gt;key;
int shmflg = params-&gt;flg;
size_t size = params-&gt;u.size;
int error;
struct shmid_kernel *shp;
size_t numpages = (size + PAGE_SIZE - 1) &gt;&gt; PAGE_SHIFT;
struct file *file;
char name[13];
vm_flags_t acctflag = 0;
......
shp = kvmalloc(sizeof(*shp), GFP_KERNEL);
......
shp-&gt;shm_perm.key = key;
shp-&gt;shm_perm.mode = (shmflg &amp; S_IRWXUGO);
shp-&gt;mlock_user = NULL;
shp-&gt;shm_perm.security = NULL;
......
file = shmem_kernel_file_setup(name, size, acctflag);
......
shp-&gt;shm_cprid = task_tgid_vnr(current);
shp-&gt;shm_lprid = 0;
shp-&gt;shm_atim = shp-&gt;shm_dtim = 0;
shp-&gt;shm_ctim = get_seconds();
shp-&gt;shm_segsz = size;
shp-&gt;shm_nattch = 0;
shp-&gt;shm_file = file;
shp-&gt;shm_creator = current;
error = ipc_addid(&amp;shm_ids(ns), &amp;shp-&gt;shm_perm, ns-&gt;shm_ctlmni);
......
list_add(&amp;shp-&gt;shm_clist, &amp;current-&gt;sysvshm.shm_clist);
......
file_inode(file)-&gt;i_ino = shp-&gt;shm_perm.id;
ns-&gt;shm_tot += numpages;
error = shp-&gt;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(&amp;shmem_fs_type);
shm_mnt = kern_mount(&amp;shmem_fs_type);
......
return 0;
}
static struct file_system_type shmem_fs_type = {
.owner = THIS_MODULE,
.name = &quot;tmpfs&quot;,
.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/&lt;pid&gt;/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-&gt;mnt_sb;
path.mnt = mntget(shm_mnt);
path.dentry = d_alloc_pseudo(sb, &amp;this);
d_set_d_op(path.dentry, &amp;anon_ops);
......
inode = shmem_get_inode(sb, NULL, S_IFREG | S_IRWXUGO, 0, flags);
inode-&gt;i_flags |= i_flags;
d_instantiate(path.dentry, inode);
inode-&gt;i_size = size;
......
res = alloc_file(&amp;path, FMODE_WRITE | FMODE_READ,
&amp;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, &amp;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-&gt;nsproxy-&gt;ipc_ns;
shp = shm_obtain_object_check(ns, shmid);
......
path = shp-&gt;shm_file-&gt;f_path;
path_get(&amp;path);
shp-&gt;shm_nattch++;
size = i_size_read(d_inode(path.dentry));
......
sfd = kzalloc(sizeof(*sfd), GFP_KERNEL);
......
file = alloc_file(&amp;path, f_mode,
is_file_hugepages(shp-&gt;shm_file) ?
&amp;shm_file_operations_huge :
&amp;shm_file_operations);
......
file-&gt;private_data = sfd;
file-&gt;f_mapping = shp-&gt;shm_file-&gt;f_mapping;
sfd-&gt;id = shp-&gt;shm_perm.id;
sfd-&gt;ns = get_ipc_ns(ns);
sfd-&gt;file = shp-&gt;shm_file;
sfd-&gt;vm_ops = NULL;
......
addr = do_mmap_pgoff(file, addr, size, prot, flags, 0, &amp;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-&gt;file, vma);
sfd-&gt;vm_ops = vma-&gt;vm_ops;
vma-&gt;vm_ops = &amp;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-&gt;vm_ops = &amp;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-&gt;vma-&gt;vm_file;
struct shm_file_data *sfd = shm_file_data(file);
return sfd-&gt;vm_ops-&gt;fault(vmf);
}
```
虽然基于内存的文件系统已经为这个内存文件分配了inode但是内存也却是一点儿都没分配只有在发生缺页异常的时候才进行分配。
```
static int shmem_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf-&gt;vma;
struct inode *inode = file_inode(vma-&gt;vm_file);
gfp_t gfp = mapping_gfp_mask(inode-&gt;i_mapping);
......
error = shmem_getpage_gfp(inode, vmf-&gt;pgoff, &amp;vmf-&gt;page, sgp,
gfp, vma, vmf, &amp;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在物理内存中分配一个页。
至此,共享内存才真的映射到了虚拟地址空间中,进程可以像访问本地内存一样访问共享内存。
## 总结时刻
我们来总结一下共享内存的创建和映射过程。
1. 调用shmget创建共享内存。
1. 先通过ipc_findkey在基数树中查找key对应的共享内存对象shmid_kernel是否已经被创建过如果已经被创建就会被查询出来例如producer创建过在consumer中就会查询出来。
1. 如果共享内存没有被创建过则调用shm_ops的newseg方法创建一个共享内存对象shmid_kernel。例如在producer中就会新建。
1. 在shmem文件系统里面创建一个文件共享内存对象shmid_kernel指向这个文件这个文件用struct file表示我们姑且称它为file1。
1. 调用shmat将共享内存映射到虚拟地址空间。
1. shm_obtain_object_check先从基数树里面找到shmid_kernel对象。
1. 创建用于内存映射到文件的file和shm_file_data这里的struct file我们姑且称为file2。
1. 关联内存区域vm_area_struct和用于内存映射到文件的file也即file2调用file2的mmap函数。
1. file2的mmap函数shm_mmap会调用file1的mmap函数shmem_mmap设置shm_file_data和vm_area_struct的vm_ops。
1. 内存映射完毕之后其实并没有真的分配物理内存当访问内存的时候会触发缺页异常do_page_fault。
1. vm_area_struct的vm_ops的shm_fault会调用shm_file_data的vm_ops的shmem_fault。
1. 在page cache中找一个空闲页或者创建一个空闲页。
<img src="https://static001.geekbang.org/resource/image/20/51/20e8f4e69d47b7469f374bc9fbcf7251.png" alt="">
## 课堂练习
在这里我们只分析了shm_ids的结构消息队列的程序我们写过了但是msg_ids的结构没有解析你可以试着解析一下。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,539 @@
<audio id="audio" title="42 | IPC不同项目组之间抢资源如何协调" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/09/32/097590a47025a46e19b1551183297c32.mp3"></audio>
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-&gt;nsproxy-&gt;ipc_ns;
sem_params.key = key;
sem_params.flg = semflg;
sem_params.u.nsems = nsems;
return ipcget(ns, &amp;sem_ids(ns), &amp;sem_ops, &amp;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-&gt;key;
int nsems = params-&gt;u.nsems;
int semflg = params-&gt;flg;
int i;
......
sma = sem_alloc(nsems);
......
sma-&gt;sem_perm.mode = (semflg &amp; S_IRWXUGO);
sma-&gt;sem_perm.key = key;
sma-&gt;sem_perm.security = NULL;
......
for (i = 0; i &lt; nsems; i++) {
INIT_LIST_HEAD(&amp;sma-&gt;sems[i].pending_alter);
INIT_LIST_HEAD(&amp;sma-&gt;sems[i].pending_const);
spin_lock_init(&amp;sma-&gt;sems[i].lock);
}
sma-&gt;complex_count = 0;
sma-&gt;use_global_lock = USE_GLOBAL_LOCK_HYSTERESIS;
INIT_LIST_HEAD(&amp;sma-&gt;pending_alter);
INIT_LIST_HEAD(&amp;sma-&gt;pending_const);
INIT_LIST_HEAD(&amp;sma-&gt;list_id);
sma-&gt;sem_nsems = nsems;
sma-&gt;sem_ctime = get_seconds();
retval = ipc_addid(&amp;sem_ids(ns), &amp;sma-&gt;sem_perm, ns-&gt;sc_semmni);
......
ns-&gt;used_sems += nsems;
......
return sma-&gt;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-&gt;nsproxy-&gt;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-&gt;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 &lt; nsems; i++) {
sma-&gt;sems[i].semval = sem_io[i];
sma-&gt;sems[i].sempid = task_tgid_vnr(current);
}
......
sma-&gt;sem_ctime = get_seconds();
/* maybe some queued-up processes were waiting for this */
do_smart_update(sma, NULL, 0, 0, &amp;wake_q);
err = 0;
goto out_unlock;
}
}
......
wake_up_q(&amp;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 = &amp;sma-&gt;sems[semnum];
......
curr-&gt;semval = val;
curr-&gt;sempid = task_tgid_vnr(current);
sma-&gt;sem_ctime = get_seconds();
/* maybe some queued-up processes were waiting for this */
do_smart_update(sma, NULL, 0, 0, &amp;wake_q);
......
wake_up_q(&amp;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-&gt;nsproxy-&gt;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(&amp;_timeout, timeout, sizeof(*timeout))) {
}
jiffies_left = timespec_to_jiffies(&amp;_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, &amp;queue);
if (error == 0) { /* non-blocking succesfull path */
DEFINE_WAKE_Q(wake_q);
......
do_smart_update(sma, sops, nsops, 1, &amp;wake_q);
......
wake_up_q(&amp;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 = &amp;sma-&gt;sems[sops-&gt;sem_num];
......
list_add_tail(&amp;queue.list,
&amp;curr-&gt;pending_alter);
......
} else {
......
list_add_tail(&amp;queue.list, &amp;sma-&gt;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 &amp;&amp; jiffies_left == 0)
error = -EAGAIN;
} while (error == -EINTR &amp;&amp; !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-&gt;sops;
nsops = q-&gt;nsops;
un = q-&gt;undo;
for (sop = sops; sop &lt; sops + nsops; sop++) {
curr = &amp;sma-&gt;sems[sop-&gt;sem_num];
sem_op = sop-&gt;sem_op;
result = curr-&gt;semval;
......
result += sem_op;
if (result &lt; 0)
goto would_block;
......
if (sop-&gt;sem_flg &amp; SEM_UNDO) {
int undo = un-&gt;semadj[sop-&gt;sem_num] - sem_op;
.....
}
}
for (sop = sops; sop &lt; sops + nsops; sop++) {
curr = &amp;sma-&gt;sems[sop-&gt;sem_num];
sem_op = sop-&gt;sem_op;
result = curr-&gt;semval;
if (sop-&gt;sem_flg &amp; SEM_UNDO) {
int undo = un-&gt;semadj[sop-&gt;sem_num] - sem_op;
un-&gt;semadj[sop-&gt;sem_num] = undo;
}
curr-&gt;semval += sem_op;
curr-&gt;sempid = q-&gt;pid;
}
return 0;
would_block:
q-&gt;blocking = sop;
return sop-&gt;sem_flg &amp; IPC_NOWAIT ? -EAGAIN : 1;
}
```
在perform_atomic_semop函数中对于所有信号量操作都进行两次循环。在第一次循环中如果发现计算出的result小于0则说明必须等待于是跳到would_block中设置q-&gt;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 = &amp;sma-&gt;pending_alter;
else
pending_list = &amp;sma-&gt;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-&gt;sleeper still need to sleep? */
if (error &gt; 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-&gt;sleeper);
......
}
```
update_queue会依次循环整个信号量集合的等待队列pending_alter或者某个信号量的等待队列。试图在信号量的值变了的情况下再次尝试perform_atomic_semop进行信号量操作。如果不成功则尝试队列中的下一个如果尝试成功则调用unlink_queue从队列上取下来然后调用wake_up_sem_queue_prepare将q-&gt;sleeper加到wake_q上去。q-&gt;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-&gt;first;
while (node != WAKE_Q_TAIL) {
struct task_struct *task;
task = container_of(node, struct task_struct, wake_q);
node = node-&gt;next;
task-&gt;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)标号所示。
<img src="https://static001.geekbang.org/resource/image/03/d6/0352227c5f49d194b6094f229220cdd6.png" alt="">
首先我们来看进程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结构里面的值加回当前信号量的值就能够得到正确的信号量的值不会因为一个进程退出导致信号量的值处于不一致的状态。
## 总结时刻
信号量的机制也很复杂,我们对着下面这个图总结一下。
<img src="https://static001.geekbang.org/resource/image/60/7c/6028c83b0aa00e65916988911aa01b7c.png" alt="">
1. 调用semget创建信号量集合。
1. ipc_findkey会在基数树中根据key查找信号量集合sem_array对象。如果已经被创建就会被查询出来。例如producer被创建过在consumer中就会查询出来。
1. 如果信号量集合没有被创建过则调用sem_ops的newary方法创建一个信号量集合对象sem_array。例如在producer中就会新建。
1. 调用semctl(SETALL)初始化信号量。
1. sem_obtain_object_check先从基数树里面找到sem_array对象。
1. 根据用户指定的信号量数组初始化信号量集合也即初始化sem_array对象的struct sem sems[]成员。
1. 调用semop操作信号量。
1. 创建信号量操作结构sem_queue放入队列。
1. 创建undo结构放入链表。
## 课堂练习
现在我们的共享内存、信号量、消息队列都讲完了你是不是觉得它们的API非常相似。为了方便记忆你可以自己整理一个表格列一下这三种进程间通信机制、行为创建xxxget、使用、控制xxxctl、对应的API和系统调用。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,356 @@
<audio id="audio" title="10 | 进程:公司接这么多项目,如何管?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/18/5f/18164999bd57a7863b51f95362c3355f.mp3"></audio>
有了系统调用咱们公司就能开始批量接项目啦对应到Linux操作系统就是可以创建进程了。
在[命令行](https://time.geekbang.org/column/article/88761)那一节我们讲了使用命令创建Linux进程的几种方式。现在学习了系统调用你是不是想尝试一下如何通过写代码使用系统调用创建一个进程呢我们一起来看看。
## 写代码:用系统调用创建进程
在Linux上写程序和编译程序也需要一系列的开发套件就像Visual Studio一样。运行下面的命令就可以在centOS 7操作系统上安装开发套件。在以后的章节里面我们的实验都是基于centOS 7操作系统进行的。
```
yum -y groupinstall &quot;Development Tools&quot;
```
接下来我们要开始写程序了。在Windows上写的程序都会被保存成.h或者.c文件容易让人感觉这是某种有特殊格式的文件但其实这些文件只是普普通通的文本文件。因而在Linux上我们用Vim来创建并编辑一个文件就行了。
我们先来创建一个文件里面用一个函数封装通用的创建进程的逻辑名字叫process.c代码如下
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;unistd.h&gt;
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 &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;unistd.h&gt;
extern int create_process (char* program, char** arg_list);
int main ()
{
char* arg_list[] = {
&quot;ls&quot;,
&quot;-l&quot;,
&quot;/etc/yum.repos.d/&quot;,
NULL
};
create_process (&quot;ls&quot;, arg_list);
return 0;
}
```
在这里我们创建的子程序运行了一个最最简单的命令ls。学过命令行的那一节之后这里你应该很熟悉了。
## 进行编译:程序的二进制格式
程序写完了是不是很简单你可能要问了这是不是就是我们所谓的项目执行计划书了呢当然不是了这两个文件只是文本文件CPU是不能执行文本文件里面的指令的这些指令只有人能看懂CPU能够执行的命令是二进制的比如“0101”这种所以这些指令还需要翻译一下这个翻译的过程就是**编译**Compile。编译好的二进制文件才是项目执行计划书。
现在咱们是正规的公司了,接项目要有章法,项目执行计划书也要有统一的格式,这样才能保证无论项目交到哪个项目组手里,都能以固定的流程执行。按照里面的指令来,项目也能达到预期的效果。
在Linux下面二进制的程序也要有严格的格式这个格式我们称为**ELF**Executeable and Linkable Format可执行与可链接格式。这个格式可以根据编译的结果不同分为不同的格式。
接下来我们看一下,如何从文本文件编译成二进制格式。
<img src="https://static001.geekbang.org/resource/image/85/de/85320245cd80ce61e69c8391958240de.jpeg" alt="">
在上面两段代码中上面include的部分是头文件而我们写的这个.c结尾的是源文件。
接下来我们编译这两个程序。
```
gcc -c -fPIC process.c
gcc -c -fPIC createprocess.c
```
在编译的时候,先做预处理工作,例如将头文件嵌入到正文中,将定义的宏展开,然后就是真正的编译过程,最终编译成为.o文件这就是ELF的第一种类型**可重定位文件**Relocatable File
这个文件的格式是这样的:
<img src="https://static001.geekbang.org/resource/image/e9/d6/e9c2b4c67f8784a8eec7392628ce6cd6.jpg" alt="">
ELF文件的头是用于描述整个文件的。这个文件格式在内核中有定义分别为struct elf32_hdr和struct elf64_hdr。
接下来我们来看一个一个的section我们也叫**节**。这里面的名字有点晦涩,不过你可以猜一下它们是干什么的。
这个编译好的二进制文件里面,应该是代码,还有一些全局变量、静态变量等等。没错,我们依次来看。
<li>
.text放编译好的二进制可执行代码
</li>
<li>
.data已经初始化好的全局变量
</li>
<li>
.rodata只读数据例如字符串常量、const的变量
</li>
<li>
.bss未初始化全局变量运行时会置0
</li>
<li>
.symtab符号表记录的则是函数和变量
</li>
<li>
.strtab字符串表、字符串常量和变量名
</li>
为啥这里只有全局变量呢?其实前面我们讲函数栈的时候说过,局部变量是放在栈里面的,是程序运行过程中随时分配空间,随时释放的,现在我们讨论的是二进制文件,还没启动呢,所以只需要讨论在哪里保存全局变量。
这些节的元数据信息也需要有一个地方保存就是最后的节头部表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的第二种格式格式如下
<img src="https://static001.geekbang.org/resource/image/1d/60/1d8de36a58a98a53352b40efa81e9660.jpg" alt="">
这个格式和.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 TablePLT一个是.got.plt全局偏移量表Global Offset TableGOT
它们是怎么工作的使得程序运行的时候可以将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-&gt;do_execveat_common-&gt;exec_binprm-&gt;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比较特殊它是一组函数
<li>
包含p的函数execvp, execlp会在PATH路径下面寻找程序
</li>
<li>
不包含p的函数需要输入程序的全路径
</li>
<li>
包含v的函数execv, execvp, execve以数组的形式接收参数
</li>
<li>
包含l的函数execl, execlp, execle以列表的形式接收参数
</li>
<li>
包含e的函数execve, execle以数组的形式接收环境变量。
</li>
<img src="https://static001.geekbang.org/resource/image/46/f6/465b740b86ccc6ad3f8e38de25336bf6.jpg" alt="">
在上面process.c的代码中我们创建ls进程也是通过exec。
## 进程树
既然所有的进程都是从父进程fork过来的那总归有一个祖宗进程这就是咱们系统启动的init进程。
<img src="https://static001.geekbang.org/resource/image/4d/16/4de740c10670a92bbaa58348e66b7b16.jpeg" alt="">
在解析Linux的启动过程的时候1号进程是/sbin/init。如果在centOS 7里面我们ls一下可以看到这个进程是被软链接到systemd的。
```
/sbin/init -&gt; ../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进程systemdPID 2的进程是内核线程kthreadd这两个我们在内核启动的时候都见过。其中用户态的不带中括号内核态的带中括号。
接下来进程号依次增大但是你会看所有带中括号的内核态的进程祖先都是2号进程。而用户态的进程祖先都是1号进程。tty那一列是问号的说明不是前台启动的一般都是后台的服务。
pts的父进程是sshdbash的父进程是ptsps -ef这个命令的父进程是bash。这样整个链条都比较清晰了。
## 总结时刻
这一节我们讲了一个进程从代码到二进制到运行时的一个过程,我们用一个图总结一下。
我们首先通过图右边的文件编译过程生成so文件和可执行文件放在硬盘上。下图左边的用户态的进程A执行fork创建进程B在进程B的处理逻辑中执行exec系列系统调用。这个系统调用会通过load_elf_binary方法将刚才生成的可执行文件加载到进程B的内存中执行。
<img src="https://static001.geekbang.org/resource/image/db/a9/dbd8785da6c3ce3fe1abb7bb5934b7a9.jpeg" alt="">
## 课堂练习
对于ELF有几个工具能帮你看这些文件的格式。readelf工具用于分析ELF的信息objdump工具用来显示二进制文件的信息hexdump工具用来查看文件的十六进制编码nm 工具用来显示关于指定文件中符号的信息。你可以尝试用这几个工具,来解析这一节生成的.o, .so 和可执行文件。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,555 @@
<audio id="audio" title="11 | 线程:如何让复杂的项目并行执行?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/c3/969787bd61560a605ca14868dc1eeec3.mp3"></audio>
上一节我们讲了如何创建进程,这一节我们来看如何创建线程。
## 为什么要有线程?
其实,对于任何一个进程来讲,即便我们没有主动去创建线程,进程也是默认有一个主线程的。线程是负责执行二进制指令的,它会根据项目执行计划书,一行一行执行下去。进程要比线程管的宽多了,除了执行指令之外,内存、文件系统等等都要它来管。
所以,**进程相当于一个项目,而线程就是为了完成项目需求,而建立的一个个开发任务**。默认情况下,你可以建一个大的任务,就是完成某某功能,然后交给一个人让它从头做到尾,这就是主线程。但是有时候,你发现任务是可以拆解的,如果相关性没有非常大前后关联关系,就可以并行执行。
例如你接到了一个开发任务要开发200个页面最后组成一个网站。这时候你就可以拆分成20个任务每个任务10个页面并行开发。都开发完了再做一次整合这肯定比依次开发200个页面快多了。
<img src="https://static001.geekbang.org/resource/image/48/9e/485ce8195d241c2a6930803286302e9e.jpg" alt="">
那我们能不能成立多个项目组实现并行开发呢?当然可以了,只不过这样做有两个比较麻烦的地方。
第一个麻烦是,立项。涉及的部门比较多,总是劳师动众。你本来想的是,只要能并行执行任务就可以,不需要把会议室都搞成独立的。另一个麻烦是,项目组是独立的,会议室是独立的,很多事情就不受你控制了,例如一旦有了两个项目组,就会有沟通问题。
所以,使用进程实现并行执行的问题也有两个。第一,创建进程占用资源太多;第二,进程之间的通信需要数据在不同的内存空间传来传去,无法共享。
除了希望任务能够并行执行,有的时候,你作为项目管理人员,肯定要管控风险,因此还会预留一部分人作为应急小分队,来处理紧急的事情。
例如,主线程正在一行一行执行二进制命令,突然收到一个通知,要做一点小事情,应该停下主线程来做么?太耽误事情了,应该创建一个单独的线程,单独处理这些事件。
另外,咱们希望自己的公司越来越有竞争力。要想实现远大的目标,我们不能把所有人力都用在接项目上,应该预留一些人力来做技术积累,比如开发一些各个项目都能用到的共享库、框架等等。
在Linux中有时候我们希望将前台的任务和后台的任务分开。因为有些任务是需要马上返回结果的例如你输入了一个字符不可能五分钟再显示出来而有些任务是可以默默执行的例如将本机的数据同步到服务器上去这个就没刚才那么着急。因此这样两个任务就应该在不同的线程处理以保证互不耽误。
## 如何创建线程?
看来多线程还是有很多好处的。接下来我们来看一下,如何使用线程来干一件大事。
假如说现在我们有N个非常大的视频需要下载一个个下载需要的时间太长了。按照刚才的思路我们可以拆分成N个任务分给N个线程各自去下载。
我们知道,进程的执行是需要项目执行计划书的,那线程是一个项目小组,这个小组也应该有自己的项目执行计划书,也就是一个函数。我们将要执行的子任务放在这个函数里面,比如上面的下载任务。
这个函数参数是void类型的指针用于接收任何类型的参数。我们就可以将要下载的文件的文件名通过这个指针传给它。
为了方便,我将代码整段都贴在这里,这样你把下面的代码放在一个文件里面就能成功编译。
当然,这里我们不是真的下载这个文件,而仅仅打印日志,并生成一个一百以内的随机数,作为下载时间返回。这样,每个子任务干活的同时在喊:“我正在下载,终于下载完了,用了多少时间。”
```
#include &lt;pthread.h&gt;
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#define NUM_OF_TASKS 5
void *downloadfile(void *filename)
{
printf(&quot;I am downloading the file %s!\n&quot;, (char *)filename);
sleep(10);
long downloadtime = rand()%100;
printf(&quot;I finish downloading the file within %d minutes!\n&quot;, downloadtime);
pthread_exit((void *)downloadtime);
}
int main(int argc, char *argv[])
{
char files[NUM_OF_TASKS][20]={&quot;file1.avi&quot;,&quot;file2.rmvb&quot;,&quot;file3.mp4&quot;,&quot;file4.wmv&quot;,&quot;file5.flv&quot;};
pthread_t threads[NUM_OF_TASKS];
int rc;
int t;
int downloadtime;
pthread_attr_t thread_attr;
pthread_attr_init(&amp;thread_attr);
pthread_attr_setdetachstate(&amp;thread_attr,PTHREAD_CREATE_JOINABLE);
for(t=0;t&lt;NUM_OF_TASKS;t++){
printf(&quot;creating thread %d, please help me to download %s\n&quot;, t, files[t]);
rc = pthread_create(&amp;threads[t], &amp;thread_attr, downloadfile, (void *)files[t]);
if (rc){
printf(&quot;ERROR; return code from pthread_create() is %d\n&quot;, rc);
exit(-1);
}
}
pthread_attr_destroy(&amp;thread_attr);
for(t=0;t&lt;NUM_OF_TASKS;t++){
pthread_join(threads[t],(void**)&amp;downloadtime);
printf(&quot;Thread %d downloads the file %s in %d minutes.\n&quot;,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.
```
这里我们画一张图总结一下,一个普通线程的创建和运行过程。
<img src="https://static001.geekbang.org/resource/image/e3/bd/e38c28b0972581d009ef16f1ebdee2bd.jpg" alt="">
## 线程的数据
线程可以将项目并行起来,加快进度,但是也带来的负面影响,过程并行起来了,那数据呢?
我们把线程访问的数据细分成三类。下面我们一一来看。
<img src="https://static001.geekbang.org/resource/image/e7/3f/e7b06dcf431f388170ab0a79677ee43f.jpg" alt="">
第一类是**线程栈上的本地数据**,比如函数执行过程中的局部变量。前面我们说过,函数的调用会使用栈的模型,这在线程里面是一样的。只不过每个线程都有自己的栈空间。
栈的大小可以通过命令ulimit -a查看默认情况下线程栈大小为81928MB。我们可以使用命令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 &lt;pthread.h&gt;
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#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(&quot;Thread %u is transfering money!\n&quot;, (unsigned int)tid);
//第一次运行去掉下面这行
pthread_mutex_lock(&amp;g_money_lock);
sleep(rand()%10);
money_of_tom+=10;
sleep(rand()%10);
money_of_jerry-=10;
//第一次运行去掉下面这行
pthread_mutex_unlock(&amp;g_money_lock);
printf(&quot;Thread %u finish transfering money!\n&quot;, (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(&amp;g_money_lock, NULL);
for(t=0;t&lt;NUM_OF_TASKS;t++){
rc = pthread_create(&amp;threads[t], NULL, transfer, NULL);
if (rc){
printf(&quot;ERROR; return code from pthread_create() is %d\n&quot;, rc);
exit(-1);
}
}
for(t=0;t&lt;100;t++){
//第一次运行去掉下面这行
pthread_mutex_lock(&amp;g_money_lock);
printf(&quot;money_of_tom + money_of_jerry = %d\n&quot;, money_of_tom + money_of_jerry);
//第一次运行去掉下面这行
pthread_mutex_unlock(&amp;g_money_lock);
}
//第一次运行去掉下面这行
pthread_mutex_destroy(&amp;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的使用流程。
<img src="https://static001.geekbang.org/resource/image/0c/be/0ccf37aafa2b287363399e130b2726be.jpg" alt="">
在使用Mutex的时候有个问题是如果使用pthread_mutex_lock()那就需要一直在那里等着。如果是pthread_mutex_trylock(),就可以不用等着,去干点儿别的,但是我怎么知道什么时候回来再试一下,是不是轮到我了呢?能不能在轮到我的时候,通知我一下呢?
这其实就是条件变量,也就是说如果没事儿,就让大家歇着,有事儿了就去通知,别让人家没事儿就来问问,浪费大家的时间。
但是当它接到了通知,来操作共享资源的时候,还是需要抢互斥锁,因为可能很多人都受到了通知,都来访问了,所以**条件变量和互斥锁是配合使用的**。
我这里还是用一个场景给你解释。
你这个老板,招聘了三个员工,但是你不是有了活才去招聘员工,而是先把员工招来,没有活的时候员工需要在那里等着,一旦有了活,你要去通知他们,他们要去抢活干(为啥要抢活?因为有绩效呀!),干完了再等待,你再有活,再通知他们。
具体的样例代码我也放在这里。你可以直接编译运行。
```
#include &lt;pthread.h&gt;
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#define NUM_OF_TASKS 3
#define MAX_TASK_QUEUE 11
char tasklist[MAX_TASK_QUEUE]=&quot;ABCDEFGHIJ&quot;;
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(&amp;g_task_lock);
while(tail == head){
if(quit){
pthread_mutex_unlock(&amp;g_task_lock);
pthread_exit((void *)0);
}
printf(&quot;No task now! Thread %u is waiting!\n&quot;, (unsigned int)tid);
pthread_cond_wait(&amp;g_task_cv, &amp;g_task_lock);
printf(&quot;Have task now! Thread %u is grabing the task !\n&quot;, (unsigned int)tid);
}
char task = tasklist[head++];
pthread_mutex_unlock(&amp;g_task_lock);
printf(&quot;Thread %u has a task %c now!\n&quot;, (unsigned int)tid, task);
sleep(5);
printf(&quot;Thread %u finish the task %c!\n&quot;, (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(&amp;g_task_lock, NULL);
pthread_cond_init(&amp;g_task_cv, NULL);
for(t=0;t&lt;NUM_OF_TASKS;t++){
rc = pthread_create(&amp;threads[t], NULL, coder, NULL);
if (rc){
printf(&quot;ERROR; return code from pthread_create() is %d\n&quot;, rc);
exit(-1);
}
}
sleep(5);
for(t=1;t&lt;=4;t++){
pthread_mutex_lock(&amp;g_task_lock);
tail+=t;
printf(&quot;I am Boss, I assigned %d tasks, I notify all coders!\n&quot;, t);
pthread_cond_broadcast(&amp;g_task_cv);
pthread_mutex_unlock(&amp;g_task_lock);
sleep(20);
}
pthread_mutex_lock(&amp;g_task_lock);
quit = 1;
pthread_cond_broadcast(&amp;g_task_cv);
pthread_mutex_unlock(&amp;g_task_lock);
pthread_mutex_destroy(&amp;g_task_lock);
pthread_cond_destroy(&amp;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是用于通知和抢任务的工作模式如下图所示
<img src="https://static001.geekbang.org/resource/image/1d/f7/1d4e17fdb1860f7ca7f23bbe682d93f7.jpeg" alt="">
图中左边的就是员工的工作模式对于每一个员工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使用的套路、条件变量使用的套路。
<img src="https://static001.geekbang.org/resource/image/02/58/02a774d7c0f83bb69fec4662622d6d58.png" alt="">
## 课堂练习
这一节讲了多线程编程的套路,但是我没有对于每一个函数进行详细的讲解,相关的还有很多其他的函数可以调用,这需要你自己去学习。这里我给你推荐一本书$Programming with POSIX<br>
Threads$,你可以系统地学习一下。另外,上面的代码,建议你一定要上手编译运行一下。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,219 @@
<audio id="audio" title="12 | 进程数据结构(上):项目多了就需要项目管理系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/73/44/732f131303e8a319d25f1338def3db44.mp3"></audio>
前面两节,我们讲了如何使用系统调用,创建进程和线程。你是不是觉得进程和线程管理,还挺复杂的呢?如此复杂的体系,在内核里面应该如何管理呢?
有的进程只有一个线程有的进程有多个线程它们都需要由内核分配CPU来干活。可是CPU总共就这么几个应该怎么管理怎么调度呢你是老板这个事儿得你来操心。
首先,我们得明确,公司的项目售前售后人员,接来了这么多的项目,这是个好事儿。这些项目都通过办事大厅立了项的,有的需要整个项目组一起开发,有的是一个项目组分成多个小组并行开发。无论哪种模式,到你这个老板这里,都需要有一个项目管理体系,进行统一排期、统一管理和统一协调。这样,你才能对公司的业务了如指掌。
那具体应该怎么做呢还记得咱们平时开发的时候用的项目管理软件Jira吧它的办法对我们来讲就很有参考意义。
我们这么来看,其实,无论是一个大的项目组一起完成一个大的功能(单体应用模式),还是把一个大的功能拆成小的功能并行开发(微服务模式),这些都是开发组根据客户的需求来定的,项目经理没办法决定,但是从项目经理的角度来看,这些都是任务,需要同样关注进度、协调资源等等。
同样在Linux里面无论是进程还是线程到了内核里面我们统一都叫任务Task由一个统一的结构**task_struct**进行管理。这个结构非常复杂,但你也不用怕,我们慢慢来解析。
<img src="https://static001.geekbang.org/resource/image/75/2d/75c4d28a9d2daa4acc1107832be84e2d.jpeg" alt="">
接下来,我们沿着建立项目管理体系的思路,设想一下,**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 idtgid是thread group ID。
任何一个进程如果只有主线程那pid是自己tgid是自己group_leader指向的还是自己。
但是如果一个进程创建了其他线程那就会有所变化了。线程有自己的pidtgid就是进程的主线程的pidgroup_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里面任务的运行就可以分成下面的状态。
<img src="https://static001.geekbang.org/resource/image/e0/21/e0019fcd11ff1ba33a3389e285b6a121.jpg" alt="">
在task_struct里面涉及任务状态的是下面这几个变量
```
volatile long state; /* -1 unrunnable, 0 runnable, &gt;0 stopped */
int exit_state;
unsigned int flags;
```
state状态可以取的值定义在include/linux/sched.h头文件中。
```
/* Used in tsk-&gt;state: */
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define __TASK_STOPPED 4
#define __TASK_TRACED 8
/* Used in tsk-&gt;exit_state: */
#define EXIT_DEAD 16
#define EXIT_ZOMBIE 32
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk-&gt;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
```
从定义的数值很容易看出来state是通过bitset的方式设置的也就是说当前是什么状态哪一位就置一。
<img src="https://static001.geekbang.org/resource/image/e2/88/e2fa348c67ce41ef730048ff9ca4c988.jpeg" alt="">
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的结构图。其中红色的部分是今天讲的部分你可以对着这张图说出它们的含义。
<img src="https://static001.geekbang.org/resource/image/01/e8/016ae7fb63f8b3fd0ca072cb9964e3e8.jpeg" alt="">
## 课堂练习
这一节我们讲了任务的状态,你可以试着在代码里面搜索一下这些状态改变的地方是哪个函数,是什么时机,从而进一步理解任务的概念。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,190 @@
<audio id="audio" title="13 | 进程数据结构(中):项目多了就需要项目管理系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3a/8e/3a23420976c838a0f7f0d1a8e2c7828e.mp3"></audio>
上一节我们讲了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 */
```
<li>
parent指向其父进程。当它终止时必须向它的父进程发送信号。
</li>
<li>
children表示链表的头部。链表中的所有元素都是它的子进程。
</li>
<li>
sibling用于把当前进程插入到兄弟链表中。
</li>
<img src="https://static001.geekbang.org/resource/image/92/04/92711107d8dcdf2c19e8fe4ee3965304.jpeg" alt="">
通常情况下real_parent和parent是一样的但是也会有另外的情况存在。例如bash创建一个进程那进程的parent和real_parent就都是bash。如果在bash上使用GDB来debug一个进程这个时候GDB是parentbash是这个进程的real_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也是一样的。因为谁启动的进程就应该审核启动的用户到底有没有这个权限。
但是也有特殊的情况。
<img src="https://static001.geekbang.org/resource/image/c4/f7/c4688c36afd90f933727483c56500ff7.jpeg" alt="">
例如用户A想玩一个游戏这个游戏的程序是用户B安装的。游戏这个程序文件的权限为rwxrr--。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;
```
## 总结时刻
这一节,我们终于把进程管理复杂的数据结构基本讲完了,请你重点记住以下两点:
<li>
进程亲缘关系维护的数据结构,是一种很有参考价值的实现方式,在内核中会多个地方出现类似的结构;
</li>
<li>
进程权限中setuid的原理这一点比较难理解但是很重要面试经常会考。
</li>
你可以对着下面这张图,看看自己是否真的理解了,进程树是如何组织的,以及如何控制进程的权限的。
<img src="https://static001.geekbang.org/resource/image/1c/bc/1c91956b52574b62a4418a7c6993d8bc.jpeg" alt="">
## 课堂练习
通过这一节的学习,你会发现,一个进程的运行竟然要保存这么多信息,这些信息都可以通过命令行取出来,所以今天的练习题就是,对于一个正在运行的进程,通过命令行找到上述进程运行的所有信息。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,346 @@
<audio id="audio" title="14 | 进程数据结构(下):项目多了就需要项目管理系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1a/7b/1af93ca1d507yy4eebf050eb27b7577b.mp3"></audio>
上两节我们解读了task_struct的大部分的成员变量。这样一个任务执行的方方面面都可以很好地管理起来但是其中有一个问题我们没有谈。在程序执行过程中一旦调用到系统调用就需要进入内核继续执行。那如何将用户态的执行和内核态的执行串起来呢
这就需要以下两个重要的成员变量:
```
struct thread_info thread_info;
void *stack;
```
## 用户态函数栈
在用户态中,程序的执行往往是一个函数调用另一个函数。函数调用都是通过栈来进行的。我们前面大致讲过函数栈的原理,今天我们仔细分析一下。
函数调用其实也很简单。如果你去看汇编语言的代码,其实就是指令跳转,从代码的一个地方跳到另外一个地方。这里比较棘手的问题是,参数和返回地址应该怎么传递过去呢?
我们看函数的调用过程A调用B、调用C、调用D然后返回C、返回B、返回A这是一个后进先出的过程。有没有觉得这个过程很熟悉没错咱们数据结构里学的栈也是后进先出的所以用栈保存这些最合适。
在进程的内存空间里面,栈是一个从高地址到低地址,往下增长的结构,也就是上面是栈底,下面是栈顶,入栈和出栈的操作都是从下面的栈顶开始的。
<img src="https://static001.geekbang.org/resource/image/ae/2e/aec865abccf0308155f4138cc905972e.jpg" alt="">
我们先来看32位操作系统的情况。在CPU里**ESP**Extended Stack Pointer是栈顶指针寄存器入栈操作Push和出栈操作Pop指令会自动调整ESP的值。另外有一个寄存器**EBP**Extended Base Pointer是栈基地址指针寄存器指向当前栈帧的最底部。
例如A调用BA的栈里面包含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个参数有时候需要进行寻址但是如果在寄存器里面是没有地址的因而还是会放到栈里面只不过放到栈里面的操作是被调用函数做的。
<img src="https://static001.geekbang.org/resource/image/77/c0/770b0036a8b2695463cd95869f5adec0.jpg" alt="">
以上的栈操作,都是在进程的内存空间里面进行的。
## 内核态函数栈
接下来,我们通过系统调用,从进程的内存空间到内核中了。内核中也有各种各样的函数调用来调用去的,也需要这样一个机制,这该怎么办呢?
这时候上面的成员变量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 &lt;&lt; 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 &lt;&lt; THREAD_SIZE_ORDER)
```
内核栈是一个非常特殊的结构,如下图所示:
<img src="https://static001.geekbang.org/resource/image/31/2d/31d15bcd2a053235b5590977d12ffa2d.jpeg" alt="">
这段空间的最低位置是一个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-&gt;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 &quot;struct pt_regs&quot;
* 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
* &quot;struct pt_regs&quot; 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()-&gt;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 &lt;asm/current.h&gt;
#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) = &amp;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(&quot;mov&quot;, var)
```
好了现在如果你是一个进程正在某个CPU上运行就能够轻松得到task_struct了。
## 总结时刻
这一节虽然只介绍了内核栈但是内容更加重要。如果说task_struct的其他成员变量都是和进程管理有关的内核栈是和进程运行有关系的。
我这里画了一张图总结一下32位和64位的工作模式左边是32位的右边是64位的。
<li>
在用户态应用程序进行了至少一次函数调用。32位和64的传递参数的方式稍有不同32位的就是用函数栈64位的前6个参数用寄存器其他的用函数栈。
</li>
<li>
在内核态32位和64位都使用内核栈格式也稍有不同主要集中在pt_regs结构上。
</li>
<li>
在内核态32位和64位的内核栈和task_struct的关联关系不同。32位主要靠thread_info64位主要靠Per-CPU变量。
</li>
<img src="https://static001.geekbang.org/resource/image/82/5c/82ba663aad4f6bd946d48424196e515c.jpeg" alt="">
## 课堂练习
这一节讲函数调用的时候,我们讲了函数栈的工作模式。请你写一个程序,然后编译为汇编语言,打开看一下,函数栈是如何起作用的。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,441 @@
<audio id="audio" title="15 | 调度(上):如何制定项目管理流程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/14/f8/14216d5747af1f835f328943490637f8.mp3"></audio>
前几节我们介绍了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;
```
优先级其实就是一个数值对于实时进程优先级的范围是099对于普通进程优先级的范围是100139。数值越小优先级越高。从这里可以看出所有的实时进程都比普通进程优先级要高。毕竟谁让人家加钱了呢。
### 实时调度策略
对于调度策略其中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有几种实现
<li>
stop_sched_class优先级最高的任务会使用这种策略会中断所有其他线程且不会被其他任务打断
</li>
<li>
dl_sched_class就对应上面的deadline调度策略
</li>
<li>
rt_sched_class就对应RR算法或者FIFO算法的调度策略具体调度策略由进程的task_struct-&gt;policy指定
</li>
<li>
fair_sched_class就是普通进程的调度策略
</li>
<li>
idle_sched_class就是空闲进程的调度策略。
</li>
这里实时进程的调度策略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-&gt;curr;
u64 now = rq_clock_task(rq_of(cfs_rq));
u64 delta_exec;
......
delta_exec = now - curr-&gt;exec_start;
......
curr-&gt;exec_start = now;
......
curr-&gt;sum_exec_runtime += delta_exec;
......
curr-&gt;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-&gt;load.weight != NICE_0_LOAD))
/* delta_exec * weight / lw.weight */
delta = __calc_delta(delta, NICE_0_LOAD, &amp;se-&gt;load);
return delta;
}
```
在这里得到当前的时间以及这次的时间片开始的时间两者相减就是这次运行的时间delta_exec 但是得到的这个时间其实是实际运行的时间需要做一定的转化才作为虚拟运行时间vruntime。转化方法如下
这就是说同样的实际运行时间给高权重的算少了低权重的算多了但是当选取下一个运行进程的时候还是按照最小的vruntime来的这样高权重的获得的实际运行时间自然就多了。这就相当于给一个体重(权重)200斤的胖子吃两个馒头和给一个体重100斤的瘦子吃一个馒头然后说你们两个吃的是一样多。这样虽然总体胖子比瘦子多吃了一倍但是还是公平的。
## 调度队列与调度实体
看来CFS需要一个数据结构来对vruntime进行排序找出最小的那个。这个能够排序的数据结构不但需要查询的时候能够快速找到最小的更新的时候也需要能够快速地调整排序要知道vruntime可是经常在变的变了再插入这个数据结构就需要重新排序。
能够平衡查询和更新速度的是树,在这里使用的是红黑树。
红黑树的的节点是应该包括vruntime的称为调度实体。
在task_struct中有这样的成员变量
struct sched_entity se;<br>
struct sched_rt_entity rt;<br>
struct sched_dl_entity dl;
这里有实时调度实体sched_rt_entityDeadline调度实体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;
......
};
```
下图是一个红黑树的例子。
<img src="https://static001.geekbang.org/resource/image/c2/93/c2b86e79f19d811ce10774688fc0c093.jpeg" alt="">
所有可运行的进程通过不断地插入操作最终都存储在以时间为顺序的红黑树中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指向的是最左面的节点。
到这里终于凑够数据结构了,上面这些数据结构的关系如下图:
<img src="https://static001.geekbang.org/resource/image/ac/fd/ac043a08627b40b85e624477d937f3fd.jpeg" alt="">
## 调度类是如何工作的?
凑够了数据结构,接下来我们来看调度类是如何工作的。
调度类的定义如下:
```
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-&gt;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 = &amp;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_fairrt_sched_class的实现是pick_next_task_rt。
我们会发现这两个函数是操作不同的队列pick_next_task_rt操作的是rt_rqpick_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 = &amp;rq-&gt;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 = &amp;rq-&gt;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定义的与调度有关的函数。
<li>
enqueue_task向就绪队列中添加一个进程当某个进程进入可运行状态时调用这个函数
</li>
<li>
dequeue_task 将一个进程从就绪队列中删除;
</li>
<li>
pick_next_task 选择接下来要运行的进程;
</li>
<li>
put_prev_task 用另一个进程代替当前运行的进程;
</li>
<li>
set_curr_task 用于修改调度策略;
</li>
<li>
task_tick 每次周期性时钟到的时候,这个函数被调用,可能触发调度。
</li>
在这里面我们重点看fair_sched_class对于pick_next_task的实现pick_next_task_fair获取下一个进程。调用路径如下pick_next_task_fair-&gt;pick_next_entity-&gt;__pick_first_entity。
```
struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
struct rb_node *left = rb_first_cached(&amp;cfs_rq-&gt;tasks_timeline);
if (!left)
return NULL;
return rb_entry(left, struct sched_entity, run_node);
```
从这个函数的实现可以看出,就是从红黑树里面取最左面的节点。
## 总结时刻
好了这一节我们讲了调度相关的数据结构还是比较复杂的。一个CPU上有一个队列CFS的队列是一棵红黑树树的每一个节点都是一个sched_entity每个sched_entity都属于一个task_structtask_struct里面有指针指向这个进程属于哪个调度类。
<img src="https://static001.geekbang.org/resource/image/10/af/10381dbafe0f78d80beb87560a9506af.jpeg" alt="">
在调度的时候依次调用调度类的函数从CPU的队列中取出下一个进程。上面图中的调度器、上下文切换这一节我们没有讲下一节我们讲讲基于这些数据结构如何实现调度。
## 课堂练习
这里讲了进程调度的策略和算法你知道如何通过API设置进程和线程的调度策略吗你可以写个程序尝试一下。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,432 @@
<audio id="audio" title="16 | 调度(中):主动调度是如何发生的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9c/53/9c66465f00cbc22674ca5ac1220ef853.mp3"></audio>
上一节,我们为调度准备了这么多的数据结构,这一节我们来看调度是如何发生的。
所谓进程调度其实就是一个人在做A项目在某个时刻换成做B项目去了。发生这种情况主要有两种方式。
**方式一**A项目做着做着发现里面有一条指令sleep也就是要休息一下或者在等待某个I/O事件。那没办法了就要主动让出CPU然后可以开始做B项目。
**方式二**A项目做着做着旷日持久实在受不了了。项目经理介入了说这个项目A先停停B项目也要做一下要不然B项目该投诉了。
## 主动调度
我们这一节先来看方式一,主动调度。
这里我找了几个代码片段。**第一个片段是Btrfs等待一个写入**。[B](https://zh.wikipedia.org/wiki/Btrfs)[trfs](https://zh.wikipedia.org/wiki/Btrfs)B-Tree是一种文件系统感兴趣你可以自己去了解一下。
这个片段可以看作写入块设备的一个典型场景。写入需要一段时间这段时间用不上CPU还不如主动让给其他进程。
```
static void btrfs_wait_for_no_snapshoting_writes(struct btrfs_root *root)
{
......
do {
prepare_to_wait(&amp;root-&gt;subv_writers-&gt;wait, &amp;wait,
TASK_UNINTERRUPTIBLE);
writers = percpu_counter_sum(&amp;root-&gt;subv_writers-&gt;counter);
if (writers)
schedule();
finish_wait(&amp;root-&gt;subv_writers-&gt;wait, &amp;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(&amp;q-&gt;sk), &amp;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-&gt;curr;
......
```
首先在当前的CPU上我们取出任务队列rq。
task_struct *prev指向这个CPU的任务队列上面正在运行的那个进程curr。为啥是prev因为一旦将来它被切换下来那它就成了前任了。
接下来代码如下:
```
next = pick_next_task(rq, prev, &amp;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-&gt;sched_class == &amp;idle_sched_class ||
prev-&gt;sched_class == &amp;fair_sched_class) &amp;&amp;
rq-&gt;nr_running == rq-&gt;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-&gt;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-&gt;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 = &amp;rq-&gt;cfs;
struct sched_entity *se;
struct task_struct *p;
int new_tasks;
```
对于CFS调度类取出相应的队列cfs_rq这就是我们上一节讲的那棵红黑树。
```
struct sched_entity *curr = cfs_rq-&gt;curr;
if (curr) {
if (curr-&gt;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 = &amp;prev-&gt;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-&gt;nr_switches++;
rq-&gt;curr = next;
++*switch_count;
......
rq = context_switch(rq, prev, next, &amp;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-&gt;mm;
oldmm = prev-&gt;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 = &amp;prev_p-&gt;thread;
struct thread_struct *next = &amp;next_p-&gt;thread;
......
int cpu = smp_processor_id();
struct tss_struct *tss = &amp;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希望在内存里面维护一个TSSTask State Segment任务状态段结构。这里面有所有的寄存器。
另外还有一个特殊的寄存器TRTask Register任务寄存器指向某个进程的TSS。更改TR的值将会触发硬件保存CPU所有寄存器的值到当前进程的TSS中然后从新进程的TSS中读出所有寄存器值加载到CPU对应的寄存器中。
下图就是32位的TSS结构。
<img src="https://static001.geekbang.org/resource/image/df/64/dfa9762cfec16822ec74d53350db4664.png" alt="">
但是这样有个缺点。我们做进程切换的时候没必要每个寄存器都切换这样每个进程一个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 = &amp;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是Anext是B。但是A执行完__switch_to_asm之后就被切换走了当C再次切换到A的时候运行到__switch_to_asm是从C的内核栈运行的。这个时候prev是Cnext是A但是__switch_to_asm里面切换成为了A当时的内核栈。
还记得当年的场景“prev是Anext是B”__switch_to_asm里面return prev的时候还没return的时候prev这个变量里面放的还是C因而它会把C放到返回结果中。但是一旦return就会弹出A当时的内核栈。这个时候prev变量就变成了Anext变量就变成了B。这就还原了当年的场景好在返回值里面的last还是C。
通过三个变量switch_to(prev = A, next=B, last=C)A进程就明白了我当时被切换走的时候是切换成B这次切换回来是从C回来的。
## 总结时刻
这一节我们讲主动调度的过程也即一个运行中的进程主动调用__schedule让出CPU。在__schedule里面会做两件事情第一是选取下一个进程第二是进行上下文切换。而上下文切换又分用户态进程空间的切换和内核态的切换。
<img src="https://static001.geekbang.org/resource/image/9f/64/9f4433e82c78ed5cd4399b4b116a9064.png" alt="">
## 课堂练习
你知道应该用什么命令查看进程的运行时间和上下文切换次数吗?
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。

View File

@@ -0,0 +1,270 @@
<audio id="audio" title="17 | 调度(下):抢占式调度是如何发生的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/87/e9701600d287e4fb9aea3b6c1d342b87.mp3"></audio>
上一节我们讲了主动调度就是进程运行到一半因为等待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-&gt;curr;
......
curr-&gt;sched_class-&gt;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 = &amp;curr-&gt;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-&gt;nr_running &gt; 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-&gt;sum_exec_runtime - curr-&gt;prev_sum_exec_runtime;
if (delta_exec &gt; ideal_runtime) {
resched_curr(rq_of(cfs_rq));
return;
}
......
se = __pick_first_entity(cfs_rq);
delta = curr-&gt;vruntime - se-&gt;vruntime;
if (delta &lt; 0)
return;
if (delta &gt; ideal_runtime)
resched_curr(rq_of(cfs_rq));
}
```
check_preempt_tick先是调用sched_slice函数计算出的ideal_runtime。ideal_runtime是一个调度周期中该进程运行的实际时间。
sum_exec_runtime指进程总共执行的实际时间prev_sum_exec_runtime指上次该进程被调度时已经占用的实际时间。每次在调度一个新的进程时都会把它的se-&gt;prev_sum_exec_runtime = se-&gt;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-&gt;state = TASK_RUNNING;
trace_sched_wakeup(p);
```
到这里,你会发现,抢占问题只做完了一半。就是标识当前运行中的进程应该被抢占了,但是真正的抢占动作并没有发生。
## 抢占的时机
真正的抢占还需要时机也就是需要那么一个时刻让正在运行中的进程有机会调用一下__schedule。
你可以想象不可能某个进程代码运行着突然要去调用__schedule代码里面不可能这么写所以一定要规划几个时机这个时机分为用户态和内核态。
### 用户态的抢占时机
对于用户态的进程来讲,从系统调用中返回的那个时刻,是一个被抢占的时机。
前面讲系统调用的时候64位的系统调用的链路位do_syscall_64-&gt;syscall_return_slowpath-&gt;prepare_exit_to_usermode-&gt;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 &amp; _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-&gt;preempt_schedule_common-&gt;__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 &amp;&amp;
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的执行过程这是上一节讲的因为要切换的东西比较多需要你详细了解每一部分是如何切换的。
第二条总结了标记为可抢占的场景,第三条是所有的抢占发生的时机,这里是真正验证了进程调度第一定律的。
<img src="https://static001.geekbang.org/resource/image/93/7f/93588d71abd7f007397979f0ba7def7f.png" alt="">
## 课堂练习
通过对于内核中进程调度的分析我们知道时间对于调度是很重要的你知道Linux内核是如何管理和度量时间的吗
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,320 @@
<audio id="audio" title="18 | 进程的创建:如何发起一个新项目?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/92/1d98f9d47fc8deb4c2fe30774ddcaf92.mp3"></audio>
前面我们学习了如何使用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 &amp; 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的结构图拿出来对比着看如何一个个复制。
<img src="https://static001.geekbang.org/resource/image/fd/1d/fda98b6c68605babb2036bf91782311d.png" alt="">
```
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主要做了下面几件事情
<li>
调用alloc_task_struct_node分配一个task_struct结构
</li>
<li>
调用alloc_thread_stack_node来创建内核栈这里面调用__vmalloc_node_range分配一个连续的THREAD_SIZE的内存空间赋值给task_struct的void *stack成员变量
</li>
<li>
调用arch_dup_task_struct(struct task_struct *dst, struct task_struct *src)将task_struct进行复制其实就是调用memcpy
</li>
<li>
调用setup_thread_stack设置thread_info。
</li>
到这里整个task_struct复制了一份而且内核栈也创建好了。
我们再接着看copy_process。
```
retval = copy_creds(p, clone_flags);
```
轮到权限相关了copy_creds主要做了下面几件事情
<li>
调用prepare_creds准备一个新的struct cred *new。如何准备呢其实还是从内存中分配一个新的struct cred结构然后调用memcpy复制一份父进程的cred
</li>
<li>
接着p-&gt;cred = p-&gt;real_cred = get_cred(new)将新进程的“我能操作谁”和“谁能操作我”两个权限都指向新的cred。
</li>
接下来copy_process重新设置进程运行的统计量。
```
p-&gt;utime = p-&gt;stime = p-&gt;gtime = 0;
p-&gt;start_time = ktime_get_ns();
p-&gt;real_start_time = ktime_get_boot_ns();
```
接下来copy_process开始设置调度相关的变量。
```
retval = sched_fork(clone_flags, p);
```
sched_fork主要做了下面几件事情
<li>
调用__sched_fork在这里面将on_rq设为0初始化sched_entity将里面的exec_start、sum_exec_runtime、prev_sum_exec_runtime、vruntime都设为0。你还记得吗这几个变量涉及进程的实际运行时间和虚拟运行时间。是否到时间应该被调度了就靠它们几个
</li>
<li>
设置进程的状态p-&gt;state = TASK_NEW
</li>
<li>
初始化优先级prio、normal_prio、static_prio
</li>
<li>
设置调度类如果是普通进程就设置为p-&gt;sched_class = &amp;fair_sched_class
</li>
<li>
调用调度类的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也就是说把父进程设置为应该被调度这样下次调度的时候父进程会被子进程抢占。
</li>
接下来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(&amp;p-&gt;pending);
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
```
copy_sighand会分配一个新的sighand_struct。这里最主要的是维护信号处理函数在copy_sighand里面会调用memcpy将信号处理函数sighand-&gt;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设置tidgroup_leader并且建立进程之间的亲缘关系。
```
INIT_LIST_HEAD(&amp;p-&gt;children);
INIT_LIST_HEAD(&amp;p-&gt;sibling);
......
p-&gt;pid = pid_nr(pid);
if (clone_flags &amp; CLONE_THREAD) {
p-&gt;exit_signal = -1;
p-&gt;group_leader = current-&gt;group_leader;
p-&gt;tgid = current-&gt;tgid;
} else {
if (clone_flags &amp; CLONE_PARENT)
p-&gt;exit_signal = current-&gt;group_leader-&gt;exit_signal;
else
p-&gt;exit_signal = (clone_flags &amp; CSIGNAL);
p-&gt;group_leader = p;
p-&gt;tgid = p-&gt;pid;
}
......
if (clone_flags &amp; (CLONE_PARENT|CLONE_THREAD)) {
p-&gt;real_parent = current-&gt;real_parent;
p-&gt;parent_exec_id = current-&gt;parent_exec_id;
} else {
p-&gt;real_parent = current;
p-&gt;parent_exec_id = current-&gt;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-&gt;state = TASK_RUNNING;
......
activate_task(rq, p, ENQUEUE_NOCLOCK);
p-&gt;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-&gt;sched_class-&gt;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 = &amp;p-&gt;se;
......
cfs_rq = cfs_rq_of(se);
enqueue_entity(cfs_rq, se, flags);
......
cfs_rq-&gt;h_nr_running++;
......
}
```
在enqueue_task_fair中取出的队列就是cfs_rq然后调用enqueue_entity。
在enqueue_entity函数里面会调用update_curr更新运行的统计量然后调用__enqueue_entity将sched_entity加入到红黑树里面然后将se-&gt;on_rq = 1设置在队列上。
回到enqueue_task_fair后将这个队列上运行的进程数目加一。然后wake_up_new_task会调用check_preempt_curr看是否能够抢占当前进程。
在check_preempt_curr中会调用相应的调度类的rq-&gt;curr-&gt;sched_class-&gt;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-&gt;curr;
struct sched_entity *se = &amp;curr-&gt;se, *pse = &amp;p-&gt;se;
struct cfs_rq *cfs_rq = task_cfs_rq(curr);
......
if (test_tsk_need_resched(curr))
return;
......
find_matching_se(&amp;se, &amp;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了。
<img src="https://static001.geekbang.org/resource/image/9d/58/9d9c5779436da40cabf8e8599eb85558.jpeg" alt="">
## 课堂练习
你可以试着设置sysctl_sched_child_runs_first参数然后使用系统调用写程序创建进程看看执行结果。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,489 @@
<audio id="audio" title="19 | 线程的创建:如何执行一个新子项目?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a3/36/a3a57522202a3443cd08a6e86eb9b736.mp3"></audio>
上一节,我们了解了进程创建的整个过程,今天我们来看线程创建的过程。
我们前面已经写过多线程编程的程序了你应该都知道创建一个线程调用的是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 = &amp;default_attr;
}
```
接下来就像在内核里一样每一个进程或者线程都有一个task_struct结构在用户态也有一个用于维护线程的结构就是这个pthread结构。
```
struct pthread *pd = NULL;
```
凡是涉及函数的调用,都要使用到栈。每个线程也有自己的栈。那接下来就是创建线程栈了。
```
int err = ALLOCATE_STACK (iattr, &amp;pd);
```
ALLOCATE_STACK是一个宏我们找到它的定义之后发现它其实就是一个函数。只是这个函数有些复杂所以我这里把主要的代码列一下。
```
# define ALLOCATE_STACK(attr, pd) allocate_stack (attr, pd, &amp;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-&gt;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) &amp; PF_X) ? PROT_EXEC : 0));
/* Adjust the stack size for alignment. */
size &amp;= ~__static_tls_align_m1;
/* Make sure the size of the stack is enough for the guard and
eventually the thread descriptor. */
guardsize = (attr-&gt;guardsize + pagesize_m1) &amp; ~pagesize_m1;
size += guardsize;
pd = get_cached_stack (&amp;size, &amp;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) &amp; ~__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-&gt;stackblock = mem;
pd-&gt;stackblock_size = size;
pd-&gt;guardsize = guardsize;
pd-&gt;specific[0] = pd-&gt;specific_1stblock;
/* And add to the list of stacks in use. */
stack_list_add (&amp;pd-&gt;list, &amp;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主要做了以下这些事情
<li>
如果你在线程属性里面设置过栈的大小,需要你把设置的值拿出来;
</li>
<li>
为了防止栈的访问越界在栈的末尾会有一块空间guardsize一旦访问到这里就错误了
</li>
<li>
其实线程栈是在进程的堆里面创建的。如果一个进程不断地创建和删除线程我们不可能不断地去申请和清除线程栈使用的内存块这样就需要有一个缓存。get_cached_stack就是根据计算出来的size大小看一看已经有的缓存中有没有已经能够满足条件的
</li>
<li>
如果缓存里面没有就需要调用__mmap创建一块新的系统调用那一节我们讲过如果要在堆里面malloc一块内存比较大的话用__mmap
</li>
<li>
线程栈也是自顶向下生长的还记得每个线程要有一个pthread结构这个结构也是放在栈的空间里面的。在栈底的位置其实是地址最高位
</li>
<li>
计算出guard内存的位置调用setup_stack_prot设置这块内存的是受保护的
</li>
<li>
接下来开始填充pthread这个结构里面的成员变量stackblock、stackblock_size、guardsize、specific。这里的specific是用于存放Thread Specific Data的也即属于线程的全局变量
</li>
<li>
将这个线程栈放到stack_used链表中其实管理线程栈总共有两个链表一个是stack_used也就是这个栈正被使用另一个是stack_cache就是上面说的一旦线程结束先缓存起来不释放等有其他的线程创建的时候给其他的线程用。
</li>
搞定了用户态栈的问题,其实用户态的事情基本搞定了一半。
## 内核态创建任务
接下来我们接着pthread_create看。其实有了用户态的栈接着需要解决的就是用户态的程序从哪里开始运行的问题。
```
pd-&gt;start_routine = start_routine;
pd-&gt;arg = arg;
pd-&gt;schedpolicy = self-&gt;schedpolicy;
pd-&gt;schedparam = self-&gt;schedparam;
/* Pass the descriptor to the caller. */
*newthread = (pthread_t) pd;
atomic_increment (&amp;__nptl_nthreads);
retval = create_thread (pd, iattr, &amp;stopped_start, STACK_VARIABLES_ARGS, &amp;thread_ran);
```
start_routine就是咱们给线程的函数start_routinestart_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 (&amp;start_thread, STACK_VARIABLES_ARGS, clone_flags, pd, &amp;pd-&gt;tid, tp, &amp;pd-&gt;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-&gt;files;
if (clone_flags &amp; CLONE_FILES) {
atomic_inc(&amp;oldf-&gt;count);
goto out;
}
newf = dup_fd(oldf, &amp;error);
tsk-&gt;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-&gt;fs;
if (clone_flags &amp; CLONE_FS) {
fs-&gt;users++;
return 0;
}
tsk-&gt;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 &amp; CLONE_SIGHAND) {
atomic_inc(&amp;current-&gt;sighand-&gt;count);
return 0;
}
sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
atomic_set(&amp;sig-&gt;count, 1);
memcpy(sig-&gt;action, current-&gt;sighand-&gt;action, sizeof(sig-&gt;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 &amp; CLONE_THREAD)
return 0;
sig = kmem_cache_zalloc(signal_cachep, GFP_KERNEL);
tsk-&gt;signal = sig;
init_sigpending(&amp;sig-&gt;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-&gt;mm;
if (clone_flags &amp; CLONE_VM) {
mmget(oldmm);
mm = oldmm;
goto good_mm;
}
mm = dup_mm(tsk);
good_mm:
tsk-&gt;mm = mm;
tsk-&gt;active_mm = mm;
return 0;
}
```
第二个就是**对于亲缘关系的影响**,毕竟我们要识别多个线程是不是属于一个进程。
```
p-&gt;pid = pid_nr(pid);
if (clone_flags &amp; CLONE_THREAD) {
p-&gt;exit_signal = -1;
p-&gt;group_leader = current-&gt;group_leader;
p-&gt;tgid = current-&gt;tgid;
} else {
if (clone_flags &amp; CLONE_PARENT)
p-&gt;exit_signal = current-&gt;group_leader-&gt;exit_signal;
else
p-&gt;exit_signal = (clone_flags &amp; CSIGNAL);
p-&gt;group_leader = p;
p-&gt;tgid = p-&gt;pid;
}
/* CLONE_PARENT re-uses the old parent */
if (clone_flags &amp; (CLONE_PARENT|CLONE_THREAD)) {
p-&gt;real_parent = current-&gt;real_parent;
p-&gt;parent_exec_id = current-&gt;parent_exec_id;
} else {
p-&gt;real_parent = current;
p-&gt;parent_exec_id = current-&gt;self_exec_id;
}
```
从上面的代码可以看出使用了CLONE_THREAD标识位之后使得亲缘关系有了一定的变化。
<li>
如果是新进程那这个进程的group_leader就是它自己tgid是它自己的pid这就完全重打锣鼓另开张了自己是线程组的头。如果是新线程group_leader是当前进程的group_leadertgid是当前进程的tgid也就是当前进程的pid这个时候还是拜原来进程为老大。
</li>
<li>
如果是新进程新进程的real_parent是当前的进程在进程树里面又见一辈人如果是新线程线程的real_parent是当前的进程的real_parent其实是平辈的。
</li>
第三,**对于信号的处理**如何保证发给进程的信号虽然可以被一个线程处理但是影响范围应该是整个进程的。例如kill一个进程则所有线程都要被干掉。如果一个信号是发给一个线程的pthread_kill则应该只有线程能够收到。
在copy_process的主流程里面无论是创建进程还是线程都会初始化struct sigpending pending也就是每个task_struct都会有这样一个成员变量。这就是一个信号列表。如果这个task_struct是一个线程这里面的信号就是发给这个线程的如果这个task_struct是一个进程这里面的信号是发给主线程的。
```
init_sigpending(&amp;p-&gt;pending);
```
另外上面copy_signal的时候我们可以看到在创建进程的过程中会初始化signal_struct里面的struct sigpending shared_pending。但是在创建线程的过程中连signal_struct都共享了。也就是说整个进程里的所有线程共享一个shared_pending这也是一个信号列表是发给整个进程的哪个线程处理都一样。
```
init_sigpending(&amp;sig-&gt;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-&gt;start_routine (pd-&gt;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 (&amp;__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 (&amp;pd-&gt;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-&gt;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函数里面 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。
<img src="https://static001.geekbang.org/resource/image/14/4b/14635b1613d04df9f217c3508ae8524b.jpeg" alt="">
## 课堂练习
你知道如果查看一个进程的线程以及线程栈的使用情况吗请找一下相关的命令和API尝试一下。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,286 @@
<audio id="audio" title="49 | 虚拟机:如何成立子公司,让公司变集团?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/ea/fb3977764c78de633ef1a1f5df9342ea.mp3"></audio>
我们前面所有章节涉及的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、内存、网络、硬盘给到我让我自我感觉良好感觉自己终于又像个内核了。
但是,真正的工作模式其实是下面这样的。
>
<p>虚拟机内核说我要在CPU上跑一个指令<br>
虚拟化软件说:没问题,你是内核嘛,可以跑!<br>
虚拟化软件转过头去找物理机内核说报告我管理的虚拟机里面的一个要执行一个CPU指令帮忙来一小段时间空闲的CPU时间让我代它跑个指令。<br>
物理机内核说:你等着,另一个跑着呢。(过了一会儿)它跑完了,该你了。<br>
虚拟化软件说:我代它跑,终于跑完了,出来结果了。<br>
虚拟化软件转头给虚拟机内核说:哥们儿,跑完了,结果是这个。我说你是内核吧,绝对有权限,没问题,下次跑指令找我啊!<br>
虚拟机内核说:看来我真的是内核呢,可是,哥,好像这点儿指令跑得有点慢啊!<br>
虚拟化软件说:这就不错啦,好几个排着队跑呢!</p>
内存的申请模式是下面这样的。
>
<p>虚拟机内核说我启动需要4G内存我好分给我上面的应用。<br>
虚拟化软件说没问题才4G你是内核嘛我马上申请好。<br>
虚拟化软件转头给物理机内核说报告我启动了一个虚拟机需要4G内存给我4个房间呗。<br>
物理机内核怎么又一个虚拟机啊好吧给你90、91、92、93四个房间。<br>
虚拟化软件转头给虚拟机内核说哥们内存有了0、1、2、3这个四个房间都是你的。你看你是内核嘛独占资源从0编号的就是你的。<br>
虚拟机内核说看来我真的是内核啊能从头开始用。那好我就在房间2的第三个柜子里面放个东西吧<br>
虚拟化软件说要放东西啊没问题。但是它心里想我查查看这个虚拟机是90号房间开头的它要在房间2放东西那就相当于在房间92放东西。<br>
虚拟化软件转头给物理机内核说报告我上面的虚拟机要在92号房间的第三个柜子里面放个东西。</p>
好了说完了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里面过一手因而性能就会比较差。
<img src="https://static001.geekbang.org/resource/image/05/fa/058be86de5a43a782392aff3cb8a1ffa.png" alt="">
按照上面的介绍完全虚拟化是非常慢的所以要使用硬件辅助虚拟化技术Intel-VTAMD-V所以需要CPU硬件开启这个标志位一般在BIOS里面设置。
当确认开始了标志位之后通过KVMGuestOS的CPU指令不用经过Qemu转译直接运行大大提高了速度。
所以KVM在内核里面需要有一个模块来设置当前CPU是Guest OS在用还是Host OS在用。
下面我们来查看内核模块中是否含有kvm, lsmod | grep kvm。
KVM内核模块通过/dev/kvm暴露接口用户态程序可以通过ioctl来访问这个接口。例如你可以通过下面的流程编写程序。
<img src="https://static001.geekbang.org/resource/image/f5/62/f5ee1a44d7c4890e411c2520507ddc62.png" alt="">
Qemu将KVM整合进来将有关CPU指令的部分交由内核模块来做就是qemu-kvm (qemu-system-XXX)。
qemu和kvm整合之后CPU的性能问题解决了。另外Qemu还会模拟其他的硬件如网络和硬盘。同样全虚拟化的方式也会影响这些设备的性能。
于是qemu采取半虚拟化的方式让Guest OS加载特殊的驱动来做这件事情。
例如网络需要加载virtio_net存储需要加载virtio_blkGuest需要安装这些半虚拟化驱动GuestOS知道自己是虚拟机所以数据会直接发送给半虚拟化设备经过特殊处理例如排队、缓存、批量处理等性能优化方式最终发送给真正的硬件。这在一定程度上提高了性能。
至此,整个关系如下图所示。
<img src="https://static001.geekbang.org/resource/image/f7/22/f748fd6b6b84fa90a1044a92443c3522.png" alt="">
## 创建虚拟机
了解了qemu-kvm的工作原理之后下面我们来看一下如何使用qemu-kvm创建一个能够上网的虚拟机。
如果使用VirtualBox创建过虚拟机通过界面点点就能创建一个能够上网的虚拟机。如果使用qemu-kvm就没有这么简单了。一切都得自己来做不过这个过程可以了解KVM虚拟机的创建原理。
首先我们要给虚拟机起一个名字在KVM里面就是-name ubuntutest。
<img src="https://static001.geekbang.org/resource/image/93/a8/93d2df627d6dfb09cf1c13fee16629a8.png" alt="">
设置一个内存大小在KVM里面就是-m 1024。
<img src="https://static001.geekbang.org/resource/image/db/76/db13672936d08b2c2c28115f16937876.png" alt="">
创建一个虚拟硬盘对于VirtualBox是VDI格式对于KVM则不同。
<img src="https://static001.geekbang.org/resource/image/13/dd/136d17009a7481cbd472036e4ee83ddd.png" alt="">
硬盘有两种格式,一个是动态分配,也即开始创建的时候,看起来很大,其实占用的空间很少,真实有多少数据,才真的占用多少空间。一个是固定大小,一开始就占用指定的大小。
<img src="https://static001.geekbang.org/resource/image/c2/66/c201a260cb24b2f39d6ba0ac7b548a66.png" alt="">
比如我这台电脑硬盘的大小为8G。
<img src="https://static001.geekbang.org/resource/image/9e/6d/9ed4e7712a6fe9f43dae5d1c1ecb9e6d.png" alt="">
在KVM中创建一个虚拟机镜像大小为8G其中qcow2格式为动态分配raw格式为固定大小。
```
qemu-img create -f qcow2 ubuntutest.img 8G
```
我们将Ubuntu的ISO挂载为光盘在KVM里面-cdrom [ubuntu-xxx-server-amd64.iso](http://ubuntu-xxx-server-amd64.iso)。
<img src="https://static001.geekbang.org/resource/image/09/b0/091d934004d10550a87b4d635546d5b0.png" alt="">
创建一个网络有时候会选择桥接网络有时候会选择NAT网络这个在KVM里面只有自己配置了。
<img src="https://static001.geekbang.org/resource/image/6d/5b/6d8733d73192bd1b114df03f01c6275b.png" alt="">
接下来Virtualbox就会有一个界面可以看到安装的整个过程在KVM里面我们用VNC来做。参数为-vnc :19
于是我们也可以创建KVM虚拟机了可以用下面的命令
```
qemu-system-x86_64 -enable-kvm -name ubuntutest -m 2048 -hda ubuntutest.img -cdrom ubuntu-14.04-server-amd64.iso -boot d -vnc :19
```
启动了虚拟机后连接VNC我们也能看到安装的过程。
<img src="https://static001.geekbang.org/resource/image/6a/1e/6afde27eb9bf29b566c47bf60fd56f1e.png" alt="">
按照普通安装Ubuntu的流程安装好Ubuntu然后shutdown -h now关闭虚拟机。
接下来我们可以对KVM创建桥接网络了。这个要模拟virtualbox的桥接网络模式。
如果在桌面虚拟化软件上选择桥接网络,在你的笔记本电脑上,就会形成下面的结构。
<img src="https://static001.geekbang.org/resource/image/2b/47/2b49867c473162d4706553e8cbb5f247.png" alt="">
每个虚拟机都会有虚拟网卡,在你的笔记本电脑上,会发现多了几个网卡,其实是虚拟交换机。这个虚拟交换机将虚拟机连接在一起。在桥接模式下,物理网卡也连接到这个虚拟交换机上。物理网卡在桌面虚拟化软件的“界面名称”那里选定。
如果使用桥接网络当你登录虚拟机里看IP地址时会发现你的虚拟机的地址和你的笔记本电脑的地址以及你旁边的同事的电脑的网段是一个网段。这是为什么呢这其实相当于将物理机和虚拟机放在同一个网桥上相当于这个网桥上有三台机器是一个网段的全部打平了。
<img src="https://static001.geekbang.org/resource/image/78/47/7899a96aaa0b91c165f867d3ec42e947.png" alt="">
在数据中心里面采取的也是类似的技术连接方式如下图所示只不过是Linux在每台机器上都创建网桥br0虚拟机的网卡都连到br0上物理网卡也连到br0上所有的br0都通过物理网卡连接到物理交换机上。
<img src="https://static001.geekbang.org/resource/image/da/a7/da83bb01b7ed63ac0062b5cc835099a7.png" alt="">
同样我们换一个角度看待这个拓扑图。同样是将网络打平,虚拟机会和物理网络具有相同的网段,就相当于两个虚拟交换机、一个物理交换机,一共三个交换机连在一起。两组四个虚拟机和两台物理机都是在一个二层网络里面的。
<img src="https://static001.geekbang.org/resource/image/8e/c6/8e471a287e0181f1b7af56b60b84adc6.png" alt="">
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按照上面我写的步骤创建一台虚拟机。
欢迎留言和我分享你的疑惑和见解,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,541 @@
<audio id="audio" title="50 | 计算虚拟化之CPU如何复用集团的人力资源" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/86/8d/864b26555d79e4635efeac6b4580bb8d.mp3"></audio>
上一节我们讲了一下虚拟化的基本原理以及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
```
接下来,我们在[这里下载](https://www.qemu.org/)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-&gt;init = fn;
e-&gt;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-&gt;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(&amp;kvm_accel_type);
}
TypeImpl *type_register_static(const TypeInfo *info)
{
return type_register(info);
}
TypeImpl *type_register(const TypeInfo *info)
{
assert(info-&gt;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-&gt;name) != NULL) {
}
ti-&gt;name = g_strdup(info-&gt;name);
ti-&gt;parent = g_strdup(info-&gt;parent);
ti-&gt;class_size = info-&gt;class_size;
ti-&gt;instance_size = info-&gt;instance_size;
ti-&gt;class_init = info-&gt;class_init;
ti-&gt;class_base_init = info-&gt;class_base_init;
ti-&gt;class_data = info-&gt;class_data;
ti-&gt;instance_init = info-&gt;instance_init;
ti-&gt;instance_post_init = info-&gt;instance_post_init;
ti-&gt;instance_finalize = info-&gt;instance_finalize;
ti-&gt;abstract = info-&gt;abstract;
for (i = 0; info-&gt;interfaces &amp;&amp; info-&gt;interfaces[i].type; i++) {
ti-&gt;interfaces[i].typename = g_strdup(info-&gt;interfaces[i].type);
}
ti-&gt;num_interfaces = i;
return ti;
}
static void type_table_add(TypeImpl *ti)
{
assert(!enumerating_types);
g_hash_table_insert(type_table_get(), (void *)ti-&gt;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-&gt;type_register_static-&gt;type_register-&gt;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(&amp;qemu_drive_opts);
qemu_add_opts(&amp;qemu_chardev_opts);
qemu_add_opts(&amp;qemu_device_opts);
qemu_add_opts(&amp;qemu_netdev_opts);
qemu_add_opts(&amp;qemu_nic_opts);
qemu_add_opts(&amp;qemu_net_opts);
qemu_add_opts(&amp;qemu_rtc_opts);
qemu_add_opts(&amp;qemu_machine_opts);
qemu_add_opts(&amp;qemu_accel_opts);
qemu_add_opts(&amp;qemu_mem_opts);
qemu_add_opts(&amp;qemu_smp_opts);
qemu_add_opts(&amp;qemu_boot_opts);
qemu_add_opts(&amp;qemu_name_opts);
qemu_add_opts(&amp;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
```
<li>
-enable-kvm表示启用硬件辅助虚拟化。
</li>
<li>
-name instance-00000024表示虚拟机的名称。
</li>
<li>
<p>-machine pc-i440fx-trusty,accel=kvm,usb=offmachine是什么呢其实就是计算机体系结构。不知道什么是体系结构的话可以订阅极客时间的另一个专栏《深入浅出计算机组成原理》。<br>
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。</p>
</li>
<li>
-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表示设置CPUSandyBridge是Intel处理器后面的加号都是添加的CPU的参数这些参数会显示在/proc/cpuinfo里面。
</li>
<li>
-m 2048表示内存。
</li>
<li>
<p>-smp 1,sockets=1,cores=1,threads=1SMP我们解析过叫对称多处理器和NUMA对应。qemu仿真了一个具有1个vcpu一个socket一个core一个threads的处理器。<br>
socket、core、threads是什么概念呢socket就是主板上插cpu的槽的数目也即常说的“路”core就是我们平时说的“核”即双核、4核等。thread就是每个core的硬件线程数即超线程。举个具体的例子某个服务器是2路4核超线程一般默认为2个线程通过cat /proc/cpuinfo我们看到的是2**4**2=16个processor很多人也习惯成为16核了。</p>
</li>
<li>
-rtc base=utc,driftfix=slew表示系统时间由参数-rtc指定。
</li>
<li>
-device cirrus-vga,id=video0,bus=pci.0,addr=0x2表示显示器用参数-vga设置默认为cirrus它模拟了CL-GD5446PCI VGA card。
</li>
<li>
有关网卡,使用-net参数和-device。
</li>
<li>
从HOST角度-netdev tap,fd=32,id=hostnet0,vhost=on,vhostfd=37。
</li>
<li>
从GUEST角度-device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:d1:2d:99,bus=pci.0,addr=0x3。
</li>
<li>
有关硬盘,使用-hda -hdb或者使用-drive和-device。
</li>
<li>
从HOST角度-drive file=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/disk,if=none,id=drive-virtio-disk0,format=qcow2,cache=none
</li>
<li>
从GUEST角度-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1
</li>
<li>
-vnc 0.0.0.0:12设置VNC。
</li>
在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, &quot;pc-i440fx-4.0&quot;, 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-&gt;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(&amp;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-&gt;alias = &quot;pc&quot;;
m-&gt;is_default = 1;
}
static void pc_i440fx_machine_options(MachineClass *m)
{
PCMachineClass *pcmc = PC_MACHINE_CLASS(m);
pcmc-&gt;default_nic_model = &quot;e1000&quot;;
m-&gt;family = &quot;pc_piix&quot;;
m-&gt;desc = &quot;Standard PC (i440FX + PIIX, 1996)&quot;;
m-&gt;default_machine_opts = &quot;firmware=bios-256k.bin&quot;;
m-&gt;default_display = &quot;std&quot;;
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, &quot;type&quot;);
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-&gt;next) {
MachineClass *temp = el-&gt;data;
if (temp-&gt;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, &amp;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, &amp;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-&gt;class;
......
data-&gt;fn(k, data-&gt;opaque);
}
static void type_initialize(TypeImpl *ti)
{
TypeImpl *parent;
......
ti-&gt;class_size = type_class_get_size(ti);
ti-&gt;instance_size = type_object_get_size(ti);
if (ti-&gt;instance_size == 0) {
ti-&gt;abstract = true;
}
......
ti-&gt;class = g_malloc0(ti-&gt;class_size);
......
ti-&gt;class-&gt;type = ti;
while (parent) {
if (parent-&gt;class_base_init) {
parent-&gt;class_base_init(ti-&gt;class, ti-&gt;class_data);
}
parent = type_get_parent(parent);
}
if (ti-&gt;class_init) {
ti-&gt;class_init(ti-&gt;class, ti-&gt;class_data);
}
}
```
在object_class_foreach_tramp中会调用将type_initialize这里面会调用class_init将纸面上的class也即TypeImpl变为ObjectClassObjectClass是所有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-&gt;instance_size);
object_initialize_with_type(obj, type-&gt;instance_size, type);
obj-&gt;free = g_free;
return obj;
}
```
object_new中TypeImpl的instance_init会被调用创建一个对象。current_machine就是这个对象它的类型是MachineState。
至此绕了这么大一圈有关体系结构的对象才创建完毕接下来很多的设备的初始化包括CPU和内存的初始化都是围绕着体系结构的对象来的后面我们会常常看到current_machine。
## 总结时刻
这一节我们学到虚拟机对于设备的模拟是一件非常复杂的事情需要用复杂的参数模拟各种各样的设备。为了能够适配这些设备qemu定义了自己的模块管理机制只有了解了这种机制后面看每一种设备的虚拟化的时候才有一个整体的思路。
这里的MachineClass是我们遇到的第一个我们需要掌握它里面各种定义之间的关系。
<img src="https://static001.geekbang.org/resource/image/07/30/078dc698ef1b3df93ee9569e55ea2f30.png" alt="">
每个模块都会有一个定义TypeInfo会通过type_init变为全局的TypeImpl。TypeInfo以及生成的TypeImpl有以下成员
- name表示当前类型的名称
- parent表示父类的名称
- class_init用于将TypeImpl初始化为MachineClass
- instance_init用于将MachineClass初始化为MachineState
所以,以后遇到任何一个类型的时候,将父类和子类之间的关系,以及对应的初始化函数都要看好,这样就一目了然了。
## 课堂练习
你可能会问这么复杂的qemu命令我是怎么找到的当然不是我一个字一个字打的这是著名的云平台管理软件OpenStack创建虚拟机的时候自动生成的命令行。所以给你留一道课堂练习题请你看一下OpenStack的基本原理看它是通过什么工具来管理如此复杂的命令行的。
欢迎留言和我分享你的疑惑和见解,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,887 @@
<audio id="audio" title="51 | 计算虚拟化之CPU如何复用集团的人力资源" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6e/f6/6e9eb376b3f8ef84f8af74b699194cf6.mp3"></audio>
上一节qemu初始化的main函数我们解析了一个开头得到了表示体系结构的MachineClass以及MachineState。
## 4.初始化块设备
我们接着回到main函数接下来初始化的是块设备调用的是configure_blockdev。这里我们需要重点关注上面参数中的硬盘不过我们放在存储虚拟化那一节再解析。
```
configure_blockdev(&amp;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(), &quot;accel&quot;);
accel = &quot;kvm&quot;;
accel_list = g_strsplit(accel, &quot;:&quot;, 0);
for (tmp = accel_list; !accel_initialised &amp;&amp; tmp &amp;&amp; *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(&quot;%s&quot;), 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-&gt;accelerator = accel;
*(acc-&gt;allowed) = true;
ret = acc-&gt;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-&gt;name = &quot;KVM&quot;;
ac-&gt;init_machine = kvm_init;
ac-&gt;allowed = &amp;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-&gt;accelerator);
s-&gt;fd = qemu_open(&quot;/dev/kvm&quot;, O_RDWR);
ret = kvm_ioctl(s, KVM_GET_API_VERSION, 0);
......
do {
ret = kvm_ioctl(s, KVM_CREATE_VM, type);
} while (ret == -EINTR);
......
s-&gt;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 &lt; 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,
&quot;kvm&quot;,
&amp;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这个函数。这个过程我们在[字符设备](https://time.geekbang.org/column/article/100068)那一节也讲了。
```
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(&quot;kvm-vm&quot;, &amp;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(&amp;net_clients);
if (qemu_opts_foreach(qemu_find_opts(&quot;netdev&quot;),
net_init_netdev, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts(&quot;nic&quot;), net_param_nic, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts(&quot;net&quot;), 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-&gt;init(machine);
}
```
在pc_init1里面我们重点关注两件重要的事情一个的CPU的虚拟化主要调用pc_cpus_init另外就是内存的虚拟化主要调用pc_memory_init。这一节我们重点关注CPU的虚拟化下一节我们来看内存的虚拟化。
```
void pc_cpus_init(PCMachineState *pcms)
{
......
for (i = 0; i &lt; smp_cpus; i++) {
pc_new_cpu(possible_cpus-&gt;cpus[i].type, possible_cpus-&gt;cpus[i].arch_id, &amp;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, &quot;apic-id&quot;, &amp;local_err);
object_property_set_bool(cpu, true, &quot;realized&quot;, &amp;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的定义。
```
{ &quot;SandyBridge&quot; &quot;-&quot; TYPE_X86_CPU, &quot;min-xlevel&quot;, &quot;0x8000000a&quot; }
```
接下来,我们就来看"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_CPUTYPE_CPU的父类是TYPE_DEVICETYPE_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,
&amp;xcc-&gt;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-&gt;realized = false;
object_property_add_bool(obj, &quot;realized&quot;,
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-&gt;thread = g_malloc0(sizeof(QemuThread));
cpu-&gt;halt_cond = g_malloc0(sizeof(QemuCond));
qemu_cond_init(cpu-&gt;halt_cond);
qemu_thread_create(cpu-&gt;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-&gt;thread);
cpu-&gt;thread_id = qemu_get_thread_id();
cpu-&gt;can_do_io = 1;
current_cpu = cpu;
r = kvm_init_vcpu(cpu);
kvm_init_cpu_signals(cpu);
/* signal CPU creation */
cpu-&gt;created = true;
qemu_cond_signal(&amp;qemu_cpu_cond);
do {
if (cpu_can_run(cpu)) {
r = kvm_cpu_exec(cpu);
}
qemu_wait_io_event(cpu);
} while (!cpu-&gt;unplug || cpu_can_run(cpu));
qemu_kvm_destroy_vcpu(cpu);
cpu-&gt;created = false;
qemu_cond_signal(&amp;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-&gt;kvm_fd = ret;
cpu-&gt;kvm_state = s;
cpu-&gt;vcpu_dirty = true;
mmap_size = kvm_ioctl(s, KVM_GET_VCPU_MMAP_SIZE, 0);
......
cpu-&gt;kvm_run = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, cpu-&gt;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-&gt;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(&amp;kvm_userspace_mem, argp,
sizeof(kvm_userspace_mem)))
goto out;
r = kvm_vm_ioctl_set_memory_region(kvm, &amp;kvm_userspace_mem);
break;
}
......
case KVM_CREATE_DEVICE: {
struct kvm_create_device cd;
if (copy_from_user(&amp;cd, argp, sizeof(cd)))
goto out;
r = kvm_ioctl_create_device(kvm, &amp;cd);
if (copy_to_user(argp, &amp;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-&gt;created_vcpus++;
......
vcpu = kvm_arch_vcpu_create(kvm, id);
preempt_notifier_init(&amp;vcpu-&gt;preempt_notifier, &amp;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-&gt;vcpus[atomic_read(&amp;kvm-&gt;online_vcpus)] = vcpu;
......
}
struct kvm_vcpu *kvm_arch_vcpu_create(struct kvm *kvm,
unsigned int id)
{
struct kvm_vcpu *vcpu;
vcpu = kvm_x86_ops-&gt;vcpu_create(kvm, id);
return vcpu;
}
static int create_vcpu_fd(struct kvm_vcpu *vcpu)
{
return anon_inode_getfd(&quot;kvm-vcpu&quot;, &amp;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-&gt;vpid = allocate_vpid();
err = kvm_vcpu_init(&amp;vmx-&gt;vcpu, kvm, id);
vmx-&gt;guest_msrs = kmalloc(PAGE_SIZE, GFP_KERNEL);
vmx-&gt;loaded_vmcs = &amp;vmx-&gt;vmcs01;
vmx-&gt;loaded_vmcs-&gt;vmcs = alloc_vmcs();
vmx-&gt;loaded_vmcs-&gt;shadow_vmcs = NULL;
loaded_vmcs_init(vmx-&gt;loaded_vmcs);
cpu = get_cpu();
vmx_vcpu_load(&amp;vmx-&gt;vcpu, cpu);
vmx-&gt;vcpu.cpu = cpu;
err = vmx_vcpu_setup(vmx);
vmx_vcpu_put(&amp;vmx-&gt;vcpu);
put_cpu();
if (enable_ept) {
if (!kvm-&gt;arch.ept_identity_map_addr)
kvm-&gt;arch.ept_identity_map_addr =
VMX_EPT_IDENTITY_PAGETABLE_ADDR;
err = init_rmode_identity_map(kvm);
}
return &amp;vmx-&gt;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的运行行为进行控制。例如发生中断怎么办是否使用EPTExtended Page Table功能等。
接下来对于VMCS有两个重要的操作。
VM-Entry我们称为从根模式切换到非根模式也即切换到guest上这个时候CPU上运行的是虚拟机。VM-Exit我们称为CPU从非根模式切换到根模式也即从guest切换到宿主机。例如当要执行一些虚拟机没有权限的敏感指令时。
<img src="https://static001.geekbang.org/resource/image/1e/dc/1ec7600be619221dfac03e6ade67f7dc.png" alt="">
为了维护这两个动作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-&gt;kvm_run;
int ret, run_ret;
......
do {
......
run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);
......
switch (run-&gt;exit_reason) {
case KVM_EXIT_IO:
kvm_handle_io(run-&gt;io.port, attrs,
(uint8_t *)run + run-&gt;io.data_offset,
run-&gt;io.direction,
run-&gt;io.size,
run-&gt;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, &quot;KVM: unknown exit, hardware reason %&quot; PRIx64 &quot;\n&quot;,(uint64_t)run-&gt;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-&gt;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-&gt;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-&gt;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-&gt;run-&gt;exit_reason = KVM_EXIT_INTR;
++vcpu-&gt;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-&gt;mode = IN_GUEST_MODE;
kvm_load_guest_xcr0(vcpu);
......
guest_enter_irqoff();
kvm_x86_ops-&gt;run(vcpu);
vcpu-&gt;mode = OUTSIDE_GUEST_MODE;
......
kvm_put_guest_xcr0(vcpu);
kvm_x86_ops-&gt;handle_external_intr(vcpu);
++vcpu-&gt;stat.exits;
guest_exit_irqoff();
r = kvm_x86_ops-&gt;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-&gt;__launched = vmx-&gt;loaded_vmcs-&gt;launched;
asm(
/* Store host registers */
&quot;push %%&quot; _ASM_DX &quot;; push %%&quot; _ASM_BP &quot;;&quot;
&quot;push %%&quot; _ASM_CX &quot; \n\t&quot; /* placeholder for guest rcx */
&quot;push %%&quot; _ASM_CX &quot; \n\t&quot;
......
/* Load guest registers. Don't clobber flags. */
&quot;mov %c[rax](%0), %%&quot; _ASM_AX &quot; \n\t&quot;
&quot;mov %c[rbx](%0), %%&quot; _ASM_BX &quot; \n\t&quot;
&quot;mov %c[rdx](%0), %%&quot; _ASM_DX &quot; \n\t&quot;
&quot;mov %c[rsi](%0), %%&quot; _ASM_SI &quot; \n\t&quot;
&quot;mov %c[rdi](%0), %%&quot; _ASM_DI &quot; \n\t&quot;
&quot;mov %c[rbp](%0), %%&quot; _ASM_BP &quot; \n\t&quot;
#ifdef CONFIG_X86_64
&quot;mov %c[r8](%0), %%r8 \n\t&quot;
&quot;mov %c[r9](%0), %%r9 \n\t&quot;
&quot;mov %c[r10](%0), %%r10 \n\t&quot;
&quot;mov %c[r11](%0), %%r11 \n\t&quot;
&quot;mov %c[r12](%0), %%r12 \n\t&quot;
&quot;mov %c[r13](%0), %%r13 \n\t&quot;
&quot;mov %c[r14](%0), %%r14 \n\t&quot;
&quot;mov %c[r15](%0), %%r15 \n\t&quot;
#endif
&quot;mov %c[rcx](%0), %%&quot; _ASM_CX &quot; \n\t&quot; /* kills %0 (ecx) */
/* Enter guest mode */
&quot;jne 1f \n\t&quot;
__ex(ASM_VMX_VMLAUNCH) &quot;\n\t&quot;
&quot;jmp 2f \n\t&quot;
&quot;1: &quot; __ex(ASM_VMX_VMRESUME) &quot;\n\t&quot;
&quot;2: &quot;
/* Save guest registers, load host registers, keep flags */
&quot;mov %0, %c[wordsize](%%&quot; _ASM_SP &quot;) \n\t&quot;
&quot;pop %0 \n\t&quot;
&quot;mov %%&quot; _ASM_AX &quot;, %c[rax](%0) \n\t&quot;
&quot;mov %%&quot; _ASM_BX &quot;, %c[rbx](%0) \n\t&quot;
__ASM_SIZE(pop) &quot; %c[rcx](%0) \n\t&quot;
&quot;mov %%&quot; _ASM_DX &quot;, %c[rdx](%0) \n\t&quot;
&quot;mov %%&quot; _ASM_SI &quot;, %c[rsi](%0) \n\t&quot;
&quot;mov %%&quot; _ASM_DI &quot;, %c[rdi](%0) \n\t&quot;
&quot;mov %%&quot; _ASM_BP &quot;, %c[rbp](%0) \n\t&quot;
#ifdef CONFIG_X86_64
&quot;mov %%r8, %c[r8](%0) \n\t&quot;
&quot;mov %%r9, %c[r9](%0) \n\t&quot;
&quot;mov %%r10, %c[r10](%0) \n\t&quot;
&quot;mov %%r11, %c[r11](%0) \n\t&quot;
&quot;mov %%r12, %c[r12](%0) \n\t&quot;
&quot;mov %%r13, %c[r13](%0) \n\t&quot;
&quot;mov %%r14, %c[r14](%0) \n\t&quot;
&quot;mov %%r15, %c[r15](%0) \n\t&quot;
#endif
&quot;mov %%cr2, %%&quot; _ASM_AX &quot; \n\t&quot;
&quot;mov %%&quot; _ASM_AX &quot;, %c[cr2](%0) \n\t&quot;
&quot;pop %%&quot; _ASM_BP &quot;; pop %%&quot; _ASM_DX &quot; \n\t&quot;
&quot;setbe %c[fail](%0) \n\t&quot;
&quot;.pushsection .rodata \n\t&quot;
&quot;.global vmx_return \n\t&quot;
&quot;vmx_return: &quot; _ASM_PTR &quot; 2b \n\t&quot;
......
);
......
vmx-&gt;loaded_vmcs-&gt;launched = 1;
vmx-&gt;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的虚拟化过程还是很复杂的我画了一张图总结了一下。
<img src="https://static001.geekbang.org/resource/image/c4/67/c43639f7024848aa3e828bcfc10ca467.png" alt="">
- 首先我们要定义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中一切皆文件”。那你能否整理一下这些文件系统之间的关系呢
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,843 @@
<audio id="audio" title="52 | 计算虚拟化之内存:如何建立独立的办公室?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e0/29/e09a498824c7c5dc63a27ed518a2f929.mp3"></audio>
上一节我们解析了计算虚拟化之CPU。可以看到CPU的虚拟化是用户态的qemu和内核态的KVM共同配合完成的。它们二者通过ioctl进行通信。对于内存管理来讲也是需要这两者配合完成的。
咱们在内存管理的时候讲过,操作系统给每个进程分配的内存都是虚拟内存,需要通过页表映射,变成物理内存进行访问。当有了虚拟机之后,情况会变得更加复杂。因为虚拟机对于物理机来讲是一个进程,但是虚拟机里面也有内核,也有虚拟机里面跑的进程。所以有了虚拟机,内存就变成了四类:
- **虚拟机里面的虚拟内存**Guest OS Virtual MemoryGVA这是虚拟机里面的进程看到的内存空间
- **虚拟机里面的物理内存**Guest OS Physical MemoryGPA这是虚拟机里面的操作系统看到的内存它认为这是物理内存
- **物理机的虚拟内存**Host Virtual MemoryHVA这是物理机上的qemu进程看到的内存空间
- **物理机的物理内存**Host Physical MemoryHPA这是物理机上的操作系统看到的内存。
咱们内存管理那一章讲的两大内容一个是内存管理它变得非常复杂另一个是内存映射具体来说就是从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, &amp;s-&gt;memory_listener,
&amp;address_space_memory, 0);
memory_listener_register(&amp;kvm_io_listener,
&amp;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-&gt;slots = g_malloc0(s-&gt;nr_slots * sizeof(KVMSlot));
kml-&gt;as_id = as_id;
for (i = 0; i &lt; s-&gt;nr_slots; i++) {
kml-&gt;slots[i].slot = i;
}
kml-&gt;listener.region_add = kvm_region_add;
kml-&gt;listener.region_del = kvm_region_del;
kml-&gt;listener.priority = 10;
memory_listener_register(&amp;kml-&gt;listener, as);
}
```
在这个KVMMemoryListener中是这样配置的当添加一个MemoryRegion的时候region_add会被调用这个我们后面会用到。
接下来在qemu启动的main函数中我们会调用cpu_exec_init_all-&gt;memory_map_init.
```
static void memory_map_init(void)
{
system_memory = g_malloc(sizeof(*system_memory));
memory_region_init(system_memory, NULL, &quot;system&quot;, UINT64_MAX);
address_space_init(&amp;address_space_memory, system_memory, &quot;memory&quot;);
system_io = g_malloc(sizeof(*system_io));
memory_region_init_io(system_io, NULL, &amp;unassigned_io_ops, NULL, &quot;io&quot;,
65536);
address_space_init(&amp;address_space_io, system_io, &quot;I/O&quot;);
}
```
在这里对于系统内存区域system_memory和用于I/O的内存区域system_io我们都进行了初始化并且关联到了相应的地址空间AddressSpace。
```
void address_space_init(AddressSpace *as, MemoryRegion *root, const char *name)
{
memory_region_ref(root);
as-&gt;root = root;
as-&gt;current_map = NULL;
as-&gt;ioeventfd_nb = 0;
as-&gt;ioeventfds = NULL;
QTAILQ_INIT(&amp;as-&gt;listeners);
QTAILQ_INSERT_TAIL(&amp;address_spaces, as, address_spaces_link);
as-&gt;name = g_strdup(name ? name : &quot;anonymous&quot;);
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-&gt;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-&gt;root);
FlatView *new_view = g_hash_table_lookup(flat_views, physmr);
if (old_view == new_view) {
return;
}
......
if (!QTAILQ_EMPTY(&amp;as-&gt;listeners)) {
FlatView tmpview = { .nr = 0 }, *old_view2 = old_view;
if (!old_view2) {
old_view2 = &amp;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(&amp;as-&gt;current_map, new_view);
......
}
```
这里面会生成AddressSpace的flatview。flatview是什么意思呢
我们可以看到在AddressSpace里面除了树形结构的MemoryRegion之外还有一个flatview结构其实这个结构就是把这样一个树形的内存结构变成平的内存结构。因为树形内存结构比较容易管理但是平的内存结构比较方便和内核里面通信来请求物理内存。虽然操作系统内核里面也是用树形结构来表示内存区域的但是用户态向内核申请内存的时候会按照平的、连续的模式进行申请。这里qemu在用户态所以要做这样一个转换。
在address_space_set_flatview中我们将老的flatview和新的flatview进行比较。如果不同说明内存结构发生了变化会调用address_space_update_topology_pass-&gt;MEMORY_LISTENER_UPDATE_REGION-&gt;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, &quot;pc.ram&quot;,
machine-&gt;ram_size);
*ram_memory = ram;
ram_below_4g = g_malloc(sizeof(*ram_below_4g));
memory_region_init_alias(ram_below_4g, NULL, &quot;ram-below-4g&quot;, ram,
0, pcms-&gt;below_4g_mem_size);
memory_region_add_subregion(system_memory, 0, ram_below_4g);
e820_add_entry(0, pcms-&gt;below_4g_mem_size, E820_RAM);
if (pcms-&gt;above_4g_mem_size &gt; 0) {
ram_above_4g = g_malloc(sizeof(*ram_above_4g));
memory_region_init_alias(ram_above_4g, NULL, &quot;ram-above-4g&quot;, ram, pcms-&gt;below_4g_mem_size, pcms-&gt;above_4g_mem_size);
memory_region_add_subregion(system_memory, 0x100000000ULL,
ram_above_4g);
e820_add_entry(0x100000000ULL, pcms-&gt;above_4g_mem_size, E820_RAM);
}
......
}
```
在pc_memory_init中我们已经知道了虚拟机要申请的内存ram_size于是通过memory_region_allocate_system_memory来申请内存。
接下来的调用链为memory_region_allocate_system_memory-&gt;allocate_system_memory_nonnuma-&gt;memory_region_init_ram_nomigrate-&gt;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-&gt;ram = true;
mr-&gt;terminates = true;
mr-&gt;destructor = memory_region_destructor_ram;
mr-&gt;ram_block = qemu_ram_alloc(size, share, mr, &amp;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-&gt;mr = mr;
new_block-&gt;resized = resized;
new_block-&gt;used_length = size;
new_block-&gt;max_length = max_size;
new_block-&gt;fd = -1;
new_block-&gt;page_size = getpagesize();
new_block-&gt;host = host;
......
ram_block_add(new_block, &amp;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-&gt;offset = find_ram_offset(new_block-&gt;max_length);
if (!new_block-&gt;host) {
new_block-&gt;host = phys_mem_alloc(new_block-&gt;max_length, &amp;new_block-&gt;mr-&gt;align, shared);
......
}
}
......
}
```
这里面我们会调用qemu_ram_alloc创建一个RAMBlock用来表示内存块。这里面调用ram_block_add-&gt;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-&gt;memory_region_add_subregion_common-&gt;memory_region_update_container_subregions。
```
static void memory_region_update_container_subregions(MemoryRegion *subregion)
{
MemoryRegion *mr = subregion-&gt;container;
MemoryRegion *other;
memory_region_transaction_begin();
memory_region_ref(subregion);
QTAILQ_FOREACH(other, &amp;mr-&gt;subregions, subregions_link) {
if (subregion-&gt;priority &gt;= other-&gt;priority) {
QTAILQ_INSERT_BEFORE(other, subregion, subregions_link);
goto done;
}
}
QTAILQ_INSERT_TAIL(&amp;mr-&gt;subregions, subregion, subregions_link);
done:
memory_region_update_pending |= mr-&gt;enabled &amp;&amp; subregion-&gt;enabled;
memory_region_transaction_commit();
}
```
在memory_region_update_container_subregions中我们会将子区域放到链表中然后调用memory_region_transaction_commit。在这里面我们会调用address_space_set_flatview。因为内存区域变了flatview也会变就像上面分析过的一样listener会被调用。
因为添加了一个MemoryRegionregion_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-&gt;mr;
bool writeable = !mr-&gt;readonly &amp;&amp; !mr-&gt;rom_device;
hwaddr start_addr, size;
void *ram;
......
size = kvm_align_section(section, &amp;start_addr);
......
/* use aligned delta to align the ram address */
ram = memory_region_get_ram_ptr(mr) + section-&gt;offset_within_region + (start_addr - section-&gt;offset_within_address_space);
......
/* register the new slot */
mem = kvm_alloc_slot(kml);
mem-&gt;memory_size = size;
mem-&gt;start_addr = start_addr;
mem-&gt;ram = ram;
mem-&gt;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-&gt;slot | (kml-&gt;as_id &lt;&lt; 16);
mem.guest_phys_addr = slot-&gt;start_addr;
mem.userspace_addr = (unsigned long)slot-&gt;ram;
mem.flags = slot-&gt;flags;
......
mem.memory_size = slot-&gt;memory_size;
ret = kvm_vm_ioctl(s, KVM_SET_USER_MEMORY_REGION, &amp;mem);
slot-&gt;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-&gt;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(&amp;kvm_userspace_mem, argp,
sizeof(kvm_userspace_mem)))
goto out;
r = kvm_vm_ioctl_set_memory_region(kvm, &amp;kvm_userspace_mem);
break;
}
......
}
```
接下来的调用链为kvm_vm_ioctl_set_memory_region-&gt;kvm_set_memory_region-&gt;__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-&gt;slot &gt;&gt; 16;
id = (u16)mem-&gt;slot;
slot = id_to_memslot(__kvm_memslots(kvm, as_id), id);
base_gfn = mem-&gt;guest_phys_addr &gt;&gt; PAGE_SHIFT;
npages = mem-&gt;memory_size &gt;&gt; PAGE_SHIFT;
......
new = old = *slot;
new.id = id;
new.base_gfn = base_gfn;
new.npages = npages;
new.flags = mem-&gt;flags;
......
if (change == KVM_MR_CREATE) {
new.userspace_addr = mem-&gt;userspace_addr;
if (kvm_arch_create_memslot(kvm, &amp;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, &amp;new, mem, change);
update_memslots(slots, &amp;new);
old_memslots = install_new_memslots(kvm, as_id, slots);
kvm_arch_commit_memory_region(kvm, mem, &amp;old, &amp;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的EPTExtent Page Table扩展页表技术。
EPT在原有客户机页表对客户机虚拟地址到客户机物理地址映射的基础上又引入了 EPT页表来实现客户机物理地址到宿主机物理地址的另一次映射。客户机运行时客户机页表被载入 CR3而EPT页表被载入专门的EPT 页表指针寄存器 EPTP。
有了EPT在客户机物理地址到宿主机物理地址转换的过程中缺页会产生EPT 缺页异常。KVM首先根据引起异常的客户机物理地址映射到对应的宿主机虚拟地址然后为此虚拟地址分配新的物理页最后 KVM 再更新 EPT 页表,建立起引起异常的客户机物理地址到宿主机物理地址之间的映射。
KVM 只需为每个客户机维护一套 EPT 页表,也大大减少了内存的开销。
这里我们重点看第二种方式。因为使用了EPT之后客户机里面的页表映射也即从GVA到GPA的转换还是用传统的方式和在内存管理那一章讲的没有什么区别。而EPT重点帮我们解决的就是从GPA到HPA的转换问题。因为要经过两次页表所以EPT又称为tdptwo dimentional paging
EPT的页表结构也是分为四层EPT Pointer EPTP指向PML4的首地址。
<img src="https://static001.geekbang.org/resource/image/02/30/02e4740398bc3685f366351260ae7230.jpg" alt="">
管理物理页面的Page结构和咱们讲内存管理那一章是一样的。EPT页表也需要存放在一个页中这些页要用kvm_mmu_page这个结构来管理。
当一个虚拟机运行进入客户机模式的时候我们上一节解析过它会调用vcpu_enter_guest函数这里面会调用kvm_mmu_reload-&gt;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-&gt;arch.mmu.set_cr3(vcpu, vcpu-&gt;arch.mmu.root_hpa);
......
}
static int mmu_alloc_roots(struct kvm_vcpu *vcpu)
{
if (vcpu-&gt;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-&gt;arch.mmu.shadow_root_level == PT64_ROOT_LEVEL) {
spin_lock(&amp;vcpu-&gt;kvm-&gt;mmu_lock);
make_mmu_pages_available(vcpu);
sp = kvm_mmu_get_page(vcpu, 0, 0, PT64_ROOT_LEVEL, 1, ACC_ALL);
++sp-&gt;root_count;
spin_unlock(&amp;vcpu-&gt;kvm-&gt;mmu_lock);
vcpu-&gt;arch.mmu.root_hpa = __pa(sp-&gt;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-&gt;arch.gpa_available = true;
vcpu-&gt;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-&gt;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 &gt;&gt; PAGE_SHIFT;
unsigned long mmu_seq;
int write = error_code &amp; PFERR_WRITE_MASK;
bool map_writable;
r = mmu_topup_memory_caches(vcpu);
level = mapping_level(vcpu, gfn, &amp;force_pt_level);
......
if (try_async_pf(vcpu, prefault, gfn, gpa, &amp;pfn, write, &amp;map_writable))
return 0;
if (handle_abnormal_pfn(vcpu, 0, gfn, pfn, ACC_ALL, &amp;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, &amp;async, write, writable);
if (!async)
return false; /* *pfn has correct page already */
if (!prefault &amp;&amp; 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-&gt;arch.mmu.root_hpa))
return 0;
for_each_shadow_entry(vcpu, (u64)gfn &lt;&lt; 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-&gt;stat.pf_fixed;
break;
}
drop_large_spte(vcpu, iterator.sptep);
if (!is_shadow_present_pte(*iterator.sptep)) {
u64 base_addr = iterator.addr;
base_addr &amp;= PT64_LVL_ADDR_MASK(iterator.level);
pseudo_gfn = base_addr &gt;&gt; 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页表映射。
<img src="https://static001.geekbang.org/resource/image/01/9b/0186c533b7ef706df880dfd775c2449b.jpg" alt="">
## 课堂练习
这一节,影子页表我们没有深入去讲,你能自己研究一下,它是如何实现的吗?
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,440 @@
<audio id="audio" title="53 | 存储虚拟化(上):如何建立自己保管的单独档案库?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/b5/0cea069cfe1898557329f1b2e780f1b5.mp3"></audio>
前面几节我们讲了CPU和内存的虚拟化。我们知道完全虚拟化是很慢的而通过内核的KVM技术和EPT技术加速虚拟机对于物理CPU和内存的使用我们称为硬件辅助虚拟化。
对于一台虚拟机而言除了要虚拟化CPU和内存存储和网络也需要虚拟化存储和网络都属于外部设备这些外部设备应该如何虚拟化呢
当然一种方式还是完全虚拟化。比如有什么样的硬盘设备或者网卡设备我们就用qemu模拟一个一模一样的软件的硬盘和网卡设备这样在虚拟机里面的操作系统看来使用这些设备和使用物理设备是一样的。当然缺点就是qemu模拟的设备又是一个翻译官的角色。虽然这个时候虚拟机里面的操作系统意识不到自己是运行在虚拟机里面的但是这种每个指令都翻译的方式实在是太慢了。
另外一种方式就是,虚拟机里面的操作系统不是一个通用的操作系统,它知道自己是运行在虚拟机里面的,使用的硬盘设备和网络设备都是虚拟的,应该加载特殊的驱动才能运行。这些特殊的驱动往往要通过虚拟机里面和外面配合工作的模式,来加速对于物理存储和网络设备的使用。
## virtio的基本原理
在虚拟化技术的早期,不同的虚拟化技术会针对不同硬盘设备和网络设备实现不同的驱动,虚拟机里面的操作系统也要根据不同的虚拟化技术和物理存储和网络设备,选择加载不同的驱动。但是,由于硬盘设备和网络设备太多了,驱动纷繁复杂。
后来慢慢就形成了一定的标准,这就是**virtio**,就是**虚拟化I/O设备**的意思。virtio负责对于虚拟机提供统一的接口。也就是说在虚拟机里面的操作系统加载的驱动以后都统一加载virtio就可以了。
<img src="https://static001.geekbang.org/resource/image/1e/33/1e13ffd5ac846c52739291cb489d0233.png" alt="">
在虚拟机外我们可以实现不同的virtio的后端来适配不同的物理硬件设备。那virtio到底长什么样子呢我们一起来看一看。
virtio的架构可以分为四层。
- 首先在虚拟机里面的virtio前端针对不同类型的设备有不同的**驱动程序**但是接口都是统一的。例如硬盘就是virtio_blk网络就是virtio_net。
- 其次在宿主机的qemu里面实现virtio后端的逻辑主要就是**操作硬件的设备**。例如通过写一个物理机硬盘上的文件来完成虚拟机写入硬盘的操作。再如向内核协议栈发送一个网络包完成虚拟机对于网络的操作。
- 在virtio的前端和后端之间有一个通信层里面包含**virtio层**和**virtio-ring层**。virtio这一层实现的是虚拟队列接口算是前后端通信的桥梁。而virtio-ring则是该桥梁的具体实现。
<img src="https://static001.geekbang.org/resource/image/2e/f3/2e9ef612f7b80ec9fcd91e200f4946f3.png" alt="">
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(&amp;virtio_blk_info);
}
type_init(virtio_register_types)
```
Virtio Block Device这种类的定义是有多层继承关系的。TYPE_VIRTIO_BLK的父类是TYPE_VIRTIO_DEVICETYPE_VIRTIO_DEVICE的父类是TYPE_DEVICETYPE_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](https://time.geekbang.org/column/article/109335)那一节也有类似的结构。
```
static void virtio_blk_device_realize(DeviceState *dev, Error **errp)
{
VirtIODevice *vdev = VIRTIO_DEVICE(dev);
VirtIOBlock *s = VIRTIO_BLK(dev);
VirtIOBlkConf *conf = &amp;s-&gt;conf;
......
blkconf_blocksizes(&amp;conf-&gt;conf);
virtio_blk_set_config_size(s, s-&gt;host_features);
virtio_init(vdev, &quot;virtio-blk&quot;, VIRTIO_ID_BLOCK, s-&gt;config_size);
s-&gt;blk = conf-&gt;conf.blk;
s-&gt;rq = NULL;
s-&gt;sector_mask = (s-&gt;conf.conf.logical_block_size / BDRV_SECTOR_SIZE) - 1;
for (i = 0; i &lt; conf-&gt;num_queues; i++) {
virtio_add_queue(vdev, conf-&gt;queue_size, virtio_blk_handle_output);
}
virtio_blk_data_plane_create(vdev, conf, &amp;s-&gt;dataplane, &amp;err);
s-&gt;change = qemu_add_vm_change_state_handler(virtio_blk_dma_restart_cb, s);
blk_set_dev_ops(s-&gt;blk, &amp;virtio_block_ops, s);
blk_set_guest_block_size(s-&gt;blk, s-&gt;conf.conf.logical_block_size);
blk_iostatus_enable(s-&gt;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-&gt;query_nvectors ? k-&gt;query_nvectors(qbus-&gt;parent) : 0;
if (nvectors) {
vdev-&gt;vector_queues =
g_malloc0(sizeof(*vdev-&gt;vector_queues) * nvectors);
}
vdev-&gt;device_id = device_id;
vdev-&gt;status = 0;
atomic_set(&amp;vdev-&gt;isr, 0);
vdev-&gt;queue_sel = 0;
vdev-&gt;config_vector = VIRTIO_NO_VECTOR;
vdev-&gt;vq = g_malloc0(sizeof(VirtQueue) * VIRTIO_QUEUE_MAX);
vdev-&gt;vm_running = runstate_is_running();
vdev-&gt;broken = false;
for (i = 0; i &lt; VIRTIO_QUEUE_MAX; i++) {
vdev-&gt;vq[i].vector = VIRTIO_NO_VECTOR;
vdev-&gt;vq[i].vdev = vdev;
vdev-&gt;vq[i].queue_index = i;
}
vdev-&gt;name = name;
vdev-&gt;config_len = config_size;
if (vdev-&gt;config_len) {
vdev-&gt;config = g_malloc0(config_size);
} else {
vdev-&gt;config = NULL;
}
vdev-&gt;vmstate = qemu_add_vm_change_state_handler(virtio_vmstate_change,
vdev);
vdev-&gt;device_endian = virtio_default_endian();
vdev-&gt;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-&gt;vq[i].vring.num = queue_size;
vdev-&gt;vq[i].vring.num_default = queue_size;
vdev-&gt;vq[i].vring.align = VIRTIO_PCI_VRING_ALIGN;
vdev-&gt;vq[i].handle_output = handle_output;
vdev-&gt;vq[i].handle_aio_output = NULL;
return &amp;vdev-&gt;vq[i];
}
```
在每个VirtQueue中都有一个vring用来维护这个队列里面的数据另外还有一个函数virtio_blk_handle_output用于处理数据写入这个函数我们后面会用到。
至此VirtIODeviceVirtQueuevring之间的关系如下图所示。这是在qemu里面的对应关系请你记好后面我们还能看到类似的结构。
<img src="https://static001.geekbang.org/resource/image/e1/6d/e18dae0a5951392c4a8e8630e53a616d.jpg" alt="">
## 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(&amp;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(&quot;drive&quot;), drive_init_func,
&amp;machine_class-&gt;block_default_type, &amp;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(&amp;qemu_legacy_drive_opts, NULL, 0,
&amp;error_abort);
......
/* Add virtio block device */
if (type == IF_VIRTIO) {
QemuOpts *devopts;
devopts = qemu_opts_create(qemu_find_opts(&quot;device&quot;), NULL, 0,
&amp;error_abort);
qemu_opt_set(devopts, &quot;driver&quot;, &quot;virtio-blk-pci&quot;, &amp;error_abort);
qemu_opt_set(devopts, &quot;drive&quot;, qdict_get_str(bs_opts, &quot;id&quot;),
&amp;error_abort);
}
filename = qemu_opt_get(legacy_opts, &quot;file&quot;);
......
/* Actual block device init: Functionality shared with blockdev-add */
blk = blockdev_init(filename, bs_opts, &amp;local_err);
......
/* Create legacy DriveInfo */
dinfo = g_malloc0(sizeof(*dinfo));
dinfo-&gt;opts = all_opts;
dinfo-&gt;type = type;
dinfo-&gt;bus = bus_id;
dinfo-&gt;unit = unit_id;
blk_set_legacy_dinfo(blk, dinfo);
switch(type) {
case IF_IDE:
case IF_SCSI:
case IF_XEN:
case IF_NONE:
dinfo-&gt;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-&gt;root = bdrv_root_attach_child(bs, &quot;root&quot;, &amp;child_root,
perm, BLK_PERM_ALL, blk, errp);
return blk;
}
```
接下来的调用链为bdrv_open-&gt;bdrv_open_inherit-&gt;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-&gt;drv = drv;
bs-&gt;read_only = !(bs-&gt;open_flags &amp; BDRV_O_RDWR);
bs-&gt;opaque = g_malloc0(drv-&gt;instance_size);
if (drv-&gt;bdrv_open) {
ret = drv-&gt;bdrv_open(bs, options, open_flags, &amp;local_err);
}
......
}
```
在bdrv_open_common中根据硬盘文件的格式得到BlockDriver。因为虚拟机的硬盘文件格式有很多种qcow2是一种raw是一种vmdk是一种各有优缺点启动虚拟机的时候可以自由选择。
对于不同的格式打开的方式不一样我们拿qcow2来解析。它的BlockDriver定义如下
```
BlockDriver bdrv_qcow2 = {
.format_name = &quot;qcow2&quot;,
.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-&gt;opaque;
QCow2OpenCo qoc = {
.bs = bs,
.options = options,
.flags = flags,
.errp = errp,
.ret = -EINPROGRESS
};
bs-&gt;file = bdrv_open_child(NULL, options, &quot;file&quot;, bs, &amp;child_file,
false, errp);
qemu_coroutine_enter(qemu_coroutine_create(qcow2_open_entry, &amp;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-&gt;bs-&gt;opaque;
qemu_co_mutex_lock(&amp;s-&gt;lock);
qoc-&gt;ret = qcow2_do_open(qoc-&gt;bs, qoc-&gt;options, qoc-&gt;flags, qoc-&gt;errp);
qemu_co_mutex_unlock(&amp;s-&gt;lock);
}
```
我们可以看到qcow2_open_entry函数前面有一个coroutine_fn说明它是一个协程函数。在qcow2_do_open中qcow2_do_open根据qcow2的格式打开硬盘文件。这个格式[官网](https://github.com/qemu/qemu/blob/master/docs/interop/qcow2.txt)就有,我们这里就不花篇幅解析了。
## 总结时刻
我们这里来总结一下,存储虚拟化的过程分为前端、后端和中间的队列。
- 前端有前端的块设备驱动Front-end driver在客户机的内核里面它符合普通设备驱动的格式对外通过VFS暴露文件系统接口给客户机里面的应用。这一部分这一节我们没有讲放在下一节解析。
- 后端有后端的设备驱动Back-end driver在宿主机的qemu进程中当收到客户机的写入请求的时候调用文件系统的write函数写入宿主机的VFS文件系统最终写到物理硬盘设备上的qcow2文件。
- 中间的队列用于前端和后端之间传输数据在前端的设备驱动和后端的设备驱动都有类似的数据结构virt-queue来管理这些队列这一部分这一节我们也没有讲也放到下一节解析。
<img src="https://static001.geekbang.org/resource/image/1f/4b/1f0c3043a11d6ea1a802f7d0f3b0b34b.jpg" alt="">
## 课堂练习
对于qemu-kvm来讲qcow2是一种常见的文件格式。它有精妙的格式设计从而适应虚拟化的场景请你研究一下这个文件格式。
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,925 @@
<audio id="audio" title="54 | 存储虚拟化(下):如何建立自己保管的单独档案库?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/9a/96ae957ba80acda102c44d661ea0be9a.mp3"></audio>
上一节我们讲了qemu启动过程中的存储虚拟化。好了现在qemu启动了硬盘设备文件已经打开了。那如果我们要往虚拟机的一个进程写入一个文件该怎么做呢最终这个文件又是如何落到宿主机上的硬盘文件的呢这一节我们一起来看一看。
## 前端设备驱动virtio_blk
虚拟机里面的进程写入一个文件,当然要通过文件系统。整个过程和咱们在[文件系统](https://time.geekbang.org/column/article/97876)那一节讲的过程没有区别。只是到了设备驱动层我们看到的就不是普通的硬盘驱动了而是virtio的驱动。
virtio的驱动程序代码在Linux操作系统的源代码里面文件名叫drivers/block/virtio_blk.c。
```
static int __init init(void)
{
int error;
virtblk_wq = alloc_workqueue(&quot;virtio-blk&quot;, 0, 0);
major = register_blkdev(0, &quot;virtblk&quot;);
error = register_virtio_driver(&amp;virtio_blk);
......
}
module_init(init);
module_exit(fini);
MODULE_DEVICE_TABLE(virtio, id_table);
MODULE_DESCRIPTION(&quot;Virtio block driver&quot;);
MODULE_LICENSE(&quot;GPL&quot;);
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-&gt;priv = vblk = kmalloc(sizeof(*vblk), GFP_KERNEL);
vblk-&gt;vdev = vdev;
vblk-&gt;sg_elems = sg_elems;
INIT_WORK(&amp;vblk-&gt;config_work, virtblk_config_changed_work);
......
err = init_vq(vblk);
......
vblk-&gt;disk = alloc_disk(1 &lt;&lt; PART_BITS);
memset(&amp;vblk-&gt;tag_set, 0, sizeof(vblk-&gt;tag_set));
vblk-&gt;tag_set.ops = &amp;virtio_mq_ops;
vblk-&gt;tag_set.queue_depth = virtblk_queue_depth;
vblk-&gt;tag_set.numa_node = NUMA_NO_NODE;
vblk-&gt;tag_set.flags = BLK_MQ_F_SHOULD_MERGE;
vblk-&gt;tag_set.cmd_size =
sizeof(struct virtblk_req) +
sizeof(struct scatterlist) * sg_elems;
vblk-&gt;tag_set.driver_data = vblk;
vblk-&gt;tag_set.nr_hw_queues = vblk-&gt;num_vqs;
err = blk_mq_alloc_tag_set(&amp;vblk-&gt;tag_set);
......
q = blk_mq_init_queue(&amp;vblk-&gt;tag_set);
vblk-&gt;disk-&gt;queue = q;
q-&gt;queuedata = vblk;
virtblk_name_format(&quot;vd&quot;, index, vblk-&gt;disk-&gt;disk_name, DISK_NAME_LEN);
vblk-&gt;disk-&gt;major = major;
vblk-&gt;disk-&gt;first_minor = index_to_minor(index);
vblk-&gt;disk-&gt;private_data = vblk;
vblk-&gt;disk-&gt;fops = &amp;virtblk_fops;
vblk-&gt;disk-&gt;flags |= GENHD_FL_EXT_DEVT;
vblk-&gt;index = index;
......
device_add_disk(&amp;vdev-&gt;dev, vblk-&gt;disk);
err = device_create_file(disk_to_dev(vblk-&gt;disk), &amp;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-&gt;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-&gt;vdev;
......
vblk-&gt;vqs = kmalloc_array(num_vqs, sizeof(*vblk-&gt;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 &lt; num_vqs; i++) {
callbacks[i] = virtblk_done;
names[i] = vblk-&gt;vqs[i].name;
}
/* Discover virtqueues and write information to configuration. */
err = virtio_find_vqs(vdev, num_vqs, vqs, callbacks, names, &amp;desc);
for (i = 0; i &lt; num_vqs; i++) {
vblk-&gt;vqs[i].vq = vqs[i];
}
vblk-&gt;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-&gt;config-&gt;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, &amp;vdev-&gt;vqs, list) {
vp_iowrite16(vq-&gt;index, &amp;vp_dev-&gt;common-&gt;queue_select);
vp_iowrite16(1, &amp;vp_dev-&gt;common-&gt;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-&gt;vqs = kcalloc(nvqs, sizeof(*vp_dev-&gt;vqs), GFP_KERNEL);
err = request_irq(vp_dev-&gt;pci_dev-&gt;irq, vp_interrupt, IRQF_SHARED,
dev_name(&amp;vdev-&gt;dev), vp_dev);
vp_dev-&gt;intx_enabled = 1;
vp_dev-&gt;per_vq_vectors = false;
for (i = 0; i &lt; 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-&gt;setup_vq(vp_dev, info, index, callback, name, ctx,
msix_vec);
info-&gt;vq = vq;
if (callback) {
spin_lock_irqsave(&amp;vp_dev-&gt;lock, flags);
list_add(&amp;info-&gt;node, &amp;vp_dev-&gt;virtqueues);
spin_unlock_irqrestore(&amp;vp_dev-&gt;lock, flags);
} else {
INIT_LIST_HEAD(&amp;info-&gt;node);
}
vp_dev-&gt;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-&gt;common;
struct virtqueue *vq;
u16 num, off;
int err;
/* Select the queue we're interested in */
vp_iowrite16(index, &amp;cfg-&gt;queue_select);
/* Check if queue is either not available or already active. */
num = vp_ioread16(&amp;cfg-&gt;queue_size);
/* get offset of notification word for this vq */
off = vp_ioread16(&amp;cfg-&gt;queue_notify_off);
info-&gt;msix_vector = msix_vec;
/* create the vring */
vq = vring_create_virtqueue(index, num,
SMP_CACHE_BYTES, &amp;vp_dev-&gt;vdev,
true, true, ctx,
vp_notify, callback, name);
/* activate the queue */
vp_iowrite16(virtqueue_get_vring_size(vq), &amp;cfg-&gt;queue_size);
vp_iowrite64_twopart(virtqueue_get_desc_addr(vq),
&amp;cfg-&gt;queue_desc_lo, &amp;cfg-&gt;queue_desc_hi);
vp_iowrite64_twopart(virtqueue_get_avail_addr(vq),
&amp;cfg-&gt;queue_avail_lo, &amp;cfg-&gt;queue_avail_hi);
vp_iowrite64_twopart(virtqueue_get_used_addr(vq),
&amp;cfg-&gt;queue_used_lo, &amp;cfg-&gt;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 &amp;&amp; vring_size(num, vring_align) &gt; PAGE_SIZE; num /= 2) {
queue = vring_alloc_queue(vdev, vring_size(num, vring_align),
&amp;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),
&amp;dma_addr, GFP_KERNEL|__GFP_ZERO);
}
queue_size_in_bytes = vring_size(num, vring_align);
vring_init(&amp;vring, num, queue, vring_align);
vq = __vring_new_virtqueue(index, vring, vdev, weak_barriers, context, notify, callback, name);
to_vvq(vq)-&gt;queue_dma_addr = dma_addr;
to_vvq(vq)-&gt;queue_size_in_bytes = queue_size_in_bytes;
to_vvq(vq)-&gt;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的后端有数据结构VirtIODeviceVirtQueue和vring一模一样前端和后端对应起来都应该指向刚才创建的那一段内存。
现在的问题是我们刚才分配的内存在客户机的内核里面如何告知qemu来访问这段内存呢
别忘了qemu模拟出来的virtio block device只是一个PCI设备。对于客户机来讲这是一个外部设备我们可以通过给外部设备发送指令的方式告知外部设备这就是代码中vp_iowrite16的作用。它会调用专门给外部设备发送指令的函数iowrite告诉外部的PCI设备。
告知的有三个地址virtqueue_get_desc_addr、virtqueue_get_avail_addrvirtqueue_get_used_addr。从客户机角度来看这里面的地址都是物理地址也即GPAGuest 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(&amp;proxy-&gt;bar, OBJECT(proxy),
&amp;virtio_pci_config_ops,
proxy, &quot;virtio-pci&quot;, 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-&gt;virtio_ioport_write-&gt;virtio_queue_set_addr。设置virtio的queue的地址是一项很重要的操作。
```
void virtio_queue_set_addr(VirtIODevice *vdev, int n, hwaddr addr)
{
vdev-&gt;vq[n].vring.desc = addr;
virtio_queue_update_rings(vdev, n);
}
```
从这里我们可以看出qemu后端的VirtIODevice的VirtQueue的vring的地址被设置成了刚才给队列分配的内存的GPA。
<img src="https://static001.geekbang.org/resource/image/25/d0/2572f8b1e75b9eaab6560866fcb31fd0.jpg" alt="">
接着,我们来看一下这个队列的格式。
<img src="https://static001.geekbang.org/resource/image/49/db/49414d5acc81933b66410bbba102b0db.jpg" alt="">
```
/* Virtio ring descriptors: 16 bytes. These can chain together via &quot;next&quot;. */
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-&gt;ring[]是发送端维护的环形队列指向需要接收端处理的vring_desc。
- used-&gt;ring[]是接收端维护的环形队列指向自己已经处理过了的vring_desc。
## 数据写入的流程
接下来,我们来看,真的写入一个数据的时候,会发生什么。
按照上面virtio驱动初始化的时候的逻辑blk_mq_make_request会被调用。这个函数比较复杂会分成多个分支但是最终都会调用到request_queue的virtio_mq_ops的queue_rq函数。
```
struct request_queue *q = rq-&gt;q;
q-&gt;mq_ops-&gt;queue_rq(hctx, &amp;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-&gt;queue-&gt;queuedata;
struct request *req = bd-&gt;rq;
struct virtblk_req *vbr = blk_mq_rq_to_pdu(req);
......
err = virtblk_add_req(vblk-&gt;vqs[qid].vq, vbr, vbr-&gt;sg, num);
......
if (notify)
virtqueue_notify(vblk-&gt;vqs[qid].vq);
return BLK_STS_OK;
}
```
在virtio_queue_rq中我们会将请求写入的数据通过virtblk_add_req放入struct virtqueue。
因此接下来的调用链为virtblk_add_req-&gt;virtqueue_add_sgs-&gt;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-&gt;free_head;
indirect = false;
desc = vq-&gt;vring.desc;
i = head;
descs_used = total_sg;
for (n = 0; n &lt; 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-&gt;vdev, VRING_DESC_F_NEXT);
desc[i].addr = cpu_to_virtio64(_vq-&gt;vdev, addr);
desc[i].len = cpu_to_virtio32(_vq-&gt;vdev, sg-&gt;length);
prev = i;
i = virtio16_to_cpu(_vq-&gt;vdev, desc[i].next);
}
}
/* Last one doesn't continue. */
desc[prev].flags &amp;= cpu_to_virtio16(_vq-&gt;vdev, ~VRING_DESC_F_NEXT);
/* We're using some buffers from the free list. */
vq-&gt;vq.num_free -= descs_used;
/* Update free pointer */
vq-&gt;free_head = i;
/* Store token and indirect buffer state. */
vq-&gt;desc_state[head].data = data;
/* Put entry in available array (but don't update avail-&gt;idx until they do sync). */
avail = vq-&gt;avail_idx_shadow &amp; (vq-&gt;vring.num - 1);
vq-&gt;vring.avail-&gt;ring[avail] = cpu_to_virtio16(_vq-&gt;vdev, head);
/* Descriptors and available array need to be set before we expose the new available array entries. */
virtio_wmb(vq-&gt;weak_barriers);
vq-&gt;avail_idx_shadow++;
vq-&gt;vring.avail-&gt;idx = cpu_to_virtio16(_vq-&gt;vdev, vq-&gt;avail_idx_shadow);
vq-&gt;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 &amp; (vq-&gt;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-&gt;index, (void __iomem *)vq-&gt;priv);
return true;
}
```
然后我们写入一个I/O会触发VM exit。我们在解析CPU的时候看到过这个逻辑。
```
int kvm_cpu_exec(CPUState *cpu)
{
struct kvm_run *run = cpu-&gt;kvm_run;
int ret, run_ret;
......
run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);
......
switch (run-&gt;exit_reason) {
case KVM_EXIT_IO:
DPRINTF(&quot;handle_io\n&quot;);
/* Called outside BQL */
kvm_handle_io(run-&gt;io.port, attrs,
(uint8_t *)run + run-&gt;io.data_offset,
run-&gt;io.direction,
run-&gt;io.size,
run-&gt;io.count);
ret = 0;
break;
}
......
}
```
这次写入的也是一个I/O的内存空间同样会触发virtio_ioport_write这次会调用virtio_queue_notify。
```
void virtio_queue_notify(VirtIODevice *vdev, int n)
{
VirtQueue *vq = &amp;vdev-&gt;vq[n];
......
if (vq-&gt;handle_aio_output) {
event_notifier_set(&amp;vq-&gt;host_notifier);
} else if (vq-&gt;handle_output) {
vq-&gt;handle_output(vdev, vq);
}
}
```
virtio_queue_notify会调用VirtQueue的handle_output函数前面我们已经设置过这个函数了是virtio_blk_handle_output。
接下来的调用链为virtio_blk_handle_output-&gt;virtio_blk_handle_output_do-&gt;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, &amp;mrb)) {
virtqueue_detach_element(req-&gt;vq, &amp;req-&gt;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-&gt;blk, &amp;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-&gt;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-&gt;vring.num;
i = head;
caches = vring_get_region_caches(vq);
desc_cache = &amp;caches-&gt;desc;
vring_desc_read(vdev, &amp;desc, desc_cache, i);
......
/* Collect all the descriptors */
do {
bool map_ok;
if (desc.flags &amp; VRING_DESC_F_WRITE) {
map_ok = virtqueue_map_desc(vdev, &amp;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, &amp;out_num, addr, iov,
VIRTQUEUE_MAX_SIZE, false,
desc.addr, desc.len);
}
......
rc = virtqueue_read_next_desc(vdev, &amp;desc, desc_cache, max, &amp;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-&gt;index = head;
for (i = 0; i &lt; out_num; i++) {
elem-&gt;out_addr[i] = addr[i];
elem-&gt;out_sg[i] = iov[i];
}
for (i = 0; i &lt; in_num; i++) {
elem-&gt;in_addr[i] = addr[out_num + i];
elem-&gt;in_sg[i] = iov[out_num + i];
}
vq-&gt;inuse++;
......
return elem;
}
```
我们可以看到virtio_blk_get_request会调用virtqueue_pop。在这里面我们能看到对于vring的操作也即从这里面将客户机里面写入的数据读取出来放到VirtIOBlockReq结构中。
接下来我们就要调用virtio_blk_handle_request处理这些数据。所以接下来的调用链为virtio_blk_handle_request-&gt;virtio_blk_submit_multireq-&gt;submit_requests。
```
static inline void submit_requests(BlockBackend *blk, MultiReqBuffer *mrb,int start, int num_reqs, int niov)
{
QEMUIOVector *qiov = &amp;mrb-&gt;reqs[start]-&gt;qiov;
int64_t sector_num = mrb-&gt;reqs[start]-&gt;sector_num;
bool is_write = mrb-&gt;is_write;
if (num_reqs &gt; 1) {
int i;
struct iovec *tmp_iov = qiov-&gt;iov;
int tmp_niov = qiov-&gt;niov;
qemu_iovec_init(qiov, niov);
for (i = 0; i &lt; tmp_niov; i++) {
qemu_iovec_add(qiov, tmp_iov[i].iov_base, tmp_iov[i].iov_len);
}
for (i = start + 1; i &lt; start + num_reqs; i++) {
qemu_iovec_concat(qiov, &amp;mrb-&gt;reqs[i]-&gt;qiov, 0,
mrb-&gt;reqs[i]-&gt;qiov.size);
mrb-&gt;reqs[i - 1]-&gt;mr_next = mrb-&gt;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 &lt;&lt; BDRV_SECTOR_BITS, qiov, 0,
virtio_blk_rw_complete, mrb-&gt;reqs[start]);
} else {
blk_aio_preadv(blk, sector_num &lt;&lt; BDRV_SECTOR_BITS, qiov, 0,
virtio_blk_rw_complete, mrb-&gt;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-&gt;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(&amp;blk_aio_em_aiocb_info, blk, cb, opaque);
acb-&gt;rwco = (BlkRwCo) {
.blk = blk,
.offset = offset,
.iobuf = iobuf,
.flags = flags,
.ret = NOT_DONE,
};
acb-&gt;bytes = bytes;
acb-&gt;has_returned = false;
co = qemu_coroutine_create(co_entry, acb);
bdrv_coroutine_enter(blk_bs(blk), co);
acb-&gt;has_returned = true;
return &amp;acb-&gt;common;
}
```
在blk_aio_pwritev中我们看到又是创建了一个协程来进行写入。写入完毕之后调用virtio_blk_rw_complete-&gt;virtio_blk_req_complete。
```
static void virtio_blk_req_complete(VirtIOBlockReq *req, unsigned char status)
{
VirtIOBlock *s = req-&gt;dev;
VirtIODevice *vdev = VIRTIO_DEVICE(s);
trace_virtio_blk_req_complete(vdev, req, status);
stb_p(&amp;req-&gt;in-&gt;status, status);
virtqueue_push(req-&gt;vq, &amp;req-&gt;elem, req-&gt;in_len);
virtio_notify(vdev, req-&gt;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-&gt;isr);
/* Configuration change? Tell driver if it wants to know. */
if (isr &amp; VIRTIO_PCI_ISR_CONFIG)
vp_config_changed(irq, opaque);
return vp_vring_interrupt(irq, opaque);
}
```
就像前面说的一样vp_interrupt这个中断处理函数一是处理配置变化二是处理I/O结束。第二种的调用链为vp_interrupt-&gt;vp_vring_interrupt-&gt;vring_interrupt。
```
irqreturn_t vring_interrupt(int irq, void *_vq)
{
struct vring_virtqueue *vq = to_vvq(_vq);
......
if (vq-&gt;vq.callback)
vq-&gt;vq.callback(&amp;vq-&gt;vq);
return IRQ_HANDLED;
}
```
在vring_interrupt中我们会调用callback函数这个也是在前面注册过的是virtblk_done。
接下来的调用链为virtblk_done-&gt;virtqueue_get_buf-&gt;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-&gt;last_used_idx &amp; (vq-&gt;vring.num - 1));
i = virtio32_to_cpu(_vq-&gt;vdev, vq-&gt;vring.used-&gt;ring[last_used].id);
*len = virtio32_to_cpu(_vq-&gt;vdev, vq-&gt;vring.used-&gt;ring[last_used].len);
......
/* detach_buf clears data, so grab it now. */
ret = vq-&gt;desc_state[i].data;
detach_buf(vq, i, ctx);
vq-&gt;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会收到这个通知。
<img src="https://static001.geekbang.org/resource/image/79/0c/79ad143a3149ea36bc80219940d7d00c.jpg" alt="">
## 课堂练习
请你沿着代码仔细分析并牢记virtqueue的结构以及写入和读取方式。这个结构在下面的网络传输过程中还要起大作用。
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,927 @@
<audio id="audio" title="55 | 网络虚拟化:如何成立独立的合作部?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/17/dc/17dd6491d3f574bceea1b2d7ce0029dc.mp3"></audio>
上一节,我们讲了存储虚拟化,这一节我们来讲网络虚拟化。
网络虚拟化有和存储虚拟化类似的地方例如它们都是基于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(&amp;virtio_net_info);
}
type_init(virtio_register_types)
```
Virtio Network Device这种类的定义是有多层继承关系的TYPE_VIRTIO_NET的父类是TYPE_VIRTIO_DEVICETYPE_VIRTIO_DEVICE的父类是TYPE_DEVICETYPE_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, &quot;virtio-net&quot;, VIRTIO_ID_NET, n-&gt;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-&gt;net_conf.rx_queue_size &lt; VIRTIO_NET_RX_QUEUE_MIN_SIZE ||
n-&gt;net_conf.rx_queue_size &gt; VIRTQUEUE_MAX_SIZE ||
!is_power_of_2(n-&gt;net_conf.rx_queue_size)) {
......
return;
}
if (n-&gt;net_conf.tx_queue_size &lt; VIRTIO_NET_TX_QUEUE_MIN_SIZE ||
n-&gt;net_conf.tx_queue_size &gt; VIRTQUEUE_MAX_SIZE ||
!is_power_of_2(n-&gt;net_conf.tx_queue_size)) {
......
return;
}
n-&gt;max_queues = MAX(n-&gt;nic_conf.peers.queues, 1);
if (n-&gt;max_queues * 2 + 1 &gt; VIRTIO_QUEUE_MAX) {
......
return;
}
n-&gt;vqs = g_malloc0(sizeof(VirtIONetQueue) * n-&gt;max_queues);
n-&gt;curr_queues = 1;
......
n-&gt;net_conf.tx_queue_size = MIN(virtio_net_max_tx_queue_size(n),
n-&gt;net_conf.tx_queue_size);
for (i = 0; i &lt; n-&gt;max_queues; i++) {
virtio_net_add_queue(n, i);
}
n-&gt;ctrl_vq = virtio_add_queue(vdev, 64, virtio_net_handle_ctrl);
qemu_macaddr_default_if_unset(&amp;n-&gt;nic_conf.macaddr);
memcpy(&amp;n-&gt;mac[0], &amp;n-&gt;nic_conf.macaddr, sizeof(n-&gt;mac));
n-&gt;status = VIRTIO_NET_S_LINK_UP;
if (n-&gt;netclient_type) {
n-&gt;nic = qemu_new_nic(&amp;net_virtio_info, &amp;n-&gt;nic_conf,
n-&gt;netclient_type, n-&gt;netclient_name, n);
} else {
n-&gt;nic = qemu_new_nic(&amp;net_virtio_info, &amp;n-&gt;nic_conf,
object_get_typename(OBJECT(dev)), dev-&gt;id, n);
}
......
}
```
这里面创建了一个VirtIODevice这一点和存储虚拟化也是一样的。virtio_init用来初始化这个设备。VirtIODevice结构里面有一个VirtQueue数组这就是virtio前端和后端互相传数据的队列最多有VIRTIO_QUEUE_MAX个。
刚才我们说的都是一样的地方,其实也有不一样的地方,我们下面来看。
你会发现这里面有这样的语句n-&gt;max_queues * 2 + 1 &gt; 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-&gt;vqs[index].rx_vq = virtio_add_queue(vdev, n-&gt;net_conf.rx_queue_size, virtio_net_handle_rx);
......
n-&gt;vqs[index].tx_vq = virtio_add_queue(vdev, n-&gt;net_conf.tx_queue_size, virtio_net_handle_tx_bh);
n-&gt;vqs[index].tx_bh = qemu_bh_new(virtio_net_tx_bh, &amp;n-&gt;vqs[index]);
n-&gt;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-&gt;peers.ncs;
NICState *nic;
int i, queues = MAX(1, conf-&gt;peers.queues);
......
nic = g_malloc0(info-&gt;size + sizeof(NetClientState) * queues);
nic-&gt;ncs = (void *)nic + info-&gt;size;
nic-&gt;conf = conf;
nic-&gt;opaque = opaque;
for (i = 0; i &lt; queues; i++) {
qemu_net_client_setup(&amp;nic-&gt;ncs[i], info, peers[i], model, name, NULL);
nic-&gt;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-&gt;info = info;
nc-&gt;model = g_strdup(model);
if (name) {
nc-&gt;name = g_strdup(name);
} else {
nc-&gt;name = assign_name(nc, model);
}
QTAILQ_INSERT_TAIL(&amp;net_clients, nc, next);
nc-&gt;incoming_queue = qemu_new_net_queue(qemu_deliver_packet_iov, nc);
nc-&gt;destructor = destructor;
QTAILQ_INIT(&amp;nc-&gt;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(&amp;net_clients);
if (qemu_opts_foreach(qemu_find_opts(&quot;netdev&quot;),
net_init_netdev, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts(&quot;nic&quot;), net_param_nic, NULL, errp)) {
return -1;
}
if (qemu_opts_foreach(qemu_find_opts(&quot;net&quot;), net_init_client, NULL, errp)) {
return -1;
}
return 0;
}
```
net_init_clients会解析参数。上面的参数netdev会调用net_init_netdev-&gt;net_client_init-&gt;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-&gt;net_tap_init-&gt;tap_open。
```
#define PATH_NET_TUN &quot;/dev/net/tun&quot;
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(&amp;ifr, 0, sizeof(ifr));
ifr.ifr_flags = IFF_TAP | IFF_NO_PI;
if (ioctl(fd, TUNGETFEATURES, &amp;features) == -1) {
features = 0;
}
if (features &amp; IFF_ONE_QUEUE) {
ifr.ifr_flags |= IFF_ONE_QUEUE;
}
if (*vnet_hdr) {
if (features &amp; IFF_VNET_HDR) {
*vnet_hdr = 1;
ifr.ifr_flags |= IFF_VNET_HDR;
} else {
*vnet_hdr = 0;
}
ioctl(fd, TUNSETVNETHDRSZ, &amp;len);
}
......
ret = ioctl(fd, TUNSETIFF, (void *) &amp;ifr);
......
fcntl(fd, F_SETFL, O_NONBLOCK);
return fd;
}
```
在tap_open中我们打开一个文件"/dev/net/tun"然后通过ioctl操作这个文件。这是Linux内核的一项机制和KVM机制很像。其实这就是一种通过打开这个字符设备文件然后通过ioctl操作这个文件和内核打交道来使用内核的能力。
<img src="https://static001.geekbang.org/resource/image/24/d3/243e93913b18c3ab00be5676bef334d3.png" alt="">
为什么需要使用内核的机制呢?因为网络包需要从虚拟机里面发送到虚拟机外面,发送到宿主机上的时候,必须是一个正常的网络包才能被转发。要形成一个网络包,我们那就需要经过复杂的协议栈,协议栈的复杂咱们在[发送网络包](https://time.geekbang.org/column/article/106490)那一节讲过了。
客户机会将网络包发送给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(&quot;GPL&quot;);
MODULE_ALIAS_MISCDEV(TUN_MINOR);
MODULE_ALIAS(&quot;devname:net/tun&quot;);
static int __init tun_init(void)
{
......
ret = rtnl_link_register(&amp;tun_link_ops);
......
ret = misc_register(&amp;tun_miscdev);
......
ret = register_netdevice_notifier(&amp;tun_notifier_block);
......
}
```
这里面注册了一个tun_miscdev字符设备从它的定义可以看出这就是"/dev/net/tun"字符设备。
```
static struct miscdevice tun_miscdev = {
.minor = TUN_MINOR,
.name = &quot;tun&quot;,
.nodename = &quot;net/tun&quot;,
.fops = &amp;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,
&amp;tun_proto, 0);
RCU_INIT_POINTER(tfile-&gt;tun, NULL);
tfile-&gt;flags = 0;
tfile-&gt;ifindex = 0;
init_waitqueue_head(&amp;tfile-&gt;wq.wait);
RCU_INIT_POINTER(tfile-&gt;socket.wq, &amp;tfile-&gt;wq);
tfile-&gt;socket.file = file;
tfile-&gt;socket.ops = &amp;tun_socket_ops;
sock_init_data(&amp;tfile-&gt;socket, &amp;tfile-&gt;sk);
tfile-&gt;sk.sk_write_space = tun_sock_write_space;
tfile-&gt;sk.sk_sndbuf = INT_MAX;
file-&gt;private_data = tfile;
INIT_LIST_HEAD(&amp;tfile-&gt;next);
sock_set_flag(&amp;tfile-&gt;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因为要用到内核的网络协议栈所以就需要这两个结构这在[网络协议](https://time.geekbang.org/column/article/105338)那一节已经分析过了。
所以按照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-&gt;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(&amp;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(&amp;tfile-&gt;sk), file, &amp;ifr);
......
if (copy_to_user(argp, &amp;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-&gt;private_data;
struct net_device *dev;
......
char *name;
unsigned long flags = 0;
int queues = ifr-&gt;ifr_flags &amp; IFF_MULTI_QUEUE ?
MAX_TAP_QUEUES : 1;
if (ifr-&gt;ifr_flags &amp; IFF_TUN) {
/* TUN device */
flags |= IFF_TUN;
name = &quot;tun%d&quot;;
} else if (ifr-&gt;ifr_flags &amp; IFF_TAP) {
/* TAP device */
flags |= IFF_TAP;
name = &quot;tap%d&quot;;
} else
return -EINVAL;
if (*ifr-&gt;ifr_name)
name = ifr-&gt;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-&gt;rtnl_link_ops = &amp;tun_link_ops;
dev-&gt;ifindex = tfile-&gt;ifindex;
dev-&gt;sysfs_groups[0] = &amp;tun_attr_group;
tun = netdev_priv(dev);
tun-&gt;dev = dev;
tun-&gt;flags = flags;
tun-&gt;txflt.count = 0;
tun-&gt;vnet_hdr_sz = sizeof(struct virtio_net_hdr);
tun-&gt;align = NET_SKB_PAD;
tun-&gt;filter_attached = false;
tun-&gt;sndbuf = tfile-&gt;socket.sk-&gt;sk_sndbuf;
tun-&gt;rx_batched = 0;
tun_net_init(dev);
tun_flow_init(tun);
err = tun_attach(tun, file, false);
err = register_netdevice(tun-&gt;dev);
netif_carrier_on(tun-&gt;dev);
if (netif_running(tun-&gt;dev))
netif_tx_wake_all_queues(tun-&gt;dev);
strcpy(ifr-&gt;ifr_name, tun-&gt;dev-&gt;name);
return 0;
}
```
tun_set_iff创建了struct tun_struct和struct net_device并且将这个tuntap网络设备通过register_netdevice注册到内核中。这样我们就能在宿主机上通过ip addr看到这个网卡了。
<img src="https://static001.geekbang.org/resource/image/98/fd/9826223c7375bec19bd13588f3875ffd.png" alt="">
至此宿主机上的内核的数据结构也完成了。
## 关联前端设备驱动和后端设备驱动
下面,我们来解析在客户机中发送一个网络包的时候,会发生哪些事情。
虚拟机里面的进程发送一个网络包通过文件系统和Socket调用网络协议栈到达网络设备层。只不过这个不是普通的网络设备而是virtio_net的驱动。
virtio_net的驱动程序代码在Linux操作系统的源代码里面文件名为drivers/net/virtio_net.c。
```
static __init int virtio_net_driver_init(void)
{
ret = register_virtio_driver(&amp;virtio_net_driver);
......
}
module_init(virtio_net_driver_init);
module_exit(virtio_net_driver_exit);
MODULE_DEVICE_TABLE(virtio, id_table);
MODULE_DESCRIPTION(&quot;Virtio network driver&quot;);
MODULE_LICENSE(&quot;GPL&quot;);
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-&gt;priv_flags |= IFF_UNICAST_FLT | IFF_LIVE_ADDR_CHANGE;
dev-&gt;netdev_ops = &amp;virtnet_netdev;
dev-&gt;features = NETIF_F_HIGHDMA;
dev-&gt;ethtool_ops = &amp;virtnet_ethtool_ops;
SET_NETDEV_DEV(dev, &amp;vdev-&gt;dev);
......
/* MTU range: 68 - 65535 */
dev-&gt;min_mtu = MIN_MTU;
dev-&gt;max_mtu = MAX_MTU;
/* Set up our device-specific information */
vi = netdev_priv(dev);
vi-&gt;dev = dev;
vi-&gt;vdev = vdev;
vdev-&gt;priv = vi;
vi-&gt;stats = alloc_percpu(struct virtnet_stats);
INIT_WORK(&amp;vi-&gt;config_work, virtnet_config_changed_work);
......
vi-&gt;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-&gt;curr_queue_pairs);
netif_set_real_num_rx_queues(dev, vi-&gt;curr_queue_pairs);
virtnet_init_settings(dev);
err = register_netdev(dev);
virtio_device_ready(vdev);
virtnet_set_queues(vi, vi-&gt;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 &amp; 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-&gt;sq = kzalloc(sizeof(*vi-&gt;sq) * vi-&gt;max_queue_pairs, GFP_KERNEL);
vi-&gt;rq = kzalloc(sizeof(*vi-&gt;rq) * vi-&gt;max_queue_pairs, GFP_KERNEL);
INIT_DELAYED_WORK(&amp;vi-&gt;refill, refill_work);
for (i = 0; i &lt; vi-&gt;max_queue_pairs; i++) {
vi-&gt;rq[i].pages = NULL;
netif_napi_add(vi-&gt;dev, &amp;vi-&gt;rq[i].napi, virtnet_poll,
napi_weight);
netif_tx_napi_add(vi-&gt;dev, &amp;vi-&gt;sq[i].napi, virtnet_poll_tx,
napi_tx ? napi_weight : 0);
sg_init_table(vi-&gt;rq[i].sg, ARRAY_SIZE(vi-&gt;rq[i].sg));
ewma_pkt_len_init(&amp;vi-&gt;rq[i].mrg_avg_pkt_len);
sg_init_table(vi-&gt;sq[i].sg, ARRAY_SIZE(vi-&gt;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 &lt; vi-&gt;max_queue_pairs; i++) {
callbacks[rxq2vq(i)] = skb_recv_done;
callbacks[txq2vq(i)] = skb_xmit_done;
names[rxq2vq(i)] = vi-&gt;rq[i].name;
names[txq2vq(i)] = vi-&gt;sq[i].name;
}
ret = vi-&gt;vdev-&gt;config-&gt;find_vqs(vi-&gt;vdev, total_vqs, vqs, callbacks, names, ctx, NULL);
......
for (i = 0; i &lt; vi-&gt;max_queue_pairs; i++) {
vi-&gt;rq[i].vq = vqs[rxq2vq(i)];
vi-&gt;rq[i].min_buf_len = mergeable_min_buf_len(vi, vi-&gt;rq[i].vq);
vi-&gt;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-&gt;xmit_skb-&gt; virtqueue_add_outbuf-&gt;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 = &amp;vi-&gt;sq[qnum];
int err;
struct netdev_queue *txq = netdev_get_tx_queue(dev, qnum);
bool kick = !skb-&gt;xmit_more;
bool use_napi = sq-&gt;napi.weight;
......
/* Try to transmit */
err = xmit_skb(sq, skb);
......
if (kick || netif_xmit_stopped(txq))
virtqueue_kick(sq-&gt;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 = &amp;n-&gt;vqs[vq2q(virtio_get_queue_index(vq))];
q-&gt;tx_waiting = 1;
virtio_queue_set_notification(vq, 0);
qemu_bh_schedule(q-&gt;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-&gt;n;
VirtIODevice *vdev = VIRTIO_DEVICE(n);
VirtQueueElement *elem;
int32_t num_packets = 0;
int queue_index = vq2q(virtio_get_queue_index(q-&gt;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-&gt;tx_vq, sizeof(VirtQueueElement));
out_num = elem-&gt;out_num;
out_sg = elem-&gt;out_sg;
......
ret = qemu_sendv_packet_async(qemu_get_subqueue(n-&gt;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-&gt;qemu_net_queue_send_iov-&gt;qemu_net_queue_flush-&gt;qemu_net_queue_deliver。
在qemu_net_queue_deliver中我们会调用NetQueue的deliver函数。前面qemu_new_net_queue会把deliver函数设置为qemu_deliver_packet_iov。它会调用nc-&gt;info-&gt;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-&gt;writev写入这个字符设备。
在内核的字符设备驱动中tun_chr_write_iter会被调用。
```
static ssize_t tun_chr_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
struct file *file = iocb-&gt;ki_filp;
struct tun_struct *tun = tun_get(file);
struct tun_file *tfile = file-&gt;private_data;
ssize_t result;
result = tun_get_user(tun, tfile, NULL, from,
file-&gt;f_flags &amp; 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虚拟网卡完成从虚拟机里面到宿主机的整个发送过程。
<img src="https://static001.geekbang.org/resource/image/e3/44/e329505cfcd367612f8ae47054ec8e44.jpg" alt="">
## 课堂练习
这一节我们解析的是发送过程,请你根据类似的思路,解析一下接收过程。
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,183 @@
<audio id="audio" title="06 | x86架构有了开放的架构才能打造开放的营商环境" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/3d/85835deac48171c277b1ef9e9026923d.mp3"></audio>
做生意的人最喜欢开放的营商环境,也就是说,我的这家公司,只要符合国家的法律,到哪里做生意,都能受到公平的对待,这样就不用为了适配各个地方的规则煞费苦心,只要集中精力优化自己的服务就可以了。
作为Linux操作系统何尝不是这样。如果下面的硬件环境千差万别就会很难集中精力做出让用户易用的产品。毕竟天天适配不同的平台就已经够头大了。x86架构就是这样一个开放的平台。今天我们就来解析一下它。
## 计算机的工作模式是什么样的?
还记得咱们攒电脑时买的那堆硬件吗?虽然你可以根据经验,把那些复杂的设备和连接线安装起来,但是你真的了解它们为什么要这么连接吗?
现在我就把硬件图和计算机的逻辑图对应起来,带你看看计算机的工作模式。
<img src="https://static001.geekbang.org/resource/image/fa/9b/fa6c2b6166d02ac37637d7da4e4b579b.jpeg" alt="">
对于一个计算机来讲,最核心的就是**CPU**Central Processing Unit中央处理器。这是这台计算机的大脑所有的设备都围绕它展开。
对于公司来说CPU是真正干活的将来执行项目都要靠它。
CPU就相当于咱们公司的程序员我们常说二十一世最缺的是什么是人才所以大量水平高、干活快的程序员才是营商环境中最重要的部分。
CPU和其他设备连接要靠一种叫作**总线**Bus的东西其实就是主板上密密麻麻的集成电路这些东西组成了CPU和其他设备的高速通道。
在这些设备中,最重要的是**内存**Memory。因为单靠CPU是没办法完成计算任务的很多复杂的计算任务都需要将中间结果保存下来然后基于中间结果进行进一步的计算。CPU本身没办法保存这么多中间结果这就要依赖内存了。
内存就相当于办公室,我们要看看方不方便租到办公室,有没有什么创新科技园之类的。有了共享的、便宜的办公位,公司就有注册地了。
当然总线上还有一些其他设备例如显卡会连接显示器、磁盘控制器会连接硬盘、USB控制器会连接键盘和鼠标等等。
CPU和内存是完成计算任务的核心组件所以这里我们重点介绍一下**CPU和内存是如何配合工作的**。
CPU其实也不是单纯的一块它包括三个部分运算单元、数据单元和控制单元。
**运算单元**只管算,例如做加法、做位移等等。但是,它不知道应该算哪些数据,运算结果应该放在哪里。
运算单元计算的数据如果每次都要经过总线,到内存里面现拿,这样就太慢了,所以就有了**数据单元**。数据单元包括CPU内部的缓存和寄存器组空间很小但是速度飞快可以暂时存放数据和运算结果。
有了放数据的地方,也有了算的地方,还需要有个指挥到底做什么运算的地方,这就是**控制单元**。控制单元是一个统一的指挥中心,它可以获得下一条指令,然后执行这条指令。这个指令会指导运算单元取出数据单元中的某几个数据,计算出个结果,然后放在数据单元的某个地方。
<img src="https://static001.geekbang.org/resource/image/3a/23/3afda18fc38e7e53604e9ebf9cb42023.jpeg" alt="">
每个项目都有一个项目执行计划书,里面是一行行项目执行的指令,这些都是放在档案库里面的。每个进程都有一个程序放在硬盘上,是二进制的,再里面就是一行行的指令,会操作一些数据。
进程一旦运行比如图中两个进程A和B会有独立的内存空间互相隔离程序会分别加载到进程A和进程B的内存空间里面形成各自的代码段。当然真实情况肯定比我说的要复杂的多进程的内存虽然隔离但不连续除了简单的区分代码段和数据段还会分得更细。
程序运行的过程中要操作的数据和产生的计算结果,都会放在数据段里面。**那CPU怎么执行这些程序操作这些数据产生一些结果<strong><strong>并**</strong>写入回内存呢?</strong>
CPU的控制单元里面有一个**指令指针寄存器**,它里面存放的是下一条指令在内存中的地址。控制单元会不停地将代码段的指令拿进来,先放入指令寄存器。
当前的指令分两部分,一部分是做什么操作,例如是加法还是位移;一部分是操作哪些数据。
要执行这条指令,就要把第一部分交给运算单元,第二部分交给数据单元。
数据单元根据数据的地址,从数据段里读到数据寄存器里,就可以参与运算了。运算单元做完运算,产生的结果会暂存在数据单元的数据寄存器里。最终,会有指令将数据写回内存中的数据段。
你可能会问上面算来算去执行的都是进程A里的指令那进程B呢CPU里有两个寄存器专门保存当前处理进程的代码段的起始地址以及数据段的起始地址。这里面写的都是进程A那当前执行的就是进程A的指令等切换成进程B就会执行B的指令了这个过程叫作**进程切换**Process Switch。这是一个多任务系统的必备操作我们后面有专门的章节讲这个内容这里你先有个印象。
到这里你会发现CPU和内存来来回回传数据靠的都是总线。其实总线上主要有两类数据一个是地址数据也就是我想拿内存中哪个位置的数据这类总线叫**地址总线**Address Bus另一类是真正的数据这类总线叫**数据总线**Data Bus
所以说总线其实有点像连接CPU和内存这两个设备的高速公路说总线到底是多少位就类似说高速公路有几个车道。但是这两种总线的位数意义是不同的。
地址总线的位数决定了能访问的地址范围到底有多广。例如只有两位那CPU就只能认00011011四个位置超过四个位置就区分不出来了。位数越多能够访问的位置就越多能管理的内存的范围也就越广。
而数据总线的位数决定了一次能拿多少个数据进来。例如只有两位那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数据总线和地址总线越来越宽处理能力越来越强。但是一直不能忘记三点一是标准二是开放三是兼容。因为要想如此大的一个软硬件生态都基于这个架构符合它的标准如果是封闭或者不兼容的那谁都不答应。
<img src="https://static001.geekbang.org/resource/image/54/8a/548dfd163066d061d1e882c73e7c2b8a.jpg" alt="">
## 从8086的原理说起
说完了x86的历史我们再来看x86中最经典的一款处理器8086处理器。虽然它已经很老了但是咱们现在操作系统中的很多特性都和它有关并且一直保持兼容。
我们把CPU里面的组件放大之后来看。你可以看我画的这幅图。
<img src="https://static001.geekbang.org/resource/image/2d/1c/2dc8237e996e699a0361a6b5ffd4871c.jpeg" alt="">
我们先来看数据单元。
为了暂存数据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就是出栈。
<img src="https://static001.geekbang.org/resource/image/08/47/08ea4adb633f114d788d5c6a9dae0f47.jpeg" alt="">
凡是与函数调用相关的操作都与栈紧密相关。例如A调用BB调用C。当A调用B的时候要执行B函数的逻辑因而A运行的相关信息就会被push到栈里面。当B调用C的时候同样B运行相关信息会被push到栈里面然后才运行C函数的逻辑。当C运行完毕的时候先pop出来的是BB就接着调用C之后的指令运行下去。B运行完了再pop出来的就是AA接着运行直到结束。
如果运算中需要加载内存中的数据需要通过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位的。
<img src="https://static001.geekbang.org/resource/image/e3/84/e3f4f64e6dfe5591b7d8ef346e8e8884.jpeg" alt="">
而改动比较大,有点不兼容的就是**段寄存器**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架构总体来说还是很复杂的其中和操作系统交互比较密切的部分我画了个图。在这个图中建议你重点牢记这些寄存器的作用以及段的工作模式后面我们马上就能够用到了。
<img src="https://static001.geekbang.org/resource/image/e2/76/e2e92f2239fe9b4c024d300046536d76.jpeg" alt="">
## 课堂练习
操作这些底层的寄存器往往需要使用汇编语言,操作系统的一些底层的模块也是用汇编语言写的,因而你需要简单回顾一些汇编语言中的一些简单的命令的作用。所以,今天给你留个练习题,简单了解一下这些命令。
mov, call, jmp, int, ret, add, or, xor, shl, shr, push, pop, inc, dec, sub, cmp。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,152 @@
<audio id="audio" title="07 | 从BIOS到bootloader创业伊始有活儿老板自己上" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/1c/759af1740f3fa587eab1cf0211182c1c.mp3"></audio>
有了开放的营商环境,咱们外包公司的创业之旅就要开始了。
上一节我们说x86作为一个开放的营商环境有两种模式一种模式是实模式只能寻址1M每个段最多64K。这个太小了相当于咱们创业的个体户模式。有了项目只能老板自己上本小利微万事开头难。另一种是保护模式对于32位系统能够寻址4G。这就是大买卖了老板要雇佣很多人接项目。
几乎所有成功的公司,都是从个体户模式发展壮大的,因此,这一节咱们就从系统刚刚启动的个体户模式开始说起。
## BIOS时期
当你轻轻按下计算机的启动按钮时,你的主板就加上电了。
按照我们之前说的这时候你的CPU应该开始执行指令了。你作为老板同时也作为员工要开始干活了。可是你发现这个时候还没有项目执行计划书所以你没啥可干的。
也就是说这个时候没有操作系统内存也是空的一穷二白。CPU该怎么办呢
你作为这个创业公司的老板,由于原来没开过公司,对于公司的运营当然是一脸懵的。但是我们有一个良好的营商环境,其中的创业指导中心早就考虑到这种情况了。于是,创业指导中心就给了你一套创业公司启动指导手册。你只要按着指导手册来干就行了。
<img src="https://static001.geekbang.org/resource/image/a4/6a/a4009d3de2dbae10340256af2737c26a.jpeg" alt="">
计算机系统也早有计划。在主板上,有一个东西叫**ROM**Read Only Memory只读存储器。这和咱们平常说的内存**RAM**Random Access Memory随机存取存储器不同。
咱们平时买的内存条是可读可写的这样才能保存计算结果。而ROM是只读的上面早就固化了一些初始化的程序也就是**BIOS**Basic Input and Output System基本输入输出系统
如果你自己安装过操作系统刚启动的时候按某个组合键显示器会弹出一个蓝色的界面。能够调整启动顺序的系统就是我说的BIOS然后我们就可以先执行它。
<img src="https://static001.geekbang.org/resource/image/13/b7/13187b1ffe878bc406da53967e8cddb7.png" alt="">
创业初期你的办公室肯定很小。假如现在你有1M的内存地址空间。这个空间非常有限你需要好好利用才行。
<img src="https://static001.geekbang.org/resource/image/5f/fc/5f364ef5c9d1a3b1d9bb7153bd166bfc.jpeg" alt="">
在x86系统中将1M空间最上面的0xF0000到0xFFFFF这64K映射给ROM也就是说到这部分地址访问的时候会访问ROM。
当电脑刚加电的时候会做一些重置的工作将CS设置为0xFFFF将IP设置为0x0000所以第一条指令就会指向0xFFFF0正是在ROM的范围内。在这里有一个JMP命令会跳到ROM中做初始化工作的代码于是BIOS开始进行初始化的工作。
创业指导手册第一条BIOS要检查一下系统的硬件是不是都好着呢。
创业指导手册第二条,要有个办事大厅,只不过自己就是办事员。这个时期你能提供的服务很简单,但也会有零星的客户来提要求。
这个时候,要建立一个中断向量表和中断服务程序,因为现在你还要用键盘和鼠标,这些都要通过中断进行的。
这个时期也要给客户输出一些结果,因为需要你自己来,所以你还要充当客户对接人。你做了什么工作,做到了什么程度,都要主动显示给客户,也就是在内存空间映射显存的空间,在显示器上显示一些字符。
<img src="https://static001.geekbang.org/resource/image/29/63/2900bed28c7345e6c90437da8a5cd563.jpeg" alt="">
最后,政府领进门,创业靠个人。接下来就是你发挥聪明才智的时候了。
## 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
}
```
这里面的选项会在系统启动的时候,成为一个列表,让你选择从哪个系统启动。最终显示出来的结果就是下面这张图。至于上面选项的具体意思,我们后面再说。
<img src="https://static001.geekbang.org/resource/image/88/97/883f3f5d4227a593228e1bcb93f67297.png" alt="">
使用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和一系列的模块组成功能比较丰富能做很多事情。
<img src="https://static001.geekbang.org/resource/image/2b/6a/2b8573bbbf31fc0cb0420e32d07b196a.jpeg" alt="">
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)才开始真正地启动内核。
## 总结时刻
启动的过程比较复杂,我这里画一个图,让你比较形象地理解这个过程。你可以根据我讲的,自己来梳理一遍这个过程,做到不管是从流程还是细节上,都能心中有数。
<img src="https://static001.geekbang.org/resource/image/0a/6b/0a29c1d3e1a53b2523d2dcab3a59886b.jpeg" alt="">
## 课堂练习
grub2是一个非常牛的Linux启动管理器请你研究一下grub2的命令和配置并试试通过它启动Ubuntu和centOS两个操作系统。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,236 @@
<audio id="audio" title="08 | 内核初始化:生意做大了就得成立公司" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/4a/01ca95e908afd2725d6b8a79db48424a.mp3"></audio>
上一节,你获得了一本《企业经营宝典》,完成了一件大事,切换到了老板角色,从实模式切换到了保护模式。有了更强的寻址能力,接下来,我们就要按照宝典里面的指引,开始经营企业了。
内核的启动从入口函数start_kernel()开始。在init/main.c文件中start_kernel相当于内核的main函数。打开这个函数你会发现里面是各种各样初始化函数XXXX_init。
<img src="https://static001.geekbang.org/resource/image/cd/01/cdfc33db2fe1e07b6acf8faa3959cb01.jpeg" alt="">
## 初始化公司职能部门
于是,公司要开始建立各种职能部门了。
首先是**项目管理部门**。咱们将来肯定要接各种各样的项目,因此,项目管理体系和项目管理流程首先要建立起来。之前讲的创建项目都是复制老项目,现在咱们需要有第一个全新的项目。这个项目需要你这个老板来打个样。
在操作系统里面先要有个创始进程有一行指令set_task_stack_end_magic(&amp;init_task)。这里面有一个参数init_task它的定义是struct task_struct init_task = INIT_TASK(init_task)。它是系统创建的第一个进程,我们称为**0号进程**。这是唯一一个没有通过fork或者kernel_thread产生的进程是进程列表的第一个。
所谓进程列表Process 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()-&gt;init_rootfs()。这里面有一行代码register_filesystem(&amp;rootfs_fs_type)。在VFS虚拟文件系统里面注册了一种类型我们定义为struct file_system_type rootfs_fs_type。
文件系统是我们的项目资料库为了兼容各种各样的文件系统我们需要将文件的相关数据结构和操作抽象出来形成一个抽象层对上提供统一的接口这个抽象层就是VFSVirtual File System虚拟文件系统。
这里的rootfs还有其他用处下面我们会用到。
<img src="https://static001.geekbang.org/resource/image/d8/f5/d85b24af560f288847ea9f3e8776adf5.jpeg" alt="">
最后start_kernel()调用的是rest_init(),用来做其他方面的初始化,这里面做了好多的工作。
## 初始化1号进程
rest_init的第一大工作是用kernel_thread(kernel_init, NULL, CLONE_FS)创建第二个进程,这个是**1号进程**。
1号进程对于操作系统来讲有“划时代”的意义。因为它将运行一个用户进程这意味着这个公司把一个老板独立完成的制度变成了可以交付他人完成的制度。这个1号进程就相当于老板带了一个大徒弟有了第一个就有第二个后面大徒弟开枝散叶带了很多徒弟形成一棵进程树。
一旦有了用户进程,公司的运行模式就要发生一定的变化。因为原来你是老板,没有雇佣其他人,所有东西都是你的,无论多么关键的资源,第一,不会有人给你抢,第二,不会有人恶意破坏、恶意使用。
但是现在有了其他人,你就要开始做一定的区分,哪些是核心资源,哪些是非核心资源;办公区也要分开,有普通的项目人员都能访问的项目工作区,还有职业核心人员能够访问的核心保密区。
好在x86提供了分层的权限机制把区域分成了四个Ring越往里权限越高越往外权限越低。
<img src="https://static001.geekbang.org/resource/image/2b/42/2b53b470673cde8f9d8e2573f7d07242.jpg" alt="">
操作系统很好地利用了这个机制将能够访问关键资源的代码放在Ring0我们称为**内核态**Kernel Mode将普通的程序代码放在Ring3我们称为**用户态**User Mode
你别忘了,现在咱们的系统已经处于保护模式了,保护模式除了可访问空间大一些,还有另一个重要功能,就是“保护”,也就是说,当处于用户态的代码想要执行更高权限的指令,这种行为是被禁止的,要防止他们为所欲为。
如果用户态的代码想要访问核心资源,怎么办呢?咱们不是有提供系统调用的办事大厅吗?这里是统一的入口,用户态代码在这里请求就是了。办事大厅后面就是内核态,用户态代码不用管后面发生了什么,做完了返回结果就可以了。
当一个用户态的程序运行到一半,要访问一个核心资源,例如访问网卡发一个网络包,就需要暂停当前的运行,调用系统调用,接下来就轮到内核中的代码运行了。
首先,内核将从系统调用传过来的包,在网卡上排队,轮到的时候就发送。发送完了,系统调用就结束了,返回用户态,让暂停运行的程序接着运行。
这个暂停怎么实现呢?其实就是把程序运行到一半的情况保存下来。例如,我们知道,内存是用来保存程序运行时候的中间结果的,现在要暂时停下来,这些中间结果不能丢,因为再次运行的时候,还要基于这些中间结果接着来。另外就是,当前运行到代码的哪一行了,当前的栈在哪里,这些都是在寄存器里面的。
所以暂停的那一刻要把当时CPU的寄存器的值全部暂存到一个地方这个地方可以放在进程管理系统很容易获取的地方。在后面讨论进程管理数据结构的时候我们还会详细讲。当系统调用完毕返回的时候再从这个地方将寄存器的值恢复回去就能接着运行了。
<img src="https://static001.geekbang.org/resource/image/71/e6/71b04097edb2d47f01ab5585fd2ea4e6.jpeg" alt="">
这个过程就是这样的:用户态-系统调用-保存寄存器-内核态执行系统调用-恢复寄存器-返回用户态,然后接着运行。
<img src="https://static001.geekbang.org/resource/image/d2/14/d2fce8af88dd278670395ce1ca6d4d14.jpg" alt="">
### 从内核态到用户态
我们再回到1号进程启动的过程。当前执行kernel_thread这个函数的时候我们还在内核态现在我们就来跨越这道屏障到用户态去运行一个程序。这该怎么办呢很少听说“先内核态再用户态”的。
kernel_thread的参数是一个函数kernel_init也就是这个进程会运行这个函数。在kernel_init里面会调用kernel_init_freeable(),里面有这样的代码:
```
if (!ramdisk_execute_command)
ramdisk_execute_command = &quot;/init&quot;;
```
先不管ramdisk是啥我们回到kernel_init里面。这里面有这样的代码块
```
if (ramdisk_execute_command) {
ret = run_init_process(ramdisk_execute_command);
......
}
......
if (!try_to_run_init_process(&quot;/sbin/init&quot;) ||
!try_to_run_init_process(&quot;/etc/init&quot;) ||
!try_to_run_init_process(&quot;/bin/init&quot;) ||
!try_to_run_init_process(&quot;/bin/sh&quot;))
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-&gt;do_execveat_common-&gt;exec_binprm-&gt;search_binary_handler这里面会调用这段内容
```
int search_binary_handler(struct linux_binprm *bprm)
{
......
struct linux_binfmt *fmt;
......
retval = fmt-&gt;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-&gt;fs = 0;
regs-&gt;ds = __USER_DS;
regs-&gt;es = __USER_DS;
regs-&gt;ss = __USER_DS;
regs-&gt;cs = __USER_CS;
regs-&gt;ip = new_ip;
regs-&gt;sp = new_sp;
regs-&gt;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负责所有内核态的线程的调度和管理是内核态所有线程运行的祖先。
这下好了,用户态和内核态都有人管了,可以开始接项目了。
## 总结时刻
这一节,我们讲了内核的初始化过程,主要做了以下几件事情:
<li>
各个职能部门的创建;
</li>
<li>
用户态祖先进程的创建;
</li>
<li>
内核态祖先进程的创建。
</li>
咱们还是用一个图来总结一下这个过程。
<img src="https://static001.geekbang.org/resource/image/75/cd/758c283cf7633465d24ab3ef778328cd.jpeg" alt="">
## 课堂练习
这一节,我们看到内核创建了一些进程,这些进程都是放在一个列表中的,请你研读内核代码,看看这个列表是如何实现的。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。

View File

@@ -0,0 +1,410 @@
<audio id="audio" title="09 | 系统调用:公司成立好了就要开始接项目" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/7c/27751b4781f548fd8fcf7553b6dd187c.mp3"></audio>
上一节,系统终于进入了用户态,公司由一个“皮包公司”进入正轨,可以开始接项目了。
这一节我们来解析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-&gt;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-&gt;orig_ax;
......
if (likely(nr &lt; IA32_NR_syscalls)) {
regs-&gt;ax = ia32_sys_call_table[nr](
(unsigned int)regs-&gt;bx, (unsigned int)regs-&gt;cx,
(unsigned int)regs-&gt;dx, (unsigned int)regs-&gt;si,
(unsigned int)regs-&gt;di, (unsigned int)regs-&gt;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位的系统调用是如何执行的。
<img src="https://static001.geekbang.org/resource/image/56/06/566299fe7411161bae25b62e7fe20506.jpg" alt="">
## 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-&gt;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-&gt;ss */
pushq PER_CPU_VAR(rsp_scratch) /* pt_regs-&gt;sp */
pushq %r11 /* pt_regs-&gt;flags */
pushq $__USER_CS /* pt_regs-&gt;cs */
pushq %rcx /* pt_regs-&gt;ip */
pushq %rax /* pt_regs-&gt;orig_ax */
pushq %rdi /* pt_regs-&gt;di */
pushq %rsi /* pt_regs-&gt;si */
pushq %rdx /* pt_regs-&gt;dx */
pushq %rcx /* pt_regs-&gt;cx */
pushq $-ENOSYS /* pt_regs-&gt;ax */
pushq %r8 /* pt_regs-&gt;r8 */
pushq %r9 /* pt_regs-&gt;r9 */
pushq %r10 /* pt_regs-&gt;r10 */
pushq %r11 /* pt_regs-&gt;r11 */
sub $(6*8), %rsp /* pt_regs-&gt;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-&gt;do_syscall_64。
```
__visible void do_syscall_64(struct pt_regs *regs)
{
struct thread_info *ti = current_thread_info();
unsigned long nr = regs-&gt;orig_ax;
......
if (likely((nr &amp; __SYSCALL_MASK) &lt; NR_syscalls)) {
regs-&gt;ax = sys_call_table[nr &amp; __SYSCALL_MASK](
regs-&gt;di, regs-&gt;si, regs-&gt;dx,
regs-&gt;r10, regs-&gt;r8, regs-&gt;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位的系统调用是如何执行的。
<img src="https://static001.geekbang.org/resource/image/1f/d7/1fc62ab8406c218de6e0b8c7e01fdbd7.jpg" alt="">
## 系统调用表
前面我们重点关注了系统调用的方式,都是最终到了系统调用表,但是到底调用内核的什么函数,还没有解读。
现在我们再来看系统调用表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 &amp; below is removed.
*/
[0 ... __NR_syscall_compat_max] = &amp;sys_ni_syscall,
#include &lt;asm/syscalls_32.h&gt;
};
```
同理在文件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 &amp; below is removed.
*/
[0 ... __NR_syscall_max] = &amp;sys_ni_syscall,
#include &lt;asm/syscalls_64.h&gt;
};
```
## 总结时刻
系统调用的过程还是挺复杂的吧如果加上上一节的内核态和用户态的模式切换就更复杂了。这里我们重点分析64位的系统调用我将整个完整的过程画了一张图帮你总结、梳理一下。
<img src="https://static001.geekbang.org/resource/image/86/a5/868db3f559ad08659ddc74db07a9a0a5.jpg" alt="">
## 课堂练习
请你根据这一节的分析看一下与open这个系统调用相关的文件都有哪些在每个文件里面都做了什么如果你要自己实现一个系统调用能不能照着open来一个呢
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,332 @@
<audio id="audio" title="27 | 文件系统:项目成果要归档,我们就需要档案库" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/92/e4/92b69c9fc81719fa6666a7cfd5fb01e4.mp3"></audio>
咱们花了这么长的时间,规划了会议室管理系统,这样多个项目执行的时候,隔离性可以得到保证。但是,会议室里面保存的资料还是暂时的,一旦项目结束,会议室会被回收,会议室里面的资料就丢失了。有一些资料我们希望项目结束也能继续保存,这就需要一个和项目运行生命周期无关的地方,可以永久保存,并且空间也要比会议室大得多。
## 文件系统的功能规划
要知道,这些资料才是咱们公司的财富,是执行多个项目积累下来的,是公司竞争力的保证,需要有一个地方归档。这就需要我们有一个存放资料的档案库,在操作系统中就是**文件系统**。那我们应该如何组织规划文件系统这个档案库呢?
对于运行的进程来说,内存就像一个纸箱子,仅仅是一个暂存数据的地方,而且空间有限。如果我们想要进程结束之后,数据依然能够保存下来,就不能只保存在内存里,而是应该保存在外部存储中。就像图书馆这种地方,不仅空间大,而且能够永久保存。
我们最常用的外部存储就是硬盘,数据是以文件的形式保存在硬盘上的。为了管理这些文件,我们在规划文件系统的时候,需要考虑到以下几点。
**第一点,文件系统要有严格的组织形式,使得文件能够以块为单位进行存储**。这就像图书馆里,我们会设置一排排书架,然后再把书架分成一个个小格子,有的项目存放的资料非常多,一个格子放不下,就需要多个格子来存放。我们把这个区域称为存放原始资料的仓库区。
**第二点,文件系统中也要有索引区,用来方便查找一个文件分成的多个块都存放在了什么位置**。这就好比,图书馆的书太多了,为了方便查找,我们需要专门设置一排书架,这里面会写清楚整个档案库有哪些资料,资料在哪个架子的哪个格子上。这样找资料的时候就不用跑遍整个档案库,在这个书架上找到后,直奔目标书架就可以了。
<img src="https://static001.geekbang.org/resource/image/93/07/93bf5e8e940752b32531ed6752b5f607.png" alt="">
**第三点,如果文件系统中有的文件是热点文件,近期经常被读取和写入,文件系统应该有缓存层**。这就相当于图书馆里面的热门图书区,这里面的书都是畅销书或者是常常被借还的图书。因为借还的次数比较多,那就没必要每次有人还了之后,还放回遥远的货架,我们可以专门开辟一个区域,放置这些借还频次高的图书。这样借还的效率就会提高。
**第四点,文件应该用文件夹的形式组织起来,方便管理和查询**。这就像在图书馆里面,你可以给这些资料分门别类,比如分成计算机类、文学类、历史类等等。这样你也容易管理,项目组借阅的时候只要在某个类别中去找就可以了。
在文件系统中,每个文件都有一个名字,这样我们访问一个文件,希望通过它的名字就可以找到。文件名就是一个普通的文本。当然文件名会经常冲突,不同用户取相同的名字的情况还是会经常出现的。
要想把很多的文件有序地组织起来,我们就需要把它们成为目录或者文件夹。这样,一个文件夹里可以包含文件夹,也可以包含文件,这样就形成了一种树形结构。而我们可以将不同的用户放在不同的用户目录下,就可以一定程度上避免了命名的冲突问题。
<img src="https://static001.geekbang.org/resource/image/e7/4f/e71da53d6e2e4458bcc0af1e23f08e4f.png" alt="">
如图所示不同的用户的文件放在不同的目录下虽然很多文件都叫“文件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的结果的第一位标识位看出来。
<li>
-表示普通文件;
</li>
<li>
d表示文件夹
</li>
<li>
c表示字符设备文件这在设备那一节讲解
</li>
<li>
b表示块设备文件这也在设备那一节讲解
</li>
<li>
s表示套接字socket文件这在网络那一节讲解
</li>
<li>
l表示符号链接也即软链接就是通过名字指向另外一个文件例如下面的代码instance这个文件就是指向了/var/lib/cloud/instances这个文件。软链接的机制我们这一章会讲解。
</li>
```
# ls -l
lrwxrwxrwx 1 root root 61 Dec 14 19:53 instance -&gt; /var/lib/cloud/instances
```
## 文件系统相关系统调用
看完了命令行,我们来看一下,如何使用系统调用操作文件?我们先来看一个完整的例子。
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;unistd.h&gt;
#include &lt;fcntl.h&gt;
int main(int argc, char *argv[])
{
int fd = -1;
int ret = 1;
int buffer = 1024;
int num = 0;
if((fd=open(&quot;./test&quot;, O_RDWR|O_CREAT|O_TRUNC))==-1)
{
printf(&quot;Open Error\n&quot;);
exit(1);
}
ret = write(fd, &amp;buffer, sizeof(int));
if( ret &lt; 0)
{
printf(&quot;write Error\n&quot;);
exit(1);
}
printf(&quot;write %d byte(s)\n&quot;,ret);
lseek(fd, 0L, SEEK_SET);
ret= read(fd, &amp;num, sizeof(int));
if(ret==-1)
{
printf(&quot;read Error\n&quot;);
exit(1);
}
printf(&quot;read %d byte(s)the number is %d\n&quot;, ret, num);
close(fd);
return 0;
}
```
当使用系统调用open打开一个文件时操作系统会创建一些数据结构来表示这个被打开的文件。下一节我们就会看到这些。为了能够找到这些数据结构在进程中我们会为这个打开的文件分配一个文件描述符fdFile Descriptor
文件描述符就是用来区分一个进程打开的多个文件的。它的作用域就是当前进程出了当前进程这个文件描述符就没有意义了。open返回的fd必须记录好我们对这个文件的所有操作都要靠这个fd包括最后关闭文件。
在Open函数中有一些参数
<li>
O_CREAT表示当文件不存在创建一个新文件
</li>
<li>
O_RDWR表示以读写方式打开
</li>
<li>
O_TRUNC表示打开文件后将文件的长度截断为0。
</li>
接下来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 &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;unistd.h&gt;
#include &lt;fcntl.h&gt;
#include &lt;sys/types.h&gt;
#include &lt;sys/stat.h&gt;
#include &lt;dirent.h&gt;
int main(int argc, char *argv[])
{
struct stat sb;
DIR *dirp;
struct dirent *direntp;
char filename[128];
if ((dirp = opendir(&quot;/root&quot;)) == NULL) {
printf(&quot;Open Directory Error%s\n&quot;);
exit(1);
}
while ((direntp = readdir(dirp)) != NULL){
sprintf(filename, &quot;/root/%s&quot;, direntp-&gt;d_name);
if (lstat(filename, &amp;sb) == -1)
{
printf(&quot;lstat Error%s\n&quot;);
exit(1);
}
printf(&quot;name : %s, mode : %d, size : %d, user id : %d\n&quot;, direntp-&gt;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所指的目录流。
到这里,你应该既会使用系统调用操作文件,也会使用系统调用操作目录了。下一节,我们开始来看内核如何实现的。
## 总结时刻
这一节,我们对于文件系统的主要功能有了一个总体的印象,我们通过下面这张图梳理一下。
<li>
在文件系统上需要维护文件的严格的格式要通过mkfs.ext4命令来格式化为严格的格式。
</li>
<li>
每一个硬盘上保存的文件都要有一个索引,来维护这个文件上的数据块都保存在哪里。
</li>
<li>
文件通过文件夹组织起来,可以方便用户使用。
</li>
<li>
为了能够更快读取文件,内存里会分配一块空间作为缓存,让一些数据块放在缓存里面。
</li>
<li>
在内核中,要有一整套的数据结构来表示打开的文件。
</li>
<li>
在用户态,每个打开的文件都有一个文件描述符,可以通过各种文件相关的系统调用,操作这个文件描述符。
</li>
<img src="https://static001.geekbang.org/resource/image/27/50/2788a6267f8361c9b6c338b06a1afc50.png" alt="">
## 课堂练习
你可以试着将一块空闲的硬盘分区成为两块并安装不同的文件系统进行挂载。这是Linux运维人员经常做的一件事情。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,384 @@
<audio id="audio" title="28 | 硬盘文件系统:如何最合理地组织档案库的文档?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6a/e6/6a0da7ae53e1ca6bfc93896d619a66e6.mp3"></audio>
上一节,我们按照图书馆的模式,规划了档案库,也即文件系统应该有的样子。这一节,我们将这个模式搬到硬盘上来看一看。
<img src="https://static001.geekbang.org/resource/image/2e/d2/2ea68b40d928e6469233fcb4948c7cd2.jpg" alt="">
我们常见的硬盘是上面这幅图左边的样子中间圆的部分是磁盘的盘片右边的图是抽象出来的图。每一层里分多个磁道每个磁道分多个扇区每个扇区是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],直接得到保存文件内容的块。
<img src="https://static001.geekbang.org/resource/image/73/e2/73349c0fab1a92d4e1ae0c684cfe06e2.jpeg" alt="">
但是如果一个文件比较大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如何来存储呢它其实会保存成一棵树。
<img src="https://static001.geekbang.org/resource/image/b8/2a/b8f184696be8d37ad6f2e2a4f12d002a.jpeg" alt="">
树有一个个的节点有叶子节点也有分支节点。每个节点都有一个头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&gt;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的位图。
上海虹桥火车站的厕位智能引导系统,不知道你有没有见过?这个系统很厉害,我们要想知道哪个位置有没有被占用,不用挨个拉门,从这样一个电子版上就能看到了。
<img src="https://static001.geekbang.org/resource/image/d7/25/d790fb19b76d7504985639aceac43c25.jpeg" alt="">
接下来我们来看位图究竟是如何在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-&gt; do_filp_open-&gt;path_openat-&gt;do_last-&gt;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-&gt;d_inode &amp;&amp; (open_flag &amp; O_CREAT)) {
......
error = dir_inode-&gt;i_op-&gt;create(dir_inode, dentry, mode,
open_flag &amp; 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-&gt;ext4_new_inode_start_handle-&gt;__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-&gt;b_data,
EXT4_INODES_PER_GROUP(sb), ino);
......
}
```
这里面一个重要的逻辑就是从文件系统里面读取inode位图然后找到下一个为0的inode就是空闲的inode。
对于block位图在写入文件的时候也会有这个过程我就不展开说了。感兴趣的话你可以自己去找代码看。
## 文件系统的格式
看起来我们现在应该能够很顺利地通过inode位图和block位图创建文件了。如果仔细计算一下其实还是有问题的。
数据块的位图是放在一个块里面的共4k。每位表示一个数据块共可以表示$4 * 1024 * 8 = 2^{15}$个数据块。如果每个数据块也是按默认的4K最大可以表示空间为$2^{15} * 4 * 1024 = 2^{27}$个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。这里面有整个文件系统一共有多少inodes_inodes_count一共有多少块s_blocks_count_lo每个块组有多少inodes_inodes_per_group每个块组有多少块s_blocks_per_group等。这些都是这类的全局信息。
对于整个文件系统别忘了咱们讲系统启动的时候说的。如果是一个启动盘我们需要预留一块区域作为引导区所以第一个块组的前面要留1K用于启动引导区。
最终,整个文件系统格式就是下面这个样子。
<img src="https://static001.geekbang.org/resource/image/e3/1b/e3718f0af6a2523a43606a0c4003631b.jpeg" alt="">
这里面我还需要重点说一下,超级块和块组描述符表都是全局信息,而且这些数据很重要。如果这些数据丢失了,整个文件系统都打不开了,这比一个文件的一个块损坏更严重。所以,这两部分我们都需要备份,但是采取不同的策略。
默认情况下,超级块和块组描述符表都有副本保存在每一个块组里面。
如果开启了sparse_super特性超级块和块组描述符表的副本只会保存在块组索引为0、3、5、7的整数幂里。除了块组0中存在一个超级块外在块组1$3^0=1$的第一个块中存在一个副本在块组3$3^1=3$、块组5$5^1=5$、块组7$7^1=7$、块组9$3^2=9$、块组25$5^2=25$、块组27$3^3=27$的第一个block处也存在一个副本。
对于超级块来讲由于超级块不是很大所以就算我们备份多了也没有太多问题。但是对于块组描述符表来讲如果每个块组里面都保存一份完整的块组描述符表一方面很浪费空间另一个方面由于一个块组最大128M而块组描述符表里面有多少项这就限制了有多少个块组128M * 块组的总数目是整个文件系统的大小,就被限制住了。
我们的改进的思路就是引入**Meta Block Groups特性**。
首先块组描述符表不会保存所有块组的描述符了而是将块组分成多个组我们称为元块组Meta Block Group。每个元块组里面的块组描述符表仅仅包括自己的一个元块组包含64个块组这样一个元块组中的块组描述符表最多64项。我们假设一共有256个块组原来是一个整的块组描述符表里面有256项要备份就全备份现在分成4个元块组每个元块组里面的块组描述符表就只有64项了这就小多了而且四个元块组自己备份自己的。
<img src="https://static001.geekbang.org/resource/image/b0/b9/b0bf4690882253a70705acc7368983b9.jpeg" alt="">
根据图中每一个元块组包含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多的文件分散到很多的块里面可以很快地进行查找。
<img src="https://static001.geekbang.org/resource/image/3e/6d/3ea2ad5704f20538d9c911b02f42086d.jpeg" alt="">
## 软链接和硬链接的存储格式
还有一种特殊的文件格式硬链接Hard Link和软链接Symbolic Link。在讲操作文件的命令的时候我们讲过软链接的概念。所谓的链接Link我们可以认为是文件的别名而链接又可分为两种硬链接与软链接。通过下面的命令可以创建。
```
ln [参数][源文件或目录][目标文件或目录]
```
ln -s创建的是软链接不带-s创建的是硬链接。它们有什么区别呢在文件系统里面是怎么保存的呢
<img src="https://static001.geekbang.org/resource/image/45/7b/45a6cfdd9d45e30dc2f38f0d2572be7b.jpeg" alt="">
如图所示硬链接与原始文件共用一个inode的但是inode是不跨文件系统的每个文件系统都有自己的inode列表因而硬链接是没有办法跨文件系统的。
而软链接不同软链接相当于重新创建了一个文件。这个文件也有独立的inode只不过打开这个文件看里面内容的时候内容指向另外的一个文件。这就很灵活了。我们可以跨文件系统甚至目标文件被删除了链接文件还是在的只不过指向的文件找不到了而已。
## 总结时刻
这一节我们描述了复杂的硬盘上的文件系统但是对于咱们平时的应用来讲用的最多的是两个概念一个是inode一个是数据块。
这里我画了一张图来总结一下inode和数据块在文件系统上的关联关系。
为了表示图中上半部分的那个简单的树形结构在文件系统上的布局就像图的下半部分一样。无论是文件夹还是文件都有一个inode。inode里面会指向数据块对于文件夹的数据块里面是一个表是下一层的文件名和inode的对应关系文件的数据块里面存放的才是真正的数据。
<img src="https://static001.geekbang.org/resource/image/f8/38/f81bf3e5a6cd060c3225a8ae1803a138.png" alt="">
## 课堂练习
你知道如何查看inode的内容和文件夹的内容吗
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,438 @@
<audio id="audio" title="29 | 虚拟文件系统:文件多了就需要档案管理系统" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4e/50/4e86134e9bdcf5fcdffeac276c05e550.mp3"></audio>
上一节,咱们的图书馆书架,也就是硬盘上的文件系统格式都搭建好了,现在我们还需要一个图书管理与借阅系统,也就是文件管理模块,不然我们怎么知道书都借给谁了呢?
进程要想往文件系统里面读写数据,需要很多层的组件一起合作。具体是怎么合作的呢?我们一起来看一看。
- 在应用层进程在进行文件读写操作时可通过系统调用如sys_open、sys_read、sys_write等。
- 在内核,每个进程都需要为打开的文件,维护一定的数据结构。
- 在内核,整个系统打开的文件,也需要维护一定的数据结构。
- Linux可以支持多达数十种不同的文件系统。它们的实现各不相同因此Linux内核向用户空间提供了虚拟文件系统这个统一的接口来对文件系统进行操作。它提供了常见的文件系统对象模型例如inode、directory entry、mount等以及操作这些对象的方法例如inode operations、directory operations、file operations等。
- 然后就是对接的是真正的文件系统例如我们上节讲的ext4文件系统。
- 为了读写ext4文件系统要通过块设备I/O层也即BIO层。这是文件系统层和块设备驱动的接口。
- 为了加快块设备的读写效率,我们还有一个缓存层。
- 最下层是块设备驱动程序。
<img src="https://static001.geekbang.org/resource/image/3c/73/3c506edf93b15341da3db658e9970773.jpg" alt="">
接下来我们逐层解析。
在这之前,有一点你需要注意。解析系统调用是了解内核架构最有力的一把钥匙,这里我们只要重点关注这几个最重要的系统调用就可以了:
- mount系统调用用于挂载文件系统
- open系统调用用于打开或者创建文件创建要在flags中设置O_CREAT对于读写要设置flags为O_RDWR
- read系统调用用于读取文件内容
- write系统调用用于写入文件内容。
## 挂载文件系统
想要操作文件系统,第一件事情就是挂载文件系统。
内核是不是支持某种类型的文件系统需要我们进行注册才能知道。例如咱们上一节解析的ext4文件系统就需要通过register_filesystem进行注册传入的参数是ext4_fs_type表示注册的是ext4类型的文件系统。这里面最重要的一个成员变量就是ext4_mount。记住它这个我们后面还会用。
```
register_filesystem(&amp;ext4_fs_type);
static struct file_system_type ext4_fs_type = {
.owner = THIS_MODULE,
.name = &quot;ext4&quot;,
.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-&gt;do_new_mount-&gt;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-&gt;mnt.mnt_root = root;
mnt-&gt;mnt.mnt_sb = root-&gt;d_sb;
mnt-&gt;mnt_mountpoint = mnt-&gt;mnt.mnt_root;
mnt-&gt;mnt_parent = mnt;
list_add_tail(&amp;mnt-&gt;mnt_instance, &amp;root-&gt;d_sb-&gt;s_mounts);
return &amp;mnt-&gt;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-&gt;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是装载点在父文件系统中的dentrystruct dentry表示目录并和目录的inode关联mnt_root是当前文件系统根目录的dentrymnt_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-&gt;mount(type, flags, name, data);
......
sb = root-&gt;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。
为了维护这些关系,操作系统创建了这一系列数据结构。具体你可以看下面的图。
<img src="https://static001.geekbang.org/resource/image/66/27/663b3c5903d15fd9ba52f6d049e0dc27.jpeg" alt="">
文件系统是树形关系。如果所有的文件夹都是几代单传,那就变成了一条线。你注意看图中的三条斜线。
第一条线是最左边的向左斜的**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系统调用说起。
在[系统调用](https://time.geekbang.org/column/article/89251)的那一节我们知道在进程里面通过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 &gt;= 0) {
struct file *f = do_filp_open(dfd, tmp, &amp;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(&amp;nd, dfd, pathname);
filp = path_openat(&amp;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)) &amp;&amp;
(error = do_last(nd, file, op, &amp;opened)) &gt; 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, &amp;path, &amp;inode, &amp;seq);
......
error = lookup_open(nd, &amp;path, file, op, got_write, opened);
......
error = vfs_open(&amp;nd-&gt;path, file, current_cred());
......
}
```
在这里面我们需要先查找文件路径最后一部分对应的dentry。如何查找呢
Linux为了提高目录项对象的处理效率设计与实现了目录项高速缓存dentry cache简称dcache。它主要由两个数据结构组成
- 哈希表dentry_hashtabledcache中的所有dentry对象都通过d_hash指针链到相应的dentry哈希链表中
- 未使用的dentry对象链表s_dentry_lrudentry对象通过其d_lru指针链入LRU链表中。LRU的意思是最近最少使用我们已经好几次看到它了。只要有它就说明长时间不使用就应该释放了。
<img src="https://static001.geekbang.org/resource/image/82/59/82dd76e1e84915206eefb8fc88385859.jpeg" alt="">
这两个列表之间会产生复杂的关系:
- 引用为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, &amp;nd-&gt;last, &amp;wq);
......
struct dentry *res = dir_inode-&gt;i_op-&gt;lookup(dir_inode, dentry,
nd-&gt;flags);
......
path-&gt;dentry = dentry;
path-&gt;mnt = nd-&gt;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-&gt;dentry, NULL, file-&gt;f_flags, 0);
......
file-&gt;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-&gt;f_mode = OPEN_FMODE(f-&gt;f_flags) | FMODE_LSEEK |
FMODE_PREAD | FMODE_PWRITE;
path_get(&amp;f-&gt;f_path);
f-&gt;f_inode = inode;
f-&gt;f_mapping = inode-&gt;i_mapping;
......
f-&gt;f_op = fops_get(inode-&gt;i_fop);
......
open = f-&gt;f_op-&gt;open;
......
error = open(inode, f);
......
f-&gt;f_flags &amp;= ~(O_CREAT | O_EXCL | O_NOCTTY | O_TRUNC);
file_ra_state_init(&amp;f-&gt;f_ra, f-&gt;f_mapping-&gt;host-&gt;i_mapping);
return 0;
......
}
const struct file_operations ext4_file_operations = {
......
.open = ext4_file_open,
......
};
```
vfs_open里面最终要做的一件事情是调用f_op-&gt;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;
}
```
## 总结时刻
对于虚拟文件系统的解析就到这里了,我们可以看出,有关文件的数据结构层次多,而且很复杂,就得到了下面这张图,这张图在这个专栏最开始的时候,已经展示过一遍,到这里,你应该能明白它们之间的关系了。
<img src="https://static001.geekbang.org/resource/image/80/b9/8070294bacd74e0ac5ccc5ac88be1bb9.png" alt="">
这张图十分重要,一定要掌握。因为我们后面的字符设备、块设备、管道、进程间通信、网络等等,全部都要用到这里面的知识。希望当你再次遇到它的时候,能够马上说出各个数据结构之间的关系。
这里我带你简单做一个梳理,帮助你理解记忆它。
对于每一个进程打开的文件都有一个文件描述符在files_struct里面会有文件描述符数组。每个一个文件描述符是这个数组的下标里面的内容指向一个file结构表示打开的文件。这个结构里面有这个文件对应的inode最重要的是这个文件对应的操作file_operation。如果操作这个文件就看这个file_operation里面的定义了。
对于每一个打开的文件都有一个dentry对应虽然叫作directory entry但是不仅仅表示文件夹也表示文件。它最重要的作用就是指向这个文件对应的inode。
如果说file结构是一个文件打开以后才创建的dentry是放在一个dentry cache里面的文件关闭了他依然存在因而他可以更长期地维护内存中的文件的表示和硬盘上文件的表示之间的关系。
inode结构就表示硬盘上的inode包括块设备号等。
几乎每一种结构都有自己对应的operation结构里面都是一些方法因而当后面遇到对于某种结构进行处理的时候如果不容易找到相应的处理函数就先找这个operation结构就清楚了。
## 课堂练习
上一节的总结中,我们说,同一个文件系统中,文件夹和文件的对应关系。如果跨的是文件系统,你知道如何维护这种映射关系吗?
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,453 @@
<audio id="audio" title="30 | 文件缓存:常用文档应该放在触手可得的地方" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fb/8e/fb5daec4baa92b1c2cf0850886a3e18e.mp3"></audio>
上一节,我们讲了文件系统的挂载和文件的打开,并通过打开文件的过程,构建了一个文件管理的整套数据结构体系。其实到这里,我们还没有对文件进行读写,还属于对于元数据的操作。那这一节,我们就重点关注读写。
## 系统调用层和虚拟文件系统层
文件系统的读写其实就是调用系统函数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, &amp;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, &amp;pos);
......
}
```
对于read来讲里面调用vfs_read-&gt;__vfs_read。对于write来讲里面调用vfs_write-&gt;__vfs_write。
下面是__vfs_read和__vfs_write的代码。
```
ssize_t __vfs_read(struct file *file, char __user *buf, size_t count,
loff_t *pos)
{
if (file-&gt;f_op-&gt;read)
return file-&gt;f_op-&gt;read(file, buf, count, pos);
else if (file-&gt;f_op-&gt;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-&gt;f_op-&gt;write)
return file-&gt;f_op-&gt;write(file, p, count, pos);
else if (file-&gt;f_op-&gt;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_iterext4_file_write_iter会调用__generic_file_write_iter。
```
ssize_t
generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
......
if (iocb-&gt;ki_flags &amp; IOCB_DIRECT) {
......
struct address_space *mapping = file-&gt;f_mapping;
......
retval = mapping-&gt;a_ops-&gt;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-&gt;ki_flags &amp; IOCB_DIRECT) {
......
written = generic_file_direct_write(iocb, from);
......
} else {
......
written = generic_perform_write(file, from, iocb-&gt;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_aopsdirect_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-&gt;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-&gt;f_mapping;
const struct address_space_operations *a_ops = mapping-&gt;a_ops;
do {
struct page *page;
unsigned long offset; /* Offset into pagecache page */
unsigned long bytes; /* Bytes to write to page */
status = a_ops-&gt;write_begin(file, mapping, pos, bytes, flags,
&amp;page, &amp;fsdata);
copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
flush_dcache_page(page);
status = a_ops-&gt;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是一种日志文件系统是为了防止突然断电的时候的数据丢失引入了**日志******<strong>Journal**</strong>****模式**。日志文件系统比非日志文件系统多了一个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-&gt;__block_commit_write-&gt;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-&gt;host;
struct backing_dev_info *bdi = inode_to_bdi(inode);
struct bdi_writeback *wb = NULL;
int ratelimit;
......
if (unlikely(current-&gt;nr_dirtied &gt;= ratelimit))
balance_dirty_pages(mapping, wb, current-&gt;nr_dirtied);
......
}
```
在balance_dirty_pages_ratelimited里面发现脏页的数目超过了规定的数目就调用balance_dirty_pages-&gt;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(&amp;wb-&gt;work_lock);
if (test_bit(WB_registered, &amp;wb-&gt;state))
mod_delayed_work(bdi_wq, &amp;wb-&gt;dwork, 0);
spin_unlock_bh(&amp;wb-&gt;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 dworkbdi_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-&gt;bdi = bdi;
wb-&gt;last_old_flush = jiffies;
INIT_LIST_HEAD(&amp;wb-&gt;b_dirty);
INIT_LIST_HEAD(&amp;wb-&gt;b_io);
INIT_LIST_HEAD(&amp;wb-&gt;b_more_io);
INIT_LIST_HEAD(&amp;wb-&gt;b_dirty_time);
wb-&gt;bw_time_stamp = jiffies;
wb-&gt;balanced_dirty_ratelimit = INIT_BW;
wb-&gt;dirty_ratelimit = INIT_BW;
wb-&gt;write_bandwidth = INIT_BW;
wb-&gt;avg_write_bandwidth = INIT_BW;
spin_lock_init(&amp;wb-&gt;work_lock);
INIT_LIST_HEAD(&amp;wb-&gt;work_list);
INIT_DELAYED_WORK(&amp;wb-&gt;dwork, wb_workfn);
wb-&gt;dirty_sleep = jiffies;
......
}
#define __INIT_DELAYED_WORK(_work, _func, _tflags) \
do { \
INIT_WORK(&amp;(_work)-&gt;work, (_func)); \
__setup_timer(&amp;(_work)-&gt;timer, delayed_work_timer_fn, \
(unsigned long)(_work), \
```
这里面最重要的是INIT_DELAYED_WORK。其实就是初始化一个timer也即定时器到时候我们就执行wb_workfn这个函数。
接下来的调用链为wb_workfn-&gt;wb_do_writeback-&gt;wb_writeback-&gt;writeback_sb_inodes-&gt;__writeback_single_inode-&gt;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-&gt;ki_filp;
struct address_space *mapping = filp-&gt;f_mapping;
struct inode *inode = mapping-&gt;host;
for (;;) {
struct page *page;
pgoff_t end_index;
loff_t isize;
page = find_get_page(mapping, index);
if (!page) {
if (iocb-&gt;ki_flags &amp; 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读写的流程不一样。对于读从块设备读取到缓存中然后从缓存中拷贝到用户态。对于写从用户态拷贝到缓存设置缓存页为脏然后启动一个线程写入块设备。
<img src="https://static001.geekbang.org/resource/image/0c/65/0c49a870b9e6441381fec8d9bf3dee65.png" alt="">
## 课堂练习
你知道如何查询和清除文件系统缓存吗?
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,161 @@
<audio id="audio" title="43 | Socket通信遇上特大项目要学会和其他公司合作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1a/07/1a90cc49c2b9f27bc2ed6159f1e6f107.mp3"></audio>
上一篇预习文章说了这么多现在我们终于可以来看一下在应用层我们应该如何使用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表示IPv4AF_INET6表示IPv6。
- type表示socket类型。SOCK_STREAM顾名思义就是TCP面向流的SOCK_DGRAM就是UDP面向数据报的SOCK_RAW可以直接操作IP层或者非TCP和UDP的协议。例如ICMP。
- protocol表示的协议包括IPPROTO_TCP、IPPTOTO_UDP。
通信结束后我们还要像关闭文件一样关闭socket。
## 针对TCP应该如何编程
接下来我们来看针对TCP我们应该如何编程。
<img src="https://static001.geekbang.org/resource/image/99/da/997e39e5574252ada22220e4b3646dda.png" alt="">
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表示IPv4sin_port是端口号sin_addr是IP地址。
服务端所在的服务器可能有多个网卡、多个地址可以选择监听在一个地址也可以监听0.0.0.0表示所有的地址都监听。服务端一般要监听在一个众所周知的端口上例如Nginx一般是80Tomcat一般是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的连接了也就是著名的三次握手其实就是将客户端和服务端的状态通过三次网络交互达到初始状态是协同的状态。下图就是三次握手的序列图以及对应的状态转换。
<img src="https://static001.geekbang.org/resource/image/0e/a4/0ef257133471e95bd334383e0155fda4.png" alt="">
接下来服务端要调用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应该如何编程。
<img src="https://static001.geekbang.org/resource/image/28/b2/283b0e1c21f0277ba5b4b5cbcaca03b2.png" alt="">
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读取数据。
<img src="https://static001.geekbang.org/resource/image/d3/5c/d34e667d1c3340deb8c82a2d44f2a65c.png" alt="">
## 课堂练习
请你根据今天讲的socket系统调用写一个简单的socket程序来传输一个字符串。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,105 @@
<audio id="audio" title="43 预习 | Socket通信之网络协议基本原理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f7/92/f72ff7d906f3210b035248fd2b899892.mp3"></audio>
上一节我们讲的进程间通信其实是通过内核的数据结构完成的主要用于在一台Linux上两个进程之间的通信。但是一旦超出一台机器的范畴我们就需要一种跨机器的通信机制。
一台机器将自己想要表达的内容,按照某种约定好的格式发送出去,当另外一台机器收到这些信息后,也能够按照约定好的格式解析出来,从而准确、可靠地获得发送方想要表达的内容。这种约定好的格式就是**网络协议**Networking Protocol
我们将要讲的Socket通信以及相关的系统调用、内核机制都是基于网络协议的如果不了解网络协议的机制解析Socket的过程中你就会迷失方向因此这一节我们有必要做一个预习先来大致讲一下网络协议的基本原理。
## 网络为什么要分层?
我们这里先构建一个相对简单的场景,之后几节内容,我们都要基于这个场景进行讲解。
我们假设这里就涉及三台机器。Linux服务器A和Linux服务器B处于不同的网段通过中间的Linux服务器作为路由器进行转发。
<img src="https://static001.geekbang.org/resource/image/f6/0e/f6982eb85dc66bd04200474efb3a050e.png" alt="">
说到网络协议,我们还需要简要介绍一下两种网络协议模型,一种是**OSI的标准七层模型**,一种是**业界标准的TCP/IP模型**。它们的对应关系如下图所示:
<img src="https://static001.geekbang.org/resource/image/92/0e/92f8e85f7b9a9f764c71081b56286e0e.png" alt="">
为什么网络要分层呢?因为网络环境过于复杂,不是一个能够集中控制的体系。全球数以亿记的服务器和设备各有各的体系,但是都可以通过同一套网络协议栈通过切分成多个层次和组合,来满足不同服务器和设备的通信需求。
我们这里简单介绍一下网络协议的几个层次。
我们从哪一个层次开始呢从第三层网络层开始因为这一层有我们熟悉的IP地址。也因此这一层我们也叫IP层。
我们通常看到的IP地址都是这个样子的192.168.1.100/24。斜杠前面是IP地址这个地址被点分隔为四个部分每个部分8位总共是32位。斜线后面24的意思是32位中前24位是网络号后8位是主机号。
为什么要这样分呢?我们可以想象,虽然全世界组成一张大的互联网,美国的网站你也能够访问的,但是这个网络不是一整个的。你们小区有一个网络,你们公司也有一个网络,联通、移动、电信运营商也各有各的网络,所以一个大网络是被分成个小的网络。
那如何区分这些网络呢?这就是网络号的概念。一个网络里面会有多个设备,这些设备的网络号一样,主机号不一样。不信你可以观察一下你家里的手机、电视、电脑。
连接到网络上的每一个设备都至少有一个IP地址用于定位这个设备。无论是近在咫尺的你旁边同学的电脑还是远在天边的电商网站都可以通过IP地址进行定位。因此**IP地址类似互联网上的邮寄地址是有全局定位功能的**。
就算你要访问美国的一个地址也可以从你身边的网络出发通过不断的打听道儿经过多个网络最终到达目的地址和快递员送包裹的过程差不多。打听道儿的协议也在第三层称为路由协议Routing protocol将网络包从一个网络转发给另一个网络的设备称为路由器。
路由器和路由协议十分复杂,我们这里就不详细讲解了,感兴趣可以去看我写的另一个专栏“趣谈网络协议”里的[相关文章](https://time.geekbang.org/column/article/8729)。
总而言之第三层干的事情就是网络包从一个起始的IP地址沿着路由协议指的道儿经过多个网络通过多次路由器转发到达目标IP地址。
从第三层我们往下看第二层是数据链路层。有时候我们简称为二层或者MAC层。所谓MAC就是每个网卡都有的唯一的硬件地址不绝对唯一相对大概率唯一即可类比[UUID](https://zh.wikipedia.org/wiki/%E9%80%9A%E7%94%A8%E5%94%AF%E4%B8%80%E8%AF%86%E5%88%AB%E7%A0%81))。这虽然也是一个地址,但是这个地址是没有全局定位功能的。
就像给你送外卖的小哥不可能根据手机尾号找到你家但是手机尾号有本地定位功能的只不过这个定位主要靠“吼”。外卖小哥到了你的楼层就开始大喊“尾号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层帮你负重前行。
传输层再往上就是应用层例如咱们在浏览器里面输入的HTTPJava服务端写的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。
<img src="https://static001.geekbang.org/resource/image/98/28/98a4496fff94eb02d1b1b8ae88f8dc28.jpeg" alt="">
在客户端浏览器我们将请求封装为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-&gt;IPv4-&gt;ARP这一条链就可以了因为后面我们的分析都是重点分析这条链。
另外,前面那个简单的拓扑图中,网络包的封装、转发、解封装的过程,建议你多看几遍,了熟于心,因为接下来,我们就能从代码层面,看到这个过程。到时候,对应起来,你就比较容易理解。
了解了Socket的基本原理下一篇文章我们就来看一看在Linux操作系统里面Socket系统调用的接口是什么样的。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,902 @@
<audio id="audio" title="44 | Socket内核数据结构如何成立特大项目合作部" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8d/d2/8d261d757e85d32fa96139356c61a3d2.mp3"></audio>
上一节我们讲了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 &amp;&amp; (flags &amp; SOCK_NONBLOCK))
flags = (flags &amp; ~SOCK_NONBLOCK) | O_NONBLOCK;
retval = sock_create(family, type, protocol, &amp;sock);
......
retval = sock_map_fd(sock, flags &amp; (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-&gt;type = type;
......
pf = rcu_dereference(net_families[family]);
......
err = pf-&gt;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_familyIP地址族的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, &amp;inetsw[sock-&gt;type], list) {
err = 0;
/* Check the non-wild match. */
if (protocol == answer-&gt;protocol) {
if (protocol != IPPROTO_IP)
break;
} else {
/* Check for the two wild cases. */
if (IPPROTO_IP == protocol) {
protocol = answer-&gt;protocol;
break;
}
if (IPPROTO_IP == answer-&gt;protocol)
break;
}
err = -EPROTONOSUPPORT;
}
......
sock-&gt;ops = answer-&gt;ops;
answer_prot = answer-&gt;prot;
answer_flags = answer-&gt;flags;
......
sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);
......
inet = inet_sk(sk);
inet-&gt;nodefrag = 0;
if (SOCK_RAW == sock-&gt;type) {
inet-&gt;inet_num = protocol;
if (IPPROTO_RAW == protocol)
inet-&gt;hdrincl = 1;
}
inet-&gt;inet_id = 0;
sock_init_data(sock, sk);
sk-&gt;sk_destruct = inet_sock_destruct;
sk-&gt;sk_protocol = protocol;
sk-&gt;sk_backlog_rcv = sk-&gt;sk_prot-&gt;backlog_rcv;
inet-&gt;uc_ttl = -1;
inet-&gt;mc_loop = 1;
inet-&gt;mc_ttl = 1;
inet-&gt;mc_all = 1;
inet-&gt;mc_index = 0;
inet-&gt;mc_list = NULL;
inet-&gt;rcv_tos = 0;
if (inet-&gt;inet_num) {
inet-&gt;inet_sport = htons(inet-&gt;inet_num);
/* Add to protocol hash chains. */
err = sk-&gt;sk_prot-&gt;hash(sk);
}
if (sk-&gt;sk_prot-&gt;init) {
err = sk-&gt;sk_prot-&gt;init(sk);
}
......
}
```
在inet_create中我们先会看到一个循环list_for_each_entry_rcu。在这里第二个参数type开始起作用。因为循环查看的是inetsw[sock-&gt;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 = &amp;inetsw[0]; r &lt; &amp;inetsw[SOCK_MAX]; ++r)
INIT_LIST_HEAD(r);
for (q = inetsw_array; q &lt; &amp;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 = &amp;tcp_prot,
.ops = &amp;inet_stream_ops,
.flags = INET_PROTOSW_PERMANENT |
INET_PROTOSW_ICSK,
},
{
.type = SOCK_DGRAM,
.protocol = IPPROTO_UDP,
.prot = &amp;udp_prot,
.ops = &amp;inet_dgram_ops,
.flags = INET_PROTOSW_PERMANENT,
},
{
.type = SOCK_DGRAM,
.protocol = IPPROTO_ICMP,
.prot = &amp;ping_prot,
.ops = &amp;inet_sockraw_ops,
.flags = INET_PROTOSW_REUSE,
},
{
.type = SOCK_RAW,
.protocol = IPPROTO_IP, /* wild card */
.prot = &amp;raw_prot,
.ops = &amp;inet_sockraw_ops,
.flags = INET_PROTOSW_REUSE,
}
}
```
我们回到inet_create的list_for_each_entry_rcu循环中。到这里就好理解了这是在inetsw数组中根据type找到属于这个类型的列表然后依次比较列表中的struct inet_protosw的protocol是不是用户指定的protocol如果是就得到了符合用户指定的family-&gt;type-&gt;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 = &quot;TCP&quot;,
.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, &amp;err, &amp;fput_needed);
if (sock) {
err = move_addr_to_kernel(umyaddr, addrlen, &amp;address);
if (err &gt;= 0) {
err = sock-&gt;ops-&gt;bind(sock,
(struct sockaddr *)
&amp;address, addrlen);
}
fput_light(sock-&gt;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-&gt;sk;
struct inet_sock *inet = inet_sk(sk);
struct net *net = sock_net(sk);
unsigned short snum;
......
snum = ntohs(addr-&gt;sin_port);
......
inet-&gt;inet_rcv_saddr = inet-&gt;inet_saddr = addr-&gt;sin_addr.s_addr;
/* Make sure we are allowed to bind here. */
if ((snum || !inet-&gt;bind_address_no_port) &amp;&amp;
sk-&gt;sk_prot-&gt;get_port(sk, snum)) {
......
}
inet-&gt;inet_sport = htons(inet-&gt;inet_num);
inet-&gt;inet_daddr = 0;
inet-&gt;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, &amp;err, &amp;fput_needed);
if (sock) {
somaxconn = sock_net(sock-&gt;sk)-&gt;core.sysctl_somaxconn;
if ((unsigned int)backlog &gt; somaxconn)
backlog = somaxconn;
err = sock-&gt;ops-&gt;listen(sock, backlog);
fput_light(sock-&gt;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-&gt;sk;
unsigned char old_state;
int err;
old_state = sk-&gt;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-&gt;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(&amp;icsk-&gt;icsk_accept_queue);
sk-&gt;sk_max_ack_backlog = backlog;
sk-&gt;sk_ack_backlog = 0;
inet_csk_delack_init(sk);
sk_state_store(sk, TCP_LISTEN);
if (!sk-&gt;sk_prot-&gt;get_port(sk, inet-&gt;inet_num)) {
......
}
......
}
```
这里面建立了一个新的结构inet_connection_sock这个结构一开始是struct inet_sockinet_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, &amp;err, &amp;fput_needed);
newsock = sock_alloc();
newsock-&gt;type = sock-&gt;type;
newsock-&gt;ops = sock-&gt;ops;
newfd = get_unused_fd_flags(flags);
newfile = sock_alloc_file(newsock, flags, sock-&gt;sk-&gt;sk_prot_creator-&gt;name);
err = sock-&gt;ops-&gt;accept(sock, newsock, sock-&gt;file-&gt;f_flags, false);
if (upeer_sockaddr) {
if (newsock-&gt;ops-&gt;getname(newsock, (struct sockaddr *)&amp;address, &amp;len, 2) &lt; 0) {
}
err = move_addr_to_user(&amp;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-&gt;ops-&gt;accept也即会调用inet_stream_ops的accept函数也即inet_accept。
```
int inet_accept(struct socket *sock, struct socket *newsock, int flags, bool kern)
{
struct sock *sk1 = sock-&gt;sk;
int err = -EINVAL;
struct sock *sk2 = sk1-&gt;sk_prot-&gt;accept(sk1, flags, &amp;err, kern);
sock_rps_record_flow(sk2);
sock_graft(sk2, newsock);
newsock-&gt;state = SS_CONNECTED;
}
```
inet_accept会调用struct sock的sk1-&gt;sk_prot-&gt;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 = &amp;icsk-&gt;icsk_accept_queue;
struct request_sock *req;
struct sock *newsk;
int error;
if (sk-&gt;sk_state != TCP_LISTEN)
goto out_err;
/* Find already established connection */
if (reqsk_queue_empty(queue)) {
long timeo = sock_rcvtimeo(sk, flags &amp; O_NONBLOCK);
error = inet_csk_wait_for_connect(sk, timeo);
}
req = reqsk_queue_remove(queue, sk);
newsk = req-&gt;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), &amp;wait,
TASK_INTERRUPTIBLE);
release_sock(sk);
if (reqsk_queue_empty(&amp;icsk-&gt;icsk_accept_queue))
timeo = schedule_timeout(timeo);
sched_annotate_sleep();
lock_sock(sk);
err = 0;
if (!reqsk_queue_empty(&amp;icsk-&gt;icsk_accept_queue))
break;
err = -EINVAL;
if (sk-&gt;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), &amp;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才不为空呢当然是三次握手结束才可以。接下来我们来分析三次握手的过程。
<img src="https://static001.geekbang.org/resource/image/ab/df/ab92c2afb4aafb53143c471293ccb2df.png" alt="">
三次握手一般是由客户端调用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, &amp;err, &amp;fput_needed);
err = move_addr_to_kernel(uservaddr, addrlen, &amp;address);
err = sock-&gt;ops-&gt;connect(sock, (struct sockaddr *)&amp;address, addrlen, sock-&gt;file-&gt;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-&gt;sk;
int err;
long timeo;
switch (sock-&gt;state) {
......
case SS_UNCONNECTED:
err = -EISCONN;
if (sk-&gt;sk_state != TCP_CLOSE)
goto out;
err = sk-&gt;sk_prot-&gt;connect(sk, uaddr, addr_len);
sock-&gt;state = SS_CONNECTING;
break;
}
timeo = sock_sndtimeo(sk, flags &amp; O_NONBLOCK);
if ((1 &lt;&lt; sk-&gt;sk_state) &amp; (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-&gt;state = SS_CONNECTED;
}
```
在__inet_stream_connect里面我们发现如果socket处于SS_UNCONNECTED状态那就调用struct sock的sk-&gt;sk_prot-&gt;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-&gt;inet_sport;
orig_dport = usin-&gt;sin_port;
rt = ip_route_connect(fl4, nexthop, inet-&gt;inet_saddr,
RT_CONN_FLAGS(sk), sk-&gt;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-&gt;inet_sport, inet-&gt;inet_dport, sk);
/* OK, now commit destination to socket. */
sk-&gt;sk_gso_type = SKB_GSO_TCPV4;
sk_setup_caps(sk, &amp;rt-&gt;dst);
if (likely(!tp-&gt;repair)) {
if (!tp-&gt;write_seq)
tp-&gt;write_seq = secure_tcp_seq(inet-&gt;inet_saddr,
inet-&gt;inet_daddr,
inet-&gt;inet_sport,
usin-&gt;sin_port);
tp-&gt;tsoffset = secure_tcp_ts_off(sock_net(sk),
inet-&gt;inet_saddr,
inet-&gt;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-&gt;sk_allocation, true);
......
tcp_init_nondata_skb(buff, tp-&gt;write_seq++, TCPHDR_SYN);
tcp_mstamp_refresh(tp);
tp-&gt;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-&gt;fastopen_req ? tcp_send_syn_data(sk, buff) :
tcp_transmit_skb(sk, buff, 1, sk-&gt;sk_allocation);
......
tp-&gt;snd_nxt = tp-&gt;write_seq;
tp-&gt;pushed_seq = tp-&gt;write_seq;
buff = tcp_send_head(sk);
if (unlikely(buff)) {
tp-&gt;snd_nxt = TCP_SKB_CB(buff)-&gt;seq;
tp-&gt;pushed_seq = TCP_SKB_CB(buff)-&gt;seq;
}
......
/* Timer for repeating the SYN until an answer. */
inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
inet_csk(sk)-&gt;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-&gt;sk_prot-&gt;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-&gt;tcp_v4_do_rcv-&gt;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-&gt;sk_state) {
......
case TCP_LISTEN:
......
if (th-&gt;syn) {
acceptable = icsk-&gt;icsk_af_ops-&gt;conn_request(sk, skb) &gt;= 0;
if (!acceptable)
return 1;
consume_skb(skb);
return 0;
}
......
}
```
目前服务端是处于TCP_LISTEN状态的而且发过来的包是SYN因而就有了上面的代码调用icsk-&gt;icsk_af_ops-&gt;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-&gt;send_synack(sk, dst, &amp;fl, req, &amp;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-&gt;sk_state) {
......
case TCP_SYN_SENT:
tp-&gt;rx_opt.saw_tstamp = 0;
tcp_mstamp_refresh(tp);
queued = tcp_rcv_synsent_state_process(sk, skb, th);
if (queued &gt;= 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-&gt;sk_state) {
case TCP_SYN_RECV:
if (req) {
inet_csk(sk)-&gt;icsk_retransmits = 0;
reqsk_fastopen_remove(sk, req, false);
} else {
/* Make sure socket is routed, for correct metrics. */
icsk-&gt;icsk_af_ops-&gt;rebuild_header(sk);
tcp_call_bpf(sk, BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB);
tcp_init_congestion_control(sk);
tcp_mtup_init(sk);
tp-&gt;copied_seq = tp-&gt;rcv_nxt;
tcp_init_buffer_space(sk);
}
smp_mb();
tcp_set_state(sk, TCP_ESTABLISHED);
sk-&gt;sk_state_change(sk);
if (sk-&gt;sk_socket)
sk_wake_async(sk, SOCK_WAKE_IO, POLL_OUT);
tp-&gt;snd_una = TCP_SKB_CB(skb)-&gt;ack_seq;
tp-&gt;snd_wnd = ntohs(th-&gt;window) &lt;&lt; tp-&gt;rx_opt.snd_wscale;
tcp_init_wl(tp, TCP_SKB_CB(skb)-&gt;seq);
break;
......
}
```
## 总结时刻
这一节除了网络包的接收和发送,其他的系统调用我们都分析到了。可以看出来,它们有一个统一的数据结构和流程。具体如下图所示:
<img src="https://static001.geekbang.org/resource/image/c0/d8/c028381cf45d65d3f148e57408d26bd8.png" alt="">
首先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是如何实现各层的函数的。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,498 @@
<audio id="audio" title="45 | 发送网络包(上):如何表达我们想让合作伙伴做什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d7/c6/d7a052488b1ddd0e4e4a70f6832f33c6.mp3"></audio>
上一节我们通过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-&gt;ki_filp;
struct socket *sock = file-&gt;private_data;
struct msghdr msg = {.msg_iter = *from,
.msg_iocb = iocb};
ssize_t res;
......
res = sock_sendmsg(sock, &amp;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-&gt;ops-&gt;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-&gt;sk;
......
return sk-&gt;sk_prot-&gt;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, &amp;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-&gt;ip_summed == CHECKSUM_NONE)
max = mss_now;
copy = max - skb-&gt;len;
}
if (copy &lt;= 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(&amp;sk-&gt;sk_write_queue);
skb = sk_stream_alloc_skb(sk,
select_size(sk, sg, first_skb),
sk-&gt;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 &gt; msg_data_left(msg))
copy = msg_data_left(msg);
/* Where to copy to? */
if (skb_availroom(skb) &gt; 0) {
/* We have some space in skb head. Superb! */
copy = min_t(int, copy, skb_availroom(skb));
err = skb_add_data_nocache(sk, skb, &amp;msg-&gt;msg_iter, copy);
......
} else {
bool merge = true;
int i = skb_shinfo(skb)-&gt;nr_frags;
struct page_frag *pfrag = sk_page_frag(sk);
......
copy = min_t(int, copy, pfrag-&gt;size - pfrag-&gt;offset);
......
err = skb_copy_to_page_nocache(sk, &amp;msg-&gt;msg_iter, skb,
pfrag-&gt;page,
pfrag-&gt;offset,
copy);
......
pfrag-&gt;offset += copy;
}
......
tp-&gt;write_seq += copy;
TCP_SKB_CB(skb)-&gt;end_seq += copy;
tcp_skb_pcount_set(skb, 0);
copied += copy;
if (!msg_data_left(msg)) {
if (unlikely(flags &amp; MSG_EOR))
TCP_SKB_CB(skb)-&gt;eor = 1;
goto out;
}
if (skb-&gt;len &lt; max || (flags &amp; MSG_OOB) || unlikely(tp-&gt;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-&gt;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 &amp; 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-&gt;data 的值,来逐步剥离协议首部。而要发送报文时,各协议会创建 sk_buff{},在经过各下层协议时,通过减少 skb-&gt;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/GatherI/O顾名思义就是IP层没必要通过内存拷贝进行聚合让散的数据零散的放在原处在设备层进行聚合。如果使用这种模式网络包的数据就不会放在连续的数据区域而是放在struct skb_shared_info结构里面指向的离散数据skb_shared_info的成员变量skb_frag_t frags[MAX_SKB_FRAGS],会指向一个数组的页面,就不能保证连续了。
<img src="https://static001.geekbang.org/resource/image/9a/b8/9ad34c3c748978f915027d5085a858b8.png" alt="">
于是我们就有了**第四步**。在注释/* 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 &gt; 1 &amp;&amp; !tcp_urg_mode(tp))
limit = tcp_mss_split_point(sk, skb, mss_now, min_t(unsigned int, cwnd_quota, max_segs), nonagle);
if (skb-&gt;len &gt; limit &amp;&amp;
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-&gt;len, mss_now)。也就是sk_buff的长度除以mss_now应该分成几个段。如果算出来要分成多个段接下来就是要看是在这里协议栈的代码里面分好还是等待到了底层网卡再分。
于是调用函数tcp_mss_split_point开始计算切分的limit。这里面会计算max_len = mss_now * max_segs根据现在不切分来计算limit所以下一步的判断中大部分情况下tso_fragment不会被调用等待到了底层网卡来切分。
第二个概念是**拥塞窗口**的概念cwndcongestion window也就是说为了避免拼命发包把网络塞满了定义一个窗口的概念在这个窗口之内的才能发送超过这个窗口的就不能发送来控制发送的频率。
那窗口大小是多少呢?就是遵循下面这个著名的拥塞窗口变化图。
<img src="https://static001.geekbang.org/resource/image/40/1f/404a6c5041452c0641ae3cba5319dc1f.png" alt="">
一开始的窗口只有一个mss大小叫作slow start慢启动。一开始的增长速度的很快的翻倍增长。一旦到达一个临界值ssthresh就变成线性增长我们就称为**拥塞避免**。什么时候算真正拥塞呢就是出现了丢包。一旦丢包一种方法是马上降回到一个mss然后重复先翻倍再线性对的过程。如果觉得太过激进也可以有第二种方法就是降到当前cwnd的一半然后进行线性增长。
在代码中tcp_cwnd_test会将当前的snd_cwnd减去已经在窗口里面尚未发送完毕的网络包那就是剩下的窗口大小cwnd_quota也即就能发送这么多了。
第三个概念就是**接收窗口**rwnd的概念receive window也叫滑动窗口。如果说拥塞窗口是为了怕把网络塞满在出现丢包的时候减少发送速度那么滑动窗口就是为了怕把接收方塞满而控制发送速度。
<img src="https://static001.geekbang.org/resource/image/97/65/9791e2f9ff63a9d8f849df7cd55fe965.png" alt="">
滑动窗口,其实就是接收方告诉发送方自己的网络包的接收能力,超过这个能力,我就受不了了。因为滑动窗口的存在,将发送方的缓存分成了四个部分。
- 第一部分:发送了并且已经确认的。这部分是已经发送完毕的网络包,这部分没有用了,可以回收。
- 第二部分:发送了但尚未确认的。这部分,发送方要等待,万一发送不成功,还要重新发送,所以不能删除。
- 第三部分:没有发送,但是已经等待发送的。这部分是接收方空闲的能力,可以马上发送,接收方收得了。
- 第四部分:没有发送,并且暂时还不会发送的。这部分已经超过了接收方的接收能力,再发送接收方就收不了了。
<img src="https://static001.geekbang.org/resource/image/b6/31/b62eea403e665bb196dceba571392531.png" alt="">
因为滑动窗口的存在,接收方的缓存也要分成了三个部分。
- 第一部分:接受并且确认过的任务。这部分完全接收成功了,可以交给应用层了。
- 第二部分:还没接收,但是马上就能接收的任务。这部分有的网络包到达了,但是还没确认,不算完全完毕,有的还没有到达,那就是接收方能够接受的最大的网络包数量。
- 第三部分:还没接收,也没法接收的任务。这部分已经超出接收方能力。
在网络包的交互过程中接收方会将第二部分的大小作为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)-&gt;seq;
max_len = mss_now * max_segs;
if (likely(max_len &lt;= window &amp;&amp; skb != tcp_write_queue_tail(sk)))
return max_len;
needed = min(skb-&gt;len, window);
if (max_len &lt;= 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-&gt;skb_mstamp = tp-&gt;tcp_mstamp;
inet = inet_sk(sk);
tcb = TCP_SKB_CB(skb);
memset(&amp;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-&gt;data;
th-&gt;source = inet-&gt;inet_sport;
th-&gt;dest = inet-&gt;inet_dport;
th-&gt;seq = htonl(tcb-&gt;seq);
th-&gt;ack_seq = htonl(tp-&gt;rcv_nxt);
*(((__be16 *)th) + 6) = htons(((tcp_header_size &gt;&gt; 2) &lt;&lt; 12) |
tcb-&gt;tcp_flags);
th-&gt;check = 0;
th-&gt;urg_ptr = 0;
......
tcp_options_write((__be32 *)(th + 1), tp, &amp;opts);
th-&gt;window = htons(min(tp-&gt;rcv_wnd, 65535U));
......
err = icsk-&gt;icsk_af_ops-&gt;queue_xmit(sk, skb, &amp;inet-&gt;cork.fl);
......
}
```
tcp_transmit_skb这个函数比较长主要做了两件事情第一件事情就是填充TCP头如果我们对着TCP头的格式。
<img src="https://static001.geekbang.org/resource/image/be/0e/be225a97816a664367f29be9046aa30e.png" alt="">
这里面有源端口设置为inet_sport有目标端口设置为inet_dport有序列号设置为tcb-&gt;seq有确认序列号设置为tp-&gt;rcv_nxt。我们把所有的flags设置为tcb-&gt;tcp_flags。设置选项为opts。设置窗口大小为tp-&gt;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,
};
```
## 总结时刻
这一节,我们解析了发送一个网络包的一部分过程,如下图所示。
<img src="https://static001.geekbang.org/resource/image/dc/44/dc66535fa7e1a10fd6d728865f6c9344.png" alt="">
这个过程分成几个层次。
- 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的包看看里面的结构。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,892 @@
<audio id="audio" title="46 | 发送网络包(下):如何表达我们想让合作伙伴做什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e7/99/e7ee28d7548651dffc3504e3b4e65299.mp3"></audio>
上一节我们讲网络包的发送讲了上半部分也即从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-&gt;inet_opt);
fl4 = &amp;fl-&gt;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-&gt;inet_daddr;
......
rt = ip_route_output_ports(net, fl4, sk,
daddr, inet-&gt;inet_saddr,
inet-&gt;inet_dport,
inet-&gt;inet_sport,
sk-&gt;sk_protocol,
RT_CONN_FLAGS(sk),
sk-&gt;sk_bound_dev_if);
if (IS_ERR(rt))
goto no_route;
sk_setup_caps(sk, &amp;rt-&gt;dst);
}
skb_dst_set_noref(skb, &amp;rt-&gt;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-&gt;opt.optlen : 0));
skb_reset_network_header(skb);
iph = ip_hdr(skb);
*((__be16 *)iph) = htons((4 &lt;&lt; 12) | (5 &lt;&lt; 8) | (inet-&gt;tos &amp; 0xff));
if (ip_dont_fragment(sk, &amp;rt-&gt;dst) &amp;&amp; !skb-&gt;ignore_df)
iph-&gt;frag_off = htons(IP_DF);
else
iph-&gt;frag_off = 0;
iph-&gt;ttl = ip_select_ttl(inet, &amp;rt-&gt;dst);
iph-&gt;protocol = sk-&gt;sk_protocol;
ip_copy_addrs(iph, fl4);
/* Transport layer set skb-&gt;h.foo itself. */
if (inet_opt &amp;&amp; inet_opt-&gt;opt.optlen) {
iph-&gt;ihl += inet_opt-&gt;opt.optlen &gt;&gt; 2;
ip_options_build(skb, &amp;inet_opt-&gt;opt, inet-&gt;inet_daddr, rt, 0);
}
ip_select_ident_segs(net, skb, sk,
skb_shinfo(skb)-&gt;gso_segs ?: 1);
/* TODO : should we use skb-&gt;sk here instead of sk ? */
skb-&gt;priority = sk-&gt;sk_priority;
skb-&gt;mark = sk-&gt;sk_mark;
res = ip_local_out(net, sk, skb);
......
}
```
在ip_queue_xmit中也即IP层的发送函数里面有三部分逻辑。
第一部分,选取路由,也即我要发送这个包应该从哪个网卡出去。
这件事情主要由ip_route_output_ports函数完成。接下来的调用链为ip_route_output_ports-&gt;ip_route_output_flow-&gt;__ip_route_output_key-&gt;ip_route_output_key_hash-&gt;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-&gt;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命令查看。
<img src="https://static001.geekbang.org/resource/image/f6/0e/f6982eb85dc66bd04200474efb3a050e.png" alt="">
```
# 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树中如下图所示
<img src="https://static001.geekbang.org/resource/image/3f/11/3f0a99cf1c47afcd0bd740c4b7802511.png" alt="">
对于将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(&amp;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-&gt;rt_genid = rt_genid_ipv4(dev_net(dev));
rt-&gt;rt_flags = flags;
rt-&gt;rt_type = type;
rt-&gt;rt_is_input = 0;
rt-&gt;rt_iif = 0;
rt-&gt;rt_pmtu = 0;
rt-&gt;rt_gateway = 0;
rt-&gt;rt_uses_gateway = 0;
rt-&gt;rt_table_id = 0;
INIT_LIST_HEAD(&amp;rt-&gt;rt_uncached);
rt-&gt;dst.output = ip_output;
if (flags &amp; RTCF_LOCAL)
rt-&gt;dst.input = ip_local_deliver;
}
return rt;
}
```
最终返回struct rtable实例第一部分也就完成了。
第二部分就是准备IP层的头往里面填充内容。这就要对着IP层的头的格式进行理解。
<img src="https://static001.geekbang.org/resource/image/6b/2b/6b2ea7148a8e04138a2228c5dbc7182b.png" alt="">
在这里面服务类型设置为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-&gt;tot_len = htons(skb-&gt;len);
skb-&gt;protocol = htons(ETH_P_IP);
return nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT,
net, sk, skb, NULL, skb_dst(skb)-&gt;dev,
dst_output);
}
```
ip_local_out先是调用__ip_local_out然后里面调用了nf_hook。这是什么呢nf的意思是Netfilter这是Linux内核的一个机制用于在网络发送和转发的关键节点上加上hook函数这些函数可以截获数据包对数据包进行干预。
一个著名的实现就是内核模块ip_tables。在用户态还有一个客户端程序iptables用命令行来干预内核的规则。
<img src="https://static001.geekbang.org/resource/image/75/4d/75c8257049eed99499e802fcc2eacf4d.png" alt="">
iptables有表和链的概念最终要的是两个表。
filter表处理过滤功能主要包含以下三个链。
- INPUT链过滤所有目标地址是本机的数据包
- FORWARD链过滤所有路过本机的数据包
- OUTPUT链过滤所有由本机产生的数据包
nat表主要处理网络地址转换可以进行SNAT改变源地址、DNAT改变目标地址包含以下三个链。
- PREROUTING链可以在数据包到达时改变目标地址
- OUTPUT链可以改变本地产生的数据包的目标地址
- POSTROUTING链在数据包离开时改变数据包的源地址
<img src="https://static001.geekbang.org/resource/image/76/da/765e5431fe4b17f62b1b5712cc82abda.png" alt="">
在这里网络包马上就要发出去了因而是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)-&gt;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)-&gt;dev;
skb-&gt;dev = dev;
skb-&gt;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)-&gt;flags &amp; 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-&gt;dev;
unsigned int hh_len = LL_RESERVED_SPACE(dev);
struct neighbour *neigh;
u32 nexthop;
......
nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)-&gt;daddr);
neigh = __ipv4_neigh_lookup_noref(dev, nexthop);
if (unlikely(!neigh))
neigh = __neigh_create(&amp;arp_tbl, &amp;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(&amp;arp_tbl, neigh_key_eq32, arp_hashfn, &amp;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 = &quot;arp_cache&quot;,
......
.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-&gt;key_len;
int error;
struct neighbour *n1, *rc, *n = neigh_alloc(tbl, dev);
struct neigh_hash_table *nht;
memcpy(n-&gt;primary_key, pkey, key_len);
n-&gt;dev = dev;
dev_hold(dev);
/* Protocol specific setup. */
if (tbl-&gt;constructor &amp;&amp; (error = tbl-&gt;constructor(n)) &lt; 0) {
......
}
......
if (atomic_read(&amp;tbl-&gt;entries) &gt; (1 &lt;&lt; nht-&gt;hash_shift))
nht = neigh_hash_grow(tbl, nht-&gt;hash_shift + 1);
hash_val = tbl-&gt;hash(pkey, dev, nht-&gt;hash_rnd) &gt;&gt; (32 - nht-&gt;hash_shift);
for (n1 = rcu_dereference_protected(nht-&gt;hash_buckets[hash_val],
lockdep_is_held(&amp;tbl-&gt;lock));
n1 != NULL;
n1 = rcu_dereference_protected(n1-&gt;next,
lockdep_is_held(&amp;tbl-&gt;lock))) {
if (dev == n1-&gt;dev &amp;&amp; !memcmp(n1-&gt;primary_key, pkey, key_len)) {
if (want_ref)
neigh_hold(n1);
rc = n1;
goto out_tbl_unlock;
}
}
......
rcu_assign_pointer(n-&gt;next,
rcu_dereference_protected(nht-&gt;hash_buckets[hash_val],
lockdep_is_held(&amp;tbl-&gt;lock)));
rcu_assign_pointer(nht-&gt;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-&gt;entry_size + dev-&gt;neigh_priv_len, GFP_ATOMIC);
if (!n)
goto out_entries;
__skb_queue_head_init(&amp;n-&gt;arp_queue);
rwlock_init(&amp;n-&gt;lock);
seqlock_init(&amp;n-&gt;ha_lock);
n-&gt;updated = n-&gt;used = now;
n-&gt;nud_state = NUD_NONE;
n-&gt;output = neigh_blackhole;
seqlock_init(&amp;n-&gt;hh.hh_lock);
n-&gt;parms = neigh_parms_clone(&amp;tbl-&gt;parms);
setup_timer(&amp;n-&gt;timer, neigh_timer_handler, (unsigned long)n);
NEIGH_CACHE_STAT_INC(tbl, allocs);
n-&gt;tbl = tbl;
refcount_set(&amp;n-&gt;refcnt, 1);
n-&gt;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-&gt;primary_key;
struct net_device *dev = neigh-&gt;dev;
struct in_device *in_dev;
struct neigh_parms *parms;
......
neigh-&gt;type = inet_addr_type_dev_table(dev_net(dev), dev, addr);
parms = in_dev-&gt;arp_parms;
__neigh_parms_put(neigh-&gt;parms);
neigh-&gt;parms = neigh_parms_clone(parms);
......
neigh-&gt;ops = &amp;arp_hh_ops;
......
neigh-&gt;output = neigh-&gt;ops-&gt;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-&gt;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-&gt;nud_state &amp; (NUD_STALE | NUD_INCOMPLETE))) {
if (NEIGH_VAR(neigh-&gt;parms, MCAST_PROBES) +
NEIGH_VAR(neigh-&gt;parms, APP_PROBES)) {
unsigned long next, now = jiffies;
atomic_set(&amp;neigh-&gt;probes,
NEIGH_VAR(neigh-&gt;parms, UCAST_PROBES));
neigh-&gt;nud_state = NUD_INCOMPLETE;
neigh-&gt;updated = now;
next = now + max(NEIGH_VAR(neigh-&gt;parms, RETRANS_TIME),
HZ/2);
neigh_add_timer(neigh, next);
immediate_probe = true;
}
......
} else if (neigh-&gt;nud_state &amp; NUD_STALE) {
neigh_dbg(2, &quot;neigh %p is delayed\n&quot;, neigh);
neigh-&gt;nud_state = NUD_DELAY;
neigh-&gt;updated = jiffies;
neigh_add_timer(neigh, jiffies +
NEIGH_VAR(neigh-&gt;parms, DELAY_PROBE_TIME));
}
if (neigh-&gt;nud_state == NUD_INCOMPLETE) {
if (skb) {
.......
__skb_queue_tail(&amp;neigh-&gt;arp_queue, skb);
neigh-&gt;arp_queue_len_Bytes += skb-&gt;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-&gt;lock)
{
struct sk_buff *skb = skb_peek_tail(&amp;neigh-&gt;arp_queue);
......
if (neigh-&gt;ops-&gt;solicit)
neigh-&gt;ops-&gt;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-&gt;dev;
struct netdev_queue *txq;
struct Qdisc *q;
......
txq = netdev_pick_tx(dev, skb, accel_priv);
q = rcu_dereference_bh(txq-&gt;qdisc);
if (q-&gt;enqueue) {
rc = __dev_xmit_skb(skb, q, dev, txq);
goto out;
}
......
}
```
就像咱们在讲述硬盘块设备的时候讲过,每个块设备都有队列,用于将内核的数据放到队列里面,然后设备驱动从队列里面取出后,将数据根据具体设备的特性发送给设备。
网络设备也是类似的对于发送来说有一个发送队列struct netdev_queue *txq。
这里还有另一个变量叫做struct Qdisc这个是什么呢如果我们在一台Linux机器上运行ip addr我们能看到对于一个网卡都有下面的输出。
```
# ip addr
1: lo: &lt;LOOPBACK,UP,LOWER_UP&gt; 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: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; 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 ServiceTOS被分配到三个波段里面的。TOS是IP头里面的一个字段代表了当前的包是高优先级的还是低优先级的。
pfifo_fast分为三个先入先出的队列我们能称为三个Band。根据网络包里面的TOS看这个包到底应该进入哪个队列。TOS总共四位每一位表示的意思不同总共十六种类型。
<img src="https://static001.geekbang.org/resource/image/ab/d9/ab6af2f9e1a64868636080a05cfde0d9.png" alt="">
通过命令行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-&gt;enqueue(skb, q, &amp;to_free) &amp; 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, &amp;packets)) {
/*
* Ordered by possible occurrence: Postpone processing if
* 1. we've exceeded packet quota
* 2. another process needs the CPU;
*/
quota -= packets;
if (quota &lt;= 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(&amp;softnet_data);
q-&gt;next_sched = NULL;
*sd-&gt;output_queue_tailp = q;
sd-&gt;output_queue_tailp = &amp;q-&gt;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(&amp;softnet_data);
......
if (sd-&gt;output_queue) {
struct Qdisc *head;
local_irq_disable();
head = sd-&gt;output_queue;
sd-&gt;output_queue = NULL;
sd-&gt;output_queue_tailp = &amp;sd-&gt;output_queue;
local_irq_enable();
while (head) {
struct Qdisc *q = head;
spinlock_t *root_lock;
head = head-&gt;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, &amp;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, &amp;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-&gt;next;
rc = xmit_one(skb, dev, txq, next != NULL);
skb = next;
if (netif_xmit_stopped(txq) &amp;&amp; skb) {
rc = NETDEV_TX_BUSY;
break;
}
}
......
}
```
在dev_hard_start_xmit中是一个while循环。每次在队列中取出一个sk_buff调用xmit_one发送。
接下来的调用链为xmit_one-&gt;netdev_start_xmit-&gt;__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-&gt;xmit_more = more ? 1 : 0;
return ops-&gt;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, &amp;adapter-&gt;tx_ring, DESC_NEEDED);
}
......
return NETDEV_TX_OK;
}
```
在ixgb_xmit_frame中我们会得到这个网卡对应的适配器然后将其放入硬件网卡的队列中。
至此,整个发送才算结束。
## 总结时刻
这一节,我们继续解析了发送一个网络包的过程,我们整个过程的图画在了下面。
<img src="https://static001.geekbang.org/resource/image/79/6f/79cc42f3163d159a66e163c006d9f36f.png" alt="">
这个过程分成几个层次。
- 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的过程。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,498 @@
<audio id="audio" title="47 | 接收网络包(上):如何搞明白合作伙伴让我们做什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d4/9d/d400e8c1fb0a87fd4e600ca7d5b39d9d.mp3"></audio>
前面两节,我们分析了发送网络包的整个过程。这一节,我们来解析接收网络包的过程。
如果说网络包的发送是从应用层开始层层调用一直到网卡驱动程序的话网络包的结束过程就是一个反过来的过程我们不能从应用层的读取开始而应该从网卡接收到一个网络包开始。我们用两节来解析这个过程这一节我们从硬件网卡解析到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 = &amp;ixgb_err_handler
};
MODULE_AUTHOR(&quot;Intel Corporation, &lt;linux.nics@intel.com&gt;&quot;);
MODULE_DESCRIPTION(&quot;Intel(R) PRO/10GbE Network Driver&quot;);
MODULE_LICENSE(&quot;GPL&quot;);
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(&quot;%s - version %s\n&quot;, ixgb_driver_string, ixgb_driver_version);
pr_info(&quot;%s\n&quot;, ixgb_copyright);
return pci_register_driver(&amp;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, &amp;pdev-&gt;dev);
pci_set_drvdata(pdev, netdev);
adapter = netdev_priv(netdev);
adapter-&gt;netdev = netdev;
adapter-&gt;pdev = pdev;
adapter-&gt;hw.back = adapter;
adapter-&gt;msg_enable = netif_msg_init(debug, DEFAULT_MSG_ENABLE);
adapter-&gt;hw.hw_addr = pci_ioremap_bar(pdev, BAR_0);
......
netdev-&gt;netdev_ops = &amp;ixgb_netdev_ops;
ixgb_set_ethtool_ops(netdev);
netdev-&gt;watchdog_timeo = 5 * HZ;
netif_napi_add(netdev, &amp;adapter-&gt;napi, ixgb_clean, 64);
strncpy(netdev-&gt;name, pci_name(pdev), sizeof(netdev-&gt;name) - 1);
adapter-&gt;bd_number = cards_found;
adapter-&gt;link_speed = 0;
adapter-&gt;link_duplex = 0;
......
}
```
在ixgb_probe中我们会创建一个struct net_device表示这个网络设备并且netif_napi_add函数为这个网络设备注册一个轮询poll函数ixgb_clean将来一旦出现网络包的时候就是要通过它来轮询了。
当一个网卡被激活的时候我们会调用函数ixgb_open-&gt;ixgb_up在这里面注册一个硬件的中断处理函数。
```
int
ixgb_up(struct ixgb_adapter *adapter)
{
struct net_device *netdev = adapter-&gt;netdev;
......
err = request_irq(adapter-&gt;pdev-&gt;irq, ixgb_intr, irq_flags,
netdev-&gt;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 = &amp;adapter-&gt;hw;
......
if (napi_schedule_prep(&amp;adapter-&gt;napi)) {
IXGB_WRITE_REG(&amp;adapter-&gt;hw, IMC, ~0);
__napi_schedule(&amp;adapter-&gt;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(&amp;softnet_data), n);
local_irq_restore(flags);
}
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi)
{
list_add_tail(&amp;napi-&gt;poll_list, &amp;sd-&gt;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(&amp;softnet_data);
LIST_HEAD(list);
list_splice_init(&amp;sd-&gt;poll_list, &amp;list);
......
for (;;) {
struct napi_struct *n;
......
n = list_first_entry(&amp;list, struct napi_struct, poll_list);
budget -= napi_poll(n, &amp;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 = &amp;adapter-&gt;rx_ring;
struct net_device *netdev = adapter-&gt;netdev;
struct pci_dev *pdev = adapter-&gt;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-&gt;next_to_clean;
rx_desc = IXGB_RX_DESC(*rx_ring, i);
buffer_info = &amp;rx_ring-&gt;buffer_info[i];
while (rx_desc-&gt;status &amp; IXGB_RX_DESC_STATUS_DD) {
struct sk_buff *skb;
u8 status;
status = rx_desc-&gt;status;
skb = buffer_info-&gt;skb;
buffer_info-&gt;skb = NULL;
prefetch(skb-&gt;data - NET_IP_ALIGN);
if (++i == rx_ring-&gt;count)
i = 0;
next_rxd = IXGB_RX_DESC(*rx_ring, i);
prefetch(next_rxd);
j = i + 1;
if (j == rx_ring-&gt;count)
j = 0;
next2_buffer = &amp;rx_ring-&gt;buffer_info[j];
prefetch(next2_buffer);
next_buffer = &amp;rx_ring-&gt;buffer_info[i];
......
length = le16_to_cpu(rx_desc-&gt;length);
rx_desc-&gt;length = 0;
......
ixgb_check_copybreak(&amp;adapter-&gt;napi, buffer_info, length, &amp;skb);
/* Good Receive */
skb_put(skb, length);
/* Receive Checksum Offload */
ixgb_rx_checksum(adapter, rx_desc, skb);
skb-&gt;protocol = eth_type_trans(skb, netdev);
netif_receive_skb(skb);
......
/* use prefetched values */
rx_desc = next_rxd;
buffer_info = next_buffer;
}
rx_ring-&gt;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-&gt;netif_receive_skb_internal-&gt;__netif_receive_skb-&gt;__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-&gt;protocol;
......
deliver_ptype_list_skb(skb, &amp;pt_prev, orig_dev, type,
&amp;orig_dev-&gt;ptype_specific);
if (pt_prev) {
ret = pt_prev-&gt;func(skb, skb-&gt;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-&gt;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(&amp;ip_packet_type)添加IP协议。协议被放在一个链表里面。
```
void dev_add_pack(struct packet_type *pt)
{
struct list_head *head = ptype_head(pt);
list_add_rcu(&amp;pt-&gt;list, head);
}
static inline struct list_head *ptype_head(const struct packet_type *pt)
{
if (pt-&gt;type == htons(ETH_P_ALL))
return pt-&gt;dev ? &amp;pt-&gt;dev-&gt;ptype_all : &amp;ptype_all;
else
return pt-&gt;dev ? &amp;pt-&gt;dev-&gt;ptype_specific : &amp;ptype_base[ntohs(pt-&gt;type) &amp; 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-&gt;tot_len);
skb-&gt;transport_header = skb-&gt;network_header + iph-&gt;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-&gt;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)-&gt;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-&gt;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-&gt;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)-&gt;protocol;
const struct net_protocol *ipprot;
ipprot = rcu_dereference(inet_protos[protocol]);
if (ipprot) {
int ret;
ret = ipprot-&gt;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 **)&amp;inet_protos[protocol],
NULL, prot) ? 0 : -1;
}
static int __init inet_init(void)
{
......
if (inet_add_protocol(&amp;udp_protocol, IPPROTO_UDP) &lt; 0)
pr_crit(&quot;%s: Cannot add UDP protocol\n&quot;, __func__);
if (inet_add_protocol(&amp;tcp_protocol, IPPROTO_TCP) &lt; 0)
pr_crit(&quot;%s: Cannot add TCP protocol\n&quot;, __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_actionnet_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。
<img src="https://static001.geekbang.org/resource/image/a5/37/a51af8ada1135101e252271626669337.png" alt="">
## 课堂练习
我们没有仔细分析对于二层VLAN的处理请你研究一下VLAN的原理然后在代码中看一下对于VLAN的处理过程这是一项重要的网络基础知识。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,529 @@
<audio id="audio" title="48 | 接收网络包(下):如何搞明白合作伙伴让我们做什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/46/35/469ce452d6a394e9c4048082bd8e1b35.mp3"></audio>
上一节我们解析了网络包接收的上半部分从硬件网卡到IP层。这一节我们接着来解析TCP层和Socket层都做了哪些事情。
## 网络协议栈的TCP层
从tcp_v4_rcv函数开始我们的处理逻辑就从IP层到了TCP层。
```
int tcp_v4_rcv(struct sk_buff *skb)
{
struct net *net = dev_net(skb-&gt;dev);
const struct iphdr *iph;
const struct tcphdr *th;
bool refcounted;
struct sock *sk;
int ret;
......
th = (const struct tcphdr *)skb-&gt;data;
iph = ip_hdr(skb);
......
TCP_SKB_CB(skb)-&gt;seq = ntohl(th-&gt;seq);
TCP_SKB_CB(skb)-&gt;end_seq = (TCP_SKB_CB(skb)-&gt;seq + th-&gt;syn + th-&gt;fin + skb-&gt;len - th-&gt;doff * 4);
TCP_SKB_CB(skb)-&gt;ack_seq = ntohl(th-&gt;ack_seq);
TCP_SKB_CB(skb)-&gt;tcp_flags = tcp_flag_byte(th);
TCP_SKB_CB(skb)-&gt;tcp_tw_isn = 0;
TCP_SKB_CB(skb)-&gt;ip_dsfield = ipv4_get_dsfield(iph);
TCP_SKB_CB(skb)-&gt;sacked = 0;
lookup:
sk = __inet_lookup_skb(&amp;tcp_hashinfo, skb, __tcp_hdrlen(th), th-&gt;source, th-&gt;dest, &amp;refcounted);
process:
if (sk-&gt;sk_state == TCP_TIME_WAIT)
goto do_time_wait;
if (sk-&gt;sk_state == TCP_NEW_SYN_RECV) {
......
}
......
th = (const struct tcphdr *)skb-&gt;data;
iph = ip_hdr(skb);
skb-&gt;dev = NULL;
if (sk-&gt;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-&gt;sk_state == TCP_ESTABLISHED) { /* Fast path */
struct dst_entry *dst = sk-&gt;sk_rx_dst;
......
tcp_rcv_established(sk, skb, tcp_hdr(skb), skb-&gt;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-&gt;sk_state) {
case TCP_CLOSE:
......
case TCP_LISTEN:
......
case TCP_SYN_SENT:
......
}
......
switch (sk-&gt;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-&gt;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所有状态的处理其中和连接建立相关的状态咱们已经分析过所以我们重点关注连接状态下的工作模式。
<img src="https://static001.geekbang.org/resource/image/38/c6/385ff4a348dfd2f64feb0d7ba81e2bc6.png" alt="">
在连接状态下我们会调用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)-&gt;seq == tp-&gt;rcv_nxt) {
if (tcp_receive_window(tp) == 0)
goto out_of_window;
/* Ok. In sequence. In window. */
if (tp-&gt;ucopy.task == current &amp;&amp;
tp-&gt;copied_seq == tp-&gt;rcv_nxt &amp;&amp; tp-&gt;ucopy.len &amp;&amp;
sock_owned_by_user(sk) &amp;&amp; !tp-&gt;urg_data) {
int chunk = min_t(unsigned int, skb-&gt;len,
tp-&gt;ucopy.len);
__set_current_state(TASK_RUNNING);
if (!skb_copy_datagram_msg(skb, 0, tp-&gt;ucopy.msg, chunk)) {
tp-&gt;ucopy.len -= chunk;
tp-&gt;copied_seq += chunk;
eaten = (chunk == skb-&gt;len);
tcp_rcv_space_adjust(sk);
}
}
if (eaten &lt;= 0) {
queue_and_out:
......
eaten = tcp_queue_rcv(sk, skb, 0, &amp;fragstolen);
}
tcp_rcv_nxt_update(tp, TCP_SKB_CB(skb)-&gt;end_seq);
......
if (!RB_EMPTY_ROOT(&amp;tp-&gt;out_of_order_queue)) {
tcp_ofo_queue(sk);
......
}
......
return;
}
if (!after(TCP_SKB_CB(skb)-&gt;end_seq, tp-&gt;rcv_nxt)) {
/* A retransmit, 2nd most common case. Force an immediate ack. */
tcp_dsack_set(sk, TCP_SKB_CB(skb)-&gt;seq, TCP_SKB_CB(skb)-&gt;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)-&gt;seq, tp-&gt;rcv_nxt + tcp_receive_window(tp)))
goto out_of_window;
tcp_enter_quickack_mode(sk);
if (before(TCP_SKB_CB(skb)-&gt;seq, tp-&gt;rcv_nxt)) {
/* Partial packet, seq &lt; rcv_next &lt; end_seq */
tcp_dsack_set(sk, TCP_SKB_CB(skb)-&gt;seq, tp-&gt;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-&gt;rcv_nxt说明来的网络包正是我服务端期望的下一个网络包。这个时候我们判断sock_owned_by_user也即用户进程也是正在等待读取这种情况下就直接skb_copy_datagram_msg将网络包拷贝给用户进程就可以了。
如果用户进程没有正在等待读取或者因为内存原因没有能够拷贝成功tcp_queue_rcv里面还是将网络包放入sk_receive_queue队列。
接下来tcp_rcv_nxt_update将tp-&gt;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-&gt;ki_filp;
struct socket *sock = file-&gt;private_data;
struct msghdr msg = {.msg_iter = *to,
.msg_iocb = iocb};
ssize_t res;
if (file-&gt;f_flags &amp; O_NONBLOCK)
msg.msg_flags = MSG_DONTWAIT;
......
res = sock_recvmsg(sock, &amp;msg, msg.msg_flags);
*to = msg.msg_iter;
return res;
}
```
在sock_read_iter中通过VFS中的struct file将创建好的socket结构拿出来然后调用sock_recvmsgsock_recvmsg会调用sock_recvmsg_nosec。
```
static inline int sock_recvmsg_nosec(struct socket *sock, struct msghdr *msg, int flags)
{
return sock-&gt;ops-&gt;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-&gt;sk;
int addr_len = 0;
int err;
......
err = sk-&gt;sk_prot-&gt;recvmsg(sk, msg, size, flags &amp; MSG_DONTWAIT,
flags &amp; ~MSG_DONTWAIT, &amp;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(&amp;sk-&gt;sk_receive_queue);
skb_queue_walk(&amp;sk-&gt;sk_receive_queue, skb) {
last = skb;
offset = *seq - TCP_SKB_CB(skb)-&gt;seq;
if (offset &lt; skb-&gt;len)
goto found_ok_skb;
......
}
......
if (!sysctl_tcp_low_latency &amp;&amp; tp-&gt;ucopy.task == user_recv) {
/* Install new reader */
if (!user_recv &amp;&amp; !(flags &amp; (MSG_TRUNC | MSG_PEEK))) {
user_recv = current;
tp-&gt;ucopy.task = user_recv;
tp-&gt;ucopy.msg = msg;
}
tp-&gt;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(&amp;tp-&gt;ucopy.prequeue))
goto do_prequeue;
}
if (copied &gt;= target) {
/* Do not sleep, just process backlog. */
release_sock(sk);
lock_sock(sk);
} else {
sk_wait_data(sk, &amp;timeo, last);
}
if (user_recv) {
int chunk;
chunk = len - tp-&gt;ucopy.len;
if (chunk != 0) {
len -= chunk;
copied += chunk;
}
if (tp-&gt;rcv_nxt == tp-&gt;copied_seq &amp;&amp;
!skb_queue_empty(&amp;tp-&gt;ucopy.prequeue)) {
do_prequeue:
tcp_prequeue_process(sk);
chunk = len - tp-&gt;ucopy.len;
if (chunk != 0) {
len -= chunk;
copied += chunk;
}
}
}
continue;
found_ok_skb:
/* Ok so how much can we use? */
used = skb-&gt;len - offset;
if (len &lt; used)
used = len;
if (!(flags &amp; MSG_TRUNC)) {
err = skb_copy_datagram_msg(skb, offset, msg, used);
......
}
*seq += used;
copied += used;
len -= used;
tcp_rcv_space_adjust(sk);
......
} while (len &gt; 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-&gt;sk_backlog.tail)
__release_sock(sk);
......
}
static void __release_sock(struct sock *sk)
__releases(&amp;sk-&gt;sk_lock.slock)
__acquires(&amp;sk-&gt;sk_lock.slock)
{
struct sk_buff *skb, *next;
while ((skb = sk-&gt;sk_backlog.head) != NULL) {
sk-&gt;sk_backlog.head = sk-&gt;sk_backlog.tail = NULL;
do {
next = skb-&gt;next;
prefetch(next);
skb-&gt;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_actionnet_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队列。
<img src="https://static001.geekbang.org/resource/image/20/52/20df32a842495d0f629ca5da53e47152.png" alt="">
## 课堂练习
对于TCP协议、三次握手、发送和接收的连接维护、拥塞控制、滑动窗口我们都解析过了。唯独四次挥手我们没有解析对应的代码你应该知道在什么地方了你可以自己试着解析一下四次挥手的过程。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,192 @@
<audio id="audio" title="31 | 输入与输出:如何建立售前售后生态体系?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/95/41/959cc496dd14b1e75283c5876ed1db41.mp3"></audio>
到这一节,操作系统作为一家外包公司,里面最核心的职能部门差不多都凑齐了。我们有了项目管理部门(进程管理),有为了维护项目执行期间数据的会议室管理部门(内存管理),有项目执行完毕后归档的档案库管理部门(文件系统)。
这一节,我们来规划一下这家公司的售前售后生态体系(输入输出系统)。这里你需要注意“生态”两个字,我们不仅仅是招聘一些售前和售后员工,而是应该建立一套体系让供应商,让渠道帮着我们卖,形成一个生态。
计算机系统的输入和输出系统都有哪些呢我们能举出来的例如键盘、鼠标、显示器、网卡、硬盘、打印机、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指令触发一种是**硬件中断**,就是硬件通过中断控制器触发的。所以将中断作为办事大厅的一项服务,没有什么问题。
<img src="https://static001.geekbang.org/resource/image/5d/55/5d9290f08847685d65bc3edd88242855.jpg" alt="">
有的设备需要读取或者写入大量数据。如果所有过程都让CPU协调的话就需要占用CPU大量的时间比方说磁盘就是这样的。这种类型的设备需要支持DMA功能也就是说允许设备在CPU不参与的情况下能够自行完成对内存的读写。实现DMA机制需要有个DMA控制器帮你的CPU来做协调就像下面这个图中显示的一样。
CPU只需要对DMA控制器下指令说它想读取多少数据放在内存的某个地方就可以了接下来DMA控制器会发指令给磁盘控制器读取磁盘上的数据到指定的内存位置传输完毕之后DMA控制器发中断通知CPU指令完成CPU就可以直接用内存里面现成的数据了。还记得咱们讲内存的时候有个DMA区域就是这个作用。
DMA有点儿像一些比较大的代理商不但能够帮你代购代销而且自己有能力售前、售后和技术支持实施部署都能自己搞定。
<img src="https://static001.geekbang.org/resource/image/1e/35/1ef05750bc9ff87a3330104802965335.jpeg" alt="">
## 用驱动程序屏蔽设备控制器差异
虽然代理商机制能够帮我们屏蔽很多设备的细节,但是从上面的描述我们可以看出,由于每种设备的控制器的寄存器、缓冲区等使用模式,指令都不同,所以对于操作系统这家公司来讲,需要有个部门专门对接代理商,向其他部门屏蔽代理商的差异,类似公司的渠道管理部门。
那什么才是操作系统的渠道管理部门呢?就是用来对接各个设备控制器的设备驱动程序。
这里需要注意的是,设备控制器不属于操作系统的一部分,但是设备驱动程序属于操作系统的一部分。操作系统的内核代码可以像调用本地代码一样调用驱动程序的代码,而驱动程序的代码需要发出特殊的面向设备控制器的指令,才能操作设备控制器。
设备驱动程序中是一些面向特殊设备控制器的代码。不同的设备不同。但是对于操作系统其它部分的代码而言,设备驱动程序应该有统一的接口。就像下面图中的一样,不同的设备驱动程序,可以以同样的方式接入操作系统,而操作系统的其它部分的代码,也可以无视不同设备的区别,以同样的接口调用设备驱动程序。
接下来两节,我们会讲字符设备驱动程序和块设备驱动程序的模型,从那里我们也可以看出,所有设备驱动程序都要,按照同样的规则,实现同样的方法。
<img src="https://static001.geekbang.org/resource/image/7b/68/7bf96d3c8e3a82cdac9c7629b81fa368.png" alt="">
上面咱们说了,设备做完了事情要通过中断来通知操作系统。那操作系统就需要有一个地方处理这个中断,既然设备驱动程序是用来对接设备控制器的,中断处理也应该在设备驱动里面完成。
然而中断的触发最终会到达CPU会中断操作系统当前运行的程序所以操作系统也要有一个统一的流程来处理中断使得不同设备的中断使用统一的流程。
一般的流程是一个设备驱动程序初始化的时候要先注册一个该设备的中断处理函数。咱们讲进程切换的时候说过中断返回的那一刻是进程切换的时机。不知道你还记不记得中断的时候触发的函数是do_IRQ。这个函数是中断处理的统一入口。在这个函数里面我们可以找到设备驱动程序注册的中断处理函数Handler然后执行它进行中断处理。
<img src="https://static001.geekbang.org/resource/image/aa/c0/aa9d074d9819f0eb513e11014a5772c0.jpg" alt="">
另外对于块设备来讲在驱动程序之上文件系统之下还需要一层通用设备层。比如咱们上一章讲的文件系统里面的逻辑和磁盘设备没有什么关系可以说是通用的逻辑。在写文件的最底层我们看到了BIO字眼的函数但是好像和设备驱动也没有什么关系。是的因为块设备类型非常多而Linux操作系统里面一切是文件。我们也不想文件系统以下就直接对接各种各样的块设备驱动程序这样会使得文件系统的复杂度非常高。所以我们在中间加了一层通用块层将与块设备相关的通用逻辑放在这一层维护与设备无关的块的大小然后通用块层下面对接各种各样的驱动程序。
<img src="https://static001.geekbang.org/resource/image/3c/73/3c506edf93b15341da3db658e9970773.jpg" alt="">
## 用文件系统接口屏蔽驱动程序的差异
上面我们从硬件设备到设备控制器,到驱动程序,到通用块层,到文件系统,层层屏蔽不同的设备的差别,最终到这里涉及对用户使用接口,也要统一。
虽然我们操作设备,都是基于文件系统的接口,也要有一个统一的标准。
首先要统一的是设备名称。所有设备都在/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中创建对应的文件。
<img src="https://static001.geekbang.org/resource/image/62/90/6234738aac8d5897449e1a541d557090.jpg" alt="">
有了文件系统接口之后我们不但可以通过文件系统的命令行操作设备也可以通过程序调用read、write函数像读写文件一样操作设备。但是有些任务只使用读写很难完成例如检查特定于设备的功能和属性超出了通用文件系统的限制。所以对于设备来讲还有一种接口称为ioctl表示输入输出控制接口是用于配置和修改特定设备属性的通用接口这个我们后面几节会详细说。
## 总结时刻
这一节,我们讲了输入与输出设备的管理,内容比较多。输入输出设备就像管理代理商一样。因为代理商复杂多变,代理商管理也同样复杂多变,需要层层屏蔽差异化的部分,给上层提供标准化的部分,最终到用户态,给用户提供了基于文件系统的统一的接口。
<img src="https://static001.geekbang.org/resource/image/80/7f/80e152fe768e3cb4c84be62ad8d6d07f.jpg" alt="">
## 课堂练习
如果你手头的Linux是一台物理机试着插进一块U盘看文件系统中设备的变化。如果你没有Linux物理机可以使用公有云的云主机添加一块硬盘看文件系统中设备的变化。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,653 @@
<audio id="audio" title="32 | 字符设备(上):如何建立直销模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/e1/655281c771bd57e3bef07e4377af21e1.mp3"></audio>
上一节,我们讲了输入输出设备的层次模型,还是比较复杂的,块设备尤其复杂。这一节为了让你更清晰地了解设备驱动程序的架构,我们先来讲稍微简单一点的字符设备驱动。
这一节我找了两个比较简单的字符设备驱动来解析一下。一个是输入字符设备鼠标。代码在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 &lt;linux/module.h&gt;
#include &lt;linux/init.h&gt;
```
如果你去看上面两个驱动程序,都能找到这两个头文件。当然如果需要的话,我们还可以引入更多的头文件。
**第二部分,定义一些函数,用于处理内核模块的主要逻辑**。例如打开、关闭、读取、写入设备的函数或者响应中断的函数。
例如logibm.c里面就定义了logibm_open。logibm_close就是处理打开和关闭的定义了logibm_interrupt就是用来响应中断的。再如lp.c里面就定义了lp_readlp_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_modulelogibm.c就定义了logibm_init和logibm_exit。
**第五部分调用module_init和module_exit**,分别指向上面两个初始化函数和退出函数。就像本节最开头展示的一样。
**第六部分声明一下lisense调用MODULE_LICENSE**
有了这六部分,一个内核模块就基本合格了,可以工作了。
## 打开字符设备
字符设备可不是一个普通的内核模块,它有自己独特的行为。接下来,我们就沿着打开一个字符设备的过程,看看字符设备这个内核模块做了哪些特殊的事情。
<img src="https://static001.geekbang.org/resource/image/2e/e6/2e29767e84b299324ea7fc524a3dcee6.jpeg" alt="">
要使用一个字符设备我们首先要把写好的内核模块通过insmod加载进内核。这个时候先调用的就是module_init调用的初始化函数。
例如在lp.c的初始化函数lp_init对应的代码如下
```
static int __init lp_init (void)
{
......
if (register_chrdev (LP_MAJOR, &quot;lp&quot;, &amp;lp_fops)) {
printk (KERN_ERR &quot;lp: unable to get major %d\n&quot;, 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-&gt;owner = fops-&gt;owner;
cdev-&gt;ops = fops;
kobject_set_name(&amp;cdev-&gt;kobj, &quot;%s&quot;, name);
err = cdev_add(cdev, MKDEV(cd-&gt;major, baseminor), count);
cd-&gt;cdev = cdev;
return major ? 0 : cd-&gt;major;
}
```
在字符设备驱动的内核模块加载的时候最重要的一件事情就是注册这个字符设备。注册的方式是调用__register_chrdev_region注册字符设备的主次设备号和名称然后分配一个struct cdev结构将cdev的ops成员变量指向这个模块声明的file_operations。然后cdev_add会将这个字符设备添加到内核中一个叫作struct kobj_map *cdev_map的结构来统一管理所有字符设备。
其中MKDEV(cd-&gt;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-&gt;dev = dev;
p-&gt;count = count;
error = kobj_map(cdev_map, dev, count, NULL,
exact_match, exact_lock, p);
kobject_get(p-&gt;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, &amp;path, lookup_flags);
......
switch (mode &amp; S_IFMT) {
......
case S_IFCHR: case S_IFBLK:
error = vfs_mknod(path.dentry-&gt;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-&gt;i_op-&gt;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 = &quot;devtmpfs&quot;,
.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-&gt;i_mode = mode;
if (S_ISCHR(mode)) {
inode-&gt;i_fop = &amp;def_chr_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISBLK(mode)) {
inode-&gt;i_fop = &amp;def_blk_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISFIFO(mode))
inode-&gt;i_fop = &amp;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下面的设备文件。
现在我们来打开它。打开一个文件的流程,我们在[文件系统](https://time.geekbang.org/column/article/97876)那一节讲过了这里不再重复。最终就像打开字符设备的图中一样打开文件的进程的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-&gt;i_cdev;
if (!p) {
struct kobject *kobj;
int idx;
kobj = kobj_lookup(cdev_map, inode-&gt;i_rdev, &amp;idx);
new = container_of(kobj, struct cdev, kobj);
p = inode-&gt;i_cdev;
if (!p) {
inode-&gt;i_cdev = p = new;
list_add(&amp;inode-&gt;i_devices, &amp;p-&gt;list);
new = NULL;
}
}
......
fops = fops_get(p-&gt;ops);
......
replace_fops(filp, fops);
if (filp-&gt;f_op-&gt;open) {
ret = filp-&gt;f_op-&gt;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。这些多和设备相关你不必看懂它们。
## 写入字符设备
当我们像打开一个文件一样打开一个字符设备之后,接下来就是对这个设备的读写。对于文件的读写咱们在文件系统那一章详细讲述过,读写的过程是类似的,所以这里我们只解析打印机驱动写入的过程。
<img src="https://static001.geekbang.org/resource/image/9b/e2/9bd3cd8a8705dbf69f889ba3b2b5c2e2.jpeg" alt="">
写入一个字符设备就是用文件系统的标准接口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-&gt;f_op-&gt;write)
return file-&gt;f_op-&gt;write(file, p, count, pos);
else if (file-&gt;f_op-&gt;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-&gt;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 &gt; 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 &gt; 0) {
copy_size -= written;
count -= written;
buf += written;
retv += written;
}
......
if (need_resched())
schedule ();
if (count) {
copy_size = count;
if (copy_size &gt; LP_BUFFER_SIZE)
copy_size = LP_BUFFER_SIZE;
if (copy_from_user(kbuf, buf, copy_size)) {
if (retv == 0)
retv = -EFAULT;
break;
}
}
} while (count &gt; 0);
......
```
这个设备驱动程序的写入函数的实现还是比较典型的。先是调用copy_from_user将数据从用户态拷贝到内核态的缓存中然后调用parport_write写入外部设备。这里还有一个schedule函数也即写入的过程中给其他线程抢占CPU的机会。然后如果count还是大于0也就是数据还没有写完那我们就接着copy_from_user接着parport_write直到写完为止。
## 使用IOCTL控制设备
对于I/O设备来讲我们前面也说过除了读写设备还会调用ioctl做一些特殊的I/O操作。
<img src="https://static001.geekbang.org/resource/image/c3/1d/c3498dad4f15712529354e0fa123c31d.jpeg" alt="">
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) &gt;&gt; _IOC_DIRSHIFT) &amp; _IOC_DIRMASK)
#define _IOC_TYPE(nr) (((nr) &gt;&gt; _IOC_TYPESHIFT) &amp; _IOC_TYPEMASK)
#define _IOC_NR(nr) (((nr) &gt;&gt; _IOC_NRSHIFT) &amp; _IOC_NRMASK)
#define _IOC_SIZE(nr) (((nr) &gt;&gt; _IOC_SIZESHIFT) &amp; _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-&gt;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 -&gt;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-&gt;f_op-&gt;unlocked_ioctl)
goto out;
error = filp-&gt;f_op-&gt;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(&amp;lp_mutex);
switch (cmd) {
......
default:
ret = lp_do_ioctl(minor, cmd, arg, (void __user *)arg);
break;
}
mutex_unlock(&amp;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 &gt; 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) &amp;= ~LP_ABORT;
break;
case LPABORTOPEN:
if (arg)
LP_F(minor) |= LP_ABORTOPEN;
else
LP_F(minor) &amp;= ~LP_ABORTOPEN;
break;
case LPCAREFUL:
if (arg)
LP_F(minor) |= LP_CAREFUL;
else
LP_F(minor) &amp;= ~LP_CAREFUL;
break;
case LPWAIT:
LP_WAIT(minor) = arg;
break;
case LPSETIRQ:
return -EINVAL;
break;
case LPGETIRQ:
if (copy_to_user(argp, &amp;LP_IRQ(minor),
sizeof(int)))
return -EFAULT;
break;
case LPGETSTATUS:
if (mutex_lock_interruptible(&amp;lp_table[minor].port_mutex))
return -EINTR;
lp_claim_parport_or_block (&amp;lp_table[minor]);
status = r_str(minor);
lp_release_parport (&amp;lp_table[minor]);
mutex_unlock(&amp;lp_table[minor].port_mutex);
if (copy_to_user(argp, &amp;status, sizeof(int)))
return -EFAULT;
break;
case LPRESET:
lp_reset(minor);
break;
case LPGETFLAGS:
status = LP_F(minor);
if (copy_to_user(argp, &amp;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函数的时候修改为指向设备操作函数从而读写一个字符设备文件就会直接变成读写外部设备了。
<img src="https://static001.geekbang.org/resource/image/fb/cd/fba61fe95e0d2746235b1070eb4c18cd.jpeg" alt="">
## 课堂练习
这节我用打印机驱动程序作为例子来给你讲解字符设备,请你仔细看一下它的代码,设想一下,如果让你自己写一个字符设备驱动程序,应该实现哪些函数呢?
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,625 @@
<audio id="audio" title="33 | 字符设备(下):如何建立直销模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/33/51/33307f82ecdb7940a6a0003abd72ca51.mp3"></audio>
上一节,我们讲了一个设备能够被打开、能够读写,主流的功能基本就完成了。我们讲输入输出设备的时候说到,如果一个设备有事情需要通知操作系统,会通过中断和设备驱动程序进行交互,今天我们就来解析中断处理机制。
鼠标就是通过中断,将自己的位置和按键信息,传递给设备驱动程序。
```
static int logibm_open(struct input_dev *dev)
{
if (request_irq(logibm_irq, logibm_interrupt, 0, &quot;logibm&quot;, NULL)) {
printk(KERN_ERR &quot;logibm.c: Can't allocate irq %d\n&quot;, 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) &amp; 0xf);
outb(LOGIBM_READ_X_HIGH, LOGIBM_CONTROL_PORT);
dx |= (inb(LOGIBM_DATA_PORT) &amp; 0xf) &lt;&lt; 4;
outb(LOGIBM_READ_Y_LOW, LOGIBM_CONTROL_PORT);
dy = (inb(LOGIBM_DATA_PORT) &amp; 0xf);
outb(LOGIBM_READ_Y_HIGH, LOGIBM_CONTROL_PORT);
buttons = inb(LOGIBM_DATA_PORT);
dy |= (buttons &amp; 0xf) &lt;&lt; 4;
buttons = ~buttons &gt;&gt; 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 &amp; 1);
input_report_key(logibm_dev, BTN_MIDDLE, buttons &amp; 2);
input_report_key(logibm_dev, BTN_LEFT, buttons &amp; 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 &lt;&lt; 0),
IRQ_HANDLED = (1 &lt;&lt; 0),
IRQ_WAKE_THREAD = (1 &lt;&lt; 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-&gt;handler = handler;
action-&gt;thread_fn = thread_fn;
action-&gt;flags = irqflags;
action-&gt;name = devname;
action-&gt;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(&amp;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 &lt; 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-&gt;irq = irq;
......
/*
* Create a handler thread when a thread function is supplied
* and the interrupt does not nest into another interrupt
* thread.
*/
if (new-&gt;thread_fn &amp;&amp; !nested) {
ret = setup_irq_thread(new, irq, false);
}
......
old_ptr = &amp;desc-&gt;action;
old = *old_ptr;
if (old) {
/* add new interrupt at end of irq queue */
do {
thread_mask |= old-&gt;thread_mask;
old_ptr = &amp;old-&gt;next;
old = *old_ptr;
} while (old);
}
......
*old_ptr = new;
......
if (new-&gt;thread)
wake_up_process(new-&gt;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, &quot;irq/%d-%s&quot;, irq, new-&gt;name);
sched_setscheduler_nocheck(t, SCHED_FIFO, &amp;param);
get_task_struct(t);
new-&gt;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。
<img src="https://static001.geekbang.org/resource/image/dd/13/dd492efdcf956cb22ce3d51592cdc113.png" alt="">
在这里我们不解析硬件的部分我们从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 &lt; 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
* &quot;sidt&quot; 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(&amp;s, type, (unsigned long)addr, dpl, ist, seg);
write_idt_entry(idt_table, gate, &amp;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 &lt; 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-&gt;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 &lt; nr_cpu_ids) {
int new_cpu, offset;
......
vector = current_vector;
offset = current_offset;
next:
vector += 16;
if (vector &gt;= 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-&gt;cfg.vector)
cpumask_copy(d-&gt;old_domain, d-&gt;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-&gt;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-&gt;irq_data.irq;
struct irqaction *action;
record_irq_time(desc);
for_each_action_of_desc(desc, action) {
irqreturn_t res;
res = action-&gt;handler(irq, action-&gt;dev_id);
switch (res) {
case IRQ_WAKE_THREAD:
__irq_wake_thread(desc, action);
case IRQ_HANDLED:
*flags |= action-&gt;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指向设备驱动程序里面注册的中断处理函数。
<img src="https://static001.geekbang.org/resource/image/26/8f/26bde4fa2279f66098856c5b2b6d308f.png" alt="">
## 课堂练习
你知道如何查看每个CPU都收到了哪些中断吗
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,658 @@
<audio id="audio" title="34 | 块设备(上):如何建立代理商销售模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f8/a1/f8c4379ed8b62ea91cb95fa48e5839a1.mp3"></audio>
上一章我们解析了文件系统最后讲文件系统读写的流程到达底层的时候没有更深入地分析下去这是因为文件系统再往下就是硬盘设备了。上两节我们解析了字符设备的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-&gt;i_mode = mode;
if (S_ISCHR(mode)) {
inode-&gt;i_fop = &amp;def_chr_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISBLK(mode)) {
inode-&gt;i_fop = &amp;def_blk_fops;
inode-&gt;i_rdev = rdev;
} else if (S_ISFIFO(mode))
inode-&gt;i_fop = &amp;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 = &quot;ext4&quot;,
.mount = ext4_mount,
.kill_sb = kill_block_super,
.fs_flags = FS_REQUIRES_DEV,
};
```
在将一个硬盘的块设备mount成为ext4的时候我们会调用ext4_mount-&gt;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 &amp; 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-&gt;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, &amp;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-&gt;i_rdev);
if (bdev) {
spin_lock(&amp;bdev_lock);
if (!inode-&gt;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 -&gt;i_mapping always
* without igrab().
*/
bdgrab(bdev);
inode-&gt;i_bdev = bdev;
inode-&gt;i_mapping = bdev-&gt;bd_inode-&gt;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, &amp;dev);
bdev = &amp;BDEV_I(inode)-&gt;bdev;
if (inode-&gt;i_state &amp; I_NEW) {
bdev-&gt;bd_contains = NULL;
bdev-&gt;bd_super = NULL;
bdev-&gt;bd_inode = inode;
bdev-&gt;bd_block_size = i_blocksize(inode);
bdev-&gt;bd_part_count = 0;
bdev-&gt;bd_invalidated = 0;
inode-&gt;i_mode = S_IFBLK;
inode-&gt;i_rdev = dev;
inode-&gt;i_bdev = bdev;
inode-&gt;i_data.a_ops = &amp;def_blk_aops;
mapping_set_gfp_mask(&amp;inode-&gt;i_data, GFP_USER);
spin_lock(&amp;bdev_lock);
list_add(&amp;bdev-&gt;bd_list, &amp;all_bdevs);
spin_unlock(&amp;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 = &quot;bdev&quot;,
.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(&quot;bdev_cache&quot;, sizeof(struct bdev_inode), 0, (SLAB_HWCACHE_ALIGN|SLAB_RECLAIM_ACCOUNT|SLAB_MEM_SPREAD|SLAB_ACCOUNT|SLAB_PANIC), init_once);
err = register_filesystem(&amp;bd_type);
if (err)
panic(&quot;Cannot register bdev pseudo-fs&quot;);
bd_mnt = kern_mount(&amp;bd_type);
if (IS_ERR(bd_mnt))
panic(&quot;Cannot create bdev pseudo-fs&quot;);
blockdev_superblock = bd_mnt-&gt;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我们也应该能知道它分了几个区域所以就有了下图这个复杂的关系结构。
<img src="https://static001.geekbang.org/resource/image/85/76/85f4d83e7ebf2aadf7ffcd5fd393b176.png" alt="">
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_structbd_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 &amp; FMODE_READ)
perm |= MAY_READ;
if (mode &amp; FMODE_WRITE)
perm |= MAY_WRITE;
......
disk = get_gendisk(bdev-&gt;bd_dev, &amp;partno);
......
owner = disk-&gt;fops-&gt;owner;
......
if (!bdev-&gt;bd_openers) {
bdev-&gt;bd_disk = disk;
bdev-&gt;bd_queue = disk-&gt;queue;
bdev-&gt;bd_contains = bdev;
if (!partno) {
ret = -ENXIO;
bdev-&gt;bd_part = disk_get_part(disk, partno);
......
if (disk-&gt;fops-&gt;open) {
ret = disk-&gt;fops-&gt;open(bdev, mode);
......
}
if (!ret)
bd_set_size(bdev,(loff_t)get_capacity(disk)&lt;&lt;9);
if (bdev-&gt;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-&gt;bd_contains = whole;
bdev-&gt;bd_part = disk_get_part(disk, partno);
......
bd_set_size(bdev, (loff_t)bdev-&gt;bd_part-&gt;nr_sects &lt;&lt; 9);
}
}
......
bdev-&gt;bd_openers++;
if (for_part)
bdev-&gt;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(&amp;ext_devt_idr, blk_mangle_minor(MINOR(devt)));
if (part &amp;&amp; get_disk(part_to_disk(part))) {
*partno = part-&gt;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-&gt;device_add_disk-&gt;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-&gt;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 = &amp;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-&gt;s_bdev = data;
s-&gt;s_dev = s-&gt;s_bdev-&gt;bd_dev;
s-&gt;s_bdi = bdi_get(s-&gt;s_bdev-&gt;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 &amp; ~MS_SUBMOUNT), user_ns);
......
}
err = set(s, data);
......
s-&gt;s_type = type;
strlcpy(s-&gt;s_id, type-&gt;name, sizeof(s-&gt;s_id));
list_add_tail(&amp;s-&gt;s_list, &amp;super_blocks);
hlist_add_head(&amp;s-&gt;s_instances, &amp;type-&gt;fs_supers);
spin_unlock(&amp;sb_lock);
get_filesystem(type);
register_shrinker(&amp;s-&gt;s_shrink);
return s;
}
```
好了到此为止mount中一个块设备的过程就结束了。设备打开了形成了block_device结构并且塞到了super_block中。
有了ext4文件系统的super_block之后接下来对于文件的读写过程就和文件系统那一章的过程一摸一样了。只要不涉及真正写入设备的代码super_block中的这个block_device就没啥用处。这也是为什么文件系统那一章我们丝毫感觉不到它的存在但是一旦到了底层就到了block_device起作用的时候了这个我们下一节仔细分析。
## 总结时刻
从这一节我们可以看出,块设备比字符设备复杂多了,涉及三个文件系统,工作过程我用一张图总结了一下,下面带你总结一下。
1. 所有的块设备被一个map结构管理从dev_t到gendisk的映射
1. 所有的block_device表示的设备或者分区都在bdev文件系统的inode列表中
1. mknod创建出来的块设备文件在devtemfs文件系统里面特殊inode里面有块设备号
1. mount一个块设备上的文件系统调用这个文件系统的mount接口
1. 通过按照/dev/xxx在文件系统devtmpfs文件系统上搜索到特殊inode得到块设备号
1. 根据特殊inode里面的dev_t在bdev文件系统里面找到inode
1. 根据bdev文件系统上的inode找到对应的block_device根据dev_t在map中找到gendisk将两者关联起来
1. 找到block_device后打开设备调用和block_device关联的gendisk里面的block_device_operations打开设备
1. 创建被mount的文件系统的super_block。
<img src="https://static001.geekbang.org/resource/image/62/20/6290b73283063f99d6eb728c26339620.png" alt="">
## 课堂练习
到这里你是否真的体会到了Linux里面“一切皆文件”了呢那个特殊的inode除了能够表示字符设备和块设备还能表示什么呢请你看代码分析一下。
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,756 @@
<audio id="audio" title="35 | 块设备(下):如何建立代理商销售模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/a2/34dabb36478dc91257ee4d70ffd21da2.mp3"></audio>
在[文件系统](https://time.geekbang.org/column/article/97876)那一节我们讲了文件的写入到了设备驱动这一层就没有再往下分析。上一节我们又讲了mount一个块设备将block_device信息放到了ext4文件系统的super_block里面有了这些基础是时候把整个写入的故事串起来了。
还记得咱们在文件系统那一节分析写入流程的时候对于ext4文件系统最后调用的是ext4_file_write_iter它将I/O的调用分成两种情况
第一是**直接I/O**。最终我们调用的是generic_file_direct_write这里调用的是mapping-&gt;a_ops-&gt;direct_IO实际调用的是ext4_direct_IO往设备层写入数据。
第二种是**缓存I/O**。最终我们会将数据从应用拷贝到内存缓存中但是这个时候并不执行真正的I/O操作。它们只将整个页或其中部分标记为脏。写操作由一个timer触发那个时候才调用wb_workfn往硬盘写入页面。
接下来的调用链为wb_workfn-&gt;wb_do_writeback-&gt;wb_writeback-&gt;writeback_sb_inodes-&gt;__writeback_single_inode-&gt;do_writepages。在do_writepages中我们要调用mapping-&gt;a_ops-&gt;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-&gt;ki_filp;
struct inode *inode = file-&gt;f_mapping-&gt;host;
size_t count = iov_iter_count(iter);
loff_t offset = iocb-&gt;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-&gt;ki_filp;
struct inode *inode = file-&gt;f_mapping-&gt;host;
struct ext4_inode_info *ei = EXT4_I(inode);
ssize_t ret;
loff_t offset = iocb-&gt;ki_pos;
size_t count = iov_iter_count(iter);
......
ret = __blockdev_direct_IO(iocb, inode, inode-&gt;i_sb-&gt;s_bdev, iter,
get_block_func, ext4_end_io_dio, NULL,
dio_flags);
……
}
```
在ext4_direct_IO_write调用__blockdev_direct_IO有个参数你需要特别注意一下那就是inode-&gt;i_sb-&gt;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-&gt;i_blkbits);
unsigned blkbits = i_blkbits;
unsigned blocksize_mask = (1 &lt;&lt; blkbits) - 1;
ssize_t retval = -EINVAL;
size_t count = iov_iter_count(iter);
loff_t offset = iocb-&gt;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-&gt;flags = flags;
dio-&gt;i_size = i_size_read(inode);
dio-&gt;inode = inode;
if (iov_iter_rw(iter) == WRITE) {
dio-&gt;op = REQ_OP_WRITE;
dio-&gt;op_flags = REQ_SYNC | REQ_IDLE;
if (iocb-&gt;ki_flags &amp; IOCB_NOWAIT)
dio-&gt;op_flags |= REQ_NOWAIT;
} else {
dio-&gt;op = REQ_OP_READ;
}
sdio.blkbits = blkbits;
sdio.blkfactor = i_blkbits - blkbits;
sdio.block_in_file = offset &gt;&gt; blkbits;
sdio.get_block = get_block;
dio-&gt;end_io = end_io;
sdio.submit_io = submit_io;
sdio.final_block_in_bio = -1;
sdio.next_block_for_io = -1;
dio-&gt;iocb = iocb;
dio-&gt;refcount = 1;
sdio.iter = iter;
sdio.final_block_in_request =
(offset + iov_iter_count(iter)) &gt;&gt; blkbits;
......
sdio.pages_in_io += iov_iter_npages(iter, INT_MAX);
retval = do_direct_IO(dio, &amp;sdio, &amp;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-&gt;blkbits;
const unsigned i_blkbits = blkbits + sdio-&gt;blkfactor;
int ret = 0;
while (sdio-&gt;block_in_file &lt; sdio-&gt;final_block_in_request) {
struct page *page;
size_t from, to;
page = dio_get_page(dio, sdio);
from = sdio-&gt;head ? 0 : sdio-&gt;from;
to = (sdio-&gt;head == sdio-&gt;tail - 1) ? sdio-&gt;to : PAGE_SIZE;
sdio-&gt;head++;
while (from &lt; 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-&gt;next_block_for_io,
map_bh);
......
sdio-&gt;next_block_for_io += this_chunk_blocks;
sdio-&gt;block_in_file += this_chunk_blocks;
from += this_chunk_bytes;
dio-&gt;result += this_chunk_bytes;
sdio-&gt;blocks_available -= this_chunk_blocks;
if (sdio-&gt;block_in_file == sdio-&gt;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 &amp;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-&gt;host;
struct ext4_sb_info *sbi = EXT4_SB(mapping-&gt;host-&gt;i_sb);
......
mpd.do_map = 0;
mpd.io_submit.io_end = ext4_init_io_end(inode, GFP_KERNEL);
ret = mpage_prepare_extent_to_map(&amp;mpd);
/* Submit prepared bio */
ext4_io_submit(&amp;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-&gt;mpage_process_page_bufs-&gt;mpage_submit_page-&gt;ext4_bio_write_page-&gt;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-&gt;io_wbc, bio);
bio-&gt;bi_iter.bi_sector = bh-&gt;b_blocknr * (bh-&gt;b_size &gt;&gt; 9);
bio-&gt;bi_bdev = bh-&gt;b_bdev;
bio-&gt;bi_end_io = ext4_end_bio;
bio-&gt;bi_private = ext4_get_io_end(io-&gt;io_end);
io-&gt;io_bio = bio;
io-&gt;io_next_block = bh-&gt;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-&gt;io_bio;
if (bio) {
int io_op_flags = io-&gt;io_wbc-&gt;sync_mode == WB_SYNC_ALL ?
REQ_SYNC : 0;
io-&gt;io_bio-&gt;bi_write_hint = io-&gt;io_end-&gt;inode-&gt;i_write_hint;
bio_set_op_attrs(io-&gt;io_bio, REQ_OP_WRITE, io_op_flags);
submit_bio(io-&gt;io_bio);
}
io-&gt;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-&gt;bio_list) {
bio_list_add(&amp;current-&gt;bio_list[0], bio);
goto out;
}
bio_list_init(&amp;bio_list_on_stack[0]);
current-&gt;bio_list = bio_list_on_stack;
do {
struct request_queue *q = bdev_get_queue(bio-&gt;bi_bdev);
if (likely(blk_queue_enter(q, bio-&gt;bi_opf &amp; 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(&amp;bio_list_on_stack[0]);
ret = q-&gt;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(&amp;lower);
bio_list_init(&amp;same);
while ((bio = bio_list_pop(&amp;bio_list_on_stack[0])) != NULL)
if (q == bdev_get_queue(bio-&gt;bi_bdev))
bio_list_add(&amp;same, bio);
else
bio_list_add(&amp;lower, bio);
/* now assemble so we handle the lowest level first */
bio_list_merge(&amp;bio_list_on_stack[0], &amp;lower);
bio_list_merge(&amp;bio_list_on_stack[0], &amp;same);
bio_list_merge(&amp;bio_list_on_stack[0], &amp;bio_list_on_stack[1]);
}
......
bio = bio_list_pop(&amp;bio_list_on_stack[0]);
} while (bio);
current-&gt;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指向一组页面。
<img src="https://static001.geekbang.org/resource/image/3c/0e/3c473d163b6e90985d7301f115ab660e.jpeg" alt="">
在请求队列request_queue上还有两个重要的函数一个是make_request_fn函数用于生成request另一个是request_fn函数用于处理request。
### 块设备的初始化
我们还是以scsi驱动为例。在初始化设备驱动的时候我们会调用scsi_alloc_queue把request_fn设置为scsi_request_fn。我们还会调用blk_init_allocated_queue-&gt;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 &amp;scsi_device for
* @lun: which lun
* @hostdata: usually NULL and set by -&gt;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-&gt;transportt-&gt;device_size,
GFP_ATOMIC);
......
sdev-&gt;request_queue = scsi_alloc_queue(sdev);
......
}
struct request_queue *scsi_alloc_queue(struct scsi_device *sdev)
{
struct Scsi_Host *shost = sdev-&gt;host;
struct request_queue *q;
q = blk_alloc_queue_node(GFP_KERNEL, NUMA_NO_NODE);
if (!q)
return NULL;
q-&gt;cmd_size = sizeof(struct scsi_cmnd) + shost-&gt;hostt-&gt;cmd_size;
q-&gt;rq_alloc_data = shost;
q-&gt;request_fn = scsi_request_fn;
q-&gt;init_rq_fn = scsi_init_rq;
q-&gt;exit_rq_fn = scsi_exit_rq;
q-&gt;initialize_rq_fn = scsi_initialize_rq;
//调用blk_queue_make_request(q, blk_queue_bio);
if (blk_init_allocated_queue(q) &lt; 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-&gt;fq = blk_alloc_flush_queue(q, NUMA_NO_NODE, q-&gt;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, &amp;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-&gt;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-&gt;elevator;
struct request *__rq;
......
if (q-&gt;last_merge &amp;&amp; elv_bio_merge_ok(q-&gt;last_merge, bio)) {
enum elv_merge ret = blk_try_merge(q-&gt;last_merge, bio);
if (ret != ELEVATOR_NO_MERGE) {
*req = q-&gt;last_merge;
return ret;
}
}
......
__rq = elv_rqhash_find(q, bio-&gt;bi_iter.bi_sector);
if (__rq &amp;&amp; elv_bio_merge_ok(__rq, bio)) {
*req = __rq;
return ELEVATOR_BACK_MERGE;
}
if (e-&gt;uses_mq &amp;&amp; e-&gt;type-&gt;ops.mq.request_merge)
return e-&gt;type-&gt;ops.mq.request_merge(q, req, bio);
else if (!e-&gt;uses_mq &amp;&amp; e-&gt;type-&gt;ops.sq.elevator_merge_fn)
return e-&gt;type-&gt;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-&gt;bi_iter.bi_sector也就是说这个request的起始地址加上它的大小其实是这个request的结束地址如果和bio的起始地址能接得上那就把bio放在request的最后我们称为ELEVATOR_BACK_MERGE。
如果blk_rq_pos(rq) - bio_sectors(bio) == bio-&gt;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-&gt;bi_iter.bi_sector)
return ELEVATOR_BACK_MERGE;
else if (blk_rq_pos(rq) - bio_sectors(bio) == bio-&gt;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-&gt;elevator-&gt;elevator_data;
struct request *__rq;
__rq = cfq_find_rq_fmerge(cfqd, bio);
if (__rq &amp;&amp; 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-&gt;io_context);
if (!cic)
return NULL;
cfqq = cic_to_cfqq(cic, op_is_sync(bio-&gt;bi_opf));
if (cfqq)
return elv_rb_find(&amp;cfqq-&gt;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-&gt;bio_list设置为bio_list_on_stack并在generic_make_request的一开始就判断current-&gt;bio_list是否为空。如果不为空说明已经在generic_make_request的调用里面了就不必调用make_request_fn进行递归了直接把请求加入到bio_list里面就可以了这就实现了递归的及时退出。
如果current-&gt;bio_list为空那我们就将current-&gt;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就是更低层次的块设备的biosame是同层次的块设备的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-&gt;queue_lock)
__acquires(q-&gt;queue_lock)
{
struct scsi_device *sdev = q-&gt;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-&gt;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) &amp;&amp; !blk_queue_start_tag(q, req)))
blk_start_request(req);
.....
cmd = req-&gt;special;
......
/*
* Dispatch the command to the low-level driver.
*/
cmd-&gt;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函数它用于从队列里面取出请求来写入外部设备。
<img src="https://static001.geekbang.org/resource/image/c9/3c/c9f6a08075ba4eae3314523fa258363c.png" alt="">
至此,整个写入文件的过程才算完全结束。这真是个复杂的过程,涉及系统调用、内存管理、文件系统和输入输出。这足以说明,操作系统真的是一个非常复杂的体系,环环相扣,需要分层次层层展开来学习。
到这里,专栏已经过半了,你应该能发现,很多我之前说“后面会细讲”的东西,现在正在一点一点解释清楚,而文中越来越多出现“前面我们讲过”的字眼,你是否当时学习前面知识的时候,没有在意,导致学习后面的知识产生困惑了呢?没关系,及时倒回去复习,再回过头去看,当初学过的很多知识会变得清晰很多。
## 课堂练习
你知道如何查看磁盘调度算法、修改磁盘调度算法以及I/O队列的长度吗
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,344 @@
<audio id="audio" title="56 | 容器:大公司为保持创新,鼓励内部创业" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/a4/d1d6bd823b83801b5dfcb824bfd0bba4.mp3"></audio>
上一章我们讲了虚拟化的原理。从一台物理机虚拟化出很多的虚拟机这种方式一定程度上实现了资源创建的灵活性。但是你同时会发现虚拟化的方式还是非常复杂的。这有点儿像你去成立子公司虽然说公司小但毕竟是一些独立的公司麻雀虽小五脏俱全因而就像上一章我们看到的那样CPU、内存、网络、硬盘全部需要虚拟化一个都不能偷懒。
那有没有一种更加灵活的方式既可以隔离出一部分资源专门用于某个进程又不需要费劲周折的虚拟化这么多的硬件呢毕竟最终我只想跑一个程序而不是要一整个Linux系统。这就像在一家大公司搞创新如果每一个创新项目都要成立一家子公司的话那简直太麻烦了。一般方式是在公司内部成立一个独立的组织分配独立的资源和人力先做一段时间的内部创业。如果真的做成功了再成立子公司也不迟。
在Linux操作系统中有一项新的技术称为容器它就可以做到这一点。
容器的英文叫ContainerContainer的另一个意思是“集装箱”。其实容器就像船上的不同的集装箱装着不同的货物有一定的隔离但是隔离性又没有那么好仅仅做简单的封装。当然封装也带来了好处一个是打包二是标准。
在没有集装箱的时代假设我们要将货物从A运到B中间要经过三个码头、换三次船。那么每次都要将货物卸下船来弄得乱七八糟然后还要再搬上船重新摆好。因此在没有集装箱的时候每次换船船员们都要在岸上待几天才能干完活。
有了尺寸全部都一样的集装箱以后,我们可以把所有的货物都打包在一起。每次换船的时候,把整个集装箱搬过去就行了,几个小时就能完成。船员换船时间大大缩短了。这是集装箱的“打包”和“标准”两大特点在生活中的应用。
其实容器的思想就是要变成软件交付的集装箱。那么容器如何对应用打包呢?
我们先来学习一下集装箱的打包过程。首先,我们得有个封闭的环境,将货物封装起来,让货物之间互不干扰,互相隔离,这样装货卸货才方便。
容器实现封闭的环境主要要靠两种技术,一种是看起来是隔离的技术,称为**namespace**命名空间。在每个namespace中的应用看到的都是不同的 IP地址、用户空间、进程ID等。另一种是用起来是隔离的技术称为**cgroup**(网络资源限制),即明明整台机器有很多的 CPU、内存但是一个应用只能用其中的一部分。
有了这两项技术,就相当于我们焊好了集装箱。接下来的问题就是,如何“将这些集装箱标准化”,在哪艘船上都能运输。这里就要用到镜像了。
所谓**镜像**Image就是在你焊好集装箱的那一刻将集装箱的状态保存下来。就像孙悟空说“定集装箱里的状态就被“定”在了那一刻然后这一刻的状态会被保存成一系列文件。无论在哪里运行这个镜像都能完整地还原当时的情况。
当程序员根据产品设计开发完毕之后,可以将代码连同运行环境打包成一个容器镜像。这个时候集装箱就焊好了。接下来,无论是在开发环境、测试环境,还是生产环境运行代码,都可以使用相同的镜像。就好像集装箱在开发、测试、生产这三个码头非常顺利地整体迁移,这样产品的发布和上线速度就加快了。
下面我们就来体验一下这个Linux上的容器技术
首先我们要安装一个目前最主流的容器技术的实现Docker。假设我们的操作系统是CentOS你可以参考[https://docs.docker.com/install/linux/docker-ce/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/](https://hub.docker.com/)上,你能找到你能想到的几乎所有环境。
最基础的环境就是操作系统。
咱们最初讲命令行的时候讲过每种操作系统的命令行不太一样就像黑话一样。有时候我们写一个脚本需要基于某种类型的操作系统例如Ubuntu或者centOS。但是Ubuntu或者centOS不同版本的命令也不一样需要有一个环境尝试一下命令是否正确。
最常见的做法是有几种类型的操作系统,就弄几台物理机。当然这样一般人可玩不起,但是有了虚拟机就好一些了。你可以在你的笔记本电脑上创建多台虚拟机,但是这个时候又会有另一个苦恼,那就是,虚拟机往往需要比较大的内存,一般一台笔记本电脑上无法启动多台虚拟机,所以做起实验来要经常切换虚拟机,非常麻烦。现在有了容器,好了,我们可以在一台虚拟机上创建任意的操作系统环境了。
比方说,你可以在[https://hub.docker.com/](https://hub.docker.com/)上搜索Ubuntu。点开之后找到Tags。镜像都有Tag这是镜像制作者自己任意指定的多用于表示这个镜像的版本号。
<img src="https://static001.geekbang.org/resource/image/16/fb/160b839adb2bd7390c16c4591204befb.png" alt="">
如果仔细看这些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=&quot;Ubuntu 14.04.6 LTS&quot;
```
如果我们想尝试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 &quot;nginx -g 'daemon of…&quot; 2 minutes ago Up 2 minutes 0.0.0.0:8080-&gt;80/tcp modest_payne
# curl http://localhost:8080
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Welcome to nginx!&lt;/title&gt;
```
这次nginx镜像运行的方式和操作系统不太一样一个是-d因为它是一个应用不需要像操作系统那样有交互命令行而是以后台方式运行-d就是daemon的意思。
另外一个就是端口-p 8080:80。容器这么容易启动每台机器上可以启动N个nginx。大家都监听80端口不就冲突了吗所以我们要设置端口冒号后面的80是容器内部环境监听的端口冒号前面的8080是宿主机上监听的端口。
一旦容器启动起来之后通过docker ps就可以查看都有哪些容器正在运行。
接下来我们通过curl命令访问本机的8080端口可以打印出nginx的欢迎页面。
docker run一下应用就启动起来了是不是非常方便nginx是已经有人打包好的容器镜像放在公共的镜像仓库里面。如果是你自己开发的应用应该如何打包成为镜像呢
因为Java代码比较麻烦我们这里举一个简单的例子。假设你自己写的HTML的文件就是代码。
```
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Welcome to nginx Test 7!&lt;/title&gt;
&lt;style&gt;
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
&lt;/style&gt;
&lt;/head&gt;
&lt;body&gt;
&lt;h1&gt;Test 7&lt;/h1&gt;
&lt;p&gt;If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.&lt;/p&gt;
&lt;p&gt;For online documentation and support please refer to
&lt;a href=&quot;http://nginx.org/&quot;&gt;nginx.org&lt;/a&gt;.&lt;br/&gt;
Commercial support is available at
&lt;a href=&quot;http://nginx.com/&quot;&gt;nginx.com&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Thank you for using nginx.&lt;/em&gt;&lt;/p&gt;
&lt;/body&gt;
&lt;/html&gt;
```
那我们如何将这些代码放到容器镜像里面呢要通过DockerfileDockerfile的格式应该包含下面的部分
- FROM 基础镜像
- RUN 运行过的所有命令
- COPY 拷贝到容器中的资源
- ENTRYPOINT 前台启动的命令或者脚本
按照上面说的格式可以有下面的Dockerfile。
```
FROM ubuntu:14.04
RUN echo &quot;deb http://archive.ubuntu.com/ubuntu trusty main restricted universe multiverse&quot; &gt; /etc/apt/sources.list
RUN echo &quot;deb http://archive.ubuntu.com/ubuntu trusty-updates main restricted universe multiverse&quot; &gt;&gt; /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 &quot;daemon off;&quot;
```
将代码、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 &quot;/bin/sh -c 'nginx -…&quot; 2 seconds ago Up 2 seconds 0.0.0.0:8081-&gt;80/tcp youthful_torvalds
73ff0c8bea6e nginx &quot;nginx -g 'daemon of…&quot; 33 minutes ago Up 33 minutes 0.0.0.0:8080-&gt;80/tcp modest_payne
```
我们再来访问我们在nginx里面写的代码。
```
[root@deployer nginx]# curl http://localhost:8081/test.html
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;title&gt;Welcome to nginx Test 7!&lt;/title&gt;
```
看,我们的代码已经运行起来了。是不是很酷?
其实这种运行方式有更加酷的功能。
第一就是持续集成。
想象一下你写了一个程序然后把它打成了上面一样的镜像。你在本地一运行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进程里面是有客户机内核的应用运行在客户机的用户态。
<img src="https://static001.geekbang.org/resource/image/5a/c5/5a499cb50a1b214a39ddf19cbb63dcc5.jpg" alt="">
## 课堂练习
请你试着用Tomcat的容器镜像启动一个Java网站程序并进行访问。
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,579 @@
<audio id="audio" title="57 | Namespace技术内部创业公司应该独立运营" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/21/b2/213859bc0db6062bd7a77363d24dd4b2.mp3"></audio>
上一节我们讲了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 &quot;/bin/sh -c 'nginx -…&quot; 17 hours ago Up 17 hours 0.0.0.0:8081-&gt;80/tcp youthful_torvalds
```
我们可以看这个容器对应的entrypoint的pid。通过docker inspect命令可以看到进程号为58212。
```
[root@deployer ~]# docker inspect f604f0e34bc2
[
{
&quot;Id&quot;: &quot;f604f0e34bc263bc32ba683d97a1db2a65de42ab052da16df3c7811ad07f0dc3&quot;,
&quot;Created&quot;: &quot;2019-07-15T17:43:44.158300531Z&quot;,
&quot;Path&quot;: &quot;/bin/sh&quot;,
&quot;Args&quot;: [
&quot;-c&quot;,
&quot;nginx -g \&quot;daemon off;\&quot;&quot;
],
&quot;State&quot;: {
&quot;Status&quot;: &quot;running&quot;,
&quot;Running&quot;: true,
&quot;Pid&quot;: 58212,
&quot;ExitCode&quot;: 0,
&quot;StartedAt&quot;: &quot;2019-07-15T17:43:44.651756682Z&quot;,
&quot;FinishedAt&quot;: &quot;0001-01-01T00:00:00Z&quot;
},
......
&quot;Name&quot;: &quot;/youthful_torvalds&quot;,
&quot;RestartCount&quot;: 0,
&quot;Driver&quot;: &quot;overlay2&quot;,
&quot;Platform&quot;: &quot;linux&quot;,
&quot;HostConfig&quot;: {
&quot;NetworkMode&quot;: &quot;default&quot;,
&quot;PortBindings&quot;: {
&quot;80/tcp&quot;: [
{
&quot;HostIp&quot;: &quot;&quot;,
&quot;HostPort&quot;: &quot;8081&quot;
}
]
},
......
},
&quot;Config&quot;: {
&quot;Hostname&quot;: &quot;f604f0e34bc2&quot;,
&quot;ExposedPorts&quot;: {
&quot;80/tcp&quot;: {}
},
&quot;Image&quot;: &quot;testnginx:1&quot;,
&quot;Entrypoint&quot;: [
&quot;/bin/sh&quot;,
&quot;-c&quot;,
&quot;nginx -g \&quot;daemon off;\&quot;&quot;
],
},
&quot;NetworkSettings&quot;: {
&quot;Bridge&quot;: &quot;&quot;,
&quot;SandboxID&quot;: &quot;7fd3eb469578903b66687090e512958658ae28d17bce1a7cee2da3148d1dfad4&quot;,
&quot;Ports&quot;: {
&quot;80/tcp&quot;: [
{
&quot;HostIp&quot;: &quot;0.0.0.0&quot;,
&quot;HostPort&quot;: &quot;8081&quot;
}
]
},
&quot;Gateway&quot;: &quot;172.17.0.1&quot;,
&quot;IPAddress&quot;: &quot;172.17.0.3&quot;,
&quot;IPPrefixLen&quot;: 16,
&quot;MacAddress&quot;: &quot;02:42:ac:11:00:03&quot;,
&quot;Networks&quot;: {
&quot;bridge&quot;: {
&quot;NetworkID&quot;: &quot;c8eef1603afb399bf17af154be202fd1e543d3772cc83ef4a1ca3f97b8bd6eda&quot;,
&quot;EndpointID&quot;: &quot;8d9bb18ca57889112e758ede193d2cfb45cbf794c9d952819763c08f8545da46&quot;,
&quot;Gateway&quot;: &quot;172.17.0.1&quot;,
&quot;IPAddress&quot;: &quot;172.17.0.3&quot;,
&quot;IPPrefixLen&quot;: 16,
&quot;MacAddress&quot;: &quot;02:42:ac:11:00:03&quot;,
}
}
}
}
]
```
如果我们用ps查看机器上的nginx进程可以看到master和workerworker的父进程是master。
```
# ps -ef |grep nginx
root 58212 58195 0 01:43 ? 00:00:00 /bin/sh -c nginx -g &quot;daemon off;&quot;
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 -&gt; ipc:[4026532278]
lrwxrwxrwx 1 root root 0 Jul 16 19:19 mnt -&gt; mnt:[4026532276]
lrwxrwxrwx 1 root root 0 Jul 16 01:43 net -&gt; net:[4026532281]
lrwxrwxrwx 1 root root 0 Jul 16 19:19 pid -&gt; pid:[4026532279]
lrwxrwxrwx 1 root root 0 Jul 16 19:19 user -&gt; user:[4026531837]
lrwxrwxrwx 1 root root 0 Jul 16 19:19 uts -&gt; uts:[4026532277]
# ls -l /proc/58253/ns
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 ipc -&gt; ipc:[4026532278]
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 mnt -&gt; mnt:[4026532276]
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 net -&gt; net:[4026532281]
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 pid -&gt; pid:[4026532279]
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 user -&gt; user:[4026531837]
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 uts -&gt; 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: &lt;LOOPBACK,UP,LOWER_UP&gt; 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: &lt;BROADCAST,MULTICAST,UP,LOWER_UP&gt; 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: &lt;LOOPBACK&gt; 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是使当前进程加入新的namespaceclone是创建一个新的子进程然后让子进程加入新的namespace而当前进程保持不变。
这里我们尝试一下通过clone函数来进入一个namespace。
```
#define _GNU_SOURCE
#include &lt;sys/wait.h&gt;
#include &lt;sys/utsname.h&gt;
#include &lt;sched.h&gt;
#include &lt;string.h&gt;
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
#include &lt;unistd.h&gt;
#define STACK_SIZE (1024 * 1024)
static int childFunc(void *arg)
{
printf(&quot;In child process.\n&quot;);
execlp(&quot;bash&quot;, &quot;bash&quot;, (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(&quot;malloc&quot;);
exit(1);
}
stackTop = stack + STACK_SIZE;
pid = clone(childFunc, stackTop, CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWNET|SIGCHLD, NULL);
if (pid == -1)
{
perror(&quot;clone&quot;);
exit(1);
}
printf(&quot;clone() returned %ld\n&quot;, (long) pid);
sleep(1);
if (waitpid(pid, NULL, 0) == -1)
{
perror(&quot;waitpid&quot;);
exit(1);
}
printf(&quot;child has terminated\n&quot;);
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: &lt;LOOPBACK&gt; 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它在一个独立的pid namespace里面自己是1号进程。如果运行ip addr可以看到宿主机的网卡都找不到了因为新的bash也在一个独立的network namespace里面等退出了再次echo $$的时候,就可以得到原来进程号。
clone系统调用我们在[进程的创建](https://time.geekbang.org/column/article/94064)那一节解析过当时我们没有看关于namespace的代码现在我们就来看一看namespace在内核做了哪些事情。
在内核里面clone会调用_do_fork-&gt;copy_process-&gt;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 = &amp;init_uts_ns,
#if defined(CONFIG_POSIX_MQUEUE) || defined(CONFIG_SYSVIPC)
.ipc_ns = &amp;init_ipc_ns,
#endif
.mnt_ns = NULL,
.pid_ns_for_children = &amp;init_pid_ns,
#ifdef CONFIG_NET
.net_ns = &amp;init_net,
#endif
#ifdef CONFIG_CGROUPS
.cgroup_ns = &amp;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-&gt;nsproxy;
struct user_namespace *user_ns = task_cred_xxx(tsk, user_ns);
struct nsproxy *new_ns;
if (likely(!(flags &amp; (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-&gt;fs);
tsk-&gt;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-&gt;mnt_ns = copy_mnt_ns(flags, tsk-&gt;nsproxy-&gt;mnt_ns, user_ns, new_fs);
......
new_nsp-&gt;uts_ns = copy_utsname(flags, user_ns, tsk-&gt;nsproxy-&gt;uts_ns);
......
new_nsp-&gt;ipc_ns = copy_ipcs(flags, user_ns, tsk-&gt;nsproxy-&gt;ipc_ns);
......
new_nsp-&gt;pid_ns_for_children =
copy_pid_ns(flags, user_ns, tsk-&gt;nsproxy-&gt;pid_ns_for_children);
......
new_nsp-&gt;cgroup_ns = copy_cgroup_ns(flags, user_ns,
tsk-&gt;nsproxy-&gt;cgroup_ns);
......
new_nsp-&gt;net_ns = copy_net_ns(flags, user_ns, tsk-&gt;nsproxy-&gt;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 &amp; 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 &amp; CLONE_NEWNET))
return get_net(old_net);
ucounts = inc_net_namespaces(user_ns);
......
net = net_alloc();
......
get_user_ns(user_ns);
net-&gt;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(&amp;net-&gt;count, 1);
refcount_set(&amp;net-&gt;passive, 1);
net-&gt;dev_base_seq = 1;
net-&gt;user_ns = user_ns;
idr_init(&amp;net-&gt;netns_ids);
spin_lock_init(&amp;net-&gt;nsid_lock);
list_for_each_entry(ops, &amp;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(&amp;loopback_net_ops)
int register_pernet_device(struct pernet_operations *ops)
{
int error;
mutex_lock(&amp;net_mutex);
error = register_pernet_operations(&amp;pernet_list, ops);
if (!error &amp;&amp; (first_device == &amp;pernet_list))
first_device = &amp;ops-&gt;list;
mutex_unlock(&amp;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, &quot;lo&quot;, NET_NAME_UNKNOWN, loopback_setup);
......
dev_net_set(dev, net);
err = register_netdev(dev);
......
net-&gt;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。
<img src="https://static001.geekbang.org/resource/image/56/d7/56bb9502b58628ff3d1bee83b6f53cd7.png" alt="">
## 课堂练习
网络的Namespace有一个非常好的命令ip netns。请你研究一下这个命令并且创建一个容器用这个命令查看网络namespace。
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,870 @@
<audio id="audio" title="58 | cgroup技术内部创业公司应该独立核算成本" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/71/d9/7187ee713149abd66837faa9173a30d9.mp3"></audio>
我们前面说了容器实现封闭的环境主要靠两种技术一种是“看起来是隔离”的技术Namespace另一种是用起来是隔离的技术cgroup。
上一节我们讲了“看起来隔离“的技术Namespace这一节我们就来看一下“用起来隔离“的技术cgroup。
cgroup全称是control group顾名思义它是用来做“控制”的。控制什么东西呢当然是资源的使用了。那它都能控制哪些资源的使用呢我们一起来看一看。
首先cgroup定义了下面的一系列子系统每个子系统用于控制某一类资源。
- CPU子系统主要限制进程的CPU使用率。
- cpuacct 子系统,可以统计 cgroup 中的进程的 CPU 使用报告。
- cpuset 子系统,可以为 cgroup 中的进程分配单独的 CPU 节点或者内存节点。
- memory 子系统,可以限制进程的 Memory 使用量。
- blkio 子系统,可以限制进程的块设备 IO。
- devices 子系统,可以控制进程能够访问某些设备。
- net_cls 子系统,可以标记 cgroups 中进程的网络数据包,然后可以使用 tc 模块traffic control对数据包进行控制。
- freezer 子系统,可以挂起或者恢复 cgroup 中的进程。
这么多子系统你可能要说了那我们不用都掌握吧没错这里面最常用的是对于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 &quot;/bin/sh -c 'nginx -…&quot; About a minute ago Up About a minute 0.0.0.0:8081-&gt;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 -&gt; cpu,cpuacct
lrwxrwxrwx 1 root root 11 May 30 17:00 cpuacct -&gt; 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 -&gt; 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 -&gt; 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资源的控制在用户态是如何表现的。我画了一张图总结一下。
<img src="https://static001.geekbang.org/resource/image/1c/0f/1c762a6283429ff3587a7fc370fc090f.png" alt="">
在内核中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-&gt;id = i;
ss-&gt;name = cgroup_subsys_name[i];
......
cgroup_init_subsys(ss, true);
}
#define for_each_subsys(ss, ssid) \
for ((ssid) = 0; (ssid) &lt; CGROUP_SUBSYS_COUNT &amp;&amp; \
(((ss) = cgroup_subsys[ssid]) || true); (ssid)++)
```
for_each_subsys会在cgroup_subsys数组中进行循环。这个cgroup_subsys数组是如何形成的呢
```
#define SUBSYS(_x) [_x ## _cgrp_id] = &amp;_x ## _cgrp_subsys,
struct cgroup_subsys *cgroup_subsys[] = {
#include &lt;linux/cgroup_subsys.h&gt;
};
#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] = &amp;cpu_cgrp_subsys而SUBSYS(memory)其实是[memory_cgrp_id] = &amp;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(&amp;ss-&gt;css_idr);
INIT_LIST_HEAD(&amp;ss-&gt;cfts);
/* Create the root cgroup state for this subsystem */
ss-&gt;root = &amp;cgrp_dfl_root;
css = ss-&gt;css_alloc(cgroup_css(&amp;cgrp_dfl_root.cgrp, ss));
......
init_and_link_css(css, ss, &amp;cgrp_dfl_root.cgrp);
......
css-&gt;id = cgroup_idr_alloc(&amp;ss-&gt;css_idr, css, 1, 2, GFP_KERNEL);
init_css_set.subsys[ss-&gt;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 &quot;owned&quot; 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-&gt;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-&gt;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 = &quot;tasks&quot;,
.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 = &quot;cgroup&quot;,
.mount = cgroup_mount,
.kill_sb = cgroup_kill_sb,
.fs_flags = FS_USERNS_MOUNT,
};
```
当我们mount这个cgroup文件系统的时候会调用cgroup_mount-&gt;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, &amp;opts);
ret = cgroup_setup_root(root, opts.subsys_mask, PERCPU_REF_INIT_DEAD);
......
dentry = cgroup_do_mount(&amp;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 = &amp;root-&gt;cgrp;
struct kernfs_syscall_ops *kf_sops;
struct css_set *cset;
int i, ret;
root-&gt;kf_root = kernfs_create_root(kf_sops,
KERNFS_ROOT_CREATE_DEACTIVATED,
root_cgrp);
root_cgrp-&gt;kn = root-&gt;kf_root-&gt;kn;
ret = css_populate_dir(&amp;root_cgrp-&gt;self);
ret = rebind_subsystems(root, ss_mask);
......
list_add(&amp;root-&gt;root_list, &amp;cgroup_roots);
cgroup_root_count++;
......
kernfs_activate(root_cgrp-&gt;kn);
......
}
```
就像在普通文件系统上每一个文件都对应一个inode在cgroup文件系统上每个文件都对应一个struct kernfs_node结构当然kernfs_root作为文件系的根也对应一个kernfs_node结构。
接下来css_populate_dir会调用cgroup_addrm_files-&gt;cgroup_add_file-&gt;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-&gt;kn, cgroup_file_name(cgrp, cft, name),
cgroup_file_mode(cft), 0, cft-&gt;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 &amp; S_IALLUGO) | S_IFREG, flags);
kn-&gt;attr.ops = ops;
kn-&gt;attr.size = size;
kn-&gt;ns = ns;
kn-&gt;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 = &quot;shares&quot;,
.read_u64 = cpu_shares_read_u64,
.write_u64 = cpu_shares_write_u64,
},
#endif
#ifdef CONFIG_CFS_BANDWIDTH
{
.name = &quot;cfs_quota_us&quot;,
.read_s64 = cpu_cfs_quota_read_s64,
.write_s64 = cpu_cfs_quota_write_s64,
},
{
.name = &quot;cfs_period_us&quot;,
.read_u64 = cpu_cfs_period_read_u64,
.write_u64 = cpu_cfs_period_write_u64,
},
}
static struct cftype mem_cgroup_legacy_files[] = {
{
.name = &quot;usage_in_bytes&quot;,
.private = MEMFILE_PRIVATE(_MEM, RES_USAGE),
.read_u64 = mem_cgroup_read_u64,
},
{
.name = &quot;max_usage_in_bytes&quot;,
.private = MEMFILE_PRIVATE(_MEM, RES_MAX_USAGE),
.write = mem_cgroup_reset,
.read_u64 = mem_cgroup_read_u64,
},
{
.name = &quot;limit_in_bytes&quot;,
.private = MEMFILE_PRIVATE(_MEM, RES_LIMIT),
.write = mem_cgroup_write,
.read_u64 = mem_cgroup_read_u64,
},
{
.name = &quot;soft_limit_in_bytes&quot;,
.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-&gt;shares = shares;
for_each_possible_cpu(i) {
struct rq *rq = cpu_rq(i);
struct sched_entity *se = tg-&gt;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-&gt;__cgroup_procs_write-&gt;cgroup_attach_task-&gt; cgroup_migrate-&gt;cgroup_migrate_execute。将这个进程和一个cgroup关联起来也即将这个进程迁移到这个cgroup下面。
```
static int cgroup_migrate_execute(struct cgroup_mgctx *mgctx)
{
struct cgroup_taskset *tset = &amp;mgctx-&gt;tset;
struct cgroup_subsys *ss;
struct task_struct *task, *tmp_task;
struct css_set *cset, *tmp_cset;
......
if (tset-&gt;nr_tasks) {
do_each_subsys_mask(ss, ssid, mgctx-&gt;ss_mask) {
if (ss-&gt;attach) {
tset-&gt;ssid = ssid;
ss-&gt;attach(tset);
}
} while_each_subsys_mask();
}
......
}
```
每一个cgroup子系统会调用相应的attach函数。而CPU调用的是cpu_cgroup_attach-&gt; sched_move_task-&gt; 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-&gt;sched_task_group = tg;
#ifdef CONFIG_FAIR_GROUP_SCHED
if (tsk-&gt;sched_class-&gt;task_change_group)
tsk-&gt;sched_class-&gt;task_change_group(tsk, type);
else
#endif
set_task_rq(tsk, task_cpu(tsk));
}
```
在sched_change_group中设置这个进程以这个task_group的方式参与调度从而使得上面的cpu.shares起作用。
对于内存来讲写入内存的限制使用函数mem_cgroup_write-&gt;mem_cgroup_resize_limit来设置struct mem_cgroup的memory.limit成员。
在进程执行过程中申请内存的时候我们会调用handle_pte_fault-&gt;do_anonymous_page()-&gt;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的工作机制我们在这里总结一下。
<img src="https://static001.geekbang.org/resource/image/c9/c4/c9cc56d20e6a4bac0f9657e6380a96c4.png" alt="">
第一步系统初始化的时候初始化cgroup的各个子系统的操作函数分配各个子系统的数据结构。
第二步mount cgroup文件系统创建文件系统的树形结构以及操作函数。
第三步写入cgroup文件设置cpu或者memory的相关参数这个时候文件系统的操作函数会调用到cgroup子系统的操作函数从而将参数设置到cgroup子系统的数据结构中。
第四步写入tasks文件将进程交给某个cgroup进行管理因为tasks文件也是一个cgroup文件统一会调用文件系统的操作函数进而调用cgroup子系统的操作函数将cgroup子系统的数据结构和进程关联起来。
第五步对于CPU来讲会修改scheduled entity放入相应的队列里面去从而下次调度的时候就起作用了。对于内存的cgroup设定只有在申请内存的时候才起作用。
## 课堂练习
这里我们用cgroup限制了CPU和内存如何限制网络呢给你一个提示tc请你研究一下。
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。

View File

@@ -0,0 +1,112 @@
<audio id="audio" title="59 | 数据中心操作系统:上市敲钟" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/98/349889ca2fdd38573a4924b9e16d6198.mp3"></audio>
在这门课程里面我们说了内核态有很多的模块可以帮助我们管理硬件设备最重要的四种硬件资源是CPU、内存、存储和网络。
最初使用汇编语言的前辈在程序中需要指定使用的硬件资源例如指定使用哪个寄存器、放在内存的哪个位置、写入或者读取哪个串口等等。对于这些资源的使用需要程序员自己心里非常地清楚要不然一旦jump错了位置程序就无法运行。
为了将程序员从对硬件的直接操作中解放出来提升程序设计的效率于是我们有了操作系统这一层用来实现对于硬件资源的统一管理。某个程序应该使用哪个CPU、哪部分内存、哪部分硬盘只需要调用API就可以了这些都由操作系统自行分配和管理。
其实操作系统最重要的事情,就是调度。因此,在内核态就产生了这些模块:进程管理子系统、内存管理子系统、文件子系统、设备子系统和网络子系统。
这些模块通过统一的API也就是系统调用对上提供服务。基于这些API用户态有很多的工具可以帮我们使用好Linux操作系统比如用户管理、软件安装、软件运行、周期性进程、文件管理、网络管理和存储管理。
但是到目前为止,我们能管理的还是少数几台机器。当我们面临数据中心成千上万台机器的时候,仍然非常“痛苦”。如果我们运维数据中心依然像运维一台台物理机的前辈一样,天天关心哪个程序放在了哪台机器上,使用多少内存、多少硬盘,每台机器总共有多少内存、多少硬盘,还剩多少内存和硬盘,那头就大了。
因而对应到数据中心我们也需要一个调度器将运维人员从指定物理机或者虚拟机的痛苦中解放出来实现对于物理资源的统一管理这就是Kubernetes。
Kubernetes究竟有哪些功能可以解放运维人员呢为什么它能做数据中心的操作系统呢
我列了两个表格将操作系统的功能和模块与Kubernetes的功能和模块做了一个对比你可以看看。
<img src="https://static001.geekbang.org/resource/image/49/47/497c8c2c0cb193e0380ed1d7c82ac147.jpeg" alt="">
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 InterfaceCSI接口这是一个标准接口不同的存储可以实现这个接口来对接Kubernetes。是不是特别像设备驱动程序呀操作系统只要定义统一的接口不同的存储设备的驱动实现这些接口就能被操作系统使用了。
存储的问题解决了接下来是网络。因为不同的服务器上的Docker还是需要互相通信的。
Kubernetes有自己的网络模型里面是这样规定的。
1.IP-per-Pod每个 Pod 都拥有一个独立 IP 地址Pod 内所有容器共享一个网络命名空间。<br>
2.集群内所有 Pod 都在一个直接连通的扁平网络中,可通过 IP 直接访问。
- 所有容器之间无需 NAT 就可以直接互相访问。
- 所有 Node 和所有容器之间无需 NAT 就可以直接互相访问。
- 容器自己看到的 IP 跟其它容器看到的一样。
这其实是说里面的每一个Docker访问另一个Docker的时候都是感觉在一个扁平的网络里面。
要实现这样的网络模型有很多种方式例如Kubernetes自己提供Calico、Flannel。当然也可以对接Openvswitch这样的虚拟交换机也可以使用brctl这种传统的桥接模式也可以对接硬件交换机。
这又是一种类似驱动的模式和操作系统面临的问题是一样的。Kubernetes同样是提供统一的接口Container Network InterfaceCNI容器网络接口。无论你用哪种方式实现网络模型只要对接这个统一的接口Kubernetes就可以管理容器的网络。
至此Kubernetes作为数据中心的操作系统内核的问题解决了。
接下来是用户态的工作模式问题了。我们能不能像操作一台服务器那样操作数据中心呢?
使用操作系统需要安装一些软件于是我们需要yum之类的包管理系统使得软件的使用者和软件的编译者分隔开来软件的编译者需要知道这个软件需要安装哪些包包之间的依赖关系是什么软件安装到什么地方而软件的使用者仅仅需要yum install就可以了。Kubernetes就有这样一套包管理软件Helm你可以用它来很方便地安装、升级、扩容一些数据中心里面的常用软件例如数据库、缓存、消息队列。
使用操作系统,运行一个进程是最常见的需求。第一种进程是**交互式命令行**运行起来就是执行一个任务结束了马上返回结果。在Kubernetes里面有对应的概念叫作JobJob 负责批量处理短暂的一次性任务 (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操作系统的iptablesKubernetes 在有个概念叫Network PolicyNetwork Policy 提供了基于策略的网络控制,用于隔离应用并减少攻击面。它使用标签选择器模拟传统的分段网络,并通过策略控制它们之间的流量以及来自外部的流量。
是不是很神奇有了Kubernetes我们就能像管理一台Linux服务器那样去管理数据中心了。
如果想深入了解Kubernetes这个数据中心的操作系统你可以订阅极客时间的专栏“[深入剖析Kubernetes](https://time.geekbang.org/column/article/114197)”。
## 总结时刻
下面,你可以对照着这个图,来总结一下这个数据中心操作系统的功能。
<img src="https://static001.geekbang.org/resource/image/1a/e5/1a8450f1fcda83b75c9ba301ebf9fbe5.jpg" alt="">
## 课堂练习
Kubernetes有一个简单的版本你可以按照官方文档用一台虚拟机安装一个试验一下。
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,193 @@
<audio id="audio" title="20 | 内存管理(上):为客户保密,规划进程内存空间布局" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/0e/3dcddd86cdd56441df5f5b5767008d0e.mp3"></audio>
平时我们说计算机的“计算”两个字其实说的就是两方面第一进程和线程对于CPU的使用第二对于内存的管理。所以从这一节开始我们来看看内存管理的机制。
我之前说把内存管理比喻为一个项目组的“封闭开发的会议室”。很显然,如果不隔离,就会不安全、就会泄密,所以我们说每个进程应该有自己的内存空间。内存空间都是独立的、相互隔离的。对于每个进程来讲,看起来应该都是独占的。
## 独享内存空间的原理
之前我只是简单地形容了一下。这一节,我们来深入分析一下,为啥一定要封闭开发呢?
执行一个项目,要依赖于项目执行计划书里的指令。项目只要按这些指令运行就行了。但是,在运行指令的过程中,免不了要产生一些数据。这些数据要保存在一个地方,这个地方就是内存,也就是我们刚才说的“会议室”。
和会议室一样,**内存都被分成一块一块儿的,都编好了号**。例如3F-10就是三楼十号会议室。内存也有这样一个地址。这个地址是实实在在的地址通过这个地址我们就能够定位到物理内存的位置。
使用这种类型的地址会不会有问题呢?我们的二进制程序,也就是项目执行计划书,都是事先写好的,可以多次运行的。如果里面有个指令是,要把用户输入的数字保存在内存中,那就会有问题。
会产生什么问题呢我举个例子你就明白了。如果我们使用那个实实在在的地址3F-10打开三个相同的程序都执行到某一步。比方说打开了三个计算器用户在这三个程序的界面上分别输入了10、100、1000。如果内存中的这个位置只能保存一个数那应该保存哪个呢这不就冲突了吗
如果不用这个实实在在的地址,那应该怎么办呢?于是,我们就想出一个办法,那就是**封闭开发**。
每个项目的物理地址对于进程不可见谁也不能直接访问这个物理地址。操作系统会给进程分配一个虚拟地址。所有进程看到的这个地址都是一样的里面的内存都是从0开始编号。
在程序里面指令写入的地址是虚拟地址。例如位置为10M的内存区域操作系统会提供一种机制将不同进程的虚拟地址和不同内存的物理地址映射起来。
当程序要访问虚拟地址的时候,由内核的数据结构进行转换,转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了。
## 规划虚拟地址空间
通过以上的原理,我们可以看出,操作系统的内存管理,主要分为三个方面。
第一,物理内存的管理,相当于会议室管理员管理会议室。
第二,虚拟地址的管理,也即在项目组的视角,会议室的虚拟地址应该如何组织。
第三,虚拟地址和物理地址如何映射,也即会议室管理员如何管理映射表。
接下来,我们都会围绕虚拟地址和物理地址展开。这两个概念有点绕,很多时候你可能会犯糊涂:这个地方,我们用的是虚拟地址呢,还是物理地址呢?所以,请你在学习这一章节的时候,时刻问自己这个问题。
我们还是切换到外包公司老板的角度。现在,如果让你规划一下,到底应该怎么管理会议室,你会怎么办?是不是可以先听听项目组的意见,收集一下需求。
于是,你看到了项目组的项目执行计划书是这样一个程序。
```
#include &lt;stdio.h&gt;
#include &lt;stdlib.h&gt;
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&lt;length; i++){
buffer[i]=rand()%26+'a';
}
buffer[length]='\0';
return buffer;
}
int main(int argc, char *argv[])
{
int num;
char * buffer;
printf (&quot;Input the string length : &quot;);
scanf (&quot;%d&quot;, &amp;num);
if(num &gt; max_length){
num = max_length;
}
buffer = generate(num);
printf (&quot;Random string is: %s\n&quot;,buffer);
free (buffer);
return 0;
}
```
这个程序比较简单就是根据用户输入的整数来生成字符串最长是128。由于字符串的长度不是固定的因而不能提前知道需要动态地分配内存使用malloc函数。当然用完了需要释放内存这就要使用free函数。
我们来总结一下,这个简单的程序在使用内存时的几种方式:
<li>
代码需要放在内存里面;
</li>
<li>
全局变量例如max_length
</li>
<li>
常量字符串"Input the string length : "
</li>
<li>
函数栈例如局部变量num是作为参数传给generate函数的这里面涉及了函数调用局部变量函数参数等都是保存在函数栈上面的
</li>
<li>
malloc分配的内存在堆里面
</li>
<li>
这里面涉及对glibc的调用所以glibc的代码是以so文件的形式存在的也需要放在内存里面。
</li>
这就完了吗还没有呢别忘了malloc会调用系统调用进入内核所以这个程序一旦运行起来内核部分还需要分配内存
<li>
内核的代码要在内存里面;
</li>
<li>
内核中也有全局变量;
</li>
<li>
每个进程都要有一个task_struct
</li>
<li>
每个进程还有一个内核栈;
</li>
<li>
在内核里面也有动态分配的内存;
</li>
<li>
虚拟地址到物理地址的映射表放在哪里?
</li>
竟然收集了这么多的需求,看来做个内存管理还是挺复杂的啊!
我们现在来问一下自己,上面的这些内存里面的数据,应该用虚拟地址访问呢?还是应该用物理地址访问呢?
你可能会说,这很简单嘛。用户态的用虚拟地址访问,内核态的用物理地址访问。其实不是的。你有没有想过,内核里面的代码如果都使用物理地址,就相当于公司里的项目管理部门、文档管理部门都可以直接使用实际的地址访问会议室,这对于会议室管理部门来讲,简直是一个“灾难”。因为一旦到了内核,大家对于会议室的访问都脱离了会议室管理部门的控制。
所以,我们应该清楚一件事情,真正能够使用会议室的物理地址的,只有会议室管理部门,所有其他部门的行为涉及访问会议室的,都要统统使用虚拟地址,统统到会议室管理部门那里转换一道,才能进行统一的控制。
我上面列举出来的,对于内存的访问,用户态的进程使用虚拟地址,这点毫无疑问,内核态的也基本都是使用虚拟地址,只有最后一项容易让人产生疑问。虚拟地址到物理地址的映射表,这个感觉起来是内存管理模块的一部分,这个是“实”是“虚”呢?这个问题先保留,我们暂不讨论,放到内存映射那一节见分晓。
既然都是虚拟地址,我们就先不管映射到物理地址以后是如何布局的,反正现在至少从“虚”的角度来看,这一大片连续的内存空间都是我的了。
如果是32位有2^32 = 4G的内存空间都是我的不管内存是不是真的有4G。如果是64位在x86_64下面其实只使用了48位那也挺恐怖的。48位地址长度也就是对应了256TB的地址空间。我都没怎么见过256T的硬盘别说是内存了。
现在,你可比世界首富房子还大。虽然是虚拟的。下面你可以尽情地去排列咱们要放的东西。请记住,现在你是站在一个进程的角度去看这个虚拟的空间,不用管其他进程。
首先,这么大的虚拟空间一切二,一部分用来放内核的东西,称为**内核空间**,一部分用来放进程的东西,称为**用户空间**。用户空间在下在低地址我们假设就是0号到29号会议室内核空间在上在高地址我们假设是30号到39号会议室。这两部分空间的分界线因为32位和64位的不同而不同我们这里不深究。
对于普通进程来说内核空间的那部分虽然虚拟地址在那里但是不能访问。这就像作为普通员工你明明知道财务办公室在这个30号会议室门里面但是门上挂着“闲人免进”你只能在自己的用户空间里面折腾。
<img src="https://static001.geekbang.org/resource/image/af/83/afa4beefd380effefb0e54a8d9345c83.jpeg" alt="">
我们从最低位开始排起,先是**Text Segment、Data Segment和BSS Segment**。Text Segment是存放二进制可执行代码的位置Data Segment存放静态常量BSS Segment存放未初始化的静态变量。是不是觉得这几个名字很熟悉没错咱们前面讲ELF格式的时候提到过在二进制执行文件里面就有这三个部分。这里就是把二进制执行文件的三个部分加载到内存里面。
接下来是**堆**Heap**段**。堆是往高地址增长的是用来动态分配内存的区域malloc就是在这里面分配的。
接下来的区域是**Memory Mapping Segment**。这块地址可以用来把文件映射进内存用的如果二进制的执行文件依赖于某个动态链接库就是在这个区域里面将so文件映射到了内存中。
再下面就是**栈**Stack**地址段**。主线程的函数调用的函数栈就是用这里的。
如果普通进程还想进一步访问内核空间,是没办法的,只能眼巴巴地看着。如果需要进行更高权限的工作,就需要调用系统调用,进入内核。
一旦进入了内核就换了一种视角。刚才是普通进程的视角觉着整个空间是它独占的没有其他进程存在。当然另一个进程也这样认为因为它们互相看不到对方。这也就是说不同进程的0号到29号会议室放的东西都不一样。
但是到了内核里面无论是从哪个进程进来的看到的都是同一个内核空间看到的都是同一个进程列表。虽然内核栈是各用各的但是如果想知道的话还是能够知道每个进程的内核栈在哪里的。所以如果要访问一些公共的数据结构需要进行锁保护。也就是说不同的进程进入到内核后进入的30号到39号会议室是同一批会议室。
<img src="https://static001.geekbang.org/resource/image/4e/9d/4ed91c744220d8b4298237d2ab2eda9d.jpeg" alt="">
内核的代码访问内核的数据结构大部分的情况下都是使用虚拟地址的虽然内核代码权限很大但是能够使用的虚拟地址范围也只能在内核空间也即内核代码访问内核数据结构。只能用30号到39号这些编号不能用0到29号因为这些是被进程空间占用的。而且进程有很多个。你现在在内核但是你不知道当前指的0号是哪个进程的0号。
在内核里面也会有内核的代码同样有Text Segment、Data Segment和BSS Segment别忘了咱们讲内核启动的时候内核代码也是ELF格式的。
内核的其他数据结构的分配方式就比较复杂了,这一节我们先不讲。
## 总结时刻
好了,这一节就到这里了,我们来总结一下。这一节我们讲了为什么要独享内存空间,并且站在老板的角度,设计了虚拟地址空间应该存放的数据。
通过这一节,你应该知道,一个内存管理系统至少应该做三件事情:
<li>
第一,虚拟内存空间的管理,每个进程看到的是独立的、互不干扰的虚拟地址空间;
</li>
<li>
第二,物理内存的管理,物理内存地址只有内存管理模块能够使用;
</li>
<li>
第三,内存映射,需要将虚拟内存和物理内存映射、关联起来。
</li>
## 课堂练习
这一节我们讲了进程内存空间的布局,请找一下,有没有一个命令可以查看进程内存空间的布局,打印出来看一下,这对我们后面解析非常有帮助。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,125 @@
<audio id="audio" title="21 | 内存管理(下):为客户保密,项目组独享会议室封闭开发" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/83/34e45393bcf240883825c37698181683.mp3"></audio>
上一节,我们讲了虚拟空间的布局。接下来,我们需要知道,如何将其映射成为物理地址呢?
你可能已经想到了咱们前面讲x86 CPU的时候讲过分段机制咱们规划虚拟空间的时候也是将空间分成多个段进行保存。
那就直接用分段机制呗。我们来看看分段机制的原理。
<img src="https://static001.geekbang.org/resource/image/96/eb/9697ae17b9f561e78514890f9d58d4eb.jpg" alt="">
分段机制下的虚拟地址由两部分组成,**段选择子**和**段内偏移量**。段选择子就保存在咱们前面讲过的段寄存器里面。段选择子里面最重要的是**段号**,用作段表的索引。段表里面保存的是这个段的**基地址**、**段的界限**和**特权等级**等。虚拟地址中的段内偏移量应该位于0和段界限之间。如果段内偏移量是合法的就将段基地址加上段内偏移量得到物理内存地址。
例如我们将上面的虚拟空间分成以下4个段用03来编号。每个段在段表中有一个项在物理空间中段的排列如下图的右边所示。
<img src="https://static001.geekbang.org/resource/image/7c/04/7c82068d2d6bdb601084a07569ac8b04.jpg" alt="">
如果要访问段2中偏移量600的虚拟地址我们可以计算出物理地址为段2基地址2000 + 偏移量600 = 2600。
多好的机制啊我们来看看Linux是如何使用这个机制的。
在Linux里面段表全称**段描述符表**segment descriptors放在**全局描述符表GDT**Global Descriptor Table里面会有下面的宏来初始化段描述符表里面的表项。
```
#define GDT_ENTRY_INIT(flags, base, limit) { { { \
.a = ((limit) &amp; 0xffff) | (((base) &amp; 0xffff) &lt;&lt; 16), \
.b = (((base) &amp; 0xff0000) &gt;&gt; 16) | (((flags) &amp; 0xf0ff) &lt;&lt; 8) | \
((limit) &amp; 0xf0000) | ((base) &amp; 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。为了能够定位和访问每个页需要有个页表保存每个页的起始地址再加上在页内的偏移量组成线性地址就能对于内存中的每个位置进行访问了。
<img src="https://static001.geekbang.org/resource/image/ab/40/abbcafe962d93fac976aa26b7fcb7440.jpg" alt="">
虚拟地址分为两部分,**页号**和**页内偏移**。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址。这个基地址与页内偏移的组合就形成了物理内存地址。
下面的图,举了一个简单的页表的例子,虚拟内存中的页通过页表映射为了物理内存中的页。
<img src="https://static001.geekbang.org/resource/image/84/eb/8495dfcbaed235f7500c7e11149b2feb.jpg" alt="">
32位环境下虚拟地址空间共4GB。如果分成4KB一个页那就是1M个页。每个页表项需要4个字节来存储那么整个4GB空间的映射就需要4MB的内存来存储映射表。如果每个进程都有自己的映射表100个进程就需要400MB的内存。对于内核来讲有点大了 。
页表中所有页表项必须提前建好,并且要求是连续的。如果不连续,就没有办法通过虚拟地址里面的页号找到对应的页表项了。
那怎么办呢我们可以试着将页表再分页4G的空间需要4M的页表来存储映射。我们把这4M分成1K1024个4K每个4K又能放在一页里面这样1K个4K就是1K个页这1K个页也需要一个表进行管理我们称为页目录表这个页目录表里面有1K项每项4个字节页目录表大小也是4K。
页目录有1K项用10位就可以表示访问页目录的哪一项。这一项其实对应的是一整页的页表项也即4K的页表项。每个页表项也是4个字节因而一整页的页表项是1K个。再用10位就可以表示访问页表项的哪一项页表项中的一项对应的就是一个页是存放数据的页这个页的大小是4K用12位可以定位这个页内的任何一个位置。
这样加起来正好32位也就是用前10位定位到页目录表中的一项。将这一项对应的页表取出来共1k项再用中间10位定位到页表中的一项将这一项对应的存放数据的页取出来再用最后12位定位到页中的具体位置访问数据。
<img src="https://static001.geekbang.org/resource/image/b6/b8/b6960eb0a7eea008d33f8e0c4facc8b8.jpg" alt="">
你可能会问如果这样的话映射4GB地址空间就需要4MB+4KB的内存这样不是更大了吗 当然如果页是满的,当时是更大了,但是,我们往往不会为一个进程分配那么多内存。
比如说上面图中我们假设只给这个进程分配了一个数据页。如果只使用页表也需要完整的1M个页表项共4M的内存但是如果使用了页目录页目录需要1K个全部分配占用内存4K但是里面只有一项使用了。到了页表项只需要分配能够管理那个数据页的页表项页就可以了也就是说最多4K这样内存就节省多了。
当然对于64位的系统两级肯定不够了就变成了四级目录分别是全局页目录项PGDPage Global Directory、上层页目录项PUDPage Upper Directory、中间页目录项PMDPage Middle Directory和页表项PTEPage Table Entry
<img src="https://static001.geekbang.org/resource/image/42/0b/42eff3e7574ac8ce2501210e25cd2c0b.jpg" alt="">
## 总结时刻
这一节我们讲了分段机制、分页机制以及从虚拟地址到物理地址的映射方式。总结一下这两节,我们可以把内存管理系统精细化为下面三件事情:
<li>
第一,虚拟内存空间的管理,将虚拟内存分成大小相等的页;
</li>
<li>
第二,物理内存的管理,将物理内存分成大小相等的页;
</li>
<li>
第三,内存映射,将虚拟内存页和物理内存页映射起来,并且在内存紧张的时候可以换出到硬盘中。
</li>
<img src="https://static001.geekbang.org/resource/image/7d/91/7dd9039e4ad2f6433aa09c14ede92991.jpg" alt="">
## 课堂练习
这一节我们说一个页的大小为4K有时候我们需要为应用配置大页HugePage。请你查一下大页的大小及配置方法咱们后面会用到。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,445 @@
<audio id="audio" title="22 | 进程空间管理:项目组还可以自行布置会议室" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/2a/761be85e2464e0a69089889d24b39b2a.mp3"></audio>
上两节,我们讲了内存管理的三个方面,虚拟内存空间的管理、物理内存的管理以及内存映射。你现在对进程内存空间的整体布局应该有了一个大致的了解。今天我们就来详细看看第一个方面,进程的虚拟内存空间是如何管理的。
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 &lt;&lt; 47) - PAGE_SIZE)
#define TASK_SIZE (test_thread_flag(TIF_ADDR32) ? \
IA32_PAGE_OFFSET : TASK_SIZE_MAX)
......
```
当执行一个新的进程的时候,会做以下的设置:
```
current-&gt;mm-&gt;task_size = TASK_SIZE;
```
对于32位系统最大能够寻址2^32=4G其中用户态虚拟地址空间是3G内核态是1G。
对于64位系统虚拟地址只使用了48位。就像代码里面写的一样1左移了47位就相当于48位地址空间一半的位置0x0000800000000000然后减去一个页就是0x00007FFFFFFFF000共128T。同样内核空间也是128T。内核空间和用户空间之间隔着很大的空隙以此来进行隔离。
<img src="https://static001.geekbang.org/resource/image/89/59/89723dc967b59f6f49419082f6ab7659.jpg" alt="">
## 用户态布局
我们先来看用户态虚拟空间的布局。
之前我们讲了用户态虚拟空间里面有几类数据例如代码、全局变量、堆、栈、内存映射区等。在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 &amp; ~VM_SHARED &amp; ~VM_STACK */
unsigned long exec_vm; /* VM_EXEC &amp; ~VM_WRITE &amp; ~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位的空间相差很大但是区域的类别和布局是相似的。
<img src="https://static001.geekbang.org/resource/image/f8/b1/f83b8d49b4e74c0e255b5735044c1eb1.jpg" alt="">
除了位置信息之外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 &amp;
* 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-&gt;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(&amp;loc-&gt;interp_elf_ex,
interpreter,
&amp;interp_map_addr,
load_bias, interp_elf_phdata);
......
current-&gt;mm-&gt;end_code = end_code;
current-&gt;mm-&gt;start_code = start_code;
current-&gt;mm-&gt;start_data = start_data;
current-&gt;mm-&gt;end_data = end_data;
current-&gt;mm-&gt;start_stack = bprm-&gt;p;
......
}
```
load_elf_binary会完成以下的事情
<li>
调用setup_new_exec设置内存映射区mmap_base
</li>
<li>
调用setup_arg_pages设置栈的vm_area_struct这里面设置了mm-&gt;arg_start是指向栈底的current-&gt;mm-&gt;start_stack就是栈底
</li>
<li>
elf_map会将ELF文件中的代码部分映射到内存中来
</li>
<li>
set_brk设置了堆的vm_area_struct这里面设置了current-&gt;mm-&gt;start_brk = current-&gt;mm-&gt;brk也即堆里面还是空的
</li>
<li>
load_elf_interp将依赖的so映射到内存中的内存映射区域。
</li>
最终就形成下面这个内存映射图。
<img src="https://static001.geekbang.org/resource/image/7a/4c/7af58012466c7d006511a7e16143314c.jpeg" alt="">
映射完毕后,什么情况下会修改呢?
第一种情况是函数的调用,涉及函数栈的改变,主要是改变栈顶指针。
第二种情况是通过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-&gt;mm;
struct vm_area_struct *next;
......
newbrk = PAGE_ALIGN(brk);
oldbrk = PAGE_ALIGN(mm-&gt;brk);
if (oldbrk == newbrk)
goto set_brk;
/* Always allow shrinking brk. */
if (brk &lt;= mm-&gt;brk) {
if (!do_munmap(mm, newbrk, oldbrk-newbrk, &amp;uf))
goto set_brk;
goto out;
}
/* Check against existing mmap mappings. */
next = find_vma(mm, oldbrk);
if (next &amp;&amp; newbrk + PAGE_SIZE &gt; vm_start_gap(next))
goto out;
/* Ok, looks good - let it rip. */
if (do_brk(oldbrk, newbrk-oldbrk, &amp;uf) &lt; 0)
goto out;
set_brk:
mm-&gt;brk = brk;
......
return brk;
out:
retval = mm-&gt;brk;
return retval
```
前面我们讲过了堆是从低地址向高地址增长的sys_brk函数的参数brk是新的堆顶位置而当前的mm-&gt;brk是原来堆顶的位置。
首先要做的第一个事情将原来的堆顶和现在的堆顶都按照页对齐地址然后比较大小。如果两者相同说明这次增加的堆的量很小还在一个页里面不需要另行分配页直接跳到set_brk那里设置mm-&gt;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-&gt;mm;
struct vm_area_struct *vma, *prev;
unsigned long len;
struct rb_node **rb_link, *rb_parent;
pgoff_t pgoff = addr &gt;&gt; PAGE_SHIFT;
int error;
len = PAGE_ALIGN(request);
......
find_vma_links(mm, addr, addr + len, &amp;prev, &amp;rb_link,
&amp;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(&amp;vma-&gt;anon_vma_chain);
vma-&gt;vm_mm = mm;
vma-&gt;vm_start = addr;
vma-&gt;vm_end = addr + len;
vma-&gt;vm_pgoff = pgoff;
vma-&gt;vm_flags = flags;
vma-&gt;vm_page_prot = vm_get_page_prot(flags);
vma_link(mm, vma, prev, rb_link, rb_parent);
out:
perf_event_mmap(vma);
mm-&gt;total_vm += len &gt;&gt; PAGE_SHIFT;
mm-&gt;data_vm += len &gt;&gt; PAGE_SHIFT;
if (flags &amp; VM_LOCKED)
mm-&gt;locked_vm += (len &gt;&gt; PAGE_SHIFT);
vma-&gt;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位的内核态的布局。
<img src="https://static001.geekbang.org/resource/image/83/04/83a6511faf802014fbc2c02afc397a04.jpg" alt="">
32位的内核态虚拟地址空间一共就1G占绝大部分的前896M我们称为**直接映射区**。
所谓的直接映射区就是这一块空间是连续的和物理内存是非常简单的映射关系其实就是虚拟内存地址减去3G就得到物理内存的位置。
在内核里面,有两个宏:
<li>
__pa(vaddr) 返回与虚拟地址 vaddr 相关的物理地址;
</li>
<li>
__va(paddr) 则计算出对应于物理地址 paddr 的虚拟地址。
</li>
```
#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的空间了。
于是,我们可以将剩下的虚拟内存地址分成下面这几个部分。
<li>
在896M到VMALLOC_START之间有8M的空间。
</li>
<li>
VMALLOC_START到VMALLOC_END之间称为内核动态映射空间也即内核想像用户态进程一样malloc申请内存在内核里面可以使用vmalloc。假设物理内存里面896M到1.5G之间已经被用户态进程占用了并且映射关系放在了进程的页表中内核vmalloc的时候只能从分配物理内存1.5G开始,就需要使用这一段的虚拟地址进行映射,映射关系放在专门给内核自己用的页表里面。
</li>
<li>
PKMAP_BASE到FIXADDR_START的空间称为持久内核映射。使用alloc_pages()函数的时候在物理内存的高端内存得到struct page结构可以调用kmap将其映射到这个区域。
</li>
<li>
FIXADDR_START到FIXADDR_TOP(0xFFFF F000)的空间,称为固定映射区域,主要用于满足特殊需求。
</li>
<li>
在最后一个区域可以通过kmap_atomic实现临时内核映射。假设用户态的进程要映射一个文件到内存中先要映射用户态进程空间的一段虚拟地址到物理内存然后将文件内容写入这个物理内存供用户态进程访问。给用户态进程分配物理内存页可以通过alloc_pages()分配完毕后按说将用户态进程虚拟地址和物理内存的映射关系放在用户态进程的页表中就完事大吉了。这个时候用户态进程可以通过用户态的虚拟地址也即0至3G的部分经过页表映射后访问物理内存并不需要内核态的虚拟地址里面也划出一块来映射到这个物理内存页。但是如果要把文件内容写入物理内存这件事情要内核来干了这就只好通过kmap_atomic做一个临时映射写入物理内存完毕后再kunmap_atomic来解映射即可。
</li>
32位的内核态布局我们看完了接下来我们再来看64位的内核布局。
其实64位的内核布局反而简单因为虚拟空间实在是太大了根本不需要所谓的高端内存因为内核是128T根本不可能有物理内存超过这个值。
64位的内存布局如图所示。
<img src="https://static001.geekbang.org/resource/image/7e/f6/7eaf620768c62ff53e5ea2b11b4940f6.jpg" alt="">
64位的内核主要包含以下几个部分。
从0xffff800000000000开始就是内核的部分只不过一开始有8T的空档区域。
从__PAGE_OFFSET_BASE(0xffff880000000000)开始的64T的虚拟地址空间是直接映射区域也就是减去PAGE_OFFSET就是物理地址。虚拟地址和物理地址之间的映射在大部分情况下还是会通过建立页表的方式进行映射。
从VMALLOC_START0xffffc90000000000开始到VMALLOC_END0xffffe90000000000的32T的空间是给vmalloc的。
从VMEMMAP_START0xffffea0000000000开始的1T空间用于存放物理页面的描述结构struct page的。
从__START_KERNEL_map0xffffffff80000000开始的512M用于存放内核代码段、全局变量、BSS等。这里对应到物理内存开始的位置减去__START_KERNEL_map就能得到物理内存的地址。这里和直接映射区有点像但是不矛盾因为直接映射区之前有8T的空当区域早就过了内核代码在物理内存中加载的位置。
到这里内核中虚拟空间的布局就介绍完了。
## 总结时刻
还记得咱们上一节咱们收集项目组需求的时候,我们知道一个进程要运行起来需要以下的内存结构。
用户态:
<li>
代码段、全局变量、BSS
</li>
<li>
函数栈
</li>
<li>
</li>
<li>
内存映射区
</li>
内核态:
<li>
内核的代码、全局变量、BSS
</li>
<li>
内核数据结构例如task_struct
</li>
<li>
内核栈
</li>
<li>
内核中动态分配的内存
</li>
现在这些是不是已经都有了着落?
我画了一个图总结一下进程运行状态在32位下对应关系。
<img src="https://static001.geekbang.org/resource/image/28/e8/2861968d1907bc314b82c34c221aace8.jpeg" alt="">
对于64位的对应关系只是稍有区别我这里也画了一个图方便你对比理解。
<img src="https://static001.geekbang.org/resource/image/2a/ce/2ad275ff8fdf6aafced4a7aeea4ca0ce.jpeg" alt="">
## 课堂练习
请通过命令行工具查看进程虚拟内存的布局和物理内存的布局,对照着这一节讲的内容,看一下各部分的位置。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,440 @@
<audio id="audio" title="23 | 物理内存管理(上):会议室管理员如何分配会议室?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/63/d1e56b8a6774d6e32a0425bbf1eada63.mp3"></audio>
前一节,我们讲了如何从项目经理的角度看内存,看到的是虚拟地址空间,这些虚拟的地址,总是要映射到物理的页面。这一节,我们来看,物理的页面是如何管理的。
## 物理内存的组织方式
前面咱们讲虚拟内存涉及物理内存的映射的时候我们总是把内存想象成它是由连续的一页一页的块组成的。我们可以从0开始对物理页编号这样每个物理页都会有个页号。
由于物理地址是连续的页也是连续的每个页大小也是一样的。因而对于任何一个地址只要直接除一下每页的大小很容易直接算出在哪一页。每个页有一个结构struct page表示这个结构也是放在一个数组里面这样根据页号很容易通过下标找到相应的struct page结构。
如果是这样,整个物理内存的布局就非常简单、易管理,这就是最经典的**平坦内存模型**Flat Memory Model
我们讲x86的工作模式的时候讲过CPU是通过总线去访问内存的这就是最经典的内存使用方式。
<img src="https://static001.geekbang.org/resource/image/fa/9b/fa6c2b6166d02ac37637d7da4e4b579b.jpeg" alt="">
在这种模式下CPU也会有多个在总线的一侧。所有的内存条组成一大片内存在总线的另一侧所有的CPU访问内存都要过总线而且距离都是一样的这种模式称为**SMP**Symmetric multiprocessing即对称多处理器。当然它也有一个显著的缺点就是总线会成为瓶颈因为数据都要走它。
<img src="https://static001.geekbang.org/resource/image/8f/49/8f158f58dda94ec04b26200073e15449.jpeg" alt="">
为了提高性能和可扩展性,后来有了一种更高级的模式,**NUMA**Non-uniform memory access非一致内存访问。在这种模式下内存不是一整块。每个CPU都有自己的本地内存CPU访问本地内存不用过总线因而速度要快很多每个CPU和内存在一起称为一个NUMA节点。但是在本地内存不足的情况下每个CPU都可以去另外的NUMA节点申请内存这个时候访问延时就会比较长。
这样,内存被分成了多个节点,每个节点再被分成一个一个的页面。由于页需要全局唯一定位,页还是需要有全局唯一的页号的。但是由于物理内存不是连起来的了,页号也就不再连续了。于是内存模型就变成了非连续内存模型,管理起来就复杂一些。
这里需要指出的是NUMA往往是非连续内存模型。而非连续内存模型不一定就是NUMA有时候一大片内存的情况下也会有物理内存地址不连续的情况。
后来内存技术牛了,可以支持热插拔了。这个时候,不连续成为常态,于是就有了稀疏内存模型。
### 节点
我们主要解析当前的主流场景NUMA方式。我们首先要能够表示NUMA节点的概念于是有了下面这个结构typedef struct pglist_data pg_data_t它里面有以下的成员变量
<li>
每一个节点都有自己的IDnode_id
</li>
<li>
node_mem_map就是这个节点的struct page数组用于描述这个节点里面的所有的页
</li>
<li>
node_start_pfn是这个节点的起始页号
</li>
<li>
node_spanned_pages是这个节点中包含不连续的物理内存地址的页面数
</li>
<li>
node_present_pages是真正可用的物理页面的数目。
</li>
例如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是指可用于作DMADirect 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 PageCPU读起来速度会快很多如果没有就是冷页Cold Page。由于每个CPU都有自己的高速缓存因而per_cpu_pageset也是每个CPU一个。
### 页
了解了区域zone接下来我们就到了组成物理内存的基本单位页的数据结构struct page。这是一个特别复杂的结构里面有很多的unionunion结构是在C语言中被用于同一块内存根据情况保存不同类型数据的一种方式。这里之所以用了union是因为一个物理页面使用模式有多种。
第一种模式要用就用一整页。这一整页的内存或者直接和虚拟地址空间建立映射关系我们把这种称为匿名页Anonymous Page。或者用于关联一个文件然后再和虚拟地址空间建立映射关系这样的文件我们称为内存映射文件Memory-mapped File
如果某一页是这种使用模式则会使用union中的以下变量
<li>
struct address_space *mapping就是用于内存映射如果是匿名页最低位为1如果是映射文件最低位为0
</li>
<li>
pgoff_t index是在映射区的偏移量
</li>
<li>
atomic_t _mapcount每个进程都有自己的页表这里指有多少个页表项指向了这个页
</li>
<li>
struct list_head lru表示这一页应该在一个链表上例如这个页面被换出就在换出页的链表中
</li>
<li>
compound相关的变量用于复合页Compound Page就是将物理上连续的两个或多个页看成一个独立的大页。
</li>
第二种模式仅需分配小块内存。有时候我们不需要一下子分配这么多的内存例如分配一个task_struct结构只需要分配小块的内存去存储这个进程描述结构的对象。为了满足对这种小内存块的需要Linux系统采用了一种被称为**slab allocator**的技术用于分配称为slab的一小块内存。它的基本原理是从内存管理模块申请一整块页然后划分成多个小块的存储池用复杂的队列来维护这些小块的状态状态包括被分配了/被放回池子/应该被回收)。
也正是因为slab allocator对于队列的维护过于复杂后来就有了一种不使用队列的分配器slub allocator后面我们会解析这个分配器。但是你会发现它里面还是用了很多slab的字眼因为它保留了slab的用户接口可以看成slab allocator的另一种实现。
还有一种小块内存的分配器称为**slob**,非常简单,主要使用在小型的嵌入式系统。
如果某一页是用于分割成一小块一小块的内存进行分配的使用模式则会使用union中的以下变量
<li>
s_mem是已经分配了正在使用的slab的第一个对象
</li>
<li>
freelist是池子中的空闲对象
</li>
<li>
rcu_head是需要释放的列表。
</li>
```
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大小的连续内存。每个页块的第一个页的物理地址是该页块大小的整数倍。
<img src="https://static001.geekbang.org/resource/image/27/cf/2738c0c98d2ed31cbbe1fdcba01142cf.jpeg" alt="">
第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 = &amp;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表示希望在哪个区域中分配这个内存
<li>
GFP_USER用于分配一个页映射到用户进程的虚拟地址空间并且希望直接被内核或者硬件访问主要用于一个用户进程希望通过内存映射的方式访问某些硬件的缓存例如显卡缓存
</li>
<li>
GFP_KERNEL用于内核中分配页主要分配ZONE_NORMAL区域也即直接映射区
</li>
<li>
GFP_HIGHMEM顾名思义就是主要分配高端区域的内存。
</li>
另一个参数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-&gt;zonelist, ac-&gt;high_zoneidx, ac-&gt;nodemask) {
struct page *page;
......
page = rmqueue(ac-&gt;preferred_zoneref-&gt;zone, zone, order,
gfp_mask, alloc_flags, ac-&gt;migratetype);
......
}
```
每一个zone都有伙伴系统维护的各种大小的队列就像上面伙伴系统原理里讲的那样。这里调用rmqueue就很好理解了就是找到合适大小的那个队列把页面取下来。
接下来的调用链是rmqueue-&gt;__rmqueue-&gt;__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 &lt; MAX_ORDER; ++current_order) {
area = &amp;(zone-&gt;free_area[current_order]);
page = list_first_entry_or_null(&amp;area-&gt;free_list[migratetype],
struct page, lru);
if (!page)
continue;
list_del(&amp;page-&gt;lru);
rmv_page_order(page);
area-&gt;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就是伙伴系统那个表里面的前一项前一项里面的页块大小是当前项的页块大小除以2size右移一位也就是除以2list_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 &lt;&lt; high;
while (high &gt; low) {
area--;
high--;
size &gt;&gt;= 1;
......
list_add(&amp;page[size].lru, &amp;area-&gt;free_list[migratetype]);
area-&gt;nr_free++;
set_page_order(&amp;page[size], high);
}
}
```
## 总结时刻
对于物理内存的管理的讲解,到这里要告一段落了。这一节我们主要讲了物理内存的组织形式,就像下面图中展示的一样。
如果有多个CPU那就有多个节点。每个节点用struct pglist_data表示放在一个数组里面。
每个节点分为多个区域每个区域用struct zone表示也放在一个数组里面。
每个区域分为多个页。为了方便分配空闲页放在struct free_area里面使用伙伴系统进行管理和分配每一页用struct page表示。
<img src="https://static001.geekbang.org/resource/image/3f/4f/3fa8123990e5ae2c86859f70a8351f4f.jpeg" alt="">
## 课堂练习
伙伴系统是一种非常精妙的实现方式,无论你使用什么语言,请自己实现一个这样的分配系统,说不定哪天你在做某个系统的时候,就用到了。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,490 @@
<audio id="audio" title="24 | 物理内存管理(下):会议室管理员如何分配会议室?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/30/e9/3042e6821d9fb594b50a7454c3ffafe9.mp3"></audio>
前一节,前面我们解析了整页的分配机制。如果遇到小的对象,物理内存是如何分配的呢?这一节,我们一起来看一看。
## 小内存的分配
前面我们讲过如果遇到小的对象会使用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(&quot;task_struct&quot;,
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就是能够存放的缓存对象的数量。
最终,我们将大内存块切分成小内存块,样子就像下面这样。
<img src="https://static001.geekbang.org/resource/image/17/5e/172839800c8d51c49b67ec8c4d07315e.jpeg" alt="">
每一项的结构都是缓存对象后面跟一个下一个空闲对象的指针,这样非常方便将所有的空闲对象链成一个链。其实,这就相当于咱们数据结构里面学的,用数组实现一个可随机插入和删除的链表。
所以这里面就有三个变量size是包含这个指针的大小object_size是纯对象的大小offset就是把下一个空闲对象的指针存放在这一项里的偏移量。
那这些缓存对象哪些被分配了、哪些在空着,什么情况下整个大内存块都被分配完了,需要向伙伴系统申请几个页形成新的大内存块?这些信息该由谁来维护呢?
接下来就是最重要的两个成员变量出场的时候了。kmem_cache_cpu和kmem_cache_node它们都是每个NUMA节点上有一个我们只需要看一个节点里面的情况。
<img src="https://static001.geekbang.org/resource/image/45/0a/45f38a0c7bce8c98881bbe8b8b4c190a.jpeg" alt="">
在分配缓存块的时候,要分两种路径,**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-&gt;cpu_slab-&gt;tid);
c = raw_cpu_ptr(s-&gt;cpu_slab);
......
object = c-&gt;freelist;
page = c-&gt;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-&gt;freelist in case of cpu migration or IRQ */
freelist = c-&gt;freelist;
if (freelist)
goto load_freelist;
freelist = get_freelist(s, page);
if (!freelist) {
c-&gt;page = NULL;
stat(s, DEACTIVATE_BYPASS);
goto new_slab;
}
load_freelist:
c-&gt;freelist = get_freepointer(s, freelist);
c-&gt;tid = next_tid(c-&gt;tid);
return freelist;
new_slab:
if (slub_percpu_partial(c)) {
page = c-&gt;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, &amp;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-&gt;cpu_slab);
if (c-&gt;page)
flush_slab(s, c);
freelist = page-&gt;freelist;
page-&gt;freelist = NULL;
stat(s, ALLOC_SLAB);
c-&gt;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, &amp;n-&gt;partial, lru) {
void *t;
t = acquire_slab(s, n, page, object == NULL, &amp;objects);
if (!t)
break;
available += objects;
if (!object) {
c-&gt;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 &gt; 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-&gt;oo;
gfp_t alloc_gfp;
void *start, *p;
int idx, order;
bool shuffle;
flags &amp;= gfp_allowed_mask;
......
page = alloc_slab_page(s, alloc_gfp, node, oo);
if (unlikely(!page)) {
oo = s-&gt;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-&gt;node_reclaim-&gt;__node_reclaim-&gt;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-&gt;kswapd_shrink_node-&gt;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 &lt;= 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将内存页写入文件系统对于内存映射关联了文件的我们需要将在内存中对于文件的修改写回到文件中。
## 总结时刻
好了,对于物理内存的管理就讲到这里了,我们来总结一下。对于物理内存来讲,从下层到上层的关系及分配模式如下:
<li>
物理内存分NUMA节点分别进行管理
</li>
<li>
每个NUMA节点分成多个内存区域
</li>
<li>
每个内存区域分成多个物理页面;
</li>
<li>
伙伴系统将多个连续的页面作为一个大的内存块分配给上层;
</li>
<li>
kswapd负责物理页面的换入换出
</li>
<li>
Slub Allocator将从伙伴系统申请的大内存块切成小块分配给其他系统。
</li>
<img src="https://static001.geekbang.org/resource/image/52/54/527e5c861fd06c6eb61a761e4214ba54.jpeg" alt="">
## 课堂练习
内存的换入和换出涉及swap分区那你知道如何检查当前swap分区情况如何启用和关闭swap区域如何调整swappiness吗
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,666 @@
<audio id="audio" title="25 | 用户态内存映射:如何找到正确的会议室?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/a7/758c60f8395f324ea87d74301cbe8da7.mp3"></audio>
前面几节,我们既看了虚拟内存空间如何组织的,也看了物理页面如何管理的。现在我们需要一些数据结构,将二者关联起来。
## 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-&gt;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 &amp;
* 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) */
```
其实内存映射不仅仅是物理内存和虚拟内存之间的映射,还包括将文件中的内容映射到虚拟内存空间。这个时候,访问内存空间就能够访问到文件里面的数据。而仅有物理内存和虚拟内存的映射,是一种特殊情况。
<img src="https://static001.geekbang.org/resource/image/f0/45/f0dcb83fcaa4f185a8e36c9d28f12345.jpg" alt="">
前面咱们讲堆的时候讲过如果我们要申请小块内存就用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 &gt;&gt; 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-&gt;do_mmap_pgoff-&gt;do_mmap。这里面主要干了两件事情
<li>
调用get_unmapped_area找到一个没有映射的区域
</li>
<li>
调用mmap_region映射这个区域。
</li>
我们先来看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-&gt;mm-&gt;get_unmapped_area;
if (file) {
if (file-&gt;f_op-&gt;get_unmapped_area)
get_area = file-&gt;f_op-&gt;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-&gt;mm-&gt;get_unmapped_area(filp, 0, len_pad,
off &gt;&gt; PAGE_SHIFT, flags);
addr += (off - addr) &amp; (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-&gt;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-&gt;vm_mm = mm;
vma-&gt;vm_start = addr;
vma-&gt;vm_end = addr + len;
vma-&gt;vm_flags = vm_flags;
vma-&gt;vm_page_prot = vm_get_page_prot(vm_flags);
vma-&gt;vm_pgoff = pgoff;
INIT_LIST_HEAD(&amp;vma-&gt;anon_vma_chain);
if (file) {
vma-&gt;vm_file = get_file(file);
error = call_mmap(file, vma);
addr = vma-&gt;vm_start;
vm_flags = vma-&gt;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-&gt;f_op-&gt;mmap(file, vma);
}
static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
{
......
vma-&gt;vm_ops = &amp;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-&gt;vm_file;
if (file) {
struct address_space *mapping = file-&gt;f_mapping;
vma_interval_tree_insert(vma, &amp;mapping-&gt;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-&gt;mm;
if (unlikely(fault_in_kernel_space(address))) {
if (vmalloc_fault(address) &gt;= 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 &amp; PAGE_MASK,
.flags = flags,
.pgoff = linear_page_index(vma, address),
.gfp_mask = __get_fault_gfp_mask(vma),
};
struct mm_struct *mm = vma-&gt;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(&amp;vmf);
}
```
到这里终于看到了我们熟悉的PGD、P4G、PUD、PMD、PTE这就是前面讲页表的时候讲述的四级页表的概念因为暂且不考虑五级页表我们暂时忽略P4G。
<img src="https://static001.geekbang.org/resource/image/9b/f1/9b802943af4e3ae80ce4d0d7f2190af1.jpg" alt="">
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-&gt;mm;
mm = allocate_mm();
memcpy(mm, oldmm, sizeof(*mm));
if (!mm_init(mm, tsk, mm-&gt;user_ns))
goto fail_nomem;
err = dup_mmap(mm, oldmm);
return mm;
}
```
在这里除了创建一个新的mm_struct并且通过memcpy将它和父进程的弄成一模一样之外我们还需要调用mm_init进行初始化。接下来mm_init调用mm_alloc_pgd分配全局页目录项赋值给mm_struct的pgd成员变量。
```
static inline int mm_alloc_pgd(struct mm_struct *mm)
{
mm-&gt;pgd = pgd_alloc(mm);
return 0;
}
```
pgd_alloc里面除了分配PGD之外还做了很重要的一个事情就是调用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 &amp;&amp; SHARED_KERNEL_PMD) ||
CONFIG_PGTABLE_LEVELS &gt;= 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上运行的时候咱们在[调度](https://time.geekbang.org/column/article/93251)那一节讲过要调用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里面的成员变量pgdmm_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-&gt;pte = pte_offset_map(vmf-&gt;pmd, vmf-&gt;address);
vmf-&gt;orig_pte = *vmf-&gt;pte;
......
if (!vmf-&gt;pte) {
if (vma_is_anonymous(vmf-&gt;vma))
return do_anonymous_page(vmf);
else
return do_fault(vmf);
}
if (!pte_present(vmf-&gt;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-&gt;vma;
struct mem_cgroup *memcg;
struct page *page;
int ret = 0;
pte_t entry;
......
if (pte_alloc(vma-&gt;vm_mm, vmf-&gt;pmd, vmf-&gt;address))
return VM_FAULT_OOM;
......
page = alloc_zeroed_user_highpage_movable(vma, vmf-&gt;address);
......
entry = mk_pte(page, vma-&gt;vm_page_prot);
if (vma-&gt;vm_flags &amp; VM_WRITE)
entry = pte_mkwrite(pte_mkdirty(entry));
vmf-&gt;pte = pte_offset_map_lock(vma-&gt;vm_mm, vmf-&gt;pmd, vmf-&gt;address,
&amp;vmf-&gt;ptl);
......
set_pte_at(vma-&gt;vm_mm, vmf-&gt;address, vmf-&gt;pte, entry);
......
}
```
第二种情况映射到文件do_fault最终我们会调用__do_fault。
```
static int __do_fault(struct vm_fault *vmf)
{
struct vm_area_struct *vma = vmf-&gt;vma;
int ret;
......
ret = vma-&gt;vm_ops-&gt;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-&gt;vma-&gt;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-&gt;vma-&gt;vm_file;
struct address_space *mapping = file-&gt;f_mapping;
struct inode *inode = mapping-&gt;host;
pgoff_t offset = vmf-&gt;pgoff;
struct page *page;
int ret = 0;
......
page = find_get_page(mapping, offset);
if (likely(page) &amp;&amp; !(vmf-&gt;flags &amp; FAULT_FLAG_TRIED)) {
do_async_mmap_readahead(vmf-&gt;vma, ra, file, page, offset);
} else if (!page) {
goto no_cached_page;
}
......
vmf-&gt;page = page;
return ret | VM_FAULT_LOCKED;
no_cached_page:
error = page_cache_read(file, offset, vmf-&gt;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-&gt;f_mapping;
struct page *page;
......
page = __page_cache_alloc(gfp_mask|__GFP_COLD);
......
ret = add_to_page_cache_lru(page, mapping, offset, gfp_mask &amp; GFP_KERNEL);
......
ret = mapping-&gt;a_ops-&gt;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, &amp;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-&gt;vma;
struct page *page, *swapcache;
struct mem_cgroup *memcg;
swp_entry_t entry;
pte_t pte;
......
entry = pte_to_swp_entry(vmf-&gt;orig_pte);
......
page = lookup_swap_cache(entry);
if (!page) {
page = swapin_readahead(entry, GFP_HIGHUSER_MOVABLE, vma,
vmf-&gt;address);
......
}
......
swapcache = page;
......
pte = mk_pte(page, vma-&gt;vm_page_prot);
......
set_pte_at(vma-&gt;vm_mm, vmf-&gt;address, vmf-&gt;pte, pte);
vmf-&gt;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-&gt;flags &amp; SWP_FILE) {
struct file *swap_file = sis-&gt;swap_file;
struct address_space *mapping = swap_file-&gt;f_mapping;
ret = mapping-&gt;a_ops-&gt;readpage(swap_file, page);
return ret;
}
......
}
```
通过上面复杂的过程,用户态缺页异常处理完毕了。物理内存中有了页面,页表也建立好了映射。接下来,用户程序在虚拟内存空间里面,可以通过虚拟地址顺利经过页表映射的访问物理页面上的数据了。
为了加快映射速度,我们不需要每次从虚拟地址到物理地址的转换都走一遍页表。
<img src="https://static001.geekbang.org/resource/image/94/b3/94efd92cbeb4d4ff155a645b93d71eb3.jpg" alt="">
页表一般都很大,只能存放在内存中。操作系统每次访问内存都要折腾两步,先通过查询页表得到物理地址,然后访问该物理地址读取指令、数据。
为了提高映射速度,我们引入了**TLB**Translation Lookaside Buffer我们经常称为**快表**专门用来做地址映射的硬件设备。它不在内存中可存储的数据比较少但是比内存要快。所以我们可以想象TLB就是页表的Cache其中存储了当前最可能被访问到的页表项其内容是部分页表项的一个副本。
有了TLB之后地址映射的过程就像图中画的。我们先查块表块表中有映射关系然后直接转换为物理地址。如果在TLB查不到映射关系时才会到内存中查询页表。
## 总结时刻
用户态的内存映射机制,我们解析的差不多了,我们来总结一下,用户态的内存映射机制包含以下几个部分。
<li>
用户态内存映射函数mmap包括用它来做匿名映射和文件映射。
</li>
<li>
用户态的页表结构存储位置在mm_struct中。
</li>
<li>
在用户态访问没有映射的内存会引发缺页异常分配物理页表、补齐页表。如果是匿名映射则分配物理内存如果是swap则将swap文件读入如果是文件映射则将文件读入。
</li>
<img src="https://static001.geekbang.org/resource/image/78/44/78d351d0105c8e5bf0e49c685a2c1a44.jpg" alt="">
## 课堂练习
你可以试着用mmap系统调用写一个程序来映射一个文件并读取文件的内容。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">

View File

@@ -0,0 +1,364 @@
<audio id="audio" title="26 | 内核态内存映射:如何找到正确的会议室?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/13/96/1378c56588e2a2a95c61f04d963df796.mp3"></audio>
前面讲用户态内存映射机制的时候,我们已经多次引申出了内核的映射机制,但是咱们都暂时放了放,这一节我们就来详细解析一下,让你彻底搞懂它。
首先,你要知道,内核态的内存映射机制,主要包含以下几个部分:
<li>
内核态内存映射函数vmalloc、kmap_atomic是如何工作的
</li>
<li>
内核态页表是放在哪里的如何工作的swapper_pg_dir是怎么回事
</li>
<li>
出现了内核态缺页异常应该怎么办?
</li>
## 内核页表
和用户态页表不同,在系统初始化的时候,我们就要创建内核页表了。
我们从内核页表的根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内核代码区。
接下来的代码就很类似了,就是初始化个表项,然后指向下一级目录,最终形成下面这张图。
<img src="https://static001.geekbang.org/resource/image/78/6d/78c8d44d7d8c08c03eee6f7a94652d6d.png" alt="">
内核页表定义完了一开始这里面的页表能够覆盖的内存范围比较小。例如内核代码区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 = &amp;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 &lt; vaddr_end; vaddr = vaddr_next) {
pgd_t *pgd = pgd_offset_k(vaddr);
p4d_t *p4d;
vaddr_next = (vaddr &amp; 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(&amp;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 &gt;= VMALLOC_START &amp;&amp; address &lt; VMALLOC_END))
return -1;
/*
* Synchronize this task's top level page-table
* with the 'reference' page table.
*
* Do _not_ use &quot;current&quot; 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。
<img src="https://static001.geekbang.org/resource/image/27/9a/274e22b3f5196a4c68bb6813fb643f9a.png" alt="">
## 课堂练习
伙伴系统分配好了物理页面之后如何转换成为虚拟地址呢请研究一下page_address函数的实现。
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">