mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-10 19:54:28 +08:00
mod
This commit is contained in:
91
极客时间专栏/趣谈Linux操作系统/专栏加餐/“趣谈Linux操作系统”食用指南.md
Normal file
91
极客时间专栏/趣谈Linux操作系统/专栏加餐/“趣谈Linux操作系统”食用指南.md
Normal 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="">
|
||||
89
极客时间专栏/趣谈Linux操作系统/专栏加餐/学习攻略(一):学好操作系统,需要掌握哪些前置知识?.md
Normal file
89
极客时间专栏/趣谈Linux操作系统/专栏加餐/学习攻略(一):学好操作系统,需要掌握哪些前置知识?.md
Normal 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来记。当读了每篇文章的一个小节的时候,甚至可以更细到几个自然段,就像咱们学语文的时候划分自然段一样,你可以为这一段知识做一页PPT,PPT的标题就是这一段的段落大意,能够总结出段落大意。其实你已经知道这一段主要是做什么的,只不过很多细节的知识点还不清楚,这样将来通过 标题浏览PPT的时候,就很容易把握整篇文章的总体思路。
|
||||
|
||||
接下来在这页PPT的正文中,将所有的知识点都列出来,其中不懂的部分编个号。例如,知识点17,然后接下来的几页PPT,我们就以知识点17为标题,开始查所有能够帮助你了解这个知识点的文章和书籍,将内容贴进来,直到你发现你已经懂了这个知识点,当然知识点可能还要再套知识点,接着编号就行。当从这个知识点的PPT返回的时候,你知道你理解了知识点17,接下来应该攻克知识点18了。当所有的知识点都趟过去,你会发现这是一个特别长的PPT,没有关系,你把所有的以知识点为标题的PPT页都放在最后做附页,再次看整篇文章,就会有一种一马平川的感觉。如果暂时忘了,可以到附页中去查看自己记的笔记,很容易就可以回忆起来。
|
||||
|
||||
这个特别长的PPT就是起到了把书读厚的作用。如果你有兴趣搜索我写的文章《别以为真懂Openstack:虚拟机创建的50个步骤和100个知识点》,看看我是怎样趟过这100个OpenStack相关知识点的。
|
||||
|
||||
第三遍再读薄,是我希望能够达到这样一个效果。经过前两遍的学习,现在你对知识的细节和整体的脉络都应该有了一定了解。因此这第三遍无需面面俱到,你需要把这些知识真正变成你自己的东西。
|
||||
|
||||
专栏每篇文章后面我都会用一张图对这篇文章做一个总结。这些总结图代表了我对知识的梳理和理解。你可以先试着看着我的图来复习,之后,你可以自己尝试去总结这些内容。把知识真正装进你自己的脑袋里。等整个专栏全部结束的时候,你只需要对着这些图,将整个操作系统的知识串下来,基本就融会贯通了。
|
||||
|
||||
## 4.做好练习,用好音频
|
||||
|
||||
你应该有感觉,操作系统的知识量非常大。但凡想要讲的面面俱到,都需要一本砖头厚的书籍才行。专栏不可能和书籍一样,我主要是基于Linux给你讲,我是怎么理解操作系统的。因而,我讲的肯定是最核心的内容,很多旁支的东西需要你自己去预习和复习。
|
||||
|
||||
每节课我留的练习题往往都是对当节课程的补充,也是对后面章节要讲知识的准备,所以你一定要重视课后题。每节课后都认真去做一做题,思考题目背后的知识点。
|
||||
|
||||
另外,音频这个形式非常重要。文稿里是最核心的内容,很多对于代码的分析放进去会非常繁琐,但是这块又非常重要,于是在音频讲解里,我会对代码部分进行更加详尽的讲述。如果你遇到,仅仅看文字不能理解的部分,尝试听听语音,或许会有不一样的收获。
|
||||
|
||||
最后,我想借用陈皓的一句话,“学习不是为了找到答案,而是为了找到方法”。哪怕我已经尽力为你考虑周全,但是没有你自己主动学习,去钻研,去下苦功寻找适合自己的方法,成功是不可能自己找上门来的。希望你可以为自己而努力。
|
||||
|
||||
好了,今天我想分享的就是这些。不知道我今天的分享哪个地方对你最有用呢?你在学专栏的过程中还遇到过什么问题呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解。你可以收藏今天的文章,反复研读。你也可以把今天文章分享给你的朋友,和他一起学习操作系统。
|
||||
|
||||
|
||||
6
极客时间专栏/趣谈Linux操作系统/入门准备篇/01 | 入学测验:你究竟对Linux操作系统了解多少?.md
Normal file
6
极客时间专栏/趣谈Linux操作系统/入门准备篇/01 | 入学测验:你究竟对Linux操作系统了解多少?.md
Normal 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%的高频问题。
|
||||
114
极客时间专栏/趣谈Linux操作系统/入门准备篇/02 | 学习路径:爬过这六个陡坡,你就能对Linux了如指掌.md
Normal file
114
极客时间专栏/趣谈Linux操作系统/入门准备篇/02 | 学习路径:爬过这六个陡坡,你就能对Linux了如指掌.md
Normal 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%的高频问题。
|
||||
90
极客时间专栏/趣谈Linux操作系统/入门准备篇/开篇词 | 为什么要学习Linux操作系统?.md
Normal file
90
极客时间专栏/趣谈Linux操作系统/入门准备篇/开篇词 | 为什么要学习Linux操作系统?.md
Normal 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&utm_source=app&utm_medium=geektime&utm_campaign=85-end&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%的高频问题。
|
||||
382
极客时间专栏/趣谈Linux操作系统/实战串讲篇/60 | 搭建操作系统实验环境(上):授人以鱼不如授人以渔.md
Normal file
382
极客时间专栏/趣谈Linux操作系统/实战串讲篇/60 | 搭建操作系统实验环境(上):授人以鱼不如授人以渔.md
Normal 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文件,这样容易维护。
|
||||
|
||||
```
|
||||
<domain type='qemu'>
|
||||
<name>ubuntutest</name>
|
||||
<uuid>0f0806ab-531d-6134-5def-c5b4955292aa</uuid>
|
||||
<memory unit='GiB'>4</memory>
|
||||
<currentMemory unit='GiB'>4</currentMemory>
|
||||
<vcpu placement='static'>2</vcpu>
|
||||
<os>
|
||||
<type arch='x86_64' machine='pc-i440fx-trusty'>hvm</type>
|
||||
<boot dev='hd'/>
|
||||
</os>
|
||||
<features>
|
||||
<acpi/>
|
||||
<apic/>
|
||||
<pae/>
|
||||
</features>
|
||||
<clock offset='utc'/>
|
||||
<on_poweroff>destroy</on_poweroff>
|
||||
<on_reboot>restart</on_reboot>
|
||||
<on_crash>restart</on_crash>
|
||||
<devices>
|
||||
<emulator>/usr/bin/qemu-system-x86_64</emulator>
|
||||
<disk type='file' device='disk'>
|
||||
<driver name='qemu' type='qcow2'/>
|
||||
<source file='/mnt/vdc/ubuntutest.img'/>
|
||||
<target dev='vda' bus='virtio'/>
|
||||
</disk>
|
||||
<controller type='pci' index='0' model='pci-root'/>
|
||||
<interface type='bridge'>
|
||||
<mac address='fa:16:3e:6e:89:ce'/>
|
||||
<source bridge='br0'/>
|
||||
<target dev='tap1'/>
|
||||
<model type='virtio'/>
|
||||
</interface>
|
||||
<serial type='pty'>
|
||||
<target port='0'/>
|
||||
</serial>
|
||||
<console type='pty'>
|
||||
<target type='serial' port='0'/>
|
||||
</console>
|
||||
<graphics type='vnc' port='-1' autoport='yes' listen='0.0.0.0'>
|
||||
<listen type='address' address='0.0.0.0'/>
|
||||
</graphics>
|
||||
<video>
|
||||
<model type='cirrus'/>
|
||||
</video>
|
||||
</devices>
|
||||
</domain>
|
||||
|
||||
```
|
||||
|
||||
在这个XML文件中,/mnt/vdc/ubuntutest.img就是虚拟机的镜像,br0就是我们创建的网桥,连接到网桥上的网卡libvirt会自动帮我们创建。
|
||||
|
||||
接下来,需要将这个XML保存为domain.xml,然后调用下面的命令,交给libvirt进行管理。
|
||||
|
||||
```
|
||||
virsh define domain.xml
|
||||
|
||||
```
|
||||
|
||||
接下来,运行virsh list --all,我们就可以看到这个定义好的虚拟机了,然后我们调用virsh start ubuntutest,启动这个虚拟机。
|
||||
|
||||
```
|
||||
# virsh list
|
||||
Id Name State
|
||||
----------------------------------------------------
|
||||
1 ubuntutest running
|
||||
|
||||
```
|
||||
|
||||
我们可以通过ps查看libvirt启动的qemu进程。这个命令行是不是很眼熟?我们之前花了一章来讲解。如果不记得了,你可以回去看看前面的内容。
|
||||
|
||||
```
|
||||
# ps aux | grep qemu
|
||||
libvirt+ 9343 85.1 34.7 10367352 5699400 ? Sl Jul27 1239:18 /usr/bin/qemu-system-x86_64 -name ubuntutest -S -machine pc-i440fx-trusty,accel=tcg,usb=off -m 4096 -realtime mlock=off -smp 2,sockets=2,cores=1,threads=1 -uuid 0f0806ab-531d-6134-5def-c5b4955292aa -no-user-config -nodefaults -chardev socket,id=charmonitor,path=/var/lib/libvirt/qemu/domain-ubuntutest/monitor.sock,server,nowait -mon chardev=charmonitor,id=monitor,mode=control -rtc base=utc -no-shutdown -boot strict=on -device piix3-usb-uhci,id=usb,bus=pci.0,addr=0x1.0x2 -drive file=/mnt/vdc/ubuntutest.img,format=qcow2,if=none,id=drive-virtio-disk0 -device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1 -netdev tap,fd=26,id=hostnet0 -device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:6e:89:ce,bus=pci.0,addr=0x3 -chardev pty,id=charserial0 -device isa-serial,chardev=charserial0,id=serial0 -vnc 0.0.0.0:0 -device cirrus-vga,id=video0,bus=pci.0,addr=0x2 -device virtio-balloon-pci,id=balloon0,bus=pci.0,addr=0x5 -msg timestamp=on
|
||||
|
||||
```
|
||||
|
||||
从这里,我们可以看到,VNC的设置为0.0.0.0:0。我们可以用VNCViewer工具登录到这个虚拟机的界面,但是这样实在是太麻烦了,其实virsh有一个特别好的工具,但是需要在虚拟机里面配置一些东西。
|
||||
|
||||
在虚拟机里面,我们修改/boot/grub/里面的两个文件,一个是grub.cfg,另一个是menu.lst,这里面就是咱们在[系统初始化](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 >= 512){
|
||||
return -1;
|
||||
}
|
||||
copy_from_user(buffer, words, count);
|
||||
ret=printk("User Mode says %s to the Kernel Mode!", buffer);
|
||||
return ret;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来就要开始编译内核了。
|
||||
|
||||
## 编译内核
|
||||
|
||||
编译之前,我们需要安装一些编译要依赖的包。
|
||||
|
||||
```
|
||||
apt-get install libncurses5-dev libssl-dev bison flex libelf-dev gcc make openssl libc6-dev
|
||||
|
||||
```
|
||||
|
||||
首先,我们要定义编译选项。
|
||||
|
||||
```
|
||||
make menuconfig
|
||||
|
||||
```
|
||||
|
||||
然后,我们能通过选中下面的选项,激活CONFIG_DEBUG_INFO和CONFIG_FRAME_POINTER选项。
|
||||
|
||||
```
|
||||
Kernel hacking --->
|
||||
Compile-time checks and compiler options --->
|
||||
[*] Compile the kernel with debug info
|
||||
[*] Compile the kernel with frame pointers
|
||||
|
||||
```
|
||||
|
||||
选择完毕之后,配置会保存在.config文件中。如果我们打开看,能看到这样的配置:
|
||||
|
||||
```
|
||||
CONFIG_FRAME_POINTER=y
|
||||
CONFIG_DEBUG_INFO=y
|
||||
|
||||
```
|
||||
|
||||
接下来,我们编译内核。
|
||||
|
||||
```
|
||||
nohup make -j8 > make1.log 2>&1 &
|
||||
nohup make modules_install > make2.log 2>&1 &
|
||||
nohup make install > make3.log 2>&1 &
|
||||
|
||||
```
|
||||
|
||||
这是一个非常长的过程,请耐心等待,可能需要数个小时,因而这里用了nohup,你可以去干别的事情。
|
||||
|
||||
当编译完毕之后,grub和menu.lst都会发生改变。例如,grub.conf里面会多一个新内核的项。
|
||||
|
||||
```
|
||||
submenu 'Advanced options for Ubuntu' $menuentry_id_option 'gnulinux-advanced-470f3a42-7a97-4b9d-aaa0-26deb3d234f9' {
|
||||
menuentry 'Ubuntu, with Linux 4.15.18' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-4.15.18-advanced-470f3a42-7a97-4b9d-aaa0-26deb3d234f9' {
|
||||
recordfail
|
||||
load_video
|
||||
gfxmode $linux_gfx_mode
|
||||
insmod gzio
|
||||
if [ x$grub_platform = xxen ]; then insmod xzio; insmod lzopio; fi
|
||||
insmod part_gpt
|
||||
insmod ext2
|
||||
if [ x$feature_platform_search_hint = xy ]; then
|
||||
search --no-floppy --fs-uuid --set=root 470f3a42-7a97-4b9d-aaa0-26deb3d234f9
|
||||
else
|
||||
search --no-floppy --fs-uuid --set=root 470f3a42-7a97-4b9d-aaa0-26deb3d234f9
|
||||
fi
|
||||
echo 'Loading Linux 4.15.18 ...'
|
||||
linux /boot/vmlinuz-4.15.18 root=UUID=470f3a42-7a97-4b9d-aaa0-26deb3d234f9 ro console=ttyS0 maybe-ubiquity
|
||||
echo 'Loading initial ramdisk ...'
|
||||
initrd /boot/initrd.img-4.15.18
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
例如,menu.lst也多了新的内核的项。
|
||||
|
||||
```
|
||||
title Ubuntu 18.04.2 LTS, kernel 4.15.18
|
||||
root (hd0)
|
||||
kernel /boot/vmlinuz-4.15.18 root=/dev/hda1 ro console=hvc0 console=ttyS0
|
||||
initrd /boot/initrd.img-4.15.18
|
||||
|
||||
```
|
||||
|
||||
别忘了,这里面都要加上console=ttyS0。
|
||||
|
||||
下面,我们要做的就是重启虚拟机。进入的时候,会出现GRUB界面。我们选择Ubuntu高级选项,然后选择第一项进去,通过uname命令,我们就进入了新的内核。
|
||||
|
||||
```
|
||||
# uname -a
|
||||
Linux popsuper 4.15.18 #1 SMP Sat Jul 27 13:43:42 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
|
||||
|
||||
```
|
||||
|
||||
进入新的系统后,我们写一个测试程序。
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <linux/kernel.h>
|
||||
#include <sys/syscall.h>
|
||||
#include <string.h>
|
||||
|
||||
int main ()
|
||||
{
|
||||
char * words = "I am liuchao from user mode.";
|
||||
int ret;
|
||||
ret = syscall(333, words, strlen(words)+1);
|
||||
printf("return %d from kernel mode.\n", ret);
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们能利用gcc编译器编译后运行。如果我们查看日志/var/log/syslog,就能够看到里面打印出来下面的日志,这说明我们的系统调用已经添加成功了。
|
||||
|
||||
```
|
||||
Aug 1 06:33:12 popsuper kernel: [ 2048.873393] User Mode says I am liuchao from user mode. to the Kernel Mode!
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节是一节实战课,我们创建了一台虚拟机,在里面下载源代码,尝试修改了Linux内核,添加了一个自己的系统调用,并且进行了编译并安装了新内核。如果你按照这个过程做下来,你会惊喜地发现,原来令我们敬畏的内核,也是能够加以干预,为我而用的呢。没错,这就是你开始逐渐掌握内核的重要一步。
|
||||
|
||||
## 课堂练习
|
||||
|
||||
这一节的课堂练习,希望你能够按照整个过程,一步一步操作下来。毕竟看懂不算懂,做出来才算入门啊。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
|
||||
287
极客时间专栏/趣谈Linux操作系统/实战串讲篇/61 | 搭建操作系统实验环境(下):授人以鱼不如授人以渔.md
Normal file
287
极客时间专栏/趣谈Linux操作系统/实战串讲篇/61 | 搭建操作系统实验环境(下):授人以鱼不如授人以渔.md
Normal 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 <stdio.h>
|
||||
2 #include <stdlib.h>
|
||||
3 #include <unistd.h>
|
||||
4 #include <linux/kernel.h>
|
||||
5 #include <sys/syscall.h>
|
||||
6 #include <string.h>
|
||||
7
|
||||
8 int main ()
|
||||
9 {
|
||||
10 char * words = "I am liuchao from user mode.";
|
||||
(gdb) b 10
|
||||
Breakpoint 1 at 0x6e2: file syscall.c, line 10.
|
||||
(gdb) r
|
||||
Starting program: /root/syscall/a.out
|
||||
|
||||
Breakpoint 1, main () at syscall.c:10
|
||||
10 char * words = "I am liuchao from user mode.";
|
||||
(gdb) n
|
||||
12 ret = syscall(333, words, strlen(words)+1);
|
||||
(gdb) p words
|
||||
$1 = 0x5555555547c4 "I am liuchao from user mode."
|
||||
(gdb) s
|
||||
__strlen_sse2 () at ../sysdeps/x86_64/multiarch/../strlen.S:79
|
||||
(gdb) bt
|
||||
#0 __strlen_sse2 () at ../sysdeps/x86_64/multiarch/../strlen.S:79
|
||||
#1 0x00005555555546f9 in main () at syscall.c:12
|
||||
(gdb) c
|
||||
Continuing.
|
||||
return 63 from kernel mode.
|
||||
[Inferior 1 (process 1774) exited normally]
|
||||
(gdb) q
|
||||
|
||||
```
|
||||
|
||||
在上面的例子中,我们只要掌握简单的几个gdb的命令就可以了。
|
||||
|
||||
- l,即list,用于显示多行源代码。
|
||||
- b,即break,用于设置断点。
|
||||
- r,即run,用于开始运行程序。
|
||||
- n,即next,用于执行下一条语句。如果该语句为函数调用,则不会进入函数内部执行。
|
||||
- p,即print,用于打印内部变量值。
|
||||
- s,即step,用于执行下一条语句。如果该语句为函数调用,则进入函数,执行其中的第一条语句。
|
||||
- c,即continue,用于继续程序的运行,直到遇到下一个断点。
|
||||
- bt,即backtrace,用于查看函数调用信息。
|
||||
- q,即quit,用于退出gdb环境。
|
||||
|
||||
## Debug kernel
|
||||
|
||||
看了debug一个进程还是简单的,接下来,我们来试着debug整个kernel。
|
||||
|
||||
第一步,要想kernel能够被debug,需要像上面编译程序一样,将debug所需信息也放入二进制文件里面去。这个我们在编译内核的时候已经设置过了,也就是把“CONFIG_DEBUG_INFO”和“CONFIG_FRAME_POINTER”两个变量设置为yes。
|
||||
|
||||
第二步,就是安装gdb。kernel运行在qemu虚拟机里面,gdb运行在宿主机上,所以我们应该在宿主机上进行安装。
|
||||
|
||||
第三步,找到gdb要运行的那个内核的二进制文件。这个文件在哪里呢?根据grub里面的配置,它应该在/boot/vmlinuz-4.15.18这里。
|
||||
|
||||
另外,为了方便在debug的过程中查看源代码,我们可以将/usr/src/linux-source-4.15.0整个目录,都拷贝到宿主机上来。因为内核一旦进入debug模式,就不能运行了。
|
||||
|
||||
```
|
||||
scp -r popsuper@192.168.57.100:/usr/src/linux-source-4.15.0 ./
|
||||
|
||||
```
|
||||
|
||||
在/usr/src/linux-source-4.15.0这个目录下面,vmlinux文件也是内核的二进制文件。
|
||||
|
||||
第四步,修改qemu的启动参数和qemu里面虚拟机的启动参数,从而使得gdb可以远程attach到qemu里面的内核上。
|
||||
|
||||
我们知道,gdb debug一个进程的时候,gdb会监控进程的运行,使得进程一行一行地执行二进制文件。如果像syscall.c的二进制文件a.out一样,就在本地,gdb可以通过attach到这个进程上,作为这个进程的父进程,来监控它的运行。
|
||||
|
||||
但是,gdb debug一个内核的时候,因为内核在qemu虚拟机里面,所以我们无法监控本地进程,而要通过qemu来监控qemu里面的内核,这就要借助qemu的机制。
|
||||
|
||||
qemu有个参数-s,它代表参数-gdb tcp::1234,意思是qemu监听1234端口,gdb可以attach到这个端口上来,debug qemu里面的内核。
|
||||
|
||||
为了完成这一点,我们需要修改ubuntutest这个虚拟机的定义文件。
|
||||
|
||||
```
|
||||
virsh edit ubuntutest
|
||||
|
||||
```
|
||||
|
||||
在这里,我们能将虚拟机的定义文件修改成下面的样子,其中主要改了两项:
|
||||
|
||||
- 在domain的最后加上了qemu:commandline,里面指定了参数-s;
|
||||
- 在domain中添加xmlns:qemu。没有这个XML的namespace,qemu:commandline这个参数libvirt不认。
|
||||
|
||||
```
|
||||
<domain type='qemu' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>
|
||||
<name>ubuntutest</name>
|
||||
<uuid>0f0806ab-531d-6134-5def-c5b4955292aa</uuid>
|
||||
<memory unit='KiB'>8388608</memory>
|
||||
<currentMemory unit='KiB'>8388608</currentMemory>
|
||||
<vcpu placement='static'>8</vcpu>
|
||||
<os>
|
||||
<type arch='x86_64' machine='pc-i440fx-trusty'>hvm</type>
|
||||
<boot dev='hd'/>
|
||||
</os>
|
||||
<clock offset='utc'/>
|
||||
<on_poweroff>destroy</on_poweroff>
|
||||
<on_reboot>restart</on_reboot>
|
||||
<on_crash>restart</on_crash>
|
||||
<devices>
|
||||
<emulator>/usr/bin/qemu-system-x86_64</emulator>
|
||||
<disk type='file' device='disk'>
|
||||
<driver name='qemu' type='qcow2'/>
|
||||
<source file='/mnt/vdc/ubuntutest.img'/>
|
||||
<backingStore/>
|
||||
<target dev='vda' bus='virtio'/>
|
||||
<alias name='virtio-disk0'/>
|
||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x04' function='0x0'/>
|
||||
</disk>
|
||||
......
|
||||
<interface type='bridge'>
|
||||
<mac address='fa:16:3e:6e:89:ce'/>
|
||||
<source bridge='br0'/>
|
||||
<target dev='tap1'/>
|
||||
<model type='virtio'/>
|
||||
<alias name='net0'/>
|
||||
<address type='pci' domain='0x0000' bus='0x00' slot='0x03' function='0x0'/>
|
||||
</interface>
|
||||
......
|
||||
</devices>
|
||||
<qemu:commandline>
|
||||
<qemu:arg value='-s'/>
|
||||
</qemu:commandline>
|
||||
</domain>
|
||||
|
||||
```
|
||||
|
||||
另外,为了远程debug成功,我们还需要修改qemu里面的虚拟机的grub和menu.list,在内核命令行中添加nokaslr,来关闭KASLR。KASLR会使得内核地址空间布局随机化,从而会造成我们打的断点不起作用。
|
||||
|
||||
对于grub.conf,修改如下:
|
||||
|
||||
```
|
||||
submenu 'Advanced options for Ubuntu' $menuentry_id_option 'gnulinux-advanced-470f3a42-7a97-4b9d-aaa0-26deb3d234f9' {
|
||||
menuentry 'Ubuntu, with Linux 4.15.18' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-4.15.18-advanced-470f3a42-7a97-4b9d-aaa0-26deb3d234f9' {
|
||||
recordfail
|
||||
load_video
|
||||
gfxmode $linux_gfx_mode
|
||||
insmod gzio
|
||||
if [ x$grub_platform = xxen ]; then insmod xzio; insmod lzopio; fi
|
||||
insmod part_gpt
|
||||
insmod ext2
|
||||
if [ x$feature_platform_search_hint = xy ]; then
|
||||
search --no-floppy --fs-uuid --set=root 470f3a42-7a97-4b9d-aaa0-26deb3d234f9
|
||||
else
|
||||
search --no-floppy --fs-uuid --set=root 470f3a42-7a97-4b9d-aaa0-26deb3d234f9
|
||||
fi
|
||||
echo 'Loading Linux 4.15.18 ...'
|
||||
linux /boot/vmlinuz-4.15.18 root=UUID=470f3a42-7a97-4b9d-aaa0-26deb3d234f9 ro nokaslr console=ttyS0 maybe-ubiquity
|
||||
echo 'Loading initial ramdisk ...'
|
||||
initrd /boot/initrd.img-4.15.18
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于menu.list,修改如下:
|
||||
|
||||
```
|
||||
title Ubuntu 18.04.2 LTS, kernel 4.15.18
|
||||
root (hd0)
|
||||
kernel /boot/vmlinuz-4.15.18 root=/dev/hda1 ro nokaslr console=hvc0 console=ttyS0
|
||||
initrd /boot/initrd.img-4.15.18
|
||||
|
||||
```
|
||||
|
||||
修改完毕后,我们需要在虚拟机里面shutdown -h now,来关闭虚拟机。注意不要reboot,因为虚拟机里面运行reboot,我们改过的那个XML会不起作用。
|
||||
|
||||
当我们在宿主机上发现虚拟机关机之后,就可以通过virsh start ubuntutest启动虚拟机,这个时候我们添加的参数-s才起作用。
|
||||
|
||||
第五步,使用gdb运行内核的二进制文件,执行gdb vmlinux。
|
||||
|
||||
```
|
||||
/mnt/vdc/linux-source-4.15.0# gdb vmlinux
|
||||
GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.5) 7.11.1
|
||||
......
|
||||
To enable execution of this file add
|
||||
add-auto-load-safe-path /mnt/vdc/linux-source-4.15.0/vmlinux-gdb.py
|
||||
......
|
||||
(gdb) b sys_sayhelloworld
|
||||
Breakpoint 1 at 0xffffffff8109e2f0: file kernel/sys.c, line 192.
|
||||
(gdb) target remote :1234
|
||||
Remote debugging using :1234
|
||||
native_safe_halt () at ./arch/x86/include/asm/irqflags.h:61
|
||||
61 }
|
||||
(gdb) c
|
||||
Continuing.
|
||||
[Switching to Thread 2]
|
||||
Thread 2 hit Breakpoint 1, sys_sayhelloworld (words=0x563cbfa907c4 "I am liuchao from user mode.", count=29) at kernel/sys.c:192
|
||||
192 {
|
||||
(gdb) bt
|
||||
#0 sys_sayhelloworld (words=0x55b2811537c4 "I am liuchao from user mode.", count=29) at kernel/sys.c:192
|
||||
#1 0xffffffff810039f7 in do_syscall_64 (regs=0xffffc9000133bf58) at arch/x86/entry/common.c:290
|
||||
#2 0xffffffff81a00081 in entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:237
|
||||
(gdb) n
|
||||
195 if(count >= 1024){
|
||||
(gdb) n
|
||||
198 copy_from_user(buffer, words, count);
|
||||
(gdb) n
|
||||
199 ret=printk("User Mode says %s to the Kernel Mode!", buffer);
|
||||
(gdb) p buffer
|
||||
$1 = "I am liuchao from user mode.\000\177\000\000\...
|
||||
(gdb) n
|
||||
200 return ret;
|
||||
(gdb) p ret
|
||||
$2 = 63
|
||||
(gdb) c
|
||||
(gdb) n
|
||||
do_syscall_64 (regs=0xffffc9000133bf58) at arch/x86/entry/common.c:295
|
||||
295 syscall_return_slowpath(regs);
|
||||
(gdb) s
|
||||
syscall_return_slowpath (regs=<optimized out>) at arch/x86/entry/common.c:295
|
||||
(gdb) n
|
||||
268 prepare_exit_to_usermode(regs);
|
||||
(gdb) n
|
||||
do_syscall_64 (regs=0xffffc9000133bf58) at arch/x86/entry/common.c:296
|
||||
296 }
|
||||
(gdb) n
|
||||
entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:246
|
||||
246 movq RCX(%rsp), %rcx
|
||||
......
|
||||
(gdb) n
|
||||
entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:330
|
||||
330 USERGS_SYSRET64
|
||||
|
||||
```
|
||||
|
||||
我们先设置一个断点在我们自己写的系统调用上b sys_sayhelloworld,通过执行target remote :1234,来attach到qemu上,然后,执行c,也即continue运行内核。这个时候内核始终在Continuing的状态,也即持续在运行中,这个时候我们可以远程登录到qemu里的虚拟机上,执行各种命令。
|
||||
|
||||
如果我们在虚拟机里面运行syscall.c编译好的a.out,这个时候肯定会调用到内核。内核肯定会经过系统调用的过程,到达sys_sayhelloworld这个函数,这就碰到了我们设置的那个断点。
|
||||
|
||||
如果执行bt,我们能看到,这个系统调用是从entry_64.S里面的entry_SYSCALL_64 ()函数,调用到do_syscall_64函数,再调用到sys_sayhelloworld函数的。这一点和我们在[系统调用](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="">
|
||||
127
极客时间专栏/趣谈Linux操作系统/实战串讲篇/62 | 知识串讲:用一个创业故事串起操作系统原理(一).md
Normal file
127
极客时间专栏/趣谈Linux操作系统/实战串讲篇/62 | 知识串讲:用一个创业故事串起操作系统原理(一).md
Normal 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的大小,我们通常称为MBR(Master Boot Record,主引导记录/扇区)。这里保存了boot.img,BIOS手册会将他加载到内存中的0x7c00来运行。
|
||||
|
||||
boot.img做不了太多的事情。他能做的最重要的一个事情,就是加载grub2的另一个镜像core.img。
|
||||
|
||||
引导扇区就是小马找到的门卫,虽然他看着档案库的大门,但是知道的事情很少。他不知道宝典在哪里,但是,他知道应该问谁。门卫说,档案库入口处有个管理处,然后把小马领到门口。
|
||||
|
||||
core.img就是管理处,他们知道的和能做的事情就多了一些。core.img由lzma_decompress.img、diskboot.img、kernel.img和一系列的模块组成,功能比较丰富,能做很多事情。
|
||||
|
||||
boot.img将控制权交给diskboot.img后,diskboot.img的任务就是将core.img的其他部分加载进来,先是解压缩程序lzma_decompress.img,再往下是kernel.img,最后是各个模块module对应的映像。
|
||||
|
||||
管理处听说小马要找宝典,知道他将来是要做老板的人。管理处就告诉小马,既然是老板,早晚都要雇人干活的。这不是个体户小打小闹,所以,你需要切换到老板角色,进入保护模式,把哪些是你的权限,哪些是你可以授权给别人的,都分得清清楚楚。
|
||||
|
||||
这些,小马都铭记在心,此时此刻,虽然公司还是只有他一个人,但是小马的眼界放宽了,能够管理的内存空间大多了,也开始区分哪些是用户态,哪些是内核态了。
|
||||
|
||||
接下来,kernel.img里面的grub_main会给小马展示一个《企业经营宝典》的列表,也即操作系统的列表,让小马进行选择。经营企业的方式也有很多种,到底是人性化的,还是强纪律的,这个时候你要做一个选择。
|
||||
|
||||
<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="">
|
||||
136
极客时间专栏/趣谈Linux操作系统/实战串讲篇/63 | 知识串讲:用一个创业故事串起操作系统原理(二).md
Normal file
136
极客时间专栏/趣谈Linux操作系统/实战串讲篇/63 | 知识串讲:用一个创业故事串起操作系统原理(二).md
Normal 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="">
|
||||
149
极客时间专栏/趣谈Linux操作系统/实战串讲篇/64 | 知识串讲:用一个创业故事串起操作系统原理(三).md
Normal file
149
极客时间专栏/趣谈Linux操作系统/实战串讲篇/64 | 知识串讲:用一个创业故事串起操作系统原理(三).md
Normal 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分成1K(1024)个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位的系统,两级肯定不够了,就变成了四级目录,分别是全局页目录项PGD(Page Global Directory)、上层页目录项PUD(Page Upper Directory)、中间页目录项PMD(Page Middle Directory)和页表项PTE(Page Table Entry)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/0b/42eff3e7574ac8ce2501210e25cd2c0b.jpg" alt="">
|
||||
|
||||
设计完毕会议室管理系统,再加上前面的项目管理系统,对于一家外包公司来讲,无论接什么样的项目都能轻松搞定了。我们常把CPU和内存合称为计算。至此,计算的问题就算搞定了。解决了这两大问题,一家外包公司的生存问题,就算解决了。
|
||||
|
||||
小马总算是可以松一口气了,他和周瑜、张昭好好地搓了一顿,喝得昏天黑地。周瑜和张昭纷纷感慨,幸亏当年跟了马哥,今日才有出头之日。
|
||||
|
||||
生存问题虽然解决了,马哥可非池中之物,接下来要解决的就是发展问题,马哥能想出什么办法进一步壮大企业呢?欲知后事,且听下回分解。
|
||||
193
极客时间专栏/趣谈Linux操作系统/实战串讲篇/65 | 知识串讲:用一个创业故事串起操作系统原理(四).md
Normal file
193
极客时间专栏/趣谈Linux操作系统/实战串讲篇/65 | 知识串讲:用一个创业故事串起操作系统原理(四).md
Normal 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操作系统里面,每一个文件有一个Inode,inode的“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="">
|
||||
137
极客时间专栏/趣谈Linux操作系统/实战串讲篇/66 | 知识串讲:用一个创业故事串起操作系统原理(五).md
Normal file
137
极客时间专栏/趣谈Linux操作系统/实战串讲篇/66 | 知识串讲:用一个创业故事串起操作系统原理(五).md
Normal 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层在负重前行。
|
||||
|
||||
传输层再往上就是应用层,例如,咱们在浏览器里面输入的HTTP,Java服务端写的Servlet,都是这一层的。
|
||||
|
||||
二层到四层都是在Linux内核里面处理的,应用层例如浏览器、Nginx、Tomcat都是用户态的。内核里面对于网络包的处理是不区分应用的。
|
||||
|
||||
从四层再往上,就需要区分网络包发给哪个应用。在传输层的TCP和UDP协议里面,都有端口的概念,不同的应用监听不同的端口。例如,服务端Nginx监听80、Tomcat监听8080;再如客户端浏览器监听一个随机端口,FTP客户端监听另外一个随机端口。
|
||||
|
||||
应用层和内核互通的机制,就是通过Socket系统调用。所以经常有人会问,Socket属于哪一层,其实它哪一层都不属于,它属于操作系统的概念,而非网络协议分层的概念。
|
||||
|
||||
操作系统对于网络协议的实现模式是这样的:二到四层的处理代码在内核里面,七层的处理代码让应用自己去做。两者需要跨内核态和用户态通信,就需要一个系统调用完成这个衔接,这就是Socket。
|
||||
|
||||
如果公司想要和其他公司沟通,我们将请求封装为HTTP协议,通过Socket发送到内核。内核的网络协议栈里面,在TCP层创建用于维护连接、序列号、重传、拥塞控制的数据结构,将HTTP包加上TCP头,发送给IP层,IP层加上IP头,发送给MAC层,MAC层加上MAC头,从硬件网卡发出去。
|
||||
|
||||
最终网络包会被转发到目标服务器,它发现MAC地址匹配,就将MAC头取下来,交给上一层。IP层发现IP地址匹配,将IP头取下来,交给上一层。TCP层会根据TCP头中的序列号等信息,发现它是一个正确的网络包,就会将网络包缓存起来,等待应用层的读取。
|
||||
|
||||
应用层通过Socket监听某个端口,因而读取的时候,内核会根据TCP头中的端口号,将网络包发给相应的应用。
|
||||
|
||||
<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里面设置)。当确认开始了标志位之后,通过内核模块KVM,GuestOS的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="">
|
||||
112
极客时间专栏/趣谈Linux操作系统/实战串讲篇/期末测试 | 这些操作系统问题,你真的掌握了吗?.md
Normal file
112
极客时间专栏/趣谈Linux操作系统/实战串讲篇/期末测试 | 这些操作系统问题,你真的掌握了吗?.md
Normal 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&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>
|
||||
秃头哥:……
|
||||
85
极客时间专栏/趣谈Linux操作系统/实战串讲篇/结束语 | 永远别轻视任何技术,也永远别轻视自己.md
Normal file
85
极客时间专栏/趣谈Linux操作系统/实战串讲篇/结束语 | 永远别轻视任何技术,也永远别轻视自己.md
Normal 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)。如果你对这个专栏或者我本人有什么建议,可以通过这个问卷进行反馈,我一定会认真查看每一封的内容。期待你的反馈!
|
||||
@@ -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”。显卡画完了,客户看到了,就觉得自己的输入成功了。
|
||||
|
||||
当用户输入完毕之后,回车一下,还是会通过键盘驱动程序告诉操作系统,操作系统还是会找到QQ,QQ会将用户的输入发送到网络上。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="">
|
||||
@@ -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的密码;对于Linux,Root的密码同样也是在安装过程中设置的。
|
||||
|
||||
<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-r–r--就可以翻译为,这是一个普通文件,对于所属用户,可读可写不能执行;对于所属的组,仅仅可读;对于其他用户,也是仅仅可读。如果想改变权限,可以使用命令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就是query,a就是all,-l的意思就是list。
|
||||
|
||||
如果真的去运行的话,你会发现这个列表很长很长,很难找到你安装的软件。如果你知道要安装的软件包含某个关键词,可以用一个很好用的搜索工具grep。
|
||||
|
||||
rpm -qa | grep jdk,这个命令是将列出来的所有软件形成一个输出。| 是管道,用于连接两个程序,前面rpm -qa的输出就放进管道里面,然后作为grep的输入,grep将在里面进行搜索带关键词jdk的行,并且输出出来。grep支持正则表达式,因此搜索的时候很灵活,再加上管道,这是一个很常用的模式。同理dpkg -l | grep jdk也是能够找到的。
|
||||
|
||||
如果你不知道关键词,可以使用rpm -qa | more和rpm -qa | less这两个命令,它们可以将很长的结果分页展示出来。这样你就可以一个个来找了。
|
||||
|
||||
我们还是利用管道的机制。more是分页后只能往后翻页,翻到最后一页自动结束返回命令行,less是往前往后都能翻页,需要输入q返回命令行,q就是quit。
|
||||
|
||||
如果要删除,可以用rpm -e和dpkg -r。-e就是erase,-r就是remove。
|
||||
|
||||
我们刚才说的都是没有软件管家的情况,后来Windows上有了软件管家,就方便多了。我们直接搜索一下,然后点击安装就行了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4c/9b/4c0cddd6f5ea77bc4aeabc135e6e8a9b.png" alt="">
|
||||
|
||||
Linux也有自己的软件管家,CentOS下面是yum,Ubuntu下面是apt-get。
|
||||
|
||||
你可以根据关键词搜索,例如搜索jdk、yum search jdk和apt-cache search jdk,可以搜索出很多很多可以安装的jdk版本。如果数目太多,你可以通过管道grep、more、less来进行过滤。
|
||||
|
||||
选中一个之后,我们就可以进行安装了。你可以用yum install java-11-openjdk.x86_64和apt-get install openjdk-9-jdk来进行安装。
|
||||
|
||||
安装以后,如何卸载呢?我们可以使用yum erase java-11-openjdk.x86_64和apt-get purge openjdk-9-jdk。
|
||||
|
||||
Windows上的软件管家会有一个统一的服务端,来保存这些软件,但是我们不知道服务端在哪里。而Linux允许我们配置从哪里下载这些软件的,地点就在配置文件里面。
|
||||
|
||||
对于CentOS来讲,配置文件在/etc/yum.repos.d/CentOS-Base.repo里。
|
||||
|
||||
```
|
||||
[base]
|
||||
name=CentOS-$releasever - Base - 163.com
|
||||
baseurl=http://mirrors.163.com/centos/$releasever/os/$basearch/
|
||||
gpgcheck=1
|
||||
gpgkey=http://mirrors.163.com/centos/RPM-GPG-KEY-CentOS-7
|
||||
|
||||
|
||||
```
|
||||
|
||||
对于Ubuntu来讲,配置文件在/etc/apt/sources.list里。
|
||||
|
||||
```
|
||||
deb http://mirrors.163.com/ubuntu/ xenial main restricted universe multiverse
|
||||
deb http://mirrors.163.com/ubuntu/ xenial-security main restricted universe multiverse
|
||||
deb http://mirrors.163.com/ubuntu/ xenial-updates main restricted universe multiverse
|
||||
deb http://mirrors.163.com/ubuntu/ xenial-proposed main restricted universe multiverse
|
||||
deb http://mirrors.163.com/ubuntu/ xenial-backports main restricted universe multiverse
|
||||
|
||||
```
|
||||
|
||||
这里为什么都是163.com呢?因为Linux服务器遍布全球,不能都从一个地方下载,最好选一个就近的地方下载,例如在中国,选择163.com,就不用跨越重洋了。
|
||||
|
||||
**其实无论是先下载再安装,还是通过软件管家进行安装,都是下载一些文件,然后将这些文件放在某个路径下,然后在相应的配置文件中配置一下。**例如,在Windows里面,最终会变成C:\Program Files下面的一个文件夹以及注册表里面的一些配置。对应Linux里面会放的更散一点。例如,主执行文件会放在/usr/bin或者/usr/sbin下面,其他的库文件会放在/var下面,配置文件会放在/etc下面。
|
||||
|
||||
所以其实还有一种简单粗暴的方法,就是将安装好的路径直接下载下来,然后解压缩成为一个整的路径。在JDK的安装目录中,Windows有jdk-XXX_Windows-x64_bin.zip,这是Windows下常用的压缩模式。Linux有jdk-XXX_linux-x64_bin.tar.gz,这是Linux下常用的压缩模式。
|
||||
|
||||
如何下载呢?Linux上面有一个工具wget,后面加上链接,就能从网上下载了。
|
||||
|
||||
下载下来后,我们就可以进行解压缩了。Windows下可以有winzip之类的解压缩程序,Linux下面默认会有tar程序。如果是解压缩zip包,就需要另行安装。
|
||||
|
||||
```
|
||||
yum install zip.x86_64 unzip.x86_64
|
||||
apt-get install zip unzip
|
||||
|
||||
```
|
||||
|
||||
如果是tar.gz这种格式的,通过tar xvzf jdk-XXX_linux-x64_bin.tar.gz就可以解压缩了。
|
||||
|
||||
对于Windows上jdk的安装,如果采取这种下载压缩包的格式,需要在系统设置的环境变量配置里面设置JAVA_HOME和PATH。
|
||||
|
||||
<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(不挂起),也就是说,当前交互命令行退出的时候,程序还要在。
|
||||
|
||||
当然这个时候,程序不能霸占交互命令行,而是应该在后台运行。最后加一个&,就表示后台运行。
|
||||
|
||||
另外一个要处理的就是输出,原来什么都打印在交互命令行里,现在在后台运行了,输出到哪里呢?输出到文件是最好的。
|
||||
|
||||
最终命令的一般形式为nohup command >out.file 2>&1 &。这里面,“1”表示文件描述符1,表示标准输出,“2”表示文件描述符2,意思是标准错误输出,“2>&1”表示标准输出和错误输出合并了。合并到哪里去呢?到out.file里。
|
||||
|
||||
那这个进程如何关闭呢?我们假设启动的程序包含某个关键字,那就可以使用下面的命令。
|
||||
|
||||
```
|
||||
ps -ef |grep 关键字 |awk '{print $2}'|xargs kill -9
|
||||
|
||||
```
|
||||
|
||||
从这个命令中,我们多少能看出shell的灵活性和精巧组合。
|
||||
|
||||
其中ps -ef可以单独执行,列出所有正在运行的程序,grep上面我们介绍过了,通过关键字找到咱们刚才启动的程序。
|
||||
|
||||
awk工具可以很灵活地对文本进行处理,这里的awk '{print $2}'是指第二列的内容,是运行的程序ID。我们可以通过xargs传递给kill -9,也就是发给这个运行的程序一个信号,让它关闭。如果你已经知道运行的程序ID,可以直接使用kill关闭运行的程序。
|
||||
|
||||
在Windows里面还有一种程序,称为服务。这是系统启动的时候就在的,我们可以通过控制面板的服务管理启动和关闭它。
|
||||
|
||||
<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的服务端应用,上手使用一下。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
|
||||
@@ -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位的是4G,64位的就更大了,我们不可能有这么多物理内存。就像一个公司的会议室是有限的,作为老板,你不可能事先都给项目组分配好。哪有这么多会议室啊,一定是需要的时候再分配。
|
||||
|
||||
所以,进程自己不用的部分就不用管,只有进程要去使用部分内存的时候,才会使用内存管理的系统调用来登记,说自己马上就要用了,希望分配一部分内存给它,但是这还不代表真的就对应到了物理内存。只有真的写入数据的时候,发现没有对应物理内存,才会触发一个中断,现分配物理内存。
|
||||
|
||||
<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="">
|
||||
@@ -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 "hello world" > hello
|
||||
|
||||
```
|
||||
|
||||
这个时候,管道里面的内容没有被读出,这个命令就是停在这里的,这说明当一个项目组要把它的输出交接给另一个项目组做输入,当没有交接完毕的时候,前一个项目组是不能撒手不管的。
|
||||
|
||||
这个时候,我们就需要重新连接一个终端。在终端中,用下面的命令读取管道里面的内容:
|
||||
|
||||
```
|
||||
# cat < 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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/msg.h>
|
||||
|
||||
|
||||
int main() {
|
||||
int messagequeueid;
|
||||
key_t key;
|
||||
|
||||
|
||||
if((key = ftok("/root/messagequeue/messagequeuekey", 1024)) < 0)
|
||||
{
|
||||
perror("ftok error");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
|
||||
printf("Message Queue key: %d.\n", key);
|
||||
|
||||
|
||||
if ((messagequeueid = msgget(key, IPC_CREAT|0777)) == -1)
|
||||
{
|
||||
perror("msgget error");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
|
||||
printf("Message queue id: %d.\n", messagequeueid);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在运行上面这个程序之前,我们先使用命令touch messagequeuekey,创建一个文件,然后多次执行的结果就会像下面这样:
|
||||
|
||||
```
|
||||
# ./a.out
|
||||
Message Queue key: 92536.
|
||||
Message queue id: 32768.
|
||||
|
||||
```
|
||||
|
||||
System V IPC体系有一个统一的命令行工具:ipcmk,ipcs和ipcrm用于创建、查看和删除IPC对象。
|
||||
|
||||
例如,ipcs -q就能看到上面我们创建的消息队列对象。
|
||||
|
||||
```
|
||||
# ipcs -q
|
||||
|
||||
|
||||
------ Message Queues --------
|
||||
key msqid owner perms used-bytes messages
|
||||
0x00016978 32768 root 777 0 0
|
||||
|
||||
```
|
||||
|
||||
接下来,我们来看如何发送信息。发送消息主要调用**msgsnd函数**。第一个参数是message queue的id,第二个参数是消息的结构体,第三个参数是消息的长度,最后一个参数是flag。这里IPC_NOWAIT表示发送的时候不阻塞,直接返回。
|
||||
|
||||
下面的这段程序,getopt_long、do-while循环以及switch,是用来解析命令行参数的。命令行参数的格式定义在long_options里面。每一项的第一个成员“id”“type”“message”是参数选项的全称,第二个成员都为1,表示参数选项后面要跟参数,最后一个成员’i’‘t’'m’是参数选项的简称。
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/msg.h>
|
||||
#include <getopt.h>
|
||||
#include <string.h>
|
||||
|
||||
|
||||
struct msg_buffer {
|
||||
long mtype;
|
||||
char mtext[1024];
|
||||
};
|
||||
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
int next_option;
|
||||
const char* const short_options = "i:t:m:";
|
||||
const struct option long_options[] = {
|
||||
{ "id", 1, NULL, 'i'},
|
||||
{ "type", 1, NULL, 't'},
|
||||
{ "message", 1, NULL, 'm'},
|
||||
{ NULL, 0, NULL, 0 }
|
||||
};
|
||||
|
||||
int messagequeueid = -1;
|
||||
struct msg_buffer buffer;
|
||||
buffer.mtype = -1;
|
||||
int len = -1;
|
||||
char * message = NULL;
|
||||
do {
|
||||
next_option = getopt_long (argc, argv, short_options, long_options, NULL);
|
||||
switch (next_option)
|
||||
{
|
||||
case 'i':
|
||||
messagequeueid = atoi(optarg);
|
||||
break;
|
||||
case 't':
|
||||
buffer.mtype = atol(optarg);
|
||||
break;
|
||||
case 'm':
|
||||
message = optarg;
|
||||
len = strlen(message) + 1;
|
||||
if (len > 1024) {
|
||||
perror("message too long.");
|
||||
exit(1);
|
||||
}
|
||||
memcpy(buffer.mtext, message, len);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}while(next_option != -1);
|
||||
|
||||
|
||||
if(messagequeueid != -1 && buffer.mtype != -1 && len != -1 && message != NULL){
|
||||
if(msgsnd(messagequeueid, &buffer, len, IPC_NOWAIT) == -1){
|
||||
perror("fail to send message.");
|
||||
exit(1);
|
||||
}
|
||||
} else {
|
||||
perror("arguments error");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我们可以编译并运行这个发送程序。
|
||||
|
||||
```
|
||||
gcc -o send sendmessage.c
|
||||
./send -i 32768 -t 123 -m "hello world"
|
||||
|
||||
```
|
||||
|
||||
接下来,我们再来看如何收消息。收消息主要调用**msgrcv函数**,第一个参数是message queue的id,第二个参数是消息的结构体,第三个参数是可接受的最大长度,第四个参数是消息类型,最后一个参数是flag,这里IPC_NOWAIT表示接收的时候不阻塞,直接返回。
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/msg.h>
|
||||
#include <getopt.h>
|
||||
#include <string.h>
|
||||
|
||||
|
||||
struct msg_buffer {
|
||||
long mtype;
|
||||
char mtext[1024];
|
||||
};
|
||||
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
int next_option;
|
||||
const char* const short_options = "i:t:";
|
||||
const struct option long_options[] = {
|
||||
{ "id", 1, NULL, 'i'},
|
||||
{ "type", 1, NULL, 't'},
|
||||
{ NULL, 0, NULL, 0 }
|
||||
};
|
||||
|
||||
int messagequeueid = -1;
|
||||
struct msg_buffer buffer;
|
||||
long type = -1;
|
||||
do {
|
||||
next_option = getopt_long (argc, argv, short_options, long_options, NULL);
|
||||
switch (next_option)
|
||||
{
|
||||
case 'i':
|
||||
messagequeueid = atoi(optarg);
|
||||
break;
|
||||
case 't':
|
||||
type = atol(optarg);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}while(next_option != -1);
|
||||
|
||||
|
||||
if(messagequeueid != -1 && type != -1){
|
||||
if(msgrcv(messagequeueid, &buffer, 1024, type, IPC_NOWAIT) == -1){
|
||||
perror("fail to recv message.");
|
||||
exit(1);
|
||||
}
|
||||
printf("received message type : %d, text: %s.", buffer.mtype, buffer.mtext);
|
||||
} else {
|
||||
perror("arguments error");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我们可以编译并运行这个发送程序。可以看到,如果有消息,可以正确地读到消息;如果没有,则返回没有消息。
|
||||
|
||||
```
|
||||
# ./recv -i 32768 -t 123
|
||||
received message type : 123, text: hello world.
|
||||
# ./recv -i 32768 -t 123
|
||||
fail to recv message.: No message of desired type
|
||||
|
||||
```
|
||||
|
||||
有了消息这种模型,两个进程之间的通信就像咱们平时发邮件一样,你来一封,我回一封,可以频繁沟通了。
|
||||
|
||||
## 共享内存模型
|
||||
|
||||
<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 < 0,就请求sem_op的绝对值的资源。如果相应的资源数可以满足请求,则将该信号量的值减去sem_op的绝对值,函数成功返回。
|
||||
|
||||
当相应的资源数不能满足请求时,就要看sem_flg了。如果把sem_flg设置为IPC_NOWAIT,也就是没有资源也不等待,则semop函数出错返回EAGAIN。如果sem_flg 没有指定IPC_NOWAIT,则进程挂起,直到当相应的资源数可以满足请求。若sem_op > 0,表示进程归还相应的资源数,将 sem_op 的值加到信号量的值上。如果有进程正在休眠等待此信号量,则唤醒它们。
|
||||
|
||||
```
|
||||
int semop(int semid, struct sembuf semoparray[], size_t numops);
|
||||
|
||||
|
||||
struct sembuf
|
||||
{
|
||||
short sem_num; // 信号量组中对应的序号,0~sem_nums-1
|
||||
short sem_op; // 信号量值在一次操作中的改变量
|
||||
short sem_flg; // IPC_NOWAIT, SEM_UNDO
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
信号量和共享内存都比较复杂,两者还要结合起来用,就更加复杂,它们内核的机制就更加复杂。这一节我们先不讲,放到本章的最后一节重点讲解。
|
||||
|
||||
## 信号
|
||||
|
||||
上面讲的进程间通信的方式,都是常规状态下的工作模式,对应到咱们平时的工作交接,收发邮件、联合开发等,其实还有一种异常情况下的工作模式。
|
||||
|
||||
例如出现线上系统故障,这个时候,什么流程都来不及了,不可能发邮件,也来不及开会,所有的架构师、开发、运维都要被通知紧急出动。所以,7乘24小时不间断执行的系统都需要有告警系统,一旦出事情,就要通知到人,哪怕是半夜,也要电话叫起来,处理故障。
|
||||
|
||||
对应到操作系统中,就是信号。信号没有特别复杂的数据结构,就是用一个代号一样的数字。Linux提供了几十种信号,分别代表不同的意义。信号之间依靠它们的值来区分。这就像咱们看警匪片,对于紧急的行动,都是说,“1号作战任务”开始执行,警察就开始行动了。情况紧急,不能啰里啰嗦了。
|
||||
|
||||
信号可以在任何时候发送给某一进程,进程需要为这个信号配置信号处理函数。当某个信号发生的时候,就默认执行这个函数就可以了。这就相当于咱们运维一个系统应急手册,当遇到什么情况,做什么事情,都事先准备好,出了事情照着做就可以了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们整体讲解了一下进程间通信的各种模式。你现在还能记住多少?
|
||||
|
||||
- 类似瀑布开发模式的管道
|
||||
- 类似邮件模式的消息队列
|
||||
- 类似会议室联合开发的共享内存加信号量
|
||||
- 类似应急预案的信号
|
||||
|
||||
当你自己使用的时候,可以根据不同的通信需要,选择不同的模式。
|
||||
|
||||
- 管道,请你记住这是命令行中常用的模式,面试问到的话,不要忘了。
|
||||
- 消息队列其实很少使用,因为有太多的用户级别的消息队列,功能更强大。
|
||||
- 共享内存加信号量是常用的模式。这个需要牢记,常见到一些知名的以C语言开发的开源软件都会用到它。
|
||||
- 信号更加常用,机制也比较复杂。我们后面会有单独的一节来解析。
|
||||
|
||||
## 课堂练习
|
||||
|
||||
这节课的程序,请你务必自己编译通过,搞清楚参数解析是怎么做的,这个以后你自己写程序的时候,很有用,另外消息队列模型的API调用流程,也要搞清楚,要知道他们都属于System V系列,后面我们学共享内存和信号量,能看到完全类似的API调用流程。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
@@ -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 (&act.sa_mask);
|
||||
act.sa_flags = SA_ONESHOT | SA_NOMASK | SA_INTERRUPT;
|
||||
act.sa_flags &= ~SA_RESTART;
|
||||
if (__sigaction (sig, &act, &oact) < 0)
|
||||
return SIG_ERR;
|
||||
return oact.sa_handler;
|
||||
}
|
||||
weak_alias (__sysv_signal, sysv_signal)
|
||||
|
||||
```
|
||||
|
||||
在这里面,sa_flags进行了默认的设置。SA_ONESHOT是什么意思呢?意思就是,这里设置的信号处理函数,仅仅起作用一次。用完了一次后,就设置回默认行为。这其实并不是我们想看到的。毕竟我们一旦安装了一个信号处理函数,肯定希望它一直起作用,直到我显式地关闭它。
|
||||
|
||||
另外一个设置就是**SA_NOMASK**。我们通过__sigemptyset,将sa_mask设置为空。这样的设置表示在这个信号处理函数执行过程中,如果再有其他信号,哪怕相同的信号到来的时候,这个信号处理函数会被中断。如果一个信号处理函数真的被其他信号中断,其实问题也不大,因为当处理完了其他的信号处理函数后,还会回来接着处理这个信号处理函数的,但是对于相同的信号就有点尴尬了,这就需要这个信号处理函数写得比较有技巧了。
|
||||
|
||||
例如,对于这个信号的处理过程中,要操作某个数据结构,因为是相同的信号,很可能操作的是同一个实例,这样的话,同步、死锁这些都要想好。其实一般的思路应该是,当某一个信号的信号处理函数运行的时候,我们暂时屏蔽这个信号。后面我们还会仔细分析屏蔽这个动作,屏蔽并不意味着信号一定丢失,而是暂存,这样能够做到信号处理函数对于相同的信号,处理完一个再处理下一个,这样信号处理函数的逻辑要简单得多。
|
||||
|
||||
还有一个设置就是设置了**SA_INTERRUPT,清除了SA_RESTART**。这是什么意思呢?我们知道,信号的到来时间是不可预期的,有可能程序正在调用某个漫长的系统调用的时候(你可以在一台Linux机器上运行man 7 signal命令,在这里找Interruption of system calls and library functions by signal handlers的部分,里面说得非常详细),这个时候一个信号来了,会中断这个系统调用,去执行信号处理函数,那执行完了以后呢?系统调用怎么办呢?
|
||||
|
||||
这时候有两种处理方法,一种就是SA_INTERRUPT,也即系统调用被中断了,就不再重试这个系统调用了,而是直接返回一个-EINTR常量,告诉调用方,这个系统调用被信号中断了,但是怎么处理你看着办。如果是这样的话,调用方可以根据自己的逻辑,重新调用或者直接返回,这会使得我们的代码非常复杂,在所有系统调用的返回值判断里面,都要特殊判断一下这个值。
|
||||
|
||||
另外一种处理方法是SA_RESTART。这个时候系统调用会被自动重新启动,不需要调用方自己写代码。当然也可能存在问题,例如从终端读入一个字符,这个时候用户在终端输入一个`'a'`字符,在处理`'a'`字符的时候被信号中断了,等信号处理完毕,再次读入一个字符的时候,如果用户不再输入,就停在那里了,需要用户再次输入同一个字符。
|
||||
|
||||
因此,建议你使用sigaction函数,根据自己的需要定制参数。
|
||||
|
||||
接下来,我们来看sigaction具体做了些什么。
|
||||
|
||||
还记得在学习系统调用那一节的时候,我们知道,glibc里面有个文件syscalls.list。这里面定义了库函数调用哪些系统调用,在这里我们找到了sigaction。
|
||||
|
||||
```
|
||||
sigaction - sigaction i:ipp __sigaction sigaction
|
||||
|
||||
```
|
||||
|
||||
接下来,在glibc中,__sigaction会调用__libc_sigaction,并最终调用的系统调用是rt_sigaction。
|
||||
|
||||
```
|
||||
int
|
||||
__sigaction (int sig, const struct sigaction *act, struct sigaction *oact)
|
||||
{
|
||||
......
|
||||
return __libc_sigaction (sig, act, oact);
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
__libc_sigaction (int sig, const struct sigaction *act, struct sigaction *oact)
|
||||
{
|
||||
int result;
|
||||
struct kernel_sigaction kact, koact;
|
||||
|
||||
|
||||
if (act)
|
||||
{
|
||||
kact.k_sa_handler = act->sa_handler;
|
||||
memcpy (&kact.sa_mask, &act->sa_mask, sizeof (sigset_t));
|
||||
kact.sa_flags = act->sa_flags | SA_RESTORER;
|
||||
|
||||
|
||||
kact.sa_restorer = &restore_rt;
|
||||
}
|
||||
|
||||
|
||||
result = INLINE_SYSCALL (rt_sigaction, 4,
|
||||
sig, act ? &kact : NULL,
|
||||
oact ? &koact : NULL, _NSIG / 8);
|
||||
if (oact && result >= 0)
|
||||
{
|
||||
oact->sa_handler = koact.k_sa_handler;
|
||||
memcpy (&oact->sa_mask, &koact.sa_mask, sizeof (sigset_t));
|
||||
oact->sa_flags = koact.sa_flags;
|
||||
oact->sa_restorer = koact.sa_restorer;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这也是很多人看信号处理的内核实现的时候,比较困惑的地方。例如,内核代码注释里面会说,系统调用signal是为了兼容过去,系统调用sigaction也是为了兼容过去,连参数都变成了struct compat_old_sigaction,所以说,我们的库函数虽然调用的是sigaction,到了系统调用层,调用的可不是系统调用sigaction,而是系统调用rt_sigaction。
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE4(rt_sigaction, int, sig,
|
||||
const struct sigaction __user *, act,
|
||||
struct sigaction __user *, oact,
|
||||
size_t, sigsetsize)
|
||||
{
|
||||
struct k_sigaction new_sa, old_sa;
|
||||
int ret = -EINVAL;
|
||||
......
|
||||
if (act) {
|
||||
if (copy_from_user(&new_sa.sa, act, sizeof(new_sa.sa)))
|
||||
return -EFAULT;
|
||||
}
|
||||
|
||||
|
||||
ret = do_sigaction(sig, act ? &new_sa : NULL, oact ? &old_sa : NULL);
|
||||
|
||||
|
||||
if (!ret && oact) {
|
||||
if (copy_to_user(oact, &old_sa.sa, sizeof(old_sa.sa)))
|
||||
return -EFAULT;
|
||||
}
|
||||
out:
|
||||
return ret;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在rt_sigaction里面,我们将用户态的struct sigaction结构,拷贝为内核态的k_sigaction,然后调用do_sigaction。do_sigaction也很简单,还记得进程内核的数据结构里,struct task_struct里面有一个成员sighand,里面有一个action。这是一个数组,下标是信号,内容就是信号处理函数,do_sigaction就是设置sighand里的信号处理函数。
|
||||
|
||||
```
|
||||
int do_sigaction(int sig, struct k_sigaction *act, struct k_sigaction *oact)
|
||||
{
|
||||
struct task_struct *p = current, *t;
|
||||
struct k_sigaction *k;
|
||||
sigset_t mask;
|
||||
......
|
||||
k = &p->sighand->action[sig-1];
|
||||
|
||||
|
||||
spin_lock_irq(&p->sighand->siglock);
|
||||
if (oact)
|
||||
*oact = *k;
|
||||
|
||||
|
||||
if (act) {
|
||||
sigdelsetmask(&act->sa.sa_mask,
|
||||
sigmask(SIGKILL) | sigmask(SIGSTOP));
|
||||
*k = *act;
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
spin_unlock_irq(&p->sighand->siglock);
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
至此,信号处理函数的注册已经完成了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节讲了如何通过API注册一个信号处理函数,整个过程如下图所示。
|
||||
|
||||
- 在用户程序里面,有两个函数可以调用,一个是signal,一个是sigaction,推荐使用sigaction。
|
||||
- 用户程序调用的是Glibc里面的函数,signal调用的是__sysv_signal,里面默认设置了一些参数,使得signal的功能受到了限制,sigaction调用的是__sigaction,参数用户可以任意设定。
|
||||
- 无论是__sysv_signal还是__sigaction,调用的都是统一的一个系统调用rt_sigaction。
|
||||
- 在内核中,rt_sigaction调用的是do_sigaction设置信号处理函数。在每一个进程的task_struct里面,都有一个sighand指向struct sighand_struct,里面是一个数组,下标是信号,里面的内容是信号处理函数。
|
||||
|
||||
<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="">
|
||||
@@ -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->kill_something_info->kill_pid_info->group_send_sig_info->do_send_sig_info
|
||||
- tkill->do_tkill->do_send_specific->do_send_sig_info
|
||||
- tgkill->do_tkill->do_send_specific->do_send_sig_info
|
||||
- rt_sigqueueinfo->do_rt_sigqueueinfo->kill_proc_info->kill_pid_info->group_send_sig_info->do_send_sig_info
|
||||
|
||||
do_send_sig_info会调用send_signal,进而调用__send_signal。
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE2(kill, pid_t, pid, int, sig)
|
||||
{
|
||||
struct siginfo info;
|
||||
|
||||
info.si_signo = sig;
|
||||
info.si_errno = 0;
|
||||
info.si_code = SI_USER;
|
||||
info.si_pid = task_tgid_vnr(current);
|
||||
info.si_uid = from_kuid_munged(current_user_ns(), current_uid());
|
||||
|
||||
return kill_something_info(sig, &info, pid);
|
||||
}
|
||||
|
||||
|
||||
static int __send_signal(int sig, struct siginfo *info, struct task_struct *t,
|
||||
int group, int from_ancestor_ns)
|
||||
{
|
||||
struct sigpending *pending;
|
||||
struct sigqueue *q;
|
||||
int override_rlimit;
|
||||
int ret = 0, result;
|
||||
......
|
||||
pending = group ? &t->signal->shared_pending : &t->pending;
|
||||
......
|
||||
if (legacy_queue(pending, sig))
|
||||
goto ret;
|
||||
|
||||
if (sig < SIGRTMIN)
|
||||
override_rlimit = (is_si_special(info) || info->si_code >= 0);
|
||||
else
|
||||
override_rlimit = 0;
|
||||
|
||||
q = __sigqueue_alloc(sig, t, GFP_ATOMIC | __GFP_NOTRACK_FALSE_POSITIVE,
|
||||
override_rlimit);
|
||||
if (q) {
|
||||
list_add_tail(&q->list, &pending->list);
|
||||
switch ((unsigned long) info) {
|
||||
case (unsigned long) SEND_SIG_NOINFO:
|
||||
q->info.si_signo = sig;
|
||||
q->info.si_errno = 0;
|
||||
q->info.si_code = SI_USER;
|
||||
q->info.si_pid = task_tgid_nr_ns(current,
|
||||
task_active_pid_ns(t));
|
||||
q->info.si_uid = from_kuid_munged(current_user_ns(), current_uid());
|
||||
break;
|
||||
case (unsigned long) SEND_SIG_PRIV:
|
||||
q->info.si_signo = sig;
|
||||
q->info.si_errno = 0;
|
||||
q->info.si_code = SI_KERNEL;
|
||||
q->info.si_pid = 0;
|
||||
q->info.si_uid = 0;
|
||||
break;
|
||||
default:
|
||||
copy_siginfo(&q->info, info);
|
||||
if (from_ancestor_ns)
|
||||
q->info.si_pid = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
userns_fixup_signal_uid(&q->info, t);
|
||||
|
||||
}
|
||||
......
|
||||
out_set:
|
||||
signalfd_notify(t, sig);
|
||||
sigaddset(&pending->signal, sig);
|
||||
complete_signal(sig, t, group);
|
||||
ret:
|
||||
return ret;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里,我们看到,在学习进程数据结构中task_struct里面的sigpending。在上面的代码里面,我们先是要决定应该用哪个sigpending。这就要看我们发送的信号,是给进程的还是线程的。如果是kill发送的,也就是发送给整个进程的,就应该发送给t->signal->shared_pending。这里面是整个进程所有线程共享的信号;如果是tkill发送的,也就是发给某个线程的,就应该发给t->pending。这里面是这个线程的task_struct独享的。
|
||||
|
||||
struct sigpending里面有两个成员,一个是一个集合sigset_t,表示都收到了哪些信号,还有一个链表,也表示收到了哪些信号。它的结构如下:
|
||||
|
||||
```
|
||||
struct sigpending {
|
||||
struct list_head list;
|
||||
sigset_t signal;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
如果都表示收到了信号,这两者有什么区别呢?我们接着往下看__send_signal里面的代码。接下来,我们要调用legacy_queue。如果满足条件,那就直接退出。那legacy_queue里面判断的是什么条件呢?我们来看它的代码。
|
||||
|
||||
```
|
||||
static inline int legacy_queue(struct sigpending *signals, int sig)
|
||||
{
|
||||
return (sig < SIGRTMIN) && sigismember(&signals->signal, sig);
|
||||
}
|
||||
|
||||
|
||||
#define SIGRTMIN 32
|
||||
#define SIGRTMAX _NSIG
|
||||
#define _NSIG 64
|
||||
|
||||
```
|
||||
|
||||
当信号小于SIGRTMIN,也即32的时候,如果我们发现这个信号已经在集合里面了,就直接退出了。这样会造成什么现象呢?就是信号的丢失。例如,我们发送给进程100个SIGUSR1(对应的信号为10),那最终能够被我们的信号处理函数处理的信号有多少呢?这就不好说了,比如总共5个SIGUSR1,分别是A、B、C、D、E。
|
||||
|
||||
如果这五个信号来得太密。A来了,但是信号处理函数还没来得及处理,B、C、D、E就都来了。根据上面的逻辑,因为A已经将SIGUSR1放在sigset_t集合中了,因而后面四个都要丢失。 如果是另一种情况,A来了已经被信号处理函数处理了,内核在调用信号处理函数之前,我们会将集合中的标志位清除,这个时候B再来,B还是会进入集合,还是会被处理,也就不会丢。
|
||||
|
||||
这样信号能够处理多少,和信号处理函数什么时候被调用,信号多大频率被发送,都有关系,而且从后面的分析,我们可以知道,信号处理函数的调用时间也是不确定的。看小于32的信号如此不靠谱,我们就称它为**不可靠信号**。
|
||||
|
||||
如果大于32的信号是什么情况呢?我们接着看。接下来,__sigqueue_alloc会分配一个struct sigqueue对象,然后通过list_add_tail挂在struct sigpending里面的链表上。这样就靠谱多了是不是?如果发送过来100个信号,变成链表上的100项,都不会丢,哪怕相同的信号发送多遍,也处理多遍。因此,大于32的信号我们称为**可靠信号**。当然,队列的长度也是有限制的,如果我们执行ulimit命令,可以看到,这个限制pending signals (-i) 15408。
|
||||
|
||||
当信号挂到了task_struct结构之后,最后我们需要调用complete_signal。这里面的逻辑也很简单,就是说,既然这个进程有了一个新的信号,赶紧找一个线程处理一下吧。
|
||||
|
||||
```
|
||||
static void complete_signal(int sig, struct task_struct *p, int group)
|
||||
{
|
||||
struct signal_struct *signal = p->signal;
|
||||
struct task_struct *t;
|
||||
|
||||
/*
|
||||
* Now find a thread we can wake up to take the signal off the queue.
|
||||
*
|
||||
* If the main thread wants the signal, it gets first crack.
|
||||
* Probably the least surprising to the average bear.
|
||||
*/
|
||||
if (wants_signal(sig, p))
|
||||
t = p;
|
||||
else if (!group || thread_group_empty(p))
|
||||
/*
|
||||
* There is just one thread and it does not need to be woken.
|
||||
* It will dequeue unblocked signals before it runs again.
|
||||
*/
|
||||
return;
|
||||
else {
|
||||
/*
|
||||
* Otherwise try to find a suitable thread.
|
||||
*/
|
||||
t = signal->curr_target;
|
||||
while (!wants_signal(sig, t)) {
|
||||
t = next_thread(t);
|
||||
if (t == signal->curr_target)
|
||||
return;
|
||||
}
|
||||
signal->curr_target = t;
|
||||
}
|
||||
......
|
||||
/*
|
||||
* The signal is already in the shared-pending queue.
|
||||
* Tell the chosen thread to wake up and dequeue it.
|
||||
*/
|
||||
signal_wake_up(t, sig == SIGKILL);
|
||||
return;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在找到了一个进程或者线程的task_struct之后,我们要调用signal_wake_up,来企图唤醒它,signal_wake_up会调用signal_wake_up_state。
|
||||
|
||||
```
|
||||
void signal_wake_up_state(struct task_struct *t, unsigned int state)
|
||||
{
|
||||
set_tsk_thread_flag(t, TIF_SIGPENDING);
|
||||
|
||||
|
||||
if (!wake_up_state(t, state | TASK_INTERRUPTIBLE))
|
||||
kick_process(t);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
signal_wake_up_state里面主要做了两件事情。第一,就是给这个线程设置TIF_SIGPENDING,这就说明其实信号的处理和进程的调度是采取这样一种类似的机制。还记得咱们调度的时候是怎么操作的吗?
|
||||
|
||||
当发现一个进程应该被调度的时候,我们并不直接把它赶下来,而是设置一个标识位TIF_NEED_RESCHED,表示等待调度,然后等待系统调用结束或者中断处理结束,从内核态返回用户态的时候,调用schedule函数进行调度。信号也是类似的,当信号来的时候,我们并不直接处理这个信号,而是设置一个标识位TIF_SIGPENDING,来表示已经有信号等待处理。同样等待系统调用结束,或者中断处理结束,从内核态返回用户态的时候,再进行信号的处理。
|
||||
|
||||
signal_wake_up_state的第二件事情,就是试图唤醒这个进程或者线程。wake_up_state会调用try_to_wake_up方法。这个函数我们讲进程的时候讲过,就是将这个进程或者线程设置为TASK_RUNNING,然后放在运行队列中,这个时候,当随着时钟不断的滴答,迟早会被调用。如果wake_up_state返回0,说明进程或者线程已经是TASK_RUNNING状态了,如果它在另外一个CPU上运行,则调用kick_process发送一个处理器间中断,强制那个进程或者线程重新调度,重新调度完毕后,会返回用户态运行。这是一个时机会检查TIF_SIGPENDING标识位。
|
||||
|
||||
## 信号的处理
|
||||
|
||||
好了,信号已经发送到位了,什么时候真正处理它呢?
|
||||
|
||||
就是在从系统调用或者中断返回的时候,咱们讲调度的时候讲过,无论是从系统调用返回还是从中断返回,都会调用exit_to_usermode_loop,只不过我们上次主要关注了_TIF_NEED_RESCHED这个标识位,这次我们重点关注**_TIF_SIGPENDING标识位**。
|
||||
|
||||
```
|
||||
static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
|
||||
{
|
||||
while (true) {
|
||||
......
|
||||
if (cached_flags & _TIF_NEED_RESCHED)
|
||||
schedule();
|
||||
......
|
||||
/* deal with pending signal delivery */
|
||||
if (cached_flags & _TIF_SIGPENDING)
|
||||
do_signal(regs);
|
||||
......
|
||||
if (!(cached_flags & EXIT_TO_USERMODE_LOOP_FLAGS))
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果在前一个环节中,已经设置了_TIF_SIGPENDING,我们就调用do_signal进行处理。
|
||||
|
||||
```
|
||||
void do_signal(struct pt_regs *regs)
|
||||
{
|
||||
struct ksignal ksig;
|
||||
|
||||
if (get_signal(&ksig)) {
|
||||
/* Whee! Actually deliver the signal. */
|
||||
handle_signal(&ksig, regs);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Did we come from a system call? */
|
||||
if (syscall_get_nr(current, regs) >= 0) {
|
||||
/* Restart the system call - no handlers present */
|
||||
switch (syscall_get_error(current, regs)) {
|
||||
case -ERESTARTNOHAND:
|
||||
case -ERESTARTSYS:
|
||||
case -ERESTARTNOINTR:
|
||||
regs->ax = regs->orig_ax;
|
||||
regs->ip -= 2;
|
||||
break;
|
||||
|
||||
case -ERESTART_RESTARTBLOCK:
|
||||
regs->ax = get_nr_restart_syscall(regs);
|
||||
regs->ip -= 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
restore_saved_sigmask();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
do_signal会调用handle_signal。按说,信号处理就是调用用户提供的信号处理函数,但是这事儿没有看起来这么简单,因为信号处理函数是在用户态的。
|
||||
|
||||
咱们又要来回忆系统调用的过程了。这个进程当时在用户态执行到某一行Line A,调用了一个系统调用,在进入内核的那一刻,在内核pt_regs里面保存了用户态执行到了Line A。现在我们从系统调用返回用户态了,按说应该从pt_regs拿出Line A,然后接着Line A执行下去,但是为了响应信号,我们不能回到用户态的时候返回Line A了,而是应该返回信号处理函数的起始地址。
|
||||
|
||||
```
|
||||
static void
|
||||
handle_signal(struct ksignal *ksig, struct pt_regs *regs)
|
||||
{
|
||||
bool stepping, failed;
|
||||
......
|
||||
/* Are we from a system call? */
|
||||
if (syscall_get_nr(current, regs) >= 0) {
|
||||
/* If so, check system call restarting.. */
|
||||
switch (syscall_get_error(current, regs)) {
|
||||
case -ERESTART_RESTARTBLOCK:
|
||||
case -ERESTARTNOHAND:
|
||||
regs->ax = -EINTR;
|
||||
break;
|
||||
case -ERESTARTSYS:
|
||||
if (!(ksig->ka.sa.sa_flags & SA_RESTART)) {
|
||||
regs->ax = -EINTR;
|
||||
break;
|
||||
}
|
||||
/* fallthrough */
|
||||
case -ERESTARTNOINTR:
|
||||
regs->ax = regs->orig_ax;
|
||||
regs->ip -= 2;
|
||||
break;
|
||||
}
|
||||
}
|
||||
......
|
||||
failed = (setup_rt_frame(ksig, regs) < 0);
|
||||
......
|
||||
signal_setup_done(failed, ksig, stepping);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个时候,我们就需要干预和自己来定制pt_regs了。这个时候,我们要看,是否从系统调用中返回。如果是从系统调用返回的话,还要区分我们是从系统调用中正常返回,还是在一个非运行状态的系统调用中,因为会被信号中断而返回。
|
||||
|
||||
我们这里解析一个最复杂的场景。还记得咱们解析进程调度的时候,我们举的一个例子,就是从一个tap网卡中读取数据。当时我们主要关注schedule那一行,也即如果当发现没有数据的时候,就调用schedule,自己进入等待状态,然后将CPU让给其他进程。具体的代码如下:
|
||||
|
||||
```
|
||||
static ssize_t tap_do_read(struct tap_queue *q,
|
||||
struct iov_iter *to,
|
||||
int noblock, struct sk_buff *skb)
|
||||
{
|
||||
......
|
||||
while (1) {
|
||||
if (!noblock)
|
||||
prepare_to_wait(sk_sleep(&q->sk), &wait,
|
||||
TASK_INTERRUPTIBLE);
|
||||
|
||||
/* Read frames from the queue */
|
||||
skb = skb_array_consume(&q->skb_array);
|
||||
if (skb)
|
||||
break;
|
||||
if (noblock) {
|
||||
ret = -EAGAIN;
|
||||
break;
|
||||
}
|
||||
if (signal_pending(current)) {
|
||||
ret = -ERESTARTSYS;
|
||||
break;
|
||||
}
|
||||
/* Nothing to read, let's sleep */
|
||||
schedule();
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里我们关注和信号相关的部分。这其实是一个信号中断系统调用的典型逻辑。
|
||||
|
||||
首先,我们把当前进程或者线程的状态设置为TASK_INTERRUPTIBLE,这样才能使这个系统调用可以被中断。
|
||||
|
||||
其次,可以被中断的系统调用往往是比较慢的调用,并且会因为数据不就绪而通过schedule让出CPU进入等待状态。在发送信号的时候,我们除了设置这个进程和线程的_TIF_SIGPENDING标识位之外,还试图唤醒这个进程或者线程,也就是将它从等待状态中设置为TASK_RUNNING。
|
||||
|
||||
当这个进程或者线程再次运行的时候,我们根据进程调度第一定律,从schedule函数中返回,然后再次进入while循环。由于这个进程或者线程是由信号唤醒的,而不是因为数据来了而唤醒的,因而是读不到数据的,但是在signal_pending函数中,我们检测到了_TIF_SIGPENDING标识位,这说明系统调用没有真的做完,于是返回一个错误ERESTARTSYS,然后带着这个错误从系统调用返回。
|
||||
|
||||
然后,我们到了exit_to_usermode_loop->do_signal->handle_signal。在这里面,当发现出现错误ERESTARTSYS的时候,我们就知道这是从一个没有调用完的系统调用返回的,设置系统调用错误码EINTR。
|
||||
|
||||
接下来,我们就开始折腾pt_regs了,主要通过调用setup_rt_frame->__setup_rt_frame。
|
||||
|
||||
```
|
||||
static int __setup_rt_frame(int sig, struct ksignal *ksig,
|
||||
sigset_t *set, struct pt_regs *regs)
|
||||
{
|
||||
struct rt_sigframe __user *frame;
|
||||
void __user *fp = NULL;
|
||||
int err = 0;
|
||||
|
||||
frame = get_sigframe(&ksig->ka, regs, sizeof(struct rt_sigframe), &fp);
|
||||
......
|
||||
put_user_try {
|
||||
......
|
||||
/* Set up to return from userspace. If provided, use a stub
|
||||
already in userspace. */
|
||||
/* x86-64 should always use SA_RESTORER. */
|
||||
if (ksig->ka.sa.sa_flags & SA_RESTORER) {
|
||||
put_user_ex(ksig->ka.sa.sa_restorer, &frame->pretcode);
|
||||
}
|
||||
} put_user_catch(err);
|
||||
|
||||
err |= setup_sigcontext(&frame->uc.uc_mcontext, fp, regs, set->sig[0]);
|
||||
err |= __copy_to_user(&frame->uc.uc_sigmask, set, sizeof(*set));
|
||||
|
||||
/* Set up registers for signal handler */
|
||||
regs->di = sig;
|
||||
/* In case the signal handler was declared without prototypes */
|
||||
regs->ax = 0;
|
||||
|
||||
regs->si = (unsigned long)&frame->info;
|
||||
regs->dx = (unsigned long)&frame->uc;
|
||||
regs->ip = (unsigned long) ksig->ka.sa.sa_handler;
|
||||
|
||||
regs->sp = (unsigned long)frame;
|
||||
regs->cs = __USER_CS;
|
||||
......
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
frame的类型是rt_sigframe。frame的意思是帧。我们只有在学习栈的时候,提到过栈帧的概念。对的,这个frame就是一个栈帧。
|
||||
|
||||
我们在get_sigframe中会得到pt_regs的sp变量,也就是原来这个程序在用户态的栈顶指针,然后get_sigframe中,我们会将sp减去sizeof(struct rt_sigframe),也就是把这个栈帧塞到了栈里面,然后我们又在__setup_rt_frame中把regs->sp设置成等于frame。这就相当于强行在程序原来的用户态的栈里面插入了一个栈帧,并在最后将regs->ip设置为用户定义的信号处理函数sa_handler。这意味着,本来返回用户态应该接着原来的代码执行的,现在不了,要执行sa_handler了。那执行完了以后呢?按照函数栈的规则,弹出上一个栈帧来,也就是弹出了frame。
|
||||
|
||||
那如果我们假设sa_handler成功返回了,怎么回到程序原来在用户态运行的地方呢?玄机就在frame里面。要想恢复原来运行的地方,首先,原来的pt_regs不能丢,这个没问题,是在setup_sigcontext里面,将原来的pt_regs保存在了frame中的uc_mcontext里面。
|
||||
|
||||
另外,很重要的一点,程序如何跳过去呢?在__setup_rt_frame中,还有一个不引起重视的操作,那就是通过put_user_ex,将sa_restorer放到了frame->pretcode里面,而且还是按照函数栈的规则。函数栈里面包含了函数执行完跳回去的地址。当sa_handler执行完之后,弹出的函数栈是frame,也就应该跳到sa_restorer的地址。这是什么地址呢?
|
||||
|
||||
咱们在sigaction介绍的时候就没有介绍它,在Glibc的__libc_sigaction函数中也没有注意到,它被赋值成了restore_rt。这其实就是sa_handler执行完毕之后,马上要执行的函数。从名字我们就能感觉到,它将恢复原来程序运行的地方。
|
||||
|
||||
在Glibc中,我们可以找到它的定义,它竟然调用了一个系统调用,系统调用号为__NR_rt_sigreturn。
|
||||
|
||||
```
|
||||
RESTORE (restore_rt, __NR_rt_sigreturn)
|
||||
|
||||
#define RESTORE(name, syscall) RESTORE2 (name, syscall)
|
||||
# define RESTORE2(name, syscall) \
|
||||
asm \
|
||||
( \
|
||||
".LSTART_" #name ":\n" \
|
||||
" .type __" #name ",@function\n" \
|
||||
"__" #name ":\n" \
|
||||
" movq $" #syscall ", %rax\n" \
|
||||
" syscall\n" \
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
我们可以在内核里面找到__NR_rt_sigreturn对应的系统调用。
|
||||
|
||||
```
|
||||
asmlinkage long sys_rt_sigreturn(void)
|
||||
{
|
||||
struct pt_regs *regs = current_pt_regs();
|
||||
struct rt_sigframe __user *frame;
|
||||
sigset_t set;
|
||||
unsigned long uc_flags;
|
||||
|
||||
frame = (struct rt_sigframe __user *)(regs->sp - sizeof(long));
|
||||
if (__copy_from_user(&set, &frame->uc.uc_sigmask, sizeof(set)))
|
||||
goto badframe;
|
||||
if (__get_user(uc_flags, &frame->uc.uc_flags))
|
||||
goto badframe;
|
||||
|
||||
set_current_blocked(&set);
|
||||
|
||||
if (restore_sigcontext(regs, &frame->uc.uc_mcontext, uc_flags))
|
||||
goto badframe;
|
||||
......
|
||||
return regs->ax;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里面,我们把上次填充的那个rt_sigframe拿出来,然后restore_sigcontext将pt_regs恢复成为原来用户态的样子。从这个系统调用返回的时候,应用还误以为从上次的系统调用返回的呢。
|
||||
|
||||
至此,整个信号处理过程才全部结束。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
信号的发送与处理是一个复杂的过程,这里来总结一下。
|
||||
|
||||
1. 假设我们有一个进程A,main函数里面调用系统调用进入内核。
|
||||
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="">
|
||||
@@ -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->mnt_sb, &empty_name);
|
||||
......
|
||||
path.mnt = mntget(pipe_mnt);
|
||||
|
||||
d_instantiate(path.dentry, inode);
|
||||
|
||||
f = alloc_file(&path, FMODE_WRITE, &pipefifo_fops);
|
||||
......
|
||||
f->f_flags = O_WRONLY | (flags & (O_NONBLOCK | O_DIRECT));
|
||||
f->private_data = inode->i_pipe;
|
||||
|
||||
res[0] = alloc_file(&path, FMODE_READ, &pipefifo_fops);
|
||||
......
|
||||
path_get(&path);
|
||||
res[0]->private_data = inode->i_pipe;
|
||||
res[0]->f_flags = O_RDONLY | (flags & O_NONBLOCK);
|
||||
res[1] = f;
|
||||
return 0;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从get_pipe_inode的实现,我们可以看出,匿名管道来自一个特殊的文件系统pipefs。这个文件系统被挂载后,我们就得到了struct vfsmount *pipe_mnt。然后挂载的文件系统的superblock就变成了:pipe_mnt->mnt_sb。如果你对文件系统的操作还不熟悉,要返回去复习一下文件系统那一章啊。
|
||||
|
||||
```
|
||||
static struct file_system_type pipe_fs_type = {
|
||||
.name = "pipefs",
|
||||
.mount = pipefs_mount,
|
||||
.kill_sb = kill_anon_super,
|
||||
};
|
||||
|
||||
static int __init init_pipe_fs(void)
|
||||
{
|
||||
int err = register_filesystem(&pipe_fs_type);
|
||||
|
||||
if (!err) {
|
||||
pipe_mnt = kern_mount(&pipe_fs_type);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
static struct inode * get_pipe_inode(void)
|
||||
{
|
||||
struct inode *inode = new_inode_pseudo(pipe_mnt->mnt_sb);
|
||||
struct pipe_inode_info *pipe;
|
||||
......
|
||||
inode->i_ino = get_next_ino();
|
||||
|
||||
pipe = alloc_pipe_info();
|
||||
......
|
||||
inode->i_pipe = pipe;
|
||||
pipe->files = 2;
|
||||
pipe->readers = pipe->writers = 1;
|
||||
inode->i_fop = &pipefifo_fops;
|
||||
inode->i_state = I_DIRTY;
|
||||
inode->i_mode = S_IFIFO | S_IRUSR | S_IWUSR;
|
||||
inode->i_uid = current_fsuid();
|
||||
inode->i_gid = current_fsgid();
|
||||
inode->i_atime = inode->i_mtime = inode->i_ctime = current_time(inode);
|
||||
|
||||
return inode;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们从new_inode_pseudo函数创建一个inode。这里面开始填写Inode的成员,这里和文件系统的很像。这里值得注意的是struct pipe_inode_info,这个结构里面有个成员是struct pipe_buffer *bufs。我们可以知道,**所谓的匿名管道,其实就是内核里面的一串缓存**。
|
||||
|
||||
另外一个需要注意的是pipefifo_fops,将来我们对于文件描述符的操作,在内核里面都是对应这里面的操作。
|
||||
|
||||
```
|
||||
const struct file_operations pipefifo_fops = {
|
||||
.open = fifo_open,
|
||||
.llseek = no_llseek,
|
||||
.read_iter = pipe_read,
|
||||
.write_iter = pipe_write,
|
||||
.poll = pipe_poll,
|
||||
.unlocked_ioctl = pipe_ioctl,
|
||||
.release = pipe_release,
|
||||
.fasync = pipe_fasync,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
我们回到create_pipe_files函数,创建完了inode,还需创建一个dentry和他对应。dentry和inode对应好了,我们就要开始创建struct file对象了。先创建用于写入的,对应的操作为pipefifo_fops;再创建读取的,对应的操作也为pipefifo_fops。然后把private_data设置为pipe_inode_info。这样从struct file这个层级上,就能直接操作底层的读写操作。
|
||||
|
||||
至此,一个匿名管道就创建成功了。如果对于fd[1]写入,调用的是pipe_write,向pipe_buffer里面写入数据;如果对于fd[0]的读入,调用的是pipe_read,也就是从pipe_buffer里面读取数据。
|
||||
|
||||
但是这个时候,两个文件描述符都是在一个进程里面的,并没有起到进程间通信的作用,怎么样才能使得管道是跨两个进程的呢?还记得创建进程调用的fork吗?在这里面,创建的子进程会复制父进程的struct files_struct,在这里面fd的数组会复制一份,但是fd指向的struct file对于同一个文件还是只有一份,这样就做到了,两个进程各有两个fd指向同一个struct file的模式,两个进程就可以通过各自的fd写入和读取同一个管道文件实现跨进程通信了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/a3/9c0e38e31c7a51da12faf4a1aca10ba3.png" alt="">
|
||||
|
||||
由于管道只能一端写入,另一端读出,所以上面的这种模式会造成混乱,因为父进程和子进程都可以写入,也都可以读出,通常的方法是父进程关闭读取的fd,只保留写入的fd,而子进程关闭写入的fd,只保留读取的fd,如果需要双向通行,则应该创建两个管道。
|
||||
|
||||
一个典型的使用管道在父子进程之间的通信代码如下:
|
||||
|
||||
```
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
int fds[2];
|
||||
if (pipe(fds) == -1)
|
||||
perror("pipe error");
|
||||
|
||||
pid_t pid;
|
||||
pid = fork();
|
||||
if (pid == -1)
|
||||
perror("fork error");
|
||||
|
||||
if (pid == 0){
|
||||
close(fds[0]);
|
||||
char msg[] = "hello world";
|
||||
write(fds[1], msg, strlen(msg) + 1);
|
||||
close(fds[1]);
|
||||
exit(0);
|
||||
} else {
|
||||
close(fds[1]);
|
||||
char msg[128];
|
||||
read(fds[0], msg, 128);
|
||||
close(fds[0]);
|
||||
printf("message : %s\n", msg);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<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 <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
int fds[2];
|
||||
if (pipe(fds) == -1)
|
||||
perror("pipe error");
|
||||
|
||||
pid_t pid;
|
||||
pid = fork();
|
||||
if (pid == -1)
|
||||
perror("fork error");
|
||||
|
||||
if (pid == 0){
|
||||
dup2(fds[1], STDOUT_FILENO);
|
||||
close(fds[1]);
|
||||
close(fds[0]);
|
||||
execlp("ps", "ps", "-ef", NULL);
|
||||
} else {
|
||||
dup2(fds[0], STDIN_FILENO);
|
||||
close(fds[0]);
|
||||
close(fds[1]);
|
||||
execlp("grep", "grep", "systemd", NULL);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,我们来看命名管道。我们在讲命令的时候讲过,命名管道需要事先通过命令mkfifo,进行创建。如果是通过代码创建命名管道,也有一个函数,但是这不是一个系统调用,而是Glibc提供的函数。它的定义如下:
|
||||
|
||||
```
|
||||
int
|
||||
mkfifo (const char *path, mode_t mode)
|
||||
{
|
||||
dev_t dev = 0;
|
||||
return __xmknod (_MKNOD_VER, path, mode | S_IFIFO, &dev);
|
||||
}
|
||||
|
||||
int
|
||||
__xmknod (int vers, const char *path, mode_t mode, dev_t *dev)
|
||||
{
|
||||
unsigned long long int k_dev;
|
||||
......
|
||||
/* We must convert the value to dev_t type used by the kernel. */
|
||||
k_dev = (*dev) & ((1ULL << 32) - 1);
|
||||
......
|
||||
return INLINE_SYSCALL (mknodat, 4, AT_FDCWD, path, mode,
|
||||
(unsigned int) k_dev);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Glibc的mkfifo函数会调用mknodat系统调用,还记得咱们学字符设备的时候,创建一个字符设备的时候,也是调用的mknod。这里命名管道也是一个设备,因而我们也用mknod。
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE4(mknodat, int, dfd, const char __user *, filename, umode_t, mode, unsigned, dev)
|
||||
{
|
||||
struct dentry *dentry;
|
||||
struct path path;
|
||||
unsigned int lookup_flags = 0;
|
||||
......
|
||||
retry:
|
||||
dentry = user_path_create(dfd, filename, &path, lookup_flags);
|
||||
......
|
||||
switch (mode & S_IFMT) {
|
||||
......
|
||||
case S_IFIFO: case S_IFSOCK:
|
||||
error = vfs_mknod(path.dentry->d_inode,dentry,mode,0);
|
||||
break;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于mknod的解析,我们在字符设备那一节已经解析过了,先是通过user_path_create对于这个管道文件创建一个dentry,然后因为是S_IFIFO,所以调用vfs_mknod。由于这个管道文件是创建在一个普通文件系统上的,假设是在ext4文件上,于是vfs_mknod会调用ext4_dir_inode_operations的mknod,也即会调用ext4_mknod。
|
||||
|
||||
```
|
||||
const struct inode_operations ext4_dir_inode_operations = {
|
||||
......
|
||||
.mknod = ext4_mknod,
|
||||
......
|
||||
};
|
||||
|
||||
static int ext4_mknod(struct inode *dir, struct dentry *dentry,
|
||||
umode_t mode, dev_t rdev)
|
||||
{
|
||||
handle_t *handle;
|
||||
struct inode *inode;
|
||||
......
|
||||
inode = ext4_new_inode_start_handle(dir, mode, &dentry->d_name, 0,
|
||||
NULL, EXT4_HT_DIR, credits);
|
||||
handle = ext4_journal_current_handle();
|
||||
if (!IS_ERR(inode)) {
|
||||
init_special_inode(inode, inode->i_mode, rdev);
|
||||
inode->i_op = &ext4_special_inode_operations;
|
||||
err = ext4_add_nondir(handle, dentry, inode);
|
||||
if (!err && IS_DIRSYNC(dir))
|
||||
ext4_handle_sync(handle);
|
||||
}
|
||||
if (handle)
|
||||
ext4_journal_stop(handle);
|
||||
......
|
||||
}
|
||||
|
||||
#define ext4_new_inode_start_handle(dir, mode, qstr, goal, owner, \
|
||||
type, nblocks) \
|
||||
__ext4_new_inode(NULL, (dir), (mode), (qstr), (goal), (owner), \
|
||||
0, (type), __LINE__, (nblocks))
|
||||
|
||||
void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
|
||||
{
|
||||
inode->i_mode = mode;
|
||||
if (S_ISCHR(mode)) {
|
||||
inode->i_fop = &def_chr_fops;
|
||||
inode->i_rdev = rdev;
|
||||
} else if (S_ISBLK(mode)) {
|
||||
inode->i_fop = &def_blk_fops;
|
||||
inode->i_rdev = rdev;
|
||||
} else if (S_ISFIFO(mode))
|
||||
inode->i_fop = &pipefifo_fops;
|
||||
else if (S_ISSOCK(mode))
|
||||
; /* leave it no_open_fops */
|
||||
else
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ext4_mknod中,ext4_new_inode_start_handle会调用__ext4_new_inode,在ext4文件系统上真的创建一个文件,但是会调用init_special_inode,创建一个内存中特殊的inode,这个函数我们在字符设备文件中也遇到过,只不过当时inode的i_fop指向的是def_chr_fops,这次换成管道文件了,inode的i_fop变成指向pipefifo_fops,这一点和匿名管道是一样的。
|
||||
|
||||
这样,管道文件就创建完毕了。
|
||||
|
||||
接下来,要打开这个管道文件,我们还是会调用文件系统的open函数。还是沿着文件系统的调用方式,一路调用到pipefifo_fops的open函数,也就是fifo_open。
|
||||
|
||||
```
|
||||
static int fifo_open(struct inode *inode, struct file *filp)
|
||||
{
|
||||
struct pipe_inode_info *pipe;
|
||||
bool is_pipe = inode->i_sb->s_magic == PIPEFS_MAGIC;
|
||||
int ret;
|
||||
filp->f_version = 0;
|
||||
|
||||
if (inode->i_pipe) {
|
||||
pipe = inode->i_pipe;
|
||||
pipe->files++;
|
||||
} else {
|
||||
pipe = alloc_pipe_info();
|
||||
pipe->files = 1;
|
||||
inode->i_pipe = pipe;
|
||||
spin_unlock(&inode->i_lock);
|
||||
}
|
||||
filp->private_data = pipe;
|
||||
filp->f_mode &= (FMODE_READ | FMODE_WRITE);
|
||||
|
||||
switch (filp->f_mode) {
|
||||
case FMODE_READ:
|
||||
pipe->r_counter++;
|
||||
if (pipe->readers++ == 0)
|
||||
wake_up_partner(pipe);
|
||||
if (!is_pipe && !pipe->writers) {
|
||||
if ((filp->f_flags & O_NONBLOCK)) {
|
||||
filp->f_version = pipe->w_counter;
|
||||
} else {
|
||||
if (wait_for_partner(pipe, &pipe->w_counter))
|
||||
goto err_rd;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case FMODE_WRITE:
|
||||
pipe->w_counter++;
|
||||
if (!pipe->writers++)
|
||||
wake_up_partner(pipe);
|
||||
if (!is_pipe && !pipe->readers) {
|
||||
if (wait_for_partner(pipe, &pipe->r_counter))
|
||||
goto err_wr;
|
||||
}
|
||||
break;
|
||||
case FMODE_READ | FMODE_WRITE:
|
||||
pipe->readers++;
|
||||
pipe->writers++;
|
||||
pipe->r_counter++;
|
||||
pipe->w_counter++;
|
||||
if (pipe->readers == 1 || pipe->writers == 1)
|
||||
wake_up_partner(pipe);
|
||||
break;
|
||||
......
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在fifo_open里面,创建pipe_inode_info,这一点和匿名管道也是一样的。这个结构里面有个成员是struct pipe_buffer *bufs。我们可以知道,**所谓的命名管道,其实是也是内核里面的一串缓存。**
|
||||
|
||||
接下来,对于命名管道的写入,我们还是会调用pipefifo_fops的pipe_write函数,向pipe_buffer里面写入数据。对于命名管道的读入,我们还是会调用pipefifo_fops的pipe_read,也就是从pipe_buffer里面读取数据。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
无论是匿名管道,还是命名管道,在内核都是一个文件。只要是文件就要有一个inode。这里我们又用到了特殊inode、字符设备、块设备,其实都是这种特殊的inode。
|
||||
|
||||
在这种特殊的inode里面,file_operations指向管道特殊的pipefifo_fops,这个inode对应内存里面的缓存。
|
||||
|
||||
当我们用文件的open函数打开这个管道设备文件的时候,会调用pipefifo_fops里面的方法创建struct file结构,他的inode指向特殊的inode,也对应内存里面的缓存,file_operations也指向管道特殊的pipefifo_fops。
|
||||
|
||||
写入一个pipe就是从struct file结构找到缓存写入,读取一个pipe就是从struct file结构找到缓存读出。
|
||||
|
||||
<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="">
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/ipc.h>
|
||||
#include <sys/shm.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/sem.h>
|
||||
#include <string.h>
|
||||
|
||||
#define MAX_NUM 128
|
||||
|
||||
struct shm_data {
|
||||
int data[MAX_NUM];
|
||||
int datalength;
|
||||
};
|
||||
|
||||
union semun {
|
||||
int val;
|
||||
struct semid_ds *buf;
|
||||
unsigned short int *array;
|
||||
struct seminfo *__buf;
|
||||
};
|
||||
|
||||
int get_shmid(){
|
||||
int shmid;
|
||||
key_t key;
|
||||
|
||||
if((key = ftok("/root/sharememory/sharememorykey", 1024)) < 0){
|
||||
perror("ftok error");
|
||||
return -1;
|
||||
}
|
||||
|
||||
shmid = shmget(key, sizeof(struct shm_data), IPC_CREAT|0777);
|
||||
return shmid;
|
||||
}
|
||||
|
||||
int get_semaphoreid(){
|
||||
int semid;
|
||||
key_t key;
|
||||
|
||||
if((key = ftok("/root/sharememory/semaphorekey", 1024)) < 0){
|
||||
perror("ftok error");
|
||||
return -1;
|
||||
}
|
||||
|
||||
semid = semget(key, 1, IPC_CREAT|0777);
|
||||
return semid;
|
||||
}
|
||||
|
||||
int semaphore_init (int semid) {
|
||||
union semun argument;
|
||||
unsigned short values[1];
|
||||
values[0] = 1;
|
||||
argument.array = values;
|
||||
return semctl (semid, 0, SETALL, argument);
|
||||
}
|
||||
|
||||
int semaphore_p (int semid) {
|
||||
struct sembuf operations[1];
|
||||
operations[0].sem_num = 0;
|
||||
operations[0].sem_op = -1;
|
||||
operations[0].sem_flg = SEM_UNDO;
|
||||
return semop (semid, operations, 1);
|
||||
}
|
||||
|
||||
int semaphore_v (int semid) {
|
||||
struct sembuf operations[1];
|
||||
operations[0].sem_num = 0;
|
||||
operations[0].sem_op = 1;
|
||||
operations[0].sem_flg = SEM_UNDO;
|
||||
return semop (semid, operations, 1);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 共享内存
|
||||
|
||||
我们先来看里面对于共享内存的操作。
|
||||
|
||||
首先,创建之前,我们要有一个key来唯一标识这个共享内存。这个key可以根据文件系统上的一个文件的inode随机生成。
|
||||
|
||||
然后,我们需要创建一个共享内存,就像创建一个消息队列差不多,都是使用xxxget来创建。其中,创建共享内存使用的是下面这个函数:
|
||||
|
||||
```
|
||||
int shmget(key_t key, size_t size, int shmflag);
|
||||
|
||||
```
|
||||
|
||||
其中,key就是前面生成的那个key,shmflag如果为IPC_CREAT,就表示新创建,还可以指定读写权限0777。
|
||||
|
||||
对于共享内存,需要指定一个大小size,这个一般要申请多大呢?一个最佳实践是,我们将多个进程需要共享的数据放在一个struct里面,然后这里的size就应该是这个struct的大小。这样每一个进程得到这块内存后,只要强制将类型转换为这个struct类型,就能够访问里面的共享数据了。
|
||||
|
||||
在这里,我们定义了一个struct shm_data结构。这里面有两个成员,一个是一个整型的数组,一个是数组中元素的个数。
|
||||
|
||||
生成了共享内存以后,接下来就是将这个共享内存映射到进程的虚拟地址空间中。我们使用下面这个函数来进行操作。
|
||||
|
||||
```
|
||||
void *shmat(int shm_id, const void *addr, int shmflg);
|
||||
|
||||
```
|
||||
|
||||
这里面的shm_id,就是上面创建的共享内存的id,addr就是指定映射在某个地方。如果不指定,则内核会自动选择一个地址,作为返回值返回。得到了返回地址以后,我们需要将指针强制类型转换为struct shm_data结构,就可以使用这个指针设置data和datalength了。
|
||||
|
||||
当共享内存使用完毕,我们可以通过shmdt解除它到虚拟内存的映射。
|
||||
|
||||
```
|
||||
int shmdt(const void *shmaddr);
|
||||
|
||||
```
|
||||
|
||||
## 信号量
|
||||
|
||||
看完了共享内存,接下来我们再来看信号量。信号量以集合的形式存在的。
|
||||
|
||||
首先,创建之前,我们同样需要有一个key,来唯一标识这个信号量集合。这个key同样可以根据文件系统上的一个文件的inode随机生成。
|
||||
|
||||
然后,我们需要创建一个信号量集合,同样也是使用xxxget来创建,其中创建信号量集合使用的是下面这个函数。
|
||||
|
||||
```
|
||||
int semget(key_t key, int nsems, int semflg);
|
||||
|
||||
```
|
||||
|
||||
这里面的key,就是前面生成的那个key,shmflag如果为IPC_CREAT,就表示新创建,还可以指定读写权限0777。
|
||||
|
||||
这里,nsems表示这个信号量集合里面有几个信号量,最简单的情况下,我们设置为1。
|
||||
|
||||
信号量往往代表某种资源的数量,如果用信号量做互斥,那往往将信号量设置为1。这就是上面代码中semaphore_init函数的作用,这里面调用semctl函数,将这个信号量集合的中的第0个信号量,也即唯一的这个信号量设置为1。
|
||||
|
||||
对于信号量,往往要定义两种操作,P操作和V操作。对应上面代码中semaphore_p函数和semaphore_v函数,semaphore_p会调用semop函数将信号量的值减一,表示申请占用一个资源,当发现当前没有资源的时候,进入等待。semaphore_v会调用semop函数将信号量的值加一,表示释放一个资源,释放之后,就允许等待中的其他进程占用这个资源。
|
||||
|
||||
我们可以用这个信号量,来保护共享内存中的struct shm_data,使得同时只有一个进程可以操作这个结构。
|
||||
|
||||
你是否记得咱们讲线程同步机制的时候,构建了一个老板分配活的场景。这里我们同样构建一个场景,分为producer.c和consumer.c,其中producer也即生产者,负责往struct shm_data塞入数据,而consumer.c负责处理struct shm_data中的数据。
|
||||
|
||||
下面我们来看producer.c的代码。
|
||||
|
||||
```
|
||||
#include "share.h"
|
||||
|
||||
int main() {
|
||||
void *shm = NULL;
|
||||
struct shm_data *shared = NULL;
|
||||
int shmid = get_shmid();
|
||||
int semid = get_semaphoreid();
|
||||
int i;
|
||||
|
||||
shm = shmat(shmid, (void*)0, 0);
|
||||
if(shm == (void*)-1){
|
||||
exit(0);
|
||||
}
|
||||
shared = (struct shm_data*)shm;
|
||||
memset(shared, 0, sizeof(struct shm_data));
|
||||
semaphore_init(semid);
|
||||
while(1){
|
||||
semaphore_p(semid);
|
||||
if(shared->datalength > 0){
|
||||
semaphore_v(semid);
|
||||
sleep(1);
|
||||
} else {
|
||||
printf("how many integers to caculate : ");
|
||||
scanf("%d",&shared->datalength);
|
||||
if(shared->datalength > MAX_NUM){
|
||||
perror("too many integers.");
|
||||
shared->datalength = 0;
|
||||
semaphore_v(semid);
|
||||
exit(1);
|
||||
}
|
||||
for(i=0;i<shared->datalength;i++){
|
||||
printf("Input the %d integer : ", i);
|
||||
scanf("%d",&shared->data[i]);
|
||||
}
|
||||
semaphore_v(semid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里面,get_shmid创建了共享内存,get_semaphoreid创建了信号量集合,然后shmat将共享内存映射到了虚拟地址空间的shm指针指向的位置,然后通过强制类型转换,shared的指针指向放在共享内存里面的struct shm_data结构,然后初始化为0。semaphore_init将信号量进行了初始化。
|
||||
|
||||
接着,producer进入了一个无限循环。在这个循环里面,我们先通过semaphore_p申请访问共享内存的权利,如果发现datalength大于零,说明共享内存里面的数据没有被处理过,于是semaphore_v释放权利,先睡一会儿,睡醒了再看。如果发现datalength等于0,说明共享内存里面的数据被处理完了,于是开始往里面放数据。让用户输入多少个数,然后每个数是什么,都放在struct shm_data结构中,然后semaphore_v释放权利,等待其他的进程将这些数拿去处理。
|
||||
|
||||
我们再来看consumer的代码。
|
||||
|
||||
```
|
||||
#include "share.h"
|
||||
|
||||
int main() {
|
||||
void *shm = NULL;
|
||||
struct shm_data *shared = NULL;
|
||||
int shmid = get_shmid();
|
||||
int semid = get_semaphoreid();
|
||||
int i;
|
||||
|
||||
shm = shmat(shmid, (void*)0, 0);
|
||||
if(shm == (void*)-1){
|
||||
exit(0);
|
||||
}
|
||||
shared = (struct shm_data*)shm;
|
||||
while(1){
|
||||
semaphore_p(semid);
|
||||
if(shared->datalength > 0){
|
||||
int sum = 0;
|
||||
for(i=0;i<shared->datalength-1;i++){
|
||||
printf("%d+",shared->data[i]);
|
||||
sum += shared->data[i];
|
||||
}
|
||||
printf("%d",shared->data[shared->datalength-1]);
|
||||
sum += shared->data[shared->datalength-1];
|
||||
printf("=%d\n",sum);
|
||||
memset(shared, 0, sizeof(struct shm_data));
|
||||
semaphore_v(semid);
|
||||
} else {
|
||||
semaphore_v(semid);
|
||||
printf("no tasks, waiting.\n");
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里面,get_shmid获得producer创建的共享内存,get_semaphoreid获得producer创建的信号量集合,然后shmat将共享内存映射到了虚拟地址空间的shm指针指向的位置,然后通过强制类型转换,shared的指针指向放在共享内存里面的struct shm_data结构。
|
||||
|
||||
接着,consumer进入了一个无限循环,在这个循环里面,我们先通过semaphore_p申请访问共享内存的权利,如果发现datalength等于0,就说明没什么活干,需要等待。如果发现datalength大于0,就说明有活干,于是将datalength个整型数字从data数组中取出来求和。最后将struct shm_data清空为0,表示任务处理完毕,通过semaphore_v释放权利。
|
||||
|
||||
通过程序创建的共享内存和信号量集合,我们可以通过命令ipcs查看。当然,我们也可以通过ipcrm进行删除。
|
||||
|
||||
```
|
||||
# ipcs
|
||||
------ Message Queues --------
|
||||
key msqid owner perms used-bytes messages
|
||||
------ Shared Memory Segments --------
|
||||
key shmid owner perms bytes nattch status
|
||||
0x00016988 32768 root 777 516 0
|
||||
------ Semaphore Arrays --------
|
||||
key semid owner perms nsems
|
||||
0x00016989 32768 root 777 1
|
||||
|
||||
```
|
||||
|
||||
下面我们来运行一下producer和consumer,可以得到下面的结果:
|
||||
|
||||
```
|
||||
# ./producer
|
||||
how many integers to caculate : 2
|
||||
Input the 0 integer : 3
|
||||
Input the 1 integer : 4
|
||||
how many integers to caculate : 4
|
||||
Input the 0 integer : 3
|
||||
Input the 1 integer : 4
|
||||
Input the 2 integer : 5
|
||||
Input the 3 integer : 6
|
||||
how many integers to caculate : 7
|
||||
Input the 0 integer : 9
|
||||
Input the 1 integer : 8
|
||||
Input the 2 integer : 7
|
||||
Input the 3 integer : 6
|
||||
Input the 4 integer : 5
|
||||
Input the 5 integer : 4
|
||||
Input the 6 integer : 3
|
||||
|
||||
# ./consumer
|
||||
3+4=7
|
||||
3+4+5+6=18
|
||||
9+8+7+6+5+4+3=42
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节的内容差不多了,我们来总结一下。共享内存和信号量的配合机制,如下图所示:
|
||||
|
||||
- 无论是共享内存还是信号量,创建与初始化都遵循同样流程,通过ftok得到key,通过xxxget创建对象并生成id;
|
||||
- 生产者和消费者都通过shmat将共享内存映射到各自的内存空间,在不同的进程里面映射的位置不同;
|
||||
- 为了访问共享内存,需要信号量进行保护,信号量需要通过semctl初始化为某个值;
|
||||
- 接下来生产者和消费者要通过semop(-1)来竞争信号量,如果生产者抢到信号量则写入,然后通过semop(+1)释放信号量,如果消费者抢到信号量则读出,然后通过semop(+1)释放信号量;
|
||||
- 共享内存使用完毕,可以通过shmdt来解除映射。
|
||||
|
||||
<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="">
|
||||
@@ -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)->ids[IPC_SEM_IDS])
|
||||
#define msg_ids(ns) ((ns)->ids[IPC_MSG_IDS])
|
||||
#define shm_ids(ns) ((ns)->ids[IPC_SHM_IDS])
|
||||
|
||||
```
|
||||
|
||||
根据代码中的定义,第0项用于信号量,第1项用于消息队列,第2项用于共享内存,分别可以通过sem_ids、msg_ids、shm_ids来访问。
|
||||
|
||||
这段代码里面有ns,全称叫namespace。可能不容易理解,你现在可以将它认为是将一台Linux服务器逻辑的隔离为多台Linux服务器的机制,它背后的原理是一个相当大的话题,我们需要在容器那一章详细讲述。现在,你就可以简单的认为没有namespace,整个Linux在一个namespace下面,那这些ids也是整个Linux只有一份。
|
||||
|
||||
接下来,我们再来看struct ipc_ids里面保存了什么。
|
||||
|
||||
首先,in_use表示当前有多少个ipc;其次,seq和next_id用于一起生成ipc唯一的id,因为信号量,共享内存,消息队列,它们三个的id也不能重复;ipcs_idr是一棵基数树,我们又碰到它了,一旦涉及从一个整数查找一个对象,它都是最好的选择。
|
||||
|
||||
```
|
||||
struct ipc_ids {
|
||||
int in_use;
|
||||
unsigned short seq;
|
||||
struct rw_semaphore rwsem;
|
||||
struct idr ipcs_idr;
|
||||
int next_id;
|
||||
};
|
||||
|
||||
struct idr {
|
||||
struct radix_tree_root idr_rt;
|
||||
unsigned int idr_next;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
也就是说,对于sem_ids、msg_ids、shm_ids各有一棵基数树。那这棵树里面究竟存放了什么,能够统一管理这三类ipc对象呢?
|
||||
|
||||
通过下面这个函数ipc_obtain_object_idr,我们可以看出端倪。这个函数根据id,在基数树里面找出来的是struct kern_ipc_perm。
|
||||
|
||||
```
|
||||
struct kern_ipc_perm *ipc_obtain_object_idr(struct ipc_ids *ids, int id)
|
||||
{
|
||||
struct kern_ipc_perm *out;
|
||||
int lid = ipcid_to_idx(id);
|
||||
out = idr_find(&ids->ipcs_idr, lid);
|
||||
return out;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果我们看用于表示信号量、消息队列、共享内存的结构,就会发现,这三个结构的第一项都是struct kern_ipc_perm。
|
||||
|
||||
```
|
||||
struct sem_array {
|
||||
struct kern_ipc_perm sem_perm; /* permissions .. see ipc.h */
|
||||
time_t sem_ctime; /* create/last semctl() time */
|
||||
struct list_head pending_alter; /* pending operations */
|
||||
/* that alter the array */
|
||||
struct list_head pending_const; /* pending complex operations */
|
||||
/* that do not alter semvals */
|
||||
struct list_head list_id; /* undo requests on this array */
|
||||
int sem_nsems; /* no. of semaphores in array */
|
||||
int complex_count; /* pending complex operations */
|
||||
unsigned int use_global_lock;/* >0: global lock required */
|
||||
|
||||
struct sem sems[];
|
||||
} __randomize_layout;
|
||||
|
||||
struct msg_queue {
|
||||
struct kern_ipc_perm q_perm;
|
||||
time_t q_stime; /* last msgsnd time */
|
||||
time_t q_rtime; /* last msgrcv time */
|
||||
time_t q_ctime; /* last change time */
|
||||
unsigned long q_cbytes; /* current number of bytes on queue */
|
||||
unsigned long q_qnum; /* number of messages in queue */
|
||||
unsigned long q_qbytes; /* max number of bytes on queue */
|
||||
pid_t q_lspid; /* pid of last msgsnd */
|
||||
pid_t q_lrpid; /* last receive pid */
|
||||
|
||||
struct list_head q_messages;
|
||||
struct list_head q_receivers;
|
||||
struct list_head q_senders;
|
||||
} __randomize_layout;
|
||||
|
||||
struct shmid_kernel /* private to the kernel */
|
||||
{
|
||||
struct kern_ipc_perm shm_perm;
|
||||
struct file *shm_file;
|
||||
unsigned long shm_nattch;
|
||||
unsigned long shm_segsz;
|
||||
time_t shm_atim;
|
||||
time_t shm_dtim;
|
||||
time_t shm_ctim;
|
||||
pid_t shm_cprid;
|
||||
pid_t shm_lprid;
|
||||
struct user_struct *mlock_user;
|
||||
|
||||
/* The task created the shm object. NULL if the task is dead. */
|
||||
struct task_struct *shm_creator;
|
||||
struct list_head shm_clist; /* list by creator */
|
||||
} __randomize_layout;
|
||||
|
||||
```
|
||||
|
||||
也就是说,我们完全可以通过struct kern_ipc_perm的指针,通过进行强制类型转换后,得到整个结构。做这件事情的函数如下:
|
||||
|
||||
```
|
||||
static inline struct sem_array *sem_obtain_object(struct ipc_namespace *ns, int id)
|
||||
{
|
||||
struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&sem_ids(ns), id);
|
||||
return container_of(ipcp, struct sem_array, sem_perm);
|
||||
}
|
||||
|
||||
static inline struct msg_queue *msq_obtain_object(struct ipc_namespace *ns, int id)
|
||||
{
|
||||
struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&msg_ids(ns), id);
|
||||
return container_of(ipcp, struct msg_queue, q_perm);
|
||||
}
|
||||
|
||||
static inline struct shmid_kernel *shm_obtain_object(struct ipc_namespace *ns, int id)
|
||||
{
|
||||
struct kern_ipc_perm *ipcp = ipc_obtain_object_idr(&shm_ids(ns), id);
|
||||
return container_of(ipcp, struct shmid_kernel, shm_perm);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过这种机制,我们就可以将信号量、消息队列、共享内存抽象为ipc类型进行统一处理。你有没有觉得,这有点儿面向对象编程中抽象类和实现类的意思?没错,如果你试图去了解C++中类的实现机制,其实也是这么干的。
|
||||
|
||||
<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->nsproxy->ipc_ns;
|
||||
shm_params.key = key;
|
||||
shm_params.flg = shmflg;
|
||||
shm_params.u.size = size;
|
||||
return ipcget(ns, &shm_ids(ns), &shm_ops, &shm_params);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面调用了抽象的ipcget、参数分别为共享内存对应的shm_ids、对应的操作shm_ops以及对应的参数shm_params。
|
||||
|
||||
如果key设置为IPC_PRIVATE则永远创建新的,如果不是的话,就会调用ipcget_public。ipcget的具体代码如下:
|
||||
|
||||
```
|
||||
int ipcget(struct ipc_namespace *ns, struct ipc_ids *ids,
|
||||
const struct ipc_ops *ops, struct ipc_params *params)
|
||||
{
|
||||
if (params->key == IPC_PRIVATE)
|
||||
return ipcget_new(ns, ids, ops, params);
|
||||
else
|
||||
return ipcget_public(ns, ids, ops, params);
|
||||
}
|
||||
|
||||
static int ipcget_public(struct ipc_namespace *ns, struct ipc_ids *ids, const struct ipc_ops *ops, struct ipc_params *params)
|
||||
{
|
||||
struct kern_ipc_perm *ipcp;
|
||||
int flg = params->flg;
|
||||
int err;
|
||||
ipcp = ipc_findkey(ids, params->key);
|
||||
if (ipcp == NULL) {
|
||||
if (!(flg & IPC_CREAT))
|
||||
err = -ENOENT;
|
||||
else
|
||||
err = ops->getnew(ns, params);
|
||||
} else {
|
||||
if (flg & IPC_CREAT && flg & IPC_EXCL)
|
||||
err = -EEXIST;
|
||||
else {
|
||||
err = 0;
|
||||
if (ops->more_checks)
|
||||
err = ops->more_checks(ipcp, params);
|
||||
......
|
||||
}
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ipcget_public中,我们会按照key,去查找struct kern_ipc_perm。如果没有找到,那就看是否设置了IPC_CREAT;如果设置了,就创建一个新的。如果找到了,就将对应的id返回。
|
||||
|
||||
我们这里重点看,如何按照参数shm_ops,创建新的共享内存,会调用newseg。
|
||||
|
||||
```
|
||||
static int newseg(struct ipc_namespace *ns, struct ipc_params *params)
|
||||
{
|
||||
key_t key = params->key;
|
||||
int shmflg = params->flg;
|
||||
size_t size = params->u.size;
|
||||
int error;
|
||||
struct shmid_kernel *shp;
|
||||
size_t numpages = (size + PAGE_SIZE - 1) >> PAGE_SHIFT;
|
||||
struct file *file;
|
||||
char name[13];
|
||||
vm_flags_t acctflag = 0;
|
||||
......
|
||||
shp = kvmalloc(sizeof(*shp), GFP_KERNEL);
|
||||
......
|
||||
shp->shm_perm.key = key;
|
||||
shp->shm_perm.mode = (shmflg & S_IRWXUGO);
|
||||
shp->mlock_user = NULL;
|
||||
|
||||
shp->shm_perm.security = NULL;
|
||||
......
|
||||
file = shmem_kernel_file_setup(name, size, acctflag);
|
||||
......
|
||||
shp->shm_cprid = task_tgid_vnr(current);
|
||||
shp->shm_lprid = 0;
|
||||
shp->shm_atim = shp->shm_dtim = 0;
|
||||
shp->shm_ctim = get_seconds();
|
||||
shp->shm_segsz = size;
|
||||
shp->shm_nattch = 0;
|
||||
shp->shm_file = file;
|
||||
shp->shm_creator = current;
|
||||
|
||||
error = ipc_addid(&shm_ids(ns), &shp->shm_perm, ns->shm_ctlmni);
|
||||
......
|
||||
list_add(&shp->shm_clist, &current->sysvshm.shm_clist);
|
||||
......
|
||||
file_inode(file)->i_ino = shp->shm_perm.id;
|
||||
|
||||
ns->shm_tot += numpages;
|
||||
error = shp->shm_perm.id;
|
||||
......
|
||||
return error;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**newseg函数的第一步,通过kvmalloc在直接映射区分配一个struct shmid_kernel结构。**这个结构就是用来描述共享内存的。这个结构最开始就是上面说的struct kern_ipc_perm结构。接下来就是填充这个struct shmid_kernel结构,例如key、权限等。
|
||||
|
||||
**newseg函数的第二步,共享内存需要和文件进行关联**。**为什么要做这个呢?我们在讲内存映射的时候讲过,虚拟地址空间可以和物理内存关联,但是物理内存是某个进程独享的。虚拟地址空间也可以映射到一个文件,文件是可以跨进程共享的。
|
||||
|
||||
咱们这里的共享内存需要跨进程共享,也应该借鉴文件映射的思路。只不过不应该映射一个硬盘上的文件,而是映射到一个内存文件系统上的文件。mm/shmem.c里面就定义了这样一个基于内存的文件系统。这里你一定要注意区分shmem和shm的区别,前者是一个文件系统,后者是进程通信机制。
|
||||
|
||||
在系统初始化的时候,shmem_init注册了shmem文件系统shmem_fs_type,并且挂在到了shm_mnt下面。
|
||||
|
||||
```
|
||||
int __init shmem_init(void)
|
||||
{
|
||||
int error;
|
||||
error = shmem_init_inodecache();
|
||||
error = register_filesystem(&shmem_fs_type);
|
||||
shm_mnt = kern_mount(&shmem_fs_type);
|
||||
......
|
||||
return 0;
|
||||
}
|
||||
|
||||
static struct file_system_type shmem_fs_type = {
|
||||
.owner = THIS_MODULE,
|
||||
.name = "tmpfs",
|
||||
.mount = shmem_mount,
|
||||
.kill_sb = kill_litter_super,
|
||||
.fs_flags = FS_USERNS_MOUNT,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
接下来,newseg函数会调用shmem_kernel_file_setup,其实就是在shmem文件系统里面创建一个文件。
|
||||
|
||||
```
|
||||
/**
|
||||
* shmem_kernel_file_setup - get an unlinked file living in tmpfs which must be kernel internal.
|
||||
* @name: name for dentry (to be seen in /proc/<pid>/maps
|
||||
* @size: size to be set for the file
|
||||
* @flags: VM_NORESERVE suppresses pre-accounting of the entire object size */
|
||||
struct file *shmem_kernel_file_setup(const char *name, loff_t size, unsigned long flags)
|
||||
{
|
||||
return __shmem_file_setup(name, size, flags, S_PRIVATE);
|
||||
}
|
||||
|
||||
static struct file *__shmem_file_setup(const char *name, loff_t size,
|
||||
unsigned long flags, unsigned int i_flags)
|
||||
{
|
||||
struct file *res;
|
||||
struct inode *inode;
|
||||
struct path path;
|
||||
struct super_block *sb;
|
||||
struct qstr this;
|
||||
......
|
||||
this.name = name;
|
||||
this.len = strlen(name);
|
||||
this.hash = 0; /* will go */
|
||||
sb = shm_mnt->mnt_sb;
|
||||
path.mnt = mntget(shm_mnt);
|
||||
path.dentry = d_alloc_pseudo(sb, &this);
|
||||
d_set_d_op(path.dentry, &anon_ops);
|
||||
......
|
||||
inode = shmem_get_inode(sb, NULL, S_IFREG | S_IRWXUGO, 0, flags);
|
||||
inode->i_flags |= i_flags;
|
||||
d_instantiate(path.dentry, inode);
|
||||
inode->i_size = size;
|
||||
......
|
||||
res = alloc_file(&path, FMODE_WRITE | FMODE_READ,
|
||||
&shmem_file_operations);
|
||||
return res;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
__shmem_file_setup会创建新的shmem文件对应的dentry和inode,并将它们两个关联起来,然后分配一个struct file结构,来表示新的shmem文件,并且指向独特的shmem_file_operations。
|
||||
|
||||
```
|
||||
static const struct file_operations shmem_file_operations = {
|
||||
.mmap = shmem_mmap,
|
||||
.get_unmapped_area = shmem_get_unmapped_area,
|
||||
#ifdef CONFIG_TMPFS
|
||||
.llseek = shmem_file_llseek,
|
||||
.read_iter = shmem_file_read_iter,
|
||||
.write_iter = generic_file_write_iter,
|
||||
.fsync = noop_fsync,
|
||||
.splice_read = generic_file_splice_read,
|
||||
.splice_write = iter_file_splice_write,
|
||||
.fallocate = shmem_fallocate,
|
||||
#endif
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
**newseg函数的第三步,通过ipc_addid将新创建的struct shmid_kernel结构挂到shm_ids里面的基数树上,并返回相应的id,并且将struct shmid_kernel挂到当前进程的sysvshm队列中。**
|
||||
|
||||
至此,共享内存的创建就完成了。
|
||||
|
||||
## 如何将共享内存映射到虚拟地址空间?
|
||||
|
||||
从上面的代码解析中,我们知道,共享内存的数据结构struct shmid_kernel,是通过它的成员struct file *shm_file,来管理内存文件系统shmem上的内存文件的。无论这个共享内存是否被映射,shm_file都是存在的。
|
||||
|
||||
接下来,我们要将共享内存映射到虚拟地址空间中。调用的是shmat,对应的系统调用如下:
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE3(shmat, int, shmid, char __user *, shmaddr, int, shmflg)
|
||||
{
|
||||
unsigned long ret;
|
||||
long err;
|
||||
err = do_shmat(shmid, shmaddr, shmflg, &ret, SHMLBA);
|
||||
force_successful_syscall_return();
|
||||
return (long)ret;
|
||||
}
|
||||
|
||||
long do_shmat(int shmid, char __user *shmaddr, int shmflg,
|
||||
ulong *raddr, unsigned long shmlba)
|
||||
{
|
||||
struct shmid_kernel *shp;
|
||||
unsigned long addr = (unsigned long)shmaddr;
|
||||
unsigned long size;
|
||||
struct file *file;
|
||||
int err;
|
||||
unsigned long flags = MAP_SHARED;
|
||||
unsigned long prot;
|
||||
int acc_mode;
|
||||
struct ipc_namespace *ns;
|
||||
struct shm_file_data *sfd;
|
||||
struct path path;
|
||||
fmode_t f_mode;
|
||||
unsigned long populate = 0;
|
||||
......
|
||||
prot = PROT_READ | PROT_WRITE;
|
||||
acc_mode = S_IRUGO | S_IWUGO;
|
||||
f_mode = FMODE_READ | FMODE_WRITE;
|
||||
......
|
||||
ns = current->nsproxy->ipc_ns;
|
||||
shp = shm_obtain_object_check(ns, shmid);
|
||||
......
|
||||
path = shp->shm_file->f_path;
|
||||
path_get(&path);
|
||||
shp->shm_nattch++;
|
||||
size = i_size_read(d_inode(path.dentry));
|
||||
......
|
||||
sfd = kzalloc(sizeof(*sfd), GFP_KERNEL);
|
||||
......
|
||||
file = alloc_file(&path, f_mode,
|
||||
is_file_hugepages(shp->shm_file) ?
|
||||
&shm_file_operations_huge :
|
||||
&shm_file_operations);
|
||||
......
|
||||
file->private_data = sfd;
|
||||
file->f_mapping = shp->shm_file->f_mapping;
|
||||
sfd->id = shp->shm_perm.id;
|
||||
sfd->ns = get_ipc_ns(ns);
|
||||
sfd->file = shp->shm_file;
|
||||
sfd->vm_ops = NULL;
|
||||
......
|
||||
addr = do_mmap_pgoff(file, addr, size, prot, flags, 0, &populate, NULL);
|
||||
*raddr = addr;
|
||||
err = 0;
|
||||
......
|
||||
return err;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个函数里面,shm_obtain_object_check会通过共享内存的id,在基数树中找到对应的struct shmid_kernel结构,通过它找到shmem上的内存文件。
|
||||
|
||||
接下来,我们要分配一个struct shm_file_data,来表示这个内存文件。将shmem中指向内存文件的shm_file赋值给struct shm_file_data中的file成员。
|
||||
|
||||
然后,我们创建了一个struct file,指向的也是shmem中的内存文件。
|
||||
|
||||
为什么要再创建一个呢?这两个的功能不同,shmem中shm_file用于管理内存文件,是一个中立的,独立于任何一个进程的角色。而新创建的struct file是专门用于做内存映射的,就像咱们在讲内存映射那一节讲过的,一个硬盘上的文件要映射到虚拟地址空间中的时候,需要在vm_area_struct里面有一个struct file *vm_file指向硬盘上的文件,现在变成内存文件了,但是这个结构还是不能少。
|
||||
|
||||
新创建的struct file的private_data,指向struct shm_file_data,这样内存映射那部分的数据结构,就能够通过它来访问内存文件了。
|
||||
|
||||
新创建的struct file的file_operations也发生了变化,变成了shm_file_operations。
|
||||
|
||||
```
|
||||
static const struct file_operations shm_file_operations = {
|
||||
.mmap = shm_mmap,
|
||||
.fsync = shm_fsync,
|
||||
.release = shm_release,
|
||||
.get_unmapped_area = shm_get_unmapped_area,
|
||||
.llseek = noop_llseek,
|
||||
.fallocate = shm_fallocate,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
接下来,do_mmap_pgoff函数我们遇到过,原来映射硬盘上的文件的时候,也是调用它。这里我们不再详细解析了。它会分配一个vm_area_struct指向虚拟地址空间中没有分配的区域,它的vm_file指向这个内存文件,然后它会调用shm_file_operations的mmap函数,也即shm_mmap进行映射。
|
||||
|
||||
```
|
||||
static int shm_mmap(struct file *file, struct vm_area_struct *vma)
|
||||
{
|
||||
struct shm_file_data *sfd = shm_file_data(file);
|
||||
int ret;
|
||||
ret = __shm_open(vma);
|
||||
ret = call_mmap(sfd->file, vma);
|
||||
sfd->vm_ops = vma->vm_ops;
|
||||
vma->vm_ops = &shm_vm_ops;
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
shm_mmap中调用了shm_file_data中的file的mmap函数,这次调用的是shmem_file_operations的mmap,也即shmem_mmap。
|
||||
|
||||
```
|
||||
static int shmem_mmap(struct file *file, struct vm_area_struct *vma)
|
||||
{
|
||||
file_accessed(file);
|
||||
vma->vm_ops = &shmem_vm_ops;
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面,vm_area_struct的vm_ops指向shmem_vm_ops。等从call_mmap中返回之后,shm_file_data的vm_ops指向了shmem_vm_ops,而vm_area_struct的vm_ops改为指向shm_vm_ops。
|
||||
|
||||
我们来看一下,shm_vm_ops和shmem_vm_ops的定义。
|
||||
|
||||
```
|
||||
static const struct vm_operations_struct shm_vm_ops = {
|
||||
.open = shm_open, /* callback for a new vm-area open */
|
||||
.close = shm_close, /* callback for when the vm-area is released */
|
||||
.fault = shm_fault,
|
||||
};
|
||||
|
||||
static const struct vm_operations_struct shmem_vm_ops = {
|
||||
.fault = shmem_fault,
|
||||
.map_pages = filemap_map_pages,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
它们里面最关键的就是fault函数,也即访问虚拟内存的时候,访问不到应该怎么办。
|
||||
|
||||
当访问不到的时候,先调用vm_area_struct的vm_ops,也即shm_vm_ops的fault函数shm_fault。然后它会转而调用shm_file_data的vm_ops,也即shmem_vm_ops的fault函数shmem_fault。
|
||||
|
||||
```
|
||||
static int shm_fault(struct vm_fault *vmf)
|
||||
{
|
||||
struct file *file = vmf->vma->vm_file;
|
||||
struct shm_file_data *sfd = shm_file_data(file);
|
||||
return sfd->vm_ops->fault(vmf);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
虽然基于内存的文件系统,已经为这个内存文件分配了inode,但是内存也却是一点儿都没分配,只有在发生缺页异常的时候才进行分配。
|
||||
|
||||
```
|
||||
static int shmem_fault(struct vm_fault *vmf)
|
||||
{
|
||||
struct vm_area_struct *vma = vmf->vma;
|
||||
struct inode *inode = file_inode(vma->vm_file);
|
||||
gfp_t gfp = mapping_gfp_mask(inode->i_mapping);
|
||||
......
|
||||
error = shmem_getpage_gfp(inode, vmf->pgoff, &vmf->page, sgp,
|
||||
gfp, vma, vmf, &ret);
|
||||
......
|
||||
}
|
||||
|
||||
/*
|
||||
* shmem_getpage_gfp - find page in cache, or get from swap, or allocate
|
||||
*
|
||||
* If we allocate a new one we do not mark it dirty. That's up to the
|
||||
* vm. If we swap it in we mark it dirty since we also free the swap
|
||||
* entry since a page cannot live in both the swap and page cache.
|
||||
*
|
||||
* fault_mm and fault_type are only supplied by shmem_fault:
|
||||
* otherwise they are NULL.
|
||||
*/
|
||||
static int shmem_getpage_gfp(struct inode *inode, pgoff_t index,
|
||||
struct page **pagep, enum sgp_type sgp, gfp_t gfp,
|
||||
struct vm_area_struct *vma, struct vm_fault *vmf, int *fault_type)
|
||||
{
|
||||
......
|
||||
page = shmem_alloc_and_acct_page(gfp, info, sbinfo,
|
||||
index, false);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
shmem_fault会调用shmem_getpage_gfp在page cache和swap中找一个空闲页,如果找不到就通过shmem_alloc_and_acct_page分配一个新的页,他最终会调用内存管理系统的alloc_page_vma在物理内存中分配一个页。
|
||||
|
||||
至此,共享内存才真的映射到了虚拟地址空间中,进程可以像访问本地内存一样访问共享内存。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
我们来总结一下共享内存的创建和映射过程。
|
||||
|
||||
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的结构没有解析,你可以试着解析一下。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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->nsproxy->ipc_ns;
|
||||
sem_params.key = key;
|
||||
sem_params.flg = semflg;
|
||||
sem_params.u.nsems = nsems;
|
||||
return ipcget(ns, &sem_ids(ns), &sem_ops, &sem_params);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们解析过了共享内存,再看信号量,就顺畅很多了。这里同样调用了抽象的ipcget,参数分别为信号量对应的sem_ids、对应的操作sem_ops以及对应的参数sem_params。
|
||||
|
||||
ipcget的代码我们已经解析过了。如果key设置为IPC_PRIVATE则永远创建新的;如果不是的话,就会调用ipcget_public。
|
||||
|
||||
在ipcget_public中,我们能会按照key,去查找struct kern_ipc_perm。如果没有找到,那就看看是否设置了IPC_CREAT。如果设置了,就创建一个新的。如果找到了,就将对应的id返回。
|
||||
|
||||
我们这里重点看,如何按照参数sem_ops,创建新的信号量会调用newary。
|
||||
|
||||
```
|
||||
static int newary(struct ipc_namespace *ns, struct ipc_params *params)
|
||||
{
|
||||
int retval;
|
||||
struct sem_array *sma;
|
||||
key_t key = params->key;
|
||||
int nsems = params->u.nsems;
|
||||
int semflg = params->flg;
|
||||
int i;
|
||||
......
|
||||
sma = sem_alloc(nsems);
|
||||
......
|
||||
sma->sem_perm.mode = (semflg & S_IRWXUGO);
|
||||
sma->sem_perm.key = key;
|
||||
sma->sem_perm.security = NULL;
|
||||
......
|
||||
for (i = 0; i < nsems; i++) {
|
||||
INIT_LIST_HEAD(&sma->sems[i].pending_alter);
|
||||
INIT_LIST_HEAD(&sma->sems[i].pending_const);
|
||||
spin_lock_init(&sma->sems[i].lock);
|
||||
}
|
||||
sma->complex_count = 0;
|
||||
sma->use_global_lock = USE_GLOBAL_LOCK_HYSTERESIS;
|
||||
INIT_LIST_HEAD(&sma->pending_alter);
|
||||
INIT_LIST_HEAD(&sma->pending_const);
|
||||
INIT_LIST_HEAD(&sma->list_id);
|
||||
sma->sem_nsems = nsems;
|
||||
sma->sem_ctime = get_seconds();
|
||||
retval = ipc_addid(&sem_ids(ns), &sma->sem_perm, ns->sc_semmni);
|
||||
......
|
||||
ns->used_sems += nsems;
|
||||
......
|
||||
return sma->sem_perm.id;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
newary函数的第一步,通过kvmalloc在直接映射区分配一个struct sem_array结构。这个结构是用来描述信号量的,这个结构最开始就是上面说的struct kern_ipc_perm结构。接下来就是填充这个struct sem_array结构,例如key、权限等。
|
||||
|
||||
struct sem_array里有多个信号量,放在struct sem sems[]数组里面,在struct sem里面有当前的信号量的数值semval。
|
||||
|
||||
```
|
||||
struct sem {
|
||||
int semval; /* current value */
|
||||
/*
|
||||
* PID of the process that last modified the semaphore. For
|
||||
* Linux, specifically these are:
|
||||
* - semop
|
||||
* - semctl, via SETVAL and SETALL.
|
||||
* - at task exit when performing undo adjustments (see exit_sem).
|
||||
*/
|
||||
int sempid;
|
||||
spinlock_t lock; /* spinlock for fine-grained semtimedop */
|
||||
struct list_head pending_alter; /* pending single-sop operations that alter the semaphore */
|
||||
struct list_head pending_const; /* pending single-sop operations that do not alter the semaphore*/
|
||||
time_t sem_otime; /* candidate for sem_otime */
|
||||
} ____cacheline_aligned_in_smp;
|
||||
|
||||
```
|
||||
|
||||
struct sem_array和struct sem各有一个链表struct list_head pending_alter,分别表示对于整个信号量数组的修改和对于某个信号量的修改。
|
||||
|
||||
newary函数的第二步,就是初始化这些链表。
|
||||
|
||||
newary函数的第三步,通过ipc_addid将新创建的struct sem_array结构,挂到sem_ids里面的基数树上,并返回相应的id。
|
||||
|
||||
信号量创建的过程到此结束,接下来我们来看,如何通过semctl对信号量数组进行初始化。
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE4(semctl, int, semid, int, semnum, int, cmd, unsigned long, arg)
|
||||
{
|
||||
int version;
|
||||
struct ipc_namespace *ns;
|
||||
void __user *p = (void __user *)arg;
|
||||
ns = current->nsproxy->ipc_ns;
|
||||
switch (cmd) {
|
||||
case IPC_INFO:
|
||||
case SEM_INFO:
|
||||
case IPC_STAT:
|
||||
case SEM_STAT:
|
||||
return semctl_nolock(ns, semid, cmd, version, p);
|
||||
case GETALL:
|
||||
case GETVAL:
|
||||
case GETPID:
|
||||
case GETNCNT:
|
||||
case GETZCNT:
|
||||
case SETALL:
|
||||
return semctl_main(ns, semid, semnum, cmd, p);
|
||||
case SETVAL:
|
||||
return semctl_setval(ns, semid, semnum, arg);
|
||||
case IPC_RMID:
|
||||
case IPC_SET:
|
||||
return semctl_down(ns, semid, cmd, version, p);
|
||||
default:
|
||||
return -EINVAL;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里我们重点看,SETALL操作调用的semctl_main函数,以及SETVAL操作调用的semctl_setval函数。
|
||||
|
||||
对于SETALL操作来讲,传进来的参数为union semun里面的unsigned short *array,会设置整个信号量集合。
|
||||
|
||||
```
|
||||
static int semctl_main(struct ipc_namespace *ns, int semid, int semnum,
|
||||
int cmd, void __user *p)
|
||||
{
|
||||
struct sem_array *sma;
|
||||
struct sem *curr;
|
||||
int err, nsems;
|
||||
ushort fast_sem_io[SEMMSL_FAST];
|
||||
ushort *sem_io = fast_sem_io;
|
||||
DEFINE_WAKE_Q(wake_q);
|
||||
sma = sem_obtain_object_check(ns, semid);
|
||||
nsems = sma->sem_nsems;
|
||||
......
|
||||
switch (cmd) {
|
||||
......
|
||||
case SETALL:
|
||||
{
|
||||
int i;
|
||||
struct sem_undo *un;
|
||||
......
|
||||
if (copy_from_user(sem_io, p, nsems*sizeof(ushort))) {
|
||||
......
|
||||
}
|
||||
......
|
||||
for (i = 0; i < nsems; i++) {
|
||||
sma->sems[i].semval = sem_io[i];
|
||||
sma->sems[i].sempid = task_tgid_vnr(current);
|
||||
}
|
||||
......
|
||||
sma->sem_ctime = get_seconds();
|
||||
/* maybe some queued-up processes were waiting for this */
|
||||
do_smart_update(sma, NULL, 0, 0, &wake_q);
|
||||
err = 0;
|
||||
goto out_unlock;
|
||||
}
|
||||
}
|
||||
......
|
||||
wake_up_q(&wake_q);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在semctl_main函数中,先是通过sem_obtain_object_check,根据信号量集合的id在基数树里面找到struct sem_array对象,发现如果是SETALL操作,就将用户的参数中的unsigned short *array通过copy_from_user拷贝到内核里面的sem_io数组,然后是一个循环,对于信号量集合里面的每一个信号量,设置semval,以及修改这个信号量值的pid。
|
||||
|
||||
对于SETVAL操作来讲,传进来的参数union semun里面的int val,仅仅会设置某个信号量。
|
||||
|
||||
```
|
||||
static int semctl_setval(struct ipc_namespace *ns, int semid, int semnum,
|
||||
unsigned long arg)
|
||||
{
|
||||
struct sem_undo *un;
|
||||
struct sem_array *sma;
|
||||
struct sem *curr;
|
||||
int err, val;
|
||||
DEFINE_WAKE_Q(wake_q);
|
||||
......
|
||||
sma = sem_obtain_object_check(ns, semid);
|
||||
......
|
||||
curr = &sma->sems[semnum];
|
||||
......
|
||||
curr->semval = val;
|
||||
curr->sempid = task_tgid_vnr(current);
|
||||
sma->sem_ctime = get_seconds();
|
||||
/* maybe some queued-up processes were waiting for this */
|
||||
do_smart_update(sma, NULL, 0, 0, &wake_q);
|
||||
......
|
||||
wake_up_q(&wake_q);
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在semctl_setval函数中,我们先是通过sem_obtain_object_check,根据信号量集合的id在基数树里面找到struct sem_array对象,对于SETVAL操作,直接根据参数中的val设置semval,以及修改这个信号量值的pid。
|
||||
|
||||
至此,信号量数组初始化完毕。接下来我们来看P操作和V操作。无论是P操作,还是V操作都是调用semop系统调用。
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE3(semop, int, semid, struct sembuf __user *, tsops,
|
||||
unsigned, nsops)
|
||||
{
|
||||
return sys_semtimedop(semid, tsops, nsops, NULL);
|
||||
}
|
||||
|
||||
SYSCALL_DEFINE4(semtimedop, int, semid, struct sembuf __user *, tsops,
|
||||
unsigned, nsops, const struct timespec __user *, timeout)
|
||||
{
|
||||
int error = -EINVAL;
|
||||
struct sem_array *sma;
|
||||
struct sembuf fast_sops[SEMOPM_FAST];
|
||||
struct sembuf *sops = fast_sops, *sop;
|
||||
struct sem_undo *un;
|
||||
int max, locknum;
|
||||
bool undos = false, alter = false, dupsop = false;
|
||||
struct sem_queue queue;
|
||||
unsigned long dup = 0, jiffies_left = 0;
|
||||
struct ipc_namespace *ns;
|
||||
|
||||
ns = current->nsproxy->ipc_ns;
|
||||
......
|
||||
if (copy_from_user(sops, tsops, nsops * sizeof(*tsops))) {
|
||||
error = -EFAULT;
|
||||
goto out_free;
|
||||
}
|
||||
|
||||
if (timeout) {
|
||||
struct timespec _timeout;
|
||||
if (copy_from_user(&_timeout, timeout, sizeof(*timeout))) {
|
||||
}
|
||||
jiffies_left = timespec_to_jiffies(&_timeout);
|
||||
}
|
||||
......
|
||||
/* On success, find_alloc_undo takes the rcu_read_lock */
|
||||
un = find_alloc_undo(ns, semid);
|
||||
......
|
||||
sma = sem_obtain_object_check(ns, semid);
|
||||
......
|
||||
queue.sops = sops;
|
||||
queue.nsops = nsops;
|
||||
queue.undo = un;
|
||||
queue.pid = task_tgid_vnr(current);
|
||||
queue.alter = alter;
|
||||
queue.dupsop = dupsop;
|
||||
|
||||
error = perform_atomic_semop(sma, &queue);
|
||||
if (error == 0) { /* non-blocking succesfull path */
|
||||
DEFINE_WAKE_Q(wake_q);
|
||||
......
|
||||
do_smart_update(sma, sops, nsops, 1, &wake_q);
|
||||
......
|
||||
wake_up_q(&wake_q);
|
||||
goto out_free;
|
||||
}
|
||||
/*
|
||||
* We need to sleep on this operation, so we put the current
|
||||
* task into the pending queue and go to sleep.
|
||||
*/
|
||||
if (nsops == 1) {
|
||||
struct sem *curr;
|
||||
curr = &sma->sems[sops->sem_num];
|
||||
......
|
||||
list_add_tail(&queue.list,
|
||||
&curr->pending_alter);
|
||||
......
|
||||
} else {
|
||||
......
|
||||
list_add_tail(&queue.list, &sma->pending_alter);
|
||||
......
|
||||
}
|
||||
|
||||
do {
|
||||
queue.status = -EINTR;
|
||||
queue.sleeper = current;
|
||||
|
||||
__set_current_state(TASK_INTERRUPTIBLE);
|
||||
if (timeout)
|
||||
jiffies_left = schedule_timeout(jiffies_left);
|
||||
else
|
||||
schedule();
|
||||
......
|
||||
/*
|
||||
* If an interrupt occurred we have to clean up the queue.
|
||||
*/
|
||||
if (timeout && jiffies_left == 0)
|
||||
error = -EAGAIN;
|
||||
} while (error == -EINTR && !signal_pending(current)); /* spurious */
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
semop会调用semtimedop,这是一个非常复杂的函数。
|
||||
|
||||
semtimedop做的第一件事情,就是将用户的参数,例如,对于信号量的操作struct sembuf,拷贝到内核里面来。另外,如果是P操作,很可能让进程进入等待状态,是否要为这个等待状态设置一个超时,timeout也是一个参数,会把它变成时钟的滴答数目。
|
||||
|
||||
semtimedop做的第二件事情,是通过sem_obtain_object_check,根据信号量集合的id,获得struct sem_array,然后,创建一个struct sem_queue表示当前的信号量操作。为什么叫queue呢?因为这个操作可能马上就能完成,也可能因为无法获取信号量不能完成,不能完成的话就只好排列到队列上,等待信号量满足条件的时候。semtimedop会调用perform_atomic_semop在实施信号量操作。
|
||||
|
||||
```
|
||||
static int perform_atomic_semop(struct sem_array *sma, struct sem_queue *q)
|
||||
{
|
||||
int result, sem_op, nsops;
|
||||
struct sembuf *sop;
|
||||
struct sem *curr;
|
||||
struct sembuf *sops;
|
||||
struct sem_undo *un;
|
||||
|
||||
sops = q->sops;
|
||||
nsops = q->nsops;
|
||||
un = q->undo;
|
||||
|
||||
for (sop = sops; sop < sops + nsops; sop++) {
|
||||
curr = &sma->sems[sop->sem_num];
|
||||
sem_op = sop->sem_op;
|
||||
result = curr->semval;
|
||||
......
|
||||
result += sem_op;
|
||||
if (result < 0)
|
||||
goto would_block;
|
||||
......
|
||||
if (sop->sem_flg & SEM_UNDO) {
|
||||
int undo = un->semadj[sop->sem_num] - sem_op;
|
||||
.....
|
||||
}
|
||||
}
|
||||
|
||||
for (sop = sops; sop < sops + nsops; sop++) {
|
||||
curr = &sma->sems[sop->sem_num];
|
||||
sem_op = sop->sem_op;
|
||||
result = curr->semval;
|
||||
|
||||
if (sop->sem_flg & SEM_UNDO) {
|
||||
int undo = un->semadj[sop->sem_num] - sem_op;
|
||||
un->semadj[sop->sem_num] = undo;
|
||||
}
|
||||
curr->semval += sem_op;
|
||||
curr->sempid = q->pid;
|
||||
}
|
||||
return 0;
|
||||
would_block:
|
||||
q->blocking = sop;
|
||||
return sop->sem_flg & IPC_NOWAIT ? -EAGAIN : 1;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在perform_atomic_semop函数中,对于所有信号量操作都进行两次循环。在第一次循环中,如果发现计算出的result小于0,则说明必须等待,于是跳到would_block中,设置q->blocking = sop表示这个queue是block在这个操作上,然后如果需要等待,则返回1。如果第一次循环中发现无需等待,则第二个循环实施所有的信号量操作,将信号量的值设置为新的值,并且返回0。
|
||||
|
||||
接下来,我们回到semtimedop,来看它干的第三件事情,就是如果需要等待,应该怎么办?
|
||||
|
||||
如果需要等待,则要区分刚才的对于信号量的操作,是对一个信号量的,还是对于整个信号量集合的。如果是对于一个信号量的,那我们就将queue挂到这个信号量的pending_alter中;如果是对于整个信号量集合的,那我们就将queue挂到整个信号量集合的pending_alter中。
|
||||
|
||||
接下来的do-while循环,就是要开始等待了。如果等待没有时间限制,则调用schedule让出CPU;如果等待有时间限制,则调用schedule_timeout让出CPU,过一段时间还回来。当回来的时候,判断是否等待超时,如果没有等待超时则进入下一轮循环,再次等待,如果超时则退出循环,返回错误。在让出CPU的时候,设置进程的状态为TASK_INTERRUPTIBLE,并且循环的结束会通过signal_pending查看是否收到过信号,这说明这个等待信号量的进程是可以被信号中断的,也即一个等待信号量的进程是可以通过kill杀掉的。
|
||||
|
||||
我们再来看,semtimedop要做的第四件事情,如果不需要等待,应该怎么办?
|
||||
|
||||
如果不需要等待,就说明对于信号量的操作完成了,也改变了信号量的值。接下来,就是一个标准流程。我们通过DEFINE_WAKE_Q(wake_q)声明一个wake_q,调用do_smart_update,看这次对于信号量的值的改变,可以影响并可以激活等待队列中的哪些struct sem_queue,然后把它们都放在wake_q里面,调用wake_up_q唤醒这些进程。其实,所有的对于信号量的值的修改都会涉及这三个操作,如果你回过头去仔细看SETALL和SETVAL操作,在设置完毕信号量之后,也是这三个操作。
|
||||
|
||||
我们来看do_smart_update是如何实现的。do_smart_update会调用update_queue。
|
||||
|
||||
```
|
||||
static int update_queue(struct sem_array *sma, int semnum, struct wake_q_head *wake_q)
|
||||
{
|
||||
struct sem_queue *q, *tmp;
|
||||
struct list_head *pending_list;
|
||||
int semop_completed = 0;
|
||||
|
||||
if (semnum == -1)
|
||||
pending_list = &sma->pending_alter;
|
||||
else
|
||||
pending_list = &sma->sems[semnum].pending_alter;
|
||||
|
||||
again:
|
||||
list_for_each_entry_safe(q, tmp, pending_list, list) {
|
||||
int error, restart;
|
||||
......
|
||||
error = perform_atomic_semop(sma, q);
|
||||
|
||||
/* Does q->sleeper still need to sleep? */
|
||||
if (error > 0)
|
||||
continue;
|
||||
|
||||
unlink_queue(sma, q);
|
||||
......
|
||||
wake_up_sem_queue_prepare(q, error, wake_q);
|
||||
......
|
||||
}
|
||||
return semop_completed;
|
||||
}
|
||||
|
||||
static inline void wake_up_sem_queue_prepare(struct sem_queue *q, int error,
|
||||
struct wake_q_head *wake_q)
|
||||
{
|
||||
wake_q_add(wake_q, q->sleeper);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
update_queue会依次循环整个信号量集合的等待队列pending_alter,或者某个信号量的等待队列。试图在信号量的值变了的情况下,再次尝试perform_atomic_semop进行信号量操作。如果不成功,则尝试队列中的下一个;如果尝试成功,则调用unlink_queue从队列上取下来,然后调用wake_up_sem_queue_prepare,将q->sleeper加到wake_q上去。q->sleeper是一个task_struct,是等待在这个信号量操作上的进程。
|
||||
|
||||
接下来,wake_up_q就依次唤醒wake_q上的所有task_struct,调用的是我们在进程调度那一节学过的wake_up_process方法。
|
||||
|
||||
```
|
||||
void wake_up_q(struct wake_q_head *head)
|
||||
{
|
||||
struct wake_q_node *node = head->first;
|
||||
|
||||
while (node != WAKE_Q_TAIL) {
|
||||
struct task_struct *task;
|
||||
|
||||
task = container_of(node, struct task_struct, wake_q);
|
||||
|
||||
node = node->next;
|
||||
task->wake_q.next = NULL;
|
||||
|
||||
wake_up_process(task);
|
||||
put_task_struct(task);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
至此,对于信号量的主流操作都解析完毕了。
|
||||
|
||||
其实还有一点需要强调一下,信号量是一个整个Linux可见的全局资源,而不像咱们在线程同步那一节讲过的都是某个进程独占的资源,好处是可以跨进程通信,坏处就是如果一个进程通过P操作拿到了一个信号量,但是不幸异常退出了,如果没有来得及归还这个信号量,可能所有其他的进程都阻塞了。
|
||||
|
||||
那怎么办呢?Linux有一种机制叫SEM_UNDO,也即每一个semop操作都会保存一个反向struct sem_undo操作,当因为某个进程异常退出的时候,这个进程做的所有的操作都会回退,从而保证其他进程可以正常工作。
|
||||
|
||||
如果你回头看,我们写的程序里面的semaphore_p函数和semaphore_v函数,都把sem_flg设置为SEM_UNDO,就是这个作用。
|
||||
|
||||
等待队列上的每一个struct sem_queue,都有一个struct sem_undo,以此来表示这次操作的反向操作。
|
||||
|
||||
```
|
||||
struct sem_queue {
|
||||
struct list_head list; /* queue of pending operations */
|
||||
struct task_struct *sleeper; /* this process */
|
||||
struct sem_undo *undo; /* undo structure */
|
||||
int pid; /* process id of requesting process */
|
||||
int status; /* completion status of operation */
|
||||
struct sembuf *sops; /* array of pending operations */
|
||||
struct sembuf *blocking; /* the operation that blocked */
|
||||
int nsops; /* number of operations */
|
||||
bool alter; /* does *sops alter the array? */
|
||||
bool dupsop; /* sops on more than one sem_num */
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在进程的task_struct里面对于信号量有一个成员struct sysv_sem,里面是一个struct sem_undo_list,将这个进程所有的semop所带来的undo操作都串起来。
|
||||
|
||||
```
|
||||
struct task_struct {
|
||||
......
|
||||
struct sysv_sem sysvsem;
|
||||
......
|
||||
}
|
||||
|
||||
struct sysv_sem {
|
||||
struct sem_undo_list *undo_list;
|
||||
};
|
||||
|
||||
struct sem_undo {
|
||||
struct list_head list_proc; /* per-process list: *
|
||||
* all undos from one process
|
||||
* rcu protected */
|
||||
struct rcu_head rcu; /* rcu struct for sem_undo */
|
||||
struct sem_undo_list *ulp; /* back ptr to sem_undo_list */
|
||||
struct list_head list_id; /* per semaphore array list:
|
||||
* all undos for one array */
|
||||
int semid; /* semaphore set identifier */
|
||||
short *semadj; /* array of adjustments */
|
||||
/* one per semaphore */
|
||||
};
|
||||
|
||||
struct sem_undo_list {
|
||||
atomic_t refcnt;
|
||||
spinlock_t lock;
|
||||
struct list_head list_proc;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
为了让你更清楚地理解struct sem_undo的原理,我们这里举一个例子。
|
||||
|
||||
假设我们创建了两个信号量集合。一个叫semaphore1,它包含三个信号量,初始化值为3,另一个叫semaphore2,它包含4个信号量,初始化值都为4。初始化时候的信号量以及undo结构里面的值如图中(1)标号所示。
|
||||
|
||||
<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和系统调用。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
356
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/10 | 进程:公司接这么多项目,如何管?.md
Normal file
356
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/10 | 进程:公司接这么多项目,如何管?.md
Normal 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 "Development Tools"
|
||||
|
||||
```
|
||||
|
||||
接下来,我们要开始写程序了。在Windows上写的程序,都会被保存成.h或者.c文件,容易让人感觉这是某种有特殊格式的文件,但其实这些文件只是普普通通的文本文件。因而在Linux上,我们用Vim来创建并编辑一个文件就行了。
|
||||
|
||||
我们先来创建一个文件,里面用一个函数封装通用的创建进程的逻辑,名字叫process.c,代码如下:
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
|
||||
extern int create_process (char* program, char** arg_list);
|
||||
|
||||
|
||||
int create_process (char* program, char** arg_list)
|
||||
{
|
||||
pid_t child_pid;
|
||||
child_pid = fork ();
|
||||
if (child_pid != 0)
|
||||
return child_pid;
|
||||
else {
|
||||
execvp (program, arg_list);
|
||||
abort ();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面用到了咱们学过的fork系统调用,通过这里面的if-else,我们可以看到,根据fork的返回值不同,父进程和子进程就此分道扬镳了。在子进程里面,我们需要通过execvp运行一个新的程序。
|
||||
|
||||
接下来我们创建第二个文件,调用上面这个函数。
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <sys/types.h>
|
||||
#include <unistd.h>
|
||||
|
||||
extern int create_process (char* program, char** arg_list);
|
||||
|
||||
int main ()
|
||||
{
|
||||
char* arg_list[] = {
|
||||
"ls",
|
||||
"-l",
|
||||
"/etc/yum.repos.d/",
|
||||
NULL
|
||||
};
|
||||
create_process ("ls", arg_list);
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里,我们创建的子程序运行了一个最最简单的命令ls。学过命令行的那一节之后,这里你应该很熟悉了。
|
||||
|
||||
## 进行编译:程序的二进制格式
|
||||
|
||||
程序写完了,是不是很简单?你可能要问了,这是不是就是我们所谓的项目执行计划书了呢?当然不是了,这两个文件只是文本文件,CPU是不能执行文本文件里面的指令的,这些指令只有人能看懂,CPU能够执行的命令是二进制的,比如“0101”这种,所以这些指令还需要翻译一下,这个翻译的过程就是**编译**(Compile)。编译好的二进制文件才是项目执行计划书。
|
||||
|
||||
现在咱们是正规的公司了,接项目要有章法,项目执行计划书也要有统一的格式,这样才能保证无论项目交到哪个项目组手里,都能以固定的流程执行。按照里面的指令来,项目也能达到预期的效果。
|
||||
|
||||
在Linux下面,二进制的程序也要有严格的格式,这个格式我们称为**ELF**(Executeable and Linkable Format,可执行与可链接格式)。这个格式可以根据编译的结果不同,分为不同的格式。
|
||||
|
||||
接下来我们看一下,如何从文本文件编译成二进制格式。
|
||||
|
||||
<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 Table,PLT),一个是.got.plt,全局偏移量表(Global Offset Table,GOT)。
|
||||
|
||||
它们是怎么工作的,使得程序运行的时候,可以将so文件动态链接到进程空间的呢?
|
||||
|
||||
dynamiccreateprocess这个程序要调用libdynamicprocess.so里的create_process函数。由于是运行时才去找,编译的时候,压根不知道这个函数在哪里,所以就在PLT里面建立一项PLT[x]。这一项也是一些代码,有点像一个本地的代理,在二进制程序里面,不直接调用create_process函数,而是调用PLT[x]里面的代理代码,这个代理代码会在运行的时候找真正的create_process函数。
|
||||
|
||||
去哪里找代理代码呢?这就用到了GOT,这里面也会为create_process函数创建一项GOT[y]。这一项是运行时create_process函数在内存中真正的地址。
|
||||
|
||||
如果这个地址在dynamiccreateprocess调用PLT[x]里面的代理代码,代理代码调用GOT表中对应项GOT[y],调用的就是加载到内存中的libdynamicprocess.so里面的create_process函数了。
|
||||
|
||||
但是GOT怎么知道的呢?对于create_process函数,GOT一开始就会创建一项GOT[y],但是这里面没有真正的地址,因为它也不知道,但是它有办法,它又回调PLT,告诉它,你里面的代理代码来找我要create_process函数的真实地址,我不知道,你想想办法吧。
|
||||
|
||||
PLT这个时候会转而调用PLT[0],也即第一项,PLT[0]转而调用GOT[2],这里面是ld-linux.so的入口函数,这个函数会找到加载到内存中的libdynamicprocess.so里面的create_process函数的地址,然后把这个地址放在GOT[y]里面。下次,PLT[x]的代理函数就能够直接调用了。
|
||||
|
||||
这个过程有点绕,但是是不是也很巧妙?
|
||||
|
||||
## 运行程序为进程
|
||||
|
||||
知道了ELF这个格式,这个时候它还是个程序,那怎么把这个文件加载到内存里面呢?
|
||||
|
||||
在内核中,有这样一个数据结构,用来定义加载二进制文件的方法。
|
||||
|
||||
```
|
||||
struct linux_binfmt {
|
||||
struct list_head lh;
|
||||
struct module *module;
|
||||
int (*load_binary)(struct linux_binprm *);
|
||||
int (*load_shlib)(struct file *);
|
||||
int (*core_dump)(struct coredump_params *cprm);
|
||||
unsigned long min_coredump; /* minimal dump size */
|
||||
} __randomize_layout;
|
||||
|
||||
```
|
||||
|
||||
对于ELF文件格式,有对应的实现。
|
||||
|
||||
```
|
||||
static struct linux_binfmt elf_format = {
|
||||
.module = THIS_MODULE,
|
||||
.load_binary = load_elf_binary,
|
||||
.load_shlib = load_elf_library,
|
||||
.core_dump = elf_core_dump,
|
||||
.min_coredump = ELF_EXEC_PAGESIZE,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
load_elf_binary是不是你很熟悉?没错,我们加载内核镜像的时候,用的也是这种格式。
|
||||
|
||||
还记得当时是谁调用的load_elf_binary函数吗?具体是这样的:do_execve->do_execveat_common->exec_binprm->search_binary_handler。
|
||||
|
||||
那do_execve又是被谁调用的呢?我们看下面的代码。
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE3(execve,
|
||||
const char __user *, filename,
|
||||
const char __user *const __user *, argv,
|
||||
const char __user *const __user *, envp)
|
||||
{
|
||||
return do_execve(getname(filename), argv, envp);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
学过了系统调用一节,你会发现,原理是exec这个系统调用最终调用的load_elf_binary。
|
||||
|
||||
exec比较特殊,它是一组函数:
|
||||
|
||||
<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 -> ../lib/systemd/systemd
|
||||
|
||||
```
|
||||
|
||||
系统启动之后,init进程会启动很多的daemon进程,为系统运行提供服务,然后就是启动getty,让用户登录,登录后运行shell,用户启动的进程都是通过shell运行的,从而形成了一棵进程树。
|
||||
|
||||
我们可以通过ps -ef命令查看当前系统启动的进程,我们会发现有三类进程。
|
||||
|
||||
```
|
||||
[root@deployer ~]# ps -ef
|
||||
UID PID PPID C STIME TTY TIME CMD
|
||||
root 1 0 0 2018 ? 00:00:29 /usr/lib/systemd/systemd --system --deserialize 21
|
||||
root 2 0 0 2018 ? 00:00:00 [kthreadd]
|
||||
root 3 2 0 2018 ? 00:00:00 [ksoftirqd/0]
|
||||
root 5 2 0 2018 ? 00:00:00 [kworker/0:0H]
|
||||
root 9 2 0 2018 ? 00:00:40 [rcu_sched]
|
||||
......
|
||||
root 337 2 0 2018 ? 00:00:01 [kworker/3:1H]
|
||||
root 380 1 0 2018 ? 00:00:00 /usr/lib/systemd/systemd-udevd
|
||||
root 415 1 0 2018 ? 00:00:01 /sbin/auditd
|
||||
root 498 1 0 2018 ? 00:00:03 /usr/lib/systemd/systemd-logind
|
||||
......
|
||||
root 852 1 0 2018 ? 00:06:25 /usr/sbin/rsyslogd -n
|
||||
root 2580 1 0 2018 ? 00:00:00 /usr/sbin/sshd -D
|
||||
root 29058 2 0 Jan03 ? 00:00:01 [kworker/1:2]
|
||||
root 29672 2 0 Jan04 ? 00:00:09 [kworker/2:1]
|
||||
root 30467 1 0 Jan06 ? 00:00:00 /usr/sbin/crond -n
|
||||
root 31574 2 0 Jan08 ? 00:00:01 [kworker/u128:2]
|
||||
......
|
||||
root 32792 2580 0 Jan10 ? 00:00:00 sshd: root@pts/0
|
||||
root 32794 32792 0 Jan10 pts/0 00:00:00 -bash
|
||||
root 32901 32794 0 00:01 pts/0 00:00:00 ps -ef
|
||||
|
||||
```
|
||||
|
||||
你会发现,PID 1的进程就是我们的init进程systemd,PID 2的进程是内核线程kthreadd,这两个我们在内核启动的时候都见过。其中用户态的不带中括号,内核态的带中括号。
|
||||
|
||||
接下来进程号依次增大,但是你会看所有带中括号的内核态的进程,祖先都是2号进程。而用户态的进程,祖先都是1号进程。tty那一列,是问号的,说明不是前台启动的,一般都是后台的服务。
|
||||
|
||||
pts的父进程是sshd,bash的父进程是pts,ps -ef这个命令的父进程是bash。这样整个链条都比较清晰了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节我们讲了一个进程从代码到二进制到运行时的一个过程,我们用一个图总结一下。
|
||||
|
||||
我们首先通过图右边的文件编译过程,生成so文件和可执行文件,放在硬盘上。下图左边的用户态的进程A执行fork,创建进程B,在进程B的处理逻辑中,执行exec系列系统调用。这个系统调用会通过load_elf_binary方法,将刚才生成的可执行文件,加载到进程B的内存中执行。
|
||||
|
||||
<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="">
|
||||
555
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/11 | 线程:如何让复杂的项目并行执行?.md
Normal file
555
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/11 | 线程:如何让复杂的项目并行执行?.md
Normal 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 <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define NUM_OF_TASKS 5
|
||||
|
||||
void *downloadfile(void *filename)
|
||||
{
|
||||
printf("I am downloading the file %s!\n", (char *)filename);
|
||||
sleep(10);
|
||||
long downloadtime = rand()%100;
|
||||
printf("I finish downloading the file within %d minutes!\n", downloadtime);
|
||||
pthread_exit((void *)downloadtime);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
char files[NUM_OF_TASKS][20]={"file1.avi","file2.rmvb","file3.mp4","file4.wmv","file5.flv"};
|
||||
pthread_t threads[NUM_OF_TASKS];
|
||||
int rc;
|
||||
int t;
|
||||
int downloadtime;
|
||||
|
||||
pthread_attr_t thread_attr;
|
||||
pthread_attr_init(&thread_attr);
|
||||
pthread_attr_setdetachstate(&thread_attr,PTHREAD_CREATE_JOINABLE);
|
||||
|
||||
for(t=0;t<NUM_OF_TASKS;t++){
|
||||
printf("creating thread %d, please help me to download %s\n", t, files[t]);
|
||||
rc = pthread_create(&threads[t], &thread_attr, downloadfile, (void *)files[t]);
|
||||
if (rc){
|
||||
printf("ERROR; return code from pthread_create() is %d\n", rc);
|
||||
exit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
pthread_attr_destroy(&thread_attr);
|
||||
|
||||
for(t=0;t<NUM_OF_TASKS;t++){
|
||||
pthread_join(threads[t],(void**)&downloadtime);
|
||||
printf("Thread %d downloads the file %s in %d minutes.\n",t,files[t],downloadtime);
|
||||
}
|
||||
|
||||
pthread_exit(NULL);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
一个运行中的线程可以调用pthread_exit退出线程。这个函数可以传入一个参数转换为(void *)类型。这是线程退出的返回值。
|
||||
|
||||
接下来,我们来看主线程。在这里面,我列了五个文件名。接下来声明了一个数组,里面有五个pthread_t类型的线程对象。
|
||||
|
||||
接下来,声明一个线程属性pthread_attr_t。我们通过pthread_attr_init初始化这个属性,并且设置属性PTHREAD_CREATE_JOINABLE。这表示将来主线程程等待这个线程的结束,并获取退出时的状态。
|
||||
|
||||
接下来是一个循环。对于每一个文件和每一个线程,可以调用pthread_create创建线程。一共有四个参数,第一个参数是线程对象,第二个参数是线程的属性,第三个参数是线程运行函数,第四个参数是线程运行函数的参数。主线程就是通过第四个参数,将自己的任务派给子线程。
|
||||
|
||||
任务分配完毕,每个线程下载一个文件,接下来主线程要做的事情就是等待这些子任务完成。当一个线程退出的时候,就会发送信号给其他所有同进程的线程。有一个线程使用pthread_join获取这个线程退出的返回值。线程的返回值通过pthread_join传给主线程,这样子线程就将自己下载文件所耗费的时间,告诉给主线程。
|
||||
|
||||
好了,程序写完了,开始编译。多线程程序要依赖于libpthread.so。
|
||||
|
||||
```
|
||||
gcc download.c -lpthread
|
||||
|
||||
```
|
||||
|
||||
编译好了,执行一下,就能得到下面的结果。
|
||||
|
||||
```
|
||||
# ./a.out
|
||||
creating thread 0, please help me to download file1.avi
|
||||
creating thread 1, please help me to download file2.rmvb
|
||||
I am downloading the file file1.avi!
|
||||
creating thread 2, please help me to download file3.mp4
|
||||
I am downloading the file file2.rmvb!
|
||||
creating thread 3, please help me to download file4.wmv
|
||||
I am downloading the file file3.mp4!
|
||||
creating thread 4, please help me to download file5.flv
|
||||
I am downloading the file file4.wmv!
|
||||
I am downloading the file file5.flv!
|
||||
I finish downloading the file within 83 minutes!
|
||||
I finish downloading the file within 77 minutes!
|
||||
I finish downloading the file within 86 minutes!
|
||||
I finish downloading the file within 15 minutes!
|
||||
I finish downloading the file within 93 minutes!
|
||||
Thread 0 downloads the file file1.avi in 83 minutes.
|
||||
Thread 1 downloads the file file2.rmvb in 86 minutes.
|
||||
Thread 2 downloads the file file3.mp4 in 77 minutes.
|
||||
Thread 3 downloads the file file4.wmv in 93 minutes.
|
||||
Thread 4 downloads the file file5.flv in 15 minutes.
|
||||
|
||||
```
|
||||
|
||||
这里我们画一张图总结一下,一个普通线程的创建和运行过程。
|
||||
|
||||
<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查看,默认情况下线程栈大小为8192(8MB)。我们可以使用命令ulimit -s修改。
|
||||
|
||||
对于线程栈,可以通过下面这个函数pthread_attr_t,修改线程栈的大小。
|
||||
|
||||
```
|
||||
int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
|
||||
|
||||
```
|
||||
|
||||
主线程在内存中有一个栈空间,其他线程栈也拥有独立的栈空间。为了避免线程之间的栈空间踩踏,线程栈之间还会有小块区域,用来隔离保护各自的栈空间。一旦另一个线程踏入到这个隔离区,就会引发段错误。
|
||||
|
||||
第二类数据就是**在整个进程里共享的全局数据**。例如全局变量,虽然在不同进程中是隔离的,但是在一个进程中是共享的。如果同一个全局变量,两个线程一起修改,那肯定会有问题,有可能把数据改的面目全非。这就需要有一种机制来保护他们,比如你先用我再用。这一节的最后,我们专门来谈这个问题。
|
||||
|
||||
那线程能不能像进程一样,也有自己的私有数据呢?如果想声明一个线程级别,而非进程级别的全局变量,有没有什么办法呢?虽然咱们都是一个大组,分成小组,也应该有点隐私。
|
||||
|
||||
这就是第三类数据,**线程私有数据**(Thread Specific Data),可以通过以下函数创建:
|
||||
|
||||
```
|
||||
int pthread_key_create(pthread_key_t *key, void (*destructor)(void*))
|
||||
|
||||
```
|
||||
|
||||
可以看到,创建一个key,伴随着一个析构函数。
|
||||
|
||||
key一旦被创建,所有线程都可以访问它,但各线程可根据自己的需要往key中填入不同的值,这就相当于提供了一个同名而不同值的全局变量。
|
||||
|
||||
我们可以通过下面的函数设置key对应的value。
|
||||
|
||||
```
|
||||
int pthread_setspecific(pthread_key_t key, const void *value)
|
||||
|
||||
```
|
||||
|
||||
我们还可以通过下面的函数获取key对应的value。
|
||||
|
||||
```
|
||||
void *pthread_getspecific(pthread_key_t key)
|
||||
|
||||
```
|
||||
|
||||
而等到线程退出的时候,就会调用析构函数释放value。
|
||||
|
||||
## 数据的保护
|
||||
|
||||
接下来,我们来看共享的数据保护问题。
|
||||
|
||||
我们先来看一种方式,**Mutex**,全称Mutual Exclusion,中文叫**互斥**。顾名思义,有你没我,有我没你。它的模式就是在共享数据访问的时候,去申请加把锁,谁先拿到锁,谁就拿到了访问权限,其他人就只好在门外等着,等这个人访问结束,把锁打开,其他人再去争夺,还是遵循谁先拿到谁访问。
|
||||
|
||||
我这里构建了一个“转账”的场景。相关的代码我放到这里,你可以看看。
|
||||
|
||||
```
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define NUM_OF_TASKS 5
|
||||
|
||||
int money_of_tom = 100;
|
||||
int money_of_jerry = 100;
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_t g_money_lock;
|
||||
|
||||
void *transfer(void *notused)
|
||||
{
|
||||
pthread_t tid = pthread_self();
|
||||
printf("Thread %u is transfering money!\n", (unsigned int)tid);
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_lock(&g_money_lock);
|
||||
sleep(rand()%10);
|
||||
money_of_tom+=10;
|
||||
sleep(rand()%10);
|
||||
money_of_jerry-=10;
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_unlock(&g_money_lock);
|
||||
printf("Thread %u finish transfering money!\n", (unsigned int)tid);
|
||||
pthread_exit((void *)0);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
pthread_t threads[NUM_OF_TASKS];
|
||||
int rc;
|
||||
int t;
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_init(&g_money_lock, NULL);
|
||||
|
||||
for(t=0;t<NUM_OF_TASKS;t++){
|
||||
rc = pthread_create(&threads[t], NULL, transfer, NULL);
|
||||
if (rc){
|
||||
printf("ERROR; return code from pthread_create() is %d\n", rc);
|
||||
exit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
for(t=0;t<100;t++){
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_lock(&g_money_lock);
|
||||
printf("money_of_tom + money_of_jerry = %d\n", money_of_tom + money_of_jerry);
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_unlock(&g_money_lock);
|
||||
}
|
||||
//第一次运行去掉下面这行
|
||||
pthread_mutex_destroy(&g_money_lock);
|
||||
pthread_exit(NULL);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里说,有两个员工Tom和Jerry,公司食堂的饭卡里面各自有100元,并行启动5个线程,都是Jerry转10元给Tom,主线程不断打印Tom和Jerry的资金之和。按说,这样的话,总和应该永远是200元。
|
||||
|
||||
在上面的程序中,我们先去掉mutex相关的行,就像注释里面写的那样。在没有锁的保护下,在Tom的账户里面加上10元,在Jerry的账户里面减去10元,这不是一个原子操作。
|
||||
|
||||
我们来编译一下。
|
||||
|
||||
```
|
||||
gcc mutex.c -lpthread
|
||||
|
||||
```
|
||||
|
||||
然后运行一下,就看到了下面这样的结果。
|
||||
|
||||
```
|
||||
[root@deployer createthread]# ./a.out
|
||||
Thread 508479232 is transfering money!
|
||||
Thread 491693824 is transfering money!
|
||||
Thread 500086528 is transfering money!
|
||||
Thread 483301120 is transfering money!
|
||||
Thread 516871936 is transfering money!
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 220
|
||||
money_of_tom + money_of_jerry = 220
|
||||
money_of_tom + money_of_jerry = 230
|
||||
money_of_tom + money_of_jerry = 240
|
||||
Thread 483301120 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 240
|
||||
Thread 508479232 finish transfering money!
|
||||
Thread 500086528 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 220
|
||||
Thread 516871936 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 210
|
||||
money_of_tom + money_of_jerry = 210
|
||||
Thread 491693824 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
|
||||
```
|
||||
|
||||
可以看到,中间有很多状态不正确,比如两个人的账户之和出现了超过200的情况,也就是Tom转入了,Jerry还没转出。
|
||||
|
||||
接下来我们在上面的代码里面,加上mutex,然后编译、运行,就得到了下面的结果。
|
||||
|
||||
```
|
||||
[root@deployer createthread]# ./a.out
|
||||
Thread 568162048 is transfering money!
|
||||
Thread 576554752 is transfering money!
|
||||
Thread 551376640 is transfering money!
|
||||
Thread 542983936 is transfering money!
|
||||
Thread 559769344 is transfering money!
|
||||
Thread 568162048 finish transfering money!
|
||||
Thread 576554752 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
Thread 542983936 finish transfering money!
|
||||
Thread 559769344 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
Thread 551376640 finish transfering money!
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
money_of_tom + money_of_jerry = 200
|
||||
|
||||
```
|
||||
|
||||
这个结果就正常了。两个账号之和永远是200。这下你看到锁的作用了吧?
|
||||
|
||||
使用Mutex,首先要使用pthread_mutex_init函数初始化这个mutex,初始化后,就可以用它来保护共享变量了。
|
||||
|
||||
pthread_mutex_lock() 就是去抢那把锁的函数,如果抢到了,就可以执行下一行程序,对共享变量进行访问;如果没抢到,就被阻塞在那里等待。
|
||||
|
||||
如果不想被阻塞,可以使用pthread_mutex_trylock去抢那把锁,如果抢到了,就可以执行下一行程序,对共享变量进行访问;如果没抢到,不会被阻塞,而是返回一个错误码。
|
||||
|
||||
当共享数据访问结束了,别忘了使用pthread_mutex_unlock释放锁,让给其他人使用,最终调用pthread_mutex_destroy销毁掉这把锁。
|
||||
|
||||
这里我画个图,总结一下Mutex的使用流程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/be/0ccf37aafa2b287363399e130b2726be.jpg" alt="">
|
||||
|
||||
在使用Mutex的时候,有个问题是如果使用pthread_mutex_lock(),那就需要一直在那里等着。如果是pthread_mutex_trylock(),就可以不用等着,去干点儿别的,但是我怎么知道什么时候回来再试一下,是不是轮到我了呢?能不能在轮到我的时候,通知我一下呢?
|
||||
|
||||
这其实就是条件变量,也就是说如果没事儿,就让大家歇着,有事儿了就去通知,别让人家没事儿就来问问,浪费大家的时间。
|
||||
|
||||
但是当它接到了通知,来操作共享资源的时候,还是需要抢互斥锁,因为可能很多人都受到了通知,都来访问了,所以**条件变量和互斥锁是配合使用的**。
|
||||
|
||||
我这里还是用一个场景给你解释。
|
||||
|
||||
你这个老板,招聘了三个员工,但是你不是有了活才去招聘员工,而是先把员工招来,没有活的时候员工需要在那里等着,一旦有了活,你要去通知他们,他们要去抢活干(为啥要抢活?因为有绩效呀!),干完了再等待,你再有活,再通知他们。
|
||||
|
||||
具体的样例代码我也放在这里。你可以直接编译运行。
|
||||
|
||||
```
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define NUM_OF_TASKS 3
|
||||
#define MAX_TASK_QUEUE 11
|
||||
|
||||
char tasklist[MAX_TASK_QUEUE]="ABCDEFGHIJ";
|
||||
int head = 0;
|
||||
int tail = 0;
|
||||
|
||||
int quit = 0;
|
||||
|
||||
pthread_mutex_t g_task_lock;
|
||||
pthread_cond_t g_task_cv;
|
||||
|
||||
void *coder(void *notused)
|
||||
{
|
||||
pthread_t tid = pthread_self();
|
||||
|
||||
while(!quit){
|
||||
|
||||
pthread_mutex_lock(&g_task_lock);
|
||||
while(tail == head){
|
||||
if(quit){
|
||||
pthread_mutex_unlock(&g_task_lock);
|
||||
pthread_exit((void *)0);
|
||||
}
|
||||
printf("No task now! Thread %u is waiting!\n", (unsigned int)tid);
|
||||
pthread_cond_wait(&g_task_cv, &g_task_lock);
|
||||
printf("Have task now! Thread %u is grabing the task !\n", (unsigned int)tid);
|
||||
}
|
||||
char task = tasklist[head++];
|
||||
pthread_mutex_unlock(&g_task_lock);
|
||||
printf("Thread %u has a task %c now!\n", (unsigned int)tid, task);
|
||||
sleep(5);
|
||||
printf("Thread %u finish the task %c!\n", (unsigned int)tid, task);
|
||||
}
|
||||
|
||||
pthread_exit((void *)0);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
pthread_t threads[NUM_OF_TASKS];
|
||||
int rc;
|
||||
int t;
|
||||
|
||||
pthread_mutex_init(&g_task_lock, NULL);
|
||||
pthread_cond_init(&g_task_cv, NULL);
|
||||
|
||||
for(t=0;t<NUM_OF_TASKS;t++){
|
||||
rc = pthread_create(&threads[t], NULL, coder, NULL);
|
||||
if (rc){
|
||||
printf("ERROR; return code from pthread_create() is %d\n", rc);
|
||||
exit(-1);
|
||||
}
|
||||
}
|
||||
|
||||
sleep(5);
|
||||
|
||||
for(t=1;t<=4;t++){
|
||||
pthread_mutex_lock(&g_task_lock);
|
||||
tail+=t;
|
||||
printf("I am Boss, I assigned %d tasks, I notify all coders!\n", t);
|
||||
pthread_cond_broadcast(&g_task_cv);
|
||||
pthread_mutex_unlock(&g_task_lock);
|
||||
sleep(20);
|
||||
}
|
||||
|
||||
pthread_mutex_lock(&g_task_lock);
|
||||
quit = 1;
|
||||
pthread_cond_broadcast(&g_task_cv);
|
||||
pthread_mutex_unlock(&g_task_lock);
|
||||
|
||||
pthread_mutex_destroy(&g_task_lock);
|
||||
pthread_cond_destroy(&g_task_cv);
|
||||
pthread_exit(NULL);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
首先,我们创建了10个任务,每个任务一个字符,放在一个数组里面,另外有两个变量head和tail,表示当前分配的工作从哪里开始,到哪里结束。如果head等于tail,则当前的工作分配完毕;如果tail加N,就是新分配了N个工作。
|
||||
|
||||
接下来声明的pthread_mutex_t g_task_lock和pthread_cond_t g_task_cv,是用于通知和抢任务的,工作模式如下图所示:
|
||||
|
||||
<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="">
|
||||
@@ -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 id,tgid是thread group ID。
|
||||
|
||||
任何一个进程,如果只有主线程,那pid是自己,tgid是自己,group_leader指向的还是自己。
|
||||
|
||||
但是,如果一个进程创建了其他线程,那就会有所变化了。线程有自己的pid,tgid就是进程的主线程的pid,group_leader指向的就是进程的主线程。
|
||||
|
||||
好了,有了tgid,我们就知道tast_struct代表的是一个进程还是代表一个线程了。
|
||||
|
||||
## 信号处理
|
||||
|
||||
这里既然提到了下发指令的问题,我就顺便提一下task_struct里面关于信号处理的字段。
|
||||
|
||||
```
|
||||
/* Signal handlers: */
|
||||
struct signal_struct *signal;
|
||||
struct sighand_struct *sighand;
|
||||
sigset_t blocked;
|
||||
sigset_t real_blocked;
|
||||
sigset_t saved_sigmask;
|
||||
struct sigpending pending;
|
||||
unsigned long sas_ss_sp;
|
||||
size_t sas_ss_size;
|
||||
unsigned int sas_ss_flags;
|
||||
|
||||
```
|
||||
|
||||
这里定义了哪些信号被阻塞暂不处理(blocked),哪些信号尚等待处理(pending),哪些信号正在通过信号处理函数进行处理(sighand)。处理的结果可以是忽略,可以是结束进程等等。
|
||||
|
||||
信号处理函数默认使用用户态的函数栈,当然也可以开辟新的栈专门用于信号处理,这就是sas_ss_xxx这三个变量的作用。
|
||||
|
||||
上面我说了下发信号的时候,需要区分进程和线程。从这里我们其实也能看出一些端倪。
|
||||
|
||||
task_struct里面有一个struct sigpending pending。如果我们进入struct signal_struct *signal去看的话,还有一个struct sigpending shared_pending。它们一个是本任务的,一个是线程组共享的。
|
||||
|
||||
关于信号,你暂时了解到这里就够用了,后面我们会有单独的章节进行解读。
|
||||
|
||||
## 任务状态
|
||||
|
||||
作为一个项目经理,另外一个需要关注的是项目当前的状态。例如,在Jira里面,任务的运行就可以分成下面的状态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e0/21/e0019fcd11ff1ba33a3389e285b6a121.jpg" alt="">
|
||||
|
||||
在task_struct里面,涉及任务状态的是下面这几个变量:
|
||||
|
||||
```
|
||||
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
|
||||
int exit_state;
|
||||
unsigned int flags;
|
||||
|
||||
```
|
||||
|
||||
state(状态)可以取的值定义在include/linux/sched.h头文件中。
|
||||
|
||||
```
|
||||
/* Used in tsk->state: */
|
||||
#define TASK_RUNNING 0
|
||||
#define TASK_INTERRUPTIBLE 1
|
||||
#define TASK_UNINTERRUPTIBLE 2
|
||||
#define __TASK_STOPPED 4
|
||||
#define __TASK_TRACED 8
|
||||
/* Used in tsk->exit_state: */
|
||||
#define EXIT_DEAD 16
|
||||
#define EXIT_ZOMBIE 32
|
||||
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
|
||||
/* Used in tsk->state again: */
|
||||
#define TASK_DEAD 64
|
||||
#define TASK_WAKEKILL 128
|
||||
#define TASK_WAKING 256
|
||||
#define TASK_PARKED 512
|
||||
#define TASK_NOLOAD 1024
|
||||
#define TASK_NEW 2048
|
||||
#define TASK_STATE_MAX 4096
|
||||
|
||||
```
|
||||
|
||||
从定义的数值很容易看出来,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="">
|
||||
@@ -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是parent,bash是这个进程的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安装的。游戏这个程序文件的权限为rwxr–r--。A是没有权限运行这个程序的,所以用户B要给用户A权限才行。用户B说没问题,都是朋友嘛,于是用户B就给这个程序设定了所有的用户都能执行的权限rwxr-xr-x,说兄弟你玩吧。
|
||||
|
||||
于是,用户A就获得了运行这个游戏的权限。当游戏运行起来之后,游戏进程的uid、euid、fsuid都是用户A。看起来没有问题,玩得很开心。
|
||||
|
||||
用户A好不容易通过一关,想保留通关数据的时候,发现坏了,这个游戏的玩家数据是保存在另一个文件里面的。这个文件权限rw-------,只给用户B开了写入权限,而游戏进程的euid和fsuid都是用户A,当然写不进去了。完了,这一局白玩儿了。
|
||||
|
||||
那怎么解决这个问题呢?我们可以通过chmod u+s program命令,给这个游戏程序设置set-user-ID的标识位,把游戏的权限变成rwsr-xr-x。这个时候,用户A再启动这个游戏的时候,创建的进程uid当然还是用户A,但是euid和fsuid就不是用户A了,因为看到了set-user-id标识,就改为文件的所有者的ID,也就是说,euid和fsuid都改成用户B了,这样就能够将通关结果保存下来。
|
||||
|
||||
在Linux里面,一个进程可以随时通过setuid设置用户ID,所以,游戏程序的用户B的ID还会保存在一个地方,这就是suid和sgid,也就是saved uid和save gid。这样就可以很方便地使用setuid,通过设置uid或者suid来改变权限。
|
||||
|
||||
除了以用户和用户组控制权限,Linux还有另一个机制就是**capabilities**。
|
||||
|
||||
原来控制进程的权限,要么是高权限的root用户,要么是一般权限的普通用户,这时候的问题是,root用户权限太大,而普通用户权限太小。有时候一个普通用户想做一点高权限的事情,必须给他整个root的权限。这个太不安全了。
|
||||
|
||||
于是,我们引入新的机制capabilities,用位图表示权限,在capability.h可以找到定义的权限。我这里列举几个。
|
||||
|
||||
```
|
||||
#define CAP_CHOWN 0
|
||||
#define CAP_KILL 5
|
||||
#define CAP_NET_BIND_SERVICE 10
|
||||
#define CAP_NET_RAW 13
|
||||
#define CAP_SYS_MODULE 16
|
||||
#define CAP_SYS_RAWIO 17
|
||||
#define CAP_SYS_BOOT 22
|
||||
#define CAP_SYS_TIME 25
|
||||
#define CAP_AUDIT_READ 37
|
||||
#define CAP_LAST_CAP CAP_AUDIT_READ
|
||||
|
||||
```
|
||||
|
||||
对于普通用户运行的进程,当有这个权限的时候,就能做这些操作;没有的时候,就不能做,这样粒度要小很多。
|
||||
|
||||
cap_permitted表示进程能够使用的权限。但是真正起作用的是cap_effective。cap_permitted中可以包含cap_effective中没有的权限。一个进程可以在必要的时候,放弃自己的某些权限,这样更加安全。假设自己因为代码漏洞被攻破了,但是如果啥也干不了,就没办法进一步突破。
|
||||
|
||||
cap_inheritable表示当可执行文件的扩展属性设置了inheritable位时,调用exec执行该程序会继承调用者的inheritable集合,并将其加入到permitted集合。但在非root用户下执行exec时,通常不会保留inheritable集合,但是往往又是非root用户,才想保留权限,所以非常鸡肋。
|
||||
|
||||
cap_bset,也就是capability bounding set,是系统中所有进程允许保留的权限。如果这个集合中不存在某个权限,那么系统中的所有进程都没有这个权限。即使以超级用户权限执行的进程,也是一样的。
|
||||
|
||||
这样有很多好处。例如,系统启动以后,将加载内核模块的权限去掉,那所有进程都不能加载内核模块。这样,即便这台机器被攻破,也做不了太多有害的事情。
|
||||
|
||||
cap_ambient是比较新加入内核的,就是为了解决cap_inheritable鸡肋的状况,也就是,非root用户进程使用exec执行一个程序的时候,如何保留权限的问题。当执行exec的时候,cap_ambient会被添加到cap_permitted中,同时设置到cap_effective中。
|
||||
|
||||
## 内存管理
|
||||
|
||||
每个进程都有自己独立的虚拟内存空间,这需要有一个数据结构来表示,就是mm_struct。这个我们在内存管理那一节详细讲述。这里你先有个印象。
|
||||
|
||||
```
|
||||
struct mm_struct *mm;
|
||||
struct mm_struct *active_mm;
|
||||
|
||||
```
|
||||
|
||||
## 文件与文件系统
|
||||
|
||||
每个进程有一个文件系统的数据结构,还有一个打开文件的数据结构。这个我们放到文件系统那一节详细讲述。
|
||||
|
||||
```
|
||||
/* Filesystem information: */
|
||||
struct fs_struct *fs;
|
||||
/* Open file information: */
|
||||
struct files_struct *files;
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们终于把进程管理复杂的数据结构基本讲完了,请你重点记住以下两点:
|
||||
|
||||
<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="">
|
||||
@@ -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调用B,A的栈里面包含A函数的局部变量,然后是调用B的时候要传给它的参数,然后返回A的地址,这个地址也应该入栈,这就形成了A的栈帧。接下来就是B的栈帧部分了,先保存的是A栈帧的栈底位置,也就是EBP。因为在B函数里面获取A传进来的参数,就是通过这个指针获取的,接下来保存的是B的局部变量等等。
|
||||
|
||||
当B返回的时候,返回值会保存在EAX寄存器中,从栈中弹出返回地址,将指令跳转回去,参数也从栈中弹出,然后继续执行A。
|
||||
|
||||
对于64位操作系统,模式多少有些不一样。因为64位操作系统的寄存器数目比较多。rax用于保存函数调用的返回结果。栈顶指针寄存器变成了rsp,指向栈顶位置。堆栈的Pop和Push操作会自动调整rsp,栈基指针寄存器变成了rbp,指向当前栈帧的起始位置。
|
||||
|
||||
改变比较多的是参数传递。rdi、rsi、rdx、rcx、r8、r9这6个寄存器,用于传递存储函数调用时的6个参数。如果超过6的时候,还是需要放到栈里面。
|
||||
|
||||
然而,前6个参数有时候需要进行寻址,但是如果在寄存器里面,是没有地址的,因而还是会放到栈里面,只不过放到栈里面的操作是被调用函数做的。
|
||||
|
||||
<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 << THREAD_SIZE_ORDER)
|
||||
|
||||
```
|
||||
|
||||
内核栈在64位系统上arch/x86/include/asm/page_64_types.h,是这样定义的:在PAGE_SIZE的基础上左移两位,也即16K,并且要求起始地址必须是8192的整数倍。
|
||||
|
||||
```
|
||||
#ifdef CONFIG_KASAN
|
||||
#define KASAN_STACK_ORDER 1
|
||||
#else
|
||||
#define KASAN_STACK_ORDER 0
|
||||
#endif
|
||||
|
||||
|
||||
#define THREAD_SIZE_ORDER (2 + KASAN_STACK_ORDER)
|
||||
#define THREAD_SIZE (PAGE_SIZE << THREAD_SIZE_ORDER)
|
||||
|
||||
```
|
||||
|
||||
内核栈是一个非常特殊的结构,如下图所示:
|
||||
|
||||
<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->stack;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从task_struct如何得到相应的pt_regs呢?我们可以通过下面的函数:
|
||||
|
||||
```
|
||||
/*
|
||||
* TOP_OF_KERNEL_STACK_PADDING reserves 8 bytes on top of the ring0 stack.
|
||||
* This is necessary to guarantee that the entire "struct pt_regs"
|
||||
* is accessible even if the CPU haven't stored the SS/ESP registers
|
||||
* on the stack (interrupt gate does not save these registers
|
||||
* when switching to the same priv ring).
|
||||
* Therefore beware: accessing the ss/esp fields of the
|
||||
* "struct pt_regs" is possible, but they may contain the
|
||||
* completely wrong values.
|
||||
*/
|
||||
#define task_pt_regs(task) \
|
||||
({ \
|
||||
unsigned long __ptr = (unsigned long)task_stack_page(task); \
|
||||
__ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING; \
|
||||
((struct pt_regs *)__ptr) - 1; \
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
你会发现,这是先从task_struct找到内核栈的开始位置。然后这个位置加上THREAD_SIZE就到了最后的位置,然后转换为struct pt_regs,再减一,就相当于减少了一个pt_regs的位置,就到了这个结构的首地址。
|
||||
|
||||
这里面有一个TOP_OF_KERNEL_STACK_PADDING,这个的定义如下:
|
||||
|
||||
```
|
||||
#ifdef CONFIG_X86_32
|
||||
# ifdef CONFIG_VM86
|
||||
# define TOP_OF_KERNEL_STACK_PADDING 16
|
||||
# else
|
||||
# define TOP_OF_KERNEL_STACK_PADDING 8
|
||||
# endif
|
||||
#else
|
||||
# define TOP_OF_KERNEL_STACK_PADDING 0
|
||||
#endif
|
||||
|
||||
```
|
||||
|
||||
也就是说,32位机器上是8,其他是0。这是为什么呢?因为压栈pt_regs有两种情况。我们知道,CPU用ring来区分权限,从而Linux可以区分内核态和用户态。
|
||||
|
||||
因此,第一种情况,我们拿涉及从用户态到内核态的变化的系统调用来说。因为涉及权限的改变,会压栈保存SS、ESP寄存器的,这两个寄存器共占用8个byte。
|
||||
|
||||
另一种情况是,不涉及权限的变化,就不会压栈这8个byte。这样就会使得两种情况不兼容。如果没有压栈还访问,就会报错,所以还不如预留在这里,保证安全。在64位上,修改了这个问题,变成了定长的。
|
||||
|
||||
好了,现在如果你task_struct在手,就能够轻松得到内核栈和内核寄存器。
|
||||
|
||||
## 通过内核栈找task_struct
|
||||
|
||||
那如果一个当前在某个CPU上执行的进程,想知道自己的task_struct在哪里,又该怎么办呢?
|
||||
|
||||
这个艰巨的任务要交给thread_info这个结构。
|
||||
|
||||
```
|
||||
struct thread_info {
|
||||
struct task_struct *task; /* main task structure */
|
||||
__u32 flags; /* low level flags */
|
||||
__u32 status; /* thread synchronous flags */
|
||||
__u32 cpu; /* current CPU */
|
||||
mm_segment_t addr_limit;
|
||||
unsigned int sig_on_uaccess_error:1;
|
||||
unsigned int uaccess_err:1; /* uaccess failed */
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这里面有个成员变量task指向task_struct,所以我们常用current_thread_info()->task来获取task_struct。
|
||||
|
||||
```
|
||||
static inline struct thread_info *current_thread_info(void)
|
||||
{
|
||||
return (struct thread_info *)(current_top_of_stack() - THREAD_SIZE);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而thread_info的位置就是内核栈的最高位置,减去THREAD_SIZE,就到了thread_info的起始地址。
|
||||
|
||||
但是现在变成这样了,只剩下一个flags。
|
||||
|
||||
```
|
||||
struct thread_info {
|
||||
unsigned long flags; /* low level flags */
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
那这时候怎么获取当前运行中的task_struct呢?current_thread_info有了新的实现方式。
|
||||
|
||||
在include/linux/thread_info.h中定义了current_thread_info。
|
||||
|
||||
```
|
||||
#include <asm/current.h>
|
||||
#define current_thread_info() ((struct thread_info *)current)
|
||||
#endif
|
||||
|
||||
```
|
||||
|
||||
那current又是什么呢?在arch/x86/include/asm/current.h中定义了。
|
||||
|
||||
```
|
||||
struct task_struct;
|
||||
|
||||
|
||||
DECLARE_PER_CPU(struct task_struct *, current_task);
|
||||
|
||||
|
||||
static __always_inline struct task_struct *get_current(void)
|
||||
{
|
||||
return this_cpu_read_stable(current_task);
|
||||
}
|
||||
|
||||
|
||||
#define current get_current
|
||||
|
||||
```
|
||||
|
||||
到这里,你会发现,新的机制里面,每个CPU运行的task_struct不通过thread_info获取了,而是直接放在Per CPU 变量里面了。
|
||||
|
||||
多核情况下,CPU是同时运行的,但是它们共同使用其他的硬件资源的时候,我们需要解决多个CPU之间的同步问题。
|
||||
|
||||
Per CPU变量是内核中一种重要的同步机制。顾名思义,Per CPU变量就是为每个CPU构造一个变量的副本,这样多个CPU各自操作自己的副本,互不干涉。比如,当前进程的变量current_task就被声明为Per CPU变量。
|
||||
|
||||
要使用Per CPU变量,首先要声明这个变量,在arch/x86/include/asm/current.h中有:
|
||||
|
||||
```
|
||||
DECLARE_PER_CPU(struct task_struct *, current_task);
|
||||
|
||||
```
|
||||
|
||||
然后是定义这个变量,在arch/x86/kernel/cpu/common.c中有:
|
||||
|
||||
```
|
||||
DEFINE_PER_CPU(struct task_struct *, current_task) = &init_task;
|
||||
|
||||
```
|
||||
|
||||
也就是说,系统刚刚初始化的时候,current_task都指向init_task。
|
||||
|
||||
当某个CPU上的进程进行切换的时候,current_task被修改为将要切换到的目标进程。例如,进程切换函数__switch_to就会改变current_task。
|
||||
|
||||
```
|
||||
__visible __notrace_funcgraph struct task_struct *
|
||||
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
|
||||
{
|
||||
......
|
||||
this_cpu_write(current_task, next_p);
|
||||
......
|
||||
return prev_p;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当要获取当前的运行中的task_struct的时候,就需要调用this_cpu_read_stable进行读取。
|
||||
|
||||
```
|
||||
#define this_cpu_read_stable(var) percpu_stable_op("mov", var)
|
||||
|
||||
```
|
||||
|
||||
好了,现在如果你是一个进程,正在某个CPU上运行,就能够轻松得到task_struct了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节虽然只介绍了内核栈,但是内容更加重要。如果说task_struct的其他成员变量都是和进程管理有关的,内核栈是和进程运行有关系的。
|
||||
|
||||
我这里画了一张图总结一下32位和64位的工作模式,左边是32位的,右边是64位的。
|
||||
|
||||
<li>
|
||||
在用户态,应用程序进行了至少一次函数调用。32位和64的传递参数的方式稍有不同,32位的就是用函数栈,64位的前6个参数用寄存器,其他的用函数栈。
|
||||
</li>
|
||||
<li>
|
||||
在内核态,32位和64位都使用内核栈,格式也稍有不同,主要集中在pt_regs结构上。
|
||||
</li>
|
||||
<li>
|
||||
在内核态,32位和64位的内核栈和task_struct的关联关系不同。32位主要靠thread_info,64位主要靠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="">
|
||||
441
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/15 | 调度(上):如何制定项目管理流程?.md
Normal file
441
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/15 | 调度(上):如何制定项目管理流程?.md
Normal 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;
|
||||
|
||||
```
|
||||
|
||||
优先级其实就是一个数值,对于实时进程,优先级的范围是0~99;对于普通进程,优先级的范围是100~139。数值越小,优先级越高。从这里可以看出,所有的实时进程都比普通进程优先级要高。毕竟,谁让人家加钱了呢。
|
||||
|
||||
### 实时调度策略
|
||||
|
||||
对于调度策略,其中SCHED_FIFO、SCHED_RR、SCHED_DEADLINE是实时进程的调度策略。
|
||||
|
||||
虽然大家都是加钱加急的项目,但是也不能乱来,还是需要有个办事流程才行。
|
||||
|
||||
例如,**SCHED_FIFO**就是交了相同钱的,先来先服务,但是有的加钱多,可以分配更高的优先级,也就是说,高优先级的进程可以抢占低优先级的进程,而相同优先级的进程,我们遵循先来先得。
|
||||
|
||||
另外一种策略是,交了相同钱的,轮换着来,这就是**SCHED_RR轮流调度算法**,采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,而高优先级的任务也是可以抢占低优先级的任务。
|
||||
|
||||
还有一种新的策略是**SCHED_DEADLINE**,是按照任务的deadline进行调度的。当产生一个调度点的时候,DL调度器总是选择其deadline距离当前时间点最近的那个任务,并调度它执行。
|
||||
|
||||
### 普通调度策略
|
||||
|
||||
对于普通进程的调度策略有,SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE。
|
||||
|
||||
既然大家的项目都没有那么紧急,就应该按照普通的项目流程,公平地分配人员。
|
||||
|
||||
SCHED_NORMAL是普通的进程,就相当于咱们公司接的普通项目。
|
||||
|
||||
SCHED_BATCH是后台进程,几乎不需要和前端进行交互。这有点像公司在接项目同时,开发一些可以复用的模块,作为公司的技术积累,从而使得在之后接新项目的时候,能够减少工作量。这类项目可以默默执行,不要影响需要交互的进程,可以降低它的优先级。
|
||||
|
||||
SCHED_IDLE是特别空闲的时候才跑的进程,相当于咱们学习训练类的项目,比如咱们公司很长时间没有接到外在项目了,可以弄几个这样的项目练练手。
|
||||
|
||||
上面无论是policy还是priority,都设置了一个变量,变量仅仅表示了应该这样这样干,但事情总要有人去干,谁呢?在task_struct里面,还有这样的成员变量:
|
||||
|
||||
```
|
||||
const struct sched_class *sched_class;
|
||||
|
||||
```
|
||||
|
||||
调度策略的执行逻辑,就封装在这里面,它是真正干活的那个。
|
||||
|
||||
sched_class有几种实现:
|
||||
|
||||
<li>
|
||||
stop_sched_class优先级最高的任务会使用这种策略,会中断所有其他线程,且不会被其他任务打断;
|
||||
</li>
|
||||
<li>
|
||||
dl_sched_class就对应上面的deadline调度策略;
|
||||
</li>
|
||||
<li>
|
||||
rt_sched_class就对应RR算法或者FIFO算法的调度策略,具体调度策略由进程的task_struct->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->curr;
|
||||
u64 now = rq_clock_task(rq_of(cfs_rq));
|
||||
u64 delta_exec;
|
||||
......
|
||||
delta_exec = now - curr->exec_start;
|
||||
......
|
||||
curr->exec_start = now;
|
||||
......
|
||||
curr->sum_exec_runtime += delta_exec;
|
||||
......
|
||||
curr->vruntime += calc_delta_fair(delta_exec, curr);
|
||||
update_min_vruntime(cfs_rq);
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* delta /= w
|
||||
*/
|
||||
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
|
||||
{
|
||||
if (unlikely(se->load.weight != NICE_0_LOAD))
|
||||
/* delta_exec * weight / lw.weight */
|
||||
delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
|
||||
return delta;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里得到当前的时间,以及这次的时间片开始的时间,两者相减就是这次运行的时间delta_exec ,但是得到的这个时间其实是实际运行的时间,需要做一定的转化才作为虚拟运行时间vruntime。转化方法如下:
|
||||
|
||||
这就是说,同样的实际运行时间,给高权重的算少了,低权重的算多了,但是当选取下一个运行进程的时候,还是按照最小的vruntime来的,这样高权重的获得的实际运行时间自然就多了。这就相当于给一个体重(权重)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_entity,Deadline调度实体sched_dl_entity,以及完全公平算法调度实体sched_entity。
|
||||
|
||||
看来不光CFS调度策略需要有这样一个数据结构进行排序,其他的调度策略也同样有自己的数据结构进行排序,因为任何一个策略做调度的时候,都是要区分谁先运行谁后运行。
|
||||
|
||||
而进程根据自己是实时的,还是普通的类型,通过这个成员变量,将自己挂在某一个数据结构里面,和其他的进程排序,等待被调度。如果这个进程是个普通进程,则通过sched_entity,将自己挂在这棵红黑树上。
|
||||
|
||||
对于普通进程的调度实体定义如下,这里面包含了vruntime和权重load_weight,以及对于运行时间的统计。
|
||||
|
||||
```
|
||||
struct sched_entity {
|
||||
struct load_weight load;
|
||||
struct rb_node run_node;
|
||||
struct list_head group_node;
|
||||
unsigned int on_rq;
|
||||
u64 exec_start;
|
||||
u64 sum_exec_runtime;
|
||||
u64 vruntime;
|
||||
u64 prev_sum_exec_runtime;
|
||||
u64 nr_migrations;
|
||||
struct sched_statistics statistics;
|
||||
......
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
下图是一个红黑树的例子。
|
||||
|
||||
<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->pick_next_task(rq, prev, rf);
|
||||
if (p) {
|
||||
if (unlikely(p == RETRY_TASK))
|
||||
goto again;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这就说明,调度的时候是从优先级最高的调度类到优先级低的调度类,依次执行。而对于每种调度类,有自己的实现,例如,CFS就有fair_sched_class。
|
||||
|
||||
```
|
||||
const struct sched_class fair_sched_class = {
|
||||
.next = &idle_sched_class,
|
||||
.enqueue_task = enqueue_task_fair,
|
||||
.dequeue_task = dequeue_task_fair,
|
||||
.yield_task = yield_task_fair,
|
||||
.yield_to_task = yield_to_task_fair,
|
||||
.check_preempt_curr = check_preempt_wakeup,
|
||||
.pick_next_task = pick_next_task_fair,
|
||||
.put_prev_task = put_prev_task_fair,
|
||||
.set_curr_task = set_curr_task_fair,
|
||||
.task_tick = task_tick_fair,
|
||||
.task_fork = task_fork_fair,
|
||||
.prio_changed = prio_changed_fair,
|
||||
.switched_from = switched_from_fair,
|
||||
.switched_to = switched_to_fair,
|
||||
.get_rr_interval = get_rr_interval_fair,
|
||||
.update_curr = update_curr_fair,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
对于同样的pick_next_task选取下一个要运行的任务这个动作,不同的调度类有自己的实现。fair_sched_class的实现是pick_next_task_fair,rt_sched_class的实现是pick_next_task_rt。
|
||||
|
||||
我们会发现这两个函数是操作不同的队列,pick_next_task_rt操作的是rt_rq,pick_next_task_fair操作的是cfs_rq。
|
||||
|
||||
```
|
||||
static struct task_struct *
|
||||
pick_next_task_rt(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
|
||||
{
|
||||
struct task_struct *p;
|
||||
struct rt_rq *rt_rq = &rq->rt;
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
static struct task_struct *
|
||||
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
|
||||
{
|
||||
struct cfs_rq *cfs_rq = &rq->cfs;
|
||||
struct sched_entity *se;
|
||||
struct task_struct *p;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样整个运行的场景就串起来了,在每个CPU上都有一个队列rq,这个队列里面包含多个子队列,例如rt_rq和cfs_rq,不同的队列有不同的实现方式,cfs_rq就是用红黑树实现的。
|
||||
|
||||
当有一天,某个CPU需要找下一个任务执行的时候,会按照优先级依次调用调度类,不同的调度类操作不同的队列。当然rt_sched_class先被调用,它会在rt_rq上找下一个任务,只有找不到的时候,才轮到fair_sched_class被调用,它会在cfs_rq上找下一个任务。这样保证了实时任务的优先级永远大于普通任务。
|
||||
|
||||
下面我们仔细看一下sched_class定义的与调度有关的函数。
|
||||
|
||||
<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->pick_next_entity->__pick_first_entity。
|
||||
|
||||
```
|
||||
struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
|
||||
{
|
||||
struct rb_node *left = rb_first_cached(&cfs_rq->tasks_timeline);
|
||||
|
||||
|
||||
if (!left)
|
||||
return NULL;
|
||||
|
||||
|
||||
return rb_entry(left, struct sched_entity, run_node);
|
||||
|
||||
```
|
||||
|
||||
从这个函数的实现可以看出,就是从红黑树里面取最左面的节点。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
好了,这一节我们讲了调度相关的数据结构,还是比较复杂的。一个CPU上有一个队列,CFS的队列是一棵红黑树,树的每一个节点都是一个sched_entity,每个sched_entity都属于一个task_struct,task_struct里面有指针指向这个进程属于哪个调度类。
|
||||
|
||||
<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="">
|
||||
432
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/16 | 调度(中):主动调度是如何发生的?.md
Normal file
432
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/16 | 调度(中):主动调度是如何发生的?.md
Normal 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(&root->subv_writers->wait, &wait,
|
||||
TASK_UNINTERRUPTIBLE);
|
||||
writers = percpu_counter_sum(&root->subv_writers->counter);
|
||||
if (writers)
|
||||
schedule();
|
||||
finish_wait(&root->subv_writers->wait, &wait);
|
||||
} while (writers);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
另外一个例子是,**从Tap网络设备等待一个读取**。Tap网络设备是虚拟机使用的网络设备。当没有数据到来的时候,它也需要等待,所以也会选择把CPU让给其他进程。
|
||||
|
||||
```
|
||||
static ssize_t tap_do_read(struct tap_queue *q,
|
||||
struct iov_iter *to,
|
||||
int noblock, struct sk_buff *skb)
|
||||
{
|
||||
......
|
||||
while (1) {
|
||||
if (!noblock)
|
||||
prepare_to_wait(sk_sleep(&q->sk), &wait,
|
||||
TASK_INTERRUPTIBLE);
|
||||
......
|
||||
/* Nothing to read, let's sleep */
|
||||
schedule();
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你应该知道,计算机主要处理计算、网络、存储三个方面。计算主要是CPU和内存的合作;网络和存储则多是和外部设备的合作;在操作外部设备的时候,往往需要让出CPU,就像上面两段代码一样,选择调用schedule()函数。
|
||||
|
||||
接下来,我们就来看**schedule函数的调用过程**。
|
||||
|
||||
```
|
||||
asmlinkage __visible void __sched schedule(void)
|
||||
{
|
||||
struct task_struct *tsk = current;
|
||||
|
||||
|
||||
sched_submit_work(tsk);
|
||||
do {
|
||||
preempt_disable();
|
||||
__schedule(false);
|
||||
sched_preempt_enable_no_resched();
|
||||
} while (need_resched());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段代码的主要逻辑是在__schedule函数中实现的。这个函数比较复杂,我们分几个部分来讲解。
|
||||
|
||||
```
|
||||
static void __sched notrace __schedule(bool preempt)
|
||||
{
|
||||
struct task_struct *prev, *next;
|
||||
unsigned long *switch_count;
|
||||
struct rq_flags rf;
|
||||
struct rq *rq;
|
||||
int cpu;
|
||||
|
||||
|
||||
cpu = smp_processor_id();
|
||||
rq = cpu_rq(cpu);
|
||||
prev = rq->curr;
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
首先,在当前的CPU上,我们取出任务队列rq。
|
||||
|
||||
task_struct *prev指向这个CPU的任务队列上面正在运行的那个进程curr。为啥是prev?因为一旦将来它被切换下来,那它就成了前任了。
|
||||
|
||||
接下来代码如下:
|
||||
|
||||
```
|
||||
next = pick_next_task(rq, prev, &rf);
|
||||
clear_tsk_need_resched(prev);
|
||||
clear_preempt_need_resched();
|
||||
|
||||
```
|
||||
|
||||
第二步,获取下一个任务,task_struct *next指向下一个任务,这就是**继任**。
|
||||
|
||||
pick_next_task的实现如下:
|
||||
|
||||
```
|
||||
static inline struct task_struct *
|
||||
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
|
||||
{
|
||||
const struct sched_class *class;
|
||||
struct task_struct *p;
|
||||
/*
|
||||
* Optimization: we know that if all tasks are in the fair class we can call that function directly, but only if the @prev task wasn't of a higher scheduling class, because otherwise those loose the opportunity to pull in more work from other CPUs.
|
||||
*/
|
||||
if (likely((prev->sched_class == &idle_sched_class ||
|
||||
prev->sched_class == &fair_sched_class) &&
|
||||
rq->nr_running == rq->cfs.h_nr_running)) {
|
||||
p = fair_sched_class.pick_next_task(rq, prev, rf);
|
||||
if (unlikely(p == RETRY_TASK))
|
||||
goto again;
|
||||
/* Assumes fair_sched_class->next == idle_sched_class */
|
||||
if (unlikely(!p))
|
||||
p = idle_sched_class.pick_next_task(rq, prev, rf);
|
||||
return p;
|
||||
}
|
||||
again:
|
||||
for_each_class(class) {
|
||||
p = class->pick_next_task(rq, prev, rf);
|
||||
if (p) {
|
||||
if (unlikely(p == RETRY_TASK))
|
||||
goto again;
|
||||
return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们来看again这里,就是咱们上一节讲的依次调用调度类。但是这里有了一个优化,因为大部分进程是普通进程,所以大部分情况下会调用上面的逻辑,调用的就是fair_sched_class.pick_next_task。
|
||||
|
||||
根据上一节对于fair_sched_class的定义,它调用的是pick_next_task_fair,代码如下:
|
||||
|
||||
```
|
||||
static struct task_struct *
|
||||
pick_next_task_fair(struct rq *rq, struct task_struct *prev, struct rq_flags *rf)
|
||||
{
|
||||
struct cfs_rq *cfs_rq = &rq->cfs;
|
||||
struct sched_entity *se;
|
||||
struct task_struct *p;
|
||||
int new_tasks;
|
||||
|
||||
```
|
||||
|
||||
对于CFS调度类,取出相应的队列cfs_rq,这就是我们上一节讲的那棵红黑树。
|
||||
|
||||
```
|
||||
struct sched_entity *curr = cfs_rq->curr;
|
||||
if (curr) {
|
||||
if (curr->on_rq)
|
||||
update_curr(cfs_rq);
|
||||
else
|
||||
curr = NULL;
|
||||
......
|
||||
}
|
||||
se = pick_next_entity(cfs_rq, curr);
|
||||
|
||||
```
|
||||
|
||||
取出当前正在运行的任务curr,如果依然是可运行的状态,也即处于进程就绪状态,则调用update_curr更新vruntime。update_curr咱们上一节就见过了,它会根据实际运行时间算出vruntime来。
|
||||
|
||||
接着,pick_next_entity从红黑树里面,取最左边的一个节点。这个函数的实现我们上一节也讲过了。
|
||||
|
||||
```
|
||||
p = task_of(se);
|
||||
|
||||
|
||||
if (prev != p) {
|
||||
struct sched_entity *pse = &prev->se;
|
||||
......
|
||||
put_prev_entity(cfs_rq, pse);
|
||||
set_next_entity(cfs_rq, se);
|
||||
}
|
||||
|
||||
|
||||
return p
|
||||
|
||||
```
|
||||
|
||||
task_of得到下一个调度实体对应的task_struct,如果发现继任和前任不一样,这就说明有一个更需要运行的进程了,就需要更新红黑树了。前面前任的vruntime更新过了,put_prev_entity放回红黑树,会找到相应的位置,然后set_next_entity将继任者设为当前任务。
|
||||
|
||||
第三步,当选出的继任者和前任不同,就要进行上下文切换,继任者进程正式进入运行。
|
||||
|
||||
```
|
||||
if (likely(prev != next)) {
|
||||
rq->nr_switches++;
|
||||
rq->curr = next;
|
||||
++*switch_count;
|
||||
......
|
||||
rq = context_switch(rq, prev, next, &rf);
|
||||
|
||||
```
|
||||
|
||||
## 进程上下文切换
|
||||
|
||||
上下文切换主要干两件事情,一是切换进程空间,也即虚拟内存;二是切换寄存器和CPU上下文。
|
||||
|
||||
我们先来看context_switch的实现。
|
||||
|
||||
```
|
||||
/*
|
||||
* context_switch - switch to the new MM and the new thread's register state.
|
||||
*/
|
||||
static __always_inline struct rq *
|
||||
context_switch(struct rq *rq, struct task_struct *prev,
|
||||
struct task_struct *next, struct rq_flags *rf)
|
||||
{
|
||||
struct mm_struct *mm, *oldmm;
|
||||
......
|
||||
mm = next->mm;
|
||||
oldmm = prev->active_mm;
|
||||
......
|
||||
switch_mm_irqs_off(oldmm, mm, next);
|
||||
......
|
||||
/* Here we just switch the register state and the stack. */
|
||||
switch_to(prev, next, prev);
|
||||
barrier();
|
||||
return finish_task_switch(prev);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里首先是内存空间的切换,里面涉及内存管理的内容比较多。内存管理后面我们会有专门的章节来讲,这里你先知道有这么一回事就行了。
|
||||
|
||||
接下来,我们看switch_to。它就是寄存器和栈的切换,它调用到了__switch_to_asm。这是一段汇编代码,主要用于栈的切换。
|
||||
|
||||
对于32位操作系统来讲,切换的是栈顶指针esp。
|
||||
|
||||
```
|
||||
/*
|
||||
* %eax: prev task
|
||||
* %edx: next task
|
||||
*/
|
||||
ENTRY(__switch_to_asm)
|
||||
......
|
||||
/* switch stack */
|
||||
movl %esp, TASK_threadsp(%eax)
|
||||
movl TASK_threadsp(%edx), %esp
|
||||
......
|
||||
jmp __switch_to
|
||||
END(__switch_to_asm)
|
||||
|
||||
```
|
||||
|
||||
对于64位操作系统来讲,切换的是栈顶指针rsp。
|
||||
|
||||
```
|
||||
/*
|
||||
* %rdi: prev task
|
||||
* %rsi: next task
|
||||
*/
|
||||
ENTRY(__switch_to_asm)
|
||||
......
|
||||
/* switch stack */
|
||||
movq %rsp, TASK_threadsp(%rdi)
|
||||
movq TASK_threadsp(%rsi), %rsp
|
||||
......
|
||||
jmp __switch_to
|
||||
END(__switch_to_asm)
|
||||
|
||||
```
|
||||
|
||||
最终,都返回了__switch_to这个函数。这个函数对于32位和64位操作系统虽然有不同的实现,但里面做的事情是差不多的。所以我这里仅仅列出64位操作系统做的事情。
|
||||
|
||||
```
|
||||
__visible __notrace_funcgraph struct task_struct *
|
||||
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
|
||||
{
|
||||
struct thread_struct *prev = &prev_p->thread;
|
||||
struct thread_struct *next = &next_p->thread;
|
||||
......
|
||||
int cpu = smp_processor_id();
|
||||
struct tss_struct *tss = &per_cpu(cpu_tss, cpu);
|
||||
......
|
||||
load_TLS(next, cpu);
|
||||
......
|
||||
this_cpu_write(current_task, next_p);
|
||||
|
||||
|
||||
/* Reload esp0 and ss1. This changes current_thread_info(). */
|
||||
load_sp0(tss, next);
|
||||
......
|
||||
return prev_p;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面有一个Per CPU的结构体tss。这是个什么呢?
|
||||
|
||||
在x86体系结构中,提供了一种以硬件的方式进行进程切换的模式,对于每个进程,x86希望在内存里面维护一个TSS(Task State Segment,任务状态段)结构。这里面有所有的寄存器。
|
||||
|
||||
另外,还有一个特殊的寄存器TR(Task Register,任务寄存器),指向某个进程的TSS。更改TR的值,将会触发硬件保存CPU所有寄存器的值到当前进程的TSS中,然后从新进程的TSS中读出所有寄存器值,加载到CPU对应的寄存器中。
|
||||
|
||||
下图就是32位的TSS结构。
|
||||
|
||||
<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 = &per_cpu(cpu_tss, cpu);
|
||||
......
|
||||
load_sp0(t, thread);
|
||||
set_tss_desc(cpu, t);
|
||||
load_TR_desc();
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
struct tss_struct {
|
||||
/*
|
||||
* The hardware state:
|
||||
*/
|
||||
struct x86_hw_tss x86_tss;
|
||||
unsigned long io_bitmap[IO_BITMAP_LONGS + 1];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在Linux中,真的参与进程切换的寄存器很少,主要的就是栈顶寄存器。
|
||||
|
||||
于是,在task_struct里面,还有一个我们原来没有注意的成员变量thread。这里面保留了要切换进程的时候需要修改的寄存器。
|
||||
|
||||
```
|
||||
/* CPU-specific state of this task: */
|
||||
struct thread_struct thread;
|
||||
|
||||
```
|
||||
|
||||
所谓的进程切换,就是将某个进程的thread_struct里面的寄存器的值,写入到CPU的TR指向的tss_struct,对于CPU来讲,这就算是完成了切换。
|
||||
|
||||
例如__switch_to中的load_sp0,就是将下一个进程的thread_struct的sp0的值加载到tss_struct里面去。
|
||||
|
||||
## 指令指针的保存与恢复
|
||||
|
||||
你是不是觉得,这样真的就完成切换了吗?是的,不信我们来**盘点**一下。
|
||||
|
||||
从进程A切换到进程B,用户栈要不要切换呢?当然要,其实早就已经切换了,就在切换内存空间的时候。每个进程的用户栈都是独立的,都在内存空间里面。
|
||||
|
||||
那内核栈呢?已经在__switch_to里面切换了,也就是将current_task指向当前的task_struct。里面的void *stack指针,指向的就是当前的内核栈。
|
||||
|
||||
内核栈的栈顶指针呢?在__switch_to_asm里面已经切换了栈顶指针,并且将栈顶指针在__switch_to加载到了TSS里面。
|
||||
|
||||
用户栈的栈顶指针呢?如果当前在内核里面的话,它当然是在内核栈顶部的pt_regs结构里面呀。当从内核返回用户态运行的时候,pt_regs里面有所有当时在用户态的时候运行的上下文信息,就可以开始运行了。
|
||||
|
||||
唯一让人不容易理解的是指令指针寄存器,它应该指向下一条指令的,那它是如何切换的呢?这里有点绕,请你仔细看。
|
||||
|
||||
这里我先明确一点,进程的调度都最终会调用到__schedule函数。为了方便你记住,我姑且给它起个名字,就叫“**进程调度第一定律**”。后面我们会多次用到这个定律,你一定要记住。
|
||||
|
||||
我们用最前面的例子仔细分析这个过程。本来一个进程A在用户态是要写一个文件的,写文件的操作用户态没办法完成,就要通过系统调用到达内核态。在这个切换的过程中,用户态的指令指针寄存器是保存在pt_regs里面的,到了内核态,就开始沿着写文件的逻辑一步一步执行,结果发现需要等待,于是就调用__schedule函数。
|
||||
|
||||
这个时候,进程A在内核态的指令指针是指向__schedule了。这里请记住,A进程的内核栈会保存这个__schedule的调用,而且知道这是从btrfs_wait_for_no_snapshoting_writes这个函数里面进去的。
|
||||
|
||||
__schedule里面经过上面的层层调用,到达了context_switch的最后三行指令(其中barrier语句是一个编译器指令,用于保证switch_to和finish_task_switch的执行顺序,不会因为编译阶段优化而改变,这里咱们可以忽略它)。
|
||||
|
||||
```
|
||||
switch_to(prev, next, prev);
|
||||
barrier();
|
||||
return finish_task_switch(prev);
|
||||
|
||||
```
|
||||
|
||||
当进程A在内核里面执行switch_to的时候,内核态的指令指针也是指向这一行的。但是在switch_to里面,将寄存器和栈都切换到成了进程B的,唯一没有变的就是指令指针寄存器。当switch_to返回的时候,指令指针寄存器指向了下一条语句finish_task_switch。
|
||||
|
||||
但这个时候的finish_task_switch已经不是进程A的finish_task_switch了,而是进程B的finish_task_switch了。
|
||||
|
||||
这样合理吗?你怎么知道进程B当时被切换下去的时候,执行到哪里了?恢复B进程执行的时候一定在这里呢?这时候就要用到咱的“进程调度第一定律”了。
|
||||
|
||||
当年B进程被别人切换走的时候,也是调用__schedule,也是调用到switch_to,被切换成为C进程的,所以,B进程当年的下一个指令也是finish_task_switch,这就说明指令指针指到这里是没有错的。
|
||||
|
||||
接下来,我们要从finish_task_switch完毕后,返回__schedule的调用了。返回到哪里呢?按照函数返回的原理,当然是从内核栈里面去找,是返回到btrfs_wait_for_no_snapshoting_writes吗?当然不是了,因为btrfs_wait_for_no_snapshoting_writes是在A进程的内核栈里面的,它早就被切换走了,应该从B进程的内核栈里面找。
|
||||
|
||||
假设,B就是最前面例子里面调用tap_do_read读网卡的进程。它当年调用__schedule的时候,是从tap_do_read这个函数调用进去的。
|
||||
|
||||
当然,B进程的内核栈里面放的是tap_do_read。于是,从__schedule返回之后,当然是接着tap_do_read运行,然后在内核运行完毕后,返回用户态。这个时候,B进程内核栈的pt_regs也保存了用户态的指令指针寄存器,就接着在用户态的下一条指令开始运行就可以了。
|
||||
|
||||
假设,我们只有一个CPU,从B切换到C,从C又切换到A。在C切换到A的时候,还是按照“进程调度第一定律”,C进程还是会调用__schedule到达switch_to,在里面切换成为A的内核栈,然后运行finish_task_switch。
|
||||
|
||||
这个时候运行的finish_task_switch,才是A进程的finish_task_switch。运行完毕从__schedule返回的时候,从内核栈上才知道,当年是从btrfs_wait_for_no_snapshoting_writes调用进去的,因而应该返回btrfs_wait_for_no_snapshoting_writes继续执行,最后内核执行完毕返回用户态,同样恢复pt_regs,恢复用户态的指令指针寄存器,从用户态接着运行。
|
||||
|
||||
到这里你是不是有点理解为什么switch_to有三个参数呢?为啥有两个prev呢?其实我们从定义就可以看到。
|
||||
|
||||
```
|
||||
#define switch_to(prev, next, last) \
|
||||
do { \
|
||||
prepare_switch_to(prev, next); \
|
||||
\
|
||||
((last) = __switch_to_asm((prev), (next))); \
|
||||
} while (0)
|
||||
|
||||
```
|
||||
|
||||
在上面的例子中,A切换到B的时候,运行到__switch_to_asm这一行的时候,是在A的内核栈上运行的,prev是A,next是B。但是,A执行完__switch_to_asm之后就被切换走了,当C再次切换到A的时候,运行到__switch_to_asm,是从C的内核栈运行的。这个时候,prev是C,next是A,但是__switch_to_asm里面切换成为了A当时的内核栈。
|
||||
|
||||
还记得当年的场景“prev是A,next是B”,__switch_to_asm里面return prev的时候,还没return的时候,prev这个变量里面放的还是C,因而它会把C放到返回结果中。但是,一旦return,就会弹出A当时的内核栈。这个时候,prev变量就变成了A,next变量就变成了B。这就还原了当年的场景,好在返回值里面的last还是C。
|
||||
|
||||
通过三个变量switch_to(prev = A, next=B, last=C),A进程就明白了,我当时被切换走的时候,是切换成B,这次切换回来,是从C回来的。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节我们讲主动调度的过程,也即一个运行中的进程主动调用__schedule让出CPU。在__schedule里面会做两件事情,第一是选取下一个进程,第二是进行上下文切换。而上下文切换又分用户态进程空间的切换和内核态的切换。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/64/9f4433e82c78ed5cd4399b4b116a9064.png" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
你知道应该用什么命令查看进程的运行时间和上下文切换次数吗?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
|
||||
270
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/17 | 调度(下):抢占式调度是如何发生的?.md
Normal file
270
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/17 | 调度(下):抢占式调度是如何发生的?.md
Normal 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->curr;
|
||||
......
|
||||
curr->sched_class->task_tick(rq, curr, 0);
|
||||
cpu_load_update_active(rq);
|
||||
calc_global_load_tick(rq);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个函数先取出当前CPU的运行队列,然后得到这个队列上当前正在运行中的进程的task_struct,然后调用这个task_struct的调度类的task_tick函数,顾名思义这个函数就是来处理时钟事件的。
|
||||
|
||||
如果当前运行的进程是普通进程,调度类为fair_sched_class,调用的处理时钟的函数为task_tick_fair。我们来看一下它的实现。
|
||||
|
||||
```
|
||||
static void task_tick_fair(struct rq *rq, struct task_struct *curr, int queued)
|
||||
{
|
||||
struct cfs_rq *cfs_rq;
|
||||
struct sched_entity *se = &curr->se;
|
||||
|
||||
|
||||
for_each_sched_entity(se) {
|
||||
cfs_rq = cfs_rq_of(se);
|
||||
entity_tick(cfs_rq, se, queued);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
根据当前进程的task_struct,找到对应的调度实体sched_entity和cfs_rq队列,调用entity_tick。
|
||||
|
||||
```
|
||||
static void
|
||||
entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr, int queued)
|
||||
{
|
||||
update_curr(cfs_rq);
|
||||
update_load_avg(curr, UPDATE_TG);
|
||||
update_cfs_shares(curr);
|
||||
.....
|
||||
if (cfs_rq->nr_running > 1)
|
||||
check_preempt_tick(cfs_rq, curr);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在entity_tick里面,我们又见到了熟悉的update_curr。它会更新当前进程的vruntime,然后调用check_preempt_tick。顾名思义就是,检查是否是时候被抢占了。
|
||||
|
||||
```
|
||||
static void
|
||||
check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
|
||||
{
|
||||
unsigned long ideal_runtime, delta_exec;
|
||||
struct sched_entity *se;
|
||||
s64 delta;
|
||||
|
||||
|
||||
ideal_runtime = sched_slice(cfs_rq, curr);
|
||||
delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
|
||||
if (delta_exec > ideal_runtime) {
|
||||
resched_curr(rq_of(cfs_rq));
|
||||
return;
|
||||
}
|
||||
......
|
||||
se = __pick_first_entity(cfs_rq);
|
||||
delta = curr->vruntime - se->vruntime;
|
||||
if (delta < 0)
|
||||
return;
|
||||
if (delta > ideal_runtime)
|
||||
resched_curr(rq_of(cfs_rq));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
check_preempt_tick先是调用sched_slice函数计算出的ideal_runtime。ideal_runtime是一个调度周期中,该进程运行的实际时间。
|
||||
|
||||
sum_exec_runtime指进程总共执行的实际时间,prev_sum_exec_runtime指上次该进程被调度时已经占用的实际时间。每次在调度一个新的进程时都会把它的se->prev_sum_exec_runtime = se->sum_exec_runtime,所以sum_exec_runtime-prev_sum_exec_runtime就是这次调度占用实际时间。如果这个时间大于ideal_runtime,则应该被抢占了。
|
||||
|
||||
除了这个条件之外,还会通过__pick_first_entity取出红黑树中最小的进程。如果当前进程的vruntime大于红黑树中最小的进程的vruntime,且差值大于ideal_runtime,也应该被抢占了。
|
||||
|
||||
当发现当前进程应该被抢占,不能直接把它踢下来,而是把它标记为应该被抢占。为什么呢?因为进程调度第一定律呀,一定要等待正在运行的进程调用__schedule才行啊,所以这里只能先标记一下。
|
||||
|
||||
标记一个进程应该被抢占,都是调用resched_curr,它会调用set_tsk_need_resched,标记进程应该被抢占,但是此时此刻,并不真的抢占,而是打上一个标签TIF_NEED_RESCHED。
|
||||
|
||||
```
|
||||
static inline void set_tsk_need_resched(struct task_struct *tsk)
|
||||
{
|
||||
set_tsk_thread_flag(tsk,TIF_NEED_RESCHED);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
另外一个可能抢占的场景是**当一个进程被唤醒的时候**。
|
||||
|
||||
我们前面说过,当一个进程在等待一个I/O的时候,会主动放弃CPU。但是当I/O到来的时候,进程往往会被唤醒。这个时候是一个时机。当被唤醒的进程优先级高于CPU上的当前进程,就会触发抢占。try_to_wake_up()调用ttwu_queue将这个唤醒的任务添加到队列当中。ttwu_queue再调用ttwu_do_activate激活这个任务。ttwu_do_activate调用ttwu_do_wakeup。这里面调用了check_preempt_curr检查是否应该发生抢占。如果应该发生抢占,也不是直接踢走当前进程,而是将当前进程标记为应该被抢占。
|
||||
|
||||
```
|
||||
static void ttwu_do_wakeup(struct rq *rq, struct task_struct *p, int wake_flags,
|
||||
struct rq_flags *rf)
|
||||
{
|
||||
check_preempt_curr(rq, p, wake_flags);
|
||||
p->state = TASK_RUNNING;
|
||||
trace_sched_wakeup(p);
|
||||
|
||||
```
|
||||
|
||||
到这里,你会发现,抢占问题只做完了一半。就是标识当前运行中的进程应该被抢占了,但是真正的抢占动作并没有发生。
|
||||
|
||||
## 抢占的时机
|
||||
|
||||
真正的抢占还需要时机,也就是需要那么一个时刻,让正在运行中的进程有机会调用一下__schedule。
|
||||
|
||||
你可以想象,不可能某个进程代码运行着,突然要去调用__schedule,代码里面不可能这么写,所以一定要规划几个时机,这个时机分为用户态和内核态。
|
||||
|
||||
### 用户态的抢占时机
|
||||
|
||||
对于用户态的进程来讲,从系统调用中返回的那个时刻,是一个被抢占的时机。
|
||||
|
||||
前面讲系统调用的时候,64位的系统调用的链路位do_syscall_64->syscall_return_slowpath->prepare_exit_to_usermode->exit_to_usermode_loop,当时我们还没关注exit_to_usermode_loop这个函数,现在我们来看一下。
|
||||
|
||||
```
|
||||
static void exit_to_usermode_loop(struct pt_regs *regs, u32 cached_flags)
|
||||
{
|
||||
while (true) {
|
||||
/* We have work to do. */
|
||||
local_irq_enable();
|
||||
|
||||
|
||||
if (cached_flags & _TIF_NEED_RESCHED)
|
||||
schedule();
|
||||
......
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
现在我们看到在exit_to_usermode_loop函数中,上面打的标记起了作用,如果被打了_TIF_NEED_RESCHED,调用schedule进行调度,调用的过程和上一节解析的一样,会选择一个进程让出CPU,做上下文切换。
|
||||
|
||||
对于用户态的进程来讲,从中断中返回的那个时刻,也是一个被抢占的时机。
|
||||
|
||||
在arch/x86/entry/entry_64.S中有中断的处理过程。又是一段汇编语言代码,你重点领会它的意思就行,不要纠结每一行都看懂。
|
||||
|
||||
```
|
||||
common_interrupt:
|
||||
ASM_CLAC
|
||||
addq $-0x80, (%rsp)
|
||||
interrupt do_IRQ
|
||||
ret_from_intr:
|
||||
popq %rsp
|
||||
testb $3, CS(%rsp)
|
||||
jz retint_kernel
|
||||
/* Interrupt came from user space */
|
||||
GLOBAL(retint_user)
|
||||
mov %rsp,%rdi
|
||||
call prepare_exit_to_usermode
|
||||
TRACE_IRQS_IRETQ
|
||||
SWAPGS
|
||||
jmp restore_regs_and_iret
|
||||
/* Returning to kernel space */
|
||||
retint_kernel:
|
||||
#ifdef CONFIG_PREEMPT
|
||||
bt $9, EFLAGS(%rsp)
|
||||
jnc 1f
|
||||
0: cmpl $0, PER_CPU_VAR(__preempt_count)
|
||||
jnz 1f
|
||||
call preempt_schedule_irq
|
||||
jmp 0b
|
||||
|
||||
```
|
||||
|
||||
中断处理调用的是do_IRQ函数,中断完毕后分为两种情况,一个是返回用户态,一个是返回内核态。这个通过注释也能看出来。
|
||||
|
||||
咱们先来看返回用户态这一部分,先不管返回内核态的那部分代码,retint_user会调用prepare_exit_to_usermode,最终调用exit_to_usermode_loop,和上面的逻辑一样,发现有标记则调用schedule()。
|
||||
|
||||
### 内核态的抢占时机
|
||||
|
||||
用户态的抢占时机讲完了,接下来我们看内核态的抢占时机。
|
||||
|
||||
对内核态的执行中,被抢占的时机一般发生在preempt_enable()中。
|
||||
|
||||
在内核态的执行中,有的操作是不能被中断的,所以在进行这些操作之前,总是先调用preempt_disable()关闭抢占,当再次打开的时候,就是一次内核态代码被抢占的机会。
|
||||
|
||||
就像下面代码中展示的一样,preempt_enable()会调用preempt_count_dec_and_test(),判断preempt_count和TIF_NEED_RESCHED是否可以被抢占。如果可以,就调用preempt_schedule->preempt_schedule_common->__schedule进行调度。还是满足进程调度第一定律的。
|
||||
|
||||
```
|
||||
#define preempt_enable() \
|
||||
do { \
|
||||
if (unlikely(preempt_count_dec_and_test())) \
|
||||
__preempt_schedule(); \
|
||||
} while (0)
|
||||
|
||||
|
||||
#define preempt_count_dec_and_test() \
|
||||
({ preempt_count_sub(1); should_resched(0); })
|
||||
|
||||
|
||||
static __always_inline bool should_resched(int preempt_offset)
|
||||
{
|
||||
return unlikely(preempt_count() == preempt_offset &&
|
||||
tif_need_resched());
|
||||
}
|
||||
|
||||
|
||||
#define tif_need_resched() test_thread_flag(TIF_NEED_RESCHED)
|
||||
|
||||
|
||||
static void __sched notrace preempt_schedule_common(void)
|
||||
{
|
||||
do {
|
||||
......
|
||||
__schedule(true);
|
||||
......
|
||||
} while (need_resched())
|
||||
|
||||
```
|
||||
|
||||
在内核态也会遇到中断的情况,当中断返回的时候,返回的仍然是内核态。这个时候也是一个执行抢占的时机,现在我们再来上面中断返回的代码中返回内核的那部分代码,调用的是preempt_schedule_irq。
|
||||
|
||||
```
|
||||
asmlinkage __visible void __sched preempt_schedule_irq(void)
|
||||
{
|
||||
......
|
||||
do {
|
||||
preempt_disable();
|
||||
local_irq_enable();
|
||||
__schedule(true);
|
||||
local_irq_disable();
|
||||
sched_preempt_enable_no_resched();
|
||||
} while (need_resched());
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
preempt_schedule_irq调用__schedule进行调度。还是满足进程调度第一定律的。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
好了,抢占式调度就讲到这里了。我这里画了一张脑图,将整个进程的调度体系都放在里面。
|
||||
|
||||
这个脑图里面第一条就是总结了进程调度第一定律的核心函数__schedule的执行过程,这是上一节讲的,因为要切换的东西比较多,需要你详细了解每一部分是如何切换的。
|
||||
|
||||
第二条总结了标记为可抢占的场景,第三条是所有的抢占发生的时机,这里是真正验证了进程调度第一定律的。
|
||||
|
||||
<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="">
|
||||
320
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/18 | 进程的创建:如何发起一个新项目?.md
Normal file
320
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/18 | 进程的创建:如何发起一个新项目?.md
Normal 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 & 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->cred = p->real_cred = get_cred(new),将新进程的“我能操作谁”和“谁能操作我”两个权限都指向新的cred。
|
||||
</li>
|
||||
|
||||
接下来,copy_process重新设置进程运行的统计量。
|
||||
|
||||
```
|
||||
p->utime = p->stime = p->gtime = 0;
|
||||
p->start_time = ktime_get_ns();
|
||||
p->real_start_time = ktime_get_boot_ns();
|
||||
|
||||
```
|
||||
|
||||
接下来,copy_process开始设置调度相关的变量。
|
||||
|
||||
```
|
||||
retval = sched_fork(clone_flags, p);
|
||||
|
||||
```
|
||||
|
||||
sched_fork主要做了下面几件事情:
|
||||
|
||||
<li>
|
||||
调用__sched_fork,在这里面将on_rq设为0,初始化sched_entity,将里面的exec_start、sum_exec_runtime、prev_sum_exec_runtime、vruntime都设为0。你还记得吗,这几个变量涉及进程的实际运行时间和虚拟运行时间。是否到时间应该被调度了,就靠它们几个;
|
||||
</li>
|
||||
<li>
|
||||
设置进程的状态p->state = TASK_NEW;
|
||||
</li>
|
||||
<li>
|
||||
初始化优先级prio、normal_prio、static_prio;
|
||||
</li>
|
||||
<li>
|
||||
设置调度类,如果是普通进程,就设置为p->sched_class = &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(&p->pending);
|
||||
retval = copy_sighand(clone_flags, p);
|
||||
retval = copy_signal(clone_flags, p);
|
||||
|
||||
```
|
||||
|
||||
copy_sighand会分配一个新的sighand_struct。这里最主要的是维护信号处理函数,在copy_sighand里面会调用memcpy,将信号处理函数sighand->action从父进程复制到子进程。
|
||||
|
||||
init_sigpending和copy_signal用于初始化,并且复制用于维护发给这个进程的信号的数据结构。copy_signal函数会分配一个新的signal_struct,并进行初始化。
|
||||
|
||||
接下来,copy_process开始复制进程内存空间。
|
||||
|
||||
```
|
||||
retval = copy_mm(clone_flags, p);
|
||||
|
||||
```
|
||||
|
||||
进程都有自己的内存空间,用mm_struct结构来表示。copy_mm函数中调用dup_mm,分配一个新的mm_struct结构,调用memcpy复制这个结构。dup_mmap用于复制内存空间中内存映射的部分。前面讲系统调用的时候,我们说过,mmap可以分配大块的内存,其实mmap也可以将一个文件映射到内存中,方便可以像读写内存一样读写文件,这个在内存管理那节我们讲。
|
||||
|
||||
接下来,copy_process开始分配pid,设置tid,group_leader,并且建立进程之间的亲缘关系。
|
||||
|
||||
```
|
||||
INIT_LIST_HEAD(&p->children);
|
||||
INIT_LIST_HEAD(&p->sibling);
|
||||
......
|
||||
p->pid = pid_nr(pid);
|
||||
if (clone_flags & CLONE_THREAD) {
|
||||
p->exit_signal = -1;
|
||||
p->group_leader = current->group_leader;
|
||||
p->tgid = current->tgid;
|
||||
} else {
|
||||
if (clone_flags & CLONE_PARENT)
|
||||
p->exit_signal = current->group_leader->exit_signal;
|
||||
else
|
||||
p->exit_signal = (clone_flags & CSIGNAL);
|
||||
p->group_leader = p;
|
||||
p->tgid = p->pid;
|
||||
}
|
||||
......
|
||||
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
|
||||
p->real_parent = current->real_parent;
|
||||
p->parent_exec_id = current->parent_exec_id;
|
||||
} else {
|
||||
p->real_parent = current;
|
||||
p->parent_exec_id = current->self_exec_id;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
好了,copy_process要结束了,上面图中的组件也初始化的差不多了。
|
||||
|
||||
## fork的第二件大事:唤醒新进程
|
||||
|
||||
_do_fork做的第二件大事是wake_up_new_task。新任务刚刚建立,有没有机会抢占别人,获得CPU呢?
|
||||
|
||||
```
|
||||
void wake_up_new_task(struct task_struct *p)
|
||||
{
|
||||
struct rq_flags rf;
|
||||
struct rq *rq;
|
||||
......
|
||||
p->state = TASK_RUNNING;
|
||||
......
|
||||
activate_task(rq, p, ENQUEUE_NOCLOCK);
|
||||
p->on_rq = TASK_ON_RQ_QUEUED;
|
||||
trace_sched_wakeup_new(p);
|
||||
check_preempt_curr(rq, p, WF_FORK);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
首先,我们需要将进程的状态设置为TASK_RUNNING。
|
||||
|
||||
activate_task函数中会调用enqueue_task。
|
||||
|
||||
```
|
||||
static inline void enqueue_task(struct rq *rq, struct task_struct *p, int flags)
|
||||
{
|
||||
.....
|
||||
p->sched_class->enqueue_task(rq, p, flags);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果是CFS的调度类,则执行相应的enqueue_task_fair。
|
||||
|
||||
```
|
||||
static void
|
||||
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
|
||||
{
|
||||
struct cfs_rq *cfs_rq;
|
||||
struct sched_entity *se = &p->se;
|
||||
......
|
||||
cfs_rq = cfs_rq_of(se);
|
||||
enqueue_entity(cfs_rq, se, flags);
|
||||
......
|
||||
cfs_rq->h_nr_running++;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在enqueue_task_fair中取出的队列就是cfs_rq,然后调用enqueue_entity。
|
||||
|
||||
在enqueue_entity函数里面,会调用update_curr,更新运行的统计量,然后调用__enqueue_entity,将sched_entity加入到红黑树里面,然后将se->on_rq = 1设置在队列上。
|
||||
|
||||
回到enqueue_task_fair后,将这个队列上运行的进程数目加一。然后,wake_up_new_task会调用check_preempt_curr,看是否能够抢占当前进程。
|
||||
|
||||
在check_preempt_curr中,会调用相应的调度类的rq->curr->sched_class->check_preempt_curr(rq, p, flags)。对于CFS调度类来讲,调用的是check_preempt_wakeup。
|
||||
|
||||
```
|
||||
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p, int wake_flags)
|
||||
{
|
||||
struct task_struct *curr = rq->curr;
|
||||
struct sched_entity *se = &curr->se, *pse = &p->se;
|
||||
struct cfs_rq *cfs_rq = task_cfs_rq(curr);
|
||||
......
|
||||
if (test_tsk_need_resched(curr))
|
||||
return;
|
||||
......
|
||||
find_matching_se(&se, &pse);
|
||||
update_curr(cfs_rq_of(se));
|
||||
if (wakeup_preempt_entity(se, pse) == 1) {
|
||||
goto preempt;
|
||||
}
|
||||
return;
|
||||
preempt:
|
||||
resched_curr(rq);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在check_preempt_wakeup函数中,前面调用task_fork_fair的时候,设置sysctl_sched_child_runs_first了,已经将当前父进程的TIF_NEED_RESCHED设置了,则直接返回。
|
||||
|
||||
否则,check_preempt_wakeup还是会调用update_curr更新一次统计量,然后wakeup_preempt_entity将父进程和子进程PK一次,看是不是要抢占,如果要则调用resched_curr标记父进程为TIF_NEED_RESCHED。
|
||||
|
||||
如果新创建的进程应该抢占父进程,在什么时间抢占呢?别忘了fork是一个系统调用,从系统调用返回的时候,是抢占的一个好时机,如果父进程判断自己已经被设置为TIF_NEED_RESCHED,就让子进程先跑,抢占自己。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
好了,fork系统调用的过程咱们就解析完了。它包含两个重要的事件,一个是将task_struct结构复制一份并且初始化,另一个是试图唤醒新创建的子进程。
|
||||
|
||||
这个过程我画了一张图,你可以对照着这张图回顾进程创建的过程。
|
||||
|
||||
这个图的上半部分是复制task_struct结构,你可以对照着右面的task_struct结构图,看这里面的成员是如何一部分一部分地被复制的。图的下半部分是唤醒新创建的子进程,如果条件满足,就会将当前进程设置应该被调度的标识位,就等着当前进程执行__schedule了。
|
||||
|
||||
<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="">
|
||||
489
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/19 | 线程的创建:如何执行一个新子项目?.md
Normal file
489
极客时间专栏/趣谈Linux操作系统/核心原理篇:第三部分 进程管理/19 | 线程的创建:如何执行一个新子项目?.md
Normal 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 = &default_attr;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,就像在内核里一样,每一个进程或者线程都有一个task_struct结构,在用户态也有一个用于维护线程的结构,就是这个pthread结构。
|
||||
|
||||
```
|
||||
struct pthread *pd = NULL;
|
||||
|
||||
```
|
||||
|
||||
凡是涉及函数的调用,都要使用到栈。每个线程也有自己的栈。那接下来就是创建线程栈了。
|
||||
|
||||
```
|
||||
int err = ALLOCATE_STACK (iattr, &pd);
|
||||
|
||||
```
|
||||
|
||||
ALLOCATE_STACK是一个宏,我们找到它的定义之后,发现它其实就是一个函数。只是,这个函数有些复杂,所以我这里把主要的代码列一下。
|
||||
|
||||
```
|
||||
# define ALLOCATE_STACK(attr, pd) allocate_stack (attr, pd, &stackaddr)
|
||||
|
||||
|
||||
static int
|
||||
allocate_stack (const struct pthread_attr *attr, struct pthread **pdp,
|
||||
ALLOCATE_STACK_PARMS)
|
||||
{
|
||||
struct pthread *pd;
|
||||
size_t size;
|
||||
size_t pagesize_m1 = __getpagesize () - 1;
|
||||
......
|
||||
size = attr->stacksize;
|
||||
......
|
||||
/* Allocate some anonymous memory. If possible use the cache. */
|
||||
size_t guardsize;
|
||||
void *mem;
|
||||
const int prot = (PROT_READ | PROT_WRITE
|
||||
| ((GL(dl_stack_flags) & PF_X) ? PROT_EXEC : 0));
|
||||
/* Adjust the stack size for alignment. */
|
||||
size &= ~__static_tls_align_m1;
|
||||
/* Make sure the size of the stack is enough for the guard and
|
||||
eventually the thread descriptor. */
|
||||
guardsize = (attr->guardsize + pagesize_m1) & ~pagesize_m1;
|
||||
size += guardsize;
|
||||
pd = get_cached_stack (&size, &mem);
|
||||
if (pd == NULL)
|
||||
{
|
||||
/* If a guard page is required, avoid committing memory by first
|
||||
allocate with PROT_NONE and then reserve with required permission
|
||||
excluding the guard page. */
|
||||
mem = __mmap (NULL, size, (guardsize == 0) ? prot : PROT_NONE,
|
||||
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);
|
||||
/* Place the thread descriptor at the end of the stack. */
|
||||
#if TLS_TCB_AT_TP
|
||||
pd = (struct pthread *) ((char *) mem + size) - 1;
|
||||
#elif TLS_DTV_AT_TP
|
||||
pd = (struct pthread *) ((((uintptr_t) mem + size - __static_tls_size) & ~__static_tls_align_m1) - TLS_PRE_TCB_SIZE);
|
||||
#endif
|
||||
/* Now mprotect the required region excluding the guard area. */
|
||||
char *guard = guard_position (mem, size, guardsize, pd, pagesize_m1);
|
||||
setup_stack_prot (mem, size, guard, guardsize, prot);
|
||||
pd->stackblock = mem;
|
||||
pd->stackblock_size = size;
|
||||
pd->guardsize = guardsize;
|
||||
pd->specific[0] = pd->specific_1stblock;
|
||||
/* And add to the list of stacks in use. */
|
||||
stack_list_add (&pd->list, &stack_used);
|
||||
}
|
||||
|
||||
*pdp = pd;
|
||||
void *stacktop;
|
||||
# if TLS_TCB_AT_TP
|
||||
/* The stack begins before the TCB and the static TLS block. */
|
||||
stacktop = ((char *) (pd + 1) - __static_tls_size);
|
||||
# elif TLS_DTV_AT_TP
|
||||
stacktop = (char *) (pd - 1);
|
||||
# endif
|
||||
*stack = stacktop;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们来看一下,allocate_stack主要做了以下这些事情:
|
||||
|
||||
<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->start_routine = start_routine;
|
||||
pd->arg = arg;
|
||||
pd->schedpolicy = self->schedpolicy;
|
||||
pd->schedparam = self->schedparam;
|
||||
/* Pass the descriptor to the caller. */
|
||||
*newthread = (pthread_t) pd;
|
||||
atomic_increment (&__nptl_nthreads);
|
||||
retval = create_thread (pd, iattr, &stopped_start, STACK_VARIABLES_ARGS, &thread_ran);
|
||||
|
||||
```
|
||||
|
||||
start_routine就是咱们给线程的函数,start_routine,start_routine的参数arg,以及调度策略都要赋值给pthread。
|
||||
|
||||
接下来__nptl_nthreads加一,说明又多了一个线程。
|
||||
|
||||
真正创建线程的是调用create_thread函数,这个函数定义如下:
|
||||
|
||||
```
|
||||
static int
|
||||
create_thread (struct pthread *pd, const struct pthread_attr *attr,
|
||||
bool *stopped_start, STACK_VARIABLES_PARMS, bool *thread_ran)
|
||||
{
|
||||
const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM | CLONE_SIGHAND | CLONE_THREAD | CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | 0);
|
||||
ARCH_CLONE (&start_thread, STACK_VARIABLES_ARGS, clone_flags, pd, &pd->tid, tp, &pd->tid);
|
||||
/* It's started now, so if we fail below, we'll have to cancel it
|
||||
and let it clean itself up. */
|
||||
*thread_ran = true;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面有很长的clone_flags,这些咱们原来一直没注意,不过接下来的过程,我们要特别的关注一下这些标志位。
|
||||
|
||||
然后就是ARCH_CLONE,其实调用的是__clone。看到这里,你应该就有感觉了,马上就要到系统调用了。
|
||||
|
||||
```
|
||||
# define ARCH_CLONE __clone
|
||||
|
||||
|
||||
/* The userland implementation is:
|
||||
int clone (int (*fn)(void *arg), void *child_stack, int flags, void *arg),
|
||||
the kernel entry is:
|
||||
int clone (long flags, void *child_stack).
|
||||
|
||||
|
||||
The parameters are passed in register and on the stack from userland:
|
||||
rdi: fn
|
||||
rsi: child_stack
|
||||
rdx: flags
|
||||
rcx: arg
|
||||
r8d: TID field in parent
|
||||
r9d: thread pointer
|
||||
%esp+8: TID field in child
|
||||
|
||||
|
||||
The kernel expects:
|
||||
rax: system call number
|
||||
rdi: flags
|
||||
rsi: child_stack
|
||||
rdx: TID field in parent
|
||||
r10: TID field in child
|
||||
r8: thread pointer */
|
||||
|
||||
.text
|
||||
ENTRY (__clone)
|
||||
movq $-EINVAL,%rax
|
||||
......
|
||||
/* Insert the argument onto the new stack. */
|
||||
subq $16,%rsi
|
||||
movq %rcx,8(%rsi)
|
||||
|
||||
|
||||
/* Save the function pointer. It will be popped off in the
|
||||
child in the ebx frobbing below. */
|
||||
movq %rdi,0(%rsi)
|
||||
|
||||
|
||||
/* Do the system call. */
|
||||
movq %rdx, %rdi
|
||||
movq %r8, %rdx
|
||||
movq %r9, %r8
|
||||
mov 8(%rsp), %R10_LP
|
||||
movl $SYS_ify(clone),%eax
|
||||
......
|
||||
syscall
|
||||
......
|
||||
PSEUDO_END (__clone)
|
||||
|
||||
```
|
||||
|
||||
如果对于汇编不太熟悉也没关系,你可以重点看上面的注释。
|
||||
|
||||
我们能看到最后调用了syscall,这一点clone和我们原来熟悉的其他系统调用几乎是一致的。但是,也有少许不一样的地方。
|
||||
|
||||
如果在进程的主线程里面调用其他系统调用,当前用户态的栈是指向整个进程的栈,栈顶指针也是指向进程的栈,指令指针也是指向进程的主线程的代码。此时此刻执行到这里,调用clone的时候,用户态的栈、栈顶指针、指令指针和其他系统调用一样,都是指向主线程的。
|
||||
|
||||
但是对于线程来说,这些都要变。因为我们希望当clone这个系统调用成功的时候,除了内核里面有这个线程对应的task_struct,当系统调用返回到用户态的时候,用户态的栈应该是线程的栈,栈顶指针应该指向线程的栈,指令指针应该指向线程将要执行的那个函数。
|
||||
|
||||
所以这些都需要我们自己做,将线程要执行的函数的参数和指令的位置都压到栈里面,当从内核返回,从栈里弹出来的时候,就从这个函数开始,带着这些参数执行下去。
|
||||
|
||||
接下来我们就要进入内核了。内核里面对于clone系统调用的定义是这样的:
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
|
||||
int __user *, parent_tidptr,
|
||||
int __user *, child_tidptr,
|
||||
unsigned long, tls)
|
||||
{
|
||||
return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr, tls);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看到这里,发现了熟悉的面孔_do_fork,是不是轻松了一些?上一节我们已经沿着它的逻辑过了一遍了。这里我们重点关注几个区别。
|
||||
|
||||
第一个是上面**复杂的标志位设定**,我们来看都影响了什么。
|
||||
|
||||
对于copy_files,原来是调用dup_fd复制一个files_struct的,现在因为CLONE_FILES标识位变成将原来的files_struct引用计数加一。
|
||||
|
||||
```
|
||||
static int copy_files(unsigned long clone_flags, struct task_struct *tsk)
|
||||
{
|
||||
struct files_struct *oldf, *newf;
|
||||
oldf = current->files;
|
||||
if (clone_flags & CLONE_FILES) {
|
||||
atomic_inc(&oldf->count);
|
||||
goto out;
|
||||
}
|
||||
newf = dup_fd(oldf, &error);
|
||||
tsk->files = newf;
|
||||
out:
|
||||
return error;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于copy_fs,原来是调用copy_fs_struct复制一个fs_struct,现在因为CLONE_FS标识位变成将原来的fs_struct的用户数加一。
|
||||
|
||||
```
|
||||
static int copy_fs(unsigned long clone_flags, struct task_struct *tsk)
|
||||
{
|
||||
struct fs_struct *fs = current->fs;
|
||||
if (clone_flags & CLONE_FS) {
|
||||
fs->users++;
|
||||
return 0;
|
||||
}
|
||||
tsk->fs = copy_fs_struct(fs);
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于copy_sighand,原来是创建一个新的sighand_struct,现在因为CLONE_SIGHAND标识位变成将原来的sighand_struct引用计数加一。
|
||||
|
||||
```
|
||||
static int copy_sighand(unsigned long clone_flags, struct task_struct *tsk)
|
||||
{
|
||||
struct sighand_struct *sig;
|
||||
|
||||
|
||||
if (clone_flags & CLONE_SIGHAND) {
|
||||
atomic_inc(&current->sighand->count);
|
||||
return 0;
|
||||
}
|
||||
sig = kmem_cache_alloc(sighand_cachep, GFP_KERNEL);
|
||||
atomic_set(&sig->count, 1);
|
||||
memcpy(sig->action, current->sighand->action, sizeof(sig->action));
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于copy_signal,原来是创建一个新的signal_struct,现在因为CLONE_THREAD直接返回了。
|
||||
|
||||
```
|
||||
static int copy_signal(unsigned long clone_flags, struct task_struct *tsk)
|
||||
{
|
||||
struct signal_struct *sig;
|
||||
if (clone_flags & CLONE_THREAD)
|
||||
return 0;
|
||||
sig = kmem_cache_zalloc(signal_cachep, GFP_KERNEL);
|
||||
tsk->signal = sig;
|
||||
init_sigpending(&sig->shared_pending);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于copy_mm,原来是调用dup_mm复制一个mm_struct,现在因为CLONE_VM标识位而直接指向了原来的mm_struct。
|
||||
|
||||
```
|
||||
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
|
||||
{
|
||||
struct mm_struct *mm, *oldmm;
|
||||
oldmm = current->mm;
|
||||
if (clone_flags & CLONE_VM) {
|
||||
mmget(oldmm);
|
||||
mm = oldmm;
|
||||
goto good_mm;
|
||||
}
|
||||
mm = dup_mm(tsk);
|
||||
good_mm:
|
||||
tsk->mm = mm;
|
||||
tsk->active_mm = mm;
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二个就是**对于亲缘关系的影响**,毕竟我们要识别多个线程是不是属于一个进程。
|
||||
|
||||
```
|
||||
p->pid = pid_nr(pid);
|
||||
if (clone_flags & CLONE_THREAD) {
|
||||
p->exit_signal = -1;
|
||||
p->group_leader = current->group_leader;
|
||||
p->tgid = current->tgid;
|
||||
} else {
|
||||
if (clone_flags & CLONE_PARENT)
|
||||
p->exit_signal = current->group_leader->exit_signal;
|
||||
else
|
||||
p->exit_signal = (clone_flags & CSIGNAL);
|
||||
p->group_leader = p;
|
||||
p->tgid = p->pid;
|
||||
}
|
||||
/* CLONE_PARENT re-uses the old parent */
|
||||
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
|
||||
p->real_parent = current->real_parent;
|
||||
p->parent_exec_id = current->parent_exec_id;
|
||||
} else {
|
||||
p->real_parent = current;
|
||||
p->parent_exec_id = current->self_exec_id;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从上面的代码可以看出,使用了CLONE_THREAD标识位之后,使得亲缘关系有了一定的变化。
|
||||
|
||||
<li>
|
||||
如果是新进程,那这个进程的group_leader就是它自己,tgid是它自己的pid,这就完全重打锣鼓另开张了,自己是线程组的头。如果是新线程,group_leader是当前进程的,group_leader,tgid是当前进程的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(&p->pending);
|
||||
|
||||
```
|
||||
|
||||
另外,上面copy_signal的时候,我们可以看到,在创建进程的过程中,会初始化signal_struct里面的struct sigpending shared_pending。但是,在创建线程的过程中,连signal_struct都共享了。也就是说,整个进程里的所有线程共享一个shared_pending,这也是一个信号列表,是发给整个进程的,哪个线程处理都一样。
|
||||
|
||||
```
|
||||
init_sigpending(&sig->shared_pending);
|
||||
|
||||
```
|
||||
|
||||
至此,clone在内核的调用完毕,要返回系统调用,回到用户态。
|
||||
|
||||
## 用户态执行线程
|
||||
|
||||
根据__clone的第一个参数,回到用户态也不是直接运行我们指定的那个函数,而是一个通用的start_thread,这是所有线程在用户态的统一入口。
|
||||
|
||||
```
|
||||
#define START_THREAD_DEFN \
|
||||
static int __attribute__ ((noreturn)) start_thread (void *arg)
|
||||
|
||||
|
||||
START_THREAD_DEFN
|
||||
{
|
||||
struct pthread *pd = START_THREAD_SELF;
|
||||
/* Run the code the user provided. */
|
||||
THREAD_SETMEM (pd, result, pd->start_routine (pd->arg));
|
||||
/* Call destructors for the thread_local TLS variables. */
|
||||
/* Run the destructor for the thread-local data. */
|
||||
__nptl_deallocate_tsd ();
|
||||
if (__glibc_unlikely (atomic_decrement_and_test (&__nptl_nthreads)))
|
||||
/* This was the last thread. */
|
||||
exit (0);
|
||||
__free_tcb (pd);
|
||||
__exit_thread ();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在start_thread入口函数中,才真正的调用用户提供的函数,在用户的函数执行完毕之后,会释放这个线程相关的数据。例如,线程本地数据thread_local variables,线程数目也减一。如果这是最后一个线程了,就直接退出进程,另外__free_tcb用于释放pthread。
|
||||
|
||||
```
|
||||
void
|
||||
internal_function
|
||||
__free_tcb (struct pthread *pd)
|
||||
{
|
||||
......
|
||||
__deallocate_stack (pd);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
internal_function
|
||||
__deallocate_stack (struct pthread *pd)
|
||||
{
|
||||
/* Remove the thread from the list of threads with user defined
|
||||
stacks. */
|
||||
stack_list_del (&pd->list);
|
||||
/* Not much to do. Just free the mmap()ed memory. Note that we do
|
||||
not reset the 'used' flag in the 'tid' field. This is done by
|
||||
the kernel. If no thread has been created yet this field is
|
||||
still zero. */
|
||||
if (__glibc_likely (! pd->user_stack))
|
||||
(void) queue_stack (pd);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
__free_tcb会调用__deallocate_stack来释放整个线程栈,这个线程栈要从当前使用线程栈的列表stack_used中拿下来,放到缓存的线程栈列表stack_cache中。
|
||||
|
||||
好了,整个线程的生命周期到这里就结束了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
线程的调用过程解析完毕了,我画了一个图总结一下。这个图对比了创建进程和创建线程在用户态和内核态的不同。
|
||||
|
||||
创建进程的话,调用的系统调用是fork,在copy_process函数里面,会将五大结构files_struct、fs_struct、sighand_struct、signal_struct、mm_struct都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用clone,在copy_process函数里面, 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。
|
||||
|
||||
<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="">
|
||||
286
极客时间专栏/趣谈Linux操作系统/核心原理篇:第九部分 虚拟化/49 | 虚拟机:如何成立子公司,让公司变集团?.md
Normal file
286
极客时间专栏/趣谈Linux操作系统/核心原理篇:第九部分 虚拟化/49 | 虚拟机:如何成立子公司,让公司变集团?.md
Normal 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-VT,AMD-V,所以需要CPU硬件开启这个标志位,一般在BIOS里面设置。
|
||||
|
||||
当确认开始了标志位之后,通过KVM,GuestOS的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_blk,Guest需要安装这些半虚拟化驱动,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,按照上面我写的步骤创建一台虚拟机。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
@@ -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->init = fn;
|
||||
e->type = type;
|
||||
|
||||
l = find_type(type);
|
||||
|
||||
QTAILQ_INSERT_TAIL(l, e, node);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从代码里面的定义我们可以看出来,type_init后面的参数是一个函数,调用type_init就相当于调用module_init,在这里函数就是kvm_type_init,类型就是MODULE_INIT_QOM。是不是感觉和驱动有点儿像?
|
||||
|
||||
module_init最终要调用register_module_init。属于MODULE_INIT_QOM这种类型的,有一个Module列表ModuleTypeList,列表里面是一项一项的ModuleEntry。KVM就是其中一项,并且会初始化每一项的init函数为参数表示的函数fn,也即KVM这个module的init函数就是kvm_type_init。
|
||||
|
||||
当然,MODULE_INIT_QOM这种类型会有很多很多的module,从后面的代码我们可以看到,所有调用type_init的地方都注册了一个MODULE_INIT_QOM类型的Module。
|
||||
|
||||
了解了Module的注册机制,我们继续回到main函数中module_call_init的调用。
|
||||
|
||||
```
|
||||
void module_call_init(module_init_type type)
|
||||
{
|
||||
ModuleTypeList *l;
|
||||
ModuleEntry *e;
|
||||
l = find_type(type);
|
||||
QTAILQ_FOREACH(e, l, node) {
|
||||
e->init();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在module_call_init中,我们会找到MODULE_INIT_QOM这种类型对应的ModuleTypeList,找出列表中所有的ModuleEntry,然后调用每个ModuleEntry的init函数。这里需要注意的是,在module_call_init调用的这一步,所有Module的init函数都已经被调用过了。
|
||||
|
||||
后面我们会看到很多的Module,当你看到它们的时候,你需要意识到,它的init函数在这里也被调用过了。这里我们还是以对于kvm这个module为例子,看看它的init函数都做了哪些事情。你会发现,其实它调用的是kvm_type_init。
|
||||
|
||||
```
|
||||
static void kvm_type_init(void)
|
||||
{
|
||||
type_register_static(&kvm_accel_type);
|
||||
}
|
||||
|
||||
TypeImpl *type_register_static(const TypeInfo *info)
|
||||
{
|
||||
return type_register(info);
|
||||
}
|
||||
|
||||
TypeImpl *type_register(const TypeInfo *info)
|
||||
{
|
||||
assert(info->parent);
|
||||
return type_register_internal(info);
|
||||
}
|
||||
|
||||
static TypeImpl *type_register_internal(const TypeInfo *info)
|
||||
{
|
||||
TypeImpl *ti;
|
||||
ti = type_new(info);
|
||||
|
||||
type_table_add(ti);
|
||||
return ti;
|
||||
}
|
||||
|
||||
static TypeImpl *type_new(const TypeInfo *info)
|
||||
{
|
||||
TypeImpl *ti = g_malloc0(sizeof(*ti));
|
||||
int i;
|
||||
|
||||
if (type_table_lookup(info->name) != NULL) {
|
||||
}
|
||||
|
||||
ti->name = g_strdup(info->name);
|
||||
ti->parent = g_strdup(info->parent);
|
||||
|
||||
ti->class_size = info->class_size;
|
||||
ti->instance_size = info->instance_size;
|
||||
|
||||
ti->class_init = info->class_init;
|
||||
ti->class_base_init = info->class_base_init;
|
||||
ti->class_data = info->class_data;
|
||||
|
||||
ti->instance_init = info->instance_init;
|
||||
ti->instance_post_init = info->instance_post_init;
|
||||
ti->instance_finalize = info->instance_finalize;
|
||||
|
||||
ti->abstract = info->abstract;
|
||||
|
||||
for (i = 0; info->interfaces && info->interfaces[i].type; i++) {
|
||||
ti->interfaces[i].typename = g_strdup(info->interfaces[i].type);
|
||||
}
|
||||
ti->num_interfaces = i;
|
||||
|
||||
return ti;
|
||||
}
|
||||
|
||||
static void type_table_add(TypeImpl *ti)
|
||||
{
|
||||
assert(!enumerating_types);
|
||||
g_hash_table_insert(type_table_get(), (void *)ti->name, ti);
|
||||
}
|
||||
|
||||
static GHashTable *type_table_get(void)
|
||||
{
|
||||
static GHashTable *type_table;
|
||||
|
||||
if (type_table == NULL) {
|
||||
type_table = g_hash_table_new(g_str_hash, g_str_equal);
|
||||
}
|
||||
|
||||
return type_table;
|
||||
}
|
||||
|
||||
static const TypeInfo kvm_accel_type = {
|
||||
.name = TYPE_KVM_ACCEL,
|
||||
.parent = TYPE_ACCEL,
|
||||
.class_init = kvm_accel_class_init,
|
||||
.instance_size = sizeof(KVMState),
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
每一个Module既然要模拟某种设备,那应该定义一种类型TypeImpl来表示这些设备,这其实是一种面向对象编程的思路,只不过这里用的是纯C语言的实现,所以需要变相实现一下类和对象。
|
||||
|
||||
kvm_type_init会注册kvm_accel_type,定义上面的代码,我们可以认为这样动态定义了一个类。这个类的名字是TYPE_KVM_ACCEL,这个类有父类TYPE_ACCEL,这个类的初始化应该调用函数kvm_accel_class_init(看,这里已经直接叫类class了)。如果用这个类声明一个对象,对象的大小应该是instance_size。是不是有点儿Java语言反射的意思,根据一些名称的定义,一个类就定义好了。
|
||||
|
||||
这里的调用链为:kvm_type_init->type_register_static->type_register->type_register_internal。
|
||||
|
||||
在type_register_internal中,我们会根据kvm_accel_type这个TypeInfo,创建一个TypeImpl来表示这个新注册的类,也就是说,TypeImpl才是我们想要声明的那个class。在qemu里面,有一个全局的哈希表type_table,用来存放所有定义的类。在type_new里面,我们先从全局表里面根据名字找这个类。如果找到,说明这个类曾经被注册过,就报错;如果没有找到,说明这是一个新的类,则将TypeInfo里面信息填到TypeImpl里面。type_table_add会将这个类注册到全局的表里面。到这里,我们注意,class_init还没有被调用,也即这个类现在还处于纸面的状态。
|
||||
|
||||
这点更加像Java的反射机制了。在Java里面,对于一个类,首先我们写代码的时候要写一个class xxx的定义,编译好就放在.class文件中,这也是出于纸面的状态。然后,Java会有一个Class对象,用于读取和表示这个纸面上的class xxx,可以生成真正的对象。
|
||||
|
||||
相同的过程在后面的代码中我们也可以看到,class_init会生成XXXClass,就相当于Java里面的Class对象,TypeImpl还会有一个instance_init函数,相当于构造函数,用于根据XXXClass生成Object,这就相当于Java反射里面最终创建的对象。和构造函数对应的还有instance_finalize,相当于析构函数。
|
||||
|
||||
这一套反射机制放在qom文件夹下面,全称QEMU Object Model,也即用C实现了一套面向对象的反射机制。
|
||||
|
||||
说完了初始化Module,我们还回到main函数接着分析。
|
||||
|
||||
## 2.解析qemu的命令行
|
||||
|
||||
第二步我们就要开始解析qemu的命令行了。qemu的命令行解析,就是下面这样一长串。还记得咱们自己写过一个解析命令行参数的程序吗?这里的opts是差不多的意思。
|
||||
|
||||
```
|
||||
qemu_add_opts(&qemu_drive_opts);
|
||||
qemu_add_opts(&qemu_chardev_opts);
|
||||
qemu_add_opts(&qemu_device_opts);
|
||||
qemu_add_opts(&qemu_netdev_opts);
|
||||
qemu_add_opts(&qemu_nic_opts);
|
||||
qemu_add_opts(&qemu_net_opts);
|
||||
qemu_add_opts(&qemu_rtc_opts);
|
||||
qemu_add_opts(&qemu_machine_opts);
|
||||
qemu_add_opts(&qemu_accel_opts);
|
||||
qemu_add_opts(&qemu_mem_opts);
|
||||
qemu_add_opts(&qemu_smp_opts);
|
||||
qemu_add_opts(&qemu_boot_opts);
|
||||
qemu_add_opts(&qemu_name_opts);
|
||||
qemu_add_opts(&qemu_numa_opts);
|
||||
|
||||
```
|
||||
|
||||
为什么有这么多的opts呢?这是因为,我们上一节给的参数都是简单的参数,实际运行中创建的kvm参数会复杂N倍。这里我们贴一个开源云平台软件OpenStack创建出来的KVM的参数,如下所示。不要被吓坏,你不需要全部看懂,只需要看懂一部分就行了。具体我来给你解析。
|
||||
|
||||
```
|
||||
qemu-system-x86_64
|
||||
-enable-kvm
|
||||
-name instance-00000024
|
||||
-machine pc-i440fx-trusty,accel=kvm,usb=off
|
||||
-cpu SandyBridge,+erms,+smep,+fsgsbase,+pdpe1gb,+rdrand,+f16c,+osxsave,+dca,+pcid,+pdcm,+xtpr,+tm2,+est,+smx,+vmx,+ds_cpl,+monitor,+dtes64,+pbe,+tm,+ht,+ss,+acpi,+ds,+vme
|
||||
-m 2048
|
||||
-smp 1,sockets=1,cores=1,threads=1
|
||||
......
|
||||
-rtc base=utc,driftfix=slew
|
||||
-drive file=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/disk,if=none,id=drive-virtio-disk0,format=qcow2,cache=none
|
||||
-device virtio-blk-pci,scsi=off,bus=pci.0,addr=0x4,drive=drive-virtio-disk0,id=virtio-disk0,bootindex=1
|
||||
-netdev tap,fd=32,id=hostnet0,vhost=on,vhostfd=37
|
||||
-device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:d1:2d:99,bus=pci.0,addr=0x3
|
||||
-chardev file,id=charserial0,path=/var/lib/nova/instances/1f8e6f7e-5a70-4780-89c1-464dc0e7f308/console.log
|
||||
-vnc 0.0.0.0:12
|
||||
-device cirrus-vga,id=video0,bus=pci.0,addr=0x2
|
||||
|
||||
```
|
||||
|
||||
<li>
|
||||
-enable-kvm:表示启用硬件辅助虚拟化。
|
||||
</li>
|
||||
<li>
|
||||
-name instance-00000024:表示虚拟机的名称。
|
||||
</li>
|
||||
<li>
|
||||
<p>-machine pc-i440fx-trusty,accel=kvm,usb=off:machine是什么呢?其实就是计算机体系结构。不知道什么是体系结构的话,可以订阅极客时间的另一个专栏《深入浅出计算机组成原理》。<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:表示设置CPU,SandyBridge是Intel处理器,后面的加号都是添加的CPU的参数,这些参数会显示在/proc/cpuinfo里面。
|
||||
</li>
|
||||
<li>
|
||||
-m 2048:表示内存。
|
||||
</li>
|
||||
<li>
|
||||
<p>-smp 1,sockets=1,cores=1,threads=1:SMP我们解析过,叫对称多处理器,和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, "pc-i440fx-4.0", NULL,
|
||||
pc_i440fx_4_0_machine_options);
|
||||
|
||||
#define DEFINE_I440FX_MACHINE(suffix, name, compatfn, optionfn) \
|
||||
static void pc_init_##suffix(MachineState *machine) \
|
||||
{ \
|
||||
......
|
||||
pc_init1(machine, TYPE_I440FX_PCI_HOST_BRIDGE, \
|
||||
TYPE_I440FX_PCI_DEVICE); \
|
||||
} \
|
||||
DEFINE_PC_MACHINE(suffix, name, pc_init_##suffix, optionfn)
|
||||
|
||||
|
||||
#define DEFINE_PC_MACHINE(suffix, namestr, initfn, optsfn) \
|
||||
static void pc_machine_##suffix##_class_init(ObjectClass *oc, void *data
|
||||
) \
|
||||
{ \
|
||||
MachineClass *mc = MACHINE_CLASS(oc); \
|
||||
optsfn(mc); \
|
||||
mc->init = initfn; \
|
||||
} \
|
||||
static const TypeInfo pc_machine_type_##suffix = { \
|
||||
.name = namestr TYPE_MACHINE_SUFFIX, \
|
||||
.parent = TYPE_PC_MACHINE, \
|
||||
.class_init = pc_machine_##suffix##_class_init, \
|
||||
}; \
|
||||
static void pc_machine_init_##suffix(void) \
|
||||
{ \
|
||||
type_register(&pc_machine_type_##suffix); \
|
||||
} \
|
||||
type_init(pc_machine_init_##suffix)
|
||||
|
||||
```
|
||||
|
||||
为了定义machine_class,这里有一系列的宏定义。入口是DEFINE_I440FX_MACHINE。这个宏有几个参数,v4_0是后缀,"pc-i440fx-4.0"是名字,pc_i440fx_4_0_machine_options是一个函数,用于定义machine_class相关的选项。这个函数定义如下:
|
||||
|
||||
```
|
||||
static void pc_i440fx_4_0_machine_options(MachineClass *m)
|
||||
{
|
||||
pc_i440fx_machine_options(m);
|
||||
m->alias = "pc";
|
||||
m->is_default = 1;
|
||||
}
|
||||
|
||||
static void pc_i440fx_machine_options(MachineClass *m)
|
||||
{
|
||||
PCMachineClass *pcmc = PC_MACHINE_CLASS(m);
|
||||
pcmc->default_nic_model = "e1000";
|
||||
|
||||
m->family = "pc_piix";
|
||||
m->desc = "Standard PC (i440FX + PIIX, 1996)";
|
||||
m->default_machine_opts = "firmware=bios-256k.bin";
|
||||
m->default_display = "std";
|
||||
machine_class_allow_dynamic_sysbus_dev(m, TYPE_RAMFB_DEVICE);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们先不看pc_i440fx_4_0_machine_options,先来看DEFINE_I440FX_MACHINE。
|
||||
|
||||
这里面定义了一个pc_init_##suffix,也就是pc_init_v4_0。这里面转而调用pc_init1。注意这里这个函数只是定义了一下,没有被调用。
|
||||
|
||||
接下来,DEFINE_I440FX_MACHINE里面又定义了DEFINE_PC_MACHINE。它有四个参数,除了DEFINE_I440FX_MACHINE传进来的三个参数以外,多了一个initfn,也即初始化函数,指向刚才定义的pc_init_##suffix。
|
||||
|
||||
在DEFINE_PC_MACHINE中,我们定义了一个函数pc_machine_##suffix##**class_init。从函数的名字class_init可以看出,这是machine_class从纸面上的class初始化为Class对象的方法。在这个函数里面,我们可以看到,它创建了一个MachineClass对象,这个就是Class对象。MachineClass对象的init函数指向上面定义的pc_init**##suffix,说明这个函数是machine这种类型初始化的一个函数,后面会被调用。
|
||||
|
||||
接着,我们看DEFINE_PC_MACHINE。它定义了一个pc_machine_type_##suffix的TypeInfo。这是用于生成纸面上的class的原材料,果真后面调用了type_init。
|
||||
|
||||
看到了type_init,我们应该能够想到,既然它定义了一个纸面上的class,那上面的那句module_call_init,会和我们上面解析的type_init是一样的,在全局的表里面注册了一个全局的名字是"pc-i440fx-4.0"的纸面上的class,也即TypeImpl。
|
||||
|
||||
现在全局表中有这个纸面上的class了。我们回到select_machine。
|
||||
|
||||
```
|
||||
static MachineClass *select_machine(void)
|
||||
{
|
||||
MachineClass *machine_class = find_default_machine();
|
||||
const char *optarg;
|
||||
QemuOpts *opts;
|
||||
......
|
||||
opts = qemu_get_machine_opts();
|
||||
qemu_opts_loc_restore(opts);
|
||||
|
||||
optarg = qemu_opt_get(opts, "type");
|
||||
if (optarg) {
|
||||
machine_class = machine_parse(optarg);
|
||||
}
|
||||
......
|
||||
return machine_class;
|
||||
}
|
||||
|
||||
MachineClass *find_default_machine(void)
|
||||
{
|
||||
GSList *el, *machines = object_class_get_list(TYPE_MACHINE, false);
|
||||
MachineClass *mc = NULL;
|
||||
for (el = machines; el; el = el->next) {
|
||||
MachineClass *temp = el->data;
|
||||
if (temp->is_default) {
|
||||
mc = temp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
g_slist_free(machines);
|
||||
return mc;
|
||||
}
|
||||
|
||||
static MachineClass *machine_parse(const char *name)
|
||||
{
|
||||
MachineClass *mc = NULL;
|
||||
GSList *el, *machines = object_class_get_list(TYPE_MACHINE, false);
|
||||
|
||||
if (name) {
|
||||
mc = find_machine(name);
|
||||
}
|
||||
if (mc) {
|
||||
g_slist_free(machines);
|
||||
return mc;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在select_machine中,有两种方式可以生成MachineClass。一种方式是find_default_machine,找一个默认的;另一种方式是machine_parse,通过解析参数生成MachineClass。无论哪种方式,都会调用object_class_get_list获得一个MachineClass的列表,然后在里面找。object_class_get_list定义如下:
|
||||
|
||||
```
|
||||
GSList *object_class_get_list(const char *implements_type,
|
||||
bool include_abstract)
|
||||
{
|
||||
GSList *list = NULL;
|
||||
|
||||
object_class_foreach(object_class_get_list_tramp,
|
||||
implements_type, include_abstract, &list);
|
||||
return list;
|
||||
}
|
||||
|
||||
void object_class_foreach(void (*fn)(ObjectClass *klass, void *opaque), const char *implements_type, bool include_abstract,
|
||||
void *opaque)
|
||||
{
|
||||
OCFData data = { fn, implements_type, include_abstract, opaque };
|
||||
|
||||
enumerating_types = true;
|
||||
g_hash_table_foreach(type_table_get(), object_class_foreach_tramp, &data);
|
||||
enumerating_types = false;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在全局表type_table_get()中,对于每一项TypeImpl,我们都执行object_class_foreach_tramp。
|
||||
|
||||
```
|
||||
static void object_class_foreach_tramp(gpointer key, gpointer value,
|
||||
gpointer opaque)
|
||||
{
|
||||
OCFData *data = opaque;
|
||||
TypeImpl *type = value;
|
||||
ObjectClass *k;
|
||||
|
||||
type_initialize(type);
|
||||
k = type->class;
|
||||
......
|
||||
data->fn(k, data->opaque);
|
||||
}
|
||||
|
||||
static void type_initialize(TypeImpl *ti)
|
||||
{
|
||||
TypeImpl *parent;
|
||||
......
|
||||
ti->class_size = type_class_get_size(ti);
|
||||
ti->instance_size = type_object_get_size(ti);
|
||||
if (ti->instance_size == 0) {
|
||||
ti->abstract = true;
|
||||
}
|
||||
......
|
||||
ti->class = g_malloc0(ti->class_size);
|
||||
......
|
||||
ti->class->type = ti;
|
||||
|
||||
while (parent) {
|
||||
if (parent->class_base_init) {
|
||||
parent->class_base_init(ti->class, ti->class_data);
|
||||
}
|
||||
parent = type_get_parent(parent);
|
||||
}
|
||||
|
||||
if (ti->class_init) {
|
||||
ti->class_init(ti->class, ti->class_data);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在object_class_foreach_tramp中,会调用将type_initialize,这里面会调用class_init将纸面上的class也即TypeImpl变为ObjectClass,ObjectClass是所有Class类的祖先,MachineClass是它的子类。
|
||||
|
||||
因为在machine的命令行里面,我们指定了名字为"pc-i440fx-4.0",就肯定能够找到我们注册过了的TypeImpl,并调用它的class_init函数。
|
||||
|
||||
因而pc_machine_##suffix##**class_init会被调用,在这里面,pc_i440fx_machine_options才真正被调用初始化MachineClass,并且将MachineClass的init函数设置为pc_init**##suffix。也即,当select_machine执行完毕后,就有一个MachineClass了。
|
||||
|
||||
接着,我们回到object_new。这就很好理解了,MachineClass是一个Class类,接下来应该通过它生成一个Instance,也即对象,这就是object_new的作用。
|
||||
|
||||
```
|
||||
Object *object_new(const char *typename)
|
||||
{
|
||||
TypeImpl *ti = type_get_by_name(typename);
|
||||
|
||||
return object_new_with_type(ti);
|
||||
}
|
||||
|
||||
static Object *object_new_with_type(Type type)
|
||||
{
|
||||
Object *obj;
|
||||
type_initialize(type);
|
||||
obj = g_malloc(type->instance_size);
|
||||
object_initialize_with_type(obj, type->instance_size, type);
|
||||
obj->free = g_free;
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
object_new中,TypeImpl的instance_init会被调用,创建一个对象。current_machine就是这个对象,它的类型是MachineState。
|
||||
|
||||
至此,绕了这么大一圈,有关体系结构的对象才创建完毕,接下来很多的设备的初始化,包括CPU和内存的初始化,都是围绕着体系结构的对象来的,后面我们会常常看到current_machine。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们学到,虚拟机对于设备的模拟是一件非常复杂的事情,需要用复杂的参数模拟各种各样的设备。为了能够适配这些设备,qemu定义了自己的模块管理机制,只有了解了这种机制,后面看每一种设备的虚拟化的时候,才有一个整体的思路。
|
||||
|
||||
这里的MachineClass是我们遇到的第一个,我们需要掌握它里面各种定义之间的关系。
|
||||
|
||||
<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的基本原理,看它是通过什么工具来管理如此复杂的命令行的。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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(&bdo_queue, machine_class, snapshot);
|
||||
|
||||
```
|
||||
|
||||
## 5.初始化计算虚拟化的加速模式
|
||||
|
||||
接下来初始化的是计算虚拟化的加速模式,也即要不要使用KVM。根据参数中的配置是启用KVM。这里调用的是configure_accelerator。
|
||||
|
||||
```
|
||||
configure_accelerator(current_machine, argv[0]);
|
||||
|
||||
void configure_accelerator(MachineState *ms, const char *progname)
|
||||
{
|
||||
const char *accel;
|
||||
char **accel_list, **tmp;
|
||||
int ret;
|
||||
bool accel_initialised = false;
|
||||
bool init_failed = false;
|
||||
AccelClass *acc = NULL;
|
||||
|
||||
accel = qemu_opt_get(qemu_get_machine_opts(), "accel");
|
||||
accel = "kvm";
|
||||
accel_list = g_strsplit(accel, ":", 0);
|
||||
|
||||
for (tmp = accel_list; !accel_initialised && tmp && *tmp; tmp++) {
|
||||
acc = accel_find(*tmp);
|
||||
ret = accel_init_machine(acc, ms);
|
||||
}
|
||||
}
|
||||
|
||||
static AccelClass *accel_find(const char *opt_name)
|
||||
{
|
||||
char *class_name = g_strdup_printf(ACCEL_CLASS_NAME("%s"), opt_name);
|
||||
AccelClass *ac = ACCEL_CLASS(object_class_by_name(class_name));
|
||||
g_free(class_name);
|
||||
return ac;
|
||||
}
|
||||
|
||||
static int accel_init_machine(AccelClass *acc, MachineState *ms)
|
||||
{
|
||||
ObjectClass *oc = OBJECT_CLASS(acc);
|
||||
const char *cname = object_class_get_name(oc);
|
||||
AccelState *accel = ACCEL(object_new(cname));
|
||||
int ret;
|
||||
ms->accelerator = accel;
|
||||
*(acc->allowed) = true;
|
||||
ret = acc->init_machine(ms);
|
||||
return ret;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在configure_accelerator中,我们看命令行参数里面的accel,发现是kvm,则调用accel_find根据名字,得到相应的纸面上的class,并初始化为Class类。
|
||||
|
||||
MachineClass是计算机体系结构的Class类,同理,AccelClass就是加速器的Class类,然后调用accel_init_machine,通过object_new,将AccelClass这个Class类实例化为AccelState,类似对于体系结构的实例是MachineState。
|
||||
|
||||
在accel_find中,我们会根据名字kvm,找到纸面上的class,也即kvm_accel_type,然后调用type_initialize,里面调用kvm_accel_type的class_init方法,也即kvm_accel_class_init。
|
||||
|
||||
```
|
||||
static void kvm_accel_class_init(ObjectClass *oc, void *data)
|
||||
{
|
||||
AccelClass *ac = ACCEL_CLASS(oc);
|
||||
ac->name = "KVM";
|
||||
ac->init_machine = kvm_init;
|
||||
ac->allowed = &kvm_allowed;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在kvm_accel_class_init中,我们创建AccelClass,将init_machine设置为kvm_init。在accel_init_machine中其实就调用了这个init_machine函数,也即调用kvm_init方法。
|
||||
|
||||
```
|
||||
static int kvm_init(MachineState *ms)
|
||||
{
|
||||
MachineClass *mc = MACHINE_GET_CLASS(ms);
|
||||
int soft_vcpus_limit, hard_vcpus_limit;
|
||||
KVMState *s;
|
||||
const KVMCapabilityInfo *missing_cap;
|
||||
int ret;
|
||||
int type = 0;
|
||||
const char *kvm_type;
|
||||
|
||||
s = KVM_STATE(ms->accelerator);
|
||||
s->fd = qemu_open("/dev/kvm", O_RDWR);
|
||||
ret = kvm_ioctl(s, KVM_GET_API_VERSION, 0);
|
||||
......
|
||||
do {
|
||||
ret = kvm_ioctl(s, KVM_CREATE_VM, type);
|
||||
} while (ret == -EINTR);
|
||||
......
|
||||
s->vmfd = ret;
|
||||
|
||||
/* check the vcpu limits */
|
||||
soft_vcpus_limit = kvm_recommended_vcpus(s);
|
||||
hard_vcpus_limit = kvm_max_vcpus(s);
|
||||
......
|
||||
ret = kvm_arch_init(ms, s);
|
||||
if (ret < 0) {
|
||||
goto err;
|
||||
}
|
||||
|
||||
if (machine_kernel_irqchip_allowed(ms)) {
|
||||
kvm_irqchip_create(ms, s);
|
||||
}
|
||||
......
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
这里面的操作就从用户态到内核态的KVM了。就像前面原理讲过的一样,用户态使用内核态KVM的能力,需要打开一个文件/dev/kvm,这是一个字符设备文件,打开一个字符设备文件的过程我们讲过,这里不再赘述。
|
||||
|
||||
```
|
||||
static struct miscdevice kvm_dev = {
|
||||
KVM_MINOR,
|
||||
"kvm",
|
||||
&kvm_chardev_ops,
|
||||
};
|
||||
|
||||
static struct file_operations kvm_chardev_ops = {
|
||||
.unlocked_ioctl = kvm_dev_ioctl,
|
||||
.compat_ioctl = kvm_dev_ioctl,
|
||||
.llseek = noop_llseek,
|
||||
};
|
||||
|
||||
|
||||
```
|
||||
|
||||
KVM这个字符设备文件定义了一个字符设备文件的操作函数kvm_chardev_ops,这里面只定义了ioctl的操作。
|
||||
|
||||
接下来,用户态就通过ioctl系统调用,调用到kvm_dev_ioctl这个函数。这个过程我们在[字符设备](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("kvm-vm", &kvm_vm_fops, kvm, O_RDWR);
|
||||
......
|
||||
fd_install(r, file);
|
||||
return r;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在kvm_dev_ioctl_create_vm中,首先调用kvm_create_vm创建一个struct kvm结构。这个结构在内核里面代表一个虚拟机。
|
||||
|
||||
从下面结构的定义里,我们可以看到,这里面有vcpu,有mm_struct结构。这个结构本来用来管理进程的内存的。虚拟机也是一个进程,所以虚拟机的用户进程空间也是用它来表示。虚拟机里面的操作系统以及应用的进程空间不归它管。
|
||||
|
||||
在kvm_dev_ioctl_create_vm中,第二件事情就是创建一个文件描述符,和struct file关联起来,这个struct file的file_operations会被设置为kvm_vm_fops。
|
||||
|
||||
```
|
||||
struct kvm {
|
||||
struct mm_struct *mm; /* userspace tied to this vm */
|
||||
struct kvm_memslots __rcu *memslots[KVM_ADDRESS_SPACE_NUM];
|
||||
struct kvm_vcpu *vcpus[KVM_MAX_VCPUS];
|
||||
atomic_t online_vcpus;
|
||||
int created_vcpus;
|
||||
int last_boosted_vcpu;
|
||||
struct list_head vm_list;
|
||||
struct mutex lock;
|
||||
struct kvm_io_bus __rcu *buses[KVM_NR_BUSES];
|
||||
......
|
||||
struct kvm_vm_stat stat;
|
||||
struct kvm_arch arch;
|
||||
refcount_t users_count;
|
||||
......
|
||||
long tlbs_dirty;
|
||||
struct list_head devices;
|
||||
pid_t userspace_pid;
|
||||
};
|
||||
|
||||
static struct file_operations kvm_vm_fops = {
|
||||
.release = kvm_vm_release,
|
||||
.unlocked_ioctl = kvm_vm_ioctl,
|
||||
.llseek = noop_llseek,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
kvm_dev_ioctl_create_vm结束之后,对于一台虚拟机而言,只是在内核中有一个数据结构,对于相应的资源还没有分配,所以我们还需要接着看。
|
||||
|
||||
## 6.初始化网络设备
|
||||
|
||||
接下来,调用net_init_clients进行网络设备的初始化。我们可以解析net参数,也会在net_init_clients中解析netdev参数。这属于网络虚拟化的部分,我们先暂时放一下。
|
||||
|
||||
```
|
||||
int net_init_clients(Error **errp)
|
||||
{
|
||||
QTAILQ_INIT(&net_clients);
|
||||
if (qemu_opts_foreach(qemu_find_opts("netdev"),
|
||||
net_init_netdev, NULL, errp)) {
|
||||
return -1;
|
||||
}
|
||||
if (qemu_opts_foreach(qemu_find_opts("nic"), net_param_nic, NULL, errp)) {
|
||||
return -1;
|
||||
}
|
||||
if (qemu_opts_foreach(qemu_find_opts("net"), net_init_client, NULL, errp)) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 7.CPU虚拟化
|
||||
|
||||
接下来,我们要调用machine_run_board_init。这里面调用了MachineClass的init函数。盼啊盼才到了它,这才调用了pc_init1。
|
||||
|
||||
```
|
||||
void machine_run_board_init(MachineState *machine)
|
||||
{
|
||||
MachineClass *machine_class = MACHINE_GET_CLASS(machine);
|
||||
numa_complete_configuration(machine);
|
||||
if (nb_numa_nodes) {
|
||||
machine_numa_finish_cpu_init(machine);
|
||||
}
|
||||
......
|
||||
machine_class->init(machine);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在pc_init1里面,我们重点关注两件重要的事情,一个的CPU的虚拟化,主要调用pc_cpus_init;另外就是内存的虚拟化,主要调用pc_memory_init。这一节我们重点关注CPU的虚拟化,下一节,我们来看内存的虚拟化。
|
||||
|
||||
```
|
||||
void pc_cpus_init(PCMachineState *pcms)
|
||||
{
|
||||
......
|
||||
for (i = 0; i < smp_cpus; i++) {
|
||||
pc_new_cpu(possible_cpus->cpus[i].type, possible_cpus->cpus[i].arch_id, &error_fatal);
|
||||
}
|
||||
}
|
||||
|
||||
static void pc_new_cpu(const char *typename, int64_t apic_id, Error **errp)
|
||||
{
|
||||
Object *cpu = NULL;
|
||||
cpu = object_new(typename);
|
||||
object_property_set_uint(cpu, apic_id, "apic-id", &local_err);
|
||||
object_property_set_bool(cpu, true, "realized", &local_err);//调用 object_property_add_bool的时候,设置了用 device_set_realized 来设置
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在pc_cpus_init中,对于每一个CPU,都调用pc_new_cpu,在这里,我们又看到了object_new,这又是一个从TypeImpl到Class类再到对象的一个过程。
|
||||
|
||||
这个时候,我们就要看CPU的类是怎么组织的了。
|
||||
|
||||
在上面的参数里面,CPU的配置是这样的:
|
||||
|
||||
```
|
||||
-cpu SandyBridge,+erms,+smep,+fsgsbase,+pdpe1gb,+rdrand,+f16c,+osxsave,+dca,+pcid,+pdcm,+xtpr,+tm2,+est,+smx,+vmx,+ds_cpl,+monitor,+dtes64,+pbe,+tm,+ht,+ss,+acpi,+ds,+vme
|
||||
|
||||
```
|
||||
|
||||
在这里我们知道,SandyBridge是CPU的一种类型。在hw/i386/pc.c中,我们能看到这种CPU的定义。
|
||||
|
||||
```
|
||||
{ "SandyBridge" "-" TYPE_X86_CPU, "min-xlevel", "0x8000000a" }
|
||||
|
||||
```
|
||||
|
||||
接下来,我们就来看"SandyBridge",也即TYPE_X86_CPU这种CPU的类,是一个什么样的结构。
|
||||
|
||||
```
|
||||
static const TypeInfo device_type_info = {
|
||||
.name = TYPE_DEVICE,
|
||||
.parent = TYPE_OBJECT,
|
||||
.instance_size = sizeof(DeviceState),
|
||||
.instance_init = device_initfn,
|
||||
.instance_post_init = device_post_init,
|
||||
.instance_finalize = device_finalize,
|
||||
.class_base_init = device_class_base_init,
|
||||
.class_init = device_class_init,
|
||||
.abstract = true,
|
||||
.class_size = sizeof(DeviceClass),
|
||||
};
|
||||
|
||||
static const TypeInfo cpu_type_info = {
|
||||
.name = TYPE_CPU,
|
||||
.parent = TYPE_DEVICE,
|
||||
.instance_size = sizeof(CPUState),
|
||||
.instance_init = cpu_common_initfn,
|
||||
.instance_finalize = cpu_common_finalize,
|
||||
.abstract = true,
|
||||
.class_size = sizeof(CPUClass),
|
||||
.class_init = cpu_class_init,
|
||||
};
|
||||
|
||||
static const TypeInfo x86_cpu_type_info = {
|
||||
.name = TYPE_X86_CPU,
|
||||
.parent = TYPE_CPU,
|
||||
.instance_size = sizeof(X86CPU),
|
||||
.instance_init = x86_cpu_initfn,
|
||||
.abstract = true,
|
||||
.class_size = sizeof(X86CPUClass),
|
||||
.class_init = x86_cpu_common_class_init,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
CPU这种类的定义是有多层继承关系的。TYPE_X86_CPU的父类是TYPE_CPU,TYPE_CPU的父类是TYPE_DEVICE,TYPE_DEVICE的父类是TYPE_OBJECT。到头了。
|
||||
|
||||
这里面每一层都有class_init,用于从TypeImpl生产xxxClass,也有instance_init将xxxClass初始化为实例。
|
||||
|
||||
在TYPE_X86_CPU这一层的class_init中,也即x86_cpu_common_class_init中,设置了DeviceClass的realize函数为x86_cpu_realizefn。这个函数很重要,马上就能用到。
|
||||
|
||||
```
|
||||
static void x86_cpu_common_class_init(ObjectClass *oc, void *data)
|
||||
{
|
||||
X86CPUClass *xcc = X86_CPU_CLASS(oc);
|
||||
CPUClass *cc = CPU_CLASS(oc);
|
||||
DeviceClass *dc = DEVICE_CLASS(oc);
|
||||
|
||||
device_class_set_parent_realize(dc, x86_cpu_realizefn,
|
||||
&xcc->parent_realize);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在TYPE_DEVICE这一层的instance_init函数device_initfn,会为这个设备添加一个属性"realized",要设置这个属性,需要用函数device_set_realized。
|
||||
|
||||
```
|
||||
static void device_initfn(Object *obj)
|
||||
{
|
||||
DeviceState *dev = DEVICE(obj);
|
||||
ObjectClass *class;
|
||||
Property *prop;
|
||||
dev->realized = false;
|
||||
object_property_add_bool(obj, "realized",
|
||||
device_get_realized, device_set_realized, NULL);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们回到pc_new_cpu函数,这里面就是通过object_property_set_bool设置这个属性为true,所以device_set_realized函数会被调用。
|
||||
|
||||
在device_set_realized中,DeviceClass的realize函数x86_cpu_realizefn会被调用。这里面qemu_init_vcpu会调用qemu_kvm_start_vcpu。
|
||||
|
||||
```
|
||||
static void qemu_kvm_start_vcpu(CPUState *cpu)
|
||||
{
|
||||
char thread_name[VCPU_THREAD_NAME_SIZE];
|
||||
cpu->thread = g_malloc0(sizeof(QemuThread));
|
||||
cpu->halt_cond = g_malloc0(sizeof(QemuCond));
|
||||
qemu_cond_init(cpu->halt_cond);
|
||||
qemu_thread_create(cpu->thread, thread_name, qemu_kvm_cpu_thread_fn, cpu, QEMU_THREAD_JOINABLE);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里面,为这个vcpu创建一个线程,也即虚拟机里面的一个vcpu对应物理机上的一个线程,然后这个线程被调度到某个物理CPU上。
|
||||
|
||||
我们来看这个vcpu的线程执行函数。
|
||||
|
||||
```
|
||||
static void *qemu_kvm_cpu_thread_fn(void *arg)
|
||||
{
|
||||
CPUState *cpu = arg;
|
||||
int r;
|
||||
|
||||
rcu_register_thread();
|
||||
|
||||
qemu_mutex_lock_iothread();
|
||||
qemu_thread_get_self(cpu->thread);
|
||||
cpu->thread_id = qemu_get_thread_id();
|
||||
cpu->can_do_io = 1;
|
||||
current_cpu = cpu;
|
||||
|
||||
r = kvm_init_vcpu(cpu);
|
||||
kvm_init_cpu_signals(cpu);
|
||||
|
||||
/* signal CPU creation */
|
||||
cpu->created = true;
|
||||
qemu_cond_signal(&qemu_cpu_cond);
|
||||
|
||||
do {
|
||||
if (cpu_can_run(cpu)) {
|
||||
r = kvm_cpu_exec(cpu);
|
||||
}
|
||||
qemu_wait_io_event(cpu);
|
||||
} while (!cpu->unplug || cpu_can_run(cpu));
|
||||
|
||||
qemu_kvm_destroy_vcpu(cpu);
|
||||
cpu->created = false;
|
||||
qemu_cond_signal(&qemu_cpu_cond);
|
||||
qemu_mutex_unlock_iothread();
|
||||
rcu_unregister_thread();
|
||||
return NULL;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在qemu_kvm_cpu_thread_fn中,先是kvm_init_vcpu初始化这个vcpu。
|
||||
|
||||
```
|
||||
int kvm_init_vcpu(CPUState *cpu)
|
||||
{
|
||||
KVMState *s = kvm_state;
|
||||
long mmap_size;
|
||||
int ret;
|
||||
......
|
||||
ret = kvm_get_vcpu(s, kvm_arch_vcpu_id(cpu));
|
||||
......
|
||||
cpu->kvm_fd = ret;
|
||||
cpu->kvm_state = s;
|
||||
cpu->vcpu_dirty = true;
|
||||
|
||||
mmap_size = kvm_ioctl(s, KVM_GET_VCPU_MMAP_SIZE, 0);
|
||||
......
|
||||
cpu->kvm_run = mmap(NULL, mmap_size, PROT_READ | PROT_WRITE, MAP_SHARED, cpu->kvm_fd, 0);
|
||||
......
|
||||
ret = kvm_arch_init_vcpu(cpu);
|
||||
err:
|
||||
return ret;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在kvm_get_vcpu中,我们会调用kvm_vm_ioctl(s, KVM_CREATE_VCPU, (void *)vcpu_id),在内核里面创建一个vcpu。在上面创建KVM_CREATE_VM的时候,我们已经创建了一个struct file,它的file_operations被设置为kvm_vm_fops,这个内核文件也是可以响应ioctl的。
|
||||
|
||||
如果我们切换到内核KVM,在kvm_vm_ioctl函数中,有对于KVM_CREATE_VCPU的处理,调用的是kvm_vm_ioctl_create_vcpu。
|
||||
|
||||
```
|
||||
static long kvm_vm_ioctl(struct file *filp,
|
||||
unsigned int ioctl, unsigned long arg)
|
||||
{
|
||||
struct kvm *kvm = filp->private_data;
|
||||
void __user *argp = (void __user *)arg;
|
||||
int r;
|
||||
switch (ioctl) {
|
||||
case KVM_CREATE_VCPU:
|
||||
r = kvm_vm_ioctl_create_vcpu(kvm, arg);
|
||||
break;
|
||||
case KVM_SET_USER_MEMORY_REGION: {
|
||||
struct kvm_userspace_memory_region kvm_userspace_mem;
|
||||
if (copy_from_user(&kvm_userspace_mem, argp,
|
||||
sizeof(kvm_userspace_mem)))
|
||||
goto out;
|
||||
r = kvm_vm_ioctl_set_memory_region(kvm, &kvm_userspace_mem);
|
||||
break;
|
||||
}
|
||||
......
|
||||
case KVM_CREATE_DEVICE: {
|
||||
struct kvm_create_device cd;
|
||||
if (copy_from_user(&cd, argp, sizeof(cd)))
|
||||
goto out;
|
||||
r = kvm_ioctl_create_device(kvm, &cd);
|
||||
if (copy_to_user(argp, &cd, sizeof(cd)))
|
||||
goto out;
|
||||
break;
|
||||
}
|
||||
case KVM_CHECK_EXTENSION:
|
||||
r = kvm_vm_ioctl_check_extension_generic(kvm, arg);
|
||||
break;
|
||||
default:
|
||||
r = kvm_arch_vm_ioctl(filp, ioctl, arg);
|
||||
}
|
||||
out:
|
||||
return r;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在kvm_vm_ioctl_create_vcpu中,kvm_arch_vcpu_create调用kvm_x86_ops的vcpu_create函数来创建CPU。
|
||||
|
||||
```
|
||||
static int kvm_vm_ioctl_create_vcpu(struct kvm *kvm, u32 id)
|
||||
{
|
||||
int r;
|
||||
struct kvm_vcpu *vcpu;
|
||||
kvm->created_vcpus++;
|
||||
......
|
||||
vcpu = kvm_arch_vcpu_create(kvm, id);
|
||||
preempt_notifier_init(&vcpu->preempt_notifier, &kvm_preempt_ops);
|
||||
r = kvm_arch_vcpu_setup(vcpu);
|
||||
......
|
||||
/* Now it's all set up, let userspace reach it */
|
||||
kvm_get_kvm(kvm);
|
||||
r = create_vcpu_fd(vcpu);
|
||||
kvm->vcpus[atomic_read(&kvm->online_vcpus)] = vcpu;
|
||||
......
|
||||
}
|
||||
|
||||
struct kvm_vcpu *kvm_arch_vcpu_create(struct kvm *kvm,
|
||||
unsigned int id)
|
||||
{
|
||||
struct kvm_vcpu *vcpu;
|
||||
vcpu = kvm_x86_ops->vcpu_create(kvm, id);
|
||||
return vcpu;
|
||||
}
|
||||
|
||||
static int create_vcpu_fd(struct kvm_vcpu *vcpu)
|
||||
{
|
||||
return anon_inode_getfd("kvm-vcpu", &kvm_vcpu_fops, vcpu, O_RDWR | O_CLOEXEC);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,create_vcpu_fd又创建了一个struct file,它的file_operations指向kvm_vcpu_fops。从这里可以看出,KVM的内核模块是一个文件,可以通过ioctl进行操作。基于这个内核模块创建的VM也是一个文件,也可以通过ioctl进行操作。在这个VM上创建的vcpu同样是一个文件,同样可以通过ioctl进行操作。
|
||||
|
||||
我们回过头来看,kvm_x86_ops的vcpu_create函数。kvm_x86_ops对于不同的硬件加速虚拟化指向不同的结构,如果是vmx,则指向vmx_x86_ops;如果是svm,则指向svm_x86_ops。我们这里看vmx_x86_ops。这个结构很长,里面有非常多的操作,我们用一个看一个。
|
||||
|
||||
```
|
||||
static struct kvm_x86_ops vmx_x86_ops __ro_after_init = {
|
||||
......
|
||||
.vcpu_create = vmx_create_vcpu,
|
||||
......
|
||||
}
|
||||
|
||||
static struct kvm_vcpu *vmx_create_vcpu(struct kvm *kvm, unsigned int id)
|
||||
{
|
||||
int err;
|
||||
struct vcpu_vmx *vmx = kmem_cache_zalloc(kvm_vcpu_cache, GFP_KERNEL);
|
||||
int cpu;
|
||||
vmx->vpid = allocate_vpid();
|
||||
err = kvm_vcpu_init(&vmx->vcpu, kvm, id);
|
||||
vmx->guest_msrs = kmalloc(PAGE_SIZE, GFP_KERNEL);
|
||||
vmx->loaded_vmcs = &vmx->vmcs01;
|
||||
vmx->loaded_vmcs->vmcs = alloc_vmcs();
|
||||
vmx->loaded_vmcs->shadow_vmcs = NULL;
|
||||
loaded_vmcs_init(vmx->loaded_vmcs);
|
||||
|
||||
cpu = get_cpu();
|
||||
vmx_vcpu_load(&vmx->vcpu, cpu);
|
||||
vmx->vcpu.cpu = cpu;
|
||||
err = vmx_vcpu_setup(vmx);
|
||||
vmx_vcpu_put(&vmx->vcpu);
|
||||
put_cpu();
|
||||
|
||||
if (enable_ept) {
|
||||
if (!kvm->arch.ept_identity_map_addr)
|
||||
kvm->arch.ept_identity_map_addr =
|
||||
VMX_EPT_IDENTITY_PAGETABLE_ADDR;
|
||||
err = init_rmode_identity_map(kvm);
|
||||
}
|
||||
|
||||
return &vmx->vcpu;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
vmx_create_vcpu创建用于表示vcpu的结构struct vcpu_vmx,并填写里面的内容。例如guest_msrs,咱们在讲系统调用的时候提过msr寄存器,虚拟机也需要有这样的寄存器。
|
||||
|
||||
enable_ept是和内存虚拟化相关的,EPT全称Extended Page Table,顾名思义,是优化内存虚拟化的,这个功能我们放到内存的那一节讲。
|
||||
|
||||
最最重要的就是loaded_vmcs了。VMCS是什么呢?它的全称是Virtual Machine Control Structure。它是来干什么呢?
|
||||
|
||||
前面咱们讲进程调度的时候讲过,为了支持进程在CPU上的切换,CPU硬件要求有一个TSS结构,用于保存进程运行时的所有寄存器的状态,进程切换的时候,需要根据TSS恢复寄存器。
|
||||
|
||||
虚拟机也是一个进程,也需要切换,而且切换更加的复杂,可能是两个虚拟机之间切换,也可能是虚拟机切换给内核,虚拟机因为里面还有另一个操作系统,要保存的信息比普通的进程多得多。那就需要有一个结构来保存虚拟机运行的上下文,VMCS就是是Intel实现CPU虚拟化,记录vCPU状态的一个关键数据结构。
|
||||
|
||||
VMCS数据结构主要包含以下信息。
|
||||
|
||||
- Guest-state area,即vCPU的状态信息,包括vCPU的基本运行环境,例如寄存器等。
|
||||
- Host-state area,是物理CPU的状态信息。物理CPU和vCPU之间也会来回切换,所以,VMCS中既要记录vCPU的状态,也要记录物理CPU的状态。
|
||||
- VM-execution control fields,对vCPU的运行行为进行控制。例如,发生中断怎么办,是否使用EPT(Extended Page Table)功能等。
|
||||
|
||||
接下来,对于VMCS,有两个重要的操作。
|
||||
|
||||
VM-Entry,我们称为从根模式切换到非根模式,也即切换到guest上,这个时候CPU上运行的是虚拟机。VM-Exit我们称为CPU从非根模式切换到根模式,也即从guest切换到宿主机。例如,当要执行一些虚拟机没有权限的敏感指令时。
|
||||
|
||||
<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->kvm_run;
|
||||
int ret, run_ret;
|
||||
......
|
||||
do {
|
||||
......
|
||||
run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);
|
||||
......
|
||||
switch (run->exit_reason) {
|
||||
case KVM_EXIT_IO:
|
||||
kvm_handle_io(run->io.port, attrs,
|
||||
(uint8_t *)run + run->io.data_offset,
|
||||
run->io.direction,
|
||||
run->io.size,
|
||||
run->io.count);
|
||||
break;
|
||||
case KVM_EXIT_IRQ_WINDOW_OPEN:
|
||||
ret = EXCP_INTERRUPT;
|
||||
break;
|
||||
case KVM_EXIT_SHUTDOWN:
|
||||
qemu_system_reset_request(SHUTDOWN_CAUSE_GUEST_RESET);
|
||||
ret = EXCP_INTERRUPT;
|
||||
break;
|
||||
case KVM_EXIT_UNKNOWN:
|
||||
fprintf(stderr, "KVM: unknown exit, hardware reason %" PRIx64 "\n",(uint64_t)run->hw.hardware_exit_reason);
|
||||
ret = -1;
|
||||
break;
|
||||
case KVM_EXIT_INTERNAL_ERROR:
|
||||
ret = kvm_handle_internal_error(cpu, run);
|
||||
break;
|
||||
......
|
||||
}
|
||||
} while (ret == 0);
|
||||
......
|
||||
return ret;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在kvm_cpu_exec中,我们能看到一个循环,在循环中,kvm_vcpu_ioctl(KVM_RUN)运行这个虚拟机,这个时候CPU进入VM-Entry,也即进入客户机模式。
|
||||
|
||||
如果一直是客户机的操作系统占用这个CPU,则会一直停留在这一行运行,一旦这个调用返回了,就说明CPU进入VM-Exit退出客户机模式,将CPU交还给宿主机。在循环中,我们会对退出的原因exit_reason进行分析处理,因为有了I/O,还有了中断等,做相应的处理。处理完毕之后,再次循环,再次通过VM-Entry,进入客户机模式。如此循环,直到虚拟机正常或者异常退出。
|
||||
|
||||
我们来看kvm_vcpu_ioctl(KVM_RUN)在内核做了哪些事情。
|
||||
|
||||
上面我们也讲了,vcpu在内核也是一个文件,也是通过ioctl进行用户态和内核态通信的,在内核中,调用的是kvm_vcpu_ioctl。
|
||||
|
||||
```
|
||||
static long kvm_vcpu_ioctl(struct file *filp,
|
||||
unsigned int ioctl, unsigned long arg)
|
||||
{
|
||||
struct kvm_vcpu *vcpu = filp->private_data;
|
||||
void __user *argp = (void __user *)arg;
|
||||
int r;
|
||||
struct kvm_fpu *fpu = NULL;
|
||||
struct kvm_sregs *kvm_sregs = NULL;
|
||||
......
|
||||
r = vcpu_load(vcpu);
|
||||
switch (ioctl) {
|
||||
case KVM_RUN: {
|
||||
struct pid *oldpid;
|
||||
r = kvm_arch_vcpu_ioctl_run(vcpu, vcpu->run);
|
||||
break;
|
||||
}
|
||||
case KVM_GET_REGS: {
|
||||
struct kvm_regs *kvm_regs;
|
||||
kvm_regs = kzalloc(sizeof(struct kvm_regs), GFP_KERNEL);
|
||||
r = kvm_arch_vcpu_ioctl_get_regs(vcpu, kvm_regs);
|
||||
if (copy_to_user(argp, kvm_regs, sizeof(struct kvm_regs)))
|
||||
goto out_free1;
|
||||
break;
|
||||
}
|
||||
case KVM_SET_REGS: {
|
||||
struct kvm_regs *kvm_regs;
|
||||
kvm_regs = memdup_user(argp, sizeof(*kvm_regs));
|
||||
r = kvm_arch_vcpu_ioctl_set_regs(vcpu, kvm_regs);
|
||||
break;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
kvm_arch_vcpu_ioctl_run会调用vcpu_run,这里面也是一个无限循环。
|
||||
|
||||
```
|
||||
static int vcpu_run(struct kvm_vcpu *vcpu)
|
||||
{
|
||||
int r;
|
||||
struct kvm *kvm = vcpu->kvm;
|
||||
|
||||
for (;;) {
|
||||
if (kvm_vcpu_running(vcpu)) {
|
||||
r = vcpu_enter_guest(vcpu);
|
||||
} else {
|
||||
r = vcpu_block(kvm, vcpu);
|
||||
}
|
||||
....
|
||||
if (signal_pending(current)) {
|
||||
r = -EINTR;
|
||||
vcpu->run->exit_reason = KVM_EXIT_INTR;
|
||||
++vcpu->stat.signal_exits;
|
||||
break;
|
||||
}
|
||||
if (need_resched()) {
|
||||
cond_resched();
|
||||
}
|
||||
}
|
||||
......
|
||||
return r;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个循环中,除了调用vcpu_enter_guest进入客户机模式运行之外,还有对于信号的响应signal_pending,也即一台虚拟机是可以被kill掉的,还有对于调度的响应,这台虚拟机可以被从当前的物理CPU上赶下来,换成别的虚拟机或者其他进程。
|
||||
|
||||
我们这里重点看vcpu_enter_guest。
|
||||
|
||||
```
|
||||
static int vcpu_enter_guest(struct kvm_vcpu *vcpu)
|
||||
{
|
||||
r = kvm_mmu_reload(vcpu);
|
||||
vcpu->mode = IN_GUEST_MODE;
|
||||
kvm_load_guest_xcr0(vcpu);
|
||||
......
|
||||
guest_enter_irqoff();
|
||||
kvm_x86_ops->run(vcpu);
|
||||
vcpu->mode = OUTSIDE_GUEST_MODE;
|
||||
......
|
||||
kvm_put_guest_xcr0(vcpu);
|
||||
kvm_x86_ops->handle_external_intr(vcpu);
|
||||
++vcpu->stat.exits;
|
||||
guest_exit_irqoff();
|
||||
r = kvm_x86_ops->handle_exit(vcpu);
|
||||
return r;
|
||||
......
|
||||
}
|
||||
|
||||
static struct kvm_x86_ops vmx_x86_ops __ro_after_init = {
|
||||
......
|
||||
.run = vmx_vcpu_run,
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在vcpu_enter_guest中,我们会调用vmx_x86_ops 的vmx_vcpu_run函数,进入客户机模式。
|
||||
|
||||
```
|
||||
static void __noclone vmx_vcpu_run(struct kvm_vcpu *vcpu)
|
||||
{
|
||||
struct vcpu_vmx *vmx = to_vmx(vcpu);
|
||||
unsigned long debugctlmsr, cr3, cr4;
|
||||
......
|
||||
cr3 = __get_current_cr3_fast();
|
||||
......
|
||||
cr4 = cr4_read_shadow();
|
||||
......
|
||||
vmx->__launched = vmx->loaded_vmcs->launched;
|
||||
asm(
|
||||
/* Store host registers */
|
||||
"push %%" _ASM_DX "; push %%" _ASM_BP ";"
|
||||
"push %%" _ASM_CX " \n\t" /* placeholder for guest rcx */
|
||||
"push %%" _ASM_CX " \n\t"
|
||||
......
|
||||
/* Load guest registers. Don't clobber flags. */
|
||||
"mov %c[rax](%0), %%" _ASM_AX " \n\t"
|
||||
"mov %c[rbx](%0), %%" _ASM_BX " \n\t"
|
||||
"mov %c[rdx](%0), %%" _ASM_DX " \n\t"
|
||||
"mov %c[rsi](%0), %%" _ASM_SI " \n\t"
|
||||
"mov %c[rdi](%0), %%" _ASM_DI " \n\t"
|
||||
"mov %c[rbp](%0), %%" _ASM_BP " \n\t"
|
||||
#ifdef CONFIG_X86_64
|
||||
"mov %c[r8](%0), %%r8 \n\t"
|
||||
"mov %c[r9](%0), %%r9 \n\t"
|
||||
"mov %c[r10](%0), %%r10 \n\t"
|
||||
"mov %c[r11](%0), %%r11 \n\t"
|
||||
"mov %c[r12](%0), %%r12 \n\t"
|
||||
"mov %c[r13](%0), %%r13 \n\t"
|
||||
"mov %c[r14](%0), %%r14 \n\t"
|
||||
"mov %c[r15](%0), %%r15 \n\t"
|
||||
#endif
|
||||
"mov %c[rcx](%0), %%" _ASM_CX " \n\t" /* kills %0 (ecx) */
|
||||
|
||||
/* Enter guest mode */
|
||||
"jne 1f \n\t"
|
||||
__ex(ASM_VMX_VMLAUNCH) "\n\t"
|
||||
"jmp 2f \n\t"
|
||||
"1: " __ex(ASM_VMX_VMRESUME) "\n\t"
|
||||
"2: "
|
||||
/* Save guest registers, load host registers, keep flags */
|
||||
"mov %0, %c[wordsize](%%" _ASM_SP ") \n\t"
|
||||
"pop %0 \n\t"
|
||||
"mov %%" _ASM_AX ", %c[rax](%0) \n\t"
|
||||
"mov %%" _ASM_BX ", %c[rbx](%0) \n\t"
|
||||
__ASM_SIZE(pop) " %c[rcx](%0) \n\t"
|
||||
"mov %%" _ASM_DX ", %c[rdx](%0) \n\t"
|
||||
"mov %%" _ASM_SI ", %c[rsi](%0) \n\t"
|
||||
"mov %%" _ASM_DI ", %c[rdi](%0) \n\t"
|
||||
"mov %%" _ASM_BP ", %c[rbp](%0) \n\t"
|
||||
#ifdef CONFIG_X86_64
|
||||
"mov %%r8, %c[r8](%0) \n\t"
|
||||
"mov %%r9, %c[r9](%0) \n\t"
|
||||
"mov %%r10, %c[r10](%0) \n\t"
|
||||
"mov %%r11, %c[r11](%0) \n\t"
|
||||
"mov %%r12, %c[r12](%0) \n\t"
|
||||
"mov %%r13, %c[r13](%0) \n\t"
|
||||
"mov %%r14, %c[r14](%0) \n\t"
|
||||
"mov %%r15, %c[r15](%0) \n\t"
|
||||
#endif
|
||||
"mov %%cr2, %%" _ASM_AX " \n\t"
|
||||
"mov %%" _ASM_AX ", %c[cr2](%0) \n\t"
|
||||
|
||||
"pop %%" _ASM_BP "; pop %%" _ASM_DX " \n\t"
|
||||
"setbe %c[fail](%0) \n\t"
|
||||
".pushsection .rodata \n\t"
|
||||
".global vmx_return \n\t"
|
||||
"vmx_return: " _ASM_PTR " 2b \n\t"
|
||||
......
|
||||
);
|
||||
......
|
||||
vmx->loaded_vmcs->launched = 1;
|
||||
vmx->exit_reason = vmcs_read32(VM_EXIT_REASON);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在vmx_vcpu_run中,出现了汇编语言的代码,比较难看懂,但是没有关系呀,里面有注释呀,我们可以沿着注释来看。
|
||||
|
||||
- 首先是Store host registers,要从宿主机模式变为客户机模式了,所以原来宿主机运行时候的寄存器要保存下来。
|
||||
- 接下来是Load guest registers,将原来客户机运行的时候的寄存器加载进来。
|
||||
- 接下来是Enter guest mode,调用ASM_VMX_VMLAUNCH进入客户机模型运行,或者ASM_VMX_VMRESUME恢复客户机模型运行。
|
||||
- 如果客户机因为某种原因退出,Save guest registers, load host registers,也即保存客户机运行的时候的寄存器,就加载宿主机运行的时候的寄存器。
|
||||
- 最后将exit_reason保存在vmx结构中。
|
||||
|
||||
至此,CPU虚拟化就解析完了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
CPU的虚拟化过程还是很复杂的,我画了一张图总结了一下。
|
||||
|
||||
<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="">
|
||||
843
极客时间专栏/趣谈Linux操作系统/核心原理篇:第九部分 虚拟化/52 | 计算虚拟化之内存:如何建立独立的办公室?.md
Normal file
843
极客时间专栏/趣谈Linux操作系统/核心原理篇:第九部分 虚拟化/52 | 计算虚拟化之内存:如何建立独立的办公室?.md
Normal 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 Memory,GVA),这是虚拟机里面的进程看到的内存空间;
|
||||
- **虚拟机里面的物理内存**(Guest OS Physical Memory,GPA),这是虚拟机里面的操作系统看到的内存,它认为这是物理内存;
|
||||
- **物理机的虚拟内存**(Host Virtual Memory,HVA),这是物理机上的qemu进程看到的内存空间;
|
||||
- **物理机的物理内存**(Host Physical Memory,HPA),这是物理机上的操作系统看到的内存。
|
||||
|
||||
咱们内存管理那一章讲的两大内容,一个是内存管理,它变得非常复杂;另一个是内存映射,具体来说就是,从GVA到GPA,到HVA,再到HPA,这样几经转手,计算机的性能就会变得很差。当然,虚拟化技术成熟的今天,有了一些优化的手段,具体怎么优化呢?我们这一节就来一一解析。
|
||||
|
||||
## 内存管理
|
||||
|
||||
我们先来看内存管理的部分。
|
||||
|
||||
由于CPU和内存是紧密结合的,因而内存虚拟化的初始化过程,和CPU虚拟化的初始化是一起完成的。
|
||||
|
||||
上一节说CPU虚拟化初始化的时候,我们会调用kvm_init函数,这里面打开了"/dev/kvm"这个字符文件,并且通过ioctl调用到内核kvm的KVM_CREATE_VM操作,除了这些CPU相关的调用,接下来还有内存相关的。我们来看看。
|
||||
|
||||
```
|
||||
static int kvm_init(MachineState *ms)
|
||||
{
|
||||
MachineClass *mc = MACHINE_GET_CLASS(ms);
|
||||
......
|
||||
kvm_memory_listener_register(s, &s->memory_listener,
|
||||
&address_space_memory, 0);
|
||||
memory_listener_register(&kvm_io_listener,
|
||||
&address_space_io);
|
||||
......
|
||||
}
|
||||
|
||||
AddressSpace address_space_io;
|
||||
AddressSpace address_space_memory;
|
||||
|
||||
```
|
||||
|
||||
这里面有两个地址空间AddressSpace,一个是系统内存的地址空间address_space_memory,一个用于I/O的地址空间address_space_io。这里我们重点看address_space_memory。
|
||||
|
||||
```
|
||||
struct AddressSpace {
|
||||
/* All fields are private. */
|
||||
struct rcu_head rcu;
|
||||
char *name;
|
||||
MemoryRegion *root;
|
||||
|
||||
/* Accessed via RCU. */
|
||||
struct FlatView *current_map;
|
||||
|
||||
int ioeventfd_nb;
|
||||
struct MemoryRegionIoeventfd *ioeventfds;
|
||||
QTAILQ_HEAD(, MemoryListener) listeners;
|
||||
QTAILQ_ENTRY(AddressSpace) address_spaces_link;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
对于一个地址空间,会有多个内存区域MemoryRegion组成树形结构。这里面,root是这棵树的根。另外,还有一个MemoryListener链表,当内存区域发生变化的时候,需要做一些动作,使得用户态和内核态能够协同,就是由这些MemoryListener完成的。
|
||||
|
||||
在kvm_init这个时候,还没有内存区域加入进来,root还是空的,但是我们可以先注册MemoryListener,这里注册的是KVMMemoryListener。
|
||||
|
||||
```
|
||||
void kvm_memory_listener_register(KVMState *s, KVMMemoryListener *kml,
|
||||
AddressSpace *as, int as_id)
|
||||
{
|
||||
int i;
|
||||
|
||||
kml->slots = g_malloc0(s->nr_slots * sizeof(KVMSlot));
|
||||
kml->as_id = as_id;
|
||||
|
||||
for (i = 0; i < s->nr_slots; i++) {
|
||||
kml->slots[i].slot = i;
|
||||
}
|
||||
|
||||
kml->listener.region_add = kvm_region_add;
|
||||
kml->listener.region_del = kvm_region_del;
|
||||
kml->listener.priority = 10;
|
||||
|
||||
memory_listener_register(&kml->listener, as);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个KVMMemoryListener中是这样配置的:当添加一个MemoryRegion的时候,region_add会被调用,这个我们后面会用到。
|
||||
|
||||
接下来,在qemu启动的main函数中,我们会调用cpu_exec_init_all->memory_map_init.
|
||||
|
||||
```
|
||||
static void memory_map_init(void)
|
||||
{
|
||||
system_memory = g_malloc(sizeof(*system_memory));
|
||||
|
||||
memory_region_init(system_memory, NULL, "system", UINT64_MAX);
|
||||
address_space_init(&address_space_memory, system_memory, "memory");
|
||||
|
||||
system_io = g_malloc(sizeof(*system_io));
|
||||
memory_region_init_io(system_io, NULL, &unassigned_io_ops, NULL, "io",
|
||||
65536);
|
||||
address_space_init(&address_space_io, system_io, "I/O");
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里,对于系统内存区域system_memory和用于I/O的内存区域system_io,我们都进行了初始化,并且关联到了相应的地址空间AddressSpace。
|
||||
|
||||
```
|
||||
void address_space_init(AddressSpace *as, MemoryRegion *root, const char *name)
|
||||
{
|
||||
memory_region_ref(root);
|
||||
as->root = root;
|
||||
as->current_map = NULL;
|
||||
as->ioeventfd_nb = 0;
|
||||
as->ioeventfds = NULL;
|
||||
QTAILQ_INIT(&as->listeners);
|
||||
QTAILQ_INSERT_TAIL(&address_spaces, as, address_spaces_link);
|
||||
as->name = g_strdup(name ? name : "anonymous");
|
||||
address_space_update_topology(as);
|
||||
address_space_update_ioeventfds(as);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于系统内存地址空间address_space_memory,我们需要把它里面内存区域的根root设置为system_memory。
|
||||
|
||||
另外,在这里,我们还调用了address_space_update_topology。
|
||||
|
||||
```
|
||||
static void address_space_update_topology(AddressSpace *as)
|
||||
{
|
||||
MemoryRegion *physmr = memory_region_get_flatview_root(as->root);
|
||||
|
||||
flatviews_init();
|
||||
if (!g_hash_table_lookup(flat_views, physmr)) {
|
||||
generate_memory_topology(physmr);
|
||||
}
|
||||
address_space_set_flatview(as);
|
||||
}
|
||||
|
||||
static void address_space_set_flatview(AddressSpace *as)
|
||||
{
|
||||
FlatView *old_view = address_space_to_flatview(as);
|
||||
MemoryRegion *physmr = memory_region_get_flatview_root(as->root);
|
||||
FlatView *new_view = g_hash_table_lookup(flat_views, physmr);
|
||||
|
||||
if (old_view == new_view) {
|
||||
return;
|
||||
}
|
||||
......
|
||||
if (!QTAILQ_EMPTY(&as->listeners)) {
|
||||
FlatView tmpview = { .nr = 0 }, *old_view2 = old_view;
|
||||
|
||||
if (!old_view2) {
|
||||
old_view2 = &tmpview;
|
||||
}
|
||||
address_space_update_topology_pass(as, old_view2, new_view, false);
|
||||
address_space_update_topology_pass(as, old_view2, new_view, true);
|
||||
}
|
||||
|
||||
/* Writes are protected by the BQL. */
|
||||
atomic_rcu_set(&as->current_map, new_view);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面会生成AddressSpace的flatview。flatview是什么意思呢?
|
||||
|
||||
我们可以看到,在AddressSpace里面,除了树形结构的MemoryRegion之外,还有一个flatview结构,其实这个结构就是把这样一个树形的内存结构变成平的内存结构。因为树形内存结构比较容易管理,但是平的内存结构,比较方便和内核里面通信,来请求物理内存。虽然操作系统内核里面也是用树形结构来表示内存区域的,但是用户态向内核申请内存的时候,会按照平的、连续的模式进行申请。这里,qemu在用户态,所以要做这样一个转换。
|
||||
|
||||
在address_space_set_flatview中,我们将老的flatview和新的flatview进行比较。如果不同,说明内存结构发生了变化,会调用address_space_update_topology_pass->MEMORY_LISTENER_UPDATE_REGION->MEMORY_LISTENER_CALL。
|
||||
|
||||
这里面调用所有的listener。但是,这个逻辑这里不会执行的。这是因为这里内存处于初始化的阶段,全局的flat_views里面肯定找不到。因而generate_memory_topology第一次生成了FlatView,然后才调用了address_space_set_flatview。这里面,老的flatview和新的flatview一定是一样的。
|
||||
|
||||
但是,请你记住这个逻辑,到这里我们还没解析qemu有关内存的参数,所以这里添加的MemoryRegion虽然是一个根,但是是空的,是为了管理使用的,后面真的添加内存的时候,这个逻辑还会调用到。
|
||||
|
||||
我们再回到qemu启动的main函数中。接下来的初始化过程会调用pc_init1。在这里面,对于CPU虚拟化,我们会调用pc_cpus_init。这个我们在上一节已经讲过了。另外,pc_init1还会调用pc_memory_init,进行内存的虚拟化,我们这里解析这一部分。
|
||||
|
||||
```
|
||||
void pc_memory_init(PCMachineState *pcms,
|
||||
MemoryRegion *system_memory,
|
||||
MemoryRegion *rom_memory,
|
||||
MemoryRegion **ram_memory)
|
||||
{
|
||||
int linux_boot, i;
|
||||
MemoryRegion *ram, *option_rom_mr;
|
||||
MemoryRegion *ram_below_4g, *ram_above_4g;
|
||||
FWCfgState *fw_cfg;
|
||||
MachineState *machine = MACHINE(pcms);
|
||||
PCMachineClass *pcmc = PC_MACHINE_GET_CLASS(pcms);
|
||||
......
|
||||
/* Allocate RAM. We allocate it as a single memory region and use
|
||||
* aliases to address portions of it, mostly for backwards compatibility with older qemus that used qemu_ram_alloc().
|
||||
*/
|
||||
ram = g_malloc(sizeof(*ram));
|
||||
memory_region_allocate_system_memory(ram, NULL, "pc.ram",
|
||||
machine->ram_size);
|
||||
*ram_memory = ram;
|
||||
ram_below_4g = g_malloc(sizeof(*ram_below_4g));
|
||||
memory_region_init_alias(ram_below_4g, NULL, "ram-below-4g", ram,
|
||||
0, pcms->below_4g_mem_size);
|
||||
memory_region_add_subregion(system_memory, 0, ram_below_4g);
|
||||
e820_add_entry(0, pcms->below_4g_mem_size, E820_RAM);
|
||||
if (pcms->above_4g_mem_size > 0) {
|
||||
ram_above_4g = g_malloc(sizeof(*ram_above_4g));
|
||||
memory_region_init_alias(ram_above_4g, NULL, "ram-above-4g", ram, pcms->below_4g_mem_size, pcms->above_4g_mem_size);
|
||||
memory_region_add_subregion(system_memory, 0x100000000ULL,
|
||||
ram_above_4g);
|
||||
e820_add_entry(0x100000000ULL, pcms->above_4g_mem_size, E820_RAM);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在pc_memory_init中,我们已经知道了虚拟机要申请的内存ram_size,于是通过memory_region_allocate_system_memory来申请内存。
|
||||
|
||||
接下来的调用链为:memory_region_allocate_system_memory->allocate_system_memory_nonnuma->memory_region_init_ram_nomigrate->memory_region_init_ram_shared_nomigrate。
|
||||
|
||||
```
|
||||
void memory_region_init_ram_shared_nomigrate(MemoryRegion *mr,
|
||||
Object *owner,
|
||||
const char *name,
|
||||
uint64_t size,
|
||||
bool share,
|
||||
Error **errp)
|
||||
{
|
||||
Error *err = NULL;
|
||||
memory_region_init(mr, owner, name, size);
|
||||
mr->ram = true;
|
||||
mr->terminates = true;
|
||||
mr->destructor = memory_region_destructor_ram;
|
||||
mr->ram_block = qemu_ram_alloc(size, share, mr, &err);
|
||||
......
|
||||
}
|
||||
|
||||
static
|
||||
RAMBlock *qemu_ram_alloc_internal(ram_addr_t size, ram_addr_t max_size, void (*resized)(const char*,uint64_t length,void *host),void *host, bool resizeable, bool share,MemoryRegion *mr, Error **errp)
|
||||
{
|
||||
RAMBlock *new_block;
|
||||
size = HOST_PAGE_ALIGN(size);
|
||||
max_size = HOST_PAGE_ALIGN(max_size);
|
||||
new_block = g_malloc0(sizeof(*new_block));
|
||||
new_block->mr = mr;
|
||||
new_block->resized = resized;
|
||||
new_block->used_length = size;
|
||||
new_block->max_length = max_size;
|
||||
new_block->fd = -1;
|
||||
new_block->page_size = getpagesize();
|
||||
new_block->host = host;
|
||||
......
|
||||
ram_block_add(new_block, &local_err, share);
|
||||
return new_block;
|
||||
}
|
||||
|
||||
static void ram_block_add(RAMBlock *new_block, Error **errp, bool shared)
|
||||
{
|
||||
RAMBlock *block;
|
||||
RAMBlock *last_block = NULL;
|
||||
ram_addr_t old_ram_size, new_ram_size;
|
||||
Error *err = NULL;
|
||||
old_ram_size = last_ram_page();
|
||||
new_block->offset = find_ram_offset(new_block->max_length);
|
||||
if (!new_block->host) {
|
||||
new_block->host = phys_mem_alloc(new_block->max_length, &new_block->mr->align, shared);
|
||||
......
|
||||
}
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面,我们会调用qemu_ram_alloc,创建一个RAMBlock用来表示内存块。这里面调用ram_block_add->phys_mem_alloc。phys_mem_alloc是一个函数指针,指向函数qemu_anon_ram_alloc,这里面调用qemu_ram_mmap,在qemu_ram_mmap中调用mmap分配内存。
|
||||
|
||||
```
|
||||
static void *(*phys_mem_alloc)(size_t size, uint64_t *align, bool shared) = qemu_anon_ram_alloc;
|
||||
|
||||
void *qemu_anon_ram_alloc(size_t size, uint64_t *alignment, bool shared)
|
||||
{
|
||||
size_t align = QEMU_VMALLOC_ALIGN;
|
||||
void *ptr = qemu_ram_mmap(-1, size, align, shared);
|
||||
......
|
||||
if (alignment) {
|
||||
*alignment = align;
|
||||
}
|
||||
return ptr;
|
||||
}
|
||||
|
||||
void *qemu_ram_mmap(int fd, size_t size, size_t align, bool shared)
|
||||
{
|
||||
int flags;
|
||||
int guardfd;
|
||||
size_t offset;
|
||||
size_t pagesize;
|
||||
size_t total;
|
||||
void *guardptr;
|
||||
void *ptr;
|
||||
......
|
||||
total = size + align;
|
||||
guardfd = -1;
|
||||
pagesize = getpagesize();
|
||||
flags = MAP_PRIVATE | MAP_ANONYMOUS;
|
||||
guardptr = mmap(0, total, PROT_NONE, flags, guardfd, 0);
|
||||
......
|
||||
flags = MAP_FIXED;
|
||||
flags |= fd == -1 ? MAP_ANONYMOUS : 0;
|
||||
flags |= shared ? MAP_SHARED : MAP_PRIVATE;
|
||||
offset = QEMU_ALIGN_UP((uintptr_t)guardptr, align) - (uintptr_t)guardptr;
|
||||
ptr = mmap(guardptr + offset, size, PROT_READ | PROT_WRITE, flags, fd, 0);
|
||||
......
|
||||
return ptr;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们回到pc_memory_init,通过memory_region_allocate_system_memory申请到内存以后,为了兼容过去的版本,我们分成两个MemoryRegion进行管理,一个是ram_below_4g,一个是ram_above_4g。对于这两个MemoryRegion,我们都会初始化一个alias,也即别名,意思是说,两个MemoryRegion其实都指向memory_region_allocate_system_memory分配的内存,只不过分成两个部分,起两个别名指向不同的区域。
|
||||
|
||||
这两部分MemoryRegion都会调用memory_region_add_subregion,将这两部分作为子的内存区域添加到system_memory这棵树上。
|
||||
|
||||
接下来的调用链为:memory_region_add_subregion->memory_region_add_subregion_common->memory_region_update_container_subregions。
|
||||
|
||||
```
|
||||
static void memory_region_update_container_subregions(MemoryRegion *subregion)
|
||||
{
|
||||
MemoryRegion *mr = subregion->container;
|
||||
MemoryRegion *other;
|
||||
|
||||
memory_region_transaction_begin();
|
||||
|
||||
memory_region_ref(subregion);
|
||||
QTAILQ_FOREACH(other, &mr->subregions, subregions_link) {
|
||||
if (subregion->priority >= other->priority) {
|
||||
QTAILQ_INSERT_BEFORE(other, subregion, subregions_link);
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
QTAILQ_INSERT_TAIL(&mr->subregions, subregion, subregions_link);
|
||||
done:
|
||||
memory_region_update_pending |= mr->enabled && subregion->enabled;
|
||||
memory_region_transaction_commit();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在memory_region_update_container_subregions中,我们会将子区域放到链表中,然后调用memory_region_transaction_commit。在这里面,我们会调用address_space_set_flatview。因为内存区域变了,flatview也会变,就像上面分析过的一样,listener会被调用。
|
||||
|
||||
因为添加了一个MemoryRegion,region_add也即kvm_region_add。
|
||||
|
||||
```
|
||||
static void kvm_region_add(MemoryListener *listener,
|
||||
MemoryRegionSection *section)
|
||||
{
|
||||
KVMMemoryListener *kml = container_of(listener, KVMMemoryListener, listener);
|
||||
kvm_set_phys_mem(kml, section, true);
|
||||
}
|
||||
|
||||
static void kvm_set_phys_mem(KVMMemoryListener *kml,
|
||||
MemoryRegionSection *section, bool add)
|
||||
{
|
||||
KVMSlot *mem;
|
||||
int err;
|
||||
MemoryRegion *mr = section->mr;
|
||||
bool writeable = !mr->readonly && !mr->rom_device;
|
||||
hwaddr start_addr, size;
|
||||
void *ram;
|
||||
......
|
||||
size = kvm_align_section(section, &start_addr);
|
||||
......
|
||||
/* use aligned delta to align the ram address */
|
||||
ram = memory_region_get_ram_ptr(mr) + section->offset_within_region + (start_addr - section->offset_within_address_space);
|
||||
......
|
||||
/* register the new slot */
|
||||
mem = kvm_alloc_slot(kml);
|
||||
mem->memory_size = size;
|
||||
mem->start_addr = start_addr;
|
||||
mem->ram = ram;
|
||||
mem->flags = kvm_mem_flags(mr);
|
||||
|
||||
err = kvm_set_user_memory_region(kml, mem, true);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
kvm_region_add调用的是kvm_set_phys_mem,这里面分配一个用于放这块内存的KVMSlot结构,就像一个内存条一样,当然这是在用户态模拟出来的内存条,放在KVMState结构里面。这个结构是我们上一节创建虚拟机的时候创建的。
|
||||
|
||||
接下来,kvm_set_user_memory_region就会将用户态模拟出来的内存条,和内核中的KVM模块关联起来。
|
||||
|
||||
```
|
||||
static int kvm_set_user_memory_region(KVMMemoryListener *kml, KVMSlot *slot, bool new)
|
||||
{
|
||||
KVMState *s = kvm_state;
|
||||
struct kvm_userspace_memory_region mem;
|
||||
int ret;
|
||||
|
||||
mem.slot = slot->slot | (kml->as_id << 16);
|
||||
mem.guest_phys_addr = slot->start_addr;
|
||||
mem.userspace_addr = (unsigned long)slot->ram;
|
||||
mem.flags = slot->flags;
|
||||
......
|
||||
mem.memory_size = slot->memory_size;
|
||||
ret = kvm_vm_ioctl(s, KVM_SET_USER_MEMORY_REGION, &mem);
|
||||
slot->old_flags = mem.flags;
|
||||
......
|
||||
return ret;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
终于,在这里,我们又看到了可以和内核通信的kvm_vm_ioctl。我们来看内核收到KVM_SET_USER_MEMORY_REGION会做哪些事情。
|
||||
|
||||
```
|
||||
static long kvm_vm_ioctl(struct file *filp,
|
||||
unsigned int ioctl, unsigned long arg)
|
||||
{
|
||||
struct kvm *kvm = filp->private_data;
|
||||
void __user *argp = (void __user *)arg;
|
||||
switch (ioctl) {
|
||||
case KVM_SET_USER_MEMORY_REGION: {
|
||||
struct kvm_userspace_memory_region kvm_userspace_mem;
|
||||
if (copy_from_user(&kvm_userspace_mem, argp,
|
||||
sizeof(kvm_userspace_mem)))
|
||||
goto out;
|
||||
r = kvm_vm_ioctl_set_memory_region(kvm, &kvm_userspace_mem);
|
||||
break;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来的调用链为:kvm_vm_ioctl_set_memory_region->kvm_set_memory_region->__kvm_set_memory_region。
|
||||
|
||||
```
|
||||
int __kvm_set_memory_region(struct kvm *kvm,
|
||||
const struct kvm_userspace_memory_region *mem)
|
||||
{
|
||||
int r;
|
||||
gfn_t base_gfn;
|
||||
unsigned long npages;
|
||||
struct kvm_memory_slot *slot;
|
||||
struct kvm_memory_slot old, new;
|
||||
struct kvm_memslots *slots = NULL, *old_memslots;
|
||||
int as_id, id;
|
||||
enum kvm_mr_change change;
|
||||
......
|
||||
as_id = mem->slot >> 16;
|
||||
id = (u16)mem->slot;
|
||||
|
||||
slot = id_to_memslot(__kvm_memslots(kvm, as_id), id);
|
||||
base_gfn = mem->guest_phys_addr >> PAGE_SHIFT;
|
||||
npages = mem->memory_size >> PAGE_SHIFT;
|
||||
......
|
||||
new = old = *slot;
|
||||
|
||||
new.id = id;
|
||||
new.base_gfn = base_gfn;
|
||||
new.npages = npages;
|
||||
new.flags = mem->flags;
|
||||
......
|
||||
if (change == KVM_MR_CREATE) {
|
||||
new.userspace_addr = mem->userspace_addr;
|
||||
|
||||
if (kvm_arch_create_memslot(kvm, &new, npages))
|
||||
goto out_free;
|
||||
}
|
||||
......
|
||||
slots = kvzalloc(sizeof(struct kvm_memslots), GFP_KERNEL);
|
||||
memcpy(slots, __kvm_memslots(kvm, as_id), sizeof(struct kvm_memslots));
|
||||
......
|
||||
r = kvm_arch_prepare_memory_region(kvm, &new, mem, change);
|
||||
|
||||
update_memslots(slots, &new);
|
||||
old_memslots = install_new_memslots(kvm, as_id, slots);
|
||||
|
||||
kvm_arch_commit_memory_region(kvm, mem, &old, &new, change);
|
||||
return 0;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在用户态每个KVMState有多个KVMSlot,在内核里面,同样每个struct kvm也有多个struct kvm_memory_slot,两者是对应起来的。
|
||||
|
||||
```
|
||||
//用户态
|
||||
struct KVMState
|
||||
{
|
||||
......
|
||||
int nr_slots;
|
||||
......
|
||||
KVMMemoryListener memory_listener;
|
||||
......
|
||||
};
|
||||
|
||||
typedef struct KVMMemoryListener {
|
||||
MemoryListener listener;
|
||||
KVMSlot *slots;
|
||||
int as_id;
|
||||
} KVMMemoryListener
|
||||
|
||||
typedef struct KVMSlot
|
||||
{
|
||||
hwaddr start_addr;
|
||||
ram_addr_t memory_size;
|
||||
void *ram;
|
||||
int slot;
|
||||
int flags;
|
||||
int old_flags;
|
||||
} KVMSlot;
|
||||
|
||||
//内核态
|
||||
struct kvm {
|
||||
spinlock_t mmu_lock;
|
||||
struct mutex slots_lock;
|
||||
struct mm_struct *mm; /* userspace tied to this vm */
|
||||
struct kvm_memslots __rcu *memslots[KVM_ADDRESS_SPACE_NUM];
|
||||
......
|
||||
}
|
||||
|
||||
struct kvm_memslots {
|
||||
u64 generation;
|
||||
struct kvm_memory_slot memslots[KVM_MEM_SLOTS_NUM];
|
||||
/* The mapping table from slot id to the index in memslots[]. */
|
||||
short id_to_index[KVM_MEM_SLOTS_NUM];
|
||||
atomic_t lru_slot;
|
||||
int used_slots;
|
||||
};
|
||||
|
||||
struct kvm_memory_slot {
|
||||
gfn_t base_gfn;//根据guest_phys_addr计算
|
||||
unsigned long npages;
|
||||
unsigned long *dirty_bitmap;
|
||||
struct kvm_arch_memory_slot arch;
|
||||
unsigned long userspace_addr;
|
||||
u32 flags;
|
||||
short id;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
并且,id_to_memslot函数可以根据用户态的slot号得到内核态的slot结构。
|
||||
|
||||
如果传进来的参数是KVM_MR_CREATE,表示要创建一个新的内存条,就会调用kvm_arch_create_memslot来创建kvm_memory_slot的成员kvm_arch_memory_slot。
|
||||
|
||||
接下来就是创建kvm_memslots结构,填充这个结构,然后通过install_new_memslots将这个新的内存条,添加到struct kvm结构中。
|
||||
|
||||
至此,用户态的内存结构和内核态的内存结构算是对应了起来。
|
||||
|
||||
## 页面分配和映射
|
||||
|
||||
上面对于内存的管理,还只是停留在元数据的管理。对于内存的分配与映射,我们还没有涉及,接下来,我们就来看看,页面是如何进行分配和映射的。
|
||||
|
||||
上面咱们说了,内存映射对于虚拟机来讲是一件非常麻烦的事情,从GVA到GPA到HVA到HPA,性能很差,为了解决这个问题,有两种主要的思路。
|
||||
|
||||
### 影子页表
|
||||
|
||||
第一种方式就是软件的方式,**影子页表** (Shadow Page Table)。
|
||||
|
||||
按照咱们在内存管理那一节讲的,内存映射要通过页表来管理,页表地址应该放在cr3寄存器里面。本来的过程是,客户机要通过cr3找到客户机的页表,实现从GVA到GPA的转换,然后在宿主机上,要通过cr3找到宿主机的页表,实现从HVA到HPA的转换。
|
||||
|
||||
为了实现客户机虚拟地址空间到宿主机物理地址空间的直接映射。客户机中每个进程都有自己的虚拟地址空间,所以KVM需要为客户机中的每个进程页表都要维护一套相应的影子页表。
|
||||
|
||||
在客户机访问内存时,使用的不是客户机的原来的页表,而是这个页表对应的影子页表,从而实现了从客户机虚拟地址到宿主机物理地址的直接转换。而且,在TLB和CPU 缓存上缓存的是来自影子页表中客户机虚拟地址和宿主机物理地址之间的映射,也因此提高了缓存的效率。
|
||||
|
||||
但是影子页表的引入也意味着 KVM 需要为每个客户机的每个进程的页表都要维护一套相应的影子页表,内存占用比较大,而且客户机页表和和影子页表也需要进行实时同步。
|
||||
|
||||
### 扩展页表
|
||||
|
||||
于是就有了第二种方式,就是硬件的方式,Intel的EPT(Extent Page Table,扩展页表)技术。
|
||||
|
||||
EPT在原有客户机页表对客户机虚拟地址到客户机物理地址映射的基础上,又引入了 EPT页表来实现客户机物理地址到宿主机物理地址的另一次映射。客户机运行时,客户机页表被载入 CR3,而EPT页表被载入专门的EPT 页表指针寄存器 EPTP。
|
||||
|
||||
有了EPT,在客户机物理地址到宿主机物理地址转换的过程中,缺页会产生EPT 缺页异常。KVM首先根据引起异常的客户机物理地址,映射到对应的宿主机虚拟地址,然后为此虚拟地址分配新的物理页,最后 KVM 再更新 EPT 页表,建立起引起异常的客户机物理地址到宿主机物理地址之间的映射。
|
||||
|
||||
KVM 只需为每个客户机维护一套 EPT 页表,也大大减少了内存的开销。
|
||||
|
||||
这里,我们重点看第二种方式。因为使用了EPT之后,客户机里面的页表映射,也即从GVA到GPA的转换,还是用传统的方式,和在内存管理那一章讲的没有什么区别。而EPT重点帮我们解决的就是从GPA到HPA的转换问题。因为要经过两次页表,所以EPT又称为tdp(two dimentional paging)。
|
||||
|
||||
EPT的页表结构也是分为四层,EPT Pointer (EPTP)指向PML4的首地址。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/30/02e4740398bc3685f366351260ae7230.jpg" alt="">
|
||||
|
||||
管理物理页面的Page结构和咱们讲内存管理那一章是一样的。EPT页表也需要存放在一个页中,这些页要用kvm_mmu_page这个结构来管理。
|
||||
|
||||
当一个虚拟机运行,进入客户机模式的时候,我们上一节解析过,它会调用vcpu_enter_guest函数,这里面会调用kvm_mmu_reload->kvm_mmu_load。
|
||||
|
||||
```
|
||||
int kvm_mmu_load(struct kvm_vcpu *vcpu)
|
||||
{
|
||||
......
|
||||
r = mmu_topup_memory_caches(vcpu);
|
||||
r = mmu_alloc_roots(vcpu);
|
||||
kvm_mmu_sync_roots(vcpu);
|
||||
/* set_cr3() should ensure TLB has been flushed */
|
||||
vcpu->arch.mmu.set_cr3(vcpu, vcpu->arch.mmu.root_hpa);
|
||||
......
|
||||
}
|
||||
|
||||
static int mmu_alloc_roots(struct kvm_vcpu *vcpu)
|
||||
{
|
||||
if (vcpu->arch.mmu.direct_map)
|
||||
return mmu_alloc_direct_roots(vcpu);
|
||||
else
|
||||
return mmu_alloc_shadow_roots(vcpu);
|
||||
}
|
||||
|
||||
static int mmu_alloc_direct_roots(struct kvm_vcpu *vcpu)
|
||||
{
|
||||
struct kvm_mmu_page *sp;
|
||||
unsigned i;
|
||||
|
||||
if (vcpu->arch.mmu.shadow_root_level == PT64_ROOT_LEVEL) {
|
||||
spin_lock(&vcpu->kvm->mmu_lock);
|
||||
make_mmu_pages_available(vcpu);
|
||||
sp = kvm_mmu_get_page(vcpu, 0, 0, PT64_ROOT_LEVEL, 1, ACC_ALL);
|
||||
++sp->root_count;
|
||||
spin_unlock(&vcpu->kvm->mmu_lock);
|
||||
vcpu->arch.mmu.root_hpa = __pa(sp->spt);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里构建的是页表的根部,也即顶级页表,并且设置cr3来刷新TLB。mmu_alloc_roots会调用mmu_alloc_direct_roots,因为我们用的是EPT模式,而非影子表。在mmu_alloc_direct_roots中,kvm_mmu_get_page会分配一个kvm_mmu_page,来存放顶级页表项。
|
||||
|
||||
接下来,当虚拟机真的要访问内存的时候,会发现有的页表没有建立,有的物理页没有分配,这都会触发缺页异常,在KVM里面会发送VM-Exit,从客户机模式转换为宿主机模式,来修复这个缺失的页表或者物理页。
|
||||
|
||||
```
|
||||
static int (*const kvm_vmx_exit_handlers[])(struct kvm_vcpu *vcpu) = {
|
||||
[EXIT_REASON_EXCEPTION_NMI] = handle_exception,
|
||||
[EXIT_REASON_EXTERNAL_INTERRUPT] = handle_external_interrupt,
|
||||
[EXIT_REASON_IO_INSTRUCTION] = handle_io,
|
||||
......
|
||||
[EXIT_REASON_EPT_VIOLATION] = handle_ept_violation,
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
咱们前面讲过,虚拟机退出客户机模式有很多种原因,例如接收到中断、接收到I/O等,EPT的缺页异常也是一种类型,我们称为EXIT_REASON_EPT_VIOLATION,对应的处理函数是handle_ept_violation。
|
||||
|
||||
```
|
||||
static int handle_ept_violation(struct kvm_vcpu *vcpu)
|
||||
{
|
||||
gpa_t gpa;
|
||||
......
|
||||
gpa = vmcs_read64(GUEST_PHYSICAL_ADDRESS);
|
||||
......
|
||||
vcpu->arch.gpa_available = true;
|
||||
vcpu->arch.exit_qualification = exit_qualification;
|
||||
|
||||
return kvm_mmu_page_fault(vcpu, gpa, error_code, NULL, 0);
|
||||
}
|
||||
|
||||
int kvm_mmu_page_fault(struct kvm_vcpu *vcpu, gva_t cr2, u64 error_code,
|
||||
void *insn, int insn_len)
|
||||
{
|
||||
......
|
||||
r = vcpu->arch.mmu.page_fault(vcpu, cr2, lower_32_bits(error_code),false);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在handle_ept_violation里面,我们从VMCS中得到没有解析成功的GPA,也即客户机的物理地址,然后调用kvm_mmu_page_fault,看为什么解析不成功。kvm_mmu_page_fault会调用page_fault函数,其实是tdp_page_fault函数。tdp的意思就是EPT,前面我们解释过了。
|
||||
|
||||
```
|
||||
static int tdp_page_fault(struct kvm_vcpu *vcpu, gva_t gpa, u32 error_code, bool prefault)
|
||||
{
|
||||
kvm_pfn_t pfn;
|
||||
int r;
|
||||
int level;
|
||||
bool force_pt_level;
|
||||
gfn_t gfn = gpa >> PAGE_SHIFT;
|
||||
unsigned long mmu_seq;
|
||||
int write = error_code & PFERR_WRITE_MASK;
|
||||
bool map_writable;
|
||||
|
||||
r = mmu_topup_memory_caches(vcpu);
|
||||
level = mapping_level(vcpu, gfn, &force_pt_level);
|
||||
......
|
||||
if (try_async_pf(vcpu, prefault, gfn, gpa, &pfn, write, &map_writable))
|
||||
return 0;
|
||||
|
||||
if (handle_abnormal_pfn(vcpu, 0, gfn, pfn, ACC_ALL, &r))
|
||||
return r;
|
||||
|
||||
make_mmu_pages_available(vcpu);
|
||||
r = __direct_map(vcpu, write, map_writable, level, gfn, pfn, prefault);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
既然没有映射,就应该加上映射,tdp_page_fault就是干这个事情的。
|
||||
|
||||
在tdp_page_fault这个函数开头,我们通过gpa,也即客户机的物理地址得到客户机的页号gfn。接下来,我们要通过调用try_async_pf得到宿主机的物理地址对应的页号,也即真正的物理页的页号,然后通过__direct_map将两者关联起来。
|
||||
|
||||
```
|
||||
static bool try_async_pf(struct kvm_vcpu *vcpu, bool prefault, gfn_t gfn, gva_t gva, kvm_pfn_t *pfn, bool write, bool *writable)
|
||||
{
|
||||
struct kvm_memory_slot *slot;
|
||||
bool async;
|
||||
|
||||
slot = kvm_vcpu_gfn_to_memslot(vcpu, gfn);
|
||||
async = false;
|
||||
*pfn = __gfn_to_pfn_memslot(slot, gfn, false, &async, write, writable);
|
||||
if (!async)
|
||||
return false; /* *pfn has correct page already */
|
||||
|
||||
if (!prefault && kvm_can_do_async_pf(vcpu)) {
|
||||
if (kvm_find_async_pf_gfn(vcpu, gfn)) {
|
||||
kvm_make_request(KVM_REQ_APF_HALT, vcpu);
|
||||
return true;
|
||||
} else if (kvm_arch_setup_async_pf(vcpu, gva, gfn))
|
||||
return true;
|
||||
}
|
||||
*pfn = __gfn_to_pfn_memslot(slot, gfn, false, NULL, write, writable);
|
||||
return false;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在try_async_pf中,要想得到pfn,也即物理页的页号,会先通过kvm_vcpu_gfn_to_memslot,根据客户机的物理地址对应的页号找到内存条,然后调用__gfn_to_pfn_memslot,根据内存条找到pfn。
|
||||
|
||||
```
|
||||
kvm_pfn_t __gfn_to_pfn_memslot(struct kvm_memory_slot *slot, gfn_t gfn,bool atomic, bool *async, bool write_fault,bool *writable)
|
||||
{
|
||||
unsigned long addr = __gfn_to_hva_many(slot, gfn, NULL, write_fault);
|
||||
......
|
||||
return hva_to_pfn(addr, atomic, async, write_fault,
|
||||
writable);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在__gfn_to_pfn_memslot中,我们会调用__gfn_to_hva_many,从客户机物理地址对应的页号,得到宿主机虚拟地址hva,然后从宿主机虚拟地址到宿主机物理地址,调用的是hva_to_pfn。
|
||||
|
||||
hva_to_pfn会调用hva_to_pfn_slow。
|
||||
|
||||
```
|
||||
static int hva_to_pfn_slow(unsigned long addr, bool *async, bool write_fault,
|
||||
bool *writable, kvm_pfn_t *pfn)
|
||||
{
|
||||
struct page *page[1];
|
||||
int npages = 0;
|
||||
......
|
||||
if (async) {
|
||||
npages = get_user_page_nowait(addr, write_fault, page);
|
||||
} else {
|
||||
......
|
||||
npages = get_user_pages_unlocked(addr, 1, page, flags);
|
||||
}
|
||||
......
|
||||
*pfn = page_to_pfn(page[0]);
|
||||
return npages;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在hva_to_pfn_slow中,我们要先调用get_user_page_nowait,得到一个物理页面,然后再调用page_to_pfn将物理页面转换成为物理页号。
|
||||
|
||||
无论是哪一种get_user_pages_XXX,最终都会调用__get_user_pages函数。这里面会调用faultin_page,在faultin_page中我们会调用handle_mm_fault。看到这个是不是很熟悉?这就是咱们内存管理那一章讲的缺页异常的逻辑,分配一个物理内存。
|
||||
|
||||
至此,try_async_pf得到了物理页面,并且转换为对应的物理页号。
|
||||
|
||||
接下来,__direct_map会关联客户机物理页号和宿主机物理页号。
|
||||
|
||||
```
|
||||
static int __direct_map(struct kvm_vcpu *vcpu, int write, int map_writable,
|
||||
int level, gfn_t gfn, kvm_pfn_t pfn, bool prefault)
|
||||
{
|
||||
struct kvm_shadow_walk_iterator iterator;
|
||||
struct kvm_mmu_page *sp;
|
||||
int emulate = 0;
|
||||
gfn_t pseudo_gfn;
|
||||
|
||||
if (!VALID_PAGE(vcpu->arch.mmu.root_hpa))
|
||||
return 0;
|
||||
|
||||
for_each_shadow_entry(vcpu, (u64)gfn << PAGE_SHIFT, iterator) {
|
||||
if (iterator.level == level) {
|
||||
emulate = mmu_set_spte(vcpu, iterator.sptep, ACC_ALL,
|
||||
write, level, gfn, pfn, prefault,
|
||||
map_writable);
|
||||
direct_pte_prefetch(vcpu, iterator.sptep);
|
||||
++vcpu->stat.pf_fixed;
|
||||
break;
|
||||
}
|
||||
|
||||
drop_large_spte(vcpu, iterator.sptep);
|
||||
if (!is_shadow_present_pte(*iterator.sptep)) {
|
||||
u64 base_addr = iterator.addr;
|
||||
|
||||
base_addr &= PT64_LVL_ADDR_MASK(iterator.level);
|
||||
pseudo_gfn = base_addr >> PAGE_SHIFT;
|
||||
sp = kvm_mmu_get_page(vcpu, pseudo_gfn, iterator.addr,
|
||||
iterator.level - 1, 1, ACC_ALL);
|
||||
|
||||
link_shadow_page(vcpu, iterator.sptep, sp);
|
||||
}
|
||||
}
|
||||
return emulate;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
__direct_map首先判断页表的根是否存在,当然存在,我们刚才初始化了。
|
||||
|
||||
接下来是for_each_shadow_entry一个循环。每一个循环中,先是会判断需要映射的level,是否正是当前循环的这个iterator.level。如果是,则说明是叶子节点,直接映射真正的物理页面pfn,然后退出。接着是非叶子节点的情形,判断如果这一项指向的页表项不存在,就要建立页表项,通过kvm_mmu_get_page得到保存页表项的页面,然后将这一项指向下一级的页表页面。
|
||||
|
||||
至此,内存映射就结束了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
我们这里来总结一下,虚拟机的内存管理也是需要用户态的qemu和内核态的KVM共同完成。为了加速内存映射,需要借助硬件的EPT技术。
|
||||
|
||||
在用户态qemu中,有一个结构AddressSpace address_space_memory来表示虚拟机的系统内存,这个内存可能包含多个内存区域struct MemoryRegion,组成树形结构,指向由mmap分配的虚拟内存。
|
||||
|
||||
在AddressSpace结构中,有一个struct KVMMemoryListener,当有新的内存区域添加的时候,会被通知调用kvm_region_add来通知内核。
|
||||
|
||||
在用户态qemu中,对于虚拟机有一个结构struct KVMState表示这个虚拟机,这个结构会指向一个数组的struct KVMSlot表示这个虚拟机的多个内存条,KVMSlot中有一个void *ram指针指向mmap分配的那块虚拟内存。
|
||||
|
||||
kvm_region_add是通过ioctl来通知内核KVM的,会给内核KVM发送一个KVM_SET_USER_MEMORY_REGION消息,表示用户态qemu添加了一个内存区域,内核KVM也应该添加一个相应的内存区域。
|
||||
|
||||
和用户态qemu对应的内核KVM,对于虚拟机有一个结构struct kvm表示这个虚拟机,这个结构会指向一个数组的struct kvm_memory_slot表示这个虚拟机的多个内存条,kvm_memory_slot中有起始页号,页面数目,表示这个虚拟机的物理内存空间。
|
||||
|
||||
虚拟机的物理内存空间里面的页面当然不是一开始就映射到物理页面的,只有当虚拟机的内存被访问的时候,也即mmap分配的虚拟内存空间被访问的时候,先查看EPT页表,是否已经映射过,如果已经映射过,则经过四级页表映射,就能访问到物理页面。
|
||||
|
||||
如果没有映射过,则虚拟机会通过VM-Exit指令回到宿主机模式,通过handle_ept_violation补充页表映射。先是通过handle_mm_fault为虚拟机的物理内存空间分配真正的物理页面,然后通过__direct_map添加EPT页表映射。
|
||||
|
||||
<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="">
|
||||
@@ -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(&virtio_blk_info);
|
||||
}
|
||||
|
||||
type_init(virtio_register_types)
|
||||
|
||||
```
|
||||
|
||||
Virtio Block Device这种类的定义是有多层继承关系的。TYPE_VIRTIO_BLK的父类是TYPE_VIRTIO_DEVICE,TYPE_VIRTIO_DEVICE的父类是TYPE_DEVICE,TYPE_DEVICE的父类是TYPE_OBJECT。到头了。
|
||||
|
||||
type_init用于注册这种类。这里面每一层都有class_init,用于从TypeImpl生产xxxClass。还有instance_init,可以将xxxClass初始化为实例。
|
||||
|
||||
在TYPE_VIRTIO_BLK层的class_init函数virtio_blk_class_init中,定义了DeviceClass的realize函数为virtio_blk_device_realize,这一点在[CPU](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 = &s->conf;
|
||||
......
|
||||
blkconf_blocksizes(&conf->conf);
|
||||
virtio_blk_set_config_size(s, s->host_features);
|
||||
virtio_init(vdev, "virtio-blk", VIRTIO_ID_BLOCK, s->config_size);
|
||||
s->blk = conf->conf.blk;
|
||||
s->rq = NULL;
|
||||
s->sector_mask = (s->conf.conf.logical_block_size / BDRV_SECTOR_SIZE) - 1;
|
||||
for (i = 0; i < conf->num_queues; i++) {
|
||||
virtio_add_queue(vdev, conf->queue_size, virtio_blk_handle_output);
|
||||
}
|
||||
virtio_blk_data_plane_create(vdev, conf, &s->dataplane, &err);
|
||||
s->change = qemu_add_vm_change_state_handler(virtio_blk_dma_restart_cb, s);
|
||||
blk_set_dev_ops(s->blk, &virtio_block_ops, s);
|
||||
blk_set_guest_block_size(s->blk, s->conf.conf.logical_block_size);
|
||||
blk_iostatus_enable(s->blk);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在virtio_blk_device_realize函数中,我们先是通过virtio_init初始化VirtIODevice结构。
|
||||
|
||||
```
|
||||
void virtio_init(VirtIODevice *vdev, const char *name,
|
||||
uint16_t device_id, size_t config_size)
|
||||
{
|
||||
BusState *qbus = qdev_get_parent_bus(DEVICE(vdev));
|
||||
VirtioBusClass *k = VIRTIO_BUS_GET_CLASS(qbus);
|
||||
int i;
|
||||
int nvectors = k->query_nvectors ? k->query_nvectors(qbus->parent) : 0;
|
||||
|
||||
if (nvectors) {
|
||||
vdev->vector_queues =
|
||||
g_malloc0(sizeof(*vdev->vector_queues) * nvectors);
|
||||
}
|
||||
vdev->device_id = device_id;
|
||||
vdev->status = 0;
|
||||
atomic_set(&vdev->isr, 0);
|
||||
vdev->queue_sel = 0;
|
||||
vdev->config_vector = VIRTIO_NO_VECTOR;
|
||||
vdev->vq = g_malloc0(sizeof(VirtQueue) * VIRTIO_QUEUE_MAX);
|
||||
vdev->vm_running = runstate_is_running();
|
||||
vdev->broken = false;
|
||||
for (i = 0; i < VIRTIO_QUEUE_MAX; i++) {
|
||||
vdev->vq[i].vector = VIRTIO_NO_VECTOR;
|
||||
vdev->vq[i].vdev = vdev;
|
||||
vdev->vq[i].queue_index = i;
|
||||
}
|
||||
vdev->name = name;
|
||||
vdev->config_len = config_size;
|
||||
if (vdev->config_len) {
|
||||
vdev->config = g_malloc0(config_size);
|
||||
} else {
|
||||
vdev->config = NULL;
|
||||
}
|
||||
vdev->vmstate = qemu_add_vm_change_state_handler(virtio_vmstate_change,
|
||||
vdev);
|
||||
vdev->device_endian = virtio_default_endian();
|
||||
vdev->use_guest_notifier_mask = true;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从virtio_init中可以看出,VirtIODevice结构里面有一个VirtQueue数组,这就是virtio前端和后端互相传数据的队列,最多VIRTIO_QUEUE_MAX个。
|
||||
|
||||
我们回到virtio_blk_device_realize函数。接下来,根据配置的队列数目num_queues,对于每个队列都调用virtio_add_queue来初始化队列。
|
||||
|
||||
```
|
||||
VirtQueue *virtio_add_queue(VirtIODevice *vdev, int queue_size,
|
||||
VirtIOHandleOutput handle_output)
|
||||
{
|
||||
int i;
|
||||
vdev->vq[i].vring.num = queue_size;
|
||||
vdev->vq[i].vring.num_default = queue_size;
|
||||
vdev->vq[i].vring.align = VIRTIO_PCI_VRING_ALIGN;
|
||||
vdev->vq[i].handle_output = handle_output;
|
||||
vdev->vq[i].handle_aio_output = NULL;
|
||||
|
||||
return &vdev->vq[i];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在每个VirtQueue中,都有一个vring,用来维护这个队列里面的数据;另外还有一个函数virtio_blk_handle_output,用于处理数据写入,这个函数我们后面会用到。
|
||||
|
||||
至此,VirtIODevice,VirtQueue,vring之间的关系如下图所示。这是在qemu里面的对应关系,请你记好,后面我们还能看到类似的结构。
|
||||
|
||||
<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(&bdo_queue, machine_class, snapshot);
|
||||
|
||||
```
|
||||
|
||||
在qemu启动的main函数里面,初始化块设备,是通过configure_blockdev调用开始的。
|
||||
|
||||
```
|
||||
static void configure_blockdev(BlockdevOptionsQueue *bdo_queue, MachineClass *machine_class, int snapshot)
|
||||
{
|
||||
......
|
||||
if (qemu_opts_foreach(qemu_find_opts("drive"), drive_init_func,
|
||||
&machine_class->block_default_type, &error_fatal)) {
|
||||
.....
|
||||
}
|
||||
}
|
||||
|
||||
static int drive_init_func(void *opaque, QemuOpts *opts, Error **errp)
|
||||
{
|
||||
BlockInterfaceType *block_default_type = opaque;
|
||||
return drive_new(opts, *block_default_type, errp) == NULL;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在configure_blockdev中,我们能看到对于drive这个参数的解析,并且初始化这个设备要调用drive_init_func函数,这里面会调用drive_new创建一个设备。
|
||||
|
||||
```
|
||||
DriveInfo *drive_new(QemuOpts *all_opts, BlockInterfaceType block_default_type, Error **errp)
|
||||
{
|
||||
const char *value;
|
||||
BlockBackend *blk;
|
||||
DriveInfo *dinfo = NULL;
|
||||
QDict *bs_opts;
|
||||
QemuOpts *legacy_opts;
|
||||
DriveMediaType media = MEDIA_DISK;
|
||||
BlockInterfaceType type;
|
||||
int max_devs, bus_id, unit_id, index;
|
||||
const char *werror, *rerror;
|
||||
bool read_only = false;
|
||||
bool copy_on_read;
|
||||
const char *filename;
|
||||
Error *local_err = NULL;
|
||||
int i;
|
||||
......
|
||||
legacy_opts = qemu_opts_create(&qemu_legacy_drive_opts, NULL, 0,
|
||||
&error_abort);
|
||||
......
|
||||
/* Add virtio block device */
|
||||
if (type == IF_VIRTIO) {
|
||||
QemuOpts *devopts;
|
||||
devopts = qemu_opts_create(qemu_find_opts("device"), NULL, 0,
|
||||
&error_abort);
|
||||
qemu_opt_set(devopts, "driver", "virtio-blk-pci", &error_abort);
|
||||
qemu_opt_set(devopts, "drive", qdict_get_str(bs_opts, "id"),
|
||||
&error_abort);
|
||||
}
|
||||
|
||||
filename = qemu_opt_get(legacy_opts, "file");
|
||||
......
|
||||
/* Actual block device init: Functionality shared with blockdev-add */
|
||||
blk = blockdev_init(filename, bs_opts, &local_err);
|
||||
......
|
||||
/* Create legacy DriveInfo */
|
||||
dinfo = g_malloc0(sizeof(*dinfo));
|
||||
dinfo->opts = all_opts;
|
||||
|
||||
dinfo->type = type;
|
||||
dinfo->bus = bus_id;
|
||||
dinfo->unit = unit_id;
|
||||
|
||||
blk_set_legacy_dinfo(blk, dinfo);
|
||||
|
||||
switch(type) {
|
||||
case IF_IDE:
|
||||
case IF_SCSI:
|
||||
case IF_XEN:
|
||||
case IF_NONE:
|
||||
dinfo->media_cd = media == MEDIA_CDROM;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在drive_new里面,会解析qemu的启动参数。对于virtio来讲,会解析device参数,把driver设置为virtio-blk-pci;还会解析file参数,就是指向那个宿主机上的文件。
|
||||
|
||||
接下来,drive_new会调用blockdev_init,根据参数进行初始化,最后会创建一个DriveInfo来管理这个设备。
|
||||
|
||||
我们重点来看blockdev_init。在这里面,我们发现,如果file不为空,则应该调用blk_new_open打开宿主机上的硬盘文件,返回的结果是BlockBackend,对应我们上面讲原理的时候的virtio的后端。
|
||||
|
||||
```
|
||||
BlockBackend *blk_new_open(const char *filename, const char *reference,
|
||||
QDict *options, int flags, Error **errp)
|
||||
{
|
||||
BlockBackend *blk;
|
||||
BlockDriverState *bs;
|
||||
uint64_t perm = 0;
|
||||
......
|
||||
blk = blk_new(perm, BLK_PERM_ALL);
|
||||
bs = bdrv_open(filename, reference, options, flags, errp);
|
||||
blk->root = bdrv_root_attach_child(bs, "root", &child_root,
|
||||
perm, BLK_PERM_ALL, blk, errp);
|
||||
return blk;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来的调用链为:bdrv_open->bdrv_open_inherit->bdrv_open_common.
|
||||
|
||||
```
|
||||
static int bdrv_open_common(BlockDriverState *bs, BlockBackend *file,
|
||||
QDict *options, Error **errp)
|
||||
{
|
||||
int ret, open_flags;
|
||||
const char *filename;
|
||||
const char *driver_name = NULL;
|
||||
const char *node_name = NULL;
|
||||
const char *discard;
|
||||
QemuOpts *opts;
|
||||
BlockDriver *drv;
|
||||
Error *local_err = NULL;
|
||||
......
|
||||
drv = bdrv_find_format(driver_name);
|
||||
......
|
||||
ret = bdrv_open_driver(bs, drv, node_name, options, open_flags, errp);
|
||||
......
|
||||
}
|
||||
|
||||
static int bdrv_open_driver(BlockDriverState *bs, BlockDriver *drv,
|
||||
const char *node_name, QDict *options,
|
||||
int open_flags, Error **errp)
|
||||
{
|
||||
......
|
||||
bs->drv = drv;
|
||||
bs->read_only = !(bs->open_flags & BDRV_O_RDWR);
|
||||
bs->opaque = g_malloc0(drv->instance_size);
|
||||
|
||||
if (drv->bdrv_open) {
|
||||
ret = drv->bdrv_open(bs, options, open_flags, &local_err);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在bdrv_open_common中,根据硬盘文件的格式,得到BlockDriver。因为虚拟机的硬盘文件格式有很多种,qcow2是一种,raw是一种,vmdk是一种,各有优缺点,启动虚拟机的时候,可以自由选择。
|
||||
|
||||
对于不同的格式,打开的方式不一样,我们拿qcow2来解析。它的BlockDriver定义如下:
|
||||
|
||||
```
|
||||
BlockDriver bdrv_qcow2 = {
|
||||
.format_name = "qcow2",
|
||||
.instance_size = sizeof(BDRVQcow2State),
|
||||
.bdrv_probe = qcow2_probe,
|
||||
.bdrv_open = qcow2_open,
|
||||
.bdrv_close = qcow2_close,
|
||||
......
|
||||
.bdrv_snapshot_create = qcow2_snapshot_create,
|
||||
.bdrv_snapshot_goto = qcow2_snapshot_goto,
|
||||
.bdrv_snapshot_delete = qcow2_snapshot_delete,
|
||||
.bdrv_snapshot_list = qcow2_snapshot_list,
|
||||
.bdrv_snapshot_load_tmp = qcow2_snapshot_load_tmp,
|
||||
.bdrv_measure = qcow2_measure,
|
||||
.bdrv_get_info = qcow2_get_info,
|
||||
.bdrv_get_specific_info = qcow2_get_specific_info,
|
||||
|
||||
.bdrv_save_vmstate = qcow2_save_vmstate,
|
||||
.bdrv_load_vmstate = qcow2_load_vmstate,
|
||||
|
||||
.supports_backing = true,
|
||||
.bdrv_change_backing_file = qcow2_change_backing_file,
|
||||
|
||||
.bdrv_refresh_limits = qcow2_refresh_limits,
|
||||
......
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
根据上面的定义,对于qcow2来讲,bdrv_open调用的是qcow2_open。
|
||||
|
||||
```
|
||||
static int qcow2_open(BlockDriverState *bs, QDict *options, int flags,
|
||||
Error **errp)
|
||||
{
|
||||
BDRVQcow2State *s = bs->opaque;
|
||||
QCow2OpenCo qoc = {
|
||||
.bs = bs,
|
||||
.options = options,
|
||||
.flags = flags,
|
||||
.errp = errp,
|
||||
.ret = -EINPROGRESS
|
||||
};
|
||||
|
||||
bs->file = bdrv_open_child(NULL, options, "file", bs, &child_file,
|
||||
false, errp);
|
||||
qemu_coroutine_enter(qemu_coroutine_create(qcow2_open_entry, &qoc));
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在qcow2_open中,我们会通过qemu_coroutine_enter进入一个协程coroutine。什么叫协程呢?我们可以简单地将它理解为用户态自己实现的线程。
|
||||
|
||||
前面咱们讲线程的时候说过,如果一个程序想实现并发,可以创建多个线程,但是线程是一个内核的概念,创建的每一个线程内核都能看到,内核的调度也是以线程为单位的。这对于普通的进程没有什么问题,但是对于qemu这种虚拟机,如果在用户态和内核态切换来切换去,由于还涉及虚拟机的状态,代价比较大。
|
||||
|
||||
但是,qemu的设备也是需要多线程能力的,怎么办呢?我们就在用户态实现一个类似线程的东西,也就是协程,用于实现并发,并且不被内核看到,调度全部在用户态完成。
|
||||
|
||||
从后面的读写过程可以看出,协程在后端经常使用。这里打开一个qcow2文件就是使用一个协程,创建一个协程和创建一个线程很像,也需要指定一个函数来执行,qcow2_open_entry就是协程的函数。
|
||||
|
||||
```
|
||||
static void coroutine_fn qcow2_open_entry(void *opaque)
|
||||
{
|
||||
QCow2OpenCo *qoc = opaque;
|
||||
BDRVQcow2State *s = qoc->bs->opaque;
|
||||
|
||||
qemu_co_mutex_lock(&s->lock);
|
||||
qoc->ret = qcow2_do_open(qoc->bs, qoc->options, qoc->flags, qoc->errp);
|
||||
qemu_co_mutex_unlock(&s->lock);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,qcow2_open_entry函数前面有一个coroutine_fn,说明它是一个协程函数。在qcow2_do_open中,qcow2_do_open根据qcow2的格式打开硬盘文件。这个格式[官网](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="">
|
||||
@@ -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("virtio-blk", 0, 0);
|
||||
major = register_blkdev(0, "virtblk");
|
||||
error = register_virtio_driver(&virtio_blk);
|
||||
......
|
||||
}
|
||||
|
||||
module_init(init);
|
||||
module_exit(fini);
|
||||
|
||||
MODULE_DEVICE_TABLE(virtio, id_table);
|
||||
MODULE_DESCRIPTION("Virtio block driver");
|
||||
MODULE_LICENSE("GPL");
|
||||
|
||||
static struct virtio_driver virtio_blk = {
|
||||
......
|
||||
.driver.name = KBUILD_MODNAME,
|
||||
.driver.owner = THIS_MODULE,
|
||||
.id_table = id_table,
|
||||
.probe = virtblk_probe,
|
||||
.remove = virtblk_remove,
|
||||
......
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
前面我们介绍过设备驱动程序,从这里的代码中,我们能看到非常熟悉的结构。它会创建一个workqueue,注册一个块设备,并获得一个主设备号,然后注册一个驱动函数virtio_blk。
|
||||
|
||||
当一个设备驱动作为一个内核模块被初始化的时候,probe函数会被调用,因而我们来看一下virtblk_probe。
|
||||
|
||||
```
|
||||
static int virtblk_probe(struct virtio_device *vdev)
|
||||
{
|
||||
struct virtio_blk *vblk;
|
||||
struct request_queue *q;
|
||||
......
|
||||
vdev->priv = vblk = kmalloc(sizeof(*vblk), GFP_KERNEL);
|
||||
vblk->vdev = vdev;
|
||||
vblk->sg_elems = sg_elems;
|
||||
INIT_WORK(&vblk->config_work, virtblk_config_changed_work);
|
||||
......
|
||||
err = init_vq(vblk);
|
||||
......
|
||||
vblk->disk = alloc_disk(1 << PART_BITS);
|
||||
memset(&vblk->tag_set, 0, sizeof(vblk->tag_set));
|
||||
vblk->tag_set.ops = &virtio_mq_ops;
|
||||
vblk->tag_set.queue_depth = virtblk_queue_depth;
|
||||
vblk->tag_set.numa_node = NUMA_NO_NODE;
|
||||
vblk->tag_set.flags = BLK_MQ_F_SHOULD_MERGE;
|
||||
vblk->tag_set.cmd_size =
|
||||
sizeof(struct virtblk_req) +
|
||||
sizeof(struct scatterlist) * sg_elems;
|
||||
vblk->tag_set.driver_data = vblk;
|
||||
vblk->tag_set.nr_hw_queues = vblk->num_vqs;
|
||||
err = blk_mq_alloc_tag_set(&vblk->tag_set);
|
||||
......
|
||||
q = blk_mq_init_queue(&vblk->tag_set);
|
||||
vblk->disk->queue = q;
|
||||
q->queuedata = vblk;
|
||||
virtblk_name_format("vd", index, vblk->disk->disk_name, DISK_NAME_LEN);
|
||||
vblk->disk->major = major;
|
||||
vblk->disk->first_minor = index_to_minor(index);
|
||||
vblk->disk->private_data = vblk;
|
||||
vblk->disk->fops = &virtblk_fops;
|
||||
vblk->disk->flags |= GENHD_FL_EXT_DEVT;
|
||||
vblk->index = index;
|
||||
......
|
||||
device_add_disk(&vdev->dev, vblk->disk);
|
||||
err = device_create_file(disk_to_dev(vblk->disk), &dev_attr_serial);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在virtblk_probe中,我们首先看到的是struct request_queue,这是每一个块设备都有的一个队列。还记得吗?它有两个函数,一个是make_request_fn函数,用于生成request;另一个是request_fn函数,用于处理request。
|
||||
|
||||
这个request_queue的初始化过程在blk_mq_init_queue中。它会调用blk_mq_init_allocated_queue->blk_queue_make_request。在这里面,我们可以将make_request_fn函数设置为blk_mq_make_request,也就是说,一旦上层有写入请求,我们就通过blk_mq_make_request这个函数,将请求放入request_queue队列中。
|
||||
|
||||
另外,在virtblk_probe中,我们会初始化一个gendisk。前面我们也讲了,每一个块设备都有这样一个结构。
|
||||
|
||||
在virtblk_probe中,还有一件重要的事情就是,init_vq会来初始化virtqueue。
|
||||
|
||||
```
|
||||
static int init_vq(struct virtio_blk *vblk)
|
||||
{
|
||||
int err;
|
||||
int i;
|
||||
vq_callback_t **callbacks;
|
||||
const char **names;
|
||||
struct virtqueue **vqs;
|
||||
unsigned short num_vqs;
|
||||
struct virtio_device *vdev = vblk->vdev;
|
||||
......
|
||||
vblk->vqs = kmalloc_array(num_vqs, sizeof(*vblk->vqs), GFP_KERNEL);
|
||||
names = kmalloc_array(num_vqs, sizeof(*names), GFP_KERNEL);
|
||||
callbacks = kmalloc_array(num_vqs, sizeof(*callbacks), GFP_KERNEL);
|
||||
vqs = kmalloc_array(num_vqs, sizeof(*vqs), GFP_KERNEL);
|
||||
......
|
||||
for (i = 0; i < num_vqs; i++) {
|
||||
callbacks[i] = virtblk_done;
|
||||
names[i] = vblk->vqs[i].name;
|
||||
}
|
||||
|
||||
/* Discover virtqueues and write information to configuration. */
|
||||
err = virtio_find_vqs(vdev, num_vqs, vqs, callbacks, names, &desc);
|
||||
|
||||
for (i = 0; i < num_vqs; i++) {
|
||||
vblk->vqs[i].vq = vqs[i];
|
||||
}
|
||||
vblk->num_vqs = num_vqs;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
按照上面的原理来说,virtqueue是一个介于客户机前端和qemu后端的一个结构,用于在这两端之间传递数据。这里建立的struct virtqueue是客户机前端对于队列的管理的数据结构,在客户机的linux内核中通过kmalloc_array进行分配。
|
||||
|
||||
而队列的实体需要通过函数virtio_find_vqs查找或者生成,所以这里我们还把callback函数指定为virtblk_done。当buffer使用发生变化的时候,我们需要调用这个callback函数进行通知。
|
||||
|
||||
```
|
||||
static inline
|
||||
int virtio_find_vqs(struct virtio_device *vdev, unsigned nvqs,
|
||||
struct virtqueue *vqs[], vq_callback_t *callbacks[],
|
||||
const char * const names[],
|
||||
struct irq_affinity *desc)
|
||||
{
|
||||
return vdev->config->find_vqs(vdev, nvqs, vqs, callbacks, names, NULL, desc);
|
||||
}
|
||||
|
||||
static const struct virtio_config_ops virtio_pci_config_ops = {
|
||||
.get = vp_get,
|
||||
.set = vp_set,
|
||||
.generation = vp_generation,
|
||||
.get_status = vp_get_status,
|
||||
.set_status = vp_set_status,
|
||||
.reset = vp_reset,
|
||||
.find_vqs = vp_modern_find_vqs,
|
||||
.del_vqs = vp_del_vqs,
|
||||
.get_features = vp_get_features,
|
||||
.finalize_features = vp_finalize_features,
|
||||
.bus_name = vp_bus_name,
|
||||
.set_vq_affinity = vp_set_vq_affinity,
|
||||
.get_vq_affinity = vp_get_vq_affinity,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
根据virtio_config_ops的定义,virtio_find_vqs会调用vp_modern_find_vqs。
|
||||
|
||||
```
|
||||
static int vp_modern_find_vqs(struct virtio_device *vdev, unsigned nvqs,
|
||||
struct virtqueue *vqs[],
|
||||
vq_callback_t *callbacks[],
|
||||
const char * const names[], const bool *ctx,
|
||||
struct irq_affinity *desc)
|
||||
{
|
||||
struct virtio_pci_device *vp_dev = to_vp_device(vdev);
|
||||
struct virtqueue *vq;
|
||||
int rc = vp_find_vqs(vdev, nvqs, vqs, callbacks, names, ctx, desc);
|
||||
/* Select and activate all queues. Has to be done last: once we do
|
||||
* this, there's no way to go back except reset.
|
||||
*/
|
||||
list_for_each_entry(vq, &vdev->vqs, list) {
|
||||
vp_iowrite16(vq->index, &vp_dev->common->queue_select);
|
||||
vp_iowrite16(1, &vp_dev->common->queue_enable);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在vp_modern_find_vqs中,vp_find_vqs会调用vp_find_vqs_intx。
|
||||
|
||||
```
|
||||
static int vp_find_vqs_intx(struct virtio_device *vdev, unsigned nvqs,
|
||||
struct virtqueue *vqs[], vq_callback_t *callbacks[],
|
||||
const char * const names[], const bool *ctx)
|
||||
{
|
||||
struct virtio_pci_device *vp_dev = to_vp_device(vdev);
|
||||
int i, err;
|
||||
|
||||
vp_dev->vqs = kcalloc(nvqs, sizeof(*vp_dev->vqs), GFP_KERNEL);
|
||||
err = request_irq(vp_dev->pci_dev->irq, vp_interrupt, IRQF_SHARED,
|
||||
dev_name(&vdev->dev), vp_dev);
|
||||
vp_dev->intx_enabled = 1;
|
||||
vp_dev->per_vq_vectors = false;
|
||||
for (i = 0; i < nvqs; ++i) {
|
||||
vqs[i] = vp_setup_vq(vdev, i, callbacks[i], names[i],
|
||||
ctx ? ctx[i] : false,
|
||||
VIRTIO_MSI_NO_VECTOR);
|
||||
......
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在vp_find_vqs_intx中,我们通过request_irq注册一个中断处理函数vp_interrupt,当设备的配置信息发生改变,会产生一个中断,当设备向队列中写入信息时,也会产生一个中断,我们称为vq中断,中断处理函数需要调用相应的队列的回调函数。
|
||||
|
||||
然后,我们根据队列的数目,依次调用vp_setup_vq,完成virtqueue、vring的分配和初始化。
|
||||
|
||||
```
|
||||
static struct virtqueue *vp_setup_vq(struct virtio_device *vdev, unsigned index,
|
||||
void (*callback)(struct virtqueue *vq),
|
||||
const char *name,
|
||||
bool ctx,
|
||||
u16 msix_vec)
|
||||
{
|
||||
struct virtio_pci_device *vp_dev = to_vp_device(vdev);
|
||||
struct virtio_pci_vq_info *info = kmalloc(sizeof *info, GFP_KERNEL);
|
||||
struct virtqueue *vq;
|
||||
unsigned long flags;
|
||||
......
|
||||
vq = vp_dev->setup_vq(vp_dev, info, index, callback, name, ctx,
|
||||
msix_vec);
|
||||
info->vq = vq;
|
||||
if (callback) {
|
||||
spin_lock_irqsave(&vp_dev->lock, flags);
|
||||
list_add(&info->node, &vp_dev->virtqueues);
|
||||
spin_unlock_irqrestore(&vp_dev->lock, flags);
|
||||
} else {
|
||||
INIT_LIST_HEAD(&info->node);
|
||||
}
|
||||
vp_dev->vqs[index] = info;
|
||||
return vq;
|
||||
}
|
||||
|
||||
static struct virtqueue *setup_vq(struct virtio_pci_device *vp_dev,
|
||||
struct virtio_pci_vq_info *info,
|
||||
unsigned index,
|
||||
void (*callback)(struct virtqueue *vq),
|
||||
const char *name,
|
||||
bool ctx,
|
||||
u16 msix_vec)
|
||||
{
|
||||
struct virtio_pci_common_cfg __iomem *cfg = vp_dev->common;
|
||||
struct virtqueue *vq;
|
||||
u16 num, off;
|
||||
int err;
|
||||
|
||||
/* Select the queue we're interested in */
|
||||
vp_iowrite16(index, &cfg->queue_select);
|
||||
|
||||
/* Check if queue is either not available or already active. */
|
||||
num = vp_ioread16(&cfg->queue_size);
|
||||
|
||||
/* get offset of notification word for this vq */
|
||||
off = vp_ioread16(&cfg->queue_notify_off);
|
||||
|
||||
info->msix_vector = msix_vec;
|
||||
|
||||
/* create the vring */
|
||||
vq = vring_create_virtqueue(index, num,
|
||||
SMP_CACHE_BYTES, &vp_dev->vdev,
|
||||
true, true, ctx,
|
||||
vp_notify, callback, name);
|
||||
/* activate the queue */
|
||||
vp_iowrite16(virtqueue_get_vring_size(vq), &cfg->queue_size);
|
||||
vp_iowrite64_twopart(virtqueue_get_desc_addr(vq),
|
||||
&cfg->queue_desc_lo, &cfg->queue_desc_hi);
|
||||
vp_iowrite64_twopart(virtqueue_get_avail_addr(vq),
|
||||
&cfg->queue_avail_lo, &cfg->queue_avail_hi);
|
||||
vp_iowrite64_twopart(virtqueue_get_used_addr(vq),
|
||||
&cfg->queue_used_lo, &cfg->queue_used_hi);
|
||||
......
|
||||
return vq;
|
||||
}
|
||||
|
||||
struct virtqueue *vring_create_virtqueue(
|
||||
unsigned int index,
|
||||
unsigned int num,
|
||||
unsigned int vring_align,
|
||||
struct virtio_device *vdev,
|
||||
bool weak_barriers,
|
||||
bool may_reduce_num,
|
||||
bool context,
|
||||
bool (*notify)(struct virtqueue *),
|
||||
void (*callback)(struct virtqueue *),
|
||||
const char *name)
|
||||
{
|
||||
struct virtqueue *vq;
|
||||
void *queue = NULL;
|
||||
dma_addr_t dma_addr;
|
||||
size_t queue_size_in_bytes;
|
||||
struct vring vring;
|
||||
|
||||
/* TODO: allocate each queue chunk individually */
|
||||
for (; num && vring_size(num, vring_align) > PAGE_SIZE; num /= 2) {
|
||||
queue = vring_alloc_queue(vdev, vring_size(num, vring_align),
|
||||
&dma_addr,
|
||||
GFP_KERNEL|__GFP_NOWARN|__GFP_ZERO);
|
||||
if (queue)
|
||||
break;
|
||||
}
|
||||
|
||||
if (!queue) {
|
||||
/* Try to get a single page. You are my only hope! */
|
||||
queue = vring_alloc_queue(vdev, vring_size(num, vring_align),
|
||||
&dma_addr, GFP_KERNEL|__GFP_ZERO);
|
||||
}
|
||||
|
||||
queue_size_in_bytes = vring_size(num, vring_align);
|
||||
vring_init(&vring, num, queue, vring_align);
|
||||
|
||||
vq = __vring_new_virtqueue(index, vring, vdev, weak_barriers, context, notify, callback, name);
|
||||
|
||||
to_vvq(vq)->queue_dma_addr = dma_addr;
|
||||
to_vvq(vq)->queue_size_in_bytes = queue_size_in_bytes;
|
||||
to_vvq(vq)->we_own_ring = true;
|
||||
|
||||
return vq;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在vring_create_virtqueue中,我们会调用vring_alloc_queue,来创建队列所需要的内存空间,然后调用vring_init初始化结构struct vring,来管理队列的内存空间,调用__vring_new_virtqueue,来创建struct vring_virtqueue。
|
||||
|
||||
这个结构的一开始,是struct virtqueue,它也是struct virtqueue的一个扩展,紧接着后面就是struct vring。
|
||||
|
||||
```
|
||||
struct vring_virtqueue {
|
||||
struct virtqueue vq;
|
||||
|
||||
/* Actual memory layout for this queue */
|
||||
struct vring vring;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
至此我们发现,虚拟机里面的virtio的前端是这样的结构:struct virtio_device里面有一个struct vring_virtqueue,在struct vring_virtqueue里面有一个struct vring。
|
||||
|
||||
## 中间virtio队列的管理
|
||||
|
||||
还记不记得我们上面讲qemu初始化的时候,virtio的后端有数据结构VirtIODevice,VirtQueue和vring一模一样,前端和后端对应起来,都应该指向刚才创建的那一段内存。
|
||||
|
||||
现在的问题是,我们刚才分配的内存在客户机的内核里面,如何告知qemu来访问这段内存呢?
|
||||
|
||||
别忘了,qemu模拟出来的virtio block device只是一个PCI设备。对于客户机来讲,这是一个外部设备,我们可以通过给外部设备发送指令的方式告知外部设备,这就是代码中vp_iowrite16的作用。它会调用专门给外部设备发送指令的函数iowrite,告诉外部的PCI设备。
|
||||
|
||||
告知的有三个地址virtqueue_get_desc_addr、virtqueue_get_avail_addr,virtqueue_get_used_addr。从客户机角度来看,这里面的地址都是物理地址,也即GPA(Guest Physical Address)。因为只有物理地址才是客户机和qemu程序都认可的地址,本来客户机的物理内存也是qemu模拟出来的。
|
||||
|
||||
在qemu中,对PCI总线添加一个设备的时候,我们会调用virtio_pci_device_plugged。
|
||||
|
||||
```
|
||||
static void virtio_pci_device_plugged(DeviceState *d, Error **errp)
|
||||
{
|
||||
VirtIOPCIProxy *proxy = VIRTIO_PCI(d);
|
||||
......
|
||||
memory_region_init_io(&proxy->bar, OBJECT(proxy),
|
||||
&virtio_pci_config_ops,
|
||||
proxy, "virtio-pci", size);
|
||||
......
|
||||
}
|
||||
|
||||
static const MemoryRegionOps virtio_pci_config_ops = {
|
||||
.read = virtio_pci_config_read,
|
||||
.write = virtio_pci_config_write,
|
||||
.impl = {
|
||||
.min_access_size = 1,
|
||||
.max_access_size = 4,
|
||||
},
|
||||
.endianness = DEVICE_LITTLE_ENDIAN,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在这里面,对于这个加载的设备进行I/O操作,会映射到读写某一块内存空间,对应的操作为virtio_pci_config_ops,也即写入这块内存空间,这就相当于对于这个PCI设备进行某种配置。
|
||||
|
||||
对PCI设备进行配置的时候,会有这样的调用链:virtio_pci_config_write->virtio_ioport_write->virtio_queue_set_addr。设置virtio的queue的地址是一项很重要的操作。
|
||||
|
||||
```
|
||||
void virtio_queue_set_addr(VirtIODevice *vdev, int n, hwaddr addr)
|
||||
{
|
||||
vdev->vq[n].vring.desc = addr;
|
||||
virtio_queue_update_rings(vdev, n);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从这里我们可以看出,qemu后端的VirtIODevice的VirtQueue的vring的地址,被设置成了刚才给队列分配的内存的GPA。
|
||||
|
||||
<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 "next". */
|
||||
struct vring_desc {
|
||||
/* Address (guest-physical). */
|
||||
__virtio64 addr;
|
||||
/* Length. */
|
||||
__virtio32 len;
|
||||
/* The flags as indicated above. */
|
||||
__virtio16 flags;
|
||||
/* We chain unused descriptors via this, too */
|
||||
__virtio16 next;
|
||||
};
|
||||
|
||||
struct vring_avail {
|
||||
__virtio16 flags;
|
||||
__virtio16 idx;
|
||||
__virtio16 ring[];
|
||||
};
|
||||
|
||||
/* u32 is used here for ids for padding reasons. */
|
||||
struct vring_used_elem {
|
||||
/* Index of start of used descriptor chain. */
|
||||
__virtio32 id;
|
||||
/* Total length of the descriptor chain which was used (written to) */
|
||||
__virtio32 len;
|
||||
};
|
||||
|
||||
struct vring_used {
|
||||
__virtio16 flags;
|
||||
__virtio16 idx;
|
||||
struct vring_used_elem ring[];
|
||||
};
|
||||
|
||||
struct vring {
|
||||
unsigned int num;
|
||||
|
||||
struct vring_desc *desc;
|
||||
|
||||
struct vring_avail *avail;
|
||||
|
||||
struct vring_used *used;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
vring包含三个成员:
|
||||
|
||||
- vring_desc指向分配的内存块,用于存放客户机和qemu之间传输的数据。
|
||||
- avail->ring[]是发送端维护的环形队列,指向需要接收端处理的vring_desc。
|
||||
- used->ring[]是接收端维护的环形队列,指向自己已经处理过了的vring_desc。
|
||||
|
||||
## 数据写入的流程
|
||||
|
||||
接下来,我们来看,真的写入一个数据的时候,会发生什么。
|
||||
|
||||
按照上面virtio驱动初始化的时候的逻辑,blk_mq_make_request会被调用。这个函数比较复杂,会分成多个分支,但是最终都会调用到request_queue的virtio_mq_ops的queue_rq函数。
|
||||
|
||||
```
|
||||
struct request_queue *q = rq->q;
|
||||
q->mq_ops->queue_rq(hctx, &bd);
|
||||
|
||||
static const struct blk_mq_ops virtio_mq_ops = {
|
||||
.queue_rq = virtio_queue_rq,
|
||||
.complete = virtblk_request_done,
|
||||
.init_request = virtblk_init_request,
|
||||
.map_queues = virtblk_map_queues,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
根据virtio_mq_ops的定义,我们现在要调用virtio_queue_rq。
|
||||
|
||||
```
|
||||
static blk_status_t virtio_queue_rq(struct blk_mq_hw_ctx *hctx,
|
||||
const struct blk_mq_queue_data *bd)
|
||||
{
|
||||
struct virtio_blk *vblk = hctx->queue->queuedata;
|
||||
struct request *req = bd->rq;
|
||||
struct virtblk_req *vbr = blk_mq_rq_to_pdu(req);
|
||||
......
|
||||
err = virtblk_add_req(vblk->vqs[qid].vq, vbr, vbr->sg, num);
|
||||
......
|
||||
if (notify)
|
||||
virtqueue_notify(vblk->vqs[qid].vq);
|
||||
return BLK_STS_OK;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在virtio_queue_rq中,我们会将请求写入的数据,通过virtblk_add_req放入struct virtqueue。
|
||||
|
||||
因此,接下来的调用链为:virtblk_add_req->virtqueue_add_sgs->virtqueue_add。
|
||||
|
||||
```
|
||||
static inline int virtqueue_add(struct virtqueue *_vq,
|
||||
struct scatterlist *sgs[],
|
||||
unsigned int total_sg,
|
||||
unsigned int out_sgs,
|
||||
unsigned int in_sgs,
|
||||
void *data,
|
||||
void *ctx,
|
||||
gfp_t gfp)
|
||||
{
|
||||
struct vring_virtqueue *vq = to_vvq(_vq);
|
||||
struct scatterlist *sg;
|
||||
struct vring_desc *desc;
|
||||
unsigned int i, n, avail, descs_used, uninitialized_var(prev), err_idx;
|
||||
int head;
|
||||
bool indirect;
|
||||
......
|
||||
head = vq->free_head;
|
||||
|
||||
indirect = false;
|
||||
desc = vq->vring.desc;
|
||||
i = head;
|
||||
descs_used = total_sg;
|
||||
|
||||
for (n = 0; n < out_sgs; n++) {
|
||||
for (sg = sgs[n]; sg; sg = sg_next(sg)) {
|
||||
dma_addr_t addr = vring_map_one_sg(vq, sg, DMA_TO_DEVICE);
|
||||
......
|
||||
desc[i].flags = cpu_to_virtio16(_vq->vdev, VRING_DESC_F_NEXT);
|
||||
desc[i].addr = cpu_to_virtio64(_vq->vdev, addr);
|
||||
desc[i].len = cpu_to_virtio32(_vq->vdev, sg->length);
|
||||
prev = i;
|
||||
i = virtio16_to_cpu(_vq->vdev, desc[i].next);
|
||||
}
|
||||
}
|
||||
|
||||
/* Last one doesn't continue. */
|
||||
desc[prev].flags &= cpu_to_virtio16(_vq->vdev, ~VRING_DESC_F_NEXT);
|
||||
|
||||
/* We're using some buffers from the free list. */
|
||||
vq->vq.num_free -= descs_used;
|
||||
|
||||
/* Update free pointer */
|
||||
vq->free_head = i;
|
||||
|
||||
/* Store token and indirect buffer state. */
|
||||
vq->desc_state[head].data = data;
|
||||
|
||||
/* Put entry in available array (but don't update avail->idx until they do sync). */
|
||||
avail = vq->avail_idx_shadow & (vq->vring.num - 1);
|
||||
vq->vring.avail->ring[avail] = cpu_to_virtio16(_vq->vdev, head);
|
||||
|
||||
/* Descriptors and available array need to be set before we expose the new available array entries. */
|
||||
virtio_wmb(vq->weak_barriers);
|
||||
vq->avail_idx_shadow++;
|
||||
vq->vring.avail->idx = cpu_to_virtio16(_vq->vdev, vq->avail_idx_shadow);
|
||||
vq->num_added++;
|
||||
......
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在virtqueue_add函数中,我们能看到,free_head指向的整个内存块空闲链表的起始位置,用head变量记住这个起始位置。
|
||||
|
||||
接下来,i也指向这个起始位置,然后是一个for循环,将数据放到内存块里面,放的过程中,next不断指向下一个空闲位置,这样空闲的内存块被不断的占用。等所有的写入都结束了,i就会指向这次存放的内存块的下一个空闲位置,然后free_head就指向i,因为前面的都填满了。
|
||||
|
||||
至此,从head到i之间的内存块,就是这次写入的全部数据。
|
||||
|
||||
于是,在vring的avail变量中,在ring[]数组中分配新的一项,在avail的位置,avail的计算是avail_idx_shadow & (vq->vring.num - 1),其中,avail_idx_shadow是上一次的avail的位置。这里如果超过了ring[]数组的下标,则重新跳到起始位置,就说明是一个环。这次分配的新的avail的位置就存放新写入的从head到i之间的内存块。然后是avail_idx_shadow++,这说明这一块内存可以被接收方读取了。
|
||||
|
||||
接下来,我们回到virtio_queue_rq,调用virtqueue_notify通知接收方。而virtqueue_notify会调用vp_notify。
|
||||
|
||||
```
|
||||
bool vp_notify(struct virtqueue *vq)
|
||||
{
|
||||
/* we write the queue's selector into the notification register to
|
||||
* signal the other end */
|
||||
iowrite16(vq->index, (void __iomem *)vq->priv);
|
||||
return true;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们写入一个I/O会触发VM exit。我们在解析CPU的时候看到过这个逻辑。
|
||||
|
||||
```
|
||||
int kvm_cpu_exec(CPUState *cpu)
|
||||
{
|
||||
struct kvm_run *run = cpu->kvm_run;
|
||||
int ret, run_ret;
|
||||
......
|
||||
run_ret = kvm_vcpu_ioctl(cpu, KVM_RUN, 0);
|
||||
......
|
||||
switch (run->exit_reason) {
|
||||
case KVM_EXIT_IO:
|
||||
DPRINTF("handle_io\n");
|
||||
/* Called outside BQL */
|
||||
kvm_handle_io(run->io.port, attrs,
|
||||
(uint8_t *)run + run->io.data_offset,
|
||||
run->io.direction,
|
||||
run->io.size,
|
||||
run->io.count);
|
||||
ret = 0;
|
||||
break;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这次写入的也是一个I/O的内存空间,同样会触发virtio_ioport_write,这次会调用virtio_queue_notify。
|
||||
|
||||
```
|
||||
void virtio_queue_notify(VirtIODevice *vdev, int n)
|
||||
{
|
||||
VirtQueue *vq = &vdev->vq[n];
|
||||
......
|
||||
if (vq->handle_aio_output) {
|
||||
event_notifier_set(&vq->host_notifier);
|
||||
} else if (vq->handle_output) {
|
||||
vq->handle_output(vdev, vq);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
virtio_queue_notify会调用VirtQueue的handle_output函数,前面我们已经设置过这个函数了,是virtio_blk_handle_output。
|
||||
|
||||
接下来的调用链为:virtio_blk_handle_output->virtio_blk_handle_output_do->virtio_blk_handle_vq。
|
||||
|
||||
```
|
||||
bool virtio_blk_handle_vq(VirtIOBlock *s, VirtQueue *vq)
|
||||
{
|
||||
VirtIOBlockReq *req;
|
||||
MultiReqBuffer mrb = {};
|
||||
bool progress = false;
|
||||
......
|
||||
do {
|
||||
virtio_queue_set_notification(vq, 0);
|
||||
|
||||
while ((req = virtio_blk_get_request(s, vq))) {
|
||||
progress = true;
|
||||
if (virtio_blk_handle_request(req, &mrb)) {
|
||||
virtqueue_detach_element(req->vq, &req->elem, 0);
|
||||
virtio_blk_free_request(req);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
virtio_queue_set_notification(vq, 1);
|
||||
} while (!virtio_queue_empty(vq));
|
||||
|
||||
if (mrb.num_reqs) {
|
||||
virtio_blk_submit_multireq(s->blk, &mrb);
|
||||
}
|
||||
......
|
||||
return progress;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在virtio_blk_handle_vq中,有一个while循环,在循环中调用函数virtio_blk_get_request从vq中取出请求,然后调用virtio_blk_handle_request处理从vq中取出的请求。
|
||||
|
||||
我们先来看virtio_blk_get_request。
|
||||
|
||||
```
|
||||
static VirtIOBlockReq *virtio_blk_get_request(VirtIOBlock *s, VirtQueue *vq)
|
||||
{
|
||||
VirtIOBlockReq *req = virtqueue_pop(vq, sizeof(VirtIOBlockReq));
|
||||
|
||||
if (req) {
|
||||
virtio_blk_init_request(s, vq, req);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
void *virtqueue_pop(VirtQueue *vq, size_t sz)
|
||||
{
|
||||
unsigned int i, head, max;
|
||||
VRingMemoryRegionCaches *caches;
|
||||
MemoryRegionCache *desc_cache;
|
||||
int64_t len;
|
||||
VirtIODevice *vdev = vq->vdev;
|
||||
VirtQueueElement *elem = NULL;
|
||||
unsigned out_num, in_num, elem_entries;
|
||||
hwaddr addr[VIRTQUEUE_MAX_SIZE];
|
||||
struct iovec iov[VIRTQUEUE_MAX_SIZE];
|
||||
VRingDesc desc;
|
||||
int rc;
|
||||
......
|
||||
/* When we start there are none of either input nor output. */
|
||||
out_num = in_num = elem_entries = 0;
|
||||
|
||||
max = vq->vring.num;
|
||||
|
||||
i = head;
|
||||
|
||||
caches = vring_get_region_caches(vq);
|
||||
desc_cache = &caches->desc;
|
||||
vring_desc_read(vdev, &desc, desc_cache, i);
|
||||
......
|
||||
/* Collect all the descriptors */
|
||||
do {
|
||||
bool map_ok;
|
||||
|
||||
if (desc.flags & VRING_DESC_F_WRITE) {
|
||||
map_ok = virtqueue_map_desc(vdev, &in_num, addr + out_num,
|
||||
iov + out_num,
|
||||
VIRTQUEUE_MAX_SIZE - out_num, true,
|
||||
desc.addr, desc.len);
|
||||
} else {
|
||||
map_ok = virtqueue_map_desc(vdev, &out_num, addr, iov,
|
||||
VIRTQUEUE_MAX_SIZE, false,
|
||||
desc.addr, desc.len);
|
||||
}
|
||||
......
|
||||
rc = virtqueue_read_next_desc(vdev, &desc, desc_cache, max, &i);
|
||||
} while (rc == VIRTQUEUE_READ_DESC_MORE);
|
||||
......
|
||||
/* Now copy what we have collected and mapped */
|
||||
elem = virtqueue_alloc_element(sz, out_num, in_num);
|
||||
elem->index = head;
|
||||
for (i = 0; i < out_num; i++) {
|
||||
elem->out_addr[i] = addr[i];
|
||||
elem->out_sg[i] = iov[i];
|
||||
}
|
||||
for (i = 0; i < in_num; i++) {
|
||||
elem->in_addr[i] = addr[out_num + i];
|
||||
elem->in_sg[i] = iov[out_num + i];
|
||||
}
|
||||
|
||||
vq->inuse++;
|
||||
......
|
||||
return elem;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,virtio_blk_get_request会调用virtqueue_pop。在这里面,我们能看到对于vring的操作,也即从这里面将客户机里面写入的数据读取出来,放到VirtIOBlockReq结构中。
|
||||
|
||||
接下来,我们就要调用virtio_blk_handle_request处理这些数据。所以接下来的调用链为:virtio_blk_handle_request->virtio_blk_submit_multireq->submit_requests。
|
||||
|
||||
```
|
||||
static inline void submit_requests(BlockBackend *blk, MultiReqBuffer *mrb,int start, int num_reqs, int niov)
|
||||
{
|
||||
QEMUIOVector *qiov = &mrb->reqs[start]->qiov;
|
||||
int64_t sector_num = mrb->reqs[start]->sector_num;
|
||||
bool is_write = mrb->is_write;
|
||||
|
||||
if (num_reqs > 1) {
|
||||
int i;
|
||||
struct iovec *tmp_iov = qiov->iov;
|
||||
int tmp_niov = qiov->niov;
|
||||
qemu_iovec_init(qiov, niov);
|
||||
|
||||
for (i = 0; i < tmp_niov; i++) {
|
||||
qemu_iovec_add(qiov, tmp_iov[i].iov_base, tmp_iov[i].iov_len);
|
||||
}
|
||||
|
||||
for (i = start + 1; i < start + num_reqs; i++) {
|
||||
qemu_iovec_concat(qiov, &mrb->reqs[i]->qiov, 0,
|
||||
mrb->reqs[i]->qiov.size);
|
||||
mrb->reqs[i - 1]->mr_next = mrb->reqs[i];
|
||||
}
|
||||
|
||||
block_acct_merge_done(blk_get_stats(blk),
|
||||
is_write ? BLOCK_ACCT_WRITE : BLOCK_ACCT_READ,
|
||||
num_reqs - 1);
|
||||
}
|
||||
|
||||
if (is_write) {
|
||||
blk_aio_pwritev(blk, sector_num << BDRV_SECTOR_BITS, qiov, 0,
|
||||
virtio_blk_rw_complete, mrb->reqs[start]);
|
||||
} else {
|
||||
blk_aio_preadv(blk, sector_num << BDRV_SECTOR_BITS, qiov, 0,
|
||||
virtio_blk_rw_complete, mrb->reqs[start]);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在submit_requests中,我们看到了BlockBackend。这是在qemu启动的时候,打开qcow2文件的时候生成的,现在我们可以用它来写入文件了,调用的是blk_aio_pwritev。
|
||||
|
||||
```
|
||||
BlockAIOCB *blk_aio_pwritev(BlockBackend *blk, int64_t offset,
|
||||
QEMUIOVector *qiov, BdrvRequestFlags flags,
|
||||
BlockCompletionFunc *cb, void *opaque)
|
||||
{
|
||||
return blk_aio_prwv(blk, offset, qiov->size, qiov,
|
||||
blk_aio_write_entry, flags, cb, opaque);
|
||||
}
|
||||
|
||||
static BlockAIOCB *blk_aio_prwv(BlockBackend *blk, int64_t offset, int bytes,
|
||||
void *iobuf, CoroutineEntry co_entry,
|
||||
BdrvRequestFlags flags,
|
||||
BlockCompletionFunc *cb, void *opaque)
|
||||
{
|
||||
BlkAioEmAIOCB *acb;
|
||||
Coroutine *co;
|
||||
acb = blk_aio_get(&blk_aio_em_aiocb_info, blk, cb, opaque);
|
||||
acb->rwco = (BlkRwCo) {
|
||||
.blk = blk,
|
||||
.offset = offset,
|
||||
.iobuf = iobuf,
|
||||
.flags = flags,
|
||||
.ret = NOT_DONE,
|
||||
};
|
||||
acb->bytes = bytes;
|
||||
acb->has_returned = false;
|
||||
|
||||
co = qemu_coroutine_create(co_entry, acb);
|
||||
bdrv_coroutine_enter(blk_bs(blk), co);
|
||||
|
||||
acb->has_returned = true;
|
||||
return &acb->common;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在blk_aio_pwritev中,我们看到,又是创建了一个协程来进行写入。写入完毕之后调用virtio_blk_rw_complete->virtio_blk_req_complete。
|
||||
|
||||
```
|
||||
static void virtio_blk_req_complete(VirtIOBlockReq *req, unsigned char status)
|
||||
{
|
||||
VirtIOBlock *s = req->dev;
|
||||
VirtIODevice *vdev = VIRTIO_DEVICE(s);
|
||||
|
||||
trace_virtio_blk_req_complete(vdev, req, status);
|
||||
|
||||
stb_p(&req->in->status, status);
|
||||
virtqueue_push(req->vq, &req->elem, req->in_len);
|
||||
virtio_notify(vdev, req->vq);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在virtio_blk_req_complete中,我们先是调用virtqueue_push,更新vring中used变量,表示这部分已经写入完毕,空间可以回收利用了。但是,这部分的改变仅仅改变了qemu后端的vring,我们还需要通知客户机中virtio前端的vring的值,因而要调用virtio_notify。virtio_notify会调用virtio_irq发送一个中断。
|
||||
|
||||
还记得咱们前面注册过一个中断处理函数vp_interrupt吗?它就是干这个事情的。
|
||||
|
||||
```
|
||||
static irqreturn_t vp_interrupt(int irq, void *opaque)
|
||||
{
|
||||
struct virtio_pci_device *vp_dev = opaque;
|
||||
u8 isr;
|
||||
|
||||
/* reading the ISR has the effect of also clearing it so it's very
|
||||
* important to save off the value. */
|
||||
isr = ioread8(vp_dev->isr);
|
||||
|
||||
/* Configuration change? Tell driver if it wants to know. */
|
||||
if (isr & VIRTIO_PCI_ISR_CONFIG)
|
||||
vp_config_changed(irq, opaque);
|
||||
|
||||
return vp_vring_interrupt(irq, opaque);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
就像前面说的一样vp_interrupt这个中断处理函数,一是处理配置变化,二是处理I/O结束。第二种的调用链为:vp_interrupt->vp_vring_interrupt->vring_interrupt。
|
||||
|
||||
```
|
||||
irqreturn_t vring_interrupt(int irq, void *_vq)
|
||||
{
|
||||
struct vring_virtqueue *vq = to_vvq(_vq);
|
||||
......
|
||||
if (vq->vq.callback)
|
||||
vq->vq.callback(&vq->vq);
|
||||
|
||||
return IRQ_HANDLED;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在vring_interrupt中,我们会调用callback函数,这个也是在前面注册过的,是virtblk_done。
|
||||
|
||||
接下来的调用链为:virtblk_done->virtqueue_get_buf->virtqueue_get_buf_ctx。
|
||||
|
||||
```
|
||||
void *virtqueue_get_buf_ctx(struct virtqueue *_vq, unsigned int *len,
|
||||
void **ctx)
|
||||
{
|
||||
struct vring_virtqueue *vq = to_vvq(_vq);
|
||||
void *ret;
|
||||
unsigned int i;
|
||||
u16 last_used;
|
||||
......
|
||||
last_used = (vq->last_used_idx & (vq->vring.num - 1));
|
||||
i = virtio32_to_cpu(_vq->vdev, vq->vring.used->ring[last_used].id);
|
||||
*len = virtio32_to_cpu(_vq->vdev, vq->vring.used->ring[last_used].len);
|
||||
......
|
||||
/* detach_buf clears data, so grab it now. */
|
||||
ret = vq->desc_state[i].data;
|
||||
detach_buf(vq, i, ctx);
|
||||
vq->last_used_idx++;
|
||||
......
|
||||
return ret;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在virtqueue_get_buf_ctx中,我们可以看到,virtio前端的vring中的last_used_idx加一,说明这块数据qemu后端已经消费完毕。我们可以通过detach_buf将其放入空闲队列中,留给以后的写入请求使用。
|
||||
|
||||
至此,整个存储虚拟化的写入流程才全部完成。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
下面我们来总结一下存储虚拟化的场景下,整个写入的过程。
|
||||
|
||||
- 在虚拟机里面,应用层调用write系统调用写入文件。
|
||||
- write系统调用进入虚拟机里面的内核,经过VFS,通用块设备层,I/O调度层,到达块设备驱动。
|
||||
- 虚拟机里面的块设备驱动是virtio_blk,它和通用的块设备驱动一样,有一个request queue,另外有一个函数make_request_fn会被设置为blk_mq_make_request,这个函数用于将请求放入队列。
|
||||
- 虚拟机里面的块设备驱动是virtio_blk会注册一个中断处理函数vp_interrupt。当qemu写入完成之后,它会通知虚拟机里面的块设备驱动。
|
||||
- blk_mq_make_request最终调用virtqueue_add,将请求添加到传输队列virtqueue中,然后调用virtqueue_notify通知qemu。
|
||||
- 在qemu中,本来虚拟机正处于KVM_RUN的状态,也即处于客户机状态。
|
||||
- qemu收到通知后,通过VM exit指令退出客户机状态,进入宿主机状态,根据退出原因,得知有I/O需要处理。
|
||||
- qemu调用virtio_blk_handle_output,最终调用virtio_blk_handle_vq。
|
||||
- virtio_blk_handle_vq里面有一个循环,在循环中,virtio_blk_get_request函数从传输队列中拿出请求,然后调用virtio_blk_handle_request处理请求。
|
||||
- virtio_blk_handle_request会调用blk_aio_pwritev,通过BlockBackend驱动写入qcow2文件。
|
||||
- 写入完毕之后,virtio_blk_req_complete会调用virtio_notify通知虚拟机里面的驱动。数据写入完成,刚才注册的中断处理函数vp_interrupt会收到这个通知。
|
||||
|
||||
<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="">
|
||||
927
极客时间专栏/趣谈Linux操作系统/核心原理篇:第九部分 虚拟化/55 | 网络虚拟化:如何成立独立的合作部?.md
Normal file
927
极客时间专栏/趣谈Linux操作系统/核心原理篇:第九部分 虚拟化/55 | 网络虚拟化:如何成立独立的合作部?.md
Normal 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(&virtio_net_info);
|
||||
}
|
||||
|
||||
type_init(virtio_register_types)
|
||||
|
||||
```
|
||||
|
||||
Virtio Network Device这种类的定义是有多层继承关系的,TYPE_VIRTIO_NET的父类是TYPE_VIRTIO_DEVICE,TYPE_VIRTIO_DEVICE的父类是TYPE_DEVICE,TYPE_DEVICE的父类是TYPE_OBJECT,继承关系到头了。
|
||||
|
||||
type_init用于注册这种类。这里面每一层都有class_init,用于从TypeImpl生成xxxClass,也有instance_init,会将xxxClass初始化为实例。
|
||||
|
||||
TYPE_VIRTIO_NET层的class_init函数virtio_net_class_init,定义了DeviceClass的realize函数为virtio_net_device_realize,这一点和存储块设备是一样的。
|
||||
|
||||
```
|
||||
static void virtio_net_device_realize(DeviceState *dev, Error **errp)
|
||||
{
|
||||
VirtIODevice *vdev = VIRTIO_DEVICE(dev);
|
||||
VirtIONet *n = VIRTIO_NET(dev);
|
||||
NetClientState *nc;
|
||||
int i;
|
||||
......
|
||||
virtio_init(vdev, "virtio-net", VIRTIO_ID_NET, n->config_size);
|
||||
|
||||
/*
|
||||
* We set a lower limit on RX queue size to what it always was.
|
||||
* Guests that want a smaller ring can always resize it without
|
||||
* help from us (using virtio 1 and up).
|
||||
*/
|
||||
if (n->net_conf.rx_queue_size < VIRTIO_NET_RX_QUEUE_MIN_SIZE ||
|
||||
n->net_conf.rx_queue_size > VIRTQUEUE_MAX_SIZE ||
|
||||
!is_power_of_2(n->net_conf.rx_queue_size)) {
|
||||
......
|
||||
return;
|
||||
}
|
||||
|
||||
if (n->net_conf.tx_queue_size < VIRTIO_NET_TX_QUEUE_MIN_SIZE ||
|
||||
n->net_conf.tx_queue_size > VIRTQUEUE_MAX_SIZE ||
|
||||
!is_power_of_2(n->net_conf.tx_queue_size)) {
|
||||
......
|
||||
return;
|
||||
}
|
||||
|
||||
n->max_queues = MAX(n->nic_conf.peers.queues, 1);
|
||||
if (n->max_queues * 2 + 1 > VIRTIO_QUEUE_MAX) {
|
||||
......
|
||||
return;
|
||||
}
|
||||
n->vqs = g_malloc0(sizeof(VirtIONetQueue) * n->max_queues);
|
||||
n->curr_queues = 1;
|
||||
......
|
||||
n->net_conf.tx_queue_size = MIN(virtio_net_max_tx_queue_size(n),
|
||||
n->net_conf.tx_queue_size);
|
||||
|
||||
for (i = 0; i < n->max_queues; i++) {
|
||||
virtio_net_add_queue(n, i);
|
||||
}
|
||||
|
||||
n->ctrl_vq = virtio_add_queue(vdev, 64, virtio_net_handle_ctrl);
|
||||
qemu_macaddr_default_if_unset(&n->nic_conf.macaddr);
|
||||
memcpy(&n->mac[0], &n->nic_conf.macaddr, sizeof(n->mac));
|
||||
n->status = VIRTIO_NET_S_LINK_UP;
|
||||
|
||||
if (n->netclient_type) {
|
||||
n->nic = qemu_new_nic(&net_virtio_info, &n->nic_conf,
|
||||
n->netclient_type, n->netclient_name, n);
|
||||
} else {
|
||||
n->nic = qemu_new_nic(&net_virtio_info, &n->nic_conf,
|
||||
object_get_typename(OBJECT(dev)), dev->id, n);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面创建了一个VirtIODevice,这一点和存储虚拟化也是一样的。virtio_init用来初始化这个设备。VirtIODevice结构里面有一个VirtQueue数组,这就是virtio前端和后端互相传数据的队列,最多有VIRTIO_QUEUE_MAX个。
|
||||
|
||||
刚才我们说的都是一样的地方,其实也有不一样的地方,我们下面来看。
|
||||
|
||||
你会发现,这里面有这样的语句n->max_queues * 2 + 1 > VIRTIO_QUEUE_MAX。为什么要乘以2呢?这是因为,对于网络设备来讲,应该分发送队列和接收队列两个方向,所以乘以2。
|
||||
|
||||
接下来,我们调用virtio_net_add_queue来初始化队列,可以看出来,这里面就有发送tx_vq和接收rx_vq两个队列。
|
||||
|
||||
```
|
||||
typedef struct VirtIONetQueue {
|
||||
VirtQueue *rx_vq;
|
||||
VirtQueue *tx_vq;
|
||||
QEMUTimer *tx_timer;
|
||||
QEMUBH *tx_bh;
|
||||
uint32_t tx_waiting;
|
||||
struct {
|
||||
VirtQueueElement *elem;
|
||||
} async_tx;
|
||||
struct VirtIONet *n;
|
||||
} VirtIONetQueue;
|
||||
|
||||
static void virtio_net_add_queue(VirtIONet *n, int index)
|
||||
{
|
||||
VirtIODevice *vdev = VIRTIO_DEVICE(n);
|
||||
|
||||
n->vqs[index].rx_vq = virtio_add_queue(vdev, n->net_conf.rx_queue_size, virtio_net_handle_rx);
|
||||
|
||||
......
|
||||
|
||||
n->vqs[index].tx_vq = virtio_add_queue(vdev, n->net_conf.tx_queue_size, virtio_net_handle_tx_bh);
|
||||
n->vqs[index].tx_bh = qemu_bh_new(virtio_net_tx_bh, &n->vqs[index]);
|
||||
n->vqs[index].n = n;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
每个VirtQueue中,都有一个vring用来维护这个队列里面的数据;另外还有函数virtio_net_handle_rx用于处理网络包的接收;函数virtio_net_handle_tx_bh用于网络包的发送,这个函数我们后面会用到。
|
||||
|
||||
```
|
||||
NICState *qemu_new_nic(NetClientInfo *info,
|
||||
NICConf *conf,
|
||||
const char *model,
|
||||
const char *name,
|
||||
void *opaque)
|
||||
{
|
||||
NetClientState **peers = conf->peers.ncs;
|
||||
NICState *nic;
|
||||
int i, queues = MAX(1, conf->peers.queues);
|
||||
......
|
||||
nic = g_malloc0(info->size + sizeof(NetClientState) * queues);
|
||||
nic->ncs = (void *)nic + info->size;
|
||||
nic->conf = conf;
|
||||
nic->opaque = opaque;
|
||||
|
||||
for (i = 0; i < queues; i++) {
|
||||
qemu_net_client_setup(&nic->ncs[i], info, peers[i], model, name, NULL);
|
||||
nic->ncs[i].queue_index = i;
|
||||
}
|
||||
|
||||
return nic;
|
||||
}
|
||||
|
||||
static void qemu_net_client_setup(NetClientState *nc,
|
||||
NetClientInfo *info,
|
||||
NetClientState *peer,
|
||||
const char *model,
|
||||
const char *name,
|
||||
NetClientDestructor *destructor)
|
||||
{
|
||||
nc->info = info;
|
||||
nc->model = g_strdup(model);
|
||||
if (name) {
|
||||
nc->name = g_strdup(name);
|
||||
} else {
|
||||
nc->name = assign_name(nc, model);
|
||||
}
|
||||
|
||||
QTAILQ_INSERT_TAIL(&net_clients, nc, next);
|
||||
|
||||
nc->incoming_queue = qemu_new_net_queue(qemu_deliver_packet_iov, nc);
|
||||
nc->destructor = destructor;
|
||||
QTAILQ_INIT(&nc->filters);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来,qemu_new_nic会创建一个虚拟机里面的网卡。
|
||||
|
||||
## qemu的启动过程中的网络虚拟化
|
||||
|
||||
初始化过程解析完毕以后,我们接下来从qemu的启动过程看起。
|
||||
|
||||
对于网卡的虚拟化,qemu的启动参数里面有关的是下面两行:
|
||||
|
||||
```
|
||||
-netdev tap,fd=32,id=hostnet0,vhost=on,vhostfd=37
|
||||
-device virtio-net-pci,netdev=hostnet0,id=net0,mac=fa:16:3e:d1:2d:99,bus=pci.0,addr=0x3
|
||||
|
||||
```
|
||||
|
||||
qemu的main函数会调用net_init_clients进行网络设备的初始化,可以解析net参数,也可以在net_init_clients中解析netdev参数。
|
||||
|
||||
```
|
||||
int net_init_clients(Error **errp)
|
||||
{
|
||||
QTAILQ_INIT(&net_clients);
|
||||
if (qemu_opts_foreach(qemu_find_opts("netdev"),
|
||||
net_init_netdev, NULL, errp)) {
|
||||
return -1;
|
||||
}
|
||||
if (qemu_opts_foreach(qemu_find_opts("nic"), net_param_nic, NULL, errp)) {
|
||||
return -1;
|
||||
}
|
||||
if (qemu_opts_foreach(qemu_find_opts("net"), net_init_client, NULL, errp)) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
net_init_clients会解析参数。上面的参数netdev会调用net_init_netdev->net_client_init->net_client_init1。
|
||||
|
||||
net_client_init1会根据不同的driver类型,调用不同的初始化函数。
|
||||
|
||||
```
|
||||
static int (* const net_client_init_fun[NET_CLIENT_DRIVER__MAX])(
|
||||
const Netdev *netdev,
|
||||
const char *name,
|
||||
NetClientState *peer, Error **errp) = {
|
||||
[NET_CLIENT_DRIVER_NIC] = net_init_nic,
|
||||
[NET_CLIENT_DRIVER_TAP] = net_init_tap,
|
||||
[NET_CLIENT_DRIVER_SOCKET] = net_init_socket,
|
||||
[NET_CLIENT_DRIVER_HUBPORT] = net_init_hubport,
|
||||
......
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
由于我们配置的driver的类型是tap,因而这里会调用net_init_tap->net_tap_init->tap_open。
|
||||
|
||||
```
|
||||
#define PATH_NET_TUN "/dev/net/tun"
|
||||
|
||||
int tap_open(char *ifname, int ifname_size, int *vnet_hdr,
|
||||
int vnet_hdr_required, int mq_required, Error **errp)
|
||||
{
|
||||
struct ifreq ifr;
|
||||
int fd, ret;
|
||||
int len = sizeof(struct virtio_net_hdr);
|
||||
unsigned int features;
|
||||
|
||||
TFR(fd = open(PATH_NET_TUN, O_RDWR));
|
||||
memset(&ifr, 0, sizeof(ifr));
|
||||
ifr.ifr_flags = IFF_TAP | IFF_NO_PI;
|
||||
|
||||
if (ioctl(fd, TUNGETFEATURES, &features) == -1) {
|
||||
features = 0;
|
||||
}
|
||||
|
||||
if (features & IFF_ONE_QUEUE) {
|
||||
ifr.ifr_flags |= IFF_ONE_QUEUE;
|
||||
}
|
||||
|
||||
if (*vnet_hdr) {
|
||||
if (features & IFF_VNET_HDR) {
|
||||
*vnet_hdr = 1;
|
||||
ifr.ifr_flags |= IFF_VNET_HDR;
|
||||
} else {
|
||||
*vnet_hdr = 0;
|
||||
}
|
||||
ioctl(fd, TUNSETVNETHDRSZ, &len);
|
||||
}
|
||||
......
|
||||
ret = ioctl(fd, TUNSETIFF, (void *) &ifr);
|
||||
......
|
||||
fcntl(fd, F_SETFL, O_NONBLOCK);
|
||||
return fd;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在tap_open中,我们打开一个文件"/dev/net/tun",然后通过ioctl操作这个文件。这是Linux内核的一项机制,和KVM机制很像。其实这就是一种通过打开这个字符设备文件,然后通过ioctl操作这个文件和内核打交道,来使用内核的能力。
|
||||
|
||||
<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("GPL");
|
||||
MODULE_ALIAS_MISCDEV(TUN_MINOR);
|
||||
MODULE_ALIAS("devname:net/tun");
|
||||
|
||||
static int __init tun_init(void)
|
||||
{
|
||||
......
|
||||
ret = rtnl_link_register(&tun_link_ops);
|
||||
......
|
||||
ret = misc_register(&tun_miscdev);
|
||||
......
|
||||
ret = register_netdevice_notifier(&tun_notifier_block);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面注册了一个tun_miscdev字符设备,从它的定义可以看出,这就是"/dev/net/tun"字符设备。
|
||||
|
||||
```
|
||||
static struct miscdevice tun_miscdev = {
|
||||
.minor = TUN_MINOR,
|
||||
.name = "tun",
|
||||
.nodename = "net/tun",
|
||||
.fops = &tun_fops,
|
||||
};
|
||||
|
||||
static const struct file_operations tun_fops = {
|
||||
.owner = THIS_MODULE,
|
||||
.llseek = no_llseek,
|
||||
.read_iter = tun_chr_read_iter,
|
||||
.write_iter = tun_chr_write_iter,
|
||||
.poll = tun_chr_poll,
|
||||
.unlocked_ioctl = tun_chr_ioctl,
|
||||
.open = tun_chr_open,
|
||||
.release = tun_chr_close,
|
||||
.fasync = tun_chr_fasync,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
qemu的tap_open函数会打开这个字符设备PATH_NET_TUN。打开字符设备的过程我们不再重复。我就说一下,到了驱动这一层,调用的是tun_chr_open。
|
||||
|
||||
```
|
||||
static int tun_chr_open(struct inode *inode, struct file * file)
|
||||
{
|
||||
struct tun_file *tfile;
|
||||
tfile = (struct tun_file *)sk_alloc(net, AF_UNSPEC, GFP_KERNEL,
|
||||
&tun_proto, 0);
|
||||
RCU_INIT_POINTER(tfile->tun, NULL);
|
||||
tfile->flags = 0;
|
||||
tfile->ifindex = 0;
|
||||
|
||||
init_waitqueue_head(&tfile->wq.wait);
|
||||
RCU_INIT_POINTER(tfile->socket.wq, &tfile->wq);
|
||||
|
||||
tfile->socket.file = file;
|
||||
tfile->socket.ops = &tun_socket_ops;
|
||||
|
||||
sock_init_data(&tfile->socket, &tfile->sk);
|
||||
|
||||
tfile->sk.sk_write_space = tun_sock_write_space;
|
||||
tfile->sk.sk_sndbuf = INT_MAX;
|
||||
|
||||
file->private_data = tfile;
|
||||
INIT_LIST_HEAD(&tfile->next);
|
||||
|
||||
sock_set_flag(&tfile->sk, SOCK_ZEROCOPY);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在tun_chr_open的参数里面,有一个struct file,这是代表什么文件呢?它代表的就是打开的字符设备文件"/dev/net/tun",因而往这个字符设备文件中写数据,就会通过这个struct file写入。这个struct file里面的file_operations,按照字符设备打开的规则,指向的就是tun_fops。
|
||||
|
||||
另外,我们还需要在tun_chr_open创建了一个结构struct tun_file,并且将struct file的private_data指向它。
|
||||
|
||||
```
|
||||
/* A tun_file connects an open character device to a tuntap netdevice. It
|
||||
* also contains all socket related structures
|
||||
* to serve as one transmit queue for tuntap device.
|
||||
*/
|
||||
struct tun_file {
|
||||
struct sock sk;
|
||||
struct socket socket;
|
||||
struct socket_wq wq;
|
||||
struct tun_struct __rcu *tun;
|
||||
struct fasync_struct *fasync;
|
||||
/* only used for fasnyc */
|
||||
unsigned int flags;
|
||||
union {
|
||||
u16 queue_index;
|
||||
unsigned int ifindex;
|
||||
};
|
||||
struct list_head next;
|
||||
struct tun_struct *detached;
|
||||
struct skb_array tx_array;
|
||||
};
|
||||
|
||||
struct tun_struct {
|
||||
struct tun_file __rcu *tfiles[MAX_TAP_QUEUES];
|
||||
unsigned int numqueues;
|
||||
unsigned int flags;
|
||||
kuid_t owner;
|
||||
kgid_t group;
|
||||
|
||||
struct net_device *dev;
|
||||
netdev_features_t set_features;
|
||||
int align;
|
||||
int vnet_hdr_sz;
|
||||
int sndbuf;
|
||||
struct tap_filter txflt;
|
||||
struct sock_fprog fprog;
|
||||
/* protected by rtnl lock */
|
||||
bool filter_attached;
|
||||
spinlock_t lock;
|
||||
struct hlist_head flows[TUN_NUM_FLOW_ENTRIES];
|
||||
struct timer_list flow_gc_timer;
|
||||
unsigned long ageing_time;
|
||||
unsigned int numdisabled;
|
||||
struct list_head disabled;
|
||||
void *security;
|
||||
u32 flow_count;
|
||||
u32 rx_batched;
|
||||
struct tun_pcpu_stats __percpu *pcpu_stats;
|
||||
};
|
||||
|
||||
static const struct proto_ops tun_socket_ops = {
|
||||
.peek_len = tun_peek_len,
|
||||
.sendmsg = tun_sendmsg,
|
||||
.recvmsg = tun_recvmsg,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在struct tun_file中,有一个成员struct tun_struct,它里面有一个struct net_device,这个用来表示宿主机上的tuntap网络设备。在struct tun_file中,还有struct socket和struct sock,因为要用到内核的网络协议栈,所以就需要这两个结构,这在[网络协议](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->private_data;
|
||||
struct tun_struct *tun;
|
||||
void __user* argp = (void __user*)arg;
|
||||
struct ifreq ifr;
|
||||
kuid_t owner;
|
||||
kgid_t group;
|
||||
int sndbuf;
|
||||
int vnet_hdr_sz;
|
||||
unsigned int ifindex;
|
||||
int le;
|
||||
int ret;
|
||||
|
||||
if (cmd == TUNSETIFF || cmd == TUNSETQUEUE || _IOC_TYPE(cmd) == SOCK_IOC_TYPE) {
|
||||
if (copy_from_user(&ifr, argp, ifreq_len))
|
||||
return -EFAULT;
|
||||
}
|
||||
......
|
||||
tun = __tun_get(tfile);
|
||||
if (cmd == TUNSETIFF) {
|
||||
ifr.ifr_name[IFNAMSIZ-1] = '\0';
|
||||
ret = tun_set_iff(sock_net(&tfile->sk), file, &ifr);
|
||||
......
|
||||
if (copy_to_user(argp, &ifr, ifreq_len))
|
||||
ret = -EFAULT;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在__tun_chr_ioctl中,我们首先通过copy_from_user把配置从用户态拷贝到内核态,调用tun_set_iff设置tuntap网络设备,然后调用copy_to_user将配置结果返回。
|
||||
|
||||
```
|
||||
static int tun_set_iff(struct net *net, struct file *file, struct ifreq *ifr)
|
||||
{
|
||||
struct tun_struct *tun;
|
||||
struct tun_file *tfile = file->private_data;
|
||||
struct net_device *dev;
|
||||
......
|
||||
char *name;
|
||||
unsigned long flags = 0;
|
||||
int queues = ifr->ifr_flags & IFF_MULTI_QUEUE ?
|
||||
MAX_TAP_QUEUES : 1;
|
||||
|
||||
if (ifr->ifr_flags & IFF_TUN) {
|
||||
/* TUN device */
|
||||
flags |= IFF_TUN;
|
||||
name = "tun%d";
|
||||
} else if (ifr->ifr_flags & IFF_TAP) {
|
||||
/* TAP device */
|
||||
flags |= IFF_TAP;
|
||||
name = "tap%d";
|
||||
} else
|
||||
return -EINVAL;
|
||||
|
||||
if (*ifr->ifr_name)
|
||||
name = ifr->ifr_name;
|
||||
|
||||
dev = alloc_netdev_mqs(sizeof(struct tun_struct), name,
|
||||
NET_NAME_UNKNOWN, tun_setup, queues,
|
||||
queues);
|
||||
|
||||
err = dev_get_valid_name(net, dev, name);
|
||||
dev_net_set(dev, net);
|
||||
dev->rtnl_link_ops = &tun_link_ops;
|
||||
dev->ifindex = tfile->ifindex;
|
||||
dev->sysfs_groups[0] = &tun_attr_group;
|
||||
|
||||
tun = netdev_priv(dev);
|
||||
tun->dev = dev;
|
||||
tun->flags = flags;
|
||||
tun->txflt.count = 0;
|
||||
tun->vnet_hdr_sz = sizeof(struct virtio_net_hdr);
|
||||
|
||||
tun->align = NET_SKB_PAD;
|
||||
tun->filter_attached = false;
|
||||
tun->sndbuf = tfile->socket.sk->sk_sndbuf;
|
||||
tun->rx_batched = 0;
|
||||
|
||||
tun_net_init(dev);
|
||||
tun_flow_init(tun);
|
||||
|
||||
err = tun_attach(tun, file, false);
|
||||
err = register_netdevice(tun->dev);
|
||||
|
||||
netif_carrier_on(tun->dev);
|
||||
|
||||
if (netif_running(tun->dev))
|
||||
netif_tx_wake_all_queues(tun->dev);
|
||||
|
||||
strcpy(ifr->ifr_name, tun->dev->name);
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
tun_set_iff创建了struct tun_struct和struct net_device,并且将这个tuntap网络设备通过register_netdevice注册到内核中。这样,我们就能在宿主机上通过ip addr看到这个网卡了。
|
||||
|
||||
<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(&virtio_net_driver);
|
||||
......
|
||||
}
|
||||
module_init(virtio_net_driver_init);
|
||||
module_exit(virtio_net_driver_exit);
|
||||
|
||||
MODULE_DEVICE_TABLE(virtio, id_table);
|
||||
MODULE_DESCRIPTION("Virtio network driver");
|
||||
MODULE_LICENSE("GPL");
|
||||
|
||||
static struct virtio_driver virtio_net_driver = {
|
||||
.driver.name = KBUILD_MODNAME,
|
||||
.driver.owner = THIS_MODULE,
|
||||
.id_table = id_table,
|
||||
.validate = virtnet_validate,
|
||||
.probe = virtnet_probe,
|
||||
.remove = virtnet_remove,
|
||||
.config_changed = virtnet_config_changed,
|
||||
......
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在virtio_net的驱动程序的初始化代码中,我们需要注册一个驱动函数virtio_net_driver。
|
||||
|
||||
当一个设备驱动作为一个内核模块被初始化的时候,probe函数会被调用,因而我们来看一下virtnet_probe。
|
||||
|
||||
```
|
||||
static int virtnet_probe(struct virtio_device *vdev)
|
||||
{
|
||||
int i, err;
|
||||
struct net_device *dev;
|
||||
struct virtnet_info *vi;
|
||||
u16 max_queue_pairs;
|
||||
int mtu;
|
||||
|
||||
/* Allocate ourselves a network device with room for our info */
|
||||
dev = alloc_etherdev_mq(sizeof(struct virtnet_info), max_queue_pairs);
|
||||
|
||||
/* Set up network device as normal. */
|
||||
dev->priv_flags |= IFF_UNICAST_FLT | IFF_LIVE_ADDR_CHANGE;
|
||||
dev->netdev_ops = &virtnet_netdev;
|
||||
dev->features = NETIF_F_HIGHDMA;
|
||||
|
||||
dev->ethtool_ops = &virtnet_ethtool_ops;
|
||||
SET_NETDEV_DEV(dev, &vdev->dev);
|
||||
......
|
||||
/* MTU range: 68 - 65535 */
|
||||
dev->min_mtu = MIN_MTU;
|
||||
dev->max_mtu = MAX_MTU;
|
||||
|
||||
/* Set up our device-specific information */
|
||||
vi = netdev_priv(dev);
|
||||
vi->dev = dev;
|
||||
vi->vdev = vdev;
|
||||
vdev->priv = vi;
|
||||
vi->stats = alloc_percpu(struct virtnet_stats);
|
||||
INIT_WORK(&vi->config_work, virtnet_config_changed_work);
|
||||
......
|
||||
vi->max_queue_pairs = max_queue_pairs;
|
||||
|
||||
/* Allocate/initialize the rx/tx queues, and invoke find_vqs */
|
||||
err = init_vqs(vi);
|
||||
netif_set_real_num_tx_queues(dev, vi->curr_queue_pairs);
|
||||
netif_set_real_num_rx_queues(dev, vi->curr_queue_pairs);
|
||||
|
||||
virtnet_init_settings(dev);
|
||||
|
||||
err = register_netdev(dev);
|
||||
virtio_device_ready(vdev);
|
||||
virtnet_set_queues(vi, vi->curr_queue_pairs);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在virtnet_probe中,会创建struct net_device,并且通过register_netdev注册这个网络设备,这样在客户机里面,就能看到这个网卡了。
|
||||
|
||||
在virtnet_probe中,还有一件重要的事情就是,init_vqs会初始化发送和接收的virtqueue。
|
||||
|
||||
```
|
||||
static int init_vqs(struct virtnet_info *vi)
|
||||
{
|
||||
int ret;
|
||||
|
||||
/* Allocate send & receive queues */
|
||||
ret = virtnet_alloc_queues(vi);
|
||||
ret = virtnet_find_vqs(vi);
|
||||
......
|
||||
get_online_cpus();
|
||||
virtnet_set_affinity(vi);
|
||||
put_online_cpus();
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int virtnet_alloc_queues(struct virtnet_info *vi)
|
||||
{
|
||||
int i;
|
||||
|
||||
vi->sq = kzalloc(sizeof(*vi->sq) * vi->max_queue_pairs, GFP_KERNEL);
|
||||
vi->rq = kzalloc(sizeof(*vi->rq) * vi->max_queue_pairs, GFP_KERNEL);
|
||||
|
||||
INIT_DELAYED_WORK(&vi->refill, refill_work);
|
||||
for (i = 0; i < vi->max_queue_pairs; i++) {
|
||||
vi->rq[i].pages = NULL;
|
||||
netif_napi_add(vi->dev, &vi->rq[i].napi, virtnet_poll,
|
||||
napi_weight);
|
||||
netif_tx_napi_add(vi->dev, &vi->sq[i].napi, virtnet_poll_tx,
|
||||
napi_tx ? napi_weight : 0);
|
||||
|
||||
sg_init_table(vi->rq[i].sg, ARRAY_SIZE(vi->rq[i].sg));
|
||||
ewma_pkt_len_init(&vi->rq[i].mrg_avg_pkt_len);
|
||||
sg_init_table(vi->sq[i].sg, ARRAY_SIZE(vi->sq[i].sg));
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
按照上一节的virtio原理,virtqueue是一个介于客户机前端和qemu后端的一个结构,用于在这两端之间传递数据,对于网络设备来讲有发送和接收两个方向的队列。这里建立的struct virtqueue是客户机前端对于队列的管理的数据结构。
|
||||
|
||||
队列的实体需要通过函数virtnet_find_vqs查找或者生成,这里还会指定接收队列的callback函数为skb_recv_done,发送队列的callback函数为skb_xmit_done。那当buffer使用发生变化的时候,我们可以调用这个callback函数进行通知。
|
||||
|
||||
```
|
||||
static int virtnet_find_vqs(struct virtnet_info *vi)
|
||||
{
|
||||
vq_callback_t **callbacks;
|
||||
struct virtqueue **vqs;
|
||||
int ret = -ENOMEM;
|
||||
int i, total_vqs;
|
||||
const char **names;
|
||||
|
||||
/* Allocate space for find_vqs parameters */
|
||||
vqs = kzalloc(total_vqs * sizeof(*vqs), GFP_KERNEL);
|
||||
callbacks = kmalloc(total_vqs * sizeof(*callbacks), GFP_KERNEL);
|
||||
names = kmalloc(total_vqs * sizeof(*names), GFP_KERNEL);
|
||||
|
||||
/* Allocate/initialize parameters for send/receive virtqueues */
|
||||
for (i = 0; i < vi->max_queue_pairs; i++) {
|
||||
callbacks[rxq2vq(i)] = skb_recv_done;
|
||||
callbacks[txq2vq(i)] = skb_xmit_done;
|
||||
names[rxq2vq(i)] = vi->rq[i].name;
|
||||
names[txq2vq(i)] = vi->sq[i].name;
|
||||
}
|
||||
|
||||
ret = vi->vdev->config->find_vqs(vi->vdev, total_vqs, vqs, callbacks, names, ctx, NULL);
|
||||
......
|
||||
for (i = 0; i < vi->max_queue_pairs; i++) {
|
||||
vi->rq[i].vq = vqs[rxq2vq(i)];
|
||||
vi->rq[i].min_buf_len = mergeable_min_buf_len(vi, vi->rq[i].vq);
|
||||
vi->sq[i].vq = vqs[txq2vq(i)];
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里的find_vqs是在struct virtnet_info里的struct virtio_device里的struct virtio_config_ops *config里面定义的。
|
||||
|
||||
根据virtio_config_ops的定义,find_vqs会调用vp_modern_find_vqs,到这一步和块设备是一样的了。
|
||||
|
||||
在vp_modern_find_vqs中,vp_find_vqs会调用vp_find_vqs_intx。在vp_find_vqs_intx中,通过request_irq注册一个中断处理函数vp_interrupt。当设备向队列中写入信息时,会产生一个中断,也就是vq中断。中断处理函数需要调用相应的队列的回调函数,然后根据队列的数目,依次调用vp_setup_vq完成virtqueue、vring的分配和初始化。
|
||||
|
||||
同样,这些数据结构会和virtio后端的VirtIODevice、VirtQueue、vring对应起来,都应该指向刚才创建的那一段内存。
|
||||
|
||||
客户机同样会通过调用专门给外部设备发送指令的函数iowrite告诉外部的pci设备,这些共享内存的地址。
|
||||
|
||||
至此前端设备驱动和后端设备驱动之间的两个收发队列就关联好了,这两个队列的格式和块设备是一样的。
|
||||
|
||||
## 发送网络包过程
|
||||
|
||||
接下来,我们来看当真的发送一个网络包的时候,会发生什么。
|
||||
|
||||
当网络包经过客户机的协议栈到达virtio_net驱动的时候,按照net_device_ops的定义,start_xmit会被调用。
|
||||
|
||||
```
|
||||
static const struct net_device_ops virtnet_netdev = {
|
||||
.ndo_open = virtnet_open,
|
||||
.ndo_stop = virtnet_close,
|
||||
.ndo_start_xmit = start_xmit,
|
||||
.ndo_validate_addr = eth_validate_addr,
|
||||
.ndo_set_mac_address = virtnet_set_mac_address,
|
||||
.ndo_set_rx_mode = virtnet_set_rx_mode,
|
||||
.ndo_get_stats64 = virtnet_stats,
|
||||
.ndo_vlan_rx_add_vid = virtnet_vlan_rx_add_vid,
|
||||
.ndo_vlan_rx_kill_vid = virtnet_vlan_rx_kill_vid,
|
||||
.ndo_xdp = virtnet_xdp,
|
||||
.ndo_features_check = passthru_features_check,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
接下来的调用链为:start_xmit->xmit_skb-> virtqueue_add_outbuf->virtqueue_add,将网络包放入队列中,并调用virtqueue_notify通知接收方。
|
||||
|
||||
```
|
||||
static netdev_tx_t start_xmit(struct sk_buff *skb, struct net_device *dev)
|
||||
{
|
||||
struct virtnet_info *vi = netdev_priv(dev);
|
||||
int qnum = skb_get_queue_mapping(skb);
|
||||
struct send_queue *sq = &vi->sq[qnum];
|
||||
int err;
|
||||
struct netdev_queue *txq = netdev_get_tx_queue(dev, qnum);
|
||||
bool kick = !skb->xmit_more;
|
||||
bool use_napi = sq->napi.weight;
|
||||
......
|
||||
/* Try to transmit */
|
||||
err = xmit_skb(sq, skb);
|
||||
......
|
||||
if (kick || netif_xmit_stopped(txq))
|
||||
virtqueue_kick(sq->vq);
|
||||
return NETDEV_TX_OK;
|
||||
}
|
||||
|
||||
bool virtqueue_kick(struct virtqueue *vq)
|
||||
{
|
||||
if (virtqueue_kick_prepare(vq))
|
||||
return virtqueue_notify(vq);
|
||||
return true;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
写入一个I/O会使得qemu触发VM exit,这个逻辑我们在解析CPU的时候看到过。
|
||||
|
||||
接下来,我们那会调用VirtQueue的handle_output函数。前面我们已经设置过这个函数了,其实就是virtio_net_handle_tx_bh。
|
||||
|
||||
```
|
||||
static void virtio_net_handle_tx_bh(VirtIODevice *vdev, VirtQueue *vq)
|
||||
{
|
||||
VirtIONet *n = VIRTIO_NET(vdev);
|
||||
VirtIONetQueue *q = &n->vqs[vq2q(virtio_get_queue_index(vq))];
|
||||
|
||||
q->tx_waiting = 1;
|
||||
|
||||
virtio_queue_set_notification(vq, 0);
|
||||
qemu_bh_schedule(q->tx_bh);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
virtio_net_handle_tx_bh调用了qemu_bh_schedule,而在virtio_net_add_queue中调用qemu_bh_new,并把函数设置为virtio_net_tx_bh。
|
||||
|
||||
virtio_net_tx_bh函数调用发送函数virtio_net_flush_tx。
|
||||
|
||||
```
|
||||
static int32_t virtio_net_flush_tx(VirtIONetQueue *q)
|
||||
{
|
||||
VirtIONet *n = q->n;
|
||||
VirtIODevice *vdev = VIRTIO_DEVICE(n);
|
||||
VirtQueueElement *elem;
|
||||
int32_t num_packets = 0;
|
||||
int queue_index = vq2q(virtio_get_queue_index(q->tx_vq));
|
||||
|
||||
for (;;) {
|
||||
ssize_t ret;
|
||||
unsigned int out_num;
|
||||
struct iovec sg[VIRTQUEUE_MAX_SIZE], sg2[VIRTQUEUE_MAX_SIZE + 1], *out_sg;
|
||||
struct virtio_net_hdr_mrg_rxbuf mhdr;
|
||||
|
||||
elem = virtqueue_pop(q->tx_vq, sizeof(VirtQueueElement));
|
||||
out_num = elem->out_num;
|
||||
out_sg = elem->out_sg;
|
||||
......
|
||||
ret = qemu_sendv_packet_async(qemu_get_subqueue(n->nic, queue_index),out_sg, out_num, virtio_net_tx_complete);
|
||||
}
|
||||
......
|
||||
return num_packets;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
virtio_net_flush_tx会调用virtqueue_pop。这里面,我们能看到对于vring的操作,也即从这里面将客户机里面写入的数据读取出来。
|
||||
|
||||
然后,我们调用qemu_sendv_packet_async发送网络包。接下来的调用链为:qemu_sendv_packet_async->qemu_net_queue_send_iov->qemu_net_queue_flush->qemu_net_queue_deliver。
|
||||
|
||||
在qemu_net_queue_deliver中,我们会调用NetQueue的deliver函数。前面qemu_new_net_queue会把deliver函数设置为qemu_deliver_packet_iov。它会调用nc->info->receive_iov。
|
||||
|
||||
```
|
||||
static NetClientInfo net_tap_info = {
|
||||
.type = NET_CLIENT_DRIVER_TAP,
|
||||
.size = sizeof(TAPState),
|
||||
.receive = tap_receive,
|
||||
.receive_raw = tap_receive_raw,
|
||||
.receive_iov = tap_receive_iov,
|
||||
.poll = tap_poll,
|
||||
.cleanup = tap_cleanup,
|
||||
.has_ufo = tap_has_ufo,
|
||||
.has_vnet_hdr = tap_has_vnet_hdr,
|
||||
.has_vnet_hdr_len = tap_has_vnet_hdr_len,
|
||||
.using_vnet_hdr = tap_using_vnet_hdr,
|
||||
.set_offload = tap_set_offload,
|
||||
.set_vnet_hdr_len = tap_set_vnet_hdr_len,
|
||||
.set_vnet_le = tap_set_vnet_le,
|
||||
.set_vnet_be = tap_set_vnet_be,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
根据net_tap_info的定义调用的是tap_receive_iov。他会调用tap_write_packet->writev写入这个字符设备。
|
||||
|
||||
在内核的字符设备驱动中,tun_chr_write_iter会被调用。
|
||||
|
||||
```
|
||||
static ssize_t tun_chr_write_iter(struct kiocb *iocb, struct iov_iter *from)
|
||||
{
|
||||
struct file *file = iocb->ki_filp;
|
||||
struct tun_struct *tun = tun_get(file);
|
||||
struct tun_file *tfile = file->private_data;
|
||||
ssize_t result;
|
||||
|
||||
result = tun_get_user(tun, tfile, NULL, from,
|
||||
file->f_flags & O_NONBLOCK, false);
|
||||
|
||||
tun_put(tun);
|
||||
return result;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当我们使用writev()系统调用向tun/tap设备的字符设备文件写入数据时,tun_chr_write函数将被调用。它会使用tun_get_user,从用户区接收数据,将数据存入skb中,然后调用关键的函数netif_rx_ni(skb) ,将skb送给tcp/ip协议栈处理,最终完成虚拟网卡的数据接收。
|
||||
|
||||
至此,从虚拟机内部到宿主机的网络传输过程才算结束。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
最后,我们把网络虚拟化场景下网络包的发送过程总结一下。
|
||||
|
||||
- 在虚拟机里面的用户态,应用程序通过write系统调用写入socket。
|
||||
- 写入的内容经过VFS层,内核协议栈,到达虚拟机里面的内核的网络设备驱动,也即virtio_net。
|
||||
- virtio_net网络设备有一个操作结构struct net_device_ops,里面定义了发送一个网络包调用的函数为start_xmit。
|
||||
- 在virtio_net的前端驱动和qemu中的后端驱动之间,有两个队列virtqueue,一个用于发送,一个用于接收。然后,我们需要在start_xmit中调用virtqueue_add,将网络包放入发送队列,然后调用virtqueue_notify通知qemu。
|
||||
- qemu本来处于KVM_RUN的状态,收到通知后,通过VM exit指令退出客户机模式,进入宿主机模式。发送网络包的时候,virtio_net_handle_tx_bh函数会被调用。
|
||||
- 接下来是一个for循环,我们需要在循环中调用virtqueue_pop,从传输队列中获取要发送的数据,然后调用qemu_sendv_packet_async进行发送。
|
||||
- qemu会调用writev向字符设备文件写入,进入宿主机的内核。
|
||||
- 在宿主机内核中字符设备文件的file_operations里面的write_iter会被调用,也即会调用tun_chr_write_iter。
|
||||
- 在tun_chr_write_iter函数中,tun_get_user将要发送的网络包从qemu拷贝到宿主机内核里面来,然后调用netif_rx_ni开始调用宿主机内核协议栈进行处理。
|
||||
- 宿主机内核协议栈处理完毕之后,会发送给tap虚拟网卡,完成从虚拟机里面到宿主机的整个发送过程。
|
||||
|
||||
<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="">
|
||||
@@ -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就只能认00,01,10,11四个位置,超过四个位置,就区分不出来了。位数越多,能够访问的位置就越多,能管理的内存的范围也就越广。
|
||||
|
||||
而数据总线的位数,决定了一次能拿多少个数据进来。例如只有两位,那CPU一次只能从内存拿两位数。要想拿八位,就要拿四次。位数越多,一次拿的数据就越多,访问速度也就越快。
|
||||
|
||||
## x86成为开放平台历史中的重要一笔
|
||||
|
||||
那CPU中总线的位数有没有个标准呢?如果没有标准,那操作系统作为软件就很难办了,因为软件层没办法实现通用的运算逻辑。这就像很多非标准的元器件一样,你烧你的电路板,我烧我的电路板,谁都不能用彼此的。
|
||||
|
||||
早期的IBM凭借大型机技术成为计算机市场的领头羊,直到后来个人计算机兴起,苹果公司诞生。但是,那个时候,无论是大型机还是个人计算机,每家的CPU架构都不一样。如果一直是这样,个人电脑、平板电脑、手机等等,都没办法形成统一的体系,就不会有我们现在通用的计算机了,更别提什么云计算、大数据这些统一的大平台了。
|
||||
|
||||
好在历史将x86平台推到了**开放、统一、兼容**的位置。我们继续来看IBM和x86的故事。
|
||||
|
||||
IBM开始做IBM PC时,一开始并没有让最牛的华生实验室去研发,而是交给另一个团队。一年时间,软硬件全部自研根本不可能完成,于是他们采用了英特尔的8088芯片作为CPU,使用微软的MS-DOS做操作系统。
|
||||
|
||||
谁能想到IBM PC卖得超级好,好到因为垄断市场而被起诉。IBM就在被逼的情况下公开了一些技术,使得后来无数IBM-PC兼容机公司的出现,也就有了后来占据市场的惠普、康柏、戴尔等等。
|
||||
|
||||
能够开放自己的技术是一件了不起的事。从技术和发展的层面来讲,它会使得一项技术大面积铺开,形成行业标准。就比如现在常用的Android手机,如果没有开放的Android系统,我们也没办法享受到这么多不同类型的手机。
|
||||
|
||||
对于当年的PC机来说,其实也是这样。英特尔的技术因此成为了行业的开放事实标准。由于这个系列开端于8086,因此称为x86架构。
|
||||
|
||||
后来英特尔的CPU数据总线和地址总线越来越宽,处理能力越来越强。但是一直不能忘记三点,一是标准,二是开放,三是兼容。因为要想如此大的一个软硬件生态都基于这个架构,符合它的标准,如果是封闭或者不兼容的,那谁都不答应。
|
||||
|
||||
<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调用B,B调用C。当A调用B的时候,要执行B函数的逻辑,因而A运行的相关信息就会被push到栈里面。当B调用C的时候,同样,B运行相关信息会被push到栈里面,然后才运行C函数的逻辑。当C运行完毕的时候,先pop出来的是B,B就接着调用C之后的指令运行下去。B运行完了,再pop出来的就是A,A接着运行,直到结束。
|
||||
|
||||
如果运算中需要加载内存中的数据,需要通过DS找到内存中的数据,加载到通用寄存器中,应该如何加载呢?对于一个段,有一个起始的地址,而段内的具体位置,我们称为**偏移量**(Offset)。例如8号会议室的第三排,8号会议室就是起始地址,第三排就是偏移量。
|
||||
|
||||
在CS和DS中都存放着一个段的起始地址。代码段的偏移量在IP寄存器中,数据段的偏移量会放在通用寄存器中。
|
||||
|
||||
这时候问题来了,CS和DS都是16位的,也就是说,起始地址都是16位的,IP寄存器和通用寄存器都是16位的,偏移量也是16位的,但是8086的地址总线地址是20位。怎么凑够这20位呢?方法就是“**起始地址*16+偏移量**”,也就是把CS和DS中的值左移4位,变成20位的,加上16位的偏移量,这样就可以得到最终20位的数据地址。
|
||||
|
||||
从这个计算方式可以算出,无论真正的内存多么大,对于只有20位地址总线的8086来讲,能够区分出的地址也就2^20=1M,超过这个空间就访问不到了。这又是为啥呢?如果你想访问1M+X的地方,这个位置已经超过20位了,由于地址总线只有20位,在总线上超过20位的部分根本是发不出去的,所以发出去的还是X,最后还是会访问1M内的X的位置。
|
||||
|
||||
那一个段最大能有多大呢?因为偏移量只能是16位的,所以一个段最大的大小是2^16=64k。
|
||||
|
||||
是不是好可怜?对于8086CPU,最多只能访问1M的内存空间,还要分成多个段,每个段最多64K。尽管我们现在看来这不可想象的小,根本没法儿用,但是在当时其实够用了。
|
||||
|
||||
## 再来说32位处理器
|
||||
|
||||
当然,后来计算机的发展日新月异,内存越来越大,总线也越来越宽。在32位处理器中,有32根地址总线,可以访问2^32=4G的内存。使用原来的模式肯定不行了,但是又不能完全抛弃原来的模式,因为这个架构是开放的。
|
||||
|
||||
“开放”,意味着有大量其他公司的软硬件是基于这个架构来实现的,不能为所欲为,想怎么改怎么改,一定要和原来的架构兼容,而且要一直兼容,这样大家才愿意跟着你这个开放平台一直玩下去。如果你朝令夕改,那其他厂商就惨了。
|
||||
|
||||
如果是不开放的架构,那就没有问题。硬件、操作系统,甚至上面的软件都是自己搞的,你想怎么改就可以怎么改。
|
||||
|
||||
我们下面来说说,在开放架构的基础上,如何保持兼容呢?
|
||||
|
||||
首先,通用寄存器有扩展,可以将8个16位的扩展到8个32位的,但是依然可以保留16位的和8位的使用方式。你可能会问,为什么高16位不分成两个8位使用呢?因为这样就不兼容了呀!
|
||||
|
||||
其中,指向下一条指令的指令指针寄存器IP,就会扩展成32位的,同样也兼容16位的。
|
||||
|
||||
<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="">
|
||||
@@ -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="">
|
||||
236
极客时间专栏/趣谈Linux操作系统/核心原理篇:第二部分 系统初始化/08 | 内核初始化:生意做大了就得成立公司.md
Normal file
236
极客时间专栏/趣谈Linux操作系统/核心原理篇:第二部分 系统初始化/08 | 内核初始化:生意做大了就得成立公司.md
Normal 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(&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()->init_rootfs()。这里面有一行代码,register_filesystem(&rootfs_fs_type)。在VFS虚拟文件系统里面注册了一种类型,我们定义为struct file_system_type rootfs_fs_type。
|
||||
|
||||
文件系统是我们的项目资料库,为了兼容各种各样的文件系统,我们需要将文件的相关数据结构和操作抽象出来,形成一个抽象层对上提供统一的接口,这个抽象层就是VFS(Virtual 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 = "/init";
|
||||
|
||||
```
|
||||
|
||||
先不管ramdisk是啥,我们回到kernel_init里面。这里面有这样的代码块:
|
||||
|
||||
```
|
||||
if (ramdisk_execute_command) {
|
||||
ret = run_init_process(ramdisk_execute_command);
|
||||
......
|
||||
}
|
||||
......
|
||||
if (!try_to_run_init_process("/sbin/init") ||
|
||||
!try_to_run_init_process("/etc/init") ||
|
||||
!try_to_run_init_process("/bin/init") ||
|
||||
!try_to_run_init_process("/bin/sh"))
|
||||
return 0;
|
||||
|
||||
|
||||
```
|
||||
|
||||
这就说明,1号进程运行的是一个文件。如果我们打开run_init_process函数,会发现它调用的是do_execve。
|
||||
|
||||
这个名字是不是看起来很熟悉?前面讲系统调用的时候,execve是一个系统调用,它的作用是运行一个执行文件。加一个do_的往往是内核系统调用的实现。没错,这就是一个系统调用,它会尝试运行ramdisk的“/init”,或者普通文件系统上的“/sbin/init”“/etc/init”“/bin/init”“/bin/sh”。不同版本的Linux会选择不同的文件启动,但是只要有一个起来了就可以。
|
||||
|
||||
```
|
||||
static int run_init_process(const char *init_filename)
|
||||
{
|
||||
argv_init[0] = init_filename;
|
||||
return do_execve(getname_kernel(init_filename),
|
||||
(const char __user *const __user *)argv_init,
|
||||
(const char __user *const __user *)envp_init);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如何利用执行init文件的机会,从内核态回到用户态呢?
|
||||
|
||||
我们从系统调用的过程可以得到启发,“用户态-系统调用-保存寄存器-内核态执行系统调用-恢复寄存器-返回用户态”,然后接着运行。而咱们刚才运行init,是调用do_execve,正是上面的过程的后半部分,从内核态执行系统调用开始。
|
||||
|
||||
do_execve->do_execveat_common->exec_binprm->search_binary_handler,这里面会调用这段内容:
|
||||
|
||||
```
|
||||
int search_binary_handler(struct linux_binprm *bprm)
|
||||
{
|
||||
......
|
||||
struct linux_binfmt *fmt;
|
||||
......
|
||||
retval = fmt->load_binary(bprm);
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
也就是说,我要运行一个程序,需要加载这个二进制文件,这就是我们常说的**项目执行计划书**。它是有一定格式的。Linux下一个常用的格式是**ELF**(Executable and Linkable Format,可执行与可链接格式)。于是我们就有了下面这个定义:
|
||||
|
||||
```
|
||||
static struct linux_binfmt elf_format = {
|
||||
.module = THIS_MODULE,
|
||||
.load_binary = load_elf_binary,
|
||||
.load_shlib = load_elf_library,
|
||||
.core_dump = elf_core_dump,
|
||||
.min_coredump = ELF_EXEC_PAGESIZE,
|
||||
};
|
||||
|
||||
|
||||
```
|
||||
|
||||
这其实就是先调用load_elf_binary,最后调用start_thread。
|
||||
|
||||
```
|
||||
void
|
||||
start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
|
||||
{
|
||||
set_user_gs(regs, 0);
|
||||
regs->fs = 0;
|
||||
regs->ds = __USER_DS;
|
||||
regs->es = __USER_DS;
|
||||
regs->ss = __USER_DS;
|
||||
regs->cs = __USER_CS;
|
||||
regs->ip = new_ip;
|
||||
regs->sp = new_sp;
|
||||
regs->flags = X86_EFLAGS_IF;
|
||||
force_iret();
|
||||
}
|
||||
EXPORT_SYMBOL_GPL(start_thread);
|
||||
|
||||
```
|
||||
|
||||
看到这里,你是不是有点感觉了?struct pt_regs,看名字里的register,就是寄存器啊!这个结构就是在系统调用的时候,内核中保存用户态运行上下文的,里面将用户态的代码段CS设置为__USER_CS,将用户态的数据段DS设置为__USER_DS,以及指令指针寄存器IP、栈指针寄存器SP。这里相当于补上了原来系统调用里,保存寄存器的一个步骤。
|
||||
|
||||
最后的iret是干什么的呢?它是用于从系统调用中返回。这个时候会恢复寄存器。从哪里恢复呢?按说是从进入系统调用的时候,保存的寄存器里面拿出。好在上面的函数补上了寄存器。CS和指令指针寄存器IP恢复了,指向用户态下一个要执行的语句。DS和函数栈指针SP也被恢复了,指向用户态函数栈的栈顶。所以,下一条指令,就从用户态开始运行了。
|
||||
|
||||
### ramdisk的作用
|
||||
|
||||
init终于从内核到用户态了。一开始到用户态的是ramdisk的init,后来会启动真正根文件系统上的init,成为所有用户态进程的祖先。
|
||||
|
||||
为什么会有ramdisk这个东西呢?还记得上一节咱们内核启动的时候,配置过这个参数:
|
||||
|
||||
```
|
||||
initrd16 /boot/initramfs-3.10.0-862.el7.x86_64.img
|
||||
|
||||
```
|
||||
|
||||
就是这个东西,这是一个基于内存的文件系统。为啥会有这个呢?
|
||||
|
||||
是因为刚才那个init程序是在文件系统上的,文件系统一定是在一个存储设备上的,例如硬盘。Linux访问存储设备,要有驱动才能访问。如果存储系统数目很有限,那驱动可以直接放到内核里面,反正前面我们加载过内核到内存里了,现在可以直接对存储系统进行访问。
|
||||
|
||||
但是存储系统越来越多了,如果所有市面上的存储系统的驱动都默认放进内核,内核就太大了。这该怎么办呢?
|
||||
|
||||
我们只好先弄一个基于内存的文件系统。内存访问是不需要驱动的,这个就是ramdisk。这个时候,ramdisk是根文件系统。
|
||||
|
||||
然后,我们开始运行ramdisk上的/init。等它运行完了就已经在用户态了。/init这个程序会先根据存储系统的类型加载驱动,有了驱动就可以设置真正的根文件系统了。有了真正的根文件系统,ramdisk上的/init会启动文件系统上的init。
|
||||
|
||||
接下来就是各种系统的初始化。启动系统的服务,启动控制台,用户就可以登录进来了。
|
||||
|
||||
先别忙着高兴,rest_init的第一个大事情才完成。我们仅仅形成了用户态所有进程的祖先。
|
||||
|
||||
## 创建2号进程
|
||||
|
||||
用户态的所有进程都有大师兄了,那内核态的进程有没有一个人统一管起来呢?有的,rest_init第二大事情就是第三个进程,就是2号进程。
|
||||
|
||||
kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES)又一次使用kernel_thread函数创建进程。这里需要指出一点,函数名thread可以翻译成“线程”,这也是操作系统很重要的一个概念。它和进程有什么区别呢?为什么这里创建的是进程,函数名却是线程呢?
|
||||
|
||||
从用户态来看,创建进程其实就是立项,也就是启动一个项目。这个项目包含很多资源,例如会议室、资料库等。这些东西都属于这个项目,但是这个项目需要人去执行。有多个人并行执行不同的部分,这就叫**多线程**(Multithreading)。如果只有一个人,那它就是这个项目的主线程。
|
||||
|
||||
但是从内核态来看,无论是进程,还是线程,我们都可以统称为任务(Task),都使用相同的数据结构,平放在同一个链表中。这些在进程的那一章节,我会更加详细地讲。
|
||||
|
||||
这里的函数kthreadd,负责所有内核态的线程的调度和管理,是内核态所有线程运行的祖先。
|
||||
|
||||
这下好了,用户态和内核态都有人管了,可以开始接项目了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们讲了内核的初始化过程,主要做了以下几件事情:
|
||||
|
||||
<li>
|
||||
各个职能部门的创建;
|
||||
</li>
|
||||
<li>
|
||||
用户态祖先进程的创建;
|
||||
</li>
|
||||
<li>
|
||||
内核态祖先进程的创建。
|
||||
</li>
|
||||
|
||||
咱们还是用一个图来总结一下这个过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/cd/758c283cf7633465d24ab3ef778328cd.jpeg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
这一节,我们看到内核创建了一些进程,这些进程都是放在一个列表中的,请你研读内核代码,看看这个列表是如何实现的。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
|
||||
410
极客时间专栏/趣谈Linux操作系统/核心原理篇:第二部分 系统初始化/09 | 系统调用:公司成立好了就要开始接项目.md
Normal file
410
极客时间专栏/趣谈Linux操作系统/核心原理篇:第二部分 系统初始化/09 | 系统调用:公司成立好了就要开始接项目.md
Normal 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->orig_ax */
|
||||
SAVE_ALL pt_regs_ax=$-ENOSYS /* save rest */
|
||||
movl %esp, %eax
|
||||
call do_syscall_32_irqs_on
|
||||
.Lsyscall_32_done:
|
||||
......
|
||||
.Lirq_return:
|
||||
INTERRUPT_RETURN
|
||||
|
||||
```
|
||||
|
||||
通过push和SAVE_ALL将当前用户态的寄存器,保存在pt_regs结构里面。
|
||||
|
||||
进入内核之前,保存所有的寄存器,然后调用do_syscall_32_irqs_on。它的实现如下:
|
||||
|
||||
```
|
||||
static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs)
|
||||
{
|
||||
struct thread_info *ti = current_thread_info();
|
||||
unsigned int nr = (unsigned int)regs->orig_ax;
|
||||
......
|
||||
if (likely(nr < IA32_NR_syscalls)) {
|
||||
regs->ax = ia32_sys_call_table[nr](
|
||||
(unsigned int)regs->bx, (unsigned int)regs->cx,
|
||||
(unsigned int)regs->dx, (unsigned int)regs->si,
|
||||
(unsigned int)regs->di, (unsigned int)regs->bp);
|
||||
}
|
||||
syscall_return_slowpath(regs);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里,我们看到,将系统调用号从eax里面取出来,然后根据系统调用号,在系统调用表中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。如果仔细比对,就能发现,这些参数所对应的寄存器,和Linux的注释是一样的。
|
||||
|
||||
根据宏定义,#define ia32_sys_call_table sys_call_table,系统调用就是放在这个表里面。至于这个表是如何形成的,我们后面讲。
|
||||
|
||||
当系统调用结束之后,在entry_INT80_32之后,紧接着调用的是INTERRUPT_RETURN,我们能够找到它的定义,也就是iret。
|
||||
|
||||
```
|
||||
#define INTERRUPT_RETURN iret
|
||||
|
||||
```
|
||||
|
||||
iret指令将原来用户态保存的现场恢复回来,包含代码段、指令指针寄存器等。这时候用户态进程恢复执行。
|
||||
|
||||
这里我总结一下32位的系统调用是如何执行的。
|
||||
|
||||
<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->syscall_init。这里面有这样的代码:
|
||||
|
||||
```
|
||||
wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
|
||||
|
||||
```
|
||||
|
||||
rdmsr和wrmsr是用来读写特殊模块寄存器的。MSR_LSTAR就是这样一个特殊的寄存器,当syscall指令调用的时候,会从这个寄存器里面拿出函数地址来调用,也就是调用entry_SYSCALL_64。
|
||||
|
||||
在arch/x86/entry/entry_64.S中定义了entry_SYSCALL_64。
|
||||
|
||||
```
|
||||
ENTRY(entry_SYSCALL_64)
|
||||
/* Construct struct pt_regs on stack */
|
||||
pushq $__USER_DS /* pt_regs->ss */
|
||||
pushq PER_CPU_VAR(rsp_scratch) /* pt_regs->sp */
|
||||
pushq %r11 /* pt_regs->flags */
|
||||
pushq $__USER_CS /* pt_regs->cs */
|
||||
pushq %rcx /* pt_regs->ip */
|
||||
pushq %rax /* pt_regs->orig_ax */
|
||||
pushq %rdi /* pt_regs->di */
|
||||
pushq %rsi /* pt_regs->si */
|
||||
pushq %rdx /* pt_regs->dx */
|
||||
pushq %rcx /* pt_regs->cx */
|
||||
pushq $-ENOSYS /* pt_regs->ax */
|
||||
pushq %r8 /* pt_regs->r8 */
|
||||
pushq %r9 /* pt_regs->r9 */
|
||||
pushq %r10 /* pt_regs->r10 */
|
||||
pushq %r11 /* pt_regs->r11 */
|
||||
sub $(6*8), %rsp /* pt_regs->bp, bx, r12-15 not saved */
|
||||
movq PER_CPU_VAR(current_task), %r11
|
||||
testl $_TIF_WORK_SYSCALL_ENTRY|_TIF_ALLWORK_MASK, TASK_TI_flags(%r11)
|
||||
jnz entry_SYSCALL64_slow_path
|
||||
......
|
||||
entry_SYSCALL64_slow_path:
|
||||
/* IRQs are off. */
|
||||
SAVE_EXTRA_REGS
|
||||
movq %rsp, %rdi
|
||||
call do_syscall_64 /* returns with IRQs disabled */
|
||||
return_from_SYSCALL_64:
|
||||
RESTORE_EXTRA_REGS
|
||||
TRACE_IRQS_IRETQ
|
||||
movq RCX(%rsp), %rcx
|
||||
movq RIP(%rsp), %r11
|
||||
movq R11(%rsp), %r11
|
||||
......
|
||||
syscall_return_via_sysret:
|
||||
/* rcx and r11 are already restored (see code above) */
|
||||
RESTORE_C_REGS_EXCEPT_RCX_R11
|
||||
movq RSP(%rsp), %rsp
|
||||
USERGS_SYSRET64
|
||||
|
||||
```
|
||||
|
||||
这里先保存了很多寄存器到pt_regs结构里面,例如用户态的代码段、数据段、保存参数的寄存器,然后调用entry_SYSCALL64_slow_pat->do_syscall_64。
|
||||
|
||||
```
|
||||
__visible void do_syscall_64(struct pt_regs *regs)
|
||||
{
|
||||
struct thread_info *ti = current_thread_info();
|
||||
unsigned long nr = regs->orig_ax;
|
||||
......
|
||||
if (likely((nr & __SYSCALL_MASK) < NR_syscalls)) {
|
||||
regs->ax = sys_call_table[nr & __SYSCALL_MASK](
|
||||
regs->di, regs->si, regs->dx,
|
||||
regs->r10, regs->r8, regs->r9);
|
||||
}
|
||||
syscall_return_slowpath(regs);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在do_syscall_64里面,从rax里面拿出系统调用号,然后根据系统调用号,在系统调用表sys_call_table中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。如果仔细比对,你就能发现,这些参数所对应的寄存器,和Linux的注释又是一样的。
|
||||
|
||||
所以,无论是32位,还是64位,都会到系统调用表sys_call_table这里来。
|
||||
|
||||
在研究系统调用表之前,我们看64位的系统调用返回的时候,执行的是USERGS_SYSRET64。定义如下:
|
||||
|
||||
```
|
||||
#define USERGS_SYSRET64 \
|
||||
swapgs; \
|
||||
sysretq;
|
||||
|
||||
```
|
||||
|
||||
这里,返回用户态的指令变成了sysretq。
|
||||
|
||||
我们这里总结一下64位的系统调用是如何执行的。
|
||||
|
||||
<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 & below is removed.
|
||||
*/
|
||||
[0 ... __NR_syscall_compat_max] = &sys_ni_syscall,
|
||||
#include <asm/syscalls_32.h>
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
同理,在文件arch/x86/entry/syscall_64.c,定义了这样一个表,里面include了这个头文件,这样所有的sys_系统调用就都在这个表里面了。
|
||||
|
||||
```
|
||||
/* System call table for x86-64. */
|
||||
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
|
||||
/*
|
||||
* Smells like a compiler bug -- it doesn't work
|
||||
* when the & below is removed.
|
||||
*/
|
||||
[0 ... __NR_syscall_max] = &sys_ni_syscall,
|
||||
#include <asm/syscalls_64.h>
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
系统调用的过程还是挺复杂的吧?如果加上上一节的内核态和用户态的模式切换,就更复杂了。这里我们重点分析64位的系统调用,我将整个完整的过程画了一张图,帮你总结、梳理一下。
|
||||
|
||||
<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="">
|
||||
332
极客时间专栏/趣谈Linux操作系统/核心原理篇:第五部分 文件系统/27 | 文件系统:项目成果要归档,我们就需要档案库.md
Normal file
332
极客时间专栏/趣谈Linux操作系统/核心原理篇:第五部分 文件系统/27 | 文件系统:项目成果要归档,我们就需要档案库.md
Normal 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 -> /var/lib/cloud/instances
|
||||
|
||||
```
|
||||
|
||||
## 文件系统相关系统调用
|
||||
|
||||
看完了命令行,我们来看一下,如何使用系统调用操作文件?我们先来看一个完整的例子。
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
|
||||
|
||||
int fd = -1;
|
||||
int ret = 1;
|
||||
int buffer = 1024;
|
||||
int num = 0;
|
||||
|
||||
|
||||
if((fd=open("./test", O_RDWR|O_CREAT|O_TRUNC))==-1)
|
||||
{
|
||||
printf("Open Error\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
|
||||
ret = write(fd, &buffer, sizeof(int));
|
||||
if( ret < 0)
|
||||
{
|
||||
printf("write Error\n");
|
||||
exit(1);
|
||||
}
|
||||
printf("write %d byte(s)\n",ret);
|
||||
|
||||
|
||||
lseek(fd, 0L, SEEK_SET);
|
||||
ret= read(fd, &num, sizeof(int));
|
||||
if(ret==-1)
|
||||
{
|
||||
printf("read Error\n");
|
||||
exit(1);
|
||||
}
|
||||
printf("read %d byte(s),the number is %d\n", ret, num);
|
||||
|
||||
|
||||
close(fd);
|
||||
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当使用系统调用open打开一个文件时,操作系统会创建一些数据结构来表示这个被打开的文件。下一节,我们就会看到这些。为了能够找到这些数据结构,在进程中,我们会为这个打开的文件分配一个文件描述符fd(File Descriptor)。
|
||||
|
||||
文件描述符,就是用来区分一个进程打开的多个文件的。它的作用域就是当前进程,出了当前进程这个文件描述符就没有意义了。open返回的fd必须记录好,我们对这个文件的所有操作都要靠这个fd,包括最后关闭文件。
|
||||
|
||||
在Open函数中,有一些参数:
|
||||
|
||||
<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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/types.h>
|
||||
#include <sys/stat.h>
|
||||
#include <dirent.h>
|
||||
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
struct stat sb;
|
||||
DIR *dirp;
|
||||
struct dirent *direntp;
|
||||
char filename[128];
|
||||
if ((dirp = opendir("/root")) == NULL) {
|
||||
printf("Open Directory Error%s\n");
|
||||
exit(1);
|
||||
}
|
||||
while ((direntp = readdir(dirp)) != NULL){
|
||||
sprintf(filename, "/root/%s", direntp->d_name);
|
||||
if (lstat(filename, &sb) == -1)
|
||||
{
|
||||
printf("lstat Error%s\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
|
||||
printf("name : %s, mode : %d, size : %d, user id : %d\n", direntp->d_name, sb.st_mode, sb.st_size, sb.st_uid);
|
||||
|
||||
|
||||
}
|
||||
closedir(dirp);
|
||||
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
opendir函数打开一个目录名所对应的DIR目录流。并返回指向DIR目录流的指针。流定位在DIR 目录流的第一个条目。
|
||||
|
||||
readdir函数从DIR目录流中读取一个项目,返回的是一个指针,指向dirent结构体,且流的自动指向下一个目录条目。如果已经到流的最后一个条目,则返回NULL。
|
||||
|
||||
closedir()关闭参数dir所指的目录流。
|
||||
|
||||
到这里,你应该既会使用系统调用操作文件,也会使用系统调用操作目录了。下一节,我们开始来看内核如何实现的。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们对于文件系统的主要功能有了一个总体的印象,我们通过下面这张图梳理一下。
|
||||
|
||||
<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="">
|
||||
@@ -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>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-> do_filp_open->path_openat->do_last->lookup_open。这个调用链的逻辑是,要打开一个文件,先要根据路径找到文件夹。如果发现文件夹下面没有这个文件,同时又设置了O_CREAT,就说明我们要在这个文件夹下面创建一个文件,那我们就需要一个新的inode。
|
||||
|
||||
```
|
||||
static int lookup_open(struct nameidata *nd, struct path *path,
|
||||
struct file *file,
|
||||
const struct open_flags *op,
|
||||
bool got_write, int *opened)
|
||||
{
|
||||
......
|
||||
if (!dentry->d_inode && (open_flag & O_CREAT)) {
|
||||
......
|
||||
error = dir_inode->i_op->create(dir_inode, dentry, mode,
|
||||
open_flag & O_EXCL);
|
||||
......
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
想要创建新的inode,我们就要调用dir_inode,也就是文件夹的inode的create函数。它的具体定义是这样的:
|
||||
|
||||
```
|
||||
const struct inode_operations ext4_dir_inode_operations = {
|
||||
.create = ext4_create,
|
||||
.lookup = ext4_lookup,
|
||||
.link = ext4_link,
|
||||
.unlink = ext4_unlink,
|
||||
.symlink = ext4_symlink,
|
||||
.mkdir = ext4_mkdir,
|
||||
.rmdir = ext4_rmdir,
|
||||
.mknod = ext4_mknod,
|
||||
.tmpfile = ext4_tmpfile,
|
||||
.rename = ext4_rename2,
|
||||
.setattr = ext4_setattr,
|
||||
.getattr = ext4_getattr,
|
||||
.listxattr = ext4_listxattr,
|
||||
.get_acl = ext4_get_acl,
|
||||
.set_acl = ext4_set_acl,
|
||||
.fiemap = ext4_fiemap,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这里面定义了,如果文件夹inode要做一些操作,每个操作对应应该调用哪些函数。这里create操作调用的是ext4_create。
|
||||
|
||||
接下来的调用链是这样的:ext4_create->ext4_new_inode_start_handle->__ext4_new_inode。在__ext4_new_inode函数中,我们会创建新的inode。
|
||||
|
||||
```
|
||||
struct inode *__ext4_new_inode(handle_t *handle, struct inode *dir,
|
||||
umode_t mode, const struct qstr *qstr,
|
||||
__u32 goal, uid_t *owner, __u32 i_flags,
|
||||
int handle_type, unsigned int line_no,
|
||||
int nblocks)
|
||||
{
|
||||
......
|
||||
inode_bitmap_bh = ext4_read_inode_bitmap(sb, group);
|
||||
......
|
||||
ino = ext4_find_next_zero_bit((unsigned long *)
|
||||
inode_bitmap_bh->b_data,
|
||||
EXT4_INODES_PER_GROUP(sb), ino);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面一个重要的逻辑就是,从文件系统里面读取inode位图,然后找到下一个为0的inode,就是空闲的inode。
|
||||
|
||||
对于block位图,在写入文件的时候,也会有这个过程,我就不展开说了。感兴趣的话,你可以自己去找代码看。
|
||||
|
||||
## 文件系统的格式
|
||||
|
||||
看起来,我们现在应该能够很顺利地通过inode位图和block位图创建文件了。如果仔细计算一下,其实还是有问题的。
|
||||
|
||||
数据块的位图是放在一个块里面的,共4k。每位表示一个数据块,共可以表示$4 * 1024 * 8 = 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。这里面有整个文件系统一共有多少inode,s_inodes_count;一共有多少块,s_blocks_count_lo,每个块组有多少inode,s_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="">
|
||||
438
极客时间专栏/趣谈Linux操作系统/核心原理篇:第五部分 文件系统/29 | 虚拟文件系统:文件多了就需要档案管理系统.md
Normal file
438
极客时间专栏/趣谈Linux操作系统/核心原理篇:第五部分 文件系统/29 | 虚拟文件系统:文件多了就需要档案管理系统.md
Normal 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(&ext4_fs_type);
|
||||
|
||||
|
||||
static struct file_system_type ext4_fs_type = {
|
||||
.owner = THIS_MODULE,
|
||||
.name = "ext4",
|
||||
.mount = ext4_mount,
|
||||
.kill_sb = kill_block_super,
|
||||
.fs_flags = FS_REQUIRES_DEV,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
如果一种文件系统的类型曾经在内核注册过,这就说明允许你挂载并且使用这个文件系统。
|
||||
|
||||
刚才我说了几个需要重点关注的系统调用,那我们就从第一个mount系统调用开始解析。mount系统调用的定义如下:
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE5(mount, char __user *, dev_name, char __user *, dir_name, char __user *, type, unsigned long, flags, void __user *, data)
|
||||
{
|
||||
......
|
||||
ret = do_mount(kernel_dev, dir_name, kernel_type, flags, options);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
接下来的调用链为:do_mount->do_new_mount->vfs_kern_mount。
|
||||
|
||||
```
|
||||
struct vfsmount *
|
||||
vfs_kern_mount(struct file_system_type *type, int flags, const char *name, void *data)
|
||||
{
|
||||
......
|
||||
mnt = alloc_vfsmnt(name);
|
||||
......
|
||||
root = mount_fs(type, flags, name, data);
|
||||
......
|
||||
mnt->mnt.mnt_root = root;
|
||||
mnt->mnt.mnt_sb = root->d_sb;
|
||||
mnt->mnt_mountpoint = mnt->mnt.mnt_root;
|
||||
mnt->mnt_parent = mnt;
|
||||
list_add_tail(&mnt->mnt_instance, &root->d_sb->s_mounts);
|
||||
return &mnt->mnt;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
vfs_kern_mount先是创建struct mount结构,每个挂载的文件系统都对应于这样一个结构。
|
||||
|
||||
```
|
||||
struct mount {
|
||||
struct hlist_node mnt_hash;
|
||||
struct mount *mnt_parent;
|
||||
struct dentry *mnt_mountpoint;
|
||||
struct vfsmount mnt;
|
||||
union {
|
||||
struct rcu_head mnt_rcu;
|
||||
struct llist_node mnt_llist;
|
||||
};
|
||||
struct list_head mnt_mounts; /* list of children, anchored here */
|
||||
struct list_head mnt_child; /* and going through their mnt_child */
|
||||
struct list_head mnt_instance; /* mount instance on sb->s_mounts */
|
||||
const char *mnt_devname; /* Name of device e.g. /dev/dsk/hda1 */
|
||||
struct list_head mnt_list;
|
||||
......
|
||||
} __randomize_layout;
|
||||
|
||||
|
||||
struct vfsmount {
|
||||
struct dentry *mnt_root; /* root of the mounted tree */
|
||||
struct super_block *mnt_sb; /* pointer to superblock */
|
||||
int mnt_flags;
|
||||
} __randomize_layout;
|
||||
|
||||
```
|
||||
|
||||
其中,mnt_parent是装载点所在的父文件系统,mnt_mountpoint是装载点在父文件系统中的dentry;struct dentry表示目录,并和目录的inode关联;mnt_root是当前文件系统根目录的dentry,mnt_sb是指向超级块的指针。
|
||||
|
||||
接下来,我们来看调用mount_fs挂载文件系统。
|
||||
|
||||
```
|
||||
struct dentry *
|
||||
mount_fs(struct file_system_type *type, int flags, const char *name, void *data)
|
||||
{
|
||||
struct dentry *root;
|
||||
struct super_block *sb;
|
||||
......
|
||||
root = type->mount(type, flags, name, data);
|
||||
......
|
||||
sb = root->d_sb;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里调用的是ext4_fs_type的mount函数,也就是咱们上面提到的ext4_mount,从文件系统里面读取超级块。在文件系统的实现中,每个在硬盘上的结构,在内存中也对应相同格式的结构。当所有的数据结构都读到内存里面,内核就可以通过操作这些数据结构,来操作文件系统了。
|
||||
|
||||
可以看出来,理解各个数据结构在这里的关系,非常重要。我这里举一个例子,来解析经过mount之后,刚刚那些数据结构之间的关系。
|
||||
|
||||
我们假设根文件系统下面有一个目录home,有另外一个文件系统A挂载在这个目录home下面。在文件系统A的根目录下面有另外一个文件夹hello。由于文件系统A已经挂载到了目录home下面,所以我们就有了目录/home/hello,然后有另外一个文件系统B挂载在/home/hello下面。在文件系统B的根目录下面有另外一个文件夹world,在world下面有个文件夹data。由于文件系统B已经挂载到了/home/hello下面,所以我们就有了目录/home/hello/world/data。
|
||||
|
||||
为了维护这些关系,操作系统创建了这一系列数据结构。具体你可以看下面的图。
|
||||
|
||||
<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 >= 0) {
|
||||
struct file *f = do_filp_open(dfd, tmp, &op);
|
||||
if (IS_ERR(f)) {
|
||||
put_unused_fd(fd);
|
||||
fd = PTR_ERR(f);
|
||||
} else {
|
||||
fsnotify_open(f);
|
||||
fd_install(fd, f);
|
||||
}
|
||||
}
|
||||
putname(tmp);
|
||||
return fd;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
要打开一个文件,首先要通过get_unused_fd_flags得到一个没有用的文件描述符。如何获取这个文件描述符呢?
|
||||
|
||||
在每一个进程的task_struct中,有一个指针files,类型是files_struct。
|
||||
|
||||
```
|
||||
struct files_struct *files;
|
||||
|
||||
```
|
||||
|
||||
files_struct里面最重要的是一个文件描述符列表,每打开一个文件,就会在这个列表中分配一项,下标就是文件描述符。
|
||||
|
||||
```
|
||||
struct files_struct {
|
||||
......
|
||||
struct file __rcu * fd_array[NR_OPEN_DEFAULT];
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
对于任何一个进程,默认情况下,文件描述符0表示stdin标准输入,文件描述符1表示stdout标准输出,文件描述符2表示stderr标准错误输出。另外,再打开的文件,都会从这个列表中找一个空闲位置分配给它。
|
||||
|
||||
文件描述符列表的每一项都是一个指向struct file的指针,也就是说,每打开一个文件,都会有一个struct file对应。
|
||||
|
||||
do_sys_open中调用do_filp_open,就是创建这个struct file结构,然后fd_install(fd, f)是将文件描述符和这个结构关联起来。
|
||||
|
||||
```
|
||||
struct file *do_filp_open(int dfd, struct filename *pathname,
|
||||
const struct open_flags *op)
|
||||
{
|
||||
......
|
||||
set_nameidata(&nd, dfd, pathname);
|
||||
filp = path_openat(&nd, op, flags | LOOKUP_RCU);
|
||||
......
|
||||
restore_nameidata();
|
||||
return filp;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
do_filp_open里面首先初始化了struct nameidata这个结构。我们知道,文件都是一串的路径名称,需要逐个解析。这个结构在解析和查找路径的时候提供辅助作用。
|
||||
|
||||
在struct nameidata里面有一个关键的成员变量struct path。
|
||||
|
||||
```
|
||||
struct path {
|
||||
struct vfsmount *mnt;
|
||||
struct dentry *dentry;
|
||||
} __randomize_layout;
|
||||
|
||||
```
|
||||
|
||||
其中,struct vfsmount和文件系统的挂载有关。另一个struct dentry,除了上面说的用于标识目录之外,还可以表示文件名,还会建立文件名及其inode之间的关联。
|
||||
|
||||
接下来就调用path_openat,主要做了以下几件事情:
|
||||
|
||||
- get_empty_filp生成一个struct file结构;
|
||||
- path_init初始化nameidata,准备开始节点路径查找;
|
||||
- link_path_walk对于路径名逐层进行节点路径查找,这里面有一个大的循环,用“/”分隔逐层处理;
|
||||
- do_last获取文件对应的inode对象,并且初始化file对象。
|
||||
|
||||
```
|
||||
static struct file *path_openat(struct nameidata *nd,
|
||||
const struct open_flags *op, unsigned flags)
|
||||
{
|
||||
......
|
||||
file = get_empty_filp();
|
||||
......
|
||||
s = path_init(nd, flags);
|
||||
......
|
||||
while (!(error = link_path_walk(s, nd)) &&
|
||||
(error = do_last(nd, file, op, &opened)) > 0) {
|
||||
......
|
||||
}
|
||||
terminate_walk(nd);
|
||||
......
|
||||
return file;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
例如,文件“/root/hello/world/data”,link_path_walk会解析前面的路径部分“/root/hello/world”,解析完毕的时候nameidata的dentry为路径名的最后一部分的父目录“/root/hello/world”,而nameidata的filename为路径名的最后一部分“data”。
|
||||
|
||||
最后一部分的解析和处理,我们交给do_last。
|
||||
|
||||
```
|
||||
static int do_last(struct nameidata *nd,
|
||||
struct file *file, const struct open_flags *op,
|
||||
int *opened)
|
||||
{
|
||||
......
|
||||
error = lookup_fast(nd, &path, &inode, &seq);
|
||||
......
|
||||
error = lookup_open(nd, &path, file, op, got_write, opened);
|
||||
......
|
||||
error = vfs_open(&nd->path, file, current_cred());
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里面,我们需要先查找文件路径最后一部分对应的dentry。如何查找呢?
|
||||
|
||||
Linux为了提高目录项对象的处理效率,设计与实现了目录项高速缓存dentry cache,简称dcache。它主要由两个数据结构组成:
|
||||
|
||||
- 哈希表dentry_hashtable:dcache中的所有dentry对象都通过d_hash指针链到相应的dentry哈希链表中;
|
||||
- 未使用的dentry对象链表s_dentry_lru:dentry对象通过其d_lru指针链入LRU链表中。LRU的意思是最近最少使用,我们已经好几次看到它了。只要有它,就说明长时间不使用,就应该释放了。
|
||||
|
||||
<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, &nd->last, &wq);
|
||||
......
|
||||
struct dentry *res = dir_inode->i_op->lookup(dir_inode, dentry,
|
||||
nd->flags);
|
||||
......
|
||||
path->dentry = dentry;
|
||||
path->mnt = nd->path.mnt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
const struct inode_operations ext4_dir_inode_operations = {
|
||||
.create = ext4_create,
|
||||
.lookup = ext4_lookup,
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
do_last()的最后一步是调用vfs_open真正打开文件。
|
||||
|
||||
```
|
||||
int vfs_open(const struct path *path, struct file *file,
|
||||
const struct cred *cred)
|
||||
{
|
||||
struct dentry *dentry = d_real(path->dentry, NULL, file->f_flags, 0);
|
||||
......
|
||||
file->f_path = *path;
|
||||
return do_dentry_open(file, d_backing_inode(dentry), NULL, cred);
|
||||
}
|
||||
|
||||
|
||||
static int do_dentry_open(struct file *f,
|
||||
struct inode *inode,
|
||||
int (*open)(struct inode *, struct file *),
|
||||
const struct cred *cred)
|
||||
{
|
||||
......
|
||||
f->f_mode = OPEN_FMODE(f->f_flags) | FMODE_LSEEK |
|
||||
FMODE_PREAD | FMODE_PWRITE;
|
||||
path_get(&f->f_path);
|
||||
f->f_inode = inode;
|
||||
f->f_mapping = inode->i_mapping;
|
||||
......
|
||||
f->f_op = fops_get(inode->i_fop);
|
||||
......
|
||||
open = f->f_op->open;
|
||||
......
|
||||
error = open(inode, f);
|
||||
......
|
||||
f->f_flags &= ~(O_CREAT | O_EXCL | O_NOCTTY | O_TRUNC);
|
||||
file_ra_state_init(&f->f_ra, f->f_mapping->host->i_mapping);
|
||||
return 0;
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
const struct file_operations ext4_file_operations = {
|
||||
......
|
||||
.open = ext4_file_open,
|
||||
......
|
||||
};
|
||||
|
||||
|
||||
```
|
||||
|
||||
vfs_open里面最终要做的一件事情是,调用f_op->open,也就是调用ext4_file_open。另外一件重要的事情是将打开文件的所有信息,填写到struct file这个结构里面。
|
||||
|
||||
```
|
||||
struct file {
|
||||
union {
|
||||
struct llist_node fu_llist;
|
||||
struct rcu_head fu_rcuhead;
|
||||
} f_u;
|
||||
struct path f_path;
|
||||
struct inode *f_inode; /* cached value */
|
||||
const struct file_operations *f_op;
|
||||
spinlock_t f_lock;
|
||||
enum rw_hint f_write_hint;
|
||||
atomic_long_t f_count;
|
||||
unsigned int f_flags;
|
||||
fmode_t f_mode;
|
||||
struct mutex f_pos_lock;
|
||||
loff_t f_pos;
|
||||
struct fown_struct f_owner;
|
||||
const struct cred *f_cred;
|
||||
......
|
||||
struct address_space *f_mapping;
|
||||
errseq_t f_wb_err;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
对于虚拟文件系统的解析就到这里了,我们可以看出,有关文件的数据结构层次多,而且很复杂,就得到了下面这张图,这张图在这个专栏最开始的时候,已经展示过一遍,到这里,你应该能明白它们之间的关系了。
|
||||
|
||||
<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="">
|
||||
453
极客时间专栏/趣谈Linux操作系统/核心原理篇:第五部分 文件系统/30 | 文件缓存:常用文档应该放在触手可得的地方.md
Normal file
453
极客时间专栏/趣谈Linux操作系统/核心原理篇:第五部分 文件系统/30 | 文件缓存:常用文档应该放在触手可得的地方.md
Normal 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, &pos);
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
|
||||
size_t, count)
|
||||
{
|
||||
struct fd f = fdget_pos(fd);
|
||||
......
|
||||
loff_t pos = file_pos_read(f.file);
|
||||
ret = vfs_write(f.file, buf, count, &pos);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于read来讲,里面调用vfs_read->__vfs_read。对于write来讲,里面调用vfs_write->__vfs_write。
|
||||
|
||||
下面是__vfs_read和__vfs_write的代码。
|
||||
|
||||
```
|
||||
ssize_t __vfs_read(struct file *file, char __user *buf, size_t count,
|
||||
loff_t *pos)
|
||||
{
|
||||
if (file->f_op->read)
|
||||
return file->f_op->read(file, buf, count, pos);
|
||||
else if (file->f_op->read_iter)
|
||||
return new_sync_read(file, buf, count, pos);
|
||||
else
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
|
||||
ssize_t __vfs_write(struct file *file, const char __user *p, size_t count,
|
||||
loff_t *pos)
|
||||
{
|
||||
if (file->f_op->write)
|
||||
return file->f_op->write(file, p, count, pos);
|
||||
else if (file->f_op->write_iter)
|
||||
return new_sync_write(file, p, count, pos);
|
||||
else
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上一节,我们讲了,每一个打开的文件,都有一个struct file结构。这里面有一个struct file_operations f_op,用于定义对这个文件做的操作。__vfs_read会调用相应文件系统的file_operations里面的read操作,__vfs_write会调用相应文件系统file_operations里的write操作。
|
||||
|
||||
## ext4文件系统层
|
||||
|
||||
对于ext4文件系统来讲,内核定义了一个ext4_file_operations。
|
||||
|
||||
```
|
||||
const struct file_operations ext4_file_operations = {
|
||||
......
|
||||
.read_iter = ext4_file_read_iter,
|
||||
.write_iter = ext4_file_write_iter,
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
由于ext4没有定义read和write函数,于是会调用ext4_file_read_iter和ext4_file_write_iter。
|
||||
|
||||
ext4_file_read_iter会调用generic_file_read_iter,ext4_file_write_iter会调用__generic_file_write_iter。
|
||||
|
||||
```
|
||||
ssize_t
|
||||
generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
|
||||
{
|
||||
......
|
||||
if (iocb->ki_flags & IOCB_DIRECT) {
|
||||
......
|
||||
struct address_space *mapping = file->f_mapping;
|
||||
......
|
||||
retval = mapping->a_ops->direct_IO(iocb, iter);
|
||||
}
|
||||
......
|
||||
retval = generic_file_buffered_read(iocb, iter, retval);
|
||||
}
|
||||
|
||||
|
||||
ssize_t __generic_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
|
||||
{
|
||||
......
|
||||
if (iocb->ki_flags & IOCB_DIRECT) {
|
||||
......
|
||||
written = generic_file_direct_write(iocb, from);
|
||||
......
|
||||
} else {
|
||||
......
|
||||
written = generic_perform_write(file, from, iocb->ki_pos);
|
||||
......
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
generic_file_read_iter和__generic_file_write_iter有相似的逻辑,就是要区分是否用缓存。
|
||||
|
||||
缓存其实就是内存中的一块空间。因为内存比硬盘快得多,Linux为了改进性能,有时候会选择不直接操作硬盘,而是读写都在内存中,然后批量读取或者写入硬盘。一旦能够命中内存,读写效率就会大幅度提高。
|
||||
|
||||
因此,根据是否使用内存做缓存,我们可以把文件的I/O操作分为两种类型。
|
||||
|
||||
第一种类型是**缓存I/O**。大多数文件系统的默认I/O操作都是缓存I/O。对于读操作来讲,操作系统会先检查,内核的缓冲区有没有需要的数据。如果已经缓存了,那就直接从缓存中返回;否则从磁盘中读取,然后缓存在操作系统的缓存中。对于写操作来讲,操作系统会先将数据从用户空间复制到内核空间的缓存中。这时对用户程序来说,写操作就已经完成。至于什么时候再写到磁盘中由操作系统决定,除非显式地调用了sync同步命令。
|
||||
|
||||
第二种类型是**直接IO**,就是应用程序直接访问磁盘数据,而不经过内核缓冲区,从而减少了在内核缓存和用户程序之间数据复制。
|
||||
|
||||
如果在读的逻辑generic_file_read_iter里面,发现设置了IOCB_DIRECT,则会调用address_space的direct_IO的函数,将数据直接读取硬盘。我们在mmap映射文件到内存的时候讲过address_space,它主要用于在内存映射的时候将文件和内存页产生关联。
|
||||
|
||||
同样,对于缓存来讲,也需要文件和内存页进行关联,这就要用到address_space。address_space的相关操作定义在struct address_space_operations结构中。对于ext4文件系统来讲, address_space的操作定义在ext4_aops,direct_IO对应的函数是ext4_direct_IO。
|
||||
|
||||
```
|
||||
static const struct address_space_operations ext4_aops = {
|
||||
......
|
||||
.direct_IO = ext4_direct_IO,
|
||||
......
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
如果在写的逻辑__generic_file_write_iter里面,发现设置了IOCB_DIRECT,则调用generic_file_direct_write,里面同样会调用address_space的direct_IO的函数,将数据直接写入硬盘。
|
||||
|
||||
ext4_direct_IO最终会调用到__blockdev_direct_IO->do_blockdev_direct_IO,这就跨过了缓存层,到了通用块层,最终到了文件系统的设备驱动层。由于文件系统是块设备,所以这个调用的是blockdev相关的函数,有关块设备驱动程序的原理我们下一章详细讲,这一节我们就讲到文件系统到块设备的分界线部分。
|
||||
|
||||
```
|
||||
/*
|
||||
* This is a library function for use by filesystem drivers.
|
||||
*/
|
||||
static inline ssize_t
|
||||
do_blockdev_direct_IO(struct kiocb *iocb, struct inode *inode,
|
||||
struct block_device *bdev, struct iov_iter *iter,
|
||||
get_block_t get_block, dio_iodone_t end_io,
|
||||
dio_submit_t submit_io, int flags)
|
||||
{......}
|
||||
|
||||
```
|
||||
|
||||
接下来,我们重点看带缓存的部分如果进行读写。
|
||||
|
||||
## 带缓存的写入操作
|
||||
|
||||
我们先来看带缓存写入的函数generic_perform_write。
|
||||
|
||||
```
|
||||
ssize_t generic_perform_write(struct file *file,
|
||||
struct iov_iter *i, loff_t pos)
|
||||
{
|
||||
struct address_space *mapping = file->f_mapping;
|
||||
const struct address_space_operations *a_ops = mapping->a_ops;
|
||||
do {
|
||||
struct page *page;
|
||||
unsigned long offset; /* Offset into pagecache page */
|
||||
unsigned long bytes; /* Bytes to write to page */
|
||||
status = a_ops->write_begin(file, mapping, pos, bytes, flags,
|
||||
&page, &fsdata);
|
||||
copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
|
||||
flush_dcache_page(page);
|
||||
status = a_ops->write_end(file, mapping, pos, bytes, copied,
|
||||
page, fsdata);
|
||||
pos += copied;
|
||||
written += copied;
|
||||
|
||||
|
||||
balance_dirty_pages_ratelimited(mapping);
|
||||
} while (iov_iter_count(i));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个函数里,是一个while循环。我们需要找出这次写入影响的所有的页,然后依次写入。对于每一个循环,主要做四件事情:
|
||||
|
||||
- 对于每一页,先调用address_space的write_begin做一些准备;
|
||||
- 调用iov_iter_copy_from_user_atomic,将写入的内容从用户态拷贝到内核态的页中;
|
||||
- 调用address_space的write_end完成写操作;
|
||||
- 调用balance_dirty_pages_ratelimited,看脏页是否太多,需要写回硬盘。所谓脏页,就是写入到缓存,但是还没有写入到硬盘的页面。
|
||||
|
||||
我们依次来看这四个步骤。
|
||||
|
||||
```
|
||||
static const struct address_space_operations ext4_aops = {
|
||||
......
|
||||
.write_begin = ext4_write_begin,
|
||||
.write_end = ext4_write_end,
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第一步,对于ext4来讲,调用的是ext4_write_begin。
|
||||
|
||||
ext4是一种日志文件系统,是为了防止突然断电的时候的数据丢失,引入了**日志****(**<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->__block_commit_write->mark_buffer_dirty,将修改过的缓存标记为脏页。可以看出,其实所谓的完成写入,并没有真正写入硬盘,仅仅是写入缓存后,标记为脏页。
|
||||
|
||||
但是这里有一个问题,数据很危险,一旦宕机就没有了,所以需要一种机制,将写入的页面真正写到硬盘中,我们称为回写(Write Back)。
|
||||
|
||||
第四步,调用 balance_dirty_pages_ratelimited,是回写脏页的一个很好的时机。
|
||||
|
||||
```
|
||||
/**
|
||||
* balance_dirty_pages_ratelimited - balance dirty memory state
|
||||
* @mapping: address_space which was dirtied
|
||||
*
|
||||
* Processes which are dirtying memory should call in here once for each page
|
||||
* which was newly dirtied. The function will periodically check the system's
|
||||
* dirty state and will initiate writeback if needed.
|
||||
*/
|
||||
void balance_dirty_pages_ratelimited(struct address_space *mapping)
|
||||
{
|
||||
struct inode *inode = mapping->host;
|
||||
struct backing_dev_info *bdi = inode_to_bdi(inode);
|
||||
struct bdi_writeback *wb = NULL;
|
||||
int ratelimit;
|
||||
......
|
||||
if (unlikely(current->nr_dirtied >= ratelimit))
|
||||
balance_dirty_pages(mapping, wb, current->nr_dirtied);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在balance_dirty_pages_ratelimited里面,发现脏页的数目超过了规定的数目,就调用balance_dirty_pages->wb_start_background_writeback,启动一个背后线程开始回写。
|
||||
|
||||
```
|
||||
void wb_start_background_writeback(struct bdi_writeback *wb)
|
||||
{
|
||||
/*
|
||||
* We just wake up the flusher thread. It will perform background
|
||||
* writeback as soon as there is no other work to do.
|
||||
*/
|
||||
wb_wakeup(wb);
|
||||
}
|
||||
|
||||
|
||||
static void wb_wakeup(struct bdi_writeback *wb)
|
||||
{
|
||||
spin_lock_bh(&wb->work_lock);
|
||||
if (test_bit(WB_registered, &wb->state))
|
||||
mod_delayed_work(bdi_wq, &wb->dwork, 0);
|
||||
spin_unlock_bh(&wb->work_lock);
|
||||
}
|
||||
|
||||
|
||||
(_tflags) | TIMER_IRQSAFE); \
|
||||
} while (0)
|
||||
|
||||
|
||||
/* bdi_wq serves all asynchronous writeback tasks */
|
||||
struct workqueue_struct *bdi_wq;
|
||||
|
||||
|
||||
/**
|
||||
* mod_delayed_work - modify delay of or queue a delayed work
|
||||
* @wq: workqueue to use
|
||||
* @dwork: work to queue
|
||||
* @delay: number of jiffies to wait before queueing
|
||||
*
|
||||
* mod_delayed_work_on() on local CPU.
|
||||
*/
|
||||
static inline bool mod_delayed_work(struct workqueue_struct *wq,
|
||||
struct delayed_work *dwork,
|
||||
unsigned long delay)
|
||||
{....
|
||||
|
||||
```
|
||||
|
||||
通过上面的代码,我们可以看出,bdi_wq是一个全局变量,所有回写的任务都挂在这个队列上。mod_delayed_work函数负责将一个回写任务bdi_writeback挂在这个队列上。bdi_writeback有个成员变量struct delayed_work dwork,bdi_writeback就是以delayed_work的身份挂到队列上的,并且把delay设置为0,意思就是一刻不等,马上执行。
|
||||
|
||||
那具体这个任务由谁来执行呢?这里的bdi的意思是backing device info,用于描述后端存储相关的信息。每个块设备都会有这样一个结构,并且在初始化块设备的时候,调用bdi_init初始化这个结构,在初始化bdi的时候,也会调用wb_init初始化bdi_writeback。
|
||||
|
||||
```
|
||||
static int wb_init(struct bdi_writeback *wb, struct backing_dev_info *bdi,
|
||||
int blkcg_id, gfp_t gfp)
|
||||
{
|
||||
wb->bdi = bdi;
|
||||
wb->last_old_flush = jiffies;
|
||||
INIT_LIST_HEAD(&wb->b_dirty);
|
||||
INIT_LIST_HEAD(&wb->b_io);
|
||||
INIT_LIST_HEAD(&wb->b_more_io);
|
||||
INIT_LIST_HEAD(&wb->b_dirty_time);
|
||||
wb->bw_time_stamp = jiffies;
|
||||
wb->balanced_dirty_ratelimit = INIT_BW;
|
||||
wb->dirty_ratelimit = INIT_BW;
|
||||
wb->write_bandwidth = INIT_BW;
|
||||
wb->avg_write_bandwidth = INIT_BW;
|
||||
spin_lock_init(&wb->work_lock);
|
||||
INIT_LIST_HEAD(&wb->work_list);
|
||||
INIT_DELAYED_WORK(&wb->dwork, wb_workfn);
|
||||
wb->dirty_sleep = jiffies;
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
#define __INIT_DELAYED_WORK(_work, _func, _tflags) \
|
||||
do { \
|
||||
INIT_WORK(&(_work)->work, (_func)); \
|
||||
__setup_timer(&(_work)->timer, delayed_work_timer_fn, \
|
||||
(unsigned long)(_work), \
|
||||
|
||||
```
|
||||
|
||||
这里面最重要的是INIT_DELAYED_WORK。其实就是初始化一个timer,也即定时器,到时候我们就执行wb_workfn这个函数。
|
||||
|
||||
接下来的调用链为:wb_workfn->wb_do_writeback->wb_writeback->writeback_sb_inodes->__writeback_single_inode->do_writepages,写入页面到硬盘。
|
||||
|
||||
在调用write的最后,当发现缓存的数据太多的时候,会触发回写,这仅仅是回写的一种场景。另外还有几种场景也会触发回写:
|
||||
|
||||
- 用户主动调用sync,将缓存刷到硬盘上去,最终会调用wakeup_flusher_threads,同步脏页;
|
||||
- 当内存十分紧张,以至于无法分配页面的时候,会调用free_more_memory,最终会调用wakeup_flusher_threads,释放脏页;
|
||||
- 脏页已经更新了较长时间,时间上超过了timer,需要及时回写,保持内存和磁盘上数据一致性。
|
||||
|
||||
## 带缓存的读操作
|
||||
|
||||
带缓存的写分析完了,接下来,我们看带缓存的读,对应的是函数generic_file_buffered_read。
|
||||
|
||||
```
|
||||
static ssize_t generic_file_buffered_read(struct kiocb *iocb,
|
||||
struct iov_iter *iter, ssize_t written)
|
||||
{
|
||||
struct file *filp = iocb->ki_filp;
|
||||
struct address_space *mapping = filp->f_mapping;
|
||||
struct inode *inode = mapping->host;
|
||||
for (;;) {
|
||||
struct page *page;
|
||||
pgoff_t end_index;
|
||||
loff_t isize;
|
||||
page = find_get_page(mapping, index);
|
||||
if (!page) {
|
||||
if (iocb->ki_flags & IOCB_NOWAIT)
|
||||
goto would_block;
|
||||
page_cache_sync_readahead(mapping,
|
||||
ra, filp,
|
||||
index, last_index - index);
|
||||
page = find_get_page(mapping, index);
|
||||
if (unlikely(page == NULL))
|
||||
goto no_cached_page;
|
||||
}
|
||||
if (PageReadahead(page)) {
|
||||
page_cache_async_readahead(mapping,
|
||||
ra, filp, page,
|
||||
index, last_index - index);
|
||||
}
|
||||
/*
|
||||
* Ok, we have the page, and it's up-to-date, so
|
||||
* now we can copy it to user space...
|
||||
*/
|
||||
ret = copy_page_to_iter(page, offset, nr, iter);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
读取比写入总体而言简单一些,主要涉及预读的问题。
|
||||
|
||||
在generic_file_buffered_read函数中,我们需要先找到page cache里面是否有缓存页。如果没有找到,不但读取这一页,还要进行预读,这需要在page_cache_sync_readahead函数中实现。预读完了以后,再试一把查找缓存页,应该能找到了。
|
||||
|
||||
如果第一次找缓存页就找到了,我们还是要判断,是不是应该继续预读;如果需要,就调用page_cache_async_readahead发起一个异步预读。
|
||||
|
||||
最后,copy_page_to_iter会将内容从内核缓存页拷贝到用户内存空间。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节对于读取和写入的分析就到这里了。我们发现这个过程还是很复杂的,我这里画了一张调用图,你可以看到调用过程。
|
||||
|
||||
在系统调用层我们需要仔细学习read和write。在VFS层调用的是vfs_read和vfs_write并且调用file_operation。在ext4层调用的是ext4_file_read_iter和ext4_file_write_iter。
|
||||
|
||||
接下来就是分叉。你需要知道缓存I/O和直接I/O。直接I/O读写的流程是一样的,调用ext4_direct_IO,再往下就调用块设备层了。缓存I/O读写的流程不一样。对于读,从块设备读取到缓存中,然后从缓存中拷贝到用户态。对于写,从用户态拷贝到缓存,设置缓存页为脏,然后启动一个线程写入块设备。
|
||||
|
||||
<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="">
|
||||
@@ -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表示IPv4,AF_INET6表示IPv6。
|
||||
- type:表示socket类型。SOCK_STREAM,顾名思义就是TCP面向流的,SOCK_DGRAM就是UDP面向数据报的,SOCK_RAW可以直接操作IP层,或者非TCP和UDP的协议。例如ICMP。
|
||||
- protocol表示的协议,包括IPPROTO_TCP、IPPTOTO_UDP。
|
||||
|
||||
通信结束后,我们还要像关闭文件一样,关闭socket。
|
||||
|
||||
## 针对TCP应该如何编程?
|
||||
|
||||
接下来我们来看,针对TCP,我们应该如何编程。
|
||||
|
||||
<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,表示IPv4;sin_port是端口号;sin_addr是IP地址。
|
||||
|
||||
服务端所在的服务器可能有多个网卡、多个地址,可以选择监听在一个地址,也可以监听0.0.0.0表示所有的地址都监听。服务端一般要监听在一个众所周知的端口上,例如,Nginx一般是80,Tomcat一般是8080。
|
||||
|
||||
客户端要访问服务端,肯定事先要知道服务端的端口。无论是电商,还是游戏,还是视频,如果你仔细观察,会发现都有一个这样的端口。可能你会发现,客户端不需要bind,因为浏览器嘛,随机分配一个端口就可以了,只有你主动去连接别人,别人不会主动连接你,没有人关心客户端监听到了哪里。
|
||||
|
||||
如果你看上面代码中的数据结构,里面的变量名称都有“be”两个字母,代表的意思是“big-endian”。如果在网络上传输超过1 Byte的类型,就要区分**大端**(Big Endian)和**小端**(Little Endian)。
|
||||
|
||||
假设,我们要在32位4 Bytes的一个空间存放整数1,很显然只要1 Byte放1,其他3 Bytes放0就可以了。那问题是,最后一个Byte放1呢,还是第一个Byte放1呢?或者说,1作为最低位,应该放在32位的最后一个位置呢,还是放在第一个位置呢?
|
||||
|
||||
最低位放在最后一个位置,我们叫作小端,最低位放在第一个位置,叫作大端。TCP/IP栈是按照大端来设计的,而x86机器多按照小端来设计,因而发出去时需要做一个转换。
|
||||
|
||||
接下来,就要建立TCP的连接了,也就是著名的三次握手,其实就是将客户端和服务端的状态通过三次网络交互,达到初始状态是协同的状态。下图就是三次握手的序列图以及对应的状态转换。
|
||||
|
||||
<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="">
|
||||
105
极客时间专栏/趣谈Linux操作系统/核心原理篇:第八部分 网络系统/43 预习 | Socket通信之网络协议基本原理.md
Normal file
105
极客时间专栏/趣谈Linux操作系统/核心原理篇:第八部分 网络系统/43 预习 | Socket通信之网络协议基本原理.md
Normal 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层帮你负重前行。
|
||||
|
||||
传输层再往上就是应用层,例如咱们在浏览器里面输入的HTTP,Java服务端写的Servlet,都是这一层的。
|
||||
|
||||
二层到四层都是在Linux内核里面处理的,应用层例如浏览器、Nginx、Tomcat都是用户态的。内核里面对于网络包的处理是不区分应用的。
|
||||
|
||||
从四层再往上,就需要区分网络包发给哪个应用。在传输层的TCP和UDP协议里面,都有端口的概念,不同的应用监听不同的端口。例如,服务端Nginx监听80、Tomcat监听8080;再如客户端浏览器监听一个随机端口,FTP客户端监听另外一个随机端口。
|
||||
|
||||
应用层和内核互通的机制,就是通过Socket系统调用。所以经常有人会问,Socket属于哪一层,其实它哪一层都不属于,它属于操作系统的概念,而非网络协议分层的概念。只不过操作系统选择对于网络协议的实现模式是,二到四层的处理代码在内核里面,七层的处理代码让应用自己去做,两者需要跨内核态和用户态通信,就需要一个系统调用完成这个衔接,这就是Socket。
|
||||
|
||||
## 发送数据包
|
||||
|
||||
网络分完层之后,对于数据包的发送,就是层层封装的过程。
|
||||
|
||||
就像下面的图中展示的一样,在Linux服务器B上部署的服务端Nginx和Tomcat,都是通过Socket监听80和8080端口。这个时候,内核的数据结构就知道了。如果遇到发送到这两个端口的,就发送给这两个进程。
|
||||
|
||||
在Linux服务器A上的客户端,打开一个Firefox连接Ngnix。也是通过Socket,客户端会被分配一个随机端口12345。同理,打开一个Chrome连接Tomcat,同样通过Socket分配随机端口12346。
|
||||
|
||||
<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->IPv4->ARP这一条链就可以了,因为后面我们的分析都是重点分析这条链。
|
||||
|
||||
另外,前面那个简单的拓扑图中,网络包的封装、转发、解封装的过程,建议你多看几遍,了熟于心,因为接下来,我们就能从代码层面,看到这个过程。到时候,对应起来,你就比较容易理解。
|
||||
|
||||
了解了Socket的基本原理,下一篇文章,我们就来看一看在Linux操作系统里面Socket系统调用的接口是什么样的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
@@ -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 && (flags & SOCK_NONBLOCK))
|
||||
flags = (flags & ~SOCK_NONBLOCK) | O_NONBLOCK;
|
||||
|
||||
retval = sock_create(family, type, protocol, &sock);
|
||||
......
|
||||
retval = sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
|
||||
......
|
||||
return retval;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面的代码比较容易看懂,Socket系统调用会调用sock_create创建一个struct socket结构,然后通过sock_map_fd和文件描述符对应起来。
|
||||
|
||||
在创建Socket的时候,有三个参数。
|
||||
|
||||
一个是**family**,表示地址族。不是所有的Socket都要通过IP进行通信,还有其他的通信方式。例如,下面的定义中,domain sockets就是通过本地文件进行通信的,不需要IP地址。只不过,通过IP地址只是最常用的模式,所以我们这里着重分析这种模式。
|
||||
|
||||
```
|
||||
#define AF_UNIX 1/* Unix domain sockets */
|
||||
#define AF_INET 2/* Internet IP Protocol */
|
||||
|
||||
```
|
||||
|
||||
第二个参数是**type**,也即Socket的类型。类型是比较少的。
|
||||
|
||||
第三个参数是**protocol**,是协议。协议数目是比较多的,也就是说,多个协议会属于同一种类型。
|
||||
|
||||
常用的Socket类型有三种,分别是SOCK_STREAM、SOCK_DGRAM和SOCK_RAW。
|
||||
|
||||
```
|
||||
enum sock_type {
|
||||
SOCK_STREAM = 1,
|
||||
SOCK_DGRAM = 2,
|
||||
SOCK_RAW = 3,
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
SOCK_STREAM是面向数据流的,协议IPPROTO_TCP属于这种类型。SOCK_DGRAM是面向数据报的,协议IPPROTO_UDP属于这种类型。如果在内核里面看的话,IPPROTO_ICMP也属于这种类型。SOCK_RAW是原始的IP包,IPPROTO_IP属于这种类型。
|
||||
|
||||
**这一节,我们重点看SOCK_STREAM类型和IPPROTO_TCP协议。**
|
||||
|
||||
为了管理family、type、protocol这三个分类层次,内核会创建对应的数据结构。
|
||||
|
||||
接下来,我们打开sock_create函数看一下。它会调用__sock_create。
|
||||
|
||||
```
|
||||
int __sock_create(struct net *net, int family, int type, int protocol,
|
||||
struct socket **res, int kern)
|
||||
{
|
||||
int err;
|
||||
struct socket *sock;
|
||||
const struct net_proto_family *pf;
|
||||
......
|
||||
sock = sock_alloc();
|
||||
......
|
||||
sock->type = type;
|
||||
......
|
||||
pf = rcu_dereference(net_families[family]);
|
||||
......
|
||||
err = pf->create(net, sock, protocol, kern);
|
||||
......
|
||||
*res = sock;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里先是分配了一个struct socket结构。接下来我们要用到family参数。这里有一个net_families数组,我们可以以family参数为下标,找到对应的struct net_proto_family。
|
||||
|
||||
```
|
||||
/* Supported address families. */
|
||||
#define AF_UNSPEC 0
|
||||
#define AF_UNIX 1 /* Unix domain sockets */
|
||||
#define AF_LOCAL 1 /* POSIX name for AF_UNIX */
|
||||
#define AF_INET 2 /* Internet IP Protocol */
|
||||
......
|
||||
#define AF_INET6 10 /* IP version 6 */
|
||||
......
|
||||
#define AF_MPLS 28 /* MPLS */
|
||||
......
|
||||
#define AF_MAX 44 /* For now.. */
|
||||
#define NPROTO AF_MAX
|
||||
|
||||
struct net_proto_family __rcu *net_families[NPROTO] __read_mostly;
|
||||
|
||||
```
|
||||
|
||||
我们可以找到net_families的定义。每一个地址族在这个数组里面都有一项,里面的内容是net_proto_family。每一种地址族都有自己的net_proto_family,IP地址族的net_proto_family定义如下,里面最重要的就是,create函数指向inet_create。
|
||||
|
||||
```
|
||||
//net/ipv4/af_inet.c
|
||||
static const struct net_proto_family inet_family_ops = {
|
||||
.family = PF_INET,
|
||||
.create = inet_create,//这个用于socket系统调用创建
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们回到函数__sock_create。接下来,在这里面,这个inet_create会被调用。
|
||||
|
||||
```
|
||||
static int inet_create(struct net *net, struct socket *sock, int protocol, int kern)
|
||||
{
|
||||
struct sock *sk;
|
||||
struct inet_protosw *answer;
|
||||
struct inet_sock *inet;
|
||||
struct proto *answer_prot;
|
||||
unsigned char answer_flags;
|
||||
int try_loading_module = 0;
|
||||
int err;
|
||||
|
||||
/* Look for the requested type/protocol pair. */
|
||||
lookup_protocol:
|
||||
list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {
|
||||
err = 0;
|
||||
/* Check the non-wild match. */
|
||||
if (protocol == answer->protocol) {
|
||||
if (protocol != IPPROTO_IP)
|
||||
break;
|
||||
} else {
|
||||
/* Check for the two wild cases. */
|
||||
if (IPPROTO_IP == protocol) {
|
||||
protocol = answer->protocol;
|
||||
break;
|
||||
}
|
||||
if (IPPROTO_IP == answer->protocol)
|
||||
break;
|
||||
}
|
||||
err = -EPROTONOSUPPORT;
|
||||
}
|
||||
......
|
||||
sock->ops = answer->ops;
|
||||
answer_prot = answer->prot;
|
||||
answer_flags = answer->flags;
|
||||
......
|
||||
sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);
|
||||
......
|
||||
inet = inet_sk(sk);
|
||||
inet->nodefrag = 0;
|
||||
if (SOCK_RAW == sock->type) {
|
||||
inet->inet_num = protocol;
|
||||
if (IPPROTO_RAW == protocol)
|
||||
inet->hdrincl = 1;
|
||||
}
|
||||
inet->inet_id = 0;
|
||||
sock_init_data(sock, sk);
|
||||
|
||||
sk->sk_destruct = inet_sock_destruct;
|
||||
sk->sk_protocol = protocol;
|
||||
sk->sk_backlog_rcv = sk->sk_prot->backlog_rcv;
|
||||
|
||||
inet->uc_ttl = -1;
|
||||
inet->mc_loop = 1;
|
||||
inet->mc_ttl = 1;
|
||||
inet->mc_all = 1;
|
||||
inet->mc_index = 0;
|
||||
inet->mc_list = NULL;
|
||||
inet->rcv_tos = 0;
|
||||
|
||||
if (inet->inet_num) {
|
||||
inet->inet_sport = htons(inet->inet_num);
|
||||
/* Add to protocol hash chains. */
|
||||
err = sk->sk_prot->hash(sk);
|
||||
}
|
||||
|
||||
if (sk->sk_prot->init) {
|
||||
err = sk->sk_prot->init(sk);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在inet_create中,我们先会看到一个循环list_for_each_entry_rcu。在这里,第二个参数type开始起作用。因为循环查看的是inetsw[sock->type]。
|
||||
|
||||
这里的inetsw也是一个数组,type作为下标,里面的内容是struct inet_protosw,是协议,也即inetsw数组对于每个类型有一项,这一项里面是属于这个类型的协议。
|
||||
|
||||
```
|
||||
static struct list_head inetsw[SOCK_MAX];
|
||||
|
||||
static int __init inet_init(void)
|
||||
{
|
||||
......
|
||||
/* Register the socket-side information for inet_create. */
|
||||
for (r = &inetsw[0]; r < &inetsw[SOCK_MAX]; ++r)
|
||||
INIT_LIST_HEAD(r);
|
||||
for (q = inetsw_array; q < &inetsw_array[INETSW_ARRAY_LEN]; ++q)
|
||||
inet_register_protosw(q);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
inetsw数组是在系统初始化的时候初始化的,就像下面代码里面实现的一样。
|
||||
|
||||
首先,一个循环会将inetsw数组的每一项,都初始化为一个链表。咱们前面说了,一个type类型会包含多个protocol,因而我们需要一个链表。接下来一个循环,是将inetsw_array注册到inetsw数组里面去。inetsw_array的定义如下,这个数组里面的内容很重要,后面会用到它们。
|
||||
|
||||
```
|
||||
static struct inet_protosw inetsw_array[] =
|
||||
{
|
||||
{
|
||||
.type = SOCK_STREAM,
|
||||
.protocol = IPPROTO_TCP,
|
||||
.prot = &tcp_prot,
|
||||
.ops = &inet_stream_ops,
|
||||
.flags = INET_PROTOSW_PERMANENT |
|
||||
INET_PROTOSW_ICSK,
|
||||
},
|
||||
{
|
||||
.type = SOCK_DGRAM,
|
||||
.protocol = IPPROTO_UDP,
|
||||
.prot = &udp_prot,
|
||||
.ops = &inet_dgram_ops,
|
||||
.flags = INET_PROTOSW_PERMANENT,
|
||||
},
|
||||
{
|
||||
.type = SOCK_DGRAM,
|
||||
.protocol = IPPROTO_ICMP,
|
||||
.prot = &ping_prot,
|
||||
.ops = &inet_sockraw_ops,
|
||||
.flags = INET_PROTOSW_REUSE,
|
||||
},
|
||||
{
|
||||
.type = SOCK_RAW,
|
||||
.protocol = IPPROTO_IP, /* wild card */
|
||||
.prot = &raw_prot,
|
||||
.ops = &inet_sockraw_ops,
|
||||
.flags = INET_PROTOSW_REUSE,
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们回到inet_create的list_for_each_entry_rcu循环中。到这里就好理解了,这是在inetsw数组中,根据type找到属于这个类型的列表,然后依次比较列表中的struct inet_protosw的protocol是不是用户指定的protocol;如果是,就得到了符合用户指定的family->type->protocol的struct inet_protosw *answer对象。
|
||||
|
||||
接下来,struct socket *sock的ops成员变量,被赋值为answer的ops。对于TCP来讲,就是inet_stream_ops。后面任何用户对于这个socket的操作,都是通过inet_stream_ops进行的。
|
||||
|
||||
接下来,我们创建一个struct sock *sk对象。这里比较让人困惑。socket和sock看起来几乎一样,容易让人混淆,这里需要说明一下,socket是用于负责对上给用户提供接口,并且和文件系统关联。而sock,负责向下对接内核网络协议栈。
|
||||
|
||||
在sk_alloc函数中,struct inet_protosw *answer结构的tcp_prot赋值给了struct sock *sk的sk_prot成员。tcp_prot的定义如下,里面定义了很多的函数,都是sock之下内核协议栈的动作。
|
||||
|
||||
```
|
||||
struct proto tcp_prot = {
|
||||
.name = "TCP",
|
||||
.owner = THIS_MODULE,
|
||||
.close = tcp_close,
|
||||
.connect = tcp_v4_connect,
|
||||
.disconnect = tcp_disconnect,
|
||||
.accept = inet_csk_accept,
|
||||
.ioctl = tcp_ioctl,
|
||||
.init = tcp_v4_init_sock,
|
||||
.destroy = tcp_v4_destroy_sock,
|
||||
.shutdown = tcp_shutdown,
|
||||
.setsockopt = tcp_setsockopt,
|
||||
.getsockopt = tcp_getsockopt,
|
||||
.keepalive = tcp_set_keepalive,
|
||||
.recvmsg = tcp_recvmsg,
|
||||
.sendmsg = tcp_sendmsg,
|
||||
.sendpage = tcp_sendpage,
|
||||
.backlog_rcv = tcp_v4_do_rcv,
|
||||
.release_cb = tcp_release_cb,
|
||||
.hash = inet_hash,
|
||||
.get_port = inet_csk_get_port,
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在inet_create函数中,接下来创建一个struct inet_sock结构,这个结构一开始就是struct sock,然后扩展了一些其他的信息,剩下的代码就填充这些信息。这一幕我们会经常看到,将一个结构放在另一个结构的开始位置,然后扩展一些成员,通过对于指针的强制类型转换,来访问这些成员。
|
||||
|
||||
socket的创建至此结束。
|
||||
|
||||
## 解析bind函数
|
||||
|
||||
接下来,我们来看bind。
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE3(bind, int, fd, struct sockaddr __user *, umyaddr, int, addrlen)
|
||||
{
|
||||
struct socket *sock;
|
||||
struct sockaddr_storage address;
|
||||
int err, fput_needed;
|
||||
|
||||
sock = sockfd_lookup_light(fd, &err, &fput_needed);
|
||||
if (sock) {
|
||||
err = move_addr_to_kernel(umyaddr, addrlen, &address);
|
||||
if (err >= 0) {
|
||||
err = sock->ops->bind(sock,
|
||||
(struct sockaddr *)
|
||||
&address, addrlen);
|
||||
}
|
||||
fput_light(sock->file, fput_needed);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在bind中,sockfd_lookup_light会根据fd文件描述符,找到struct socket结构。然后将sockaddr从用户态拷贝到内核态,然后调用struct socket结构里面ops的bind函数。根据前面创建socket的时候的设定,调用的是inet_stream_ops的bind函数,也即调用inet_bind。
|
||||
|
||||
```
|
||||
int inet_bind(struct socket *sock, struct sockaddr *uaddr, int addr_len)
|
||||
{
|
||||
struct sockaddr_in *addr = (struct sockaddr_in *)uaddr;
|
||||
struct sock *sk = sock->sk;
|
||||
struct inet_sock *inet = inet_sk(sk);
|
||||
struct net *net = sock_net(sk);
|
||||
unsigned short snum;
|
||||
......
|
||||
snum = ntohs(addr->sin_port);
|
||||
......
|
||||
inet->inet_rcv_saddr = inet->inet_saddr = addr->sin_addr.s_addr;
|
||||
/* Make sure we are allowed to bind here. */
|
||||
if ((snum || !inet->bind_address_no_port) &&
|
||||
sk->sk_prot->get_port(sk, snum)) {
|
||||
......
|
||||
}
|
||||
inet->inet_sport = htons(inet->inet_num);
|
||||
inet->inet_daddr = 0;
|
||||
inet->inet_dport = 0;
|
||||
sk_dst_reset(sk);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
bind里面会调用sk_prot的get_port函数,也即inet_csk_get_port来检查端口是否冲突,是否可以绑定。如果允许,则会设置struct inet_sock的本方的地址inet_saddr和本方的端口inet_sport,对方的地址inet_daddr和对方的端口inet_dport都初始化为0。
|
||||
|
||||
bind的逻辑相对比较简单,就到这里了。
|
||||
|
||||
## 解析listen函数
|
||||
|
||||
接下来我们来看listen。
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE2(listen, int, fd, int, backlog)
|
||||
{
|
||||
struct socket *sock;
|
||||
int err, fput_needed;
|
||||
int somaxconn;
|
||||
|
||||
sock = sockfd_lookup_light(fd, &err, &fput_needed);
|
||||
if (sock) {
|
||||
somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
|
||||
if ((unsigned int)backlog > somaxconn)
|
||||
backlog = somaxconn;
|
||||
err = sock->ops->listen(sock, backlog);
|
||||
fput_light(sock->file, fput_needed);
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在listen中,我们还是通过sockfd_lookup_light,根据fd文件描述符,找到struct socket结构。接着,我们调用struct socket结构里面ops的listen函数。根据前面创建socket的时候的设定,调用的是inet_stream_ops的listen函数,也即调用inet_listen。
|
||||
|
||||
```
|
||||
int inet_listen(struct socket *sock, int backlog)
|
||||
{
|
||||
struct sock *sk = sock->sk;
|
||||
unsigned char old_state;
|
||||
int err;
|
||||
old_state = sk->sk_state;
|
||||
/* Really, if the socket is already in listen state
|
||||
* we can only allow the backlog to be adjusted.
|
||||
*/
|
||||
if (old_state != TCP_LISTEN) {
|
||||
err = inet_csk_listen_start(sk, backlog);
|
||||
}
|
||||
sk->sk_max_ack_backlog = backlog;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果这个socket还不在TCP_LISTEN状态,会调用inet_csk_listen_start进入监听状态。
|
||||
|
||||
```
|
||||
int inet_csk_listen_start(struct sock *sk, int backlog)
|
||||
{
|
||||
struct inet_connection_sock *icsk = inet_csk(sk);
|
||||
struct inet_sock *inet = inet_sk(sk);
|
||||
int err = -EADDRINUSE;
|
||||
|
||||
reqsk_queue_alloc(&icsk->icsk_accept_queue);
|
||||
|
||||
sk->sk_max_ack_backlog = backlog;
|
||||
sk->sk_ack_backlog = 0;
|
||||
inet_csk_delack_init(sk);
|
||||
|
||||
sk_state_store(sk, TCP_LISTEN);
|
||||
if (!sk->sk_prot->get_port(sk, inet->inet_num)) {
|
||||
......
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面建立了一个新的结构inet_connection_sock,这个结构一开始是struct inet_sock,inet_csk其实做了一次强制类型转换,扩大了结构,看到了吧,又是这个套路。
|
||||
|
||||
struct inet_connection_sock结构比较复杂。如果打开它,你能看到处于各种状态的队列,各种超时时间、拥塞控制等字眼。我们说TCP是面向连接的,就是客户端和服务端都是有一个结构维护连接的状态,就是指这个结构。我们这里先不详细分析里面的变量,因为太多了,后面我们遇到一个分析一个。
|
||||
|
||||
首先,我们遇到的是icsk_accept_queue。它是干什么的呢?
|
||||
|
||||
在TCP的状态里面,有一个listen状态,当调用listen函数之后,就会进入这个状态,虽然我们写程序的时候,一般要等待服务端调用accept后,等待在哪里的时候,让客户端就发起连接。其实服务端一旦处于listen状态,不用accept,客户端也能发起连接。其实TCP的状态中,没有一个是否被accept的状态,那accept函数的作用是什么呢?
|
||||
|
||||
在内核中,为每个Socket维护两个队列。一个是已经建立了连接的队列,这时候连接三次握手已经完毕,处于established状态;一个是还没有完全建立连接的队列,这个时候三次握手还没完成,处于syn_rcvd的状态。
|
||||
|
||||
服务端调用accept函数,其实是在第一个队列中拿出一个已经完成的连接进行处理。如果还没有完成就阻塞等待。这里的icsk_accept_queue就是第一个队列。
|
||||
|
||||
初始化完之后,将TCP的状态设置为TCP_LISTEN,再次调用get_port判断端口是否冲突。
|
||||
|
||||
至此,listen的逻辑就结束了。
|
||||
|
||||
## 解析accept函数
|
||||
|
||||
接下来,我们解析服务端调用accept。
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE3(accept, int, fd, struct sockaddr __user *, upeer_sockaddr,
|
||||
int __user *, upeer_addrlen)
|
||||
{
|
||||
return sys_accept4(fd, upeer_sockaddr, upeer_addrlen, 0);
|
||||
}
|
||||
|
||||
SYSCALL_DEFINE4(accept4, int, fd, struct sockaddr __user *, upeer_sockaddr,
|
||||
int __user *, upeer_addrlen, int, flags)
|
||||
{
|
||||
struct socket *sock, *newsock;
|
||||
struct file *newfile;
|
||||
int err, len, newfd, fput_needed;
|
||||
struct sockaddr_storage address;
|
||||
......
|
||||
sock = sockfd_lookup_light(fd, &err, &fput_needed);
|
||||
newsock = sock_alloc();
|
||||
newsock->type = sock->type;
|
||||
newsock->ops = sock->ops;
|
||||
newfd = get_unused_fd_flags(flags);
|
||||
newfile = sock_alloc_file(newsock, flags, sock->sk->sk_prot_creator->name);
|
||||
err = sock->ops->accept(sock, newsock, sock->file->f_flags, false);
|
||||
if (upeer_sockaddr) {
|
||||
if (newsock->ops->getname(newsock, (struct sockaddr *)&address, &len, 2) < 0) {
|
||||
}
|
||||
err = move_addr_to_user(&address,
|
||||
len, upeer_sockaddr, upeer_addrlen);
|
||||
}
|
||||
fd_install(newfd, newfile);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
accept函数的实现,印证了socket的原理中说的那样,原来的socket是监听socket,这里我们会找到原来的struct socket,并基于它去创建一个新的newsock。这才是连接socket。除此之外,我们还会创建一个新的struct file和fd,并关联到socket。
|
||||
|
||||
这里面还会调用struct socket的sock->ops->accept,也即会调用inet_stream_ops的accept函数,也即inet_accept。
|
||||
|
||||
```
|
||||
int inet_accept(struct socket *sock, struct socket *newsock, int flags, bool kern)
|
||||
{
|
||||
struct sock *sk1 = sock->sk;
|
||||
int err = -EINVAL;
|
||||
struct sock *sk2 = sk1->sk_prot->accept(sk1, flags, &err, kern);
|
||||
sock_rps_record_flow(sk2);
|
||||
sock_graft(sk2, newsock);
|
||||
newsock->state = SS_CONNECTED;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
inet_accept会调用struct sock的sk1->sk_prot->accept,也即tcp_prot的accept函数,inet_csk_accept函数。
|
||||
|
||||
```
|
||||
/*
|
||||
* This will accept the next outstanding connection.
|
||||
*/
|
||||
struct sock *inet_csk_accept(struct sock *sk, int flags, int *err, bool kern)
|
||||
{
|
||||
struct inet_connection_sock *icsk = inet_csk(sk);
|
||||
struct request_sock_queue *queue = &icsk->icsk_accept_queue;
|
||||
struct request_sock *req;
|
||||
struct sock *newsk;
|
||||
int error;
|
||||
|
||||
if (sk->sk_state != TCP_LISTEN)
|
||||
goto out_err;
|
||||
|
||||
/* Find already established connection */
|
||||
if (reqsk_queue_empty(queue)) {
|
||||
long timeo = sock_rcvtimeo(sk, flags & O_NONBLOCK);
|
||||
error = inet_csk_wait_for_connect(sk, timeo);
|
||||
}
|
||||
req = reqsk_queue_remove(queue, sk);
|
||||
newsk = req->sk;
|
||||
......
|
||||
}
|
||||
|
||||
/*
|
||||
* Wait for an incoming connection, avoid race conditions. This must be called
|
||||
* with the socket locked.
|
||||
*/
|
||||
static int inet_csk_wait_for_connect(struct sock *sk, long timeo)
|
||||
{
|
||||
struct inet_connection_sock *icsk = inet_csk(sk);
|
||||
DEFINE_WAIT(wait);
|
||||
int err;
|
||||
for (;;) {
|
||||
prepare_to_wait_exclusive(sk_sleep(sk), &wait,
|
||||
TASK_INTERRUPTIBLE);
|
||||
release_sock(sk);
|
||||
if (reqsk_queue_empty(&icsk->icsk_accept_queue))
|
||||
timeo = schedule_timeout(timeo);
|
||||
sched_annotate_sleep();
|
||||
lock_sock(sk);
|
||||
err = 0;
|
||||
if (!reqsk_queue_empty(&icsk->icsk_accept_queue))
|
||||
break;
|
||||
err = -EINVAL;
|
||||
if (sk->sk_state != TCP_LISTEN)
|
||||
break;
|
||||
err = sock_intr_errno(timeo);
|
||||
if (signal_pending(current))
|
||||
break;
|
||||
err = -EAGAIN;
|
||||
if (!timeo)
|
||||
break;
|
||||
}
|
||||
finish_wait(sk_sleep(sk), &wait);
|
||||
return err;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
inet_csk_accept的实现,印证了上面我们讲的两个队列的逻辑。如果icsk_accept_queue为空,则调用inet_csk_wait_for_connect进行等待;等待的时候,调用schedule_timeout,让出CPU,并且将进程状态设置为TASK_INTERRUPTIBLE。
|
||||
|
||||
如果再次CPU醒来,我们会接着判断icsk_accept_queue是否为空,同时也会调用signal_pending看有没有信号可以处理。一旦icsk_accept_queue不为空,就从inet_csk_wait_for_connect中返回,在队列中取出一个struct sock对象赋值给newsk。
|
||||
|
||||
## 解析connect函数
|
||||
|
||||
什么情况下,icsk_accept_queue才不为空呢?当然是三次握手结束才可以。接下来我们来分析三次握手的过程。
|
||||
|
||||
<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, &err, &fput_needed);
|
||||
err = move_addr_to_kernel(uservaddr, addrlen, &address);
|
||||
err = sock->ops->connect(sock, (struct sockaddr *)&address, addrlen, sock->file->f_flags);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
connect函数的实现一开始你应该很眼熟,还是通过sockfd_lookup_light,根据fd文件描述符,找到struct socket结构。接着,我们会调用struct socket结构里面ops的connect函数,根据前面创建socket的时候的设定,调用inet_stream_ops的connect函数,也即调用inet_stream_connect。
|
||||
|
||||
```
|
||||
/*
|
||||
* Connect to a remote host. There is regrettably still a little
|
||||
* TCP 'magic' in here.
|
||||
*/
|
||||
int __inet_stream_connect(struct socket *sock, struct sockaddr *uaddr,
|
||||
int addr_len, int flags, int is_sendmsg)
|
||||
{
|
||||
struct sock *sk = sock->sk;
|
||||
int err;
|
||||
long timeo;
|
||||
|
||||
switch (sock->state) {
|
||||
......
|
||||
case SS_UNCONNECTED:
|
||||
err = -EISCONN;
|
||||
if (sk->sk_state != TCP_CLOSE)
|
||||
goto out;
|
||||
|
||||
err = sk->sk_prot->connect(sk, uaddr, addr_len);
|
||||
sock->state = SS_CONNECTING;
|
||||
break;
|
||||
}
|
||||
|
||||
timeo = sock_sndtimeo(sk, flags & O_NONBLOCK);
|
||||
|
||||
if ((1 << sk->sk_state) & (TCPF_SYN_SENT | TCPF_SYN_RECV)) {
|
||||
......
|
||||
if (!timeo || !inet_wait_for_connect(sk, timeo, writebias))
|
||||
goto out;
|
||||
|
||||
err = sock_intr_errno(timeo);
|
||||
if (signal_pending(current))
|
||||
goto out;
|
||||
}
|
||||
sock->state = SS_CONNECTED;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在__inet_stream_connect里面,我们发现,如果socket处于SS_UNCONNECTED状态,那就调用struct sock的sk->sk_prot->connect,也即tcp_prot的connect函数——tcp_v4_connect函数。
|
||||
|
||||
```
|
||||
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
|
||||
{
|
||||
struct sockaddr_in *usin = (struct sockaddr_in *)uaddr;
|
||||
struct inet_sock *inet = inet_sk(sk);
|
||||
struct tcp_sock *tp = tcp_sk(sk);
|
||||
__be16 orig_sport, orig_dport;
|
||||
__be32 daddr, nexthop;
|
||||
struct flowi4 *fl4;
|
||||
struct rtable *rt;
|
||||
......
|
||||
orig_sport = inet->inet_sport;
|
||||
orig_dport = usin->sin_port;
|
||||
rt = ip_route_connect(fl4, nexthop, inet->inet_saddr,
|
||||
RT_CONN_FLAGS(sk), sk->sk_bound_dev_if,
|
||||
IPPROTO_TCP,
|
||||
orig_sport, orig_dport, sk);
|
||||
......
|
||||
tcp_set_state(sk, TCP_SYN_SENT);
|
||||
err = inet_hash_connect(tcp_death_row, sk);
|
||||
sk_set_txhash(sk);
|
||||
rt = ip_route_newports(fl4, rt, orig_sport, orig_dport,
|
||||
inet->inet_sport, inet->inet_dport, sk);
|
||||
/* OK, now commit destination to socket. */
|
||||
sk->sk_gso_type = SKB_GSO_TCPV4;
|
||||
sk_setup_caps(sk, &rt->dst);
|
||||
if (likely(!tp->repair)) {
|
||||
if (!tp->write_seq)
|
||||
tp->write_seq = secure_tcp_seq(inet->inet_saddr,
|
||||
inet->inet_daddr,
|
||||
inet->inet_sport,
|
||||
usin->sin_port);
|
||||
tp->tsoffset = secure_tcp_ts_off(sock_net(sk),
|
||||
inet->inet_saddr,
|
||||
inet->inet_daddr);
|
||||
}
|
||||
rt = NULL;
|
||||
......
|
||||
err = tcp_connect(sk);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在tcp_v4_connect函数中,ip_route_connect其实是做一个路由的选择。为什么呢?因为三次握手马上就要发送一个SYN包了,这就要凑齐源地址、源端口、目标地址、目标端口。目标地址和目标端口是服务端的,已经知道源端口是客户端随机分配的,源地址应该用哪一个呢?这时候要选择一条路由,看从哪个网卡出去,就应该填写哪个网卡的IP地址。
|
||||
|
||||
接下来,在发送SYN之前,我们先将客户端socket的状态设置为TCP_SYN_SENT。然后初始化TCP的seq num,也即write_seq,然后调用tcp_connect进行发送。
|
||||
|
||||
```
|
||||
/* Build a SYN and send it off. */
|
||||
int tcp_connect(struct sock *sk)
|
||||
{
|
||||
struct tcp_sock *tp = tcp_sk(sk);
|
||||
struct sk_buff *buff;
|
||||
int err;
|
||||
......
|
||||
tcp_connect_init(sk);
|
||||
......
|
||||
buff = sk_stream_alloc_skb(sk, 0, sk->sk_allocation, true);
|
||||
......
|
||||
tcp_init_nondata_skb(buff, tp->write_seq++, TCPHDR_SYN);
|
||||
tcp_mstamp_refresh(tp);
|
||||
tp->retrans_stamp = tcp_time_stamp(tp);
|
||||
tcp_connect_queue_skb(sk, buff);
|
||||
tcp_ecn_send_syn(sk, buff);
|
||||
|
||||
/* Send off SYN; include data in Fast Open. */
|
||||
err = tp->fastopen_req ? tcp_send_syn_data(sk, buff) :
|
||||
tcp_transmit_skb(sk, buff, 1, sk->sk_allocation);
|
||||
......
|
||||
tp->snd_nxt = tp->write_seq;
|
||||
tp->pushed_seq = tp->write_seq;
|
||||
buff = tcp_send_head(sk);
|
||||
if (unlikely(buff)) {
|
||||
tp->snd_nxt = TCP_SKB_CB(buff)->seq;
|
||||
tp->pushed_seq = TCP_SKB_CB(buff)->seq;
|
||||
}
|
||||
......
|
||||
/* Timer for repeating the SYN until an answer. */
|
||||
inet_csk_reset_xmit_timer(sk, ICSK_TIME_RETRANS,
|
||||
inet_csk(sk)->icsk_rto, TCP_RTO_MAX);
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在tcp_connect中,有一个新的结构struct tcp_sock,如果打开他,你会发现他是struct inet_connection_sock的一个扩展,struct inet_connection_sock在struct tcp_sock开头的位置,通过强制类型转换访问,故伎重演又一次。
|
||||
|
||||
struct tcp_sock里面维护了更多的TCP的状态,咱们同样是遇到了再分析。
|
||||
|
||||
接下来tcp_init_nondata_skb初始化一个SYN包,tcp_transmit_skb将SYN包发送出去,inet_csk_reset_xmit_timer设置了一个timer,如果SYN发送不成功,则再次发送。
|
||||
|
||||
发送网络包的过程,我们放到下一节讲解。这里我们姑且认为SYN已经发送出去了。
|
||||
|
||||
我们回到__inet_stream_connect函数,在调用sk->sk_prot->connect之后,inet_wait_for_connect会一直等待客户端收到服务端的ACK。而我们知道,服务端在accept之后,也是在等待中。
|
||||
|
||||
网络包是如何接收的呢?对于解析的详细过程,我们会在下下节讲解,这里为了解析三次握手,我们简单的看网络包接收到TCP层做的部分事情。
|
||||
|
||||
```
|
||||
static struct net_protocol tcp_protocol = {
|
||||
.early_demux = tcp_v4_early_demux,
|
||||
.early_demux_handler = tcp_v4_early_demux,
|
||||
.handler = tcp_v4_rcv,
|
||||
.err_handler = tcp_v4_err,
|
||||
.no_policy = 1,
|
||||
.netns_ok = 1,
|
||||
.icmp_strict_tag_validation = 1,
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们通过struct net_protocol结构中的handler进行接收,调用的函数是tcp_v4_rcv。接下来的调用链为tcp_v4_rcv->tcp_v4_do_rcv->tcp_rcv_state_process。tcp_rcv_state_process,顾名思义,是用来处理接收一个网络包后引起状态变化的。
|
||||
|
||||
```
|
||||
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb)
|
||||
{
|
||||
struct tcp_sock *tp = tcp_sk(sk);
|
||||
struct inet_connection_sock *icsk = inet_csk(sk);
|
||||
const struct tcphdr *th = tcp_hdr(skb);
|
||||
struct request_sock *req;
|
||||
int queued = 0;
|
||||
bool acceptable;
|
||||
|
||||
switch (sk->sk_state) {
|
||||
......
|
||||
case TCP_LISTEN:
|
||||
......
|
||||
if (th->syn) {
|
||||
acceptable = icsk->icsk_af_ops->conn_request(sk, skb) >= 0;
|
||||
if (!acceptable)
|
||||
return 1;
|
||||
consume_skb(skb);
|
||||
return 0;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
目前服务端是处于TCP_LISTEN状态的,而且发过来的包是SYN,因而就有了上面的代码,调用icsk->icsk_af_ops->conn_request函数。struct inet_connection_sock对应的操作是inet_connection_sock_af_ops,按照下面的定义,其实调用的是tcp_v4_conn_request。
|
||||
|
||||
```
|
||||
const struct inet_connection_sock_af_ops ipv4_specific = {
|
||||
.queue_xmit = ip_queue_xmit,
|
||||
.send_check = tcp_v4_send_check,
|
||||
.rebuild_header = inet_sk_rebuild_header,
|
||||
.sk_rx_dst_set = inet_sk_rx_dst_set,
|
||||
.conn_request = tcp_v4_conn_request,
|
||||
.syn_recv_sock = tcp_v4_syn_recv_sock,
|
||||
.net_header_len = sizeof(struct iphdr),
|
||||
.setsockopt = ip_setsockopt,
|
||||
.getsockopt = ip_getsockopt,
|
||||
.addr2sockaddr = inet_csk_addr2sockaddr,
|
||||
.sockaddr_len = sizeof(struct sockaddr_in),
|
||||
.mtu_reduced = tcp_v4_mtu_reduced,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
tcp_v4_conn_request会调用tcp_conn_request,这个函数也比较长,里面调用了send_synack,但实际调用的是tcp_v4_send_synack。具体发送的过程我们不去管它,看注释我们能知道,这是收到了SYN后,回复一个SYN-ACK,回复完毕后,服务端处于TCP_SYN_RECV。
|
||||
|
||||
```
|
||||
int tcp_conn_request(struct request_sock_ops *rsk_ops,
|
||||
const struct tcp_request_sock_ops *af_ops,
|
||||
struct sock *sk, struct sk_buff *skb)
|
||||
{
|
||||
......
|
||||
af_ops->send_synack(sk, dst, &fl, req, &foc,
|
||||
!want_cookie ? TCP_SYNACK_NORMAL :
|
||||
TCP_SYNACK_COOKIE);
|
||||
......
|
||||
}
|
||||
|
||||
/*
|
||||
* Send a SYN-ACK after having received a SYN.
|
||||
*/
|
||||
static int tcp_v4_send_synack(const struct sock *sk, struct dst_entry *dst,
|
||||
struct flowi *fl,
|
||||
struct request_sock *req,
|
||||
struct tcp_fastopen_cookie *foc,
|
||||
enum tcp_synack_type synack_type)
|
||||
{......}
|
||||
|
||||
```
|
||||
|
||||
这个时候,轮到客户端接收网络包了。都是TCP协议栈,所以过程和服务端没有太多区别,还是会走到tcp_rcv_state_process函数的,只不过由于客户端目前处于TCP_SYN_SENT状态,就进入了下面的代码分支。
|
||||
|
||||
```
|
||||
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb)
|
||||
{
|
||||
struct tcp_sock *tp = tcp_sk(sk);
|
||||
struct inet_connection_sock *icsk = inet_csk(sk);
|
||||
const struct tcphdr *th = tcp_hdr(skb);
|
||||
struct request_sock *req;
|
||||
int queued = 0;
|
||||
bool acceptable;
|
||||
|
||||
switch (sk->sk_state) {
|
||||
......
|
||||
case TCP_SYN_SENT:
|
||||
tp->rx_opt.saw_tstamp = 0;
|
||||
tcp_mstamp_refresh(tp);
|
||||
queued = tcp_rcv_synsent_state_process(sk, skb, th);
|
||||
if (queued >= 0)
|
||||
return queued;
|
||||
/* Do step6 onward by hand. */
|
||||
tcp_urg(sk, skb, th);
|
||||
__kfree_skb(skb);
|
||||
tcp_data_snd_check(sk);
|
||||
return 0;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
tcp_rcv_synsent_state_process会调用tcp_send_ack,发送一个ACK-ACK,发送后客户端处于TCP_ESTABLISHED状态。
|
||||
|
||||
又轮到服务端接收网络包了,我们还是归tcp_rcv_state_process函数处理。由于服务端目前处于状态TCP_SYN_RECV状态,因而又走了另外的分支。当收到这个网络包的时候,服务端也处于TCP_ESTABLISHED状态,三次握手结束。
|
||||
|
||||
```
|
||||
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb)
|
||||
{
|
||||
struct tcp_sock *tp = tcp_sk(sk);
|
||||
struct inet_connection_sock *icsk = inet_csk(sk);
|
||||
const struct tcphdr *th = tcp_hdr(skb);
|
||||
struct request_sock *req;
|
||||
int queued = 0;
|
||||
bool acceptable;
|
||||
......
|
||||
switch (sk->sk_state) {
|
||||
case TCP_SYN_RECV:
|
||||
if (req) {
|
||||
inet_csk(sk)->icsk_retransmits = 0;
|
||||
reqsk_fastopen_remove(sk, req, false);
|
||||
} else {
|
||||
/* Make sure socket is routed, for correct metrics. */
|
||||
icsk->icsk_af_ops->rebuild_header(sk);
|
||||
tcp_call_bpf(sk, BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB);
|
||||
tcp_init_congestion_control(sk);
|
||||
|
||||
tcp_mtup_init(sk);
|
||||
tp->copied_seq = tp->rcv_nxt;
|
||||
tcp_init_buffer_space(sk);
|
||||
}
|
||||
smp_mb();
|
||||
tcp_set_state(sk, TCP_ESTABLISHED);
|
||||
sk->sk_state_change(sk);
|
||||
if (sk->sk_socket)
|
||||
sk_wake_async(sk, SOCK_WAKE_IO, POLL_OUT);
|
||||
tp->snd_una = TCP_SKB_CB(skb)->ack_seq;
|
||||
tp->snd_wnd = ntohs(th->window) << tp->rx_opt.snd_wscale;
|
||||
tcp_init_wl(tp, TCP_SKB_CB(skb)->seq);
|
||||
break;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节除了网络包的接收和发送,其他的系统调用我们都分析到了。可以看出来,它们有一个统一的数据结构和流程。具体如下图所示:
|
||||
|
||||
<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是如何实现各层的函数的。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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->ki_filp;
|
||||
struct socket *sock = file->private_data;
|
||||
struct msghdr msg = {.msg_iter = *from,
|
||||
.msg_iocb = iocb};
|
||||
ssize_t res;
|
||||
......
|
||||
res = sock_sendmsg(sock, &msg);
|
||||
*from = msg.msg_iter;
|
||||
return res;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在sock_write_iter中,我们通过VFS中的struct file,将创建好的socket结构拿出来,然后调用sock_sendmsg。而sock_sendmsg会调用sock_sendmsg_nosec。
|
||||
|
||||
```
|
||||
static inline int sock_sendmsg_nosec(struct socket *sock, struct msghdr *msg)
|
||||
{
|
||||
int ret = sock->ops->sendmsg(sock, msg, msg_data_left(msg));
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里调用了socket的ops的sendmsg,我们在上一节已经遇到它好几次了。根据inet_stream_ops的定义,我们这里调用的是inet_sendmsg。
|
||||
|
||||
```
|
||||
int inet_sendmsg(struct socket *sock, struct msghdr *msg, size_t size)
|
||||
{
|
||||
struct sock *sk = sock->sk;
|
||||
......
|
||||
return sk->sk_prot->sendmsg(sk, msg, size);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面,从socket结构中,我们可以得到更底层的sock结构,然后调用sk_prot的sendmsg方法。这个我们同样在上一节遇到好几次了。
|
||||
|
||||
## 解析tcp_sendmsg函数
|
||||
|
||||
根据tcp_prot的定义,我们调用的是tcp_sendmsg。
|
||||
|
||||
```
|
||||
int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size)
|
||||
{
|
||||
struct tcp_sock *tp = tcp_sk(sk);
|
||||
struct sk_buff *skb;
|
||||
int flags, err, copied = 0;
|
||||
int mss_now = 0, size_goal, copied_syn = 0;
|
||||
long timeo;
|
||||
......
|
||||
/* Ok commence sending. */
|
||||
copied = 0;
|
||||
restart:
|
||||
mss_now = tcp_send_mss(sk, &size_goal, flags);
|
||||
|
||||
while (msg_data_left(msg)) {
|
||||
int copy = 0;
|
||||
int max = size_goal;
|
||||
|
||||
skb = tcp_write_queue_tail(sk);
|
||||
if (tcp_send_head(sk)) {
|
||||
if (skb->ip_summed == CHECKSUM_NONE)
|
||||
max = mss_now;
|
||||
copy = max - skb->len;
|
||||
}
|
||||
|
||||
if (copy <= 0 || !tcp_skb_can_collapse_to(skb)) {
|
||||
bool first_skb;
|
||||
|
||||
new_segment:
|
||||
/* Allocate new segment. If the interface is SG,
|
||||
* allocate skb fitting to single page.
|
||||
*/
|
||||
if (!sk_stream_memory_free(sk))
|
||||
goto wait_for_sndbuf;
|
||||
......
|
||||
first_skb = skb_queue_empty(&sk->sk_write_queue);
|
||||
skb = sk_stream_alloc_skb(sk,
|
||||
select_size(sk, sg, first_skb),
|
||||
sk->sk_allocation,
|
||||
first_skb);
|
||||
......
|
||||
skb_entail(sk, skb);
|
||||
copy = size_goal;
|
||||
max = size_goal;
|
||||
......
|
||||
}
|
||||
|
||||
/* Try to append data to the end of skb. */
|
||||
if (copy > msg_data_left(msg))
|
||||
copy = msg_data_left(msg);
|
||||
|
||||
/* Where to copy to? */
|
||||
if (skb_availroom(skb) > 0) {
|
||||
/* We have some space in skb head. Superb! */
|
||||
copy = min_t(int, copy, skb_availroom(skb));
|
||||
err = skb_add_data_nocache(sk, skb, &msg->msg_iter, copy);
|
||||
......
|
||||
} else {
|
||||
bool merge = true;
|
||||
int i = skb_shinfo(skb)->nr_frags;
|
||||
struct page_frag *pfrag = sk_page_frag(sk);
|
||||
......
|
||||
copy = min_t(int, copy, pfrag->size - pfrag->offset);
|
||||
......
|
||||
err = skb_copy_to_page_nocache(sk, &msg->msg_iter, skb,
|
||||
pfrag->page,
|
||||
pfrag->offset,
|
||||
copy);
|
||||
......
|
||||
pfrag->offset += copy;
|
||||
}
|
||||
|
||||
......
|
||||
tp->write_seq += copy;
|
||||
TCP_SKB_CB(skb)->end_seq += copy;
|
||||
tcp_skb_pcount_set(skb, 0);
|
||||
|
||||
copied += copy;
|
||||
if (!msg_data_left(msg)) {
|
||||
if (unlikely(flags & MSG_EOR))
|
||||
TCP_SKB_CB(skb)->eor = 1;
|
||||
goto out;
|
||||
}
|
||||
|
||||
if (skb->len < max || (flags & MSG_OOB) || unlikely(tp->repair))
|
||||
continue;
|
||||
|
||||
if (forced_push(tp)) {
|
||||
tcp_mark_push(tp, skb);
|
||||
__tcp_push_pending_frames(sk, mss_now, TCP_NAGLE_PUSH);
|
||||
} else if (skb == tcp_send_head(sk))
|
||||
tcp_push_one(sk, mss_now);
|
||||
continue;
|
||||
......
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
tcp_sendmsg的实现还是很复杂的,这里面做了这样几件事情。
|
||||
|
||||
msg是用户要写入的数据,这个数据要拷贝到内核协议栈里面去发送;在内核协议栈里面,网络包的数据都是由struct sk_buff维护的,因而第一件事情就是找到一个空闲的内存空间,将用户要写入的数据,拷贝到struct sk_buff的管辖范围内。而第二件事情就是发送struct sk_buff。
|
||||
|
||||
在tcp_sendmsg中,我们首先通过强制类型转换,将sock结构转换为struct tcp_sock,这个是维护TCP连接状态的重要数据结构。
|
||||
|
||||
接下来是tcp_sendmsg的第一件事情,把数据拷贝到struct sk_buff。
|
||||
|
||||
我们先声明一个变量copied,初始化为0,这表示拷贝了多少数据。紧接着是一个循环,while (msg_data_left(msg)),也即如果用户的数据没有发送完毕,就一直循环。循环里声明了一个copy变量,表示这次拷贝的数值,在循环的最后有copied += copy,将每次拷贝的数量都加起来。
|
||||
|
||||
我们这里只需要看一次循环做了哪些事情。
|
||||
|
||||
**第一步**,tcp_write_queue_tail从TCP写入队列sk_write_queue中拿出最后一个struct sk_buff,在这个写入队列中排满了要发送的struct sk_buff,为什么要拿最后一个呢?这里面只有最后一个,可能会因为上次用户给的数据太少,而没有填满。
|
||||
|
||||
**第二步**,tcp_send_mss会计算MSS,也即Max Segment Size。这是什么呢?这个意思是说,我们在网络上传输的网络包的大小是有限制的,而这个限制在最底层开始就有。
|
||||
|
||||
**MTU**(Maximum Transmission Unit,最大传输单元)是二层的一个定义。以以太网为例,MTU为1500个Byte,前面有6个Byte的目标MAC地址,6个Byte的源MAC地址,2个Byte的类型,后面有4个Byte的CRC校验,共1518个Byte。
|
||||
|
||||
在IP层,一个IP数据报在以太网中传输,如果它的长度大于该MTU值,就要进行分片传输。
|
||||
|
||||
在TCP层有个**MSS**(Maximum Segment Size,最大分段大小),等于MTU减去IP头,再减去TCP头。也就是,在不分片的情况下,TCP里面放的最大内容。
|
||||
|
||||
在这里,max是struct sk_buff的最大数据长度,skb->len是当前已经占用的skb的数据长度,相减得到当前skb的剩余数据空间。
|
||||
|
||||
**第三步**,如果copy小于0,说明最后一个struct sk_buff已经没地方存放了,需要调用sk_stream_alloc_skb,重新分配struct sk_buff,然后调用skb_entail,将新分配的sk_buff放到队列尾部。
|
||||
|
||||
struct sk_buff是存储网络包的重要的数据结构,在应用层数据包叫data,在TCP层我们称为segment,在IP层我们叫packet,在数据链路层称为frame。在struct sk_buff,首先是一个链表,将struct sk_buff结构串起来。
|
||||
|
||||
接下来,我们从headers_start开始,到headers_end结束,里面都是各层次的头的位置。这里面有二层的mac_header、三层的network_header和四层的transport_header。
|
||||
|
||||
```
|
||||
struct sk_buff {
|
||||
union {
|
||||
struct {
|
||||
/* These two members must be first. */
|
||||
struct sk_buff *next;
|
||||
struct sk_buff *prev;
|
||||
......
|
||||
};
|
||||
struct rb_node rbnode; /* used in netem & tcp stack */
|
||||
};
|
||||
......
|
||||
/* private: */
|
||||
__u32 headers_start[0];
|
||||
/* public: */
|
||||
......
|
||||
__u32 priority;
|
||||
int skb_iif;
|
||||
__u32 hash;
|
||||
__be16 vlan_proto;
|
||||
__u16 vlan_tci;
|
||||
......
|
||||
union {
|
||||
__u32 mark;
|
||||
__u32 reserved_tailroom;
|
||||
};
|
||||
|
||||
union {
|
||||
__be16 inner_protocol;
|
||||
__u8 inner_ipproto;
|
||||
};
|
||||
|
||||
__u16 inner_transport_header;
|
||||
__u16 inner_network_header;
|
||||
__u16 inner_mac_header;
|
||||
|
||||
__be16 protocol;
|
||||
__u16 transport_header;
|
||||
__u16 network_header;
|
||||
__u16 mac_header;
|
||||
|
||||
/* private: */
|
||||
__u32 headers_end[0];
|
||||
/* public: */
|
||||
|
||||
/* These elements must be at the end, see alloc_skb() for details. */
|
||||
sk_buff_data_t tail;
|
||||
sk_buff_data_t end;
|
||||
unsigned char *head,
|
||||
*data;
|
||||
unsigned int truesize;
|
||||
refcount_t users;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
最后几项, head指向分配的内存块起始地址。data这个指针指向的位置是可变的。它有可能随着报文所处的层次而变动。当接收报文时,从网卡驱动开始,通过协议栈层层往上传送数据报,通过增加 skb->data 的值,来逐步剥离协议首部。而要发送报文时,各协议会创建 sk_buff{},在经过各下层协议时,通过减少 skb->data的值来增加协议首部。tail指向数据的结尾,end指向分配的内存块的结束地址。
|
||||
|
||||
要分配这样一个结构,sk_stream_alloc_skb会最终调用到__alloc_skb。在这个函数里面,除了分配一个sk_buff结构之外,还要分配sk_buff指向的数据区域。这段数据区域分为下面这几个部分。
|
||||
|
||||
第一部分是连续的数据区域。紧接着是第二部分,一个struct skb_shared_info结构。这个结构是对于网络包发送过程的一个优化,因为传输层之上就是应用层了。按照TCP的定义,应用层感受不到下面的网络层的IP包是一个个独立的包的存在的。反正就是一个流,往里写就是了,可能一下子写多了,超过了一个IP包的承载能力,就会出现上面MSS的定义,拆分成一个个的Segment放在一个个的IP包里面,也可能一次写一点,一次写一点,这样数据是分散的,在IP层还要通过内存拷贝合成一个IP包。
|
||||
|
||||
为了减少内存拷贝的代价,有的网络设备支持**分散聚合**(Scatter/Gather)I/O,顾名思义,就是IP层没必要通过内存拷贝进行聚合,让散的数据零散的放在原处,在设备层进行聚合。如果使用这种模式,网络包的数据就不会放在连续的数据区域,而是放在struct skb_shared_info结构里面指向的离散数据,skb_shared_info的成员变量skb_frag_t frags[MAX_SKB_FRAGS],会指向一个数组的页面,就不能保证连续了。
|
||||
|
||||
<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 > 1 && !tcp_urg_mode(tp))
|
||||
limit = tcp_mss_split_point(sk, skb, mss_now, min_t(unsigned int, cwnd_quota, max_segs), nonagle);
|
||||
|
||||
if (skb->len > limit &&
|
||||
unlikely(tso_fragment(sk, skb, limit, mss_now, gfp)))
|
||||
break;
|
||||
......
|
||||
if (unlikely(tcp_transmit_skb(sk, skb, 1, gfp)))
|
||||
break;
|
||||
|
||||
repair:
|
||||
/* Advance the send_head. This one is sent out.
|
||||
* This call will increment packets_out.
|
||||
*/
|
||||
tcp_event_new_data_sent(sk, skb);
|
||||
|
||||
tcp_minshall_update(tp, mss_now, skb);
|
||||
sent_pkts += tcp_skb_pcount(skb);
|
||||
|
||||
if (push_one)
|
||||
break;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面主要的逻辑是一个循环,用来处理发送队列,只要队列不空,就会发送。
|
||||
|
||||
在一个循环中,涉及TCP层的很多传输算法,我们来一一解析。
|
||||
|
||||
第一个概念是**TSO**(TCP Segmentation Offload)。如果发送的网络包非常大,就像上面说的一样,要进行分段。分段这个事情可以由协议栈代码在内核做,但是缺点是比较费CPU,另一种方式是延迟到硬件网卡去做,需要网卡支持对大数据包进行自动分段,可以降低CPU负载。
|
||||
|
||||
在代码中,tcp_init_tso_segs会调用tcp_set_skb_tso_segs。这里面有这样的语句:DIV_ROUND_UP(skb->len, mss_now)。也就是sk_buff的长度除以mss_now,应该分成几个段。如果算出来要分成多个段,接下来就是要看,是在这里(协议栈的代码里面)分好,还是等待到了底层网卡再分。
|
||||
|
||||
于是,调用函数tcp_mss_split_point,开始计算切分的limit。这里面会计算max_len = mss_now * max_segs,根据现在不切分来计算limit,所以下一步的判断中,大部分情况下tso_fragment不会被调用,等待到了底层网卡来切分。
|
||||
|
||||
第二个概念是**拥塞窗口**的概念(cwnd,congestion window),也就是说为了避免拼命发包,把网络塞满了,定义一个窗口的概念,在这个窗口之内的才能发送,超过这个窗口的就不能发送,来控制发送的频率。
|
||||
|
||||
那窗口大小是多少呢?就是遵循下面这个著名的拥塞窗口变化图。
|
||||
|
||||
<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)->seq;
|
||||
max_len = mss_now * max_segs;
|
||||
|
||||
if (likely(max_len <= window && skb != tcp_write_queue_tail(sk)))
|
||||
return max_len;
|
||||
|
||||
needed = min(skb->len, window);
|
||||
|
||||
if (max_len <= needed)
|
||||
return max_len;
|
||||
......
|
||||
return needed;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面除了会判断上面讲的,是否会因为超出mss而分段,还会判断另一个条件,就是是否在滑动窗口的运行范围之内,如果小于窗口的大小,也需要分段,也即需要调用tso_fragment。
|
||||
|
||||
在一个循环的最后,是调用tcp_transmit_skb,真的去发送一个网络包。
|
||||
|
||||
```
|
||||
static int tcp_transmit_skb(struct sock *sk, struct sk_buff *skb, int clone_it,
|
||||
gfp_t gfp_mask)
|
||||
{
|
||||
const struct inet_connection_sock *icsk = inet_csk(sk);
|
||||
struct inet_sock *inet;
|
||||
struct tcp_sock *tp;
|
||||
struct tcp_skb_cb *tcb;
|
||||
struct tcphdr *th;
|
||||
int err;
|
||||
|
||||
tp = tcp_sk(sk);
|
||||
|
||||
skb->skb_mstamp = tp->tcp_mstamp;
|
||||
inet = inet_sk(sk);
|
||||
tcb = TCP_SKB_CB(skb);
|
||||
memset(&opts, 0, sizeof(opts));
|
||||
|
||||
tcp_header_size = tcp_options_size + sizeof(struct tcphdr);
|
||||
skb_push(skb, tcp_header_size);
|
||||
|
||||
/* Build TCP header and checksum it. */
|
||||
th = (struct tcphdr *)skb->data;
|
||||
th->source = inet->inet_sport;
|
||||
th->dest = inet->inet_dport;
|
||||
th->seq = htonl(tcb->seq);
|
||||
th->ack_seq = htonl(tp->rcv_nxt);
|
||||
*(((__be16 *)th) + 6) = htons(((tcp_header_size >> 2) << 12) |
|
||||
tcb->tcp_flags);
|
||||
|
||||
th->check = 0;
|
||||
th->urg_ptr = 0;
|
||||
......
|
||||
tcp_options_write((__be32 *)(th + 1), tp, &opts);
|
||||
th->window = htons(min(tp->rcv_wnd, 65535U));
|
||||
......
|
||||
err = icsk->icsk_af_ops->queue_xmit(sk, skb, &inet->cork.fl);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
tcp_transmit_skb这个函数比较长,主要做了两件事情,第一件事情就是填充TCP头,如果我们对着TCP头的格式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/0e/be225a97816a664367f29be9046aa30e.png" alt="">
|
||||
|
||||
这里面有源端口,设置为inet_sport,有目标端口,设置为inet_dport;有序列号,设置为tcb->seq;有确认序列号,设置为tp->rcv_nxt。我们把所有的flags设置为tcb->tcp_flags。设置选项为opts。设置窗口大小为tp->rcv_wnd。
|
||||
|
||||
全部设置完毕之后,就会调用icsk_af_ops的queue_xmit方法,icsk_af_ops指向ipv4_specific,也即调用的是ip_queue_xmit函数。
|
||||
|
||||
```
|
||||
const struct inet_connection_sock_af_ops ipv4_specific = {
|
||||
.queue_xmit = ip_queue_xmit,
|
||||
.send_check = tcp_v4_send_check,
|
||||
.rebuild_header = inet_sk_rebuild_header,
|
||||
.sk_rx_dst_set = inet_sk_rx_dst_set,
|
||||
.conn_request = tcp_v4_conn_request,
|
||||
.syn_recv_sock = tcp_v4_syn_recv_sock,
|
||||
.net_header_len = sizeof(struct iphdr),
|
||||
.setsockopt = ip_setsockopt,
|
||||
.getsockopt = ip_getsockopt,
|
||||
.addr2sockaddr = inet_csk_addr2sockaddr,
|
||||
.sockaddr_len = sizeof(struct sockaddr_in),
|
||||
.mtu_reduced = tcp_v4_mtu_reduced,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们解析了发送一个网络包的一部分过程,如下图所示。
|
||||
|
||||
<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的包,看看里面的结构。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
@@ -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->inet_opt);
|
||||
fl4 = &fl->u.ip4;
|
||||
rt = skb_rtable(skb);
|
||||
/* Make sure we can route this packet. */
|
||||
rt = (struct rtable *)__sk_dst_check(sk, 0);
|
||||
if (!rt) {
|
||||
__be32 daddr;
|
||||
/* Use correct destination address if we have options. */
|
||||
daddr = inet->inet_daddr;
|
||||
......
|
||||
rt = ip_route_output_ports(net, fl4, sk,
|
||||
daddr, inet->inet_saddr,
|
||||
inet->inet_dport,
|
||||
inet->inet_sport,
|
||||
sk->sk_protocol,
|
||||
RT_CONN_FLAGS(sk),
|
||||
sk->sk_bound_dev_if);
|
||||
if (IS_ERR(rt))
|
||||
goto no_route;
|
||||
sk_setup_caps(sk, &rt->dst);
|
||||
}
|
||||
skb_dst_set_noref(skb, &rt->dst);
|
||||
|
||||
packet_routed:
|
||||
/* OK, we know where to send it, allocate and build IP header. */
|
||||
skb_push(skb, sizeof(struct iphdr) + (inet_opt ? inet_opt->opt.optlen : 0));
|
||||
skb_reset_network_header(skb);
|
||||
iph = ip_hdr(skb);
|
||||
*((__be16 *)iph) = htons((4 << 12) | (5 << 8) | (inet->tos & 0xff));
|
||||
if (ip_dont_fragment(sk, &rt->dst) && !skb->ignore_df)
|
||||
iph->frag_off = htons(IP_DF);
|
||||
else
|
||||
iph->frag_off = 0;
|
||||
iph->ttl = ip_select_ttl(inet, &rt->dst);
|
||||
iph->protocol = sk->sk_protocol;
|
||||
ip_copy_addrs(iph, fl4);
|
||||
|
||||
/* Transport layer set skb->h.foo itself. */
|
||||
|
||||
if (inet_opt && inet_opt->opt.optlen) {
|
||||
iph->ihl += inet_opt->opt.optlen >> 2;
|
||||
ip_options_build(skb, &inet_opt->opt, inet->inet_daddr, rt, 0);
|
||||
}
|
||||
|
||||
ip_select_ident_segs(net, skb, sk,
|
||||
skb_shinfo(skb)->gso_segs ?: 1);
|
||||
|
||||
/* TODO : should we use skb->sk here instead of sk ? */
|
||||
skb->priority = sk->sk_priority;
|
||||
skb->mark = sk->sk_mark;
|
||||
|
||||
res = ip_local_out(net, sk, skb);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ip_queue_xmit中,也即IP层的发送函数里面,有三部分逻辑。
|
||||
|
||||
第一部分,选取路由,也即我要发送这个包应该从哪个网卡出去。
|
||||
|
||||
这件事情主要由ip_route_output_ports函数完成。接下来的调用链为:ip_route_output_ports->ip_route_output_flow->__ip_route_output_key->ip_route_output_key_hash->ip_route_output_key_hash_rcu。
|
||||
|
||||
```
|
||||
struct rtable *ip_route_output_key_hash_rcu(struct net *net, struct flowi4 *fl4, struct fib_result *res, const struct sk_buff *skb)
|
||||
{
|
||||
struct net_device *dev_out = NULL;
|
||||
int orig_oif = fl4->flowi4_oif;
|
||||
unsigned int flags = 0;
|
||||
struct rtable *rth;
|
||||
......
|
||||
err = fib_lookup(net, fl4, res, 0);
|
||||
......
|
||||
make_route:
|
||||
rth = __mkroute_output(res, fl4, orig_oif, dev_out, flags);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
ip_route_output_key_hash_rcu先会调用fib_lookup。
|
||||
|
||||
**FIB**全称是Forwarding Information Base,**转发信息表。**其实就是咱们常说的路由表。
|
||||
|
||||
```
|
||||
static inline int fib_lookup(struct net *net, const struct flowi4 *flp, struct fib_result *res, unsigned int flags)
|
||||
{ struct fib_table *tb;
|
||||
......
|
||||
tb = fib_get_table(net, RT_TABLE_MAIN);
|
||||
if (tb)
|
||||
err = fib_table_lookup(tb, flp, res, flags | FIB_LOOKUP_NOREF);
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
路由表可以有多个,一般会有一个主表,RT_TABLE_MAIN。然后fib_table_lookup函数在这个表里面进行查找。
|
||||
|
||||
路由表是一个什么样的结构呢?
|
||||
|
||||
路由就是在Linux服务器上的路由表里面配置的一条一条规则。这些规则大概是这样的:想访问某个网段,从某个网卡出去,下一跳是某个IP。
|
||||
|
||||
之前我们讲过一个简单的拓扑图,里面的三台Linux机器的路由表都可以通过ip route命令查看。
|
||||
|
||||
<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(&ipv4_dst_ops, dev, 1, DST_OBSOLETE_FORCE_CHK,
|
||||
(will_cache ? 0 : DST_HOST) |
|
||||
(nopolicy ? DST_NOPOLICY : 0) |
|
||||
(noxfrm ? DST_NOXFRM : 0));
|
||||
|
||||
if (rt) {
|
||||
rt->rt_genid = rt_genid_ipv4(dev_net(dev));
|
||||
rt->rt_flags = flags;
|
||||
rt->rt_type = type;
|
||||
rt->rt_is_input = 0;
|
||||
rt->rt_iif = 0;
|
||||
rt->rt_pmtu = 0;
|
||||
rt->rt_gateway = 0;
|
||||
rt->rt_uses_gateway = 0;
|
||||
rt->rt_table_id = 0;
|
||||
INIT_LIST_HEAD(&rt->rt_uncached);
|
||||
|
||||
rt->dst.output = ip_output;
|
||||
if (flags & RTCF_LOCAL)
|
||||
rt->dst.input = ip_local_deliver;
|
||||
}
|
||||
|
||||
return rt;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最终返回struct rtable实例,第一部分也就完成了。
|
||||
|
||||
第二部分,就是准备IP层的头,往里面填充内容。这就要对着IP层的头的格式进行理解。
|
||||
|
||||
<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->tot_len = htons(skb->len);
|
||||
skb->protocol = htons(ETH_P_IP);
|
||||
|
||||
return nf_hook(NFPROTO_IPV4, NF_INET_LOCAL_OUT,
|
||||
net, sk, skb, NULL, skb_dst(skb)->dev,
|
||||
dst_output);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
ip_local_out先是调用__ip_local_out,然后里面调用了nf_hook。这是什么呢?nf的意思是Netfilter,这是Linux内核的一个机制,用于在网络发送和转发的关键节点上加上hook函数,这些函数可以截获数据包,对数据包进行干预。
|
||||
|
||||
一个著名的实现,就是内核模块ip_tables。在用户态,还有一个客户端程序iptables,用命令行来干预内核的规则。
|
||||
|
||||
<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)->output(net, sk, skb);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里调用的就是struct rtable成员dst的ouput函数。在rt_dst_alloc中,我们可以看到,output函数指向的是ip_output。
|
||||
|
||||
```
|
||||
int ip_output(struct net *net, struct sock *sk, struct sk_buff *skb)
|
||||
{
|
||||
struct net_device *dev = skb_dst(skb)->dev;
|
||||
skb->dev = dev;
|
||||
skb->protocol = htons(ETH_P_IP);
|
||||
|
||||
return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING,
|
||||
net, sk, skb, NULL, dev,
|
||||
ip_finish_output,
|
||||
!(IPCB(skb)->flags & IPSKB_REROUTED));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ip_output里面,我们又看到了熟悉的NF_HOOK。这一次是NF_INET_POST_ROUTING,也即POSTROUTING链,处理完之后,调用ip_finish_output。
|
||||
|
||||
## 解析ip_finish_output函数
|
||||
|
||||
从ip_finish_output函数开始,发送网络包的逻辑由第三层到达第二层。ip_finish_output最终调用ip_finish_output2。
|
||||
|
||||
```
|
||||
static int ip_finish_output2(struct net *net, struct sock *sk, struct sk_buff *skb)
|
||||
{
|
||||
struct dst_entry *dst = skb_dst(skb);
|
||||
struct rtable *rt = (struct rtable *)dst;
|
||||
struct net_device *dev = dst->dev;
|
||||
unsigned int hh_len = LL_RESERVED_SPACE(dev);
|
||||
struct neighbour *neigh;
|
||||
u32 nexthop;
|
||||
......
|
||||
nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)->daddr);
|
||||
neigh = __ipv4_neigh_lookup_noref(dev, nexthop);
|
||||
if (unlikely(!neigh))
|
||||
neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);
|
||||
if (!IS_ERR(neigh)) {
|
||||
int res;
|
||||
sock_confirm_neigh(skb, neigh);
|
||||
res = neigh_output(neigh, skb);
|
||||
return res;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ip_finish_output2中,先找到struct rtable路由表里面的下一跳,下一跳一定和本机在同一个局域网中,可以通过二层进行通信,因而通过__ipv4_neigh_lookup_noref,查找如何通过二层访问下一跳。
|
||||
|
||||
```
|
||||
static inline struct neighbour *__ipv4_neigh_lookup_noref(struct net_device *dev, u32 key)
|
||||
{
|
||||
return ___neigh_lookup_noref(&arp_tbl, neigh_key_eq32, arp_hashfn, &key, dev);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
__ipv4_neigh_lookup_noref是从本地的ARP表中查找下一跳的MAC地址。ARP表的定义如下:
|
||||
|
||||
```
|
||||
struct neigh_table arp_tbl = {
|
||||
.family = AF_INET,
|
||||
.key_len = 4,
|
||||
.protocol = cpu_to_be16(ETH_P_IP),
|
||||
.hash = arp_hash,
|
||||
.key_eq = arp_key_eq,
|
||||
.constructor = arp_constructor,
|
||||
.proxy_redo = parp_redo,
|
||||
.id = "arp_cache",
|
||||
......
|
||||
.gc_interval = 30 * HZ,
|
||||
.gc_thresh1 = 128,
|
||||
.gc_thresh2 = 512,
|
||||
.gc_thresh3 = 1024,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
如果在ARP表中没有找到相应的项,则调用__neigh_create进行创建。
|
||||
|
||||
```
|
||||
struct neighbour *__neigh_create(struct neigh_table *tbl, const void *pkey, struct net_device *dev, bool want_ref)
|
||||
{
|
||||
u32 hash_val;
|
||||
int key_len = tbl->key_len;
|
||||
int error;
|
||||
struct neighbour *n1, *rc, *n = neigh_alloc(tbl, dev);
|
||||
struct neigh_hash_table *nht;
|
||||
|
||||
memcpy(n->primary_key, pkey, key_len);
|
||||
n->dev = dev;
|
||||
dev_hold(dev);
|
||||
|
||||
/* Protocol specific setup. */
|
||||
if (tbl->constructor && (error = tbl->constructor(n)) < 0) {
|
||||
......
|
||||
}
|
||||
......
|
||||
if (atomic_read(&tbl->entries) > (1 << nht->hash_shift))
|
||||
nht = neigh_hash_grow(tbl, nht->hash_shift + 1);
|
||||
|
||||
hash_val = tbl->hash(pkey, dev, nht->hash_rnd) >> (32 - nht->hash_shift);
|
||||
|
||||
for (n1 = rcu_dereference_protected(nht->hash_buckets[hash_val],
|
||||
lockdep_is_held(&tbl->lock));
|
||||
n1 != NULL;
|
||||
n1 = rcu_dereference_protected(n1->next,
|
||||
lockdep_is_held(&tbl->lock))) {
|
||||
if (dev == n1->dev && !memcmp(n1->primary_key, pkey, key_len)) {
|
||||
if (want_ref)
|
||||
neigh_hold(n1);
|
||||
rc = n1;
|
||||
goto out_tbl_unlock;
|
||||
}
|
||||
}
|
||||
......
|
||||
rcu_assign_pointer(n->next,
|
||||
rcu_dereference_protected(nht->hash_buckets[hash_val],
|
||||
lockdep_is_held(&tbl->lock)));
|
||||
rcu_assign_pointer(nht->hash_buckets[hash_val], n);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
__neigh_create先调用neigh_alloc,创建一个struct neighbour结构,用于维护MAC地址和ARP相关的信息。这个名字也很好理解,大家都是在一个局域网里面,可以通过MAC地址访问到,当然是邻居了。
|
||||
|
||||
```
|
||||
static struct neighbour *neigh_alloc(struct neigh_table *tbl, struct net_device *dev)
|
||||
{
|
||||
struct neighbour *n = NULL;
|
||||
unsigned long now = jiffies;
|
||||
int entries;
|
||||
......
|
||||
n = kzalloc(tbl->entry_size + dev->neigh_priv_len, GFP_ATOMIC);
|
||||
if (!n)
|
||||
goto out_entries;
|
||||
|
||||
__skb_queue_head_init(&n->arp_queue);
|
||||
rwlock_init(&n->lock);
|
||||
seqlock_init(&n->ha_lock);
|
||||
n->updated = n->used = now;
|
||||
n->nud_state = NUD_NONE;
|
||||
n->output = neigh_blackhole;
|
||||
seqlock_init(&n->hh.hh_lock);
|
||||
n->parms = neigh_parms_clone(&tbl->parms);
|
||||
setup_timer(&n->timer, neigh_timer_handler, (unsigned long)n);
|
||||
|
||||
NEIGH_CACHE_STAT_INC(tbl, allocs);
|
||||
n->tbl = tbl;
|
||||
refcount_set(&n->refcnt, 1);
|
||||
n->dead = 1;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在neigh_alloc中,我们先分配一个struct neighbour结构并且初始化。这里面比较重要的有两个成员,一个是arp_queue,所以上层想通过ARP获取MAC地址的任务,都放在这个队列里面。另一个是timer定时器,我们设置成,过一段时间就调用neigh_timer_handler,来处理这些ARP任务。
|
||||
|
||||
__neigh_create然后调用了arp_tbl的constructor函数,也即调用了arp_constructor,在这里面定义了ARP的操作arp_hh_ops。
|
||||
|
||||
```
|
||||
static int arp_constructor(struct neighbour *neigh)
|
||||
{
|
||||
__be32 addr = *(__be32 *)neigh->primary_key;
|
||||
struct net_device *dev = neigh->dev;
|
||||
struct in_device *in_dev;
|
||||
struct neigh_parms *parms;
|
||||
......
|
||||
neigh->type = inet_addr_type_dev_table(dev_net(dev), dev, addr);
|
||||
|
||||
parms = in_dev->arp_parms;
|
||||
__neigh_parms_put(neigh->parms);
|
||||
neigh->parms = neigh_parms_clone(parms);
|
||||
......
|
||||
neigh->ops = &arp_hh_ops;
|
||||
......
|
||||
neigh->output = neigh->ops->output;
|
||||
......
|
||||
}
|
||||
|
||||
static const struct neigh_ops arp_hh_ops = {
|
||||
.family = AF_INET,
|
||||
.solicit = arp_solicit,
|
||||
.error_report = arp_error_report,
|
||||
.output = neigh_resolve_output,
|
||||
.connected_output = neigh_resolve_output,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
__neigh_create最后是将创建的struct neighbour结构放入一个哈希表,从里面的代码逻辑比较容易看出,这是一个数组加链表的链式哈希表,先计算出哈希值hash_val,得到相应的链表,然后循环这个链表找到对应的项,如果找不到就在最后插入一项。
|
||||
|
||||
我们回到ip_finish_output2,在__neigh_create之后,会调用neigh_output发送网络包。
|
||||
|
||||
```
|
||||
static inline int neigh_output(struct neighbour *n, struct sk_buff *skb)
|
||||
{
|
||||
......
|
||||
return n->output(n, skb);
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
按照上面对于struct neighbour的操作函数arp_hh_ops 的定义,output调用的是neigh_resolve_output。
|
||||
|
||||
```
|
||||
int neigh_resolve_output(struct neighbour *neigh, struct sk_buff *skb)
|
||||
{
|
||||
if (!neigh_event_send(neigh, skb)) {
|
||||
......
|
||||
rc = dev_queue_xmit(skb);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在neigh_resolve_output里面,首先neigh_event_send触发一个事件,看能否激活ARP。
|
||||
|
||||
```
|
||||
int __neigh_event_send(struct neighbour *neigh, struct sk_buff *skb)
|
||||
{
|
||||
int rc;
|
||||
bool immediate_probe = false;
|
||||
|
||||
if (!(neigh->nud_state & (NUD_STALE | NUD_INCOMPLETE))) {
|
||||
if (NEIGH_VAR(neigh->parms, MCAST_PROBES) +
|
||||
NEIGH_VAR(neigh->parms, APP_PROBES)) {
|
||||
unsigned long next, now = jiffies;
|
||||
|
||||
atomic_set(&neigh->probes,
|
||||
NEIGH_VAR(neigh->parms, UCAST_PROBES));
|
||||
neigh->nud_state = NUD_INCOMPLETE;
|
||||
neigh->updated = now;
|
||||
next = now + max(NEIGH_VAR(neigh->parms, RETRANS_TIME),
|
||||
HZ/2);
|
||||
neigh_add_timer(neigh, next);
|
||||
immediate_probe = true;
|
||||
}
|
||||
......
|
||||
} else if (neigh->nud_state & NUD_STALE) {
|
||||
neigh_dbg(2, "neigh %p is delayed\n", neigh);
|
||||
neigh->nud_state = NUD_DELAY;
|
||||
neigh->updated = jiffies;
|
||||
neigh_add_timer(neigh, jiffies +
|
||||
NEIGH_VAR(neigh->parms, DELAY_PROBE_TIME));
|
||||
}
|
||||
|
||||
if (neigh->nud_state == NUD_INCOMPLETE) {
|
||||
if (skb) {
|
||||
.......
|
||||
__skb_queue_tail(&neigh->arp_queue, skb);
|
||||
neigh->arp_queue_len_Bytes += skb->truesize;
|
||||
}
|
||||
rc = 1;
|
||||
}
|
||||
out_unlock_bh:
|
||||
if (immediate_probe)
|
||||
neigh_probe(neigh);
|
||||
.......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在__neigh_event_send中,激活ARP分两种情况,第一种情况是马上激活,也即immediate_probe。另一种情况是延迟激活则仅仅设置一个timer。然后将ARP包放在arp_queue上。如果马上激活,就直接调用neigh_probe;如果延迟激活,则定时器到了就会触发neigh_timer_handler,在这里面还是会调用neigh_probe。
|
||||
|
||||
我们就来看neigh_probe的实现,在这里面会从arp_queue中拿出ARP包来,然后调用struct neighbour的solicit操作。
|
||||
|
||||
```
|
||||
static void neigh_probe(struct neighbour *neigh)
|
||||
__releases(neigh->lock)
|
||||
{
|
||||
struct sk_buff *skb = skb_peek_tail(&neigh->arp_queue);
|
||||
......
|
||||
if (neigh->ops->solicit)
|
||||
neigh->ops->solicit(neigh, skb);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
按照上面对于struct neighbour的操作函数arp_hh_ops 的定义,solicit调用的是arp_solicit,在这里我们可以找到对于arp_send_dst的调用,创建并发送一个arp包,得到结果放在struct dst_entry里面。
|
||||
|
||||
```
|
||||
static void arp_send_dst(int type, int ptype, __be32 dest_ip,
|
||||
struct net_device *dev, __be32 src_ip,
|
||||
const unsigned char *dest_hw,
|
||||
const unsigned char *src_hw,
|
||||
const unsigned char *target_hw,
|
||||
struct dst_entry *dst)
|
||||
{
|
||||
struct sk_buff *skb;
|
||||
......
|
||||
skb = arp_create(type, ptype, dest_ip, dev, src_ip,
|
||||
dest_hw, src_hw, target_hw);
|
||||
......
|
||||
skb_dst_set(skb, dst_clone(dst));
|
||||
arp_xmit(skb);
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
我们回到neigh_resolve_output中,当ARP发送完毕,就可以调用dev_queue_xmit发送二层网络包了。
|
||||
|
||||
```
|
||||
/**
|
||||
* __dev_queue_xmit - transmit a buffer
|
||||
* @skb: buffer to transmit
|
||||
* @accel_priv: private data used for L2 forwarding offload
|
||||
*
|
||||
* Queue a buffer for transmission to a network device.
|
||||
*/
|
||||
static int __dev_queue_xmit(struct sk_buff *skb, void *accel_priv)
|
||||
{
|
||||
struct net_device *dev = skb->dev;
|
||||
struct netdev_queue *txq;
|
||||
struct Qdisc *q;
|
||||
......
|
||||
txq = netdev_pick_tx(dev, skb, accel_priv);
|
||||
q = rcu_dereference_bh(txq->qdisc);
|
||||
|
||||
if (q->enqueue) {
|
||||
rc = __dev_xmit_skb(skb, q, dev, txq);
|
||||
goto out;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
就像咱们在讲述硬盘块设备的时候讲过,每个块设备都有队列,用于将内核的数据放到队列里面,然后设备驱动从队列里面取出后,将数据根据具体设备的特性发送给设备。
|
||||
|
||||
网络设备也是类似的,对于发送来说,有一个发送队列struct netdev_queue *txq。
|
||||
|
||||
这里还有另一个变量叫做struct Qdisc,这个是什么呢?如果我们在一台Linux机器上运行ip addr,我们能看到对于一个网卡,都有下面的输出。
|
||||
|
||||
```
|
||||
# ip addr
|
||||
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
|
||||
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
||||
inet 127.0.0.1/8 scope host lo
|
||||
valid_lft forever preferred_lft forever
|
||||
inet6 ::1/128 scope host
|
||||
valid_lft forever preferred_lft forever
|
||||
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1400 qdisc pfifo_fast state UP group default qlen 1000
|
||||
link/ether fa:16:3e:75:99:08 brd ff:ff:ff:ff:ff:ff
|
||||
inet 10.173.32.47/21 brd 10.173.39.255 scope global noprefixroute dynamic eth0
|
||||
valid_lft 67104sec preferred_lft 67104sec
|
||||
inet6 fe80::f816:3eff:fe75:9908/64 scope link
|
||||
valid_lft forever preferred_lft forever
|
||||
|
||||
```
|
||||
|
||||
这里面有个关键字qdisc pfifo_fast是什么意思呢?qdisc全称是queueing discipline,中文叫排队规则。内核如果需要通过某个网络接口发送数据包,都需要按照为这个接口配置的qdisc(排队规则)把数据包加入队列。
|
||||
|
||||
最简单的qdisc是pfifo,它不对进入的数据包做任何的处理,数据包采用先入先出的方式通过队列。pfifo_fast稍微复杂一些,它的队列包括三个波段(band)。在每个波段里面,使用先进先出规则。
|
||||
|
||||
三个波段的优先级也不相同。band 0的优先级最高,band 2的最低。如果band 0里面有数据包,系统就不会处理band 1里面的数据包,band 1和band 2之间也是一样。
|
||||
|
||||
数据包是按照服务类型(Type of Service,TOS)被分配到三个波段里面的。TOS是IP头里面的一个字段,代表了当前的包是高优先级的,还是低优先级的。
|
||||
|
||||
pfifo_fast分为三个先入先出的队列,我们能称为三个Band。根据网络包里面的TOS,看这个包到底应该进入哪个队列。TOS总共四位,每一位表示的意思不同,总共十六种类型。
|
||||
|
||||
<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->enqueue(skb, q, &to_free) & NET_XMIT_MASK;
|
||||
if (qdisc_run_begin(q)) {
|
||||
......
|
||||
__qdisc_run(q);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
void __qdisc_run(struct Qdisc *q)
|
||||
{
|
||||
int quota = dev_tx_weight;
|
||||
int packets;
|
||||
while (qdisc_restart(q, &packets)) {
|
||||
/*
|
||||
* Ordered by possible occurrence: Postpone processing if
|
||||
* 1. we've exceeded packet quota
|
||||
* 2. another process needs the CPU;
|
||||
*/
|
||||
quota -= packets;
|
||||
if (quota <= 0 || need_resched()) {
|
||||
__netif_schedule(q);
|
||||
break;
|
||||
}
|
||||
}
|
||||
qdisc_run_end(q);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
__dev_xmit_skb会将请求放入队列,然后调用__qdisc_run处理队列中的数据。qdisc_restart用于数据的发送。根据注释中的说法,qdisc的另一个功能是用于控制网络包的发送速度,因而如果超过速度,就需要重新调度,则会调用__netif_schedule。
|
||||
|
||||
```
|
||||
static void __netif_reschedule(struct Qdisc *q)
|
||||
{
|
||||
struct softnet_data *sd;
|
||||
unsigned long flags;
|
||||
local_irq_save(flags);
|
||||
sd = this_cpu_ptr(&softnet_data);
|
||||
q->next_sched = NULL;
|
||||
*sd->output_queue_tailp = q;
|
||||
sd->output_queue_tailp = &q->next_sched;
|
||||
raise_softirq_irqoff(NET_TX_SOFTIRQ);
|
||||
local_irq_restore(flags);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
__netif_schedule会调用__netif_reschedule,发起一个软中断NET_TX_SOFTIRQ。咱们讲设备驱动程序的时候讲过,设备驱动程序处理中断,分两个过程,一个是屏蔽中断的关键处理逻辑,一个是延迟处理逻辑。当时说工作队列是延迟处理逻辑的处理方案,软中断也是一种方案。
|
||||
|
||||
在系统初始化的时候,我们会定义软中断的处理函数。例如,NET_TX_SOFTIRQ的处理函数是net_tx_action,用于发送网络包。还有一个NET_RX_SOFTIRQ的处理函数是net_rx_action,用于接收网络包。接收网络包的过程咱们下一节解析。
|
||||
|
||||
```
|
||||
open_softirq(NET_TX_SOFTIRQ, net_tx_action);
|
||||
open_softirq(NET_RX_SOFTIRQ, net_rx_action);
|
||||
|
||||
|
||||
```
|
||||
|
||||
这里我们来解析一下net_tx_action。
|
||||
|
||||
```
|
||||
static __latent_entropy void net_tx_action(struct softirq_action *h)
|
||||
{
|
||||
struct softnet_data *sd = this_cpu_ptr(&softnet_data);
|
||||
......
|
||||
if (sd->output_queue) {
|
||||
struct Qdisc *head;
|
||||
|
||||
local_irq_disable();
|
||||
head = sd->output_queue;
|
||||
sd->output_queue = NULL;
|
||||
sd->output_queue_tailp = &sd->output_queue;
|
||||
local_irq_enable();
|
||||
|
||||
while (head) {
|
||||
struct Qdisc *q = head;
|
||||
spinlock_t *root_lock;
|
||||
|
||||
head = head->next_sched;
|
||||
......
|
||||
qdisc_run(q);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们会发现,net_tx_action还是调用了qdisc_run,还是会调用__qdisc_run,然后调用qdisc_restart发送网络包。
|
||||
|
||||
我们来看一下qdisc_restart的实现。
|
||||
|
||||
```
|
||||
static inline int qdisc_restart(struct Qdisc *q, int *packets)
|
||||
{
|
||||
struct netdev_queue *txq;
|
||||
struct net_device *dev;
|
||||
spinlock_t *root_lock;
|
||||
struct sk_buff *skb;
|
||||
bool validate;
|
||||
|
||||
/* Dequeue packet */
|
||||
skb = dequeue_skb(q, &validate, packets);
|
||||
if (unlikely(!skb))
|
||||
return 0;
|
||||
|
||||
root_lock = qdisc_lock(q);
|
||||
dev = qdisc_dev(q);
|
||||
txq = skb_get_tx_queue(dev, skb);
|
||||
|
||||
return sch_direct_xmit(skb, q, dev, txq, root_lock, validate);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
qdisc_restart将网络包从Qdisc的队列中拿下来,然后调用sch_direct_xmit进行发送。
|
||||
|
||||
```
|
||||
int sch_direct_xmit(struct sk_buff *skb, struct Qdisc *q,
|
||||
struct net_device *dev, struct netdev_queue *txq,
|
||||
spinlock_t *root_lock, bool validate)
|
||||
{
|
||||
int ret = NETDEV_TX_BUSY;
|
||||
|
||||
if (likely(skb)) {
|
||||
if (!netif_xmit_frozen_or_stopped(txq))
|
||||
skb = dev_hard_start_xmit(skb, dev, txq, &ret);
|
||||
}
|
||||
......
|
||||
if (dev_xmit_complete(ret)) {
|
||||
/* Driver sent out skb successfully or skb was consumed */
|
||||
ret = qdisc_qlen(q);
|
||||
} else {
|
||||
/* Driver returned NETDEV_TX_BUSY - requeue skb */
|
||||
ret = dev_requeue_skb(skb, q);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在sch_direct_xmit中,调用dev_hard_start_xmit进行发送,如果发送不成功,会返回NETDEV_TX_BUSY。这说明网络卡很忙,于是就调用dev_requeue_skb,重新放入队列。
|
||||
|
||||
```
|
||||
struct sk_buff *dev_hard_start_xmit(struct sk_buff *first, struct net_device *dev, struct netdev_queue *txq, int *ret)
|
||||
{
|
||||
struct sk_buff *skb = first;
|
||||
int rc = NETDEV_TX_OK;
|
||||
|
||||
while (skb) {
|
||||
struct sk_buff *next = skb->next;
|
||||
rc = xmit_one(skb, dev, txq, next != NULL);
|
||||
skb = next;
|
||||
if (netif_xmit_stopped(txq) && skb) {
|
||||
rc = NETDEV_TX_BUSY;
|
||||
break;
|
||||
}
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在dev_hard_start_xmit中,是一个while循环。每次在队列中取出一个sk_buff,调用xmit_one发送。
|
||||
|
||||
接下来的调用链为:xmit_one->netdev_start_xmit->__netdev_start_xmit。
|
||||
|
||||
```
|
||||
static inline netdev_tx_t __netdev_start_xmit(const struct net_device_ops *ops, struct sk_buff *skb, struct net_device *dev, bool more)
|
||||
{
|
||||
skb->xmit_more = more ? 1 : 0;
|
||||
return ops->ndo_start_xmit(skb, dev);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个时候,已经到了设备驱动层了。我们能看到,drivers/net/ethernet/intel/ixgb/ixgb_main.c里面有对于这个网卡的操作的定义。
|
||||
|
||||
```
|
||||
static const struct net_device_ops ixgb_netdev_ops = {
|
||||
.ndo_open = ixgb_open,
|
||||
.ndo_stop = ixgb_close,
|
||||
.ndo_start_xmit = ixgb_xmit_frame,
|
||||
.ndo_set_rx_mode = ixgb_set_multi,
|
||||
.ndo_validate_addr = eth_validate_addr,
|
||||
.ndo_set_mac_address = ixgb_set_mac,
|
||||
.ndo_change_mtu = ixgb_change_mtu,
|
||||
.ndo_tx_timeout = ixgb_tx_timeout,
|
||||
.ndo_vlan_rx_add_vid = ixgb_vlan_rx_add_vid,
|
||||
.ndo_vlan_rx_kill_vid = ixgb_vlan_rx_kill_vid,
|
||||
.ndo_fix_features = ixgb_fix_features,
|
||||
.ndo_set_features = ixgb_set_features,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在这里面,我们可以找到对于ndo_start_xmit的定义,调用ixgb_xmit_frame。
|
||||
|
||||
```
|
||||
static netdev_tx_t
|
||||
ixgb_xmit_frame(struct sk_buff *skb, struct net_device *netdev)
|
||||
{
|
||||
struct ixgb_adapter *adapter = netdev_priv(netdev);
|
||||
......
|
||||
if (count) {
|
||||
ixgb_tx_queue(adapter, count, vlan_id, tx_flags);
|
||||
/* Make sure there is space in the ring for the next send. */
|
||||
ixgb_maybe_stop_tx(netdev, &adapter->tx_ring, DESC_NEEDED);
|
||||
|
||||
}
|
||||
......
|
||||
return NETDEV_TX_OK;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ixgb_xmit_frame中,我们会得到这个网卡对应的适配器,然后将其放入硬件网卡的队列中。
|
||||
|
||||
至此,整个发送才算结束。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们继续解析了发送一个网络包的过程,我们整个过程的图画在了下面。
|
||||
|
||||
<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="">
|
||||
@@ -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 = &ixgb_err_handler
|
||||
};
|
||||
|
||||
MODULE_AUTHOR("Intel Corporation, <linux.nics@intel.com>");
|
||||
MODULE_DESCRIPTION("Intel(R) PRO/10GbE Network Driver");
|
||||
MODULE_LICENSE("GPL");
|
||||
MODULE_VERSION(DRV_VERSION);
|
||||
|
||||
/**
|
||||
* ixgb_init_module - Driver Registration Routine
|
||||
*
|
||||
* ixgb_init_module is the first routine called when the driver is
|
||||
* loaded. All it does is register with the PCI subsystem.
|
||||
**/
|
||||
|
||||
static int __init
|
||||
ixgb_init_module(void)
|
||||
{
|
||||
pr_info("%s - version %s\n", ixgb_driver_string, ixgb_driver_version);
|
||||
pr_info("%s\n", ixgb_copyright);
|
||||
|
||||
return pci_register_driver(&ixgb_driver);
|
||||
}
|
||||
|
||||
module_init(ixgb_init_module);
|
||||
|
||||
```
|
||||
|
||||
在网卡驱动程序初始化的时候,我们会调用ixgb_init_module,注册一个驱动ixgb_driver,并且调用它的probe函数ixgb_probe。
|
||||
|
||||
```
|
||||
static int
|
||||
ixgb_probe(struct pci_dev *pdev, const struct pci_device_id *ent)
|
||||
{
|
||||
struct net_device *netdev = NULL;
|
||||
struct ixgb_adapter *adapter;
|
||||
......
|
||||
netdev = alloc_etherdev(sizeof(struct ixgb_adapter));
|
||||
SET_NETDEV_DEV(netdev, &pdev->dev);
|
||||
|
||||
pci_set_drvdata(pdev, netdev);
|
||||
adapter = netdev_priv(netdev);
|
||||
adapter->netdev = netdev;
|
||||
adapter->pdev = pdev;
|
||||
adapter->hw.back = adapter;
|
||||
adapter->msg_enable = netif_msg_init(debug, DEFAULT_MSG_ENABLE);
|
||||
|
||||
adapter->hw.hw_addr = pci_ioremap_bar(pdev, BAR_0);
|
||||
......
|
||||
netdev->netdev_ops = &ixgb_netdev_ops;
|
||||
ixgb_set_ethtool_ops(netdev);
|
||||
netdev->watchdog_timeo = 5 * HZ;
|
||||
netif_napi_add(netdev, &adapter->napi, ixgb_clean, 64);
|
||||
|
||||
strncpy(netdev->name, pci_name(pdev), sizeof(netdev->name) - 1);
|
||||
|
||||
adapter->bd_number = cards_found;
|
||||
adapter->link_speed = 0;
|
||||
adapter->link_duplex = 0;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ixgb_probe中,我们会创建一个struct net_device表示这个网络设备,并且netif_napi_add函数为这个网络设备注册一个轮询poll函数ixgb_clean,将来一旦出现网络包的时候,就是要通过它来轮询了。
|
||||
|
||||
当一个网卡被激活的时候,我们会调用函数ixgb_open->ixgb_up,在这里面注册一个硬件的中断处理函数。
|
||||
|
||||
```
|
||||
int
|
||||
ixgb_up(struct ixgb_adapter *adapter)
|
||||
{
|
||||
struct net_device *netdev = adapter->netdev;
|
||||
......
|
||||
err = request_irq(adapter->pdev->irq, ixgb_intr, irq_flags,
|
||||
netdev->name, netdev);
|
||||
......
|
||||
}
|
||||
|
||||
/**
|
||||
* ixgb_intr - Interrupt Handler
|
||||
* @irq: interrupt number
|
||||
* @data: pointer to a network interface device structure
|
||||
**/
|
||||
|
||||
static irqreturn_t
|
||||
ixgb_intr(int irq, void *data)
|
||||
{
|
||||
struct net_device *netdev = data;
|
||||
struct ixgb_adapter *adapter = netdev_priv(netdev);
|
||||
struct ixgb_hw *hw = &adapter->hw;
|
||||
......
|
||||
if (napi_schedule_prep(&adapter->napi)) {
|
||||
IXGB_WRITE_REG(&adapter->hw, IMC, ~0);
|
||||
__napi_schedule(&adapter->napi);
|
||||
}
|
||||
return IRQ_HANDLED;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果一个网络包到来,触发了硬件中断,就会调用ixgb_intr,这里面会调用__napi_schedule。
|
||||
|
||||
```
|
||||
/**
|
||||
* __napi_schedule - schedule for receive
|
||||
* @n: entry to schedule
|
||||
*
|
||||
* The entry's receive function will be scheduled to run.
|
||||
* Consider using __napi_schedule_irqoff() if hard irqs are masked.
|
||||
*/
|
||||
void __napi_schedule(struct napi_struct *n)
|
||||
{
|
||||
unsigned long flags;
|
||||
|
||||
local_irq_save(flags);
|
||||
____napi_schedule(this_cpu_ptr(&softnet_data), n);
|
||||
local_irq_restore(flags);
|
||||
}
|
||||
|
||||
static inline void ____napi_schedule(struct softnet_data *sd,
|
||||
struct napi_struct *napi)
|
||||
{
|
||||
list_add_tail(&napi->poll_list, &sd->poll_list);
|
||||
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
__napi_schedule是处于中断处理的关键部分,在他被调用的时候,中断是暂时关闭的,但是处理网络包是个复杂的过程,需要到延迟处理部分,所以____napi_schedule将当前设备放到struct softnet_data结构的poll_list里面,说明在延迟处理部分可以接着处理这个poll_list里面的网络设备。
|
||||
|
||||
然后____napi_schedule触发一个软中断NET_RX_SOFTIRQ,通过软中断触发中断处理的延迟处理部分,也是常用的手段。
|
||||
|
||||
上一节,我们知道,软中断NET_RX_SOFTIRQ对应的中断处理函数是net_rx_action。
|
||||
|
||||
```
|
||||
static __latent_entropy void net_rx_action(struct softirq_action *h)
|
||||
{
|
||||
struct softnet_data *sd = this_cpu_ptr(&softnet_data);
|
||||
LIST_HEAD(list);
|
||||
list_splice_init(&sd->poll_list, &list);
|
||||
......
|
||||
for (;;) {
|
||||
struct napi_struct *n;
|
||||
......
|
||||
n = list_first_entry(&list, struct napi_struct, poll_list);
|
||||
budget -= napi_poll(n, &repoll);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在net_rx_action中,会得到struct softnet_data结构,这个结构在发送的时候我们也遇到过。当时它的output_queue用于网络包的发送,这里的poll_list用于网络包的接收。
|
||||
|
||||
```
|
||||
struct softnet_data {
|
||||
struct list_head poll_list;
|
||||
......
|
||||
struct Qdisc *output_queue;
|
||||
struct Qdisc **output_queue_tailp;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在net_rx_action中,接下来是一个循环,在poll_list里面取出网络包到达的设备,然后调用napi_poll来轮询这些设备,napi_poll会调用最初设备初始化的时候,注册的poll函数,对于ixgb_driver,对应的函数是ixgb_clean。
|
||||
|
||||
ixgb_clean会调用ixgb_clean_rx_irq。
|
||||
|
||||
```
|
||||
static bool
|
||||
ixgb_clean_rx_irq(struct ixgb_adapter *adapter, int *work_done, int work_to_do)
|
||||
{
|
||||
struct ixgb_desc_ring *rx_ring = &adapter->rx_ring;
|
||||
struct net_device *netdev = adapter->netdev;
|
||||
struct pci_dev *pdev = adapter->pdev;
|
||||
struct ixgb_rx_desc *rx_desc, *next_rxd;
|
||||
struct ixgb_buffer *buffer_info, *next_buffer, *next2_buffer;
|
||||
u32 length;
|
||||
unsigned int i, j;
|
||||
int cleaned_count = 0;
|
||||
bool cleaned = false;
|
||||
|
||||
i = rx_ring->next_to_clean;
|
||||
rx_desc = IXGB_RX_DESC(*rx_ring, i);
|
||||
buffer_info = &rx_ring->buffer_info[i];
|
||||
|
||||
while (rx_desc->status & IXGB_RX_DESC_STATUS_DD) {
|
||||
struct sk_buff *skb;
|
||||
u8 status;
|
||||
|
||||
status = rx_desc->status;
|
||||
skb = buffer_info->skb;
|
||||
buffer_info->skb = NULL;
|
||||
|
||||
prefetch(skb->data - NET_IP_ALIGN);
|
||||
|
||||
if (++i == rx_ring->count)
|
||||
i = 0;
|
||||
next_rxd = IXGB_RX_DESC(*rx_ring, i);
|
||||
prefetch(next_rxd);
|
||||
|
||||
j = i + 1;
|
||||
if (j == rx_ring->count)
|
||||
j = 0;
|
||||
next2_buffer = &rx_ring->buffer_info[j];
|
||||
prefetch(next2_buffer);
|
||||
|
||||
next_buffer = &rx_ring->buffer_info[i];
|
||||
......
|
||||
length = le16_to_cpu(rx_desc->length);
|
||||
rx_desc->length = 0;
|
||||
......
|
||||
ixgb_check_copybreak(&adapter->napi, buffer_info, length, &skb);
|
||||
|
||||
/* Good Receive */
|
||||
skb_put(skb, length);
|
||||
|
||||
/* Receive Checksum Offload */
|
||||
ixgb_rx_checksum(adapter, rx_desc, skb);
|
||||
|
||||
skb->protocol = eth_type_trans(skb, netdev);
|
||||
|
||||
netif_receive_skb(skb);
|
||||
......
|
||||
/* use prefetched values */
|
||||
rx_desc = next_rxd;
|
||||
buffer_info = next_buffer;
|
||||
}
|
||||
|
||||
rx_ring->next_to_clean = i;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在网络设备的驱动层,有一个用于接收网络包的rx_ring。它是一个环,从网卡硬件接收的包会放在这个环里面。这个环里面的buffer_info[]是一个数组,存放的是网络包的内容。i和j是这个数组的下标,在ixgb_clean_rx_irq里面的while循环中,依次处理环里面的数据。在这里面,我们看到了i和j加一之后,如果超过了数组的大小,就跳回下标0,就说明这是一个环。
|
||||
|
||||
ixgb_check_copybreak函数将buffer_info里面的内容,拷贝到struct sk_buff *skb,从而可以作为一个网络包进行后续的处理,然后调用netif_receive_skb。
|
||||
|
||||
## 网络协议栈的二层逻辑
|
||||
|
||||
从netif_receive_skb函数开始,我们就进入了内核的网络协议栈。
|
||||
|
||||
接下来的调用链为:netif_receive_skb->netif_receive_skb_internal->__netif_receive_skb->__netif_receive_skb_core。
|
||||
|
||||
在__netif_receive_skb_core中,我们先是处理了二层的一些逻辑。例如,对于VLAN的处理,接下来要想办法交给第三层。
|
||||
|
||||
```
|
||||
static int __netif_receive_skb_core(struct sk_buff *skb, bool pfmemalloc)
|
||||
{
|
||||
struct packet_type *ptype, *pt_prev;
|
||||
......
|
||||
type = skb->protocol;
|
||||
......
|
||||
deliver_ptype_list_skb(skb, &pt_prev, orig_dev, type,
|
||||
&orig_dev->ptype_specific);
|
||||
if (pt_prev) {
|
||||
ret = pt_prev->func(skb, skb->dev, pt_prev, orig_dev);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
static inline void deliver_ptype_list_skb(struct sk_buff *skb,
|
||||
struct packet_type **pt,
|
||||
struct net_device *orig_dev,
|
||||
__be16 type,
|
||||
struct list_head *ptype_list)
|
||||
{
|
||||
struct packet_type *ptype, *pt_prev = *pt;
|
||||
|
||||
list_for_each_entry_rcu(ptype, ptype_list, list) {
|
||||
if (ptype->type != type)
|
||||
continue;
|
||||
if (pt_prev)
|
||||
deliver_skb(skb, pt_prev, orig_dev);
|
||||
pt_prev = ptype;
|
||||
}
|
||||
*pt = pt_prev;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在网络包struct sk_buff里面,二层的头里面有一个protocol,表示里面一层,也即三层是什么协议。deliver_ptype_list_skb在一个协议列表中逐个匹配。如果能够匹配到,就返回。
|
||||
|
||||
这些协议的注册在网络协议栈初始化的时候, inet_init函数调用dev_add_pack(&ip_packet_type),添加IP协议。协议被放在一个链表里面。
|
||||
|
||||
```
|
||||
void dev_add_pack(struct packet_type *pt)
|
||||
{
|
||||
struct list_head *head = ptype_head(pt);
|
||||
list_add_rcu(&pt->list, head);
|
||||
}
|
||||
|
||||
static inline struct list_head *ptype_head(const struct packet_type *pt)
|
||||
{
|
||||
if (pt->type == htons(ETH_P_ALL))
|
||||
return pt->dev ? &pt->dev->ptype_all : &ptype_all;
|
||||
else
|
||||
return pt->dev ? &pt->dev->ptype_specific : &ptype_base[ntohs(pt->type) & PTYPE_HASH_MASK];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
假设这个时候的网络包是一个IP包,则在这个链表里面一定能够找到ip_packet_type,在__netif_receive_skb_core中会调用ip_packet_type的func函数。
|
||||
|
||||
```
|
||||
static struct packet_type ip_packet_type __read_mostly = {
|
||||
.type = cpu_to_be16(ETH_P_IP),
|
||||
.func = ip_rcv,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
从上面的定义我们可以看出,接下来,ip_rcv会被调用。
|
||||
|
||||
## 网络协议栈的IP层
|
||||
|
||||
从ip_rcv函数开始,我们的处理逻辑就从二层到了三层,IP层。
|
||||
|
||||
```
|
||||
int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev)
|
||||
{
|
||||
const struct iphdr *iph;
|
||||
struct net *net;
|
||||
u32 len;
|
||||
......
|
||||
net = dev_net(dev);
|
||||
......
|
||||
iph = ip_hdr(skb);
|
||||
len = ntohs(iph->tot_len);
|
||||
skb->transport_header = skb->network_header + iph->ihl*4;
|
||||
......
|
||||
return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING,
|
||||
net, NULL, skb, dev, NULL,
|
||||
ip_rcv_finish);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ip_rcv中,得到IP头,然后又遇到了我们见过多次的NF_HOOK,这次因为是接收网络包,第一个hook点是NF_INET_PRE_ROUTING,也就是iptables的PREROUTING链。如果里面有规则,则执行规则,然后调用ip_rcv_finish。
|
||||
|
||||
```
|
||||
static int ip_rcv_finish(struct net *net, struct sock *sk, struct sk_buff *skb)
|
||||
{
|
||||
const struct iphdr *iph = ip_hdr(skb);
|
||||
struct net_device *dev = skb->dev;
|
||||
struct rtable *rt;
|
||||
int err;
|
||||
......
|
||||
rt = skb_rtable(skb);
|
||||
.....
|
||||
return dst_input(skb);
|
||||
}
|
||||
|
||||
static inline int dst_input(struct sk_buff *skb)
|
||||
{
|
||||
return skb_dst(skb)->input(skb);
|
||||
|
||||
|
||||
```
|
||||
|
||||
ip_rcv_finish得到网络包对应的路由表,然后调用dst_input,在dst_input中,调用的是struct rtable的成员的dst的input函数。在rt_dst_alloc中,我们可以看到,input函数指向的是ip_local_deliver。
|
||||
|
||||
```
|
||||
int ip_local_deliver(struct sk_buff *skb)
|
||||
{
|
||||
/*
|
||||
* Reassemble IP fragments.
|
||||
*/
|
||||
struct net *net = dev_net(skb->dev);
|
||||
|
||||
if (ip_is_fragment(ip_hdr(skb))) {
|
||||
if (ip_defrag(net, skb, IP_DEFRAG_LOCAL_DELIVER))
|
||||
return 0;
|
||||
}
|
||||
|
||||
return NF_HOOK(NFPROTO_IPV4, NF_INET_LOCAL_IN,
|
||||
net, NULL, skb, skb->dev, NULL,
|
||||
ip_local_deliver_finish);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ip_local_deliver函数中,如果IP层进行了分段,则进行重新的组合。接下来就是我们熟悉的NF_HOOK。hook点在NF_INET_LOCAL_IN,对应iptables里面的INPUT链。在经过iptables规则处理完毕后,我们调用ip_local_deliver_finish。
|
||||
|
||||
```
|
||||
static int ip_local_deliver_finish(struct net *net, struct sock *sk, struct sk_buff *skb)
|
||||
{
|
||||
__skb_pull(skb, skb_network_header_len(skb));
|
||||
|
||||
int protocol = ip_hdr(skb)->protocol;
|
||||
const struct net_protocol *ipprot;
|
||||
|
||||
ipprot = rcu_dereference(inet_protos[protocol]);
|
||||
if (ipprot) {
|
||||
int ret;
|
||||
ret = ipprot->handler(skb);
|
||||
......
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在IP头中,有一个字段protocol用于指定里面一层的协议,在这里应该是TCP协议。于是,从inet_protos数组中,找出TCP协议对应的处理函数。这个数组的定义如下,里面的内容是struct net_protocol。
|
||||
|
||||
```
|
||||
struct net_protocol __rcu *inet_protos[MAX_INET_PROTOS] __read_mostly;
|
||||
|
||||
int inet_add_protocol(const struct net_protocol *prot, unsigned char protocol)
|
||||
{
|
||||
......
|
||||
return !cmpxchg((const struct net_protocol **)&inet_protos[protocol],
|
||||
NULL, prot) ? 0 : -1;
|
||||
}
|
||||
|
||||
static int __init inet_init(void)
|
||||
{
|
||||
......
|
||||
if (inet_add_protocol(&udp_protocol, IPPROTO_UDP) < 0)
|
||||
pr_crit("%s: Cannot add UDP protocol\n", __func__);
|
||||
if (inet_add_protocol(&tcp_protocol, IPPROTO_TCP) < 0)
|
||||
pr_crit("%s: Cannot add TCP protocol\n", __func__);
|
||||
......
|
||||
}
|
||||
|
||||
static struct net_protocol tcp_protocol = {
|
||||
.early_demux = tcp_v4_early_demux,
|
||||
.early_demux_handler = tcp_v4_early_demux,
|
||||
.handler = tcp_v4_rcv,
|
||||
.err_handler = tcp_v4_err,
|
||||
.no_policy = 1,
|
||||
.netns_ok = 1,
|
||||
.icmp_strict_tag_validation = 1,
|
||||
};
|
||||
|
||||
static struct net_protocol udp_protocol = {
|
||||
.early_demux = udp_v4_early_demux,
|
||||
.early_demux_handler = udp_v4_early_demux,
|
||||
.handler = udp_rcv,
|
||||
.err_handler = udp_err,
|
||||
.no_policy = 1,
|
||||
.netns_ok = 1,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在系统初始化的时候,网络协议栈的初始化调用的是inet_init,它会调用inet_add_protocol,将TCP协议对应的处理函数tcp_protocol、UDP协议对应的处理函数udp_protocol,放到inet_protos数组中。
|
||||
|
||||
在上面的网络包的接收过程中,会取出TCP协议对应的处理函数tcp_protocol,然后调用handler函数,也即tcp_v4_rcv函数。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节我们讲了接收网络包的上半部分,分以下几个层次。
|
||||
|
||||
- 硬件网卡接收到网络包之后,通过DMA技术,将网络包放入Ring Buffer。
|
||||
- 硬件网卡通过中断通知CPU新的网络包的到来。
|
||||
- 网卡驱动程序会注册中断处理函数ixgb_intr。
|
||||
- 中断处理函数处理完需要暂时屏蔽中断的核心流程之后,通过软中断NET_RX_SOFTIRQ触发接下来的处理过程。
|
||||
- NET_RX_SOFTIRQ软中断处理函数net_rx_action,net_rx_action会调用napi_poll,进而调用ixgb_clean_rx_irq,从Ring Buffer中读取数据到内核struct sk_buff。
|
||||
- 调用netif_receive_skb进入内核网络协议栈,进行一些关于VLAN的二层逻辑处理后,调用ip_rcv进入三层IP层。
|
||||
- 在IP层,会处理iptables规则,然后调用ip_local_deliver,交给更上层TCP层。
|
||||
- 在TCP层调用tcp_v4_rcv。
|
||||
|
||||
<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="">
|
||||
@@ -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->dev);
|
||||
const struct iphdr *iph;
|
||||
const struct tcphdr *th;
|
||||
bool refcounted;
|
||||
struct sock *sk;
|
||||
int ret;
|
||||
......
|
||||
th = (const struct tcphdr *)skb->data;
|
||||
iph = ip_hdr(skb);
|
||||
......
|
||||
TCP_SKB_CB(skb)->seq = ntohl(th->seq);
|
||||
TCP_SKB_CB(skb)->end_seq = (TCP_SKB_CB(skb)->seq + th->syn + th->fin + skb->len - th->doff * 4);
|
||||
TCP_SKB_CB(skb)->ack_seq = ntohl(th->ack_seq);
|
||||
TCP_SKB_CB(skb)->tcp_flags = tcp_flag_byte(th);
|
||||
TCP_SKB_CB(skb)->tcp_tw_isn = 0;
|
||||
TCP_SKB_CB(skb)->ip_dsfield = ipv4_get_dsfield(iph);
|
||||
TCP_SKB_CB(skb)->sacked = 0;
|
||||
|
||||
lookup:
|
||||
sk = __inet_lookup_skb(&tcp_hashinfo, skb, __tcp_hdrlen(th), th->source, th->dest, &refcounted);
|
||||
|
||||
process:
|
||||
if (sk->sk_state == TCP_TIME_WAIT)
|
||||
goto do_time_wait;
|
||||
|
||||
if (sk->sk_state == TCP_NEW_SYN_RECV) {
|
||||
......
|
||||
}
|
||||
......
|
||||
th = (const struct tcphdr *)skb->data;
|
||||
iph = ip_hdr(skb);
|
||||
|
||||
skb->dev = NULL;
|
||||
|
||||
if (sk->sk_state == TCP_LISTEN) {
|
||||
ret = tcp_v4_do_rcv(sk, skb);
|
||||
goto put_and_return;
|
||||
}
|
||||
......
|
||||
if (!sock_owned_by_user(sk)) {
|
||||
if (!tcp_prequeue(sk, skb))
|
||||
ret = tcp_v4_do_rcv(sk, skb);
|
||||
} else if (tcp_add_backlog(sk, skb)) {
|
||||
goto discard_and_relse;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在tcp_v4_rcv中,得到TCP的头之后,我们可以开始处理TCP层的事情。因为TCP层是分状态的,状态被维护在数据结构struct sock里面,因而我们要根据IP地址以及TCP头里面的内容,在tcp_hashinfo中找到这个包对应的struct sock,从而得到这个包对应的连接的状态。
|
||||
|
||||
接下来,我们就根据不同的状态做不同的处理,例如,上面代码中的TCP_LISTEN、TCP_NEW_SYN_RECV状态属于连接建立过程中。这个我们在讲三次握手的时候讲过了。再如,TCP_TIME_WAIT状态是连接结束的时候的状态,这个我们暂时可以不用看。
|
||||
|
||||
接下来,我们来分析最主流的网络包的接收过程,这里面涉及三个队列:
|
||||
|
||||
- backlog队列
|
||||
- prequeue队列
|
||||
- sk_receive_queue队列
|
||||
|
||||
为什么接收网络包的过程,需要在这三个队列里面倒腾过来、倒腾过去呢?这是因为,同样一个网络包要在三个主体之间交接。
|
||||
|
||||
第一个主体是**软中断的处理过程**。如果你没忘记的话,我们在执行tcp_v4_rcv函数的时候,依然处于软中断的处理逻辑里,所以必然会占用这个软中断。
|
||||
|
||||
第二个主体就是**用户态进程**。如果用户态触发系统调用read读取网络包,也要从队列里面找。
|
||||
|
||||
第三个主体就是**内核协议栈**。哪怕用户进程没有调用read,读取网络包,当网络包来的时候,也得有一个地方收着呀。
|
||||
|
||||
这时候,我们就能够了解上面代码中sock_owned_by_user的意思了,其实就是说,当前这个sock是不是正有一个用户态进程等着读数据呢,如果没有,内核协议栈也调用tcp_add_backlog,暂存在backlog队列中,并且抓紧离开软中断的处理过程。
|
||||
|
||||
如果有一个用户态进程等待读取数据呢?我们先调用tcp_prequeue,也即赶紧放入prequeue队列,并且离开软中断的处理过程。在这个函数里面,我们会看到对于sysctl_tcp_low_latency的判断,也即是不是要低时延地处理网络包。
|
||||
|
||||
如果把sysctl_tcp_low_latency设置为0,那就要放在prequeue队列中暂存,这样不用等待网络包处理完毕,就可以离开软中断的处理过程,但是会造成比较长的时延。如果把sysctl_tcp_low_latency设置为1,我们还是调用tcp_v4_do_rcv。
|
||||
|
||||
```
|
||||
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
|
||||
{
|
||||
struct sock *rsk;
|
||||
|
||||
if (sk->sk_state == TCP_ESTABLISHED) { /* Fast path */
|
||||
struct dst_entry *dst = sk->sk_rx_dst;
|
||||
......
|
||||
tcp_rcv_established(sk, skb, tcp_hdr(skb), skb->len);
|
||||
return 0;
|
||||
}
|
||||
......
|
||||
if (tcp_rcv_state_process(sk, skb)) {
|
||||
......
|
||||
}
|
||||
return 0;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在tcp_v4_do_rcv中,分两种情况,一种情况是连接已经建立,处于TCP_ESTABLISHED状态,调用tcp_rcv_established。另一种情况,就是其他的状态,调用tcp_rcv_state_process。
|
||||
|
||||
```
|
||||
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb)
|
||||
{
|
||||
struct tcp_sock *tp = tcp_sk(sk);
|
||||
struct inet_connection_sock *icsk = inet_csk(sk);
|
||||
const struct tcphdr *th = tcp_hdr(skb);
|
||||
struct request_sock *req;
|
||||
int queued = 0;
|
||||
bool acceptable;
|
||||
|
||||
switch (sk->sk_state) {
|
||||
case TCP_CLOSE:
|
||||
......
|
||||
case TCP_LISTEN:
|
||||
......
|
||||
case TCP_SYN_SENT:
|
||||
......
|
||||
}
|
||||
......
|
||||
switch (sk->sk_state) {
|
||||
case TCP_SYN_RECV:
|
||||
......
|
||||
case TCP_FIN_WAIT1:
|
||||
......
|
||||
case TCP_CLOSING:
|
||||
......
|
||||
case TCP_LAST_ACK:
|
||||
......
|
||||
}
|
||||
|
||||
/* step 7: process the segment text */
|
||||
switch (sk->sk_state) {
|
||||
case TCP_CLOSE_WAIT:
|
||||
case TCP_CLOSING:
|
||||
case TCP_LAST_ACK:
|
||||
......
|
||||
case TCP_FIN_WAIT1:
|
||||
case TCP_FIN_WAIT2:
|
||||
......
|
||||
case TCP_ESTABLISHED:
|
||||
......
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在tcp_rcv_state_process中,如果我们对着TCP的状态图进行比对,能看到,对于TCP所有状态的处理,其中和连接建立相关的状态,咱们已经分析过,所以我们重点关注连接状态下的工作模式。
|
||||
|
||||
<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)->seq == tp->rcv_nxt) {
|
||||
if (tcp_receive_window(tp) == 0)
|
||||
goto out_of_window;
|
||||
|
||||
/* Ok. In sequence. In window. */
|
||||
if (tp->ucopy.task == current &&
|
||||
tp->copied_seq == tp->rcv_nxt && tp->ucopy.len &&
|
||||
sock_owned_by_user(sk) && !tp->urg_data) {
|
||||
int chunk = min_t(unsigned int, skb->len,
|
||||
tp->ucopy.len);
|
||||
|
||||
__set_current_state(TASK_RUNNING);
|
||||
|
||||
if (!skb_copy_datagram_msg(skb, 0, tp->ucopy.msg, chunk)) {
|
||||
tp->ucopy.len -= chunk;
|
||||
tp->copied_seq += chunk;
|
||||
eaten = (chunk == skb->len);
|
||||
tcp_rcv_space_adjust(sk);
|
||||
}
|
||||
}
|
||||
|
||||
if (eaten <= 0) {
|
||||
queue_and_out:
|
||||
......
|
||||
eaten = tcp_queue_rcv(sk, skb, 0, &fragstolen);
|
||||
}
|
||||
tcp_rcv_nxt_update(tp, TCP_SKB_CB(skb)->end_seq);
|
||||
......
|
||||
if (!RB_EMPTY_ROOT(&tp->out_of_order_queue)) {
|
||||
tcp_ofo_queue(sk);
|
||||
......
|
||||
}
|
||||
......
|
||||
return;
|
||||
}
|
||||
|
||||
if (!after(TCP_SKB_CB(skb)->end_seq, tp->rcv_nxt)) {
|
||||
/* A retransmit, 2nd most common case. Force an immediate ack. */
|
||||
tcp_dsack_set(sk, TCP_SKB_CB(skb)->seq, TCP_SKB_CB(skb)->end_seq);
|
||||
|
||||
out_of_window:
|
||||
tcp_enter_quickack_mode(sk);
|
||||
inet_csk_schedule_ack(sk);
|
||||
drop:
|
||||
tcp_drop(sk, skb);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Out of window. F.e. zero window probe. */
|
||||
if (!before(TCP_SKB_CB(skb)->seq, tp->rcv_nxt + tcp_receive_window(tp)))
|
||||
goto out_of_window;
|
||||
|
||||
tcp_enter_quickack_mode(sk);
|
||||
|
||||
if (before(TCP_SKB_CB(skb)->seq, tp->rcv_nxt)) {
|
||||
/* Partial packet, seq < rcv_next < end_seq */
|
||||
tcp_dsack_set(sk, TCP_SKB_CB(skb)->seq, tp->rcv_nxt);
|
||||
/* If window is closed, drop tail of packet. But after
|
||||
* remembering D-SACK for its head made in previous line.
|
||||
*/
|
||||
if (!tcp_receive_window(tp))
|
||||
goto out_of_window;
|
||||
goto queue_and_out;
|
||||
}
|
||||
|
||||
tcp_data_queue_ofo(sk, skb);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在tcp_data_queue中,对于收到的网络包,我们要分情况进行处理。
|
||||
|
||||
第一种情况,seq == tp->rcv_nxt,说明来的网络包正是我服务端期望的下一个网络包。这个时候我们判断sock_owned_by_user,也即用户进程也是正在等待读取,这种情况下,就直接skb_copy_datagram_msg,将网络包拷贝给用户进程就可以了。
|
||||
|
||||
如果用户进程没有正在等待读取,或者因为内存原因没有能够拷贝成功,tcp_queue_rcv里面还是将网络包放入sk_receive_queue队列。
|
||||
|
||||
接下来,tcp_rcv_nxt_update将tp->rcv_nxt设置为end_seq,也即当前的网络包接收成功后,更新下一个期待的网络包。
|
||||
|
||||
这个时候,我们还会判断一下另一个队列,out_of_order_queue,也看看乱序队列的情况,看看乱序队列里面的包,会不会因为这个新的网络包的到来,也能放入到sk_receive_queue队列中。
|
||||
|
||||
例如,客户端发送的网络包序号为5、6、7、8、9。在5还没有到达的时候,服务端的rcv_nxt应该是5,也即期望下一个网络包是5。但是由于中间网络通路的问题,5、6还没到达服务端,7、8已经到达了服务端了,这就出现了乱序。
|
||||
|
||||
乱序的包不能进入sk_receive_queue队列。因为一旦进入到这个队列,意味着可以发送给用户进程。然而,按照TCP的定义,用户进程应该是按顺序收到包的,没有排好序,就不能给用户进程。所以,7、8不能进入sk_receive_queue队列,只能暂时放在out_of_order_queue乱序队列中。
|
||||
|
||||
当5、6到达的时候,5、6先进入sk_receive_queue队列。这个时候我们再来看out_of_order_queue乱序队列中的7、8,发现能够接上。于是,7、8也能进入sk_receive_queue队列了。tcp_ofo_queue函数就是做这个事情的。
|
||||
|
||||
至此第一种情况处理完毕。
|
||||
|
||||
第二种情况,end_seq不大于rcv_nxt,也即服务端期望网络包5。但是,来了一个网络包3,怎样才会出现这种情况呢?肯定是服务端早就收到了网络包3,但是ACK没有到达客户端,中途丢了,那客户端就认为网络包3没有发送成功,于是又发送了一遍,这种情况下,要赶紧给客户端再发送一次ACK,表示早就收到了。
|
||||
|
||||
第三种情况,seq不小于rcv_nxt + tcp_receive_window。这说明客户端发送得太猛了。本来seq肯定应该在接收窗口里面的,这样服务端才来得及处理,结果现在超出了接收窗口,说明客户端一下子把服务端给塞满了。
|
||||
|
||||
这种情况下,服务端不能再接收数据包了,只能发送ACK了,在ACK中会将接收窗口为0的情况告知客户端,客户端就知道不能再发送了。这个时候双方只能交互窗口探测数据包,直到服务端因为用户进程把数据读走了,空出接收窗口,才能在ACK里面再次告诉客户端,又有窗口了,又能发送数据包了。
|
||||
|
||||
第四种情况,seq小于rcv_nxt,但是end_seq大于rcv_nxt,这说明从seq到rcv_nxt这部分网络包原来的ACK客户端没有收到,所以重新发送了一次,从rcv_nxt到end_seq时新发送的,可以放入sk_receive_queue队列。
|
||||
|
||||
当前四种情况都排除掉了,说明网络包一定是一个乱序包了。这里有点儿难理解,我们还是用上面那个乱序的例子仔细分析一下rcv_nxt=5。
|
||||
|
||||
我们假设tcp_receive_window也是5,也即超过10服务端就接收不了了。当前来的这个网络包既不在rcv_nxt之前(不是3这种),也不在rcv_nxt + tcp_receive_window之后(不是11这种),说明这正在我们期望的接收窗口里面,但是又不是rcv_nxt(不是我们马上期望的网络包5),这正是上面的例子中网络包7、8的情况。
|
||||
|
||||
对于网络包7、8,我们只好调用tcp_data_queue_ofo进入out_of_order_queue乱序队列,但是没有关系,当网络包5、6到来的时候,我们会走第一种情况,把7、8拿出来放到sk_receive_queue队列中。
|
||||
|
||||
至此,网络协议栈的处理过程就结束了。
|
||||
|
||||
## Socket层
|
||||
|
||||
当接收的网络包进入各种队列之后,接下来我们就要等待用户进程去读取它们了。
|
||||
|
||||
读取一个socket,就像读取一个文件一样,读取socket的文件描述符,通过read系统调用。
|
||||
|
||||
read系统调用对于一个文件描述符的操作,大致过程都是类似的,在文件系统那一节,我们已经详细解析过。最终它会调用到用来表示一个打开文件的结构stuct file指向的file_operations操作。
|
||||
|
||||
对于socket来讲,它的file_operations定义如下:
|
||||
|
||||
```
|
||||
static const struct file_operations socket_file_ops = {
|
||||
.owner = THIS_MODULE,
|
||||
.llseek = no_llseek,
|
||||
.read_iter = sock_read_iter,
|
||||
.write_iter = sock_write_iter,
|
||||
.poll = sock_poll,
|
||||
.unlocked_ioctl = sock_ioctl,
|
||||
.mmap = sock_mmap,
|
||||
.release = sock_close,
|
||||
.fasync = sock_fasync,
|
||||
.sendpage = sock_sendpage,
|
||||
.splice_write = generic_splice_sendpage,
|
||||
.splice_read = sock_splice_read,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
按照文件系统的读取流程,调用的是sock_read_iter。
|
||||
|
||||
```
|
||||
static ssize_t sock_read_iter(struct kiocb *iocb, struct iov_iter *to)
|
||||
{
|
||||
struct file *file = iocb->ki_filp;
|
||||
struct socket *sock = file->private_data;
|
||||
struct msghdr msg = {.msg_iter = *to,
|
||||
.msg_iocb = iocb};
|
||||
ssize_t res;
|
||||
|
||||
if (file->f_flags & O_NONBLOCK)
|
||||
msg.msg_flags = MSG_DONTWAIT;
|
||||
......
|
||||
res = sock_recvmsg(sock, &msg, msg.msg_flags);
|
||||
*to = msg.msg_iter;
|
||||
return res;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在sock_read_iter中,通过VFS中的struct file,将创建好的socket结构拿出来,然后调用sock_recvmsg,sock_recvmsg会调用sock_recvmsg_nosec。
|
||||
|
||||
```
|
||||
static inline int sock_recvmsg_nosec(struct socket *sock, struct msghdr *msg, int flags)
|
||||
{
|
||||
return sock->ops->recvmsg(sock, msg, msg_data_left(msg), flags);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里调用了socket的ops的recvmsg,这个我们遇到好几次了。根据inet_stream_ops的定义,这里调用的是inet_recvmsg。
|
||||
|
||||
```
|
||||
int inet_recvmsg(struct socket *sock, struct msghdr *msg, size_t size,
|
||||
int flags)
|
||||
{
|
||||
struct sock *sk = sock->sk;
|
||||
int addr_len = 0;
|
||||
int err;
|
||||
......
|
||||
err = sk->sk_prot->recvmsg(sk, msg, size, flags & MSG_DONTWAIT,
|
||||
flags & ~MSG_DONTWAIT, &addr_len);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面,从socket结构,我们可以得到更底层的sock结构,然后调用sk_prot的recvmsg方法。这个同样遇到好几次了,根据tcp_prot的定义,调用的是tcp_recvmsg。
|
||||
|
||||
```
|
||||
int tcp_recvmsg(struct sock *sk, struct msghdr *msg, size_t len, int nonblock,
|
||||
int flags, int *addr_len)
|
||||
{
|
||||
struct tcp_sock *tp = tcp_sk(sk);
|
||||
int copied = 0;
|
||||
u32 peek_seq;
|
||||
u32 *seq;
|
||||
unsigned long used;
|
||||
int err;
|
||||
int target; /* Read at least this many bytes */
|
||||
long timeo;
|
||||
struct task_struct *user_recv = NULL;
|
||||
struct sk_buff *skb, *last;
|
||||
.....
|
||||
do {
|
||||
u32 offset;
|
||||
......
|
||||
/* Next get a buffer. */
|
||||
last = skb_peek_tail(&sk->sk_receive_queue);
|
||||
skb_queue_walk(&sk->sk_receive_queue, skb) {
|
||||
last = skb;
|
||||
offset = *seq - TCP_SKB_CB(skb)->seq;
|
||||
if (offset < skb->len)
|
||||
goto found_ok_skb;
|
||||
......
|
||||
}
|
||||
......
|
||||
if (!sysctl_tcp_low_latency && tp->ucopy.task == user_recv) {
|
||||
/* Install new reader */
|
||||
if (!user_recv && !(flags & (MSG_TRUNC | MSG_PEEK))) {
|
||||
user_recv = current;
|
||||
tp->ucopy.task = user_recv;
|
||||
tp->ucopy.msg = msg;
|
||||
}
|
||||
|
||||
tp->ucopy.len = len;
|
||||
/* Look: we have the following (pseudo)queues:
|
||||
*
|
||||
* 1. packets in flight
|
||||
* 2. backlog
|
||||
* 3. prequeue
|
||||
* 4. receive_queue
|
||||
*
|
||||
* Each queue can be processed only if the next ones
|
||||
* are empty.
|
||||
*/
|
||||
if (!skb_queue_empty(&tp->ucopy.prequeue))
|
||||
goto do_prequeue;
|
||||
}
|
||||
|
||||
if (copied >= target) {
|
||||
/* Do not sleep, just process backlog. */
|
||||
release_sock(sk);
|
||||
lock_sock(sk);
|
||||
} else {
|
||||
sk_wait_data(sk, &timeo, last);
|
||||
}
|
||||
|
||||
if (user_recv) {
|
||||
int chunk;
|
||||
chunk = len - tp->ucopy.len;
|
||||
if (chunk != 0) {
|
||||
len -= chunk;
|
||||
copied += chunk;
|
||||
}
|
||||
|
||||
if (tp->rcv_nxt == tp->copied_seq &&
|
||||
!skb_queue_empty(&tp->ucopy.prequeue)) {
|
||||
do_prequeue:
|
||||
tcp_prequeue_process(sk);
|
||||
|
||||
chunk = len - tp->ucopy.len;
|
||||
if (chunk != 0) {
|
||||
len -= chunk;
|
||||
copied += chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
found_ok_skb:
|
||||
/* Ok so how much can we use? */
|
||||
used = skb->len - offset;
|
||||
if (len < used)
|
||||
used = len;
|
||||
|
||||
if (!(flags & MSG_TRUNC)) {
|
||||
err = skb_copy_datagram_msg(skb, offset, msg, used);
|
||||
......
|
||||
}
|
||||
|
||||
*seq += used;
|
||||
copied += used;
|
||||
len -= used;
|
||||
|
||||
tcp_rcv_space_adjust(sk);
|
||||
......
|
||||
} while (len > 0);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
tcp_recvmsg这个函数比较长,里面逻辑也很复杂,好在里面有一段注释概括了这里面的逻辑。注释里面提到了三个队列,receive_queue队列、prequeue队列和backlog队列。这里面,我们需要把前一个队列处理完毕,才处理后一个队列。
|
||||
|
||||
tcp_recvmsg的整个逻辑也是这样执行的:这里面有一个while循环,不断地读取网络包。
|
||||
|
||||
这里,我们会先处理sk_receive_queue队列。如果找到了网络包,就跳到found_ok_skb这里。这里会调用skb_copy_datagram_msg,将网络包拷贝到用户进程中,然后直接进入下一层循环。
|
||||
|
||||
直到sk_receive_queue队列处理完毕,我们才到了sysctl_tcp_low_latency判断。如果不需要低时延,则会有prequeue队列。于是,我们能就跳到do_prequeue这里,调用tcp_prequeue_process进行处理。
|
||||
|
||||
如果sysctl_tcp_low_latency设置为1,也即没有prequeue队列,或者prequeue队列为空,则需要处理backlog队列,在release_sock函数中处理。
|
||||
|
||||
release_sock会调用__release_sock,这里面会依次处理队列中的网络包。
|
||||
|
||||
```
|
||||
void release_sock(struct sock *sk)
|
||||
{
|
||||
......
|
||||
if (sk->sk_backlog.tail)
|
||||
__release_sock(sk);
|
||||
......
|
||||
}
|
||||
|
||||
static void __release_sock(struct sock *sk)
|
||||
__releases(&sk->sk_lock.slock)
|
||||
__acquires(&sk->sk_lock.slock)
|
||||
{
|
||||
struct sk_buff *skb, *next;
|
||||
|
||||
while ((skb = sk->sk_backlog.head) != NULL) {
|
||||
sk->sk_backlog.head = sk->sk_backlog.tail = NULL;
|
||||
do {
|
||||
next = skb->next;
|
||||
prefetch(next);
|
||||
skb->next = NULL;
|
||||
sk_backlog_rcv(sk, skb);
|
||||
cond_resched();
|
||||
skb = next;
|
||||
} while (skb != NULL);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
最后,哪里都没有网络包,我们只好调用sk_wait_data,继续等待在哪里,等待网络包的到来。
|
||||
|
||||
至此,网络包的接收过程到此结束。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节我们讲完了接收网络包,我们来从头串一下,整个过程可以分成以下几个层次。
|
||||
|
||||
- 硬件网卡接收到网络包之后,通过DMA技术,将网络包放入Ring Buffer;
|
||||
- 硬件网卡通过中断通知CPU新的网络包的到来;
|
||||
- 网卡驱动程序会注册中断处理函数ixgb_intr;
|
||||
- 中断处理函数处理完需要暂时屏蔽中断的核心流程之后,通过软中断NET_RX_SOFTIRQ触发接下来的处理过程;
|
||||
- NET_RX_SOFTIRQ软中断处理函数net_rx_action,net_rx_action会调用napi_poll,进而调用ixgb_clean_rx_irq,从Ring Buffer中读取数据到内核struct sk_buff;
|
||||
- 调用netif_receive_skb进入内核网络协议栈,进行一些关于VLAN的二层逻辑处理后,调用ip_rcv进入三层IP层;
|
||||
- 在IP层,会处理iptables规则,然后调用ip_local_deliver交给更上层TCP层;
|
||||
- 在TCP层调用tcp_v4_rcv,这里面有三个队列需要处理,如果当前的Socket不是正在被读;取,则放入backlog队列,如果正在被读取,不需要很实时的话,则放入prequeue队列,其他情况调用tcp_v4_do_rcv;
|
||||
- 在tcp_v4_do_rcv中,如果是处于TCP_ESTABLISHED状态,调用tcp_rcv_established,其他的状态,调用tcp_rcv_state_process;
|
||||
- 在tcp_rcv_established中,调用tcp_data_queue,如果序列号能够接的上,则放入sk_receive_queue队列;如果序列号接不上,则暂时放入out_of_order_queue队列,等序列号能够接上的时候,再放入sk_receive_queue队列。
|
||||
|
||||
至此内核接收网络包的过程到此结束,接下来就是用户态读取网络包的过程,这个过程分成几个层次。
|
||||
|
||||
- VFS层:read系统调用找到struct file,根据里面的file_operations的定义,调用sock_read_iter函数。sock_read_iter函数调用sock_recvmsg函数。
|
||||
- Socket层:从struct file里面的private_data得到struct socket,根据里面ops的定义,调用inet_recvmsg函数。
|
||||
- Sock层:从struct socket里面的sk得到struct sock,根据里面sk_prot的定义,调用tcp_recvmsg函数。
|
||||
- TCP层:tcp_recvmsg函数会依次读取receive_queue队列、prequeue队列和backlog队列。
|
||||
|
||||
<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="">
|
||||
192
极客时间专栏/趣谈Linux操作系统/核心原理篇:第六部分 输入输出系统/31 | 输入与输出:如何建立售前售后生态体系?.md
Normal file
192
极客时间专栏/趣谈Linux操作系统/核心原理篇:第六部分 输入输出系统/31 | 输入与输出:如何建立售前售后生态体系?.md
Normal 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="">
|
||||
653
极客时间专栏/趣谈Linux操作系统/核心原理篇:第六部分 输入输出系统/32 | 字符设备(上):如何建立直销模式?.md
Normal file
653
极客时间专栏/趣谈Linux操作系统/核心原理篇:第六部分 输入输出系统/32 | 字符设备(上):如何建立直销模式?.md
Normal 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 <linux/module.h>
|
||||
#include <linux/init.h>
|
||||
|
||||
```
|
||||
|
||||
如果你去看上面两个驱动程序,都能找到这两个头文件。当然如果需要的话,我们还可以引入更多的头文件。
|
||||
|
||||
**第二部分,定义一些函数,用于处理内核模块的主要逻辑**。例如打开、关闭、读取、写入设备的函数或者响应中断的函数。
|
||||
|
||||
例如,logibm.c里面就定义了logibm_open。logibm_close就是处理打开和关闭的,定义了logibm_interrupt就是用来响应中断的。再如,lp.c里面就定义了lp_read,lp_write就是处理读写的。
|
||||
|
||||
**第三部分,定义一个file_operations结构**。前面我们讲过,设备是可以通过文件系统的接口进行访问的。咱们讲文件系统的时候说过,对于某种文件系统的操作,都是放在file_operations里面的。例如ext4就定义了这么一个结构,里面都是ext4_xxx之类的函数。设备要想被文件系统的接口操作,也需要定义这样一个结构。
|
||||
|
||||
例如,lp.c里面就定义了这样一个结构。
|
||||
|
||||
```
|
||||
static const struct file_operations lp_fops = {
|
||||
.owner = THIS_MODULE,
|
||||
.write = lp_write,
|
||||
.unlocked_ioctl = lp_ioctl,
|
||||
#ifdef CONFIG_COMPAT
|
||||
.compat_ioctl = lp_compat_ioctl,
|
||||
#endif
|
||||
.open = lp_open,
|
||||
.release = lp_release,
|
||||
#ifdef CONFIG_PARPORT_1284
|
||||
.read = lp_read,
|
||||
#endif
|
||||
.llseek = noop_llseek,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在logibm.c里面,我们找不到这样的结构,是因为它属于众多输入设备的一种,而输入设备的操作被统一定义在drivers/input/input.c里面,logibm.c只是定义了一些自己独有的操作。
|
||||
|
||||
```
|
||||
static const struct file_operations input_devices_fileops = {
|
||||
.owner = THIS_MODULE,
|
||||
.open = input_proc_devices_open,
|
||||
.poll = input_proc_devices_poll,
|
||||
.read = seq_read,
|
||||
.llseek = seq_lseek,
|
||||
.release = seq_release,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
**第四部分,定义整个模块的初始化函数和退出函数**,用于加载和卸载这个ko的时候调用。
|
||||
|
||||
例如lp.c就定义了lp_init_module和lp_cleanup_module,logibm.c就定义了logibm_init和logibm_exit。
|
||||
|
||||
**第五部分,调用module_init和module_exit**,分别指向上面两个初始化函数和退出函数。就像本节最开头展示的一样。
|
||||
|
||||
**第六部分,声明一下lisense,调用MODULE_LICENSE**。
|
||||
|
||||
有了这六部分,一个内核模块就基本合格了,可以工作了。
|
||||
|
||||
## 打开字符设备
|
||||
|
||||
字符设备可不是一个普通的内核模块,它有自己独特的行为。接下来,我们就沿着打开一个字符设备的过程,看看字符设备这个内核模块做了哪些特殊的事情。
|
||||
|
||||
<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, "lp", &lp_fops)) {
|
||||
printk (KERN_ERR "lp: unable to get major %d\n", LP_MAJOR);
|
||||
return -EIO;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
int __register_chrdev(unsigned int major, unsigned int baseminor,
|
||||
unsigned int count, const char *name,
|
||||
const struct file_operations *fops)
|
||||
{
|
||||
struct char_device_struct *cd;
|
||||
struct cdev *cdev;
|
||||
int err = -ENOMEM;
|
||||
......
|
||||
cd = __register_chrdev_region(major, baseminor, count, name);
|
||||
cdev = cdev_alloc();
|
||||
cdev->owner = fops->owner;
|
||||
cdev->ops = fops;
|
||||
kobject_set_name(&cdev->kobj, "%s", name);
|
||||
err = cdev_add(cdev, MKDEV(cd->major, baseminor), count);
|
||||
cd->cdev = cdev;
|
||||
return major ? 0 : cd->major;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在字符设备驱动的内核模块加载的时候,最重要的一件事情就是,注册这个字符设备。注册的方式是调用__register_chrdev_region,注册字符设备的主次设备号和名称,然后分配一个struct cdev结构,将cdev的ops成员变量指向这个模块声明的file_operations。然后,cdev_add会将这个字符设备添加到内核中一个叫作struct kobj_map *cdev_map的结构,来统一管理所有字符设备。
|
||||
|
||||
其中,MKDEV(cd->major, baseminor)表示将主设备号和次设备号生成一个dev_t的整数,然后将这个整数dev_t和cdev关联起来。
|
||||
|
||||
```
|
||||
/**
|
||||
* cdev_add() - add a char device to the system
|
||||
* @p: the cdev structure for the device
|
||||
* @dev: the first device number for which this device is responsible
|
||||
* @count: the number of consecutive minor numbers corresponding to this
|
||||
* device
|
||||
*
|
||||
* cdev_add() adds the device represented by @p to the system, making it
|
||||
* live immediately. A negative error code is returned on failure.
|
||||
*/
|
||||
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
|
||||
{
|
||||
int error;
|
||||
|
||||
|
||||
p->dev = dev;
|
||||
p->count = count;
|
||||
|
||||
|
||||
error = kobj_map(cdev_map, dev, count, NULL,
|
||||
exact_match, exact_lock, p);
|
||||
kobject_get(p->kobj.parent);
|
||||
|
||||
|
||||
return 0;
|
||||
|
||||
```
|
||||
|
||||
在logibm.c中,我们在logibm_init找不到注册字符设备,这是因为input.c里面的初始化函数input_init会调用register_chrdev_region,注册输入的字符设备,会在logibm_init中调用input_register_device,将logibm.c这个字符设备注册到input.c里面去,这就相当于input.c对多个输入字符设备进行统一的管理。
|
||||
|
||||
内核模块加载完毕后,接下来要通过mknod在/dev下面创建一个设备文件,只有有了这个设备文件,我们才能通过文件系统的接口,对这个设备文件进行操作。
|
||||
|
||||
mknod也是一个系统调用,定义如下:
|
||||
|
||||
```
|
||||
SYSCALL_DEFINE3(mknod, const char __user *, filename, umode_t, mode, unsigned, dev)
|
||||
{
|
||||
return sys_mknodat(AT_FDCWD, filename, mode, dev);
|
||||
}
|
||||
|
||||
|
||||
SYSCALL_DEFINE4(mknodat, int, dfd, const char __user *, filename, umode_t, mode,
|
||||
unsigned, dev)
|
||||
{
|
||||
struct dentry *dentry;
|
||||
struct path path;
|
||||
......
|
||||
dentry = user_path_create(dfd, filename, &path, lookup_flags);
|
||||
......
|
||||
switch (mode & S_IFMT) {
|
||||
......
|
||||
case S_IFCHR: case S_IFBLK:
|
||||
error = vfs_mknod(path.dentry->d_inode,dentry,mode,
|
||||
new_decode_dev(dev));
|
||||
break;
|
||||
......
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以在这个系统调用里看到,在文件系统上,顺着路径找到/dev/xxx所在的文件夹,然后为这个新创建的设备文件创建一个dentry。这是维护文件和inode之间的关联关系的结构。
|
||||
|
||||
接下来,如果是字符文件S_IFCHR或者设备文件S_IFBLK,我们就调用vfs_mknod。
|
||||
|
||||
```
|
||||
int vfs_mknod(struct inode *dir, struct dentry *dentry, umode_t mode, dev_t dev)
|
||||
{
|
||||
......
|
||||
error = dir->i_op->mknod(dir, dentry, mode, dev);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里需要调用对应的文件系统的inode_operations。应该调用哪个文件系统呢?
|
||||
|
||||
如果我们在linux下面执行mount命令,能看到下面这一行:
|
||||
|
||||
```
|
||||
devtmpfs on /dev type devtmpfs (rw,nosuid,size=3989584k,nr_inodes=997396,mode=755)
|
||||
|
||||
```
|
||||
|
||||
也就是说,/dev下面的文件系统的名称为devtmpfs,我们可以在内核中找到它。
|
||||
|
||||
```
|
||||
static struct dentry *dev_mount(struct file_system_type *fs_type, int flags,
|
||||
const char *dev_name, void *data)
|
||||
{
|
||||
#ifdef CONFIG_TMPFS
|
||||
return mount_single(fs_type, flags, data, shmem_fill_super);
|
||||
#else
|
||||
return mount_single(fs_type, flags, data, ramfs_fill_super);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
static struct file_system_type dev_fs_type = {
|
||||
.name = "devtmpfs",
|
||||
.mount = dev_mount,
|
||||
.kill_sb = kill_litter_super,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
从这里可以看出,devtmpfs在挂载的时候,有两种模式,一种是ramfs,一种是shmem都是基于内存的文件系统。这里你先不用管,基于内存的文件系统具体是怎么回事儿。
|
||||
|
||||
```
|
||||
static const struct inode_operations ramfs_dir_inode_operations = {
|
||||
......
|
||||
.mknod = ramfs_mknod,
|
||||
};
|
||||
|
||||
|
||||
static const struct inode_operations shmem_dir_inode_operations = {
|
||||
#ifdef CONFIG_TMPFS
|
||||
......
|
||||
.mknod = shmem_mknod,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
这两个mknod虽然实现不同,但是都会调用到同一个函数init_special_inode。
|
||||
|
||||
```
|
||||
void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
|
||||
{
|
||||
inode->i_mode = mode;
|
||||
if (S_ISCHR(mode)) {
|
||||
inode->i_fop = &def_chr_fops;
|
||||
inode->i_rdev = rdev;
|
||||
} else if (S_ISBLK(mode)) {
|
||||
inode->i_fop = &def_blk_fops;
|
||||
inode->i_rdev = rdev;
|
||||
} else if (S_ISFIFO(mode))
|
||||
inode->i_fop = &pipefifo_fops;
|
||||
else if (S_ISSOCK(mode))
|
||||
; /* leave it no_open_fops */
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
显然这个文件是个特殊文件,inode也是特殊的。这里这个inode可以关联字符设备、块设备、FIFO文件、Socket等。我们这里只看字符设备。
|
||||
|
||||
这里的inode的file_operations指向一个def_chr_fops,这里面只有一个open,就等着你打开它。
|
||||
|
||||
另外,inode的i_rdev指向这个设备的dev_t。还记得cdev_map吗?通过这个dev_t,可以找到我们刚在加载的字符设备cdev。
|
||||
|
||||
```
|
||||
const struct file_operations def_chr_fops = {
|
||||
.open = chrdev_open,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
到目前为止,我们只是创建了/dev下面的一个文件,并且和相应的设备号关联起来。但是,我们还没有打开这个/dev下面的设备文件。
|
||||
|
||||
现在我们来打开它。打开一个文件的流程,我们在[文件系统](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->i_cdev;
|
||||
if (!p) {
|
||||
struct kobject *kobj;
|
||||
int idx;
|
||||
kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);
|
||||
new = container_of(kobj, struct cdev, kobj);
|
||||
p = inode->i_cdev;
|
||||
if (!p) {
|
||||
inode->i_cdev = p = new;
|
||||
list_add(&inode->i_devices, &p->list);
|
||||
new = NULL;
|
||||
}
|
||||
}
|
||||
......
|
||||
fops = fops_get(p->ops);
|
||||
......
|
||||
replace_fops(filp, fops);
|
||||
if (filp->f_op->open) {
|
||||
ret = filp->f_op->open(inode, filp);
|
||||
......
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个函数里面,我们首先看这个inode的i_cdev,是否已经关联到cdev。如果第一次打开,当然没有。没有没关系,inode里面有i_rdev呀,也就是有dev_t。我们可以通过它在cdev_map中找cdev。咱们上面注册过了,所以肯定能够找到。找到后我们就将inode的i_cdev,关联到找到的cdev new。
|
||||
|
||||
找到cdev就好办了。cdev里面有file_operations,这是设备驱动程序自己定义的。我们可以通过它来操作设备驱动程序,把它付给struct file里面的file_operations。这样以后操作文件描述符,就是直接操作设备了。
|
||||
|
||||
最后,我们需要调用设备驱动程序的file_operations的open函数,真正打开设备。对于打印机,调用的是lp_open。对于鼠标调用的是input_proc_devices_open,最终会调用到logibm_open。这些多和设备相关,你不必看懂它们。
|
||||
|
||||
## 写入字符设备
|
||||
|
||||
当我们像打开一个文件一样打开一个字符设备之后,接下来就是对这个设备的读写。对于文件的读写咱们在文件系统那一章详细讲述过,读写的过程是类似的,所以这里我们只解析打印机驱动写入的过程。
|
||||
|
||||
<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->f_op->write)
|
||||
return file->f_op->write(file, p, count, pos);
|
||||
else if (file->f_op->write_iter)
|
||||
return new_sync_write(file, p, count, pos);
|
||||
else
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以看到,在__vfs_write里面,我们会调用struct file结构里的file_operations的write函数。上面我们打开字符设备的时候,已经将struct file结构里面的file_operations指向了设备驱动程序的file_operations结构,所以这里的write函数最终会调用到lp_write。
|
||||
|
||||
```
|
||||
static ssize_t lp_write(struct file * file, const char __user * buf,
|
||||
size_t count, loff_t *ppos)
|
||||
{
|
||||
unsigned int minor = iminor(file_inode(file));
|
||||
struct parport *port = lp_table[minor].dev->port;
|
||||
char *kbuf = lp_table[minor].lp_buffer;
|
||||
ssize_t retv = 0;
|
||||
ssize_t written;
|
||||
size_t copy_size = count;
|
||||
......
|
||||
/* Need to copy the data from user-space. */
|
||||
if (copy_size > LP_BUFFER_SIZE)
|
||||
copy_size = LP_BUFFER_SIZE;
|
||||
......
|
||||
if (copy_from_user (kbuf, buf, copy_size)) {
|
||||
retv = -EFAULT;
|
||||
goto out_unlock;
|
||||
}
|
||||
......
|
||||
do {
|
||||
/* Write the data. */
|
||||
written = parport_write (port, kbuf, copy_size);
|
||||
if (written > 0) {
|
||||
copy_size -= written;
|
||||
count -= written;
|
||||
buf += written;
|
||||
retv += written;
|
||||
}
|
||||
......
|
||||
if (need_resched())
|
||||
schedule ();
|
||||
|
||||
|
||||
if (count) {
|
||||
copy_size = count;
|
||||
if (copy_size > LP_BUFFER_SIZE)
|
||||
copy_size = LP_BUFFER_SIZE;
|
||||
|
||||
|
||||
if (copy_from_user(kbuf, buf, copy_size)) {
|
||||
if (retv == 0)
|
||||
retv = -EFAULT;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} while (count > 0);
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
这个设备驱动程序的写入函数的实现还是比较典型的。先是调用copy_from_user将数据从用户态拷贝到内核态的缓存中,然后调用parport_write写入外部设备。这里还有一个schedule函数,也即写入的过程中,给其他线程抢占CPU的机会。然后,如果count还是大于0,也就是数据还没有写完,那我们就接着copy_from_user,接着parport_write,直到写完为止。
|
||||
|
||||
## 使用IOCTL控制设备
|
||||
|
||||
对于I/O设备来讲,我们前面也说过,除了读写设备,还会调用ioctl,做一些特殊的I/O操作。
|
||||
|
||||
<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) >> _IOC_DIRSHIFT) & _IOC_DIRMASK)
|
||||
#define _IOC_TYPE(nr) (((nr) >> _IOC_TYPESHIFT) & _IOC_TYPEMASK)
|
||||
#define _IOC_NR(nr) (((nr) >> _IOC_NRSHIFT) & _IOC_NRMASK)
|
||||
#define _IOC_SIZE(nr) (((nr) >> _IOC_SIZESHIFT) & _IOC_SIZEMASK)
|
||||
|
||||
```
|
||||
|
||||
在用户程序中,可以通过上面的“Used to create numbers”这些宏,根据参数生成cmd,在驱动程序中,可以通过下面的“used to decode ioctl numbers”这些宏,解析cmd后,执行指令。
|
||||
|
||||
ioctl中会调用do_vfs_ioctl,这里面对于已经定义好的cmd,进行相应的处理。如果不是默认定义好的cmd,则执行默认操作。对于普通文件,调用file_ioctl;对于其他文件调用vfs_ioctl。
|
||||
|
||||
```
|
||||
int do_vfs_ioctl(struct file *filp, unsigned int fd, unsigned int cmd,
|
||||
unsigned long arg)
|
||||
{
|
||||
int error = 0;
|
||||
int __user *argp = (int __user *)arg;
|
||||
struct inode *inode = file_inode(filp);
|
||||
|
||||
|
||||
switch (cmd) {
|
||||
......
|
||||
case FIONBIO:
|
||||
error = ioctl_fionbio(filp, argp);
|
||||
break;
|
||||
|
||||
|
||||
case FIOASYNC:
|
||||
error = ioctl_fioasync(fd, filp, argp);
|
||||
break;
|
||||
......
|
||||
case FICLONE:
|
||||
return ioctl_file_clone(filp, arg, 0, 0, 0);
|
||||
|
||||
|
||||
default:
|
||||
if (S_ISREG(inode->i_mode))
|
||||
error = file_ioctl(filp, cmd, arg);
|
||||
else
|
||||
error = vfs_ioctl(filp, cmd, arg);
|
||||
break;
|
||||
}
|
||||
return error;
|
||||
|
||||
```
|
||||
|
||||
由于咱们这里是设备驱动程序,所以调用的是vfs_ioctl。
|
||||
|
||||
```
|
||||
/**
|
||||
* vfs_ioctl - call filesystem specific ioctl methods
|
||||
* @filp: open file to invoke ioctl method on
|
||||
* @cmd: ioctl command to execute
|
||||
* @arg: command-specific argument for ioctl
|
||||
*
|
||||
* Invokes filesystem specific ->unlocked_ioctl, if one exists; otherwise
|
||||
* returns -ENOTTY.
|
||||
*
|
||||
* Returns 0 on success, -errno on error.
|
||||
*/
|
||||
long vfs_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
|
||||
{
|
||||
int error = -ENOTTY;
|
||||
|
||||
|
||||
if (!filp->f_op->unlocked_ioctl)
|
||||
goto out;
|
||||
|
||||
|
||||
error = filp->f_op->unlocked_ioctl(filp, cmd, arg);
|
||||
if (error == -ENOIOCTLCMD)
|
||||
error = -ENOTTY;
|
||||
out:
|
||||
return error;
|
||||
|
||||
```
|
||||
|
||||
这里面调用的是struct file里file_operations的unlocked_ioctl函数。我们前面初始化设备驱动的时候,已经将file_operations指向设备驱动的file_operations了。这里调用的是设备驱动的unlocked_ioctl。对于打印机程序来讲,调用的是lp_ioctl。可以看出来,这里面就是switch语句,它会根据不同的cmd,做不同的操作。
|
||||
|
||||
```
|
||||
static long lp_ioctl(struct file *file, unsigned int cmd,
|
||||
unsigned long arg)
|
||||
{
|
||||
unsigned int minor;
|
||||
struct timeval par_timeout;
|
||||
int ret;
|
||||
|
||||
|
||||
minor = iminor(file_inode(file));
|
||||
mutex_lock(&lp_mutex);
|
||||
switch (cmd) {
|
||||
......
|
||||
default:
|
||||
ret = lp_do_ioctl(minor, cmd, arg, (void __user *)arg);
|
||||
break;
|
||||
}
|
||||
mutex_unlock(&lp_mutex);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
static int lp_do_ioctl(unsigned int minor, unsigned int cmd,
|
||||
unsigned long arg, void __user *argp)
|
||||
{
|
||||
int status;
|
||||
int retval = 0;
|
||||
|
||||
|
||||
switch ( cmd ) {
|
||||
case LPTIME:
|
||||
if (arg > UINT_MAX / HZ)
|
||||
return -EINVAL;
|
||||
LP_TIME(minor) = arg * HZ/100;
|
||||
break;
|
||||
case LPCHAR:
|
||||
LP_CHAR(minor) = arg;
|
||||
break;
|
||||
case LPABORT:
|
||||
if (arg)
|
||||
LP_F(minor) |= LP_ABORT;
|
||||
else
|
||||
LP_F(minor) &= ~LP_ABORT;
|
||||
break;
|
||||
case LPABORTOPEN:
|
||||
if (arg)
|
||||
LP_F(minor) |= LP_ABORTOPEN;
|
||||
else
|
||||
LP_F(minor) &= ~LP_ABORTOPEN;
|
||||
break;
|
||||
case LPCAREFUL:
|
||||
if (arg)
|
||||
LP_F(minor) |= LP_CAREFUL;
|
||||
else
|
||||
LP_F(minor) &= ~LP_CAREFUL;
|
||||
break;
|
||||
case LPWAIT:
|
||||
LP_WAIT(minor) = arg;
|
||||
break;
|
||||
case LPSETIRQ:
|
||||
return -EINVAL;
|
||||
break;
|
||||
case LPGETIRQ:
|
||||
if (copy_to_user(argp, &LP_IRQ(minor),
|
||||
sizeof(int)))
|
||||
return -EFAULT;
|
||||
break;
|
||||
case LPGETSTATUS:
|
||||
if (mutex_lock_interruptible(&lp_table[minor].port_mutex))
|
||||
return -EINTR;
|
||||
lp_claim_parport_or_block (&lp_table[minor]);
|
||||
status = r_str(minor);
|
||||
lp_release_parport (&lp_table[minor]);
|
||||
mutex_unlock(&lp_table[minor].port_mutex);
|
||||
|
||||
|
||||
if (copy_to_user(argp, &status, sizeof(int)))
|
||||
return -EFAULT;
|
||||
break;
|
||||
case LPRESET:
|
||||
lp_reset(minor);
|
||||
break;
|
||||
case LPGETFLAGS:
|
||||
status = LP_F(minor);
|
||||
if (copy_to_user(argp, &status, sizeof(int)))
|
||||
return -EFAULT;
|
||||
break;
|
||||
default:
|
||||
retval = -EINVAL;
|
||||
}
|
||||
return retval
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节我们讲了字符设备的打开、写入和ioctl等最常见的操作。一个字符设备要能够工作,需要三部分配合。
|
||||
|
||||
第一,有一个设备驱动程序的ko模块,里面有模块初始化函数、中断处理函数、设备操作函数。这里面封装了对于外部设备的操作。加载设备驱动程序模块的时候,模块初始化函数会被调用。在内核维护所有字符设备驱动的数据结构cdev_map里面注册,我们就可以很容易根据设备号,找到相应的设备驱动程序。
|
||||
|
||||
第二,在/dev目录下有一个文件表示这个设备,这个文件在特殊的devtmpfs文件系统上,因而也有相应的dentry和inode。这里的inode是一个特殊的inode,里面有设备号。通过它,我们可以在cdev_map中找到设备驱动程序,里面还有针对字符设备文件的默认操作def_chr_fops。
|
||||
|
||||
第三,打开一个字符设备文件和打开一个普通的文件有类似的数据结构,有文件描述符、有struct file、指向字符设备文件的dentry和inode。字符设备文件的相关操作file_operations一开始指向def_chr_fops,在调用def_chr_fops里面的chrdev_open函数的时候,修改为指向设备操作函数,从而读写一个字符设备文件就会直接变成读写外部设备了。
|
||||
|
||||
<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="">
|
||||
625
极客时间专栏/趣谈Linux操作系统/核心原理篇:第六部分 输入输出系统/33 | 字符设备(下):如何建立直销模式?.md
Normal file
625
极客时间专栏/趣谈Linux操作系统/核心原理篇:第六部分 输入输出系统/33 | 字符设备(下):如何建立直销模式?.md
Normal 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, "logibm", NULL)) {
|
||||
printk(KERN_ERR "logibm.c: Can't allocate irq %d\n", logibm_irq);
|
||||
return -EBUSY;
|
||||
}
|
||||
outb(LOGIBM_ENABLE_IRQ, LOGIBM_CONTROL_PORT);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
static irqreturn_t logibm_interrupt(int irq, void *dev_id)
|
||||
{
|
||||
char dx, dy;
|
||||
unsigned char buttons;
|
||||
|
||||
|
||||
outb(LOGIBM_READ_X_LOW, LOGIBM_CONTROL_PORT);
|
||||
dx = (inb(LOGIBM_DATA_PORT) & 0xf);
|
||||
outb(LOGIBM_READ_X_HIGH, LOGIBM_CONTROL_PORT);
|
||||
dx |= (inb(LOGIBM_DATA_PORT) & 0xf) << 4;
|
||||
outb(LOGIBM_READ_Y_LOW, LOGIBM_CONTROL_PORT);
|
||||
dy = (inb(LOGIBM_DATA_PORT) & 0xf);
|
||||
outb(LOGIBM_READ_Y_HIGH, LOGIBM_CONTROL_PORT);
|
||||
buttons = inb(LOGIBM_DATA_PORT);
|
||||
dy |= (buttons & 0xf) << 4;
|
||||
buttons = ~buttons >> 5;
|
||||
|
||||
|
||||
input_report_rel(logibm_dev, REL_X, dx);
|
||||
input_report_rel(logibm_dev, REL_Y, dy);
|
||||
input_report_key(logibm_dev, BTN_RIGHT, buttons & 1);
|
||||
input_report_key(logibm_dev, BTN_MIDDLE, buttons & 2);
|
||||
input_report_key(logibm_dev, BTN_LEFT, buttons & 4);
|
||||
input_sync(logibm_dev);
|
||||
|
||||
|
||||
outb(LOGIBM_ENABLE_IRQ, LOGIBM_CONTROL_PORT);
|
||||
return IRQ_HANDLED
|
||||
|
||||
```
|
||||
|
||||
要处理中断,需要有一个中断处理函数。定义如下:
|
||||
|
||||
```
|
||||
irqreturn_t (*irq_handler_t)(int irq, void * dev_id);
|
||||
|
||||
|
||||
/**
|
||||
* enum irqreturn
|
||||
* @IRQ_NONE interrupt was not from this device or was not handled
|
||||
* @IRQ_HANDLED interrupt was handled by this device
|
||||
* @IRQ_WAKE_THREAD handler requests to wake the handler thread
|
||||
*/
|
||||
enum irqreturn {
|
||||
IRQ_NONE = (0 << 0),
|
||||
IRQ_HANDLED = (1 << 0),
|
||||
IRQ_WAKE_THREAD = (1 << 1),
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
其中,irq是一个整数,是中断信号。dev_id是一个void *的通用指针,主要用于区分同一个中断处理函数对于不同设备的处理。
|
||||
|
||||
这里的返回值有三种:IRQ_NONE表示不是我的中断,不归我管;IRQ_HANDLED表示处理完了的中断;IRQ_WAKE_THREAD表示有一个进程正在等待这个中断,中断处理完了,应该唤醒它。
|
||||
|
||||
上面的例子中,logibm_interrupt这个中断处理函数,先是获取了x和y的移动坐标,以及左中右的按键,上报上去,然后返回IRQ_HANDLED,这表示处理完毕。
|
||||
|
||||
其实,写一个真正生产用的中断处理程序还是很复杂的。当一个中断信号A触发后,正在处理的过程中,这个中断信号A是应该暂时关闭的,这样是为了防止再来一个中断信号A,在当前的中断信号A的处理过程中插一杠子。但是,这个暂时关闭的时间应该多长呢?
|
||||
|
||||
如果太短了,应该原子化处理完毕的没有处理完毕,又被另一个中断信号A中断了,很多操作就不正确了;如果太长了,一直关闭着,新的中断信号A进不来,系统就显得很慢。所以,很多中断处理程序将整个中断要做的事情分成两部分,称为上半部和下半部,或者成为关键处理部分和延迟处理部分。在中断处理函数中,仅仅处理关键部分,完成了就将中断信号打开,使得新的中断可以进来,需要比较长时间处理的部分,也即延迟部分,往往通过工作队列等方式慢慢处理。
|
||||
|
||||
这个写起来可以是一本书了,推荐你好好读一读《Linux Device Drivers》这本书,这里我就不详细介绍了。
|
||||
|
||||
有了中断处理函数,接下来要调用request_irq来注册这个中断处理函数。request_irq有这样几个参数:
|
||||
|
||||
- unsigned int irq是中断信号;
|
||||
- irq_handler_t handler是中断处理函数;
|
||||
- unsigned long flags是一些标识位;
|
||||
- const char *name是设备名称;
|
||||
- void *dev这个通用指针应该和中断处理函数的void *dev相对应。
|
||||
|
||||
```
|
||||
static inline int __must_check
|
||||
request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev)
|
||||
{
|
||||
return request_threaded_irq(irq, handler, NULL, flags, name, dev);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
中断处理函数被注册到哪里去呢?让我们沿着request_irq看下去。request_irq调用的是request_threaded_irq。代码如下:
|
||||
|
||||
```
|
||||
int request_threaded_irq(unsigned int irq, irq_handler_t handler,
|
||||
irq_handler_t thread_fn, unsigned long irqflags,
|
||||
const char *devname, void *dev_id)
|
||||
{
|
||||
struct irqaction *action;
|
||||
struct irq_desc *desc;
|
||||
int retval;
|
||||
......
|
||||
desc = irq_to_desc(irq);
|
||||
......
|
||||
action = kzalloc(sizeof(struct irqaction), GFP_KERNEL);
|
||||
action->handler = handler;
|
||||
action->thread_fn = thread_fn;
|
||||
action->flags = irqflags;
|
||||
action->name = devname;
|
||||
action->dev_id = dev_id;
|
||||
......
|
||||
retval = __setup_irq(irq, desc, action);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于每一个中断,都有一个对中断的描述结构struct irq_desc。它有一个重要的成员变量是struct irqaction,用于表示处理这个中断的动作。如果我们仔细看这个结构,会发现,它里面有next指针,也就是说,这是一个链表,对于这个中断的所有处理动作,都串在这个链表上。
|
||||
|
||||
```
|
||||
struct irq_desc {
|
||||
......
|
||||
struct irqaction *action; /* IRQ action list */
|
||||
......
|
||||
struct module *owner;
|
||||
const char *name;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* struct irqaction - per interrupt action descriptor
|
||||
* @handler: interrupt handler function
|
||||
* @name: name of the device
|
||||
* @dev_id: cookie to identify the device
|
||||
* @percpu_dev_id: cookie to identify the device
|
||||
* @next: pointer to the next irqaction for shared interrupts
|
||||
* @irq: interrupt number
|
||||
* @flags: flags (see IRQF_* above)
|
||||
* @thread_fn: interrupt handler function for threaded interrupts
|
||||
* @thread: thread pointer for threaded interrupts
|
||||
* @secondary: pointer to secondary irqaction (force threading)
|
||||
* @thread_flags: flags related to @thread
|
||||
* @thread_mask: bitmask for keeping track of @thread activity
|
||||
* @dir: pointer to the proc/irq/NN/name entry
|
||||
*/
|
||||
struct irqaction {
|
||||
irq_handler_t handler;
|
||||
void *dev_id;
|
||||
void __percpu *percpu_dev_id;
|
||||
struct irqaction *next;
|
||||
irq_handler_t thread_fn;
|
||||
struct task_struct *thread;
|
||||
struct irqaction *secondary;
|
||||
unsigned int irq;
|
||||
unsigned int flags;
|
||||
unsigned long thread_flags;
|
||||
unsigned long thread_mask;
|
||||
const char *name;
|
||||
struct proc_dir_entry *dir;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
每一个中断处理动作的结构struct irqaction,都有以下成员:
|
||||
|
||||
- 中断处理函数handler;
|
||||
- void *dev_id为设备id;
|
||||
- irq为中断信号;
|
||||
- 如果中断处理函数在单独的线程运行,则有thread_fn是线程的执行函数,thread是线程的task_struct。
|
||||
|
||||
在request_threaded_irq函数中,irq_to_desc根据中断信号查找中断描述结构。如何查找呢?这就要区分情况。一般情况下,所有的struct irq_desc都放在一个数组里面,我们直接按下标查找就可以了。如果配置了CONFIG_SPARSE_IRQ,那中断号是不连续的,就不适合用数组保存了,
|
||||
|
||||
我们可以放在一棵基数树上。我们不是第一次遇到这个数据结构了。这种结构对于从某个整型key找到value速度很快,中断信号irq是这个整数。通过它,我们很快就能定位到对应的struct irq_desc。
|
||||
|
||||
```
|
||||
#ifdef CONFIG_SPARSE_IRQ
|
||||
static RADIX_TREE(irq_desc_tree, GFP_KERNEL);
|
||||
struct irq_desc *irq_to_desc(unsigned int irq)
|
||||
{
|
||||
return radix_tree_lookup(&irq_desc_tree, irq);
|
||||
}
|
||||
#else /* !CONFIG_SPARSE_IRQ */
|
||||
struct irq_desc irq_desc[NR_IRQS] __cacheline_aligned_in_smp = {
|
||||
[0 ... NR_IRQS-1] = {
|
||||
}
|
||||
};
|
||||
struct irq_desc *irq_to_desc(unsigned int irq)
|
||||
{
|
||||
return (irq < NR_IRQS) ? irq_desc + irq : NULL;
|
||||
}
|
||||
#endif /* !CONFIG_SPARSE_IRQ */
|
||||
|
||||
```
|
||||
|
||||
为什么中断信号会有稀疏,也就是不连续的情况呢?这里需要说明一下,这里的irq并不是真正的、物理的中断信号,而是一个抽象的、虚拟的中断信号。因为物理的中断信号和硬件关联比较大,中断控制器也是各种各样的。
|
||||
|
||||
作为内核,我们不可能写程序的时候,适配各种各样的硬件中断控制器,因而就需要有一层中断抽象层。这里虚拟中断信号到中断描述结构的映射,就是抽象中断层的主要逻辑。
|
||||
|
||||
下面我们讲真正中断响应的时候,会涉及物理中断信号。可以想象,如果只有一个CPU,一个中断控制器,则基本能够保证从物理中断信号到虚拟中断信号的映射是线性的,这样用数组表示就没啥问题,但是如果有多个CPU,多个中断控制器,每个中断控制器各有各的物理中断信号,就没办法保证虚拟中断信号是连续的,所以就要用到基数树了。
|
||||
|
||||
接下来,request_threaded_irq函数分配了一个struct irqaction,并且初始化它,接着调用__setup_irq。在这个函数里面,如果struct irq_desc里面已经有struct irqaction了,我们就将新的struct irqaction挂在链表的末端。如果设定了以单独的线程运行中断处理函数,setup_irq_thread就会创建这个内核线程,wake_up_process会唤醒它。
|
||||
|
||||
```
|
||||
static int
|
||||
__setup_irq(unsigned int irq, struct irq_desc *desc, struct irqaction *new)
|
||||
{
|
||||
struct irqaction *old, **old_ptr;
|
||||
unsigned long flags, thread_mask = 0;
|
||||
int ret, nested, shared = 0;
|
||||
......
|
||||
new->irq = irq;
|
||||
......
|
||||
/*
|
||||
* Create a handler thread when a thread function is supplied
|
||||
* and the interrupt does not nest into another interrupt
|
||||
* thread.
|
||||
*/
|
||||
if (new->thread_fn && !nested) {
|
||||
ret = setup_irq_thread(new, irq, false);
|
||||
}
|
||||
......
|
||||
old_ptr = &desc->action;
|
||||
old = *old_ptr;
|
||||
if (old) {
|
||||
/* add new interrupt at end of irq queue */
|
||||
do {
|
||||
thread_mask |= old->thread_mask;
|
||||
old_ptr = &old->next;
|
||||
old = *old_ptr;
|
||||
} while (old);
|
||||
}
|
||||
......
|
||||
*old_ptr = new;
|
||||
......
|
||||
if (new->thread)
|
||||
wake_up_process(new->thread);
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
static int
|
||||
setup_irq_thread(struct irqaction *new, unsigned int irq, bool secondary)
|
||||
{
|
||||
struct task_struct *t;
|
||||
struct sched_param param = {
|
||||
.sched_priority = MAX_USER_RT_PRIO/2,
|
||||
};
|
||||
|
||||
|
||||
t = kthread_create(irq_thread, new, "irq/%d-%s", irq, new->name);
|
||||
sched_setscheduler_nocheck(t, SCHED_FIFO, &param);
|
||||
get_task_struct(t);
|
||||
new->thread = t;
|
||||
......
|
||||
return 0;
|
||||
|
||||
```
|
||||
|
||||
至此为止,request_irq完成了它的使命。总结来说,它就是根据中断信号irq,找到基数树上对应的irq_desc,然后将新的irqaction挂在链表上。
|
||||
|
||||
接下来,我们就来看,真正中断来了的时候,会发生一些什么。
|
||||
|
||||
真正中断的发生还是要从硬件开始。这里面有四个层次。
|
||||
|
||||
- 第一个层次是外部设备给中断控制器发送物理中断信号。
|
||||
- 第二个层次是中断控制器将物理中断信号转换成为中断向量interrupt vector,发给各个CPU。
|
||||
- 第三个层次是每个CPU都会有一个中断向量表,根据interrupt vector调用一个IRQ处理函数。注意这里的IRQ处理函数还不是咱们上面指定的irq_handler_t,到这一层还是CPU硬件的要求。
|
||||
- 第四个层次是在IRQ处理函数中,将interrupt vector转化为抽象中断层的中断信号irq,调用中断信号irq对应的中断描述结构里面的irq_handler_t。
|
||||
|
||||
<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 < FIRST_EXTERNAL_VECTOR; i++)
|
||||
set_bit(i, used_vectors);
|
||||
|
||||
|
||||
#ifdef CONFIG_X86_32
|
||||
set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);
|
||||
set_bit(IA32_SYSCALL_VECTOR, used_vectors);
|
||||
#endif
|
||||
|
||||
|
||||
/*
|
||||
* Set the IDT descriptor to a fixed read-only location, so that the
|
||||
* "sidt" instruction will not leak the location of the kernel, and
|
||||
* to defend the IDT against arbitrary memory write vulnerabilities.
|
||||
* It will be reloaded in cpu_init() */
|
||||
__set_fixmap(FIX_RO_IDT, __pa_symbol(idt_table), PAGE_KERNEL_RO);
|
||||
idt_descr.address = fix_to_virt(FIX_RO_IDT);
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
我这里贴的代码省略了很多,在trap_init函数的一开始,调用了大量的set_intr_gate,最终都会调用_set_gate,代码如下:
|
||||
|
||||
```
|
||||
static inline void _set_gate(int gate, unsigned type, void *addr,
|
||||
unsigned dpl, unsigned ist, unsigned seg)
|
||||
{
|
||||
gate_desc s;
|
||||
pack_gate(&s, type, (unsigned long)addr, dpl, ist, seg);
|
||||
write_idt_entry(idt_table, gate, &s);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从代码可以看出,set_intr_gate其实就是将每个中断都设置了中断处理函数,放在中断向量表idt_table中。
|
||||
|
||||
在trap_init中,由于set_intr_gate调用的太多,容易让人眼花缭乱。其实arch/x86/include/asm/traps.h文件中,早就定义好了前32个中断。如果仔细对比一下,你会发现,这些都在trap_init中使用set_intr_gate设置过了。
|
||||
|
||||
```
|
||||
/* Interrupts/Exceptions */
|
||||
enum {
|
||||
X86_TRAP_DE = 0, /* 0, Divide-by-zero */
|
||||
X86_TRAP_DB, /* 1, Debug */
|
||||
X86_TRAP_NMI, /* 2, Non-maskable Interrupt */
|
||||
X86_TRAP_BP, /* 3, Breakpoint */
|
||||
X86_TRAP_OF, /* 4, Overflow */
|
||||
X86_TRAP_BR, /* 5, Bound Range Exceeded */
|
||||
X86_TRAP_UD, /* 6, Invalid Opcode */
|
||||
X86_TRAP_NM, /* 7, Device Not Available */
|
||||
X86_TRAP_DF, /* 8, Double Fault */
|
||||
X86_TRAP_OLD_MF, /* 9, Coprocessor Segment Overrun */
|
||||
X86_TRAP_TS, /* 10, Invalid TSS */
|
||||
X86_TRAP_NP, /* 11, Segment Not Present */
|
||||
X86_TRAP_SS, /* 12, Stack Segment Fault */
|
||||
X86_TRAP_GP, /* 13, General Protection Fault */
|
||||
X86_TRAP_PF, /* 14, Page Fault */
|
||||
X86_TRAP_SPURIOUS, /* 15, Spurious Interrupt */
|
||||
X86_TRAP_MF, /* 16, x87 Floating-Point Exception */
|
||||
X86_TRAP_AC, /* 17, Alignment Check */
|
||||
X86_TRAP_MC, /* 18, Machine Check */
|
||||
X86_TRAP_XF, /* 19, SIMD Floating-Point Exception */
|
||||
X86_TRAP_IRET = 32, /* 32, IRET Exception */
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
我们回到trap_init中,当前32个中断都用set_intr_gate设置完毕。在中断向量表idt_table中填完了之后,接下来的for循环,for (i = 0; i < FIRST_EXTERNAL_VECTOR; i++),将前32个中断都在used_vectors中标记为1,表示这些都设置过中断处理函数了。
|
||||
|
||||
接下来,trap_init单独调用set_intr_gate来设置32位系统调用的中断。IA32_SYSCALL_VECTOR,也即128,单独将used_vectors中的第128位标记为1。
|
||||
|
||||
在trap_init的最后,我们将idt_table放在一个固定的虚拟地址上。trap_init结束后,中断向量表中已经填好了前32位,外加一位32位系统调用,其他的都是用于设备中断。
|
||||
|
||||
在start_kernel调用完毕trap_init之后,还会调用init_IRQ()来初始化其他的设备中断,最终会调用到native_init_IRQ。
|
||||
|
||||
```
|
||||
void __init native_init_IRQ(void)
|
||||
{
|
||||
int i;
|
||||
i = FIRST_EXTERNAL_VECTOR;
|
||||
#ifndef CONFIG_X86_LOCAL_APIC
|
||||
#define first_system_vector NR_VECTORS
|
||||
#endif
|
||||
for_each_clear_bit_from(i, used_vectors, first_system_vector) {
|
||||
/* IA32_SYSCALL_VECTOR could be used in trap_init already. */
|
||||
set_intr_gate(i, irq_entries_start +
|
||||
8 * (i - FIRST_EXTERNAL_VECTOR));
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面从第32个中断开始,到最后NR_VECTORS为止,对于used_vectors中没有标记为1的位置,都会调用set_intr_gate设置中断向量表。
|
||||
|
||||
其实used_vectors中没有标记为1的,都是设备中断的部分。
|
||||
|
||||
也即所有的设备中断的中断处理函数,在中断向量表里面都会设置为从irq_entries_start开始,偏移量为i - FIRST_EXTERNAL_VECTOR的一项。
|
||||
|
||||
看来中断处理函数是定义在irq_entries_start这个表里面的,我们在arch\x86\entry\entry_32.S和arch\x86\entry\entry_64.S都能找到这个函数表的定义。
|
||||
|
||||
这又是汇编语言,不需要完全看懂,但是我们还是能看出来,这里面定义了FIRST_SYSTEM_VECTOR - FIRST_EXTERNAL_VECTOR项。每一项都是中断处理函数,会跳到common_interrupt去执行。这里会最终调用do_IRQ,调用完毕后,就从中断返回。这里我们需要区分返回用户态还是内核态。这里会有一个机会触发抢占,咱们讲进程切换的时候讲过的。
|
||||
|
||||
```
|
||||
ENTRY(irq_entries_start)
|
||||
vector=FIRST_EXTERNAL_VECTOR
|
||||
.rept (FIRST_SYSTEM_VECTOR - FIRST_EXTERNAL_VECTOR)
|
||||
pushl $(~vector+0x80) /* Note: always in signed byte range */
|
||||
vector=vector+1
|
||||
jmp common_interrupt /* 会调用到do_IRQ */
|
||||
.align 8
|
||||
.endr
|
||||
END(irq_entries_start)
|
||||
|
||||
|
||||
common_interrupt:
|
||||
ASM_CLAC
|
||||
addq $-0x80, (%rsp) /* Adjust vector to [-256, -1] range */
|
||||
interrupt do_IRQ
|
||||
/* 0(%rsp): old RSP */
|
||||
ret_from_intr:
|
||||
......
|
||||
/* Interrupt came from user space */
|
||||
GLOBAL(retint_user)
|
||||
......
|
||||
/* Returning to kernel space */
|
||||
retint_kernel:
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
这样任何一个中断向量到达任何一个CPU,最终都会走到do_IRQ。我们来看do_IRQ的实现。
|
||||
|
||||
```
|
||||
/*
|
||||
* do_IRQ handles all normal device IRQ's (the special
|
||||
* SMP cross-CPU interrupts have their own specific
|
||||
* handlers).
|
||||
*/
|
||||
__visible unsigned int __irq_entry do_IRQ(struct pt_regs *regs)
|
||||
{
|
||||
struct pt_regs *old_regs = set_irq_regs(regs);
|
||||
struct irq_desc * desc;
|
||||
/* high bit used in ret_from_ code */
|
||||
unsigned vector = ~regs->orig_ax;
|
||||
......
|
||||
desc = __this_cpu_read(vector_irq[vector]);
|
||||
if (!handle_irq(desc, regs)) {
|
||||
......
|
||||
}
|
||||
......
|
||||
set_irq_regs(old_regs);
|
||||
return 1;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里面,从AX寄存器里面拿到了中断向量vector,但是别忘了中断控制器发送给每个CPU的中断向量都是每个CPU局部的,而抽象中断处理层的虚拟中断信号irq以及它对应的中断描述结构irq_desc是全局的,也即这个CPU的200号的中断向量和另一个CPU的200号中断向量对应的虚拟中断信号irq和中断描述结构irq_desc可能不一样,这就需要一个映射关系。这个映射关系放在Per CPU变量vector_irq里面。
|
||||
|
||||
```
|
||||
DECLARE_PER_CPU(vector_irq_t, vector_irq);
|
||||
|
||||
```
|
||||
|
||||
在系统初始化的时候,我们会调用__assign_irq_vector,将虚拟中断信号irq分配到某个CPU上的中断向量。
|
||||
|
||||
```
|
||||
static int __assign_irq_vector(int irq, struct apic_chip_data *d,
|
||||
const struct cpumask *mask,
|
||||
struct irq_data *irqdata)
|
||||
{
|
||||
static int current_vector = FIRST_EXTERNAL_VECTOR + VECTOR_OFFSET_START;
|
||||
static int current_offset = VECTOR_OFFSET_START % 16;
|
||||
int cpu, vector;
|
||||
......
|
||||
while (cpu < nr_cpu_ids) {
|
||||
int new_cpu, offset;
|
||||
......
|
||||
vector = current_vector;
|
||||
offset = current_offset;
|
||||
next:
|
||||
vector += 16;
|
||||
if (vector >= first_system_vector) {
|
||||
offset = (offset + 1) % 16;
|
||||
vector = FIRST_EXTERNAL_VECTOR + offset;
|
||||
}
|
||||
|
||||
|
||||
/* If the search wrapped around, try the next cpu */
|
||||
if (unlikely(current_vector == vector))
|
||||
goto next_cpu;
|
||||
|
||||
|
||||
|
||||
|
||||
if (test_bit(vector, used_vectors))
|
||||
goto next;
|
||||
|
||||
|
||||
......
|
||||
/* Found one! */
|
||||
current_vector = vector;
|
||||
current_offset = offset;
|
||||
/* Schedule the old vector for cleanup on all cpus */
|
||||
if (d->cfg.vector)
|
||||
cpumask_copy(d->old_domain, d->domain);
|
||||
for_each_cpu(new_cpu, vector_searchmask)
|
||||
per_cpu(vector_irq, new_cpu)[vector] = irq_to_desc(irq);
|
||||
goto update;
|
||||
|
||||
|
||||
next_cpu:
|
||||
cpumask_or(searched_cpumask, searched_cpumask, vector_cpumask);
|
||||
cpumask_andnot(vector_cpumask, mask, searched_cpumask);
|
||||
cpu = cpumask_first_and(vector_cpumask, cpu_online_mask);
|
||||
continue;
|
||||
}
|
||||
....
|
||||
|
||||
```
|
||||
|
||||
在这里,一旦找到某个向量,就将CPU的此向量对应的向量描述结构irq_desc,设置为虚拟中断信号irq对应的向量描述结构irq_to_desc(irq)。
|
||||
|
||||
这样do_IRQ会根据中断向量vector得到对应的irq_desc,然后调用handle_irq。handle_irq会调用generic_handle_irq_desc,里面调用irq_desc的handle_irq。
|
||||
|
||||
```
|
||||
static inline void generic_handle_irq_desc(struct irq_desc *desc)
|
||||
{
|
||||
desc->handle_irq(desc);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里的handle_irq,最终会调用__handle_irq_event_percpu。代码如下:
|
||||
|
||||
```
|
||||
irqreturn_t __handle_irq_event_percpu(struct irq_desc *desc, unsigned int *flags)
|
||||
{
|
||||
irqreturn_t retval = IRQ_NONE;
|
||||
unsigned int irq = desc->irq_data.irq;
|
||||
struct irqaction *action;
|
||||
|
||||
|
||||
record_irq_time(desc);
|
||||
|
||||
|
||||
for_each_action_of_desc(desc, action) {
|
||||
irqreturn_t res;
|
||||
res = action->handler(irq, action->dev_id);
|
||||
switch (res) {
|
||||
case IRQ_WAKE_THREAD:
|
||||
__irq_wake_thread(desc, action);
|
||||
case IRQ_HANDLED:
|
||||
*flags |= action->flags;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
retval |= res;
|
||||
}
|
||||
return retval;
|
||||
|
||||
```
|
||||
|
||||
__handle_irq_event_percpu里面调用了irq_desc里每个hander,这些hander是我们在所有action列表中注册的,这才是我们设置的那个中断处理函数。如果返回值是IRQ_HANDLED,就说明处理完毕;如果返回值是IRQ_WAKE_THREAD就唤醒线程。
|
||||
|
||||
至此,中断的整个过程就结束了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们讲了中断的整个处理过程。中断是从外部设备发起的,会形成外部中断。外部中断会到达中断控制器,中断控制器会发送中断向量Interrupt Vector给CPU。
|
||||
|
||||
对于每一个CPU,都要求有一个idt_table,里面存放了不同的中断向量的处理函数。中断向量表中已经填好了前32位,外加一位32位系统调用,其他的都是用于设备中断。
|
||||
|
||||
硬件中断的处理函数是do_IRQ进行统一处理,在这里会让中断向量,通过vector_irq映射为irq_desc。
|
||||
|
||||
irq_desc是一个用于描述用户注册的中断处理函数的结构,为了能够根据中断向量得到irq_desc结构,会把这些结构放在一个基数树里面,方便查找。
|
||||
|
||||
irq_desc里面有一个成员是irqaction,指向设备驱动程序里面注册的中断处理函数。
|
||||
|
||||
<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="">
|
||||
658
极客时间专栏/趣谈Linux操作系统/核心原理篇:第六部分 输入输出系统/34 | 块设备(上):如何建立代理商销售模式?.md
Normal file
658
极客时间专栏/趣谈Linux操作系统/核心原理篇:第六部分 输入输出系统/34 | 块设备(上):如何建立代理商销售模式?.md
Normal 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->i_mode = mode;
|
||||
if (S_ISCHR(mode)) {
|
||||
inode->i_fop = &def_chr_fops;
|
||||
inode->i_rdev = rdev;
|
||||
} else if (S_ISBLK(mode)) {
|
||||
inode->i_fop = &def_blk_fops;
|
||||
inode->i_rdev = rdev;
|
||||
} else if (S_ISFIFO(mode))
|
||||
inode->i_fop = &pipefifo_fops;
|
||||
else if (S_ISSOCK(mode))
|
||||
; /* leave it no_open_fops */
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
特殊inode的默认file_operations是def_blk_fops,就像字符设备一样,有打开、读写这个块设备文件,但是我们常规操作不会这样做。我们会将这个块设备文件mount到一个文件夹下面。
|
||||
|
||||
```
|
||||
const struct file_operations def_blk_fops = {
|
||||
.open = blkdev_open,
|
||||
.release = blkdev_close,
|
||||
.llseek = block_llseek,
|
||||
.read_iter = blkdev_read_iter,
|
||||
.write_iter = blkdev_write_iter,
|
||||
.mmap = generic_file_mmap,
|
||||
.fsync = blkdev_fsync,
|
||||
.unlocked_ioctl = block_ioctl,
|
||||
.splice_read = generic_file_splice_read,
|
||||
.splice_write = iter_file_splice_write,
|
||||
.fallocate = blkdev_fallocate,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
不过,这里我们还是简单看一下,打开这个块设备的操作blkdev_open。它里面调用的是blkdev_get打开这个块设备,了解到这一点就可以了。
|
||||
|
||||
接下来,我们要调用mount,将这个块设备文件挂载到一个文件夹下面。如果这个块设备原来被格式化为一种文件系统的格式,例如ext4,那我们调用的就是ext4相应的mount操作。**这是块设备遇到的第二个文件系统**,也是向这个块设备读写文件,需要基于的主流文件系统。咱们在文件系统那一节解析的对于文件的读写流程,都是基于这个文件系统的。
|
||||
|
||||
还记得,咱们注册ext4文件系统的时候,有下面这样的结构:
|
||||
|
||||
```
|
||||
static struct file_system_type ext4_fs_type = {
|
||||
.owner = THIS_MODULE,
|
||||
.name = "ext4",
|
||||
.mount = ext4_mount,
|
||||
.kill_sb = kill_block_super,
|
||||
.fs_flags = FS_REQUIRES_DEV,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在将一个硬盘的块设备mount成为ext4的时候,我们会调用ext4_mount->mount_bdev。
|
||||
|
||||
```
|
||||
static struct dentry *ext4_mount(struct file_system_type *fs_type, int flags, const char *dev_name, void *data)
|
||||
{
|
||||
return mount_bdev(fs_type, flags, dev_name, data, ext4_fill_super);
|
||||
}
|
||||
|
||||
|
||||
struct dentry *mount_bdev(struct file_system_type *fs_type,
|
||||
int flags, const char *dev_name, void *data,
|
||||
int (*fill_super)(struct super_block *, void *, int))
|
||||
{
|
||||
struct block_device *bdev;
|
||||
struct super_block *s;
|
||||
fmode_t mode = FMODE_READ | FMODE_EXCL;
|
||||
int error = 0;
|
||||
|
||||
|
||||
if (!(flags & MS_RDONLY))
|
||||
mode |= FMODE_WRITE;
|
||||
|
||||
|
||||
bdev = blkdev_get_by_path(dev_name, mode, fs_type);
|
||||
......
|
||||
s = sget(fs_type, test_bdev_super, set_bdev_super, flags | MS_NOSEC, bdev);
|
||||
......
|
||||
return dget(s->s_root);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
mount_bdev主要做了两件大事情。第一,blkdev_get_by_path根据/dev/xxx这个名字,找到相应的设备并打开它;第二,sget根据打开的设备文件,填充ext4文件系统的super_block,从而以此为基础,建立一整套咱们在文件系统那一章讲的体系。
|
||||
|
||||
一旦这套体系建立起来以后,对于文件的读写都是通过ext4文件系统这个体系进行的,创建的inode结构也是指向ext4文件系统的。文件系统那一章我们只解析了这部分,由于没有到达底层,也就没有关注块设备相关的操作。这一章我们重新回过头来,一方面看mount的时候,对于块设备都做了哪些操作,另一方面看读写的时候,到了底层,对于块设备做了哪些操作。
|
||||
|
||||
这里我们先来看mount_bdev做的第一件大事情,通过blkdev_get_by_path,根据设备名/dev/xxx,得到struct block_device *bdev。
|
||||
|
||||
```
|
||||
/**
|
||||
* blkdev_get_by_path - open a block device by name
|
||||
* @path: path to the block device to open
|
||||
* @mode: FMODE_* mask
|
||||
* @holder: exclusive holder identifier
|
||||
*
|
||||
* Open the blockdevice described by the device file at @path. @mode
|
||||
* and @holder are identical to blkdev_get().
|
||||
*
|
||||
* On success, the returned block_device has reference count of one.
|
||||
*/
|
||||
struct block_device *blkdev_get_by_path(const char *path, fmode_t mode,
|
||||
void *holder)
|
||||
{
|
||||
struct block_device *bdev;
|
||||
int err;
|
||||
|
||||
|
||||
bdev = lookup_bdev(path);
|
||||
......
|
||||
err = blkdev_get(bdev, mode, holder);
|
||||
......
|
||||
return bdev;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
blkdev_get_by_path干了两件事情。第一个,lookup_bdev根据设备路径/dev/xxx得到block_device。第二个,打开这个设备,调用blkdev_get。
|
||||
|
||||
咱们上面分析过def_blk_fops的默认打开设备函数blkdev_open,它也是调用blkdev_get的。块设备的打开往往不是直接调用设备文件的打开函数,而是调用mount来打开的。
|
||||
|
||||
```
|
||||
/**
|
||||
* lookup_bdev - lookup a struct block_device by name
|
||||
* @pathname: special file representing the block device
|
||||
*
|
||||
* Get a reference to the blockdevice at @pathname in the current
|
||||
* namespace if possible and return it. Return ERR_PTR(error)
|
||||
* otherwise.
|
||||
*/
|
||||
struct block_device *lookup_bdev(const char *pathname)
|
||||
{
|
||||
struct block_device *bdev;
|
||||
struct inode *inode;
|
||||
struct path path;
|
||||
int error;
|
||||
|
||||
|
||||
if (!pathname || !*pathname)
|
||||
return ERR_PTR(-EINVAL);
|
||||
|
||||
|
||||
error = kern_path(pathname, LOOKUP_FOLLOW, &path);
|
||||
if (error)
|
||||
return ERR_PTR(error);
|
||||
|
||||
|
||||
inode = d_backing_inode(path.dentry);
|
||||
......
|
||||
bdev = bd_acquire(inode);
|
||||
......
|
||||
goto out;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
lookup_bdev这里的pathname是设备的文件名,例如/dev/xxx。这个文件是在devtmpfs文件系统中的,kern_path可以在这个文件系统里面,一直找到它对应的dentry。接下来,d_backing_inode会获得inode。这个inode就是那个init_special_inode生成的特殊inode。
|
||||
|
||||
接下来,bd_acquire通过这个特殊的inode,找到struct block_device。
|
||||
|
||||
```
|
||||
static struct block_device *bd_acquire(struct inode *inode)
|
||||
{
|
||||
struct block_device *bdev;
|
||||
......
|
||||
bdev = bdget(inode->i_rdev);
|
||||
if (bdev) {
|
||||
spin_lock(&bdev_lock);
|
||||
if (!inode->i_bdev) {
|
||||
/*
|
||||
* We take an additional reference to bd_inode,
|
||||
* and it's released in clear_inode() of inode.
|
||||
* So, we can access it via ->i_mapping always
|
||||
* without igrab().
|
||||
*/
|
||||
bdgrab(bdev);
|
||||
inode->i_bdev = bdev;
|
||||
inode->i_mapping = bdev->bd_inode->i_mapping;
|
||||
}
|
||||
}
|
||||
return bdev;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
bd_acquire中最主要的就是调用bdget,它的参数是特殊inode的i_rdev。这里面在mknod的时候,放的是设备号dev_t。
|
||||
|
||||
```
|
||||
struct block_device *bdget(dev_t dev)
|
||||
{
|
||||
struct block_device *bdev;
|
||||
struct inode *inode;
|
||||
|
||||
|
||||
inode = iget5_locked(blockdev_superblock, hash(dev),
|
||||
bdev_test, bdev_set, &dev);
|
||||
|
||||
bdev = &BDEV_I(inode)->bdev;
|
||||
|
||||
|
||||
if (inode->i_state & I_NEW) {
|
||||
bdev->bd_contains = NULL;
|
||||
bdev->bd_super = NULL;
|
||||
bdev->bd_inode = inode;
|
||||
bdev->bd_block_size = i_blocksize(inode);
|
||||
bdev->bd_part_count = 0;
|
||||
bdev->bd_invalidated = 0;
|
||||
inode->i_mode = S_IFBLK;
|
||||
inode->i_rdev = dev;
|
||||
inode->i_bdev = bdev;
|
||||
inode->i_data.a_ops = &def_blk_aops;
|
||||
mapping_set_gfp_mask(&inode->i_data, GFP_USER);
|
||||
spin_lock(&bdev_lock);
|
||||
list_add(&bdev->bd_list, &all_bdevs);
|
||||
spin_unlock(&bdev_lock);
|
||||
unlock_new_inode(inode);
|
||||
}
|
||||
return bdev;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**在bdget中,我们遇到了第三个文件系统,bdev伪文件系统**。bdget函数根据传进来的dev_t,在blockdev_superblock这个文件系统里面找到inode。这里注意,这个inode已经不是devtmpfs文件系统的inode了。blockdev_superblock的初始化在整个系统初始化的时候,会调用bdev_cache_init进行初始化。它的定义如下:
|
||||
|
||||
```
|
||||
struct super_block *blockdev_superblock __read_mostly;
|
||||
|
||||
|
||||
static struct file_system_type bd_type = {
|
||||
.name = "bdev",
|
||||
.mount = bd_mount,
|
||||
.kill_sb = kill_anon_super,
|
||||
};
|
||||
|
||||
|
||||
void __init bdev_cache_init(void)
|
||||
{
|
||||
int err;
|
||||
static struct vfsmount *bd_mnt;
|
||||
|
||||
|
||||
bdev_cachep = kmem_cache_create("bdev_cache", sizeof(struct bdev_inode), 0, (SLAB_HWCACHE_ALIGN|SLAB_RECLAIM_ACCOUNT|SLAB_MEM_SPREAD|SLAB_ACCOUNT|SLAB_PANIC), init_once);
|
||||
err = register_filesystem(&bd_type);
|
||||
if (err)
|
||||
panic("Cannot register bdev pseudo-fs");
|
||||
bd_mnt = kern_mount(&bd_type);
|
||||
if (IS_ERR(bd_mnt))
|
||||
panic("Cannot create bdev pseudo-fs");
|
||||
blockdev_superblock = bd_mnt->mnt_sb; /* For writeback */
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所有表示块设备的inode都保存在伪文件系统 bdev中,这些对用户层不可见,主要为了方便块设备的管理。Linux将块设备的block_device和bdev文件系统的块设备的inode,通过struct bdev_inode进行关联。所以,在bdget中,BDEV_I就是通过bdev文件系统的inode,获得整个struct bdev_inode结构的地址,然后取成员bdev,得到block_device。
|
||||
|
||||
```
|
||||
struct bdev_inode {
|
||||
struct block_device bdev;
|
||||
struct inode vfs_inode;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
绕了一大圈,我们终于通过设备文件/dev/xxx,获得了设备的结构block_device。有点儿绕,我们再捋一下。设备文件/dev/xxx在devtmpfs文件系统中,找到devtmpfs文件系统中的inode,里面有dev_t。我们可以通过dev_t,在伪文件系统 bdev中找到对应的inode,然后根据struct bdev_inode找到关联的block_device。
|
||||
|
||||
接下来,blkdev_get_by_path开始做第二件事情,在找到block_device之后,要调用blkdev_get打开这个设备。blkdev_get会调用__blkdev_get。
|
||||
|
||||
在分析打开一个设备之前,我们先来看block_device这个结构是什么样的。
|
||||
|
||||
```
|
||||
struct block_device {
|
||||
dev_t bd_dev; /* not a kdev_t - it's a search key */
|
||||
int bd_openers;
|
||||
struct super_block * bd_super;
|
||||
......
|
||||
struct block_device * bd_contains;
|
||||
unsigned bd_block_size;
|
||||
struct hd_struct * bd_part;
|
||||
unsigned bd_part_count;
|
||||
int bd_invalidated;
|
||||
struct gendisk * bd_disk;
|
||||
struct request_queue * bd_queue;
|
||||
struct backing_dev_info *bd_bdi;
|
||||
struct list_head bd_list;
|
||||
......
|
||||
} ;
|
||||
|
||||
```
|
||||
|
||||
你应该能发现,这个结构和其他几个结构有着千丝万缕的联系,比较复杂。这是因为块设备本身就比较复杂。
|
||||
|
||||
比方说,我们有一个磁盘/dev/sda,我们既可以把它整个格式化成一个文件系统,也可以把它分成多个分区/dev/sda1、 /dev/sda2,然后把每个分区格式化成不同的文件系统。如果我们访问某个分区的设备文件/dev/sda2,我们应该能知道它是哪个磁盘设备的。按说它们的驱动应该是一样的。如果我们访问整个磁盘的设备文件/dev/sda,我们也应该能知道它分了几个区域,所以就有了下图这个复杂的关系结构。
|
||||
|
||||
<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_struct,bd_contains指向的是整个块设备的block_device。
|
||||
|
||||
了解了这些复杂的关系,我们再来看打开设备文件的代码,就会清晰很多。
|
||||
|
||||
```
|
||||
static int __blkdev_get(struct block_device *bdev, fmode_t mode, int for_part)
|
||||
{
|
||||
struct gendisk *disk;
|
||||
struct module *owner;
|
||||
int ret;
|
||||
int partno;
|
||||
int perm = 0;
|
||||
|
||||
|
||||
if (mode & FMODE_READ)
|
||||
perm |= MAY_READ;
|
||||
if (mode & FMODE_WRITE)
|
||||
perm |= MAY_WRITE;
|
||||
......
|
||||
disk = get_gendisk(bdev->bd_dev, &partno);
|
||||
......
|
||||
owner = disk->fops->owner;
|
||||
......
|
||||
if (!bdev->bd_openers) {
|
||||
bdev->bd_disk = disk;
|
||||
bdev->bd_queue = disk->queue;
|
||||
bdev->bd_contains = bdev;
|
||||
|
||||
|
||||
if (!partno) {
|
||||
ret = -ENXIO;
|
||||
bdev->bd_part = disk_get_part(disk, partno);
|
||||
......
|
||||
if (disk->fops->open) {
|
||||
ret = disk->fops->open(bdev, mode);
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
if (!ret)
|
||||
bd_set_size(bdev,(loff_t)get_capacity(disk)<<9);
|
||||
|
||||
|
||||
if (bdev->bd_invalidated) {
|
||||
if (!ret)
|
||||
rescan_partitions(disk, bdev);
|
||||
......
|
||||
}
|
||||
......
|
||||
} else {
|
||||
struct block_device *whole;
|
||||
whole = bdget_disk(disk, 0);
|
||||
......
|
||||
ret = __blkdev_get(whole, mode, 1);
|
||||
......
|
||||
bdev->bd_contains = whole;
|
||||
bdev->bd_part = disk_get_part(disk, partno);
|
||||
......
|
||||
bd_set_size(bdev, (loff_t)bdev->bd_part->nr_sects << 9);
|
||||
}
|
||||
}
|
||||
......
|
||||
bdev->bd_openers++;
|
||||
if (for_part)
|
||||
bdev->bd_part_count++;
|
||||
.....
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在__blkdev_get函数中,我们先调用get_gendisk,根据block_device获取gendisk。具体代码如下:
|
||||
|
||||
```
|
||||
/**
|
||||
* get_gendisk - get partitioning information for a given device
|
||||
* @devt: device to get partitioning information for
|
||||
* @partno: returned partition index
|
||||
*
|
||||
* This function gets the structure containing partitioning
|
||||
* information for the given device @devt.
|
||||
*/
|
||||
struct gendisk *get_gendisk(dev_t devt, int *partno)
|
||||
{
|
||||
struct gendisk *disk = NULL;
|
||||
|
||||
|
||||
if (MAJOR(devt) != BLOCK_EXT_MAJOR) {
|
||||
struct kobject *kobj;
|
||||
|
||||
|
||||
kobj = kobj_lookup(bdev_map, devt, partno);
|
||||
if (kobj)
|
||||
disk = dev_to_disk(kobj_to_dev(kobj));
|
||||
} else {
|
||||
struct hd_struct *part;
|
||||
part = idr_find(&ext_devt_idr, blk_mangle_minor(MINOR(devt)));
|
||||
if (part && get_disk(part_to_disk(part))) {
|
||||
*partno = part->partno;
|
||||
disk = part_to_disk(part);
|
||||
}
|
||||
}
|
||||
return disk;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们可以想象这里面有两种情况。第一种情况是,block_device是指向整个磁盘设备的。这个时候,我们只需要根据dev_t,在bdev_map中将对应的gendisk拿出来就好。
|
||||
|
||||
bdev_map是干什么的呢?前面咱们学习字符设备驱动的时候讲过,任何一个字符设备初始化的时候,都需要调用__register_chrdev_region,注册这个字符设备。对于块设备也是类似的,每一个块设备驱动初始化的时候,都会调用add_disk注册一个gendisk。
|
||||
|
||||
这里需要说明一下,gen的意思是general通用的意思,也就是说,所有的块设备,不仅仅是硬盘disk,都会用一个gendisk来表示,然后通过调用链add_disk->device_add_disk->blk_register_region,将dev_t和一个gendisk关联起来,保存在bdev_map中。
|
||||
|
||||
```
|
||||
static struct kobj_map *bdev_map;
|
||||
|
||||
|
||||
static inline void add_disk(struct gendisk *disk)
|
||||
{
|
||||
device_add_disk(NULL, disk);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* device_add_disk - add partitioning information to kernel list
|
||||
* @parent: parent device for the disk
|
||||
* @disk: per-device partitioning information
|
||||
*
|
||||
* This function registers the partitioning information in @disk
|
||||
* with the kernel.
|
||||
*/
|
||||
void device_add_disk(struct device *parent, struct gendisk *disk)
|
||||
{
|
||||
......
|
||||
blk_register_region(disk_devt(disk), disk->minors, NULL,
|
||||
exact_match, exact_lock, disk);
|
||||
.....
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* Register device numbers dev..(dev+range-1)
|
||||
* range must be nonzero
|
||||
* The hash chain is sorted on range, so that subranges can override.
|
||||
*/
|
||||
void blk_register_region(dev_t devt, unsigned long range, struct module *module,
|
||||
struct kobject *(*probe)(dev_t, int *, void *),
|
||||
int (*lock)(dev_t, void *), void *data)
|
||||
{
|
||||
kobj_map(bdev_map, devt, range, module, probe, lock, data);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
get_gendisk要处理的第二种情况是,block_device是指向某个分区的。这个时候我们要先得到hd_struct,然后通过hd_struct,找到对应的整个设备的gendisk,并且把partno设置为分区号。
|
||||
|
||||
我们再回到__blkdev_get函数中,得到gendisk。接下来我们可以分两种情况。
|
||||
|
||||
如果partno为0,也就是说,打开的是整个设备而不是分区,那我们就调用disk_get_part,获取gendisk中的分区数组,然后调用block_device_operations里面的open函数打开设备。
|
||||
|
||||
如果partno不为0,也就是说打开的是分区,那我们就获取整个设备的block_device,赋值给变量struct block_device *whole,然后调用递归__blkdev_get,打开whole代表的整个设备,将bd_contains设置为变量whole。
|
||||
|
||||
block_device_operations就是在驱动层了。例如在drivers/scsi/sd.c里面,也就是MODULE_DESCRIPTION(“SCSI disk (sd) driver”)中,就有这样的定义。
|
||||
|
||||
```
|
||||
static const struct block_device_operations sd_fops = {
|
||||
.owner = THIS_MODULE,
|
||||
.open = sd_open,
|
||||
.release = sd_release,
|
||||
.ioctl = sd_ioctl,
|
||||
.getgeo = sd_getgeo,
|
||||
#ifdef CONFIG_COMPAT
|
||||
.compat_ioctl = sd_compat_ioctl,
|
||||
#endif
|
||||
.check_events = sd_check_events,
|
||||
.revalidate_disk = sd_revalidate_disk,
|
||||
.unlock_native_capacity = sd_unlock_native_capacity,
|
||||
.pr_ops = &sd_pr_ops,
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* sd_open - open a scsi disk device
|
||||
* @bdev: Block device of the scsi disk to open
|
||||
* @mode: FMODE_* mask
|
||||
*
|
||||
* Returns 0 if successful. Returns a negated errno value in case
|
||||
* of error.
|
||||
**/
|
||||
static int sd_open(struct block_device *bdev, fmode_t mode)
|
||||
{
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在驱动层打开了磁盘设备之后,我们可以看到,在这个过程中,block_device相应的成员变量该填的都填上了,这才完成了mount_bdev的第一件大事,通过blkdev_get_by_path得到block_device。
|
||||
|
||||
接下来就是第二件大事情,我们要通过sget,将block_device塞进superblock里面。注意,调用sget的时候,有一个参数是一个函数set_bdev_super。这里面将block_device设置进了super_block。而sget要做的,就是分配一个super_block,然后调用set_bdev_super这个callback函数。这里的super_block是ext4文件系统的super_block。
|
||||
|
||||
sget(fs_type, test_bdev_super, set_bdev_super, flags | MS_NOSEC, bdev);
|
||||
|
||||
```
|
||||
static int set_bdev_super(struct super_block *s, void *data)
|
||||
{
|
||||
s->s_bdev = data;
|
||||
s->s_dev = s->s_bdev->bd_dev;
|
||||
s->s_bdi = bdi_get(s->s_bdev->bd_bdi);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* sget - find or create a superblock
|
||||
* @type: filesystem type superblock should belong to
|
||||
* @test: comparison callback
|
||||
* @set: setup callback
|
||||
* @flags: mount flags
|
||||
* @data: argument to each of them
|
||||
*/
|
||||
struct super_block *sget(struct file_system_type *type,
|
||||
int (*test)(struct super_block *,void *),
|
||||
int (*set)(struct super_block *,void *),
|
||||
int flags,
|
||||
void *data)
|
||||
{
|
||||
......
|
||||
return sget_userns(type, test, set, flags, user_ns, data);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* sget_userns - find or create a superblock
|
||||
* @type: filesystem type superblock should belong to
|
||||
* @test: comparison callback
|
||||
* @set: setup callback
|
||||
* @flags: mount flags
|
||||
* @user_ns: User namespace for the super_block
|
||||
* @data: argument to each of them
|
||||
*/
|
||||
struct super_block *sget_userns(struct file_system_type *type,
|
||||
int (*test)(struct super_block *,void *),
|
||||
int (*set)(struct super_block *,void *),
|
||||
int flags, struct user_namespace *user_ns,
|
||||
void *data)
|
||||
{
|
||||
struct super_block *s = NULL;
|
||||
struct super_block *old;
|
||||
int err;
|
||||
......
|
||||
if (!s) {
|
||||
s = alloc_super(type, (flags & ~MS_SUBMOUNT), user_ns);
|
||||
......
|
||||
}
|
||||
err = set(s, data);
|
||||
......
|
||||
s->s_type = type;
|
||||
strlcpy(s->s_id, type->name, sizeof(s->s_id));
|
||||
list_add_tail(&s->s_list, &super_blocks);
|
||||
hlist_add_head(&s->s_instances, &type->fs_supers);
|
||||
spin_unlock(&sb_lock);
|
||||
get_filesystem(type);
|
||||
register_shrinker(&s->s_shrink);
|
||||
return s;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
好了,到此为止,mount中一个块设备的过程就结束了。设备打开了,形成了block_device结构,并且塞到了super_block中。
|
||||
|
||||
有了ext4文件系统的super_block之后,接下来对于文件的读写过程,就和文件系统那一章的过程一摸一样了。只要不涉及真正写入设备的代码,super_block中的这个block_device就没啥用处。这也是为什么文件系统那一章,我们丝毫感觉不到它的存在,但是一旦到了底层,就到了block_device起作用的时候了,这个我们下一节仔细分析。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
从这一节我们可以看出,块设备比字符设备复杂多了,涉及三个文件系统,工作过程我用一张图总结了一下,下面带你总结一下。
|
||||
|
||||
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除了能够表示字符设备和块设备,还能表示什么呢?请你看代码分析一下。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解 ,也欢迎可以收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
|
||||
|
||||
756
极客时间专栏/趣谈Linux操作系统/核心原理篇:第六部分 输入输出系统/35 | 块设备(下):如何建立代理商销售模式?.md
Normal file
756
极客时间专栏/趣谈Linux操作系统/核心原理篇:第六部分 输入输出系统/35 | 块设备(下):如何建立代理商销售模式?.md
Normal 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->a_ops->direct_IO,实际调用的是ext4_direct_IO,往设备层写入数据。
|
||||
|
||||
第二种是**缓存I/O**。最终我们会将数据从应用拷贝到内存缓存中,但是这个时候,并不执行真正的I/O操作。它们只将整个页或其中部分标记为脏。写操作由一个timer触发,那个时候,才调用wb_workfn往硬盘写入页面。
|
||||
|
||||
接下来的调用链为:wb_workfn->wb_do_writeback->wb_writeback->writeback_sb_inodes->__writeback_single_inode->do_writepages。在do_writepages中,我们要调用mapping->a_ops->writepages,但实际调用的是ext4_writepages,往设备层写入数据。
|
||||
|
||||
这一节,我们就沿着这两种情况分析下去。
|
||||
|
||||
## 直接I/O如何访问块设备?
|
||||
|
||||
我们先来看第一种情况,直接I/O调用到ext4_direct_IO。
|
||||
|
||||
```
|
||||
static ssize_t ext4_direct_IO(struct kiocb *iocb, struct iov_iter *iter)
|
||||
{
|
||||
struct file *file = iocb->ki_filp;
|
||||
struct inode *inode = file->f_mapping->host;
|
||||
size_t count = iov_iter_count(iter);
|
||||
loff_t offset = iocb->ki_pos;
|
||||
ssize_t ret;
|
||||
......
|
||||
ret = ext4_direct_IO_write(iocb, iter);
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
static ssize_t ext4_direct_IO_write(struct kiocb *iocb, struct iov_iter *iter)
|
||||
{
|
||||
struct file *file = iocb->ki_filp;
|
||||
struct inode *inode = file->f_mapping->host;
|
||||
struct ext4_inode_info *ei = EXT4_I(inode);
|
||||
ssize_t ret;
|
||||
loff_t offset = iocb->ki_pos;
|
||||
size_t count = iov_iter_count(iter);
|
||||
......
|
||||
ret = __blockdev_direct_IO(iocb, inode, inode->i_sb->s_bdev, iter,
|
||||
get_block_func, ext4_end_io_dio, NULL,
|
||||
dio_flags);
|
||||
|
||||
|
||||
……
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ext4_direct_IO_write调用__blockdev_direct_IO,有个参数你需要特别注意一下,那就是inode->i_sb->s_bdev。通过当前文件的inode,我们可以得到super_block。这个super_block中的s_bdev,就是咱们上一节填进去的那个block_device。
|
||||
|
||||
__blockdev_direct_IO会调用do_blockdev_direct_IO,在这里面我们要准备一个struct dio结构和struct dio_submit结构,用来描述将要发生的写入请求。
|
||||
|
||||
```
|
||||
static inline ssize_t
|
||||
do_blockdev_direct_IO(struct kiocb *iocb, struct inode *inode,
|
||||
struct block_device *bdev, struct iov_iter *iter,
|
||||
get_block_t get_block, dio_iodone_t end_io,
|
||||
dio_submit_t submit_io, int flags)
|
||||
{
|
||||
unsigned i_blkbits = ACCESS_ONCE(inode->i_blkbits);
|
||||
unsigned blkbits = i_blkbits;
|
||||
unsigned blocksize_mask = (1 << blkbits) - 1;
|
||||
ssize_t retval = -EINVAL;
|
||||
size_t count = iov_iter_count(iter);
|
||||
loff_t offset = iocb->ki_pos;
|
||||
loff_t end = offset + count;
|
||||
struct dio *dio;
|
||||
struct dio_submit sdio = { 0, };
|
||||
struct buffer_head map_bh = { 0, };
|
||||
......
|
||||
dio = kmem_cache_alloc(dio_cache, GFP_KERNEL);
|
||||
dio->flags = flags;
|
||||
dio->i_size = i_size_read(inode);
|
||||
dio->inode = inode;
|
||||
if (iov_iter_rw(iter) == WRITE) {
|
||||
dio->op = REQ_OP_WRITE;
|
||||
dio->op_flags = REQ_SYNC | REQ_IDLE;
|
||||
if (iocb->ki_flags & IOCB_NOWAIT)
|
||||
dio->op_flags |= REQ_NOWAIT;
|
||||
} else {
|
||||
dio->op = REQ_OP_READ;
|
||||
}
|
||||
sdio.blkbits = blkbits;
|
||||
sdio.blkfactor = i_blkbits - blkbits;
|
||||
sdio.block_in_file = offset >> blkbits;
|
||||
|
||||
|
||||
sdio.get_block = get_block;
|
||||
dio->end_io = end_io;
|
||||
sdio.submit_io = submit_io;
|
||||
sdio.final_block_in_bio = -1;
|
||||
sdio.next_block_for_io = -1;
|
||||
|
||||
|
||||
dio->iocb = iocb;
|
||||
dio->refcount = 1;
|
||||
|
||||
|
||||
sdio.iter = iter;
|
||||
sdio.final_block_in_request =
|
||||
(offset + iov_iter_count(iter)) >> blkbits;
|
||||
......
|
||||
sdio.pages_in_io += iov_iter_npages(iter, INT_MAX);
|
||||
|
||||
|
||||
retval = do_direct_IO(dio, &sdio, &map_bh);
|
||||
.....
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
do_direct_IO里面有两层循环,第一层循环是依次处理这次要写入的所有块。对于每一块,取出对应的内存中的页page,在这一块中,有写入的起始地址from和终止地址to,所以,第二层循环就是依次处理from到to的数据,调用submit_page_section,提交到块设备层进行写入。
|
||||
|
||||
```
|
||||
static int do_direct_IO(struct dio *dio, struct dio_submit *sdio,
|
||||
struct buffer_head *map_bh)
|
||||
{
|
||||
const unsigned blkbits = sdio->blkbits;
|
||||
const unsigned i_blkbits = blkbits + sdio->blkfactor;
|
||||
int ret = 0;
|
||||
|
||||
|
||||
while (sdio->block_in_file < sdio->final_block_in_request) {
|
||||
struct page *page;
|
||||
size_t from, to;
|
||||
|
||||
|
||||
page = dio_get_page(dio, sdio);
|
||||
from = sdio->head ? 0 : sdio->from;
|
||||
to = (sdio->head == sdio->tail - 1) ? sdio->to : PAGE_SIZE;
|
||||
sdio->head++;
|
||||
|
||||
|
||||
while (from < to) {
|
||||
unsigned this_chunk_bytes; /* # of bytes mapped */
|
||||
unsigned this_chunk_blocks; /* # of blocks */
|
||||
......
|
||||
ret = submit_page_section(dio, sdio, page,
|
||||
from,
|
||||
this_chunk_bytes,
|
||||
sdio->next_block_for_io,
|
||||
map_bh);
|
||||
......
|
||||
sdio->next_block_for_io += this_chunk_blocks;
|
||||
sdio->block_in_file += this_chunk_blocks;
|
||||
from += this_chunk_bytes;
|
||||
dio->result += this_chunk_bytes;
|
||||
sdio->blocks_available -= this_chunk_blocks;
|
||||
if (sdio->block_in_file == sdio->final_block_in_request)
|
||||
break;
|
||||
......
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
submit_page_section会调用dio_bio_submit,进而调用submit_bio向块设备层提交数据。其中,参数struct bio是将数据传给块设备的通用传输对象。定义如下:
|
||||
|
||||
```
|
||||
/**
|
||||
* submit_bio - submit a bio to the block device layer for I/O
|
||||
* @bio: The &struct bio which describes the I/O
|
||||
*/
|
||||
blk_qc_t submit_bio(struct bio *bio)
|
||||
{
|
||||
......
|
||||
return generic_make_request(bio);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 缓存I/O如何访问块设备?
|
||||
|
||||
我们再来看第二种情况,缓存I/O调用到ext4_writepages。这个函数比较长,我们这里只截取最重要的部分来讲解。
|
||||
|
||||
```
|
||||
static int ext4_writepages(struct address_space *mapping,
|
||||
struct writeback_control *wbc)
|
||||
{
|
||||
......
|
||||
struct mpage_da_data mpd;
|
||||
struct inode *inode = mapping->host;
|
||||
struct ext4_sb_info *sbi = EXT4_SB(mapping->host->i_sb);
|
||||
......
|
||||
mpd.do_map = 0;
|
||||
mpd.io_submit.io_end = ext4_init_io_end(inode, GFP_KERNEL);
|
||||
ret = mpage_prepare_extent_to_map(&mpd);
|
||||
/* Submit prepared bio */
|
||||
ext4_io_submit(&mpd.io_submit);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里比较重要的一个数据结构是struct mpage_da_data。这里面有文件的inode、要写入的页的偏移量,还有一个重要的struct ext4_io_submit,里面有通用传输对象bio。
|
||||
|
||||
```
|
||||
struct mpage_da_data {
|
||||
struct inode *inode;
|
||||
......
|
||||
pgoff_t first_page; /* The first page to write */
|
||||
pgoff_t next_page; /* Current page to examine */
|
||||
pgoff_t last_page; /* Last page to examine */
|
||||
struct ext4_map_blocks map;
|
||||
struct ext4_io_submit io_submit; /* IO submission data */
|
||||
unsigned int do_map:1;
|
||||
};
|
||||
|
||||
|
||||
struct ext4_io_submit {
|
||||
......
|
||||
struct bio *io_bio;
|
||||
ext4_io_end_t *io_end;
|
||||
sector_t io_next_block;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在ext4_writepages中,mpage_prepare_extent_to_map用于初始化这个struct mpage_da_data结构。接下来的调用链为:mpage_prepare_extent_to_map->mpage_process_page_bufs->mpage_submit_page->ext4_bio_write_page->io_submit_add_bh。
|
||||
|
||||
在io_submit_add_bh中,此时的bio还是空的,因而我们要调用io_submit_init_bio,初始化bio。
|
||||
|
||||
```
|
||||
static int io_submit_init_bio(struct ext4_io_submit *io,
|
||||
struct buffer_head *bh)
|
||||
{
|
||||
struct bio *bio;
|
||||
|
||||
|
||||
bio = bio_alloc(GFP_NOIO, BIO_MAX_PAGES);
|
||||
if (!bio)
|
||||
return -ENOMEM;
|
||||
wbc_init_bio(io->io_wbc, bio);
|
||||
bio->bi_iter.bi_sector = bh->b_blocknr * (bh->b_size >> 9);
|
||||
bio->bi_bdev = bh->b_bdev;
|
||||
bio->bi_end_io = ext4_end_bio;
|
||||
bio->bi_private = ext4_get_io_end(io->io_end);
|
||||
io->io_bio = bio;
|
||||
io->io_next_block = bh->b_blocknr;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
我们再回到ext4_writepages中。在bio初始化完之后,我们要调用ext4_io_submit,提交I/O。在这里我们又是调用submit_bio,向块设备层传输数据。ext4_io_submit的实现如下:
|
||||
|
||||
```
|
||||
void ext4_io_submit(struct ext4_io_submit *io)
|
||||
{
|
||||
struct bio *bio = io->io_bio;
|
||||
|
||||
|
||||
if (bio) {
|
||||
int io_op_flags = io->io_wbc->sync_mode == WB_SYNC_ALL ?
|
||||
REQ_SYNC : 0;
|
||||
io->io_bio->bi_write_hint = io->io_end->inode->i_write_hint;
|
||||
bio_set_op_attrs(io->io_bio, REQ_OP_WRITE, io_op_flags);
|
||||
submit_bio(io->io_bio);
|
||||
}
|
||||
io->io_bio = NULL;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
## 如何向块设备层提交请求?
|
||||
|
||||
既然不管是直接I/O,还是缓存I/O,最后都到了submit_bio里面,那我们就来重点分析一下它。
|
||||
|
||||
submit_bio会调用generic_make_request。代码如下:
|
||||
|
||||
```
|
||||
blk_qc_t generic_make_request(struct bio *bio)
|
||||
{
|
||||
/*
|
||||
* bio_list_on_stack[0] contains bios submitted by the current
|
||||
* make_request_fn.
|
||||
* bio_list_on_stack[1] contains bios that were submitted before
|
||||
* the current make_request_fn, but that haven't been processed
|
||||
* yet.
|
||||
*/
|
||||
struct bio_list bio_list_on_stack[2];
|
||||
blk_qc_t ret = BLK_QC_T_NONE;
|
||||
......
|
||||
if (current->bio_list) {
|
||||
bio_list_add(&current->bio_list[0], bio);
|
||||
goto out;
|
||||
}
|
||||
|
||||
|
||||
bio_list_init(&bio_list_on_stack[0]);
|
||||
current->bio_list = bio_list_on_stack;
|
||||
do {
|
||||
struct request_queue *q = bdev_get_queue(bio->bi_bdev);
|
||||
|
||||
|
||||
if (likely(blk_queue_enter(q, bio->bi_opf & REQ_NOWAIT) == 0)) {
|
||||
struct bio_list lower, same;
|
||||
|
||||
|
||||
/* Create a fresh bio_list for all subordinate requests */
|
||||
bio_list_on_stack[1] = bio_list_on_stack[0];
|
||||
bio_list_init(&bio_list_on_stack[0]);
|
||||
ret = q->make_request_fn(q, bio);
|
||||
|
||||
|
||||
blk_queue_exit(q);
|
||||
|
||||
|
||||
/* sort new bios into those for a lower level
|
||||
* and those for the same level
|
||||
*/
|
||||
bio_list_init(&lower);
|
||||
bio_list_init(&same);
|
||||
while ((bio = bio_list_pop(&bio_list_on_stack[0])) != NULL)
|
||||
if (q == bdev_get_queue(bio->bi_bdev))
|
||||
bio_list_add(&same, bio);
|
||||
else
|
||||
bio_list_add(&lower, bio);
|
||||
/* now assemble so we handle the lowest level first */
|
||||
bio_list_merge(&bio_list_on_stack[0], &lower);
|
||||
bio_list_merge(&bio_list_on_stack[0], &same);
|
||||
bio_list_merge(&bio_list_on_stack[0], &bio_list_on_stack[1]);
|
||||
}
|
||||
......
|
||||
bio = bio_list_pop(&bio_list_on_stack[0]);
|
||||
} while (bio);
|
||||
current->bio_list = NULL; /* deactivate */
|
||||
out:
|
||||
return ret;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里的逻辑有点复杂,我们先来看大的逻辑。在do-while中,我们先是获取一个请求队列request_queue,然后调用这个队列的make_request_fn函数。
|
||||
|
||||
### 块设备队列结构
|
||||
|
||||
如果再来看struct block_device结构和struct gendisk结构,我们会发现,每个块设备都有一个请求队列struct request_queue,用于处理上层发来的请求。
|
||||
|
||||
在每个块设备的驱动程序初始化的时候,会生成一个request_queue。
|
||||
|
||||
```
|
||||
struct request_queue {
|
||||
/*
|
||||
* Together with queue_head for cacheline sharing
|
||||
*/
|
||||
struct list_head queue_head;
|
||||
struct request *last_merge;
|
||||
struct elevator_queue *elevator;
|
||||
......
|
||||
request_fn_proc *request_fn;
|
||||
make_request_fn *make_request_fn;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在请求队列request_queue上,首先是有一个链表list_head,保存请求request。
|
||||
|
||||
```
|
||||
struct request {
|
||||
struct list_head queuelist;
|
||||
......
|
||||
struct request_queue *q;
|
||||
......
|
||||
struct bio *bio;
|
||||
struct bio *biotail;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
每个request包括一个链表的struct bio,有指针指向一头一尾。
|
||||
|
||||
```
|
||||
struct bio {
|
||||
struct bio *bi_next; /* request queue link */
|
||||
struct block_device *bi_bdev;
|
||||
blk_status_t bi_status;
|
||||
......
|
||||
struct bvec_iter bi_iter;
|
||||
unsigned short bi_vcnt; /* how many bio_vec's */
|
||||
unsigned short bi_max_vecs; /* max bvl_vecs we can hold */
|
||||
atomic_t __bi_cnt; /* pin count */
|
||||
struct bio_vec *bi_io_vec; /* the actual vec list */
|
||||
......
|
||||
};
|
||||
|
||||
|
||||
struct bio_vec {
|
||||
struct page *bv_page;
|
||||
unsigned int bv_len;
|
||||
unsigned int bv_offset;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在bio中,bi_next是链表中的下一项,struct bio_vec指向一组页面。
|
||||
|
||||
<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->blk_queue_make_request,把make_request_fn设置为blk_queue_bio。
|
||||
|
||||
```
|
||||
/**
|
||||
* scsi_alloc_sdev - allocate and setup a scsi_Device
|
||||
* @starget: which target to allocate a &scsi_device for
|
||||
* @lun: which lun
|
||||
* @hostdata: usually NULL and set by ->slave_alloc instead
|
||||
*
|
||||
* Description:
|
||||
* Allocate, initialize for io, and return a pointer to a scsi_Device.
|
||||
* Stores the @shost, @channel, @id, and @lun in the scsi_Device, and
|
||||
* adds scsi_Device to the appropriate list.
|
||||
*
|
||||
* Return value:
|
||||
* scsi_Device pointer, or NULL on failure.
|
||||
**/
|
||||
static struct scsi_device *scsi_alloc_sdev(struct scsi_target *starget,
|
||||
u64 lun, void *hostdata)
|
||||
{
|
||||
struct scsi_device *sdev;
|
||||
sdev = kzalloc(sizeof(*sdev) + shost->transportt->device_size,
|
||||
GFP_ATOMIC);
|
||||
......
|
||||
sdev->request_queue = scsi_alloc_queue(sdev);
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
struct request_queue *scsi_alloc_queue(struct scsi_device *sdev)
|
||||
{
|
||||
struct Scsi_Host *shost = sdev->host;
|
||||
struct request_queue *q;
|
||||
|
||||
|
||||
q = blk_alloc_queue_node(GFP_KERNEL, NUMA_NO_NODE);
|
||||
if (!q)
|
||||
return NULL;
|
||||
q->cmd_size = sizeof(struct scsi_cmnd) + shost->hostt->cmd_size;
|
||||
q->rq_alloc_data = shost;
|
||||
q->request_fn = scsi_request_fn;
|
||||
q->init_rq_fn = scsi_init_rq;
|
||||
q->exit_rq_fn = scsi_exit_rq;
|
||||
q->initialize_rq_fn = scsi_initialize_rq;
|
||||
|
||||
|
||||
//调用blk_queue_make_request(q, blk_queue_bio);
|
||||
if (blk_init_allocated_queue(q) < 0) {
|
||||
blk_cleanup_queue(q);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
||||
__scsi_init_queue(shost, q);
|
||||
......
|
||||
return q
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在blk_init_allocated_queue中,除了初始化make_request_fn函数,我们还要做一件很重要的事情,就是初始化I/O的电梯算法。
|
||||
|
||||
```
|
||||
int blk_init_allocated_queue(struct request_queue *q)
|
||||
{
|
||||
q->fq = blk_alloc_flush_queue(q, NUMA_NO_NODE, q->cmd_size);
|
||||
......
|
||||
blk_queue_make_request(q, blk_queue_bio);
|
||||
......
|
||||
/* init elevator */
|
||||
if (elevator_init(q, NULL)) {
|
||||
......
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
电梯算法有很多种类型,定义为elevator_type。下面我来逐一说一下。
|
||||
|
||||
- **struct elevator_type elevator_noop**
|
||||
|
||||
Noop调度算法是最简单的IO调度算法,它将IO请求放入到一个FIFO队列中,然后逐个执行这些IO请求。
|
||||
|
||||
- **struct elevator_type iosched_deadline**
|
||||
|
||||
Deadline算法要保证每个IO请求在一定的时间内一定要被服务到,以此来避免某个请求饥饿。为了完成这个目标,算法中引入了两类队列,一类队列用来对请求按起始扇区序号进行排序,通过红黑树来组织,我们称为sort_list,按照此队列传输性能会比较高;另一类队列对请求按它们的生成时间进行排序,由链表来组织,称为fifo_list,并且每一个请求都有一个期限值。
|
||||
|
||||
- **struct elevator_type iosched_cfq**
|
||||
|
||||
又看到了熟悉的CFQ完全公平调度算法。所有的请求会在多个队列中排序。同一个进程的请求,总是在同一队列中处理。时间片会分配到每个队列,通过轮询算法,我们保证了I/O带宽,以公平的方式,在不同队列之间进行共享。
|
||||
|
||||
elevator_init中会根据名称来指定电梯算法,如果没有选择,那就默认使用iosched_cfq。
|
||||
|
||||
### 请求提交与调度
|
||||
|
||||
接下来,我们回到generic_make_request函数中。调用队列的make_request_fn函数,其实就是调用blk_queue_bio。
|
||||
|
||||
```
|
||||
static blk_qc_t blk_queue_bio(struct request_queue *q, struct bio *bio)
|
||||
{
|
||||
struct request *req, *free;
|
||||
unsigned int request_count = 0;
|
||||
......
|
||||
switch (elv_merge(q, &req, bio)) {
|
||||
case ELEVATOR_BACK_MERGE:
|
||||
if (!bio_attempt_back_merge(q, req, bio))
|
||||
break;
|
||||
elv_bio_merged(q, req, bio);
|
||||
free = attempt_back_merge(q, req);
|
||||
if (free)
|
||||
__blk_put_request(q, free);
|
||||
else
|
||||
elv_merged_request(q, req, ELEVATOR_BACK_MERGE);
|
||||
goto out_unlock;
|
||||
case ELEVATOR_FRONT_MERGE:
|
||||
if (!bio_attempt_front_merge(q, req, bio))
|
||||
break;
|
||||
elv_bio_merged(q, req, bio);
|
||||
free = attempt_front_merge(q, req);
|
||||
if (free)
|
||||
__blk_put_request(q, free);
|
||||
else
|
||||
elv_merged_request(q, req, ELEVATOR_FRONT_MERGE);
|
||||
goto out_unlock;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
get_rq:
|
||||
req = get_request(q, bio->bi_opf, bio, GFP_NOIO);
|
||||
......
|
||||
blk_init_request_from_bio(req, bio);
|
||||
......
|
||||
add_acct_request(q, req, where);
|
||||
__blk_run_queue(q);
|
||||
out_unlock:
|
||||
......
|
||||
return BLK_QC_T_NONE;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
blk_queue_bio首先做的一件事情是调用elv_merge来判断,当前这个bio请求是否能够和目前已有的request合并起来,成为同一批I/O操作,从而提高读取和写入的性能。
|
||||
|
||||
判断标准和struct bio的成员struct bvec_iter有关,它里面有两个变量,一个是起始磁盘簇bi_sector,另一个是大小bi_size。
|
||||
|
||||
```
|
||||
enum elv_merge elv_merge(struct request_queue *q, struct request **req,
|
||||
struct bio *bio)
|
||||
{
|
||||
struct elevator_queue *e = q->elevator;
|
||||
struct request *__rq;
|
||||
......
|
||||
if (q->last_merge && elv_bio_merge_ok(q->last_merge, bio)) {
|
||||
enum elv_merge ret = blk_try_merge(q->last_merge, bio);
|
||||
|
||||
|
||||
if (ret != ELEVATOR_NO_MERGE) {
|
||||
*req = q->last_merge;
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
......
|
||||
__rq = elv_rqhash_find(q, bio->bi_iter.bi_sector);
|
||||
if (__rq && elv_bio_merge_ok(__rq, bio)) {
|
||||
*req = __rq;
|
||||
return ELEVATOR_BACK_MERGE;
|
||||
}
|
||||
|
||||
|
||||
if (e->uses_mq && e->type->ops.mq.request_merge)
|
||||
return e->type->ops.mq.request_merge(q, req, bio);
|
||||
else if (!e->uses_mq && e->type->ops.sq.elevator_merge_fn)
|
||||
return e->type->ops.sq.elevator_merge_fn(q, req, bio);
|
||||
|
||||
|
||||
return ELEVATOR_NO_MERGE;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
elv_merge尝试了三次合并。
|
||||
|
||||
第一次,它先判断和上一次合并的request能不能再次合并,看看能不能赶上马上要走的这部电梯。在blk_try_merge主要做了这样的判断:如果blk_rq_pos(rq) + blk_rq_sectors(rq) == bio->bi_iter.bi_sector,也就是说这个request的起始地址加上它的大小(其实是这个request的结束地址),如果和bio的起始地址能接得上,那就把bio放在request的最后,我们称为ELEVATOR_BACK_MERGE。
|
||||
|
||||
如果blk_rq_pos(rq) - bio_sectors(bio) == bio->bi_iter.bi_sector,也就是说,这个request的起始地址减去bio的大小等于bio的起始地址,这说明bio放在request的最前面能够接得上,那就把bio放在request的最前面,我们称为ELEVATOR_FRONT_MERGE。否则,那就不合并,我们称为ELEVATOR_NO_MERGE。
|
||||
|
||||
```
|
||||
enum elv_merge blk_try_merge(struct request *rq, struct bio *bio)
|
||||
{
|
||||
......
|
||||
if (blk_rq_pos(rq) + blk_rq_sectors(rq) == bio->bi_iter.bi_sector)
|
||||
return ELEVATOR_BACK_MERGE;
|
||||
else if (blk_rq_pos(rq) - bio_sectors(bio) == bio->bi_iter.bi_sector)
|
||||
return ELEVATOR_FRONT_MERGE;
|
||||
return ELEVATOR_NO_MERGE;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二次,如果和上一个合并过的request无法合并,那我们就调用elv_rqhash_find。然后按照bio的起始地址查找request,看有没有能够合并的。如果有的话,因为是按照起始地址找的,应该接在人家的后面,所以是ELEVATOR_BACK_MERGE。
|
||||
|
||||
第三次,调用elevator_merge_fn试图合并。对于iosched_cfq,调用的是cfq_merge。在这里面,cfq_find_rq_fmerge会调用elv_rb_find函数,里面的参数是bio的结束地址。我们还是要看,能不能找到可以合并的。如果有的话,因为是按照结束地址找的,应该接在人家前面,所以是ELEVATOR_FRONT_MERGE。
|
||||
|
||||
```
|
||||
static enum elv_merge cfq_merge(struct request_queue *q, struct request **req,
|
||||
struct bio *bio)
|
||||
{
|
||||
struct cfq_data *cfqd = q->elevator->elevator_data;
|
||||
struct request *__rq;
|
||||
|
||||
|
||||
__rq = cfq_find_rq_fmerge(cfqd, bio);
|
||||
if (__rq && elv_bio_merge_ok(__rq, bio)) {
|
||||
*req = __rq;
|
||||
return ELEVATOR_FRONT_MERGE;
|
||||
}
|
||||
|
||||
|
||||
return ELEVATOR_NO_MERGE;
|
||||
}
|
||||
|
||||
|
||||
static struct request *
|
||||
cfq_find_rq_fmerge(struct cfq_data *cfqd, struct bio *bio)
|
||||
{
|
||||
struct task_struct *tsk = current;
|
||||
struct cfq_io_cq *cic;
|
||||
struct cfq_queue *cfqq;
|
||||
|
||||
|
||||
cic = cfq_cic_lookup(cfqd, tsk->io_context);
|
||||
if (!cic)
|
||||
return NULL;
|
||||
|
||||
|
||||
cfqq = cic_to_cfqq(cic, op_is_sync(bio->bi_opf));
|
||||
if (cfqq)
|
||||
return elv_rb_find(&cfqq->sort_list, bio_end_sector(bio));
|
||||
|
||||
|
||||
return NUL
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
等从elv_merge返回blk_queue_bio的时候,我们就知道,应该做哪种类型的合并,接着就要进行真的合并。如果没有办法合并,那就调用get_request,创建一个新的request,调用blk_init_request_from_bio,将bio放到新的request里面,然后调用add_acct_request,把新的request加到request_queue队列中。
|
||||
|
||||
至此,我们解析完了generic_make_request中最重要的两大逻辑:获取一个请求队列request_queue和调用这个队列的make_request_fn函数。
|
||||
|
||||
其实,generic_make_request其他部分也很令人困惑。感觉里面有特别多的struct bio_list,倒腾过来,倒腾过去的。这是因为,很多块设备是有层次的。
|
||||
|
||||
比如,我们用两块硬盘组成RAID,两个RAID盘组成LVM,然后我们就可以在LVM上创建一个块设备给用户用,我们称接近用户的块设备为**高层次的块设备**,接近底层的块设备为**低层次**(lower)**的块设备**。这样,generic_make_request把I/O请求发送给高层次的块设备的时候,会调用高层块设备的make_request_fn,高层块设备又要调用generic_make_request,将请求发送给低层次的块设备。虽然块设备的层次不会太多,但是对于代码generic_make_request来讲,这可是递归的调用,一不小心,就会递归过深,无法正常退出,而且内核栈的大小又非常有限,所以要比较小心。
|
||||
|
||||
这里你是否理解了struct bio_list bio_list_on_stack[2]的名字为什么叫stack呢?其实,将栈的操作变成对于队列的操作,队列不在栈里面,会大很多。每次generic_make_request被当前任务调用的时候,将current->bio_list设置为bio_list_on_stack,并在generic_make_request的一开始就判断current->bio_list是否为空。如果不为空,说明已经在generic_make_request的调用里面了,就不必调用make_request_fn进行递归了,直接把请求加入到bio_list里面就可以了,这就实现了递归的及时退出。
|
||||
|
||||
如果current->bio_list为空,那我们就将current->bio_list设置为bio_list_on_stack后,进入do-while循环,做咱们分析过的generic_make_request的两大逻辑。但是,当前的队列调用make_request_fn的时候,在make_request_fn的具体实现中,会生成新的bio。调用更底层的块设备,也会生成新的bio,都会放在bio_list_on_stack的队列中,是一个边处理还边创建的过程。
|
||||
|
||||
bio_list_on_stack[1] = bio_list_on_stack[0]这一句在make_request_fn之前,将之前队列里面遗留没有处理的保存下来,接着bio_list_init将bio_list_on_stack[0]设置为空,然后调用make_request_fn,在make_request_fn里面如果有新的bio生成,都会加到bio_list_on_stack[0]这个队列里面来。
|
||||
|
||||
make_request_fn执行完毕后,可以想象bio_list_on_stack[0]可能又多了一些bio了,接下来的循环中调用bio_list_pop将bio_list_on_stack[0]积攒的bio拿出来,分别放在两个队列lower和same中,顾名思义,lower就是更低层次的块设备的bio,same是同层次的块设备的bio。
|
||||
|
||||
接下来我们能将lower、same以及bio_list_on_stack[1] 都取出来,放在bio_list_on_stack[0]统一进行处理。当然应该lower优先了,因为只有底层的块设备的I/O做完了,上层的块设备的I/O才能做完。
|
||||
|
||||
到这里,generic_make_request的逻辑才算解析完毕。对于写入的数据来讲,其实仅仅是将bio请求放在请求队列上,设备驱动程序还没往设备里面写呢。
|
||||
|
||||
### 请求的处理
|
||||
|
||||
设备驱动程序往设备里面写,调用的是请求队列request_queue的另外一个函数request_fn。对于scsi设备来讲,调用的是scsi_request_fn。
|
||||
|
||||
```
|
||||
static void scsi_request_fn(struct request_queue *q)
|
||||
__releases(q->queue_lock)
|
||||
__acquires(q->queue_lock)
|
||||
{
|
||||
struct scsi_device *sdev = q->queuedata;
|
||||
struct Scsi_Host *shost;
|
||||
struct scsi_cmnd *cmd;
|
||||
struct request *req;
|
||||
|
||||
|
||||
/*
|
||||
* To start with, we keep looping until the queue is empty, or until
|
||||
* the host is no longer able to accept any more requests.
|
||||
*/
|
||||
shost = sdev->host;
|
||||
for (;;) {
|
||||
int rtn;
|
||||
/*
|
||||
* get next queueable request. We do this early to make sure
|
||||
* that the request is fully prepared even if we cannot
|
||||
* accept it.
|
||||
*/
|
||||
req = blk_peek_request(q);
|
||||
......
|
||||
/*
|
||||
* Remove the request from the request list.
|
||||
*/
|
||||
if (!(blk_queue_tagged(q) && !blk_queue_start_tag(q, req)))
|
||||
blk_start_request(req);
|
||||
.....
|
||||
cmd = req->special;
|
||||
......
|
||||
/*
|
||||
* Dispatch the command to the low-level driver.
|
||||
*/
|
||||
cmd->scsi_done = scsi_done;
|
||||
rtn = scsi_dispatch_cmd(cmd);
|
||||
......
|
||||
}
|
||||
return;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里面是一个for无限循环,从request_queue中读取request,然后封装更加底层的指令,给设备控制器下指令,实施真正的I/O操作。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节我们讲了如何将块设备I/O请求送达到外部设备。
|
||||
|
||||
对于块设备的I/O操作分为两种,一种是直接I/O,另一种是缓存I/O。无论是哪种I/O,最终都会调用submit_bio提交块设备I/O请求。
|
||||
|
||||
对于每一种块设备,都有一个gendisk表示这个设备,它有一个请求队列,这个队列是一系列的request对象。每个request对象里面包含多个BIO对象,指向page cache。所谓的写入块设备,I/O就是将page cache里面的数据写入硬盘。
|
||||
|
||||
对于请求队列来讲,还有两个函数,一个函数叫make_request_fn函数,用于将请求放入队列。submit_bio会调用generic_make_request,然后调用这个函数。
|
||||
|
||||
另一个函数往往在设备驱动程序里实现,我们叫request_fn函数,它用于从队列里面取出请求来,写入外部设备。
|
||||
|
||||
<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="">
|
||||
344
极客时间专栏/趣谈Linux操作系统/核心原理篇:第十部分 容器化/56 | 容器:大公司为保持创新,鼓励内部创业.md
Normal file
344
极客时间专栏/趣谈Linux操作系统/核心原理篇:第十部分 容器化/56 | 容器:大公司为保持创新,鼓励内部创业.md
Normal 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操作系统中,有一项新的技术,称为容器,它就可以做到这一点。
|
||||
|
||||
容器的英文叫Container,Container的另一个意思是“集装箱”。其实容器就像船上的不同的集装箱装着不同的货物,有一定的隔离,但是隔离性又没有那么好,仅仅做简单的封装。当然封装也带来了好处,一个是打包,二是标准。
|
||||
|
||||
在没有集装箱的时代,假设我们要将货物从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="Ubuntu 14.04.6 LTS"
|
||||
|
||||
```
|
||||
|
||||
如果我们想尝试centOS 6,也是没问题的。
|
||||
|
||||
```
|
||||
# docker pull centos:6
|
||||
6: Pulling from library/centos
|
||||
ff50d722b382: Pull complete
|
||||
Digest: sha256:dec8f471302de43f4cfcf82f56d99a5227b5ea1aa6d02fa56344986e1f4610e7
|
||||
Status: Downloaded newer image for centos:6
|
||||
|
||||
# docker images
|
||||
REPOSITORY TAG IMAGE ID CREATED SIZE
|
||||
ubuntu 14.04 2c5e00d77a67 2 months ago 188MB
|
||||
centos 6 d0957ffdf8a2 4 months ago 194MB
|
||||
|
||||
# docker run -it --entrypoint bash centos:6
|
||||
[root@af4c8d598bdf /]# cat /etc/redhat-release
|
||||
CentOS release 6.10 (Final)
|
||||
|
||||
```
|
||||
|
||||
除了可以如此简单地创建一个操作系统环境,容器还有一个很酷的功能,就是镜像里面带应用。这样的话,应用就可以像集装箱一样,到处迁移,启动即可提供服务。而不用像虚拟机那样,要先有一个操作系统的环境,然后再在里面安装应用。
|
||||
|
||||
我们举一个最简单的应用的例子,也就是nginx。我们可以下载一个nginx的镜像,运行起来,里面就自带nginx了,并且直接可以访问了。
|
||||
|
||||
```
|
||||
# docker pull nginx
|
||||
Using default tag: latest
|
||||
latest: Pulling from library/nginx
|
||||
fc7181108d40: Pull complete
|
||||
d2e987ca2267: Pull complete
|
||||
0b760b431b11: Pull complete
|
||||
Digest: sha256:48cbeee0cb0a3b5e885e36222f969e0a2f41819a68e07aeb6631ca7cb356fed1
|
||||
Status: Downloaded newer image for nginx:latest
|
||||
|
||||
# docker run -d -p 8080:80 nginx
|
||||
73ff0c8bea6e169d1801afe807e909d4c84793962cba18dd022bfad9545ad488
|
||||
|
||||
# docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
73ff0c8bea6e nginx "nginx -g 'daemon of…" 2 minutes ago Up 2 minutes 0.0.0.0:8080->80/tcp modest_payne
|
||||
|
||||
# curl http://localhost:8080
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome to nginx!</title>
|
||||
|
||||
```
|
||||
|
||||
这次nginx镜像运行的方式和操作系统不太一样,一个是-d,因为它是一个应用,不需要像操作系统那样有交互命令行,而是以后台方式运行,-d就是daemon的意思。
|
||||
|
||||
另外一个就是端口-p 8080:80。容器这么容易启动,每台机器上可以启动N个nginx。大家都监听80端口,不就冲突了吗?所以我们要设置端口,冒号后面的80是容器内部环境监听的端口,冒号前面的8080是宿主机上监听的端口。
|
||||
|
||||
一旦容器启动起来之后,通过docker ps就可以查看都有哪些容器正在运行。
|
||||
|
||||
接下来,我们通过curl命令,访问本机的8080端口,可以打印出nginx的欢迎页面。
|
||||
|
||||
docker run一下,应用就启动起来了,是不是非常方便?nginx是已经有人打包好的容器镜像,放在公共的镜像仓库里面。如果是你自己开发的应用,应该如何打包成为镜像呢?
|
||||
|
||||
因为Java代码比较麻烦,我们这里举一个简单的例子。假设你自己写的HTML的文件就是代码。
|
||||
|
||||
```
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome to nginx Test 7!</title>
|
||||
<style>
|
||||
body {
|
||||
width: 35em;
|
||||
margin: 0 auto;
|
||||
font-family: Tahoma, Verdana, Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test 7</h1>
|
||||
<p>If you see this page, the nginx web server is successfully installed and
|
||||
working. Further configuration is required.</p>
|
||||
<p>For online documentation and support please refer to
|
||||
<a href="http://nginx.org/">nginx.org</a>.<br/>
|
||||
Commercial support is available at
|
||||
<a href="http://nginx.com/">nginx.com</a>.</p>
|
||||
<p><em>Thank you for using nginx.</em></p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
```
|
||||
|
||||
那我们如何将这些代码放到容器镜像里面呢?要通过Dockerfile,Dockerfile的格式应该包含下面的部分:
|
||||
|
||||
- FROM 基础镜像
|
||||
- RUN 运行过的所有命令
|
||||
- COPY 拷贝到容器中的资源
|
||||
- ENTRYPOINT 前台启动的命令或者脚本
|
||||
|
||||
按照上面说的格式,可以有下面的Dockerfile。
|
||||
|
||||
```
|
||||
FROM ubuntu:14.04
|
||||
RUN echo "deb http://archive.ubuntu.com/ubuntu trusty main restricted universe multiverse" > /etc/apt/sources.list
|
||||
RUN echo "deb http://archive.ubuntu.com/ubuntu trusty-updates main restricted universe multiverse" >> /etc/apt/sources.list
|
||||
RUN apt-get -y update
|
||||
RUN apt-get -y install nginx
|
||||
COPY test.html /usr/share/nginx/html/test.html
|
||||
ENTRYPOINT nginx -g "daemon off;"
|
||||
|
||||
```
|
||||
|
||||
将代码、Dockerfile、脚本,放在一个文件夹下,以上面的Dockerfile为例子。
|
||||
|
||||
```
|
||||
[nginx]# ls
|
||||
Dockerfile test.html
|
||||
|
||||
```
|
||||
|
||||
现在我们编译这个Dockerfile。
|
||||
|
||||
```
|
||||
docker build -f Dockerfile -t testnginx:1 .
|
||||
|
||||
```
|
||||
|
||||
编译过后,我们就有了一个新的镜像。
|
||||
|
||||
```
|
||||
# docker images
|
||||
REPOSITORY TAG IMAGE ID CREATED SIZE
|
||||
testnginx 1 3b0e5da1a384 11 seconds ago 221MB
|
||||
nginx latest f68d6e55e065 13 days ago 109MB
|
||||
ubuntu 14.04 2c5e00d77a67 2 months ago 188MB
|
||||
centos 6 d0957ffdf8a2 4 months ago 194MB
|
||||
|
||||
```
|
||||
|
||||
接下来,我们就可以运行这个新的镜像。
|
||||
|
||||
```
|
||||
# docker run -d -p 8081:80 testnginx:1
|
||||
f604f0e34bc263bc32ba683d97a1db2a65de42ab052da16df3c7811ad07f0dc3
|
||||
# docker ps
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
f604f0e34bc2 testnginx:1 "/bin/sh -c 'nginx -…" 2 seconds ago Up 2 seconds 0.0.0.0:8081->80/tcp youthful_torvalds
|
||||
73ff0c8bea6e nginx "nginx -g 'daemon of…" 33 minutes ago Up 33 minutes 0.0.0.0:8080->80/tcp modest_payne
|
||||
|
||||
```
|
||||
|
||||
我们再来访问我们在nginx里面写的代码。
|
||||
|
||||
```
|
||||
[root@deployer nginx]# curl http://localhost:8081/test.html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome to nginx Test 7!</title>
|
||||
|
||||
```
|
||||
|
||||
看,我们的代码已经运行起来了。是不是很酷?
|
||||
|
||||
其实这种运行方式有更加酷的功能。
|
||||
|
||||
第一就是持续集成。
|
||||
|
||||
想象一下,你写了一个程序,然后把它打成了上面一样的镜像。你在本地一运行docker run就把他运行起来了。接下来,你交给测试的就不是一个“程序包+配置+手册”了,而是一个容器镜像了。测试小伙伴同样通过docker run也就运行起来了,不存在“你这里跑的起来,他那里跑不起来的情况”。测试完了再上生产,交给运维的小伙伴也是这样一个镜像,同样的运行同样的顺畅。这种模式使得软件的交付效率大大提高,可以一天发布多次。
|
||||
|
||||
第二就是弹性伸缩。
|
||||
|
||||
想象一下,你写了一个程序,平时用的人少,只需要10个副本就能够扛住了。突然有一天,要做促销,需要100个副本,另外90台机器创建出来相对比较容易,用任何一个云都可以做到,但是里面90个副本的应用如何部署呢?一个个上去手动部署吗?有了容器就方便多了,只要在每台机器上docker run一下就搞定了。
|
||||
|
||||
第三就是跨云迁移。
|
||||
|
||||
如果你不相信任何一个云,怕被一个云绑定,怕一个云挂了自己的应用也就挂了。那我们想一想,该怎么办呢?你只能手动在一个云上部署一份,在另外一个云上也部署一份。有了容器了之后,由于容器镜像对于云是中立的,你在这个云上docker run,就在这个云上提供服务,等哪天想用另一个云了,不用怕应用迁移不走,只要在另外一个云上docker run一下就解决了。
|
||||
|
||||
到现在,是不是能够感受到容器的集装箱功能了,这就是看起来隔离的作用。
|
||||
|
||||
你可能会问,多个容器运行在一台机器上,不会相互影响吗?如何限制CPU和内存的使用呢?
|
||||
|
||||
Docker本身提供了这样的功能。Docker可以限制对于CPU的使用,我们可以分几种的方式。
|
||||
|
||||
- Docker允许用户为每个容器设置一个数字,代表容器的 CPU share,默认情况下每个容器的 share 是 1024。这个数值是相对的,本身并不能代表任何确定的意义。当主机上有多个容器运行时,每个容器占用的 CPU 时间比例为它的 share 在总额中的比例。Docker为容器设置CPU share 的参数是 -c --cpu-shares。
|
||||
- Docker提供了 --cpus 参数可以限定容器能使用的 CPU 核数。
|
||||
- Docker可以通过 --cpuset 参数让容器只运行在某些核上
|
||||
|
||||
Docker会限制容器内存使用量,下面是一些具体的参数。
|
||||
|
||||
- -m --memory:容器能使用的最大内存大小。
|
||||
- –memory-swap:容器能够使用的 swap 大小。
|
||||
- –memory-swappiness:默认情况下,主机可以把容器使用的匿名页swap出来,你可以设置一个 0-100 之间的值,代表允许 swap 出来的比例。
|
||||
- –memory-reservation:设置一个内存使用的 soft limit,如果 docker 发现主机内存不足,会执行 OOM (Out of Memory)操作。这个值必须小于 --memory 设置的值。
|
||||
- –kernel-memory:容器能够使用的 kernel memory 大小。
|
||||
- –oom-kill-disable:是否运行 OOM (Out of Memory)的时候杀死容器。只有设置了 -m,才可以把这个选项设置为 false,否则容器会耗尽主机内存,而且导致主机应用被杀死。
|
||||
|
||||
这就是用起来隔离的效果。
|
||||
|
||||
那这些看起来隔离和用起来隔离的技术,到内核里面是如何实现的呢?我们下一节仔细分析。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这里我们来总结一下这一节的内容。无论是容器,还是虚拟机,都依赖于内核中的技术,虚拟机依赖的是KVM,容器依赖的是namespace和cgroup对进程进行隔离。
|
||||
|
||||
为了运行Docker,有一个daemon进程Docker Daemon用于接收命令行。
|
||||
|
||||
为了描述Docker里面运行的环境和应用,有一个Dockerfile,通过build命令称为容器镜像。容器镜像可以上传到镜像仓库,也可以通过pull命令从镜像仓库中下载现成的容器镜像。
|
||||
|
||||
通过Docker run命令将容器镜像运行为容器,通过namespace和cgroup进行隔离,容器里面不包含内核,是共享宿主机的内核的。对比虚拟机,虚拟机在qemu进程里面是有客户机内核的,应用运行在客户机的用户态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/c5/5a499cb50a1b214a39ddf19cbb63dcc5.jpg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
请你试着用Tomcat的容器镜像启动一个Java网站程序,并进行访问。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
@@ -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 "/bin/sh -c 'nginx -…" 17 hours ago Up 17 hours 0.0.0.0:8081->80/tcp youthful_torvalds
|
||||
|
||||
```
|
||||
|
||||
我们可以看这个容器对应的entrypoint的pid。通过docker inspect命令,可以看到,进程号为58212。
|
||||
|
||||
```
|
||||
[root@deployer ~]# docker inspect f604f0e34bc2
|
||||
[
|
||||
{
|
||||
"Id": "f604f0e34bc263bc32ba683d97a1db2a65de42ab052da16df3c7811ad07f0dc3",
|
||||
"Created": "2019-07-15T17:43:44.158300531Z",
|
||||
"Path": "/bin/sh",
|
||||
"Args": [
|
||||
"-c",
|
||||
"nginx -g \"daemon off;\""
|
||||
],
|
||||
"State": {
|
||||
"Status": "running",
|
||||
"Running": true,
|
||||
"Pid": 58212,
|
||||
"ExitCode": 0,
|
||||
"StartedAt": "2019-07-15T17:43:44.651756682Z",
|
||||
"FinishedAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
......
|
||||
"Name": "/youthful_torvalds",
|
||||
"RestartCount": 0,
|
||||
"Driver": "overlay2",
|
||||
"Platform": "linux",
|
||||
"HostConfig": {
|
||||
"NetworkMode": "default",
|
||||
"PortBindings": {
|
||||
"80/tcp": [
|
||||
{
|
||||
"HostIp": "",
|
||||
"HostPort": "8081"
|
||||
}
|
||||
]
|
||||
},
|
||||
......
|
||||
},
|
||||
"Config": {
|
||||
"Hostname": "f604f0e34bc2",
|
||||
"ExposedPorts": {
|
||||
"80/tcp": {}
|
||||
},
|
||||
"Image": "testnginx:1",
|
||||
"Entrypoint": [
|
||||
"/bin/sh",
|
||||
"-c",
|
||||
"nginx -g \"daemon off;\""
|
||||
],
|
||||
},
|
||||
"NetworkSettings": {
|
||||
"Bridge": "",
|
||||
"SandboxID": "7fd3eb469578903b66687090e512958658ae28d17bce1a7cee2da3148d1dfad4",
|
||||
"Ports": {
|
||||
"80/tcp": [
|
||||
{
|
||||
"HostIp": "0.0.0.0",
|
||||
"HostPort": "8081"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Gateway": "172.17.0.1",
|
||||
"IPAddress": "172.17.0.3",
|
||||
"IPPrefixLen": 16,
|
||||
"MacAddress": "02:42:ac:11:00:03",
|
||||
"Networks": {
|
||||
"bridge": {
|
||||
"NetworkID": "c8eef1603afb399bf17af154be202fd1e543d3772cc83ef4a1ca3f97b8bd6eda",
|
||||
"EndpointID": "8d9bb18ca57889112e758ede193d2cfb45cbf794c9d952819763c08f8545da46",
|
||||
"Gateway": "172.17.0.1",
|
||||
"IPAddress": "172.17.0.3",
|
||||
"IPPrefixLen": 16,
|
||||
"MacAddress": "02:42:ac:11:00:03",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
```
|
||||
|
||||
如果我们用ps查看机器上的nginx进程,可以看到master和worker,worker的父进程是master。
|
||||
|
||||
```
|
||||
# ps -ef |grep nginx
|
||||
root 58212 58195 0 01:43 ? 00:00:00 /bin/sh -c nginx -g "daemon off;"
|
||||
root 58244 58212 0 01:43 ? 00:00:00 nginx: master process nginx -g daemon off;
|
||||
33 58250 58244 0 01:43 ? 00:00:00 nginx: worker process
|
||||
33 58251 58244 0 01:43 ? 00:00:05 nginx: worker process
|
||||
33 58252 58244 0 01:43 ? 00:00:05 nginx: worker process
|
||||
33 58253 58244 0 01:43 ? 00:00:05 nginx: worker process
|
||||
|
||||
```
|
||||
|
||||
在/proc/pid/ns里面,我们能够看到这个进程所属于的6种namespace。我们拿出两个进程来,应该可以看出来,它们属于同一个namespace。
|
||||
|
||||
```
|
||||
# ls -l /proc/58212/ns
|
||||
lrwxrwxrwx 1 root root 0 Jul 16 19:19 ipc -> ipc:[4026532278]
|
||||
lrwxrwxrwx 1 root root 0 Jul 16 19:19 mnt -> mnt:[4026532276]
|
||||
lrwxrwxrwx 1 root root 0 Jul 16 01:43 net -> net:[4026532281]
|
||||
lrwxrwxrwx 1 root root 0 Jul 16 19:19 pid -> pid:[4026532279]
|
||||
lrwxrwxrwx 1 root root 0 Jul 16 19:19 user -> user:[4026531837]
|
||||
lrwxrwxrwx 1 root root 0 Jul 16 19:19 uts -> uts:[4026532277]
|
||||
|
||||
# ls -l /proc/58253/ns
|
||||
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 ipc -> ipc:[4026532278]
|
||||
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 mnt -> mnt:[4026532276]
|
||||
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 net -> net:[4026532281]
|
||||
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 pid -> pid:[4026532279]
|
||||
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 user -> user:[4026531837]
|
||||
lrwxrwxrwx 1 33 tape 0 Jul 16 19:20 uts -> uts:[4026532277]
|
||||
|
||||
```
|
||||
|
||||
接下来,我们来看,如何操作namespace。这里我们重点关注pid和network。
|
||||
|
||||
操作namespace的常用指令**nsenter**,可以用来运行一个进程,进入指定的namespace。例如,通过下面的命令,我们可以运行/bin/bash,并且进入nginx所在容器的namespace。
|
||||
|
||||
```
|
||||
# nsenter --target 58212 --mount --uts --ipc --net --pid -- env --ignore-environment -- /bin/bash
|
||||
|
||||
root@f604f0e34bc2:/# ip addr
|
||||
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
|
||||
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
||||
inet 127.0.0.1/8 scope host lo
|
||||
valid_lft forever preferred_lft forever
|
||||
23: eth0@if24: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
|
||||
link/ether 02:42:ac:11:00:03 brd ff:ff:ff:ff:ff:ff
|
||||
inet 172.17.0.3/16 brd 172.17.255.255 scope global eth0
|
||||
valid_lft forever preferred_lft forever
|
||||
|
||||
```
|
||||
|
||||
另一个命令是**unshare**,它会离开当前的namespace,创建且加入新的namespace,然后执行参数中指定的命令。
|
||||
|
||||
例如,运行下面这行命令之后,pid和net都进入了新的namespace。
|
||||
|
||||
```
|
||||
unshare --mount --ipc --pid --net --mount-proc=/proc --fork /bin/bash
|
||||
|
||||
```
|
||||
|
||||
如果从shell上运行上面这行命令的话,好像没有什么变化,但是因为pid和net都进入了新的namespace,所以我们查看进程列表和ip地址的时候应该会发现有所不同。
|
||||
|
||||
```
|
||||
# ip addr
|
||||
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
|
||||
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
||||
|
||||
# ps aux
|
||||
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
|
||||
root 1 0.0 0.0 115568 2136 pts/0 S 22:55 0:00 /bin/bash
|
||||
root 13 0.0 0.0 155360 1872 pts/0 R+ 22:55 0:00 ps aux
|
||||
|
||||
```
|
||||
|
||||
果真,我们看不到宿主机上的IP地址和网卡了,也看不到宿主机上的所有进程了。
|
||||
|
||||
另外,我们还可以通过函数操作namespace。
|
||||
|
||||
第一个函数是**clone**,也就是创建一个新的进程,并把它放到新的namespace中。
|
||||
|
||||
```
|
||||
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);
|
||||
|
||||
```
|
||||
|
||||
clone函数我们原来介绍过。这里面有一个参数flags,原来我们没有注意它。其实它可以设置为CLONE_NEWUTS、CLONE_NEWUSER、CLONE_NEWNS、CLONE_NEWPID。CLONE_NEWNET会将clone出来的新进程放到新的namespace中。
|
||||
|
||||
第二个函数是**setns**,用于将当前进程加入到已有的namespace中。
|
||||
|
||||
```
|
||||
int setns(int fd, int nstype);
|
||||
|
||||
```
|
||||
|
||||
其中,fd指向/proc/[pid]/ns/目录里相应namespace对应的文件,表示要加入哪个namespace。nstype用来指定namespace的类型,可以设置为CLONE_NEWUTS、CLONE_NEWUSER、CLONE_NEWNS、CLONE_NEWPID和CLONE_NEWNET。
|
||||
|
||||
第三个函数是**unshare**,它可以使当前进程退出当前的namespace,并加入到新创建的namespace。
|
||||
|
||||
```
|
||||
int unshare(int flags);
|
||||
|
||||
```
|
||||
|
||||
其中,flags用于指定一个或者多个上面的CLONE_NEWUTS、CLONE_NEWUSER、CLONE_NEWNS、CLONE_NEWPID和CLONE_NEWNET。
|
||||
|
||||
clone和unshare的区别是,unshare是使当前进程加入新的namespace;clone是创建一个新的子进程,然后让子进程加入新的namespace,而当前进程保持不变。
|
||||
|
||||
这里我们尝试一下,通过clone函数来进入一个namespace。
|
||||
|
||||
```
|
||||
#define _GNU_SOURCE
|
||||
#include <sys/wait.h>
|
||||
#include <sys/utsname.h>
|
||||
#include <sched.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#define STACK_SIZE (1024 * 1024)
|
||||
|
||||
static int childFunc(void *arg)
|
||||
{
|
||||
printf("In child process.\n");
|
||||
execlp("bash", "bash", (char *) NULL);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
char *stack;
|
||||
char *stackTop;
|
||||
pid_t pid;
|
||||
|
||||
stack = malloc(STACK_SIZE);
|
||||
if (stack == NULL)
|
||||
{
|
||||
perror("malloc");
|
||||
exit(1);
|
||||
}
|
||||
stackTop = stack + STACK_SIZE;
|
||||
|
||||
pid = clone(childFunc, stackTop, CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWNET|SIGCHLD, NULL);
|
||||
if (pid == -1)
|
||||
{
|
||||
perror("clone");
|
||||
exit(1);
|
||||
}
|
||||
printf("clone() returned %ld\n", (long) pid);
|
||||
|
||||
sleep(1);
|
||||
|
||||
if (waitpid(pid, NULL, 0) == -1)
|
||||
{
|
||||
perror("waitpid");
|
||||
exit(1);
|
||||
}
|
||||
printf("child has terminated\n");
|
||||
exit(0);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在上面的代码中,我们调用clone的时候,给的参数是CLONE_NEWNS|CLONE_NEWPID|CLONE_NEWNET,也就是说,我们会进入一个新的pid、network,以及mount的namespace。
|
||||
|
||||
如果我们编译运行它,可以得到下面的结果。
|
||||
|
||||
```
|
||||
# echo $$
|
||||
64267
|
||||
|
||||
# ps aux | grep bash | grep -v grep
|
||||
root 64267 0.0 0.0 115572 2176 pts/0 Ss 16:53 0:00 -bash
|
||||
|
||||
# ./a.out
|
||||
clone() returned 64360
|
||||
In child process.
|
||||
|
||||
# echo $$
|
||||
1
|
||||
|
||||
# ip addr
|
||||
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
|
||||
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
|
||||
|
||||
# exit
|
||||
exit
|
||||
child has terminated
|
||||
|
||||
# echo $$
|
||||
64267
|
||||
|
||||
```
|
||||
|
||||
通过`echo $$`,我们可以得到当前bash的进程号。一旦运行了上面的程序,我们就会进入一个新的pid的namespace。
|
||||
|
||||
当我们再次`echo $$`的时候就会发现,当前bash的进程号变成了1。上面的程序运行了一个新的bash,它在一个独立的pid namespace里面,自己是1号进程。如果运行ip addr,可以看到,宿主机的网卡都找不到了,因为新的bash也在一个独立的network namespace里面,等退出了,再次echo $$的时候,就可以得到原来进程号。
|
||||
|
||||
clone系统调用我们在[进程的创建](https://time.geekbang.org/column/article/94064)那一节解析过,当时我们没有看关于namespace的代码,现在我们就来看一看,namespace在内核做了哪些事情。
|
||||
|
||||
在内核里面,clone会调用_do_fork->copy_process->copy_namespaces,也就是说,在创建子进程的时候,有一个机会可以复制和设置namespace。
|
||||
|
||||
namespace是在哪里定义的呢?在每一个进程的task_struct里面,有一个指向namespace结构体的指针nsproxy。
|
||||
|
||||
```
|
||||
struct task_struct {
|
||||
......
|
||||
/* Namespaces: */
|
||||
struct nsproxy *nsproxy;
|
||||
......
|
||||
}
|
||||
|
||||
/*
|
||||
* A structure to contain pointers to all per-process
|
||||
* namespaces - fs (mount), uts, network, sysvipc, etc.
|
||||
*
|
||||
* The pid namespace is an exception -- it's accessed using
|
||||
* task_active_pid_ns. The pid namespace here is the
|
||||
* namespace that children will use.
|
||||
*/
|
||||
struct nsproxy {
|
||||
atomic_t count;
|
||||
struct uts_namespace *uts_ns;
|
||||
struct ipc_namespace *ipc_ns;
|
||||
struct mnt_namespace *mnt_ns;
|
||||
struct pid_namespace *pid_ns_for_children;
|
||||
struct net *net_ns;
|
||||
struct cgroup_namespace *cgroup_ns;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
我们可以看到在struct nsproxy结构里面,有我们上面讲过的各种namespace。
|
||||
|
||||
在系统初始化的时候,有一个默认的init_nsproxy。
|
||||
|
||||
```
|
||||
struct nsproxy init_nsproxy = {
|
||||
.count = ATOMIC_INIT(1),
|
||||
.uts_ns = &init_uts_ns,
|
||||
#if defined(CONFIG_POSIX_MQUEUE) || defined(CONFIG_SYSVIPC)
|
||||
.ipc_ns = &init_ipc_ns,
|
||||
#endif
|
||||
.mnt_ns = NULL,
|
||||
.pid_ns_for_children = &init_pid_ns,
|
||||
#ifdef CONFIG_NET
|
||||
.net_ns = &init_net,
|
||||
#endif
|
||||
#ifdef CONFIG_CGROUPS
|
||||
.cgroup_ns = &init_cgroup_ns,
|
||||
#endif
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
下面,我们来看copy_namespaces的实现。
|
||||
|
||||
```
|
||||
/*
|
||||
* called from clone. This now handles copy for nsproxy and all
|
||||
* namespaces therein.
|
||||
*/
|
||||
int copy_namespaces(unsigned long flags, struct task_struct *tsk)
|
||||
{
|
||||
struct nsproxy *old_ns = tsk->nsproxy;
|
||||
struct user_namespace *user_ns = task_cred_xxx(tsk, user_ns);
|
||||
struct nsproxy *new_ns;
|
||||
|
||||
if (likely(!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
|
||||
CLONE_NEWPID | CLONE_NEWNET |
|
||||
CLONE_NEWCGROUP)))) {
|
||||
get_nsproxy(old_ns);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!ns_capable(user_ns, CAP_SYS_ADMIN))
|
||||
return -EPERM;
|
||||
......
|
||||
new_ns = create_new_namespaces(flags, tsk, user_ns, tsk->fs);
|
||||
|
||||
tsk->nsproxy = new_ns;
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果clone的参数里面没有CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC | CLONE_NEWPID | CLONE_NEWNET | CLONE_NEWCGROUP,就返回原来的namespace,调用get_nsproxy。
|
||||
|
||||
接着,我们调用create_new_namespaces。
|
||||
|
||||
```
|
||||
/*
|
||||
* Create new nsproxy and all of its the associated namespaces.
|
||||
* Return the newly created nsproxy. Do not attach this to the task,
|
||||
* leave it to the caller to do proper locking and attach it to task.
|
||||
*/
|
||||
static struct nsproxy *create_new_namespaces(unsigned long flags,
|
||||
struct task_struct *tsk, struct user_namespace *user_ns,
|
||||
struct fs_struct *new_fs)
|
||||
{
|
||||
struct nsproxy *new_nsp;
|
||||
|
||||
new_nsp = create_nsproxy();
|
||||
......
|
||||
new_nsp->mnt_ns = copy_mnt_ns(flags, tsk->nsproxy->mnt_ns, user_ns, new_fs);
|
||||
......
|
||||
new_nsp->uts_ns = copy_utsname(flags, user_ns, tsk->nsproxy->uts_ns);
|
||||
......
|
||||
new_nsp->ipc_ns = copy_ipcs(flags, user_ns, tsk->nsproxy->ipc_ns);
|
||||
......
|
||||
new_nsp->pid_ns_for_children =
|
||||
copy_pid_ns(flags, user_ns, tsk->nsproxy->pid_ns_for_children);
|
||||
......
|
||||
new_nsp->cgroup_ns = copy_cgroup_ns(flags, user_ns,
|
||||
tsk->nsproxy->cgroup_ns);
|
||||
......
|
||||
new_nsp->net_ns = copy_net_ns(flags, user_ns, tsk->nsproxy->net_ns);
|
||||
......
|
||||
return new_nsp;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在create_new_namespaces中,我们可以看到对于各种namespace的复制。
|
||||
|
||||
我们来看copy_pid_ns对于pid namespace的复制。
|
||||
|
||||
```
|
||||
struct pid_namespace *copy_pid_ns(unsigned long flags,
|
||||
struct user_namespace *user_ns, struct pid_namespace *old_ns)
|
||||
{
|
||||
if (!(flags & CLONE_NEWPID))
|
||||
return get_pid_ns(old_ns);
|
||||
if (task_active_pid_ns(current) != old_ns)
|
||||
return ERR_PTR(-EINVAL);
|
||||
return create_pid_namespace(user_ns, old_ns);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在copy_pid_ns中,如果没有设置CLONE_NEWPID,则返回老的pid namespace;如果设置了,就调用create_pid_namespace,创建新的pid namespace.
|
||||
|
||||
我们再来看copy_net_ns对于network namespace的复制。
|
||||
|
||||
```
|
||||
struct net *copy_net_ns(unsigned long flags,
|
||||
struct user_namespace *user_ns, struct net *old_net)
|
||||
{
|
||||
struct ucounts *ucounts;
|
||||
struct net *net;
|
||||
int rv;
|
||||
|
||||
if (!(flags & CLONE_NEWNET))
|
||||
return get_net(old_net);
|
||||
|
||||
ucounts = inc_net_namespaces(user_ns);
|
||||
......
|
||||
net = net_alloc();
|
||||
......
|
||||
get_user_ns(user_ns);
|
||||
net->ucounts = ucounts;
|
||||
rv = setup_net(net, user_ns);
|
||||
......
|
||||
return net;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里面,我们需要判断,如果flags中不包含CLONE_NEWNET,也就是不会创建一个新的network namespace,则返回old_net;否则需要新建一个network namespace。
|
||||
|
||||
然后,copy_net_ns会调用net = net_alloc(),分配一个新的struct net结构,然后调用setup_net对新分配的net结构进行初始化,之后调用list_add_tail_rcu,将新建的network namespace,添加到全局的network namespace列表net_namespace_list中。
|
||||
|
||||
我们来看一下setup_net的实现。
|
||||
|
||||
```
|
||||
/*
|
||||
* setup_net runs the initializers for the network namespace object.
|
||||
*/
|
||||
static __net_init int setup_net(struct net *net, struct user_namespace *user_ns)
|
||||
{
|
||||
/* Must be called with net_mutex held */
|
||||
const struct pernet_operations *ops, *saved_ops;
|
||||
LIST_HEAD(net_exit_list);
|
||||
|
||||
atomic_set(&net->count, 1);
|
||||
refcount_set(&net->passive, 1);
|
||||
net->dev_base_seq = 1;
|
||||
net->user_ns = user_ns;
|
||||
idr_init(&net->netns_ids);
|
||||
spin_lock_init(&net->nsid_lock);
|
||||
|
||||
list_for_each_entry(ops, &pernet_list, list) {
|
||||
error = ops_init(ops, net);
|
||||
......
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在setup_net中,这里面有一个循环list_for_each_entry,对于pernet_list的每一项struct pernet_operations,运行ops_init,也就是调用pernet_operations的init函数。
|
||||
|
||||
这个pernet_list是怎么来的呢?在网络设备初始化的时候,我们要调用net_dev_init函数,这里面有下面的代码。
|
||||
|
||||
```
|
||||
register_pernet_device(&loopback_net_ops)
|
||||
|
||||
int register_pernet_device(struct pernet_operations *ops)
|
||||
{
|
||||
int error;
|
||||
mutex_lock(&net_mutex);
|
||||
error = register_pernet_operations(&pernet_list, ops);
|
||||
if (!error && (first_device == &pernet_list))
|
||||
first_device = &ops->list;
|
||||
mutex_unlock(&net_mutex);
|
||||
return error;
|
||||
}
|
||||
|
||||
struct pernet_operations __net_initdata loopback_net_ops = {
|
||||
.init = loopback_net_init,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
register_pernet_device函数注册了一个loopback_net_ops,在这里面,把init函数设置为loopback_net_init.
|
||||
|
||||
```
|
||||
static __net_init int loopback_net_init(struct net *net)
|
||||
{
|
||||
struct net_device *dev;
|
||||
dev = alloc_netdev(0, "lo", NET_NAME_UNKNOWN, loopback_setup);
|
||||
......
|
||||
dev_net_set(dev, net);
|
||||
err = register_netdev(dev);
|
||||
......
|
||||
net->loopback_dev = dev;
|
||||
return 0;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在loopback_net_init函数中,我们会创建并且注册一个名字为"lo"的struct net_device。注册完之后,在这个namespace里面就会出现一个这样的网络设备,称为loopback网络设备。
|
||||
|
||||
这就是为什么上面的实验中,创建出的新的network namespace里面有一个lo网络设备。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节我们讲了namespace相关的技术,有六种类型,分别是UTS、User、Mount、Pid、Network和IPC。
|
||||
|
||||
还有两个常用的命令nsenter和unshare,主要用于操作Namespace,有三个常用的函数clone、setns和unshare。
|
||||
|
||||
在内核里面,对于任何一个进程task_struct来讲,里面都会有一个成员struct nsproxy,用于保存namespace相关信息,里面有 struct uts_namespace、struct ipc_namespace、struct mnt_namespace、struct pid_namespace、struct net *net_ns和struct cgroup_namespace *cgroup_ns。
|
||||
|
||||
创建namespace的时候,我们在内核中会调用copy_namespaces,调用顺序依次是copy_mnt_ns、copy_utsname、copy_ipcs、copy_pid_ns、copy_cgroup_ns和copy_net_ns,来复制namespace。
|
||||
|
||||
<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="">
|
||||
@@ -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 "/bin/sh -c 'nginx -…" About a minute ago Up About a minute 0.0.0.0:8081->80/tcp boring_cohen
|
||||
|
||||
```
|
||||
|
||||
在Linux上,为了操作cgroup,有一个专门的cgroup文件系统,我们运行mount命令可以查看。
|
||||
|
||||
```
|
||||
# mount -t cgroup
|
||||
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd)
|
||||
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_prio,net_cls)
|
||||
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event)
|
||||
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices)
|
||||
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
|
||||
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu)
|
||||
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
|
||||
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
|
||||
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb)
|
||||
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer)
|
||||
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids)
|
||||
|
||||
```
|
||||
|
||||
cgroup文件系统多挂载到/sys/fs/cgroup下,通过上面的命令行,我们可以看到我们可以用cgroup控制哪些资源。
|
||||
|
||||
对于CPU的控制,我在这一章的第一节讲过,Docker可以控制cpu-shares、cpus和cpuset。
|
||||
|
||||
我们在/sys/fs/cgroup/下面能看到下面的目录结构。
|
||||
|
||||
```
|
||||
drwxr-xr-x 5 root root 0 May 30 17:00 blkio
|
||||
lrwxrwxrwx 1 root root 11 May 30 17:00 cpu -> cpu,cpuacct
|
||||
lrwxrwxrwx 1 root root 11 May 30 17:00 cpuacct -> cpu,cpuacct
|
||||
drwxr-xr-x 5 root root 0 May 30 17:00 cpu,cpuacct
|
||||
drwxr-xr-x 3 root root 0 May 30 17:00 cpuset
|
||||
drwxr-xr-x 5 root root 0 May 30 17:00 devices
|
||||
drwxr-xr-x 3 root root 0 May 30 17:00 freezer
|
||||
drwxr-xr-x 3 root root 0 May 30 17:00 hugetlb
|
||||
drwxr-xr-x 5 root root 0 May 30 17:00 memory
|
||||
lrwxrwxrwx 1 root root 16 May 30 17:00 net_cls -> net_cls,net_prio
|
||||
drwxr-xr-x 3 root root 0 May 30 17:00 net_cls,net_prio
|
||||
lrwxrwxrwx 1 root root 16 May 30 17:00 net_prio -> net_cls,net_prio
|
||||
drwxr-xr-x 3 root root 0 May 30 17:00 perf_event
|
||||
drwxr-xr-x 5 root root 0 May 30 17:00 pids
|
||||
drwxr-xr-x 5 root root 0 May 30 17:00 systemd
|
||||
|
||||
```
|
||||
|
||||
我们可以想象,CPU的资源控制的配置文件,应该在cpu,cpuacct这个文件夹下面。
|
||||
|
||||
```
|
||||
# ls
|
||||
cgroup.clone_children cpu.cfs_period_us notify_on_release
|
||||
cgroup.event_control cpu.cfs_quota_us release_agent
|
||||
cgroup.procs cpu.rt_period_us system.slice
|
||||
cgroup.sane_behavior cpu.rt_runtime_us tasks
|
||||
cpuacct.stat cpu.shares user.slice
|
||||
cpuacct.usage cpu.stat
|
||||
cpuacct.usage_percpu docker
|
||||
|
||||
```
|
||||
|
||||
果真,这下面是对CPU的相关控制,里面还有一个路径叫docker。我们进入这个路径。
|
||||
|
||||
```
|
||||
]# ls
|
||||
cgroup.clone_children
|
||||
cgroup.event_control
|
||||
cgroup.procs
|
||||
cpuacct.stat
|
||||
cpuacct.usage
|
||||
cpuacct.usage_percpu
|
||||
cpu.cfs_period_us
|
||||
cpu.cfs_quota_us
|
||||
cpu.rt_period_us
|
||||
cpu.rt_runtime_us
|
||||
cpu.shares
|
||||
cpu.stat
|
||||
3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd
|
||||
notify_on_release
|
||||
tasks
|
||||
|
||||
```
|
||||
|
||||
这里面有个很长的id,是我们创建的docker的id。
|
||||
|
||||
```
|
||||
[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# ls
|
||||
cgroup.clone_children cpuacct.usage_percpu cpu.shares
|
||||
cgroup.event_control cpu.cfs_period_us cpu.stat
|
||||
cgroup.procs cpu.cfs_quota_us notify_on_release
|
||||
cpuacct.stat cpu.rt_period_us tasks
|
||||
cpuacct.usage cpu.rt_runtime_us
|
||||
|
||||
```
|
||||
|
||||
在这里,我们能看到cpu.shares,还有一个重要的文件tasks。这里面是这个容器里所有进程的进程号,也即所有这些进程都被这些CPU策略控制。
|
||||
|
||||
```
|
||||
[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat tasks
|
||||
39487
|
||||
39520
|
||||
39526
|
||||
39527
|
||||
39528
|
||||
39529
|
||||
|
||||
```
|
||||
|
||||
如果我们查看cpu.shares,里面就是我们设置的513。
|
||||
|
||||
```
|
||||
[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat cpu.shares
|
||||
513
|
||||
|
||||
```
|
||||
|
||||
另外,我们还配置了cpus,这个值其实是由cpu.cfs_period_us和cpu.cfs_quota_us共同决定的。cpu.cfs_period_us是运行周期,cpu.cfs_quota_us是在周期内这些进程占用多少时间。我们设置了cpus为2,代表的意思是,在周期100000微秒的运行周期内,这些进程要占用200000微秒的时间,也即需要两个CPU同时运行一个整的周期。
|
||||
|
||||
```
|
||||
[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat cpu.cfs_period_us
|
||||
100000
|
||||
[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat cpu.cfs_quota_us
|
||||
200000
|
||||
|
||||
```
|
||||
|
||||
对于cpuset,也即CPU绑核的参数,在另外一个文件夹里面/sys/fs/cgroup/cpuset,这里面同样有一个docker文件夹,下面同样有docker id 也即3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd文件夹,这里面的cpuset.cpus就是配置的绑定到1、3两个核。
|
||||
|
||||
```
|
||||
[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat cpuset.cpus
|
||||
1,3
|
||||
|
||||
```
|
||||
|
||||
这一章的第一节我们还讲了Docker可以限制内存的使用量,例如memory、memory-swap、memory-swappiness。这些在哪里控制呢?
|
||||
|
||||
/sys/fs/cgroup/下面还有一个memory路径,控制策略就是在这里面定义的。
|
||||
|
||||
```
|
||||
[root@deployer memory]# ls
|
||||
cgroup.clone_children memory.memsw.failcnt
|
||||
cgroup.event_control memory.memsw.limit_in_bytes
|
||||
cgroup.procs memory.memsw.max_usage_in_bytes
|
||||
cgroup.sane_behavior memory.memsw.usage_in_bytes
|
||||
docker memory.move_charge_at_immigrate
|
||||
memory.failcnt memory.numa_stat
|
||||
memory.force_empty memory.oom_control
|
||||
memory.kmem.failcnt memory.pressure_level
|
||||
memory.kmem.limit_in_bytes memory.soft_limit_in_bytes
|
||||
memory.kmem.max_usage_in_bytes memory.stat
|
||||
memory.kmem.slabinfo memory.swappiness
|
||||
memory.kmem.tcp.failcnt memory.usage_in_bytes
|
||||
memory.kmem.tcp.limit_in_bytes memory.use_hierarchy
|
||||
memory.kmem.tcp.max_usage_in_bytes notify_on_release
|
||||
memory.kmem.tcp.usage_in_bytes release_agent
|
||||
memory.kmem.usage_in_bytes system.slice
|
||||
memory.limit_in_bytes tasks
|
||||
memory.max_usage_in_bytes user.slice
|
||||
|
||||
```
|
||||
|
||||
这里面全是对于memory的控制参数,在这里面我们可看到了docker,里面还有容器的id作为文件夹。
|
||||
|
||||
```
|
||||
[docker]# ls
|
||||
3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd
|
||||
cgroup.clone_children
|
||||
cgroup.event_control
|
||||
cgroup.procs
|
||||
memory.failcnt
|
||||
memory.force_empty
|
||||
memory.kmem.failcnt
|
||||
memory.kmem.limit_in_bytes
|
||||
memory.kmem.max_usage_in_bytes
|
||||
memory.kmem.slabinfo
|
||||
memory.kmem.tcp.failcnt
|
||||
memory.kmem.tcp.limit_in_bytes
|
||||
memory.kmem.tcp.max_usage_in_bytes
|
||||
memory.kmem.tcp.usage_in_bytes
|
||||
memory.kmem.usage_in_bytes
|
||||
memory.limit_in_bytes
|
||||
memory.max_usage_in_bytes
|
||||
memory.memsw.failcnt
|
||||
memory.memsw.limit_in_bytes
|
||||
memory.memsw.max_usage_in_bytes
|
||||
memory.memsw.usage_in_bytes
|
||||
memory.move_charge_at_immigrate
|
||||
memory.numa_stat
|
||||
memory.oom_control
|
||||
memory.pressure_level
|
||||
memory.soft_limit_in_bytes
|
||||
memory.stat
|
||||
memory.swappiness
|
||||
memory.usage_in_bytes
|
||||
memory.use_hierarchy
|
||||
notify_on_release
|
||||
tasks
|
||||
|
||||
[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# ls
|
||||
cgroup.clone_children memory.memsw.failcnt
|
||||
cgroup.event_control memory.memsw.limit_in_bytes
|
||||
cgroup.procs memory.memsw.max_usage_in_bytes
|
||||
memory.failcnt memory.memsw.usage_in_bytes
|
||||
memory.force_empty memory.move_charge_at_immigrate
|
||||
memory.kmem.failcnt memory.numa_stat
|
||||
memory.kmem.limit_in_bytes memory.oom_control
|
||||
memory.kmem.max_usage_in_bytes memory.pressure_level
|
||||
memory.kmem.slabinfo memory.soft_limit_in_bytes
|
||||
memory.kmem.tcp.failcnt memory.stat
|
||||
memory.kmem.tcp.limit_in_bytes memory.swappiness
|
||||
memory.kmem.tcp.max_usage_in_bytes memory.usage_in_bytes
|
||||
memory.kmem.tcp.usage_in_bytes memory.use_hierarchy
|
||||
memory.kmem.usage_in_bytes notify_on_release
|
||||
memory.limit_in_bytes tasks
|
||||
memory.max_usage_in_bytes
|
||||
|
||||
```
|
||||
|
||||
在docker id的文件夹下面,有一个memory.limit_in_bytes,里面配置的就是memory。
|
||||
|
||||
```
|
||||
[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat memory.limit_in_bytes
|
||||
1073741824
|
||||
|
||||
```
|
||||
|
||||
还有memory.swappiness,里面配置的就是memory-swappiness。
|
||||
|
||||
```
|
||||
[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat memory.swappiness
|
||||
7
|
||||
|
||||
```
|
||||
|
||||
还有就是memory.memsw.limit_in_bytes,里面配置的是memory-swap。
|
||||
|
||||
```
|
||||
[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat memory.memsw.limit_in_bytes
|
||||
1293942784
|
||||
|
||||
```
|
||||
|
||||
我们还可以看一下tasks文件的内容,tasks里面是容器里面所有进程的进程号。
|
||||
|
||||
```
|
||||
[3dc0601189dd218898f31f9526a6cfae83913763a4da59f95ec789c6e030ecfd]# cat tasks
|
||||
39487
|
||||
39520
|
||||
39526
|
||||
39527
|
||||
39528
|
||||
39529
|
||||
|
||||
```
|
||||
|
||||
至此,我们看到了cgroup对于Docker资源的控制,在用户态是如何表现的。我画了一张图总结一下。
|
||||
|
||||
<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->id = i;
|
||||
ss->name = cgroup_subsys_name[i];
|
||||
......
|
||||
cgroup_init_subsys(ss, true);
|
||||
}
|
||||
|
||||
#define for_each_subsys(ss, ssid) \
|
||||
for ((ssid) = 0; (ssid) < CGROUP_SUBSYS_COUNT && \
|
||||
(((ss) = cgroup_subsys[ssid]) || true); (ssid)++)
|
||||
|
||||
```
|
||||
|
||||
for_each_subsys会在cgroup_subsys数组中进行循环。这个cgroup_subsys数组是如何形成的呢?
|
||||
|
||||
```
|
||||
#define SUBSYS(_x) [_x ## _cgrp_id] = &_x ## _cgrp_subsys,
|
||||
struct cgroup_subsys *cgroup_subsys[] = {
|
||||
#include <linux/cgroup_subsys.h>
|
||||
};
|
||||
#undef SUBSYS
|
||||
|
||||
```
|
||||
|
||||
SUBSYS这个宏定义了这个cgroup_subsys数组,数组中的项定义在cgroup_subsys.h头文件中。例如,对于CPU和内存有下面的定义。
|
||||
|
||||
```
|
||||
//cgroup_subsys.h
|
||||
|
||||
#if IS_ENABLED(CONFIG_CPUSETS)
|
||||
SUBSYS(cpuset)
|
||||
#endif
|
||||
|
||||
#if IS_ENABLED(CONFIG_CGROUP_SCHED)
|
||||
SUBSYS(cpu)
|
||||
#endif
|
||||
|
||||
#if IS_ENABLED(CONFIG_CGROUP_CPUACCT)
|
||||
SUBSYS(cpuacct)
|
||||
#endif
|
||||
|
||||
#if IS_ENABLED(CONFIG_MEMCG)
|
||||
SUBSYS(memory)
|
||||
#endif
|
||||
|
||||
```
|
||||
|
||||
根据SUBSYS的定义,SUBSYS(cpu)其实是[cpu_cgrp_id] = &cpu_cgrp_subsys,而SUBSYS(memory)其实是[memory_cgrp_id] = &memory_cgrp_subsys。
|
||||
|
||||
我们能够找到cpu_cgrp_subsys和memory_cgrp_subsys的定义。
|
||||
|
||||
```
|
||||
cpuset_cgrp_subsys
|
||||
struct cgroup_subsys cpuset_cgrp_subsys = {
|
||||
.css_alloc = cpuset_css_alloc,
|
||||
.css_online = cpuset_css_online,
|
||||
.css_offline = cpuset_css_offline,
|
||||
.css_free = cpuset_css_free,
|
||||
.can_attach = cpuset_can_attach,
|
||||
.cancel_attach = cpuset_cancel_attach,
|
||||
.attach = cpuset_attach,
|
||||
.post_attach = cpuset_post_attach,
|
||||
.bind = cpuset_bind,
|
||||
.fork = cpuset_fork,
|
||||
.legacy_cftypes = files,
|
||||
.early_init = true,
|
||||
};
|
||||
|
||||
cpu_cgrp_subsys
|
||||
struct cgroup_subsys cpu_cgrp_subsys = {
|
||||
.css_alloc = cpu_cgroup_css_alloc,
|
||||
.css_online = cpu_cgroup_css_online,
|
||||
.css_released = cpu_cgroup_css_released,
|
||||
.css_free = cpu_cgroup_css_free,
|
||||
.fork = cpu_cgroup_fork,
|
||||
.can_attach = cpu_cgroup_can_attach,
|
||||
.attach = cpu_cgroup_attach,
|
||||
.legacy_cftypes = cpu_files,
|
||||
.early_init = true,
|
||||
};
|
||||
|
||||
memory_cgrp_subsys
|
||||
struct cgroup_subsys memory_cgrp_subsys = {
|
||||
.css_alloc = mem_cgroup_css_alloc,
|
||||
.css_online = mem_cgroup_css_online,
|
||||
.css_offline = mem_cgroup_css_offline,
|
||||
.css_released = mem_cgroup_css_released,
|
||||
.css_free = mem_cgroup_css_free,
|
||||
.css_reset = mem_cgroup_css_reset,
|
||||
.can_attach = mem_cgroup_can_attach,
|
||||
.cancel_attach = mem_cgroup_cancel_attach,
|
||||
.post_attach = mem_cgroup_move_task,
|
||||
.bind = mem_cgroup_bind,
|
||||
.dfl_cftypes = memory_files,
|
||||
.legacy_cftypes = mem_cgroup_legacy_files,
|
||||
.early_init = 0,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在for_each_subsys的循环里面,cgroup_subsys[]数组中的每一个cgroup_subsys,都会调用cgroup_init_subsys,对于cgroup_subsys对于初始化。
|
||||
|
||||
```
|
||||
static void __init cgroup_init_subsys(struct cgroup_subsys *ss, bool early)
|
||||
{
|
||||
struct cgroup_subsys_state *css;
|
||||
......
|
||||
idr_init(&ss->css_idr);
|
||||
INIT_LIST_HEAD(&ss->cfts);
|
||||
|
||||
/* Create the root cgroup state for this subsystem */
|
||||
ss->root = &cgrp_dfl_root;
|
||||
css = ss->css_alloc(cgroup_css(&cgrp_dfl_root.cgrp, ss));
|
||||
......
|
||||
init_and_link_css(css, ss, &cgrp_dfl_root.cgrp);
|
||||
......
|
||||
css->id = cgroup_idr_alloc(&ss->css_idr, css, 1, 2, GFP_KERNEL);
|
||||
init_css_set.subsys[ss->id] = css;
|
||||
......
|
||||
BUG_ON(online_css(css));
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
cgroup_init_subsys里面会做两件事情,一个是调用cgroup_subsys的css_alloc函数创建一个cgroup_subsys_state;另外就是调用online_css,也即调用cgroup_subsys的css_online函数,激活这个cgroup。
|
||||
|
||||
对于CPU来讲,css_alloc函数就是cpu_cgroup_css_alloc。这里面会调用 sched_create_group创建一个struct task_group。在这个结构中,第一项就是cgroup_subsys_state,也就是说,task_group是cgroup_subsys_state的一个扩展,最终返回的是指向cgroup_subsys_state结构的指针,可以通过强制类型转换变为task_group。
|
||||
|
||||
```
|
||||
struct task_group {
|
||||
struct cgroup_subsys_state css;
|
||||
|
||||
#ifdef CONFIG_FAIR_GROUP_SCHED
|
||||
/* schedulable entities of this group on each cpu */
|
||||
struct sched_entity **se;
|
||||
/* runqueue "owned" by this group on each cpu */
|
||||
struct cfs_rq **cfs_rq;
|
||||
unsigned long shares;
|
||||
|
||||
#ifdef CONFIG_SMP
|
||||
atomic_long_t load_avg ____cacheline_aligned;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
struct rcu_head rcu;
|
||||
struct list_head list;
|
||||
|
||||
struct task_group *parent;
|
||||
struct list_head siblings;
|
||||
struct list_head children;
|
||||
|
||||
struct cfs_bandwidth cfs_bandwidth;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在task_group结构中,有一个成员是sched_entity,前面我们讲进程调度的时候,遇到过它。它是调度的实体,也即这一个task_group也是一个调度实体。
|
||||
|
||||
接下来,online_css会被调用。对于CPU来讲,online_css调用的是cpu_cgroup_css_online。它会调用sched_online_group->online_fair_sched_group。
|
||||
|
||||
```
|
||||
void online_fair_sched_group(struct task_group *tg)
|
||||
{
|
||||
struct sched_entity *se;
|
||||
struct rq *rq;
|
||||
int i;
|
||||
|
||||
for_each_possible_cpu(i) {
|
||||
rq = cpu_rq(i);
|
||||
se = tg->se[i];
|
||||
update_rq_clock(rq);
|
||||
attach_entity_cfs_rq(se);
|
||||
sync_throttle(tg, i);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里面,对于每一个CPU,取出每个CPU的运行队列rq,也取出task_group的sched_entity,然后通过attach_entity_cfs_rq将sched_entity添加到运行队列中。
|
||||
|
||||
对于内存来讲,css_alloc函数就是mem_cgroup_css_alloc。这里面会调用 mem_cgroup_alloc,创建一个struct mem_cgroup。在这个结构中,第一项就是cgroup_subsys_state,也就是说,mem_cgroup是cgroup_subsys_state的一个扩展,最终返回的是指向cgroup_subsys_state结构的指针,我们可以通过强制类型转换变为mem_cgroup。
|
||||
|
||||
```
|
||||
struct mem_cgroup {
|
||||
struct cgroup_subsys_state css;
|
||||
|
||||
/* Private memcg ID. Used to ID objects that outlive the cgroup */
|
||||
struct mem_cgroup_id id;
|
||||
|
||||
/* Accounted resources */
|
||||
struct page_counter memory;
|
||||
struct page_counter swap;
|
||||
|
||||
/* Legacy consumer-oriented counters */
|
||||
struct page_counter memsw;
|
||||
struct page_counter kmem;
|
||||
struct page_counter tcpmem;
|
||||
|
||||
/* Normal memory consumption range */
|
||||
unsigned long low;
|
||||
unsigned long high;
|
||||
|
||||
/* Range enforcement for interrupt charges */
|
||||
struct work_struct high_work;
|
||||
|
||||
unsigned long soft_limit;
|
||||
|
||||
......
|
||||
int swappiness;
|
||||
......
|
||||
/*
|
||||
* percpu counter.
|
||||
*/
|
||||
struct mem_cgroup_stat_cpu __percpu *stat;
|
||||
|
||||
int last_scanned_node;
|
||||
|
||||
/* List of events which userspace want to receive */
|
||||
struct list_head event_list;
|
||||
spinlock_t event_list_lock;
|
||||
|
||||
struct mem_cgroup_per_node *nodeinfo[0];
|
||||
/* WARNING: nodeinfo must be the last member here */
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在cgroup_init函数中,cgroup的初始化还做了一件很重要的事情,它会调用cgroup_init_cftypes(NULL, cgroup1_base_files),来初始化对于cgroup文件类型cftype的操作函数,也就是将struct kernfs_ops *kf_ops设置为cgroup_kf_ops。
|
||||
|
||||
```
|
||||
struct cftype cgroup1_base_files[] = {
|
||||
......
|
||||
{
|
||||
.name = "tasks",
|
||||
.seq_start = cgroup_pidlist_start,
|
||||
.seq_next = cgroup_pidlist_next,
|
||||
.seq_stop = cgroup_pidlist_stop,
|
||||
.seq_show = cgroup_pidlist_show,
|
||||
.private = CGROUP_FILE_TASKS,
|
||||
.write = cgroup_tasks_write,
|
||||
},
|
||||
}
|
||||
|
||||
static struct kernfs_ops cgroup_kf_ops = {
|
||||
.atomic_write_len = PAGE_SIZE,
|
||||
.open = cgroup_file_open,
|
||||
.release = cgroup_file_release,
|
||||
.write = cgroup_file_write,
|
||||
.seq_start = cgroup_seqfile_start,
|
||||
.seq_next = cgroup_seqfile_next,
|
||||
.seq_stop = cgroup_seqfile_stop,
|
||||
.seq_show = cgroup_seqfile_show,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
在cgroup初始化完毕之后,接下来就是创建一个cgroup的文件系统,用于配置和操作cgroup。
|
||||
|
||||
cgroup是一种特殊的文件系统。它的定义如下:
|
||||
|
||||
```
|
||||
struct file_system_type cgroup_fs_type = {
|
||||
.name = "cgroup",
|
||||
.mount = cgroup_mount,
|
||||
.kill_sb = cgroup_kill_sb,
|
||||
.fs_flags = FS_USERNS_MOUNT,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
当我们mount这个cgroup文件系统的时候,会调用cgroup_mount->cgroup1_mount。
|
||||
|
||||
```
|
||||
struct dentry *cgroup1_mount(struct file_system_type *fs_type, int flags,
|
||||
void *data, unsigned long magic,
|
||||
struct cgroup_namespace *ns)
|
||||
{
|
||||
struct super_block *pinned_sb = NULL;
|
||||
struct cgroup_sb_opts opts;
|
||||
struct cgroup_root *root;
|
||||
struct cgroup_subsys *ss;
|
||||
struct dentry *dentry;
|
||||
int i, ret;
|
||||
bool new_root = false;
|
||||
......
|
||||
root = kzalloc(sizeof(*root), GFP_KERNEL);
|
||||
new_root = true;
|
||||
|
||||
init_cgroup_root(root, &opts);
|
||||
|
||||
ret = cgroup_setup_root(root, opts.subsys_mask, PERCPU_REF_INIT_DEAD);
|
||||
......
|
||||
dentry = cgroup_do_mount(&cgroup_fs_type, flags, root,
|
||||
CGROUP_SUPER_MAGIC, ns);
|
||||
......
|
||||
return dentry;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
cgroup被组织成为树形结构,因而有cgroup_root。init_cgroup_root会初始化这个cgroup_root。cgroup_root是cgroup的根,它有一个成员kf_root,是cgroup文件系统的根struct kernfs_root。kernfs_create_root就是用来创建这个kernfs_root结构的。
|
||||
|
||||
```
|
||||
int cgroup_setup_root(struct cgroup_root *root, u16 ss_mask, int ref_flags)
|
||||
{
|
||||
LIST_HEAD(tmp_links);
|
||||
struct cgroup *root_cgrp = &root->cgrp;
|
||||
struct kernfs_syscall_ops *kf_sops;
|
||||
struct css_set *cset;
|
||||
int i, ret;
|
||||
|
||||
root->kf_root = kernfs_create_root(kf_sops,
|
||||
KERNFS_ROOT_CREATE_DEACTIVATED,
|
||||
root_cgrp);
|
||||
root_cgrp->kn = root->kf_root->kn;
|
||||
|
||||
ret = css_populate_dir(&root_cgrp->self);
|
||||
ret = rebind_subsystems(root, ss_mask);
|
||||
......
|
||||
list_add(&root->root_list, &cgroup_roots);
|
||||
cgroup_root_count++;
|
||||
......
|
||||
kernfs_activate(root_cgrp->kn);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
就像在普通文件系统上,每一个文件都对应一个inode,在cgroup文件系统上,每个文件都对应一个struct kernfs_node结构,当然kernfs_root作为文件系的根也对应一个kernfs_node结构。
|
||||
|
||||
接下来,css_populate_dir会调用cgroup_addrm_files->cgroup_add_file->cgroup_add_file,来创建整棵文件树,并且为树中的每个文件创建对应的kernfs_node结构,并将这个文件的操作函数设置为kf_ops,也即指向cgroup_kf_ops 。
|
||||
|
||||
```
|
||||
static int cgroup_add_file(struct cgroup_subsys_state *css, struct cgroup *cgrp,
|
||||
struct cftype *cft)
|
||||
{
|
||||
char name[CGROUP_FILE_NAME_MAX];
|
||||
struct kernfs_node *kn;
|
||||
......
|
||||
kn = __kernfs_create_file(cgrp->kn, cgroup_file_name(cgrp, cft, name),
|
||||
cgroup_file_mode(cft), 0, cft->kf_ops, cft,
|
||||
NULL, key);
|
||||
......
|
||||
}
|
||||
|
||||
struct kernfs_node *__kernfs_create_file(struct kernfs_node *parent,
|
||||
const char *name,
|
||||
umode_t mode, loff_t size,
|
||||
const struct kernfs_ops *ops,
|
||||
void *priv, const void *ns,
|
||||
struct lock_class_key *key)
|
||||
{
|
||||
struct kernfs_node *kn;
|
||||
unsigned flags;
|
||||
int rc;
|
||||
|
||||
flags = KERNFS_FILE;
|
||||
|
||||
kn = kernfs_new_node(parent, name, (mode & S_IALLUGO) | S_IFREG, flags);
|
||||
|
||||
kn->attr.ops = ops;
|
||||
kn->attr.size = size;
|
||||
kn->ns = ns;
|
||||
kn->priv = priv;
|
||||
......
|
||||
rc = kernfs_add_one(kn);
|
||||
......
|
||||
return kn;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
从cgroup_setup_root返回后,接下来,在cgroup1_mount中,要做的一件事情是cgroup_do_mount,调用kernfs_mount真的去mount这个文件系统,返回一个普通的文件系统都认识的dentry。这种特殊的文件系统对应的文件操作函数为kernfs_file_fops。
|
||||
|
||||
```
|
||||
const struct file_operations kernfs_file_fops = {
|
||||
.read = kernfs_fop_read,
|
||||
.write = kernfs_fop_write,
|
||||
.llseek = generic_file_llseek,
|
||||
.mmap = kernfs_fop_mmap,
|
||||
.open = kernfs_fop_open,
|
||||
.release = kernfs_fop_release,
|
||||
.poll = kernfs_fop_poll,
|
||||
.fsync = noop_fsync,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
当我们要写入一个CGroup文件来设置参数的时候,根据文件系统的操作,kernfs_fop_write会被调用,在这里面会调用kernfs_ops的write函数,根据上面的定义为cgroup_file_write,在这里会调用cftype的write函数。对于CPU和内存的write函数,有以下不同的定义。
|
||||
|
||||
```
|
||||
static struct cftype cpu_files[] = {
|
||||
#ifdef CONFIG_FAIR_GROUP_SCHED
|
||||
{
|
||||
.name = "shares",
|
||||
.read_u64 = cpu_shares_read_u64,
|
||||
.write_u64 = cpu_shares_write_u64,
|
||||
},
|
||||
#endif
|
||||
#ifdef CONFIG_CFS_BANDWIDTH
|
||||
{
|
||||
.name = "cfs_quota_us",
|
||||
.read_s64 = cpu_cfs_quota_read_s64,
|
||||
.write_s64 = cpu_cfs_quota_write_s64,
|
||||
},
|
||||
{
|
||||
.name = "cfs_period_us",
|
||||
.read_u64 = cpu_cfs_period_read_u64,
|
||||
.write_u64 = cpu_cfs_period_write_u64,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
static struct cftype mem_cgroup_legacy_files[] = {
|
||||
{
|
||||
.name = "usage_in_bytes",
|
||||
.private = MEMFILE_PRIVATE(_MEM, RES_USAGE),
|
||||
.read_u64 = mem_cgroup_read_u64,
|
||||
},
|
||||
{
|
||||
.name = "max_usage_in_bytes",
|
||||
.private = MEMFILE_PRIVATE(_MEM, RES_MAX_USAGE),
|
||||
.write = mem_cgroup_reset,
|
||||
.read_u64 = mem_cgroup_read_u64,
|
||||
},
|
||||
{
|
||||
.name = "limit_in_bytes",
|
||||
.private = MEMFILE_PRIVATE(_MEM, RES_LIMIT),
|
||||
.write = mem_cgroup_write,
|
||||
.read_u64 = mem_cgroup_read_u64,
|
||||
},
|
||||
{
|
||||
.name = "soft_limit_in_bytes",
|
||||
.private = MEMFILE_PRIVATE(_MEM, RES_SOFT_LIMIT),
|
||||
.write = mem_cgroup_write,
|
||||
.read_u64 = mem_cgroup_read_u64,
|
||||
},
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果设置的是cpu.shares,则调用cpu_shares_write_u64。在这里面,task_group的shares变量更新了,并且更新了CPU队列上的调度实体。
|
||||
|
||||
```
|
||||
int sched_group_set_shares(struct task_group *tg, unsigned long shares)
|
||||
{
|
||||
int i;
|
||||
|
||||
shares = clamp(shares, scale_load(MIN_SHARES), scale_load(MAX_SHARES));
|
||||
|
||||
tg->shares = shares;
|
||||
for_each_possible_cpu(i) {
|
||||
struct rq *rq = cpu_rq(i);
|
||||
struct sched_entity *se = tg->se[i];
|
||||
struct rq_flags rf;
|
||||
|
||||
update_rq_clock(rq);
|
||||
for_each_sched_entity(se) {
|
||||
update_load_avg(se, UPDATE_TG);
|
||||
update_cfs_shares(se);
|
||||
}
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是这个时候别忘了,我们还没有将CPU的文件夹下面的tasks文件写入进程号呢。写入一个进程号到tasks文件里面,按照cgroup1_base_files里面的定义,我们应该调用cgroup_tasks_write。
|
||||
|
||||
接下来的调用链为:cgroup_tasks_write->__cgroup_procs_write->cgroup_attach_task-> cgroup_migrate->cgroup_migrate_execute。将这个进程和一个cgroup关联起来,也即将这个进程迁移到这个cgroup下面。
|
||||
|
||||
```
|
||||
static int cgroup_migrate_execute(struct cgroup_mgctx *mgctx)
|
||||
{
|
||||
struct cgroup_taskset *tset = &mgctx->tset;
|
||||
struct cgroup_subsys *ss;
|
||||
struct task_struct *task, *tmp_task;
|
||||
struct css_set *cset, *tmp_cset;
|
||||
......
|
||||
if (tset->nr_tasks) {
|
||||
do_each_subsys_mask(ss, ssid, mgctx->ss_mask) {
|
||||
if (ss->attach) {
|
||||
tset->ssid = ssid;
|
||||
ss->attach(tset);
|
||||
}
|
||||
} while_each_subsys_mask();
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
每一个cgroup子系统会调用相应的attach函数。而CPU调用的是cpu_cgroup_attach-> sched_move_task-> sched_change_group。
|
||||
|
||||
```
|
||||
static void sched_change_group(struct task_struct *tsk, int type)
|
||||
{
|
||||
struct task_group *tg;
|
||||
|
||||
tg = container_of(task_css_check(tsk, cpu_cgrp_id, true),
|
||||
struct task_group, css);
|
||||
tg = autogroup_task_group(tsk, tg);
|
||||
tsk->sched_task_group = tg;
|
||||
|
||||
#ifdef CONFIG_FAIR_GROUP_SCHED
|
||||
if (tsk->sched_class->task_change_group)
|
||||
tsk->sched_class->task_change_group(tsk, type);
|
||||
else
|
||||
#endif
|
||||
set_task_rq(tsk, task_cpu(tsk));
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在sched_change_group中设置这个进程以这个task_group的方式参与调度,从而使得上面的cpu.shares起作用。
|
||||
|
||||
对于内存来讲,写入内存的限制使用函数mem_cgroup_write->mem_cgroup_resize_limit来设置struct mem_cgroup的memory.limit成员。
|
||||
|
||||
在进程执行过程中,申请内存的时候,我们会调用handle_pte_fault->do_anonymous_page()->mem_cgroup_try_charge()。
|
||||
|
||||
```
|
||||
int mem_cgroup_try_charge(struct page *page, struct mm_struct *mm,
|
||||
gfp_t gfp_mask, struct mem_cgroup **memcgp,
|
||||
bool compound)
|
||||
{
|
||||
struct mem_cgroup *memcg = NULL;
|
||||
......
|
||||
if (!memcg)
|
||||
memcg = get_mem_cgroup_from_mm(mm);
|
||||
|
||||
ret = try_charge(memcg, gfp_mask, nr_pages);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在mem_cgroup_try_charge中,先是调用get_mem_cgroup_from_mm获得这个进程对应的mem_cgroup结构,然后在try_charge中,根据mem_cgroup的限制,看是否可以申请分配内存。
|
||||
|
||||
至此,cgroup对于内存的限制才真正起作用。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
内核中cgroup的工作机制,我们在这里总结一下。
|
||||
|
||||
<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,请你研究一下。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习和进步。
|
||||
112
极客时间专栏/趣谈Linux操作系统/核心原理篇:第十部分 容器化/59 | 数据中心操作系统:上市敲钟.md
Normal file
112
极客时间专栏/趣谈Linux操作系统/核心原理篇:第十部分 容器化/59 | 数据中心操作系统:上市敲钟.md
Normal 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 Interface(CSI)接口,这是一个标准接口,不同的存储可以实现这个接口来对接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 Interface(CNI,容器网络接口)。无论你用哪种方式实现网络模型,只要对接这个统一的接口,Kubernetes就可以管理容器的网络。
|
||||
|
||||
至此,Kubernetes作为数据中心的操作系统,内核的问题解决了。
|
||||
|
||||
接下来是用户态的工作模式问题了。我们能不能像操作一台服务器那样操作数据中心呢?
|
||||
|
||||
使用操作系统,需要安装一些软件,于是,我们需要yum之类的包管理系统,使得软件的使用者和软件的编译者分隔开来,软件的编译者需要知道这个软件需要安装哪些包,包之间的依赖关系是什么,软件安装到什么地方,而软件的使用者仅仅需要yum install就可以了。Kubernetes就有这样一套包管理软件Helm,你可以用它来很方便地安装、升级、扩容一些数据中心里面的常用软件,例如数据库、缓存、消息队列。
|
||||
|
||||
使用操作系统,运行一个进程是最常见的需求。第一种进程是**交互式命令行**,运行起来就是执行一个任务,结束了马上返回结果。在Kubernetes里面有对应的概念叫作Job,Job 负责批量处理短暂的一次性任务 (Short Lived One-off Tasks),即仅执行一次的任务,它保证批处理任务的一个或多个 Pod 成功结束。
|
||||
|
||||
第二种进程是**nohup(长期运行)**的进程。在Kubernetes里对应的概念是Deployment,使用 Deployment 来创建 ReplicaSet。ReplicaSet 在后台创建 Pod。也即,Doployment里面会声明我希望某个进程以N的Pod副本的形式运行,并且长期运行,一旦副本变少就会自动添加。
|
||||
|
||||
第三种进程是**系统服务**。在Kubernetes里面对应的概念是DaemonSet,它保证在每个节点上都运行一个容器副本,常用来部署一些集群的日志、监控或者其他系统管理应用。
|
||||
|
||||
第四种进程是**周期性进程**,也即Crontab,常常用来设置一些周期性的任务。在Kubernetes里面对应的概念是CronJob(定时任务),就类似于 Linux 系统的 Crontab,在指定的时间周期运行指定的任务。
|
||||
|
||||
使用操作系统,我们还需使用文件系统,或者使用网络发送数据。虽然在Kubernetes里面有CSI和CNI来对接存储和网络,但是在用户态,不能让用户意识到后面具体设备,而是应该有抽象的概念。
|
||||
|
||||
对于存储来讲,Kubernetes有Volume的概念。Kubernetes Volume 的生命周期与 Pod 绑定在一起,容器挂掉后,Kubelet 再次重启容器时,Volume 的数据依然还在,而 Pod 删除时,Volume 才会真的被清理。数据是否丢失取决于具体的 Volume 类型。Volume的概念是对具体存储设备的抽象,就像当我们使用ext4文件系统时,不用管它是基于什么硬盘一样。
|
||||
|
||||
对于网络来讲,Kubernetes有自己的DNS,有Service的概念。Kubernetes Service是一个 Pod 的逻辑分组,这一组 Pod 能够被 Service 访问。每一个Service都一个名字,Kubernetes会将Service的名字作为域名解析成为一个虚拟的Cluster IP,然后通过负载均衡,转发到后端的Pod。虽然Pod可能漂移,IP会变,但是Service会一直不变。
|
||||
|
||||
对应到Linux操作系统的iptables,Kubernetes 在有个概念叫Network Policy,Network Policy 提供了基于策略的网络控制,用于隔离应用并减少攻击面。它使用标签选择器模拟传统的分段网络,并通过策略控制它们之间的流量以及来自外部的流量。
|
||||
|
||||
看,是不是很神奇?有了Kubernetes,我们就能像管理一台Linux服务器那样,去管理数据中心了。
|
||||
|
||||
如果想深入了解Kubernetes这个数据中心的操作系统,你可以订阅极客时间的专栏“[深入剖析Kubernetes](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="">
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
int max_length = 128;
|
||||
|
||||
char * generate(int length){
|
||||
int i;
|
||||
char * buffer = (char*) malloc (length+1);
|
||||
if (buffer == NULL)
|
||||
return NULL;
|
||||
for (i=0; i<length; i++){
|
||||
buffer[i]=rand()%26+'a';
|
||||
}
|
||||
buffer[length]='\0';
|
||||
return buffer;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[])
|
||||
{
|
||||
int num;
|
||||
char * buffer;
|
||||
|
||||
printf ("Input the string length : ");
|
||||
scanf ("%d", &num);
|
||||
|
||||
if(num > max_length){
|
||||
num = max_length;
|
||||
}
|
||||
|
||||
buffer = generate(num);
|
||||
|
||||
printf ("Random string is: %s\n",buffer);
|
||||
free (buffer);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个程序比较简单,就是根据用户输入的整数来生成字符串,最长是128。由于字符串的长度不是固定的,因而不能提前知道,需要动态地分配内存,使用malloc函数。当然用完了需要释放内存,这就要使用free函数。
|
||||
|
||||
我们来总结一下,这个简单的程序在使用内存时的几种方式:
|
||||
|
||||
<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="">
|
||||
@@ -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个段,用0~3来编号。每个段在段表中有一个项,在物理空间中,段的排列如下图的右边所示。
|
||||
|
||||
<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) & 0xffff) | (((base) & 0xffff) << 16), \
|
||||
.b = (((base) & 0xff0000) >> 16) | (((flags) & 0xf0ff) << 8) | \
|
||||
((limit) & 0xf0000) | ((base) & 0xff000000), \
|
||||
} } }
|
||||
|
||||
```
|
||||
|
||||
一个段表项由段基地址base、段界限limit,还有一些标识符组成。
|
||||
|
||||
```
|
||||
DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = {
|
||||
#ifdef CONFIG_X86_64
|
||||
[GDT_ENTRY_KERNEL32_CS] = GDT_ENTRY_INIT(0xc09b, 0, 0xfffff),
|
||||
[GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xa09b, 0, 0xfffff),
|
||||
[GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc093, 0, 0xfffff),
|
||||
[GDT_ENTRY_DEFAULT_USER32_CS] = GDT_ENTRY_INIT(0xc0fb, 0, 0xfffff),
|
||||
[GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f3, 0, 0xfffff),
|
||||
[GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xa0fb, 0, 0xfffff),
|
||||
#else
|
||||
[GDT_ENTRY_KERNEL_CS] = GDT_ENTRY_INIT(0xc09a, 0, 0xfffff),
|
||||
[GDT_ENTRY_KERNEL_DS] = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
|
||||
[GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xc0fa, 0, 0xfffff),
|
||||
[GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f2, 0, 0xfffff),
|
||||
......
|
||||
#endif
|
||||
} };
|
||||
EXPORT_PER_CPU_SYMBOL_GPL(gdt_page);
|
||||
|
||||
```
|
||||
|
||||
这里面对于64位的和32位的,都定义了内核代码段、内核数据段、用户代码段和用户数据段。
|
||||
|
||||
另外,还会定义下面四个段选择子,指向上面的段描述符表项。这四个段选择子看着是不是有点眼熟?咱们讲内核初始化的时候,启动第一个用户态的进程,就是将这四个值赋值给段寄存器。
|
||||
|
||||
```
|
||||
#define __KERNEL_CS (GDT_ENTRY_KERNEL_CS*8)
|
||||
#define __KERNEL_DS (GDT_ENTRY_KERNEL_DS*8)
|
||||
#define __USER_DS (GDT_ENTRY_DEFAULT_USER_DS*8 + 3)
|
||||
#define __USER_CS (GDT_ENTRY_DEFAULT_USER_CS*8 + 3)
|
||||
|
||||
```
|
||||
|
||||
通过分析,我们发现,所有的段的起始地址都是一样的,都是0。这算哪门子分段嘛!所以,在Linux操作系统中,并没有使用到全部的分段功能。那分段是不是完全没有用处呢?分段可以做权限审核,例如用户态DPL是3,内核态DPL是0。当用户态试图访问内核态的时候,会因为权限不足而报错。
|
||||
|
||||
其实Linux倾向于另外一种从虚拟地址到物理地址的转换方式,称为**分页**(Paging)。
|
||||
|
||||
对于物理内存,操作系统把它分成一块一块大小相同的页,这样更方便管理,例如有的内存页面长时间不用了,可以暂时写到硬盘上,称为**换出**。一旦需要的时候,再加载进来,叫做**换入**。这样可以扩大可用物理内存的大小,提高物理内存的利用率。
|
||||
|
||||
这个换入和换出都是以页为单位的。页面的大小一般为4KB。为了能够定位和访问每个页,需要有个页表,保存每个页的起始地址,再加上在页内的偏移量,组成线性地址,就能对于内存中的每个位置进行访问了。
|
||||
|
||||
<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分成1K(1024)个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位的系统,两级肯定不够了,就变成了四级目录,分别是全局页目录项PGD(Page Global Directory)、上层页目录项PUD(Page Upper Directory)、中间页目录项PMD(Page Middle Directory)和页表项PTE(Page 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="">
|
||||
445
极客时间专栏/趣谈Linux操作系统/核心原理篇:第四部分 内存管理/22 | 进程空间管理:项目组还可以自行布置会议室.md
Normal file
445
极客时间专栏/趣谈Linux操作系统/核心原理篇:第四部分 内存管理/22 | 进程空间管理:项目组还可以自行布置会议室.md
Normal 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 << 47) - PAGE_SIZE)
|
||||
#define TASK_SIZE (test_thread_flag(TIF_ADDR32) ? \
|
||||
IA32_PAGE_OFFSET : TASK_SIZE_MAX)
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
当执行一个新的进程的时候,会做以下的设置:
|
||||
|
||||
```
|
||||
current->mm->task_size = TASK_SIZE;
|
||||
|
||||
```
|
||||
|
||||
对于32位系统,最大能够寻址2^32=4G,其中用户态虚拟地址空间是3G,内核态是1G。
|
||||
|
||||
对于64位系统,虚拟地址只使用了48位。就像代码里面写的一样,1左移了47位,就相当于48位地址空间一半的位置,0x0000800000000000,然后减去一个页,就是0x00007FFFFFFFF000,共128T。同样,内核空间也是128T。内核空间和用户空间之间隔着很大的空隙,以此来进行隔离。
|
||||
|
||||
<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 & ~VM_SHARED & ~VM_STACK */
|
||||
unsigned long exec_vm; /* VM_EXEC & ~VM_WRITE & ~VM_STACK */
|
||||
unsigned long stack_vm; /* VM_STACK */
|
||||
unsigned long start_code, end_code, start_data, end_data;
|
||||
unsigned long start_brk, brk, start_stack;
|
||||
unsigned long arg_start, arg_end, env_start, env_end;
|
||||
|
||||
```
|
||||
|
||||
其中,total_vm是总共映射的页的数目。我们知道,这么大的虚拟地址空间,不可能都有真实内存对应,所以这里是映射的数目。当内存吃紧的时候,有些页可以换出到硬盘上,有的页因为比较重要,不能换出。locked_vm就是被锁定不能换出,pinned_vm是不能换出,也不能移动。
|
||||
|
||||
data_vm是存放数据的页的数目,exec_vm是存放可执行文件的页的数目,stack_vm是栈所占的页的数目。
|
||||
|
||||
start_code和end_code表示可执行代码的开始和结束位置,start_data和end_data表示已初始化数据的开始位置和结束位置。
|
||||
|
||||
start_brk是堆的起始位置,brk是堆当前的结束位置。前面咱们讲过malloc申请一小块内存的话,就是通过改变brk位置实现的。
|
||||
|
||||
start_stack是栈的起始位置,栈的结束位置在寄存器的栈顶指针中。
|
||||
|
||||
arg_start和arg_end是参数列表的位置, env_start和env_end是环境变量的位置。它们都位于栈中最高地址的地方。
|
||||
|
||||
mmap_base表示虚拟地址空间中用于内存映射的起始地址。一般情况下,这个空间是从高地址到低地址增长的。前面咱们讲malloc申请一大块内存的时候,就是通过mmap在这里映射一块区域到物理内存。咱们加载动态链接库so文件,也是在这个区域里面,映射一块区域到so文件。
|
||||
|
||||
这下所有用户态的区域的位置基本上都描述清楚了。整个布局就像下面这张图这样。虽然32位和64位的空间相差很大,但是区域的类别和布局是相似的。
|
||||
|
||||
<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 &
|
||||
* page_table_lock */
|
||||
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
|
||||
/* Function pointers to deal with this struct. */
|
||||
const struct vm_operations_struct *vm_ops;
|
||||
struct file * vm_file; /* File we map to (can be NULL). */
|
||||
void * vm_private_data; /* was vm_pte (shared mem) */
|
||||
} __randomize_layout;
|
||||
|
||||
```
|
||||
|
||||
vm_start和vm_end指定了该区域在用户空间中的起始和结束地址。vm_next和vm_prev将这个区域串在链表上。vm_rb将这个区域放在红黑树上。vm_ops里面是对这个内存区域可以做的操作的定义。
|
||||
|
||||
虚拟内存区域可以映射到物理内存,也可以映射到文件,映射到物理内存的时候称为匿名映射,anon_vma中,anoy就是anonymous,匿名的意思,映射到文件就需要有vm_file指定被映射的文件。
|
||||
|
||||
那这些vm_area_struct是如何和上面的内存区域关联的呢?
|
||||
|
||||
这个事情是在load_elf_binary里面实现的。没错,就是它。加载内核的是它,启动第一个用户态进程init的是它,fork完了以后,调用exec运行一个二进制程序的也是它。
|
||||
|
||||
当exec运行一个二进制程序的时候,除了解析ELF的格式之外,另外一个重要的事情就是建立内存映射。
|
||||
|
||||
```
|
||||
static int load_elf_binary(struct linux_binprm *bprm)
|
||||
{
|
||||
......
|
||||
setup_new_exec(bprm);
|
||||
......
|
||||
retval = setup_arg_pages(bprm, randomize_stack_top(STACK_TOP),
|
||||
executable_stack);
|
||||
......
|
||||
error = elf_map(bprm->file, load_bias + vaddr, elf_ppnt,
|
||||
elf_prot, elf_flags, total_size);
|
||||
......
|
||||
retval = set_brk(elf_bss, elf_brk, bss_prot);
|
||||
......
|
||||
elf_entry = load_elf_interp(&loc->interp_elf_ex,
|
||||
interpreter,
|
||||
&interp_map_addr,
|
||||
load_bias, interp_elf_phdata);
|
||||
......
|
||||
current->mm->end_code = end_code;
|
||||
current->mm->start_code = start_code;
|
||||
current->mm->start_data = start_data;
|
||||
current->mm->end_data = end_data;
|
||||
current->mm->start_stack = bprm->p;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
load_elf_binary会完成以下的事情:
|
||||
|
||||
<li>
|
||||
调用setup_new_exec,设置内存映射区mmap_base;
|
||||
</li>
|
||||
<li>
|
||||
调用setup_arg_pages,设置栈的vm_area_struct,这里面设置了mm->arg_start是指向栈底的,current->mm->start_stack就是栈底;
|
||||
</li>
|
||||
<li>
|
||||
elf_map会将ELF文件中的代码部分映射到内存中来;
|
||||
</li>
|
||||
<li>
|
||||
set_brk设置了堆的vm_area_struct,这里面设置了current->mm->start_brk = current->mm->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->mm;
|
||||
struct vm_area_struct *next;
|
||||
......
|
||||
newbrk = PAGE_ALIGN(brk);
|
||||
oldbrk = PAGE_ALIGN(mm->brk);
|
||||
if (oldbrk == newbrk)
|
||||
goto set_brk;
|
||||
|
||||
|
||||
/* Always allow shrinking brk. */
|
||||
if (brk <= mm->brk) {
|
||||
if (!do_munmap(mm, newbrk, oldbrk-newbrk, &uf))
|
||||
goto set_brk;
|
||||
goto out;
|
||||
}
|
||||
|
||||
|
||||
/* Check against existing mmap mappings. */
|
||||
next = find_vma(mm, oldbrk);
|
||||
if (next && newbrk + PAGE_SIZE > vm_start_gap(next))
|
||||
goto out;
|
||||
|
||||
|
||||
/* Ok, looks good - let it rip. */
|
||||
if (do_brk(oldbrk, newbrk-oldbrk, &uf) < 0)
|
||||
goto out;
|
||||
|
||||
|
||||
set_brk:
|
||||
mm->brk = brk;
|
||||
......
|
||||
return brk;
|
||||
out:
|
||||
retval = mm->brk;
|
||||
return retval
|
||||
|
||||
```
|
||||
|
||||
前面我们讲过了,堆是从低地址向高地址增长的,sys_brk函数的参数brk是新的堆顶位置,而当前的mm->brk是原来堆顶的位置。
|
||||
|
||||
首先要做的第一个事情,将原来的堆顶和现在的堆顶,都按照页对齐地址,然后比较大小。如果两者相同,说明这次增加的堆的量很小,还在一个页里面,不需要另行分配页,直接跳到set_brk那里,设置mm->brk为新的brk就可以了。
|
||||
|
||||
如果发现新旧堆顶不在一个页里面,麻烦了,这下要跨页了。如果发现新堆顶小于旧堆顶,这说明不是新分配内存了,而是释放内存了,释放的还不小,至少释放了一页,于是调用do_munmap将这一页的内存映射去掉。
|
||||
|
||||
如果堆将要扩大,就要调用find_vma。如果打开这个函数,看到的是对红黑树的查找,找到的是原堆顶所在的vm_area_struct的下一个vm_area_struct,看当前的堆顶和下一个vm_area_struct之间还能不能分配一个完整的页。如果不能,没办法只好直接退出返回,内存空间都被占满了。
|
||||
|
||||
如果还有空间,就调用do_brk进一步分配堆空间,从旧堆顶开始,分配计算出的新旧堆顶之间的页数。
|
||||
|
||||
```
|
||||
static int do_brk(unsigned long addr, unsigned long len, struct list_head *uf)
|
||||
{
|
||||
return do_brk_flags(addr, len, 0, uf);
|
||||
}
|
||||
|
||||
|
||||
static int do_brk_flags(unsigned long addr, unsigned long request, unsigned long flags, struct list_head *uf)
|
||||
{
|
||||
struct mm_struct *mm = current->mm;
|
||||
struct vm_area_struct *vma, *prev;
|
||||
unsigned long len;
|
||||
struct rb_node **rb_link, *rb_parent;
|
||||
pgoff_t pgoff = addr >> PAGE_SHIFT;
|
||||
int error;
|
||||
|
||||
|
||||
len = PAGE_ALIGN(request);
|
||||
......
|
||||
find_vma_links(mm, addr, addr + len, &prev, &rb_link,
|
||||
&rb_parent);
|
||||
......
|
||||
vma = vma_merge(mm, prev, addr, addr + len, flags,
|
||||
NULL, NULL, pgoff, NULL, NULL_VM_UFFD_CTX);
|
||||
if (vma)
|
||||
goto out;
|
||||
......
|
||||
vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
|
||||
INIT_LIST_HEAD(&vma->anon_vma_chain);
|
||||
vma->vm_mm = mm;
|
||||
vma->vm_start = addr;
|
||||
vma->vm_end = addr + len;
|
||||
vma->vm_pgoff = pgoff;
|
||||
vma->vm_flags = flags;
|
||||
vma->vm_page_prot = vm_get_page_prot(flags);
|
||||
vma_link(mm, vma, prev, rb_link, rb_parent);
|
||||
out:
|
||||
perf_event_mmap(vma);
|
||||
mm->total_vm += len >> PAGE_SHIFT;
|
||||
mm->data_vm += len >> PAGE_SHIFT;
|
||||
if (flags & VM_LOCKED)
|
||||
mm->locked_vm += (len >> PAGE_SHIFT);
|
||||
vma->vm_flags |= VM_SOFTDIRTY;
|
||||
return 0;
|
||||
|
||||
```
|
||||
|
||||
在do_brk中,调用find_vma_links找到将来的vm_area_struct节点在红黑树的位置,找到它的父节点、前序节点。接下来调用vma_merge,看这个新节点是否能够和现有树中的节点合并。如果地址是连着的,能够合并,则不用创建新的vm_area_struct了,直接跳到out,更新统计值即可;如果不能合并,则创建新的vm_area_struct,既加到anon_vma_chain链表中,也加到红黑树中。
|
||||
|
||||
## 内核态的布局
|
||||
|
||||
用户态虚拟空间分析完毕,接下来我们分析内核态虚拟空间。
|
||||
|
||||
内核态的虚拟空间和某一个进程没有关系,所有进程通过系统调用进入到内核之后,看到的虚拟地址空间都是一样的。
|
||||
|
||||
这里强调一下,千万别以为到了内核里面,咱们就会直接使用物理内存地址了,想当然地认为下面讨论的都是物理内存地址,不是的,这里讨论的还是虚拟内存地址,但是由于内核总是涉及管理物理内存,因而总是隐隐约约发生关系,所以这里必须思路清晰,分清楚物理内存地址和虚拟内存地址。
|
||||
|
||||
在内核态,32位和64位的布局差别比较大,主要是因为32位内核态空间太小了。
|
||||
|
||||
我们来看32位的内核态的布局。
|
||||
|
||||
<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_START(0xffffc90000000000)开始到VMALLOC_END(0xffffe90000000000)的32T的空间是给vmalloc的。
|
||||
|
||||
从VMEMMAP_START(0xffffea0000000000)开始的1T空间用于存放物理页面的描述结构struct page的。
|
||||
|
||||
从__START_KERNEL_map(0xffffffff80000000)开始的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="">
|
||||
@@ -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>
|
||||
每一个节点都有自己的ID:node_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是指可用于作DMA(Direct Memory Access,直接内存存取)的内存。DMA是这样一种机制:要把外设的数据读入内存或把内存的数据传送到外设,原来都要通过CPU控制完成,但是这会占用CPU,影响CPU处理其他事情,所以有了DMA模式。CPU只需向DMA控制器下达指令,让DMA控制器来处理数据的传送,数据传送完毕再把信息反馈给CPU,这样就可以解放CPU。
|
||||
|
||||
对于64位系统,有两个DMA区域。除了上面说的ZONE_DMA,还有ZONE_DMA32。在这里你大概理解DMA的原理就可以,不必纠结,我们后面会讲DMA的机制。
|
||||
|
||||
ZONE_NORMAL是直接映射区,就是上一节讲的,从物理内存到虚拟内存的内核区域,通过加上一个常量直接映射。
|
||||
|
||||
ZONE_HIGHMEM是高端内存区,就是上一节讲的,对于32位系统来说超过896M的地方,对于64位没必要有的一段区域。
|
||||
|
||||
ZONE_MOVABLE是可移动区域,通过将物理内存划分为可移动分配区域和不可移动分配区域来避免内存碎片。
|
||||
|
||||
这里你需要注意一下,我们刚才对于区域的划分,都是针对物理内存的。
|
||||
|
||||
nr_zones表示当前节点的区域的数量。node_zonelists是备用节点和它的内存区域的情况。前面讲NUMA的时候,我们讲了CPU访问内存,本节点速度最快,但是如果本节点内存不够怎么办,还是需要去其他节点进行分配。毕竟,就算在备用节点里面选择,慢了点也比没有强。
|
||||
|
||||
既然整个内存被分成了多个节点,那pglist_data应该放在一个数组里面。每个节点一项,就像下面代码里面一样:
|
||||
|
||||
```
|
||||
struct pglist_data *node_data[MAX_NUMNODES] __read_mostly;
|
||||
|
||||
```
|
||||
|
||||
### 区域
|
||||
|
||||
到这里,我们把内存分成了节点,把节点分成了区域。接下来我们来看,一个区域里面是如何组织的。
|
||||
|
||||
表示区域的数据结构zone的定义如下:
|
||||
|
||||
```
|
||||
struct zone {
|
||||
......
|
||||
struct pglist_data *zone_pgdat;
|
||||
struct per_cpu_pageset __percpu *pageset;
|
||||
|
||||
|
||||
unsigned long zone_start_pfn;
|
||||
|
||||
|
||||
/*
|
||||
* spanned_pages is the total pages spanned by the zone, including
|
||||
* holes, which is calculated as:
|
||||
* spanned_pages = zone_end_pfn - zone_start_pfn;
|
||||
*
|
||||
* present_pages is physical pages existing within the zone, which
|
||||
* is calculated as:
|
||||
* present_pages = spanned_pages - absent_pages(pages in holes);
|
||||
*
|
||||
* managed_pages is present pages managed by the buddy system, which
|
||||
* is calculated as (reserved_pages includes pages allocated by the
|
||||
* bootmem allocator):
|
||||
* managed_pages = present_pages - reserved_pages;
|
||||
*
|
||||
*/
|
||||
unsigned long managed_pages;
|
||||
unsigned long spanned_pages;
|
||||
unsigned long present_pages;
|
||||
|
||||
|
||||
const char *name;
|
||||
......
|
||||
/* free areas of different sizes */
|
||||
struct free_area free_area[MAX_ORDER];
|
||||
|
||||
|
||||
/* zone flags, see below */
|
||||
unsigned long flags;
|
||||
|
||||
|
||||
/* Primarily protects free_area */
|
||||
spinlock_t lock;
|
||||
......
|
||||
} ____cacheline_internodealigned_in_
|
||||
|
||||
```
|
||||
|
||||
在一个zone里面,zone_start_pfn表示属于这个zone的第一个页。
|
||||
|
||||
如果我们仔细看代码的注释,可以看到,spanned_pages = zone_end_pfn - zone_start_pfn,也即spanned_pages指的是不管中间有没有物理内存空洞,反正就是最后的页号减去起始的页号。
|
||||
|
||||
present_pages = spanned_pages - absent_pages(pages in holes),也即present_pages是这个zone在物理内存中真实存在的所有page数目。
|
||||
|
||||
managed_pages = present_pages - reserved_pages,也即managed_pages是这个zone被伙伴系统管理的所有的page数目,伙伴系统的工作机制我们后面会讲。
|
||||
|
||||
per_cpu_pageset用于区分冷热页。什么叫冷热页呢?咱们讲x86体系结构的时候讲过,为了让CPU快速访问段描述符,在CPU里面有段描述符缓存。CPU访问这个缓存的速度比内存快得多。同样对于页面来讲,也是这样的。如果一个页被加载到CPU高速缓存里面,这就是一个热页(Hot Page),CPU读起来速度会快很多,如果没有就是冷页(Cold Page)。由于每个CPU都有自己的高速缓存,因而per_cpu_pageset也是每个CPU一个。
|
||||
|
||||
### 页
|
||||
|
||||
了解了区域zone,接下来我们就到了组成物理内存的基本单位,页的数据结构struct page。这是一个特别复杂的结构,里面有很多的union,union结构是在C语言中被用于同一块内存根据情况保存不同类型数据的一种方式。这里之所以用了union,是因为一个物理页面使用模式有多种。
|
||||
|
||||
第一种模式,要用就用一整页。这一整页的内存,或者直接和虚拟地址空间建立映射关系,我们把这种称为匿名页(Anonymous Page)。或者用于关联一个文件,然后再和虚拟地址空间建立映射关系,这样的文件,我们称为内存映射文件(Memory-mapped File)。
|
||||
|
||||
如果某一页是这种使用模式,则会使用union中的以下变量:
|
||||
|
||||
<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 = &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->zonelist, ac->high_zoneidx, ac->nodemask) {
|
||||
struct page *page;
|
||||
......
|
||||
page = rmqueue(ac->preferred_zoneref->zone, zone, order,
|
||||
gfp_mask, alloc_flags, ac->migratetype);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
每一个zone,都有伙伴系统维护的各种大小的队列,就像上面伙伴系统原理里讲的那样。这里调用rmqueue就很好理解了,就是找到合适大小的那个队列,把页面取下来。
|
||||
|
||||
接下来的调用链是rmqueue->__rmqueue->__rmqueue_smallest。在这里,我们能清楚看到伙伴系统的逻辑。
|
||||
|
||||
```
|
||||
static inline
|
||||
struct page *__rmqueue_smallest(struct zone *zone, unsigned int order,
|
||||
int migratetype)
|
||||
{
|
||||
unsigned int current_order;
|
||||
struct free_area *area;
|
||||
struct page *page;
|
||||
|
||||
|
||||
/* Find a page of the appropriate size in the preferred list */
|
||||
for (current_order = order; current_order < MAX_ORDER; ++current_order) {
|
||||
area = &(zone->free_area[current_order]);
|
||||
page = list_first_entry_or_null(&area->free_list[migratetype],
|
||||
struct page, lru);
|
||||
if (!page)
|
||||
continue;
|
||||
list_del(&page->lru);
|
||||
rmv_page_order(page);
|
||||
area->nr_free--;
|
||||
expand(zone, page, order, current_order, area, migratetype);
|
||||
set_pcppage_migratetype(page, migratetype);
|
||||
return page;
|
||||
}
|
||||
|
||||
|
||||
return NULL;
|
||||
|
||||
```
|
||||
|
||||
从当前的order,也即指数开始,在伙伴系统的free_area找2^order大小的页块。如果链表的第一个不为空,就找到了;如果为空,就到更大的order的页块链表里面去找。找到以后,除了将页块从链表中取下来,我们还要把多余部分放到其他页块链表里面。expand就是干这个事情的。area–就是伙伴系统那个表里面的前一项,前一项里面的页块大小是当前项的页块大小除以2,size右移一位也就是除以2,list_add就是加到链表上,nr_free++就是计数加1。
|
||||
|
||||
```
|
||||
static inline void expand(struct zone *zone, struct page *page,
|
||||
int low, int high, struct free_area *area,
|
||||
int migratetype)
|
||||
{
|
||||
unsigned long size = 1 << high;
|
||||
|
||||
|
||||
while (high > low) {
|
||||
area--;
|
||||
high--;
|
||||
size >>= 1;
|
||||
......
|
||||
list_add(&page[size].lru, &area->free_list[migratetype]);
|
||||
area->nr_free++;
|
||||
set_page_order(&page[size], high);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
对于物理内存的管理的讲解,到这里要告一段落了。这一节我们主要讲了物理内存的组织形式,就像下面图中展示的一样。
|
||||
|
||||
如果有多个CPU,那就有多个节点。每个节点用struct pglist_data表示,放在一个数组里面。
|
||||
|
||||
每个节点分为多个区域,每个区域用struct zone表示,也放在一个数组里面。
|
||||
|
||||
每个区域分为多个页。为了方便分配,空闲页放在struct free_area里面,使用伙伴系统进行管理和分配,每一页用struct page表示。
|
||||
|
||||
<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="">
|
||||
@@ -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("task_struct",
|
||||
arch_task_struct_size, align,
|
||||
SLAB_PANIC|SLAB_NOTRACK|SLAB_ACCOUNT, NULL);
|
||||
|
||||
static inline struct task_struct *alloc_task_struct_node(int node)
|
||||
{
|
||||
return kmem_cache_alloc_node(task_struct_cachep, GFP_KERNEL, node);
|
||||
}
|
||||
|
||||
static inline void free_task_struct(struct task_struct *tsk)
|
||||
{
|
||||
kmem_cache_free(task_struct_cachep, tsk);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在系统初始化的时候,task_struct_cachep会被kmem_cache_create函数创建。这个函数也比较容易看懂,专门用于分配task_struct对象的缓存。这个缓存区的名字就叫task_struct。缓存区中每一块的大小正好等于task_struct的大小,也即arch_task_struct_size。
|
||||
|
||||
有了这个缓存区,每次创建task_struct的时候,我们不用到内存里面去分配,先在缓存里面看看有没有直接可用的,这就是**kmem_cache_alloc_node**的作用。
|
||||
|
||||
当一个进程结束,task_struct也不用直接被销毁,而是放回到缓存中,这就是**kmem_cache_free**的作用。这样,新进程创建的时候,我们就可以直接用现成的缓存中的task_struct了。
|
||||
|
||||
我们来仔细看看,缓存区struct kmem_cache到底是什么样子。
|
||||
|
||||
```
|
||||
struct kmem_cache {
|
||||
struct kmem_cache_cpu __percpu *cpu_slab;
|
||||
/* Used for retriving partial slabs etc */
|
||||
unsigned long flags;
|
||||
unsigned long min_partial;
|
||||
int size; /* The size of an object including meta data */
|
||||
int object_size; /* The size of an object without meta data */
|
||||
int offset; /* Free pointer offset. */
|
||||
#ifdef CONFIG_SLUB_CPU_PARTIAL
|
||||
int cpu_partial; /* Number of per cpu partial objects to keep around */
|
||||
#endif
|
||||
struct kmem_cache_order_objects oo;
|
||||
/* Allocation and freeing of slabs */
|
||||
struct kmem_cache_order_objects max;
|
||||
struct kmem_cache_order_objects min;
|
||||
gfp_t allocflags; /* gfp flags to use on each alloc */
|
||||
int refcount; /* Refcount for slab cache destroy */
|
||||
void (*ctor)(void *);
|
||||
......
|
||||
const char *name; /* Name (only for display!) */
|
||||
struct list_head list; /* List of slab caches */
|
||||
......
|
||||
struct kmem_cache_node *node[MAX_NUMNODES];
|
||||
};
|
||||
|
||||
|
||||
```
|
||||
|
||||
在struct kmem_cache里面,有个变量struct list_head list,这个结构我们已经看到过多次了。我们可以想象一下,对于操作系统来讲,要创建和管理的缓存绝对不止task_struct。难道mm_struct就不需要吗?fs_struct就不需要吗?都需要。因此,所有的缓存最后都会放在一个链表里面,也就是LIST_HEAD(slab_caches)。
|
||||
|
||||
对于缓存来讲,其实就是分配了连续几页的大内存块,然后根据缓存对象的大小,切成小内存块。
|
||||
|
||||
所以,我们这里有三个kmem_cache_order_objects类型的变量。这里面的order,就是2的order次方个页面的大内存块,objects就是能够存放的缓存对象的数量。
|
||||
|
||||
最终,我们将大内存块切分成小内存块,样子就像下面这样。
|
||||
|
||||
<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->cpu_slab->tid);
|
||||
c = raw_cpu_ptr(s->cpu_slab);
|
||||
......
|
||||
object = c->freelist;
|
||||
page = c->page;
|
||||
if (unlikely(!object || !node_match(page, node))) {
|
||||
object = __slab_alloc(s, gfpflags, node, addr, c);
|
||||
stat(s, ALLOC_SLOWPATH);
|
||||
}
|
||||
......
|
||||
return object;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
快速通道很简单,取出cpu_slab也即kmem_cache_cpu的freelist,这就是第一个空闲的项,可以直接返回了。如果没有空闲的了,则只好进入普通通道,调用__slab_alloc。
|
||||
|
||||
```
|
||||
static void *___slab_alloc(struct kmem_cache *s, gfp_t gfpflags, int node,
|
||||
unsigned long addr, struct kmem_cache_cpu *c)
|
||||
{
|
||||
void *freelist;
|
||||
struct page *page;
|
||||
......
|
||||
redo:
|
||||
......
|
||||
/* must check again c->freelist in case of cpu migration or IRQ */
|
||||
freelist = c->freelist;
|
||||
if (freelist)
|
||||
goto load_freelist;
|
||||
|
||||
|
||||
freelist = get_freelist(s, page);
|
||||
|
||||
|
||||
if (!freelist) {
|
||||
c->page = NULL;
|
||||
stat(s, DEACTIVATE_BYPASS);
|
||||
goto new_slab;
|
||||
}
|
||||
|
||||
|
||||
load_freelist:
|
||||
c->freelist = get_freepointer(s, freelist);
|
||||
c->tid = next_tid(c->tid);
|
||||
return freelist;
|
||||
|
||||
|
||||
new_slab:
|
||||
|
||||
|
||||
if (slub_percpu_partial(c)) {
|
||||
page = c->page = slub_percpu_partial(c);
|
||||
slub_set_percpu_partial(c, page);
|
||||
stat(s, CPU_PARTIAL_ALLOC);
|
||||
goto redo;
|
||||
}
|
||||
|
||||
|
||||
freelist = new_slab_objects(s, gfpflags, node, &c);
|
||||
......
|
||||
return freeli
|
||||
|
||||
|
||||
```
|
||||
|
||||
在这里,我们首先再次尝试一下kmem_cache_cpu的freelist。为什么呢?万一当前进程被中断,等回来的时候,别人已经释放了一些缓存,说不定又有空间了呢。如果找到了,就跳到load_freelist,在这里将freelist指向下一个空闲项,返回就可以了。
|
||||
|
||||
如果freelist还是没有,则跳到new_slab里面去。这里面我们先去kmem_cache_cpu的partial里面看。如果partial不是空的,那就将kmem_cache_cpu的page,也就是快速通道的那一大块内存,替换为partial里面的大块内存。然后redo,重新试下。这次应该就可以成功了。
|
||||
|
||||
如果真的还不行,那就要到new_slab_objects了。
|
||||
|
||||
```
|
||||
static inline void *new_slab_objects(struct kmem_cache *s, gfp_t flags,
|
||||
int node, struct kmem_cache_cpu **pc)
|
||||
{
|
||||
void *freelist;
|
||||
struct kmem_cache_cpu *c = *pc;
|
||||
struct page *page;
|
||||
|
||||
|
||||
freelist = get_partial(s, flags, node, c);
|
||||
|
||||
|
||||
if (freelist)
|
||||
return freelist;
|
||||
|
||||
|
||||
page = new_slab(s, flags, node);
|
||||
if (page) {
|
||||
c = raw_cpu_ptr(s->cpu_slab);
|
||||
if (c->page)
|
||||
flush_slab(s, c);
|
||||
|
||||
|
||||
freelist = page->freelist;
|
||||
page->freelist = NULL;
|
||||
|
||||
|
||||
stat(s, ALLOC_SLAB);
|
||||
c->page = page;
|
||||
*pc = c;
|
||||
} else
|
||||
freelist = NULL;
|
||||
|
||||
|
||||
return freelis
|
||||
|
||||
```
|
||||
|
||||
在这里面,get_partial会根据node id,找到相应的kmem_cache_node,然后调用get_partial_node,开始在这个节点进行分配。
|
||||
|
||||
```
|
||||
/*
|
||||
* Try to allocate a partial slab from a specific node.
|
||||
*/
|
||||
static void *get_partial_node(struct kmem_cache *s, struct kmem_cache_node *n,
|
||||
struct kmem_cache_cpu *c, gfp_t flags)
|
||||
{
|
||||
struct page *page, *page2;
|
||||
void *object = NULL;
|
||||
int available = 0;
|
||||
int objects;
|
||||
......
|
||||
list_for_each_entry_safe(page, page2, &n->partial, lru) {
|
||||
void *t;
|
||||
|
||||
|
||||
t = acquire_slab(s, n, page, object == NULL, &objects);
|
||||
if (!t)
|
||||
break;
|
||||
|
||||
|
||||
available += objects;
|
||||
if (!object) {
|
||||
c->page = page;
|
||||
stat(s, ALLOC_FROM_PARTIAL);
|
||||
object = t;
|
||||
} else {
|
||||
put_cpu_partial(s, page, 0);
|
||||
stat(s, CPU_PARTIAL_NODE);
|
||||
}
|
||||
if (!kmem_cache_has_cpu_partial(s)
|
||||
|| available > slub_cpu_partial(s) / 2)
|
||||
break;
|
||||
}
|
||||
......
|
||||
return object;
|
||||
|
||||
```
|
||||
|
||||
acquire_slab会从kmem_cache_node的partial链表中拿下一大块内存来,并且将freelist,也就是第一块空闲的缓存块,赋值给t。并且当第一轮循环的时候,将kmem_cache_cpu的page指向取下来的这一大块内存,返回的object就是这块内存里面的第一个缓存块t。如果kmem_cache_cpu也有一个partial,就会进行第二轮,再次取下一大块内存来,这次调用put_cpu_partial,放到kmem_cache_cpu的partial里面。
|
||||
|
||||
如果kmem_cache_node里面也没有空闲的内存,这就说明原来分配的页里面都放满了,就要回到new_slab_objects函数,里面new_slab函数会调用allocate_slab。
|
||||
|
||||
```
|
||||
static struct page *allocate_slab(struct kmem_cache *s, gfp_t flags, int node)
|
||||
{
|
||||
struct page *page;
|
||||
struct kmem_cache_order_objects oo = s->oo;
|
||||
gfp_t alloc_gfp;
|
||||
void *start, *p;
|
||||
int idx, order;
|
||||
bool shuffle;
|
||||
|
||||
|
||||
flags &= gfp_allowed_mask;
|
||||
......
|
||||
page = alloc_slab_page(s, alloc_gfp, node, oo);
|
||||
if (unlikely(!page)) {
|
||||
oo = s->min;
|
||||
alloc_gfp = flags;
|
||||
/*
|
||||
* Allocation may have failed due to fragmentation.
|
||||
* Try a lower order alloc if possible
|
||||
*/
|
||||
page = alloc_slab_page(s, alloc_gfp, node, oo);
|
||||
if (unlikely(!page))
|
||||
goto out;
|
||||
stat(s, ORDER_FALLBACK);
|
||||
}
|
||||
......
|
||||
return page;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里,我们看到了alloc_slab_page分配页面。分配的时候,要按kmem_cache_order_objects里面的order来。如果第一次分配不成功,说明内存已经很紧张了,那就换成min版本的kmem_cache_order_objects。
|
||||
|
||||
好了,这个复杂的层层分配机制,我们就讲到这里,你理解到这里也就够用了。
|
||||
|
||||
## 页面换出
|
||||
|
||||
另一个物理内存管理必须要处理的事情就是,页面换出。每个进程都有自己的虚拟地址空间,无论是32位还是64位,虚拟地址空间都非常大,物理内存不可能有这么多的空间放得下。所以,一般情况下,页面只有在被使用的时候,才会放在物理内存中。如果过了一段时间不被使用,即便用户进程并没有释放它,物理内存管理也有责任做一定的干预。例如,将这些物理内存中的页面换出到硬盘上去;将空出的物理内存,交给活跃的进程去使用。
|
||||
|
||||
什么情况下会触发页面换出呢?
|
||||
|
||||
可以想象,最常见的情况就是,分配内存的时候,发现没有地方了,就试图回收一下。例如,咱们解析申请一个页面的时候,会调用get_page_from_freelist,接下来的调用链为get_page_from_freelist->node_reclaim->__node_reclaim->shrink_node,通过这个调用链可以看出,页面换出也是以内存节点为单位的。
|
||||
|
||||
当然还有一种情况,就是作为内存管理系统应该主动去做的,而不能等真的出了事儿再做,这就是内核线程**kswapd**。这个内核线程,在系统初始化的时候就被创建。这样它会进入一个无限循环,直到系统停止。在这个循环中,如果内存使用没有那么紧张,那它就可以放心睡大觉;如果内存紧张了,就需要去检查一下内存,看看是否需要换出一些内存页。
|
||||
|
||||
```
|
||||
/*
|
||||
* The background pageout daemon, started as a kernel thread
|
||||
* from the init process.
|
||||
*
|
||||
* This basically trickles out pages so that we have _some_
|
||||
* free memory available even if there is no other activity
|
||||
* that frees anything up. This is needed for things like routing
|
||||
* etc, where we otherwise might have all activity going on in
|
||||
* asynchronous contexts that cannot page things out.
|
||||
*
|
||||
* If there are applications that are active memory-allocators
|
||||
* (most normal use), this basically shouldn't matter.
|
||||
*/
|
||||
static int kswapd(void *p)
|
||||
{
|
||||
unsigned int alloc_order, reclaim_order;
|
||||
unsigned int classzone_idx = MAX_NR_ZONES - 1;
|
||||
pg_data_t *pgdat = (pg_data_t*)p;
|
||||
struct task_struct *tsk = current;
|
||||
|
||||
|
||||
for ( ; ; ) {
|
||||
......
|
||||
kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order,
|
||||
classzone_idx);
|
||||
......
|
||||
reclaim_order = balance_pgdat(pgdat, alloc_order, classzone_idx);
|
||||
......
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
这里的调用链是balance_pgdat->kswapd_shrink_node->shrink_node,是以内存节点为单位的,最后也是调用shrink_node。
|
||||
|
||||
shrink_node会调用shrink_node_memcg。这里面有一个循环处理页面的列表,看这个函数的注释,其实和上面我们想表达的内存换出是一样的。
|
||||
|
||||
```
|
||||
/*
|
||||
* This is a basic per-node page freer. Used by both kswapd and direct reclaim.
|
||||
*/
|
||||
static void shrink_node_memcg(struct pglist_data *pgdat, struct mem_cgroup *memcg,
|
||||
struct scan_control *sc, unsigned long *lru_pages)
|
||||
{
|
||||
......
|
||||
unsigned long nr[NR_LRU_LISTS];
|
||||
enum lru_list lru;
|
||||
......
|
||||
while (nr[LRU_INACTIVE_ANON] || nr[LRU_ACTIVE_FILE] ||
|
||||
nr[LRU_INACTIVE_FILE]) {
|
||||
unsigned long nr_anon, nr_file, percentage;
|
||||
unsigned long nr_scanned;
|
||||
|
||||
|
||||
for_each_evictable_lru(lru) {
|
||||
if (nr[lru]) {
|
||||
nr_to_scan = min(nr[lru], SWAP_CLUSTER_MAX);
|
||||
nr[lru] -= nr_to_scan;
|
||||
|
||||
|
||||
nr_reclaimed += shrink_list(lru, nr_to_scan,
|
||||
lruvec, memcg, sc);
|
||||
}
|
||||
}
|
||||
......
|
||||
}
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
这里面有个lru列表。从下面的定义,我们可以想象,所有的页面都被挂在LRU列表中。LRU是Least Recent Use,也就是最近最少使用。也就是说,这个列表里面会按照活跃程度进行排序,这样就容易把不怎么用的内存页拿出来做处理。
|
||||
|
||||
内存页总共分两类,一类是**匿名页**,和虚拟地址空间进行关联;一类是**内存映射**,不但和虚拟地址空间关联,还和文件管理关联。
|
||||
|
||||
它们每一类都有两个列表,一个是active,一个是inactive。顾名思义,active就是比较活跃的,inactive就是不怎么活跃的。这两个里面的页会变化,过一段时间,活跃的可能变为不活跃,不活跃的可能变为活跃。如果要换出内存,那就是从不活跃的列表中找出最不活跃的,换出到硬盘上。
|
||||
|
||||
```
|
||||
enum lru_list {
|
||||
LRU_INACTIVE_ANON = LRU_BASE,
|
||||
LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
|
||||
LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
|
||||
LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
|
||||
LRU_UNEVICTABLE,
|
||||
NR_LRU_LISTS
|
||||
};
|
||||
|
||||
|
||||
#define for_each_evictable_lru(lru) for (lru = 0; lru <= LRU_ACTIVE_FILE; lru++)
|
||||
|
||||
|
||||
static unsigned long shrink_list(enum lru_list lru, unsigned long nr_to_scan,
|
||||
struct lruvec *lruvec, struct mem_cgroup *memcg,
|
||||
struct scan_control *sc)
|
||||
{
|
||||
if (is_active_lru(lru)) {
|
||||
if (inactive_list_is_low(lruvec, is_file_lru(lru),
|
||||
memcg, sc, true))
|
||||
shrink_active_list(nr_to_scan, lruvec, sc, lru);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
return shrink_inactive_list(nr_to_scan, lruvec, sc, lru);
|
||||
|
||||
```
|
||||
|
||||
从上面的代码可以看出,shrink_list会先缩减活跃页面列表,再压缩不活跃的页面列表。对于不活跃列表的缩减,shrink_inactive_list就需要对页面进行回收;对于匿名页来讲,需要分配swap,将内存页写入文件系统;对于内存映射关联了文件的,我们需要将在内存中对于文件的修改写回到文件中。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
好了,对于物理内存的管理就讲到这里了,我们来总结一下。对于物理内存来讲,从下层到上层的关系及分配模式如下:
|
||||
|
||||
<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="">
|
||||
666
极客时间专栏/趣谈Linux操作系统/核心原理篇:第四部分 内存管理/25 | 用户态内存映射:如何找到正确的会议室?.md
Normal file
666
极客时间专栏/趣谈Linux操作系统/核心原理篇:第四部分 内存管理/25 | 用户态内存映射:如何找到正确的会议室?.md
Normal 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->i_mmap interval tree.
|
||||
*/
|
||||
struct {
|
||||
struct rb_node rb;
|
||||
unsigned long rb_subtree_last;
|
||||
} shared;
|
||||
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
|
||||
* list, after a COW of one of the file pages. A MAP_SHARED vma
|
||||
* can only be in the i_mmap tree. An anonymous MAP_PRIVATE, stack
|
||||
* or brk vma (with NULL file) can only be in an anon_vma list.
|
||||
*/
|
||||
struct list_head anon_vma_chain; /* Serialized by mmap_sem &
|
||||
* page_table_lock */
|
||||
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
|
||||
|
||||
|
||||
|
||||
|
||||
/* Function pointers to deal with this struct. */
|
||||
const struct vm_operations_struct *vm_ops;
|
||||
/* Information about our backing store: */
|
||||
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
|
||||
units */
|
||||
struct file * vm_file; /* File we map to (can be NULL). */
|
||||
void * vm_private_data; /* was vm_pte (shared mem) */
|
||||
|
||||
```
|
||||
|
||||
其实内存映射不仅仅是物理内存和虚拟内存之间的映射,还包括将文件中的内容映射到虚拟内存空间。这个时候,访问内存空间就能够访问到文件里面的数据。而仅有物理内存和虚拟内存的映射,是一种特殊情况。
|
||||
|
||||
<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 >> PAGE_SHIFT);
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
SYSCALL_DEFINE6(mmap_pgoff, unsigned long, addr, unsigned long, len,
|
||||
unsigned long, prot, unsigned long, flags,
|
||||
unsigned long, fd, unsigned long, pgoff)
|
||||
{
|
||||
struct file *file = NULL;
|
||||
......
|
||||
file = fget(fd);
|
||||
......
|
||||
retval = vm_mmap_pgoff(file, addr, len, prot, flags, pgoff);
|
||||
return retval;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果要映射到文件,fd会传进来一个文件描述符,并且mmap_pgoff里面通过fget函数,根据文件描述符获得struct file。struct file表示打开的一个文件。
|
||||
|
||||
接下来的调用链是vm_mmap_pgoff->do_mmap_pgoff->do_mmap。这里面主要干了两件事情:
|
||||
|
||||
<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->mm->get_unmapped_area;
|
||||
if (file) {
|
||||
if (file->f_op->get_unmapped_area)
|
||||
get_area = file->f_op->get_unmapped_area;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面如果是匿名映射,则调用mm_struct里面的get_unmapped_area函数。这个函数其实是arch_get_unmapped_area。它会调用find_vma_prev,在表示虚拟内存区域的vm_area_struct红黑树上找到相应的位置。之所以叫prev,是说这个时候虚拟内存区域还没有建立,找到前一个vm_area_struct。
|
||||
|
||||
如果不是匿名映射,而是映射到一个文件,这样在Linux里面,每个打开的文件都有一个struct file结构,里面有一个file_operations,用来表示和这个文件相关的操作。如果是我们熟知的ext4文件系统,调用的是thp_get_unmapped_area。如果我们仔细看这个函数,最终还是调用mm_struct里面的get_unmapped_area函数。殊途同归。
|
||||
|
||||
```
|
||||
const struct file_operations ext4_file_operations = {
|
||||
......
|
||||
.mmap = ext4_file_mmap
|
||||
.get_unmapped_area = thp_get_unmapped_area,
|
||||
};
|
||||
|
||||
|
||||
unsigned long __thp_get_unmapped_area(struct file *filp, unsigned long len,
|
||||
loff_t off, unsigned long flags, unsigned long size)
|
||||
{
|
||||
unsigned long addr;
|
||||
loff_t off_end = off + len;
|
||||
loff_t off_align = round_up(off, size);
|
||||
unsigned long len_pad;
|
||||
len_pad = len + size;
|
||||
......
|
||||
addr = current->mm->get_unmapped_area(filp, 0, len_pad,
|
||||
off >> PAGE_SHIFT, flags);
|
||||
addr += (off - addr) & (size - 1);
|
||||
return addr;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们再来看mmap_region,看它如何映射这个虚拟内存区域。
|
||||
|
||||
```
|
||||
unsigned long mmap_region(struct file *file, unsigned long addr,
|
||||
unsigned long len, vm_flags_t vm_flags, unsigned long pgoff,
|
||||
struct list_head *uf)
|
||||
{
|
||||
struct mm_struct *mm = current->mm;
|
||||
struct vm_area_struct *vma, *prev;
|
||||
struct rb_node **rb_link, *rb_parent;
|
||||
|
||||
|
||||
/*
|
||||
* Can we just expand an old mapping?
|
||||
*/
|
||||
vma = vma_merge(mm, prev, addr, addr + len, vm_flags,
|
||||
NULL, file, pgoff, NULL, NULL_VM_UFFD_CTX);
|
||||
if (vma)
|
||||
goto out;
|
||||
|
||||
|
||||
/*
|
||||
* Determine the object being mapped and call the appropriate
|
||||
* specific mapper. the address has already been validated, but
|
||||
* not unmapped, but the maps are removed from the list.
|
||||
*/
|
||||
vma = kmem_cache_zalloc(vm_area_cachep, GFP_KERNEL);
|
||||
if (!vma) {
|
||||
error = -ENOMEM;
|
||||
goto unacct_error;
|
||||
}
|
||||
|
||||
|
||||
vma->vm_mm = mm;
|
||||
vma->vm_start = addr;
|
||||
vma->vm_end = addr + len;
|
||||
vma->vm_flags = vm_flags;
|
||||
vma->vm_page_prot = vm_get_page_prot(vm_flags);
|
||||
vma->vm_pgoff = pgoff;
|
||||
INIT_LIST_HEAD(&vma->anon_vma_chain);
|
||||
|
||||
|
||||
if (file) {
|
||||
vma->vm_file = get_file(file);
|
||||
error = call_mmap(file, vma);
|
||||
addr = vma->vm_start;
|
||||
vm_flags = vma->vm_flags;
|
||||
}
|
||||
......
|
||||
vma_link(mm, vma, prev, rb_link, rb_parent);
|
||||
return addr;
|
||||
.....
|
||||
|
||||
```
|
||||
|
||||
还记得咱们刚找到了虚拟内存区域的前一个vm_area_struct,我们首先要看,是否能够基于它进行扩展,也即调用vma_merge,和前一个vm_area_struct合并到一起。
|
||||
|
||||
如果不能,就需要调用kmem_cache_zalloc,在Slub里面创建一个新的vm_area_struct对象,设置起始和结束位置,将它加入队列。如果是映射到文件,则设置vm_file为目标文件,调用call_mmap。其实就是调用file_operations的mmap函数。对于ext4文件系统,调用的是ext4_file_mmap。从这个函数的参数可以看出,这一刻文件和内存开始发生关系了。这里我们将vm_area_struct的内存操作设置为文件系统操作,也就是说,读写内存其实就是读写文件系统。
|
||||
|
||||
```
|
||||
static inline int call_mmap(struct file *file, struct vm_area_struct *vma)
|
||||
{
|
||||
return file->f_op->mmap(file, vma);
|
||||
}
|
||||
|
||||
|
||||
static int ext4_file_mmap(struct file *file, struct vm_area_struct *vma)
|
||||
{
|
||||
......
|
||||
vma->vm_ops = &ext4_file_vm_ops;
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们再回到mmap_region函数。最终,vma_link函数将新创建的vm_area_struct挂在了mm_struct里面的红黑树上。
|
||||
|
||||
这个时候,从内存到文件的映射关系,至少要在逻辑层面建立起来。那从文件到内存的映射关系呢?vma_link还做了另外一件事情,就是__vma_link_file。这个东西要用于建立这层映射关系。
|
||||
|
||||
对于打开的文件,会有一个结构struct file来表示。它有个成员指向struct address_space结构,这里面有棵变量名为i_mmap的红黑树,vm_area_struct就挂在这棵树上。
|
||||
|
||||
```
|
||||
struct address_space {
|
||||
struct inode *host; /* owner: inode, block_device */
|
||||
......
|
||||
struct rb_root i_mmap; /* tree of private and shared mappings */
|
||||
......
|
||||
const struct address_space_operations *a_ops; /* methods */
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
static void __vma_link_file(struct vm_area_struct *vma)
|
||||
{
|
||||
struct file *file;
|
||||
|
||||
|
||||
file = vma->vm_file;
|
||||
if (file) {
|
||||
struct address_space *mapping = file->f_mapping;
|
||||
vma_interval_tree_insert(vma, &mapping->i_mmap);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
到这里,内存映射的内容要告一段落了。你可能会困惑,好像还没和物理内存发生任何关系,还是在虚拟内存里面折腾呀?
|
||||
|
||||
对的,因为到目前为止,我们还没有开始真正访问内存呀!这个时候,内存管理并不直接分配物理内存,因为物理内存相对于虚拟地址空间太宝贵了,只有等你真正用的那一刻才会开始分配。
|
||||
|
||||
## 用户态缺页异常
|
||||
|
||||
一旦开始访问虚拟内存的某个地址,如果我们发现,并没有对应的物理页,那就触发缺页中断,调用do_page_fault。
|
||||
|
||||
```
|
||||
dotraplinkage void notrace
|
||||
do_page_fault(struct pt_regs *regs, unsigned long error_code)
|
||||
{
|
||||
unsigned long address = read_cr2(); /* Get the faulting address */
|
||||
......
|
||||
__do_page_fault(regs, error_code, address);
|
||||
......
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* This routine handles page faults. It determines the address,
|
||||
* and the problem, and then passes it off to one of the appropriate
|
||||
* routines.
|
||||
*/
|
||||
static noinline void
|
||||
__do_page_fault(struct pt_regs *regs, unsigned long error_code,
|
||||
unsigned long address)
|
||||
{
|
||||
struct vm_area_struct *vma;
|
||||
struct task_struct *tsk;
|
||||
struct mm_struct *mm;
|
||||
tsk = current;
|
||||
mm = tsk->mm;
|
||||
|
||||
|
||||
if (unlikely(fault_in_kernel_space(address))) {
|
||||
if (vmalloc_fault(address) >= 0)
|
||||
return;
|
||||
}
|
||||
......
|
||||
vma = find_vma(mm, address);
|
||||
......
|
||||
fault = handle_mm_fault(vma, address, flags);
|
||||
......
|
||||
|
||||
```
|
||||
|
||||
在__do_page_fault里面,先要判断缺页中断是否发生在内核。如果发生在内核则调用vmalloc_fault,这就和咱们前面学过的虚拟内存的布局对应上了。在内核里面,vmalloc区域需要内核页表映射到物理页。咱们这里把内核的这部分放放,接着看用户空间的部分。
|
||||
|
||||
接下来在用户空间里面,找到你访问的那个地址所在的区域vm_area_struct,然后调用handle_mm_fault来映射这个区域。
|
||||
|
||||
```
|
||||
static int __handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
|
||||
unsigned int flags)
|
||||
{
|
||||
struct vm_fault vmf = {
|
||||
.vma = vma,
|
||||
.address = address & PAGE_MASK,
|
||||
.flags = flags,
|
||||
.pgoff = linear_page_index(vma, address),
|
||||
.gfp_mask = __get_fault_gfp_mask(vma),
|
||||
};
|
||||
struct mm_struct *mm = vma->vm_mm;
|
||||
pgd_t *pgd;
|
||||
p4d_t *p4d;
|
||||
int ret;
|
||||
|
||||
|
||||
pgd = pgd_offset(mm, address);
|
||||
p4d = p4d_alloc(mm, pgd, address);
|
||||
......
|
||||
vmf.pud = pud_alloc(mm, p4d, address);
|
||||
......
|
||||
vmf.pmd = pmd_alloc(mm, vmf.pud, address);
|
||||
......
|
||||
return handle_pte_fault(&vmf);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
到这里,终于看到了我们熟悉的PGD、P4G、PUD、PMD、PTE,这就是前面讲页表的时候,讲述的四级页表的概念,因为暂且不考虑五级页表,我们暂时忽略P4G。
|
||||
|
||||
<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->mm;
|
||||
mm = allocate_mm();
|
||||
memcpy(mm, oldmm, sizeof(*mm));
|
||||
if (!mm_init(mm, tsk, mm->user_ns))
|
||||
goto fail_nomem;
|
||||
err = dup_mmap(mm, oldmm);
|
||||
return mm;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这里,除了创建一个新的mm_struct,并且通过memcpy将它和父进程的弄成一模一样之外,我们还需要调用mm_init进行初始化。接下来,mm_init调用mm_alloc_pgd,分配全局页目录项,赋值给mm_struct的pgd成员变量。
|
||||
|
||||
```
|
||||
static inline int mm_alloc_pgd(struct mm_struct *mm)
|
||||
{
|
||||
mm->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 && SHARED_KERNEL_PMD) ||
|
||||
CONFIG_PGTABLE_LEVELS >= 4) {
|
||||
clone_pgd_range(pgd + KERNEL_PGD_BOUNDARY,
|
||||
swapper_pg_dir + KERNEL_PGD_BOUNDARY,
|
||||
KERNEL_PGD_PTRS);
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
pgd_ctor干了什么事情呢?我们注意看里面的注释,它拷贝了对于swapper_pg_dir的引用。swapper_pg_dir是内核页表的最顶级的全局页目录。
|
||||
|
||||
一个进程的虚拟地址空间包含用户态和内核态两部分。为了从虚拟地址空间映射到物理页面,页表也分为用户地址空间的页表和内核页表,这就和上面遇到的vmalloc有关系了。在内核里面,映射靠内核页表,这里内核页表会拷贝一份到进程的页表。至于swapper_pg_dir是什么,怎么初始化的,怎么工作的,我们还是先放一放,放到下一节统一讨论。
|
||||
|
||||
至此,一个进程fork完毕之后,有了内核页表,有了自己顶级的pgd,但是对于用户地址空间来讲,还完全没有映射过。这需要等到这个进程在某个CPU上运行,并且对内存访问的那一刻了。
|
||||
|
||||
当这个进程被调度到某个CPU上运行的时候,咱们在[调度](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里面的成员变量pgd(mm_struct里面存的都是虚拟地址)变为物理地址,才能加载到cr3里面去。
|
||||
|
||||
第二点,用户进程在运行的过程中,访问虚拟内存中的数据,会被cr3里面指向的页表转换为物理地址后,才在物理内存中访问数据,这个过程都是在用户态运行的,地址转换的过程无需进入内核态。
|
||||
|
||||
只有访问虚拟内存的时候,发现没有映射到物理内存,页表也没有创建过,才触发缺页异常。进入内核调用do_page_fault,一直调用到__handle_mm_fault,这才有了上面解析到这个函数的时候,我们看到的代码。既然原来没有创建过页表,那只好补上这一课。于是,__handle_mm_fault调用pud_alloc和pmd_alloc,来创建相应的页目录项,最后调用handle_pte_fault来创建页表项。
|
||||
|
||||
绕了一大圈,终于将页表整个机制的各个部分串了起来。但是咱们的故事还没讲完,物理的内存还没找到。我们还得接着分析handle_pte_fault的实现。
|
||||
|
||||
```
|
||||
static int handle_pte_fault(struct vm_fault *vmf)
|
||||
{
|
||||
pte_t entry;
|
||||
......
|
||||
vmf->pte = pte_offset_map(vmf->pmd, vmf->address);
|
||||
vmf->orig_pte = *vmf->pte;
|
||||
......
|
||||
if (!vmf->pte) {
|
||||
if (vma_is_anonymous(vmf->vma))
|
||||
return do_anonymous_page(vmf);
|
||||
else
|
||||
return do_fault(vmf);
|
||||
}
|
||||
|
||||
|
||||
if (!pte_present(vmf->orig_pte))
|
||||
return do_swap_page(vmf);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面总的来说分了三种情况。如果PTE,也就是页表项,从来没有出现过,那就是新映射的页。如果是匿名页,就是第一种情况,应该映射到一个物理内存页,在这里调用的是do_anonymous_page。如果是映射到文件,调用的就是do_fault,这是第二种情况。如果PTE原来出现过,说明原来页面在物理内存中,后来换出到硬盘了,现在应该换回来,调用的是do_swap_page。
|
||||
|
||||
我们来看第一种情况,do_anonymous_page。对于匿名页的映射,我们需要先通过pte_alloc分配一个页表项,然后通过alloc_zeroed_user_highpage_movable分配一个页。之后它会调用alloc_pages_vma,并最终调用__alloc_pages_nodemask。
|
||||
|
||||
这个函数你还记得吗?就是咱们伙伴系统的核心函数,专门用来分配物理页面的。do_anonymous_page接下来要调用mk_pte,将页表项指向新分配的物理页,set_pte_at会将页表项塞到页表里面。
|
||||
|
||||
```
|
||||
static int do_anonymous_page(struct vm_fault *vmf)
|
||||
{
|
||||
struct vm_area_struct *vma = vmf->vma;
|
||||
struct mem_cgroup *memcg;
|
||||
struct page *page;
|
||||
int ret = 0;
|
||||
pte_t entry;
|
||||
......
|
||||
if (pte_alloc(vma->vm_mm, vmf->pmd, vmf->address))
|
||||
return VM_FAULT_OOM;
|
||||
......
|
||||
page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
|
||||
......
|
||||
entry = mk_pte(page, vma->vm_page_prot);
|
||||
if (vma->vm_flags & VM_WRITE)
|
||||
entry = pte_mkwrite(pte_mkdirty(entry));
|
||||
|
||||
|
||||
vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, vmf->address,
|
||||
&vmf->ptl);
|
||||
......
|
||||
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二种情况映射到文件do_fault,最终我们会调用__do_fault。
|
||||
|
||||
```
|
||||
static int __do_fault(struct vm_fault *vmf)
|
||||
{
|
||||
struct vm_area_struct *vma = vmf->vma;
|
||||
int ret;
|
||||
......
|
||||
ret = vma->vm_ops->fault(vmf);
|
||||
......
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
这里调用了struct vm_operations_struct vm_ops的fault函数。还记得咱们上面用mmap映射文件的时候,对于ext4文件系统,vm_ops指向了ext4_file_vm_ops,也就是调用了ext4_filemap_fault。
|
||||
|
||||
```
|
||||
static const struct vm_operations_struct ext4_file_vm_ops = {
|
||||
.fault = ext4_filemap_fault,
|
||||
.map_pages = filemap_map_pages,
|
||||
.page_mkwrite = ext4_page_mkwrite,
|
||||
};
|
||||
|
||||
|
||||
int ext4_filemap_fault(struct vm_fault *vmf)
|
||||
{
|
||||
struct inode *inode = file_inode(vmf->vma->vm_file);
|
||||
......
|
||||
err = filemap_fault(vmf);
|
||||
......
|
||||
return err;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
ext4_filemap_fault里面的逻辑我们很容易就能读懂。vm_file就是咱们当时mmap的时候映射的那个文件,然后我们需要调用filemap_fault。对于文件映射来说,一般这个文件会在物理内存里面有页面作为它的缓存,find_get_page就是找那个页。如果找到了,就调用do_async_mmap_readahead,预读一些数据到内存里面;如果没有,就跳到no_cached_page。
|
||||
|
||||
```
|
||||
int filemap_fault(struct vm_fault *vmf)
|
||||
{
|
||||
int error;
|
||||
struct file *file = vmf->vma->vm_file;
|
||||
struct address_space *mapping = file->f_mapping;
|
||||
struct inode *inode = mapping->host;
|
||||
pgoff_t offset = vmf->pgoff;
|
||||
struct page *page;
|
||||
int ret = 0;
|
||||
......
|
||||
page = find_get_page(mapping, offset);
|
||||
if (likely(page) && !(vmf->flags & FAULT_FLAG_TRIED)) {
|
||||
do_async_mmap_readahead(vmf->vma, ra, file, page, offset);
|
||||
} else if (!page) {
|
||||
goto no_cached_page;
|
||||
}
|
||||
......
|
||||
vmf->page = page;
|
||||
return ret | VM_FAULT_LOCKED;
|
||||
no_cached_page:
|
||||
error = page_cache_read(file, offset, vmf->gfp_mask);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果没有物理内存中的缓存页,那我们就调用page_cache_read。在这里显示分配一个缓存页,将这一页加到lru表里面,然后在address_space中调用address_space_operations的readpage函数,将文件内容读到内存中。address_space的作用咱们上面也介绍过了。
|
||||
|
||||
```
|
||||
static int page_cache_read(struct file *file, pgoff_t offset, gfp_t gfp_mask)
|
||||
{
|
||||
struct address_space *mapping = file->f_mapping;
|
||||
struct page *page;
|
||||
......
|
||||
page = __page_cache_alloc(gfp_mask|__GFP_COLD);
|
||||
......
|
||||
ret = add_to_page_cache_lru(page, mapping, offset, gfp_mask & GFP_KERNEL);
|
||||
......
|
||||
ret = mapping->a_ops->readpage(file, page);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
struct address_space_operations对于ext4文件系统的定义如下所示。这么说来,上面的readpage调用的其实是ext4_readpage。因为我们还没讲到文件系统,这里我们不详细介绍ext4_readpage具体干了什么。你只要知道,最后会调用ext4_read_inline_page,这里面有部分逻辑和内存映射有关就行了。
|
||||
|
||||
```
|
||||
static const struct address_space_operations ext4_aops = {
|
||||
.readpage = ext4_readpage,
|
||||
.readpages = ext4_readpages,
|
||||
......
|
||||
};
|
||||
|
||||
|
||||
static int ext4_read_inline_page(struct inode *inode, struct page *page)
|
||||
{
|
||||
void *kaddr;
|
||||
......
|
||||
kaddr = kmap_atomic(page);
|
||||
ret = ext4_read_inline_data(inode, kaddr, len, &iloc);
|
||||
flush_dcache_page(page);
|
||||
kunmap_atomic(kaddr);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在ext4_read_inline_page函数里,我们需要先调用kmap_atomic,将物理内存映射到内核的虚拟地址空间,得到内核中的地址kaddr。 我们在前面提到过kmap_atomic,它是用来做临时内核映射的。本来把物理内存映射到用户虚拟地址空间,不需要在内核里面映射一把。但是,现在因为要从文件里面读取数据并写入这个物理页面,又不能使用物理地址,我们只能使用虚拟地址,这就需要在内核里面临时映射一把。临时映射后,ext4_read_inline_data读取文件到这个虚拟地址。读取完毕后,我们取消这个临时映射kunmap_atomic就行了。
|
||||
|
||||
至于kmap_atomic的具体实现,我们还是放到内核映射部分再讲。
|
||||
|
||||
我们再来看第三种情况,do_swap_page。之前我们讲过物理内存管理,你这里可以回忆一下。如果长时间不用,就要换出到硬盘,也就是swap,现在这部分数据又要访问了,我们还得想办法再次读到内存中来。
|
||||
|
||||
```
|
||||
int do_swap_page(struct vm_fault *vmf)
|
||||
{
|
||||
struct vm_area_struct *vma = vmf->vma;
|
||||
struct page *page, *swapcache;
|
||||
struct mem_cgroup *memcg;
|
||||
swp_entry_t entry;
|
||||
pte_t pte;
|
||||
......
|
||||
entry = pte_to_swp_entry(vmf->orig_pte);
|
||||
......
|
||||
page = lookup_swap_cache(entry);
|
||||
if (!page) {
|
||||
page = swapin_readahead(entry, GFP_HIGHUSER_MOVABLE, vma,
|
||||
vmf->address);
|
||||
......
|
||||
}
|
||||
......
|
||||
swapcache = page;
|
||||
......
|
||||
pte = mk_pte(page, vma->vm_page_prot);
|
||||
......
|
||||
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, pte);
|
||||
vmf->orig_pte = pte;
|
||||
......
|
||||
swap_free(entry);
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
do_swap_page函数会先查找swap文件有没有缓存页。如果没有,就调用swapin_readahead,将swap文件读到内存中来,形成内存页,并通过mk_pte生成页表项。set_pte_at将页表项插入页表,swap_free将swap文件清理。因为重新加载回内存了,不再需要swap文件了。
|
||||
|
||||
swapin_readahead会最终调用swap_readpage,在这里,我们看到了熟悉的readpage函数,也就是说读取普通文件和读取swap文件,过程是一样的,同样需要用kmap_atomic做临时映射。
|
||||
|
||||
```
|
||||
int swap_readpage(struct page *page, bool do_poll)
|
||||
{
|
||||
struct bio *bio;
|
||||
int ret = 0;
|
||||
struct swap_info_struct *sis = page_swap_info(page);
|
||||
blk_qc_t qc;
|
||||
struct block_device *bdev;
|
||||
......
|
||||
if (sis->flags & SWP_FILE) {
|
||||
struct file *swap_file = sis->swap_file;
|
||||
struct address_space *mapping = swap_file->f_mapping;
|
||||
ret = mapping->a_ops->readpage(swap_file, page);
|
||||
return ret;
|
||||
}
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
通过上面复杂的过程,用户态缺页异常处理完毕了。物理内存中有了页面,页表也建立好了映射。接下来,用户程序在虚拟内存空间里面,可以通过虚拟地址顺利经过页表映射的访问物理页面上的数据了。
|
||||
|
||||
为了加快映射速度,我们不需要每次从虚拟地址到物理地址的转换都走一遍页表。
|
||||
|
||||
<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="">
|
||||
364
极客时间专栏/趣谈Linux操作系统/核心原理篇:第四部分 内存管理/26 | 内核态内存映射:如何找到正确的会议室?.md
Normal file
364
极客时间专栏/趣谈Linux操作系统/核心原理篇:第四部分 内存管理/26 | 内核态内存映射:如何找到正确的会议室?.md
Normal 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 = &init_user_ns,
|
||||
INIT_MM_CONTEXT(init_mm)
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
定义完了内核页表,接下来是初始化内核页表,在系统启动的时候start_kernel会调用setup_arch。
|
||||
|
||||
```
|
||||
void __init setup_arch(char **cmdline_p)
|
||||
{
|
||||
/*
|
||||
* copy kernel address range established so far and switch
|
||||
* to the proper swapper page table
|
||||
*/
|
||||
clone_pgd_range(swapper_pg_dir + KERNEL_PGD_BOUNDARY,
|
||||
initial_page_table + KERNEL_PGD_BOUNDARY,
|
||||
KERNEL_PGD_PTRS);
|
||||
|
||||
|
||||
load_cr3(swapper_pg_dir);
|
||||
__flush_tlb_all();
|
||||
......
|
||||
init_mm.start_code = (unsigned long) _text;
|
||||
init_mm.end_code = (unsigned long) _etext;
|
||||
init_mm.end_data = (unsigned long) _edata;
|
||||
init_mm.brk = _brk_end;
|
||||
......
|
||||
init_mem_mapping();
|
||||
......
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在setup_arch中,load_cr3(swapper_pg_dir)说明内核页表要开始起作用了,并且刷新了TLB,初始化init_mm的成员变量,最重要的就是init_mem_mapping。最终它会调用kernel_physical_mapping_init。
|
||||
|
||||
```
|
||||
/*
|
||||
* Create page table mapping for the physical memory for specific physical
|
||||
* addresses. The virtual and physical addresses have to be aligned on PMD level
|
||||
* down. It returns the last physical address mapped.
|
||||
*/
|
||||
unsigned long __meminit
|
||||
kernel_physical_mapping_init(unsigned long paddr_start,
|
||||
unsigned long paddr_end,
|
||||
unsigned long page_size_mask)
|
||||
{
|
||||
unsigned long vaddr, vaddr_start, vaddr_end, vaddr_next, paddr_last;
|
||||
|
||||
|
||||
paddr_last = paddr_end;
|
||||
vaddr = (unsigned long)__va(paddr_start);
|
||||
vaddr_end = (unsigned long)__va(paddr_end);
|
||||
vaddr_start = vaddr;
|
||||
|
||||
|
||||
for (; vaddr < vaddr_end; vaddr = vaddr_next) {
|
||||
pgd_t *pgd = pgd_offset_k(vaddr);
|
||||
p4d_t *p4d;
|
||||
|
||||
|
||||
vaddr_next = (vaddr & PGDIR_MASK) + PGDIR_SIZE;
|
||||
|
||||
|
||||
if (pgd_val(*pgd)) {
|
||||
p4d = (p4d_t *)pgd_page_vaddr(*pgd);
|
||||
paddr_last = phys_p4d_init(p4d, __pa(vaddr),
|
||||
__pa(vaddr_end),
|
||||
page_size_mask);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
p4d = alloc_low_page();
|
||||
paddr_last = phys_p4d_init(p4d, __pa(vaddr), __pa(vaddr_end),
|
||||
page_size_mask);
|
||||
|
||||
|
||||
p4d_populate(&init_mm, p4d_offset(pgd, vaddr), (pud_t *) p4d);
|
||||
}
|
||||
__flush_tlb_all();
|
||||
|
||||
|
||||
return paddr_l
|
||||
|
||||
```
|
||||
|
||||
在kernel_physical_mapping_init里,我们先通过__va将物理地址转换为虚拟地址,然后再创建虚拟地址和物理地址的映射页表。
|
||||
|
||||
你可能会问,怎么这么麻烦啊?既然对于内核来讲,我们可以用__va和__pa直接在虚拟地址和物理地址之间直接转来转去,为啥还要辛辛苦苦建立页表呢?因为这是CPU和内存的硬件的需求,也就是说,CPU在保护模式下访问虚拟地址的时候,就会用CR3这个寄存器,这个寄存器是CPU定义的,作为操作系统,我们是软件,只能按照硬件的要求来。
|
||||
|
||||
你可能又会问了,按照咱们讲初始化的时候的过程,系统早早就进入了保护模式,到了setup_arch里面才load_cr3,如果使用cr3是硬件的要求,那之前是怎么办的呢?如果你仔细去看arch\x86\kernel\head_64.S,这里面除了初始化内核页表之外,在这之前,还有另一个页表early_top_pgt。看到关键字early了嘛?这个页表就是专门用在真正的内核页表初始化之前,为了遵循硬件的要求而设置的。早期页表不是我们这节的重点,这里我就不展开多说了。
|
||||
|
||||
## vmalloc和kmap_atomic原理
|
||||
|
||||
在用户态可以通过malloc函数分配内存,当然malloc在分配比较大的内存的时候,底层调用的是mmap,当然也可以直接通过mmap做内存映射,在内核里面也有相应的函数。
|
||||
|
||||
在虚拟地址空间里面,有个vmalloc区域,从VMALLOC_START开始到VMALLOC_END,可以用于映射一段物理内存。
|
||||
|
||||
```
|
||||
/**
|
||||
* vmalloc - allocate virtually contiguous memory
|
||||
* @size: allocation size
|
||||
* Allocate enough pages to cover @size from the page level
|
||||
* allocator and map them into contiguous kernel virtual space.
|
||||
*
|
||||
* For tight control over page level allocator and protection flags
|
||||
* use __vmalloc() instead.
|
||||
*/
|
||||
void *vmalloc(unsigned long size)
|
||||
{
|
||||
return __vmalloc_node_flags(size, NUMA_NO_NODE,
|
||||
GFP_KERNEL);
|
||||
}
|
||||
|
||||
|
||||
static void *__vmalloc_node(unsigned long size, unsigned long align,
|
||||
gfp_t gfp_mask, pgprot_t prot,
|
||||
int node, const void *caller)
|
||||
{
|
||||
return __vmalloc_node_range(size, align, VMALLOC_START, VMALLOC_END,
|
||||
gfp_mask, prot, 0, node, caller);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们再来看内核的临时映射函数kmap_atomic的实现。从下面的代码我们可以看出,如果是32位有高端地址的,就需要调用set_pte通过内核页表进行临时映射;如果是64位没有高端地址的,就调用page_address,里面会调用lowmem_page_address。其实低端内存的映射,会直接使用__va进行临时映射。
|
||||
|
||||
```
|
||||
void *kmap_atomic_prot(struct page *page, pgprot_t prot)
|
||||
{
|
||||
......
|
||||
if (!PageHighMem(page))
|
||||
return page_address(page);
|
||||
......
|
||||
vaddr = __fix_to_virt(FIX_KMAP_BEGIN + idx);
|
||||
set_pte(kmap_pte-idx, mk_pte(page, prot));
|
||||
......
|
||||
return (void *)vaddr;
|
||||
}
|
||||
|
||||
|
||||
void *kmap_atomic(struct page *page)
|
||||
{
|
||||
return kmap_atomic_prot(page, kmap_prot);
|
||||
}
|
||||
|
||||
|
||||
static __always_inline void *lowmem_page_address(const struct page *page)
|
||||
{
|
||||
return page_to_virt(page);
|
||||
}
|
||||
|
||||
|
||||
#define page_to_virt(x) __va(PFN_PHYS(page_to_pfn(x)
|
||||
|
||||
```
|
||||
|
||||
## 内核态缺页异常
|
||||
|
||||
可以看出,kmap_atomic和vmalloc不同。kmap_atomic发现,没有页表的时候,就直接创建页表进行映射了。而vmalloc没有,它只分配了内核的虚拟地址。所以,访问它的时候,会产生缺页异常。
|
||||
|
||||
内核态的缺页异常还是会调用do_page_fault,但是会走到咱们上面用户态缺页异常中没有解析的那部分vmalloc_fault。这个函数并不复杂,主要用于关联内核页表项。
|
||||
|
||||
```
|
||||
/*
|
||||
* 32-bit:
|
||||
*
|
||||
* Handle a fault on the vmalloc or module mapping area
|
||||
*/
|
||||
static noinline int vmalloc_fault(unsigned long address)
|
||||
{
|
||||
unsigned long pgd_paddr;
|
||||
pmd_t *pmd_k;
|
||||
pte_t *pte_k;
|
||||
|
||||
|
||||
/* Make sure we are in vmalloc area: */
|
||||
if (!(address >= VMALLOC_START && address < VMALLOC_END))
|
||||
return -1;
|
||||
|
||||
|
||||
/*
|
||||
* Synchronize this task's top level page-table
|
||||
* with the 'reference' page table.
|
||||
*
|
||||
* Do _not_ use "current" here. We might be inside
|
||||
* an interrupt in the middle of a task switch..
|
||||
*/
|
||||
pgd_paddr = read_cr3_pa();
|
||||
pmd_k = vmalloc_sync_one(__va(pgd_paddr), address);
|
||||
if (!pmd_k)
|
||||
return -1;
|
||||
|
||||
|
||||
pte_k = pte_offset_kernel(pmd_k, address);
|
||||
if (!pte_present(*pte_k))
|
||||
return -1;
|
||||
|
||||
|
||||
return 0
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
至此,内核态的内存映射也讲完了。这下,我们可以将整个内存管理的体系串起来了。
|
||||
|
||||
物理内存根据NUMA架构分节点。每个节点里面再分区域。每个区域里面再分页。
|
||||
|
||||
物理页面通过伙伴系统进行分配。分配的物理页面要变成虚拟地址让上层可以访问,kswapd可以根据物理页面的使用情况对页面进行换入换出。
|
||||
|
||||
对于内存的分配需求,可能来自内核态,也可能来自用户态。
|
||||
|
||||
对于内核态,kmalloc在分配大内存的时候,以及vmalloc分配不连续物理页的时候,直接使用伙伴系统,分配后转换为虚拟地址,访问的时候需要通过内核页表进行映射。
|
||||
|
||||
对于kmem_cache以及kmalloc分配小内存,则使用slub分配器,将伙伴系统分配出来的大块内存切成一小块一小块进行分配。
|
||||
|
||||
kmem_cache和kmalloc的部分不会被换出,因为用这两个函数分配的内存多用于保持内核关键的数据结构。内核态中vmalloc分配的部分会被换出,因而当访问的时候,发现不在,就会调用do_page_fault。
|
||||
|
||||
对于用户态的内存分配,或者直接调用mmap系统调用分配,或者调用malloc。调用malloc的时候,如果分配小的内存,就用sys_brk系统调用;如果分配大的内存,还是用sys_mmap系统调用。正常情况下,用户态的内存都是可以换出的,因而一旦发现内存中不存在,就会调用do_page_fault。
|
||||
|
||||
<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="">
|
||||
Reference in New Issue
Block a user